Harden custom-server login: validate input, resolve PDS on submit, truncate hosts
- Validate the custom-server address (reject unparseable/invalid hosts) and gate the dialog's Done button on it; truncate + cap the recent-server shortcuts - Resolve the host on submit (best-effort, timeout-bounded, falls back to the default) so a fast submit / password-manager autofill can't beat the background lookup - Middle-truncate displayed hosts so long/unparseable values can't blow out the layout; show a spinner while resolving
This commit is contained in:
@@ -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<string[]>(
|
||||
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 (
|
||||
<Dialog.ScrollableInner
|
||||
accessibilityDescribedBy="dialog-description"
|
||||
@@ -196,21 +231,29 @@ function DialogInner({
|
||||
<TextField.LabelText nativeID="address-input-label">
|
||||
<Trans>Server address</Trans>
|
||||
</TextField.LabelText>
|
||||
<TextField.Root>
|
||||
<TextField.Root isInvalid={!!validationError}>
|
||||
<TextField.Icon icon={Globe} />
|
||||
<Dialog.Input
|
||||
testID="customServerTextInput"
|
||||
value={customAddress}
|
||||
onChangeText={setCustomAddress}
|
||||
onChangeText={v => {
|
||||
setCustomAddress(v)
|
||||
if (validationError) setValidationError('')
|
||||
}}
|
||||
label="my-server.com"
|
||||
accessibilityLabelledBy="address-input-label"
|
||||
autoCapitalize="none"
|
||||
keyboardType="url"
|
||||
/>
|
||||
</TextField.Root>
|
||||
{pdsAddressHistory.length > 0 && (
|
||||
{validationError ? (
|
||||
<View style={[a.mt_xs]}>
|
||||
<Admonition type="error">{validationError}</Admonition>
|
||||
</View>
|
||||
) : null}
|
||||
{recentServers.length > 0 && (
|
||||
<View style={[a.flex_row, a.flex_wrap, a.mt_xs]}>
|
||||
{pdsAddressHistory.map(uri => (
|
||||
{recentServers.map(uri => (
|
||||
<Button
|
||||
key={uri}
|
||||
variant="ghost"
|
||||
@@ -218,7 +261,9 @@ function DialogInner({
|
||||
label={uri}
|
||||
style={[a.px_sm, a.py_xs, a.rounded_sm, a.gap_sm]}
|
||||
onPress={() => setCustomAddress(uri)}>
|
||||
<ButtonText>{uri}</ButtonText>
|
||||
<ButtonText numberOfLines={1}>
|
||||
{enforceLen(uri, MAX_PDS_LABEL_LEN, true, 'middle')}
|
||||
</ButtonText>
|
||||
</Button>
|
||||
))}
|
||||
</View>
|
||||
@@ -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`)}>
|
||||
<ButtonText>
|
||||
<Trans>Done</Trans>
|
||||
|
||||
@@ -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<string | undefined> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
queryClient.fetchQuery(resolvePdsQueryOptions(handle)),
|
||||
new Promise<undefined>(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<TextInput>(null)
|
||||
const hasFocusedOnce = useRef<boolean>(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 = (
|
||||
<Text
|
||||
style={[a.text_sm, t.atoms.text_contrast_medium, web(a.text_right)]}>
|
||||
<Trans>You're signing in to {toNiceDomain(override)}.</Trans>{' '}
|
||||
<Trans>You're signing in to {niceHostLabel(override)}.</Trans>{' '}
|
||||
<InlineLinkText
|
||||
label={_(msg`Change server`)}
|
||||
{...createStaticClick(onPressUseCustomServer)}
|
||||
@@ -519,10 +578,12 @@ function PdsResolveStatus({
|
||||
}
|
||||
contentKey = 'loading'
|
||||
content = (
|
||||
<Text
|
||||
style={[a.text_sm, t.atoms.text_contrast_medium, web(a.text_right)]}>
|
||||
<Trans>Resolving your server…</Trans>
|
||||
</Text>
|
||||
<View
|
||||
accessibilityLabel={_(msg`Resolving your server`)}
|
||||
accessibilityHint=""
|
||||
style={[web(a.align_end)]}>
|
||||
<Loader size="md" />
|
||||
</View>
|
||||
)
|
||||
} else if (query.isError) {
|
||||
contentKey = 'error'
|
||||
@@ -557,7 +618,7 @@ function PdsResolveStatus({
|
||||
content = (
|
||||
<Text
|
||||
style={[a.text_sm, t.atoms.text_contrast_medium, web(a.text_right)]}>
|
||||
<Trans>You're signing in to {toNiceDomain(query.data.pds)}</Trans>
|
||||
<Trans>You're signing in to {niceHostLabel(query.data.pds)}</Trans>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user