diff --git a/Cargo.lock b/Cargo.lock index cf4b9ac..6cc7114 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1418,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "furumusic" -version = "0.3.1" +version = "0.4.1" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 8bf83d8..a8c4f23 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumusic" -version = "0.4.1" +version = "0.4.2" edition = "2024" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" diff --git a/src/oidc.rs b/src/oidc.rs index c23c3d7..4a9acf8 100644 --- a/src/oidc.rs +++ b/src/oidc.rs @@ -981,11 +981,151 @@ fn safe_mobile_redirect_uri(raw: Option<&str>) -> Option { } fn mobile_redirect_success(app_redirect_uri: &str, code: &str) -> cot::response::Response { - auth::redirect(&append_query_param(app_redirect_uri, "code", code)) + let deep_link = append_query_param(app_redirect_uri, "code", code); + mobile_deep_link_page( + "success", + "Sign-in complete", + "Furumi should open automatically. You can close this window after the app opens.", + None, + &deep_link, + ) } fn mobile_redirect_error(app_redirect_uri: &str, error: &str) -> cot::response::Response { - auth::redirect(&append_query_param(app_redirect_uri, "error", error)) + let deep_link = append_query_param(app_redirect_uri, "error", error); + mobile_deep_link_page( + "error", + "Sign-in failed", + "Furumi should open automatically and show the sign-in error. You can close this window after the app opens.", + Some(error), + &deep_link, + ) +} + +fn mobile_deep_link_page( + state: &str, + title: &str, + message: &str, + detail: Option<&str>, + deep_link: &str, +) -> cot::response::Response { + let state_class = html_escape(state); + let title_html = html_escape(title); + let message_html = html_escape(message); + let detail_html = detail + .map(|value| format!(r#"

Reason: {}

"#, html_escape(value))) + .unwrap_or_default(); + let deep_link_html = html_escape(deep_link); + let deep_link_js = + serde_json::to_string(deep_link).expect("serializing URL string cannot fail"); + + let html = format!( + r#" + + + + + {title_html} + + + +
+ +

{title_html}

+

{message_html}

+ {detail_html} + Open Furumi +

If nothing happens, use the button above.

+
+ + +"#, + mark = if state == "error" { "!" } else { "OK" } + ); + + cot::http::Response::builder() + .status(cot::http::StatusCode::OK) + .header(cot::http::header::CONTENT_TYPE, "text/html; charset=utf-8") + .header(cot::http::header::CACHE_CONTROL, "no-store") + .body(cot::Body::fixed(html)) + .expect("valid response") } fn append_query_param(uri: &str, key: &str, value: &str) -> String { @@ -999,6 +1139,21 @@ fn append_query_param(uri: &str, key: &str, value: &str) -> String { out } +fn html_escape(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for ch in value.chars() { + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(ch), + } + } + out +} + fn extract_groups_from_jwt(token: &str) -> Vec { use base64::Engine; @@ -1038,3 +1193,41 @@ fn urlencoded(s: &str) -> String { } out } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mobile_oidc_append_query_param_preserves_fragment() { + assert_eq!( + append_query_param("furumi://auth/callback#done", "code", "a b"), + "furumi://auth/callback?code=a%20b#done" + ); + assert_eq!( + append_query_param("furumi://auth/callback?desktop=1", "error", "oidc_error"), + "furumi://auth/callback?desktop=1&error=oidc_error" + ); + } + + #[test] + fn mobile_oidc_html_escape_escapes_page_values() { + assert_eq!( + html_escape(r#"'text'"#), + "<tag attr="x&y">'text'</tag>" + ); + } + + #[test] + fn mobile_oidc_redirect_uri_allows_only_furumi_schemes() { + assert_eq!( + safe_mobile_redirect_uri(Some("furumi://auth/callback")).as_deref(), + Some("furumi://auth/callback") + ); + assert_eq!( + safe_mobile_redirect_uri(Some("furumusic://auth/callback")).as_deref(), + Some("furumusic://auth/callback") + ); + assert!(safe_mobile_redirect_uri(Some("https://example.com/callback")).is_none()); + } +} diff --git a/src/player/mod.rs b/src/player/mod.rs index fd4261f..0349c4f 100644 --- a/src/player/mod.rs +++ b/src/player/mod.rs @@ -848,15 +848,19 @@ fn native_device_name_from_user_agent(user_agent: Option<&str>) -> Option format!("Furumi Android {version}"), - None => "Furumi Android".to_string(), - }); + if product.eq_ignore_ascii_case("FurumiAndroid") { + return Some(match version.as_deref() { + Some(v) => format!("Furumi Android {v}"), + None => "Furumi Android".to_string(), + }); + } + if product.eq_ignore_ascii_case("FurumiMacOS") { + return Some(match version.as_deref() { + Some(v) => format!("Furumi MacOS {v}"), + None => "Furumi MacOS".to_string(), + }); + } } None } @@ -883,6 +887,9 @@ fn device_kind_from_user_agent(user_agent: Option<&str>) -> &'static str { "phone" }; } + if ua.contains("furumimac") { + return "computer"; + } if ua.contains("iphone") || (ua.contains("android") && ua.contains("mobile")) { "phone" } else if ua.contains("ipad") || ua.contains("tablet") || ua.contains("android") {