kiwi/app/api/endpoints/mastodon_oauth.py
pyr0ball 6e954c5c6e
Some checks failed
CI / Backend (Python) (push) Has been cancelled
CI / Frontend (Vue) (push) Has been cancelled
Mirror / mirror (push) Has been cancelled
feat(ap): issue #113 — ActivityPub federation + Mastodon OAuth
Full ActivityPub implementation wired to cf-core.activitypub module:

Endpoints (root-level, not under /api/v1):
  GET  /.well-known/webfinger  — WebFinger JRD (AP_ENABLED only)
  GET  /ap/actor               — Instance actor document
  POST /ap/actor/inbox         — Incoming Follow/Undo (dedup + Accept dispatch)
  GET  /ap/outbox              — OrderedCollection of community posts
  GET  /ap/posts/{slug}        — Individual AP Note
  GET  /ap/followers           — Follower count collection
  GET  /ap/following           — Empty following collection

Mastodon OAuth (under /api/v1/social/mastodon/):
  POST   /connect      — Dynamic app registration + OAuth flow start
  GET    /callback     — Code exchange + token storage (Fernet-encrypted)
  DELETE /disconnect   — Token revocation
  GET    /status       — Connection status

Config: AP_ENABLED, AP_HOST, AP_KEY_PATH, AP_TOKEN_ENCRYPTION_KEY
Migration 042: ap_followers, ap_deliveries, ap_received, mastodon_tokens tables
Key manager: auto-generates RSA-2048 keypair on first boot if AP_ENABLED
Delivery service: deliver_to_followers() with 3-retry exponential backoff + DB log
Post publish: background fan-out to AP followers + Mastodon when opted-in

All AP endpoints gracefully degrade (404) when AP_ENABLED=false.
2026-05-11 17:55:51 -07:00

133 lines
4.5 KiB
Python

# app/api/endpoints/mastodon_oauth.py
# MIT License
#
# Mastodon OAuth flow endpoints:
# POST /social/mastodon/connect — Start OAuth (dynamic app registration)
# GET /social/mastodon/callback — OAuth callback, exchange code for token
# DELETE /social/mastodon/disconnect — Revoke and remove stored token
# GET /social/mastodon/status — Check connection status
from __future__ import annotations
import asyncio
import logging
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import RedirectResponse
from app.cloud_session import CloudUser, get_session
from app.core.config import settings
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/social/mastodon", tags=["mastodon"])
def _redirect_uri() -> str:
host = settings.AP_HOST or "localhost:8512"
return f"https://{host}/api/v1/social/mastodon/callback"
# In-memory pending state: maps state_token → {instance_url, client_id, client_secret, user_id}
# A real deployment would persist this in a short-TTL cache or DB.
_pending: dict[str, dict] = {}
@router.post("/connect")
async def connect_mastodon(body: dict, session: CloudUser = Depends(get_session)):
"""Start the Mastodon OAuth flow.
Body: {"instance_url": "https://mastodon.social"}
Returns: {"authorize_url": "..."}
"""
import secrets
from app.services.ap.mastodon import build_authorize_url, register_app
instance_url = (body.get("instance_url") or "").strip().rstrip("/")
if not instance_url.startswith("https://"):
raise HTTPException(status_code=422, detail="instance_url must be an https:// URL.")
redirect_uri = _redirect_uri()
try:
app_creds = await asyncio.to_thread(register_app, instance_url, redirect_uri)
except Exception as exc:
raise HTTPException(
status_code=502, detail=f"Could not register with Mastodon instance: {exc}"
) from exc
state = secrets.token_urlsafe(24)
_pending[state] = {
"instance_url": instance_url,
"client_id": app_creds["client_id"],
"client_secret": app_creds["client_secret"],
"user_id": session.user_id,
}
authorize_url = build_authorize_url(
instance_url=instance_url,
client_id=app_creds["client_id"],
redirect_uri=redirect_uri + f"?state={state}",
)
return {"authorize_url": authorize_url, "state": state}
@router.get("/callback")
async def mastodon_callback(code: str | None = None, state: str | None = None):
"""OAuth callback. Exchanges auth code for access token and stores it."""
if not code or not state:
raise HTTPException(status_code=400, detail="Missing code or state parameter.")
pending = _pending.pop(state, None)
if pending is None:
raise HTTPException(status_code=400, detail="Unknown or expired OAuth state.")
from app.services.ap.mastodon import exchange_code, store_token
redirect_uri = _redirect_uri() + f"?state={state}"
try:
access_token = await asyncio.to_thread(
exchange_code,
pending["instance_url"],
pending["client_id"],
pending["client_secret"],
code,
redirect_uri,
)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Token exchange failed: {exc}") from exc
await asyncio.to_thread(
store_token,
settings.DB_PATH,
pending["user_id"],
pending["instance_url"],
access_token,
settings.AP_TOKEN_ENCRYPTION_KEY,
)
# Redirect to frontend settings page after successful connect
return RedirectResponse(url="/#/settings?mastodon=connected", status_code=302)
@router.delete("/disconnect", status_code=204)
async def disconnect_mastodon(session: CloudUser = Depends(get_session)):
"""Remove the stored Mastodon token."""
from app.services.ap.mastodon import delete_token
await asyncio.to_thread(delete_token, settings.DB_PATH, session.user_id)
@router.get("/status")
async def mastodon_status(session: CloudUser = Depends(get_session)):
"""Return connection status and instance URL (no token value)."""
from app.services.ap.mastodon import get_token
result = await asyncio.to_thread(
get_token,
settings.DB_PATH,
session.user_id,
settings.AP_TOKEN_ENCRYPTION_KEY,
)
if result is None:
return {"connected": False, "instance_url": None}
instance_url, _ = result
return {"connected": True, "instance_url": instance_url}