diff --git a/changedetectionio/blueprint/backups/__init__.py b/changedetectionio/blueprint/backups/__init__.py index a3ac3b90..0d162694 100644 --- a/changedetectionio/blueprint/backups/__init__.py +++ b/changedetectionio/blueprint/backups/__init__.py @@ -176,7 +176,7 @@ def construct_blueprint(datastore: ChangeDetectionStore): ) return output - @backups_blueprint.route("/remove-backups", methods=['GET']) + @backups_blueprint.route("/remove-backups", methods=['POST']) @login_optionally_required def remove_backups(): diff --git a/changedetectionio/blueprint/backups/templates/backup_create.html b/changedetectionio/blueprint/backups/templates/backup_create.html index 21678fd2..8bbd6f64 100644 --- a/changedetectionio/blueprint/backups/templates/backup_create.html +++ b/changedetectionio/blueprint/backups/templates/backup_create.html @@ -38,8 +38,13 @@ {{ _('Create backup') }} {% if available_backups %} - {{ _('Remove backups') }} + {# POST + CSRF token: this permanently deletes every backup archive, so it must + not be reachable from a bare GET (an on any page the operator + visits would have triggered it). A real form keeps it working without JS. #} +
+ + +
{% endif %} diff --git a/changedetectionio/blueprint/tags/__init__.py b/changedetectionio/blueprint/tags/__init__.py index d20603e9..c32023cb 100644 --- a/changedetectionio/blueprint/tags/__init__.py +++ b/changedetectionio/blueprint/tags/__init__.py @@ -67,7 +67,7 @@ def construct_blueprint(datastore: ChangeDetectionStore): tag.commit() return redirect(url_for('tags.tags_overview_page')) - @tags_blueprint.route("/delete/", methods=['GET']) + @tags_blueprint.route("/delete/", methods=['POST']) @login_optionally_required def delete(uuid): # Delete the tag from settings immediately @@ -94,7 +94,7 @@ def construct_blueprint(datastore: ChangeDetectionStore): flash(gettext("Tag deleted, removing from watches in background")) return redirect(url_for('tags.tags_overview_page')) - @tags_blueprint.route("/unlink/", methods=['GET']) + @tags_blueprint.route("/unlink/", methods=['POST']) @login_optionally_required def unlink(uuid): # Unlink tag from all watches in background thread to avoid blocking @@ -117,7 +117,7 @@ def construct_blueprint(datastore: ChangeDetectionStore): flash(gettext("Unlinking tag from watches in background")) return redirect(url_for('tags.tags_overview_page')) - @tags_blueprint.route("/delete_all", methods=['GET']) + @tags_blueprint.route("/delete_all", methods=['POST']) @login_optionally_required def delete_all(): diff --git a/changedetectionio/blueprint/tags/templates/groups-overview.html b/changedetectionio/blueprint/tags/templates/groups-overview.html index 769fdb4d..f82e2c9c 100644 --- a/changedetectionio/blueprint/tags/templates/groups-overview.html +++ b/changedetectionio/blueprint/tags/templates/groups-overview.html @@ -72,9 +72,13 @@ html[data-darkmode="true"] .watch-tag-list.tag-{{ class_name }} { {{ tag.title }} {{ _('Edit') }} - {{ _('Recheck') }} +
+ + +
{{ _('Delete') }} ", methods=['GET']) + @ui_blueprint.route("/clear_history/", methods=['POST']) @login_optionally_required def clear_watch_history(uuid): try: @@ -227,7 +227,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_pool, return output # Clear all statuses, so we do not see the 'unviewed' class - @ui_blueprint.route("/form/mark-all-viewed", methods=['GET']) + @ui_blueprint.route("/form/mark-all-viewed", methods=['POST']) @login_optionally_required def mark_all_viewed(): # Save the current newest history as the most recently viewed. Operate on @@ -267,7 +267,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_pool, return redirect(url_for('watchlist.index', **wl_filters.filter_query_args(request.args))) - @ui_blueprint.route("/delete", methods=['GET']) + @ui_blueprint.route("/delete", methods=['POST']) @login_optionally_required def form_delete(): uuid = request.args.get('uuid') @@ -284,7 +284,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_pool, return redirect(url_for('watchlist.index')) - @ui_blueprint.route("/clone", methods=['GET']) + @ui_blueprint.route("/clone", methods=['POST']) @login_optionally_required def form_clone(): uuid = request.args.get('uuid') @@ -301,7 +301,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_pool, return redirect(url_for("ui.ui_edit.edit_page", uuid=new_uuid)) - @ui_blueprint.route("/checknow", methods=['GET']) + @ui_blueprint.route("/checknow", methods=['POST']) @login_optionally_required def form_watch_checknow(): # Forced recheck will skip the 'skip if content is the same' rule (, 'reprocess_existing_data': True}))) diff --git a/changedetectionio/blueprint/ui/templates/edit.html b/changedetectionio/blueprint/ui/templates/edit.html index aead58ad..0c2ae1eb 100644 --- a/changedetectionio/blueprint/ui/templates/edit.html +++ b/changedetectionio/blueprint/ui/templates/edit.html @@ -540,6 +540,7 @@ Math: {{ 1 + 1 }}") }} {{ render_button(form.save_button) }} {{ _('Delete') }} {% if watch.history_n %}{{ _('Clear History') }}{% endif %} {{ _('Clone & Edit') }} {{ _('RSS Feed for this watch') }} diff --git a/changedetectionio/blueprint/watchlist/templates/watch-overview.html b/changedetectionio/blueprint/watchlist/templates/watch-overview.html index 0c747694..204fe94c 100644 --- a/changedetectionio/blueprint/watchlist/templates/watch-overview.html +++ b/changedetectionio/blueprint/watchlist/templates/watch-overview.html @@ -272,8 +272,14 @@ window.watchOverviewI18n = { It used to be {% if unread_count %}-gated, which meant it could not exist in the DOM of another open tab — so marking viewed (or a new change arriving) in one tab could never show/hide it in the other. Presence in the DOM is what lets realtime toggle it. #} - {{ _('Mark all viewed') }} - {{ _('Recheck all') }} {% if active_tag_uuid %}{{ _("in '%(title)s'", title=active_tag.title) }}{% endif %} + {# Submit button rather than a link: these mutate state, so they must not be + reachable by GET (an would fire them). formaction/formmethod + retarget the enclosing #watch-list-form, whose csrf_token comes along - and + avoids a nested
, which is invalid HTML and silently dropped. #} + + RSS Feed diff --git a/changedetectionio/static/js/modal.js b/changedetectionio/static/js/modal.js index 272cd9e7..a13ed507 100644 --- a/changedetectionio/static/js/modal.js +++ b/changedetectionio/static/js/modal.js @@ -172,6 +172,44 @@ const ModalDialog = { // Make available globally window.ModalDialog = ModalDialog; +/** + * Submit `url` as a POST carrying the CSRF token, via a throwaway body-level form. + * + * Body-level (rather than wrapping the link) because these links often live inside another + * - the watch list and the edit page both do - and nested forms are invalid HTML that + * browsers silently drop. The token comes from the global `csrftoken` set in base.html. + * + * Used for state-changing actions that must not be reachable by GET, otherwise an + * on any page the operator visits performs the action for them. + */ +function postWithCsrf(url) { + const form = document.createElement('form'); + form.method = 'POST'; + form.action = url; + form.style.display = 'none'; + if (typeof csrftoken !== 'undefined' && csrftoken) { + const tok = document.createElement('input'); + tok.type = 'hidden'; + tok.name = 'csrf_token'; + tok.value = csrftoken; + form.appendChild(tok); + } + document.body.appendChild(form); + form.submit(); +} +window.postWithCsrf = postWithCsrf; + +/** + * Anchors marked data-method="POST" that do NOT also ask for confirmation. + * (The confirm variant is handled in the data-requires-confirm handler below.) + */ +$(document).ready(function() { + $(document).on('click', 'a[data-method="POST"]:not([data-requires-confirm])', function(e) { + e.preventDefault(); + postWithCsrf($(this).attr('href')); + }); +}); + /** * Auto-attach modal confirmations to links with data-requires-confirm attribute * Usage in HTML: @@ -203,19 +241,7 @@ $(document).ready(function() { // on a bare GET, since CSRF relies on GET firing. const method = ($element.attr('data-method') || 'GET').toUpperCase(); if (method === 'POST') { - const form = document.createElement('form'); - form.method = 'POST'; - form.action = url; - form.style.display = 'none'; - if (typeof csrftoken !== 'undefined' && csrftoken) { - const tok = document.createElement('input'); - tok.type = 'hidden'; - tok.name = 'csrf_token'; - tok.value = csrftoken; - form.appendChild(tok); - } - document.body.appendChild(form); - form.submit(); + postWithCsrf(url); return; } diff --git a/changedetectionio/static/styles/scss/parts/_button.scss b/changedetectionio/static/styles/scss/parts/_button.scss index e091f208..6257af2a 100644 --- a/changedetectionio/static/styles/scss/parts/_button.scss +++ b/changedetectionio/static/styles/scss/parts/_button.scss @@ -13,6 +13,10 @@ border: 1px solid var(--color-border-table-cell); background: var(--color-background); color: var(--color-text-input-description); + //