diff --git a/Cargo.lock b/Cargo.lock index 0d2eb64..b8f003b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4772,6 +4772,7 @@ dependencies = [ "chrono-tz", "cot", "futures", + "idna", "image", "multer", "password-auth", diff --git a/Cargo.toml b/Cargo.toml index b72d1ee..20523c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ tokio = { version = "1", features = ["fs", "process", "rt-multi-thread"] } uuid = { version = "1", features = ["v4"] } base64 = "0.22" urlencoding = "2" +idna = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } web-push-native = "0.5" diff --git a/src/admin.rs b/src/admin.rs index c3664a4..2744ee8 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -322,6 +322,39 @@ struct ClientFormTemplate<'a> { client_id: i64, client_status: &'a str, client_token: &'a str, + /// Base origin for the portal link with any punycode host decoded to Unicode + /// (e.g. `https://мурняня.рф`); empty falls back to the browser origin. + portal_base: String, +} + +/// Decodes any punycode (`xn--…`) labels in a URL's host to human-readable +/// Unicode, keeping the scheme and trimming a trailing slash. Returns an empty +/// string for empty input. +fn humanize_domain(raw: &str) -> String { + let raw = raw.trim(); + if raw.is_empty() { + return String::new(); + } + let (scheme, rest) = match raw.split_once("://") { + Some((s, r)) => (Some(s), r), + None => (None, raw), + }; + let (host, path) = match rest.split_once('/') { + Some((h, p)) => (h, Some(p)), + None => (rest, None), + }; + let (unicode_host, _) = idna::domain_to_unicode(host); + let mut out = String::new(); + if let Some(s) = scheme { + out.push_str(s); + out.push_str("://"); + } + out.push_str(&unicode_host); + if let Some(p) = path.filter(|p| !p.is_empty()) { + out.push('/'); + out.push_str(p); + } + out.trim_end_matches('/').to_string() } #[derive(Debug, Template)] @@ -1270,6 +1303,7 @@ async fn client_new_page(request: Request, session: Session) -> cot::Result Router { #[cfg(test)] mod tests { - use super::preserved_secret_value; + use super::{humanize_domain, preserved_secret_value}; #[test] fn keeps_masks_and_updates_secrets_consistently() { @@ -3553,4 +3595,19 @@ mod tests { assert_eq!(preserved_secret_value("stored", "", true), ""); assert_eq!(preserved_secret_value("stored", "new", true), "new"); } + + #[test] + fn decodes_punycode_host_to_unicode() { + assert_eq!( + humanize_domain("https://xn--l1acako8eb.xn--p1ai"), + "https://мурняня.рф" + ); + assert_eq!( + humanize_domain("https://xn--l1acako8eb.xn--p1ai/"), + "https://мурняня.рф" + ); + // Plain ASCII domains are returned unchanged (minus trailing slash). + assert_eq!(humanize_domain("https://example.com/"), "https://example.com"); + assert_eq!(humanize_domain(""), ""); + } } diff --git a/templates/admin/client_form.html b/templates/admin/client_form.html index 008c193..eefc81d 100644 --- a/templates/admin/client_form.html +++ b/templates/admin/client_form.html @@ -72,7 +72,8 @@