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 | 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 | 3 | import ( |
| 4 | 4 | "encoding/json" |
| 5 | 5 | "math/rand" |
| 6 | "strconv" | |
| 7 | 6 | "strings" |
| 8 | 7 | |
| 9 | 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 | |
| 68 | // times when a search comes back empty or NSFW-filtered. | |
| 66 | // fetchDailyDeviations is devianter.GetDailyDeviations behind a variable so | |
| 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 | 75 | // TODO: add filters. |
| 71 | 76 | func (a API) Random() { |
| 72 | // Bounded retries: the loop used to be unbounded, and the DeviantArt-error | |
| 73 | // path never incremented attempt, so a single request could spin forever | |
| 74 | // hammering the API (and get this instance's egress IP banned). | |
| 75 | const maxAttempts = 3 | |
| 76 | ||
| 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 | } | |
| 77 | dd, daErr := fetchDailyDeviations(0) | |
| 78 | if daErr.RAW != nil { | |
| 79 | a.Error("deviantart returned an error", 502) | |
| 80 | return | |
| 81 | } | |
| 86 | 82 | |
| 87 | // rand.Intn panics on 0, so an empty result set must be skipped. | |
| 88 | if len(s.Results) == 0 { | |
| 89 | continue | |
| 83 | var pool []*devianter.Deviation | |
| 84 | for i := range dd.Deviations { | |
| 85 | if d := &dd.Deviations[i]; VisibleDeviation(d) { | |
| 86 | pool = append(pool, d) | |
| 90 | 87 | } |
| 91 | ||
| 92 | deviation := &s.Results[rand.Intn(len(s.Results))] //nolint:gosec // G404: see above | |
| 93 | if deviation.NSFW && !CFG.Nsfw { | |
| 94 | continue | |
| 88 | } | |
| 89 | for s := range dd.Strips { | |
| 90 | for i := range dd.Strips[s].Deviations { | |
| 91 | if d := &dd.Strips[s].Deviations[i]; VisibleDeviation(d) { | |
| 92 | pool = append(pool, d) | |
| 93 | } | |
| 95 | 94 | } |
| 96 | ||
| 97 | a.sendMedia(deviation) | |
| 95 | } | |
| 96 | if len(pool) == 0 { | |
| 97 | a.Error("no daily deviation this instance can show", 404) | |
| 98 | 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 | 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 | 19 | loadTemplatesOnce.Do(func() { |
| 20 | 20 | static.StaticPath = "../static" |
| 21 | 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 | 389 | list.WriteString("&folder=") |
| 390 | 390 | list.WriteString(esc(f)) |
| 391 | 391 | } |
| 392 | if s.Args.Get("comments") != "" { | |
| 393 | list.WriteString("&comments=1") | |
| 394 | } | |
| 392 | 395 | list.WriteString(`">`) |
| 393 | 396 | list.WriteString(msg) |
| 394 | 397 | list.WriteString("</a> ") |
app/wrapper.go +39 −3
| @@ -3,6 +3,8 @@ package app | ||
| 3 | 3 | import ( |
| 4 | 4 | "crypto/sha1" //nolint:gosec // G505: SHA-1 is a cache-key hash here, not a security primitive |
| 5 | 5 | "html/template" |
| 6 | "maps" | |
| 7 | "net/url" | |
| 6 | 8 | "regexp" |
| 7 | 9 | "strconv" |
| 8 | 10 | "strings" |
| @@ -12,6 +14,40 @@ import ( | ||
| 12 | 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 | 51 | // GRUser renders a group or user page: the about tab, the gallery, or favourites, |
| 16 | 52 | // selected by the request's type argument. |
| 17 | 53 | func (s skunkyart) GRUser() { |
| @@ -70,7 +106,7 @@ func (s skunkyart) GRUser() { | ||
| 70 | 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 | 111 | case "cover_deviation": |
| 76 | 112 | group.About.BGMeta = x.ModuleData.CoverDeviation.Deviation |
| @@ -184,7 +220,7 @@ func (s skunkyart) Deviation(author, postname string) { | ||
| 184 | 220 | post := &s.Templates.Deviation |
| 185 | 221 | |
| 186 | 222 | id := idSearch[len(idSearch)-1] |
| 187 | post.Post, err = devianter.GetDeviation(id, author) | |
| 223 | post.Post, err = fetchDeviation(id, author) | |
| 188 | 224 | if err.RAW != nil { |
| 189 | 225 | s.Error(err) |
| 190 | 226 | return |
| @@ -227,7 +263,7 @@ func (s skunkyart) Deviation(author, postname string) { | ||
| 227 | 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 | 267 | post.StringTime = post.Post.Deviation.PublishedTime.UTC().String() |
| 232 | 268 | post.Post.IMG = ParseMedia(s.Host, post.Post.Deviation.Media) |
| 233 | 269 | |
static/lang/en.json +1
| @@ -18,6 +18,7 @@ | ||
| 18 | 18 | "deviation.original": "Redirect to original", |
| 19 | 19 | "deviation.description": "Description", |
| 20 | 20 | "deviation.related": "Related content", |
| 21 | "deviation.comments": "Comments", | |
| 21 | 22 | "about.report": "Report an issue", |
| 22 | 23 | "about.settings": "Instance settings:", |
| 23 | 24 | "about.nsfw": "NSFW", |
static/lang/es.json +1
| @@ -19,6 +19,7 @@ | ||
| 19 | 19 | "deviation.original": "Ir al original", |
| 20 | 20 | "deviation.published": "Publicado: ", |
| 21 | 21 | "deviation.related": "Contenido relacionado", |
| 22 | "deviation.comments": "Comentarios", | |
| 22 | 23 | "index.logo": "Logo de SkunkyArt", |
| 23 | 24 | "index.source": "Código fuente", |
| 24 | 25 | "nav.about": "Acerca de", |