refactor: #85 T4 extrait le domaine backups vers backend/routers + sse partage (comportement inchange)

This commit is contained in:
2026-09-26 13:06:45 -04:00
parent 83a81da319
commit 750114a923
13 changed files with 527 additions and 451 deletions
+11 -1
View File
@@ -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`
+3 -3
View File
@@ -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*
+3 -3
View File
@@ -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*
+1 -1
View File
@@ -1 +1 @@
2.27.4
2.27.5
+10 -436
View File
@@ -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
+412
View File
@@ -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}
+26
View File
@@ -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
# ---------------------------------------------------------------------------
+54
View File
@@ -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()
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.27.4"
version = "2.27.5"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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",
+3 -3
View File
@@ -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)
+1 -1
View File
@@ -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": {