Combines shared, blog, market, cart, events, federation, and account into a single repository. Eliminates submodule sync, sibling model copying at build time, and per-app CI orchestration. Changes: - Remove per-app .git, .gitmodules, .gitea, submodule shared/ dirs - Remove stale sibling model copies from each app - Update all 6 Dockerfiles for monorepo build context (root = .) - Add build directives to docker-compose.yml - Add single .gitea/workflows/ci.yml with change detection - Add .dockerignore for monorepo build context - Create __init__.py for federation and account (cross-app imports)
102 lines
3.1 KiB
Python
102 lines
3.1 KiB
Python
"""Deliver activities to external service inboxes via signed HTTP POST.
|
|
|
|
External services (like artdag) that don't share the coop database receive
|
|
activities via HTTP, authenticated with the same HTTP Signatures used for
|
|
ActivityPub federation.
|
|
|
|
Config via env: EXTERNAL_INBOXES=name|url,name2|url2,...
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
from urllib.parse import urlparse
|
|
|
|
import httpx
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from shared.events.bus import register_activity_handler
|
|
from shared.models.federation import ActorProfile, APActivity
|
|
from shared.utils.http_signatures import sign_request
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
# Activity types to deliver externally
|
|
_DELIVERABLE_TYPES = {"rose:DeviceAuth"}
|
|
|
|
|
|
def _get_external_inboxes() -> list[tuple[str, str]]:
|
|
"""Parse EXTERNAL_INBOXES env var into [(name, url), ...]."""
|
|
raw = os.environ.get("EXTERNAL_INBOXES", "")
|
|
if not raw:
|
|
return []
|
|
result = []
|
|
for entry in raw.split(","):
|
|
entry = entry.strip()
|
|
if "|" in entry:
|
|
name, url = entry.split("|", 1)
|
|
result.append((name.strip(), url.strip()))
|
|
return result
|
|
|
|
|
|
def _get_ap_domain() -> str:
|
|
return os.environ.get("AP_DOMAIN", "federation.rose-ash.com")
|
|
|
|
|
|
async def on_external_activity(activity: APActivity, session: AsyncSession) -> None:
|
|
"""Deliver matching activities to configured external inboxes."""
|
|
if activity.activity_type not in _DELIVERABLE_TYPES:
|
|
return
|
|
|
|
inboxes = _get_external_inboxes()
|
|
if not inboxes:
|
|
return
|
|
|
|
# Get the first actor profile for signing
|
|
actor = await session.scalar(select(ActorProfile).limit(1))
|
|
if not actor:
|
|
log.warning("No ActorProfile available for signing external deliveries")
|
|
return
|
|
|
|
domain = _get_ap_domain()
|
|
key_id = f"https://{domain}/users/{actor.preferred_username}#main-key"
|
|
|
|
payload = {
|
|
"@context": "https://www.w3.org/ns/activitystreams",
|
|
"type": activity.activity_type,
|
|
"actor": activity.actor_uri,
|
|
"object": activity.object_data,
|
|
}
|
|
if activity.published:
|
|
payload["published"] = activity.published.isoformat()
|
|
|
|
body_bytes = json.dumps(payload).encode()
|
|
|
|
for name, inbox_url in inboxes:
|
|
parsed = urlparse(inbox_url)
|
|
headers = sign_request(
|
|
private_key_pem=actor.private_key_pem,
|
|
key_id=key_id,
|
|
method="POST",
|
|
path=parsed.path,
|
|
host=parsed.netloc,
|
|
body=body_bytes,
|
|
)
|
|
headers["Content-Type"] = "application/activity+json"
|
|
try:
|
|
async with httpx.AsyncClient(timeout=3) as client:
|
|
resp = await client.post(inbox_url, content=body_bytes, headers=headers)
|
|
log.info(
|
|
"External delivery to %s: %d",
|
|
name, resp.status_code,
|
|
)
|
|
except Exception:
|
|
log.warning("External delivery to %s failed", name, exc_info=True)
|
|
|
|
|
|
# Register for all deliverable types
|
|
for _t in _DELIVERABLE_TYPES:
|
|
register_activity_handler(_t, on_external_activity)
|