From 750114a923c692df941aa580ee9e7dab7e655e88 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sat, 26 Sep 2026 13:06:41 -0400 Subject: [PATCH] refactor: #85 T4 extrait le domaine backups vers backend/routers + sse partage (comportement inchange) --- CHANGELOG.md | 12 +- README.fr.md | 6 +- README.md | 6 +- VERSION | 2 +- backend/main.py | 446 +------------------------------------ backend/routers/backups.py | 412 ++++++++++++++++++++++++++++++++++ backend/schemas.py | 26 +++ backend/sse.py | 54 +++++ desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- docs/ROADMAP.md | 6 +- package.json | 2 +- 13 files changed, 527 insertions(+), 451 deletions(-) create mode 100644 backend/routers/backups.py create mode 100644 backend/sse.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e5a56a0..6647d4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > **En cours de développement** : les changements à venir sont listés dans la section -> [Unreleased](#unreleased). La dernière version livrée est **2.27.4**. +> [Unreleased](#unreleased). La dernière version livrée est **2.27.5**. --- @@ -14,6 +14,10 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.27.5] — 2026-09-26 + +--- + ## [2.27.4] — 2026-09-26 --- @@ -26,6 +30,12 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Modifié +- **#85 (T4) — extraction du domaine `backups` hors du monolithe `backend/main.py`.** + Les 9 routes (`/api/file/{vault}/backups|diff|restore`, `/api/backups`, + `/delete`, `/purge`, `/content`, `/compress`, `/auto`) sont servies par le + nouveau `backend/routers/backups.py` ; `Diff/Restore*` déménagent dans + `schemas.py` et le singleton SSE dans `backend/sse.py` (partagé avec + `main`) — comportement inchangé, aucun impact utilisateur. - **#85 (T3) — extraction du domaine `sharing` hors du monolithe `backend/main.py`.** `POST /api/share/{vault}`, `GET /api/shares`, `DELETE /api/share/{share_id}` et les pages publiques `/s/{token}`, `/s/{token}/raw`, `/s/{token}/pdf` diff --git a/README.fr.md b/README.fr.md index 05fe0cc..3dbc512 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[![Version](https://img.shields.io/badge/Version-2.27.4-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.27.5-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l ## 📝 Changelog -Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.27.4). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.27.5). --- -*Projet : ObsiGate | Version : 2.27.4 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.27.5 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index b415cf6..1483eaa 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[![Version](https://img.shields.io/badge/Version-2.27.4-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.27.5-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## 📝 Changelog -See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.27.4). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.27.5). --- -*Project: ObsiGate | Version: 2.27.4 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.27.5 | Last updated: September 2026* diff --git a/VERSION b/VERSION index 857f77a..474d182 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.27.4 +2.27.5 diff --git a/backend/main.py b/backend/main.py index 71a1a9b..4298c6a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -7,11 +7,9 @@ import re import secrets import shutil import string -import time import urllib.request from concurrent.futures import ThreadPoolExecutor from contextlib import asynccontextmanager -from datetime import datetime, timezone from functools import partial from pathlib import Path from typing import Any @@ -70,12 +68,6 @@ from backend.schemas import ( AttachmentRescanResponse, AttachmentStatsResponse, BacklinksResponse, - BackupContentResponse, - BackupsAutoResponse, - BackupsCompressResponse, - BackupsDeletedResponse, - BackupsListResponse, - BackupsResponse, BookmarksResponse, BookmarkToggleResponse, ConflictResolveResponse, @@ -99,9 +91,7 @@ from backend.search import ( suggest_titles, ) from backend.semantic_search import init_semantic_index -from backend.services.backups import diff_backup as service_diff_backup from backend.services.backups import get_backup_dir as service_get_backup_dir -from backend.services.backups import list_backup_files as service_list_backup_files from backend.services.errors import ServiceError from backend.services.files import read_raw_file from backend.services.graph import get_graph as service_get_graph @@ -138,9 +128,6 @@ from backend.services.mutations import ( from backend.services.mutations import ( replace_in_files as service_replace_in_files, ) -from backend.services.mutations import ( - restore_backup as service_restore_backup, -) from backend.services.recent import humanize_mtime, list_recent from backend.services.sanitizer import sanitize_html from backend.services.search import advanced_search_vaults, list_paths, search_paths, search_vaults @@ -511,77 +498,16 @@ class DiffRequest(BaseModel): compare_with: int | None = 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: int | None = 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: int | None = 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 +# SSE Manager — voir backend.sse (ROADMAP #85 T4, instance partagée) # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- -class SSEManager: - """Manages SSE client connections and broadcasts events.""" - - def __init__(self): - self._clients: list[asyncio.Queue] = [] - - async def connect(self) -> asyncio.Queue: - """Register a new SSE client and return its message queue.""" - queue: asyncio.Queue = asyncio.Queue() - self._clients.append(queue) - logger.debug(f"SSE client connected (total: {len(self._clients)})") - return queue - - def disconnect(self, queue: asyncio.Queue): - """Remove a disconnected SSE client.""" - if queue in self._clients: - self._clients.remove(queue) - logger.debug(f"SSE client disconnected (total: {len(self._clients)})") - - async def broadcast(self, event_type: str, data: dict): - """Send an event to all connected SSE clients.""" - message = _json.dumps(data, ensure_ascii=False) - dead: list[asyncio.Queue] = [] - for q in self._clients: - try: - q.put_nowait({"event": event_type, "data": message}) - except asyncio.QueueFull: - dead.append(q) - for q in dead: - self.disconnect(q) - - @property - def client_count(self) -> int: - return len(self._clients) - - -sse_manager = SSEManager() - +from backend.sse import sse_manager # --------------------------------------------------------------------------- # Application lifespan (replaces deprecated on_event) # --------------------------------------------------------------------------- - from backend.watcher import VaultWatcher # Thread pool for offloading CPU-bound search from the event loop. @@ -944,6 +870,7 @@ except Exception: # pragma: no cover - WeasyPrint/GTK missing from backend.ai_routes import router as ai_router from backend.bookslm_routes import router as bookslm_router from backend.export import ExportError, export_epub, export_html, export_md_bundle +from backend.routers.backups import router as backups_router from backend.routers.health import router as health_router from backend.routers.sharing import router as sharing_router from backend.routers.webhooks import router as webhooks_router @@ -957,6 +884,7 @@ app.include_router(ai_router) app.include_router(bookslm_router) app.include_router(skills_router) app.include_router(health_router) # ROADMAP #85 T1 — System / health +app.include_router(backups_router) # ROADMAP #85 T4 — Backups app.include_router(webhooks_router) # ROADMAP #85 T2 — Webhooks app.include_router(sharing_router) # ROADMAP #85 T3 — Sharing @@ -2126,123 +2054,9 @@ def _get_backup_dir(vault_name: str, relative_path: str) -> Path: return service_get_backup_dir(vault_name, relative_path) -def _list_backup_files(vault_name: str, relative_path: str) -> list[dict]: - """List all backup files for a given vault file, sorted newest first. - - Thin wrapper around :func:`backend.services.backups.list_backup_files` - (single implementation used by both routes and tools). - """ - return service_list_backup_files(vault_name, relative_path) - - -@app.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 = _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} - - -@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: 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) - - -@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}'") - - 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, - } - +# --------------------------------------------------------------------------- +# File-level backup endpoints : voir backend.routers.backups (#85 T4) +# --------------------------------------------------------------------------- @app.get("/api/file/{vault_name}/backlinks", response_model=BacklinksResponse) async def api_file_backlinks( @@ -3483,249 +3297,9 @@ async def api_get_all_vault_settings(current_user=Depends(require_auth)): # Backup Management API # --------------------------------------------------------------------------- -@app.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}") - - -@app.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} - - -@app.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} - - - -@app.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)) - - -@app.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} - - -@app.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("\\", "/") - _backup_file(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} - +# --------------------------------------------------------------------------- +# Global backup endpoints : voir backend.routers.backups (#85 T4) +# --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Configuration API diff --git a/backend/routers/backups.py b/backend/routers/backups.py new file mode 100644 index 0000000..d2e4cd8 --- /dev/null +++ b/backend/routers/backups.py @@ -0,0 +1,412 @@ +"""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} diff --git a/backend/schemas.py b/backend/schemas.py index 0961381..fce599d 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -188,6 +188,32 @@ class BackupsAutoResponse(BaseModel): since_hours: int | float = Field(description="Look-back window in hours") +class DiffResponse(BaseModel): + """Response containing a unified diff between two file versions (#85 — extrait de backend.main, inchangé).""" + + 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: int | None = 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 (#85 — extrait de backend.main, inchangé).""" + + version: int = Field(description="Timestamp of the backup version to restore") + + +class RestoreResponse(BaseModel): + """Response after restoring a file from backup (#85 — extrait de backend.main, inchangé).""" + + 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: int | None = Field(default=None, description="Timestamp of the backup created from the current version before restore, if any") + + # --------------------------------------------------------------------------- # PDF # --------------------------------------------------------------------------- diff --git a/backend/sse.py b/backend/sse.py new file mode 100644 index 0000000..3cf485e --- /dev/null +++ b/backend/sse.py @@ -0,0 +1,54 @@ +"""Server-Sent Events manager (ROADMAP #85, tranche 4). + +Singleton extrait de :mod:`backend.main` sans changement de comportement : +les routers montés par ``main`` partagent la même instance (les clients SSE +connectés sur ``/api/events`` reçoivent les broadcasts émis depuis +n'importe quel router). +""" + +from __future__ import annotations + +import asyncio +import json as _json +import logging + +logger = logging.getLogger("obsigate") + + +class SSEManager: + """Manages SSE client connections and broadcasts events.""" + + def __init__(self): + self._clients: list[asyncio.Queue] = [] + + async def connect(self) -> asyncio.Queue: + """Register a new SSE client and return its message queue.""" + queue: asyncio.Queue = asyncio.Queue() + self._clients.append(queue) + logger.debug(f"SSE client connected (total: {len(self._clients)})") + return queue + + def disconnect(self, queue: asyncio.Queue): + """Remove a disconnected SSE client.""" + if queue in self._clients: + self._clients.remove(queue) + logger.debug(f"SSE client disconnected (total: {len(self._clients)})") + + async def broadcast(self, event_type: str, data: dict): + """Send an event to all connected SSE clients.""" + message = _json.dumps(data, ensure_ascii=False) + dead: list[asyncio.Queue] = [] + for q in self._clients: + try: + q.put_nowait({"event": event_type, "data": message}) + except asyncio.QueueFull: + dead.append(q) + for q in dead: + self.disconnect(q) + + @property + def client_count(self) -> int: + return len(self._clients) + + +sse_manager = SSEManager() diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 6e7b229..996a4f6 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.27.4" +version = "2.27.5" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index cd83bde..c7e0c41 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.27.4" +version = "2.27.5" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index 6c015e0..175c17e 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.27.4", + "version": "2.27.5", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 00f6dce..813beed 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.27.4 | **Dernière mise à jour :** 2026-09-26 +> **Version :** 2.27.5 | **Dernière mise à jour :** 2026-09-26 > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > vers les fonctionnalités livrées. > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** @@ -67,10 +67,10 @@ - **Effort :** 8-12 jours | **Impact :** 🟡 | **Zone :** backend - **Décision 2026-09-26 : prioritaire (axe Dette & sécurité).** -- **Statut :** 🔵 en cours depuis 2026-09-26 — découpe par tranches à impact minimal (comportement inchangé, un domaine par commit). **T1 livrée (v2.27.2) :** `health` (`/api/health`, `/api/health/detailed` → `backend/routers/health.py`, `HealthResponse` → `schemas.py`). **T2 livrée (v2.27.3) :** `webhooks` (CRUD `/api/webhooks` → `backend/routers/webhooks.py`, logique déjà dans `backend/webhooks.py`). **T3 livrée (v2.27.4) :** `sharing` (`/api/share/*`, `/api/shares`, `/s/{token}*` → `backend/routers/sharing.py`, logique déjà dans `backend/share.py`). +- **Statut :** 🔵 en cours depuis 2026-09-26 — découpe par tranches à impact minimal (comportement inchangé, un domaine par commit). **T1 livrée (v2.27.2) :** `health` (`/api/health`, `/api/health/detailed` → `backend/routers/health.py`, `HealthResponse` → `schemas.py`). **T2 livrée (v2.27.3) :** `webhooks` (CRUD `/api/webhooks` → `backend/routers/webhooks.py`, logique déjà dans `backend/webhooks.py`). **T3 livrée (v2.27.4) :** `sharing` (`/api/share/*`, `/api/shares`, `/s/{token}*` → `backend/routers/sharing.py`, logique déjà dans `backend/share.py`). **T4 livrée (v2.27.5) :** `backups` (9 routes `/api/file/{vault}/backups|diff|restore` + `/api/backups*` → `backend/routers/backups.py`, `Diff/Restore*` → `schemas.py`, singleton SSE → `backend/sse.py`). - **Description :** extraire le monolithe `backend/main.py` (~4 827 lignes au 2026-09-26, ~17 % du backend) en routers FastAPI par domaine et rendre persistant l'état qui ne l'est pas (index de recherche, JTI révoqués, compteurs de rate-limit) pour préparer le multi-nœuds. L'état mémoire actuel (index, inverted index, vecteurs sémantiques, `SSEManager`, collab) rend le multi-workers unsafe. - **Sous-tâches :** - - [ ] Routers par domaine : files, search, share, webhooks, plugins, collab, admin, ai — `main.py` conservé comme assemblage (< 500 lignes) ; dédupliquer les modèles Pydantic vers `schemas.py`. **Avancement :** `health` ✅ (T1, `backend/routers/health.py`), `webhooks` ✅ (T2, `backend/routers/webhooks.py`), `sharing` ✅ (T3, `backend/routers/sharing.py`) ; `tools/registry.py` existe déjà (permissions/quotas/redaction — à compléter, pas à créer) + - [ ] Routers par domaine : files, search, share, webhooks, plugins, collab, admin, ai — `main.py` conservé comme assemblage (< 500 lignes) ; dédupliquer les modèles Pydantic vers `schemas.py`. **Avancement :** `health` ✅ (T1, `backend/routers/health.py`), `webhooks` ✅ (T2, `backend/routers/webhooks.py`), `sharing` ✅ (T3, `backend/routers/sharing.py`), `backups` ✅ (T4, `backend/routers/backups.py` + `backend/sse.py`) ; `tools/registry.py` existe déjà (permissions/quotas/redaction — à compléter, pas à créer) - [ ] Compléter `tools/registry.py` (existant : permissions/quotas/redaction) comme contrat central des outils IA si des manques sont constatés - [ ] Persister index, JTI révoqués et compteurs de rate-limit (SQLite par défaut, Redis en option multi-nœuds ; le rate-limit actuel est in-memory mono-process) - [ ] Verrous asyncio autour de l'index global et des stores JSON ; auditer les `except Exception` larges (> 100 occurrences) : best-effort (backup/audit) vs masquage d'erreur (erreurs typées 4xx/5xx + test) diff --git a/package.json b/package.json index 7da9aba..23210b0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.27.4", + "version": "2.27.5", "description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.", "main": "patch.js", "directories": {