refactor: #85 T6a extrait lecture fichiers vers backend/routers (comportement inchange)

This commit is contained in:
2026-09-26 13:43:53 -04:00
parent dadc055429
commit b83d8dacdf
13 changed files with 680 additions and 592 deletions
+10 -577
View File
@@ -11,7 +11,6 @@ import urllib.request
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
from urllib.parse import quote
import frontmatter
import mistune
@@ -33,11 +32,9 @@ from backend.history import (
)
from backend.image_processor import preprocess_images
from backend.indexer import (
_extract_tags,
add_vault_to_index,
build_index,
find_file_in_index,
get_backlinks,
get_conflicts,
get_vault_data,
handle_file_move,
@@ -64,7 +61,6 @@ from backend.schemas import (
AppConfigResponse,
AttachmentRescanResponse,
AttachmentStatsResponse,
BacklinksResponse,
BookmarksResponse,
BookmarkToggleResponse,
ConflictResolveResponse,
@@ -87,7 +83,6 @@ from backend.search import (
from backend.semantic_search import init_semantic_index
from backend.services.backups import get_backup_dir as service_get_backup_dir
from backend.services.errors import ServiceError
from backend.services.files import read_raw_file
from backend.services.mutations import (
batch_upload_files as service_batch_upload_files,
)
@@ -121,7 +116,6 @@ from backend.services.mutations import (
from backend.services.recent import humanize_mtime, list_recent
from backend.services.sanitizer import sanitize_html
from backend.services.vaults import (
browse_directory,
list_accessible_vaults,
list_all_files,
)
@@ -149,64 +143,6 @@ class VaultInfo(BaseModel):
type: str = Field(default="VAULT", description="Type of the vault mapping (VAULT or DIR)")
class BrowseItem(BaseModel):
"""A single entry (file or directory) returned by the browse endpoint."""
name: str = Field(description="File or directory name")
path: str = Field(description="Relative path within vault")
type: str = Field(description="'file' or 'directory'")
children_count: int | None = Field(default=None, description="Number of children (directories only)")
size: int | None = Field(default=None, description="File size in bytes")
extension: str | None = Field(default=None, description="File extension")
class BrowseResponse(BaseModel):
"""Paginated directory listing for a vault."""
vault: str
path: str
items: list[BrowseItem]
class FileContentResponse(BaseModel):
"""Rendered file content with metadata."""
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path within the vault")
title: str = Field(description="File title (from frontmatter or filename)")
tags: list[str] = Field(description="Extracted tags from frontmatter and inline #tags")
frontmatter: dict[str, Any] = Field(description="YAML frontmatter as key-value dict")
html: str = Field(description="Rendered HTML content")
raw_length: int = Field(description="Length of raw file content in characters")
extension: str = Field(description="File extension (e.g. .md, .txt)")
is_markdown: bool = Field(description="Whether the file is markdown")
unsupported: bool | None = Field(default=False, description="True for binary/unsupported files")
size_bytes: int | None = Field(default=None, description="File size in bytes (for unsupported files)")
is_pdf: bool | None = Field(default=None, description="True for PDF files")
is_image: bool | None = Field(default=None, description="True for image files")
is_audio: bool | None = Field(default=None, description="True for audio files (HTML5 <audio>, roadmap #109)")
is_video: bool | None = Field(default=None, description="True for video files (HTML5 <video>, roadmap #109)")
media_too_large: bool | None = Field(default=None, description="True when audio/video exceeds the inline streaming limit")
stream_url: str | None = Field(default=None, description="Byte-range streaming URL under /api/media (audio/video)")
media_mime: str | None = Field(default=None, description="MIME type for audio/video files")
is_csv: bool | None = Field(default=None, description="True for CSV files")
is_xlsx: bool | None = Field(default=None, description="True for Excel .xlsx files")
xlsx_sheets: list[dict[str, Any]] | None = Field(
default=None, description="Rendered xlsx sheets [{name, html}]"
)
is_json: bool | None = Field(default=None, description="True for JSON files")
is_excalidraw: bool | None = Field(default=None, description="True for Excalidraw diagram files")
excalidraw_data: dict[str, Any] | None = Field(default=None, description="Excalidraw diagram data (elements, appState, files)")
excalidraw_data_compressed: str | None = Field(default=None, description="Compressed Excalidraw data for .excalidraw.md files")
pdf_metadata: dict[str, Any] | None = Field(default=None, description="PDF metadata")
pdf_toc: list[dict[str, Any]] | None = Field(default=None, description="PDF table of contents")
image_mime: str | None = Field(default=None, description="MIME type for image files")
class FileRawResponse(BaseModel):
"""Raw text content of a file."""
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path within the vault")
raw: str = Field(description="Raw file content as text")
class FileSaveResponse(BaseModel):
"""Confirmation after saving a file."""
status: str = Field(description="Always 'ok'")
@@ -717,6 +653,7 @@ 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_read import router as files_read_router
from backend.routers.health import router as health_router
from backend.routers.search import router as search_router
from backend.routers.sharing import router as sharing_router
@@ -733,6 +670,7 @@ app.include_router(skills_router)
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(webhooks_router) # ROADMAP #85 T2 — Webhooks
app.include_router(sharing_router) # ROADMAP #85 T3 — Sharing
@@ -784,44 +722,11 @@ async def api_docs_landing():
# ---------------------------------------------------------------------------
# Path safety helper
# Path safety helper : voir backend.routers.helpers (#85 T6a)
# ---------------------------------------------------------------------------
def _content_disposition(disposition: str, filename: str) -> str:
"""Build a header-safe Content-Disposition value.
HTTP header values must be ASCII. Unicode filenames are sent per
RFC 5987 via ``filename*`` (percent-encoded UTF-8) with a pure-ASCII
``filename`` fallback. This avoids a UnicodeDecodeError / HTTP 500 when
the filename contains accented characters (e.g. 'Bière blonde…pdf').
"""
from urllib.parse import quote
ascii_name = "".join(c for c in filename if c.isascii() and (c.isalnum() or c in " _-.")).strip() or "file"
ext = Path(filename).suffix
if ext and not Path(ascii_name).suffix:
ascii_name = ascii_name + ext
return f"{disposition}; filename=\"{ascii_name}\"; filename*=UTF-8''{quote(filename)}"
def _media_max_inline_bytes() -> int:
"""Maximum size (bytes) for inline audio/video playback (roadmap #109-A3).
Configurable via ``OBSIGATE_MEDIA_MAX_INLINE_MB`` (default 500 MB). Files
above the limit are not streamed in the viewer (the UI falls back to the
download button), which keeps a single uvicorn worker from being pinned by
multi-gigabyte media. Invalid or non-positive values fall back to default.
"""
default_mb = 500
raw = os.environ.get("OBSIGATE_MEDIA_MAX_INLINE_MB", "").strip()
if not raw:
return default_mb * 1024 * 1024
try:
mb = float(raw)
except ValueError:
return default_mb * 1024 * 1024
if mb <= 0:
return default_mb * 1024 * 1024
return int(mb * 1024 * 1024)
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:
@@ -1199,97 +1104,9 @@ async def api_delete_saved_search(search_id: str, current_user=Depends(require_a
return {"status": "deleted"}
@app.get("/api/browse/{vault_name}", response_model=BrowseResponse)
async def api_browse(vault_name: str, path: str = "", current_user=Depends(require_auth)):
"""Browse directories and files in a vault at a given path level.
Returns sorted entries (directories first, then files) with metadata.
Hidden files/directories (starting with ``"."`` ) are excluded.
Args:
vault_name: Name of the vault to browse.
path: Relative directory path within the vault (empty = root).
Returns:
``BrowseResponse`` with vault name, path, and item list.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
return browse_directory(vault_name, path)
# Map file extensions to highlight.js language hints
EXT_TO_LANG = {
".py": "python", ".js": "javascript", ".ts": "typescript",
".jsx": "jsx", ".tsx": "tsx", ".sh": "bash", ".bash": "bash",
".zsh": "bash", ".fish": "fish", ".bat": "batch", ".cmd": "batch",
".ps1": "powershell", ".json": "json", ".yaml": "yaml", ".yml": "yaml",
".toml": "toml", ".xml": "xml", ".csv": "plaintext",
".cfg": "ini", ".ini": "ini", ".conf": "ini", ".env": "bash",
".html": "html", ".css": "css", ".scss": "scss", ".less": "less",
".java": "java", ".c": "c", ".cpp": "cpp", ".h": "c", ".hpp": "cpp",
".cs": "csharp", ".go": "go", ".rs": "rust", ".rb": "ruby",
".php": "php", ".sql": "sql", ".r": "r", ".swift": "swift",
".kt": "kotlin", ".txt": "plaintext", ".log": "plaintext",
".lua": "lua", ".pl": "perl", ".pm": "perl", ".ex": "elixir", ".exs": "elixir",
".dart": "dart", ".tf": "haskell", ".gradle": "groovy", ".groovy": "groovy",
".graphql": "graphql", ".gql": "graphql", ".prisma": "sql", ".proto": "c",
".vb": "basic", ".asm": "x86asm", ".s": "armasm",
".vue": "xml", ".svelte": "xml", ".astro": "xml",
".properties": "ini", ".service": "ini", ".hosts": "ini",
".ksh": "bash", ".dockerfile": "dockerfile",
".makefile": "makefile", ".cmake": "cmake",
}
@app.get("/api/file/{vault_name}/raw", response_model=FileRawResponse)
async def api_file_raw(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
"""Return raw file content as plain text.
Args:
vault_name: Name of the vault.
path: Relative file path within the vault.
Returns:
``FileRawResponse`` with vault, path, and raw text content.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
return read_raw_file(vault_name, path)
@app.get("/api/file/{vault_name}/download", response_class=FileResponse)
async def api_file_download(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
"""Download a file as an attachment.
Args:
vault_name: Name of the vault.
path: Relative file path within the vault.
Returns:
``FileResponse`` with ``application/octet-stream`` content-type.
"""
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}")
# Record history
record_open(current_user.get("username"), vault_name, path)
return FileResponse(
path=str(file_path),
filename=file_path.name,
media_type="application/octet-stream",
)
# ---------------------------------------------------------------------------
# File browse & read endpoints : voir backend.routers.files_read (#85 T6a)
# ---------------------------------------------------------------------------
@app.get(
"/api/file/{vault_name}/pdf",
@@ -1906,389 +1723,7 @@ def _get_backup_dir(vault_name: str, relative_path: str) -> Path:
# File-level backup endpoints : voir backend.routers.backups (#85 T4)
# ---------------------------------------------------------------------------
@app.get("/api/file/{vault_name}/backlinks", response_model=BacklinksResponse)
async def api_file_backlinks(
vault_name: str,
path: str = Query(..., description="Relative path to file"),
current_user=Depends(require_auth),
):
"""Get backlinks (files linking to this file via wikilinks).
Returns a list of files that contain `[[wikilinks]]` pointing
to the requested file, across all accessible vaults.
Args:
vault_name: Name of the vault containing the target file.
path: Relative path of the target file within the vault.
Returns:
``{"vault": str, "path": str, "backlinks": [...]}``
"""
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")
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
backlinks = get_backlinks(vault_name, path)
# Filter by user-accessible vaults
if "*" not in user_vaults:
backlinks = [b for b in backlinks if b["vault"] in user_vaults]
return {
"vault": vault_name,
"path": path,
"backlinks": backlinks,
"total": len(backlinks),
}
@app.get("/api/file/{vault_name}", response_model=FileContentResponse)
async def api_file(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
"""Return rendered HTML and metadata for a file.
Markdown files are parsed for frontmatter, rendered with wikilink
support, and returned with extracted tags. Other supported file
types are syntax-highlighted as code blocks.
Args:
vault_name: Name of the vault.
path: Relative file path within the vault.
Returns:
``FileContentResponse`` with HTML, metadata, and tags.
"""
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}")
# Record history
record_open(current_user.get("username"), vault_name, path, title=file_path.name)
ext = file_path.suffix.lower()
# === PDF: special handling before read_text (binary file) ===
if ext == ".pdf":
try:
from backend.pdf_reader import extract_pdf_metadata, extract_pdf_text, extract_pdf_toc
pdf_text = extract_pdf_text(file_path, max_chars=100000)
pdf_meta = extract_pdf_metadata(file_path)
pdf_toc = extract_pdf_toc(file_path)
size = file_path.stat().st_size
return {
"vault": vault_name,
"path": path,
"title": pdf_meta.get("title") or file_path.name,
"tags": [],
"frontmatter": {},
"html": f"<div class='pdf-viewer'><p>PDF — {pdf_meta.get('pages', '?')} pages</p><pre>{pdf_text[:5000]}</pre></div>",
"raw_length": size,
"extension": ext,
"is_markdown": False,
"is_pdf": True,
"unsupported": False,
"pdf_metadata": pdf_meta,
"pdf_toc": pdf_toc,
"size_bytes": size,
}
except Exception as e:
logger.error(f"PDF read error for {path}: {e}")
raise HTTPException(status_code=500, detail=f"Error reading PDF: {e!s}")
# === Excel .xlsx: render sheets as HTML tables (binary, before read_text) ===
if ext == ".xlsx":
try:
from backend.xlsx_reader import render_sheets
sheets = render_sheets(file_path)
size = file_path.stat().st_size
return {
"vault": vault_name,
"path": path,
"title": file_path.name,
"tags": [],
"frontmatter": {},
"html": sheets[0]["html"] if sheets else "",
"raw_length": size,
"extension": ext,
"is_markdown": False,
"is_xlsx": True,
"xlsx_sheets": sheets,
"unsupported": False,
"size_bytes": size,
}
except Exception as e:
logger.error(f"XLSX read error for {path}: {e}")
raise HTTPException(status_code=500, detail=f"Error reading XLSX: {e!s}")
# === Images: return as viewable image ===
if is_image(ext):
size = file_path.stat().st_size
mime = media_mime_type(str(file_path))
# #108-B1 — the raw endpoint returns JSON (FileRawResponse), so the
# standalone <img> must point to /api/image, which serves the bytes
# with the right MIME type. Paths are URL-encoded (accents, spaces).
img_url = f"/api/image/{quote(vault_name, safe='')}?path={quote(path, safe='')}"
html = (
f'<div class="image-viewer">'
f'<img src="{img_url}" '
f'alt="{html_mod.escape(file_path.name, quote=True)}" '
f'style="max-width:100%;max-height:80vh;object-fit:contain" />'
f'</div>'
)
return {
"vault": vault_name,
"path": path,
"title": file_path.name,
"tags": [],
"frontmatter": {},
"html": html,
"raw_length": size,
"extension": ext,
"is_markdown": False,
"is_image": True,
"image_mime": mime,
"size_bytes": size,
}
# === Audio / Video: HTML5 players streamed from /api/media (roadmap #109) ===
if is_audio(ext) or is_video(ext):
size = file_path.stat().st_size
mime = media_mime_type(str(file_path))
media_kind = "audio" if is_audio(ext) else "video"
# #109-A3 — beyond the inline limit the viewer falls back to download
# (a single uvicorn worker must not be pinned by multi-GB media).
if size > _media_max_inline_bytes():
return {
"vault": vault_name,
"path": path,
"title": file_path.name,
"tags": [],
"frontmatter": {},
"html": "",
"raw_length": size,
"extension": ext,
"is_markdown": False,
"unsupported": True,
"media_too_large": True,
"size_bytes": size,
}
# #109-A2 — byte-range endpoint: enables scrub and is required by Safari.
stream_url = f"/api/media/{quote(vault_name, safe='')}?path={quote(path, safe='')}"
if media_kind == "audio":
html = (
f'<div class="audio-viewer">'
f'<audio controls preload="metadata" src="{stream_url}"></audio>'
f'</div>'
)
else:
html = (
f'<div class="video-viewer">'
f'<video controls playsinline preload="metadata" src="{stream_url}"></video>'
f'</div>'
)
return {
"vault": vault_name,
"path": path,
"title": file_path.name,
"tags": [],
"frontmatter": {},
"html": html,
"raw_length": size,
"extension": ext,
"is_markdown": False,
"is_audio": media_kind == "audio",
"is_video": media_kind == "video",
"media_mime": mime,
"stream_url": stream_url,
"size_bytes": size,
}
try:
raw = file_path.read_text(encoding="utf-8", errors="replace")
except PermissionError as e:
logger.error(f"Permission denied reading file {path}: {e}")
raise HTTPException(status_code=403, detail=f"Permission denied: cannot read file {path}")
except UnicodeDecodeError:
# Binary / unsupported file — return structured info with download option
size = file_path.stat().st_size
return {
"vault": vault_name,
"path": path,
"title": file_path.name,
"tags": [],
"frontmatter": {},
"html": "",
"raw_length": size,
"extension": ext,
"is_markdown": False,
"unsupported": True,
"size_bytes": size,
}
except Exception as e:
logger.error(f"Unexpected error reading file {path}: {e}")
raise HTTPException(status_code=500, detail=f"Error reading file: {e!s}")
# === CSV: render as HTML table ===
if ext == ".csv":
import csv
import io as csv_io
reader = csv.reader(csv_io.StringIO(raw))
rows = list(reader)
if not rows:
html = "<p><em>Fichier CSV vide</em></p>"
else:
headers = rows[0]
data_rows = rows[1:]
html = '<div class="csv-table-wrapper"><table class="csv-table"><thead><tr>'
for h in headers:
html += f"<th>{h}</th>"
html += "</tr></thead><tbody>"
for row in data_rows:
html += "<tr>"
for cell in row:
html += f"<td>{cell}</td>"
html += "</tr>"
html += "</tbody></table></div>"
return {
"vault": vault_name, "path": path,
"title": file_path.name, "tags": [], "frontmatter": {},
"html": html, "raw_length": len(raw), "extension": ext,
"is_markdown": False, "is_csv": True,
}
# === JSON: syntax-highlighted display ===
if ext == ".json":
import json as json_mod
try:
parsed = json_mod.loads(raw)
formatted = json_mod.dumps(parsed, indent=2, ensure_ascii=False)
except json_mod.JSONDecodeError:
formatted = raw
html = f"<pre class='json-viewer'><code>{html_mod.escape(formatted)}</code></pre>"
return {
"vault": vault_name, "path": path,
"title": file_path.name, "tags": [], "frontmatter": {},
"html": html, "raw_length": len(raw), "extension": ext,
"is_markdown": False, "is_json": True,
}
# === Excalidraw .excalidraw.md (Obsidian plugin format) ===
if path.lower().endswith(".excalidraw.md"):
import re as re_mod
raw_lower = file_path.read_text(encoding="utf-8", errors="replace")
# Check for excalidraw-plugin in frontmatter or body
if "excalidraw-plugin:" in raw_lower:
# Extract compressed JSON block
match = re_mod.search(r'```compressed-json\n(.*?)\n```', raw_lower, re_mod.DOTALL)
if match:
compressed = match.group(1).strip()
return {
"vault": vault_name, "path": path,
"title": file_path.name.replace(".excalidraw.md", ""),
"tags": [], "frontmatter": {},
"html": "", "raw_length": len(raw_lower),
"extension": ".excalidraw.md",
"is_markdown": False,
"is_excalidraw": True,
"excalidraw_data_compressed": compressed,
}
# Fallback: treat as regular markdown
raw = raw_lower
if ext == ".excalidraw":
import json as json_mod
try:
parsed = json_mod.loads(raw)
except json_mod.JSONDecodeError:
parsed = None
if parsed and parsed.get("type") == "excalidraw":
return {
"vault": vault_name,
"path": path,
"title": parsed.get("appState", {}).get("name") or file_path.name,
"tags": [],
"frontmatter": {},
"html": "",
"raw_length": len(raw),
"extension": ext,
"is_markdown": False,
"is_excalidraw": True,
"excalidraw_data": {
"elements": parsed.get("elements", []),
"appState": parsed.get("appState", {}),
"files": parsed.get("files", {}),
},
}
else:
# Not a valid Excalidraw file — fall through to text viewer
pass
# === Plain text / other readable files ===
TEXT_EXTENSIONS = {".txt", ".log", ".yml", ".yaml", ".toml", ".ini", ".cfg",
".sh", ".bash", ".py", ".js", ".ts", ".html", ".css",
".xml", ".rst", ".tex", ".sql", ".conf", ".env"}
if ext in TEXT_EXTENSIONS or ext == ".md":
pass # handled below or by markdown section
if ext == ".md":
post = parse_markdown_file(raw)
# Extract metadata using shared indexer logic
tags = _extract_tags(post)
title = post.metadata.get("title", file_path.stem.replace("-", " ").replace("_", " "))
html_content = _render_markdown(post.content, vault_name, file_path)
return {
"vault": vault_name,
"path": path,
"title": str(title),
"tags": tags,
"frontmatter": dict(post.metadata) if post.metadata else {},
"html": html_content,
"raw_length": len(raw),
"extension": ext,
"is_markdown": True,
}
else:
# Non-markdown: wrap in syntax-highlighted code block
lang = EXT_TO_LANG.get(ext, "")
if not lang:
# Fichiers sans extension usuels (Dockerfile, Makefile, etc.)
NAME_TO_LANG = {
"dockerfile": "dockerfile", "makefile": "makefile",
"cmakelists.txt": "cmake", "jenkinsfile": "groovy",
"vagrantfile": "ruby", "rakefile": "ruby", "gemfile": "ruby",
"procfile": "plaintext", "bashrc": "bash", "bash_profile": "bash",
"zshrc": "bash", "profile": "bash", "gitignore": "plaintext",
}
lang = NAME_TO_LANG.get(file_path.name.lower(), "plaintext")
escaped = html_mod.escape(raw)
html_content = f'<pre><code class="language-{lang}">{escaped}</code></pre>'
return {
"vault": vault_name,
"path": path,
"title": file_path.name,
"tags": [],
"frontmatter": {},
"html": html_content,
"raw_length": len(raw),
"extension": ext,
"is_markdown": False,
}
# File backlinks + view endpoints : voir backend.routers.files_read (#85 T6a)
def _stream_file_with_range(file_path: Path, request: Request, media_type: str):
@@ -3379,9 +2814,7 @@ async def api_dashboard(current_user=Depends(require_auth)):
# Share (public document) endpoints : voir backend.routers.sharing (#85 T3)
# ---------------------------------------------------------------------------
@app.get("/s/{token}", response_class=HTMLResponse)
# ---------------------------------------------------------------------------
# Syncthing conflict endpoints
# ---------------------------------------------------------------------------
# Syncthing conflict endpoints
# ---------------------------------------------------------------------------