app/config.go
271 lines · 8667 bytes · executable
1package app
2
3import (
4 "encoding/json"
5 "errors"
6 "os"
7 "regexp"
8 "skunkyart/static"
9 "strconv"
10 "time"
11
12 "github.com/krazywarez/devianter"
13)
14
15// Release carries the build's version and description, set at link time and
16// shown by --help and the API.
17var Release struct {
18 Version string
19 Description string
20}
21
22type cacheConfig struct {
23 Enabled bool `json:"enabled"`
24 MemCache bool `json:"memcache"`
25 Path string `json:"path"`
26 MaxSize int64 `json:"max-size"`
27 Lifetime string `json:"lifetime"`
28 UpdateInterval int64 `json:"update-interval"`
29}
30
31type apiCacheConfig struct {
32 Enabled bool `json:"enabled"`
33 MaxSize int64 `json:"max-size"`
34 TTL string `json:"ttl"`
35 Stale string `json:"stale"`
36}
37
38type rateLimitConfig struct {
39 PerMinute int `json:"per-minute"`
40 Burst int `json:"burst"`
41}
42
43// upstreamConfig paces the instance's own requests to DeviantArt.
44type upstreamConfig struct {
45 MinIntervalMS int `json:"min-interval-ms"`
46 MaxConcurrent int `json:"max-concurrent"`
47}
48
49type config struct {
50 cfg string
51 Listen string `json:"listen"`
52 URI string `json:"uri"`
53 Cache cacheConfig `json:"cache"`
54 APICache apiCacheConfig `json:"api-cache"`
55 RateLimit rateLimitConfig `json:"rate-limit"`
56 Upstream upstreamConfig `json:"upstream"`
57 Proxy bool `json:"proxy"`
58 Nsfw bool `json:"nsfw"`
59 HideAI bool `json:"hide-ai"`
60 Theme string `json:"theme"`
61 Language string `json:"language"`
62 UserAgent string `json:"user-agent"`
63 DownloadProxy string `json:"download-proxy"`
64 StaticPath string `json:"static-path"`
65}
66
67// CFG is the running instance's configuration, holding the defaults below until
68// ExecuteConfig overwrites them from the config file.
69var CFG = config{
70 cfg: "config.json",
71 Listen: "127.0.0.1:3003",
72 Theme: "auto",
73 Language: "auto",
74 URI: "/",
75 Cache: cacheConfig{
76 Enabled: true,
77 Path: "cache",
78 Lifetime: "1w",
79 MaxSize: 200,
80 UpdateInterval: 1,
81 },
82 APICache: apiCacheConfig{
83 Enabled: true,
84 MaxSize: 64,
85 TTL: "5i",
86 Stale: "1h",
87 },
88 RateLimit: rateLimitConfig{
89 PerMinute: 60,
90 Burst: 20,
91 },
92 Upstream: upstreamConfig{
93 MinIntervalMS: 400,
94 MaxConcurrent: 2,
95 },
96 StaticPath: "static",
97 UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36",
98 Proxy: true,
99 Nsfw: false,
100}
101
102var lifetimeParsed int64
103
104// apiCacheTTL and apiCacheStale are api-cache.ttl and api-cache.stale parsed,
105// set by ExecuteConfig.
106var apiCacheTTL, apiCacheStale time.Duration
107
108// parseLifetime reads a duration in the config's unit syntax: a number
109// followed by i (minutes), h (hours), d (days), w (weeks), m (30-day
110// months) or y (360-day years).
111func parseLifetime(s string) (time.Duration, error) {
112 if s == "" {
113 return 0, errors.New("empty lifetime")
114 }
115 numstr := regexp.MustCompile("[0-9]+").FindAllString(s, -1)
116 if len(numstr) == 0 {
117 return 0, errors.New("lifetime has no number: " + s)
118 }
119 num, _ := strconv.Atoi(numstr[len(numstr)-1])
120
121 day := 24 * time.Hour
122 var unit time.Duration
123 switch s[len(s)-1:] {
124 case "i":
125 unit = time.Minute
126 case "h":
127 unit = time.Hour
128 case "d":
129 unit = day
130 case "w":
131 unit = 7 * day
132 case "m":
133 unit = 30 * day
134 case "y":
135 unit = 360 * day
136 default:
137 return 0, errors.New("invalid unit specified: " + s[len(s)-1:])
138 }
139 return unit * time.Duration(num), nil
140}
141
142// checkCacheWritable creates the cache directory if it is missing and confirms
143// this process can actually write into it, returning the error that a real cache
144// write would hit.
145//
146// An unwritable cache directory is otherwise a silent cliff: every media request
147// still succeeds by re-downloading from the CDN, so the only symptom is one
148// "permission denied" line per request and a cache that never fills.
149func checkCacheWritable(path string) error {
150 if err := os.MkdirAll(path, 0700); err != nil {
151 return err
152 }
153 probe := path + "/.skunkyart-write-probe"
154 if err := os.WriteFile(probe, nil, 0600); err != nil {
155 return err
156 }
157 return os.Remove(probe)
158}
159
160// cfgExplicit records that -c named the config file, so a missing one is an
161// error rather than a fall-back to the defaults.
162var cfgExplicit bool
163
164// ExecuteConfig loads the config file into CFG, validates it, and starts the
165// cache rotation loop if caching is on. A missing default config.json means
166// the built-in defaults; a missing file named with -c exits. It also exits on
167// a config that cannot be parsed, that asks for caching without proxying, or
168// that points caching at a directory this process cannot write.
169func ExecuteConfig() {
170 f, err := os.ReadFile(CFG.cfg)
171 switch {
172 case err == nil:
173 tryWithExitStatus(json.Unmarshal(f, &CFG), 1)
174 case os.IsNotExist(err) && !cfgExplicit:
175 // The default file is optional: the built-in defaults are a working
176 // instance. A path given with -c is not, since a typo there would
177 // otherwise start something the operator did not configure.
178 println("no", CFG.cfg, "found; running with the built-in defaults")
179 default:
180 tryWithExitStatus(err, 1)
181 }
182
183 if CFG.Cache.Enabled && !CFG.Proxy {
184 exit("Incompatible settings detected: cannot use caching media content without proxy", 1)
185 }
186
187 if CFG.Cache.Enabled {
188 if err := checkCacheWritable(CFG.Cache.Path); err != nil {
189 exit("Cache directory is not writable by this process (uid "+
190 strconv.Itoa(os.Getuid())+"): "+err.Error()+
191 "\nGrant that uid write access to the directory, or set cache.enabled to false."+
192 "\nThe official container image runs as uid 10000, so a bind-mounted cache needs:"+
193 "\n chown -R 10000:10000 <cache dir on the host>", 1)
194 }
195
196 if CFG.Cache.Lifetime != "" {
197 d, err := parseLifetime(CFG.Cache.Lifetime)
198 if err != nil {
199 exit("config: cache.lifetime: "+err.Error(), 1)
200 }
201 lifetimeParsed = d.Milliseconds()
202 }
203 // max-size is documented in megabytes. This was 1024^2, which in Go is
204 // XOR (1026), not exponentiation — so the cap was ~1000x too small.
205 CFG.Cache.MaxSize *= 1024 * 1024
206 go InitCacheSystem()
207 if CFG.Cache.MemCache {
208 go InitMemCacheJanitor()
209 }
210 }
211
212 About = instanceAbout{
213 Proxy: CFG.Proxy,
214 Nsfw: CFG.Nsfw,
215 HideAI: CFG.HideAI,
216 Theme: CFG.Theme,
217 }
218
219 // A theme the stylesheet cannot honour would silently fall back to auto,
220 // so say so instead.
221 switch CFG.Theme {
222 case "auto", "dark", "light":
223 default:
224 exit("config: theme must be one of auto, dark, light; got "+CFG.Theme, 1)
225 }
226
227 if CFG.APICache.Enabled {
228 d, err := parseLifetime(CFG.APICache.TTL)
229 if err != nil {
230 exit("config: api-cache.ttl: "+err.Error(), 1)
231 }
232 apiCacheTTL = d
233 if CFG.APICache.Stale != "" {
234 d, err := parseLifetime(CFG.APICache.Stale)
235 if err != nil {
236 exit("config: api-cache.stale: "+err.Error(), 1)
237 }
238 apiCacheStale = d
239 }
240 }
241
242 // per-minute 0 turns the limit off; a burst below one token would
243 // refuse every request, so it is floored to one.
244 if CFG.RateLimit.PerMinute > 0 {
245 daLimiter = newRateLimiter(CFG.RateLimit.PerMinute, max(CFG.RateLimit.Burst, 1))
246 }
247
248 // Read here so InstallDAThrottle, which runs next, builds the throttle
249 // from the file rather than the source defaults.
250 daMinInterval = time.Duration(max(CFG.Upstream.MinIntervalMS, 0)) * time.Millisecond
251 daMaxConcurrent = max(CFG.Upstream.MaxConcurrent, 1)
252
253 static.StaticPath = CFG.StaticPath
254 devianter.UserAgent = CFG.UserAgent
255}
256
257// forcedThemeCSS returns a block that pins the palette when the instance has
258// chosen a theme, or "" for "auto". Light repeats what the prefers-color-scheme
259// block already holds; dark repeats :root. Both are emitted after the
260// stylesheet so they win on order rather than on !important.
261func forcedThemeCSS() string {
262 switch CFG.Theme {
263 case "light":
264 return `
265:root{--bg:#f4f1ee;--fg:#1f2421;--fg-strong:#0d100e;--link:#1c6b78;--link-hover:#5a6b00;--edge:#8fbcae;--edge-strong:#258268;--accent:#4d27d6;--surface:#d9e8e1;--surface-sunken:#e8f0ec;--surface-alt:#e2e4f2;--surface-deep:#dbe7ef;--status-bad:#a11;--status-good:#157a3a;--status-mild:#2e8b57;--status-note:#8a007f}`
266 case "dark":
267 return `
268:root{--bg:black;--fg:rgb(234,216,216);--fg-strong:whitesmoke;--link:cadetblue;--link-hover:#d0ff00;--edge:#164e3e;--edge-strong:#258268;--accent:#4d27d6;--surface:#134134;--surface-sunken:#091f19;--surface-alt:#060820;--surface-deep:#011522;--status-bad:red;--status-good:green;--status-mild:seagreen;--status-note:rgb(160,0,147)}`
269 }
270 return ""
271}