M9: a team list on figcraft-server: the files shared now, joined without a token (team_files)

This commit is contained in:
Patrick Wozniak
2026-10-30 17:09:07 +01:00
parent b3ca228a75
commit 4b4b2bfd00
20 changed files with 621 additions and 70 deletions
+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
@@ -115,4 +115,6 @@ mod server_tests;
#[cfg(test)]
mod store_tests;
#[cfg(test)]
mod team_tests;
#[cfg(test)]
mod test_util;
+26 -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 {
@@ -613,5 +636,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 }
}
}
}
@@ -264,6 +288,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
}
+97
View File
@@ -0,0 +1,97 @@
//! The team list (`team_files`): everyone who reaches the server sees the files shared from it now
//! and opens them with a team link, one per file, that the server makes the first time someone
//! asks for it and keeps (its token readable, unlike other links') while the file stays shared. A
//! file is shared while a link of its own (not a team link) has a join code: Share ▸ Start sharing
//! makes such links, Stop sharing revokes them, and with the last of them the team links go too
//! (whoever joined with one is disconnected, as with the links themselves).
use figcraft_collab::message::{MAX_TEAM, TeamFile};
use super::{Access, AdminReply, Entry, LINKS, NewLink, OpenFile, Store, TeamToken, new_link, now, write_json};
/// What a server without a team list answers.
pub const NO_TEAM: &str = "this server keeps no team list (its operator can turn one on with team_files)";
/// What asking for the link of a file that isn't shared now answers.
pub const NOT_SHARED: &str = "this file isn't shared now (its owner stopped sharing it, or removed it)";
/// The label of a team link (as `figcraft-server links list` shows it).
fn label(access: Access) -> String {
format!("Team ({})", access.id())
}
/// Whether `entry` is shared now: a link of its own has a join code.
fn shared(entry: &Entry) -> bool {
entry.links.iter().any(|l| l.team.is_none() && l.code.is_some())
}
impl Store {
/// The files shared now, most recently changed first (at most [`MAX_TEAM`]).
pub(super) fn team(&self, open: &dyn Fn(&str) -> Option<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);
}
let before = entry.links.clone();
entry.links.retain(|l| l.team.is_none());
if entry.links.len() >= max {
entry.links = before;
return Err(format!("a file has at most {max} links: revoke one first"));
}
let (mut link, token) = new_link(access, &label(access), now())?;
link.team = Some(TeamToken(token.clone()));
entry.links.push(link.clone());
if let Err(e) = write_json(&path, &entry.links) {
entry.links = before;
return Err(format!("couldn't keep the team link: {e}"));
}
log::info!("event=team_link file={file} link={}", link.id);
Ok(NewLink { link: link.info(file), token })
}
/// `file` stopped being shared (a link was revoked): revoke its team links too. The ids
/// revoked (whoever joined with them is disconnected); none while it is still shared.
pub(super) fn team_unshared(&mut self, file: &str) -> Vec<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)))
}
+199
View File
@@ -0,0 +1,199 @@
//! The team list (`team_files`) against a real server: off unless the operator turns it on; on,
//! it lists the files shared now (not the ones only published), hands out one team link per file
//! that joins with the list's access, and when the owner stops sharing the file leaves the list,
//! its team link stops working and whoever joined with it is disconnected. Turning the list off,
//! or changing its access, refuses the old team links at once.
use std::net::SocketAddr;
use figcraft_collab::message::{Access, Admin, AdminReply, ClientMsg, OpenHosting, PROTOCOL, ResolveTeam, ServerMsg, TeamFile};
use figcraft_engine::live::Status;
use serde_json::{Value, json};
use crate::config::Config;
use crate::store::team::{NO_TEAM, NOT_SHARED};
use crate::test_util::{Raw, TempDir, config, join, live, publish, pump, session};
fn team(dir: &std::path::Path, access: &str) -> Config {
Config { open_publish: true, team_files: access.into(), web_url: "https://figcraft.example.com/app/".into(), ..config(dir) }
}
fn ex(s: &mut figcraft_engine::Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
/// One request on an open hosting connection that names no file (anyone's).
fn ask(addr: SocketAddr, req: Admin) -> AdminReply {
let mut raw = Raw::connect(addr);
raw.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: "test".into(), file: String::new(), owner: String::new() }));
assert!(matches!(raw.next(), ServerMsg::Hosting(_)));
raw.send(&ClientMsg::Admin(req));
match raw.next() {
ServerMsg::Admin(r) => *r,
other => panic!("answered {other:?}"),
}
}
fn listed(addr: SocketAddr) -> (String, Vec<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.
let server = crate::start(&Config { open_publish: true, ..config(dir.path()) }).unwrap();
let mut b = join(&invite(server.addr(), &file, &view), "Bob");
pump(&mut [&mut b], "the team link refused", |s| refused(s[0]));
}
#[test]
fn a_team_list_link_joins_without_a_token_while_the_file_is_shared() {
let dir = TempDir::new("team-join");
let server = crate::start(&team(dir.path(), "edit")).unwrap();
let addr = server.addr();
let (mut a, file) = share(addr, "Homepage");
// What the team list gives people to click or paste: no token in it.
let link = format!("figcraft://join?host={addr}&file={file}&team=1");
let mut b = join(&link, "Bob");
pump(&mut [&mut a, &mut b], "Bob in by the team list", |s| live(s[1]) && s[0].live().peers.len() == 1);
assert_eq!(b.live_summary()["access"], "edit");
let failed = |s: &figcraft_engine::Session, why: &str| matches!(&s.live().status, Status::Failed { reason } if reason.contains(why));
let mut c = join(&format!("figcraft://join?host={addr}&file=0123456789abcdef&team=1"), "Cy");
pump(&mut [&mut c], "a file that isn't shared refused", |s| failed(s[0], "isn't shared now"));
// A server without a team list says so.
let dir = TempDir::new("team-join-off");
let off = crate::start(&Config { open_publish: true, ..config(dir.path()) }).unwrap();
let mut d = join(&format!("figcraft://join?host={}&file={file}&team=1", off.addr()), "Di");
pump(&mut [&mut d], "no team list", |s| failed(s[0], "keeps no team list"));
}
+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};
// `std::time::Instant` on native builds; the browser's clock in the web build.
use web_time::Instant;
@@ -24,7 +24,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::requests::Publish;
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
@@ -179,9 +179,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) {
@@ -216,8 +216,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();
@@ -373,8 +373,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)]
@@ -735,6 +769,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.
@@ -800,8 +837,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() })]
}