Files
ulicraft-server-v1/plan/20-files.md
T
oierandClaude Opus 5 8507ffd020 feat(files): gate files. browse behind FILES_BROWSE (on|off)
Per-host toggle for the read-only, no-auth browse of the shared upload dir,
so it can stay on for the LAN-party host (oxps) and be off on the
internet-facing one (cochi). Defaults to `on` — existing hosts unchanged.

Neither layer has conditionals, so each gets the value substituted in:
- caddy: docker-compose mounts 62-files.${FILES_BROWSE:-on}.caddy at the
  fixed conf.d target (62-files.caddy -> 62-files.on.caddy, plus a
  comments-only 62-files.off.caddy stub — caddy needs a parseable file
  there). Same trick as FILES_MOUNT_MODE.
- nginx: render-nginx.sh splices nginx/files-browse.server.tmpl into
  ${FILES_BROWSE_SERVER} and adds/omits the name in the port-80 redirect.
  This is the gate that matters; caddy is bound to 127.0.0.1.

Applying a change needs render-nginx.sh --install plus a caddy
--force-recreate (single-file bind mount pins the inode). With `off`,
drop `files` from LE_SUBDOMAINS on that host. upload. is unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 17:08:33 +02:00

17 KiB

20 — Filestash (upload.${BASE_DOMAIN})

Web file share for player media (screenshots, schematics, maps, guides). One Filestash container behind caddy at upload., one shared username/password for everybody, writable. Config is rendered read-only from a template, like drasl/nmsr.

The same ${FILES_DATA_DIR} is also exposed at files.${BASE_DOMAIN} as a plain caddy file_server browse — read-only, no auth, every uploaded file publicly listable (62-files.on.caddy, toggled per host by FILES_BROWSE). Filestash gets its own subdomain because it cannot be served under a subpath (SPA hardcodes /api, /assets, /login at root).

Not a backup browser, not a mod mirror. See "What goes in it".

Everything below was verified against the real image (digest pinned in compose) and against the upstream source, not inferred from the docs — the docs omit most of it.

Shape

Internet ── host nginx (TLS, LE cert upload.) ──► caddy ──► filestash:8334 (rw)
                                                              │
                                          ${FILES_DATA_DIR} ──┤ (bind mount)
                                                              │
Internet ── host nginx (TLS, LE cert files.) ──► caddy file_server (ro, no auth)
Thing Value
Vhost (filestash) upload.${BASE_DOMAIN}
Vhost (static browse) files.${BASE_DOMAIN} — read-only, no auth, 62-files.on.caddy, per-host FILES_BROWSE=on|off
Image machines/filestash pinned by digest (upstream ships only latest)
Container port 8334, runs as uid/gid 1000 (filestash)
Auth htpasswd, one shared account, FILES_USER / FILES_PASSWORD_HASH
Access read + write for anyone with the password
Data ${FILES_DATA_DIR} on the host, outside the repo
State named volume filestash_state → /app/data/state
Config filestash/config.json rendered from .tmpl, mounted :ro
Backups none — outside mc-backup's scope. Treat as disposable.
Quota none — eyeballed

Decisions (settled — don't relitigate)

Auth is htpasswd with one shared credential, not SSO. Filestash's OIDC and SAML middlewares are enterprise-only; the community build compiles in htpasswd, ldap, passthrough, wordpress, local only (verified in server/plugin/index.go). Keycloak was considered and dropped — it would need an oauth2-proxy sidecar in front + passthrough. That path stays open but is not built.

One instance, everything behind the password. No anonymous read tier. Filestash's auth middleware is global to the instance, so "anonymous read + authenticated write" is impossible in one container (that's the enterprise RBAC). A two-instance split was designed and rejected as not worth it.

FILES_AUTH=none is gated on the host being internal-only. The off-switch exists for when this server moves behind a LAN/VPN. Do not set it while upload. is internet-reachable — auth-off on a writable public share is an open upload relay: someone parks malware or a phishing kit on your domain, under your LE cert, and the abuse mail, the bandwidth, and a possible domain blocklisting (which would take auth. and the apex down with it, breaking logins and the launcher for every player) all land on you. render-config.sh prints a loud warning when it renders this mode.

