Skip to main content

qualia_client_core/
magic_link.rs

1//! **Magic link** — encode/decode a [`ConnectionIdentifier`] as a shareable link for email/text/web
2//! onboarding. A single connection payload can travel three interchangeable ways, all decoded by
3//! [`from_link`]:
4//!
5//! 1. A custom-scheme **deep link** (`web+qualia://connect?p=<payload>`) that opens the app directly.
6//! 2. A **domain-hosted HTTPS fallback** (`https://<domain>/w#<payload>`) — a normal link that works in
7//!    any browser and, on a device with the app registered, hands off to it. The payload rides in the
8//!    URL `#` fragment so it is never sent to the hosting server (fragments are client-only).
9//! 3. A **bare** `qcx1_…` string (paste-anywhere).
10//!
11//! The payload in every form is the self-certifying `qcx1_<base64url>` from
12//! [`ConnectionIdentifier::encode`]; recipients verify it locally, so the link carries no trust in the
13//! transport. base64url is already URL-safe, but we percent-decode defensively on the way in.
14
15use crate::connection_identifier::ConnectionIdentifier;
16
17/// Custom URL scheme registered by the app for deep links.
18pub const SCHEME: &str = "web+qualia";
19
20/// Build a custom-scheme **deep link** that opens the app directly:
21/// `web+qualia://connect?p=<id.encode()?>`.
22///
23/// The payload is base64url (URL-safe) so it is placed in the query verbatim.
24pub fn to_deep_link(id: &ConnectionIdentifier) -> Result<String, String> {
25    let payload = id.encode()?;
26    Ok(format!("{SCHEME}://connect?p={payload}"))
27}
28
29/// Build a **domain-hosted HTTPS fallback** link: `https://<domain>/w#<id.encode()?>`.
30///
31/// This is an ordinary `https://` URL that works in any browser; on a device where the app has
32/// claimed the domain it opens the app instead. The payload rides in the `#` fragment, which browsers
33/// never transmit to the server — so the hosting domain sees only that `/w` was requested, not the
34/// connection payload.
35pub fn to_https_link(id: &ConnectionIdentifier, domain: &str) -> Result<String, String> {
36    let payload = id.encode()?;
37    Ok(format!("https://{domain}/w#{payload}"))
38}
39
40/// Decode a [`ConnectionIdentifier`] from ANY supported link form:
41///
42/// - `web+qualia://connect?p=<payload>` — the deep link (payload is the `p=` query value).
43/// - `https://<domain>/w#<payload>` — the HTTPS fallback (payload is the `#` fragment).
44/// - a bare `qcx1_…` string.
45///
46/// The extracted payload is percent-decoded defensively (base64url is URL-safe, but a `%`-escaped
47/// payload is still accepted) before [`ConnectionIdentifier::decode`].
48pub fn from_link(link: &str) -> Result<ConnectionIdentifier, String> {
49    let link = link.trim();
50
51    let payload = if let Some(rest) = link.strip_prefix(&format!("{SCHEME}://")) {
52        // web+qualia://connect?p=<payload>[&...]  — extract the `p` query value.
53        let query = rest.split_once('?').map(|(_, q)| q).unwrap_or("");
54        query
55            .split('&')
56            .find_map(|kv| kv.strip_prefix("p="))
57            .ok_or("deep link missing `p=` payload")?
58            .to_string()
59    } else if let Some((_, frag)) = link.split_once('#') {
60        // https://<domain>/w#<payload>  — payload is the fragment.
61        if frag.is_empty() {
62            return Err("https link has an empty `#` fragment".into());
63        }
64        frag.to_string()
65    } else if link.starts_with("qcx1_") {
66        // Bare identifier string.
67        link.to_string()
68    } else {
69        return Err("unrecognised link: expected a web+qualia:// deep link, an https://…/w# link, or a bare qcx1_ string".into());
70    };
71
72    let payload = pct_decode(&payload);
73    ConnectionIdentifier::decode(&payload)
74}
75
76/// Build a `mailto:` URI that pre-fills an onboarding email: a short human line plus the deep link in
77/// the body. Both subject and body are percent-encoded.
78///
79/// `mailto:?subject=<pct>&body=<pct>`
80pub fn to_mailto(id: &ConnectionIdentifier, subject: &str) -> Result<String, String> {
81    let deep = to_deep_link(id)?;
82    let body =
83        format!("You've been invited to connect. Open this link on your device:\n\n{deep}\n");
84    Ok(format!(
85        "mailto:?subject={}&body={}",
86        pct_encode(subject),
87        pct_encode(&body)
88    ))
89}
90
91/// Percent-encode a string for use in a URL query/`mailto` component. Uses the `urlencoding` crate
92/// (a declared dependency) so the escaping is RFC 3986 compliant.
93fn pct_encode(s: &str) -> String {
94    urlencoding::encode(s).into_owned()
95}
96
97/// Percent-decode a string. Invalid `%`-escapes are left as-is (lossy, defensive): base64url payloads
98/// contain no `%`, so a payload with no escapes round-trips unchanged.
99fn pct_decode(s: &str) -> String {
100    match urlencoding::decode(s) {
101        Ok(decoded) => decoded.into_owned(),
102        Err(_) => s.to_string(),
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    fn sample() -> ConnectionIdentifier {
111        ConnectionIdentifier {
112            version: 1,
113            front_door_did: "did:x".into(),
114            identity_pubkey_hex: String::new(),
115            wireguard_pubkey_hex: "aa".repeat(32),
116            overlay_addr: "fd00::1".into(),
117            rendezvous: vec![],
118            relation_type: String::new(),
119            display_name: "Alice".into(),
120            created_at: 1,
121            expires_at: 0,
122            nonce: "n".into(),
123            signature_hex: String::new(),
124        }
125    }
126
127    #[test]
128    fn deep_link_round_trips() {
129        let id = sample();
130        let link = to_deep_link(&id).expect("deep link");
131        assert!(link.starts_with("web+qualia://connect?p="));
132        let back = from_link(&link).expect("decode deep link");
133        assert_eq!(back, id, "deep link round-trips to an equal identifier");
134    }
135
136    #[test]
137    fn https_link_round_trips() {
138        let id = sample();
139        let link = to_https_link(&id, "alice.example").expect("https link");
140        assert!(link.starts_with("https://alice.example/w#"));
141        let back = from_link(&link).expect("decode https link");
142        assert_eq!(back, id, "https link round-trips to an equal identifier");
143    }
144
145    #[test]
146    fn bare_identifier_is_accepted() {
147        let id = sample();
148        let bare = id.encode().expect("encode");
149        assert!(bare.starts_with("qcx1_"));
150        let back = from_link(&bare).expect("decode bare identifier");
151        assert_eq!(back, id, "a bare qcx1_ string is accepted");
152    }
153
154    #[test]
155    fn mailto_contains_the_deep_link() {
156        let id = sample();
157        let deep = to_deep_link(&id).expect("deep link");
158        let mailto = to_mailto(&id, "Connect with Alice").expect("mailto");
159        assert!(mailto.starts_with("mailto:?subject="));
160        assert!(mailto.contains("&body="));
161        // The deep link survives percent-encoding into the body.
162        let encoded_deep = pct_encode(&deep);
163        assert!(
164            mailto.contains(&encoded_deep),
165            "mailto body contains the (percent-encoded) deep link"
166        );
167    }
168
169    #[test]
170    fn percent_escaped_payload_is_decoded_defensively() {
171        // base64url is URL-safe, but a payload that arrives percent-escaped must still decode.
172        let id = sample();
173        let payload = id.encode().expect("encode");
174        // Escape every 'a' as %61 to force the percent-decode path.
175        let escaped = payload.replace('a', "%61");
176        let link = format!("{SCHEME}://connect?p={escaped}");
177        let back = from_link(&link).expect("decode percent-escaped payload");
178        assert_eq!(back, id, "percent-escaped payload decodes");
179    }
180
181    #[test]
182    fn unrecognised_link_is_rejected() {
183        assert!(from_link("ftp://nope").is_err());
184        assert!(from_link("just some text").is_err());
185    }
186}