Commit bf87ba9fc5

bf87ba9fc5853883feb74607b3488d17cab01e98

parent: cf823b1736

Verified · cmc

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

Add the api-cache config block and a shared lifetime parser

Ref #8

Layout: unified · split

SETUP.md +11
@@ -3,6 +3,7 @@ Maximum file size in megabytes, requires numeric value.<br>
33Time units:
44* `i` — minutes
55* `h` — hours
6* `d` — days
67* `w` — weeks
78* `m` — months
89* `y` — years
@@ -23,6 +24,16 @@ Time units:
2324 * `lifetime` — Cached file life time, requires numeric value, followed by multiplicative suffix (see Time Units for details)
2425 * `max-size` — Maximum file size in megabytes
2526 * `update-interval` — Automatic rotation interval
27* `api-cache` — In-memory cache of DeviantArt API responses. Every page,
28 feed poll and API call that asks DeviantArt the same question within the
29 TTL is answered from memory, and concurrent requests for one thing make
30 one upstream call. On by default; DeviantArt bans egress IPs that ask too
31 often, so leave it on unless you are debugging.
32 * `enabled` — boolean, default true
33 * `max-size` — megabytes of response bodies to hold, default 64. Least
34 recently used entries are dropped past this.
35 * `ttl` — how long a response is reused, in the time units above. Default
36 `5i`.
2637* `static-path` — This setting determines path to static, which will be copied to RAM when SkunkyArt is started. Useless if you're use binary compiled with 'embed' tag.
2738* `download-proxy` — Outbound proxy used when fetching media from DeviantArt's
2839 CDN. Leave empty (`""`) unless you actually run a proxy: if this points at
app/config.go +73 −33
@@ -2,6 +2,7 @@ package app
22
33import (
44 "encoding/json"
5 "errors"
56 "os"
67 "regexp"
78 "skunkyart/static"
@@ -27,19 +28,26 @@ type cacheConfig struct {
2728 UpdateInterval int64 `json:"update-interval"`
2829}
2930
31type apiCacheConfig struct {
32 Enabled bool `json:"enabled"`
33 MaxSize int64 `json:"max-size"`
34 TTL string `json:"ttl"`
35}
36
3037type config struct {
3138 cfg string
32 Listen string `json:"listen"`
33 URI string `json:"uri"`
34 Cache cacheConfig `json:"cache"`
35 Proxy bool `json:"proxy"`
36 Nsfw bool `json:"nsfw"`
37 HideAI bool `json:"hide-ai"`
38 Theme string `json:"theme"`
39 Language string `json:"language"`
40 UserAgent string `json:"user-agent"`
41 DownloadProxy string `json:"download-proxy"`
42 StaticPath string `json:"static-path"`
39 Listen string `json:"listen"`
40 URI string `json:"uri"`
41 Cache cacheConfig `json:"cache"`
42 APICache apiCacheConfig `json:"api-cache"`
43 Proxy bool `json:"proxy"`
44 Nsfw bool `json:"nsfw"`
45 HideAI bool `json:"hide-ai"`
46 Theme string `json:"theme"`
47 Language string `json:"language"`
48 UserAgent string `json:"user-agent"`
49 DownloadProxy string `json:"download-proxy"`
50 StaticPath string `json:"static-path"`
4351}
4452
4553// CFG is the running instance's configuration, holding the defaults below until
@@ -55,6 +63,11 @@ var CFG = config{
5563 Path: "cache",
5664 UpdateInterval: 1,
5765 },
66 APICache: apiCacheConfig{
67 Enabled: true,
68 MaxSize: 64,
69 TTL: "5i",
70 },
5871 StaticPath: "static",
5972 UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36",
6073 Proxy: true,
@@ -63,6 +76,43 @@ var CFG = config{
6376
6477var lifetimeParsed int64
6578
79// apiCacheTTL is api-cache.ttl parsed, set by ExecuteConfig.
80var apiCacheTTL time.Duration
81
82// parseLifetime reads a duration in the config's unit syntax: a number
83// followed by i (minutes), h (hours), d (days), w (weeks), m (30-day
84// months) or y (360-day years).
85func parseLifetime(s string) (time.Duration, error) {
86 if s == "" {
87 return 0, errors.New("empty lifetime")
88 }
89 numstr := regexp.MustCompile("[0-9]+").FindAllString(s, -1)
90 if len(numstr) == 0 {
91 return 0, errors.New("lifetime has no number: " + s)
92 }
93 num, _ := strconv.Atoi(numstr[len(numstr)-1])
94
95 day := 24 * time.Hour
96 var unit time.Duration
97 switch s[len(s)-1:] {
98 case "i":
99 unit = time.Minute
100 case "h":
101 unit = time.Hour
102 case "d":
103 unit = day
104 case "w":
105 unit = 7 * day
106 case "m":
107 unit = 30 * day
108 case "y":
109 unit = 360 * day
110 default:
111 return 0, errors.New("invalid unit specified: " + s[len(s)-1:])
112 }
113 return unit * time.Duration(num), nil
114}
115
66116// checkCacheWritable creates the cache directory if it is missing and confirms
67117// this process can actually write into it, returning the error that a real cache
68118// write would hit.
@@ -104,29 +154,11 @@ func ExecuteConfig() {
104154 }
105155
106156 if CFG.Cache.Lifetime != "" {
107 var duration int64
108 day := 24 * time.Hour.Milliseconds()
109 numstr := regexp.MustCompile("[0-9]+").FindAllString(CFG.Cache.Lifetime, -1)
110 num, _ := strconv.Atoi(numstr[len(numstr)-1])
111
112 switch unit := CFG.Cache.Lifetime[len(CFG.Cache.Lifetime)-1:]; unit {
113 case "i":
114 duration = time.Minute.Milliseconds()
115 case "h":
116 duration = time.Hour.Milliseconds()
117 case "d":
118 duration = day
119 case "w":
120 duration = day * 7
121 case "m":
122 duration = day * 30
123 case "y":
124 duration = day * 360
125 default:
126 exit("Invalid unit specified: "+unit, 1)
157 d, err := parseLifetime(CFG.Cache.Lifetime)
158 if err != nil {
159 exit("config: cache.lifetime: "+err.Error(), 1)
127160 }
128
129 lifetimeParsed = duration * int64(num)
161 lifetimeParsed = d.Milliseconds()
130162 }
131163 // max-size is documented in megabytes. This was 1024^2, which in Go is
132164 // XOR (1026), not exponentiation — so the cap was ~1000x too small.
@@ -152,6 +184,14 @@ func ExecuteConfig() {
152184 exit("config: theme must be one of auto, dark, light; got "+CFG.Theme, 1)
153185 }
154186
187 if CFG.APICache.Enabled {
188 d, err := parseLifetime(CFG.APICache.TTL)
189 if err != nil {
190 exit("config: api-cache.ttl: "+err.Error(), 1)
191 }
192 apiCacheTTL = d
193 }
194
155195 static.StaticPath = CFG.StaticPath
156196 devianter.UserAgent = CFG.UserAgent
157197 }
app/config_test.go added +38
@@ -0,0 +1,38 @@
1package app
2
3import (
4 "testing"
5 "time"
6)
7
8func TestParseLifetimeUnits(t *testing.T) {
9 cases := map[string]time.Duration{
10 "5i": 5 * time.Minute,
11 "2h": 2 * time.Hour,
12 "3d": 72 * time.Hour,
13 "1w": 7 * 24 * time.Hour,
14 "1m": 30 * 24 * time.Hour,
15 "1y": 360 * 24 * time.Hour,
16 "12i": 12 * time.Minute,
17 }
18 for in, want := range cases {
19 got, err := parseLifetime(in)
20 if err != nil || got != want {
21 t.Errorf("parseLifetime(%q) = %v, %v; want %v", in, got, err, want)
22 }
23 }
24}
25
26func TestParseLifetimeRejectsBadInput(t *testing.T) {
27 for _, in := range []string{"", "5", "5x", "h"} {
28 if _, err := parseLifetime(in); err == nil {
29 t.Errorf("parseLifetime(%q) accepted, want an error", in)
30 }
31 }
32}
33
34func TestAPICacheDefaults(t *testing.T) {
35 if !CFG.APICache.Enabled || CFG.APICache.MaxSize != 64 || CFG.APICache.TTL != "5i" {
36 t.Errorf("defaults are %+v, want enabled, 64 MB, 5i", CFG.APICache)
37 }
38}
config.example.json +5
@@ -9,6 +9,11 @@
99 "memcache": false,
1010 "update-interval": 5
1111 },
12 "api-cache": {
13 "enabled": true,
14 "max-size": 64,
15 "ttl": "5i"
16 },
1217 "static-path": "static",
1318 "download-proxy": "",
1419 "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36",