Files
llmgw/internal/web/download.go
T
Ilker ManapandClaude Opus 5.5 11231d8c2b The portal in English and Turkish: a catalogue per language, a switcher at the foot of every page, landing.language, Turkish numbers and dates
Every template and handler message in internal/web goes through a JSON
catalogue under internal/web/locales/<code>/, with English as the fallback.
The language is the person's choice (a cookie set by /_lang/{code}), else
Accept-Language, else landing.language. Tests refuse a key some language
lacks, a translation with other arguments than English, and template text
left in English.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:12:53 +02:00

92 lines
2.5 KiB
Go

package web
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// CodeArchive turns one reply into a zip of the files it contains.
//
// Server-side because the interesting part is path handling, and a filename
// chosen by a language model is exactly the kind of input that wants
// archive/zip and a tested sanitiser rather than string concatenation in a
// browser. Nothing is stored: the reply arrives, the zip goes back, and
// neither is kept.
func CodeArchive(maxBody int64) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
noStore(w)
var in struct {
Content string `json:"content"`
Name string `json:"name"`
}
body, err := io.ReadAll(io.LimitReader(r.Body, maxBody))
if err != nil || json.Unmarshal(body, &in) != nil {
http.Error(w, langOf(r).T("download.err.read"), http.StatusBadRequest)
return
}
files := ExtractCode(in.Content)
if len(files) == 0 {
// Shown on the page's download button, so in the page's language:
// the browser's request carries the same cookie and header.
http.Error(w, langOf(r).T("download.err.no_code"), http.StatusBadRequest)
return
}
// Headers before the first write: once a byte of zip is out, an error
// can only be delivered as a corrupt download.
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition",
fmt.Sprintf("attachment; filename=%q", archiveName(in.Name)))
// The archive is assembled from model output; nothing should sniff it
// as something else.
w.Header().Set("X-Content-Type-Options", "nosniff")
zw := zip.NewWriter(w)
for _, f := range files {
hdr := &zip.FileHeader{Name: f.Path, Method: zip.Deflate, Modified: time.Now()}
hdr.SetMode(0o644)
out, err := zw.CreateHeader(hdr)
if err != nil {
// Half a zip is already on the wire; closing is the only
// honest end, and the browser will report a broken download.
_ = zw.Close()
return
}
if _, err := io.WriteString(out, f.Body); err != nil {
_ = zw.Close()
return
}
}
_ = zw.Close()
}
}
// archiveName keeps the download recognisable without trusting what it is given.
func archiveName(name string) string {
name = strings.TrimSpace(name)
keep := strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
return r
case r == '-', r == '_':
return r
default:
return -1
}
}, name)
if keep == "" {
keep = "code"
}
if len(keep) > 40 {
keep = keep[:40]
}
return keep + ".zip"
}