From 78423276e73fc961e8937899a4605fe17cadca8d Mon Sep 17 00:00:00 2001 From: nathaninline Date: Mon, 10 Aug 2026 20:29:57 +0200 Subject: [PATCH] Telecharger un fichier depuis ajean.link rendait du JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le proxy chiffre (relay_e2e.go) reemballe TOUTE reponse en JSON avant de la renvoyer : une reponse non-JSON est enveloppee en chaine. Du binaire n'y survit pas — les octets non-UTF8 sont massacres — et le navigateur telechargeait l'enveloppe JSON a la place du fichier. Le probleme est structurel, pas propre au telechargement : il touchait aussi l'export Markdown de conversation, qui revenait entre guillemets, echappements compris. handleChatFile prend donc deux formes supplementaires : `meta=1` rend une fiche {name, size, e2e}, et `b64=1&offset=&len=` une tranche en base64. Le client demande la fiche d'abord — minuscule, elle passe partout — et en tire le transport a utiliser : binaire direct en local, tranches base64 derriere le tunnel. Le client ne peut pas le deviner autrement, le proxy lui rendant des reponses JSON parfaitement ordinaires. Le decoupage en tranches de 8 Mo evite de tenir le fichier entier en memoire pour le transporter, comme a l'envoi. Verifie sur 120 Mo : les deux chemins rendent exactement les memes 125 829 120 octets, et le test Go compare octet a octet sur des donnees non-UTF8, celles que le reemballage JSON detruisait. relay_e2e.go marque desormais les requetes venues du tunnel (X-Ajean-E2E) : c'est le seul moyen pour un handler de savoir que sa reponse sera reemballee. --- internal/ajean/relay_e2e.go | 5 ++ internal/ajean/ui/index.html | 69 ++++++++++++++++++++---- internal/ajean/ui/src/js/06-settings.js | 16 ++++-- internal/ajean/ui/src/js/14-attach.js | 53 +++++++++++++++--- internal/ajean/web_upload.go | 69 ++++++++++++++++++++++++ internal/ajean/web_upload_test.go | 71 +++++++++++++++++++++++++ 6 files changed, 261 insertions(+), 22 deletions(-) diff --git a/internal/ajean/relay_e2e.go b/internal/ajean/relay_e2e.go index 3c8f0c7..33aecdb 100644 --- a/internal/ajean/relay_e2e.go +++ b/internal/ajean/relay_e2e.go @@ -232,6 +232,11 @@ func handleE2EReq(w http.ResponseWriter, r *http.Request, inner http.Handler) { } ir := httptest.NewRequest(method, req.Path, bodyReader) ir.Header.Set("Content-Type", "application/json") + // Marque la requête comme arrivée PAR LE TUNNEL. Tout ce qui ressort d'ici est + // réemballé en JSON (voir plus bas) : un handler qui renvoie du binaire — le + // téléchargement d'un fichier — doit le savoir pour proposer une forme qui + // traverse, sans quoi le client reçoit l'enveloppe JSON à la place du fichier. + ir.Header.Set(e2eInnerHeader, "1") rec := httptest.NewRecorder() inner.ServeHTTP(rec, ir) respBody := rec.Body.Bytes() diff --git a/internal/ajean/ui/index.html b/internal/ajean/ui/index.html index 4090656..792a0e7 100644 --- a/internal/ajean/ui/index.html +++ b/internal/ajean/ui/index.html @@ -2713,13 +2713,21 @@ async function downloadExport(url){ try{ const r = await jfetch(url); if(!r.ok){ toast('erreur : HTTP ' + r.status); return; } - const blob = await r.blob(); + // Derrière app.ajean.link, le proxy chiffré réemballe TOUTE réponse en JSON : + // un export Markdown revenait donc comme une chaîne JSON entre guillemets, + // échappements compris. On la déballe. En local, rien à faire — le type de + // contenu n'est pas du JSON, et un export JSON est déjà à sa place. + let text = await r.text(); + if((r.headers.get('Content-Type')||'').includes('json')){ + try{ const v = JSON.parse(text); if(typeof v === 'string') text = v; }catch(_){} + } + const blob = new Blob([text]); // Nom du fichier : celui proposé par le serveur (horodaté), à défaut un nom - // local déduit du type renvoyé. + // déduit du format DEMANDÉ — le type renvoyé ne dit plus rien derrière le + // tunnel, où tout arrive en application/json. const cd = r.headers.get('Content-Disposition') || ''; const m = cd.match(/filename="([^"]+)"/); - const name = m ? m[1] - : 'ajean-conversation.' + ((r.headers.get('Content-Type')||'').includes('json') ? 'json' : 'md'); + const name = m ? m[1] : 'ajean-conversation.' + (url.includes('format=json') ? 'json' : 'md'); // ⚠️ blobURL, surtout pas `url` : ce nom est déjà celui du paramètre, et le // redéclarer ici mettrait la ligne `jfetch(url)` ci-dessus dans la zone morte // du const — l'export échouerait avant même de partir. @@ -5089,23 +5097,62 @@ function hasMsgFiles(el){ // Un simple href ne suffirait pas : la clé de pilotage voyage dans un en-tête // Authorization que le navigateur ne mettrait pas sur une navigation, et le // chemin de base change derrière le tunnel (/u/). D'où le fetch + blob. +const DL_CHUNK = 8*1024*1024; // aligné sur downloadChunkMax (web_upload.go) +// Décode une tranche base64 en octets. +function b64ToBytes(s){ + const bin=atob(s), out=new Uint8Array(bin.length); + for(let i=0;i({})); + if(!m.ok || !meta.ok){ + toast('téléchargement impossible : '+(meta.error||('HTTP '+m.status))); return; } - const blobURL=URL.createObjectURL(await r.blob()); + let blob; + if(meta.e2e){ + blob=await fetchFileB64(path, meta.size||0, a); + } else { + const r=await jfetch('/api/chat/file?path='+encodeURIComponent(path)); + if(!r.ok){ + let msg='HTTP '+r.status; try{ msg=(await r.json()).error||msg; }catch(_){} + toast('téléchargement impossible : '+msg); return; + } + blob=await r.blob(); + } + const blobURL=URL.createObjectURL(blob); const link=document.createElement('a'); - link.href=blobURL; link.download=name||'fichier'; + link.href=blobURL; link.download=name||meta.name||'fichier'; document.body.appendChild(link); link.click(); link.remove(); // Révocation différée : Safari annule le téléchargement si l'URL disparaît // dans la foulée du clic. setTimeout(()=>URL.revokeObjectURL(blobURL), 10000); }catch(e){ toast('téléchargement impossible : '+((e&&e.message)||'erreur')); } - finally{ if(a) a.classList.remove('busy'); } + finally{ if(a){ a.classList.remove('busy'); a.title=wasTitle; } } } // Markdown refuse les espaces NON échappés dans la cible d'un lien : le modèle // écrit [le rapport](mon rapport.pdf), et rien n'est rendu du tout — même pas un diff --git a/internal/ajean/ui/src/js/06-settings.js b/internal/ajean/ui/src/js/06-settings.js index 1b39b46..46a3fce 100644 --- a/internal/ajean/ui/src/js/06-settings.js +++ b/internal/ajean/ui/src/js/06-settings.js @@ -353,13 +353,21 @@ async function downloadExport(url){ try{ const r = await jfetch(url); if(!r.ok){ toast('erreur : HTTP ' + r.status); return; } - const blob = await r.blob(); + // Derrière app.ajean.link, le proxy chiffré réemballe TOUTE réponse en JSON : + // un export Markdown revenait donc comme une chaîne JSON entre guillemets, + // échappements compris. On la déballe. En local, rien à faire — le type de + // contenu n'est pas du JSON, et un export JSON est déjà à sa place. + let text = await r.text(); + if((r.headers.get('Content-Type')||'').includes('json')){ + try{ const v = JSON.parse(text); if(typeof v === 'string') text = v; }catch(_){} + } + const blob = new Blob([text]); // Nom du fichier : celui proposé par le serveur (horodaté), à défaut un nom - // local déduit du type renvoyé. + // déduit du format DEMANDÉ — le type renvoyé ne dit plus rien derrière le + // tunnel, où tout arrive en application/json. const cd = r.headers.get('Content-Disposition') || ''; const m = cd.match(/filename="([^"]+)"/); - const name = m ? m[1] - : 'ajean-conversation.' + ((r.headers.get('Content-Type')||'').includes('json') ? 'json' : 'md'); + const name = m ? m[1] : 'ajean-conversation.' + (url.includes('format=json') ? 'json' : 'md'); // ⚠️ blobURL, surtout pas `url` : ce nom est déjà celui du paramètre, et le // redéclarer ici mettrait la ligne `jfetch(url)` ci-dessus dans la zone morte // du const — l'export échouerait avant même de partir. diff --git a/internal/ajean/ui/src/js/14-attach.js b/internal/ajean/ui/src/js/14-attach.js index fdc50c4..5a79272 100644 --- a/internal/ajean/ui/src/js/14-attach.js +++ b/internal/ajean/ui/src/js/14-attach.js @@ -76,23 +76,62 @@ function hasMsgFiles(el){ // Un simple href ne suffirait pas : la clé de pilotage voyage dans un en-tête // Authorization que le navigateur ne mettrait pas sur une navigation, et le // chemin de base change derrière le tunnel (/u/). D'où le fetch + blob. +const DL_CHUNK = 8*1024*1024; // aligné sur downloadChunkMax (web_upload.go) +// Décode une tranche base64 en octets. +function b64ToBytes(s){ + const bin=atob(s), out=new Uint8Array(bin.length); + for(let i=0;i({})); + if(!m.ok || !meta.ok){ + toast('téléchargement impossible : '+(meta.error||('HTTP '+m.status))); return; } - const blobURL=URL.createObjectURL(await r.blob()); + let blob; + if(meta.e2e){ + blob=await fetchFileB64(path, meta.size||0, a); + } else { + const r=await jfetch('/api/chat/file?path='+encodeURIComponent(path)); + if(!r.ok){ + let msg='HTTP '+r.status; try{ msg=(await r.json()).error||msg; }catch(_){} + toast('téléchargement impossible : '+msg); return; + } + blob=await r.blob(); + } + const blobURL=URL.createObjectURL(blob); const link=document.createElement('a'); - link.href=blobURL; link.download=name||'fichier'; + link.href=blobURL; link.download=name||meta.name||'fichier'; document.body.appendChild(link); link.click(); link.remove(); // Révocation différée : Safari annule le téléchargement si l'URL disparaît // dans la foulée du clic. setTimeout(()=>URL.revokeObjectURL(blobURL), 10000); }catch(e){ toast('téléchargement impossible : '+((e&&e.message)||'erreur')); } - finally{ if(a) a.classList.remove('busy'); } + finally{ if(a){ a.classList.remove('busy'); a.title=wasTitle; } } } // Markdown refuse les espaces NON échappés dans la cible d'un lien : le modèle // écrit [le rapport](mon rapport.pdf), et rien n'est rendu du tout — même pas un diff --git a/internal/ajean/web_upload.go b/internal/ajean/web_upload.go index e032225..a11c5a0 100644 --- a/internal/ajean/web_upload.go +++ b/internal/ajean/web_upload.go @@ -14,10 +14,12 @@ import ( "encoding/base64" "encoding/json" "fmt" + "io" "net/http" "net/url" "os" "path/filepath" + "strconv" "strings" "sync" "time" @@ -34,6 +36,11 @@ const ( // 8 Mo décodés ≈ 11 Mo de base64. C'est aussi la taille demandée au client ; // un morceau plus gros est refusé plutôt que d'être avalé. uploadChunkMax = 8 << 20 + + // downloadChunkMax borne UNE tranche de téléchargement encodée en base64 (voir + // handleChatFile). Même logique que pour l'envoi : c'est la mémoire du process + // qu'on protège, pas la taille du fichier. + downloadChunkMax = 8 << 20 ) // uploadsDir renvoie (en le créant) le dossier de dépôt, dans le workspace agent. @@ -172,8 +179,25 @@ func workspaceRel(abs string) (string, bool) { return filepath.ToSlash(rel), true } +// e2eInnerHeader marque une requête dispatchée depuis le proxy chiffré +// (relay_e2e.go). Cf. handleChatFile : c'est le seul moyen pour un handler de +// savoir que sa réponse sera réemballée en JSON. +const e2eInnerHeader = "X-Ajean-E2E" + // handleChatFile sert un fichier du dossier de travail, en pièce jointe. Le // client passe le chemin RELATIF que le modèle a écrit dans sa réponse. +// +// Trois formes de réponse, pour une raison de transport : +// - par défaut, le fichier brut — le chemin direct, en local ou sur le LAN ; +// - `meta=1`, une fiche {name, size, e2e} : le client y apprend s'il est +// derrière le tunnel, donc quelle forme demander ensuite ; +// - `b64=1&offset=&len=`, une tranche encodée en base64. +// +// La raison : à travers app.ajean.link, TOUTE réponse est réemballée en JSON par +// le proxy chiffré. Du binaire n'y survit pas — les octets non-UTF8 sont +// massacrés, et on téléchargeait une enveloppe JSON au lieu du fichier. Le +// base64 traverse, et le découpage en tranches évite de tenir un gigaoctet en +// mémoire pour le transporter. func handleChatFile(w http.ResponseWriter, r *http.Request) { rel := r.URL.Query().Get("path") if strings.TrimSpace(rel) == "" { @@ -193,6 +217,51 @@ func handleChatFile(w http.ResponseWriter, r *http.Request) { return } name := filepath.Base(abs) + q := r.URL.Query() + if q.Get("meta") != "" { + sendJSON(w, 200, map[string]any{ + "ok": true, "name": name, "size": st.Size(), + // Le client ne peut pas deviner seul qu'il passe par le tunnel : le + // proxy lui rend des réponses JSON parfaitement ordinaires. + "e2e": r.Header.Get(e2eInnerHeader) != "", + }) + return + } + if q.Get("b64") != "" { + off, _ := strconv.ParseInt(q.Get("offset"), 10, 64) + length, _ := strconv.ParseInt(q.Get("len"), 10, 64) + if length <= 0 || length > downloadChunkMax { + length = downloadChunkMax + } + if off < 0 || off > st.Size() { + sendJSON(w, 400, map[string]any{"ok": false, "error": "position hors du fichier"}) + return + } + if off+length > st.Size() { + length = st.Size() - off + } + f, err := os.Open(abs) + if err != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + defer f.Close() + buf := make([]byte, length) + // ReadAt : positionne et lit en une fois, et remplit tout le tampon (ce + // qu'un simple Read ne garantit pas). io.EOF sur la dernière tranche est + // normal, pas une erreur. + n, err := f.ReadAt(buf, off) + if err != nil && err != io.EOF { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{ + "ok": true, "name": name, "size": st.Size(), "offset": off, + "data": base64.StdEncoding.EncodeToString(buf[:n]), + "eof": off+int64(n) >= st.Size(), + }) + return + } // Toujours en TÉLÉCHARGEMENT, jamais rendu : un .html écrit par le modèle ne // doit pas s'exécuter dans l'origine de l'UI (il y lirait la clé de pilotage). w.Header().Set("Content-Type", "application/octet-stream") diff --git a/internal/ajean/web_upload_test.go b/internal/ajean/web_upload_test.go index a7fffa1..686777f 100644 --- a/internal/ajean/web_upload_test.go +++ b/internal/ajean/web_upload_test.go @@ -1,8 +1,10 @@ package ajean import ( + "bytes" "encoding/base64" "encoding/json" + "fmt" "net/http/httptest" "net/url" "os" @@ -231,3 +233,72 @@ func TestChatUploadUnknownSessionRefused(t *testing.T) { t.Fatalf("code %d, attendu 409 — resp %+v", code, resp) } } + +// Le téléchargement via app.ajean.link : le proxy chiffré réemballe toute +// réponse en JSON, où du binaire ne survit pas. Le handler doit donc annoncer +// qu'on est derrière le tunnel, puis savoir servir des tranches base64. +func TestChatFileB64ForE2E(t *testing.T) { + dir, _ := uploadsDir() + // Des octets NON-UTF8 : c'est exactement ce que le réemballage JSON massacre. + raw := make([]byte, 5000) + for i := range raw { + raw[i] = byte(i % 256) + } + p := filepath.Join(dir, "binaire.bin") + if err := os.WriteFile(p, raw, 0o644); err != nil { + t.Fatal(err) + } + defer os.Remove(p) + + // La fiche dit « tunnel » quand la requête vient du proxy, et pas autrement. + meta := func(viaTunnel bool) map[string]any { + req := httptest.NewRequest("GET", "/api/chat/file?meta=1&path=uploads/binaire.bin", nil) + if viaTunnel { + req.Header.Set(e2eInnerHeader, "1") + } + rec := httptest.NewRecorder() + handleChatFile(rec, req) + var m map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &m) + return m + } + if m := meta(true); m["e2e"] != true || m["size"].(float64) != 5000 { + t.Fatalf("fiche via tunnel : %+v", m) + } + if m := meta(false); m["e2e"] != false { + t.Fatalf("fiche en local annoncée comme tunnel : %+v", m) + } + + // Reconstitution par tranches : l'octet doit revenir intact. + var got []byte + for off, guard := 0, 0; ; guard++ { + if guard > 50 { + t.Fatal("boucle de tranches sans fin") + } + rec := httptest.NewRecorder() + handleChatFile(rec, httptest.NewRequest("GET", + fmt.Sprintf("/api/chat/file?b64=1&len=1024&offset=%d&path=uploads/binaire.bin", off), nil)) + if rec.Code != 200 { + t.Fatalf("tranche à %d : code %d — %s", off, rec.Code, rec.Body.String()) + } + var j struct { + Data string `json:"data"` + EOF bool `json:"eof"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &j); err != nil { + t.Fatal(err) + } + b, err := base64.StdEncoding.DecodeString(j.Data) + if err != nil { + t.Fatal(err) + } + got = append(got, b...) + off += len(b) + if j.EOF { + break + } + } + if !bytes.Equal(got, raw) { + t.Fatalf("fichier reconstitué : %d octets, attendu %d", len(got), len(raw)) + } +}