From 827bb42d6069a7451cc9f8fcb5841a525a33b06e Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Fri, 22 May 2026 15:04:36 -0500 Subject: [PATCH] [APP-2160] Centralize known publisher lookup with strict host matching Move the publisher list and host detection into a single publishers.ts module so both PublicationMetaRow and the Subscribe button consult the same source. Match exact hosts and proper subdomains only, rejecting lookalikes like evilleaflet.pub. --- .../StandardSiteEmbed/PublicationMetaRow.tsx | 16 +---- .../Post/Embed/StandardSiteEmbed/index.tsx | 33 +-------- .../Embed/StandardSiteEmbed/publishers.ts | 39 +++++++++++ .../Embed/StandardSiteEmbed/utils.test.ts | 70 +++++++++++++++++++ 4 files changed, 114 insertions(+), 44 deletions(-) create mode 100644 src/components/Post/Embed/StandardSiteEmbed/publishers.ts create mode 100644 src/components/Post/Embed/StandardSiteEmbed/utils.test.ts diff --git a/src/components/Post/Embed/StandardSiteEmbed/PublicationMetaRow.tsx b/src/components/Post/Embed/StandardSiteEmbed/PublicationMetaRow.tsx index bfc4a9c50a..01e39bce90 100644 --- a/src/components/Post/Embed/StandardSiteEmbed/PublicationMetaRow.tsx +++ b/src/components/Post/Embed/StandardSiteEmbed/PublicationMetaRow.tsx @@ -1,4 +1,4 @@ -import {Fragment, type ReactNode, useMemo} from 'react' +import {Fragment, type ReactNode} from 'react' import {View} from 'react-native' import {type AppBskyEmbedExternal} from '@atproto/api' import {Trans, useLingui} from '@lingui/react/macro' @@ -8,6 +8,7 @@ import {toNiceDomain} from '#/lib/strings/url-helpers' import {useProfileQuery} from '#/state/queries/profile' import {atoms as a, useTheme} from '#/alf' import {InlineLinkText} from '#/components/Link' +import {matchStandardSitePublisher} from '#/components/Post/Embed/StandardSiteEmbed/publishers' import {Text} from '#/components/Typography' export function PublicationMetaRow({ @@ -25,18 +26,7 @@ export function PublicationMetaRow({ const {t: l} = useLingui() const profileQuery = useProfileQuery({did: author.did ?? undefined}) const handle = author.did ? profileQuery.data?.handle : undefined - const highlightedPublisher = useMemo(() => { - try { - const u = new URL(view.source?.uri || '') - return ( - u.host.endsWith('leaflet.pub') || - u.host.endsWith('pckt.blog') || - u.host.endsWith('offprint.app') - ) - } catch (e) { - return false - } - }, [view]) + const highlightedPublisher = !!matchStandardSitePublisher(view) const metaTextStyle = [ a.text_xs, diff --git a/src/components/Post/Embed/StandardSiteEmbed/index.tsx b/src/components/Post/Embed/StandardSiteEmbed/index.tsx index 86a86a57fe..8274ba20f1 100644 --- a/src/components/Post/Embed/StandardSiteEmbed/index.tsx +++ b/src/components/Post/Embed/StandardSiteEmbed/index.tsx @@ -14,14 +14,12 @@ import {ButtonIcon, ButtonText} from '#/components/Button' import {Divider} from '#/components/Divider' import {useInteractionState} from '#/components/hooks/useInteractionState' import {Clock_Stroke2_Corner0_Rounded as Clock} from '#/components/icons/Clock' -import {Leaflet} from '#/components/icons/community/Leaflet' -import {Offprint} from '#/components/icons/community/Offprint' -import {Pckt} from '#/components/icons/community/Pckt' import {StandardSite} from '#/components/icons/community/StandardSite' import {Earth_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' import {Link} from '#/components/Link' import {MediaInsetBorder} from '#/components/MediaInsetBorder' import {PublicationMetaRow} from '#/components/Post/Embed/StandardSiteEmbed/PublicationMetaRow' +import {matchStandardSitePublisher} from '#/components/Post/Embed/StandardSiteEmbed/publishers' import {StandardSiteThemeProvider} from '#/components/Post/Embed/StandardSiteEmbed/StandardSiteThemeProvider' import {isStandardSitePublicationEmbed} from '#/components/Post/Embed/StandardSiteEmbed/utils' import {Text} from '#/components/Typography' @@ -37,33 +35,6 @@ const PUBLICATION_AVATAR_STYLE = { borderRadius: a.rounded_sm.borderRadius, } -export function useStandardSitePublisherConfig( - view: AppBskyEmbedExternal.ViewExternal, -) { - try { - const u = new URL(view.source?.uri || '') - if (u.host.endsWith('leaflet.pub')) { - return { - name: 'Leaflet', - Icon: Leaflet, - } - } else if (u.host.endsWith('pckt.blog')) { - return { - name: 'pckt', - Icon: Pckt, - } - } else if (u.host.endsWith('offprint.app')) { - return { - name: 'Offprint', - Icon: Offprint, - } - } - return null - } catch (e) { - return null - } -} - export const StandardSiteEmbed = ({ view, onOpen, @@ -431,7 +402,7 @@ export function SubscribeButton({ style?: StyleProp }) { const {t: l} = useLingui() - const highlightedPublisher = useStandardSitePublisherConfig(view) + const highlightedPublisher = matchStandardSitePublisher(view) const cta = highlightedPublisher ? l`Subscribe on ${highlightedPublisher.name}` : l`View publication` diff --git a/src/components/Post/Embed/StandardSiteEmbed/publishers.ts b/src/components/Post/Embed/StandardSiteEmbed/publishers.ts new file mode 100644 index 0000000000..7976f24c1b --- /dev/null +++ b/src/components/Post/Embed/StandardSiteEmbed/publishers.ts @@ -0,0 +1,39 @@ +import {type AppBskyEmbedExternal} from '@atproto/api' + +import {Leaflet} from '#/components/icons/community/Leaflet' +import {Offprint} from '#/components/icons/community/Offprint' +import {Pckt} from '#/components/icons/community/Pckt' + +export type StandardSitePublisher = { + host: string + name: string + Icon: typeof Leaflet +} + +const STANDARD_SITE_PUBLISHERS: StandardSitePublisher[] = [ + {host: 'leaflet.pub', name: 'Leaflet', Icon: Leaflet}, + {host: 'pckt.blog', name: 'pckt', Icon: Pckt}, + {host: 'offprint.app', name: 'Offprint', Icon: Offprint}, +] + +export function getStandardSitePublisherHost( + view: AppBskyEmbedExternal.ViewExternal, +): string | null { + try { + return new URL(view.source?.uri || '').host + } catch { + return null + } +} + +export function hostMatches(host: string, target: string): boolean { + return host === target || host.endsWith('.' + target) +} + +export function matchStandardSitePublisher( + view: AppBskyEmbedExternal.ViewExternal, +): StandardSitePublisher | null { + const host = getStandardSitePublisherHost(view) + if (!host) return null + return STANDARD_SITE_PUBLISHERS.find(p => hostMatches(host, p.host)) ?? null +} diff --git a/src/components/Post/Embed/StandardSiteEmbed/utils.test.ts b/src/components/Post/Embed/StandardSiteEmbed/utils.test.ts new file mode 100644 index 0000000000..5f5dd23d60 --- /dev/null +++ b/src/components/Post/Embed/StandardSiteEmbed/utils.test.ts @@ -0,0 +1,70 @@ +import {type AppBskyEmbedExternal} from '@atproto/api' + +import {isStandardSiteEmbed, isStandardSitePublicationEmbed} from './utils' + +function makeView( + partial: Record, +): AppBskyEmbedExternal.ViewExternal { + return { + uri: 'https://example.com/post', + title: 'title', + description: 'description', + ...partial, + } +} + +describe('isStandardSiteEmbed', () => { + it('returns true when any associated ref is in the site.standard.* namespace', () => { + const view = makeView({ + associatedRefs: [{uri: 'at://did:plc:abc/site.standard.publication/foo'}], + }) + expect(isStandardSiteEmbed(view)).toBe(true) + }) + + it('returns false when no associated refs are in the site.standard.* namespace', () => { + const view = makeView({ + associatedRefs: [{uri: 'at://did:plc:abc/app.bsky.feed.post/foo'}], + }) + expect(isStandardSiteEmbed(view)).toBe(false) + }) + + it('returns falsy when associatedRefs is missing', () => { + expect(isStandardSiteEmbed(makeView({}))).toBeFalsy() + }) +}) + +describe('isStandardSitePublicationEmbed', () => { + it('returns true with at least one publication ref and no document refs', () => { + const view = makeView({ + associatedRefs: [{uri: 'at://did:plc:abc/site.standard.publication/foo'}], + }) + expect(isStandardSitePublicationEmbed(view)).toBe(true) + }) + + it('returns false when any ref is a document', () => { + const view = makeView({ + associatedRefs: [ + {uri: 'at://did:plc:abc/site.standard.publication/foo'}, + {uri: 'at://did:plc:abc/site.standard.document/bar'}, + ], + }) + expect(isStandardSitePublicationEmbed(view)).toBe(false) + }) + + it('returns false when there are no publication refs', () => { + const view = makeView({ + associatedRefs: [{uri: 'at://did:plc:abc/site.standard.other/foo'}], + }) + expect(isStandardSitePublicationEmbed(view)).toBe(false) + }) + + it('returns falsy for an empty associatedRefs array', () => { + expect(isStandardSitePublicationEmbed(makeView({associatedRefs: []}))).toBe( + false, + ) + }) + + it('returns falsy when associatedRefs is missing', () => { + expect(isStandardSitePublicationEmbed(makeView({}))).toBeFalsy() + }) +})