"""Backup destination management (SFTP / S3-compatible).""" from __future__ import annotations import asyncio import json from fastapi import APIRouter, Depends, HTTPException, Request from sqlmodel import Session, select from auth import get_current_user, require_admin from database import get_session from models.backup_destination import ( DESTINATION_TYPES, SECRET_KEYS, BackupDestination, DestinationCreate, DestinationRead, DestinationUpdate, ) from models.user import User from services import audit_service, backup_destination_service as dest_service router = APIRouter(prefix="/api/backups/destinations", tags=["backups"]) def _ip(request: Request) -> str: return request.client.host if request.client else "unknown" def _mask(config: dict) -> dict: return {k: ("••••••" if k in SECRET_KEYS and v else v) for k, v in config.items()} def _to_read(d: BackupDestination) -> DestinationRead: return DestinationRead( id=d.id, name=d.name, type=d.type, config=_mask(dest_service.parse_config(d)), created_at=d.created_at, ) def _get_or_404(session: Session, dest_id: int) -> BackupDestination: d = session.get(BackupDestination, dest_id) if not d: raise HTTPException(status_code=404, detail=f"Destination {dest_id} not found") return d @router.get("", response_model=list[DestinationRead]) def list_destinations( session: Session = Depends(get_session), _user: User = Depends(require_admin), ) -> list[DestinationRead]: rows = session.exec(select(BackupDestination).order_by(BackupDestination.id)).all() return [_to_read(d) for d in rows] @router.post("", response_model=DestinationRead, status_code=201) def create_destination( body: DestinationCreate, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> DestinationRead: if body.type not in DESTINATION_TYPES: raise HTTPException(status_code=400, detail=f"Unknown type '{body.type}'") d = BackupDestination(name=body.name, type=body.type, config=json.dumps(body.config)) session.add(d) session.commit() session.refresh(d) audit_service.record( session, user=user.username, action="destination.create", target=body.name, detail=body.type, ip=_ip(request), ) return _to_read(d) @router.put("/{dest_id}", response_model=DestinationRead) def update_destination( dest_id: int, body: DestinationUpdate, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> DestinationRead: d = _get_or_404(session, dest_id) if body.name is not None: d.name = body.name if body.config is not None: # Merge so masked/blank secrets don't wipe stored ones. existing = dest_service.parse_config(d) for k, v in body.config.items(): if k in SECRET_KEYS and (v == "" or v == "••••••"): continue # keep existing secret existing[k] = v d.config = json.dumps(existing) session.add(d) session.commit() session.refresh(d) audit_service.record( session, user=user.username, action="destination.update", target=d.name, ip=_ip(request), ) return _to_read(d) @router.delete("/{dest_id}") def delete_destination( dest_id: int, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> dict: d = _get_or_404(session, dest_id) name = d.name session.delete(d) session.commit() audit_service.record( session, user=user.username, action="destination.delete", target=name, ip=_ip(request), ) return {"ok": True} @router.post("/{dest_id}/test") async def test_destination( dest_id: int, session: Session = Depends(get_session), _user: User = Depends(require_admin), ) -> dict: d = _get_or_404(session, dest_id) try: await asyncio.to_thread(dest_service.test, d) return {"ok": True} except dest_service.DestinationError as exc: return {"ok": False, "error": str(exc)} @router.get("/{dest_id}/backups") async def list_destination_backups( dest_id: int, session: Session = Depends(get_session), _user: User = Depends(require_admin), ) -> list[dict]: d = _get_or_404(session, dest_id) try: return await asyncio.to_thread(dest_service.list_backups, d) except dest_service.DestinationError as exc: raise HTTPException(status_code=502, detail=str(exc)) from exc @router.delete("/{dest_id}/backups/{name}") async def delete_destination_backup( dest_id: int, name: str, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> dict: d = _get_or_404(session, dest_id) try: await asyncio.to_thread(dest_service.delete, d, name) except dest_service.DestinationError as exc: raise HTTPException(status_code=502, detail=str(exc)) from exc audit_service.record( session, user=user.username, action="destination.backup.delete", target=f"{d.name}/{name}", ip=_ip(request), ) return {"ok": True}