aboutsummaryrefslogtreecommitdiff
path: root/webmentions_ssg
diff options
context:
space:
mode:
authorDennis Fink2026-08-10 23:28:03 +0200
committerDennis Fink2026-08-10 23:28:03 +0200
commitb3e12b975064f0873bdb4d2834cae75925387d6b (patch)
tree92a7ce50f9a3e2b0a49aa32addd9c7e35062d91e /webmentions_ssg
parent5b39f70aa14cac635b6084170e9d0bd3f548b911 (diff)
downloadwebmentions-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.
Diffstat (limited to 'webmentions_ssg')
-rw-r--r--webmentions_ssg/__init__.py3
-rw-r--r--webmentions_ssg/forms/__init__.py4
-rw-r--r--webmentions_ssg/templates/received.html97
-rw-r--r--webmentions_ssg/views.py72
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})