from __future__ import annotations from fastapi import APIRouter, Depends, Header, HTTPException, status from sqlalchemy import select from sqlalchemy.orm import Session from app.core.security import hash_token, token_urlsafe from app.db.base import get_db from app.models import ConnectionToken, Group, RemoteServerConnection from app.schemas import ConnectionTokenCreate, RemoteConnect from app.services.auth import CurrentContext, audit, ensure_home_profile, get_current_context, get_member_for_group from app.services.permissions import require_role from app.services.remote import fetch_manifest, manifest, mask_store_token, sync_connection, sync_payload_for_token, validate_connection_token from app.services.serializers import remote_connection_dict api_router = APIRouter(prefix="/api", tags=["remote"]) well_known_router = APIRouter(tags=["remote"]) @well_known_router.get("/.well-known/group-platform.json") def well_known_manifest() -> dict: return manifest() @api_router.get("/sync") def sync(since: str | None = None, authorization: str | None = Header(default=None), db: Session = Depends(get_db)) -> dict: if not authorization or not authorization.lower().startswith("bearer "): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail={"error": {"code": "token_required", "message": "Connection code required.", "details": {}}}) raw_token = authorization.split(" ", 1)[1].strip() token = validate_connection_token(db, raw_token) if not token or "sync:read" not in token.scopes_json: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail={"error": {"code": "permission_denied", "message": "This connection cannot sync.", "details": {}}}) return sync_payload_for_token(db, token) @api_router.post("/connection-tokens") def create_connection_token(payload: ConnectionTokenCreate, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict: actor = None if payload.group_id: group = db.get(Group, payload.group_id) if not group: raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Group not found.", "details": {}}}) actor = get_member_for_group(db, ctx, group.id) require_role(actor, "admin") elif ctx.member: actor = ctx.member raw = token_urlsafe(32) token = ConnectionToken( created_by_member_id=actor.id if actor else None, group_id=payload.group_id, label=payload.label, token_hash=hash_token(raw), scopes_json=payload.scopes, expires_at=payload.expires_at, ) db.add(token) audit(db, ctx=ctx, action="connection_token_created", resource_type="connection_token", resource_id=token.id) db.commit() return {"connection_code_display_once": raw, "token": {"id": token.id, "label": token.label, "scopes": token.scopes_json, "expires_at": token.expires_at.isoformat() if token.expires_at else None}} @api_router.get("/remote/servers") def remote_servers(ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict: if not ctx.home_profile: return {"servers": []} rows = db.scalars(select(RemoteServerConnection).where(RemoteServerConnection.home_profile_id == ctx.home_profile.id)).all() return {"servers": [remote_connection_dict(item) for item in rows]} @api_router.post("/remote/servers/connect") def connect_remote(payload: RemoteConnect, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict: profile = ensure_home_profile(db, ctx) server_url = str(payload.server_url).rstrip("/") try: remote_manifest = fetch_manifest(server_url) except Exception as exc: # noqa: BLE001 raise HTTPException(status_code=400, detail={"error": {"code": "remote_unreachable", "message": "Could not read that server's group manifest.", "details": {"reason": str(exc)}}}) from exc connection = RemoteServerConnection( home_profile_id=profile.id, server_origin=server_url, server_name=remote_manifest.get("server_name", server_url), api_base=remote_manifest.get("api_base", f"{server_url}/api"), protocol_version=remote_manifest.get("protocol_version", "0.1"), capabilities_json=remote_manifest.get("capabilities", {}), access_token_encrypted=mask_store_token(payload.connection_code), status="active", ) db.add(connection) db.flush() audit(db, ctx=ctx, action="remote_server_connected", resource_type="remote_connection", resource_id=connection.id) sync_connection(db, connection) db.commit() return {"server": remote_connection_dict(connection)} @api_router.post("/remote/servers/{connection_id}/sync") def sync_remote_server(connection_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict: if not ctx.home_profile: raise HTTPException(status_code=403, detail={"error": {"code": "profile_required", "message": "Save access before connecting servers.", "details": {}}}) connection = db.scalar(select(RemoteServerConnection).where(RemoteServerConnection.id == connection_id, RemoteServerConnection.home_profile_id == ctx.home_profile.id)) if not connection: raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Connected server not found.", "details": {}}}) sync_connection(db, connection) audit(db, ctx=ctx, action="remote_server_synced", resource_type="remote_connection", resource_id=connection.id) db.commit() return {"server": remote_connection_dict(connection)} @api_router.delete("/remote/servers/{connection_id}") def delete_remote_server(connection_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict: if not ctx.home_profile: raise HTTPException(status_code=403, detail={"error": {"code": "profile_required", "message": "Save access before managing servers.", "details": {}}}) connection = db.scalar(select(RemoteServerConnection).where(RemoteServerConnection.id == connection_id, RemoteServerConnection.home_profile_id == ctx.home_profile.id)) if not connection: raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Connected server not found.", "details": {}}}) connection.status = "revoked" audit(db, ctx=ctx, action="remote_server_removed", resource_type="remote_connection", resource_id=connection.id) db.commit() return {"ok": True}