"""Backup endpoints (ROADMAP #85, tranche 4). Handlers déplacés depuis :mod:`backend.main` sans changement de comportement : mêmes chemins (``/api/file/{vault}/backups|diff|restore``, ``/api/backups*``), mêmes modèles de réponse, mêmes dépendances d'authentification. La logique métier vit déjà dans :mod:`backend.services.backups`. Adaptations strictement équivalentes : - ``_resolve_safe_path`` / ``_backup_file`` / ``_list_backup_files`` de ``main`` n'étaient que des wrappers directs : appelés ici via :mod:`backend.services.paths` et :mod:`backend.services.backups`. - ``RestoreRequest`` / ``RestoreResponse`` / ``DiffResponse`` ont déménagé dans :mod:`backend.schemas`. - Le singleton SSE vit désormais dans :mod:`backend.sse` (partagé avec ``main`` : les clients ``/api/events`` reçoivent les mêmes broadcasts). """ import logging import os import time from datetime import datetime, timezone from pathlib import Path from typing import Any from fastapi import APIRouter, Body, Depends, HTTPException, Query from backend.auth.middleware import check_vault_access, require_auth from backend.indexer import get_vault_data, index, update_single_file from backend.schemas import ( BackupContentResponse, BackupsAutoResponse, BackupsCompressResponse, BackupsDeletedResponse, BackupsListResponse, BackupsResponse, DiffResponse, RestoreRequest, RestoreResponse, ) from backend.services.backups import ( create_backup, ) from backend.services.backups import ( diff_backup as service_diff_backup, ) from backend.services.backups import ( list_backup_files as service_list_backup_files, ) from backend.services.mutations import ( restore_backup as service_restore_backup, ) from backend.services.paths import resolve_safe_path from backend.sse import sse_manager from backend.webhooks import dispatch_webhooks logger = logging.getLogger("obsigate") router = APIRouter(tags=["backups"]) @router.get("/api/file/{vault_name}/backups", response_model=BackupsResponse) async def api_file_backups( vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth), ): """List all available backups for a file. Args: vault_name: Name of the vault. path: Relative path of the file within the vault. Returns: BackupListResponse with backups sorted newest first. """ if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") vault_data = get_vault_data(vault_name) if not vault_data: raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") vault_root = Path(vault_data["path"]) file_path = resolve_safe_path(vault_root, path) if not file_path.exists() or not file_path.is_file(): raise HTTPException(status_code=404, detail=f"File not found: {path}") try: backups = service_list_backup_files(vault_name, path) except Exception as e: logger.error(f"Error listing backups for {vault_name}/{path}: {type(e).__name__}: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"Erreur lors de la lecture des backups: {e!s}") return {"vault": vault_name, "path": path, "backups": backups} @router.get("/api/file/{vault_name}/diff", response_model=DiffResponse) async def api_file_diff( vault_name: str, path: str = Query(..., description="Relative path to file"), version: int = Query(..., description="Timestamp of the backup version (left/old side)"), compare_with: int | None = Query(default=None, description="Timestamp of another backup (right/new side). If omitted, compares with the current file."), current_user=Depends(require_auth), ): """Generate a unified diff between a backup version and another version or the current file. Args: vault_name: Name of the vault. path: Relative path of the file within the vault. version: Timestamp of the backup to use as the old/left side. compare_with: Optional timestamp of another backup as the new/right side. If omitted, the current file on disk is used. Returns: DiffResponse containing the unified diff string. """ if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") return service_diff_backup(vault_name, path, version, compare_with) @router.post("/api/file/{vault_name}/restore", response_model=RestoreResponse) async def api_file_restore( vault_name: str, path: str = Query(..., description="Relative path to file"), body: RestoreRequest = ..., # type: ignore current_user=Depends(require_auth), ): """Restore a file from a backup version. The current file is backed up before being overwritten (so the operation is reversible). Args: vault_name: Name of the vault. path: Relative path of the file within the vault. body: RestoreRequest with the backup version timestamp. Returns: RestoreResponse confirming the restore. """ if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") result = service_restore_backup(vault_name, path, body.version) current_backed_up = result["current_backed_up"] # Update index await update_single_file(vault_name, path) # Broadcast SSE event await sse_manager.broadcast("file_restored", { "vault": vault_name, "path": path, "restored_from": body.version, "current_backed_up": current_backed_up, }) await dispatch_webhooks("file_restored", {"vault": vault_name, "path": path, "restored_from": body.version}) return { "success": True, "vault": vault_name, "path": path, "restored_from": body.version, "current_backed_up": current_backed_up, } @router.get("/api/backups", response_model=BackupsListResponse) async def api_backups_list( vault: str | None = Query(None, description="Filter by vault name"), current_user=Depends(require_auth), ): """List all backups across vaults, grouped by file.""" result: list[dict[str, Any]] = [] try: for vault_name in index: if vault and vault_name != vault: continue if not check_vault_access(vault_name, current_user): continue vd = get_vault_data(vault_name) if not vd: continue vault_root = Path(vd["path"]) backup_root = Path(os.environ.get("OBSIGATE_BACKUP_DIR", ".obsigate-backup")) if not backup_root.is_absolute(): backup_root = vault_root / backup_root vault_backup_dir = backup_root / vault_name if not vault_backup_dir.exists(): continue for fpath in vault_backup_dir.rglob("*.bak"): if not fpath.is_file(): continue st = fpath.stat() fsize = st.st_size ts_part = fpath.name.rsplit(".", 2) if len(ts_part) < 3 or not ts_part[-2].isdigit(): continue ts = int(ts_part[-2]) rel_dir = str(fpath.parent.relative_to(vault_backup_dir)).replace("\\", "/") rel_file = rel_dir + "/" + ts_part[0] if rel_dir != "." else ts_part[0] result.append({ "vault": vault_name, "file": rel_file, "backup_file": fpath.name, "timestamp": ts, "datetime": datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(), "size": fsize, "full_path": str(fpath), }) result.sort(key=lambda x: x["timestamp"], reverse=True) total_size = sum(r["size"] for r in result) return {"backups": result, "total": len(result), "total_size_bytes": total_size} except Exception as e: logger.error(f"Error listing backups: {type(e).__name__}: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"Erreur listing backups: {e!s}") @router.post("/api/backups/delete", response_model=BackupsDeletedResponse) async def api_backups_delete( body: dict = Body(...), current_user=Depends(require_auth), ): """Delete one or more backup files.""" paths = body.get("paths", []) if not paths: raise HTTPException(status_code=400, detail="No backup paths provided") deleted = 0 for p in paths: try: fpath = Path(p) # Security: ensure path is within a backup directory if ".obsigate-backup" not in str(fpath): continue if fpath.exists() and fpath.is_file(): fpath.unlink() deleted += 1 except Exception as e: logger.warning(f"Failed to delete backup {p}: {e}") return {"deleted": deleted} @router.post("/api/backups/purge", response_model=BackupsDeletedResponse) async def api_backups_purge( body: dict = Body(...), current_user=Depends(require_auth), ): """Purge all backups for a specific file or entire vault.""" vault_name = body.get("vault") file_path = body.get("file") # optional if not vault_name: raise HTTPException(status_code=400, detail="Vault name required") if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail="Access denied") vd = get_vault_data(vault_name) if not vd: raise HTTPException(status_code=404, detail="Vault not found") vault_root = Path(vd["path"]) backup_root = Path(os.environ.get("OBSIGATE_BACKUP_DIR", ".obsigate-backup")) if not backup_root.is_absolute(): backup_root = vault_root / backup_root if file_path: # Delete backups for specific file backup_dir = backup_root / vault_name / Path(file_path).parent if backup_dir.exists(): fname = Path(file_path).name deleted = 0 for f in backup_dir.iterdir(): if f.is_file() and f.name.startswith(fname + ".") and f.name.endswith(".bak"): f.unlink() deleted += 1 return {"deleted": deleted} return {"deleted": 0} else: # Delete all backups for vault vault_backup_dir = backup_root / vault_name if vault_backup_dir.exists(): deleted = 0 for f in vault_backup_dir.rglob("*.bak"): if f.is_file(): f.unlink() deleted += 1 return {"deleted": deleted} return {"deleted": 0} @router.get("/api/backups/content", response_model=BackupContentResponse) async def api_backups_content( path: str = Query(..., description="Full path to backup file"), current_user=Depends(require_auth), ): """Return the content of a specific backup file.""" try: fpath = Path(path) if ".obsigate-backup" not in str(fpath): raise HTTPException(status_code=403, detail="Access denied") if not fpath.exists() or not fpath.is_file(): raise HTTPException(status_code=404, detail="Backup not found") content = fpath.read_text(encoding="utf-8", errors="replace") # Truncate large files to 100KB if len(content) > 102400: content = content[:102400] + "\n\n... (tronque a 100 Ko)" return {"content": content, "name": fpath.name, "size": len(content)} except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @router.post("/api/backups/compress", response_model=BackupsCompressResponse) async def api_backups_compress( body: dict = Body(...), current_user=Depends(require_auth), ): """Compress backups older than N days. Body: {older_than_days: 30, dry_run: false}""" import gzip as gz_mod older_than = body.get("older_than_days", 30) dry_run = body.get("dry_run", False) cutoff = time.time() - (older_than * 86400) compressed = 0 saved_bytes = 0 for vault_name in index: if not check_vault_access(vault_name, current_user): continue vd = get_vault_data(vault_name) if not vd: continue vault_root = Path(vd["path"]) backup_root = Path(os.environ.get("OBSIGATE_BACKUP_DIR", ".obsigate-backup")) if not backup_root.is_absolute(): backup_root = vault_root / backup_root vault_dir = backup_root / vault_name if not vault_dir.exists(): continue for fpath in vault_dir.rglob("*.bak"): if not fpath.is_file(): continue if fpath.name.endswith(".bak.gz"): continue mtime = fpath.stat().st_mtime if mtime > cutoff: continue if not dry_run: try: gz_path = fpath.with_suffix(fpath.suffix + ".gz") data = fpath.read_bytes() with gz_mod.open(str(gz_path), "wb", compresslevel=6) as gzf: gzf.write(data) orig_size = len(data) gz_size = gz_path.stat().st_size if gz_size < orig_size: fpath.unlink() saved_bytes += (orig_size - gz_size) else: gz_path.unlink() # compression didn't help compressed += 1 except Exception as e: logger.warning(f"Failed to compress {fpath}: {e}") else: compressed += 1 return {"compressed": compressed, "saved_bytes": saved_bytes, "dry_run": dry_run} @router.post("/api/backups/auto", response_model=BackupsAutoResponse) async def api_backups_auto( body: dict = Body(...), current_user=Depends(require_auth), ): """Create backups for files modified since a given time. Body: {since_hours: 24}""" since_hours = body.get("since_hours", 24) cutoff = time.time() - (since_hours * 3600) backed_up = 0 for vault_name in index: if not check_vault_access(vault_name, current_user): continue vd = get_vault_data(vault_name) if not vd: continue vault_root = Path(vd["path"]) for fpath in vault_root.rglob("*"): if not fpath.is_file(): continue if fpath.name.startswith('.'): continue if any(p.startswith('.') or p in {'.obsidian', '.trash', '.git', '.obsigate-backup', '__pycache__', 'node_modules'} for p in fpath.relative_to(vault_root).parts): continue mtime = fpath.stat().st_mtime if mtime < cutoff: continue try: rel = str(fpath.relative_to(vault_root)).replace("\\", "/") create_backup(fpath, vault_name, rel) backed_up += 1 except Exception as e: logger.warning(f"Auto-backup failed for {rel}: {e}") return {"backed_up": backed_up, "since_hours": since_hours}