Fix per-app AP delivery, NULL uniqueness, and reverse discovery
- Delivery handler now signs/delivers using the per-app domain that matches the follower's subscription (not always federation domain) - app_domain is NOT NULL with default 'federation' (sentinel replaces NULL to avoid uniqueness constraint edge case) - Aggregate actor advertises per-app actors via alsoKnownAs - Migration backfills existing NULL rows to 'federation' Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,15 +3,22 @@
|
||||
Registered as a wildcard handler — fires for every activity. Skips
|
||||
non-public activities and those without an actor profile.
|
||||
|
||||
Per-app delivery: activities are delivered using the domain that matches
|
||||
the follower's subscription. A follower of ``@alice@blog.rose-ash.com``
|
||||
receives activities with ``actor: https://blog.rose-ash.com/users/alice``
|
||||
and signatures using that domain's key_id. Aggregate followers
|
||||
(``app_domain='federation'``) receive the federation domain identity.
|
||||
|
||||
Idempotent: successful deliveries are recorded in ap_delivery_log.
|
||||
On retry (at-least-once reaper), already-delivered inboxes are skipped.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.events.bus import register_activity_handler
|
||||
@@ -24,6 +31,12 @@ AP_CONTENT_TYPE = "application/activity+json"
|
||||
DELIVERY_TIMEOUT = 15 # seconds per request
|
||||
|
||||
|
||||
def _domain_for_app(app_name: str) -> str:
|
||||
"""Resolve the public AP domain for an app name."""
|
||||
from shared.infrastructure.activitypub import _ap_domain
|
||||
return _ap_domain(app_name)
|
||||
|
||||
|
||||
def _build_activity_json(activity: APActivity, actor: ActorProfile, domain: str) -> dict:
|
||||
"""Build the full AP activity JSON-LD for delivery."""
|
||||
username = actor.preferred_username
|
||||
@@ -108,8 +121,7 @@ async def _deliver_to_inbox(
|
||||
|
||||
|
||||
async def on_any_activity(activity: APActivity, session: AsyncSession) -> None:
|
||||
"""Deliver a public activity to all followers of its actor."""
|
||||
import os
|
||||
"""Deliver a public activity to all matching followers of its actor."""
|
||||
|
||||
# Only deliver public activities that have an actor profile
|
||||
if activity.visibility != "public":
|
||||
@@ -119,8 +131,6 @@ async def on_any_activity(activity: APActivity, session: AsyncSession) -> None:
|
||||
if not services.has("federation"):
|
||||
return
|
||||
|
||||
domain = os.getenv("AP_DOMAIN", "federation.rose-ash.com")
|
||||
|
||||
# Load actor with private key
|
||||
actor = (
|
||||
await session.execute(
|
||||
@@ -131,26 +141,19 @@ async def on_any_activity(activity: APActivity, session: AsyncSession) -> None:
|
||||
log.warning("Actor not found or missing key for activity %s", activity.activity_id)
|
||||
return
|
||||
|
||||
# Load followers: aggregate (app_domain IS NULL) always get everything.
|
||||
# If the activity has an origin_app, also include app-specific followers.
|
||||
from sqlalchemy import or_
|
||||
|
||||
follower_filters = [
|
||||
APFollower.actor_profile_id == actor.id,
|
||||
]
|
||||
|
||||
# Load matching followers.
|
||||
# Aggregate followers (app_domain='federation') always get everything.
|
||||
# Per-app followers only get activities from their app.
|
||||
origin_app = activity.origin_app
|
||||
follower_filters = [APFollower.actor_profile_id == actor.id]
|
||||
|
||||
if origin_app and origin_app != "federation":
|
||||
# Aggregate followers (NULL) + followers of this specific app
|
||||
follower_filters.append(
|
||||
or_(
|
||||
APFollower.app_domain.is_(None),
|
||||
APFollower.app_domain == "federation",
|
||||
APFollower.app_domain == origin_app,
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Federation / no origin_app: deliver to all followers
|
||||
pass
|
||||
|
||||
followers = (
|
||||
await session.execute(
|
||||
@@ -162,9 +165,6 @@ async def on_any_activity(activity: APActivity, session: AsyncSession) -> None:
|
||||
log.debug("No followers to deliver to for %s", activity.activity_id)
|
||||
return
|
||||
|
||||
# Deduplicate inboxes (same remote actor may follow both aggregate + app)
|
||||
all_inboxes = {f.follower_inbox for f in followers if f.follower_inbox}
|
||||
|
||||
# Check delivery log — skip inboxes we already delivered to (idempotency)
|
||||
existing = (
|
||||
await session.execute(
|
||||
@@ -176,38 +176,59 @@ async def on_any_activity(activity: APActivity, session: AsyncSession) -> None:
|
||||
).scalars().all()
|
||||
already_delivered = set(existing)
|
||||
|
||||
inboxes = all_inboxes - already_delivered
|
||||
if not inboxes:
|
||||
log.info("All %d inbox(es) already delivered for %s", len(all_inboxes), activity.activity_id)
|
||||
# Group followers by app_domain so we deliver with the correct
|
||||
# actor URL and signing domain for each subscriber.
|
||||
# If the same inbox appears under multiple app_domains, prefer
|
||||
# the per-app domain (it's what the follower subscribed to).
|
||||
inbox_to_domain: dict[str, str] = {}
|
||||
for f in followers:
|
||||
if not f.follower_inbox:
|
||||
continue
|
||||
if f.follower_inbox in already_delivered:
|
||||
continue
|
||||
app_dom = f.app_domain or "federation"
|
||||
# Per-app domain wins over aggregate if both exist
|
||||
if f.follower_inbox not in inbox_to_domain or app_dom != "federation":
|
||||
inbox_to_domain[f.follower_inbox] = app_dom
|
||||
|
||||
if not inbox_to_domain:
|
||||
if already_delivered:
|
||||
log.info("All inbox(es) already delivered for %s", activity.activity_id)
|
||||
return
|
||||
|
||||
if already_delivered:
|
||||
log.info(
|
||||
"Skipping %d already-delivered inbox(es), delivering to %d remaining",
|
||||
len(already_delivered), len(inboxes),
|
||||
len(already_delivered), len(inbox_to_domain),
|
||||
)
|
||||
|
||||
# Build activity JSON
|
||||
activity_json = _build_activity_json(activity, actor, domain)
|
||||
# Group by domain to reuse activity JSON per domain
|
||||
domain_inboxes: dict[str, list[str]] = defaultdict(list)
|
||||
for inbox_url, app_dom in inbox_to_domain.items():
|
||||
domain_inboxes[app_dom].append(inbox_url)
|
||||
|
||||
log.info(
|
||||
"Delivering %s to %d inbox(es) for @%s",
|
||||
activity.activity_type, len(inboxes), actor.preferred_username,
|
||||
"Delivering %s to %d inbox(es) for @%s across %d domain(s)",
|
||||
activity.activity_type, len(inbox_to_domain),
|
||||
actor.preferred_username, len(domain_inboxes),
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
for inbox_url in inboxes:
|
||||
status_code = await _deliver_to_inbox(
|
||||
client, inbox_url, activity_json, actor, domain
|
||||
)
|
||||
# Log successful deliveries for idempotency
|
||||
if status_code is not None and status_code < 300:
|
||||
session.add(APDeliveryLog(
|
||||
activity_id=activity.id,
|
||||
inbox_url=inbox_url,
|
||||
status_code=status_code,
|
||||
))
|
||||
await session.flush()
|
||||
for app_dom, inboxes in domain_inboxes.items():
|
||||
domain = _domain_for_app(app_dom)
|
||||
activity_json = _build_activity_json(activity, actor, domain)
|
||||
|
||||
for inbox_url in inboxes:
|
||||
status_code = await _deliver_to_inbox(
|
||||
client, inbox_url, activity_json, actor, domain
|
||||
)
|
||||
if status_code is not None and status_code < 300:
|
||||
session.add(APDeliveryLog(
|
||||
activity_id=activity.id,
|
||||
inbox_url=inbox_url,
|
||||
status_code=status_code,
|
||||
))
|
||||
await session.flush()
|
||||
|
||||
|
||||
# Wildcard: fires for every activity
|
||||
|
||||
Reference in New Issue
Block a user