"""Backup routes: upload, download, history, and delete E2E-encrypted backups. Blobs are stored in S3 via BlobStore. Backup metadata is persisted in the PostgreSQL ``backup_metadata`` table. IMPORTANT: GET /history must be declared BEFORE GET / to avoid FastAPI treating "history" as a ``{backup_id}`` path parameter. """ from __future__ import annotations import uuid from email.utils import parsedate_to_datetime from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response, status from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user from app.billing.tier_manager import tier_manager from app.db import get_session from app.models import BackupMetadata as BackupMetadataModel from app.schemas import BackupMetadata, UserProfile from app.storage.blob_store import BlobStore from app.storage.encryption import reject_if_tampered router = APIRouter(prefix="/backup", tags=["backup"]) _blob_store = BlobStore() async def _current_backup_bytes(user_id: str, db: AsyncSession) -> int: """Return total backup bytes stored by *user_id*.""" result = await db.execute( select(func.coalesce(func.sum(BackupMetadataModel.size_bytes), 0)).where( BackupMetadataModel.user_id == user_id ) ) return int(result.scalar_one()) async def _check_backup_quota( user: UserProfile, size_bytes: int, db: AsyncSession ) -> None: """Raise HTTP 402 if the upload would exceed the tier's backup limit.""" current = await _current_backup_bytes(user.id, db) tier_manager.enforce_backup_quota( user.tier, current_bytes=current, additional_bytes=size_bytes ) @router.put("") async def upload_backup( request: Request, x_backup_version: int = Header(..., alias="X-Backup-Version"), x_backup_timestamp: int = Header(..., alias="X-Backup-Timestamp"), x_backup_checksum: str = Header(..., alias="X-Backup-Checksum"), current_user: UserProfile = Depends(get_current_user), db: AsyncSession = Depends(get_session), ) -> dict[str, bool]: """Upload an E2E-encrypted backup blob. Metadata is passed via custom headers; the raw body is the encrypted blob. """ blob = await request.body() reject_if_tampered(blob, x_backup_checksum) await _check_backup_quota(current_user, len(blob), db) s3_key = await _blob_store.upload( current_user.id, "backup", str(x_backup_timestamp), blob, x_backup_checksum ) row = BackupMetadataModel( id=str(uuid.uuid4()), user_id=current_user.id, s3_key=s3_key, version=x_backup_version, timestamp=x_backup_timestamp, checksum=x_backup_checksum, size_bytes=len(blob), ) db.add(row) await db.commit() return {"ok": True} @router.get("/history", response_model=list[BackupMetadata]) async def backup_history( current_user: UserProfile = Depends(get_current_user), db: AsyncSession = Depends(get_session), ) -> list[BackupMetadata]: """Return backup metadata records for the authenticated user (no blob bytes).""" result = await db.execute( select(BackupMetadataModel) .where(BackupMetadataModel.user_id == current_user.id) .order_by(BackupMetadataModel.timestamp.desc()) ) rows = result.scalars().all() return [ BackupMetadata( version=r.version, timestamp=r.timestamp, checksum=r.checksum, chunk_count=1, ) for r in rows ] @router.get("") async def download_backup( request: Request, current_user: UserProfile = Depends(get_current_user), db: AsyncSession = Depends(get_session), ) -> Response: """Download the latest backup blob. Supports ``If-Modified-Since``.""" result = await db.execute( select(BackupMetadataModel) .where(BackupMetadataModel.user_id == current_user.id) .order_by(BackupMetadataModel.timestamp.desc()) .limit(1) ) latest = result.scalar_one_or_none() if latest is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No backup found") ims_header = request.headers.get("If-Modified-Since") if ims_header: try: ims_dt = parsedate_to_datetime(ims_header) ims_ms = int(ims_dt.timestamp() * 1000) if latest.timestamp <= ims_ms: return Response(status_code=status.HTTP_304_NOT_MODIFIED) except Exception: pass # malformed header — ignore and serve the blob blob = await _blob_store.download(current_user.id, latest.s3_key) return Response( content=blob, media_type="application/octet-stream", headers={ "X-Backup-Version": str(latest.version), "X-Backup-Timestamp": str(latest.timestamp), "X-Checksum": latest.checksum, }, ) @router.delete("/{backup_id}", response_model=dict) async def delete_backup( backup_id: str, current_user: UserProfile = Depends(get_current_user), db: AsyncSession = Depends(get_session), ) -> dict[str, bool]: """Delete a specific backup by ID.""" result = await db.execute( select(BackupMetadataModel).where( BackupMetadataModel.id == backup_id, BackupMetadataModel.user_id == current_user.id, ) ) target = result.scalar_one_or_none() if target is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup not found") await _blob_store.delete(current_user.id, target.s3_key) await db.delete(target) await db.commit() return {"ok": True}