feat: support complet des images — arborescence, visionneuse, indexation #108
CI / lint (push) Successful in 1m57s
CI / security (push) Successful in 1m22s
CI / test (push) Successful in 4m32s
CI / build (push) Failing after 1m16s
CI / e2e (push) Skipped

This commit is contained in:
2026-09-23 07:47:04 -04:00
parent 69cee4d93a
commit eccbf7474e
31 changed files with 1317 additions and 138 deletions
+103 -33
View File
@@ -2,7 +2,6 @@ import asyncio
import html as html_mod
import json as _json
import logging
import mimetypes
import os
import re
import secrets
@@ -16,6 +15,7 @@ from datetime import datetime, timezone
from functools import partial
from pathlib import Path
from typing import Any
from urllib.parse import quote
import frontmatter
import mistune
@@ -52,6 +52,8 @@ from backend.indexer import (
remove_vault_from_index,
update_single_file,
)
from backend.media_thumbs import generate_thumbnail, is_decodable
from backend.media_types import IMAGE_EXTENSIONS, is_image, media_mime_type
from backend.openapi_docs import (
API_DESCRIPTION,
TAGS_METADATA,
@@ -699,20 +701,23 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
response.headers["X-Frame-Options"] = "SAMEORIGIN"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net https://esm.sh; "
"img-src 'self' data: blob:; "
"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net; "
"font-src 'self' data: https://fonts.gstatic.com https://esm.sh; "
"worker-src 'self' blob:; "
"frame-src 'self' blob:; "
"object-src 'none'; "
"base-uri 'self'; "
"form-action 'self'; "
"frame-ancestors 'self';"
)
# A route may set a stricter per-response policy (e.g. ``sandbox`` for
# standalone SVG, #108-B3); keep it instead of overwriting it.
if "Content-Security-Policy" not in response.headers:
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net https://esm.sh; "
"img-src 'self' data: blob:; "
"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net; "
"font-src 'self' data: https://fonts.gstatic.com https://esm.sh; "
"worker-src 'self' blob:; "
"frame-src 'self' blob:; "
"object-src 'none'; "
"base-uri 'self'; "
"form-action 'self'; "
"frame-ancestors 'self';"
)
# Static assets are NOT content-hashed, so they must revalidate:
# ``immutable``/long max-age made Cloudflare and mobile browsers serve
# a stale build for a year (the service worker cache compounded it).
@@ -2409,19 +2414,18 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
raise HTTPException(status_code=500, detail=f"Error reading PDF: {e!s}")
# === Images: return as viewable image ===
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".ico"}
if ext in IMAGE_EXTENSIONS:
if is_image(ext):
size = file_path.stat().st_size
mime_map = {
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".gif": "image/gif", ".svg": "image/svg+xml", ".webp": "image/webp",
".bmp": "image/bmp", ".ico": "image/x-icon",
}
mime = mime_map.get(ext, "application/octet-stream")
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="/api/file/{vault_name}/raw?path={path}" '
f'alt="{file_path.name}" style="max-width:100%;max-height:80vh;object-fit:contain" />'
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 {
@@ -3183,16 +3187,19 @@ async def api_image(vault_name: str, path: str = Query(..., description="Relativ
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail=f"Image not found: {path}")
# Determine MIME type
mime_type, _ = mimetypes.guess_type(str(file_path))
if not mime_type:
# Default to octet-stream if unknown
mime_type = "application/octet-stream"
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)
return Response(content=content, media_type=mime_type, headers=headers)
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied")
except Exception as e:
@@ -3200,6 +3207,55 @@ async def api_image(vault_name: str, path: str = Query(..., description="Relativ
raise HTTPException(status_code=500, detail=f"Error serving image: {e!s}")
@app.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)
@app.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.
@@ -4092,6 +4148,7 @@ async def api_dashboard(current_user=Depends(require_auth)):
total_files = 0
total_tags = set()
total_size = 0
total_images = 0
for vname, vdata in index.items():
if "*" not in user_vaults and vname not in user_vaults:
continue
@@ -4100,13 +4157,26 @@ async def api_dashboard(current_user=Depends(require_auth)):
total_files += fc
vtags = set()
vsize = 0
vimages = 0
for f in files:
vtags.update(f.get("tags", []))
vsize += f.get("size", 0)
if (f.get("extension") or "").lower() in IMAGE_EXTENSIONS:
vimages += 1
total_tags.update(vtags)
total_size += vsize
vault_stats.append({"name": vname, "file_count": fc, "tag_count": len(vtags), "total_size_bytes": vsize})
return {"vaults": vault_stats, "total_files": total_files, "total_tags": len(total_tags), "total_size_bytes": total_size}
total_images += vimages
vault_stats.append({
"name": vname, "file_count": fc, "tag_count": len(vtags),
"total_size_bytes": vsize, "image_count": vimages,
})
return {
"vaults": vault_stats,
"total_files": total_files,
"total_tags": len(total_tags),
"total_size_bytes": total_size,
"total_images": total_images,
}
# ---------------------------------------------------------------------------