diff --git a/src/components/PdsDialog.tsx b/src/components/PdsDialog.tsx index 4d6ee7c85..5010ca4ac 100644 --- a/src/components/PdsDialog.tsx +++ b/src/components/PdsDialog.tsx @@ -10,7 +10,7 @@ import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' import { - getPdsFallbackFaviconUrl, + getPdsFallbackFaviconUrls, isBridgedPdsUrl, isBskyPdsUrl, } from '#/state/queries/pds-label.util' @@ -247,24 +247,18 @@ function DbBadgeIcon({ function FaviconBadgeIcon({ size, borderRadius, - faviconUrl, - fallbackFaviconUrl, + faviconUrls, }: { size: number borderRadius: number - faviconUrl: string - fallbackFaviconUrl?: string + faviconUrls: string[] }) { - const getInitialUrl = () => { - if (!failedFaviconUrls.has(faviconUrl)) return faviconUrl - if (fallbackFaviconUrl && !failedFaviconUrls.has(fallbackFaviconUrl)) { - return fallbackFaviconUrl - } - return undefined - } - const [currentUrl, setCurrentUrl] = useState( - getInitialUrl, - ) + const t = useTheme() + const getNextUrl = (currentUrl?: string) => + faviconUrls.find( + url => url !== currentUrl && url && !failedFaviconUrls.has(url), + ) + const [currentUrl, setCurrentUrl] = useState(getNextUrl) const [imageLoaded, setImageLoaded] = useState(false) if (!currentUrl) { @@ -279,9 +273,12 @@ function FaviconBadgeIcon({ width: size, height: size, borderRadius, + backgroundColor: t.atoms.bg_contrast_100.backgroundColor, }, ]}> - + {!imageLoaded ? ( + + ) : null} @@ -335,26 +323,20 @@ export function PdsBadgeIcon({ const r = borderRadius ?? size / 5 if (isBsky) return if (isBridged) return - const fallbackFaviconUrl = pdsUrl - ? getPdsFallbackFaviconUrl(pdsUrl) - : undefined - if (faviconUrl) - return ( - - ) - if (fallbackFaviconUrl) + const faviconCandidates = Array.from( + new Set( + [faviconUrl, ...(pdsUrl ? getPdsFallbackFaviconUrls(pdsUrl) : [])].filter( + Boolean, + ) as string[], + ), + ) + if (faviconCandidates.length > 0) return ( ) return diff --git a/src/lib/atproto/did.ts b/src/lib/atproto/did.ts new file mode 100644 index 000000000..f8a67dc35 --- /dev/null +++ b/src/lib/atproto/did.ts @@ -0,0 +1,38 @@ +import {type Did} from '@atproto/api' + +export function getDidDocumentUrl( + did: Did, + plcDirectory: string, +): string | undefined { + if (did.startsWith('did:plc:')) { + return `${plcDirectory}/${did}` + } + + if (!did.startsWith('did:web:')) { + return undefined + } + + const msid = did.slice('did:web:'.length) + if (!msid) { + return undefined + } + + const [hostEnc, ...pathSegments] = msid.split(':') + if (!hostEnc) { + return undefined + } + + const host = hostEnc.replace(/%3A/gi, ':') + const protocol = + host.startsWith('localhost') && + (host.length === 'localhost'.length || + host.charAt('localhost'.length) === ':') + ? 'http' + : 'https' + const path = + pathSegments.length > 0 + ? `/${pathSegments.join('/')}/did.json` + : '/.well-known/did.json' + + return `${protocol}://${host}${path}` +} diff --git a/src/screens/Settings/RunesSettings.tsx b/src/screens/Settings/RunesSettings.tsx index cb8e715c8..737571935 100644 --- a/src/screens/Settings/RunesSettings.tsx +++ b/src/screens/Settings/RunesSettings.tsx @@ -441,6 +441,7 @@ function FaviconServiceDialog({control}: {control: Dialog.DialogControlProps}) { const presets = [ 'https://twenty-icons.com/(pds)', 'https://favicon.im/(pds)?larger=true&throw-error-on-404=true', + 'https://favicon.blueat.net/(pds)?larger=true&throw-error-on-404=true', ] return ( diff --git a/src/state/queries/__tests__/resolve-identity-test.ts b/src/state/queries/__tests__/resolve-identity-test.ts new file mode 100644 index 000000000..35e5a93cd --- /dev/null +++ b/src/state/queries/__tests__/resolve-identity-test.ts @@ -0,0 +1,100 @@ +import {beforeEach, describe, expect, it, jest} from '@jest/globals' + +const mockResolveDid: jest.MockedFunction< + ( + params: {did: string}, + options?: {signal?: AbortSignal}, + ) => Promise<{ + data: { + didDoc: Record + } + }> +> = jest.fn() +const mockDispose: jest.MockedFunction<() => void> = jest.fn() + +jest.mock('#/state/session/agent', () => ({ + createPublicAgent() { + return { + com: { + atproto: { + identity: { + resolveDid: mockResolveDid, + }, + }, + }, + dispose: mockDispose, + } + }, +})) + +jest.mock('../direct-fetch-record', () => ({ + LRU: class { + private map = new Map() + + async getOrTryInsertWith(key: K, factory: () => Promise) { + if (this.map.has(key)) { + return this.map.get(key) + } + const value = await factory() + this.map.set(key, value) + return value + } + }, +})) + +import {resolveDidDocument} from '../resolve-identity' + +describe('query DID resolution', () => { + beforeEach(() => { + mockResolveDid.mockReset() + mockDispose.mockReset() + jest.restoreAllMocks() + }) + + it('resolves did:web paths using the correct did.json URL', async () => { + const fetchSpy = jest.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + id: 'did:web:alice.example:users:bob', + service: [], + }), + } as Response) + + await expect( + resolveDidDocument('did:web:alice.example:users:bob'), + ).resolves.toEqual({ + id: 'did:web:alice.example:users:bob', + service: [], + }) + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://alice.example/users/bob/did.json', + { + headers: { + accept: 'application/did+ld+json, application/json', + }, + }, + ) + }) + + it('falls back to appview DID resolution when direct did:web fetching fails', async () => { + jest.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('CORS')) + mockResolveDid.mockResolvedValueOnce({ + data: { + didDoc: { + id: 'did:web:alice.example', + service: [], + }, + }, + }) + + await expect(resolveDidDocument('did:web:alice.example')).resolves.toEqual({ + id: 'did:web:alice.example', + service: [], + }) + + expect(mockResolveDid).toHaveBeenCalledWith({did: 'did:web:alice.example'}) + expect(mockDispose).toHaveBeenCalled() + }) +}) diff --git a/src/state/queries/pds-label.util.ts b/src/state/queries/pds-label.util.ts index 800f84b23..a2cdda1a3 100644 --- a/src/state/queries/pds-label.util.ts +++ b/src/state/queries/pds-label.util.ts @@ -1,6 +1,7 @@ const BSKY_PDS_HOSTNAMES = ['bsky.social', 'staging.bsky.dev'] const BSKY_PDS_SUFFIX = '.bsky.network' const BRIDGY_FED_HOSTNAME = 'atproto.brid.gy' +const PDS_FAVICON_CANDIDATE_PATHS = ['/favicon.ico'] export function isBskyPdsUrl(url: string): boolean { try { @@ -35,9 +36,16 @@ export function getFaviconServiceUrl( } export function getPdsFallbackFaviconUrl(pdsUrl: string): string | undefined { + return getPdsFallbackFaviconUrls(pdsUrl)[0] +} + +export function getPdsFallbackFaviconUrls(pdsUrl: string): string[] { try { - return new URL('/favicon.ico', pdsUrl).toString() + const origin = new URL(pdsUrl).origin + return PDS_FAVICON_CANDIDATE_PATHS.map(path => + new URL(path, origin).toString(), + ) } catch { - return undefined + return [] } } diff --git a/src/state/queries/resolve-identity.ts b/src/state/queries/resolve-identity.ts index 930606ec6..f9cd4d47d 100644 --- a/src/state/queries/resolve-identity.ts +++ b/src/state/queries/resolve-identity.ts @@ -1,7 +1,9 @@ import {type Did, isDid} from '@atproto/api' import {useQuery} from '@tanstack/react-query' +import {getDidDocumentUrl} from '#/lib/atproto/did' import {readPlcDirectory} from '#/state/preferences/plc-directory' +import {createPublicAgent} from '#/state/session/agent' import {STALE} from '.' import {LRU} from './direct-fetch-record' const RQKEY_ROOT = 'resolve-identity' @@ -31,18 +33,43 @@ export type Service = { const serviceCache = new LRU() +async function resolveDidDocumentUsingAppView(did: Did) { + const agent = createPublicAgent() + try { + const res = await agent.com.atproto.identity.resolveDid({did}) + return res.data.didDoc as DidDocument + } finally { + agent.dispose() + } +} + export async function resolveDidDocument(did: Did) { - const cacheKey = did.startsWith('did:plc:') - ? `${readPlcDirectory()}|${did}` - : did + const plcDirectory = readPlcDirectory() + const cacheKey = did.startsWith('did:plc:') ? `${plcDirectory}|${did}` : did return await serviceCache.getOrTryInsertWith(cacheKey, async () => { - const docUrl = did.startsWith('did:plc:') - ? `${readPlcDirectory()}/${did}` - : `https://${did.substring(8)}/.well-known/did.json` + const docUrl = getDidDocumentUrl(did, plcDirectory) + if (!docUrl) { + throw new Error(`Unsupported DID method for ${did}`) + } + + try { + const res = await fetch(docUrl, { + headers: { + accept: 'application/did+ld+json, application/json', + }, + }) + if (!res.ok) { + throw new Error(`Failed to resolve DID document for ${did}`) + } - // TODO: we should probably validate this... - return await (await fetch(docUrl)).json() + return (await res.json()) as DidDocument + } catch (err) { + if (!did.startsWith('did:web:')) { + throw err + } + return await resolveDidDocumentUsingAppView(did) + } }) } diff --git a/src/state/session/__tests__/identity-resolver-test.ts b/src/state/session/__tests__/identity-resolver-test.ts index 423edaa85..199932a89 100644 --- a/src/state/session/__tests__/identity-resolver-test.ts +++ b/src/state/session/__tests__/identity-resolver-test.ts @@ -10,11 +10,28 @@ const mockResolveHandle: jest.MockedFunction< } }> > = jest.fn() +const mockResolveDid: jest.MockedFunction< + ( + params: {did: string}, + options?: {signal?: AbortSignal}, + ) => Promise<{ + data: { + didDoc: Record + } + }> +> = jest.fn() const mockDispose: jest.MockedFunction<() => void> = jest.fn() jest.mock('../agent', () => ({ createPublicAgent() { return { + com: { + atproto: { + identity: { + resolveDid: mockResolveDid, + }, + }, + }, resolveHandle: mockResolveHandle, dispose: mockDispose, } @@ -30,6 +47,7 @@ import { describe('appview identity resolver', () => { beforeEach(() => { mockResolveHandle.mockReset() + mockResolveDid.mockReset() mockDispose.mockReset() jest.restoreAllMocks() }) @@ -170,6 +188,79 @@ describe('appview identity resolver', () => { }) }) + it('resolves did:web path identities using the correct DID document URL', async () => { + mockResolveHandle.mockResolvedValueOnce({ + data: { + did: 'did:web:alice.example:users:bob', + }, + }) + const fetchSpy = jest.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + id: 'did:web:alice.example:users:bob', + alsoKnownAs: ['at://alice.example'], + service: [ + { + id: '#atproto_pds', + type: 'AtprotoPersonalDataServer', + serviceEndpoint: 'https://pds.alice.example', + }, + ], + }), + } as Response) + + const resolver = createIdentityResolver() + const identity = await resolver.resolve('did:web:alice.example:users:bob') + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://alice.example/users/bob/did.json', + { + headers: { + accept: 'application/did+ld+json, application/json', + }, + signal: undefined, + }, + ) + expect(identity.did).toBe('did:web:alice.example:users:bob') + expect(identity.handle).toBe('alice.example') + }) + + it('falls back to appview DID resolution when direct did:web fetching fails', async () => { + jest.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('CORS')) + mockResolveDid.mockResolvedValueOnce({ + data: { + didDoc: { + id: 'did:web:alice.example', + alsoKnownAs: ['at://alice.example'], + service: [ + { + id: '#atproto_pds', + type: 'AtprotoPersonalDataServer', + serviceEndpoint: 'https://pds.alice.example', + }, + ], + }, + }, + }) + mockResolveHandle.mockResolvedValueOnce({ + data: { + did: 'did:web:alice.example', + }, + }) + + const resolver = createIdentityResolver() + const identity = await resolver.resolve('did:web:alice.example') + + expect(mockResolveDid).toHaveBeenCalledWith( + {did: 'did:web:alice.example'}, + {signal: undefined}, + ) + expect(mockDispose).toHaveBeenCalled() + expect(identity.did).toBe('did:web:alice.example') + expect(identity.handle).toBe('alice.example') + }) + it('extracts the pds service url from resolved identity info', () => { expect( getPdsServiceUrlFromIdentityInfo({ diff --git a/src/state/session/identity-resolver.ts b/src/state/session/identity-resolver.ts index 2bcecb5fc..3cfad80fa 100644 --- a/src/state/session/identity-resolver.ts +++ b/src/state/session/identity-resolver.ts @@ -4,6 +4,7 @@ import { type IdentityResolver, } from '@atproto-labs/identity-resolver' +import {getDidDocumentUrl} from '#/lib/atproto/did' import {DOH_ENDPOINT} from '#/lib/constants' import {readPlcDirectory} from '#/state/preferences/plc-directory' import {createPublicAgent} from './agent' @@ -166,22 +167,37 @@ async function resolveDidDocument( did: AtprotoDid, signal?: AbortSignal, ): Promise { - const docUrl = did.startsWith('did:plc:') - ? `${readPlcDirectory()}/${did}` - : `https://${did.substring(8)}/.well-known/did.json` + const docUrl = getDidDocumentUrl(did, readPlcDirectory()) + if (!docUrl) { + throw new Error(`Unsupported DID method for ${did}`) + } - const res = await fetch(docUrl, { - headers: { - accept: 'application/did+ld+json, application/json', - }, - signal, - }) + try { + const res = await fetch(docUrl, { + headers: { + accept: 'application/did+ld+json, application/json', + }, + signal, + }) - if (!res.ok) { - throw new Error(`Failed to resolve DID document for ${did}`) - } + if (!res.ok) { + throw new Error(`Failed to resolve DID document for ${did}`) + } - return (await res.json()) as DidDocument + return (await res.json()) as DidDocument + } catch (err) { + if (!did.startsWith('did:web:')) { + throw err + } + + const agent = createPublicAgent() + try { + const res = await agent.com.atproto.identity.resolveDid({did}, {signal}) + return res.data.didDoc as DidDocument + } finally { + agent.dispose() + } + } } async function getValidatedHandleFromDidDocument(