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. -[]() +[]() [](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* 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. -[]() +[]() [](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* 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(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'