Compare commits

..
2 Commits
16 changed files with 1354 additions and 1209 deletions
+21 -1
View File
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> **En cours de développement** : les changements à venir sont listés dans la section
> [Unreleased](#unreleased). La dernière version livrée est **2.27.7**.
> [Unreleased](#unreleased). La dernière version livrée est **2.27.9**.
---
@@ -14,6 +14,14 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.27.9] — 2026-09-26
---
## [2.27.8] — 2026-09-26
---
## [2.27.7] — 2026-09-26
---
@@ -38,6 +46,18 @@ 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
le nouveau `backend/routers/files_write.py` (effets de bord inchangés :
audit, index, SSE, webhooks, plugins, historique) ; 15 modèles dans
`schemas.py`.
- **#85 (T6a) — extraction lecture fichiers hors du monolithe `backend/main.py`.**
`/api/browse/{vault}`, `/api/file/{vault}/raw|download|backlinks` et
`GET /api/file/{vault}` (vue rendue tous formats) sont servis par le
+3 -3
View File
@@ -4,7 +4,7 @@
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
[![Version](https://img.shields.io/badge/Version-2.27.7-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.27.9-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.7).
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.7 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.27.9 | Dernière mise à jour : Septembre 2026*
+3 -3
View File
@@ -2,7 +2,7 @@
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
[![Version](https://img.shields.io/badge/Version-2.27.7-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.27.9-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.7).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.27.9).
---
*Project: ObsiGate | Version: 2.27.7 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.27.9 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.27.7
2.27.9
+24 -1189
View File
File diff suppressed because it is too large Load Diff
+569
View File
@@ -0,0 +1,569 @@
"""Media, PDF, export & vault-settings endpoints (ROADMAP #85, tranche 6c).
Handlers déplacés depuis :mod:`backend.main` sans changement de
comportement : mêmes chemins (``/api/file/*/pdf*``, ``/api/export/*``,
``/api/guide/download``, ``/api/image/*``, ``/api/media*``,
``/api/attachments/*``, ``/api/vaults/*/settings``, ``/api/vault/*/files``,
``/api/vaults/settings/all``), mêmes modèles de réponse, mêmes dépendances
d'authentification.
Adaptations strictement équivalentes :
- ``_resolve_safe_path`` → :mod:`backend.services.paths` (pass-through).
- ``_render_markdown`` reste dans ``main`` (import différé).
- ``_resolve_export_target`` / ``_safe_export_name`` (export uniquement)
sont définis ici ; ``stream_file_with_range`` vit dans
:mod:`backend.routers.helpers` (partagé).
"""
import asyncio
import logging
from pathlib import Path
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request
from fastapi.responses import FileResponse, Response
from backend.attachment_indexer import get_attachment_stats, rescan_vault_attachments
from backend.auth.middleware import check_vault_access, require_admin, require_auth
from backend.export import ExportError, export_epub, export_html, export_md_bundle
from backend.history import record_open
from backend.indexer import get_vault_data, index, parse_markdown_file
from backend.media_thumbs import generate_thumbnail, is_decodable
from backend.media_types import is_audio, is_image, is_video, media_mime_type
from backend.routers.helpers import media_max_inline_bytes, stream_file_with_range
from backend.schemas import (
AllVaultSettingsResponse,
AttachmentRescanResponse,
AttachmentStatsResponse,
PdfInfoResponse,
VaultFilesResponse,
VaultSettingsResponse,
)
from backend.secret_redactor import redact_file_content
from backend.services.paths import resolve_safe_path
from backend.services.vaults import list_all_files
from backend.vault_settings import get_vault_setting, update_vault_setting
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() # pas de tags : assignation par chemin via openapi_docs.tag_for_path (comme avant)
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
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"
@router.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."""
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)")
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"'})
@router.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"'},
)
@router.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"'},
)
@router.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"'},
)
@router.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}"'},
)
@router.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")
@router.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,
}
@router.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 <img> 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}")
@router.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 ``<audio>``/``<video>`` and is required by Safari
for MP4. Files above ``OBSIGATE_MEDIA_MAX_INLINE_MB`` (default 500 MB) are
refused with ``413`` — the viewer falls back to the download button.
"""
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"Media not found: {path}")
ext = file_path.suffix.lower()
if not (is_audio(ext) or is_video(ext)):
raise HTTPException(status_code=400, detail="Not an audio/video file")
if file_path.stat().st_size > media_max_inline_bytes():
raise HTTPException(status_code=413, detail="Media too large for inline streaming")
return stream_file_with_range(file_path, request, media_mime_type(str(file_path)))
@router.get("/api/media/{vault_name}/thumb", response_class=FileResponse)
async def api_media_thumb(
vault_name: str,
path: str = Query(..., description="Relative path to image"),
size: int = Query(256, ge=32, le=1024, description="Max thumbnail edge in pixels"),
current_user=Depends(require_auth),
):
"""Serve a cached WebP thumbnail of an image (roadmap #108-C).
SVG (and any format Pillow cannot decode) falls back to the original
bytes. Generation runs in a thread and is capped at 2 s; on timeout or
failure the original is served so the UI never breaks.
"""
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}")
if not is_image(file_path.suffix.lower()):
raise HTTPException(status_code=400, detail="Not an image file")
mime_type = media_mime_type(str(file_path))
if not is_decodable(file_path):
# SVG: never let a standalone navigation execute embedded JS (#108-B3).
svg_headers = {"X-Content-Type-Options": "nosniff"}
if file_path.suffix.lower() == ".svg":
svg_headers["Content-Security-Policy"] = "sandbox"
return FileResponse(str(file_path), media_type=mime_type, headers=svg_headers)
loop = asyncio.get_running_loop()
thumb: Path | None = None
try:
thumb = await asyncio.wait_for(
loop.run_in_executor(None, generate_thumbnail, file_path, size),
timeout=2.0,
)
except Exception:
thumb = None
if thumb is not None and thumb.exists():
return FileResponse(str(thumb), media_type="image/webp")
return FileResponse(str(file_path), media_type=mime_type)
@router.post("/api/attachments/rescan/{vault_name}", response_model=AttachmentRescanResponse)
async def api_rescan_attachments(vault_name: str, current_user=Depends(require_admin)):
"""Rescan attachments for a specific vault.
Args:
vault_name: Name of the vault to rescan.
Returns:
Dict with status and attachment count.
"""
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_path = vault_data["path"]
count = await rescan_vault_attachments(vault_name, vault_path)
logger.info(f"Rescanned attachments for vault '{vault_name}': {count} attachments")
return {"status": "ok", "vault": vault_name, "attachment_count": count}
@router.get("/api/attachments/stats", response_model=AttachmentStatsResponse)
async def api_attachment_stats(vault: str | None = Query(None, description="Vault filter"), current_user=Depends(require_auth)):
"""Get attachment statistics for vaults.
Args:
vault: Optional vault name to filter stats.
Returns:
Dict with vault names as keys and attachment counts as values.
"""
stats = get_attachment_stats(vault)
return {"vaults": stats}
@router.get("/api/vaults/{vault_name}/settings", response_model=VaultSettingsResponse)
async def api_get_vault_settings(vault_name: str, current_user=Depends(require_auth)):
"""Get UI display settings for a specific vault.
Args:
vault_name: Name of the vault.
Returns:
Dict with vault settings including hideHiddenFiles.
"""
if vault_name not in index:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
# Get persisted settings
persisted = get_vault_setting(vault_name) or {}
# Default settings
settings = {
"hideHiddenFiles": False,
}
settings.update(persisted)
return settings
@router.post("/api/vaults/{vault_name}/settings", response_model=VaultSettingsResponse)
async def api_update_vault_settings(vault_name: str, body: dict = Body(...), current_user=Depends(require_admin)):
"""Update UI display settings for a specific vault.
Args:
vault_name: Name of the vault.
body: Dict with settings to update (hideHiddenFiles).
Returns:
Updated settings dict.
"""
if vault_name not in index:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
# Validate settings
settings_to_update = {}
if "hideHiddenFiles" in body:
if not isinstance(body["hideHiddenFiles"], bool):
raise HTTPException(status_code=400, detail="hideHiddenFiles must be a boolean")
settings_to_update["hideHiddenFiles"] = body["hideHiddenFiles"]
# Update persisted settings
try:
updated = update_vault_setting(vault_name, settings_to_update)
except PermissionError as e:
logger.error(f"Permission error saving settings for vault '{vault_name}': {e}")
raise HTTPException(
status_code=500,
detail="Permission denied: Cannot write to settings file. Check /app/data permissions."
)
except Exception as e:
logger.error(f"Error saving settings for vault '{vault_name}': {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to save settings: {e!s}"
)
logger.info(f"Updated settings for vault '{vault_name}': {settings_to_update}")
return updated
@router.get("/api/vault/{vault_name}/files", response_model=VaultFilesResponse)
async def api_vault_recent_files(
vault_name: str,
dir: str = Query("", description="Directory path within the vault (empty = root)"),
limit: int = Query(200, description="Maximum number of files to return"),
recursive: bool = Query(True, description="If true, list files recursively from directory and all subdirectories"),
current_user=Depends(require_auth),
):
"""List files in a vault directory sorted by modification time (newest first).
Returns file metadata suitable for a vault home page display.
Unlike /api/browse, this endpoint sorts by mtime and returns
additional metadata (size, modified time, extension).
When recursive=True (default), lists files from the directory
AND all its subdirectories, with a ``rel_dir`` field indicating
the subdirectory path relative to the requested directory.
Args:
vault_name: Name of the vault.
dir: Relative directory path within the vault (empty for root).
limit: Maximum files to return (default 200).
recursive: If true, recursively list files in subdirectories (default true).
Returns:
JSON with vault, directory, count, recursive flag, and list of file entries.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
return list_all_files(vault_name, dir=dir, limit=limit, recursive=recursive)
@router.get("/api/vaults/settings/all", response_model=AllVaultSettingsResponse)
async def api_get_all_vault_settings(current_user=Depends(require_auth)):
"""Get UI display settings for all vaults.
Returns:
Dict mapping vault names to their settings.
"""
all_settings = {}
for vault_name in index:
persisted = get_vault_setting(vault_name) or {}
settings = {
"hideHiddenFiles": False,
}
settings.update(persisted)
all_settings[vault_name] = settings
return all_settings
+506
View File
@@ -0,0 +1,506 @@
"""File & directory mutation endpoints (ROADMAP #85, tranche 6b).
Handlers déplacés depuis :mod:`backend.main` sans changement de
comportement : mêmes chemins (``PUT/DELETE/PATCH/POST /api/file/*``,
``/api/directory/*``, ``/api/move/*``, ``/api/vault/*/batch-upload``),
mêmes modèles de requête/réponse (déménagés dans :mod:`backend.schemas`),
mêmes dépendances d'authentification et mêmes effets de bord (audit, index
incrémental, SSE, webhooks, plugins, historique).
La logique métier vit déjà dans :mod:`backend.services.mutations`.
"""
import logging
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from backend.audit import log_file_delete, log_file_save
from backend.auth.middleware import check_vault_access, require_auth
from backend.history import (
remove_recent,
update_bookmarks_after_rename,
update_history_after_rename,
)
from backend.indexer import handle_file_move, remove_single_file, update_single_file
from backend.schemas import (
BatchUploadRequest,
BatchUploadResponse,
DirectoryCreateRequest,
DirectoryCreateResponse,
DirectoryDeleteResponse,
DirectoryRenameRequest,
DirectoryRenameResponse,
FileCreateRequest,
FileCreateResponse,
FileDeleteResponse,
FileMoveRequest,
FileMoveResponse,
FileRenameRequest,
FileRenameResponse,
FileSaveResponse,
)
from backend.services.mutations import (
batch_upload_files as service_batch_upload_files,
)
from backend.services.mutations import (
create_directory as service_create_directory,
)
from backend.services.mutations import (
create_file as service_create_file,
)
from backend.services.mutations import (
delete_directory as service_delete_directory,
)
from backend.services.mutations import (
delete_file as service_delete_file,
)
from backend.services.mutations import (
edit_file as service_edit_file,
)
from backend.services.mutations import (
edit_xlsx_cells as service_edit_xlsx_cells,
)
from backend.services.mutations import (
move_path as service_move_path,
)
from backend.services.mutations import (
rename_directory as service_rename_directory,
)
from backend.services.mutations import (
rename_file as service_rename_file,
)
from backend.share import update_shares_after_rename
from backend.sse import sse_manager
from backend.webhooks import dispatch_webhooks
logger = logging.getLogger("obsigate")
router = APIRouter(tags=["files"])
@router.put("/api/file/{vault_name}/save", response_model=FileSaveResponse)
async def api_file_save(
vault_name: str,
path: str = Query(..., description="Relative path to file"),
body: dict = Body(...),
backup: bool = Query(True, description="Create a backup before saving (default true, set false for auto-save)"),
current_user=Depends(require_auth),
):
"""Save (overwrite) a file's content.
Expects a JSON body with a ``content`` key containing the new text.
The path is validated against traversal attacks before writing.
Args:
vault_name: Name of the vault.
path: Relative file path within the vault.
body: JSON body with ``content`` string.
Returns:
``FileSaveResponse`` confirming the write.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
content = body.get("content", "")
result = service_edit_file(vault_name, path, content, backup=backup)
# Audit log
client_ip = current_user.get("_request_ip", "unknown")
log_file_save(current_user["username"], vault_name, path, len(content), client_ip)
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]}
@router.put("/api/file/{vault_name}/xlsx/save", response_model=FileSaveResponse)
async def api_file_xlsx_save(
vault_name: str,
path: str = Query(..., description="Relative path to the .xlsx file"),
body: dict = Body(..., description='{"sheet": str, "cells": {"A1": value}}'),
current_user=Depends(require_auth),
):
"""Apply cell edits to an .xlsx workbook.
Expects a JSON body with ``sheet`` and ``cells`` (A1 references to new
scalar values, max 500 per request). A backup is created before the
workbook is rewritten.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
sheet = body.get("sheet")
cells = body.get("cells")
if not isinstance(sheet, str) or not sheet:
raise HTTPException(status_code=400, detail="Feuille manquante")
if not isinstance(cells, dict) or not cells or len(cells) > 500:
raise HTTPException(status_code=400, detail="Cellules invalides (1 à 500 par requête)")
for ref, value in cells.items():
if not isinstance(ref, str) or not isinstance(value, (str, int, float, bool, type(None))):
raise HTTPException(status_code=400, detail=f"Cellule invalide: {ref!r}")
result = service_edit_xlsx_cells(vault_name, path, sheet, cells)
log_file_save(
current_user["username"], vault_name, path,
sum(len(str(v)) for v in cells.values()),
current_user.get("_request_ip", "unknown"),
)
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]}
@router.delete("/api/file/{vault_name}", response_model=FileDeleteResponse)
async def api_file_delete(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
"""Delete a file from the vault.
The path is validated against traversal attacks before deletion.
Args:
vault_name: Name of the vault.
path: Relative file path within the vault.
Returns:
``FileDeleteResponse`` confirming the deletion.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
result = service_delete_file(vault_name, path)
# Audit log
client_ip = current_user.get("_request_ip", "unknown")
log_file_delete(current_user["username"], vault_name, path, client_ip)
# Update index
await remove_single_file(vault_name, path)
# Broadcast SSE event
await sse_manager.broadcast("file_deleted", {
"vault": vault_name,
"path": path,
})
from backend.plugins import emit_file_deleted
emit_file_deleted(vault_name, path)
# Remove from recent files
remove_recent(current_user["username"], vault_name, path)
# Dispatch webhooks
await dispatch_webhooks("file_deleted", {"vault": vault_name, "path": path})
return {"status": "ok", "vault": result["vault"], "path": result["path"]}
@router.post("/api/directory/{vault_name}", response_model=DirectoryCreateResponse)
async def api_directory_create(
vault_name: str,
body: DirectoryCreateRequest,
current_user=Depends(require_auth),
):
"""Create a new directory in a vault.
Args:
vault_name: Name of the vault.
body: Request body with directory path.
Returns:
DirectoryCreateResponse confirming creation.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
result = service_create_directory(vault_name, body.path)
# Update path_index with the new directory
from backend.indexer import _index_lock
from backend.indexer import path_index as _path_idx
with _index_lock:
if vault_name not in _path_idx:
_path_idx[vault_name] = []
existing = {p["path"] for p in _path_idx[vault_name]}
# Build all parent segments
parts = body.path.split("/")
for i in range(1, len(parts) + 1):
seg_path = "/".join(parts[:i])
if seg_path and seg_path not in existing:
existing.add(seg_path)
_path_idx[vault_name].append({
"path": seg_path,
"name": parts[i - 1],
"type": "directory",
})
# Broadcast SSE event
await sse_manager.broadcast("directory_created", {
"vault": vault_name,
"path": result["path"],
})
await dispatch_webhooks("directory_created", {"vault": vault_name, "path": result["path"]})
return {"success": True, "path": result["path"]}
@router.patch("/api/directory/{vault_name}", response_model=DirectoryRenameResponse)
async def api_directory_rename(
vault_name: str,
body: DirectoryRenameRequest,
current_user=Depends(require_auth),
):
"""Rename a directory in a vault.
Args:
vault_name: Name of the vault.
body: Request body with current path and new name.
Returns:
DirectoryRenameResponse with old and new paths.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
result = service_rename_directory(vault_name, body.path, body.new_name)
old_path_str = result["old_path"]
new_path_str = result["new_path"]
# Update index for all files in the directory
from backend.indexer import reload_single_vault
await reload_single_vault(vault_name)
# Broadcast SSE event
await sse_manager.broadcast("directory_renamed", {
"vault": vault_name,
"old_path": old_path_str,
"new_path": new_path_str,
})
await dispatch_webhooks("directory_renamed", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str})
return {"success": True, "old_path": old_path_str, "new_path": new_path_str}
@router.delete("/api/directory/{vault_name}", response_model=DirectoryDeleteResponse)
async def api_directory_delete(
vault_name: str,
path: str = Query(..., description="Relative path to directory"),
current_user=Depends(require_auth),
):
"""Delete a directory and all its contents from a vault.
Args:
vault_name: Name of the vault.
path: Relative directory path within the vault.
Returns:
DirectoryDeleteResponse with count of deleted files.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
result = service_delete_directory(vault_name, path, recursive=True)
file_count = result["deleted_count"]
# Update index
from backend.indexer import reload_single_vault
await reload_single_vault(vault_name)
# Broadcast SSE event
await sse_manager.broadcast("directory_deleted", {
"vault": vault_name,
"path": result["path"],
"deleted_count": file_count,
})
await dispatch_webhooks("directory_deleted", {"vault": vault_name, "path": result["path"]})
return {"success": True, "deleted_count": file_count}
@router.post("/api/file/{vault_name}", response_model=FileCreateResponse)
async def api_file_create(
vault_name: str,
body: FileCreateRequest,
current_user=Depends(require_auth),
):
"""Create a new file in a vault.
Args:
vault_name: Name of the vault.
body: Request body with file path and initial content.
Returns:
FileCreateResponse confirming creation.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
result = service_create_file(vault_name, body.path, body.content)
# Update index
await update_single_file(vault_name, result["path"])
# Broadcast SSE event
await sse_manager.broadcast("file_created", {
"vault": vault_name,
"path": result["path"],
})
await dispatch_webhooks("file_created", {"vault": vault_name, "path": result["path"]})
from backend.plugins import emit_file_created
emit_file_created(vault_name, result["path"])
return {"success": True, "path": result["path"]}
@router.post("/api/vault/{vault_name}/batch-upload", response_model=BatchUploadResponse)
async def api_batch_upload(
vault_name: str,
body: BatchUploadRequest,
current_user=Depends(require_auth),
):
"""Upload multiple files and directories (recursively) into a vault.
Accepts base64 encoded or plain text files with relative directory paths.
Creates missing parent folders safely.
Args:
vault_name: Target vault name.
body: BatchUploadRequest with target_dir and files list.
Returns:
BatchUploadResponse with summary of uploaded files and errors.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
import base64
items: list[dict[str, Any]] = []
for f in body.files:
if f.is_dir:
items.append({"path": f.path, "is_dir": True})
continue
raw_bytes = b""
if f.content is not None:
# Check if content is base64 encoded data URI or raw base64
content_str = f.content
if content_str.startswith("data:") and ";base64," in content_str:
content_str = content_str.split(";base64,", 1)[1]
try:
raw_bytes = base64.b64decode(content_str)
except Exception:
# Fallback to utf-8 text encoding
raw_bytes = f.content.encode("utf-8")
items.append({"path": f.path, "content": raw_bytes, "is_dir": False})
result = service_batch_upload_files(
vault_name,
body.target_dir,
items,
overwrite=body.overwrite,
)
# Update index and SSE notifications for uploaded files
for path in result["uploaded"]:
try:
await update_single_file(vault_name, path)
await sse_manager.broadcast("file_created", {
"vault": vault_name,
"path": path,
})
await dispatch_webhooks("file_created", {"vault": vault_name, "path": path})
except Exception as e:
logger.warning(f"Failed to post-process upload of {path}: {e}")
# SSE notification for tree refresh
if result["uploaded"] or result["created_dirs"]:
await sse_manager.broadcast("tree_updated", {
"vault": vault_name,
"target_dir": result["target_dir"],
})
return result
@router.patch("/api/file/{vault_name}", response_model=FileRenameResponse)
async def api_file_rename(
vault_name: str,
body: FileRenameRequest,
current_user=Depends(require_auth),
):
"""Rename a file in a vault.
Args:
vault_name: Name of the vault.
body: Request body with current path and new name.
Returns:
FileRenameResponse with old and new paths.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
result = service_rename_file(vault_name, body.path, body.new_name)
old_path_str = result["old_path"]
new_path_str = result["new_path"]
# Update index
await handle_file_move(vault_name, old_path_str, new_path_str)
# Update bookmarks, history, and shares
update_bookmarks_after_rename(vault_name, old_path_str, new_path_str)
update_history_after_rename(vault_name, old_path_str, new_path_str)
update_shares_after_rename(vault_name, old_path_str, new_path_str)
# Broadcast SSE event
await sse_manager.broadcast("file_renamed", {
"vault": vault_name,
"old_path": old_path_str,
"new_path": new_path_str,
})
await dispatch_webhooks("file_renamed", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str})
return {"success": True, "old_path": old_path_str, "new_path": new_path_str}
@router.post("/api/move/{vault_name}", response_model=FileMoveResponse)
async def api_file_move(
vault_name: str,
body: FileMoveRequest,
current_user=Depends(require_auth),
):
"""Move a file or directory to a different parent directory within the same vault.
Supports both files and directories. The item keeps its original name;
only the parent directory changes.
Args:
vault_name: Name of the vault.
body: Request body with source_path and destination_dir.
Returns:
FileMoveResponse with old and new paths.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
result = service_move_path(vault_name, body.source_path, body.destination_dir)
old_path_str = result["old_path"]
new_path_str = result["new_path"]
item_type = result["item_type"]
# Update index
if item_type == "directory":
from backend.indexer import reload_single_vault
await reload_single_vault(vault_name)
else:
await handle_file_move(vault_name, old_path_str, new_path_str)
# Broadcast SSE event
await sse_manager.broadcast("item_moved", {
"vault": vault_name,
"old_path": old_path_str,
"new_path": new_path_str,
"item_type": item_type,
})
await dispatch_webhooks("item_moved", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type})
return {"success": True, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type}
+78
View File
@@ -10,9 +10,14 @@ de comportement. Regroupées ici car utilisées par plusieurs routers
from __future__ import annotations
import asyncio
import os
import re
from pathlib import Path
from fastapi import HTTPException, Request
from fastapi.responses import FileResponse, StreamingResponse
def content_disposition(disposition: str, filename: str) -> str:
"""Build a header-safe Content-Disposition value.
@@ -49,3 +54,76 @@ def media_max_inline_bytes() -> int:
if mb <= 0:
return default_mb * 1024 * 1024
return int(mb * 1024 * 1024)
def stream_file_with_range(file_path: Path, request: Request, media_type: str):
"""Return a file response honouring the HTTP ``Range`` header (roadmap #109).
Extrait de :mod:`backend.main` (``_stream_file_with_range``) sans
changement de comportement. 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})
+130
View File
@@ -281,6 +281,136 @@ class FileRawResponse(BaseModel):
raw: str = Field(description="Raw file content as text")
# ---------------------------------------------------------------------------
# Files — mutations (#85 — extrait de backend.main, inchangé)
# ---------------------------------------------------------------------------
class FileSaveResponse(BaseModel):
"""Confirmation after saving a file."""
status: str = Field(description="Always 'ok'")
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path within the vault")
size: int = Field(description="Size of saved content in characters")
class FileDeleteResponse(BaseModel):
"""Confirmation after deleting a file."""
status: str = Field(description="Always 'ok'")
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path within the vault")
class DirectoryCreateRequest(BaseModel):
"""Request to create a new directory."""
path: str = Field(description="Relative path of the new directory")
class DirectoryCreateResponse(BaseModel):
"""Response after creating a directory."""
success: bool = Field(description="Whether creation succeeded")
path: str = Field(description="Path of the created directory")
class DirectoryRenameRequest(BaseModel):
"""Request to rename a directory."""
path: str = Field(description="Current path of the directory")
new_name: str = Field(description="New name for the directory")
class DirectoryRenameResponse(BaseModel):
"""Response after renaming a directory."""
success: bool = Field(description="Whether rename succeeded")
old_path: str = Field(description="Original directory path")
new_path: str = Field(description="New directory path")
class DirectoryDeleteResponse(BaseModel):
"""Response after deleting a directory."""
success: bool = Field(description="Whether deletion succeeded")
deleted_count: int = Field(description="Number of files recursively deleted")
class FileCreateRequest(BaseModel):
"""Request to create a new file."""
path: str = Field(description="Relative path of the new file")
content: str = Field(default="", description="Initial content")
class FileCreateResponse(BaseModel):
"""Response after creating a file."""
success: bool = Field(description="Whether creation succeeded")
path: str = Field(description="Path of the created file")
class BatchUploadFileItem(BaseModel):
"""A single file/dir entry in a batch upload request."""
path: str = Field(description="Relative path of the item within the batch")
content: str | None = Field(default=None, description="Base64 encoded or text content for files")
is_dir: bool = Field(default=False, description="True if entry represents an empty directory")
class BatchUploadRequest(BaseModel):
"""Request payload for batch file/directory upload."""
target_dir: str = Field(default="", description="Base directory in vault to upload into (empty for root)")
files: list[BatchUploadFileItem] = Field(description="List of files and directories to upload")
overwrite: bool = Field(default=True, description="Whether to overwrite existing files (creates backups)")
class BatchUploadResponse(BaseModel):
"""Response from batch file/directory upload."""
success: bool = Field(description="True if all files uploaded without error")
vault: str = Field(description="Vault name")
target_dir: str = Field(description="Target directory")
uploaded: list[str] = Field(description="List of created/updated file paths")
created_dirs: list[str] = Field(description="List of created directory paths")
errors: list[dict[str, Any]] = Field(default_factory=list, description="List of items that failed")
total_files: int = Field(description="Total uploaded files count")
class FileRenameRequest(BaseModel):
"""Request to rename a file."""
path: str = Field(description="Current path of the file")
new_name: str = Field(description="New name for the file")
class FileRenameResponse(BaseModel):
"""Response after renaming a file."""
success: bool = Field(description="Whether rename succeeded")
old_path: str
new_path: str
class FileMoveRequest(BaseModel):
"""Request to move a file or directory to a different parent directory."""
source_path: str = Field(description="Current relative path of the file/directory")
destination_dir: str = Field(description="Target directory relative path (empty string for vault root)")
class FileMoveResponse(BaseModel):
"""Response after moving a file or directory."""
success: bool = Field(description="Whether move succeeded")
old_path: str = Field(description="Original path")
new_path: str = Field(description="New path after move")
item_type: str = Field(description="Type of item moved: 'file' or 'directory'")
# ---------------------------------------------------------------------------
# Search / suggest / graph (#85 — extrait de backend.main, inchangé)
# ---------------------------------------------------------------------------
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.27.7"
version = "2.27.9"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.27.7"
version = "2.27.9"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.27.7",
"version": "2.27.9",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+3 -3
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.27.7 | **Dernière mise à jour :** 2026-09-26
> **Version :** 2.27.9 | **Dernière mise à jour :** 2026-09-26
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
@@ -67,10 +67,10 @@
- **Effort :** 8-12 jours | **Impact :** 🟡 | **Zone :** backend
- **Décision 2026-09-26 : prioritaire (axe Dette & sécurité).**
- **Statut :** 🔵 en cours depuis 2026-09-26 — découpe par tranches à impact minimal (comportement inchangé, un domaine par commit). **T1 livrée (v2.27.2) :** `health` (`/api/health`, `/api/health/detailed` → `backend/routers/health.py`, `HealthResponse` → `schemas.py`). **T2 livrée (v2.27.3) :** `webhooks` (CRUD `/api/webhooks` → `backend/routers/webhooks.py`, logique déjà dans `backend/webhooks.py`). **T3 livrée (v2.27.4) :** `sharing` (`/api/share/*`, `/api/shares`, `/s/{token}*` → `backend/routers/sharing.py`, logique déjà dans `backend/share.py`). **T4 livrée (v2.27.5) :** `backups` (9 routes `/api/file/{vault}/backups|diff|restore` + `/api/backups*` → `backend/routers/backups.py`, `Diff/Restore*` → `schemas.py`, singleton SSE → `backend/sse.py`). **T5 livrée (v2.27.6) :** `search` (11 routes search/tags/suggest/graph/reload → `backend/routers/search.py`, modèles search → `schemas.py`, pool threads → `backend/search_executor.py`). **T6a livrée (v2.27.7) :** lecture fichiers (`/api/browse`, `/raw`, `/download`, `/backlinks`, `GET /api/file` → `backend/routers/files_read.py`, modèles + `EXT_TO_LANG` déplacés, helpers `_content_disposition`/`_media_max_inline_bytes` → `backend/routers/helpers.py`).
- **Statut :** 🔵 en cours depuis 2026-09-26 — découpe par tranches à impact minimal (comportement inchangé, un domaine par commit). **T1 livrée (v2.27.2) :** `health` (`/api/health`, `/api/health/detailed` → `backend/routers/health.py`, `HealthResponse` → `schemas.py`). **T2 livrée (v2.27.3) :** `webhooks` (CRUD `/api/webhooks` → `backend/routers/webhooks.py`, logique déjà dans `backend/webhooks.py`). **T3 livrée (v2.27.4) :** `sharing` (`/api/share/*`, `/api/shares`, `/s/{token}*` → `backend/routers/sharing.py`, logique déjà dans `backend/share.py`). **T4 livrée (v2.27.5) :** `backups` (9 routes `/api/file/{vault}/backups|diff|restore` + `/api/backups*` → `backend/routers/backups.py`, `Diff/Restore*` → `schemas.py`, singleton SSE → `backend/sse.py`). **T5 livrée (v2.27.6) :** `search` (11 routes search/tags/suggest/graph/reload → `backend/routers/search.py`, modèles search → `schemas.py`, pool threads → `backend/search_executor.py`). **T6a livrée (v2.27.7) :** lecture fichiers (`/api/browse`, `/raw`, `/download`, `/backlinks`, `GET /api/file` → `backend/routers/files_read.py`, modèles + `EXT_TO_LANG` déplacés, helpers `_content_disposition`/`_media_max_inline_bytes` → `backend/routers/helpers.py`). **T6b livrée (v2.27.8) :** mutations fichiers/dossiers (save, xlsx/save, delete, create, rename, move, directories ×3, batch-upload → `backend/routers/files_write.py`, 15 modèles → `schemas.py`). **T6c livrée (v2.27.9) :** media/pdf/export/guide (file/pdf, exports ×3, guide, pdf/stream|info, image, media+thumb, attachments ×2, vault settings ×3, vault files → `backend/routers/files_media.py`, Range helper → `helpers.py`).
- **Description :** extraire le monolithe `backend/main.py` (~4 827 lignes au 2026-09-26, ~17 % du backend) en routers FastAPI par domaine et rendre persistant l'état qui ne l'est pas (index de recherche, JTI révoqués, compteurs de rate-limit) pour préparer le multi-nœuds. L'état mémoire actuel (index, inverted index, vecteurs sémantiques, `SSEManager`, collab) rend le multi-workers unsafe.
- **Sous-tâches :**
- [ ] Routers par domaine : files, search, share, webhooks, plugins, collab, admin, ai — `main.py` conservé comme assemblage (< 500 lignes) ; dédupliquer les modèles Pydantic vers `schemas.py`. **Avancement :** `health` ✅ (T1, `backend/routers/health.py`), `webhooks` ✅ (T2, `backend/routers/webhooks.py`), `sharing` ✅ (T3, `backend/routers/sharing.py`), `backups` ✅ (T4, `backend/routers/backups.py` + `backend/sse.py`), `search` ✅ (T5, `backend/routers/search.py` + `backend/search_executor.py`), `files-read` ✅ (T6a, `backend/routers/files_read.py` + `helpers.py`) ; `tools/registry.py` existe déjà (permissions/quotas/redaction — à compléter, pas à créer)
- [ ] Routers par domaine : files, search, share, webhooks, plugins, collab, admin, ai — `main.py` conservé comme assemblage (< 500 lignes) ; dédupliquer les modèles Pydantic vers `schemas.py`. **Avancement :** `health` ✅ (T1, `backend/routers/health.py`), `webhooks` ✅ (T2, `backend/routers/webhooks.py`), `sharing` ✅ (T3, `backend/routers/sharing.py`), `backups` ✅ (T4, `backend/routers/backups.py` + `backend/sse.py`), `search` ✅ (T5, `backend/routers/search.py` + `backend/search_executor.py`), `files-read` ✅ (T6a, `backend/routers/files_read.py` + `helpers.py`), `files-write` ✅ (T6b, `backend/routers/files_write.py`), `files-media` ✅ (T6c, `backend/routers/files_media.py`) ; `tools/registry.py` existe déjà (permissions/quotas/redaction — à compléter, pas à créer)
- [ ] Compléter `tools/registry.py` (existant : permissions/quotas/redaction) comme contrat central des outils IA si des manques sont constatés
- [ ] Persister index, JTI révoqués et compteurs de rate-limit (SQLite par défaut, Redis en option multi-nœuds ; le rate-limit actuel est in-memory mono-process)
- [ ] Verrous asyncio autour de l'index global et des stores JSON ; auditer les `except Exception` larges (> 100 occurrences) : best-effort (backup/audit) vs masquage d'erreur (erreurs typées 4xx/5xx + test)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.27.7",
"version": "2.27.9",
"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": {
+4 -2
View File
@@ -22,6 +22,7 @@ const viewer = readFileSync(path.join(ROOT, "frontend", "js", "viewer.js"), "utf
const utils = readFileSync(path.join(ROOT, "frontend", "js", "utils.js"), "utf8");
const css = readFileSync(path.join(ROOT, "frontend", "style.css"), "utf8");
const main = readFileSync(path.join(ROOT, "backend", "main.py"), "utf8");
const filesRead = readFileSync(path.join(ROOT, "backend", "routers", "files_read.py"), "utf8");
function test(label, fn) {
try {
@@ -186,8 +187,9 @@ test("utils.js maps image extensions to the Lucide 'image' icon", () => {
});
test("backend api_file_view points <img> at /api/image", () => {
assert.match(main, /img_url = f"\/api\/image\//);
assert.match(main, /f'<img src="\{img_url\}"/);
// #85 T6a : le handler vit dans backend/routers/files_read.py
assert.match(filesRead, /img_url = f"\/api\/image\//);
assert.match(filesRead, /f'<img src="\{img_url\}"/);
});
if (process.exitCode) {
+8 -3
View File
@@ -24,6 +24,9 @@ const utils = readFileSync(path.join(ROOT, "frontend", "js", "utils.js"), "utf8"
const css = readFileSync(path.join(ROOT, "frontend", "style.css"), "utf8");
const sw = readFileSync(path.join(ROOT, "frontend", "sw.js"), "utf8");
const main = readFileSync(path.join(ROOT, "backend", "main.py"), "utf8");
const filesMedia = readFileSync(path.join(ROOT, "backend", "routers", "files_media.py"), "utf8");
const filesRead = readFileSync(path.join(ROOT, "backend", "routers", "files_read.py"), "utf8");
const routerHelpers = readFileSync(path.join(ROOT, "backend", "routers", "helpers.py"), "utf8");
const fr = readFileSync(path.join(ROOT, "frontend", "locales", "fr.json"), "utf8");
const en = readFileSync(path.join(ROOT, "frontend", "locales", "en.json"), "utf8");
@@ -177,9 +180,11 @@ test("service worker never caches streamed media", () => {
// ── Static checks: backend ─────────────────────────────────────────────────
test("backend exposes /api/media and the shared Range helper", () => {
assert.match(main, /@app\.get\("\/api\/media\/\{vault_name\}"/);
assert.match(main, /def _stream_file_with_range\(/);
assert.match(main, /is_audio\(ext\) or is_video\(ext\)/);
// #85 T6c : routes dans backend/routers/files_media.py, helper Range partagé
// dans backend/routers/helpers.py, branche audio/vidéo dans files_read.py
assert.match(filesMedia, /@router\.get\("\/api\/media\/\{vault_name\}"/);
assert.match(routerHelpers, /def stream_file_with_range\(/);
assert.match(filesRead, /is_audio\(ext\) or is_video\(ext\)/);
});
if (process.exitCode) {