diff options
| author | Dennis Fink | 2026-08-10 23:28:03 +0200 |
|---|---|---|
| committer | Dennis Fink | 2026-08-10 23:28:03 +0200 |
| commit | b3e12b975064f0873bdb4d2834cae75925387d6b (patch) | |
| tree | 92a7ce50f9a3e2b0a49aa32addd9c7e35062d91e | |
| parent | 5b39f70aa14cac635b6084170e9d0bd3f548b911 (diff) | |
| download | webmentions-ssg-b3e12b975064f0873bdb4d2834cae75925387d6b.tar.gz webmentions-ssg-b3e12b975064f0873bdb4d2834cae75925387d6b.zip | |
feat(core): add received webmention admin actions
Add authenticated delete and reverify actions to the received
webmentions admin view and protect them with CSRF validation.
Enable CSRF protection globally while exempting the public Webmention
endpoint.
| -rw-r--r-- | webmentions_ssg/__init__.py | 3 | ||||
| -rw-r--r-- | webmentions_ssg/forms/__init__.py | 4 | ||||
| -rw-r--r-- | webmentions_ssg/templates/received.html | 97 | ||||
| -rw-r--r-- | webmentions_ssg/views.py | 72 |
4 files changed, 165 insertions, 11 deletions
diff --git a/webmentions_ssg/__init__.py b/webmentions_ssg/__init__.py index f66e478..b799678 100644 --- a/webmentions_ssg/__init__.py +++ b/webmentions_ssg/__init__.py @@ -14,6 +14,7 @@ from flask import Flask from flask_bootstrap import Bootstrap5 from flask_migrate import Migrate, upgrade from flask_sqlalchemy import SQLAlchemy +from flask_wtf.csrf import CSRFProtect from sqlalchemy.orm import DeclarativeBase from webmentions_ssg.config import DefaultConfig, DevelopmentConfig, ProductionConfig @@ -28,6 +29,7 @@ class Base(DeclarativeBase): pass +CSRF = CSRFProtect() BOOTSTRAP = Bootstrap5() DATABASE = SQLAlchemy(model_class=Base) MIGRATE = Migrate() @@ -94,6 +96,7 @@ def create_app(config_class: type[DefaultConfig] | None = None) -> Flask: DATABASE.init_app(app) MIGRATE.init_app(app, db=DATABASE) + CSRF.init_app(app) from .auth import AUTH diff --git a/webmentions_ssg/forms/__init__.py b/webmentions_ssg/forms/__init__.py index 0c54999..c386eb1 100644 --- a/webmentions_ssg/forms/__init__.py +++ b/webmentions_ssg/forms/__init__.py @@ -41,3 +41,7 @@ class EndpointForm(FlaskForm): AllowedHostname(), ], ) + + +class AdminActionForm(FlaskForm): + submit = SubmitField("Confirm") diff --git a/webmentions_ssg/templates/received.html b/webmentions_ssg/templates/received.html index ea74557..64f225e 100644 --- a/webmentions_ssg/templates/received.html +++ b/webmentions_ssg/templates/received.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% from "bootstrap5/form.html" import render_form %} {% from "bootstrap5/pagination.html" import render_pagination %} {% block content %} <div class="d-flex justify-content-between align-items-center mb-3"> @@ -16,16 +17,20 @@ <th scope="col">Received</th> <th scope="col">Updated</th> <th scope="col">Reason</th> + <th scope="col">Actions</th> </tr> </thead> <tbody> {% for webmention in webmentions %} - {% if webmention.status == "verified" %} + {% if webmention.status == "received" %} + {% set row_class = "table-info" %} + {% set display_status = "Pending" %} + {% elif webmention.status == "verifying" %} + {% set row_class = "table-warning" %} + {% set display_status = "Verifying" %} + {% elif webmention.status == "verified" %} {% set row_class = "table-success" %} {% set display_status = "Verified" %} - {% elif webmention.status in ("received", "verifying") %} - {% set row_class = "table-warning" %} - {% set display_status = "Pending" %} {% else %} {% set row_class = "table-danger" %} {% set display_status = "Declined" %} @@ -51,19 +56,93 @@ </time> </td> <td>{{ webmention.failure_reason or "—" }}</td> + <td class="text-nowrap"> + <button type="button" + class="btn btn-sm btn-primary" + data-bs-toggle="modal" + data-bs-target="#confirmation-modal" + data-action="{{ url_for('root.reverify_received_webmention', identifier=webmention.uuid, page=webmentions.page) }}" + data-title="Reverify webmention?" + data-message="The webmention will be fetched and verified again." + data-confirm-label="Reverify" + data-confirm-class="btn-primary">Reverify</button> + <button type="button" + class="btn btn-sm btn-danger" + data-bs-toggle="modal" + data-bs-target="#confirmation-modal" + data-action="{{ url_for('root.delete_received_webmention', identifier=webmention.uuid, page=webmentions.page) }}" + data-title="Delete webmention?" + data-message="This permanently deletes the webmention and cannot be undone." + data-confirm-label="Delete" + data-confirm-class="btn-danger">Delete</button> + </td> </tr> {% endfor %} </tbody> </table> </div> {% if webmentions.pages > 1 %} - <div class="mt-3"> - {{ render_pagination(webmentions, - align="center", - size="sm") }} - </div> + <div class="mt-3">{{ render_pagination(webmentions) }}</div> {% endif %} + <div class="modal fade" + id="confirmation-modal" + tabindex="-1" + aria-labelledby="confirmation-modal-title" + aria-hidden="true"> + <div class="modal-dialog modal-dialog-centered"> + <div class="modal-content"> + <div class="modal-header"> + <h1 class="modal-title fs-5" id="confirmation-modal-title"></h1> + <button type="button" + class="btn-close" + data-bs-dismiss="modal" + aria-label="Close"></button> + </div> + <div class="modal-body"> + <p id="confirmation-modal-message" class="mb-0"></p> + </div> + <div class="modal-footer"> + {{ render_form(action_form, + id="confirmation-modal-form", + button_style="primary", + form_group_classes="mb-0") }} + <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> + </div> + </div> + </div> + </div> {% else %} <p class="text-body-secondary">No Webmentions have been received yet.</p> {% endif %} {% endblock %} +{% block scripts %} + {{ super() }} + <script> + const confirmationModal = document.getElementById( + "confirmation-modal" + ); + + confirmationModal.addEventListener("show.bs.modal", (event) => { + const button = event.relatedTarget; + + const form = confirmationModal.querySelector( + "#confirmation-modal-form" + ); + const title = confirmationModal.querySelector( + "#confirmation-modal-title" + ); + const message = confirmationModal.querySelector( + "#confirmation-modal-message" + ); + const submit = form.querySelector("#submit"); + + form.action = button.dataset.action; + + title.textContent = button.dataset.title; + message.textContent = button.dataset.message; + + submit.value = button.dataset.confirmLabel; + submit.className = `btn ${button.dataset.confirmClass}`; + }); + </script> +{% endblock %} diff --git a/webmentions_ssg/views.py b/webmentions_ssg/views.py index 5408fa4..b52ae07 100644 --- a/webmentions_ssg/views.py +++ b/webmentions_ssg/views.py @@ -17,8 +17,8 @@ from flask import ( from flask.typing import ResponseReturnValue from flask_login import current_user, login_required, login_user, logout_user +from . import CSRF, forms from . import DATABASE as db -from . import forms from .models import ReceivedWebmention, User from .tasks.receiver import verify_webmention @@ -74,10 +74,78 @@ def received() -> ResponseReturnValue: "received.html", title="Received Webmentions", webmentions=webmentions, + action_form=forms.AdminActionForm(), ) -@root_page.route("/endpoint", methods=["POST"]) +@root_page.post("/received/<uuid:identifier>/delete") +@login_required +def delete_received_webmention( + identifier: uuid.UUID, +) -> ResponseReturnValue: + form = forms.AdminActionForm() + + if not form.validate_on_submit(): + return abort(400) + + webmention = db.session.get( + ReceivedWebmention, + identifier, + ) + + if webmention is None: + return abort(404) + + db.session.delete(webmention) + db.session.commit() + + flash(f"Webmention {webmention.uuid} deleted.", "success") + + return redirect( + url_for( + "root.received", + page=request.args.get("page", 1, type=int), + ) + ) + + +@root_page.post("/received/<uuid:identifier>/reverify") +@login_required +def reverify_received_webmention( + identifier: uuid.UUID, +) -> ResponseReturnValue: + form = forms.AdminActionForm() + + if not form.validate_on_submit(): + return abort(400) + + webmention = db.session.get( + ReceivedWebmention, + identifier, + ) + + if webmention is None: + return abort(404) + + webmention.status = "received" + webmention.failure_reason = None + + db.session.commit() + + verify_webmention(identifier) + + flash(f"Webmention {webmention.uuid} queued for reverification.", "success") + + return redirect( + url_for( + "root.received", + page=request.args.get("page", 1, type=int), + ) + ) + + +@root_page.post("/endpoint") +@CSRF.exempt def endpoint() -> ResponseReturnValue: form = forms.EndpointForm(meta={"csrf": False}) |
