app/config.go

v1.5.3
skunky-art/app/config.go history · blame · raw

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}