"""ActivityPub actor endpoints: profiles, outbox, inbox. Ported from ~/art-dag/activity-pub/app/routers/users.py. """ from __future__ import annotations import json import os from quart import Blueprint, request, abort, Response, g, render_template from shared.services.registry import services from shared.models.federation import APInboxItem def _domain() -> str: return os.getenv("AP_DOMAIN", "rose-ash.com") def register(url_prefix="/users"): bp = Blueprint("actors", __name__, url_prefix=url_prefix) @bp.get("/") async def profile(username: str): actor = await services.federation.get_actor_by_username(g.s, username) if not actor: abort(404) domain = _domain() accept = request.headers.get("accept", "") # AP JSON-LD response if "application/activity+json" in accept or "application/ld+json" in accept: actor_json = { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", ], "type": "Person", "id": f"https://{domain}/users/{username}", "name": actor.display_name or username, "preferredUsername": username, "summary": actor.summary or "", "inbox": f"https://{domain}/users/{username}/inbox", "outbox": f"https://{domain}/users/{username}/outbox", "followers": f"https://{domain}/users/{username}/followers", "following": f"https://{domain}/users/{username}/following", "publicKey": { "id": f"https://{domain}/users/{username}#main-key", "owner": f"https://{domain}/users/{username}", "publicKeyPem": actor.public_key_pem, }, "url": f"https://{domain}/users/{username}", } return Response( response=json.dumps(actor_json), content_type="application/activity+json", ) # HTML profile page 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, ) @bp.get("//outbox") async def outbox(username: str): actor = await services.federation.get_actor_by_username(g.s, username) if not actor: abort(404) domain = _domain() actor_id = 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) return Response( response=json.dumps({ "@context": "https://www.w3.org/ns/activitystreams", "type": "OrderedCollection", "id": f"{actor_id}/outbox", "totalItems": total, "first": f"{actor_id}/outbox?page=1", }), content_type="application/activity+json", ) page_num = int(page_param) activities, total = await services.federation.get_outbox( g.s, username, page=page_num, per_page=20, ) items = [] for a in activities: items.append({ "@context": "https://www.w3.org/ns/activitystreams", "type": a.activity_type, "id": a.activity_id, "actor": actor_id, "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_id}/outbox?page={page_num}", "partOf": f"{actor_id}/outbox", "totalItems": total, "orderedItems": items, }), content_type="application/activity+json", ) @bp.post("//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") # Store raw inbox item for async processing from shared.models.federation import ActorProfile from sqlalchemy import select actor_row = ( await g.s.execute( select(ActorProfile).where( ActorProfile.preferred_username == username ) ) ).scalar_one() item = APInboxItem( actor_profile_id=actor_row.id, raw_json=body, activity_type=body.get("type"), from_actor=body.get("actor"), ) g.s.add(item) await g.s.flush() # Emit domain event for processing from shared.events import emit_event await emit_event( g.s, "federation.inbox_received", "APInboxItem", item.id, { "actor_username": username, "activity_type": body.get("type"), "from_actor": body.get("actor"), }, ) return Response(status=202) @bp.get("//followers") async def followers(username: str): actor = await services.federation.get_actor_by_username(g.s, username) if not actor: abort(404) domain = _domain() follower_list = await services.federation.get_followers(g.s, username) return Response( response=json.dumps({ "@context": "https://www.w3.org/ns/activitystreams", "type": "OrderedCollection", "id": f"https://{domain}/users/{username}/followers", "totalItems": len(follower_list), "orderedItems": [f.follower_actor_url for f in follower_list], }), content_type="application/activity+json", ) @bp.get("//following") async def following(username: str): actor = await services.federation.get_actor_by_username(g.s, username) if not actor: abort(404) domain = _domain() return Response( response=json.dumps({ "@context": "https://www.w3.org/ns/activitystreams", "type": "OrderedCollection", "id": f"https://{domain}/users/{username}/following", "totalItems": 0, "orderedItems": [], }), content_type="application/activity+json", ) return bp