M9: open hosting mode for public figcraft-server

Config-gated (open_publish): anyone publishes without the server key and
gets an owner token (128-bit, shown once, stored hashed) that manages only
that file. Per-address limits (X-Forwarded-For only from trusted_proxy,
bounded tables), global caps, idle expiry, reports, persisted blocks,
files list --sort, check, GET /healthz on the same listener, startup
warnings. The app asks the server first, publishes without a key when it
is open, shows the owner token once, the host/terms line, the expiry date
and a report button; collab.serverInfo, collab.manageFile, collab.report
and pref.publishServer for MCP/CLI. Protocol additions stay within
version 2, gated like signals.
This commit is contained in:
bflatastic
2026-10-12 11:40:03 +07:00
committed by BFlat
parent f2802cca02
commit b7af7c7ef7
37 changed files with 2503 additions and 203 deletions
+104
View File
@@ -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<IpAddr, Address>,
}
/// `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<Instant> = self.table.values().map(|a| a.used).collect();
used.sort_unstable();
let Some(&cut) = used.get(used.len() / 2) else { return };
self.table.retain(|_, a| a.used > cut);
}
}
+122 -27
View File
@@ -7,7 +7,7 @@ use std::net::{IpAddr, SocketAddr};
use std::path::{Path, PathBuf};
use std::time::Duration;
use figcraft_collab::message::{Access, Admin, AdminReply, FileInfo, LinkInfo, NewLink, PluginEntry, PluginReply, PluginReq};
use figcraft_collab::message::{Access, Admin, AdminReply, FileRow, LinkInfo, NewLink, PluginEntry, PluginReply, PluginReq, ReportInfo};
use figcraft_collab_net::hub::Backend;
use figcraft_collab_net::{Endpoint, Invite, manage};
use serde::{Deserialize, Serialize};
@@ -25,13 +25,18 @@ figcraft-server: FigCraft's collaboration server (files, share links, live sessi
Usage:
figcraft-server [serve] [--listen ADDR] [--data DIR] [--public-url URL] [--config FILE]
figcraft-server key [--rotate] the address to publish to, with the server key
figcraft-server files [list] the files kept
figcraft-server check check the settings (after editing the --config file)
figcraft-server files [list] [--sort size|age|idle] the files kept (biggest, oldest or longest unopened first)
figcraft-server files remove FILE remove a file and its links for good
figcraft-server links list FILE a file's share links
figcraft-server links create FILE --access edit|view [--label TEXT]
figcraft-server links revoke FILE LINK revoke a link (whoever joined with it is disconnected)
figcraft-server plugins [list] the plugin directory: every plugin and its versions
figcraft-server plugins remove ID [--version V] unpublish a plugin (every version, or one)
figcraft-server reports what people reported about files
figcraft-server block-ip IP refuse an address (an IPv6 address: its /64) and drop its connections
figcraft-server unblock-ip IP accept an address again
figcraft-server blocked the blocked addresses
Options (also keys of the --config TOML file):
--listen ADDR where to listen (default 127.0.0.1:47900; 0.0.0.0:47900 for every network)
@@ -39,25 +44,48 @@ Options (also keys of the --config TOML file):
--public-url URL how links name the server behind a reverse proxy (wss://host/path/)
--config FILE settings: listen, data, public_url, max_files, max_file_mb, max_links,
max_connections, max_people, compact_after, compact_mb, max_plugins,
max_plugin_mb, max_plugins_mb
max_plugin_mb, max_plugins_mb; for a public server: open_publish,
operator_contact, terms_url, trusted_proxy, idle_days, max_total_mb,
max_files_per_ip, max_mb_per_ip, max_connections_per_ip,
connects_per_minute, publishes_per_hour, messages_per_second
-h, --help this text
-V, --version the version
Logs go to standard output (RUST_LOG=debug for more); they never contain keys or tokens.
Logs go to standard output (RUST_LOG=debug for more); they never contain keys, tokens or file contents.
GET /healthz on the same port answers \"ok\" (for uptime checks).
";
/// How `files list` orders the files.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Sort {
/// By id.
#[default]
Id,
/// Biggest first.
Size,
/// Oldest first.
Age,
/// Longest unused first.
Idle,
}
/// What to do.
#[derive(Clone, Debug, PartialEq)]
pub enum Command {
Serve,
Key { rotate: bool },
Files,
Files { sort: Sort },
RemoveFile { file: String },
Links { file: String },
CreateLink { file: String, access: Access, label: String },
Revoke { file: String, link: String },
Plugins,
RemovePlugin { id: String, version: Option<String> },
Reports,
Check,
Block { ip: String },
Unblock { ip: String },
Blocked,
Help,
Version,
}
@@ -81,6 +109,7 @@ pub fn parse(args: &[String]) -> Result<Cli, String> {
let mut label = String::new();
let mut rotate = false;
let mut version = None;
let mut sort = Sort::Id;
let mut it = args.iter();
while let Some(a) = it.next() {
let mut value = |name: &str| it.next().cloned().ok_or_else(|| format!("{name} needs a value"));
@@ -98,6 +127,14 @@ pub fn parse(args: &[String]) -> Result<Cli, String> {
"--access" => access = Some(Access::parse(&value("--access")?).ok_or("--access must be edit or view")?),
"--label" => label = value("--label")?,
"--rotate" => rotate = true,
"--sort" => {
sort = match value("--sort")?.as_str() {
"size" => Sort::Size,
"age" => Sort::Age,
"idle" => Sort::Idle,
_ => return Err("--sort must be size, age or idle".into()),
}
}
s if s.starts_with('-') => return Err(format!("unknown option {s}")),
s => words.push(s.to_string()),
}
@@ -106,7 +143,7 @@ pub fn parse(args: &[String]) -> Result<Cli, String> {
let command = match w.as_slice() {
[] | ["serve"] => Command::Serve,
["key"] => Command::Key { rotate },
["files"] | ["files", "list"] => Command::Files,
["files"] | ["files", "list"] => Command::Files { sort },
["files", "remove", file] => Command::RemoveFile { file: file.to_string() },
["links", "list", file] => Command::Links { file: file.to_string() },
["links", "create", file] => {
@@ -115,6 +152,11 @@ pub fn parse(args: &[String]) -> Result<Cli, String> {
["links", "revoke", file, link] => Command::Revoke { file: file.to_string(), link: link.to_string() },
["plugins"] | ["plugins", "list"] => Command::Plugins,
["plugins", "remove", id] => Command::RemovePlugin { id: id.to_string(), version },
["reports"] => Command::Reports,
["check"] => Command::Check,
["block-ip", ip] => Command::Block { ip: ip.to_string() },
["unblock-ip", ip] => Command::Unblock { ip: ip.to_string() },
["blocked"] => Command::Blocked,
_ => return Err(format!("unknown command `{}`", words.join(" "))),
};
config.check()?;
@@ -161,8 +203,29 @@ pub fn run(cli: &Cli) -> Result<String, String> {
Command::Version => Ok(format!("figcraft-server {}\n", env!("CARGO_PKG_VERSION"))),
Command::Serve => Err("serving runs in the foreground: run figcraft-server without a command".into()),
Command::Key { rotate } => key(cfg, *rotate),
Command::Files => match admin(cfg, Admin::Files)? {
AdminReply::Files(files) => Ok(files_table(&files)),
Command::Files { sort } => match admin(cfg, Admin::Listing)? {
AdminReply::Listing(files) => Ok(files_table(files, *sort, store::now())),
other => unexpected(other),
},
// `parse` checked them.
Command::Check => {
Ok(format!("The settings are fine{}.\n", if cfg.open_publish { ": open to the public (anyone may publish without the key)" } else { "" }))
}
Command::Reports => match admin(cfg, Admin::Reports)? {
AdminReply::Reports(reports) => Ok(reports_table(&reports)),
other => unexpected(other),
},
Command::Block { ip } => match admin(cfg, Admin::Block { ip: ip.clone() })? {
AdminReply::Done => Ok(format!("Blocked {ip}: its connections were dropped and new ones are refused.\n")),
other => unexpected(other),
},
Command::Unblock { ip } => match admin(cfg, Admin::Unblock { ip: ip.clone() })? {
AdminReply::Done => Ok(format!("Unblocked {ip}.\n")),
other => unexpected(other),
},
Command::Blocked => match admin(cfg, Admin::Blocked)? {
AdminReply::Blocked(list) if list.is_empty() => Ok("No addresses are blocked.\n".into()),
AdminReply::Blocked(list) => Ok(list.iter().map(|ip| format!("{ip}\n")).collect()),
other => unexpected(other),
},
Command::RemoveFile { file } => match admin(cfg, Admin::Remove { file: file.clone() })? {
@@ -284,6 +347,9 @@ fn key(cfg: &Config, rotate: bool) -> Result<String, String> {
let _ = writeln!(out, "Publish to this address (in FigCraft: Share > Publish to server…):");
let _ = writeln!(out, " {}?key={key}", public.url());
let _ = writeln!(out, "Whoever has it can publish files and manage their links: share it only with people you trust.");
if cfg.open_publish {
let _ = writeln!(out, "Open to the public: anyone can publish to {} without the key (each file gets an owner token).", public.url());
}
Ok(out)
}
@@ -302,17 +368,60 @@ fn new_link(cfg: &Config, l: &NewLink) -> Result<String, String> {
Ok(out)
}
fn files_table(files: &[FileInfo]) -> String {
/// When a listed file was last used (made, changed or opened).
fn used(f: &FileRow) -> u64 {
f.info.created.max(f.info.updated).max(f.opened)
}
fn files_table(mut files: Vec<FileRow>, sort: Sort, now: u64) -> String {
if files.is_empty() {
return "No files yet (publish one from FigCraft: Share > Publish to server…).\n".into();
}
let mut out = String::from("ID NAME PEOPLE UPDATED SIZE\n");
for f in files {
let _ = writeln!(out, "{:<16} {:<30} {:>6} {:<20} {}", f.id, elide(&f.name, 30), f.people, date(f.updated), size(f.bytes));
match sort {
Sort::Id => {}
Sort::Size => files.sort_by_key(|f| std::cmp::Reverse(f.info.bytes)),
Sort::Age => files.sort_by_key(|f| f.info.created),
Sort::Idle => files.sort_by_key(used),
}
let mut out = String::from("ID NAME PEOPLE SIZE CREATED IDLE EXPIRES REPORTS BY\n");
for f in &files {
let idle = format!("{}d", now.saturating_sub(used(f)) / 86_400);
let expires = f.expires.map_or_else(|| "never".to_string(), day);
let by = if f.open { f.ip.as_str() } else { "key" };
let _ = writeln!(
out,
"{:<16} {:<30} {:>6} {:<9} {:<10} {:>4} {:<10} {:>7} {}",
f.info.id,
elide(&f.info.name, 30),
f.info.people,
size(f.info.bytes),
day(f.info.created),
idle,
expires,
f.reports,
by
);
}
out
}
fn reports_table(reports: &[ReportInfo]) -> String {
if reports.is_empty() {
return "No reports.\n".into();
}
let mut out = String::new();
for r in reports {
let _ = writeln!(out, "{} file {} ({}) from {} link {}", date(r.at), r.file, elide(&r.name, 40), r.ip, r.link);
let _ = writeln!(out, " {}", r.reason);
}
out
}
/// `YYYY-MM-DD` for seconds since 1970.
fn day(secs: u64) -> String {
date(secs).chars().take(10).collect()
}
fn links_table(links: &[LinkInfo]) -> String {
if links.is_empty() {
return "No links (make one with `figcraft-server links create FILE --access view`).\n".into();
@@ -337,18 +446,4 @@ fn size(bytes: u64) -> String {
}
/// `YYYY-MM-DD HH:MM UTC` for seconds since 1970.
pub fn date(secs: u64) -> String {
let days = i64::try_from(secs / 86_400).unwrap_or(0);
let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60);
// Days to a civil date (proleptic Gregorian).
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(mo <= 2);
format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02} UTC")
}
pub use figcraft_collab::message::utc_date as date;
+6 -6
View File
@@ -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]
+127 -10
View File
@@ -1,13 +1,14 @@
//! The server's settings: defaults, then a TOML file (`--config server.toml`), then flags.
use std::net::SocketAddr;
use std::net::{IpAddr, SocketAddr};
use std::path::{Path, PathBuf};
use figcraft_collab_net::{Endpoint, NetLimits};
use serde::Deserialize;
use crate::abuse::AbuseConfig;
use crate::directory::DirectoryConfig;
use crate::store::StoreConfig;
use crate::store::{HostingConfig, StoreConfig};
/// The port the server listens on unless told otherwise (the app's sessions try it first too).
pub const DEFAULT_PORT: u16 = figcraft_collab_net::DEFAULT_PORT;
@@ -26,8 +27,8 @@ pub struct Config {
/// Most files kept.
pub max_files: usize,
/// Largest file (its snapshot), in MiB (a welcome carries the whole file, and messages are at
/// most 64 MiB).
pub max_file_mb: u64,
/// most 64 MiB). Unset: 64, or 16 when open to the public ([`Config::file_mb`]).
pub max_file_mb: Option<u64>,
/// Most share links per file.
pub max_links: usize,
/// Most open connections.
@@ -44,6 +45,30 @@ pub struct Config {
pub max_plugin_mb: u64,
/// Most MiB of plugin bundles kept.
pub max_plugins_mb: u64,
/// Anyone may publish a file without the server key (and gets an owner token for it): a
/// server open to the public. The limits below then apply per address.
pub open_publish: bool,
/// How to reach the operator (an email address or a web page) and the terms of use (a web
/// address): the app shows them where people publish.
pub operator_contact: String,
pub terms_url: String,
/// The reverse proxy in front of the server (`127.0.0.1` for Caddy or nginx on the same
/// machine): only its `X-Forwarded-For` is believed.
pub trusted_proxy: Option<String>,
/// Files published without the key are deleted after this many days without anyone opening
/// them (0: kept; fractions allowed).
pub idle_days: f64,
/// Publishing without the key stops while the files take this many MiB in all.
pub max_total_mb: u64,
/// Per address, when open: most files published without the key, and their MiB.
pub max_files_per_ip: usize,
pub max_mb_per_ip: u64,
/// Per address, when open: most connections at once, new connections a minute, files
/// published an hour and messages a second.
pub max_connections_per_ip: usize,
pub connects_per_minute: u32,
pub publishes_per_hour: u32,
pub messages_per_second: u32,
}
impl Default for Config {
@@ -53,7 +78,7 @@ impl Default for Config {
data: PathBuf::from("figcraft-data"),
public_url: None,
max_files: 1000,
max_file_mb: 64,
max_file_mb: None,
max_links: 100,
max_connections: 512,
max_people: 256,
@@ -62,6 +87,18 @@ impl Default for Config {
max_plugins: 500,
max_plugin_mb: 16,
max_plugins_mb: 1024,
open_publish: false,
operator_contact: String::new(),
terms_url: String::new(),
trusted_proxy: None,
idle_days: 30.0,
max_total_mb: 10_240,
max_files_per_ip: 20,
max_mb_per_ip: 256,
max_connections_per_ip: 32,
connects_per_minute: 30,
publishes_per_hour: 10,
messages_per_second: 200,
}
}
}
@@ -88,13 +125,71 @@ impl Config {
Ok(Some(e))
}
/// The largest file in MiB: as set, else 64, or 16 on a server open to the public (strangers'
/// files stay small unless the operator says otherwise).
pub fn file_mb(&self) -> u64 {
self.max_file_mb.unwrap_or(if self.open_publish { 16 } else { 64 })
}
/// What an operator should hear about these settings at startup (a public server without an
/// abuse contact, or one whose clients would all count as its proxy).
pub fn warnings(&self) -> Vec<String> {
let mut out = Vec::new();
if !self.open_publish {
return out;
}
if self.operator_contact.trim().is_empty() {
out.push("open_publish is on but operator_contact is empty: people can't reach you about abuse; set it (and terms_url)".into());
}
let behind_proxy = self.public().ok().flatten().is_some_and(|e| e.secure) || self.addr().is_ok_and(|a| a.ip().is_loopback());
if behind_proxy && self.trusted_proxy.as_deref().is_none_or(|p| p.trim().is_empty()) {
out.push(
"open_publish is on without trusted_proxy: behind a reverse proxy every client counts as the proxy's address, so one person's limits stop everyone; set trusted_proxy = \"127.0.0.1\"".into(),
);
}
if self.idle_days == 0.0 {
out.push("open_publish is on with idle_days = 0: files published without the key are never deleted".into());
}
out
}
/// The trusted proxy's address, when set.
pub fn proxy(&self) -> Result<Option<IpAddr>, String> {
let Some(p) = self.trusted_proxy.as_deref().map(str::trim).filter(|p| !p.is_empty()) else { return Ok(None) };
p.parse().map(Some).map_err(|_| "`trusted_proxy` must be an IP address, like 127.0.0.1".to_string())
}
/// Check everything (before starting).
pub fn check(&self) -> Result<(), String> {
self.addr()?;
self.public()?;
self.proxy()?;
if !self.idle_days.is_finite() || !(0.0..=3650.0).contains(&self.idle_days) {
return Err("`idle_days` must be from 0 to 3650".into());
}
let terms = self.terms_url.trim();
if !terms.is_empty() && !(terms.starts_with("https://") || terms.starts_with("http://")) || terms.contains(char::is_whitespace) {
return Err("`terms_url` must be a web address (https://…)".into());
}
if self.operator_contact.chars().count() > 120 || self.operator_contact.chars().any(char::is_control) || terms.len() > 500 {
return Err("`operator_contact` is at most 120 characters and `terms_url` at most 500, on one line".into());
}
for (name, v) in [
("max_total_mb", self.max_total_mb),
("max_files_per_ip", self.max_files_per_ip as u64),
("max_mb_per_ip", self.max_mb_per_ip),
("max_connections_per_ip", self.max_connections_per_ip as u64),
("connects_per_minute", u64::from(self.connects_per_minute)),
("publishes_per_hour", u64::from(self.publishes_per_hour)),
("messages_per_second", u64::from(self.messages_per_second)),
] {
if v == 0 {
return Err(format!("`{name}` must be at least 1"));
}
}
for (name, v) in [
("max_files", self.max_files as u64),
("max_file_mb", self.max_file_mb),
("max_file_mb", self.file_mb()),
("max_links", self.max_links as u64),
("max_connections", self.max_connections as u64),
("max_people", self.max_people as u64),
@@ -107,7 +202,7 @@ impl Config {
return Err(format!("`{name}` must be at least 1"));
}
}
if self.max_file_mb > 4096 || self.compact_mb > 4096 {
if self.file_mb() > 4096 || self.compact_mb > 4096 {
return Err("sizes are in MiB and at most 4096".into());
}
// A bundle travels in one message (at most 64 MiB) next to its entry.
@@ -124,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()
}
}
}
+6
View File
@@ -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<Running, String> {
for p in store.problems() {
log::warn!("{p}");
}
for w in cfg.warnings() {
log::warn!("{w}");
}
let files = store.len();
let addr = cfg.addr()?;
let listener = TcpListener::bind(addr).map_err(|e| format!("can't listen on {addr}: {e}"))?;
@@ -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;
+491
View File
@@ -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<figcraft_collab::Change> {
let mut d = Document::new(name);
let page = d.first_page().unwrap();
d.create(page, NodeKind::Rectangle, "Card", 0.0, 0.0, 100.0, 60.0).unwrap();
d.repair();
Replica::new(d).snapshot().unwrap()
}
fn ip(s: &str) -> IpAddr {
s.parse().unwrap()
}
fn ex(s: &mut figcraft_engine::Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn cli(cfg: &Config, command: Command) -> Result<String, String> {
cli::run(&Cli { config: cfg.clone(), command })
}
#[test]
fn anyone_publishes_and_the_owner_token_manages_the_file() {
let dir = TempDir::new("public");
let cfg = open(dir.path());
let mut server = crate::start(&cfg).unwrap();
let addr = server.addr();
let url = format!("ws://{addr}/");
let mut a = session("Ada");
// The server says it is open, who runs it and its terms before anything is uploaded.
let info = ex(&mut a, "collab.serverInfo", json!({"server": url}));
assert_eq!(info["open"], true);
assert_eq!(info["operator"], "abuse@example.com");
assert_eq!(info["terms"], "https://example.com/terms");
assert_eq!(info["idleDays"], 30.0);
// Published without a key: links, an owner token shown once, and an expiry date.
ex(&mut a, "collab.publish", json!({"server": url, "name": "Poster"}));
pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live.published.is_some() && s[0].live.hosting.is_some());
let p = a.live.published.clone().unwrap();
let owner = p.owner.clone().expect("an owner token");
assert_eq!(owner.len(), 22, "128 bits");
assert!(p.expires.unwrap() > crate::store::now() + 29 * 86_400);
let summary = a.live_summary();
assert_eq!(summary["published"]["owner"], owner.as_str());
assert_eq!(summary["hosting"]["operator"], "abuse@example.com");
// The owner token is kept only as a hash.
let meta = std::fs::read_to_string(dir.path().join("files").join(&p.file).join("meta.json")).unwrap();
assert!(!meta.contains(&owner) && meta.contains(&crate::store::hash(&owner)) && meta.contains("127.0.0.1"), "{meta}");
// Someone joining hears about the hosting too (with the file's expiry).
let mut b = join(&p.edit, "Bo");
pump(&mut [&mut a, &mut b], "Bo in, with the hosting", |s| live(s[1]) && s[1].live.hosting.as_ref().is_some_and(|h| h.expires.is_some()));
// The owner lists, makes, revokes links and renames the file.
let manage = |s: &mut figcraft_engine::Session, extra: Value| {
let mut p2 = json!({"server": url, "file": p.file, "owner": owner});
p2.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
s.execute("collab.manageFile", &p2)
};
let links = manage(&mut a, json!({"action": "links"})).unwrap();
assert_eq!(links["links"].as_array().unwrap().len(), 2);
let made = manage(&mut a, json!({"action": "createLink", "access": "view", "label": "Review"})).unwrap();
assert!(made["link"].as_str().unwrap().starts_with("figcraft://join?host="), "{made}");
let view_id = links["links"].as_array().unwrap().iter().find(|l| l["access"] == "view").unwrap()["id"].as_str().unwrap().to_string();
assert_eq!(manage(&mut a, json!({"action": "revoke", "link": view_id})).unwrap()["done"], true);
manage(&mut a, json!({"action": "rename", "name": "Poster v2"})).unwrap();
assert!(cli(&cfg, Command::Files { sort: Sort::Size }).unwrap().contains("Poster v2"));
// A wrong token, or another file's, manages nothing.
let e = a.execute("collab.manageFile", &json!({"server": url, "file": p.file, "owner": "AAAAAAAAAAAAAAAAAAAAAA", "action": "links"}));
assert!(e.unwrap_err().to_string().contains("doesn't manage"));
let mut raw = Raw::connect(addr);
raw.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: String::new(), file: p.file.clone(), owner: owner.clone() }));
assert!(matches!(raw.next(), ServerMsg::Hosting(_)));
raw.send(&ClientMsg::Admin(Admin::Remove { file: "0000000000000000".into() }));
assert!(matches!(raw.next(), ServerMsg::Admin(r) if matches!(*r, AdminReply::Failed { ref reason } if reason.contains("only its own"))));
raw.send(&ClientMsg::Admin(Admin::Listing));
assert!(matches!(raw.next(), ServerMsg::Admin(r) if matches!(*r, AdminReply::Failed { ref reason } if reason.contains("server key"))));
// Removing it disconnects everyone in it.
assert_eq!(manage(&mut a, json!({"action": "remove"})).unwrap()["done"], true);
pump(&mut [&mut a, &mut b], "everyone told", |s| s.iter().all(|x| x.live_summary()["status"] == "failed"));
assert!(b.message.as_deref().unwrap_or_default().contains("removed"), "{:?}", b.message);
assert!(!dir.path().join("files").join(&p.file).exists());
server.stop();
}
#[test]
fn a_private_server_still_needs_its_key() {
let dir = TempDir::new("private");
let mut server = crate::start(&config(dir.path())).unwrap();
let url = format!("ws://{}/", server.addr());
let mut a = session("Ada");
let info = ex(&mut a, "collab.serverInfo", json!({"server": url}));
assert_eq!(info["open"], false);
ex(&mut a, "collab.publish", json!({"server": url}));
pump(&mut [&mut a], "the refusal", |s| s[0].live_summary()["status"] == "failed");
assert!(a.message.as_deref().unwrap_or_default().contains("only from people who have its key"), "{:?}", a.message);
assert!(!dir.path().join("files").read_dir().unwrap().any(|_| true), "nothing kept");
// The operator's key publishes as before (no owner token, no expiry).
let p = crate::test_util::publish(&mut a, dir.path(), server.addr());
assert!(p.owner.is_none() && p.expires.is_none());
server.stop();
}
#[test]
fn caps_per_address_and_for_everyone() {
let dir = TempDir::new("caps");
let mut c = open(dir.path());
c.max_files_per_ip = 2;
c.publishes_per_hour = 100;
let mut store = Store::open(dir.path(), c.store()).unwrap();
let (a, b) = (ip("203.0.113.1"), ip("203.0.113.2"));
store.publish_open(a, "One", &snapshot("One")).unwrap();
store.publish_open(a, "Two", &snapshot("Two")).unwrap();
let e = store.publish_open(a, "Three", &snapshot("Three")).unwrap_err();
assert!(e.contains("keeps 2 files"), "{e}");
store.publish_open(b, "Other", &snapshot("Other")).unwrap();
drop(store);
// Bytes per address, and for everyone (a byte: nothing fits).
let mut tight = c.store();
tight.hosting.max_bytes_per_ip = 1;
let mut store = Store::open(dir.path(), tight.clone()).unwrap();
let e = store.publish_open(ip("203.0.113.3"), "Big", &snapshot("Big")).unwrap_err();
assert!(e.contains("MiB it may keep"), "{e}");
drop(store);
tight.hosting.max_bytes_per_ip = u64::MAX;
tight.hosting.max_total_bytes = 1;
let mut store = Store::open(dir.path(), tight).unwrap();
let e = store.publish_open(ip("203.0.113.3"), "Big", &snapshot("Big")).unwrap_err();
assert!(e.contains("server is full"), "{e}");
drop(store);
// Publishing too often.
let mut c3 = open(dir.path());
c3.publishes_per_hour = 1;
let mut store = Store::open(dir.path(), c3.store()).unwrap();
store.publish_open(ip("198.51.100.7"), "Once", &snapshot("Once")).unwrap();
let e = store.publish_open(ip("198.51.100.7"), "Twice", &snapshot("Twice")).unwrap_err();
assert!(e.contains("too many files published"), "{e}");
// Closed to the public: nobody publishes without the key.
drop(store);
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
assert_eq!(store.publish_open(ip("198.51.100.8"), "No", &snapshot("No")).unwrap_err(), KEY_ONLY);
}
#[test]
fn idle_files_published_without_the_key_expire() {
let dir = TempDir::new("expiry");
let mut c = open(dir.path());
c.idle_days = 2.0;
let mut store = Store::open(dir.path(), c.store()).unwrap();
let (public, ..) = store.publish_open(ip("203.0.113.9"), "Public", &snapshot("Public")).unwrap();
let (fresh, ..) = store.publish_open(ip("203.0.113.9"), "Fresh", &snapshot("Fresh")).unwrap();
let (keyed, ..) = store.publish("Operator's", &snapshot("Operator's")).unwrap();
store.age(&public.id, 2 * 86_400 + 1);
store.age(&keyed.id, 365 * 86_400);
store.age(&fresh.id, 86_400);
assert_eq!(store.sweep(), vec![public.id.clone()]);
let ids: Vec<String> = store.listing(&|_| None).into_iter().map(|r| r.info.id).collect();
assert!(!ids.contains(&public.id) && ids.contains(&fresh.id) && ids.contains(&keyed.id));
assert!(!dir.path().join("files").join(&public.id).exists());
// Opening it counts as using it: idle time starts over.
let mut server = figcraft_collab_net::hub::Backend::open(&mut store, &fresh.id).unwrap();
store.age(&fresh.id, 0);
assert!(store.listing(&|_| None).iter().any(|r| r.info.id == fresh.id && r.expires.unwrap() > crate::store::now() + 86_400));
// An open file never expires under someone.
store.age(&fresh.id, 10 * 86_400);
assert!(store.sweep().is_empty());
figcraft_collab_net::hub::Backend::idle(&mut store, &fresh.id, &mut server);
assert!(store.sweep().is_empty(), "everyone just left: the idle time starts now");
// A startup finds what expired while the server was down.
store.age(&fresh.id, 3 * 86_400);
drop(store);
let store = Store::open(dir.path(), c.store()).unwrap();
assert!(store.listing(&|_| None).iter().all(|r| r.info.id != fresh.id));
}
#[test]
fn rate_limits_and_blocks_refuse_with_a_reason() {
let dir = TempDir::new("limits");
let mut c = open(dir.path());
c.connects_per_minute = 3;
let mut server = crate::start(&c).unwrap();
let addr = server.addr();
let ask = || {
let mut raw = Raw::connect(addr);
raw.send(&ClientMsg::OpenHosting(OpenHosting {
protocol: PROTOCOL,
client_version: String::new(),
file: String::new(),
owner: String::new(),
}));
raw.next()
};
for _ in 0..3 {
assert!(matches!(ask(), ServerMsg::Hosting(_)));
}
assert!(matches!(ask(), ServerMsg::Refused { reason } if reason.contains("too many connections from your address")));
server.stop();
// Messages per address, and blocking.
let mut c = open(dir.path());
c.messages_per_second = 5;
let mut server = crate::start(&c).unwrap();
let addr = server.addr();
let mut raw = Raw::connect(addr);
raw.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: String::new(), file: String::new(), owner: String::new() }));
assert!(matches!(raw.next(), ServerMsg::Hosting(_)));
let refused = (0..200).find_map(|_| {
raw.send(&ClientMsg::Ping);
match raw.next() {
ServerMsg::Refused { reason } => Some(reason),
_ => None,
}
});
assert!(refused.is_some_and(|r| r.contains("too many messages from your address")));
let mut held = Raw::connect(addr);
held.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: String::new(), file: String::new(), owner: String::new() }));
assert!(matches!(held.next(), ServerMsg::Hosting(_)));
assert!(cli(&c, Command::Block { ip: "127.0.0.1".into() }).unwrap().contains("Blocked"));
assert!(matches!(held.next(), ServerMsg::Refused { reason } if reason.contains("no longer accepts")));
let mut again = Raw::connect(addr);
again.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: String::new(), file: String::new(), owner: String::new() }));
assert!(matches!(again.next(), ServerMsg::Refused { .. }));
assert_eq!(cli(&c, Command::Blocked).unwrap(), "127.0.0.1\n");
server.stop();
// Kept across a restart (the commands work on the directory itself then).
assert_eq!(cli(&c, Command::Blocked).unwrap(), "127.0.0.1\n");
assert!(cli(&c, Command::Unblock { ip: "127.0.0.1".into() }).unwrap().contains("Unblocked"));
assert!(cli(&c, Command::Block { ip: "2001:db8:1:2:3:4:5:6".into() }).unwrap().contains("Blocked"));
assert_eq!(cli(&c, Command::Blocked).unwrap(), "2001:db8:1:2::\n", "an IPv6 address blocks its /64");
assert!(cli(&c, Command::Block { ip: "not an address".into() }).is_err());
}
#[test]
fn participants_report_files_to_the_operator() {
let dir = TempDir::new("reports");
let cfg = open(dir.path());
let mut server = crate::start(&cfg).unwrap();
let url = format!("ws://{}/", server.addr());
let mut a = session("Ada");
ex(&mut a, "collab.publish", json!({"server": url, "name": "Flyer"}));
pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live.published.is_some());
let p = a.live.published.clone().unwrap();
let mut b = join(&p.view, "Bo");
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]) && s[1].live.hosting.is_some());
assert!(b.execute("collab.report", &json!({"reason": " "})).is_err());
ex(&mut b, "collab.report", json!({"reason": "Spam\nwith a link"}));
pump(&mut [&mut b], "the answer", |s| s[0].message.as_deref().is_some_and(|m| m.contains("operator")));
let reports = cli(&cfg, Command::Reports).unwrap();
assert!(reports.contains(&p.file) && reports.contains("Spam with a link") && reports.contains("Flyer"), "{reports}");
assert!(cli(&cfg, Command::Files { sort: Sort::Idle }).unwrap().lines().any(|l| l.contains(&p.file) && l.contains("127.0.0.1")));
server.stop();
// Kept on disk.
assert!(cli(&cfg, Command::Reports).unwrap().contains("Spam with a link"));
}
/// Connect with `X-Forwarded-For: forwarded` and open a hosting connection: what the server said.
fn via_proxy(addr: SocketAddr, forwarded: &str) -> (tungstenite::WebSocket<TcpStream>, ServerMsg) {
let s = TcpStream::connect(addr).unwrap();
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
let mut req = format!("ws://{addr}/").into_client_request().unwrap();
req.headers_mut().insert("X-Forwarded-For", forwarded.parse().unwrap());
let (mut ws, _) = tungstenite::client::client(req, s).unwrap();
let open = ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: String::new(), file: String::new(), owner: String::new() });
ws.send(Message::Binary(Bytes::from(message::encode(&open).unwrap()))).unwrap();
let reply = loop {
if let Message::Binary(b) = ws.read().unwrap() {
break message::decode(&b, usize::MAX).unwrap();
}
};
(ws, reply)
}
#[test]
fn forwarded_addresses_count_only_from_the_trusted_proxy() {
let dir = TempDir::new("proxy");
let mut c = open(dir.path());
c.max_connections_per_ip = 1;
c.trusted_proxy = Some("127.0.0.1".into());
let mut server = crate::start(&c).unwrap();
let addr = server.addr();
// Behind the proxy each client is its own address: the last one the proxy added.
let (_one, r) = via_proxy(addr, "203.0.113.1");
assert!(matches!(r, ServerMsg::Hosting(_)));
let (_two, r) = via_proxy(addr, "10.0.0.1, 203.0.113.2");
assert!(matches!(r, ServerMsg::Hosting(_)));
let (_three, r) = via_proxy(addr, "203.0.113.2, 203.0.113.1");
assert!(matches!(r, ServerMsg::Refused { reason } if reason.contains("too many connections")));
// Blocking the forwarded address blocks that client only.
cli(&c, Command::Block { ip: "203.0.113.9".into() }).unwrap();
assert!(matches!(via_proxy(addr, "203.0.113.9").1, ServerMsg::Refused { .. }));
assert!(matches!(via_proxy(addr, "203.0.113.10").1, ServerMsg::Hosting(_)));
server.stop();
// Without a trusted proxy the header is anyone's to forge: the connection's own address counts.
c.trusted_proxy = None;
let mut server = crate::start(&c).unwrap();
let addr = server.addr();
let (_one, r) = via_proxy(addr, "203.0.113.1");
assert!(matches!(r, ServerMsg::Hosting(_)));
let (_two, r) = via_proxy(addr, "203.0.113.2");
assert!(matches!(r, ServerMsg::Refused { .. }));
server.stop();
}
/// Send `request` on a raw socket; what came back.
fn http(addr: SocketAddr, request: &str) -> String {
let mut s = TcpStream::connect(addr).unwrap();
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
s.write_all(request.as_bytes()).unwrap();
let mut out = String::new();
let _ = s.read_to_string(&mut out);
out
}
#[test]
fn uptime_checks_share_the_port() {
let dir = TempDir::new("healthz");
let mut server = crate::start(&config(dir.path())).unwrap();
let addr = server.addr();
let ok = http(addr, "GET /healthz HTTP/1.1\r\nHost: x\r\n\r\n");
assert!(ok.starts_with("HTTP/1.1 200 OK") && ok.ends_with("ok\n"), "{ok}");
// Under a path (behind a proxy's prefix), and with a query.
assert!(http(addr, "GET /figcraft/healthz?x=1 HTTP/1.1\r\n\r\n").starts_with("HTTP/1.1 200"));
// Anything else is a WebSocket handshake or nothing.
assert!(!http(addr, "GET /healthzz HTTP/1.1\r\n\r\n").contains("200 OK"));
assert!(!http(addr, "POST /healthz HTTP/1.1\r\n\r\n").contains("200 OK"));
// Sessions still work beside it.
let mut a = session("Ada");
let p = crate::test_util::publish(&mut a, dir.path(), addr);
assert!(!p.edit.is_empty());
server.stop();
}
#[test]
fn older_clients_get_clear_answers() {
let dir = TempDir::new("old");
let mut server = crate::start(&open(dir.path())).unwrap();
let addr = server.addr();
// An older app tries to publish with a wrong key: it is told the server is open.
let e =
manage::request(&Endpoint::plain(addr.to_string()), "wrong", vec![Admin::Files], &NetLimits::default(), Duration::from_secs(5)).unwrap_err();
assert!(e.contains("open to the public"), "{e}");
// A hello that doesn't announce `hosting` is never sent what it can't read: after its
// welcome, the answer to its ping comes before anything about hosting would.
let mut a = session("Ada");
let p = crate::test_util::publish(&mut a, dir.path(), addr);
let edit = Invite::parse(&p.edit).unwrap();
let mut raw = Raw::connect(addr);
let mut hello = figcraft_collab::Client::hello(&edit.file, &edit.token, "Old", "");
if let ClientMsg::Hello(h) = &mut hello {
h.client_version = "0.3.0 signals".into();
}
raw.send(&hello);
raw.send(&ClientMsg::Ping);
let seen: Vec<ServerMsg> = std::iter::from_fn(|| Some(raw.next())).take_while(|m| *m != ServerMsg::Pong).collect();
assert!(seen.iter().any(|m| matches!(m, ServerMsg::Welcome { .. })));
assert!(!seen.iter().any(|m| matches!(m, ServerMsg::Hosting(_))), "{seen:?}");
// The operator's key still manages everything.
let ok =
manage::request(&Endpoint::plain(addr.to_string()), &key(dir.path()), vec![Admin::Listing], &NetLimits::default(), Duration::from_secs(5))
.unwrap();
assert!(matches!(&ok[..], [AdminReply::Listing(rows)] if rows.len() == 1));
server.stop();
}
#[test]
fn the_address_table_stays_bounded() {
let mut abuse = Abuse::new(AbuseConfig { open: true, connects_per_minute: 1, publishes_per_hour: 1, messages_per_second: 1 });
let t = Instant::now();
for i in 0..(MAX_ADDRESSES as u32 * 3) {
assert!(abuse.allow(IpAddr::from(i.to_be_bytes()), Action::Connect, t + Duration::from_micros(u64::from(i))));
}
assert!(abuse.len() <= MAX_ADDRESSES);
// A remembered address is limited; a private server limits only reports.
let me = ip("192.0.2.1");
assert!(abuse.allow(me, Action::Connect, t) && !abuse.allow(me, Action::Connect, t));
let mut closed = Abuse::new(AbuseConfig { open: false, connects_per_minute: 1, publishes_per_hour: 1, messages_per_second: 1 });
assert!((0..100).all(|_| closed.allow(me, Action::Connect, t)));
assert!((0..10).all(|_| closed.allow(me, Action::Report, t)) && !closed.allow(me, Action::Report, t));
}
#[test]
fn open_mode_has_safe_defaults_and_warns_the_operator() {
let private = Config::default();
assert_eq!(private.file_mb(), 64, "a private server behaves as before");
assert!(private.warnings().is_empty());
assert_eq!(private.net().max_per_address, 0);
let open = Config { open_publish: true, public_url: Some("wss://figcraft.example.com/".into()), ..Config::default() };
assert_eq!(open.file_mb(), 16, "strangers' files stay small");
assert_eq!(open.store().hosting.idle_secs, 30 * 86_400, "idle files expire");
assert_eq!(open.net().max_per_address, 32);
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();
}
+24 -1
View File
@@ -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(),
+399 -14
View File
@@ -12,8 +12,15 @@
//! <data>/files/<id>/library.bin its team library, when one was published (a `.figcraft` archive)
//! <data>/files/<id>/usage.json how other files use that library, as their apps report it
//! <data>/plugins/ the plugin directory (see `directory`)
//! <data>/blocked.json addresses the operator blocked
//! <data>/reports.json reports people sent about files
//! ```
//!
//! A server open to the public ([`HostingConfig::open`]) also keeps files anyone published
//! without the key: such a file has an owner token (stored as its hash) that manages it, the
//! address it came from (for the per-address caps and the operator), and it is deleted once nobody
//! has opened it for [`HostingConfig::idle_secs`].
//!
//! An open file appends its server's journal to its log; the hub has the store make the appends
//! durable before anyone hears of them. After [`StoreConfig::compact_after`] entries or
//! [`StoreConfig::compact_bytes`] of log, when everyone has left, and when the server stops, the
@@ -21,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<LibraryMeta>,
/// Published without the key: SHA-256 of its owner token (hex), and the address it came from.
#[serde(default, skip_serializing_if = "Option::is_none")]
owner: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
ip: Option<String>,
/// When someone last opened it (seconds since 1970).
#[serde(default)]
opened: u64,
}
impl Meta {
/// When it was last used: made, changed or opened.
fn used(&self) -> u64 {
self.created.max(self.updated).max(self.opened)
}
}
/// What a file's team library is (`library.bin` holds it).
@@ -130,6 +203,12 @@ pub struct Store {
problems: Vec<String>,
/// The plugin directory.
directory: Directory,
/// What addresses may do how often, the ones blocked, and the reports kept.
abuse: Abuse,
blocked: BTreeSet<IpAddr>,
reports: Vec<ReportInfo>,
/// 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::<String>(BLOCKED).iter().filter_map(|s| s.parse().ok()).map(ip_key).collect();
store.reports = store.read_list(REPORTS);
store.sweep();
Ok(Some(store))
}
/// A list kept at the root (missing: empty; damaged: empty and reported, kept until the next
/// change writes over it).
fn read_list<T: serde::de::DeserializeOwned>(&mut self, name: &str) -> Vec<T> {
let path = self.root.join(name);
if !path.exists() {
return Vec::new();
}
match disk::read_capped(&path, 16 << 20).and_then(|b| serde_json::from_slice(&b).map_err(|e| format!("{name} is damaged ({e})"))) {
Ok(list) => list,
Err(e) => {
self.problems.push(e);
Vec::new()
}
}
}
fn scan(&mut self, dir: &Path) {
let entries = match fs::read_dir(dir) {
Ok(e) => e,
@@ -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<OpenFile>) -> 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<u64> {
let idle = self.cfg.hosting.idle_secs;
(meta.owner.is_some() && idle > 0).then(|| meta.used().saturating_add(idle))
}
fn info(&self, id: &str, e: &Entry, open: Option<OpenFile>) -> FileInfo {
let 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<IpAddr>,
fits: &dyn Fn(u64) -> Result<(), String>,
) -> Result<(FileInfo, NewLink, NewLink, Option<String>), String> {
if self.files.len() >= self.cfg.max_files {
return Err(format!("the server keeps at most {} files", self.cfg.max_files));
}
@@ -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<OpenFile>) -> Vec<FileRow> {
self.files
.iter()
.map(|(id, e)| FileRow {
info: self.info(id, e, open(id)),
opened: e.meta.opened,
expires: self.expires(&e.meta),
open: e.meta.owner.is_some(),
ip: e.meta.ip.clone().unwrap_or_default(),
reports: u32::try_from(self.reports.iter().filter(|r| r.file == *id).count()).unwrap_or(u32::MAX),
})
.collect()
}
/// Rename a file (its listing; the document inside keeps its own name).
pub fn rename(&mut self, file: &str, name: &str) -> Result<(), String> {
let name = clean_name(name);
if name.is_empty() {
return Err("give the file a name".into());
}
let path = self.dir(file).join(META);
let entry = self.entry(file)?;
let before = std::mem::replace(&mut entry.meta.name, name);
if let Err(e) = write_json(&path, &entry.meta) {
entry.meta.name = before;
return Err(format!("couldn't rename the file: {e}"));
}
Ok(())
}
/// Delete the files published without the key that nobody opened for the idle time (not
/// those open now); their ids.
pub fn sweep(&mut self) -> Vec<String> {
let t = now();
self.next_sweep = t.saturating_add(SWEEP_EVERY);
let due: Vec<String> =
self.files.iter().filter(|(_, e)| e.log.is_none() && self.expires(&e.meta).is_some_and(|x| x <= t)).map(|(id, _)| id.clone()).collect();
for id in &due {
match self.remove(id) {
Ok(()) => log::info!("event=expired file={id}"),
Err(e) => log::warn!("event=expire_failed file={id} reason={e:?}"),
}
}
due
}
/// Make a file look unused for `secs` more (tests: expiry without waiting).
#[cfg(test)]
pub fn age(&mut self, file: &str, secs: u64) {
let path = self.dir(file).join(META);
if let Some(e) = self.files.get_mut(file) {
let m = &mut e.meta;
(m.created, m.updated, m.opened) = (m.created.saturating_sub(secs), m.updated.saturating_sub(secs), m.opened.saturating_sub(secs));
let _ = write_json(&path, m);
}
}
/// Note that a file was opened now (it is used: it doesn't expire).
fn touch(&mut self, file: &str) {
let path = self.dir(file).join(META);
if let Some(e) = self.files.get_mut(file) {
e.meta.opened = now();
if let Err(err) = write_json(&path, &e.meta) {
log::warn!("file {file}: couldn't update {META}: {err}");
}
}
}
/// What the server says about its hosting (with `file`'s expiry).
fn host_info(&self, file: Option<&str>) -> HostInfo {
let h = &self.cfg.hosting;
let expires = file.and_then(|f| self.files.get(f)).and_then(|e| self.expires(&e.meta));
HostInfo { open: h.open, operator: h.operator.clone(), terms: h.terms.clone(), idle_secs: h.idle_secs, max_file_mb: h.max_file_mb, expires }
}
/// Block or unblock an address (an IPv6 address: its /64); the address as kept.
pub fn block(&mut self, ip: &str, on: bool) -> Result<IpAddr, String> {
let ip = ip_key(ip.trim().parse::<IpAddr>().map_err(|_| format!("`{}` isn't an IP address", ip.trim()))?);
if on && !self.blocked.contains(&ip) && self.blocked.len() >= MAX_BLOCKED {
return Err(format!("at most {MAX_BLOCKED} addresses can be blocked"));
}
let changed = if on { self.blocked.insert(ip) } else { self.blocked.remove(&ip) };
if changed {
let list: Vec<String> = self.blocked.iter().map(IpAddr::to_string).collect();
write_json(&self.root.join(BLOCKED), &list).map_err(|e| format!("couldn't keep the blocked addresses: {e}"))?;
log::info!("event={} ip={ip}", if on { "blocked" } else { "unblocked" });
}
Ok(ip)
}
/// The blocked addresses.
pub fn blocked(&self) -> Vec<String> {
self.blocked.iter().map(IpAddr::to_string).collect()
}
/// The reports kept, oldest first.
pub fn reports(&self) -> &[ReportInfo] {
&self.reports
}
/// Keep a report about `file` (from someone who joined it with `link`, at `ip`).
pub fn keep_report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> {
let reason: String = reason.trim().chars().map(|c| if c.is_control() { ' ' } else { c }).take(MAX_REASON).collect();
if reason.is_empty() {
return Err("say what is wrong with the file".into());
}
let name = self.entry(file)?.meta.name.clone();
if !self.abuse.allow(ip, Action::Report, Instant::now()) {
return Err("too many reports from your address lately: try again in an hour".into());
}
self.reports.push(ReportInfo {
at: now(),
file: file.to_string(),
name,
link: link.unwrap_or_default().to_string(),
ip: ip.to_string(),
reason,
});
if self.reports.len() > MAX_REPORTS {
let extra = self.reports.len() - MAX_REPORTS;
self.reports.drain(..extra);
}
write_json(&self.root.join(REPORTS), &self.reports).map_err(|e| format!("couldn't keep the report: {e}"))?;
log::info!("event=report file={file} ip={ip}");
Ok(())
}
/// The files kept.
@@ -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<Server, String> {
self.touch(file);
let dir = self.dir(file);
let (generation, state) = disk::decode_snapshot(&disk::read_capped(&dir.join(SNAPSHOT), self.cfg.max_file_bytes)?)?;
let mut server = Server::restore(state, "", self.cfg.core).map_err(|e| format!("the snapshot can't be used: {e}"))?;
@@ -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<HostInfo> {
Some(self.host_info(Some(file)))
}
fn open_hosting(&mut self, _ip: IpAddr, file: &str, owner: &str) -> Result<HostInfo, String> {
if file.is_empty() && owner.is_empty() {
return Ok(self.host_info(None));
}
let presented = hash(owner);
let owns = self.files.get(file).and_then(|e| e.meta.owner.as_deref()).is_some_and(|h| same_secret(&presented, h));
if !owns {
return Err("that owner token doesn't manage a file of that id here (it may have been removed or expired)".into());
}
Ok(self.host_info(Some(file)))
}
fn hosted(&mut self, ip: IpAddr, owns: Option<&str>, req: Admin, open: &dyn Fn(&str) -> Option<OpenFile>) -> Handled {
let failed = |reason: &str| Handled::from(AdminReply::Failed { reason: reason.to_string() });
let file = match &req {
Admin::Publish { name, snapshot } => {
return match self.publish_open(ip, name, snapshot) {
Ok((file, edit, view, owner)) => {
let expires = self.files.get(&file.id).and_then(|e| self.expires(&e.meta));
AdminReply::Hosted { file, edit, view, owner, expires }.into()
}
Err(e) => {
log::info!("event=publish_refused ip={ip} reason={e:?}");
Handled::from(AdminReply::Failed { reason: e })
}
};
}
Admin::Links { file }
| Admin::CreateLink { file, .. }
| Admin::Revoke { file, .. }
| Admin::Rename { file, .. }
| Admin::Remove { file } => file,
_ => return failed("that needs the server key"),
};
if owns != Some(file.as_str()) {
return failed("an owner token manages only its own file");
}
self.admin(req, open)
}
fn report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> {
self.keep_report(file, link, ip, reason)
}
fn admin(&mut self, req: Admin, open: &dyn Fn(&str) -> Option<OpenFile>) -> Handled {
@@ -712,9 +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 }
}
}
}
+2 -2
View File
@@ -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);
+30 -16
View File
@@ -16,7 +16,7 @@ use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use figcraft_collab::Replica;
use figcraft_collab::message::{self, Admin, AdminReply, ClientMsg, Presence, ServerMsg};
use figcraft_collab::message::{self, Admin, AdminReply, ClientMsg, HostInfo, Presence, ServerMsg};
use figcraft_doc::Document;
use figcraft_engine::live::{Event, Published, Status};
use tungstenite::protocol::frame::coding::CloseCode;
@@ -34,8 +34,8 @@ use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
/// What [`Guest::publish`] uploads, and where.
pub struct Publish {
pub server: Endpoint,
/// The server's key.
pub key: String,
/// The server's key (`None`: publish as anyone may, on a server open to the public).
pub key: Option<String>,
/// The file's name on the server.
pub name: String,
pub doc: Arc<Document>,
@@ -75,10 +75,13 @@ impl Guest {
let me = tx.clone();
let thread = std::thread::Builder::new().name("live-publish".into()).spawn(move || {
inbox.push(Event::Status(Status::Connecting));
let (cfg, published) = match upload(&job, cfg) {
let (cfg, published, hosting) = match upload(&job, cfg) {
Ok(done) => done,
Err(e) => return inbox.push(Event::Status(Status::Failed { reason: format!("Couldn't publish to the server: {e}") })),
};
if let Some(h) = hosting {
inbox.push(Event::Hosting(h));
}
inbox.push(Event::Published(published));
// Left while uploading: the file stays on the server, nobody joins it. Anything else
// that came meanwhile (presence) waits for the session.
@@ -138,33 +141,44 @@ struct Conn {
sock: TcpStream,
}
/// Keep `doc` on `server` as a new file named `name`: its edit link, and where it is with its links.
/// Keep `doc` on `server` as a new file named `name`, with the server's `key` (or, without one, as
/// anyone may on a server open to the public): its edit link, where it is with its links (and its
/// owner token without a key), and what the server says about its hosting (without a key).
pub(crate) fn upload_file(
server: &Endpoint,
key: &str,
key: Option<&str>,
name: &str,
doc: &Document,
limits: &NetLimits,
timeout: Duration,
) -> Result<(Invite, Published), String> {
) -> Result<(Invite, Published, Option<HostInfo>), String> {
let snapshot = Replica::new(doc.clone()).snapshot().map_err(|e| e.to_string())?;
let replies = manage::request(server, key, vec![Admin::Publish { name: name.to_string(), snapshot }], limits, timeout)?;
let (file, edit, view) = match replies.into_iter().next() {
Some(AdminReply::Published { file, edit, view }) => (file, edit, view),
let publish = vec![Admin::Publish { name: name.to_string(), snapshot }];
let (replies, hosting) = match key {
Some(key) => (manage::request(server, key, publish, limits, timeout)?, None),
None => {
let (info, replies) = manage::hosting(server, "", "", publish, limits, timeout)?;
(replies, Some(info))
}
};
let (file, edit, view, owner, expires) = match replies.into_iter().next() {
Some(AdminReply::Published { file, edit, view }) => (file, edit, view, None, None),
Some(AdminReply::Hosted { file, edit, view, owner, expires }) => (file, edit, view, Some(owner), expires),
Some(AdminReply::Failed { reason }) => return Err(reason),
_ => return Err("the server didn't say where the file is".into()),
};
let link = |token: String| Invite { server: server.clone(), file: file.id.clone(), token };
let (edit, view) = (link(edit.token), link(view.token));
let published = Published { server: server.display(), file: file.id.clone(), name: file.name, edit: edit.link(), view: view.link() };
Ok((edit, published))
let published =
Published { server: server.display(), file: file.id.clone(), name: file.name, edit: edit.link(), view: view.link(), owner, expires };
Ok((edit, published, hosting))
}
/// Upload the document; the join settings for the new file and its links.
fn upload(job: &Publish, mut cfg: GuestConfig) -> Result<(GuestConfig, Published), String> {
let (edit, published) = upload_file(&job.server, &job.key, &job.name, &job.doc, &cfg.limits, cfg.connect_timeout)?;
/// Upload the document; the join settings for the new file, its links and the server's hosting.
fn upload(job: &Publish, mut cfg: GuestConfig) -> Result<(GuestConfig, Published, Option<HostInfo>), String> {
let (edit, published, hosting) = upload_file(&job.server, job.key.as_deref(), &job.name, &job.doc, &cfg.limits, cfg.connect_timeout)?;
cfg.invite = edit;
Ok((cfg, published))
Ok((cfg, published, hosting))
}
fn connect(cfg: &GuestConfig) -> Result<Conn, String> {
+360 -84
View File
@@ -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<String>,
/// An address was blocked ([`ip_key`]): its connections are dropped.
pub blocked: Option<IpAddr>,
}
impl From<AdminReply> 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<HostInfo> {
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<HostInfo, String> {
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<OpenFile>) -> 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<Out>,
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<Mutex<HashMap<IpAddr, usize>>>);
/// 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<Counted> {
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<IpAddr> {
let last = req.headers().get_all("x-forwarded-for").iter().next_back()?.to_str().ok()?;
let entry = last.rsplit(',').next()?.trim();
entry.parse::<IpAddr>().ok().or_else(|| entry.parse::<SocketAddr>().ok().map(|a| a.ip()))
}
fn accept(listener: &TcpListener, hub: &Sender<HubIn>, stop: &AtomicBool, open: &Arc<AtomicUsize>, 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<HubIn>, 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<Duplex>, max: usize) -> Result<Option<ClientM
}
/// One connection's reader (the writer runs on its own thread).
fn serve(stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimits, _open: Open) {
fn serve(mut stream: TcpStream, conn: u64, hub: &Sender<HubIn>, 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<Response, ErrorResponse> {
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<HubIn>, 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::<Out>(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<HubIn>, 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<String> },
}
/// A joined or managing connection.
/// A connection whose first message the hub is looking at.
struct Pending {
out: SyncSender<Out>,
sock: TcpStream,
ip: IpAddr,
}
/// A connection the hub took in.
struct Conn {
out: SyncSender<Out>,
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<B: Backend> Hub<B> {
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<B: Backend> Hub<B> {
}
}
/// 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<B: Backend> Hub<B> {
self.outbox.extend(outs.into_iter().map(|o| (file.to_string(), o)));
}
fn join(&mut self, conn: u64, mut hello: Hello, out: SyncSender<Out>, 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<B: Backend> Hub<B> {
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<B: Backend> Hub<B> {
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<Out>, 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<Out>, 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<Out>, 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<u64> = self
@@ -799,6 +1071,10 @@ impl<B: Backend> Hub<B> {
self.rooms.remove(&file);
self.kick(&users, "this file was removed from the server");
}
if let Some(ip) = handled.blocked {
let users: Vec<u64> = 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).
+2
View File
@@ -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(_) => {}
}
+8
View File
@@ -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<IpAddr>,
}
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,
}
}
}
+24 -4
View File
@@ -5,9 +5,9 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use figcraft_collab::message::{Access, ClientMsg, LibraryReply, LibraryReq, PluginReply, PluginReq};
use figcraft_collab::message::{Access, Admin, AdminReply, ClientMsg, LibraryReply, LibraryReq, PluginReply, PluginReq};
use figcraft_engine::live::{
Bind, Event, HostOptions, LiveLink, NetReply, NetRequest, PageInfo, Presence, PublishOptions, Published, Role, SessionInfo,
Bind, Event, HostInfo, HostOptions, LiveLink, NetReply, NetRequest, PageInfo, Presence, PublishOptions, Published, Role, SessionInfo,
};
use crate::guest::{Guest, GuestConfig, Publish, upload_file};
@@ -151,8 +151,8 @@ impl LiveLink for NetLink {
fn publish(&mut self, o: PublishOptions) -> Result<(), String> {
let (server, query) = Endpoint::parse(&o.server)?;
// Without a key: as anyone may, when the server is open to the public (it says if not).
let key = o.key.or_else(|| query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v)).filter(|k| !k.trim().is_empty());
let key = key.ok_or("the server address needs the server's key (ws://host:port/?key=…; `figcraft-server key` prints it)")?;
// Until the upload is done the invite names only the server.
let cfg = self.guest_config(Invite { server: server.clone(), file: String::new(), token: String::new() }, &o.me);
self.leave();
@@ -176,6 +176,10 @@ impl LiveLink for NetLink {
plugins(server, req, &self.limits)
}
fn hosting(&mut self, server: &str, file: &str, owner: &str, req: Option<Admin>) -> Result<(HostInfo, Option<AdminReply>), String> {
hosting(server, file, owner, req, &self.limits)
}
fn background(&mut self, job: u64, req: NetRequest) -> bool {
let (inbox, limits) = (self.libraries.clone(), self.limits);
let (failed, failure) = (inbox.clone(), req.failure());
@@ -184,6 +188,7 @@ impl LiveLink for NetLink {
NetRequest::Plugins { server, req } => NetReply::Plugins(plugins(&server, req, &limits)),
NetRequest::Library { link, req } => NetReply::Library(manage::library(&link, req, &limits, CONNECT)),
NetRequest::Upload(o) => NetReply::Upload(upload(o, &limits)),
NetRequest::Hosting { server, file, owner, req } => NetReply::Hosting(hosting(&server, &file, &owner, req, &limits)),
};
inbox.push(Event::Net { job, reply: Box::new(reply) });
});
@@ -248,7 +253,22 @@ fn upload(o: PublishOptions, limits: &NetLimits) -> Result<Published, String> {
let (server, query) = Endpoint::parse(&o.server)?;
let key = o.key.or_else(|| query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v)).filter(|k| !k.trim().is_empty());
let key = key.ok_or("the server address needs the server's key (ws://host:port/?key=…; `figcraft-server key` prints it)")?;
upload_file(&server, &key, &o.name, &o.doc, limits, CONNECT).map(|(_, published)| published)
upload_file(&server, Some(&key), &o.name, &o.doc, limits, CONNECT).map(|(_, published, _)| published)
}
/// What the server at `server` says about its hosting, and its answer to `req` about `file` with
/// its owner token (see [`LiveLink::hosting`]). A new link comes back whole, in place of its token.
fn hosting(server: &str, file: &str, owner: &str, req: Option<Admin>, limits: &NetLimits) -> Result<(HostInfo, Option<AdminReply>), String> {
let (endpoint, _) = Endpoint::parse(server)?;
let (info, replies) = manage::hosting(&endpoint, file, owner, req.into_iter().collect(), limits, CONNECT)?;
let reply = replies.into_iter().next().map(|r| match r {
AdminReply::Link(mut l) => {
l.token = Invite { server: endpoint.clone(), file: l.link.file.clone(), token: l.token }.link();
AdminReply::Link(l)
}
other => other,
});
Ok((info, reply))
}
/// One request to the plugin directory at `server` (its key in the address's query, if any).
+41 -1
View File
@@ -7,7 +7,8 @@
use std::time::Duration;
use figcraft_collab::message::{
self, Admin, AdminReply, ClientMsg, LibraryReply, LibraryReq, Manage, OpenLibrary, OpenPlugins, PROTOCOL, PluginReply, PluginReq, ServerMsg,
self, Admin, AdminReply, ClientMsg, HostInfo, LibraryReply, LibraryReq, Manage, OpenHosting, OpenLibrary, OpenPlugins, PROTOCOL, PluginReply,
PluginReq, ServerMsg,
};
use tungstenite::{Bytes, Message, WebSocket};
@@ -40,6 +41,45 @@ pub fn request(server: &Endpoint, key: &str, requests: Vec<Admin>, limits: &NetL
Ok(replies)
}
/// Ask the server at `server` about its hosting, with a file's owner token when `file` and `owner`
/// aren't empty; what it says, and its replies to `requests` (publishing without the key, managing
/// that file), in order. Fails when the server can't be reached or refuses (a wrong owner token, a
/// blocked address); a server older than open hosting can't read the request, and is reported as
/// taking files only with its key. Never echoes the token.
pub fn hosting(
server: &Endpoint,
file: &str,
owner: &str,
requests: Vec<Admin>,
limits: &NetLimits,
timeout: Duration,
) -> Result<(HostInfo, Vec<AdminReply>), String> {
let admin = |m: ServerMsg| match m {
ServerMsg::Admin(r) => Some(*r),
_ => None,
};
let open = OpenHosting { protocol: PROTOCOL, client_version: message::client_version(), file: file.to_string(), owner: owner.to_string() };
let mut ws = connect(server, limits, timeout)?;
send(&mut ws, &ClientMsg::OpenHosting(open))?;
let info = match reply(&mut ws, limits, |m| match m {
ServerMsg::Hosting(i) => Some(*i),
_ => None,
}) {
Ok(i) => i,
Err(e) if e.contains("couldn't be read") => {
return Err(format!("{} takes files only with its key (it is older than this app): add ?key=… to the address", server.display()));
}
Err(e) => return Err(e),
};
let mut replies = Vec::with_capacity(requests.len());
for r in requests {
send(&mut ws, &ClientMsg::Admin(r))?;
replies.push(reply(&mut ws, limits, admin)?);
}
close(ws);
Ok((info, replies))
}
/// Send one library request about the file behind share link `link`; the reply. Fails when the
/// link isn't one, the server can't be reached or refuses the link (a request it can't do is a
/// [`LibraryReply::Failed`] reply). Never echoes the link.
+3 -2
View File
@@ -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 {
+47 -5
View File
@@ -4,9 +4,9 @@ use figcraft_doc::{Document, NodeId, NodeKind};
use crate::change::{Change, Json, Prop};
use crate::message::{
Accepted, Access, Admin, AdminReply, CatchUp, ClientMsg, EditingText, FileInfo, FileUsage, Hello, LibraryReply, LibraryReq, LinkInfo, Manage,
NewLink, OpenLibrary, OpenPlugins, PROTOCOL, Peer, PluginEntry, PluginReply, PluginReq, Presence, Processed, Resume, ServerMsg, Signal, Usage,
WIRE_VERSION, decode, decode_json, encode, encode_json,
Accepted, Access, Admin, AdminReply, CatchUp, ClientMsg, EditingText, FileInfo, FileRow, FileUsage, Hello, HostInfo, LibraryReply, LibraryReq,
LinkInfo, Manage, NewLink, OpenHosting, OpenLibrary, OpenPlugins, PROTOCOL, Peer, PluginEntry, PluginReply, PluginReq, Presence, Processed,
ReportInfo, Resume, ServerMsg, Signal, Usage, WIRE_VERSION, decode, decode_json, encode, encode_json,
};
use crate::sim::Rng;
use crate::{Error, Replica, diff};
@@ -68,6 +68,14 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
ClientMsg::Plugins(PluginReq::Fetch { id: "org.example.p".into(), version: Some("1.0.0".into()) }),
ClientMsg::Plugins(PluginReq::Publish { bundle: vec![b'F', b'C'] }),
ClientMsg::Plugins(PluginReq::Unpublish { id: "org.example.p".into(), version: None }),
ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: "0.3.2 hosting".into(), file: "f".into(), owner: "o".into() }),
ClientMsg::Admin(Admin::Rename { file: "f".into(), name: "Café 2".into() }),
ClientMsg::Admin(Admin::Listing),
ClientMsg::Admin(Admin::Reports),
ClientMsg::Admin(Admin::Block { ip: "203.0.113.9".into() }),
ClientMsg::Admin(Admin::Unblock { ip: "2001:db8::".into() }),
ClientMsg::Admin(Admin::Blocked),
ClientMsg::Report { reason: "Spam ☕".into() },
];
let snapshot = Replica::new(Document::new("Snap")).snapshot().unwrap();
let file = FileInfo { id: "f".into(), name: "Café".into(), created: 1, updated: 2, seq: 3, bytes: 4, people: 5 };
@@ -112,9 +120,9 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
ServerMsg::Pong,
ServerMsg::Admin(Box::new(AdminReply::Ready)),
ServerMsg::Admin(Box::new(AdminReply::Published { file: file.clone(), edit: new_link.clone(), view: new_link.clone() })),
ServerMsg::Admin(Box::new(AdminReply::Files(vec![file]))),
ServerMsg::Admin(Box::new(AdminReply::Files(vec![file.clone()]))),
ServerMsg::Admin(Box::new(AdminReply::Links(vec![link]))),
ServerMsg::Admin(Box::new(AdminReply::Link(new_link))),
ServerMsg::Admin(Box::new(AdminReply::Link(new_link.clone()))),
ServerMsg::Admin(Box::new(AdminReply::Done)),
ServerMsg::Admin(Box::new(AdminReply::Failed { reason: "no".into() })),
ServerMsg::Library(Box::new(LibraryReply::Ready { access: Access::View })),
@@ -135,6 +143,40 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
ServerMsg::Plugins(Box::new(PluginReply::Published(entry))),
ServerMsg::Plugins(Box::new(PluginReply::Removed(vec!["1.0.0".into()]))),
ServerMsg::Plugins(Box::new(PluginReply::Failed { reason: "no".into() })),
ServerMsg::Admin(Box::new(AdminReply::Hosted {
file: file.clone(),
edit: new_link.clone(),
view: new_link.clone(),
owner: "o".into(),
expires: Some(99),
})),
ServerMsg::Admin(Box::new(AdminReply::Listing(vec![FileRow {
info: file.clone(),
opened: 7,
expires: None,
open: true,
ip: "203.0.113.9".into(),
reports: 2,
}]))),
ServerMsg::Admin(Box::new(AdminReply::Reports(vec![ReportInfo {
at: 8,
file: "f".into(),
name: "Café".into(),
link: "l".into(),
ip: "203.0.113.9".into(),
reason: "Spam".into(),
}]))),
ServerMsg::Admin(Box::new(AdminReply::Blocked(vec!["203.0.113.9".into()]))),
ServerMsg::Hosting(Box::new(HostInfo {
open: true,
operator: "abuse@example.com".into(),
terms: "https://example.com/terms".into(),
idle_secs: 86_400,
max_file_mb: 16,
expires: Some(1_800_000_000),
})),
ServerMsg::Reported { error: None },
ServerMsg::Reported { error: Some("too many reports".into()) },
];
(client, server)
}
+125 -1
View File
@@ -19,9 +19,13 @@ pub const WIRE_VERSION: u8 = 1;
/// server sends signals only to clients that say so (older ones couldn't read the message).
pub const SIGNALS: &str = "signals";
/// The word a client puts in its [`Hello::client_version`] when it reads [`ServerMsg::Hosting`] and
/// [`ServerMsg::Reported`]: a server sends them only to clients that say so.
pub const HOSTING: &str = "hosting";
/// The `client_version` this build announces: its version, then what it reads beyond the protocol.
pub fn client_version() -> String {
format!("{} {SIGNALS}", env!("CARGO_PKG_VERSION"))
format!("{} {SIGNALS} {HOSTING}", env!("CARGO_PKG_VERSION"))
}
/// A [`Hello::client_version`] that announces [`SIGNALS`].
@@ -29,6 +33,11 @@ pub fn reads_signals(client_version: &str) -> bool {
client_version.split_whitespace().any(|w| w == SIGNALS)
}
/// A [`Hello::client_version`] that announces [`HOSTING`].
pub fn reads_hosting(client_version: &str) -> bool {
client_version.split_whitespace().any(|w| w == HOSTING)
}
/// Join a file's session.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Hello {
@@ -245,6 +254,18 @@ pub enum Admin {
Revoke { file: String, link: String },
/// Remove a file and its links (whoever is in it is disconnected).
Remove { file: String },
/// Rename a file on the server (its listing; the document inside keeps its own name).
Rename { file: String, name: String },
/// The files kept, with what the operator moderates by ([`AdminReply::Listing`]).
Listing,
/// The reports people sent about files.
Reports,
/// Refuse connections from an address (an IPv6 address: its /64), and disconnect them.
Block { ip: String },
/// Accept an address again.
Unblock { ip: String },
/// The blocked addresses.
Blocked,
}
/// A file a server keeps.
@@ -303,6 +324,76 @@ pub enum AdminReply {
Failed {
reason: String,
},
/// A file published on an open hosting connection: its first two links and its owner token
/// (tokens shown once), and when it is deleted if nobody opens it (seconds since 1970).
Hosted {
file: FileInfo,
edit: NewLink,
view: NewLink,
owner: String,
expires: Option<u64>,
},
Listing(Vec<FileRow>),
Reports(Vec<ReportInfo>),
Blocked(Vec<String>),
}
/// A file with what its operator moderates by.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FileRow {
pub info: FileInfo,
/// When someone last opened it (seconds since 1970; 0: never since it was published).
pub opened: u64,
/// When it is deleted if nobody opens it (files published without the server key).
pub expires: Option<u64>,
/// Published without the server key (it has an owner token), from this address.
pub open: bool,
pub ip: String,
/// Reports kept about it.
pub reports: u32,
}
/// A report someone in a file sent its server's operator.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ReportInfo {
/// Seconds since 1970.
pub at: u64,
pub file: String,
/// The file's name then.
pub name: String,
/// The link the reporter joined with, and their address.
pub link: String,
pub ip: String,
pub reason: String,
}
/// Opens a connection to a server's open hosting (instead of [`Hello`]): anyone may ask about the
/// server and, when it is open to the public, publish a file without the server key; with a file's
/// owner token (`file` and `owner` set) the connection also manages that file. Answered with
/// [`ServerMsg::Hosting`] or `Refused`; requests are [`Admin`] messages, each answered with one
/// [`AdminReply`].
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct OpenHosting {
pub protocol: u32,
pub client_version: String,
pub file: String,
pub owner: String,
}
/// What a server says about its hosting: whether anyone may publish, who runs it and its terms,
/// how long files published without the key are kept unopened, and the largest file.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostInfo {
/// Anyone may publish without the server key.
pub open: bool,
/// How to reach the operator (an email address or a web page), and the terms of use (a URL).
pub operator: String,
pub terms: String,
/// Files published without the key are deleted after this many seconds unopened (0: kept).
pub idle_secs: u64,
pub max_file_mb: u64,
/// This file's expiry (seconds since 1970), when it is one that expires.
pub expires: Option<u64>,
}
/// Opens a connection that uses a file's team library (instead of [`Hello`]), with one of the
@@ -481,6 +572,14 @@ pub enum ClientMsg {
OpenPlugins(OpenPlugins),
/// A request on a plugin directory connection.
Plugins(PluginReq),
/// Ask about the server's hosting, publish without the key or manage a file with its owner
/// token (the first message on a connection, instead of `Hello`).
OpenHosting(OpenHosting),
/// Report the file you are in to the server's operator (only to a server that sent
/// [`ServerMsg::Hosting`]): why, in a few words.
Report {
reason: String,
},
}
/// Server → client.
@@ -536,6 +635,31 @@ pub enum ServerMsg {
},
/// The answer to a plugin directory connection's request.
Plugins(Box<PluginReply>),
/// The server's hosting: the answer to [`OpenHosting`], and after a welcome to clients whose
/// hello announced [`HOSTING`] (with the file's expiry).
Hosting(Box<HostInfo>),
/// The answer to a [`ClientMsg::Report`]: kept, or why not.
Reported {
error: Option<String>,
},
}
/// `YYYY-MM-DD HH:MM UTC` for seconds since 1970 (how servers and apps show the times in these
/// messages).
pub fn utc_date(secs: u64) -> String {
let days = i64::try_from(secs / 86_400).unwrap_or(0);
let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60);
// Days to a civil date (proleptic Gregorian).
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(mo <= 2);
format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02} UTC")
}
/// The binary form of `msg`: [`WIRE_VERSION`], then postcard.
+3 -1
View File
@@ -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() })]
}
}
+1
View File
@@ -111,6 +111,7 @@ fn answer(s: &mut Session, wire: &Arc<Mutex<Wire>>) -> usize {
NetRequest::Plugins { req, .. } => NetReply::Plugins(Ok(directory(req))),
NetRequest::Library { req, .. } => NetReply::Library(Ok(library(&mut w, req))),
NetRequest::Upload(_) => NetReply::Upload(Err("no".into())),
NetRequest::Hosting { .. } => NetReply::Hosting(Err("no".into())),
};
w.answers.push(Event::Net { job: *job, reply: Box::new(reply) });
}
+21
View File
@@ -290,6 +290,27 @@ pub fn specs() -> Vec<CommandSpec> {
s.prefs.user_name = if name.is_empty() { super::comment::DEFAULT_AUTHOR.into() } else { name };
Ok(json!({"name": s.prefs.user_name}))
}),
spec!(
"pref.publishServer",
"Server to publish to",
"{url: ws://host:port/ or wss://host/path/ (no key; empty: none)} → {url}",
false,
|s, p| {
const CMD: &str = "pref.publishServer";
let url = req_string(CMD, p, "url")?.trim().to_string();
let web_socket = url.starts_with("ws://") || url.starts_with("wss://");
if !url.is_empty()
&& (!web_socket || url.len() > 500 || url.contains(|c: char| c.is_whitespace() || c.is_control() || c == '?' || c == '#'))
{
return Err(bad(
CMD,
"the server's address is ws://host:port/ or wss://host/path/, without a key (it is saved with your preferences)",
));
}
s.prefs.publish_server = url;
Ok(json!({"url": s.prefs.publish_server}))
}
),
spec!("pref.language", "Interface language", "{language: system|en|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…; else English}", false, |s, p| {
let lang = req_string("pref.language", p, "language")?;
let lang = lang.trim();
+5
View File
@@ -564,6 +564,10 @@ pub struct Prefs {
/// The plugin directory browsed last (`ws://host:port/`, never with a key).
#[serde(skip_serializing_if = "Option::is_none")]
pub plugin_directory: Option<String>,
/// The server Share ▸ Publish to server suggests (`wss://host/`, never with a key; empty:
/// none). There is no built-in default.
#[serde(skip_serializing_if = "String::is_empty")]
pub publish_server: String,
/// Recently opened or saved files, newest first (File ▸ Open recent; at most
/// [`cmd::file::MAX_RECENT`]).
pub recent_files: Vec<String>,
@@ -587,6 +591,7 @@ impl Default for Prefs {
plugins: Vec::new(),
plugin_settings: BTreeMap::new(),
plugin_directory: None,
publish_server: String::new(),
recent_files: Vec::new(),
}
}
+166 -4
View File
@@ -32,9 +32,9 @@ use std::sync::Arc;
use figcraft_collab::Client;
pub use figcraft_collab::message::Signal;
use figcraft_collab::message::{ClientMsg, EditingText, LibraryReply, LibraryReq, PluginReply, PluginReq, ServerMsg};
use figcraft_collab::message::{Admin, AdminReply, ClientMsg, EditingText, LibraryReply, LibraryReq, PluginReply, PluginReq, ServerMsg};
pub use figcraft_collab::message::Access;
pub use figcraft_collab::message::{Access, HostInfo, utc_date};
use figcraft_doc::{Document, NodeId};
use serde::Serialize;
use serde_json::{Value, json};
@@ -162,6 +162,11 @@ pub struct Published {
/// The link that lets people edit, and the one that lets them only view.
pub edit: String,
pub view: String,
/// Published without the server's key: the owner token that revokes links, renames or removes
/// the file (shown this once; the server keeps only its hash).
pub owner: Option<String>,
/// When the server deletes the file if nobody opens it (seconds since 1970).
pub expires: Option<u64>,
}
/// What happened in the session since the last [`LiveLink::poll`].
@@ -187,6 +192,10 @@ pub enum Event {
},
/// The document is on the server (`collab.publish`); joining it follows.
Published(Published),
/// What the server says about its hosting (operator, terms, this file's expiry).
Hosting(HostInfo),
/// The server's answer to `collab.report`: kept, or why not.
Reported(Option<String>),
/// The answer to a team library request sent in the background
/// ([`LiveLink::library_background`]) about the file behind share link `link`.
Library {
@@ -211,6 +220,9 @@ pub enum NetRequest {
Library { link: String, req: LibraryReq },
/// Keep a file on a server without joining it ([`LiveLink::upload`]).
Upload(PublishOptions),
/// Ask the server at `server` about its hosting and, with a file's owner token, manage that
/// file ([`LiveLink::hosting`]).
Hosting { server: String, file: String, owner: String, req: Option<Admin> },
}
impl NetRequest {
@@ -219,6 +231,7 @@ impl NetRequest {
match self {
NetRequest::Plugins { .. } => crate::plugin_directory::NO_DIRECTORY,
NetRequest::Library { .. } | NetRequest::Upload(_) => NO_TEAM_LIBRARIES,
NetRequest::Hosting { .. } => NO_HOSTING,
}
}
@@ -228,6 +241,7 @@ impl NetRequest {
NetRequest::Plugins { .. } => |why| NetReply::Plugins(Err(why)),
NetRequest::Library { .. } => |why| NetReply::Library(Err(why)),
NetRequest::Upload(_) => |why| NetReply::Upload(Err(why)),
NetRequest::Hosting { .. } => |why| NetReply::Hosting(Err(why)),
}
}
@@ -239,6 +253,9 @@ impl NetRequest {
(NetRequest::Plugins { server: a, req: x }, NetRequest::Plugins { server: b, req: y }) => a == b && discriminant(x) == discriminant(y),
(NetRequest::Library { link: a, req: x }, NetRequest::Library { link: b, req: y }) => a == b && discriminant(x) == discriminant(y),
(NetRequest::Upload(a), NetRequest::Upload(b)) => a.server == b.server,
(NetRequest::Hosting { server: a, file: f, req: x, .. }, NetRequest::Hosting { server: b, file: g, req: y, .. }) => {
a == b && f == g && x.as_ref().map(discriminant) == y.as_ref().map(discriminant)
}
_ => false,
}
}
@@ -250,8 +267,13 @@ pub enum NetReply {
Plugins(std::result::Result<PluginReply, String>),
Library(std::result::Result<LibraryReply, String>),
Upload(std::result::Result<Published, String>),
/// What the server says about its hosting, and its answer to the request, if one was sent.
Hosting(std::result::Result<(HostInfo, Option<AdminReply>), String>),
}
/// What a session without the network says about servers' hosting.
pub const NO_HOSTING: &str = "publishing to a server needs the desktop app or the MCP server";
/// Which network interface a hosted session listens on.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Bind {
@@ -281,7 +303,8 @@ pub struct PublishOptions {
/// The server's address (`ws://host:port/`, `wss://host/path/`), which may carry its key
/// (`?key=…`).
pub server: String,
/// The server's key, when the address doesn't carry it.
/// The server's key, when the address doesn't carry it. Without one the file is published
/// as anyone may, on a server open to the public (with an owner token).
pub key: Option<String>,
/// The file's name on the server.
pub name: String,
@@ -329,12 +352,25 @@ pub trait LiveLink: Send {
fn plugins(&mut self, _server: &str, _req: PluginReq) -> std::result::Result<PluginReply, String> {
Err(crate::plugin_directory::NO_DIRECTORY.into())
}
/// What the server at `server` says about its hosting, and with a file's owner token
/// (`file` and `owner`) its answer to `req` about that file; waits for the server. Errors
/// never echo the token.
fn hosting(
&mut self,
_server: &str,
_file: &str,
_owner: &str,
_req: Option<Admin>,
) -> std::result::Result<(HostInfo, Option<AdminReply>), String> {
Err(NO_HOSTING.into())
}
/// Send `req` and wait for the answer (through the methods above).
fn request(&mut self, req: NetRequest) -> NetReply {
match req {
NetRequest::Plugins { server, req } => NetReply::Plugins(self.plugins(&server, req)),
NetRequest::Library { link, req } => NetReply::Library(self.library(&link, req)),
NetRequest::Upload(o) => NetReply::Upload(self.upload(o)),
NetRequest::Hosting { server, file, owner, req } => NetReply::Hosting(self.hosting(&server, &file, &owner, req)),
}
}
/// Send `req` on a thread of its own and return at once: the answer arrives as [`Event::Net`]
@@ -393,6 +429,9 @@ pub struct Live {
pub links: Option<Box<dyn crate::invite_links::LinkRegistrar>>,
/// The file you published to a server in this session, with its links.
pub published: Option<Published>,
/// What the server of the file you're in says about its hosting (operator, terms, expiry);
/// a server that says it takes reports (`collab.report`).
pub hosting: Option<HostInfo>,
/// The host passes cursor chat and spotlights on (it said so after the welcome).
pub signals: bool,
/// Others' open cursor chat messages.
@@ -445,6 +484,7 @@ impl Live {
self.deferred.clear();
self.hold = 0;
self.published = None;
self.hosting = None;
self.signals = false;
self.chats.clear();
self.chat.clear();
@@ -703,6 +743,11 @@ impl Session {
self.message = Some(format!("Published {} to the server", p.name));
live.published = Some(p);
}
Event::Hosting(info) => live.hosting = Some(clean_hosting(info)),
Event::Reported(error) => {
self.message =
Some(error.map_or_else(|| "Your report was sent to the server's operator".into(), |e| format!("The report wasn't sent: {e}")));
}
Event::Library { link, reply } => self.library_answer(&link, reply),
Event::Net { job, reply } => self.background_answer(job, *reply),
}
@@ -1131,6 +1176,7 @@ impl Session {
"access": l.client.as_ref().map(Client::access).or(info.map(|i| i.access)),
"readOnly": self.active_read_only().is_some(),
"published": l.published,
"hosting": l.hosting.as_ref().map(hosting_json),
"follow": l.follow,
"signals": l.signals,
"chat": l.chat,
@@ -1144,6 +1190,31 @@ impl Session {
}
}
/// What a server says about its hosting, made safe to show: no control characters, short, and a
/// terms address only when it is a web page (`http(s)://`).
fn clean_hosting(mut info: HostInfo) -> HostInfo {
let clean = |s: &str, n: usize| s.trim().chars().filter(|c| !c.is_control()).take(n).collect::<String>();
info.operator = clean(&info.operator, 120);
info.terms = clean(&info.terms, 500);
let web = info.terms.starts_with("https://") || info.terms.starts_with("http://");
if !web || info.terms.contains(char::is_whitespace) {
info.terms.clear();
}
info
}
/// A server's hosting as JSON (`collab.peers`, `collab.serverInfo`).
pub fn hosting_json(h: &HostInfo) -> Value {
json!({
"open": h.open,
"operator": h.operator,
"terms": h.terms,
"idleDays": (h.idle_secs > 0).then(|| (h.idle_secs as f64 / 86_400.0 * 100.0).round() / 100.0),
"maxFileMb": h.max_file_mb,
"expires": h.expires,
})
}
fn unavailable(cmd: &str) -> EngineError {
EngineError::Disabled(
cmd.to_string(),
@@ -1190,6 +1261,76 @@ fn publish(s: &mut Session, p: &Value) -> Result<Value> {
Ok(s.live_summary())
}
/// What the server at `server` says about its hosting, and its answer to `req` about `file` with
/// its owner token (`collab.serverInfo`, `collab.manageFile`).
fn hosting_request(s: &mut Session, cmd: &str, server: &str, file: &str, owner: &str, req: Option<Admin>) -> Result<(HostInfo, Option<AdminReply>)> {
let req = NetRequest::Hosting { server: server.to_string(), file: file.to_string(), owner: owner.to_string(), req };
match s.net(req)?.0 {
// The server's refusals say why themselves.
NetReply::Hosting(Ok((_, Some(AdminReply::Failed { reason })))) | NetReply::Hosting(Err(reason)) => Err(EngineError::Other(reason)),
NetReply::Hosting(Ok((info, reply))) => Ok((clean_hosting(info), reply)),
_ => Err(EngineError::Other(format!("{cmd}: the server answered something else"))),
}
}
fn server_info(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "collab.serverInfo";
let server = req_string(CMD, p, "server")?;
let (info, _) = hosting_request(s, CMD, server.trim(), "", "", None)?;
Ok(hosting_json(&info))
}
fn manage_file(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "collab.manageFile";
let (server, file, owner) = (req_string(CMD, p, "server")?, req_string(CMD, p, "file")?, req_string(CMD, p, "owner")?);
let (file, owner) = (file.trim().to_string(), owner.trim().to_string());
let action = req_string(CMD, p, "action")?;
let req = match action.trim() {
"links" => Admin::Links { file: file.clone() },
"createLink" => {
let access = match string(p, "access").as_deref().map(str::trim) {
None | Some("view") => Access::View,
Some("edit") => Access::Edit,
Some(_) => return Err(bad(CMD, "`access` must be \"edit\" or \"view\"")),
};
Admin::CreateLink { file: file.clone(), access, label: string(p, "label").unwrap_or_default() }
}
"revoke" => Admin::Revoke { file: file.clone(), link: req_string(CMD, p, "link")?.trim().to_string() },
"rename" => Admin::Rename { file: file.clone(), name: req_string(CMD, p, "name")? },
"remove" => Admin::Remove { file: file.clone() },
_ => return Err(bad(CMD, "`action` must be links, createLink, revoke, rename or remove")),
};
match hosting_request(s, CMD, server.trim(), &file, &owner, Some(req))?.1 {
Some(AdminReply::Links(links)) => Ok(json!({"links": links.iter().map(|l| json!({
"id": l.id, "access": l.access.id(), "label": l.label, "created": l.created
})).collect::<Vec<_>>()})),
// The link layer gives the new link whole in place of its token.
Some(AdminReply::Link(l)) => Ok(json!({"id": l.link.id, "access": l.link.access.id(), "link": l.token})),
Some(AdminReply::Done) => Ok(json!({"done": true})),
_ => Err(bad(CMD, "the server answered something else")),
}
}
fn report(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "collab.report";
let reason: String = req_string(CMD, p, "reason")?.trim().chars().map(|c| if c.is_control() { ' ' } else { c }).take(MAX_REPORT).collect();
if reason.is_empty() {
return Err(bad(CMD, "say in a few words what is wrong with the file (`reason`)"));
}
if s.live.status != Status::Live || s.live.role() != Some(Role::Guest) {
return Err(bad(CMD, "join a file on a FigCraft server first: reports go to that server's operator"));
}
if s.live.hosting.is_none() {
return Err(bad(CMD, "this server doesn't take reports (it is older than this app); contact its operator another way"));
}
let link = s.live.link.as_mut().ok_or_else(|| unavailable(CMD))?;
link.send(ClientMsg::Report { reason });
Ok(json!({"sent": true}))
}
/// Longest report sent (characters).
pub const MAX_REPORT: usize = 500;
fn join(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "collab.join";
let link_text = req_string(CMD, p, "link")?;
@@ -1280,10 +1421,31 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"collab.publish",
"Publish to server",
"{server: ws://host:port/?key=… or wss://host/path/?key=… (the server's address with its key; `figcraft-server key` prints it), key? (when the address has none), name? (default: the file's name)} → status \"connecting\": the open file is uploaded as a new file and this tab syncs through the server; `collab.peers` then has `published: {server, file, name, edit, view}` (share links: edit, or view only)",
"{server: ws://host:port/?key=… or wss://host/path/?key=… (the server's address with its key; `figcraft-server key` prints it; without a key on a server open to the public), key? (when the address has none), name? (default: the file's name)} → status \"connecting\": the open file is uploaded as a new file and this tab syncs through the server; `collab.peers` then has `published: {server, file, name, edit, view, owner (published without a key: the owner token, shown once), expires (seconds since 1970, or null)}` and `hosting` (the server's operator, terms and idle expiry)",
false,
publish
),
spec!(
"collab.serverInfo",
"Server info",
"{server: ws://host:port/ or wss://host/path/} → {open (anyone may publish without the key), operator, terms (a web address), idleDays (files published without the key are deleted after this many days unopened; null: kept), maxFileMb, expires}",
false,
server_info
),
spec!(
"collab.manageFile",
"Manage published file",
"{server, file (its id), owner (the owner token you got when you published it without the key), action: links | createLink {access?: view (default) | edit, label?} | revoke {link (a link's id)} | rename {name} | remove} → {links: [{id, access, label, created}]} | {id, access, link (a new share link, shown once)} | {done}",
false,
manage_file
),
spec!(
"collab.report",
"Report file to server operator",
"{reason (a few words, at most 500 characters)} → {sent}: the file you joined on a server is reported to its operator; the session's message says when the server kept it (servers older than this app don't take reports)",
false,
report
),
spec!(
"collab.peers",
"People in this file",
+9 -1
View File
@@ -46,7 +46,15 @@ fn publishing_makes_the_open_tab_the_server_file() {
let mut doc = s.doc().unwrap().clone();
doc.name = "Team".into();
let (mut server, id, welcome) = serve_as(&doc, Access::Edit);
let published = Published { server: "127.0.0.1:47900".into(), file: "f".into(), name: "Team".into(), edit: "e".into(), view: "v".into() };
let published = Published {
server: "127.0.0.1:47900".into(),
file: "f".into(),
name: "Team".into(),
edit: "e".into(),
view: "v".into(),
owner: None,
expires: None,
};
push(&script, vec![Event::Published(published.clone()), Event::Welcome { info: info(Role::Guest, id), peers: vec![] }, welcome]);
s.live_poll();
assert!(s.live.syncing());
+21
View File
@@ -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"));
}
+3
View File
@@ -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"}),
-1
View File
@@ -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."
+22 -1
View File
@@ -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."
+26 -1
View File
@@ -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"
-1
View File
@@ -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."
-1
View File
@@ -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} 個のみ表示。"
-1
View File
@@ -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}개만 표시됨."
-1
View File
@@ -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."
-1
View File
@@ -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} 个已更改的图层。"
+8
View File
@@ -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;
+209 -12
View File
@@ -14,7 +14,7 @@ use std::collections::BTreeMap;
use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Shape, Stroke, Ui, pos2, vec2};
use figcraft_engine::live::{Access, PEOPLE, Role, Signal, Status, initials, parse_color, presence_page};
use serde_json::json;
use serde_json::{Value, json};
use crate::FigApp;
use crate::a11y::{self, Tip};
@@ -40,6 +40,14 @@ const PUBLISHED_NOTE: &str = tr_noop(
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them.",
);
/// Why the owner token matters (shown once, when a file is published without the server key).
const OWNER_NOTE: &str = tr_noop(
"Copy the owner token and keep it somewhere safe: it is the only way to revoke these links, rename the file or delete it from the server, and it won't be shown again.",
);
/// What a server open to the public can see.
const PLAIN_NOTE: &str = tr_noop("Files aren't end-to-end encrypted: the server's operator can read them.");
/// How fast a remote cursor closes in on where it was last reported (per second).
const GLIDE: f64 = 20.0;
/// Most of one person's selection outlined.
@@ -84,6 +92,12 @@ pub struct LiveUi {
focus_publish: bool,
/// The last server address published to (kept for this run only: it carries the server key).
last_server: String,
/// A file published from the dialog without the key: its owner token shows once it arrives.
show_owner: bool,
/// The report being written about the file you're in (open when `Some`), and putting the
/// keyboard in its field on its next frame.
pub report: Option<String>,
focus_report: bool,
/// Others' chat messages as last seen, with when they changed (UI clock): they fade from then.
chat_seen: BTreeMap<u32, (String, f64)>,
/// Your cursor chat (open when `Some`).
@@ -97,6 +111,8 @@ pub struct PublishForm {
pub server: String,
/// The file's name on the server.
pub name: String,
/// What the server said about its hosting (`collab.serverInfo`), and the address asked.
pub info: Option<(String, Value)>,
}
impl LiveUi {
@@ -613,14 +629,88 @@ fn guest_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
widgets::label(ui, &trf("Pages: {pages}", &[("pages", &pages.join(", "))]), t.text_secondary);
}
}
let hosting = live.hosting.clone();
if let Some(p) = live.published.clone() {
link_rows(app, ui, &[(tr("Edit link"), &p.edit, tr("Edit link copied")), (tr("View link"), &p.view, tr("View link copied"))]);
widgets::label(ui, tr(PUBLISHED_NOTE), t.text_secondary);
}
if let Some(h) = &hosting {
host_line(ui, t, &h.operator, &h.terms);
expiry_line(ui, t, h.expires);
}
widgets::label(ui, tr(if view { VIEW_NOTE } else { UNDO_NOTE }), t.text_secondary);
if widgets::button(ui, tr("Leave session"), false, true).on_hover_text(tr("Your copy of the file stays open")).clicked() {
app.run("collab.leave", json!({}));
}
// A server that takes reports said so (its hosting).
if hosting.is_some() && app.session.live.status == Status::Live {
report_ui(app, ui, t);
}
}
/// "Hosted by …" and a link to the terms, when the server names them.
fn host_line(ui: &mut Ui, t: &Tokens, operator: &str, terms: &str) {
if operator.is_empty() && terms.is_empty() {
return;
}
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
if !operator.is_empty() {
widgets::label(ui, &trf("Hosted by {operator}", &[("operator", &operator)]), t.text_secondary);
}
if !terms.is_empty() {
if !operator.is_empty() {
widgets::label(ui, "·", t.text_secondary);
}
ui.hyperlink_to(egui::RichText::new(tr("Terms")).font(regular(11.0)), terms).on_hover_text(terms);
}
});
}
/// When the server deletes the file unless someone opens it.
fn expiry_line(ui: &mut Ui, t: &Tokens, expires: Option<u64>) {
if let Some(e) = expires {
let date = figcraft_engine::live::utc_date(e);
widgets::label(ui, &trf("The server deletes this file if nobody opens it before {date}.", &[("date", &date)]), t.text_secondary);
}
}
/// Report the file you're in to the server's operator: a button, then a reason to send.
fn report_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
let Some(mut reason) = app.ui.live.report.take() else {
if widgets::button(ui, tr("Report this file…"), false, true).on_hover_text(tr("Tell the server's operator what is wrong with it")).clicked()
{
app.ui.live.report = Some(String::new());
app.ui.live.focus_report = true;
}
return;
};
widgets::label(ui, tr("What is wrong with this file? The server's operator gets your report."), t.text);
let resp = ui.add(
egui::TextEdit::singleline(&mut reason)
.hint_text(tr("Spam, stolen work, abuse…"))
.char_limit(figcraft_engine::live::MAX_REPORT)
.desired_width(ui.available_width())
.id(egui::Id::new("live_report_reason")),
);
a11y::text(&resp, tr("Report"), &reason);
if std::mem::take(&mut app.ui.live.focus_report) {
resp.request_focus();
}
let mut keep = true;
ui.horizontal(|ui| {
let send = widgets::button(ui, tr("Send report"), true, !reason.trim().is_empty()).clicked()
|| (resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)));
if send && !reason.trim().is_empty() && app.run("collab.report", json!({"reason": reason.trim()})).is_some() {
keep = false;
}
if widgets::button(ui, tr("Cancel"), false, true).clicked() {
keep = false;
}
});
if keep {
app.ui.live.report = Some(reason);
}
}
/// Links to copy: a label, the link (elided, monospace) and a Copy button each.
@@ -704,25 +794,59 @@ pub fn join_dialog(app: &mut FigApp, ctx: &egui::Context) {
}
}
/// Open the Publish to server dialog for the open file (the last address used this run in it).
/// Open the Publish to server dialog for the open file (the last address used this run in it,
/// else the one in Preferences).
pub fn open_publish(app: &mut FigApp) {
if !app.session.live.can_host() {
app.toast(tr("Publishing to a server needs the desktop app; in the browser you can join a server's files"));
return;
}
let name = app.session.doc().map(|d| d.name.clone()).unwrap_or_default();
app.ui.live.publish = Some(PublishForm { server: app.ui.live.last_server.clone(), name });
let server = if app.ui.live.last_server.is_empty() { app.session.prefs.publish_server.clone() } else { app.ui.live.last_server.clone() };
app.ui.live.publish = Some(PublishForm { server, name, info: None });
app.ui.live.focus_publish = true;
}
/// Share ▸ Publish to server…: the server's address (with its key) and the file's name.
/// The address carries a server key (`?key=…`).
fn has_key(server: &str) -> bool {
server.split_once('?').is_some_and(|(_, q)| q.split('&').any(|kv| kv.strip_prefix("key=").is_some_and(|k| !k.trim().is_empty())))
}
/// Ask the server at `server` about its hosting in the background; the answer goes into the
/// Publish dialog while it still shows that address.
fn ask_server(app: &mut FigApp, ctx: &egui::Context, server: String) {
app.run_background(
ctx,
SERVER_INFO,
json!({"server": server}),
Box::new(move |app, _, v| {
if let Some(f) = app.ui.live.publish.as_mut().filter(|f| f.server.trim() == server) {
f.info = Some((server, v));
}
}),
);
}
const SERVER_INFO: &str = "collab.serverInfo";
/// Share ▸ Publish to server…: the server's address (with its key, unless the server is open to
/// the public: it is asked first) and the file's name.
pub fn publish_dialog(app: &mut FigApp, ctx: &egui::Context) {
published_dialog(app, ctx);
let Some(mut form) = app.ui.live.publish.take() else { return };
let t = Tokens::get(ctx);
let mut open = true;
let mut cancel = false;
let mut go = false;
let mut ask = false;
let focus = std::mem::take(&mut app.ui.live.focus_publish);
let address = form.server.trim().to_string();
let keyed = has_key(&address);
let info = form.info.as_ref().filter(|(at, _)| *at == address).map(|(_, v)| v.clone());
let asking = app.session.waiting_for(SERVER_INFO);
let open_server = info.as_ref().is_some_and(|v| v["open"] == true);
// A first look at an address already filled in.
ask |= focus && !address.is_empty() && !keyed && info.is_none();
crate::dialogs::window(tr("Publish to server"), ctx).id(egui::Id::new("Publish to server")).open(&mut open).show(ctx, |ui| {
ui.set_width(380.0);
widgets::label(
@@ -733,34 +857,104 @@ pub fn publish_dialog(app: &mut FigApp, ctx: &egui::Context) {
widgets::label(ui, tr("Server address"), t.text_secondary);
let resp = ui.add(
egui::TextEdit::singleline(&mut form.server)
.hint_text("ws://server:47900/?key=…")
.hint_text("wss://server/ or ws://server:47900/?key=…")
.desired_width(380.0)
.id(egui::Id::new("live_publish_server")),
);
// The address carries the server key: it isn't read out.
// The address may carry the server key: it isn't read out.
a11y::text(&resp, tr("Server address"), "");
if focus {
resp.request_focus();
}
let enter = |ui: &Ui| ui.input(|i| i.key_pressed(egui::Key::Enter));
go = resp.lost_focus() && enter(ui);
widgets::label(ui, tr("On the server's machine, `figcraft-server key` prints it."), t.text_secondary);
// Leaving the field (or Enter) asks a server without a key whether it is open.
let left = resp.lost_focus() && !address.is_empty() && !keyed && info.is_none() && !asking;
go = resp.lost_focus() && enter(ui) && (keyed || open_server);
ask |= left;
match &info {
_ if keyed => widgets::label(ui, tr("Publishing with the server's key."), t.text_secondary),
_ if asking => widgets::label(ui, tr("Asking the server…"), t.text_secondary),
Some(v) if open_server => {
widgets::label(ui, tr("This server is open to the public: no key needed."), t.success);
host_line(ui, &t, v["operator"].as_str().unwrap_or_default(), v["terms"].as_str().unwrap_or_default());
if let Some(days) = v["idleDays"].as_f64() {
let n = days.ceil().max(1.0) as usize;
widgets::label(ui, &trn("Files nobody opens for {n} days are deleted.", n, &[]), t.text_secondary);
}
widgets::label(ui, tr(PLAIN_NOTE), t.text_secondary);
}
Some(_) => widgets::label(
ui,
tr("This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)."),
t.danger,
),
None => widgets::label(
ui,
tr("Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)."),
t.text_secondary,
),
}
widgets::label(ui, tr("File name"), t.text_secondary);
let nresp = ui.add(egui::TextEdit::singleline(&mut form.name).desired_width(380.0).id(egui::Id::new("live_publish_name")));
a11y::text(&nresp, tr("File name"), &form.name);
go |= nresp.lost_focus() && enter(ui);
go |= nresp.lost_focus() && enter(ui) && (keyed || open_server);
ui.add_space(4.0);
ui.horizontal(|ui| {
go |= widgets::button(ui, tr("Publish"), true, !form.server.trim().is_empty()).clicked();
go |= widgets::button(ui, tr("Publish"), true, keyed || open_server).clicked();
cancel = widgets::button(ui, tr("Cancel"), false, true).clicked();
});
});
if go && !form.server.trim().is_empty() && app.run("collab.publish", json!({"server": form.server.trim(), "name": form.name.trim()})).is_some() {
app.ui.live.last_server = form.server.trim().to_string();
if ask {
ask_server(app, ctx, address.clone());
}
if go && app.run("collab.publish", json!({"server": address, "name": form.name.trim()})).is_some() {
app.ui.live.last_server = address;
// The owner token shows once the file is on the server.
app.ui.live.show_owner = !keyed;
app.cancel_background(SERVER_INFO);
return;
}
if open && !cancel {
app.ui.live.publish = Some(form);
} else {
app.cancel_background(SERVER_INFO);
}
}
/// The file just published from the dialog without the key: its links and its owner token, shown
/// this once, with where it is hosted and when it expires.
fn published_dialog(app: &mut FigApp, ctx: &egui::Context) {
if !app.ui.live.show_owner {
return;
}
let live = &app.session.live;
let Some(p) = live.published.clone().filter(|p| p.owner.is_some()) else {
// Still on its way, or it didn't get there (the message says why).
app.ui.live.show_owner = live.status == Status::Connecting;
return;
};
let hosting = live.hosting.clone();
let owner = p.owner.clone().unwrap_or_default();
let t = Tokens::get(ctx);
let mut open = true;
let mut done = false;
crate::dialogs::window(tr("File published"), ctx).id(egui::Id::new("File published")).open(&mut open).show(ctx, |ui| {
ui.set_width(400.0);
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
widgets::label(ui, &trf("{name} is on the server.", &[("name", &p.name)]), t.text);
link_rows(app, ui, &[(tr("Owner token"), &owner, tr("Owner token copied"))]);
widgets::label(ui, tr(OWNER_NOTE), t.danger);
link_rows(app, ui, &[(tr("Edit link"), &p.edit, tr("Edit link copied")), (tr("View link"), &p.view, tr("View link copied"))]);
expiry_line(ui, &t, p.expires);
if let Some(h) = &hosting {
host_line(ui, &t, &h.operator, &h.terms);
}
widgets::label(ui, tr(PLAIN_NOTE), t.text_secondary);
ui.add_space(4.0);
done = widgets::button(ui, tr("Done"), true, true).clicked();
});
if !open || done {
app.ui.live.show_owner = false;
}
}
@@ -781,6 +975,9 @@ pub fn inspect(app: &FigApp) -> serde_json::Value {
"cursorsShown": app.ui.live.shown(),
"joinDialog": app.ui.live.join.is_some(),
"publishDialog": app.ui.live.publish.is_some(),
"publishInfo": app.ui.live.publish.as_ref().and_then(|f| f.info.as_ref().map(|(_, v)| v.clone())),
"ownerDialog": app.ui.live.show_owner && app.session.live.published.as_ref().is_some_and(|p| p.owner.is_some()),
"report": app.ui.live.report,
"readOnly": app.session.active_read_only(),
})
}
+88 -4
View File
@@ -1,12 +1,14 @@
//! Files on a server in the window: the Publish to server dialog (from the File menu and the
//! Share popover; its field takes the keyboard; Escape closes it; publishing leaves the tab
//! read-only until the file is there), the Share popover's address, access and a published file's
//! links, and a view link's "View only" badge with only the moving tools.
//! read-only until the file is there; a server open to the public takes it without a key and the
//! owner token shows once), the Share popover's address, access, a published file's links, where
//! it is hosted, when it expires and reporting it, and a view link's "View only" badge with only
//! the moving tools.
use egui::{Event, Key};
use figcraft_engine::figcraft_collab::message::{ClientMsg, ServerMsg};
use figcraft_engine::figcraft_collab::{Client, Limits, Outgoing, Server};
use figcraft_engine::live::{Access, Event as LiveEvent, Published, Role, SessionInfo, Status, VIEW_ONLY};
use figcraft_engine::live::{Access, Event as LiveEvent, HostInfo, Published, Role, SessionInfo, Status, VIEW_ONLY};
use figcraft_engine::tools::ToolId;
use serde_json::json;
@@ -63,9 +65,13 @@ fn the_publish_dialog() {
assert!(has_text(&shapes, "Or keep the file on a FigCraft server and share links to edit or to view it."));
h.click(text_center(&shapes, "Publish to server…"));
let shapes = h.frames(2);
assert!(has_text(&shapes, "On the server's machine, `figcraft-server key` prints it."));
assert!(has_text(
&shapes,
"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)."
));
h.frame(vec![Event::Text("ws://127.0.0.1:47900/?key=KeyKeyKeyKeyKeyKeyKey0".into())]);
let shapes = h.frames(1);
assert!(has_text(&shapes, "Publishing with the server's key."));
h.click(text_center(&shapes, "Publish"));
let shapes = h.frames(2);
assert!(h.app.ui.live.publish.is_none(), "it closes once publishing starts");
@@ -74,6 +80,69 @@ fn the_publish_dialog() {
assert_eq!(crate::live::inspect(&h.app)["readOnly"], figcraft_engine::live::PUBLISHING);
}
fn hosting(expires: Option<u64>) -> HostInfo {
HostInfo {
open: true,
operator: "abuse@example.com".into(),
terms: "https://example.com/terms".into(),
idle_secs: 30 * 86_400,
max_file_mb: 64,
expires,
}
}
#[test]
fn a_server_open_to_the_public_takes_a_file_without_a_key() {
let (mut h, _) = Harness::new();
crate::menus::invoke(&mut h.app, "collab.publish");
h.frames(2);
h.frame(vec![Event::Text("wss://figcraft.example.com/".into())]);
let shapes = h.frames(1);
assert!(!crate::live::inspect(&h.app)["publishDialog"].is_null());
// Without a key and before the server said it is open, Publish waits.
h.click(text_center(&shapes, "Publish"));
assert!(h.app.ui.live.publish.is_some(), "nothing is sent yet");
// The server's answer (collab.serverInfo).
h.app.ui.live.publish.as_mut().unwrap().info = Some((
"wss://figcraft.example.com/".into(),
json!({"open": true, "operator": "abuse@example.com", "terms": "https://example.com/terms", "idleDays": 30.0}),
));
let shapes = h.frames(2);
assert!(has_text(&shapes, "This server is open to the public: no key needed."));
assert!(has_text(&shapes, "Hosted by abuse@example.com") && has_text(&shapes, "Terms"));
assert!(has_text(&shapes, "Files nobody opens for 30 days are deleted."));
assert!(has_text(&shapes, "Files aren't end-to-end encrypted: the server's operator can read them."));
h.click(text_center(&shapes, "Publish"));
h.frames(2);
assert!(h.app.ui.live.publish.is_none());
assert_eq!(h.app.session.live.status, Status::Connecting);
// On the server: the owner token shows once, with the links and the expiry.
let owner = "OwnerOwnerOwnerOwner00";
h.send(vec![
LiveEvent::Hosting(hosting(None)),
LiveEvent::Published(Published {
server: "wss://figcraft.example.com/".into(),
file: "f".into(),
name: "Poster".into(),
edit: "figcraft://join?server=wss://figcraft.example.com/&file=f&token=EditEditEditEditEdit00".into(),
view: "figcraft://join?server=wss://figcraft.example.com/&file=f&token=ViewViewViewViewView00".into(),
owner: Some(owner.into()),
expires: Some(1_800_000_000),
}),
]);
let shapes = h.frames(3);
assert_eq!(crate::live::inspect(&h.app)["ownerDialog"], true);
assert!(has_text(&shapes, "File published") && has_text(&shapes, "Owner token") && has_text(&shapes, owner));
assert!(has_text(
&shapes,
"Copy the owner token and keep it somewhere safe: it is the only way to revoke these links, rename the file or delete it from the server, and it won't be shown again."
));
assert!(has_text(&shapes, "The server deletes this file if nobody opens it before 2027-01-15 08:00 UTC."));
h.click(text_center(&shapes, "Done"));
h.frames(2);
assert_eq!(crate::live::inspect(&h.app)["ownerDialog"], false, "shown once");
}
#[test]
fn a_published_file_shows_its_links() {
let (mut h, _) = Harness::new();
@@ -84,7 +153,10 @@ fn a_published_file_shows_its_links() {
name: "Team".into(),
edit: "figcraft://join?host=127.0.0.1:47900&file=f&token=EditEditEditEditEdit00".into(),
view: "figcraft://join?host=127.0.0.1:47900&file=f&token=ViewViewViewViewView00".into(),
owner: None,
expires: None,
});
h.send(vec![LiveEvent::Hosting(hosting(Some(1_800_000_000)))]);
let shapes = h.frames(2);
h.click(text_center(&shapes, "Share"));
let shapes = h.frames(2);
@@ -92,6 +164,18 @@ fn a_published_file_shows_its_links() {
assert!(has_text(&shapes, "127.0.0.1:47900 · Can edit"));
assert!(has_text(&shapes, "Edit link") && has_text(&shapes, "View link"));
assert!(has_text(&shapes, "Leave session"));
assert!(!has_text(&shapes, "Owner token"), "the owner token showed once, not here");
// Where it is hosted, when it expires, and reporting it to the operator.
assert!(has_text(&shapes, "Hosted by abuse@example.com") && has_text(&shapes, "Terms"));
assert!(has_text(&shapes, "The server deletes this file if nobody opens it before 2027-01-15 08:00 UTC."));
h.click(text_center(&shapes, "Report this file…"));
let shapes = h.frames(2);
assert!(has_text(&shapes, "What is wrong with this file? The server's operator gets your report."));
h.app.ui.live.report = Some("Spam".into());
let shapes = h.frames(2);
h.click(text_center(&shapes, "Send report"));
h.frames(2);
assert!(h.app.ui.live.report.is_none(), "sent");
}
#[test]