diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8c8636d..c24b4e5 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.8**.
+> [Unreleased](#unreleased). La dernière version livrée est **2.27.9**.
---
@@ -14,6 +14,10 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
+## [2.27.9] — 2026-09-26
+
+---
+
## [2.27.8] — 2026-09-26
---
@@ -42,6 +46,12 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Modifié
+- **#85 (T6c) — extraction media/pdf/export/guide hors du monolithe `backend/main.py`.**
+ file/pdf, exports (html/md-bundle/epub), guide/download, pdf/stream|info,
+ image, media+thumb, attachments (rescan/stats), vault settings (get/post/all)
+ et vault files sont servis par `backend/routers/files_media.py` ; le helper
+ Range partagé vit dans `backend/routers/helpers.py` (tags OpenAPI inchangés,
+ tests statiques frontend `media-viewer`/`image-viewer` réalignés).
- **#85 (T6b) — extraction mutations fichiers/dossiers hors du monolithe `backend/main.py`.**
`PUT .../save|xlsx/save`, `DELETE/POST/PATCH /api/file`, `POST/PATCH/DELETE
/api/directory`, `POST /api/move`, `POST .../batch-upload` sont servis par
diff --git a/README.fr.md b/README.fr.md
index 06514c5..3da52bb 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.8).
+Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.27.9).
---
-*Projet : ObsiGate | Version : 2.27.8 | Dernière mise à jour : Septembre 2026*
+*Projet : ObsiGate | Version : 2.27.9 | Dernière mise à jour : Septembre 2026*
diff --git a/README.md b/README.md
index 7cd4569..69cf6db 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.8).
+See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.27.9).
---
-*Project: ObsiGate | Version: 2.27.8 | Last updated: September 2026*
+*Project: ObsiGate | Version: 2.27.9 | Last updated: September 2026*
diff --git a/VERSION b/VERSION
index a85fcf2..f89d8bb 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-2.27.8
+2.27.9
diff --git a/backend/main.py b/backend/main.py
index b307975..38ea194 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -14,16 +14,14 @@ from pathlib import Path
import frontmatter
import mistune
from fastapi import Body, Depends, FastAPI, HTTPException, Query, Request, WebSocket
-from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response, StreamingResponse
+from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from starlette.middleware.base import BaseHTTPMiddleware
-from backend.attachment_indexer import get_attachment_stats, rescan_vault_attachments
from backend.collab import authenticate_websocket, collab_manager
from backend.history import (
get_bookmarks,
- record_open,
toggle_bookmark,
)
from backend.image_processor import preprocess_images
@@ -35,13 +33,11 @@ from backend.indexer import (
get_vault_data,
handle_file_move,
index,
- parse_markdown_file,
remove_single_file,
remove_vault_from_index,
update_single_file,
)
-from backend.media_thumbs import generate_thumbnail, is_decodable
-from backend.media_types import IMAGE_EXTENSIONS, is_audio, is_image, is_video, media_mime_type
+from backend.media_types import IMAGE_EXTENSIONS
from backend.openapi_docs import (
API_DESCRIPTION,
TAGS_METADATA,
@@ -53,23 +49,17 @@ from backend.schemas import (
AIKeysResponse,
AIModelsResponse,
AITestResponse,
- AllVaultSettingsResponse,
AppConfigResponse,
- AttachmentRescanResponse,
- AttachmentStatsResponse,
BookmarksResponse,
BookmarkToggleResponse,
ConflictResolveResponse,
ConflictsResponse,
DashboardResponse,
DiagnosticsResponse,
- PdfInfoResponse,
RecentResponse,
SavedSearch,
StatusResponse,
VaultActionResponse,
- VaultFilesResponse,
- VaultSettingsResponse,
VaultsStatusResponse,
VaultStatsResponse,
)
@@ -83,11 +73,6 @@ from backend.services.recent import humanize_mtime, list_recent
from backend.services.sanitizer import sanitize_html
from backend.services.vaults import (
list_accessible_vaults,
- list_all_files,
-)
-from backend.vault_settings import (
- get_vault_setting,
- update_vault_setting,
)
logging.basicConfig(
@@ -486,6 +471,8 @@ app.add_middleware(SSESafeGZipMiddleware, minimum_size=1000)
app.add_middleware(SecurityHeadersMiddleware)
# Auth router
+# Multi-format export (HTML / MD bundle / ePub) — voir backend.routers.files_media (#85 T6c).
+from backend.ai_routes import router as ai_router
from backend.audit import log_file_delete
from backend.auth.middleware import (
check_vault_access,
@@ -493,22 +480,9 @@ from backend.auth.middleware import (
require_auth,
)
from backend.auth.router import router as auth_router
-from backend.secret_redactor import redact_file_content
-
-# 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)")
-
-# Multi-format export (HTML / MD bundle / ePub) — pure Python, no heavy deps.
-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.files_media import router as files_media_router
from backend.routers.files_read import router as files_read_router
from backend.routers.files_write import router as files_write_router
from backend.routers.health import router as health_router
@@ -516,6 +490,7 @@ from backend.routers.search import router as search_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.secret_redactor import redact_file_content
from backend.skills_routes import router as skills_router
app.include_router(auth_router)
@@ -526,6 +501,7 @@ app.include_router(health_router) # ROADMAP #85 T1 — System / health
app.include_router(search_router) # ROADMAP #85 T5 — Search
app.include_router(backups_router) # ROADMAP #85 T4 — Backups
app.include_router(files_read_router) # ROADMAP #85 T6a — Files read
+app.include_router(files_media_router) # ROADMAP #85 T6c — Media/export
app.include_router(files_write_router) # ROADMAP #85 T6b — Files write
app.include_router(webhooks_router) # ROADMAP #85 T2 — Webhooks
app.include_router(sharing_router) # ROADMAP #85 T3 — Sharing
@@ -578,12 +554,9 @@ async def api_docs_landing():
# ---------------------------------------------------------------------------
-# Path safety helper : voir backend.routers.helpers (#85 T6a)
+# Path safety helper : voir backend.routers.helpers (#85 T6a, T6c)
# ---------------------------------------------------------------------------
-from backend.routers.helpers import content_disposition as _content_disposition
-from backend.routers.helpers import media_max_inline_bytes as _media_max_inline_bytes
-
def _resolve_safe_path(vault_root: Path, relative_path: str | None) -> Path:
"""Resolve a relative path safely within the vault root.
@@ -964,168 +937,10 @@ async def api_delete_saved_search(search_id: str, current_user=Depends(require_a
# File browse & read endpoints : voir backend.routers.files_read (#85 T6a)
# ---------------------------------------------------------------------------
-@app.get(
- "/api/file/{vault_name}/pdf",
- response_class=Response,
- responses={200: {"content": {"application/pdf": {}}, "description": "PDF document"}},
-)
-async def api_file_pdf(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
- """Download a markdown file as PDF."""
- if generate_pdf is None:
- raise HTTPException(501, "PDF export unavailable (WeasyPrint/GTK not available)")
- if not check_vault_access(vault_name, current_user):
- raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'")
- vault_data = get_vault_data(vault_name)
- if not vault_data:
- raise HTTPException(404, 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():
- raise HTTPException(404, f"File not found: {path}")
- try:
- raw = file_path.read_text(encoding="utf-8", errors="replace")
- except Exception:
- raise HTTPException(500, "Cannot read file")
- record_open(current_user.get("username"), vault_name, path)
- raw = redact_file_content(raw, str(file_path))
- post = parse_markdown_file(raw)
- html = _render_markdown(post.content, vault_name, file_path)
- 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"'})
-
-
# ---------------------------------------------------------------------------
-# Multi-format export endpoints (HTML / Markdown bundle / ePub)
+# File PDF / export / guide / media endpoints : voir backend.routers.files_media (#85 T6c)
# ---------------------------------------------------------------------------
-def _resolve_export_target(vault_name: str, path: str, current_user: dict) -> tuple[Path, Path]:
- """Resolve a vault + relative path into (vault_root, absolute file path).
-
- Enforces auth (vault access) and path traversal protection.
- """
- 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"])
- target = _resolve_safe_path(vault_root, path)
- return vault_root, target
-
-
-@app.get(
- "/api/export/html",
- response_class=Response,
- responses={200: {"content": {"text/html": {}}, "description": "Standalone HTML file"}},
-)
-async def api_export_html(
- vault: str = Query(..., description="Vault name"),
- path: str = Query(..., description="Relative path to file"),
- current_user=Depends(require_auth),
-):
- """Export a markdown note as a standalone HTML file."""
- try:
- vault_root, target = _resolve_export_target(vault, path, current_user)
- html_bytes = export_html(vault_root, target)
- except ExportError as e:
- raise HTTPException(status_code=400, detail=str(e))
- record_open(current_user.get("username"), vault, path)
- safe_name = _safe_export_name(target.stem)
- return Response(
- content=html_bytes,
- media_type="text/html; charset=utf-8",
- headers={"Content-Disposition": f'attachment; filename="{safe_name}.html"'},
- )
-
-
-@app.get(
- "/api/export/md-bundle",
- response_class=Response,
- responses={200: {"content": {"application/zip": {}}, "description": "Markdown ZIP bundle"}},
-)
-async def api_export_md_bundle(
- vault: str = Query(..., description="Vault name"),
- path: str = Query(..., description="Relative path to directory or file"),
- current_user=Depends(require_auth),
-):
- """Export a directory (or single file) of markdown as a ZIP bundle."""
- try:
- vault_root, target = _resolve_export_target(vault, path, current_user)
- zip_bytes = export_md_bundle(vault_root, target)
- except ExportError as e:
- raise HTTPException(status_code=400, detail=str(e))
- safe_name = _safe_export_name(target.name)
- return Response(
- content=zip_bytes,
- media_type="application/zip",
- headers={"Content-Disposition": f'attachment; filename="{safe_name}.zip"'},
- )
-
-
-@app.get(
- "/api/export/epub",
- response_class=Response,
- responses={200: {"content": {"application/epub+zip": {}}, "description": "ePub document"}},
-)
-async def api_export_epub(
- vault: str = Query(..., description="Vault name"),
- path: str = Query(..., description="Relative path to file"),
- current_user=Depends(require_auth),
-):
- """Export a markdown note as an ePub document."""
- try:
- vault_root, target = _resolve_export_target(vault, path, current_user)
- epub_bytes = export_epub(vault_root, target)
- except ExportError as e:
- raise HTTPException(status_code=400, detail=str(e))
- record_open(current_user.get("username"), vault, path)
- safe_name = _safe_export_name(target.stem)
- return Response(
- content=epub_bytes,
- media_type="application/epub+zip",
- headers={"Content-Disposition": f'attachment; filename="{safe_name}.epub"'},
- )
-
-
-def _safe_export_name(name: str) -> str:
- """ASCII-safe, filename-safe download name (falls back to 'document')."""
- cleaned = "".join(c for c in name if c.isascii() and (c.isalnum() or c in " _-.")).strip()
- return cleaned or "document"
-
-
-@app.get(
- "/api/guide/download",
- response_class=Response,
- responses={200: {"content": {"application/pdf": {}, "text/markdown": {}}}},
-)
-async def api_guide_download(
- format: str = Query("md", description="Download format: 'md' or 'pdf'"),
- lang: str = Query("fr", description="Guide language: 'fr' or 'en'"),
- current_user=Depends(require_auth),
-):
- """Download the in-app user guide as Markdown or PDF (#105).
-
- The document is generated from the live help modal in index.html resolved
- through the locale files, so it always mirrors exactly what the user sees.
- """
- from backend.guide_export import get_guide_document
-
- if format not in ("md", "pdf"):
- raise HTTPException(status_code=400, detail="format doit être 'md' ou 'pdf'")
- try:
- payload, media, fname = get_guide_document(format, lang)
- except Exception as e: # weasyprint/reportlab unavailable
- logger.exception("guide export failed")
- raise HTTPException(status_code=500, detail=f"Export impossible: {e}") from e
- return Response(
- content=payload,
- media_type=media,
- headers={"Content-Disposition": f'attachment; filename="{fname}"'},
- )
-
# ---------------------------------------------------------------------------
# File & directory mutations : voir backend.routers.files_write (#85 T6b)
@@ -1160,138 +975,9 @@ def _get_backup_dir(vault_name: str, relative_path: str) -> Path:
# File backlinks + view endpoints : voir backend.routers.files_read (#85 T6a)
-def _stream_file_with_range(file_path: Path, request: Request, media_type: str):
- """Return a file response honouring the HTTP ``Range`` header (roadmap #109).
+# Range helper : voir backend.routers.helpers.stream_file_with_range (#85 T6c)
- Shared by ``pdf/stream`` and ``/api/media``: a plain :class:`FileResponse`
- with ``Accept-Ranges: bytes`` when no range is requested, or a
- :class:`StreamingResponse` (206 Partial Content, 64 KiB chunks) for a valid
- single range. An unsatisfiable range yields ``416`` with a
- ``Content-Range: bytes */size`` header.
-
- Reads are offloaded to threads so the event loop is never blocked
- (ASYNC230), matching the previous inline implementation.
- """
- file_size = file_path.stat().st_size
- range_header = request.headers.get("range")
- disposition = _content_disposition("inline", file_path.name)
-
- if range_header:
- # Parse "bytes=start-end" (single range only; multi-range is not used by viewers)
- m = re.match(r"bytes=(\d*)-(\d*)", range_header)
- if not m:
- raise HTTPException(status_code=416,
- headers={"Content-Range": f"bytes */{file_size}"})
- start_s, end_s = m.group(1), m.group(2)
- if start_s == "" and end_s == "":
- raise HTTPException(status_code=416,
- headers={"Content-Range": f"bytes */{file_size}"})
- if start_s == "":
- # suffix range: last N bytes
- length = min(int(end_s), file_size)
- start = file_size - length
- end = file_size - 1
- else:
- start = int(start_s)
- end = int(end_s) if end_s else file_size - 1
- end = min(end, file_size - 1)
- if start > end or start >= file_size:
- raise HTTPException(status_code=416,
- headers={"Content-Range": f"bytes */{file_size}"})
-
- chunk_size = end - start + 1
-
- async def _partial():
- f = await asyncio.to_thread(open, str(file_path), "rb")
- try:
- await asyncio.to_thread(f.seek, start)
- remaining = chunk_size
- while remaining > 0:
- data = await asyncio.to_thread(f.read, min(64 * 1024, remaining))
- if not data:
- break
- remaining -= len(data)
- yield data
- finally:
- await asyncio.to_thread(f.close)
-
- return StreamingResponse(
- _partial(),
- status_code=206,
- media_type=media_type,
- headers={
- "Content-Range": f"bytes {start}-{end}/{file_size}",
- "Accept-Ranges": "bytes",
- "Content-Length": str(chunk_size),
- "Content-Disposition": disposition,
- },
- )
-
- return FileResponse(str(file_path), media_type=media_type, headers={
- "Accept-Ranges": "bytes",
- "Content-Disposition": disposition})
-
-
-@app.get("/api/file/{vault_name}/pdf/stream", response_class=FileResponse)
-async def api_pdf_stream(
- request: Request,
- vault_name: str,
- path: str = Query(...),
- current_user=Depends(require_auth),
-):
- """Stream a PDF file with Content-Type: application/pdf for inline browser viewing.
-
- Supports HTTP Range requests (206 Partial Content) so browsers can
- progressively render large PDFs in the native viewer.
- """
- 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}")
- if file_path.suffix.lower() != ".pdf":
- raise HTTPException(status_code=400, detail="Not a PDF file")
-
- return _stream_file_with_range(file_path, request, "application/pdf")
-
-
-@app.get("/api/file/{vault_name}/pdf/info", response_model=PdfInfoResponse)
-async def api_pdf_info(
- vault_name: str,
- path: str = Query(..., description="Relative path to PDF file"),
- current_user=Depends(require_auth),
-):
- """Return PDF metadata (pages, title, author, size) without the document content.
-
- Lets the UI display file info before loading a heavy PDF into the viewer.
- """
- 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}")
- if file_path.suffix.lower() != ".pdf":
- raise HTTPException(status_code=400, detail="Not a PDF file")
-
- from backend.pdf_reader import extract_pdf_metadata
- meta = extract_pdf_metadata(file_path)
- stat = file_path.stat()
- return {
- "vault": vault_name,
- "path": path,
- "pages": meta.get("pages", 0),
- "title": meta.get("title") or file_path.name,
- "author": meta.get("author", ""),
- "size_bytes": stat.st_size,
- }
+# PDF stream/info endpoints : voir backend.routers.files_media (#85 T6c)
# ---------------------------------------------------------------------------
@@ -1421,298 +1107,13 @@ async def api_vaults_status(current_user=Depends(require_auth)):
}
-@app.get(
- "/api/image/{vault_name}",
- response_class=Response,
- responses={200: {"content": {"application/octet-stream": {}}, "description": "Image bytes"}},
-)
-async def api_image(vault_name: str, path: str = Query(..., description="Relative path to image"), current_user=Depends(require_auth)):
- """Serve an image file with proper MIME type.
-
- Args:
- vault_name: Name of the vault.
- path: Relative file path within the vault.
-
- Returns:
- Image file with appropriate content-type header.
- """
- 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"Image not found: {path}")
-
- mime_type = media_mime_type(str(file_path))
-
- # #108-B3 — a standalone SVG opened in a tab executes its embedded JS
- # (same-origin XSS). ``sandbox`` forces a unique opaque origin with no
- # script execution; inside an
tag the header is irrelevant.
- headers = {"X-Content-Type-Options": "nosniff"}
- if file_path.suffix.lower() == ".svg":
- headers["Content-Security-Policy"] = "sandbox"
-
- try:
- # Read and return the image file
- content = file_path.read_bytes()
- return Response(content=content, media_type=mime_type, headers=headers)
- except PermissionError:
- raise HTTPException(status_code=403, detail="Permission denied")
- except Exception as e:
- logger.error(f"Error serving image {vault_name}/{path}: {e}")
- raise HTTPException(status_code=500, detail=f"Error serving image: {e!s}")
-
-
-@app.get("/api/media/{vault_name}", response_class=FileResponse)
-async def api_media_stream(
- request: Request,
- vault_name: str,
- path: str = Query(..., description="Relative path to audio/video file"),
- current_user=Depends(require_auth),
-):
- """Stream an audio/video file with HTTP Range support (roadmap #109-A2).
-
- Serves the bytes with the correct MIME type and honours ``Range`` requests
- (``206 Partial Content`` + ``Content-Range``/``Accept-Ranges``), which is
- what enables scrubbing in ``