aboutsummaryrefslogtreecommitdiff
path: root/webmentions_ssg
diff options
context:
space:
mode:
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
+ )