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--pyproject.toml32
-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--uv.lock354
-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
27 files changed, 3079 insertions, 9 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/pyproject.toml b/pyproject.toml
index 28d6627..9da3352 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -14,17 +14,33 @@ authors = [
readme = "README.md"
requires-python = ">=3.14"
dependencies = [
+ "beautifulsoup4>=4.15.0",
"bootstrap-flask>=2.5.0",
+ "coloredlogs>=15.0.1",
"flask>=3.1.3",
- "flask-httpauth>=4.8.1",
+ "flask-login>=0.6.3",
+ "flask-migrate>=4.1.0",
"flask-sqlalchemy>=3.1.1",
"flask-wtf>=1.3.0",
+ "httpx>=0.28.1",
+ "huey>=3.1.0",
+ "rfc3987>=1.3.8",
]
[project.urls]
Repository = "https://codeberg.org/metalgamer/webmentions-ssg"
Issues = "https://codeberg.org/metalgamer/webmentions-ssg/issues"
+[dependency-groups]
+dev = [
+ "python-dotenv>=1.2.2",
+ "types-wtforms>=3.2.1.20260518",
+]
+test = [
+ "pytest>=9.1.1",
+ "pytest-cov>=7.1.0",
+]
+
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
@@ -42,3 +58,17 @@ include = [
"README.md",
"pyproject.toml"
]
+
+[tool.pytest.ini_options]
+pythonpath = ["."]
+testpaths = ["tests"]
+addopts = [
+ "-ra",
+ "--strict-config",
+ "--strict-markers",
+ "--cov=webmentions_ssg",
+ "--cov-report=html",
+]
+
+[tool.uv]
+default-groups = ["dev", "test"]
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/uv.lock b/uv.lock
index 7588ef1..cee73c3 100644
--- a/uv.lock
+++ b/uv.lock
@@ -3,6 +3,45 @@ revision = 3
requires-python = ">=3.14"
[[package]]
+name = "alembic"
+version = "1.18.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mako" },
+ { name = "sqlalchemy" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1a/cc/ac0bed8e562e7407fe55c3ba85a4dce86e6dbd8730887bd1e406a6c5c18a/alembic-1.18.5.tar.gz", hash = "sha256:1554982221dd17e9a749b53902407578eb305e453f71999e8c7f0a48389fff8e", size = 2060480, upload-time = "2026-06-25T15:20:54.888Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/96/78/5fe6dc3a3a5b2f5a2a4faef8bfe336d5fa049a38884ab3172e0098160c01/alembic-1.18.5-py3-none-any.whl", hash = "sha256:06d8ba9d04558022f5395e9317de03d270f3dced49cee01f89fe7a13c26f14bc", size = 264664, upload-time = "2026-06-25T15:20:56.673Z" },
+]
+
+[[package]]
+name = "anyio"
+version = "4.14.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
+]
+
+[[package]]
+name = "beautifulsoup4"
+version = "4.15.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "soupsieve" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" },
+]
+
+[[package]]
name = "blinker"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
@@ -25,6 +64,15 @@ wheels = [
]
[[package]]
+name = "certifi"
+version = "2026.7.22"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
+]
+
+[[package]]
name = "click"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
@@ -46,6 +94,57 @@ wheels = [
]
[[package]]
+name = "coloredlogs"
+version = "15.0.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "humanfriendly" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" },
+]
+
+[[package]]
+name = "coverage"
+version = "7.15.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" },
+ { url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" },
+ { url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" },
+ { url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" },
+ { url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" },
+ { url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" },
+ { url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" },
+ { url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" },
+]
+
+[[package]]
name = "flask"
version = "3.1.3"
source = { registry = "https://pypi.org/simple" }
@@ -63,15 +162,30 @@ wheels = [
]
[[package]]
-name = "flask-httpauth"
-version = "4.8.1"
+name = "flask-login"
+version = "0.6.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flask" },
+ { name = "werkzeug" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/ec/f4/6957215e827021eeb7d8de9f59b1864d73933b04851e59272708cb6e5d2b/flask_httpauth-4.8.1.tar.gz", hash = "sha256:88499b22f1353893743c3cd68f2ca561c4ad9ef75cd6bcc7f621161cd0e80744", size = 38993, upload-time = "2026-03-28T19:45:24.254Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/6e/2f4e13e373bb49e68c02c51ceadd22d172715a06716f9299d9df01b6ddb2/Flask-Login-0.6.3.tar.gz", hash = "sha256:5e23d14a607ef12806c699590b89d0f0e0d67baeec599d75947bf9c147330333", size = 48834, upload-time = "2023-10-30T14:53:21.151Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/72/da/624c87bf6c13107ceab8ee23815d9468e47d89c7480c1dc9af39b08eb290/flask_httpauth-4.8.1-py3-none-any.whl", hash = "sha256:0080393d70e12327781f7509115175ec5e47209816489a620d4fd39e20cea2e8", size = 9651, upload-time = "2026-03-28T19:45:23.155Z" },
+ { url = "https://files.pythonhosted.org/packages/59/f5/67e9cc5c2036f58115f9fe0f00d203cf6780c3ff8ae0e705e7a9d9e8ff9e/Flask_Login-0.6.3-py3-none-any.whl", hash = "sha256:849b25b82a436bf830a054e74214074af59097171562ab10bfa999e6b78aae5d", size = 17303, upload-time = "2023-10-30T14:53:19.636Z" },
+]
+
+[[package]]
+name = "flask-migrate"
+version = "4.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "alembic" },
+ { name = "flask" },
+ { name = "flask-sqlalchemy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/47c7b3c93855ceffc2eabfa271782332942443321a07de193e4198f920cf/flask_migrate-4.1.0.tar.gz", hash = "sha256:1a336b06eb2c3ace005f5f2ded8641d534c18798d64061f6ff11f79e1434126d", size = 21965, upload-time = "2025-01-10T18:51:11.848Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d2/c4/3f329b23d769fe7628a5fc57ad36956f1fb7132cf8837be6da762b197327/Flask_Migrate-4.1.0-py3-none-any.whl", hash = "sha256:24d8051af161782e0743af1b04a152d007bad9772b2bca67b7ec1e8ceeb3910d", size = 21237, upload-time = "2025-01-10T18:51:09.527Z" },
]
[[package]]
@@ -141,6 +255,82 @@ wheels = [
]
[[package]]
+name = "h11"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
+]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "certifi" },
+ { name = "httpcore" },
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
+]
+
+[[package]]
+name = "huey"
+version = "3.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b0/10/fb09d6746df436043abc31406232d63fdb6ff46e0295b9385c9b5ead2abb/huey-3.1.0.tar.gz", hash = "sha256:0661b751e921c5109cbf7f38c163363e37ad44be8f5eec7b2fd9544d224cb001", size = 281082, upload-time = "2026-07-02T17:36:34.758Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/43/d7/c04762268ff66b33e7006e5a2e550e5ac3c3a27e949235494fb0bc5bb560/huey-3.1.0-py3-none-any.whl", hash = "sha256:4ac5e1aeed4ee0955a2dbde1f1b5f61b6764c8a94d1fdcf7e82463010d3942df", size = 98944, upload-time = "2026-07-02T17:36:33.434Z" },
+]
+
+[[package]]
+name = "humanfriendly"
+version = "10.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyreadline3", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.18"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[package]]
name = "itsdangerous"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
@@ -162,6 +352,18 @@ wheels = [
]
[[package]]
+name = "mako"
+version = "1.3.12"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" },
+]
+
+[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
@@ -192,6 +394,99 @@ wheels = [
]
[[package]]
+name = "packaging"
+version = "26.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.20.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
+]
+
+[[package]]
+name = "pyreadline3"
+version = "3.5.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b6/6d/f94028646d7bbe6d9d873c47ee7c246f2d29129d253f0d96cb6fcab70733/pyreadline3-3.5.6.tar.gz", hash = "sha256:61e53218b99656091ddb077df9e71f25850e72e030b6183b39c9b7e6e4f4a9bf", size = 100368, upload-time = "2026-05-14T17:55:04.471Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f7/5e/35c856e186b74678c24927847ad9895a51f1bc02a0c6126477a6c6040064/pyreadline3-3.5.6-py3-none-any.whl", hash = "sha256:8449b734232e42a5dcd74048e39b60db2839a4c38cf3ae2bf7707d58b5389c0d", size = 85243, upload-time = "2026-05-14T17:55:03.262Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
+]
+
+[[package]]
+name = "pytest-cov"
+version = "7.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "coverage" },
+ { name = "pluggy" },
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
+]
+
+[[package]]
+name = "python-dotenv"
+version = "1.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
+]
+
+[[package]]
+name = "rfc3987"
+version = "1.3.8"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/14/bb/f1395c4b62f251a1cb503ff884500ebd248eed593f41b469f89caa3547bd/rfc3987-1.3.8.tar.gz", hash = "sha256:d3c4d257a560d544e9826b38bc81db676890c79ab9d7ac92b39c7a253d5ca733", size = 20700, upload-time = "2018-07-29T17:23:47.954Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/65/d4/f7407c3d15d5ac779c3dd34fbbc6ea2090f77bd7dd12f207ccf881551208/rfc3987-1.3.8-py2.py3-none-any.whl", hash = "sha256:10702b1e51e5658843460b189b185c0366d2cf4cff716f13111b0ea9fd2dce53", size = 13377, upload-time = "2018-07-29T17:23:45.313Z" },
+]
+
+[[package]]
+name = "soupsieve"
+version = "2.9.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d9/38/e12680bbe6b4f8f3d17adcaf38d26850aa756c85cf4a80e79fc12a018fe8/soupsieve-2.9.1.tar.gz", hash = "sha256:c33e6605bbc71dd628b00c632d58ae607c22bade247e52553928f83bbb75b4ba", size = 122261, upload-time = "2026-07-21T16:57:17.452Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0f/2c/437fe806897c2d6cfdc3ee43a18da8bf8e568530a4ae9bac781541ca9896/soupsieve-2.9.1-py3-none-any.whl", hash = "sha256:4f4477399246b7a0c720a88ca2454b11cd6bb9ae4c9d170140786e916776c14c", size = 37404, upload-time = "2026-07-21T16:57:16.421Z" },
+]
+
+[[package]]
name = "sqlalchemy"
version = "2.0.51"
source = { registry = "https://pypi.org/simple" }
@@ -219,6 +514,18 @@ wheels = [
]
[[package]]
+name = "types-wtforms"
+version = "3.2.1.20260518"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bf/f0/0c84f7175c56089ba0c000d1eb4f20b144721b7446b90280625f3c8ce23b/types_wtforms-3.2.1.20260518.tar.gz", hash = "sha256:05fb7adfc8cdd72c7b597e20b74f6270511ca492a80953805f25ee7d1cfd1282", size = 17505, upload-time = "2026-05-18T06:07:30.769Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6e/94/0da3294e8c4fd4b64e96d57f7a768fb57e3b2d77ac0aed142c25fe1f28d5/types_wtforms-3.2.1.20260518-py3-none-any.whl", hash = "sha256:193377e60e715920b47481fbc62a7f3796045d0492e02a46319c5064eee81e23", size = 24303, upload-time = "2026-05-18T06:07:29.866Z" },
+]
+
+[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
@@ -229,23 +536,54 @@ wheels = [
[[package]]
name = "webmentions-ssg"
-version = "0.0.1"
-source = { virtual = "." }
+source = { editable = "." }
dependencies = [
+ { name = "beautifulsoup4" },
{ name = "bootstrap-flask" },
+ { name = "coloredlogs" },
{ name = "flask" },
- { name = "flask-httpauth" },
+ { name = "flask-login" },
+ { name = "flask-migrate" },
{ name = "flask-sqlalchemy" },
{ name = "flask-wtf" },
+ { name = "httpx" },
+ { name = "huey" },
+ { name = "rfc3987" },
+]
+
+[package.dev-dependencies]
+dev = [
+ { name = "python-dotenv" },
+ { name = "types-wtforms" },
+]
+test = [
+ { name = "pytest" },
+ { name = "pytest-cov" },
]
[package.metadata]
requires-dist = [
+ { name = "beautifulsoup4", specifier = ">=4.15.0" },
{ name = "bootstrap-flask", specifier = ">=2.5.0" },
+ { name = "coloredlogs", specifier = ">=15.0.1" },
{ name = "flask", specifier = ">=3.1.3" },
- { name = "flask-httpauth", specifier = ">=4.8.1" },
+ { name = "flask-login", specifier = ">=0.6.3" },
+ { name = "flask-migrate", specifier = ">=4.1.0" },
{ name = "flask-sqlalchemy", specifier = ">=3.1.1" },
{ name = "flask-wtf", specifier = ">=1.3.0" },
+ { name = "httpx", specifier = ">=0.28.1" },
+ { name = "huey", specifier = ">=3.1.0" },
+ { name = "rfc3987", specifier = ">=1.3.8" },
+]
+
+[package.metadata.requires-dev]
+dev = [
+ { name = "python-dotenv", specifier = ">=1.2.2" },
+ { name = "types-wtforms", specifier = ">=3.2.1.20260518" },
+]
+test = [
+ { name = "pytest", specifier = ">=9.1.1" },
+ { name = "pytest-cov", specifier = ">=7.1.0" },
]
[[package]]
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
+ )