Proxy booru client for Gelbooru — Express backend + React (Vite) frontend. Features configurable API caching, rate limiting, and S3-backed media proxy.
- Node.js 22+
- Yarn 1.x
- (Optional) Docker + Docker Compose for deployment
- (Optional) A Gelbooru account for API credentials (required for
dapiendpoints)
cp conf.json.example conf.json # edit with your preferences
cp .env.example .env # add API keysconf.json — all settings in one file (gitignored). See Configuration reference below.
.env — secrets and deployment-specific values:
| Variable | Required | Default | Description |
|---|---|---|---|
GELBOORU_USER_ID |
Yes (for dapi) | — | Gelbooru account ID |
GELBOORU_API_KEY |
Yes (for dapi) | — | Gelbooru API key |
HOST |
No | 0.0.0.0 |
Server bind address |
PORT |
No | 3000 |
Server port |
REDIS_URL |
No | — | Redis connection string (redis://...). Falls back to in-memory cache/rate-limiting if unset |
S3_ENDPOINT, S3_PORT, etc. |
No | — | See S3 media cache |
Development (two terminals):
# Terminal 1 — Express server (serves API on :3000)
node server/index.js
# Terminal 2 — Vite dev server (hot reload on :5173, proxies /api to :3000)
cd client && yarn devProduction (local):
cd client && yarn build && cd ..
node server/index.jsExpress serves both the API and the built client from client/dist/.
cp conf.json.example conf.json # edit with your preferences
cp .env.prod.example .env.prod # add API keys.env.prod uses Docker service hostnames (redis://redis:6379, S3_ENDPOINT=minio) that match the services in compose.yml. See Environment files for details.
docker compose up -dThis starts three containers:
| Service | Image | Purpose |
|---|---|---|
app |
(builds from Dockerfile) |
Express server + built frontend, exposed on port 3000 |
redis |
redis:7-alpine |
API cache and rate-limiting backend, data persisted in redis-data volume |
minio |
minio/minio |
S3-compatible media cache, data persisted in minio-data volume |
On first startup, the app auto-creates the S3 bucket (subooru-media by default) and sets a lifecycle rule to expire objects after max_age_days (configured in conf.json).
docker compose build --pull # rebuild with latest base images
docker compose up -d # restartdocker compose down # stops containers, preserves volumes
docker compose down -v # stops and deletes volumes (cache wiped)Two environment files serve different purposes:
| File | Tracked | Used by | Purpose |
|---|---|---|---|
.env.example |
Yes | Reference | Template for local development |
.env |
No (gitignored) | node server/index.js |
Local dev — uses localhost service addresses |
.env.prod.example |
Yes | Reference | Template for Docker deployment |
.env.prod |
No (gitignored) | docker compose up |
Docker — uses Docker service names (redis, minio) |
GELBOORU_USER_ID=12345
GELBOORU_API_KEY=your-api-key
HOST=0.0.0.0
PORT=3000
# Optional — Redis (local or remote)
REDIS_URL=redis://localhost:6379
# Optional — S3-compatible storage (MinIO, AWS S3, etc.)
S3_ENDPOINT=localhost
S3_PORT=9000
S3_USE_SSL=false
S3_REGION=us-east-1
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
S3_BUCKET=subooru-media
GELBOORU_USER_ID=12345
GELBOORU_API_KEY=your-api-key
REDIS_URL=redis://redis:6379
S3_ENDPOINT=minio
S3_PORT=9000
S3_USE_SSL=false
S3_REGION=us-east-1
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
S3_BUCKET=subooru-media
The only difference from .env is the service hostnames — redis and minio instead of localhost. Gelbooru credentials are the same. If you're using AWS S3 or another provider, set S3_ENDPOINT to your provider's endpoint.
All configuration lives in conf.json (gitignored). conf.json.example is the reference template.
"log": {
"level": "info",
"console": true,
"file": "logs/subooru.log"
}| Field | Type | Default | Description |
|---|---|---|---|
level |
string | "info" |
Pino log level: trace, debug, info, warn, error, fatal |
console |
boolean | true |
Log to stdout (pino-pretty formatted) |
file |
string | — | Path to log file. Omit or set null to disable file logging |
"rate_limit": {
"enabled": true,
"window_ms": 60000,
"endpoints": {
"posts": 30,
"tags": 15,
"media": 60,
"config": 10
}
}| Field | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | true |
Set false to disable all rate limiting |
window_ms |
number | 60000 |
Rate limit window in milliseconds |
endpoints.* |
number | — | Max requests per window_ms per IP for each route |
| Field | Type | Default | Description |
|---|---|---|---|
trust_proxy |
boolean | false |
Set true when behind Cloudflare or a reverse proxy. Enables X-Forwarded-For trust so req.ip reflects the real visitor IP for rate limiting and logging. |
"cache": {
"enabled": true,
"default_ttl_ms": 300000,
"endpoints": {
"posts": 300000,
"tags": 3600000,
"tags_search": 3600000
}
}| Field | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | true |
Set false to disable API response caching |
default_ttl_ms |
number | 300000 |
Default TTL (5 min) for endpoints not listed in endpoints |
endpoints.* |
number | — | Per-endpoint TTL in milliseconds |
Backend: Redis if REDIS_URL is set, otherwise in-memory Map.
"media_cache": {
"enabled": true,
"max_age_days": 1
}| Field | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | true |
Set false to disable S3 media caching (always fetches from Gelbooru) |
max_age_days |
number | 1 |
S3 lifecycle expiration in days. Set 0 to disable auto-expiry. Objects are deleted by MinIO/S3 after this many days |
S3 credentials (S3_ENDPOINT, S3_ACCESS_KEY, etc.) are set in .env / .env.prod, not in conf.json. If env vars are missing, the cache logs a warning and media is always fetched directly from Gelbooru.
"server_proxy": trueControls the /api/media endpoint. When false, the route returns 503 Service Unavailable. The separate client.server_proxy flag (returned via /api/config) controls the frontend's media fallback chain.
"include": ["rating:safe"],
"blacklist": ["guro", "scat"]include— tags silently appended to every Gelbooru query (server-side, client cannot circumvent)blacklist— tags silently excluded from results (server-side, strips-and~prefixes before matching)
"metatags": [
{ "prefix": "rating:", "tags": ["rating:general", "rating:safe", "rating:questionable", "rating:explicit"] },
{ "prefix": "sort:", "tags": ["sort:random", "sort:score", "sort:mpixels", ...] },
{ "prefix": "score:", "tags": ["score:>=100", "score:>50", ...] }
]Autocomplete suggestions for prefix-based tags. When the user types rating:, the dropdown shows the listed options.
"client": {
"include": [],
"blacklist": [],
"worker_base": null,
"server_proxy": true,
"proxy_thumbnails": false
}| Field | Type | Description |
|---|---|---|---|
| include | string[] | Default tags added to every search when the "Default tags" toggle is on. These appear as removable tag chips in the sidebar. "Default tags" toggle in Settings controls whether these are prepended to the search URL. |
| blacklist | string[] | Client-side tag blacklist (applied when "Default blacklist" toggle is on, in addition to server blacklist) |
| worker_base | string | null | Cloudflare Worker URL for media proxying. null disables worker |
| server_proxy | boolean | Tells the frontend whether to fall back to /api/media when the worker fails |
| proxy_thumbnails | boolean | When true, grid thumbnails, video posters, and favorites thumbnails are fetched through the media proxy chain (Worker → /api/media) instead of directly from Gelbooru |
These values are returned to the frontend via GET /api/config.
An optional Cloudflare Worker in subooru-worker/ that proxies media from Gelbooru CDN. Offloads bandwidth from the Express server and reduces latency by serving from Cloudflare's edge.
When client.worker_base is set in conf.json, the frontend requests media through the Worker URL first. If the Worker fails and client.server_proxy is true, it falls back to the Express server's /api/media.
- Wrangler CLI —
npm install -g wrangler - A Cloudflare account
# Authenticate Wrangler with your Cloudflare account
wrangler login
# Or set a token for CI/deployment scripts
# export CLOUDFLARE_API_TOKEN=your-tokenEdit subooru-worker/wrangler.toml:
name = "subooru-media" # your worker name
main = "src/index.js"
compatibility_date = "2025-01-01"
# Optional — route through a custom domain
# [routes]
# pattern = "media.yourdomain.com/*"
# zone_id = "your-zone-id"cd subooru-worker
wrangler deployThis outputs a URL like https://subooru-media.your-name.workers.dev.
Set the Worker URL in conf.json.client:
"client": {
"worker_base": "https://subooru-media.your-name.workers.dev",
"server_proxy": true
}worker_base— the frontend sends media requests to this URL firstserver_proxy— whentrue, the frontend falls back to Express/api/mediaif the Worker fails. Whenfalse, media fails to load if the Worker is unavailable
| Route | Description | Rate limit (default) |
|---|---|---|
GET /api/posts?page=&q= |
Search posts (100 per page) | 30/min |
GET /api/tags?t= |
Lookup tags by name | 15/min |
GET /api/tags/search?q= |
Autocomplete tags | 30/min |
GET /api/media?url= |
Proxy media from Gelbooru CDN (with S3 caching) | 60/min |
GET /api/config |
Returns client-side config | 10/min |
GET /api/version |
Returns { "version": "0.1.0" } |
— |
Gelbooru API responses (posts, tags) are cached in Redis/memory per server.cache. Media files are cached in S3 per server.media_cache.