fix(pdf,excalidraw): BUG-001 + BUG-002
CI / lint (push) Successful in 47s
CI / security (push) Successful in 32s
CI / test (push) Successful in 1m0s
CI / build (push) Successful in 30s
CI / e2e (push) Successful in 9m22s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
CI / lint (push) Successful in 47s
CI / security (push) Successful in 32s
CI / test (push) Successful in 1m0s
CI / build (push) Successful in 30s
CI / e2e (push) Successful in 9m22s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
BUG-001 (PDF stream 500) : le nom Unicode du fichier etait injecte brut dans Content-Disposition -> en-tete HTTP invalide -> 500. Nouveau helper _content_disposition (RFC 5987 : filename ASCII + filename*=UTF-8''). Applique aux 2 branches /pdf/stream. Test: tests/test_pdf_stream.py. BUG-002 (Excalidraw 'Loading...' infini) : deux causes. 1) L'import esm.sh avec ?alias=react:... etait propage a chaque dep transitive; esm.sh renvoie 408 sur les builds a gamme semver (jotai@>=2.9.2) -> import entier echoue. On retire l'alias et on utilise React 19 coherent (spec a gamme + target identique a celle d'Excalidraw) -> plus de 408. 2) L'editeur passait la prop legacy 'excalidrawRef', inoperante en 0.18 (la prop reelle est 'excalidrawAPI') -> l'API n'etait jamais recue, le 'Loading' ne se masquait pas, save/export morts. Verifie : 534 tests backend verts (dont 3 nouveaux PDF) + E2E navigateur (loading masque + cycle requestSave->save OK).
This commit is contained in:
+18
-2
@@ -735,6 +735,22 @@ FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
|
|||||||
# Path safety helper
|
# Path safety helper
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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 _resolve_safe_path(vault_root: Path, relative_path: str) -> Path:
|
def _resolve_safe_path(vault_root: Path, relative_path: str) -> Path:
|
||||||
"""Resolve a relative path safely within the vault root.
|
"""Resolve a relative path safely within the vault root.
|
||||||
|
|
||||||
@@ -2801,13 +2817,13 @@ async def api_pdf_stream(
|
|||||||
"Content-Range": f"bytes {start}-{end}/{file_size}",
|
"Content-Range": f"bytes {start}-{end}/{file_size}",
|
||||||
"Accept-Ranges": "bytes",
|
"Accept-Ranges": "bytes",
|
||||||
"Content-Length": str(chunk_size),
|
"Content-Length": str(chunk_size),
|
||||||
"Content-Disposition": f'inline; filename="{file_path.name}"',
|
"Content-Disposition": _content_disposition("inline", file_path.name),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
return FileResponse(str(file_path), media_type="application/pdf", headers={
|
return FileResponse(str(file_path), media_type="application/pdf", headers={
|
||||||
"Accept-Ranges": "bytes",
|
"Accept-Ranges": "bytes",
|
||||||
"Content-Disposition": f'inline; filename="{file_path.name}"'})
|
"Content-Disposition": _content_disposition("inline", file_path.name)})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/file/{vault_name}/pdf/info")
|
@app.get("/api/file/{vault_name}/pdf/info")
|
||||||
|
|||||||
@@ -107,9 +107,8 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
|||||||
|
|
||||||
| # | Titre | Statut | Priorité | Scope | Assigné | Zone (fichier) | Cmd de repro | Correctif / Commit | Notes |
|
| # | Titre | Statut | Priorité | Scope | Assigné | Zone (fichier) | Cmd de repro | Correctif / Commit | Notes |
|
||||||
|---|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
| *BUG-001* | L'ouverture des fichier PDF ne fonctionne pas et donne l'erreur Internal Server Error | 🔴 ouvert | P1 | fichier PDF | IA | |
|
| *BUG-001* | L'ouverture des fichier PDF ne fonctionne pas et donne l'erreur Internal Server Error | 🟢 corrigé | P1 | fichier PDF | IA | `backend/main.py` | `GET /api/file/{vault}/pdf/stream?path=…(pdf à nom accentué)` | `backend/main.py` : Content-Disposition encodé RFC 5987 (helper `_content_disposition`) | 500 car nom Unicode brut dans l'en-tête → header invalide. Vérifié: stream 200 / Range 206 + test `tests/test_pdf_stream.py` |
|
||||||
https://og.dracodev.net/api/file/Recettes/pdf/stream?path=98_Boite_Outils%2F98.2_Attachments%2FBi%C3%A8re%20blonde%20envoy%C3%A9%20par%20Desja.pdf| — | l'erreur se retrouve dans la section network de la console web et dans l'interface web lors du chargement d'un pdf |
|
| *BUG-002* | l'ouverture d'un fichier .excalidraw ne fonctionne pas et affiche toujours Loading *Excalidraw…* | 🟢 corrigé | P1 | fichier .excalidraw | IA | `frontend/excalidraw-editor.html` | Ouvrir un fichier `.excalidraw` | `frontend/excalidraw-editor.html` : alias esm.sh supprimé (408 jotai) + React 19 cohérent + prop `excalidrawAPI` | 2 causes: 408 esm.sh sur `?alias` + prop legacy `excalidrawRef` inopérante en 0.18. Vérifié navigateur: Loading masqué + cycle save OK |
|
||||||
| *BUG-002* | l'ouverture d'un fichier .excalidraw ne fonctionne pas et affiche toujours Loading *Excalidraw…* | 🔴 ouvert | P1 | fichier .excalidaw | IA | | | — | test d'accès réaliser via cloudflare et réseau local démontre que ce problème est au 2 endroits |
|
|
||||||
| | | | | | | | | | |
|
| | | | | | | | | | |
|
||||||
|
|
||||||
### TODOs techniques (améliorations / nouvelles tâches)
|
### TODOs techniques (améliorations / nouvelles tâches)
|
||||||
@@ -130,6 +129,7 @@ https://og.dracodev.net/api/file/Recettes/pdf/stream?path=98_Boite_Outils%2F98.2
|
|||||||
| Date | ID(s) traité(s) | Action | Fichiers modifiés | Résumé | Statut après |
|
| Date | ID(s) traité(s) | Action | Fichiers modifiés | Résumé | Statut après |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| *(exemple)* 2026-06-15 | BUG-001 | Correction | `frontend/app.js` | Réécriture de `renderFile()` pour préserver le DOM dashboard | 🟢 corrigé (en attente vérif) |
|
| *(exemple)* 2026-06-15 | BUG-001 | Correction | `frontend/app.js` | Réécriture de `renderFile()` pour préserver le DOM dashboard | 🟢 corrigé (en attente vérif) |
|
||||||
|
| 2026-09-09 | BUG-001, BUG-002 | Correction | `backend/main.py`, `frontend/excalidraw-editor.html`, `tests/test_pdf_stream.py` | BUG-001: Content-Disposition RFC 5987 (nom PDF accentué ne casse plus l'en-tête → plus de 500). BUG-002: suppression alias esm.sh (408 jotai) + React 19 cohérent + prop `excalidrawAPI` → Loading masqué, save OK. Vérifié: 534 tests backend verts + E2E navigateur. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -92,26 +92,20 @@
|
|||||||
<button id="btn-export-svg" title="Export SVG">📐 SVG</button>
|
<button id="btn-export-svg" title="Export SVG">📐 SVG</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Import map: force ALL modules (Excalidraw + sub-deps) to use React 18.3.1 -->
|
<!-- Excalidraw is loaded from esm.sh WITHOUT the `?alias=react:…` query.
|
||||||
<script type="importmap">
|
The alias gets propagated by esm.sh to every transitive dependency; for
|
||||||
{
|
range-version deps (e.g. jotai@>=2.9.2) esm.sh then tries to rebuild the
|
||||||
"imports": {
|
range + alias variant and times out (HTTP 408) → the whole import fails →
|
||||||
"react": "https://esm.sh/[email protected]",
|
the diagram stays stuck on "Loading Excalidraw…". Dropping the alias lets
|
||||||
"react-dom": "https://esm.sh/[email protected]",
|
esm.sh resolve a consistent React 19 (Excalidraw 0.18's default), which
|
||||||
"react-dom/": "https://esm.sh/[email protected]/"
|
loads reliably. -->
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Single module script -->
|
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import React from "react";
|
import React from "https://esm.sh/react@^19.2.0?target=es2022";
|
||||||
import ReactDOM from "react-dom/client";
|
import { createRoot } from "https://esm.sh/react-dom@^19.2.0/client?target=es2022";
|
||||||
import { decompressFromBase64 } from "https://esm.sh/[email protected]";
|
import { decompressFromBase64 } from "https://esm.sh/[email protected]";
|
||||||
import * as ExcalidrawLib from "https://esm.sh/@excalidraw/[email protected]?alias=react:[email protected],react-dom:[email protected]";
|
import * as ExcalidrawLib from "https://esm.sh/@excalidraw/[email protected]";
|
||||||
|
|
||||||
window.React = React;
|
window.React = React;
|
||||||
window.ReactDOM = ReactDOM;
|
|
||||||
window.EXCALIDRAW_ASSET_PATH = "https://esm.sh/@excalidraw/[email protected]/dist/prod/";
|
window.EXCALIDRAW_ASSET_PATH = "https://esm.sh/@excalidraw/[email protected]/dist/prod/";
|
||||||
window.ExcalidrawLib = ExcalidrawLib;
|
window.ExcalidrawLib = ExcalidrawLib;
|
||||||
|
|
||||||
@@ -186,7 +180,11 @@
|
|||||||
function App({ initialData, theme }) {
|
function App({ initialData, theme }) {
|
||||||
const [appState, setAppState] = React.useState(null);
|
const [appState, setAppState] = React.useState(null);
|
||||||
|
|
||||||
const excalidrawRef = React.useCallback((api) => {
|
// Excalidraw 0.18 exposes its imperative API through the `excalidrawAPI`
|
||||||
|
// prop, called with the API object once mounted (NOT the legacy
|
||||||
|
// `excalidrawRef` name). Without this the loading overlay never hides
|
||||||
|
// and save/export stay dead.
|
||||||
|
const onReady = React.useCallback((api) => {
|
||||||
if (api) {
|
if (api) {
|
||||||
excalidrawAPI = api;
|
excalidrawAPI = api;
|
||||||
loadingEl.classList.add("hidden");
|
loadingEl.classList.add("hidden");
|
||||||
@@ -201,7 +199,7 @@
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return React.createElement(Excalidraw, {
|
return React.createElement(Excalidraw, {
|
||||||
excalidrawRef: excalidrawRef,
|
excalidrawAPI: onReady,
|
||||||
initialData: initialData,
|
initialData: initialData,
|
||||||
onChange: onChange,
|
onChange: onChange,
|
||||||
theme: theme,
|
theme: theme,
|
||||||
@@ -247,10 +245,10 @@
|
|||||||
setTheme(currentTheme);
|
setTheme(currentTheme);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (typeof ReactDOM.createRoot !== "function") {
|
if (typeof createRoot !== "function") {
|
||||||
throw new Error("ReactDOM.createRoot is not available — check react-dom/client import");
|
throw new Error("createRoot is not available — check react-dom/client import");
|
||||||
}
|
}
|
||||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
createRoot(document.getElementById("root")).render(
|
||||||
React.createElement(App, {
|
React.createElement(App, {
|
||||||
initialData: initialData,
|
initialData: initialData,
|
||||||
theme: currentTheme,
|
theme: currentTheme,
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# tests/test_pdf_stream.py — Regression tests for BUG-001 (PDF stream HTTP 500)
|
||||||
|
#
|
||||||
|
# BUG-001: opening a PDF whose filename contains accented characters
|
||||||
|
# (e.g. "Bière blonde envoyé par Desja.pdf") returned HTTP 500 because the raw
|
||||||
|
# Unicode name was injected into the Content-Disposition header, producing an
|
||||||
|
# invalid (non-ASCII) header value. /pdf/info worked because it sets no
|
||||||
|
# filename header.
|
||||||
|
#
|
||||||
|
# Fix: RFC 5987 header encoding (ASCII `filename` fallback + `filename*=UTF-8''…`).
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Minimal valid PDF so pypdf/stream treats the bytes as a real PDF.
|
||||||
|
MIN_PDF = (
|
||||||
|
b"%PDF-1.4\n"
|
||||||
|
b"1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
|
||||||
|
b"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
|
||||||
|
b"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj\n"
|
||||||
|
b"xref\n0 4\n0000000000 65535 f \n"
|
||||||
|
b"trailer<</Size 4/Root 1 0 R>>\nstartxref\n0\n%%EOF\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_pdf_with_unicode_name():
|
||||||
|
vault = Path(os.environ["VAULT_1_PATH"])
|
||||||
|
sub = vault / "98_Boite_Outils" / "98.2_Attachments"
|
||||||
|
sub.mkdir(parents=True, exist_ok=True)
|
||||||
|
pdf = sub / "Bière blonde envoyé par Desja.pdf"
|
||||||
|
pdf.write_bytes(MIN_PDF)
|
||||||
|
return "98_Boite_Outils/98.2_Attachments/Bière blonde envoyé par Desja.pdf"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPdfStreamUnicodeFilename:
|
||||||
|
def test_full_stream_ok(self, client):
|
||||||
|
rel = _create_pdf_with_unicode_name()
|
||||||
|
resp = client.get("/api/file/TestVault/pdf/stream", params={"path": rel})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.headers["content-type"] == "application/pdf"
|
||||||
|
cd = resp.headers.get("content-disposition", "")
|
||||||
|
# Content-Disposition must be RFC 5987 encoded (ASCII-safe)
|
||||||
|
assert "filename*=UTF-8''" in cd, cd
|
||||||
|
# UTF-8 percent-encoded accented name present (é -> %C3%A8)
|
||||||
|
assert "%C3%A8re%20blonde" in cd
|
||||||
|
|
||||||
|
def test_range_request_ok(self, client):
|
||||||
|
rel = _create_pdf_with_unicode_name()
|
||||||
|
resp = client.get(
|
||||||
|
"/api/file/TestVault/pdf/stream",
|
||||||
|
params={"path": rel},
|
||||||
|
headers={"Range": "bytes=0-99"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 206
|
||||||
|
assert resp.headers.get("content-range", "").startswith("bytes 0-99/")
|
||||||
|
|
||||||
|
def test_disposition_ascii_safe(self, client):
|
||||||
|
rel = _create_pdf_with_unicode_name()
|
||||||
|
resp = client.get("/api/file/TestVault/pdf/stream", params={"path": rel})
|
||||||
|
cd = resp.headers.get("content-disposition", "")
|
||||||
|
# Every header field value must be ASCII-encodable (no UnicodeEncodeError)
|
||||||
|
cd.encode("ascii")
|
||||||
|
# ASCII fallback filename present
|
||||||
|
assert 'filename="' in cd
|
||||||
Reference in New Issue
Block a user