Files
ulicraft-server-v1/plan/20-files.md
Oier Bravo Urtasun 10b86ae23e fix(files): general.host must be a bare hostname, not a URL
The SPA computes its redirect origin as (force_ssl ? "https://" : "http://")
+ general.host. With host set to "https://files.${BASE_DOMAIN}" that produced
"http://https://files.ulicraft.net", so every client-side redirect broke and
the page never routed anywhere.

Server-side this was invisible: SecureOrigin strips the scheme before the
Host check, so `curl /` returned 200, the API answered, and the uptime
monitor stayed green — only real browsers failed. The tell is a
`POST /report?...msg=Redirecting to http://https://...` line in the log.

Set host to the bare hostname and force_ssl=true (which the origin needs to
build https://, and which only emits an HSTS header — it does not redirect,
so it can't loop behind the plain-http nginx→caddy hop).

Verified: origin is now "https://files.ulicraft.net"; login + ls still work.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 18:27:42 +02:00

10 KiB

20 — Filestash (files.${BASE_DOMAIN})

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

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 for files.) ──► caddy ──► filestash:8334
                                                                  │
                                              ${FILES_DATA_DIR} ──┘ (bind mount)
Thing Value
Vhost files.${BASE_DOMAIN}
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 files. 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.

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_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 files

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 `files` from LE_SUBDOMAINS
tooling/render-nginx.sh --install              # adds the files. vhost + 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://files.${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": "files.ulicraft.net", not "https://files.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://files.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.

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.
  • 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.