aboutsummaryrefslogtreecommitdiff
path: root/webmentions_ssg
diff options
context:
space:
mode:
Diffstat (limited to '')
-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
14 files changed, 1178 insertions, 170 deletions
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>")