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 | 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 @@ | |||
| 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 | t.Errorf("next link drops comments=1:\n%s", out) | 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 | 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. | ||
| 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 | _, _ = io.WriteString(w, s) | 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 | println(msg) | 33 | println(msg) |
| 32 | os.Exit(code) | 34 | os.Exit(code) |
| 33 | } | 35 | } |
| 36 | |||
| 34 | func try(e error) { | 37 | func 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 |
| 161 | func (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 | 165 | var 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) }, | 170 | func 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. | ||
| 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 | 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 | ||
app/wrapper.go +20 −13
| @@ -18,8 +18,25 @@ import ( | |||
| 18 | var ( | 18 | var ( |
| 19 | fetchDeviation = devianter.GetDeviation | 19 | fetchDeviation = devianter.GetDeviation |
| 20 | fetchComments = devianter.GetComments | 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 | // commentsOrLink renders a comment thread only when the request asked for it | 40 | // commentsOrLink renders a comment thread only when the request asked for it |
| 24 | // with ?comments=1, and otherwise a link that does. A thread is a second | 41 | // with ?comments=1, and otherwise a link that does. A thread is a second |
| 25 | // upstream call on every post and profile view, and most viewers never open | 42 | // upstream call on every post and profile view, and most viewers never open |
| @@ -60,7 +77,7 @@ func (s skunkyart) GRUser() { | |||
| 60 | var daError devianter.Error | 77 | var daError devianter.Error |
| 61 | g.Name = s.Query | 78 | g.Name = s.Query |
| 62 | var err error | 79 | var err error |
| 63 | s.Templates.GroupUser.GR, daError, err = g.Get() | 80 | s.Templates.GroupUser.GR, daError, err = fetchProfile(s.Query) |
| 64 | try(err) | 81 | try(err) |
| 65 | if daError.RAW != nil { | 82 | if daError.RAW != nil { |
| 66 | s.Error(daError) | 83 | s.Error(daError) |
| @@ -81,7 +98,7 @@ func (s skunkyart) GRUser() { | |||
| 81 | group.Group = true | 98 | group.Group = true |
| 82 | group.CreationDate = x.ModuleData.GroupAbout.FoundatedAt.UTC().String() | 99 | group.CreationDate = x.ModuleData.GroupAbout.FoundatedAt.UTC().String() |
| 83 | group.About.DescriptionFormatted = template.HTML(ParseDescription(s.Host, about.Description)) //nolint:gosec // G203: ParseDescription escapes its input | 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 | group.About.A = x.ModuleData.About | 102 | group.About.A = x.ModuleData.About |
| 86 | var about = &group.About.A | 103 | var about = &group.About.A |
| 87 | group.CreationDate = time.Unix(time.Now().Unix()-x.ModuleData.About.RegDate, 0).UTC().String() | 104 | group.CreationDate = time.Unix(time.Now().Unix()-x.ModuleData.About.RegDate, 0).UTC().String() |
| @@ -318,20 +335,10 @@ func (s skunkyart) Search() { | |||
| 318 | case 'r': // scraper, since DeviantArt withholds the guest API for group search | 335 | case 'r': // scraper, since DeviantArt withholds the guest API for group search |
| 319 | var ( | 336 | var ( |
| 320 | usernames = make(map[int]string) | 337 | usernames = make(map[int]string) |
| 321 | url strings.Builder | ||
| 322 | num int | 338 | num int |
| 323 | ) | 339 | ) |
| 324 | 340 | ||
| 325 | s.Page++ | 341 | dwnld := Download(groupSearchURL(s.Query, 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()) | ||
| 335 | 342 | ||
| 336 | for z := html.NewTokenizer(strings.NewReader(string(dwnld.Body))); ; { | 343 | for z := html.NewTokenizer(strings.NewReader(string(dwnld.Body))); ; { |
| 337 | if n, token := z.Next(), z.Token(); n == html.StartTagToken && token.Data == "a" { | 344 | if n, token := z.Next(), z.Token(); n == html.StartTagToken && token.Data == "a" { |
app/wrapper_test.go +48
| @@ -1,9 +1,14 @@ | |||
| 1 | package app | 1 | package app |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "encoding/json" | ||
| 4 | "errors" | 5 | "errors" |
| 5 | "net/http/httptest" | 6 | "net/http/httptest" |
| 7 | "net/url" | ||
| 8 | "strings" | ||
| 6 | "testing" | 9 | "testing" |
| 10 | |||
| 11 | "github.com/krazywarez/devianter" | ||
| 7 | ) | 12 | ) |
| 8 | 13 | ||
| 9 | // withAvatarCache turns the media cache on over a temporary directory and | 14 | // withAvatarCache turns the media cache on over a temporary directory and |
| @@ -67,3 +72,46 @@ func TestEmojitarFetchesEveryTimeWithCacheOff(t *testing.T) { | |||
| 67 | t.Errorf("avatar fetched %d times, want 2 with the cache off", *calls) | 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 | // 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. |