Commit f3e8d80307
Verified · cmc ci/build: success ci/lint: success ci/test: success
Layout: unified · split
API.md +1 −1
| @@ -25,7 +25,7 @@ Version and the instance's settings. | ||
| 25 | 25 | Parameters: |
| 26 | 26 | |
| 27 | 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 | 29 | * `usr` — required for `g` and `f`; the user whose gallery or favourites to read. |
| 30 | 30 | * `p` — page number, default 0. |
| 31 | 31 | |
SETUP.md +73 −60
| @@ -1,38 +1,39 @@ | ||
| 1 | # Units | |
| 2 | Maximum file size in megabytes, requires numeric value.<br> | |
| 3 | Time units: | |
| 4 | * `i` — minutes | |
| 5 | * `h` — hours | |
| 6 | * `d` — days | |
| 7 | * `w` — weeks | |
| 8 | * `m` — months | |
| 9 | * `y` — years | |
| 10 | ||
| 11 | 1 | # Config |
| 12 | * `listen` — IP and port to listen on in the following form: ip:port | |
| 13 | * `uri` — Instance URI. Example: `"uri":"/art/"` -> https://skunky.ebloid.ru/art/ | |
| 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. | |
| 15 | * `enabled` — Caching system state, requires boolean value | |
| 16 | * `path` — Path to cache directory. It must be writable by the user SkunkyArt | |
| 17 | runs as, and SkunkyArt refuses to start if it is not. The container image | |
| 18 | runs as uid 10000, so a bind-mounted cache needs | |
| 2 | ||
| 3 | SkunkyArt reads `config.json` from the working directory, or the file named | |
| 4 | with `-c`. The default file is optional: without it the built-in defaults | |
| 5 | below apply. A file named with `-c` must exist. | |
| 6 | ||
| 7 | * `listen` — IP and port to listen on, `ip:port`. Default `127.0.0.1:3003`. | |
| 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 | 17 | `sudo chown -R 10000:10000 <dir>` on the host. |
| 20 | 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 | |
| 22 | a round unused. The cache is bounded only by that scoring, so leave it off | |
| 23 | unless you have RAM to spare for your traffic. | |
| 24 | * `lifetime` — Cached file life time, requires numeric value, followed by multiplicative suffix (see Time Units for details) | |
| 25 | * `max-size` — Maximum file size in megabytes | |
| 26 | * `update-interval` — Automatic rotation interval | |
| 19 | Entries are scored by how often they are requested and dropped once they | |
| 20 | go a round unused. The cache is bounded only by that scoring, so leave it | |
| 21 | off unless you have RAM to spare for your traffic. Default false. | |
| 22 | * `lifetime` — How long a cached file is kept, in the time units below. | |
| 23 | Default `1w`. | |
| 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 | 28 | * `api-cache` — In-memory cache of DeviantArt API responses. Every page, |
| 28 | 29 | feed poll and API call that asks DeviantArt the same question within the |
| 29 | 30 | TTL is answered from memory, and concurrent requests for one thing make |
| 30 | 31 | one upstream call. On by default; DeviantArt bans egress IPs that ask too |
| 31 | 32 | often, so leave it on unless you are debugging. |
| 32 | * `enabled` — boolean, default true | |
| 33 | * `max-size` — megabytes of response bodies to hold, default 64. Least | |
| 33 | * `enabled` — boolean, default true. | |
| 34 | * `max-size` — Megabytes of response bodies to hold, default 64. Least | |
| 34 | 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 | 37 | `5i`. |
| 37 | 38 | * `rate-limit` — Per-client budget for page, feed and API requests, so one |
| 38 | 39 | crawler cannot spend the whole upstream budget. Media, avatars and static |
| @@ -40,54 +41,31 @@ Time units: | ||
| 40 | 41 | Behind a reverse proxy the client is taken from the rightmost |
| 41 | 42 | `X-Forwarded-For` entry, but only when the connection itself comes from a |
| 42 | 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 | 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 | 47 | applies, default 20. |
| 47 | ||
| 48 | `/robots.txt` is served automatically. It disallows search, the API, user | |
| 49 | and group pages, media and paginated URLs, and asks for a 10 second crawl | |
| 50 | delay; 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. | |
| 48 | * `static-path` — Directory of templates, styles and catalogues, read into | |
| 49 | memory at startup. Default `static`. Ignored by a binary built with the | |
| 50 | `embed` tag. | |
| 52 | 51 | * `download-proxy` — Outbound proxy used when fetching media from DeviantArt's |
| 53 | 52 | CDN. Leave empty (`""`) unless you actually run a proxy: if this points at |
| 54 | 53 | something that isn't listening, every image 502s while pages still render, |
| 55 | 54 | because only media fetches go through it. Inside a container `127.0.0.1` is |
| 56 | 55 | the container itself, so a host-side proxy must be addressed by service name |
| 57 | 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 | 58 | * `proxy` — Serve media through this instance instead of linking straight to |
| 60 | 59 | DeviantArt's CDN. Required by `cache`; when off, clients fetch images from |
| 61 | wixmp directly. | |
| 62 | * `nsfw` — Show mature content. | |
| 60 | wixmp directly. Default true. | |
| 61 | * `nsfw` — Show mature content. Default false. | |
| 63 | 62 | * `hide-ai` — Omit AI-generated deviations (those flagged `[🤖]`) from all |
| 64 | listings: search, daily deviations, galleries and favourites. | |
| 65 | ||
| 66 | # Setting up reverse proxy | |
| 67 | Pretty 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 | ||
| 69 | Nginx example configuration: | |
| 70 | ```apache | |
| 71 | server { | |
| 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 | ||
| 63 | listings: search, daily deviations, galleries and favourites. Default false. | |
| 85 | 64 | * `theme` — Palette for the interface. `auto` (default) serves the dark theme |
| 86 | 65 | and lets a visitor whose system asks for light get the light one, through |
| 87 | 66 | `prefers-color-scheme` — no cookie, no query string, no JavaScript. `dark` or |
| 88 | 67 | `light` pins one for everybody. An unrecognised value stops startup rather |
| 89 | 68 | than quietly falling back. |
| 90 | ||
| 91 | 69 | * `language` — Interface language. `auto` (default) reads the browser's own |
| 92 | 70 | `Accept-Language` header, which it sends on every request anyway, so nothing |
| 93 | 71 | extra is stored or asked for. A language code (`en`, `es`) pins one for |
| @@ -98,7 +76,42 @@ server { | ||
| 98 | 76 | Catalogues live in `static/lang/*.json`, keyed by the strings the templates |
| 99 | 77 | ask for. `en.json` is the reference and is always complete; a catalogue that |
| 100 | 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, | |
| 102 | and name it after the code. | |
| 79 | translation is useful immediately. To add a language, copy `en.json`, | |
| 80 | translate it, and name it after the code. | |
| 103 | 81 | |
| 82 | # Time units | |
| 104 | 83 | |
| 84 | A 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 | |
| 96 | and group pages, media and paginated URLs, and asks for a 10 second crawl | |
| 97 | delay; the index, daily deviations and posts stay crawlable. | |
| 98 | ||
| 99 | # Setting up reverse proxy | |
| 100 | ||
| 101 | Pretty 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 | ||
| 103 | Nginx example configuration: | |
| 104 | ```apache | |
| 105 | server { | |
| 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 | 28 | case "-c", "--config": |
| 29 | 29 | if n+1 < len(a) { |
| 30 | 30 | CFG.cfg = a[n+1] |
| 31 | cfgExplicit = true | |
| 31 | 32 | } else { |
| 32 | 33 | exit("Not enought arguments", 1) |
| 33 | 34 | } |
app/config.go +70 −57
| @@ -83,7 +83,7 @@ var CFG = config{ | ||
| 83 | 83 | StaticPath: "static", |
| 84 | 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 | 85 | Proxy: true, |
| 86 | Nsfw: true, | |
| 86 | Nsfw: false, | |
| 87 | 87 | } |
| 88 | 88 | |
| 89 | 89 | var lifetimeParsed int64 |
| @@ -143,76 +143,89 @@ func checkCacheWritable(path string) error { | ||
| 143 | 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. | |
| 148 | var cfgExplicit bool | |
| 149 | ||
| 146 | 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 | |
| 148 | // cannot be read, that asks for caching without proxying, or that points caching | |
| 149 | // at a directory this process cannot write. | |
| 151 | // cache rotation loop if caching is on. A missing default config.json means | |
| 152 | // the built-in defaults; a missing file named with -c exits. It also exits on | |
| 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. | |
| 150 | 155 | func ExecuteConfig() { |
| 151 | if CFG.cfg != "" { | |
| 152 | f, err := os.ReadFile(CFG.cfg) | |
| 153 | tryWithExitStatus(err, 1) | |
| 156 | f, err := os.ReadFile(CFG.cfg) | |
| 157 | switch { | |
| 158 | case err == nil: | |
| 154 | 159 | tryWithExitStatus(json.Unmarshal(f, &CFG), 1) |
| 155 | if CFG.Cache.Enabled && !CFG.Proxy { | |
| 156 | exit("Incompatible settings detected: cannot use caching media content without proxy", 1) | |
| 157 | } | |
| 158 | ||
| 159 | if CFG.Cache.Enabled { | |
| 160 | if err := checkCacheWritable(CFG.Cache.Path); err != nil { | |
| 161 | exit("Cache directory is not writable by this process (uid "+ | |
| 162 | strconv.Itoa(os.Getuid())+"): "+err.Error()+ | |
| 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 | } | |
| 160 | case os.IsNotExist(err) && !cfgExplicit: | |
| 161 | // The default file is optional: the built-in defaults are a working | |
| 162 | // instance. A path given with -c is not, since a typo there would | |
| 163 | // otherwise start something the operator did not configure. | |
| 164 | println("no", CFG.cfg, "found; running with the built-in defaults") | |
| 165 | default: | |
| 166 | tryWithExitStatus(err, 1) | |
| 167 | } | |
| 183 | 168 | |
| 184 | About = instanceAbout{ | |
| 185 | Proxy: CFG.Proxy, | |
| 186 | Nsfw: CFG.Nsfw, | |
| 187 | HideAI: CFG.HideAI, | |
| 188 | Theme: CFG.Theme, | |
| 189 | } | |
| 169 | if CFG.Cache.Enabled && !CFG.Proxy { | |
| 170 | exit("Incompatible settings detected: cannot use caching media content without proxy", 1) | |
| 171 | } | |
| 190 | 172 | |
| 191 | // A theme the stylesheet cannot honour would silently fall back to auto, | |
| 192 | // so say so instead. | |
| 193 | switch CFG.Theme { | |
| 194 | case "auto", "dark", "light": | |
| 195 | default: | |
| 196 | exit("config: theme must be one of auto, dark, light; got "+CFG.Theme, 1) | |
| 173 | if CFG.Cache.Enabled { | |
| 174 | if err := checkCacheWritable(CFG.Cache.Path); err != nil { | |
| 175 | exit("Cache directory is not writable by this process (uid "+ | |
| 176 | strconv.Itoa(os.Getuid())+"): "+err.Error()+ | |
| 177 | "\nGrant that uid write access to the directory, or set cache.enabled to false."+ | |
| 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 { | |
| 200 | d, err := parseLifetime(CFG.APICache.TTL) | |
| 182 | if CFG.Cache.Lifetime != "" { | |
| 183 | d, err := parseLifetime(CFG.Cache.Lifetime) | |
| 201 | 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 | |
| 208 | // refuse every request, so it is floored to one. | |
| 209 | if CFG.RateLimit.PerMinute > 0 { | |
| 210 | daLimiter = newRateLimiter(CFG.RateLimit.PerMinute, max(CFG.RateLimit.Burst, 1)) | |
| 205 | // A theme the stylesheet cannot honour would silently fall back to auto, | |
| 206 | // so say so instead. | |
| 207 | switch CFG.Theme { | |
| 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 | |
| 214 | devianter.UserAgent = CFG.UserAgent | |
| 221 | // per-minute 0 turns the limit off; a burst below one token would | |
| 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 | 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 | 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. | |
| 50 | func 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 | ||
| 59 | func 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 | ||
| 73 | func 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 | 4 | "cache": { |
| 5 | 5 | "enabled": true, |
| 6 | 6 | "path": "cache", |
| 7 | "lifetime": null, | |
| 7 | "lifetime": "1w", | |
| 8 | 8 | "max-size": 200, |
| 9 | 9 | "memcache": false, |
| 10 | 10 | "update-interval": 5 |
static/html/gruser.htm +1 −1
| @@ -21,7 +21,7 @@ | ||
| 21 | 21 | <input type="hidden" name="usr" value="{{.Templates.GroupUser.GR.Owner.Username}}"> |
| 22 | 22 | <select name="type" aria-label="Search type"> |
| 23 | 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 | 25 | <option value="all">{{T "search.all"}}</option> |
| 26 | 26 | <option value="tag">{{T "search.tag"}}</option> |
| 27 | 27 | <option value="r">{{T "search.groups"}}</option> |
static/lang/en.json −1
| @@ -6,7 +6,6 @@ | ||
| 6 | 6 | "nav.rss": "RSS", |
| 7 | 7 | "search.placeholder": "Search for ...", |
| 8 | 8 | "search.submit": "Search!", |
| 9 | "search.folders": "Folders", | |
| 10 | 9 | "search.all": "All", |
| 11 | 10 | "search.tag": "Tag", |
| 12 | 11 | "search.groups": "Groups", |
static/lang/es.json −1
| @@ -30,7 +30,6 @@ | ||
| 30 | 30 | "nav.home": "INICIO", |
| 31 | 31 | "nav.rss": "RSS", |
| 32 | 32 | "search.all": "Todo", |
| 33 | "search.folders": "Carpetas", | |
| 34 | 33 | "search.groups": "Grupos", |
| 35 | 34 | "search.placeholder": "Buscar ...", |
| 36 | 35 | "search.submit": "¡Buscar!", |