krz/skunky-art

Alternative privacy frontend for DeviantArt.

clone: git clone https://gitbay.org/krz/skunky-art.git

main: app/config.go · raw

  1package app
  2
  3import (
  4	"encoding/json"
  5	"os"
  6	"regexp"
  7	"skunkyart/static"
  8	"strconv"
  9	"time"
 10
 11	"github.com/krazywarez/devianter"
 12)
 13
 14// Release carries the build's version and description, set at link time and
 15// shown by --help and the API.
 16var Release struct {
 17	Version     string
 18	Description string
 19}
 20
 21type cacheConfig struct {
 22	Enabled        bool   `json:"enabled"`
 23	MemCache       bool   `json:"memcache"`
 24	Path           string `json:"path"`
 25	MaxSize        int64  `json:"max-size"`
 26	Lifetime       string `json:"lifetime"`
 27	UpdateInterval int64  `json:"update-interval"`
 28}
 29
 30type config struct {
 31	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"`
 43}
 44
 45// CFG is the running instance's configuration, holding the defaults below until
 46// ExecuteConfig overwrites them from the config file.
 47var CFG = config{
 48	cfg:      "config.json",
 49	Listen:   "127.0.0.1:3003",
 50	Theme:    "auto",
 51	Language: "auto",
 52	URI:      "/",
 53	Cache: cacheConfig{
 54		Enabled:        false,
 55		Path:           "cache",
 56		UpdateInterval: 1,
 57	},
 58	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",
 60	Proxy:      true,
 61	Nsfw:       true,
 62}
 63
 64var lifetimeParsed int64
 65
 66// 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
 68// write would hit.
 69//
 70// An unwritable cache directory is otherwise a silent cliff: every media request
 71// still succeeds by re-downloading from the CDN, so the only symptom is one
 72// "permission denied" line per request and a cache that never fills.
 73func checkCacheWritable(path string) error {
 74	if err := os.MkdirAll(path, 0700); err != nil {
 75		return err
 76	}
 77	probe := path + "/.skunkyart-write-probe"
 78	if err := os.WriteFile(probe, nil, 0600); err != nil {
 79		return err
 80	}
 81	return os.Remove(probe)
 82}
 83
 84// ExecuteConfig loads the config file into CFG, validates it, and starts the
 85// cache rotation loop if caching is on. It exits the process on a config that
 86// cannot be read, that asks for caching without proxying, or that points caching
 87// at a directory this process cannot write.
 88func ExecuteConfig() {
 89	if CFG.cfg != "" {
 90		f, err := os.ReadFile(CFG.cfg)
 91		tryWithExitStatus(err, 1)
 92		tryWithExitStatus(json.Unmarshal(f, &CFG), 1)
 93		if CFG.Cache.Enabled && !CFG.Proxy {
 94			exit("Incompatible settings detected: cannot use caching media content without proxy", 1)
 95		}
 96
 97		if CFG.Cache.Enabled {
 98			if err := checkCacheWritable(CFG.Cache.Path); err != nil {
 99				exit("Cache directory is not writable by this process (uid "+
100					strconv.Itoa(os.Getuid())+"): "+err.Error()+
101					"\nGrant that uid write access to the directory, or set cache.enabled to false."+
102					"\nThe official container image runs as uid 10000, so a bind-mounted cache needs:"+
103					"\n  chown -R 10000:10000 <cache dir on the host>", 1)
104			}
105
106			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)
127				}
128
129				lifetimeParsed = duration * int64(num)
130			}
131			// 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.
133			CFG.Cache.MaxSize *= 1024 * 1024
134			go InitCacheSystem()
135			if CFG.Cache.MemCache {
136				go InitMemCacheJanitor()
137			}
138		}
139
140		About = instanceAbout{
141			Proxy:  CFG.Proxy,
142			Nsfw:   CFG.Nsfw,
143			HideAI: CFG.HideAI,
144			Theme:  CFG.Theme,
145		}
146
147		// A theme the stylesheet cannot honour would silently fall back to auto,
148		// so say so instead.
149		switch CFG.Theme {
150		case "auto", "dark", "light":
151		default:
152			exit("config: theme must be one of auto, dark, light; got "+CFG.Theme, 1)
153		}
154
155		static.StaticPath = CFG.StaticPath
156		devianter.UserAgent = CFG.UserAgent
157	}
158}
159
160// forcedThemeCSS returns a block that pins the palette when the instance has
161// chosen a theme, or "" for "auto". Light repeats what the prefers-color-scheme
162// block already holds; dark repeats :root. Both are emitted after the
163// stylesheet so they win on order rather than on !important.
164func forcedThemeCSS() string {
165	switch CFG.Theme {
166	case "light":
167		return `
168: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}`
169	case "dark":
170		return `
171: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)}`
172	}
173	return ""
174}