files. is public read wherever it is enabled. Independent of FILES_AUTH, the static browse serves the whole share with no auth — anyone who reaches files.${BASE_DOMAIN} can list and download every uploaded file. This is deliberate (chosen 2026-07-24). The htpasswd login only gates writes (via upload.); it never gated reads at this URL. Don't put anything private in the share.

FILES_BROWSE=on|off turns the whole files. vhost off per host (added 2026-07-30) — the answer to "I want it on oxps (LAN party) but not on cochi (internet-facing)". One var, two layers, because each has its own conditional problem:

Layer Mechanism
caddy docker-compose.yml substitutes the value into the mount source: ./caddy/conf.d/62-files.${FILES_BROWSE:-on}.caddy → /etc/caddy/conf.d/62-files.caddy. 62-files.off.caddy is a comments-only stub (compose has no conditionals, and caddy needs a parseable file at the fixed mount target — same trick as FILES_MOUNT_MODE).
nginx render-nginx.sh splices nginx/files-browse.server.tmpl into ${FILES_BROWSE_SERVER} when on, or a comment when off, and adds/omits the name in the port-80 redirect (nginx has no conditionals either).

nginx is the gate that matters — with no TLS vhost the name is unreachable from the internet regardless of caddy (caddy is bound to 127.0.0.1). The caddy stub is the second layer, and it also matters on hosts reached directly on caddy's port rather than through nginx (oxps on the LAN). Applying a change:

tooling/render-nginx.sh --install                 # nginx vhost in/out + reload
docker compose up -d --force-recreate caddy       # single-file bind mount pins the inode

restart caddy is NOT enough (the conf mount is a single file — see plan/14-deploy.md). With off, files also needs dropping from LE_SUBDOMAINS on that host: no vhost, no cert to renew. upload. (filestash, authenticated, writable) is untouched by this var.

Per-host auth status (the two prod envs diverge):

Host upload. reachability FILES_AUTH Notes
cochi internet-facing htpasswd shared uli login, $6$ hash
oxps LAN-only none (passthrough) auth off — gate satisfied (2026-07-23)

oxps runs auth-off deliberately: it is unreachable from the internet, so the open-upload-relay risk above does not apply. Applied host-locally (.env FILES_AUTH=none → render-config.sh → docker compose up -d --force-recreate filestash); nothing committed — .env and the rendered config.json are both gitignored per host. If oxps ever gains an internet path, flip it back to htpasswd (or set FILES_MOUNT_MODE=ro) before exposing it.

Shared credential ⇒ no audit trail, no per-person revocation. You will never know who deleted a folder; rotating means re-telling everyone. Fine for a friends' media share — but nothing here is backed up, so treat the share as disposable and keep anything you'd miss elsewhere.

What goes in it

