Opening an original by URL (?access_token=) baked in the 15-minute access token, so a long video opened in a new tab stopped streaming once that token expired mid-playback: the access token can't be refreshed in an already-opened tab, and its next Range request 401'd. Add a content token: a signed, single-file capability (typ=content, fid claim) with its own longer TTL (CONTENT_TOKEN_TTL, default 6h) and — crucially — no session id, so it survives refresh rotation and outlives the short access TTL. POST /files/:id/content-token mints one after the same view-ACL check content serving does; GET /files/:id/content now runs under content-aware auth that accepts either a normal access token or a content token scoped to that file. View permission is still enforced against the token's user, so the token only changes when a file may be read by URL, never which files. It's a bearer capability for that one file until expiry, hence the bounded, configurable TTL. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tanabata File Manager
A multi-user, tag-based web file manager for images and video. Go + Gin backend (Clean Architecture, pgx, goose migrations), SvelteKit SPA frontend, PostgreSQL, JWT auth — shipped as a single Docker image that serves both the API and the built SPA on one port.
Documentation
openapi.yaml— full REST API specificationdocs/DEPLOY.md— production deploy (Gitea Actions → host)docs/GO_PROJECT_STRUCTURE.md— backend architecturedocs/FRONTEND_STRUCTURE.md— frontend architecture.env.example— every configuration variable, documented
Quick start
cp .env.example .env # then edit the secrets (JWT_SECRET, ADMIN_PASSWORD, …)
docker compose up -d --build
By default this runs the app plus a bundled PostgreSQL container
(COMPOSE_PROFILES=with-db). To point at a Postgres already on the host, set
COMPOSE_PROFILES= empty and aim DATABASE_URL at host.docker.internal. See
.env.example for the full matrix.
The app is published on 127.0.0.1 only and expects a reverse proxy in front (see below). The default port is 42776 — the sum of the Unicode code points of 七夕.
Reverse proxy (nginx)
The container publishes its port on loopback (127.0.0.1:${APP_PORT}:42776 in
docker-compose.yml), so a reverse proxy on the host
terminates TLS and forwards to it. Three settings matter for this app:
client_max_body_size— uploads go up toMAX_UPLOAD_BYTES(500 MiB by default). nginx caps request bodies at 1 MiB out of the box, so without this every large upload fails with413.- Forwarded headers — the app trusts
X-Forwarded-Foronly from the hops inTRUSTED_PROXIES(default: loopback + Docker bridge ranges) and keys its login/refresh rate limiter on the resulting client IP. If the proxy doesn't send the header, every request looks like it comes from the proxy and shares one rate-limit bucket. - Streaming for big media — turning request/response buffering off lets large uploads stream straight to the app and lets video range-seeks work without nginx spooling whole files to disk first.
server {
listen 443 ssl;
server_name tanabata.example.com;
# ssl_certificate / ssl_certificate_key ... (e.g. from certbot)
# Match MAX_UPLOAD_BYTES (500 MiB default); nginx defaults to 1m → 413.
client_max_body_size 512m;
location / {
proxy_pass http://127.0.0.1:42776; # APP_PORT
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Stream large uploads/downloads instead of buffering to disk; keeps
# video range-seek responsive. Scope these to file/preview locations
# instead if you'd rather keep buffering for small JSON responses.
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
}
If you run the app without a proxy and want it reachable on the LAN, drop the
127.0.0.1: prefix from the ports line in
docker-compose.yml and adjust TRUSTED_PROXIES
accordingly.
Development
# Backend
cd backend
go run ./cmd/server # dev server
go test ./... # all tests
# Frontend
cd frontend
npm run dev # Vite dev server
npm run build # production build
npm run generate:types # regenerate API types from openapi.yaml