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