modeler-server#
Cloud sync + document gallery backend for CSG Modeler. Built with Axum + SQLite (sqlx).
Stores assemblies and scenes as opaque JSON blobs per user. Three auth methods: API keys, username + password (Argon2id), and GitHub OAuth.
Quick Start#
# Create a user with username + password
cargo run -- add-user alice --username alice --password hunter2-secret
# Or create an API-key-only user
cargo run -- add-user bob
# Start the server
cargo run -- serve
The server listens on http://127.0.0.1:8080 by default.
Docker#
# Build
docker build -t modeler-server -f server/Dockerfile .
# Run (persist SQLite database to ./data/)
docker run -p 8080:8080 -v "$PWD/data:/data" \
-e DATABASE_URL=sqlite:/data/modeler.db?mode=rwc \
modeler-server
# Create a user (one-off)
docker run --rm -v "$PWD/data:/data" \
-e DATABASE_URL=sqlite:/data/modeler.db?mode=rwc \
modeler-server modeler-server add-user alice --username alice --password hunter2-secret
# With GitHub OAuth
docker run -p 8080:8080 -v "$PWD/data:/data" \
-e DATABASE_URL=sqlite:/data/modeler.db?mode=rwc \
-e GITHUB_CLIENT_ID=your_client_id \
-e GITHUB_CLIENT_SECRET=your_client_secret \
-e PUBLIC_URL=http://localhost:8080 \
modeler-server
CLI#
modeler-server serve [--port <port>] [--host <host>] [--database-url <url>]
[--cors-origin <origin>] [--static-dir <path>]
modeler-server add-user <display-name> [--username <u>] [--password <p>] [--admin] [--database-url <url>]
modeler-server set-password <username> [--password <p>] [--database-url <url>]
modeler-server rotate-api-key <display-name> [--label <label>] [--database-url <url>]
serve— Start the HTTP server. Use--static-dirto serve the WASM build (see WASM Build below).add-user— Create a user. Prints an API key. Use--adminfor admin privileges,--username/--passwordto enable password login (omit--passwordfor an API-key-only user).set-password— Set or change a user's password. If--passwordis omitted, a random one is generated and printed.rotate-api-key— Generate a new API key for a user (revokes the old one).
Environment Variables#
| Variable | Default | Description |
|---|---|---|
GITHUB_CLIENT_ID |
— | GitHub OAuth App client ID |
GITHUB_CLIENT_SECRET |
— | GitHub OAuth App client secret |
PUBLIC_URL |
http://localhost:8080 |
Public-facing server URL for OAuth callbacks and index.html injection |
GALLERY_PUBLIC |
true |
Set to false to require auth for gallery access |
RATE_LIMIT_LOGIN_PER_MIN |
10 |
Max login requests per 60s per IP (shared across both login endpoints) |
WASM Static Hosting#
modeler-server serve ... --static-dir dist/
When --static-dir is provided, the server serves all files in that directory at
the root path. Key behaviours:
/or/index.html→Cache-Control: no-cache- Content-hashed wasm-bindgen assets →
Cache-Control: public, max-age=31536000, immutable - Extensionless paths →
index.html(SPA deep-link support) .wasm→Content-Type: application/wasm- The
/api/and/admin/namespaces always take precedence over the static dir - The embedded
/galleryand/loginHTML pages take precedence over the fallback - The server injects
window.__MODELER_SRV__intoindex.htmlwith its own URL, so the WASM frontend always knows the API base URL
Build the WASM frontend (from the repo root):
cargo build --target wasm32-unknown-unknown --release
wasm-bindgen target/wasm32-unknown-unknown/release/modeler_app.wasm \
--out-dir dist --out-name modeler_app --target web
wasm-opt -Oz dist/modeler_app_bg.wasm -o dist/modeler_app_bg.wasm
Or use just serve / just serve-release from the repo root to build + start in one step.
API#
All endpoints return JSON. Timestamps use RFC 3339. Authenticated endpoints
require Authorization: Bearer sess_... header.
Public#
GET /api/health
{"status":"ok"} — also pings the database, returns {"status":"degraded"} on failure.
GET /api/users/{user_id}
Public profile: {"id": "...", "display_name": "...", "bio": "...", "doc_count": N}
GET /api/gallery[?kind=<scene|assembly>&sort=<name|updated|created>&order=<asc|desc>
&page=<n>&per_page=<n>&user_id=<uid>&search=<q>]
Paginated list of public documents. Each item has kind, name, user_id,
author, description, thumbnail_url, created_at, updated_at.
GET /api/gallery/count
{"total": N}
GET /api/gallery/thumbnails/{user_id}/{kind}/{name}
Returns the raw 512×512 PNG, or 404.
GET /api/gallery/document/{kind}/{user_id}/{name}
Returns a public document's full JSON payload (kind, user_id, name, data).
POST /api/gallery/fork/{kind}/{user_id}/{name}
Authorization: Bearer sess_...
Copy another user's public document into the current user's namespace.
Request body: {"name": "my-copy"} (overrides the name if set).
Authentication#
POST /api/auth/register
Content-Type: application/json
Request: {"invite_code": "alice-bloom", "username": "bob", "display_name": "Bob", "password": "hunter2-secret"}
Response: {"token": "sess_...", "user": {"id": "...", "display_name": "Bob", "username": "bob"}}
Rate-limited: 3 attempts per hour per IP.
Errors: 400 Invalid invite code · 400 Invite code has expired · 429 rate_limited
POST /api/auth/api-key
Content-Type: application/json
Request: {"api_key": "mkr_..."}
Response: {"token": "sess_...", "user": {"id": "...", "display_name": "..."}}
Errors: 401 invalid_api_key · 429 rate_limited
POST /api/auth/login
Content-Type: application/json
Request: {"username": "alice", "password": "hunter2-secret"}
Response: same shape as /api/auth/api-key
Errors: 401 invalid_credentials · 429 rate_limited
GET /api/auth/github/login?redirect_uri=<url>
Redirects to GitHub OAuth consent screen.
GET /api/auth/github/callback?code=<code>&state=<state>
Exchanges code for a session token, redirects to redirect_uri?session_token=sess_....
Authenticated User#
GET /api/auth/me
{"id": "...", "display_name": "...", "username": "...", "bio": "...", "created_at": "..."}
PATCH /api/auth/me
Content-Type: application/json
Update display_name, bio, or password.
Body: {"display_name": "...", "bio": "...", "password": "...", "current_password": "..."}
(current_password required when changing password).
GET /api/auth/api-keys
List all API keys for the authenticated user.
POST /api/auth/api-keys
Content-Type: application/json
Create a new API key. Body: {"label": "..."} (label optional).
Returns the raw key once: {"api_key": "mkr_...", "label": "..."}
DELETE /api/auth/api-keys/{key_hash}
Revoke a specific API key by its hash.
POST /api/auth/invite-codes
Content-Type: application/json
Generate a new invite code. Body: {"label": "for a friend", "max_uses": 3} (both optional; default max_uses is 1).
Returns: {"code": "alice-bloom", "max_uses": 3, "use_count": 0, "label": "for a friend", "created_at": "..."}
The code format is {username}-{word} for readability. Auto-copies to clipboard in the UI.
GET /api/auth/invite-codes
List the current user's invite codes (newest first). Each entry has code, max_uses, use_count, label, created_at.
Documents#
GET /api/documents[?kind=<scene|assembly>&search=<q>]
List current user's documents (assemblies and scenes), optionally filtered.
GET /api/documents/{kind}/{name}
Get metadata for an owned document.
PATCH /api/documents/{kind}/{name}
Authorization: Bearer sess_...
Content-Type: application/json
Update description, is_public flag, or thumbnail of your own document.
Body fields: description (≤5000 chars), is_public (bool),
thumbnail_png_base64 (base64-encoded 512×512 PNG ≤256 KiB),
clear_thumbnail (bool). Owner-only — returns 404 for cross-owner writes.
GET /api/documents/{kind}/{name}/thumbnail
Get owned document's thumbnail PNG.
Revisions#
GET /api/documents/{kind}/{name}/revisions[?page=<n>&per_page=<n>]
Paginated revision history (newest first).
GET /api/documents/{kind}/{name}/revisions/{version}
Get a specific revision's full data.
POST /api/documents/{kind}/{name}/restore
Content-Type: application/json
Restore document to a previous revision. Body: {"version": N}
Captures current data as a new revision first.
Sync#
GET /api/sync[?since=<ISO8601>]
Bulk pull. Returns manifest of assemblies and scenes updated after since.
PUT /api/sync
Content-Type: application/json
Bulk push. Request body: {"assemblies": [...], "scenes": [...]}, each entry
has name, data, updated_at.
Assemblies#
GET /api/assemblies # List all
GET /api/assemblies/{name} # Get one
PUT /api/assemblies/{name} # Create/update
DELETE /api/assemblies/{name} # Delete
Scenes#
Same structure as assemblies but under /api/scenes/{name}.
Embedded HTML Pages#
GET /gallery
Self-contained HTML/CSS/JS gallery browser (thumbnail grid, search, sort, pagination, modal detail view, share links, copy JSON). Ships in the binary — no static directory required.
GET /login
Account management page: sign in with API key or password, register with
invite code, manage API keys and invite codes, view account info. Stores
session in localStorage. Ships in the binary.
GET /admin
Admin dashboard with login form (API key + password tabs), user list, stats, document moderation, and invite referral stats. Ships in the binary.
Admin (requires admin user)#
GET /api/admin/users
{"users": [{"id", "display_name", "username", "created_at", "is_admin", "github_user_id", "assembly_count", "scene_count", "storage_bytes"}, ...]}
POST /api/admin/users
Content-Type: application/json
Create a new user. Body: {"display_name": "...", "username": "...", "password": "...", "admin": bool}
Returns {"api_key": "mkr_...", "user": {...}}.
DELETE /api/admin/users/{id}
Returns 204 on success.
PATCH /api/admin/users/{id}
Content-Type: application/json
Update user: {"display_name": "...", "password": "...", "is_admin": bool}
GET /api/admin/stats
{"total_users": N, "admin_users": N, "total_assemblies": N, "total_scenes": N}
GET /api/admin/moderation_queue
List recently published documents pending moderation.
GET /api/admin/metrics
Per-route request metrics (count, error_count, avg_duration_ns).
DELETE /api/admin/documents/{kind}/{user_id}/{name}
Hard-delete any document.
POST /api/admin/documents/{kind}/{user_id}/{name}/unpublish
Set a document's is_public to 0.
GET /api/admin/invite-stats
Referral stats aggregated by issuer: [{"issuer_id": "...", "issuer_name": "...", "codes_issued": N, "total_redemptions": N}]
Auth Flow#
Two independent login methods, each issuing the same sess_... session token format:
API key (machine-friendly)
- Create a user with
modeler-server add-user <name>(optionally--admin,--username,--password). - Exchange the API key for a session token:
POST /api/auth/api-keywith{"api_key": "mkr_..."}. - Use
Authorization: Bearer sess_...on all subsequent requests. - Session tokens expire after 7 days.
Username + password (user-friendly)
- Create a user with
modeler-server add-user <name> --username <u> --password <p>. - Exchange credentials for a session token:
POST /api/auth/loginwith{"username": "…", "password": "…"}. - Same
Authorization: Bearer sess_...for subsequent requests.
The session token is generated fresh from a CSPRNG (ChaCha12) at each login and is cryptographically independent of the credential used to obtain it — neither the API key nor the password can be recovered from the session token.
GitHub OAuth#
Requires GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET env vars. The client
initiates login by visiting /api/auth/github/login?redirect_uri=<url>, then
the callback at /api/auth/github/callback exchanges the code and redirects
back to redirect_uri?session_token=....
Login Rate Limiting & Audit Logging#
Both /api/auth/api-key and /api/auth/login share a per-IP sliding-window
rate limiter, default 10 requests per 60 seconds (override with
RATE_LIMIT_LOGIN_PER_MIN=<n>). Exceeding the limit returns 429 Too Many Requests with {"error":"rate_limited", …}.
Every login attempt is logged via tracing:
INFO login: api_key exchange succeeded user_id=<uuid> ip=<addr>
INFO login: password succeeded user_id=<uuid> ip=<addr>
WARN login: invalid api key ip=<addr>
WARN login: invalid password user_id=<uuid> ip=<addr>
WARN login: unknown username ip=<addr>
WARN login: user has no password user_id=<uuid> ip=<addr>
WARN login: rate limited ip=<addr>
Set RUST_LOG=modeler_server=info (or =debug) to see them.
Passwords are hashed with Argon2id (default parameters, random per-user
salt) and stored as PHC strings in the users.password_hash column. The
plaintext password never touches the server after the initial set.
Integration Tests#
bash tools/test_server.sh
128 tests covering health, OAuth error handling, API key/session auth, password login, sync push/pull, assembly/scene CRUD, metadata fields, gallery listing + PATCH + fork, gallery visibility gate + GALLERY_PUBLIC toggle, admin user CRUD + stats + moderation + unpublish + delete + metrics + referral stats, static file serving (Content-Type, cache headers, SPA fallback, API precedence), embedded HTML pages (gallery, login, admin), public document fetch (200 + 404), user profiles, revision history, and invite code registration + management.
Unit Tests#
cargo test -p modeler-server
92 tests across gallery validation (34), middleware (10), static file classification
(16), gallery DB queries (24), revision history (4), gallery HTML page (2), and
login HTML page (2). DB tests use #[tokio::test] with an isolated in-memory
SQLite database.