172 lines
5.6 KiB
Python
172 lines
5.6 KiB
Python
"""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}
|