Monorepo: consolidate 7 repos into one

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)
This commit is contained in:
giles
2026-02-24 19:44:17 +00:00
commit f42042ccb7
895 changed files with 61147 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
"""Shared event handlers."""
def register_shared_handlers():
"""Import handler modules to trigger registration. Call at app startup."""
import shared.events.handlers.container_handlers # noqa: F401
import shared.events.handlers.login_handlers # noqa: F401
import shared.events.handlers.order_handlers # noqa: F401
import shared.events.handlers.ap_delivery_handler # noqa: F401
import shared.events.handlers.external_delivery_handler # noqa: F401

View File

@@ -0,0 +1,250 @@
"""Deliver AP activities to remote followers.
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
import os
from collections import defaultdict
import httpx
from sqlalchemy import select, or_
from sqlalchemy.ext.asyncio import AsyncSession
from shared.events.bus import register_activity_handler
from shared.models.federation import ActorProfile, APActivity, APFollower, APDeliveryLog
from shared.services.registry import services
log = logging.getLogger(__name__)
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
actor_url = f"https://{domain}/users/{username}"
obj = dict(activity.object_data or {})
# Rewrite all URLs from the federation domain to the delivery domain
# so Mastodon's origin check passes (all IDs must match actor host).
import re
fed_domain = os.getenv("AP_DOMAIN", "federation.rose-ash.com")
def _rewrite(url: str) -> str:
if isinstance(url, str) and fed_domain in url:
return url.replace(f"https://{fed_domain}", f"https://{domain}")
return url
activity_id = _rewrite(activity.activity_id)
object_id = activity_id + "/object"
# Rewrite any federation-domain URLs in object_data
if "id" in obj:
obj["id"] = _rewrite(obj["id"])
if "attributedTo" in obj:
obj["attributedTo"] = _rewrite(obj["attributedTo"])
if activity.activity_type == "Delete":
obj.setdefault("id", object_id)
obj.setdefault("type", "Tombstone")
else:
obj.setdefault("id", object_id)
obj.setdefault("type", activity.object_type)
obj.setdefault("attributedTo", actor_url)
obj.setdefault("published", activity.published.isoformat() if activity.published else None)
obj.setdefault("to", ["https://www.w3.org/ns/activitystreams#Public"])
obj.setdefault("cc", [f"{actor_url}/followers"])
if activity.activity_type == "Update":
from datetime import datetime, timezone
obj["updated"] = datetime.now(timezone.utc).isoformat()
return {
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
],
"id": activity_id,
"type": activity.activity_type,
"actor": actor_url,
"published": activity.published.isoformat() if activity.published else None,
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": [f"{actor_url}/followers"],
"object": obj,
}
async def _deliver_to_inbox(
client: httpx.AsyncClient,
inbox_url: str,
body: dict,
actor: ActorProfile,
domain: str,
) -> int | None:
"""POST signed activity to a single inbox. Returns status code or None on error."""
from shared.utils.http_signatures import sign_request
from urllib.parse import urlparse
import json
body_bytes = json.dumps(body).encode()
key_id = f"https://{domain}/users/{actor.preferred_username}#main-key"
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"] = AP_CONTENT_TYPE
try:
resp = await client.post(
inbox_url,
content=body_bytes,
headers=headers,
timeout=DELIVERY_TIMEOUT,
)
if resp.status_code < 300:
log.info("Delivered to %s%d", inbox_url, resp.status_code)
else:
log.warning("Delivery to %s%d: %s", inbox_url, resp.status_code, resp.text[:200])
return resp.status_code
except Exception:
log.exception("Delivery failed for %s", inbox_url)
return None
async def on_any_activity(activity: APActivity, session: AsyncSession) -> None:
"""Deliver a public activity to all matching followers of its actor."""
# Only deliver public activities that have an actor profile
if activity.visibility != "public":
return
if activity.actor_profile_id is None:
return
if not services.has("federation"):
return
# Load actor with private key
actor = (
await session.execute(
select(ActorProfile).where(ActorProfile.id == activity.actor_profile_id)
)
).scalar_one_or_none()
if not actor or not actor.private_key_pem:
log.warning("Actor not found or missing key for activity %s", activity.activity_id)
return
# 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":
follower_filters.append(
or_(
APFollower.app_domain == "federation",
APFollower.app_domain == origin_app,
)
)
followers = (
await session.execute(
select(APFollower).where(*follower_filters)
)
).scalars().all()
if not followers:
log.debug("No followers to deliver to for %s", activity.activity_id)
return
# Check delivery log — skip (inbox, domain) pairs already delivered (idempotency)
existing = (
await session.execute(
select(APDeliveryLog.inbox_url, APDeliveryLog.app_domain).where(
APDeliveryLog.activity_id == activity.id,
APDeliveryLog.status_code < 300,
)
)
).all()
already_delivered: set[tuple[str, str]] = {(r[0], r[1]) for r in existing}
# Collect all (inbox, app_domain) pairs to deliver to.
# Each follower subscription gets its own delivery with the correct
# actor identity, so followers of @user@blog and @user@federation
# both see posts on their respective actor profiles.
delivery_pairs: set[tuple[str, str]] = set()
for f in followers:
if not f.follower_inbox:
continue
app_dom = f.app_domain or "federation"
pair = (f.follower_inbox, app_dom)
if pair not in already_delivered:
delivery_pairs.add(pair)
if not delivery_pairs:
if already_delivered:
log.info("All deliveries already done for %s", activity.activity_id)
return
if already_delivered:
log.info(
"Skipping %d already-delivered, delivering to %d remaining",
len(already_delivered), len(delivery_pairs),
)
# Group by domain to reuse activity JSON per domain
domain_inboxes: dict[str, list[str]] = defaultdict(list)
for inbox_url, app_dom in delivery_pairs:
domain_inboxes[app_dom].append(inbox_url)
log.info(
"Delivering %s to %d target(s) for @%s across %d domain(s)",
activity.activity_type, len(delivery_pairs),
actor.preferred_username, len(domain_inboxes),
)
async with httpx.AsyncClient() as client:
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,
app_domain=app_dom,
status_code=status_code,
))
await session.flush()
# Wildcard: fires for every activity
register_activity_handler("*", on_any_activity)

View File

@@ -0,0 +1,19 @@
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from shared.events import register_activity_handler
from shared.models.federation import APActivity
from shared.services.navigation import rebuild_navigation
async def on_child_attached(activity: APActivity, session: AsyncSession) -> None:
await rebuild_navigation(session)
async def on_child_detached(activity: APActivity, session: AsyncSession) -> None:
await rebuild_navigation(session)
register_activity_handler("Add", on_child_attached, object_type="rose:ContainerRelation")
register_activity_handler("Remove", on_child_detached, object_type="rose:ContainerRelation")

View File

@@ -0,0 +1,101 @@
"""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)

