Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 32 additions & 5 deletions src/screens/Signup/StepCaptcha/CaptchaWebView.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {useEffect, useMemo, useRef} from 'react'
import {useCallback, useEffect, useMemo, useRef} from 'react'
import {WebView, type WebViewNavigation} from 'react-native-webview'
import {type ShouldStartLoadRequest} from 'react-native-webview/lib/WebViewTypes'

Expand All @@ -16,6 +16,25 @@ const ALLOWED_HOSTS = [
'api2.hcaptcha.com',
]

/**
* The gate page lives on whichever PDS signup is pointed at, so the static list
* can't cover community PDSes. Derive that host from the same `serviceUrl` the
* captcha URL is built from — an unset or malformed value adds nothing, leaving
* the static list as-is.
*/
export function buildAllowedHosts(serviceUrl?: string): string[] {
if (!serviceUrl) return ALLOWED_HOSTS

try {
const {host} = new URL(serviceUrl)
return ALLOWED_HOSTS.includes(host)
? ALLOWED_HOSTS
: [...ALLOWED_HOSTS, host]
} catch {
return ALLOWED_HOSTS
}
}

const MIN_DELAY = 3_500

export function CaptchaWebView({
Expand Down Expand Up @@ -55,10 +74,18 @@ export function CaptchaWebView({

const wasSuccessful = useRef(false)

const onShouldStartLoadWithRequest = (event: ShouldStartLoadRequest) => {
const urlp = new URL(event.url)
return ALLOWED_HOSTS.includes(urlp.host)
}
const allowedHosts = useMemo(
() => buildAllowedHosts(state?.serviceUrl),
[state?.serviceUrl],
)

const onShouldStartLoadWithRequest = useCallback(
(event: ShouldStartLoadRequest) => {
const urlp = new URL(event.url)
return allowedHosts.includes(urlp.host)
},
[allowedHosts],
)

const onNavigationStateChange = (e: WebViewNavigation) => {
if (wasSuccessful.current) return
Expand Down
36 changes: 36 additions & 0 deletions src/screens/Signup/StepCaptcha/__tests__/CaptchaWebView.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import {buildAllowedHosts} from '#/screens/Signup/StepCaptcha/CaptchaWebView'

describe('buildAllowedHosts', () => {
it('allows the community PDS the signup is pointed at', () => {
expect(buildAllowedHosts('https://medsky.network')).toContain(
'medsky.network',
)
})

it('keeps the static hosts alongside the community PDS', () => {
const hosts = buildAllowedHosts('https://medsky.network')
expect(hosts).toEqual(expect.arrayContaining(['blacksky.app', 'bsky.app']))
})

it('strips the port and path from the service URL', () => {
expect(buildAllowedHosts('https://pds.example.com/xrpc')).toContain(
'pds.example.com',
)
})

it('does not duplicate a host already on the static list', () => {
const hosts = buildAllowedHosts('https://blacksky.app')
expect(hosts.filter(h => h === 'blacksky.app')).toHaveLength(1)
})

it('falls back to the static list when serviceUrl is unset', () => {
expect(buildAllowedHosts(undefined)).toContain('blacksky.app')
expect(buildAllowedHosts(undefined)).not.toContain('medsky.network')
})

it('falls back to the static list when serviceUrl is malformed', () => {
const hosts = buildAllowedHosts('not a url')
expect(hosts).toContain('blacksky.app')
expect(hosts).toHaveLength(buildAllowedHosts(undefined).length)
})
})
64 changes: 64 additions & 0 deletions src/screens/Signup/__tests__/handleDomains.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {filterUserDomains} from '#/screens/Signup/handleDomains'

const BLACKSKY_DOMAINS = [
'.myatproto.social',
'.blacksky.app',
'.cryptoanarchy.network',
'.latinsky.app',
'.afrolatinsky.app',
]

describe('filterUserDomains', () => {
it("restricts domains to the selected community's handles", () => {
expect(
filterUserDomains(BLACKSKY_DOMAINS, [
'.latinsky.app',
'.afrolatinsky.app',
]),
).toEqual(['.latinsky.app', '.afrolatinsky.app'])
})

it('puts a community domain first so it becomes the default suffix', () => {
const [first] = filterUserDomains(BLACKSKY_DOMAINS, ['.latinsky.app'])
expect(first).toBe('.latinsky.app')
})

it('shows every advertised domain when the community config is missing', () => {
expect(filterUserDomains(BLACKSKY_DOMAINS, undefined)).toEqual(
BLACKSKY_DOMAINS,
)
})

it('shows every advertised domain when the community lists none', () => {
expect(filterUserDomains(BLACKSKY_DOMAINS, [])).toEqual(BLACKSKY_DOMAINS)
})

it('ignores community handles the PDS does not advertise', () => {
expect(
filterUserDomains(
['.medsky.network', '.nursesky.network'],
['.medsky.app', '.medsky.network'],
),
).toEqual(['.medsky.network'])
})

it('falls back to the advertised domains when nothing intersects', () => {
expect(filterUserDomains(['.blacksky.app'], ['.latinsky.app'])).toEqual([
'.blacksky.app',
])
})

it('never returns an empty list while the PDS advertises a domain', () => {
// Reachable today: picking a community and then switching the hosting
// provider leaves the community slug and the PDS pointing at different
// servers, so the two lists can disagree entirely.
const cases: [string[], string[]][] = [
[['.bsky.social'], ['.latinsky.app', '.afrolatinsky.app']],
[['.medsky.network'], ['.blacksky.app']],
]

for (const [domains, allowed] of cases) {
expect(filterUserDomains(domains, allowed)).toEqual(domains)
}
})
})
25 changes: 25 additions & 0 deletions src/screens/Signup/handleDomains.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* Narrow the domains a PDS advertises down to the ones the selected community
* publishes.
*
* Falls back to every advertised domain in two cases:
*
* - `allowed` is empty or missing — the community's config hasn't loaded or
* couldn't be fetched, so there is no restriction to apply.
* - Nothing intersects — the community and the PDS disagree, which happens when
* the hosting provider is switched after a community is picked (the two are
* tracked in separate state). The PDS is the authority on what it accepts.
*
* Either way the PDS's own list wins over an empty handle step: an extra domain
* on offer is cheaper to correct than a signup the user has to restart.
*/
export function filterUserDomains(
domains: string[],
allowed?: string[],
): string[] {
if (!allowed || allowed.length === 0) return domains

const filtered = domains.filter(domain => allowed.includes(domain))

return filtered.length > 0 ? filtered : domains
}
34 changes: 30 additions & 4 deletions src/screens/Signup/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import {AppBskyGraphStarterpack} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {useQuery} from '@tanstack/react-query'

import {useBrand} from '#/lib/community/BrandContext'
import {DEFAULT_BRAND_CONFIG, useBrand} from '#/lib/community/BrandContext'
import {fetchBrandBySlug} from '#/lib/community/resolveBrand'
import {FEEDBACK_FORM_URL} from '#/lib/constants'
import {logger} from '#/logger'
import {useServiceQuery} from '#/state/queries/service'
Expand Down Expand Up @@ -98,6 +100,30 @@ export function Signup({
refetch,
} = useServiceQuery(state.serviceUrl)

/*
* The picked community's handle domains live in its published brand config,
* not in the ambient brand — which native signup pins to the bundled Blacksky
* copy while logged out. Fetch the live config for whichever community is
* selected (Blacksky included, since the bundle can be stale).
*/
const communitySlug =
state.selectedBrandSlug ?? DEFAULT_BRAND_CONFIG.metadata.slug
const {data: communityConfig} = useQuery({
queryKey: ['signup-brand-config', communitySlug],
queryFn: () => fetchBrandBySlug(communitySlug),
staleTime: 5 * 60 * 1000,
})

/*
* On a failed or pending fetch this falls back to the ambient brand: correct
* on web (injected by hostname) and empty on native, which leaves every
* domain the PDS advertises selectable. Showing an extra domain beats
* blocking signup on a brand-service blip.
*/
const availableHandles =
communityConfig?.services.pds.availableHandles ??
brand.services.pds.availableHandles

useEffect(() => {
if (isFetching) {
dispatch({type: 'setIsLoading', value: true})
Expand All @@ -111,7 +137,7 @@ export function Signup({
dispatch({
type: 'setServiceDescription',
value: undefined,
availableHandles: brand.services.pds.availableHandles,
availableHandles,
})
dispatch({
type: 'setError',
Expand All @@ -123,11 +149,11 @@ export function Signup({
dispatch({
type: 'setServiceDescription',
value: serviceInfo,
availableHandles: brand.services.pds.availableHandles,
availableHandles,
})
dispatch({type: 'setError', value: ''})
}
}, [_, serviceInfo, isError, brand.services.pds.availableHandles])
}, [_, serviceInfo, isError, availableHandles])

useEffect(() => {
if (state.pendingSubmit) {
Expand Down
7 changes: 2 additions & 5 deletions src/screens/Signup/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {createFullHandle} from '#/lib/strings/handles'
import {getAge} from '#/lib/strings/time'
import {useSessionApi} from '#/state/session'
import {useOnboardingDispatch} from '#/state/shell'
import {filterUserDomains} from '#/screens/Signup/handleDomains'
import {type AnalyticsContextType, useAnalytics} from '#/analytics'

export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
Expand Down Expand Up @@ -192,11 +193,7 @@ export function reducer(s: SignupState, a: SignupAction): SignupState {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)

const domains = a.value?.availableUserDomains ?? []
const allowed = a.availableHandles
const filtered =
allowed && allowed.length > 0
? domains.filter(d => allowed.includes(d))
: domains
const filtered = filterUserDomains(domains, a.availableHandles)

next.serviceDescription = a.value
? {...a.value, availableUserDomains: filtered}
Expand Down
Loading