Commit 1b89e1fe37

1b89e1fe37621ef52527d14a466009484014f822

parent: 90ffcd3e9d

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

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

Cache avatars and emotes, and send Cache-Control headers

Emojitar stores fetched avatars and emotes in the media cache, so each
is fetched from DeviantArt once per lifetime instead of on every view.
A miss is not stored.

Every response now carries Cache-Control: a year and immutable on
signed wixmp media, a day on avatars, emotes, the stylesheet and the
favicon, five minutes on pages and API JSON. Error responses drop the
header so a failure is never remembered. The catch-all handler is
exposed as Handler so the routes can be tested.

Closes #9

Layout: unified · split

SETUP.md +1 −1
@@ -11,7 +11,7 @@ Time units:
11# Config 11# Config
12* `listen` — IP and port to listen on in the following form: ip:port 12* `listen` — IP and port to listen on in the following form: ip:port
13* `uri` — Instance URI. Example: `"uri":"/art/"` -> https://skunky.ebloid.ru/art/ 13* `uri` — Instance URI. Example: `"uri":"/art/"` -> https://skunky.ebloid.ru/art/
14* `cache` — Caching system; default is off. 14* `cache` — Caching system for proxied media, avatars and emotes; default is off.
15 * `enabled` — Caching system state, requires boolean value 15 * `enabled` — Caching system state, requires boolean value
16 * `path` — Path to cache directory. It must be writable by the user SkunkyArt 16 * `path` — Path to cache directory. It must be writable by the user SkunkyArt
17 runs as, and SkunkyArt refuses to start if it is not. The container image 17 runs as, and SkunkyArt refuses to start if it is not. The container image
app/api.go +1
@@ -37,6 +37,7 @@ func (a API) Info() {
37 37
38// Error responds with a JSON error body and the given HTTP status. 38// Error responds with a JSON error body and the given HTTP status.
39func (a API) Error(description string, status int) { 39func (a API) Error(description string, status int) {
40 a.main.Writer.Header().Del("Cache-Control")
40 a.main.Writer.WriteHeader(status) 41 a.main.Writer.WriteHeader(status)
41 var response strings.Builder 42 var response strings.Builder
42 response.WriteString(`{"error":"`) 43 response.WriteString(`{"error":"`)
app/cache.go +34 −1
@@ -205,7 +205,7 @@ func (s skunkyart) DownloadAndSendMedia(subdomain, path string) {
205 switch { 205 switch {
206 case CFG.Cache.Enabled: 206 case CFG.Cache.Enabled:
207 key := sha1.Sum([]byte(subdomain + path)) //nolint:gosec // G401: cache-key hash, not a security primitive 207 key := sha1.Sum([]byte(subdomain + path)) //nolint:gosec // G401: cache-key hash, not a security primitive
208 filePath := CFG.Cache.Path + "/" + hex.EncodeToString(key[:]) 208 filePath := cacheFilePath(key)
209 209
210 if CFG.Cache.MemCache { 210 if CFG.Cache.MemCache {
211 if cached := memGet(key); cached != nil { 211 if cached := memGet(key); cached != nil {
@@ -232,6 +232,7 @@ func (s skunkyart) DownloadAndSendMedia(subdomain, path string) {
232 } 232 }
233 response = dwnld.Body 233 response = dwnld.Body
234 default: 234 default:
235 s.Writer.Header().Del("Cache-Control")
235 s.Writer.WriteHeader(403) 236 s.Writer.WriteHeader(403)
236 response = []byte("Sorry, butt proxy on this instance are disabled.") 237 response = []byte("Sorry, butt proxy on this instance are disabled.")
237 } 238 }
@@ -315,3 +316,35 @@ func InitCacheSystem() {
315 time.Sleep(time.Second * time.Duration(c.UpdateInterval)) 316 time.Sleep(time.Second * time.Duration(c.UpdateInterval))
316 } 317 }
317} 318}
319
320// cacheFilePath is where the body cached under key lives on disk.
321func cacheFilePath(key [20]byte) string {
322 return CFG.Cache.Path + "/" + hex.EncodeToString(key[:])
323}
324
325// cachedBody returns the body stored under key, from memory when memcache is
326// on and otherwise from disk, or nil when there is none.
327func cachedBody(key [20]byte) []byte {
328 if CFG.Cache.MemCache {
329 if body := memGet(key); body != nil {
330 return body
331 }
332 }
333 // The path is a hash of the key, not user input.
334 body, err := os.ReadFile(cacheFilePath(key)) //nolint:gosec // G304
335 if err != nil || len(body) == 0 {
336 return nil
337 }
338 if CFG.Cache.MemCache {
339 memPut(key, body)
340 }
341 return body
342}
343
344// storeBody writes body under key to disk and, when memcache is on, memory.
345func storeBody(key [20]byte, body []byte) {
346 try(os.WriteFile(cacheFilePath(key), body, 0600))
347 if CFG.Cache.MemCache {
348 memPut(key, body)
349 }
350}
app/router.go +37 −16
@@ -10,9 +10,38 @@ import (
10 "time" 10 "time"
11) 11)
12 12
13// Cache-Control values by route. Signed wixmp media never changes under its
14// URL; avatars, emotes and static assets change rarely; pages and API JSON
15// follow the API cache's default TTL so a reverse proxy can hold them too.
16// Error responses drop the header (see ReturnHTTPError and friends) so a
17// failure is never remembered.
18const (
19 cacheControlMedia = "public, max-age=31536000, immutable"
20 cacheControlAssets = "public, max-age=86400"
21 cacheControlPage = "public, max-age=300"
22)
23
13// Router registers the single catch-all handler that dispatches every path, then 24// Router registers the single catch-all handler that dispatches every path, then
14// serves until the process exits. It does not return on success. 25// serves until the process exits. It does not return on success.
15func Router() { 26func Router() {
27 http.HandleFunc("/", Handler())
28 println("SkunkyArt is listening on", CFG.Listen)
29
30 // Explicit timeouts: the bare http.ListenAndServe has none, so a slow client
31 // can hold a connection (and its handler) open indefinitely. WriteTimeout is
32 // generous because media proxying streams large files through a handler.
33 srv := &http.Server{
34 Addr: CFG.Listen,
35 ReadHeaderTimeout: 10 * time.Second,
36 ReadTimeout: 30 * time.Second,
37 WriteTimeout: 120 * time.Second,
38 IdleTimeout: 120 * time.Second,
39 }
40 tryWithExitStatus(srv.ListenAndServe(), 1)
41}
42
43// Handler returns the single catch-all handler that dispatches every path.
44func Handler() http.HandlerFunc {
16 parsepath := func(path string) map[int]string { 45 parsepath := func(path string) map[int]string {
17 if l := len(CFG.URI); len(path) > l { 46 if l := len(CFG.URI); len(path) > l {
18 path = path[l-1:] 47 path = path[l-1:]
@@ -62,7 +91,7 @@ func Router() {
62 } 91 }
63 92
64 // the function that drives everything 93 // the function that drives everything
65 handle := func(w http.ResponseWriter, r *http.Request) { 94 return func(w http.ResponseWriter, r *http.Request) {
66 path := parsepath(r.URL.Path) 95 path := parsepath(r.URL.Path)
67 96
68 // Per-request, not a package global: requests arrive concurrently on 97 // Per-request, not a package global: requests arrive concurrently on
@@ -106,6 +135,7 @@ func Router() {
106 } 135 }
107 136
108 w.Header().Add("X-Frame-Options", "DENY") 137 w.Header().Add("X-Frame-Options", "DENY")
138 w.Header().Set("Cache-Control", cacheControlPage)
109 139
110 switch skunky.Endpoint { 140 switch skunky.Endpoint {
111 // main 141 // main
@@ -130,11 +160,16 @@ func Router() {
130 if a := arg("filename"); a != "" { 160 if a := arg("filename"); a != "" {
131 skunky.SetFilename(a) 161 skunky.SetFilename(a)
132 } 162 }
163 w.Header().Set("Cache-Control", cacheControlMedia)
133 skunky.DownloadAndSendMedia(path[3], next(path, 4)) 164 skunky.DownloadAndSendMedia(path[3], next(path, 4))
134 case "emojitar": 165 case "emojitar":
166 w.Header().Set("Cache-Control", cacheControlAssets)
135 skunky.Emojitar(path[3]) 167 skunky.Emojitar(path[3])
168 default:
169 skunky.ReturnHTTPError(404)
136 } 170 }
137 case "stylesheet": 171 case "stylesheet":
172 w.Header().Set("Cache-Control", cacheControlAssets)
138 w.Header().Add("Content-Type", "text/css") 173 w.Header().Add("Content-Type", "text/css")
139 _, _ = w.Write(open("css/skunky.css")) 174 _, _ = w.Write(open("css/skunky.css"))
140 // "auto" is the stylesheet as written: dark, with a light palette 175 // "auto" is the stylesheet as written: dark, with a light palette
@@ -145,6 +180,7 @@ func Router() {
145 _, _ = w.Write([]byte(css)) 180 _, _ = w.Write([]byte(css))
146 } 181 }
147 case "favicon.ico": 182 case "favicon.ico":
183 w.Header().Set("Cache-Control", cacheControlAssets)
148 _, _ = w.Write(open("images/logo.png")) 184 _, _ = w.Write(open("images/logo.png"))
149 185
150 // API 186 // API
@@ -168,19 +204,4 @@ func Router() {
168 skunky.ReturnHTTPError(404) 204 skunky.ReturnHTTPError(404)
169 } 205 }
170 } 206 }
171
172 http.HandleFunc("/", handle)
173 println("SkunkyArt is listening on", CFG.Listen)
174
175 // Explicit timeouts: the bare http.ListenAndServe has none, so a slow client
176 // can hold a connection (and its handler) open indefinitely. WriteTimeout is
177 // generous because media proxying streams large files through a handler.
178 srv := &http.Server{
179 Addr: CFG.Listen,
180 ReadHeaderTimeout: 10 * time.Second,
181 ReadTimeout: 30 * time.Second,
182 WriteTimeout: 120 * time.Second,
183 IdleTimeout: 120 * time.Second,
184 }
185 tryWithExitStatus(srv.ListenAndServe(), 1)
186} 207}
app/router_test.go added +60
@@ -0,0 +1,60 @@
1package app
2
3import (
4 "errors"
5 "net/http"
6 "net/http/httptest"
7 "testing"
8)
9
10// serve runs one request through the real handler.
11func serve(t *testing.T, target string) *httptest.ResponseRecorder {
12 t.Helper()
13 loadTemplates()
14 rec := httptest.NewRecorder()
15 req := httptest.NewRequest(http.MethodGet, target, nil)
16 Handler()(rec, req)
17 return rec
18}
19
20// TestCacheControlByRoute pins the header each kind of response carries, and
21// that error responses carry none, so a proxy or browser never keeps a
22// failure.
23func TestCacheControlByRoute(t *testing.T) {
24 uri := CFG.URI
25 CFG.URI = "/"
26 defer func() { CFG.URI = uri }()
27
28 orig := fetchAvatar
29 fetchAvatar = func(name string, _ rune) (string, error) {
30 if name == "missing" {
31 return "", errors.New("user not exists")
32 }
33 return "png-bytes", nil
34 }
35 defer func() { fetchAvatar = orig }()
36
37 cases := []struct {
38 target, want string
39 status int
40 }{
41 {"/stylesheet", cacheControlAssets, 200},
42 {"/favicon.ico", cacheControlAssets, 200},
43 {"/about", cacheControlPage, 200},
44 {"/api/instance", cacheControlPage, 200},
45 {"/media/emojitar/alice?type=a", cacheControlAssets, 200},
46 {"/media/emojitar/missing?type=a", "", 404},
47 {"/media/file/x@evil/f.png", "", 400},
48 {"/api/nonexistent", "", 404},
49 {"/nonexistent", "", 404},
50 }
51 for _, c := range cases {
52 rec := serve(t, c.target)
53 if rec.Code != c.status {
54 t.Errorf("%s: status %d, want %d", c.target, rec.Code, c.status)
55 }
56 if got := rec.Header().Get("Cache-Control"); got != c.want {
57 t.Errorf("%s: Cache-Control %q, want %q", c.target, got, c.want)
58 }
59 }
60}
app/util.go +2
@@ -199,6 +199,7 @@ func URLBuilder(host string, strs ...string) string {
199// first line is shown: a WAF block arrives as a whole HTML page, which is 199// first line is shown: a WAF block arrives as a whole HTML page, which is
200// neither readable nor safe to echo. 200// neither readable nor safe to echo.
201func (s skunkyart) Error(dAerr devianter.Error) { 201func (s skunkyart) Error(dAerr devianter.Error) {
202 s.Writer.Header().Del("Cache-Control")
202 s.Writer.WriteHeader(502) 203 s.Writer.WriteHeader(502)
203 204
204 reason, _, _ := strings.Cut(dAerr.Error, "\n") 205 reason, _, _ := strings.Cut(dAerr.Error, "\n")
@@ -220,6 +221,7 @@ func (s skunkyart) ReturnHTTPError(status int) {
220 if status < 100 || status > 599 { 221 if status < 100 || status > 599 {
221 status = http.StatusBadGateway 222 status = http.StatusBadGateway
222 } 223 }
224 s.Writer.Header().Del("Cache-Control")
223 s.Writer.WriteHeader(status) 225 s.Writer.WriteHeader(status)
224 226
225 var msg strings.Builder 227 var msg strings.Builder
app/wrapper.go +21 −2
@@ -1,6 +1,7 @@
1package app 1package app
2 2
3import ( 3import (
4 "crypto/sha1" //nolint:gosec // G505: SHA-1 is a cache-key hash here, not a security primitive
4 "html/template" 5 "html/template"
5 "regexp" 6 "regexp"
6 "strconv" 7 "strconv"
@@ -190,6 +191,7 @@ func (s skunkyart) Deviation(author, postname string) {
190 } 191 }
191 192
192 if post.Post.Deviation.NSFW && !CFG.Nsfw { 193 if post.Post.Deviation.NSFW && !CFG.Nsfw {
194 s.Writer.Header().Del("Cache-Control")
193 s.Writer.WriteHeader(403) 195 s.Writer.WriteHeader(403)
194 wr(s.Writer, `<html><link rel="stylesheet" href="`+ 196 wr(s.Writer, `<html><link rel="stylesheet" href="`+
195 URLBuilder(s.Host, "stylesheet")+ 197 URLBuilder(s.Host, "stylesheet")+
@@ -343,17 +345,34 @@ func (s skunkyart) Search() {
343 s.ExecuteTemplate("search.htm", "html", &s) 345 s.ExecuteTemplate("search.htm", "html", &s)
344} 346}
345 347
348// fetchAvatar is devianter.AEmedia behind a variable so tests can count calls.
349var fetchAvatar = devianter.AEmedia
350
346// Emojitar proxies a user's avatar or emoji image, selected by the request's 351// Emojitar proxies a user's avatar or emoji image, selected by the request's
347// type argument. 352// type argument. With the media cache on, the image is served from it after
353// the first fetch: avatars are on every comment and listing, and DeviantArt
354// answers each fetch with up to three requests.
348func (s skunkyart) Emojitar(name string) { 355func (s skunkyart) Emojitar(name string) {
349 if name == "" || (s.Type != 'a' && s.Type != 'e') { 356 if name == "" || (s.Type != 'a' && s.Type != 'e') {
350 s.ReturnHTTPError(400) 357 s.ReturnHTTPError(400)
351 return 358 return
352 } 359 }
353 360
354 ae, e := devianter.AEmedia(name, s.Type) 361 key := sha1.Sum([]byte("emojitar:" + string(s.Type) + ":" + strings.ToLower(name))) //nolint:gosec // G401: cache key, not a security primitive
362 if CFG.Cache.Enabled {
363 if body := cachedBody(key); body != nil {
364 _, _ = s.Writer.Write(body)
365 return
366 }
367 }
368
369 ae, e := fetchAvatar(name, s.Type)
355 if e != nil { 370 if e != nil {
356 s.ReturnHTTPError(404) 371 s.ReturnHTTPError(404)
372 return
373 }
374 if CFG.Cache.Enabled {
375 storeBody(key, []byte(ae))
357 } 376 }
358 wr(s.Writer, ae) 377 wr(s.Writer, ae)
359} 378}
app/wrapper_test.go added +69
@@ -0,0 +1,69 @@
1package app
2
3import (
4 "errors"
5 "net/http/httptest"
6 "testing"
7)
8
9// withAvatarCache turns the media cache on over a temporary directory and
10// swaps in a counting avatar fetcher for the test's duration.
11func withAvatarCache(t *testing.T, enabled bool, fetch func(string, rune) (string, error)) *int {
12 t.Helper()
13 cache, orig := CFG.Cache, fetchAvatar
14 CFG.Cache.Enabled = enabled
15 CFG.Cache.MemCache = false
16 CFG.Cache.Path = t.TempDir()
17 calls := 0
18 fetchAvatar = func(name string, r rune) (string, error) {
19 calls++
20 return fetch(name, r)
21 }
22 t.Cleanup(func() { CFG.Cache, fetchAvatar = cache, orig })
23 return &calls
24}
25
26func emojitar(name string) *httptest.ResponseRecorder {
27 rec := httptest.NewRecorder()
28 skunkyart{Writer: rec, Host: "http://localhost", Type: 'a'}.Emojitar(name)
29 return rec
30}
31
32func TestEmojitarServesFromCacheAfterFirstFetch(t *testing.T) {
33 calls := withAvatarCache(t, true, func(string, rune) (string, error) { return "png", nil })
34
35 first := emojitar("Alice")
36 second := emojitar("alice") // case-insensitive, as DeviantArt's paths are
37
38 if *calls != 1 {
39 t.Errorf("avatar fetched %d times, want 1", *calls)
40 }
41 if first.Body.String() != "png" || second.Body.String() != "png" {
42 t.Errorf("bodies %q and %q, want both png", first.Body.String(), second.Body.String())
43 }
44}
45
46func TestEmojitarDoesNotCacheAMiss(t *testing.T) {
47 calls := withAvatarCache(t, true, func(string, rune) (string, error) { return "", errors.New("user not exists") })
48
49 first := emojitar("nobody")
50 emojitar("nobody")
51
52 if first.Code != 404 {
53 t.Errorf("status %d, want 404", first.Code)
54 }
55 if *calls != 2 {
56 t.Errorf("avatar fetched %d times, want 2: a miss must not be cached", *calls)
57 }
58}
59
60func TestEmojitarFetchesEveryTimeWithCacheOff(t *testing.T) {
61 calls := withAvatarCache(t, false, func(string, rune) (string, error) { return "png", nil })
62
63 emojitar("alice")
64 emojitar("alice")
65
66 if *calls != 2 {
67 t.Errorf("avatar fetched %d times, want 2 with the cache off", *calls)
68 }
69}