Files
comiaunicaty/backend/app/routers/remote.py

120 lines
6.4 KiB
Python

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}