Files

413 lines
15 KiB
Python

"""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}