View File

@@ -0,0 +1,23 @@
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from shared.events import register_activity_handler
from shared.models.federation import APActivity
from shared.services.registry import services
async def on_user_logged_in(activity: APActivity, session: AsyncSession) -> None:
data = activity.object_data
user_id = data["user_id"]
session_id = data["session_id"]
if services.has("cart"):
await services.cart.adopt_cart_for_user(session, user_id, session_id)
if services.has("calendar"):
await services.calendar.adopt_entries_for_user(session, user_id, session_id)
await services.calendar.adopt_tickets_for_user(session, user_id, session_id)
register_activity_handler("rose:Login", on_user_logged_in)

View File

@@ -0,0 +1,22 @@
from __future__ import annotations
import logging
from sqlalchemy.ext.asyncio import AsyncSession
from shared.events import register_activity_handler
from shared.models.federation import APActivity
log = logging.getLogger(__name__)
async def on_order_created(activity: APActivity, session: AsyncSession) -> None:
log.info("order.created: order_id=%s", activity.object_data.get("order_id"))
async def on_order_paid(activity: APActivity, session: AsyncSession) -> None:
log.info("order.paid: order_id=%s", activity.object_data.get("order_id"))
register_activity_handler("Create", on_order_created, object_type="rose:Order")
register_activity_handler("rose:OrderPaid", on_order_paid)