120 lines
6.4 KiB
Python
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}
|