diff --git a/ROADMAP.md b/ROADMAP.md index a58f9c15..d05353d3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M9, #1075: `figcraft-server` gives requests that change its files (join codes, links, renames, removals, blocks, publishing with the key) a budget of 60 a minute per address, with the server key and owner tokens alike (100 renames in a row from one address: 100 served before, 60 now; another address is unaffected), and finds join codes through an index of their hashes instead of opening every link's code. Same results, same protocol. Tests: `admin_budget_tests`. Numbers unchanged | | 2026-10-29 | minor | M8, #1080: SVG import draws a pattern's tile once per pattern and scale and reuses it for every other layer it fills (1,000 rectangles filled with one pattern: 1,000 tile drawings before, 1 now, same image fill), and the layers inside a tile count in the import's 100,000-layer budget instead of each tile starting a fresh one, so patterns nested in patterns stop at the budget. Tests: `tile_tests` (svg). Numbers unchanged | | 2026-10-29 | patch | M4, #1478: a letter and its combining marks are shaped with one font that has them all. Text typed decomposed (NFD, `E` + U+0302 + U+0309 for Ể) in a font with the letters but not the marks drew missing-glyph boxes beside and above the letter, while the precomposed form fell back to a font that has it; each grapheme cluster now takes the first face (the style's own, then the fallbacks) that covers every char in it (joiners and variation selectors need no glyph), else the letter's own face as before. Fonts that cover Vietnamese were already right: mark positioning (GPOS `mark`/`mkmk`) stacks the marks over the letter on the canvas, in PNG, SVG and PDF. Tests in `crates/text/src/vietnamese_tests.rs`. Numbers unchanged | | 2026-10-29 | minor | M9, #1415: `comment.list` (CLI, MCP `run_command`, the `figcraft://comments` resource) gives every message, the first and each reply, its `mentions`: `{name, known, you}` for each name it @mentions, by the rule the thread view highlights them with (`figcraft_doc::mentions`, which the highlighting now shares). A name resolves against the file's people and you, as the file spells it. An unknown name is reported with `known: false`. `a@b.com` mentions nobody. Filtering threads by a mentioned name and listing mentioned names in `comment.people` stay open. Numbers unchanged | diff --git a/apps/figcraft-server/src/abuse.rs b/apps/figcraft-server/src/abuse.rs index 2ea939c3..05eb5122 100644 --- a/apps/figcraft-server/src/abuse.rs +++ b/apps/figcraft-server/src/abuse.rs @@ -1,5 +1,6 @@ //! 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 +//! (only on a server open to the public), and reports, downloads and requests that change the +//! server's files (always, with the server key too). 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 @@ -34,6 +35,10 @@ pub const DOWNLOAD_BURST: f64 = (16u64 << 20) as f64; /// 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; +/// Requests that change the server's files (join codes, links, renames, removals, blocks, +/// publishing with the key) one address may make a minute, with the server key or an owner token. +pub const ADMIN_PER_MINUTE: f64 = 60.0; + /// Join codes one address may try a minute (right or wrong). pub const CODES_PER_MINUTE: f64 = 10.0; /// Wrong codes in a row (within [`CODE_LOCKOUT`] of each other) that lock an address out of codes. @@ -66,6 +71,8 @@ pub enum Action { Download(usize), /// Trying a join code. Code, + /// A request that changes the server's files ([`ADMIN_PER_MINUTE`]). + Admin, } struct Address { @@ -76,6 +83,7 @@ struct Address { reports: Bucket, downloads: Bucket, codes: Bucket, + admin: Bucket, /// Wrong join codes in a row, and when the last one was. wrong: u32, wrong_at: Instant, @@ -177,6 +185,7 @@ impl Abuse { reports: bucket(REPORTS_PER_HOUR * times, 3600.0), downloads: Bucket::new(DOWNLOAD_RATE * times, DOWNLOAD_BURST * times), codes: bucket(CODES_PER_MINUTE * times, 60.0), + admin: bucket(ADMIN_PER_MINUTE * times, 60.0), wrong: 0, wrong_at: now, locked: None, @@ -195,6 +204,7 @@ impl Abuse { Action::Report => (&mut a.reports, 1.0), Action::Download(n) => (&mut a.downloads, n as f64), Action::Code => (&mut a.codes, 1.0), + Action::Admin => (&mut a.admin, 1.0), } } diff --git a/apps/figcraft-server/src/admin_budget_tests.rs b/apps/figcraft-server/src/admin_budget_tests.rs new file mode 100644 index 00000000..cee514b6 --- /dev/null +++ b/apps/figcraft-server/src/admin_budget_tests.rs @@ -0,0 +1,75 @@ +//! Requests that change the server's files have a budget per address, with the server key or an +//! owner token too; join codes are found through an index of their hashes, with the same results +//! as comparing every link. + +use std::net::IpAddr; + +use figcraft_collab::message::{Admin, AdminReply}; +use figcraft_collab_net::hub::Backend; + +use crate::abuse::ADMIN_PER_MINUTE; +use crate::store::{BUSY_ADMIN, CODE_GONE, Store}; +use crate::test_util::{TempDir, config, ip, snapshot}; + +fn rename(store: &mut Store, from: IpAddr, file: &str) -> AdminReply { + Backend::admin(store, from, Admin::Rename { file: file.into(), name: "Renamed".into() }, &|_| None).reply +} + +#[test] +fn a_flood_of_changes_from_one_address_is_held_back_and_others_are_not() { + let dir = TempDir::new("admin-budget"); + let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap(); + let (file, ..) = store.publish("Poster", &snapshot("Poster")).unwrap(); + let flooder = ip("198.51.100.1"); + let done = (0..100).filter(|_| rename(&mut store, flooder, &file.id) == AdminReply::Done).count(); + assert_eq!(done, ADMIN_PER_MINUTE as usize, "a minute's worth, then wait"); + assert_eq!(rename(&mut store, flooder, &file.id), AdminReply::Failed { reason: BUSY_ADMIN.into() }); + // An owner token's requests share the address's budget. + let hosted = store.hosted(flooder, Some(&file.id), Admin::Rename { file: file.id.clone(), name: "Mine".into() }, &|_| None).reply; + assert_eq!(hosted, AdminReply::Failed { reason: BUSY_ADMIN.into() }); + // Looking costs nothing, and another address has its own budget. + assert!(matches!(Backend::admin(&mut store, flooder, Admin::Files, &|_| None).reply, AdminReply::Files(_))); + assert_eq!(rename(&mut store, ip("198.51.100.2"), &file.id), AdminReply::Done); +} + +#[test] +fn codes_are_found_through_the_index_as_before() { + let dir = TempDir::new("code-index"); + let files_dir = dir.path().join("files"); + let (files, codes) = { + let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap(); + let mut files = Vec::new(); + let mut codes = Vec::new(); + for n in 0..12 { + let (file, edit, _view) = store.publish(&format!("File {n}"), &snapshot("File")).unwrap(); + codes.push(store.set_code(&file.id, &edit.token).unwrap()); + files.push((file.id, edit.token, edit.link.id)); + } + (files, codes) + }; + // A copy of a file's folder holds the same code (the last file by id wins, as before). + let copy = "zzzzzzzzzzzzzzzz"; + std::fs::create_dir_all(files_dir.join(copy)).unwrap(); + for f in std::fs::read_dir(files_dir.join(&files[0].0)).unwrap() { + let f = f.unwrap(); + std::fs::copy(f.path(), files_dir.join(copy).join(f.file_name())).unwrap(); + } + let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap(); + let who = |n: usize| ip(&format!("192.0.2.{n}")); + for (n, ((id, token, _), code)) in files.iter().zip(&codes).enumerate().skip(1) { + assert_eq!(store.resolve_code(who(n), code).unwrap(), (id.clone(), token.clone()), "file {n}"); + } + assert_eq!(store.resolve_code(who(100), &codes[0]).unwrap().0, copy); + // Revoking the copy's link leaves the original's code working; revoking that one ends it. + store.revoke(copy, &files[0].2).unwrap(); + assert_eq!(store.resolve_code(who(101), &codes[0]).unwrap(), (files[0].0.clone(), files[0].1.clone())); + store.revoke(&files[0].0, &files[0].2).unwrap(); + assert_eq!(store.resolve_code(who(102), &codes[0]).unwrap_err(), CODE_GONE); + // A new code replaces the old in the index; a removed file's code is gone. + let newer = store.set_code(&files[1].0, &files[1].1).unwrap(); + assert_eq!(store.resolve_code(who(103), &codes[1]).unwrap_err(), CODE_GONE); + assert_eq!(store.resolve_code(who(104), &newer).unwrap().0, files[1].0); + store.remove(&files[2].0).unwrap(); + assert_eq!(store.resolve_code(who(105), &codes[2]).unwrap_err(), CODE_GONE); + assert_eq!(store.resolve_code(who(106), &codes[3]).unwrap().0, files[3].0); +} diff --git a/apps/figcraft-server/src/cli.rs b/apps/figcraft-server/src/cli.rs index f0bd5929..d92689df 100644 --- a/apps/figcraft-server/src/cli.rs +++ b/apps/figcraft-server/src/cli.rs @@ -316,7 +316,8 @@ fn running(data: &Path) -> Result { /// Do one request: through the running server, or on the data directory. fn admin(cfg: &Config, req: Admin) -> Result { match Store::try_open(&cfg.data, cfg.store())? { - Some(mut store) => Ok(store.admin(req, &|_| None).reply), + // On this machine, one request per command: never over the address's budget. + Some(mut store) => Ok(store.admin(std::net::Ipv4Addr::LOCALHOST.into(), req, &|_| None).reply), None => { let addr = running(&cfg.data)?; let key = std::fs::read_to_string(cfg.data.join(KEY)).map_err(|e| format!("can't read the server key: {e}"))?; diff --git a/apps/figcraft-server/src/codes_tests.rs b/apps/figcraft-server/src/codes_tests.rs index 1e58f7c2..e226d2ec 100644 --- a/apps/figcraft-server/src/codes_tests.rs +++ b/apps/figcraft-server/src/codes_tests.rs @@ -7,11 +7,9 @@ use std::net::IpAddr; use std::time::{Duration, Instant}; -use figcraft_collab::Replica; use figcraft_collab::code; use figcraft_collab::message::{Admin, AdminReply, ClientMsg, Hello, OpenHosting, PROTOCOL, Resolve, ServerMsg}; use figcraft_collab_net::hub::Backend; -use figcraft_doc::{Document, NodeKind}; use figcraft_engine::Session; use figcraft_engine::live::Status; use serde_json::{Value, json}; @@ -20,24 +18,12 @@ use crate::abuse::{Abuse, AbuseConfig, Action, CODE_LOCKOUT, CODES_PER_MINUTE, W use crate::codes::Key; use crate::config::Config; use crate::store::{CODE_GONE, CODES_BUSY, NO_CODE, Store}; -use crate::test_util::{Raw, TempDir, config, live, pump, session}; +use crate::test_util::{Raw, TempDir, config, ip, live, pump, session, snapshot}; 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 { - 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 Session, cmd: &str, p: Value) -> Value { s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) } diff --git a/apps/figcraft-server/src/hardening_tests.rs b/apps/figcraft-server/src/hardening_tests.rs index 92b17710..8226aa3f 100644 --- a/apps/figcraft-server/src/hardening_tests.rs +++ b/apps/figcraft-server/src/hardening_tests.rs @@ -25,24 +25,12 @@ 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}; +use crate::test_util::{Raw, TempDir, config, ip, snapshot}; fn open(dir: &std::path::Path) -> Config { Config { open_publish: true, operator_contact: "abuse@example.com".into(), ..config(dir) } } -fn snapshot(name: &str) -> Vec { - 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 { diff --git a/apps/figcraft-server/src/lib.rs b/apps/figcraft-server/src/lib.rs index e2f0d440..746205d1 100644 --- a/apps/figcraft-server/src/lib.rs +++ b/apps/figcraft-server/src/lib.rs @@ -86,6 +86,8 @@ pub fn start(cfg: &Config) -> Result { Ok(Running { hosting }) } +#[cfg(test)] +mod admin_budget_tests; #[cfg(test)] mod background_tests; #[cfg(test)] diff --git a/apps/figcraft-server/src/public_tests.rs b/apps/figcraft-server/src/public_tests.rs index 37cbabe1..b63f0993 100644 --- a/apps/figcraft-server/src/public_tests.rs +++ b/apps/figcraft-server/src/public_tests.rs @@ -6,10 +6,8 @@ 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}; @@ -18,24 +16,12 @@ 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}; +use crate::test_util::{Raw, TempDir, config, ip, join, key, live, pump, session, snapshot}; 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 { - 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}")) } diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index 90fce92b..2108bfb8 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -30,7 +30,7 @@ //! hashes and compared in constant time. Whatever is damaged at startup is skipped and reported, //! never deleted. -use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque}; use std::fs::{self, File, OpenOptions, TryLockError}; use std::io::{Cursor, Write}; use std::net::IpAddr; @@ -88,6 +88,9 @@ 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 a request that changes the server's files is turned away (the address's share is used up). +pub const BUSY_ADMIN: &str = "too many changes to this server's files from your address: wait a minute and try again"; + /// 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)"; @@ -270,6 +273,9 @@ pub struct Store { // shortcut: in memory only, so after a restart such a code reads as unknown; keep them in the // data directory if people ask why their code "never existed". gone_codes: VecDeque, + /// The files whose links hold each join code, by the code's hash ([`Sealed::hash`]): a typed + /// code is looked up here instead of against every link. + codes: HashMap>, /// When idle files are looked for next (seconds since 1970). next_sweep: u64, /// Compactions so far (tests count them). @@ -396,6 +402,7 @@ impl Store { blocked: BTreeSet::new(), reports: Vec::new(), gone_codes: VecDeque::new(), + codes: HashMap::new(), next_sweep: 0, compactions: 0, cut_short: Vec::new(), @@ -448,6 +455,9 @@ impl Store { } match self.check(&path) { Ok(entry) => { + for c in entry.links.iter().filter_map(|l| l.code.as_ref()) { + self.codes.entry(c.hash.clone()).or_default().insert(id.clone()); + } self.files.insert(id, entry); } Err(why) => self.note(format!("skipped file {id}: {why}")), diff --git a/apps/figcraft-server/src/store/backend.rs b/apps/figcraft-server/src/store/backend.rs index 95fd8948..fc36a337 100644 --- a/apps/figcraft-server/src/store/backend.rs +++ b/apps/figcraft-server/src/store/backend.rs @@ -1,9 +1,18 @@ use super::journal::flush_log; use super::{ - Access, Action, Admin, AdminReply, Admission, BUSY_DOWNLOADS, Backend, ClientMsg, Closing, Handled, HostInfo, Instant, IpAddr, LibraryReply, - LibraryReq, OpenFile, PluginReply, PluginReq, Server, Store, disk, now, same_secret, + Access, Action, Admin, AdminReply, Admission, BUSY_ADMIN, BUSY_DOWNLOADS, Backend, ClientMsg, Closing, Handled, HostInfo, Instant, IpAddr, + LibraryReply, LibraryReq, OpenFile, PluginReply, PluginReq, Server, Store, disk, now, same_secret, }; +/// Whether `req` changes the server's files (a write, a removal or a join code's stretching): +/// such requests count against the address's budget ([`Action::Admin`]); looking doesn't. +fn changes_files(req: &Admin) -> bool { + !matches!( + req, + Admin::Files | Admin::Links { .. } | Admin::Listing | Admin::Reports | Admin::Blocked | Admin::Owned { .. } | Admin::RemovePeer { .. } + ) +} + impl Backend for Store { fn admit(&mut self, file: &str, token: &str) -> Result { let entry = self.entry(file)?; @@ -156,15 +165,19 @@ impl Backend for Store { if owns != Some(file.as_str()) { return failed("an owner token manages only its own file"); } - self.admin(req, open) + self.admin(ip, 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) -> Handled { + fn admin(&mut self, ip: IpAddr, req: Admin, open: &dyn Fn(&str) -> Option) -> Handled { let failed = |reason: String| Handled::from(AdminReply::Failed { reason }); + if changes_files(&req) && !self.abuse.allow(ip, Action::Admin, Instant::now()) { + log::info!("event=refused ip={ip} reason=admin_per_address"); + return failed(BUSY_ADMIN.into()); + } match req { Admin::Publish { name, snapshot } => match self.publish(&name, &snapshot) { Ok((file, edit, view)) => AdminReply::Published { file, edit, view }.into(), diff --git a/apps/figcraft-server/src/store/links.rs b/apps/figcraft-server/src/store/links.rs index 6c279219..bbdcde7b 100644 --- a/apps/figcraft-server/src/store/links.rs +++ b/apps/figcraft-server/src/store/links.rs @@ -44,18 +44,30 @@ impl Store { found } - /// These join codes no longer open anything: typing one says so. - fn forget_codes(&mut self, codes: impl IntoIterator) { - self.gone_codes.extend(codes.into_iter().map(|c| c.hash)); + /// These join codes of `file` no longer open anything: typing one says so. A code is dropped + /// from the index once none of `file`'s links holds it. + fn forget_codes(&mut self, file: &str, codes: impl IntoIterator) { + for c in codes { + let held = self.files.get(file).is_some_and(|e| e.links.iter().any(|l| l.code.as_ref().is_some_and(|k| k.hash == c.hash))); + if !held && let Some(files) = self.codes.get_mut(&c.hash) { + files.remove(file); + if files.is_empty() { + self.codes.remove(&c.hash); + } + } + self.gone_codes.push_back(c.hash); + } let extra = self.gone_codes.len().saturating_sub(MAX_GONE_CODES); self.gone_codes.drain(..extra); } /// Where the link with the join code `key` is: its file, its place among the file's links - /// and its token (every code is compared, so timing doesn't tell which one matched). + /// and its token. Only the files the index names for the code's hash are looked at; their + /// links are each compared, in constant time. fn find_code(&self, key: &codes::Key) -> Option<(String, usize, String)> { let mut found = None; - for (id, e) in &self.files { + for id in self.codes.get(key.hash())? { + let Some(e) = self.files.get(id) else { continue }; for (i, l) in e.links.iter().enumerate() { if let Some(token) = l.code.as_ref().and_then(|c| key.open(c)) { found = Some((id.clone(), i, token)); @@ -78,6 +90,7 @@ impl Store { } }; let sealed = key.seal(token)?; + let found_by = sealed.hash.clone(); let path = self.dir(file).join(LINKS); let entry = self.entry(file)?; let link = entry.links.get_mut(at).ok_or("that file has no such link")?; @@ -89,7 +102,8 @@ impl Store { } return Err(format!("couldn't keep the join code: {e}")); } - self.forget_codes(before); + self.codes.entry(found_by).or_default().insert(file.to_string()); + self.forget_codes(file, before); log::info!("event=code file={file} link={id}"); Ok(code) } @@ -137,7 +151,7 @@ impl Store { return Err(format!("couldn't revoke the link: {e}")); } log::info!("revoked link {link} of file {file}"); - self.forget_codes(gone.code); + self.forget_codes(file, gone.code); Ok(()) } @@ -146,7 +160,7 @@ impl Store { pub fn remove(&mut self, file: &str) -> Result<(), String> { // Closed first: an open log would keep the folder from being deleted on Windows. let entry = self.files.remove(file).ok_or("this file isn't on the server (it may have been removed)")?; - self.forget_codes(entry.links.iter().filter_map(|l| l.code.clone())); + self.forget_codes(file, entry.links.iter().filter_map(|l| l.code.clone())); drop(entry); let dir = self.dir(file); fs::remove_dir_all(&dir) diff --git a/apps/figcraft-server/src/test_util.rs b/apps/figcraft-server/src/test_util.rs index 18fe0d86..da5b54a8 100644 --- a/apps/figcraft-server/src/test_util.rs +++ b/apps/figcraft-server/src/test_util.rs @@ -170,3 +170,17 @@ impl Raw { } } } + +/// A one-page file with one rectangle, named `name`, as a snapshot to publish. +pub fn snapshot(name: &str) -> Vec { + let mut d = figcraft_doc::Document::new(name); + let page = d.first_page().unwrap(); + d.create(page, figcraft_doc::NodeKind::Rectangle, "Card", 0.0, 0.0, 100.0, 60.0).unwrap(); + d.repair(); + figcraft_collab::Replica::new(d).snapshot().unwrap() +} + +/// An address from text. +pub fn ip(s: &str) -> std::net::IpAddr { + s.parse().unwrap() +} diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index 363ecae0..7b548fb0 100644 --- a/crates/collab-net/src/hub.rs +++ b/crates/collab-net/src/hub.rs @@ -305,7 +305,7 @@ impl Hub { let reply = self.removed_reply(&file, client); self.answer(conn, &ServerMsg::Admin(Box::new(reply))); } - ClientMsg::Admin(req) => self.admin(conn, req), + ClientMsg::Admin(req) => self.admin(conn, ip, req), ClientMsg::Ping => self.answer(conn, &ServerMsg::Pong), _ => self.kick(&[conn], "only requests to manage files are spoken on this connection"), }, @@ -538,10 +538,10 @@ impl Hub { } } - fn admin(&mut self, conn: u64, req: Admin) { + fn admin(&mut self, conn: u64, ip: IpAddr, req: Admin) { let rooms = &self.rooms; let open = |file: &str| rooms.get(file).map(Room::info); - let handled = self.backend.admin(req, &open); + let handled = self.backend.admin(ip, req, &open); self.handled(conn, handled); } diff --git a/crates/collab-net/src/hub/backend.rs b/crates/collab-net/src/hub/backend.rs index e594b0f1..929ab6ae 100644 --- a/crates/collab-net/src/hub/backend.rs +++ b/crates/collab-net/src/hub/backend.rs @@ -76,8 +76,9 @@ pub trait Backend: Send + 'static { Err("this session is hosted by an app: it has no files to manage".into()) } - /// A request on a managing connection; `open` tells about a file that is open. - fn admin(&mut self, _req: Admin, _open: &dyn Fn(&str) -> Option) -> Handled { + /// A request on a managing connection from `ip` ([`ip_key`]); `open` tells about a file that + /// is open. + fn admin(&mut self, _ip: IpAddr, _req: Admin, _open: &dyn Fn(&str) -> Option) -> Handled { AdminReply::Failed { reason: "nothing to manage here".into() }.into() } diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index 904b33b9..91ac86f0 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -1,6 +1,6 @@ # Collaboration parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; 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, @@ -28,6 +28,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M9, #1075: `figcraft-server` gives requests that change its files (join codes, links, renames, removals, blocks, publishing with the key) a budget of 60 a minute per address and block (`Action::Admin`), on every server, with the server key and with an owner token alike; listing and looking aren't counted. Before, a managing connection with the key had no limit at all and an owner token only the message rate, so one address could ask for hundreds of 10,000-round code stretches and `links.json` writes a second (100 renames in a row: 100 served before, 60 now, then "too many changes to this server's files from your address…"; another address is unaffected). A typed or new join code is found through an index of code hashes to the files holding them, instead of opening every link's code on the server; results are the same, a copied file folder holding the same code included. The protocol is unchanged. Tests: `admin_budget_tests`. Numbers unchanged | | 2026-10-29 | minor | M9, #1415: `comment.list` (CLI, MCP `run_command`, the `figcraft://comments` resource) gives every message, the first and each reply, its `mentions`: `{name, known, you}` for each name it @mentions, by the rule the thread view highlights them with (`figcraft_doc::mentions`, which the highlighting now shares). A name resolves against the file's people and you, as the file spells it. An unknown name is reported with `known: false`. `a@b.com` mentions nobody. Filtering threads by a mentioned name and listing mentioned names in `comment.people` stay open. Numbers unchanged | | 2026-10-29 | minor | Bounded replay log (#1069, from a security audit): the batches a session keeps for apps that reconnect were bounded only by count (10,000 batches, 1,000,000 changes) on a desktop host and a private `figcraft-server`, so a long busy session could hold any number of bytes. `Limits::max_log_bytes` now defaults to 64 MiB, and `figcraft-server` takes it from a new `max_log_mb` setting (default 64; open to the public, also at most a quarter of `max_file_mb`, as before). An app further behind than the trimmed log catches up with the whole file, as it already did past the count limits. Nine 8 MiB renames with default limits kept all 75.5 MB before; now 58.7 MB, and a client that missed them gets a snapshot. The protocol is unchanged. | | 2026-10-29 | minor | Sibling placement in live sessions (#1065, from a security audit): every replica (the server's and each app's) placed a created, moved or reordered layer by counting each sibling that sorts before it, so a batch of many layers under one parent took quadratic time, on the server's single hub thread too. It now binary-searches the siblings' `(key, id)` order on each side of the layer's old place. The order is unchanged, ties and fractional positions included: a test replays 5,000 creations and 2,500 moves against the counting rule. 20,000 creations and 10,000 moves under two parents took 20 s in a debug build; they now take 0.3 s. | diff --git a/docs/roadmap.md b/docs/roadmap.md index 2e500c03..1082ae74 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M9, #1075: `figcraft-server` gives requests that change its files a budget of 60 a minute per address, with the server key and owner tokens alike, and finds join codes through an index of their hashes. Tests: `admin_budget_tests` | | 2026-10-29 | minor | M8, #1080: SVG import draws a pattern's tile once per pattern and scale and reuses it for every other layer it fills, and the layers inside a tile count in the import's 100,000-layer budget instead of each tile starting a fresh one. Tests: `tile_tests` (svg) | | 2026-10-29 | patch | M4, #1478: a letter and its combining marks are shaped with one font that has them all. Text typed decomposed (NFD, `E` + U+0302 + U+0309 for Ể) in a font with the letters but not the marks drew missing-glyph boxes beside and above the letter, while the precomposed form fell back to a font that has it; each grapheme cluster now takes the first face (the style's own, then the fallbacks) that covers every char in it (joiners and variation selectors need no glyph), else the letter's own face as before. Fonts that cover Vietnamese were already right: mark positioning (GPOS `mark`/`mkmk`) stacks the marks over the letter on the canvas, in PNG, SVG and PDF. Tests in `crates/text/src/vietnamese_tests.rs`. Numbers unchanged | | 2026-10-29 | minor | M9, #1415: `comment.list` (CLI, MCP `run_command`, the `figcraft://comments` resource) gives every message, the first and each reply, its `mentions`: `{name, known, you}` for each name it @mentions, by the rule the thread view highlights them with (`figcraft_doc::mentions`, which the highlighting now shares). A name resolves against the file's people and you, as the file spells it. An unknown name is reported with `known: false`. `a@b.com` mentions nobody. Filtering threads by a mentioned name and listing mentioned names in `comment.people` stay open. Numbers unchanged | diff --git a/docs/server.md b/docs/server.md index a88d3a3c..0dcd26c9 100644 --- a/docs/server.md +++ b/docs/server.md @@ -498,7 +498,7 @@ are larger. | 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 | +| 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*); requests that change files (join codes, links, renames, removals, blocks, publishing with the key), sixty a minute, with the server key or an owner token too ("too many changes to this server's files from your address…"; listing and looking aren't counted) (*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 |