Critical: Add ownership checks to all order routes (IDOR fix). High: Redis rate limiting on auth endpoints, HMAC-signed internal service calls replacing header-presence-only checks, nh3 HTML sanitization on ghost_sync and product import, internal auth on market API endpoints, SHA-256 hashed OAuth grant/code tokens. Medium: SECRET_KEY production guard, AP signature enforcement, is_admin param removal, cart_sid validation, SSRF protection on remote actor fetch. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
506 lines
18 KiB
Python
506 lines
18 KiB
Python
"""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, "federation"
|
|
follower_app_domain: str = 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
|
|
|
|
# ------------------------------------------------------------------
|
|
# Federation session management — AP tables live in db_federation,
|
|
# which is separate from each app's own DB after the per-app split.
|
|
# g._ap_s points to the federation DB; on the federation app itself
|
|
# it's just an alias for g.s.
|
|
# ------------------------------------------------------------------
|
|
|
|
from shared.db.session import needs_federation_session, create_federation_session
|
|
|
|
@bp.before_request
|
|
async def _open_ap_session():
|
|
if needs_federation_session():
|
|
sess = create_federation_session()
|
|
g._ap_s = sess
|
|
g._ap_tx = await sess.begin()
|
|
g._ap_own = True
|
|
else:
|
|
g._ap_s = g.s
|
|
g._ap_own = False
|
|
|
|
@bp.after_request
|
|
async def _commit_ap_session(response):
|
|
if getattr(g, "_ap_own", False):
|
|
if 200 <= response.status_code < 400:
|
|
try:
|
|
await g._ap_tx.commit()
|
|
except Exception:
|
|
try:
|
|
await g._ap_tx.rollback()
|
|
except Exception:
|
|
pass
|
|
return response
|
|
|
|
@bp.teardown_request
|
|
async def _close_ap_session(exc):
|
|
if getattr(g, "_ap_own", False):
|
|
s = getattr(g, "_ap_s", None)
|
|
if s:
|
|
if exc is not None or s.in_transaction():
|
|
tx = getattr(g, "_ap_tx", None)
|
|
if tx and tx.is_active:
|
|
try:
|
|
await tx.rollback()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
await s.close()
|
|
except Exception:
|
|
pass
|
|
|
|
# ------------------------------------------------------------------
|
|
# 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._ap_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._ap_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 = (
|
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
'<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">\n'
|
|
f' <Link rel="lrdd" type="application/xrd+xml" '
|
|
f'template="https://{domain}/.well-known/webfinger?resource={{uri}}"/>\n'
|
|
'</XRD>'
|
|
)
|
|
return Response(response=xml, content_type="application/xrd+xml")
|
|
|
|
# ------------------------------------------------------------------
|
|
# Actor profile
|
|
# ------------------------------------------------------------------
|
|
|
|
@bp.get("/users/<username>")
|
|
async def actor_profile(username: str):
|
|
actor = await services.federation.get_actor_by_username(g._ap_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,
|
|
}
|
|
|
|
if aggregate:
|
|
# Aggregate actor advertises all per-app actors
|
|
also_known = [
|
|
f"https://{_ap_domain(a)}/users/{username}"
|
|
for a in AP_APPS if a != "federation"
|
|
]
|
|
if also_known:
|
|
actor_json["alsoKnownAs"] = also_known
|
|
else:
|
|
# Per-app actors link back to the aggregate federation actor
|
|
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._ap_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/<username>/inbox")
|
|
async def inbox(username: str):
|
|
actor = await services.federation.get_actor_by_username(g._ap_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 — rejecting",
|
|
from_actor_url, activity_type, domain,
|
|
)
|
|
abort(401, "Invalid or missing HTTP signature")
|
|
|
|
# Load actor row for DB operations
|
|
actor_row = (
|
|
await g._ap_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._ap_s.add(item)
|
|
await g._ap_s.flush()
|
|
|
|
# Dispatch to shared handlers
|
|
from shared.infrastructure.ap_inbox_handlers import dispatch_inbox_activity
|
|
await dispatch_inbox_activity(
|
|
g._ap_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._ap_s.flush()
|
|
|
|
return Response(status=202)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Outbox
|
|
# ------------------------------------------------------------------
|
|
|
|
@bp.get("/users/<username>/outbox")
|
|
async def outbox(username: str):
|
|
actor = await services.federation.get_actor_by_username(g._ap_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._ap_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._ap_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/<username>/followers")
|
|
async def followers(username: str):
|
|
actor = await services.federation.get_actor_by_username(g._ap_s, username)
|
|
if not actor:
|
|
abort(404)
|
|
|
|
collection_id = f"https://{domain}/users/{username}/followers"
|
|
follower_list = await services.federation.get_followers(
|
|
g._ap_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/<username>/following")
|
|
async def following(username: str):
|
|
actor = await services.federation.get_actor_by_username(g._ap_s, username)
|
|
if not actor:
|
|
abort(404)
|
|
|
|
collection_id = f"https://{domain}/users/{username}/following"
|
|
following_list, total = await services.federation.get_following(g._ap_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
|