use serde::Serialize; use tauri::{Emitter, Manager}; use url::Url; use crate::nostr_bind; fn activate_main_window(app: &tauri::AppHandle) { let Some(window) = app.get_webview_window("main") else { return; }; if let Err(error) = window.unminimize() { eprintln!("buzz-desktop: failed to unminimize main window for deep link: {error}"); } if let Err(error) = window.show() { eprintln!("buzz-desktop: failed to show main window for deep link: {error}"); } if let Err(error) = window.set_focus() { eprintln!("buzz-desktop: failed to focus main window for deep link: {error}"); } } /// Parse the query string of a `buzz://message?…` URL into the JSON /// payload emitted on `deep-link-message`. Returns `None` when a required /// param (`channel`, `id`) is missing or empty — mirroring the validation /// policy of the `connect` arm so the frontend never sees a half-formed /// payload (e.g. `channelId: ""` from `channel=&id=foo`). /// /// Pulled out of `handle_deep_link_url` so it can be unit-tested without /// a live `tauri::AppHandle`. fn parse_message_deep_link(url: &Url) -> Option { let mut channel: Option = None; let mut message_id: Option = None; let mut thread: Option = None; for (k, v) in url.query_pairs() { let v = v.into_owned(); if v.is_empty() { continue; } match k.as_ref() { "channel" => channel = Some(v), "id" => message_id = Some(v), "thread" => thread = Some(v), _ => {} } } let (channel_id, message_id) = (channel?, message_id?); Some(serde_json::json!({ "channelId": channel_id, "messageId": message_id, "threadRootId": thread, })) } /// Parse the query string of a `buzz://join?…` URL into the JSON payload /// emitted on `deep-link-join`. Requires a ws(s) `relay` URL and a non-empty /// `code`; returns `None` otherwise so the frontend never sees a half-formed /// payload. fn parse_join_deep_link(url: &Url) -> Option { let mut relay: Option = None; let mut code: Option = None; for (k, v) in url.query_pairs() { let v = v.into_owned(); if v.is_empty() { continue; } match k.as_ref() { "relay" => relay = Some(v), "code" => code = Some(v), _ => {} } } let (relay_url, code) = (relay?, code?); match Url::parse(&relay_url) { Ok(parsed) if parsed.scheme() == "ws" || parsed.scheme() == "wss" => {} _ => return None, } Some(serde_json::json!({ "relayUrl": relay_url, "code": code, })) } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] struct NostrBindDeepLinkPayload { challenge_id: String, nonce: String, verification_code: String, audience: String, action: String, protocol: String, version: String, origin: String, expires_at: String, return_mode: String, callback_url: Option, } fn non_empty_param(url: &Url, name: &str) -> Result { url.query_pairs() .find(|(key, _)| key == name) .map(|(_, value)| value.into_owned()) .filter(|value| !value.is_empty()) .ok_or_else(|| format!("missing {name}")) } fn optional_non_empty_param(url: &Url, name: &str) -> Option { url.query_pairs() .find(|(key, _)| key == name) .map(|(_, value)| value.into_owned()) .filter(|value| !value.is_empty()) } fn validate_nostr_bind_callback_url(callback_url: &str, origin: &str) -> Result<(), String> { let callback = Url::parse(callback_url).map_err(|error| format!("invalid callback_url: {error}"))?; let origin = Url::parse(origin).map_err(|error| format!("invalid origin: {error}"))?; if callback.scheme() != "https" { return Err("callback_url must use https".into()); } if callback.host_str().is_none() { return Err("callback_url missing host".into()); } if !callback.username().is_empty() || callback.password().is_some() { return Err("callback_url must not include credentials".into()); } if callback.scheme() != origin.scheme() || callback.host_str() != origin.host_str() || callback.port_or_known_default() != origin.port_or_known_default() { return Err("callback_url must match origin".into()); } Ok(()) } fn parse_nostr_bind_deep_link(url: &Url) -> Result { let challenge_id = non_empty_param(url, "challenge_id")?; let nonce = non_empty_param(url, "nonce")?; let verification_code = non_empty_param(url, "verification_code")?; let audience = non_empty_param(url, "audience")?; let action = non_empty_param(url, "action")?; let protocol = non_empty_param(url, "protocol")?; let version = non_empty_param(url, "version")?; let origin = non_empty_param(url, "origin")?; let expires_at = non_empty_param(url, "expires_at")?; let return_mode = non_empty_param(url, "return")?; let callback_url = optional_non_empty_param(url, "callback_url"); nostr_bind::validate_challenge_id(&challenge_id)?; nostr_bind::validate_nonce(&nonce)?; nostr_bind::validate_verification_code(&verification_code)?; nostr_bind::validate_protocol_fields(&audience, &action, &protocol, &version)?; nostr_bind::validate_origin(&origin)?; // Expired links still reach the consent surface so the user gets an explicit // failure instead of a silent stderr-only rejection from a launched app. nostr_bind::validate_expires_at_format(&expires_at)?; if return_mode != nostr_bind::RETURN_MODE { return Err("unsupported return mode".into()); } if let Some(callback_url) = callback_url.as_deref() { validate_nostr_bind_callback_url(callback_url, &origin)?; } Ok(NostrBindDeepLinkPayload { challenge_id, nonce, verification_code, audience, action, protocol, version, origin, expires_at, return_mode, callback_url, }) } /// Handle an incoming `buzz://` deep link URL. /// /// Currently supports: /// - `buzz://connect?relay=` — emits `deep-link-connect` to the frontend pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { let url = match Url::parse(url_str) { Ok(u) => u, Err(e) => { eprintln!("buzz-desktop: invalid deep link URL {url_str:?}: {e}"); return; } }; if url.scheme() != "buzz" { eprintln!("buzz-desktop: ignoring unsupported deep link scheme: {url_str}"); return; } match url.host_str() { Some("connect") => { let relay = url .query_pairs() .find(|(k, _)| k == "relay") .map(|(_, v)| v.into_owned()); let Some(relay_url) = relay else { eprintln!("buzz-desktop: connect deep link missing relay param: {url_str}"); return; }; // Validate the relay URL is ws:// or wss:// match Url::parse(&relay_url) { Ok(parsed) if parsed.scheme() == "ws" || parsed.scheme() == "wss" => {} Ok(parsed) => { eprintln!( "buzz-desktop: rejecting non-websocket relay URL scheme {:?}: {relay_url}", parsed.scheme() ); return; } Err(e) => { eprintln!("buzz-desktop: invalid relay URL {relay_url:?}: {e}"); return; } } activate_main_window(app); let _ = app.emit("deep-link-connect", relay_url); } Some("join") => { // `buzz://join?relay=&code=` — fired by // the relay's /invite/ landing page. The frontend claims the // invite against the relay's HTTP API, then adds the workspace. let Some(payload) = parse_join_deep_link(&url) else { eprintln!("buzz-desktop: join deep link missing/invalid relay or code: {url_str}"); return; }; activate_main_window(app); let _ = app.emit("deep-link-join", payload); } Some("message") => { // `buzz://message?channel=&id=[&thread=]` // // Validation policy mirrors the `connect` arm: parse what we // need, refuse to emit anything if a required param is missing // so the frontend never sees a half-formed payload. The // frontend listener mirrors `parseMessageLink` in TS — we keep // structure on this side (serde JSON) and let the TS code own // any further normalisation. let Some(payload) = parse_message_deep_link(&url) else { eprintln!("buzz-desktop: message deep link missing channel or id: {url_str}"); return; }; activate_main_window(app); let _ = app.emit("deep-link-message", payload); } Some("nostr-bind") => match parse_nostr_bind_deep_link(&url) { Ok(payload) => { activate_main_window(app); let _ = app.emit("deep-link-nostr-bind", payload); } Err(error) => { eprintln!("buzz-desktop: rejecting nostr-bind deep link: {error}: {url_str}"); } }, Some(action) => { eprintln!("buzz-desktop: unknown deep link action: {action}"); } None => { eprintln!("buzz-desktop: deep link missing action: {url_str}"); } } } #[cfg(test)] mod tests { use url::Url; use super::{parse_join_deep_link, parse_message_deep_link, parse_nostr_bind_deep_link}; fn valid_nostr_bind_url() -> Url { Url::parse( "buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard", ) .unwrap() } #[test] fn parse_message_deep_link_extracts_required_params() { let url = Url::parse("buzz://message?channel=abc&id=xyz").unwrap(); let payload = parse_message_deep_link(&url).expect("required params present"); assert_eq!(payload["channelId"], "abc"); assert_eq!(payload["messageId"], "xyz"); assert!(payload["threadRootId"].is_null()); } #[test] fn parse_message_deep_link_accepts_buzz_scheme() { let url = Url::parse("buzz://message?channel=abc&id=xyz").unwrap(); let payload = parse_message_deep_link(&url).expect("required params present"); assert_eq!(payload["channelId"], "abc"); assert_eq!(payload["messageId"], "xyz"); } #[test] fn parse_message_deep_link_includes_thread_root() { let url = Url::parse("buzz://message?channel=abc&id=xyz&thread=root1").unwrap(); let payload = parse_message_deep_link(&url).expect("required params present"); assert_eq!(payload["threadRootId"], "root1"); } #[test] fn parse_message_deep_link_rejects_missing_id() { let url = Url::parse("buzz://message?channel=abc").unwrap(); assert!(parse_message_deep_link(&url).is_none()); } #[test] fn parse_message_deep_link_rejects_empty_channel() { // Regression: `channel=&id=foo` previously produced channelId: "". let url = Url::parse("buzz://message?channel=&id=foo").unwrap(); assert!(parse_message_deep_link(&url).is_none()); } #[test] fn parse_message_deep_link_rejects_empty_id() { let url = Url::parse("buzz://message?channel=abc&id=").unwrap(); assert!(parse_message_deep_link(&url).is_none()); } #[test] fn parse_message_deep_link_treats_empty_thread_as_absent() { let url = Url::parse("buzz://message?channel=abc&id=xyz&thread=").unwrap(); let payload = parse_message_deep_link(&url).expect("required params present"); assert!(payload["threadRootId"].is_null()); } #[test] fn parse_join_deep_link_extracts_relay_and_code() { let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example&code=abc.def").unwrap(); let payload = parse_join_deep_link(&url).expect("required params present"); assert_eq!(payload["relayUrl"], "wss://relay.example"); assert_eq!(payload["code"], "abc.def"); } #[test] fn parse_join_deep_link_rejects_missing_code() { let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example").unwrap(); assert!(parse_join_deep_link(&url).is_none()); } #[test] fn parse_join_deep_link_rejects_empty_code() { let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example&code=").unwrap(); assert!(parse_join_deep_link(&url).is_none()); } #[test] fn parse_join_deep_link_rejects_missing_relay() { let url = Url::parse("buzz://join?code=abc.def").unwrap(); assert!(parse_join_deep_link(&url).is_none()); } #[test] fn parse_join_deep_link_rejects_non_websocket_relay() { let url = Url::parse("buzz://join?relay=https%3A%2F%2Frelay.example&code=abc.def").unwrap(); assert!(parse_join_deep_link(&url).is_none()); } #[test] fn parse_nostr_bind_deep_link_accepts_valid_url() { let payload = parse_nostr_bind_deep_link(&valid_nostr_bind_url()).unwrap(); assert_eq!(payload.challenge_id, "550e8400-e29b-41d4-a716-446655440000"); assert_eq!(payload.nonce, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567"); assert_eq!(payload.verification_code, "123456"); assert_eq!(payload.audience, "buzz:nostr-identity"); assert_eq!(payload.action, "bind_nostr_identity"); assert_eq!(payload.protocol, "buzz-nostr-identity"); assert_eq!(payload.version, "1"); assert_eq!(payload.origin, "https://example.com"); assert_eq!(payload.expires_at, "2999-01-01T00:00:00Z"); assert_eq!(payload.return_mode, "clipboard"); assert_eq!(payload.callback_url, None); } #[test] fn parse_nostr_bind_deep_link_accepts_same_origin_callback_url() { let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=https%3A%2F%2Fexample.com%2Fbuzz%3FmockSession%3D1").unwrap(); let payload = parse_nostr_bind_deep_link(&url).unwrap(); assert_eq!( payload.callback_url.as_deref(), Some("https://example.com/buzz?mockSession=1") ); } #[test] fn parse_nostr_bind_deep_link_rejects_cross_origin_callback_url() { let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=https%3A%2F%2Fevil.example%2Fbuzz").unwrap(); assert!(parse_nostr_bind_deep_link(&url).is_err()); } #[test] fn parse_nostr_bind_deep_link_rejects_http_callback_url() { let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=http%3A%2F%2Fexample.com%2Fbuzz").unwrap(); assert!(parse_nostr_bind_deep_link(&url).is_err()); } #[test] fn parse_nostr_bind_deep_link_rejects_missing_challenge_id() { let url = Url::parse("buzz://nostr-bind?nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); assert!(parse_nostr_bind_deep_link(&url).is_err()); } #[test] fn parse_nostr_bind_deep_link_rejects_empty_nonce() { let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); assert!(parse_nostr_bind_deep_link(&url).is_err()); } #[test] fn parse_nostr_bind_deep_link_rejects_missing_verification_code() { let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); assert!(parse_nostr_bind_deep_link(&url).is_err()); } #[test] fn parse_nostr_bind_deep_link_rejects_short_verification_code() { let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=12345&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); assert!(parse_nostr_bind_deep_link(&url).is_err()); } #[test] fn parse_nostr_bind_deep_link_rejects_long_verification_code() { let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=1234567&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); assert!(parse_nostr_bind_deep_link(&url).is_err()); } #[test] fn parse_nostr_bind_deep_link_rejects_non_digit_verification_code() { let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=12345a&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); assert!(parse_nostr_bind_deep_link(&url).is_err()); } #[test] fn parse_nostr_bind_deep_link_rejects_wrong_action() { let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=wrong&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); assert!(parse_nostr_bind_deep_link(&url).is_err()); } #[test] fn parse_nostr_bind_deep_link_rejects_wrong_audience() { let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=other&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); assert!(parse_nostr_bind_deep_link(&url).is_err()); } #[test] fn parse_nostr_bind_deep_link_rejects_non_https_origin() { let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=http%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); assert!(parse_nostr_bind_deep_link(&url).is_err()); } #[test] fn parse_nostr_bind_deep_link_rejects_origin_with_path() { let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com%2Fbind&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); assert!(parse_nostr_bind_deep_link(&url).is_err()); } #[test] fn parse_nostr_bind_deep_link_rejects_origin_with_credentials() { let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fuser%40example.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); assert!(parse_nostr_bind_deep_link(&url).is_err()); } #[test] fn parse_nostr_bind_deep_link_rejects_unsupported_return_mode() { let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=callback").unwrap(); assert!(parse_nostr_bind_deep_link(&url).is_err()); } #[test] fn parse_nostr_bind_deep_link_accepts_expired_link_for_user_facing_error() { let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2000-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); let payload = parse_nostr_bind_deep_link(&url).unwrap(); assert_eq!(payload.expires_at, "2000-01-01T00:00:00Z"); } }