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>
3Time units: 3Time units:
4* `i` — minutes 4* `i` — minutes
5* `h` — hours 5* `h` — hours
6* `d` — days
6* `w` — weeks 7* `w` — weeks
7* `m` — months 8* `m` — months
8* `y` — years 9* `y` — years
@@ -23,6 +24,16 @@ Time units:
23 * `lifetime` — Cached file life time, requires numeric value, followed by multiplicative suffix (see Time Units for details) 24 * `lifetime` — Cached file life time, requires numeric value, followed by multiplicative suffix (see Time Units for details)
24 * `max-size` — Maximum file size in megabytes 25 * `max-size` — Maximum file size in megabytes
25 * `update-interval` — Automatic rotation interval 26 * `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`.
26* `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. 37* `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.
27* `download-proxy` — Outbound proxy used when fetching media from DeviantArt's 38* `download-proxy` — Outbound proxy used when fetching media from DeviantArt's
28 CDN. Leave empty (`""`) unless you actually run a proxy: if this points at 39 CDN. Leave empty (`""`) unless you actually run a proxy: if this points at
app/config.go +73 −33
@@ -2,6 +2,7 @@ package app
2 2
3import ( 3import (
4 "encoding/json" 4 "encoding/json"
5 "errors"
5 "os" 6 "os"
6 "regexp" 7 "regexp"
7 "skunkyart/static" 8 "skunkyart/static"
@@ -27,19 +28,26 @@ type cacheConfig struct {
27 UpdateInterval int64 `json:"update-interval"` 28 UpdateInterval int64 `json:"update-interval"`
28} 29}
29 30
31type apiCacheConfig struct {
32 Enabled bool `json:"enabled"`
33 MaxSize int64 `json:"max-size"`
34 TTL string `json:"ttl"`
35}
36
30type config struct { 37type config struct {
31 cfg string 38 cfg string
32 Listen string `json:"listen"` 39 Listen string `json:"listen"`
33 URI string `json:"uri"` 40 URI string `json:"uri"`
34 Cache cacheConfig `json:"cache"` 41 Cache cacheConfig `json:"cache"`
35 Proxy bool `json:"proxy"` 42 APICache apiCacheConfig `json:"api-cache"`
36 Nsfw bool `json:"nsfw"` 43 Proxy bool `json:"proxy"`
37 HideAI bool `json:"hide-ai"` 44 Nsfw bool `json:"nsfw"`
38 Theme string `json:"theme"` 45 HideAI bool `json:"hide-ai"`
39 Language string `json:"language"` 46 Theme string `json:"theme"`
40 UserAgent string `json:"user-agent"` 47 Language string `json:"language"`
41 DownloadProxy string `json:"download-proxy"` 48 UserAgent string `json:"user-agent"`
42 StaticPath string `json:"static-path"` 49 DownloadProxy string `json:"download-proxy"`
50 StaticPath string `json:"static-path"`
43} 51}
44 52
45// CFG is the running instance's configuration, holding the defaults below until 53// CFG is the running instance's configuration, holding the defaults below until
@@ -55,6 +63,11 @@ var CFG = config{
55 Path: "cache", 63 Path: "cache",
56 UpdateInterval: 1, 64 UpdateInterval: 1,
57 }, 65 },
66 APICache: apiCacheConfig{
67 Enabled: true,
68 MaxSize: 64,
69 TTL: "5i",
70 },
58 StaticPath: "static", 71 StaticPath: "static",
59 UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", 72 UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36",
60 Proxy: true, 73 Proxy: true,
@@ -63,6 +76,43 @@ var CFG = config{
63 76
64var lifetimeParsed int64 77var lifetimeParsed int64
65 78
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
66// checkCacheWritable creates the cache directory if it is missing and confirms 116// checkCacheWritable creates the cache directory if it is missing and confirms
67// this process can actually write into it, returning the error that a real cache 117// this process can actually write into it, returning the error that a real cache
68// write would hit. 118// write would hit.
@@ -104,29 +154,11 @@ func ExecuteConfig() {
104 } 154 }
105 155
106 if CFG.Cache.Lifetime != "" { 156 if CFG.Cache.Lifetime != "" {
107 var duration int64 157 d, err := parseLifetime(CFG.Cache.Lifetime)
108 day := 24 * time.Hour.Milliseconds() 158 if err != nil {
109 numstr := regexp.MustCompile("[0-9]+").FindAllString(CFG.Cache.Lifetime, -1) 159 exit("config: cache.lifetime: "+err.Error(), 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)
127 } 160 }
128 161 lifetimeParsed = d.Milliseconds()
129 lifetimeParsed = duration * int64(num)
130 } 162 }
131 // max-size is documented in megabytes. This was 1024^2, which in Go is 163 // max-size is documented in megabytes. This was 1024^2, which in Go is
132 // XOR (1026), not exponentiation — so the cap was ~1000x too small. 164 // XOR (1026), not exponentiation — so the cap was ~1000x too small.
@@ -152,6 +184,14 @@ func ExecuteConfig() {
152 exit("config: theme must be one of auto, dark, light; got "+CFG.Theme, 1) 184 exit("config: theme must be one of auto, dark, light; got "+CFG.Theme, 1)
153 } 185 }
154 186
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
155 static.StaticPath = CFG.StaticPath 195 static.StaticPath = CFG.StaticPath
156 devianter.UserAgent = CFG.UserAgent 196 devianter.UserAgent = CFG.UserAgent
157 } 197 }
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 @@
9 "memcache": false, 9 "memcache": false,
10 "update-interval": 5 10 "update-interval": 5
11 }, 11 },
12 "api-cache": {
13 "enabled": true,
14 "max-size": 64,
15 "ttl": "5i"
16 },
12 "static-path": "static", 17 "static-path": "static",
13 "download-proxy": "", 18 "download-proxy": "",
14 "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", 19 "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",