Commit 1b89e1fe37
Verified · cmc ci/build: success ci/lint: success ci/test: success
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. |
| 39 | func (a API) Error(description string, status int) { | 39 | func (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. | ||
| 321 | func 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. | ||
| 327 | func 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. | ||
| 345 | func 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. | ||
| 18 | const ( | ||
| 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. |
| 15 | func Router() { | 26 | func 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. | ||
| 44 | func 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 @@ | |||
| 1 | package app | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "errors" | ||
| 5 | "net/http" | ||
| 6 | "net/http/httptest" | ||
| 7 | "testing" | ||
| 8 | ) | ||
| 9 | |||
| 10 | // serve runs one request through the real handler. | ||
| 11 | func 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. | ||
| 23 | func 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. |
| 201 | func (s skunkyart) Error(dAerr devianter.Error) { | 201 | func (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 @@ | |||
| 1 | package app | 1 | package app |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 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. | ||
| 349 | var 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. | ||
| 348 | func (s skunkyart) Emojitar(name string) { | 355 | func (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 @@ | |||
| 1 | package app | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 11 | func 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 | |||
| 26 | func 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 | |||
| 32 | func 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 | |||
| 46 | func 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 | |||
| 60 | func 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 | } | ||