1mod dclogin_scheme;
4use std::collections::BTreeMap;
5use std::sync::LazyLock;
6
7use anyhow::{anyhow, bail, ensure, Context as _, Result};
8pub use dclogin_scheme::LoginOptions;
9use deltachat_contact_tools::{addr_normalize, may_be_valid_addr, ContactAddress};
10use percent_encoding::{percent_decode_str, percent_encode, NON_ALPHANUMERIC};
11use serde::Deserialize;
12
13pub(crate) use self::dclogin_scheme::configure_from_login_qr;
14use crate::chat::ChatIdBlocked;
15use crate::config::Config;
16use crate::constants::Blocked;
17use crate::contact::{Contact, ContactId, Origin};
18use crate::context::Context;
19use crate::events::EventType;
20use crate::key::Fingerprint;
21use crate::message::Message;
22use crate::net::http::post_empty;
23use crate::net::proxy::{ProxyConfig, DEFAULT_SOCKS_PORT};
24use crate::peerstate::Peerstate;
25use crate::token;
26use crate::tools::validate_id;
27
28const OPENPGP4FPR_SCHEME: &str = "OPENPGP4FPR:"; const IDELTACHAT_SCHEME: &str = "https://i.delta.chat/#";
30const IDELTACHAT_NOSLASH_SCHEME: &str = "https://i.delta.chat#";
31const DCACCOUNT_SCHEME: &str = "DCACCOUNT:";
32pub(super) const DCLOGIN_SCHEME: &str = "DCLOGIN:";
33const DCWEBRTC_SCHEME: &str = "DCWEBRTC:";
34const TG_SOCKS_SCHEME: &str = "https://t.me/socks";
35const MAILTO_SCHEME: &str = "mailto:";
36const MATMSG_SCHEME: &str = "MATMSG:";
37const VCARD_SCHEME: &str = "BEGIN:VCARD";
38const SMTP_SCHEME: &str = "SMTP:";
39const HTTPS_SCHEME: &str = "https://";
40const SHADOWSOCKS_SCHEME: &str = "ss://";
41
42pub(crate) const DCBACKUP_SCHEME_PREFIX: &str = "DCBACKUP";
44
45pub(crate) const DCBACKUP_VERSION: i32 = 2;
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum Qr {
52 AskVerifyContact {
56 contact_id: ContactId,
58
59 fingerprint: Fingerprint,
61
62 invitenumber: String,
64
65 authcode: String,
67 },
68
69 AskVerifyGroup {
71 grpname: String,
73
74 grpid: String,
76
77 contact_id: ContactId,
79
80 fingerprint: Fingerprint,
82
83 invitenumber: String,
85
86 authcode: String,
88 },
89
90 FprOk {
94 contact_id: ContactId,
96 },
97
98 FprMismatch {
100 contact_id: Option<ContactId>,
102 },
103
104 FprWithoutAddr {
106 fingerprint: String,
108 },
109
110 Account {
112 domain: String,
114 },
115
116 Backup2 {
118 node_addr: iroh::NodeAddr,
120
121 auth_token: String,
123 },
124
125 BackupTooNew {},
127
128 WebrtcInstance {
130 domain: String,
132
133 instance_pattern: String,
135 },
136
137 Proxy {
147 url: String,
151
152 host: String,
154
155 port: u16,
157 },
158
159 Addr {
164 contact_id: ContactId,
166
167 draft: Option<String>,
169 },
170
171 Url {
175 url: String,
177 },
178
179 Text {
183 text: String,
185 },
186
187 WithdrawVerifyContact {
189 contact_id: ContactId,
191
192 fingerprint: Fingerprint,
194
195 invitenumber: String,
197
198 authcode: String,
200 },
201
202 WithdrawVerifyGroup {
204 grpname: String,
206
207 grpid: String,
209
210 contact_id: ContactId,
212
213 fingerprint: Fingerprint,
215
216 invitenumber: String,
218
219 authcode: String,
221 },
222
223 ReviveVerifyContact {
225 contact_id: ContactId,
227
228 fingerprint: Fingerprint,
230
231 invitenumber: String,
233
234 authcode: String,
236 },
237
238 ReviveVerifyGroup {
240 grpname: String,
242
243 grpid: String,
245
246 contact_id: ContactId,
248
249 fingerprint: Fingerprint,
251
252 invitenumber: String,
254
255 authcode: String,
257 },
258
259 Login {
263 address: String,
265
266 options: LoginOptions,
268 },
269}
270
271fn fix_add_second_device_qr(qr: &str) -> String {
274 qr.replacen(r#","info":{"relay_url":"#, r#","relay_url":"#, 1)
275 .replacen(r#""]}}"#, r#""]}"#, 1)
276}
277
278fn starts_with_ignore_case(string: &str, pattern: &str) -> bool {
279 string.to_lowercase().starts_with(&pattern.to_lowercase())
280}
281
282pub async fn check_qr(context: &Context, qr: &str) -> Result<Qr> {
287 let qr = qr.trim();
288 let qrcode = if starts_with_ignore_case(qr, OPENPGP4FPR_SCHEME) {
289 decode_openpgp(context, qr)
290 .await
291 .context("failed to decode OPENPGP4FPR QR code")?
292 } else if qr.starts_with(IDELTACHAT_SCHEME) {
293 decode_ideltachat(context, IDELTACHAT_SCHEME, qr).await?
294 } else if qr.starts_with(IDELTACHAT_NOSLASH_SCHEME) {
295 decode_ideltachat(context, IDELTACHAT_NOSLASH_SCHEME, qr).await?
296 } else if starts_with_ignore_case(qr, DCACCOUNT_SCHEME) {
297 decode_account(qr)?
298 } else if starts_with_ignore_case(qr, DCLOGIN_SCHEME) {
299 dclogin_scheme::decode_login(qr)?
300 } else if starts_with_ignore_case(qr, DCWEBRTC_SCHEME) {
301 decode_webrtc_instance(context, qr)?
302 } else if starts_with_ignore_case(qr, TG_SOCKS_SCHEME) {
303 decode_tg_socks_proxy(context, qr)?
304 } else if qr.starts_with(SHADOWSOCKS_SCHEME) {
305 decode_shadowsocks_proxy(qr)?
306 } else if starts_with_ignore_case(qr, DCBACKUP_SCHEME_PREFIX) {
307 let qr_fixed = fix_add_second_device_qr(qr);
308 decode_backup2(&qr_fixed)?
309 } else if qr.starts_with(MAILTO_SCHEME) {
310 decode_mailto(context, qr).await?
311 } else if qr.starts_with(SMTP_SCHEME) {
312 decode_smtp(context, qr).await?
313 } else if qr.starts_with(MATMSG_SCHEME) {
314 decode_matmsg(context, qr).await?
315 } else if qr.starts_with(VCARD_SCHEME) {
316 decode_vcard(context, qr).await?
317 } else if let Ok(url) = url::Url::parse(qr) {
318 match url.scheme() {
319 "socks5" => Qr::Proxy {
320 url: qr.to_string(),
321 host: url.host_str().context("URL has no host")?.to_string(),
322 port: url.port().unwrap_or(DEFAULT_SOCKS_PORT),
323 },
324 "http" | "https" => {
325 let url = if let Some(rest) = qr.strip_prefix("http://") {
329 url::Url::parse(&format!("foobarbaz://{rest}"))?
330 } else if let Some(rest) = qr.strip_prefix("https://") {
331 url::Url::parse(&format!("foobarbaz://{rest}"))?
332 } else {
333 url
335 };
336
337 if url.port().is_none() | (url.path() != "") | url.query().is_some() {
338 Qr::Url {
340 url: qr.to_string(),
341 }
342 } else {
343 Qr::Proxy {
344 url: qr.to_string(),
345 host: url.host_str().context("URL has no host")?.to_string(),
346 port: url
347 .port_or_known_default()
348 .context("HTTP(S) URLs are guaranteed to return Some port")?,
349 }
350 }
351 }
352 _ => Qr::Url {
353 url: qr.to_string(),
354 },
355 }
356 } else {
357 Qr::Text {
358 text: qr.to_string(),
359 }
360 };
361 Ok(qrcode)
362}
363
364pub fn format_backup(qr: &Qr) -> Result<String> {
371 match qr {
372 Qr::Backup2 {
373 ref node_addr,
374 ref auth_token,
375 } => {
376 let node_addr = serde_json::to_string(node_addr)?;
377 Ok(format!(
378 "{DCBACKUP_SCHEME_PREFIX}{DCBACKUP_VERSION}:{auth_token}&{node_addr}"
379 ))
380 }
381 _ => Err(anyhow!("Not a backup QR code")),
382 }
383}
384
385async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
389 let payload = qr
390 .get(OPENPGP4FPR_SCHEME.len()..)
391 .context("Invalid OPENPGP4FPR scheme")?;
392
393 let (fingerprint, fragment) = match payload
396 .split_once('#')
397 .or_else(|| payload.split_once("%23"))
398 {
399 Some(pair) => pair,
400 None => (payload, ""),
401 };
402 let fingerprint: Fingerprint = fingerprint
403 .parse()
404 .context("Failed to parse fingerprint in the QR code")?;
405
406 let param: BTreeMap<&str, &str> = fragment
407 .split('&')
408 .filter_map(|s| {
409 if let [key, value] = s.splitn(2, '=').collect::<Vec<_>>()[..] {
410 Some((key, value))
411 } else {
412 None
413 }
414 })
415 .collect();
416
417 let addr = if let Some(addr) = param.get("a") {
418 Some(normalize_address(addr)?)
419 } else {
420 None
421 };
422
423 let name = if let Some(encoded_name) = param.get("n") {
424 let encoded_name = encoded_name.replace('+', "%20"); match percent_decode_str(&encoded_name).decode_utf8() {
426 Ok(name) => name.to_string(),
427 Err(err) => bail!("Invalid name: {}", err),
428 }
429 } else {
430 "".to_string()
431 };
432
433 let invitenumber = param
434 .get("i")
435 .filter(|&s| validate_id(s))
436 .map(|s| s.to_string());
437 let authcode = param
438 .get("s")
439 .filter(|&s| validate_id(s))
440 .map(|s| s.to_string());
441 let grpid = param
442 .get("x")
443 .filter(|&s| validate_id(s))
444 .map(|s| s.to_string());
445
446 let grpname = if grpid.is_some() {
447 if let Some(encoded_name) = param.get("g") {
448 let encoded_name = encoded_name.replace('+', "%20"); match percent_decode_str(&encoded_name).decode_utf8() {
450 Ok(name) => Some(name.to_string()),
451 Err(err) => bail!("Invalid group name: {}", err),
452 }
453 } else {
454 None
455 }
456 } else {
457 None
458 };
459
460 let peerstate = Peerstate::from_fingerprint(context, &fingerprint)
462 .await
463 .context("Can't load peerstate")?;
464
465 if let (Some(addr), Some(invitenumber), Some(authcode)) = (&addr, invitenumber, authcode) {
466 let addr = ContactAddress::new(addr)?;
467 let (contact_id, _) =
468 Contact::add_or_lookup(context, &name, &addr, Origin::UnhandledSecurejoinQrScan)
469 .await
470 .with_context(|| format!("failed to add or lookup contact for address {addr:?}"))?;
471
472 if let (Some(grpid), Some(grpname)) = (grpid, grpname) {
473 if context
474 .is_self_addr(&addr)
475 .await
476 .with_context(|| format!("can't check if address {addr:?} is our address"))?
477 {
478 if token::exists(context, token::Namespace::InviteNumber, &invitenumber).await? {
479 Ok(Qr::WithdrawVerifyGroup {
480 grpname,
481 grpid,
482 contact_id,
483 fingerprint,
484 invitenumber,
485 authcode,
486 })
487 } else {
488 Ok(Qr::ReviveVerifyGroup {
489 grpname,
490 grpid,
491 contact_id,
492 fingerprint,
493 invitenumber,
494 authcode,
495 })
496 }
497 } else {
498 Ok(Qr::AskVerifyGroup {
499 grpname,
500 grpid,
501 contact_id,
502 fingerprint,
503 invitenumber,
504 authcode,
505 })
506 }
507 } else if context.is_self_addr(&addr).await? {
508 if token::exists(context, token::Namespace::InviteNumber, &invitenumber).await? {
509 Ok(Qr::WithdrawVerifyContact {
510 contact_id,
511 fingerprint,
512 invitenumber,
513 authcode,
514 })
515 } else {
516 Ok(Qr::ReviveVerifyContact {
517 contact_id,
518 fingerprint,
519 invitenumber,
520 authcode,
521 })
522 }
523 } else {
524 Ok(Qr::AskVerifyContact {
525 contact_id,
526 fingerprint,
527 invitenumber,
528 authcode,
529 })
530 }
531 } else if let Some(addr) = addr {
532 if let Some(peerstate) = peerstate {
533 let peerstate_addr = ContactAddress::new(&peerstate.addr)?;
534 let (contact_id, _) =
535 Contact::add_or_lookup(context, &name, &peerstate_addr, Origin::UnhandledQrScan)
536 .await
537 .context("add_or_lookup")?;
538 ChatIdBlocked::get_for_contact(context, contact_id, Blocked::Request)
539 .await
540 .context("Failed to create (new) chat for contact")?;
541 Ok(Qr::FprOk { contact_id })
542 } else {
543 let contact_id = Contact::lookup_id_by_addr(context, &addr, Origin::Unknown)
544 .await
545 .with_context(|| format!("Error looking up contact {addr:?}"))?;
546 Ok(Qr::FprMismatch { contact_id })
547 }
548 } else {
549 Ok(Qr::FprWithoutAddr {
550 fingerprint: fingerprint.to_string(),
551 })
552 }
553}
554
555async fn decode_ideltachat(context: &Context, prefix: &str, qr: &str) -> Result<Qr> {
557 let qr = qr.replacen(prefix, OPENPGP4FPR_SCHEME, 1);
558 let qr = qr.replacen('&', "#", 1);
559 decode_openpgp(context, &qr)
560 .await
561 .with_context(|| format!("failed to decode {prefix} QR code"))
562}
563
564fn decode_account(qr: &str) -> Result<Qr> {
566 let payload = qr
567 .get(DCACCOUNT_SCHEME.len()..)
568 .context("Invalid DCACCOUNT payload")?;
569 let url = url::Url::parse(payload).context("Invalid account URL")?;
570 if url.scheme() == "http" || url.scheme() == "https" {
571 Ok(Qr::Account {
572 domain: url
573 .host_str()
574 .context("can't extract account setup domain")?
575 .to_string(),
576 })
577 } else {
578 bail!("Bad scheme for account URL: {:?}.", url.scheme());
579 }
580}
581
582fn decode_webrtc_instance(_context: &Context, qr: &str) -> Result<Qr> {
584 let payload = qr
585 .get(DCWEBRTC_SCHEME.len()..)
586 .context("Invalid DCWEBRTC payload")?;
587
588 let (_type, url) = Message::parse_webrtc_instance(payload);
589 let url = url::Url::parse(&url).context("Invalid WebRTC instance")?;
590
591 if url.scheme() == "http" || url.scheme() == "https" {
592 Ok(Qr::WebrtcInstance {
593 domain: url
594 .host_str()
595 .context("can't extract WebRTC instance domain")?
596 .to_string(),
597 instance_pattern: payload.to_string(),
598 })
599 } else {
600 bail!("Bad URL scheme for WebRTC instance: {:?}", url.scheme());
601 }
602}
603
604fn decode_tg_socks_proxy(_context: &Context, qr: &str) -> Result<Qr> {
606 let url = url::Url::parse(qr).context("Invalid t.me/socks url")?;
607
608 let mut host: Option<String> = None;
609 let mut port: u16 = DEFAULT_SOCKS_PORT;
610 let mut user: Option<String> = None;
611 let mut pass: Option<String> = None;
612 for (key, value) in url.query_pairs() {
613 if key == "server" {
614 host = Some(value.to_string());
615 } else if key == "port" {
616 port = value.parse().unwrap_or(DEFAULT_SOCKS_PORT);
617 } else if key == "user" {
618 user = Some(value.to_string());
619 } else if key == "pass" {
620 pass = Some(value.to_string());
621 }
622 }
623
624 let Some(host) = host else {
625 bail!("Bad t.me/socks url: {:?}", url);
626 };
627
628 let mut url = "socks5://".to_string();
629 if let Some(pass) = pass {
630 url += &percent_encode(user.unwrap_or_default().as_bytes(), NON_ALPHANUMERIC).to_string();
631 url += ":";
632 url += &percent_encode(pass.as_bytes(), NON_ALPHANUMERIC).to_string();
633 url += "@";
634 };
635 url += &host;
636 url += ":";
637 url += &port.to_string();
638
639 Ok(Qr::Proxy { url, host, port })
640}
641
642fn decode_shadowsocks_proxy(qr: &str) -> Result<Qr> {
644 let server_config = shadowsocks::config::ServerConfig::from_url(qr)?;
645 let addr = server_config.addr();
646 let host = addr.host().to_string();
647 let port = addr.port();
648 Ok(Qr::Proxy {
649 url: qr.to_string(),
650 host,
651 port,
652 })
653}
654
655fn decode_backup2(qr: &str) -> Result<Qr> {
657 let version_and_payload = qr
658 .strip_prefix(DCBACKUP_SCHEME_PREFIX)
659 .ok_or_else(|| anyhow!("Invalid DCBACKUP scheme"))?;
660 let (version, payload) = version_and_payload
661 .split_once(':')
662 .context("DCBACKUP scheme separator missing")?;
663 let version: i32 = version.parse().context("Not a valid number")?;
664 if version > DCBACKUP_VERSION {
665 return Ok(Qr::BackupTooNew {});
666 }
667
668 let (auth_token, node_addr) = payload
669 .split_once('&')
670 .context("Backup QR code has no separator")?;
671 let auth_token = auth_token.to_string();
672 let node_addr = serde_json::from_str::<iroh::NodeAddr>(node_addr)
673 .context("Invalid node addr in backup QR code")?;
674
675 Ok(Qr::Backup2 {
676 node_addr,
677 auth_token,
678 })
679}
680
681#[derive(Debug, Deserialize)]
682struct CreateAccountSuccessResponse {
683 email: String,
685
686 password: String,
688}
689#[derive(Debug, Deserialize)]
690struct CreateAccountErrorResponse {
691 reason: String,
693}
694
695pub(crate) async fn set_account_from_qr(context: &Context, qr: &str) -> Result<()> {
699 let url_str = qr
700 .get(DCACCOUNT_SCHEME.len()..)
701 .context("Invalid DCACCOUNT scheme")?;
702
703 if !url_str.starts_with(HTTPS_SCHEME) {
704 bail!("DCACCOUNT QR codes must use HTTPS scheme");
705 }
706
707 let (response_text, response_success) = post_empty(context, url_str).await?;
708 if response_success {
709 let CreateAccountSuccessResponse { password, email } = serde_json::from_str(&response_text)
710 .with_context(|| {
711 format!("Cannot create account, response is malformed:\n{response_text:?}")
712 })?;
713 context
714 .set_config_internal(Config::Addr, Some(&email))
715 .await?;
716 context
717 .set_config_internal(Config::MailPw, Some(&password))
718 .await?;
719
720 Ok(())
721 } else {
722 match serde_json::from_str::<CreateAccountErrorResponse>(&response_text) {
723 Ok(error) => Err(anyhow!(error.reason)),
724 Err(parse_error) => {
725 context.emit_event(EventType::Error(format!(
726 "Cannot create account, server response could not be parsed:\n{parse_error:#}\nraw response:\n{response_text}"
727 )));
728 bail!(
729 "Cannot create account, unexpected server response:\n{:?}",
730 response_text
731 )
732 }
733 }
734 }
735}
736
737pub async fn set_config_from_qr(context: &Context, qr: &str) -> Result<()> {
739 match check_qr(context, qr).await? {
740 Qr::Account { .. } => set_account_from_qr(context, qr).await?,
741 Qr::WebrtcInstance {
742 domain: _,
743 instance_pattern,
744 } => {
745 context
746 .set_config_internal(Config::WebrtcInstance, Some(&instance_pattern))
747 .await?;
748 }
749 Qr::Proxy { url, .. } => {
750 let old_proxy_url_value = context
751 .get_config(Config::ProxyUrl)
752 .await?
753 .unwrap_or_default();
754
755 let url = ProxyConfig::from_url(&url)?.to_url();
757
758 let proxy_urls: Vec<&str> = std::iter::once(url.as_str())
759 .chain(
760 old_proxy_url_value
761 .split('\n')
762 .filter(|s| !s.is_empty() && *s != url),
763 )
764 .collect();
765 context
766 .set_config(Config::ProxyUrl, Some(&proxy_urls.join("\n")))
767 .await?;
768 context.set_config_bool(Config::ProxyEnabled, true).await?;
769 }
770 Qr::WithdrawVerifyContact {
771 invitenumber,
772 authcode,
773 ..
774 } => {
775 token::delete(context, token::Namespace::InviteNumber, &invitenumber).await?;
776 token::delete(context, token::Namespace::Auth, &authcode).await?;
777 context
778 .sync_qr_code_token_deletion(invitenumber, authcode)
779 .await?;
780 }
781 Qr::WithdrawVerifyGroup {
782 invitenumber,
783 authcode,
784 ..
785 } => {
786 token::delete(context, token::Namespace::InviteNumber, &invitenumber).await?;
787 token::delete(context, token::Namespace::Auth, &authcode).await?;
788 context
789 .sync_qr_code_token_deletion(invitenumber, authcode)
790 .await?;
791 }
792 Qr::ReviveVerifyContact {
793 invitenumber,
794 authcode,
795 ..
796 } => {
797 token::save(context, token::Namespace::InviteNumber, None, &invitenumber).await?;
798 token::save(context, token::Namespace::Auth, None, &authcode).await?;
799 context.sync_qr_code_tokens(None).await?;
800 context.scheduler.interrupt_inbox().await;
801 }
802 Qr::ReviveVerifyGroup {
803 invitenumber,
804 authcode,
805 grpid,
806 ..
807 } => {
808 token::save(
809 context,
810 token::Namespace::InviteNumber,
811 Some(&grpid),
812 &invitenumber,
813 )
814 .await?;
815 token::save(context, token::Namespace::Auth, Some(&grpid), &authcode).await?;
816 context.sync_qr_code_tokens(Some(&grpid)).await?;
817 context.scheduler.interrupt_inbox().await;
818 }
819 Qr::Login { address, options } => {
820 configure_from_login_qr(context, &address, options).await?
821 }
822 _ => bail!("QR code does not contain config"),
823 }
824
825 Ok(())
826}
827
828async fn decode_mailto(context: &Context, qr: &str) -> Result<Qr> {
832 let payload = qr
833 .get(MAILTO_SCHEME.len()..)
834 .context("Invalid mailto: scheme")?;
835
836 let (addr, query) = payload.split_once('?').unwrap_or((payload, ""));
837
838 let param: BTreeMap<&str, &str> = query
839 .split('&')
840 .filter_map(|s| {
841 if let [key, value] = s.splitn(2, '=').collect::<Vec<_>>()[..] {
842 Some((key, value))
843 } else {
844 None
845 }
846 })
847 .collect();
848
849 let subject = if let Some(subject) = param.get("subject") {
850 subject.to_string()
851 } else {
852 "".to_string()
853 };
854 let draft = if let Some(body) = param.get("body") {
855 if subject.is_empty() {
856 body.to_string()
857 } else {
858 subject + "\n" + body
859 }
860 } else {
861 subject
862 };
863 let draft = draft.replace('+', "%20"); let draft = match percent_decode_str(&draft).decode_utf8() {
865 Ok(decoded_draft) => decoded_draft.to_string(),
866 Err(_err) => draft,
867 };
868
869 let addr = normalize_address(addr)?;
870 let name = "";
871 Qr::from_address(
872 context,
873 name,
874 &addr,
875 if draft.is_empty() { None } else { Some(draft) },
876 )
877 .await
878}
879
880async fn decode_smtp(context: &Context, qr: &str) -> Result<Qr> {
884 let payload = qr.get(SMTP_SCHEME.len()..).context("Invalid SMTP scheme")?;
885
886 let (addr, _rest) = payload
887 .split_once(':')
888 .context("Invalid SMTP scheme payload")?;
889 let addr = normalize_address(addr)?;
890 let name = "";
891 Qr::from_address(context, name, &addr, None).await
892}
893
894async fn decode_matmsg(context: &Context, qr: &str) -> Result<Qr> {
900 let addr = if let Some(to_index) = qr.find("TO:") {
903 let addr = qr.get(to_index + 3..).unwrap_or_default().trim();
904 if let Some(semi_index) = addr.find(';') {
905 addr.get(..semi_index).unwrap_or_default().trim()
906 } else {
907 addr
908 }
909 } else {
910 bail!("Invalid MATMSG found");
911 };
912
913 let addr = normalize_address(addr)?;
914 let name = "";
915 Qr::from_address(context, name, &addr, None).await
916}
917
918static VCARD_NAME_RE: LazyLock<regex::Regex> =
919 LazyLock::new(|| regex::Regex::new(r"(?m)^N:([^;]*);([^;\n]*)").unwrap());
920static VCARD_EMAIL_RE: LazyLock<regex::Regex> =
921 LazyLock::new(|| regex::Regex::new(r"(?m)^EMAIL([^:\n]*):([^;\n]*)").unwrap());
922
923async fn decode_vcard(context: &Context, qr: &str) -> Result<Qr> {
927 let name = VCARD_NAME_RE
928 .captures(qr)
929 .and_then(|caps| {
930 let last_name = caps.get(1)?.as_str().trim();
931 let first_name = caps.get(2)?.as_str().trim();
932
933 Some(format!("{first_name} {last_name}"))
934 })
935 .unwrap_or_default();
936
937 let addr = if let Some(cap) = VCARD_EMAIL_RE.captures(qr).and_then(|caps| caps.get(2)) {
938 normalize_address(cap.as_str().trim())?
939 } else {
940 bail!("Bad e-mail address");
941 };
942
943 Qr::from_address(context, &name, &addr, None).await
944}
945
946impl Qr {
947 pub async fn from_address(
951 context: &Context,
952 name: &str,
953 addr: &str,
954 draft: Option<String>,
955 ) -> Result<Self> {
956 let addr = ContactAddress::new(addr)?;
957 let (contact_id, _) =
958 Contact::add_or_lookup(context, name, &addr, Origin::UnhandledQrScan).await?;
959 Ok(Qr::Addr { contact_id, draft })
960 }
961}
962
963fn normalize_address(addr: &str) -> Result<String> {
965 let new_addr = percent_decode_str(addr).decode_utf8()?;
967 let new_addr = addr_normalize(&new_addr);
968
969 ensure!(may_be_valid_addr(&new_addr), "Bad e-mail address");
970
971 Ok(new_addr.to_string())
972}
973
974#[cfg(test)]
975mod qr_tests;