Commit b74215186e

b74215186e959262dd99bfbf972fc0f0a535c1eb

parent: 1ea3f7a94a

Verified · cmc ci/build: success ci/lint: success ci/test: success

cmc <hello@cleberg.net> · 2026-09-11 02:59 UTC

Parse templates once per language, and fix the -c bounds check

ParseTemplates parses static/html at startup, one set per loaded
language since T is bound at parse time; ExecuteTemplate looks the set
up by the request's language. A template that does not parse now fails
at boot instead of as a 500 on every request.

-c checked len(args) >= 2 rather than that a value follows, so
`skunkyart -x -c` panicked. exit is a variable so the test can see it.

Closes #18
Closes #20

Layout: unified · split

app/cli.go +1 −1
@@ -26,7 +26,7 @@ Copyright lost+skunk and zerolabs, X11. https://github.com/krazywarez/skunky-art
26 for n, x := range a { 26 for n, x := range a {
27 switch x { 27 switch x {
28 case "-c", "--config": 28 case "-c", "--config":
29 if len(a) >= 2 { 29 if n+1 < len(a) {
30 CFG.cfg = a[n+1] 30 CFG.cfg = a[n+1]
31 } else { 31 } else {
32 exit("Not enought arguments", 1) 32 exit("Not enought arguments", 1)
app/cli_test.go added +48
@@ -0,0 +1,48 @@
1package app
2
3import (
4 "os"
5 "testing"
6)
7
8// captureExit swaps the fatal exit for one that records its message.
9func captureExit(t *testing.T) *[]string {
10 t.Helper()
11 orig := exit
12 var msgs []string
13 exit = func(msg string, _ int) { msgs = append(msgs, msg) }
14 t.Cleanup(func() { exit = orig })
15 return &msgs
16}
17
18// TestConfigFlagNeedsAValue is the regression test for the bounds check: -c as
19// the last argument, with other arguments before it, used to index past the
20// end and panic instead of reporting the missing value.
21func TestConfigFlagNeedsAValue(t *testing.T) {
22 args, cfg := os.Args, CFG.cfg
23 defer func() { os.Args, CFG.cfg = args, cfg }()
24 msgs := captureExit(t)
25
26 os.Args = []string{"skunkyart", "-x", "-c"}
27 ExecuteCommandLineArguments()
28
29 if len(*msgs) != 1 {
30 t.Fatalf("exit called %d times, want 1 usage error", len(*msgs))
31 }
32 if CFG.cfg != cfg {
33 t.Errorf("config path changed to %q with no value given", CFG.cfg)
34 }
35}
36
37func TestConfigFlagTakesTheNextArgument(t *testing.T) {
38 args, cfg := os.Args, CFG.cfg
39 defer func() { os.Args, CFG.cfg = args, cfg }()
40 msgs := captureExit(t)
41
42 os.Args = []string{"skunkyart", "-c", "other.json"}
43 ExecuteCommandLineArguments()
44
45 if len(*msgs) != 0 || CFG.cfg != "other.json" {
46 t.Errorf("exit calls %v, config path %q; want none and other.json", *msgs, CFG.cfg)
47 }
48}
app/escape_test.go +13
@@ -20,6 +20,7 @@ func loadTemplates() {
20 static.StaticPath = "../static" 20 static.StaticPath = "../static"
21 static.CopyTemplatesToMemory() 21 static.CopyTemplatesToMemory()
22 LoadLanguages() 22 LoadLanguages()
23 ParseTemplates()
23 }) 24 })
24} 25}
25 26
@@ -149,3 +150,15 @@ func TestErrorPageShowsOneEscapedLine(t *testing.T) {
149 t.Errorf("upstream error not escaped:\n%s", body) 150 t.Errorf("upstream error not escaped:\n%s", body)
150 } 151 }
151} 152}
153
154// TestExecuteTemplateUsesTheRequestLanguage pins that the per-language parsed
155// sets answer with the right catalogue.
156func TestExecuteTemplateUsesTheRequestLanguage(t *testing.T) {
157 loadTemplates()
158 rec := httptest.NewRecorder()
159 s := skunkyart{Writer: rec, Host: "http://localhost", BasePath: "/", Lang: "es"}
160 s.ExecuteTemplate("about.htm", "html", &s)
161 if !strings.Contains(rec.Body.String(), "Ajustes de la instancia") {
162 t.Errorf("Spanish request rendered without the Spanish catalogue:\n%s", rec.Body.String())
163 }
164}
app/util.go +41 −15
@@ -27,10 +27,13 @@ func wr(w io.Writer, s string) {
27 _, _ = io.WriteString(w, s) 27 _, _ = io.WriteString(w, s)
28} 28}
29 29
30func exit(msg string, code int) { 30// exit is a variable so a test can observe a fatal path without ending the
31// test binary.
32var exit = func(msg string, code int) {
31 println(msg) 33 println(msg)
32 os.Exit(code) 34 os.Exit(code)
33} 35}
36
34func try(e error) { 37func try(e error) {
35 if e != nil { 38 if e != nil {
36 println(e.Error()) 39 println(e.Error())
@@ -156,23 +159,46 @@ type skunkyart struct {
156 } 159 }
157} 160}
158 161
159// ExecuteTemplate renders the named template from dir with data, responding 500 162// pageTemplates is every page template parsed once per language, by
160// if the template cannot be parsed. 163// ParseTemplates. One set per language because T is bound at parse time, so
161func (s skunkyart) ExecuteTemplate(file, dir string, data any) { 164// templates ask for a key and never have to know which catalogue answered.
162 var buf strings.Builder 165var pageTemplates = map[string]*template.Template{}
163 tmp := template.New(file) 166
164 // T is bound to this request's language, so templates ask for a key and 167// ParseTemplates parses static/html once for each loaded language. Call it at
165 // never have to know which catalogue answered. 168// startup after LoadLanguages; a template that does not parse exits the
166 tmp = tmp.Funcs(template.FuncMap{ 169// process, since it would otherwise be a 500 on every request for that page.
167 "T": func(key string) string { return T(s.Lang, key) }, 170func ParseTemplates() {
168 }) 171 langs := Languages()
169 tmp, err := tmp.ParseFS(static.Templates, dir+"/*") 172 if len(langs) == 0 {
170 if err != nil { 173 langs = []string{DefaultLang}
174 }
175 for _, lang := range langs {
176 tmp := template.New("").Funcs(template.FuncMap{
177 "T": func(key string) string { return T(lang, key) },
178 })
179 tmp, err := tmp.ParseFS(static.Templates, "html/*")
180 if err != nil {
181 exit("templates: "+err.Error(), 1)
182 return
183 }
184 pageTemplates[lang] = tmp
185 }
186}
187
188// ExecuteTemplate renders the named page template with data in the request's
189// language, responding 500 if the templates were never parsed.
190func (s skunkyart) ExecuteTemplate(file, _ string, data any) {
191 tmp := pageTemplates[s.Lang]
192 if tmp == nil {
193 tmp = pageTemplates[DefaultLang]
194 }
195 if tmp == nil {
171 s.Writer.WriteHeader(500) 196 s.Writer.WriteHeader(500)
172 wr(s.Writer, err.Error()) 197 wr(s.Writer, "templates not parsed")
173 return 198 return
174 } 199 }
175 try(tmp.Execute(&buf, &data)) 200 var buf strings.Builder
201 try(tmp.ExecuteTemplate(&buf, file, &data))
176 wr(s.Writer, buf.String()) 202 wr(s.Writer, buf.String())
177} 203}
178 204
main.go +1
@@ -28,6 +28,7 @@ func main() {
28 // After the copy, not before: the catalogues are assets, and ExecuteConfig 28 // After the copy, not before: the catalogues are assets, and ExecuteConfig
29 // runs while static/ is still unread. 29 // runs while static/ is still unread.
30 app.LoadLanguages() 30 app.LoadLanguages()
31 app.ParseTemplates()
31 32
32 // Rate/concurrency-limit + time-out outbound DeviantArt requests so bot floods 33 // Rate/concurrency-limit + time-out outbound DeviantArt requests so bot floods
33 // can't exhaust the process or get our egress IP banned by CloudFront/WAF. 34 // can't exhaust the process or get our egress IP banned by CloudFront/WAF.