"""Per-app ActivityPub blueprint. Factory function ``create_activitypub_blueprint(app_name)`` returns a Blueprint with WebFinger, host-meta, nodeinfo, actor profile, inbox, outbox, and followers endpoints. Per-app actors are *virtual projections* of the same ``ActorProfile``. Same keypair, same ``preferred_username`` — the only differences are: - the domain in URLs (e.g. blog.rose-ash.com vs federation.rose-ash.com) - which activities are served in the outbox (filtered by ``origin_app``) - which followers are returned (filtered by ``app_domain``) - Follow requests create ``APFollower(app_domain=app_name)`` Federation app acts as the aggregate: no origin_app filter, app_domain=NULL. """ from __future__ import annotations import json import logging import os from datetime import datetime, timezone from quart import Blueprint, request, abort, Response, g from sqlalchemy import select from shared.services.registry import services from shared.models.federation import ActorProfile, APInboxItem from shared.browser.app.csrf import csrf_exempt log = logging.getLogger(__name__) AP_CONTENT_TYPE = "application/activity+json" # Apps that serve per-app AP actors AP_APPS = {"blog", "market", "events", "federation"} def _ap_domain(app_name: str) -> str: """Return the public domain for this app's AP identity.""" env_key = f"AP_DOMAIN_{app_name.upper()}" env_val = os.getenv(env_key) if env_val: return env_val # Default: {app}.rose-ash.com, except federation uses AP_DOMAIN if app_name == "federation": return os.getenv("AP_DOMAIN", "federation.rose-ash.com") return f"{app_name}.rose-ash.com" def _federation_domain() -> str: """The aggregate federation domain (for alsoKnownAs links).""" return os.getenv("AP_DOMAIN", "federation.rose-ash.com") def _is_aggregate(app_name: str) -> bool: """Federation serves the aggregate actor (no per-app filter).""" return app_name == "federation" def create_activitypub_blueprint(app_name: str) -> Blueprint: """Return a Blueprint with AP endpoints for *app_name*.""" bp = Blueprint("activitypub", __name__) domain = _ap_domain(app_name) fed_domain = _federation_domain() aggregate = _is_aggregate(app_name) # For per-app follows, store app_domain; for federation aggregate, NULL follower_app_domain: str | None = None if aggregate else app_name # For per-app outboxes, filter by origin_app; for federation, show all outbox_origin_app: str | None = None if aggregate else app_name # ------------------------------------------------------------------ # Well-known endpoints # ------------------------------------------------------------------ @bp.get("/.well-known/webfinger") async def webfinger(): resource = request.args.get("resource", "") if not resource.startswith("acct:"): abort(400, "Invalid resource format") parts = resource[5:].split("@") if len(parts) != 2: abort(400, "Invalid resource format") username, res_domain = parts if res_domain != domain: abort(404, "User not on this server") actor = await services.federation.get_actor_by_username(g.s, username) if not actor: abort(404, "User not found") actor_url = f"https://{domain}/users/{username}" return Response( response=json.dumps({ "subject": resource, "aliases": [actor_url], "links": [ { "rel": "self", "type": AP_CONTENT_TYPE, "href": actor_url, }, { "rel": "http://webfinger.net/rel/profile-page", "type": "text/html", "href": actor_url, }, ], }), content_type="application/jrd+json", ) @bp.get("/.well-known/nodeinfo") async def nodeinfo_index(): return Response( response=json.dumps({ "links": [ { "rel": "http://nodeinfo.diaspora.software/ns/schema/2.0", "href": f"https://{domain}/nodeinfo/2.0", } ] }), content_type="application/json", ) @bp.get("/nodeinfo/2.0") async def nodeinfo(): stats = await services.federation.get_stats(g.s) return Response( response=json.dumps({ "version": "2.0", "software": { "name": "rose-ash", "version": "1.0.0", }, "protocols": ["activitypub"], "usage": { "users": { "total": stats.get("actors", 0), "activeMonth": stats.get("actors", 0), }, "localPosts": stats.get("activities", 0), }, "openRegistrations": False, "metadata": { "nodeName": f"Rose Ash ({app_name})", "nodeDescription": f"Rose Ash {app_name} — ActivityPub federation", }, }), content_type="application/json", ) @bp.get("/.well-known/host-meta") async def host_meta(): xml = ( '\n' '\n' f' \n' '' ) return Response(response=xml, content_type="application/xrd+xml") # ------------------------------------------------------------------ # Actor profile # ------------------------------------------------------------------ @bp.get("/users/") async def actor_profile(username: str): actor = await services.federation.get_actor_by_username(g.s, username) if not actor: abort(404) accept_header = request.headers.get("accept", "") if "application/activity+json" in accept_header or "application/ld+json" in accept_header: actor_url = f"https://{domain}/users/{username}" actor_json = { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", ], "type": "Person", "id": actor_url, "name": actor.display_name or username, "preferredUsername": username, "summary": actor.summary or "", "manuallyApprovesFollowers": False, "inbox": f"{actor_url}/inbox", "outbox": f"{actor_url}/outbox", "followers": f"{actor_url}/followers", "following": f"{actor_url}/following", "publicKey": { "id": f"{actor_url}#main-key", "owner": actor_url, "publicKeyPem": actor.public_key_pem, }, "url": actor_url, } # Per-app actors link back to the aggregate federation actor if not aggregate and domain != fed_domain: actor_json["alsoKnownAs"] = [ f"https://{fed_domain}/users/{username}", ] return Response( response=json.dumps(actor_json), content_type=AP_CONTENT_TYPE, ) # HTML: federation renders its own profile; other apps redirect there if aggregate: from quart import render_template activities, total = await services.federation.get_outbox( g.s, username, page=1, per_page=20, ) return await render_template( "federation/profile.html", actor=actor, activities=activities, total=total, ) from quart import redirect return redirect(f"https://{fed_domain}/users/{username}") # ------------------------------------------------------------------ # Inbox # ------------------------------------------------------------------ @csrf_exempt @bp.post("/users//inbox") async def inbox(username: str): actor = await services.federation.get_actor_by_username(g.s, username) if not actor: abort(404) body = await request.get_json() if not body: abort(400, "Invalid JSON") activity_type = body.get("type", "") from_actor_url = body.get("actor", "") # Verify HTTP signature (best-effort) sig_valid = False try: from shared.utils.http_signatures import verify_request_signature from shared.infrastructure.ap_inbox_handlers import fetch_remote_actor req_headers = dict(request.headers) sig_header = req_headers.get("Signature", "") remote_actor = await fetch_remote_actor(from_actor_url) if remote_actor and sig_header: pub_key_pem = (remote_actor.get("publicKey") or {}).get("publicKeyPem") if pub_key_pem: sig_valid = verify_request_signature( public_key_pem=pub_key_pem, signature_header=sig_header, method="POST", path=f"/users/{username}/inbox", headers=req_headers, ) except Exception: log.debug("Signature verification failed for %s", from_actor_url, exc_info=True) if not sig_valid: log.warning( "Unverified inbox POST from %s (%s) on %s — accepting anyway for now", from_actor_url, activity_type, domain, ) # Load actor row for DB operations actor_row = ( await g.s.execute( select(ActorProfile).where( ActorProfile.preferred_username == username ) ) ).scalar_one() # Store raw inbox item item = APInboxItem( actor_profile_id=actor_row.id, raw_json=body, activity_type=activity_type, from_actor=from_actor_url, ) g.s.add(item) await g.s.flush() # Dispatch to shared handlers from shared.infrastructure.ap_inbox_handlers import dispatch_inbox_activity await dispatch_inbox_activity( g.s, actor_row, body, from_actor_url, domain=domain, app_domain=follower_app_domain, ) # Mark as processed item.state = "processed" item.processed_at = datetime.now(timezone.utc) await g.s.flush() return Response(status=202) # ------------------------------------------------------------------ # Outbox # ------------------------------------------------------------------ @bp.get("/users//outbox") async def outbox(username: str): actor = await services.federation.get_actor_by_username(g.s, username) if not actor: abort(404) actor_url = f"https://{domain}/users/{username}" page_param = request.args.get("page") if not page_param: _, total = await services.federation.get_outbox( g.s, username, page=1, per_page=1, origin_app=outbox_origin_app, ) return Response( response=json.dumps({ "@context": "https://www.w3.org/ns/activitystreams", "type": "OrderedCollection", "id": f"{actor_url}/outbox", "totalItems": total, "first": f"{actor_url}/outbox?page=1", }), content_type=AP_CONTENT_TYPE, ) page_num = int(page_param) activities, total = await services.federation.get_outbox( g.s, username, page=page_num, per_page=20, origin_app=outbox_origin_app, ) items = [] for a in activities: items.append({ "@context": "https://www.w3.org/ns/activitystreams", "type": a.activity_type, "id": a.activity_id, "actor": actor_url, "published": a.published.isoformat() if a.published else None, "object": { "type": a.object_type, **(a.object_data or {}), }, }) return Response( response=json.dumps({ "@context": "https://www.w3.org/ns/activitystreams", "type": "OrderedCollectionPage", "id": f"{actor_url}/outbox?page={page_num}", "partOf": f"{actor_url}/outbox", "totalItems": total, "orderedItems": items, }), content_type=AP_CONTENT_TYPE, ) # ------------------------------------------------------------------ # Followers / following collections # ------------------------------------------------------------------ @bp.get("/users//followers") async def followers(username: str): actor = await services.federation.get_actor_by_username(g.s, username) if not actor: abort(404) collection_id = f"https://{domain}/users/{username}/followers" follower_list = await services.federation.get_followers( g.s, username, app_domain=follower_app_domain, ) page_param = request.args.get("page") if not page_param: return Response( response=json.dumps({ "@context": "https://www.w3.org/ns/activitystreams", "type": "OrderedCollection", "id": collection_id, "totalItems": len(follower_list), "first": f"{collection_id}?page=1", }), content_type=AP_CONTENT_TYPE, ) return Response( response=json.dumps({ "@context": "https://www.w3.org/ns/activitystreams", "type": "OrderedCollectionPage", "id": f"{collection_id}?page=1", "partOf": collection_id, "totalItems": len(follower_list), "orderedItems": [f.follower_actor_url for f in follower_list], }), content_type=AP_CONTENT_TYPE, ) @bp.get("/users//following") async def following(username: str): actor = await services.federation.get_actor_by_username(g.s, username) if not actor: abort(404) collection_id = f"https://{domain}/users/{username}/following" following_list, total = await services.federation.get_following(g.s, username) page_param = request.args.get("page") if not page_param: return Response( response=json.dumps({ "@context": "https://www.w3.org/ns/activitystreams", "type": "OrderedCollection", "id": collection_id, "totalItems": total, "first": f"{collection_id}?page=1", }), content_type=AP_CONTENT_TYPE, ) return Response( response=json.dumps({ "@context": "https://www.w3.org/ns/activitystreams", "type": "OrderedCollectionPage", "id": f"{collection_id}?page=1", "partOf": collection_id, "totalItems": total, "orderedItems": [f.actor_url for f in following_list], }), content_type=AP_CONTENT_TYPE, ) return bp