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:
1111# Config
1212* `listen` — IP and port to listen on in the following form: ip:port
1313* `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.
1515 * `enabled` — Caching system state, requires boolean value
1616 * `path` — Path to cache directory. It must be writable by the user SkunkyArt
1717 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() {
3737
3838// Error responds with a JSON error body and the given HTTP status.
3939func (a API) Error(description string, status int) {
40 a.main.Writer.Header().Del("Cache-Control")
4041 a.main.Writer.WriteHeader(status)
4142 var response strings.Builder
4243 response.WriteString(`{"error":"`)
app/cache.go +34 −1
@@ -205,7 +205,7 @@ func (s skunkyart) DownloadAndSendMedia(subdomain, path string) {
205205 switch {
206206 case CFG.Cache.Enabled:
207207 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)
209209
210210 if CFG.Cache.MemCache {
211211 if cached := memGet(key); cached != nil {
@@ -232,6 +232,7 @@ func (s skunkyart) DownloadAndSendMedia(subdomain, path string) {
232232 }
233233 response = dwnld.Body
234234 default:
235 s.Writer.Header().Del("Cache-Control")
235236 s.Writer.WriteHeader(403)
236237 response = []byte("Sorry, butt proxy on this instance are disabled.")
237238 }
@@ -315,3 +316,35 @@ func InitCacheSystem() {
315316 time.Sleep(time.Second * time.Duration(c.UpdateInterval))
316317 }
317318}
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 (
1010 "time"
1111)
1212
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
1324// Router registers the single catch-all handler that dispatches every path, then
1425// serves until the process exits. It does not return on success.
1526func 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 {
1645 parsepath := func(path string) map[int]string {
1746 if l := len(CFG.URI); len(path) > l {
1847 path = path[l-1:]
@@ -62,7 +91,7 @@ func Router() {
6291 }
6392
6493 // the function that drives everything
65 handle := func(w http.ResponseWriter, r *http.Request) {
94 return func(w http.ResponseWriter, r *http.Request) {
6695 path := parsepath(r.URL.Path)
6796
6897 // Per-request, not a package global: requests arrive concurrently on
@@ -106,6 +135,7 @@ func Router() {
106135 }
107136
108137 w.Header().Add("X-Frame-Options", "DENY")
138 w.Header().Set("Cache-Control", cacheControlPage)
109139
110140 switch skunky.Endpoint {
111141 // main
@@ -130,11 +160,16 @@ func Router() {
130160 if a := arg("filename"); a != "" {
131161 skunky.SetFilename(a)
132162 }
163 w.Header().Set("Cache-Control", cacheControlMedia)
133164 skunky.DownloadAndSendMedia(path[3], next(path, 4))
134165 case "emojitar":
166 w.Header().Set("Cache-Control", cacheControlAssets)
135167 skunky.Emojitar(path[3])
168 default:
169 skunky.ReturnHTTPError(404)
136170 }
137171 case "stylesheet":
172 w.Header().Set("Cache-Control", cacheControlAssets)
138173 w.Header().Add("Content-Type", "text/css")
139174 _, _ = w.Write(open("css/skunky.css"))
140175 // "auto" is the stylesheet as written: dark, with a light palette
@@ -145,6 +180,7 @@ func Router() {
145180 _, _ = w.Write([]byte(css))
146181 }
147182 case "favicon.ico":
183 w.Header().Set("Cache-Control", cacheControlAssets)
148184 _, _ = w.Write(open("images/logo.png"))
149185
150186 // API
@@ -168,19 +204,4 @@ func Router() {
168204 skunky.ReturnHTTPError(404)
169205 }
170206 }
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)
186207}
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 {
199199// first line is shown: a WAF block arrives as a whole HTML page, which is
200200// neither readable nor safe to echo.
201201func (s skunkyart) Error(dAerr devianter.Error) {
202 s.Writer.Header().Del("Cache-Control")
202203 s.Writer.WriteHeader(502)
203204
204205 reason, _, _ := strings.Cut(dAerr.Error, "\n")
@@ -220,6 +221,7 @@ func (s skunkyart) ReturnHTTPError(status int) {
220221 if status < 100 || status > 599 {
221222 status = http.StatusBadGateway
222223 }
224 s.Writer.Header().Del("Cache-Control")
223225 s.Writer.WriteHeader(status)
224226
225227 var msg strings.Builder
app/wrapper.go +21 −2
@@ -1,6 +1,7 @@
11package app
22
33import (
4 "crypto/sha1" //nolint:gosec // G505: SHA-1 is a cache-key hash here, not a security primitive
45 "html/template"
56 "regexp"
67 "strconv"
@@ -190,6 +191,7 @@ func (s skunkyart) Deviation(author, postname string) {
190191 }
191192
192193 if post.Post.Deviation.NSFW && !CFG.Nsfw {
194 s.Writer.Header().Del("Cache-Control")
193195 s.Writer.WriteHeader(403)
194196 wr(s.Writer, `<html><link rel="stylesheet" href="`+
195197 URLBuilder(s.Host, "stylesheet")+
@@ -343,17 +345,34 @@ func (s skunkyart) Search() {
343345 s.ExecuteTemplate("search.htm", "html", &s)
344346}
345347
348// fetchAvatar is devianter.AEmedia behind a variable so tests can count calls.
349var fetchAvatar = devianter.AEmedia
350
346351// 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.
348355func (s skunkyart) Emojitar(name string) {
349356 if name == "" || (s.Type != 'a' && s.Type != 'e') {
350357 s.ReturnHTTPError(400)
351358 return
352359 }
353360
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)
355370 if e != nil {
356371 s.ReturnHTTPError(404)
372 return
373 }
374 if CFG.Cache.Enabled {
375 storeBody(key, []byte(ae))
357376 }
358377 wr(s.Writer, ae)
359378}
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}