Commit f3e8d80307

f3e8d803070a8a126b35f419b39ff07cac76a9f0

parent: 7e0547cd17

Verified · cmc ci/build: success ci/lint: success ci/test: success

cmc <hello@cleberg.net> · 2026-09-11 14:31 UTC

Config-less start, and fix the config, cache and search docs

config.json is optional when it is the default path; a file named with
-c must exist. The built-in nsfw default is now false, matching the
example. SETUP.md is one config list with theme and language in it,
then time units, robots.txt and the reverse proxy; the cache entries
say update-interval is seconds, list the d unit, and say max-size
empties the directory. API.md documents t as tag search. The Folders
option on user pages, which was a favourites search, is labelled as
one, and the unused search.folders key is dropped.

Closes #21
Closes #22
Closes #23
Closes #26

Layout: unified · split

API.md +1 −1
@@ -25,7 +25,7 @@ Version and the instance's settings.
25Parameters: 25Parameters:
26 26
27* `q` — required. The search query. 27* `q` — required. The search query.
28* `type` — `a` art (default), `t` text, `g` gallery, `f` favourites. 28* `type` — `a` art (default), `t` tag, `g` gallery, `f` favourites.
29* `usr` — required for `g` and `f`; the user whose gallery or favourites to read. 29* `usr` — required for `g` and `f`; the user whose gallery or favourites to read.
30* `p` — page number, default 0. 30* `p` — page number, default 0.
31 31
SETUP.md +73 −60
@@ -1,38 +1,39 @@
1# Units
2Maximum file size in megabytes, requires numeric value.<br>
3Time units:
4* `i` — minutes
5* `h` — hours
6* `d` — days
7* `w` — weeks
8* `m` — months
9* `y` — years
10
11# Config 1# Config
12* `listen` — IP and port to listen on in the following form: ip:port 2
13* `uri` — Instance URI. Example: `"uri":"/art/"` -> https://skunky.ebloid.ru/art/ 3SkunkyArt reads `config.json` from the working directory, or the file named
14* `cache` — Caching system for proxied media, avatars and emotes. On by default: `path` `cache`, `lifetime` `1w`, `max-size` `200`, `memcache` off. A proxying instance with no cache re-fetches every image from wixmp on every view, which is the pattern that gets an egress IP blocked. 4with `-c`. The default file is optional: without it the built-in defaults
15 * `enabled` — Caching system state, requires boolean value 5below apply. A file named with `-c` must exist.
16 * `path` — Path to cache directory. It must be writable by the user SkunkyArt 6
17 runs as, and SkunkyArt refuses to start if it is not. The container image 7* `listen` — IP and port to listen on, `ip:port`. Default `127.0.0.1:3003`.
18 runs as uid 10000, so a bind-mounted cache needs 8* `uri` — Instance URI. Example: `"uri":"/art/"` -> https://skunky.example.com/art/.
9 Default `/`.
10* `cache` — On-disk cache for proxied media, avatars and emotes. On by
11 default. A proxying instance with no cache re-fetches every image from
12 wixmp on every view, which is the pattern that gets an egress IP blocked.
13 * `enabled` — boolean, default true.
14 * `path` — Cache directory, default `cache`. It must be writable by the
15 user SkunkyArt runs as, and SkunkyArt refuses to start if it is not. The
16 container image runs as uid 10000, so a bind-mounted cache needs
19 `sudo chown -R 10000:10000 <dir>` on the host. 17 `sudo chown -R 10000:10000 <dir>` on the host.
20 * `memcache` — Also keep served media in RAM, on top of the on-disk cache. 18 * `memcache` — Also keep served media in RAM, on top of the on-disk cache.
21 Entries are scored by how often they are requested and dropped once they go 19 Entries are scored by how often they are requested and dropped once they
22 a round unused. The cache is bounded only by that scoring, so leave it off 20 go a round unused. The cache is bounded only by that scoring, so leave it
23 unless you have RAM to spare for your traffic. 21 off unless you have RAM to spare for your traffic. Default false.
24 * `lifetime` — Cached file life time, requires numeric value, followed by multiplicative suffix (see Time Units for details) 22 * `lifetime` — How long a cached file is kept, in the time units below.
25 * `max-size` — Maximum file size in megabytes 23 Default `1w`.
26 * `update-interval` — Automatic rotation interval 24 * `max-size` — Cache size cap in megabytes, default 200. When the cache
25 grows past it the whole directory is emptied, not trimmed.
26 * `update-interval` — Seconds between rotation passes, default 1. Each
27 pass reads the whole cache directory, so raise it on a large cache.
27* `api-cache` — In-memory cache of DeviantArt API responses. Every page, 28* `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 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 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 one upstream call. On by default; DeviantArt bans egress IPs that ask too
31 often, so leave it on unless you are debugging. 32 often, so leave it on unless you are debugging.
32 * `enabled` — boolean, default true 33 * `enabled` — boolean, default true.
33 * `max-size` — megabytes of response bodies to hold, default 64. Least 34 * `max-size` — Megabytes of response bodies to hold, default 64. Least
34 recently used entries are dropped past this. 35 recently used entries are dropped past this.
35 * `ttl` — how long a response is reused, in the time units above. Default 36 * `ttl` — How long a response is reused, in the time units below. Default
36 `5i`. 37 `5i`.
37* `rate-limit` — Per-client budget for page, feed and API requests, so one 38* `rate-limit` — Per-client budget for page, feed and API requests, so one
38 crawler cannot spend the whole upstream budget. Media, avatars and static 39 crawler cannot spend the whole upstream budget. Media, avatars and static
@@ -40,54 +41,31 @@ Time units:
40 Behind a reverse proxy the client is taken from the rightmost 41 Behind a reverse proxy the client is taken from the rightmost
41 `X-Forwarded-For` entry, but only when the connection itself comes from a 42 `X-Forwarded-For` entry, but only when the connection itself comes from a
42 loopback or private address; a direct client's header is ignored. 43 loopback or private address; a direct client's header is ignored.
43 * `per-minute` — sustained requests per minute per client, default 60. 44 * `per-minute` — Sustained requests per minute per client, default 60.
44 `0` turns the limit off. 45 `0` turns the limit off.
45 * `burst` — how many requests a client can make at once before the rate 46 * `burst` — How many requests a client can make at once before the rate
46 applies, default 20. 47 applies, default 20.
47 48* `static-path` — Directory of templates, styles and catalogues, read into
48`/robots.txt` is served automatically. It disallows search, the API, user 49 memory at startup. Default `static`. Ignored by a binary built with the
49and group pages, media and paginated URLs, and asks for a 10 second crawl 50 `embed` tag.
50delay; the index, daily deviations and posts stay crawlable.
51* `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.
52* `download-proxy` — Outbound proxy used when fetching media from DeviantArt's 51* `download-proxy` — Outbound proxy used when fetching media from DeviantArt's
53 CDN. Leave empty (`""`) unless you actually run a proxy: if this points at 52 CDN. Leave empty (`""`) unless you actually run a proxy: if this points at
54 something that isn't listening, every image 502s while pages still render, 53 something that isn't listening, every image 502s while pages still render,
55 because only media fetches go through it. Inside a container `127.0.0.1` is 54 because only media fetches go through it. Inside a container `127.0.0.1` is
56 the container itself, so a host-side proxy must be addressed by service name 55 the container itself, so a host-side proxy must be addressed by service name
57 or host IP, not loopback. 56 or host IP, not loopback.
58* `user-agent` — String, which SkunkyArt uses as UA 57* `user-agent` — The User-Agent SkunkyArt sends to DeviantArt.
59* `proxy` — Serve media through this instance instead of linking straight to 58* `proxy` — Serve media through this instance instead of linking straight to
60 DeviantArt's CDN. Required by `cache`; when off, clients fetch images from 59 DeviantArt's CDN. Required by `cache`; when off, clients fetch images from
61 wixmp directly. 60 wixmp directly. Default true.
62* `nsfw` — Show mature content. 61* `nsfw` — Show mature content. Default false.
63* `hide-ai` — Omit AI-generated deviations (those flagged `[🤖]`) from all 62* `hide-ai` — Omit AI-generated deviations (those flagged `[🤖]`) from all
64 listings: search, daily deviations, galleries and favourites. 63 listings: search, daily deviations, galleries and favourites. Default false.
65
66# Setting up reverse proxy
67Pretty much business as usual, except for the [`X-Forwarded-Proto`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto) header setting.
68
69Nginx example configuration:
70```apache
71server {
72 listen 443 ssl;
73 server_name skunky.example.com;
74
75 # In case of subdomain, use / instend of ((BASE_URL))
76 location ((BASE_URL)) {
77 proxy_set_header X-Forwarded-Proto $scheme;
78 proxy_set_header Host $host;
79 proxy_http_version 1.1;
80 proxy_pass http://((IP)):((PORT));
81 }
82}
83```
84
85* `theme` — Palette for the interface. `auto` (default) serves the dark theme 64* `theme` — Palette for the interface. `auto` (default) serves the dark theme
86 and lets a visitor whose system asks for light get the light one, through 65 and lets a visitor whose system asks for light get the light one, through
87 `prefers-color-scheme` — no cookie, no query string, no JavaScript. `dark` or 66 `prefers-color-scheme` — no cookie, no query string, no JavaScript. `dark` or
88 `light` pins one for everybody. An unrecognised value stops startup rather 67 `light` pins one for everybody. An unrecognised value stops startup rather
89 than quietly falling back. 68 than quietly falling back.
90
91* `language` — Interface language. `auto` (default) reads the browser's own 69* `language` — Interface language. `auto` (default) reads the browser's own
92 `Accept-Language` header, which it sends on every request anyway, so nothing 70 `Accept-Language` header, which it sends on every request anyway, so nothing
93 extra is stored or asked for. A language code (`en`, `es`) pins one for 71 extra is stored or asked for. A language code (`en`, `es`) pins one for
@@ -98,7 +76,42 @@ server {
98 Catalogues live in `static/lang/*.json`, keyed by the strings the templates 76 Catalogues live in `static/lang/*.json`, keyed by the strings the templates
99 ask for. `en.json` is the reference and is always complete; a catalogue that 77 ask for. `en.json` is the reference and is always complete; a catalogue that
100 is missing a key shows the English for that one string, so a partial 78 is missing a key shows the English for that one string, so a partial
101 translation is useful immediately. To add a language, copy `en.json`,translate it, 79 translation is useful immediately. To add a language, copy `en.json`,
102 and name it after the code. 80 translate it, and name it after the code.
103 81
82# Time units
104 83
84A number followed by one of:
85
86* `i` — minutes
87* `h` — hours
88* `d` — days
89* `w` — weeks
90* `m` — months (30 days)
91* `y` — years (360 days)
92
93# robots.txt
94
95`/robots.txt` is served automatically. It disallows search, the API, user
96and group pages, media and paginated URLs, and asks for a 10 second crawl
97delay; the index, daily deviations and posts stay crawlable.
98
99# Setting up reverse proxy
100
101Pretty much business as usual, except for the [`X-Forwarded-Proto`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto) header setting.
102
103Nginx example configuration:
104```apache
105server {
106 listen 443 ssl;
107 server_name skunky.example.com;
108
109 # In case of subdomain, use / instead of ((BASE_URL))
110 location ((BASE_URL)) {
111 proxy_set_header X-Forwarded-Proto $scheme;
112 proxy_set_header Host $host;
113 proxy_http_version 1.1;
114 proxy_pass http://((IP)):((PORT));
115 }
116}
117```
app/cli.go +1
@@ -28,6 +28,7 @@ Copyright lost+skunk and zerolabs, X11. https://github.com/krazywarez/skunky-art
28 case "-c", "--config": 28 case "-c", "--config":
29 if n+1 < len(a) { 29 if n+1 < len(a) {
30 CFG.cfg = a[n+1] 30 CFG.cfg = a[n+1]
31 cfgExplicit = true
31 } else { 32 } else {
32 exit("Not enought arguments", 1) 33 exit("Not enought arguments", 1)
33 } 34 }
app/config.go +70 −57
@@ -83,7 +83,7 @@ var CFG = config{
83 StaticPath: "static", 83 StaticPath: "static",
84 UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", 84 UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36",
85 Proxy: true, 85 Proxy: true,
86 Nsfw: true, 86 Nsfw: false,
87} 87}
88 88
89var lifetimeParsed int64 89var lifetimeParsed int64
@@ -143,76 +143,89 @@ func checkCacheWritable(path string) error {
143 return os.Remove(probe) 143 return os.Remove(probe)
144} 144}
145 145
146// cfgExplicit records that -c named the config file, so a missing one is an
147// error rather than a fall-back to the defaults.
148var cfgExplicit bool
149
146// ExecuteConfig loads the config file into CFG, validates it, and starts the 150// ExecuteConfig loads the config file into CFG, validates it, and starts the
147// cache rotation loop if caching is on. It exits the process on a config that 151// cache rotation loop if caching is on. A missing default config.json means
148// cannot be read, that asks for caching without proxying, or that points caching 152// the built-in defaults; a missing file named with -c exits. It also exits on
149// at a directory this process cannot write. 153// a config that cannot be parsed, that asks for caching without proxying, or
154// that points caching at a directory this process cannot write.
150func ExecuteConfig() { 155func ExecuteConfig() {
151 if CFG.cfg != "" { 156 f, err := os.ReadFile(CFG.cfg)
152 f, err := os.ReadFile(CFG.cfg) 157 switch {
153 tryWithExitStatus(err, 1) 158 case err == nil:
154 tryWithExitStatus(json.Unmarshal(f, &CFG), 1) 159 tryWithExitStatus(json.Unmarshal(f, &CFG), 1)
155 if CFG.Cache.Enabled && !CFG.Proxy { 160 case os.IsNotExist(err) && !cfgExplicit:
156 exit("Incompatible settings detected: cannot use caching media content without proxy", 1) 161 // The default file is optional: the built-in defaults are a working
157 } 162 // instance. A path given with -c is not, since a typo there would
158 163 // otherwise start something the operator did not configure.
159 if CFG.Cache.Enabled { 164 println("no", CFG.cfg, "found; running with the built-in defaults")
160 if err := checkCacheWritable(CFG.Cache.Path); err != nil { 165 default:
161 exit("Cache directory is not writable by this process (uid "+ 166 tryWithExitStatus(err, 1)
162 strconv.Itoa(os.Getuid())+"): "+err.Error()+ 167 }
163 "\nGrant that uid write access to the directory, or set cache.enabled to false."+
164 "\nThe official container image runs as uid 10000, so a bind-mounted cache needs:"+
165 "\n chown -R 10000:10000 <cache dir on the host>", 1)
166 }
167
168 if CFG.Cache.Lifetime != "" {
169 d, err := parseLifetime(CFG.Cache.Lifetime)
170 if err != nil {
171 exit("config: cache.lifetime: "+err.Error(), 1)
172 }
173 lifetimeParsed = d.Milliseconds()
174 }
175 // max-size is documented in megabytes. This was 1024^2, which in Go is
176 // XOR (1026), not exponentiation — so the cap was ~1000x too small.
177 CFG.Cache.MaxSize *= 1024 * 1024
178 go InitCacheSystem()
179 if CFG.Cache.MemCache {
180 go InitMemCacheJanitor()
181 }
182 }
183 168
184 About = instanceAbout{ 169 if CFG.Cache.Enabled && !CFG.Proxy {
185 Proxy: CFG.Proxy, 170 exit("Incompatible settings detected: cannot use caching media content without proxy", 1)
186 Nsfw: CFG.Nsfw, 171 }
187 HideAI: CFG.HideAI,
188 Theme: CFG.Theme,
189 }
190 172
191 // A theme the stylesheet cannot honour would silently fall back to auto, 173 if CFG.Cache.Enabled {
192 // so say so instead. 174 if err := checkCacheWritable(CFG.Cache.Path); err != nil {
193 switch CFG.Theme { 175 exit("Cache directory is not writable by this process (uid "+
194 case "auto", "dark", "light": 176 strconv.Itoa(os.Getuid())+"): "+err.Error()+
195 default: 177 "\nGrant that uid write access to the directory, or set cache.enabled to false."+
196 exit("config: theme must be one of auto, dark, light; got "+CFG.Theme, 1) 178 "\nThe official container image runs as uid 10000, so a bind-mounted cache needs:"+
179 "\n chown -R 10000:10000 <cache dir on the host>", 1)
197 } 180 }
198 181
199 if CFG.APICache.Enabled { 182 if CFG.Cache.Lifetime != "" {
200 d, err := parseLifetime(CFG.APICache.TTL) 183 d, err := parseLifetime(CFG.Cache.Lifetime)
201 if err != nil { 184 if err != nil {
202 exit("config: api-cache.ttl: "+err.Error(), 1) 185 exit("config: cache.lifetime: "+err.Error(), 1)
203 } 186 }
204 apiCacheTTL = d 187 lifetimeParsed = d.Milliseconds()
188 }
189 // max-size is documented in megabytes. This was 1024^2, which in Go is
190 // XOR (1026), not exponentiation — so the cap was ~1000x too small.
191 CFG.Cache.MaxSize *= 1024 * 1024
192 go InitCacheSystem()
193 if CFG.Cache.MemCache {
194 go InitMemCacheJanitor()
205 } 195 }
196 }
197
198 About = instanceAbout{
199 Proxy: CFG.Proxy,
200 Nsfw: CFG.Nsfw,
201 HideAI: CFG.HideAI,
202 Theme: CFG.Theme,
203 }
206 204
207 // per-minute 0 turns the limit off; a burst below one token would 205 // A theme the stylesheet cannot honour would silently fall back to auto,
208 // refuse every request, so it is floored to one. 206 // so say so instead.
209 if CFG.RateLimit.PerMinute > 0 { 207 switch CFG.Theme {
210 daLimiter = newRateLimiter(CFG.RateLimit.PerMinute, max(CFG.RateLimit.Burst, 1)) 208 case "auto", "dark", "light":
209 default:
210 exit("config: theme must be one of auto, dark, light; got "+CFG.Theme, 1)
211 }
212
213 if CFG.APICache.Enabled {
214 d, err := parseLifetime(CFG.APICache.TTL)
215 if err != nil {
216 exit("config: api-cache.ttl: "+err.Error(), 1)
211 } 217 }
218 apiCacheTTL = d
219 }
212 220
213 static.StaticPath = CFG.StaticPath 221 // per-minute 0 turns the limit off; a burst below one token would
214 devianter.UserAgent = CFG.UserAgent 222 // refuse every request, so it is floored to one.
223 if CFG.RateLimit.PerMinute > 0 {
224 daLimiter = newRateLimiter(CFG.RateLimit.PerMinute, max(CFG.RateLimit.Burst, 1))
215 } 225 }
226
227 static.StaticPath = CFG.StaticPath
228 devianter.UserAgent = CFG.UserAgent
216} 229}
217 230
218// forcedThemeCSS returns a block that pins the palette when the instance has 231// forcedThemeCSS returns a block that pins the palette when the instance has
app/config_test.go +37
@@ -43,3 +43,40 @@ func TestMediaCacheDefaults(t *testing.T) {
43 t.Errorf("defaults are %+v, want enabled, 1w, 200 MB, memcache off", c) 43 t.Errorf("defaults are %+v, want enabled, 1w, 200 MB, memcache off", c)
44 } 44 }
45} 45}
46
47// withScratchConfig points CFG at a config path under a temporary directory,
48// with the media cache off so no rotation goroutine starts, and restores the
49// whole configuration afterwards.
50func withScratchConfig(t *testing.T, path string, explicit bool) {
51 t.Helper()
52 cfg, limiter, exp := CFG, daLimiter, cfgExplicit
53 CFG.cfg = path
54 CFG.Cache.Enabled = false
55 cfgExplicit = explicit
56 t.Cleanup(func() { CFG, daLimiter, cfgExplicit = cfg, limiter, exp })
57}
58
59func TestExecuteConfigRunsWithoutTheDefaultFile(t *testing.T) {
60 withScratchConfig(t, t.TempDir()+"/config.json", false)
61 msgs := captureExit(t)
62
63 ExecuteConfig()
64
65 if len(*msgs) != 0 {
66 t.Errorf("exit called with %v; want a start on the built-in defaults", *msgs)
67 }
68 if CFG.Listen != "127.0.0.1:3003" || CFG.Nsfw {
69 t.Errorf("defaults not in effect: listen %q nsfw %v", CFG.Listen, CFG.Nsfw)
70 }
71}
72
73func TestExecuteConfigExitsOnAMissingExplicitFile(t *testing.T) {
74 withScratchConfig(t, t.TempDir()+"/named.json", true)
75 msgs := captureExit(t)
76
77 ExecuteConfig()
78
79 if len(*msgs) == 0 {
80 t.Error("a missing file named with -c started the instance, want an exit")
81 }
82}
config.example.json +1 −1
@@ -4,7 +4,7 @@
4 "cache": { 4 "cache": {
5 "enabled": true, 5 "enabled": true,
6 "path": "cache", 6 "path": "cache",
7 "lifetime": null, 7 "lifetime": "1w",
8 "max-size": 200, 8 "max-size": 200,
9 "memcache": false, 9 "memcache": false,
10 "update-interval": 5 10 "update-interval": 5
static/html/gruser.htm +1 −1
@@ -21,7 +21,7 @@
21 <input type="hidden" name="usr" value="{{.Templates.GroupUser.GR.Owner.Username}}"> 21 <input type="hidden" name="usr" value="{{.Templates.GroupUser.GR.Owner.Username}}">
22 <select name="type" aria-label="Search type"> 22 <select name="type" aria-label="Search type">
23 <option value="gallery">{{T "nav.gallery"}}</option> 23 <option value="gallery">{{T "nav.gallery"}}</option>
24 <option value="folders">{{T "search.folders"}}</option> 24 <option value="favourites">{{T "nav.favourites"}}</option>
25 <option value="all">{{T "search.all"}}</option> 25 <option value="all">{{T "search.all"}}</option>
26 <option value="tag">{{T "search.tag"}}</option> 26 <option value="tag">{{T "search.tag"}}</option>
27 <option value="r">{{T "search.groups"}}</option> 27 <option value="r">{{T "search.groups"}}</option>
static/lang/en.json −1
@@ -6,7 +6,6 @@
6 "nav.rss": "RSS", 6 "nav.rss": "RSS",
7 "search.placeholder": "Search for ...", 7 "search.placeholder": "Search for ...",
8 "search.submit": "Search!", 8 "search.submit": "Search!",
9 "search.folders": "Folders",
10 "search.all": "All", 9 "search.all": "All",
11 "search.tag": "Tag", 10 "search.tag": "Tag",
12 "search.groups": "Groups", 11 "search.groups": "Groups",
static/lang/es.json −1
@@ -30,7 +30,6 @@
30 "nav.home": "INICIO", 30 "nav.home": "INICIO",
31 "nav.rss": "RSS", 31 "nav.rss": "RSS",
32 "search.all": "Todo", 32 "search.all": "Todo",
33 "search.folders": "Carpetas",
34 "search.groups": "Grupos", 33 "search.groups": "Grupos",
35 "search.placeholder": "Buscar ...", 34 "search.placeholder": "Buscar ...",
36 "search.submit": "¡Buscar!", 35 "search.submit": "¡Buscar!",