413 lines
15 KiB
Python
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}
|