aboutsummaryrefslogtreecommitdiff
path: root/webmentions_ssg
diff options
context:
space:
mode:
authorDennis Fink2026-08-09 14:15:29 +0200
committerDennis Fink2026-08-09 14:22:00 +0200
commit67eff0a854da010cb6ecd119d84238ec3e119272 (patch)
treec4a85592b957bb01fa62f79329faa6ba0823d9b3 /webmentions_ssg
parentf53c1136184d2afabfb1e5974e527229aa71939a (diff)
downloadwebmentions-ssg-67eff0a854da010cb6ecd119d84238ec3e119272.tar.gz
webmentions-ssg-67eff0a854da010cb6ecd119d84238ec3e119272.zip
Implement received Webmention handling
Add the Flask application setup, database models and migrations, authentication, and configuration for development and testing. Implement asynchronous Webmention verification with Huey, including HTML and plain-text source validation, retries, status tracking, and size limits. Add status, login, and paginated received-Webmention views together with comprehensive tests for forms, views, and receiver tasks.
Diffstat (limited to 'webmentions_ssg')
-rw-r--r--webmentions_ssg/__init__.py141
-rw-r--r--webmentions_ssg/auth.py13
-rw-r--r--webmentions_ssg/config.py50
-rw-r--r--webmentions_ssg/forms/__init__.py42
-rw-r--r--webmentions_ssg/forms/validators.py62
-rw-r--r--webmentions_ssg/models.py89
-rw-r--r--webmentions_ssg/tasks/__init__.py3
-rw-r--r--webmentions_ssg/tasks/consumer.py8
-rw-r--r--webmentions_ssg/tasks/extension.py159
-rw-r--r--webmentions_ssg/tasks/receiver.py225
-rw-r--r--webmentions_ssg/templates/base.html44
-rw-r--r--webmentions_ssg/templates/login.html8
-rw-r--r--webmentions_ssg/templates/received.html69
-rw-r--r--webmentions_ssg/templates/status.html64
-rw-r--r--webmentions_ssg/views.py160
15 files changed, 1137 insertions, 0 deletions
diff --git a/webmentions_ssg/__init__.py b/webmentions_ssg/__init__.py
index 7f93695..f66e478 100644
--- a/webmentions_ssg/__init__.py
+++ b/webmentions_ssg/__init__.py
@@ -2,4 +2,145 @@
#
# SPDX-License-Identifier: BSD-3-Clause
+import logging
+import logging.handlers
+import os
+from pathlib import Path
+
+import click
+import coloredlogs
+import sqlalchemy as sa
+from flask import Flask
+from flask_bootstrap import Bootstrap5
+from flask_migrate import Migrate, upgrade
+from flask_sqlalchemy import SQLAlchemy
+from sqlalchemy.orm import DeclarativeBase
+
+from webmentions_ssg.config import DefaultConfig, DevelopmentConfig, ProductionConfig
+
+from .tasks import Huey
+
+APP_NAME = "webmentions-ssg"
VERSION = "0.0.1"
+
+
+class Base(DeclarativeBase):
+ pass
+
+
+BOOTSTRAP = Bootstrap5()
+DATABASE = SQLAlchemy(model_class=Base)
+MIGRATE = Migrate()
+HUEY = Huey()
+
+
+def create_app(config_class: type[DefaultConfig] | None = None) -> Flask:
+ """Creates and configures the Flask application."""
+
+ development = os.environ.get(
+ "WEBMENTIONS_SSG_DEV",
+ "",
+ ).lower() in {
+ "1",
+ "true",
+ "yes",
+ "on",
+ }
+
+ if config_class is None:
+ config_class = DevelopmentConfig if development else ProductionConfig
+
+ app = Flask(
+ __name__,
+ instance_path=str(Path("testing").absolute()) if development else None,
+ )
+ app.config.from_object(config_class)
+
+ if not app.testing:
+ app.config.from_pyfile(app.config["CONFIG_FILE"], silent=True)
+ app.config.from_prefixed_env(prefix="FLASK")
+ app.config.from_prefixed_env(prefix="WEBMENTIONS_SSG")
+
+ default_logging_formatter = logging.Formatter(app.config["LOG_FORMAT"])
+
+ log_file_handler = logging.handlers.RotatingFileHandler(
+ app.config["LOG_FILE"],
+ maxBytes=app.config["LOGFILE_MAX_BYTES"],
+ backupCount=app.config["LOGFILE_BACKUP_COUNT"],
+ )
+ log_file_handler.setLevel(app.config["LOG_LEVEL"])
+ log_file_handler.setFormatter(default_logging_formatter)
+
+ logging.basicConfig(level=logging.DEBUG, handlers=[logging.NullHandler()])
+
+ app.logger.handlers.clear()
+ app.logger.setLevel(app.config["LOG_LEVEL"])
+ app.logger.addHandler(log_file_handler)
+
+ werkzeug_logger = logging.getLogger("werkzeug")
+ werkzeug_logger.setLevel(app.config["LOG_LEVEL"])
+ werkzeug_logger.addHandler(log_file_handler)
+
+ coloredlogs.install(
+ level=app.config["LOG_LEVEL"],
+ logger=app.logger,
+ fmt=app.config["LOG_FORMAT"],
+ )
+ coloredlogs.install(
+ level=app.config["LOG_LEVEL"],
+ logger=werkzeug_logger,
+ fmt=app.config["LOG_FORMAT"],
+ )
+
+ DATABASE.init_app(app)
+ MIGRATE.init_app(app, db=DATABASE)
+
+ from .auth import AUTH
+
+ AUTH.init_app(app)
+ BOOTSTRAP.init_app(app)
+ HUEY.init_app(app)
+
+ @app.context_processor
+ def inject_version():
+ return dict(VERSION=VERSION)
+
+ from .views import root_page
+
+ app.register_blueprint(root_page)
+
+ @app.cli.command("create-db")
+ def create_db() -> None:
+ """Create or update the database to the latest migration."""
+ upgrade()
+
+ @app.cli.command("create-user")
+ @click.argument("username")
+ @click.password_option(
+ confirmation_prompt=True,
+ )
+ def create_user(
+ username: str,
+ password: str,
+ ) -> None:
+ """Create a user."""
+
+ from .models import User
+
+ if (
+ DATABASE.session.scalar(sa.select(User).where(User.username == username))
+ is not None
+ ):
+ raise click.ClickException(f"User {username!r} already exists.")
+
+ user = User(
+ username=username,
+ )
+ user.set_password(password)
+
+ DATABASE.session.add(user)
+ DATABASE.session.commit()
+
+ click.echo(f"Created user {username!r}.")
+
+ return app
diff --git a/webmentions_ssg/auth.py b/webmentions_ssg/auth.py
new file mode 100644
index 0000000..f8fd28c
--- /dev/null
+++ b/webmentions_ssg/auth.py
@@ -0,0 +1,13 @@
+from flask_login import LoginManager
+
+from . import DATABASE as db
+from .models import User
+
+AUTH = LoginManager()
+
+AUTH.login_view = "root.login"
+
+
+@AUTH.user_loader
+def load_user(id):
+ return db.session.get(User, int(id))
diff --git a/webmentions_ssg/config.py b/webmentions_ssg/config.py
new file mode 100644
index 0000000..7c448e9
--- /dev/null
+++ b/webmentions_ssg/config.py
@@ -0,0 +1,50 @@
+import logging
+import secrets
+
+
+class DefaultConfig:
+ TESTING = False
+ DEBUG = False
+ SECRET_KEY = secrets.token_urlsafe()
+ BOOTSTRAP_SERVE_LOCAL = True
+ # BOOTSTRAP_BOOTSWATCH_THEME = "Materia"
+ LOG_FILE = "/var/log/webmentions-ssg.log"
+ LOG_FORMAT = "[%(asctime)s]:%(levelname)s:%(name)s %(message)s"
+ LOG_LEVEL = logging.INFO
+ LOGFILE_MAX_BYTES = 20_000_000
+ LOGFILE_BACKUP_COUNT = 10
+ SQLALCHEMY_DATABASE_URI = "sqlite:///webmentions-ssg.db"
+
+
+class DevelopmentConfig(DefaultConfig):
+ DEBUG = True
+ LOG_FILE = "testing/webmentions-ssg.log"
+ LOG_LEVEL = logging.DEBUG
+ CONFIG_FILE = "testing/webmentions-ssg.cfg"
+
+
+class ProductionConfig(DefaultConfig):
+ CONFIG_FILE = "/etc/webmentions-ssg.cfg"
+
+
+class TestingConfig(DefaultConfig):
+ TESTING = True
+
+ SECRET_KEY = "testing-secret-key"
+ WTF_CSRF_ENABLED = False
+
+ LOG_FILE = "testing/webmentions-ssg-tests.log"
+ LOG_LEVEL = logging.DEBUG
+
+ SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"
+ SQLALCHEMY_TRACK_MODIFICATIONS = False
+
+ HUEY_URL = "memory://"
+
+ WEBMENTIONS_SSG_ALLOWED_HOSTNAMES = {
+ "dennisfink.me",
+ }
+
+ WEBMENTIONS_SSG_MAX_REDIRECTS = 20
+ WEBMENTIONS_SSG_MAX_SOURCE_BYTES = 1_000_000
+ WEBMENTIONS_SSG_REQUEST_TIMEOUT = 5.0
diff --git a/webmentions_ssg/forms/__init__.py b/webmentions_ssg/forms/__init__.py
new file mode 100644
index 0000000..98962ad
--- /dev/null
+++ b/webmentions_ssg/forms/__init__.py
@@ -0,0 +1,42 @@
+import re
+
+from flask_wtf import FlaskForm
+from wtforms import PasswordField, StringField, SubmitField
+from wtforms.validators import URL, InputRequired, Regexp
+
+from .validators import AllowedHostname, NotEqualTo
+
+
+class LoginForm(FlaskForm):
+ username = StringField("Username", validators=[InputRequired()])
+ password = PasswordField("Password", validators=[InputRequired()])
+ submit = SubmitField("Sign In")
+
+
+class EndpointForm(FlaskForm):
+ source = StringField(
+ "source",
+ validators=[
+ InputRequired(),
+ URL(),
+ Regexp(
+ "^https?://.*",
+ flags=re.IGNORECASE,
+ message="source must begin with http or https",
+ ),
+ NotEqualTo("target"),
+ ],
+ )
+ target = StringField(
+ "target",
+ validators=[
+ InputRequired(),
+ URL(),
+ Regexp(
+ "^https?://.*",
+ flags=re.IGNORECASE,
+ message="target must begin with http or https",
+ ),
+ AllowedHostname(),
+ ],
+ )
diff --git a/webmentions_ssg/forms/validators.py b/webmentions_ssg/forms/validators.py
new file mode 100644
index 0000000..20d9ebf
--- /dev/null
+++ b/webmentions_ssg/forms/validators.py
@@ -0,0 +1,62 @@
+from urllib.parse import urlsplit
+
+from flask import current_app
+from wtforms import ValidationError
+
+
+class NotEqualTo:
+ """
+ Compares the values of two fields.
+
+ :param fieldname:
+ The name of the other field to compare to.
+ :param message:
+ Error message to raise in case of a validation error. Can be
+ interpolated with `%(other_label)s` and `%(other_name)s` to provide a
+ more helpful error.
+ """
+
+ def __init__(self, fieldname, message=None):
+ self.fieldname = fieldname
+ self.message = message
+
+ def __call__(self, form, field):
+ try:
+ other = form[self.fieldname]
+ except KeyError as exc:
+ raise ValidationError(
+ field.gettext("Invalid field name '%s'.") % self.fieldname
+ ) from exc
+
+ if field.data != other.data:
+ return
+
+ d = {
+ "other_label": hasattr(other, "label")
+ and other.label.text
+ or self.fieldname,
+ "other_name": self.fieldname,
+ }
+ message = self.message
+ if message is None:
+ message = field.gettext("Field must not be equal to %(other_name)s.")
+
+ raise ValidationError(message % d)
+
+
+class AllowedHostname:
+ def __init__(self, message=None):
+ self.message = message
+
+ def __call__(self, form, field):
+ if (
+ urlsplit(field.data).hostname
+ in current_app.config["WEBMENTIONS_SSG_ALLOWED_HOSTNAMES"]
+ ):
+ return
+
+ message = self.message
+ if self.message is None:
+ message = field.gettext("Invalid input.")
+
+ raise ValidationError(message)
diff --git a/webmentions_ssg/models.py b/webmentions_ssg/models.py
new file mode 100644
index 0000000..8c378aa
--- /dev/null
+++ b/webmentions_ssg/models.py
@@ -0,0 +1,89 @@
+import uuid
+from datetime import datetime, timezone
+from typing import Optional
+
+from flask_login import UserMixin
+from sqlalchemy import DateTime, String, Text, UniqueConstraint, Uuid
+from sqlalchemy.orm import Mapped, mapped_column
+from werkzeug.security import check_password_hash, generate_password_hash
+
+from . import Base
+
+
+class User(UserMixin, Base):
+ __tablename__ = "users"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ username: Mapped[str] = mapped_column(
+ String(64),
+ index=True,
+ unique=True,
+ )
+ password_hash: Mapped[Optional[str]] = mapped_column(String(256))
+
+ def __repr__(self):
+ return f"<User {self.username}>"
+
+ def set_password(self, password):
+ self.password_hash = generate_password_hash(password)
+
+ def check_password(self, password):
+ return check_password_hash(
+ self.password_hash,
+ password,
+ )
+
+
+class ReceivedWebmention(Base):
+ __tablename__ = "received_webmentions"
+
+ uuid: Mapped[uuid.UUID] = mapped_column(
+ Uuid(as_uuid=True),
+ primary_key=True,
+ )
+
+ source: Mapped[str] = mapped_column(
+ Text(),
+ nullable=False,
+ )
+ target: Mapped[str] = mapped_column(
+ Text(),
+ nullable=False,
+ )
+
+ status: Mapped[str] = mapped_column(
+ Text(),
+ nullable=False,
+ default="received",
+ )
+ failure_reason: Mapped[str | None] = mapped_column(
+ Text(),
+ nullable=True,
+ default=None,
+ )
+
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ default=lambda: datetime.now(timezone.utc),
+ onupdate=lambda: datetime.now(timezone.utc),
+ nullable=False,
+ )
+
+ __table_args__ = (
+ UniqueConstraint(
+ "source",
+ "target",
+ name="uq_webmention_source_target",
+ ),
+ )
+
+ @property
+ def verified(self) -> bool:
+ return self.status == "verified"
+
+ @property
+ def created_at(self) -> datetime:
+ return datetime.fromtimestamp(
+ self.uuid.time / 1000,
+ tz=timezone.utc,
+ )
diff --git a/webmentions_ssg/tasks/__init__.py b/webmentions_ssg/tasks/__init__.py
new file mode 100644
index 0000000..5af3987
--- /dev/null
+++ b/webmentions_ssg/tasks/__init__.py
@@ -0,0 +1,3 @@
+from .extension import Huey
+
+__all__ = ["Huey"]
diff --git a/webmentions_ssg/tasks/consumer.py b/webmentions_ssg/tasks/consumer.py
new file mode 100644
index 0000000..5b85d0e
--- /dev/null
+++ b/webmentions_ssg/tasks/consumer.py
@@ -0,0 +1,8 @@
+from .. import HUEY, create_app
+
+app = create_app()
+
+# Import the tasks so they are registered with the initialized Huey instance.
+from . import receiver # noqa: E402, F401
+
+huey = HUEY.huey
diff --git a/webmentions_ssg/tasks/extension.py b/webmentions_ssg/tasks/extension.py
new file mode 100644
index 0000000..ebdaa76
--- /dev/null
+++ b/webmentions_ssg/tasks/extension.py
@@ -0,0 +1,159 @@
+from functools import wraps
+from typing import Any, Callable
+from urllib.parse import urlsplit, urlunsplit
+
+from flask import Flask
+
+
+class Huey:
+ def __init__(self, app: Flask | None = None):
+ self.app: Flask | None = None
+ self._huey = None
+
+ if app is not None:
+ self.init_app(app)
+
+ def init_app(self, app: Flask):
+ config: dict[str, Any] = {
+ "name": app.import_name,
+ "results": True,
+ "store_none": False,
+ "utc": True,
+ "immediate": app.config.get("TESTING", False),
+ **app.config.get("HUEY", {}),
+ }
+
+ url = app.config.get("HUEY_URL", config.pop("url", "memory://"))
+ huey_class, storage_kwargs = self.backend_from_url(url)
+
+ self.app = app
+ self._huey = huey_class(
+ **config,
+ **storage_kwargs,
+ )
+
+ app.extensions["huey"] = self
+
+ @property
+ def huey(self):
+ if self._huey is None:
+ raise RuntimeError(
+ "Huey has not been initialized. "
+ "Call huey.init_app(app) before importing tasks."
+ )
+ return self._huey
+
+ def task(self, *task_args: Any, **task_kwargs: Any):
+ def decorator(func: Callable):
+ @wraps(func)
+ def wrapper(*args: Any, **kwargs: Any):
+ if self.app is None:
+ raise RuntimeError("Flask app is not available.")
+
+ with self.app.app_context():
+ return func(*args, **kwargs)
+
+ return self.huey.task(*task_args, **task_kwargs)(wrapper)
+
+ return decorator
+
+ def periodic_task(self, *task_args: Any, **task_kwargs: Any):
+ def decorator(func: Callable):
+ @wraps(func)
+ def wrapper(*args: Any, **kwargs: Any):
+ if self.app is None:
+ raise RuntimeError("Flask app is not available.")
+
+ with self.app.app_context():
+ return func(*args, **kwargs)
+
+ return self.huey.periodic_task(*task_args, **task_kwargs)(wrapper)
+
+ return decorator
+
+ def __getattr__(self, name: str):
+ """
+ Forward unknown attributes to the real Huey instance.
+
+ This lets you still use things like:
+ huey.enqueue(...)
+ huey.scheduled()
+ huey.pending()
+ """
+ return getattr(self.huey, name)
+
+ @staticmethod
+ def backend_from_url(url: str) -> tuple[Any, dict[str, str]]:
+ parsed = urlsplit(url)
+ scheme = parsed.scheme.lower()
+
+ if scheme.startswith("redis") or scheme.startswith("rediss"):
+ fixed_url = urlunsplit(parsed._replace(scheme=scheme.split("+", 1)[0]))
+
+ if scheme.endswith("priority+expire"):
+ from huey import PriorityRedisExpireHuey
+
+ return PriorityRedisExpireHuey, {"url": fixed_url}
+ elif scheme.endswith("priority"):
+ from huey import PriorityRedisHuey
+
+ return PriorityRedisHuey, {"url": fixed_url}
+ elif scheme.endswith("expire"):
+ from huey import RedisExpireHuey
+
+ return RedisExpireHuey, {"url": fixed_url}
+ else:
+ from huey import RedisHuey
+
+ return RedisHuey, {"url": url}
+
+ elif scheme == "sqlite":
+ from huey import SqliteHuey
+
+ prefix = "sqlite:///"
+
+ if not url.startswith(prefix):
+ raise RuntimeError(
+ "SQLite Huey URLs must look like sqlite:///var/huey.db"
+ )
+
+ filename = url.removeprefix(prefix)
+
+ if not filename:
+ raise RuntimeError("SQLite Huey URL must include a database path.")
+
+ return SqliteHuey, {"filename": filename}
+
+ elif scheme == "file":
+ from huey import FileHuey
+
+ prefix = "file:///"
+
+ if not url.startswith(prefix):
+ raise RuntimeError(
+ "File Huey URLs must look like file:///var/huey-queue"
+ )
+
+ path = url.removeprefix(prefix)
+
+ if not path:
+ raise RuntimeError("File Huey URL must include a directory path.")
+
+ return FileHuey, {"path": path}
+
+ elif scheme in {"postgres", "postgresql"}:
+ from huey import PostgresHuey
+
+ return PostgresHuey, {"dsn": url}
+
+ elif scheme == "memory":
+ from huey import MemoryHuey
+
+ return MemoryHuey, {}
+
+ elif scheme == "blackhole":
+ from huey import BlackHoleHuey
+
+ return BlackHoleHuey, {}
+
+ raise RuntimeError(f"Unsupported HUEY_URL scheme: {scheme!r}")
diff --git a/webmentions_ssg/tasks/receiver.py b/webmentions_ssg/tasks/receiver.py
new file mode 100644
index 0000000..ea48299
--- /dev/null
+++ b/webmentions_ssg/tasks/receiver.py
@@ -0,0 +1,225 @@
+import uuid
+from urllib.parse import urljoin
+
+import httpx
+import rfc3987
+from bs4 import BeautifulSoup
+from flask import current_app
+
+from .. import APP_NAME, VERSION
+from .. import DATABASE as db
+from .. import HUEY as huey
+from ..models import ReceivedWebmention
+
+
+class VerificationError(Exception):
+ """The source permanently failed ReceivedWebmention verification."""
+
+
+class SourceGoneError(VerificationError):
+ """The source explicitly reports that it has been removed."""
+
+
+class TemporaryFetchError(Exception):
+ """Fetching the source may succeed when retried later."""
+
+
+IRI_PATTERN = rfc3987.get_compiled_pattern("IRI")
+
+
+HTML_URL_ATTRIBUTES = {
+ "href": {"a", "area", "link"},
+ "src": {
+ "audio",
+ "embed",
+ "iframe",
+ "img",
+ 'input[type="image" i]',
+ "script",
+ "audio source",
+ "video source",
+ "track",
+ "video",
+ },
+ "cite": {
+ "blockquote",
+ "del",
+ "ins",
+ "q",
+ },
+}
+
+
+def html_mentions_target(
+ body: bytes,
+ source_url: str,
+ target_url: str,
+) -> bool:
+ """Check valid HTML URL attributes for the exact target URL."""
+
+ document = BeautifulSoup(body, "html.parser")
+ base_url = source_url
+
+ if (base_element := document.select_one("base[href]")) is not None and isinstance(
+ base_href := base_element.get("href"),
+ str,
+ ):
+ base_url = urljoin(
+ source_url,
+ base_href.strip(),
+ )
+
+ for attribute, selectors in HTML_URL_ATTRIBUTES.items():
+ selector = ", ".join(
+ [
+ "{selector}[{attribute}]".format(
+ selector=selector_string, attribute=attribute
+ )
+ for selector_string in selectors
+ ]
+ )
+
+ for element in document.select(selector):
+ if not isinstance(
+ reference := element.get(attribute),
+ str,
+ ):
+ continue
+
+ if (
+ urljoin(
+ base_url,
+ reference.strip(),
+ )
+ == target_url
+ ):
+ return True
+
+ return False
+
+
+def text_mentions_target(body: str, target_url: str) -> bool:
+ """Check whether plain text contains the exact target IRI."""
+ return any(match.group() == target_url for match in IRI_PATTERN.finditer(body))
+
+
+def fetch_source(source_url: str) -> tuple[httpx.Response, bytes]:
+ """Fetch a source with limits on redirects, time, and response size."""
+
+ with httpx.Client(
+ headers={
+ "Accept": "text/html, application/xhtml+xml;q=0.9, text/plain;q=0.8",
+ "User-Agent": f"{APP_NAME}/{VERSION} ReceivedWebmention",
+ },
+ timeout=httpx.Timeout(
+ current_app.config.get("WEBMENTIONS_SSG_REQUEST_TIMEOUT", 5.0)
+ ),
+ follow_redirects=True,
+ max_redirects=current_app.config.get("WEBMENTIONS_SSG_MAX_REDIRECTS", 20),
+ trust_env=False,
+ ) as client:
+ with client.stream("GET", source_url) as response:
+ match response.status_code:
+ case 200:
+ pass
+ case 410:
+ raise SourceGoneError("Source returned HTTP 410")
+ case status:
+ if status in {408, 425, 429} or 500 <= status <= 599:
+ raise TemporaryFetchError(f"Source returned HTTP {status}")
+ else:
+ raise VerificationError(f"Source returned HTTP {status}")
+
+ max_source_bytes = current_app.config.get(
+ "WEBMENTIONS_SSG_MAX_SOURCE_BYTES",
+ 1_000_000,
+ )
+
+ if (content_length := response.headers.get("Content-Length")) is not None:
+ try:
+ if int(content_length) > max_source_bytes:
+ raise VerificationError("Source document is too large")
+ except ValueError:
+ pass
+
+ body = bytearray()
+ for chunk in response.iter_bytes(chunk_size=64 * 1024):
+ body.extend(chunk)
+
+ if len(body) > max_source_bytes:
+ raise VerificationError("Source document is too large")
+
+ return response, bytes(body)
+
+
+def source_mentions_target(source_url: str, target_url: str) -> bool:
+ """Fetch the source and verify it according to its media type."""
+
+ response, body = fetch_source(source_url)
+
+ media_type = (
+ response.headers.get("Content-Type", "").partition(";")[0].strip().lower()
+ )
+
+ match media_type:
+ case "text/html" | "application/xhtml+xml":
+ return html_mentions_target(body, str(response.url), target_url)
+ case "text/plain":
+ try:
+ decoded_body = body.decode(
+ response.encoding or "utf-8",
+ errors="replace",
+ )
+ except LookupError:
+ decoded_body = body.decode(
+ "utf-8",
+ errors="replace",
+ )
+ return text_mentions_target(decoded_body, target_url)
+ case _:
+ raise VerificationError(
+ f"Unsupported source content type: {media_type or 'missing'}"
+ )
+
+
+@huey.task(retries=2, retry_delay=50)
+def verify_webmention(webmention_uuid: uuid.UUID) -> None:
+ """Verify a ReceivedWebmention and store the result."""
+
+ webmention = db.session.get(ReceivedWebmention, webmention_uuid)
+
+ if webmention is None:
+ current_app.logger.warning(
+ "Cannot verify unknown ReceivedWebmention %s",
+ webmention_uuid,
+ )
+ return
+
+ webmention.status = "verifying"
+ webmention.failure_reason = None
+ db.session.commit()
+
+ try:
+ mentions_target = source_mentions_target(webmention.source, webmention.target)
+ except SourceGoneError as exc:
+ webmention.status = "deleted"
+ webmention.failure_reason = str(exc)
+ except VerificationError as exc:
+ webmention.status = "failed"
+ webmention.failure_reason = str(exc)
+ except (TemporaryFetchError, httpx.RequestError) as exc:
+ webmention.status = "failed"
+ webmention.failure_reason = str(exc) or "Source could not be fetched"
+ db.session.commit()
+
+ # Huey retries the task because the exception escapes.
+ raise
+ else:
+ if mentions_target:
+ webmention.status = "verified"
+ webmention.failure_reason = None
+ else:
+ webmention.status = "deleted"
+ webmention.failure_reason = "Source does not mention target"
+
+ db.session.commit()
diff --git a/webmentions_ssg/templates/base.html b/webmentions_ssg/templates/base.html
new file mode 100644
index 0000000..237a437
--- /dev/null
+++ b/webmentions_ssg/templates/base.html
@@ -0,0 +1,44 @@
+{% from "bootstrap5/utils.html" import render_messages %}
+<!DOCTYPE html>
+<html lang="en" data-bs-theme="dark">
+ <head>
+ {% block head %}
+ <!-- Required meta tags -->
+ <meta charset="utf-8">
+ <meta name="viewport"
+ content="width=device-width, initial-scale=1, shrink-to-fit=no">
+ {% block styles %}
+ <!-- Bootstrap CSS -->
+ {{ bootstrap.load_css() }}
+ {% endblock %}
+ <title>{{ title }}</title>
+ {% endblock %}
+ </head>
+ <body>
+ <header>
+ <nav class="navbar bg-primary py-1">
+ <div class="container-fluid px3">
+ <a class="navbar-brand fs-6 mb-0" href="{{ url_for("root.index") }}">Webmentions</a>
+ {% if current_user.is_authenticated %}
+ <div class="navbar-nav flex-row gap-3">
+ <a class="nav-link{% if request.endpoint == 'root.received' %} active{% endif %}"
+ href="{{ url_for("root.received") }}">Received</a>
+ <span class="nav-link disabled">Sent</span>
+ <a class="nav-link" href="{{ url_for("root.logout") }}">Logout</a>
+ </div>
+ {% endif %}
+ </div>
+ </div>
+ </nav>
+ </header>
+ <div class="mt-4">{{ render_messages(container=True) }}</div>
+ <div class="container py-4">
+ {% block content %}
+ {% endblock %}
+ </div>
+ {% block scripts %}
+ <!-- Optional JavaScript -->
+ {{ bootstrap.load_js() }}
+ {% endblock %}
+</body>
+</html>
diff --git a/webmentions_ssg/templates/login.html b/webmentions_ssg/templates/login.html
new file mode 100644
index 0000000..e7d858c
--- /dev/null
+++ b/webmentions_ssg/templates/login.html
@@ -0,0 +1,8 @@
+{% extends "base.html" %}
+{% set title = "Login" %}
+{% from "bootstrap5/form.html" import render_form %}
+{% block content %}
+ <div class="row justify-content-center">
+ <div class="col-12 col-sm-8 col-md-6 col-lg-4">{{ render_form(form) }}</div>
+ </div>
+{% endblock %}
diff --git a/webmentions_ssg/templates/received.html b/webmentions_ssg/templates/received.html
new file mode 100644
index 0000000..ea74557
--- /dev/null
+++ b/webmentions_ssg/templates/received.html
@@ -0,0 +1,69 @@
+{% extends "base.html" %}
+{% from "bootstrap5/pagination.html" import render_pagination %}
+{% block content %}
+ <div class="d-flex justify-content-between align-items-center mb-3">
+ <h1 class="h3 mb-0">Received Webmentions</h1>
+ <span class="text-body-secondary">{{ webmentions.total }} total</span>
+ </div>
+ {% if webmentions.items %}
+ <div class="table-responsive">
+ <table class="table table-bordered table-striped table-hover align-middle">
+ <thead>
+ <tr>
+ <th scope="col">Status</th>
+ <th scope="col">Source</th>
+ <th scope="col">Target</th>
+ <th scope="col">Received</th>
+ <th scope="col">Updated</th>
+ <th scope="col">Reason</th>
+ </tr>
+ </thead>
+ <tbody>
+ {% for webmention in webmentions %}
+ {% if 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" %}
+ {% endif %}
+ <tr class="{{ row_class }}">
+ <td>
+ <strong>{{ display_status }}</strong>
+ </td>
+ <td class="text-break">
+ <a href="{{ webmention.source }}">{{ webmention.source }}</a>
+ </td>
+ <td class="text-break">
+ <a href="{{ webmention.target }}">{{ webmention.target }}</a>
+ </td>
+ <td class="text-nowrap">
+ <time datetime="{{ webmention.created_at.isoformat() }}">
+ {{ webmention.created_at.strftime("%Y-%m-%d %H:%M:%S") }}
+ </time>
+ </td>
+ <td class="text-nowrap">
+ <time datetime="{{ webmention.updated_at.isoformat() }}">
+ {{ webmention.updated_at.strftime("%Y-%m-%d %H:%M:%S") }}
+ </time>
+ </td>
+ <td>{{ webmention.failure_reason or "—" }}</td>
+ </tr>
+ {% endfor %}
+ </tbody>
+ </table>
+ </div>
+ {% if webmentions.pages > 1 %}
+ <div class="mt-3">
+ {{ render_pagination(webmentions,
+ align="center",
+ size="sm") }}
+ </div>
+ {% endif %}
+ {% else %}
+ <p class="text-body-secondary">No Webmentions have been received yet.</p>
+ {% endif %}
+{% endblock %}
diff --git a/webmentions_ssg/templates/status.html b/webmentions_ssg/templates/status.html
new file mode 100644
index 0000000..451f26f
--- /dev/null
+++ b/webmentions_ssg/templates/status.html
@@ -0,0 +1,64 @@
+{% extends "base.html" %}
+{% from "bootstrap5/utils.html" import render_icon %}
+{% block content %}
+ <main class="py-5">
+ <div class="text-center mb-5">
+ {% if webmention.status == "verified" %}
+ <div class="text-success mb-3">{{ render_icon("check-circle-fill", size="5rem", title="Verified") }}</div>
+ <h1 class="h3">Webmention verified</h1>
+ {% elif webmention.status in ("received", "verifying") %}
+ <div class="text-warning mb-3">{{ render_icon("hourglass-split", size="5rem", title="Verification pending") }}</div>
+ <h1 class="h3">Webmention verification pending</h1>
+ {% elif webmention.status == "deleted" %}
+ <div class="text-danger mb-3">{{ render_icon("x-circle-fill", size="5rem", title="Not verified") }}</div>
+ <h1 class="h3">Webmention not verified</h1>
+ {% elif webmention.status == "failed" %}
+ <div class="text-danger mb-3">{{ render_icon("x-circle-fill", size="5rem", title="Verification failed") }}</div>
+ <h1 class="h3">Webmention verification failed</h1>
+ {% else %}
+ <div class="text-secondary mb-3">{{ render_icon("question-circle-fill", size="5rem", title="Unknown status") }}</div>
+ <h1 class="h3">Unknown Webmention status</h1>
+ {% endif %}
+ </div>
+ <div class="card mx-auto" style="max-width: 48rem;">
+ <div class="card-body">
+ <dl class="row mb-0">
+ <dt class="col-sm-3">Status</dt>
+ <dd class="col-sm-9">
+ {{ webmention.status }}
+ </dd>
+ <dt class="col-sm-3">Source</dt>
+ <dd class="col-sm-9 text-break">
+ <a href="{{ webmention.source }}">{{ webmention.source }}</a>
+ </dd>
+ <dt class="col-sm-3">Target</dt>
+ <dd class="col-sm-9 text-break">
+ <a href="{{ webmention.target }}">{{ webmention.target }}</a>
+ </dd>
+ <dt class="col-sm-3">Received</dt>
+ <dd class="col-sm-9">
+ <time datetime="{{ webmention.created_at.isoformat() }}">
+ {{ webmention.created_at.strftime("%Y-%m-%d %H:%M:%S %Z") }}
+ </time>
+ </dd>
+ <dt class="col-sm-3">Last updated</dt>
+ <dd class="col-sm-9">
+ <time datetime="{{ webmention.updated_at.isoformat() }}">
+ {{ webmention.updated_at.strftime("%Y-%m-%d %H:%M:%S %Z") }}
+ </time>
+ </dd>
+ <dt class="col-sm-3">ID</dt>
+ <dd class="col-sm-9">
+ <code>{{ webmention.uuid }}</code>
+ </dd>
+ {% if webmention.failure_reason %}
+ <dt class="col-sm-3">Reason</dt>
+ <dd class="col-sm-9">
+ {{ webmention.failure_reason }}
+ </dd>
+ {% endif %}
+ </dl>
+ </div>
+ </div>
+ </main>
+{% endblock %}
diff --git a/webmentions_ssg/views.py b/webmentions_ssg/views.py
new file mode 100644
index 0000000..5408fa4
--- /dev/null
+++ b/webmentions_ssg/views.py
@@ -0,0 +1,160 @@
+import uuid
+from typing import cast
+from urllib.parse import urlsplit
+
+import sqlalchemy as sa
+import sqlalchemy.exc as sa_exc
+from flask import (
+ Blueprint,
+ Response,
+ abort,
+ flash,
+ redirect,
+ render_template,
+ request,
+ url_for,
+)
+from flask.typing import ResponseReturnValue
+from flask_login import current_user, login_required, login_user, logout_user
+
+from . import DATABASE as db
+from . import forms
+from .models import ReceivedWebmention, User
+from .tasks.receiver import verify_webmention
+
+root_page = Blueprint("root", __name__)
+
+
+@root_page.route("/")
+def index() -> ResponseReturnValue:
+ return redirect(url_for("root.received"))
+
+
+@root_page.route("/login", methods=["GET", "POST"])
+def login() -> ResponseReturnValue:
+ if current_user.is_authenticated:
+ return redirect(url_for("root.index"))
+
+ form = forms.LoginForm()
+
+ if form.validate_on_submit():
+ user = db.session.scalar(
+ sa.select(User).where(User.username == form.username.data)
+ )
+
+ if user is None or not user.check_password(form.password.data):
+ flash("Invalid username or password", "danger")
+ return redirect(url_for("root.login"))
+
+ login_user(user)
+
+ next_page = request.args.get("next")
+ if not next_page or urlsplit(next_page).netloc != "":
+ next_page = url_for("root.index")
+ return redirect(next_page)
+
+ return render_template("login.html", form=form)
+
+
+@root_page.route("/logout")
+def logout() -> ResponseReturnValue:
+ logout_user()
+ return redirect(url_for("root.index"))
+
+
+@root_page.route("/received")
+@login_required
+def received() -> ResponseReturnValue:
+ webmentions = db.paginate(
+ sa.select(ReceivedWebmention).order_by(ReceivedWebmention.uuid.desc()),
+ per_page=25,
+ )
+
+ return render_template(
+ "received.html",
+ title="Received Webmentions",
+ webmentions=webmentions,
+ )
+
+
+@root_page.route("/endpoint", methods=["POST"])
+def endpoint() -> ResponseReturnValue:
+ form = forms.EndpointForm(meta={"csrf": False})
+
+ if not form.validate_on_submit():
+ return form.errors, 400
+
+ source = cast(str, form.source.data)
+ target = cast(str, form.target.data)
+
+ webmention = db.session.execute(
+ sa.select(ReceivedWebmention).where(
+ ReceivedWebmention.source == source,
+ ReceivedWebmention.target == target,
+ )
+ ).scalar_one_or_none()
+
+ if webmention is None:
+ identifier = uuid.uuid7()
+
+ webmention = ReceivedWebmention(
+ uuid=identifier,
+ source=source,
+ target=target,
+ )
+
+ db.session.add(webmention)
+
+ try:
+ db.session.commit()
+ except sa_exc.IntegrityError:
+ db.session.rollback()
+
+ # Another request may have inserted the same source/target pair
+ # after our SELECT but before our COMMIT.
+ webmention = db.session.execute(
+ sa.select(ReceivedWebmention).where(
+ ReceivedWebmention.source == source,
+ ReceivedWebmention.target == target,
+ )
+ ).scalar_one()
+
+ identifier = webmention.uuid
+ webmention.status = "received"
+ webmention.failure_reason = None
+
+ db.session.commit()
+
+ else:
+ identifier = webmention.uuid
+
+ # Re-sent webmention: re-verify the existing row.
+ webmention.status = "received"
+ webmention.failure_reason = None
+
+ db.session.commit()
+
+ verify_webmention(webmention.uuid)
+
+ status_url = url_for(
+ "root.status",
+ identifier=str(identifier),
+ _external=True,
+ )
+
+ return Response(
+ status=201,
+ headers={"Location": status_url},
+ )
+
+
+@root_page.route("/status/<uuid:identifier>")
+def status(identifier: uuid.UUID) -> ResponseReturnValue:
+ webmention = db.session.get(ReceivedWebmention, identifier)
+
+ if webmention is None:
+ return abort(404)
+
+ return render_template(
+ "status.html", title="Webmention status", webmention=webmention
+ )