"""Backup routes: upload, download, history, and delete E2E-encrypted backups. Blobs are stored in S3 via BlobStore. Backup metadata is kept in an in-memory dict until Step 12 migrates it to 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 time from email.utils import parsedate_to_datetime from typing import Any from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response, status from app.api.deps import get_current_user 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() # In-memory backup metadata — replaced by PostgreSQL backup_metadata table in Step 12 _backups: dict[str, list[dict[str, Any]]] = {} # user_id → list of backup records # TODO(Step11/12): replace with TierManager.check_quota(user_id) _TIER_BACKUP_LIMITS_GB: dict[str, int] = { "free": 0, "pro": 5, "power": 25, "team": -1, # unlimited } def _check_backup_quota(user_id: str, tier: str, size_bytes: int) -> None: """Raise HTTP 402 if the upload would exceed the tier's backup limit.""" limit_gb = _TIER_BACKUP_LIMITS_GB.get(tier, 0) if limit_gb == 0: raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, detail="Backup is not available on the free tier", ) if limit_gb == -1: return # unlimited limit_bytes = limit_gb * 1024**3 used = sum(b["size_bytes"] for b in _backups.get(user_id, [])) if used + size_bytes > limit_bytes: raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, detail=f"Backup quota exceeded for tier '{tier}'", ) @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), ) -> 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) _check_backup_quota(current_user.id, current_user.tier, len(blob)) s3_key = await _blob_store.upload( current_user.id, "backup", str(x_backup_timestamp), blob, x_backup_checksum ) backup_record: dict[str, Any] = { "id": str(x_backup_timestamp), "s3_key": s3_key, "version": x_backup_version, "timestamp": x_backup_timestamp, "checksum": x_backup_checksum, "size_bytes": len(blob), } user_backups = _backups.setdefault(current_user.id, []) user_backups.append(backup_record) user_backups.sort(key=lambda b: b["timestamp"], reverse=True) return {"ok": True} @router.get("/history", response_model=list[BackupMetadata]) async def backup_history( current_user: UserProfile = Depends(get_current_user), ) -> list[BackupMetadata]: """Return backup metadata records for the authenticated user (no blob bytes).""" return [ BackupMetadata( version=b["version"], timestamp=b["timestamp"], checksum=b["checksum"], chunk_count=1, # single-chunk uploads for now — TODO(Step12): track real count ) for b in _backups.get(current_user.id, []) ] @router.get("") async def download_backup( request: Request, current_user: UserProfile = Depends(get_current_user), ) -> Response: """Download the latest backup blob. Supports ``If-Modified-Since``.""" user_backups = _backups.get(current_user.id, []) if not user_backups: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No backup found") latest = user_backups[0] 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), ) -> dict[str, bool]: """Delete a specific backup by ID.""" user_backups = _backups.get(current_user.id, []) target = next((b for b in user_backups if b["id"] == backup_id), 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"]) _backups[current_user.id] = [b for b in user_backups if b["id"] != backup_id] return {"ok": True}