aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--migrations/versions/681a2a9a1bc0_.py66
-rw-r--r--pyproject.toml9
-rw-r--r--tests/tasks/test_receiver.py396
-rw-r--r--tests/tasks/test_scanner.py1558
-rw-r--r--tests/tasks/test_sender.py788
-rw-r--r--tests/test_forms.py80
-rw-r--r--tests/test_url_security.py54
-rw-r--r--tests/test_views.py202
-rw-r--r--uv.lock183
-rw-r--r--webmentions_ssg/config.py7
-rw-r--r--webmentions_ssg/forms/validators.py22
-rw-r--r--webmentions_ssg/models.py182
-rw-r--r--webmentions_ssg/tasks/consumer.py19
-rw-r--r--webmentions_ssg/tasks/extension.py5
-rw-r--r--webmentions_ssg/tasks/receiver.py65
-rw-r--r--webmentions_ssg/tasks/scanner.py397
-rw-r--r--webmentions_ssg/tasks/sender.py315
-rw-r--r--webmentions_ssg/templates/base.html5
-rw-r--r--webmentions_ssg/templates/received.html2
-rw-r--r--webmentions_ssg/templates/sent.html61
-rw-r--r--webmentions_ssg/templates/sent_source.html155
-rw-r--r--webmentions_ssg/url_security.py28
-rw-r--r--webmentions_ssg/views.py85
23 files changed, 4089 insertions, 595 deletions
diff --git a/migrations/versions/681a2a9a1bc0_.py b/migrations/versions/681a2a9a1bc0_.py
new file mode 100644
index 0000000..2420b79
--- /dev/null
+++ b/migrations/versions/681a2a9a1bc0_.py
@@ -0,0 +1,66 @@
+"""empty message
+
+Revision ID: 681a2a9a1bc0
+Revises: f63641044cc1
+Create Date: 2026-08-14 13:40:55.149604
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '681a2a9a1bc0'
+down_revision = 'f63641044cc1'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('sources',
+ sa.Column('uuid', sa.Uuid(), nullable=False),
+ sa.Column('path', sa.Text(), nullable=False),
+ sa.Column('url', sa.Text(), nullable=False),
+ sa.Column('content_hash', sa.String(length=32), nullable=False),
+ sa.Column('revision', sa.Integer(), nullable=False),
+ sa.Column('last_seen_at', sa.DateTime(timezone=True), nullable=False),
+ sa.Column('revised_at', sa.DateTime(timezone=True), nullable=False),
+ sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
+ sa.PrimaryKeyConstraint('uuid'),
+ sa.UniqueConstraint('path'),
+ sa.UniqueConstraint('url')
+ )
+ op.create_table('sent_webmentions',
+ sa.Column('uuid', sa.Uuid(), nullable=False),
+ sa.Column('source_id', sa.Uuid(), nullable=False),
+ sa.Column('target', sa.Text(), nullable=False),
+ sa.Column('active', sa.Boolean(), nullable=False),
+ sa.Column('desired_revision', sa.Integer(), nullable=False),
+ sa.Column('processed_revision', sa.Integer(), nullable=True),
+ sa.Column('sent_revision', sa.Integer(), nullable=True),
+ sa.Column('status', sa.Enum('sent', 'unsupported', 'failed', name='sent_webmention_status', native_enum=False), nullable=True),
+ sa.Column('failure_reason', sa.Text(), nullable=True),
+ sa.Column('endpoint', sa.Text(), nullable=True),
+ sa.Column('response_status', sa.Integer(), nullable=True),
+ sa.Column('status_url', sa.Text(), nullable=True),
+ sa.Column('last_attempted_at', sa.DateTime(timezone=True), nullable=True),
+ sa.Column('last_sent_at', sa.DateTime(timezone=True), nullable=True),
+ sa.ForeignKeyConstraint(['source_id'], ['sources.uuid'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('uuid'),
+ sa.UniqueConstraint('source_id', 'target', name='uq_sent_webmention_source_target')
+ )
+ with op.batch_alter_table('sent_webmentions', schema=None) as batch_op:
+ batch_op.create_index(batch_op.f('ix_sent_webmentions_source_id'), ['source_id'], unique=False)
+
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table('sent_webmentions', schema=None) as batch_op:
+ batch_op.drop_index(batch_op.f('ix_sent_webmentions_source_id'))
+
+ op.drop_table('sent_webmentions')
+ op.drop_table('sources')
+ # ### end Alembic commands ###
diff --git a/pyproject.toml b/pyproject.toml
index 9af850a..81dcd1b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -25,7 +25,9 @@ dependencies = [
"flask-wtf>=1.3.0",
"httpx>=0.28.1",
"huey>=3.1.0",
+ "mf2py>=2.0.1",
"rfc3987>=1.3.8",
+ "xxhash>=3.8.1",
]
[project.urls]
@@ -34,6 +36,7 @@ Issues = "https://codeberg.org/metalgamer/webmentions-ssg/issues"
[dependency-groups]
dev = [
+ "pyright>=1.1.411",
"python-dotenv>=1.2.2",
"types-wtforms>=3.2.1.20260518",
]
@@ -70,6 +73,12 @@ addopts = [
"--cov=webmentions_ssg",
"--cov-report=html",
]
+filterwarnings = [
+ "ignore:codecs\\.open\\(\\) is deprecated\\. Use open\\(\\) instead\\.:DeprecationWarning:mf2py\\.backcompat",
+]
[tool.uv]
default-groups = ["dev", "test"]
+
+[tool.ruff.format]
+skip-magic-trailing-comma = true
diff --git a/tests/tasks/test_receiver.py b/tests/tasks/test_receiver.py
index 19630c3..811ab63 100644
--- a/tests/tasks/test_receiver.py
+++ b/tests/tasks/test_receiver.py
@@ -9,10 +9,7 @@ from flask import Flask
from webmentions_ssg import DATABASE as db
from webmentions_ssg.models import ReceivedWebmention
-from webmentions_ssg.url_security import (
- AddressResolutionError,
- NonPublicAddressError,
-)
+from webmentions_ssg.url_security import AddressResolutionError
SOURCE_URL = "https://source.example/article"
TARGET_URL = "https://dennisfink.me/blog/example/"
@@ -50,28 +47,16 @@ def create_webmention(
return identifier
-def get_webmention_state(
- app: Flask,
- identifier: uuid.UUID,
-) -> tuple[str, str | None]:
+def get_webmention_state(app: Flask, identifier: uuid.UUID) -> tuple[str, str | None]:
with app.app_context():
- webmention = db.session.get(
- ReceivedWebmention,
- identifier,
- )
+ webmention = db.session.get(ReceivedWebmention, identifier)
assert webmention is not None
- return (
- webmention.status,
- webmention.failure_reason,
- )
+ return (webmention.status, webmention.failure_reason)
-def set_stream_response(
- httpx_client: Mock,
- response: httpx.Response,
-) -> Mock:
+def set_stream_response(httpx_client: Mock, response: httpx.Response) -> Mock:
client = httpx_client.return_value.__enter__.return_value
client.stream.return_value.__enter__.return_value = response
return client
@@ -80,65 +65,25 @@ def set_stream_response(
@pytest.mark.parametrize(
("body", "expected"),
[
+ pytest.param(f'<a href="{TARGET_URL}">Reply</a>', True, id="a-href"),
+ pytest.param(f'<area href="{TARGET_URL}" alt="Target">', True, id="area-href"),
pytest.param(
- f'<a href="{TARGET_URL}">Reply</a>',
- True,
- id="a-href",
- ),
- pytest.param(
- f'<area href="{TARGET_URL}" alt="Target">',
- True,
- id="area-href",
- ),
- pytest.param(
- f'<link href="{TARGET_URL}" rel="alternate">',
- True,
- id="link-href",
- ),
- pytest.param(
- f'<img src="{TARGET_URL}" alt="">',
- True,
- id="img-src",
- ),
- pytest.param(
- f'<audio src="{TARGET_URL}"></audio>',
- True,
- id="audio-src",
- ),
- pytest.param(
- f'<video src="{TARGET_URL}"></video>',
- True,
- id="video-src",
- ),
- pytest.param(
- f'<audio><source src="{TARGET_URL}"></audio>',
- True,
- id="audio-source-src",
+ f'<link href="{TARGET_URL}" rel="alternate">', True, id="link-href"
),
+ pytest.param(f'<img src="{TARGET_URL}" alt="">', True, id="img-src"),
+ pytest.param(f'<audio src="{TARGET_URL}"></audio>', True, id="audio-src"),
+ pytest.param(f'<video src="{TARGET_URL}"></video>', True, id="video-src"),
pytest.param(
- f'<video><source src="{TARGET_URL}"></video>',
- True,
- id="video-source-src",
+ f'<audio><source src="{TARGET_URL}"></audio>', True, id="audio-source-src"
),
pytest.param(
- f'<iframe src="{TARGET_URL}"></iframe>',
- True,
- id="iframe-src",
+ f'<video><source src="{TARGET_URL}"></video>', True, id="video-source-src"
),
+ pytest.param(f'<iframe src="{TARGET_URL}"></iframe>', True, id="iframe-src"),
+ pytest.param(f'<embed src="{TARGET_URL}">', True, id="embed-src"),
+ pytest.param(f'<script src="{TARGET_URL}"></script>', True, id="script-src"),
pytest.param(
- f'<embed src="{TARGET_URL}">',
- True,
- id="embed-src",
- ),
- pytest.param(
- f'<script src="{TARGET_URL}"></script>',
- True,
- id="script-src",
- ),
- pytest.param(
- f'<video><track src="{TARGET_URL}"></video>',
- True,
- id="track-src",
+ f'<video><track src="{TARGET_URL}"></video>', True, id="track-src"
),
pytest.param(
f'<input type="image" src="{TARGET_URL}" alt="">',
@@ -155,51 +100,23 @@ def set_stream_response(
True,
id="blockquote-cite",
),
- pytest.param(
- f'<q cite="{TARGET_URL}">Quotation</q>',
- True,
- id="q-cite",
- ),
- pytest.param(
- f'<ins cite="{TARGET_URL}">Addition</ins>',
- True,
- id="ins-cite",
- ),
- pytest.param(
- f'<del cite="{TARGET_URL}">Removal</del>',
- True,
- id="del-cite",
- ),
+ pytest.param(f'<q cite="{TARGET_URL}">Quotation</q>', True, id="q-cite"),
+ pytest.param(f'<ins cite="{TARGET_URL}">Addition</ins>', True, id="ins-cite"),
+ pytest.param(f'<del cite="{TARGET_URL}">Removal</del>', True, id="del-cite"),
pytest.param(
'<base href="https://dennisfink.me/blog/"><a href="example/">Reply</a>',
True,
id="base-url",
),
- pytest.param(
- f'<img cite="{TARGET_URL}" alt="">',
- False,
- id="img-cite-invalid",
- ),
+ pytest.param(f'<img cite="{TARGET_URL}" alt="">', False, id="img-cite-invalid"),
pytest.param(
f'<blockquote src="{TARGET_URL}">Quote</blockquote>',
False,
id="blockquote-src-invalid",
),
- pytest.param(
- f'<a src="{TARGET_URL}">Reply</a>',
- False,
- id="a-src-invalid",
- ),
- pytest.param(
- f'<div href="{TARGET_URL}"></div>',
- False,
- id="div-href-invalid",
- ),
- pytest.param(
- f'<link src="{TARGET_URL}">',
- False,
- id="link-src-invalid",
- ),
+ pytest.param(f'<a src="{TARGET_URL}">Reply</a>', False, id="a-src-invalid"),
+ pytest.param(f'<div href="{TARGET_URL}"></div>', False, id="div-href-invalid"),
+ pytest.param(f'<link src="{TARGET_URL}">', False, id="link-src-invalid"),
pytest.param(
f'<input type="text" src="{TARGET_URL}">',
False,
@@ -210,51 +127,28 @@ def set_stream_response(
False,
id="picture-source-src-invalid",
),
+ pytest.param(f'<base href="{TARGET_URL}">', False, id="base-is-not-mention"),
pytest.param(
- f'<base href="{TARGET_URL}">',
- False,
- id="base-is-not-mention",
- ),
- pytest.param(
- f'<a href="{TARGET_URL}more">Different page</a>',
- False,
- id="longer-url",
+ f'<a href="{TARGET_URL}more">Different page</a>', False, id="longer-url"
),
pytest.param(
f'<img cite="{TARGET_URL}" src="https://example.com/image.jpg" alt="">',
False,
id="invalid-cite-does-not-override-valid-src",
),
+ pytest.param(f"<p>{TARGET_URL}</p>", False, id="text-content"),
pytest.param(
- f"<p>{TARGET_URL}</p>",
- False,
- id="text-content",
- ),
- pytest.param(
- '<a href="https://example.com/">Other site</a>',
- False,
- id="missing-target",
+ '<a href="https://example.com/">Other site</a>', False, id="missing-target"
),
],
)
-def test_html_mentions_target(
- receiver: ModuleType,
- body: str,
- expected: bool,
-) -> None:
+def test_html_mentions_target(receiver: ModuleType, body: str, expected: bool) -> None:
assert (
- receiver.html_mentions_target(
- body.encode(),
- SOURCE_URL,
- TARGET_URL,
- )
- is expected
+ receiver.html_mentions_target(body.encode(), SOURCE_URL, TARGET_URL) is expected
)
-def test_html_mentions_relative_target(
- receiver: ModuleType,
-) -> None:
+def test_html_mentions_relative_target(receiver: ModuleType) -> None:
assert receiver.html_mentions_target(
b'<a href="../target/">Reply</a>',
"https://source.example/posts/article/",
@@ -279,63 +173,14 @@ def test_html_mentions_relative_target(
],
)
def test_text_mentions_target(
- receiver: ModuleType,
- body: str,
- target_url: str,
- expected: bool,
+ receiver: ModuleType, body: str, target_url: str, expected: bool
) -> None:
assert receiver.text_mentions_target(body, target_url) is expected
-@patch("webmentions_ssg.tasks.receiver.ensure_public_url")
-def test_ensure_public_request_accepts_public_url(
- ensure_public_url: Mock,
- receiver: ModuleType,
-) -> None:
- receiver.ensure_public_request(httpx.Request("GET", SOURCE_URL))
-
- ensure_public_url.assert_called_once_with(SOURCE_URL)
-
-
-@patch(
- "webmentions_ssg.tasks.receiver.ensure_public_url",
- side_effect=NonPublicAddressError("Non-public address"),
-)
-def test_ensure_public_request_rejects_non_public_address(
- ensure_public_url: Mock,
- receiver: ModuleType,
-) -> None:
- with pytest.raises(
- receiver.VerificationError,
- match="Source resolves to a non-public address",
- ):
- receiver.ensure_public_request(httpx.Request("GET", SOURCE_URL))
-
- ensure_public_url.assert_called_once_with(SOURCE_URL)
-
-
-@patch(
- "webmentions_ssg.tasks.receiver.ensure_public_url",
- side_effect=AddressResolutionError("Could not resolve hostname"),
-)
-def test_ensure_public_request_maps_dns_failure_to_temporary_error(
- ensure_public_url: Mock,
- receiver: ModuleType,
-) -> None:
- with pytest.raises(
- receiver.TemporaryFetchError,
- match="Source hostname could not be resolved",
- ):
- receiver.ensure_public_request(httpx.Request("GET", SOURCE_URL))
-
- ensure_public_url.assert_called_once_with(SOURCE_URL)
-
-
@patch("webmentions_ssg.tasks.receiver.httpx.Client")
def test_fetch_source_returns_response_and_body(
- httpx_client: Mock,
- app: Flask,
- receiver: ModuleType,
+ httpx_client: Mock, app: Flask, receiver: ModuleType
) -> None:
response = httpx.Response(
200,
@@ -360,13 +205,10 @@ def test_fetch_source_returns_response_and_body(
}
assert options["follow_redirects"] is True
assert options["max_redirects"] == app.config.get(
- "WEBMENTIONS_SSG_MAX_REDIRECTS",
- 20,
+ "WEBMENTIONS_SSG_MAX_REDIRECTS", 20
)
assert options["trust_env"] is False
- assert options["event_hooks"] == {
- "request": [receiver.ensure_public_request],
- }
+ assert options["event_hooks"] == {"request": [receiver.ensure_public_request]}
@patch("webmentions_ssg.tasks.receiver.httpx.Client")
@@ -392,51 +234,34 @@ def test_fetch_source_maps_http_status_to_exception(
) -> None:
set_stream_response(
httpx_client,
- httpx.Response(
- status_code,
- request=httpx.Request("GET", SOURCE_URL),
- ),
+ httpx.Response(status_code, request=httpx.Request("GET", SOURCE_URL)),
)
exception_type = getattr(receiver, exception_name)
- with (
- app.app_context(),
- pytest.raises(
- exception_type,
- match=f"HTTP {status_code}",
- ),
- ):
+ with app.app_context(), pytest.raises(exception_type, match=f"HTTP {status_code}"):
receiver.fetch_source(SOURCE_URL)
@patch("webmentions_ssg.tasks.receiver.httpx.Client")
def test_fetch_source_propagates_network_error(
- httpx_client: Mock,
- app: Flask,
- receiver: ModuleType,
+ httpx_client: Mock, app: Flask, receiver: ModuleType
) -> None:
client = httpx_client.return_value.__enter__.return_value
client.stream.side_effect = httpx.ConnectError(
- "Connection refused",
- request=httpx.Request("GET", SOURCE_URL),
+ "Connection refused", request=httpx.Request("GET", SOURCE_URL)
)
with (
app.app_context(),
- pytest.raises(
- httpx.ConnectError,
- match="Connection refused",
- ),
+ pytest.raises(httpx.ConnectError, match="Connection refused"),
):
receiver.fetch_source(SOURCE_URL)
@patch("webmentions_ssg.tasks.receiver.httpx.Client")
def test_fetch_source_rejects_declared_oversized_body(
- httpx_client: Mock,
- app: Flask,
- receiver: ModuleType,
+ httpx_client: Mock, app: Flask, receiver: ModuleType
) -> None:
app.config["WEBMENTIONS_SSG_MAX_SOURCE_BYTES"] = 10
@@ -444,10 +269,7 @@ def test_fetch_source_rejects_declared_oversized_body(
httpx_client,
httpx.Response(
200,
- headers={
- "Content-Type": "text/html",
- "Content-Length": "11",
- },
+ headers={"Content-Type": "text/html", "Content-Length": "11"},
content=b"x" * 11,
request=httpx.Request("GET", SOURCE_URL),
),
@@ -455,19 +277,14 @@ def test_fetch_source_rejects_declared_oversized_body(
with (
app.app_context(),
- pytest.raises(
- receiver.VerificationError,
- match="Source document is too large",
- ),
+ pytest.raises(receiver.VerificationError, match="Source document is too large"),
):
receiver.fetch_source(SOURCE_URL)
@patch("webmentions_ssg.tasks.receiver.httpx.Client")
def test_fetch_source_rejects_streamed_oversized_body(
- httpx_client: Mock,
- app: Flask,
- receiver: ModuleType,
+ httpx_client: Mock, app: Flask, receiver: ModuleType
) -> None:
app.config["WEBMENTIONS_SSG_MAX_SOURCE_BYTES"] = 10
@@ -488,10 +305,7 @@ def test_fetch_source_rejects_streamed_oversized_body(
with (
app.app_context(),
- pytest.raises(
- receiver.VerificationError,
- match="Source document is too large",
- ),
+ pytest.raises(receiver.VerificationError, match="Source document is too large"),
):
receiver.fetch_source(SOURCE_URL)
@@ -537,10 +351,7 @@ def test_fetch_source_rejects_streamed_oversized_body(
id="plain-text-invalid-byte",
),
pytest.param(
- "text/plain",
- b"No target here.",
- False,
- id="plain-text-without-target",
+ "text/plain", b"No target here.", False, id="plain-text-without-target"
),
],
)
@@ -600,24 +411,14 @@ def test_source_mentions_target_rejects_unsupported_media_type(
@patch("webmentions_ssg.tasks.receiver.source_mentions_target")
def test_verify_webmention_marks_row_verifying_before_check(
- source_mentions_target: Mock,
- app: Flask,
- receiver: ModuleType,
+ source_mentions_target: Mock, app: Flask, receiver: ModuleType
) -> None:
identifier = create_webmention(
- app,
- status="failed",
- failure_reason="Earlier failure",
+ app, status="failed", failure_reason="Earlier failure"
)
- def verify_source(
- source_url: str,
- target_url: str,
- ) -> bool:
- webmention = db.session.get(
- ReceivedWebmention,
- identifier,
- )
+ 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"
@@ -631,13 +432,7 @@ def test_verify_webmention_marks_row_verifying_before_check(
receiver.verify_webmention.call_local(identifier)
- assert get_webmention_state(
- app,
- identifier,
- ) == (
- "verified",
- None,
- )
+ assert get_webmention_state(app, identifier) == ("verified", None)
@patch("webmentions_ssg.tasks.receiver.source_mentions_target")
@@ -678,28 +473,13 @@ def test_verify_webmention_persists_final_state(
receiver.verify_webmention.call_local(identifier)
- assert get_webmention_state(
- app,
- identifier,
- ) == (
- expected_status,
- expected_reason,
- )
+ assert get_webmention_state(app, identifier) == (expected_status, expected_reason)
@patch("webmentions_ssg.tasks.receiver.source_mentions_target")
-@pytest.mark.parametrize(
- "failure",
- [
- "temporary-http",
- "network",
- ],
-)
+@pytest.mark.parametrize("failure", ["temporary-http", "network"])
def test_verify_webmention_persists_retryable_failure_and_reraises(
- source_mentions_target: Mock,
- app: Flask,
- receiver: ModuleType,
- failure: str,
+ source_mentions_target: Mock, app: Flask, receiver: ModuleType, failure: str
) -> None:
identifier = create_webmention(app)
@@ -708,32 +488,21 @@ def test_verify_webmention_persists_retryable_failure_and_reraises(
exception = receiver.TemporaryFetchError("Source returned HTTP 503")
case "network":
exception = httpx.ConnectError(
- "Connection refused",
- request=httpx.Request("GET", SOURCE_URL),
+ "Connection refused", request=httpx.Request("GET", SOURCE_URL)
)
case _:
raise AssertionError(f"Unexpected failure: {failure}")
source_mentions_target.side_effect = exception
- with pytest.raises(
- type(exception),
- match=str(exception),
- ):
+ with pytest.raises(type(exception), match=str(exception)):
receiver.verify_webmention.call_local(identifier)
- assert get_webmention_state(
- app,
- identifier,
- ) == (
- "failed",
- str(exception),
- )
+ assert get_webmention_state(app, identifier) == ("failed", str(exception))
def test_verify_webmention_ignores_unknown_identifier(
- receiver: ModuleType,
- caplog: pytest.LogCaptureFixture,
+ receiver: ModuleType, caplog: pytest.LogCaptureFixture
) -> None:
identifier = uuid.uuid7()
@@ -741,3 +510,52 @@ def test_verify_webmention_ignores_unknown_identifier(
receiver.verify_webmention.call_local(identifier)
assert f"Cannot verify unknown ReceivedWebmention {identifier}" in caplog.text
+
+
+@patch("webmentions_ssg.tasks.receiver.is_public_url", return_value=True)
+def test_ensure_public_request_accepts_public_url(
+ is_public_url: Mock, receiver: ModuleType
+) -> None:
+ receiver.ensure_public_request(httpx.Request("GET", SOURCE_URL))
+
+ is_public_url.assert_called_once_with(SOURCE_URL)
+
+
+@patch("webmentions_ssg.tasks.receiver.is_public_url", return_value=False)
+def test_ensure_public_request_rejects_non_public_address(
+ is_public_url: Mock, receiver: ModuleType
+) -> None:
+ with pytest.raises(
+ receiver.VerificationError, match="Source resolves to a non-public address"
+ ):
+ receiver.ensure_public_request(httpx.Request("GET", SOURCE_URL))
+
+ is_public_url.assert_called_once_with(SOURCE_URL)
+
+
+@patch(
+ "webmentions_ssg.tasks.receiver.is_public_url",
+ side_effect=AddressResolutionError("Could not resolve hostname"),
+)
+def test_ensure_public_request_maps_dns_failure_to_temporary_error(
+ is_public_url: Mock, receiver: ModuleType
+) -> None:
+ with pytest.raises(
+ receiver.TemporaryFetchError, match="Source hostname could not be resolved"
+ ):
+ receiver.ensure_public_request(httpx.Request("GET", SOURCE_URL))
+
+ is_public_url.assert_called_once_with(SOURCE_URL)
+
+
+@patch(
+ "webmentions_ssg.tasks.receiver.is_public_url",
+ side_effect=ValueError("No hostname was specified"),
+)
+def test_ensure_public_request_rejects_url_without_hostname(
+ is_public_url: Mock, receiver: ModuleType
+) -> None:
+ with pytest.raises(receiver.VerificationError, match="Source URL has no hostname"):
+ receiver.ensure_public_request(httpx.Request("GET", SOURCE_URL))
+
+ is_public_url.assert_called_once_with(SOURCE_URL)
diff --git a/tests/tasks/test_scanner.py b/tests/tasks/test_scanner.py
new file mode 100644
index 0000000..65031b4
--- /dev/null
+++ b/tests/tasks/test_scanner.py
@@ -0,0 +1,1558 @@
+from pathlib import Path
+from types import ModuleType
+from uuid import UUID
+
+import pytest
+import sqlalchemy as sa
+from bs4 import BeautifulSoup
+from flask import Flask
+from pytest import MonkeyPatch
+
+from webmentions_ssg import DATABASE as db
+from webmentions_ssg.models import SentWebmention, SentWebmentionStatus, Source
+
+BASE_URL = "https://dennisfink.me/blog/"
+SOURCE_URL = f"{BASE_URL}example/"
+
+
+@pytest.fixture
+def scanner_module(app: Flask) -> ModuleType:
+ # Importing scanner registers Huey tasks. The dependency on the
+ # app fixture guarantees that Huey has been initialized first.
+ _ = app
+
+ from webmentions_ssg.tasks import scanner
+
+ return scanner
+
+
+def run_scan(app: Flask, scanner_module: ModuleType) -> None:
+ with app.app_context():
+ scanner_module.scan_sources()
+
+
+def parse_html(html: str) -> BeautifulSoup:
+ return BeautifulSoup(html, "html5lib")
+
+
+def write_post(
+ root: Path,
+ slug: str,
+ content: str,
+ *,
+ canonical: str | None = None,
+ entry_url: str | None = None,
+) -> Path:
+ directory = root / slug
+ directory.mkdir(parents=True, exist_ok=True)
+
+ source_url = f"{BASE_URL}{slug}/"
+
+ if canonical is None:
+ canonical = source_url
+
+ if entry_url is None:
+ entry_url = source_url
+
+ path = directory / "index.html"
+ path.write_text(
+ f"""
+ <!doctype html>
+ <html>
+ <head>
+ <link
+ rel="canonical"
+ href="{canonical}"
+ >
+ </head>
+ <body>
+ <article class="h-entry">
+ <a
+ class="u-url"
+ href="{entry_url}"
+ >
+ Permalink
+ </a>
+
+ <div class="e-content">
+ {content}
+ </div>
+ </article>
+ </body>
+ </html>
+ """,
+ encoding="utf-8",
+ )
+
+ return path
+
+
+def configure_scanner(app: Flask, root: Path, *, base_url: str | None = None) -> None:
+ app.config["WEBMENTIONS_SSG_SOURCE_DIRECTORY"] = str(root)
+ app.config["WEBMENTIONS_SSG_SOURCE_BASE_URL"] = base_url
+
+
+# ---------------------------------------------------------------------------
+# Microformats parsing
+# ---------------------------------------------------------------------------
+
+
+def test_parse_entry_returns_microformats_entry(scanner_module: ModuleType) -> None:
+ document = parse_html(
+ f"""
+ <article class="h-entry">
+ <a class="u-url" href="{SOURCE_URL}">
+ Permalink
+ </a>
+
+ <div class="e-content">
+ Hello world
+ </div>
+ </article>
+ """
+ )
+
+ element = document.find(class_="h-entry")
+
+ assert element is not None
+
+ entry = scanner_module.parse_entry(element, SOURCE_URL)
+
+ assert "h-entry" in entry["type"]
+ assert entry["properties"]["url"] == [SOURCE_URL]
+
+
+def test_parse_entry_rejects_non_entry(scanner_module: ModuleType) -> None:
+ document = parse_html(
+ """
+ <article>
+ <p>Hello world</p>
+ </article>
+ """
+ )
+
+ element = document.find("article")
+
+ assert element is not None
+
+ with pytest.raises(
+ scanner_module.SourceScanError, match="Expected exactly one parsed h-entry"
+ ):
+ scanner_module.parse_entry(element, SOURCE_URL)
+
+
+def test_primary_entry_returns_source_entry(scanner_module: ModuleType) -> None:
+ document = parse_html(
+ f"""
+ <article class="h-entry" id="source">
+ <a class="u-url" href="{SOURCE_URL}">
+ Permalink
+ </a>
+
+ <p class="p-name">
+ Example post
+ </p>
+
+ <div class="e-content">
+ Hello world
+ </div>
+ </article>
+ """
+ )
+
+ element, entry = scanner_module.primary_entry(document, SOURCE_URL)
+
+ assert element["id"] == "source"
+ assert entry["properties"]["name"] == ["Example post"]
+ assert entry["properties"]["url"] == [SOURCE_URL]
+
+
+def test_primary_entry_rejects_missing_h_entry(scanner_module: ModuleType) -> None:
+ document = parse_html(
+ """
+ <main>
+ <p>No microformats here.</p>
+ </main>
+ """
+ )
+
+ with pytest.raises(
+ scanner_module.SourceScanError, match="Expected exactly one h-entry, found 0"
+ ):
+ scanner_module.primary_entry(document, SOURCE_URL)
+
+
+def test_primary_entry_rejects_multiple_h_entries(scanner_module: ModuleType) -> None:
+ document = parse_html(
+ f"""
+ <article class="h-entry">
+ <a class="u-url" href="{SOURCE_URL}">
+ First
+ </a>
+ </article>
+
+ <article class="h-entry">
+ <a
+ class="u-url"
+ href="https://example.com/other/"
+ >
+ Second
+ </a>
+ </article>
+ """
+ )
+
+ with pytest.raises(
+ scanner_module.SourceScanError, match="Expected exactly one h-entry, found 2"
+ ):
+ scanner_module.primary_entry(document, SOURCE_URL)
+
+
+def test_primary_entry_rejects_nested_h_entry(scanner_module: ModuleType) -> None:
+ document = parse_html(
+ f"""
+ <article class="h-entry">
+ <a class="u-url" href="{SOURCE_URL}">
+ Permalink
+ </a>
+
+ <div class="e-content">
+ <article class="h-entry">
+ <a
+ class="u-url"
+ href="https://example.com/nested/"
+ >
+ Nested
+ </a>
+ </article>
+ </div>
+ </article>
+ """
+ )
+
+ with pytest.raises(
+ scanner_module.SourceScanError, match="Expected exactly one h-entry, found 2"
+ ):
+ scanner_module.primary_entry(document, SOURCE_URL)
+
+
+def test_primary_entry_rejects_missing_u_url(scanner_module: ModuleType) -> None:
+ document = parse_html(
+ """
+ <article class="h-entry">
+ <div class="e-content">
+ Hello world
+ </div>
+ </article>
+ """
+ )
+
+ with pytest.raises(scanner_module.SourceScanError, match="does not match"):
+ scanner_module.primary_entry(document, SOURCE_URL)
+
+
+def test_primary_entry_rejects_nonmatching_u_url(scanner_module: ModuleType) -> None:
+ document = parse_html(
+ """
+ <article class="h-entry">
+ <a
+ class="u-url"
+ href="https://example.com/other/"
+ >
+ Other
+ </a>
+
+ <div class="e-content">
+ Hello world
+ </div>
+ </article>
+ """
+ )
+
+ with pytest.raises(scanner_module.SourceScanError, match="does not match"):
+ scanner_module.primary_entry(document, SOURCE_URL)
+
+
+# ---------------------------------------------------------------------------
+# e-content
+# ---------------------------------------------------------------------------
+
+
+def test_content_element_returns_e_content(scanner_module: ModuleType) -> None:
+ document = parse_html(
+ """
+ <article class="h-entry">
+ <div class="e-content" id="content">
+ Hello world
+ </div>
+ </article>
+ """
+ )
+
+ entry = document.find(class_="h-entry")
+
+ assert entry is not None
+
+ content = scanner_module.content_element(entry)
+
+ assert content["id"] == "content"
+
+
+def test_content_element_rejects_missing_e_content(scanner_module: ModuleType) -> None:
+ document = parse_html(
+ """
+ <article class="h-entry">
+ <p>Hello world</p>
+ </article>
+ """
+ )
+
+ entry = document.find(class_="h-entry")
+
+ assert entry is not None
+
+ with pytest.raises(
+ scanner_module.SourceScanError, match="Expected exactly one e-content, found 0"
+ ):
+ scanner_module.content_element(entry)
+
+
+def test_content_element_rejects_multiple_e_content(scanner_module: ModuleType) -> None:
+ document = parse_html(
+ """
+ <article class="h-entry">
+ <div class="e-content">
+ First
+ </div>
+
+ <div class="e-content">
+ Second
+ </div>
+ </article>
+ """
+ )
+
+ entry = document.find(class_="h-entry")
+
+ assert entry is not None
+
+ with pytest.raises(
+ scanner_module.SourceScanError, match="Expected exactly one e-content, found 2"
+ ):
+ scanner_module.content_element(entry)
+
+
+# ---------------------------------------------------------------------------
+# Canonical URLs
+# ---------------------------------------------------------------------------
+
+
+def test_canonical_url_returns_absolute_url(scanner_module: ModuleType) -> None:
+ document = parse_html(
+ f"""
+ <html>
+ <head>
+ <link
+ rel="canonical"
+ href="{SOURCE_URL}"
+ >
+ </head>
+ </html>
+ """
+ )
+
+ result = scanner_module.canonical_url(
+ document, relative_path=Path("example/index.html"), base_url=None
+ )
+
+ assert result == SOURCE_URL
+
+
+def test_canonical_url_returns_none_when_missing(scanner_module: ModuleType) -> None:
+ document = parse_html(
+ """
+ <html>
+ <head>
+ <title>Example</title>
+ </head>
+ </html>
+ """
+ )
+
+ result = scanner_module.canonical_url(
+ document, relative_path=Path("example/index.html"), base_url=None
+ )
+
+ assert result is None
+
+
+def test_canonical_url_resolves_relative_url_with_base_url(
+ scanner_module: ModuleType,
+) -> None:
+ document = parse_html(
+ """
+ <html>
+ <head>
+ <link
+ rel="canonical"
+ href="./canonical/"
+ >
+ </head>
+ </html>
+ """
+ )
+
+ result = scanner_module.canonical_url(
+ document, relative_path=Path("example/index.html"), base_url=BASE_URL
+ )
+
+ assert result == ("https://dennisfink.me/blog/example/canonical/")
+
+
+def test_relative_canonical_requires_base_url(scanner_module: ModuleType) -> None:
+ document = parse_html(
+ """
+ <html>
+ <head>
+ <link
+ rel="canonical"
+ href="./canonical/"
+ >
+ </head>
+ </html>
+ """
+ )
+
+ with pytest.raises(scanner_module.SourceScanError, match="relative canonical"):
+ scanner_module.canonical_url(
+ document, relative_path=Path("example/index.html"), base_url=None
+ )
+
+
+def test_empty_canonical_is_rejected(scanner_module: ModuleType) -> None:
+ document = parse_html(
+ """
+ <html>
+ <head>
+ <link
+ rel="canonical"
+ href=" "
+ >
+ </head>
+ </html>
+ """
+ )
+
+ with pytest.raises(scanner_module.SourceScanError, match="empty canonical URL"):
+ scanner_module.canonical_url(
+ document, relative_path=Path("example/index.html"), base_url=BASE_URL
+ )
+
+
+# ---------------------------------------------------------------------------
+# Target extraction
+# ---------------------------------------------------------------------------
+
+
+def test_scan_source_uses_canonical_without_base_url(
+ scanner_module: ModuleType, tmp_path: Path
+) -> None:
+ path = write_post(
+ tmp_path,
+ "example",
+ """
+ <a href="https://example.com/target">
+ Target
+ </a>
+ """,
+ )
+
+ scanned = scanner_module.scan_source_file(path, root=tmp_path, base_url=None)
+
+ assert scanned.path == "example/index.html"
+ assert scanned.url == SOURCE_URL
+ assert scanned.targets == frozenset({"https://example.com/target"})
+ assert len(scanned.content_hash) == 32
+
+
+def test_scan_source_falls_back_to_base_url(
+ scanner_module: ModuleType, tmp_path: Path
+) -> None:
+ directory = tmp_path / "example"
+ directory.mkdir()
+
+ path = directory / "index.html"
+ path.write_text(
+ """
+ <!doctype html>
+ <html>
+ <body>
+ <article class="h-entry">
+ <a class="u-url" href="./">
+ Permalink
+ </a>
+
+ <div class="e-content">
+ <a href="https://example.com/target">
+ Target
+ </a>
+ </div>
+ </article>
+ </body>
+ </html>
+ """,
+ encoding="utf-8",
+ )
+
+ scanned = scanner_module.scan_source_file(path, root=tmp_path, base_url=BASE_URL)
+
+ assert scanned.url == SOURCE_URL
+ assert scanned.targets == frozenset({"https://example.com/target"})
+
+
+def test_scan_source_requires_canonical_or_base_url(
+ scanner_module: ModuleType, tmp_path: Path
+) -> None:
+ directory = tmp_path / "example"
+ directory.mkdir()
+
+ path = directory / "index.html"
+ path.write_text(
+ """
+ <!doctype html>
+ <html>
+ <body>
+ <article class="h-entry">
+ <a class="u-url" href="./">
+ Permalink
+ </a>
+
+ <div class="e-content">
+ <p>Hello world.</p>
+ </div>
+ </article>
+ </body>
+ </html>
+ """,
+ encoding="utf-8",
+ )
+
+ with pytest.raises(scanner_module.SourceScanError, match="no canonical URL"):
+ scanner_module.scan_source_file(path, root=tmp_path, base_url=None)
+
+
+def test_scan_source_extracts_only_content_links(
+ scanner_module: ModuleType, tmp_path: Path
+) -> None:
+ directory = tmp_path / "example"
+ directory.mkdir()
+
+ path = directory / "index.html"
+ path.write_text(
+ f"""
+ <!doctype html>
+ <html>
+ <head>
+ <link
+ rel="canonical"
+ href="{SOURCE_URL}"
+ >
+ </head>
+ <body>
+ <article class="h-entry">
+ <a class="u-url" href="{SOURCE_URL}">
+ Permalink
+ </a>
+
+ <a href="https://example.com/metadata">
+ Metadata link
+ </a>
+
+ <div class="e-content">
+ <a href="https://example.com/content">
+ Content link
+ </a>
+ </div>
+ </article>
+ </body>
+ </html>
+ """,
+ encoding="utf-8",
+ )
+
+ scanned = scanner_module.scan_source_file(path, root=tmp_path, base_url=None)
+
+ assert scanned.targets == frozenset({"https://example.com/content"})
+
+
+def test_scan_source_extracts_relative_content_link(
+ scanner_module: ModuleType, tmp_path: Path
+) -> None:
+ path = write_post(
+ tmp_path,
+ "example",
+ """
+ <a href="../other/">
+ Other post
+ </a>
+ """,
+ )
+
+ scanned = scanner_module.scan_source_file(path, root=tmp_path, base_url=None)
+
+ assert scanned.targets == frozenset({"https://dennisfink.me/blog/other/"})
+
+
+@pytest.mark.parametrize(
+ "property_name", ("in-reply-to", "like-of", "repost-of", "bookmark-of")
+)
+def test_scan_source_extracts_reaction_properties(
+ scanner_module: ModuleType, tmp_path: Path, property_name: str
+) -> None:
+ directory = tmp_path / "example"
+ directory.mkdir()
+
+ target = f"https://example.com/{property_name}"
+
+ path = directory / "index.html"
+ path.write_text(
+ f"""
+ <!doctype html>
+ <html>
+ <head>
+ <link
+ rel="canonical"
+ href="{SOURCE_URL}"
+ >
+ </head>
+ <body>
+ <article class="h-entry">
+ <a class="u-url" href="{SOURCE_URL}">
+ Permalink
+ </a>
+
+ <a
+ class="u-{property_name}"
+ href="{target}"
+ >
+ Reaction target
+ </a>
+
+ <div class="e-content">
+ <p>Post content.</p>
+ </div>
+ </article>
+ </body>
+ </html>
+ """,
+ encoding="utf-8",
+ )
+
+ scanned = scanner_module.scan_source_file(path, root=tmp_path, base_url=None)
+
+ assert scanned.targets == frozenset({target})
+
+
+def test_scan_source_extracts_anchor_from_e_content(
+ scanner_module: ModuleType, tmp_path: Path
+) -> None:
+ path = write_post(
+ tmp_path,
+ "example",
+ """
+ <a href="https://example.com/target">
+ Target
+ </a>
+ """,
+ )
+
+ scanned = scanner_module.scan_source_file(path, root=tmp_path, base_url=None)
+
+ assert scanned.targets == frozenset({"https://example.com/target"})
+
+
+@pytest.mark.parametrize("tag", ("link", "area"))
+def test_scan_source_ignores_non_anchor_href_elements(
+ scanner_module: ModuleType, tmp_path: Path, tag: str
+) -> None:
+ path = write_post(
+ tmp_path,
+ "example",
+ f"""
+ <{tag} href="https://example.com/target">
+ """,
+ )
+
+ scanned = scanner_module.scan_source_file(path, root=tmp_path, base_url=None)
+
+ assert scanned.targets == frozenset()
+
+
+def test_scan_source_ignores_self_fragment(
+ scanner_module: ModuleType, tmp_path: Path
+) -> None:
+ path = write_post(
+ tmp_path,
+ "example",
+ """
+ <a href="#section">
+ Section
+ </a>
+
+ <a href="https://example.com/target">
+ Target
+ </a>
+ """,
+ )
+
+ scanned = scanner_module.scan_source_file(path, root=tmp_path, base_url=None)
+
+ assert scanned.targets == frozenset({"https://example.com/target"})
+
+
+# ---------------------------------------------------------------------------
+# Database reconciliation
+# ---------------------------------------------------------------------------
+
+
+def test_scan_creates_source_and_webmentions(
+ app: Flask, scanner_module: ModuleType, tmp_path: Path, monkeypatch: MonkeyPatch
+) -> None:
+ configure_scanner(app, tmp_path)
+
+ write_post(
+ tmp_path,
+ "example",
+ """
+ <a href="https://example.com/a">
+ A
+ </a>
+
+ <a href="https://example.com/b">
+ B
+ </a>
+ """,
+ )
+
+ queued: list[UUID] = []
+
+ monkeypatch.setattr(scanner_module, "send_webmention", queued.append)
+
+ run_scan(app, scanner_module)
+
+ with app.app_context():
+ source = db.session.scalar(sa.select(Source))
+
+ assert source is not None
+ assert source.path == "example/index.html"
+ assert source.url == SOURCE_URL
+ assert source.revision == 1
+ assert source.deleted_at is None
+ assert len(source.content_hash) == 32
+
+ webmentions = list(
+ db.session.scalars(
+ sa.select(SentWebmention).order_by(SentWebmention.target)
+ )
+ )
+
+ assert [webmention.target for webmention in webmentions] == [
+ "https://example.com/a",
+ "https://example.com/b",
+ ]
+
+ assert all(webmention.active for webmention in webmentions)
+ assert all(webmention.desired_revision == 1 for webmention in webmentions)
+ assert all(webmention.processed_revision is None for webmention in webmentions)
+
+ identifiers = {webmention.uuid for webmention in webmentions}
+
+ assert set(queued) == identifiers
+
+
+def test_unchanged_source_does_not_increment_revision(
+ app: Flask, scanner_module: ModuleType, tmp_path: Path, monkeypatch: MonkeyPatch
+) -> None:
+ configure_scanner(app, tmp_path)
+
+ write_post(
+ tmp_path,
+ "example",
+ """
+ <a href="https://example.com/a">
+ A
+ </a>
+ """,
+ )
+
+ queued: list[UUID] = []
+
+ monkeypatch.setattr(scanner_module, "send_webmention", queued.append)
+
+ run_scan(app, scanner_module)
+
+ with app.app_context():
+ webmention = db.session.scalar(sa.select(SentWebmention))
+
+ assert webmention is not None
+
+ webmention.processed_revision = 1
+ webmention.sent_revision = 1
+ webmention.status = SentWebmentionStatus.SENT
+
+ db.session.commit()
+
+ queued.clear()
+
+ run_scan(app, scanner_module)
+
+ with app.app_context():
+ source = db.session.scalar(sa.select(Source))
+
+ assert source is not None
+ assert source.revision == 1
+
+ assert queued == []
+
+
+def test_updated_source_increments_revision(
+ app: Flask, scanner_module: ModuleType, tmp_path: Path, monkeypatch: MonkeyPatch
+) -> None:
+ configure_scanner(app, tmp_path)
+
+ path = write_post(
+ tmp_path,
+ "example",
+ """
+ <p>Original content</p>
+
+ <a href="https://example.com/a">
+ A
+ </a>
+ """,
+ )
+
+ queued: list[UUID] = []
+
+ monkeypatch.setattr(scanner_module, "send_webmention", queued.append)
+
+ run_scan(app, scanner_module)
+
+ with app.app_context():
+ webmention = db.session.scalar(sa.select(SentWebmention))
+
+ assert webmention is not None
+
+ webmention.processed_revision = 1
+ webmention.sent_revision = 1
+ webmention.status = SentWebmentionStatus.SENT
+
+ db.session.commit()
+
+ queued.clear()
+
+ path.write_text(
+ f"""
+ <!doctype html>
+ <html>
+ <head>
+ <link
+ rel="canonical"
+ href="{SOURCE_URL}"
+ >
+ </head>
+ <body>
+ <article class="h-entry">
+ <a class="u-url" href="{SOURCE_URL}">
+ Permalink
+ </a>
+
+ <div class="e-content">
+ <p>Updated content</p>
+
+ <a href="https://example.com/a">
+ A
+ </a>
+ </div>
+ </article>
+ </body>
+ </html>
+ """,
+ encoding="utf-8",
+ )
+
+ run_scan(app, scanner_module)
+
+ with app.app_context():
+ source = db.session.scalar(sa.select(Source))
+ webmention = db.session.scalar(sa.select(SentWebmention))
+
+ assert source is not None
+ assert webmention is not None
+
+ assert source.revision == 2
+
+ assert webmention.active
+ assert webmention.desired_revision == 2
+ assert webmention.processed_revision == 1
+ assert webmention.sent_revision == 1
+ assert webmention.pending
+
+ identifier = webmention.uuid
+
+ assert queued == [identifier]
+
+
+def test_new_target_is_added_on_update(
+ app: Flask, scanner_module: ModuleType, tmp_path: Path, monkeypatch: MonkeyPatch
+) -> None:
+ configure_scanner(app, tmp_path)
+
+ path = write_post(
+ tmp_path,
+ "example",
+ """
+ <p>No links yet.</p>
+ """,
+ )
+
+ queued: list[UUID] = []
+
+ monkeypatch.setattr(scanner_module, "send_webmention", queued.append)
+
+ run_scan(app, scanner_module)
+
+ assert queued == []
+
+ path.write_text(
+ f"""
+ <!doctype html>
+ <html>
+ <head>
+ <link
+ rel="canonical"
+ href="{SOURCE_URL}"
+ >
+ </head>
+ <body>
+ <article class="h-entry">
+ <a class="u-url" href="{SOURCE_URL}">
+ Permalink
+ </a>
+
+ <div class="e-content">
+ <a href="https://example.com/new">
+ New
+ </a>
+ </div>
+ </article>
+ </body>
+ </html>
+ """,
+ encoding="utf-8",
+ )
+
+ run_scan(app, scanner_module)
+
+ with app.app_context():
+ source = db.session.scalar(sa.select(Source))
+ webmention = db.session.scalar(sa.select(SentWebmention))
+
+ assert source is not None
+ assert webmention is not None
+
+ assert source.revision == 2
+
+ assert webmention.target == ("https://example.com/new")
+ assert webmention.active
+ assert webmention.desired_revision == 2
+ assert webmention.processed_revision is None
+ assert webmention.sent_revision is None
+ assert webmention.pending
+
+ identifier = webmention.uuid
+
+ assert queued == [identifier]
+
+
+def test_removed_sent_target_is_queued_again(
+ app: Flask, scanner_module: ModuleType, tmp_path: Path, monkeypatch: MonkeyPatch
+) -> None:
+ configure_scanner(app, tmp_path)
+
+ path = write_post(
+ tmp_path,
+ "example",
+ """
+ <a href="https://example.com/sent">
+ Sent
+ </a>
+ """,
+ )
+
+ queued: list[UUID] = []
+
+ monkeypatch.setattr(scanner_module, "send_webmention", queued.append)
+
+ run_scan(app, scanner_module)
+
+ with app.app_context():
+ webmention = db.session.scalar(sa.select(SentWebmention))
+
+ assert webmention is not None
+
+ webmention.processed_revision = 1
+ webmention.sent_revision = 1
+ webmention.status = SentWebmentionStatus.SENT
+
+ identifier = webmention.uuid
+ db.session.commit()
+
+ queued.clear()
+
+ path.write_text(
+ f"""
+ <!doctype html>
+ <html>
+ <head>
+ <link
+ rel="canonical"
+ href="{SOURCE_URL}"
+ >
+ </head>
+ <body>
+ <article class="h-entry">
+ <a class="u-url" href="{SOURCE_URL}">
+ Permalink
+ </a>
+
+ <div class="e-content">
+ <p>The link is gone.</p>
+ </div>
+ </article>
+ </body>
+ </html>
+ """,
+ encoding="utf-8",
+ )
+
+ run_scan(app, scanner_module)
+
+ with app.app_context():
+ source = db.session.scalar(sa.select(Source))
+ webmention = db.session.scalar(sa.select(SentWebmention))
+
+ assert source is not None
+ assert webmention is not None
+
+ assert source.revision == 2
+
+ assert not webmention.active
+ assert webmention.desired_revision == 2
+ assert webmention.processed_revision == 1
+ assert webmention.sent_revision == 1
+ assert webmention.pending
+
+ assert queued == [identifier]
+
+
+def test_removed_unsent_target_is_not_queued(
+ app: Flask, scanner_module: ModuleType, tmp_path: Path, monkeypatch: MonkeyPatch
+) -> None:
+ configure_scanner(app, tmp_path)
+
+ path = write_post(
+ tmp_path,
+ "example",
+ """
+ <a href="https://example.com/unsent">
+ Unsent
+ </a>
+ """,
+ )
+
+ queued: list[UUID] = []
+
+ monkeypatch.setattr(scanner_module, "send_webmention", queued.append)
+
+ run_scan(app, scanner_module)
+
+ queued.clear()
+
+ path.write_text(
+ f"""
+ <!doctype html>
+ <html>
+ <head>
+ <link
+ rel="canonical"
+ href="{SOURCE_URL}"
+ >
+ </head>
+ <body>
+ <article class="h-entry">
+ <a class="u-url" href="{SOURCE_URL}">
+ Permalink
+ </a>
+
+ <div class="e-content">
+ <p>The link is gone.</p>
+ </div>
+ </article>
+ </body>
+ </html>
+ """,
+ encoding="utf-8",
+ )
+
+ run_scan(app, scanner_module)
+
+ with app.app_context():
+ source = db.session.scalar(sa.select(Source))
+ webmention = db.session.scalar(sa.select(SentWebmention))
+
+ assert source is not None
+ assert webmention is not None
+
+ assert source.revision == 2
+
+ assert not webmention.active
+ assert webmention.desired_revision == 2
+ assert webmention.processed_revision == 2
+ assert webmention.sent_revision is None
+ assert not webmention.pending
+
+ assert queued == []
+
+
+# ---------------------------------------------------------------------------
+# Source deletion/restoration
+# ---------------------------------------------------------------------------
+
+
+def test_deleted_source_queues_previously_sent_webmention(
+ app: Flask, scanner_module: ModuleType, tmp_path: Path, monkeypatch: MonkeyPatch
+) -> None:
+ configure_scanner(app, tmp_path)
+
+ path = write_post(
+ tmp_path,
+ "example",
+ """
+ <a href="https://example.com/a">
+ A
+ </a>
+ """,
+ )
+
+ queued: list[UUID] = []
+
+ monkeypatch.setattr(scanner_module, "send_webmention", queued.append)
+
+ run_scan(app, scanner_module)
+
+ with app.app_context():
+ webmention = db.session.scalar(sa.select(SentWebmention))
+
+ assert webmention is not None
+
+ webmention.processed_revision = 1
+ webmention.sent_revision = 1
+ webmention.status = SentWebmentionStatus.SENT
+
+ identifier = webmention.uuid
+ db.session.commit()
+
+ queued.clear()
+ path.unlink()
+
+ run_scan(app, scanner_module)
+
+ with app.app_context():
+ source = db.session.scalar(sa.select(Source))
+ webmention = db.session.scalar(sa.select(SentWebmention))
+
+ assert source is not None
+ assert webmention is not None
+
+ assert source.revision == 2
+ assert source.deleted_at is not None
+
+ assert not webmention.active
+ assert webmention.desired_revision == 2
+ assert webmention.processed_revision == 1
+ assert webmention.sent_revision == 1
+ assert webmention.pending
+
+ assert queued == [identifier]
+
+
+def test_deleted_source_does_not_queue_unsent_webmention(
+ app: Flask, scanner_module: ModuleType, tmp_path: Path, monkeypatch: MonkeyPatch
+) -> None:
+ configure_scanner(app, tmp_path)
+
+ path = write_post(
+ tmp_path,
+ "example",
+ """
+ <a href="https://example.com/a">
+ A
+ </a>
+ """,
+ )
+
+ queued: list[UUID] = []
+
+ monkeypatch.setattr(scanner_module, "send_webmention", queued.append)
+
+ run_scan(app, scanner_module)
+
+ queued.clear()
+ path.unlink()
+
+ run_scan(app, scanner_module)
+
+ with app.app_context():
+ source = db.session.scalar(sa.select(Source))
+ webmention = db.session.scalar(sa.select(SentWebmention))
+
+ assert source is not None
+ assert webmention is not None
+
+ assert source.revision == 2
+ assert source.deleted_at is not None
+
+ assert not webmention.active
+ assert webmention.desired_revision == 2
+ assert webmention.processed_revision == 2
+ assert webmention.sent_revision is None
+ assert not webmention.pending
+
+ assert queued == []
+
+
+def test_restored_source_creates_new_revision(
+ app: Flask, scanner_module: ModuleType, tmp_path: Path, monkeypatch: MonkeyPatch
+) -> None:
+ configure_scanner(app, tmp_path)
+
+ path = write_post(
+ tmp_path,
+ "example",
+ """
+ <a href="https://example.com/a">
+ A
+ </a>
+ """,
+ )
+
+ queued: list[UUID] = []
+
+ monkeypatch.setattr(scanner_module, "send_webmention", queued.append)
+
+ run_scan(app, scanner_module)
+
+ with app.app_context():
+ webmention = db.session.scalar(sa.select(SentWebmention))
+
+ assert webmention is not None
+
+ webmention.processed_revision = 1
+ webmention.sent_revision = 1
+ webmention.status = SentWebmentionStatus.SENT
+
+ db.session.commit()
+
+ path.unlink()
+ run_scan(app, scanner_module)
+
+ queued.clear()
+
+ write_post(
+ tmp_path,
+ "example",
+ """
+ <a href="https://example.com/a">
+ A
+ </a>
+ """,
+ )
+
+ run_scan(app, scanner_module)
+
+ with app.app_context():
+ source = db.session.scalar(sa.select(Source))
+ webmention = db.session.scalar(sa.select(SentWebmention))
+
+ assert source is not None
+ assert webmention is not None
+
+ assert source.revision == 3
+ assert source.deleted_at is None
+
+ assert webmention.active
+ assert webmention.desired_revision == 3
+ assert webmention.sent_revision == 1
+ assert webmention.pending
+
+ identifier = webmention.uuid
+
+ assert queued == [identifier]
+
+
+# ---------------------------------------------------------------------------
+# Queue recovery and invalid sources
+# ---------------------------------------------------------------------------
+
+
+def test_pending_webmention_is_requeued_on_next_scan(
+ app: Flask, scanner_module: ModuleType, tmp_path: Path, monkeypatch: MonkeyPatch
+) -> None:
+ configure_scanner(app, tmp_path)
+
+ write_post(
+ tmp_path,
+ "example",
+ """
+ <a href="https://example.com/a">
+ A
+ </a>
+ """,
+ )
+
+ queued: list[UUID] = []
+
+ monkeypatch.setattr(scanner_module, "send_webmention", queued.append)
+
+ run_scan(app, scanner_module)
+
+ assert len(queued) == 1
+
+ identifier = queued[0]
+ queued.clear()
+
+ run_scan(app, scanner_module)
+
+ assert queued == [identifier]
+
+
+def test_invalid_known_source_is_not_deleted(
+ app: Flask,
+ scanner_module: ModuleType,
+ tmp_path: Path,
+ monkeypatch: MonkeyPatch,
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ configure_scanner(app, tmp_path)
+
+ path = write_post(
+ tmp_path,
+ "example",
+ """
+ <p>Hello world.</p>
+ """,
+ )
+
+ monkeypatch.setattr(scanner_module, "send_webmention", lambda identifier: None)
+
+ run_scan(app, scanner_module)
+
+ path.write_text(
+ f"""
+ <!doctype html>
+ <html>
+ <head>
+ <link
+ rel="canonical"
+ href="{SOURCE_URL}"
+ >
+ </head>
+ <body>
+ <p>The h-entry disappeared.</p>
+ </body>
+ </html>
+ """,
+ encoding="utf-8",
+ )
+
+ run_scan(app, scanner_module)
+
+ with app.app_context():
+ source = db.session.scalar(sa.select(Source))
+
+ assert source is not None
+ assert source.revision == 1
+ assert source.deleted_at is None
+
+ assert "Could not scan source" in caplog.text
+ assert "Expected exactly one h-entry, found 0" in caplog.text
+
+
+def test_scan_sources_accepts_valid_base_url(
+ app: Flask, scanner_module: ModuleType, tmp_path: Path, monkeypatch: MonkeyPatch
+) -> None:
+ configure_scanner(app, tmp_path, base_url=BASE_URL)
+
+ directory = tmp_path / "example"
+ directory.mkdir()
+
+ path = directory / "index.html"
+ path.write_text(
+ """
+ <!doctype html>
+ <html>
+ <body>
+ <article class="h-entry">
+ <a class="u-url" href="./">
+ Permalink
+ </a>
+
+ <div class="e-content">
+ <p>Hello world.</p>
+ </div>
+ </article>
+ </body>
+ </html>
+ """,
+ encoding="utf-8",
+ )
+
+ monkeypatch.setattr(scanner_module, "send_webmention", lambda identifier: None)
+
+ run_scan(app, scanner_module)
+
+ with app.app_context():
+ source = db.session.scalar(sa.select(Source))
+
+ assert source is not None
+ assert source.url == SOURCE_URL
+
+
+def test_scan_sources_rejects_invalid_base_url(
+ app: Flask, scanner_module: ModuleType, tmp_path: Path
+) -> None:
+ configure_scanner(app, tmp_path, base_url="not-a-url")
+
+ with pytest.raises(RuntimeError, match="absolute HTTP or HTTPS URL"):
+ run_scan(app, scanner_module)
+
+
+@pytest.mark.parametrize(
+ "href",
+ (
+ "https://dennisfink.me/blog/other/",
+ "https://www.dennisfink.me/blog/other/",
+ "https://webmentions.dennisfink.me/endpoint",
+ "https://foo.bar.dennisfink.me/example",
+ ),
+)
+def test_scan_source_ignores_matching_hostname(
+ scanner_module: ModuleType, tmp_path: Path, href: str
+) -> None:
+ path = write_post(
+ tmp_path,
+ "example",
+ f"""
+ <a href="{href}">
+ Ignored
+ </a>
+
+ <a href="https://example.com/target">
+ External
+ </a>
+ """,
+ )
+
+ scanned = scanner_module.scan_source_file(
+ path,
+ root=tmp_path,
+ base_url=None,
+ ignored_hostnames=("dennisfink.me", "*.dennisfink.me"),
+ )
+
+ assert scanned.targets == frozenset({"https://example.com/target"})
+
+
+@pytest.mark.parametrize(
+ "href",
+ (
+ "https://notdennisfink.me/example",
+ "https://dennisfink.me.example.com/example",
+ "https://example.com/example",
+ ),
+)
+def test_scan_source_keeps_nonmatching_hostname(
+ scanner_module: ModuleType, tmp_path: Path, href: str
+) -> None:
+ path = write_post(
+ tmp_path,
+ "example",
+ f"""
+ <a href="{href}">
+ Target
+ </a>
+ """,
+ )
+
+ scanned = scanner_module.scan_source_file(
+ path,
+ root=tmp_path,
+ base_url=None,
+ ignored_hostnames=("dennisfink.me", "*.dennisfink.me"),
+ )
+
+ assert scanned.targets == frozenset({href})
+
+
+def test_scan_source_ignores_reaction_to_matching_hostname(
+ scanner_module: ModuleType, tmp_path: Path
+) -> None:
+ directory = tmp_path / "example"
+ directory.mkdir()
+
+ path = directory / "index.html"
+ path.write_text(
+ f"""
+ <!doctype html>
+ <html>
+ <head>
+ <link
+ rel="canonical"
+ href="{SOURCE_URL}"
+ >
+ </head>
+ <body>
+ <article class="h-entry">
+ <a
+ class="u-url"
+ href="{SOURCE_URL}"
+ >
+ Permalink
+ </a>
+
+ <a
+ class="u-in-reply-to"
+ href="https://www.dennisfink.me/blog/other/"
+ >
+ Reply target
+ </a>
+
+ <div class="e-content">
+ <p>Reply content.</p>
+ </div>
+ </article>
+ </body>
+ </html>
+ """,
+ encoding="utf-8",
+ )
+
+ scanned = scanner_module.scan_source_file(
+ path,
+ root=tmp_path,
+ base_url=None,
+ ignored_hostnames=("dennisfink.me", "*.dennisfink.me"),
+ )
+
+ assert scanned.targets == frozenset()
diff --git a/tests/tasks/test_sender.py b/tests/tasks/test_sender.py
new file mode 100644
index 0000000..86799f7
--- /dev/null
+++ b/tests/tasks/test_sender.py
@@ -0,0 +1,788 @@
+import logging
+import uuid
+from datetime import datetime, timezone
+from types import ModuleType
+from unittest.mock import MagicMock, Mock
+
+import httpx
+import pytest
+from flask import Flask
+
+from webmentions_ssg import DATABASE as db
+from webmentions_ssg.models import SentWebmention, SentWebmentionStatus, Source
+
+SOURCE_URL = "https://dennisfink.me/blog/example/"
+TARGET_URL = "https://example.com/post"
+ENDPOINT_URL = "https://example.com/webmention"
+STATUS_URL = "https://example.com/webmention/status/123"
+
+
+def create_sent_webmention(
+ app: Flask,
+ *,
+ active: bool = True,
+ desired_revision: int = 1,
+ processed_revision: int | None = None,
+ sent_revision: int | None = None,
+ status: SentWebmentionStatus | None = None,
+ failure_reason: str | None = None,
+ endpoint: str | None = None,
+ response_status: int | None = None,
+ status_url: str | None = None,
+) -> uuid.UUID:
+ now = datetime.now(timezone.utc)
+
+ with app.app_context():
+ source = Source(
+ path="example/index.html",
+ url=SOURCE_URL,
+ content_hash="0" * 32,
+ revision=desired_revision,
+ last_seen_at=now,
+ revised_at=now,
+ )
+
+ webmention = SentWebmention(
+ target=TARGET_URL,
+ active=active,
+ desired_revision=desired_revision,
+ processed_revision=processed_revision,
+ sent_revision=sent_revision,
+ status=status,
+ failure_reason=failure_reason,
+ endpoint=endpoint,
+ response_status=response_status,
+ status_url=status_url,
+ )
+
+ source.sent_webmentions.append(webmention)
+
+ db.session.add(source)
+ db.session.commit()
+
+ return webmention.uuid
+
+
+def mock_sender_requests(
+ sender_module: ModuleType,
+ monkeypatch: pytest.MonkeyPatch,
+ *,
+ endpoint: str | None = ENDPOINT_URL,
+ result: tuple[int, str | None] = (202, None),
+) -> tuple[Mock, Mock, Mock, Mock]:
+ httpx_client = MagicMock()
+ client = httpx_client.return_value.__enter__.return_value
+
+ discover = Mock(return_value=endpoint)
+ post = Mock(return_value=result)
+
+ monkeypatch.setattr(sender_module.httpx, "Client", httpx_client)
+ monkeypatch.setattr(sender_module, "discover_webmention_endpoint", discover)
+ monkeypatch.setattr(sender_module, "post_webmention", post)
+
+ return httpx_client, client, discover, post
+
+
+@pytest.fixture
+def sender_module(app: Flask) -> ModuleType:
+ # Importing sender registers Huey tasks. The dependency on the
+ # app fixture guarantees that Huey has been initialized first.
+ _ = app
+
+ from webmentions_ssg.tasks import sender
+
+ return sender
+
+
+@pytest.mark.parametrize(
+ ("value", "expected"),
+ (
+ (
+ '<https://example.com/webmention>; rel="webmention"',
+ ("https://example.com/webmention", {"webmention"}),
+ ),
+ (
+ "<https://example.com/webmention>; rel=webmention",
+ ("https://example.com/webmention", {"webmention"}),
+ ),
+ (
+ '<https://example.com/webmention>; rel="webmention alternate"',
+ ("https://example.com/webmention", {"webmention", "alternate"}),
+ ),
+ (
+ '<https://example.com/webmention>; REL="WebMention Alternate"',
+ ("https://example.com/webmention", {"webmention", "alternate"}),
+ ),
+ (
+ "<https://example.com/webmention>; "
+ 'type="text/html"; rel="webmention"; title="Endpoint"',
+ ("https://example.com/webmention", {"webmention"}),
+ ),
+ (
+ '<https://example.com/stylesheet>; type="text/css"',
+ ("https://example.com/stylesheet", set()),
+ ),
+ ),
+)
+def test_parse_link_value(
+ sender_module: ModuleType, value: str, expected: tuple[str, set[str]]
+) -> None:
+ assert sender_module.parse_link_value(value) == expected
+
+
+@pytest.mark.parametrize(
+ "value", ("", "https://example.com/webmention", "<https://example.com/webmention")
+)
+def test_parse_link_value_rejects_malformed_value(
+ sender_module: ModuleType, value: str
+) -> None:
+ assert sender_module.parse_link_value(value) is None
+
+
+@pytest.mark.parametrize(
+ ("target", "headers", "html", "expected_endpoint"),
+ (
+ pytest.param(
+ "https://example.com/test/1",
+ (("Link", "</test/1/webmention?head=true>; rel=webmention"),),
+ "",
+ "https://example.com/test/1/webmention?head=true",
+ id="1-http-link-unquoted-rel-relative-url",
+ ),
+ pytest.param(
+ "https://example.com/test/2",
+ (
+ (
+ "Link",
+ "<https://example.com/test/2/webmention?head=true>; rel=webmention",
+ ),
+ ),
+ "",
+ "https://example.com/test/2/webmention?head=true",
+ id="2-http-link-unquoted-rel-absolute-url",
+ ),
+ pytest.param(
+ "https://example.com/test/3",
+ (),
+ """
+ <link
+ rel="webmention"
+ href="/test/3/webmention"
+ >
+ """,
+ "https://example.com/test/3/webmention",
+ id="3-html-link-relative-url",
+ ),
+ pytest.param(
+ "https://example.com/test/4",
+ (),
+ """
+ <link
+ rel="webmention"
+ href="https://example.com/test/4/webmention"
+ >
+ """,
+ "https://example.com/test/4/webmention",
+ id="4-html-link-absolute-url",
+ ),
+ pytest.param(
+ "https://example.com/test/5",
+ (),
+ """
+ <a
+ rel="webmention"
+ href="/test/5/webmention"
+ >
+ Endpoint
+ </a>
+ """,
+ "https://example.com/test/5/webmention",
+ id="5-html-a-relative-url",
+ ),
+ pytest.param(
+ "https://example.com/test/6",
+ (),
+ """
+ <a
+ rel="webmention"
+ href="https://example.com/test/6/webmention"
+ >
+ Endpoint
+ </a>
+ """,
+ "https://example.com/test/6/webmention",
+ id="6-html-a-absolute-url",
+ ),
+ pytest.param(
+ "https://example.com/test/7",
+ (
+ (
+ "LinK",
+ "<https://example.com/test/7/webmention?head=true>; rel=webmention",
+ ),
+ ),
+ "",
+ "https://example.com/test/7/webmention?head=true",
+ id="7-http-link-strange-casing",
+ ),
+ pytest.param(
+ "https://example.com/test/8",
+ (
+ (
+ "Link",
+ '<https://example.com/test/8/webmention?head=true>; rel="webmention"',
+ ),
+ ),
+ "",
+ "https://example.com/test/8/webmention?head=true",
+ id="8-http-link-quoted-rel",
+ ),
+ pytest.param(
+ "https://example.com/test/9",
+ (),
+ """
+ <link
+ rel="webmention somethingelse"
+ href="https://example.com/test/9/webmention"
+ >
+ """,
+ "https://example.com/test/9/webmention",
+ id="9-multiple-html-rel-values",
+ ),
+ pytest.param(
+ "https://example.com/test/10",
+ (
+ (
+ "Link",
+ "<https://example.com/test/10/webmention?head=true>; "
+ 'rel="webmention somethingelse"',
+ ),
+ ),
+ "",
+ "https://example.com/test/10/webmention?head=true",
+ id="10-multiple-link-header-rel-values",
+ ),
+ pytest.param(
+ "https://example.com/test/11",
+ (("Link", '</test/11/webmention>; rel="webmention"'),),
+ """
+ <link
+ rel="webmention"
+ href="/test/11/webmention/error"
+ >
+
+ <a
+ rel="webmention"
+ href="/test/11/webmention/error"
+ >
+ Wrong endpoint
+ </a>
+ """,
+ "https://example.com/test/11/webmention",
+ id="11-http-link-precedence",
+ ),
+ pytest.param(
+ "https://example.com/test/12",
+ (),
+ """
+ <link
+ rel="not-webmention"
+ href="/test/12/webmention/error"
+ >
+
+ <a
+ rel="webmention"
+ href="/test/12/webmention"
+ >
+ Correct endpoint
+ </a>
+ """,
+ "https://example.com/test/12/webmention",
+ id="12-exact-rel-match",
+ ),
+ pytest.param(
+ "https://example.com/test/13",
+ (),
+ """
+ <!--
+ <a
+ rel="webmention"
+ href="/test/13/webmention/error"
+ >
+ False endpoint
+ </a>
+ -->
+
+ <a
+ rel="webmention"
+ href="/test/13/webmention"
+ >
+ Correct endpoint
+ </a>
+ """,
+ "https://example.com/test/13/webmention",
+ id="13-endpoint-inside-html-comment",
+ ),
+ pytest.param(
+ "https://example.com/test/14",
+ (),
+ """
+ <code>
+ &lt;a
+ rel="webmention"
+ href="/test/14/webmention/error"
+ &gt;
+ False endpoint
+ &lt;/a&gt;
+ </code>
+
+ <a
+ rel="webmention"
+ href="/test/14/webmention"
+ >
+ Correct endpoint
+ </a>
+ """,
+ "https://example.com/test/14/webmention",
+ id="14-endpoint-in-escaped-html",
+ ),
+ pytest.param(
+ "https://example.com/test/15",
+ (),
+ """
+ <link
+ rel="webmention"
+ href=""
+ >
+ """,
+ "https://example.com/test/15",
+ id="15-empty-href",
+ ),
+ pytest.param(
+ "https://example.com/test/16",
+ (),
+ """
+ <a
+ rel="webmention"
+ href="/test/16/webmention"
+ >
+ First endpoint
+ </a>
+
+ <link
+ rel="webmention"
+ href="/test/16/webmention/error"
+ >
+ """,
+ "https://example.com/test/16/webmention",
+ id="16-a-before-link",
+ ),
+ pytest.param(
+ "https://example.com/test/17",
+ (),
+ """
+ <link
+ rel="webmention"
+ href="/test/17/webmention"
+ >
+
+ <a
+ rel="webmention"
+ href="/test/17/webmention/error"
+ >
+ Second endpoint
+ </a>
+ """,
+ "https://example.com/test/17/webmention",
+ id="17-link-before-a",
+ ),
+ pytest.param(
+ "https://example.com/test/18",
+ (
+ ("Link", '<https://example.com/test/18/webmention/error>; rel="other"'),
+ (
+ "Link",
+ "<https://example.com/test/18/webmention?head=true>; "
+ 'rel="webmention"',
+ ),
+ ),
+ "",
+ "https://example.com/test/18/webmention?head=true",
+ id="18-multiple-http-link-headers",
+ ),
+ pytest.param(
+ "https://example.com/test/19",
+ (
+ (
+ "Link",
+ "<https://example.com/test/19/webmention/error>; "
+ 'rel="other", '
+ "<https://example.com/test/19/webmention?head=true>; "
+ 'rel="webmention"',
+ ),
+ ),
+ "",
+ "https://example.com/test/19/webmention?head=true",
+ id="19-single-header-multiple-values",
+ ),
+ pytest.param(
+ "https://example.com/test/20",
+ (),
+ """
+ <link rel="webmention">
+
+ <a
+ rel="webmention"
+ href="/test/20/webmention"
+ >
+ Correct endpoint
+ </a>
+ """,
+ "https://example.com/test/20/webmention",
+ id="20-link-without-href",
+ ),
+ pytest.param(
+ "https://example.com/test/21",
+ (),
+ """
+ <link
+ rel="webmention"
+ href="/test/21/webmention?query=yes"
+ >
+ """,
+ "https://example.com/test/21/webmention?query=yes",
+ id="21-endpoint-query-string",
+ ),
+ pytest.param(
+ "https://example.com/test/22",
+ (),
+ """
+ <link
+ rel="webmention"
+ href="22/webmention"
+ >
+ """,
+ "https://example.com/test/22/webmention",
+ id="22-endpoint-relative-to-path",
+ ),
+ ),
+)
+def test_discovery(
+ app: Flask,
+ sender_module: ModuleType,
+ target: str,
+ headers: tuple[tuple[str, str], ...],
+ html: str,
+ expected_endpoint: str,
+) -> None:
+ def handler(request: httpx.Request) -> httpx.Response:
+ if request.method == "HEAD":
+ return httpx.Response(200, headers=headers)
+
+ return httpx.Response(200, headers={"Content-Type": "text/html"}, text=html)
+
+ with (
+ app.app_context(),
+ httpx.Client(
+ transport=httpx.MockTransport(handler), follow_redirects=True
+ ) as client,
+ ):
+ endpoint = sender_module.discover_webmention_endpoint(client, target)
+
+ assert endpoint == expected_endpoint
+
+
+@pytest.mark.parametrize(
+ ("target", "responses", "expected_endpoint"),
+ (
+ pytest.param(
+ "https://example.com/test/23/page",
+ {
+ "/test/23/page": (302, {"Location": "page/redirect-key"}),
+ "/test/23/page/redirect-key": (
+ 200,
+ {"Link": ("<webmention-endpoint/endpoint-key>; rel=webmention")},
+ ),
+ },
+ ("https://example.com/test/23/page/webmention-endpoint/endpoint-key"),
+ id="relative-endpoint-after-redirect",
+ ),
+ ),
+)
+def test_discovery_redirect(
+ app: Flask,
+ sender_module: ModuleType,
+ target: str,
+ responses: dict[str, tuple[int, dict[str, str]]],
+ expected_endpoint: str,
+) -> None:
+ def handler(request: httpx.Request) -> httpx.Response:
+ try:
+ status, headers = responses[request.url.path]
+ except KeyError:
+ raise AssertionError(f"Unexpected request: {request.url}") from None
+
+ return httpx.Response(status, headers=headers)
+
+ with (
+ app.app_context(),
+ httpx.Client(
+ transport=httpx.MockTransport(handler), follow_redirects=True
+ ) as client,
+ ):
+ endpoint = sender_module.discover_webmention_endpoint(client, target)
+
+ assert endpoint == expected_endpoint
+
+
+def test_discovery_stops_after_head(app: Flask, sender_module: ModuleType) -> None:
+ requests: list[httpx.Request] = []
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ requests.append(request)
+
+ return httpx.Response(200, headers={"Link": "</webmention>; rel=webmention"})
+
+ with (
+ app.app_context(),
+ httpx.Client(
+ transport=httpx.MockTransport(handler), follow_redirects=True
+ ) as client,
+ ):
+ endpoint = sender_module.discover_webmention_endpoint(
+ client, "https://example.com/post"
+ )
+
+ assert endpoint == "https://example.com/webmention"
+ assert [request.method for request in requests] == ["HEAD"]
+
+
+def test_discovery_falls_back_to_get(app: Flask, sender_module: ModuleType) -> None:
+ requests: list[httpx.Request] = []
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ requests.append(request)
+
+ if request.method == "HEAD":
+ return httpx.Response(200)
+
+ return httpx.Response(
+ 200,
+ headers={"Content-Type": "text/html"},
+ text="""
+ <link
+ rel="webmention"
+ href="/webmention"
+ >
+ """,
+ )
+
+ with (
+ app.app_context(),
+ httpx.Client(
+ transport=httpx.MockTransport(handler), follow_redirects=True
+ ) as client,
+ ):
+ endpoint = sender_module.discover_webmention_endpoint(
+ client, "https://example.com/post"
+ )
+
+ assert endpoint == "https://example.com/webmention"
+ assert [request.method for request in requests] == ["HEAD", "GET"]
+
+
+def test_send_webmention_persists_success(
+ app: Flask, sender_module: ModuleType, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ identifier = create_sent_webmention(app)
+
+ _, client, discover, post = mock_sender_requests(
+ sender_module, monkeypatch, result=(201, STATUS_URL)
+ )
+
+ sender_module.send_webmention.call_local(identifier)
+
+ discover.assert_called_once_with(client, TARGET_URL)
+ post.assert_called_once_with(
+ client, endpoint=ENDPOINT_URL, source=SOURCE_URL, target=TARGET_URL
+ )
+
+ with app.app_context():
+ webmention = db.session.get(SentWebmention, identifier)
+
+ assert webmention is not None
+ assert webmention.processed_revision == 1
+ assert webmention.sent_revision == 1
+ assert webmention.status == SentWebmentionStatus.SENT
+ assert webmention.failure_reason is None
+ assert webmention.endpoint == ENDPOINT_URL
+ assert webmention.response_status == 201
+ assert webmention.status_url == STATUS_URL
+ assert webmention.last_attempted_at is not None
+ assert webmention.last_sent_at is not None
+ assert not webmention.pending
+
+
+def test_send_webmention_marks_unsupported_target_processed(
+ app: Flask, sender_module: ModuleType, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ identifier = create_sent_webmention(
+ app,
+ desired_revision=2,
+ processed_revision=1,
+ sent_revision=1,
+ status=SentWebmentionStatus.SENT,
+ endpoint="https://old.example/webmention",
+ response_status=201,
+ status_url="https://old.example/status/123",
+ )
+
+ _, client, discover, post = mock_sender_requests(
+ sender_module, monkeypatch, endpoint=None
+ )
+
+ sender_module.send_webmention.call_local(identifier)
+
+ discover.assert_called_once_with(client, TARGET_URL)
+ post.assert_not_called()
+
+ with app.app_context():
+ webmention = db.session.get(SentWebmention, identifier)
+
+ assert webmention is not None
+ assert webmention.processed_revision == 2
+ assert webmention.sent_revision == 1
+ assert webmention.status == SentWebmentionStatus.UNSUPPORTED
+ assert webmention.failure_reason == "No Webmention endpoint discovered"
+ assert webmention.endpoint is None
+ assert webmention.response_status is None
+ assert webmention.status_url is None
+ assert webmention.last_attempted_at is not None
+ assert not webmention.pending
+
+
+def test_send_webmention_persists_permanent_failure(
+ app: Flask, sender_module: ModuleType, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ identifier = create_sent_webmention(
+ app,
+ desired_revision=2,
+ processed_revision=1,
+ sent_revision=1,
+ status=SentWebmentionStatus.SENT,
+ status_url="https://old.example/status/123",
+ )
+
+ mock_sender_requests(sender_module, monkeypatch, result=(400, None))
+
+ sender_module.send_webmention.call_local(identifier)
+
+ with app.app_context():
+ webmention = db.session.get(SentWebmention, identifier)
+
+ assert webmention is not None
+ assert webmention.processed_revision == 2
+ assert webmention.sent_revision == 1
+ assert webmention.status == SentWebmentionStatus.FAILED
+ assert webmention.failure_reason == "Webmention endpoint returned HTTP 400"
+ assert webmention.endpoint == ENDPOINT_URL
+ assert webmention.response_status == 400
+ assert webmention.status_url is None
+ assert not webmention.pending
+
+
+def test_send_webmention_persists_temporary_failure_and_reraises(
+ app: Flask, sender_module: ModuleType, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ identifier = create_sent_webmention(
+ app,
+ desired_revision=2,
+ processed_revision=1,
+ sent_revision=1,
+ status=SentWebmentionStatus.SENT,
+ status_url="https://old.example/status/123",
+ )
+
+ mock_sender_requests(sender_module, monkeypatch, result=(503, None))
+
+ with pytest.raises(
+ sender_module.TemporarySenderError,
+ match="Webmention endpoint returned HTTP 503",
+ ):
+ sender_module.send_webmention.call_local(identifier)
+
+ with app.app_context():
+ webmention = db.session.get(SentWebmention, identifier)
+
+ assert webmention is not None
+ assert webmention.processed_revision == 1
+ assert webmention.sent_revision == 1
+ assert webmention.status == SentWebmentionStatus.FAILED
+ assert webmention.failure_reason == "Webmention endpoint returned HTTP 503"
+ assert webmention.endpoint == ENDPOINT_URL
+ assert webmention.response_status == 503
+ assert webmention.status_url is None
+ assert webmention.pending
+
+
+def test_send_webmention_ignores_processed_revision(
+ app: Flask, sender_module: ModuleType, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ identifier = create_sent_webmention(
+ app,
+ desired_revision=2,
+ processed_revision=2,
+ sent_revision=2,
+ status=SentWebmentionStatus.SENT,
+ )
+
+ httpx_client, _, discover, post = mock_sender_requests(sender_module, monkeypatch)
+
+ sender_module.send_webmention.call_local(identifier)
+
+ httpx_client.assert_not_called()
+ discover.assert_not_called()
+ post.assert_not_called()
+
+
+def test_send_webmention_ignores_result_for_superseded_revision(
+ app: Flask, sender_module: ModuleType, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ identifier = create_sent_webmention(app)
+
+ _, _, _, post = mock_sender_requests(sender_module, monkeypatch)
+
+ def supersede_revision(*args, **kwargs) -> tuple[int, None]:
+ webmention = db.session.get(SentWebmention, identifier)
+
+ assert webmention is not None
+
+ webmention.desired_revision = 2
+ db.session.commit()
+
+ return (202, None)
+
+ post.side_effect = supersede_revision
+
+ sender_module.send_webmention.call_local(identifier)
+
+ with app.app_context():
+ webmention = db.session.get(SentWebmention, identifier)
+
+ assert webmention is not None
+ assert webmention.desired_revision == 2
+ assert webmention.processed_revision is None
+ assert webmention.sent_revision is None
+ assert webmention.status is None
+ assert webmention.pending
+
+
+def test_send_webmention_ignores_unknown_identifier(
+ sender_module: ModuleType, caplog: pytest.LogCaptureFixture
+) -> None:
+ identifier = uuid.uuid7()
+
+ with caplog.at_level(logging.WARNING):
+ sender_module.send_webmention.call_local(identifier)
+
+ assert f"Cannot send unknown SentWebmention {identifier}" in caplog.text
diff --git a/tests/test_forms.py b/tests/test_forms.py
index 417c655..73c6ffc 100644
--- a/tests/test_forms.py
+++ b/tests/test_forms.py
@@ -10,83 +10,54 @@ VALID_SOURCE = "https://source.example/post"
VALID_TARGET = "https://dennisfink.me/blog/example/"
-@patch(
- "webmentions_ssg.forms.validators.ensure_public_url",
- new=lambda url: None,
-)
+@patch("webmentions_ssg.forms.validators.is_public_url", new=lambda url: True)
@pytest.mark.parametrize(
- (
- "form_data",
- "invalid_field",
- "expected_error",
- ),
+ ("form_data", "invalid_field", "expected_error"),
[
pytest.param(
- {
- "target": VALID_TARGET,
- },
+ {"target": VALID_TARGET},
"source",
"This field is required.",
id="source-required",
),
pytest.param(
- {
- "source": "not a URL",
- "target": VALID_TARGET,
- },
+ {"source": "not a URL", "target": VALID_TARGET},
"source",
"Invalid URL.",
id="source-url",
),
pytest.param(
- {
- "source": "ftp://source.example/post",
- "target": VALID_TARGET,
- },
+ {"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": VALID_TARGET, "target": VALID_TARGET},
"source",
None,
id="source-not-equal-to-target",
),
pytest.param(
- {
- "source": VALID_SOURCE,
- },
+ {"source": VALID_SOURCE},
"target",
"This field is required.",
id="target-required",
),
pytest.param(
- {
- "source": VALID_SOURCE,
- "target": "not a URL",
- },
+ {"source": VALID_SOURCE, "target": "not a URL"},
"target",
"Invalid URL.",
id="target-url",
),
pytest.param(
- {
- "source": VALID_SOURCE,
- "target": "ftp://dennisfink.me/blog/example/",
- },
+ {"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",
- },
+ {"source": VALID_SOURCE, "target": "https://example.com/post"},
"target",
None,
id="target-allowed-hostname",
@@ -99,14 +70,8 @@ def test_endpoint_form_rejects_invalid_data(
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},
- )
+ 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
@@ -115,24 +80,11 @@ def test_endpoint_form_rejects_invalid_data(
assert expected_error in form.errors[invalid_field]
-@patch(
- "webmentions_ssg.forms.validators.ensure_public_url",
- new=lambda url: None,
-)
-def test_endpoint_form_accepts_valid_data(
- app: Flask,
-) -> None:
- with app.test_request_context(
- "/endpoint",
- method="POST",
- ):
+@patch("webmentions_ssg.forms.validators.is_public_url", new=lambda url: True)
+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,
- }
- ),
+ formdata=MultiDict({"source": VALID_SOURCE, "target": VALID_TARGET}),
meta={"csrf": False},
)
diff --git a/tests/test_url_security.py b/tests/test_url_security.py
index b10e6ff..ffa0378 100644
--- a/tests/test_url_security.py
+++ b/tests/test_url_security.py
@@ -1,11 +1,9 @@
from unittest.mock import Mock, patch
+import dns.resolver
import pytest
-from webmentions_ssg.url_security import (
- NonPublicAddressError,
- ensure_public_url,
-)
+from webmentions_ssg.url_security import AddressResolutionError, is_public_url
@patch("webmentions_ssg.url_security.dns.resolver.resolve_name")
@@ -21,14 +19,50 @@ from webmentions_ssg.url_security import (
("http://169.254.169.254/", ["169.254.169.254"]),
("http://localhost/", ["127.0.0.1", "::1"]),
("http://internal.example/", ["192.168.1.10"]),
+ ("https://example.com/", ["93.184.216.34", "192.168.1.10"]),
+ ],
+)
+def test_is_public_url_returns_false_for_non_public_addresses(
+ resolve_name: Mock, url: str, resolved_addresses: list[str]
+) -> None:
+ resolve_name.return_value.addresses.return_value = resolved_addresses
+
+ assert not is_public_url(url)
+
+
+@patch("webmentions_ssg.url_security.dns.resolver.resolve_name")
+@pytest.mark.parametrize(
+ ("url", "resolved_addresses"),
+ [
+ ("https://example.com/", ["93.184.216.34"]),
+ (
+ "https://example.com/",
+ ["93.184.216.34", "2606:2800:220:1:248:1893:25c8:1946"],
+ ),
],
)
-def test_ensure_public_url_rejects_non_public_addresses(
- resolve_name: Mock,
- url: str,
- resolved_addresses: list[str],
+def test_is_public_url_returns_true_for_public_addresses(
+ resolve_name: Mock, url: str, resolved_addresses: list[str]
) -> None:
resolve_name.return_value.addresses.return_value = resolved_addresses
- with pytest.raises(NonPublicAddressError):
- ensure_public_url(url)
+ assert is_public_url(url)
+
+
+@patch(
+ "webmentions_ssg.url_security.dns.resolver.resolve_name",
+ side_effect=dns.resolver.NXDOMAIN(),
+)
+def test_is_public_url_raises_for_resolution_failure(resolve_name: Mock) -> None:
+ with pytest.raises(AddressResolutionError, match="Could not resolve hostname"):
+ is_public_url("https://nonexistent.example/")
+
+ resolve_name.assert_called_once_with("nonexistent.example")
+
+
+@patch("webmentions_ssg.url_security.dns.resolver.resolve_name")
+def test_is_public_url_raises_when_url_has_no_hostname(resolve_name: Mock) -> None:
+ with pytest.raises(ValueError, match="No hostname was specified"):
+ is_public_url("/relative/url")
+
+ resolve_name.assert_not_called()
diff --git a/tests/test_views.py b/tests/test_views.py
index 06e546c..aac281e 100644
--- a/tests/test_views.py
+++ b/tests/test_views.py
@@ -1,29 +1,89 @@
import uuid
+from datetime import datetime, timezone
from unittest.mock import Mock, call, patch
+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
+from webmentions_ssg.models import (
+ ReceivedWebmention,
+ SentWebmention,
+ SentWebmentionStatus,
+ Source,
+ User,
+)
-def test_endpoint_only_accepts_post(
- client: FlaskClient,
-) -> None:
+def log_in(app: Flask, client: FlaskClient) -> None:
+ with app.app_context():
+ user = User(username="admin")
+
+ db.session.add(user)
+ db.session.commit()
+
+ user_id = user.id
+
+ with client.session_transaction() as session:
+ session["_user_id"] = str(user_id)
+ session["_fresh"] = True
+
+
+def create_sent_source(app: Flask) -> uuid.UUID:
+ now = datetime.now(timezone.utc)
+
+ with app.app_context():
+ source = Source(
+ path="blog/example/index.html",
+ url="https://dennisfink.me/blog/example/",
+ content_hash="0" * 32,
+ revision=2,
+ last_seen_at=now,
+ revised_at=now,
+ )
+
+ source.sent_webmentions.extend(
+ [
+ SentWebmention(
+ target="https://example.com/b",
+ active=True,
+ desired_revision=2,
+ processed_revision=2,
+ sent_revision=2,
+ status=SentWebmentionStatus.SENT,
+ endpoint="https://example.com/webmention",
+ response_status=202,
+ ),
+ SentWebmention(
+ target="https://example.com/a",
+ active=True,
+ desired_revision=2,
+ processed_revision=2,
+ sent_revision=1,
+ status=SentWebmentionStatus.FAILED,
+ failure_reason="Webmention endpoint returned HTTP 400",
+ endpoint="https://example.com/webmention",
+ response_status=400,
+ ),
+ ]
+ )
+
+ db.session.add(source)
+ db.session.commit()
+
+ return source.uuid
+
+
+def test_endpoint_only_accepts_post(client: FlaskClient) -> None:
response = client.get("/endpoint")
assert response.status_code == 405
-@patch(
- "webmentions_ssg.forms.validators.ensure_public_url",
- new=lambda url: None,
-)
-def test_endpoint_returns_form_errors(
- client: FlaskClient,
-) -> None:
+@patch("webmentions_ssg.forms.validators.is_public_url", new=lambda url: True)
+def test_endpoint_returns_form_errors(client: FlaskClient) -> None:
response = client.post(
"/endpoint",
data={
@@ -40,26 +100,15 @@ def test_endpoint_returns_form_errors(
assert "target" in errors
-@patch(
- "webmentions_ssg.forms.validators.ensure_public_url",
- new=lambda url: None,
-)
+@patch("webmentions_ssg.forms.validators.is_public_url", new=lambda url: True)
@patch("webmentions_ssg.views.verify_webmention")
def test_endpoint_creates_webmention(
- verify_webmention: Mock,
- app: Flask,
- client: FlaskClient,
+ verify_webmention: Mock, app: Flask, client: FlaskClient
) -> None:
source = "https://source.example/post"
target = "https://dennisfink.me/blog/example/"
- response = client.post(
- "/endpoint",
- data={
- "source": source,
- "target": target,
- },
- )
+ response = client.post("/endpoint", data={"source": source, "target": target})
assert response.status_code == 201
@@ -78,29 +127,18 @@ def test_endpoint_creates_webmention(
verify_webmention.assert_called_once_with(identifier)
-@patch(
- "webmentions_ssg.forms.validators.ensure_public_url",
- new=lambda url: None,
-)
+@patch("webmentions_ssg.forms.validators.is_public_url", new=lambda url: True)
@patch("webmentions_ssg.views.verify_webmention")
def test_endpoint_is_idempotent(
- verify_webmention: Mock,
- app: Flask,
- client: FlaskClient,
+ verify_webmention: Mock, app: Flask, client: FlaskClient
) -> None:
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,
- )
+ 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
@@ -120,21 +158,13 @@ def test_endpoint_is_idempotent(
identifier = webmention.uuid
- assert verify_webmention.call_args_list == [
- call(identifier),
- call(identifier),
- ]
+ assert verify_webmention.call_args_list == [call(identifier), call(identifier)]
-@patch(
- "webmentions_ssg.forms.validators.ensure_public_url",
- new=lambda url: None,
-)
+@patch("webmentions_ssg.forms.validators.is_public_url", new=lambda url: True)
@patch("webmentions_ssg.views.verify_webmention")
def test_resending_resets_failure_state(
- verify_webmention: Mock,
- app: Flask,
- client: FlaskClient,
+ verify_webmention: Mock, app: Flask, client: FlaskClient
) -> None:
source = "https://source.example/post"
target = "https://dennisfink.me/blog/example/"
@@ -153,24 +183,72 @@ def test_resending_resets_failure_state(
identifier = existing.uuid
- response = client.post(
- "/endpoint",
- data={
- "source": source,
- "target": target,
- },
- )
+ response = client.post("/endpoint", data={"source": source, "target": target})
assert response.status_code == 201
with app.app_context():
- webmention = db.session.get(
- ReceivedWebmention,
- identifier,
- )
+ webmention = db.session.get(ReceivedWebmention, identifier)
assert webmention is not None
assert webmention.status == "received"
assert webmention.failure_reason is None
verify_webmention.assert_called_once_with(identifier)
+
+
+@pytest.mark.parametrize("path", ["/sent", f"/sent/{uuid.uuid7()}"])
+def test_sent_views_require_login(client: FlaskClient, path: str) -> None:
+ response = client.get(path)
+
+ assert response.status_code == 302
+ assert "/login" in response.headers["Location"]
+
+
+def test_sent_lists_sources(app: Flask, client: FlaskClient) -> None:
+ log_in(app, client)
+ identifier = create_sent_source(app)
+
+ response = client.get("/sent")
+
+ assert response.status_code == 200
+
+ html = response.get_data(as_text=True)
+
+ assert "blog/example/index.html" in html
+ assert "https://dennisfink.me/blog/example/" in html
+ assert f"/sent/{identifier}" in html
+
+
+def test_sent_source_lists_webmentions(app: Flask, client: FlaskClient) -> None:
+ log_in(app, client)
+ identifier = create_sent_source(app)
+
+ response = client.get(f"/sent/{identifier}")
+
+ assert response.status_code == 200
+
+ html = response.get_data(as_text=True)
+
+ assert "blog/example/index.html" in html
+ assert "https://dennisfink.me/blog/example/" in html
+
+ first_target = "https://example.com/a"
+ second_target = "https://example.com/b"
+
+ assert first_target in html
+ assert second_target in html
+ assert html.index(first_target) < html.index(second_target)
+
+ assert "Webmention endpoint returned HTTP 400" in html
+ assert "https://example.com/webmention" in html
+
+
+def test_sent_source_returns_404_for_unknown_source(
+ app: Flask, client: FlaskClient
+) -> None:
+ log_in(app, client)
+
+ response = client.get(f"/sent/{uuid.uuid7()}")
+
+ assert response.status_code == 404
diff --git a/uv.lock b/uv.lock
index 1fb4556..ef40da9 100644
--- a/uv.lock
+++ b/uv.lock
@@ -73,6 +73,41 @@ wheels = [
]
[[package]]
+name = "charset-normalizer"
+version = "3.4.9"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" },
+ { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" },
+ { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" },
+ { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" },
+ { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" },
+ { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" },
+ { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" },
+ { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" },
+ { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" },
+ { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" },
+ { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" },
+ { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" },
+]
+
+[[package]]
name = "click"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
@@ -273,6 +308,19 @@ wheels = [
]
[[package]]
+name = "html5lib"
+version = "1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "six" },
+ { name = "webencodings" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ac/b6/b55c3f49042f1df3dcd422b7f224f939892ee94f22abcf503a9b7339eaf2/html5lib-1.1.tar.gz", hash = "sha256:b2e5b40261e20f354d198eae92afc10d750afb487ed5e50f9c4eaf07c184146f", size = 272215, upload-time = "2020-06-22T23:32:38.834Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6c/dd/a834df6482147d48e225a49515aabc28974ad5a4ca3215c18a882565b028/html5lib-1.1-py2.py3-none-any.whl", hash = "sha256:0d78f8fde1c230e99fe37986a60526d7049ed4bf8a9fadbad5f00e22e58e041d", size = 112173, upload-time = "2020-06-22T23:32:36.781Z" },
+]
+
+[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
@@ -403,6 +451,29 @@ wheels = [
]
[[package]]
+name = "mf2py"
+version = "2.0.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "beautifulsoup4" },
+ { name = "html5lib" },
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f8/7d/bccfc42706cb24053e7897c33c14e79a8c9c69379d21edfca13ec93ed0ac/mf2py-2.0.1.tar.gz", hash = "sha256:1380924633413b8d72e704b5c86b4382c4b1371699edecc907b01cd21138d7cd", size = 21843, upload-time = "2023-12-08T03:41:59.755Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8e/88/b1d83c9e71cbdaefcec38ea350d2bd6360a9d1e030b090ad4b0fcc421ca1/mf2py-2.0.1-py3-none-any.whl", hash = "sha256:092806e17f1a93db4aafa5e8d3c4124b5e42cd89027e2db48a5248ef4eabde03", size = 25767, upload-time = "2023-12-08T03:41:58.443Z" },
+]
+
+[[package]]
+name = "nodeenv"
+version = "1.10.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
+]
+
+[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
@@ -439,6 +510,19 @@ wheels = [
]
[[package]]
+name = "pyright"
+version = "1.1.411"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "nodeenv" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" },
+]
+
+[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
@@ -478,6 +562,21 @@ wheels = [
]
[[package]]
+name = "requests"
+version = "2.34.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
+]
+
+[[package]]
name = "rfc3987"
version = "1.3.8"
source = { registry = "https://pypi.org/simple" }
@@ -487,6 +586,15 @@ wheels = [
]
[[package]]
+name = "six"
+version = "1.17.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
+]
+
+[[package]]
name = "soupsieve"
version = "2.9.1"
source = { registry = "https://pypi.org/simple" }
@@ -544,6 +652,24 @@ wheels = [
]
[[package]]
+name = "urllib3"
+version = "2.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
+]
+
+[[package]]
+name = "webencodings"
+version = "0.5.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" },
+]
+
+[[package]]
name = "webmentions-ssg"
source = { editable = "." }
dependencies = [
@@ -558,11 +684,14 @@ dependencies = [
{ name = "flask-wtf" },
{ name = "httpx" },
{ name = "huey" },
+ { name = "mf2py" },
{ name = "rfc3987" },
+ { name = "xxhash" },
]
[package.dev-dependencies]
dev = [
+ { name = "pyright" },
{ name = "python-dotenv" },
{ name = "types-wtforms" },
]
@@ -584,11 +713,14 @@ requires-dist = [
{ name = "flask-wtf", specifier = ">=1.3.0" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "huey", specifier = ">=3.1.0" },
+ { name = "mf2py", specifier = ">=2.0.1" },
{ name = "rfc3987", specifier = ">=1.3.8" },
+ { name = "xxhash", specifier = ">=3.8.1" },
]
[package.metadata.requires-dev]
dev = [
+ { name = "pyright", specifier = ">=1.1.411" },
{ name = "python-dotenv", specifier = ">=1.2.2" },
{ name = "types-wtforms", specifier = ">=3.2.1.20260518" },
]
@@ -620,3 +752,54 @@ sdist = { url = "https://files.pythonhosted.org/packages/e9/91/ed9b517da898e3fb7
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/76/bb225c8300f3a0ba28e01df51419c6c9574a297c43d71b29048e03b65deb/wtforms-3.2.2-py3-none-any.whl", hash = "sha256:72b90d5d921bd3119252069cf0301e9c13915f9e52792652bc91c5dda4b79e56", size = 158656, upload-time = "2026-05-03T05:53:46.072Z" },
]
+
+[[package]]
+name = "xxhash"
+version = "3.8.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8e/63/71aa56b151a1b28770037a61bd4e461c2619cfc8866a4fcaf1548605e325/xxhash-3.8.1.tar.gz", hash = "sha256:b0de4bf3aa66363552d52c6a89003c479911f12098cd48a53d44a0f7a25f7c46", size = 86223, upload-time = "2026-07-06T10:49:58.937Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c2/8b/df2ba04f22a6cd6b39f96a6577329a8471a55c90ef8d8e2f7c102363613f/xxhash-3.8.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:9db455cb649dcfe4504d6d68a6d83a7315a99a3ca59871dc3ff840671f99adba", size = 38430, upload-time = "2026-07-06T10:46:31.496Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/4f/6a059e8ad3ca8deedc91dfe335b211204900895152212c03ebbe721de68b/xxhash-3.8.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:affb37f152e55b5e4494bb9d0107f7bb08515c6704fbed82d9f61214d74adc17", size = 36558, upload-time = "2026-07-06T10:46:33.078Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/95/40be178205acce092ae418feb20ac737b32a02c7b864926ed0717354c9f8/xxhash-3.8.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:460261045936975193bfd20549a0de1cd52a33b405cbb972f0d80940c42266cd", size = 31181, upload-time = "2026-07-06T10:46:34.793Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/89/2da4dbf051bafa156c0e3f12012db2b0ac3b84ff37ca1f021f6bfffcdfbb/xxhash-3.8.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:38c887aedb696ef8bca19983206d270848558cfae4a91afa6a2fb05dde58ffc5", size = 32192, upload-time = "2026-07-06T10:46:36.393Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/4e/e000bbae3566bc8e0be771a8a0f294aa99075e3f0bc4ef43922ebffdebc8/xxhash-3.8.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:594131ce1aad18db3689781f806db1b065cdaa04f4df36b4c038d2013aefd0bf", size = 34691, upload-time = "2026-07-06T10:46:38.1Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/4a/ea954aacc7d1c8711880ac2b55da94429a9b4296b151c4fc0966549ca1ee/xxhash-3.8.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:78c794b643d214f1522e7a288bcf5a2de120d26cd170516749a4009dc92722c9", size = 34807, upload-time = "2026-07-06T10:46:39.647Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/29/df598e738ff37558ac627264deb2e560902d9bf7f46d3bd5175c9eee593e/xxhash-3.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af0c9fedc4a2c24e8664953882fe8185f3790b8338c9c700f76f5ad660817711", size = 32410, upload-time = "2026-07-06T10:46:41.359Z" },
+ { url = "https://files.pythonhosted.org/packages/59/9c/81ab40e7d33ada0b3df5d1bc884894d15dbf4f805cd645b685e4606bb8e0/xxhash-3.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:115772daeb71b2f3b9381177017f53e6cf3f3439c840737fdabd21aba6e54920", size = 220564, upload-time = "2026-07-06T10:46:43.463Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/6f/62ae6f5c8606320a0e2a41c2dc8c6d91cc5d63d0f84dd9582e9543779dd8/xxhash-3.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:000435984a0469b0f822fe76f35bddea0f96a4d6521b3339a60a6428cdee1edc", size = 241462, upload-time = "2026-07-06T10:46:45.509Z" },
+ { url = "https://files.pythonhosted.org/packages/15/a1/9c3a0ec6cb524396f551eddd102a76690a795494eb9784fc67542b0daa37/xxhash-3.8.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2f1c68394818e0595569c2ff3cbc1e6d5a36a434e796f5c526b987b80c8a8c62", size = 264491, upload-time = "2026-07-06T10:46:47.655Z" },
+ { url = "https://files.pythonhosted.org/packages/64/f2/700a4674e4308eb59d2fdb973977e82eae231bea5044753fee5c9eec0e0c/xxhash-3.8.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:46b39976d008e2a845758650f0ff7136bca004f40da0c8798bd37ac37860154f", size = 242905, upload-time = "2026-07-06T10:46:49.857Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/8a/72d9874375c8d4cbc64a8cd1d659d5695a8765c3db82efa82dc5bd9f14d0/xxhash-3.8.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d5006c65ec507a333479e76e00e2c368781f16c24ededa764763956b32a0e93e", size = 473873, upload-time = "2026-07-06T10:46:51.953Z" },
+ { url = "https://files.pythonhosted.org/packages/03/f0/6db07590ed7e0a77f186ef0bcea8d52553bf1ba57833e09467a2411f0f2d/xxhash-3.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31a2649bcf1fe97cf11c79848d761df33ac46b3896942d31b640557b486ff6b", size = 220765, upload-time = "2026-07-06T10:46:55.41Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/10/00d12d8b8beabbf49a8bbc626fb9f40445145a8887eb41a6acfb69149ac4/xxhash-3.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f759eed402448c2bdbb492e4fba1f20668ffe29688605ea61f0f67f9e4e386d", size = 310478, upload-time = "2026-07-06T10:46:57.729Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/f9/12a82394eefb0f185d15a7f7b9f627c61c475a72dd83718436a5b84b42ac/xxhash-3.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5f97ecfede10d5b2870383620e2d25c8561e217c7bf9081073802b54248d2b", size = 238393, upload-time = "2026-07-06T10:46:59.87Z" },
+ { url = "https://files.pythonhosted.org/packages/20/f3/53f963e320b9ce678337aa7273f39ce692ded8b99e3d22a866ec722159ab/xxhash-3.8.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1da930bbcac3e8fbe2191850e2abb57977a99348c12c4b385e1058ac1b0a9ecc", size = 268704, upload-time = "2026-07-06T10:47:01.806Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/50/5b5badbd87c82d9f9b5f58ac74a3f29ef08f6fc387b324b8fd482450b862/xxhash-3.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:747476436f6891b9773374ce8d48edcc8b12cb5b61b67c6fb6289633747d088f", size = 225015, upload-time = "2026-07-06T10:47:03.784Z" },
+ { url = "https://files.pythonhosted.org/packages/30/93/3ca68265afe7b4e69435e08a7b6a1d9d0f2a071e889da1f8041ed00fe878/xxhash-3.8.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef09bbc2519a93cd0f95f2ceb5f7b85919dffea643278e02362bf40e3c4bed1", size = 240951, upload-time = "2026-07-06T10:47:05.816Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/a6/27e19670c40f46b5e76e11f2f4713d21054804568425d870670e757172ad/xxhash-3.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a5eed9d41995a83f3332b4e3396abb7f433cac584222bd7e305b606d8353861e", size = 300751, upload-time = "2026-07-06T10:47:07.95Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/fb/b33e27689959fe7ed2ae0b830af41560d65213943983afa9db3a8d481bce/xxhash-3.8.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:53f3ed9118397074ff63a79b66b7fec1c84c782eecde35c5bc94e420a971c231", size = 443480, upload-time = "2026-07-06T10:47:10Z" },
+ { url = "https://files.pythonhosted.org/packages/26/60/0e0d973be5fe280753ef02fbc89349492ad6e903bf1dcb870b668f94b662/xxhash-3.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d247b34bf433c92b41689318fd25d246313cab2275a6a47e2efac178b80d6efe", size = 217657, upload-time = "2026-07-06T10:47:12.196Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/68/c9e3ecef4a9a417d464cb5bd200aa12f73192dee677901b9e08e0ad0d1bb/xxhash-3.8.1-cp314-cp314-win32.whl", hash = "sha256:d58ce8b6cfa9c4d2f230557f69caf7c06369e318015d0b19485095bc2c5963ab", size = 32690, upload-time = "2026-07-06T10:47:14.204Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/99/e9e44588c0b62837bbec5ba7927816de0afa03406b1a0b6c7a7e1d1a30a0/xxhash-3.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:6cee733fe4ccb1737e0997135283c82341e5cfa9cf214b165f9087fb663aaf4f", size = 33460, upload-time = "2026-07-06T10:47:16.021Z" },
+ { url = "https://files.pythonhosted.org/packages/45/2b/64f36d86380b3657ad9031967ab814f3ef31307174650853f69c18932ebc/xxhash-3.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:58346024d47e84f7d8b3e7f5d6faa1d58acbbe49a8771497872059f58c1d8ea5", size = 30092, upload-time = "2026-07-06T10:47:17.81Z" },
+ { url = "https://files.pythonhosted.org/packages/92/cb/18b64bff88c58a0ca209dc533e63cf02d7ae5aa6b1b9a9fd14e81b5dbd60/xxhash-3.8.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:01cab782f8a0a05ecad2c63d7ef10f7ab475f660e0d6419d069418c14d88de7c", size = 35024, upload-time = "2026-07-06T10:47:19.821Z" },
+ { url = "https://files.pythonhosted.org/packages/af/1d/72d8a70520e5dcddb472ea0486d299da3240745a10658290cd7b5690ede2/xxhash-3.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:717b12fdc51819833704e85e6926d76981ffa3f780ef92e33ebb8b26d46bb230", size = 32697, upload-time = "2026-07-06T10:47:21.649Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/b8/e041f555903c56db3d0a731b3d72a6575d75e0ed868b1bd2e5176111ca44/xxhash-3.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ec55d80e9b8a519d742669e0b49e8ce9e6747be42bf3c138158b6543a9c8e489", size = 226044, upload-time = "2026-07-06T10:47:23.612Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/7e/5cdcf06bf6ec4b5d2ac073feb23432ec1d603fd438864cbd2c09c7cb45e1/xxhash-3.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98d8ac1129b4dd39098cffed94d1284aceb61c3aa396757ccc736ac392e4cee5", size = 249899, upload-time = "2026-07-06T10:47:25.812Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/c0/eb7e059cb5e1dba11fd30d2fdf882f56e5a417a3eaa43669d43623767f45/xxhash-3.8.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3bc0fa90830df1e1277f33cc6e55de9990b83c0319fd8c7412866cfde38b025e", size = 274892, upload-time = "2026-07-06T10:47:27.931Z" },
+ { url = "https://files.pythonhosted.org/packages/66/74/a600aaf7cd39957fd1510adeedb1749c1e7eb82bd632a1153d9c664c3135/xxhash-3.8.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c73b6f652f0745425aa6378319c331293b5341756262e9408ed3d45f183375e6", size = 252243, upload-time = "2026-07-06T10:47:30.288Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/04/78d88fa75a6763e5d09bf1b947a392a27988903381b219006f92f3c68fc8/xxhash-3.8.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6114692261eff4266386cdec0f7d87eee24e317ab397c218b7ae6a76b4c6339", size = 482191, upload-time = "2026-07-06T10:47:32.45Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/06/07a8aea1108d682de8791ce608cdf367d75ff4e7e57cd3c154bdc6f47b23/xxhash-3.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df57c0b161ec1b3ed0526a67b0db0914b557e86ee8aae51887aec941b261542", size = 226877, upload-time = "2026-07-06T10:47:34.705Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/b5/86bade5618a524d2c06c4041aa2fe8e5749ce16e88afba60d67c1684a21f/xxhash-3.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9043877a917be88ccf230aa5667c1bd059bce80f4c2727e4defa1b29b7f48b08", size = 319794, upload-time = "2026-07-06T10:47:37.08Z" },
+ { url = "https://files.pythonhosted.org/packages/23/69/9b1a2b89b1621bb740fbcb7beb512f60f99480c1bdc680c0c90e1f56ff75/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:559e3cabe522231909f9de98ef06929edbd53782046bd21aae0c72db6f2a0775", size = 246202, upload-time = "2026-07-06T10:47:39.676Z" },
+ { url = "https://files.pythonhosted.org/packages/08/ea/662ed6cb49f1d34078b6a3a3e0f3d29ff93fd7b5a03c0bc9ecfd9b2159c3/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:264710bd335016f303763ce1275c6486df30bb57c2245c91b224c983d7ac39b8", size = 275628, upload-time = "2026-07-06T10:47:41.99Z" },
+ { url = "https://files.pythonhosted.org/packages/13/f5/49fc9e4c6728a5a3bd8fe639199d2fa67609b3a84f938aff6e8568dd3e4f/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e14800b9b10bb39d7a60ad4a310e403164d7b8988a27ae933d4e40618a44088e", size = 231390, upload-time = "2026-07-06T10:47:44.233Z" },
+ { url = "https://files.pythonhosted.org/packages/64/9d/3acaf8f599c0e0b30e910a3a11ba32929da53c86dc73c7c55fe6a010b4e9/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:ea6a3e734b0fd41b82784a400be946821900daebe610c050a5e0760838a34f99", size = 250600, upload-time = "2026-07-06T10:47:47.611Z" },
+ { url = "https://files.pythonhosted.org/packages/23/64/8acab4c5ec60dbe664b5b9858fd44c2413b07e535b09556a0a5022e78aa6/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cf399fac542a1c7a4734a435b93df2c55e858c7d31abf6c1bdf46f9ae67fbfd0", size = 310032, upload-time = "2026-07-06T10:47:49.88Z" },
+ { url = "https://files.pythonhosted.org/packages/56/47/a0288d7329b1fe63e2734a32d19d444a96ae2b4810f545bc61e561224917/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:44c89d915a75c11d2547eaee9098fcd80398987c4bff2974a0497a925bf92c07", size = 448882, upload-time = "2026-07-06T10:47:52.631Z" },
+ { url = "https://files.pythonhosted.org/packages/01/e7/3071dfd3beb5c38204ce1cf56bf7749fce08de900fa92714b81d1d8ca1f2/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:358650d5bda9c635da699c53adf4e8134af492ecc79c960f917eebf088bb6799", size = 223728, upload-time = "2026-07-06T10:47:55.093Z" },
+ { url = "https://files.pythonhosted.org/packages/12/11/b99949f0ba2b07e9f9ffe83b9c86faa685f9080725dc21a916a607313be5/xxhash-3.8.1-cp314-cp314t-win32.whl", hash = "sha256:c240939e963653054fc7e4a17c382829cda4aa88a7daf0af841715dbded1b497", size = 33150, upload-time = "2026-07-06T10:47:57.274Z" },
+ { url = "https://files.pythonhosted.org/packages/54/1c/09703eb341f8416e74e58d6c6732d4b5c46de59c942363203cb237cc95b0/xxhash-3.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:7258ee276e8772599bc19e14b36f6260306e21b637190cd7cb489a2449d48684", size = 34005, upload-time = "2026-07-06T10:47:59.434Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/f9/6ed7251bb6a8af10ac73b1821c60583d2826e5b2064e45a979c935287c98/xxhash-3.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:8f454166c2ffed45636c8d501741e649851ba2f346c4eb73a64c07ac00428f20", size = 30239, upload-time = "2026-07-06T10:48:01.874Z" },
+]
diff --git a/webmentions_ssg/config.py b/webmentions_ssg/config.py
index 7c448e9..6996b57 100644
--- a/webmentions_ssg/config.py
+++ b/webmentions_ssg/config.py
@@ -14,6 +14,9 @@ class DefaultConfig:
LOGFILE_MAX_BYTES = 20_000_000
LOGFILE_BACKUP_COUNT = 10
SQLALCHEMY_DATABASE_URI = "sqlite:///webmentions-ssg.db"
+ WEBMENTIONS_SSG_SOURCE_DIRECTORY = None
+ WEBMENTIONS_SSG_SOURCE_BASE_URL = None
+ WEBMENTIONS_SSG_SCANNER_SCHEDULE = "0 * * * *"
class DevelopmentConfig(DefaultConfig):
@@ -41,9 +44,7 @@ class TestingConfig(DefaultConfig):
HUEY_URL = "memory://"
- WEBMENTIONS_SSG_ALLOWED_HOSTNAMES = {
- "dennisfink.me",
- }
+ WEBMENTIONS_SSG_ALLOWED_HOSTNAMES = {"dennisfink.me"}
WEBMENTIONS_SSG_MAX_REDIRECTS = 20
WEBMENTIONS_SSG_MAX_SOURCE_BYTES = 1_000_000
diff --git a/webmentions_ssg/forms/validators.py b/webmentions_ssg/forms/validators.py
index e979644..c4b5eda 100644
--- a/webmentions_ssg/forms/validators.py
+++ b/webmentions_ssg/forms/validators.py
@@ -3,11 +3,7 @@ from urllib.parse import urlsplit
from flask import current_app
from wtforms import ValidationError
-from ..url_security import (
- AddressResolutionError,
- NonPublicAddressError,
- ensure_public_url,
-)
+from ..url_security import AddressResolutionError, is_public_url
class NotEqualTo:
@@ -73,15 +69,13 @@ class PublicURL:
self.message = message
def __call__(self, form, field):
- try:
- ensure_public_url(field.data)
- except (
- AddressResolutionError,
- NonPublicAddressError,
- ) as exc:
- message = self.message
+ message = self.message
- if message is None:
- message = field.gettext("URL must resolve to a public address.")
+ if message is None:
+ message = field.gettext("URL must resolve to a public address.")
+ try:
+ if not is_public_url(field.data):
+ raise ValidationError(message)
+ except (AddressResolutionError, ValueError) as exc:
raise ValidationError(message) from exc
diff --git a/webmentions_ssg/models.py b/webmentions_ssg/models.py
index 8c378aa..02c42ef 100644
--- a/webmentions_ssg/models.py
+++ b/webmentions_ssg/models.py
@@ -1,89 +1,189 @@
+from __future__ import annotations
+
import uuid
from datetime import datetime, timezone
+from enum import StrEnum
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 sqlalchemy import (
+ Boolean,
+ DateTime,
+ Enum,
+ ForeignKey,
+ Integer,
+ String,
+ Text,
+ UniqueConstraint,
+ Uuid,
+)
+from sqlalchemy.orm import Mapped, mapped_column, relationship
from werkzeug.security import check_password_hash, generate_password_hash
from . import Base
+class SentWebmentionStatus(StrEnum):
+ SENT = "sent"
+ UNSUPPORTED = "unsupported"
+ FAILED = "failed"
+
+
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,
- )
+ username: Mapped[str] = mapped_column(String(64), index=True, unique=True)
password_hash: Mapped[Optional[str]] = mapped_column(String(256))
- def __repr__(self):
+ def __repr__(self) -> str:
return f"<User {self.username}>"
- def set_password(self, password):
+ def set_password(self, password: str) -> None:
self.password_hash = generate_password_hash(password)
- def check_password(self, password):
- return check_password_hash(
- self.password_hash,
- password,
- )
+ def check_password(self, password: str) -> bool:
+ 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,
+ 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
)
- source: Mapped[str] = mapped_column(
- Text(),
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ default=lambda: datetime.now(timezone.utc),
+ onupdate=lambda: datetime.now(timezone.utc),
nullable=False,
)
- target: Mapped[str] = mapped_column(
- Text(),
- 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 uuid7_to_datetime(self.uuid)
+
+
+class Source(Base):
+ __tablename__ = "sources"
+
+ uuid: Mapped[uuid.UUID] = mapped_column(
+ Uuid(as_uuid=True), primary_key=True, default=uuid.uuid7
+ )
+
+ path: Mapped[str] = mapped_column(Text(), nullable=False, unique=True)
+ url: Mapped[str] = mapped_column(Text(), nullable=False, unique=True)
+
+ content_hash: Mapped[str] = mapped_column(String(32), nullable=False)
+ revision: Mapped[int] = mapped_column(Integer(), nullable=False, default=1)
+
+ last_seen_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), nullable=False
+ )
+ revised_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), nullable=False
+ )
+ deleted_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True), nullable=True, default=None
+ )
+
+ sent_webmentions: Mapped[list[SentWebmention]] = relationship(
+ back_populates="source", cascade="all, delete-orphan", lazy="selectin"
+ )
+
+ @property
+ def created_at(self) -> datetime:
+ return uuid7_to_datetime(self.uuid)
+
+
+class SentWebmention(Base):
+ __tablename__ = "sent_webmentions"
+
+ uuid: Mapped[uuid.UUID] = mapped_column(
+ Uuid(as_uuid=True), primary_key=True, default=uuid.uuid7
)
- status: Mapped[str] = mapped_column(
- Text(),
+ source_id: Mapped[uuid.UUID] = mapped_column(
+ Uuid(as_uuid=True),
+ ForeignKey("sources.uuid", ondelete="CASCADE"),
nullable=False,
- default="received",
+ index=True,
)
- failure_reason: Mapped[str | None] = mapped_column(
- Text(),
+
+ target: Mapped[str] = mapped_column(Text(), nullable=False)
+
+ active: Mapped[bool] = mapped_column(Boolean(), nullable=False, default=True)
+
+ desired_revision: Mapped[int] = mapped_column(Integer(), nullable=False)
+ processed_revision: Mapped[int | None] = mapped_column(
+ Integer(), nullable=True, default=None
+ )
+ sent_revision: Mapped[int | None] = mapped_column(
+ Integer(), nullable=True, default=None
+ )
+
+ status: Mapped[SentWebmentionStatus | None] = mapped_column(
+ Enum(
+ SentWebmentionStatus,
+ name="sent_webmention_status",
+ values_callable=lambda enum_type: [member.value for member in enum_type],
+ native_enum=False,
+ ),
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,
+ failure_reason: Mapped[str | None] = mapped_column(
+ Text(), nullable=True, default=None
+ )
+
+ endpoint: Mapped[str | None] = mapped_column(Text(), nullable=True, default=None)
+ response_status: Mapped[int | None] = mapped_column(
+ Integer(), nullable=True, default=None
+ )
+ status_url: Mapped[str | None] = mapped_column(Text(), nullable=True, default=None)
+
+ last_attempted_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True), nullable=True, default=None
+ )
+ last_sent_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True), nullable=True, default=None
)
+ source: Mapped[Source] = relationship(back_populates="sent_webmentions")
+
__table_args__ = (
UniqueConstraint(
- "source",
- "target",
- name="uq_webmention_source_target",
+ "source_id", "target", name="uq_sent_webmention_source_target"
),
)
@property
- def verified(self) -> bool:
- return self.status == "verified"
+ def pending(self) -> bool:
+ return (
+ self.processed_revision is None
+ or self.processed_revision < self.desired_revision
+ )
@property
def created_at(self) -> datetime:
- return datetime.fromtimestamp(
- self.uuid.time / 1000,
- tz=timezone.utc,
- )
+ return uuid7_to_datetime(self.uuid)
+
+
+def uuid7_to_datetime(identifier: uuid.UUID) -> datetime:
+ return datetime.fromtimestamp(identifier.time / 1000, tz=timezone.utc)
diff --git a/webmentions_ssg/tasks/consumer.py b/webmentions_ssg/tasks/consumer.py
index 5b85d0e..e53fce5 100644
--- a/webmentions_ssg/tasks/consumer.py
+++ b/webmentions_ssg/tasks/consumer.py
@@ -1,8 +1,19 @@
+from huey import Huey, crontab
+
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
+def create_consumer() -> Huey:
+ app = create_app()
+
+ from . import receiver, scanner, sender # noqa: E402, F401
+
+ schedule = crontab(
+ *app.config["WEBMENTIONS_SSG_SCANNER_SCHEDULE"].split(), strict=True
+ )
+ HUEY.periodic_task(schedule)(scanner.scan_sources)
+
+ return HUEY.huey
+
-huey = HUEY.huey
+huey = create_consumer()
diff --git a/webmentions_ssg/tasks/extension.py b/webmentions_ssg/tasks/extension.py
index ebdaa76..15bc669 100644
--- a/webmentions_ssg/tasks/extension.py
+++ b/webmentions_ssg/tasks/extension.py
@@ -27,10 +27,7 @@ class Huey:
huey_class, storage_kwargs = self.backend_from_url(url)
self.app = app
- self._huey = huey_class(
- **config,
- **storage_kwargs,
- )
+ self._huey = huey_class(**config, **storage_kwargs)
app.extensions["huey"] = self
diff --git a/webmentions_ssg/tasks/receiver.py b/webmentions_ssg/tasks/receiver.py
index 9475866..39def73 100644
--- a/webmentions_ssg/tasks/receiver.py
+++ b/webmentions_ssg/tasks/receiver.py
@@ -10,11 +10,7 @@ from .. import APP_NAME, VERSION
from .. import DATABASE as db
from .. import HUEY as huey
from ..models import ReceivedWebmention
-from ..url_security import (
- AddressResolutionError,
- NonPublicAddressError,
- ensure_public_url,
-)
+from ..url_security import AddressResolutionError, is_public_url
class VerificationError(Exception):
@@ -46,33 +42,20 @@ HTML_URL_ATTRIBUTES = {
"track",
"video",
},
- "cite": {
- "blockquote",
- "del",
- "ins",
- "q",
- },
+ "cite": {"blockquote", "del", "ins", "q"},
}
-def html_mentions_target(
- body: bytes,
- source_url: str,
- target_url: str,
-) -> bool:
+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_href := base_element.get("href"), str
):
- base_url = urljoin(
- source_url,
- base_href.strip(),
- )
+ base_url = urljoin(source_url, base_href.strip())
for attribute, selectors in HTML_URL_ATTRIBUTES.items():
selector = ", ".join(
@@ -85,19 +68,10 @@ def html_mentions_target(
)
for element in document.select(selector):
- if not isinstance(
- reference := element.get(attribute),
- str,
- ):
+ if not isinstance(reference := element.get(attribute), str):
continue
- if (
- urljoin(
- base_url,
- reference.strip(),
- )
- == target_url
- ):
+ if urljoin(base_url, reference.strip()) == target_url:
return True
return False
@@ -111,9 +85,10 @@ def text_mentions_target(body: str, target_url: str) -> bool:
def ensure_public_request(request: httpx.Request) -> None:
"""Prevent requests to non-public network addresses."""
try:
- ensure_public_url(str(request.url))
- except NonPublicAddressError as exc:
- raise VerificationError("Source resolves to a non-public address") from exc
+ if not is_public_url(str(request.url)):
+ raise VerificationError("Source resolves to a non-public address")
+ except ValueError as exc:
+ raise VerificationError("Source URL has no hostname") from exc
except AddressResolutionError as exc:
raise TemporaryFetchError("Source hostname could not be resolved") from exc
@@ -132,9 +107,7 @@ def fetch_source(source_url: str) -> tuple[httpx.Response, bytes]:
follow_redirects=True,
max_redirects=current_app.config.get("WEBMENTIONS_SSG_MAX_REDIRECTS", 20),
trust_env=False,
- event_hooks={
- "request": [ensure_public_request],
- },
+ event_hooks={"request": [ensure_public_request]},
) as client:
with client.stream("GET", source_url) as response:
match response.status_code:
@@ -149,8 +122,7 @@ def fetch_source(source_url: str) -> tuple[httpx.Response, bytes]:
raise VerificationError(f"Source returned HTTP {status}")
max_source_bytes = current_app.config.get(
- "WEBMENTIONS_SSG_MAX_SOURCE_BYTES",
- 1_000_000,
+ "WEBMENTIONS_SSG_MAX_SOURCE_BYTES", 1_000_000
)
if (content_length := response.headers.get("Content-Length")) is not None:
@@ -185,14 +157,10 @@ def source_mentions_target(source_url: str, target_url: str) -> bool:
case "text/plain":
try:
decoded_body = body.decode(
- response.encoding or "utf-8",
- errors="replace",
+ response.encoding or "utf-8", errors="replace"
)
except LookupError:
- decoded_body = body.decode(
- "utf-8",
- errors="replace",
- )
+ decoded_body = body.decode("utf-8", errors="replace")
return text_mentions_target(decoded_body, target_url)
case _:
raise VerificationError(
@@ -208,8 +176,7 @@ def verify_webmention(webmention_uuid: uuid.UUID) -> None:
if webmention is None:
current_app.logger.warning(
- "Cannot verify unknown ReceivedWebmention %s",
- webmention_uuid,
+ "Cannot verify unknown ReceivedWebmention %s", webmention_uuid
)
return
diff --git a/webmentions_ssg/tasks/scanner.py b/webmentions_ssg/tasks/scanner.py
new file mode 100644
index 0000000..f80e5f4
--- /dev/null
+++ b/webmentions_ssg/tasks/scanner.py
@@ -0,0 +1,397 @@
+from collections.abc import Iterator
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from fnmatch import fnmatchcase
+from itertools import chain
+from pathlib import Path
+from typing import Any
+from urllib.parse import quote, urldefrag, urljoin, urlsplit
+
+import mf2py
+import sqlalchemy as sa
+import xxhash
+from bs4 import BeautifulSoup, Tag
+from flask import current_app
+from sqlalchemy.orm import selectinload
+
+from .. import DATABASE as db
+from .. import HUEY as huey
+from ..models import SentWebmention, Source
+from ..url_security import is_http_url
+from .sender import send_webmention
+
+REACTION_PROPERTIES = ("in-reply-to", "like-of", "repost-of", "bookmark-of")
+
+
+class SourceScanError(Exception):
+ """A source document cannot safely be processed."""
+
+
+@dataclass(frozen=True)
+class ScannedSource:
+ path: str
+ url: str
+ content_hash: str
+ targets: frozenset[str]
+
+
+def source_url_for_path(relative_path: Path, base_url: str) -> str:
+ """Derive the public source URL from its relative filesystem path."""
+ directory = relative_path.parent.as_posix()
+
+ return urljoin(base_url, f"{quote(directory, safe='/')}/")
+
+
+def canonical_url(
+ document: BeautifulSoup, *, relative_path: Path, base_url: str | None
+) -> str | None:
+ """Return the canonical URL declared by the document."""
+ if (link := document.select_one('link[rel~="canonical"][href]')) is not None:
+ href = link.get("href")
+
+ if not isinstance(href, str) or not (href := href.strip()):
+ raise SourceScanError("Document contains an empty canonical URL")
+
+ if is_http_url(href):
+ return href
+
+ if base_url is None:
+ raise SourceScanError(
+ "Document contains a relative canonical URL, but "
+ "WEBMENTIONS_SSG_SOURCE_BASE_URL is not configured"
+ )
+
+ resolved = urljoin(source_url_for_path(relative_path, base_url), href)
+
+ if not is_http_url(resolved):
+ raise SourceScanError(
+ f"Canonical URL is not a valid HTTP or HTTPS URL: {href!r}"
+ )
+
+ return resolved
+
+
+def property_urls(entry: dict[str, Any], property_name: str) -> Iterator[str]:
+ """Yield URL values from a microformats property."""
+ properties = entry.get("properties")
+
+ if not isinstance(properties, dict):
+ return
+
+ values = properties.get(property_name)
+
+ if not isinstance(values, list):
+ return
+
+ yield from (value for value in values if isinstance(value, str))
+
+
+def parse_entry(element: Tag, base_url: str) -> dict[str, Any]:
+ """Parse the source h-entry with mf2py."""
+ parsed = mf2py.parse(doc=str(element), url=base_url)
+
+ if not isinstance(parsed, dict):
+ raise SourceScanError("Microformats parser did not return a document object")
+
+ items = parsed.get("items")
+
+ if not isinstance(items, list) or len(items) != 1:
+ raise SourceScanError("Expected exactly one parsed h-entry")
+
+ entry = items[0]
+
+ if not isinstance(entry, dict):
+ raise SourceScanError("Parsed h-entry is not an object")
+
+ types = entry.get("type")
+
+ if not isinstance(types, list) or "h-entry" not in types:
+ raise SourceScanError("Parsed microformats item is not an h-entry")
+
+ return entry
+
+
+def primary_entry(
+ document: BeautifulSoup, source_url: str
+) -> tuple[Tag, dict[str, Any]]:
+ """Return and validate the source h-entry."""
+ entries = document.find_all(class_="h-entry")
+
+ if len(entries) != 1:
+ raise SourceScanError(f"Expected exactly one h-entry, found {len(entries)}")
+
+ entry_element = entries[0]
+ mf2_entry = parse_entry(entry_element, source_url)
+
+ urls = tuple(property_urls(mf2_entry, "url"))
+
+ if source_url not in urls:
+ raise SourceScanError(
+ f"The h-entry u-url does not match the source URL {source_url!r}: {urls!r}"
+ )
+
+ return entry_element, mf2_entry
+
+
+def content_element(entry: Tag) -> Tag:
+ """Return the source h-entry's e-content element."""
+ contents = entry.find_all(class_="e-content")
+
+ if len(contents) != 1:
+ raise SourceScanError(f"Expected exactly one e-content, found {len(contents)}")
+
+ return contents[0]
+
+
+def normalize_target(value: str, *, base_url: str, source_url: str) -> str | None:
+ """Resolve and validate a possible Webmention target URL."""
+ value = value.strip()
+
+ if not value:
+ return None
+
+ target = urljoin(base_url, value)
+
+ if not is_http_url(target):
+ return None
+
+ if urldefrag(target)[0] == urldefrag(source_url)[0]:
+ return None
+
+ return target
+
+
+def iter_targets(
+ content: Tag,
+ mf2_entry: dict[str, Any],
+ *,
+ base_url: str,
+ source_url: str,
+ ignored_hostnames: tuple[str, ...] = (),
+) -> Iterator[str]:
+ """Yield outgoing Webmention targets from an h-entry."""
+ hrefs = (
+ href
+ for element in content.find_all("a", href=True)
+ if isinstance(href := element.get("href"), str)
+ )
+
+ reactions = chain.from_iterable(
+ property_urls(mf2_entry, property_name) for property_name in REACTION_PROPERTIES
+ )
+
+ for value in chain(hrefs, reactions):
+ target = normalize_target(value, base_url=base_url, source_url=source_url)
+ if target is not None:
+ hostname = urlsplit(target).hostname or ""
+ if not any(fnmatchcase(hostname, pattern) for pattern in ignored_hostnames):
+ yield target
+
+
+def scan_source_file(
+ path: Path,
+ *,
+ root: Path,
+ base_url: str | None,
+ ignored_hostnames: tuple[str, ...] = (),
+) -> ScannedSource:
+ """Parse one generated source document."""
+ document = BeautifulSoup(path.read_bytes(), "html5lib")
+
+ relative_path = path.relative_to(root)
+
+ source_url = canonical_url(document, relative_path=relative_path, base_url=base_url)
+
+ if source_url is None:
+ if base_url is None:
+ raise SourceScanError(
+ "Document has no canonical URL and "
+ "WEBMENTIONS_SSG_SOURCE_BASE_URL is not configured"
+ )
+
+ source_url = source_url_for_path(relative_path, base_url)
+
+ entry_element, mf2_entry = primary_entry(document, source_url)
+
+ content = content_element(entry_element)
+
+ return ScannedSource(
+ path=relative_path.as_posix(),
+ url=source_url,
+ content_hash=xxhash.xxh3_128_hexdigest(str(entry_element).encode("utf-8")),
+ targets=frozenset(
+ iter_targets(
+ content,
+ mf2_entry,
+ base_url=source_url,
+ source_url=source_url,
+ ignored_hostnames=ignored_hostnames,
+ )
+ ),
+ )
+
+
+def create_source(scanned: ScannedSource, scan_time: datetime) -> Source:
+ """Create a source from a newly discovered document."""
+ source = Source(
+ path=scanned.path,
+ url=scanned.url,
+ content_hash=scanned.content_hash,
+ revision=1,
+ last_seen_at=scan_time,
+ revised_at=scan_time,
+ deleted_at=None,
+ )
+
+ for target in scanned.targets:
+ source.sent_webmentions.append(
+ SentWebmention(target=target, active=True, desired_revision=1)
+ )
+
+ return source
+
+
+def update_source(
+ source: Source, scanned: ScannedSource, scan_time: datetime
+) -> Source:
+ """Update a source from a newly scanned revision."""
+ if source.url != scanned.url:
+ raise SourceScanError(
+ f"Source path {source.path!r} changed public URL "
+ f"from {source.url!r} to {scanned.url!r}"
+ )
+
+ source.last_seen_at = scan_time
+
+ if source.deleted_at is None and source.content_hash == scanned.content_hash:
+ return source
+
+ source.revision += 1
+ source.content_hash = scanned.content_hash
+ source.revised_at = scan_time
+ source.deleted_at = None
+
+ existing = {webmention.target: webmention for webmention in source.sent_webmentions}
+
+ for webmention in existing.values():
+ webmention.active = webmention.target in scanned.targets
+ webmention.desired_revision = source.revision
+
+ if not webmention.active and webmention.sent_revision is None:
+ webmention.processed_revision = source.revision
+
+ for target in scanned.targets:
+ if target not in existing:
+ source.sent_webmentions.append(
+ SentWebmention(
+ target=target, active=True, desired_revision=source.revision
+ )
+ )
+
+ return source
+
+
+@huey.lock_task("scan-webmention-sources")
+def scan_sources() -> None:
+ """Scan generated source documents and queue pending Webmentions."""
+ directory = current_app.config.get("WEBMENTIONS_SSG_SOURCE_DIRECTORY")
+
+ if directory is None:
+ return
+
+ root = Path(directory).expanduser().resolve(strict=True)
+
+ if not root.is_dir():
+ raise RuntimeError(f"Source directory is not a directory: {root}")
+
+ base_url = current_app.config.get("WEBMENTIONS_SSG_SOURCE_BASE_URL")
+
+ if base_url is not None:
+ if not is_http_url(base_url):
+ raise RuntimeError(
+ "WEBMENTIONS_SSG_SOURCE_BASE_URL must be an absolute HTTP or HTTPS URL"
+ )
+ base_url = f"{base_url.rstrip('/')}/"
+
+ ignored_hostnames = tuple(
+ pattern.lower().rstrip(".")
+ for pattern in current_app.config.get("WEBMENTIONS_SSG_IGNORED_HOSTNAMES", ())
+ )
+
+ sources_by_path = {
+ source.path: source
+ for source in db.session.scalars(
+ sa.select(Source).options(selectinload(Source.sent_webmentions))
+ )
+ }
+
+ seen_paths: set[str] = set()
+ scanned_count = 0
+ scan_time = datetime.now(timezone.utc)
+
+ for index_path in root.rglob("index.html"):
+ relative_path = index_path.relative_to(root)
+
+ if relative_path.parent == Path("."):
+ continue
+
+ seen_paths.add(relative_path.as_posix())
+
+ try:
+ scanned = scan_source_file(
+ index_path,
+ root=root,
+ base_url=base_url,
+ ignored_hostnames=ignored_hostnames,
+ )
+
+ if (source := sources_by_path.get(scanned.path)) is None:
+ source = create_source(scanned, scan_time)
+ db.session.add(source)
+ else:
+ source = update_source(source, scanned, scan_time)
+
+ sources_by_path[source.path] = source
+ scanned_count += 1
+
+ except (OSError, SourceScanError) as exc:
+ current_app.logger.warning("Could not scan source %s: %s", index_path, exc)
+
+ for path, source in sources_by_path.items():
+ if source.deleted_at is not None or path in seen_paths:
+ continue
+
+ source.revision += 1
+ source.revised_at = scan_time
+ source.deleted_at = scan_time
+
+ for webmention in source.sent_webmentions:
+ webmention.active = False
+ webmention.desired_revision = source.revision
+
+ if webmention.sent_revision is None:
+ webmention.processed_revision = source.revision
+
+ # Persist the desired state before queueing any work.
+ db.session.commit()
+
+ pending_count = 0
+
+ for identifier in db.session.scalars(
+ sa.select(SentWebmention.uuid)
+ .where(
+ sa.or_(
+ SentWebmention.processed_revision.is_(None),
+ SentWebmention.processed_revision < SentWebmention.desired_revision,
+ )
+ )
+ .order_by(SentWebmention.uuid)
+ ):
+ send_webmention(identifier)
+ pending_count += 1
+
+ current_app.logger.info(
+ "Webmention source scan complete: %d sources scanned, %d pending sends",
+ scanned_count,
+ pending_count,
+ )
diff --git a/webmentions_ssg/tasks/sender.py b/webmentions_ssg/tasks/sender.py
new file mode 100644
index 0000000..fa9b624
--- /dev/null
+++ b/webmentions_ssg/tasks/sender.py
@@ -0,0 +1,315 @@
+import re
+import uuid
+from datetime import datetime, timezone
+from urllib.parse import urljoin
+
+import httpx
+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 SentWebmention, SentWebmentionStatus
+from ..url_security import AddressResolutionError, is_http_url, is_public_url
+
+LINK_SPLIT = re.compile(r",\s*(?=<)")
+
+DISCOVERY_HEADERS = {"Accept": "text/html, application/xhtml+xml;q=0.9"}
+
+
+class SenderError(Exception):
+ """A Webmention cannot be sent."""
+
+
+class TemporarySenderError(SenderError):
+ """A Webmention send failed for a potentially temporary reason."""
+
+
+class PermanentSenderError(SenderError):
+ """A Webmention send failed permanently for this source revision."""
+
+
+def temporary_http_status(status: int) -> bool:
+ """Return whether an HTTP status should be retried."""
+ return status in {408, 425, 429} or 500 <= status <= 599
+
+
+def ensure_public_request(request: httpx.Request) -> None:
+ """Prevent requests to non-public network addresses."""
+ try:
+ if not is_public_url(str(request.url)):
+ raise PermanentSenderError("Request resolves to a non-public address")
+ except AddressResolutionError as exc:
+ raise TemporarySenderError("Request hostname could not be resolved") from exc
+
+
+def resolve_endpoint(response: httpx.Response, href: str) -> str:
+ """Resolve and validate a discovered Webmention endpoint."""
+ endpoint = urljoin(str(response.url), href.strip())
+
+ if not is_http_url(endpoint):
+ raise PermanentSenderError(f"Invalid Webmention endpoint: {endpoint!r}")
+
+ return endpoint
+
+
+def parse_link_value(value: str) -> tuple[str, set[str]] | None:
+ """Parse a Link header value into its target and relations."""
+ value = value.strip()
+
+ if not value.startswith("<"):
+ return None
+
+ href, separator, parameters = value[1:].partition(">")
+
+ if not separator:
+ return None
+
+ relations: set[str] = set()
+
+ for parameter in parameters.split(";"):
+ name, separator, value = parameter.partition("=")
+
+ if separator and name.strip().lower() == "rel":
+ relations.update(
+ relation.lower() for relation in value.strip().strip("\"'").split()
+ )
+
+ return href, relations
+
+
+def endpoint_from_headers(response: httpx.Response) -> str | None:
+ """Return the first Webmention endpoint advertised by HTTP Link."""
+ for header in response.headers.get_list("Link"):
+ for value in LINK_SPLIT.split(header):
+ if (link := parse_link_value(value)) is None:
+ continue
+
+ href, relations = link
+
+ if "webmention" in relations:
+ return resolve_endpoint(response, href)
+
+ return None
+
+
+def endpoint_from_html(response: httpx.Response, body: bytes) -> str | None:
+ """Return the first HTML Webmention endpoint in document order."""
+ document = BeautifulSoup(body, "html5lib")
+
+ for element in document.find_all(["link", "a"], href=True):
+ relations = element.get("rel")
+
+ if isinstance(relations, str):
+ relations = relations.split()
+
+ if not relations:
+ continue
+
+ if not any(
+ isinstance(relation, str) and relation.lower() == "webmention"
+ for relation in relations
+ ):
+ continue
+
+ href = element.get("href")
+
+ if not isinstance(href, str):
+ continue
+
+ return resolve_endpoint(response, href)
+
+ return None
+
+
+def read_target_body(response: httpx.Response) -> bytes:
+ """Read a target document up to the configured size limit."""
+ max_bytes = current_app.config.get("WEBMENTIONS_SSG_MAX_TARGET_BYTES", 1_000_000)
+
+ if (content_length := response.headers.get("Content-Length")) is not None:
+ try:
+ if int(content_length) > max_bytes:
+ raise PermanentSenderError("Target 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_bytes:
+ raise PermanentSenderError("Target document is too large")
+
+ return bytes(body)
+
+
+def discover_webmention_endpoint(client: httpx.Client, target: str) -> str | None:
+ """Discover the Webmention endpoint advertised by a target."""
+ head_response = client.head(target, headers=DISCOVERY_HEADERS)
+
+ if endpoint := endpoint_from_headers(head_response):
+ return endpoint
+
+ with client.stream("GET", target, headers=DISCOVERY_HEADERS) as response:
+ if endpoint := endpoint_from_headers(response):
+ return endpoint
+
+ if temporary_http_status(response.status_code):
+ raise TemporarySenderError(f"Target returned HTTP {response.status_code}")
+
+ if not response.is_success:
+ raise PermanentSenderError(f"Target returned HTTP {response.status_code}")
+
+ media_type = (
+ response.headers.get("Content-Type", "").partition(";")[0].strip().lower()
+ )
+
+ if media_type not in {"text/html", "application/xhtml+xml"}:
+ return None
+
+ return endpoint_from_html(response, read_target_body(response))
+
+
+def post_webmention(
+ client: httpx.Client, *, endpoint: str, source: str, target: str
+) -> tuple[int, str | None]:
+ """POST a Webmention and return its status code and status URL."""
+ with client.stream(
+ "POST", endpoint, data={"source": source, "target": target}
+ ) as response:
+ status_url = None
+
+ if response.status_code == 201 and (
+ location := response.headers.get("Location")
+ ):
+ status_url = urljoin(str(response.url), location)
+
+ return (response.status_code, status_url)
+
+
+def attempt_is_current(webmention: SentWebmention, revision: int) -> bool:
+ """Return whether an attempt still represents the desired revision."""
+ db.session.refresh(webmention)
+
+ return webmention.desired_revision == revision and (
+ webmention.processed_revision is None
+ or webmention.processed_revision < revision
+ )
+
+
+@huey.task(retries=2, retry_delay=50)
+def send_webmention(webmention_uuid: uuid.UUID) -> None:
+ """Discover a receiver endpoint and send one Webmention."""
+ webmention = db.session.get(SentWebmention, webmention_uuid)
+
+ if webmention is None:
+ current_app.logger.warning(
+ "Cannot send unknown SentWebmention %s", webmention_uuid
+ )
+ return
+
+ if (
+ webmention.processed_revision is not None
+ and webmention.processed_revision >= webmention.desired_revision
+ ):
+ return
+
+ revision = webmention.desired_revision
+ source = webmention.source.url
+ target = webmention.target
+
+ webmention.last_attempted_at = datetime.now(timezone.utc)
+ webmention.endpoint = None
+ webmention.response_status = None
+ webmention.status_url = None
+
+ db.session.commit()
+
+ endpoint: str | None = None
+ response_status: int | None = None
+
+ try:
+ with httpx.Client(
+ headers={"User-Agent": (f"{APP_NAME}/{VERSION} SentWebmention")},
+ 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,
+ event_hooks={"request": [ensure_public_request]},
+ ) as client:
+ endpoint = discover_webmention_endpoint(client, target)
+
+ if endpoint is None:
+ if not attempt_is_current(webmention, revision):
+ return
+
+ webmention.processed_revision = revision
+ webmention.status = SentWebmentionStatus.UNSUPPORTED
+ webmention.failure_reason = "No Webmention endpoint discovered"
+ webmention.endpoint = None
+ webmention.response_status = None
+
+ db.session.commit()
+ return
+
+ (response_status, status_url) = post_webmention(
+ client, endpoint=endpoint, source=source, target=target
+ )
+
+ if not (200 <= response_status <= 299):
+ message = f"Webmention endpoint returned HTTP {response_status}"
+
+ if temporary_http_status(response_status):
+ raise TemporarySenderError(message)
+
+ raise PermanentSenderError(message)
+
+ except (TemporarySenderError, httpx.RequestError) as exc:
+ if not attempt_is_current(webmention, revision):
+ return
+
+ webmention.status = SentWebmentionStatus.FAILED
+ webmention.failure_reason = str(exc) or "Webmention request failed"
+ webmention.endpoint = endpoint
+ webmention.response_status = response_status
+
+ db.session.commit()
+
+ # Leave processed_revision unchanged. Huey may retry the
+ # attempt, and the periodic scanner will also rediscover
+ # outstanding work.
+ raise
+
+ except PermanentSenderError as exc:
+ if not attempt_is_current(webmention, revision):
+ return
+
+ webmention.processed_revision = revision
+ webmention.status = SentWebmentionStatus.FAILED
+ webmention.failure_reason = str(exc)
+ webmention.endpoint = endpoint
+ webmention.response_status = response_status
+
+ db.session.commit()
+ return
+
+ if not attempt_is_current(webmention, revision):
+ return
+
+ webmention.processed_revision = revision
+ webmention.sent_revision = revision
+
+ webmention.status = SentWebmentionStatus.SENT
+ webmention.failure_reason = None
+
+ webmention.endpoint = endpoint
+ webmention.response_status = response_status
+ webmention.status_url = status_url
+
+ webmention.last_sent_at = datetime.now(timezone.utc)
+
+ db.session.commit()
diff --git a/webmentions_ssg/templates/base.html b/webmentions_ssg/templates/base.html
index 237a437..eb11b8e 100644
--- a/webmentions_ssg/templates/base.html
+++ b/webmentions_ssg/templates/base.html
@@ -22,8 +22,9 @@
{% 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>
+ href='{{ url_for("root.received") }}'>Received</a>
+ <a class="nav-link{% if request.endpoint == 'root.sent' %} active{% endif %}"
+ href='{{ url_for("root.sent") }}'>Sent</a>
<a class="nav-link" href="{{ url_for("root.logout") }}">Logout</a>
</div>
{% endif %}
diff --git a/webmentions_ssg/templates/received.html b/webmentions_ssg/templates/received.html
index 64f225e..a44b706 100644
--- a/webmentions_ssg/templates/received.html
+++ b/webmentions_ssg/templates/received.html
@@ -3,7 +3,7 @@
{% 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>
+ <h1 class="mb-0">Received Webmentions</h1>
<span class="text-body-secondary">{{ webmentions.total }} total</span>
</div>
{% if webmentions.items %}
diff --git a/webmentions_ssg/templates/sent.html b/webmentions_ssg/templates/sent.html
new file mode 100644
index 0000000..69a91d8
--- /dev/null
+++ b/webmentions_ssg/templates/sent.html
@@ -0,0 +1,61 @@
+{% 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="mb-0">Sources</h1>
+ <span class="text-body-secondary">{{ sources.total }} sources</span>
+ </div>
+ {% if sources.items %}
+ <div class="table-responsive">
+ <table class="table table-bordered table-striped table-hover align-middle">
+ <thead>
+ <tr>
+ <th scope="col">Path</th>
+ <th scope="col">URL</th>
+ <th scope="col">Webmentions</th>
+ <th scope="col">Revision</th>
+ <th scope="col">Created</th>
+ <th scope="col">Last seen</th>
+ <th scope="col">Status</th>
+ </tr>
+ </thead>
+ <tbody>
+ {% for source in sources %}
+ <tr>
+ <td class="text-nowrap">
+ <a href="{{ url_for('root.sent_source', identifier=source.uuid) }}">{{ source.path }}</a>
+ </td>
+ <td class="text-break">
+ <a href="{{ source.url }}">{{ source.url }}</a>
+ </td>
+ <td>{{ source.sent_webmentions | length }}</td>
+ <td>{{ source.revision }}</td>
+ <td class="text-nowrap">
+ <time datetime="{{ source.created_at.isoformat() }}">
+ {{ source.created_at.strftime("%Y-%m-%d %H:%M:%S") }}
+ </time>
+ </td>
+ <td class="text-nowrap">
+ <time datetime="{{ source.last_seen_at.isoformat() }}">
+ {{ source.last_seen_at.strftime("%Y-%m-%d %H:%M:%S") }}
+ </time>
+ </td>
+ <td>
+ {% if source.deleted_at is none %}
+ <span class="badge text-bg-success">Active</span>
+ {% else %}
+ <span class="badge text-bg-danger">Deleted</span>
+ {% endif %}
+ </td>
+ </tr>
+ {% endfor %}
+ </tbody>
+ </table>
+ </div>
+ {% if sources.pages > 1 %}
+ <div class="mt-3">{{ render_pagination(sources) }}</div>
+ {% endif %}
+ {% else %}
+ <p class="text-body-secondary">No Webmention sources have been discovered yet.</p>
+ {% endif %}
+{% endblock %}
diff --git a/webmentions_ssg/templates/sent_source.html b/webmentions_ssg/templates/sent_source.html
new file mode 100644
index 0000000..6d397f2
--- /dev/null
+++ b/webmentions_ssg/templates/sent_source.html
@@ -0,0 +1,155 @@
+{% extends "base.html" %}
+{% block content %}
+ <div class="mb-3">
+ <a href="{{ url_for("root.sent") }}">&larr; Back to sources</a>
+ </div>
+ <div class="d-flex justify-content-between align-items-start mb-3">
+ <h1 class="mb-0">Source Details</h1>
+ </div>
+ <div class="d-flex justify-content-between align-items-start mb-3">
+ <dl class="row">
+ <dt class="col-sm-2">UUID</dt>
+ <dd class="col-sm-4">
+ {{ source.uuid }}
+ </dd>
+ <dt class="col-sm-2">Hash</dt>
+ <dd class="col-sm-4">
+ {{ source.content_hash }}
+ </dd>
+ <dt class="col-sm-2">Path</dt>
+ <dd class="col-sm-4">
+ {{ source.path }}
+ </dd>
+ <dt class="col-sm-2">URL</dt>
+ <dd class="col-sm-4">
+ <a href="{{ source.url }}" class="text-break">{{ source.url }}</a>
+ </dd>
+ <dt class="col-sm-2">Created at</dt>
+ <dd class="col-sm-4">
+ <time datetime="{{ source.created_at.isoformat() }}">
+ {{ source.created_at.strftime("%Y-%m-%d %H:%M:%S") }}
+ </time>
+ </dd>
+ <dt class="col-sm-2">Last seen at</dt>
+ <dd class="col-sm-4">
+ <time datetime="{{ source.last_seen_at.isoformat() }}">
+ {{ source.last_seen_at.strftime("%Y-%m-%d %H:%M:%S") }}
+ </time>
+ </dd>
+ <dt class="col-sm-2">Revised at</dt>
+ <dd class="col-sm-4">
+ <time datetime="{{ source.revised_at.isoformat() }}">
+ {{ source.revised_at.strftime("%Y-%m-%d %H:%M:%S") }}
+ </time>
+ </dd>
+ <dt class="col-sm-2">Deleted at</dt>
+ <dd class="col-sm-4">
+ {% if source.deleted_at is none %}
+ N/A
+ {% else %}
+ <time datetime="{{ source.deleted_at.isoformat() }}">
+ {{ source.deleted_at.strftime("%Y-%m-%d %H:%M:%S") }}
+ </time>
+ {% endif %}
+ </dd>
+ <dt class="col-sm-2">Revision</dt>
+ <dd class="col-sm-4">
+ {{ source.revision }}
+ </dd>
+ </dl>
+ </div>
+ <div class="d-flex justify-content-between align-items-start mb-3">
+ <h2 class="mb-0">Sent Webmentions</h2>
+ <span class="text-body-secondary">{{ webmentions | length }} webmentions</span>
+ </div>
+ {% if webmentions %}
+ <div class="table-responsive">
+ <table class="table table-bordered table-striped table-hover align-middle">
+ <thead>
+ <tr>
+ <th scope="col">Target</th>
+ <th scope="col">Status</th>
+ <th scope="col">Active</th>
+ <th scope="col">Desired revision</th>
+ <th scope="col">Processed revision</th>
+ <th scope="col">Sent revision</th>
+ <th scope="col">Failure Reason</th>
+ <th scope="col">Endpoint</th>
+ <th scope="col">Response Status</th>
+ <th scope="col">Status URL</th>
+ <th scope="col">Last Attempted at</th>
+ <th scope="col">Last Sent at</th>
+ </tr>
+ </thead>
+ <tbody>
+ {% for webmention in webmentions %}
+ <tr>
+ <td class="text-break">
+ <a href="{{ webmention.target }}">{{ webmention.target }}</a>
+ </td>
+ <td>{{ webmention.status.value }}</td>
+ <td>
+ {% if webmention.active %}
+ <span class="badge text-bg-success">Active</span>
+ {% else %}
+ <span class="badge text-bg-secondary">Removed</span>
+ {% endif %}
+ </td>
+ <td>{{ webmention.desired_revision }}</td>
+ <td>
+ {{ webmention.processed_revision
+ if webmention.processed_revision is not none
+ else "—" }}
+ </td>
+ <td>
+ {{ webmention.sent_revision
+ if webmention.sent_revision is not none
+ else "—" }}
+ </td>
+ <td>
+ {{ webmention.failure_reason
+ if webmention.failure_reason is not none
+ else "—" }}
+ </td>
+ <td>
+ {{ webmention.endpoint
+ if webmention.endpoint is not none
+ else "—" }}
+ </td>
+ <td>
+ {{ webmention.response_status
+ if webmention.response_status is not none
+ else "—" }}
+ </td>
+ <td>
+ {{ webmention.status_url
+ if webmention.status_url is not none
+ else "—" }}
+ </td>
+ <td>
+ {% if webmention.last_attempted_at is not none %}
+ <time datetime="{{ webmention.last_attempted_at.isoformat() }}">
+ {{ webmention.last_attempted_at.strftime("%Y-%m-%d %H:%M:%S") }}
+ </time>
+ {% else %}
+ —
+ {% endif %}
+ </td>
+ <td>
+ {% if webmention.last_sent_at is not none %}
+ <time datetime="{{ webmention.last_sent_at.isoformat() }}">
+ {{ webmention.last_sent_at.strftime("%Y-%m-%d %H:%M:%S") }}
+ </time>
+ {% else %}
+ —
+ {% endif %}
+ </td>
+ </tr>
+ {% endfor %}
+ </tbody>
+ </table>
+ </div>
+ {% else %}
+ <p class="text-body-secondary">No Webmentions have been discovered for this source.</p>
+ {% endif %}
+{% endblock %}
diff --git a/webmentions_ssg/url_security.py b/webmentions_ssg/url_security.py
index ce5d700..51c037a 100644
--- a/webmentions_ssg/url_security.py
+++ b/webmentions_ssg/url_security.py
@@ -9,15 +9,11 @@ class AddressResolutionError(Exception):
pass
-class NonPublicAddressError(Exception):
- pass
-
-
-def ensure_public_url(url: str) -> None:
+def is_public_url(url: str) -> bool:
hostname = urlsplit(url).hostname
if hostname is None:
- raise NonPublicAddressError("URL has no hostname")
+ raise ValueError("No hostname was specified")
try:
answers = dns.resolver.resolve_name(hostname)
@@ -25,13 +21,19 @@ def ensure_public_url(url: str) -> None:
raise AddressResolutionError(
f"Could not resolve hostname {hostname!r}"
) from exc
+ else:
+ for address in answers.addresses():
+ if not ipaddress.ip_address(address).is_global:
+ return False
- addresses = {ipaddress.ip_address(address) for address in answers.addresses()}
+ return True
- if not addresses:
- raise AddressResolutionError(f"Hostname {hostname!r} did not resolve")
- if any(not address.is_global for address in addresses):
- raise NonPublicAddressError(
- f"Hostname {hostname!r} resolves to a non-public address"
- )
+def is_http_url(url: str) -> bool:
+ """Return whether a URL is an absolute HTTP or HTTPS URL."""
+ try:
+ parsed = urlsplit(url)
+ except ValueError:
+ return False
+
+ return parsed.scheme.lower() in {"http", "https"} and parsed.hostname is not None
diff --git a/webmentions_ssg/views.py b/webmentions_ssg/views.py
index b52ae07..eeea765 100644
--- a/webmentions_ssg/views.py
+++ b/webmentions_ssg/views.py
@@ -16,10 +16,11 @@ from flask import (
)
from flask.typing import ResponseReturnValue
from flask_login import current_user, login_required, login_user, logout_user
+from sqlalchemy.orm import selectinload
from . import CSRF, forms
from . import DATABASE as db
-from .models import ReceivedWebmention, User
+from .models import ReceivedWebmention, Source, User
from .tasks.receiver import verify_webmention
root_page = Blueprint("root", __name__)
@@ -80,18 +81,13 @@ def received() -> ResponseReturnValue:
@root_page.post("/received/<uuid:identifier>/delete")
@login_required
-def delete_received_webmention(
- identifier: uuid.UUID,
-) -> ResponseReturnValue:
+def delete_received_webmention(identifier: uuid.UUID) -> ResponseReturnValue:
form = forms.AdminActionForm()
if not form.validate_on_submit():
return abort(400)
- webmention = db.session.get(
- ReceivedWebmention,
- identifier,
- )
+ webmention = db.session.get(ReceivedWebmention, identifier)
if webmention is None:
return abort(404)
@@ -102,27 +98,19 @@ def delete_received_webmention(
flash(f"Webmention {webmention.uuid} deleted.", "success")
return redirect(
- url_for(
- "root.received",
- page=request.args.get("page", 1, type=int),
- )
+ url_for("root.received", page=request.args.get("page", 1, type=int))
)
@root_page.post("/received/<uuid:identifier>/reverify")
@login_required
-def reverify_received_webmention(
- identifier: uuid.UUID,
-) -> ResponseReturnValue:
+def reverify_received_webmention(identifier: uuid.UUID) -> ResponseReturnValue:
form = forms.AdminActionForm()
if not form.validate_on_submit():
return abort(400)
- webmention = db.session.get(
- ReceivedWebmention,
- identifier,
- )
+ webmention = db.session.get(ReceivedWebmention, identifier)
if webmention is None:
return abort(404)
@@ -137,10 +125,41 @@ def reverify_received_webmention(
flash(f"Webmention {webmention.uuid} queued for reverification.", "success")
return redirect(
- url_for(
- "root.received",
- page=request.args.get("page", 1, type=int),
- )
+ url_for("root.received", page=request.args.get("page", 1, type=int))
+ )
+
+
+@root_page.route("/sent")
+@login_required
+def sent() -> ResponseReturnValue:
+ sources = db.paginate(
+ sa.select(Source)
+ .options(selectinload(Source.sent_webmentions))
+ .order_by(Source.last_seen_at.desc()),
+ per_page=25,
+ )
+ return render_template("sent.html", title="Sent Webmentions", sources=sources)
+
+
+@root_page.route("/sent/<uuid:identifier>")
+@login_required
+def sent_source(identifier: uuid.UUID) -> ResponseReturnValue:
+ source = db.session.scalar(
+ sa.select(Source)
+ .options(selectinload(Source.sent_webmentions))
+ .where(Source.uuid == identifier)
+ )
+ if source is None:
+ return abort(404)
+
+ webmentions = sorted(
+ source.sent_webmentions, key=lambda webmention: webmention.target
+ )
+ return render_template(
+ "sent_source.html",
+ title="Sent Webmentions",
+ source=source,
+ webmentions=webmentions,
)
@@ -157,19 +176,14 @@ def endpoint() -> ResponseReturnValue:
webmention = db.session.execute(
sa.select(ReceivedWebmention).where(
- ReceivedWebmention.source == source,
- ReceivedWebmention.target == target,
+ 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,
- )
+ webmention = ReceivedWebmention(uuid=identifier, source=source, target=target)
db.session.add(webmention)
@@ -204,16 +218,9 @@ def endpoint() -> ResponseReturnValue:
verify_webmention(webmention.uuid)
- status_url = url_for(
- "root.status",
- identifier=str(identifier),
- _external=True,
- )
+ status_url = url_for("root.status", identifier=str(identifier), _external=True)
- return Response(
- status=201,
- headers={"Location": status_url},
- )
+ return Response(status=201, headers={"Location": status_url})
@root_page.route("/status/<uuid:identifier>")