From 8bba80dee893ac613c1cf6ecfd325536acd0563a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 16:45:17 +0000 Subject: [PATCH] =?UTF-8?q?Lot=2011=20:=20fichiers=20de=20la=20discussion?= =?UTF-8?q?=20tenus=20par=20la=20fa=C3=A7ade=20(LOKI=5FFILES=3Drust)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La façade sert le dépôt des pièces jointes (morceaux base64, 1 Gio au plus, espace disque vérifié), le panneau Fichiers, la suppression, le téléchargement (fiche, tranches base64 pour le tunnel E2E, brut avec plage d'octets), l'affichage des images (type reniflé, CSP fermée, vignette redressée et réduite par moyennage) et l'archive .zip, quand LOKI_FILES=rust (expérimental ; le Go reste le défaut). Côté Go : la nouvelle route /internal/v1/conv/workspace donne le dossier de la discussion active (le Go la connaît toujours), facade_files.go remet ces routes à la façade quand elles arrivent par le tunnel, et les .part d'envois ne sont plus vidés par le Go au démarrage. Parité : parity_files_test.go tire du Go les noms, slugs, en-têtes EXIF, orientations et réductions (pixel pour pixel), reniflages, puis 42 appels de route sur une arborescence fixe, rejoués par tests/convfiles.rs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- README.md | 1 + ...15-rust-migration-lot11-fichiers-design.md | 63 + internal/loki/facade_chat.go | 5 +- internal/loki/facade_chat_test.go | 46 + internal/loki/facade_files.go | 67 + internal/loki/parity_files_test.go | 435 +++++ internal/loki/web_internal.go | 2 + internal/loki/web_server.go | 10 +- rust/Cargo.lock | 176 +- rust/loki-rs/Cargo.toml | 3 + rust/loki-rs/src/api.rs | 8 +- rust/loki-rs/src/convfiles.rs | 1573 +++++++++++++++++ rust/loki-rs/src/lib.rs | 1 + rust/loki-rs/src/main.rs | 42 +- rust/loki-rs/src/proxy.rs | 8 + rust/loki-rs/tests/common/fakego.rs | 8 + rust/loki-rs/tests/convfiles.rs | 334 ++++ rust/loki-rs/tests/lot2.rs | 1 + rust/loki-rs/tests/parity_files/files.json | 975 ++++++++++ rust/loki-rs/tests/proxy.rs | 1 + 20 files changed, 3751 insertions(+), 8 deletions(-) create mode 100644 docs/superpowers/specs/2026-10-15-rust-migration-lot11-fichiers-design.md create mode 100644 internal/loki/facade_files.go create mode 100644 internal/loki/parity_files_test.go create mode 100644 rust/loki-rs/src/convfiles.rs create mode 100644 rust/loki-rs/tests/convfiles.rs create mode 100644 rust/loki-rs/tests/parity_files/files.json 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;