// web_upload.go — dépôt de fichiers depuis le chat. // // L'utilisateur glisse un fichier dans le composeur ; il est écrit dans // uploads/ à l'intérieur du dossier de la DISCUSSION ouverte (chat_convfiles.go), // et son chemin RELATIF est joint au message. Le modèle en fait ce qu'il veut // avec ses outils (read, bash, edit) — on ne tente ni extraction ni // interprétation ici. // // Le transport est du JSON base64, et non du multipart : c'est la seule forme // qui traverse le tunnel E2E d'app.ajean.link (relay_e2e.go ne dispatche que des // corps JSON), donc l'accès distant marche sans code spécifique. package loki import ( "encoding/base64" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "strconv" "strings" "sync" "time" "unicode" ) const ( // uploadMaxBytes borne la taille d'UN fichier. Le fichier n'est jamais tenu en // mémoire (voir upSession) : cette limite protège le disque, pas la RAM. uploadMaxBytes = 1 << 30 // 1 Go // uploadChunkMax borne UN morceau, et c'est LUI qui décide de la mémoire du // process : un morceau est reçu en JSON, donc entièrement en RAM, puis décodé. // 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 de la discussion // ouverte. Un fichier joint appartient à la discussion où il a été déposé : il // n'apparaît pas dans les autres et part avec elle. func uploadsDir() (string, error) { dir := filepath.Join(convWorkspace(), uploadsSub) if err := os.MkdirAll(dir, 0o755); err != nil { return "", err } return dir, nil } // safeUploadName réduit un nom fourni par le client à un nom de fichier simple : // pas de dossier, pas de "..", pas de caractères de contrôle ni de séparateurs. // C'est la seule barrière entre un client hostile et une écriture arbitraire sur // le disque — l'API écoute sur 0.0.0.0 et n'a pas forcément de clé. func safeUploadName(name string) string { // Coupe tout ce qui ressemble à un chemin, dans les deux conventions : un nom // Windows arrive tel quel sur un serveur Linux, où filepath.Base ne verrait // pas les antislashs. name = strings.ReplaceAll(name, "\\", "/") name = filepath.Base(filepath.FromSlash(name)) var b strings.Builder for _, r := range name { switch { case unicode.IsControl(r), r == '/', r == '\\', r == ':', r == '*', r == '?', r == '"', r == '<', r == '>', r == '|': b.WriteRune('_') default: b.WriteRune(r) } } name = strings.TrimSpace(b.String()) name = strings.Trim(name, ".") // ".." et les noms cachés/vides if name == "" { name = "fichier" } // Un nom démesuré casse l'écriture sur certains systèmes de fichiers ; on // tronque la BASE en gardant l'extension, qui porte le sens. if len(name) > 120 { ext := filepath.Ext(name) if len(ext) > 16 { ext = "" } name = name[:120-len(ext)] + ext } return name } // uniqueUploadPath évite d'écraser un dépôt précédent : rapport.pdf, // rapport-2.pdf, rapport-3.pdf… func uniqueUploadPath(dir, name string) string { p := filepath.Join(dir, name) if _, err := os.Stat(p); os.IsNotExist(err) { return p } ext := filepath.Ext(name) base := strings.TrimSuffix(name, ext) for i := 2; i < 1000; i++ { p = filepath.Join(dir, fmt.Sprintf("%s-%d%s", base, i, ext)) if _, err := os.Stat(p); os.IsNotExist(err) { return p } } return p } // attachInfo décrit une pièce jointe retenue : ce que le modèle lira dans le // texte, et ce que l'UI affiche dans la bulle. type attachInfo struct { Name string `json:"name"` // nom seul, pour l'affichage Path string `json:"path"` // "uploads/", ce que le modèle reçoit Size int64 `json:"size"` } // attachFiles valide les chemins envoyés par le CLIENT : on les re-normalise en // uploads/ et on jette ceux qui ne désignent pas un dépôt existant, // plutôt que d'annoncer au modèle un fichier qu'il ne trouvera pas — ou de le // laisser pointer ailleurs sur le disque. func attachFiles(files []string) []attachInfo { dir, err := uploadsDir() if err != nil { return nil } var out []attachInfo for _, f := range files { name := safeUploadName(f) st, err := os.Stat(filepath.Join(dir, name)) if err != nil || st.IsDir() { continue } out = append(out, attachInfo{Name: name, Path: "uploads/" + name, Size: st.Size()}) } return out } // attachNote est la phrase ajoutée en tête du message pour le MODÈLE. Elle ne // s'affiche pas dans le chat : la bulle porte des pastilles de fichier (delta // `files`), parce qu'une consigne interne recopiée dans le fil se lit comme un // message que l'utilisateur n'a pas écrit. func attachNote(files []attachInfo) string { if len(files) == 0 { return "" } head := "Fichier joint à ce message, déposé dans ton dossier de travail :" if len(files) > 1 { head = "Fichiers joints à ce message, déposés dans ton dossier de travail :" } var lines []string for _, f := range files { lines = append(lines, fmt.Sprintf("- %s (%s)", f.Path, humanBytes(f.Size))) } return head + "\n" + strings.Join(lines, "\n") + "\n\n" } // imageMimes : les extensions qu'on peut ENVOYER AU MODÈLE comme image (contenu // multimodal), quand la vision est active. Un format hors de cette liste reste // un simple fichier du workspace, que le modèle ouvre avec ses outils. var imageMimes = map[string]string{ ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp", } // imageMime renvoie le type MIME image d'un nom de fichier, ou "" si ce n'est // pas une image qu'on sait montrer au modèle. func imageMime(name string) string { return imageMimes[strings.ToLower(filepath.Ext(name))] } // visionEnabled dit si un projecteur multimodal est configuré (clé MMPROJ) — // c'est la condition pour que backend_serve.go passe --mmproj au moteur, donc la // seule où envoyer une image AU MODÈLE a un sens. Sans projecteur, llama-server // rejetterait un contenu image ; on s'en tient alors au dépôt-fichier. func visionEnabled() bool { if externalActive() { return externalVisionActive() // modèle distant déclaré multimodal } return strings.TrimSpace(ReadConfig()["MMPROJ"]) != "" } // userMessageContent construit le champ Content du message utilisateur. Cas // courant : une simple chaîne (note des fichiers joints + texte). Quand la vision // est active ET qu'au moins une pièce jointe est une image, on renvoie le format // multimodal d'OpenAI — une partie `text` suivie d'une partie `image_url` par // image (data URI base64) — que llama-server comprend une fois --mmproj chargé : // le modèle VOIT alors l'image au lieu de devoir l'ouvrir comme un fichier binaire. // Les images ainsi intégrées sortent de la note textuelle (inutile de dire au // modèle d'aller ouvrir un fichier qu'il a déjà sous les yeux) ; les autres // fichiers, eux, restent annoncés comme avant. func userMessageContent(files []attachInfo, prompt string) any { // Double condition : MMPROJ configuré ET moteur qui déclare la vision // (/props). Avec un projecteur d'un AUTRE modèle — donc jamais chargé — le // gabarit sérialisait le base64 de la pièce jointe en TEXTE : un PNG de // 3,5 Mo devenait des dizaines de milliers de tokens dans le contexte, le // même engorgement que celui corrigé pour les captures d'écran. if !visionEnabled() || !engineSeesImages() { return attachNote(files) + prompt } dir, err := uploadsDir() if err != nil { return attachNote(files) + prompt } var imgParts []map[string]any var textFiles []attachInfo for _, f := range files { mime := imageMime(f.Name) if mime == "" { textFiles = append(textFiles, f) continue } b, err := os.ReadFile(filepath.Join(dir, f.Name)) if err != nil { textFiles = append(textFiles, f) // illisible ici : au moins l'annoncer comme fichier continue } // Photo prise au téléphone : l'orientation vit dans un tag EXIF que le // projecteur ignore (il voit les pixels bruts, donc l'image couchée), et // une image de plusieurs milliers de pixels ne fait que gonfler le base64 // — voir prepareImageForModel (web_upload_orient.go). b, mime = prepareImageForModel(b, mime) imgParts = append(imgParts, imageURLPart(b, mime)) } if len(imgParts) == 0 { return attachNote(files) + prompt } parts := []map[string]any{{"type": "text", "text": attachNote(textFiles) + prompt}} return append(parts, imgParts...) } // workspaceRel dit si `abs` se trouve DANS le dossier de travail de l'agent et, // si oui, renvoie son chemin relatif en séparateurs '/'. // // C'est le seul périmètre téléchargeable. L'agent peut écrire n'importe où quand // on lui donne un chemin absolu (voir resolveAgentPath) ; ouvrir le téléchargement // à ces fichiers-là ferait de /api/chat/file un « lis-moi ce fichier du serveur » // à usage général — l'API n'a pas forcément de clé et écoute sur 0.0.0.0. func workspaceRel(abs string) (string, bool) { return relWithin(agentWorkspace(), abs) } // 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-Loki-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) == "" { sendJSON(w, 400, map[string]any{"ok": false, "error": "chemin manquant"}) return } // Un chemin ABSOLU fourni par le client ne doit pas être suivi : workspaceFile // le traite comme relatif au dossier de la discussion et tranche lui-même. abs, ok := workspaceFile(rel) if !ok { sendJSON(w, 403, map[string]any{"ok": false, "error": "hors du dossier de travail"}) return } st, err := os.Stat(abs) if err != nil || st.IsDir() { sendJSON(w, 404, map[string]any{"ok": false, "error": "fichier introuvable"}) 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") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(name)) http.ServeFile(w, r, abs) } // handleChatImage sert une image du dossier de travail POUR AFFICHAGE dans le // fil (captures d'écran, images produites par l'agent). Route distincte de // handleChatFile, qui force le téléchargement de TOUT : la règle « rien ne // s'exécute dans l'origine de l'UI » est ici tenue autrement, en ne servant // jamais que des images — // - le type est déduit du CONTENU (les 512 premiers octets), pas de // l'extension : un .png qui contient du HTML est refusé ; // - nosniff empêche le navigateur de revenir sur ce type ; // - une CSP « default-src 'none' » neutralise le document au cas où. func handleChatImage(w http.ResponseWriter, r *http.Request) { rel := r.URL.Query().Get("path") if strings.TrimSpace(rel) == "" { sendJSON(w, 400, map[string]any{"ok": false, "error": "chemin manquant"}) return } abs, ok := workspaceFile(rel) if !ok { sendJSON(w, 403, map[string]any{"ok": false, "error": "hors du dossier de travail"}) return } st, err := os.Stat(abs) if err != nil || st.IsDir() { sendJSON(w, 404, map[string]any{"ok": false, "error": "fichier introuvable"}) return } f, err := os.Open(abs) if err != nil { sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) return } defer f.Close() head := make([]byte, 512) n, _ := io.ReadFull(f, head) mime := http.DetectContentType(head[:n]) if !strings.HasPrefix(mime, "image/") { sendJSON(w, 415, map[string]any{"ok": false, "error": "ce fichier n'est pas une image"}) return } if _, err := f.Seek(0, io.SeekStart); err != nil { sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) return } w.Header().Set("Content-Type", mime) w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox") w.Header().Set("Content-Disposition", "inline; filename*=UTF-8''"+url.PathEscape(filepath.Base(abs))) http.ServeContent(w, r, filepath.Base(abs), st.ModTime(), f) } type uploadReq struct { Name string `json:"name"` Data string `json:"data"` // base64 d'UN morceau (accepte un data: URL complet) // Envoi en plusieurs morceaux. Le premier appel n'a pas d'ID et reçoit celui // que le serveur attribue ; les suivants le rappellent. `More` à false ferme // le fichier. Un envoi en un seul morceau (More absent) reste valable. ID string `json:"id"` More bool `json:"more"` // Size = taille totale annoncée au PREMIER morceau, pour vérifier l'espace // disque avant d'entamer un envoi d'un gigaoctet. Purement indicatif : le // vrai plafond reste vérifié morceau par morceau. Size int64 `json:"size"` } // upSession = un envoi en cours, adossé à un fichier .part sur le disque. // // Rien n'est accumulé en mémoire : chaque morceau est décodé puis écrit tout de // suite, et seul le descripteur reste ouvert. C'est ce qui permet de passer à // 1 Go — la version d'avant gardait le fichier entier en RAM, deux fois (le // base64 reçu et le binaire décodé), ce qui plafonnait l'envoi à quelques Mo // utilisables sans faire gonfler le process. type upSession struct { // mu sérialise les écritures d'UNE session. Le verrou global ne couvre que la // table : le tenir pendant l'écriture disque mettait tous les envois à la // queue leu leu, alors que le client en lance plusieurs de front (un par // fichier joint). mu sync.Mutex busy bool // écriture en cours : le ménage doit passer son tour f *os.File name string written int64 last time.Time } var ( upMu sync.Mutex upSessions = map[string]*upSession{} ) // uploadSpaceMargin : place qu'on refuse d'entamer sur le disque. Remplir le // volume de la machine qui fait tourner le modèle est autrement plus grave que // de refuser un envoi — llama-server, la base et les journaux vivent dessus. const uploadSpaceMargin = 512 << 20 // upSessionTTL : au-delà, un envoi interrompu (onglet fermé, réseau coupé) est // abandonné et son .part supprimé. Sans ça, un fichier à moitié transféré // resterait ouvert et occuperait le disque indéfiniment. const upSessionTTL = 10 * time.Minute // sweepUploadSessions ferme et efface les envois abandonnés. Appelé à chaque // nouveau morceau : pas de goroutine de ménage à faire vivre. func sweepUploadSessions() { now := time.Now() for id, s := range upSessions { // `busy` : une écriture est en cours dans cette session. La balayer // fermerait le fichier sous les pieds de la requête qui l'écrit. if !s.busy && now.Sub(s.last) > upSessionTTL { name := s.f.Name() s.f.Close() os.Remove(name) delete(upSessions, id) } } } // handleChatUpload reçoit un fichier, en un ou plusieurs morceaux, et renvoie // son chemin relatif — celui que le client joindra au message (/api/chat/send, // champ files). func handleChatUpload(w http.ResponseWriter, r *http.Request) { var body uploadReq // Le plafond porte sur UN morceau, pas sur le fichier : c'est la seule borne // qui compte pour la mémoire du process. if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2*uploadChunkMax)).Decode(&body); err != nil { sendJSON(w, 400, map[string]any{"ok": false, "error": "morceau trop gros ou requête invalide"}) return } data := body.Data // Les clients qui passent par FileReader.readAsDataURL envoient // "data:application/pdf;base64,JVBER…" : on ne garde que la charge utile. if strings.HasPrefix(data, "data:") { if i := strings.Index(data, ","); i >= 0 { data = data[i+1:] } } raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(data)) if err != nil { sendJSON(w, 400, map[string]any{"ok": false, "error": "contenu illisible (base64 attendu)"}) return } // Le verrou global ne protège QUE la table des sessions. L'écriture, elle, se // fait sous le verrou de la session — sinon deux fichiers envoyés en même // temps (le client les lance de front) s'attendraient l'un l'autre. upMu.Lock() sweepUploadSessions() s := upSessions[body.ID] if s == nil { if body.ID != "" { // L'envoi a expiré ou le serveur a redémarré en cours de route : le dire, // plutôt que de recommencer un fichier à partir de son milieu. upMu.Unlock() sendJSON(w, 409, map[string]any{"ok": false, "error": "envoi expiré — recommence le fichier"}) return } dir, err := uploadsDir() if err != nil { upMu.Unlock() sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) return } // Espace disque : refuser franchement vaut mieux que remplir le volume de // la machine qui fait tourner le modèle. La taille annoncée par le client // n'engage que lui — le plafond réel reste vérifié morceau par morceau. if free := diskFree(dir); free > 0 && body.Size > 0 && free < body.Size+uploadSpaceMargin { upMu.Unlock() sendJSON(w, 507, map[string]any{"ok": false, "error": fmt.Sprintf("espace insuffisant : %s libres, %s nécessaires", humanBytes(free), humanBytes(body.Size+uploadSpaceMargin))}) return } f, err := os.CreateTemp(dir, ".upload-*.part") if err != nil { upMu.Unlock() sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) return } s = &upSession{f: f, name: safeUploadName(body.Name)} body.ID = filepath.Base(f.Name()) upSessions[body.ID] = s } s.last = time.Now() s.busy = true upMu.Unlock() s.mu.Lock() defer func() { s.mu.Unlock() upMu.Lock() s.busy = false upMu.Unlock() }() // drop ferme et oublie la session : sur erreur, et à la fin de l'envoi. drop := func() string { name := s.f.Name() s.f.Close() upMu.Lock() delete(upSessions, body.ID) upMu.Unlock() return name } abort := func(code int, msg string) { os.Remove(drop()) sendJSON(w, code, map[string]any{"ok": false, "error": msg}) } if s.written+int64(len(raw)) > uploadMaxBytes { abort(413, fmt.Sprintf("fichier trop gros (max %s)", humanBytes(uploadMaxBytes))) return } if len(raw) > 0 { if _, err := s.f.Write(raw); err != nil { abort(500, err.Error()) return } s.written += int64(len(raw)) } if body.More { // Morceau intermédiaire : on rend l'ID pour la suite et l'avancement, qui // alimente la barre de progression côté client. sendJSON(w, 200, map[string]any{"ok": true, "id": body.ID, "received": s.written}) return } // Dernier morceau : on ferme et on donne au fichier son vrai nom. Le Close est // dans drop() ; son erreur éventuelle est celle d'un tampon non vidé, donc // d'un fichier incomplet — on ne le publie pas dans ce cas. part := s.f.Name() syncErr := s.f.Sync() drop() if syncErr != nil { os.Remove(part) sendJSON(w, 500, map[string]any{"ok": false, "error": syncErr.Error()}) return } if s.written == 0 { os.Remove(part) sendJSON(w, 400, map[string]any{"ok": false, "error": "fichier vide"}) return } dest := uniqueUploadPath(filepath.Dir(part), s.name) if err := os.Rename(part, dest); err != nil { os.Remove(part) sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) return } sendJSON(w, 200, map[string]any{ "ok": true, "path": "uploads/" + filepath.Base(dest), "abs": dest, // affiché à l'utilisateur, jamais renvoyé au serveur "size": s.written, }) } // cleanStaleUploadParts efface les .part laissés par un envoi que le process n'a // pas pu terminer (arrêt du service, crash). Appelé au démarrage : les sessions // vivent en mémoire, aucun de ces fichiers n'est reprenable. // // Le balayage couvre TOUTES les discussions, pas seulement l'active : un envoi // coupé dans une discussion qu'on ne rouvrira jamais laisserait sinon son .part // occuper le disque pour toujours. func cleanStaleUploadParts() { dirs := []string{filepath.Join(agentWorkspace(), uploadsSub)} // dépôts d'avant le rangement par discussion convs := filepath.Join(agentWorkspace(), convFilesRoot) if ents, err := os.ReadDir(convs); err == nil { for _, e := range ents { if e.IsDir() { dirs = append(dirs, filepath.Join(convs, e.Name(), uploadsSub)) } } } for _, dir := range dirs { entries, err := os.ReadDir(dir) if err != nil { continue } for _, e := range entries { n := e.Name() if !e.IsDir() && strings.HasPrefix(n, ".upload-") && strings.HasSuffix(n, ".part") { os.Remove(filepath.Join(dir, n)) } } } }