Compare commits
6 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
7d078e9bd8 | |
|
|
d74eeb4ab3 | |
|
|
395a2d9dae | |
|
|
47d56034a5 | |
|
|
d0f649699c | |
|
|
db6f522612 |
|
|
@ -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
|
||||
|
|
@ -35,6 +35,8 @@ jobs:
|
|||
"$RUNNER_TEMP/actionlint" -color
|
||||
- name: shellcheck
|
||||
run: shellcheck packaging/*.sh packaging/*/*.sh packaging/linux/AppRun
|
||||
- name: Server install script (Caddyfile handling)
|
||||
run: bash packaging/server/install-test.sh
|
||||
- name: AppImage AppRun desktop integration
|
||||
run: bash packaging/linux/apprun-test.sh
|
||||
- name: PowerShell
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1645,12 +1645,14 @@ dependencies = [
|
|||
"figcraft-format",
|
||||
"figcraft-plugin",
|
||||
"log",
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"toml",
|
||||
"tungstenite",
|
||||
"wat",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
10
ROADMAP.md
10
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,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Public `figcraft-server` hardened against resource exhaustion after a security review: messages capped by what a connection is for (a file's size when open), byte-bounded send queues and a bound on bytes waiting for the hub, files checked as they grow (refused batches roll back with the reason; per-address and total caps count growth), the core's recent-batch memory bounded in bytes, no confirmation for changes that couldn't be written, team libraries only in key-published files on public servers with unpacking bounded by the file cap, usage reports kept in memory, layered IPv6 limits (/64, /56, /48), a global publish rate, connections reserved for sessions, per-address download shares, library connections cut on revoke and removal, a private data directory and key, and safer install.sh, systemd and backups. Percentages and hours unchanged |
|
||||
| 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 |
|
||||
|
|
|
|||
|
|
@ -33,7 +33,13 @@ env_logger = { version = "0.11", default-features = false, features = ["humantim
|
|||
# Stopping gracefully on SIGTERM/SIGINT/SIGHUP (systemd, Docker, Ctrl+C) and Ctrl+C/close on Windows.
|
||||
ctrlc = { version = "3", features = ["termination"] }
|
||||
|
||||
# Free space on the data directory's disk (statvfs, without unsafe code of our own).
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
rustix = { version = "1", default-features = false, features = ["std", "fs"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# Crafting hostile archives (zip bombs) in tests.
|
||||
zip = { workspace = true }
|
||||
wat = { workspace = true }
|
||||
figcraft-engine = { workspace = true }
|
||||
figcraft-doc = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,160 @@
|
|||
//! What one address may do how often: new connections, publishing without the key and messages
|
||||
//! (only on a server open to the public), and reports and downloads (always). Each is a token
|
||||
//! bucket per block an address counts in ([`ip_blocks`]: an IPv4 address, or an IPv6 /64 and its
|
||||
//! /56 and /48 with 2 and 4 times the rate), so a tunnel's /48 doesn't get 65,536 /64s' worth. Publishing has a budget for
|
||||
//! everyone besides ([`PUBLISHES_ALL`] times one address's). The table keeps at most
|
||||
//! [`MAX_ADDRESSES`] addresses: when it is full the half used longest ago is forgotten, so a flood
|
||||
//! of addresses costs bounded memory (and forgotten addresses start with full buckets).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::time::Instant;
|
||||
|
||||
use figcraft_collab_net::hub::{Block, ip_blocks};
|
||||
use figcraft_collab_net::net::Bucket;
|
||||
|
||||
/// Most addresses remembered.
|
||||
pub const MAX_ADDRESSES: usize = 10_000;
|
||||
|
||||
/// Reports one address may send an hour.
|
||||
pub const REPORTS_PER_HOUR: f64 = 10.0;
|
||||
|
||||
/// Bytes one address may download a second (team libraries, plugins), and at once: an IPv6 /48
|
||||
/// gets four times that, an eighth of everyone's budgets (32 MiB a second, 512 MiB at once).
|
||||
pub const DOWNLOAD_RATE: f64 = (1u64 << 20) as f64;
|
||||
pub const DOWNLOAD_BURST: f64 = (16u64 << 20) as f64;
|
||||
|
||||
/// Everyone together may publish without the key this many times what one address may: eight
|
||||
/// times what the widest block (an IPv6 /48, four addresses' worth) may, so no block takes it all.
|
||||
pub const PUBLISHES_ALL: f64 = 32.0;
|
||||
|
||||
/// The rates (see the server's settings).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct AbuseConfig {
|
||||
/// Open to the public: connections, publishing and messages are limited.
|
||||
pub open: bool,
|
||||
pub connects_per_minute: u32,
|
||||
pub publishes_per_hour: u32,
|
||||
pub messages_per_second: u32,
|
||||
}
|
||||
|
||||
/// What an address does.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
Connect,
|
||||
Publish,
|
||||
Message,
|
||||
/// Presence (cursors, selections, viewports), cursor chat and pings: twice `messages_per_second`.
|
||||
Presence,
|
||||
Report,
|
||||
/// Downloading this many bytes.
|
||||
Download(usize),
|
||||
}
|
||||
|
||||
struct Address {
|
||||
connects: Bucket,
|
||||
publishes: Bucket,
|
||||
messages: Bucket,
|
||||
presence: Bucket,
|
||||
reports: Bucket,
|
||||
downloads: Bucket,
|
||||
used: Instant,
|
||||
}
|
||||
|
||||
/// The buckets of the addresses seen lately.
|
||||
pub struct Abuse {
|
||||
cfg: AbuseConfig,
|
||||
table: HashMap<Block, Address>,
|
||||
/// Publishing without the key, everyone together.
|
||||
publishes: Bucket,
|
||||
}
|
||||
|
||||
/// `n` every `period` seconds on average, and all `n` at once at most.
|
||||
fn bucket(n: f64, period: f64) -> Bucket {
|
||||
Bucket::new(n / period, n.max(1.0))
|
||||
}
|
||||
|
||||
impl Abuse {
|
||||
pub fn new(cfg: AbuseConfig) -> Self {
|
||||
let publishes = bucket(f64::from(cfg.publishes_per_hour) * PUBLISHES_ALL, 3600.0);
|
||||
Self { cfg, table: HashMap::new(), publishes }
|
||||
}
|
||||
|
||||
/// Whether `ip` may do `what` now (counting it, in every block it is in, when it may).
|
||||
pub fn allow(&mut self, ip: IpAddr, what: Action, now: Instant) -> bool {
|
||||
if !self.cfg.open && matches!(what, Action::Connect | Action::Publish | Action::Message | Action::Presence) {
|
||||
return true;
|
||||
}
|
||||
let keys: Vec<(Block, f64)> = ip_blocks(ip).into_iter().map(|(k, times)| (k, f64::from(times))).collect();
|
||||
// Every block must have room before any is charged.
|
||||
if !keys.iter().all(|&(k, times)| self.room(k, times, what, now)) {
|
||||
return false;
|
||||
}
|
||||
if what == Action::Publish && !self.publishes.take(now) {
|
||||
return false;
|
||||
}
|
||||
for &(k, times) in &keys {
|
||||
let (b, n) = Self::pick(self.address(k, times, now), what);
|
||||
b.take_n(now, n);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The address's buckets, made when it is new (forgetting the oldest half when full).
|
||||
fn address(&mut self, ip: Block, times: f64, now: Instant) -> &mut Address {
|
||||
if !self.table.contains_key(&ip) && self.table.len() >= MAX_ADDRESSES {
|
||||
self.forget_oldest_half();
|
||||
}
|
||||
let c = self.cfg;
|
||||
let a = self.table.entry(ip).or_insert_with(|| Address {
|
||||
connects: bucket(f64::from(c.connects_per_minute) * times, 60.0),
|
||||
publishes: bucket(f64::from(c.publishes_per_hour) * times, 3600.0),
|
||||
// A burst of four seconds' worth (a big paste, a quick drag).
|
||||
messages: Bucket::new(f64::from(c.messages_per_second) * times, f64::from(c.messages_per_second) * times * 4.0),
|
||||
presence: Bucket::new(f64::from(c.messages_per_second) * times * 2.0, f64::from(c.messages_per_second) * times * 8.0),
|
||||
reports: bucket(REPORTS_PER_HOUR * times, 3600.0),
|
||||
downloads: Bucket::new(DOWNLOAD_RATE * times, DOWNLOAD_BURST * times),
|
||||
used: now,
|
||||
});
|
||||
a.used = now;
|
||||
a
|
||||
}
|
||||
|
||||
fn pick(a: &mut Address, what: Action) -> (&mut Bucket, f64) {
|
||||
match what {
|
||||
Action::Connect => (&mut a.connects, 1.0),
|
||||
Action::Publish => (&mut a.publishes, 1.0),
|
||||
Action::Message => (&mut a.messages, 1.0),
|
||||
Action::Presence => (&mut a.presence, 1.0),
|
||||
Action::Report => (&mut a.reports, 1.0),
|
||||
Action::Download(n) => (&mut a.downloads, n as f64),
|
||||
}
|
||||
}
|
||||
|
||||
/// The block has `what`'s tokens now (nothing is spent).
|
||||
fn room(&mut self, ip: Block, times: f64, what: Action, now: Instant) -> bool {
|
||||
let (b, n) = Self::pick(self.address(ip, times, now), what);
|
||||
if b.take_n(now, n) {
|
||||
b.give(n);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Addresses remembered.
|
||||
pub fn len(&self) -> usize {
|
||||
self.table.len()
|
||||
}
|
||||
|
||||
/// None remembered.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.table.is_empty()
|
||||
}
|
||||
|
||||
fn forget_oldest_half(&mut self) {
|
||||
let mut used: Vec<Instant> = self.table.values().map(|a| a.used).collect();
|
||||
used.sort_unstable();
|
||||
let Some(&cut) = used.get(used.len() / 2) else { return };
|
||||
self.table.retain(|_, a| a.used > cut);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ use std::net::{IpAddr, SocketAddr};
|
|||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use figcraft_collab::message::{Access, Admin, AdminReply, FileInfo, LinkInfo, NewLink, PluginEntry, PluginReply, PluginReq};
|
||||
use figcraft_collab::message::{Access, Admin, AdminReply, FileRow, LinkInfo, NewLink, PluginEntry, PluginReply, PluginReq, ReportInfo};
|
||||
use figcraft_collab_net::hub::Backend;
|
||||
use figcraft_collab_net::{Endpoint, Invite, manage};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -25,13 +25,18 @@ figcraft-server: FigCraft's collaboration server (files, share links, live sessi
|
|||
Usage:
|
||||
figcraft-server [serve] [--listen ADDR] [--data DIR] [--public-url URL] [--config FILE]
|
||||
figcraft-server key [--rotate] the address to publish to, with the server key
|
||||
figcraft-server files [list] the files kept
|
||||
figcraft-server check check the settings (after editing the --config file)
|
||||
figcraft-server files [list] [--sort size|age|idle] the files kept (biggest, oldest or longest unopened first)
|
||||
figcraft-server files remove FILE remove a file and its links for good
|
||||
figcraft-server links list FILE a file's share links
|
||||
figcraft-server links create FILE --access edit|view [--label TEXT]
|
||||
figcraft-server links revoke FILE LINK revoke a link (whoever joined with it is disconnected)
|
||||
figcraft-server plugins [list] the plugin directory: every plugin and its versions
|
||||
figcraft-server plugins remove ID [--version V] unpublish a plugin (every version, or one)
|
||||
figcraft-server reports what people reported about files
|
||||
figcraft-server block-ip IP refuse an address (an IPv6 address: its /64) and drop its connections
|
||||
figcraft-server unblock-ip IP accept an address again
|
||||
figcraft-server blocked the blocked addresses
|
||||
|
||||
Options (also keys of the --config TOML file):
|
||||
--listen ADDR where to listen (default 127.0.0.1:47900; 0.0.0.0:47900 for every network)
|
||||
|
|
@ -39,25 +44,48 @@ Options (also keys of the --config TOML file):
|
|||
--public-url URL how links name the server behind a reverse proxy (wss://host/path/)
|
||||
--config FILE settings: listen, data, public_url, max_files, max_file_mb, max_links,
|
||||
max_connections, max_people, compact_after, compact_mb, max_plugins,
|
||||
max_plugin_mb, max_plugins_mb
|
||||
max_plugin_mb, max_plugins_mb; for a public server: open_publish,
|
||||
operator_contact, terms_url, trusted_proxy, idle_days, max_total_mb,
|
||||
max_files_per_ip, max_mb_per_ip, max_connections_per_ip,
|
||||
connects_per_minute, publishes_per_hour, messages_per_second
|
||||
-h, --help this text
|
||||
-V, --version the version
|
||||
|
||||
Logs go to standard output (RUST_LOG=debug for more); they never contain keys or tokens.
|
||||
Logs go to standard output (RUST_LOG=debug for more); they never contain keys, tokens or file contents.
|
||||
GET /healthz on the same port answers \"ok\" (for uptime checks).
|
||||
";
|
||||
|
||||
/// How `files list` orders the files.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Sort {
|
||||
/// By id.
|
||||
#[default]
|
||||
Id,
|
||||
/// Biggest first.
|
||||
Size,
|
||||
/// Oldest first.
|
||||
Age,
|
||||
/// Longest unused first.
|
||||
Idle,
|
||||
}
|
||||
|
||||
/// What to do.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Command {
|
||||
Serve,
|
||||
Key { rotate: bool },
|
||||
Files,
|
||||
Files { sort: Sort },
|
||||
RemoveFile { file: String },
|
||||
Links { file: String },
|
||||
CreateLink { file: String, access: Access, label: String },
|
||||
Revoke { file: String, link: String },
|
||||
Plugins,
|
||||
RemovePlugin { id: String, version: Option<String> },
|
||||
Reports,
|
||||
Check,
|
||||
Block { ip: String },
|
||||
Unblock { ip: String },
|
||||
Blocked,
|
||||
Help,
|
||||
Version,
|
||||
}
|
||||
|
|
@ -81,6 +109,7 @@ pub fn parse(args: &[String]) -> Result<Cli, String> {
|
|||
let mut label = String::new();
|
||||
let mut rotate = false;
|
||||
let mut version = None;
|
||||
let mut sort = Sort::Id;
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
let mut value = |name: &str| it.next().cloned().ok_or_else(|| format!("{name} needs a value"));
|
||||
|
|
@ -98,6 +127,14 @@ pub fn parse(args: &[String]) -> Result<Cli, String> {
|
|||
"--access" => access = Some(Access::parse(&value("--access")?).ok_or("--access must be edit or view")?),
|
||||
"--label" => label = value("--label")?,
|
||||
"--rotate" => rotate = true,
|
||||
"--sort" => {
|
||||
sort = match value("--sort")?.as_str() {
|
||||
"size" => Sort::Size,
|
||||
"age" => Sort::Age,
|
||||
"idle" => Sort::Idle,
|
||||
_ => return Err("--sort must be size, age or idle".into()),
|
||||
}
|
||||
}
|
||||
s if s.starts_with('-') => return Err(format!("unknown option {s}")),
|
||||
s => words.push(s.to_string()),
|
||||
}
|
||||
|
|
@ -106,7 +143,7 @@ pub fn parse(args: &[String]) -> Result<Cli, String> {
|
|||
let command = match w.as_slice() {
|
||||
[] | ["serve"] => Command::Serve,
|
||||
["key"] => Command::Key { rotate },
|
||||
["files"] | ["files", "list"] => Command::Files,
|
||||
["files"] | ["files", "list"] => Command::Files { sort },
|
||||
["files", "remove", file] => Command::RemoveFile { file: file.to_string() },
|
||||
["links", "list", file] => Command::Links { file: file.to_string() },
|
||||
["links", "create", file] => {
|
||||
|
|
@ -115,6 +152,11 @@ pub fn parse(args: &[String]) -> Result<Cli, String> {
|
|||
["links", "revoke", file, link] => Command::Revoke { file: file.to_string(), link: link.to_string() },
|
||||
["plugins"] | ["plugins", "list"] => Command::Plugins,
|
||||
["plugins", "remove", id] => Command::RemovePlugin { id: id.to_string(), version },
|
||||
["reports"] => Command::Reports,
|
||||
["check"] => Command::Check,
|
||||
["block-ip", ip] => Command::Block { ip: ip.to_string() },
|
||||
["unblock-ip", ip] => Command::Unblock { ip: ip.to_string() },
|
||||
["blocked"] => Command::Blocked,
|
||||
_ => return Err(format!("unknown command `{}`", words.join(" "))),
|
||||
};
|
||||
config.check()?;
|
||||
|
|
@ -161,8 +203,29 @@ pub fn run(cli: &Cli) -> Result<String, String> {
|
|||
Command::Version => Ok(format!("figcraft-server {}\n", env!("CARGO_PKG_VERSION"))),
|
||||
Command::Serve => Err("serving runs in the foreground: run figcraft-server without a command".into()),
|
||||
Command::Key { rotate } => key(cfg, *rotate),
|
||||
Command::Files => match admin(cfg, Admin::Files)? {
|
||||
AdminReply::Files(files) => Ok(files_table(&files)),
|
||||
Command::Files { sort } => match admin(cfg, Admin::Listing)? {
|
||||
AdminReply::Listing(files) => Ok(files_table(files, *sort, store::now())),
|
||||
other => unexpected(other),
|
||||
},
|
||||
// `parse` checked them.
|
||||
Command::Check => {
|
||||
Ok(format!("The settings are fine{}.\n", if cfg.open_publish { ": open to the public (anyone may publish without the key)" } else { "" }))
|
||||
}
|
||||
Command::Reports => match admin(cfg, Admin::Reports)? {
|
||||
AdminReply::Reports(reports) => Ok(reports_table(&reports)),
|
||||
other => unexpected(other),
|
||||
},
|
||||
Command::Block { ip } => match admin(cfg, Admin::Block { ip: ip.clone() })? {
|
||||
AdminReply::Done => Ok(format!("Blocked {ip}: its connections were dropped and new ones are refused.\n")),
|
||||
other => unexpected(other),
|
||||
},
|
||||
Command::Unblock { ip } => match admin(cfg, Admin::Unblock { ip: ip.clone() })? {
|
||||
AdminReply::Done => Ok(format!("Unblocked {ip}.\n")),
|
||||
other => unexpected(other),
|
||||
},
|
||||
Command::Blocked => match admin(cfg, Admin::Blocked)? {
|
||||
AdminReply::Blocked(list) if list.is_empty() => Ok("No addresses are blocked.\n".into()),
|
||||
AdminReply::Blocked(list) => Ok(list.iter().map(|ip| format!("{ip}\n")).collect()),
|
||||
other => unexpected(other),
|
||||
},
|
||||
Command::RemoveFile { file } => match admin(cfg, Admin::Remove { file: file.clone() })? {
|
||||
|
|
@ -284,6 +347,9 @@ fn key(cfg: &Config, rotate: bool) -> Result<String, String> {
|
|||
let _ = writeln!(out, "Publish to this address (in FigCraft: Share > Publish to server…):");
|
||||
let _ = writeln!(out, " {}?key={key}", public.url());
|
||||
let _ = writeln!(out, "Whoever has it can publish files and manage their links: share it only with people you trust.");
|
||||
if cfg.open_publish {
|
||||
let _ = writeln!(out, "Open to the public: anyone can publish to {} without the key (each file gets an owner token).", public.url());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
|
|
@ -302,17 +368,60 @@ fn new_link(cfg: &Config, l: &NewLink) -> Result<String, String> {
|
|||
Ok(out)
|
||||
}
|
||||
|
||||
fn files_table(files: &[FileInfo]) -> String {
|
||||
/// When a listed file was last used (made, changed or opened).
|
||||
fn used(f: &FileRow) -> u64 {
|
||||
f.info.created.max(f.info.updated).max(f.opened)
|
||||
}
|
||||
|
||||
fn files_table(mut files: Vec<FileRow>, sort: Sort, now: u64) -> String {
|
||||
if files.is_empty() {
|
||||
return "No files yet (publish one from FigCraft: Share > Publish to server…).\n".into();
|
||||
}
|
||||
let mut out = String::from("ID NAME PEOPLE UPDATED SIZE\n");
|
||||
for f in files {
|
||||
let _ = writeln!(out, "{:<16} {:<30} {:>6} {:<20} {}", f.id, elide(&f.name, 30), f.people, date(f.updated), size(f.bytes));
|
||||
match sort {
|
||||
Sort::Id => {}
|
||||
Sort::Size => files.sort_by_key(|f| std::cmp::Reverse(f.info.bytes)),
|
||||
Sort::Age => files.sort_by_key(|f| f.info.created),
|
||||
Sort::Idle => files.sort_by_key(used),
|
||||
}
|
||||
let mut out = String::from("ID NAME PEOPLE SIZE CREATED IDLE EXPIRES REPORTS BY\n");
|
||||
for f in &files {
|
||||
let idle = format!("{}d", now.saturating_sub(used(f)) / 86_400);
|
||||
let expires = f.expires.map_or_else(|| "never".to_string(), day);
|
||||
let by = if f.open { f.ip.as_str() } else { "key" };
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{:<16} {:<30} {:>6} {:<9} {:<10} {:>4} {:<10} {:>7} {}",
|
||||
f.info.id,
|
||||
elide(&f.info.name, 30),
|
||||
f.info.people,
|
||||
size(f.info.bytes),
|
||||
day(f.info.created),
|
||||
idle,
|
||||
expires,
|
||||
f.reports,
|
||||
by
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn reports_table(reports: &[ReportInfo]) -> String {
|
||||
if reports.is_empty() {
|
||||
return "No reports.\n".into();
|
||||
}
|
||||
let mut out = String::new();
|
||||
for r in reports {
|
||||
let _ = writeln!(out, "{} file {} ({}) from {} link {}", date(r.at), r.file, elide(&r.name, 40), r.ip, r.link);
|
||||
let _ = writeln!(out, " {}", r.reason);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DD` for seconds since 1970.
|
||||
fn day(secs: u64) -> String {
|
||||
date(secs).chars().take(10).collect()
|
||||
}
|
||||
|
||||
fn links_table(links: &[LinkInfo]) -> String {
|
||||
if links.is_empty() {
|
||||
return "No links (make one with `figcraft-server links create FILE --access view`).\n".into();
|
||||
|
|
@ -337,18 +446,4 @@ fn size(bytes: u64) -> String {
|
|||
}
|
||||
|
||||
/// `YYYY-MM-DD HH:MM UTC` for seconds since 1970.
|
||||
pub fn date(secs: u64) -> String {
|
||||
let days = i64::try_from(secs / 86_400).unwrap_or(0);
|
||||
let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60);
|
||||
// Days to a civil date (proleptic Gregorian).
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = yoe + era * 400 + i64::from(mo <= 2);
|
||||
format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02} UTC")
|
||||
}
|
||||
pub use figcraft_collab::message::utc_date as date;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use figcraft_collab::Replica;
|
|||
use figcraft_collab::message::Access;
|
||||
use figcraft_doc::Document;
|
||||
|
||||
use crate::cli::{self, Cli, Command, date, parse};
|
||||
use crate::cli::{self, Cli, Command, Sort, date, parse};
|
||||
use crate::config::Config;
|
||||
use crate::store::Store;
|
||||
use crate::test_util::{TempDir, config};
|
||||
|
|
@ -36,7 +36,7 @@ fn parsing() {
|
|||
parse(&args(&["links", "create", "f1", "--access", "view", "--label", "Client"])).unwrap().command,
|
||||
Command::CreateLink { file: "f1".into(), access: Access::View, label: "Client".into() }
|
||||
);
|
||||
assert_eq!(parse(&args(&["files"])).unwrap().command, Command::Files);
|
||||
assert_eq!(parse(&args(&["files"])).unwrap().command, Command::Files { sort: Sort::Id });
|
||||
assert_eq!(parse(&args(&["key", "--rotate"])).unwrap().command, Command::Key { rotate: true });
|
||||
assert_eq!(parse(&args(&["-h"])).unwrap().command, Command::Help);
|
||||
for bad in
|
||||
|
|
@ -60,7 +60,7 @@ fn parsing() {
|
|||
fn managing_a_directory_on_its_own() {
|
||||
let dir = TempDir::new("cli-offline");
|
||||
let file = with_file(dir.path());
|
||||
let list = run(dir.path(), Command::Files).unwrap();
|
||||
let list = run(dir.path(), Command::Files { sort: Sort::Id }).unwrap();
|
||||
assert!(list.contains(&file) && list.contains("Board"), "{list}");
|
||||
let made = run(dir.path(), Command::CreateLink { file: file.clone(), access: Access::View, label: "Client".into() }).unwrap();
|
||||
assert!(made.contains("New view link") && made.contains(&format!("file={file}&token=")), "{made}");
|
||||
|
|
@ -75,7 +75,7 @@ fn managing_a_directory_on_its_own() {
|
|||
let rotated = run(dir.path(), Command::Key { rotate: true }).unwrap();
|
||||
assert_ne!(key.lines().nth(1), rotated.lines().nth(2));
|
||||
run(dir.path(), Command::RemoveFile { file: file.clone() }).unwrap();
|
||||
assert!(run(dir.path(), Command::Files).unwrap().contains("No files yet"));
|
||||
assert!(run(dir.path(), Command::Files { sort: Sort::Id }).unwrap().contains("No files yet"));
|
||||
assert!(run(dir.path(), Command::Links { file }).is_err());
|
||||
}
|
||||
|
||||
|
|
@ -91,12 +91,12 @@ fn managing_through_the_running_server() {
|
|||
assert!(links.contains("Team"), "{links}");
|
||||
let id = links.lines().find(|l| l.contains("Team")).and_then(|l| l.split_whitespace().next()).unwrap().to_string();
|
||||
assert!(run(dir.path(), Command::Revoke { file: file.clone(), link: id }).unwrap().contains("Revoked"));
|
||||
assert!(run(dir.path(), Command::Files).unwrap().contains("Board"));
|
||||
assert!(run(dir.path(), Command::Files { sort: Sort::Id }).unwrap().contains("Board"));
|
||||
assert!(run(dir.path(), Command::Key { rotate: true }).unwrap_err().contains("stop the server"));
|
||||
assert!(run(dir.path(), Command::Key { rotate: false }).unwrap().contains(&format!("ws://{}/?key=", server.addr())));
|
||||
server.stop();
|
||||
// Stopped: the directory is free again.
|
||||
assert!(run(dir.path(), Command::Files).unwrap().contains("Board"));
|
||||
assert!(run(dir.path(), Command::Files { sort: Sort::Id }).unwrap().contains("Board"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
//! The server's settings: defaults, then a TOML file (`--config server.toml`), then flags.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use figcraft_collab_net::{Endpoint, NetLimits};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::abuse::AbuseConfig;
|
||||
use crate::directory::DirectoryConfig;
|
||||
use crate::store::StoreConfig;
|
||||
use crate::store::{HostingConfig, StoreConfig};
|
||||
|
||||
/// The port the server listens on unless told otherwise (the app's sessions try it first too).
|
||||
pub const DEFAULT_PORT: u16 = figcraft_collab_net::DEFAULT_PORT;
|
||||
|
|
@ -26,8 +27,8 @@ pub struct Config {
|
|||
/// Most files kept.
|
||||
pub max_files: usize,
|
||||
/// Largest file (its snapshot), in MiB (a welcome carries the whole file, and messages are at
|
||||
/// most 64 MiB).
|
||||
pub max_file_mb: u64,
|
||||
/// most 64 MiB). Unset: 64, or 16 when open to the public ([`Config::file_mb`]).
|
||||
pub max_file_mb: Option<u64>,
|
||||
/// Most share links per file.
|
||||
pub max_links: usize,
|
||||
/// Most open connections.
|
||||
|
|
@ -44,6 +45,30 @@ pub struct Config {
|
|||
pub max_plugin_mb: u64,
|
||||
/// Most MiB of plugin bundles kept.
|
||||
pub max_plugins_mb: u64,
|
||||
/// Anyone may publish a file without the server key (and gets an owner token for it): a
|
||||
/// server open to the public. The limits below then apply per address.
|
||||
pub open_publish: bool,
|
||||
/// How to reach the operator (an email address or a web page) and the terms of use (a web
|
||||
/// address): the app shows them where people publish.
|
||||
pub operator_contact: String,
|
||||
pub terms_url: String,
|
||||
/// The reverse proxy in front of the server (`127.0.0.1` for Caddy or nginx on the same
|
||||
/// machine): only its `X-Forwarded-For` is believed.
|
||||
pub trusted_proxy: Option<String>,
|
||||
/// Files published without the key are deleted after this many days without anyone opening
|
||||
/// them (0: kept; fractions allowed).
|
||||
pub idle_days: f64,
|
||||
/// Publishing without the key stops while the files take this many MiB in all.
|
||||
pub max_total_mb: u64,
|
||||
/// Per address, when open: most files published without the key, and their MiB.
|
||||
pub max_files_per_ip: usize,
|
||||
pub max_mb_per_ip: u64,
|
||||
/// Per address, when open: most connections at once, new connections a minute, files
|
||||
/// published an hour and messages a second.
|
||||
pub max_connections_per_ip: usize,
|
||||
pub connects_per_minute: u32,
|
||||
pub publishes_per_hour: u32,
|
||||
pub messages_per_second: u32,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
|
|
@ -53,7 +78,7 @@ impl Default for Config {
|
|||
data: PathBuf::from("figcraft-data"),
|
||||
public_url: None,
|
||||
max_files: 1000,
|
||||
max_file_mb: 64,
|
||||
max_file_mb: None,
|
||||
max_links: 100,
|
||||
max_connections: 512,
|
||||
max_people: 256,
|
||||
|
|
@ -62,6 +87,18 @@ impl Default for Config {
|
|||
max_plugins: 500,
|
||||
max_plugin_mb: 16,
|
||||
max_plugins_mb: 1024,
|
||||
open_publish: false,
|
||||
operator_contact: String::new(),
|
||||
terms_url: String::new(),
|
||||
trusted_proxy: None,
|
||||
idle_days: 30.0,
|
||||
max_total_mb: 10_240,
|
||||
max_files_per_ip: 20,
|
||||
max_mb_per_ip: 256,
|
||||
max_connections_per_ip: 32,
|
||||
connects_per_minute: 30,
|
||||
publishes_per_hour: 10,
|
||||
messages_per_second: 200,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -88,13 +125,71 @@ impl Config {
|
|||
Ok(Some(e))
|
||||
}
|
||||
|
||||
/// The largest file in MiB: as set, else 64, or 16 on a server open to the public (strangers'
|
||||
/// files stay small unless the operator says otherwise).
|
||||
pub fn file_mb(&self) -> u64 {
|
||||
self.max_file_mb.unwrap_or(if self.open_publish { 16 } else { 64 })
|
||||
}
|
||||
|
||||
/// What an operator should hear about these settings at startup (a public server without an
|
||||
/// abuse contact, or one whose clients would all count as its proxy).
|
||||
pub fn warnings(&self) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
if !self.open_publish {
|
||||
return out;
|
||||
}
|
||||
if self.operator_contact.trim().is_empty() {
|
||||
out.push("open_publish is on but operator_contact is empty: people can't reach you about abuse; set it (and terms_url)".into());
|
||||
}
|
||||
let behind_proxy = self.public().ok().flatten().is_some_and(|e| e.secure) || self.addr().is_ok_and(|a| a.ip().is_loopback());
|
||||
if behind_proxy && self.trusted_proxy.as_deref().is_none_or(|p| p.trim().is_empty()) {
|
||||
out.push(
|
||||
"open_publish is on without trusted_proxy: behind a reverse proxy every client counts as the proxy's address, so one person's limits stop everyone; set trusted_proxy = \"127.0.0.1\"".into(),
|
||||
);
|
||||
}
|
||||
if self.idle_days == 0.0 {
|
||||
out.push("open_publish is on with idle_days = 0: files published without the key are never deleted".into());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The trusted proxy's address, when set.
|
||||
pub fn proxy(&self) -> Result<Option<IpAddr>, String> {
|
||||
let Some(p) = self.trusted_proxy.as_deref().map(str::trim).filter(|p| !p.is_empty()) else { return Ok(None) };
|
||||
p.parse().map(Some).map_err(|_| "`trusted_proxy` must be an IP address, like 127.0.0.1".to_string())
|
||||
}
|
||||
|
||||
/// Check everything (before starting).
|
||||
pub fn check(&self) -> Result<(), String> {
|
||||
self.addr()?;
|
||||
self.public()?;
|
||||
self.proxy()?;
|
||||
if !self.idle_days.is_finite() || !(0.0..=3650.0).contains(&self.idle_days) {
|
||||
return Err("`idle_days` must be from 0 to 3650".into());
|
||||
}
|
||||
let terms = self.terms_url.trim();
|
||||
if !terms.is_empty() && !(terms.starts_with("https://") || terms.starts_with("http://")) || terms.contains(char::is_whitespace) {
|
||||
return Err("`terms_url` must be a web address (https://…)".into());
|
||||
}
|
||||
if self.operator_contact.chars().count() > 120 || self.operator_contact.chars().any(char::is_control) || terms.len() > 500 {
|
||||
return Err("`operator_contact` is at most 120 characters and `terms_url` at most 500, on one line".into());
|
||||
}
|
||||
for (name, v) in [
|
||||
("max_total_mb", self.max_total_mb),
|
||||
("max_files_per_ip", self.max_files_per_ip as u64),
|
||||
("max_mb_per_ip", self.max_mb_per_ip),
|
||||
("max_connections_per_ip", self.max_connections_per_ip as u64),
|
||||
("connects_per_minute", u64::from(self.connects_per_minute)),
|
||||
("publishes_per_hour", u64::from(self.publishes_per_hour)),
|
||||
("messages_per_second", u64::from(self.messages_per_second)),
|
||||
] {
|
||||
if v == 0 {
|
||||
return Err(format!("`{name}` must be at least 1"));
|
||||
}
|
||||
}
|
||||
for (name, v) in [
|
||||
("max_files", self.max_files as u64),
|
||||
("max_file_mb", self.max_file_mb),
|
||||
("max_file_mb", self.file_mb()),
|
||||
("max_links", self.max_links as u64),
|
||||
("max_connections", self.max_connections as u64),
|
||||
("max_people", self.max_people as u64),
|
||||
|
|
@ -107,7 +202,7 @@ impl Config {
|
|||
return Err(format!("`{name}` must be at least 1"));
|
||||
}
|
||||
}
|
||||
if self.max_file_mb > 4096 || self.compact_mb > 4096 {
|
||||
if self.file_mb() > 4096 || self.compact_mb > 4096 {
|
||||
return Err("sizes are in MiB and at most 4096".into());
|
||||
}
|
||||
// A bundle travels in one message (at most 64 MiB) next to its entry.
|
||||
|
|
@ -124,22 +219,84 @@ impl Config {
|
|||
pub fn store(&self) -> StoreConfig {
|
||||
let mib = |n: u64| n.saturating_mul(1 << 20);
|
||||
StoreConfig {
|
||||
hosting: HostingConfig {
|
||||
open: self.open_publish,
|
||||
operator: self.operator_contact.trim().to_string(),
|
||||
terms: self.terms_url.trim().to_string(),
|
||||
// Whole seconds: `check` keeps the days finite and at most 3650.
|
||||
idle_secs: (self.idle_days * 86_400.0).round() as u64,
|
||||
max_file_mb: self.file_mb(),
|
||||
max_total_bytes: mib(self.max_total_mb),
|
||||
max_files_per_ip: self.max_files_per_ip,
|
||||
max_bytes_per_ip: mib(self.max_mb_per_ip),
|
||||
abuse: AbuseConfig {
|
||||
open: self.open_publish,
|
||||
connects_per_minute: self.connects_per_minute,
|
||||
publishes_per_hour: self.publishes_per_hour,
|
||||
messages_per_second: self.messages_per_second,
|
||||
},
|
||||
},
|
||||
max_files: self.max_files,
|
||||
max_file_bytes: mib(self.max_file_mb),
|
||||
max_file_bytes: mib(self.file_mb()),
|
||||
max_links: self.max_links,
|
||||
compact_after: self.compact_after,
|
||||
compact_bytes: mib(self.compact_mb),
|
||||
core: figcraft_collab::Limits { max_peers: self.max_people, ..figcraft_collab::Limits::default() },
|
||||
core: self.core(),
|
||||
directory: DirectoryConfig {
|
||||
max_plugins: self.max_plugins,
|
||||
max_plugin_bytes: mib(self.max_plugin_mb),
|
||||
max_total_bytes: mib(self.max_plugins_mb),
|
||||
},
|
||||
// At least 1 GiB, and room for two of the largest files (a compaction writes one beside
|
||||
// the old one).
|
||||
min_free_bytes: mib(self.file_mb().saturating_mul(2)).max(1 << 30),
|
||||
}
|
||||
}
|
||||
|
||||
/// The transport's part.
|
||||
/// Largest message a client may send when open to the public: a whole file (or plugin bundle)
|
||||
/// and a margin, not the core's 64 MiB.
|
||||
fn open_message(&self) -> usize {
|
||||
usize::try_from(self.file_mb().max(self.max_plugin_mb).saturating_add(1).saturating_mul(1 << 20)).unwrap_or(usize::MAX)
|
||||
}
|
||||
|
||||
/// The collaboration core's part: when open, messages as large as a file at most, and the
|
||||
/// batches kept for reconnecting clients (in memory, and in the file's snapshot) at most a
|
||||
/// quarter of a file's bytes (256 KiB at least).
|
||||
fn core(&self) -> figcraft_collab::Limits {
|
||||
let base = figcraft_collab::Limits { max_peers: self.max_people, ..figcraft_collab::Limits::default() };
|
||||
if !self.open_publish {
|
||||
return base;
|
||||
}
|
||||
let max = self.open_message().min(base.max_message_bytes);
|
||||
let file = usize::try_from(self.file_mb().saturating_mul(1 << 20)).unwrap_or(usize::MAX);
|
||||
figcraft_collab::Limits { max_message_bytes: max, max_log_bytes: (file / 4).max(256 << 10), ..base }
|
||||
}
|
||||
|
||||
/// The transport's part. A private server keeps the defaults; open to the public, connections
|
||||
/// per address are capped, messages are as large as a file at most, the bytes queued for a
|
||||
/// client and for everyone and those waiting for the hub are bounded, one-shot requests leave
|
||||
/// an eighth of the connections to sessions and are dropped after 20 idle seconds.
|
||||
pub fn net(&self) -> NetLimits {
|
||||
NetLimits { max_connections: self.max_connections, ..NetLimits::default() }
|
||||
let base = NetLimits { max_connections: self.max_connections, trusted_proxy: self.proxy().ok().flatten(), ..NetLimits::default() };
|
||||
if !self.open_publish {
|
||||
return base;
|
||||
}
|
||||
let max = self.open_message().min(base.max_client_message);
|
||||
// Shared budgets: one block of addresses (an IPv4 address, an IPv6 /48) gets at most an
|
||||
// eighth of each, and its biggest consumers are dropped first.
|
||||
let (queued, reading) = (256usize << 20, 192usize << 20);
|
||||
NetLimits {
|
||||
max_per_address: self.max_connections_per_ip,
|
||||
max_client_message: max,
|
||||
max_queued_bytes: max.saturating_add(1 << 20),
|
||||
max_queued_block: queued / 8,
|
||||
max_queued_total: queued,
|
||||
max_reading_block: max.saturating_add(1 << 20).min(reading / 8),
|
||||
max_reading_total: reading,
|
||||
max_in_flight: 128 << 20,
|
||||
reserve_for_sessions: self.max_connections / 8,
|
||||
request_idle: std::time::Duration::from_secs(20),
|
||||
..base
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,9 @@ const MAX_QUERY: usize = 200;
|
|||
const MAX_LISTING: usize = 32 << 20;
|
||||
/// What fetches may send, in MiB per second on average (each costs at least one).
|
||||
const SERVE_MIB_PER_SEC: f64 = 32.0;
|
||||
/// …and at once: eight times what one block of addresses may download at once, so no block can
|
||||
/// take it all (see `abuse`).
|
||||
const SERVE_BURST_MIB: f64 = 512.0;
|
||||
const MIB: f64 = (1 << 20) as f64;
|
||||
|
||||
/// What the directory enforces.
|
||||
|
|
@ -80,7 +83,7 @@ impl Directory {
|
|||
pub fn open(root: &Path, cfg: DirectoryConfig) -> (Directory, Vec<String>) {
|
||||
let dir = root.join(PLUGINS);
|
||||
let burst = (2 * cfg.max_plugin_bytes) as f64 / MIB;
|
||||
let mut d = Directory { dir, cfg, entries: Vec::new(), served: Bucket::new(SERVE_MIB_PER_SEC, burst.max(64.0)), broken: false };
|
||||
let mut d = Directory { dir, cfg, entries: Vec::new(), served: Bucket::new(SERVE_MIB_PER_SEC, burst.max(SERVE_BURST_MIB)), broken: false };
|
||||
let mut problems = Vec::new();
|
||||
let index = d.dir.join(INDEX);
|
||||
let _ = fs::remove_file(disk::temp_of(&index));
|
||||
|
|
@ -173,6 +176,15 @@ impl Directory {
|
|||
out
|
||||
}
|
||||
|
||||
/// The size of version `version` (or the latest) of plugin `id`'s bundle, without reading it.
|
||||
pub fn size_of(&self, id: &str, version: Option<&str>) -> Option<u64> {
|
||||
self.entries
|
||||
.iter()
|
||||
.filter(|e| e.id == id && version.is_none_or(|v| compare_versions(&e.version, v) == Some(Ordering::Equal)))
|
||||
.min_by(|a, b| by_version(a, b))
|
||||
.map(|e| e.bytes)
|
||||
}
|
||||
|
||||
/// Version `version` (or the latest) of plugin `id`, and its bundle, read back and checked
|
||||
/// against its hash.
|
||||
pub fn fetch(&mut self, id: &str, version: Option<&str>) -> Result<(PluginEntry, Vec<u8>), String> {
|
||||
|
|
|
|||
|
|
@ -15,11 +15,11 @@ use crate::directory::{DirectoryConfig, INDEX, PLUGINS};
|
|||
use crate::store::{Store, StoreConfig};
|
||||
use crate::test_util::{Raw, TempDir, config, key, session};
|
||||
|
||||
fn wasm() -> Vec<u8> {
|
||||
pub(crate) fn wasm() -> Vec<u8> {
|
||||
wat::parse_str(r#"(module (memory (export "memory") 1) (func (export "figcraft_run") (param i32) (result i32) i32.const 0))"#).unwrap()
|
||||
}
|
||||
|
||||
fn manifest(id: &str, version: &str) -> String {
|
||||
pub(crate) fn manifest(id: &str, version: &str) -> String {
|
||||
json!({"id": id, "name": "Tiny tool", "version": version, "author": "Ada Example", "description": "Does very little.", "main": "tiny.wasm",
|
||||
"permissions": {"access": "write", "commands": ["shape.rectangle"]}})
|
||||
.to_string()
|
||||
|
|
@ -125,7 +125,7 @@ fn bad_bundles_caps_and_keys() {
|
|||
assert!(matches!(d.handle(true, PluginReq::Publish { bundle: bundle(ID, "1.0.0") }), PluginReply::Published(_)));
|
||||
assert!(matches!(d.handle(false, PluginReq::List { query: "x".repeat(100_000) }), PluginReply::List(_)));
|
||||
// Fetches share a budget: a flood is turned away, not queued.
|
||||
let busy = (0..400).filter_map(|_| d.fetch(ID, None).err()).filter(|e| e.contains("busy")).count();
|
||||
let busy = (0..800).filter_map(|_| d.fetch(ID, None).err()).filter(|e| e.contains("busy")).count();
|
||||
assert!(busy > 0, "a flood of fetches hits the budget");
|
||||
drop(store);
|
||||
// Caps: versions kept, bytes kept, off.
|
||||
|
|
|
|||
|
|
@ -32,6 +32,19 @@ const SNAP_HEADER: usize = 4 + 1 + 8 + 4;
|
|||
pub const LOG_HEADER: usize = 4 + 1 + 8;
|
||||
const RECORD_HEADER: usize = 8;
|
||||
|
||||
/// Free space on the disk holding `path`, in bytes, where it can be told (Unix).
|
||||
pub fn free_bytes(path: &Path) -> Option<u64> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
rustix::fs::statvfs(path).ok().map(|s| s.f_bavail.saturating_mul(s.f_frsize))
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = path;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// `path` with `.tmp` added: where its next version is written first.
|
||||
pub fn temp_of(path: &Path) -> PathBuf {
|
||||
let mut name = path.file_name().map(|n| n.to_os_string()).unwrap_or_default();
|
||||
|
|
@ -41,9 +54,27 @@ pub fn temp_of(path: &Path) -> PathBuf {
|
|||
|
||||
/// Replace `path` with `bytes` atomically: a reader sees the old or the new content, never a mix.
|
||||
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
write_atomic_as(path, bytes, false)
|
||||
}
|
||||
|
||||
/// [`write_atomic`] for a secret: on Unix only the owner can read it, from the moment it exists.
|
||||
pub fn write_atomic_private(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
write_atomic_as(path, bytes, true)
|
||||
}
|
||||
|
||||
fn write_atomic_as(path: &Path, bytes: &[u8], private: bool) -> io::Result<()> {
|
||||
let tmp = temp_of(path);
|
||||
{
|
||||
let mut f = File::create(&tmp)?;
|
||||
let mut o = fs::OpenOptions::new();
|
||||
o.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
if private {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
o.mode(0o600);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = private;
|
||||
let mut f = o.open(&tmp)?;
|
||||
f.write_all(bytes)?;
|
||||
f.sync_all()?;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,787 @@
|
|||
//! Open hosting against resource exhaustion: each test reproduces an attack from the security
|
||||
//! review and checks what bounds it — a zip-bomb library (H1), a client that never reads its
|
||||
//! replies (H2), a file growing past its cap through batches and a log that can't be written (H3),
|
||||
//! usage reports that rewrote a file per request (M1), oversized messages and a busy hub (M2),
|
||||
//! many addresses from one IPv6 block and one-shot requests taking every connection (M3), revoked
|
||||
//! links on library connections (M4), data directory permissions (L6) and downloads per address
|
||||
//! (L7).
|
||||
|
||||
use std::io::{Cursor, Write};
|
||||
use std::net::{IpAddr, TcpListener};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use figcraft_collab::message::{
|
||||
Access, Admin, AdminReply, ClientMsg, LibraryReply, LibraryReq, OpenHosting, OpenLibrary, OpenPlugins, PROTOCOL, PluginReq, ServerMsg, Usage,
|
||||
};
|
||||
use figcraft_collab::sim::Rng;
|
||||
use figcraft_collab::{Client, Limits, NoRecompute, Outgoing, Replica, Server};
|
||||
use figcraft_collab_net::hub::{Backend, Hosting};
|
||||
use figcraft_collab_net::{Endpoint, Invite, NetLimits, manage};
|
||||
use figcraft_doc::{Document, NodeId, NodeKind};
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::abuse::{Abuse, AbuseConfig, Action, DOWNLOAD_BURST};
|
||||
use crate::config::Config;
|
||||
use crate::public_tests::via_proxy;
|
||||
use crate::store::{BUSY_DOWNLOADS, NO_PUBLIC_LIBRARY, Store};
|
||||
use crate::test_util::{Raw, TempDir, config};
|
||||
|
||||
fn open(dir: &std::path::Path) -> Config {
|
||||
Config { open_publish: true, operator_contact: "abuse@example.com".into(), ..config(dir) }
|
||||
}
|
||||
|
||||
fn snapshot(name: &str) -> Vec<figcraft_collab::Change> {
|
||||
let mut d = Document::new(name);
|
||||
let page = d.first_page().unwrap();
|
||||
d.create(page, NodeKind::Rectangle, "Card", 0.0, 0.0, 100.0, 60.0).unwrap();
|
||||
d.repair();
|
||||
Replica::new(d).snapshot().unwrap()
|
||||
}
|
||||
|
||||
fn ip(s: &str) -> IpAddr {
|
||||
s.parse().unwrap()
|
||||
}
|
||||
|
||||
/// A team library archive as the app makes it; with `bomb` bytes of spaces as its snapshot
|
||||
/// (deflated: a few KiB) and `padding` incompressible bytes stored beside it.
|
||||
fn archive(bomb: usize, padding: usize) -> Vec<u8> {
|
||||
let mut s = Session::new();
|
||||
let f = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 20, "name": "Button"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("component.create", &json!({"ids": [f]})).unwrap();
|
||||
s.execute("library.publish", &json!({})).unwrap();
|
||||
let lib = s.doc().unwrap().published.clone().unwrap();
|
||||
let bytes = figcraft_engine::library_server::archive(&lib).unwrap();
|
||||
if bomb == 0 && padding == 0 {
|
||||
return bytes;
|
||||
}
|
||||
let mut src = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for i in 0..src.len() {
|
||||
let f = src.by_index_raw(i).unwrap();
|
||||
if bomb > 0 && f.name() == "library/document.json" {
|
||||
continue;
|
||||
}
|
||||
out.raw_copy_file(f).unwrap();
|
||||
}
|
||||
let opts = |m| zip::write::SimpleFileOptions::default().compression_method(m);
|
||||
if bomb > 0 {
|
||||
out.start_file("library/document.json", opts(zip::CompressionMethod::Deflated)).unwrap();
|
||||
let chunk = vec![b' '; 1 << 20];
|
||||
for _ in 0..bomb >> 20 {
|
||||
out.write_all(&chunk).unwrap();
|
||||
}
|
||||
}
|
||||
if padding > 0 {
|
||||
out.start_file("padding.bin", opts(zip::CompressionMethod::Stored)).unwrap();
|
||||
let mut rng = Rng::new(3);
|
||||
out.write_all(&(0..padding).map(|_| rng.next_u64() as u8).collect::<Vec<u8>>()).unwrap();
|
||||
}
|
||||
out.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn open_library(link: &Invite) -> ClientMsg {
|
||||
ClientMsg::OpenLibrary(OpenLibrary { protocol: PROTOCOL, file: link.file.clone(), token: link.token.clone(), client_version: String::new() })
|
||||
}
|
||||
|
||||
fn hosting() -> ClientMsg {
|
||||
ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: String::new(), file: String::new(), owner: String::new() })
|
||||
}
|
||||
|
||||
/// Poll `f` until it holds or `secs` pass (bounded: never a fixed sleep for the outcome).
|
||||
fn until(secs: u64, what: &str, mut f: impl FnMut() -> bool) {
|
||||
let end = Instant::now() + Duration::from_secs(secs);
|
||||
while !f() {
|
||||
assert!(Instant::now() < end, "timed out waiting for {what}");
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
/// A client in `server` (as the hub would let it in).
|
||||
fn join(server: &mut Server) -> Client {
|
||||
let ClientMsg::Hello(h) = Client::hello("f", "", "Ada", "#FF8800") else { panic!() };
|
||||
let (_, out) = server.join_as(h, Access::Edit).unwrap();
|
||||
let Some(Outgoing::To(_, w)) = out.into_iter().next() else { panic!() };
|
||||
Client::join(w).unwrap()
|
||||
}
|
||||
|
||||
/// Rename the first page through `c`, as the server takes it.
|
||||
fn rename(server: &mut Server, c: &mut Client, name: &str) {
|
||||
let mut d = c.document().clone();
|
||||
let page = d.first_page().unwrap();
|
||||
d.node_mut(page).unwrap().name = name.into();
|
||||
let m = c.edit(&d).unwrap().unwrap();
|
||||
for o in server.receive(c.id(), m) {
|
||||
if let Outgoing::All(m) | Outgoing::To(_, m) = o {
|
||||
c.receive(m, &NoRecompute).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// H1 -------------------------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn a_zip_bomb_library_is_refused_before_it_unpacks() {
|
||||
let dir = TempDir::new("bomb");
|
||||
let mut c = config(dir.path());
|
||||
c.max_file_mb = Some(1);
|
||||
let mut store = Store::open(dir.path(), c.store()).unwrap();
|
||||
let (keyed, ..) = store.publish("Library", &snapshot("Library")).unwrap();
|
||||
let bomb = archive(64 << 20, 0);
|
||||
assert!(bomb.len() < 1 << 20, "a small archive ({} bytes) that unpacks to 64 MiB", bomb.len());
|
||||
let e = store.publish_library(&keyed.id, &bomb).unwrap_err();
|
||||
assert!(e.contains("too large"), "{e}");
|
||||
assert_eq!(store.publish_library(&keyed.id, &archive(0, 0)).unwrap(), 1, "a real library still goes in");
|
||||
drop(store);
|
||||
// Open to the public: a file published without the key holds no library at all.
|
||||
let mut store = Store::open(dir.path(), open(dir.path()).store()).unwrap();
|
||||
let (mine, ..) = store.publish_open(ip("203.0.113.5"), "Mine", &snapshot("Mine")).unwrap();
|
||||
assert_eq!(store.publish_library(&mine.id, &archive(0, 0)).unwrap_err(), NO_PUBLIC_LIBRARY);
|
||||
assert_ne!(store.publish_library(&keyed.id, &archive(0, 0)).unwrap_err(), NO_PUBLIC_LIBRARY, "the operator's files still can");
|
||||
}
|
||||
|
||||
// H2 -------------------------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn a_client_that_never_reads_can_queue_only_so_much() {
|
||||
let dir = TempDir::new("noread");
|
||||
let cfg = Config { max_file_mb: Some(2), max_plugin_mb: 1, ..open(dir.path()) };
|
||||
let cap = cfg.net().max_queued_bytes;
|
||||
assert_eq!(cap, 4 << 20, "the largest message and a margin");
|
||||
let view = {
|
||||
let mut store = Store::open(dir.path(), cfg.store()).unwrap();
|
||||
let (file, _, view) = store.publish("Library", &snapshot("Library")).unwrap();
|
||||
store.publish_library(&file.id, &archive(0, 1_500_000)).unwrap();
|
||||
Invite { server: Endpoint::plain("x"), file: file.id, token: view.token }
|
||||
};
|
||||
let mut server = crate::start(&cfg).unwrap();
|
||||
let mut raw = Raw::connect(server.addr());
|
||||
raw.send(&open_library(&view));
|
||||
assert!(matches!(raw.next(), ServerMsg::Library(r) if matches!(*r, LibraryReply::Ready { .. })));
|
||||
// Ask for the 1.5 MB library over and over without reading an answer.
|
||||
for _ in 0..100 {
|
||||
if raw.try_send(&ClientMsg::Library(LibraryReq::Fetch { have: None, usage: None })).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut most = 0;
|
||||
let end = Instant::now() + Duration::from_secs(3);
|
||||
while Instant::now() < end {
|
||||
most = most.max(server.queued_bytes());
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
assert!(most <= cap, "{most} bytes were queued for one client (cap {cap})");
|
||||
// Its queue is let go once it is dropped.
|
||||
until(15, "the stuck client's queue to be freed", || server.queued_bytes() == 0);
|
||||
server.stop();
|
||||
}
|
||||
|
||||
// H3 -------------------------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn a_file_cant_grow_past_its_cap_through_batches() {
|
||||
let dir = TempDir::new("grow");
|
||||
let cfg = Config { max_file_mb: Some(1), max_plugin_mb: 1, ..open(dir.path()) };
|
||||
let max = 1u64 << 20;
|
||||
let mut server = crate::start(&cfg).unwrap();
|
||||
let addr = server.addr();
|
||||
let (info, replies) = manage::hosting(
|
||||
&Endpoint::plain(addr.to_string()),
|
||||
"",
|
||||
"",
|
||||
vec![Admin::Publish { name: "Grow".into(), snapshot: snapshot("Grow") }],
|
||||
&NetLimits::default(),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(info.open);
|
||||
let Some(AdminReply::Hosted { file, edit, .. }) = replies.into_iter().next() else { panic!("not hosted") };
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&Client::hello(&file.id, &edit.token, "Grower", ""));
|
||||
let welcome = loop {
|
||||
if let w @ ServerMsg::Welcome { .. } = raw.next() {
|
||||
break w;
|
||||
}
|
||||
};
|
||||
let mut client = Client::join(welcome).unwrap();
|
||||
// Each batch adds a layer with a 200 KB name: the file grows until the server refuses.
|
||||
let mut refused = None;
|
||||
for i in 0..12 {
|
||||
let mut d = client.document().clone();
|
||||
let page = d.first_page().unwrap();
|
||||
d.create(page, NodeKind::Rectangle, &format!("{i}{}", "x".repeat(200_000)), 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
let Some(m) = client.edit(&d).unwrap() else { continue };
|
||||
raw.send(&m);
|
||||
let answer = loop {
|
||||
if let m @ (ServerMsg::Changes(_) | ServerMsg::Reject { .. }) = raw.next() {
|
||||
break m;
|
||||
}
|
||||
};
|
||||
if let ServerMsg::Reject { reason, .. } = &answer {
|
||||
refused = Some(reason.clone());
|
||||
}
|
||||
client.receive(answer, &NoRecompute).unwrap();
|
||||
if refused.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let reason = refused.expect("the server refused a batch once the file was full");
|
||||
assert!(reason.contains("reached the server's limit of 1 MiB"), "{reason}");
|
||||
let on_disk: u64 = ["snapshot.bin", "log.bin"]
|
||||
.iter()
|
||||
.filter_map(|n| std::fs::metadata(dir.path().join("files").join(&file.id).join(n)).ok())
|
||||
.map(|m| m.len())
|
||||
.sum();
|
||||
// Its snapshot holds the document and the recent batches kept for reconnecting clients (at
|
||||
// most a quarter of the cap), and the last batch let in may finish what the cap allows.
|
||||
assert!(on_disk <= max + max / 4 + 210_000, "{on_disk} bytes on disk");
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sessions_memory_of_batches_is_bounded_in_bytes() {
|
||||
let mut server = Server::new(Document::new("Log"), "", Limits { max_log_bytes: 100_000, ..Limits::default() });
|
||||
let mut c = join(&mut server);
|
||||
for i in 0..20 {
|
||||
rename(&mut server, &mut c, &format!("{i}{}", "y".repeat(30_000)));
|
||||
assert!(server.log_bytes() <= 100_000, "{} bytes kept", server.log_bytes());
|
||||
}
|
||||
assert!(server.log_bytes() > 0, "recent batches are still kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changes_that_cant_be_saved_are_never_confirmed() {
|
||||
let dir = TempDir::new("unsaved");
|
||||
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
|
||||
let (info, ..) = store.publish("Durable", &snapshot("Durable")).unwrap();
|
||||
let mut server = Backend::open(&mut store, &info.id).unwrap();
|
||||
let mut c = join(&mut server);
|
||||
rename(&mut server, &mut c, "Kept");
|
||||
Backend::changed(&mut store, &info.id, &mut server);
|
||||
assert!(Backend::flush(&mut store).is_empty());
|
||||
rename(&mut server, &mut c, "Lost");
|
||||
Backend::changed(&mut store, &info.id, &mut server);
|
||||
store.break_log(&info.id);
|
||||
assert_eq!(Backend::flush(&mut store), vec![info.id.clone()], "the hub hears it couldn't be saved");
|
||||
// The file opens again from what is on disk: the saved change, not the unsaved one.
|
||||
drop(server);
|
||||
let mut server = Backend::open(&mut store, &info.id).unwrap();
|
||||
let c = join(&mut server);
|
||||
let page = c.document().first_page().unwrap();
|
||||
assert_eq!(c.document().node(page).unwrap().name, "Kept");
|
||||
}
|
||||
|
||||
// M1 -------------------------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn usage_reports_dont_rewrite_a_file_per_request() {
|
||||
let dir = TempDir::new("usage");
|
||||
let cfg = config(dir.path());
|
||||
let view = {
|
||||
let mut store = Store::open(dir.path(), cfg.store()).unwrap();
|
||||
let (file, _, view) = store.publish("Library", &snapshot("Library")).unwrap();
|
||||
store.publish_library(&file.id, &archive(0, 0)).unwrap();
|
||||
Invite { server: Endpoint::plain("x"), file: file.id, token: view.token }
|
||||
};
|
||||
let usage_file = dir.path().join("files").join(&view.file).join("usage.json");
|
||||
let mut server = crate::start(&cfg).unwrap();
|
||||
let mut raw = Raw::connect(server.addr());
|
||||
raw.send(&open_library(&view));
|
||||
raw.next();
|
||||
for i in 0..50u32 {
|
||||
let usage = Usage { file: format!("User {}", i % 7), components: vec![(NodeId(2), i + 1)] };
|
||||
raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: Some(1), usage: Some(usage) }));
|
||||
assert!(matches!(raw.next(), ServerMsg::Library(r) if matches!(*r, LibraryReply::Unchanged { .. })));
|
||||
}
|
||||
assert!(!usage_file.exists(), "kept in memory, not written 50 times");
|
||||
raw.send(&ClientMsg::Library(LibraryReq::Usage));
|
||||
let ServerMsg::Library(r) = raw.next() else { panic!() };
|
||||
let LibraryReply::Usage(files) = *r else { panic!() };
|
||||
assert_eq!(files.len(), 7);
|
||||
server.stop();
|
||||
assert!(usage_file.exists(), "written when the server stops");
|
||||
}
|
||||
|
||||
// M2 -------------------------------------------------------------------------------------------
|
||||
|
||||
/// Panics in the server's connection threads (`live-…`) since the hook went in: they mustn't
|
||||
/// happen, but a thread's panic doesn't fail a test by itself.
|
||||
static CONNECTION_PANICS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
|
||||
fn count_connection_panics() {
|
||||
static HOOK: std::sync::Once = std::sync::Once::new();
|
||||
HOOK.call_once(|| {
|
||||
let before = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
if std::thread::current().name().is_some_and(|n| n.starts_with("live-")) {
|
||||
CONNECTION_PANICS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
before(info);
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn messages_are_capped_by_what_the_connection_is_for() {
|
||||
count_connection_panics();
|
||||
let dir = TempDir::new("caps");
|
||||
let mut server = crate::start(&open(dir.path())).unwrap();
|
||||
let addr = server.addr();
|
||||
// Browsing a plugin directory takes small requests only; the oversized frame is refused and
|
||||
// the connection closed cleanly (its reader isn't asked for the rest of that frame).
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&ClientMsg::OpenPlugins(OpenPlugins { protocol: PROTOCOL, key: String::new(), client_version: String::new() }));
|
||||
raw.next();
|
||||
raw.send_header(200_000);
|
||||
assert!(matches!(raw.next(), ServerMsg::Refused { reason } if reason.contains("too large") && reason.contains("KiB")));
|
||||
drop(raw);
|
||||
// A joined client whose batch is bigger than a file may be is told the limit.
|
||||
let (_, replies) = manage::hosting(
|
||||
&Endpoint::plain(addr.to_string()),
|
||||
"",
|
||||
"",
|
||||
vec![Admin::Publish { name: "Small".into(), snapshot: snapshot("Small") }],
|
||||
&NetLimits::default(),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.unwrap();
|
||||
let Some(AdminReply::Hosted { file, edit, .. }) = replies.into_iter().next() else { panic!("not hosted") };
|
||||
let mut joined = Raw::connect(addr);
|
||||
joined.send(&Client::hello(&file.id, &edit.token, "Big", ""));
|
||||
while !matches!(joined.next(), ServerMsg::Welcome { .. }) {}
|
||||
joined.send_header(40 << 20);
|
||||
let reason = loop {
|
||||
if let ServerMsg::Refused { reason } = joined.next() {
|
||||
break reason;
|
||||
}
|
||||
};
|
||||
assert!(reason.contains("at most 17 MiB"), "{reason}");
|
||||
drop(joined);
|
||||
until(10, "the refused connections to close", || server.connections() == 0);
|
||||
assert_eq!(CONNECTION_PANICS.load(std::sync::atomic::Ordering::SeqCst), 0, "a connection thread panicked");
|
||||
// Open to the public, a message is at most a file's size (and a margin), not 64 MiB.
|
||||
assert_eq!(open(dir.path()).net().max_client_message, 17 << 20);
|
||||
server.stop();
|
||||
// Past the bytes the hub may have waiting, a connection is dropped as busy.
|
||||
let cfg = open(dir.path());
|
||||
let store = Store::open(dir.path(), cfg.store()).unwrap();
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let mut busy = Hosting::serve(listener, store, NetLimits { max_in_flight: 10_000, ..cfg.net() }).unwrap();
|
||||
let mut raw = Raw::connect(busy.addr());
|
||||
raw.send(&hosting());
|
||||
assert!(matches!(raw.next(), ServerMsg::Hosting(_)));
|
||||
let mut big = Document::new("Big");
|
||||
let page = big.first_page().unwrap();
|
||||
big.create(page, NodeKind::Rectangle, &"z".repeat(30_000), 0.0, 0.0, 1.0, 1.0).unwrap();
|
||||
raw.send(&ClientMsg::Admin(Admin::Publish { name: "Big".into(), snapshot: Replica::new(big).snapshot().unwrap() }));
|
||||
assert!(matches!(raw.next(), ServerMsg::Refused { reason } if reason.contains("busy")));
|
||||
busy.stop();
|
||||
}
|
||||
|
||||
// M3 -------------------------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn one_ipv6_block_gets_rising_but_bounded_limits() {
|
||||
let dir = TempDir::new("blocks");
|
||||
let cfg = Config { max_connections_per_ip: 1, trusted_proxy: Some("127.0.0.1".into()), ..open(dir.path()) };
|
||||
let mut server = crate::start(&cfg).unwrap();
|
||||
let addr = server.addr();
|
||||
let mut held = Vec::new();
|
||||
// Two /64s of one /56 fit (2 × the cap), the third doesn't.
|
||||
for n in 1..=2 {
|
||||
let (ws, r) = via_proxy(addr, &format!("2001:db8:1:{n}::1"));
|
||||
assert!(matches!(r, ServerMsg::Hosting(_)), "/64 number {n}");
|
||||
held.push(ws);
|
||||
}
|
||||
assert!(matches!(via_proxy(addr, "2001:db8:1:3::1").1, ServerMsg::Refused { reason } if reason.contains("too many connections")));
|
||||
// Four of one /48 fit (4 × the cap) — two of them taken above — the fifth doesn't.
|
||||
for n in 1..=2u16 {
|
||||
let (ws, r) = via_proxy(addr, &format!("2001:db8:1:{:x}::1", n << 8));
|
||||
assert!(matches!(r, ServerMsg::Hosting(_)), "/56 number {n}");
|
||||
held.push(ws);
|
||||
}
|
||||
assert!(matches!(via_proxy(addr, "2001:db8:1:300::1").1, ServerMsg::Refused { .. }), "the /48 is full");
|
||||
assert!(matches!(via_proxy(addr, "2001:db8:2::1").1, ServerMsg::Hosting(_)), "another /48 isn't");
|
||||
drop(held);
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rates_count_per_block_and_publishing_for_everyone() {
|
||||
let mut a = Abuse::new(AbuseConfig { open: true, connects_per_minute: 1, publishes_per_hour: 1, messages_per_second: 1 });
|
||||
let t = Instant::now();
|
||||
// One connection a minute per /64, two per /56, four per /48.
|
||||
let in_one_56 = (0..64u16).filter(|n| a.allow(IpAddr::from([0x2001, 0xdb8, 7, *n, 0, 0, 0, 0]), Action::Connect, t)).count();
|
||||
assert_eq!(in_one_56, 2);
|
||||
let in_one_48 = (1..64u16).filter(|n| a.allow(IpAddr::from([0x2001, 0xdb8, 7, n << 8, 0, 0, 0, 0]), Action::Connect, t)).count();
|
||||
assert_eq!(in_one_48, 2, "the /48's four, two of them taken above");
|
||||
// Publishing: one an hour per address, 32 for everyone together.
|
||||
let published = (0..60u32).filter(|n| a.allow(IpAddr::from((0xC000_0200 + n).to_be_bytes()), Action::Publish, t)).count();
|
||||
assert_eq!(published, 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_shot_requests_leave_connections_for_sessions() {
|
||||
let dir = TempDir::new("reserve");
|
||||
let cfg = Config { max_connections: 8, ..open(dir.path()) };
|
||||
assert_eq!(cfg.net().reserve_for_sessions, 1);
|
||||
let edit = {
|
||||
let mut store = Store::open(dir.path(), cfg.store()).unwrap();
|
||||
let (file, edit, _) = store.publish("Team", &snapshot("Team")).unwrap();
|
||||
(file.id, edit.token)
|
||||
};
|
||||
let mut server = crate::start(&cfg).unwrap();
|
||||
let addr = server.addr();
|
||||
let mut held = Vec::new();
|
||||
for _ in 0..7 {
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&hosting());
|
||||
assert!(matches!(raw.next(), ServerMsg::Hosting(_)));
|
||||
held.push(raw);
|
||||
}
|
||||
let mut eighth = Raw::connect(addr);
|
||||
eighth.send(&hosting());
|
||||
assert!(matches!(eighth.next(), ServerMsg::Refused { reason } if reason.contains("busy")));
|
||||
drop(eighth);
|
||||
until(5, "the refused connection to close", || server.connections() <= 7);
|
||||
let mut session = Raw::connect(addr);
|
||||
session.send(&Client::hello(&edit.0, &edit.1, "Ada", ""));
|
||||
assert!(matches!(session.next(), ServerMsg::Welcome { .. }), "a session still gets in");
|
||||
server.stop();
|
||||
}
|
||||
|
||||
// M4 -------------------------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn revoking_or_removing_cuts_library_connections_too() {
|
||||
let dir = TempDir::new("libkick");
|
||||
let cfg = config(dir.path());
|
||||
let (file, view) = {
|
||||
let mut store = Store::open(dir.path(), cfg.store()).unwrap();
|
||||
let (file, _, view) = store.publish("Library", &snapshot("Library")).unwrap();
|
||||
store.publish_library(&file.id, &archive(0, 0)).unwrap();
|
||||
(file.id, view)
|
||||
};
|
||||
let mut server = crate::start(&cfg).unwrap();
|
||||
let addr = server.addr();
|
||||
let invite = Invite { server: Endpoint::plain("x"), file: file.clone(), token: view.token.clone() };
|
||||
let mut lib = Raw::connect(addr);
|
||||
lib.send(&open_library(&invite));
|
||||
lib.next();
|
||||
let key = crate::test_util::key(dir.path());
|
||||
manage::request(
|
||||
&Endpoint::plain(addr.to_string()),
|
||||
&key,
|
||||
vec![Admin::Revoke { file: file.clone(), link: view.link.id.clone() }],
|
||||
&NetLimits::default(),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(lib.next(), ServerMsg::Refused { reason } if reason.contains("revoked")));
|
||||
// A library connection with another link is cut when the file is removed.
|
||||
let made = manage::request(
|
||||
&Endpoint::plain(addr.to_string()),
|
||||
&key,
|
||||
vec![Admin::CreateLink { file: file.clone(), access: Access::View, label: String::new() }],
|
||||
&NetLimits::default(),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.unwrap();
|
||||
let Some(AdminReply::Link(l)) = made.into_iter().next() else { panic!() };
|
||||
let mut lib = Raw::connect(addr);
|
||||
lib.send(&open_library(&Invite { token: l.token, ..invite }));
|
||||
lib.next();
|
||||
manage::request(&Endpoint::plain(addr.to_string()), &key, vec![Admin::Remove { file }], &NetLimits::default(), Duration::from_secs(5)).unwrap();
|
||||
assert!(matches!(lib.next(), ServerMsg::Refused { reason } if reason.contains("removed")));
|
||||
server.stop();
|
||||
}
|
||||
|
||||
// L6, L7 ---------------------------------------------------------------------------------------
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn only_the_server_reads_its_data_directory_and_key() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = TempDir::new("perms");
|
||||
let data = dir.path().join("data");
|
||||
let store = Store::open(&data, config(&data).store()).unwrap();
|
||||
drop(store);
|
||||
assert_eq!(std::fs::metadata(&data).unwrap().permissions().mode() & 0o777, 0o700);
|
||||
assert_eq!(std::fs::metadata(data.join("server.key")).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downloads_have_a_share_per_address() {
|
||||
let mut a = Abuse::new(AbuseConfig { open: false, connects_per_minute: 1, publishes_per_hour: 1, messages_per_second: 1 });
|
||||
let t = Instant::now();
|
||||
let me = ip("198.51.100.1");
|
||||
let chunk = 16 << 20;
|
||||
let served = (0..10).filter(|_| a.allow(me, Action::Download(chunk), t)).count();
|
||||
assert_eq!(served, (DOWNLOAD_BURST as usize) / chunk, "a burst, then wait");
|
||||
assert!(a.allow(ip("198.51.100.2"), Action::Download(chunk), t), "another address has its own share");
|
||||
// A team library is served within it, then refused, saying why; another address still gets it.
|
||||
let dir = TempDir::new("libshare");
|
||||
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
|
||||
let (file, ..) = store.publish("Library", &snapshot("Library")).unwrap();
|
||||
store.publish_library(&file.id, &archive(0, 4 << 20)).unwrap();
|
||||
let fetch = |s: &mut Store, who: &str| s.library(ip(who), &file.id, Access::View, LibraryReq::Fetch { have: None, usage: None });
|
||||
let served = (0..40).take_while(|_| matches!(fetch(&mut store, "198.51.100.1"), LibraryReply::Library { .. })).count();
|
||||
assert!((3..=5).contains(&served), "{served} downloads of 4 MiB in a burst of 16 MiB");
|
||||
assert!(matches!(fetch(&mut store, "198.51.100.1"), LibraryReply::Failed { reason } if reason == BUSY_DOWNLOADS));
|
||||
assert!(matches!(fetch(&mut store, "198.51.100.9"), LibraryReply::Library { .. }));
|
||||
}
|
||||
|
||||
// Second review: N2, L7, N3, M3, N5, N7 ------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn a_file_at_its_cap_is_compacted_at_most_once_a_minute() {
|
||||
let dir = TempDir::new("forced");
|
||||
let mut c = config(dir.path());
|
||||
c.max_file_mb = Some(1);
|
||||
let mut store = Store::open(dir.path(), c.store()).unwrap();
|
||||
let (info, ..) = store.publish("Full", &snapshot("Full")).unwrap();
|
||||
let mut server = Backend::open(&mut store, &info.id).unwrap();
|
||||
let mut client = join(&mut server);
|
||||
let before = store.compactions();
|
||||
// Alternate a small accepted batch with one that would take the file over its cap.
|
||||
for i in 0..50 {
|
||||
rename(&mut server, &mut client, &format!("page {i}"));
|
||||
Backend::changed(&mut store, &info.id, &mut server);
|
||||
assert!(Backend::room(&mut store, &info.id, &server, 2 << 20).is_err());
|
||||
}
|
||||
assert!(store.compactions() - before <= 1, "{} compactions for 50 over-cap batches", store.compactions() - before);
|
||||
}
|
||||
|
||||
/// A plugin directory with one plugin whose bundle is about `mib` MiB.
|
||||
fn plugin_store(dir: &std::path::Path, mib: usize) -> Store {
|
||||
let mut store = Store::open(dir, config(dir).store()).unwrap();
|
||||
let bundle = figcraft_plugin::bundle::Bundle {
|
||||
manifest: crate::directory_tests::manifest("org.example.big", "1.0.0"),
|
||||
wasm: crate::directory_tests::wasm(),
|
||||
assets: vec![figcraft_plugin::bundle::Asset { name: "data.bin".into(), data: vec![9; mib << 20] }],
|
||||
};
|
||||
store.directory().publish(&bundle.encode()).unwrap();
|
||||
store
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_address_cant_spend_everyones_plugin_downloads() {
|
||||
let dir = TempDir::new("plugshare");
|
||||
let mut store = plugin_store(dir.path(), 4);
|
||||
let fetch = || PluginReq::Fetch { id: "org.example.big".into(), version: None };
|
||||
let greedy = ip("198.51.100.1");
|
||||
// Its share runs out after a few; then 200 more tries cost nobody else anything.
|
||||
let served =
|
||||
(0..10).take_while(|_| matches!(store.plugins(greedy, false, fetch()), figcraft_collab::message::PluginReply::Bundle { .. })).count();
|
||||
assert!((2..=5).contains(&served), "{served}");
|
||||
for _ in 0..200 {
|
||||
let r = store.plugins(greedy, false, fetch());
|
||||
assert!(matches!(&r, figcraft_collab::message::PluginReply::Failed { reason } if reason == BUSY_DOWNLOADS));
|
||||
}
|
||||
assert!(
|
||||
matches!(store.plugins(ip("198.51.100.2"), false, fetch()), figcraft_collab::message::PluginReply::Bundle { .. }),
|
||||
"others still download"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_read_messages_count_against_a_reading_budget() {
|
||||
let dir = TempDir::new("reading");
|
||||
let cfg = open(dir.path());
|
||||
let store = Store::open(dir.path(), cfg.store()).unwrap();
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let mut busy = Hosting::serve(listener, store, NetLimits { max_reading_block: 64 << 10, max_reading_total: 1 << 20, ..cfg.net() }).unwrap();
|
||||
let mut raw = Raw::connect(busy.addr());
|
||||
raw.send(&hosting());
|
||||
assert!(matches!(raw.next(), ServerMsg::Hosting(_)), "small messages are read");
|
||||
let mut big = Document::new("Big");
|
||||
let page = big.first_page().unwrap();
|
||||
big.create(page, NodeKind::Rectangle, &"r".repeat(200_000), 0.0, 0.0, 1.0, 1.0).unwrap();
|
||||
let _ = raw.try_send(&ClientMsg::Admin(Admin::Publish { name: "Big".into(), snapshot: Replica::new(big).snapshot().unwrap() }));
|
||||
assert!(matches!(raw.next(), ServerMsg::Refused { reason } if reason.contains("busy reading")));
|
||||
until(10, "the reading budget to be given back", || busy.connections() == 0);
|
||||
// Given back: another address reads again.
|
||||
let mut raw = Raw::connect(busy.addr());
|
||||
raw.send(&hosting());
|
||||
assert!(matches!(raw.next(), ServerMsg::Hosting(_)));
|
||||
busy.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_ipv6_block_takes_at_most_an_eighth_of_the_connections() {
|
||||
let dir = TempDir::new("eighth");
|
||||
let cfg = Config { max_connections: 64, trusted_proxy: Some("127.0.0.1".into()), ..open(dir.path()) };
|
||||
let mut server = crate::start(&cfg).unwrap();
|
||||
let addr = server.addr();
|
||||
let mut held = Vec::new();
|
||||
for n in 1..=8u16 {
|
||||
let (ws, r) = via_proxy(addr, &format!("2001:db8:9:{:x}::1", n << 8));
|
||||
assert!(matches!(r, ServerMsg::Hosting(_)), "connection {n}");
|
||||
held.push(ws);
|
||||
}
|
||||
assert!(matches!(via_proxy(addr, "2001:db8:9:900::1").1, ServerMsg::Refused { reason } if reason.contains("too many connections")), "64 / 8");
|
||||
assert!(matches!(via_proxy(addr, "2001:db8:a::1").1, ServerMsg::Hosting(_)), "another /48 still gets in");
|
||||
drop(held);
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_block_can_use_up_a_shared_budget() {
|
||||
let mut a = Abuse::new(AbuseConfig { open: true, connects_per_minute: 30, publishes_per_hour: 10, messages_per_second: 200 });
|
||||
let t = Instant::now();
|
||||
// A /48 publishes at most an eighth of what everyone may; everyone else keeps the rest.
|
||||
let from_48 = (0..200u16).filter(|n| a.allow(IpAddr::from([0x2001, 0xdb8, 5, n << 8, 0, 0, 0, 0]), Action::Publish, t)).count();
|
||||
assert_eq!(from_48, 40, "four addresses' worth");
|
||||
let others = (0..400u32).filter(|n| a.allow(IpAddr::from((0xC633_6400 + n).to_be_bytes()), Action::Publish, t)).count();
|
||||
assert_eq!(others, 320 - 40, "the rest of everyone's 320 an hour");
|
||||
// Downloads: a /48's burst is at most an eighth of everyone's (checked when compiling, in `store`).
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn problems_stay_bounded_and_a_full_disk_refuses_writes() {
|
||||
let dir = TempDir::new("problems");
|
||||
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
|
||||
let (info, ..) = store.publish("Broken", &snapshot("Broken")).unwrap();
|
||||
std::fs::write(dir.path().join("files").join(&info.id).join("snapshot.bin"), b"junk").unwrap();
|
||||
for _ in 0..500 {
|
||||
assert!(Backend::open(&mut store, &info.id).is_err());
|
||||
}
|
||||
assert!(store.problems().len() <= 200, "{} problems kept", store.problems().len());
|
||||
drop(store);
|
||||
// Where the free space can be told, below the threshold nothing more is written.
|
||||
if crate::disk::free_bytes(dir.path()).is_some() {
|
||||
let mut c = config(dir.path()).store();
|
||||
c.min_free_bytes = u64::MAX;
|
||||
let mut store = Store::open(dir.path(), c).unwrap();
|
||||
let e = store.publish("More", &snapshot("More")).unwrap_err();
|
||||
assert!(e.contains("disk is nearly full"), "{e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Third review: R1, N3, R2 -------------------------------------------------------------------
|
||||
|
||||
/// Read messages until `pick` takes one (others in between are skipped).
|
||||
fn wait_for<T>(raw: &mut Raw, what: &str, mut pick: impl FnMut(ServerMsg) -> Option<T>) -> T {
|
||||
loop {
|
||||
match raw.next() {
|
||||
ServerMsg::Refused { reason } => panic!("refused while waiting for {what}: {reason}"),
|
||||
m => {
|
||||
if let Some(t) = pick(m) {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_office_behind_one_address_works_together() {
|
||||
let dir = TempDir::new("office");
|
||||
let cfg = Config { max_file_mb: Some(4), max_plugin_mb: 1, ..open(dir.path()) };
|
||||
let mut server = crate::start(&cfg).unwrap();
|
||||
let addr = server.addr();
|
||||
// A file near the size limit.
|
||||
let mut d = Document::new("Office");
|
||||
let page = d.first_page().unwrap();
|
||||
d.create(page, NodeKind::Rectangle, &"o".repeat(3_000_000), 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
let (_, replies) = manage::hosting(
|
||||
&Endpoint::plain(addr.to_string()),
|
||||
"",
|
||||
"",
|
||||
vec![Admin::Publish { name: "Office".into(), snapshot: Replica::new(d).snapshot().unwrap() }],
|
||||
&NetLimits::default(),
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.unwrap();
|
||||
let Some(AdminReply::Hosted { file, edit, .. }) = replies.into_iter().next() else { panic!("not hosted") };
|
||||
// Twenty colleagues, one address, all joining at once: twenty welcomes of 3 MB.
|
||||
let mut people: Vec<Raw> = (0..20).map(|_| Raw::connect(addr)).collect();
|
||||
for (i, p) in people.iter_mut().enumerate() {
|
||||
p.send(&Client::hello(&file.id, &edit.token, &format!("Person {i}"), ""));
|
||||
}
|
||||
let mut clients: Vec<Client> = people
|
||||
.iter_mut()
|
||||
.map(|p| Client::join(wait_for(p, "the welcome", |m| matches!(m, ServerMsg::Welcome { .. }).then_some(m))).unwrap())
|
||||
.collect();
|
||||
// Everyone moves their cursor (twenty people at ~30 a second share the address's budget).
|
||||
for p in people.iter_mut() {
|
||||
for k in 0..60 {
|
||||
p.send(&ClientMsg::Presence(figcraft_collab::Presence { cursor: Some((page, f64::from(k), 0.0)), ..Default::default() }));
|
||||
}
|
||||
}
|
||||
// One pastes 1 MB: the broadcast reaches all twenty.
|
||||
let mut doc = clients[0].document().clone();
|
||||
doc.create(page, NodeKind::Rectangle, &"p".repeat(1_000_000), 0.0, 0.0, 5.0, 5.0).unwrap();
|
||||
let batch = clients[0].edit(&doc).unwrap().unwrap();
|
||||
people[0].send(&batch);
|
||||
for (i, p) in people.iter_mut().enumerate() {
|
||||
let seq = wait_for(p, "the paste", |m| {
|
||||
if let ServerMsg::Changes(a) = m {
|
||||
a.changes.iter().any(|c| matches!(c, figcraft_collab::Change::Create { .. })).then_some(a.seq)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
assert!(seq > 0, "person {i}");
|
||||
}
|
||||
// Still all connected.
|
||||
for p in people.iter_mut() {
|
||||
p.send(&ClientMsg::Ping);
|
||||
wait_for(p, "the pong", |m| (m == ServerMsg::Pong).then_some(()));
|
||||
}
|
||||
assert_eq!(server.connections(), 20);
|
||||
drop(clients.drain(..));
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_biggest_reader_is_cut_off_when_everyones_reading_budget_runs_out() {
|
||||
count_connection_panics();
|
||||
let dir = TempDir::new("readers");
|
||||
let cfg = open(dir.path());
|
||||
let store = Store::open(dir.path(), cfg.store()).unwrap();
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let limits = NetLimits { max_reading_block: 10 << 20, max_reading_total: 300_000, ..cfg.net() };
|
||||
let mut busy = Hosting::serve(listener, store, limits).unwrap();
|
||||
let addr = busy.addr();
|
||||
// A slow sender: it starts a 300 KB message, sends 250 KB of it and stops.
|
||||
let mut hog = Raw::connect(addr);
|
||||
hog.send(&hosting());
|
||||
assert!(matches!(hog.next(), ServerMsg::Hosting(_)));
|
||||
let mut frame = vec![0x82, 0x80 | 127];
|
||||
frame.extend_from_slice(&300_000u64.to_be_bytes());
|
||||
frame.extend_from_slice(&[1, 2, 3, 4]);
|
||||
frame.extend(std::iter::repeat_n(0x55, 250_000));
|
||||
hog.raw_write(&frame);
|
||||
until(10, "the server to read the hog's bytes", || busy.reading_bytes() >= 250_000);
|
||||
// Someone else sends a 100 KB message: the hog is cut off, not them.
|
||||
let mut other = Raw::connect(addr);
|
||||
other.send(&hosting());
|
||||
assert!(matches!(other.next(), ServerMsg::Hosting(_)));
|
||||
let mut d = Document::new("Other");
|
||||
let page = d.first_page().unwrap();
|
||||
d.create(page, NodeKind::Rectangle, &"q".repeat(100_000), 0.0, 0.0, 1.0, 1.0).unwrap();
|
||||
other.send(&ClientMsg::Admin(Admin::Publish { name: "Other".into(), snapshot: Replica::new(d).snapshot().unwrap() }));
|
||||
assert!(matches!(other.next(), ServerMsg::Admin(_)), "the message was read and answered");
|
||||
assert!(hog.closed_within(Duration::from_secs(10)), "the hog's connection was cut");
|
||||
until(10, "the hog's bytes to be given back", || busy.reading_bytes() == 0);
|
||||
assert_eq!(CONNECTION_PANICS.load(std::sync::atomic::Ordering::SeqCst), 0);
|
||||
busy.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_server_forgets_addresses_whose_connections_are_gone() {
|
||||
let dir = TempDir::new("forget");
|
||||
let cfg = Config { trusted_proxy: Some("127.0.0.1".into()), ..open(dir.path()) };
|
||||
let mut server = crate::start(&cfg).unwrap();
|
||||
let addr = server.addr();
|
||||
for n in 1..=10u8 {
|
||||
let (ws, r) = via_proxy(addr, &format!("203.0.113.{n}"));
|
||||
assert!(matches!(r, ServerMsg::Hosting(_)));
|
||||
drop(ws);
|
||||
}
|
||||
until(10, "the connections to close", || server.connections() == 0);
|
||||
let (_last, _) = via_proxy(addr, "203.0.113.200");
|
||||
assert!(server.blocks_remembered() <= 2, "{} blocks remembered", server.blocks_remembered());
|
||||
server.stop();
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
//! that was confirmed and clients resume where they were. There are no user accounts yet: share
|
||||
//! links are the access, and the server key manages files.
|
||||
|
||||
pub mod abuse;
|
||||
pub mod cli;
|
||||
pub mod config;
|
||||
pub mod directory;
|
||||
|
|
@ -38,6 +39,16 @@ impl Running {
|
|||
self.hosting.connections()
|
||||
}
|
||||
|
||||
/// Bytes waiting to be sent to clients, all connections together.
|
||||
pub fn queued_bytes(&self) -> usize {
|
||||
self.hosting.queued_bytes()
|
||||
}
|
||||
|
||||
/// Blocks of addresses the server keeps counters for (see [`Hosting::blocks_remembered`]).
|
||||
pub fn blocks_remembered(&self) -> usize {
|
||||
self.hosting.blocks_remembered()
|
||||
}
|
||||
|
||||
/// Stop (see the type's docs).
|
||||
pub fn stop(&mut self) {
|
||||
self.hosting.stop();
|
||||
|
|
@ -51,6 +62,9 @@ pub fn start(cfg: &Config) -> Result<Running, String> {
|
|||
for p in store.problems() {
|
||||
log::warn!("{p}");
|
||||
}
|
||||
for w in cfg.warnings() {
|
||||
log::warn!("{w}");
|
||||
}
|
||||
let files = store.len();
|
||||
let addr = cfg.addr()?;
|
||||
let listener = TcpListener::bind(addr).map_err(|e| format!("can't listen on {addr}: {e}"))?;
|
||||
|
|
@ -78,8 +92,12 @@ mod cli_tests;
|
|||
#[cfg(test)]
|
||||
mod directory_tests;
|
||||
#[cfg(test)]
|
||||
mod hardening_tests;
|
||||
#[cfg(test)]
|
||||
mod library_tests;
|
||||
#[cfg(test)]
|
||||
mod public_tests;
|
||||
#[cfg(test)]
|
||||
mod server_tests;
|
||||
#[cfg(test)]
|
||||
mod store_tests;
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ fn publish_enable_update_and_usage() {
|
|||
let LibraryReply::Usage(files) = answer(&mut raw) else { panic!("no usage") };
|
||||
let spam = files.iter().find(|f| f.usage.file.starts_with("Spam")).unwrap();
|
||||
assert!(spam.usage.file.chars().count() <= 256 && !spam.usage.file.contains('\n'), "names are cleaned and capped");
|
||||
assert_eq!(spam.usage.components.len(), 2_000, "reports are capped");
|
||||
assert_eq!(spam.usage.components.len(), 1_000, "reports are capped");
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&open(&Invite { token: "nope".into(), ..edit.clone() }));
|
||||
assert!(matches!(raw.next(), ServerMsg::Refused { .. }));
|
||||
|
|
@ -141,7 +141,7 @@ fn publish_enable_update_and_usage() {
|
|||
drop(server);
|
||||
let mut store = Store::open(dir.path(), cfg.store()).unwrap();
|
||||
assert!(matches!(
|
||||
store.library(&edit.file, Access::View, LibraryReq::Fetch { have: None, usage: None }),
|
||||
store.library(std::net::Ipv4Addr::LOCALHOST.into(), &edit.file, Access::View, LibraryReq::Fetch { have: None, usage: None }),
|
||||
LibraryReply::Library { version: 2, .. }
|
||||
));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,495 @@
|
|||
//! A server open to the public: anyone publishes without the key and manages the file with its
|
||||
//! owner token; the caps per address and for everyone, rate limits, idle expiry, blocking,
|
||||
//! reports, the uptime check, `X-Forwarded-For` only from the trusted proxy, and old clients.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{IpAddr, SocketAddr, TcpStream};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use figcraft_collab::Replica;
|
||||
use figcraft_collab::message::{self, Admin, AdminReply, ClientMsg, OpenHosting, PROTOCOL, ServerMsg};
|
||||
use figcraft_collab_net::{Endpoint, Invite, NetLimits, manage};
|
||||
use figcraft_doc::{Document, NodeKind};
|
||||
use serde_json::{Value, json};
|
||||
use tungstenite::client::IntoClientRequest;
|
||||
use tungstenite::{Bytes, Message};
|
||||
|
||||
use crate::abuse::{Abuse, AbuseConfig, Action, MAX_ADDRESSES};
|
||||
use crate::cli::{self, Cli, Command, Sort};
|
||||
use crate::config::Config;
|
||||
use crate::store::{KEY_ONLY, Store};
|
||||
use crate::test_util::{Raw, TempDir, config, join, key, live, pump, session};
|
||||
|
||||
fn open(dir: &std::path::Path) -> Config {
|
||||
Config { open_publish: true, operator_contact: "abuse@example.com".into(), terms_url: "https://example.com/terms".into(), ..config(dir) }
|
||||
}
|
||||
|
||||
fn snapshot(name: &str) -> Vec<figcraft_collab::Change> {
|
||||
let mut d = Document::new(name);
|
||||
let page = d.first_page().unwrap();
|
||||
d.create(page, NodeKind::Rectangle, "Card", 0.0, 0.0, 100.0, 60.0).unwrap();
|
||||
d.repair();
|
||||
Replica::new(d).snapshot().unwrap()
|
||||
}
|
||||
|
||||
fn ip(s: &str) -> IpAddr {
|
||||
s.parse().unwrap()
|
||||
}
|
||||
|
||||
fn ex(s: &mut figcraft_engine::Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn cli(cfg: &Config, command: Command) -> Result<String, String> {
|
||||
cli::run(&Cli { config: cfg.clone(), command })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anyone_publishes_and_the_owner_token_manages_the_file() {
|
||||
let dir = TempDir::new("public");
|
||||
let cfg = open(dir.path());
|
||||
let mut server = crate::start(&cfg).unwrap();
|
||||
let addr = server.addr();
|
||||
let url = format!("ws://{addr}/");
|
||||
let mut a = session("Ada");
|
||||
// The server says it is open, who runs it and its terms before anything is uploaded.
|
||||
let info = ex(&mut a, "collab.serverInfo", json!({"server": url}));
|
||||
assert_eq!(info["open"], true);
|
||||
assert_eq!(info["operator"], "abuse@example.com");
|
||||
assert_eq!(info["terms"], "https://example.com/terms");
|
||||
assert_eq!(info["idleDays"], 30.0);
|
||||
// Published without a key: links, an owner token shown once, and an expiry date.
|
||||
ex(&mut a, "collab.publish", json!({"server": url, "name": "Poster"}));
|
||||
pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live.published.is_some() && s[0].live.hosting.is_some());
|
||||
let p = a.live.published.clone().unwrap();
|
||||
let owner = p.owner.clone().expect("an owner token");
|
||||
assert_eq!(owner.len(), 22, "128 bits");
|
||||
assert!(p.expires.unwrap() > crate::store::now() + 29 * 86_400);
|
||||
let summary = a.live_summary();
|
||||
assert_eq!(summary["published"]["owner"], owner.as_str());
|
||||
assert_eq!(summary["hosting"]["operator"], "abuse@example.com");
|
||||
// The owner token is kept only as a hash.
|
||||
let meta = std::fs::read_to_string(dir.path().join("files").join(&p.file).join("meta.json")).unwrap();
|
||||
assert!(!meta.contains(&owner) && meta.contains(&crate::store::hash(&owner)) && meta.contains("127.0.0.1"), "{meta}");
|
||||
// Someone joining hears about the hosting too (with the file's expiry).
|
||||
let mut b = join(&p.edit, "Bo");
|
||||
pump(&mut [&mut a, &mut b], "Bo in, with the hosting", |s| live(s[1]) && s[1].live.hosting.as_ref().is_some_and(|h| h.expires.is_some()));
|
||||
// The owner lists, makes, revokes links and renames the file.
|
||||
let manage = |s: &mut figcraft_engine::Session, extra: Value| {
|
||||
let mut p2 = json!({"server": url, "file": p.file, "owner": owner});
|
||||
p2.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
s.execute("collab.manageFile", &p2)
|
||||
};
|
||||
let links = manage(&mut a, json!({"action": "links"})).unwrap();
|
||||
assert_eq!(links["links"].as_array().unwrap().len(), 2);
|
||||
let made = manage(&mut a, json!({"action": "createLink", "access": "view", "label": "Review"})).unwrap();
|
||||
assert!(made["link"].as_str().unwrap().starts_with("figcraft://join?host="), "{made}");
|
||||
let view_id = links["links"].as_array().unwrap().iter().find(|l| l["access"] == "view").unwrap()["id"].as_str().unwrap().to_string();
|
||||
assert_eq!(manage(&mut a, json!({"action": "revoke", "link": view_id})).unwrap()["done"], true);
|
||||
manage(&mut a, json!({"action": "rename", "name": "Poster v2"})).unwrap();
|
||||
assert!(cli(&cfg, Command::Files { sort: Sort::Size }).unwrap().contains("Poster v2"));
|
||||
// A wrong token, or another file's, manages nothing.
|
||||
let e = a.execute("collab.manageFile", &json!({"server": url, "file": p.file, "owner": "AAAAAAAAAAAAAAAAAAAAAA", "action": "links"}));
|
||||
assert!(e.unwrap_err().to_string().contains("doesn't manage"));
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: String::new(), file: p.file.clone(), owner: owner.clone() }));
|
||||
assert!(matches!(raw.next(), ServerMsg::Hosting(_)));
|
||||
raw.send(&ClientMsg::Admin(Admin::Remove { file: "0000000000000000".into() }));
|
||||
assert!(matches!(raw.next(), ServerMsg::Admin(r) if matches!(*r, AdminReply::Failed { ref reason } if reason.contains("only its own"))));
|
||||
raw.send(&ClientMsg::Admin(Admin::Listing));
|
||||
assert!(matches!(raw.next(), ServerMsg::Admin(r) if matches!(*r, AdminReply::Failed { ref reason } if reason.contains("server key"))));
|
||||
// Removing it disconnects everyone in it.
|
||||
assert_eq!(manage(&mut a, json!({"action": "remove"})).unwrap()["done"], true);
|
||||
pump(&mut [&mut a, &mut b], "everyone told", |s| s.iter().all(|x| x.live_summary()["status"] == "failed"));
|
||||
assert!(b.message.as_deref().unwrap_or_default().contains("removed"), "{:?}", b.message);
|
||||
assert!(!dir.path().join("files").join(&p.file).exists());
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_private_server_still_needs_its_key() {
|
||||
let dir = TempDir::new("private");
|
||||
let mut server = crate::start(&config(dir.path())).unwrap();
|
||||
let url = format!("ws://{}/", server.addr());
|
||||
let mut a = session("Ada");
|
||||
let info = ex(&mut a, "collab.serverInfo", json!({"server": url}));
|
||||
assert_eq!(info["open"], false);
|
||||
ex(&mut a, "collab.publish", json!({"server": url}));
|
||||
pump(&mut [&mut a], "the refusal", |s| s[0].live_summary()["status"] == "failed");
|
||||
assert!(a.message.as_deref().unwrap_or_default().contains("only from people who have its key"), "{:?}", a.message);
|
||||
assert!(!dir.path().join("files").read_dir().unwrap().any(|_| true), "nothing kept");
|
||||
// The operator's key publishes as before (no owner token, no expiry).
|
||||
let p = crate::test_util::publish(&mut a, dir.path(), server.addr());
|
||||
assert!(p.owner.is_none() && p.expires.is_none());
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_per_address_and_for_everyone() {
|
||||
let dir = TempDir::new("caps");
|
||||
let mut c = open(dir.path());
|
||||
c.max_files_per_ip = 2;
|
||||
c.publishes_per_hour = 100;
|
||||
let mut store = Store::open(dir.path(), c.store()).unwrap();
|
||||
let (a, b) = (ip("203.0.113.1"), ip("203.0.113.2"));
|
||||
store.publish_open(a, "One", &snapshot("One")).unwrap();
|
||||
store.publish_open(a, "Two", &snapshot("Two")).unwrap();
|
||||
let e = store.publish_open(a, "Three", &snapshot("Three")).unwrap_err();
|
||||
assert!(e.contains("keeps 2 files"), "{e}");
|
||||
store.publish_open(b, "Other", &snapshot("Other")).unwrap();
|
||||
drop(store);
|
||||
// Bytes per address, and for everyone (a byte: nothing fits).
|
||||
let mut tight = c.store();
|
||||
tight.hosting.max_bytes_per_ip = 1;
|
||||
let mut store = Store::open(dir.path(), tight.clone()).unwrap();
|
||||
let e = store.publish_open(ip("203.0.113.3"), "Big", &snapshot("Big")).unwrap_err();
|
||||
assert!(e.contains("MiB it may keep"), "{e}");
|
||||
drop(store);
|
||||
tight.hosting.max_bytes_per_ip = u64::MAX;
|
||||
tight.hosting.max_total_bytes = 1;
|
||||
let mut store = Store::open(dir.path(), tight).unwrap();
|
||||
let e = store.publish_open(ip("203.0.113.3"), "Big", &snapshot("Big")).unwrap_err();
|
||||
assert!(e.contains("server is full"), "{e}");
|
||||
drop(store);
|
||||
// Publishing too often.
|
||||
let mut c3 = open(dir.path());
|
||||
c3.publishes_per_hour = 1;
|
||||
let mut store = Store::open(dir.path(), c3.store()).unwrap();
|
||||
store.publish_open(ip("198.51.100.7"), "Once", &snapshot("Once")).unwrap();
|
||||
let e = store.publish_open(ip("198.51.100.7"), "Twice", &snapshot("Twice")).unwrap_err();
|
||||
assert!(e.contains("too many files published"), "{e}");
|
||||
// Closed to the public: nobody publishes without the key.
|
||||
drop(store);
|
||||
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
|
||||
assert_eq!(store.publish_open(ip("198.51.100.8"), "No", &snapshot("No")).unwrap_err(), KEY_ONLY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_files_published_without_the_key_expire() {
|
||||
let dir = TempDir::new("expiry");
|
||||
let mut c = open(dir.path());
|
||||
c.idle_days = 2.0;
|
||||
let mut store = Store::open(dir.path(), c.store()).unwrap();
|
||||
let (public, ..) = store.publish_open(ip("203.0.113.9"), "Public", &snapshot("Public")).unwrap();
|
||||
let (fresh, ..) = store.publish_open(ip("203.0.113.9"), "Fresh", &snapshot("Fresh")).unwrap();
|
||||
let (keyed, ..) = store.publish("Operator's", &snapshot("Operator's")).unwrap();
|
||||
store.age(&public.id, 2 * 86_400 + 1);
|
||||
store.age(&keyed.id, 365 * 86_400);
|
||||
store.age(&fresh.id, 86_400);
|
||||
assert_eq!(store.sweep(), vec![public.id.clone()]);
|
||||
let ids: Vec<String> = store.listing(&|_| None).into_iter().map(|r| r.info.id).collect();
|
||||
assert!(!ids.contains(&public.id) && ids.contains(&fresh.id) && ids.contains(&keyed.id));
|
||||
assert!(!dir.path().join("files").join(&public.id).exists());
|
||||
// Opening it counts as using it: idle time starts over.
|
||||
let mut server = figcraft_collab_net::hub::Backend::open(&mut store, &fresh.id).unwrap();
|
||||
store.age(&fresh.id, 0);
|
||||
assert!(store.listing(&|_| None).iter().any(|r| r.info.id == fresh.id && r.expires.unwrap() > crate::store::now() + 86_400));
|
||||
// An open file never expires under someone.
|
||||
store.age(&fresh.id, 10 * 86_400);
|
||||
assert!(store.sweep().is_empty());
|
||||
figcraft_collab_net::hub::Backend::idle(&mut store, &fresh.id, &mut server);
|
||||
assert!(store.sweep().is_empty(), "everyone just left: the idle time starts now");
|
||||
// A startup finds what expired while the server was down.
|
||||
store.age(&fresh.id, 3 * 86_400);
|
||||
drop(store);
|
||||
let store = Store::open(dir.path(), c.store()).unwrap();
|
||||
assert!(store.listing(&|_| None).iter().all(|r| r.info.id != fresh.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limits_and_blocks_refuse_with_a_reason() {
|
||||
let dir = TempDir::new("limits");
|
||||
let mut c = open(dir.path());
|
||||
c.connects_per_minute = 3;
|
||||
let mut server = crate::start(&c).unwrap();
|
||||
let addr = server.addr();
|
||||
let ask = || {
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&ClientMsg::OpenHosting(OpenHosting {
|
||||
protocol: PROTOCOL,
|
||||
client_version: String::new(),
|
||||
file: String::new(),
|
||||
owner: String::new(),
|
||||
}));
|
||||
raw.next()
|
||||
};
|
||||
for _ in 0..3 {
|
||||
assert!(matches!(ask(), ServerMsg::Hosting(_)));
|
||||
}
|
||||
assert!(matches!(ask(), ServerMsg::Refused { reason } if reason.contains("too many connections from your address")));
|
||||
server.stop();
|
||||
// Messages per address, and blocking.
|
||||
let mut c = open(dir.path());
|
||||
c.messages_per_second = 5;
|
||||
let mut server = crate::start(&c).unwrap();
|
||||
let addr = server.addr();
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: String::new(), file: String::new(), owner: String::new() }));
|
||||
assert!(matches!(raw.next(), ServerMsg::Hosting(_)));
|
||||
let refused = (0..200).find_map(|_| {
|
||||
raw.send(&ClientMsg::Ping);
|
||||
match raw.next() {
|
||||
ServerMsg::Refused { reason } => Some(reason),
|
||||
_ => None,
|
||||
}
|
||||
});
|
||||
assert!(refused.is_some_and(|r| r.contains("too many messages from your address")));
|
||||
let mut held = Raw::connect(addr);
|
||||
held.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: String::new(), file: String::new(), owner: String::new() }));
|
||||
assert!(matches!(held.next(), ServerMsg::Hosting(_)));
|
||||
assert!(cli(&c, Command::Block { ip: "127.0.0.1".into() }).unwrap().contains("Blocked"));
|
||||
assert!(matches!(held.next(), ServerMsg::Refused { reason } if reason.contains("no longer accepts")));
|
||||
let mut again = Raw::connect(addr);
|
||||
again.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: String::new(), file: String::new(), owner: String::new() }));
|
||||
assert!(matches!(again.next(), ServerMsg::Refused { .. }));
|
||||
assert_eq!(cli(&c, Command::Blocked).unwrap(), "127.0.0.1\n");
|
||||
server.stop();
|
||||
// Kept across a restart (the commands work on the directory itself then).
|
||||
assert_eq!(cli(&c, Command::Blocked).unwrap(), "127.0.0.1\n");
|
||||
assert!(cli(&c, Command::Unblock { ip: "127.0.0.1".into() }).unwrap().contains("Unblocked"));
|
||||
assert!(cli(&c, Command::Block { ip: "2001:db8:1:2:3:4:5:6".into() }).unwrap().contains("Blocked"));
|
||||
assert_eq!(cli(&c, Command::Blocked).unwrap(), "2001:db8:1:2::\n", "an IPv6 address blocks its /64");
|
||||
assert!(cli(&c, Command::Block { ip: "not an address".into() }).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn participants_report_files_to_the_operator() {
|
||||
let dir = TempDir::new("reports");
|
||||
let cfg = open(dir.path());
|
||||
let mut server = crate::start(&cfg).unwrap();
|
||||
let url = format!("ws://{}/", server.addr());
|
||||
let mut a = session("Ada");
|
||||
ex(&mut a, "collab.publish", json!({"server": url, "name": "Flyer"}));
|
||||
pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live.published.is_some());
|
||||
let p = a.live.published.clone().unwrap();
|
||||
let mut b = join(&p.view, "Bo");
|
||||
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]) && s[1].live.hosting.is_some());
|
||||
assert!(b.execute("collab.report", &json!({"reason": " "})).is_err());
|
||||
ex(&mut b, "collab.report", json!({"reason": "Spam\nwith a link"}));
|
||||
pump(&mut [&mut b], "the answer", |s| s[0].message.as_deref().is_some_and(|m| m.contains("operator")));
|
||||
let reports = cli(&cfg, Command::Reports).unwrap();
|
||||
assert!(reports.contains(&p.file) && reports.contains("Spam with a link") && reports.contains("Flyer"), "{reports}");
|
||||
assert!(cli(&cfg, Command::Files { sort: Sort::Idle }).unwrap().lines().any(|l| l.contains(&p.file) && l.contains("127.0.0.1")));
|
||||
server.stop();
|
||||
// Kept on disk.
|
||||
assert!(cli(&cfg, Command::Reports).unwrap().contains("Spam with a link"));
|
||||
}
|
||||
|
||||
/// Connect with `X-Forwarded-For: forwarded` and open a hosting connection: what the server said.
|
||||
pub(crate) fn via_proxy(addr: SocketAddr, forwarded: &str) -> (tungstenite::WebSocket<TcpStream>, ServerMsg) {
|
||||
let s = TcpStream::connect(addr).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
|
||||
let mut req = format!("ws://{addr}/").into_client_request().unwrap();
|
||||
req.headers_mut().insert("X-Forwarded-For", forwarded.parse().unwrap());
|
||||
let (mut ws, _) = tungstenite::client::client(req, s).unwrap();
|
||||
let open = ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: String::new(), file: String::new(), owner: String::new() });
|
||||
ws.send(Message::Binary(Bytes::from(message::encode(&open).unwrap()))).unwrap();
|
||||
let reply = loop {
|
||||
if let Message::Binary(b) = ws.read().unwrap() {
|
||||
break message::decode(&b, usize::MAX).unwrap();
|
||||
}
|
||||
};
|
||||
(ws, reply)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forwarded_addresses_count_only_from_the_trusted_proxy() {
|
||||
let dir = TempDir::new("proxy");
|
||||
let mut c = open(dir.path());
|
||||
c.max_connections_per_ip = 1;
|
||||
c.trusted_proxy = Some("127.0.0.1".into());
|
||||
let mut server = crate::start(&c).unwrap();
|
||||
let addr = server.addr();
|
||||
// Behind the proxy each client is its own address: the last one the proxy added.
|
||||
let (_one, r) = via_proxy(addr, "203.0.113.1");
|
||||
assert!(matches!(r, ServerMsg::Hosting(_)));
|
||||
let (_two, r) = via_proxy(addr, "10.0.0.1, 203.0.113.2");
|
||||
assert!(matches!(r, ServerMsg::Hosting(_)));
|
||||
let (_three, r) = via_proxy(addr, "203.0.113.2, 203.0.113.1");
|
||||
assert!(matches!(r, ServerMsg::Refused { reason } if reason.contains("too many connections")));
|
||||
// Blocking the forwarded address blocks that client only.
|
||||
cli(&c, Command::Block { ip: "203.0.113.9".into() }).unwrap();
|
||||
assert!(matches!(via_proxy(addr, "203.0.113.9").1, ServerMsg::Refused { .. }));
|
||||
assert!(matches!(via_proxy(addr, "203.0.113.10").1, ServerMsg::Hosting(_)));
|
||||
server.stop();
|
||||
// Without a trusted proxy the header is anyone's to forge: the connection's own address counts.
|
||||
c.trusted_proxy = None;
|
||||
let mut server = crate::start(&c).unwrap();
|
||||
let addr = server.addr();
|
||||
let (_one, r) = via_proxy(addr, "203.0.113.1");
|
||||
assert!(matches!(r, ServerMsg::Hosting(_)));
|
||||
let (_two, r) = via_proxy(addr, "203.0.113.2");
|
||||
assert!(matches!(r, ServerMsg::Refused { .. }));
|
||||
server.stop();
|
||||
}
|
||||
|
||||
/// Send `request` on a raw socket; what came back.
|
||||
fn http(addr: SocketAddr, request: &str) -> String {
|
||||
let mut s = TcpStream::connect(addr).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
|
||||
s.write_all(request.as_bytes()).unwrap();
|
||||
let mut out = String::new();
|
||||
let _ = s.read_to_string(&mut out);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptime_checks_share_the_port() {
|
||||
let dir = TempDir::new("healthz");
|
||||
let mut server = crate::start(&config(dir.path())).unwrap();
|
||||
let addr = server.addr();
|
||||
let ok = http(addr, "GET /healthz HTTP/1.1\r\nHost: x\r\n\r\n");
|
||||
assert!(ok.starts_with("HTTP/1.1 200 OK") && ok.ends_with("ok\n"), "{ok}");
|
||||
// Under a path (behind a proxy's prefix), and with a query.
|
||||
assert!(http(addr, "GET /figcraft/healthz?x=1 HTTP/1.1\r\n\r\n").starts_with("HTTP/1.1 200"));
|
||||
// Anything else is a WebSocket handshake or nothing.
|
||||
assert!(!http(addr, "GET /healthzz HTTP/1.1\r\n\r\n").contains("200 OK"));
|
||||
assert!(!http(addr, "POST /healthz HTTP/1.1\r\n\r\n").contains("200 OK"));
|
||||
// Sessions still work beside it.
|
||||
let mut a = session("Ada");
|
||||
let p = crate::test_util::publish(&mut a, dir.path(), addr);
|
||||
assert!(!p.edit.is_empty());
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_clients_get_clear_answers() {
|
||||
let dir = TempDir::new("old");
|
||||
let mut server = crate::start(&open(dir.path())).unwrap();
|
||||
let addr = server.addr();
|
||||
// An older app tries to publish with a wrong key: it is told the server is open.
|
||||
let e =
|
||||
manage::request(&Endpoint::plain(addr.to_string()), "wrong", vec![Admin::Files], &NetLimits::default(), Duration::from_secs(5)).unwrap_err();
|
||||
assert!(e.contains("open to the public"), "{e}");
|
||||
// A hello that doesn't announce `hosting` is never sent what it can't read: after its
|
||||
// welcome, the answer to its ping comes before anything about hosting would.
|
||||
let mut a = session("Ada");
|
||||
let p = crate::test_util::publish(&mut a, dir.path(), addr);
|
||||
let edit = Invite::parse(&p.edit).unwrap();
|
||||
let mut raw = Raw::connect(addr);
|
||||
let mut hello = figcraft_collab::Client::hello(&edit.file, &edit.token, "Old", "");
|
||||
if let ClientMsg::Hello(h) = &mut hello {
|
||||
h.client_version = "0.3.0 signals".into();
|
||||
}
|
||||
raw.send(&hello);
|
||||
raw.send(&ClientMsg::Ping);
|
||||
let seen: Vec<ServerMsg> = std::iter::from_fn(|| Some(raw.next())).take_while(|m| *m != ServerMsg::Pong).collect();
|
||||
assert!(seen.iter().any(|m| matches!(m, ServerMsg::Welcome { .. })));
|
||||
assert!(!seen.iter().any(|m| matches!(m, ServerMsg::Hosting(_))), "{seen:?}");
|
||||
// The operator's key still manages everything.
|
||||
let ok =
|
||||
manage::request(&Endpoint::plain(addr.to_string()), &key(dir.path()), vec![Admin::Listing], &NetLimits::default(), Duration::from_secs(5))
|
||||
.unwrap();
|
||||
assert!(matches!(&ok[..], [AdminReply::Listing(rows)] if rows.len() == 1));
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_address_table_stays_bounded() {
|
||||
let mut abuse = Abuse::new(AbuseConfig { open: true, connects_per_minute: 1, publishes_per_hour: 1, messages_per_second: 1 });
|
||||
let t = Instant::now();
|
||||
for i in 0..(MAX_ADDRESSES as u32 * 3) {
|
||||
assert!(abuse.allow(IpAddr::from(i.to_be_bytes()), Action::Connect, t + Duration::from_micros(u64::from(i))));
|
||||
}
|
||||
assert!(abuse.len() <= MAX_ADDRESSES);
|
||||
// A remembered address is limited; a private server limits only reports.
|
||||
let me = ip("192.0.2.1");
|
||||
assert!(abuse.allow(me, Action::Connect, t) && !abuse.allow(me, Action::Connect, t));
|
||||
let mut closed = Abuse::new(AbuseConfig { open: false, connects_per_minute: 1, publishes_per_hour: 1, messages_per_second: 1 });
|
||||
assert!((0..100).all(|_| closed.allow(me, Action::Connect, t)));
|
||||
assert!((0..10).all(|_| closed.allow(me, Action::Report, t)) && !closed.allow(me, Action::Report, t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_mode_has_safe_defaults_and_warns_the_operator() {
|
||||
let private = Config::default();
|
||||
assert_eq!(private.file_mb(), 64, "a private server behaves as before");
|
||||
assert!(private.warnings().is_empty());
|
||||
assert_eq!(private.net().max_per_address, 0);
|
||||
let open = Config { open_publish: true, public_url: Some("wss://figcraft.example.com/".into()), ..Config::default() };
|
||||
assert_eq!(open.file_mb(), 16, "strangers' files stay small");
|
||||
assert_eq!(open.store().hosting.idle_secs, 30 * 86_400, "idle files expire");
|
||||
assert_eq!(open.net().max_per_address, 32);
|
||||
assert_eq!(open.net().request_idle, Duration::from_secs(20), "idle one-shot requests go quickly");
|
||||
assert_eq!(open.net().reserve_for_sessions, 512 / 8);
|
||||
assert_eq!(open.net().max_client_message, 17 << 20);
|
||||
assert_eq!(private.net().max_client_message, NetLimits::default().max_client_message, "unchanged when private");
|
||||
let w = open.warnings().join("\n");
|
||||
assert!(w.contains("operator_contact is empty") && w.contains("trusted_proxy"), "{w}");
|
||||
let set = Config { operator_contact: "abuse@example.com".into(), trusted_proxy: Some("127.0.0.1".into()), max_file_mb: Some(8), ..open };
|
||||
assert!(set.warnings().is_empty(), "{:?}", set.warnings());
|
||||
assert_eq!(set.file_mb(), 8);
|
||||
assert!(Config { trusted_proxy: Some("localhost".into()), ..set.clone() }.check().is_err());
|
||||
assert!(Config { terms_url: "javascript:alert(1)".into(), ..set.clone() }.check().is_err());
|
||||
assert!(Config { idle_days: f64::NAN, ..set }.check().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_connections_never_reach_what_needs_the_key() {
|
||||
let dir = TempDir::new("keyonly");
|
||||
let mut server = crate::start(&open(dir.path())).unwrap();
|
||||
let addr = server.addr();
|
||||
let mut store_side = session("Ada");
|
||||
let keyed = crate::test_util::publish(&mut store_side, dir.path(), addr);
|
||||
let other = Invite::parse(&keyed.edit).unwrap().file;
|
||||
// A file of one's own, with its owner token.
|
||||
let (info, replies) = manage::hosting(
|
||||
&Endpoint::plain(addr.to_string()),
|
||||
"",
|
||||
"",
|
||||
vec![Admin::Publish { name: "Mine".into(), snapshot: snapshot("Mine") }],
|
||||
&NetLimits::default(),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(info.open);
|
||||
let Some(AdminReply::Hosted { file: mine, owner, .. }) = replies.into_iter().next() else { panic!("not hosted") };
|
||||
let key_only = vec![
|
||||
Admin::Files,
|
||||
Admin::Listing,
|
||||
Admin::Reports,
|
||||
Admin::Blocked,
|
||||
Admin::Block { ip: "203.0.113.1".into() },
|
||||
Admin::Unblock { ip: "203.0.113.1".into() },
|
||||
Admin::Links { file: other.clone() },
|
||||
Admin::CreateLink { file: other.clone(), access: figcraft_collab::Access::Edit, label: String::new() },
|
||||
Admin::Revoke { file: other.clone(), link: "x".into() },
|
||||
Admin::Rename { file: other.clone(), name: "Taken".into() },
|
||||
Admin::Remove { file: other.clone() },
|
||||
];
|
||||
// Without a token, and with the token of another file: every one is refused.
|
||||
for (file, token) in [("", ""), (mine.id.as_str(), owner.as_str())] {
|
||||
let (_, replies) =
|
||||
manage::hosting(&Endpoint::plain(addr.to_string()), file, token, key_only.clone(), &NetLimits::default(), Duration::from_secs(5))
|
||||
.unwrap();
|
||||
for (req, r) in key_only.iter().zip(replies) {
|
||||
assert!(matches!(r, AdminReply::Failed { .. }), "{req:?} answered {r:?}");
|
||||
}
|
||||
}
|
||||
// The keyed file is untouched (its name, both links), nothing is blocked, and the plugin
|
||||
// directory won't take a bundle.
|
||||
let k = key(dir.path());
|
||||
let check = manage::request(
|
||||
&Endpoint::plain(addr.to_string()),
|
||||
&k,
|
||||
vec![Admin::Listing, Admin::Links { file: other.clone() }],
|
||||
&NetLimits::default(),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(&check[0], AdminReply::Listing(rows) if rows.iter().any(|r| r.info.id == other && r.info.name == "Team file")));
|
||||
assert!(matches!(&check[1], AdminReply::Links(links) if links.len() == 2));
|
||||
assert!(std::fs::read_to_string(dir.path().join("blocked.json")).is_err());
|
||||
let e = manage::plugins(
|
||||
&Endpoint::plain(addr.to_string()),
|
||||
"",
|
||||
figcraft_collab::message::PluginReq::Publish { bundle: vec![1, 2, 3] },
|
||||
&NetLimits::default(),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(e, figcraft_collab::message::PluginReply::Failed { .. }), "{e:?}");
|
||||
// Open-mode answers never carry the server key.
|
||||
assert!(!format!("{info:?}").contains(&k));
|
||||
server.stop();
|
||||
}
|
||||
|
|
@ -181,7 +181,10 @@ fn revoked_links_stop_working_at_once() {
|
|||
#[test]
|
||||
fn fuzzed_frames_dont_hurt_the_server() {
|
||||
let dir = TempDir::new("fuzz");
|
||||
let mut server = crate::start(&config(dir.path())).unwrap();
|
||||
// Open to the public, so publishing without the key and owner tokens are fuzzed too (with
|
||||
// rates high enough that the noise gets in).
|
||||
let cfg = crate::config::Config { open_publish: true, connects_per_minute: 100_000, messages_per_second: 100_000, ..config(dir.path()) };
|
||||
let mut server = crate::start(&cfg).unwrap();
|
||||
let addr = server.addr();
|
||||
let mut a = session("Ada");
|
||||
let p = publish(&mut a, dir.path(), addr);
|
||||
|
|
@ -202,6 +205,26 @@ fn fuzzed_frames_dont_hurt_the_server() {
|
|||
}),
|
||||
ClientMsg::OpenPlugins(message::OpenPlugins { protocol: message::PROTOCOL, key: String::new(), client_version: String::new() }),
|
||||
ClientMsg::Plugins(message::PluginReq::List { query: "tiny".into() }),
|
||||
ClientMsg::OpenHosting(message::OpenHosting {
|
||||
protocol: message::PROTOCOL,
|
||||
client_version: String::new(),
|
||||
file: String::new(),
|
||||
owner: String::new(),
|
||||
}),
|
||||
ClientMsg::OpenHosting(message::OpenHosting {
|
||||
protocol: message::PROTOCOL,
|
||||
client_version: String::new(),
|
||||
file: edit.file.clone(),
|
||||
owner: "AAAAAAAAAAAAAAAAAAAAAA".into(),
|
||||
}),
|
||||
ClientMsg::Admin(Admin::Publish {
|
||||
name: "Fuzz".into(),
|
||||
snapshot: figcraft_collab::Replica::new(figcraft_doc::Document::new("F")).snapshot().unwrap(),
|
||||
}),
|
||||
ClientMsg::Admin(Admin::Rename { file: edit.file.clone(), name: "Fuzzed".into() }),
|
||||
ClientMsg::Admin(Admin::Listing),
|
||||
ClientMsg::Admin(Admin::Block { ip: "203.0.113.1".into() }),
|
||||
ClientMsg::Report { reason: "fuzz".into() },
|
||||
ClientMsg::Plugins(message::PluginReq::Fetch { id: "org.example.tiny".into(), version: None }),
|
||||
ClientMsg::Plugins(message::PluginReq::Publish {
|
||||
bundle: figcraft_plugin::bundle::Bundle { manifest: "{}".into(), wasm: b"\0asm".to_vec(), assets: Vec::new() }.encode(),
|
||||
|
|
|
|||
|
|
@ -12,8 +12,15 @@
|
|||
//! <data>/files/<id>/library.bin its team library, when one was published (a `.figcraft` archive)
|
||||
//! <data>/files/<id>/usage.json how other files use that library, as their apps report it
|
||||
//! <data>/plugins/ the plugin directory (see `directory`)
|
||||
//! <data>/blocked.json addresses the operator blocked
|
||||
//! <data>/reports.json reports people sent about files
|
||||
//! ```
|
||||
//!
|
||||
//! A server open to the public ([`HostingConfig::open`]) also keeps files anyone published
|
||||
//! without the key: such a file has an owner token (stored as its hash) that manages it, the
|
||||
//! address it came from (for the per-address caps and the operator), and it is deleted once nobody
|
||||
//! has opened it for [`HostingConfig::idle_secs`].
|
||||
//!
|
||||
//! An open file appends its server's journal to its log; the hub has the store make the appends
|
||||
//! durable before anyone hears of them. After [`StoreConfig::compact_after`] entries or
|
||||
//! [`StoreConfig::compact_bytes`] of log, when everyone has left, and when the server stops, the
|
||||
|
|
@ -21,37 +28,73 @@
|
|||
//! hashes and compared in constant time. Whatever is damaged at startup is skipped and reported,
|
||||
//! never deleted.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fs::{self, File, OpenOptions, TryLockError};
|
||||
use std::io::{Cursor, Write};
|
||||
use std::net::IpAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use figcraft_collab::message::{
|
||||
Access, Admin, AdminReply, FileInfo, FileUsage, LibraryReply, LibraryReq, LinkInfo, NewLink, PluginReply, PluginReq, Usage,
|
||||
Access, Admin, AdminReply, ClientMsg, FileInfo, FileRow, FileUsage, HostInfo, LibraryReply, LibraryReq, LinkInfo, NewLink, PluginReply,
|
||||
PluginReq, ReportInfo, Usage,
|
||||
};
|
||||
use figcraft_collab::{Change, Replica, Server, same_secret};
|
||||
use figcraft_collab_net::hub::{Admission, Backend, Closing, Handled, OpenFile};
|
||||
use figcraft_collab_net::hub::{Admission, Backend, Closing, Handled, OpenFile, ip_key};
|
||||
use figcraft_collab_net::token;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::abuse::{Abuse, AbuseConfig, Action};
|
||||
use crate::directory::{Directory, DirectoryConfig};
|
||||
use crate::disk::{self, LIBRARY, LINKS, LOG, LOG_HEADER, META, SNAPSHOT, USAGE};
|
||||
|
||||
pub const LOCK: &str = "LOCK";
|
||||
pub const KEY: &str = "server.key";
|
||||
pub const FILES: &str = "files";
|
||||
pub const BLOCKED: &str = "blocked.json";
|
||||
pub const REPORTS: &str = "reports.json";
|
||||
|
||||
/// Most addresses blocked, and most reports kept (the oldest go first).
|
||||
const MAX_BLOCKED: usize = 100_000;
|
||||
const MAX_REPORTS: usize = 1_000;
|
||||
/// Longest report reason kept (characters).
|
||||
const MAX_REASON: usize = 500;
|
||||
/// How often idle files are looked for (seconds), besides at startup.
|
||||
const SWEEP_EVERY: u64 = 600;
|
||||
/// A file over its cap is compacted to make room at most this often (seconds): a compaction
|
||||
/// rewrites the whole file on the hub thread.
|
||||
const FORCED_COMPACTION_EVERY: u64 = 60;
|
||||
/// Most problems kept for the operator (the oldest go first).
|
||||
const MAX_PROBLEMS: usize = 200;
|
||||
|
||||
/// Why a download is turned away (an address's share, or everyone's, is used up for now).
|
||||
pub const BUSY_DOWNLOADS: &str = "too many downloads from your address or from everyone right now: try again in a minute";
|
||||
|
||||
/// Why publishing without the key is refused on a server that isn't open to the public.
|
||||
pub const KEY_ONLY: &str = "this server takes files only from people who have its key: add ?key=… to the address (its operator has the key)";
|
||||
|
||||
/// Why a file published without the key can't hold a team library on a public server (a library
|
||||
/// is a whole archive checked on the server; strangers' files don't get that).
|
||||
pub const NO_PUBLIC_LIBRARY: &str = "on this public server only files published with the server key can hold a team library";
|
||||
|
||||
/// Most files whose usage of one library is kept (the least recently changed go first), and most
|
||||
/// components one file's report keeps.
|
||||
const MAX_USERS: usize = 500;
|
||||
const MAX_REPORTED: usize = 2_000;
|
||||
const MAX_USERS: usize = 200;
|
||||
const MAX_REPORTED: usize = 1_000;
|
||||
/// Largest `usage.json` read.
|
||||
const MAX_USAGE_FILE: u64 = 32 << 20;
|
||||
const MAX_USAGE_FILE: u64 = 4 << 20;
|
||||
/// A library's usage reports are kept in memory and written at most this often (seconds), and
|
||||
/// when the server stops.
|
||||
const USAGE_WRITE_EVERY: u64 = 60;
|
||||
/// Bytes of team libraries served a second, everyone together, and at once.
|
||||
const LIBRARY_RATE: f64 = (32u64 << 20) as f64;
|
||||
const LIBRARY_BURST: f64 = (512u64 << 20) as f64;
|
||||
// A /48 (four addresses' worth) may download at most an eighth of everyone's budgets.
|
||||
const _: () = assert!(crate::abuse::DOWNLOAD_RATE * 4.0 <= LIBRARY_RATE / 8.0 && crate::abuse::DOWNLOAD_BURST * 4.0 <= LIBRARY_BURST / 8.0);
|
||||
|
||||
/// What the store enforces.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct StoreConfig {
|
||||
pub max_files: usize,
|
||||
pub max_file_bytes: u64,
|
||||
|
|
@ -60,6 +103,44 @@ pub struct StoreConfig {
|
|||
pub compact_bytes: u64,
|
||||
pub core: figcraft_collab::Limits,
|
||||
pub directory: DirectoryConfig,
|
||||
pub hosting: HostingConfig,
|
||||
/// Below this much free space on the data directory's disk, changes and new files are
|
||||
/// refused (with the reason) rather than half written.
|
||||
pub min_free_bytes: u64,
|
||||
}
|
||||
|
||||
/// Hosting for the public (see the server's settings).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct HostingConfig {
|
||||
/// Anyone may publish without the key.
|
||||
pub open: bool,
|
||||
pub operator: String,
|
||||
pub terms: String,
|
||||
/// Files published without the key are deleted after this long unopened (0: kept).
|
||||
pub idle_secs: u64,
|
||||
pub max_file_mb: u64,
|
||||
/// Publishing without the key stops while the files take this much in all.
|
||||
pub max_total_bytes: u64,
|
||||
/// Files published without the key per address, and their bytes.
|
||||
pub max_files_per_ip: usize,
|
||||
pub max_bytes_per_ip: u64,
|
||||
pub abuse: AbuseConfig,
|
||||
}
|
||||
|
||||
impl Default for HostingConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
open: false,
|
||||
operator: String::new(),
|
||||
terms: String::new(),
|
||||
idle_secs: 0,
|
||||
max_file_mb: 64,
|
||||
max_total_bytes: u64::MAX,
|
||||
max_files_per_ip: usize::MAX,
|
||||
max_bytes_per_ip: u64::MAX,
|
||||
abuse: AbuseConfig { open: false, connects_per_minute: 30, publishes_per_hour: 10, messages_per_second: 200 },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A file's description (`meta.json`).
|
||||
|
|
@ -73,6 +154,21 @@ struct Meta {
|
|||
/// The team library published under the file, if any.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
library: Option<LibraryMeta>,
|
||||
/// Published without the key: SHA-256 of its owner token (hex), and the address it came from.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
owner: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
ip: Option<String>,
|
||||
/// When someone last opened it (seconds since 1970).
|
||||
#[serde(default)]
|
||||
opened: u64,
|
||||
}
|
||||
|
||||
impl Meta {
|
||||
/// When it was last used: made, changed or opened.
|
||||
fn used(&self) -> u64 {
|
||||
self.created.max(self.updated).max(self.opened)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a file's team library is (`library.bin` holds it).
|
||||
|
|
@ -118,6 +214,17 @@ struct Entry {
|
|||
links: Vec<Link>,
|
||||
/// Open: its log.
|
||||
log: Option<Log>,
|
||||
/// Its snapshot's and log's bytes on disk (what was written; pending records not yet).
|
||||
bytes: u64,
|
||||
/// When it was last compacted to make room (seconds since 1970).
|
||||
forced: u64,
|
||||
}
|
||||
|
||||
/// A library's usage reports in memory, and whether (and when) they were written.
|
||||
struct UsageCache {
|
||||
list: Vec<FileUsage>,
|
||||
dirty: bool,
|
||||
written: u64,
|
||||
}
|
||||
|
||||
/// The data directory in use (it holds the directory's lock until dropped).
|
||||
|
|
@ -130,6 +237,20 @@ pub struct Store {
|
|||
problems: Vec<String>,
|
||||
/// The plugin directory.
|
||||
directory: Directory,
|
||||
/// What addresses may do how often, the ones blocked, and the reports kept.
|
||||
abuse: Abuse,
|
||||
/// Team library bytes served, everyone together.
|
||||
served: figcraft_collab_net::net::Bucket,
|
||||
/// Usage reports by library file.
|
||||
usage: BTreeMap<String, UsageCache>,
|
||||
blocked: BTreeSet<IpAddr>,
|
||||
reports: Vec<ReportInfo>,
|
||||
/// When idle files are looked for next (seconds since 1970).
|
||||
next_sweep: u64,
|
||||
/// Compactions so far (tests count them).
|
||||
compactions: u64,
|
||||
/// The free space last seen on the data directory's disk, and when.
|
||||
free: Option<(Instant, Option<u64>)>,
|
||||
_lock: File,
|
||||
}
|
||||
|
||||
|
|
@ -155,7 +276,12 @@ fn clean_name(s: &str) -> String {
|
|||
|
||||
/// Take the data directory's lock: `None` when someone else holds it.
|
||||
pub fn lock(root: &Path) -> Result<Option<File>, String> {
|
||||
fs::create_dir_all(root).map_err(|e| format!("can't create the data directory {}: {e}", root.display()))?;
|
||||
// Only the server's account reads what it keeps (files, addresses, reports, the key).
|
||||
let mut dirs = fs::DirBuilder::new();
|
||||
dirs.recursive(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::DirBuilderExt::mode(&mut dirs, 0o700);
|
||||
dirs.create(root).map_err(|e| format!("can't create the data directory {}: {e}", root.display()))?;
|
||||
let f = OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
|
|
@ -184,12 +310,7 @@ fn load_key(root: &Path) -> Result<String, String> {
|
|||
fn write_key(root: &Path) -> Result<String, String> {
|
||||
let key = token::secret()?;
|
||||
let path = root.join(KEY);
|
||||
disk::write_atomic(&path, format!("{key}\n").as_bytes()).map_err(|e| format!("can't write {}: {e}", path.display()))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
disk::write_atomic_private(&path, format!("{key}\n").as_bytes()).map_err(|e| format!("can't write {}: {e}", path.display()))?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
|
|
@ -207,11 +328,47 @@ impl Store {
|
|||
let dir = root.join(FILES);
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?;
|
||||
let (directory, problems) = Directory::open(root, cfg.directory);
|
||||
let mut store = Store { root: root.to_path_buf(), cfg, key, files: BTreeMap::new(), problems, directory, _lock: lock };
|
||||
let abuse = Abuse::new(cfg.hosting.abuse);
|
||||
let mut store = Store {
|
||||
root: root.to_path_buf(),
|
||||
cfg,
|
||||
key,
|
||||
files: BTreeMap::new(),
|
||||
problems,
|
||||
directory,
|
||||
abuse,
|
||||
served: figcraft_collab_net::net::Bucket::new(LIBRARY_RATE, LIBRARY_BURST),
|
||||
usage: BTreeMap::new(),
|
||||
blocked: BTreeSet::new(),
|
||||
reports: Vec::new(),
|
||||
next_sweep: 0,
|
||||
compactions: 0,
|
||||
free: None,
|
||||
_lock: lock,
|
||||
};
|
||||
store.scan(&dir);
|
||||
store.blocked = store.read_list::<String>(BLOCKED).iter().filter_map(|s| s.parse().ok()).map(ip_key).collect();
|
||||
store.reports = store.read_list(REPORTS);
|
||||
store.sweep();
|
||||
Ok(Some(store))
|
||||
}
|
||||
|
||||
/// A list kept at the root (missing: empty; damaged: empty and reported, kept until the next
|
||||
/// change writes over it).
|
||||
fn read_list<T: serde::de::DeserializeOwned>(&mut self, name: &str) -> Vec<T> {
|
||||
let path = self.root.join(name);
|
||||
if !path.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
match disk::read_capped(&path, 16 << 20).and_then(|b| serde_json::from_slice(&b).map_err(|e| format!("{name} is damaged ({e})"))) {
|
||||
Ok(list) => list,
|
||||
Err(e) => {
|
||||
self.problems.push(e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn scan(&mut self, dir: &Path) {
|
||||
let entries = match fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
|
|
@ -260,7 +417,8 @@ impl Store {
|
|||
{
|
||||
log::info!("file at {}: its log predates the last compaction; it is ignored", path.display());
|
||||
}
|
||||
Ok(Entry { meta, links, log: None })
|
||||
let bytes = [SNAPSHOT, LOG].iter().filter_map(|n| fs::metadata(path.join(n)).ok()).map(|m| m.len()).sum();
|
||||
Ok(Entry { meta, links, log: None, bytes, forced: 0 })
|
||||
}
|
||||
|
||||
/// The server key (for managing files).
|
||||
|
|
@ -302,9 +460,19 @@ impl Store {
|
|||
self.files.get_mut(id).ok_or_else(|| "this file isn't on the server (it may have been removed)".to_string())
|
||||
}
|
||||
|
||||
/// A file's bytes on disk (its snapshot and log), with the log records not written yet.
|
||||
fn bytes(&self, id: &str) -> u64 {
|
||||
self.files.get(id).map_or(0, |e| e.bytes + e.log.as_ref().map_or(0, |l| l.pending.len() as u64))
|
||||
}
|
||||
|
||||
/// When a file published without the key is deleted unless someone opens it.
|
||||
fn expires(&self, meta: &Meta) -> Option<u64> {
|
||||
let idle = self.cfg.hosting.idle_secs;
|
||||
(meta.owner.is_some() && idle > 0).then(|| meta.used().saturating_add(idle))
|
||||
}
|
||||
|
||||
fn info(&self, id: &str, e: &Entry, open: Option<OpenFile>) -> FileInfo {
|
||||
let dir = self.dir(id);
|
||||
let bytes = [SNAPSHOT, LOG].iter().filter_map(|n| fs::metadata(dir.join(n)).ok()).map(|m| m.len()).sum();
|
||||
let bytes = self.bytes(id);
|
||||
FileInfo {
|
||||
id: id.to_string(),
|
||||
name: e.meta.name.clone(),
|
||||
|
|
@ -318,9 +486,58 @@ impl Store {
|
|||
|
||||
/// Keep a document (its snapshot) as a new file with an edit link and a view link.
|
||||
pub fn publish(&mut self, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink), String> {
|
||||
self.keep(name, snapshot, None, &|_| Ok(())).map(|(info, edit, view, _)| (info, edit, view))
|
||||
}
|
||||
|
||||
/// Keep a document published without the key from `ip`, within the caps on what one address
|
||||
/// and everyone may keep: the file, its links and its owner token.
|
||||
pub fn publish_open(&mut self, ip: IpAddr, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink, String), String> {
|
||||
let h = &self.cfg.hosting;
|
||||
let (open, max_files, max_ip, max_all) = (h.open, h.max_files_per_ip, h.max_bytes_per_ip, h.max_total_bytes);
|
||||
if !open {
|
||||
return Err(KEY_ONLY.into());
|
||||
}
|
||||
if !self.abuse.allow(ip, Action::Publish, Instant::now()) {
|
||||
return Err("too many files published from your address lately: try again in an hour".into());
|
||||
}
|
||||
let ip_text = ip.to_string();
|
||||
let mine: Vec<&String> = self.files.iter().filter(|(_, e)| e.meta.ip.as_deref() == Some(ip_text.as_str())).map(|(id, _)| id).collect();
|
||||
if mine.len() >= max_files {
|
||||
return Err(format!(
|
||||
"your address keeps {} files on this server already, the most it may: remove one (with its owner token) or wait until one expires",
|
||||
mine.len()
|
||||
));
|
||||
}
|
||||
let used_ip: u64 = mine.iter().map(|id| self.bytes(id)).sum();
|
||||
let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum();
|
||||
let fits = |n: u64| {
|
||||
if used_ip.saturating_add(n) > max_ip {
|
||||
return Err(format!("your address's files would take more than the {} MiB it may keep here: remove one first", max_ip >> 20));
|
||||
}
|
||||
if used_all.saturating_add(n) > max_all {
|
||||
return Err("this server is full: it takes no more files for now (try again later, or another server)".into());
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
let (info, edit, view, owner) = self.keep(name, snapshot, Some(ip), &fits)?;
|
||||
Ok((info, edit, view, owner.unwrap_or_default()))
|
||||
}
|
||||
|
||||
/// Keep a document as a new file: published without the key from `ip` (it gets an owner
|
||||
/// token), else by the operator. `fits` checks its size against the caps that apply.
|
||||
fn keep(
|
||||
&mut self,
|
||||
name: &str,
|
||||
snapshot: &[Change],
|
||||
ip: Option<IpAddr>,
|
||||
fits: &dyn Fn(u64) -> Result<(), String>,
|
||||
) -> Result<(FileInfo, NewLink, NewLink, Option<String>), String> {
|
||||
if self.files.len() >= self.cfg.max_files {
|
||||
return Err(format!("the server keeps at most {} files", self.cfg.max_files));
|
||||
}
|
||||
if let Some(why) = self.low_disk() {
|
||||
return Err(why);
|
||||
}
|
||||
let replica = Replica::from_snapshot(snapshot).map_err(|e| format!("the file couldn't be read: {e}"))?;
|
||||
if replica.document().node_count() > self.cfg.core.max_nodes {
|
||||
return Err(format!("the file has more than {} layers", self.cfg.core.max_nodes));
|
||||
|
|
@ -337,6 +554,7 @@ impl Store {
|
|||
if bytes.len() as u64 > self.cfg.max_file_bytes {
|
||||
return Err(format!("the file is {} MiB; this server keeps files up to {} MiB", bytes.len() >> 20, self.cfg.max_file_bytes >> 20));
|
||||
}
|
||||
fits(bytes.len() as u64)?;
|
||||
let id = loop {
|
||||
let id = token::file_id()?;
|
||||
if !self.files.contains_key(&id) && !self.dir(&id).exists() {
|
||||
|
|
@ -346,7 +564,9 @@ impl Store {
|
|||
let t = now();
|
||||
let (edit, edit_token) = new_link(Access::Edit, "Published (edit)", t)?;
|
||||
let (view, view_token) = new_link(Access::View, "Published (view)", t)?;
|
||||
let meta = Meta { name, created: t, updated: t, seq: 0, library: None };
|
||||
let owner = ip.map(|_| token::secret()).transpose()?;
|
||||
let meta =
|
||||
Meta { name, created: t, updated: t, seq: 0, library: None, owner: owner.as_deref().map(hash), ip: ip.map(|i| i.to_string()), opened: 0 };
|
||||
let dir = self.dir(&id);
|
||||
let written = fs::create_dir_all(&dir)
|
||||
.map_err(|e| e.to_string())
|
||||
|
|
@ -359,11 +579,153 @@ impl Store {
|
|||
let _ = fs::remove_dir_all(&dir);
|
||||
return Err(format!("couldn't keep the file: {e}"));
|
||||
}
|
||||
let entry = Entry { meta, links: vec![edit.clone(), view.clone()], log: None };
|
||||
let info = self.info(&id, &entry, None);
|
||||
let entry = Entry { meta, links: vec![edit.clone(), view.clone()], log: None, bytes: (bytes.len() + LOG_HEADER) as u64, forced: 0 };
|
||||
self.files.insert(id.clone(), entry);
|
||||
log::info!("published file {id} ({} bytes)", bytes.len());
|
||||
Ok((info, NewLink { link: edit.info(&id), token: edit_token }, NewLink { link: view.info(&id), token: view_token }))
|
||||
let info = self.files.get(&id).map(|e| self.info(&id, e, None)).ok_or("the file vanished")?;
|
||||
match ip {
|
||||
Some(ip) => log::info!("event=published file={id} bytes={} ip={ip} by=public", bytes.len()),
|
||||
None => log::info!("event=published file={id} bytes={} by=key", bytes.len()),
|
||||
}
|
||||
Ok((info, NewLink { link: edit.info(&id), token: edit_token }, NewLink { link: view.info(&id), token: view_token }, owner))
|
||||
}
|
||||
|
||||
/// The files kept, with what the operator moderates by.
|
||||
pub fn listing(&self, open: &dyn Fn(&str) -> Option<OpenFile>) -> Vec<FileRow> {
|
||||
self.files
|
||||
.iter()
|
||||
.map(|(id, e)| FileRow {
|
||||
info: self.info(id, e, open(id)),
|
||||
opened: e.meta.opened,
|
||||
expires: self.expires(&e.meta),
|
||||
open: e.meta.owner.is_some(),
|
||||
ip: e.meta.ip.clone().unwrap_or_default(),
|
||||
reports: u32::try_from(self.reports.iter().filter(|r| r.file == *id).count()).unwrap_or(u32::MAX),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Rename a file (its listing; the document inside keeps its own name).
|
||||
pub fn rename(&mut self, file: &str, name: &str) -> Result<(), String> {
|
||||
let name = clean_name(name);
|
||||
if name.is_empty() {
|
||||
return Err("give the file a name".into());
|
||||
}
|
||||
let path = self.dir(file).join(META);
|
||||
let entry = self.entry(file)?;
|
||||
let before = std::mem::replace(&mut entry.meta.name, name);
|
||||
if let Err(e) = write_json(&path, &entry.meta) {
|
||||
entry.meta.name = before;
|
||||
return Err(format!("couldn't rename the file: {e}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete the files published without the key that nobody opened for the idle time (not
|
||||
/// those open now); their ids.
|
||||
pub fn sweep(&mut self) -> Vec<String> {
|
||||
let t = now();
|
||||
self.next_sweep = t.saturating_add(SWEEP_EVERY);
|
||||
let due: Vec<String> =
|
||||
self.files.iter().filter(|(_, e)| e.log.is_none() && self.expires(&e.meta).is_some_and(|x| x <= t)).map(|(id, _)| id.clone()).collect();
|
||||
for id in &due {
|
||||
match self.remove(id) {
|
||||
Ok(()) => log::info!("event=expired file={id}"),
|
||||
Err(e) => log::warn!("event=expire_failed file={id} reason={e:?}"),
|
||||
}
|
||||
}
|
||||
due
|
||||
}
|
||||
|
||||
/// Make a file look unused for `secs` more (tests: expiry without waiting).
|
||||
#[cfg(test)]
|
||||
pub fn age(&mut self, file: &str, secs: u64) {
|
||||
let path = self.dir(file).join(META);
|
||||
if let Some(e) = self.files.get_mut(file) {
|
||||
let m = &mut e.meta;
|
||||
(m.created, m.updated, m.opened) = (m.created.saturating_sub(secs), m.updated.saturating_sub(secs), m.opened.saturating_sub(secs));
|
||||
let _ = write_json(&path, m);
|
||||
}
|
||||
}
|
||||
|
||||
/// Make an open file's log refuse writes (tests: a full or failing disk).
|
||||
#[cfg(test)]
|
||||
pub fn break_log(&mut self, file: &str) {
|
||||
let path = self.dir(file).join(LOG);
|
||||
if let Some(log) = self.files.get_mut(file).and_then(|e| e.log.as_mut())
|
||||
&& let Ok(f) = File::open(path)
|
||||
{
|
||||
log.file = f;
|
||||
}
|
||||
}
|
||||
|
||||
/// Note that a file was opened now (it is used: it doesn't expire).
|
||||
fn touch(&mut self, file: &str) {
|
||||
let path = self.dir(file).join(META);
|
||||
if let Some(e) = self.files.get_mut(file) {
|
||||
e.meta.opened = now();
|
||||
if let Err(err) = write_json(&path, &e.meta) {
|
||||
log::warn!("file {file}: couldn't update {META}: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the server says about its hosting (with `file`'s expiry).
|
||||
fn host_info(&self, file: Option<&str>) -> HostInfo {
|
||||
let h = &self.cfg.hosting;
|
||||
let expires = file.and_then(|f| self.files.get(f)).and_then(|e| self.expires(&e.meta));
|
||||
HostInfo { open: h.open, operator: h.operator.clone(), terms: h.terms.clone(), idle_secs: h.idle_secs, max_file_mb: h.max_file_mb, expires }
|
||||
}
|
||||
|
||||
/// Block or unblock an address (an IPv6 address: its /64); the address as kept.
|
||||
pub fn block(&mut self, ip: &str, on: bool) -> Result<IpAddr, String> {
|
||||
let ip = ip_key(ip.trim().parse::<IpAddr>().map_err(|_| format!("`{}` isn't an IP address", ip.trim()))?);
|
||||
if on && !self.blocked.contains(&ip) && self.blocked.len() >= MAX_BLOCKED {
|
||||
return Err(format!("at most {MAX_BLOCKED} addresses can be blocked"));
|
||||
}
|
||||
let changed = if on { self.blocked.insert(ip) } else { self.blocked.remove(&ip) };
|
||||
if changed {
|
||||
let list: Vec<String> = self.blocked.iter().map(IpAddr::to_string).collect();
|
||||
write_json(&self.root.join(BLOCKED), &list).map_err(|e| format!("couldn't keep the blocked addresses: {e}"))?;
|
||||
log::info!("event={} ip={ip}", if on { "blocked" } else { "unblocked" });
|
||||
}
|
||||
Ok(ip)
|
||||
}
|
||||
|
||||
/// The blocked addresses.
|
||||
pub fn blocked(&self) -> Vec<String> {
|
||||
self.blocked.iter().map(IpAddr::to_string).collect()
|
||||
}
|
||||
|
||||
/// The reports kept, oldest first.
|
||||
pub fn reports(&self) -> &[ReportInfo] {
|
||||
&self.reports
|
||||
}
|
||||
|
||||
/// Keep a report about `file` (from someone who joined it with `link`, at `ip`).
|
||||
pub fn keep_report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> {
|
||||
let reason: String = reason.trim().chars().map(|c| if c.is_control() { ' ' } else { c }).take(MAX_REASON).collect();
|
||||
if reason.is_empty() {
|
||||
return Err("say what is wrong with the file".into());
|
||||
}
|
||||
let name = self.entry(file)?.meta.name.clone();
|
||||
if !self.abuse.allow(ip, Action::Report, Instant::now()) {
|
||||
return Err("too many reports from your address lately: try again in an hour".into());
|
||||
}
|
||||
self.reports.push(ReportInfo {
|
||||
at: now(),
|
||||
file: file.to_string(),
|
||||
name,
|
||||
link: link.unwrap_or_default().to_string(),
|
||||
ip: ip.to_string(),
|
||||
reason,
|
||||
});
|
||||
if self.reports.len() > MAX_REPORTS {
|
||||
let extra = self.reports.len() - MAX_REPORTS;
|
||||
self.reports.drain(..extra);
|
||||
}
|
||||
write_json(&self.root.join(REPORTS), &self.reports).map_err(|e| format!("couldn't keep the report: {e}"))?;
|
||||
log::info!("event=report file={file} ip={ip}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The files kept.
|
||||
|
|
@ -420,13 +782,19 @@ impl Store {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// A file's team library, unless its version is `have`.
|
||||
fn fetch_library(&mut self, file: &str, have: Option<u64>) -> Result<LibraryReply, String> {
|
||||
/// A file's team library for `ip`, unless its version is `have`; within what one address and
|
||||
/// everyone may download (the size is known before anything is read).
|
||||
fn fetch_library(&mut self, ip: IpAddr, file: &str, have: Option<u64>) -> Result<LibraryReply, String> {
|
||||
let (path, max) = (self.dir(file).join(LIBRARY), self.cfg.max_file_bytes);
|
||||
let Some(lib) = self.entry(file)?.meta.library.clone() else { return Ok(LibraryReply::Empty) };
|
||||
if have == Some(lib.version) {
|
||||
return Ok(LibraryReply::Unchanged { version: lib.version });
|
||||
}
|
||||
let size = fs::metadata(&path).map_or(0, |m| m.len());
|
||||
let now = Instant::now();
|
||||
if !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), now) || !self.served.take_n(now, size as f64) {
|
||||
return Err(BUSY_DOWNLOADS.into());
|
||||
}
|
||||
Ok(LibraryReply::Library { version: lib.version, archive: disk::read_capped(&path, max)? })
|
||||
}
|
||||
|
||||
|
|
@ -436,10 +804,15 @@ impl Store {
|
|||
// move it to a worker if large libraries make that noticeable.
|
||||
pub fn publish_library(&mut self, file: &str, archive: &[u8]) -> Result<u64, String> {
|
||||
let max = self.cfg.max_file_bytes;
|
||||
if self.cfg.hosting.open && self.entry(file)?.meta.owner.is_some() {
|
||||
return Err(NO_PUBLIC_LIBRARY.into());
|
||||
}
|
||||
if archive.len() as u64 > max {
|
||||
return Err(format!("the library is {} MiB; this server keeps files up to {} MiB", archive.len() >> 20, max >> 20));
|
||||
}
|
||||
let lib = match figcraft_format::read_library(Cursor::new(archive)) {
|
||||
// Everything it unpacks to counts against the same cap, so a small archive can't expand
|
||||
// into a large library in memory.
|
||||
let lib = match figcraft_format::read_library_within(Cursor::new(archive), max) {
|
||||
Ok(Some(lib)) => lib,
|
||||
Ok(None) => return Err("that isn't a published library".into()),
|
||||
Err(e) => return Err(format!("the library can't be read: {e}")),
|
||||
|
|
@ -470,44 +843,114 @@ impl Store {
|
|||
Ok(m.version)
|
||||
}
|
||||
|
||||
/// The usage reports kept for a file's library (none when it has no library).
|
||||
pub fn library_usage(&mut self, file: &str) -> Result<Vec<FileUsage>, String> {
|
||||
let path = self.dir(file).join(USAGE);
|
||||
if self.entry(file)?.meta.library.is_none() || !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
/// A library's usage reports in memory (read from disk the first time; a damaged list starts
|
||||
/// over: it is only statistics). `None` when the file has no library.
|
||||
fn usage_of(&mut self, file: &str) -> Result<Option<&mut UsageCache>, String> {
|
||||
if self.entry(file)?.meta.library.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
serde_json::from_slice(&disk::read_capped(&path, MAX_USAGE_FILE)?).map_err(|e| format!("{USAGE} is damaged ({e})"))
|
||||
if !self.usage.contains_key(file) {
|
||||
let path = self.dir(file).join(USAGE);
|
||||
let list = if path.exists() {
|
||||
disk::read_capped(&path, MAX_USAGE_FILE).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
self.usage.insert(file.to_string(), UsageCache { list, dirty: false, written: now() });
|
||||
}
|
||||
Ok(self.usage.get_mut(file))
|
||||
}
|
||||
|
||||
/// Keep how one file uses a file's library (written only when it changed).
|
||||
/// The usage reports kept for a file's library (none when it has no library).
|
||||
pub fn library_usage(&mut self, file: &str) -> Result<Vec<FileUsage>, String> {
|
||||
Ok(self.usage_of(file)?.map(|u| u.list.clone()).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Keep how one file uses a file's library (in memory; written when it changed, at most every
|
||||
/// [`USAGE_WRITE_EVERY`] seconds).
|
||||
pub fn report_usage(&mut self, file: &str, usage: Usage) -> Result<(), String> {
|
||||
let name = clean_name(&usage.file);
|
||||
if name.is_empty() || self.entry(file)?.meta.library.is_none() {
|
||||
if name.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut components: Vec<_> = usage.components.into_iter().filter(|(_, n)| *n > 0).collect();
|
||||
let mut components: Vec<_> = usage.components.into_iter().filter(|(_, n)| *n > 0).take(MAX_REPORTED * 4).collect();
|
||||
components.sort();
|
||||
components.dedup_by_key(|c| c.0);
|
||||
components.truncate(MAX_REPORTED);
|
||||
// A damaged report list starts over (it is only statistics).
|
||||
let mut all = self.library_usage(file).unwrap_or_default();
|
||||
match all.iter_mut().find(|f| f.usage.file == name) {
|
||||
let Some(u) = self.usage_of(file)? else { return Ok(()) };
|
||||
match u.list.iter_mut().find(|f| f.usage.file == name) {
|
||||
Some(f) if f.usage.components == components => return Ok(()),
|
||||
Some(f) => {
|
||||
f.usage.components = components;
|
||||
f.updated = now();
|
||||
}
|
||||
None => all.push(FileUsage { usage: Usage { file: name, components }, updated: now() }),
|
||||
None => u.list.push(FileUsage { usage: Usage { file: name, components }, updated: now() }),
|
||||
}
|
||||
if all.len() > MAX_USERS {
|
||||
all.sort_by_key(|f| std::cmp::Reverse(f.updated));
|
||||
all.truncate(MAX_USERS);
|
||||
if u.list.len() > MAX_USERS {
|
||||
u.list.sort_by_key(|f| std::cmp::Reverse(f.updated));
|
||||
u.list.truncate(MAX_USERS);
|
||||
}
|
||||
write_json(&self.dir(file).join(USAGE), &all)
|
||||
u.dirty = true;
|
||||
if now() >= u.written.saturating_add(USAGE_WRITE_EVERY) {
|
||||
self.write_usage(false);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write the usage reports that changed (`all`: whenever they were last written).
|
||||
fn write_usage(&mut self, all: bool) {
|
||||
let t = now();
|
||||
let root = self.root.join(FILES);
|
||||
for (file, u) in &mut self.usage {
|
||||
if u.dirty && (all || t >= u.written.saturating_add(USAGE_WRITE_EVERY)) {
|
||||
if let Err(e) = write_json(&root.join(file).join(USAGE), &u.list) {
|
||||
log::warn!("file {file}: library usage couldn't be kept: {e}");
|
||||
}
|
||||
(u.dirty, u.written) = (false, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `file` has room for about `n` more bytes: its cap, and for a file published
|
||||
/// without the key its address's share and everyone's. A log that took it over its cap is
|
||||
/// compacted first (the state may be smaller than its history).
|
||||
fn room_for(&mut self, file: &str, server: &Server, n: u64) -> Result<(), String> {
|
||||
if let Some(why) = self.low_disk() {
|
||||
return Err(why);
|
||||
}
|
||||
let max = self.cfg.max_file_bytes;
|
||||
let over = |s: &Store| s.bytes(file).saturating_add(n) > max;
|
||||
// A compaction to make room at most once a minute per file (it rewrites the whole file).
|
||||
let t = now();
|
||||
let may_compact = self.files.get(file).is_some_and(|e| {
|
||||
t >= e.forced.saturating_add(FORCED_COMPACTION_EVERY) && e.log.as_ref().is_some_and(|l| l.entries > 0 || !l.pending.is_empty())
|
||||
});
|
||||
if over(self) && may_compact {
|
||||
if let Some(e) = self.files.get_mut(file) {
|
||||
e.forced = t;
|
||||
}
|
||||
self.compact(file, server);
|
||||
}
|
||||
if over(self) {
|
||||
return Err(format!("this file has reached the server's limit of {} MiB: that change wasn't kept", max >> 20));
|
||||
}
|
||||
let h = &self.cfg.hosting;
|
||||
let Some(ip) = self.files.get(file).filter(|e| e.meta.owner.is_some()).and_then(|e| e.meta.ip.clone()) else { return Ok(()) };
|
||||
let (max_ip, max_all) = (h.max_bytes_per_ip, h.max_total_bytes);
|
||||
let used_ip: u64 = self.files.iter().filter(|(_, e)| e.meta.ip.as_deref() == Some(ip.as_str())).map(|(id, _)| self.bytes(id)).sum();
|
||||
if used_ip.saturating_add(n) > max_ip {
|
||||
return Err(format!("the files published from this file's address have reached their {} MiB: that change wasn't kept", max_ip >> 20));
|
||||
}
|
||||
let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum();
|
||||
if used_all.saturating_add(n) > max_all {
|
||||
return Err("this server is full: that change wasn't kept".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a file's session: its snapshot, then its log (a damaged end is dropped and reported).
|
||||
fn load(&mut self, file: &str) -> Result<Server, String> {
|
||||
self.touch(file);
|
||||
let dir = self.dir(file);
|
||||
let (generation, state) = disk::decode_snapshot(&disk::read_capped(&dir.join(SNAPSHOT), self.cfg.max_file_bytes)?)?;
|
||||
let mut server = Server::restore(state, "", self.cfg.core).map_err(|e| format!("the snapshot can't be used: {e}"))?;
|
||||
|
|
@ -536,7 +979,7 @@ impl Store {
|
|||
}
|
||||
for i in issues {
|
||||
log::warn!("{i}");
|
||||
self.problems.push(i);
|
||||
self.note(i);
|
||||
}
|
||||
server.journaling(true);
|
||||
let file_handle = OpenOptions::new().append(true).open(dir.join(LOG));
|
||||
|
|
@ -565,6 +1008,7 @@ impl Store {
|
|||
disk::write_atomic(&dir.join(LOG), &disk::log_header(generation)).map_err(|e| format!("couldn't start the log: {e}"))?;
|
||||
let f = OpenOptions::new().append(true).open(dir.join(LOG)).map_err(|e| format!("couldn't open the log: {e}"))?;
|
||||
if let Ok(e) = self.entry(file) {
|
||||
e.bytes = (bytes.len() + LOG_HEADER) as u64;
|
||||
e.meta.seq = server.seq();
|
||||
let meta = e.meta.clone();
|
||||
if let Err(err) = write_json(&dir.join(META), &meta) {
|
||||
|
|
@ -574,8 +1018,39 @@ impl Store {
|
|||
Ok(Log { generation, file: f, pending: Vec::new(), entries: 0, bytes: LOG_HEADER as u64, dirty: false })
|
||||
}
|
||||
|
||||
/// Compactions so far.
|
||||
pub fn compactions(&self) -> u64 {
|
||||
self.compactions
|
||||
}
|
||||
|
||||
/// Why nothing more may be written when the data directory's disk is nearly full (less free
|
||||
/// than [`StoreConfig::min_free_bytes`]); looked up at most once a second. Where the free space
|
||||
/// can't be told, nothing is refused.
|
||||
fn low_disk(&mut self) -> Option<String> {
|
||||
let now = Instant::now();
|
||||
let free = match self.free {
|
||||
Some((at, free)) if now.saturating_duration_since(at) < std::time::Duration::from_secs(1) => free,
|
||||
_ => {
|
||||
let free = disk::free_bytes(&self.root);
|
||||
self.free = Some((now, free));
|
||||
free
|
||||
}
|
||||
};
|
||||
free.filter(|f| *f < self.cfg.min_free_bytes)
|
||||
.map(|_| "the server's disk is nearly full: it takes no changes or new files for now (tell its operator)".to_string())
|
||||
}
|
||||
|
||||
/// Note a problem for the operator (logged too); only the latest [`MAX_PROBLEMS`] are kept.
|
||||
fn note(&mut self, problem: String) {
|
||||
if self.problems.len() >= MAX_PROBLEMS {
|
||||
self.problems.remove(0);
|
||||
}
|
||||
self.problems.push(problem);
|
||||
}
|
||||
|
||||
/// Compact an open file: its state as the next snapshot, an empty log.
|
||||
fn compact(&mut self, file: &str, server: &Server) {
|
||||
self.compactions += 1;
|
||||
let Some(generation) = self.files.get(file).and_then(|e| e.log.as_ref()).map(|l| l.generation) else { return };
|
||||
match self.rewrite(file, generation + 1, server) {
|
||||
Ok(log) => {
|
||||
|
|
@ -589,27 +1064,43 @@ impl Store {
|
|||
}
|
||||
}
|
||||
|
||||
/// Write and sync what's pending in every open log.
|
||||
fn flush_all(&mut self) {
|
||||
/// Write and sync what's pending in every open log; the files whose log couldn't be written.
|
||||
/// Those are closed here (their pending records dropped: nobody was told of them) and open
|
||||
/// again from what is on disk.
|
||||
fn flush_all(&mut self) -> Vec<String> {
|
||||
let mut failed = Vec::new();
|
||||
for (id, e) in &mut self.files {
|
||||
if let Some(log) = e.log.as_mut() {
|
||||
flush_log(id, log);
|
||||
match flush_log(id, log) {
|
||||
Ok(n) => e.bytes += n,
|
||||
Err(()) => {
|
||||
e.log = None;
|
||||
failed.push(id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
failed
|
||||
}
|
||||
}
|
||||
|
||||
/// Write and sync a log's pending records (kept for the next try when that fails).
|
||||
fn flush_log(id: &str, log: &mut Log) {
|
||||
/// Write and sync a log's pending records: the bytes written, or `Err` when that failed (the log
|
||||
/// can't be trusted to hold them).
|
||||
fn flush_log(id: &str, log: &mut Log) -> Result<u64, ()> {
|
||||
if log.pending.is_empty() {
|
||||
return;
|
||||
return Ok(0);
|
||||
}
|
||||
match log.file.write_all(&log.pending).and_then(|()| log.file.sync_data()) {
|
||||
Ok(()) => {
|
||||
log.bytes += log.pending.len() as u64;
|
||||
let n = log.pending.len() as u64;
|
||||
log.bytes += n;
|
||||
log.pending.clear();
|
||||
Ok(n)
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("file {id}: couldn't write its log: {e}");
|
||||
Err(())
|
||||
}
|
||||
Err(e) => log::error!("file {id}: couldn't write its log: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -642,7 +1133,7 @@ impl Backend for Store {
|
|||
self.load(file).map_err(|e| {
|
||||
let why = format!("file {file} can't be opened: {e}");
|
||||
log::error!("{why}");
|
||||
self.problems.push(why);
|
||||
self.note(why);
|
||||
"this file can't be opened on the server (its data is damaged); tell the server's owner".to_string()
|
||||
})
|
||||
}
|
||||
|
|
@ -670,13 +1161,17 @@ impl Backend for Store {
|
|||
}
|
||||
}
|
||||
|
||||
fn flush(&mut self) {
|
||||
self.flush_all();
|
||||
fn flush(&mut self) -> Vec<String> {
|
||||
self.flush_all()
|
||||
}
|
||||
|
||||
fn room(&mut self, file: &str, server: &Server, bytes: usize) -> Result<(), String> {
|
||||
self.room_for(file, server, bytes as u64)
|
||||
}
|
||||
|
||||
fn idle(&mut self, file: &str, server: &mut Server) -> bool {
|
||||
let dirty = self.files.get_mut(file).and_then(|e| e.log.as_mut()).is_some_and(|log| {
|
||||
flush_log(file, log);
|
||||
let _ = flush_log(file, log);
|
||||
log.dirty
|
||||
});
|
||||
if dirty {
|
||||
|
|
@ -685,10 +1180,13 @@ impl Backend for Store {
|
|||
if let Some(e) = self.files.get_mut(file) {
|
||||
e.log = None;
|
||||
}
|
||||
// Everyone just left: idle time counts from now.
|
||||
self.touch(file);
|
||||
true
|
||||
}
|
||||
|
||||
fn stop(&mut self, open: Vec<(&str, &mut Server)>) {
|
||||
self.write_usage(true);
|
||||
self.flush_all();
|
||||
for (file, server) in open {
|
||||
if self.files.get(file).and_then(|e| e.log.as_ref()).is_some_and(|l| l.dirty) {
|
||||
|
|
@ -698,7 +1196,82 @@ impl Backend for Store {
|
|||
}
|
||||
|
||||
fn manage(&mut self, key: &str) -> Result<(), String> {
|
||||
if same_secret(key, &self.key) { Ok(()) } else { Err("wrong server key".into()) }
|
||||
match same_secret(key, &self.key) {
|
||||
true => Ok(()),
|
||||
false if self.cfg.hosting.open => {
|
||||
Err("wrong server key (this server is open to the public: publish without a key, with an up-to-date FigCraft)".into())
|
||||
}
|
||||
false => Err("wrong server key".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn connect(&mut self, ip: IpAddr) -> Result<(), String> {
|
||||
if now() >= self.next_sweep {
|
||||
self.sweep();
|
||||
self.write_usage(false);
|
||||
}
|
||||
if self.blocked.contains(&ip) {
|
||||
return Err("this server doesn't accept connections from your address".into());
|
||||
}
|
||||
if !self.abuse.allow(ip, Action::Connect, Instant::now()) {
|
||||
return Err("too many connections from your address lately: wait a minute and try again".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn message(&mut self, ip: IpAddr, msg: &ClientMsg) -> bool {
|
||||
// Cursors and cursor chat (many people behind one address each send ~30 a second while
|
||||
// they move) have a budget of their own, beside everything else.
|
||||
let what = if matches!(msg, ClientMsg::Presence(_) | ClientMsg::Signal(_) | ClientMsg::Ping) { Action::Presence } else { Action::Message };
|
||||
self.abuse.allow(ip, what, Instant::now())
|
||||
}
|
||||
|
||||
fn hosting(&mut self, file: &str) -> Option<HostInfo> {
|
||||
Some(self.host_info(Some(file)))
|
||||
}
|
||||
|
||||
fn open_hosting(&mut self, _ip: IpAddr, file: &str, owner: &str) -> Result<HostInfo, String> {
|
||||
if file.is_empty() && owner.is_empty() {
|
||||
return Ok(self.host_info(None));
|
||||
}
|
||||
let presented = hash(owner);
|
||||
let owns = self.files.get(file).and_then(|e| e.meta.owner.as_deref()).is_some_and(|h| same_secret(&presented, h));
|
||||
if !owns {
|
||||
return Err("that owner token doesn't manage a file of that id here (it may have been removed or expired)".into());
|
||||
}
|
||||
Ok(self.host_info(Some(file)))
|
||||
}
|
||||
|
||||
fn hosted(&mut self, ip: IpAddr, owns: Option<&str>, req: Admin, open: &dyn Fn(&str) -> Option<OpenFile>) -> Handled {
|
||||
let failed = |reason: &str| Handled::from(AdminReply::Failed { reason: reason.to_string() });
|
||||
let file = match &req {
|
||||
Admin::Publish { name, snapshot } => {
|
||||
return match self.publish_open(ip, name, snapshot) {
|
||||
Ok((file, edit, view, owner)) => {
|
||||
let expires = self.files.get(&file.id).and_then(|e| self.expires(&e.meta));
|
||||
AdminReply::Hosted { file, edit, view, owner, expires }.into()
|
||||
}
|
||||
Err(e) => {
|
||||
log::info!("event=publish_refused ip={ip} reason={e:?}");
|
||||
Handled::from(AdminReply::Failed { reason: e })
|
||||
}
|
||||
};
|
||||
}
|
||||
Admin::Links { file }
|
||||
| Admin::CreateLink { file, .. }
|
||||
| Admin::Revoke { file, .. }
|
||||
| Admin::Rename { file, .. }
|
||||
| Admin::Remove { file } => file,
|
||||
_ => return failed("that needs the server key"),
|
||||
};
|
||||
if owns != Some(file.as_str()) {
|
||||
return failed("an owner token manages only its own file");
|
||||
}
|
||||
self.admin(req, open)
|
||||
}
|
||||
|
||||
fn report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> {
|
||||
self.keep_report(file, link, ip, reason)
|
||||
}
|
||||
|
||||
fn admin(&mut self, req: Admin, open: &dyn Fn(&str) -> Option<OpenFile>) -> Handled {
|
||||
|
|
@ -712,9 +1285,18 @@ impl Backend for Store {
|
|||
Admin::Links { file } => self.links(&file).map_or_else(failed, |l| AdminReply::Links(l).into()),
|
||||
Admin::CreateLink { file, access, label } => self.create_link(&file, access, &label).map_or_else(failed, |l| AdminReply::Link(l).into()),
|
||||
Admin::Revoke { file, link } => match self.revoke(&file, &link) {
|
||||
Ok(()) => Handled { reply: AdminReply::Done, revoked: Some((file, link)), removed: None },
|
||||
Ok(()) => Handled { reply: AdminReply::Done, revoked: Some((file, link)), removed: None, blocked: None },
|
||||
Err(e) => failed(e),
|
||||
},
|
||||
Admin::Rename { file, name } => self.rename(&file, &name).map_or_else(failed, |()| AdminReply::Done.into()),
|
||||
Admin::Listing => AdminReply::Listing(self.listing(open)).into(),
|
||||
Admin::Reports => AdminReply::Reports(self.reports.clone()).into(),
|
||||
Admin::Block { ip } => match self.block(&ip, true) {
|
||||
Ok(ip) => Handled { reply: AdminReply::Done, revoked: None, removed: None, blocked: Some(ip) },
|
||||
Err(e) => failed(e),
|
||||
},
|
||||
Admin::Unblock { ip } => self.block(&ip, false).map_or_else(failed, |_| AdminReply::Done.into()),
|
||||
Admin::Blocked => AdminReply::Blocked(self.blocked()).into(),
|
||||
Admin::Remove { file } => {
|
||||
if !self.files.contains_key(&file) {
|
||||
return failed("this file isn't on the server (it may have been removed)".into());
|
||||
|
|
@ -723,12 +1305,12 @@ impl Backend for Store {
|
|||
Ok(()) => AdminReply::Done,
|
||||
Err(reason) => AdminReply::Failed { reason },
|
||||
};
|
||||
Handled { reply, revoked: None, removed: Some(file) }
|
||||
Handled { reply, revoked: None, removed: Some(file), blocked: None }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn library(&mut self, file: &str, access: Access, req: LibraryReq) -> LibraryReply {
|
||||
fn library(&mut self, ip: IpAddr, file: &str, access: Access, req: LibraryReq) -> LibraryReply {
|
||||
let failed = |reason: String| LibraryReply::Failed { reason };
|
||||
match req {
|
||||
LibraryReq::Fetch { have, usage } => {
|
||||
|
|
@ -737,7 +1319,7 @@ impl Backend for Store {
|
|||
{
|
||||
log::warn!("file {file}: a library usage report couldn't be kept: {e}");
|
||||
}
|
||||
self.fetch_library(file, have).unwrap_or_else(failed)
|
||||
self.fetch_library(ip, file, have).unwrap_or_else(failed)
|
||||
}
|
||||
LibraryReq::Publish { .. } if access != Access::Edit => {
|
||||
failed("you can only view this file: publishing its library needs an edit link".into())
|
||||
|
|
@ -749,7 +1331,14 @@ impl Backend for Store {
|
|||
}
|
||||
}
|
||||
|
||||
fn plugins(&mut self, publisher: bool, req: PluginReq) -> PluginReply {
|
||||
fn plugins(&mut self, ip: IpAddr, publisher: bool, req: PluginReq) -> PluginReply {
|
||||
// One address's share first (before everyone's budget is spent or anything is read).
|
||||
if let PluginReq::Fetch { id, version } = &req
|
||||
&& let Some(size) = self.directory.size_of(id, version.as_deref())
|
||||
&& !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), Instant::now())
|
||||
{
|
||||
return PluginReply::Failed { reason: BUSY_DOWNLOADS.into() };
|
||||
}
|
||||
self.directory.handle(publisher, req)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ fn a_file_survives_a_crash_and_compacts() {
|
|||
let dir = TempDir::new("crash");
|
||||
let c = StoreConfig { compact_after: 1000, ..cfg(dir.path()) };
|
||||
let (id, before, seq) = {
|
||||
let mut store = Store::open(dir.path(), c).unwrap();
|
||||
let mut store = Store::open(dir.path(), c.clone()).unwrap();
|
||||
let (id, _, _) = publish(&mut store, "Crash");
|
||||
let mut server = store.open(&id).unwrap();
|
||||
let mut a = join(&mut server, Access::Edit);
|
||||
|
|
@ -131,7 +131,7 @@ fn a_file_survives_a_crash_and_compacts() {
|
|||
fn a_long_log_is_compacted_while_open() {
|
||||
let dir = TempDir::new("compact");
|
||||
let c = StoreConfig { compact_after: 3, ..cfg(dir.path()) };
|
||||
let mut store = Store::open(dir.path(), c).unwrap();
|
||||
let mut store = Store::open(dir.path(), c.clone()).unwrap();
|
||||
let (id, _, _) = publish(&mut store, "Busy");
|
||||
let mut server = store.open(&id).unwrap();
|
||||
let mut a = join(&mut server, Access::Edit);
|
||||
|
|
|
|||
|
|
@ -123,6 +123,45 @@ impl Raw {
|
|||
self.0.send(Message::Binary(Bytes::from(message::encode(m).unwrap()))).unwrap();
|
||||
}
|
||||
|
||||
/// Write just the header of a masked binary frame declaring `len` payload bytes: the server
|
||||
/// refuses an oversized message from its header, before the client would push the payload
|
||||
/// into a socket the server has already closed (a broken pipe on Linux).
|
||||
pub fn send_header(&mut self, len: u64) {
|
||||
let mut h = vec![0x82u8, 0x80 | 127];
|
||||
h.extend_from_slice(&len.to_be_bytes());
|
||||
h.extend_from_slice(&[1, 2, 3, 4]);
|
||||
self.raw_write(&h);
|
||||
}
|
||||
|
||||
/// Write raw bytes on the socket (a hand-made, partial frame).
|
||||
pub fn raw_write(&mut self, bytes: &[u8]) {
|
||||
use std::io::Write as _;
|
||||
self.0.get_mut().write_all(bytes).unwrap();
|
||||
}
|
||||
|
||||
/// The server closed the connection within `limit` (anything it sends meanwhile is skipped).
|
||||
pub fn closed_within(&mut self, limit: Duration) -> bool {
|
||||
use std::io::Read as _;
|
||||
let s = self.0.get_mut();
|
||||
let end = Instant::now() + limit;
|
||||
let mut buf = [0u8; 4096];
|
||||
let _ = s.set_read_timeout(Some(Duration::from_millis(200)));
|
||||
while Instant::now() < end {
|
||||
match s.read(&mut buf) {
|
||||
Ok(0) => return true,
|
||||
Ok(_) => {}
|
||||
Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {}
|
||||
Err(_) => return true,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Send without waiting to be read (`Err` once the server has hung up).
|
||||
pub fn try_send(&mut self, m: &ClientMsg) -> tungstenite::Result<()> {
|
||||
self.0.send(Message::Binary(Bytes::from(message::encode(m).unwrap())))
|
||||
}
|
||||
|
||||
pub fn next(&mut self) -> ServerMsg {
|
||||
loop {
|
||||
if let Message::Binary(b) = self.0.read().unwrap() {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ use std::thread::JoinHandle;
|
|||
use std::time::{Duration, Instant};
|
||||
|
||||
use figcraft_collab::Replica;
|
||||
use figcraft_collab::message::{self, Admin, AdminReply, ClientMsg, Presence, ServerMsg};
|
||||
use figcraft_collab::message::{self, Admin, AdminReply, ClientMsg, HostInfo, Presence, ServerMsg};
|
||||
use figcraft_doc::Document;
|
||||
use figcraft_engine::live::{Event, Published, Status};
|
||||
use tungstenite::protocol::frame::coding::CloseCode;
|
||||
|
|
@ -34,8 +34,8 @@ use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
|
|||
/// What [`Guest::publish`] uploads, and where.
|
||||
pub struct Publish {
|
||||
pub server: Endpoint,
|
||||
/// The server's key.
|
||||
pub key: String,
|
||||
/// The server's key (`None`: publish as anyone may, on a server open to the public).
|
||||
pub key: Option<String>,
|
||||
/// The file's name on the server.
|
||||
pub name: String,
|
||||
pub doc: Arc<Document>,
|
||||
|
|
@ -75,10 +75,13 @@ impl Guest {
|
|||
let me = tx.clone();
|
||||
let thread = std::thread::Builder::new().name("live-publish".into()).spawn(move || {
|
||||
inbox.push(Event::Status(Status::Connecting));
|
||||
let (cfg, published) = match upload(&job, cfg) {
|
||||
let (cfg, published, hosting) = match upload(&job, cfg) {
|
||||
Ok(done) => done,
|
||||
Err(e) => return inbox.push(Event::Status(Status::Failed { reason: format!("Couldn't publish to the server: {e}") })),
|
||||
};
|
||||
if let Some(h) = hosting {
|
||||
inbox.push(Event::Hosting(h));
|
||||
}
|
||||
inbox.push(Event::Published(published));
|
||||
// Left while uploading: the file stays on the server, nobody joins it. Anything else
|
||||
// that came meanwhile (presence) waits for the session.
|
||||
|
|
@ -138,33 +141,44 @@ struct Conn {
|
|||
sock: TcpStream,
|
||||
}
|
||||
|
||||
/// Keep `doc` on `server` as a new file named `name`: its edit link, and where it is with its links.
|
||||
/// Keep `doc` on `server` as a new file named `name`, with the server's `key` (or, without one, as
|
||||
/// anyone may on a server open to the public): its edit link, where it is with its links (and its
|
||||
/// owner token without a key), and what the server says about its hosting (without a key).
|
||||
pub(crate) fn upload_file(
|
||||
server: &Endpoint,
|
||||
key: &str,
|
||||
key: Option<&str>,
|
||||
name: &str,
|
||||
doc: &Document,
|
||||
limits: &NetLimits,
|
||||
timeout: Duration,
|
||||
) -> Result<(Invite, Published), String> {
|
||||
) -> Result<(Invite, Published, Option<HostInfo>), String> {
|
||||
let snapshot = Replica::new(doc.clone()).snapshot().map_err(|e| e.to_string())?;
|
||||
let replies = manage::request(server, key, vec![Admin::Publish { name: name.to_string(), snapshot }], limits, timeout)?;
|
||||
let (file, edit, view) = match replies.into_iter().next() {
|
||||
Some(AdminReply::Published { file, edit, view }) => (file, edit, view),
|
||||
let publish = vec![Admin::Publish { name: name.to_string(), snapshot }];
|
||||
let (replies, hosting) = match key {
|
||||
Some(key) => (manage::request(server, key, publish, limits, timeout)?, None),
|
||||
None => {
|
||||
let (info, replies) = manage::hosting(server, "", "", publish, limits, timeout)?;
|
||||
(replies, Some(info))
|
||||
}
|
||||
};
|
||||
let (file, edit, view, owner, expires) = match replies.into_iter().next() {
|
||||
Some(AdminReply::Published { file, edit, view }) => (file, edit, view, None, None),
|
||||
Some(AdminReply::Hosted { file, edit, view, owner, expires }) => (file, edit, view, Some(owner), expires),
|
||||
Some(AdminReply::Failed { reason }) => return Err(reason),
|
||||
_ => return Err("the server didn't say where the file is".into()),
|
||||
};
|
||||
let link = |token: String| Invite { server: server.clone(), file: file.id.clone(), token };
|
||||
let (edit, view) = (link(edit.token), link(view.token));
|
||||
let published = Published { server: server.display(), file: file.id.clone(), name: file.name, edit: edit.link(), view: view.link() };
|
||||
Ok((edit, published))
|
||||
let published =
|
||||
Published { server: server.display(), file: file.id.clone(), name: file.name, edit: edit.link(), view: view.link(), owner, expires };
|
||||
Ok((edit, published, hosting))
|
||||
}
|
||||
|
||||
/// Upload the document; the join settings for the new file and its links.
|
||||
fn upload(job: &Publish, mut cfg: GuestConfig) -> Result<(GuestConfig, Published), String> {
|
||||
let (edit, published) = upload_file(&job.server, &job.key, &job.name, &job.doc, &cfg.limits, cfg.connect_timeout)?;
|
||||
/// Upload the document; the join settings for the new file, its links and the server's hosting.
|
||||
fn upload(job: &Publish, mut cfg: GuestConfig) -> Result<(GuestConfig, Published, Option<HostInfo>), String> {
|
||||
let (edit, published, hosting) = upload_file(&job.server, job.key.as_deref(), &job.name, &job.doc, &cfg.limits, cfg.connect_timeout)?;
|
||||
cfg.invite = edit;
|
||||
Ok((cfg, published))
|
||||
Ok((cfg, published, hosting))
|
||||
}
|
||||
|
||||
fn connect(cfg: &GuestConfig) -> Result<Conn, String> {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -209,6 +209,8 @@ pub(crate) fn take(m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mut Se
|
|||
ServerMsg::Reject { .. } => inbox.push(Event::Sync(m)),
|
||||
// Checked again by the engine (the host is as untrusted as any peer).
|
||||
ServerMsg::Signal { client_id, signal } => inbox.push(Event::Signal(client_id, signal)),
|
||||
ServerMsg::Hosting(info) => inbox.push(Event::Hosting(*info)),
|
||||
ServerMsg::Reported { error } => inbox.push(Event::Reported(error)),
|
||||
// Answers for managing and library connections: not this one's.
|
||||
ServerMsg::Pong | ServerMsg::Admin(_) | ServerMsg::Library(_) | ServerMsg::Plugins(_) => {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
//! Transport limits, shared by the native transport and the browser client.
|
||||
|
||||
use std::net::IpAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Transport limits (the collaboration core has its own: [`figcraft_collab::Limits`]).
|
||||
|
|
@ -27,6 +28,31 @@ pub struct NetLimits {
|
|||
pub queue: usize,
|
||||
/// How long a write may block.
|
||||
pub write_timeout: Duration,
|
||||
/// Most connections from one address at once (an IPv6 address counts by its /64; 0: no cap).
|
||||
pub max_per_address: usize,
|
||||
/// The reverse proxy in front of a host, whose `X-Forwarded-For` names the client: only
|
||||
/// connections from this address are believed (the header is anyone's to forge).
|
||||
pub trusted_proxy: Option<IpAddr>,
|
||||
/// Bytes queued for one client before it counts as stuck (one message always fits an empty
|
||||
/// queue), for one block of addresses (an IPv4 address, an IPv6 /48), and for every client
|
||||
/// together (a broadcast counted once; past it the biggest consumer is dropped).
|
||||
pub max_queued_bytes: usize,
|
||||
pub max_queued_block: usize,
|
||||
pub max_queued_total: usize,
|
||||
/// Bytes being read into messages (not whole yet) for one block, and for every connection:
|
||||
/// past them the biggest readers are dropped.
|
||||
pub max_reading_block: usize,
|
||||
pub max_reading_total: usize,
|
||||
/// Bytes of messages handed to the hub and not handled yet, all connections together: past it
|
||||
/// a connection is dropped as busy instead of piling more on.
|
||||
pub max_in_flight: usize,
|
||||
/// Largest message on a connection that only asks small things (browsing a plugin directory).
|
||||
pub max_request: usize,
|
||||
/// Connections only sessions (`Hello`) and the operator may take: one-shot requests leave them.
|
||||
pub reserve_for_sessions: usize,
|
||||
/// A one-shot request connection (hosting, a library, a plugin directory) with no traffic for
|
||||
/// this long is dropped.
|
||||
pub request_idle: Duration,
|
||||
}
|
||||
|
||||
impl Default for NetLimits {
|
||||
|
|
@ -43,6 +69,17 @@ impl Default for NetLimits {
|
|||
keepalive: Duration::from_secs(15),
|
||||
queue: 1024,
|
||||
write_timeout: Duration::from_secs(10),
|
||||
max_per_address: 0,
|
||||
trusted_proxy: None,
|
||||
max_queued_bytes: 160 << 20,
|
||||
max_queued_block: usize::MAX,
|
||||
max_queued_total: usize::MAX,
|
||||
max_reading_block: usize::MAX,
|
||||
max_reading_total: usize::MAX,
|
||||
max_in_flight: usize::MAX,
|
||||
max_request: 64 << 10,
|
||||
reserve_for_sessions: 0,
|
||||
request_idle: Duration::from_secs(45),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ use std::sync::Arc;
|
|||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use figcraft_collab::message::{Access, ClientMsg, LibraryReply, LibraryReq, PluginReply, PluginReq};
|
||||
use figcraft_collab::message::{Access, Admin, AdminReply, ClientMsg, LibraryReply, LibraryReq, PluginReply, PluginReq};
|
||||
use figcraft_engine::live::{
|
||||
Bind, Event, HostOptions, LiveLink, NetReply, NetRequest, PageInfo, Presence, PublishOptions, Published, Role, SessionInfo,
|
||||
Bind, Event, HostInfo, HostOptions, LiveLink, NetReply, NetRequest, PageInfo, Presence, PublishOptions, Published, Role, SessionInfo,
|
||||
};
|
||||
|
||||
use crate::guest::{Guest, GuestConfig, Publish, upload_file};
|
||||
|
|
@ -151,8 +151,8 @@ impl LiveLink for NetLink {
|
|||
|
||||
fn publish(&mut self, o: PublishOptions) -> Result<(), String> {
|
||||
let (server, query) = Endpoint::parse(&o.server)?;
|
||||
// Without a key: as anyone may, when the server is open to the public (it says if not).
|
||||
let key = o.key.or_else(|| query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v)).filter(|k| !k.trim().is_empty());
|
||||
let key = key.ok_or("the server address needs the server's key (ws://host:port/?key=…; `figcraft-server key` prints it)")?;
|
||||
// Until the upload is done the invite names only the server.
|
||||
let cfg = self.guest_config(Invite { server: server.clone(), file: String::new(), token: String::new() }, &o.me);
|
||||
self.leave();
|
||||
|
|
@ -176,6 +176,10 @@ impl LiveLink for NetLink {
|
|||
plugins(server, req, &self.limits)
|
||||
}
|
||||
|
||||
fn hosting(&mut self, server: &str, file: &str, owner: &str, req: Option<Admin>) -> Result<(HostInfo, Option<AdminReply>), String> {
|
||||
hosting(server, file, owner, req, &self.limits)
|
||||
}
|
||||
|
||||
fn background(&mut self, job: u64, req: NetRequest) -> bool {
|
||||
let (inbox, limits) = (self.libraries.clone(), self.limits);
|
||||
let (failed, failure) = (inbox.clone(), req.failure());
|
||||
|
|
@ -184,6 +188,7 @@ impl LiveLink for NetLink {
|
|||
NetRequest::Plugins { server, req } => NetReply::Plugins(plugins(&server, req, &limits)),
|
||||
NetRequest::Library { link, req } => NetReply::Library(manage::library(&link, req, &limits, CONNECT)),
|
||||
NetRequest::Upload(o) => NetReply::Upload(upload(o, &limits)),
|
||||
NetRequest::Hosting { server, file, owner, req } => NetReply::Hosting(hosting(&server, &file, &owner, req, &limits)),
|
||||
};
|
||||
inbox.push(Event::Net { job, reply: Box::new(reply) });
|
||||
});
|
||||
|
|
@ -248,7 +253,22 @@ fn upload(o: PublishOptions, limits: &NetLimits) -> Result<Published, String> {
|
|||
let (server, query) = Endpoint::parse(&o.server)?;
|
||||
let key = o.key.or_else(|| query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v)).filter(|k| !k.trim().is_empty());
|
||||
let key = key.ok_or("the server address needs the server's key (ws://host:port/?key=…; `figcraft-server key` prints it)")?;
|
||||
upload_file(&server, &key, &o.name, &o.doc, limits, CONNECT).map(|(_, published)| published)
|
||||
upload_file(&server, Some(&key), &o.name, &o.doc, limits, CONNECT).map(|(_, published, _)| published)
|
||||
}
|
||||
|
||||
/// What the server at `server` says about its hosting, and its answer to `req` about `file` with
|
||||
/// its owner token (see [`LiveLink::hosting`]). A new link comes back whole, in place of its token.
|
||||
fn hosting(server: &str, file: &str, owner: &str, req: Option<Admin>, limits: &NetLimits) -> Result<(HostInfo, Option<AdminReply>), String> {
|
||||
let (endpoint, _) = Endpoint::parse(server)?;
|
||||
let (info, replies) = manage::hosting(&endpoint, file, owner, req.into_iter().collect(), limits, CONNECT)?;
|
||||
let reply = replies.into_iter().next().map(|r| match r {
|
||||
AdminReply::Link(mut l) => {
|
||||
l.token = Invite { server: endpoint.clone(), file: l.link.file.clone(), token: l.token }.link();
|
||||
AdminReply::Link(l)
|
||||
}
|
||||
other => other,
|
||||
});
|
||||
Ok((info, reply))
|
||||
}
|
||||
|
||||
/// One request to the plugin directory at `server` (its key in the address's query, if any).
|
||||
|
|
|
|||
|
|
@ -384,7 +384,7 @@ fn oversized_messages_end_the_connection() {
|
|||
let mut raw = Raw::hello(addr, TOKEN);
|
||||
let Some(ServerMsg::Welcome { client_id, .. }) = raw.next() else { panic!("no welcome") };
|
||||
raw.send(&ClientMsg::Presence(Presence { selection: Some((0..5000).map(NodeId).collect()), ..Presence::default() }));
|
||||
assert_eq!(raw.refusal().as_deref(), Some("message too large"));
|
||||
assert!(raw.refusal().is_some_and(|r| r.starts_with("message too large")));
|
||||
hp.wait("it left", |e| e.iter().any(|e| matches!(e, Event::PeerLeft(id) if *id == client_id)));
|
||||
// The host still serves others.
|
||||
let (_a, mut ap) = join(&invite, "Ada");
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use figcraft_collab::message::{
|
||||
self, Admin, AdminReply, ClientMsg, LibraryReply, LibraryReq, Manage, OpenLibrary, OpenPlugins, PROTOCOL, PluginReply, PluginReq, ServerMsg,
|
||||
self, Admin, AdminReply, ClientMsg, HostInfo, LibraryReply, LibraryReq, Manage, OpenHosting, OpenLibrary, OpenPlugins, PROTOCOL, PluginReply,
|
||||
PluginReq, ServerMsg,
|
||||
};
|
||||
use tungstenite::{Bytes, Message, WebSocket};
|
||||
|
||||
|
|
@ -40,6 +41,45 @@ pub fn request(server: &Endpoint, key: &str, requests: Vec<Admin>, limits: &NetL
|
|||
Ok(replies)
|
||||
}
|
||||
|
||||
/// Ask the server at `server` about its hosting, with a file's owner token when `file` and `owner`
|
||||
/// aren't empty; what it says, and its replies to `requests` (publishing without the key, managing
|
||||
/// that file), in order. Fails when the server can't be reached or refuses (a wrong owner token, a
|
||||
/// blocked address); a server older than open hosting can't read the request, and is reported as
|
||||
/// taking files only with its key. Never echoes the token.
|
||||
pub fn hosting(
|
||||
server: &Endpoint,
|
||||
file: &str,
|
||||
owner: &str,
|
||||
requests: Vec<Admin>,
|
||||
limits: &NetLimits,
|
||||
timeout: Duration,
|
||||
) -> Result<(HostInfo, Vec<AdminReply>), String> {
|
||||
let admin = |m: ServerMsg| match m {
|
||||
ServerMsg::Admin(r) => Some(*r),
|
||||
_ => None,
|
||||
};
|
||||
let open = OpenHosting { protocol: PROTOCOL, client_version: message::client_version(), file: file.to_string(), owner: owner.to_string() };
|
||||
let mut ws = connect(server, limits, timeout)?;
|
||||
send(&mut ws, &ClientMsg::OpenHosting(open))?;
|
||||
let info = match reply(&mut ws, limits, |m| match m {
|
||||
ServerMsg::Hosting(i) => Some(*i),
|
||||
_ => None,
|
||||
}) {
|
||||
Ok(i) => i,
|
||||
Err(e) if e.contains("couldn't be read") => {
|
||||
return Err(format!("{} takes files only with its key (it is older than this app): add ?key=… to the address", server.display()));
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let mut replies = Vec::with_capacity(requests.len());
|
||||
for r in requests {
|
||||
send(&mut ws, &ClientMsg::Admin(r))?;
|
||||
replies.push(reply(&mut ws, limits, admin)?);
|
||||
}
|
||||
close(ws);
|
||||
Ok((info, replies))
|
||||
}
|
||||
|
||||
/// Send one library request about the file behind share link `link`; the reply. Fails when the
|
||||
/// link isn't one, the server can't be reached or refuses the link (a request it can't do is a
|
||||
/// [`LibraryReply::Failed`] reply). Never echoes the link.
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@
|
|||
//! machines over the same socket; every write goes through one lock and writes whole frames, so
|
||||
//! frames from the two never interleave.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{self, Read, Write};
|
||||
use std::net::{Shutdown, TcpStream};
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
|
|
@ -29,16 +31,150 @@ pub struct Duplex {
|
|||
read: TcpStream,
|
||||
write: Arc<Mutex<TcpStream>>,
|
||||
tls: Option<Tls>,
|
||||
/// What the bytes read count against (a server's reader), if anything.
|
||||
meter: Option<Meter>,
|
||||
}
|
||||
|
||||
impl Duplex {
|
||||
/// Two handles on `stream` (for the reader and the writer) and one to shut it down with.
|
||||
pub fn split(stream: TcpStream, tls: Option<Tls>) -> io::Result<(Duplex, Duplex, TcpStream)> {
|
||||
let write = Arc::new(Mutex::new(stream.try_clone()?));
|
||||
let a = Duplex { read: stream.try_clone()?, write: write.clone(), tls: tls.clone() };
|
||||
let b = Duplex { read: stream.try_clone()?, write, tls };
|
||||
let a = Duplex { read: stream.try_clone()?, write: write.clone(), tls: tls.clone(), meter: None };
|
||||
let b = Duplex { read: stream.try_clone()?, write, tls, meter: None };
|
||||
Ok((a, b, stream))
|
||||
}
|
||||
|
||||
/// Count what this handle reads against `meter`.
|
||||
pub fn meter(&mut self, meter: Meter) {
|
||||
self.meter = Some(meter);
|
||||
}
|
||||
|
||||
/// What was read so far became a message (handed on): it no longer counts as being read.
|
||||
pub fn read_done(&mut self) {
|
||||
if let Some(m) = self.meter.as_mut() {
|
||||
m.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every reader that counts what it reads, with what it holds half-read and its socket: when
|
||||
/// everyone's reading budget runs out, the reader holding the most is cut off.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Readers(Arc<Mutex<HashMap<u64, Reader>>>);
|
||||
|
||||
/// One registered reader: what it holds half-read, its block's count, whether it was cut off, and
|
||||
/// its socket.
|
||||
struct Reader {
|
||||
mine: Arc<AtomicUsize>,
|
||||
block: Arc<AtomicUsize>,
|
||||
shed: Arc<AtomicBool>,
|
||||
sock: TcpStream,
|
||||
}
|
||||
|
||||
impl Readers {
|
||||
/// Cut off the reader holding the most half-read bytes, unless that is `me` (then `false`: the
|
||||
/// caller is the biggest). Its bytes are given back to the budget at once, it can't read any
|
||||
/// more, and its socket is shut (where that wakes a blocked read, it ends now; elsewhere at its
|
||||
/// read timeout).
|
||||
fn shed_biggest(&self, me: u64, all: &AtomicUsize) -> bool {
|
||||
let t = self.0.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let Some((&id, r)) = t.iter().filter(|(_, r)| !r.shed.load(Ordering::Relaxed)).max_by_key(|(_, r)| r.mine.load(Ordering::Relaxed)) else {
|
||||
return false;
|
||||
};
|
||||
if id == me || r.mine.load(Ordering::Relaxed) == 0 {
|
||||
return false;
|
||||
}
|
||||
log::info!("event=shed conn={id} reason=reading_bytes");
|
||||
r.shed.store(true, Ordering::Relaxed);
|
||||
let held = r.mine.swap(0, Ordering::Relaxed);
|
||||
r.block.fetch_sub(held, Ordering::Relaxed);
|
||||
all.fetch_sub(held, Ordering::Relaxed);
|
||||
let _ = r.sock.shutdown(Shutdown::Both);
|
||||
true
|
||||
}
|
||||
|
||||
/// Readers registered.
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.lock().unwrap_or_else(PoisonError::into_inner).len()
|
||||
}
|
||||
|
||||
/// None registered.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes being read into messages (not whole yet), per connection, per block of addresses and
|
||||
/// for every connection together. A read that takes its block over its share fails. One that takes
|
||||
/// everyone over the budget cuts off the readers holding the most until it fits (their bytes are
|
||||
/// given back at once, they can't read any more and their sockets are shut) or, when this reader is
|
||||
/// the biggest, fails: the biggest readers go, not the next one.
|
||||
pub struct Meter {
|
||||
id: u64,
|
||||
mine: Arc<AtomicUsize>,
|
||||
shed: Arc<AtomicBool>,
|
||||
block: Arc<AtomicUsize>,
|
||||
all: Arc<AtomicUsize>,
|
||||
max_block: usize,
|
||||
max_all: usize,
|
||||
readers: Readers,
|
||||
}
|
||||
|
||||
/// What a reader cut off for using too much of the budget is told.
|
||||
const BUSY_READING: &str = "the server is busy reading from others: try again in a moment";
|
||||
|
||||
impl Meter {
|
||||
/// Connection `id`'s meter (registered with `readers`, cut off through `sock`).
|
||||
pub fn new(
|
||||
id: u64,
|
||||
sock: &TcpStream,
|
||||
readers: &Readers,
|
||||
block: Arc<AtomicUsize>,
|
||||
all: Arc<AtomicUsize>,
|
||||
max_block: usize,
|
||||
max_all: usize,
|
||||
) -> Self {
|
||||
let (mine, shed) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicBool::new(false)));
|
||||
if let Ok(s) = sock.try_clone() {
|
||||
let r = Reader { mine: mine.clone(), block: block.clone(), shed: shed.clone(), sock: s };
|
||||
readers.0.lock().unwrap_or_else(PoisonError::into_inner).insert(id, r);
|
||||
}
|
||||
Self { id, mine, shed, block, all, max_block, max_all, readers: readers.clone() }
|
||||
}
|
||||
|
||||
fn add(&mut self, n: usize) -> io::Result<()> {
|
||||
if self.shed.load(Ordering::Relaxed) {
|
||||
return Err(io::Error::other(BUSY_READING));
|
||||
}
|
||||
self.mine.fetch_add(n, Ordering::Relaxed);
|
||||
let block = self.block.fetch_add(n, Ordering::Relaxed).saturating_add(n);
|
||||
let mut all = self.all.fetch_add(n, Ordering::Relaxed).saturating_add(n);
|
||||
if block > self.max_block {
|
||||
return Err(io::Error::other(BUSY_READING));
|
||||
}
|
||||
// Over everyone's budget: the biggest readers are cut off until it fits, or this one goes
|
||||
// when it is the biggest.
|
||||
while all > self.max_all {
|
||||
if !self.readers.shed_biggest(self.id, &self.all) {
|
||||
return Err(io::Error::other(BUSY_READING));
|
||||
}
|
||||
all = self.all.load(Ordering::Relaxed);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn release(&mut self) {
|
||||
let mine = self.mine.swap(0, Ordering::Relaxed);
|
||||
self.block.fetch_sub(mine, Ordering::Relaxed);
|
||||
self.all.fetch_sub(mine, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Meter {
|
||||
fn drop(&mut self) {
|
||||
self.release();
|
||||
self.readers.0.lock().unwrap_or_else(PoisonError::into_inner).remove(&self.id);
|
||||
}
|
||||
}
|
||||
|
||||
impl Duplex {
|
||||
|
|
@ -51,10 +187,14 @@ impl Duplex {
|
|||
|
||||
impl Read for Duplex {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
match &self.tls {
|
||||
let n = match &self.tls {
|
||||
Some(t) => tls::read(t, &mut self.read, &self.write, buf),
|
||||
None => self.read.read(buf),
|
||||
}?;
|
||||
if let Some(m) = self.meter.as_mut() {
|
||||
m.add(n)?;
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -134,4 +274,9 @@ impl Bucket {
|
|||
self.tokens -= n;
|
||||
true
|
||||
}
|
||||
|
||||
/// Give back `n` tokens (up to the burst).
|
||||
pub fn give(&mut self, n: f64) {
|
||||
self.tokens = (self.tokens + n).min(self.burst);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
//! The hub's send queues (no writers: nothing is ever sent, so what is queued stays queued): a
|
||||
//! broadcast counts once against the server's budget however many copies are queued, a block of
|
||||
//! addresses can't take more than its share, and when the server's budget is used up the
|
||||
//! connection holding the most is shed, not the next client that needs a little.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::{IpAddr, TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, sync_channel};
|
||||
|
||||
use tungstenite::Bytes;
|
||||
|
||||
use super::{BlockUse, Blocks, Charges, Conn, Kind, Line, Out, Push, deliver};
|
||||
|
||||
/// A line with its own queue counter, the given block counter and the server's.
|
||||
fn line(block: &Arc<AtomicUsize>, all: &Arc<AtomicUsize>, max_block: usize, max_all: usize) -> (Line, Receiver<Out>) {
|
||||
let (tx, rx) = sync_channel(64);
|
||||
(Line { tx, queued: Arc::default(), block: block.clone(), all: all.clone(), max: 1 << 20, max_block, max_all }, rx)
|
||||
}
|
||||
|
||||
fn socket() -> TcpStream {
|
||||
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
TcpStream::connect(l.local_addr().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broadcast_counts_once_against_the_server() {
|
||||
let all = Arc::new(AtomicUsize::new(0));
|
||||
let lines: Vec<_> = (0..4).map(|_| line(&Arc::default(), &all, usize::MAX, 10_000)).collect();
|
||||
let b = Bytes::from(vec![0u8; 3_000]);
|
||||
let mut held = Charges::default();
|
||||
for (l, _) in &lines {
|
||||
l.push(&b, &mut held, false).unwrap();
|
||||
}
|
||||
assert_eq!(all.load(Ordering::Relaxed), 3_000, "four copies of one message, counted once");
|
||||
assert!(lines.iter().all(|(l, _)| l.queued.load(Ordering::Relaxed) == 3_000), "each queue counts its copy");
|
||||
drop(held);
|
||||
// Sent (or dropped): the counts go back as the copies go.
|
||||
for (_, rx) in &lines {
|
||||
drop(rx.try_recv().unwrap());
|
||||
}
|
||||
assert_eq!(all.load(Ordering::Relaxed), 0);
|
||||
assert!(lines.iter().all(|(l, _)| l.queued.load(Ordering::Relaxed) == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_block_gets_only_its_share() {
|
||||
let (block, all) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)));
|
||||
let (a, _ra) = line(&block, &all, 1_000, usize::MAX);
|
||||
let (b, _rb) = line(&block, &all, 1_000, usize::MAX);
|
||||
let (c, _rc) = line(&Arc::default(), &all, 1_000, usize::MAX);
|
||||
let msg = Bytes::from(vec![0u8; 800]);
|
||||
a.push(&msg, &mut Charges::default(), false).unwrap();
|
||||
assert_eq!(b.push(&msg, &mut Charges::default(), false), Err(Push::Full), "the block's second connection is over its share");
|
||||
c.push(&msg, &mut Charges::default(), false).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_biggest_consumer_is_shed_when_the_budget_runs_out() {
|
||||
let all = Arc::new(AtomicUsize::new(0));
|
||||
let ip: IpAddr = "203.0.113.1".parse().unwrap();
|
||||
let mut conns = HashMap::new();
|
||||
let mut receivers = Vec::new();
|
||||
for id in 1..=3u64 {
|
||||
let (l, rx) = line(&Arc::default(), &all, usize::MAX, 10_000);
|
||||
receivers.push(rx);
|
||||
conns.insert(id, Conn { out: l, sock: socket(), kind: Kind::Managing, ip });
|
||||
}
|
||||
// Connection 1 doesn't read: it holds most of the budget.
|
||||
let big = Bytes::from(vec![0u8; 9_000]);
|
||||
assert!(deliver(&conns, 1, &big, &mut Charges::default(), &mut Vec::new()));
|
||||
// Connection 2 needs a little: the hog is shed and 2 still gets its message.
|
||||
let small = Bytes::from(vec![0u8; 2_000]);
|
||||
let mut shed = Vec::new();
|
||||
assert!(deliver(&conns, 2, &small, &mut Charges::default(), &mut shed));
|
||||
assert_eq!(shed, vec![1]);
|
||||
assert_eq!(conns[&2].out.queued.load(Ordering::Relaxed), 2_000);
|
||||
// When the receiver is itself the biggest, it is the one to go.
|
||||
let mut shed = Vec::new();
|
||||
assert!(!deliver(&conns, 1, &small, &mut Charges::default(), &mut shed), "the hog asking for more is dropped");
|
||||
assert!(shed.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broadcast_counts_once_per_block_and_welcomes_not_at_all() {
|
||||
let (office, all) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)));
|
||||
// Twenty people behind one office's address, a block share of 3 MB.
|
||||
let lines: Vec<_> = (0..20).map(|_| line(&office, &all, 3_000_000, usize::MAX)).collect();
|
||||
// A 2 MB paste to all of them: one copy against the block, so everyone gets it.
|
||||
let paste = Bytes::from(vec![0u8; 2_000_000]);
|
||||
let mut charges = Charges::default();
|
||||
for (l, _) in &lines {
|
||||
l.push(&paste, &mut charges, false).unwrap();
|
||||
}
|
||||
assert_eq!(office.load(Ordering::Relaxed), 2_000_000);
|
||||
drop(charges);
|
||||
for (_, rx) in &lines {
|
||||
drop(rx.try_recv().unwrap());
|
||||
}
|
||||
assert_eq!(office.load(Ordering::Relaxed), 0);
|
||||
// Twenty welcomes of a large file at once (each its own message): not counted against the block.
|
||||
let welcome = Bytes::from(vec![0u8; 1_000_000]);
|
||||
for (l, _) in &lines {
|
||||
l.push(&welcome, &mut Charges { skip_blocks: true, ..Charges::default() }, false).unwrap();
|
||||
}
|
||||
assert_eq!(office.load(Ordering::Relaxed), 0);
|
||||
assert_eq!(all.load(Ordering::Relaxed), 20_000_000, "they still count against the server's budget");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_nobody_uses_are_forgotten() {
|
||||
let blocks = Blocks::default();
|
||||
let a: IpAddr = "203.0.113.1".parse().unwrap();
|
||||
let held: BlockUse = blocks.get(a);
|
||||
let _b = blocks.get("203.0.113.2".parse().unwrap());
|
||||
assert_eq!(blocks.len(), 2);
|
||||
drop(held);
|
||||
// The next connection prunes the block whose connections are all gone.
|
||||
let _c = blocks.get("203.0.113.3".parse().unwrap());
|
||||
assert_eq!(blocks.len(), 2, "203.0.113.1 went");
|
||||
}
|
||||
|
|
@ -449,8 +449,9 @@ impl Client {
|
|||
self.presence.remove(&client_id);
|
||||
Ok(Vec::new())
|
||||
}
|
||||
// Cursor chat and spotlights are the app's (the transport hands them over).
|
||||
ServerMsg::Pong | ServerMsg::Signal { .. } => Ok(Vec::new()),
|
||||
// Cursor chat, spotlights and what a server says about its hosting are the app's (the
|
||||
// transport hands them over).
|
||||
ServerMsg::Pong | ServerMsg::Signal { .. } | ServerMsg::Hosting(_) | ServerMsg::Reported { .. } => Ok(Vec::new()),
|
||||
ServerMsg::Welcome { .. } if self.online => Err(Error::Protocol("already joined".into())),
|
||||
ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers, access } => {
|
||||
if session != self.session || client_id != self.id {
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ use figcraft_doc::{Document, NodeId, NodeKind};
|
|||
|
||||
use crate::change::{Change, Json, Prop};
|
||||
use crate::message::{
|
||||
Accepted, Access, Admin, AdminReply, CatchUp, ClientMsg, EditingText, FileInfo, FileUsage, Hello, LibraryReply, LibraryReq, LinkInfo, Manage,
|
||||
NewLink, OpenLibrary, OpenPlugins, PROTOCOL, Peer, PluginEntry, PluginReply, PluginReq, Presence, Processed, Resume, ServerMsg, Signal, Usage,
|
||||
WIRE_VERSION, decode, decode_json, encode, encode_json,
|
||||
Accepted, Access, Admin, AdminReply, CatchUp, ClientMsg, EditingText, FileInfo, FileRow, FileUsage, Hello, HostInfo, LibraryReply, LibraryReq,
|
||||
LinkInfo, Manage, NewLink, OpenHosting, OpenLibrary, OpenPlugins, PROTOCOL, Peer, PluginEntry, PluginReply, PluginReq, Presence, Processed,
|
||||
ReportInfo, Resume, ServerMsg, Signal, Usage, WIRE_VERSION, decode, decode_json, encode, encode_json,
|
||||
};
|
||||
use crate::sim::Rng;
|
||||
use crate::{Error, Replica, diff};
|
||||
|
|
@ -68,6 +68,14 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
|
|||
ClientMsg::Plugins(PluginReq::Fetch { id: "org.example.p".into(), version: Some("1.0.0".into()) }),
|
||||
ClientMsg::Plugins(PluginReq::Publish { bundle: vec![b'F', b'C'] }),
|
||||
ClientMsg::Plugins(PluginReq::Unpublish { id: "org.example.p".into(), version: None }),
|
||||
ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: "0.3.2 hosting".into(), file: "f".into(), owner: "o".into() }),
|
||||
ClientMsg::Admin(Admin::Rename { file: "f".into(), name: "Café 2".into() }),
|
||||
ClientMsg::Admin(Admin::Listing),
|
||||
ClientMsg::Admin(Admin::Reports),
|
||||
ClientMsg::Admin(Admin::Block { ip: "203.0.113.9".into() }),
|
||||
ClientMsg::Admin(Admin::Unblock { ip: "2001:db8::".into() }),
|
||||
ClientMsg::Admin(Admin::Blocked),
|
||||
ClientMsg::Report { reason: "Spam ☕".into() },
|
||||
];
|
||||
let snapshot = Replica::new(Document::new("Snap")).snapshot().unwrap();
|
||||
let file = FileInfo { id: "f".into(), name: "Café".into(), created: 1, updated: 2, seq: 3, bytes: 4, people: 5 };
|
||||
|
|
@ -112,9 +120,9 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
|
|||
ServerMsg::Pong,
|
||||
ServerMsg::Admin(Box::new(AdminReply::Ready)),
|
||||
ServerMsg::Admin(Box::new(AdminReply::Published { file: file.clone(), edit: new_link.clone(), view: new_link.clone() })),
|
||||
ServerMsg::Admin(Box::new(AdminReply::Files(vec![file]))),
|
||||
ServerMsg::Admin(Box::new(AdminReply::Files(vec![file.clone()]))),
|
||||
ServerMsg::Admin(Box::new(AdminReply::Links(vec![link]))),
|
||||
ServerMsg::Admin(Box::new(AdminReply::Link(new_link))),
|
||||
ServerMsg::Admin(Box::new(AdminReply::Link(new_link.clone()))),
|
||||
ServerMsg::Admin(Box::new(AdminReply::Done)),
|
||||
ServerMsg::Admin(Box::new(AdminReply::Failed { reason: "no".into() })),
|
||||
ServerMsg::Library(Box::new(LibraryReply::Ready { access: Access::View })),
|
||||
|
|
@ -135,6 +143,40 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
|
|||
ServerMsg::Plugins(Box::new(PluginReply::Published(entry))),
|
||||
ServerMsg::Plugins(Box::new(PluginReply::Removed(vec!["1.0.0".into()]))),
|
||||
ServerMsg::Plugins(Box::new(PluginReply::Failed { reason: "no".into() })),
|
||||
ServerMsg::Admin(Box::new(AdminReply::Hosted {
|
||||
file: file.clone(),
|
||||
edit: new_link.clone(),
|
||||
view: new_link.clone(),
|
||||
owner: "o".into(),
|
||||
expires: Some(99),
|
||||
})),
|
||||
ServerMsg::Admin(Box::new(AdminReply::Listing(vec![FileRow {
|
||||
info: file.clone(),
|
||||
opened: 7,
|
||||
expires: None,
|
||||
open: true,
|
||||
ip: "203.0.113.9".into(),
|
||||
reports: 2,
|
||||
}]))),
|
||||
ServerMsg::Admin(Box::new(AdminReply::Reports(vec![ReportInfo {
|
||||
at: 8,
|
||||
file: "f".into(),
|
||||
name: "Café".into(),
|
||||
link: "l".into(),
|
||||
ip: "203.0.113.9".into(),
|
||||
reason: "Spam".into(),
|
||||
}]))),
|
||||
ServerMsg::Admin(Box::new(AdminReply::Blocked(vec!["203.0.113.9".into()]))),
|
||||
ServerMsg::Hosting(Box::new(HostInfo {
|
||||
open: true,
|
||||
operator: "abuse@example.com".into(),
|
||||
terms: "https://example.com/terms".into(),
|
||||
idle_secs: 86_400,
|
||||
max_file_mb: 16,
|
||||
expires: Some(1_800_000_000),
|
||||
})),
|
||||
ServerMsg::Reported { error: None },
|
||||
ServerMsg::Reported { error: Some("too many reports".into()) },
|
||||
];
|
||||
(client, server)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,6 +87,8 @@ pub struct Limits {
|
|||
pub max_log_batches: usize,
|
||||
/// Most changes in those batches together.
|
||||
pub max_log_changes: usize,
|
||||
/// About how many bytes those batches may hold together ([`server::weight`]).
|
||||
pub max_log_bytes: usize,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
|
|
@ -99,6 +101,7 @@ impl Default for Limits {
|
|||
max_name: 64,
|
||||
max_log_batches: 10_000,
|
||||
max_log_changes: 1_000_000,
|
||||
max_log_bytes: usize::MAX,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,9 +19,13 @@ pub const WIRE_VERSION: u8 = 1;
|
|||
/// server sends signals only to clients that say so (older ones couldn't read the message).
|
||||
pub const SIGNALS: &str = "signals";
|
||||
|
||||
/// The word a client puts in its [`Hello::client_version`] when it reads [`ServerMsg::Hosting`] and
|
||||
/// [`ServerMsg::Reported`]: a server sends them only to clients that say so.
|
||||
pub const HOSTING: &str = "hosting";
|
||||
|
||||
/// The `client_version` this build announces: its version, then what it reads beyond the protocol.
|
||||
pub fn client_version() -> String {
|
||||
format!("{} {SIGNALS}", env!("CARGO_PKG_VERSION"))
|
||||
format!("{} {SIGNALS} {HOSTING}", env!("CARGO_PKG_VERSION"))
|
||||
}
|
||||
|
||||
/// A [`Hello::client_version`] that announces [`SIGNALS`].
|
||||
|
|
@ -29,6 +33,11 @@ pub fn reads_signals(client_version: &str) -> bool {
|
|||
client_version.split_whitespace().any(|w| w == SIGNALS)
|
||||
}
|
||||
|
||||
/// A [`Hello::client_version`] that announces [`HOSTING`].
|
||||
pub fn reads_hosting(client_version: &str) -> bool {
|
||||
client_version.split_whitespace().any(|w| w == HOSTING)
|
||||
}
|
||||
|
||||
/// Join a file's session.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Hello {
|
||||
|
|
@ -245,6 +254,18 @@ pub enum Admin {
|
|||
Revoke { file: String, link: String },
|
||||
/// Remove a file and its links (whoever is in it is disconnected).
|
||||
Remove { file: String },
|
||||
/// Rename a file on the server (its listing; the document inside keeps its own name).
|
||||
Rename { file: String, name: String },
|
||||
/// The files kept, with what the operator moderates by ([`AdminReply::Listing`]).
|
||||
Listing,
|
||||
/// The reports people sent about files.
|
||||
Reports,
|
||||
/// Refuse connections from an address (an IPv6 address: its /64), and disconnect them.
|
||||
Block { ip: String },
|
||||
/// Accept an address again.
|
||||
Unblock { ip: String },
|
||||
/// The blocked addresses.
|
||||
Blocked,
|
||||
}
|
||||
|
||||
/// A file a server keeps.
|
||||
|
|
@ -303,6 +324,76 @@ pub enum AdminReply {
|
|||
Failed {
|
||||
reason: String,
|
||||
},
|
||||
/// A file published on an open hosting connection: its first two links and its owner token
|
||||
/// (tokens shown once), and when it is deleted if nobody opens it (seconds since 1970).
|
||||
Hosted {
|
||||
file: FileInfo,
|
||||
edit: NewLink,
|
||||
view: NewLink,
|
||||
owner: String,
|
||||
expires: Option<u64>,
|
||||
},
|
||||
Listing(Vec<FileRow>),
|
||||
Reports(Vec<ReportInfo>),
|
||||
Blocked(Vec<String>),
|
||||
}
|
||||
|
||||
/// A file with what its operator moderates by.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FileRow {
|
||||
pub info: FileInfo,
|
||||
/// When someone last opened it (seconds since 1970; 0: never since it was published).
|
||||
pub opened: u64,
|
||||
/// When it is deleted if nobody opens it (files published without the server key).
|
||||
pub expires: Option<u64>,
|
||||
/// Published without the server key (it has an owner token), from this address.
|
||||
pub open: bool,
|
||||
pub ip: String,
|
||||
/// Reports kept about it.
|
||||
pub reports: u32,
|
||||
}
|
||||
|
||||
/// A report someone in a file sent its server's operator.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ReportInfo {
|
||||
/// Seconds since 1970.
|
||||
pub at: u64,
|
||||
pub file: String,
|
||||
/// The file's name then.
|
||||
pub name: String,
|
||||
/// The link the reporter joined with, and their address.
|
||||
pub link: String,
|
||||
pub ip: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// Opens a connection to a server's open hosting (instead of [`Hello`]): anyone may ask about the
|
||||
/// server and, when it is open to the public, publish a file without the server key; with a file's
|
||||
/// owner token (`file` and `owner` set) the connection also manages that file. Answered with
|
||||
/// [`ServerMsg::Hosting`] or `Refused`; requests are [`Admin`] messages, each answered with one
|
||||
/// [`AdminReply`].
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct OpenHosting {
|
||||
pub protocol: u32,
|
||||
pub client_version: String,
|
||||
pub file: String,
|
||||
pub owner: String,
|
||||
}
|
||||
|
||||
/// What a server says about its hosting: whether anyone may publish, who runs it and its terms,
|
||||
/// how long files published without the key are kept unopened, and the largest file.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct HostInfo {
|
||||
/// Anyone may publish without the server key.
|
||||
pub open: bool,
|
||||
/// How to reach the operator (an email address or a web page), and the terms of use (a URL).
|
||||
pub operator: String,
|
||||
pub terms: String,
|
||||
/// Files published without the key are deleted after this many seconds unopened (0: kept).
|
||||
pub idle_secs: u64,
|
||||
pub max_file_mb: u64,
|
||||
/// This file's expiry (seconds since 1970), when it is one that expires.
|
||||
pub expires: Option<u64>,
|
||||
}
|
||||
|
||||
/// Opens a connection that uses a file's team library (instead of [`Hello`]), with one of the
|
||||
|
|
@ -481,6 +572,14 @@ pub enum ClientMsg {
|
|||
OpenPlugins(OpenPlugins),
|
||||
/// A request on a plugin directory connection.
|
||||
Plugins(PluginReq),
|
||||
/// Ask about the server's hosting, publish without the key or manage a file with its owner
|
||||
/// token (the first message on a connection, instead of `Hello`).
|
||||
OpenHosting(OpenHosting),
|
||||
/// Report the file you are in to the server's operator (only to a server that sent
|
||||
/// [`ServerMsg::Hosting`]): why, in a few words.
|
||||
Report {
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Server → client.
|
||||
|
|
@ -536,6 +635,31 @@ pub enum ServerMsg {
|
|||
},
|
||||
/// The answer to a plugin directory connection's request.
|
||||
Plugins(Box<PluginReply>),
|
||||
/// The server's hosting: the answer to [`OpenHosting`], and after a welcome to clients whose
|
||||
/// hello announced [`HOSTING`] (with the file's expiry).
|
||||
Hosting(Box<HostInfo>),
|
||||
/// The answer to a [`ClientMsg::Report`]: kept, or why not.
|
||||
Reported {
|
||||
error: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DD HH:MM UTC` for seconds since 1970 (how servers and apps show the times in these
|
||||
/// messages).
|
||||
pub fn utc_date(secs: u64) -> String {
|
||||
let days = i64::try_from(secs / 86_400).unwrap_or(0);
|
||||
let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60);
|
||||
// Days to a civil date (proleptic Gregorian).
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = yoe + era * 400 + i64::from(mo <= 2);
|
||||
format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02} UTC")
|
||||
}
|
||||
|
||||
/// The binary form of `msg`: [`WIRE_VERSION`], then postcard.
|
||||
|
|
|
|||
|
|
@ -63,8 +63,9 @@ pub struct Server {
|
|||
issued: BTreeMap<u32, Processed>,
|
||||
/// The latest accepted batches, oldest first (a reconnecting client gets what it missed).
|
||||
log: VecDeque<Accepted>,
|
||||
/// Changes in `log`.
|
||||
/// Changes in `log`, and about how many bytes it holds ([`weight`]).
|
||||
log_changes: usize,
|
||||
log_bytes: usize,
|
||||
/// The connected clients that may only view.
|
||||
viewers: BTreeSet<u32>,
|
||||
/// What changed the lasting state since it was last taken (`None`: not kept).
|
||||
|
|
@ -117,6 +118,7 @@ impl Server {
|
|||
issued: BTreeMap::new(),
|
||||
log: VecDeque::new(),
|
||||
log_changes: 0,
|
||||
log_bytes: 0,
|
||||
viewers: BTreeSet::new(),
|
||||
journal: None,
|
||||
signals: BTreeSet::new(),
|
||||
|
|
@ -388,7 +390,9 @@ impl Server {
|
|||
| ClientMsg::OpenLibrary(_)
|
||||
| ClientMsg::Library(_)
|
||||
| ClientMsg::OpenPlugins(_)
|
||||
| ClientMsg::Plugins(_) => {
|
||||
| ClientMsg::Plugins(_)
|
||||
| ClientMsg::OpenHosting(_)
|
||||
| ClientMsg::Report { .. } => {
|
||||
vec![Outgoing::To(from, ServerMsg::Refused { reason: "managing files and libraries needs a connection of its own".into() })]
|
||||
}
|
||||
}
|
||||
|
|
@ -397,14 +401,25 @@ impl Server {
|
|||
/// Keep an accepted batch in the log (dropping the oldest beyond the limits).
|
||||
fn remember(&mut self, acc: Accepted) {
|
||||
self.log_changes += acc.changes.len();
|
||||
self.log_bytes = self.log_bytes.saturating_add(weight(&acc.changes));
|
||||
self.log.push_back(acc);
|
||||
while self.log.len() > self.limits.max_log_batches || (self.log_changes > self.limits.max_log_changes && !self.log.is_empty()) {
|
||||
while !self.log.is_empty()
|
||||
&& (self.log.len() > self.limits.max_log_batches
|
||||
|| self.log_changes > self.limits.max_log_changes
|
||||
|| self.log_bytes > self.limits.max_log_bytes)
|
||||
{
|
||||
if let Some(old) = self.log.pop_front() {
|
||||
self.log_changes = self.log_changes.saturating_sub(old.changes.len());
|
||||
self.log_bytes = self.log_bytes.saturating_sub(weight(&old.changes));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// About how many bytes the batches kept for reconnecting clients hold.
|
||||
pub fn log_bytes(&self) -> usize {
|
||||
self.log_bytes
|
||||
}
|
||||
|
||||
/// Validate and apply a batch; on success it has the next sequence number.
|
||||
fn accept(&mut self, from: u32, base_seq: u64, changes: &[Change]) -> Result<(), Error> {
|
||||
if changes.len() > self.limits.max_changes {
|
||||
|
|
@ -557,3 +572,27 @@ fn reason(e: &Error) -> String {
|
|||
e => e.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// About how many bytes `changes` take in memory: their values' text and a little per change.
|
||||
pub fn weight(changes: &[Change]) -> usize {
|
||||
fn json(v: &serde_json::Value) -> usize {
|
||||
match v {
|
||||
serde_json::Value::String(s) => s.len() + 2,
|
||||
serde_json::Value::Array(a) => a.iter().map(json).fold(2, usize::saturating_add),
|
||||
serde_json::Value::Object(o) => o.iter().map(|(k, v)| k.len() + 3 + json(v)).fold(2, usize::saturating_add),
|
||||
_ => 8,
|
||||
}
|
||||
}
|
||||
changes
|
||||
.iter()
|
||||
.map(|c| match c {
|
||||
Change::Create { kind, position, props, .. } => {
|
||||
props.iter().map(|(_, v)| 16 + json(&v.0)).fold(64 + json(&kind.0) + position.len(), usize::saturating_add)
|
||||
}
|
||||
Change::Set { value, .. } | Change::SetDoc { value, .. } => 32 + json(&value.0),
|
||||
Change::Put { value, .. } => 64 + json(&value.0),
|
||||
Change::Delete { .. } => 16,
|
||||
Change::Remove { .. } => 64,
|
||||
})
|
||||
.fold(0, usize::saturating_add)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ fn answer(s: &mut Session, wire: &Arc<Mutex<Wire>>) -> usize {
|
|||
NetRequest::Plugins { req, .. } => NetReply::Plugins(Ok(directory(req))),
|
||||
NetRequest::Library { req, .. } => NetReply::Library(Ok(library(&mut w, req))),
|
||||
NetRequest::Upload(_) => NetReply::Upload(Err("no".into())),
|
||||
NetRequest::Hosting { .. } => NetReply::Hosting(Err("no".into())),
|
||||
};
|
||||
w.answers.push(Event::Net { job: *job, reply: Box::new(reply) });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -290,6 +290,27 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
s.prefs.user_name = if name.is_empty() { super::comment::DEFAULT_AUTHOR.into() } else { name };
|
||||
Ok(json!({"name": s.prefs.user_name}))
|
||||
}),
|
||||
spec!(
|
||||
"pref.publishServer",
|
||||
"Server to publish to",
|
||||
"{url: ws://host:port/ or wss://host/path/ (no key; empty: none)} → {url}",
|
||||
false,
|
||||
|s, p| {
|
||||
const CMD: &str = "pref.publishServer";
|
||||
let url = req_string(CMD, p, "url")?.trim().to_string();
|
||||
let web_socket = url.starts_with("ws://") || url.starts_with("wss://");
|
||||
if !url.is_empty()
|
||||
&& (!web_socket || url.len() > 500 || url.contains(|c: char| c.is_whitespace() || c.is_control() || c == '?' || c == '#'))
|
||||
{
|
||||
return Err(bad(
|
||||
CMD,
|
||||
"the server's address is ws://host:port/ or wss://host/path/, without a key (it is saved with your preferences)",
|
||||
));
|
||||
}
|
||||
s.prefs.publish_server = url;
|
||||
Ok(json!({"url": s.prefs.publish_server}))
|
||||
}
|
||||
),
|
||||
spec!("pref.language", "Interface language", "{language: system|en|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…; else English}", false, |s, p| {
|
||||
let lang = req_string("pref.language", p, "language")?;
|
||||
let lang = lang.trim();
|
||||
|
|
|
|||
|
|
@ -564,6 +564,10 @@ pub struct Prefs {
|
|||
/// The plugin directory browsed last (`ws://host:port/`, never with a key).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub plugin_directory: Option<String>,
|
||||
/// The server Share ▸ Publish to server suggests (`wss://host/`, never with a key; empty:
|
||||
/// none). There is no built-in default.
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub publish_server: String,
|
||||
/// Recently opened or saved files, newest first (File ▸ Open recent; at most
|
||||
/// [`cmd::file::MAX_RECENT`]).
|
||||
pub recent_files: Vec<String>,
|
||||
|
|
@ -587,6 +591,7 @@ impl Default for Prefs {
|
|||
plugins: Vec::new(),
|
||||
plugin_settings: BTreeMap::new(),
|
||||
plugin_directory: None,
|
||||
publish_server: String::new(),
|
||||
recent_files: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,9 +32,9 @@ use std::sync::Arc;
|
|||
|
||||
use figcraft_collab::Client;
|
||||
pub use figcraft_collab::message::Signal;
|
||||
use figcraft_collab::message::{ClientMsg, EditingText, LibraryReply, LibraryReq, PluginReply, PluginReq, ServerMsg};
|
||||
use figcraft_collab::message::{Admin, AdminReply, ClientMsg, EditingText, LibraryReply, LibraryReq, PluginReply, PluginReq, ServerMsg};
|
||||
|
||||
pub use figcraft_collab::message::Access;
|
||||
pub use figcraft_collab::message::{Access, HostInfo, utc_date};
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
|
@ -162,6 +162,11 @@ pub struct Published {
|
|||
/// The link that lets people edit, and the one that lets them only view.
|
||||
pub edit: String,
|
||||
pub view: String,
|
||||
/// Published without the server's key: the owner token that revokes links, renames or removes
|
||||
/// the file (shown this once; the server keeps only its hash).
|
||||
pub owner: Option<String>,
|
||||
/// When the server deletes the file if nobody opens it (seconds since 1970).
|
||||
pub expires: Option<u64>,
|
||||
}
|
||||
|
||||
/// What happened in the session since the last [`LiveLink::poll`].
|
||||
|
|
@ -187,6 +192,10 @@ pub enum Event {
|
|||
},
|
||||
/// The document is on the server (`collab.publish`); joining it follows.
|
||||
Published(Published),
|
||||
/// What the server says about its hosting (operator, terms, this file's expiry).
|
||||
Hosting(HostInfo),
|
||||
/// The server's answer to `collab.report`: kept, or why not.
|
||||
Reported(Option<String>),
|
||||
/// The answer to a team library request sent in the background
|
||||
/// ([`LiveLink::library_background`]) about the file behind share link `link`.
|
||||
Library {
|
||||
|
|
@ -211,6 +220,9 @@ pub enum NetRequest {
|
|||
Library { link: String, req: LibraryReq },
|
||||
/// Keep a file on a server without joining it ([`LiveLink::upload`]).
|
||||
Upload(PublishOptions),
|
||||
/// Ask the server at `server` about its hosting and, with a file's owner token, manage that
|
||||
/// file ([`LiveLink::hosting`]).
|
||||
Hosting { server: String, file: String, owner: String, req: Option<Admin> },
|
||||
}
|
||||
|
||||
impl NetRequest {
|
||||
|
|
@ -219,6 +231,7 @@ impl NetRequest {
|
|||
match self {
|
||||
NetRequest::Plugins { .. } => crate::plugin_directory::NO_DIRECTORY,
|
||||
NetRequest::Library { .. } | NetRequest::Upload(_) => NO_TEAM_LIBRARIES,
|
||||
NetRequest::Hosting { .. } => NO_HOSTING,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -228,6 +241,7 @@ impl NetRequest {
|
|||
NetRequest::Plugins { .. } => |why| NetReply::Plugins(Err(why)),
|
||||
NetRequest::Library { .. } => |why| NetReply::Library(Err(why)),
|
||||
NetRequest::Upload(_) => |why| NetReply::Upload(Err(why)),
|
||||
NetRequest::Hosting { .. } => |why| NetReply::Hosting(Err(why)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -239,6 +253,9 @@ impl NetRequest {
|
|||
(NetRequest::Plugins { server: a, req: x }, NetRequest::Plugins { server: b, req: y }) => a == b && discriminant(x) == discriminant(y),
|
||||
(NetRequest::Library { link: a, req: x }, NetRequest::Library { link: b, req: y }) => a == b && discriminant(x) == discriminant(y),
|
||||
(NetRequest::Upload(a), NetRequest::Upload(b)) => a.server == b.server,
|
||||
(NetRequest::Hosting { server: a, file: f, req: x, .. }, NetRequest::Hosting { server: b, file: g, req: y, .. }) => {
|
||||
a == b && f == g && x.as_ref().map(discriminant) == y.as_ref().map(discriminant)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
|
@ -250,8 +267,13 @@ pub enum NetReply {
|
|||
Plugins(std::result::Result<PluginReply, String>),
|
||||
Library(std::result::Result<LibraryReply, String>),
|
||||
Upload(std::result::Result<Published, String>),
|
||||
/// What the server says about its hosting, and its answer to the request, if one was sent.
|
||||
Hosting(std::result::Result<(HostInfo, Option<AdminReply>), String>),
|
||||
}
|
||||
|
||||
/// What a session without the network says about servers' hosting.
|
||||
pub const NO_HOSTING: &str = "publishing to a server needs the desktop app or the MCP server";
|
||||
|
||||
/// Which network interface a hosted session listens on.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Bind {
|
||||
|
|
@ -281,7 +303,8 @@ pub struct PublishOptions {
|
|||
/// The server's address (`ws://host:port/`, `wss://host/path/`), which may carry its key
|
||||
/// (`?key=…`).
|
||||
pub server: String,
|
||||
/// The server's key, when the address doesn't carry it.
|
||||
/// The server's key, when the address doesn't carry it. Without one the file is published
|
||||
/// as anyone may, on a server open to the public (with an owner token).
|
||||
pub key: Option<String>,
|
||||
/// The file's name on the server.
|
||||
pub name: String,
|
||||
|
|
@ -329,12 +352,25 @@ pub trait LiveLink: Send {
|
|||
fn plugins(&mut self, _server: &str, _req: PluginReq) -> std::result::Result<PluginReply, String> {
|
||||
Err(crate::plugin_directory::NO_DIRECTORY.into())
|
||||
}
|
||||
/// What the server at `server` says about its hosting, and with a file's owner token
|
||||
/// (`file` and `owner`) its answer to `req` about that file; waits for the server. Errors
|
||||
/// never echo the token.
|
||||
fn hosting(
|
||||
&mut self,
|
||||
_server: &str,
|
||||
_file: &str,
|
||||
_owner: &str,
|
||||
_req: Option<Admin>,
|
||||
) -> std::result::Result<(HostInfo, Option<AdminReply>), String> {
|
||||
Err(NO_HOSTING.into())
|
||||
}
|
||||
/// Send `req` and wait for the answer (through the methods above).
|
||||
fn request(&mut self, req: NetRequest) -> NetReply {
|
||||
match req {
|
||||
NetRequest::Plugins { server, req } => NetReply::Plugins(self.plugins(&server, req)),
|
||||
NetRequest::Library { link, req } => NetReply::Library(self.library(&link, req)),
|
||||
NetRequest::Upload(o) => NetReply::Upload(self.upload(o)),
|
||||
NetRequest::Hosting { server, file, owner, req } => NetReply::Hosting(self.hosting(&server, &file, &owner, req)),
|
||||
}
|
||||
}
|
||||
/// Send `req` on a thread of its own and return at once: the answer arrives as [`Event::Net`]
|
||||
|
|
@ -393,6 +429,9 @@ pub struct Live {
|
|||
pub links: Option<Box<dyn crate::invite_links::LinkRegistrar>>,
|
||||
/// The file you published to a server in this session, with its links.
|
||||
pub published: Option<Published>,
|
||||
/// What the server of the file you're in says about its hosting (operator, terms, expiry);
|
||||
/// a server that says it takes reports (`collab.report`).
|
||||
pub hosting: Option<HostInfo>,
|
||||
/// The host passes cursor chat and spotlights on (it said so after the welcome).
|
||||
pub signals: bool,
|
||||
/// Others' open cursor chat messages.
|
||||
|
|
@ -445,6 +484,7 @@ impl Live {
|
|||
self.deferred.clear();
|
||||
self.hold = 0;
|
||||
self.published = None;
|
||||
self.hosting = None;
|
||||
self.signals = false;
|
||||
self.chats.clear();
|
||||
self.chat.clear();
|
||||
|
|
@ -703,6 +743,11 @@ impl Session {
|
|||
self.message = Some(format!("Published {} to the server", p.name));
|
||||
live.published = Some(p);
|
||||
}
|
||||
Event::Hosting(info) => live.hosting = Some(clean_hosting(info)),
|
||||
Event::Reported(error) => {
|
||||
self.message =
|
||||
Some(error.map_or_else(|| "Your report was sent to the server's operator".into(), |e| format!("The report wasn't sent: {e}")));
|
||||
}
|
||||
Event::Library { link, reply } => self.library_answer(&link, reply),
|
||||
Event::Net { job, reply } => self.background_answer(job, *reply),
|
||||
}
|
||||
|
|
@ -1131,6 +1176,7 @@ impl Session {
|
|||
"access": l.client.as_ref().map(Client::access).or(info.map(|i| i.access)),
|
||||
"readOnly": self.active_read_only().is_some(),
|
||||
"published": l.published,
|
||||
"hosting": l.hosting.as_ref().map(hosting_json),
|
||||
"follow": l.follow,
|
||||
"signals": l.signals,
|
||||
"chat": l.chat,
|
||||
|
|
@ -1144,6 +1190,31 @@ impl Session {
|
|||
}
|
||||
}
|
||||
|
||||
/// What a server says about its hosting, made safe to show: no control characters, short, and a
|
||||
/// terms address only when it is a web page (`http(s)://`).
|
||||
fn clean_hosting(mut info: HostInfo) -> HostInfo {
|
||||
let clean = |s: &str, n: usize| s.trim().chars().filter(|c| !c.is_control()).take(n).collect::<String>();
|
||||
info.operator = clean(&info.operator, 120);
|
||||
info.terms = clean(&info.terms, 500);
|
||||
let web = info.terms.starts_with("https://") || info.terms.starts_with("http://");
|
||||
if !web || info.terms.contains(char::is_whitespace) {
|
||||
info.terms.clear();
|
||||
}
|
||||
info
|
||||
}
|
||||
|
||||
/// A server's hosting as JSON (`collab.peers`, `collab.serverInfo`).
|
||||
pub fn hosting_json(h: &HostInfo) -> Value {
|
||||
json!({
|
||||
"open": h.open,
|
||||
"operator": h.operator,
|
||||
"terms": h.terms,
|
||||
"idleDays": (h.idle_secs > 0).then(|| (h.idle_secs as f64 / 86_400.0 * 100.0).round() / 100.0),
|
||||
"maxFileMb": h.max_file_mb,
|
||||
"expires": h.expires,
|
||||
})
|
||||
}
|
||||
|
||||
fn unavailable(cmd: &str) -> EngineError {
|
||||
EngineError::Disabled(
|
||||
cmd.to_string(),
|
||||
|
|
@ -1190,6 +1261,76 @@ fn publish(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(s.live_summary())
|
||||
}
|
||||
|
||||
/// What the server at `server` says about its hosting, and its answer to `req` about `file` with
|
||||
/// its owner token (`collab.serverInfo`, `collab.manageFile`).
|
||||
fn hosting_request(s: &mut Session, cmd: &str, server: &str, file: &str, owner: &str, req: Option<Admin>) -> Result<(HostInfo, Option<AdminReply>)> {
|
||||
let req = NetRequest::Hosting { server: server.to_string(), file: file.to_string(), owner: owner.to_string(), req };
|
||||
match s.net(req)?.0 {
|
||||
// The server's refusals say why themselves.
|
||||
NetReply::Hosting(Ok((_, Some(AdminReply::Failed { reason })))) | NetReply::Hosting(Err(reason)) => Err(EngineError::Other(reason)),
|
||||
NetReply::Hosting(Ok((info, reply))) => Ok((clean_hosting(info), reply)),
|
||||
_ => Err(EngineError::Other(format!("{cmd}: the server answered something else"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn server_info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "collab.serverInfo";
|
||||
let server = req_string(CMD, p, "server")?;
|
||||
let (info, _) = hosting_request(s, CMD, server.trim(), "", "", None)?;
|
||||
Ok(hosting_json(&info))
|
||||
}
|
||||
|
||||
fn manage_file(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "collab.manageFile";
|
||||
let (server, file, owner) = (req_string(CMD, p, "server")?, req_string(CMD, p, "file")?, req_string(CMD, p, "owner")?);
|
||||
let (file, owner) = (file.trim().to_string(), owner.trim().to_string());
|
||||
let action = req_string(CMD, p, "action")?;
|
||||
let req = match action.trim() {
|
||||
"links" => Admin::Links { file: file.clone() },
|
||||
"createLink" => {
|
||||
let access = match string(p, "access").as_deref().map(str::trim) {
|
||||
None | Some("view") => Access::View,
|
||||
Some("edit") => Access::Edit,
|
||||
Some(_) => return Err(bad(CMD, "`access` must be \"edit\" or \"view\"")),
|
||||
};
|
||||
Admin::CreateLink { file: file.clone(), access, label: string(p, "label").unwrap_or_default() }
|
||||
}
|
||||
"revoke" => Admin::Revoke { file: file.clone(), link: req_string(CMD, p, "link")?.trim().to_string() },
|
||||
"rename" => Admin::Rename { file: file.clone(), name: req_string(CMD, p, "name")? },
|
||||
"remove" => Admin::Remove { file: file.clone() },
|
||||
_ => return Err(bad(CMD, "`action` must be links, createLink, revoke, rename or remove")),
|
||||
};
|
||||
match hosting_request(s, CMD, server.trim(), &file, &owner, Some(req))?.1 {
|
||||
Some(AdminReply::Links(links)) => Ok(json!({"links": links.iter().map(|l| json!({
|
||||
"id": l.id, "access": l.access.id(), "label": l.label, "created": l.created
|
||||
})).collect::<Vec<_>>()})),
|
||||
// The link layer gives the new link whole in place of its token.
|
||||
Some(AdminReply::Link(l)) => Ok(json!({"id": l.link.id, "access": l.link.access.id(), "link": l.token})),
|
||||
Some(AdminReply::Done) => Ok(json!({"done": true})),
|
||||
_ => Err(bad(CMD, "the server answered something else")),
|
||||
}
|
||||
}
|
||||
|
||||
fn report(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "collab.report";
|
||||
let reason: String = req_string(CMD, p, "reason")?.trim().chars().map(|c| if c.is_control() { ' ' } else { c }).take(MAX_REPORT).collect();
|
||||
if reason.is_empty() {
|
||||
return Err(bad(CMD, "say in a few words what is wrong with the file (`reason`)"));
|
||||
}
|
||||
if s.live.status != Status::Live || s.live.role() != Some(Role::Guest) {
|
||||
return Err(bad(CMD, "join a file on a FigCraft server first: reports go to that server's operator"));
|
||||
}
|
||||
if s.live.hosting.is_none() {
|
||||
return Err(bad(CMD, "this server doesn't take reports (it is older than this app); contact its operator another way"));
|
||||
}
|
||||
let link = s.live.link.as_mut().ok_or_else(|| unavailable(CMD))?;
|
||||
link.send(ClientMsg::Report { reason });
|
||||
Ok(json!({"sent": true}))
|
||||
}
|
||||
|
||||
/// Longest report sent (characters).
|
||||
pub const MAX_REPORT: usize = 500;
|
||||
|
||||
fn join(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "collab.join";
|
||||
let link_text = req_string(CMD, p, "link")?;
|
||||
|
|
@ -1280,10 +1421,31 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
spec!(
|
||||
"collab.publish",
|
||||
"Publish to server",
|
||||
"{server: ws://host:port/?key=… or wss://host/path/?key=… (the server's address with its key; `figcraft-server key` prints it), key? (when the address has none), name? (default: the file's name)} → status \"connecting\": the open file is uploaded as a new file and this tab syncs through the server; `collab.peers` then has `published: {server, file, name, edit, view}` (share links: edit, or view only)",
|
||||
"{server: ws://host:port/?key=… or wss://host/path/?key=… (the server's address with its key; `figcraft-server key` prints it; without a key on a server open to the public), key? (when the address has none), name? (default: the file's name)} → status \"connecting\": the open file is uploaded as a new file and this tab syncs through the server; `collab.peers` then has `published: {server, file, name, edit, view, owner (published without a key: the owner token, shown once), expires (seconds since 1970, or null)}` and `hosting` (the server's operator, terms and idle expiry)",
|
||||
false,
|
||||
publish
|
||||
),
|
||||
spec!(
|
||||
"collab.serverInfo",
|
||||
"Server info",
|
||||
"{server: ws://host:port/ or wss://host/path/} → {open (anyone may publish without the key), operator, terms (a web address), idleDays (files published without the key are deleted after this many days unopened; null: kept), maxFileMb, expires}",
|
||||
false,
|
||||
server_info
|
||||
),
|
||||
spec!(
|
||||
"collab.manageFile",
|
||||
"Manage published file",
|
||||
"{server, file (its id), owner (the owner token you got when you published it without the key), action: links | createLink {access?: view (default) | edit, label?} | revoke {link (a link's id)} | rename {name} | remove} → {links: [{id, access, label, created}]} | {id, access, link (a new share link, shown once)} | {done}",
|
||||
false,
|
||||
manage_file
|
||||
),
|
||||
spec!(
|
||||
"collab.report",
|
||||
"Report file to server operator",
|
||||
"{reason (a few words, at most 500 characters)} → {sent}: the file you joined on a server is reported to its operator; the session's message says when the server kept it (servers older than this app don't take reports)",
|
||||
false,
|
||||
report
|
||||
),
|
||||
spec!(
|
||||
"collab.peers",
|
||||
"People in this file",
|
||||
|
|
|
|||
|
|
@ -46,7 +46,15 @@ fn publishing_makes_the_open_tab_the_server_file() {
|
|||
let mut doc = s.doc().unwrap().clone();
|
||||
doc.name = "Team".into();
|
||||
let (mut server, id, welcome) = serve_as(&doc, Access::Edit);
|
||||
let published = Published { server: "127.0.0.1:47900".into(), file: "f".into(), name: "Team".into(), edit: "e".into(), view: "v".into() };
|
||||
let published = Published {
|
||||
server: "127.0.0.1:47900".into(),
|
||||
file: "f".into(),
|
||||
name: "Team".into(),
|
||||
edit: "e".into(),
|
||||
view: "v".into(),
|
||||
owner: None,
|
||||
expires: None,
|
||||
};
|
||||
push(&script, vec![Event::Published(published.clone()), Event::Welcome { info: info(Role::Guest, id), peers: vec![] }, welcome]);
|
||||
s.live_poll();
|
||||
assert!(s.live.syncing());
|
||||
|
|
|
|||
|
|
@ -409,3 +409,24 @@ fn a_guest_opens_the_hosts_file_as_a_new_tab() {
|
|||
assert_eq!(s.docs.len(), 2);
|
||||
assert!(s.doc().unwrap().contains(NodeId(r["id"].as_u64().unwrap())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosting_commands_check_what_they_are_given() {
|
||||
let mut s = Session::new();
|
||||
// The publish server preference: a WebSocket address without a key, or nothing.
|
||||
assert_eq!(s.execute("pref.publishServer", &json!({"url": " wss://figcraft.example.com/ "})).unwrap()["url"], "wss://figcraft.example.com/");
|
||||
for bad in ["https://figcraft.example.com/", "wss://host/?key=secret", "wss://a b/"] {
|
||||
assert!(s.execute("pref.publishServer", &json!({"url": bad})).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(s.prefs.publish_server, "wss://figcraft.example.com/", "a refused address changes nothing");
|
||||
s.execute("pref.publishServer", &json!({"url": ""})).unwrap();
|
||||
assert!(s.prefs.publish_server.is_empty());
|
||||
// No network here: the server can't be asked, nothing can be reported.
|
||||
let e = s.execute("collab.serverInfo", &json!({"server": "wss://figcraft.example.com/"})).unwrap_err().to_string();
|
||||
assert!(e.contains("desktop app or the MCP server"), "{e}");
|
||||
let e = s.execute("collab.manageFile", &json!({"server": "wss://x/", "file": "f", "owner": "o", "action": "explode"})).unwrap_err().to_string();
|
||||
assert!(e.contains("`action` must be"), "{e}");
|
||||
let e = s.execute("collab.report", &json!({"reason": "Spam"})).unwrap_err().to_string();
|
||||
assert!(e.contains("join a file"), "{e}");
|
||||
assert!(s.execute("collab.report", &json!({"reason": " \n "})).unwrap_err().to_string().contains("few words"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -272,6 +272,9 @@ fn every_command_survives_junk_params() {
|
|||
// Keyboard steps (view.pan) and toggles as junk.
|
||||
json!({"dx": 1e308, "dy": "NaN", "big": "yes", "on": 3}),
|
||||
json!({"dx": -1e308, "dy": [1], "big": null, "on": null}),
|
||||
// Open hosting: servers, owner tokens, actions, reports and the publish server as junk.
|
||||
json!({"server": "wss://\u{0}", "file": [1], "owner": {"x": 1}, "action": "remove", "reason": 5, "link": -1, "access": "admin", "url": 7}),
|
||||
json!({"server": "ws://127.0.0.1:1/", "file": "../x", "owner": "", "action": "createLink", "reason": "\u{7}\u{0}", "name": "", "url": "ws://x/?key=k"}),
|
||||
// Rotation origins and placing with a tool as junk.
|
||||
json!({"tool": [1], "x": 1e308, "y": "NaN", "reset": "maybe", "on": {"x": 1}}),
|
||||
json!({"tool": "frame", "x": -1e308, "y": 1e308, "reset": true, "on": "yes"}),
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ pub mod fig;
|
|||
pub mod library;
|
||||
pub mod rest;
|
||||
|
||||
pub use library::{is_valid_library_id, read_library};
|
||||
pub use library::{is_valid_library_id, read_library, read_library_within};
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::io::{Read, Seek, Write};
|
||||
|
|
@ -470,7 +470,7 @@ pub fn load_bytes_with_versions(bytes: &[u8]) -> Result<Loaded, FormatError> {
|
|||
let hash = name.trim_start_matches("images/").to_string();
|
||||
images.insert(hash, image_data(data));
|
||||
}
|
||||
match library::read(&mut z, &images) {
|
||||
match library::read(&mut z, &images, u64::MAX) {
|
||||
Ok(lib) => doc.published = lib.map(Arc::new),
|
||||
Err(e) => issues.push(format!("the published library was skipped: {e}")),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,8 +57,13 @@ pub(crate) fn write<W: Write + Seek>(z: &mut zip::ZipWriter<W>, lib: &PublishedL
|
|||
}
|
||||
|
||||
/// The published library in an archive (`None` when the file was never published). `own` are
|
||||
/// the images already read from `images/`.
|
||||
pub(crate) fn read<R: Read + Seek>(z: &mut zip::ZipArchive<R>, own: &BTreeMap<String, ImageData>) -> Result<Option<PublishedLibrary>, FormatError> {
|
||||
/// the images already read from `images/`; `budget` caps the bytes its snapshot and images unpack
|
||||
/// to, together (beyond the per-part caps).
|
||||
pub(crate) fn read<R: Read + Seek>(
|
||||
z: &mut zip::ZipArchive<R>,
|
||||
own: &BTreeMap<String, ImageData>,
|
||||
budget: u64,
|
||||
) -> Result<Option<PublishedLibrary>, FormatError> {
|
||||
if z.index_for_name(MANIFEST).is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
|
@ -66,9 +71,9 @@ pub(crate) fn read<R: Read + Seek>(z: &mut zip::ZipArchive<R>, own: &BTreeMap<St
|
|||
if !is_valid_library_id(&manifest.id) {
|
||||
return Err(corrupt("the library has an invalid id"));
|
||||
}
|
||||
let text = String::from_utf8(read_member(z, SNAPSHOT, MAX_MEMBER)?).map_err(corrupt)?;
|
||||
let text = String::from_utf8(read_member(z, SNAPSHOT, budget.min(MAX_MEMBER))?).map_err(corrupt)?;
|
||||
let mut budget = budget.saturating_sub(text.len() as u64).min(MAX_IMAGE_BYTES);
|
||||
let (mut doc, _) = from_json(&text)?;
|
||||
let mut budget = MAX_IMAGE_BYTES;
|
||||
for hash in used_images(&doc) {
|
||||
if !is_valid_hash(&hash) {
|
||||
continue;
|
||||
|
|
@ -97,7 +102,15 @@ pub(crate) fn read<R: Read + Seek>(z: &mut zip::ZipArchive<R>, own: &BTreeMap<St
|
|||
/// when the file was never published, an error when it isn't a FigCraft archive or is damaged.
|
||||
pub fn read_library<R: Read + Seek>(r: R) -> Result<Option<PublishedLibrary>, FormatError> {
|
||||
let mut z = zip::ZipArchive::new(r).map_err(corrupt)?;
|
||||
read(&mut z, &BTreeMap::new())
|
||||
read(&mut z, &BTreeMap::new(), u64::MAX)
|
||||
}
|
||||
|
||||
/// [`read_library`] unpacking at most `max_bytes` in all (snapshot and images: an archive that
|
||||
/// would unpack to more is refused, or loses the images past it), for libraries from people the
|
||||
/// reader doesn't trust (a server).
|
||||
pub fn read_library_within<R: Read + Seek>(r: R, max_bytes: u64) -> Result<Option<PublishedLibrary>, FormatError> {
|
||||
let mut z = zip::ZipArchive::new(r).map_err(corrupt)?;
|
||||
read(&mut z, &BTreeMap::new(), max_bytes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -799,7 +799,6 @@ status = "draft — needs native review"
|
|||
"Offset" = "Versatz"
|
||||
"On click" = "Bei Klick"
|
||||
"On drag" = "Beim Ziehen"
|
||||
"On the server's machine, `figcraft-server key` prints it." = "Auf dem Server-Rechner gibt `figcraft-server key` ihn aus."
|
||||
"On this page" = "Auf dieser Seite"
|
||||
"Only the first changed layers are listed." = "Nur die ersten geänderten Ebenen werden aufgeführt."
|
||||
"Only the first {n} changed layers are listed." = "Nur die ersten {n} geänderten Ebenen werden aufgeführt."
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ name = "English"
|
|||
"Arrange" = "Arrange"
|
||||
"Arrow" = "Arrow"
|
||||
"As typed" = "As typed"
|
||||
"Asking the server…" = "Asking the server…"
|
||||
"Assets" = "Assets"
|
||||
"Auto" = "Auto"
|
||||
"Auto (space around)" = "Auto (space around)"
|
||||
|
|
@ -282,6 +283,7 @@ name = "English"
|
|||
"Copy as PNG" = "Copy as PNG"
|
||||
"Copy as SVG" = "Copy as SVG"
|
||||
"Copy link" = "Copy link"
|
||||
"Copy the owner token and keep it somewhere safe: it is the only way to revoke these links, rename the file or delete it from the server, and it won't be shown again." = "Copy the owner token and keep it somewhere safe: it is the only way to revoke these links, rename the file or delete it from the server, and it won't be shown again."
|
||||
"Copy the {label}" = "Copy the {label}"
|
||||
"Copy {value}" = "Copy {value}"
|
||||
"Corner radius" = "Corner radius"
|
||||
|
|
@ -442,6 +444,9 @@ name = "English"
|
|||
"Fetching from the server…" = "Fetching from the server…"
|
||||
"File" = "File"
|
||||
"File name" = "File name"
|
||||
"File published" = "File published"
|
||||
"Files aren't end-to-end encrypted: the server's operator can read them." = "Files aren't end-to-end encrypted: the server's operator can read them."
|
||||
"Files nobody opens for {n} days are deleted." = { one = "Files nobody opens for {n} day are deleted.", other = "Files nobody opens for {n} days are deleted." }
|
||||
"Fill" = "Fill"
|
||||
"Fill (fr)" = "Fill (fr)"
|
||||
"Fill container" = "Fill container"
|
||||
|
|
@ -497,6 +502,7 @@ name = "English"
|
|||
"Gap between columns" = "Gap between columns"
|
||||
"Gap between items" = "Gap between items"
|
||||
"Gap between rows" = "Gap between rows"
|
||||
"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)."
|
||||
"Glass" = "Glass"
|
||||
"Go to main component" = "Go to main component"
|
||||
"Go to {name}" = "Go to {name}"
|
||||
|
|
@ -539,6 +545,7 @@ name = "English"
|
|||
"Horizontal hex" = "Horizontal hex"
|
||||
"Horizontal padding" = "Horizontal padding"
|
||||
"Horizontal spacing" = "Horizontal spacing"
|
||||
"Hosted by {operator}" = "Hosted by {operator}"
|
||||
"Hosting a session needs the desktop app; here you can join one." = "Hosting a session needs the desktop app; here you can join one."
|
||||
"Hover a layer to see its properties" = "Hover a layer to see its properties"
|
||||
"Hover other layers to measure the distance" = "Hover other layers to measure the distance"
|
||||
|
|
@ -800,7 +807,6 @@ name = "English"
|
|||
"Offset" = "Offset"
|
||||
"On click" = "On click"
|
||||
"On drag" = "On drag"
|
||||
"On the server's machine, `figcraft-server key` prints it." = "On the server's machine, `figcraft-server key` prints it."
|
||||
"On this page" = "On this page"
|
||||
"Only the first changed layers are listed." = "Only the first changed layers are listed."
|
||||
"Only the first {n} changed layers are listed." = "Only the first {n} changed layers are listed."
|
||||
|
|
@ -837,6 +843,8 @@ name = "English"
|
|||
"Overlay position" = "Overlay position"
|
||||
"Overridden here (the collection it extends has another value)" = "Overridden here (the collection it extends has another value)"
|
||||
"Overrides" = "Overrides"
|
||||
"Owner token" = "Owner token"
|
||||
"Owner token copied" = "Owner token copied"
|
||||
"PDF print options" = "PDF print options"
|
||||
"PDF print settings" = "PDF print settings"
|
||||
"Padding" = "Padding"
|
||||
|
|
@ -919,6 +927,7 @@ name = "English"
|
|||
"Published version {v}: save the file so other files can use it" = "Published version {v}: save the file so other files can use it"
|
||||
"Published · {people}" = "Published · {people}"
|
||||
"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Publishing to a server needs the desktop app; in the browser you can join a server's files"
|
||||
"Publishing with the server's key." = "Publishing with the server's key."
|
||||
"Publishing…" = "Publishing…"
|
||||
"Push" = "Push"
|
||||
"Push changes to main component" = "Push changes to main component"
|
||||
|
|
@ -974,6 +983,8 @@ name = "English"
|
|||
"Replace with" = "Replace with"
|
||||
"Replace with a new interaction" = "Replace with a new interaction"
|
||||
"Reply" = "Reply"
|
||||
"Report" = "Report"
|
||||
"Report this file…" = "Report this file…"
|
||||
"Reset" = "Reset"
|
||||
"Reset adjustments" = "Reset adjustments"
|
||||
"Reset all changes" = "Reset all changes"
|
||||
|
|
@ -1063,9 +1074,11 @@ name = "English"
|
|||
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels"
|
||||
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels"
|
||||
"Send backward" = "Send backward"
|
||||
"Send report" = "Send report"
|
||||
"Send to back" = "Send to back"
|
||||
"Server address (ws://host:47900/)" = "Server address (ws://host:47900/)"
|
||||
"Server address" = "Server address"
|
||||
"Server to publish to" = "Server to publish to"
|
||||
"Set as default" = "Set as default"
|
||||
"Set variable" = "Set variable"
|
||||
"Set variable mode" = "Set variable mode"
|
||||
|
|
@ -1124,6 +1137,7 @@ name = "English"
|
|||
"Space evenly" = "Space evenly"
|
||||
"Space+drag" = "Space+drag"
|
||||
"Spacing" = "Spacing"
|
||||
"Spam, stolen work, abuse…" = "Spam, stolen work, abuse…"
|
||||
"Splay" = "Splay"
|
||||
"Spotlight me" = "Spotlight me"
|
||||
"Spread" = "Spread"
|
||||
|
|
@ -1181,8 +1195,10 @@ name = "English"
|
|||
"Take the values from the first selected layer" = "Take the values from the first selected layer"
|
||||
"Taper" = "Taper"
|
||||
"Taper both ends" = "Taper both ends"
|
||||
"Tell the server's operator what is wrong with it" = "Tell the server's operator what is wrong with it"
|
||||
"Temperature" = "Temperature"
|
||||
"Template" = "Template"
|
||||
"Terms" = "Terms"
|
||||
"Text" = "Text"
|
||||
"Text align" = "Text align"
|
||||
"Text content" = "Text content"
|
||||
|
|
@ -1195,6 +1211,7 @@ name = "English"
|
|||
"The file now holds another library (\"{name}\")" = "The file now holds another library (\"{name}\")"
|
||||
"The link carries the session's secret: share it only with people you invite." = "The link carries the session's secret: share it only with people you invite."
|
||||
"The selection uses several text styles: click to apply one" = "The selection uses several text styles: click to apply one"
|
||||
"The server deletes this file if nobody opens it before {date}." = "The server deletes this file if nobody opens it before {date}."
|
||||
"The whole file" = "The whole file"
|
||||
"Theme" = "Theme"
|
||||
"There's no destination for that action" = "There's no destination for that action"
|
||||
|
|
@ -1206,6 +1223,8 @@ name = "English"
|
|||
"This file has unsaved changes" = "This file has unsaved changes"
|
||||
"This frame is new on the branch." = "This frame is new on the branch."
|
||||
"This frame isn't on the branch." = "This frame isn't on the branch."
|
||||
"This server is open to the public: no key needed." = "This server is open to the public: no key needed."
|
||||
"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)."
|
||||
"Tidy up" = "Tidy up"
|
||||
"Tile" = "Tile"
|
||||
"Tile type" = "Tile type"
|
||||
|
|
@ -1302,6 +1321,7 @@ name = "English"
|
|||
"Wedge" = "Wedge"
|
||||
"Weight" = "Weight"
|
||||
"What changed since it was marked ready for dev" = "What changed since it was marked ready for dev"
|
||||
"What is wrong with this file? The server's operator gets your report." = "What is wrong with this file? The server's operator gets your report."
|
||||
"What it's for" = "What it's for"
|
||||
"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on."
|
||||
"While hovering" = "While hovering"
|
||||
|
|
@ -1365,6 +1385,7 @@ name = "English"
|
|||
"{instances} in {files}" = "{instances} in {files}"
|
||||
"{language}: draft translation, waiting for a native speaker's review." = "{language}: draft translation, waiting for a native speaker's review."
|
||||
"{length} bleed" = "{length} bleed"
|
||||
"{name} is on the server." = "{name} is on the server."
|
||||
"{name} is spotlighting" = "{name} is spotlighting"
|
||||
"{name}\nClick to apply to the selection" = "{name}\nClick to apply to the selection"
|
||||
"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it."
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ status = "draft — needs native review"
|
|||
"Arrange" = "Organizar"
|
||||
"Arrow" = "Flecha"
|
||||
"As typed" = "Como se escribió"
|
||||
"Asking the server…" = "Consultando al servidor…"
|
||||
"Assets" = "Recursos"
|
||||
"Auto" = "Auto"
|
||||
"Auto (space around)" = "Auto (espacio alrededor)"
|
||||
|
|
@ -283,6 +284,7 @@ status = "draft — needs native review"
|
|||
"Copy as PNG" = "Copiar como PNG"
|
||||
"Copy as SVG" = "Copiar como SVG"
|
||||
"Copy link" = "Copiar enlace"
|
||||
"Copy the owner token and keep it somewhere safe: it is the only way to revoke these links, rename the file or delete it from the server, and it won't be shown again." = "Copia el token de propietario y guárdalo en un lugar seguro: es la única forma de revocar estos enlaces, cambiar el nombre del archivo o eliminarlo del servidor, y no se volverá a mostrar."
|
||||
"Copy the {label}" = "Copiar {label}"
|
||||
"Copy {value}" = "Copiar {value}"
|
||||
"Corner radius" = "Radio de esquina"
|
||||
|
|
@ -443,6 +445,9 @@ status = "draft — needs native review"
|
|||
"Fetching from the server…" = "Obteniendo del servidor…"
|
||||
"File" = "Archivo"
|
||||
"File name" = "Nombre del archivo"
|
||||
"File published" = "Archivo publicado"
|
||||
"Files aren't end-to-end encrypted: the server's operator can read them." = "Los archivos no tienen cifrado de extremo a extremo: el operador del servidor puede leerlos."
|
||||
"Files nobody opens for {n} days are deleted." = { one = "Los archivos que nadie abre en {n} día se eliminan.", other = "Los archivos que nadie abre en {n} días se eliminan." }
|
||||
"Fill" = "Relleno"
|
||||
"Fill (fr)" = "Llenar (fr)"
|
||||
"Fill container" = "Llenar contenedor"
|
||||
|
|
@ -498,6 +503,7 @@ status = "draft — needs native review"
|
|||
"Gap between columns" = "Espacio entre columnas"
|
||||
"Gap between items" = "Espacio entre elementos"
|
||||
"Gap between rows" = "Espacio entre filas"
|
||||
"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "Indica la dirección de un servidor abierto al público, o la dirección de un servidor con su clave (`figcraft-server key` la muestra)."
|
||||
"Glass" = "Vidrio"
|
||||
"Go to main component" = "Ir al componente principal"
|
||||
"Go to {name}" = "Ir a {name}"
|
||||
|
|
@ -540,6 +546,7 @@ status = "draft — needs native review"
|
|||
"Horizontal hex" = "Hexágono horizontal"
|
||||
"Horizontal padding" = "Margen interno horizontal"
|
||||
"Horizontal spacing" = "Espaciado horizontal"
|
||||
"Hosted by {operator}" = "Alojado por {operator}"
|
||||
"Hosting a session needs the desktop app; here you can join one." = "Para alojar una sesión necesitas la app de escritorio; aquí puedes unirte a una."
|
||||
"Hover a layer to see its properties" = "Pasa el puntero sobre una capa para ver sus propiedades"
|
||||
"Hover other layers to measure the distance" = "Pasa sobre otras capas para medir la distancia"
|
||||
|
|
@ -801,7 +808,6 @@ status = "draft — needs native review"
|
|||
"Offset" = "Desplazamiento"
|
||||
"On click" = "Al hacer clic"
|
||||
"On drag" = "Al arrastrar"
|
||||
"On the server's machine, `figcraft-server key` prints it." = "En el equipo del servidor, `figcraft-server key` la muestra."
|
||||
"On this page" = "En esta página"
|
||||
"Only the first changed layers are listed." = "Solo se muestran las primeras capas cambiadas."
|
||||
"Only the first {n} changed layers are listed." = "Solo se muestran las primeras {n} capas cambiadas."
|
||||
|
|
@ -838,6 +844,8 @@ status = "draft — needs native review"
|
|||
"Overlay position" = "Posición de la superposición"
|
||||
"Overridden here (the collection it extends has another value)" = "Modificado aquí (la colección que extiende tiene otro valor)"
|
||||
"Overrides" = "Modificaciones"
|
||||
"Owner token" = "Token de propietario"
|
||||
"Owner token copied" = "Token de propietario copiado"
|
||||
"PDF print options" = "Opciones de impresión PDF"
|
||||
"PDF print settings" = "Ajustes de impresión PDF"
|
||||
"Padding" = "Margen interno"
|
||||
|
|
@ -920,6 +928,7 @@ status = "draft — needs native review"
|
|||
"Published version {v}: save the file so other files can use it" = "Versión {v} publicada: guarda el archivo para que otros archivos puedan usarla"
|
||||
"Published · {people}" = "Publicado · {people}"
|
||||
"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Para publicar en un servidor necesitas la app de escritorio; en el navegador puedes unirte a archivos de un servidor"
|
||||
"Publishing with the server's key." = "Se publica con la clave del servidor."
|
||||
"Publishing…" = "Publicando…"
|
||||
"Push" = "Empujar"
|
||||
"Push changes to main component" = "Enviar cambios al componente principal"
|
||||
|
|
@ -975,6 +984,8 @@ status = "draft — needs native review"
|
|||
"Replace with" = "Reemplazar con"
|
||||
"Replace with a new interaction" = "Reemplazar por una interacción nueva"
|
||||
"Reply" = "Responder"
|
||||
"Report" = "Denuncia"
|
||||
"Report this file…" = "Denunciar este archivo…"
|
||||
"Reset" = "Restablecer"
|
||||
"Reset adjustments" = "Restablecer ajustes"
|
||||
"Reset all changes" = "Restablecer todos los cambios"
|
||||
|
|
@ -1064,9 +1075,11 @@ status = "draft — needs native review"
|
|||
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Cuadro de selección, {w} por {h} en {x}, {y}: las flechas lo mueven, Mayús+flechas o {cmd}+flechas cambian su tamaño, Intro selecciona, Esc cancela"
|
||||
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Cuadro de selección: las flechas lo mueven, Mayús+flechas o {cmd}+flechas cambian su tamaño, Intro selecciona, Esc cancela"
|
||||
"Send backward" = "Enviar atrás"
|
||||
"Send report" = "Enviar denuncia"
|
||||
"Send to back" = "Enviar al fondo"
|
||||
"Server address (ws://host:47900/)" = "Dirección del servidor (ws://host:47900/)"
|
||||
"Server address" = "Dirección del servidor"
|
||||
"Server to publish to" = "Servidor donde publicar"
|
||||
"Set as default" = "Establecer como predeterminado"
|
||||
"Set variable" = "Establecer variable"
|
||||
"Set variable mode" = "Establecer modo de variable"
|
||||
|
|
@ -1125,6 +1138,7 @@ status = "draft — needs native review"
|
|||
"Space evenly" = "Espacio uniforme"
|
||||
"Space+drag" = "Espacio+arrastrar"
|
||||
"Spacing" = "Espaciado"
|
||||
"Spam, stolen work, abuse…" = "Spam, obra robada, abuso…"
|
||||
"Splay" = "Apertura"
|
||||
"Spotlight me" = "Ponerme en el foco"
|
||||
"Spread" = "Extensión"
|
||||
|
|
@ -1182,8 +1196,10 @@ status = "draft — needs native review"
|
|||
"Take the values from the first selected layer" = "Tomar los valores de la primera capa seleccionada"
|
||||
"Taper" = "Afinado"
|
||||
"Taper both ends" = "Afinar ambos extremos"
|
||||
"Tell the server's operator what is wrong with it" = "Explica al operador del servidor qué tiene de malo"
|
||||
"Temperature" = "Temperatura"
|
||||
"Template" = "Plantilla"
|
||||
"Terms" = "Condiciones"
|
||||
"Text" = "Texto"
|
||||
"Text align" = "Alineación del texto"
|
||||
"Text content" = "Contenido de texto"
|
||||
|
|
@ -1196,6 +1212,7 @@ status = "draft — needs native review"
|
|||
"The file now holds another library (\"{name}\")" = "El archivo ahora contiene otra biblioteca (\"{name}\")"
|
||||
"The link carries the session's secret: share it only with people you invite." = "El enlace lleva la clave secreta de la sesión: compártelo solo con quienes invites."
|
||||
"The selection uses several text styles: click to apply one" = "La selección usa varios estilos de texto: haz clic para aplicar uno"
|
||||
"The server deletes this file if nobody opens it before {date}." = "El servidor elimina este archivo si nadie lo abre antes del {date}."
|
||||
"The whole file" = "El archivo completo"
|
||||
"Theme" = "Tema"
|
||||
"There's no destination for that action" = "No hay destino para esa acción"
|
||||
|
|
@ -1207,6 +1224,8 @@ status = "draft — needs native review"
|
|||
"This file has unsaved changes" = "Este archivo tiene cambios sin guardar"
|
||||
"This frame is new on the branch." = "Este marco es nuevo en la rama."
|
||||
"This frame isn't on the branch." = "Este marco no está en la rama."
|
||||
"This server is open to the public: no key needed." = "Este servidor está abierto al público: no hace falta clave."
|
||||
"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Este servidor necesita su clave: añade ?key=… a la dirección (`figcraft-server key` la muestra en el equipo del servidor)."
|
||||
"Tidy up" = "Ordenar"
|
||||
"Tile" = "Mosaico"
|
||||
"Tile type" = "Tipo de mosaico"
|
||||
|
|
@ -1303,6 +1322,7 @@ status = "draft — needs native review"
|
|||
"Wedge" = "Cuña"
|
||||
"Weight" = "Grosor"
|
||||
"What changed since it was marked ready for dev" = "Qué cambió desde que se marcó como listo para desarrollo"
|
||||
"What is wrong with this file? The server's operator gets your report." = "¿Qué tiene de malo este archivo? El operador del servidor recibirá tu denuncia."
|
||||
"What it's for" = "Para qué sirve"
|
||||
"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "No se sabe desde qué cambió: cambió antes de que FigCraft guardara copias de los marcos listos para desarrollo. Resuelve el cambio (o vuelve a marcarlo como listo) para comparar a partir de ahora."
|
||||
"While hovering" = "Mientras se pasa el puntero"
|
||||
|
|
@ -1366,6 +1386,7 @@ status = "draft — needs native review"
|
|||
"{instances} in {files}" = "{instances} en {files}"
|
||||
"{language}: draft translation, waiting for a native speaker's review." = "{language}: traducción en borrador, pendiente de revisión por un hablante nativo."
|
||||
"{length} bleed" = "sangrado de {length}"
|
||||
"{name} is on the server." = "{name} está en el servidor."
|
||||
"{name} is spotlighting" = "{name} está en el foco"
|
||||
"{name}\nClick to apply to the selection" = "{name}\nHaz clic para aplicarlo a la selección"
|
||||
"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nArrastra al lienzo o haz clic para colocarlo en el centro de la vista.\nMantén Alt (Opción) al soltar sobre una instancia para intercambiarla."
|
||||
|
|
@ -1479,9 +1500,12 @@ status = "draft — needs native review"
|
|||
"collab.host" = "Iniciar sesión en vivo"
|
||||
"collab.join" = "Unirse a una sesión en vivo"
|
||||
"collab.leave" = "Salir de la sesión en vivo"
|
||||
"collab.manageFile" = "Gestionar archivo publicado"
|
||||
"collab.peers" = "Personas en este archivo"
|
||||
"collab.publish" = "Publicar en el servidor"
|
||||
"collab.registerLinks" = "Abrir enlaces de invitación en FigCraft"
|
||||
"collab.report" = "Denunciar archivo al operador del servidor"
|
||||
"collab.serverInfo" = "Información del servidor"
|
||||
"collab.spotlight" = "Ponerme en el foco"
|
||||
"collab.stop" = "Detener sesión en vivo"
|
||||
"command.batch" = "Ejecutar varios comandos"
|
||||
|
|
@ -1700,6 +1724,7 @@ status = "draft — needs native review"
|
|||
"pref.language" = "Idioma de la interfaz"
|
||||
"pref.nudge" = "Cantidad de desplazamiento"
|
||||
"pref.numberKeysOpacity" = "Usar teclas numéricas para la opacidad"
|
||||
"pref.publishServer" = "Servidor donde publicar"
|
||||
"pref.reduceMotion" = "Reducir movimiento"
|
||||
"pref.renameDuplicates" = "Renombrar capas duplicadas"
|
||||
"pref.showDimensions" = "Mostrar dimensiones en los objetos"
|
||||
|
|
|
|||
|
|
@ -799,7 +799,6 @@ status = "draft — needs native review"
|
|||
"Offset" = "Décalage"
|
||||
"On click" = "Au clic"
|
||||
"On drag" = "Au glissement"
|
||||
"On the server's machine, `figcraft-server key` prints it." = "Sur la machine du serveur, `figcraft-server key` l’affiche."
|
||||
"On this page" = "Sur cette page"
|
||||
"Only the first changed layers are listed." = "Seuls les premiers calques modifiés sont listés."
|
||||
"Only the first {n} changed layers are listed." = "Seuls les {n} premiers calques modifiés sont listés."
|
||||
|
|
|
|||
|
|
@ -801,7 +801,6 @@ status = "draft — needs native review"
|
|||
"Offset" = "オフセット"
|
||||
"On click" = "クリック時"
|
||||
"On drag" = "ドラッグ時"
|
||||
"On the server's machine, `figcraft-server key` prints it." = "サーバーのマシンで `figcraft-server key` を実行すると表示される。"
|
||||
"On this page" = "このページ"
|
||||
"Only the first changed layers are listed." = "変更されたレイヤーは最初の分のみ表示。"
|
||||
"Only the first {n} changed layers are listed." = "変更されたレイヤーは最初の {n} 個のみ表示。"
|
||||
|
|
|
|||
|
|
@ -800,7 +800,6 @@ status = "draft — needs native review"
|
|||
"Offset" = "오프셋"
|
||||
"On click" = "클릭 시"
|
||||
"On drag" = "드래그 시"
|
||||
"On the server's machine, `figcraft-server key` prints it." = "서버 컴퓨터에서 `figcraft-server key`를 실행하면 표시됨."
|
||||
"On this page" = "이 페이지"
|
||||
"Only the first changed layers are listed." = "변경된 레이어는 처음 일부만 표시됨."
|
||||
"Only the first {n} changed layers are listed." = "변경된 레이어는 처음 {n}개만 표시됨."
|
||||
|
|
|
|||
|
|
@ -798,7 +798,6 @@ status = "draft — needs native review"
|
|||
"Offset" = "Deslocamento"
|
||||
"On click" = "Ao clicar"
|
||||
"On drag" = "Ao arrastar"
|
||||
"On the server's machine, `figcraft-server key` prints it." = "Na máquina do servidor, `figcraft-server key` a exibe."
|
||||
"On this page" = "Nesta página"
|
||||
"Only the first changed layers are listed." = "Somente as primeiras camadas alteradas são listadas."
|
||||
"Only the first {n} changed layers are listed." = "Somente as primeiras {n} camadas alteradas são listadas."
|
||||
|
|
|
|||
|
|
@ -801,7 +801,6 @@ status = "draft — needs native review"
|
|||
"Offset" = "偏移"
|
||||
"On click" = "点击时"
|
||||
"On drag" = "拖动时"
|
||||
"On the server's machine, `figcraft-server key` prints it." = "在服务器所在机器上运行 `figcraft-server key` 即可显示。"
|
||||
"On this page" = "在此页面"
|
||||
"Only the first changed layers are listed." = "仅列出前几个已更改的图层。"
|
||||
"Only the first {n} changed layers are listed." = "仅列出前 {n} 个已更改的图层。"
|
||||
|
|
|
|||
|
|
@ -185,6 +185,14 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
|
|||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tr("Server to publish to"));
|
||||
// Suggested by Share > Publish to server; no key here (preferences are saved as text).
|
||||
if let Some(url) = crate::widgets::text_field(ui, egui::Id::new("pref_publish_server"), &prefs.publish_server, 220.0, "wss://…") {
|
||||
app.run("pref.publishServer", json!({"url": url}));
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tr("Nudge amount"));
|
||||
let mut small = prefs.nudge_small;
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ use std::collections::BTreeMap;
|
|||
|
||||
use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Shape, Stroke, Ui, pos2, vec2};
|
||||
use figcraft_engine::live::{Access, PEOPLE, Role, Signal, Status, initials, parse_color, presence_page};
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::FigApp;
|
||||
use crate::a11y::{self, Tip};
|
||||
|
|
@ -40,6 +40,14 @@ const PUBLISHED_NOTE: &str = tr_noop(
|
|||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them.",
|
||||
);
|
||||
|
||||
/// Why the owner token matters (shown once, when a file is published without the server key).
|
||||
const OWNER_NOTE: &str = tr_noop(
|
||||
"Copy the owner token and keep it somewhere safe: it is the only way to revoke these links, rename the file or delete it from the server, and it won't be shown again.",
|
||||
);
|
||||
|
||||
/// What a server open to the public can see.
|
||||
const PLAIN_NOTE: &str = tr_noop("Files aren't end-to-end encrypted: the server's operator can read them.");
|
||||
|
||||
/// How fast a remote cursor closes in on where it was last reported (per second).
|
||||
const GLIDE: f64 = 20.0;
|
||||
/// Most of one person's selection outlined.
|
||||
|
|
@ -84,6 +92,12 @@ pub struct LiveUi {
|
|||
focus_publish: bool,
|
||||
/// The last server address published to (kept for this run only: it carries the server key).
|
||||
last_server: String,
|
||||
/// A file published from the dialog without the key: its owner token shows once it arrives.
|
||||
show_owner: bool,
|
||||
/// The report being written about the file you're in (open when `Some`), and putting the
|
||||
/// keyboard in its field on its next frame.
|
||||
pub report: Option<String>,
|
||||
focus_report: bool,
|
||||
/// Others' chat messages as last seen, with when they changed (UI clock): they fade from then.
|
||||
chat_seen: BTreeMap<u32, (String, f64)>,
|
||||
/// Your cursor chat (open when `Some`).
|
||||
|
|
@ -97,6 +111,8 @@ pub struct PublishForm {
|
|||
pub server: String,
|
||||
/// The file's name on the server.
|
||||
pub name: String,
|
||||
/// What the server said about its hosting (`collab.serverInfo`), and the address asked.
|
||||
pub info: Option<(String, Value)>,
|
||||
}
|
||||
|
||||
impl LiveUi {
|
||||
|
|
@ -613,14 +629,88 @@ fn guest_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
|||
widgets::label(ui, &trf("Pages: {pages}", &[("pages", &pages.join(", "))]), t.text_secondary);
|
||||
}
|
||||
}
|
||||
let hosting = live.hosting.clone();
|
||||
if let Some(p) = live.published.clone() {
|
||||
link_rows(app, ui, &[(tr("Edit link"), &p.edit, tr("Edit link copied")), (tr("View link"), &p.view, tr("View link copied"))]);
|
||||
widgets::label(ui, tr(PUBLISHED_NOTE), t.text_secondary);
|
||||
}
|
||||
if let Some(h) = &hosting {
|
||||
host_line(ui, t, &h.operator, &h.terms);
|
||||
expiry_line(ui, t, h.expires);
|
||||
}
|
||||
widgets::label(ui, tr(if view { VIEW_NOTE } else { UNDO_NOTE }), t.text_secondary);
|
||||
if widgets::button(ui, tr("Leave session"), false, true).on_hover_text(tr("Your copy of the file stays open")).clicked() {
|
||||
app.run("collab.leave", json!({}));
|
||||
}
|
||||
// A server that takes reports said so (its hosting).
|
||||
if hosting.is_some() && app.session.live.status == Status::Live {
|
||||
report_ui(app, ui, t);
|
||||
}
|
||||
}
|
||||
|
||||
/// "Hosted by …" and a link to the terms, when the server names them.
|
||||
fn host_line(ui: &mut Ui, t: &Tokens, operator: &str, terms: &str) {
|
||||
if operator.is_empty() && terms.is_empty() {
|
||||
return;
|
||||
}
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
if !operator.is_empty() {
|
||||
widgets::label(ui, &trf("Hosted by {operator}", &[("operator", &operator)]), t.text_secondary);
|
||||
}
|
||||
if !terms.is_empty() {
|
||||
if !operator.is_empty() {
|
||||
widgets::label(ui, "·", t.text_secondary);
|
||||
}
|
||||
ui.hyperlink_to(egui::RichText::new(tr("Terms")).font(regular(11.0)), terms).on_hover_text(terms);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// When the server deletes the file unless someone opens it.
|
||||
fn expiry_line(ui: &mut Ui, t: &Tokens, expires: Option<u64>) {
|
||||
if let Some(e) = expires {
|
||||
let date = figcraft_engine::live::utc_date(e);
|
||||
widgets::label(ui, &trf("The server deletes this file if nobody opens it before {date}.", &[("date", &date)]), t.text_secondary);
|
||||
}
|
||||
}
|
||||
|
||||
/// Report the file you're in to the server's operator: a button, then a reason to send.
|
||||
fn report_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
let Some(mut reason) = app.ui.live.report.take() else {
|
||||
if widgets::button(ui, tr("Report this file…"), false, true).on_hover_text(tr("Tell the server's operator what is wrong with it")).clicked()
|
||||
{
|
||||
app.ui.live.report = Some(String::new());
|
||||
app.ui.live.focus_report = true;
|
||||
}
|
||||
return;
|
||||
};
|
||||
widgets::label(ui, tr("What is wrong with this file? The server's operator gets your report."), t.text);
|
||||
let resp = ui.add(
|
||||
egui::TextEdit::singleline(&mut reason)
|
||||
.hint_text(tr("Spam, stolen work, abuse…"))
|
||||
.char_limit(figcraft_engine::live::MAX_REPORT)
|
||||
.desired_width(ui.available_width())
|
||||
.id(egui::Id::new("live_report_reason")),
|
||||
);
|
||||
a11y::text(&resp, tr("Report"), &reason);
|
||||
if std::mem::take(&mut app.ui.live.focus_report) {
|
||||
resp.request_focus();
|
||||
}
|
||||
let mut keep = true;
|
||||
ui.horizontal(|ui| {
|
||||
let send = widgets::button(ui, tr("Send report"), true, !reason.trim().is_empty()).clicked()
|
||||
|| (resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)));
|
||||
if send && !reason.trim().is_empty() && app.run("collab.report", json!({"reason": reason.trim()})).is_some() {
|
||||
keep = false;
|
||||
}
|
||||
if widgets::button(ui, tr("Cancel"), false, true).clicked() {
|
||||
keep = false;
|
||||
}
|
||||
});
|
||||
if keep {
|
||||
app.ui.live.report = Some(reason);
|
||||
}
|
||||
}
|
||||
|
||||
/// Links to copy: a label, the link (elided, monospace) and a Copy button each.
|
||||
|
|
@ -704,25 +794,59 @@ pub fn join_dialog(app: &mut FigApp, ctx: &egui::Context) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Open the Publish to server dialog for the open file (the last address used this run in it).
|
||||
/// Open the Publish to server dialog for the open file (the last address used this run in it,
|
||||
/// else the one in Preferences).
|
||||
pub fn open_publish(app: &mut FigApp) {
|
||||
if !app.session.live.can_host() {
|
||||
app.toast(tr("Publishing to a server needs the desktop app; in the browser you can join a server's files"));
|
||||
return;
|
||||
}
|
||||
let name = app.session.doc().map(|d| d.name.clone()).unwrap_or_default();
|
||||
app.ui.live.publish = Some(PublishForm { server: app.ui.live.last_server.clone(), name });
|
||||
let server = if app.ui.live.last_server.is_empty() { app.session.prefs.publish_server.clone() } else { app.ui.live.last_server.clone() };
|
||||
app.ui.live.publish = Some(PublishForm { server, name, info: None });
|
||||
app.ui.live.focus_publish = true;
|
||||
}
|
||||
|
||||
/// Share ▸ Publish to server…: the server's address (with its key) and the file's name.
|
||||
/// The address carries a server key (`?key=…`).
|
||||
fn has_key(server: &str) -> bool {
|
||||
server.split_once('?').is_some_and(|(_, q)| q.split('&').any(|kv| kv.strip_prefix("key=").is_some_and(|k| !k.trim().is_empty())))
|
||||
}
|
||||
|
||||
/// Ask the server at `server` about its hosting in the background; the answer goes into the
|
||||
/// Publish dialog while it still shows that address.
|
||||
fn ask_server(app: &mut FigApp, ctx: &egui::Context, server: String) {
|
||||
app.run_background(
|
||||
ctx,
|
||||
SERVER_INFO,
|
||||
json!({"server": server}),
|
||||
Box::new(move |app, _, v| {
|
||||
if let Some(f) = app.ui.live.publish.as_mut().filter(|f| f.server.trim() == server) {
|
||||
f.info = Some((server, v));
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const SERVER_INFO: &str = "collab.serverInfo";
|
||||
|
||||
/// Share ▸ Publish to server…: the server's address (with its key, unless the server is open to
|
||||
/// the public: it is asked first) and the file's name.
|
||||
pub fn publish_dialog(app: &mut FigApp, ctx: &egui::Context) {
|
||||
published_dialog(app, ctx);
|
||||
let Some(mut form) = app.ui.live.publish.take() else { return };
|
||||
let t = Tokens::get(ctx);
|
||||
let mut open = true;
|
||||
let mut cancel = false;
|
||||
let mut go = false;
|
||||
let mut ask = false;
|
||||
let focus = std::mem::take(&mut app.ui.live.focus_publish);
|
||||
let address = form.server.trim().to_string();
|
||||
let keyed = has_key(&address);
|
||||
let info = form.info.as_ref().filter(|(at, _)| *at == address).map(|(_, v)| v.clone());
|
||||
let asking = app.session.waiting_for(SERVER_INFO);
|
||||
let open_server = info.as_ref().is_some_and(|v| v["open"] == true);
|
||||
// A first look at an address already filled in.
|
||||
ask |= focus && !address.is_empty() && !keyed && info.is_none();
|
||||
crate::dialogs::window(tr("Publish to server"), ctx).id(egui::Id::new("Publish to server")).open(&mut open).show(ctx, |ui| {
|
||||
ui.set_width(380.0);
|
||||
widgets::label(
|
||||
|
|
@ -733,34 +857,104 @@ pub fn publish_dialog(app: &mut FigApp, ctx: &egui::Context) {
|
|||
widgets::label(ui, tr("Server address"), t.text_secondary);
|
||||
let resp = ui.add(
|
||||
egui::TextEdit::singleline(&mut form.server)
|
||||
.hint_text("ws://server:47900/?key=…")
|
||||
.hint_text("wss://server/ or ws://server:47900/?key=…")
|
||||
.desired_width(380.0)
|
||||
.id(egui::Id::new("live_publish_server")),
|
||||
);
|
||||
// The address carries the server key: it isn't read out.
|
||||
// The address may carry the server key: it isn't read out.
|
||||
a11y::text(&resp, tr("Server address"), "");
|
||||
if focus {
|
||||
resp.request_focus();
|
||||
}
|
||||
let enter = |ui: &Ui| ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
go = resp.lost_focus() && enter(ui);
|
||||
widgets::label(ui, tr("On the server's machine, `figcraft-server key` prints it."), t.text_secondary);
|
||||
// Leaving the field (or Enter) asks a server without a key whether it is open.
|
||||
let left = resp.lost_focus() && !address.is_empty() && !keyed && info.is_none() && !asking;
|
||||
go = resp.lost_focus() && enter(ui) && (keyed || open_server);
|
||||
ask |= left;
|
||||
match &info {
|
||||
_ if keyed => widgets::label(ui, tr("Publishing with the server's key."), t.text_secondary),
|
||||
_ if asking => widgets::label(ui, tr("Asking the server…"), t.text_secondary),
|
||||
Some(v) if open_server => {
|
||||
widgets::label(ui, tr("This server is open to the public: no key needed."), t.success);
|
||||
host_line(ui, &t, v["operator"].as_str().unwrap_or_default(), v["terms"].as_str().unwrap_or_default());
|
||||
if let Some(days) = v["idleDays"].as_f64() {
|
||||
let n = days.ceil().max(1.0) as usize;
|
||||
widgets::label(ui, &trn("Files nobody opens for {n} days are deleted.", n, &[]), t.text_secondary);
|
||||
}
|
||||
widgets::label(ui, tr(PLAIN_NOTE), t.text_secondary);
|
||||
}
|
||||
Some(_) => widgets::label(
|
||||
ui,
|
||||
tr("This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)."),
|
||||
t.danger,
|
||||
),
|
||||
None => widgets::label(
|
||||
ui,
|
||||
tr("Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)."),
|
||||
t.text_secondary,
|
||||
),
|
||||
}
|
||||
widgets::label(ui, tr("File name"), t.text_secondary);
|
||||
let nresp = ui.add(egui::TextEdit::singleline(&mut form.name).desired_width(380.0).id(egui::Id::new("live_publish_name")));
|
||||
a11y::text(&nresp, tr("File name"), &form.name);
|
||||
go |= nresp.lost_focus() && enter(ui);
|
||||
go |= nresp.lost_focus() && enter(ui) && (keyed || open_server);
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
go |= widgets::button(ui, tr("Publish"), true, !form.server.trim().is_empty()).clicked();
|
||||
go |= widgets::button(ui, tr("Publish"), true, keyed || open_server).clicked();
|
||||
cancel = widgets::button(ui, tr("Cancel"), false, true).clicked();
|
||||
});
|
||||
});
|
||||
if go && !form.server.trim().is_empty() && app.run("collab.publish", json!({"server": form.server.trim(), "name": form.name.trim()})).is_some() {
|
||||
app.ui.live.last_server = form.server.trim().to_string();
|
||||
if ask {
|
||||
ask_server(app, ctx, address.clone());
|
||||
}
|
||||
if go && app.run("collab.publish", json!({"server": address, "name": form.name.trim()})).is_some() {
|
||||
app.ui.live.last_server = address;
|
||||
// The owner token shows once the file is on the server.
|
||||
app.ui.live.show_owner = !keyed;
|
||||
app.cancel_background(SERVER_INFO);
|
||||
return;
|
||||
}
|
||||
if open && !cancel {
|
||||
app.ui.live.publish = Some(form);
|
||||
} else {
|
||||
app.cancel_background(SERVER_INFO);
|
||||
}
|
||||
}
|
||||
|
||||
/// The file just published from the dialog without the key: its links and its owner token, shown
|
||||
/// this once, with where it is hosted and when it expires.
|
||||
fn published_dialog(app: &mut FigApp, ctx: &egui::Context) {
|
||||
if !app.ui.live.show_owner {
|
||||
return;
|
||||
}
|
||||
let live = &app.session.live;
|
||||
let Some(p) = live.published.clone().filter(|p| p.owner.is_some()) else {
|
||||
// Still on its way, or it didn't get there (the message says why).
|
||||
app.ui.live.show_owner = live.status == Status::Connecting;
|
||||
return;
|
||||
};
|
||||
let hosting = live.hosting.clone();
|
||||
let owner = p.owner.clone().unwrap_or_default();
|
||||
let t = Tokens::get(ctx);
|
||||
let mut open = true;
|
||||
let mut done = false;
|
||||
crate::dialogs::window(tr("File published"), ctx).id(egui::Id::new("File published")).open(&mut open).show(ctx, |ui| {
|
||||
ui.set_width(400.0);
|
||||
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
|
||||
widgets::label(ui, &trf("{name} is on the server.", &[("name", &p.name)]), t.text);
|
||||
link_rows(app, ui, &[(tr("Owner token"), &owner, tr("Owner token copied"))]);
|
||||
widgets::label(ui, tr(OWNER_NOTE), t.danger);
|
||||
link_rows(app, ui, &[(tr("Edit link"), &p.edit, tr("Edit link copied")), (tr("View link"), &p.view, tr("View link copied"))]);
|
||||
expiry_line(ui, &t, p.expires);
|
||||
if let Some(h) = &hosting {
|
||||
host_line(ui, &t, &h.operator, &h.terms);
|
||||
}
|
||||
widgets::label(ui, tr(PLAIN_NOTE), t.text_secondary);
|
||||
ui.add_space(4.0);
|
||||
done = widgets::button(ui, tr("Done"), true, true).clicked();
|
||||
});
|
||||
if !open || done {
|
||||
app.ui.live.show_owner = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -781,6 +975,9 @@ pub fn inspect(app: &FigApp) -> serde_json::Value {
|
|||
"cursorsShown": app.ui.live.shown(),
|
||||
"joinDialog": app.ui.live.join.is_some(),
|
||||
"publishDialog": app.ui.live.publish.is_some(),
|
||||
"publishInfo": app.ui.live.publish.as_ref().and_then(|f| f.info.as_ref().map(|(_, v)| v.clone())),
|
||||
"ownerDialog": app.ui.live.show_owner && app.session.live.published.as_ref().is_some_and(|p| p.owner.is_some()),
|
||||
"report": app.ui.live.report,
|
||||
"readOnly": app.session.active_read_only(),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
//! Files on a server in the window: the Publish to server dialog (from the File menu and the
|
||||
//! Share popover; its field takes the keyboard; Escape closes it; publishing leaves the tab
|
||||
//! read-only until the file is there), the Share popover's address, access and a published file's
|
||||
//! links, and a view link's "View only" badge with only the moving tools.
|
||||
//! read-only until the file is there; a server open to the public takes it without a key and the
|
||||
//! owner token shows once), the Share popover's address, access, a published file's links, where
|
||||
//! it is hosted, when it expires and reporting it, and a view link's "View only" badge with only
|
||||
//! the moving tools.
|
||||
|
||||
use egui::{Event, Key};
|
||||
use figcraft_engine::figcraft_collab::message::{ClientMsg, ServerMsg};
|
||||
use figcraft_engine::figcraft_collab::{Client, Limits, Outgoing, Server};
|
||||
use figcraft_engine::live::{Access, Event as LiveEvent, Published, Role, SessionInfo, Status, VIEW_ONLY};
|
||||
use figcraft_engine::live::{Access, Event as LiveEvent, HostInfo, Published, Role, SessionInfo, Status, VIEW_ONLY};
|
||||
use figcraft_engine::tools::ToolId;
|
||||
use serde_json::json;
|
||||
|
||||
|
|
@ -63,9 +65,13 @@ fn the_publish_dialog() {
|
|||
assert!(has_text(&shapes, "Or keep the file on a FigCraft server and share links to edit or to view it."));
|
||||
h.click(text_center(&shapes, "Publish to server…"));
|
||||
let shapes = h.frames(2);
|
||||
assert!(has_text(&shapes, "On the server's machine, `figcraft-server key` prints it."));
|
||||
assert!(has_text(
|
||||
&shapes,
|
||||
"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)."
|
||||
));
|
||||
h.frame(vec![Event::Text("ws://127.0.0.1:47900/?key=KeyKeyKeyKeyKeyKeyKey0".into())]);
|
||||
let shapes = h.frames(1);
|
||||
assert!(has_text(&shapes, "Publishing with the server's key."));
|
||||
h.click(text_center(&shapes, "Publish"));
|
||||
let shapes = h.frames(2);
|
||||
assert!(h.app.ui.live.publish.is_none(), "it closes once publishing starts");
|
||||
|
|
@ -74,6 +80,69 @@ fn the_publish_dialog() {
|
|||
assert_eq!(crate::live::inspect(&h.app)["readOnly"], figcraft_engine::live::PUBLISHING);
|
||||
}
|
||||
|
||||
fn hosting(expires: Option<u64>) -> HostInfo {
|
||||
HostInfo {
|
||||
open: true,
|
||||
operator: "abuse@example.com".into(),
|
||||
terms: "https://example.com/terms".into(),
|
||||
idle_secs: 30 * 86_400,
|
||||
max_file_mb: 64,
|
||||
expires,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_open_to_the_public_takes_a_file_without_a_key() {
|
||||
let (mut h, _) = Harness::new();
|
||||
crate::menus::invoke(&mut h.app, "collab.publish");
|
||||
h.frames(2);
|
||||
h.frame(vec![Event::Text("wss://figcraft.example.com/".into())]);
|
||||
let shapes = h.frames(1);
|
||||
assert!(!crate::live::inspect(&h.app)["publishDialog"].is_null());
|
||||
// Without a key and before the server said it is open, Publish waits.
|
||||
h.click(text_center(&shapes, "Publish"));
|
||||
assert!(h.app.ui.live.publish.is_some(), "nothing is sent yet");
|
||||
// The server's answer (collab.serverInfo).
|
||||
h.app.ui.live.publish.as_mut().unwrap().info = Some((
|
||||
"wss://figcraft.example.com/".into(),
|
||||
json!({"open": true, "operator": "abuse@example.com", "terms": "https://example.com/terms", "idleDays": 30.0}),
|
||||
));
|
||||
let shapes = h.frames(2);
|
||||
assert!(has_text(&shapes, "This server is open to the public: no key needed."));
|
||||
assert!(has_text(&shapes, "Hosted by abuse@example.com") && has_text(&shapes, "Terms"));
|
||||
assert!(has_text(&shapes, "Files nobody opens for 30 days are deleted."));
|
||||
assert!(has_text(&shapes, "Files aren't end-to-end encrypted: the server's operator can read them."));
|
||||
h.click(text_center(&shapes, "Publish"));
|
||||
h.frames(2);
|
||||
assert!(h.app.ui.live.publish.is_none());
|
||||
assert_eq!(h.app.session.live.status, Status::Connecting);
|
||||
// On the server: the owner token shows once, with the links and the expiry.
|
||||
let owner = "OwnerOwnerOwnerOwner00";
|
||||
h.send(vec![
|
||||
LiveEvent::Hosting(hosting(None)),
|
||||
LiveEvent::Published(Published {
|
||||
server: "wss://figcraft.example.com/".into(),
|
||||
file: "f".into(),
|
||||
name: "Poster".into(),
|
||||
edit: "figcraft://join?server=wss://figcraft.example.com/&file=f&token=EditEditEditEditEdit00".into(),
|
||||
view: "figcraft://join?server=wss://figcraft.example.com/&file=f&token=ViewViewViewViewView00".into(),
|
||||
owner: Some(owner.into()),
|
||||
expires: Some(1_800_000_000),
|
||||
}),
|
||||
]);
|
||||
let shapes = h.frames(3);
|
||||
assert_eq!(crate::live::inspect(&h.app)["ownerDialog"], true);
|
||||
assert!(has_text(&shapes, "File published") && has_text(&shapes, "Owner token") && has_text(&shapes, owner));
|
||||
assert!(has_text(
|
||||
&shapes,
|
||||
"Copy the owner token and keep it somewhere safe: it is the only way to revoke these links, rename the file or delete it from the server, and it won't be shown again."
|
||||
));
|
||||
assert!(has_text(&shapes, "The server deletes this file if nobody opens it before 2027-01-15 08:00 UTC."));
|
||||
h.click(text_center(&shapes, "Done"));
|
||||
h.frames(2);
|
||||
assert_eq!(crate::live::inspect(&h.app)["ownerDialog"], false, "shown once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_published_file_shows_its_links() {
|
||||
let (mut h, _) = Harness::new();
|
||||
|
|
@ -84,7 +153,10 @@ fn a_published_file_shows_its_links() {
|
|||
name: "Team".into(),
|
||||
edit: "figcraft://join?host=127.0.0.1:47900&file=f&token=EditEditEditEditEdit00".into(),
|
||||
view: "figcraft://join?host=127.0.0.1:47900&file=f&token=ViewViewViewViewView00".into(),
|
||||
owner: None,
|
||||
expires: None,
|
||||
});
|
||||
h.send(vec![LiveEvent::Hosting(hosting(Some(1_800_000_000)))]);
|
||||
let shapes = h.frames(2);
|
||||
h.click(text_center(&shapes, "Share"));
|
||||
let shapes = h.frames(2);
|
||||
|
|
@ -92,6 +164,18 @@ fn a_published_file_shows_its_links() {
|
|||
assert!(has_text(&shapes, "127.0.0.1:47900 · Can edit"));
|
||||
assert!(has_text(&shapes, "Edit link") && has_text(&shapes, "View link"));
|
||||
assert!(has_text(&shapes, "Leave session"));
|
||||
assert!(!has_text(&shapes, "Owner token"), "the owner token showed once, not here");
|
||||
// Where it is hosted, when it expires, and reporting it to the operator.
|
||||
assert!(has_text(&shapes, "Hosted by abuse@example.com") && has_text(&shapes, "Terms"));
|
||||
assert!(has_text(&shapes, "The server deletes this file if nobody opens it before 2027-01-15 08:00 UTC."));
|
||||
h.click(text_center(&shapes, "Report this file…"));
|
||||
let shapes = h.frames(2);
|
||||
assert!(has_text(&shapes, "What is wrong with this file? The server's operator gets your report."));
|
||||
h.app.ui.live.report = Some("Spam".into());
|
||||
let shapes = h.frames(2);
|
||||
h.click(text_center(&shapes, "Send report"));
|
||||
h.frames(2);
|
||||
assert!(h.app.ui.live.report.is_none(), "sent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -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 <folder> --server ws://host:port/[?key=KEY]`
|
||||
|
||||
|
|
|
|||
|
|
@ -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,39 @@ 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.
|
||||
- **Resources.** Open to the public, a message is at most a file's size (checked while it is read),
|
||||
a batch a file has no room for is answered with a `Reject` carrying the reason (no fixes: the
|
||||
client rolls it back), the bytes queued for a client and for all of them and the bytes waiting
|
||||
for the hub thread are capped (past them a connection is dropped), one-shot request connections
|
||||
leave an eighth of the connections to sessions, and when a file's log can't be written nothing
|
||||
of it is confirmed: everyone in it reconnects with close code 1012 and sends again what wasn't
|
||||
confirmed. Revoking a link or removing a file also cuts library connections that used it.
|
||||
- **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 +752,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 +790,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.
|
||||
|
|
|
|||
|
|
@ -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,8 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Public `figcraft-server` hardened against resource exhaustion after a security review: messages capped by what a connection is for (a file's size when open), byte-bounded send queues and a bound on bytes waiting for the hub, files checked as they grow (refused batches roll back with the reason; per-address and total caps count growth), the core's recent-batch memory bounded in bytes, no confirmation for changes that couldn't be written, team libraries only in key-published files on public servers with unpacking bounded by the file cap, usage reports kept in memory, layered IPv6 limits (/64, /56, /48), a global publish rate, connections reserved for sessions, per-address download shares, library connections cut on revoke and removal, a private data directory and key, and safer install.sh, systemd and backups. Percentages and hours unchanged |
|
||||
| 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 “<branch>”` 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) |
|
||||
|
|
|
|||
21
docs/mcp.md
21
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),
|
||||
|
|
|
|||
288
docs/server.md
288
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-<version>-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.
|
|||
<data>/files/<id>/usage.json which files use that library's components, as their apps report it
|
||||
<data>/plugins/index.json the plugin directory: every version kept (manifest fields, size, SHA-256)
|
||||
<data>/plugins/<sha256>.bin a plugin bundle, named by the SHA-256 of its bytes
|
||||
<data>/blocked.json the addresses the operator blocked
|
||||
<data>/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,14 @@ 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 checks there is twice the data directory's size free, stops the server for the seconds the copy
|
||||
takes, keeps `/var/backups/figcraft-server/figcraft-YYYY-MM-DD.tar.gz` (readable by root only) for
|
||||
7 days and starts it again. Copy that folder off the machine too. The archives hold every file,
|
||||
the addresses files came from, the reports and the **server key**: keep them, and any copy, as
|
||||
private as the server itself.
|
||||
|
||||
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 +295,197 @@ 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`, counting what files grow to by edits); for everyone: `max_files`, `max_file_mb`
|
||||
per file (also as it grows), `max_total_mb` and 32 addresses' worth of publishing an hour. An
|
||||
address is an IPv4 address or an IPv6 /64; an IPv6 /56 and /48 also count together, with 2 and 4
|
||||
times one address's limits, and a /48 never more than an eighth of any budget everyone shares
|
||||
(connections, queued bytes, bytes being read, publishing, downloads). Each refusal says why ("too many connections from your address…",
|
||||
"this server is full…") and the app shows it. See also [Resource limits](#resource-limits).
|
||||
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.
|
||||
- **Team libraries** on a public server live only in files published with the key: a file published
|
||||
without it can't hold one (a library is a whole archive the server unpacks and checks).
|
||||
- **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, within its size cap; publish its team library (on a public server only for files published with the key); report it | Reach any other file; grow it past `max_file_mb` or its publisher's share; 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 sizes by what a connection is for, file sizes as they
|
||||
grow, connections in all and per address and block, connection, message, publish and download rates
|
||||
per address and block, files and MiB per address and for everyone, the bytes queued for slow
|
||||
clients and waiting for the server, and idle expiry ([Resource limits](#resource-limits)). Revoking
|
||||
a link or removing a file cuts every connection that used it, library connections included. Not
|
||||
defended against: someone with many unrelated addresses (each gets its own limits; the global caps
|
||||
still hold, so they can fill the server up to them until you block them or the files expire), 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).
|
||||
|
||||
### Resource limits
|
||||
|
||||
What keeps one client (or a few) from exhausting a small machine (the official one has 2 GB of
|
||||
memory and 2 GB of swap; the defaults are chosen to hold on 1 GB). The rule throughout: no single
|
||||
address or block of addresses can use up a budget everyone shares (each gets at most an eighth),
|
||||
and when a shared budget runs out the biggest consumer is dropped, not the next client that happens
|
||||
to need a little of it. These apply only with `open_publish = true` unless marked *always*.
|
||||
|
||||
**On private servers too** (a change from earlier versions): a batch that would take a file past
|
||||
`max_file_mb` (64 MiB by default there) is now refused with the reason, and the app rolls it back,
|
||||
where it used to be kept and only logged; and nothing is written while the disk has less than
|
||||
1 GiB (or two of the largest files) free. Raise `max_file_mb` (at most 4096) if your team's files
|
||||
are larger.
|
||||
|
||||
| Resource | Limit |
|
||||
|---|---|
|
||||
| Message size | A file's size and 1 MiB (`max_file_mb`, 16 by default when open, or `max_plugin_mb` if larger): checked as the message is read, before anything is decoded; 64 KiB on a connection that only browses plugins (a client sending more is disconnected with the limit as the reason; the app keeps its own copy). The core's 64 MiB otherwise |
|
||||
| A file as it grows | Every batch is checked first (*always*): past `max_file_mb`, past its publisher's `max_mb_per_ip` or past `max_total_mb`, it is refused with the reason and the app rolls it back. A long log is compacted to make room at most once a minute per file (a compaction rewrites the whole file). A file's snapshot also holds the recent batches kept for reconnecting clients, at most a quarter of `max_file_mb` |
|
||||
| Recent batches in memory | A quarter of `max_file_mb` (256 KiB at least) per open file; older ones are dropped (a client that missed them catches up with the whole file) |
|
||||
| Bytes queued for slow clients | The largest message and 1 MiB per connection, 32 MiB per block of addresses, 256 MiB for everyone. A message sent to many people counts once for everyone and once per block (twenty colleagues behind one office address cost their block one copy of a broadcast), and welcomes (bounded by the file's size) don't count against blocks, so a whole office can join a file at once. A client that doesn't read its answers is dropped; when everyone's budget runs out, the connection holding the most is dropped |
|
||||
| Bytes being read | Messages not yet whole: the largest message and 1 MiB per block, 192 MiB for everyone; past everyone's budget the readers holding the most are cut off until it fits (their bytes count as free at once), and a reader is refused only when it is the biggest |
|
||||
| Bytes waiting for the server | 128 MiB of read messages not handled yet: past it a connection is dropped as busy |
|
||||
| Connections | `max_connections` (512) in all (*always*); `max_connections_per_ip` (32) per address, ×2 per IPv6 /56 (at most a sixteenth of all), ×4 per /48 (at most an eighth of all), joins included, so no block can take every connection; one-shot requests (asking about hosting, libraries, browsing plugins) can't take the last eighth, which stays for people joining files, and are dropped after 20 idle seconds |
|
||||
| Rates | Per address and block: `connects_per_minute`, `publishes_per_hour` (and 32 addresses' worth for everyone), `messages_per_second` (cursors, selections, cursor chat and pings have their own budget beside it, twice that, so an office moving its cursors together doesn't trip it); reports, ten an hour (*always*); downloads of libraries and plugins, 1 MiB a second with bursts of 16 MiB per address (*always*), checked before anything is read, within 32 MiB a second (512 MiB at once) for libraries and for plugins, everyone together |
|
||||
| Team libraries | Only files published with the key hold one on a public server; an archive is at most `max_file_mb` and everything it unpacks to counts against the same cap (*always*), so a zip bomb is refused before it expands |
|
||||
| Usage reports | Kept in memory, written at most once a minute per library and when the server stops; at most 1,000 components per file and 200 files per library (*always*) |
|
||||
| Disk writes | A batch is confirmed only once it is on disk; when its log can't be written, nobody hears it was accepted: the file closes, everyone in it reconnects and their apps send again what wasn't confirmed. Below 1 GiB (or two of the largest files) free, changes and new files are refused with "the server's disk is nearly full" instead (*always*, where the free space can be told: Unix). The server keeps the last 200 problems for the operator |
|
||||
| Storage | `max_files`, `max_total_mb` (10240), `max_files_per_ip` (20), `max_mb_per_ip` (256), and idle expiry (`idle_days`, 30) for files published without the key. Size `max_total_mb` to the disk: the files, a compaction's copy of the largest, the nightly backups (each about the data's size, seven kept unless you move them off) and 1 GiB to spare |
|
||||
| The service | systemd caps it at 60% (slows down) and 75% (stopped and restarted) of the machine's memory, and only lets it talk to this machine (the proxy) |
|
||||
|
||||
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 = 8
|
||||
max_plugin_mb = 8
|
||||
max_total_mb = 6144
|
||||
max_files_per_ip = 20
|
||||
max_mb_per_ip = 256
|
||||
messages_per_second = 600
|
||||
connects_per_minute = 60
|
||||
```
|
||||
|
||||
These are the recommended values for a small public server (2 GB of memory, a 25-50 GB disk with
|
||||
the nightly backups on it): files up to 8 MiB keep a whole office's welcomes within the queues,
|
||||
6 GiB of files leaves room for a week of backups, and the per-address rates leave room for an
|
||||
office behind one address.
|
||||
|
||||
### 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 units and its Caddy site (`/etc/caddy/figcraft-server.caddy`, imported from
|
||||
the main Caddyfile, whose other sites it leaves alone), opens the SSH ports `sshd` listens on, 80
|
||||
and 443 in `ufw`, and turns `ufw` on unless other services listen on the machine (then it says
|
||||
so: allow their ports and enable it yourself, or pass `--firewall-anyway`). Builds run as the
|
||||
unprivileged `figcraft-build` user on a copy of the source (root installs nothing from that copy;
|
||||
the swap file and the built binary, copied out with the build user's rights, live in the root-only
|
||||
`/var/cache/figcraft-install`), and the binary is only ever run as `figcraft` (never as root);
|
||||
a fresh clone builds the latest release tag. A Caddyfile with other sites is never replaced: it
|
||||
gains one `import figcraft-server.caddy` line, once (`packaging/server/install-test.sh` checks
|
||||
this). It checks new settings before they replace anything, 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,13 +522,18 @@ 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
|
||||
`/tmp` and devices, and a system-call filter.
|
||||
`/tmp` and devices, a system-call filter, memory caps (60% of the machine slows it down, 75%
|
||||
stops and restarts it, so only this service is hit) and network only to this machine (the proxy;
|
||||
remove `IPAddressAllow`/`IPAddressDeny` to listen on a network address without one).
|
||||
|
||||
```sh
|
||||
sudo cp figcraft-server.service /etc/systemd/system/
|
||||
|
|
@ -352,7 +587,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,20 +621,28 @@ 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
|
||||
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.
|
||||
`max_people`: the cap per file), plus `max_files`, `max_file_mb` and `max_links`, and on a public
|
||||
server the limits per address ([Resource limits](#resource-limits)). 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 (everything it unpacks to within `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 1,000 components per file and 200 files per library.
|
||||
- **Team libraries**: any link of a file reads its library and its usage reports (file names and
|
||||
counts); only an edit link publishes it.
|
||||
- **Plugin directory**: anyone who reaches the server reads it; only the server key writes to it.
|
||||
|
|
@ -406,4 +651,11 @@ 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.
|
||||
- **The machine**: the data directory is made readable by the server's account only and the server
|
||||
key is written owner-only from the start; the systemd unit caps memory and network
|
||||
(`MemoryHigh`, `MemoryMax`, `IPAddressAllow=localhost`); `install.sh` builds as an unprivileged
|
||||
user, runs the binary only as the server's account, never replaces someone else's Caddyfile and
|
||||
never cuts off SSH or other services when it turns `ufw` on.
|
||||
- **Not there yet**: user accounts, per-person permissions, audit logs, end-to-end encryption.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
# 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 checks there is room for the copy (twice the data directory's size free), 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 (readable by root only) and keeps the last
|
||||
# 7 days. The archives hold every file, the addresses files came from, the reports and the server
|
||||
# key: keep them, and any copy off the machine, as private as the server itself.
|
||||
|
||||
[Unit]
|
||||
Description=Back up figcraft-server's data directory
|
||||
After=figcraft-server.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
Nice=10
|
||||
IOSchedulingClass=idle
|
||||
UMask=0077
|
||||
# `$$` 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"; need=$$(du -sk /var/lib/figcraft-server | cut -f1); free=$$(df -k --output=avail "$$dir" | tail -n 1); if [ "$$free" -lt $$((need * 2)) ]; then echo "not enough free space for a backup ($$free KiB free, $$need KiB of data)" >&2; exit 1; fi; 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 +7 -delete'
|
||||
|
|
@ -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
|
||||
|
|
@ -23,6 +23,14 @@ RestartSec=2
|
|||
# max_connections (512 by default) plus the files kept open.
|
||||
LimitNOFILE=4096
|
||||
UMask=0077
|
||||
# Under memory pressure this service slows down, then it alone is stopped (and restarted), not the
|
||||
# rest of the machine.
|
||||
MemoryHigh=60%
|
||||
MemoryMax=75%
|
||||
# It talks only to the proxy on this machine (listen = "127.0.0.1:47900"). Listening on a network
|
||||
# address without a proxy in front needs these two lines removed.
|
||||
IPAddressAllow=localhost
|
||||
IPAddressDeny=any
|
||||
|
||||
# Hardening: the only writable place is the data directory.
|
||||
NoNewPrivileges=yes
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
#!/usr/bin/env bash
|
||||
# Checks install.sh's Caddyfile handling without installing anything (no root, no network):
|
||||
# a Caddyfile with other sites is never replaced, only gains one import line, and stays the same
|
||||
# however often install.sh runs. Run by the packaging lint workflow.
|
||||
#
|
||||
# Usage: packaging/server/install-test.sh
|
||||
set -euo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
FAILED=0
|
||||
|
||||
main() { bash "$HERE/install.sh" --caddy-main "$1" ops@example.com; }
|
||||
fail() { printf 'FAIL: %s\n' "$*" >&2; FAILED=1; }
|
||||
|
||||
# Missing: ours.
|
||||
main "$WORK/none" >"$WORK/out"
|
||||
grep -qx 'import figcraft-server.caddy' "$WORK/out" || fail "a new Caddyfile imports our site"
|
||||
grep -q 'email ops@example.com' "$WORK/out" || fail "a new Caddyfile has the certificate email"
|
||||
|
||||
# The example site the package ships: replaced by ours.
|
||||
printf '# The Caddyfile is an easy way to configure your Caddy web server.\n:80 {\n\troot * /usr/share/caddy\n\tfile_server\n}\n' >"$WORK/default"
|
||||
main "$WORK/default" >"$WORK/out"
|
||||
grep -q 'usr/share/caddy' "$WORK/out" && fail "the packaged example site is replaced"
|
||||
|
||||
# Someone else's sites: kept, one import added, and the next run changes nothing.
|
||||
printf 'shop.example.com {\n\treverse_proxy 127.0.0.1:8080\n}\n' >"$WORK/theirs"
|
||||
cp "$WORK/theirs" "$WORK/run1"
|
||||
main "$WORK/run1" >"$WORK/run2"
|
||||
grep -q 'shop.example.com' "$WORK/run2" || fail "the operator's site is kept"
|
||||
[ "$(grep -c 'import figcraft-server.caddy' "$WORK/run2")" = 1 ] || fail "one import line is added"
|
||||
main "$WORK/run2" >"$WORK/run3"
|
||||
cmp -s "$WORK/run2" "$WORK/run3" || fail "running again leaves a Caddyfile with other sites as it is"
|
||||
main "$WORK/run3" >"$WORK/run4"
|
||||
cmp -s "$WORK/run3" "$WORK/run4" || fail "and again"
|
||||
|
||||
# Ours, run again: unchanged (it imports our site).
|
||||
main "$WORK/none" >"$WORK/ours1"
|
||||
main "$WORK/ours1" >"$WORK/ours2"
|
||||
cmp -s "$WORK/ours1" "$WORK/ours2" || fail "our own Caddyfile stays the same"
|
||||
|
||||
if [ "$FAILED" = 0 ]; then echo "install.sh Caddyfile handling: ok"; fi
|
||||
exit "$FAILED"
|
||||
|
|
@ -0,0 +1,414 @@
|
|||
#!/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
|
||||
#
|
||||
# The binary comes from this folder when it is an unpacked release archive; else it is built from
|
||||
# the source tree around this script (a copy of the repository); else it is the latest release for
|
||||
# this machine (checked against SHA256SUMS.txt); else it is built from a fresh clone of the latest
|
||||
# release tag. Builds run as an unprivileged user, with a temporary swap file under 2 GB of memory.
|
||||
# It makes the `figcraft` user, its data directory and /etc/figcraft-server/server.toml (only when
|
||||
# missing: your edits are kept), installs the systemd units and its own Caddy site, opens SSH, 80
|
||||
# and 443 in ufw and starts it all. Safe to run again: it upgrades and restarts only on change.
|
||||
#
|
||||
# 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 the latest release tag; a source tree around this script is built as is)
|
||||
# --backups also enable the nightly backup timer (stops the server for a few seconds)
|
||||
# --no-firewall leave ufw alone
|
||||
# --firewall-anyway enable ufw even though other services listen on this machine
|
||||
# -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
|
||||
BUILDER=figcraft-build
|
||||
# Root's own work (downloads, its clone, the swap file, the built binary once copied out): never
|
||||
# a place the build user can touch.
|
||||
WORK=/var/cache/figcraft-install
|
||||
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
|
||||
FIREWALL_ANYWAY=false
|
||||
|
||||
say() { printf '==> %s\n' "$*"; }
|
||||
warn() { printf 'install.sh: warning: %s\n' "$*" >&2; }
|
||||
die() { printf 'install.sh: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
MARK="# figcraft-server (install.sh)"
|
||||
|
||||
caddy_main() { # caddy_main FILE EMAIL: the main Caddyfile to install, on stdout
|
||||
local file="$1" email="$2"
|
||||
if [ -f "$file" ] && grep -qE '^[[:space:]]*import[[:space:]]+figcraft-server\.caddy[[:space:]]*$' "$file"; then
|
||||
cat "$file" # it imports our site already: kept exactly as it is
|
||||
elif [ ! -f "$file" ] || ! grep -qvE '^[[:space:]]*(#|$|:80|\{|\}|root|file_server)' "$file"; then
|
||||
# Missing, or only the example site the package ships: ours, with the certificate email.
|
||||
printf '%s\n{\n\temail %s\n}\n\nimport figcraft-server.caddy\n' "$MARK" "$email"
|
||||
else
|
||||
# Someone else's sites: kept, with one line added that imports ours.
|
||||
cat "$file"
|
||||
printf '\nimport figcraft-server.caddy\n'
|
||||
fi
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
# For tests (packaging/server/install-test.sh): print the Caddyfile this script would write.
|
||||
--caddy-main) caddy_main "${2:-}" "${3:-}"; exit 0 ;;
|
||||
--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 ;;
|
||||
--firewall-anyway) FIREWALL_ANYWAY=true; shift ;;
|
||||
-h | --help) sed -n '2,27p' "$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" FLAG: 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
|
||||
install -d -o root -g root -m 700 "$WORK"
|
||||
|
||||
ARCH="$(uname -m)"
|
||||
case "$ARCH" in
|
||||
amd64) ARCH=x86_64 ;;
|
||||
arm64) ARCH=aarch64 ;;
|
||||
esac
|
||||
|
||||
# --- The binary -----------------------------------------------------------------------------
|
||||
NEW_BIN=""
|
||||
FILES="$HERE" # where the units 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="$WORK/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
|
||||
|
||||
as_builder() { # run a command as the unprivileged build user, with its own toolchain
|
||||
local jobs=()
|
||||
if [ -n "${CARGO_BUILD_JOBS:-}" ]; then jobs=("CARGO_BUILD_JOBS=$CARGO_BUILD_JOBS"); fi
|
||||
runuser -u "$BUILDER" -- env HOME="$BUILD" RUSTUP_HOME="$BUILD/rustup" CARGO_HOME="$BUILD/cargo" \
|
||||
CARGO_TARGET_DIR="$BUILD/target" "${jobs[@]}" "$@"
|
||||
}
|
||||
|
||||
from_source() { # build the source tree around this script, else a fresh clone; sets NEW_BIN and FILES
|
||||
local mem_kb src ref="$REF" copy="$BUILD/src"
|
||||
apt-get install -y -qq build-essential git pkg-config >/dev/null
|
||||
id -u "$BUILDER" >/dev/null 2>&1 || useradd --system --home-dir "$BUILD" --shell /usr/sbin/nologin "$BUILDER"
|
||||
install -d -o "$BUILDER" -g "$BUILDER" -m 755 "$BUILD"
|
||||
if [ ! -x "$BUILD/cargo/bin/cargo" ]; then
|
||||
as_builder sh -c "curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable --no-modify-path" >/dev/null
|
||||
else
|
||||
as_builder "$BUILD/cargo/bin/rustup" update stable >/dev/null
|
||||
fi
|
||||
# The source root installs files from is root's own (the copy beside this script, or root's
|
||||
# clone); the build user only ever builds a copy of it.
|
||||
if [ -n "$LOCAL_SRC" ]; then
|
||||
src="$LOCAL_SRC"
|
||||
say "Building the source in $LOCAL_SRC: this takes a while"
|
||||
else
|
||||
src="$WORK/src"
|
||||
if [ -d "$src/.git" ]; then
|
||||
git -C "$src" fetch --quiet --tags origin
|
||||
else
|
||||
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
|
||||
if [ -z "$ref" ]; then
|
||||
if [ -n "$VERSION" ]; then ref="v$VERSION"; else ref="$(git -C "$src" tag --list 'v*' --sort=-v:refname | head -n 1)"; fi
|
||||
[ -n "$ref" ] || ref=main
|
||||
fi
|
||||
say "Building $ref from a clone: this takes a while"
|
||||
# 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 build user clears and fills its own copy (root never touches paths it controls).
|
||||
as_builder rm -rf "$copy"
|
||||
as_builder mkdir -p "$copy"
|
||||
tar -C "$src" --exclude=./target --exclude=./plugins/target --exclude=./.git -cf - . | as_builder tar -C "$copy" -xf -
|
||||
# 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="$WORK/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 "$copy" && as_builder "$BUILD/cargo/bin/cargo" build --release --locked -p figcraft-server)
|
||||
cleanup
|
||||
SWAP=""
|
||||
# Copied out with the build user's rights into a file root made: a link the build user planted
|
||||
# can't make root read (or install) anything else.
|
||||
NEW_BIN="$WORK/figcraft-server.built"
|
||||
rm -f "$NEW_BIN"
|
||||
as_builder cat "$BUILD/target/release/figcraft-server" >"$NEW_BIN" || die "the build left no binary"
|
||||
chmod 755 "$NEW_BIN"
|
||||
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 units 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
|
||||
|
||||
# The server's own account runs the binary, from here on too (root never runs what was built).
|
||||
id -u figcraft >/dev/null 2>&1 || useradd --system --home-dir "$DATA" --shell /usr/sbin/nologin figcraft
|
||||
as_server() { runuser -u figcraft -- "$@"; }
|
||||
|
||||
CHANGED=false
|
||||
if ! cmp -s "$NEW_BIN" "$BIN"; then
|
||||
install -m 755 "$NEW_BIN" "$BIN.new"
|
||||
mv -f "$BIN.new" "$BIN"
|
||||
say "Installed $(as_server "$BIN" --version)"
|
||||
CHANGED=true
|
||||
fi
|
||||
|
||||
# --- Data and settings ----------------------------------------------------------------------
|
||||
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 = 8\n'
|
||||
printf 'max_plugin_mb = 8\n'
|
||||
printf '# All files together (leave room for the nightly backups on the same disk), and per address.\n'
|
||||
printf 'max_total_mb = 6144\n'
|
||||
printf 'max_files_per_ip = 20\n'
|
||||
printf 'max_mb_per_ip = 256\n'
|
||||
printf '# Per address: an office behind one address shares these (see docs/server.md for every limit).\n'
|
||||
printf 'messages_per_second = 600\n'
|
||||
printf 'connects_per_minute = 60\n'
|
||||
} >"$CONFIG.new"
|
||||
chmod 644 "$CONFIG.new"
|
||||
# Checked before it replaces anything.
|
||||
as_server "$BIN" --config "$CONFIG.new" check >/dev/null || { rm -f "$CONFIG.new"; die "the new settings don't read (see above)"; }
|
||||
mv -f "$CONFIG.new" "$CONFIG"
|
||||
CHANGED=true
|
||||
fi
|
||||
as_server "$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 ----------------------------------------------------------------------------------
|
||||
# Our site lives in a file of its own; the main Caddyfile only imports it (caddy_main). A
|
||||
# Caddyfile with other sites is never replaced: it only gains the import line, once.
|
||||
CADDYFILE=/etc/caddy/Caddyfile
|
||||
SITE_FILE=/etc/caddy/figcraft-server.caddy
|
||||
SITE="$(mktemp)"
|
||||
MAIN="$(mktemp)"
|
||||
{
|
||||
printf '%s: written by packaging/server/install.sh (run it again to update).\n' "$MARK"
|
||||
sed -e '/^#/d' -e "s/figcraft\.example\.com/$HOST/" "$FILES/Caddyfile" | sed -e '/./,$!d'
|
||||
} >"$SITE"
|
||||
caddy_main "$CADDYFILE" "$EMAIL" >"$MAIN"
|
||||
CADDY_CHANGED=false
|
||||
for f in "$CADDYFILE" "$SITE_FILE"; do
|
||||
if [ -f "$f" ]; then cp -p "$f" "$f.before-figcraft"; else rm -f "$f.before-figcraft"; fi
|
||||
done
|
||||
install_file "$SITE" "$SITE_FILE" 644 && CADDY_CHANGED=true
|
||||
install_file "$MAIN" "$CADDYFILE" 644 && CADDY_CHANGED=true
|
||||
rm -f "$SITE" "$MAIN"
|
||||
if [ "$CADDY_CHANGED" = true ]; then
|
||||
if ! caddy validate --config "$CADDYFILE" --adapter caddyfile >/dev/null 2>&1; then
|
||||
for f in "$CADDYFILE" "$SITE_FILE"; do
|
||||
if [ -f "$f.before-figcraft" ]; then cp -p "$f.before-figcraft" "$f"; else rm -f "$f"; fi
|
||||
done
|
||||
die "Caddy doesn't accept the new configuration (the old one is back; see: caddy validate --config $CADDYFILE)"
|
||||
fi
|
||||
systemctl reload caddy || systemctl restart caddy
|
||||
fi
|
||||
systemctl enable --now --quiet caddy
|
||||
|
||||
# --- Firewall -------------------------------------------------------------------------------
|
||||
if [ "$FIREWALL" = true ]; then
|
||||
# The ports sshd really listens on (sudo doesn't pass SSH_CONNECTION on).
|
||||
SSH_PORTS="$(sshd -T 2>/dev/null | awk '/^port / {print $2}' | sort -u)"
|
||||
[ -n "$SSH_PORTS" ] || SSH_PORTS=22
|
||||
say "Firewall: SSH ($(echo "$SSH_PORTS" | tr '\n' ' ' | sed 's/ $//')), 80 and 443"
|
||||
for p in $SSH_PORTS; do ufw allow "$p/tcp" >/dev/null; done
|
||||
ufw allow 80/tcp >/dev/null
|
||||
ufw allow 443/tcp >/dev/null
|
||||
if ufw status | grep -q '^Status: active'; then
|
||||
:
|
||||
else
|
||||
# Other services reachable from outside would be cut off by enabling ufw.
|
||||
OTHERS=""
|
||||
for a in $(ss -Hltn | awk '{print $4}'); do
|
||||
case "$a" in 127.* | "[::1]:"* | ::1:*) continue ;; esac
|
||||
port="${a##*:}"
|
||||
case " $(echo "$SSH_PORTS" | tr '\n' ' ') 80 443 " in *" $port "*) continue ;; esac
|
||||
OTHERS="$OTHERS $port"
|
||||
done
|
||||
if [ -n "$OTHERS" ] && [ "$FIREWALL_ANYWAY" = false ]; then
|
||||
warn "other services listen on port(s)$OTHERS: ufw was not enabled (rules added; allow those ports, then 'ufw enable', or run again with --firewall-anyway)"
|
||||
else
|
||||
ufw --force enable >/dev/null
|
||||
fi
|
||||
fi
|
||||
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 <<EOF
|
||||
|
||||
figcraft-server is running behind Caddy.
|
||||
|
||||
Publish: in FigCraft, Share > 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;
|
||||
the archives hold every file and the server key: keep them as private as the 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
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
# $DIST/figcraft-server-<version>-<os>-<arch>.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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue