mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: npub1k766u8wq088nkr9vy94uqlh9kmw5956g593a7xk7cks7frhgctmsr00p27 <b7b5ae1dc079cf3b0cac216bc07ee5b6dd42d348a163df1adec5a1e48ee8c2f7@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1k766u8wq088nkr9vy94uqlh9kmw5956g593a7xk7cks7frhgctmsr00p27 <b7b5ae1dc079cf3b0cac216bc07ee5b6dd42d348a163df1adec5a1e48ee8c2f7@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
405 lines
19 KiB
Rust
405 lines
19 KiB
Rust
use serde::Serialize;
|
|
use tauri::Emitter;
|
|
use url::Url;
|
|
|
|
use crate::nostr_bind;
|
|
|
|
/// 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<serde_json::Value> {
|
|
let mut channel: Option<String> = None;
|
|
let mut message_id: Option<String> = None;
|
|
let mut thread: Option<String> = 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<serde_json::Value> {
|
|
let mut relay: Option<String> = None;
|
|
let mut code: Option<String> = 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,
|
|
}
|
|
|
|
fn non_empty_param(url: &Url, name: &str) -> Result<String, String> {
|
|
url.query_pairs()
|
|
.find(|(key, _)| key == name)
|
|
.map(|(_, value)| value.into_owned())
|
|
.filter(|value| !value.is_empty())
|
|
.ok_or_else(|| format!("missing {name}"))
|
|
}
|
|
|
|
fn parse_nostr_bind_deep_link(url: &Url) -> Result<NostrBindDeepLinkPayload, String> {
|
|
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")?;
|
|
|
|
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());
|
|
}
|
|
|
|
Ok(NostrBindDeepLinkPayload {
|
|
challenge_id,
|
|
nonce,
|
|
verification_code,
|
|
audience,
|
|
action,
|
|
protocol,
|
|
version,
|
|
origin,
|
|
expires_at,
|
|
return_mode,
|
|
})
|
|
}
|
|
|
|
/// Handle an incoming `buzz://` deep link URL.
|
|
///
|
|
/// Currently supports:
|
|
/// - `buzz://connect?relay=<ws(s)://...>` — 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;
|
|
}
|
|
}
|
|
let _ = app.emit("deep-link-connect", relay_url);
|
|
}
|
|
Some("join") => {
|
|
// `buzz://join?relay=<ws(s)://...>&code=<invite code>` — fired by
|
|
// the relay's /invite/<code> 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;
|
|
};
|
|
let _ = app.emit("deep-link-join", payload);
|
|
}
|
|
Some("message") => {
|
|
// `buzz://message?channel=<uuid>&id=<eventId>[&thread=<rootId>]`
|
|
//
|
|
// 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;
|
|
};
|
|
let _ = app.emit("deep-link-message", payload);
|
|
}
|
|
Some("nostr-bind") => match parse_nostr_bind_deep_link(&url) {
|
|
Ok(payload) => {
|
|
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");
|
|
}
|
|
|
|
#[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");
|
|
}
|
|
}
|