feat: strengthen backup and mobile workflows
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
import shutil
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Query, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.background import BackgroundTask
|
||||
|
||||
from backend.auth import current_user, hash_token
|
||||
from backend.config import get_settings
|
||||
from backend.db import get_db
|
||||
from backend.models import BackupPreflight, Countdown, User, new_id, utcnow
|
||||
|
||||
from .archive import MAX_ARCHIVE_BYTES as ARCHIVE_DEFAULT_LIMIT
|
||||
from .archive import backup_error, parse_archive_path
|
||||
from .schemas import RestoreRequest
|
||||
from .service import BackupRepairPending, export_v2, restore_v2, validate_archive
|
||||
|
||||
router = APIRouter(prefix="/api/v1/backup", tags=["backup"])
|
||||
MAX_ARCHIVE_BYTES = ARCHIVE_DEFAULT_LIMIT
|
||||
MAX_PENDING_PREFLIGHTS_PER_USER = 3
|
||||
_READ_CHUNK = 1024 * 1024
|
||||
|
||||
|
||||
def _staging_root() -> Path:
|
||||
root = Path(get_settings().backup_staging_dir).resolve()
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def _safe_staging_path(value: str) -> Path:
|
||||
root = _staging_root()
|
||||
path = Path(value).resolve()
|
||||
if path.parent != root:
|
||||
raise backup_error("backup_preflight_invalid", "预检暂存无效", 409)
|
||||
return path
|
||||
|
||||
|
||||
def _path_size(path: str | None) -> int:
|
||||
if not path:
|
||||
return 0
|
||||
root = Path(path)
|
||||
if not root.exists():
|
||||
return 0
|
||||
if root.is_file():
|
||||
return root.stat().st_size
|
||||
return sum(item.stat().st_size for item in root.rglob("*") if item.is_file())
|
||||
|
||||
|
||||
async def _prune(db: AsyncSession) -> None:
|
||||
now = utcnow()
|
||||
rows = list((await db.scalars(select(BackupPreflight).where(
|
||||
BackupPreflight.expires_at <= now,
|
||||
BackupPreflight.status.in_((
|
||||
"reserved", "pending", "failed", "consuming", "cleanup_pending",
|
||||
"repair_pending",
|
||||
)),
|
||||
).with_for_update())).all())
|
||||
from .storage import remove_quarantine, restore_quarantine_dir
|
||||
|
||||
attachment_root = Path(get_settings().attachment_dir).resolve()
|
||||
for row in rows:
|
||||
try:
|
||||
if row.status == "repair_pending":
|
||||
if row.cleanup_path:
|
||||
restore_quarantine_dir(attachment_root, Path(row.cleanup_path))
|
||||
row.cleanup_path = None
|
||||
_safe_staging_path(row.staging_path).unlink(missing_ok=True)
|
||||
row.status = "failed"
|
||||
continue
|
||||
if row.cleanup_path:
|
||||
remove_quarantine(Path(row.cleanup_path))
|
||||
row.cleanup_path = None
|
||||
_safe_staging_path(row.staging_path).unlink(missing_ok=True)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if row.status == "cleanup_pending":
|
||||
row.status = "consumed"
|
||||
row.consumed_at = row.consumed_at or now
|
||||
else:
|
||||
await db.delete(row)
|
||||
if rows:
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.get("/export.zip")
|
||||
async def export_zip(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
path = await export_v2(db, user)
|
||||
await db.commit()
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type="application/zip",
|
||||
filename="dodo-backup-v2.zip",
|
||||
background=BackgroundTask(path.unlink, missing_ok=True),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/preflight")
|
||||
async def preflight(
|
||||
mode: str = Query(pattern="^(merge|replace)$"),
|
||||
file: UploadFile = File(...),
|
||||
user: User = Depends(current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await _prune(db)
|
||||
settings = get_settings()
|
||||
configured_limit = settings.backup_max_archive_mb * 1024 * 1024
|
||||
limit = min(MAX_ARCHIVE_BYTES, configured_limit)
|
||||
max_pending = min(MAX_PENDING_PREFLIGHTS_PER_USER, settings.backup_max_pending_per_user)
|
||||
max_staged = settings.backup_max_staged_mb_per_user * 1024 * 1024
|
||||
token = secrets.token_urlsafe(32)
|
||||
staging = _staging_root() / f"{new_id()}.zip"
|
||||
reservation = BackupPreflight(
|
||||
token_hash=hash_token(token), user_id=user.id, backup_id=new_id(),
|
||||
archive_sha256="0" * 64, archive_size=limit, staging_path=str(staging), mode=mode,
|
||||
status="reserved", expires_at=utcnow() + timedelta(seconds=settings.backup_preflight_ttl_seconds),
|
||||
)
|
||||
# A user-row write serializes quota decisions across workers on PostgreSQL;
|
||||
# SQLite serializes writers at the database level.
|
||||
await db.execute(update(User).where(User.id == user.id).values(username=User.username))
|
||||
pending_count, pending_bytes = (await db.execute(select(
|
||||
func.count(BackupPreflight.id), func.coalesce(func.sum(BackupPreflight.archive_size), 0)
|
||||
).where(
|
||||
BackupPreflight.user_id == user.id,
|
||||
BackupPreflight.status.in_((
|
||||
"reserved", "pending", "failed", "consuming", "cleanup_pending",
|
||||
"repair_pending",
|
||||
)),
|
||||
))).one()
|
||||
pending_rows = list((await db.scalars(select(BackupPreflight).where(
|
||||
BackupPreflight.user_id == user.id,
|
||||
BackupPreflight.status.in_((
|
||||
"reserved", "pending", "failed", "consuming", "cleanup_pending",
|
||||
"repair_pending",
|
||||
)),
|
||||
))).all())
|
||||
pending_bytes += sum(_path_size(item.cleanup_path) for item in pending_rows)
|
||||
if pending_count >= max_pending or pending_bytes + limit > max_staged:
|
||||
await db.rollback()
|
||||
raise backup_error("backup_preflight_quota", "待处理预检配额已达上限", 429)
|
||||
db.add(reservation)
|
||||
await db.flush()
|
||||
reservation_id = reservation.id
|
||||
await db.commit()
|
||||
|
||||
size = 0
|
||||
digest = hashlib.sha256()
|
||||
archive = None
|
||||
try:
|
||||
with staging.open("xb") as output:
|
||||
while chunk := await file.read(_READ_CHUNK):
|
||||
size += len(chunk)
|
||||
if size > limit:
|
||||
raise backup_error("backup_size_invalid", "备份文件大小无效")
|
||||
digest.update(chunk)
|
||||
output.write(chunk)
|
||||
archive = parse_archive_path(staging, max_archive_bytes=limit)
|
||||
validate_archive(archive)
|
||||
if mode == "merge":
|
||||
incoming_pin_ids = {
|
||||
str(item["id"])
|
||||
for item in archive.entities["countdowns"]
|
||||
if item.get("pinned") is True and item.get("archived_at") is None
|
||||
}
|
||||
existing_pin_ids = set((await db.scalars(select(Countdown.id).where(
|
||||
Countdown.user_id == user.id,
|
||||
Countdown.pinned.is_(True),
|
||||
Countdown.archived_at.is_(None),
|
||||
))).all())
|
||||
if (
|
||||
incoming_pin_ids
|
||||
and existing_pin_ids
|
||||
and incoming_pin_ids != {str(item) for item in existing_pin_ids}
|
||||
):
|
||||
raise backup_error("backup_constraint_invalid", "合并恢复会产生多个置顶倒数日")
|
||||
shutil.rmtree(archive.staging_dir, ignore_errors=True)
|
||||
if archive.archive_sha256 != digest.hexdigest():
|
||||
raise backup_error("backup_checksum_mismatch", "备份校验和不匹配")
|
||||
reservation.backup_id = archive.backup_id
|
||||
reservation.archive_sha256 = archive.archive_sha256
|
||||
reservation.archive_size = size
|
||||
reservation.status = "pending"
|
||||
await db.commit()
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
if archive is not None:
|
||||
shutil.rmtree(archive.staging_dir, ignore_errors=True)
|
||||
failed = await db.get(BackupPreflight, reservation_id)
|
||||
if failed is not None:
|
||||
await db.delete(failed)
|
||||
await db.commit()
|
||||
staging.unlink(missing_ok=True)
|
||||
raise
|
||||
return {"valid": True, "preflight_token": token, "backup_id": archive.backup_id,
|
||||
"archive_sha256": archive.archive_sha256,
|
||||
"entities": {name: len(rows) for name, rows in archive.entities.items()}}
|
||||
|
||||
|
||||
@router.post("/restore")
|
||||
async def restore(
|
||||
payload: RestoreRequest,
|
||||
user: User = Depends(current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await _prune(db)
|
||||
token_hash = hash_token(payload.preflight_token)
|
||||
row = await db.scalar(select(BackupPreflight).where(
|
||||
BackupPreflight.token_hash == token_hash, BackupPreflight.user_id == user.id
|
||||
).with_for_update())
|
||||
if row is None or row.mode != payload.mode or row.expires_at <= utcnow():
|
||||
raise backup_error("backup_preflight_invalid", "预检令牌无效或已过期", 409)
|
||||
if row.status == "repair_pending" and row.cleanup_path:
|
||||
from .storage import restore_quarantine_dir
|
||||
root = Path(get_settings().attachment_dir).resolve()
|
||||
try:
|
||||
restore_quarantine_dir(root, Path(row.cleanup_path))
|
||||
except OSError as exc:
|
||||
raise backup_error("backup_repair_pending", "原附件复原尚未完成,请稍后重试", 503) from exc
|
||||
row.status = "failed"
|
||||
row.cleanup_path = None
|
||||
await db.commit()
|
||||
raise backup_error("backup_restore_retry", "附件已复原,请重新预检后重试", 409)
|
||||
if row.status == "cleanup_pending":
|
||||
from .storage import remove_quarantine
|
||||
try:
|
||||
if row.cleanup_path:
|
||||
remove_quarantine(Path(row.cleanup_path))
|
||||
_safe_staging_path(row.staging_path).unlink(missing_ok=True)
|
||||
except OSError as exc:
|
||||
raise backup_error("backup_cleanup_pending", "清理尚未完成,请稍后重试", 503) from exc
|
||||
row.status = "consumed"
|
||||
row.cleanup_path = None
|
||||
await db.commit()
|
||||
return {"restored": 0, "mode": payload.mode, "already_imported": False, "cleanup_retried": True}
|
||||
claimed = await db.execute(update(BackupPreflight).where(
|
||||
BackupPreflight.id == row.id, BackupPreflight.status.in_(("pending", "failed"))
|
||||
).values(status="consuming", consumed_at=utcnow()))
|
||||
if claimed.rowcount != 1:
|
||||
await db.rollback()
|
||||
raise backup_error("backup_preflight_invalid", "预检令牌已使用", 409)
|
||||
await db.commit()
|
||||
staging = _safe_staging_path(row.staging_path)
|
||||
archive = parse_archive_path(staging)
|
||||
try:
|
||||
if archive.archive_sha256 != row.archive_sha256 or archive.backup_id != row.backup_id:
|
||||
row.status = "failed"
|
||||
await db.commit()
|
||||
raise backup_error("backup_preflight_invalid", "预检暂存已改变", 409)
|
||||
result = await restore_v2(db, user, archive, payload.mode, operation=row)
|
||||
except BackupRepairPending:
|
||||
raise
|
||||
except Exception:
|
||||
await db.refresh(row)
|
||||
if row.status not in {"cleanup_pending", "repair_pending"}:
|
||||
row.status = "failed"
|
||||
await db.commit()
|
||||
raise
|
||||
finally:
|
||||
shutil.rmtree(archive.staging_dir, ignore_errors=True)
|
||||
row.status = "cleanup_pending"
|
||||
await db.commit()
|
||||
try:
|
||||
staging.unlink(missing_ok=True)
|
||||
except OSError as exc:
|
||||
raise backup_error("backup_cleanup_pending", "数据已恢复,但暂存清理未完成;请使用同一令牌重试", 500) from exc
|
||||
row.status = "consumed"
|
||||
await db.commit()
|
||||
return result
|
||||
Reference in New Issue
Block a user