Small fixes: user About, group search paging, -c bounds, parse templates once !13

merged merged by cmc on 2026-09-11 15:13 UTC · krz/skunky-art:fix/small-bugs into main

9 files changed, +186 −33

Layout: unified · split

.mailmap deleted −4
@@ -1,4 +0,0 @@
1lost+skunk <skunky@ebloid.ru> <skunky@macaw.me>
2lost+skunk <skunky@ebloid.ru> <me@lost-skunk.cc>
3lost+skunk <skunky@ebloid.ru> <skunky@noreply.git.macaw.me>
4lost+skunk <skunky@ebloid.ru> skunky <skunky@ebloid.ru>
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/comments_test.go +14
@@ -77,3 +77,17 @@ func TestNavBaseKeepsTheCommentsParameter(t *testing.T) {
7777 t.Errorf("next link drops comments=1:\n%s", out)
7878 }
7979}
80
81func TestGroupSearchURLPagesByTen(t *testing.T) {
82 cases := map[int]string{
83 0: "https://www.deviantart.com/groups/?q=cats",
84 1: "https://www.deviantart.com/groups/?q=cats",
85 2: "https://www.deviantart.com/groups/?q=cats&offset=10",
86 3: "https://www.deviantart.com/groups/?q=cats&offset=20",
87 }
88 for page, want := range cases {
89 if got := groupSearchURL("cats", page); got != want {
90 t.Errorf("page %d: %s, want %s", page, got, want)
91 }
92 }
93}
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
app/wrapper.go +20 −13
@@ -18,8 +18,25 @@ import (
1818var (
1919 fetchDeviation = devianter.GetDeviation
2020 fetchComments = devianter.GetComments
21 fetchProfile = func(name string) (devianter.GRuser, devianter.Error, error) {
22 g := devianter.Group{Name: name}
23 return g.Get()
24 }
2125)
2226
27// groupSearchURL is the DeviantArt group search page for query, paged ten
28// results at a time. page counts from 1; 0 means the first page too.
29func groupSearchURL(query string, page int) string {
30 var url strings.Builder
31 url.WriteString("https://www.deviantart.com/groups/?q=")
32 url.WriteString(query)
33 if page > 1 {
34 url.WriteString("&offset=")
35 url.WriteString(strconv.Itoa(10 * (page - 1)))
36 }
37 return url.String()
38}
39
2340// commentsOrLink renders a comment thread only when the request asked for it
2441// with ?comments=1, and otherwise a link that does. A thread is a second
2542// upstream call on every post and profile view, and most viewers never open
@@ -60,7 +77,7 @@ func (s skunkyart) GRUser() {
6077 var daError devianter.Error
6178 g.Name = s.Query
6279 var err error
63 s.Templates.GroupUser.GR, daError, err = g.Get()
80 s.Templates.GroupUser.GR, daError, err = fetchProfile(s.Query)
6481 try(err)
6582 if daError.RAW != nil {
6683 s.Error(daError)
@@ -81,7 +98,7 @@ func (s skunkyart) GRUser() {
8198 group.Group = true
8299 group.CreationDate = x.ModuleData.GroupAbout.FoundatedAt.UTC().String()
83100 group.About.DescriptionFormatted = template.HTML(ParseDescription(s.Host, about.Description)) //nolint:gosec // G203: ParseDescription escapes its input
84 } else if false {
101 } else {
85102 group.About.A = x.ModuleData.About
86103 var about = &group.About.A
87104 group.CreationDate = time.Unix(time.Now().Unix()-x.ModuleData.About.RegDate, 0).UTC().String()
@@ -318,20 +335,10 @@ func (s skunkyart) Search() {
318335 case 'r': // scraper, since DeviantArt withholds the guest API for group search
319336 var (
320337 usernames = make(map[int]string)
321 url strings.Builder
322338 num int
323339 )
324340
325 s.Page++
326
327 url.WriteString("https://www.deviantart.com/groups/?q=")
328 url.WriteString(s.Query)
329 if s.Page > 1 {
330 url.WriteString("&offset=")
331 url.WriteString(strconv.Itoa(10 * s.Page))
332 }
333
334 dwnld := Download(url.String())
341 dwnld := Download(groupSearchURL(s.Query, s.Page))
335342
336343 for z := html.NewTokenizer(strings.NewReader(string(dwnld.Body))); ; {
337344 if n, token := z.Next(), z.Token(); n == html.StartTagToken && token.Data == "a" {
app/wrapper_test.go +48
@@ -1,9 +1,14 @@
11package app
22
33import (
4 "encoding/json"
45 "errors"
56 "net/http/httptest"
7 "net/url"
8 "strings"
69 "testing"
10
11 "github.com/krazywarez/devianter"
712)
813
914// withAvatarCache turns the media cache on over a temporary directory and
@@ -67,3 +72,46 @@ func TestEmojitarFetchesEveryTimeWithCacheOff(t *testing.T) {
6772 t.Errorf("avatar fetched %d times, want 2 with the cache off", *calls)
6873 }
6974}
75
76// TestUserAboutRendersProfileDetails is the regression test for the branch
77// upstream had disabled with `else if false`: a person's about tab must show
78// their interests, social links and how long they have been registered. The
79// profile is decoded from JSON shaped like DeviantArt's, since the module
80// slice's element type embeds an unexported struct and cannot be built by
81// name from here.
82func TestUserAboutRendersProfileDetails(t *testing.T) {
83 const profile = `{
84 "owner": {"isGroup": false, "username": "alice"},
85 "gruser": {"gruserId": 42, "page": {"modules": [
86 {"name": "about", "moduleData": {"about": {
87 "deviantFor": 86400,
88 "interests": [{"label": "Favourite animal", "value": "skunk"}],
89 "socialLinks": [{"value": "https://social.example/alice"}]
90 }}}
91 ]}}
92 }`
93 orig := fetchProfile
94 fetchProfile = func(string) (devianter.GRuser, devianter.Error, error) {
95 var p devianter.GRuser
96 if err := json.Unmarshal([]byte(profile), &p); err != nil {
97 t.Fatal(err)
98 }
99 return p, devianter.Error{}, nil
100 }
101 t.Cleanup(func() { fetchProfile = orig })
102
103 loadTemplates()
104 rec := httptest.NewRecorder()
105 s := skunkyart{Writer: rec, Host: "http://localhost", BasePath: "/", Type: 'a', Query: "alice", Args: url.Values{}, _pth: "/group_user"}
106 s.GRUser()
107
108 body := rec.Body.String()
109 for _, want := range []string{"Favourite animal: <b>skunk</b>", `href="https://social.example/alice"`, "Registration date"} {
110 if !strings.Contains(body, want) {
111 t.Errorf("about page lacks %q:\n%s", want, body)
112 }
113 }
114 if strings.Contains(body, "0001-01-01") {
115 t.Error("registration date is the zero time")
116 }
117}
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.