Commit 778622bce5

778622bce51786b01ff572b40da972cc8441029c

parent: 4261b937e4

Verified · cmc ci/build: success ci/lint: success ci/test: success

cmc <hello@cleberg.net> · 2026-09-11 02:55 UTC

Fetch comments only when asked, and pick random art from the daily page

A post or profile view no longer fetches its comment thread. It shows a
link carrying the count the deviation call already returned; following
it adds ?comments=1 and renders the thread as before, and pagination
keeps the parameter.

/api/random picks from the daily deviations, one call the API cache
answers for its TTL, instead of up to three uncacheable random searches
per hit.

Closes #11

Layout: unified · split

API.md +3 −1
@@ -67,4 +67,6 @@ and a reader following a direct link to one still gets it.
6767
6868## GET /api/random
6969
70A random artwork's media — the image itself, not JSON. Honours `nsfw`.
70A random artwork's media — the image itself, not JSON. The pick is made
71among the current daily deviations, which the instance already caches, so
72the call costs no extra upstream request. Honours `nsfw` and `hide-ai`.
app/api.go +29 −27
@@ -3,7 +3,6 @@ package app
33import (
44 "encoding/json"
55 "math/rand"
6 "strconv"
76 "strings"
87
98 "github.com/krazywarez/devianter"
@@ -64,39 +63,42 @@ func (a API) sendMedia(d *devianter.Deviation) {
6463 }
6564}
6665
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.
68var 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.
6974//
7075// TODO: add filters.
7176func (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 }
8682
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)
9087 }
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 }
9594 }
96
97 a.sendMedia(deviation)
95 }
96 if len(pool) == 0 {
97 a.Error("no daily deviation this instance can show", 404)
9898 return
9999 }
100100
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
102104}
app/api_test.go +51
@@ -57,3 +57,54 @@ func TestSendMediaIgnoresEmptyMedia(t *testing.T) {
5757 t.Errorf("Location %q set for a media-less deviation, want none", loc)
5858 }
5959}
60
61// withDailyDeviations scripts the daily deviations fetch with the given
62// entries and counts the calls.
63func 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.
77func 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.
96func 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 @@
1package app
2
3import (
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.
14func 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
33func 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
41func 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
57func 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
73func 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() {
1919 loadTemplatesOnce.Do(func() {
2020 static.StaticPath = "../static"
2121 static.CopyTemplatesToMemory()
22 LoadLanguages()
2223 })
2324}
2425
app/util.go +3
@@ -389,6 +389,9 @@ func (s skunkyart) NavBase(c DeviationList) string {
389389 list.WriteString("&folder=")
390390 list.WriteString(esc(f))
391391 }
392 if s.Args.Get("comments") != "" {
393 list.WriteString("&comments=1")
394 }
392395 list.WriteString(`">`)
393396 list.WriteString(msg)
394397 list.WriteString("</a> ")
app/wrapper.go +39 −3
@@ -3,6 +3,8 @@ package app
33import (
44 "crypto/sha1" //nolint:gosec // G505: SHA-1 is a cache-key hash here, not a security primitive
55 "html/template"
6 "maps"
7 "net/url"
68 "regexp"
79 "strconv"
810 "strings"
@@ -12,6 +14,40 @@ import (
1214 "golang.org/x/net/html"
1315)
1416
17// devianter calls behind variables so tests can count and script them.
18var (
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).
27func (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
1551// GRUser renders a group or user page: the about tab, the gallery, or favourites,
1652// selected by the request's type argument.
1753func (s skunkyart) GRUser() {
@@ -70,7 +106,7 @@ func (s skunkyart) GRUser() {
70106 group.About.Interests += template.HTML(interest.String()) //nolint:gosec // G203: escaped above
71107 }
72108 }
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)
74110
75111 case "cover_deviation":
76112 group.About.BGMeta = x.ModuleData.CoverDeviation.Deviation
@@ -184,7 +220,7 @@ func (s skunkyart) Deviation(author, postname string) {
184220 post := &s.Templates.Deviation
185221
186222 id := idSearch[len(idSearch)-1]
187 post.Post, err = devianter.GetDeviation(id, author)
223 post.Post, err = fetchDeviation(id, author)
188224 if err.RAW != nil {
189225 s.Error(err)
190226 return
@@ -227,7 +263,7 @@ func (s skunkyart) Deviation(author, postname string) {
227263 post.Tags += template.HTML(tag.String()) //nolint:gosec // G203: escaped above
228264 }
229265
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)
231267 post.StringTime = post.Post.Deviation.PublishedTime.UTC().String()
232268 post.Post.IMG = ParseMedia(s.Host, post.Post.Deviation.Media)
233269
static/lang/en.json +1
@@ -18,6 +18,7 @@
1818 "deviation.original": "Redirect to original",
1919 "deviation.description": "Description",
2020 "deviation.related": "Related content",
21 "deviation.comments": "Comments",
2122 "about.report": "Report an issue",
2223 "about.settings": "Instance settings:",
2324 "about.nsfw": "NSFW",
static/lang/es.json +1
@@ -19,6 +19,7 @@
1919 "deviation.original": "Ir al original",
2020 "deviation.published": "Publicado: ",
2121 "deviation.related": "Contenido relacionado",
22 "deviation.comments": "Comentarios",
2223 "index.logo": "Logo de SkunkyArt",
2324 "index.source": "Código fuente",
2425 "nav.about": "Acerca de",