Skip to content

Server API and Storage

Pummelchen edited this page Sep 16, 2026 · 5 revisions

Server API and Storage

roomcad/server/server.py is the whole backend: Python standard library only, SQLite for storage, reverse-proxied by Caddy under /api/* and bound to 127.0.0.1:8078 so it is never exposed directly.

Authentication

One shared password, checked server-side, supplied through the ROOMCAD_PASSWORD environment variable (never in git). POST /api/login sets an HttpOnly cookie; every other endpoint returns 401 without it. If the password is not configured the service still starts, but logins fail closed.

The cookie is SameSite=Lax, and on top of that POST and DELETE must be same-origin: an Origin header — or a Referer standing in for one — has to name the host the request was actually made to, or the API answers 403 with the usual error envelope. A request carrying neither header is allowed, because curl, the test suite and a same-origin form post send none, while a browser making a cross-site POST always sends Origin. The check covers POST /api/login deliberately: a cross-site login can force a session onto a visitor and spend the login throttle.

# log in and keep the cookie
curl -s -c jar.txt -X POST localhost:8078/api/login \
  -H 'Content-Type: application/json' -d '{"password":"letmein"}'
# {"ok": true}

# A cross-origin POST is refused — but note that curl above sent no Origin at
# all, which is why it works.
curl -s -X POST localhost:8078/api/login -H 'Origin: https://evil.example' \
  -H 'Content-Type: application/json' -d '{"password":"letmein"}'
# {"error": "cross-origin request refused"}

# every later call reuses it
curl -s -b jar.txt localhost:8078/api/rooms

Only a SHA-256 hash of the session token is stored, never the token.

The comparison is constant-time and runs over UTF-8 bytes, not str: Python's secrets.compare_digest raises TypeError when either side holds a non-ASCII character, so a password with an accent in it used to kill the handler before the failure was counted — no response, a dropped connection, and those attempts escaped the throttle entirely. A non-ASCII password now simply works.

Wrong passwords are throttled per client: 10 failures in 300 s earns 429. The client is identified from X-Forwarded-For, counted from the end according to ROOMCAD_PROXY_HOPS (the production deploy sets 1, because two proxies sit in front of the API).

Endpoints

Method Path Purpose
POST /api/login exchange the password for a session cookie
GET /api/rooms list rooms (latest version of each)
GET /api/load/<name> load the latest version, or ?version=N
GET /api/versions/<name> list a room's versions, newest first
POST /api/save save a new version → {name, version}
DELETE /api/rooms/<name> delete every version of a room
GET /api/watch/<name> Server-Sent Events stream of updates
POST /api/live/<name> publish an unsaved live draft
POST /api/live-check/<name> "am I still in sync?" — a digest, and the room back only if it has drifted
GET /api/session/last the design this browser should reopen
POST /api/session/last remember a design for this browser
GET /api/status {"count": N} — sessions active in the last 30 s

Every body must be a JSON object. [], 5 and "x" are valid JSON and were once handed straight to the handlers, which then reached for .get(...); they are answered with 400 before any handler sees them. Errors are always {"error": "<short>"}.

Names are sanitised to [A-Za-z0-9_-]. Saving with an empty name allocates the next free ternak_roomN inside the same lock as the insert, so two simultaneous saves can never collide.

Examples

# save a design (empty name = allocate a new slot)
curl -s -b jar.txt -X POST localhost:8078/api/save \
  -H 'Content-Type: application/json' \
  -d '{"name":"","json":"{\"format\":\"com.maria.roomcad-v2.room\",...}","clientId":"cli"}'
# {"name": "ternak_room4", "version": 1}

# list the versions of that room
curl -s -b jar.txt localhost:8078/api/versions/ternak_room4
# [{"version": 1, "savedAt": 1755900000000}]

# fetch one exact version
curl -s -b jar.txt 'localhost:8078/api/load/ternak_room4?version=1'

# follow live updates (stays open, one JSON object per event)
curl -sN -b jar.txt localhost:8078/api/watch/ternak_room4

Storage

CREATE TABLE rooms (
    id        INTEGER PRIMARY KEY AUTOINCREMENT,
    name      TEXT    NOT NULL,
    version   INTEGER NOT NULL,
    json      TEXT    NOT NULL,
    saved_at  INTEGER NOT NULL,   -- epoch ms
    client_id TEXT,
    UNIQUE(name, version)
);

CREATE TABLE browser_sessions (
    token_hash        TEXT PRIMARY KEY,   -- sha256 of the cookie value
    last_room_name    TEXT,
    last_room_version INTEGER,
    created_at        INTEGER NOT NULL,
    expires_at        INTEGER NOT NULL
);
  • WAL mode, synchronous=NORMAL, busy_timeout=5000 — concurrent readers never block the writer, and a busy save retries instead of failing.
  • One connection is shared across request threads (check_same_thread=False) and every statement is serialised behind a single lock.
  • On first boot, legacy .rcad files in rooms/ are imported as version 1 and removed.

Versioning

POST /api/save always inserts version = max(version) + 1, starting at v0 for a file's first save. Nothing is ever overwritten, so any earlier state can be reopened from the version list.

The room name comes from the client, derived from the design's Room Name, so saving under a new name creates a new room rather than versioning the old one. GET /api/rooms lists the most recently saved first.

Which design does a browser see?

GET /api/session/last answers it:

  1. If this browser has a remembered room and that exact version still exists → return it.
  2. Otherwise → return the project's most recently saved design, and remember it for this browser (flagged projectLatest: true).
  3. If the project has no saved rooms at all → null, and the app opens the built-in demo.

This lives in SQLite rather than local storage, so it survives an API restart and needs no second cookie.

Live drafts

POST /api/live/<name> keeps the newest unsaved draft per room in memory only and pushes it to every watcher with "live": true. A real save clears it. They are lost on restart, which is fine — they are drafts. See Live Collaboration.

Every publish carries a baseSeq, and the server refuses the edit unless it matches the room's current sequence — answering {"ok": false, "stale": true} with the room as it actually is. A client that has fallen behind therefore loses its own stale edit instead of overwriting everyone else's newer work with it. The sequence is per room and is dropped along with the draft when the room is deleted. POST /api/live-check/<name> is the other half: a client with nothing unpublished sends a digest of what it holds, and gets the room back only when it has drifted.

Storage on disk

The database lives at /var/roomcad/rooms.db, or wherever ROOMCAD_DB_PATH points. roomcad/server/rooms.db.sql is structure only — it deliberately carries no room content, because a public repository is the wrong place for real designs. Restore it into an empty file to rebuild the schema, and take the rooms from a backup of the live database:

ssh root@host "sqlite3 /var/roomcad/rooms.db '.dump'" > rooms-$(date +%F).sql

Use .dump rather than copying rooms.db; in WAL mode the file alone may be missing recent commits. server.py creates the schema itself at boot, so the SQL files are a rebuild aid and documentation — not a migration path.

Clone this wiki locally