aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.flaskenv3
-rw-r--r--migrations/README1
-rw-r--r--migrations/alembic.ini50
-rw-r--r--migrations/env.py113
-rw-r--r--migrations/script.py.mako24
-rw-r--r--migrations/versions/f63641044cc1_initial_database_schema.py50
-rw-r--r--tests/conftest.py125
-rw-r--r--tests/tasks/test_receiver.py886
-rw-r--r--tests/test_forms.py130
-rw-r--r--tests/test_views.py183
-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
25 files changed, 2702 insertions, 0 deletions
diff --git a/.flaskenv b/.flaskenv
new file mode 100644
index 0000000..2737ea1
--- /dev/null
+++ b/.flaskenv
@@ -0,0 +1,3 @@
+WEBMENTIONS_SSG_DEV=1
+FLASK_APP=webmentions_ssg:create_app
+FLASK_DEBUG=1
diff --git a/migrations/README b/migrations/README
new file mode 100644
index 0000000..0e04844
--- /dev/null
+++ b/migrations/README
@@ -0,0 +1 @@
+Single-database configuration for Flask.
diff --git a/migrations/alembic.ini b/migrations/alembic.ini
new file mode 100644
index 0000000..ec9d45c
--- /dev/null
+++ b/migrations/alembic.ini
@@ -0,0 +1,50 @@
+# A generic, single database configuration.
+
+[alembic]
+# template used to generate migration files
+# file_template = %%(rev)s_%%(slug)s
+
+# set to 'true' to run the environment during
+# the 'revision' command, regardless of autogenerate
+# revision_environment = false
+
+
+# Logging configuration
+[loggers]
+keys = root,sqlalchemy,alembic,flask_migrate
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[logger_flask_migrate]
+level = INFO
+handlers =
+qualname = flask_migrate
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S
diff --git a/migrations/env.py b/migrations/env.py
new file mode 100644
index 0000000..4c97092
--- /dev/null
+++ b/migrations/env.py
@@ -0,0 +1,113 @@
+import logging
+from logging.config import fileConfig
+
+from flask import current_app
+
+from alembic import context
+
+# this is the Alembic Config object, which provides
+# access to the values within the .ini file in use.
+config = context.config
+
+# Interpret the config file for Python logging.
+# This line sets up loggers basically.
+fileConfig(config.config_file_name)
+logger = logging.getLogger('alembic.env')
+
+
+def get_engine():
+ try:
+ # this works with Flask-SQLAlchemy<3 and Alchemical
+ return current_app.extensions['migrate'].db.get_engine()
+ except (TypeError, AttributeError):
+ # this works with Flask-SQLAlchemy>=3
+ return current_app.extensions['migrate'].db.engine
+
+
+def get_engine_url():
+ try:
+ return get_engine().url.render_as_string(hide_password=False).replace(
+ '%', '%%')
+ except AttributeError:
+ return str(get_engine().url).replace('%', '%%')
+
+
+# add your model's MetaData object here
+# for 'autogenerate' support
+# from myapp import mymodel
+# target_metadata = mymodel.Base.metadata
+config.set_main_option('sqlalchemy.url', get_engine_url())
+target_db = current_app.extensions['migrate'].db
+
+# other values from the config, defined by the needs of env.py,
+# can be acquired:
+# my_important_option = config.get_main_option("my_important_option")
+# ... etc.
+
+
+def get_metadata():
+ if hasattr(target_db, 'metadatas'):
+ return target_db.metadatas[None]
+ return target_db.metadata
+
+
+def run_migrations_offline():
+ """Run migrations in 'offline' mode.
+
+ This configures the context with just a URL
+ and not an Engine, though an Engine is acceptable
+ here as well. By skipping the Engine creation
+ we don't even need a DBAPI to be available.
+
+ Calls to context.execute() here emit the given string to the
+ script output.
+
+ """
+ url = config.get_main_option("sqlalchemy.url")
+ context.configure(
+ url=url, target_metadata=get_metadata(), literal_binds=True
+ )
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+def run_migrations_online():
+ """Run migrations in 'online' mode.
+
+ In this scenario we need to create an Engine
+ and associate a connection with the context.
+
+ """
+
+ # this callback is used to prevent an auto-migration from being generated
+ # when there are no changes to the schema
+ # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
+ def process_revision_directives(context, revision, directives):
+ if getattr(config.cmd_opts, 'autogenerate', False):
+ script = directives[0]
+ if script.upgrade_ops.is_empty():
+ directives[:] = []
+ logger.info('No changes in schema detected.')
+
+ conf_args = current_app.extensions['migrate'].configure_args
+ if conf_args.get("process_revision_directives") is None:
+ conf_args["process_revision_directives"] = process_revision_directives
+
+ connectable = get_engine()
+
+ with connectable.connect() as connection:
+ context.configure(
+ connection=connection,
+ target_metadata=get_metadata(),
+ **conf_args
+ )
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+if context.is_offline_mode():
+ run_migrations_offline()
+else:
+ run_migrations_online()
diff --git a/migrations/script.py.mako b/migrations/script.py.mako
new file mode 100644
index 0000000..2c01563
--- /dev/null
+++ b/migrations/script.py.mako
@@ -0,0 +1,24 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision = ${repr(up_revision)}
+down_revision = ${repr(down_revision)}
+branch_labels = ${repr(branch_labels)}
+depends_on = ${repr(depends_on)}
+
+
+def upgrade():
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade():
+ ${downgrades if downgrades else "pass"}
diff --git a/migrations/versions/f63641044cc1_initial_database_schema.py b/migrations/versions/f63641044cc1_initial_database_schema.py
new file mode 100644
index 0000000..a3f0e40
--- /dev/null
+++ b/migrations/versions/f63641044cc1_initial_database_schema.py
@@ -0,0 +1,50 @@
+"""Initial database schema
+
+Revision ID: f63641044cc1
+Revises:
+Create Date: 2026-08-09 11:18:10.051888
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'f63641044cc1'
+down_revision = None
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('received_webmentions',
+ sa.Column('uuid', sa.Uuid(), nullable=False),
+ sa.Column('source', sa.Text(), nullable=False),
+ sa.Column('target', sa.Text(), nullable=False),
+ sa.Column('status', sa.Text(), nullable=False),
+ sa.Column('failure_reason', sa.Text(), nullable=True),
+ sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
+ sa.PrimaryKeyConstraint('uuid'),
+ sa.UniqueConstraint('source', 'target', name='uq_webmention_source_target')
+ )
+ op.create_table('users',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('username', sa.String(length=64), nullable=False),
+ sa.Column('password_hash', sa.String(length=256), nullable=True),
+ sa.PrimaryKeyConstraint('id')
+ )
+ with op.batch_alter_table('users', schema=None) as batch_op:
+ batch_op.create_index(batch_op.f('ix_users_username'), ['username'], unique=True)
+
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table('users', schema=None) as batch_op:
+ batch_op.drop_index(batch_op.f('ix_users_username'))
+
+ op.drop_table('users')
+ op.drop_table('received_webmentions')
+ # ### end Alembic commands ###
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..94ba650
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,125 @@
+import uuid
+from collections.abc import Callable, Iterator
+from types import ModuleType
+
+import httpx
+import pytest
+from flask import Flask
+from flask.testing import FlaskClient
+
+from webmentions_ssg import DATABASE as db
+from webmentions_ssg import create_app
+from webmentions_ssg.config import TestingConfig
+from webmentions_ssg.models import ReceivedWebmention
+
+HTTPHandler = Callable[
+ [httpx.Request],
+ httpx.Response,
+]
+
+
+@pytest.fixture
+def app() -> Iterator[Flask]:
+ """
+ Create a new Flask application and in-memory database
+ for every test.
+ """
+
+ application = create_app(TestingConfig)
+
+ with application.app_context():
+ db.create_all()
+
+ yield application
+
+ with application.app_context():
+ db.session.remove()
+ db.drop_all()
+ db.engine.dispose()
+
+
+@pytest.fixture
+def client(app: Flask) -> FlaskClient:
+ return app.test_client()
+
+
+@pytest.fixture
+def receiver_module(app: Flask) -> ModuleType:
+ """
+ Ensure the application and Huey extension are initialized
+ before retrieving the tasks module.
+ """
+
+ from webmentions_ssg.tasks import receiver
+
+ return receiver
+
+
+@pytest.fixture
+def views_module(app: Flask) -> ModuleType:
+ from webmentions_ssg import views
+
+ return views
+
+
+@pytest.fixture
+def make_webmention(
+ app: Flask,
+) -> Callable[..., uuid.UUID]:
+ def create(
+ *,
+ source: str = "https://source.example/post",
+ target: str = ("https://dennisfink.me/blog/example/"),
+ status: str = "received",
+ failure_reason: str | None = None,
+ ) -> uuid.UUID:
+ identifier = uuid.uuid7()
+
+ with app.app_context():
+ webmention = ReceivedWebmention(
+ uuid=identifier,
+ source=source,
+ target=target,
+ status=status,
+ failure_reason=failure_reason,
+ )
+
+ db.session.add(webmention)
+ db.session.commit()
+
+ return identifier
+
+ return create
+
+
+@pytest.fixture
+def install_httpx_mock(
+ monkeypatch: pytest.MonkeyPatch,
+ receiver_module: ModuleType,
+) -> Callable[[HTTPHandler], None]:
+ """
+ Replace the HTTPX transport without replacing HTTPX itself.
+ """
+
+ real_client = httpx.Client
+
+ def install(handler: HTTPHandler) -> None:
+ transport = httpx.MockTransport(handler)
+
+ def create_client(
+ *args,
+ **kwargs,
+ ) -> httpx.Client:
+ return real_client(
+ *args,
+ transport=transport,
+ **kwargs,
+ )
+
+ monkeypatch.setattr(
+ receiver_module.httpx,
+ "Client",
+ create_client,
+ )
+
+ return install
diff --git a/tests/tasks/test_receiver.py b/tests/tasks/test_receiver.py
new file mode 100644
index 0000000..4545121
--- /dev/null
+++ b/tests/tasks/test_receiver.py
@@ -0,0 +1,886 @@
+import logging
+import uuid
+from collections.abc import Callable
+from types import ModuleType
+
+import httpx
+import pytest
+from flask import Flask
+
+from webmentions_ssg import DATABASE as db
+from webmentions_ssg.models import ReceivedWebmention
+
+SOURCE_URL = "https://source.example/article"
+TARGET_URL = "https://dennisfink.me/blog/example/"
+
+ReceivedWebmentionFactory = Callable[..., uuid.UUID]
+HTTPXMockInstaller = Callable[
+ [Callable[[httpx.Request], httpx.Response]],
+ None,
+]
+
+
+def get_webmention_state(
+ app: Flask,
+ identifier: uuid.UUID,
+) -> tuple[str, str | None]:
+ with app.app_context():
+ webmention = db.session.get(
+ ReceivedWebmention,
+ identifier,
+ )
+
+ assert webmention is not None
+
+ return (
+ webmention.status,
+ webmention.failure_reason,
+ )
+
+
+@pytest.mark.parametrize(
+ (
+ "body",
+ "source_url",
+ "target_url",
+ "expected",
+ ),
+ [
+ pytest.param(
+ f'<a href="{TARGET_URL}">Reply</a>',
+ SOURCE_URL,
+ TARGET_URL,
+ True,
+ id="a-href",
+ ),
+ pytest.param(
+ f'<area href="{TARGET_URL}" alt="Target">',
+ SOURCE_URL,
+ TARGET_URL,
+ True,
+ id="area-href",
+ ),
+ pytest.param(
+ f'<link href="{TARGET_URL}" rel="alternate">',
+ SOURCE_URL,
+ TARGET_URL,
+ True,
+ id="link-href",
+ ),
+ pytest.param(
+ f'<img src="{TARGET_URL}" alt="">',
+ SOURCE_URL,
+ TARGET_URL,
+ True,
+ id="img-src",
+ ),
+ pytest.param(
+ f'<audio src="{TARGET_URL}"></audio>',
+ SOURCE_URL,
+ TARGET_URL,
+ True,
+ id="audio-src",
+ ),
+ pytest.param(
+ f'<video src="{TARGET_URL}"></video>',
+ SOURCE_URL,
+ TARGET_URL,
+ True,
+ id="video-src",
+ ),
+ pytest.param(
+ (f'<audio><source src="{TARGET_URL}"></audio>'),
+ SOURCE_URL,
+ TARGET_URL,
+ True,
+ id="audio-source-src",
+ ),
+ pytest.param(
+ (f'<video><source src="{TARGET_URL}"></video>'),
+ SOURCE_URL,
+ TARGET_URL,
+ True,
+ id="video-source-src",
+ ),
+ pytest.param(
+ f'<iframe src="{TARGET_URL}"></iframe>',
+ SOURCE_URL,
+ TARGET_URL,
+ True,
+ id="iframe-src",
+ ),
+ pytest.param(
+ f'<embed src="{TARGET_URL}">',
+ SOURCE_URL,
+ TARGET_URL,
+ True,
+ id="embed-src",
+ ),
+ pytest.param(
+ f'<script src="{TARGET_URL}"></script>',
+ SOURCE_URL,
+ TARGET_URL,
+ True,
+ id="script-src",
+ ),
+ pytest.param(
+ (f'<video><track src="{TARGET_URL}"></video>'),
+ SOURCE_URL,
+ TARGET_URL,
+ True,
+ id="track-src",
+ ),
+ pytest.param(
+ f'<input type="image" src="{TARGET_URL}" alt="">',
+ SOURCE_URL,
+ TARGET_URL,
+ True,
+ id="image-input-src",
+ ),
+ pytest.param(
+ f'<input type="IMAGE" src="{TARGET_URL}" alt="">',
+ SOURCE_URL,
+ TARGET_URL,
+ True,
+ id="image-input-case-insensitive",
+ ),
+ pytest.param(
+ f'<blockquote cite="{TARGET_URL}">Quotation</blockquote>',
+ SOURCE_URL,
+ TARGET_URL,
+ True,
+ id="blockquote-cite",
+ ),
+ pytest.param(
+ f'<q cite="{TARGET_URL}">Quotation</q>',
+ SOURCE_URL,
+ TARGET_URL,
+ True,
+ id="q-cite",
+ ),
+ pytest.param(
+ f'<ins cite="{TARGET_URL}">Addition</ins>',
+ SOURCE_URL,
+ TARGET_URL,
+ True,
+ id="ins-cite",
+ ),
+ pytest.param(
+ f'<del cite="{TARGET_URL}">Removal</del>',
+ SOURCE_URL,
+ TARGET_URL,
+ True,
+ id="del-cite",
+ ),
+ pytest.param(
+ '<a href="../target/">Reply</a>',
+ "https://source.example/posts/article/",
+ "https://source.example/posts/target/",
+ True,
+ id="relative-href",
+ ),
+ pytest.param(
+ '<base href="https://dennisfink.me/blog/"><a href="example/">Reply</a>',
+ SOURCE_URL,
+ TARGET_URL,
+ True,
+ id="base-url",
+ ),
+ pytest.param(
+ f'<img cite="{TARGET_URL}" alt="">',
+ SOURCE_URL,
+ TARGET_URL,
+ False,
+ id="img-cite-invalid",
+ ),
+ pytest.param(
+ f'<blockquote src="{TARGET_URL}">Quote</blockquote>',
+ SOURCE_URL,
+ TARGET_URL,
+ False,
+ id="blockquote-src-invalid",
+ ),
+ pytest.param(
+ f'<a src="{TARGET_URL}">Reply</a>',
+ SOURCE_URL,
+ TARGET_URL,
+ False,
+ id="a-src-invalid",
+ ),
+ pytest.param(
+ f'<div href="{TARGET_URL}"></div>',
+ SOURCE_URL,
+ TARGET_URL,
+ False,
+ id="div-href-invalid",
+ ),
+ pytest.param(
+ f'<link src="{TARGET_URL}">',
+ SOURCE_URL,
+ TARGET_URL,
+ False,
+ id="link-src-invalid",
+ ),
+ pytest.param(
+ f'<input type="text" src="{TARGET_URL}">',
+ SOURCE_URL,
+ TARGET_URL,
+ False,
+ id="text-input-src-invalid",
+ ),
+ pytest.param(
+ (f'<picture><source src="{TARGET_URL}"></picture>'),
+ SOURCE_URL,
+ TARGET_URL,
+ False,
+ id="picture-source-src-invalid",
+ ),
+ pytest.param(
+ f'<base href="{TARGET_URL}">',
+ SOURCE_URL,
+ TARGET_URL,
+ False,
+ id="base-is-not-mention",
+ ),
+ pytest.param(
+ (f'<a href="{TARGET_URL}more">Different page</a>'),
+ SOURCE_URL,
+ TARGET_URL,
+ False,
+ id="longer-url",
+ ),
+ pytest.param(
+ (f'<img cite="{TARGET_URL}" src="https://example.com/image.jpg" alt="">'),
+ SOURCE_URL,
+ TARGET_URL,
+ False,
+ id="invalid-cite-does-not-override-valid-src",
+ ),
+ pytest.param(
+ f"<p>{TARGET_URL}</p>",
+ SOURCE_URL,
+ TARGET_URL,
+ False,
+ id="text-content",
+ ),
+ pytest.param(
+ '<a href="https://example.com/">Other site</a>',
+ SOURCE_URL,
+ TARGET_URL,
+ False,
+ id="missing-target",
+ ),
+ ],
+)
+def test_html_mentions_target(
+ receiver_module: ModuleType,
+ body: str,
+ source_url: str,
+ target_url: str,
+ expected: bool,
+) -> None:
+ assert (
+ receiver_module.html_mentions_target(
+ body.encode(),
+ source_url,
+ target_url,
+ )
+ is expected
+ )
+
+
+@pytest.mark.parametrize(
+ ("body", "target_url", "expected"),
+ [
+ (TARGET_URL, TARGET_URL, True),
+ (f"This post replies to {TARGET_URL}", TARGET_URL, True),
+ (
+ f"https://example.com/first {TARGET_URL} https://example.com/last",
+ TARGET_URL,
+ True,
+ ),
+ (f"{TARGET_URL}more", TARGET_URL, False),
+ ("https://dennisfink.me/blog/other/", TARGET_URL, False),
+ ("/blog/example/", TARGET_URL, False),
+ ("There are no links here.", TARGET_URL, False),
+ ],
+)
+def test_text_mentions_target(
+ receiver_module: ModuleType,
+ body: str,
+ target_url: str,
+ expected: bool,
+) -> None:
+ assert (
+ receiver_module.text_mentions_target(
+ body,
+ target_url,
+ )
+ is expected
+ )
+
+
+def test_fetch_source_returns_response_and_body(
+ app: Flask,
+ receiver_module: ModuleType,
+ install_httpx_mock: HTTPXMockInstaller,
+) -> None:
+ captured_request: httpx.Request | None = None
+
+ def handler(
+ request: httpx.Request,
+ ) -> httpx.Response:
+ nonlocal captured_request
+ captured_request = request
+
+ return httpx.Response(
+ 200,
+ headers={
+ "Content-Type": "text/html",
+ },
+ content=b"<p>Document</p>",
+ )
+
+ install_httpx_mock(handler)
+
+ with app.app_context():
+ response, body = receiver_module.fetch_source(SOURCE_URL)
+
+ assert response.status_code == 200
+ assert body == b"<p>Document</p>"
+
+ assert captured_request is not None
+ assert captured_request.url == SOURCE_URL
+
+ accept = captured_request.headers["Accept"]
+
+ assert "text/html" in accept
+ assert "application/xhtml+xml" in accept
+ assert "text/plain" in accept
+
+ assert captured_request.headers["User-Agent"] == (
+ f"{receiver_module.APP_NAME}/{receiver_module.VERSION} ReceivedWebmention"
+ )
+
+
+@pytest.mark.parametrize(
+ ("status_code", "exception_name"),
+ [
+ (400, "VerificationError"),
+ (404, "VerificationError"),
+ (410, "SourceGoneError"),
+ (408, "TemporaryFetchError"),
+ (425, "TemporaryFetchError"),
+ (429, "TemporaryFetchError"),
+ (500, "TemporaryFetchError"),
+ (503, "TemporaryFetchError"),
+ ],
+)
+def test_fetch_source_maps_http_status_to_exception(
+ app: Flask,
+ receiver_module: ModuleType,
+ install_httpx_mock: HTTPXMockInstaller,
+ status_code: int,
+ exception_name: str,
+) -> None:
+ install_httpx_mock(lambda request: httpx.Response(status_code))
+
+ exception_type = getattr(
+ receiver_module,
+ exception_name,
+ )
+
+ with (
+ app.app_context(),
+ pytest.raises(
+ exception_type,
+ match=f"HTTP {status_code}",
+ ),
+ ):
+ receiver_module.fetch_source(SOURCE_URL)
+
+
+def test_fetch_source_propagates_network_error(
+ app: Flask,
+ receiver_module: ModuleType,
+ install_httpx_mock: HTTPXMockInstaller,
+) -> None:
+ def handler(
+ request: httpx.Request,
+ ) -> httpx.Response:
+ raise httpx.ConnectError(
+ "Connection refused",
+ request=request,
+ )
+
+ install_httpx_mock(handler)
+
+ with (
+ app.app_context(),
+ pytest.raises(
+ httpx.ConnectError,
+ match="Connection refused",
+ ),
+ ):
+ receiver_module.fetch_source(SOURCE_URL)
+
+
+def test_fetch_source_follows_redirect(
+ app: Flask,
+ receiver_module: ModuleType,
+ install_httpx_mock: HTTPXMockInstaller,
+) -> None:
+ requested_paths: list[str] = []
+
+ def handler(
+ request: httpx.Request,
+ ) -> httpx.Response:
+ requested_paths.append(request.url.path)
+
+ match request.url.path:
+ case "/start":
+ return httpx.Response(
+ 302,
+ headers={
+ "Location": "/final",
+ },
+ )
+
+ case "/final":
+ return httpx.Response(
+ 200,
+ headers={
+ "Content-Type": "text/html",
+ },
+ content=b"Final document",
+ )
+
+ case _:
+ raise AssertionError(f"Unexpected URL: {request.url}")
+
+ install_httpx_mock(handler)
+
+ with app.app_context():
+ response, body = receiver_module.fetch_source("https://source.example/start")
+
+ assert requested_paths == [
+ "/start",
+ "/final",
+ ]
+ assert response.url.path == "/final"
+ assert body == b"Final document"
+
+
+def test_fetch_source_enforces_redirect_limit(
+ app: Flask,
+ receiver_module: ModuleType,
+ install_httpx_mock: HTTPXMockInstaller,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setitem(
+ app.config,
+ "WEBMENTIONS_SSG_MAX_REDIRECTS",
+ 1,
+ )
+
+ install_httpx_mock(
+ lambda request: httpx.Response(
+ 302,
+ headers={
+ "Location": "/another",
+ },
+ )
+ )
+
+ with (
+ app.app_context(),
+ pytest.raises(httpx.TooManyRedirects),
+ ):
+ receiver_module.fetch_source("https://source.example/start")
+
+
+def test_fetch_source_rejects_declared_oversized_body(
+ app: Flask,
+ receiver_module: ModuleType,
+ install_httpx_mock: HTTPXMockInstaller,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setitem(
+ app.config,
+ "WEBMENTIONS_SSG_MAX_SOURCE_BYTES",
+ 10,
+ )
+
+ install_httpx_mock(
+ lambda request: httpx.Response(
+ 200,
+ headers={
+ "Content-Type": "text/html",
+ "Content-Length": "11",
+ },
+ content=b"x" * 11,
+ )
+ )
+
+ with (
+ app.app_context(),
+ pytest.raises(
+ receiver_module.VerificationError,
+ match="Source document is too large",
+ ),
+ ):
+ receiver_module.fetch_source(SOURCE_URL)
+
+
+def test_fetch_source_rejects_streamed_oversized_body(
+ app: Flask,
+ receiver_module: ModuleType,
+ install_httpx_mock: HTTPXMockInstaller,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setitem(
+ app.config,
+ "WEBMENTIONS_SSG_MAX_SOURCE_BYTES",
+ 10,
+ )
+
+ class BodyStream(httpx.SyncByteStream):
+ def __iter__(self):
+ yield b"x" * 6
+ yield b"x" * 6
+
+ install_httpx_mock(
+ lambda request: httpx.Response(
+ 200,
+ headers={
+ "Content-Type": "text/html",
+ },
+ stream=BodyStream(),
+ )
+ )
+
+ with (
+ app.app_context(),
+ pytest.raises(
+ receiver_module.VerificationError,
+ match="Source document is too large",
+ ),
+ ):
+ receiver_module.fetch_source(SOURCE_URL)
+
+
+@pytest.mark.parametrize(
+ ("content_type", "body", "expected"),
+ [
+ pytest.param(
+ "text/html; charset=utf-8",
+ f'<a href="{TARGET_URL}">Reply</a>'.encode(),
+ True,
+ id="html",
+ ),
+ pytest.param(
+ "TEXT/HTML; CHARSET=UTF-8",
+ f'<a href="{TARGET_URL}">Reply</a>'.encode(),
+ True,
+ id="case-insensitive-html",
+ ),
+ pytest.param(
+ "application/xhtml+xml",
+ b'<a href="https://example.com/">Other</a>',
+ False,
+ id="xhtml-without-target",
+ ),
+ pytest.param(
+ "text/plain; charset=utf-8",
+ f"Reply to {TARGET_URL}".encode(),
+ True,
+ id="plain-text-utf-8",
+ ),
+ pytest.param(
+ "text/plain; charset=iso-8859-1",
+ (f"Grüße. Reply to {TARGET_URL}").encode("iso-8859-1"),
+ True,
+ id="plain-text-declared-encoding",
+ ),
+ pytest.param(
+ "text/plain; charset=utf-8",
+ b"\xff Reply to " + TARGET_URL.encode(),
+ True,
+ id="plain-text-invalid-byte",
+ ),
+ pytest.param(
+ "text/plain",
+ b"No target here.",
+ False,
+ id="plain-text-without-target",
+ ),
+ ],
+)
+def test_source_mentions_target_by_media_type(
+ receiver_module: ModuleType,
+ monkeypatch: pytest.MonkeyPatch,
+ content_type: str,
+ body: bytes,
+ expected: bool,
+) -> None:
+ response = httpx.Response(
+ 200,
+ headers={
+ "Content-Type": content_type,
+ },
+ content=body,
+ request=httpx.Request(
+ "GET",
+ SOURCE_URL,
+ ),
+ )
+
+ monkeypatch.setattr(
+ receiver_module,
+ "fetch_source",
+ lambda source_url: (
+ response,
+ body,
+ ),
+ )
+
+ assert (
+ receiver_module.source_mentions_target(
+ SOURCE_URL,
+ TARGET_URL,
+ )
+ is expected
+ )
+
+
+@pytest.mark.parametrize(
+ ("content_type", "expected_media_type"),
+ [
+ ("application/json", "application/json"),
+ ("application/pdf", "application/pdf"),
+ ("", "missing"),
+ ],
+)
+def test_source_mentions_target_rejects_unsupported_media_type(
+ receiver_module: ModuleType,
+ monkeypatch: pytest.MonkeyPatch,
+ content_type: str,
+ expected_media_type: str,
+) -> None:
+ headers = {}
+
+ if content_type:
+ headers["Content-Type"] = content_type
+
+ response = httpx.Response(
+ 200,
+ headers=headers,
+ content=b"Document",
+ request=httpx.Request(
+ "GET",
+ SOURCE_URL,
+ ),
+ )
+
+ monkeypatch.setattr(
+ receiver_module,
+ "fetch_source",
+ lambda source_url: (
+ response,
+ b"Document",
+ ),
+ )
+
+ with pytest.raises(
+ receiver_module.VerificationError,
+ match=(f"Unsupported source content type: {expected_media_type}"),
+ ):
+ receiver_module.source_mentions_target(
+ SOURCE_URL,
+ TARGET_URL,
+ )
+
+
+def test_verify_webmention_marks_row_verifying_before_check(
+ app: Flask,
+ receiver_module: ModuleType,
+ make_webmention: ReceivedWebmentionFactory,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ identifier = make_webmention(
+ status="failed",
+ failure_reason="Earlier failure",
+ )
+
+ def verify_source(
+ source_url: str,
+ target_url: str,
+ ) -> bool:
+ webmention = db.session.get(
+ ReceivedWebmention,
+ identifier,
+ )
+
+ assert webmention is not None
+ assert webmention.status == "verifying"
+ assert webmention.failure_reason is None
+ assert source_url == webmention.source
+ assert target_url == webmention.target
+
+ return True
+
+ monkeypatch.setattr(
+ receiver_module,
+ "source_mentions_target",
+ verify_source,
+ )
+
+ receiver_module.verify_webmention.call_local(identifier)
+
+ assert get_webmention_state(
+ app,
+ identifier,
+ ) == (
+ "verified",
+ None,
+ )
+
+
+@pytest.mark.parametrize(
+ ("outcome", "expected_status", "expected_reason"),
+ [
+ ("verified", "verified", None),
+ ("missing", "deleted", "Source does not mention target"),
+ ("gone", "deleted", "Source returned HTTP 410"),
+ ("permanent-failure", "failed", "Source returned HTTP 404"),
+ ],
+)
+def test_verify_webmention_persists_final_state(
+ app: Flask,
+ receiver_module: ModuleType,
+ make_webmention: ReceivedWebmentionFactory,
+ monkeypatch: pytest.MonkeyPatch,
+ outcome: str,
+ expected_status: str,
+ expected_reason: str | None,
+) -> None:
+ identifier = make_webmention(
+ status="received",
+ )
+
+ def verify_source(
+ source_url: str,
+ target_url: str,
+ ) -> bool:
+ match outcome:
+ case "verified":
+ return True
+
+ case "missing":
+ return False
+
+ case "gone":
+ raise receiver_module.SourceGoneError("Source returned HTTP 410")
+
+ case "permanent-failure":
+ raise receiver_module.VerificationError("Source returned HTTP 404")
+
+ case _:
+ raise AssertionError(f"Unexpected outcome: {outcome}")
+
+ monkeypatch.setattr(
+ receiver_module,
+ "source_mentions_target",
+ verify_source,
+ )
+
+ receiver_module.verify_webmention.call_local(identifier)
+
+ assert get_webmention_state(
+ app,
+ identifier,
+ ) == (
+ expected_status,
+ expected_reason,
+ )
+
+
+@pytest.mark.parametrize(
+ "failure",
+ [
+ "temporary-http",
+ "network",
+ ],
+)
+def test_verify_webmention_persists_retryable_failure_and_reraises(
+ app: Flask,
+ receiver_module: ModuleType,
+ make_webmention: ReceivedWebmentionFactory,
+ monkeypatch: pytest.MonkeyPatch,
+ failure: str,
+) -> None:
+ identifier = make_webmention()
+
+ def verify_source(
+ source_url: str,
+ target_url: str,
+ ) -> bool:
+ match failure:
+ case "temporary-http":
+ raise receiver_module.TemporaryFetchError("Source returned HTTP 503")
+
+ case "network":
+ raise httpx.ConnectError(
+ "Connection refused",
+ request=httpx.Request(
+ "GET",
+ source_url,
+ ),
+ )
+
+ case _:
+ raise AssertionError(f"Unexpected failure: {failure}")
+
+ monkeypatch.setattr(
+ receiver_module,
+ "source_mentions_target",
+ verify_source,
+ )
+
+ match failure:
+ case "temporary-http":
+ expected_exception = receiver_module.TemporaryFetchError
+ expected_reason = "Source returned HTTP 503"
+
+ case "network":
+ expected_exception = httpx.ConnectError
+ expected_reason = "Connection refused"
+
+ case _:
+ raise AssertionError(f"Unexpected failure: {failure}")
+
+ with pytest.raises(
+ expected_exception,
+ match=expected_reason,
+ ):
+ receiver_module.verify_webmention.call_local(identifier)
+
+ assert get_webmention_state(
+ app,
+ identifier,
+ ) == (
+ "failed",
+ expected_reason,
+ )
+
+
+def test_verify_webmention_ignores_unknown_identifier(
+ receiver_module: ModuleType,
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ identifier = uuid.uuid7()
+
+ with caplog.at_level(logging.WARNING):
+ receiver_module.verify_webmention.call_local(identifier)
+
+ assert f"Cannot verify unknown ReceivedWebmention {identifier}" in caplog.text
diff --git a/tests/test_forms.py b/tests/test_forms.py
new file mode 100644
index 0000000..9be6ca8
--- /dev/null
+++ b/tests/test_forms.py
@@ -0,0 +1,130 @@
+import pytest
+from flask import Flask
+from werkzeug.datastructures import MultiDict
+
+from webmentions_ssg.forms import EndpointForm
+
+VALID_SOURCE = "https://source.example/post"
+VALID_TARGET = "https://dennisfink.me/blog/example/"
+
+
+@pytest.mark.parametrize(
+ (
+ "form_data",
+ "invalid_field",
+ "expected_error",
+ ),
+ [
+ pytest.param(
+ {
+ "target": VALID_TARGET,
+ },
+ "source",
+ "This field is required.",
+ id="source-required",
+ ),
+ pytest.param(
+ {
+ "source": "not a URL",
+ "target": VALID_TARGET,
+ },
+ "source",
+ "Invalid URL.",
+ id="source-url",
+ ),
+ pytest.param(
+ {
+ "source": "ftp://source.example/post",
+ "target": VALID_TARGET,
+ },
+ "source",
+ "source must begin with http or https",
+ id="source-scheme",
+ ),
+ pytest.param(
+ {
+ "source": VALID_TARGET,
+ "target": VALID_TARGET,
+ },
+ "source",
+ None,
+ id="source-not-equal-to-target",
+ ),
+ pytest.param(
+ {
+ "source": VALID_SOURCE,
+ },
+ "target",
+ "This field is required.",
+ id="target-required",
+ ),
+ pytest.param(
+ {
+ "source": VALID_SOURCE,
+ "target": "not a URL",
+ },
+ "target",
+ "Invalid URL.",
+ id="target-url",
+ ),
+ pytest.param(
+ {
+ "source": VALID_SOURCE,
+ "target": "ftp://dennisfink.me/blog/example/",
+ },
+ "target",
+ "target must begin with http or https",
+ id="target-scheme",
+ ),
+ pytest.param(
+ {
+ "source": VALID_SOURCE,
+ "target": "https://example.com/post",
+ },
+ "target",
+ None,
+ id="target-allowed-hostname",
+ ),
+ ],
+)
+def test_endpoint_form_rejects_invalid_data(
+ app: Flask,
+ form_data: dict[str, str],
+ invalid_field: str,
+ expected_error: str | None,
+) -> None:
+ with app.test_request_context(
+ "/endpoint",
+ method="POST",
+ ):
+ form = EndpointForm(
+ formdata=MultiDict(form_data),
+ meta={"csrf": False},
+ )
+
+ assert not form.validate()
+ assert invalid_field in form.errors
+
+ if expected_error is not None:
+ assert expected_error in form.errors[invalid_field]
+
+
+def test_endpoint_form_accepts_valid_data(
+ app: Flask,
+) -> None:
+ with app.test_request_context(
+ "/endpoint",
+ method="POST",
+ ):
+ form = EndpointForm(
+ formdata=MultiDict(
+ {
+ "source": VALID_SOURCE,
+ "target": VALID_TARGET,
+ }
+ ),
+ meta={"csrf": False},
+ )
+
+ assert form.validate()
+ assert form.errors == {}
diff --git a/tests/test_views.py b/tests/test_views.py
new file mode 100644
index 0000000..d7fd798
--- /dev/null
+++ b/tests/test_views.py
@@ -0,0 +1,183 @@
+import uuid
+from types import ModuleType
+
+import pytest
+import sqlalchemy as sa
+from flask import Flask
+from flask.testing import FlaskClient
+
+from webmentions_ssg import DATABASE as db
+from webmentions_ssg.models import ReceivedWebmention
+
+
+def test_endpoint_only_accepts_post(
+ client: FlaskClient,
+) -> None:
+ response = client.get("/endpoint")
+
+ assert response.status_code == 405
+
+
+def test_endpoint_returns_form_errors(
+ client: FlaskClient,
+) -> None:
+ response = client.post(
+ "/endpoint",
+ data={
+ "source": "https://source.example/post",
+ "target": "https://example.com/post",
+ },
+ )
+
+ assert response.status_code == 400
+
+ errors = response.get_json()
+
+ assert errors is not None
+ assert "target" in errors
+
+
+def test_endpoint_creates_webmention(
+ app: Flask,
+ client: FlaskClient,
+ views_module: ModuleType,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ queued: list[uuid.UUID] = []
+
+ monkeypatch.setattr(
+ views_module,
+ "verify_webmention",
+ queued.append,
+ )
+
+ source = "https://source.example/post"
+ target = "https://dennisfink.me/blog/example/"
+
+ response = client.post(
+ "/endpoint",
+ data={
+ "source": source,
+ "target": target,
+ },
+ )
+
+ assert response.status_code == 201
+
+ with app.app_context():
+ webmention = db.session.scalar(sa.select(ReceivedWebmention))
+
+ assert webmention is not None
+ assert webmention.source == source
+ assert webmention.target == target
+ assert webmention.status == "received"
+ assert webmention.failure_reason is None
+
+ identifier = webmention.uuid
+
+ assert response.headers["Location"].endswith(str(identifier))
+
+ assert queued == [identifier]
+
+
+def test_endpoint_is_idempotent(
+ app: Flask,
+ client: FlaskClient,
+ views_module: ModuleType,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ queued: list[uuid.UUID] = []
+
+ monkeypatch.setattr(
+ views_module,
+ "verify_webmention",
+ queued.append,
+ )
+
+ data = {
+ "source": ("https://source.example/post"),
+ "target": ("https://dennisfink.me/blog/example/"),
+ }
+
+ first_response = client.post(
+ "/endpoint",
+ data=data,
+ )
+ second_response = client.post(
+ "/endpoint",
+ data=data,
+ )
+
+ assert first_response.status_code == 201
+ assert second_response.status_code == 201
+
+ assert first_response.headers["Location"] == second_response.headers["Location"]
+
+ with app.app_context():
+ webmentions = db.session.scalars(sa.select(ReceivedWebmention)).all()
+
+ assert len(webmentions) == 1
+
+ webmention = webmentions[0]
+
+ assert webmention.source == data["source"]
+ assert webmention.target == data["target"]
+ assert webmention.status == "received"
+ assert webmention.failure_reason is None
+
+ identifier = webmention.uuid
+
+ assert queued == [
+ identifier,
+ identifier,
+ ]
+
+
+def test_resending_resets_failure_state(
+ app: Flask,
+ client: FlaskClient,
+ views_module: ModuleType,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr(
+ views_module,
+ "verify_webmention",
+ lambda identifier: None,
+ )
+
+ source = "https://source.example/post"
+ target = "https://dennisfink.me/blog/example/"
+
+ with app.app_context():
+ existing = ReceivedWebmention(
+ uuid=uuid.uuid7(),
+ source=source,
+ target=target,
+ status="failed",
+ failure_reason="Previous failure",
+ )
+
+ db.session.add(existing)
+ db.session.commit()
+
+ identifier = existing.uuid
+
+ response = client.post(
+ "/endpoint",
+ data={
+ "source": source,
+ "target": target,
+ },
+ )
+
+ assert response.status_code == 201
+
+ with app.app_context():
+ webmention = db.session.get(
+ ReceivedWebmention,
+ identifier,
+ )
+
+ assert webmention is not None
+ assert webmention.status == "received"
+ assert webmention.failure_reason is None
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
+ )