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.
67 67
68## GET /api/random 68## GET /api/random
69 69
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
3import ( 3import (
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.
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.
69// 74//
70// TODO: add filters. 75// TODO: add filters.
71func (a API) Random() { 76func (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.
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() {
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
3import ( 3import (
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.
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
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.
17func (s skunkyart) GRUser() { 53func (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",