From 6fe94b9a8d4d3d9de9cf7e3d3adba23549d9cf14 Mon Sep 17 00:00:00 2001 From: Loki Date: Sat, 15 Aug 2026 11:54:52 +0000 Subject: [PATCH] =?UTF-8?q?Captures=20de=20pages=20web=20(Playwright)=20af?= =?UTF-8?q?fich=C3=A9es=20dans=20le=20fil?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outil web_screenshot : Chromium piloté par Playwright photographie une page RENDUE (JavaScript exécuté) dans le dossier de travail, et rend au modèle la ligne markdown exacte à recopier — lui laisser composer l'URL reviendrait à lui faire inventer un chemin, donc une image cassée. INDÉPENDANT DE LA VISION, souvent confondu : visionEnabled() (clé MMPROJ) ne décide que d'une chose, l'envoi d'images AU MODÈLE. Capturer et afficher ne passent pas par le modèle — sans projecteur, l'agent photographie sans regarder, ce qui suffit à illustrer une conversation. - route /api/chat/image : sert UNIQUEMENT des images du dossier de travail, en ligne. handleChatFile force le téléchargement de tout pour qu'un .html du modèle ne s'exécute pas dans l'origine de l'UI ; ici la même règle est tenue autrement — type déduit du CONTENU (pas de l'extension), nosniff, et CSP default-src 'none'. Un faux .png contenant du HTML est refusé en 415. - UI : une balise ne peut pas porter d'en-tête Authorization, or /api/* exige la clé dès qu'elle est définie. Les images sont donc récupérées par fetch authentifié puis posées en blob:. - l'outil n'est déclaré au modèle QUE si Playwright est réellement présent : annoncer un outil absent envoie le modèle en boucle de réessai. - Node 22 (NodeSource) au lieu du Node 18 d'Ubuntu, exigé par Playwright et par la plupart des serveurs MCP. PLAYWRIGHT=0 bâtit une image sans navigateur. - CONFIG ACTIVE affichait « llama.cpp personnalisé » pour le moteur de l'image : il annonce maintenant « llama.cpp de l'image ». Description de l'outil tenue au plus court : TestSystemPromptStaysLean a attrapé le dépassement du budget de préambule (7888 car pour 7500). Vérifié dans l'image : capture réelle d'une page, servie en image/png avec CSP ; faux PNG rejeté (415) ; évasion du workspace bloquée (403). Playwright ajoute 816 Mo à l'image. --- Dockerfile | 19 ++- internal/loki/chat_cmd.go | 3 + internal/loki/chat_screenshot.go | 166 ++++++++++++++++++++++ internal/loki/llm_client.go | 8 ++ internal/loki/ui/index.html | 37 ++++- internal/loki/ui/src/js/05-status.js | 5 +- internal/loki/ui/src/js/08-chat-render.js | 29 +++- internal/loki/ui/src/styles.css | 3 + internal/loki/web_server.go | 1 + internal/loki/web_upload.go | 49 +++++++ 10 files changed, 315 insertions(+), 5 deletions(-) create mode 100644 internal/loki/chat_screenshot.go diff --git a/Dockerfile b/Dockerfile index 6b003b4..d16ea83 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,10 +35,27 @@ LABEL org.opencontainers.image.revision="${LOKI_VERSION}" \ # git pour les outils de l'agent ; nodejs/npm pour les serveurs MCP lancés # via npx ; tini en PID 1. curl et libgomp1 sont déjà dans l'image amont. +# Node 22 depuis NodeSource et non depuis Ubuntu (qui livre Node 18) : Playwright +# et la plupart des serveurs MCP réclament Node ≥ 20. RUN apt-get update && apt-get install -y --no-install-recommends \ - git nodejs npm tini \ + git tini ca-certificates curl gnupg \ + && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ && rm -rf /var/lib/apt/lists/* +# Playwright + Chromium : l'outil web_screenshot capture une page RENDUE +# (JavaScript exécuté), ce que le moteur web intégré ne sait pas faire. +# Coût assumé : ~500 Mo à 1 Go d'image. Mettre PLAYWRIGHT=0 pour bâtir une image +# légère — l'outil se désactive alors tout seul (le binaire absent est détecté). +ARG PLAYWRIGHT=1 +ARG PLAYWRIGHT_VERSION=latest +ENV PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers +RUN if [ "$PLAYWRIGHT" = "1" ]; then \ + npm i -g playwright@${PLAYWRIGHT_VERSION} \ + && playwright install --with-deps chromium \ + && rm -rf /root/.npm /var/lib/apt/lists/* ; \ + fi + COPY --from=gobuild /out/loki /usr/local/bin/loki COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh RUN chmod +x /usr/local/bin/docker-entrypoint.sh && mkdir -p /data /models diff --git a/internal/loki/chat_cmd.go b/internal/loki/chat_cmd.go index f9417da..51b487c 100644 --- a/internal/loki/chat_cmd.go +++ b/internal/loki/chat_cmd.go @@ -115,6 +115,9 @@ func cmdChat(args []string) error { case "web_search", "web_open", "web_read", "web_grep": icon = "🌐" verb = "web" + case "web_screenshot": + icon = "📸" + verb = "capture" } if inReason { fmt.Print("\n") diff --git a/internal/loki/chat_screenshot.go b/internal/loki/chat_screenshot.go new file mode 100644 index 0000000..9a14de6 --- /dev/null +++ b/internal/loki/chat_screenshot.go @@ -0,0 +1,166 @@ +package loki + +// chat_screenshot.go — outil web_screenshot : capture d'une page web RENDUE +// (JavaScript exécuté) via le navigateur Chromium piloté par Playwright. +// +// Indépendant de la vision : la capture est un fichier PNG écrit dans le dossier +// de travail, que l'UI affiche dans le fil (route /api/chat/image). Le modèle, +// lui, ne la VOIT que si un projecteur multimodal est configuré (MMPROJ) — deux +// mécanismes distincts qu'il ne faut pas confondre. Sans vision, l'agent +// photographie sans regarder : c'est utile pour TOI, pas pour lui. +// +// Playwright est installé dans l'image Docker (voir Dockerfile). Hors conteneur, +// ou si l'image a été bâtie avec PLAYWRIGHT=0, l'outil n'est pas déclaré du tout +// plutôt que d'être annoncé au modèle puis d'échouer — un outil qu'on annonce et +// qui ne marche pas déclenche des boucles de réessai. + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" +) + +// captureDir : sous-dossier du workspace où atterrissent les captures. +const captureDir = "captures" + +// screenshotTimeout : une page lente ne doit pas bloquer le tour. Playwright a +// son propre délai interne, celui-ci est le garde-fou externe. +const screenshotTimeout = 90 * time.Second + +// playwrightBin renvoie le chemin du CLI Playwright, ou "" s'il est absent. +func playwrightBin() string { + p, err := exec.LookPath("playwright") + if err != nil { + return "" + } + return p +} + +func screenshotAvailable() bool { return playwrightBin() != "" } + +func webScreenshotTool() Tool { + return Tool{Type: "function", Function: ToolFunction{ + Name: "web_screenshot", + // Description tenue au plus court : les schémas d'outils partent dans + // CHAQUE requête et le préambule a un budget (TestSystemPromptStaysLean). + Description: "Photographie une page web (PNG, JS exécuté) pour la MONTRER. " + + "La réponse donne la ligne markdown à recopier. Tu ne vois pas l'image.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "url": map[string]any{"type": "string", "description": "URL complète"}, + "full_page": map[string]any{"type": "boolean", "description": "Page entière. Défaut true."}, + }, + "required": []string{"url"}, + }, + }} +} + +// safeSlug réduit un hôte à un nom de fichier sûr. +var slugRe = regexp.MustCompile(`[^a-zA-Z0-9._-]+`) + +func safeSlug(s string) string { + s = slugRe.ReplaceAllString(s, "-") + s = strings.Trim(s, "-.") + if r := []rune(s); len(r) > 40 { + s = string(r[:40]) + } + if s == "" { + s = "page" + } + return s +} + +func toolWebScreenshot(args map[string]any) string { + bin := playwrightBin() + if bin == "" { + return "[erreur] Playwright n'est pas installé dans cette image (bâtie avec PLAYWRIGHT=0)." + } + url, _ := args["url"].(string) + url = strings.TrimSpace(url) + if url == "" { + return "[erreur] url manquante" + } + if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { + return "[erreur] url invalide : elle doit commencer par http:// ou https://" + } + // width n'est plus déclaré dans le schéma (budget de préambule), mais reste + // honoré s'il arrive quand même : un modèle qui l'invente obtient le + // comportement attendu plutôt qu'un paramètre ignoré en silence. + width := 1280 + if v, ok := args["width"].(float64); ok && v >= 320 && v <= 3840 { + width = int(v) + } + fullPage := true + if v, ok := args["full_page"].(bool); ok { + fullPage = v + } + + dir := filepath.Join(agentWorkspace(), captureDir) + if err := os.MkdirAll(dir, 0o755); err != nil { + return "[erreur] création du dossier de captures : " + err.Error() + } + // Nom lisible et unique : hôte + horodatage. Deux captures de la même page + // ne s'écrasent donc pas, et le fil garde les deux états. + host := url + if i := strings.Index(host, "://"); i >= 0 { + host = host[i+3:] + } + if i := strings.IndexAny(host, "/?#"); i >= 0 { + host = host[:i] + } + name := fmt.Sprintf("%s-%s.png", safeSlug(host), time.Now().Format("20060102-150405")) + out := filepath.Join(dir, name) + + cmdArgs := []string{"screenshot", "--browser", "chromium", + "--viewport-size", fmt.Sprintf("%d,800", width), + "--wait-for-timeout", "2500"} + if fullPage { + cmdArgs = append(cmdArgs, "--full-page") + } + cmdArgs = append(cmdArgs, url, out) + + ctx, cancel := context.WithTimeout(context.Background(), screenshotTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, bin, cmdArgs...) + cmd.Dir = dir + combined, err := cmd.CombinedOutput() + if err != nil { + if ctx.Err() == context.DeadlineExceeded { + return "[erreur] la page n'a pas fini de charger en 90 s" + } + return "[erreur] capture impossible : " + strings.TrimSpace(lastLines(string(combined), 3)) + } + st, err := os.Stat(out) + if err != nil || st.Size() == 0 { + return "[erreur] Playwright n'a produit aucune image" + } + + rel := captureDir + "/" + name + // On rend au modèle la ligne EXACTE à recopier : lui laisser composer l'URL + // d'affichage revient à lui faire inventer un chemin, donc une image cassée. + return fmt.Sprintf("Capture enregistrée (%s, %d Ko).\n"+ + "Pour la montrer à l'utilisateur, recopie TELLE QUELLE cette ligne markdown dans ta réponse :\n"+ + "![capture de %s](/api/chat/image?path=%s)", + rel, st.Size()/1024, host, rel) +} + +// lastLines garde les n dernières lignes non vides d'une sortie d'erreur — +// Playwright est bavard, seule la fin porte la cause. +func lastLines(s string, n int) string { + var keep []string + for _, l := range strings.Split(s, "\n") { + if strings.TrimSpace(l) != "" { + keep = append(keep, l) + } + } + if len(keep) > n { + keep = keep[len(keep)-n:] + } + return strings.Join(keep, " · ") +} diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index 02e0fcd..8bfce42 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -257,6 +257,12 @@ func EnabledTools(caps Caps) []Tool { // donc prompt et outils restent cohérents — pas de web_search halluciné. if caps.Agent && caps.Internet { tools = append(tools, webSearchTool(), webOpenTool(), webReadTool(), webGrepTool()) + // Capture d'écran : seulement si Playwright est réellement présent dans + // l'image. Annoncer un outil absent enverrait le modèle en boucle de + // réessai sur un échec systématique. + if screenshotAvailable() { + tools = append(tools, webScreenshotTool()) + } } // Outils MCP : serveurs tiers configurés par le propriétaire de la machine. // Comme bash, ils exécutent du code arbitraire côté hôte → réservés au mode @@ -1054,6 +1060,8 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps result = capWebOutput(toolWebRead(args)) case "web_grep": result = capWebOutput(toolWebGrep(args)) + case "web_screenshot": + result = toolWebScreenshot(args) default: if isMCPTool(tc.Function.Name) { result = mcpCall(tc.Function.Name, args) diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 1cf3278..07c8338 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -163,6 +163,9 @@ h1{margin:0;font-size:18px;color:var(--accent)} du style de .label (petites capitales) mais sans forcer la casse du prénom. */ .msg .label .av{font-style:normal;font-size:13px;margin-right:5px;vertical-align:-1px} .msg .label .who{text-transform:none;letter-spacing:.04em;font-size:11px} +/* Captures d'écran et images de l'agent : bornées à la largeur de la bulle, et + cliquables pour ouvrir en grand dans un onglet. */ +.msg .body img{max-width:100%;height:auto;border-radius:8px;border:1px solid var(--border);display:block;margin:8px 0} /* pastille d'état service, en haut à droite de la barre latérale */ .statuspill{display:inline-flex;align-items:center;gap:6px;font-size:11px;padding:3px 10px;border-radius:999px;border:1px solid var(--border);background:var(--panel);color:var(--dim);font-weight:600;white-space:nowrap;cursor:pointer} .statuspill.ok{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 45%,transparent);background:color-mix(in srgb,var(--ok) 12%,transparent)} @@ -2508,7 +2511,10 @@ async function loadCfg(){ // Moteur : précompilé / compilé / personnalisé (avec le chemin). Le title garde // toujours le chemin complet, quel que soit le libellé. let v; - if(lc && lc.prebuilt && lc.prebuilt.in_use) v='llama.cpp précompilé'; + // En conteneur, le moteur vient de l'image : le dire, plutôt que de le + // ranger dans « personnalisé » qui laisse croire à un bricolage. + if(lc && lc.provided && sameBinPath(c.BIN, lc.provided_bin)) v='llama.cpp de l\'image'; + else if(lc && lc.prebuilt && lc.prebuilt.in_use) v='llama.cpp précompilé'; else if(lc && lc.in_use) v='llama.cpp compilé'; else v='llama.cpp personnalisé : '+c.BIN; rows.push(row('MOTEUR', v, c.BIN)); @@ -4161,7 +4167,34 @@ function setStats(el, text){ } function bodyOf(el){ return el.querySelector('.body'); } // Render markdown into a message body in place; safe because md() escapes HTML. -function renderBody(el, text){ const b=bodyOf(el); b.innerHTML = md(encodeMdLinkSpaces(text)); markNotices(b); addCopyButtons(b); markFileLinks(b); scrollMaybe(); } +function renderBody(el, text){ const b=bodyOf(el); b.innerHTML = md(encodeMdLinkSpaces(text)); markNotices(b); addCopyButtons(b); markFileLinks(b); hydrateImages(b); scrollMaybe(); } +// Images servies par Loki (/api/chat/image) : une balise ne peut pas +// porter d'en-tête Authorization, or /api/* exige la clé de pilotage dès qu'elle +// est définie. On récupère donc l'image par fetch authentifié et on la pose en +// blob:. Sans ça, toute instance protégée par une clé n'affichait que des images +// cassées. Les URLs externes (http…) ne sont pas touchées. +function hydrateImages(root){ + root.querySelectorAll('img[src*="/api/chat/image"]').forEach(async img => { + if(img.dataset.hydrated) return; + img.dataset.hydrated = '1'; + const src = img.getAttribute('src'); + try{ + const r = await jfetch(src.startsWith('/') ? src : '/' + src); + if(!r.ok) throw new Error(r.status); + const url = URL.createObjectURL(await r.blob()); + img.src = url; + img.classList.add('chatimg'); + // La révocation attend le chargement : révoquer tout de suite laisserait + // une image vide sur les navigateurs qui décodent en différé. + img.addEventListener('load', () => URL.revokeObjectURL(url), {once:true}); + }catch(e){ + const note = document.createElement('span'); + note.className = 'muted'; + note.textContent = '[image indisponible : ' + src.replace(/^.*path=/, '') + ']'; + img.replaceWith(note); + } + }); +} // Render a tool call as its own conversation message: the command the model // wrote, then the response it got back. textContent keeps it injection-safe. function renderToolMsg(el, tu){ diff --git a/internal/loki/ui/src/js/05-status.js b/internal/loki/ui/src/js/05-status.js index 9a44b85..01b986f 100644 --- a/internal/loki/ui/src/js/05-status.js +++ b/internal/loki/ui/src/js/05-status.js @@ -162,7 +162,10 @@ async function loadCfg(){ // Moteur : précompilé / compilé / personnalisé (avec le chemin). Le title garde // toujours le chemin complet, quel que soit le libellé. let v; - if(lc && lc.prebuilt && lc.prebuilt.in_use) v='llama.cpp précompilé'; + // En conteneur, le moteur vient de l'image : le dire, plutôt que de le + // ranger dans « personnalisé » qui laisse croire à un bricolage. + if(lc && lc.provided && sameBinPath(c.BIN, lc.provided_bin)) v='llama.cpp de l\'image'; + else if(lc && lc.prebuilt && lc.prebuilt.in_use) v='llama.cpp précompilé'; else if(lc && lc.in_use) v='llama.cpp compilé'; else v='llama.cpp personnalisé : '+c.BIN; rows.push(row('MOTEUR', v, c.BIN)); diff --git a/internal/loki/ui/src/js/08-chat-render.js b/internal/loki/ui/src/js/08-chat-render.js index af62df9..6966518 100644 --- a/internal/loki/ui/src/js/08-chat-render.js +++ b/internal/loki/ui/src/js/08-chat-render.js @@ -140,7 +140,34 @@ function setStats(el, text){ } function bodyOf(el){ return el.querySelector('.body'); } // Render markdown into a message body in place; safe because md() escapes HTML. -function renderBody(el, text){ const b=bodyOf(el); b.innerHTML = md(encodeMdLinkSpaces(text)); markNotices(b); addCopyButtons(b); markFileLinks(b); scrollMaybe(); } +function renderBody(el, text){ const b=bodyOf(el); b.innerHTML = md(encodeMdLinkSpaces(text)); markNotices(b); addCopyButtons(b); markFileLinks(b); hydrateImages(b); scrollMaybe(); } +// Images servies par Loki (/api/chat/image) : une balise ne peut pas +// porter d'en-tête Authorization, or /api/* exige la clé de pilotage dès qu'elle +// est définie. On récupère donc l'image par fetch authentifié et on la pose en +// blob:. Sans ça, toute instance protégée par une clé n'affichait que des images +// cassées. Les URLs externes (http…) ne sont pas touchées. +function hydrateImages(root){ + root.querySelectorAll('img[src*="/api/chat/image"]').forEach(async img => { + if(img.dataset.hydrated) return; + img.dataset.hydrated = '1'; + const src = img.getAttribute('src'); + try{ + const r = await jfetch(src.startsWith('/') ? src : '/' + src); + if(!r.ok) throw new Error(r.status); + const url = URL.createObjectURL(await r.blob()); + img.src = url; + img.classList.add('chatimg'); + // La révocation attend le chargement : révoquer tout de suite laisserait + // une image vide sur les navigateurs qui décodent en différé. + img.addEventListener('load', () => URL.revokeObjectURL(url), {once:true}); + }catch(e){ + const note = document.createElement('span'); + note.className = 'muted'; + note.textContent = '[image indisponible : ' + src.replace(/^.*path=/, '') + ']'; + img.replaceWith(note); + } + }); +} // Render a tool call as its own conversation message: the command the model // wrote, then the response it got back. textContent keeps it injection-safe. function renderToolMsg(el, tu){ diff --git a/internal/loki/ui/src/styles.css b/internal/loki/ui/src/styles.css index 7f6337a..f63b244 100644 --- a/internal/loki/ui/src/styles.css +++ b/internal/loki/ui/src/styles.css @@ -137,6 +137,9 @@ h1{margin:0;font-size:18px;color:var(--accent)} du style de .label (petites capitales) mais sans forcer la casse du prénom. */ .msg .label .av{font-style:normal;font-size:13px;margin-right:5px;vertical-align:-1px} .msg .label .who{text-transform:none;letter-spacing:.04em;font-size:11px} +/* Captures d'écran et images de l'agent : bornées à la largeur de la bulle, et + cliquables pour ouvrir en grand dans un onglet. */ +.msg .body img{max-width:100%;height:auto;border-radius:8px;border:1px solid var(--border);display:block;margin:8px 0} /* pastille d'état service, en haut à droite de la barre latérale */ .statuspill{display:inline-flex;align-items:center;gap:6px;font-size:11px;padding:3px 10px;border-radius:999px;border:1px solid var(--border);background:var(--panel);color:var(--dim);font-weight:600;white-space:nowrap;cursor:pointer} .statuspill.ok{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 45%,transparent);background:color-mix(in srgb,var(--ok) 12%,transparent)} diff --git a/internal/loki/web_server.go b/internal/loki/web_server.go index 8b35c37..34bdc68 100644 --- a/internal/loki/web_server.go +++ b/internal/loki/web_server.go @@ -198,6 +198,7 @@ func newWebMux() *http.ServeMux { api("/api/chat/send", handleChatSend) // envoie un message (lance la génération détachée) api("/api/chat/upload", handleChatUpload) // dépose un fichier dans le workspace agent (joint au message suivant) api("/api/chat/file", handleChatFile) // télécharge un fichier produit par l'agent (dossier de travail only) + api("/api/chat/image", handleChatImage) // affiche une IMAGE du dossier de travail (captures d'écran) api("/api/chat/stop", handleChatStop) // interrompt la génération en cours api("/api/chat/reset", handleChatReset) // vide la discussion courante (pour tous les appareils) // Discussions : liste, création, bascule, renommage, suppression. diff --git a/internal/loki/web_upload.go b/internal/loki/web_upload.go index a5e6635..77c0cd9 100644 --- a/internal/loki/web_upload.go +++ b/internal/loki/web_upload.go @@ -336,6 +336,55 @@ func handleChatFile(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, abs) } +// handleChatImage sert une image du dossier de travail POUR AFFICHAGE dans le +// fil (captures d'écran, images produites par l'agent). Route distincte de +// handleChatFile, qui force le téléchargement de TOUT : la règle « rien ne +// s'exécute dans l'origine de l'UI » est ici tenue autrement, en ne servant +// jamais que des images — +// - le type est déduit du CONTENU (les 512 premiers octets), pas de +// l'extension : un .png qui contient du HTML est refusé ; +// - nosniff empêche le navigateur de revenir sur ce type ; +// - une CSP « default-src 'none' » neutralise le document au cas où. +func handleChatImage(w http.ResponseWriter, r *http.Request) { + rel := r.URL.Query().Get("path") + if strings.TrimSpace(rel) == "" { + sendJSON(w, 400, map[string]any{"ok": false, "error": "chemin manquant"}) + return + } + abs := filepath.Join(agentWorkspace(), filepath.FromSlash(rel)) + if _, ok := workspaceRel(abs); !ok { + sendJSON(w, 403, map[string]any{"ok": false, "error": "hors du dossier de travail"}) + return + } + st, err := os.Stat(abs) + if err != nil || st.IsDir() { + sendJSON(w, 404, map[string]any{"ok": false, "error": "fichier introuvable"}) + return + } + f, err := os.Open(abs) + if err != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + defer f.Close() + head := make([]byte, 512) + n, _ := io.ReadFull(f, head) + mime := http.DetectContentType(head[:n]) + if !strings.HasPrefix(mime, "image/") { + sendJSON(w, 415, map[string]any{"ok": false, "error": "ce fichier n'est pas une image"}) + return + } + if _, err := f.Seek(0, io.SeekStart); err != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + w.Header().Set("Content-Type", mime) + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox") + w.Header().Set("Content-Disposition", "inline; filename*=UTF-8''"+url.PathEscape(filepath.Base(abs))) + http.ServeContent(w, r, filepath.Base(abs), st.ModTime(), f) +} + type uploadReq struct { Name string `json:"name"` Data string `json:"data"` // base64 d'UN morceau (accepte un data: URL complet)