mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-17 04:10:04 +02:00
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:
20 files changed
+3751
-8
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user