diff --git a/ROADMAP.md b/ROADMAP.md index ebde6116..5e6a1a39 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -276,7 +276,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| -| 2026-10-12 | minor | Sharing with a join code (M9, from the owner's use: sharing wasn't intuitive): the Share popover is one flow, **Online** (the file is published to the server in Preferences, built in `wss://collab.craftapps.dev/`, and the server names its edit and view links with nine-symbol codes like `K7M-2QX-9TD`) or **Local network** (the app hosts; a 20-symbol code carries its IPv4 address, port and a 44-bit secret, with check bits), one Start sharing button, the code large with Copy code and Copy link, who is here, can edit / can view online, and Stop sharing (online: the file leaves the server); **Join with a code** is one field (popover, home screen, Join dialog) for a code of either kind or a link; addresses, keys and ports moved under Advanced and Preferences ▸ Server for online sharing (reset, or empty to turn online sharing off). Protocol additions within version 2 (`Resolve` / `Resolved`, `Admin code`): the server keeps a stretched hash and the wrapped token, never the code, and limits wrong codes (10 a minute per address, a 15-minute lockout after 10 wrong, 1,000 wrong an hour for everyone); codes go with their link and file. Older servers leave the links in place of a code; older apps join by link. `collab.share`, `collab.join {code}`, `code` and `share` in `collab.peers`; the browser joins by code. Numbers unchanged | +| 2026-10-12 | minor | Sharing with a join code (M9, from the owner's use: sharing wasn't intuitive): the Share popover is one flow, **Online** (the file is published to the server in Preferences, built in `wss://collab.craftapps.dev/`, and the server names its edit and view links with nine-symbol codes like `K7M-2QX-9TD`) or **Local network** (the app hosts; a 20-symbol code carries its IPv4 address, port and a 44-bit secret, with check bits), one Start sharing button, the code large with Copy code and Copy link, who is here, can edit / can view online, and Stop sharing (online: the others' links and codes are revoked and they are disconnected, while the file stays on the server with its owner in it and can be shared again with new codes; removing it is a separate, confirmed step); **Join with a code** is one field (popover, home screen, Join dialog) for a code of either kind or a link; addresses, keys and ports moved under Advanced and Preferences ▸ Server for online sharing (reset, or empty to turn online sharing off). Protocol additions within version 2 (`Resolve` / `Resolved`, `Admin code`): the server keeps a stretched hash and the wrapped token, never the code, and limits wrong codes (10 a minute per address, a 15-minute lockout after 10 wrong, 1,000 wrong an hour for everyone); codes go with their link and file. Older servers leave the links in place of a code; older apps join by link. `collab.share`, `collab.join {code}`, `code` and `share` in `collab.peers`; the browser joins by code. Numbers unchanged | | 2026-10-12 | minor | Live sessions per document (M9, a fix from real use): the engine keeps one session per open document instead of one for the app, the link carries several under their own ids (desktop, browser, tests), the window and the `collab.*` commands follow the active document, `collab.peers` gains `session` and `sessions`, `ui.inspect`'s `live` gains `session` and `sessions`; joining opens the session's file in its own tab without touching other sessions and closing a file ends its session only. Numbers unchanged | | 2026-10-12 | minor | `.fig` export checked by opening written files in other software (gap 1): layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft, after fixes to what the export writes (visible document and pages, the format's schema numbering, outlines, text settings, font style names, line height units); effects such as glass and noise, images and prototype interactions still unchecked there. Numbers unchanged | | 2026-10-12 | minor | Public `figcraft-server` hardened against resource exhaustion after a security review: messages capped by what a connection is for (a file's size when open), byte-bounded send queues and a bound on bytes waiting for the hub, files checked as they grow (refused batches roll back with the reason; per-address and total caps count growth), the core's recent-batch memory bounded in bytes, no confirmation for changes that couldn't be written, team libraries only in key-published files on public servers with unpacking bounded by the file cap, usage reports kept in memory, layered IPv6 limits (/64, /56, /48), a global publish rate, connections reserved for sessions, per-address download shares, library connections cut on revoke and removal, a private data directory and key, and safer install.sh, systemd and backups. Percentages and hours unchanged | diff --git a/apps/figcraft-server/src/codes_tests.rs b/apps/figcraft-server/src/codes_tests.rs index 1fc7e4c3..6392e552 100644 --- a/apps/figcraft-server/src/codes_tests.rs +++ b/apps/figcraft-server/src/codes_tests.rs @@ -1,5 +1,7 @@ //! Join codes on the server: sharing online from one app and joining by code from another (real -//! links over loopback), codes kept only hashed, revoked with their link and gone with their file, +//! links over loopback; stopping revokes the others' links and codes and disconnects them while +//! the owner stays in the file, and sharing again makes new ones for the same file), codes kept +//! only hashed, revoked with their link and gone with their file, //! the limits on wrong codes, junk requests, and clients from before join codes. use std::net::IpAddr; @@ -78,6 +80,15 @@ fn a_file_shared_online_is_joined_by_its_codes() { assert_eq!(shared["code"], edit.as_str(), "the code shown lets people edit unless asked otherwise"); let p = a.live().published.clone().unwrap(); assert!(p.owner.is_some(), "the owner token is kept as before"); + // Others get links of their own (the owner keeps the file's first ones): four in all. + let edit_link = shared["share"]["editLink"].as_str().unwrap().to_string(); + assert!(edit_link != p.edit && shared["share"]["viewLink"] != p.view.as_str() && shared["share"]["shared"] == true); + let ids = |dir: &std::path::Path| -> Vec { + let links: Value = serde_json::from_str(&std::fs::read_to_string(dir.join("files").join(&p.file).join("links.json")).unwrap()).unwrap(); + links.as_array().unwrap().iter().map(|l| l["id"].as_str().unwrap().to_string()).collect() + }; + let before_stop = ids(dir.path()); + assert_eq!(before_stop.len(), 4); // On disk: hashes only, of the codes as of the links' tokens. let links = std::fs::read_to_string(dir.path().join("files").join(&p.file).join("links.json")).unwrap(); for secret in [edit.as_str(), view.as_str(), &edit.replace('-', ""), &view.replace('-', "")] { @@ -90,7 +101,7 @@ fn a_file_shared_online_is_joined_by_its_codes() { assert_eq!(ex(&mut b, "collab.join", json!({"code": edit.to_lowercase().replace('-', " ")}))["status"], "connecting"); pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]) && s[0].live().peers.len() == 1); assert_eq!(b.live_summary()["access"], "edit"); - assert_eq!(b.live_summary()["link"], p.edit.as_str(), "joined as with the link: reconnects need no code"); + assert_eq!(b.live_summary()["link"], edit_link.as_str(), "joined as with the shared link: reconnects need no code"); let before = layers(&a); ex(&mut a, "shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 30})); pump(&mut [&mut a, &mut b], "Ada's edit", |s| layers(s[1]) == before + 1); @@ -108,21 +119,49 @@ fn a_file_shared_online_is_joined_by_its_codes() { ex(&mut d, "collab.join", json!({"code": "000-000-000"})); pump(&mut [&mut d], "the refusal", |s| failed(s[0]).is_some()); assert_eq!(failed(&d).unwrap(), NO_CODE); - // Stop sharing: the file leaves the server, everyone is told, and the code no longer joins. - assert_eq!(ex(&mut a, "collab.stop", json!({}))["ended"], true); - let folder = dir.path().join("files").join(&p.file); - pump(&mut [&mut a, &mut b, &mut c], "the file gone", |s| !folder.exists() && failed(s[1]).is_some() && failed(s[2]).is_some()); - assert!(failed(&b).unwrap().contains("removed"), "{:?}", failed(&b)); + // Stop sharing: the others are out at once and told why; the file and its owner stay. + assert_eq!(ex(&mut a, "collab.stop", json!({})), json!({"ended": false, "shared": false})); + pump(&mut [&mut a, &mut b, &mut c], "the others out", |s| { + !s[1].live().status.active() && !s[2].live().status.active() && s[0].live().peers.is_empty() + }); + for guest in [&b, &c] { + assert!(guest.message.as_deref().unwrap_or_default().contains("Sharing was stopped"), "{:?}", guest.message); + assert_eq!(guest.live().status, Status::Off, "over, not failed: nothing retries"); + } assert_eq!(layers(&b), before + 2, "Bo keeps the copy"); + let folder = dir.path().join("files").join(&p.file); + assert!(folder.exists() && live(&a), "the file stays on the server and Ada stays in it"); + let stopped = a.live_summary(); + assert_eq!((&stopped["share"]["shared"], &stopped["code"], &stopped["published"]["file"]), (&json!(false), &Value::Null, &json!(p.file))); + assert_eq!(ids(dir.path()), before_stop[..2], "the owner's links are left, the shared ones are gone"); + // Neither the codes nor the links get anyone in any more. let mut e = app("Ed", &url); + for old in [&edit, &view] { + ex(&mut e, "collab.join", json!({"code": old})); + pump(&mut [&mut e], "the stopped code", |s| failed(s[0]).is_some()); + assert_eq!(failed(&e).unwrap(), CODE_GONE); + } + ex(&mut e, "collab.join", json!({"link": edit_link})); + pump(&mut [&mut e], "the stopped link", |s| failed(s[0]).is_some_and(|why| why.contains("isn't valid"))); + // The owner still edits the server's file. + ex(&mut a, "shape.rectangle", json!({"x": 200, "y": 10, "width": 40, "height": 30})); + pump(&mut [&mut a], "the owner's edit confirmed", |s| layers(s[0]) == before + 3 && s[0].live().client().is_some_and(|c| c.pending() == 0)); + // Sharing again: new codes and links for the same file (nothing is published twice). + let again = ex(&mut a, "collab.share", json!({})); + let (edit2, link2) = (again["code"].as_str().unwrap().to_string(), again["share"]["editLink"].as_str().unwrap().to_string()); + assert!(is_code(&edit2) && edit2 != edit && link2 != edit_link && again["published"]["file"] == p.file.as_str(), "{again}"); + assert_eq!(dir.path().join("files").read_dir().unwrap().count(), 1, "still one file on the server"); + assert_eq!(ids(dir.path()).len(), 4); + ex(&mut b, "collab.join", json!({"code": edit2})); + pump(&mut [&mut a, &mut b], "Bo back in", |s| live(s[1]) && layers(s[1]) == before + 3 && s[0].live().peers.len() == 1); + // The old code is still gone. ex(&mut e, "collab.join", json!({"code": edit})); - pump(&mut [&mut e], "the stopped code", |s| failed(s[0]).is_some()); - assert_eq!(failed(&e).unwrap(), CODE_GONE); + pump(&mut [&mut e], "the old code", |s| failed(s[0]).is_some_and(|why| why == CODE_GONE)); server.stop(); } #[test] -fn a_keyed_share_gets_codes_too_and_leaving_keeps_the_file() { +fn a_keyed_share_gets_codes_and_stops_with_the_key() { let dir = TempDir::new("codes-keyed"); let mut server = crate::start(&config(dir.path())).unwrap(); let url = format!("ws://{}/", server.addr()); @@ -132,11 +171,18 @@ fn a_keyed_share_gets_codes_too_and_leaving_keeps_the_file() { let shown = a.live_summary()["code"].as_str().unwrap().to_string(); assert!(is_code(&shown)); assert!(!a.live_summary().to_string().contains("key="), "the key is nowhere in the summary"); - // No owner token: stopping leaves the session, the operator's file stays, and so does its code. - ex(&mut a, "collab.stop", json!({})); let mut b = app("Bo", &url); ex(&mut b, "collab.join", json!({"code": shown})); - pump(&mut [&mut b], "Bo in", |s| live(s[0])); + pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1])); + // No owner token: the key the address carried stops the sharing (the file is the operator's). + assert_eq!(ex(&mut a, "collab.stop", json!({}))["shared"], false); + pump(&mut [&mut a, &mut b], "Bo out", |s| !s[1].live().status.active()); + assert!(live(&a) && !a.live_summary().to_string().contains("key=")); + ex(&mut b, "collab.join", json!({"code": shown})); + pump(&mut [&mut b], "the stopped code", |s| failed(s[0]).is_some_and(|why| why == CODE_GONE)); + // Leaving keeps the file there. + ex(&mut a, "collab.leave", json!({})); + assert_eq!(dir.path().join("files").read_dir().unwrap().count(), 1); server.stop(); } diff --git a/apps/figcraft-server/src/server_tests.rs b/apps/figcraft-server/src/server_tests.rs index 98a30ca2..153bb5ab 100644 --- a/apps/figcraft-server/src/server_tests.rs +++ b/apps/figcraft-server/src/server_tests.rs @@ -160,9 +160,9 @@ fn revoked_links_stop_working_at_once() { assert_eq!((files.len(), files[0].people), (1, 3)); // Revoked: Cy is out at once, the others stay. assert_eq!(ask(Admin::Revoke { file: file.clone(), link: review.link.id.clone() }), AdminReply::Done); - pump(&mut [&mut a, &mut b, &mut c], "Cy out", |s| { - matches!(&s[2].live().status, Status::Failed { reason } if reason.contains("revoked")) && s[0].live().peers.len() == 1 - }); + pump(&mut [&mut a, &mut b, &mut c], "Cy out", |s| s[2].live().status == Status::Off && s[0].live().peers.len() == 1); + // Told as when a host stops: the session is over for Cy, whose copy stays open (no retries). + assert!(c.message.as_deref().unwrap_or_default().contains("Sharing was stopped"), "{:?}", c.message); assert!(live(&b)); // The link no longer joins. c.execute("collab.join", &json!({"link": link})).unwrap(); diff --git a/crates/collab-net/src/code_tests.rs b/crates/collab-net/src/code_tests.rs index 1dea3daa..4880242f 100644 --- a/crates/collab-net/src/code_tests.rs +++ b/crates/collab-net/src/code_tests.rs @@ -139,7 +139,7 @@ fn old_server() -> (Endpoint, std::thread::JoinHandle<()>) { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let at = Endpoint::plain(listener.local_addr().unwrap().to_string()); let t = std::thread::spawn(move || { - for stream in listener.incoming().take(3).flatten() { + for stream in listener.incoming().take(2).flatten() { let Ok(mut ws) = tungstenite::accept(stream) else { continue }; let _ = ws.read(); let refused = ServerMsg::Refused { reason: "that message couldn't be read".into() }; @@ -151,6 +151,40 @@ fn old_server() -> (Endpoint, std::thread::JoinHandle<()>) { (at, t) } +/// A server from before join codes that manages a file's links as it always did: two connections, +/// the first makes links, the second (asked for codes) can't read the request. +fn old_server_with_links() -> (Endpoint, std::thread::JoinHandle<()>) { + use figcraft_collab::message::{Admin, AdminReply, LinkInfo, NewLink}; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let at = Endpoint::plain(listener.local_addr().unwrap().to_string()); + let t = std::thread::spawn(move || { + let mut made = 0; + for stream in listener.incoming().take(2).flatten() { + let Ok(mut ws) = tungstenite::accept(stream) else { continue }; + let say = |ws: &mut tungstenite::WebSocket, m: ServerMsg| { + let _ = ws.send(Message::Binary(Bytes::from(message::encode(&m).unwrap()))); + }; + while let Ok(Message::Binary(b)) = ws.read() { + match message::decode::(&b, usize::MAX) { + Ok(ClientMsg::OpenHosting(_)) => say(&mut ws, ServerMsg::Hosting(Box::default())), + Ok(ClientMsg::Admin(Admin::CreateLink { file, access, label })) => { + made += 1; + let link = LinkInfo { id: format!("link{made}"), file, access, label, created: 0 }; + say(&mut ws, ServerMsg::Admin(Box::new(AdminReply::Link(NewLink { link, token: format!("TokenTokenTokenToken0{made}") })))); + } + _ => { + say(&mut ws, ServerMsg::Refused { reason: "that message couldn't be read".into() }); + break; + } + } + } + let _ = ws.close(None); + while ws.read().is_ok() {} + } + }); + (at, t) +} + #[test] fn a_server_older_than_join_codes_says_so_and_the_links_still_work() { let (server, thread) = old_server(); @@ -164,11 +198,14 @@ fn a_server_older_than_join_codes_says_so_and_the_links_still_work() { pump(&mut [&mut guest], "the answer", |s| matches!(s[0].live().status, Status::Failed { .. })); let why = guest.live().status.label(); assert!(why.contains("doesn't know join codes yet") && why.contains("invite link"), "{why}"); - // Asking it for codes: none, without an error (the app shows the links). - assert_eq!( - manage::codes(&server, None, "0123456789abcdef", "AbCdEfGhIjKlMnOpQrStUv", &["AbCdEfGhIjKlMnOpQrStUv"], &limits, Duration::from_secs(5)), - None - ); + thread.join().unwrap(); + // Sharing a file there: it makes the links (it always could) but no codes, without an error: + // the app shows the links. + let (server, thread) = old_server_with_links(); + let shared = + manage::share(&server, None, "0123456789abcdef", "AbCdEfGhIjKlMnOpQrStUv", &[], true, &limits, Duration::from_secs(5)).unwrap().unwrap(); + assert!(shared.edit.code.is_none() && shared.view.as_ref().is_some_and(|v| v.code.is_none())); + assert!(shared.edit.link.contains("&file=0123456789abcdef&token=TokenTokenTokenToken01") && shared.edit.id == "link1"); thread.join().unwrap(); } diff --git a/crates/collab-net/src/guest.rs b/crates/collab-net/src/guest.rs index 1020a251..776dbccf 100644 --- a/crates/collab-net/src/guest.rs +++ b/crates/collab-net/src/guest.rs @@ -39,8 +39,8 @@ pub struct Publish { /// The file's name on the server. pub name: String, pub doc: Arc, - /// Ask the server for join codes for the file's links ([`Event::Codes`]). - pub codes: bool, + /// Share the file once it is there: links and join codes for others ([`Event::Shared`]). + pub share: bool, } /// A joined (or joining) session. Dropping it leaves. @@ -98,15 +98,20 @@ impl Guest { Ok(done) => done, Err(e) => return inbox.push(Event::Status(Status::Failed { reason: format!("Couldn't publish to the server: {e}") })), }; - // Before anyone hears the file is there, so the codes show with it (a server from - // before join codes has none: the links are shown). - let codes = if job.codes { join_codes(&job, &cfg, &published) } else { None }; + // Before anyone hears the file is there, so what others join with shows with it. The + // file's first links stay its owner's (this app joins with one): others get their own, + // which stopping takes away again. + let shared = job.share.then(|| { + let owner = published.owner.as_deref().unwrap_or_default(); + let made = manage::share(&job.server, job.key.as_deref(), &published.file, owner, &[], true, &cfg.limits, cfg.connect_timeout); + made.and_then(|s| s.ok_or_else(|| "the server didn't make the links".to_string())) + }); if let Some(h) = hosting { inbox.push(Event::Hosting(h)); } inbox.push(Event::Published(published)); - if let Some((edit, view)) = codes { - inbox.push(Event::Codes { edit, view }); + if let Some(shared) = shared { + inbox.push(Event::Shared(shared)); } // Left while uploading: the file stays on the server, nobody joins it. Anything else // that came meanwhile (presence) waits for the session. @@ -206,16 +211,6 @@ fn upload(job: &Publish, mut cfg: GuestConfig) -> Result<(GuestConfig, Published Ok((cfg, published, hosting)) } -/// The join codes of the file just published: its edit link's and its view link's. -fn join_codes(job: &Publish, cfg: &GuestConfig, published: &Published) -> Option<(String, String)> { - let view = Invite::parse(&published.view).ok()?; - let tokens = [cfg.invite.token.as_str(), view.token.as_str()]; - let owner = published.owner.as_deref().unwrap_or_default(); - let codes = manage::codes(&job.server, job.key.as_deref(), &published.file, owner, &tokens, &cfg.limits, cfg.connect_timeout)?; - let mut codes = codes.into_iter(); - Some((codes.next()?, codes.next()?)) -} - fn connect(cfg: &GuestConfig) -> Result { let (rd, wr, sock) = open(&cfg.invite.server, cfg.connect_timeout, &cfg.limits)?; let (reader, _) = tungstenite::client::client_with_config(cfg.invite.endpoint(), rd, Some(config(cfg.limits.max_server_message))) diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index 0e86418d..24474211 100644 --- a/crates/collab-net/src/hub.rs +++ b/crates/collab-net/src/hub.rs @@ -1104,6 +1104,10 @@ fn unsupported(protocol: u32) -> String { format!("protocol {protocol} isn't supported (this server speaks {PROTOCOL})") } +/// What someone in a file is told when the link they joined with is revoked (its owner stopped +/// sharing): the session's end for them. +pub const STOPPED: &str = "Sharing was stopped: the link you joined with no longer works. Your copy stays open"; + /// What everyone in a file is told when its changes couldn't be saved (they reconnect). const UNSAVED: &str = "The server couldn't save the latest changes; reconnecting"; @@ -1447,15 +1451,24 @@ impl Hub { fn handled(&mut self, conn: u64, handled: Handled) { self.answer(conn, &ServerMsg::Admin(Box::new(handled.reply))); if let Some((file, link)) = handled.revoked { - let users: Vec = self - .conns - .iter() - .filter(|(_, c)| { - matches!(&c.kind, Kind::Joined { file: f, link: Some(l), .. } | Kind::Library { file: f, link: Some(l), .. } if *f == file && *l == link) - }) - .map(|(k, _)| *k) - .collect(); - self.kick(&users, "the link you joined with was revoked"); + let with = |joined: bool| -> Vec { + let uses = |c: &Conn| match &c.kind { + Kind::Joined { file: f, link: Some(l), .. } => joined && *f == file && *l == link, + Kind::Library { file: f, link: Some(l), .. } => !joined && *f == file && *l == link, + _ => false, + }; + self.conns.iter().filter(|(_, c)| uses(c)).map(|(k, _)| *k).collect() + }; + let (people, libraries) = (with(true), with(false)); + // People in the file are told the session is over for them, as when a host stops (they + // keep their copy; nothing retries); a library request is refused. + for conn in &people { + if let Some(c) = self.conns.get(conn) { + let _ = c.out.tx.try_send(Out::Close(CloseCode::Away, STOPPED.into())); + } + self.gone(*conn); + } + self.kick(&libraries, "the link you joined with was revoked"); } if let Some(file) = handled.removed { let users: Vec = self diff --git a/crates/collab-net/src/link.rs b/crates/collab-net/src/link.rs index b391ad23..8de28014 100644 --- a/crates/collab-net/src/link.rs +++ b/crates/collab-net/src/link.rs @@ -9,7 +9,7 @@ use figcraft_collab::code; use figcraft_collab::message::{Access, Admin, AdminReply, ClientMsg, LibraryReply, LibraryReq, PluginReply, PluginReq}; use figcraft_engine::live::{ Bind, Event, HostInfo, HostOptions, LiveLink, NO_SESSION, NetReply, NetRequest, PageInfo, Presence, PublishOptions, Published, Role, SessionId, - SessionInfo, + SessionInfo, Shared, }; use crate::guest::{Guest, GuestConfig, Publish, upload_file}; @@ -176,7 +176,7 @@ impl LiveLink for NetLink { // Until the upload is done the invite names only the server. let cfg = self.guest_config(Invite { server: server.clone(), file: String::new(), token: String::new() }, None, &o.me); let inbox = self.sessions.inbox(); - let job = Publish { server, key, name: o.name, doc: o.doc, codes: o.codes }; + let job = Publish { server, key, name: o.name, doc: o.doc, share: o.share }; let guest = Guest::publish(job, cfg, inbox.clone()).map_err(|e| format!("can't start the connection: {e}"))?; self.started(session, Active::Guest(guest), inbox); Ok(()) @@ -198,6 +198,10 @@ impl LiveLink for NetLink { hosting(server, file, owner, req, &self.limits) } + fn share(&mut self, server: &str, file: &str, owner: &str, revoke: &[String], create: bool) -> Result, String> { + share(server, file, owner, revoke, create, &self.limits) + } + fn background(&mut self, job: u64, req: NetRequest) -> bool { let (inbox, limits) = (self.libraries.clone(), self.limits); let (failed, failure) = (inbox.clone(), req.failure()); @@ -207,6 +211,7 @@ impl LiveLink for NetLink { NetRequest::Library { link, req } => NetReply::Library(manage::library(&link, req, &limits, CONNECT)), NetRequest::Upload(o) => NetReply::Upload(upload(o, &limits)), NetRequest::Hosting { server, file, owner, req } => NetReply::Hosting(hosting(&server, &file, &owner, req, &limits)), + NetRequest::Share { server, file, owner, revoke, create } => NetReply::Share(share(&server, &file, &owner, &revoke, create, &limits)), }; inbox.push(Event::Net { job, reply: Box::new(reply) }); }); @@ -286,6 +291,14 @@ fn hosting(server: &str, file: &str, owner: &str, req: Option, limits: &N Ok((info, reply)) } +/// See [`LiveLink::share`]: as the server's operator when the address carries its key, else as +/// the file's owner. +fn share(server: &str, file: &str, owner: &str, revoke: &[String], create: bool, limits: &NetLimits) -> Result, String> { + let (endpoint, query) = Endpoint::parse(server)?; + let key = query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v).filter(|k| !k.trim().is_empty()); + manage::share(&endpoint, key.as_deref(), file, owner, revoke, create, limits, CONNECT) +} + /// One request to the plugin directory at `server` (its key in the address's query, if any). fn plugins(server: &str, req: PluginReq, limits: &NetLimits) -> Result { let (server, query) = Endpoint::parse(server)?; diff --git a/crates/collab-net/src/manage.rs b/crates/collab-net/src/manage.rs index c78f20d8..6e995bdb 100644 --- a/crates/collab-net/src/manage.rs +++ b/crates/collab-net/src/manage.rs @@ -7,9 +7,10 @@ use std::time::Duration; use figcraft_collab::message::{ - self, Admin, AdminReply, ClientMsg, HostInfo, LibraryReply, LibraryReq, Manage, OpenHosting, OpenLibrary, OpenPlugins, PROTOCOL, PluginReply, - PluginReq, Resolve, ServerMsg, + self, Access, Admin, AdminReply, ClientMsg, HostInfo, LibraryReply, LibraryReq, Manage, NewLink, OpenHosting, OpenLibrary, OpenPlugins, PROTOCOL, + PluginReply, PluginReq, Resolve, ServerMsg, }; +use figcraft_engine::live::{Shared, SharedLink}; use tungstenite::{Bytes, Message, WebSocket}; use crate::invite::{Endpoint, Invite}; @@ -96,33 +97,56 @@ pub fn resolve(server: &Endpoint, code: &str, limits: &NetLimits, timeout: Durat Ok(answer) } -/// Join codes for the links of `file` with these `tokens`, in order, asked as whoever manages the -/// file: with the server's `key`, else with the file's `owner` token. `None` when the server -/// doesn't hand codes out (it is older than them) or can't be asked: the links still work. -pub fn codes( +/// Change who else gets into `file`, as whoever manages it (with the server's `key`, else with the +/// file's `owner` token): revoke the links with the ids in `revoke` (whoever joined with them is +/// disconnected, and their join codes stop working; one already gone is fine), then, to `create`, +/// make a new edit link and a new view link and ask for their join codes: what others join with. +/// A server older than join codes makes the links without codes. +pub fn share( server: &Endpoint, key: Option<&str>, file: &str, owner: &str, - tokens: &[&str], + revoke: &[String], + create: bool, limits: &NetLimits, timeout: Duration, -) -> Option> { - let requests = tokens.iter().map(|t| Admin::Code { file: file.to_string(), token: (*t).to_string() }).collect(); - let replies = match key { +) -> Result, String> { + let ask = |requests: Vec| match key { Some(key) => request(server, key, requests, limits, timeout), None => hosting(server, file, owner, requests, limits, timeout).map(|(_, replies)| replies), }; - let codes: Option> = replies + let link = |access: Access| Admin::CreateLink { file: file.to_string(), access, label: format!("Shared ({})", access.id()) }; + let mut requests: Vec = revoke.iter().map(|id| Admin::Revoke { file: file.to_string(), link: id.clone() }).collect(); + requests.extend(create.then(|| [link(Access::Edit), link(Access::View)]).into_iter().flatten()); + let mut made = Vec::new(); + for reply in ask(requests)? { + match reply { + AdminReply::Link(l) => made.push(l), + // A link that is gone already is as revoked as it gets. + AdminReply::Failed { reason } if made.is_empty() && reason.contains("no such link") => {} + AdminReply::Failed { reason } => return Err(reason), + _ => {} + } + } + let (Some(view), Some(edit)) = (made.pop(), made.pop()) else { + return if create { Err("the server didn't make the links".into()) } else { Ok(None) }; + }; + // Their codes, on a connection of its own: a server from before join codes can't read the + // request and closes it (the links are shared without codes then). + let code = |token: &str| Admin::Code { file: file.to_string(), token: token.to_string() }; + let mut codes = ask(vec![code(&edit.token), code(&view.token)]) .map_err(|e| log::info!("live session: no join codes from {}: {e}", server.display())) - .ok()? + .unwrap_or_default() .into_iter() - .map(|r| match r { - AdminReply::Code { code } => Some(code), - _ => None, - }) - .collect(); - codes.filter(|c| c.len() == tokens.len()) + .map(|r| if let AdminReply::Code { code } = r { Some(code) } else { None }); + let mut shared = |l: NewLink| SharedLink { + link: Invite { server: server.clone(), file: file.to_string(), token: l.token }.link(), + id: l.link.id, + code: codes.next().flatten(), + }; + let edit = shared(edit); + Ok(Some(Shared { edit, view: Some(shared(view)) })) } /// Send one library request about the file behind share link `link`; the reply. Fails when the diff --git a/crates/engine/src/background_tests.rs b/crates/engine/src/background_tests.rs index 1ab2a1db..c3e5c48e 100644 --- a/crates/engine/src/background_tests.rs +++ b/crates/engine/src/background_tests.rs @@ -112,6 +112,7 @@ fn answer(s: &mut Session, wire: &Arc>) -> usize { NetRequest::Library { req, .. } => NetReply::Library(Ok(library(&mut w, req))), NetRequest::Upload(_) => NetReply::Upload(Err("no".into())), NetRequest::Hosting { .. } => NetReply::Hosting(Err("no".into())), + NetRequest::Share { .. } => NetReply::Share(Err("no".into())), }; w.answers.push(Event::Net { job: *job, reply: Box::new(reply) }); } diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 1f16bb99..7892102d 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -360,7 +360,7 @@ fn publish_to_server(s: &mut Session, p: &Value) -> Result { let link = match server { // The server's address with its key: the file is kept there first. Some(server) if key.is_some() || (server.contains("key=") && !server.contains("token=")) => { - let opts = PublishOptions { server, key, name: doc.name.clone(), me: String::new(), doc: doc.clone(), codes: false }; + let opts = PublishOptions { server, key, name: doc.name.clone(), me: String::new(), doc: doc.clone(), share: false }; let file = match s.net(NetRequest::Upload(opts))?.0 { NetReply::Upload(r) => r.map_err(|e| bad(cmd, e))?, _ => return Err(EngineError::Other("the server answered something else".into())), diff --git a/crates/engine/src/live.rs b/crates/engine/src/live.rs index 31f421f7..33c68000 100644 --- a/crates/engine/src/live.rs +++ b/crates/engine/src/live.rs @@ -195,26 +195,55 @@ pub enum Via { Local, } -/// A file shared with a join code (`collab.share`). +/// One way into a shared file: an invite link, and the join code that stands for it. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SharedLink { + /// The link's id on its server (what revokes it; empty for a session this app hosts). + pub id: String, + /// The invite link (it carries its secret). + pub link: String, + /// Its join code's symbols. `None`: there is none (a server from before join codes, a host + /// without an IPv4 address): the link is shared instead. + pub code: Option, +} + +/// What others join a shared file with: to edit it and, on a server, to only view it. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Shared { + pub edit: SharedLink, + pub view: Option, +} + +/// A file shared with a join code (`collab.share`). Online, the file is its owner's on the +/// server: sharing gives others links (and codes) of their own, and stopping takes those away +/// while the owner stays in the file. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Share { pub via: Via, - /// Online: the server's address (without a key). + /// Online: the server's address as it was given (it may carry the server's key: never shown). pub server: String, /// What the code shown lets people do. pub access: Access, - /// The join codes (their symbols) that let people edit and only view. `None`: not there yet, - /// or there is none (a server from before join codes, a host without an IPv4 address; on the - /// local network everyone edits): the invite links are shared instead. - pub edit: Option, - pub view: Option, + /// What others join with now. `None`: on its way, or (online) not shared at the moment: the + /// file is on the server, open only to its owner. + pub links: Option, } impl Share { - /// The code to show: the one for [`access`](Self::access), grouped (`K7M-2QX-9TD`). + /// The way in to show: the one for [`access`](Self::access). + fn shown(&self) -> Option<&SharedLink> { + let links = self.links.as_ref()?; + if self.access == Access::View { links.view.as_ref() } else { Some(&links.edit) } + } + + /// The code to show, grouped (`K7M-2QX-9TD`). pub fn code(&self) -> Option { - let code = if self.access == Access::View { &self.view } else { &self.edit }; - code.as_deref().map(code::grouped) + self.shown()?.code.as_deref().map(code::grouped) + } + + /// The invite link to give with (or in place of) the code. + pub fn link(&self) -> Option<&str> { + self.shown().map(|l| l.link.as_str()) } } @@ -263,12 +292,9 @@ pub enum Event { }, /// The document is on the server (`collab.publish`); joining it follows. Published(Published), - /// The server's join codes for the published file's edit link and view link (`collab.share`; - /// a server from before join codes sends none). - Codes { - edit: String, - view: String, - }, + /// The published file is shared (`collab.share`): the links and codes others join with, or + /// why the server made none. + Shared(std::result::Result), /// What the server says about its hosting (operator, terms, this file's expiry). Hosting(HostInfo), /// The server's answer to `collab.report`: kept, or why not. @@ -297,6 +323,10 @@ pub enum NetRequest { Library { link: String, req: LibraryReq }, /// Keep a file on a server without joining it ([`LiveLink::upload`]). Upload(PublishOptions), + /// Change who else gets into `file` on the server at `server` (its address, with `?key=…`, or + /// the file's `owner` token): revoke the links with these ids, then, to `create`, make a new + /// edit link and view link with their join codes ([`LiveLink::share`]). + Share { server: String, file: String, owner: String, revoke: Vec, create: bool }, /// Ask the server at `server` about its hosting and, with a file's owner token, manage that /// file ([`LiveLink::hosting`]). Hosting { server: String, file: String, owner: String, req: Option }, @@ -308,7 +338,7 @@ impl NetRequest { match self { NetRequest::Plugins { .. } => crate::plugin_directory::NO_DIRECTORY, NetRequest::Library { .. } | NetRequest::Upload(_) => NO_TEAM_LIBRARIES, - NetRequest::Hosting { .. } => NO_HOSTING, + NetRequest::Hosting { .. } | NetRequest::Share { .. } => NO_HOSTING, } } @@ -319,6 +349,7 @@ impl NetRequest { NetRequest::Library { .. } => |why| NetReply::Library(Err(why)), NetRequest::Upload(_) => |why| NetReply::Upload(Err(why)), NetRequest::Hosting { .. } => |why| NetReply::Hosting(Err(why)), + NetRequest::Share { .. } => |why| NetReply::Share(Err(why)), } } @@ -333,6 +364,9 @@ impl NetRequest { (NetRequest::Hosting { server: a, file: f, req: x, .. }, NetRequest::Hosting { server: b, file: g, req: y, .. }) => { a == b && f == g && x.as_ref().map(discriminant) == y.as_ref().map(discriminant) } + (NetRequest::Share { server: a, file: f, create: x, .. }, NetRequest::Share { server: b, file: g, create: y, .. }) => { + a == b && f == g && x == y + } _ => false, } } @@ -343,6 +377,8 @@ impl NetRequest { pub enum NetReply { Plugins(std::result::Result), Library(std::result::Result), + /// The new links, when some were asked for. + Share(std::result::Result, String>), Upload(std::result::Result), /// What the server says about its hosting, and its answer to the request, if one was sent. Hosting(std::result::Result<(HostInfo, Option), String>), @@ -390,8 +426,9 @@ pub struct PublishOptions { /// Your name in the session (empty: the server picks one). pub me: String, pub doc: Arc, - /// Ask the server for join codes for the file's links ([`Event::Codes`]; `collab.share`). - pub codes: bool, + /// Share the file once it is there: links and join codes for others ([`Event::Shared`]; + /// `collab.share`). + pub share: bool, } /// The networking behind live sessions, installed by the app ([`Lives::install`]). It carries @@ -446,9 +483,14 @@ pub trait LiveLink: Send { ) -> std::result::Result<(HostInfo, Option), String> { Err(NO_HOSTING.into()) } + /// See [`NetRequest::Share`]; waits for the server. Errors never echo the key or the token. + fn share(&mut self, _server: &str, _file: &str, _owner: &str, _revoke: &[String], _create: bool) -> std::result::Result, String> { + Err(NO_HOSTING.into()) + } /// Send `req` and wait for the answer (through the methods above). fn request(&mut self, req: NetRequest) -> NetReply { match req { + NetRequest::Share { server, file, owner, revoke, create } => NetReply::Share(self.share(&server, &file, &owner, &revoke, create)), NetRequest::Plugins { server, req } => NetReply::Plugins(self.plugins(&server, req)), NetRequest::Library { link, req } => NetReply::Library(self.library(&link, req)), NetRequest::Upload(o) => NetReply::Upload(self.upload(o)), @@ -934,13 +976,12 @@ impl Session { } live.published = Some(p); } - Event::Codes { edit, view } => { - // Only what a server made: nine symbols each, or nothing. - let online = |c: String| code::normalize(&c).ok().filter(|c| c.len() == code::ONLINE_LEN); + Event::Shared(Ok(links)) => { if let Some(share) = live.share.as_mut() { - (share.edit, share.view) = (online(edit), online(view)); + share.links = Some(clean_shared(links)); } } + Event::Shared(Err(why)) => self.message = Some(format!("The file is on the server, but couldn't be shared: {why}")), Event::Hosting(info) => live.hosting = Some(clean_hosting(info)), Event::Reported(error) => { self.message = @@ -1426,12 +1467,19 @@ impl Session { "readOnly": l.doc_uid.is_some_and(|u| self.live_read_only(u).is_some()), "published": l.published, "code": l.share.as_ref().and_then(Share::code), - "share": l.share.as_ref().map(|sh| json!({ - "via": sh.via, - "access": sh.access.id(), - "editCode": sh.edit.as_deref().map(code::grouped), - "viewCode": sh.view.as_deref().map(code::grouped), - })), + "share": l.share.as_ref().map(|sh| { + let (edit, view) = (sh.links.as_ref().map(|s| &s.edit), sh.links.as_ref().and_then(|s| s.view.as_ref())); + let code = |l: Option<&SharedLink>| l.and_then(|l| l.code.as_deref()).map(code::grouped); + json!({ + "via": sh.via, + "access": sh.access.id(), + "shared": sh.links.is_some(), + "editCode": code(edit), + "viewCode": code(view), + "editLink": edit.map(|l| l.link.as_str()), + "viewLink": view.map(|l| l.link.as_str()), + }) + }), "hosting": l.hosting.as_ref().map(hosting_json), "follow": l.follow, "signals": l.signals, @@ -1511,14 +1559,14 @@ fn publish(s: &mut Session, p: &Value) -> Result { Ok(s.live_summary()) } -/// Start publishing the open file to `server` for command `cmd` (`codes`: asking for join codes -/// too): the new session. -fn start_publish(s: &mut Session, cmd: &str, server: &str, p: &Value, codes: bool) -> Result { +/// Start publishing the open file to `server` for command `cmd` (`share`: with links and join +/// codes for others too): the new session. +fn start_publish(s: &mut Session, cmd: &str, server: &str, p: &Value, share: bool) -> Result { let key = string(p, "key").map(|k| k.trim().to_string()).filter(|k| !k.is_empty()); let (doc, uid) = s.ds().map(|ds| (ds.doc.clone(), ds.uid))?; let name = string(p, "name").map(|n| n.trim().chars().filter(|c| !c.is_control()).take(256).collect::()).filter(|n| !n.is_empty()); let me = display_name(&s.prefs); - let opts = PublishOptions { server: server.trim().to_string(), key, name: name.unwrap_or_else(|| doc.name.clone()), me, doc, codes }; + let opts = PublishOptions { server: server.trim().to_string(), key, name: name.unwrap_or_else(|| doc.name.clone()), me, doc, share }; let (id, ()) = s.live.start(cmd, |l, id| l.publish(id, opts).map_err(|e| bad(cmd, e)))?; // This tab becomes the server file's synced document once it is there. s.live.open(Live { id, status: Status::Connecting, doc_uid: Some(uid), tab: Some(uid), ..Live::default() }); @@ -1539,14 +1587,25 @@ fn share(s: &mut Session, p: &Value) -> Result { None => None, Some(a) => Some(Access::parse(&a).ok_or_else(|| bad(CMD, "`access` must be \"edit\" or \"view\""))?), }; - // Already shared: only which code is shown changes. + // A share of this file is there already: only which code is shown changes, or (the file is + // on the server, not shared at the moment) it gets new links and codes. Nothing is published + // twice. if let Some(shared) = s.live().share.as_ref().filter(|_| s.live().status.active()).map(|sh| sh.via) { if get(p, "via").is_some() && shared != via { - return Err(bad(CMD, "this file is shared the other way already: stop sharing it first (collab.stop)")); + return Err(bad( + CMD, + "this file is shared the other way already: stop sharing it first (collab.stop), or leave its session (collab.leave)", + )); } if access == Some(Access::View) && shared == Via::Local { return Err(bad(CMD, LOCAL_EDITS)); } + if let Some(req) = share_request(s.live(), Vec::new(), true).filter(|_| s.live().share.as_ref().is_some_and(|sh| sh.links.is_none())) { + let links = share_answer(s, req)?.ok_or_else(|| EngineError::Other("the server made no links".into()))?; + if let Some(sh) = s.live_mut().and_then(|l| l.share.as_mut()) { + sh.links = Some(clean_shared(links)); + } + } if let (Some(a), Some(sh)) = (access, s.live_mut().and_then(|l| l.share.as_mut())) { sh.access = a; } @@ -1566,15 +1625,45 @@ fn share(s: &mut Session, p: &Value) -> Result { } }; if let Some(live) = s.live.sessions.get_mut(&id) { - // On the local network the code says where the session is and its secret, as its link does. - let edit = live.info.as_ref().and_then(|i| code::Local::from_link(&i.link)).map(|l| l.code()); - // Kept without a key the address may carry: stopping asks the server as the file's owner. - let server = server.split('?').next().unwrap_or_default().to_string(); - live.share = Some(Share { via, server, access, edit, view: None }); + // On the local network the session's own link is what others join with, and the code says + // what the link says: where the session is and its secret. (Online they arrive with the file.) + let links = live.info.as_ref().filter(|_| via == Via::Local).map(|i| { + let code = code::Local::from_link(&i.link).map(|l| l.code()); + Shared { edit: SharedLink { id: String::new(), link: i.link.clone(), code }, view: None } + }); + live.share = Some(Share { via, server, access, links }); } Ok(s.live_summary()) } +/// Links and codes as a server gave them, with only codes that are codes (nine symbols). +fn clean_shared(mut links: Shared) -> Shared { + let clean = |l: &mut SharedLink| l.code = l.code.take().and_then(|c| code::normalize(&c).ok()).filter(|c| c.len() == code::ONLINE_LEN); + clean(&mut links.edit); + links.view.as_mut().map(clean); + links +} + +/// The request that changes who else gets into the server file `live` is its owner's session of +/// (`None`: it isn't one, or the file isn't there yet). +fn share_request(live: &Live, revoke: Vec, create: bool) -> Option { + let (share, published) = (live.share.as_ref().filter(|s| s.via == Via::Online)?, live.published.as_ref()?); + if live.status != Status::Live { + return None; + } + let owner = published.owner.clone().unwrap_or_default(); + Some(NetRequest::Share { server: share.server.clone(), file: published.file.clone(), owner, revoke, create }) +} + +/// Send `req` ([`NetRequest::Share`]): the new links, if it made any. +fn share_answer(s: &mut Session, req: NetRequest) -> Result> { + match s.net(req)?.0 { + NetReply::Share(Ok(links)) => Ok(links), + NetReply::Share(Err(why)) => Err(EngineError::Other(why)), + _ => Err(EngineError::Other("the server answered something else".into())), + } +} + /// What the server at `server` says about its hosting, and its answer to `req` about `file` with /// its owner token (`collab.serverInfo`, `collab.manageFile`). fn hosting_request(s: &mut Session, cmd: &str, server: &str, file: &str, owner: &str, req: Option) -> Result<(HostInfo, Option)> { @@ -1669,26 +1758,18 @@ fn leave(s: &mut Session, _: &Value) -> Result { } fn stop(s: &mut Session, p: &Value) -> Result { - // A file shared online was put on the server for this: it goes with the sharing (its codes - // and links stop working; everyone keeps the copy they have). - let live = s.live(); - let remove = match (&live.share, &live.published) { - (Some(Share { via: Via::Online, server, .. }), Some(Published { file, owner: Some(owner), .. })) => Some(NetRequest::Hosting { - server: server.clone(), - file: file.clone(), - owner: owner.clone(), - req: Some(Admin::Remove { file: file.clone() }), - }), - _ => None, - }; - let out = leave(s, p)?; - // Nobody waits for the answer (job 0 is no command's): a file that stays expires on its own. - if let (Some(req), Some(link)) = (remove, s.live.link()) - && !link.background(0, req.clone()) - { - let _ = link.request(req); + // A file shared online stays its owner's on the server: stopping takes the others' links (and + // their codes) away, which disconnects whoever joined with them, and the owner stays in the + // file. Anything else (a session this app hosts, one it joined, a file not shared now) ends. + let shared = s.live().share.as_ref().and_then(|sh| sh.links.as_ref()); + let ids: Vec = shared.into_iter().flat_map(|l| [Some(&l.edit), l.view.as_ref()]).flatten().map(|l| l.id.clone()).collect(); + let Some(req) = share_request(s.live(), ids, false).filter(|_| shared.is_some()) else { return leave(s, p) }; + share_answer(s, req).map_err(|e| EngineError::Other(format!("couldn't stop sharing (the others can still get in): {e}")))?; + if let Some(sh) = s.live_mut().and_then(|l| l.share.as_mut()) { + sh.links = None; } - Ok(out) + s.message = Some("Sharing stopped: the file stays on the server, open only to you".into()); + Ok(json!({"ended": false, "shared": false})) } fn follow(s: &mut Session, p: &Value) -> Result { @@ -1751,14 +1832,14 @@ pub fn specs() -> Vec { spec!( "collab.share", "Share with a join code", - "{via?: \"online\" (default: the open file is kept on the server in Preferences, `pref.publishServer`, and the server hands out the codes) | \"local\" (this app hosts it for people on the same network; no server), access?: \"edit\" (default) | \"view\" (online only: which code `code` is), server? (online: another server's address, with ?key=… when it needs its key), name? (online: the file's name there), port?, bind?: \"lan\" (default) | \"local\" (this machine only) | an IP address (local)} → the open file's session as `collab.peers` gives it: `code` is what others type to join (`collab.join {code}`, the Join field), `link` an invite link. Local: at once, the code carries the host's IPv4 address, port and secret (K7M2Q-…, 20 symbols). Online: status \"connecting\" first; once live `code` is the server's (K7M-2QX-9TD) and `share` has both (`editCode`, `viewCode`); `code` stays null when the server is older than join codes (share `published.edit` / `published.view` instead). Called again while shared: only switches which code `code` is (`access`). `collab.stop` ends it", + "{via?: \"online\" (default: the open file is kept on the server in Preferences, `pref.publishServer`, and the server hands out the codes) | \"local\" (this app hosts it for people on the same network; no server), access?: \"edit\" (default) | \"view\" (online only: which code `code` is), server? (online: another server's address, with ?key=… when it needs its key), name? (online: the file's name there), port?, bind?: \"lan\" (default) | \"local\" (this machine only) | an IP address (local)} → the open file's session as `collab.peers` gives it: `code` is what others type to join (`collab.join {code}`, the Join field), `link` an invite link. Local: at once, the code carries the host's IPv4 address, port and secret (K7M2Q-…, 20 symbols). Online: status \"connecting\" first; once live `code` is the server's (K7M-2QX-9TD) and `share` is {via, access, shared, editCode, viewCode, editLink, viewLink}: links made for others (the file's own links in `published` stay yours); `code` stays null when the server is older than join codes (give `share.editLink` / `share.viewLink` instead). Called again while shared: only switches which code `code` is (`access`). Called after `collab.stop` on a file that is still on the server: new links and codes for that same file (nothing is published twice). `collab.stop` stops sharing", false, share ), spec!( "collab.stop", "Stop live session", - "{} (the open file's session; also leaves a joined session; a file shared online with `collab.share` is removed from the server, so its codes and links stop working: `collab.leave` keeps it there) → {ended}", + "{} → {ended}: the open file's session ends (a session this app hosts stops for everyone; a joined one is left). A file shared online with `collab.share` is different: its sharing stops → {ended: false, shared: false}: the links and codes others got are revoked on the server, whoever joined with them is disconnected at once (they keep their copy), and you stay in the file, which stays on the server, open only to you (`collab.share` shares it again with new codes; `collab.leave` leaves it there; `collab.manageFile {action: \"remove\"}` with its owner token takes it off the server)", false, stop ), @@ -1807,7 +1888,7 @@ pub fn specs() -> Vec { spec!( "collab.peers", "People in this file", - "{} → the open file's session (each open file has its own or none): {available, canHost (false in the browser: it can only join), session (its id; null: this file isn't in a session), status: off|connecting|live|reconnecting|failed, reason?, role, me, name, color, fileName, pages, link, url, address, network, access: edit|view (view: a server's view link; edits are refused), readOnly (the open file can't be edited now: view only, or being published), published (a file you published: {server, file, name, edit, view}), code (the join code to give others, when the file was shared with `collab.share`; null otherwise or while it is on its way), share ({via: online|local, access, editCode, viewCode} or null), follow, syncing, document (index of the synced open document), editing (the text layer you type in: {node, layer, caret, selection}), peers: [{id, name, color, editing (theirs, same shape, or null), presence: {cursor: [page, x, y], selection, viewport: [page, x, y, w, h], editing_text}}], sessions: [{session, document (index of its open document; null for a join on its way), status, reason, role, fileName, link, peers (how many others)}] (every session of the app)}", + "{} → the open file's session (each open file has its own or none): {available, canHost (false in the browser: it can only join), session (its id; null: this file isn't in a session), status: off|connecting|live|reconnecting|failed, reason?, role, me, name, color, fileName, pages, link, url, address, network, access: edit|view (view: a server's view link; edits are refused), readOnly (the open file can't be edited now: view only, or being published), published (a file you published: {server, file, name, edit, view}), code (the join code to give others, when the file was shared with `collab.share`; null otherwise or while it is on its way), share ({via: online|local, access, shared (false: a file of yours on a server that nobody else gets into now), editCode, viewCode, editLink, viewLink (what others join with; null when not shared)} or null), follow, syncing, document (index of the synced open document), editing (the text layer you type in: {node, layer, caret, selection}), peers: [{id, name, color, editing (theirs, same shape, or null), presence: {cursor: [page, x, y], selection, viewport: [page, x, y, w, h], editing_text}}], sessions: [{session, document (index of its open document; null for a join on its way), status, reason, role, fileName, link, peers (how many others)}] (every session of the app)}", false, |s, _| Ok(s.live_summary()) ), diff --git a/crates/engine/src/live_share_tests.rs b/crates/engine/src/live_share_tests.rs index 0b811a17..286c8c82 100644 --- a/crates/engine/src/live_share_tests.rs +++ b/crates/engine/src/live_share_tests.rs @@ -1,5 +1,6 @@ //! Sharing with a join code against a scripted link: `collab.share` online and on the local -//! network, the codes in `collab.peers`, switching what the code lets people do, stopping, and +//! network, the codes in `collab.peers`, switching what the code lets people do, stopping (online: +//! the others' links go, the file and its owner stay; sharing again makes new ones), and //! `collab.join` with a code of either kind or a link (what typing tolerates, and what it says //! when it can't be a code). @@ -7,11 +8,11 @@ use std::net::Ipv4Addr; use std::sync::{Arc, Mutex}; use figcraft_collab::code::{self, Local}; -use figcraft_collab::message::{Admin, ClientMsg}; +use figcraft_collab::message::ClientMsg; use serde_json::{Value, json}; use crate::live::{ - Access, Bind, DEFAULT_SERVER, Event, HostOptions, LiveLink, NetRequest, Presence, PublishOptions, Published, Role, SessionInfo, Status, + Access, Bind, DEFAULT_SERVER, Event, HostOptions, LiveLink, Presence, PublishOptions, Published, Role, SessionInfo, Shared, SharedLink, Status, join_target, }; use crate::{EngineError, Session}; @@ -22,7 +23,10 @@ struct Script { hosted: Vec, published: Vec, joined: Vec, - asked: Vec<(u64, NetRequest)>, + /// What the server was asked about a file's sharing: (server, file, owner, revoke, create). + asked: Vec<(String, String, String, Vec, bool)>, + /// The server can't be reached. + offline: bool, left: Vec, events: Vec, session: u64, @@ -69,9 +73,15 @@ impl LiveLink for Link { s.published.push(opts); Ok(()) } - fn background(&mut self, job: u64, req: NetRequest) -> bool { - self.0.lock().unwrap().asked.push((job, req)); - true + fn share(&mut self, server: &str, file: &str, owner: &str, revoke: &[String], create: bool) -> Result, String> { + let mut s = self.0.lock().unwrap(); + if s.offline { + return Err("no answer from the server".into()); + } + s.asked.push((server.into(), file.into(), owner.into(), revoke.to_vec(), create)); + // Each time the file is shared the server makes new links, with new codes. + let n = s.asked.iter().filter(|a| a.4).count(); + Ok(create.then(|| links(n, Some((&format!("NEWED1T0{n}"), &format!("NEWV1EW0{n}")))))) } fn leave(&mut self, session: u64) { self.0.lock().unwrap().left.push(session); @@ -100,7 +110,19 @@ fn err(s: &mut Session, cmd: &str, p: Value) -> String { s.execute(cmd, &p).unwrap_err().to_string() } -/// The file is on the server and joined: its links, then (when given) its codes. +/// The links a server makes for others the `n`th time a file is shared, with their codes (a +/// server from before join codes has none). +fn links(n: usize, codes: Option<(&str, &str)>) -> Shared { + let link = |what: &str, code: Option<&str>| SharedLink { + id: format!("{what}{n}"), + link: format!("figcraft://join?server=wss://share.example:443/&file=f1&token=Share{what}Share{what}0{n}"), + code: code.map(str::to_string), + }; + Shared { edit: link("edit", codes.map(|c| c.0)), view: Some(link("view", codes.map(|c| c.1))) } +} + +/// The file is on the server and joined: its owner's links, and what others join with (codes +/// too, when given). fn on_the_server(s: &mut Session, script: &Arc>, codes: Option<(&str, &str)>) { let link = |token: &str| format!("figcraft://join?server=wss://share.example:443/&file=f1&token={token}"); let published = Published { @@ -125,13 +147,19 @@ fn on_the_server(s: &mut Session, script: &Arc>, codes: Option<(&s network: true, access: Access::Edit, }; - let mut events = vec![Event::Published(published)]; - events.extend(codes.map(|(edit, view)| Event::Codes { edit: edit.into(), view: view.into() })); - events.push(Event::Welcome { info, peers: vec![] }); + let events = vec![Event::Published(published), Event::Shared(Ok(links(0, codes))), Event::Welcome { info, peers: vec![] }]; script.lock().unwrap().events.extend(events); s.live_poll(); } +/// The file reached the server and its owner is in it, without links for others. +fn on_the_server_unshared(s: &mut Session, script: &Arc>) { + on_the_server(s, script, None); + if let Some(sh) = s.live_mut().and_then(|l| l.share.as_mut()) { + sh.links = None; + } +} + #[test] fn the_built_in_server_is_the_default_and_can_be_changed_or_turned_off() { let (mut s, _) = session(); @@ -166,13 +194,19 @@ fn sharing_online_publishes_to_the_server_in_preferences_and_shows_its_code() { { let sc = script.lock().unwrap(); let [p] = sc.published.as_slice() else { panic!("published {}", sc.published.len()) }; - assert_eq!((p.server.as_str(), p.key.as_deref(), p.name.as_str(), p.codes), ("wss://share.example/", None, "Untitled", true)); + assert_eq!((p.server.as_str(), p.key.as_deref(), p.name.as_str(), p.share), ("wss://share.example/", None, "Untitled", true)); assert!(sc.hosted.is_empty()); } on_the_server(&mut s, &script, Some(("K7M2QX9TD", "4FHJ8R2WN"))); let r = ex(&mut s, "collab.peers", json!({})); assert_eq!((&r["status"], &r["code"]), (&json!("live"), &json!("K7M-2QX-9TD"))); - assert_eq!(r["share"], json!({"via": "online", "access": "edit", "editCode": "K7M-2QX-9TD", "viewCode": "4FH-J8R-2WN"})); + assert_eq!( + (&r["share"]["shared"], &r["share"]["editCode"], &r["share"]["viewCode"]), + (&json!(true), &json!("K7M-2QX-9TD"), &json!("4FH-J8R-2WN")) + ); + // What others get are links of their own, not the owner's. + let (edit_link, own) = (r["share"]["editLink"].as_str().unwrap(), r["published"]["edit"].as_str().unwrap()); + assert!(edit_link.contains("ShareeditShareedit00") && edit_link != own && r["link"] == own, "{edit_link}"); assert!(s.message.is_none(), "the popover shows the code: no toast about publishing"); // Asking again only switches which code is the one to give out. let r = ex(&mut s, "collab.share", json!({"access": "view"})); @@ -182,15 +216,54 @@ fn sharing_online_publishes_to_the_server_in_preferences_and_shows_its_code() { assert_eq!(script.lock().unwrap().published.len(), 1); assert!(err(&mut s, "collab.share", json!({"via": "local"})).contains("stop sharing it first")); assert!(err(&mut s, "collab.share", json!({"access": "admin"})).contains("`access` must be")); - // Stopping: the session ends and the server is asked, as the file's owner, to remove it. + // Stopping while the server can't be reached stops nothing, and says so. + script.lock().unwrap().offline = true; + assert!(err(&mut s, "collab.stop", json!({})).contains("couldn't stop sharing")); + assert_eq!(ex(&mut s, "collab.peers", json!({}))["code"], "K7M-2QX-9TD"); + script.lock().unwrap().offline = false; + // Stopping: the server is asked, as the file's owner, to revoke the others' links. The file + // stays there and its owner stays in it: nothing is left, nothing is removed. + assert_eq!(ex(&mut s, "collab.stop", json!({})), json!({"ended": false, "shared": false})); + { + let sc = script.lock().unwrap(); + assert!(sc.left.is_empty(), "the owner stays in the file"); + let want = ( + "wss://share.example/".to_string(), + "f1".to_string(), + "OwnerOwnerOwnerOwner00".to_string(), + vec!["edit0".to_string(), "view0".to_string()], + false, + ); + assert_eq!(sc.asked, [want]); + } + let r = ex(&mut s, "collab.peers", json!({})); + assert_eq!((&r["status"], &r["syncing"].is_boolean(), &r["code"]), (&json!("live"), &true, &Value::Null)); + assert_eq!( + r["share"], + json!({"via": "online", "access": "edit", "shared": false, "editCode": null, "viewCode": null, "editLink": null, "viewLink": null}) + ); + assert!(r["published"]["owner"].is_string(), "still the owner's file on the server"); + assert!(s.message.as_deref().unwrap_or_default().contains("stays on the server")); + // Sharing again: new links and codes for the same file, nothing published twice. + let r = ex(&mut s, "collab.share", json!({})); + assert_eq!((&r["code"], &r["share"]["shared"], &r["share"]["viewCode"]), (&json!("NEW-ED1-T01"), &json!(true), &json!("NEW-V1E-W01"))); + assert!(r["share"]["editLink"].as_str().unwrap().contains("ShareeditShareedit01")); + { + let sc = script.lock().unwrap(); + assert_eq!(sc.published.len(), 1); + assert_eq!(sc.asked[1], ("wss://share.example/".to_string(), "f1".to_string(), "OwnerOwnerOwnerOwner00".to_string(), vec![], true)); + } + assert_eq!(ex(&mut s, "collab.share", json!({}))["code"], "NEW-ED1-T01", "shared already: nothing more is asked"); + assert_eq!(script.lock().unwrap().asked.len(), 2); + // Stopping again revokes the new ones; then `collab.stop` on a file that isn't shared leaves + // its session like any other (the file stays on the server). + ex(&mut s, "collab.stop", json!({})); + assert_eq!(script.lock().unwrap().asked[2].3, ["edit1", "view1"]); assert_eq!(ex(&mut s, "collab.stop", json!({}))["ended"], true); - let sc = script.lock().unwrap(); - assert_eq!(sc.left, vec![1]); - let [(0, NetRequest::Hosting { server, file, owner, req: Some(Admin::Remove { file: removed }) })] = sc.asked.as_slice() else { - panic!("asked {:?}", sc.asked) - }; - assert_eq!((server.as_str(), file.as_str(), owner.as_str(), removed.as_str()), ("wss://share.example/", "f1", "OwnerOwnerOwnerOwner00", "f1")); - drop(sc); + { + let sc = script.lock().unwrap(); + assert_eq!((sc.left.clone(), sc.asked.len()), (vec![1], 3)); + } let r = ex(&mut s, "collab.peers", json!({})); assert_eq!((&r["status"], &r["code"], &r["share"]), (&json!("off"), &Value::Null, &Value::Null)); } @@ -206,15 +279,29 @@ fn a_server_without_join_codes_leaves_the_links() { on_the_server(&mut s, &script, None); let r = ex(&mut s, "collab.peers", json!({})); assert_eq!((&r["status"], &r["code"], &r["share"]["editCode"]), (&json!("live"), &Value::Null, &Value::Null)); - assert!(r["published"]["edit"].as_str().unwrap().contains("token="), "the links are what there is to share"); + assert!(r["share"]["editLink"].as_str().unwrap().contains("token="), "the links are what there is to share"); assert!(!r.to_string().contains("KeyKey"), "the server's key is nowhere in the summary"); // Codes that aren't codes are not shown either. - script.lock().unwrap().events.push(Event::Codes { edit: "