v0.8.8 : envoi de fichiers jusqu'a 1 Go, en morceaux

La limite de 24 Mo n'etait pas arbitraire : le fichier partait dans un
seul corps JSON en base64, tenu entier en memoire par le navigateur puis
par le serveur. Relever la constante seule aurait demande ~1,4 Go de RAM
de chaque cote pour un fichier de 1 Go.

Le transfert est donc decoupe en tranches de 8 Mo. Le navigateur lit une
tranche a la fois (Blob.slice), le serveur la decode, l'ecrit dans un
.part et l'oublie ; seul le descripteur reste ouvert. Mesure sur 120 Mo :
la RAM du process n'a pas bouge (95 -> 96 Mo).

uploadMaxBytes (1 Go) borne desormais le DISQUE ; c'est uploadChunkMax
(8 Mo) qui borne la memoire, et lui seul.

Une session interrompue est balayee au bout de 10 min, et les .part
orphelins sont effaces au demarrage du service : les sessions vivent en
memoire, aucun n'est reprenable apres un arret. Un identifiant inconnu
est refuse en 409 plutot que de reprendre un fichier en son milieu.

La vignette affiche l'avancement en pourcentage au-dela d'une tranche.
This commit is contained in:
nathaninline committed 2026-08-10 20:05:01 +02:00
1 parent 6807bad48d
commit e07051e1e3
10 files changed
+325 -83

No files matched your search

