diff --git a/Cargo.lock b/Cargo.lock index 5780812..c880b8c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6094,7 +6094,7 @@ dependencies = [ [[package]] name = "tranquil-auth" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "base32", @@ -6117,7 +6117,7 @@ dependencies = [ [[package]] name = "tranquil-cache" -version = "0.3.0" +version = "0.3.1" dependencies = [ "async-trait", "base64 0.22.1", @@ -6131,7 +6131,7 @@ dependencies = [ [[package]] name = "tranquil-comms" -version = "0.3.0" +version = "0.3.1" dependencies = [ "async-trait", "base64 0.22.1", @@ -6146,7 +6146,7 @@ dependencies = [ [[package]] name = "tranquil-config" -version = "0.3.0" +version = "0.3.1" dependencies = [ "confique", "serde", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "tranquil-crypto" -version = "0.3.0" +version = "0.3.1" dependencies = [ "aes-gcm", "base64 0.22.1", @@ -6170,7 +6170,7 @@ dependencies = [ [[package]] name = "tranquil-db" -version = "0.3.0" +version = "0.3.1" dependencies = [ "async-trait", "chrono", @@ -6187,7 +6187,7 @@ dependencies = [ [[package]] name = "tranquil-db-traits" -version = "0.3.0" +version = "0.3.1" dependencies = [ "async-trait", "base64 0.22.1", @@ -6203,7 +6203,7 @@ dependencies = [ [[package]] name = "tranquil-infra" -version = "0.3.0" +version = "0.3.1" dependencies = [ "async-trait", "bytes", @@ -6214,7 +6214,7 @@ dependencies = [ [[package]] name = "tranquil-oauth" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "axum", @@ -6237,7 +6237,7 @@ dependencies = [ [[package]] name = "tranquil-pds" -version = "0.3.0" +version = "0.3.1" dependencies = [ "aes-gcm", "anyhow", @@ -6324,7 +6324,7 @@ dependencies = [ [[package]] name = "tranquil-repo" -version = "0.3.0" +version = "0.3.1" dependencies = [ "bytes", "cid", @@ -6336,7 +6336,7 @@ dependencies = [ [[package]] name = "tranquil-ripple" -version = "0.3.0" +version = "0.3.1" dependencies = [ "async-trait", "backon", @@ -6361,7 +6361,7 @@ dependencies = [ [[package]] name = "tranquil-scopes" -version = "0.3.0" +version = "0.3.1" dependencies = [ "axum", "futures", @@ -6377,7 +6377,7 @@ dependencies = [ [[package]] name = "tranquil-storage" -version = "0.3.0" +version = "0.3.1" dependencies = [ "async-trait", "aws-config", @@ -6394,7 +6394,7 @@ dependencies = [ [[package]] name = "tranquil-types" -version = "0.3.0" +version = "0.3.1" dependencies = [ "chrono", "cid", diff --git a/Cargo.toml b/Cargo.toml index 665fd6f..0753c22 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ members = [ ] [workspace.package] -version = "0.3.0" +version = "0.3.1" edition = "2024" license = "AGPL-3.0-or-later" diff --git a/crates/tranquil-pds/src/api/identity/account.rs b/crates/tranquil-pds/src/api/identity/account.rs index b31cae5..3b79ae4 100644 --- a/crates/tranquil-pds/src/api/identity/account.rs +++ b/crates/tranquil-pds/src/api/identity/account.rs @@ -140,7 +140,8 @@ pub async fn create_account( } } - let available_domains = tranquil_config::get().server.available_user_domain_list(); + let cfg = tranquil_config::get(); + let available_domains = cfg.server.available_user_domain_list(); let matched_domain = available_domains .iter() .filter(|d| input.handle.ends_with(&format!(".{}", d))) @@ -163,23 +164,10 @@ pub async fn create_account( } } } else { - if input.handle.contains(' ') || input.handle.contains('\t') { - return ApiError::InvalidRequest("Handle cannot contain spaces".into()).into_response(); + match crate::api::validation::validate_full_domain_handle(&input.handle) { + Ok(h) => h, + Err(e) => return ApiError::from(e).into_response(), } - if let Some(c) = input - .handle - .chars() - .find(|c| !c.is_ascii_alphanumeric() && *c != '.' && *c != '-') - { - return ApiError::InvalidRequest(format!("Handle contains invalid character: {}", c)) - .into_response(); - } - let handle_lower = input.handle.to_lowercase(); - if crate::moderation::has_explicit_slur(&handle_lower) { - return ApiError::InvalidRequest("Inappropriate language in handle".into()) - .into_response(); - } - handle_lower }; let email: Option = input .email @@ -234,7 +222,7 @@ pub async fn create_account( }, }) }; - let hostname = &tranquil_config::get().server.hostname; + let hostname = &cfg.server.hostname; let pds_endpoint = format!("https://{}", hostname); let handle = match matched_domain { Some(domain) => format!("{}.{}", validated_short_handle, domain), @@ -274,10 +262,8 @@ pub async fn create_account( if !crate::api::server::meta::is_self_hosted_did_web_enabled() { return ApiError::SelfHostedDidWebDisabled.into_response(); } - let pds_hostname = tranquil_config::get().server.hostname_without_port(); - let subdomain_host = format!("{}.{}", input.handle, pds_hostname); - let encoded_subdomain = subdomain_host.replace(':', "%3A"); - let self_hosted_did = format!("did:web:{}", encoded_subdomain); + let encoded_handle = handle.replace(':', "%3A"); + let self_hosted_did = format!("did:web:{}", encoded_handle); info!(did = %self_hosted_did, "Creating self-hosted did:web account (subdomain)"); self_hosted_did } diff --git a/crates/tranquil-pds/src/api/identity/did.rs b/crates/tranquil-pds/src/api/identity/did.rs index ed1d565..7b96490 100644 --- a/crates/tranquil-pds/src/api/identity/did.rs +++ b/crates/tranquil-pds/src/api/identity/did.rs @@ -122,17 +122,21 @@ pub fn get_public_key_multibase(key_bytes: &[u8]) -> Result { } pub async fn well_known_did(State(state): State, headers: HeaderMap) -> Response { - let hostname = &tranquil_config::get().server.hostname; - let hostname_without_port = tranquil_config::get().server.hostname_without_port(); + let cfg = tranquil_config::get(); + let hostname = &cfg.server.hostname; + let hostname_without_port = cfg.server.hostname_without_port(); let host_header = get_header_str(&headers, http::header::HOST).unwrap_or(hostname); let host_without_port = host_header.split(':').next().unwrap_or(host_header); - if host_without_port != hostname_without_port - && host_without_port.ends_with(&format!(".{}", hostname_without_port)) - { - let handle = host_without_port - .strip_suffix(&format!(".{}", hostname_without_port)) - .unwrap_or(host_without_port); - return serve_subdomain_did_doc(&state, handle, hostname).await; + if host_without_port != hostname_without_port { + let is_subdomain = cfg + .server + .available_user_domain_list() + .into_iter() + .chain(std::iter::once(hostname_without_port.to_string())) + .any(|d| host_without_port.ends_with(&format!(".{}", d))); + if is_subdomain { + return serve_handle_did_doc(&state, host_without_port, hostname).await; + } } let did = if hostname.contains(':') { format!("did:web:{}", hostname.replace(':', "%3A")) @@ -151,11 +155,9 @@ pub async fn well_known_did(State(state): State, headers: HeaderMap) - .into_response() } -async fn serve_subdomain_did_doc(state: &AppState, subdomain: &str, hostname: &str) -> Response { - let hostname_for_handles = hostname.split(':').next().unwrap_or(hostname); - let subdomain_host = format!("{}.{}", subdomain, hostname_for_handles); - let encoded_subdomain = subdomain_host.replace(':', "%3A"); - let expected_did = format!("did:web:{}", encoded_subdomain); +async fn serve_handle_did_doc(state: &AppState, handle: &str, hostname: &str) -> Response { + let encoded_handle = handle.replace(':', "%3A"); + let expected_did = format!("did:web:{}", encoded_handle); let expected_did_typed: crate::types::Did = match expected_did.parse() { Ok(d) => d, Err(_) => return ApiError::InvalidRequest("Invalid DID format".into()).into_response(), diff --git a/crates/tranquil-pds/src/api/proxy_client.rs b/crates/tranquil-pds/src/api/proxy_client.rs index edec606..99e774d 100644 --- a/crates/tranquil-pds/src/api/proxy_client.rs +++ b/crates/tranquil-pds/src/api/proxy_client.rs @@ -215,15 +215,12 @@ mod tests { use super::*; #[test] fn test_ssrf_safe_https() { - assert!(is_ssrf_safe("https://api.bsky.app/xrpc/test").is_ok()); + assert!(is_ssrf_safe("https://1.1.1.1/xrpc/test").is_ok()); } #[test] fn test_ssrf_blocks_http_by_default() { - let result = is_ssrf_safe("http://external.example.com/xrpc/test"); - assert!(matches!( - result, - Err(SsrfError::InsecureProtocol(_)) | Err(SsrfError::DnsResolutionFailed(_)) - )); + let result = is_ssrf_safe("http://93.184.216.34/xrpc/test"); + assert!(matches!(result, Err(SsrfError::InsecureProtocol(_)))); } #[test] fn test_ssrf_allows_localhost_http() { @@ -231,6 +228,21 @@ mod tests { assert!(is_ssrf_safe("http://localhost:8080/test").is_ok()); } #[test] + fn test_ssrf_blocks_non_unicast_ip() { + assert!(matches!( + is_ssrf_safe("https://0.0.0.0/test"), + Err(SsrfError::NonUnicastIp(_)) + )); + assert!(matches!( + is_ssrf_safe("https://224.0.0.1/test"), + Err(SsrfError::NonUnicastIp(_)) + )); + assert!(matches!( + is_ssrf_safe("https://255.255.255.255/test"), + Err(SsrfError::NonUnicastIp(_)) + )); + } + #[test] fn test_validate_at_uri() { let result = validate_at_uri("at://did:plc:test/app.bsky.feed.post/abc123"); assert!(result.is_ok()); diff --git a/crates/tranquil-pds/src/api/server/passkey_account.rs b/crates/tranquil-pds/src/api/server/passkey_account.rs index f82471e..8c25946 100644 --- a/crates/tranquil-pds/src/api/server/passkey_account.rs +++ b/crates/tranquil-pds/src/api/server/passkey_account.rs @@ -112,8 +112,9 @@ pub async fn create_passkey_account( .map(|d| d.starts_with("did:web:")) .unwrap_or(false); - let hostname = &tranquil_config::get().server.hostname; - let available_domains = tranquil_config::get().server.available_user_domain_list(); + let cfg = tranquil_config::get(); + let hostname = &cfg.server.hostname; + let available_domains = cfg.server.available_user_domain_list(); let matched_domain = available_domains .iter() .filter(|d| input.handle.ends_with(&format!(".{}", d))) @@ -134,7 +135,10 @@ pub async fn create_passkey_account( } } } else { - input.handle.to_lowercase() + match crate::api::validation::validate_full_domain_handle(&input.handle) { + Ok(h) => h, + Err(_) => return ApiError::InvalidHandle(None).into_response(), + } }; let email = input @@ -246,10 +250,11 @@ pub async fn create_passkey_account( let did = match did_type { "web" => { - let pds_hostname = tranquil_config::get().server.hostname_without_port(); - let subdomain_host = format!("{}.{}", input.handle, pds_hostname); - let encoded_subdomain = subdomain_host.replace(':', "%3A"); - let self_hosted_did = format!("did:web:{}", encoded_subdomain); + if !crate::api::server::meta::is_self_hosted_did_web_enabled() { + return ApiError::SelfHostedDidWebDisabled.into_response(); + } + let encoded_handle = handle.replace(':', "%3A"); + let self_hosted_did = format!("did:web:{}", encoded_handle); info!(did = %self_hosted_did, "Creating self-hosted did:web passkey account"); self_hosted_did } diff --git a/crates/tranquil-pds/src/api/validation.rs b/crates/tranquil-pds/src/api/validation.rs index 2d0ae6d..babbbed 100644 --- a/crates/tranquil-pds/src/api/validation.rs +++ b/crates/tranquil-pds/src/api/validation.rs @@ -258,6 +258,49 @@ pub enum ReservedHandlePolicy { Reject, } +pub fn validate_full_domain_handle(handle: &str) -> Result { + let handle = handle.trim(); + + if handle.is_empty() { + return Err(HandleValidationError::Empty); + } + + if handle.contains(' ') || handle.contains('\t') || handle.contains('\n') { + return Err(HandleValidationError::ContainsSpaces); + } + + if handle.len() > MAX_HANDLE_LENGTH { + return Err(HandleValidationError::TooLong); + } + + if handle + .chars() + .any(|c| !c.is_ascii_alphanumeric() && c != '.' && c != '-') + { + return Err(HandleValidationError::InvalidCharacters); + } + + if !handle.contains('.') { + return Err(HandleValidationError::InvalidCharacters); + } + + let labels: Vec<&str> = handle.split('.').collect(); + let has_invalid_label = labels + .iter() + .any(|label| label.is_empty() || label.len() > MAX_DOMAIN_LABEL_LENGTH || label.starts_with('-') || label.ends_with('-')); + if has_invalid_label { + return Err(HandleValidationError::InvalidCharacters); + } + + let handle_lower = handle.to_lowercase(); + + if crate::moderation::has_explicit_slur(&handle_lower) { + return Err(HandleValidationError::BannedWord); + } + + Ok(handle_lower) +} + pub fn validate_short_handle(handle: &str) -> Result { validate_service_handle(handle, ReservedHandlePolicy::Reject) } diff --git a/crates/tranquil-pds/src/sso/endpoints.rs b/crates/tranquil-pds/src/sso/endpoints.rs index 2eb18b9..9b514e3 100644 --- a/crates/tranquil-pds/src/sso/endpoints.rs +++ b/crates/tranquil-pds/src/sso/endpoints.rs @@ -743,6 +743,7 @@ pub async fn get_pending_registration( #[derive(Debug, Deserialize)] pub struct CheckHandleQuery { pub handle: String, + pub domain: Option, } #[derive(Debug, Serialize)] @@ -773,7 +774,18 @@ pub async fn check_handle_available( }; let available_domains = tranquil_config::get().server.available_user_domain_list(); - let full_handle = format!("{}.{}", validated, &available_domains[0]); + if let Some(ref d) = query.domain { + if !available_domains.iter().any(|ad| ad == d) { + return Err(ApiError::InvalidRequest( + "Unknown user domain".into(), + )); + } + } + let domain = query + .domain + .as_deref() + .unwrap_or(&available_domains[0]); + let full_handle = format!("{}.{}", validated, domain); let handle_typed: crate::types::Handle = match full_handle.parse() { Ok(h) => h, Err(_) => return Err(ApiError::InvalidHandle(None)), @@ -855,12 +867,32 @@ pub async fn complete_registration( .await? .ok_or(ApiError::SsoSessionExpired)?; - let hostname = &tranquil_config::get().server.hostname; - let available_domains = tranquil_config::get().server.available_user_domain_list(); + let cfg = tranquil_config::get(); + let hostname = &cfg.server.hostname; + let available_domains = cfg.server.available_user_domain_list(); - let handle = match crate::api::validation::validate_short_handle(&input.handle) { - Ok(h) => format!("{}.{}", h, &available_domains[0]), - Err(_) => return Err(ApiError::InvalidHandle(None)), + let matched_domain = available_domains + .iter() + .filter(|d| input.handle.ends_with(&format!(".{}", d))) + .max_by_key(|d| d.len()); + + let handle = if !input.handle.contains('.') || matched_domain.is_some() { + let handle_to_validate = match matched_domain { + Some(domain) => input + .handle + .strip_suffix(&format!(".{}", domain)) + .unwrap_or(&input.handle), + None => &input.handle, + }; + match crate::api::validation::validate_short_handle(handle_to_validate) { + Ok(h) => format!("{}.{}", h, matched_domain.unwrap_or(&available_domains[0])), + Err(_) => return Err(ApiError::InvalidHandle(None)), + } + } else { + match crate::api::validation::validate_full_domain_handle(&input.handle) { + Ok(h) => h, + Err(_) => return Err(ApiError::InvalidHandle(None)), + } }; let verification_channel = input @@ -981,10 +1013,11 @@ pub async fn complete_registration( let did = match did_type { "web" => { - let pds_hostname = tranquil_config::get().server.hostname_without_port(); - let subdomain_host = format!("{}.{}", input.handle, pds_hostname); - let encoded_subdomain = subdomain_host.replace(':', "%3A"); - let self_hosted_did = format!("did:web:{}", encoded_subdomain); + if !crate::api::server::meta::is_self_hosted_did_web_enabled() { + return Err(ApiError::SelfHostedDidWebDisabled); + } + let encoded_handle = handle.replace(':', "%3A"); + let self_hosted_did = format!("did:web:{}", encoded_handle); tracing::info!(did = %self_hosted_did, "Creating self-hosted did:web SSO account"); self_hosted_did } diff --git a/crates/tranquil-pds/tests/firehose/mod.rs b/crates/tranquil-pds/tests/firehose/mod.rs index 2ee0bb3..e832ea5 100644 --- a/crates/tranquil-pds/tests/firehose/mod.rs +++ b/crates/tranquil-pds/tests/firehose/mod.rs @@ -133,10 +133,6 @@ pub struct FirehoseConsumer { } impl FirehoseConsumer { - pub async fn connect(port: u16) -> Self { - Self::connect_inner(port, None).await - } - pub async fn connect_with_cursor(port: u16, cursor: i64) -> Self { Self::connect_inner(port, Some(cursor)).await } diff --git a/crates/tranquil-pds/tests/handle_domains.rs b/crates/tranquil-pds/tests/handle_domains.rs index 40f4b36..2420e96 100644 --- a/crates/tranquil-pds/tests/handle_domains.rs +++ b/crates/tranquil-pds/tests/handle_domains.rs @@ -276,3 +276,37 @@ async fn update_handle_bare_uses_configured_domain() { "updateHandle with bare handle should use configured domain, not PDS hostname" ); } + +#[tokio::test] +async fn did_web_uses_handle_domain_not_hostname() { + unsafe { + std::env::set_var("ENABLE_PDS_HOSTED_DID_WEB", "true"); + } + let client = client(); + let base = base_url_with_domain().await; + let short_handle = format!("hd{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); + let payload = json!({ + "handle": short_handle, + "email": format!("{}@example.com", short_handle), + "password": "Testpass123!", + "didType": "web" + }); + let res = client + .post(format!( + "{}/xrpc/com.atproto.server.createAccount", + base + )) + .json(&payload) + .send() + .await + .expect("createAccount request failed"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Invalid JSON"); + let did = body["did"].as_str().expect("No DID in response"); + let expected_did = format!("did:web:{}.{}", short_handle, HANDLE_DOMAIN); + assert_eq!( + did, expected_did, + "did:web should use handle domain '{}', not PDS hostname", + HANDLE_DOMAIN + ); +} diff --git a/frontend/src/lib/registration/flow.svelte.ts b/frontend/src/lib/registration/flow.svelte.ts index 649c807..204f997 100644 --- a/frontend/src/lib/registration/flow.svelte.ts +++ b/frontend/src/lib/registration/flow.svelte.ts @@ -34,6 +34,7 @@ export interface RegistrationFlowState { error: string | null; submitting: boolean; pdsHostname: string; + selectedDomain: string; handleAvailable: boolean | null; checkingHandle: boolean; discordInUse: boolean; @@ -68,6 +69,7 @@ export function createRegistrationFlow( error: null, submitting: false, pdsHostname, + selectedDomain: "", handleAvailable: null, checkingHandle: false, discordInUse: false, @@ -84,7 +86,10 @@ export function createRegistrationFlow( } function getFullHandle(): string { - return `${state.info.handle.trim()}.${state.pdsHostname}`; + const handle = state.info.handle.trim(); + if (handle.includes('.')) return handle; + const domain = state.selectedDomain || state.pdsHostname; + return `${handle}.${domain}`; } function extractDomain(did: string): string { @@ -132,10 +137,10 @@ export function createRegistrationFlow( } state.checkingHandle = true; try { + const params = new URLSearchParams({ handle }); + if (state.selectedDomain) params.set("domain", state.selectedDomain); const response = await fetch( - `${getPdsEndpoint()}/oauth/sso/check-handle-available?handle=${ - encodeURIComponent(handle) - }`, + `${getPdsEndpoint()}/oauth/sso/check-handle-available?${params}`, ); const data = await response.json(); state.handleAvailable = data.available === true; @@ -239,7 +244,7 @@ export function createRegistrationFlow( } const result = await api.createAccount({ - handle: state.info.handle.trim(), + handle: getFullHandle(), email: state.info.email.trim(), password: state.info.password!, inviteCode: state.info.inviteCode?.trim() || undefined, @@ -291,7 +296,7 @@ export function createRegistrationFlow( } const result = await api.createPasskeyAccount({ - handle: unsafeAsHandle(state.info.handle.trim()), + handle: unsafeAsHandle(getFullHandle()), email: state.info.email?.trim() ? unsafeAsEmail(state.info.email.trim()) : undefined, @@ -532,6 +537,9 @@ export function createRegistrationFlow( getPdsDid, getFullHandle, extractDomain, + setSelectedDomain(domain: string) { + state.selectedDomain = domain; + }, proceedFromInfo, selectKeyMode, diff --git a/frontend/src/routes/OAuthRegister.svelte b/frontend/src/routes/OAuthRegister.svelte index 1f5c210..10be4e5 100644 --- a/frontend/src/routes/OAuthRegister.svelte +++ b/frontend/src/routes/OAuthRegister.svelte @@ -102,6 +102,7 @@ flow = createRegistrationFlow('passkey', hostname) } selectedDomain = serverInfo?.availableUserDomains?.[0] || window.location.hostname + if (flow) flow.setSelectedDomain(selectedDomain) } catch (e) { console.error('Failed to load server info:', e) } finally { @@ -353,7 +354,7 @@ placeholder={$_('register.handlePlaceholder')} disabled={flow.state.submitting} onInput={(v) => { flow!.info.handle = v }} - onDomainChange={(d) => { selectedDomain = d }} + onDomainChange={(d) => { selectedDomain = d; flow!.setSelectedDomain(d) }} /> {#if fullHandle()}

{$_('register.handleHint', { values: { handle: fullHandle() } })}

diff --git a/frontend/src/routes/OAuthSsoRegister.svelte b/frontend/src/routes/OAuthSsoRegister.svelte index e61421d..418afbf 100644 --- a/frontend/src/routes/OAuthSsoRegister.svelte +++ b/frontend/src/routes/OAuthSsoRegister.svelte @@ -150,6 +150,7 @@ let checkHandleTimeout: ReturnType | null = null $effect(() => { + void selectedDomain if (checkHandleTimeout) { clearTimeout(checkHandleTimeout) } @@ -167,7 +168,9 @@ handleError = null try { - const response = await fetch(`/oauth/sso/check-handle-available?handle=${encodeURIComponent(handle)}`) + const params = new URLSearchParams({ handle }) + if (selectedDomain) params.set('domain', selectedDomain) + const response = await fetch(`/oauth/sso/check-handle-available?${params}`) const data = await response.json() handleAvailable = data.available if (!data.available && data.reason) { @@ -222,6 +225,9 @@ return } + const fullHandle = !handle.includes('.') && selectedDomain + ? `${handle.trim()}.${selectedDomain}` + : handle.trim() submitting = true try { @@ -233,7 +239,7 @@ }, body: JSON.stringify({ token, - handle, + handle: fullHandle, email: email || null, invite_code: inviteCode || null, verification_channel: verificationChannel, diff --git a/frontend/src/routes/Register.svelte b/frontend/src/routes/Register.svelte index 9157552..82c2faa 100644 --- a/frontend/src/routes/Register.svelte +++ b/frontend/src/routes/Register.svelte @@ -115,6 +115,7 @@ flow = createRegistrationFlow('passkey', hostname) } selectedDomain = serverInfo?.availableUserDomains?.[0] || window.location.hostname + if (flow) flow.setSelectedDomain(selectedDomain) } catch (e) { console.error('Failed to load server info:', e) } finally { @@ -368,7 +369,7 @@ placeholder={$_('register.handlePlaceholder')} disabled={flow.state.submitting} onInput={(v) => { flow!.info.handle = v }} - onDomainChange={(d) => { selectedDomain = d }} + onDomainChange={(d) => { selectedDomain = d; flow!.setSelectedDomain(d) }} /> {#if flow.info.handle.includes('.')}

{$_('register.handleDotWarning')}

diff --git a/frontend/src/routes/RegisterPassword.svelte b/frontend/src/routes/RegisterPassword.svelte index 13f8aae..6eaf8d8 100644 --- a/frontend/src/routes/RegisterPassword.svelte +++ b/frontend/src/routes/RegisterPassword.svelte @@ -109,6 +109,7 @@ flow = createRegistrationFlow('password', hostname) } selectedDomain = serverInfo?.availableUserDomains?.[0] || window.location.hostname + if (flow) flow.setSelectedDomain(selectedDomain) } catch (e) { console.error('Failed to load server info:', e) } finally { @@ -313,7 +314,7 @@ placeholder={$_('register.handlePlaceholder')} disabled={flow.state.submitting} onInput={(v) => { flow!.info.handle = v }} - onDomainChange={(d) => { selectedDomain = d }} + onDomainChange={(d) => { selectedDomain = d; flow!.setSelectedDomain(d) }} /> {#if flow.info.handle.includes('.')}

{$_('register.handleDotWarning')}

diff --git a/frontend/src/routes/SsoRegisterComplete.svelte b/frontend/src/routes/SsoRegisterComplete.svelte index b2ae83e..f2c17c9 100644 --- a/frontend/src/routes/SsoRegisterComplete.svelte +++ b/frontend/src/routes/SsoRegisterComplete.svelte @@ -165,6 +165,7 @@ let checkHandleTimeout: ReturnType | null = null $effect(() => { + void selectedDomain if (checkHandleTimeout) { clearTimeout(checkHandleTimeout) } @@ -182,7 +183,9 @@ handleError = null try { - const response = await fetch(`/oauth/sso/check-handle-available?handle=${encodeURIComponent(handle)}`) + const params = new URLSearchParams({ handle }) + if (selectedDomain) params.set('domain', selectedDomain) + const response = await fetch(`/oauth/sso/check-handle-available?${params}`) const data = await response.json() handleAvailable = data.available if (!data.available && data.reason) { @@ -269,6 +272,9 @@ return } + const fullHandle = !handle.includes('.') && selectedDomain + ? `${handle.trim()}.${selectedDomain}` + : handle.trim() submitting = true try { @@ -280,7 +286,7 @@ }, body: JSON.stringify({ token, - handle, + handle: fullHandle, email: email || null, invite_code: inviteCode || null, verification_channel: verificationChannel,