diff options
| author | Dennis Fink | 2026-08-09 14:15:29 +0200 |
|---|---|---|
| committer | Dennis Fink | 2026-08-09 14:22:00 +0200 |
| commit | 67eff0a854da010cb6ecd119d84238ec3e119272 (patch) | |
| tree | c4a85592b957bb01fa62f79329faa6ba0823d9b3 /webmentions_ssg | |
| parent | f53c1136184d2afabfb1e5974e527229aa71939a (diff) | |
| download | webmentions-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__.py | 141 | ||||
| -rw-r--r-- | webmentions_ssg/auth.py | 13 | ||||
| -rw-r--r-- | webmentions_ssg/config.py | 50 | ||||
| -rw-r--r-- | webmentions_ssg/forms/__init__.py | 42 | ||||
| -rw-r--r-- | webmentions_ssg/forms/validators.py | 62 | ||||
| -rw-r--r-- | webmentions_ssg/models.py | 89 | ||||
| -rw-r--r-- | webmentions_ssg/tasks/__init__.py | 3 | ||||
| -rw-r--r-- | webmentions_ssg/tasks/consumer.py | 8 | ||||
| -rw-r--r-- | webmentions_ssg/tasks/extension.py | 159 | ||||
| -rw-r--r-- | webmentions_ssg/tasks/receiver.py | 225 | ||||
| -rw-r--r-- | webmentions_ssg/templates/base.html | 44 | ||||
| -rw-r--r-- | webmentions_ssg/templates/login.html | 8 | ||||
| -rw-r--r-- | webmentions_ssg/templates/received.html | 69 | ||||
| -rw-r--r-- | webmentions_ssg/templates/status.html | 64 | ||||
| -rw-r--r-- | webmentions_ssg/views.py | 160 |
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 + ) |
