Merge pull request #1678 from patlux/team-directory

M9: a team list on figcraft-server, shown in Home ▸ Online, with links that carry no secret (part of #742)
This commit is contained in:
BFlat
2026-10-31 00:08:34 +07:00
committed by GitHub
39 changed files with 1179 additions and 88 deletions
+3 -2
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -44,7 +44,8 @@ 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_log_mb,
max_plugins, max_plugin_mb, max_plugins_mb; for a public server: open_publish,
max_plugins, max_plugin_mb, max_plugins_mb, team_files, web_url;
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
+35
View File
@@ -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<Option<Access>, 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<String, String> {
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<Option<IpAddr>, 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,
+2
View File
@@ -117,4 +117,6 @@ mod server_tests;
#[cfg(test)]
mod store_tests;
#[cfg(test)]
mod team_tests;
#[cfg(test)]
mod test_util;
+27 -3
View File
@@ -7,7 +7,8 @@
//! <data>/server.key the server key: managing files (publish, links, removal)
//! <data>/files/<id>/meta.json name, created, updated, sequence number
//! <data>/files/<id>/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`)
//! <data>/files/<id>/snapshot.bin the session state at the last compaction
//! <data>/files/<id>/log.bin what changed since (see `disk`)
//! <data>/files/<id>/library-<version>.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<Access>,
/// 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<Sealed>,
/// A team link's token: the server made the link for its team list.
#[serde(default, skip_serializing_if = "Option::is_none")]
team: Option<TeamToken>,
}
/// 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 {
@@ -425,6 +448,7 @@ impl Store {
pub fn open(root: &Path, cfg: StoreConfig) -> Result<Store, String> {
let mut store = Self::try_open(root, cfg)?.ok_or_else(|| format!("another figcraft-server (or a command) is using {}", root.display()))?;
store.sweep();
store.drop_stale_team_links();
Ok(store)
}
@@ -630,5 +654,5 @@ fn write_json<T: Serialize + ?Sized>(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))
}
+36 -8
View File
@@ -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<Admission, String> {
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 }
}
}
}
@@ -266,6 +290,10 @@ impl Backend for Store {
self.resolve_code(ip, code)
}
fn resolve_team(&mut self, _ip: IpAddr, file: &str) -> Result<String, String> {
self.team_link(file).map(|l| l.token)
}
fn closing(&self) -> Closing {
Closing::Restarting
}
+119
View File
@@ -0,0 +1,119 @@
//! 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<OpenFile>) -> AdminReply {
let Some(access) = self.cfg.hosting.team else { return AdminReply::Failed { reason: NO_TEAM.into() } };
let mut files: Vec<TeamFile> =
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<NewLink, String> {
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);
}
// Made before the list changes: a failure leaves the links as they were.
let (mut link, token) = new_link(access, &label(access), now())?;
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"));
}
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 })
}
/// At startup: the team links the list doesn't hand out now (it is off, or gives another
/// access) are removed for good, so setting `team_files` back doesn't bring them back. Nobody
/// keeps a team link's token for later (a team list link names none), so nothing is lost: the
/// next join makes a new one. One that can't be removed stays refused at the door, and the
/// operator is told.
pub(super) fn drop_stale_team_links(&mut self) {
let team = self.cfg.hosting.team;
let stale = |l: &super::Link| l.team.is_some() && team != Some(l.access);
let files: Vec<String> = self.files.iter().filter(|(_, e)| e.links.iter().any(stale)).map(|(id, _)| id.clone()).collect();
for file in files {
let path = self.dir(&file).join(LINKS);
let Some(entry) = self.files.get_mut(&file) else { continue };
entry.links.retain(|l| !stale(l));
if let Err(e) = write_json(&path, &entry.links) {
self.note(format!(
"file {file}: its old team links couldn't be removed ({e}); they are refused, but would work again if team_files were set back to their access"
));
}
}
}
/// `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<String> {
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<String> = 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<Access>, entry: &Entry, at: usize) -> bool {
entry.links.get(at).is_some_and(|l| l.team.is_none() || (team == Some(l.access) && shared(entry)))
}
+262
View File
@@ -0,0 +1,262 @@
//! 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<TeamFile>) {
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<String, String> {
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, also from the file's links.
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]));
drop(b);
drop(server);
assert!(!std::fs::read_to_string(dir.path().join("files").join(&file).join("links.json")).unwrap().contains(r#""team""#));
// On again with the old access: the old links stay refused; the next join gets a new one.
let server = crate::start(&team(dir.path(), "view")).unwrap();
let mut b = join(&invite(server.addr(), &file, &view), "Bob");
pump(&mut [&mut b], "the old view team link still refused", |s| refused(s[0]));
assert_ne!(team_link(server.addr(), &file).unwrap(), view);
}
#[test]
fn a_team_link_never_handed_out_again_stays_refused_after_the_setting_goes_back() {
let dir = TempDir::new("team-back");
let mut server = crate::start(&team(dir.path(), "edit")).unwrap();
let (a, file) = share(server.addr(), "Homepage");
let edit = team_link(server.addr(), &file).unwrap();
drop(a);
server.stop();
// View only for a while, and nobody asks for the view team link…
drop(crate::start(&team(dir.path(), "view")).unwrap());
// …then edit again: the edit team link of before doesn't come back.
let server = crate::start(&team(dir.path(), "edit")).unwrap();
let mut b = join(&invite(server.addr(), &file, &edit), "Bob");
pump(&mut [&mut b], "the old edit team link refused", |s| refused(s[0]));
assert_ne!(team_link(server.addr(), &file).unwrap(), edit);
}
#[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"));
}
#[test]
fn the_apps_online_files_list_the_team_list_and_open_its_files() {
let dir = TempDir::new("team-app");
let server = crate::start(&team(dir.path(), "edit")).unwrap();
let addr = server.addr();
let (mut a, file) = share(addr, "Homepage");
// Bob has the same server in Preferences and nothing of his own online.
let mut b = session("Bob");
ex(&mut b, "pref.publishServer", json!({"url": format!("ws://{addr}/")}));
assert_eq!(ex(&mut b, "collab.files", json!({}))["team"], Value::Null, "nothing asked without refresh");
let list = ex(&mut b, "collab.files", json!({"refresh": true}));
let t = &list["team"];
assert_eq!(
(&t["state"], &t["web"], &t["files"][0]["file"], &t["files"][0]["name"]),
(&json!("listed"), &json!("https://figcraft.example.com/app/"), &json!(file), &json!("Homepage"))
);
let link = t["files"][0]["link"].as_str().unwrap().to_string();
assert_eq!(link, format!("figcraft://join?server=ws://{addr}/&file={file}&team=1"));
assert_eq!(
t["files"][0]["webLink"],
json!(format!(
"https://figcraft.example.com/app/#join=figcraft%3A%2F%2Fjoin%3Fserver%3Dws%3A%2F%2F{}%2F%26file%3D{file}%26team%3D1",
addr.to_string().replace(':', "%3A")
))
);
assert!(!list.to_string().contains("token"), "no team link token in the result: {list}");
ex(&mut b, "collab.join", json!({"link": link}));
pump(&mut [&mut a, &mut b], "Bob in from the list", |s| live(s[1]) && s[0].live().peers.len() == 1);
// Another server in Preferences: its list isn't this one.
ex(&mut b, "pref.publishServer", json!({"url": "wss://elsewhere.example/"}));
assert_eq!(ex(&mut b, "collab.files", json!({}))["team"], Value::Null);
// A server without a team list says so.
let dir = TempDir::new("team-app-off");
let off = crate::start(&Config { open_publish: true, ..config(dir.path()) }).unwrap();
ex(&mut b, "pref.publishServer", json!({"url": format!("ws://{}/", off.addr())}));
assert_eq!(ex(&mut b, "collab.files", json!({"refresh": true}))["team"]["state"], "none");
}
+12 -1
View File
@@ -1,4 +1,5 @@
//! The page's URL: the invite link its fragment names, and the flags its query sets.
//! The page's URL: the invite link its fragment names, and the flags its query sets; and the server
//! the page names for itself.
/// The invite link in `#join=<link>` (`fragment` is `location.hash`, with or without its `#`), still
/// escaped. The fragment, unlike the query, never leaves the browser, so the link's secret stays out
@@ -14,6 +15,16 @@ pub fn join_link(fragment: &str) -> Option<&str> {
fragment.get(start + "join=".len()..).filter(|link| !link.is_empty() && link.len() <= MAX)
}
/// The server a page names for itself in `<meta name="figcraft-server" content="wss://…/">` (a
/// host serving the browser build beside its own `figcraft-server`): the page's default for online
/// sharing, join codes and the team list. Only a `ws://` or `wss://` address without a query (no
/// key can ride in it), at most 512 characters; anything else is ignored.
pub fn page_server(content: &str) -> Option<String> {
let c = content.trim();
let scheme = c.starts_with("wss://") || c.starts_with("ws://");
(scheme && c.len() <= 512 && !c.contains(|ch: char| ch.is_whitespace() || ch.is_control() || ch == '?' || ch == '#')).then(|| c.to_string())
}
/// Whether `search` (`location.search`, with or without its `?`) has a `control` entry (`?control`,
/// or `?control=…` with any value): the page's control channel, `window.figcraft`, is installed only
/// when it is asked for.
+13 -2
View File
@@ -1,6 +1,6 @@
//! `#join=` and `?control` in the page's URL.
//! `#join=` and `?control` in the page's URL, and the server the page names for itself.
use crate::query::{control, join_link};
use crate::query::{control, join_link, page_server};
#[test]
fn join_takes_the_rest_of_the_fragment() {
@@ -38,3 +38,14 @@ fn control_is_opt_in() {
assert!(!control("?webgl"));
assert!(!control("?nocontrol&controls"), "only the whole entry name");
}
#[test]
fn a_page_names_its_own_server_without_a_key() {
assert_eq!(page_server(" wss://figcraft.example/ ").as_deref(), Some("wss://figcraft.example/"));
assert_eq!(page_server("ws://127.0.0.1:47900/").as_deref(), Some("ws://127.0.0.1:47900/"));
for bad in
["", "https://figcraft.example/", "wss://figcraft.example/?key=secret", "wss://a b/", "wss://x/#y", &format!("wss://{}/", "a".repeat(600))]
{
assert_eq!(page_server(bad), None, "{bad:?} is ignored");
}
}
+10
View File
@@ -50,6 +50,10 @@ pub fn start() {
}
let reduce_motion = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten());
let mut session = Session::new();
// The host names its own server: the default for sharing, codes and the team list.
if let Some(server) = page_meta("figcraft-server").as_deref().and_then(crate::query::page_server) {
session.prefs.publish_server = server;
}
crate::kept::restore_name(&mut session, local_storage().and_then(|s| s.get_item(NAME_KEY).ok().flatten()));
let recovered = restore_unsaved(&mut session);
// Live sessions: the browser joins sessions and publishes files to servers;
@@ -124,6 +128,12 @@ fn local_storage() -> Option<web_sys::Storage> {
web_sys::window().and_then(|w| w.local_storage().ok().flatten())
}
/// The `content` of the page's `<meta name="…">`, if it has one.
fn page_meta(name: &str) -> Option<String> {
let document = web_sys::window()?.document()?;
document.query_selector(&format!("meta[name=\"{name}\"]")).ok().flatten()?.get_attribute("content")
}
fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
+62 -7
View File
@@ -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<Script>, Inbox) {
let (invite, found) = Invite::parse_join("figcraft://join?server=ws://127.0.0.1:47900/&code=K7M2QX9TD").unwrap();
let inbox = Inbox::new(Arc::new(|| {}));
let cfg = GuestConfig { code: found, ..GuestConfig::new(invite, "Ada".into()) };
let cfg = GuestConfig { lookup: found, ..GuestConfig::new(invite, "Ada".into()) };
(Driver::start(Script::default(), cfg, inbox.clone(), t0), inbox)
}
@@ -324,3 +329,53 @@ fn the_browser_shows_why_a_code_didnt_join() {
d.leave(t0);
assert!(d.done() && failure(&inbox).is_empty());
}
#[test]
fn links_can_name_a_file_of_a_servers_team_list() {
let link = "figcraft://join?server=wss://figcraft.example/team/&file=0123456789abcdef&team=1";
let (invite, found) = Invite::parse_join(link).unwrap();
assert_eq!(
(invite.server.url(), invite.file.as_str(), invite.token.as_str(), found),
("wss://figcraft.example:443/team/".to_string(), "0123456789abcdef", "", Some(Lookup::Team))
);
assert_eq!(Invite::parse_join(&invite.team_link()).unwrap(), (invite.clone(), Some(Lookup::Team)));
let plain = Invite::new("127.0.0.1:47900", "abc", "");
assert_eq!(plain.team_link(), "figcraft://join?host=127.0.0.1:47900&file=abc&team=1");
// It names no token, so it isn't a link to join with directly; it needs a file.
assert!(Invite::parse(link).is_err_and(|e| e.contains("team list")));
assert!(Invite::parse_join("figcraft://join?server=wss://figcraft.example/&team=1").is_err());
assert!(Invite::parse_join("figcraft://join?server=wss://figcraft.example/&file=../x&team=1").is_err());
}
/// The browser's driver joining a file of a server's team list.
fn team_driver(t0: web_time::Instant) -> (Driver<Script>, Inbox) {
let (invite, found) = Invite::parse_join("figcraft://join?server=ws://127.0.0.1:47900/&file=0123456789abcdef&team=1").unwrap();
let inbox = Inbox::new(Arc::new(|| {}));
let cfg = GuestConfig { lookup: found, ..GuestConfig::new(invite, "Ada".into()) };
(Driver::start(Script::default(), cfg, inbox.clone(), t0), inbox)
}
#[test]
fn the_browser_asks_the_team_list_for_the_link_then_joins_the_file() {
let t0 = web_time::Instant::now();
let (mut d, inbox) = team_driver(t0);
d.opened(t0);
let [ClientMsg::ResolveTeam(ask)] = d.sock.sent.as_slice() else { panic!("asked {:?}", d.sock.sent) };
assert_eq!((ask.protocol, ask.file.as_str()), (PROTOCOL, "0123456789abcdef"));
let answer = ServerMsg::Resolved { file: "0123456789abcdef".into(), token: "AbCdEfGhIjKlMnOpQrStUv".into() };
d.message(&message::encode(&answer).unwrap(), t0);
d.opened(t0);
let Some(ClientMsg::Hello(hello)) = d.sock.sent.last() else { panic!("sent {:?}", d.sock.sent) };
assert_eq!((hello.file.as_str(), hello.token.as_str()), ("0123456789abcdef", "AbCdEfGhIjKlMnOpQrStUv"));
assert!(failure(&inbox).is_empty());
// Another file in the answer than the one asked about isn't taken.
let (mut d, inbox) = team_driver(t0);
d.opened(t0);
d.message(&message::encode(&ServerMsg::Resolved { file: "fedcba9876543210".into(), token: "AbCdEfGhIjKlMnOpQrStUv".into() }).unwrap(), t0);
assert!(d.done() && failure(&inbox).contains("isn't a file"));
// A server from before team lists.
let (mut d, inbox) = team_driver(t0);
d.opened(t0);
d.message(&message::encode(&ServerMsg::Refused { reason: "that message couldn't be read".into() }).unwrap(), t0);
assert!(failure(&inbox).contains("has no team list"));
}
+8 -8
View File
@@ -16,7 +16,7 @@
//! `exchange.rs`), each waiting for its answers as long as a large upload may take; a publish then
//! joins the new file.
use figcraft_collab::message::{self, ClientMsg, PROTOCOL, Presence, Resolve, ServerMsg};
use figcraft_collab::message::{self, ClientMsg, Presence, ServerMsg};
use figcraft_engine::live::{Event, Status};
use std::time::Duration;
// `std::time::Instant` on native builds; the browser's clock in the web build.
@@ -25,7 +25,7 @@ use web_time::Instant;
use crate::exchange::{self, Exchange, Then};
use crate::inbox::Inbox;
use crate::invite::Endpoint;
use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, too_large, unresolved};
use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, ask, closed, hello, resolved, take, too_large, unresolved};
use crate::limits::NetLimits;
use crate::requests::Publish;
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
@@ -188,9 +188,9 @@ impl<S: Socket> Driver<S> {
let Some(c) = self.conn.as_mut() else { return };
c.opened = true;
c.last_heard = now;
let hello = match (&self.asking, &self.cfg.code) {
let hello = match (&self.asking, &self.cfg.lookup) {
(Some((ex, _)), _) => ex.opening().clone(),
(None, Some(code)) => ClientMsg::Resolve(Resolve { protocol: PROTOCOL, client_version: message::client_version(), code: code.clone() }),
(None, Some(lookup)) => ask(&self.cfg.invite, lookup),
(None, None) => hello(&self.cfg, &self.resume),
};
if !self.write(&hello) {
@@ -225,8 +225,8 @@ impl<S: Socket> Driver<S> {
return self.end(too_large(&self.cfg.limits), now);
}
let taken = match message::decode::<ServerMsg>(bytes, self.cfg.limits.max_server_message) {
// The join code's answer: join the file it opens, on a new connection.
Ok(ServerMsg::Resolved { file, token }) if self.cfg.code.is_some() => {
// The join code's answer (or the team list's): join the file, on a new connection.
Ok(ServerMsg::Resolved { file, token }) if self.cfg.lookup.is_some() => {
return match resolved(&mut self.cfg, file, token) {
Ok(()) => {
self.sock.close();
@@ -382,8 +382,8 @@ impl<S: Socket> Driver<S> {
// Left while asking a server: what it was asked stays done (a file uploaded stays there),
// nothing follows.
self.asking = None;
// Still asking about the join code: whatever went wrong is final.
let outcome = if self.cfg.code.is_some() { unresolved(&self.cfg, outcome) } else { outcome };
// Still asking about the join code (or the team list's file): whatever went wrong is final.
let outcome = if self.cfg.lookup.is_some() { unresolved(&self.cfg, outcome) } else { outcome };
if self.conn.take().is_some() {
self.sock.close();
}
+4 -3
View File
@@ -56,11 +56,12 @@ impl Guest {
let (tx, rx) = channel();
let me = tx.clone();
let thread = std::thread::Builder::new().name("live-guest".into()).spawn(move || {
// A join code: the server says which file it opens, then the join goes on as with a link.
if let Some(code) = cfg.code.clone() {
// A join code (or a file of the team list): the server says which file and token, then
// the join goes on as with a link.
if let Some(lookup) = cfg.lookup.clone() {
inbox.push(Event::Status(Status::Connecting));
let asked =
manage::resolve(&cfg.invite.server, &code, &cfg.limits, cfg.connect_timeout).map_err(|e| match e.strip_prefix(manage::REFUSED) {
manage::resolve(&cfg.invite, &lookup, &cfg.limits, cfg.connect_timeout).map_err(|e| match e.strip_prefix(manage::REFUSED) {
Some(why) => Outcome::Refused(why.to_string()),
None => Outcome::Retry(e),
});
+23 -2
View File
@@ -32,7 +32,7 @@ use std::time::{Duration, Instant};
use figcraft_collab::message::{
self, Access, Admin, AdminReply, ClientMsg, Hello, HostInfo, LibraryReply, LibraryReq, Manage, OpenHosting, OpenLibrary, OpenPlugins, PROTOCOL,
PluginReply, PluginReq, Presence, Resolve, ServerMsg, reads_hosting,
PluginReply, PluginReq, Presence, Resolve, ResolveTeam, ServerMsg, reads_hosting,
};
use figcraft_collab::{Outgoing, Server};
use figcraft_engine::live::{Event, MAX_NAME, PEOPLE, sanitize};
@@ -227,6 +227,7 @@ impl<B: Backend> Hub<B> {
ClientMsg::OpenPlugins(o) => self.open_plugins(conn, &o, c),
ClientMsg::OpenHosting(o) => self.open_hosting(conn, &o, c),
ClientMsg::Resolve(r) => self.resolve(&r, &c),
ClientMsg::ResolveTeam(r) => self.resolve_team(&r, &c),
_ => refuse(&c.out, "say hello first"),
}
}
@@ -541,6 +542,26 @@ impl<B: Backend> Hub<B> {
}
}
/// A file of the team list: its id and the token of its team link, then the connection closes
/// (it was never taken in).
fn resolve_team(&mut self, r: &ResolveTeam, c: &Pending) {
if r.protocol != PROTOCOL {
return refuse(&c.out, &unsupported(r.protocol));
}
match self.backend.resolve_team(c.ip, &r.file) {
Ok(token) => {
if let Some(b) = encode(&ServerMsg::Resolved { file: r.file.clone(), token }) {
let _ = c.out.tx.try_send(Out::Bytes(b, None));
}
let _ = c.out.tx.try_send(Out::Close(CloseCode::Normal, String::new()));
}
Err(why) => {
log::info!("event=team_refused ip={} reason={why:?}", c.ip);
refuse(&c.out, &why);
}
}
}
fn admin(&mut self, conn: u64, ip: IpAddr, req: Admin) {
let rooms = &self.rooms;
let open = |file: &str| rooms.get(file).map(Room::info);
@@ -554,7 +575,7 @@ impl<B: Backend> Hub<B> {
let comments =
self.conns.get(&conn).is_some_and(|c| matches!(c.kind, Kind::Managing { comments: true } | Kind::Hosting { comments: true, .. }));
self.answer(conn, &ServerMsg::Admin(Box::new(handled.reply.for_reader(comments))));
if let Some((file, link)) = handled.revoked {
for (file, link) in handled.revoked {
let with = |joined: bool| -> Vec<u64> {
let uses = |c: &Conn| match &c.kind {
Kind::Joined { file: f, link: Some(l), .. } => joined && *f == file && *l == link,
+9 -3
View File
@@ -5,8 +5,8 @@ use super::{Access, Admin, AdminReply, ClientMsg, HostInfo, IpAddr, LibraryReply
#[derive(Clone, Debug, PartialEq)]
pub struct Handled {
pub reply: AdminReply,
/// A link was revoked: `(file, link)`; whoever joined with it is disconnected.
pub revoked: Option<(String, String)>,
/// Links that were revoked: `(file, link)`; whoever joined with one is disconnected.
pub revoked: Vec<(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.
@@ -15,7 +15,7 @@ pub struct Handled {
impl From<AdminReply> for Handled {
fn from(reply: AdminReply) -> Self {
Self { reply, revoked: None, removed: None, blocked: None }
Self { reply, revoked: Vec::new(), removed: None, blocked: None }
}
}
@@ -134,6 +134,12 @@ pub trait Backend: Send + 'static {
Err("this session is hosted by an app: join it with its own code or invite link".into())
}
/// The token of the team link of `file` on the server's team list, asked from `ip`, or why not
/// (shown to them).
fn resolve_team(&mut self, _ip: IpAddr, _file: &str) -> Result<String, String> {
Err("this session is hosted by an app: it has no team list".into())
}
/// What everyone is told when the hub stops.
fn closing(&self) -> Closing;
}
+2 -1
View File
@@ -270,7 +270,8 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimit
| ClientMsg::OpenLibrary(_)
| ClientMsg::OpenPlugins(_)
| ClientMsg::OpenHosting(_)
| ClientMsg::Resolve(_)),
| ClientMsg::Resolve(_)
| ClientMsg::ResolveTeam(_)),
bytes,
))) => {
// Sessions and the operator may use the last connections; one-shot requests
+34 -5
View File
@@ -14,6 +14,11 @@
//! A join by code names the server to ask and the code:
//! `figcraft://join?server=<ws(s)://…>&code=<code>` ([`Invite::parse_join`]); the server says which
//! file and token the code stands for.
//!
//! A file of a server's team list is named by the server and the file, without a token:
//! `figcraft://join?server=<ws(s)://…>&file=<id>&team=1` ([`Invite::team_link`]); the server hands
//! out the file's team link to whoever asks, while the file is shared. Such a link carries no
//! secret: whoever can reach the server may join with it.
/// Where a session or a server listens: `ws://host:port/path` or `wss://…` (TLS).
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -36,6 +41,15 @@ pub struct Invite {
pub token: String,
}
/// What to ask an invite's server before joining: its answer gives the file and token.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Lookup {
/// The file a join code opens.
Code(String),
/// The team link of the invite's file (on the server's team list).
Team,
}
/// Longest link accepted (anything longer isn't one of ours).
const MAX_LINK: usize = 2048;
@@ -121,6 +135,16 @@ impl Invite {
}
}
/// The `figcraft://join?…&team=1` link to this invite's file on its server's team list (no
/// token: the server gives the team link to whoever asks).
pub fn team_link(&self) -> String {
if self.server.is_plain() {
format!("figcraft://join?host={}&file={}&team=1", self.server.host, self.file)
} else {
format!("figcraft://join?server={}&file={}&team=1", self.server.url(), self.file)
}
}
/// The same session as a WebSocket URL.
pub fn url(&self) -> String {
format!("{}?file={}&token={}", self.server.url(), self.file, self.token)
@@ -140,13 +164,15 @@ impl Invite {
pub fn parse(link: &str) -> Result<Invite, String> {
match Self::parse_join(link)? {
(invite, None) => Ok(invite),
(_, Some(_)) => Err("that names a join code, not a file: join with it first".into()),
(_, Some(Lookup::Code(_))) => Err("that names a join code, not a file: join with it first".into()),
(_, Some(Lookup::Team)) => Err("that names a file of a server's team list, not its link: join with it first".into()),
}
}
/// Read what a join starts from: an invite link, or a server with a join code to ask it about
/// (then the invite names only the server, until the server says what the code opens).
pub fn parse_join(link: &str) -> Result<(Invite, Option<String>), String> {
/// Read what a join starts from: an invite link, a server with a join code to ask it about
/// (then the invite names only the server, until the server says what the code opens), or a
/// file of a server's team list (then the invite has no token until the server gives one).
pub fn parse_join(link: &str) -> Result<(Invite, Option<Lookup>), String> {
let link = clean(link);
if link.len() > MAX_LINK {
return Err(NOT.into());
@@ -174,9 +200,12 @@ impl Invite {
if let Some(code) = get("code") {
let code = figcraft_collab::code::normalize(&code).ok().filter(|c| c.len() == figcraft_collab::code::ONLINE_LEN);
let code = code.ok_or("the link's join code isn't one")?;
return Ok((Invite { server, file: String::new(), token: String::new() }, Some(code)));
return Ok((Invite { server, file: String::new(), token: String::new() }, Some(Lookup::Code(code))));
}
let file = get("file").filter(|f| valid_id(f, 1, 64)).ok_or("the invite link has no valid file id")?;
if get("team").is_some() {
return Ok((Invite { server, file, token: String::new() }, Some(Lookup::Team)));
}
let token = get("token").filter(|t| valid_id(t, 16, 128)).ok_or("the invite link has no valid token")?;
Ok((Invite { server, file, token }, None))
}
+24 -10
View File
@@ -6,11 +6,11 @@ use std::sync::Mutex;
use std::time::Duration;
use figcraft_collab::Client;
use figcraft_collab::message::{ClientMsg, Peer, Resume, ServerMsg};
use figcraft_collab::message::{self, ClientMsg, PROTOCOL, Peer, Resolve, ResolveTeam, Resume, ServerMsg};
use figcraft_engine::live::{Event, MAX_PEERS, Role, SessionInfo, Status, TOO_LARGE_TO_JOIN};
use crate::inbox::Inbox;
use crate::invite::Invite;
use crate::invite::{Invite, Lookup};
use crate::limits::NetLimits;
/// What the host tells everyone when it stops (the WebSocket close reason).
@@ -29,9 +29,9 @@ pub(crate) const CLOSE_RESTART: u16 = 1012;
#[derive(Clone, Debug)]
pub struct GuestConfig {
pub invite: Invite,
/// A join code to ask `invite.server` about first: its answer fills in the invite's file and
/// token ([`resolved`]).
pub code: Option<String>,
/// What to ask `invite.server` first (a join code, or the team link of `invite.file`): its
/// answer fills in the invite's file and token ([`resolved`]).
pub lookup: Option<Lookup>,
/// Your name (empty: the host picks one).
pub name: String,
pub limits: NetLimits,
@@ -50,7 +50,7 @@ impl GuestConfig {
pub fn new(invite: Invite, name: String) -> Self {
Self {
invite,
code: None,
lookup: None,
name,
limits: NetLimits::default(),
connect_timeout: Duration::from_secs(5),
@@ -85,7 +85,10 @@ pub(crate) enum Outcome {
/// typed it (the server's own for a code it doesn't know).
pub(crate) fn unresolved(cfg: &GuestConfig, outcome: Outcome) -> Outcome {
Outcome::Failed(match outcome {
// A server from before join codes can't read the question.
// A server from before join codes (or team lists) can't read the question.
Outcome::Refused(why) if why.contains("couldn't be read") && cfg.lookup == Some(Lookup::Team) => {
format!("{} has no team list (it runs an older version): ask for the invite link instead", cfg.invite.server.display())
}
Outcome::Refused(why) if why.contains("couldn't be read") => {
format!("{} doesn't know join codes yet (it runs an older version): ask for the invite link instead", cfg.invite.server.display())
}
@@ -101,12 +104,23 @@ pub(crate) fn too_large(limits: &NetLimits) -> Outcome {
Outcome::Failed(TOO_LARGE_TO_JOIN.replace("{mb}", &(limits.max_server_message >> 20).to_string()))
}
/// The server's answer to the join code: the invite is whole now.
/// The first message that asks `invite`'s server what `lookup` stands for (answered with
/// [`ServerMsg::Resolved`]).
pub(crate) fn ask(invite: &Invite, lookup: &Lookup) -> ClientMsg {
let client_version = message::client_version();
match lookup {
Lookup::Code(code) => ClientMsg::Resolve(Resolve { protocol: PROTOCOL, client_version, code: code.clone() }),
Lookup::Team => ClientMsg::ResolveTeam(ResolveTeam { protocol: PROTOCOL, client_version, file: invite.file.clone() }),
}
}
/// The server's answer to the join code (or about the team list's file): the invite is whole now.
pub(crate) fn resolved(cfg: &mut GuestConfig, file: String, token: String) -> Result<(), Outcome> {
if !crate::invite::valid_id(&file, 1, 64) || !crate::invite::valid_id(&token, 16, 128) {
let asked_file = cfg.lookup != Some(Lookup::Team) || file == cfg.invite.file;
if !crate::invite::valid_id(&file, 1, 64) || !crate::invite::valid_id(&token, 16, 128) || !asked_file {
return Err(Outcome::Failed("the server answered the join code with something that isn't a file".into()));
}
cfg.code = None;
cfg.lookup = None;
(cfg.invite.file, cfg.invite.token) = (file, token);
Ok(())
}
+1 -1
View File
@@ -48,7 +48,7 @@ pub mod token;
mod web;
pub use inbox::{Inbox, Waker};
pub use invite::{Endpoint, Invite};
pub use invite::{Endpoint, Invite, Lookup};
pub use limits::NetLimits;
#[cfg(not(target_arch = "wasm32"))]
pub use link::{DEFAULT_PORT, NetLink, lan_ip};
+6 -6
View File
@@ -15,7 +15,7 @@ use figcraft_engine::live::{
use crate::guest::{Guest, GuestConfig, Publish, upload_file};
use crate::host::{Host, HostConfig};
use crate::inbox::{Inbox, Waker};
use crate::invite::{Endpoint, Invite};
use crate::invite::{Endpoint, Invite, Lookup};
use crate::manage;
use crate::net::NetLimits;
use crate::requests;
@@ -74,9 +74,9 @@ impl NetLink {
self
}
/// How this link joins `invite` (after asking its server about `code`, if there is one).
fn guest_config(&self, invite: Invite, code: Option<String>, name: &str) -> GuestConfig {
let mut cfg = (self.guest)(GuestConfig { code, ..GuestConfig::new(invite, name.to_string()) });
/// How this link joins `invite` (after asking its server about `lookup`, if there is one).
fn guest_config(&self, invite: Invite, lookup: Option<Lookup>, name: &str) -> GuestConfig {
let mut cfg = (self.guest)(GuestConfig { lookup, ..GuestConfig::new(invite, name.to_string()) });
cfg.limits = self.limits;
cfg
}
@@ -166,8 +166,8 @@ impl LiveLink for NetLink {
}
fn join_resuming(&mut self, session: SessionId, link: &str, name: &str, resume: Option<Resume>) -> Result<(), String> {
let (invite, code) = Invite::parse_join(link)?;
let cfg = GuestConfig { resume, ..self.guest_config(invite, code, name) };
let (invite, lookup) = Invite::parse_join(link)?;
let cfg = GuestConfig { resume, ..self.guest_config(invite, lookup, name) };
let inbox = self.sessions.inbox();
let guest = Guest::start(cfg, inbox.clone()).map_err(|e| format!("can't start the connection: {e}"))?;
self.started(session, Active::Guest(guest), inbox);
+11 -9
View File
@@ -8,12 +8,13 @@ use std::time::Duration;
use figcraft_collab::message::{
self, Admin, AdminReply, ClientMsg, HostInfo, LibraryReply, LibraryReq, Manage, OpenHosting, OpenLibrary, OpenPlugins, PROTOCOL, PluginReply,
PluginReq, Resolve, ServerMsg,
PluginReq, ServerMsg,
};
use figcraft_engine::live::Shared;
use tungstenite::{Bytes, Message, WebSocket};
use crate::invite::{Endpoint, Invite};
use crate::invite::{Endpoint, Invite, Lookup};
use crate::join::ask;
use crate::net::{Duplex, NetLimits, config, open};
pub(crate) use crate::requests::REFUSED;
use crate::requests::{code_requests, comment_request, hosting_error, link_requests, made_comment, made_links, shared};
@@ -78,14 +79,15 @@ pub fn hosting(
Ok((info, replies))
}
/// Ask the server at `server` which file join code `code` opens: the file's id and its link's
/// token. Fails with the server's own words when it knows no such code (or the address tried too
/// many: the error starts with [`REFUSED`]); a server older than join codes can't read the
/// Ask `invite`'s server what `lookup` stands for: which file a join code opens, or the team link
/// of `invite.file`; the file's id and the link's token. Fails with the server's own words when it
/// knows no such code or keeps no such file on its team list (or the address tried too many: the
/// error starts with [`REFUSED`]); a server older than join codes (or team lists) can't read the
/// question ("couldn't be read").
pub fn resolve(server: &Endpoint, code: &str, limits: &NetLimits, timeout: Duration) -> Result<(String, String), String> {
let ask = Resolve { protocol: PROTOCOL, client_version: message::client_version(), code: code.to_string() };
let mut ws = connect(server, limits, timeout)?;
send(&mut ws, &ClientMsg::Resolve(ask))?;
pub fn resolve(invite: &Invite, lookup: &Lookup, limits: &NetLimits, timeout: Duration) -> Result<(String, String), String> {
let ask = ask(invite, lookup);
let mut ws = connect(&invite.server, limits, timeout)?;
send(&mut ws, &ask)?;
let answer = reply(&mut ws, limits, |m| match m {
ServerMsg::Resolved { file, token } => Some((file, token)),
_ => None,
+2 -2
View File
@@ -202,11 +202,11 @@ impl LiveLink for NetLink {
}
fn join(&mut self, session: SessionId, link: &str, name: &str) -> Result<(), String> {
let (invite, code) = Invite::parse_join(link)?;
let (invite, lookup) = Invite::parse_join(link)?;
reachable(&invite.server)?;
let (id, sock) = socket();
let inbox = self.sessions.inbox();
let cfg = GuestConfig { code, ..GuestConfig::new(invite, name.to_string()) };
let cfg = GuestConfig { lookup, ..GuestConfig::new(invite, name.to_string()) };
run(id, Driver::start(sock, cfg, inbox.clone(), Instant::now()));
self.started(session, id, inbox);
Ok(())
+39 -2
View File
@@ -370,6 +370,22 @@ pub enum Admin {
/// can't join the file again while its session lasts. With the server key, or on an open
/// hosting connection with `file`'s owner token. Servers older than this request can't read it.
RemovePeer { file: String, client: u32 },
/// On an open hosting connection, or with the server key: the files shared from this server now
/// (a link of theirs has a join code), when its operator turned on the team list:
/// [`AdminReply::Team`]; they are joined with [`ResolveTeam`]. Servers older than this request
/// can't read it.
Team,
}
/// The most files an [`AdminReply::Team`] lists (the most recently changed ones).
pub const MAX_TEAM: usize = 500;
/// A file of a server's team list ([`AdminReply::Team`]).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TeamFile {
pub file: FileInfo,
/// What its team link lets people do.
pub access: Access,
}
/// The most files one [`Admin::Owned`] asks about.
@@ -433,6 +449,7 @@ impl AdminReply {
match &mut self {
AdminReply::Links(links) => links.iter_mut().for_each(|l| l.access = l.access.for_reader(reads_comments)),
AdminReply::Link(l) => l.link.access = l.link.access.for_reader(reads_comments),
AdminReply::Team { files, .. } => files.iter_mut().for_each(|f| f.access = f.access.for_reader(reads_comments)),
_ => {}
}
self
@@ -478,6 +495,12 @@ pub enum AdminReply {
/// The answer to [`Admin::Owned`], one per pair asked about, in order. `None`: no file of that
/// id is managed by that token here (removed, expired, or never there).
Owned(Vec<Option<OwnedFile>>),
/// The answer to [`Admin::Team`]: where the server's browser build is served (`web`, empty when
/// the operator didn't say) and the files shared now, most recently changed first.
Team {
web: String,
files: Vec<TeamFile>,
},
}
/// A file with what its operator moderates by.
@@ -533,6 +556,17 @@ pub struct Resolve {
pub code: String,
}
/// Asks a server for the link of a file on its team list (the first message on a connection,
/// instead of [`Hello`]). Answered with [`ServerMsg::Resolved`], then the connection closes; a
/// server without a team list, or a file that isn't shared now, is `Refused`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ResolveTeam {
pub protocol: u32,
pub client_version: String,
/// The file's id.
pub file: 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)]
@@ -761,6 +795,9 @@ pub enum ClientMsg {
},
/// Ask which file a join code opens (the first message on a connection, instead of `Hello`).
Resolve(Resolve),
/// Ask for the link of a file on the server's team list (the first message on a connection,
/// instead of `Hello`).
ResolveTeam(ResolveTeam),
}
/// Server → client.
@@ -826,8 +863,8 @@ pub enum ServerMsg {
Reported {
error: Option<String>,
},
/// The answer to [`ClientMsg::Resolve`]: the file the code opens and its link's token (what a
/// `Hello` joins with).
/// The answer to [`ClientMsg::Resolve`] and [`ClientMsg::ResolveTeam`]: the file and its link's
/// token (what a `Hello` joins with).
Resolved {
file: String,
token: String,
+1
View File
@@ -469,6 +469,7 @@ impl Server {
| ClientMsg::Plugins(_)
| ClientMsg::OpenHosting(_)
| ClientMsg::Resolve(_)
| ClientMsg::ResolveTeam(_)
| ClientMsg::Report { .. } => {
vec![Outgoing::To(from, ServerMsg::Refused { reason: "managing files and libraries needs a connection of its own".into() })]
}
+1
View File
@@ -84,6 +84,7 @@ pub use sharing::collab;
pub use sharing::invite_links;
pub use sharing::live;
pub use sharing::online;
pub use sharing::team;
pub use batch::{BatchScope, BatchStep};
pub use cmd::{CommandSpec, all_commands, find_command};
+1
View File
@@ -5,3 +5,4 @@ pub mod collab;
pub mod invite_links;
pub mod live;
pub mod online;
pub mod team;
+3
View File
@@ -201,6 +201,9 @@ pub struct Lives {
pub unsaved: bool,
/// Servers that couldn't say what became of your files at the last `collab.files {refresh}`.
pub servers: crate::online::Problems,
/// The team list of the server in Preferences, as it answered at the last `collab.files
/// {refresh}` ([`crate::team`]).
pub team: Option<crate::team::Team>,
/// Recovery copies of your online files waiting for their file to arrive from its server
/// (`collab.recoverFile`).
pub(crate) recovering: Vec<crate::online::Recovering>,
+6 -3
View File
@@ -446,7 +446,7 @@ impl Session {
ServerProblem::Unsupported => json!({"server": server, "host": server_host(server), "state": "unsupported", "reason": Value::Null}),
})
.collect();
json!({"files": files, "problems": servers, "max": MAX_ONLINE_FILES})
json!({"files": files, "problems": servers, "max": MAX_ONLINE_FILES, "team": self.team_json()})
}
}
@@ -454,7 +454,10 @@ fn files(s: &mut Session, p: &Value) -> Result<Value> {
match p.get("refresh") {
None => {}
Some(v) if v.as_bool() == Some(false) => {}
Some(v) if v.as_bool() == Some(true) => s.online_refresh()?,
Some(v) if v.as_bool() == Some(true) => {
s.online_refresh()?;
s.team_refresh()?;
}
Some(_) => return Err(bad("collab.files", "`refresh` must be true or false")),
}
Ok(s.online_json())
@@ -624,7 +627,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"collab.files",
"Your online files",
"{refresh? (true: ask each server once what became of its files; default false: no network)} → {files: [{server, host, file (its id), name, created, opened (seconds since 1970), shared (others get in with a code now), code, viewCode, commentCode (the join codes, while shared; commentCode null from a server older than comment links), state: unknown | online | gone (the server no longer has it: removed or expired), expires, session, document (the session and open document it is in now, or null), hasOwnerToken: true}], problems: [{server, host, state: offline | unsupported (a server older than this request), reason}], max}: the files you published or shared without a server key, most recently opened first, as recorded on this computer with the preferences. Owner tokens and your own links are never in the result",
"{refresh? (true: ask each server once what became of its files, and the server in Preferences for its team list; default false: no network)} → {files: [{server, host, file (its id), name, created, opened (seconds since 1970), shared (others get in with a code now), code, viewCode, commentCode (the join codes, while shared; commentCode null from a server older than comment links), state: unknown | online | gone (the server no longer has it: removed or expired), expires, session, document (the session and open document it is in now, or null), hasOwnerToken: true}], problems: [{server, host, state: offline | unsupported (a server older than this request), reason}], max, team: {server, host, state: listed | none (the server keeps no team list) | offline, reason, web (where its browser build is, or null), files: [{file, name, updated, people, access, link (figcraft://join?server=…&file=…&team=1: joins it, no secret in it), webLink (the same in the browser build, or null)}]} or null (not asked yet)}: the files you published or shared without a server key, most recently opened first, as recorded on this computer with the preferences, and the files shared now on the server in Preferences when its operator keeps a team list (everyone who reaches it may open them; `collab.join {link}` does). Owner tokens and your own links are never in the result",
false,
files
),
+149
View File
@@ -0,0 +1,149 @@
//! The team list of the server in Preferences (milestone M9): the files shared from it now, which
//! everyone who reaches the server may open, when its operator turned the list on (`team_files`).
//! `collab.files {refresh: true}` asks for it beside your own files, and Home ▸ Online shows it.
//!
//! A file of the list opens by its team list link, `figcraft://join?server=…&file=…&team=1`: the
//! server hands out the file's team link while the file is shared, so the link carries no secret
//! and can go anywhere people who reach the server read (a ticket, a chat). With the address the
//! server gives for its browser build, the same file opens in a browser: `<web>#join=<link>`.
use figcraft_collab::message::{Access, Admin, AdminReply, MAX_TEAM};
use serde_json::{Value, json};
use crate::live::{DEFAULT_SERVER, NetReply, NetRequest};
use crate::online::{plain_server, same_server, server_host};
use crate::{Result, Session};
/// A file of a team list.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TeamFile {
/// Its id on the server, and its name there.
pub file: String,
pub name: String,
/// When it last changed (seconds since 1970) and how many people are in it now.
pub updated: u64,
pub people: u32,
/// What its team link lets people do.
pub access: Access,
}
/// What a server said about its team list at the last refresh (this run only).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TeamList {
/// The files shared now, most recently changed first, and where the server's browser build is
/// (empty: it didn't say).
Files { web: String, files: Vec<TeamFile> },
/// The server keeps no team list (or is older than team lists).
None,
/// It couldn't be reached, or refused (why).
Offline(String),
}
/// The team list of the server at `server`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Team {
pub server: String,
pub list: TeamList,
}
/// The link that opens `file` of the team list on `server` (no secret in it).
pub fn team_link(server: &str, file: &str) -> String {
format!("figcraft://join?server={}&file={file}&team=1", plain_server(server))
}
/// The same file in the server's browser build at `web`: the link rides in the address's fragment,
/// which the browser never sends to the web server.
pub fn web_link(web: &str, server: &str, file: &str) -> String {
let escaped: String = team_link(server, file)
.bytes()
.map(|b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => char::from(b).to_string(),
_ => format!("%{b:02X}"),
})
.collect();
format!("{web}#join={escaped}")
}
/// A file id as servers make them (and nothing a link could be bent with).
fn valid_id(s: &str) -> bool {
(1..=64).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
/// A browser build's address as the server gave it, when it is one.
fn valid_web(s: &str) -> bool {
(s.starts_with("https://") || s.starts_with("http://"))
&& s.len() <= 500
&& !s.contains(|c: char| c.is_whitespace() || c.is_control() || c == '#')
}
/// The team list in a server's answer, made safe to show (anything may arrive).
fn listed(web: String, files: Vec<figcraft_collab::message::TeamFile>) -> TeamList {
let files = files
.into_iter()
.filter(|f| valid_id(&f.file.id))
.take(MAX_TEAM)
.map(|f| TeamFile {
name: f.file.name.trim().chars().filter(|c| !c.is_control()).take(256).collect(),
file: f.file.id,
updated: f.file.updated,
people: f.file.people,
access: f.access,
})
.collect();
TeamList::Files { web: if valid_web(&web) { web } else { String::new() }, files }
}
impl Session {
/// Ask the server in Preferences for its team list (none asked when online sharing is off,
/// without the network, or of the public server FigCraft ships with: it keeps none).
pub(crate) fn team_refresh(&mut self) -> Result<()> {
let server = plain_server(&self.prefs.publish_server);
let public = option_env!("FIGCRAFT_DEFAULT_SERVER").is_none() && same_server(&server, DEFAULT_SERVER);
if server.is_empty() || public || self.live.link().is_none() {
self.live.team = None;
return Ok(());
}
let req = NetRequest::Hosting { server: server.clone(), file: String::new(), owner: String::new(), req: Some(Admin::Team) };
let list = match self.net(req)?.0 {
NetReply::Hosting(Ok((_, Some(AdminReply::Team { web, files })))) => listed(web, files),
// No team list there; a server older than team lists can't read the question.
NetReply::Hosting(Ok((_, Some(AdminReply::Failed { .. })))) => TeamList::None,
NetReply::Hosting(Err(why)) if why.contains("couldn't be read") => TeamList::None,
NetReply::Hosting(Err(why)) => TeamList::Offline(why),
_ => TeamList::None,
};
self.live.team = Some(Team { server, list });
Ok(())
}
/// The team list of the server in Preferences, as asked last: `None` when it wasn't asked (or
/// Preferences name another server now).
pub fn team(&self) -> Option<&Team> {
self.live.team.as_ref().filter(|t| same_server(&t.server, &self.prefs.publish_server))
}
/// The team list as `collab.files` gives it.
pub(crate) fn team_json(&self) -> Value {
let Some(t) = self.team() else { return Value::Null };
let (state, reason, web, files) = match &t.list {
TeamList::Files { web, files } => ("listed", None, Some(web.as_str()).filter(|w| !w.is_empty()), files.as_slice()),
TeamList::None => ("none", None, None, &[][..]),
TeamList::Offline(why) => ("offline", Some(why.as_str()), None, &[][..]),
};
let files: Vec<Value> = files
.iter()
.map(|f| {
json!({
"file": f.file,
"name": f.name,
"updated": f.updated,
"people": f.people,
"access": f.access.id(),
"link": team_link(&t.server, &f.file),
"webLink": web.map(|w| web_link(w, &t.server, &f.file)),
})
})
.collect();
json!({"server": t.server, "host": server_host(&t.server), "state": state, "reason": reason, "web": web, "files": files})
}
}
+9
View File
@@ -202,6 +202,7 @@ status = "draft — needs native review"
"Bulleted list" = "Aufzählungsliste"
"C or Shift+C" = "C oder Umschalt+C"
"C or ⇧C" = "C oder ⇧C"
"Changed {date}" = "Geändert {date}"
"CMYK colour" = "CMYK-Farbe"
"Can view" = "Kann ansehen"
"Can comment" = "Kann kommentieren"
@@ -213,6 +214,8 @@ status = "draft — needs native review"
"Click objects to vote; click your badge to take a vote back" = "Klicken Sie auf Objekte, um abzustimmen; klicken Sie auf Ihr Abzeichen, um eine Stimme zurückzunehmen"
"Code or invite link" = "Code oder Einladungslink"
"Color profile: {name}" = "Farbprofil: {name}"
"Copy browser link" = "Browser-Link kopieren"
"Couldn't reach {server} for its team list." = "{server} war für die Teamliste nicht erreichbar."
"CSS custom properties…" = "Benutzerdefinierte CSS-Eigenschaften…"
"Can edit" = "Kann bearbeiten"
"Can't read this library: {e}" = "Diese Bibliothek kann nicht gelesen werden: {e}"
@@ -464,6 +467,7 @@ status = "draft — needs native review"
"Essential" = "Wesentlich"
"Every conflict has a choice." = "Für jeden Konflikt wurde eine Auswahl getroffen."
"Every top-level frame already starts a flow" = "Jeder Rahmen der obersten Ebene startet bereits einen Ablauf"
"Everyone who reaches this server can open these files while they are shared." = "Alle, die diesen Server erreichen, können diese Dateien öffnen, solange sie geteilt sind."
"Exact opacity (4 then 5: 45%)" = "Genaue Deckkraft (4, dann 5: 45 %)"
"Exclude" = "Ausschließen"
"Excluded" = "Ausgeschlossen"
@@ -724,6 +728,8 @@ status = "draft — needs native review"
"Lines deleted" = "Zeilen gelöscht"
"Link" = "Link"
"Link copied" = "Link kopiert"
"Link copied: it opens the file in FigCraft" = "Link kopiert: Er öffnet die Datei in FigCraft"
"Link copied: it opens the file in the browser" = "Link kopiert: Er öffnet die Datei im Browser"
"Link docs, code or tickets for developers." = "Verlinken Sie Dokumentation, Code oder Tickets für Entwickler."
"Link this component to its code component: instances then show your real snippet with their values." = "Verknüpfen Sie diese Komponente mit ihrer Code-Komponente: Instanzen zeigen dann Ihr echtes Snippet mit ihren Werten."
"List spacing" = "Listenabstand"
@@ -743,9 +749,11 @@ status = "draft — needs native review"
"Main component deleted" = "Hauptkomponente gelöscht"
"Main menu" = "Hauptmenü"
"Make slot" = "Slot erstellen"
"Nobody shares a file there now." = "Dort teilt gerade niemand eine Datei."
"Pasted design layers" = "Eingefügte Designebenen"
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Designebenen eingefügt; die Zwischenablage enthielt die Bilder von {layers} nicht"
"Scroll wheel zoom" = "Mit dem Mausrad zoomen"
"Shared by your team on {server}" = "Von Ihrem Team geteilt auf {server}"
"Slot name" = "Slot-Name"
"Margin" = "Rand"
"Mark all as read" = "Alle als gelesen markieren"
@@ -1663,6 +1671,7 @@ status = "draft — needs native review"
"very long vector paths were skipped" = "sehr lange Vektorpfade wurden übersprungen"
"video actions are not supported and were skipped" = "Videoaktionen werden nicht unterstützt und wurden übersprungen"
"video triggers are not supported and were skipped" = "Videoauslöser werden nicht unterstützt und wurden übersprungen"
"{count} in it now · Changed {date}" = "{count} gerade darin · Geändert {date}"
"{instances} in {files}" = "{instances} in {files}"
"{language}: draft translation, waiting for a native speaker's review." = "{language}: Übersetzungsentwurf, wartet auf die Prüfung durch Muttersprachler."
"{length} bleed" = "{length} Anschnitt"
+9
View File
@@ -217,6 +217,7 @@ name = "English"
"Bulleted list" = "Bulleted list"
"C or Shift+C" = "C or Shift+C"
"C or ⇧C" = "C or ⇧C"
"Changed {date}" = "Changed {date}"
"CMYK colour" = "CMYK colour"
"Can view" = "Can view"
"Can comment" = "Can comment"
@@ -238,6 +239,8 @@ name = "English"
"Connector" = "Connector"
"Convert to frame" = "Convert to frame"
"Convert to section" = "Convert to section"
"Copy browser link" = "Copy browser link"
"Couldn't reach {server} for its team list." = "Couldn't reach {server} for its team list."
"CSS custom properties…" = "CSS custom properties…"
"Dart constants for Flutter…" = "Dart constants for Flutter…"
"Calculated from other variables; it follows them" = "Calculated from other variables; it follows them"
@@ -509,6 +512,7 @@ name = "English"
"Essential" = "Essential"
"Every conflict has a choice." = "Every conflict has a choice."
"Every top-level frame already starts a flow" = "Every top-level frame already starts a flow"
"Everyone who reaches this server can open these files while they are shared." = "Everyone who reaches this server can open these files while they are shared."
"Exact only" = "Exact only"
"Exact opacity (4 then 5: 45%)" = "Exact opacity (4 then 5: 45%)"
"Exclude" = "Exclude"
@@ -799,6 +803,8 @@ name = "English"
"Lines deleted" = "Lines deleted"
"Link" = "Link"
"Link copied" = "Link copied"
"Link copied: it opens the file in FigCraft" = "Link copied: it opens the file in FigCraft"
"Link copied: it opens the file in the browser" = "Link copied: it opens the file in the browser"
"Link docs, code or tickets for developers." = "Link docs, code or tickets for developers."
"Link frames on the Prototype tab, then present the flow" = "Link frames on the Prototype tab, then present the flow"
"Link this component to its code component: instances then show your real snippet with their values." = "Link this component to its code component: instances then show your real snippet with their values."
@@ -820,9 +826,11 @@ name = "English"
"Main menu" = "Main menu"
"Make a component" = "Make a component"
"Make slot" = "Make slot"
"Nobody shares a file there now." = "Nobody shares a file there now."
"Pasted design layers" = "Pasted design layers"
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Pasted design layers; the clipboard didn't carry the images of: {layers}"
"Scroll wheel zoom" = "Scroll wheel zoom"
"Shared by your team on {server}" = "Shared by your team on {server}"
"Slot name" = "Slot name"
"Mar" = "Mar"
"Margin" = "Margin"
@@ -1831,6 +1839,7 @@ name = "English"
"video actions are not supported and were skipped" = "video actions are not supported and were skipped"
"video triggers are not supported and were skipped" = "video triggers are not supported and were skipped"
"yesterday" = "yesterday"
"{count} in it now · Changed {date}" = "{count} in it now · Changed {date}"
"{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"
+105 -3
View File
@@ -5,10 +5,15 @@
//! server until the section opens or a file is acted on; then each server is asked once, in the
//! background (the answer wakes the window), and one that can't be reached says so while the
//! list shows what this computer remembers.
//!
//! Below them, the team list of the server in Preferences, when its operator keeps one: the files
//! shared from it now by anyone. A row joins its file; its menu copies the file's link (no secret
//! in it) for the app, or for the server's browser build.
use egui::{Align2, CornerRadius, Rect, Sense, Ui, pos2, vec2};
use figcraft_engine::live::utc_date;
use figcraft_engine::online::{FileState, OnlineFile, ServerProblem, server_host};
use figcraft_engine::team::{Team, TeamFile, TeamList, team_link, web_link};
use serde_json::{Value, json};
use crate::FigApp;
@@ -54,9 +59,11 @@ pub fn mark(shared: bool) -> Icon {
if shared { Icon::LiveShared } else { Icon::LivePrivate }
}
/// Ask each server once what became of its files (in the background).
/// Ask each server once what became of its files, and the server in Preferences for its team list
/// (in the background).
fn refresh(app: &mut FigApp, ctx: &egui::Context) {
if !app.session.prefs.online_files.is_empty() && !app.session.waiting_for(FILES) {
let ask = !app.session.prefs.online_files.is_empty() || !app.session.prefs.publish_server.trim().is_empty();
if ask && !app.session.waiting_for(FILES) {
app.run_background(ctx, FILES, json!({"refresh": true}), Box::new(|_, _, _| {}));
}
}
@@ -104,10 +111,10 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, main: Rect, t: &Tokens) {
crate::live::join_status(app, ui, t, session);
}
let files = app.session.prefs.online_files.clone();
let team = app.session.team().cloned();
if files.is_empty() {
ui.add_space(8.0);
widgets::label(ui, tr("Nothing online yet. Use Share on a file to put it online and it is listed here."), t.text_secondary);
return;
}
ui.add_space(6.0);
let busy = app.session.waiting_for(MANAGE);
@@ -116,6 +123,9 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, main: Rect, t: &Tokens) {
for f in &files {
row(app, ui, t, f, busy);
}
if let Some(team) = &team {
team_section(app, ui, t, team);
}
});
});
}
@@ -231,6 +241,89 @@ fn row(app: &mut FigApp, ui: &mut Ui, t: &Tokens, f: &OnlineFile, busy: bool) {
}
}
/// The team list of the server in Preferences: its heading, then a row per file shared now (or
/// why there is none). A server without a team list shows nothing.
fn team_section(app: &mut FigApp, ui: &mut Ui, t: &Tokens, team: &Team) {
let host = server_host(&team.server).to_string();
let (web, files) = match &team.list {
TeamList::None => return,
TeamList::Files { web, files } => (web.as_str(), files.as_slice()),
TeamList::Offline(_) => ("", &[][..]),
};
ui.add_space(14.0);
ui.label(egui::RichText::new(trf("Shared by your team on {server}", &[("server", &host)])).font(semibold(14.0)).color(t.text));
match &team.list {
TeamList::Offline(_) => widgets::label(ui, &trf("Couldn't reach {server} for its team list.", &[("server", &host)]), t.danger),
_ if files.is_empty() => widgets::label(ui, tr("Nobody shares a file there now."), t.text_secondary),
_ => {
widgets::label(ui, tr("Everyone who reaches this server can open these files while they are shared."), t.text_secondary);
for f in files {
team_row(app, ui, t, &team.server, web, f);
}
}
}
}
/// What a team row says after the file's name.
fn team_detail(f: &TeamFile) -> String {
let date = utc_date(f.updated);
let day = date.get(..10).unwrap_or(&date);
match f.people {
0 => trf("Changed {date}", &[("date", &day)]),
n => trf("{count} in it now · Changed {date}", &[("count", &n.to_string()), ("date", &day)]),
}
}
/// Join file `f` of the team list on `server`; its tab shows once the file arrives.
fn open_team(app: &mut FigApp, server: &str, f: &TeamFile) {
if let Some(session) = app.run("collab.join", json!({"link": team_link(server, &f.file)})).and_then(|r| r["session"].as_u64()) {
app.ui.live.home_join = Some(session);
}
}
/// One file of the team list: a click joins it; its menu copies its links.
fn team_row(app: &mut FigApp, ui: &mut Ui, t: &Tokens, server: &str, web: &str, f: &TeamFile) {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW), Sense::click());
let more = Rect::from_center_size(pos2(r.max.x - 18.0, r.center().y), vec2(24.0, 24.0));
let details = team_detail(f);
a11y::button(&resp, &format!("{}, {details}", f.name));
let shown = FileRow {
mark: Some((Icon::LiveShared, t.success)),
name: &f.name,
ink: t.text,
detail: &details,
detail_size: 11.0,
detail_ink: t.text_secondary,
};
file_row(ui, t, r.with_max_x(more.min.x - 4.0), &resp, &shown);
let mresp =
ui.interact(more, ui.scope_id().with(("team_more", server, &f.file)), Sense::click()).tip(trf("Actions for {name}", &[("name", &f.name)]));
a11y::menu_button(&mresp, &trf("Actions for {name}", &[("name", &f.name)]), a11y::menu_open(&mresp));
if mresp.hovered() {
ui.painter().rect_filled(more, CornerRadius::same(5), t.pressed);
}
icons::paint(ui.painter(), more.shrink(5.0), Icon::Dots, t.icon);
crate::right::menu_popup(&mresp).width(240.0).show(|ui| {
if crate::right::menu_item(ui, tr("Open"), "") {
open_team(app, server, f);
ui.close();
}
if crate::right::menu_item(ui, tr("Copy link"), "") {
app.clipboard_set_text(&team_link(server, &f.file));
app.toast(tr("Link copied: it opens the file in FigCraft"));
ui.close();
}
if !web.is_empty() && crate::right::menu_item(ui, tr("Copy browser link"), "") {
app.clipboard_set_text(&web_link(web, server, &f.file));
app.toast(tr("Link copied: it opens the file in the browser"));
ui.close();
}
});
if resp.clicked() {
open_team(app, server, f);
}
}
/// Drop `f` from the list on this computer, and say that the file itself stays.
fn forget(app: &mut FigApp, f: &OnlineFile) {
if app.run("collab.forgetFile", json!({"server": f.server, "file": f.file})).is_none() {
@@ -316,9 +409,18 @@ pub fn inspect(app: &FigApp) -> Value {
.iter()
.map(|f| json!({"file": f.file, "name": f.name, "detail": detail(f), "shared": f.shared.is_some(), "state": f.state, "hasOwnerToken": true}))
.collect();
let team = app.session.team().map(|team| match &team.list {
TeamList::Files { files, .. } => json!({
"state": "listed",
"files": files.iter().map(|f| json!({"file": f.file, "name": f.name, "detail": team_detail(f)})).collect::<Vec<_>>(),
}),
TeamList::None => json!({"state": "none", "files": []}),
TeamList::Offline(_) => json!({"state": "offline", "files": []}),
});
json!({
"open": app.ui.home_view.section == crate::home::Section::Online,
"files": files,
"team": team,
"checking": app.session.waiting_for(FILES),
"problems": app.session.live.servers.len(),
"confirmRemove": app.ui.online.remove.as_ref().map(|r| &r.2),
+78 -1
View File
@@ -10,7 +10,7 @@ use std::time::Duration;
use egui::{Event, Key, Shape, pos2};
use figcraft_engine::Prefs;
use figcraft_engine::figcraft_collab::message::{Admin, AdminReply, ClientMsg, OwnedFile};
use figcraft_engine::figcraft_collab::message::{Admin, AdminReply, ClientMsg, FileInfo, OwnedFile, TeamFile};
use figcraft_engine::live::{
Access, Event as LiveEvent, HostInfo, HostOptions, LiveLink, NetReply, NetRequest, Peer, Presence, PublishOptions, Role, SessionInfo,
Shared as SharedLinks, SharedLink,
@@ -40,6 +40,8 @@ struct Script {
events: Vec<LiveEvent>,
session: u64,
copied: Vec<String>,
/// The team list the server keeps (its browser build's address and files), if any.
team: Option<(String, Vec<TeamFile>)>,
}
type Shared = Arc<Mutex<Script>>;
@@ -109,6 +111,10 @@ impl LiveLink for Link {
s.files.retain(|f| *f != file);
AdminReply::Done
}
Some(Admin::Team) => match s.team.clone() {
Some((web, files)) => AdminReply::Team { web, files },
None => AdminReply::Failed { reason: "this server keeps no team list".into() },
},
_ => AdminReply::Done,
};
Ok((HostInfo::default(), Some(reply)))
@@ -421,3 +427,74 @@ fn tabs_mark_files_in_a_live_session_and_toasts_name_a_file_that_isnt_in_front()
h.frames(2);
assert_eq!(toast(&h), "Hana left Homepage");
}
/// A file of the scripted server's team list: `people` in it now.
fn team_file(id: &str, name: &str, people: u32, access: Access) -> TeamFile {
TeamFile {
file: FileInfo { id: id.into(), name: name.into(), created: 1_791_800_000, updated: 1_791_800_000, seq: 3, bytes: 900, people },
access,
}
}
/// A window whose Preferences name the scripted server, which keeps `team` as its team list.
fn team_harness(team: Option<(String, Vec<TeamFile>)>) -> (Harness, Shared) {
let (mut h, script) = harness(vec![]);
script.lock().unwrap().team = team;
h.app.session.prefs.publish_server = SERVER.into();
(h, script)
}
#[test]
fn the_team_list_of_the_preferences_server_shows_and_a_row_joins_its_file() {
let web = "https://share.example/app/";
let files = vec![team_file("t1", "Homepage", 2, Access::Edit), team_file("t2", "Pricing", 0, Access::Edit)];
let (mut h, script) = team_harness(Some((web.into(), files)));
let shapes = open(&mut h);
let asked: Vec<(String, Option<Admin>)> = script.lock().unwrap().asked.clone();
assert_eq!(asked, [(SERVER.to_string(), Some(Admin::Team))], "only the team list of the Preferences server is asked");
for t in [
"Nothing online yet. Use Share on a file to put it online and it is listed here.",
"Shared by your team on share.example",
"Everyone who reaches this server can open these files while they are shared.",
"Homepage",
"2 in it now · Changed 2026-10-12",
"Pricing",
"Changed 2026-10-12",
] {
assert!(has_text(&shapes, t), "the section shows {t:?}: {:?}", texts(&shapes).iter().map(|t| t.0.clone()).collect::<Vec<_>>());
}
let inspect = h.app.inspect();
assert_eq!((&inspect["online"]["team"]["state"], &inspect["online"]["team"]["files"][1]["name"]), (&json!("listed"), &json!("Pricing")));
// The row's menu copies the links that open the file: for the app, and for the browser build.
let shapes = menu(&mut h, "Pricing");
click(&mut h, &shapes, "Copy link");
let shapes = menu(&mut h, "Pricing");
click(&mut h, &shapes, "Copy browser link");
let link = format!("figcraft://join?server={SERVER}&file=t2&team=1");
let browser = format!("{web}#join=figcraft%3A%2F%2Fjoin%3Fserver%3Dwss%3A%2F%2Fshare.example%2F%26file%3Dt2%26team%3D1");
assert_eq!(script.lock().unwrap().copied, [link, browser]);
// A click joins the file by its team list link.
let shapes = h.frames(1);
click(&mut h, &shapes, "Homepage");
assert_eq!(script.lock().unwrap().joined, [format!("figcraft://join?server={SERVER}&file=t1&team=1")]);
}
#[test]
fn a_server_without_a_team_list_shows_none_and_the_shipped_public_server_isnt_asked() {
let (mut h, script) = team_harness(None);
let shapes = open(&mut h);
assert!(!has_text(&shapes, "Shared by your team on share.example"));
assert_eq!(h.app.inspect()["online"]["team"]["state"], "none");
assert_eq!(script.lock().unwrap().asked.len(), 1);
// Nobody sharing now, and a server that can't be reached, say so.
let (mut h, _) = team_harness(Some((String::new(), vec![])));
assert!(has_text(&open(&mut h), "Nobody shares a file there now."));
let (mut h, script) = team_harness(None);
script.lock().unwrap().offline = true;
assert!(has_text(&open(&mut h), "Couldn't reach share.example for its team list."));
// The public server the app ships with keeps no team list: it isn't asked.
let (mut h, script) = harness(vec![]);
h.app.session.prefs.publish_server = figcraft_engine::live::DEFAULT_SERVER.into();
open(&mut h);
assert!(script.lock().unwrap().asked.is_empty());
}
+23
View File
@@ -664,6 +664,29 @@ resuming with its offline edits, and leaving the session going back to snapshot
opening a file, so looking at the list never postpones a file's expiry. Each pair is checked
like an owner token on `OpenHosting` (its SHA-256 against the stored hash, in constant time).
*Built* (the team list; `figcraft_engine::team`, running it: [`server.md`](server.md#the-team-list)):
- **Listing.** On a server whose operator set `team_files`, `Admin team` on an `OpenHosting`
connection that names no file (or a managing one) answers `AdminReply::Team {web, files}`: the
files that have a link with a join code now (a link of their own, not a team link), each as a
`FileInfo` with the list's access, most recently changed first, at most 500, and the address of
the browser build (`web_url`). A server without a list answers `Failed`; one older than the
request can't read it. `collab.files {refresh: true}` asks the server in Preferences (not the
public server the app ships with), after the owned files.
- **Joining.** A team list link names the server and the file, `figcraft://join?server=…&file=<id>&team=1`
(`Lookup::Team` in `Invite::parse_join`). The guest's first connection sends `ResolveTeam
{file}` instead of a hello; the server makes the file's team link the first time (access from
`team_files`, label `Team (<access>)`, its token kept in `links.json`) and answers `Resolved
{file, token}` as for a join code, then the join goes on with that token on a new connection,
in the desktop app and the browser alike. A file that isn't listed, or a server without a list,
is `Refused` with the reason; an answer naming another file than the one asked about is taken
for none.
- **Revoking.** Revoking a link (`Admin revoke`) that leaves the file without a coded link revokes
its team links too, in the same answer (`Handled::revoked` names every link revoked), so whoever
joined with one is disconnected. Admission refuses a team link while the list is off, gives
another access, or the file isn't shared, whatever `links.json` still holds, and the server
removes such team links when it starts.
*Built* (document sync between apps):
- The host's session runs the core's `Server`, seeded with the document it shared; the host's
+3 -2
View File
@@ -1,6 +1,6 @@
# Collaboration parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`figcraft-server` checks the server key by comparing SHA-256 digests, not the raw keys (#1130); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; `comment.list {mentions}` lists only the threads that @mention a name (#1415); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); an AI agent attached over MCP shows where it works (#1416); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); `figcraft-server` checks the server key by comparing SHA-256 digests, not the raw keys (#1130); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; `comment.list {mentions}` lists only the threads that @mention a name (#1415); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); an AI agent attached over MCP shows where it works (#1416); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version
history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model,
@@ -15,7 +15,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/<key>` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). The batches kept for apps that reconnect are bounded in bytes by default (64 MiB; `max_log_mb` on `figcraft-server`), and an app further behind catches up with a snapshot (#1069). Placing a layer among its siblings is a binary search, so large pastes and reorders in one batch no longer take quadratic time (#1065). A refused batch's fixes take time linear in the batch: each deleted layer is restored once and each entry fix looked up once (#1063). Left: server archives beyond Linux | 25–55 |
| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors (one name for live sessions and comments: Preferences ▸ Your name, also in the Share popover and the Join dialog, kept in the browser too; a rename shows at once in the sessions you're in, #1380), selection outlines, avatars (an AI agent attached over MCP also shows its avatar and an outline around the layers it last edited or selected while it works, in or out of a session, #1416), follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 |
| Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 |
| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit, comment and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen, and the Join dialog then shows the join until its file is ready (Cancel), or why it failed (Try again, #1381); the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser), and shares online and publishes to a server as the desktop app does, though not on the local network (#917; the owner token of a file it puts on an open server lasts as long as the page)); `figcraft-server` share links per file with edit, comment or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; a commenter also adds, replies to, reacts to and resolves comment threads, with a "Can comment" badge and the comment tool, #1525; the server refuses their other edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 |
| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit, comment and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen, and the Join dialog then shows the join until its file is ready (Cancel), or why it failed (Try again, #1381); the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser), and shares online and publishes to a server as the desktop app does, though not on the local network (#917; the owner token of a file it puts on an open server lasts as long as the page)); `figcraft-server` share links per file with edit, comment or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; a commenter also adds, replies to, reacts to and resolves comment threads, with a "Can comment" badge and the comment tool, #1525; the server refuses their other edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer; a server only a team reaches can also list the files shared from it now for everyone (`team_files`): Home ▸ Online shows that team list for the server in Preferences, a row joins its file, and its links (`figcraft://join?server=…&file=…&team=1`, or the browser build's) carry no secret and stop working with the share. No accounts, teams or projects | 8–17 |
| Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences (an AI agent attached over MCP comments as its own name, marked Agent); a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. `comment.list` (CLI, MCP) gives each message its `mentions` (`{name, known, you}`, by the highlighting rule). In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) |
| Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file: restoring keeps the replaced design as an autosave first, a titled autosave becomes a named version, merging a branch records the file before and after. Versions are per person in a live session (a shared history needs accounts); no comparing two versions (Dev Mode compares a ready-for-dev layer with its state when marked) | — |
| Branching and merging | yes (paid plans) | **done, also in live sessions**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived; entries for its creation and updates from main); fills, strokes, effects, layout grids, export settings, guides, interactions, annotations and Dev Mode resources merge item by item. In a live session a branch stays with its author (its ids in their own client space, far above the main file's) and merging it is one edit everyone gets; peers' edits merge against the branch's own base and conflicts resolve as offline; a view link refuses merging. Left: others in the session can't see or open your branches (the server would have to keep them); text runs conflict as a whole; a branch made offline that adds layers merges only outside a session | 1–2 |
@@ -26,6 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
| Date | Change | Summary |
|---|---|---|
| 2026-10-30 | minor | M9, part of #742: a team list on `figcraft-server`. With `team_files = "edit"` (or `comment`, `view`) everyone who reaches the server sees the files shared from it now (a link of theirs has a join code: Share ▸ Start sharing) and joins them by a team list link, `figcraft://join?server=…&file=…&team=1`, which carries no secret: the server hands out the file's team link while it stays shared (`ResolveTeam`, answered like a join code), and Stop sharing revokes it with the others, disconnecting whoever joined with it. Turning the list off or changing its access refuses the old team links at once. `collab.files {refresh}` asks the server in Preferences for its list (`Admin::Team`; not the public server FigCraft ships with), and Home ▸ Online shows it below your own files: a row joins its file, its menu copies the link for the app or, with the server's `web_url`, for the browser build (`<web_url>#join=…`). A browser build served beside its server names it with `<meta name="figcraft-server">`, the page's default for sharing, codes and the team list. No accounts: the list is for a server only your team reaches. Tests: `team_tests` (the server and apps over loopback), `code_tests` (links and the browser's driver), `online_tests` (the Online section), `query_tests` (the page's server). Percentages and hours unchanged |
| 2026-10-30 | minor | M9, #1130: `figcraft-server` compares the SHA-256 digests of the presented and the kept server key instead of the raw strings, as `docs/server.md` says; the key file, `figcraft-server key` and rotation are unchanged. Numbers unchanged. |
| 2026-10-30 | minor | M8, #1074: `figcraft-server` keeps a file everyone left loaded for a minute (at most 16 files, 256 MiB together), so rejoining it reads nothing, and writes a file's open time at most hourly (and when it stops) instead of at every join and leave: join/leave cycles of an unchanged file do no disk I/O on the hub thread. Edits are saved as before. Numbers unchanged. |
| 2026-10-30 | minor | M9, #1415 (the rest): `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry each thread's mentions as `comment.list` does. Numbers unchanged. |
+2 -1
View File
File diff suppressed because one or more lines are too long
+38 -2
View File
@@ -168,6 +168,39 @@ Scripts and agents use `collab.files {refresh?}`, `collab.openFile {file}`,
upgrade the server binary and restart it: the data directory needs no migration and there is no
setting to turn on.
## The team list
A server only your team reaches (a private network, an allowlist in front of it) can list the files
shared from it now for everyone who reaches it: set `team_files = "edit"` (or `"comment"`,
`"view"`: what the list lets people do). There are no accounts: reaching the server is what lets
someone in, so keep it off on a server open to anyone.
- **What is listed.** A file is on the list while a link of its own has a join code: **Share ▸
Start sharing** puts it there (the app makes such links), **Stop sharing** takes it off. Files
only published, or shared with links that have no code, aren't listed. The list names each file,
when it last changed and how many people are in it, most recently changed first (at most 500).
- **In the app.** Home ▸ Online asks the server in **Preferences ▸ Server for online sharing** for
its list each time it opens (not the public server FigCraft ships with, which keeps none) and
shows it below your own online files, under **Shared by your team on …**. Clicking a row joins
the file. Scripts and agents read it from `collab.files {refresh: true}` (`team`).
- **Links without a secret.** A file of the list is named by the server and the file:
`figcraft://join?server=wss://figcraft.example.com/&file=<id>&team=1`. The row's menu copies it
(**Copy link**), and it can go into a ticket or a chat: it joins only while the file is shared,
and only for someone who reaches the server. Opening it asks the server for the file's **team
link** (labelled `Team (edit)`, made the first time someone asks, then the same for everyone)
and joins with that. With `web_url` set to where your browser build is served
(`https://figcraft.example.com/app/`), **Copy browser link** copies
`<web_url>#join=<the link, escaped>`, which opens the file in the browser.
- **Stopping.** When the last of a file's links with a code is revoked (Stop sharing does), its
team link is revoked with it and whoever joined with it is disconnected, as with the other
links. Turning the list off, or changing `team_files`, refuses the old team links at once, and
the next start removes them for good (setting it back doesn't bring them back; the next join
makes a new one).
- **What the server keeps.** Unlike other links, a team link's token is kept readable in the
file's `links.json`, so the server can hand it out: as private as the server key beside it.
- **Upgrade note.** An app older than the team list shows no list and can't open its links (it
names no token); a server older than it answers that it has no team list.
## Joining
A [join code](#sharing-with-a-join-code) is the short way in. Share links look like the desktop
@@ -318,6 +351,8 @@ are errors):
| `connects_per_minute` | 30 | Open: new connections an address may open a minute (a burst of as many) |
| `publishes_per_hour` | 10 | Open: files an address may publish without the key an hour |
| `messages_per_second` | 200 | Open: messages an address may send a second (a burst of four seconds' worth) |
| `team_files` | (none) | The [team list](#the-team-list): everyone who reaches the server sees the files shared from it now and opens them with this access, `edit`, `comment` or `view` |
| `web_url` | (none) | Where your browser build is served (`https://figcraft.example.com/app/`): the team list's browser links point there |
With `listen` on every interface (`0.0.0.0`) and no `public_url`, links name this machine's
network address. The limits marked *open* apply only with `open_publish = true`; a private server
@@ -331,8 +366,9 @@ behaves as before (blocked addresses, report rates and the limits on
<data>/server.json where the running server listens (read by the commands)
<data>/server.key the server key
<data>/files/<id>/meta.json the file's name, when it was created and last changed
<data>/files/<id>/links.json its links: id, access, label, created, SHA-256 of the token, and for
a link with a join code its stretched hash and the wrapped token
<data>/files/<id>/links.json its links: id, access, label, created, SHA-256 of the token, for
a link with a join code its stretched hash and the wrapped token,
and for a team link its token
<data>/files/<id>/snapshot.bin its state at the last compaction (with a checksum)
<data>/files/<id>/log.bin what changed since, one checksummed record per change
<data>/files/<id>/library-<version>.bin its team library, when one was published (a .figcraft
+5
View File
@@ -33,6 +33,11 @@ browsers block from a page served over `https://` except to the same computer. T
other machines, serve the site over `http://` on a trusted network (or use the desktop app);
`wss://` comes with the standalone server.
A site served beside its own `figcraft-server` can name it in `index.html`:
`<meta name="figcraft-server" content="wss://figcraft.example.com/">` (a `ws://` or `wss://`
address without a query). The page then uses it as the server for online sharing, join codes and
the server's team list (Home ▸ Online), without anyone setting it in Preferences.
## Sizes
Measured on the 0.1.0 build (`trunk build --release`; gzip `-9`, Brotli quality 11):