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)) + } +}