mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 05:46:46 -06:00
M9: a team list on figcraft-server: the files shared now, joined without a token (team_files)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -115,4 +115,6 @@ mod server_tests;
|
||||
#[cfg(test)]
|
||||
mod store_tests;
|
||||
#[cfg(test)]
|
||||
mod team_tests;
|
||||
#[cfg(test)]
|
||||
mod test_util;
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() })]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user