diff --git a/apps/figcraft-server/src/abuse.rs b/apps/figcraft-server/src/abuse.rs new file mode 100644 index 00000000..d630d695 --- /dev/null +++ b/apps/figcraft-server/src/abuse.rs @@ -0,0 +1,104 @@ +//! 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 (always). Each is a token bucket per address +//! ([`figcraft_collab_net::hub::ip_key`]: an IPv6 address counts by its /64). 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::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; + +/// 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, + Report, +} + +struct Address { + connects: Bucket, + publishes: Bucket, + messages: Bucket, + reports: Bucket, + used: Instant, +} + +/// The buckets of the addresses seen lately. +pub struct Abuse { + cfg: AbuseConfig, + table: HashMap, +} + +/// `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 { + Self { cfg, table: HashMap::new() } + } + + /// Whether `ip` may do `what` now (counting it when it may). + pub fn allow(&mut self, ip: IpAddr, what: Action, now: Instant) -> bool { + if !self.cfg.open && what != Action::Report { + return true; + } + 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), 60.0), + publishes: bucket(f64::from(c.publishes_per_hour), 3600.0), + // A burst of four seconds' worth (a big paste, a quick drag). + messages: Bucket::new(f64::from(c.messages_per_second), f64::from(c.messages_per_second) * 4.0), + reports: bucket(REPORTS_PER_HOUR, 3600.0), + used: now, + }); + a.used = now; + match what { + Action::Connect => a.connects.take(now), + Action::Publish => a.publishes.take(now), + Action::Message => a.messages.take(now), + Action::Report => a.reports.take(now), + } + } + + /// 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 = 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); + } +} diff --git a/apps/figcraft-server/src/cli.rs b/apps/figcraft-server/src/cli.rs index 5450288d..03365cbf 100644 --- a/apps/figcraft-server/src/cli.rs +++ b/apps/figcraft-server/src/cli.rs @@ -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 }, + Reports, + Check, + Block { ip: String }, + Unblock { ip: String }, + Blocked, Help, Version, } @@ -81,6 +109,7 @@ pub fn parse(args: &[String]) -> Result { 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 { "--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 { 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 { ["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 { 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 { 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 { 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, 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; diff --git a/apps/figcraft-server/src/cli_tests.rs b/apps/figcraft-server/src/cli_tests.rs index 085daea1..7b77f71e 100644 --- a/apps/figcraft-server/src/cli_tests.rs +++ b/apps/figcraft-server/src/cli_tests.rs @@ -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] diff --git a/apps/figcraft-server/src/config.rs b/apps/figcraft-server/src/config.rs index f1f57e90..018915e3 100644 --- a/apps/figcraft-server/src/config.rs +++ b/apps/figcraft-server/src/config.rs @@ -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, /// 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, + /// 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 { + 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, 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,8 +219,25 @@ 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), @@ -138,8 +250,13 @@ impl Config { } } - /// The transport's part. + /// The transport's part (connections per address are capped only when open to the public). pub fn net(&self) -> NetLimits { - NetLimits { max_connections: self.max_connections, ..NetLimits::default() } + NetLimits { + max_connections: self.max_connections, + max_per_address: if self.open_publish { self.max_connections_per_ip } else { 0 }, + trusted_proxy: self.proxy().ok().flatten(), + ..NetLimits::default() + } } } diff --git a/apps/figcraft-server/src/lib.rs b/apps/figcraft-server/src/lib.rs index 41e43b3b..d366a922 100644 --- a/apps/figcraft-server/src/lib.rs +++ b/apps/figcraft-server/src/lib.rs @@ -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; @@ -51,6 +52,9 @@ pub fn start(cfg: &Config) -> Result { 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}"))?; @@ -80,6 +84,8 @@ mod directory_tests; #[cfg(test)] mod library_tests; #[cfg(test)] +mod public_tests; +#[cfg(test)] mod server_tests; #[cfg(test)] mod store_tests; diff --git a/apps/figcraft-server/src/public_tests.rs b/apps/figcraft-server/src/public_tests.rs new file mode 100644 index 00000000..5d0b7a42 --- /dev/null +++ b/apps/figcraft-server/src/public_tests.rs @@ -0,0 +1,491 @@ +//! 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 { + 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 { + 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 = 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. +fn via_proxy(addr: SocketAddr, forwarded: &str) -> (tungstenite::WebSocket, 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 = 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); + 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(); +} diff --git a/apps/figcraft-server/src/server_tests.rs b/apps/figcraft-server/src/server_tests.rs index 3e13eace..7504d4fa 100644 --- a/apps/figcraft-server/src/server_tests.rs +++ b/apps/figcraft-server/src/server_tests.rs @@ -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(), diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index 896eaabe..25d17054 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -12,8 +12,15 @@ //! /files//library.bin its team library, when one was published (a `.figcraft` archive) //! /files//usage.json how other files use that library, as their apps report it //! /plugins/ the plugin directory (see `directory`) +//! /blocked.json addresses the operator blocked +//! /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,27 +28,43 @@ //! 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, 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; + +/// 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)"; /// Most files whose usage of one library is kept (the least recently changed go first), and most /// components one file's report keeps. @@ -51,7 +74,7 @@ const MAX_REPORTED: usize = 2_000; const MAX_USAGE_FILE: u64 = 32 << 20; /// 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 +83,41 @@ pub struct StoreConfig { pub compact_bytes: u64, pub core: figcraft_collab::Limits, pub directory: DirectoryConfig, + pub hosting: HostingConfig, +} + +/// 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 +131,21 @@ struct Meta { /// The team library published under the file, if any. #[serde(default, skip_serializing_if = "Option::is_none")] library: Option, + /// 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + ip: Option, + /// 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). @@ -130,6 +203,12 @@ pub struct Store { problems: Vec, /// The plugin directory. directory: Directory, + /// What addresses may do how often, the ones blocked, and the reports kept. + abuse: Abuse, + blocked: BTreeSet, + reports: Vec, + /// When idle files are looked for next (seconds since 1970). + next_sweep: u64, _lock: File, } @@ -207,11 +286,43 @@ 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, + blocked: BTreeSet::new(), + reports: Vec::new(), + next_sweep: 0, + _lock: lock, + }; store.scan(&dir); + store.blocked = store.read_list::(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(&mut self, name: &str) -> Vec { + 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, @@ -302,9 +413,20 @@ impl Store { self.files.get_mut(id).ok_or_else(|| "this file isn't on the server (it may have been removed)".to_string()) } - fn info(&self, id: &str, e: &Entry, open: Option) -> FileInfo { + /// A file's bytes on disk (its snapshot and log). + fn bytes(&self, id: &str) -> u64 { let dir = self.dir(id); - let bytes = [SNAPSHOT, LOG].iter().filter_map(|n| fs::metadata(dir.join(n)).ok()).map(|m| m.len()).sum(); + [SNAPSHOT, LOG].iter().filter_map(|n| fs::metadata(dir.join(n)).ok()).map(|m| m.len()).sum() + } + + /// When a file published without the key is deleted unless someone opens it. + fn expires(&self, meta: &Meta) -> Option { + 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) -> FileInfo { + let bytes = self.bytes(id); FileInfo { id: id.to_string(), name: e.meta.name.clone(), @@ -318,6 +440,52 @@ 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, + fits: &dyn Fn(u64) -> Result<(), String>, + ) -> Result<(FileInfo, NewLink, NewLink, Option), String> { if self.files.len() >= self.cfg.max_files { return Err(format!("the server keeps at most {} files", self.cfg.max_files)); } @@ -337,6 +505,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 +515,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()) @@ -362,8 +533,139 @@ impl Store { let entry = Entry { meta, links: vec![edit.clone(), view.clone()], log: None }; let info = self.info(&id, &entry, None); 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 })) + 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) -> Vec { + 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 { + let t = now(); + self.next_sweep = t.saturating_add(SWEEP_EVERY); + let due: Vec = + 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); + } + } + + /// 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 { + let ip = ip_key(ip.trim().parse::().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 = 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 { + 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. @@ -508,6 +810,7 @@ impl Store { /// Load a file's session: its snapshot, then its log (a damaged end is dropped and reported). fn load(&mut self, file: &str) -> Result { + 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}"))?; @@ -685,6 +988,8 @@ 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 } @@ -698,7 +1003,78 @@ 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(); + } + 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) -> bool { + self.abuse.allow(ip, Action::Message, Instant::now()) + } + + fn hosting(&mut self, file: &str) -> Option { + Some(self.host_info(Some(file))) + } + + fn open_hosting(&mut self, _ip: IpAddr, file: &str, owner: &str) -> Result { + 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) -> 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) -> Handled { @@ -712,9 +1088,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,7 +1108,7 @@ 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 } } } } diff --git a/apps/figcraft-server/src/store_tests.rs b/apps/figcraft-server/src/store_tests.rs index 7ca0e56d..fda51d4f 100644 --- a/apps/figcraft-server/src/store_tests.rs +++ b/apps/figcraft-server/src/store_tests.rs @@ -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); diff --git a/crates/collab-net/src/guest.rs b/crates/collab-net/src/guest.rs index 14f0ecdd..87a6a9c3 100644 --- a/crates/collab-net/src/guest.rs +++ b/crates/collab-net/src/guest.rs @@ -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, /// The file's name on the server. pub name: String, pub doc: Arc, @@ -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), 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), 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 { diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index 6ecd5fa9..9650ebdf 100644 --- a/crates/collab-net/src/hub.rs +++ b/crates/collab-net/src/hub.rs @@ -15,22 +15,28 @@ //! clients that send too much, too fast, or can't keep up. Whatever the servers say goes out only //! after the backend has made what changed durable ([`Backend::flush`], once per burst of //! messages), so a confirmed edit is never lost to a crash. +//! +//! Every connection is known by its client's address ([`ip_key`]; behind the configured reverse +//! proxy, the one its `X-Forwarded-For` names): the transport caps connections per address and the +//! backend may refuse addresses or limit what they do. A plain `GET /healthz` on the same port is +//! answered `200 ok` for uptime checks. use std::collections::{BTreeMap, HashMap}; -use std::io; -use std::net::{SocketAddr, TcpListener, TcpStream}; -use std::sync::Arc; +use std::io::{self, Read, Write}; +use std::net::{IpAddr, Ipv6Addr, SocketAddr, TcpListener, TcpStream}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, SyncSender, TrySendError, channel, sync_channel}; +use std::sync::{Arc, Mutex, PoisonError}; use std::thread::JoinHandle; use std::time::{Duration, Instant}; use figcraft_collab::message::{ - self, Access, Admin, AdminReply, ClientMsg, Hello, LibraryReply, LibraryReq, Manage, OpenLibrary, OpenPlugins, PROTOCOL, PluginReply, PluginReq, - Presence, ServerMsg, + self, Access, Admin, AdminReply, ClientMsg, Hello, HostInfo, LibraryReply, LibraryReq, Manage, OpenHosting, OpenLibrary, OpenPlugins, PROTOCOL, + PluginReply, PluginReq, Presence, ServerMsg, reads_hosting, }; use figcraft_collab::{Outgoing, Server}; use figcraft_engine::live::{Event, MAX_NAME, PEOPLE, sanitize}; +use tungstenite::handshake::server::{ErrorResponse, Request, Response}; use tungstenite::protocol::frame::coding::CloseCode; use tungstenite::protocol::{CloseFrame, Role, WebSocket}; use tungstenite::{Bytes, Message}; @@ -69,11 +75,25 @@ pub struct Handled { pub revoked: Option<(String, String)>, /// A file was removed: whoever is in it is disconnected and it is closed without saving. pub removed: Option, + /// An address was blocked ([`ip_key`]): its connections are dropped. + pub blocked: Option, } impl From for Handled { fn from(reply: AdminReply) -> Self { - Self { reply, revoked: None, removed: None } + Self { reply, revoked: None, removed: None, blocked: None } + } +} + +/// The address limits and blocks count a client by: its IPv4 address (an IPv4-mapped IPv6 address +/// as IPv4), or its IPv6 address's /64 (one subscriber's block: they have billions of addresses). +pub fn ip_key(ip: IpAddr) -> IpAddr { + match ip.to_canonical() { + IpAddr::V6(v6) => { + let [a, b, c, d, ..] = v6.segments(); + IpAddr::V6(Ipv6Addr::new(a, b, c, d, 0, 0, 0, 0)) + } + v4 => v4, } } @@ -142,6 +162,40 @@ pub trait Backend: Send + 'static { PluginReply::Failed { reason: "this session is hosted by an app: plugin directories live on a figcraft-server".into() } } + /// A new connection from `ip` ([`ip_key`]): whether it may go on (why not is shown to the + /// client). + fn connect(&mut self, _ip: IpAddr) -> Result<(), String> { + Ok(()) + } + + /// A message from a connection from `ip`: false disconnects it (that address sends too much). + fn message(&mut self, _ip: IpAddr) -> bool { + true + } + + /// What to tell a client that reads it about the server's hosting, after its welcome to + /// `file` (`None`: nothing). + fn hosting(&mut self, _file: &str) -> Option { + None + } + + /// An open hosting connection from `ip`, with a file's owner token when `file` isn't empty: + /// what the server says about its hosting, or why the connection is refused. + fn open_hosting(&mut self, _ip: IpAddr, _file: &str, _owner: &str) -> Result { + Err("this session is hosted by an app: it doesn't keep files for anyone".into()) + } + + /// A request on an open hosting connection from `ip`; `owns`: the file whose owner token it + /// was opened with. + fn hosted(&mut self, _ip: IpAddr, _owns: Option<&str>, _req: Admin, _open: &dyn Fn(&str) -> Option) -> Handled { + AdminReply::Failed { reason: "nothing to manage here".into() }.into() + } + + /// Someone in `file` (joined with `link`, from `ip`) reports it to the operator. + fn report(&mut self, _file: &str, _link: Option<&str>, _ip: IpAddr, _reason: &str) -> Result<(), String> { + Err("this session is hosted by an app: tell the person hosting it".into()) + } + /// What everyone is told when the hub stops. fn closing(&self) -> Closing; } @@ -153,9 +207,11 @@ enum Out { } enum HubIn { - /// A connection's first message (`Hello`, `Manage`, `OpenLibrary` or `OpenPlugins`). + /// A connection's first message (`Hello`, `Manage`, `OpenLibrary`, `OpenPlugins` or + /// `OpenHosting`) and its client's address ([`ip_key`]). Open { conn: u64, + ip: IpAddr, first: ClientMsg, out: SyncSender, sock: TcpStream, @@ -313,7 +369,98 @@ impl Drop for Open { } } +/// Open connections per client address ([`ip_key`]); an address leaves when its last one closes, +/// so the table never holds more than the open connections. +#[derive(Clone, Default)] +struct PerAddress(Arc>>); + +/// One connection counted for its address until dropped. +struct Counted { + table: PerAddress, + ip: IpAddr, +} + +impl PerAddress { + /// Count a connection from `ip`, unless it has `cap` already (0: no cap). + fn enter(&self, ip: IpAddr, cap: usize) -> Option { + let mut t = self.0.lock().unwrap_or_else(PoisonError::into_inner); + let n = t.entry(ip).or_insert(0); + if cap > 0 && *n >= cap { + return None; + } + *n += 1; + Some(Counted { table: self.clone(), ip }) + } +} + +impl Drop for Counted { + fn drop(&mut self) { + let mut t = self.table.0.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(n) = t.get_mut(&self.ip) { + *n = n.saturating_sub(1); + if *n == 0 { + t.remove(&self.ip); + } + } + } +} + +/// Longest request line an uptime check may have. +const MAX_CHECK_LINE: usize = 2048; + +/// An uptime check: a plain `GET` of a path ending in `/healthz` is answered `200 ok` and closed. +/// True when it was one (the connection is done). Anything else is only peeked at, so a WebSocket +/// handshake goes on untouched. +fn health_check(stream: &mut TcpStream, until: Instant) -> bool { + let mut buf = [0u8; MAX_CHECK_LINE]; + let line = loop { + let n = match stream.peek(&mut buf) { + Ok(0) | Err(_) => return false, + Ok(n) => n, + }; + let seen = buf.get(..n).unwrap_or_default(); + if !seen.starts_with(b"GET ") && !b"GET ".starts_with(seen) { + return false; + } + if let Some(end) = seen.windows(2).position(|w| w == b"\r\n") { + break seen.get(..end).unwrap_or_default().to_vec(); + } + if n == buf.len() || Instant::now() >= until { + return false; + } + // The rest of the line is on its way (it nearly always arrives in one piece). + std::thread::sleep(Duration::from_millis(10)); + }; + let path = line.split(|b| *b == b' ').nth(1).unwrap_or_default(); + let path = path.split(|b| *b == b'?').next().unwrap_or_default(); + if !path.ends_with(b"/healthz") { + return false; + } + // Read the request (its headers end with a blank line), so closing doesn't reset it. + let mut req = Vec::new(); + let mut chunk = [0u8; 1024]; + while !req.windows(4).any(|w| w == b"\r\n\r\n") && req.len() < 16 << 10 { + match stream.read(&mut chunk) { + Ok(0) | Err(_) => break, + Ok(n) => req.extend_from_slice(chunk.get(..n).unwrap_or_default()), + } + } + let _ = stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 3\r\nCache-Control: no-store\r\nConnection: close\r\n\r\nok\n"); + let _ = stream.shutdown(std::net::Shutdown::Write); + true +} + +/// The client a trusted proxy names: the last address of its last `X-Forwarded-For` (the one the +/// proxy added; any before it are whatever the client sent). +fn forwarded_for(req: &Request) -> Option { + let last = req.headers().get_all("x-forwarded-for").iter().next_back()?.to_str().ok()?; + let entry = last.rsplit(',').next()?.trim(); + entry.parse::().ok().or_else(|| entry.parse::().ok().map(|a| a.ip())) +} + fn accept(listener: &TcpListener, hub: &Sender, stop: &AtomicBool, open: &Arc, limits: NetLimits) { + let per_address = PerAddress::default(); let mut next = 0u64; for stream in listener.incoming() { if stop.load(Ordering::SeqCst) { @@ -335,8 +482,8 @@ fn accept(listener: &TcpListener, hub: &Sender, stop: &AtomicBool, open: open.fetch_add(1, Ordering::Relaxed); let guard = Open(open.clone()); next += 1; - let (conn, hub) = (next, hub.clone()); - if let Err(e) = spawn("live-conn", move || serve(stream, conn, &hub, limits, guard)) { + let (conn, hub, per_address) = (next, hub.clone(), per_address.clone()); + if let Err(e) = spawn("live-conn", move || serve(stream, conn, &hub, limits, guard, &per_address)) { log::warn!("live session: can't start a connection thread: {e}"); } } @@ -384,14 +531,28 @@ fn next_message(ws: &mut WebSocket, max: usize) -> Result, limits: NetLimits, _open: Open) { +fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimits, _open: Open, per_address: &PerAddress) { let _ = stream.set_nodelay(true); if stream.set_read_timeout(Some(limits.hello_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() { return; } + if health_check(&mut stream, Instant::now() + limits.hello_timeout) { + return; + } + let Ok(peer) = stream.peer_addr().map(|a| a.ip().to_canonical()) else { return }; + let proxied = limits.trusted_proxy.is_some_and(|p| p.to_canonical() == peer); let Ok((rd, wr, sock)) = Duplex::split(stream, None) else { return }; let cfg = config(limits.max_client_message); - let mut ws = match tungstenite::accept_with_config(rd, Some(config(limits.max_hello))) { + let mut forwarded = None; + // The handshake callback's type is tungstenite's (its error is an HTTP response). + #[allow(clippy::result_large_err)] + let read_client = |req: &Request, resp: Response| -> Result { + if proxied { + forwarded = forwarded_for(req); + } + Ok(resp) + }; + let mut ws = match tungstenite::accept_hdr_with_config(rd, read_client, Some(config(limits.max_hello))) { Ok(ws) => ws, Err(e) => { log::debug!("live session: handshake failed: {e}"); @@ -399,6 +560,11 @@ fn serve(stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimits, _ return; } }; + if proxied && forwarded.is_none() { + log::debug!("live session: connection {conn} came through the proxy without X-Forwarded-For"); + } + let ip = ip_key(forwarded.unwrap_or(peer)); + let counted = per_address.enter(ip, limits.max_per_address); let (out, rx) = sync_channel::(limits.queue.max(4)); let writer = match sock.try_clone().map(|s| spawn("live-write", move || write_loop(WebSocket::from_raw_socket(wr, Role::Server, Some(cfg)), &rx, &s))) { @@ -415,19 +581,31 @@ fn serve(stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimits, _ let _ = out.try_send(Out::Close(CloseCode::Normal, String::new())); } }; - match next_message(&mut ws, limits.max_hello) { - Ok(Some(first @ (ClientMsg::Hello(_) | ClientMsg::Manage(_) | ClientMsg::OpenLibrary(_) | ClientMsg::OpenPlugins(_)))) => { - ws.set_config(|c| *c = cfg); - let opened = sock.try_clone().is_ok_and(|s| hub.send(HubIn::Open { conn, first, out: out.clone(), sock: s }).is_ok()); - if opened && ws.get_ref().set_read_timeout(Some(limits.idle_timeout)).is_ok() { - read_loop(&mut ws, conn, hub, limits, &end); - let _ = hub.send(HubIn::Gone { conn }); + if counted.is_none() { + log::info!("event=refused ip={ip} reason=connections_per_address"); + end(Some("too many connections from your address: close some and try again")); + } else { + match next_message(&mut ws, limits.max_hello) { + Ok(Some( + first @ (ClientMsg::Hello(_) + | ClientMsg::Manage(_) + | ClientMsg::OpenLibrary(_) + | ClientMsg::OpenPlugins(_) + | ClientMsg::OpenHosting(_)), + )) => { + ws.set_config(|c| *c = cfg); + let opened = sock.try_clone().is_ok_and(|s| hub.send(HubIn::Open { conn, ip, first, out: out.clone(), sock: s }).is_ok()); + if opened && ws.get_ref().set_read_timeout(Some(limits.idle_timeout)).is_ok() { + read_loop(&mut ws, conn, hub, limits, &end); + let _ = hub.send(HubIn::Gone { conn }); + } } + Ok(Some(_)) => end(Some("say hello first")), + Ok(None) => {} + Err(why) => end(Some(why)), } - Ok(Some(_)) => end(Some("say hello first")), - Ok(None) => {} - Err(why) => end(Some(why)), } + drop(counted); linger(&mut ws); drop(out); // The writer closes the socket once it has sent what's queued (a refusal, a close). @@ -517,13 +695,24 @@ enum Kind { Library { file: String, access: Access }, /// Using the plugin directory; `publisher`: with the server's key. Plugins { publisher: bool }, + /// Publishing without the key, or managing the file `owns` with its owner token. + Hosting { owns: Option }, } -/// A joined or managing connection. +/// A connection whose first message the hub is looking at. +struct Pending { + out: SyncSender, + sock: TcpStream, + ip: IpAddr, +} + +/// A connection the hub took in. struct Conn { out: SyncSender, sock: TcpStream, kind: Kind, + /// Its client's address ([`ip_key`]). + ip: IpAddr, } /// One open file's session. @@ -549,6 +738,16 @@ impl Room { fn empty(&self) -> bool { self.ids.is_empty() && self.local.is_none() } + + /// What listings say about it. + fn info(&self) -> OpenFile { + OpenFile { people: u32::try_from(self.server.peers().count()).unwrap_or(u32::MAX), seq: self.server.seq() } + } +} + +/// Why a client of another protocol version is refused. +fn unsupported(protocol: u32) -> String { + format!("protocol {protocol} isn't supported (this server speaks {PROTOCOL})") } /// Owns the open files' servers and routes their messages. @@ -605,40 +804,34 @@ impl Hub { fn handle(&mut self, m: HubIn) { match m { - HubIn::Open { conn, first: ClientMsg::Hello(hello), out, sock } => self.join(conn, hello, out, sock), - HubIn::Open { conn, first: ClientMsg::Manage(m), out, sock } => self.manage(conn, &m, out, sock), - HubIn::Open { conn, first: ClientMsg::OpenLibrary(o), out, sock } => self.open_library(conn, o, out, sock), - HubIn::Open { conn, first: ClientMsg::OpenPlugins(o), out, sock } => self.open_plugins(conn, &o, out, sock), - HubIn::Open { out, .. } => refuse(&out, "say hello first"), - HubIn::Msg { conn, msg } => match self.conns.get(&conn).map(|c| &c.kind) { - Some(Kind::Joined { file, id, .. }) => { - let (file, id) = (file.clone(), *id); - self.client_message(&file, id, msg); + HubIn::Open { conn, ip, first, out, sock } => { + // The operator (with the server key) gets in from anywhere; everyone else is + // checked (blocked addresses, connection rates). + let operator = matches!(&first, ClientMsg::Manage(m) if self.backend.manage(&m.key).is_ok()); + if !operator && let Err(why) = self.backend.connect(ip) { + log::info!("event=refused ip={ip} reason={why:?}"); + return refuse(&out, &why); } - Some(Kind::Managing) => match msg { - ClientMsg::Admin(req) => self.admin(conn, req), - ClientMsg::Ping => self.answer(conn, &ServerMsg::Pong), - _ => self.kick(&[conn], "only requests to manage files are spoken on this connection"), - }, - Some(Kind::Library { file, access }) => match msg { - ClientMsg::Library(req) => { - let (file, access) = (file.clone(), *access); - let reply = self.backend.library(&file, access, req); - self.answer(conn, &ServerMsg::Library(Box::new(reply))); - } - ClientMsg::Ping => self.answer(conn, &ServerMsg::Pong), - _ => self.kick(&[conn], "only library requests are spoken on this connection"), - }, - Some(Kind::Plugins { publisher }) => match msg { - ClientMsg::Plugins(req) => { - let reply = self.backend.plugins(*publisher, req); - self.answer(conn, &ServerMsg::Plugins(Box::new(reply))); - } - ClientMsg::Ping => self.answer(conn, &ServerMsg::Pong), - _ => self.kick(&[conn], "only plugin directory requests are spoken on this connection"), - }, - None => {} - }, + let c = Pending { out, sock, ip }; + match first { + ClientMsg::Hello(hello) => self.join(conn, hello, c), + ClientMsg::Manage(m) => self.manage(conn, &m, c), + ClientMsg::OpenLibrary(o) => self.open_library(conn, o, c), + ClientMsg::OpenPlugins(o) => self.open_plugins(conn, &o, c), + ClientMsg::OpenHosting(o) => self.open_hosting(conn, &o, c), + _ => refuse(&c.out, "say hello first"), + } + } + HubIn::Msg { conn, msg } => { + let Some(c) = self.conns.get(&conn) else { return }; + let ip = c.ip; + // The operator (with the server key) isn't held to the limits on addresses. + if !matches!(c.kind, Kind::Managing) && !self.backend.message(ip) { + log::info!("event=refused ip={ip} reason=messages_per_address"); + return self.kick(&[conn], "too many messages from your address: wait a minute and try again"); + } + self.message(conn, ip, msg); + } HubIn::Gone { conn } => self.gone(conn), HubIn::Local(p) | HubIn::LocalMsg(ClientMsg::Presence(p)) => { if let Some((file, id)) = self.local_id() { @@ -660,6 +853,56 @@ impl Hub { } } + /// A message on connection `conn` (from `ip`), by what the connection is for. + fn message(&mut self, conn: u64, ip: IpAddr, msg: ClientMsg) { + match self.conns.get(&conn).map(|c| &c.kind) { + Some(Kind::Joined { file, link, .. }) if matches!(msg, ClientMsg::Report { .. }) => { + let (file, link) = (file.clone(), link.clone()); + let reason = if let ClientMsg::Report { reason } = &msg { reason.as_str() } else { "" }; + let error = self.backend.report(&file, link.as_deref(), ip, reason).err(); + self.answer(conn, &ServerMsg::Reported { error }); + } + Some(Kind::Joined { file, id, .. }) => { + let (file, id) = (file.clone(), *id); + self.client_message(&file, id, msg); + } + Some(Kind::Hosting { owns }) => match msg { + ClientMsg::Admin(req) => { + let owns = owns.clone(); + let rooms = &self.rooms; + let open = |file: &str| rooms.get(file).map(Room::info); + let handled = self.backend.hosted(ip, owns.as_deref(), req, &open); + self.handled(conn, handled); + } + ClientMsg::Ping => self.answer(conn, &ServerMsg::Pong), + _ => self.kick(&[conn], "only publishing and managing your own files are spoken on this connection"), + }, + Some(Kind::Managing) => match msg { + ClientMsg::Admin(req) => self.admin(conn, req), + ClientMsg::Ping => self.answer(conn, &ServerMsg::Pong), + _ => self.kick(&[conn], "only requests to manage files are spoken on this connection"), + }, + Some(Kind::Library { file, access }) => match msg { + ClientMsg::Library(req) => { + let (file, access) = (file.clone(), *access); + let reply = self.backend.library(&file, access, req); + self.answer(conn, &ServerMsg::Library(Box::new(reply))); + } + ClientMsg::Ping => self.answer(conn, &ServerMsg::Pong), + _ => self.kick(&[conn], "only library requests are spoken on this connection"), + }, + Some(Kind::Plugins { publisher }) => match msg { + ClientMsg::Plugins(req) => { + let reply = self.backend.plugins(*publisher, req); + self.answer(conn, &ServerMsg::Plugins(Box::new(reply))); + } + ClientMsg::Ping => self.answer(conn, &ServerMsg::Pong), + _ => self.kick(&[conn], "only plugin directory requests are spoken on this connection"), + }, + None => {} + } + } + /// The hosting person's file and client id. fn local_id(&self) -> Option<(String, u32)> { let file = self.local.clone()?; @@ -682,11 +925,12 @@ impl Hub { self.outbox.extend(outs.into_iter().map(|o| (file.to_string(), o))); } - fn join(&mut self, conn: u64, mut hello: Hello, out: SyncSender, sock: TcpStream) { + fn join(&mut self, conn: u64, mut hello: Hello, c: Pending) { let admission = match self.backend.admit(&hello.file, &hello.token) { Ok(a) => a, - Err(why) => return refuse(&out, &why), + Err(why) => return refuse(&c.out, &why), }; + let hosting = reads_hosting(&hello.client_version); // The backend checked the secret: the sessions don't keep it. hello.token.clear(); let file = hello.file.clone(); @@ -695,9 +939,10 @@ impl Hub { Ok(server) => { self.rooms.insert(file.clone(), Room::new(server)); } - Err(why) => return refuse(&out, &why), + Err(why) => return refuse(&c.out, &why), } } + let info = if hosting { self.backend.hosting(&file) } else { None }; let Some(room) = self.rooms.get_mut(&file) else { return }; // Colours: the first one nobody has. let taken: Vec<&str> = room.server.peers().map(|p| p.color.as_str()).collect(); @@ -722,66 +967,93 @@ impl Hub { more.push(Outgoing::To(id, ServerMsg::PeerJoined(me.clone()))); } more.extend(room.latest.iter().map(|(&from, p)| Outgoing::To(id, ServerMsg::Presence { client_id: from, presence: p.clone() }))); + // A client that reads it hears about the server's hosting (operator, terms, expiry). + more.extend(info.map(|i| Outgoing::To(id, ServerMsg::Hosting(Box::new(i))))); if let Some(c) = old.and_then(|old| self.conns.remove(&old)) { shutdown(&c.sock); } - self.conns.insert(conn, Conn { out, sock, kind: Kind::Joined { file: file.clone(), id, link: admission.link } }); + self.conns + .insert(conn, Conn { out: c.out, sock: c.sock, kind: Kind::Joined { file: file.clone(), id, link: admission.link }, ip: c.ip }); self.queue(&file, more); } Err(ServerMsg::Refused { reason }) => { - refuse(&out, &reason); + refuse(&c.out, &reason); self.close_if_empty(&file); } Err(_) => { - refuse(&out, "refused"); + refuse(&c.out, "refused"); self.close_if_empty(&file); } } } - fn manage(&mut self, conn: u64, m: &Manage, out: SyncSender, sock: TcpStream) { - if m.protocol != PROTOCOL { - return refuse(&out, &format!("protocol {} isn't supported (this server speaks {PROTOCOL})", m.protocol)); - } - if let Err(why) = self.backend.manage(&m.key) { - return refuse(&out, &why); - } - log::info!("live session: connection {conn} manages files"); - self.conns.insert(conn, Conn { out, sock, kind: Kind::Managing }); - self.answer(conn, &ServerMsg::Admin(Box::new(AdminReply::Ready))); + /// Take in connection `c` as `kind` and send it `first`. + fn take_in(&mut self, conn: u64, c: Pending, kind: Kind, first: &ServerMsg) { + self.conns.insert(conn, Conn { out: c.out, sock: c.sock, kind, ip: c.ip }); + self.answer(conn, first); } - fn open_library(&mut self, conn: u64, o: OpenLibrary, out: SyncSender, sock: TcpStream) { + fn manage(&mut self, conn: u64, m: &Manage, c: Pending) { + if m.protocol != PROTOCOL { + return refuse(&c.out, &unsupported(m.protocol)); + } + if let Err(why) = self.backend.manage(&m.key) { + log::info!("event=refused ip={} reason=server_key", c.ip); + return refuse(&c.out, &why); + } + log::info!("live session: connection {conn} manages files"); + self.take_in(conn, c, Kind::Managing, &ServerMsg::Admin(Box::new(AdminReply::Ready))); + } + + fn open_library(&mut self, conn: u64, o: OpenLibrary, c: Pending) { if o.protocol != PROTOCOL { - return refuse(&out, &format!("protocol {} isn't supported (this server speaks {PROTOCOL})", o.protocol)); + return refuse(&c.out, &unsupported(o.protocol)); } let access = match self.backend.admit(&o.file, &o.token) { Ok(a) => a.access, - Err(why) => return refuse(&out, &why), + Err(why) => return refuse(&c.out, &why), }; - self.conns.insert(conn, Conn { out, sock, kind: Kind::Library { file: o.file, access } }); - self.answer(conn, &ServerMsg::Library(Box::new(LibraryReply::Ready { access }))); + self.take_in(conn, c, Kind::Library { file: o.file, access }, &ServerMsg::Library(Box::new(LibraryReply::Ready { access }))); } /// A plugin directory connection: anyone may browse; a key, when given, must be the server's. - fn open_plugins(&mut self, conn: u64, o: &OpenPlugins, out: SyncSender, sock: TcpStream) { + fn open_plugins(&mut self, conn: u64, o: &OpenPlugins, c: Pending) { if o.protocol != PROTOCOL { - return refuse(&out, &format!("protocol {} isn't supported (this server speaks {PROTOCOL})", o.protocol)); + return refuse(&c.out, &unsupported(o.protocol)); } let publisher = !o.key.is_empty(); if publisher && let Err(why) = self.backend.manage(&o.key) { - return refuse(&out, &why); + return refuse(&c.out, &why); } - self.conns.insert(conn, Conn { out, sock, kind: Kind::Plugins { publisher } }); - self.answer(conn, &ServerMsg::Plugins(Box::new(PluginReply::Ready { publisher }))); + self.take_in(conn, c, Kind::Plugins { publisher }, &ServerMsg::Plugins(Box::new(PluginReply::Ready { publisher }))); + } + + /// An open hosting connection: what the server says about its hosting, then publishing (when + /// it is open to the public) or managing the file whose owner token it gave. + fn open_hosting(&mut self, conn: u64, o: &OpenHosting, c: Pending) { + if o.protocol != PROTOCOL { + return refuse(&c.out, &unsupported(o.protocol)); + } + let info = match self.backend.open_hosting(c.ip, &o.file, &o.owner) { + Ok(i) => i, + Err(why) => { + log::info!("event=refused ip={} reason={why:?}", c.ip); + return refuse(&c.out, &why); + } + }; + let owns = (!o.owner.is_empty()).then(|| o.file.clone()); + self.take_in(conn, c, Kind::Hosting { owns }, &ServerMsg::Hosting(Box::new(info))); } fn admin(&mut self, conn: u64, req: Admin) { let rooms = &self.rooms; - let open = |file: &str| { - rooms.get(file).map(|r| OpenFile { people: u32::try_from(r.server.peers().count()).unwrap_or(u32::MAX), seq: r.server.seq() }) - }; + let open = |file: &str| rooms.get(file).map(Room::info); let handled = self.backend.admin(req, &open); + self.handled(conn, handled); + } + + /// Answer a managing or hosting request, and disconnect whoever it affects. + fn handled(&mut self, conn: u64, handled: Handled) { self.answer(conn, &ServerMsg::Admin(Box::new(handled.reply))); if let Some((file, link)) = handled.revoked { let users: Vec = self @@ -799,6 +1071,10 @@ impl Hub { self.rooms.remove(&file); self.kick(&users, "this file was removed from the server"); } + if let Some(ip) = handled.blocked { + let users: Vec = self.conns.iter().filter(|(_, c)| c.ip == ip).map(|(k, _)| *k).collect(); + self.kick(&users, "this server no longer accepts connections from your address"); + } } /// Send one message to one connection now (managing connections' answers). diff --git a/crates/collab-net/src/join.rs b/crates/collab-net/src/join.rs index a1c0c5ef..e8155008 100644 --- a/crates/collab-net/src/join.rs +++ b/crates/collab-net/src/join.rs @@ -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(_) => {} } diff --git a/crates/collab-net/src/limits.rs b/crates/collab-net/src/limits.rs index c2d3a9a7..dcbeb380 100644 --- a/crates/collab-net/src/limits.rs +++ b/crates/collab-net/src/limits.rs @@ -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,11 @@ 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, } impl Default for NetLimits { @@ -43,6 +49,8 @@ impl Default for NetLimits { keepalive: Duration::from_secs(15), queue: 1024, write_timeout: Duration::from_secs(10), + max_per_address: 0, + trusted_proxy: None, } } } diff --git a/crates/collab-net/src/link.rs b/crates/collab-net/src/link.rs index 45bb43b6..c63c3d85 100644 --- a/crates/collab-net/src/link.rs +++ b/crates/collab-net/src/link.rs @@ -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) -> Result<(HostInfo, Option), 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 { 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, limits: &NetLimits) -> Result<(HostInfo, Option), 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). diff --git a/crates/collab-net/src/manage.rs b/crates/collab-net/src/manage.rs index dfadb18d..70201b16 100644 --- a/crates/collab-net/src/manage.rs +++ b/crates/collab-net/src/manage.rs @@ -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, 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, + limits: &NetLimits, + timeout: Duration, +) -> Result<(HostInfo, Vec), 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. diff --git a/crates/collab/src/client.rs b/crates/collab/src/client.rs index 2bfa337b..1f57722c 100644 --- a/crates/collab/src/client.rs +++ b/crates/collab/src/client.rs @@ -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 { diff --git a/crates/collab/src/codec_tests.rs b/crates/collab/src/codec_tests.rs index 8d24580e..84931904 100644 --- a/crates/collab/src/codec_tests.rs +++ b/crates/collab/src/codec_tests.rs @@ -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, Vec) { 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, Vec) { 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, Vec) { 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) } diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs index 671eec0a..cfd87c99 100644 --- a/crates/collab/src/message.rs +++ b/crates/collab/src/message.rs @@ -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, + }, + Listing(Vec), + Reports(Vec), + Blocked(Vec), +} + +/// 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, + /// 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, } /// 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), + /// 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), + /// The answer to a [`ClientMsg::Report`]: kept, or why not. + Reported { + error: Option, + }, +} + +/// `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. diff --git a/crates/collab/src/server.rs b/crates/collab/src/server.rs index 912b8788..f463e83b 100644 --- a/crates/collab/src/server.rs +++ b/crates/collab/src/server.rs @@ -388,7 +388,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() })] } } diff --git a/crates/engine/src/background_tests.rs b/crates/engine/src/background_tests.rs index 82f1c568..d4c014d7 100644 --- a/crates/engine/src/background_tests.rs +++ b/crates/engine/src/background_tests.rs @@ -111,6 +111,7 @@ fn answer(s: &mut Session, wire: &Arc>) -> 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) }); } diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index a22536d6..06198bf8 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -290,6 +290,27 @@ pub fn specs() -> Vec { 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(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 761b447d..0364ac87 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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, + /// 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, @@ -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(), } } diff --git a/crates/engine/src/live.rs b/crates/engine/src/live.rs index 7b6749ba..d61dc8e2 100644 --- a/crates/engine/src/live.rs +++ b/crates/engine/src/live.rs @@ -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, + /// When the server deletes the file if nobody opens it (seconds since 1970). + pub expires: Option, } /// 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), /// 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 }, } 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), Library(std::result::Result), Upload(std::result::Result), + /// What the server says about its hosting, and its answer to the request, if one was sent. + Hosting(std::result::Result<(HostInfo, Option), 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, /// 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 { 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, + ) -> std::result::Result<(HostInfo, Option), 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>, /// The file you published to a server in this session, with its links. pub published: Option, + /// 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, /// 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::(); + 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 { 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) -> Result<(HostInfo, Option)> { + 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 { + 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 { + 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::>()})), + // 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 { + 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 { const CMD: &str = "collab.join"; let link_text = req_string(CMD, p, "link")?; @@ -1280,10 +1421,31 @@ pub fn specs() -> Vec { 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", diff --git a/crates/engine/src/live_access_tests.rs b/crates/engine/src/live_access_tests.rs index 60b479a2..d3b32ae5 100644 --- a/crates/engine/src/live_access_tests.rs +++ b/crates/engine/src/live_access_tests.rs @@ -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()); diff --git a/crates/engine/src/live_tests.rs b/crates/engine/src/live_tests.rs index e7207f75..8e10f336 100644 --- a/crates/engine/src/live_tests.rs +++ b/crates/engine/src/live_tests.rs @@ -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")); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 49582aae..59ac62ed 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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"}), diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 1838d477..7afabc32 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -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." diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 875b4f87..2e6e36b8 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -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." diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index f74e2c2e..c8512dc5 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -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" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 472702f4..9b4e0e37 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -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." diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index b8bdfd89..05bc5fe2 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -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} 個のみ表示。" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 90d4c50b..717d6b73 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -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}개만 표시됨." diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 2b7a8935..e09d50a1 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -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." diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 6bf2d180..9b870258 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -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} 个已更改的图层。" diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 40df7a9a..caf6147a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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; diff --git a/crates/ui-egui/src/live.rs b/crates/ui-egui/src/live.rs index af275462..7bfeebde 100644 --- a/crates/ui-egui/src/live.rs +++ b/crates/ui-egui/src/live.rs @@ -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, + focus_report: bool, /// Others' chat messages as last seen, with when they changed (UI clock): they fade from then. chat_seen: BTreeMap, /// 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) { + 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(), }) } diff --git a/crates/ui-egui/src/live_server_tests.rs b/crates/ui-egui/src/live_server_tests.rs index 7df903ef..113568a1 100644 --- a/crates/ui-egui/src/live_server_tests.rs +++ b/crates/ui-egui/src/live_server_tests.rs @@ -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) -> 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]