"""AP-style inbox endpoint for receiving signed activities from the coop. POST /inbox — verify HTTP Signature, dispatch by activity type. """ from __future__ import annotations import logging import time import httpx from fastapi import APIRouter, Request from fastapi.responses import JSONResponse from ..dependencies import get_redis_client from ..utils.http_signatures import verify_request_signature, parse_key_id log = logging.getLogger(__name__) router = APIRouter() # Cache fetched public keys in Redis for 24 hours _KEY_CACHE_TTL = 86400 async def _fetch_actor_public_key(actor_url: str) -> str | None: """Fetch an actor's public key, with Redis caching.""" redis = get_redis_client() cache_key = f"actor_pubkey:{actor_url}" # Check cache cached = redis.get(cache_key) if cached: return cached # Fetch actor JSON try: async with httpx.AsyncClient(timeout=10) as client: resp = await client.get( actor_url, headers={"Accept": "application/activity+json, application/ld+json"}, ) if resp.status_code != 200: log.warning("Failed to fetch actor %s: %d", actor_url, resp.status_code) return None data = resp.json() except Exception: log.warning("Error fetching actor %s", actor_url, exc_info=True) return None pub_key_pem = (data.get("publicKey") or {}).get("publicKeyPem") if not pub_key_pem: log.warning("No publicKey in actor %s", actor_url) return None # Cache it redis.set(cache_key, pub_key_pem, ex=_KEY_CACHE_TTL) return pub_key_pem @router.post("/inbox") async def inbox(request: Request): """Receive signed AP activities from the coop platform.""" sig_header = request.headers.get("signature", "") if not sig_header: return JSONResponse({"error": "missing signature"}, status_code=401) # Read body body = await request.body() # Verify HTTP Signature actor_url = parse_key_id(sig_header) if not actor_url: return JSONResponse({"error": "invalid keyId"}, status_code=401) pub_key = await _fetch_actor_public_key(actor_url) if not pub_key: return JSONResponse({"error": "could not fetch public key"}, status_code=401) req_headers = dict(request.headers) path = request.url.path valid = verify_request_signature( public_key_pem=pub_key, signature_header=sig_header, method="POST", path=path, headers=req_headers, ) if not valid: log.warning("Invalid signature from %s", actor_url) return JSONResponse({"error": "invalid signature"}, status_code=401) # Parse and dispatch try: activity = await request.json() except Exception: return JSONResponse({"error": "invalid json"}, status_code=400) activity_type = activity.get("type", "") log.info("Inbox received: %s from %s", activity_type, actor_url) if activity_type == "rose:DeviceAuth": _handle_device_auth(activity) # Always 202 — AP convention return JSONResponse({"status": "accepted"}, status_code=202) def _handle_device_auth(activity: dict) -> None: """Set or delete did_auth:{device_id} in local Redis.""" obj = activity.get("object", {}) device_id = obj.get("device_id", "") action = obj.get("action", "") if not device_id: log.warning("rose:DeviceAuth missing device_id") return redis = get_redis_client() if action == "login": redis.set(f"did_auth:{device_id}", str(time.time()), ex=30 * 24 * 3600) log.info("did_auth set for device %s...", device_id[:16]) elif action == "logout": redis.delete(f"did_auth:{device_id}") log.info("did_auth cleared for device %s...", device_id[:16]) else: log.warning("rose:DeviceAuth unknown action: %s", action)