diff options
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 @@ -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 + ) |
