diff --git a/README.md b/README.md index a47e4c2..1e7fe6a 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur : | `LOKI_MODELS` | `rust` : les routes des modèles — liste, dossiers, suppression, téléchargements, catalogue Hugging Face — sont servies par la façade Rust (expérimental, migration en cours) | Go | | `LOKI_DICTATE` | `rust` : la dictée vocale (`/api/transcribe`, `/api/dictate/*`) et son serveur de transcription sont tenus par la façade Rust (expérimental, migration en cours) | Go | | `LOKI_SETTINGS` | `rust` : les réglages partagés — préférences d'affichage, prompt système, intensité du raisonnement, adresse publique — sont servis par la façade Rust (expérimental, migration en cours) | Go | +| `LOKI_FILES` | `rust` : les fichiers de la discussion — dépôt, panneau Fichiers, archive .zip, téléchargement, images et vignettes — sont servis par la façade Rust (expérimental, migration en cours) | Go | **Accès par nom de domaine.** Sans clé de pilotage, l'API n'accepte que les hôtes locaux (IP, `localhost`, nom sans point, `.local`/`.lan`…) et refuse diff --git a/docs/superpowers/specs/2026-10-15-rust-migration-lot11-fichiers-design.md b/docs/superpowers/specs/2026-10-15-rust-migration-lot11-fichiers-design.md new file mode 100644 index 0000000..37fc335 --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-rust-migration-lot11-fichiers-design.md @@ -0,0 +1,63 @@ +# Lot 11 — fichiers de la discussion tenus par la façade + +## Contexte + +Les fichiers rangés par discussion vivaient dans le Go : + +- `web_upload.go` : dépôt, téléchargement, image ; +- `web_files.go` : panneau Fichiers ; +- `web_files_zip.go` : archive ; +- `web_thumb.go` et `web_upload_orient.go` : vignettes ; +- `chat_convfiles.go` : chemins. + +Lot 11 : la façade les sert quand `LOKI_FILES=rust` (`convfiles.rs`, +expérimental ; sans la variable rien ne change). + +| Route | Rôle | +|---|---| +| `POST /api/chat/upload` | dépôt en morceaux base64 (8 Mio), fichier de 1 Gio au plus, espace disque vérifié, `.part` renommé à la fin sous un nom sûr et unique | +| `GET /api/chat/files` | panneau : dossiers d'abord puis du plus récent au plus ancien, tailles récursives (liens non suivis), vue « hors discussion » | +| `POST /api/chat/file/delete` | suppression ; refus de la racine et du dossier des discussions | +| `GET /api/chat/file` | fiche, tranche base64 (tunnel E2E) ou brut en pièce jointe (plage d'octets) | +| `GET /api/chat/image` | type reniflé sur le contenu, en ligne sous une CSP fermée ; vignette JPEG `thumb=` redressée (EXIF) et réduite par moyennage | +| `GET /api/chat/files/zip` | archive construite à la fiche, puis servie en tranches ou en brut | + +Le dossier de la discussion active reste décidé par le Go : la façade le lui +demande à chaque requête par la nouvelle route `/internal/v1/conv/workspace` +(`{dir, root, id, title}`). Il connaît la discussion active, qu'il tienne la +conversation ou que ce soit la façade. Les liens des anciens messages +(`captures//…`, `uploads/…` à la racine) restent résolus comme avant. + +`facade_files.go` remet les routes du tunnel à la façade. Il ne vide plus les +`.part` d'envois au démarrage : la façade le fait au sien. + +## Écarts assumés + +- Octets des vignettes JPEG : l'encodeur diffère. Les dimensions, le + redressement et la réduction (algorithme porté pixel pour pixel) sont les + mêmes. +- Horodatage des entrées `.zip` en UTC. +- Une seule plage d'octets par requête de téléchargement : le Go en acceptait + plusieurs, en multipart. + +## Parité + +`internal/loki/parity_files_test.go` tire du Go : + +- 10 noms de dépôt et 6 slugs ; +- 6 en-têtes EXIF ; +- les 8 orientations d'une image de 5×3 et 4 réductions, pixel pour pixel ; +- 10 reniflages ; +- 42 appels de route sur une arborescence fixe, dates comprises : + - panneau, téléchargement, image et vignette ; + - dépôt en un morceau, en deux morceaux, envoi expiré, fichier vide, + base64 illisible ; + - suppressions ; + - archive de la discussion et « hors discussion », contenu comparé + entrée par entrée ; + - les fichiers restants après chaque POST. + +`tests/convfiles.rs` rejoue le tout par la façade, plus une plage d'octets, +un lien vers l'extérieur (refusé, non compté) et le nom d'archive tiré du +titre. Deux mutations (ordre du panneau, suffixe des doublons) font échouer +le rejeu. diff --git a/internal/loki/facade_chat.go b/internal/loki/facade_chat.go index 4a892c6..411aee0 100644 --- a/internal/loki/facade_chat.go +++ b/internal/loki/facade_chat.go @@ -142,7 +142,8 @@ var facadeChatRoutes = map[string]bool{ // withFacadeChat remet la requête à la façade quand elle tient la // conversation — ou les routes des modèles (facade_models.go), la dictée -// (facade_dictate.go), les réglages partagés (facade_settings.go). +// (facade_dictate.go), les réglages partagés (facade_settings.go), les fichiers +// de la discussion (facade_files.go). // Posé APRÈS requireWebAuth : seule une requête authentifiée ici part, avec le // jeton interne qui en tient lieu là-bas. func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc { @@ -155,6 +156,8 @@ func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc { held, unavailable = facadeDictate, "dictée indisponible : " case facadeSettingsRoutes[path]: held, unavailable = facadeSettings, "réglages indisponibles : " + case facadeFilesRoutes[path]: + held, unavailable = facadeFiles, "fichiers indisponibles : " default: return h } diff --git a/internal/loki/facade_chat_test.go b/internal/loki/facade_chat_test.go index c55d9de..d45bf30 100644 --- a/internal/loki/facade_chat_test.go +++ b/internal/loki/facade_chat_test.go @@ -438,3 +438,49 @@ func TestFacadeReglages(t *testing.T) { t.Fatalf("relayé sans la variable : %q", w.Body.String()) } } + +// LOKI_FILES=rust : les routes des fichiers de la discussion partent à la +// façade ; /internal/v1/conv/workspace lui donne le dossier de la discussion +// active ; les .part d'envois ne sont plus vidés ici au démarrage. +func TestFacadeFichiers(t *testing.T) { + home := testHome(t) + f := demarrerFacade(t) + t.Setenv(facadeChatEnv, "") + t.Setenv(facadeFilesEnv, "rust") + local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") } + for p := range facadeFilesRoutes { + w := httptest.NewRecorder() + withFacadeChat(p, local)(w, httptest.NewRequest("GET", p, nil)) + if w.Body.String() != f.reply { + t.Fatalf("%s : réponse %q", p, w.Body.String()) + } + } + prev := agentWorkspace() + workspacePath = filepath.Join(home, "ws") + t.Cleanup(func() { workspacePath = prev }) + convActiveRemember("c-test") + w := httptest.NewRecorder() + handleInternalConvWorkspace(w, httptest.NewRequest("POST", "/internal/v1/conv/workspace", strings.NewReader("{}"))) + var ws map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &ws); err != nil { + t.Fatal(err) + } + if ws["dir"] != filepath.Join(home, "ws", "discussions", "c-test") || ws["root"] != filepath.Join(home, "ws") || ws["id"] != "c-test" { + t.Fatalf("dossier de la discussion : %v", ws) + } + if st, err := os.Stat(ws["dir"]); err != nil || !st.IsDir() { + t.Fatalf("dossier non créé : %v", err) + } + part := filepath.Join(ws["dir"], "uploads", ".upload-1.part") + _ = os.MkdirAll(filepath.Dir(part), 0o755) + _ = os.WriteFile(part, []byte("x"), 0o644) + cleanStartupUploadParts() + if _, err := os.Stat(part); err != nil { + t.Fatalf(".part effacé alors que la façade tient les fichiers : %v", err) + } + t.Setenv(facadeFilesEnv, "") + cleanStartupUploadParts() + if _, err := os.Stat(part); !os.IsNotExist(err) { + t.Fatalf(".part gardé sans la variable : %v", err) + } +} diff --git a/internal/loki/facade_files.go b/internal/loki/facade_files.go new file mode 100644 index 0000000..4510d5d --- /dev/null +++ b/internal/loki/facade_files.go @@ -0,0 +1,67 @@ +package loki + +import ( + "net/http" + "os" + "strings" +) + +// facade_files.go — fichiers de la discussion tenus par la façade Rust +// (LOKI_FILES=rust, lot 11 de la migration ; expérimental, le Go reste le +// défaut) : dépôt des pièces jointes, panneau Fichiers, archive .zip, +// téléchargement et affichage des fichiers produits par l'agent. +// +// Le dossier de la discussion active reste décidé ICI (convWorkspace : la +// discussion active est connue de ce process, que la conversation soit tenue +// par lui ou par la façade) : la façade le demande à chaque requête par +// /internal/v1/conv/workspace. Les .part d'envois en cours sont les siens : +// ce process ne les vide plus au démarrage (la façade le fait au sien). + +const facadeFilesEnv = "LOKI_FILES" + +// facadeFiles : la façade tient-elle les fichiers de la discussion ? +func facadeFiles() bool { + return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeFilesEnv)), "rust") && + facadeAddr() != "" && internalToken() != "" +} + +// facadeFilesRoutes : les routes que la façade sert alors (convfiles.rs). +var facadeFilesRoutes = map[string]bool{ + "/api/chat/upload": true, + "/api/chat/files": true, + "/api/chat/files/zip": true, + "/api/chat/file/delete": true, + "/api/chat/file": true, + "/api/chat/image": true, +} + +// cleanStartupUploadParts vide les envois interrompus au démarrage — sauf quand +// la façade tient les fichiers : ce sont alors les siens. +func cleanStartupUploadParts() { + if !facadeFiles() { + cleanStaleUploadParts() + } +} + +// handleInternalConvWorkspace : le dossier de la discussion active (créé au +// besoin), la racine du dossier de travail, et l'identité de la discussion +// (nom de l'archive .zip). +// +// POST /internal/v1/conv/workspace {} → {"dir","root","id","title"} +func handleInternalConvWorkspace(w http.ResponseWriter, r *http.Request) { + var in struct{} + if !readInternalJSON(w, r, &in) { + return + } + id := convEnsureActive() + title := "" + for _, m := range convIndex() { + if m.ID == id { + title = m.Title + break + } + } + sendJSON(w, http.StatusOK, map[string]any{ + "dir": convWorkspace(), "root": agentWorkspace(), "id": id, "title": title, + }) +} diff --git a/internal/loki/parity_files_test.go b/internal/loki/parity_files_test.go new file mode 100644 index 0000000..25c2bf6 --- /dev/null +++ b/internal/loki/parity_files_test.go @@ -0,0 +1,435 @@ +package loki + +import ( + "archive/zip" + "bytes" + "encoding/base64" + "encoding/json" + "image" + _ "image/jpeg" + "image/png" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "runtime" + "sort" + "strings" + "testing" + "time" +) + +// parity_files_test.go — journal de référence des fichiers de la discussion +// (lot 11 de la migration Rust) : noms de dépôt, slug, orientation EXIF, +// redressement et réduction des pixels, reniflage du type, puis un scénario +// de routes (panneau Fichiers, téléchargement, image, vignette, dépôt en +// morceaux, suppression, archive .zip) sur une arborescence fixe. Rangé dans +// rust/loki-rs/tests/parity_files/files.json ; tests/convfiles.rs le rejoue +// par la façade. LOKI_HOME réécrit en {{HOME}}, la date en {{DATE}}. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteFichiers + +const parityFilesFile = "../../rust/loki-rs/tests/parity_files/files.json" + +type parityPix struct { + W int `json:"w"` + H int `json:"h"` + Pix []byte `json:"pix"` +} + +type parityFileNode struct { + // Contenu (base64) d'un fichier, ou dossier si Dir. + Data []byte `json:"data,omitempty"` + Dir bool `json:"dir,omitempty"` + Mtime int64 `json:"mtime"` +} + +type parityFilesCall struct { + Method string `json:"method"` + URL string `json:"url"` + Body string `json:"body,omitempty"` + Status int `json:"status"` + // JSON (masqué), ou corps brut (base64) et en-têtes choisis. + Out any `json:"out,omitempty"` + Raw []byte `json:"raw,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + // Vignette : dimensions de l'image décodée. + Thumb []int `json:"thumb,omitempty"` + // Archive : entrées (nom → contenu base64). + Zip map[string][]byte `json:"zip,omitempty"` + // Après l'appel : les fichiers de la discussion (chemin relatif → taille). + Tree map[string]int64 `json:"tree,omitempty"` +} + +type parityFilesFixture struct { + UploadName map[string]string `json:"upload_name"` + Slug map[string]string `json:"slug"` + Exif []struct { + In []byte `json:"in"` + Out int `json:"out"` + } `json:"exif"` + Orient []struct { + Orient int `json:"orient"` + Out parityPix `json:"out"` + } `json:"orient"` + OrientIn parityPix `json:"orient_in"` + Scale []struct { + In parityPix `json:"in"` + Max int `json:"max"` + Out parityPix `json:"out"` + } `json:"scale"` + Sniff []struct { + In []byte `json:"in"` + Out string `json:"out"` + } `json:"sniff"` + // Ce que /internal/v1/conv/workspace répond pendant le scénario. + Workspace map[string]string `json:"workspace"` + Tree map[string]parityFileNode `json:"tree"` + Calls []parityFilesCall `json:"calls"` +} + +func parityNRGBA(w, h int, seed int) *image.NRGBA { + img := image.NewNRGBA(image.Rect(0, 0, w, h)) + for i := range img.Pix { + img.Pix[i] = byte((i*37 + seed*11) % 256) + } + return img +} + +func pixOf(img image.Image) parityPix { + n := toNRGBA(img) + b := n.Bounds() + return parityPix{W: b.Dx(), H: b.Dy(), Pix: append([]byte(nil), n.Pix...)} +} + +// parityJPEGExif : un en-tête JPEG avec un segment APP1 EXIF portant +// l'orientation o (ordre d'octets « II » ou « MM »), précédé d'un APP0. +func parityJPEGExif(order string, o uint16, tagFirst bool) []byte { + be := order == "MM" + u16 := func(v uint16) []byte { + if be { + return []byte{byte(v >> 8), byte(v)} + } + return []byte{byte(v), byte(v >> 8)} + } + u32 := func(v uint32) []byte { + if be { + return []byte{byte(v >> 24), byte(v >> 16), byte(v >> 8), byte(v)} + } + return []byte{byte(v), byte(v >> 8), byte(v >> 16), byte(v >> 24)} + } + tiff := []byte(order) + tiff = append(tiff, u16(0x2A)...) + tiff = append(tiff, u32(8)...) + entries := [][]byte{} + other := append(append(append(u16(0x010F), u16(2)...), u32(1)...), u32(0)...) + orient := append(append(append(u16(0x0112), u16(3)...), u32(1)...), append(u16(o), 0, 0)...) + if tagFirst { + entries = append(entries, orient, other) + } else { + entries = append(entries, other, orient) + } + tiff = append(tiff, u16(uint16(len(entries)))...) + for _, e := range entries { + tiff = append(tiff, e...) + } + app1 := append([]byte("Exif\x00\x00"), tiff...) + out := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x06, 'J', 'F', 'I', 'F'} + out = append(out, 0xFF, 0xE1, byte((len(app1)+2)>>8), byte(len(app1)+2)) + out = append(out, app1...) + return append(out, 0xFF, 0xDA, 0x00, 0x02) +} + +func TestPariteFichiers(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("journal tiré sous Unix") + } + home := testHome(t) + if r, err := filepath.EvalSymlinks(home); err == nil { + home = r + } + t.Setenv("LOKI_HOME", home) + t.Setenv("LOKI_WORKSPACE", filepath.Join(home, "workspace")) + t.Setenv("TMPDIR", filepath.Join(home, "tmp")) + // agentWorkspace est mémorisé pour tout le process (sync.Once) : le dossier + // de travail de CE test est imposé, comme dans withWorkspace. + prevWS := agentWorkspace() + workspacePath = filepath.Join(home, "workspace") + _ = os.MkdirAll(workspacePath, 0o755) + t.Cleanup(func() { workspacePath = prevWS }) + _ = os.MkdirAll(filepath.Join(home, "tmp"), 0o755) + date := time.Now().Format("2006-01-02") + norm := func(s string) string { + return strings.ReplaceAll(strings.ReplaceAll(s, home, "{{HOME}}"), date, "{{DATE}}") + } + var fx parityFilesFixture + + fx.UploadName = map[string]string{} + for _, n := range []string{"rapport.pdf", "../../etc/passwd", `C:\Users\moi\photo.jpg`, "a:b*c?d\"eg|h.txt", + " .cache ", "..", "", "x\x01y.txt", strings.Repeat("n", 130) + ".pdf", strings.Repeat("n", 130) + "." + strings.Repeat("e", 20)} { + fx.UploadName[n] = safeUploadName(n) + } + fx.Slug = map[string]string{} + for _, s := range []string{"Déjà vu", " Rapport_Final.v2 ", "!!!", "", strings.Repeat("abc ", 20), "--a--"} { + fx.Slug[s] = slugify(s) + } + for _, c := range []struct { + in []byte + }{ + {parityJPEGExif("II", 6, true)}, {parityJPEGExif("MM", 8, false)}, {parityJPEGExif("II", 1, false)}, + {parityJPEGExif("II", 6, true)[:30]}, {[]byte{0xFF, 0xD8, 0xFF, 0xDA}}, {[]byte("pas un jpeg")}, + } { + fx.Exif = append(fx.Exif, struct { + In []byte `json:"in"` + Out int `json:"out"` + }{c.in, exifOrientation(c.in)}) + } + src := parityNRGBA(5, 3, 1) + fx.OrientIn = pixOf(src) + for o := 1; o <= 8; o++ { + fx.Orient = append(fx.Orient, struct { + Orient int `json:"orient"` + Out parityPix `json:"out"` + }{o, pixOf(applyOrientation(src, o))}) + } + for _, c := range []struct{ w, h, max int }{{7, 5, 3}, {5, 9, 4}, {10, 10, 3}, {3, 2, 5}} { + in := parityNRGBA(c.w, c.h, c.w*c.h) + fx.Scale = append(fx.Scale, struct { + In parityPix `json:"in"` + Max int `json:"max"` + Out parityPix `json:"out"` + }{pixOf(in), c.max, pixOf(scaleDownToMax(in, c.max))}) + } + var pngBuf bytes.Buffer + _ = png.Encode(&pngBuf, parityNRGBA(4, 3, 2)) + pngBytes := pngBuf.Bytes() + for _, in := range [][]byte{pngBytes[:16], {0xFF, 0xD8, 0xFF, 0xE0}, []byte("GIF89a\x01\x00"), []byte("RIFF\x10\x00\x00\x00WEBPVP8 "), + []byte("BM\x00\x00"), {0, 0, 1, 0, 1, 0}, []byte(""), []byte("x"), []byte("bonjour"), {}} { + fx.Sniff = append(fx.Sniff, struct { + In []byte `json:"in"` + Out string `json:"out"` + }{in, http.DetectContentType(in)}) + } + + // Arborescence : la discussion c-fichiers, une ancienne capture d'une autre + // discussion, des fichiers d'avant le rangement à la racine. + convActiveRemember("c-fichiers") + conv := "workspace/discussions/c-fichiers/" + fx.Tree = map[string]parityFileNode{ + conv + "rapport.md": {Data: []byte("# Rapport\n"), Mtime: 1700000030}, + conv + "image.png": {Data: pngBytes, Mtime: 1700000040}, + conv + "faux.png": {Data: []byte("pas une image"), Mtime: 1700000005}, + conv + "notes/a.txt": {Data: []byte("alpha"), Mtime: 1700000010}, + conv + "notes/sous/b.txt": {Data: []byte("beta!!"), Mtime: 1700000011}, + conv + "notes/sous": {Dir: true, Mtime: 1700000012}, + conv + "notes": {Dir: true, Mtime: 1700000020}, + "workspace/vieux.txt": {Data: []byte("old"), Mtime: 1700000001}, + "workspace/uploads/ancien.pdf": {Data: []byte("%PDF"), Mtime: 1700000002}, + "workspace/uploads": {Dir: true, Mtime: 1700000003}, + "workspace/discussions/c-autre/captures/x.jpg": {Data: []byte("jpeg?"), Mtime: 1700000004}, + } + keys := make([]string, 0, len(fx.Tree)) + for k := range fx.Tree { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { return len(keys[i]) > len(keys[j]) }) // fichiers profonds d'abord + for _, k := range keys { + n := fx.Tree[k] + p := filepath.Join(home, k) + if n.Dir { + _ = os.MkdirAll(p, 0o755) + continue + } + _ = os.MkdirAll(filepath.Dir(p), 0o755) + if err := os.WriteFile(p, n.Data, 0o644); err != nil { + t.Fatal(err) + } + } + for _, k := range keys { // dates des dossiers après leur contenu + n := fx.Tree[k] + ts := time.Unix(n.Mtime, 0) + if err := os.Chtimes(filepath.Join(home, k), ts, ts); err != nil { + t.Fatal(err) + } + } + w := httptest.NewRecorder() + handleInternalConvWorkspace(w, httptest.NewRequest("POST", "/internal/v1/conv/workspace", strings.NewReader("{}"))) + var ws map[string]string + _ = json.Unmarshal([]byte(norm(w.Body.String())), &ws) + fx.Workspace = ws + + idRe := regexp.MustCompile(`\.upload-[0-9]+\.part`) + convDir := filepath.Join(home, conv) + tree := func() map[string]int64 { + out := map[string]int64{} + _ = filepath.Walk(convDir, func(p string, info os.FileInfo, err error) error { + if err == nil && !info.IsDir() { + rel, _ := filepath.Rel(convDir, p) + out[idRe.ReplaceAllString(filepath.ToSlash(rel), ".upload-{{N}}.part")] = info.Size() + } + return nil + }) + return out + } + lastID := "" + call := func(method, url, body string) parityFilesCall { + body = strings.ReplaceAll(body, "{{ID}}", lastID) + w := httptest.NewRecorder() + r := httptest.NewRequest(method, url, strings.NewReader(body)) + path := url + if i := strings.IndexByte(url, '?'); i >= 0 { + path = url[:i] + } + switch path { + case "/api/chat/files": + handleChatFiles(w, r) + case "/api/chat/files/zip": + handleChatFilesZip(w, r) + case "/api/chat/file": + handleChatFile(w, r) + case "/api/chat/file/delete": + handleChatFileDelete(w, r) + case "/api/chat/image": + handleChatImage(w, r) + case "/api/chat/upload": + handleChatUpload(w, r) + default: + t.Fatalf("route inattendue %s", url) + } + shown := body + if lastID != "" { + shown = strings.ReplaceAll(body, lastID, "{{ID}}") + } + c := parityFilesCall{Method: method, URL: url, Body: shown, Status: w.Code} + ct := w.Header().Get("Content-Type") + switch { + case strings.HasPrefix(ct, "application/json"): + txt := norm(w.Body.String()) + var m map[string]any + if err := json.Unmarshal([]byte(txt), &m); err == nil { + if id, ok := m["id"].(string); ok { + lastID = id + m["id"] = "{{ID}}" + } + if d, ok := m["data"].(string); ok && path == "/api/chat/files/zip" { + raw, _ := base64.StdEncoding.DecodeString(d) + zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) + if err != nil { + t.Fatal(err) + } + c.Zip = map[string][]byte{} + for _, f := range zr.File { + rc, _ := f.Open() + b, _ := io.ReadAll(rc) + rc.Close() + c.Zip[f.Name] = b + } + m["data"] = "…" + } + if _, ok := m["size"]; ok && path == "/api/chat/files/zip" { + m["size"] = "…" + } + c.Out = m + } else { + _ = json.Unmarshal([]byte(txt), &c.Out) + } + case ct == "image/jpeg" && strings.Contains(url, "thumb="): + img, _, err := image.Decode(bytes.NewReader(w.Body.Bytes())) + if err != nil { + t.Fatal(err) + } + c.Thumb = []int{img.Bounds().Dx(), img.Bounds().Dy()} + c.Headers = map[string]string{} + default: + c.Raw = w.Body.Bytes() + c.Headers = map[string]string{} + } + if c.Headers != nil { + for _, h := range []string{"Content-Type", "Content-Disposition", "X-Content-Type-Options", "Content-Security-Policy", "Cache-Control"} { + if v := w.Header().Get(h); v != "" { + c.Headers[h] = v + } + } + } + if method == "POST" { + c.Tree = tree() + } + if s, ok := c.Out.(map[string]any); ok { + if p, ok := s["path"].(string); ok { + s["path"] = idRe.ReplaceAllString(p, ".upload-{{N}}.part") + } + } + return c + } + for _, c := range []struct{ m, u, b string }{ + {"GET", "/api/chat/files", ""}, + {"GET", "/api/chat/files?dir=notes", ""}, + {"GET", "/api/chat/files?dir=notes/sous/", ""}, + {"GET", "/api/chat/files?dir=../../..", ""}, + {"GET", "/api/chat/files?dir=rapport.md", ""}, + {"GET", "/api/chat/files?scope=legacy", ""}, + {"GET", "/api/chat/file?path=rapport.md&meta=1", ""}, + {"GET", "/api/chat/file?path=rapport.md&b64=1&offset=2&len=4", ""}, + {"GET", "/api/chat/file?path=rapport.md&b64=1&offset=5", ""}, + {"GET", "/api/chat/file?path=rapport.md&b64=1&offset=99", ""}, + {"GET", "/api/chat/file?path=../../vieux.txt", ""}, + {"GET", "/api/chat/file?path=absent.txt", ""}, + {"GET", "/api/chat/file?path=", ""}, + {"GET", "/api/chat/file?path=captures/c-autre/x.jpg&meta=1", ""}, + {"GET", "/api/chat/file?path=uploads/ancien.pdf&meta=1", ""}, + {"GET", "/api/chat/file?path=notes", ""}, + {"GET", "/api/chat/file?path=/notes/a.txt", ""}, + {"GET", "/api/chat/image?path=faux.png", ""}, + {"GET", "/api/chat/image?path=image.png", ""}, + {"GET", "/api/chat/image?path=image.png&thumb=2", ""}, + {"GET", "/api/chat/image?path=rapport.md&thumb=64", ""}, + {"POST", "/api/chat/upload", `{"name":"../Mon fichier?.txt","data":"aGVsbG8="}`}, + {"POST", "/api/chat/upload", `{"name":"Mon fichier?.txt","data":"aGVsbG8gIQ=="}`}, + {"POST", "/api/chat/upload", `{"name":"gros.bin","data":"AAEC","more":true,"size":5}`}, + {"POST", "/api/chat/upload", `{"id":"{{ID}}","data":"data:application/octet-stream;base64,AwQ=","more":false}`}, + {"POST", "/api/chat/upload", `{"id":"inconnu","data":"AA=="}`}, + {"POST", "/api/chat/upload", `{"name":"vide.txt","data":""}`}, + {"POST", "/api/chat/upload", `{"name":"x","data":"!!!"}`}, + {"POST", "/api/chat/upload", `pas du json`}, + {"POST", "/api/chat/file/delete", `{"path":""}`}, + {"POST", "/api/chat/file/delete", `{"path":"."}`}, + {"POST", "/api/chat/file/delete", `{"path":"../.."}`}, + {"POST", "/api/chat/file/delete", `{"path":"discussions","scope":"legacy"}`}, + {"POST", "/api/chat/file/delete", `{"path":"absent"}`}, + {"POST", "/api/chat/file/delete", `{"path":"faux.png"}`}, + {"POST", "/api/chat/file/delete", `{"path":"notes/sous"}`}, + {"GET", "/api/chat/files/zip", ""}, + {"GET", "/api/chat/files/zip?meta=1", ""}, + {"GET", "/api/chat/files/zip?b64=1&offset=0&len=99999999", ""}, + {"GET", "/api/chat/files/zip?b64=1&offset=-1", ""}, + {"GET", "/api/chat/files/zip?meta=1&scope=legacy", ""}, + {"GET", "/api/chat/files/zip?b64=1&scope=legacy", ""}, + } { + fx.Calls = append(fx.Calls, call(c.m, c.u, c.b)) + } + + got, err := parityJSON(fx) + if err != nil { + t.Fatal(err) + } + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(parityFilesFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityFilesFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityFilesFile) + if err != nil { + t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err) + } + if string(got) != string(want) { + t.Errorf("les fichiers de la discussion ne produisent plus le journal rangé dans %s.\n"+ + "Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteFichiers,\n"+ + "puis cargo test --test convfiles dit si le port Rust suit.\n--- obtenu ---\n%s", parityFilesFile, got) + } +} diff --git a/internal/loki/web_internal.go b/internal/loki/web_internal.go index 5fad81b..719f34d 100644 --- a/internal/loki/web_internal.go +++ b/internal/loki/web_internal.go @@ -107,6 +107,8 @@ func withInternalAPI(next http.Handler) http.Handler { handleInternalConvActive(w, r) case "/internal/v1/conv/dropped": handleInternalConvDropped(w, r) + case "/internal/v1/conv/workspace": + handleInternalConvWorkspace(w, r) default: http.NotFound(w, r) } diff --git a/internal/loki/web_server.go b/internal/loki/web_server.go index 70623b7..d5538c3 100644 --- a/internal/loki/web_server.go +++ b/internal/loki/web_server.go @@ -137,8 +137,9 @@ func newWebMux() *http.ServeMux { // range dans le dossier de leur discussion (une fois par process). migrateConvFiles() // 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() + // en mémoire, aucun .part ne survit utilement à l'arrêt du process (sauf + // quand la façade tient les fichiers, facade_files.go). + cleanStartupUploadParts() // 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. @@ -221,8 +222,9 @@ func newWebMux() *http.ServeMux { // withFacadeChat : routes du chat remises à la façade quand elle tient la // conversation (LOKI_CHAT=rust, facade_chat.go), celles des modèles quand // elle les tient (LOKI_MODELS=rust, facade_models.go), celles de la dictée - // (LOKI_DICTATE=rust, facade_dictate.go) et des réglages partagés - // (LOKI_SETTINGS=rust, facade_settings.go) ; sans effet sinon. + // (LOKI_DICTATE=rust, facade_dictate.go), des réglages partagés + // (LOKI_SETTINGS=rust, facade_settings.go) et des fichiers de la + // discussion (LOKI_FILES=rust, facade_files.go) ; sans effet sinon. api := func(path string, h http.HandlerFunc) { mux.HandleFunc(path, requireWebAuth(withFacadeChat(path, h))) } api("/api/ping", handlePing) api("/api/status", handleStatus) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 7221873..0bbcbed 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -17,12 +17,27 @@ dependencies = [ "memchr", ] +[[package]] +name = "arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "base64" version = "0.23.1" @@ -50,6 +65,18 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.12.1" @@ -92,6 +119,12 @@ dependencies = [ "rand_core", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "combine" version = "4.6.8" @@ -142,6 +175,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + [[package]] name = "crypto-common" version = "0.2.2" @@ -157,6 +196,17 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +[[package]] +name = "derive_arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "digest" version = "0.11.3" @@ -201,6 +251,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "filetime" version = "0.2.29" @@ -224,7 +283,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.9.1", "zlib-rs", ] @@ -326,6 +385,16 @@ dependencies = [ "rand_core", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "h2" version = "0.4.20" @@ -576,6 +645,23 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + [[package]] name = "indexmap" version = "2.14.2" @@ -692,6 +778,7 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" name = "loki-rs" version = "0.1.0" dependencies = [ + "base64", "bytes", "bzip2", "flate2", @@ -701,6 +788,7 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", + "image", "libc", "regex", "reqwest", @@ -714,6 +802,7 @@ dependencies = [ "tokio", "tokio-tungstenite", "url", + "zip", ] [[package]] @@ -722,6 +811,16 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "miniz_oxide" version = "0.9.1" @@ -743,6 +842,25 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -767,6 +885,19 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -785,6 +916,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + [[package]] name = "quote" version = "1.0.47" @@ -1576,6 +1713,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "winapi-util" version = "0.1.11" @@ -1762,6 +1905,22 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror", +] + [[package]] name = "zlib-rs" version = "0.6.8" @@ -1773,3 +1932,18 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/rust/loki-rs/Cargo.toml b/rust/loki-rs/Cargo.toml index 9d5e62e..81ab438 100644 --- a/rust/loki-rs/Cargo.toml +++ b/rust/loki-rs/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] +base64 = "0.23.1" bytes = "1.12.1" bzip2 = "0.6.1" flate2 = "1.1.10" @@ -11,10 +12,12 @@ futures-util = "0.3.34" getrandom = "0.4.3" hex = "0.4.3" http-body-util = "0.1.5" +image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "gif"] } hyper = { version = "1.12.0", features = ["http1", "server", "client"] } hyper-util = { version = "0.1.21", features = ["tokio", "server", "client-legacy", "http1"] } regex = "1.13.1" url = "2.5.8" +zip = { version = "2.4.2", default-features = false, features = ["deflate-flate2", "flate2"] } reqwest = { version = "0.13.5", default-features = false, features = ["rustls-no-provider", "stream", "http2"] } rustls = { version = "0.23.46", default-features = false, features = ["ring", "std", "tls12"], optional = true } serde = { version = "1.0.229", features = ["derive"] } diff --git a/rust/loki-rs/src/api.rs b/rust/loki-rs/src/api.rs index e094f36..c812991 100644 --- a/rust/loki-rs/src/api.rs +++ b/rust/loki-rs/src/api.rs @@ -34,6 +34,8 @@ pub struct Ctx { pub dictate: Option>, /// Réglages partagés tenus par la façade (`LOKI_SETTINGS=rust`). pub settings: bool, + /// Fichiers de la discussion tenus par la façade (`LOKI_FILES=rust`). + pub files: Option>, /// Jeton de l'API interne : une requête qui le porte vient du Go. pub internal_token: String, } @@ -42,7 +44,11 @@ impl Ctx { /// La façade tient-elle au moins un lot (le Go lui remet alors le trafic /// de ce lot qui lui arrive par le tunnel) ? pub fn holds_any(&self) -> bool { - self.chat.is_some() || self.models.is_some() || self.dictate.is_some() || self.settings + self.chat.is_some() + || self.models.is_some() + || self.dictate.is_some() + || self.settings + || self.files.is_some() } /// La requête vient du Go (jeton interne valide) : déjà authentifiée par diff --git a/rust/loki-rs/src/convfiles.rs b/rust/loki-rs/src/convfiles.rs new file mode 100644 index 0000000..3d634a6 --- /dev/null +++ b/rust/loki-rs/src/convfiles.rs @@ -0,0 +1,1573 @@ +//! Fichiers de la discussion — port de `web_files.go`, `web_files_zip.go`, +//! `web_upload.go` (dépôt, téléchargement, image), `web_thumb.go`, +//! `web_upload_orient.go` (orientation, réduction) et des chemins de +//! `chat_convfiles.go` (lot 11 de la migration), servis par la façade quand +//! `LOKI_FILES=rust` (expérimental ; le Go par défaut) : +//! +//! - `POST /api/chat/upload` : dépôt en un ou plusieurs morceaux base64 ; +//! - `GET /api/chat/files` : panneau Fichiers ; +//! - `GET /api/chat/files/zip` : toute la discussion en .zip (fiche, tranches +//! base64, ou brut) ; +//! - `POST /api/chat/file/delete` : suppression ; +//! - `GET /api/chat/file` : téléchargement (fiche, tranche base64, ou brut) ; +//! - `GET /api/chat/image` : affichage d'une image (vignette `thumb=`). +//! +//! Le dossier de la discussion active reste décidé par le Go +//! (`/internal/v1/conv/workspace`), qui la connaît dans tous les cas. +//! +//! Écarts assumés : octets des vignettes JPEG (encodeur différent : mêmes +//! dimensions, même redressement, même réduction par moyennage) ; horodatage +//! des entrées .zip en UTC ; une seule plage d'octets par requête de +//! téléchargement (le Go en acceptait plusieurs, en multipart). + +use std::collections::HashMap; +use std::io::{Read, Seek, Write}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use base64::Engine; +use bytes::Bytes; +use http_body_util::{BodyExt, Full, Limited, StreamBody}; +use hyper::body::{Frame, Incoming}; +use hyper::header::{self, HeaderMap, HeaderValue}; +use hyper::{Method, Request, Response, StatusCode}; +use serde::Deserialize; +use serde_json::{Value, json}; + +use crate::api::Ctx; +use crate::auth::json as send_json; +use crate::downloads::{disk_free, human_bytes}; +use crate::paths::{go_clean, go_join}; +use crate::proxy::Body; + +const CONV_FILES_ROOT: &str = "discussions"; +const UPLOADS_SUB: &str = "uploads"; +const CAPTURE_DIR: &str = "captures"; +const UPLOAD_MAX_BYTES: i64 = 1 << 30; +const UPLOAD_CHUNK_MAX: usize = 8 << 20; +const DOWNLOAD_CHUNK_MAX: i64 = 8 << 20; +const UPLOAD_SPACE_MARGIN: i64 = 512 << 20; +const UP_SESSION_TTL: Duration = Duration::from_secs(600); +const WS_WALK_LIMIT: usize = 20000; +const THUMB_MAX_SOURCE: u64 = 40 << 20; +const THUMB_CACHE_MAX: usize = 300; +/// Plus grand côté d'une image redressée pour l'affichage (vignette). +const E2E_HEADER: &str = "x-loki-e2e"; + +/// Routes servies ici. +pub fn handles(path: &str) -> bool { + matches!( + path, + "/api/chat/upload" + | "/api/chat/files" + | "/api/chat/files/zip" + | "/api/chat/file/delete" + | "/api/chat/file" + | "/api/chat/image" + ) +} + +// --------------------------------------------------------------------------- +// Fonctions pures +// --------------------------------------------------------------------------- + +/// `safeUploadName` : un nom de fichier simple, sans chemin ni caractère +/// interdit, tronqué à 120 octets en gardant l'extension. +pub fn safe_upload_name(name: &str) -> String { + let name = name.replace('\\', "/"); + let base = name.trim_end_matches('/').rsplit('/').next().unwrap_or(""); + let base = if base.is_empty() { "." } else { base }; + let mut b = String::new(); + for r in base.chars() { + if r.is_control() || matches!(r, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { + b.push('_'); + } else { + b.push(r); + } + } + let mut name = b + .trim_matches(char::is_whitespace) + .trim_matches('.') + .to_string(); + if name.is_empty() { + name = "fichier".into(); + } + if name.len() > 120 { + let mut ext = go_ext(&name).to_string(); + if ext.len() > 16 { + ext.clear(); + } + let mut cut = 120 - ext.len(); + while !name.is_char_boundary(cut) { + cut -= 1; + } + name = format!("{}{ext}", &name[..cut]); + } + name +} + +/// `filepath.Ext`. +fn go_ext(name: &str) -> &str { + match name.rfind(['.', '/']) { + Some(i) if name.as_bytes()[i] == b'.' => &name[i..], + _ => "", + } +} + +/// `slugify` : minuscules ASCII et tirets, 40 caractères au plus. +pub fn slugify(s: &str) -> String { + let mut b = String::new(); + for r in s.trim_matches(char::is_whitespace).to_lowercase().chars() { + if r.is_ascii_lowercase() || r.is_ascii_digit() { + b.push(r); + } else if matches!(r, ' ' | '-' | '_' | '.') && !b.is_empty() && !b.ends_with('-') { + b.push('-'); + } + if b.len() >= 40 { + break; + } + } + b.trim_matches('-').to_string() +} + +/// `safeConvID`. +fn safe_conv_id(id: &str) -> String { + let id = id.trim(); + if id.is_empty() + || id.len() > 64 + || !id + .chars() + .all(|r| r == '-' || r == '_' || r.is_numeric() || r.is_ascii_alphabetic()) + { + return String::new(); + } + id.to_string() +} + +/// `exifOrientation` : le tag Orientation (1..8) d'un JPEG, 0 sinon. +pub fn exif_orientation(b: &[u8]) -> i32 { + if b.len() < 4 || b[0] != 0xFF || b[1] != 0xD8 { + return 0; + } + let mut pos = 2; + while pos + 4 <= b.len() { + if b[pos] != 0xFF { + return 0; + } + let marker = b[pos + 1]; + if marker == 0xDA || marker == 0xD9 { + return 0; + } + let seg_len = u16::from_be_bytes([b[pos + 2], b[pos + 3]]) as usize; + if seg_len < 2 || pos + 2 + seg_len > b.len() { + return 0; + } + let seg = &b[pos + 4..pos + 2 + seg_len]; + if marker == 0xE1 && seg.len() >= 6 && &seg[..6] == b"Exif\0\0" { + return exif_from_tiff(&seg[6..]); + } + pos += 2 + seg_len; + } + 0 +} + +fn exif_from_tiff(t: &[u8]) -> i32 { + if t.len() < 8 { + return 0; + } + let be = match &t[..2] { + b"II" => false, + b"MM" => true, + _ => return 0, + }; + let u16_at = |i: usize| { + let x = [t[i], t[i + 1]]; + if be { + u16::from_be_bytes(x) + } else { + u16::from_le_bytes(x) + } + }; + let u32_at = |i: usize| { + let x = [t[i], t[i + 1], t[i + 2], t[i + 3]]; + if be { + u32::from_be_bytes(x) + } else { + u32::from_le_bytes(x) + } + }; + if u16_at(2) != 0x2A { + return 0; + } + let ifd = u32_at(4) as usize; + if ifd < 8 || ifd + 2 > t.len() { + return 0; + } + let n = u16_at(ifd) as usize; + let mut entry = ifd + 2; + for _ in 0..n { + if entry + 12 > t.len() { + return 0; + } + if u16_at(entry) == 0x0112 { + return u16_at(entry + 8) as i32; + } + entry += 12; + } + 0 +} + +/// Une image RGBA non prémultipliée (`*image.NRGBA`). +#[derive(Debug, Clone, PartialEq)] +pub struct Rgba { + pub w: usize, + pub h: usize, + pub pix: Vec, +} + +/// `applyOrientation` : rotations et miroirs du tag EXIF cuits dans les pixels. +pub fn apply_orientation(src: &Rgba, orient: i32) -> Rgba { + let (w, h) = (src.w, src.h); + let (dw, dh) = if orient >= 5 { (h, w) } else { (w, h) }; + let mut dst = vec![0u8; dw * dh * 4]; + for y in 0..h { + for x in 0..w { + let (nx, ny) = match orient { + 2 => (w - 1 - x, y), + 3 => (w - 1 - x, h - 1 - y), + 4 => (x, h - 1 - y), + 5 => (y, x), + 6 => (h - 1 - y, x), + 7 => (h - 1 - y, w - 1 - x), + 8 => (y, w - 1 - x), + _ => (x, y), + }; + let s = (y * w + x) * 4; + let d = (ny * dw + nx) * 4; + dst[d..d + 4].copy_from_slice(&src.pix[s..s + 4]); + } + } + Rgba { + w: dw, + h: dh, + pix: dst, + } +} + +/// `scaleDownToMax` : réduction par moyennage d'aire, ratio conservé. +pub fn scale_down_to_max(src: &Rgba, max: usize) -> Rgba { + let (w, h) = (src.w, src.h); + if w <= max && h <= max { + return src.clone(); + } + let scale = if h > w { + max as f64 / h as f64 + } else { + max as f64 / w as f64 + }; + let nw = ((w as f64 * scale).round() as usize).max(1); + let nh = ((h as f64 * scale).round() as usize).max(1); + let mut dst = vec![0u8; nw * nh * 4]; + for dy in 0..nh { + let sy0 = dy * h / nh; + let sy1 = ((dy + 1) * h / nh).max(sy0 + 1); + for dx in 0..nw { + let sx0 = dx * w / nw; + let sx1 = ((dx + 1) * w / nw).max(sx0 + 1); + let mut acc = [0u64; 4]; + let mut cnt = 0u64; + for sy in sy0..sy1 { + for sx in sx0..sx1 { + let i = (sy * w + sx) * 4; + for (a, p) in acc.iter_mut().zip(&src.pix[i..i + 4]) { + *a += *p as u64; + } + cnt += 1; + } + } + let di = (dy * nw + dx) * 4; + for c in 0..4 { + dst[di + c] = (acc[c] / cnt) as u8; + } + } + } + Rgba { + w: nw, + h: nh, + pix: dst, + } +} + +/// `http.DetectContentType`, réduit aux signatures d'images (le reste n'est +/// jamais servi comme image) ; "" si ce n'en est pas une. +pub fn sniff_image(head: &[u8]) -> &'static str { + let starts = |sig: &[u8]| head.starts_with(sig); + if starts(b"\x00\x00\x01\x00") || starts(b"\x00\x00\x02\x00") { + "image/x-icon" + } else if starts(b"BM") { + "image/bmp" + } else if starts(b"GIF87a") || starts(b"GIF89a") { + "image/gif" + } else if head.len() >= 14 && &head[..4] == b"RIFF" && &head[8..14] == b"WEBPVP" { + "image/webp" + } else if starts(b"\x89PNG\r\n\x1a\n") { + "image/png" + } else if starts(b"\xff\xd8\xff") { + "image/jpeg" + } else { + "" + } +} + +/// `relWithin` : `abs` est-il DANS `root` (liens résolus des deux côtés) ? Rend +/// le chemin relatif en `/`. +pub fn rel_within(root: &str, abs: &str) -> Option { + if root.is_empty() { + return None; + } + let resolve = |p: &str| { + std::fs::canonicalize(p) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|_| go_clean(p)) + }; + let (root, abs) = (resolve(root), resolve(abs)); + if abs == root { + return None; + } + let prefix = if root.ends_with('/') { + root.clone() + } else { + format!("{root}/") + }; + abs.strip_prefix(&prefix).map(str::to_string) +} + +/// `wsResolve` : un chemin relatif du client sous la racine du panneau. +pub fn ws_resolve(root: &str, rel: &str) -> Option { + let rel = rel.trim().replace('\\', "/"); + let rel = rel.trim_start_matches('/'); + if rel.is_empty() || rel == "." { + return Some(root.to_string()); + } + let abs = go_join(&[root, rel]); + rel_within(root, &abs).map(|_| abs) +} + +/// `dirSize` : somme du contenu (liens non suivis), bornée. +pub fn dir_size(root: &Path) -> (i64, usize) { + fn walk(dir: &Path, total: &mut i64, count: &mut usize) { + if *count >= WS_WALK_LIMIT { + return; + } + let Ok(rd) = std::fs::read_dir(dir) else { + return; + }; + let mut ents: Vec<_> = rd.filter_map(Result::ok).collect(); + ents.sort_by_key(|e| e.file_name()); + for e in ents { + if *count >= WS_WALK_LIMIT { + return; + } + let Ok(ft) = e.file_type() else { continue }; + if ft.is_symlink() { + continue; + } + if ft.is_dir() { + walk(&e.path(), total, count); + continue; + } + if let Ok(m) = e.metadata() { + *total += m.len() as i64; + *count += 1; + } + } + } + let (mut total, mut count) = (0, 0); + walk(root, &mut total, &mut count); + (total, count) +} + +fn mtime_secs(m: &std::fs::Metadata) -> i64 { + m.modified() + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map_or(0, |d| d.as_secs() as i64) +} + +/// Les extensions présentées comme images dans le panneau. +fn is_image_ext(name: &str) -> bool { + matches!( + go_ext(name).to_lowercase().as_str(), + ".png" | ".jpg" | ".jpeg" | ".gif" | ".webp" | ".bmp" | ".svg" | ".avif" + ) +} + +/// La discussion active vue du Go. +#[derive(Debug, Clone, Deserialize)] +pub struct Workspace { + pub dir: String, + pub root: String, + #[serde(default)] + pub id: String, + #[serde(default)] + pub title: String, +} + +impl Workspace { + fn conv_dir_for(&self, id: &str) -> String { + let id = safe_conv_id(id); + if id.is_empty() { + self.root.clone() + } else { + go_join(&[&self.root, CONV_FILES_ROOT, &id]) + } + } + + /// `filesRoot` : la discussion, ou la racine pour `scope=legacy`. + fn files_root(&self, scope: &str) -> (String, bool) { + if scope.trim() == "legacy" { + (self.root.clone(), true) + } else { + (self.dir.clone(), false) + } + } + + /// `workspaceFile` : discussion d'abord, emplacements d'avant ensuite. + pub fn workspace_file(&self, rel: &str) -> Option { + let rel = rel.trim().replace('\\', "/"); + let rel = rel.trim_start_matches('/'); + if rel.is_empty() { + return None; + } + let p = go_join(&[&self.dir, rel]); + rel_within(&self.dir, &p)?; + if std::fs::metadata(&p).is_ok() { + return Some(p); + } + if let Some(l) = self.legacy_file(rel) { + return Some(l); + } + Some(p) + } + + fn legacy_file(&self, rel: &str) -> Option { + let file = |p: &str| std::fs::metadata(p).is_ok_and(|m| !m.is_dir()); + let parts: Vec<&str> = rel.splitn(3, '/').collect(); + if parts.len() == 3 && parts[0] == CAPTURE_DIR && !safe_conv_id(parts[1]).is_empty() { + let p = go_join(&[&self.conv_dir_for(parts[1]), CAPTURE_DIR, parts[2]]); + if file(&p) { + return Some(p); + } + } + let p = go_join(&[&self.root, rel]); + rel_within(&self.root, &p)?; + file(&p).then_some(p) + } + + /// `legacyCount`. + fn legacy_count(&self) -> usize { + std::fs::read_dir(&self.root) + .map(|rd| { + rd.filter_map(Result::ok) + .filter(|e| { + let n = e.file_name().to_string_lossy().into_owned(); + n != CONV_FILES_ROOT && !n.starts_with('.') + }) + .count() + }) + .unwrap_or(0) + } +} + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + +struct UpSession { + f: std::fs::File, + path: PathBuf, + name: String, + written: i64, +} + +struct UpEntry { + sess: tokio::sync::Mutex>, + last: Mutex, + busy: std::sync::atomic::AtomicBool, +} + +/// Les fichiers de la discussion, tenus par la façade. +pub struct ConvFiles { + tmp_dir: PathBuf, + uploads: Mutex>>, + thumbs: Mutex>, +} + +impl Default for ConvFiles { + fn default() -> Self { + Self::new(std::env::temp_dir()) + } +} + +fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { + m.lock().unwrap_or_else(|e| e.into_inner()) +} + +impl ConvFiles { + /// `tmp_dir` : où se construisent les archives .zip. + pub fn new(tmp_dir: PathBuf) -> Self { + ConvFiles { + tmp_dir, + uploads: Mutex::new(HashMap::new()), + thumbs: Mutex::new(HashMap::new()), + } + } + + /// `sweepUploadSessions` : les envois abandonnés sont fermés et effacés. + fn sweep(&self) { + let mut map = lock(&self.uploads); + let stale: Vec = map + .iter() + .filter(|(_, e)| { + !e.busy.load(std::sync::atomic::Ordering::SeqCst) + && lock(&e.last).elapsed() > UP_SESSION_TTL + }) + .map(|(k, _)| k.clone()) + .collect(); + for k in stale { + if let Some(e) = map.remove(&k) + && let Ok(mut s) = e.sess.try_lock() + && let Some(s) = s.take() + { + let _ = std::fs::remove_file(&s.path); + } + } + } + + /// `chatThumb` : vignette JPEG (grand côté borné 64..1600), en cache tant + /// que le fichier ne change pas. + fn thumb(&self, path: &str, meta: &std::fs::Metadata, px: i64) -> Result { + let px = px.clamp(64, 1600) as usize; + if meta.len() > THUMB_MAX_SOURCE { + return Err("image trop lourde pour une vignette".into()); + } + let key = format!("{path}\0{px}"); + let modt = meta.modified().unwrap_or(UNIX_EPOCH); + if let Some((m, s, d)) = lock(&self.thumbs).get(&key) + && *m == modt + && *s == meta.len() + { + return Ok(d.clone()); + } + let raw = std::fs::read(path).map_err(|e| e.to_string())?; + let img = image::load_from_memory(&raw) + .map_err(|_| "format non pris en charge pour une vignette".to_string())? + .to_rgba8(); + let mut src = Rgba { + w: img.width() as usize, + h: img.height() as usize, + pix: img.into_raw(), + }; + let o = exif_orientation(&raw); + if (2..=8).contains(&o) { + src = apply_orientation(&src, o); + } + if src.w > px || src.h > px { + src = scale_down_to_max(&src, px); + } + // Fond blanc sous la transparence. + let mut rgb = Vec::with_capacity(src.w * src.h * 3); + for p in src.pix.chunks_exact(4) { + let a = p[3] as u32; + for c in &p[..3] { + rgb.push(((*c as u32 * a + 255 * (255 - a) + 127) / 255) as u8); + } + } + let mut out = Vec::new(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 78) + .encode( + &rgb, + src.w as u32, + src.h as u32, + image::ExtendedColorType::Rgb8, + ) + .map_err(|e| e.to_string())?; + let data = Bytes::from(out); + let mut cache = lock(&self.thumbs); + if cache.len() >= THUMB_CACHE_MAX { + let keys: Vec = cache + .keys() + .take(THUMB_CACHE_MAX / 2 + 1) + .cloned() + .collect(); + for k in keys { + cache.remove(&k); + } + } + cache.insert(key, (modt, meta.len(), data.clone())); + Ok(data) + } +} + +/// `cleanStaleUploadParts` : au démarrage, les envois interrompus. +pub fn clean_stale_upload_parts(root: &str) { + let mut dirs = vec![go_join(&[root, UPLOADS_SUB])]; + if let Ok(rd) = std::fs::read_dir(go_join(&[root, CONV_FILES_ROOT])) { + for e in rd.filter_map(Result::ok) { + if e.file_type().is_ok_and(|t| t.is_dir()) { + dirs.push(e.path().join(UPLOADS_SUB).to_string_lossy().into_owned()); + } + } + } + for d in dirs { + let Ok(rd) = std::fs::read_dir(&d) else { + continue; + }; + for e in rd.filter_map(Result::ok) { + let n = e.file_name().to_string_lossy().into_owned(); + if !e.file_type().is_ok_and(|t| t.is_dir()) + && n.starts_with(".upload-") + && n.ends_with(".part") + { + let _ = std::fs::remove_file(e.path()); + } + } + } +} + +/// `uniqueUploadPath` : rapport.pdf, rapport-2.pdf… +fn unique_upload_path(dir: &str, name: &str) -> String { + let p = go_join(&[dir, name]); + if std::fs::symlink_metadata(&p).is_err() { + return p; + } + let ext = go_ext(name); + let base = &name[..name.len() - ext.len()]; + let mut p = p; + for i in 2..1000 { + p = go_join(&[dir, &format!("{base}-{i}{ext}")]); + if std::fs::symlink_metadata(&p).is_err() { + return p; + } + } + p +} + +/// La date locale (`2006-01-02`). +fn local_date() -> String { + #[cfg(unix)] + { + let t = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_secs() as libc::time_t); + let mut tm: libc::tm = unsafe { std::mem::zeroed() }; + // SAFETY : pointeurs valides, structure de sortie initialisée. + if !unsafe { libc::localtime_r(&t, &mut tm) }.is_null() { + return format!( + "{:04}-{:02}-{:02}", + tm.tm_year + 1900, + tm.tm_mon + 1, + tm.tm_mday + ); + } + } + let days = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_secs() / 86400) as i64; + let (y, m, d) = civil(days); + format!("{y:04}-{m:02}-{d:02}") +} + +/// Jours depuis 1970 → (année, mois, jour), calendrier grégorien. +fn civil(days: i64) -> (i64, u32, u32) { + let z = days + 719468; + let era = z.div_euclid(146097); + let doe = z.rem_euclid(146097); + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + (era * 400 + yoe + i64::from(m <= 2), m, d) +} + +/// Date HTTP (`Last-Modified`). +fn http_date(secs: i64) -> String { + let (y, m, d) = civil(secs.div_euclid(86400)); + let rem = secs.rem_euclid(86400); + let wd = ["Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed"] + [secs.div_euclid(86400).rem_euclid(7) as usize]; + let mon = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ][m as usize - 1]; + format!( + "{wd}, {d:02} {mon} {y:04} {:02}:{:02}:{:02} GMT", + rem / 3600, + (rem / 60) % 60, + rem % 60 + ) +} + +/// `url.PathEscape`. +fn path_escape(s: &str) -> String { + crate::hf::go_path_escape(s) +} + +// --------------------------------------------------------------------------- +// Archives +// --------------------------------------------------------------------------- + +/// `buildZip` : liens ignorés, parcours borné, chemins relatifs en `/` ; +/// le dossier des discussions exclu de la vue « hors discussion ». +pub fn build_zip(root: &Path, dest: &Path, exclude_convs: bool) -> Result { + let out = std::fs::File::create(dest).map_err(|e| e.to_string())?; + let mut zw = zip::ZipWriter::new(out); + let mut files = 0usize; + fn walk( + zw: &mut zip::ZipWriter, + dir: &Path, + rel: &str, + exclude: bool, + files: &mut usize, + ) -> Result<(), String> { + let Ok(rd) = std::fs::read_dir(dir) else { + return Ok(()); + }; + let mut ents: Vec<_> = rd.filter_map(Result::ok).collect(); + ents.sort_by_key(|e| e.file_name()); + for e in ents { + if *files >= WS_WALK_LIMIT { + return Ok(()); + } + let Ok(ft) = e.file_type() else { continue }; + if ft.is_symlink() { + continue; + } + let name = e.file_name().to_string_lossy().into_owned(); + let child = if rel.is_empty() { + name.clone() + } else { + format!("{rel}/{name}") + }; + if ft.is_dir() { + if exclude && rel.is_empty() && name == CONV_FILES_ROOT { + continue; + } + walk(zw, &e.path(), &child, exclude, files)?; + continue; + } + let Ok(meta) = e.metadata() else { continue }; + let Ok(mut f) = std::fs::File::open(e.path()) else { + continue; + }; + let mut opts = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated) + .large_file(meta.len() >= 0xFFFF_FFFF); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + opts = opts.unix_permissions(meta.permissions().mode() & 0o777); + } + let secs = mtime_secs(&meta); + let (y, mo, d) = civil(secs.div_euclid(86400)); + let rem = secs.rem_euclid(86400); + if let Ok(dt) = zip::DateTime::from_date_and_time( + y.clamp(1980, 2107) as u16, + mo as u8, + d as u8, + (rem / 3600) as u8, + ((rem / 60) % 60) as u8, + (rem % 60) as u8, + ) { + opts = opts.last_modified_time(dt); + } + zw.start_file(child, opts).map_err(|e| e.to_string())?; + std::io::copy(&mut f, zw).map_err(|e| e.to_string())?; + *files += 1; + } + Ok(()) + } + walk(&mut zw, root, "", exclude_convs, &mut files)?; + zw.finish().map_err(|e| e.to_string())?; + Ok(files) +} + +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- + +fn fail(code: StatusCode, msg: impl Into) -> Response { + send_json(code, &json!({ "ok": false, "error": msg.into() })) +} + +fn query(uri: &hyper::Uri) -> HashMap { + let mut out = HashMap::new(); + for kv in uri + .query() + .unwrap_or("") + .split('&') + .filter(|s| !s.is_empty()) + { + let (k, v) = kv.split_once('=').unwrap_or((kv, "")); + // r.URL.Query().Get : la première valeur. + out.entry(form_decode(k)).or_insert_with(|| form_decode(v)); + } + out +} + +fn form_decode(s: &str) -> String { + let b = s.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while i < b.len() { + match b[i] { + b'+' => out.push(b' '), + b'%' if i + 2 < b.len() => { + match std::str::from_utf8(&b[i + 1..i + 3]) + .ok() + .and_then(|h| u8::from_str_radix(h, 16).ok()) + { + Some(v) => { + out.push(v); + i += 3; + continue; + } + None => out.push(b'%'), + } + } + c => out.push(c), + } + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +/// `strconv.ParseInt` ou 0. +fn parse_i64(s: Option<&String>) -> i64 { + s.and_then(|v| v.parse::().ok()).unwrap_or(0) +} + +async fn workspace(ctx: &Ctx) -> Result { + let v = ctx + .store + .internal_json( + Method::POST, + "/internal/v1/conv/workspace", + Some(&json!({})), + ) + .await?; + serde_json::from_value(v).map_err(|e| e.to_string()) +} + +async fn blocking(f: impl FnOnce() -> T + Send + 'static) -> T { + tokio::task::spawn_blocking(f) + .await + .expect("tâche bloquante interrompue") +} + +/// Une tranche base64 d'un fichier (`b64=1&offset=&len=`). +fn b64_slice(path: &str, name: &str, size: i64, q: &HashMap) -> Response { + let off = parse_i64(q.get("offset")); + let mut length = parse_i64(q.get("len")); + if length <= 0 || length > DOWNLOAD_CHUNK_MAX { + length = DOWNLOAD_CHUNK_MAX; + } + if off < 0 || off > size { + return fail(StatusCode::BAD_REQUEST, "position hors du fichier"); + } + if off + length > size { + length = size - off; + } + let mut buf = vec![0u8; length as usize]; + let n = match std::fs::File::open(path).and_then(|mut f| { + f.seek(std::io::SeekFrom::Start(off as u64))?; + let mut n = 0; + while n < buf.len() { + match f.read(&mut buf[n..])? { + 0 => break, + k => n += k, + } + } + Ok(n) + }) { + Ok(n) => n, + Err(e) => return fail(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), + }; + send_json( + StatusCode::OK, + &json!({ + "ok": true, "name": name, "size": size, "offset": off, + "data": base64::engine::general_purpose::STANDARD.encode(&buf[..n]), + "eof": off + n as i64 >= size, + }), + ) +} + +/// Corps d'un fichier, en flux (une plage d'octets au plus). +fn serve_file( + path: &str, + meta: &std::fs::Metadata, + req_headers: &HeaderMap, + extra: &[(&'static str, String)], +) -> Response { + let size = meta.len(); + let modified = mtime_secs(meta); + if let Some(ims) = req_headers + .get(header::IF_MODIFIED_SINCE) + .and_then(|v| v.to_str().ok()) + && ims == http_date(modified) + { + let mut r = Response::new(Full::new(Bytes::new()).map_err(|n| match n {}).boxed()); + *r.status_mut() = StatusCode::NOT_MODIFIED; + return r; + } + let (mut start, mut end, mut status) = (0u64, size.saturating_sub(1), StatusCode::OK); + if let Some(range) = req_headers + .get(header::RANGE) + .and_then(|v| v.to_str().ok()) + .and_then(|r| r.strip_prefix("bytes=")) + && !range.contains(',') + && size > 0 + { + let (a, b) = range.split_once('-').unwrap_or((range, "")); + let parsed = match (a.trim().parse::(), b.trim().parse::()) { + (Ok(a), Ok(b)) if a <= b && a < size => Some((a, b.min(size - 1))), + (Ok(a), Err(_)) if b.trim().is_empty() && a < size => Some((a, size - 1)), + (Err(_), Ok(n)) if a.trim().is_empty() && n > 0 => { + Some((size.saturating_sub(n), size - 1)) + } + _ => None, + }; + match parsed { + Some((a, b)) => { + (start, end, status) = (a, b, StatusCode::PARTIAL_CONTENT); + } + None => { + let mut r = fail(StatusCode::RANGE_NOT_SATISFIABLE, "plage invalide"); + if let Ok(v) = HeaderValue::from_str(&format!("bytes */{size}")) { + r.headers_mut().insert(header::CONTENT_RANGE, v); + } + return r; + } + } + } + let len = if size == 0 { 0 } else { end - start + 1 }; + let path = path.to_string(); + let stream = futures_util::stream::unfold( + (None::, start, len), + move |(f, pos, left)| { + let path = path.clone(); + async move { + if left == 0 { + return None; + } + let res = tokio::task::spawn_blocking(move || { + let mut f = match f { + Some(f) => f, + None => { + let mut f = std::fs::File::open(&path)?; + f.seek(std::io::SeekFrom::Start(pos))?; + f + } + }; + let mut buf = vec![0u8; left.min(64 << 10) as usize]; + let n = f.read(&mut buf)?; + buf.truncate(n); + Ok::<_, std::io::Error>((f, buf)) + }) + .await + .ok()?; + match res { + Ok((_, buf)) if buf.is_empty() => None, + Ok((f, buf)) => { + let n = buf.len() as u64; + Some(( + Ok::<_, hyper::Error>(Frame::data(Bytes::from(buf))), + (Some(f), pos + n, left - n), + )) + } + Err(_) => None, + } + } + }, + ); + let mut r = Response::new(BodyExt::boxed(StreamBody::new(stream))); + *r.status_mut() = status; + let h = r.headers_mut(); + for (k, v) in extra { + if let Ok(v) = HeaderValue::from_str(v) { + h.insert(*k, v); + } + } + h.insert(header::ACCEPT_RANGES, HeaderValue::from_static("bytes")); + if let Ok(v) = HeaderValue::from_str(&len.to_string()) { + h.insert(header::CONTENT_LENGTH, v); + } + if let Ok(v) = HeaderValue::from_str(&http_date(modified)) { + h.insert(header::LAST_MODIFIED, v); + } + if status == StatusCode::PARTIAL_CONTENT + && let Ok(v) = HeaderValue::from_str(&format!("bytes {start}-{end}/{size}")) + { + h.insert(header::CONTENT_RANGE, v); + } + r +} + +#[derive(Deserialize, Default)] +struct UploadReq { + #[serde(default)] + name: String, + #[serde(default)] + data: String, + #[serde(default)] + id: String, + #[serde(default)] + more: bool, + #[serde(default)] + size: i64, +} + +/// `from_go` : remise par le Go (tunnel), déjà authentifiée par lui. +pub async fn serve( + ctx: &Ctx, + cf: &Arc, + req: Request, + from_go: bool, +) -> Response { + let (parts, body) = req.into_parts(); + if !from_go && let Err(refus) = ctx.guard(&parts.headers).await { + return *refus; + } + let ws = match workspace(ctx).await { + Ok(w) => w, + Err(e) => { + return fail( + StatusCode::SERVICE_UNAVAILABLE, + format!("fichiers indisponibles : {e}"), + ); + } + }; + let q = query(&parts.uri); + let e2e = parts.headers.contains_key(E2E_HEADER); + match parts.uri.path() { + "/api/chat/files" => { + let scope = q.get("scope").cloned().unwrap_or_default(); + let dir = q.get("dir").cloned().unwrap_or_default(); + blocking(move || list_files(&ws, &scope, &dir)).await + } + "/api/chat/file/delete" => { + let v = read_json(body, 1 << 20).await.unwrap_or(Value::Null); + let s = |k: &str| v.get(k).and_then(Value::as_str).unwrap_or("").to_string(); + let (path, scope) = (s("path"), s("scope")); + blocking(move || delete_file(&ws, &path, &scope)).await + } + "/api/chat/file" => { + let rel = q.get("path").cloned().unwrap_or_default(); + if rel.trim().is_empty() { + return fail(StatusCode::BAD_REQUEST, "chemin manquant"); + } + let Some(abs) = ws.workspace_file(&rel) else { + return fail(StatusCode::FORBIDDEN, "hors du dossier de travail"); + }; + let meta = match std::fs::metadata(&abs) { + Ok(m) if !m.is_dir() => m, + _ => return fail(StatusCode::NOT_FOUND, "fichier introuvable"), + }; + let name = crate::paths::base_name(&abs); + if q.contains_key("meta") && !q["meta"].is_empty() { + return send_json( + StatusCode::OK, + &json!({ "ok": true, "name": name, "size": meta.len(), "e2e": e2e }), + ); + } + if q.get("b64").is_some_and(|v| !v.is_empty()) { + let size = meta.len() as i64; + return blocking(move || b64_slice(&abs, &name, size, &q)).await; + } + serve_file( + &abs, + &meta, + &parts.headers, + &[ + ("content-type", "application/octet-stream".into()), + ("x-content-type-options", "nosniff".into()), + ( + "content-disposition", + format!("attachment; filename*=UTF-8''{}", path_escape(&name)), + ), + ], + ) + } + "/api/chat/image" => { + let rel = q.get("path").cloned().unwrap_or_default(); + if rel.trim().is_empty() { + return fail(StatusCode::BAD_REQUEST, "chemin manquant"); + } + let Some(abs) = ws.workspace_file(&rel) else { + return fail(StatusCode::FORBIDDEN, "hors du dossier de travail"); + }; + let meta = match std::fs::metadata(&abs) { + Ok(m) if !m.is_dir() => m, + _ => return fail(StatusCode::NOT_FOUND, "fichier introuvable"), + }; + if let Some(t) = q.get("thumb").filter(|t| !t.is_empty()) { + let px = t.parse::().unwrap_or(0); + let (cf2, abs2, meta2) = (cf.clone(), abs.clone(), meta.clone()); + if let Ok(data) = blocking(move || cf2.thumb(&abs2, &meta2, px)).await { + let mut r = Response::new(Full::new(data).map_err(|n| match n {}).boxed()); + let h = r.headers_mut(); + h.insert(header::CONTENT_TYPE, HeaderValue::from_static("image/jpeg")); + h.insert( + "x-content-type-options", + HeaderValue::from_static("nosniff"), + ); + h.insert( + "content-security-policy", + HeaderValue::from_static("default-src 'none'; sandbox"), + ); + h.insert( + header::CACHE_CONTROL, + HeaderValue::from_static("private, max-age=3600"), + ); + return r; + } + } + let mut head = [0u8; 512]; + let n = match std::fs::File::open(&abs).and_then(|mut f| { + let mut n = 0; + while n < head.len() { + match f.read(&mut head[n..])? { + 0 => break, + k => n += k, + } + } + Ok(n) + }) { + Ok(n) => n, + Err(e) => return fail(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), + }; + let mime = sniff_image(&head[..n]); + if mime.is_empty() { + return fail( + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "ce fichier n'est pas une image", + ); + } + let name = crate::paths::base_name(&abs); + serve_file( + &abs, + &meta, + &parts.headers, + &[ + ("content-type", mime.into()), + ("x-content-type-options", "nosniff".into()), + ( + "content-security-policy", + "default-src 'none'; sandbox".into(), + ), + ( + "content-disposition", + format!("inline; filename*=UTF-8''{}", path_escape(&name)), + ), + ], + ) + } + "/api/chat/files/zip" => { + let scope = q.get("scope").cloned().unwrap_or_default(); + let legacy = scope.trim() == "legacy"; + let (root, _) = ws.files_root(&scope); + let id = if legacy { + "legacy".to_string() + } else { + let id = safe_conv_id(&ws.id); + if id.is_empty() { "conv".into() } else { id } + }; + let tmp = cf.tmp_dir.join(format!("loki-files-{id}.zip")); + let name = if legacy { + format!("loki-hors-discussion-{}.zip", local_date()) + } else { + let slug = slugify(&ws.title); + let slug = if slug.is_empty() { ws.id.clone() } else { slug }; + format!("loki-{slug}-{}.zip", local_date()) + }; + if q.get("meta").is_some_and(|v| !v.is_empty()) { + let tmp2 = tmp.clone(); + let res = blocking(move || build_zip(Path::new(&root), &tmp2, legacy)).await; + let files = match res { + Ok(n) => n, + Err(e) => return fail(StatusCode::INTERNAL_SERVER_ERROR, e), + }; + if files == 0 { + let _ = std::fs::remove_file(&tmp); + return fail(StatusCode::NOT_FOUND, "aucun fichier à archiver"); + } + let size = std::fs::metadata(&tmp).map(|m| m.len()).unwrap_or(0); + return send_json( + StatusCode::OK, + &json!({ "ok": true, "name": name, "size": size, "files": files, "e2e": e2e }), + ); + } + let meta = match std::fs::metadata(&tmp) { + Ok(m) => m, + Err(_) => { + return fail( + StatusCode::NOT_FOUND, + "archive absente — redemande la fiche (meta=1) d'abord", + ); + } + }; + let tmp_s = tmp.to_string_lossy().into_owned(); + if q.get("b64").is_some_and(|v| !v.is_empty()) { + let size = meta.len() as i64; + return blocking(move || b64_slice(&tmp_s, &name, size, &q)).await; + } + serve_file( + &tmp_s, + &meta, + &HeaderMap::new(), + &[ + ("content-type", "application/zip".into()), + ( + "content-disposition", + format!( + "attachment; filename={}", + serde_json::to_string(&name).unwrap_or_default() + ), + ), + ], + ) + } + "/api/chat/upload" => upload(cf, &ws, body).await, + _ => send_json( + StatusCode::NOT_FOUND, + &json!({ "error": "404 page not found" }), + ), + } +} + +async fn read_json(body: Incoming, max: usize) -> Result { + let b = Limited::new(body, max) + .collect() + .await + .map_err(|e| e.to_string())? + .to_bytes(); + serde_json::from_slice(&b).map_err(|e| e.to_string()) +} + +/// `handleChatFiles`. +fn list_files(ws: &Workspace, scope: &str, dir: &str) -> Response { + let (root, legacy) = ws.files_root(scope); + if !legacy { + let _ = std::fs::create_dir_all(&root); + } + let Some(abs) = ws_resolve(&root, dir) else { + return fail(StatusCode::FORBIDDEN, "hors du dossier de travail"); + }; + if !std::fs::metadata(&abs).is_ok_and(|m| m.is_dir()) { + return fail(StatusCode::NOT_FOUND, "dossier introuvable"); + } + let rd = match std::fs::read_dir(&abs) { + Ok(rd) => rd, + Err(e) => return fail(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), + }; + let mut ents: Vec<_> = rd.filter_map(Result::ok).collect(); + ents.sort_by_key(|e| e.file_name()); + let cur = rel_within(&root, &abs).unwrap_or_default(); + let mut entries: Vec<(bool, i64, Value)> = Vec::new(); + for e in ents { + let name = e.file_name().to_string_lossy().into_owned(); + if legacy && cur.is_empty() && name == CONV_FILES_ROOT { + continue; + } + let Ok(meta) = e.metadata() else { continue }; + let is_dir = e.file_type().is_ok_and(|t| t.is_dir()); + let p = if cur.is_empty() { + name.clone() + } else { + format!("{cur}/{name}") + }; + let m = mtime_secs(&meta); + let mut it = json!({ "name": name, "path": p, "dir": is_dir, "mod": m }); + if is_dir { + let (size, items) = dir_size(&e.path()); + it["size"] = json!(size); + it["image"] = json!(false); + if items > 0 { + it["items"] = json!(items); + } + } else { + it["size"] = json!(meta.len()); + it["image"] = json!(is_image_ext(&name)); + } + entries.push((is_dir, m, it)); + } + // Dossiers d'abord, puis du plus récent au plus ancien (tri stable). + entries.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1))); + let parent = match cur.rfind('/') { + Some(i) => cur[..i].to_string(), + None => String::new(), + }; + let (total, count) = dir_size(Path::new(&root)); + send_json( + StatusCode::OK, + &json!({ + "ok": true, "dir": cur, "parent": parent, "at_root": cur.is_empty(), + "entries": entries.into_iter().map(|e| e.2).collect::>(), + "total": total, "count": count, "root": root, + "scope": if legacy { "legacy" } else { "" }, "legacy": ws.legacy_count(), + }), + ) +} + +/// `handleChatFileDelete`. +fn delete_file(ws: &Workspace, path: &str, scope: &str) -> Response { + if path.trim().is_empty() { + return fail(StatusCode::BAD_REQUEST, "chemin manquant"); + } + let (root, legacy) = ws.files_root(scope); + let Some(abs) = ws_resolve(&root, path) else { + return fail(StatusCode::FORBIDDEN, "hors du dossier de travail"); + }; + if abs == root { + return fail( + StatusCode::FORBIDDEN, + "le dossier de travail lui-même ne se supprime pas", + ); + } + if legacy && abs == go_join(&[&ws.root, CONV_FILES_ROOT]) { + return fail( + StatusCode::FORBIDDEN, + "les dossiers des discussions se suppriment discussion par discussion", + ); + } + let meta = match std::fs::symlink_metadata(&abs) { + Ok(m) => m, + Err(_) => return fail(StatusCode::NOT_FOUND, "introuvable"), + }; + let (freed, res) = if meta.is_dir() { + (dir_size(Path::new(&abs)).0, std::fs::remove_dir_all(&abs)) + } else { + (meta.len() as i64, std::fs::remove_file(&abs)) + }; + if let Err(e) = res { + return fail( + StatusCode::INTERNAL_SERVER_ERROR, + crate::paths::os_err("unlinkat", &abs, &e), + ); + } + send_json(StatusCode::OK, &json!({ "ok": true, "freed": freed })) +} + +/// `handleChatUpload` : un morceau ; le dernier donne au fichier son nom. +async fn upload(cf: &Arc, ws: &Workspace, body: Incoming) -> Response { + let req: UploadReq = match Limited::new(body, 2 * UPLOAD_CHUNK_MAX) + .collect() + .await + .ok() + .and_then(|b| serde_json::from_slice(&b.to_bytes()).ok()) + { + Some(r) => r, + None => { + return fail( + StatusCode::BAD_REQUEST, + "morceau trop gros ou requête invalide", + ); + } + }; + let mut data = req.data.as_str(); + if data.starts_with("data:") + && let Some(i) = data.find(',') + { + data = &data[i + 1..]; + } + // StdEncoding.DecodeString ignore les retours à la ligne. + let cleaned: String = data + .trim() + .chars() + .filter(|c| *c != '\r' && *c != '\n') + .collect(); + let raw = match base64::engine::general_purpose::STANDARD.decode(cleaned) { + Ok(r) => r, + Err(_) => { + return fail( + StatusCode::BAD_REQUEST, + "contenu illisible (base64 attendu)", + ); + } + }; + // Le verrou de la table ne couvre que la recherche ou la création de la + // session : jamais d'attente sous lui. + let found: Result<(String, Arc), Box>> = (|| { + cf.sweep(); + let mut map = lock(&cf.uploads); + if let Some(e) = map.get(&req.id) { + return Ok((req.id.clone(), e.clone())); + } + if !req.id.is_empty() { + return Err(Box::new(fail( + StatusCode::CONFLICT, + "envoi expiré — recommence le fichier", + ))); + } + let dir = go_join(&[&ws.dir, UPLOADS_SUB]); + if let Err(e) = std::fs::create_dir_all(&dir) { + return Err(Box::new(fail( + StatusCode::INTERNAL_SERVER_ERROR, + e.to_string(), + ))); + } + let free = disk_free(&dir); + if free > 0 && req.size > 0 && free < req.size + UPLOAD_SPACE_MARGIN { + return Err(Box::new(send_json( + StatusCode::INSUFFICIENT_STORAGE, + &json!({ "ok": false, "error": format!( + "espace insuffisant : {} libres, {} nécessaires", + human_bytes(free), human_bytes(req.size + UPLOAD_SPACE_MARGIN)) }), + ))); + } + let (f, path) = + create_part(&dir).map_err(|e| Box::new(fail(StatusCode::INTERNAL_SERVER_ERROR, e)))?; + let id = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + let e = Arc::new(UpEntry { + sess: tokio::sync::Mutex::new(Some(UpSession { + f, + path, + name: safe_upload_name(&req.name), + written: 0, + })), + last: Mutex::new(Instant::now()), + busy: std::sync::atomic::AtomicBool::new(false), + }); + map.insert(id.clone(), e.clone()); + Ok((id, e)) + })(); + let (id, entry) = match found { + Ok(x) => x, + Err(r) => return *r, + }; + finish_chunk(cf, &id, entry, raw, req.more).await +} + +/// `.upload-.part`, créé exclusivement (`os.CreateTemp`). +fn create_part(dir: &str) -> Result<(std::fs::File, PathBuf), String> { + for _ in 0..10000 { + let mut b = [0u8; 4]; + getrandom::fill(&mut b).map_err(|e| e.to_string())?; + let p = PathBuf::from(go_join(&[ + dir, + &format!(".upload-{}.part", u32::from_le_bytes(b)), + ])); + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&p) + { + Ok(f) => return Ok((f, p)), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e.to_string()), + } + } + Err("aucun nom de fichier temporaire libre".into()) +} + +async fn finish_chunk( + cf: &Arc, + id: &str, + entry: Arc, + raw: Vec, + more: bool, +) -> Response { + use std::sync::atomic::Ordering; + *lock(&entry.last) = Instant::now(); + entry.busy.store(true, Ordering::SeqCst); + let mut guard = entry.sess.lock().await; + let res = (|| { + let Some(s) = guard.as_mut() else { + return Err(( + StatusCode::CONFLICT, + "envoi expiré — recommence le fichier".to_string(), + )); + }; + let drop_sess = |s: &UpSession| { + lock(&cf.uploads).remove(id); + let _ = std::fs::remove_file(&s.path); + }; + if s.written + raw.len() as i64 > UPLOAD_MAX_BYTES { + drop_sess(s); + return Err(( + StatusCode::PAYLOAD_TOO_LARGE, + format!("fichier trop gros (max {})", human_bytes(UPLOAD_MAX_BYTES)), + )); + } + if !raw.is_empty() { + if let Err(e) = s.f.write_all(&raw) { + drop_sess(s); + return Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string())); + } + s.written += raw.len() as i64; + } + if more { + return Ok(json!({ "ok": true, "id": id, "received": s.written })); + } + let sync = s.f.sync_all(); + lock(&cf.uploads).remove(id); + let part = s.path.clone(); + if let Err(e) = sync { + let _ = std::fs::remove_file(&part); + return Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string())); + } + if s.written == 0 { + let _ = std::fs::remove_file(&part); + return Err((StatusCode::BAD_REQUEST, "fichier vide".to_string())); + } + let dir = part + .parent() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default(); + let dest = unique_upload_path(&dir, &s.name); + if let Err(e) = std::fs::rename(&part, &dest) { + let _ = std::fs::remove_file(&part); + return Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string())); + } + Ok(json!({ + "ok": true, + "path": format!("uploads/{}", crate::paths::base_name(&dest)), + "abs": dest, + "size": s.written, + })) + })(); + if !more || res.is_err() { + guard.take(); + } + drop(guard); + entry.busy.store(false, Ordering::SeqCst); + match res { + Ok(v) => send_json(StatusCode::OK, &v), + Err((code, msg)) => fail(code, msg), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dates() { + assert_eq!(civil(0), (1970, 1, 1)); + assert_eq!(civil(19723), (2024, 1, 1)); + assert_eq!(http_date(1700000000), "Tue, 14 Nov 2023 22:13:20 GMT"); + assert_eq!(local_date().len(), 10); + } + + #[test] + fn extensions() { + assert_eq!(go_ext("a.tar.gz"), ".gz"); + assert_eq!(go_ext("rep.d/fichier"), ""); + assert_eq!(go_ext("sans"), ""); + } +} diff --git a/rust/loki-rs/src/lib.rs b/rust/loki-rs/src/lib.rs index 7b1913c..e3dcf35 100644 --- a/rust/loki-rs/src/lib.rs +++ b/rust/loki-rs/src/lib.rs @@ -15,6 +15,7 @@ pub mod auth; pub mod chat; pub mod chatapi; pub mod child; +pub mod convfiles; pub mod dictate; pub mod downloads; pub mod engine; diff --git a/rust/loki-rs/src/main.rs b/rust/loki-rs/src/main.rs index 06cd973..ec19d76 100644 --- a/rust/loki-rs/src/main.rs +++ b/rust/loki-rs/src/main.rs @@ -127,6 +127,9 @@ fn serve() -> ExitCode { // LOKI_SETTINGS=rust : les réglages partagés (préférences, prompt // système, intensité du raisonnement, adresse publique). let settings_rust = held_by_facade("LOKI_SETTINGS"); + // LOKI_FILES=rust : les fichiers de la discussion (dépôt, panneau + // Fichiers, archive, téléchargement, images). + let files_rust = held_by_facade("LOKI_FILES"); let mut env = vec![(auth::INTERNAL_ENV.to_string(), token.clone())]; if chat_rust { env.push(("LOKI_CHAT".into(), "rust".into())); @@ -140,7 +143,10 @@ fn serve() -> ExitCode { if settings_rust { env.push(("LOKI_SETTINGS".into(), "rust".into())); } - if chat_rust || models_rust || dictate_rust || settings_rust { + if files_rust { + env.push(("LOKI_FILES".into(), "rust".into())); + } + if chat_rust || models_rust || dictate_rust || settings_rust || files_rust { env.push(("LOKI_FACADE_ADDR".into(), format!("127.0.0.1:{web_port}"))); } let sup = child::Supervisor::spawn(go_bin, go_port, env); @@ -184,6 +190,12 @@ fn serve() -> ExitCode { std::sync::Arc::new(loki_rs::dictate::Dictation::new(&engine_paths.home)) }); + let files = if files_rust { + Some(start_files(backend, &token).await) + } else { + None + }; + let addr = SocketAddr::from(([0, 0, 0, 0], web_port)); let listener = match tokio::net::TcpListener::bind(addr).await { Ok(l) => l, @@ -203,6 +215,7 @@ fn serve() -> ExitCode { models, dictate, settings: settings_rust, + files, }; let res = proxy::serve(listener, cfg, shutdown_signal()).await; eprintln!("[loki-rs] arrêt demandé — arrêt du serveur Go"); @@ -258,6 +271,33 @@ async fn start_models( std::sync::Arc::new(loki_rs::models::Models::new()) } +/// Les envois interrompus (process arrêté en plein dépôt) ne reprendront +/// jamais : leurs `.part` sont vidés au démarrage (le Go ne le fait plus quand +/// la façade tient les fichiers). +async fn start_files( + backend: SocketAddr, + token: &str, +) -> std::sync::Arc { + let st = loki_rs::store::Store::new(backend, token.to_string()); + let root = st + .internal_json( + hyper::Method::POST, + "/internal/v1/conv/workspace", + Some(&serde_json::json!({})), + ) + .await + .ok() + .and_then(|v| v.get("root").and_then(|r| r.as_str()).map(str::to_string)); + if let Some(root) = root { + let _ = tokio::task::spawn_blocking(move || { + loki_rs::convfiles::clean_stale_upload_parts(&root) + }) + .await; + } + eprintln!("[loki-rs] fichiers de la discussion tenus par la façade (LOKI_FILES=rust)"); + std::sync::Arc::new(loki_rs::convfiles::ConvFiles::default()) +} + /// Le Go garde-t-il la conversation ? (quelques essais : il vient de démarrer) async fn go_keeps_chat(backend: SocketAddr, token: &str) -> Result, String> { let store = loki_rs::store::Store::new(backend, token.to_string()); diff --git a/rust/loki-rs/src/proxy.rs b/rust/loki-rs/src/proxy.rs index c9bdac1..5197da7 100644 --- a/rust/loki-rs/src/proxy.rs +++ b/rust/loki-rs/src/proxy.rs @@ -71,6 +71,8 @@ pub struct Config { pub dictate: Option>, /// Réglages partagés tenus par la façade (`LOKI_SETTINGS=rust`). pub settings: bool, + /// Fichiers de la discussion tenus par la façade (`LOKI_FILES=rust`). + pub files: Option>, } struct Proxy { @@ -101,6 +103,7 @@ pub async fn serve( models: cfg.models, dictate: cfg.dictate, settings: cfg.settings, + files: cfg.files, internal_token: cfg.internal_token.clone(), }, }); @@ -182,6 +185,11 @@ impl Proxy { { return chatapi::serve(&self.ctx, chat, req, from_go).await; } + if let Some(f) = &self.ctx.files + && crate::convfiles::handles(path) + { + return crate::convfiles::serve(&self.ctx, f, req, from_go).await; + } if self.ctx.settings && crate::settings::handles(path) { return crate::settings::serve(&self.ctx, req, from_go).await; } diff --git a/rust/loki-rs/tests/common/fakego.rs b/rust/loki-rs/tests/common/fakego.rs index b45e3ca..766ba26 100644 --- a/rust/loki-rs/tests/common/fakego.rs +++ b/rust/loki-rs/tests/common/fakego.rs @@ -62,6 +62,8 @@ pub struct FakeGo { pub turn_view: Mutex>, pub turn_verifier: Mutex>, pub tool_results: Mutex>, + /// Réponse de /internal/v1/conv/workspace (None : 404). + pub conv_workspace: Mutex>, } impl FakeGo { @@ -291,6 +293,10 @@ async fn handle(st: Arc, req: Request) -> Response *st.go_busy.lock().unwrap() = None; resp(204, "") } + "/internal/v1/conv/workspace" => match st.conv_workspace.lock().unwrap().clone() { + Some(v) => resp(200, v.to_string()), + None => resp(404, "404 page not found"), + }, "/internal/v1/tool/context" => match st.tool_ctx.lock().unwrap().clone() { Some(v) => resp(200, v.to_string()), None => resp(404, "404 page not found"), @@ -466,6 +472,7 @@ pub struct Opts { pub models_hf: Option, pub dictate: Option>, pub settings: bool, + pub files: Option>, } /// Comme [`start`], avec la conversation tenue par la façade @@ -558,6 +565,7 @@ pub async fn start_with( }), dictate: opts.dictate, settings: opts.settings, + files: opts.files, }; tokio::spawn(loki_rs::proxy::serve(fl, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/convfiles.rs b/rust/loki-rs/tests/convfiles.rs new file mode 100644 index 0000000..546b137 --- /dev/null +++ b/rust/loki-rs/tests/convfiles.rs @@ -0,0 +1,334 @@ +//! Lot 11 : fichiers de la discussion tenus par la façade (`LOKI_FILES=rust`). +//! +//! Le journal tiré du Go (`internal/loki/parity_files_test.go` → +//! `tests/parity_files/files.json`) est rejoué : noms de dépôt, slug, +//! orientation EXIF, redressement et réduction des pixels, reniflage, puis +//! 42 appels de route par la façade sur la même arborescence — panneau +//! Fichiers, téléchargement, image et vignette, dépôt en morceaux, +//! suppression, archive .zip — avec les fichiers restants après chaque POST. + +mod common; + +use std::collections::BTreeMap; +use std::net::SocketAddr; +use std::path::Path; +use std::sync::Arc; +use std::time::{Duration, UNIX_EPOCH}; + +use bytes::Bytes; +use common::fakego; +use http_body_util::{BodyExt, Full}; +use hyper::{Request, StatusCode}; +use loki_rs::convfiles::{self, Rgba}; +use serde_json::Value; + +fn fixture() -> Value { + serde_json::from_str(include_str!("parity_files/files.json")).expect("journal") +} + +fn b64(s: &str) -> Vec { + use base64::Engine; + base64::engine::general_purpose::STANDARD.decode(s).unwrap() +} + +fn pix(v: &Value) -> Rgba { + Rgba { + w: v["w"].as_u64().unwrap() as usize, + h: v["h"].as_u64().unwrap() as usize, + pix: b64(v["pix"].as_str().unwrap_or("")), + } +} + +#[test] +fn parite_des_fonctions_pures() { + let fx = fixture(); + for (n, want) in fx["upload_name"].as_object().unwrap() { + assert_eq!( + convfiles::safe_upload_name(n), + want.as_str().unwrap(), + "{n:?}" + ); + } + for (s, want) in fx["slug"].as_object().unwrap() { + assert_eq!(convfiles::slugify(s), want.as_str().unwrap(), "{s:?}"); + } + for c in fx["exif"].as_array().unwrap() { + assert_eq!( + convfiles::exif_orientation(&b64(c["in"].as_str().unwrap())), + c["out"].as_i64().unwrap() as i32 + ); + } + let src = pix(&fx["orient_in"]); + for c in fx["orient"].as_array().unwrap() { + let o = c["orient"].as_i64().unwrap() as i32; + assert_eq!( + convfiles::apply_orientation(&src, o), + pix(&c["out"]), + "orientation {o}" + ); + } + for c in fx["scale"].as_array().unwrap() { + let max = c["max"].as_u64().unwrap() as usize; + assert_eq!( + convfiles::scale_down_to_max(&pix(&c["in"]), max), + pix(&c["out"]), + "{max}" + ); + } + for c in fx["sniff"].as_array().unwrap() { + let want = c["out"].as_str().unwrap(); + let got = convfiles::sniff_image(&b64(c["in"].as_str().unwrap_or(""))); + if want.starts_with("image/") { + assert_eq!(got, want); + } else { + assert_eq!(got, "", "{want}"); + } + } +} + +fn set_mtime(p: &Path, secs: i64) { + let f = std::fs::File::open(p).unwrap(); + f.set_modified(UNIX_EPOCH + Duration::from_secs(secs as u64)) + .unwrap(); +} + +fn tree(dir: &Path) -> BTreeMap { + let mut out = BTreeMap::new(); + let mut stack = vec![dir.to_path_buf()]; + while let Some(d) = stack.pop() { + let Ok(rd) = std::fs::read_dir(&d) else { + continue; + }; + for e in rd.filter_map(Result::ok) { + let p = e.path(); + if p.is_dir() { + stack.push(p); + } else { + let rel = p.strip_prefix(dir).unwrap().to_string_lossy().into_owned(); + let rel = mask_part(&rel); + out.insert(rel, e.metadata().unwrap().len()); + } + } + } + out +} + +fn mask_part(s: &str) -> String { + let re = regex::Regex::new(r"\.upload-[0-9]+\.part").unwrap(); + re.replace_all(s, ".upload-{{N}}.part").into_owned() +} + +async fn call( + facade: SocketAddr, + method: &str, + url: &str, + body: &str, +) -> (StatusCode, hyper::HeaderMap, Bytes) { + use hyper_util::client::legacy::Client; + use hyper_util::rt::TokioExecutor; + let client: Client<_, Full> = Client::builder(TokioExecutor::new()).build_http(); + let r = client + .request( + Request::builder() + .method(method) + .uri(format!("http://{facade}{url}")) + .body(Full::new(Bytes::from(body.to_string()))) + .unwrap(), + ) + .await + .unwrap(); + let (p, b) = r.into_parts(); + (p.status, p.headers, b.collect().await.unwrap().to_bytes()) +} + +#[tokio::test] +async fn parite_des_routes() { + let fx = fixture(); + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().canonicalize().unwrap(); + let h = home.to_string_lossy().into_owned(); + // L'arborescence, fichiers profonds d'abord, puis les dates des dossiers. + let mut nodes: Vec<(&String, &Value)> = fx["tree"].as_object().unwrap().iter().collect(); + nodes.sort_by_key(|(k, _)| std::cmp::Reverse(k.len())); + for (k, n) in &nodes { + let p = home.join(k); + if n.get("dir").and_then(Value::as_bool).unwrap_or(false) { + std::fs::create_dir_all(&p).unwrap(); + } else { + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(&p, b64(n.get("data").and_then(Value::as_str).unwrap_or(""))).unwrap(); + } + } + for (k, n) in &nodes { + set_mtime(&home.join(k), n["mtime"].as_i64().unwrap()); + } + let zips = home.join("tmp"); + std::fs::create_dir_all(&zips).unwrap(); + let opts = fakego::Opts { + files: Some(Arc::new(convfiles::ConvFiles::new(zips))), + ..Default::default() + }; + let (facade, go, _stop, _) = fakego::start_with(home.clone(), opts).await; + let ws: Value = + serde_json::from_str(&fx["workspace"].to_string().replace("{{HOME}}", &h)).unwrap(); + *go.conv_workspace.lock().unwrap() = Some(ws.clone()); + let conv_dir = Path::new(ws["dir"].as_str().unwrap()).to_path_buf(); + + let date_re = regex::Regex::new(r"[0-9]{4}-[0-9]{2}-[0-9]{2}\.zip").unwrap(); + let mut last_id = String::new(); + for (i, c) in fx["calls"].as_array().unwrap().iter().enumerate() { + let (m, url) = (c["method"].as_str().unwrap(), c["url"].as_str().unwrap()); + let body = c + .get("body") + .and_then(Value::as_str) + .unwrap_or("") + .replace("{{ID}}", &last_id); + let (code, headers, b) = call(facade, m, url, &body).await; + let ctx = format!("appel {i} : {m} {url} {body}"); + assert_eq!( + code.as_u16() as u64, + c["status"].as_u64().unwrap(), + "{ctx} : {b:?}" + ); + if let Some(want) = c.get("out") { + let txt = String::from_utf8_lossy(&b).replace(&h, "{{HOME}}"); + let txt = date_re.replace_all(&txt, "{{DATE}}.zip").into_owned(); + let mut got: Value = serde_json::from_str(&txt).unwrap(); + if let Some(id) = got.get("id").and_then(Value::as_str) { + last_id = id.to_string(); + got["id"] = Value::String("{{ID}}".into()); + } + if url.starts_with("/api/chat/files/zip") { + if let Some(d) = got.get("data").and_then(Value::as_str) { + let raw = b64(d); + let mut zr = zip::ZipArchive::new(std::io::Cursor::new(raw)).unwrap(); + let mut entries = BTreeMap::new(); + for k in 0..zr.len() { + let mut f = zr.by_index(k).unwrap(); + let mut v = Vec::new(); + std::io::Read::read_to_end(&mut f, &mut v).unwrap(); + entries.insert(f.name().to_string(), v); + } + let want_zip: BTreeMap> = c["zip"] + .as_object() + .unwrap() + .iter() + .map(|(k, v)| (k.clone(), b64(v.as_str().unwrap_or("")))) + .collect(); + assert_eq!(entries, want_zip, "{ctx} (contenu de l'archive)"); + got["data"] = Value::String("…".into()); + } + if got.get("size").is_some() { + got["size"] = Value::String("…".into()); + } + } + if let Some(p) = got.get("path").and_then(Value::as_str) { + got["path"] = Value::String(mask_part(p)); + } + assert_eq!(&got, want, "{ctx}"); + } + if let Some(raw) = c.get("raw").and_then(Value::as_str) { + assert_eq!(b.to_vec(), b64(raw), "{ctx} (corps)"); + } + if let Some(dims) = c.get("thumb").and_then(Value::as_array) { + let img = image::load_from_memory(&b).unwrap(); + assert_eq!( + [img.width() as u64, img.height() as u64], + [dims[0].as_u64().unwrap(), dims[1].as_u64().unwrap()], + "{ctx} (vignette)" + ); + } + if let Some(hs) = c.get("headers").and_then(Value::as_object) { + for (k, v) in hs { + assert_eq!( + headers.get(k.as_str()).and_then(|x| x.to_str().ok()), + v.as_str(), + "{ctx} (en-tête {k})" + ); + } + } + if let Some(want) = c.get("tree") { + let want: BTreeMap = serde_json::from_value(want.clone()).unwrap(); + assert_eq!(tree(&conv_dir), want, "{ctx} (fichiers)"); + } + } +} + +#[tokio::test] +async fn plages_et_liens() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().canonicalize().unwrap(); + let dir = home.join("ws/discussions/c1"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("a.txt"), b"0123456789").unwrap(); + // Un lien vers l'extérieur ne sert pas d'échappatoire. + std::os::unix::fs::symlink("/etc", dir.join("dehors")).unwrap(); + let opts = fakego::Opts { + files: Some(Arc::new(convfiles::ConvFiles::new(home.clone()))), + ..Default::default() + }; + let (facade, go, _stop, _) = fakego::start_with(home.clone(), opts).await; + *go.conv_workspace.lock().unwrap() = Some(serde_json::json!({ + "dir": dir, "root": home.join("ws"), "id": "c1", "title": "Ma discussion" + })); + use hyper_util::client::legacy::Client; + use hyper_util::rt::TokioExecutor; + let client: Client<_, Full> = Client::builder(TokioExecutor::new()).build_http(); + let r = client + .request( + Request::get(format!("http://{facade}/api/chat/file?path=a.txt")) + .header("range", "bytes=2-5") + .body(Full::new(Bytes::new())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(r.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(r.headers()["content-range"], "bytes 2-5/10"); + assert_eq!( + &r.into_body().collect().await.unwrap().to_bytes()[..], + b"2345" + ); + let (code, _, _) = call( + facade, + "GET", + "/api/chat/file?path=dehors/passwd&meta=1", + "", + ) + .await; + assert_eq!(code, StatusCode::FORBIDDEN); + let (code, _, b) = call(facade, "GET", "/api/chat/files", "").await; + assert_eq!(code, StatusCode::OK); + let v: Value = serde_json::from_slice(&b).unwrap(); + // Le lien est listé (comme le Go), mais son contenu ne compte pas. + assert_eq!( + (v["total"].as_i64(), v["count"].as_i64()), + (Some(10), Some(1)) + ); + // Nom d'archive tiré du titre. + let (_, _, b) = call(facade, "GET", "/api/chat/files/zip?meta=1", "").await; + let v: Value = serde_json::from_slice(&b).unwrap(); + assert!( + v["name"] + .as_str() + .unwrap() + .starts_with("loki-ma-discussion-"), + "{v}" + ); + assert_eq!(v["files"], 1); +} + +#[tokio::test] +async fn sans_la_variable_les_fichiers_restent_au_go() { + let tmp = tempfile::tempdir().unwrap(); + let (facade, _go, _stop) = fakego::start(tmp.path().to_path_buf()).await; + for p in [ + "/api/chat/files", + "/api/chat/file", + "/api/chat/image", + "/api/chat/files/zip", + ] { + let (_, _, b) = fakego::get(facade, p, &[]).await; + assert_eq!(&b[..], format!("go:{p}").as_bytes()); + } +} diff --git a/rust/loki-rs/tests/lot2.rs b/rust/loki-rs/tests/lot2.rs index c3810f9..d52cce2 100644 --- a/rust/loki-rs/tests/lot2.rs +++ b/rust/loki-rs/tests/lot2.rs @@ -97,6 +97,7 @@ async fn banc(cle: Option<&str>) -> Banc { models: None, dictate: None, settings: false, + files: None, }; tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/parity_files/files.json b/rust/loki-rs/tests/parity_files/files.json new file mode 100644 index 0000000..9cfc7bc --- /dev/null +++ b/rust/loki-rs/tests/parity_files/files.json @@ -0,0 +1,975 @@ +{ + "upload_name": { + "": "fichier", + " .cache ": "cache", + "..": "fichier", + "../../etc/passwd": "passwd", + "C:\\Users\\moi\\photo.jpg": "photo.jpg", + "a:b*c?d\"eg|h.txt": "a_b_c_d_e_f_g_h.txt", + "nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn.eeeeeeeeeeeeeeeeeeee": "nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn", + "nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn.pdf": "nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn.pdf", + "rapport.pdf": "rapport.pdf", + "x\u0001y.txt": "x_y.txt" + }, + "slug": { + "": "", + " Rapport_Final.v2 ": "rapport-final-v2", + "!!!": "", + "--a--": "a", + "Déjà vu": "dj-vu", + "abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc ": "abc-abc-abc-abc-abc-abc-abc-abc-abc-abc" + }, + "exif": [ + { + "in": "/9j/4AAGSkZJRv/hACpFeGlmAABJSSoACAAAAAIAEgEDAAEAAAAGAAAADwECAAEAAAAAAAAA/9oAAg==", + "out": 6 + }, + { + "in": "/9j/4AAGSkZJRv/hACpFeGlmAABNTQAqAAAACAACAQ8AAgAAAAEAAAAAARIAAwAAAAEACAAA/9oAAg==", + "out": 8 + }, + { + "in": "/9j/4AAGSkZJRv/hACpFeGlmAABJSSoACAAAAAIADwECAAEAAAAAAAAAEgEDAAEAAAABAAAA/9oAAg==", + "out": 1 + }, + { + "in": "/9j/4AAGSkZJRv/hACpFeGlmAABJSSoACAAAAAIA", + "out": 0 + }, + { + "in": "/9j/2g==", + "out": 0 + }, + { + "in": "cGFzIHVuIGpwZWc=", + "out": 0 + } + ], + "orient": [ + { + "orient": 1, + "out": { + "w": 5, + "h": 3, + "pix": "CzBVep/E6Q4zWH2ix+wRNluApcrvFDleg6jN8hc8YYar0PUaP2SJrtP4HUJnjLHW+yBFao+02f4jSG2S" + } + }, + { + "orient": 2, + "out": { + "w": 5, + "h": 3, + "pix": "W4ClysfsETYzWH2in8TpDgswVXo/ZImuq9D1Ghc8YYaDqM3y7xQ5XiNIbZKPtNn++yBFameMsdbT+B1C" + } + }, + { + "orient": 3, + "out": { + "w": 5, + "h": 3, + "pix": "I0htko+02f77IEVqZ4yx1tP4HUI/ZImuq9D1Ghc8YYaDqM3y7xQ5XluApcrH7BE2M1h9op/E6Q4LMFV6" + } + }, + { + "orient": 4, + "out": { + "w": 5, + "h": 3, + "pix": "0/gdQmeMsdb7IEVqj7TZ/iNIbZLvFDleg6jN8hc8YYar0PUaP2SJrgswVXqfxOkOM1h9osfsETZbgKXK" + } + }, + { + "orient": 5, + "out": { + "w": 3, + "h": 5, + "pix": "CzBVeu8UOV7T+B1Cn8TpDoOozfJnjLHWM1h9ohc8YYb7IEVqx+wRNqvQ9RqPtNn+W4Clyj9kia4jSG2S" + } + }, + { + "orient": 6, + "out": { + "w": 3, + "h": 5, + "pix": "0/gdQu8UOV4LMFV6Z4yx1oOozfKfxOkO+yBFahc8YYYzWH2ij7TZ/qvQ9RrH7BE2I0htkj9kia5bgKXK" + } + }, + { + "orient": 7, + "out": { + "w": 3, + "h": 5, + "pix": "I0htkj9kia5bgKXKj7TZ/qvQ9RrH7BE2+yBFahc8YYYzWH2iZ4yx1oOozfKfxOkO0/gdQu8UOV4LMFV6" + } + }, + { + "orient": 8, + "out": { + "w": 3, + "h": 5, + "pix": "W4Clyj9kia4jSG2Sx+wRNqvQ9RqPtNn+M1h9ohc8YYb7IEVqn8TpDoOozfJnjLHWCzBVeu8UOV7T+B1C" + } + } + ], + "orient_in": { + "w": 5, + "h": 3, + "pix": "CzBVep/E6Q4zWH2ix+wRNluApcrvFDleg6jN8hc8YYar0PUaP2SJrtP4HUJnjLHW+yBFao+02f4jSG2S" + }, + "scale": [ + { + "in": { + "w": 7, + "h": 5, + "pix": "gabL8BU6X4SpzvMYPWKHrNH2G0Bliq/U+R5DaI2y1/whRmuQtdr/JEluk7jdAidMcZa74AUqT3SZvuMILVJ3nMHmCzBVep/E6Q4zWH2ix+wRNluApcrvFDleg6jN8hc8YYar0PUaP2SJrtP4HUJnjLHW+yBFao+02f4jSG2St9wBJktwlbrfBClOc5g=" + }, + "max": 3, + "out": { + "w": 3, + "h": 2, + "pix": "UXabwHmew2iVZV+Eb5S5Xpe8YYZsWH2G" + } + }, + { + "in": { + "w": 5, + "h": 9, + "pix": "7xQ5XoOozfIXPGGGq9D1Gj9kia7T+B1CZ4yx1vsgRWqPtNn+I0htkrfcASZLcJW63wQpTnOYveIHLFF2m8DlCi9UeZ7D6A0yV3yhxusQNVp/pMnuEzhdgqfM8RY7YIWqz/QZPmOIrdL3HEFmi7DV+h9EaY6z2P0iR2yRttsAJUpvlLneAyhNcpe84QYrUHWav+QJLlN4ncLnDDFWe6DF6g80WX6jyO0SN1yBpsvwFTpfhKnO" + }, + "max": 4, + "out": { + "w": 2, + "h": 4, + "pix": "q5B1mnJskYxzmH1ij19ZfntghaqCp6FxdW9pZHV9ho4=" + } + }, + { + "in": { + "w": 10, + "h": 10, + "pix": "THGWu+AFKk90mb7jCC1Sd5zB5gswVXqfxOkOM1h9osfsETZbgKXK7xQ5XoOozfIXPGGGq9D1Gj9kia7T+B1CZ4yx1vsgRWqPtNn+I0htkrfcASZLcJW63wQpTnOYveIHLFF2m8DlCi9UeZ7D6A0yV3yhxusQNVp/pMnuEzhdgqfM8RY7YIWqz/QZPmOIrdL3HEFmi7DV+h9EaY6z2P0iR2yRttsAJUpvlLneAyhNcpe84QYrUHWav+QJLlN4ncLnDDFWe6DF6g80WX6jyO0SN1yBpsvwFTpfhKnO8xg9Yoes0fYbQGWKr9T5HkNojbLX/CFGa5C12v8kSW6TuN0CJ0xxlrvgBSpPdJm+4wgtUnecweYLMFV6n8TpDjNYfaLH7BE2W4Clyu8UOV6DqM3yFzxhhqvQ9Ro/ZImu0/gdQmeMsdb7IEVqj7TZ/iNIbZK33AEmS3CVut8EKU5zmL3iByxRdpvA5QovVHmew+gNMld8ocbrEDVaf6TJ7hM4XYKnzPEWO2CFqs/0GT5jiK3S9w==" + }, + "max": 3, + "out": { + "w": 3, + "h": 3, + "pix": "b1uAiICJdWF/eYmZcZaCb4Nvd5yCkYxxkXaGlXhygnx+g3h9" + } + }, + { + "in": { + "w": 3, + "h": 2, + "pix": "QmeMsdb7IEVqj7TZ/iNIbZK33AEmS3CV" + }, + "max": 5, + "out": { + "w": 3, + "h": 2, + "pix": "QmeMsdb7IEVqj7TZ/iNIbZK33AEmS3CV" + } + } + ], + "sniff": [ + { + "in": "iVBORw0KGgoAAAANSUhEUg==", + "out": "image/png" + }, + { + "in": "/9j/4A==", + "out": "image/jpeg" + }, + { + "in": "R0lGODlhAQA=", + "out": "image/gif" + }, + { + "in": "UklGRhAAAABXRUJQVlA4IA==", + "out": "image/webp" + }, + { + "in": "Qk0AAA==", + "out": "image/bmp" + }, + { + "in": "AAABAAEA", + "out": "image/x-icon" + }, + { + "in": "PHN2ZyB4bWxucz0neCcvPg==", + "out": "text/plain; charset=utf-8" + }, + { + "in": "PGh0bWw+PGJvZHk+eA==", + "out": "text/html; charset=utf-8" + }, + { + "in": "Ym9uam91cg==", + "out": "text/plain; charset=utf-8" + }, + { + "in": "", + "out": "text/plain; charset=utf-8" + } + ], + "workspace": { + "dir": "{{HOME}}/workspace/discussions/c-fichiers", + "id": "c-fichiers", + "root": "{{HOME}}/workspace", + "title": "" + }, + "tree": { + "workspace/discussions/c-autre/captures/x.jpg": { + "data": "anBlZz8=", + "mtime": 1700000004 + }, + "workspace/discussions/c-fichiers/faux.png": { + "data": "PGh0bWw+cGFzIHVuZSBpbWFnZTwvaHRtbD4=", + "mtime": 1700000005 + }, + "workspace/discussions/c-fichiers/image.png": { + "data": "iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAAQ0lEQVR4nAAzAMz/AxY7YIWfssTX6fwOobPG2OsAZouw1fofRGmOs9j9IkdskQC22wAlSm+Uud4DKE1yl7zhAQAA//+6OxpmJmJbrAAAAABJRU5ErkJggg==", + "mtime": 1700000040 + }, + "workspace/discussions/c-fichiers/notes": { + "dir": true, + "mtime": 1700000020 + }, + "workspace/discussions/c-fichiers/notes/a.txt": { + "data": "YWxwaGE=", + "mtime": 1700000010 + }, + "workspace/discussions/c-fichiers/notes/sous": { + "dir": true, + "mtime": 1700000012 + }, + "workspace/discussions/c-fichiers/notes/sous/b.txt": { + "data": "YmV0YSEh", + "mtime": 1700000011 + }, + "workspace/discussions/c-fichiers/rapport.md": { + "data": "IyBSYXBwb3J0Cg==", + "mtime": 1700000030 + }, + "workspace/uploads": { + "dir": true, + "mtime": 1700000003 + }, + "workspace/uploads/ancien.pdf": { + "data": "JVBERg==", + "mtime": 1700000002 + }, + "workspace/vieux.txt": { + "data": "b2xk", + "mtime": 1700000001 + } + }, + "calls": [ + { + "method": "GET", + "url": "/api/chat/files", + "status": 200, + "out": { + "at_root": true, + "count": 5, + "dir": "", + "entries": [ + { + "dir": true, + "image": false, + "items": 2, + "mod": 1700000020, + "name": "notes", + "path": "notes", + "size": 11 + }, + { + "dir": false, + "image": true, + "mod": 1700000040, + "name": "image.png", + "path": "image.png", + "size": 124 + }, + { + "dir": false, + "image": false, + "mod": 1700000030, + "name": "rapport.md", + "path": "rapport.md", + "size": 10 + }, + { + "dir": false, + "image": true, + "mod": 1700000005, + "name": "faux.png", + "path": "faux.png", + "size": 26 + } + ], + "legacy": 2, + "ok": true, + "parent": "", + "root": "{{HOME}}/workspace/discussions/c-fichiers", + "scope": "", + "total": 171 + } + }, + { + "method": "GET", + "url": "/api/chat/files?dir=notes", + "status": 200, + "out": { + "at_root": false, + "count": 5, + "dir": "notes", + "entries": [ + { + "dir": true, + "image": false, + "items": 1, + "mod": 1700000012, + "name": "sous", + "path": "notes/sous", + "size": 6 + }, + { + "dir": false, + "image": false, + "mod": 1700000010, + "name": "a.txt", + "path": "notes/a.txt", + "size": 5 + } + ], + "legacy": 2, + "ok": true, + "parent": "", + "root": "{{HOME}}/workspace/discussions/c-fichiers", + "scope": "", + "total": 171 + } + }, + { + "method": "GET", + "url": "/api/chat/files?dir=notes/sous/", + "status": 200, + "out": { + "at_root": false, + "count": 5, + "dir": "notes/sous", + "entries": [ + { + "dir": false, + "image": false, + "mod": 1700000011, + "name": "b.txt", + "path": "notes/sous/b.txt", + "size": 6 + } + ], + "legacy": 2, + "ok": true, + "parent": "notes", + "root": "{{HOME}}/workspace/discussions/c-fichiers", + "scope": "", + "total": 171 + } + }, + { + "method": "GET", + "url": "/api/chat/files?dir=../../..", + "status": 403, + "out": { + "error": "hors du dossier de travail", + "ok": false + } + }, + { + "method": "GET", + "url": "/api/chat/files?dir=rapport.md", + "status": 404, + "out": { + "error": "dossier introuvable", + "ok": false + } + }, + { + "method": "GET", + "url": "/api/chat/files?scope=legacy", + "status": 200, + "out": { + "at_root": true, + "count": 8, + "dir": "", + "entries": [ + { + "dir": true, + "image": false, + "items": 1, + "mod": 1700000003, + "name": "uploads", + "path": "uploads", + "size": 4 + }, + { + "dir": false, + "image": false, + "mod": 1700000001, + "name": "vieux.txt", + "path": "vieux.txt", + "size": 3 + } + ], + "legacy": 2, + "ok": true, + "parent": "", + "root": "{{HOME}}/workspace", + "scope": "legacy", + "total": 183 + } + }, + { + "method": "GET", + "url": "/api/chat/file?path=rapport.md&meta=1", + "status": 200, + "out": { + "e2e": false, + "name": "rapport.md", + "ok": true, + "size": 10 + } + }, + { + "method": "GET", + "url": "/api/chat/file?path=rapport.md&b64=1&offset=2&len=4", + "status": 200, + "out": { + "data": "UmFwcA==", + "eof": false, + "name": "rapport.md", + "offset": 2, + "ok": true, + "size": 10 + } + }, + { + "method": "GET", + "url": "/api/chat/file?path=rapport.md&b64=1&offset=5", + "status": 200, + "out": { + "data": "cG9ydAo=", + "eof": true, + "name": "rapport.md", + "offset": 5, + "ok": true, + "size": 10 + } + }, + { + "method": "GET", + "url": "/api/chat/file?path=rapport.md&b64=1&offset=99", + "status": 400, + "out": { + "error": "position hors du fichier", + "ok": false + } + }, + { + "method": "GET", + "url": "/api/chat/file?path=../../vieux.txt", + "status": 403, + "out": { + "error": "hors du dossier de travail", + "ok": false + } + }, + { + "method": "GET", + "url": "/api/chat/file?path=absent.txt", + "status": 404, + "out": { + "error": "fichier introuvable", + "ok": false + } + }, + { + "method": "GET", + "url": "/api/chat/file?path=", + "status": 400, + "out": { + "error": "chemin manquant", + "ok": false + } + }, + { + "method": "GET", + "url": "/api/chat/file?path=captures/c-autre/x.jpg&meta=1", + "status": 200, + "out": { + "e2e": false, + "name": "x.jpg", + "ok": true, + "size": 5 + } + }, + { + "method": "GET", + "url": "/api/chat/file?path=uploads/ancien.pdf&meta=1", + "status": 200, + "out": { + "e2e": false, + "name": "ancien.pdf", + "ok": true, + "size": 4 + } + }, + { + "method": "GET", + "url": "/api/chat/file?path=notes", + "status": 404, + "out": { + "error": "fichier introuvable", + "ok": false + } + }, + { + "method": "GET", + "url": "/api/chat/file?path=/notes/a.txt", + "status": 200, + "raw": "YWxwaGE=", + "headers": { + "Content-Disposition": "attachment; filename*=UTF-8''a.txt", + "Content-Type": "application/octet-stream", + "X-Content-Type-Options": "nosniff" + } + }, + { + "method": "GET", + "url": "/api/chat/image?path=faux.png", + "status": 415, + "out": { + "error": "ce fichier n'est pas une image", + "ok": false + } + }, + { + "method": "GET", + "url": "/api/chat/image?path=image.png", + "status": 200, + "raw": "iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAAQ0lEQVR4nAAzAMz/AxY7YIWfssTX6fwOobPG2OsAZouw1fofRGmOs9j9IkdskQC22wAlSm+Uud4DKE1yl7zhAQAA//+6OxpmJmJbrAAAAABJRU5ErkJggg==", + "headers": { + "Content-Disposition": "inline; filename*=UTF-8''image.png", + "Content-Security-Policy": "default-src 'none'; sandbox", + "Content-Type": "image/png", + "X-Content-Type-Options": "nosniff" + } + }, + { + "method": "GET", + "url": "/api/chat/image?path=image.png&thumb=2", + "status": 200, + "headers": { + "Cache-Control": "private, max-age=3600", + "Content-Security-Policy": "default-src 'none'; sandbox", + "Content-Type": "image/jpeg", + "X-Content-Type-Options": "nosniff" + }, + "thumb": [ + 4, + 3 + ] + }, + { + "method": "GET", + "url": "/api/chat/image?path=rapport.md&thumb=64", + "status": 415, + "out": { + "error": "ce fichier n'est pas une image", + "ok": false + } + }, + { + "method": "POST", + "url": "/api/chat/upload", + "body": "{\"name\":\"../Mon fichier?.txt\",\"data\":\"aGVsbG8=\"}", + "status": 200, + "out": { + "abs": "{{HOME}}/workspace/discussions/c-fichiers/uploads/Mon fichier_.txt", + "ok": true, + "path": "uploads/Mon fichier_.txt", + "size": 5 + }, + "tree": { + "faux.png": 26, + "image.png": 124, + "notes/a.txt": 5, + "notes/sous/b.txt": 6, + "rapport.md": 10, + "uploads/Mon fichier_.txt": 5 + } + }, + { + "method": "POST", + "url": "/api/chat/upload", + "body": "{\"name\":\"Mon fichier?.txt\",\"data\":\"aGVsbG8gIQ==\"}", + "status": 200, + "out": { + "abs": "{{HOME}}/workspace/discussions/c-fichiers/uploads/Mon fichier_-2.txt", + "ok": true, + "path": "uploads/Mon fichier_-2.txt", + "size": 7 + }, + "tree": { + "faux.png": 26, + "image.png": 124, + "notes/a.txt": 5, + "notes/sous/b.txt": 6, + "rapport.md": 10, + "uploads/Mon fichier_-2.txt": 7, + "uploads/Mon fichier_.txt": 5 + } + }, + { + "method": "POST", + "url": "/api/chat/upload", + "body": "{\"name\":\"gros.bin\",\"data\":\"AAEC\",\"more\":true,\"size\":5}", + "status": 200, + "out": { + "id": "{{ID}}", + "ok": true, + "received": 3 + }, + "tree": { + "faux.png": 26, + "image.png": 124, + "notes/a.txt": 5, + "notes/sous/b.txt": 6, + "rapport.md": 10, + "uploads/.upload-{{N}}.part": 3, + "uploads/Mon fichier_-2.txt": 7, + "uploads/Mon fichier_.txt": 5 + } + }, + { + "method": "POST", + "url": "/api/chat/upload", + "body": "{\"id\":\"{{ID}}\",\"data\":\"data:application/octet-stream;base64,AwQ=\",\"more\":false}", + "status": 200, + "out": { + "abs": "{{HOME}}/workspace/discussions/c-fichiers/uploads/gros.bin", + "ok": true, + "path": "uploads/gros.bin", + "size": 5 + }, + "tree": { + "faux.png": 26, + "image.png": 124, + "notes/a.txt": 5, + "notes/sous/b.txt": 6, + "rapport.md": 10, + "uploads/Mon fichier_-2.txt": 7, + "uploads/Mon fichier_.txt": 5, + "uploads/gros.bin": 5 + } + }, + { + "method": "POST", + "url": "/api/chat/upload", + "body": "{\"id\":\"inconnu\",\"data\":\"AA==\"}", + "status": 409, + "out": { + "error": "envoi expiré — recommence le fichier", + "ok": false + }, + "tree": { + "faux.png": 26, + "image.png": 124, + "notes/a.txt": 5, + "notes/sous/b.txt": 6, + "rapport.md": 10, + "uploads/Mon fichier_-2.txt": 7, + "uploads/Mon fichier_.txt": 5, + "uploads/gros.bin": 5 + } + }, + { + "method": "POST", + "url": "/api/chat/upload", + "body": "{\"name\":\"vide.txt\",\"data\":\"\"}", + "status": 400, + "out": { + "error": "fichier vide", + "ok": false + }, + "tree": { + "faux.png": 26, + "image.png": 124, + "notes/a.txt": 5, + "notes/sous/b.txt": 6, + "rapport.md": 10, + "uploads/Mon fichier_-2.txt": 7, + "uploads/Mon fichier_.txt": 5, + "uploads/gros.bin": 5 + } + }, + { + "method": "POST", + "url": "/api/chat/upload", + "body": "{\"name\":\"x\",\"data\":\"!!!\"}", + "status": 400, + "out": { + "error": "contenu illisible (base64 attendu)", + "ok": false + }, + "tree": { + "faux.png": 26, + "image.png": 124, + "notes/a.txt": 5, + "notes/sous/b.txt": 6, + "rapport.md": 10, + "uploads/Mon fichier_-2.txt": 7, + "uploads/Mon fichier_.txt": 5, + "uploads/gros.bin": 5 + } + }, + { + "method": "POST", + "url": "/api/chat/upload", + "body": "pas du json", + "status": 400, + "out": { + "error": "morceau trop gros ou requête invalide", + "ok": false + }, + "tree": { + "faux.png": 26, + "image.png": 124, + "notes/a.txt": 5, + "notes/sous/b.txt": 6, + "rapport.md": 10, + "uploads/Mon fichier_-2.txt": 7, + "uploads/Mon fichier_.txt": 5, + "uploads/gros.bin": 5 + } + }, + { + "method": "POST", + "url": "/api/chat/file/delete", + "body": "{\"path\":\"\"}", + "status": 400, + "out": { + "error": "chemin manquant", + "ok": false + }, + "tree": { + "faux.png": 26, + "image.png": 124, + "notes/a.txt": 5, + "notes/sous/b.txt": 6, + "rapport.md": 10, + "uploads/Mon fichier_-2.txt": 7, + "uploads/Mon fichier_.txt": 5, + "uploads/gros.bin": 5 + } + }, + { + "method": "POST", + "url": "/api/chat/file/delete", + "body": "{\"path\":\".\"}", + "status": 403, + "out": { + "error": "le dossier de travail lui-même ne se supprime pas", + "ok": false + }, + "tree": { + "faux.png": 26, + "image.png": 124, + "notes/a.txt": 5, + "notes/sous/b.txt": 6, + "rapport.md": 10, + "uploads/Mon fichier_-2.txt": 7, + "uploads/Mon fichier_.txt": 5, + "uploads/gros.bin": 5 + } + }, + { + "method": "POST", + "url": "/api/chat/file/delete", + "body": "{\"path\":\"../..\"}", + "status": 403, + "out": { + "error": "hors du dossier de travail", + "ok": false + }, + "tree": { + "faux.png": 26, + "image.png": 124, + "notes/a.txt": 5, + "notes/sous/b.txt": 6, + "rapport.md": 10, + "uploads/Mon fichier_-2.txt": 7, + "uploads/Mon fichier_.txt": 5, + "uploads/gros.bin": 5 + } + }, + { + "method": "POST", + "url": "/api/chat/file/delete", + "body": "{\"path\":\"discussions\",\"scope\":\"legacy\"}", + "status": 403, + "out": { + "error": "les dossiers des discussions se suppriment discussion par discussion", + "ok": false + }, + "tree": { + "faux.png": 26, + "image.png": 124, + "notes/a.txt": 5, + "notes/sous/b.txt": 6, + "rapport.md": 10, + "uploads/Mon fichier_-2.txt": 7, + "uploads/Mon fichier_.txt": 5, + "uploads/gros.bin": 5 + } + }, + { + "method": "POST", + "url": "/api/chat/file/delete", + "body": "{\"path\":\"absent\"}", + "status": 404, + "out": { + "error": "introuvable", + "ok": false + }, + "tree": { + "faux.png": 26, + "image.png": 124, + "notes/a.txt": 5, + "notes/sous/b.txt": 6, + "rapport.md": 10, + "uploads/Mon fichier_-2.txt": 7, + "uploads/Mon fichier_.txt": 5, + "uploads/gros.bin": 5 + } + }, + { + "method": "POST", + "url": "/api/chat/file/delete", + "body": "{\"path\":\"faux.png\"}", + "status": 200, + "out": { + "freed": 26, + "ok": true + }, + "tree": { + "image.png": 124, + "notes/a.txt": 5, + "notes/sous/b.txt": 6, + "rapport.md": 10, + "uploads/Mon fichier_-2.txt": 7, + "uploads/Mon fichier_.txt": 5, + "uploads/gros.bin": 5 + } + }, + { + "method": "POST", + "url": "/api/chat/file/delete", + "body": "{\"path\":\"notes/sous\"}", + "status": 200, + "out": { + "freed": 6, + "ok": true + }, + "tree": { + "image.png": 124, + "notes/a.txt": 5, + "rapport.md": 10, + "uploads/Mon fichier_-2.txt": 7, + "uploads/Mon fichier_.txt": 5, + "uploads/gros.bin": 5 + } + }, + { + "method": "GET", + "url": "/api/chat/files/zip", + "status": 404, + "out": { + "error": "archive absente — redemande la fiche (meta=1) d'abord", + "ok": false + } + }, + { + "method": "GET", + "url": "/api/chat/files/zip?meta=1", + "status": 200, + "out": { + "e2e": false, + "files": 6, + "name": "loki-c-fichiers-{{DATE}}.zip", + "ok": true, + "size": "…" + } + }, + { + "method": "GET", + "url": "/api/chat/files/zip?b64=1&offset=0&len=99999999", + "status": 200, + "out": { + "data": "…", + "eof": true, + "name": "loki-c-fichiers-{{DATE}}.zip", + "offset": 0, + "ok": true, + "size": "…" + }, + "zip": { + "image.png": "iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAAQ0lEQVR4nAAzAMz/AxY7YIWfssTX6fwOobPG2OsAZouw1fofRGmOs9j9IkdskQC22wAlSm+Uud4DKE1yl7zhAQAA//+6OxpmJmJbrAAAAABJRU5ErkJggg==", + "notes/a.txt": "YWxwaGE=", + "rapport.md": "IyBSYXBwb3J0Cg==", + "uploads/Mon fichier_-2.txt": "aGVsbG8gIQ==", + "uploads/Mon fichier_.txt": "aGVsbG8=", + "uploads/gros.bin": "AAECAwQ=" + } + }, + { + "method": "GET", + "url": "/api/chat/files/zip?b64=1&offset=-1", + "status": 400, + "out": { + "error": "position hors du fichier", + "ok": false + } + }, + { + "method": "GET", + "url": "/api/chat/files/zip?meta=1&scope=legacy", + "status": 200, + "out": { + "e2e": false, + "files": 2, + "name": "loki-hors-discussion-{{DATE}}.zip", + "ok": true, + "size": "…" + } + }, + { + "method": "GET", + "url": "/api/chat/files/zip?b64=1&scope=legacy", + "status": 200, + "out": { + "data": "…", + "eof": true, + "name": "loki-hors-discussion-{{DATE}}.zip", + "offset": 0, + "ok": true, + "size": "…" + }, + "zip": { + "uploads/ancien.pdf": "JVBERg==", + "vieux.txt": "b2xk" + } + } + ] +} diff --git a/rust/loki-rs/tests/proxy.rs b/rust/loki-rs/tests/proxy.rs index 02409c1..71d207b 100644 --- a/rust/loki-rs/tests/proxy.rs +++ b/rust/loki-rs/tests/proxy.rs @@ -103,6 +103,7 @@ async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) { models: None, dictate: None, settings: false, + files: None, }; tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { let _ = rx.await;