From faf2e78c9d96161135840cd9e02769845be1675c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 12 Oct 2026 11:07:23 +0700 Subject: [PATCH] M9: install.sh, backup timer and docs for a public figcraft-server packaging/server/install.sh sets up Debian/Ubuntu behind Caddy: binary from its release archive, the source tree it sits in, the latest release or a clone (temporary swap under 2 GB of memory), user, dirs and config only when missing, systemd unit, Caddy site, nightly backup timer, ufw for 22/80/443; safe to re-run for upgrades. docs/server.md gains Running a public server (limits, expiry, moderation, operator responsibility, no end-to-end encryption, threat model) and an install.sh walk-through; protocol, MCP, CLI and parity docs updated. --- .gitattributes | 4 + .github/workflows/release.yml | 2 +- ROADMAP.md | 9 +- docs/cli.md | 8 +- docs/collab-protocol.md | 59 +++- docs/collaboration-parity.md | 7 +- docs/mcp.md | 21 +- docs/server.md | 219 ++++++++++++++- docs/target-app-parity.md | 5 +- packaging/server/Caddyfile | 4 +- packaging/server/figcraft-backup.service | 17 ++ packaging/server/figcraft-backup.timer | 14 + packaging/server/install.sh | 338 +++++++++++++++++++++++ packaging/server/nginx.conf | 2 + packaging/server/package.sh | 5 +- 15 files changed, 682 insertions(+), 32 deletions(-) create mode 100644 .gitattributes create mode 100644 packaging/server/figcraft-backup.service create mode 100644 packaging/server/figcraft-backup.timer create mode 100755 packaging/server/install.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..32e6369 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Scripts and server files run on Linux: keep their line endings LF in every checkout and archive +# (a copy of a Windows checkout must still run packaging/server/install.sh). +*.sh text eol=lf +packaging/server/* text eol=lf diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 47a6ba9..e5b6248 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -284,7 +284,7 @@ jobs: - name: Check the archive run: | tar -tzf "dist/release/figcraft-server-$FIGCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" | tee "$RUNNER_TEMP/server-contents.txt" - for f in '/figcraft-server$' '/figcraft-server\.service$' '/Caddyfile$' '/nginx\.conf$' '/server\.md$' '/LICENSE-MIT$'; do + for f in '/figcraft-server$' '/figcraft-server\.service$' '/Caddyfile$' '/nginx\.conf$' '/install\.sh$' '/figcraft-backup\.timer$' '/server\.md$' '/LICENSE-MIT$'; do grep -E "$f" "$RUNNER_TEMP/server-contents.txt" done ldd target/release/figcraft-server diff --git a/ROADMAP.md b/ROADMAP.md index a5bca7b..8521c7b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -131,6 +131,12 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-12** · A public `figcraft-server`: `open_publish = true` lets anyone publish without the server key + (Share ▸ Publish to server asks the server first and shows who hosts it, its terms and how long unopened files are + kept), with an owner token shown once that manages the file later (`collab.manageFile`); limits per address, + idle expiry, reports (Share ▸ Report this file…), `block-ip` and `files list --sort`, `GET /healthz`, and + `packaging/server/install.sh` that sets up a Debian/Ubuntu machine with Caddy, ufw and backups + ([server.md](docs/server.md#running-a-public-server)). Numbers unchanged (hosting, not a Figma feature). - **2026-10-12** · Experimental `.fig` export (gap 1): `file.export {format: "fig"}`, File ▸ Export… ▸ .fig (experimental), `figcraft-cli export --format fig` and MCP's `export` write the whole document: layers, geometry, paints with their images, strokes, effects, styled text, vector networks, auto layout, components and @@ -251,6 +257,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | A public `figcraft-server` (open hosting): with `open_publish = true` anyone publishes a file without the server key and gets its edit and view links plus an owner token (128 bits, shown once, stored hashed) that lists, makes and revokes its links, renames it or removes it (`collab.manageFile`); limits per address (connections at once and a minute, messages a second, publishes an hour, files and MiB kept; IPv6 by /64; `X-Forwarded-For` believed only from `trusted_proxy`; tables capped at 10,000 addresses) and for everyone (`max_total_mb`), each refusal saying why; files published without the key expire after `idle_days` unopened (the date shown to everyone in the file); moderation (`files list --sort size|age|idle`, `block-ip`, `reports`, a Report button and `collab.report`); the operator's contact and terms shown in the Share dialog; `GET /healthz` on the same port; `packaging/server/install.sh` for a Debian/Ubuntu machine behind Caddy and a nightly backup timer. Protocol additions within version 2, gated like signals (older apps get clear refusals). Hosting infrastructure rather than a Figma feature: percentages and hours unchanged | | 2026-10-12 | minor | Experimental `.fig` export (gap 1): `file.export {format: "fig"}`, File ▸ Export… ▸ .fig (experimental), the CLI and MCP write the whole document (layer tree, geometry, paints with image bytes, strokes, effects, text with styled runs, vector networks, auto layout, constraints, components, sets, instances with their overrides and property values, prototype interactions) with a kiwi encoder mirroring the decoder and the schema of the fields written; what's left out is a warning; verified only by round trips through our own importer. File formats 59% → 60%; import/export 84%/57% → 86%/58%; file exchange stays ×0.85; mainstream hours ~130–244 h → ~120–229 h; feature breadth (~84%), ready for real work (~53%), essentials (~57%), mainstream (~49%), beta (~155–280 h) and full parity (~265–523 h) unchanged at this rounding | | 2026-10-12 | minor | Keyboard shortcut parity suite and bindings (gap 4): a table test with one row per shortcut in the public documentation, macOS and Windows/Linux keys each (129 rows: 126 bound, 110 before; 1 moved off a documented conflict; 2 missing; 2 kept by a platform), plus 48 pinned undocumented bindings so every default binding is in the table; newly bound: A (Frame), Shift+' (pixel grid), Alt+Cmd+\ (others' cursors, new `view.multiplayerCursors`), Ctrl+F6, Shift+Cmd+U (link), Cmd/Ctrl+] and [ (list levels while editing, new `text.listIndent` / `text.listOutdent`), Cmd/Ctrl+Backspace (ungroup), Alt+Space / Ctrl+Space (keyboard box selection, Cmd/Ctrl+arrows draw it out), suggest auto layout, go to main component, arrows that pan with nothing selected (new `view.pan`), Control suspending snapping on macOS, Space/N/C/F while presenting; symbols another layout types with Shift count without it; no default binding uses a system-reserved chord (tested). Menu breadth 207/207 (View ▸ Multiplayer cursors). UI/UX fidelity 58% and the interaction pass 40–60 h unchanged at this rounding | | 2026-10-12 | minor | Simplified Chinese draft catalog (gap 5): `zh-Hans.toml` translates every UI string (1,371 keys; plurals give only `other`, and `plural_one` never picks "one"), command label (524) and menu wording (29), machine-drafted in a concise UI style with full-width punctuation and marked as a draft awaiting native review; Preferences ▸ Language lists 简体中文, System maps zh, zh-CN, zh-SG and zh-Hans to it, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and stays English (or the next OS language), never Simplified. A Chinese UI draws Han in a Chinese face first (Microsoft YaHei, PingFang SC, Noto Sans CJK SC), ahead of the craft-fonts build's Japanese BIZ UDPGothic, and changing the UI language lays the CJK faces out again (switching from Japanese to Chinese no longer keeps the Japanese face in front). String counts re-measured: 1,371 UI keys, 524 command labels and 29 menu wordings (1,924 entries); the French, German, Brazilian Portuguese, Japanese and Korean drafts have 1,899 of them (the 25 newer strings read English). Localization 36% → 39%, 31–54 h → 29–51 h; ready for real work unchanged (~53%); full parity ~267–527 h → ~265–524 h | diff --git a/docs/cli.md b/docs/cli.md index 372857f..c3a581c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -17,7 +17,8 @@ machine, e.g. in CI. The collaboration server is a binary of its own, `figcraft-server` (`cargo build --release -p figcraft-server`): it keeps files with share links and serves their live sessions, and its -`key`, `files` and `links` subcommands manage them. See [server.md](server.md). +`key`, `files`, `links`, `reports`, `block-ip` and `check` subcommands manage them (a server can +be open to the public: [server.md](server.md#running-a-public-server)). See [server.md](server.md). ## Subcommands @@ -119,8 +120,9 @@ machines. Serve MCP on stdio: headless by default, or attached to a running app started with `figcraft --control PORT`. See [mcp.md](mcp.md). The headless session can host, join and publish -live sessions (`collab.*`) and use plugin directories (`plugin.directory.*`); the one-shot `run` -can't (no network link). +live sessions (`collab.*`: without a key on a server open to the public, with `collab.serverInfo`, +`collab.manageFile` for the owner token and `collab.report`) and use plugin directories +(`plugin.directory.*`); the one-shot `run` can't (no network link). ### `plugin publish --server ws://host:port/[?key=KEY]` diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md index 62b16a7..8d15529 100644 --- a/docs/collab-protocol.md +++ b/docs/collab-protocol.md @@ -170,6 +170,16 @@ signals only after that, so an older server never gets one either (the app says older version). A signal is a kind and a text, so kinds added later are skipped by clients and dropped by servers that don't know them. +**Open hosting and reports** came later within version 2 the same way. A connection that opens +with `OpenHosting` asks a server about its hosting, publishes without the key when the server is +open to the public, or manages one file with its owner token; a server older than that can't read +the message and refuses the connection, which the app reports as "takes files only with its key". +A client that reads `Hosting` and `Reported` says so with the word `hosting` in its hello's +`client_version` (`"0.3.1 signals hosting"`); only those clients get a `Hosting` after their +welcome, and a client sends `Report` only to a server that sent it one. New variants only ever go +at the end of an enum, and existing messages keep their fields, so nothing an older peer reads +changes. + **Encoding (decided):** a binary message is a 1-byte wire version followed by **postcard** (serde), which is compact and needs no schema files. Property values inside are compact JSON text: the document types use tagged and flattened serde forms that only self-describing formats can read @@ -195,6 +205,9 @@ Client → server: | `Library {fetch {have?, usage?} \| publish {archive} \| usage}` | A request on a library connection; each gets one `Library` reply, in order. `usage` is `{file, components: [(id, instances)]}`. | | `Signal {kind, text}` | Ephemeral presence, only after the server's `ready`: `chat` (the cursor chat message; empty: closed) or `spotlight` (`"on"`, or empty: stopped). | | `OpenPlugins {protocol, key, client_version}` | Instead of `Hello`: use the server's plugin directory; `key` empty to browse, the server key to publish too. | +| `OpenHosting {protocol, client_version, file, owner}` | Instead of `Hello`: ask about the server's hosting; publish without the key (`Admin publish`) when it is open to the public; with a file's id and owner token, manage that file (`Admin links \| createLink \| revoke \| rename \| remove` of it). Answered with `Hosting` or `Refused`. | +| `Admin {rename {file, name} \| listing \| reports \| block {ip} \| unblock {ip} \| blocked}` | Later managing requests: rename a file on the server, the files with what the operator moderates by, the reports, blocking addresses (an IPv6 address: its /64). Owner tokens may only rename their file. | +| `Report {reason}` | On a joined connection, only to a server that sent `Hosting`: report the file to the operator (at most 500 characters kept; ten an hour per address). | | `Plugins {list {query} \| fetch {id, version?} \| publish {bundle} \| unpublish {id, version?}}` | A request on a plugin directory connection; each gets one `Plugins` reply, in order. `bundle` is a plugin bundle's bytes (`figcraft_plugin::bundle`). | Server → client: @@ -210,6 +223,9 @@ Server → client: | `Admin {ready \| published {file, edit, view} \| files \| links \| link \| done \| failed {reason}}` | A managing connection's answer: the key is right, a published file with its first two links (tokens shown once), listings, a new link, done, or why not. | | `Library {ready {access} \| library {version, archive} \| unchanged {version} \| empty \| published {version} \| usage [{usage, updated}] \| failed {reason}}` | A library connection's answer: the link is right (with its access), the library (a `.figcraft` archive holding it), no newer version, no library yet, kept, the usage reports, or why not. | | `Signal {client_id, signal: {kind, text}}` | Someone's cursor chat or spotlight, cleaned by the server; `ready` (about the client itself, after its welcome): signals may be sent. Only to clients whose hello announced `signals`. | +| `Hosting {open, operator, terms, idle_secs, max_file_mb, expires?}` | The answer to `OpenHosting`, and after a welcome to clients whose hello announced `hosting`: anyone may publish, how to reach the operator, the terms (a URL), how long files published without the key are kept unopened, the largest file, and this file's expiry (seconds since 1970). | +| `Admin {hosted {file, edit, view, owner, expires?} \| listing [row] \| reports [report] \| blocked [ip]}` | A file published without the key with its links and owner token (shown once), and the answers to the later managing requests. | +| `Reported {error?}` | The answer to `Report`: kept, or why not. | | `Plugins {ready {publisher} \| list [entry] \| bundle {entry, bundle} \| published entry \| removed [version] \| failed {reason}}` | A plugin directory connection's answer: opened (with the key or not), the latest version of each plugin with the versions kept, a bundle and its entry (its SHA-256 among them), kept, unpublished, or why not. | `Change` is one of: `Create {id, type, props}`, `Set {id, prop, value}`, `Delete {id}` (deleting a @@ -572,6 +588,32 @@ installing: [`plugins.md`](plugins.md#plugin-directories)): checks the asset names; the app checks the same again after the hash. Fetches share a budget of bytes per second on the server, so many downloads at once are turned away instead of queued. +*Built* (open hosting: a server open to the public; running one: [`server.md`](server.md#running-a-public-server)): + +- **Who the client is.** The hub knows each connection by its client's address: the socket's peer, + or behind the configured reverse proxy (`trusted_proxy`) the last address of its + `X-Forwarded-For`; an IPv6 address counts by its /64. The transport caps connections per address + (counted once the WebSocket handshake names the client); the server's backend refuses blocked + addresses and limits new connections, messages and publishes per address with token buckets in a + table of at most 10,000 addresses. A connection opened with the server key is exempt. +- **Publishing without the key.** `OpenHosting` then `Admin publish`: the server checks the + per-address and global caps, keeps the file with a new owner token (128 bits; only its SHA-256 is + stored, beside the address it came from) and answers `hosted` with the links, the token and the + expiry. The app publishes this way when the address has no key and joins the file with its edit + link as for a keyed publish (`collab.publish`); `collab.serverInfo` asks first and + `collab.manageFile` uses the token later. +- **Expiry.** A file published without the key expires once nobody opened it for the idle time: + its description records when it was last opened (on open and when everyone leaves), the server + deletes expired files at startup and every ten minutes (never one that is open), and `Hosting` + tells everyone in a file its date. +- **Reports and blocks.** `Report` from anyone in a file is kept in `reports.json` (the last 1,000) + with the file, the link the reporter joined with and their address; blocking an address + disconnects its connections and is kept in `blocked.json`. Both are the operator's to read with + the server's command line. +- **Uptime checks.** A plain `GET` of a path ending in `/healthz` on the session port is answered + `200 ok` (the request line is peeked at before the WebSocket handshake, which goes on untouched + otherwise). + *Built* (opening invite links): - The desktop app takes an invite link on its command line (`figcraft "figcraft://join?…"`): once @@ -703,14 +745,22 @@ chooses to share on the network; the default is the invite link with a random to whatever client sends them; the app's read-only state is for clarity. - **Abuse limits.** On top of the transport's: at most `max_files` files, `max_file_mb` per file (a publish over it is refused), `max_links` links per file, `max_people` per file and - `max_connections` sockets. + `max_connections` sockets. Open to the public, per address too: connections at once and a + minute, messages a second, publishes an hour, files and MiB kept, and `max_total_mb` for + everyone; refusals say which. Spoofing `X-Forwarded-For` does nothing unless the connection comes + from the trusted proxy. +- **Open hosting** gives anyone who reaches the server the right to publish (within those limits), + to report a file they are in, and with an owner token to manage that one file. Everything else + (listing files, other files' links, reports, blocks, the plugin directory's publishing) needs the + server key. Files are not end-to-end encrypted: the server reads what it keeps, and so can its + operator. - **TLS.** None in the server: a reverse proxy terminates it (Caddy and nginx examples in `packaging/server/`, deployment in [`server.md`](server.md#deploy-on-a-linux-server)); clients speak `wss://` to the proxy. Tokens and keys are never in the WebSocket request (only in the first message), so proxies don't log them, and the server logs file and link ids, never secrets. -- **Not yet**: accounts and per-person permissions, per-address rate limits, audit logs, - encryption at rest. +- **Not yet**: accounts and per-person permissions, audit logs, encryption at rest, end-to-end + encryption. ## Implementation plan @@ -733,5 +783,6 @@ chooses to share on the network; the default is the invite link with a random to desktop-hosted sessions; `figcraft://` links open the desktop app and join). 5. **Standalone `figcraft-server`** with persistence, compaction, share links and permissions. **Done** (`apps/figcraft-server` on the shared hub; edit and view links, the server key, - publishing from the app; see [`server.md`](server.md)). + publishing from the app; see [`server.md`](server.md)), followed by open hosting for a public + server (owner tokens, limits per address, idle expiry, reports and blocks). 6. **Accounts**: people signing in, per-person permissions, teams and projects. diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index acc8889..84b5137 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -1,6 +1,6 @@ # Collaboration parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model, @@ -12,10 +12,10 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Feature | Figma | FigCraft | Hours | |---|---|---|---| -| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (Share ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker). Left: server archives beyond Linux | 25–55 | +| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (Share ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Left: server archives beyond Linux | 25–55 | | Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors, selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 | | Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 | -| Share links, permissions (view / edit), teams and projects | yes | **partial**: `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. No accounts, teams or projects | 8–17 | +| Share links, permissions (view / edit), teams and projects | yes | **partial**: `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`, no app UI yet), anyone in a file can report it, and the operator blocks addresses. No accounts, teams or projects | 8–17 | | Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences; a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) | | Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file: restoring keeps the replaced design as an autosave first, a titled autosave becomes a named version, merging a branch records the file before and after. Versions are per person in a live session (a shared history needs accounts); no comparing two versions (Dev Mode compares a ready-for-dev layer with its state when marked) | — | | Branching and merging | yes (paid plans) | **done, local**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived); fills, strokes and effects merge item by item. Left: branches in live sessions; other lists (text runs, export settings) conflict as a whole | 1–2 | @@ -26,6 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | A public `figcraft-server` (open hosting): with `open_publish = true` anyone publishes a file without the server key and gets its edit and view links plus an owner token (128 bits, shown once, stored hashed) that lists, makes and revokes its links, renames it or removes it (`collab.manageFile`); limits per address (connections at once and a minute, messages a second, publishes an hour, files and MiB kept; IPv6 by /64; `X-Forwarded-For` believed only from `trusted_proxy`; tables capped at 10,000 addresses) and for everyone (`max_total_mb`), each refusal saying why; files published without the key expire after `idle_days` unopened (the date shown to everyone in the file); moderation (`files list --sort size|age|idle`, `block-ip`, `reports`, a Report button and `collab.report`); the operator's contact and terms shown in the Share dialog; `GET /healthz` on the same port; `packaging/server/install.sh` for a Debian/Ubuntu machine behind Caddy and a nightly backup timer. Protocol additions within version 2, gated like signals (older apps get clear refusals). Hosting infrastructure rather than a Figma feature: percentages and hours unchanged | | 2026-10-12 | minor | Cursor chat and spotlight in live sessions: `/` opens a chat bubble at your pointer that everyone sees as you type and that fades a few seconds after the last change (Enter sends, Escape takes it back, 100 characters, control characters dropped); your avatar's menu (or `collab.spotlight`) asks everyone to follow your view and page, latecomers included, until they move their own view. Both travel as signals: ephemeral presence the host validates, rate-limits (20 a second per person) and passes only to clients that announce they read them, so older clients keep working; never in the document or the log. Live cursors / follow / spotlight row partial → done, 8–15 h → 2–4 h; cursor chat row no → partial (no audio or reactions), 4–8 h → 2–5 h; multiplayer 45–90 h → 42–84 h (it counted these rows only in part); remaining 55–110 h (stale: team libraries had already gone to 4–8 h) → 46–92 h; collaboration ~13% unchanged at this rounding | | 2026-10-12 | minor | A branch's own version history and finer list merges (gap 17): versions saved or autosaved on a branch tab go into the branch (viewed, restored, renamed, deleted and duplicated there, saved in the file with it); merging adds a version to the main file's history and the merged or archived branch keeps its own; fills, strokes and effects merge item by item, conflicting only when the same item changed both ways, an item was removed on one side or both sides added different items. Comments, versions, find, misc breadth 85% → 87%, ready 60% → 61%, 2–5 h → 1–4 h; gap 17 1–3 h → 1–2 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~306–595 h → ~305–594 h | | 2026-10-12 | minor | Comments and version history behaviour suite written from the public documentation (15 tests, about 80 table rows plus stepwise checks; 4 tests failed before the fixes) and its fixes: in a live session, threads made in the session couldn't be replied to, edited, reacted to, resolved, moved, deleted or opened (their ids, from their creator's id space, were refused as out of range); restoring a version brought back that version's comments, losing newer threads and replies (comments now stay, those on layers the version lacks staying where they were); restoring now keeps the replaced design in the history as an autosave first, unless a version of it was just saved or restored; giving an autosave a title makes it a named version (it could be dropped to make room); merging a branch adds the main file as it was and then a `Merged “”` version to its history. Pinned where the documentation is silent: pins go to the innermost frame under them, copies, duplicates and duplicated pages come without comments, replying reopens a resolved thread, versions stay with whoever saved them in a live session (a shared history needs accounts). Left: a branch's own version history, comparing two versions; percentages and hours unchanged here (comments and versions are graded with feature area 16 in target-app-parity) | diff --git a/docs/mcp.md b/docs/mcp.md index 3c58811..c4faac6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -907,11 +907,30 @@ joined file opens as a new document). file, name, edit, view}` with the file's first edit link and view link (they carry their tokens: share them like passwords). Someone joining with the view link gets `access: "view"`: every edit on the synced document is refused with "View only: …" (the server refuses them too). + On a server open to the public ([`server.md`](server.md#running-a-public-server)) `server` needs no + key (`wss://figcraft.example.com/`): `published` then also has `owner` (the owner token, shown this + once: keep it) and `expires` (when the server deletes the file unless someone opens it, seconds + since 1970), and `hosting` says who runs the server. A server that isn't open answers that it + takes files only with its key (`reason` in `collab.peers`). +- `collab.serverInfo {server}` → `{open, operator, terms, idleDays, maxFileMb, expires}`: what a + server says about its hosting, before publishing (`open`: no key needed; `idleDays`: how long + files published without the key are kept unopened, null when they are kept). A server older + than open hosting is reported as taking files only with its key. +- `collab.manageFile {server, file, owner, action, …}` manages a file published without the key, + with its owner token: `links` → `{links: [{id, access, label, created}]}`; `createLink {access?: + view|edit, label?}` → `{id, access, link}` (a new share link, shown once); `revoke {link}` + (whoever joined with it is disconnected), `rename {name}` (its name on the server) and `remove` + (whoever is in it is disconnected) → `{done}`. A wrong token, or another file's, is refused. +- `collab.report {reason}` (at most 500 characters) reports the file you joined on a server to its + operator → `{sent}`; the session's message says when the server kept it, or why not (ten an hour + per address). Servers older than this version don't take reports (`hosting` is null). - `collab.peers {}` → `{available, canHost (false in the web build), status (off|connecting|live|reconnecting|failed), reason?, attempt?, role (host|guest), me, name, color, fileName, pages, link, url, address, network, access (edit|view), readOnly (the active document can't be edited now: view only, or being - published), published (a file you published: {server, file, name, edit, view}), + published), published (a file you published: {server, file, name, edit, view, owner, expires}), + hosting (what the server of the file you're in says: {open, operator, terms, idleDays, maxFileMb, + expires}, or null), follow, signals (the host passes cursor chat and spotlights on), chat (your cursor chat), spotlight (who spotlights, or null), spotlighting (you do), syncing (the document arrived), document (the index of the synced open document), diff --git a/docs/server.md b/docs/server.md index 8de1225..61a6a37 100644 --- a/docs/server.md +++ b/docs/server.md @@ -10,7 +10,9 @@ There are no user accounts yet: a share link is the access to one file, and the manages files (publishing, links, removal). Accounts and per-person permissions come later. A file can also hold a **team library** that other files use through one of its links (see [Team libraries](#team-libraries)), and the server can keep a **plugin directory** for the team (see -[Plugin directory](#plugin-directory)). +[Plugin directory](#plugin-directory)). A server can also be **open to the public**: anyone +publishes without the key and manages their file with its **owner token**, within limits per +address (see [Running a public server](#running-a-public-server)). The protocol and the design behind it are in [`collab-protocol.md`](collab-protocol.md). @@ -31,7 +33,11 @@ Releases also ship it ready-built for Linux (`figcraft-server--linux-x8 - `--data` is the data directory (default `./figcraft-data`), made if missing. One server at a time uses a directory: a second one refuses to start. - Logs go to standard output (`RUST_LOG=debug` for more). They name files, link ids and addresses, - never keys or tokens. + never keys, tokens or what files contain; what the operator watches for comes as `key=value` + lines (`event=published file=… bytes=… ip=… by=public`, `event=refused ip=… reason=…`, + `event=report`, `event=expired`, `event=blocked`). +- `GET /healthz` on the same port answers `200 ok` (on any path ending in `/healthz`), for uptime + checks; nothing else listens. - Stop it like any service (Ctrl+C, `systemctl stop`, `docker stop`, `kill`): on SIGTERM, SIGINT or SIGHUP (Ctrl+C or closing the console on Windows) it saves and compacts the open files and tells everyone it is restarting. Every change it accepted is on disk before anyone is told about @@ -55,6 +61,15 @@ popover then shows an **edit link** and a **view link** to copy. Agents do the s Whoever has the server key can publish files and manage every file's links: give it only to people you trust. +On a server [open to the public](#running-a-public-server) the address alone is enough +(`wss://figcraft.example.com/`): the dialog asks the server first and shows that no key is needed, +who runs it ("Hosted by …" and a link to its terms) and how long unopened files are kept. Publishing +then also gives an **owner token**, shown once with the links in a **File published** window: copy +it and keep it, it is the only way to revoke the links, rename the file or remove it later +(`collab.manageFile` from [MCP](mcp.md#live-sessions)). The Share popover shows the server's line +and the date the server deletes the file unless someone opens it. **Preferences ▸ Server to publish +to** fills the address in (empty by default: the app suggests no server of its own). + ## Joining Share links look like the desktop app's invite links and join the same way (**File ▸ Join live @@ -140,13 +155,17 @@ port as everything else; no other service is involved. | Command | What it does | |---|---| | `figcraft-server key [--rotate]` | The address to publish to, with the key; `--rotate` makes a new key (the server must be stopped) | -| `figcraft-server files [list]` | The files: id, name, people in it now, last change, size | +| `figcraft-server check` | Check the settings (after editing the `--config` file) | +| `figcraft-server files [list] [--sort size\|age\|idle]` | The files: id, name, people in it now, size, created, days unused, expiry, reports, and who published it (`key`, or the address of a file published without it); biggest, oldest or longest unused first | | `figcraft-server files remove FILE` | Remove a file and its links for good (whoever is in it is disconnected) | | `figcraft-server links list FILE` | A file's links: id, access, created, label | | `figcraft-server links create FILE --access edit\|view [--label TEXT]` | A new link, printed once | | `figcraft-server links revoke FILE LINK` | Revoke a link: it no longer joins, and whoever joined with it is disconnected | | `figcraft-server plugins [list]` | The plugin directory: every plugin, its versions, author, access, size and SHA-256 | | `figcraft-server plugins remove ID [--version V]` | Unpublish a version of a plugin, or every version | +| `figcraft-server reports` | What people in files reported to you: when, which file, the link and address they came with, why | +| `figcraft-server block-ip IP` | Refuse an address (an IPv6 address: its /64) and drop its connections; kept in `blocked.json` | +| `figcraft-server unblock-ip IP` / `blocked` | Accept it again / list the blocked addresses | Each takes `--data` (or `--config`). When a server is running on the directory, the command asks it over the same port with the server key (so a revoked link's users are disconnected at once); @@ -165,7 +184,7 @@ are errors): | `data` | `figcraft-data` | The data directory | | `public_url` | (the listen address) | How links name the server, when people reach it another way (a reverse proxy: `wss://figcraft.example.com/`) | | `max_files` | 1000 | Most files kept | -| `max_file_mb` | 64 | Largest file (its snapshot), in MiB; a welcome carries the whole file, and messages are at most 64 MiB | +| `max_file_mb` | 64 (16 when open) | Largest file (its snapshot), in MiB; a welcome carries the whole file, and messages are at most 64 MiB | | `max_links` | 100 | Most share links per file | | `max_connections` | 512 | Most open connections | | `max_people` | 256 | Most people in one file at once | @@ -174,9 +193,22 @@ are errors): | `max_plugins` | 500 | Most plugin versions the plugin directory keeps (all plugins together); 0 turns the directory off | | `max_plugin_mb` | 16 | Largest plugin bundle, in MiB (at most 32) | | `max_plugins_mb` | 1024 | Most MiB of plugin bundles kept | +| `open_publish` | false | Anyone may publish without the key ([a public server](#running-a-public-server)) | +| `operator_contact` | (none) | How to reach you about abuse (an email address or a page), shown to people where they publish | +| `terms_url` | (none) | Your terms of use (`https://…`), linked where people publish | +| `trusted_proxy` | (none) | The reverse proxy's address (`127.0.0.1` for Caddy or nginx on the same machine): only its `X-Forwarded-For` is believed | +| `idle_days` | 30 | Files published without the key are deleted after this many days nobody opened them (0: kept; fractions allowed) | +| `max_total_mb` | 10240 | Publishing without the key stops while all files take this many MiB | +| `max_files_per_ip` | 20 | Open: most files one address published without the key… | +| `max_mb_per_ip` | 256 | …and their MiB | +| `max_connections_per_ip` | 32 | Open: most connections from one address at once | +| `connects_per_minute` | 30 | Open: new connections an address may open a minute (a burst of as many) | +| `publishes_per_hour` | 10 | Open: files an address may publish without the key an hour | +| `messages_per_second` | 200 | Open: messages an address may send a second (a burst of four seconds' worth) | With `listen` on every interface (`0.0.0.0`) and no `public_url`, links name this machine's -network address. +network address. The limits marked *open* apply only with `open_publish = true`; a private server +behaves as before (blocked addresses and report rates apply to every server). ## The data directory @@ -192,8 +224,13 @@ network address. /files//usage.json which files use that library's components, as their apps report it /plugins/index.json the plugin directory: every version kept (manifest fields, size, SHA-256) /plugins/.bin a plugin bundle, named by the SHA-256 of its bytes +/blocked.json the addresses the operator blocked +/reports.json the last 1,000 reports people sent about files ``` +A file published without the key also has, in its `meta.json`, the SHA-256 of its owner token, +the address it came from and when it was last opened (for expiry). + - **Writes are atomic**: a snapshot, a description or a link list is written to a temporary file beside it, flushed, then renamed over the old one. The log is only ever appended to, and synced before the server tells anyone about the change. @@ -213,6 +250,12 @@ network address. ## Backups +[`packaging/server/figcraft-backup.timer`](../packaging/server/figcraft-backup.timer) (installed by +`install.sh`; `sudo systemctl enable --now figcraft-backup.timer`) does the following every night: +it stops the server for the seconds the copy takes, keeps +`/var/backups/figcraft-server/figcraft-YYYY-MM-DD.tar.gz` for 14 days and starts it again. Copy that +folder off the machine too. + Copy the data directory (the plugin directory, `plugins/`, included) while the server is stopped, or take an atomic snapshot of the filesystem it lives on (ZFS, Btrfs, LVM, a VSS shadow copy). A plain copy of a running server's directory can catch a file between its snapshot and its log; it restores, but may lose that @@ -250,12 +293,143 @@ to that authority's certificate (a PEM file) before starting the app: it is trus to the built-in list. There is no way to skip the check: a certificate that has expired, is for another name or comes from an unknown authority is refused, and the app says which. +## Running a public server + +A server **open to the public** (`open_publish = true`) lets anyone publish a file without the +server key and share its links: hosting for people you don't know, with no accounts. The operator +keeps the key for everything else. + +- **Publishing.** The app asks the server about itself first (`collab.serverInfo`): it is open, who + runs it (`operator_contact`), its terms (`terms_url`) and how long unopened files are kept. A file + published without the key gets its edit and view links and an **owner token** (128 random bits, + shown once, kept only as its SHA-256): with it, whoever published the file lists, makes and + revokes its links, renames it on the server or removes it (`collab.manageFile`), and nothing else. + An app older than open hosting is told the server is open when it tries a key. +- **Limits per address** (only when open): connections at once (`max_connections_per_ip`), new + connections a minute (`connects_per_minute`), files published an hour (`publishes_per_hour`), + messages a second (`messages_per_second`), and files and MiB kept (`max_files_per_ip`, + `max_mb_per_ip`); for everyone: `max_files`, `max_file_mb` per file and `max_total_mb` for + publishing without the key. An address is an IPv4 address or an IPv6 /64. Each refusal says why + ("too many connections from your address…", "this server is full…") and the app shows it. + Behind a reverse proxy set `trusted_proxy` to its address: the server then counts the client its + `X-Forwarded-For` names (the last address in it, the one the proxy added) and believes the header + from no one else. The tables of addresses keep at most 10,000 addresses (the least recently seen + go first), so floods of addresses cost bounded memory. +- **Expiry.** A file published without the key is deleted once nobody has opened it for + `idle_days` (30 by default; opening it, editing it and leaving it all count as use). The server + looks every ten minutes and at startup, never deletes a file someone is in, and tells everyone in + a file the date it would go (the Share popover shows it). Files published with the key never + expire. +- **Moderation.** `figcraft-server files list --sort size|age|idle` shows who published what + (the address) and how many reports each file has; `files remove FILE` takes a file down (whoever + is in it is disconnected); `block-ip` refuses an address and drops its connections (kept across + restarts in `blocked.json`; the operator's own key still gets in from anywhere); anyone in a file + can report it (**Report this file…** in the Share popover, `collab.report`; ten an hour per + address), and `figcraft-server reports` lists the reports with the link and address they came + from. Plugin directory publishing stays key-only. +- **Your responsibility.** As the operator you answer for what is published on your server: give a + working abuse contact (`operator_contact`) and terms (`terms_url`), read the reports, and remove + what breaks your terms or the law where you run it. Files are **not end-to-end encrypted**: TLS + protects them on the way, but whoever runs the server (and anyone who gets into the machine) can + read every file, link and report, and the server keeps the address each file was published from. + Say so in your terms. + +At startup a public server warns in its log when `operator_contact` is empty, when it seems to be +behind a reverse proxy (a `wss://` `public_url`, or listening on the loopback interface) without +`trusted_proxy` (every client would then count as the proxy's address, so one person's limits would +stop everyone), and when `idle_days = 0`. `figcraft-server check` reads the settings without +starting. + +### Threat model + +What someone can do, by what they hold (the server is reached through TLS at the proxy): + +| Who | Can | Cannot | +|---|---|---| +| Anyone who reaches the server, with nothing | Ask about its hosting (open, operator, terms, limits); on an open server publish files within the limits per address and for everyone; list and download the plugin directory; ask `/healthz` | Join, read or list any file; see links, reports or blocked addresses; use or learn the server key or anyone's token (none are ever sent back, and all are compared in constant time against hashes); publish plugins; spoof their address through `X-Forwarded-For` (believed only from `trusted_proxy`); get past a block | +| Someone with a file's **view link** | See that file and everyone's changes and presence in it; read its team library and usage; report it | Change it (the server refuses a viewer's edits); reach any other file; manage links | +| Someone with a file's **edit link** | Change that file, publish its team library; report it | Reach any other file; list, make or revoke links; rename or remove it | +| Whoever holds a file's **owner token** (published without the key) | List, make and revoke that file's links (disconnecting whoever joined with a revoked one), rename it on the server, remove it | Touch any other file, even with its id; list files; read reports; block; anything of the plugin directory | +| The **operator** (server key, or the machine) | Everything above for every file, reports, blocks, the plugin directory; read every file on disk | Recover a lost link or owner token (only hashes are kept) | + +Limits that hold against everyone: message and file sizes, connections in all and per address, +connection, message and publish rates per address, files and MiB per address and for everyone, and +idle expiry. Not defended against: someone with many addresses (each gets its own limits; the +global caps still hold), a link or owner token that leaks (revoke links with the owner token or +`figcraft-server links revoke`; remove the file), and the operator reading files (no end-to-end +encryption). + +The settings for a public server, as `install.sh` writes them: + +```toml +listen = "127.0.0.1:47900" +data = "/var/lib/figcraft-server" +public_url = "wss://figcraft.example.com/" +trusted_proxy = "127.0.0.1" +open_publish = true +operator_contact = "abuse@example.com" +terms_url = "https://example.com/figcraft-terms" +idle_days = 30 +max_file_mb = 16 +max_total_mb = 10240 +max_files_per_ip = 20 +max_mb_per_ip = 256 +``` + +### On a fresh Ubuntu server with install.sh + +[`packaging/server/install.sh`](../packaging/server/install.sh) sets up everything below on a new +Debian 12+ or Ubuntu 24.04+ machine (a cloud provider's smallest plan, 1 GB of memory, is enough to +run it). It installs the binary of the release archive it comes in, or builds the copy of the +source it sits in, or else downloads the latest release for the machine (checked against +`SHA256SUMS.txt`) or builds a fresh clone (`--from-source`); a build adds a temporary 2 GB swap +file on a machine with less than 2 GB of memory, so it isn't killed. It makes the `figcraft` user, the +data directory and `/etc/figcraft-server/server.toml` (only when missing: your edits are kept), +installs the systemd unit, the nightly backup timer and the Caddy site, opens SSH, 80 and 443 in +`ufw`, starts it all and prints what to do next. Running it again upgrades the binary and restarts +the server only when something changed. + +1. Create the server (Ubuntu 24.04, x86_64 or arm64) with your SSH key, and note its address. +2. At your DNS provider, point `figcraft.example.com` (A, and AAAA for IPv6) at that address. Caddy + needs it to get the certificate. +3. Log in and run the script, from a release archive: + + ```sh + ssh root@figcraft.example.com + VERSION=$(curl -fsSL https://api.github.com/repos/storytold/figcraft/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p') + curl -fLO "https://github.com/storytold/figcraft/releases/download/v$VERSION/figcraft-server-$VERSION-linux-$(uname -m).tar.gz" + tar -xzf figcraft-server-*.tar.gz && cd figcraft-server-*/ + sudo ./install.sh --host figcraft.example.com --email abuse@example.com --terms https://example.com/figcraft-terms + ``` + + or from a copy of the source (a private repository, or unreleased changes): it then builds that + copy as it is (with the swap file on a small machine). + + ```sh + git archive --format=tar.gz -o figcraft-src.tar.gz HEAD # on your machine + scp figcraft-src.tar.gz root@figcraft.example.com: + ssh root@figcraft.example.com 'mkdir -p figcraft && tar -xzf figcraft-src.tar.gz -C figcraft && + figcraft/packaging/server/install.sh --host figcraft.example.com --email abuse@example.com' + ``` + + Without `--host` or `--email` it asks for them. `--private` keeps publishing key-only; + `--backups` turns the nightly backup on; `--no-firewall` leaves `ufw` alone. To upgrade, copy + the newer source over and run it again. +4. Check it: `curl https://figcraft.example.com/healthz` answers `ok` once Caddy has its + certificate (a minute after DNS is right); `journalctl -u figcraft-server -f` shows the log. +5. In FigCraft, **Share ▸ Publish to server…** with `wss://figcraft.example.com/`. Moderate with + the commands it printed (`sudo -u figcraft figcraft-server --config /etc/figcraft-server/server.toml + files list --sort size`, `reports`, `block-ip`). +6. Point an uptime monitor at `https://figcraft.example.com/healthz`, copy + `/var/backups/figcraft-server` off the machine, and upgrade by running the script again. + ## Deploy on a Linux server Hosting for people outside your network: a small Linux server (1 CPU and 1 GB of memory are plenty for a team), a DNS name pointing at it (`figcraft.example.com` below: use yours), the server behind Caddy or nginx for TLS, and systemd keeping it running. The commands are for Debian -and Ubuntu; other distributions differ only in how packages are installed. +and Ubuntu; other distributions differ only in how packages are installed. `install.sh` does all of +it ([above](#on-a-fresh-ubuntu-server-with-installsh)); these are the steps by hand. **1. Install the binary**, from the latest release (x86_64 or aarch64), checked against the release's `SHA256SUMS.txt`: @@ -292,9 +466,12 @@ sudo tee /etc/figcraft-server/server.toml >/dev/null <<'EOF' listen = "127.0.0.1:47900" data = "/var/lib/figcraft-server" public_url = "wss://figcraft.example.com/" +trusted_proxy = "127.0.0.1" EOF ``` +(Add the settings of [a public server](#running-a-public-server) to open it to everyone.) + **3. Run it with systemd.** [`figcraft-server.service`](../packaging/server/figcraft-server.service) runs it as that user, restarts it if it fails, stops it gracefully, and locks it down: no new privileges, a read-only system with only the data directory writable, no capabilities, private @@ -352,7 +529,9 @@ the `figcraft` user with `--config /etc/figcraft-server/server.toml`. builds a small image (the binary on a distroless base, no shell) that runs as an unprivileged user, keeps its files in the `/data` volume and stops gracefully on `docker stop`. Build it from the repository root, give it a settings file with your `public_url`, and publish its port on the -loopback interface only (ports Docker publishes bypass `ufw`), with the proxy from step 4 in front: +loopback interface only (ports Docker publishes bypass `ufw`), with the proxy from step 4 in front +(connections then come from Docker's bridge, so a `trusted_proxy` is that bridge's gateway address, +`172.17.0.1` by default): ```sh docker build -f packaging/server/Dockerfile -t figcraft-server . @@ -384,17 +563,25 @@ sudo systemctl start figcraft-server ## Security -- **Access** is a share link (128-bit random token) per file and access level, or the server key - (128-bit) for managing files. Tokens are stored as SHA-256 hashes and every comparison runs in - constant time; revoking a link disconnects whoever joined with it. A view link's batches are - refused by the server whatever the client does. +- **Access** is a share link (128-bit random token) per file and access level, an owner token + (128-bit) per file published without the key, or the server key (128-bit) for managing files. + Tokens are stored as SHA-256 hashes and every comparison runs in constant time; revoking a link + disconnects whoever joined with it. A view link's batches are refused by the server whatever the + client does. +- **Open to the public** (`open_publish`): anyone who reaches the server may ask about it, publish + a file (within the limits above) and report a file they are in; an owner token manages only its + own file (its links, its name, removing it). Listing files, other files' links, reports, blocking + and the plugin directory's publishing stay with the server key, which also gets in from blocked + addresses. The per-address limits trust `X-Forwarded-For` only from `trusted_proxy`; without + one, every connection counts as its own address. - **Transport**: plain `ws://` on the server's port; anyone on the network path can read traffic (presence, changes, tokens) unless TLS terminates in front of it. Use a reverse proxy for anything beyond a trusted network ([Deploy on a Linux server](#deploy-on-a-linux-server)). - **Limits**: the transport's (message sizes, 100 messages a second per connection, idle and handshake timeouts, `max_connections`) and the collaboration core's (layers, changes per batch, - `max_people`), plus `max_files`, `max_file_mb` and `max_links`. Every message is decoded - defensively, and fuzzed in the tests. A library is read in full before it is kept (as files + `max_people`: the cap per file), plus `max_files`, `max_file_mb` and `max_links`, and on a public + server the limits per address. Every message is decoded defensively, and fuzzed in the tests + (including open hosting, owner tokens and reports). A library is read in full before it is kept (as files using it read it: size limits on every part, at most `max_file_mb`), must be the file's library at a newer version, and is checked on the server's main thread; usage reports keep at most 2,000 components per file and 500 files per library. @@ -406,4 +593,8 @@ sudo systemctl start figcraft-server both ends and share a bandwidth budget. Installing still means trusting the publisher: a plugin runs in FigCraft's sandbox with the permissions its manifest asks for, which the app shows before it installs anything. -- **Not there yet**: user accounts, per-person permissions, rate limits per address, audit logs. +- **Not end-to-end encrypted**: the server reads every file it keeps (it validates and stores them); + so can its operator. +- **Not there yet**: user accounts, per-person permissions, audit logs, end-to-end encryption, and + checking a file's size as it grows by edits (the core's caps bound each edit; `max_file_mb` is + checked when a file is published and logged when it is compacted). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e27a493..59b0b2a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -110,7 +110,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 14 | Import/export | 6 | 86% | 58% | `.fig` import's tail (best-effort import, editable vector networks, stand-ins for non-design layers and variable prototype actions landed; no real-file checks yet), checking the experimental `.fig` export (landed; verified only through our own importer) on real files, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 5–10 | | 15 | Dev Mode / inspect | 2 | 96% | 72% | Import lines for connected instances inside generated layer trees; an editor extension is left to the MCP server (inspect behaviour suite with colour formats, rem and icon and image assets, code connect, plugins in Dev Mode and dev resources links landed) ([dev-mode-parity](dev-mode-parity.md)) | 0–1 | | 16 | Comments, versions, find, misc | 3 | 87% | 63% | Comments are local only; comparing two versions; branches in live sessions; text runs and other lists besides fills, strokes and effects merge whole (branches with review and merge, their own version history and per-item list merges, and a comments and version history behaviour suite from the documentation landed) | 1–3 | -| 17 | Multiplayer & sharing | 8 | 17% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, cursor chat and spotlight, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view. Left: accounts and teams, audio ([collaboration-parity](collaboration-parity.md)) | 42–84 | +| 17 | Multiplayer & sharing | 8 | 17% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, cursor chat and spotlight, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view, which can be open to the public (owner tokens, limits per address, expiry, moderation). Left: accounts and teams, audio ([collaboration-parity](collaboration-parity.md)) | 42–84 | | 18 | Plugins & widgets | 4 | 60% | 35% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK), plugin panels called back per button or changed value and per-plugin settings (ABI v2), then widgets (API 3: plugin-drawn layers on the canvas with state kept in the document, redrawn on clicks; a Counter example), panels side by side and a colour picker for plugin colour fields, then a plugin directory on `figcraft-server` (publish with the server key, browse, install after a permissions confirmation, hash-checked downloads, updates by version). Left: a public catalogue, publisher accounts and signatures, ratings, background update checks, the directory in the browser; widgets take clicks only (no text input, property menus, hover or timers) and draw frames, text, shapes and images only; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 8–26 | | 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4–8 | | 20 | Figma Draw extras | 2 | 88% | 60% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses), multi-edit (several vector layers in edit mode at once). Missing: pressure from macOS/Linux tablets | 1–3 | @@ -625,6 +625,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | A public `figcraft-server` (open hosting): with `open_publish = true` anyone publishes a file without the server key and gets its edit and view links plus an owner token (128 bits, shown once, stored hashed) that lists, makes and revokes its links, renames it or removes it (`collab.manageFile`); limits per address (connections at once and a minute, messages a second, publishes an hour, files and MiB kept; IPv6 by /64; `X-Forwarded-For` believed only from `trusted_proxy`; tables capped at 10,000 addresses) and for everyone (`max_total_mb`), each refusal saying why; files published without the key expire after `idle_days` unopened (the date shown to everyone in the file); moderation (`files list --sort size|age|idle`, `block-ip`, `reports`, a Report button and `collab.report`); the operator's contact and terms shown in the Share dialog; `GET /healthz` on the same port; `packaging/server/install.sh` for a Debian/Ubuntu machine behind Caddy and a nightly backup timer. Protocol additions within version 2, gated like signals (older apps get clear refusals). Hosting infrastructure rather than a Figma feature: percentages and hours unchanged | | 2026-10-12 | minor | Experimental `.fig` export (gap 1): the whole document written from the same public format notes as the import, verified only by round trips through our own importer. Import/export breadth 84% → 86%, ready 57% → 58%; file formats 59% → 60%; file exchange with Figma users stays ×0.85 (nothing shows another app opens the files); mainstream hours ~130–244 h → ~120–229 h (`.fig` export 15–30 h → checking it elsewhere 5–15 h); feature breadth (~84%), ready for real work (~53%), essentials (~57%), mainstream (~49%), beta and full parity hours unchanged at this rounding | | 2026-10-12 | minor | Keyboard shortcut parity suite and bindings (gap 4): a table test with one row per shortcut in the public documentation, macOS and Windows/Linux keys each (129 rows: 126 bound, 110 before; 1 moved off a documented conflict; 2 missing; 2 kept by a platform), plus 48 pinned undocumented bindings so every default binding is in the table; newly bound: A (Frame), Shift+' (pixel grid), Alt+Cmd+\ (others' cursors, new `view.multiplayerCursors`), Ctrl+F6, Shift+Cmd+U (link), Cmd/Ctrl+] and [ (list levels while editing, new `text.listIndent` / `text.listOutdent`), Cmd/Ctrl+Backspace (ungroup), Alt+Space / Ctrl+Space (keyboard box selection, Cmd/Ctrl+arrows draw it out), suggest auto layout, go to main component, arrows that pan with nothing selected (new `view.pan`), Control suspending snapping on macOS, Space/N/C/F while presenting; symbols another layout types with Shift count without it; no default binding uses a system-reserved chord (tested). Menu breadth 207/207 (View ▸ Multiplayer cursors). UI/UX fidelity 58% and the interaction pass 40–60 h unchanged at this rounding | | 2026-10-12 | minor | Simplified Chinese draft catalog (gap 5): `zh-Hans.toml` translates every UI string (1,371 keys; plurals give only `other`, and `plural_one` never picks "one"), command label (524) and menu wording (29), machine-drafted in a concise UI style with full-width punctuation and marked as a draft awaiting native review; Preferences ▸ Language lists 简体中文, System maps zh, zh-CN, zh-SG and zh-Hans to it, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and stays English (or the next OS language), never Simplified. A Chinese UI draws Han in a Chinese face first (Microsoft YaHei, PingFang SC, Noto Sans CJK SC), ahead of the craft-fonts build's Japanese BIZ UDPGothic, and changing the UI language lays the CJK faces out again (switching from Japanese to Chinese no longer keeps the Japanese face in front). String counts re-measured: 1,371 UI keys, 524 command labels and 29 menu wordings (1,924 entries); the French, German, Brazilian Portuguese, Japanese and Korean drafts have 1,899 of them (the 25 newer strings read English). Localization 36% → 39%, 31–54 h → 29–51 h; ready for real work unchanged (~53%); full parity ~267–527 h → ~265–524 h | diff --git a/packaging/server/Caddyfile b/packaging/server/Caddyfile index 2e3d5d5..cd34394 100644 --- a/packaging/server/Caddyfile +++ b/packaging/server/Caddyfile @@ -1,7 +1,9 @@ # Caddy in front of figcraft-server; setup in docs/server.md ("Deploy on a Linux server"). # Replace the host name with yours (its DNS must point at this machine). Caddy obtains and renews # the certificate and passes WebSocket upgrades through by itself. Put this in /etc/caddy/Caddyfile -# and run `sudo systemctl reload caddy`. +# and run `sudo systemctl reload caddy` (install.sh does all this for you). +# Caddy tells the server who each client is (X-Forwarded-For): set trusted_proxy = "127.0.0.1" in +# server.toml so per-address limits and blocks count clients, not Caddy. figcraft.example.com { reverse_proxy 127.0.0.1:47900 diff --git a/packaging/server/figcraft-backup.service b/packaging/server/figcraft-backup.service new file mode 100644 index 0000000..5389782 --- /dev/null +++ b/packaging/server/figcraft-backup.service @@ -0,0 +1,17 @@ +# Nightly backup of figcraft-server's data directory (an example; docs/server.md, "Backups"). +# install.sh installs it with figcraft-backup.timer; turn it on with +# sudo systemctl enable --now figcraft-backup.timer +# It stops the server for the few seconds the copy takes (apps reconnect on their own), writes +# /var/backups/figcraft-server/figcraft-YYYY-MM-DD.tar.gz and keeps the last 14 days. Copy that +# folder off the machine too (rsync, your provider's snapshots, object storage). + +[Unit] +Description=Back up figcraft-server's data directory +After=figcraft-server.service + +[Service] +Type=oneshot +Nice=10 +IOSchedulingClass=idle +# `$$` and `%%` are a literal `$` and `%` for the shell (systemd expands the single ones). +ExecStart=/bin/sh -ec 'dir=/var/backups/figcraft-server; install -d -m 700 "$$dir"; systemctl stop figcraft-server; trap "systemctl start figcraft-server" EXIT; tar -C /var/lib -czf "$$dir/figcraft-$$(date +%%F).tar.gz" figcraft-server; find "$$dir" -name "figcraft-*.tar.gz" -mtime +14 -delete' diff --git a/packaging/server/figcraft-backup.timer b/packaging/server/figcraft-backup.timer new file mode 100644 index 0000000..ff2a15d --- /dev/null +++ b/packaging/server/figcraft-backup.timer @@ -0,0 +1,14 @@ +# Runs figcraft-backup.service every night (see that file). +# sudo systemctl enable --now figcraft-backup.timer +# systemctl list-timers figcraft-backup.timer + +[Unit] +Description=Nightly backup of figcraft-server's data directory + +[Timer] +OnCalendar=*-*-* 04:00:00 +RandomizedDelaySec=30min +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/packaging/server/install.sh b/packaging/server/install.sh new file mode 100755 index 0000000..da19d32 --- /dev/null +++ b/packaging/server/install.sh @@ -0,0 +1,338 @@ +#!/usr/bin/env bash +# Install or upgrade figcraft-server on a Debian or Ubuntu machine, behind Caddy (TLS), as a +# server open to the public. See docs/server.md ("Running a public server"). +# +# sudo ./install.sh --host figcraft.example.com --email you@example.com +# +# It installs the binary (from this folder when run from a release archive; built from the source +# tree around this script when it sits in one, e.g. a copy of the repository; else the latest +# release for this machine checked against its SHA256SUMS.txt, else built from a fresh clone; builds +# add a temporary swap file when the machine has under 2 GB of memory), makes the `figcraft` user, its +# data directory and /etc/figcraft-server/server.toml (only when missing: your edits are kept), +# installs the systemd unit and the Caddy site, opens SSH, 80 and 443 in ufw and starts it all. +# Safe to run again: it upgrades the binary and restarts the server only when something changed. +# +# Options: +# --host HOST the DNS name people use (its A/AAAA records must point at this machine) +# --email EMAIL for Let's Encrypt and, in a new config, the abuse contact shown to users +# --terms URL in a new config: your terms of use (https://...) +# --private in a new config: only people with the server key publish (not open) +# --version X.Y.Z install this release (default: the latest) +# --from-source build from source even when a release archive exists +# --ref REF the git branch or tag to clone and build (default: vX.Y.Z with --version, +# else main; a source tree around this script is built as it is) +# --backups also enable the nightly backup timer (stops the server for a few seconds) +# --no-firewall leave ufw alone +# -h, --help this text +set -euo pipefail + +REPO="storytold/figcraft" +CONFIG_DIR=/etc/figcraft-server +CONFIG="$CONFIG_DIR/server.toml" +DATA=/var/lib/figcraft-server +BIN=/usr/local/bin/figcraft-server +BUILD=/var/cache/figcraft-build +SERVICE=figcraft-server +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# The source tree this script sits in (packaging/server/ of a copy or checkout), if any. +LOCAL_SRC="" +if [ -f "$HERE/../../Cargo.toml" ] && [ -d "$HERE/../../apps/figcraft-server" ]; then + LOCAL_SRC="$(cd "$HERE/../.." && pwd)" +fi + +HOST="" +EMAIL="" +TERMS="" +OPEN=true +VERSION="" +FROM_SOURCE=false +REF="" +BACKUPS=false +FIREWALL=true + +say() { printf '==> %s\n' "$*"; } +die() { printf 'install.sh: %s\n' "$*" >&2; exit 1; } + +while [ $# -gt 0 ]; do + case "$1" in + --host) HOST="${2:-}"; shift 2 ;; + --email) EMAIL="${2:-}"; shift 2 ;; + --terms) TERMS="${2:-}"; shift 2 ;; + --private) OPEN=false; shift ;; + --version) VERSION="${2:-}"; shift 2 ;; + --from-source) FROM_SOURCE=true; shift ;; + --ref) REF="${2:-}"; shift 2 ;; + --backups) BACKUPS=true; shift ;; + --no-firewall) FIREWALL=false; shift ;; + -h | --help) sed -n '2,26p' "$0"; exit 0 ;; + *) die "unknown option $1 (see --help)" ;; + esac +done + +[ "$(id -u)" -eq 0 ] || die "run it as root (sudo ./install.sh ...)" +# shellcheck source=/dev/null +. /etc/os-release +case " ${ID:-} ${ID_LIKE:-} " in + *" debian "* | *" ubuntu "*) ;; + *) die "this script is for Debian and Ubuntu (see docs/server.md for other systems)" ;; +esac + +ask() { # ask VAR "question": read an answer from the terminal when the value wasn't given + local var="$1" question="$2" answer + if [ -z "${!var}" ]; then + [ -t 0 ] || die "give $3 (no terminal to ask on)" + read -r -p "$question " answer + printf -v "$var" '%s' "$answer" + fi +} +ask HOST "DNS name people will use (e.g. figcraft.example.com):" "--host" +ask EMAIL "Email for Let's Encrypt and the abuse contact shown to users:" "--email" +case "$HOST" in + "" | *[!A-Za-z0-9.-]* | .* | *. ) die "--host must be a DNS name like figcraft.example.com" ;; +esac +case "$EMAIL" in + *@*.*) ;; + *) die "--email must be an email address" ;; +esac +case "$TERMS" in + "" | https://* | http://*) ;; + *) die "--terms must be a web address (https://...)" ;; +esac +case "$EMAIL$TERMS" in + *[[:space:]\"\\]*) die "--email and --terms can't contain spaces, quotes or backslashes" ;; +esac + +export DEBIAN_FRONTEND=noninteractive +say "Packages" +apt-get update -qq +apt-get install -y -qq curl ca-certificates tar caddy ufw >/dev/null + +ARCH="$(uname -m)" +case "$ARCH" in + amd64) ARCH=x86_64 ;; + arm64) ARCH=aarch64 ;; +esac + +# --- The binary ----------------------------------------------------------------------------- +NEW_BIN="" +FILES="$HERE" # where the unit and the Caddyfile come from + +from_release() { # download and check the release archive; sets NEW_BIN and FILES + local version="$VERSION" name dir + if [ -z "$version" ]; then + version="$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p' | head -n 1)" || return 1 + [ -n "$version" ] || return 1 + fi + name="figcraft-server-$version-linux-$ARCH" + dir="$BUILD/release" + rm -rf "$dir" + mkdir -p "$dir" + say "Downloading $name" + curl -fsSL -o "$dir/$name.tar.gz" "https://github.com/$REPO/releases/download/v$version/$name.tar.gz" || return 1 + curl -fsSL -o "$dir/SHA256SUMS.txt" "https://github.com/$REPO/releases/download/v$version/SHA256SUMS.txt" || return 1 + (cd "$dir" && grep " $name.tar.gz\$" SHA256SUMS.txt | sha256sum -c --quiet -) || die "the download doesn't match SHA256SUMS.txt" + tar -C "$dir" -xzf "$dir/$name.tar.gz" + NEW_BIN="$dir/$name/figcraft-server" + FILES="$dir/$name" +} + +SWAP="" +cleanup() { + if [ -n "$SWAP" ]; then + swapoff "$SWAP" 2>/dev/null || true + rm -f "$SWAP" + fi +} +trap cleanup EXIT + +from_source() { # build the source tree around this script, else a fresh clone; sets NEW_BIN and FILES + local mem_kb src ref="$REF" + if [ -z "$ref" ]; then + if [ -n "$VERSION" ]; then ref="v$VERSION"; else ref=main; fi + fi + apt-get install -y -qq build-essential git pkg-config >/dev/null + mkdir -p "$BUILD" + export RUSTUP_HOME="$BUILD/rustup" CARGO_HOME="$BUILD/cargo" CARGO_TARGET_DIR="$BUILD/target" + if [ ! -x "$CARGO_HOME/bin/cargo" ]; then + curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable --no-modify-path >/dev/null + else + "$CARGO_HOME/bin/rustup" update stable >/dev/null + fi + if [ -n "$LOCAL_SRC" ]; then + src="$LOCAL_SRC" + say "Building the source in $src: this takes a while" + else + src="$BUILD/src" + say "Building from a clone ($ref): this takes a while" + if [ -d "$src/.git" ]; then + git -C "$src" fetch --quiet --tags origin + else + rm -rf "$src" + git clone --quiet "https://github.com/$REPO" "$src" || die "can't clone $REPO (a private repository: copy the source to this machine and run its packaging/server/install.sh)" + fi + # A branch as it is upstream now, or a tag. + git -C "$src" checkout --quiet --force --detach "origin/$ref" 2>/dev/null || + git -C "$src" checkout --quiet --force --detach "$ref" + fi + # The compiler needs about 2 GB: add a temporary swap file on smaller machines. + mem_kb="$(awk '/^MemTotal:/ {print $2}' /proc/meminfo)" + if [ "$mem_kb" -lt 2000000 ] && [ -z "$(swapon --show --noheadings)" ]; then + SWAP="$BUILD/swapfile" + say "Adding a temporary 2 GB swap file for the build" + fallocate -l 2G "$SWAP" 2>/dev/null || dd if=/dev/zero of="$SWAP" bs=1M count=2048 status=none + chmod 600 "$SWAP" + mkswap "$SWAP" >/dev/null + swapon "$SWAP" + export CARGO_BUILD_JOBS=1 + fi + (cd "$src" && "$CARGO_HOME/bin/cargo" build --release --locked -p figcraft-server) + cleanup + SWAP="" + NEW_BIN="$CARGO_TARGET_DIR/release/figcraft-server" + FILES="$src/packaging/server" +} + +if [ -x "$HERE/figcraft-server" ] && [ "$FROM_SOURCE" = false ]; then + NEW_BIN="$HERE/figcraft-server" # run from an unpacked release archive +elif [ -n "$LOCAL_SRC" ] && [ -z "$VERSION" ]; then + from_source # the source copied to (or checked out on) this machine, as it is +elif [ "$FROM_SOURCE" = true ] || ! from_release; then + [ "$FROM_SOURCE" = true ] || say "No release archive for linux-$ARCH: building from source instead" + from_source +fi +[ -x "$NEW_BIN" ] || die "no figcraft-server binary to install" +# The unit files and the Caddyfile beside this script when it has them (a checkout or an archive). +[ -f "$HERE/figcraft-backup.timer" ] && FILES="$HERE" +for f in figcraft-server.service Caddyfile figcraft-backup.service figcraft-backup.timer; do + [ -f "$FILES/$f" ] || die "$FILES/$f is missing" +done + +CHANGED=false +if ! cmp -s "$NEW_BIN" "$BIN"; then + say "Installing $("$NEW_BIN" --version)" + install -m 755 "$NEW_BIN" "$BIN.new" + mv -f "$BIN.new" "$BIN" + CHANGED=true +fi + +# --- User, data and settings ---------------------------------------------------------------- +id -u figcraft >/dev/null 2>&1 || useradd --system --home-dir "$DATA" --shell /usr/sbin/nologin figcraft +install -d -o figcraft -g figcraft -m 700 "$DATA" +install -d -m 755 "$CONFIG_DIR" +if [ ! -f "$CONFIG" ]; then + say "Writing $CONFIG" + { + printf '# figcraft-server settings (docs/server.md, "Configuration"). install.sh never overwrites this file.\n' + printf 'listen = "127.0.0.1:47900"\n' + printf 'data = "%s"\n' "$DATA" + printf 'public_url = "wss://%s/"\n' "$HOST" + printf '# Caddy on this machine: its X-Forwarded-For names the client.\n' + printf 'trusted_proxy = "127.0.0.1"\n' + printf '# Anyone may publish without the key (false: only people with the key).\n' + printf 'open_publish = %s\n' "$OPEN" + printf '# Shown to people where they publish: how to reach you about abuse, and your terms.\n' + printf 'operator_contact = "%s"\n' "$EMAIL" + printf 'terms_url = "%s"\n' "$TERMS" + printf '# Files published without the key are deleted after this many days unopened.\n' + printf 'idle_days = 30\n' + printf 'max_file_mb = 16\n' + printf '# All files together, and per address (see docs/server.md for every limit).\n' + printf 'max_total_mb = 10240\n' + printf 'max_files_per_ip = 20\n' + printf 'max_mb_per_ip = 256\n' + } >"$CONFIG.new" + chmod 644 "$CONFIG.new" + mv -f "$CONFIG.new" "$CONFIG" + CHANGED=true +fi +"$BIN" --config "$CONFIG" check >/dev/null || + die "$CONFIG isn't right for this figcraft-server (see above; a release older than open hosting needs --from-source)" + +install_file() { # install_file SOURCE TARGET MODE: true when it changed + if cmp -s "$1" "$2"; then return 1; fi + install -m "$3" "$1" "$2" +} + +# --- systemd -------------------------------------------------------------------------------- +UNITS=false +install_file "$FILES/figcraft-server.service" /etc/systemd/system/figcraft-server.service 644 && UNITS=true +install_file "$FILES/figcraft-backup.service" /etc/systemd/system/figcraft-backup.service 644 && UNITS=true +install_file "$FILES/figcraft-backup.timer" /etc/systemd/system/figcraft-backup.timer 644 && UNITS=true +if [ "$UNITS" = true ]; then + systemctl daemon-reload + CHANGED=true +fi +systemctl enable --quiet "$SERVICE" +if [ "$CHANGED" = true ] || ! systemctl is-active --quiet "$SERVICE"; then + say "Starting figcraft-server" + systemctl restart "$SERVICE" +fi +if [ "$BACKUPS" = true ]; then + systemctl enable --now --quiet figcraft-backup.timer +fi + +# --- Caddy ---------------------------------------------------------------------------------- +MARK="# figcraft-server (install.sh)" +CADDYFILE=/etc/caddy/Caddyfile +OWN=true +# Anything but the packaged example site (or ours) is someone else's: leave it, add a file of ours. +if [ -f "$CADDYFILE" ] && ! grep -qF "$MARK" "$CADDYFILE" && grep -qvE '^\s*(#|$|:80|\{|\}|root|file_server)' "$CADDYFILE"; then + CADDYFILE=/etc/caddy/figcraft-server.caddy + OWN=false +fi +SITE="$(mktemp)" +{ + printf '%s: written by packaging/server/install.sh (run it again to update).\n' "$MARK" + # Global options belong at the top of the main Caddyfile only. + [ "$OWN" = false ] || printf '{\n\temail %s\n}\n\n' "$EMAIL" + sed -e '/^#/d' -e "s/figcraft\.example\.com/$HOST/" "$FILES/Caddyfile" | sed -e '/./,$!d' +} >"$SITE" +[ "$OWN" = true ] || say "Caddy already serves other sites: add 'import figcraft-server.caddy' to /etc/caddy/Caddyfile" +if install_file "$SITE" "$CADDYFILE" 644; then + caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile >/dev/null 2>&1 || die "Caddy doesn't accept /etc/caddy/Caddyfile" + systemctl reload caddy || systemctl restart caddy +fi +rm -f "$SITE" +systemctl enable --now --quiet caddy + +# --- Firewall ------------------------------------------------------------------------------- +if [ "$FIREWALL" = true ]; then + say "Firewall: SSH, 80 and 443" + ufw allow 22/tcp >/dev/null + # The port this SSH session came in on, if it isn't 22. + if [ -n "${SSH_CONNECTION:-}" ]; then + ufw allow "${SSH_CONNECTION##* }/tcp" >/dev/null + fi + ufw allow 80/tcp >/dev/null + ufw allow 443/tcp >/dev/null + ufw --force enable >/dev/null +fi + +# --- Done ----------------------------------------------------------------------------------- +for _ in $(seq 1 50); do + curl -fs http://127.0.0.1:47900/healthz >/dev/null && break + sleep 0.2 +done +curl -fs http://127.0.0.1:47900/healthz >/dev/null || die "figcraft-server isn't answering: journalctl -u $SERVICE" +ADMIN="sudo -u figcraft figcraft-server --config $CONFIG" +cat < Publish to server..., address wss://$HOST/ + $(if [ "$OPEN" = true ]; then echo "(open to the public: no key needed; each file gets an owner token)"; else echo "(with the key: $ADMIN key)"; fi) + Check: curl https://$HOST/healthz (once DNS points here and Caddy has its certificate) + Logs: journalctl -u $SERVICE -f + + Moderate: $ADMIN files list --sort size + $ADMIN files remove FILE + $ADMIN reports + $ADMIN block-ip ADDRESS (unblock-ip, blocked) + Server key: $ADMIN key + Settings: $CONFIG (then: systemctl restart $SERVICE) + Backups: systemctl enable --now figcraft-backup.timer (nightly, to /var/backups/figcraft-server) + Upgrade: run this script again + +You are responsible for what people publish here: keep the abuse contact and terms current. +Files are not end-to-end encrypted: whoever runs the server can read them. +EOF diff --git a/packaging/server/nginx.conf b/packaging/server/nginx.conf index a2f9b86..5dac37e 100644 --- a/packaging/server/nginx.conf +++ b/packaging/server/nginx.conf @@ -33,6 +33,8 @@ server { proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; + # Who the client is, for per-address limits and blocks (trusted_proxy = "127.0.0.1"). + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # Apps ping every 15 seconds and the server drops a connection idle for 45: stay above both. proxy_read_timeout 120s; proxy_send_timeout 120s; diff --git a/packaging/server/package.sh b/packaging/server/package.sh index de01198..7d2f9c5 100755 --- a/packaging/server/package.sh +++ b/packaging/server/package.sh @@ -4,7 +4,7 @@ # $DIST/figcraft-server---.tar.gz # # One directory with the figcraft-server binary, the licences, docs/server.md and the deployment -# examples (systemd unit, Caddyfile, nginx). A headless binary: no desktop files, no fonts. +# files (install.sh, systemd units, Caddyfile, nginx). A headless binary: no desktop files, no fonts. # # Usage: packaging/server/package.sh [--skip-build] set -euo pipefail @@ -39,7 +39,8 @@ mkdir -p "$STAGE" cp "$CARGO_TARGET_DIR/release/figcraft-server" "$STAGE/" strip "$STAGE/figcraft-server" 2>/dev/null || true for f in LICENSE-MIT LICENSE-APACHE NOTICE docs/server.md; do cp "$ROOT/$f" "$STAGE/"; done -cp "$HERE/figcraft-server.service" "$HERE/Caddyfile" "$HERE/nginx.conf" "$STAGE/" +cp "$HERE/figcraft-server.service" "$HERE/figcraft-backup.service" "$HERE/figcraft-backup.timer" "$HERE/Caddyfile" "$HERE/nginx.conf" "$STAGE/" +install -m 755 "$HERE/install.sh" "$STAGE/" # Smoke test: the binary runs and reports the version being shipped. "$STAGE/figcraft-server" --version | grep -F "$VERSION"