All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 41s
Phase 0+1 of AP integration. New 5th Quart microservice: Blueprints: - wellknown: WebFinger, NodeInfo 2.0, host-meta - actors: AP actor profiles (JSON-LD + HTML), outbox, inbox, followers - identity: username selection flow (creates ActorProfile + RSA keypair) - auth: magic link login/logout (ported from blog, self-contained) Services: - Registers SqlFederationService (real impl) for federation domain - Registers real impls for blog, calendar, market, cart - All cross-domain via shared service contracts Templates: - Actor profiles, username selection, platform home - Auth login/check-email (ported from blog) Infrastructure: - Dockerfile + entrypoint.sh (matches other apps) - CI/CD via Gitea Actions - shared/ as git submodule Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
210 lines
7.0 KiB
Python
210 lines
7.0 KiB
Python
"""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("/<username>")
|
|
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("/<username>/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("/<username>/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("/<username>/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("/<username>/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
|