deltachat/
qr.rs

1//! # QR code module.
2
3mod dclogin_scheme;
4use std::collections::BTreeMap;
5use std::sync::LazyLock;
6
7use anyhow::{Context as _, Result, anyhow, bail, ensure};
8pub use dclogin_scheme::LoginOptions;
9use deltachat_contact_tools::{ContactAddress, addr_normalize, may_be_valid_addr};
10use percent_encoding::{NON_ALPHANUMERIC, percent_decode_str, percent_encode};
11use serde::Deserialize;
12
13pub(crate) use self::dclogin_scheme::configure_from_login_qr;
14use crate::config::Config;
15use crate::contact::{Contact, ContactId, Origin};
16use crate::context::Context;
17use crate::events::EventType;
18use crate::key::Fingerprint;
19use crate::net::http::post_empty;
20use crate::net::proxy::{DEFAULT_SOCKS_PORT, ProxyConfig};
21use crate::token;
22use crate::tools::validate_id;
23
24const OPENPGP4FPR_SCHEME: &str = "OPENPGP4FPR:"; // yes: uppercase
25const IDELTACHAT_SCHEME: &str = "https://i.delta.chat/#";
26const IDELTACHAT_NOSLASH_SCHEME: &str = "https://i.delta.chat#";
27const DCACCOUNT_SCHEME: &str = "DCACCOUNT:";
28pub(super) const DCLOGIN_SCHEME: &str = "DCLOGIN:";
29const TG_SOCKS_SCHEME: &str = "https://t.me/socks";
30const MAILTO_SCHEME: &str = "mailto:";
31const MATMSG_SCHEME: &str = "MATMSG:";
32const VCARD_SCHEME: &str = "BEGIN:VCARD";
33const SMTP_SCHEME: &str = "SMTP:";
34const HTTPS_SCHEME: &str = "https://";
35const SHADOWSOCKS_SCHEME: &str = "ss://";
36
37/// Backup transfer based on iroh-net.
38pub(crate) const DCBACKUP_SCHEME_PREFIX: &str = "DCBACKUP";
39
40/// Version written to Backups and Backup-QR-Codes.
41/// Imports will fail when they have a larger version.
42pub(crate) const DCBACKUP_VERSION: i32 = 3;
43
44/// Scanned QR code.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum Qr {
47    /// Ask the user whether to verify the contact.
48    ///
49    /// If the user agrees, pass this QR code to [`crate::securejoin::join_securejoin`].
50    AskVerifyContact {
51        /// ID of the contact.
52        contact_id: ContactId,
53
54        /// Fingerprint of the contact key as scanned from the QR code.
55        fingerprint: Fingerprint,
56
57        /// Invite number.
58        invitenumber: String,
59
60        /// Authentication code.
61        authcode: String,
62    },
63
64    /// Ask the user whether to join the group.
65    AskVerifyGroup {
66        /// Group name.
67        grpname: String,
68
69        /// Group ID.
70        grpid: String,
71
72        /// ID of the contact.
73        contact_id: ContactId,
74
75        /// Fingerprint of the contact key as scanned from the QR code.
76        fingerprint: Fingerprint,
77
78        /// Invite number.
79        invitenumber: String,
80
81        /// Authentication code.
82        authcode: String,
83    },
84
85    /// Contact fingerprint is verified.
86    ///
87    /// Ask the user if they want to start chatting.
88    FprOk {
89        /// Contact ID.
90        contact_id: ContactId,
91    },
92
93    /// Scanned fingerprint does not match the last seen fingerprint.
94    FprMismatch {
95        /// Contact ID.
96        contact_id: Option<ContactId>,
97    },
98
99    /// The scanned QR code contains a fingerprint but no e-mail address.
100    FprWithoutAddr {
101        /// Key fingerprint.
102        fingerprint: String,
103    },
104
105    /// Ask the user if they want to create an account on the given domain.
106    Account {
107        /// Server domain name.
108        domain: String,
109    },
110
111    /// Provides a backup that can be retrieved using iroh-net based backup transfer protocol.
112    Backup2 {
113        /// Iroh node address.
114        node_addr: iroh::NodeAddr,
115
116        /// Authentication token.
117        auth_token: String,
118    },
119
120    /// The QR code is a backup, but it is too new. The user has to update its Delta Chat.
121    BackupTooNew {},
122
123    /// Ask the user if they want to use the given proxy.
124    ///
125    /// Note that HTTP(S) URLs without a path
126    /// and query parameters are treated as HTTP(S) proxy URL.
127    /// UI may want to still offer to open the URL
128    /// in the browser if QR code contents
129    /// starts with `http://` or `https://`
130    /// and the QR code was not scanned from
131    /// the proxy configuration screen.
132    Proxy {
133        /// Proxy URL.
134        ///
135        /// This is the URL that is going to be added.
136        url: String,
137
138        /// Host extracted from the URL to display in the UI.
139        host: String,
140
141        /// Port extracted from the URL to display in the UI.
142        port: u16,
143    },
144
145    /// Contact address is scanned.
146    ///
147    /// Optionally, a draft message could be provided.
148    /// Ask the user if they want to start chatting.
149    Addr {
150        /// Contact ID.
151        contact_id: ContactId,
152
153        /// Draft message.
154        draft: Option<String>,
155    },
156
157    /// URL scanned.
158    ///
159    /// Ask the user if they want to open a browser or copy the URL to clipboard.
160    Url {
161        /// URL.
162        url: String,
163    },
164
165    /// Text scanned.
166    ///
167    /// Ask the user if they want to copy the text to clipboard.
168    Text {
169        /// Scanned text.
170        text: String,
171    },
172
173    /// Ask the user if they want to withdraw their own QR code.
174    WithdrawVerifyContact {
175        /// Contact ID.
176        contact_id: ContactId,
177
178        /// Fingerprint of the contact key as scanned from the QR code.
179        fingerprint: Fingerprint,
180
181        /// Invite number.
182        invitenumber: String,
183
184        /// Authentication code.
185        authcode: String,
186    },
187
188    /// Ask the user if they want to withdraw their own group invite QR code.
189    WithdrawVerifyGroup {
190        /// Group name.
191        grpname: String,
192
193        /// Group ID.
194        grpid: String,
195
196        /// Contact ID.
197        contact_id: ContactId,
198
199        /// Fingerprint of the contact key as scanned from the QR code.
200        fingerprint: Fingerprint,
201
202        /// Invite number.
203        invitenumber: String,
204
205        /// Authentication code.
206        authcode: String,
207    },
208
209    /// Ask the user if they want to revive their own QR code.
210    ReviveVerifyContact {
211        /// Contact ID.
212        contact_id: ContactId,
213
214        /// Fingerprint of the contact key as scanned from the QR code.
215        fingerprint: Fingerprint,
216
217        /// Invite number.
218        invitenumber: String,
219
220        /// Authentication code.
221        authcode: String,
222    },
223
224    /// Ask the user if they want to revive their own group invite QR code.
225    ReviveVerifyGroup {
226        /// Group name.
227        grpname: String,
228
229        /// Group ID.
230        grpid: String,
231
232        /// Contact ID.
233        contact_id: ContactId,
234
235        /// Fingerprint of the contact key as scanned from the QR code.
236        fingerprint: Fingerprint,
237
238        /// Invite number.
239        invitenumber: String,
240
241        /// Authentication code.
242        authcode: String,
243    },
244
245    /// `dclogin:` scheme parameters.
246    ///
247    /// Ask the user if they want to login with the email address.
248    Login {
249        /// Email address.
250        address: String,
251
252        /// Login parameters.
253        options: LoginOptions,
254    },
255}
256
257// hack around the changed JSON accidentally used by an iroh upgrade, see #6518 for more details and for code snippet.
258// this hack is mainly needed to give ppl time to upgrade and can be removed after some months (added 2025-02)
259fn fix_add_second_device_qr(qr: &str) -> String {
260    qr.replacen(r#","info":{"relay_url":"#, r#","relay_url":"#, 1)
261        .replacen(r#""]}}"#, r#""]}"#, 1)
262}
263
264fn starts_with_ignore_case(string: &str, pattern: &str) -> bool {
265    string.to_lowercase().starts_with(&pattern.to_lowercase())
266}
267
268/// Checks a scanned QR code.
269///
270/// The function should be called after a QR code is scanned.
271/// The function takes the raw text scanned and checks what can be done with it.
272pub async fn check_qr(context: &Context, qr: &str) -> Result<Qr> {
273    let qr = qr.trim();
274    let qrcode = if starts_with_ignore_case(qr, OPENPGP4FPR_SCHEME) {
275        decode_openpgp(context, qr)
276            .await
277            .context("failed to decode OPENPGP4FPR QR code")?
278    } else if qr.starts_with(IDELTACHAT_SCHEME) {
279        decode_ideltachat(context, IDELTACHAT_SCHEME, qr).await?
280    } else if qr.starts_with(IDELTACHAT_NOSLASH_SCHEME) {
281        decode_ideltachat(context, IDELTACHAT_NOSLASH_SCHEME, qr).await?
282    } else if starts_with_ignore_case(qr, DCACCOUNT_SCHEME) {
283        decode_account(qr)?
284    } else if starts_with_ignore_case(qr, DCLOGIN_SCHEME) {
285        dclogin_scheme::decode_login(qr)?
286    } else if starts_with_ignore_case(qr, TG_SOCKS_SCHEME) {
287        decode_tg_socks_proxy(context, qr)?
288    } else if qr.starts_with(SHADOWSOCKS_SCHEME) {
289        decode_shadowsocks_proxy(qr)?
290    } else if starts_with_ignore_case(qr, DCBACKUP_SCHEME_PREFIX) {
291        let qr_fixed = fix_add_second_device_qr(qr);
292        decode_backup2(&qr_fixed)?
293    } else if qr.starts_with(MAILTO_SCHEME) {
294        decode_mailto(context, qr).await?
295    } else if qr.starts_with(SMTP_SCHEME) {
296        decode_smtp(context, qr).await?
297    } else if qr.starts_with(MATMSG_SCHEME) {
298        decode_matmsg(context, qr).await?
299    } else if qr.starts_with(VCARD_SCHEME) {
300        decode_vcard(context, qr).await?
301    } else if let Ok(url) = url::Url::parse(qr) {
302        match url.scheme() {
303            "socks5" => Qr::Proxy {
304                url: qr.to_string(),
305                host: url.host_str().context("URL has no host")?.to_string(),
306                port: url.port().unwrap_or(DEFAULT_SOCKS_PORT),
307            },
308            "http" | "https" => {
309                // Parsing with a non-standard scheme
310                // is a hack to work around the `url` crate bug
311                // <https://github.com/servo/rust-url/issues/957>.
312                let url = if let Some(rest) = qr.strip_prefix("http://") {
313                    url::Url::parse(&format!("foobarbaz://{rest}"))?
314                } else if let Some(rest) = qr.strip_prefix("https://") {
315                    url::Url::parse(&format!("foobarbaz://{rest}"))?
316                } else {
317                    // Should not happen.
318                    url
319                };
320
321                if url.port().is_none() | (url.path() != "") | url.query().is_some() {
322                    // URL without a port, with a path or query cannot be a proxy URL.
323                    Qr::Url {
324                        url: qr.to_string(),
325                    }
326                } else {
327                    Qr::Proxy {
328                        url: qr.to_string(),
329                        host: url.host_str().context("URL has no host")?.to_string(),
330                        port: url
331                            .port_or_known_default()
332                            .context("HTTP(S) URLs are guaranteed to return Some port")?,
333                    }
334                }
335            }
336            _ => Qr::Url {
337                url: qr.to_string(),
338            },
339        }
340    } else {
341        Qr::Text {
342            text: qr.to_string(),
343        }
344    };
345    Ok(qrcode)
346}
347
348/// Formats the text of the [`Qr::Backup2`] variant.
349///
350/// This is the inverse of [`check_qr`] for that variant only.
351///
352/// TODO: Refactor this so all variants have a correct [`Display`] and transform `check_qr`
353/// into `FromStr`.
354pub fn format_backup(qr: &Qr) -> Result<String> {
355    match qr {
356        Qr::Backup2 {
357            node_addr,
358            auth_token,
359        } => {
360            let node_addr = serde_json::to_string(node_addr)?;
361            Ok(format!(
362                "{DCBACKUP_SCHEME_PREFIX}{DCBACKUP_VERSION}:{auth_token}&{node_addr}"
363            ))
364        }
365        _ => Err(anyhow!("Not a backup QR code")),
366    }
367}
368
369/// scheme: `OPENPGP4FPR:FINGERPRINT#a=ADDR&n=NAME&i=INVITENUMBER&s=AUTH`
370///     or: `OPENPGP4FPR:FINGERPRINT#a=ADDR&g=GROUPNAME&x=GROUPID&i=INVITENUMBER&s=AUTH`
371///     or: `OPENPGP4FPR:FINGERPRINT#a=ADDR`
372async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
373    let payload = qr
374        .get(OPENPGP4FPR_SCHEME.len()..)
375        .context("Invalid OPENPGP4FPR scheme")?;
376
377    // macOS and iOS sometimes replace the # with %23 (uri encode it), we should be able to parse this wrong format too.
378    // see issue https://github.com/deltachat/deltachat-core-rust/issues/1969 for more info
379    let (fingerprint, fragment) = match payload
380        .split_once('#')
381        .or_else(|| payload.split_once("%23"))
382    {
383        Some(pair) => pair,
384        None => (payload, ""),
385    };
386    let fingerprint: Fingerprint = fingerprint
387        .parse()
388        .context("Failed to parse fingerprint in the QR code")?;
389
390    let param: BTreeMap<&str, &str> = fragment
391        .split('&')
392        .filter_map(|s| {
393            if let [key, value] = s.splitn(2, '=').collect::<Vec<_>>()[..] {
394                Some((key, value))
395            } else {
396                None
397            }
398        })
399        .collect();
400
401    let addr = if let Some(addr) = param.get("a") {
402        Some(normalize_address(addr)?)
403    } else {
404        None
405    };
406
407    let name = if let Some(encoded_name) = param.get("n") {
408        let encoded_name = encoded_name.replace('+', "%20"); // sometimes spaces are encoded as `+`
409        match percent_decode_str(&encoded_name).decode_utf8() {
410            Ok(name) => name.to_string(),
411            Err(err) => bail!("Invalid name: {err}"),
412        }
413    } else {
414        "".to_string()
415    };
416
417    let invitenumber = param
418        .get("i")
419        .filter(|&s| validate_id(s))
420        .map(|s| s.to_string());
421    let authcode = param
422        .get("s")
423        .filter(|&s| validate_id(s))
424        .map(|s| s.to_string());
425    let grpid = param
426        .get("x")
427        .filter(|&s| validate_id(s))
428        .map(|s| s.to_string());
429
430    let grpname = if grpid.is_some() {
431        if let Some(encoded_name) = param.get("g") {
432            let encoded_name = encoded_name.replace('+', "%20"); // sometimes spaces are encoded as `+`
433            match percent_decode_str(&encoded_name).decode_utf8() {
434                Ok(name) => Some(name.to_string()),
435                Err(err) => bail!("Invalid group name: {err}"),
436            }
437        } else {
438            None
439        }
440    } else {
441        None
442    };
443
444    if let (Some(addr), Some(invitenumber), Some(authcode)) = (&addr, invitenumber, authcode) {
445        let addr = ContactAddress::new(addr)?;
446        let (contact_id, _) = Contact::add_or_lookup_ex(
447            context,
448            &name,
449            &addr,
450            &fingerprint.hex(),
451            Origin::UnhandledSecurejoinQrScan,
452        )
453        .await
454        .with_context(|| format!("failed to add or lookup contact for address {addr:?}"))?;
455
456        if let (Some(grpid), Some(grpname)) = (grpid, grpname) {
457            if context
458                .is_self_addr(&addr)
459                .await
460                .with_context(|| format!("can't check if address {addr:?} is our address"))?
461            {
462                if token::exists(context, token::Namespace::InviteNumber, &invitenumber).await? {
463                    Ok(Qr::WithdrawVerifyGroup {
464                        grpname,
465                        grpid,
466                        contact_id,
467                        fingerprint,
468                        invitenumber,
469                        authcode,
470                    })
471                } else {
472                    Ok(Qr::ReviveVerifyGroup {
473                        grpname,
474                        grpid,
475                        contact_id,
476                        fingerprint,
477                        invitenumber,
478                        authcode,
479                    })
480                }
481            } else {
482                Ok(Qr::AskVerifyGroup {
483                    grpname,
484                    grpid,
485                    contact_id,
486                    fingerprint,
487                    invitenumber,
488                    authcode,
489                })
490            }
491        } else if context.is_self_addr(&addr).await? {
492            if token::exists(context, token::Namespace::InviteNumber, &invitenumber).await? {
493                Ok(Qr::WithdrawVerifyContact {
494                    contact_id,
495                    fingerprint,
496                    invitenumber,
497                    authcode,
498                })
499            } else {
500                Ok(Qr::ReviveVerifyContact {
501                    contact_id,
502                    fingerprint,
503                    invitenumber,
504                    authcode,
505                })
506            }
507        } else {
508            Ok(Qr::AskVerifyContact {
509                contact_id,
510                fingerprint,
511                invitenumber,
512                authcode,
513            })
514        }
515    } else if let Some(addr) = addr {
516        let fingerprint = fingerprint.hex();
517        let (contact_id, _) =
518            Contact::add_or_lookup_ex(context, "", &addr, &fingerprint, Origin::UnhandledQrScan)
519                .await?;
520        let contact = Contact::get_by_id(context, contact_id).await?;
521
522        if contact.public_key(context).await?.is_some() {
523            Ok(Qr::FprOk { contact_id })
524        } else {
525            Ok(Qr::FprMismatch {
526                contact_id: Some(contact_id),
527            })
528        }
529    } else {
530        Ok(Qr::FprWithoutAddr {
531            fingerprint: fingerprint.to_string(),
532        })
533    }
534}
535
536/// scheme: `https://i.delta.chat[/]#FINGERPRINT&a=ADDR[&OPTIONAL_PARAMS]`
537async fn decode_ideltachat(context: &Context, prefix: &str, qr: &str) -> Result<Qr> {
538    let qr = qr.replacen(prefix, OPENPGP4FPR_SCHEME, 1);
539    let qr = qr.replacen('&', "#", 1);
540    decode_openpgp(context, &qr)
541        .await
542        .with_context(|| format!("failed to decode {prefix} QR code"))
543}
544
545/// scheme: `DCACCOUNT:https://example.org/new_email?t=1w_7wDjgjelxeX884x96v3`
546fn decode_account(qr: &str) -> Result<Qr> {
547    let payload = qr
548        .get(DCACCOUNT_SCHEME.len()..)
549        .context("Invalid DCACCOUNT payload")?;
550    let url = url::Url::parse(payload).context("Invalid account URL")?;
551    if url.scheme() == "http" || url.scheme() == "https" {
552        Ok(Qr::Account {
553            domain: url
554                .host_str()
555                .context("can't extract account setup domain")?
556                .to_string(),
557        })
558    } else {
559        bail!("Bad scheme for account URL: {:?}.", url.scheme());
560    }
561}
562
563/// scheme: `https://t.me/socks?server=foo&port=123` or `https://t.me/socks?server=1.2.3.4&port=123`
564fn decode_tg_socks_proxy(_context: &Context, qr: &str) -> Result<Qr> {
565    let url = url::Url::parse(qr).context("Invalid t.me/socks url")?;
566
567    let mut host: Option<String> = None;
568    let mut port: u16 = DEFAULT_SOCKS_PORT;
569    let mut user: Option<String> = None;
570    let mut pass: Option<String> = None;
571    for (key, value) in url.query_pairs() {
572        if key == "server" {
573            host = Some(value.to_string());
574        } else if key == "port" {
575            port = value.parse().unwrap_or(DEFAULT_SOCKS_PORT);
576        } else if key == "user" {
577            user = Some(value.to_string());
578        } else if key == "pass" {
579            pass = Some(value.to_string());
580        }
581    }
582
583    let Some(host) = host else {
584        bail!("Bad t.me/socks url: {url:?}");
585    };
586
587    let mut url = "socks5://".to_string();
588    if let Some(pass) = pass {
589        url += &percent_encode(user.unwrap_or_default().as_bytes(), NON_ALPHANUMERIC).to_string();
590        url += ":";
591        url += &percent_encode(pass.as_bytes(), NON_ALPHANUMERIC).to_string();
592        url += "@";
593    };
594    url += &host;
595    url += ":";
596    url += &port.to_string();
597
598    Ok(Qr::Proxy { url, host, port })
599}
600
601/// Decodes `ss://` URLs for Shadowsocks proxies.
602fn decode_shadowsocks_proxy(qr: &str) -> Result<Qr> {
603    let server_config = shadowsocks::config::ServerConfig::from_url(qr)?;
604    let addr = server_config.addr();
605    let host = addr.host().to_string();
606    let port = addr.port();
607    Ok(Qr::Proxy {
608        url: qr.to_string(),
609        host,
610        port,
611    })
612}
613
614/// Decodes a `DCBACKUP` QR code.
615fn decode_backup2(qr: &str) -> Result<Qr> {
616    let version_and_payload = qr
617        .strip_prefix(DCBACKUP_SCHEME_PREFIX)
618        .ok_or_else(|| anyhow!("Invalid DCBACKUP scheme"))?;
619    let (version, payload) = version_and_payload
620        .split_once(':')
621        .context("DCBACKUP scheme separator missing")?;
622    let version: i32 = version.parse().context("Not a valid number")?;
623    if version > DCBACKUP_VERSION {
624        return Ok(Qr::BackupTooNew {});
625    }
626
627    let (auth_token, node_addr) = payload
628        .split_once('&')
629        .context("Backup QR code has no separator")?;
630    let auth_token = auth_token.to_string();
631    let node_addr = serde_json::from_str::<iroh::NodeAddr>(node_addr)
632        .context("Invalid node addr in backup QR code")?;
633
634    Ok(Qr::Backup2 {
635        node_addr,
636        auth_token,
637    })
638}
639
640#[derive(Debug, Deserialize)]
641struct CreateAccountSuccessResponse {
642    /// Email address.
643    email: String,
644
645    /// Password.
646    password: String,
647}
648#[derive(Debug, Deserialize)]
649struct CreateAccountErrorResponse {
650    /// Reason for the failure to create account returned by the server.
651    reason: String,
652}
653
654/// take a qr of the type DC_QR_ACCOUNT, parse it's parameters,
655/// download additional information from the contained url and set the parameters.
656/// on success, a configure::configure() should be able to log in to the account
657pub(crate) async fn set_account_from_qr(context: &Context, qr: &str) -> Result<()> {
658    let url_str = qr
659        .get(DCACCOUNT_SCHEME.len()..)
660        .context("Invalid DCACCOUNT scheme")?;
661
662    if !url_str.starts_with(HTTPS_SCHEME) {
663        bail!("DCACCOUNT QR codes must use HTTPS scheme");
664    }
665
666    let (response_text, response_success) = post_empty(context, url_str).await?;
667    if response_success {
668        let CreateAccountSuccessResponse { password, email } = serde_json::from_str(&response_text)
669            .with_context(|| {
670                format!("Cannot create account, response is malformed:\n{response_text:?}")
671            })?;
672        context
673            .set_config_internal(Config::Addr, Some(&email))
674            .await?;
675        context
676            .set_config_internal(Config::MailPw, Some(&password))
677            .await?;
678
679        Ok(())
680    } else {
681        match serde_json::from_str::<CreateAccountErrorResponse>(&response_text) {
682            Ok(error) => Err(anyhow!(error.reason)),
683            Err(parse_error) => {
684                context.emit_event(EventType::Error(format!(
685                    "Cannot create account, server response could not be parsed:\n{parse_error:#}\nraw response:\n{response_text}"
686                )));
687                bail!("Cannot create account, unexpected server response:\n{response_text:?}")
688            }
689        }
690    }
691}
692
693/// Sets configuration values from a QR code.
694pub async fn set_config_from_qr(context: &Context, qr: &str) -> Result<()> {
695    match check_qr(context, qr).await? {
696        Qr::Account { .. } => set_account_from_qr(context, qr).await?,
697        Qr::Proxy { url, .. } => {
698            let old_proxy_url_value = context
699                .get_config(Config::ProxyUrl)
700                .await?
701                .unwrap_or_default();
702
703            // Normalize the URL.
704            let url = ProxyConfig::from_url(&url)?.to_url();
705
706            let proxy_urls: Vec<&str> = std::iter::once(url.as_str())
707                .chain(
708                    old_proxy_url_value
709                        .split('\n')
710                        .filter(|s| !s.is_empty() && *s != url),
711                )
712                .collect();
713            context
714                .set_config(Config::ProxyUrl, Some(&proxy_urls.join("\n")))
715                .await?;
716            context.set_config_bool(Config::ProxyEnabled, true).await?;
717        }
718        Qr::WithdrawVerifyContact {
719            invitenumber,
720            authcode,
721            ..
722        } => {
723            token::delete(context, "").await?;
724            context
725                .sync_qr_code_token_deletion(invitenumber, authcode)
726                .await?;
727        }
728        Qr::WithdrawVerifyGroup {
729            grpid,
730            invitenumber,
731            authcode,
732            ..
733        } => {
734            token::delete(context, &grpid).await?;
735            context
736                .sync_qr_code_token_deletion(invitenumber, authcode)
737                .await?;
738        }
739        Qr::ReviveVerifyContact {
740            invitenumber,
741            authcode,
742            ..
743        } => {
744            token::save(context, token::Namespace::InviteNumber, None, &invitenumber).await?;
745            token::save(context, token::Namespace::Auth, None, &authcode).await?;
746            context.sync_qr_code_tokens(None).await?;
747            context.scheduler.interrupt_inbox().await;
748        }
749        Qr::ReviveVerifyGroup {
750            invitenumber,
751            authcode,
752            grpid,
753            ..
754        } => {
755            token::save(
756                context,
757                token::Namespace::InviteNumber,
758                Some(&grpid),
759                &invitenumber,
760            )
761            .await?;
762            token::save(context, token::Namespace::Auth, Some(&grpid), &authcode).await?;
763            context.sync_qr_code_tokens(Some(&grpid)).await?;
764            context.scheduler.interrupt_inbox().await;
765        }
766        Qr::Login { address, options } => {
767            configure_from_login_qr(context, &address, options).await?
768        }
769        _ => bail!("QR code does not contain config"),
770    }
771
772    Ok(())
773}
774
775/// Extract address for the mailto scheme.
776///
777/// Scheme: `mailto:addr...?subject=...&body=..`
778async fn decode_mailto(context: &Context, qr: &str) -> Result<Qr> {
779    let payload = qr
780        .get(MAILTO_SCHEME.len()..)
781        .context("Invalid mailto: scheme")?;
782
783    let (addr, query) = payload.split_once('?').unwrap_or((payload, ""));
784
785    let param: BTreeMap<&str, &str> = query
786        .split('&')
787        .filter_map(|s| {
788            if let [key, value] = s.splitn(2, '=').collect::<Vec<_>>()[..] {
789                Some((key, value))
790            } else {
791                None
792            }
793        })
794        .collect();
795
796    let subject = if let Some(subject) = param.get("subject") {
797        subject.to_string()
798    } else {
799        "".to_string()
800    };
801    let draft = if let Some(body) = param.get("body") {
802        if subject.is_empty() {
803            body.to_string()
804        } else {
805            subject + "\n" + body
806        }
807    } else {
808        subject
809    };
810    let draft = draft.replace('+', "%20"); // sometimes spaces are encoded as `+`
811    let draft = match percent_decode_str(&draft).decode_utf8() {
812        Ok(decoded_draft) => decoded_draft.to_string(),
813        Err(_err) => draft,
814    };
815
816    let addr = normalize_address(addr)?;
817    let name = "";
818    Qr::from_address(
819        context,
820        name,
821        &addr,
822        if draft.is_empty() { None } else { Some(draft) },
823    )
824    .await
825}
826
827/// Extract address for the smtp scheme.
828///
829/// Scheme: `SMTP:addr...:subject...:body...`
830async fn decode_smtp(context: &Context, qr: &str) -> Result<Qr> {
831    let payload = qr.get(SMTP_SCHEME.len()..).context("Invalid SMTP scheme")?;
832
833    let (addr, _rest) = payload
834        .split_once(':')
835        .context("Invalid SMTP scheme payload")?;
836    let addr = normalize_address(addr)?;
837    let name = "";
838    Qr::from_address(context, name, &addr, None).await
839}
840
841/// Extract address for the matmsg scheme.
842///
843/// Scheme: `MATMSG:TO:addr...;SUB:subject...;BODY:body...;`
844///
845/// There may or may not be linebreaks after the fields.
846async fn decode_matmsg(context: &Context, qr: &str) -> Result<Qr> {
847    // Does not work when the text `TO:` is used in subject/body _and_ TO: is not the first field.
848    // we ignore this case.
849    let addr = if let Some(to_index) = qr.find("TO:") {
850        let addr = qr.get(to_index + 3..).unwrap_or_default().trim();
851        if let Some(semi_index) = addr.find(';') {
852            addr.get(..semi_index).unwrap_or_default().trim()
853        } else {
854            addr
855        }
856    } else {
857        bail!("Invalid MATMSG found");
858    };
859
860    let addr = normalize_address(addr)?;
861    let name = "";
862    Qr::from_address(context, name, &addr, None).await
863}
864
865static VCARD_NAME_RE: LazyLock<regex::Regex> =
866    LazyLock::new(|| regex::Regex::new(r"(?m)^N:([^;]*);([^;\n]*)").unwrap());
867static VCARD_EMAIL_RE: LazyLock<regex::Regex> =
868    LazyLock::new(|| regex::Regex::new(r"(?m)^EMAIL([^:\n]*):([^;\n]*)").unwrap());
869
870/// Extract address for the vcard scheme.
871///
872/// Scheme: `VCARD:BEGIN\nN:last name;first name;...;\nEMAIL;<type>:addr...;`
873async fn decode_vcard(context: &Context, qr: &str) -> Result<Qr> {
874    let name = VCARD_NAME_RE
875        .captures(qr)
876        .and_then(|caps| {
877            let last_name = caps.get(1)?.as_str().trim();
878            let first_name = caps.get(2)?.as_str().trim();
879
880            Some(format!("{first_name} {last_name}"))
881        })
882        .unwrap_or_default();
883
884    let addr = if let Some(cap) = VCARD_EMAIL_RE.captures(qr).and_then(|caps| caps.get(2)) {
885        normalize_address(cap.as_str().trim())?
886    } else {
887        bail!("Bad e-mail address");
888    };
889
890    Qr::from_address(context, &name, &addr, None).await
891}
892
893impl Qr {
894    /// Creates a new scanned QR code of a contact address.
895    ///
896    /// May contain a message draft.
897    pub async fn from_address(
898        context: &Context,
899        name: &str,
900        addr: &str,
901        draft: Option<String>,
902    ) -> Result<Self> {
903        let addr = ContactAddress::new(addr)?;
904        let (contact_id, _) =
905            Contact::add_or_lookup(context, name, &addr, Origin::UnhandledQrScan).await?;
906        Ok(Qr::Addr { contact_id, draft })
907    }
908}
909
910/// URL decodes a given address, does basic email validation on the result.
911fn normalize_address(addr: &str) -> Result<String> {
912    // urldecoding is needed at least for OPENPGP4FPR but should not hurt in the other cases
913    let new_addr = percent_decode_str(addr).decode_utf8()?;
914    let new_addr = addr_normalize(&new_addr);
915
916    ensure!(may_be_valid_addr(&new_addr), "Bad e-mail address");
917
918    Ok(new_addr.to_string())
919}
920
921#[cfg(test)]
922mod qr_tests;