feat(pdf): #74 au complet — fix auth 500 stream + endpoint pdf/info + HTTP Range 206 + indexation incrementale PDF + config taille/timeout

This commit is contained in:
2026-09-07 23:24:13 -04:00
parent f37d5fda3c
commit 7042307955
7 changed files with 320 additions and 17 deletions
+105 -5
View File
@@ -19,7 +19,7 @@ from typing import Any
import frontmatter
import mistune
from fastapi import Body, Depends, FastAPI, HTTPException, Query
from fastapi import Body, Depends, FastAPI, HTTPException, Query, Request
from fastapi.responses import FileResponse, HTMLResponse, Response, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
@@ -2727,8 +2727,17 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
@app.get("/api/file/{vault_name}/pdf/stream")
async def api_pdf_stream(vault_name: str, path: str = Query(...)):
"""Stream a PDF file with Content-Type: application/pdf for inline browser viewing."""
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)
@@ -2740,9 +2749,100 @@ async def api_pdf_stream(vault_name: str, path: str = Query(...)):
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 fastapi.responses import FileResponse
file_size = file_path.stat().st_size
range_header = request.headers.get("range")
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():
# Open + reads offloaded to threads (avoid blocking the event loop — ASYNC230)
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="application/pdf",
headers={
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Accept-Ranges": "bytes",
"Content-Length": str(chunk_size),
"Content-Disposition": f'inline; filename="{file_path.name}"',
},
)
return FileResponse(str(file_path), media_type="application/pdf", headers={
"Content-Disposition": f"inline; filename=\"{file_path.name}\""})
"Accept-Ranges": "bytes",
"Content-Disposition": f'inline; filename="{file_path.name}"'})
@app.get("/api/file/{vault_name}/pdf/info")
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,
}
@app.get("/api/search", response_model=SearchResponse)