Lot 11 : fichiers de la discussion tenus par la façade (LOKI_FILES=rust)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
Claude committed 2026-10-15 16:45:17 +00:00
1 parent 28f90543de
commit 8bba80dee8
20 files changed
+3751 -8

No files matched your search

+1
View File
@@ -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
@@ -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=<px>` 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/<id>/…`, `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.
+4 -1
View File
@@ -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
}
+46
View File
@@ -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)
}
}
+67
View File
@@ -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,
})
}
+435
View File
@@ -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\"e<f>g|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("<svg xmlns='x'/>"), []byte("<html><body>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("<html>pas une image</html>"), 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)
}
}
+2
View File
@@ -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)
}
+6 -4
View File
@@ -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)
+175 -1
View File
@@ -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",
]
+3
View File
@@ -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"] }
+7 -1
View File
@@ -34,6 +34,8 @@ pub struct Ctx {
pub dictate: Option<std::sync::Arc<crate::dictate::Dictation>>,
/// 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<std::sync::Arc<crate::convfiles::ConvFiles>>,
/// 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
File diff suppressed because it is too large. Load diff
+1
View File
@@ -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;
+41 -1
View File
@@ -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<loki_rs::convfiles::ConvFiles> {
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<Option<String>, String> {
let store = loki_rs::store::Store::new(backend, token.to_string());
+8
View File
@@ -71,6 +71,8 @@ pub struct Config {
pub dictate: Option<Arc<crate::dictate::Dictation>>,
/// 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<Arc<crate::convfiles::ConvFiles>>,
}
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;
}
+8
View File
@@ -62,6 +62,8 @@ pub struct FakeGo {
pub turn_view: Mutex<Option<serde_json::Value>>,
pub turn_verifier: Mutex<Option<serde_json::Value>>,
pub tool_results: Mutex<HashMap<String, String>>,
/// Réponse de /internal/v1/conv/workspace (None : 404).
pub conv_workspace: Mutex<Option<serde_json::Value>>,
}
impl FakeGo {
@@ -291,6 +293,10 @@ async fn handle(st: Arc<FakeGo>, req: Request<Incoming>) -> Response<Full<Bytes>
*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<String>,
pub dictate: Option<Arc<loki_rs::dictate::Dictation>>,
pub settings: bool,
pub files: Option<Arc<loki_rs::convfiles::ConvFiles>>,
}
/// 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;
+334
View File
@@ -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<u8> {
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<String, u64> {
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<Bytes>> = 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<String, Vec<u8>> = 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<String, u64> = 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<Bytes>> = 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());
}
}
+1
View File
@@ -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;
+975
View File
@@ -0,0 +1,975 @@
{
"upload_name": {
"": "fichier",
" .cache ": "cache",
"..": "fichier",
"../../etc/passwd": "passwd",
"C:\\Users\\moi\\photo.jpg": "photo.jpg",
"a:b*c?d\"e<f>g|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"
}
}
]
}
+1
View File
@@ -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;