step 8 complete: REST + WebSocket API routes for chat, plans, storage, vectors, backup, plugins, billing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
158
app/api/routes/backup.py
Normal file
158
app/api/routes/backup.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""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}
|
||||
Reference in New Issue
Block a user