diff --git a/src/components/dialogs/ServerInput.tsx b/src/components/dialogs/ServerInput.tsx index 0b769ae31e..d900fe3992 100644 --- a/src/components/dialogs/ServerInput.tsx +++ b/src/components/dialogs/ServerInput.tsx @@ -5,6 +5,7 @@ import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' import {BSKY_SERVICE} from '#/lib/constants' +import {enforceLen} from '#/lib/strings/helpers' import * as persisted from '#/state/persisted' import {useSession} from '#/state/session' import {atoms as a, platform, useBreakpoints, useTheme, web} from '#/alf' @@ -20,6 +21,40 @@ import {useAnalytics} from '#/analytics' type SegmentedControlOptions = typeof BSKY_SERVICE | 'custom' +// Max recent-server shortcuts to keep/show, and the max label length before a +// chip is middle-truncated so it can't overflow the dialog. +const MAX_PDS_HISTORY = 5 +const MAX_PDS_LABEL_LEN = 28 + +/** + * Adds a scheme if the user omitted one. `localhost` defaults to http, anything + * else to https. + */ +function normalizeServerUrl(raw: string): string { + const url = raw.trim().toLowerCase() + if (!url || url.startsWith('http://') || url.startsWith('https://')) { + return url + } + if (url === 'localhost' || url.startsWith('localhost:')) { + return `http://${url}` + } + return `https://${url}` +} + +/** + * A custom server address is valid if it parses as a URL with a hostname that + * is either `localhost` or a dotted domain. This rejects garbage like + * `localhost:2583asd` (invalid port) or a bare word with no TLD. + */ +function isValidServerUrl(raw: string): boolean { + try { + const {hostname} = new URL(normalizeServerUrl(raw)) + return hostname === 'localhost' || hostname.includes('.') + } catch { + return false + } +} + export function ServerInputDialog({ control, onSelect, @@ -100,6 +135,7 @@ function DialogInner({ const {accounts} = useSession() const {gtMobile} = useBreakpoints() const [customAddress, setCustomAddress] = useState(initialCustomAddress) + const [validationError, setValidationError] = useState('') const [pdsAddressHistory, setPdsAddressHistory] = useState( persisted.get('pdsAddressHistory') || [], ) @@ -108,31 +144,24 @@ function DialogInner({ formRef, () => ({ getFormState: () => { - let url - if (fixedOption === 'custom') { - url = customAddress.trim().toLowerCase() - if (!url) { - return null - } - } else { - url = fixedOption + if (fixedOption !== 'custom') { + return fixedOption } - if (!url.startsWith('http://') && !url.startsWith('https://')) { - if (url === 'localhost' || url.startsWith('localhost:')) { - url = `http://${url}` - } else { - url = `https://${url}` - } + // Guard against the dialog being dismissed (backdrop, escape, drag) + // with an empty or invalid address - don't propagate garbage. + if (!customAddress.trim() || !isValidServerUrl(customAddress)) { + return null } - - if (fixedOption === 'custom') { - if (!pdsAddressHistory.includes(url)) { - const newHistory = [url, ...pdsAddressHistory.slice(0, 4)] - setPdsAddressHistory(newHistory) - persisted.write('pdsAddressHistory', newHistory) - } + const url = normalizeServerUrl(customAddress) + if (!pdsAddressHistory.includes(url)) { + const newHistory = [ + url, + // Prune any legacy invalid entries while we're writing. + ...pdsAddressHistory.filter(isValidServerUrl), + ].slice(0, MAX_PDS_HISTORY) + setPdsAddressHistory(newHistory) + persisted.write('pdsAddressHistory', newHistory) } - return url }, }), @@ -141,6 +170,12 @@ function DialogInner({ const isFirstTimeUser = accounts.length === 0 + // Drop legacy/invalid entries (history predates input validation) and cap the + // count so the shortcuts can't overflow the dialog. + const recentServers = pdsAddressHistory + .filter(isValidServerUrl) + .slice(0, MAX_PDS_HISTORY) + return ( Server address - + { + setCustomAddress(v) + if (validationError) setValidationError('') + }} label="my-server.com" accessibilityLabelledBy="address-input-label" autoCapitalize="none" keyboardType="url" /> - {pdsAddressHistory.length > 0 && ( + {validationError ? ( + + {validationError} + + ) : null} + {recentServers.length > 0 && ( - {pdsAddressHistory.map(uri => ( + {recentServers.map(uri => ( ))} @@ -257,7 +302,21 @@ function DialogInner({ native: 'large', web: 'small', })} - onPress={() => control.close()} + onPress={() => { + // Block closing with an invalid custom address. An empty address + // is allowed - it clears the override / keeps the default. + if ( + fixedOption === 'custom' && + customAddress.trim() && + !isValidServerUrl(customAddress) + ) { + setValidationError( + _(msg`Enter a valid server address, e.g. example.com`), + ) + return + } + control.close() + }} label={_(msg`Done`)}> Done diff --git a/src/screens/Login/LoginForm.tsx b/src/screens/Login/LoginForm.tsx index abdac82746..e6bfad79d8 100644 --- a/src/screens/Login/LoginForm.tsx +++ b/src/screens/Login/LoginForm.tsx @@ -8,16 +8,22 @@ import { import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' +import {type QueryClient, useQueryClient} from '@tanstack/react-query' import {isBlueskyHostedPds, ResolvePdsError} from '#/lib/api/resolve-pds' import {BSKY_SERVICE, DEFAULT_SERVICE} from '#/lib/constants' import {useRequestNotificationsPermission} from '#/lib/notifications/notifications' import {cleanError, isNetworkError} from '#/lib/strings/errors' import {createFullHandle} from '#/lib/strings/handles' +import {enforceLen} from '#/lib/strings/helpers' import {toNiceDomain} from '#/lib/strings/url-helpers' import {logger} from '#/logger' import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs' -import {useResolvePdsQuery} from '#/state/queries/resolve-pds' +import { + looksResolvable, + resolvePdsQueryOptions, + useResolvePdsQuery, +} from '#/state/queries/resolve-pds' import {useSessionApi} from '#/state/session' import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {atoms as a, ios, native, useTheme, web} from '#/alf' @@ -38,6 +44,51 @@ import {FormContainer} from './FormContainer' type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema +/** + * Truncate a host for display so an unexpectedly long (or unparseable, e.g. a + * pasted blob) server string can't blow out the layout. Middle-truncation + * keeps the recognizable start and the TLD/port. + */ +function niceHostLabel(url: string): string { + return enforceLen(toNiceDomain(url), 32, true, 'middle') +} + +// Upper bound on how long a submit will wait for host resolution before +// falling back to the default service, so a slow lookup can never trap the +// Sign in button. +const RESOLVE_ON_SUBMIT_TIMEOUT_MS = 2e3 + +/** + * Best-effort host resolution at submit time. Reuses any in-flight or cached + * resolution (same query key as the background hook) and only fires a request + * if none exists. Returns the resolved PDS, or undefined on timeout/failure so + * the caller falls back to the default service. + */ +async function resolvePdsOnSubmit( + queryClient: QueryClient, + handle: string, +): Promise { + let timer: ReturnType | undefined + try { + const result = await Promise.race([ + queryClient.fetchQuery(resolvePdsQueryOptions(handle)), + new Promise(resolve => { + timer = setTimeout( + () => resolve(undefined), + RESOLVE_ON_SUBMIT_TIMEOUT_MS, + ) + }), + ]) + return result?.pds + } catch { + // Bad handle, network error, or no PDS in the DID doc. Fall back to the + // default service - login will surface any genuine auth error. + return undefined + } finally { + if (timer) clearTimeout(timer) + } +} + export const LoginForm = ({ error, serviceUrl, @@ -80,6 +131,7 @@ export const LoginForm = ({ const passwordRef = useRef(null) const hasFocusedOnce = useRef(false) const {_} = useLingui() + const queryClient = useQueryClient() const {login} = useSessionApi() const requestNotificationsPermission = useRequestNotificationsPermission() const {setShowLoggedOut} = useLoggedOutViewControls() @@ -198,13 +250,20 @@ export const LoginForm = ({ } } - // Make sure the resolved-PDS state is in sync with the current input - // before deciding which service to use. + // Keep the status message in sync with the submitted handle. if (handleForResolve !== fullIdent) { setHandleForResolve(fullIdent) } - const service = - customServerOverride ?? resolveQuery.data?.pds ?? serviceUrl + + // Pick the service to sign in to. A custom server override always wins. + // Otherwise ensure host resolution has finished first: a fast submit + // (e.g. password-manager autofill) can beat the background lookup and + // wrongly default to the main service. + let service = customServerOverride ?? serviceUrl + if (!customServerOverride && looksResolvable(fullIdent)) { + const resolvedPds = await resolvePdsOnSubmit(queryClient, fullIdent) + if (resolvedPds) service = resolvedPds + } // TODO remove double login await login( @@ -499,7 +558,7 @@ function PdsResolveStatus({ content = ( - You're signing in to {toNiceDomain(override)}.{' '} + You're signing in to {niceHostLabel(override)}.{' '} - Resolving your server… - + + + ) } else if (query.isError) { contentKey = 'error' @@ -557,7 +618,7 @@ function PdsResolveStatus({ content = ( - You're signing in to {toNiceDomain(query.data.pds)} + You're signing in to {niceHostLabel(query.data.pds)} ) } diff --git a/src/state/queries/resolve-pds.ts b/src/state/queries/resolve-pds.ts index 6b0fffdf3f..bb9d25c0af 100644 --- a/src/state/queries/resolve-pds.ts +++ b/src/state/queries/resolve-pds.ts @@ -6,20 +6,41 @@ import {STALE} from '#/state/queries' const RQKEY_ROOT = 'resolve-pds' export const RQKEY = (handle: string) => [RQKEY_ROOT, handle] -export function useResolvePdsQuery(handle: string, opts?: {enabled?: boolean}) { - const normalized = handle.trim().replace(/^@/, '').toLowerCase() - // Only auto-resolve when the input looks like a full handle or a DID. - // Skip emails (contain `@`) so legacy email-login users keep going to the - // default service. - const looksResolvable = +function normalizeHandle(handle: string) { + return handle.trim().replace(/^@/, '').toLowerCase() +} + +/** + * Whether the input is worth resolving. Only resolve when it looks like a full + * handle or a DID. Skip emails (contain `@`) so legacy email-login users keep + * going to the default service. + */ +export function looksResolvable(handle: string) { + const normalized = normalizeHandle(handle) + return ( !normalized.includes('@') && (normalized.startsWith('did:') || normalized.includes('.')) - return useQuery({ - enabled: (opts?.enabled ?? true) && looksResolvable, + ) +} + +/** + * Shared query config so the background hook and any imperative `fetchQuery` + * (e.g. resolving on form submit) use the same key, cache, and options. + */ +export function resolvePdsQueryOptions(handle: string) { + const normalized = normalizeHandle(handle) + return { queryKey: RQKEY(normalized), queryFn: () => resolvePdsForHandle(normalized), staleTime: STALE.MINUTES.FIVE, // Don't retry — failures fall back to manual server entry, no point hammering. - retry: false, + retry: false as const, + } +} + +export function useResolvePdsQuery(handle: string, opts?: {enabled?: boolean}) { + return useQuery({ + ...resolvePdsQueryOptions(handle), + enabled: (opts?.enabled ?? true) && looksResolvable(handle), }) }