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>
92 lines
2.5 KiB
Go
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"
|
|
}
|