diff --git a/ROADMAP.md b/ROADMAP.md index cdf531c..c04a83e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -126,16 +126,16 @@ - [x] **Protection** : pas de drop sur soi-même, pas de drop dans un enfant, pas de cross-vault - [x] **SSE + index** : mise à jour de l'index et broadcast SSE après move -### 6. Diff viewer backups +### ✅ 6. Diff viewer backups — FAIT - **Effort** : 1 jour | **Impact** : 🟢 -- Comparer versions sauvegardées dans `.obsigate-backup/` -- **Pourquoi** : ObsiGate fait un backup automatique avant chaque écriture de fichier (mécanisme déjà en place dans `secret_redactor.py` et les endpoints d'édition). Ces snapshots horodatés s'accumulent dans `.obsigate-backup/` mais ne sont pas exploitables depuis l'UI. -- **Ce que ça met en place** : - - Un endpoint `GET /api/file/{vault_name}/backups` qui liste les backups disponibles pour un fichier (avec horodatage) - - Un endpoint `GET /api/file/{vault_name}/diff` qui prend deux versions (ou une version + l'actuelle) et retourne un diff unifié (ligne par ligne) - - Une UI de comparaison dans le panneau de droite : sélecteur de version (dropdown avec les timestamps), affichage du diff côte à côte ou unifié avec coloration syntaxique (lignes ajoutées en vert, supprimées en rouge) - - Un bouton "Restaurer" pour revenir à une version précédente - - Accessible depuis le menu contextuel d'un fichier → "View backups" +- Comparer versions sauvegardées dans `.obsigate-backup/` ✅ +- **Fichiers** : `backend/main.py` (3 endpoints + helpers), `frontend/js/backups.js`, `frontend/js/ui.js`, `frontend/style.css` +- [x] **`GET /api/file/{vault}/backups`** — liste tous les backups horodatés d'un fichier (triés plus récent d'abord) +- [x] **`GET /api/file/{vault}/diff`** — diff unifié entre deux versions (backup vs backup, ou backup vs actuel) +- [x] **`POST /api/file/{vault}/restore`** — restaure une version backup (backup automatique de la version actuelle avant restauration) +- [x] **UI modale** : panneau avec sélecteur gauche (backup) / droite (version actuelle ou autre backup), diff coloré (vert/rouge), bouton Restaurer avec confirmation +- [x] **Menu contextuel** : clic droit sur un fichier → "Backups / Versions" +- [x] **Coloration syntaxique diff** : lignes ajoutées en vert, supprimées en rouge, en-têtes hunk en accent ### 7. Fichiers récents par vault - **Effort** : 0.5 jour | **Impact** : 🟡 diff --git a/backend/main.py b/backend/main.py index 78cbae5..fa85e85 100644 --- a/backend/main.py +++ b/backend/main.py @@ -333,6 +333,50 @@ class FileMoveResponse(BaseModel): item_type: str = Field(description="Type of item moved: 'file' or 'directory'") +class BackupEntry(BaseModel): + """A single backup version of a file.""" + timestamp: int = Field(description="Unix timestamp of when the backup was created") + datetime: str = Field(description="ISO 8601 datetime string") + size: int = Field(description="File size in bytes") + filename: str = Field(description="Backup filename on disk") + + +class BackupListResponse(BaseModel): + """Response listing all available backups for a file.""" + vault: str = Field(description="Vault name") + path: str = Field(description="Relative file path") + backups: list[BackupEntry] = Field(description="Available backups, newest first") + + +class DiffRequest(BaseModel): + """Request parameters for generating a diff.""" + version: int = Field(description="Timestamp of the backup version to compare") + compare_with: Optional[int] = Field(default=None, description="Timestamp of another backup version. If omitted, compares with the current file.") + + +class DiffResponse(BaseModel): + """Response containing a unified diff between two file versions.""" + vault: str = Field(description="Vault name") + path: str = Field(description="Relative file path") + version: int = Field(description="Backup version timestamp (left/old side)") + compare_with: Optional[int] = Field(default=None, description="Other backup version or null for current file (right/new side)") + diff: str = Field(description="Unified diff (empty if no changes)") + + +class RestoreRequest(BaseModel): + """Request to restore a file from a backup.""" + version: int = Field(description="Timestamp of the backup version to restore") + + +class RestoreResponse(BaseModel): + """Response after restoring a file from backup.""" + success: bool = Field(description="Whether restore succeeded") + vault: str = Field(description="Vault name") + path: str = Field(description="Relative file path") + restored_from: int = Field(description="Timestamp of the backup used") + current_backed_up: Optional[int] = Field(default=None, description="Timestamp of the backup created from the current version before restore, if any") + + # --------------------------------------------------------------------------- # SSE Manager — Server-Sent Events for real-time notifications # --------------------------------------------------------------------------- @@ -1875,6 +1919,249 @@ async def api_file_move( raise HTTPException(status_code=500, detail=f"Error moving item: {str(e)}") +# --------------------------------------------------------------------------- +# Backup & Diff endpoints +# --------------------------------------------------------------------------- + +def _get_backup_root() -> Path: + """Return the backup root directory as configured.""" + return Path(os.environ.get("OBSIGATE_BACKUP_DIR", ".obsigate-backup")) + + +def _get_backup_dir(vault_name: str, relative_path: str) -> Path: + """Return the directory where backups for a specific file are stored.""" + backup_root = _get_backup_root() + return backup_root / vault_name / Path(relative_path).parent + + +def _list_backup_files(vault_name: str, relative_path: str) -> list[dict]: + """List all backup files for a given vault file, sorted newest first. + + Backup filename format: {original_filename}.{timestamp}.bak + + Returns a list of dicts with timestamp, datetime, size, filename. + """ + backup_dir = _get_backup_dir(vault_name, relative_path) + if not backup_dir.exists(): + return [] + + original_name = Path(relative_path).name + prefix = original_name + "." + backups = [] + + for f in backup_dir.iterdir(): + if not f.is_file(): + continue + name = f.name + if not name.startswith(prefix) or not name.endswith(".bak"): + continue + # Extract timestamp: strip prefix and .bak suffix + ts_part = name[len(prefix):-len(".bak")] + try: + ts = int(ts_part) + except (ValueError, TypeError): + continue + dt = datetime.fromtimestamp(ts, tz=timezone.utc).isoformat() + backups.append({ + "timestamp": ts, + "datetime": dt, + "size": f.stat().st_size, + "filename": name, + }) + + backups.sort(key=lambda b: b["timestamp"], reverse=True) + return backups + + +@app.get("/api/file/{vault_name}/backups", response_model=BackupListResponse) +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}") + + backups = _list_backup_files(vault_name, path) + + return {"vault": vault_name, "path": path, "backups": backups} + + +@app.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: Optional[int] = 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}'") + + 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}") + + original_name = Path(path).name + + def _read_backup(ts: int) -> tuple[str, str]: + """Read a backup file. Returns (content, label).""" + backup_dir = _get_backup_dir(vault_name, path) + backup_path = backup_dir / f"{original_name}.{ts}.bak" + if not backup_path.exists(): + raise HTTPException(status_code=404, detail=f"Backup version {ts} not found for {path}") + content = backup_path.read_text(encoding="utf-8") + dt = datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + return content, f"{path}@{dt}" + + # Read left side (backup version) + left_content, left_label = _read_backup(version) + + # Read right side + if compare_with is not None: + right_content, right_label = _read_backup(compare_with) + else: + right_content = file_path.read_text(encoding="utf-8") + right_label = f"{path} (current)" + + # Generate unified diff using difflib + import difflib + left_lines = left_content.splitlines(keepends=True) + right_lines = right_content.splitlines(keepends=True) + + diff_lines = list(difflib.unified_diff( + left_lines, right_lines, + fromfile=left_label, tofile=right_label, + )) + + return { + "vault": vault_name, + "path": path, + "version": version, + "compare_with": compare_with, + "diff": "".join(diff_lines), + } + + +@app.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}'") + + 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"]) + + # Check if vault is writable + if not _check_vault_writable(vault_root): + raise HTTPException(status_code=403, detail="Vault is read-only") + + file_path = _resolve_safe_path(vault_root, path) + original_name = Path(path).name + + backup_dir = _get_backup_dir(vault_name, path) + backup_path = backup_dir / f"{original_name}.{body.version}.bak" + + if not backup_path.exists(): + raise HTTPException(status_code=404, detail=f"Backup version {body.version} not found for {path}") + + try: + # Backup the current file before restoring + current_backed_up = None + if file_path.exists() and file_path.is_file(): + _backup_file(file_path, vault_name, path) + current_backed_up = int(time.time()) + + # Read backup content + backup_content = backup_path.read_text(encoding="utf-8") + + # Write restored content + file_path.write_text(backup_content, encoding="utf-8") + + logger.info(f"File restored from backup: {vault_name}/{path} <- version {body.version}") + + # 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, + } + except PermissionError: + raise HTTPException(status_code=403, detail="Permission denied: cannot restore file") + except Exception as e: + logger.error(f"Error restoring file {vault_name}/{path}: {e}") + raise HTTPException(status_code=500, detail=f"Error restoring file: {str(e)}") + + @app.get("/api/file/{vault_name}/backlinks") async def api_file_backlinks( vault_name: str, diff --git a/frontend/js/backups.js b/frontend/js/backups.js new file mode 100644 index 0000000..5d423ac --- /dev/null +++ b/frontend/js/backups.js @@ -0,0 +1,380 @@ +/** + * Backup & Diff viewer module for ObsiGate. + * + * Provides a modal UI to browse file backups, compare versions with + * a unified diff view, and restore previous versions. + * + * Usage: + * import { openBackupViewer } from './backups.js'; + * openBackupViewer(vaultName, filePath); + */ + +import { api } from './auth.js'; +import { showToast } from './ui.js'; +import { refreshSidebarTreePreservingState } from './sidebar.js'; +import { safeCreateIcons } from './utils.js'; + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +const state = { + vault: null, + path: null, + backups: [], + overlay: null, + leftVersion: null, // timestamp of backup shown on left + rightVersion: null, // timestamp of backup shown on right, or null for current + diffText: '', +}; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Open the backup/diff viewer for a file. + * @param {string} vault - Vault name + * @param {string} path - Relative file path + */ +export async function openBackupViewer(vault, path) { + state.vault = vault; + state.path = path; + state.backups = []; + state.leftVersion = null; + state.rightVersion = null; + state.diffText = ''; + + // Create and show overlay + const overlay = createOverlay(); + document.body.appendChild(overlay); + state.overlay = overlay; + requestAnimationFrame(() => overlay.classList.add('active')); + + // Show loading state + setBody(renderLoading()); + + // Fetch backups + try { + const data = await api(`/api/file/${encodeURIComponent(vault)}/backups?path=${encodeURIComponent(path)}`); + state.backups = data.backups || []; + } catch (err) { + setBody(renderError('Failed to load backups')); + return; + } + + if (state.backups.length === 0) { + setBody(renderEmpty()); + return; + } + + // Auto-select: left = most recent, right = current + state.leftVersion = state.backups[0].timestamp; + state.rightVersion = null; + + // Render version selectors + renderMainView(); + + // Load initial diff + await loadDiff(); +} + +// --------------------------------------------------------------------------- +// DOM helpers +// --------------------------------------------------------------------------- + +function createOverlay() { + const overlay = document.createElement('div'); + overlay.className = 'obsigate-modal-overlay backup-viewer-overlay'; + overlay.addEventListener('click', (e) => { + if (e.target === overlay) close(); + }); + + const panel = document.createElement('div'); + panel.className = 'backup-viewer-panel'; + panel.id = 'backup-viewer-panel'; + overlay.appendChild(panel); + + return overlay; +} + +function setBody(html) { + const panel = document.getElementById('backup-viewer-panel'); + if (panel) panel.innerHTML = html; +} + +function close() { + if (state.overlay) { + state.overlay.classList.remove('active'); + setTimeout(() => { + if (state.overlay) state.overlay.remove(); + state.overlay = null; + }, 200); + } +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +function renderLoading() { + return ` +
Chargement des backups...
+${escapeHtml(msg)}
+Aucun backup disponible pour ce fichier.
+Les backups sont créés automatiquement avant chaque modification.
+| ${prefix} | ${escapeHtml(line)} |