From 83a81da319111305965cecb29de0620101240d9f Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sat, 26 Sep 2026 12:51:04 -0400 Subject: [PATCH] refactor: #85 T3 extrait le domaine sharing vers backend/routers (comportement inchange) --- CHANGELOG.md | 12 +- README.fr.md | 6 +- README.md | 6 +- VERSION | 2 +- backend/main.py | 257 +------------------------------ backend/routers/sharing.py | 300 +++++++++++++++++++++++++++++++++++++ desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- docs/ROADMAP.md | 6 +- package.json | 2 +- 11 files changed, 331 insertions(+), 266 deletions(-) create mode 100644 backend/routers/sharing.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d279a4..e5a56a0 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.3**. +> [Unreleased](#unreleased). La dernière version livrée est **2.27.4**. --- @@ -14,6 +14,10 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.27.4] — 2026-09-26 + +--- + ## [2.27.3] — 2026-09-26 --- @@ -22,6 +26,12 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Modifié +- **#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` + sont servis par le nouveau `backend/routers/sharing.py` — chemins, + réponses, tags OpenAPI et authentification inchangés (aucun impact + utilisateur). - **#85 (T2) — extraction du domaine `webhooks` hors du monolithe `backend/main.py`.** Le CRUD `GET/POST/PATCH/DELETE /api/webhooks` (admin) est servi par le nouveau `backend/routers/webhooks.py` — chemins, réponses, tags OpenAPI et diff --git a/README.fr.md b/README.fr.md index b7a17fe..05fe0cc 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.3-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.27.4-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.3). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.27.4). --- -*Projet : ObsiGate | Version : 2.27.3 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.27.4 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index 2584bef..b415cf6 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.3-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.27.4-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.3). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.27.4). --- -*Project: ObsiGate | Version: 2.27.3 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.27.4 | Last updated: September 2026* diff --git a/VERSION b/VERSION index 2adce90..857f77a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.27.3 +2.27.4 diff --git a/backend/main.py b/backend/main.py index 2d47192..71a1a9b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -86,7 +86,6 @@ from backend.schemas import ( RecentResponse, ReplaceResponse, SavedSearch, - ShareModel, StatusResponse, VaultActionResponse, VaultFilesResponse, @@ -946,16 +945,10 @@ 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.health import router as health_router +from backend.routers.sharing import router as sharing_router from backend.routers.webhooks import router as webhooks_router from backend.saved_searches import delete_saved, get_saved, save_search -from backend.share import ( - create_share, - get_share_by_token, - list_shares, - record_access, - revoke_share, - update_shares_after_rename, -) +from backend.share import update_shares_after_rename from backend.skills_routes import router as skills_router from backend.webhooks import dispatch_webhooks @@ -965,6 +958,7 @@ app.include_router(bookslm_router) app.include_router(skills_router) app.include_router(health_router) # ROADMAP #85 T1 — System / health app.include_router(webhooks_router) # ROADMAP #85 T2 — Webhooks +app.include_router(sharing_router) # ROADMAP #85 T3 — Sharing # Admin Dashboard endpoints (system stats, audit logs, backups, stream) try: @@ -4256,251 +4250,12 @@ async def api_dashboard(current_user=Depends(require_auth)): # --------------------------------------------------------------------------- -# Share (public document) endpoints +# Share (public document) endpoints : voir backend.routers.sharing (#85 T3) # --------------------------------------------------------------------------- -@app.post("/api/share/{vault_name}", response_model=ShareModel) -async def api_share_create( - vault_name: str, - body: dict = Body(...), - current_user=Depends(require_auth), -): - """Create a public share link for a document. - - Also sets ``publish: true`` in the file's YAML frontmatter so the - frontend can visually indicate the file is publicly shared. - """ - if not check_vault_access(vault_name, current_user): - raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'") - path = body.get("path", "") - expires = body.get("expires_in_hours") - share = create_share(vault_name, path, current_user["username"], expires) - share["url"] = f"/s/{share['token']}" - - # Set publish: true in the file's frontmatter - vault_data = get_vault_data(vault_name) - if vault_data: - file_path = _resolve_safe_path(Path(vault_data["path"]), path) - if file_path.exists() and file_path.suffix == ".md": - try: - raw = file_path.read_text(encoding="utf-8", errors="replace") - post = frontmatter.loads(raw) - if not post.metadata.get("publish"): - post.metadata["publish"] = True - new_raw = frontmatter.dumps(post) - _backup_file(file_path, vault_name, path) - file_path.write_text(new_raw, encoding="utf-8") - await update_single_file(vault_name, str(file_path)) - logger.info(f"Set publish:true on {vault_name}/{path}") - except Exception as e: - logger.warning(f"Failed to set publish metadata on {vault_name}/{path}: {e}") - - return share - - -@app.get("/api/shares", response_model=list[ShareModel]) -async def api_shares_list(vault: str | None = Query(None), current_user=Depends(require_auth)): - """List all shares (optionally filtered by vault).""" - shares = list_shares(vault) - for s in shares: - s["url"] = f"/s/{s['token']}" - return shares - - -@app.delete("/api/share/{share_id}", response_model=StatusResponse) -async def api_share_revoke(share_id: str, current_user=Depends(require_auth)): - if not revoke_share(share_id): - raise HTTPException(404, "Share not found") - return {"status": "revoked"} - -@app.get( - "/s/{token}/pdf", - response_class=Response, - responses={200: {"content": {"application/pdf": {}}, "description": "Shared document as PDF"}}, -) -async def public_share_pdf_download(token: str): - """Download shared document as real PDF via WeasyPrint.""" - if generate_pdf is None: - raise HTTPException(501, "PDF export unavailable (WeasyPrint/GTK not available)") - share = get_share_by_token(token) - if not share: - raise HTTPException(404, "Share not found or expired") - vault_data = get_vault_data(share["vault"]) - if not vault_data: - raise HTTPException(404, "Vault not found") - vault_root = Path(vault_data["path"]) - file_path = _resolve_safe_path(vault_root, share["path"]) - if not file_path.exists(): - raise HTTPException(404, "File not found") - try: - raw = file_path.read_text(encoding="utf-8", errors="replace") - except Exception: - raise HTTPException(500, "Cannot read file") - record_access(token) - raw = redact_file_content(raw, str(file_path)) - post = parse_markdown_file(raw) - ext = file_path.suffix.lower() - if ext == ".md": - html = _render_markdown(post.content, share["vault"], file_path) - else: - html = f'
{html_mod.escape(raw)}
' - title = post.metadata.get("title", file_path.stem) - pdf_html = build_pdf_html(html, str(title)) - pdf_bytes = generate_pdf(pdf_html, str(title)) - safe_name = "".join(c for c in str(title) if c.isascii() and (c.isalnum() or c in " _-.")).strip() or "document" - return Response(content=pdf_bytes, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{safe_name}.pdf"'}) - - -@app.get("/s/{token}/raw", response_class=FileResponse) -async def public_share_raw(token: str): - """Download the raw (original) shared document.""" - share = get_share_by_token(token) - if not share: - raise HTTPException(404, "Share not found or expired") - vault_data = get_vault_data(share["vault"]) - if not vault_data: - raise HTTPException(404, "Vault not found") - vault_root = Path(vault_data["path"]) - file_path = _resolve_safe_path(vault_root, share["path"]) - if not file_path.exists(): - raise HTTPException(404, "File not found") - record_access(token) - return FileResponse(path=str(file_path), filename=file_path.name, media_type="application/octet-stream") - - @app.get("/s/{token}", response_class=HTMLResponse) -async def public_share_view(token: str): - """Public share view — no authentication required.""" - share = get_share_by_token(token) - if not share: - raise HTTPException(404, "Share not found or expired") - vault_data = get_vault_data(share["vault"]) - if not vault_data: - raise HTTPException(404, "Vault not found") - vault_root = Path(vault_data["path"]) - file_path = _resolve_safe_path(vault_root, share["path"]) - if not file_path.exists(): - raise HTTPException(404, "File not found") - try: - raw = file_path.read_text(encoding="utf-8", errors="replace") - except Exception: - raise HTTPException(500, "Cannot read file") - record_access(token) - raw = redact_file_content(raw, str(file_path)) - post = parse_markdown_file(raw) - ext = file_path.suffix.lower() - - if ext == ".md": - html = _render_markdown(post.content, share["vault"], file_path) - else: - escaped = html_mod.escape(raw) - html = f'
{escaped}
' - - title = post.metadata.get("title", file_path.stem) - - # Escape everything user-controlled before embedding in HTML/JS (BUG-022). - title_esc = html_mod.escape(str(title)) - # Neutralise ```` in the JS string literal too. - title_download_js = ( - _json.dumps(f"{title}.md") - .replace("<", "\\u003c") - .replace(">", "\\u003e") - .replace("&", "\\u0026") - ) - - # JSON-escape raw content for embedding in HTML, and neutralise ````. - raw_json = ( - _json.dumps(raw) - .replace("<", "\\u003c") - .replace(">", "\\u003e") - .replace("&", "\\u0026") - ) - fm_html = "" - if post.metadata: - fm_items = [] - skip_keys = {"title", "titre"} - for k, v in post.metadata.items(): - if k in skip_keys: - continue - if isinstance(v, list): - v = ", ".join(str(x) for x in v) - elif isinstance(v, bool): - v = "✓" if v else "✗" - elif v is None: - v = "—" - fm_items.append( - f'
{html_mod.escape(str(k))}' - f'{html_mod.escape(str(v))}
' - ) - if fm_items: - fm_html = f'
Frontmatter
{"".join(fm_items)}
' - - return HTMLResponse(f""" -{title_esc} — ObsiGate Share - - -
- - Document partagé via ObsiGate -
-
- {title_esc} - - - -
-
{fm_html}{html}
- -""") - - +# --------------------------------------------------------------------------- +# Syncthing conflict endpoints # --------------------------------------------------------------------------- # Syncthing conflict endpoints # --------------------------------------------------------------------------- diff --git a/backend/routers/sharing.py b/backend/routers/sharing.py new file mode 100644 index 0000000..90d12f0 --- /dev/null +++ b/backend/routers/sharing.py @@ -0,0 +1,300 @@ +"""Public share endpoints (ROADMAP #85, tranche 3). + +Handlers déplacés depuis :mod:`backend.main` sans changement de +comportement : mêmes chemins (``/api/share/*``, ``/api/shares``, +``/s/{token}*``), mêmes modèles de réponse, mêmes dépendances +d'authentification (les pages ``/s/*`` restent publiques). La logique +métier vit déjà dans :mod:`backend.share`. + +Adaptations strictement équivalentes (pas de changement de comportement) : +- ``_resolve_safe_path`` / ``_backup_file`` de ``main`` n'étaient que des + wrappers directs : appelés ici via :mod:`backend.services.paths` et + :mod:`backend.services.backups` (mêmes signatures, mêmes exceptions + ``ServiceError`` toujours mappées par le handler global de ``main``). +- ``_render_markdown`` reste défini dans ``main`` (extraction prévue dans + une tranche ultérieure) : import différé à l'intérieur des handlers, donc + sans import circulaire au chargement. +""" + +import html as html_mod +import json as _json +import logging +from pathlib import Path + +import frontmatter +from fastapi import APIRouter, Body, Depends, HTTPException, Query +from fastapi.responses import FileResponse, HTMLResponse, Response + +from backend.auth.middleware import check_vault_access, require_auth +from backend.indexer import get_vault_data, parse_markdown_file, update_single_file +from backend.schemas import ShareModel, StatusResponse +from backend.secret_redactor import redact_file_content +from backend.services.backups import create_backup +from backend.services.paths import resolve_safe_path +from backend.share import ( + create_share, + get_share_by_token, + list_shares, + record_access, + revoke_share, +) + +logger = logging.getLogger("obsigate") + +# Lazy import: WeasyPrint PDF export (requires GTK, may not be available everywhere) +try: + from backend.pdf_export import build_pdf_html, generate_pdf +except Exception: # pragma: no cover - WeasyPrint/GTK missing + generate_pdf = None # type: ignore[assignment] + build_pdf_html = None # type: ignore[assignment] + + logging.getLogger("obsigate").warning("PDF export unavailable (WeasyPrint/GTK not found)") + +router = APIRouter(tags=["sharing"]) + + +@router.post("/api/share/{vault_name}", response_model=ShareModel) +async def api_share_create( + vault_name: str, + body: dict = Body(...), + current_user=Depends(require_auth), +): + """Create a public share link for a document. + + Also sets ``publish: true`` in the file's YAML frontmatter so the + frontend can visually indicate the file is publicly shared. + """ + if not check_vault_access(vault_name, current_user): + raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'") + path = body.get("path", "") + expires = body.get("expires_in_hours") + share = create_share(vault_name, path, current_user["username"], expires) + share["url"] = f"/s/{share['token']}" + + # Set publish: true in the file's frontmatter + vault_data = get_vault_data(vault_name) + if vault_data: + file_path = resolve_safe_path(Path(vault_data["path"]), path) + if file_path.exists() and file_path.suffix == ".md": + try: + raw = file_path.read_text(encoding="utf-8", errors="replace") + post = frontmatter.loads(raw) + if not post.metadata.get("publish"): + post.metadata["publish"] = True + new_raw = frontmatter.dumps(post) + create_backup(file_path, vault_name, path) + file_path.write_text(new_raw, encoding="utf-8") + await update_single_file(vault_name, str(file_path)) + logger.info(f"Set publish:true on {vault_name}/{path}") + except Exception as e: + logger.warning(f"Failed to set publish metadata on {vault_name}/{path}: {e}") + + return share + + +@router.get("/api/shares", response_model=list[ShareModel]) +async def api_shares_list(vault: str | None = Query(None), current_user=Depends(require_auth)): + """List all shares (optionally filtered by vault).""" + shares = list_shares(vault) + for s in shares: + s["url"] = f"/s/{s['token']}" + return shares + + +@router.delete("/api/share/{share_id}", response_model=StatusResponse) +async def api_share_revoke(share_id: str, current_user=Depends(require_auth)): + if not revoke_share(share_id): + raise HTTPException(404, "Share not found") + return {"status": "revoked"} + + +@router.get( + "/s/{token}/pdf", + response_class=Response, + responses={200: {"content": {"application/pdf": {}}, "description": "Shared document as PDF"}}, +) +async def public_share_pdf_download(token: str): + """Download shared document as real PDF via WeasyPrint.""" + from backend.main import _render_markdown # différé : évite l'import circulaire (#85) + + if generate_pdf is None: + raise HTTPException(501, "PDF export unavailable (WeasyPrint/GTK not available)") + share = get_share_by_token(token) + if not share: + raise HTTPException(404, "Share not found or expired") + vault_data = get_vault_data(share["vault"]) + if not vault_data: + raise HTTPException(404, "Vault not found") + vault_root = Path(vault_data["path"]) + file_path = resolve_safe_path(vault_root, share["path"]) + if not file_path.exists(): + raise HTTPException(404, "File not found") + try: + raw = file_path.read_text(encoding="utf-8", errors="replace") + except Exception: + raise HTTPException(500, "Cannot read file") + record_access(token) + raw = redact_file_content(raw, str(file_path)) + post = parse_markdown_file(raw) + ext = file_path.suffix.lower() + if ext == ".md": + html = _render_markdown(post.content, share["vault"], file_path) + else: + html = f'
{html_mod.escape(raw)}
' + title = post.metadata.get("title", file_path.stem) + pdf_html = build_pdf_html(html, str(title)) + pdf_bytes = generate_pdf(pdf_html, str(title)) + safe_name = "".join(c for c in str(title) if c.isascii() and (c.isalnum() or c in " _-.")).strip() or "document" + return Response(content=pdf_bytes, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{safe_name}.pdf"'}) + + +@router.get("/s/{token}/raw", response_class=FileResponse) +async def public_share_raw(token: str): + """Download the raw (original) shared document.""" + share = get_share_by_token(token) + if not share: + raise HTTPException(404, "Share not found or expired") + vault_data = get_vault_data(share["vault"]) + if not vault_data: + raise HTTPException(404, "Vault not found") + vault_root = Path(vault_data["path"]) + file_path = resolve_safe_path(vault_root, share["path"]) + if not file_path.exists(): + raise HTTPException(404, "File not found") + record_access(token) + return FileResponse(path=str(file_path), filename=file_path.name, media_type="application/octet-stream") + + +@router.get("/s/{token}", response_class=HTMLResponse) +async def public_share_view(token: str): + """Public share view — no authentication required.""" + from backend.main import _render_markdown # différé : évite l'import circulaire (#85) + + share = get_share_by_token(token) + if not share: + raise HTTPException(404, "Share not found or expired") + vault_data = get_vault_data(share["vault"]) + if not vault_data: + raise HTTPException(404, "Vault not found") + vault_root = Path(vault_data["path"]) + file_path = resolve_safe_path(vault_root, share["path"]) + if not file_path.exists(): + raise HTTPException(404, "File not found") + try: + raw = file_path.read_text(encoding="utf-8", errors="replace") + except Exception: + raise HTTPException(500, "Cannot read file") + record_access(token) + raw = redact_file_content(raw, str(file_path)) + post = parse_markdown_file(raw) + ext = file_path.suffix.lower() + + if ext == ".md": + html = _render_markdown(post.content, share["vault"], file_path) + else: + escaped = html_mod.escape(raw) + html = f'
{escaped}
' + + title = post.metadata.get("title", file_path.stem) + + # Escape everything user-controlled before embedding in HTML/JS (BUG-022). + title_esc = html_mod.escape(str(title)) + # Neutralise ```` in the JS string literal too. + title_download_js = ( + _json.dumps(f"{title}.md") + .replace("<", "\\u003c") + .replace(">", "\\u003e") + .replace("&", "\\u0026") + ) + + # JSON-escape raw content for embedding in HTML, and neutralise ````. + raw_json = ( + _json.dumps(raw) + .replace("<", "\\u003c") + .replace(">", "\\u003e") + .replace("&", "\\u0026") + ) + fm_html = "" + if post.metadata: + fm_items = [] + skip_keys = {"title", "titre"} + for k, v in post.metadata.items(): + if k in skip_keys: + continue + if isinstance(v, list): + v = ", ".join(str(x) for x in v) + elif isinstance(v, bool): + v = "✓" if v else "✗" + elif v is None: + v = "—" + fm_items.append( + f'
{html_mod.escape(str(k))}' + f'{html_mod.escape(str(v))}
' + ) + if fm_items: + fm_html = f'
Frontmatter
{"".join(fm_items)}
' + + return HTMLResponse(f""" +{title_esc} — ObsiGate Share + + +
+ + Document partagé via ObsiGate +
+
+ {title_esc} + + + +
+
{fm_html}{html}
+ +""") diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 17a33d9..6e7b229 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.27.3" +version = "2.27.4" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 5838004..cd83bde 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.27.3" +version = "2.27.4" 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 cfaa01f..6c015e0 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.3", + "version": "2.27.4", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 8cb12a4..00f6dce 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.27.3 | **Dernière mise à jour :** 2026-09-26 +> **Version :** 2.27.4 | **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`). +- **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`). - **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`) ; `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`) ; `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 9483e0a..7da9aba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.27.3", + "version": "2.27.4", "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": {