Commit 778622bce5
Verified · cmc ci/build: success ci/lint: success ci/test: success
Layout: unified · split
API.md +3 −1
| @@ -67,4 +67,6 @@ and a reader following a direct link to one still gets it. | |||
| 67 | 67 | ||
| 68 | ## GET /api/random | 68 | ## GET /api/random |
| 69 | 69 | ||
| 70 | A random artwork's media — the image itself, not JSON. Honours `nsfw`. | 70 | A random artwork's media — the image itself, not JSON. The pick is made |
| 71 | among the current daily deviations, which the instance already caches, so | ||
| 72 | the call costs no extra upstream request. Honours `nsfw` and `hide-ai`. | ||
app/api.go +29 −27
| @@ -3,7 +3,6 @@ package app | |||
| 3 | import ( | 3 | import ( |
| 4 | "encoding/json" | 4 | "encoding/json" |
| 5 | "math/rand" | 5 | "math/rand" |
| 6 | "strconv" | ||
| 7 | "strings" | 6 | "strings" |
| 8 | 7 | ||
| 9 | "github.com/krazywarez/devianter" | 8 | "github.com/krazywarez/devianter" |
| @@ -64,39 +63,42 @@ func (a API) sendMedia(d *devianter.Deviation) { | |||
| 64 | } | 63 | } |
| 65 | } | 64 | } |
| 66 | 65 | ||
| 67 | // Random responds with a random artwork's media, retrying a bounded number of | 66 | // fetchDailyDeviations is devianter.GetDailyDeviations behind a variable so |
| 68 | // times when a search comes back empty or NSFW-filtered. | 67 | // tests can script it. |
| 68 | var fetchDailyDeviations = devianter.GetDailyDeviations | ||
| 69 | |||
| 70 | // Random responds with a random artwork's media, picked from the current daily | ||
| 71 | // deviations. That page is one upstream call the API cache answers for its | ||
| 72 | // TTL, where the previous random searches were up to three uncacheable calls | ||
| 73 | // per hit and a cheap way for a bot to burn the instance's upstream budget. | ||
| 69 | // | 74 | // |
| 70 | // TODO: add filters. | 75 | // TODO: add filters. |
| 71 | func (a API) Random() { | 76 | func (a API) Random() { |
| 72 | // Bounded retries: the loop used to be unbounded, and the DeviantArt-error | 77 | dd, daErr := fetchDailyDeviations(0) |
| 73 | // path never incremented attempt, so a single request could spin forever | 78 | if daErr.RAW != nil { |
| 74 | // hammering the API (and get this instance's egress IP banned). | 79 | a.Error("deviantart returned an error", 502) |
| 75 | const maxAttempts = 3 | 80 | return |
| 76 | 81 | } | |
| 77 | // math/rand is deliberate: this picks a random artwork to show, which is not | ||
| 78 | // a security decision and does not need a cryptographic source. | ||
| 79 | for range maxAttempts { | ||
| 80 | // strconv.Itoa, not string(): string(65) is "A", not "65". | ||
| 81 | s, daErr, err := devianter.PerformSearch(strconv.Itoa(rand.Intn(999)), rand.Intn(30), 'a') //nolint:gosec // G404 | ||
| 82 | try(err) | ||
| 83 | if daErr.RAW != nil { | ||
| 84 | continue | ||
| 85 | } | ||
| 86 | 82 | ||
| 87 | // rand.Intn panics on 0, so an empty result set must be skipped. | 83 | var pool []*devianter.Deviation |
| 88 | if len(s.Results) == 0 { | 84 | for i := range dd.Deviations { |
| 89 | continue | 85 | if d := &dd.Deviations[i]; VisibleDeviation(d) { |
| 86 | pool = append(pool, d) | ||
| 90 | } | 87 | } |
| 91 | 88 | } | |
| 92 | deviation := &s.Results[rand.Intn(len(s.Results))] //nolint:gosec // G404: see above | 89 | for s := range dd.Strips { |
| 93 | if deviation.NSFW && !CFG.Nsfw { | 90 | for i := range dd.Strips[s].Deviations { |
| 94 | continue | 91 | if d := &dd.Strips[s].Deviations[i]; VisibleDeviation(d) { |
| 92 | pool = append(pool, d) | ||
| 93 | } | ||
| 95 | } | 94 | } |
| 96 | 95 | } | |
| 97 | a.sendMedia(deviation) | 96 | if len(pool) == 0 { |
| 97 | a.Error("no daily deviation this instance can show", 404) | ||
| 98 | return | 98 | return |
| 99 | } | 99 | } |
| 100 | 100 | ||
| 101 | a.Error("Sorry, butt NSFW on this are disabled, and the instance failed to find a random art without NSFW", 500) | 101 | // math/rand is deliberate: this picks a random artwork to show, which is not |
| 102 | // a security decision and does not need a cryptographic source. | ||
| 103 | a.sendMedia(pool[rand.Intn(len(pool))]) //nolint:gosec // G404 | ||
| 102 | } | 104 | } |
app/api_test.go +51
| @@ -57,3 +57,54 @@ func TestSendMediaIgnoresEmptyMedia(t *testing.T) { | |||
| 57 | t.Errorf("Location %q set for a media-less deviation, want none", loc) | 57 | t.Errorf("Location %q set for a media-less deviation, want none", loc) |
| 58 | } | 58 | } |
| 59 | } | 59 | } |
| 60 | |||
| 61 | // withDailyDeviations scripts the daily deviations fetch with the given | ||
| 62 | // entries and counts the calls. | ||
| 63 | func withDailyDeviations(t *testing.T, devs ...devianter.Deviation) *int { | ||
| 64 | t.Helper() | ||
| 65 | orig := fetchDailyDeviations | ||
| 66 | calls := 0 | ||
| 67 | fetchDailyDeviations = func(int) (devianter.DailyDeviations, devianter.Error) { | ||
| 68 | calls++ | ||
| 69 | return devianter.DailyDeviations{Deviations: devs}, devianter.Error{} | ||
| 70 | } | ||
| 71 | t.Cleanup(func() { fetchDailyDeviations = orig }) | ||
| 72 | return &calls | ||
| 73 | } | ||
| 74 | |||
| 75 | // TestRandomPicksFromTheDailyDeviations pins the new source: one fetch of the | ||
| 76 | // daily page, and the pick is served as media. | ||
| 77 | func TestRandomPicksFromTheDailyDeviations(t *testing.T) { | ||
| 78 | proxy, nsfw := CFG.Proxy, CFG.Nsfw | ||
| 79 | CFG.Proxy, CFG.Nsfw = false, true | ||
| 80 | defer func() { CFG.Proxy, CFG.Nsfw = proxy, nsfw }() | ||
| 81 | calls := withDailyDeviations(t, *fullviewDeviation()) | ||
| 82 | |||
| 83 | w := httptest.NewRecorder() | ||
| 84 | API{main: &skunkyart{Writer: w}}.Random() | ||
| 85 | |||
| 86 | if *calls != 1 { | ||
| 87 | t.Errorf("daily deviations fetched %d times, want 1", *calls) | ||
| 88 | } | ||
| 89 | if w.Code != 302 || w.Header().Get("Location") == "" { | ||
| 90 | t.Errorf("status %d, Location %q; want a 302 to the pick's media", w.Code, w.Header().Get("Location")) | ||
| 91 | } | ||
| 92 | } | ||
| 93 | |||
| 94 | // TestRandomHonoursNSFW pins that a pick is drawn only from what the instance | ||
| 95 | // may show: with nsfw off and only mature entries there is nothing to serve. | ||
| 96 | func TestRandomHonoursNSFW(t *testing.T) { | ||
| 97 | proxy, nsfw := CFG.Proxy, CFG.Nsfw | ||
| 98 | CFG.Proxy, CFG.Nsfw = false, false | ||
| 99 | defer func() { CFG.Proxy, CFG.Nsfw = proxy, nsfw }() | ||
| 100 | mature := *fullviewDeviation() | ||
| 101 | mature.NSFW = true | ||
| 102 | withDailyDeviations(t, mature) | ||
| 103 | |||
| 104 | w := httptest.NewRecorder() | ||
| 105 | API{main: &skunkyart{Writer: w}}.Random() | ||
| 106 | |||
| 107 | if w.Code != 404 || w.Header().Get("Location") != "" { | ||
| 108 | t.Errorf("status %d, Location %q; want 404 and no media", w.Code, w.Header().Get("Location")) | ||
| 109 | } | ||
| 110 | } | ||
app/comments_test.go added +79
| @@ -0,0 +1,79 @@ | |||
| 1 | package app | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "net/http/httptest" | ||
| 5 | "net/url" | ||
| 6 | "strings" | ||
| 7 | "testing" | ||
| 8 | |||
| 9 | "github.com/krazywarez/devianter" | ||
| 10 | ) | ||
| 11 | |||
| 12 | // withCommentSeams scripts the deviation and comment fetches and counts the | ||
| 13 | // comment fetches, which is the call the link is meant to save. | ||
| 14 | func withCommentSeams(t *testing.T, total int) *int { | ||
| 15 | t.Helper() | ||
| 16 | origDev, origCom := fetchDeviation, fetchComments | ||
| 17 | comments := 0 | ||
| 18 | fetchDeviation = func(string, string) (devianter.Post, devianter.Error) { | ||
| 19 | var p devianter.Post | ||
| 20 | p.Deviation.Title = "T" | ||
| 21 | p.Deviation.Author.Username = "alice" | ||
| 22 | p.Comments.Total = total | ||
| 23 | return p, devianter.Error{} | ||
| 24 | } | ||
| 25 | fetchComments = func(string, string, int, int) (devianter.Comments, devianter.Error) { | ||
| 26 | comments++ | ||
| 27 | return devianter.Comments{Total: total}, devianter.Error{} | ||
| 28 | } | ||
| 29 | t.Cleanup(func() { fetchDeviation, fetchComments = origDev, origCom }) | ||
| 30 | return &comments | ||
| 31 | } | ||
| 32 | |||
| 33 | func post(args url.Values) *httptest.ResponseRecorder { | ||
| 34 | loadTemplates() | ||
| 35 | rec := httptest.NewRecorder() | ||
| 36 | s := skunkyart{Writer: rec, Host: "http://localhost", BasePath: "/", Args: args, _pth: "/post/alice/t-1"} | ||
| 37 | s.Deviation("alice", "t-1") | ||
| 38 | return rec | ||
| 39 | } | ||
| 40 | |||
| 41 | func TestPostShowsACommentsLinkWithoutFetching(t *testing.T) { | ||
| 42 | nsfw := CFG.Nsfw | ||
| 43 | CFG.Nsfw = true | ||
| 44 | defer func() { CFG.Nsfw = nsfw }() | ||
| 45 | fetches := withCommentSeams(t, 7) | ||
| 46 | |||
| 47 | body := post(url.Values{}).Body.String() | ||
| 48 | |||
| 49 | if *fetches != 0 { | ||
| 50 | t.Errorf("comments fetched %d times on a plain post view, want 0", *fetches) | ||
| 51 | } | ||
| 52 | if !strings.Contains(body, `href="/post/alice/t-1?comments=1"`) || !strings.Contains(body, "Comments (7)") { | ||
| 53 | t.Errorf("post lacks the comments link with its count:\n%s", body) | ||
| 54 | } | ||
| 55 | } | ||
| 56 | |||
| 57 | func TestPostFetchesCommentsWhenAsked(t *testing.T) { | ||
| 58 | nsfw := CFG.Nsfw | ||
| 59 | CFG.Nsfw = true | ||
| 60 | defer func() { CFG.Nsfw = nsfw }() | ||
| 61 | fetches := withCommentSeams(t, 7) | ||
| 62 | |||
| 63 | body := post(url.Values{"comments": {"1"}}).Body.String() | ||
| 64 | |||
| 65 | if *fetches != 1 { | ||
| 66 | t.Errorf("comments fetched %d times with ?comments=1, want 1", *fetches) | ||
| 67 | } | ||
| 68 | if !strings.Contains(body, "<details><summary>Comments: <b>7</b>") { | ||
| 69 | t.Errorf("thread not rendered:\n%s", body) | ||
| 70 | } | ||
| 71 | } | ||
| 72 | |||
| 73 | func TestNavBaseKeepsTheCommentsParameter(t *testing.T) { | ||
| 74 | s := skunkyart{_pth: "/post/alice/t-1", Args: url.Values{"comments": {"1"}}, Page: 1} | ||
| 75 | out := s.NavBase(DeviationList{More: true}) | ||
| 76 | if !strings.Contains(out, "?p=2&comments=1") { | ||
| 77 | t.Errorf("next link drops comments=1:\n%s", out) | ||
| 78 | } | ||
| 79 | } | ||
app/escape_test.go +1
| @@ -19,6 +19,7 @@ func loadTemplates() { | |||
| 19 | loadTemplatesOnce.Do(func() { | 19 | loadTemplatesOnce.Do(func() { |
| 20 | static.StaticPath = "../static" | 20 | static.StaticPath = "../static" |
| 21 | static.CopyTemplatesToMemory() | 21 | static.CopyTemplatesToMemory() |
| 22 | LoadLanguages() | ||
| 22 | }) | 23 | }) |
| 23 | } | 24 | } |
| 24 | 25 | ||
app/util.go +3
| @@ -389,6 +389,9 @@ func (s skunkyart) NavBase(c DeviationList) string { | |||
| 389 | list.WriteString("&folder=") | 389 | list.WriteString("&folder=") |
| 390 | list.WriteString(esc(f)) | 390 | list.WriteString(esc(f)) |
| 391 | } | 391 | } |
| 392 | if s.Args.Get("comments") != "" { | ||
| 393 | list.WriteString("&comments=1") | ||
| 394 | } | ||
| 392 | list.WriteString(`">`) | 395 | list.WriteString(`">`) |
| 393 | list.WriteString(msg) | 396 | list.WriteString(msg) |
| 394 | list.WriteString("</a> ") | 397 | list.WriteString("</a> ") |
app/wrapper.go +39 −3
| @@ -3,6 +3,8 @@ package app | |||
| 3 | import ( | 3 | import ( |
| 4 | "crypto/sha1" //nolint:gosec // G505: SHA-1 is a cache-key hash here, not a security primitive | 4 | "crypto/sha1" //nolint:gosec // G505: SHA-1 is a cache-key hash here, not a security primitive |
| 5 | "html/template" | 5 | "html/template" |
| 6 | "maps" | ||
| 7 | "net/url" | ||
| 6 | "regexp" | 8 | "regexp" |
| 7 | "strconv" | 9 | "strconv" |
| 8 | "strings" | 10 | "strings" |
| @@ -12,6 +14,40 @@ import ( | |||
| 12 | "golang.org/x/net/html" | 14 | "golang.org/x/net/html" |
| 13 | ) | 15 | ) |
| 14 | 16 | ||
| 17 | // devianter calls behind variables so tests can count and script them. | ||
| 18 | var ( | ||
| 19 | fetchDeviation = devianter.GetDeviation | ||
| 20 | fetchComments = devianter.GetComments | ||
| 21 | ) | ||
| 22 | |||
| 23 | // 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 | ||
| 25 | // upstream call on every post and profile view, and most viewers never open | ||
| 26 | // it. total is shown in the link when it is known (0 or more). | ||
| 27 | func (s skunkyart) commentsOrLink(id, cursor string, kind, total int) template.HTML { | ||
| 28 | if s.Args.Get("comments") != "" { | ||
| 29 | return template.HTML(s.ParseComments(fetchComments(id, cursor, s.Page, kind))) //nolint:gosec // G203: ParseComments escapes its input | ||
| 30 | } | ||
| 31 | |||
| 32 | args := url.Values{} | ||
| 33 | maps.Copy(args, s.Args) | ||
| 34 | args.Del("p") | ||
| 35 | args.Set("comments", "1") | ||
| 36 | |||
| 37 | var link strings.Builder | ||
| 38 | link.WriteString(`<p><a href="`) | ||
| 39 | link.WriteString(esc(s._pth + "?" + args.Encode())) | ||
| 40 | link.WriteString(`">`) | ||
| 41 | link.WriteString(esc(T(s.Lang, "deviation.comments"))) | ||
| 42 | if total >= 0 { | ||
| 43 | link.WriteString(" (") | ||
| 44 | link.WriteString(strconv.Itoa(total)) | ||
| 45 | link.WriteString(")") | ||
| 46 | } | ||
| 47 | link.WriteString("</a></p>") | ||
| 48 | return template.HTML(link.String()) //nolint:gosec // G203: escaped above | ||
| 49 | } | ||
| 50 | |||
| 15 | // GRUser renders a group or user page: the about tab, the gallery, or favourites, | 51 | // GRUser renders a group or user page: the about tab, the gallery, or favourites, |
| 16 | // selected by the request's type argument. | 52 | // selected by the request's type argument. |
| 17 | func (s skunkyart) GRUser() { | 53 | func (s skunkyart) GRUser() { |
| @@ -70,7 +106,7 @@ func (s skunkyart) GRUser() { | |||
| 70 | group.About.Interests += template.HTML(interest.String()) //nolint:gosec // G203: escaped above | 106 | group.About.Interests += template.HTML(interest.String()) //nolint:gosec // G203: escaped above |
| 71 | } | 107 | } |
| 72 | } | 108 | } |
| 73 | group.About.Comments = template.HTML(s.ParseComments(devianter.GetComments(strconv.Itoa(group.GR.Gruser.ID), "", s.Page, 4))) //nolint:gosec // G203: ParseComments escapes its input | 109 | group.About.Comments = s.commentsOrLink(strconv.Itoa(group.GR.Gruser.ID), "", 4, -1) |
| 74 | 110 | ||
| 75 | case "cover_deviation": | 111 | case "cover_deviation": |
| 76 | group.About.BGMeta = x.ModuleData.CoverDeviation.Deviation | 112 | group.About.BGMeta = x.ModuleData.CoverDeviation.Deviation |
| @@ -184,7 +220,7 @@ func (s skunkyart) Deviation(author, postname string) { | |||
| 184 | post := &s.Templates.Deviation | 220 | post := &s.Templates.Deviation |
| 185 | 221 | ||
| 186 | id := idSearch[len(idSearch)-1] | 222 | id := idSearch[len(idSearch)-1] |
| 187 | post.Post, err = devianter.GetDeviation(id, author) | 223 | post.Post, err = fetchDeviation(id, author) |
| 188 | if err.RAW != nil { | 224 | if err.RAW != nil { |
| 189 | s.Error(err) | 225 | s.Error(err) |
| 190 | return | 226 | return |
| @@ -227,7 +263,7 @@ func (s skunkyart) Deviation(author, postname string) { | |||
| 227 | post.Tags += template.HTML(tag.String()) //nolint:gosec // G203: escaped above | 263 | post.Tags += template.HTML(tag.String()) //nolint:gosec // G203: escaped above |
| 228 | } | 264 | } |
| 229 | 265 | ||
| 230 | post.Comments = template.HTML(s.ParseComments(devianter.GetComments(id, post.Post.Comments.Cursor, s.Page, 1))) //nolint:gosec // G203: ParseComments escapes its input | 266 | post.Comments = s.commentsOrLink(id, post.Post.Comments.Cursor, 1, post.Post.Comments.Total) |
| 231 | post.StringTime = post.Post.Deviation.PublishedTime.UTC().String() | 267 | post.StringTime = post.Post.Deviation.PublishedTime.UTC().String() |
| 232 | post.Post.IMG = ParseMedia(s.Host, post.Post.Deviation.Media) | 268 | post.Post.IMG = ParseMedia(s.Host, post.Post.Deviation.Media) |
| 233 | 269 | ||
static/lang/en.json +1
| @@ -18,6 +18,7 @@ | |||
| 18 | "deviation.original": "Redirect to original", | 18 | "deviation.original": "Redirect to original", |
| 19 | "deviation.description": "Description", | 19 | "deviation.description": "Description", |
| 20 | "deviation.related": "Related content", | 20 | "deviation.related": "Related content", |
| 21 | "deviation.comments": "Comments", | ||
| 21 | "about.report": "Report an issue", | 22 | "about.report": "Report an issue", |
| 22 | "about.settings": "Instance settings:", | 23 | "about.settings": "Instance settings:", |
| 23 | "about.nsfw": "NSFW", | 24 | "about.nsfw": "NSFW", |
static/lang/es.json +1
| @@ -19,6 +19,7 @@ | |||
| 19 | "deviation.original": "Ir al original", | 19 | "deviation.original": "Ir al original", |
| 20 | "deviation.published": "Publicado: ", | 20 | "deviation.published": "Publicado: ", |
| 21 | "deviation.related": "Contenido relacionado", | 21 | "deviation.related": "Contenido relacionado", |
| 22 | "deviation.comments": "Comentarios", | ||
| 22 | "index.logo": "Logo de SkunkyArt", | 23 | "index.logo": "Logo de SkunkyArt", |
| 23 | "index.source": "Código fuente", | 24 | "index.source": "Código fuente", |
| 24 | "nav.about": "Acerca de", | 25 | "nav.about": "Acerca de", |