From 4b4b2bfd009857d200dcff3c527cc3e566932616 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Fri, 30 Oct 2026 16:13:54 +0100 Subject: [PATCH] M9: a team list on figcraft-server: the files shared now, joined without a token (team_files) --- apps/figcraft-server/src/config.rs | 35 ++++ apps/figcraft-server/src/lib.rs | 2 + apps/figcraft-server/src/store.rs | 29 +++- apps/figcraft-server/src/store/backend.rs | 44 ++++- apps/figcraft-server/src/store/team.rs | 97 +++++++++++ apps/figcraft-server/src/team_tests.rs | 199 ++++++++++++++++++++++ crates/collab-net/src/code_tests.rs | 69 +++++++- crates/collab-net/src/driver.rs | 16 +- crates/collab-net/src/guest.rs | 7 +- crates/collab-net/src/hub.rs | 25 ++- crates/collab-net/src/hub/backend.rs | 12 +- crates/collab-net/src/hub/conn.rs | 3 +- crates/collab-net/src/invite.rs | 39 ++++- crates/collab-net/src/join.rs | 34 ++-- crates/collab-net/src/lib.rs | 2 +- crates/collab-net/src/link.rs | 12 +- crates/collab-net/src/manage.rs | 20 ++- crates/collab-net/src/web.rs | 4 +- crates/collab/src/message.rs | 41 ++++- crates/collab/src/server.rs | 1 + 20 files changed, 621 insertions(+), 70 deletions(-) create mode 100644 apps/figcraft-server/src/store/team.rs create mode 100644 apps/figcraft-server/src/team_tests.rs diff --git a/apps/figcraft-server/src/config.rs b/apps/figcraft-server/src/config.rs index e2c48e05..fd56e02d 100644 --- a/apps/figcraft-server/src/config.rs +++ b/apps/figcraft-server/src/config.rs @@ -3,6 +3,7 @@ use std::net::{IpAddr, SocketAddr}; use std::path::{Path, PathBuf}; +use figcraft_collab::message::Access; use figcraft_collab_net::{Endpoint, NetLimits}; use serde::Deserialize; @@ -72,6 +73,13 @@ pub struct Config { pub connects_per_minute: u32, pub publishes_per_hour: u32, pub messages_per_second: u32, + /// The team list: everyone who reaches the server sees the files shared from it now (a link of + /// theirs has a join code) and opens them with this access, `"edit"`, `"comment"` or `"view"`. + /// Empty (the default): no team list. Only for a server just your team reaches. + pub team_files: String, + /// Where the browser build is served for this server (`https://figcraft.example.com/app/`): the + /// team list's browser links point there. Empty: none. + pub web_url: String, } impl Default for Config { @@ -103,6 +111,8 @@ impl Default for Config { connects_per_minute: 30, publishes_per_hour: 10, messages_per_second: 200, + team_files: String::new(), + web_url: String::new(), } } } @@ -185,6 +195,26 @@ impl Config { self.max_people.min(figcraft_collab::message::MAX_PEERS) } + /// What the team list lets people do with its files (`None`: there is no team list). + pub fn team(&self) -> Result, String> { + let t = self.team_files.trim(); + if t.is_empty() { + return Ok(None); + } + Access::parse(t).map(Some).ok_or_else(|| "`team_files` is \"edit\", \"comment\" or \"view\" (empty: no team list)".to_string()) + } + + /// The browser build's address for the team list's links, when set: an `https://` (or + /// `http://`) address without a query or fragment. + pub fn web(&self) -> Result { + let w = self.web_url.trim(); + let web = w.starts_with("https://") || w.starts_with("http://"); + if w.is_empty() || (web && w.len() <= 500 && !w.contains(['?', '#']) && !w.contains(char::is_whitespace) && !w.contains(char::is_control)) { + return Ok(w.to_string()); + } + Err("`web_url` must be a web address without a query (https://figcraft.example.com/app/), at most 500 characters".into()) + } + /// The trusted proxy's address, when set. pub fn proxy(&self) -> Result, String> { let Some(p) = self.trusted_proxy.as_deref().map(str::trim).filter(|p| !p.is_empty()) else { return Ok(None) }; @@ -196,6 +226,8 @@ impl Config { self.addr()?; self.public()?; self.proxy()?; + self.team()?; + self.web()?; 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()); } @@ -262,6 +294,9 @@ impl Config { 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), + // `check` refused anything else before the store is made. + team: self.team().ok().flatten(), + web: self.web().unwrap_or_default(), abuse: AbuseConfig { open: self.open_publish, connects_per_minute: self.connects_per_minute, diff --git a/apps/figcraft-server/src/lib.rs b/apps/figcraft-server/src/lib.rs index 575cf79c..5ae6fe9b 100644 --- a/apps/figcraft-server/src/lib.rs +++ b/apps/figcraft-server/src/lib.rs @@ -115,4 +115,6 @@ mod server_tests; #[cfg(test)] mod store_tests; #[cfg(test)] +mod team_tests; +#[cfg(test)] mod test_util; diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index 1feaf706..04e9e3ad 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -7,7 +7,8 @@ //! /server.key the server key: managing files (publish, links, removal) //! /files//meta.json name, created, updated, sequence number //! /files//links.json share links: id, access, label, created, SHA-256 of the token, -//! and a join code's hash and wrapped token (see `codes`) +//! a join code's hash and wrapped token (see `codes`), and a team +//! link's token (see `team`) //! /files//snapshot.bin the session state at the last compaction //! /files//log.bin what changed since (see `disk`) //! /files//library-.bin its team library, when one was published (a `.figcraft` @@ -57,6 +58,7 @@ mod files; mod journal; mod library; mod links; +pub(crate) mod team; pub use library::library_name; @@ -145,6 +147,10 @@ pub struct HostingConfig { pub max_files_per_ip: usize, pub max_bytes_per_ip: u64, pub abuse: AbuseConfig, + /// The team list (`team_files`): what its links let people do; `None`: no team list. + pub team: Option, + /// Where the browser build is served (`web_url`; empty: not said). + pub web: String, } impl Default for HostingConfig { @@ -159,6 +165,8 @@ impl Default for HostingConfig { 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 }, + team: None, + web: String::new(), } } } @@ -199,7 +207,8 @@ struct LibraryMeta { version: u64, } -/// A share link (`links.json`): the token itself is never stored. +/// A share link (`links.json`): the token itself is never stored, except a team link's (the server +/// hands it to whoever asks the team list for it). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] struct Link { id: String, @@ -211,6 +220,20 @@ struct Link { /// Its join code, when one was handed out (never the code itself). #[serde(default, skip_serializing_if = "Option::is_none")] code: Option, + /// A team link's token: the server made the link for its team list. + #[serde(default, skip_serializing_if = "Option::is_none")] + team: Option, +} + +/// A team link's token, kept so the team list can hand it out; never shown in debug output. +#[derive(Clone, PartialEq, Serialize, Deserialize)] +#[serde(transparent)] +struct TeamToken(String); + +impl std::fmt::Debug for TeamToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("TeamToken(..)") + } } impl Link { @@ -613,5 +636,5 @@ fn write_json(path: &Path, value: &T) -> Result<(), Strin /// A new link and its token. fn new_link(access: Access, label: &str, created: u64) -> Result<(Link, String), String> { let token = token::secret()?; - Ok((Link { id: token::file_id()?, access, label: label.to_string(), created, hash: hash(&token), code: None }, token)) + Ok((Link { id: token::file_id()?, access, label: label.to_string(), created, hash: hash(&token), code: None, team: None }, token)) } diff --git a/apps/figcraft-server/src/store/backend.rs b/apps/figcraft-server/src/store/backend.rs index ecef744c..f633b021 100644 --- a/apps/figcraft-server/src/store/backend.rs +++ b/apps/figcraft-server/src/store/backend.rs @@ -1,4 +1,5 @@ use super::journal::flush_log; +use super::team::team_admits; use super::{ Access, Action, Admin, AdminReply, Admission, BUSY_ADMIN, BUSY_DOWNLOADS, Backend, ClientMsg, Closing, Handled, HostInfo, IpAddr, LibraryReply, LibraryReq, OpenFile, PluginReply, PluginReq, Server, Store, disk, hash, now, same_secret, @@ -9,14 +10,37 @@ use super::{ fn changes_files(req: &Admin) -> bool { !matches!( req, - Admin::Files | Admin::Links { .. } | Admin::Listing | Admin::Reports | Admin::Blocked | Admin::Owned { .. } | Admin::RemovePeer { .. } + Admin::Files + | Admin::Links { .. } + | Admin::Listing + | Admin::Reports + | Admin::Blocked + | Admin::Owned { .. } + | Admin::RemovePeer { .. } + | Admin::Team ) } +impl Store { + /// Revoke link `link` of `file`, and the file's team links once it isn't shared any more: + /// whoever joined with any of them is disconnected. + fn revoke_all(&mut self, file: String, link: String) -> Handled { + match self.revoke(&file, &link) { + Ok(()) => { + let mut revoked = vec![(file.clone(), link)]; + revoked.extend(self.team_unshared(&file).into_iter().map(|l| (file.clone(), l))); + Handled { reply: AdminReply::Done, revoked, removed: None, blocked: None } + } + Err(reason) => AdminReply::Failed { reason }.into(), + } + } +} + impl Backend for Store { fn admit(&mut self, file: &str, token: &str) -> Result { + let team = self.cfg.hosting.team; let entry = self.entry(file)?; - let link = Self::link_of(entry, token).and_then(|i| entry.links.get(i)); + let link = Self::link_of(entry, token).filter(|&i| team_admits(team, entry, i)).and_then(|i| entry.links.get(i)); link.map(|l| Admission { access: l.access, link: Some(l.id.clone()) }) .ok_or_else(|| "this link isn't valid (it may have been revoked)".to_string()) } @@ -166,6 +190,8 @@ impl Backend for Store { }; } Admin::Owned { files } => return self.owned(files).into(), + // Anyone who reaches the server, when its operator turned the team list on. + Admin::Team => return self.team(open).into(), Admin::Links { file } | Admin::CreateLink { file, .. } | Admin::Revoke { file, .. } @@ -198,21 +224,19 @@ impl Backend for Store { Admin::Files => AdminReply::Files(self.list(open)).into(), 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, blocked: None }, - Err(e) => failed(e), - }, + Admin::Revoke { file, link } => self.revoke_all(file, link), 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) }, + Ok(ip) => Handled { reply: AdminReply::Done, revoked: Vec::new(), 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::Code { file, token } => self.set_code(&file, &token).map_or_else(failed, |code| AdminReply::Code { code }.into()), Admin::Owned { files } => self.owned(&files).into(), + Admin::Team => self.team(open).into(), // The hub removes people itself (it knows who is connected); it never asks the store. Admin::RemovePeer { .. } => failed("removing someone from a file is done by the server's hub".into()), Admin::Remove { file } => { @@ -223,7 +247,7 @@ impl Backend for Store { Ok(()) => AdminReply::Done, Err(reason) => AdminReply::Failed { reason }, }; - Handled { reply, revoked: None, removed: Some(file), blocked: None } + Handled { reply, revoked: Vec::new(), removed: Some(file), blocked: None } } } } @@ -264,6 +288,10 @@ impl Backend for Store { self.resolve_code(ip, code) } + fn resolve_team(&mut self, _ip: IpAddr, file: &str) -> Result { + self.team_link(file).map(|l| l.token) + } + fn closing(&self) -> Closing { Closing::Restarting } diff --git a/apps/figcraft-server/src/store/team.rs b/apps/figcraft-server/src/store/team.rs new file mode 100644 index 00000000..b59f83c5 --- /dev/null +++ b/apps/figcraft-server/src/store/team.rs @@ -0,0 +1,97 @@ +//! The team list (`team_files`): everyone who reaches the server sees the files shared from it now +//! and opens them with a team link, one per file, that the server makes the first time someone +//! asks for it and keeps (its token readable, unlike other links') while the file stays shared. A +//! file is shared while a link of its own (not a team link) has a join code: Share ▸ Start sharing +//! makes such links, Stop sharing revokes them, and with the last of them the team links go too +//! (whoever joined with one is disconnected, as with the links themselves). + +use figcraft_collab::message::{MAX_TEAM, TeamFile}; + +use super::{Access, AdminReply, Entry, LINKS, NewLink, OpenFile, Store, TeamToken, new_link, now, write_json}; + +/// What a server without a team list answers. +pub const NO_TEAM: &str = "this server keeps no team list (its operator can turn one on with team_files)"; + +/// What asking for the link of a file that isn't shared now answers. +pub const NOT_SHARED: &str = "this file isn't shared now (its owner stopped sharing it, or removed it)"; + +/// The label of a team link (as `figcraft-server links list` shows it). +fn label(access: Access) -> String { + format!("Team ({})", access.id()) +} + +/// Whether `entry` is shared now: a link of its own has a join code. +fn shared(entry: &Entry) -> bool { + entry.links.iter().any(|l| l.team.is_none() && l.code.is_some()) +} + +impl Store { + /// The files shared now, most recently changed first (at most [`MAX_TEAM`]). + pub(super) fn team(&self, open: &dyn Fn(&str) -> Option) -> AdminReply { + let Some(access) = self.cfg.hosting.team else { return AdminReply::Failed { reason: NO_TEAM.into() } }; + let mut files: Vec = + self.files.iter().filter(|(_, e)| shared(e)).map(|(id, e)| TeamFile { file: self.info(id, e, open(id)), access }).collect(); + files.sort_by(|a, b| b.file.updated.cmp(&a.file.updated).then_with(|| a.file.name.cmp(&b.file.name))); + files.truncate(MAX_TEAM); + AdminReply::Team { web: self.cfg.hosting.web.clone(), files } + } + + /// The link the team joins `file` with: made now when it has none with the team list's access. + /// It replaces a team link made with another access before the operator changed the setting + /// (that took a restart, so nobody is in the file with it). + pub(super) fn team_link(&mut self, file: &str) -> Result { + let access = self.cfg.hosting.team.ok_or(NO_TEAM)?; + let max = self.cfg.max_links; + let path = self.dir(file).join(LINKS); + let entry = self.files.get_mut(file).filter(|e| shared(e)).ok_or(NOT_SHARED)?; + let made = entry.links.iter().find_map(|l| match &l.team { + Some(TeamToken(token)) if l.access == access => Some(NewLink { link: l.info(file), token: token.clone() }), + _ => None, + }); + if let Some(made) = made { + return Ok(made); + } + let before = entry.links.clone(); + entry.links.retain(|l| l.team.is_none()); + if entry.links.len() >= max { + entry.links = before; + return Err(format!("a file has at most {max} links: revoke one first")); + } + let (mut link, token) = new_link(access, &label(access), now())?; + link.team = Some(TeamToken(token.clone())); + entry.links.push(link.clone()); + if let Err(e) = write_json(&path, &entry.links) { + entry.links = before; + return Err(format!("couldn't keep the team link: {e}")); + } + log::info!("event=team_link file={file} link={}", link.id); + Ok(NewLink { link: link.info(file), token }) + } + + /// `file` stopped being shared (a link was revoked): revoke its team links too. The ids + /// revoked (whoever joined with them is disconnected); none while it is still shared. + pub(super) fn team_unshared(&mut self, file: &str) -> Vec { + let path = self.dir(file).join(LINKS); + let Some(entry) = self.files.get_mut(file).filter(|e| !shared(e)) else { return Vec::new() }; + let gone: Vec = entry.links.iter().filter(|l| l.team.is_some()).map(|l| l.id.clone()).collect(); + if gone.is_empty() { + return gone; + } + let before = entry.links.clone(); + entry.links.retain(|l| l.team.is_none()); + if let Err(e) = write_json(&path, &entry.links) { + // Still refused at the door ([`team_admits`]) while the file isn't shared. + log::error!("file {file}: its team links couldn't be revoked: {e}"); + entry.links = before; + return Vec::new(); + } + log::info!("revoked the team links of file {file}: it isn't shared now"); + gone + } +} + +/// Whether link `at` of `entry` lets people in, on a server whose team list gives `team`: a team +/// link only while the list is on with its access and the file is shared; any other link always. +pub(super) fn team_admits(team: Option, entry: &Entry, at: usize) -> bool { + entry.links.get(at).is_some_and(|l| l.team.is_none() || (team == Some(l.access) && shared(entry))) +} diff --git a/apps/figcraft-server/src/team_tests.rs b/apps/figcraft-server/src/team_tests.rs new file mode 100644 index 00000000..8769c84b --- /dev/null +++ b/apps/figcraft-server/src/team_tests.rs @@ -0,0 +1,199 @@ +//! The team list (`team_files`) against a real server: off unless the operator turns it on; on, +//! it lists the files shared now (not the ones only published), hands out one team link per file +//! that joins with the list's access, and when the owner stops sharing the file leaves the list, +//! its team link stops working and whoever joined with it is disconnected. Turning the list off, +//! or changing its access, refuses the old team links at once. + +use std::net::SocketAddr; + +use figcraft_collab::message::{Access, Admin, AdminReply, ClientMsg, OpenHosting, PROTOCOL, ResolveTeam, ServerMsg, TeamFile}; +use figcraft_engine::live::Status; +use serde_json::{Value, json}; + +use crate::config::Config; +use crate::store::team::{NO_TEAM, NOT_SHARED}; +use crate::test_util::{Raw, TempDir, config, join, live, publish, pump, session}; + +fn team(dir: &std::path::Path, access: &str) -> Config { + Config { open_publish: true, team_files: access.into(), web_url: "https://figcraft.example.com/app/".into(), ..config(dir) } +} + +fn ex(s: &mut figcraft_engine::Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +/// One request on an open hosting connection that names no file (anyone's). +fn ask(addr: SocketAddr, req: Admin) -> AdminReply { + let mut raw = Raw::connect(addr); + raw.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: "test".into(), file: String::new(), owner: String::new() })); + assert!(matches!(raw.next(), ServerMsg::Hosting(_))); + raw.send(&ClientMsg::Admin(req)); + match raw.next() { + ServerMsg::Admin(r) => *r, + other => panic!("answered {other:?}"), + } +} + +fn listed(addr: SocketAddr) -> (String, Vec) { + match ask(addr, Admin::Team) { + AdminReply::Team { web, files } => (web, files), + other => panic!("not a team list: {other:?}"), + } +} + +/// The team link of `file`, as a join by the team list asks for it: its token. +fn team_link(addr: SocketAddr, file: &str) -> Result { + let mut raw = Raw::connect(addr); + raw.send(&ClientMsg::ResolveTeam(ResolveTeam { protocol: PROTOCOL, client_version: "test".into(), file: file.into() })); + match raw.next() { + ServerMsg::Resolved { file: f, token } if f == file => Ok(token), + ServerMsg::Refused { reason } => Err(reason), + other => panic!("answered {other:?}"), + } +} + +/// The team link's entry in the file's links (as `figcraft-server links list` shows them). +fn team_entry(dir: &std::path::Path, file: &str) -> (Access, String) { + let links: serde_json::Value = serde_json::from_slice(&std::fs::read(dir.join("files").join(file).join("links.json")).unwrap()).unwrap(); + let l = links.as_array().unwrap().iter().find(|l| l["team"].is_string()).expect("a team link"); + (Access::parse(l["access"].as_str().unwrap()).unwrap(), l["label"].as_str().unwrap().to_string()) +} + +fn invite(addr: SocketAddr, file: &str, token: &str) -> String { + format!("ws://{addr}/?file={file}&token={token}") +} + +/// Ada shares a file online: the app with the file live, and the file's id. +fn share(addr: SocketAddr, name: &str) -> (figcraft_engine::Session, String) { + let mut a = session("Ada"); + ex(&mut a, "pref.publishServer", json!({"url": format!("ws://{addr}/")})); + ex(&mut a, "file.rename", json!({"name": name})); + ex(&mut a, "shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 30})); + ex(&mut a, "collab.share", json!({})); + pump(&mut [&mut a], "the share", |s| live(s[0]) && s[0].live().share.as_ref().is_some_and(|sh| sh.links.is_some())); + let file = a.prefs.online_files[0].file.clone(); + (a, file) +} + +fn layers(s: &figcraft_engine::Session) -> usize { + s.doc().map_or(0, |d| d.node_count()) +} + +fn refused(s: &figcraft_engine::Session) -> bool { + matches!(&s.live().status, Status::Failed { reason } if reason.contains("isn't valid")) +} + +#[test] +fn there_is_no_team_list_unless_the_operator_turns_it_on() { + let dir = TempDir::new("team-off"); + let server = crate::start(&Config { open_publish: true, ..config(dir.path()) }).unwrap(); + let (_a, file) = share(server.addr(), "Homepage"); + assert_eq!(ask(server.addr(), Admin::Team), AdminReply::Failed { reason: NO_TEAM.into() }); + assert_eq!(team_link(server.addr(), &file), Err(NO_TEAM.into())); + for bad in ["owner", "edit view"] { + assert!(team(dir.path(), bad).check().is_err(), "team_files = {bad:?} is refused"); + } + for bad in ["ftp://x/", "https://x/app/?k=1", "https://x/app/#a", "https://x y/"] { + assert!(Config { web_url: bad.into(), ..config(dir.path()) }.check().is_err(), "web_url = {bad:?} is refused"); + } + assert!(team(dir.path(), " View ").check().is_ok()); +} + +#[test] +fn the_team_list_has_the_files_shared_now_and_their_team_link_joins() { + let dir = TempDir::new("team-list"); + let server = crate::start(&team(dir.path(), "edit")).unwrap(); + let addr = server.addr(); + let (mut a, file) = share(addr, "Homepage"); + // Published with the key but not shared: not on the list. + let mut c = session("Cy"); + let unshared = publish(&mut c, dir.path(), addr).file; + let (web, files) = listed(addr); + assert_eq!(web, "https://figcraft.example.com/app/"); + let ids: Vec<(&str, &str, Access)> = files.iter().map(|f| (f.file.id.as_str(), f.file.name.as_str(), f.access)).collect(); + assert_eq!(ids, vec![(file.as_str(), "Homepage", Access::Edit)]); + assert!(team_link(addr, &unshared).is_err_and(|e| e == NOT_SHARED)); + assert!(team_link(addr, "nonesuch").is_err_and(|e| e == NOT_SHARED)); + // The team link: made once, the same for everyone after. + let token = team_link(addr, &file).unwrap(); + assert_eq!(team_entry(dir.path(), &file), (Access::Edit, "Team (edit)".to_string())); + assert_eq!(team_link(addr, &file).unwrap(), token); + // Bob joins with it and edits; Ada sees the change. + let mut b = join(&invite(addr, &file, &token), "Bob"); + pump(&mut [&mut a, &mut b], "Bob in", |s| live(s[1]) && s[0].live().peers.len() == 1); + let before = layers(&a); + ex(&mut b, "shape.ellipse", json!({"x": 80, "y": 10, "width": 30, "height": 30})); + pump(&mut [&mut a, &mut b], "Bob's ellipse at Ada's", |s| layers(s[0]) == before + 1); + // The team link isn't a share of its own: the file isn't listed twice, and the owner's + // record still has just the links Ada made. + assert_eq!(listed(addr).1.len(), 1); +} + +#[test] +fn stopping_sharing_takes_the_file_off_the_list_and_ends_the_team_link() { + let dir = TempDir::new("team-unshare"); + let server = crate::start(&team(dir.path(), "edit")).unwrap(); + let addr = server.addr(); + let (mut a, file) = share(addr, "Homepage"); + let token = team_link(addr, &file).unwrap(); + let mut b = join(&invite(addr, &file, &token), "Bob"); + pump(&mut [&mut a, &mut b], "Bob in", |s| live(s[1])); + assert_eq!(ex(&mut a, "collab.manageFile", json!({"file": file, "action": "unshare"}))["shared"], false); + // Bob is out, as people who joined with Ada's own links are. + pump(&mut [&mut a, &mut b], "Bob disconnected", |s| matches!(s[1].live().status, Status::Failed { .. } | Status::Off)); + assert!(listed(addr).1.is_empty()); + assert!(team_link(addr, &file).is_err_and(|e| e == NOT_SHARED)); + let mut again = join(&invite(addr, &file, &token), "Bob"); + pump(&mut [&mut again], "the old team link refused", |s| refused(s[0])); + // Sharing again lists it again, with a new team link. + assert_eq!(ex(&mut a, "collab.manageFile", json!({"file": file, "action": "share"}))["shared"], true); + assert_eq!(listed(addr).1.len(), 1); + assert_ne!(team_link(addr, &file).unwrap(), token); +} + +#[test] +fn turning_the_list_off_or_changing_its_access_refuses_old_team_links() { + let dir = TempDir::new("team-config"); + let mut server = crate::start(&team(dir.path(), "edit")).unwrap(); + let addr = server.addr(); + let (a, file) = share(addr, "Homepage"); + let edit = team_link(addr, &file).unwrap(); + drop(a); + server.stop(); + // View only now: the edit team link no longer joins; the new one is view only. + let server = crate::start(&team(dir.path(), "view")).unwrap(); + let addr = server.addr(); + let mut b = join(&invite(addr, &file, &edit), "Bob"); + pump(&mut [&mut b], "the edit team link refused", |s| refused(s[0])); + let view = team_link(addr, &file).unwrap(); + assert_eq!(team_entry(dir.path(), &file).0, Access::View); + let mut b = join(&invite(addr, &file, &view), "Bob"); + pump(&mut [&mut b], "Bob in, view only", |s| live(s[0]) && s[0].live_summary()["access"] == "view"); + drop(b); + drop(server); + // Off: the list is gone and its links with it. + let server = crate::start(&Config { open_publish: true, ..config(dir.path()) }).unwrap(); + let mut b = join(&invite(server.addr(), &file, &view), "Bob"); + pump(&mut [&mut b], "the team link refused", |s| refused(s[0])); +} + +#[test] +fn a_team_list_link_joins_without_a_token_while_the_file_is_shared() { + let dir = TempDir::new("team-join"); + let server = crate::start(&team(dir.path(), "edit")).unwrap(); + let addr = server.addr(); + let (mut a, file) = share(addr, "Homepage"); + // What the team list gives people to click or paste: no token in it. + let link = format!("figcraft://join?host={addr}&file={file}&team=1"); + let mut b = join(&link, "Bob"); + pump(&mut [&mut a, &mut b], "Bob in by the team list", |s| live(s[1]) && s[0].live().peers.len() == 1); + assert_eq!(b.live_summary()["access"], "edit"); + let failed = |s: &figcraft_engine::Session, why: &str| matches!(&s.live().status, Status::Failed { reason } if reason.contains(why)); + let mut c = join(&format!("figcraft://join?host={addr}&file=0123456789abcdef&team=1"), "Cy"); + pump(&mut [&mut c], "a file that isn't shared refused", |s| failed(s[0], "isn't shared now")); + // A server without a team list says so. + let dir = TempDir::new("team-join-off"); + let off = crate::start(&Config { open_publish: true, ..config(dir.path()) }).unwrap(); + let mut d = join(&format!("figcraft://join?host={}&file={file}&team=1", off.addr()), "Di"); + pump(&mut [&mut d], "no team list", |s| failed(s[0], "keeps no team list")); +} diff --git a/crates/collab-net/src/code_tests.rs b/crates/collab-net/src/code_tests.rs index 4cf10f6a..7f074f47 100644 --- a/crates/collab-net/src/code_tests.rs +++ b/crates/collab-net/src/code_tests.rs @@ -17,7 +17,7 @@ use tungstenite::{Bytes, Message}; use crate::driver::{Driver, Socket}; use crate::guest::GuestConfig; use crate::inbox::Inbox; -use crate::invite::{Endpoint, Invite}; +use crate::invite::{Endpoint, Invite, Lookup}; use crate::loopback_tests::{fast_limits, quick}; use crate::{NetLink, manage}; @@ -117,13 +117,18 @@ fn a_local_code_with_the_wrong_secret_or_a_typo_doesnt_join() { assert!(e.contains("share it online"), "{e}"); } +/// An invite naming only `server` (what a join code is asked at). +fn code_at(server: &Endpoint) -> Invite { + Invite { server: server.clone(), file: String::new(), token: String::new() } +} + #[test] fn links_can_name_a_join_code() { let (invite, found) = Invite::parse_join("figcraft://join?server=wss://share.example/&code=k7m-2qx-9td").unwrap(); assert_eq!((invite.server.url(), invite.file.as_str(), invite.token.as_str()), ("wss://share.example:443/".to_string(), "", "")); - assert_eq!(found.as_deref(), Some("K7M2QX9TD")); + assert_eq!(found, Some(Lookup::Code("K7M2QX9TD".into()))); let (invite, found) = Invite::parse_join(" figcraft://join?host=127.0.0.1:47900&code=K7M2QX9TD ").unwrap(); - assert_eq!((invite.server, found.as_deref()), (Endpoint::plain("127.0.0.1:47900"), Some("K7M2QX9TD"))); + assert_eq!((invite.server, found), (Endpoint::plain("127.0.0.1:47900"), Some(Lookup::Code("K7M2QX9TD".into())))); // A link with a file and a token is read as before. let link = "figcraft://join?host=127.0.0.1:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv"; assert_eq!(Invite::parse_join(link).unwrap(), (Invite::parse(link).unwrap(), None)); @@ -198,7 +203,7 @@ fn a_server_older_than_join_codes_says_so_and_the_links_still_work() { let (server, thread) = old_server(); let limits = fast_limits(); // Asking it about a code: the join fails with what to do instead. - let e = manage::resolve(&server, "K7M2QX9TD", &limits, Duration::from_secs(5)).unwrap_err(); + let e = manage::resolve(&code_at(&server), &Lookup::Code("K7M2QX9TD".into()), &limits, Duration::from_secs(5)).unwrap_err(); assert!(e.starts_with(manage::REFUSED) && e.contains("couldn't be read"), "{e}"); let mut guest = session("Ada"); guest.execute("pref.publishServer", &json!({"url": server.url()})).unwrap(); @@ -233,11 +238,11 @@ fn an_app_hosted_session_knows_no_online_codes() { let mut host = session("Hana"); let shared = host.execute("collab.host", &json!({"port": 0})).unwrap(); let server = Endpoint::plain(shared["address"].as_str().unwrap()); - let e = manage::resolve(&server, "K7M2QX9TD", &fast_limits(), Duration::from_secs(5)).unwrap_err(); + let e = manage::resolve(&code_at(&server), &Lookup::Code("K7M2QX9TD".into()), &fast_limits(), Duration::from_secs(5)).unwrap_err(); assert!(e.contains("hosted by an app"), "{e}"); // Junk in place of a code is refused the same way, and the session goes on. for junk in ["", "💥", &"Z".repeat(4000)] { - assert!(manage::resolve(&server, junk, &fast_limits(), Duration::from_secs(5)).is_err()); + assert!(manage::resolve(&code_at(&server), &Lookup::Code(junk.into()), &fast_limits(), Duration::from_secs(5)).is_err()); } assert_eq!(host.live_summary()["status"], "live"); } @@ -268,7 +273,7 @@ impl Socket for Script { fn driver(t0: web_time::Instant) -> (Driver