Small fixes: user About, group search paging, -c bounds, parse templates once !13
9 files changed, +186 −33
Layout: unified · split
.mailmap deleted −4
| @@ -1,4 +0,0 @@ | ||
| 1 | lost+skunk <skunky@ebloid.ru> <skunky@macaw.me> | |
| 2 | lost+skunk <skunky@ebloid.ru> <me@lost-skunk.cc> | |
| 3 | lost+skunk <skunky@ebloid.ru> <skunky@noreply.git.macaw.me> | |
| 4 | lost+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 | ||
| 26 | 26 | for n, x := range a { |
| 27 | 27 | switch x { |
| 28 | 28 | case "-c", "--config": |
| 29 | if len(a) >= 2 { | |
| 29 | if n+1 < len(a) { | |
| 30 | 30 | CFG.cfg = a[n+1] |
| 31 | 31 | } else { |
| 32 | 32 | exit("Not enought arguments", 1) |
app/cli_test.go added +48
| @@ -0,0 +1,48 @@ | ||
| 1 | package app | |
| 2 | ||
| 3 | import ( | |
| 4 | "os" | |
| 5 | "testing" | |
| 6 | ) | |
| 7 | ||
| 8 | // captureExit swaps the fatal exit for one that records its message. | |
| 9 | func 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. | |
| 21 | func 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 | ||
| 37 | func 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) { | ||
| 77 | 77 | t.Errorf("next link drops comments=1:\n%s", out) |
| 78 | 78 | } |
| 79 | 79 | } |
| 80 | ||
| 81 | func 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() { | ||
| 20 | 20 | static.StaticPath = "../static" |
| 21 | 21 | static.CopyTemplatesToMemory() |
| 22 | 22 | LoadLanguages() |
| 23 | ParseTemplates() | |
| 23 | 24 | }) |
| 24 | 25 | } |
| 25 | 26 | |
| @@ -149,3 +150,15 @@ func TestErrorPageShowsOneEscapedLine(t *testing.T) { | ||
| 149 | 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. | |
| 156 | func 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 | 27 | _, _ = io.WriteString(w, s) |
| 28 | 28 | } |
| 29 | 29 | |
| 30 | func exit(msg string, code int) { | |
| 30 | // exit is a variable so a test can observe a fatal path without ending the | |
| 31 | // test binary. | |
| 32 | var exit = func(msg string, code int) { | |
| 31 | 33 | println(msg) |
| 32 | 34 | os.Exit(code) |
| 33 | 35 | } |
| 36 | ||
| 34 | 37 | func try(e error) { |
| 35 | 38 | if e != nil { |
| 36 | 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 | |
| 160 | // if the template cannot be parsed. | |
| 161 | func (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. | |
| 165 | var 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. | |
| 170 | func 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. | |
| 190 | func (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 | 196 | s.Writer.WriteHeader(500) |
| 172 | wr(s.Writer, err.Error()) | |
| 197 | wr(s.Writer, "templates not parsed") | |
| 173 | 198 | return |
| 174 | 199 | } |
| 175 | try(tmp.Execute(&buf, &data)) | |
| 200 | var buf strings.Builder | |
| 201 | try(tmp.ExecuteTemplate(&buf, file, &data)) | |
| 176 | 202 | wr(s.Writer, buf.String()) |
| 177 | 203 | } |
| 178 | 204 | |
app/wrapper.go +20 −13
| @@ -18,8 +18,25 @@ import ( | ||
| 18 | 18 | var ( |
| 19 | 19 | fetchDeviation = devianter.GetDeviation |
| 20 | 20 | fetchComments = devianter.GetComments |
| 21 | fetchProfile = func(name string) (devianter.GRuser, devianter.Error, error) { | |
| 22 | g := devianter.Group{Name: name} | |
| 23 | return g.Get() | |
| 24 | } | |
| 21 | 25 | ) |
| 22 | 26 | |
| 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. | |
| 29 | func 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 | ||
| 23 | 40 | // commentsOrLink renders a comment thread only when the request asked for it |
| 24 | 41 | // with ?comments=1, and otherwise a link that does. A thread is a second |
| 25 | 42 | // upstream call on every post and profile view, and most viewers never open |
| @@ -60,7 +77,7 @@ func (s skunkyart) GRUser() { | ||
| 60 | 77 | var daError devianter.Error |
| 61 | 78 | g.Name = s.Query |
| 62 | 79 | var err error |
| 63 | s.Templates.GroupUser.GR, daError, err = g.Get() | |
| 80 | s.Templates.GroupUser.GR, daError, err = fetchProfile(s.Query) | |
| 64 | 81 | try(err) |
| 65 | 82 | if daError.RAW != nil { |
| 66 | 83 | s.Error(daError) |
| @@ -81,7 +98,7 @@ func (s skunkyart) GRUser() { | ||
| 81 | 98 | group.Group = true |
| 82 | 99 | group.CreationDate = x.ModuleData.GroupAbout.FoundatedAt.UTC().String() |
| 83 | 100 | group.About.DescriptionFormatted = template.HTML(ParseDescription(s.Host, about.Description)) //nolint:gosec // G203: ParseDescription escapes its input |
| 84 | } else if false { | |
| 101 | } else { | |
| 85 | 102 | group.About.A = x.ModuleData.About |
| 86 | 103 | var about = &group.About.A |
| 87 | 104 | group.CreationDate = time.Unix(time.Now().Unix()-x.ModuleData.About.RegDate, 0).UTC().String() |
| @@ -318,20 +335,10 @@ func (s skunkyart) Search() { | ||
| 318 | 335 | case 'r': // scraper, since DeviantArt withholds the guest API for group search |
| 319 | 336 | var ( |
| 320 | 337 | usernames = make(map[int]string) |
| 321 | url strings.Builder | |
| 322 | 338 | num int |
| 323 | 339 | ) |
| 324 | 340 | |
| 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)) | |
| 335 | 342 | |
| 336 | 343 | for z := html.NewTokenizer(strings.NewReader(string(dwnld.Body))); ; { |
| 337 | 344 | if n, token := z.Next(), z.Token(); n == html.StartTagToken && token.Data == "a" { |
app/wrapper_test.go +48
| @@ -1,9 +1,14 @@ | ||
| 1 | 1 | package app |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "encoding/json" | |
| 4 | 5 | "errors" |
| 5 | 6 | "net/http/httptest" |
| 7 | "net/url" | |
| 8 | "strings" | |
| 6 | 9 | "testing" |
| 10 | ||
| 11 | "github.com/krazywarez/devianter" | |
| 7 | 12 | ) |
| 8 | 13 | |
| 9 | 14 | // withAvatarCache turns the media cache on over a temporary directory and |
| @@ -67,3 +72,46 @@ func TestEmojitarFetchesEveryTimeWithCacheOff(t *testing.T) { | ||
| 67 | 72 | t.Errorf("avatar fetched %d times, want 2 with the cache off", *calls) |
| 68 | 73 | } |
| 69 | 74 | } |
| 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. | |
| 82 | func 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() { | ||
| 28 | 28 | // After the copy, not before: the catalogues are assets, and ExecuteConfig |
| 29 | 29 | // runs while static/ is still unread. |
| 30 | 30 | app.LoadLanguages() |
| 31 | app.ParseTemplates() | |
| 31 | 32 | |
| 32 | 33 | // Rate/concurrency-limit + time-out outbound DeviantArt requests so bot floods |
| 33 | 34 | // can't exhaust the process or get our egress IP banned by CloudFront/WAF. |