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
2626 for n, x := range a {
2727 switch x {
2828 case "-c", "--config":
29 if len(a) >= 2 {
29 if n+1 < len(a) {
3030 CFG.cfg = a[n+1]
3131 } else {
3232 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() {
2020 static.StaticPath = "../static"
2121 static.CopyTemplatesToMemory()
2222 LoadLanguages()
23 ParseTemplates()
2324 })
2425}
2526
@@ -149,3 +150,15 @@ func TestErrorPageShowsOneEscapedLine(t *testing.T) {
149150 t.Errorf("upstream error not escaped:\n%s", body)
150151 }
151152}
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) {
2727 _, _ = io.WriteString(w, s)
2828}
2929
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) {
3133 println(msg)
3234 os.Exit(code)
3335}
36
3437func try(e error) {
3538 if e != nil {
3639 println(e.Error())
@@ -156,23 +159,46 @@ type skunkyart struct {
156159 }
157160}
158161
159// ExecuteTemplate renders the named template from dir with data, responding 500
160// if the template cannot be parsed.
161func (s skunkyart) ExecuteTemplate(file, dir string, data any) {
162 var buf strings.Builder
163 tmp := template.New(file)
164 // T is bound to this request's language, so templates ask for a key and
165 // never have to know which catalogue answered.
166 tmp = tmp.Funcs(template.FuncMap{
167 "T": func(key string) string { return T(s.Lang, key) },
168 })
169 tmp, err := tmp.ParseFS(static.Templates, dir+"/*")
170 if err != nil {
162// pageTemplates is every page template parsed once per language, by
163// ParseTemplates. One set per language because T is bound at parse time, so
164// templates ask for a key and never have to know which catalogue answered.
165var pageTemplates = map[string]*template.Template{}
166
167// ParseTemplates parses static/html once for each loaded language. Call it at
168// startup after LoadLanguages; a template that does not parse exits the
169// process, since it would otherwise be a 500 on every request for that page.
170func ParseTemplates() {
171 langs := Languages()
172 if len(langs) == 0 {
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 {
171196 s.Writer.WriteHeader(500)
172 wr(s.Writer, err.Error())
197 wr(s.Writer, "templates not parsed")
173198 return
174199 }
175 try(tmp.Execute(&buf, &data))
200 var buf strings.Builder
201 try(tmp.ExecuteTemplate(&buf, file, &data))
176202 wr(s.Writer, buf.String())
177203}
178204
main.go +1
@@ -28,6 +28,7 @@ func main() {
2828 // After the copy, not before: the catalogues are assets, and ExecuteConfig
2929 // runs while static/ is still unread.
3030 app.LoadLanguages()
31 app.ParseTemplates()
3132
3233 // Rate/concurrency-limit + time-out outbound DeviantArt requests so bot floods
3334 // can't exhaust the process or get our egress IP banned by CloudFront/WAF.