A simple csg modeler with parameterized assemblies
0

Configure Feed

Select the types of activity you want to include in your feed.

at canon 1 folder 4 files
README.md

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-dir to serve the WASM build (see WASM Build below).
  • add-user — Create a user. Prints an API key. Use --admin for admin privileges, --username/--password to enable password login (omit --password for an API-key-only user).
  • set-password — Set or change a user's password. If --password is 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.htmlCache-Control: no-cache
  • Content-hashed wasm-bindgen assets → Cache-Control: public, max-age=31536000, immutable
  • Extensionless paths → index.html (SPA deep-link support)
  • .wasmContent-Type: application/wasm
  • The /api/ and /admin/ namespaces always take precedence over the static dir
  • The embedded /gallery and /login HTML pages take precedence over the fallback
  • The server injects window.__MODELER_SRV__ into index.html with 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)

  1. Create a user with modeler-server add-user <name> (optionally --admin, --username, --password).
  2. Exchange the API key for a session token: POST /api/auth/api-key with {"api_key": "mkr_..."}.
  3. Use Authorization: Bearer sess_... on all subsequent requests.
  4. Session tokens expire after 7 days.

Username + password (user-friendly)

  1. Create a user with modeler-server add-user <name> --username <u> --password <p>.
  2. Exchange credentials for a session token: POST /api/auth/login with {"username": "…", "password": "…"}.
  3. 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.