+8 -29
View File
@@ -1,39 +1,18 @@
Les fichiers circulent enfin dans les deux sens : vous pouvez en envoyer à l'IA, et récupérer ceux qu'elle produit.
La limite d'envoi de fichiers passe de 24 Mo à 1 Go.
## Envoyer un fichier
## Des fichiers d'un gigaoctet
Un trombone est apparu dans le bas du composeur. Vous pouvez aussi glisser un fichier n'importe où dans la fenêtre, ou le coller avec Ctrl+V — une capture d'écran passe donc directement dans le chat.
La 0.8.7 plafonnait à 24 Mo, et ce n'était pas un caprice : le fichier partait dans un seul message, encodé en base64, gardé entier en mémoire par le navigateur puis par le serveur. Un gigaoctet ainsi transmis, c'est près d'un gigaoctet et demi de mémoire de chaque côté — de quoi faire tomber la machine.
Le fichier est déposé dans le dossier de travail de l'IA, sous `uploads/`, et son chemin est annoncé dans le message. Elle en fait ensuite ce qu'elle veut avec ses outils : le lire, le convertir, le lancer. Rien n'est interprété au passage, donc n'importe quel type de fichier fonctionne — à condition que le mode agent soit actif, sans quoi elle voit le nom mais n'a aucun outil pour l'ouvrir.
Le fichier part maintenant par tranches de 8 Mo. Le navigateur n'en lit qu'une à la fois, le serveur l'écrit sur le disque et l'oublie aussitôt : plus personne ne détient le fichier entier. Mesuré sur un envoi de 120 Mo, la mémoire du serveur n'a pas bougé d'un mégaoctet.
Deux détails qui comptent à l'usage. Le dépôt n'a lieu qu'à **l'envoi** du message : tant que vous n'avez pas cliqué, rien n'atterrit sur le disque, et retirer un fichier de la liste ne laisse aucune trace. Et joindre un fichier ne déplace pas la zone de saisie d'un pixel — les vignettes se posent au-dessus de la carte, dans l'espace qu'elle réservait déjà.
Au passage, la vignette affiche l'avancement en pourcentage : sur un gros fichier, l'anneau qui tournait sans rien dire n'était pas d'un grand secours.
Le transfert passe par le tunnel chiffré comme le reste : l'envoi de fichiers fonctionne aussi depuis l'accès distant, sans rien configurer.
Un envoi interrompu — onglet fermé, réseau coupé, service redémarré — ne laisse rien derrière lui : le fichier partiel est effacé au bout de dix minutes, et au démarrage du service.
Taille maximale : 24 Mo par fichier.
Ce qui n'a pas changé : le dépôt n'a toujours lieu qu'à l'envoi du message, et l'accès distant fonctionne pareil, les tranches passant par le tunnel chiffré comme le reste.
## Recevoir un fichier
Quand vous demandiez un fichier à l'IA, elle répondait par un chemin sur le serveur — `/etc/ajean/workspace/rapport.pdf` — parfaitement inutilisable depuis un navigateur.
Elle écrit maintenant un lien Markdown ordinaire, et cliquer dessus télécharge le fichier :
> C'est prêt : [le rapport](rapport.pdf)
Aucune syntaxe particulière à connaître de son côté, et les liens web restent des liens web. Seul le dossier de travail est accessible ; un chemin qui en sort est refusé, et les fichiers sont toujours servis en téléchargement, jamais affichés.
## Un quart de contexte rendu au mode agent
Le préambule envoyé au modèle à chaque tour est passé d'environ 2350 à 1770 tokens, sans qu'une seule consigne disparaisse.
L'essentiel du poids n'était pas dans le texte du prompt mais dans les schémas des outils, qui pèsent le double. Le prompt réénumérait des outils que ces schémas décrivent déjà, et la règle « écris les fichiers avec `write`, jamais avec `echo` » y figurait trois fois. Elle ne vit plus que là où elle s'applique.
C'est autant de fenêtre rendue à la conversation, et un compactage qui arrive plus tard.
## Aussi
- L'export d'une conversation mentionne les fichiers joints. Un message envoyé sans texte, juste avec un fichier, y apparaissait comme une section vide.
- Le décompte de contexte, sous la zone de saisie, ne s'écrit plus « contexte 4210 / 32768 » mais « 4210 / 32768 » — la jauge juste en dessous dit déjà de quoi il s'agit, et la place gagnée sert au nom du modèle.
Pensez tout de même à la place disque de la machine qui héberge AJEAN : les fichiers envoyés s'accumulent dans `uploads/`, à l'intérieur du dossier de travail de l'IA.
## Mise à jour
Binary file not shown.
Binary file not shown.
+3 -3
View File
@@ -3,13 +3,13 @@
"FileVersion": {
"Major": 0,
"Minor": 8,
"Patch": 7,
"Patch": 8,
"Build": 0
},
"ProductVersion": {
"Major": 0,
"Minor": 8,
"Patch": 7,
"Patch": 8,
"Build": 0
},
"FileFlagsMask": "3f",
@@ -25,7 +25,7 @@
"LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.",
"OriginalFilename": "ajean.exe",
"ProductName": "AJEAN",
"ProductVersion": "0.8.7",
"ProductVersion": "0.8.8",
"Comments": "https://github.com/nathaninline/ajean — projet open source (MIT)"
},
"VarFileInfo": {
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"strings"
)
const Version = "0.8.7"
const Version = "0.8.8"
// Main est le vrai main() du binaire (cmd/ajean ne fait que l'appeler).
func Main() {
+44 -13
View File
@@ -5021,11 +5021,17 @@ async function remotePairCode(){
// Le transfert passe par du base64 dans du JSON (et non du multipart) : c'est la
// seule forme qui traverse le tunnel E2E d'app.ajean.link, donc l'accès distant
// fonctionne sans code particulier.
const ATTACH_MAX = 24*1024*1024; // doit rester aligné sur uploadMaxBytes (web_upload.go)
// Doivent rester alignés sur uploadMaxBytes / uploadChunkMax (web_upload.go).
// Le fichier part par MORCEAUX : ni le navigateur ni le serveur n'en tiennent
// plus d'un à la fois en mémoire, ce qui rend le gigaoctet possible. Envoyer
// 1 Go en un seul corps JSON demanderait ~1,4 Go de base64 de chaque côté.
const ATTACH_MAX = 1024*1024*1024;
const ATTACH_CHUNK = 8*1024*1024;
let ATTACH = [];
let ATTACH_SEQ = 0;
function fmtSize(n){
if(n >= 1024*1024*1024) return (n/(1024*1024*1024)).toFixed(1)+' Go';
if(n >= 1024*1024) return (n/(1024*1024)).toFixed(n >= 10*1024*1024 ? 0 : 1)+' Mo';
if(n >= 1024) return Math.round(n/1024)+' Ko';
return n+' o';
@@ -5158,7 +5164,11 @@ function renderAttach(){
el.appendChild(fileChip(a.name, a.size, {
cls: a.state==='up' ? 'up' : (a.state==='err' ? 'err' : ''),
title: a.error || a.name,
sizeText: a.state==='err' ? 'échec' : fmtSize(a.size),
// Un gros fichier prend du temps : on montre l'avancement plutôt qu'un
// anneau qui tourne sans rien dire. Sous un morceau, il n'y a rien à suivre.
sizeText: a.state==='err' ? 'échec'
: (a.state==='up' && a.size>ATTACH_CHUNK) ? Math.round((a.sent||0)*100/a.size)+' %'
: fmtSize(a.size),
onRemove: ()=>{ ATTACH=ATTACH.filter(o=>o.id!==a.id); renderAttach(); }
}));
}
@@ -5177,22 +5187,43 @@ function readAsBase64(file){
fr.readAsDataURL(file);
});
}
// Envoie UN morceau. Pas de jfetch : son délai de 30 s est calibré pour de
// petits appels et couperait un transfert.
async function sendChunk(payload){
const r=await fetch(API_BASE+'/api/chat/upload',{
method:'POST', headers:authHeaders({'Content-Type':'application/json'}),
body:JSON.stringify(payload)
});
const j=await r.json().catch(()=>({}));
if(!r.ok || !j.ok) throw new Error(j.error||('erreur '+r.status));
return j;
}
// Le dépôt n'a lieu qu'à l'ENVOI du message, jamais à l'ajout : tant qu'on n'a
// pas cliqué, rien ne doit atterrir dans le dossier de travail de l'IA. Retirer
// un fichier de la liste ne laisse donc aucune trace sur le disque.
//
// Découpage en morceaux de 8 Mo : le navigateur ne lit qu'une tranche à la fois
// (Blob.slice), le serveur l'écrit et l'oublie. Un fichier d'un gigaoctet passe
// donc sans que personne ne le tienne entier en mémoire — et on sait dire où on
// en est, ce qu'un envoi monolithique ne permettait pas.
async function uploadAttach(a){
a.state='up'; renderAttach();
a.state='up'; a.sent=0; renderAttach();
try{
const data=await readAsBase64(a.file);
// Pas de jfetch ici : son délai de 30 s est calibré pour de petits appels,
// et couperait le transfert d'un fichier de plusieurs Mo.
const r=await fetch(API_BASE+'/api/chat/upload',{
method:'POST', headers:authHeaders({'Content-Type':'application/json'}),
body:JSON.stringify({name:a.name, data:data})
});
const j=await r.json().catch(()=>({}));
if(!r.ok || !j.ok) throw new Error(j.error||('erreur '+r.status));
a.path=j.path; a.state='ok'; a.file=null; // le contenu ne sert plus à rien côté page
let id='';
// `do…while` et non `while` : un fichier plus petit qu'un morceau doit quand
// même faire un tour de boucle, sinon rien ne part.
let off=0;
do{
const end=Math.min(off+ATTACH_CHUNK, a.size);
const data=await readAsBase64(a.file.slice(off, end));
const last=end>=a.size;
const j=await sendChunk({name:a.name, data:data, id:id, more:!last});
if(j.id) id=j.id;
off=end;
a.sent=off; renderAttach();
if(last){ a.path=j.path; }
} while(off<a.size);
a.state='ok'; a.file=null; // le contenu ne sert plus à rien côté page
}catch(e){
a.state='err'; a.error=(e&&e.message)||'échec';
toast('« '+a.name+' » : '+a.error);
+44 -13
View File
@@ -8,11 +8,17 @@
// Le transfert passe par du base64 dans du JSON (et non du multipart) : c'est la
// seule forme qui traverse le tunnel E2E d'app.ajean.link, donc l'accès distant
// fonctionne sans code particulier.
const ATTACH_MAX = 24*1024*1024; // doit rester aligné sur uploadMaxBytes (web_upload.go)
// Doivent rester alignés sur uploadMaxBytes / uploadChunkMax (web_upload.go).
// Le fichier part par MORCEAUX : ni le navigateur ni le serveur n'en tiennent
// plus d'un à la fois en mémoire, ce qui rend le gigaoctet possible. Envoyer
// 1 Go en un seul corps JSON demanderait ~1,4 Go de base64 de chaque côté.
const ATTACH_MAX = 1024*1024*1024;
const ATTACH_CHUNK = 8*1024*1024;
let ATTACH = [];
let ATTACH_SEQ = 0;
function fmtSize(n){
if(n >= 1024*1024*1024) return (n/(1024*1024*1024)).toFixed(1)+' Go';
if(n >= 1024*1024) return (n/(1024*1024)).toFixed(n >= 10*1024*1024 ? 0 : 1)+' Mo';
if(n >= 1024) return Math.round(n/1024)+' Ko';
return n+' o';
@@ -145,7 +151,11 @@ function renderAttach(){
el.appendChild(fileChip(a.name, a.size, {
cls: a.state==='up' ? 'up' : (a.state==='err' ? 'err' : ''),
title: a.error || a.name,
sizeText: a.state==='err' ? 'échec' : fmtSize(a.size),
// Un gros fichier prend du temps : on montre l'avancement plutôt qu'un
// anneau qui tourne sans rien dire. Sous un morceau, il n'y a rien à suivre.
sizeText: a.state==='err' ? 'échec'
: (a.state==='up' && a.size>ATTACH_CHUNK) ? Math.round((a.sent||0)*100/a.size)+' %'
: fmtSize(a.size),
onRemove: ()=>{ ATTACH=ATTACH.filter(o=>o.id!==a.id); renderAttach(); }
}));
}
@@ -164,22 +174,43 @@ function readAsBase64(file){
fr.readAsDataURL(file);
});
}
// Envoie UN morceau. Pas de jfetch : son délai de 30 s est calibré pour de
// petits appels et couperait un transfert.
async function sendChunk(payload){
const r=await fetch(API_BASE+'/api/chat/upload',{
method:'POST', headers:authHeaders({'Content-Type':'application/json'}),
body:JSON.stringify(payload)
});
const j=await r.json().catch(()=>({}));
if(!r.ok || !j.ok) throw new Error(j.error||('erreur '+r.status));
return j;
}
// Le dépôt n'a lieu qu'à l'ENVOI du message, jamais à l'ajout : tant qu'on n'a
// pas cliqué, rien ne doit atterrir dans le dossier de travail de l'IA. Retirer
// un fichier de la liste ne laisse donc aucune trace sur le disque.
//
// Découpage en morceaux de 8 Mo : le navigateur ne lit qu'une tranche à la fois
// (Blob.slice), le serveur l'écrit et l'oublie. Un fichier d'un gigaoctet passe
// donc sans que personne ne le tienne entier en mémoire — et on sait dire où on
// en est, ce qu'un envoi monolithique ne permettait pas.
async function uploadAttach(a){
a.state='up'; renderAttach();
a.state='up'; a.sent=0; renderAttach();
try{
const data=await readAsBase64(a.file);
// Pas de jfetch ici : son délai de 30 s est calibré pour de petits appels,
// et couperait le transfert d'un fichier de plusieurs Mo.
const r=await fetch(API_BASE+'/api/chat/upload',{
method:'POST', headers:authHeaders({'Content-Type':'application/json'}),
body:JSON.stringify({name:a.name, data:data})
});
const j=await r.json().catch(()=>({}));
if(!r.ok || !j.ok) throw new Error(j.error||('erreur '+r.status));
a.path=j.path; a.state='ok'; a.file=null; // le contenu ne sert plus à rien côté page
let id='';
// `do…while` et non `while` : un fichier plus petit qu'un morceau doit quand
// même faire un tour de boucle, sinon rien ne part.
let off=0;
do{
const end=Math.min(off+ATTACH_CHUNK, a.size);
const data=await readAsBase64(a.file.slice(off, end));
const last=end>=a.size;
const j=await sendChunk({name:a.name, data:data, id:id, more:!last});
if(j.id) id=j.id;
off=end;
a.sent=off; renderAttach();
if(last){ a.path=j.path; }
} while(off<a.size);
a.state='ok'; a.file=null; // le contenu ne sert plus à rien côté page
}catch(e){
a.state='err'; a.error=(e&&e.message)||'échec';
toast('« '+a.name+' » : '+a.error);
+3
View File
@@ -92,6 +92,9 @@ func newWebMux() *http.ServeMux {
// Un téléchargement de modèle coupé net (crash, restart du service) laisse un
// .part orphelin non reprenable : on nettoie au démarrage.
cleanStalePartFiles()
// Idem pour un envoi de fichier coupé en plein transfert : les sessions vivent
// en mémoire, aucun .part ne survit utilement à l'arrêt du process.
cleanStaleUploadParts()
// Idem pour une installation de moteur : elle meurt avec le process. On
// recharge son état pour l'annoncer « interrompue » au lieu de n'afficher
// plus rien du tout.
+153 -24
View File
@@ -19,13 +19,22 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"time"
"unicode"
)
// uploadMaxBytes borne la taille d'UN fichier. Tout le chemin (corps JSON, E2E,
// écriture) passe par la mémoire : au-delà, un envoi maladroit ferait gonfler le
// process de plusieurs centaines de Mo. 24 Mo décodés ≈ 32 Mo de base64.
const uploadMaxBytes = 24 << 20
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
)
// uploadsDir renvoie (en le créant) le dossier de dépôt, dans le workspace agent.
func uploadsDir() (string, error) {
@@ -194,15 +203,61 @@ func handleChatFile(w http.ResponseWriter, r *http.Request) {
type uploadReq struct {
Name string `json:"name"`
Data string `json:"data"` // base64 (accepte un data: URL complet)
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"`
}
// handleChatUpload écrit un fichier dans le workspace et renvoie son chemin
// relatif, celui que le client joindra au message (/api/chat/send, champ files).
// 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 {
f *os.File
name string
written int64
last time.Time
}
var (
upMu sync.Mutex
upSessions = map[string]*upSession{}
)
// 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 {
if 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
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2*uploadMaxBytes)).Decode(&body); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": "fichier trop gros ou requête invalide"})
// 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
@@ -218,29 +273,103 @@ func handleChatUpload(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 400, map[string]any{"ok": false, "error": "contenu illisible (base64 attendu)"})
return
}
if len(raw) == 0 {
upMu.Lock()
defer upMu.Unlock()
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.
sendJSON(w, 409, map[string]any{"ok": false, "error": "envoi expiré — recommence le fichier"})
return
}
dir, err := uploadsDir()
if err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
f, err := os.CreateTemp(dir, ".upload-*.part")
if err != nil {
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()
abort := func(code int, msg string) {
name := s.f.Name()
s.f.Close()
os.Remove(name)
delete(upSessions, body.ID)
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.
delete(upSessions, body.ID)
part := s.f.Name()
if err := s.f.Close(); err != nil {
os.Remove(part)
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
if s.written == 0 {
os.Remove(part)
sendJSON(w, 400, map[string]any{"ok": false, "error": "fichier vide"})
return
}
if len(raw) > uploadMaxBytes {
sendJSON(w, 413, map[string]any{"ok": false, "error": fmt.Sprintf("fichier trop gros (max %s)", humanBytes(int64(uploadMaxBytes)))})
return
}
dir, err := uploadsDir()
if err != nil {
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
}
dest := uniqueUploadPath(dir, safeUploadName(body.Name))
if err := os.WriteFile(dest, raw, 0o644); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
rel := "uploads/" + filepath.Base(dest)
sendJSON(w, 200, map[string]any{
"ok": true,
"path": rel, // ce que le modèle verra (résolu par resolveAgentPath)
"path": "uploads/" + filepath.Base(dest),
"abs": dest, // affiché à l'utilisateur, jamais renvoyé au serveur
"size": len(raw),
"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.
func cleanStaleUploadParts() {
dir, err := uploadsDir()
if err != nil {
return
}
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range entries {
n := e.Name()
if !e.IsDir() && strings.HasPrefix(n, ".upload-") && strings.HasSuffix(n, ".part") {
os.Remove(filepath.Join(dir, n))
}
}
}
+69
View File
@@ -162,3 +162,72 @@ func TestExportFileNamesBothForms(t *testing.T) {
t.Fatalf("sans pièce jointe : %v", got)
}
}
// upload envoie un morceau et renvoie la réponse décodée.
func uploadChunk(t *testing.T, body map[string]any) (int, map[string]any) {
t.Helper()
b, _ := json.Marshal(body)
rec := httptest.NewRecorder()
handleChatUpload(rec, httptest.NewRequest("POST", "/api/chat/upload", strings.NewReader(string(b))))
var out map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &out)
return rec.Code, out
}
// Un fichier arrive en plusieurs morceaux et doit être reconstitué à l'octet
// près. C'est ce découpage qui permet le gigaoctet : sans lui, tout le fichier
// passait dans un seul corps JSON, gardé en mémoire des deux côtés.
func TestChatUploadChunked(t *testing.T) {
part1 := strings.Repeat("A", 5000)
part2 := strings.Repeat("B", 3000)
part3 := "fin"
code, resp := uploadChunk(t, map[string]any{
"name": "gros.bin", "data": base64.StdEncoding.EncodeToString([]byte(part1)), "more": true})
if code != 200 || resp["id"] == nil {
t.Fatalf("1er morceau : code %d, resp %+v", code, resp)
}
id := resp["id"].(string)
if got := resp["received"].(float64); got != 5000 {
t.Fatalf("avancement après 1er morceau = %v", got)
}
if code, resp = uploadChunk(t, map[string]any{
"id": id, "data": base64.StdEncoding.EncodeToString([]byte(part2)), "more": true}); code != 200 {
t.Fatalf("2e morceau : code %d, resp %+v", code, resp)
}
code, resp = uploadChunk(t, map[string]any{
"id": id, "data": base64.StdEncoding.EncodeToString([]byte(part3))})
if code != 200 || resp["ok"] != true {
t.Fatalf("dernier morceau : code %d, resp %+v", code, resp)
}
abs := resp["abs"].(string)
defer os.Remove(abs)
if resp["path"] != "uploads/gros.bin" {
t.Fatalf("chemin = %v", resp["path"])
}
got, err := os.ReadFile(abs)
if err != nil {
t.Fatal(err)
}
if string(got) != part1+part2+part3 {
t.Fatalf("reconstitution : %d octets, attendu %d", len(got), len(part1+part2+part3))
}
// Aucun .part ne doit traîner une fois l'envoi terminé.
dir, _ := uploadsDir()
entries, _ := os.ReadDir(dir)
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".part") {
t.Fatalf("fichier temporaire laissé : %s", e.Name())
}
}
}
// Un identifiant inconnu (envoi expiré, serveur redémarré) ne doit pas produire
// un fichier tronqué qui repart de son milieu.
func TestChatUploadUnknownSessionRefused(t *testing.T) {
code, resp := uploadChunk(t, map[string]any{
"id": "inconnu-1234.part", "data": base64.StdEncoding.EncodeToString([]byte("x"))})
if code != 409 {
t.Fatalf("code %d, attendu 409 — resp %+v", code, resp)
}
}