Content Verdict
Screenshots, schematics, maps, guides ✅ the point
World downloads / ./backups/*.tgz ❌ contains playerdata/, stats/, the full map — every player's inventory and coords, handed to anyone with the shared password
Mods / jars ❌ redistribution; distribution.${BASE_DOMAIN} already serves the modset to launchers

.env

See .env.example for the annotated block. Summary:

Var What
FILES_DATA_DIR host path of the share, outside the repo
FILES_MOUNT_MODE literal mount flag, rw | ro
FILES_BROWSE on | off — serve the files. read-only browse on this host
FILES_AUTH htpasswd | passthrough | none
FILES_USER shared login name
FILES_PASSWORD_HASH $6$, from openssl passwd -6 '<pw>'
FILES_ADMIN_PASSWORD_HASH bcrypt, from docker run --rm caddy:alpine caddy hash-password --plaintext '<pw>'
FILES_SECRET_KEY openssl rand -hex 16, must be non-empty
FILES_LOCAL_SECRET openssl rand -hex 24, unlocks the local backend
LE_SUBDOMAINS must include upload, plus files when FILES_BROWSE=on

filestash/config.json is rendered and gitignored — it holds every hash and both secrets. Only the .tmpl is tracked.

Deploy

tooling/render-config.sh                       # -> filestash/config.json (validates the JSON)
tooling/issue-letsencrypt.sh                   # picks up `upload`+`files` from LE_SUBDOMAINS
tooling/render-nginx.sh --install              # adds the upload.+files. vhosts + reloads nginx
mkdir -p "$FILES_DATA_DIR"                     # uid 1000 must be able to write it
docker compose up -d filestash caddy

Then add an Uptime Kuma HTTP monitor for https://upload.${BASE_DOMAIN} (expect 200 — the login page is a 200) and put it on the status page with the others.

Gotchas (all of these were hit for real)

The local backend is admin-gated — this is the one that wastes an evening

plg_backend_local.Init() refuses to start unless the connection params carry $LOCAL_BACKEND_SECRET or the bcrypt admin password. Miss it and every login dies at backend error - Not Allowed, which reads like a credentials problem and isn't. config.json's attribute_mapping injects the secret server-side (Filestash calls this the "facade pattern"), so players never see it. FILES_LOCAL_SECRET must be identical in the compose env and the rendered config — render-config.sh renders both from the same var.

$2y$ bcrypt silently fails every login

The htpasswd plugin's bcrypt branch matches on the literal prefix $2a$. htpasswd -B emits $2y$, which falls through to return false — so every login fails with correct credentials and no useful log line. Use openssl passwd -6 ($6$, sha512) for FILES_PASSWORD_HASH. render-config.sh hard-fails on a $2y$/$2b$ hash rather than shipping a config nobody can log into. (The admin password is checked by Go's bcrypt directly, which does accept $2y$ — but caddy hash-password gives $2a$ anyway, so just use that for both.)

general.host is a BARE HOSTNAME — a scheme breaks the browser only

"host": "upload.ulicraft.net", not "https://upload.ulicraft.net", plus "force_ssl": true. The SPA computes its redirect origin as (force_ssl ? "https://" : "http://") + host, so a scheme in host yields http://https://upload.ulicraft.net and every client-side redirect dies — the page loads, then silently fails to route anywhere.

The vicious part: the server-side Host check (SecureOrigin) strips the scheme before comparing, so the server is perfectly happy. curl / returns 200, the API answers, and the Uptime Kuma monitor stays green — only real browsers break. Symptom in the log is a POST /report?...msg=Redirecting to http://https://... line. Hit this on 2026-07-14; fixed in config.json.tmpl.

force_ssl only sets an HSTS header — it does not redirect, so it can't loop behind the nginx→caddy (plain http) hop. It must be true or the SPA builds http:// origins on an https page.

general.host must also match the incoming Host header

Filestash blocks every non-/admin/ request whose Host differs from general.host, with "only traffic from X is allowed" — a 403 that looks like an auth failure. nginx forwards the original Host and caddy passes it through — don't rewrite it anywhere.

The login form only renders at ?action=redirect

GET /api/session/auth/ renders the htpasswd form only when action=redirect is present; any other request falls through to the credential check, fails with empty creds, and 303s back to ?action=redirect. So the real form URL is /api/session/auth/?action=redirect&label=files. A bare /api/session/auth/?label=files returning a 303 is normal, not a bug.

config.json is a single-file bind mount → stale inode

Same shape as the caddy conf.d gotcha. The mount pins the inode, so re-rendering (or a git pull) writes a new file while the container keeps reading the old one.

After re-rendering: docker compose up -d --force-recreate filestash. restart is not enough. Rotating the shared password needs a recreate too.

Don't set APPLICATION_URL or ADMIN_PASSWORD env

Either one makes Filestash rewrite config.json at boot, which fails against the :ro mount. Both live in the rendered config instead (general.host / auth.admin). general.secret_key must likewise be non-empty, or Filestash generates one and tries to save that. With all three set in the file, Filestash never attempts a boot write and the :ro mount is clean — you get one harmless cannot chmod config file WARN in the log, and nothing else.

The admin console cannot save

/admin loads and its login works, but config.json is :ro, so every save silently fails. That's the deliberate trade for keeping git authoritative (drasl/nmsr never write their configs; Filestash does, which is exactly why it needs pinning). To change config: edit the .tmpl, re-render, recreate. To re-derive the schema after an upstream upgrade: run the image locally against a writable state volume, click it into shape, docker cp the result out.

The API needs X-Requested-With: XmlHttpRequest

The SPA sends it; anything else (curl, a naive monitor) trips intrusion detection and gets a 403 that looks like an auth bug. Only matters when probing by hand — the Kuma monitor hits /, which is fine.

Share dir must be owned by uid 1000 or writes silently 403

filestash runs as uid/gid 1000. FILES_MOUNT_MODE=rw and FILES_AUTH=none (or a valid login) are not enough — the kernel still enforces the share directory's own perms. If ${FILES_DATA_DIR} is owned by a different uid with mode 0775, uid 1000 lands in other (r-x) and every upload/mkdir/delete dies with Permission denied, while the UI, curl /, and the Kuma monitor all stay green (same false-green trap as the SPA gotchas). Hit on oxps 2026-07-24: the share (/home/oier/mc/distribution-ulicraft/files) was 1002:1002 0775.

Fix — give the share to filestash's uid:

sudo chown -R 1000:1000 "$FILES_DATA_DIR"
sudo chmod g+s "$FILES_DATA_DIR"          # new files inherit the group
docker exec -u 1000 filestash sh -c 'touch /data/files/.wtest && echo OK && rm /data/files/.wtest'

Reversible (chown -R <old> "$FILES_DATA_DIR"). If another account also writes the share directly, use a shared group + g+rwx instead of chowning it away.

Cleaner: run filestash as the owning uid (share files become oier:oier)

Chowning the share to filestash's 1000 works, but then everything filestash writes is owned by an anonymous 1000 nobody logs in as. To make the share files owned by a real host account (oxps oier = uid 1002) so you can manage them over ssh, run the container as that uid. Compose exposes it:

user: "${FILES_UID:-1000}:${FILES_GID:-1000}"
group_add: ["1000"]

Two things make a bare uid override fail, both handled above:

  • The image binary /app/filestash is 0730 filestash:filestash and /app/data is 0770 — a non-1000 uid is other and can neither exec nor traverse. The group_add: ["1000"] restores that via the filestash group.
  • The filestash_state volume (config, sqlite, search index) is owned 1000:1000. Running as another uid without chowning it → filestash can't write its own state and crash-loops. Chown the state volume too.

Do all of it with tooling/filestash-set-run-user.sh (reads FILES_UID/ FILES_GID from .env, chowns the state volume + FILES_DATA_DIR, recreates, write-tests). oxps: set FILES_UID=FILES_GID=1002 in .env, run it. Default 1000:1000 leaves cochi untouched. Per-host, host-local — nothing but the compose knob and this script is committed.

Accessing the share files directly (bypassing the web UI)

${FILES_DATA_DIR} is a plain host directory — the web UI is just a view of it.

  • On the host: ls "$FILES_DATA_DIR" (oxps: /home/oier/mc/distribution-ulicraft/files). Write as uid 1000 to match filestash ownership: sudo -u '#1000' cp foo "$FILES_DATA_DIR"/.
  • From your machine (oxps is LAN-only): scp file oxps:/home/oier/mc/distribution-ulicraft/files/ then ssh oxps 'sudo chown 1000:1000 /home/oier/mc/distribution-ulicraft/files/file', or mount over sshfs: sshfs oxps:/home/oier/mc/distribution-ulicraft/files ~/mnt/ulifiles.
  • Via the container: docker exec -u 1000 filestash ls /data/files.
  • Web (LAN): http://upload.${BASE_DOMAIN} (filestash, no login on oxps, auth=none) or http://files.${BASE_DOMAIN} (read-only static browse, no auth).

Disk is the shared failure domain

FILES_DATA_DIR grows without a quota, driven by whoever has the password. A full disk on cochi does not just break files. — it takes Minecraft, Drasl, and mc-backup down with it. Eyeballing is the accepted plan. Revisit (quota, separate partition, or a Kuma disk monitor) if the share sees real use.

Pinned digest, not latest

machines/filestash publishes essentially only latest. Unpinned, any docker compose pull silently ships a new upstream build of an internet-facing, writable file manager. Pin the digest; upgrade deliberately — same discipline as NEOFORGE_VERSION.

Deferred

  • Keycloak SSO — needs oauth2-proxy + FILES_AUTH=passthrough. Not built.
  • Anonymous access (FILES_AUTH=none) — only once the host is internal-only. Done on oxps (LAN-only, 2026-07-23); still htpasswd on cochi.
  • Landing page link — deliberately none on day one; plan/18-landing-rework.md is mid-rework, so revisit with WS5 (footer) rather than merging into a moving design.
  • Per-user accounts / roles — htpasswd RBAC is enterprise-only.
  • Backups / quota — none.