krz/skunky-art
Alternative privacy frontend for DeviantArt.
clone: git clone https://gitbay.org/krz/skunky-art.git
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}