diff --git a/apps/figcraft-web/src/main.rs b/apps/figcraft-web/src/main.rs index 35aebb02..7eed2ffb 100644 --- a/apps/figcraft-web/src/main.rs +++ b/apps/figcraft-web/src/main.rs @@ -13,10 +13,12 @@ //! (browsers block `ws://` from secure pages); serve it over `http://` on a trusted network to //! join from other machines. //! -//! URL query: `?webgl` forces the WebGL2 backend instead of WebGPU; `?join=` joins that live session once the page has loaded, so a bookmark opens a shared file. -//! `join` takes the rest of the query: put it last. The link carries the session's secret, so the -//! URL (and the browser history) is as private as the link itself. +//! URL query flag: `?webgl` forces the WebGL2 backend instead of WebGPU. +//! +//! URL fragment: `#join=` joins that live session once the page has +//! loaded, so a bookmark opens a shared file. `join` takes the rest of the fragment: put it last. +//! The link carries the session's secret; a fragment is never sent to the web host, but the address +//! bar and the browser history keep it, so the URL is as private as the link itself. #[cfg(any(target_arch = "wasm32", test))] mod query; diff --git a/apps/figcraft-web/src/query.rs b/apps/figcraft-web/src/query.rs index be68f598..befeb36b 100644 --- a/apps/figcraft-web/src/query.rs +++ b/apps/figcraft-web/src/query.rs @@ -1,10 +1,15 @@ -//! The page's URL query: which flags it sets and the invite link it names. +//! The page's URL: the invite link its fragment names. -/// The invite link in `?join=` (`search` is `location.search`, with or without its `?`), still -/// escaped. `join` takes the rest of the query, so a pasted `figcraft://join?host=…&file=…&token=…` -/// keeps its own `&`s; put it last. -pub fn join_link(search: &str) -> Option<&str> { - let query = search.strip_prefix('?').unwrap_or(search); - let start = if query.starts_with("join=") { 0 } else { query.find("&join=")? + 1 }; - query.get(start + "join=".len()..).filter(|link| !link.is_empty()) +/// The invite link in `#join=` (`fragment` is `location.hash`, with or without its `#`), still +/// escaped. The fragment, unlike the query, never leaves the browser, so the link's secret stays out +/// of the web host's logs and `Referer` headers. `join` takes the rest of the fragment, so a pasted +/// `figcraft://join?host=…&file=…&token=…` keeps its own `&`s. Anything longer than an escaped invite +/// link can be is ignored; what is left is checked by `collab.join` like a link the desktop app is +/// opened with. +pub fn join_link(fragment: &str) -> Option<&str> { + // An invite link is at most 2 KiB; every byte of it escaped is three. + const MAX: usize = 3 * 2048; + let fragment = fragment.strip_prefix('#').unwrap_or(fragment); + let start = if fragment.starts_with("join=") { 0 } else { fragment.find("&join=")? + 1 }; + fragment.get(start + "join=".len()..).filter(|link| !link.is_empty() && link.len() <= MAX) } diff --git a/apps/figcraft-web/src/query_tests.rs b/apps/figcraft-web/src/query_tests.rs index 9582682f..261ebaeb 100644 --- a/apps/figcraft-web/src/query_tests.rs +++ b/apps/figcraft-web/src/query_tests.rs @@ -1,21 +1,29 @@ -//! `?join=` in the page's URL. +//! `#join=` in the page's URL. use crate::query::join_link; #[test] -fn join_takes_the_rest_of_the_query() { +fn join_takes_the_rest_of_the_fragment() { let link = "figcraft://join?host=192.168.1.20:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv"; - assert_eq!(join_link(&format!("?join={link}")), Some(link), "a pasted link keeps its own &s"); - assert_eq!(join_link(&format!("?webgl&join={link}")), Some(link), "after other flags"); - assert_eq!(join_link("?join=figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), Some("figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), "escaped, decoded later"); - assert_eq!(join_link("join=K7M-2QX-9TD"), Some("K7M-2QX-9TD"), "a join code, no leading ?"); + assert_eq!(join_link(&format!("#join={link}")), Some(link), "a pasted link keeps its own &s"); + assert_eq!(join_link(&format!("#x=1&join={link}")), Some(link), "after other entries"); + assert_eq!(join_link("#join=figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), Some("figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), "escaped, decoded later"); + assert_eq!(join_link("join=K7M-2QX-9TD"), Some("K7M-2QX-9TD"), "a join code, no leading #"); } #[test] -fn no_join_without_the_parameter() { +fn no_join_without_the_entry() { assert_eq!(join_link(""), None); - assert_eq!(join_link("?webgl"), None); - assert_eq!(join_link("?join="), None); - assert_eq!(join_link("?rejoin=x"), None, "only the whole parameter name"); - assert_eq!(join_link("?xjoin=x&a=1"), None); + assert_eq!(join_link("#"), None); + assert_eq!(join_link("#join="), None); + assert_eq!(join_link("#rejoin=x"), None, "only the whole entry name"); + assert_eq!(join_link("#xjoin=x&a=1"), None); + assert_eq!(join_link("?join=x"), None, "the query isn't read: it goes to the web host"); +} + +#[test] +fn an_overlong_link_is_ignored() { + let long = format!("#join=figcraft://join?host=h&file=f&token={}", "a".repeat(7000)); + assert_eq!(join_link(&long), None); + assert_eq!(join_link(&format!("#join={}", "é".repeat(4000))), None, "length counts bytes"); } diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index d3c16441..29213621 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -41,9 +41,9 @@ pub fn start() { let ctx = cc.egui_ctx.clone(); session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint())))); let mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() })); - // `?join=`: join that session, as opening an invite link does on the desktop. - let search = query(); - if let Some(link) = crate::query::join_link(&search) { + // `#join=`: join that session, as opening an invite link does on the desktop. + let hash = web_sys::window().and_then(|w| w.location().hash().ok()).unwrap_or_default(); + if let Some(link) = crate::query::join_link(&hash) { let link = js_sys::decode_uri_component(link).ok().and_then(|s| s.as_string()).unwrap_or_else(|| link.to_string()); figcraft_ui_egui::live::open_invite(&mut fig, &link); }