refactor: #85 T3 extrait le domaine sharing vers backend/routers (comportement inchange)
This commit is contained in:
+11
-1
@@ -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
|
||||
|
||||
+3
-3
@@ -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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](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*
|
||||
|
||||
@@ -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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](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*
|
||||
|
||||
+6
-251
@@ -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'<pre style="font-family:monospace;font-size:12px;line-height:1.6;white-space:pre-wrap">{html_mod.escape(raw)}</pre>'
|
||||
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'<pre style="background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:16px;overflow-x:auto;font-size:0.85rem;line-height:1.6"><code>{escaped}</code></pre>'
|
||||
|
||||
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 ``</script>`` 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 ``</script>``.
|
||||
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'<div class="fm-row"><span class="fm-key">{html_mod.escape(str(k))}</span>'
|
||||
f'<span class="fm-val">{html_mod.escape(str(v))}</span></div>'
|
||||
)
|
||||
if fm_items:
|
||||
fm_html = f'<div class="fm-section"><div class="fm-header">Frontmatter</div><div class="fm-body">{"".join(fm_items)}</div></div>'
|
||||
|
||||
return HTMLResponse(f"""<!DOCTYPE html><html lang="fr" data-theme="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{title_esc} — ObsiGate Share</title>
|
||||
<style>
|
||||
:root {{ --bg:#1a1a2e; --bg-card:#16213e; --text:#e0e0e0; --text-muted:#888; --accent:#6366f1; --border:#2a2a4a; --banner-bg:var(--accent); --banner-text:#fff; }}
|
||||
[data-theme="light"] {{ --bg:#f8f9fa; --bg-card:#fff; --text:#1a1a2e; --text-muted:#666; --accent:#4f46e5; --border:#ddd; --banner-bg:#eef2ff; --banner-text:#4338ca; }}
|
||||
*{{box-sizing:border-box;margin:0;padding:0}}
|
||||
body{{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);line-height:1.7;min-height:100vh}}
|
||||
.toolbar{{position:sticky;top:0;z-index:10;background:var(--bg-card);border-bottom:1px solid var(--border);padding:8px 16px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}}
|
||||
.toolbar-title{{font-weight:600;font-size:0.9rem;margin-right:auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
|
||||
.toolbar-btn{{padding:6px 12px;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);cursor:pointer;font-size:0.8rem;display:flex;align-items:center;gap:5px;transition:all .15s}}
|
||||
.toolbar-btn:hover{{background:var(--accent);color:#fff;border-color:var(--accent)}}
|
||||
.toolbar-btn svg{{width:15px;height:15px;flex-shrink:0}}
|
||||
.toolbar-btn:hover svg{{stroke:#fff}}
|
||||
.share-banner{{background:var(--banner-bg);color:var(--banner-text);padding:6px 16px;font-size:0.8rem;text-align:center;display:flex;align-items:center;justify-content:center;gap:6px}}
|
||||
.share-banner svg{{width:14px;height:14px;flex-shrink:0}}
|
||||
.content{{max-width:820px;margin:0 auto;padding:24px 20px 60px}}
|
||||
.content h1{{font-size:1.8rem;margin-bottom:16px;border-bottom:2px solid var(--border);padding-bottom:8px}}
|
||||
.content h2{{font-size:1.4rem;margin:24px 0 12px}}
|
||||
.content h3{{font-size:1.15rem;margin:20px 0 8px}}
|
||||
.content p{{margin:8px 0}}
|
||||
.content pre{{background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:12px 16px;overflow-x:auto;font-size:0.85rem}}
|
||||
.content code{{font-size:0.9em;background:var(--bg-card);padding:1px 4px;border-radius:3px}}
|
||||
.content pre code{{background:none;padding:0}}
|
||||
.content a{{color:var(--accent)}}.content img{{max-width:100%;border-radius:6px}}
|
||||
.fm-section{{background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:20px}}
|
||||
.fm-header{{font-weight:600;font-size:0.8rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:8px}}
|
||||
.fm-body{{display:grid;grid-template-columns:1fr 2fr;gap:4px 12px;font-size:0.85rem}}
|
||||
.fm-row{{display:contents}}
|
||||
.fm-key{{color:var(--accent);font-weight:500}}
|
||||
.fm-val{{color:var(--text);word-break:break-word}}
|
||||
.content blockquote{{border-left:3px solid var(--accent);padding-left:16px;color:var(--text-muted);margin:12px 0}}
|
||||
.content table{{border-collapse:collapse;width:100%;margin:12px 0}}
|
||||
.content th,.content td{{border:1px solid var(--border);padding:8px 12px;text-align:left}}
|
||||
.content th{{background:var(--bg-card)}}
|
||||
@media print{{.toolbar,.share-banner{{display:none}}body{{background:#fff;color:#000}}}}
|
||||
@media(max-width:600px){{.content{{padding:16px 12px 40px}}.toolbar{{gap:4px}}.toolbar-btn{{padding:4px 8px;font-size:0.7rem}}}}
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="share-banner">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
Document partagé via ObsiGate
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<span class="toolbar-title">{title_esc}</span>
|
||||
<button class="toolbar-btn" onclick="toggleTheme()" title="Thème clair/sombre">
|
||||
<svg id="theme-icon-dark" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||
<svg id="theme-icon-light" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
||||
</button>
|
||||
<button class="toolbar-btn" onclick="exportMD()" title="Télécharger en Markdown">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
.md
|
||||
</button>
|
||||
<button class="toolbar-btn" onclick="location.href=location.pathname+'/pdf'" title="Télécharger en PDF">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
||||
PDF
|
||||
</button>
|
||||
</div>
|
||||
<div class="content" id="content">{fm_html}{html}</div>
|
||||
<script id="raw-content" type="text/plain" style="display:none">{raw_json}</script>
|
||||
<script>
|
||||
function toggleTheme(){{var t=document.documentElement;var isDark=t.dataset.theme==="dark";t.dataset.theme=isDark?"light":"dark";document.getElementById("theme-icon-dark").style.display=isDark?"none":"";document.getElementById("theme-icon-light").style.display=isDark?"":"none";localStorage.setItem("obsigate-share-theme",t.dataset.theme)}}
|
||||
(function(){{var s=localStorage.getItem("obsigate-share-theme");if(!s)s="dark";document.documentElement.dataset.theme=s;var isDark=s==="dark";document.getElementById("theme-icon-dark").style.display=isDark?"":"none";document.getElementById("theme-icon-light").style.display=isDark?"none":""}})();
|
||||
function exportMD(){{var raw=JSON.parse(document.getElementById("raw-content").textContent);var b=new Blob([raw],{{type:"text/markdown"}});var a=document.createElement("a");a.href=URL.createObjectURL(b);a.download={title_download_js};a.click()}}
|
||||
</script></body></html>""")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Syncthing conflict endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
# Syncthing conflict endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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'<pre style="font-family:monospace;font-size:12px;line-height:1.6;white-space:pre-wrap">{html_mod.escape(raw)}</pre>'
|
||||
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'<pre style="background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:16px;overflow-x:auto;font-size:0.85rem;line-height:1.6"><code>{escaped}</code></pre>'
|
||||
|
||||
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 ``</script>`` 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 ``</script>``.
|
||||
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'<div class="fm-row"><span class="fm-key">{html_mod.escape(str(k))}</span>'
|
||||
f'<span class="fm-val">{html_mod.escape(str(v))}</span></div>'
|
||||
)
|
||||
if fm_items:
|
||||
fm_html = f'<div class="fm-section"><div class="fm-header">Frontmatter</div><div class="fm-body">{"".join(fm_items)}</div></div>'
|
||||
|
||||
return HTMLResponse(f"""<!DOCTYPE html><html lang="fr" data-theme="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{title_esc} — ObsiGate Share</title>
|
||||
<style>
|
||||
:root {{ --bg:#1a1a2e; --bg-card:#16213e; --text:#e0e0e0; --text-muted:#888; --accent:#6366f1; --border:#2a2a4a; --banner-bg:var(--accent); --banner-text:#fff; }}
|
||||
[data-theme="light"] {{ --bg:#f8f9fa; --bg-card:#fff; --text:#1a1a2e; --text-muted:#666; --accent:#4f46e5; --border:#ddd; --banner-bg:#eef2ff; --banner-text:#4338ca; }}
|
||||
*{{box-sizing:border-box;margin:0;padding:0}}
|
||||
body{{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);line-height:1.7;min-height:100vh}}
|
||||
.toolbar{{position:sticky;top:0;z-index:10;background:var(--bg-card);border-bottom:1px solid var(--border);padding:8px 16px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}}
|
||||
.toolbar-title{{font-weight:600;font-size:0.9rem;margin-right:auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
|
||||
.toolbar-btn{{padding:6px 12px;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);cursor:pointer;font-size:0.8rem;display:flex;align-items:center;gap:5px;transition:all .15s}}
|
||||
.toolbar-btn:hover{{background:var(--accent);color:#fff;border-color:var(--accent)}}
|
||||
.toolbar-btn svg{{width:15px;height:15px;flex-shrink:0}}
|
||||
.toolbar-btn:hover svg{{stroke:#fff}}
|
||||
.share-banner{{background:var(--banner-bg);color:var(--banner-text);padding:6px 16px;font-size:0.8rem;text-align:center;display:flex;align-items:center;justify-content:center;gap:6px}}
|
||||
.share-banner svg{{width:14px;height:14px;flex-shrink:0}}
|
||||
.content{{max-width:820px;margin:0 auto;padding:24px 20px 60px}}
|
||||
.content h1{{font-size:1.8rem;margin-bottom:16px;border-bottom:2px solid var(--border);padding-bottom:8px}}
|
||||
.content h2{{font-size:1.4rem;margin:24px 0 12px}}
|
||||
.content h3{{font-size:1.15rem;margin:20px 0 8px}}
|
||||
.content p{{margin:8px 0}}
|
||||
.content pre{{background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:12px 16px;overflow-x:auto;font-size:0.85rem}}
|
||||
.content code{{font-size:0.9em;background:var(--bg-card);padding:1px 4px;border-radius:3px}}
|
||||
.content pre code{{background:none;padding:0}}
|
||||
.content a{{color:var(--accent)}}.content img{{max-width:100%;border-radius:6px}}
|
||||
.fm-section{{background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:20px}}
|
||||
.fm-header{{font-weight:600;font-size:0.8rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:8px}}
|
||||
.fm-body{{display:grid;grid-template-columns:1fr 2fr;gap:4px 12px;font-size:0.85rem}}
|
||||
.fm-row{{display:contents}}
|
||||
.fm-key{{color:var(--accent);font-weight:500}}
|
||||
.fm-val{{color:var(--text);word-break:break-word}}
|
||||
.content blockquote{{border-left:3px solid var(--accent);padding-left:16px;color:var(--text-muted);margin:12px 0}}
|
||||
.content table{{border-collapse:collapse;width:100%;margin:12px 0}}
|
||||
.content th,.content td{{border:1px solid var(--border);padding:8px 12px;text-align:left}}
|
||||
.content th{{background:var(--bg-card)}}
|
||||
@media print{{.toolbar,.share-banner{{display:none}}body{{background:#fff;color:#000}}}}
|
||||
@media(max-width:600px){{.content{{padding:16px 12px 40px}}.toolbar{{gap:4px}}.toolbar-btn{{padding:4px 8px;font-size:0.7rem}}}}
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="share-banner">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
Document partagé via ObsiGate
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<span class="toolbar-title">{title_esc}</span>
|
||||
<button class="toolbar-btn" onclick="toggleTheme()" title="Thème clair/sombre">
|
||||
<svg id="theme-icon-dark" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||
<svg id="theme-icon-light" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
||||
</button>
|
||||
<button class="toolbar-btn" onclick="exportMD()" title="Télécharger en Markdown">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
.md
|
||||
</button>
|
||||
<button class="toolbar-btn" onclick="location.href=location.pathname+'/pdf'" title="Télécharger en PDF">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
||||
PDF
|
||||
</button>
|
||||
</div>
|
||||
<div class="content" id="content">{fm_html}{html}</div>
|
||||
<script id="raw-content" type="text/plain" style="display:none">{raw_json}</script>
|
||||
<script>
|
||||
function toggleTheme(){{var t=document.documentElement;var isDark=t.dataset.theme==="dark";t.dataset.theme=isDark?"light":"dark";document.getElementById("theme-icon-dark").style.display=isDark?"none":"";document.getElementById("theme-icon-light").style.display=isDark?"":"none";localStorage.setItem("obsigate-share-theme",t.dataset.theme)}}
|
||||
(function(){{var s=localStorage.getItem("obsigate-share-theme");if(!s)s="dark";document.documentElement.dataset.theme=s;var isDark=s==="dark";document.getElementById("theme-icon-dark").style.display=isDark?"":"none";document.getElementById("theme-icon-light").style.display=isDark?"none":""}})();
|
||||
function exportMD(){{var raw=JSON.parse(document.getElementById("raw-content").textContent);var b=new Blob([raw],{{type:"text/markdown"}});var a=document.createElement("a");a.href=URL.createObjectURL(b);a.download={title_download_js};a.click()}}
|
||||
</script></body></html>""")
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.27.3"
|
||||
version = "2.27.4"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
+3
-3
@@ -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)
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user