feat: backup management page + max backups config + auto-cleanup
CI / lint (push) Failing after 4s
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / security (push) Successful in 9s

- Config: max_backups_per_file (default 10) in config.json + UI field
- Auto-cleanup: _backup_file deletes oldest backups when limit exceeded
- New endpoints: GET /api/backups (list all), POST /api/backups/delete,
  POST /api/backups/purge (by file or vault)
- New module: backup-manager.js - full-page view with filter, grouped by
  vault/file, individual delete, per-file purge, stats
- Link from backup viewer footer: 'Gerer tous les backups'
This commit is contained in:
2026-06-04 20:15:31 -04:00
parent ca132c2668
commit fb87c9f7c3
6 changed files with 485 additions and 1 deletions
+138
View File
@@ -735,6 +735,17 @@ def _backup_file(file_path: Path, vault_name: str, relative_path: str):
backup_path = backup_dir / backup_name
shutil.copy2(file_path, backup_path)
logger.info(f"Backup created: {relative_path} -> {backup_path}")
# Auto-cleanup: keep only the N most recent backups
config = _load_config()
max_backups = config.get("max_backups_per_file", 10)
all_backups = sorted(
[f for f in backup_dir.iterdir() if f.is_file() and f.name.startswith(file_path.name + ".") and f.name.endswith(".bak")],
key=lambda f: f.stat().st_mtime, reverse=True
)
for old in all_backups[max_backups:]:
old.unlink()
logger.debug(f"Auto-cleanup: removed old backup {old.name}")
except Exception as e:
logger.warning(f"Failed to backup {relative_path} (vault={vault_name}): {e}", exc_info=True)
@@ -3258,6 +3269,132 @@ async def api_get_all_vault_settings(current_user=Depends(require_auth)):
return all_settings
# ---------------------------------------------------------------------------
# Backup Management API
# ---------------------------------------------------------------------------
@app.get("/api/backups")
async def api_backups_list(
vault: Optional[str] = Query(None, description="Filter by vault name"),
current_user=Depends(require_auth),
):
"""List all backups across vaults, grouped by file."""
result = []
for vault_name in index.keys():
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
stat = fpath.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": stat,
"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}
@app.post("/api/backups/delete")
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}
@app.post("/api/backups/purge")
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}
# ---------------------------------------------------------------------------
# Configuration API
# ---------------------------------------------------------------------------
@@ -3283,6 +3420,7 @@ _DEFAULT_CONFIG = {
"tag_boost": 2.0,
"prefix_max_expansions": 50,
"recent_files_limit": 20,
"max_backups_per_file": 10,
}