Warn the user on links that dont match their text (#1573)

* Add link warning modal when URLs do not match their text

* Simplify the misleading link case for clarity

* Fix typecheck

* fix dark mode

* Give a stronger visual indication of the root domain in the link warning

* More rigorous URL mismatch logic

* Remove debug

---------

Co-authored-by: Ansh Nanda <anshnanda10@gmail.com>
zio/stable
Paul Frazee 2023-10-02 14:47:39 -07:00 committed by GitHub
parent 2f157c152a
commit fd5bbb2769
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 357 additions and 3 deletions

View File

@ -0,0 +1,98 @@
import {
linkRequiresWarning,
isPossiblyAUrl,
splitApexDomain,
} from '../../../src/lib/strings/url-helpers'
describe('linkRequiresWarning', () => {
type Case = [string, string, boolean]
const cases: Case[] = [
['http://example.com', 'http://example.com', false],
['http://example.com', 'example.com', false],
['http://example.com', 'example.com/page', false],
['http://example.com', '', true],
['http://example.com', 'other.com', true],
['http://example.com', 'http://other.com', true],
['http://example.com', 'some label', true],
['http://example.com', 'example.com more', true],
['http://example.com', 'http://example.co', true],
['http://example.co', 'http://example.com', true],
['http://example.com', 'example.co', true],
['http://example.co', 'example.com', true],
['http://site.pages.dev', 'http://site.page', true],
['http://site.page', 'http://site.pages.dev', true],
['http://site.pages.dev', 'site.page', true],
['http://site.page', 'site.pages.dev', true],
['http://site.pages.dev', 'http://site.pages', true],
['http://site.pages', 'http://site.pages.dev', true],
['http://site.pages.dev', 'site.pages', true],
['http://site.pages', 'site.pages.dev', true],
// bad uri inputs, default to true
['', '', true],
['example.com', 'example.com', true],
]
it.each(cases)(
'given input uri %p and text %p, returns %p',
(uri, text, expected) => {
const output = linkRequiresWarning(uri, text)
expect(output).toEqual(expected)
},
)
})
describe('isPossiblyAUrl', () => {
type Case = [string, boolean]
const cases: Case[] = [
['', false],
['text', false],
['some text', false],
['some text', false],
['some domain.com', false],
['domain.com', true],
[' domain.com', true],
['domain.com ', true],
[' domain.com ', true],
['http://domain.com', true],
[' http://domain.com', true],
['http://domain.com ', true],
[' http://domain.com ', true],
['https://domain.com', true],
[' https://domain.com', true],
['https://domain.com ', true],
[' https://domain.com ', true],
['http://domain.com/foo', true],
['http://domain.com stuff', true],
]
it.each(cases)('given input uri %p, returns %p', (str, expected) => {
const output = isPossiblyAUrl(str)
expect(output).toEqual(expected)
})
})
describe('splitApexDomain', () => {
type Case = [string, string, string]
const cases: Case[] = [
['', '', ''],
['example.com', '', 'example.com'],
['foo.example.com', 'foo.', 'example.com'],
['foo.bar.example.com', 'foo.bar.', 'example.com'],
['example.co.uk', '', 'example.co.uk'],
['foo.example.co.uk', 'foo.', 'example.co.uk'],
['example.nonsense', '', 'example.nonsense'],
['foo.example.nonsense', '', 'foo.example.nonsense'],
['foo.bar.example.nonsense', '', 'foo.bar.example.nonsense'],
['example.com.example.com', 'example.com.', 'example.com'],
]
it.each(cases)(
'given input uri %p, returns %p,%p',
(str, expected1, expected2) => {
const output = splitApexDomain(str)
expect(output[0]).toEqual(expected1)
expect(output[1]).toEqual(expected2)
},
)
})

View File

@ -115,6 +115,7 @@
"normalize-url": "^8.0.0", "normalize-url": "^8.0.0",
"patch-package": "^6.5.1", "patch-package": "^6.5.1",
"postinstall-postinstall": "^2.1.0", "postinstall-postinstall": "^2.1.0",
"psl": "^1.9.0",
"react": "18.2.0", "react": "18.2.0",
"react-avatar-editor": "^13.0.0", "react-avatar-editor": "^13.0.0",
"react-circular-progressbar": "^2.1.0", "react-circular-progressbar": "^2.1.0",
@ -175,6 +176,7 @@
"@types/lodash.samplesize": "^4.2.7", "@types/lodash.samplesize": "^4.2.7",
"@types/lodash.set": "^4.3.7", "@types/lodash.set": "^4.3.7",
"@types/lodash.shuffle": "^4.2.7", "@types/lodash.shuffle": "^4.2.7",
"@types/psl": "^1.1.1",
"@types/react-avatar-editor": "^13.0.0", "@types/react-avatar-editor": "^13.0.0",
"@types/react-responsive": "^8.0.5", "@types/react-responsive": "^8.0.5",
"@types/react-test-renderer": "^17.0.1", "@types/react-test-renderer": "^17.0.1",

View File

@ -1,6 +1,7 @@
import {AtUri} from '@atproto/api' import {AtUri} from '@atproto/api'
import {PROD_SERVICE} from 'state/index' import {PROD_SERVICE} from 'state/index'
import TLDs from 'tlds' import TLDs from 'tlds'
import psl from 'psl'
export function isValidDomain(str: string): boolean { export function isValidDomain(str: string): boolean {
return !!TLDs.find(tld => { return !!TLDs.find(tld => {
@ -166,3 +167,53 @@ export function getYoutubeVideoId(link: string): string | undefined {
} }
return videoId return videoId
} }
export function linkRequiresWarning(uri: string, label: string) {
const labelDomain = labelToDomain(label)
if (!labelDomain) {
return true
}
try {
const urip = new URL(uri)
return labelDomain !== urip.hostname
} catch {
return true
}
}
function labelToDomain(label: string): string | undefined {
// any spaces just immediately consider the label a non-url
if (/\s/.test(label)) {
return undefined
}
try {
return new URL(label).hostname
} catch {}
try {
return new URL('https://' + label).hostname
} catch {}
return undefined
}
export function isPossiblyAUrl(str: string): boolean {
str = str.trim()
if (str.startsWith('http://')) {
return true
}
if (str.startsWith('https://')) {
return true
}
const [firstWord] = str.split(/[\s\/]/)
return isValidDomain(firstWord)
}
export function splitApexDomain(hostname: string): [string, string] {
const hostnamep = psl.parse(hostname)
if (hostnamep.error || !hostnamep.listed || !hostnamep.domain) {
return ['', hostname]
}
return [
hostnamep.subdomain ? `${hostnamep.subdomain}.` : '',
hostnamep.domain,
]
}

View File

@ -154,6 +154,12 @@ export interface SwitchAccountModal {
name: 'switch-account' name: 'switch-account'
} }
export interface LinkWarningModal {
name: 'link-warning'
text: string
href: string
}
export type Modal = export type Modal =
// Account // Account
| AddAppPasswordModal | AddAppPasswordModal
@ -191,6 +197,7 @@ export type Modal =
// Generic // Generic
| ConfirmModal | ConfirmModal
| LinkWarningModal
interface LightboxModel {} interface LightboxModel {}

View File

@ -0,0 +1,162 @@
import React from 'react'
import {Linking, SafeAreaView, StyleSheet, View} from 'react-native'
import {ScrollView} from './util'
import {observer} from 'mobx-react-lite'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Text} from '../util/text/Text'
import {Button} from '../util/forms/Button'
import {useStores} from 'state/index'
import {s, colors} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {isWeb} from 'platform/detection'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {isPossiblyAUrl, splitApexDomain} from 'lib/strings/url-helpers'
export const snapPoints = ['50%']
export const Component = observer(function Component({
text,
href,
}: {
text: string
href: string
}) {
const pal = usePalette('default')
const store = useStores()
const {isMobile} = useWebMediaQueries()
const potentiallyMisleading = isPossiblyAUrl(text)
const onPressVisit = () => {
store.shell.closeModal()
Linking.openURL(href)
}
return (
<SafeAreaView style={[s.flex1, pal.view]}>
<ScrollView
testID="linkWarningModal"
style={[s.flex1, isMobile && {paddingHorizontal: 18}]}>
<View style={styles.titleSection}>
{potentiallyMisleading ? (
<>
<FontAwesomeIcon
icon="circle-exclamation"
color={pal.colors.text}
size={18}
/>
<Text type="title-lg" style={[pal.text, styles.title]}>
Potentially Misleading Link
</Text>
</>
) : (
<Text type="title-lg" style={[pal.text, styles.title]}>
Leaving Bluesky
</Text>
)}
</View>
<View style={{gap: 10}}>
<Text type="lg" style={pal.text}>
This link is taking you to the following website:
</Text>
<LinkBox href={href} />
{potentiallyMisleading && (
<Text type="lg" style={pal.text}>
Make sure this is where you intend to go!
</Text>
)}
</View>
<View style={[styles.btnContainer, isMobile && {paddingBottom: 40}]}>
<Button
testID="confirmBtn"
type="primary"
onPress={onPressVisit}
accessibilityLabel="Visit Site"
accessibilityHint=""
label="Visit Site"
labelContainerStyle={{justifyContent: 'center', padding: 4}}
labelStyle={[s.f18]}
/>
<Button
testID="cancelBtn"
type="default"
onPress={() => store.shell.closeModal()}
accessibilityLabel="Cancel"
accessibilityHint=""
label="Cancel"
labelContainerStyle={{justifyContent: 'center', padding: 4}}
labelStyle={[s.f18]}
/>
</View>
</ScrollView>
</SafeAreaView>
)
})
function LinkBox({href}: {href: string}) {
const pal = usePalette('default')
const [scheme, hostname, rest] = React.useMemo(() => {
try {
const urlp = new URL(href)
const [subdomain, apexdomain] = splitApexDomain(urlp.hostname)
return [
urlp.protocol + '//' + subdomain,
apexdomain,
urlp.pathname + urlp.search + urlp.hash,
]
} catch {
return ['', href, '']
}
}, [href])
return (
<View style={[pal.view, pal.border, styles.linkBox]}>
<Text type="lg" style={pal.textLight}>
{scheme}
<Text type="lg-bold" style={pal.text}>
{hostname}
</Text>
{rest}
</Text>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingBottom: isWeb ? 0 : 40,
},
titleSection: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
gap: 6,
paddingTop: isWeb ? 0 : 4,
paddingBottom: isWeb ? 14 : 10,
},
title: {
textAlign: 'center',
fontWeight: '600',
},
linkBox: {
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 6,
borderWidth: 1,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 32,
padding: 14,
backgroundColor: colors.blue3,
},
btnContainer: {
paddingTop: 20,
gap: 6,
},
})

View File

@ -33,6 +33,7 @@ import * as BirthDateSettingsModal from './BirthDateSettings'
import * as VerifyEmailModal from './VerifyEmail' import * as VerifyEmailModal from './VerifyEmail'
import * as ChangeEmailModal from './ChangeEmail' import * as ChangeEmailModal from './ChangeEmail'
import * as SwitchAccountModal from './SwitchAccount' import * as SwitchAccountModal from './SwitchAccount'
import * as LinkWarningModal from './LinkWarning'
const DEFAULT_SNAPPOINTS = ['90%'] const DEFAULT_SNAPPOINTS = ['90%']
@ -148,6 +149,9 @@ export const ModalsContainer = observer(function ModalsContainer() {
} else if (activeModal?.name === 'switch-account') { } else if (activeModal?.name === 'switch-account') {
snapPoints = SwitchAccountModal.snapPoints snapPoints = SwitchAccountModal.snapPoints
element = <SwitchAccountModal.Component /> element = <SwitchAccountModal.Component />
} else if (activeModal?.name === 'link-warning') {
snapPoints = LinkWarningModal.snapPoints
element = <LinkWarningModal.Component {...activeModal} />
} else { } else {
return null return null
} }

View File

@ -30,6 +30,7 @@ import * as ModerationDetailsModal from './ModerationDetails'
import * as BirthDateSettingsModal from './BirthDateSettings' import * as BirthDateSettingsModal from './BirthDateSettings'
import * as VerifyEmailModal from './VerifyEmail' import * as VerifyEmailModal from './VerifyEmail'
import * as ChangeEmailModal from './ChangeEmail' import * as ChangeEmailModal from './ChangeEmail'
import * as LinkWarningModal from './LinkWarning'
export const ModalsContainer = observer(function ModalsContainer() { export const ModalsContainer = observer(function ModalsContainer() {
const store = useStores() const store = useStores()
@ -116,6 +117,8 @@ function Modal({modal}: {modal: ModalIface}) {
element = <VerifyEmailModal.Component {...modal} /> element = <VerifyEmailModal.Component {...modal} />
} else if (modal.name === 'change-email') { } else if (modal.name === 'change-email') {
element = <ChangeEmailModal.Component /> element = <ChangeEmailModal.Component />
} else if (modal.name === 'link-warning') {
element = <LinkWarningModal.Component {...modal} />
} else { } else {
return null return null
} }

View File

@ -23,7 +23,11 @@ import {TypographyVariant} from 'lib/ThemeContext'
import {NavigationProp} from 'lib/routes/types' import {NavigationProp} from 'lib/routes/types'
import {router} from '../../../routes' import {router} from '../../../routes'
import {useStores, RootStoreModel} from 'state/index' import {useStores, RootStoreModel} from 'state/index'
import {convertBskyAppUrlIfNeeded, isExternalUrl} from 'lib/strings/url-helpers' import {
convertBskyAppUrlIfNeeded,
isExternalUrl,
linkRequiresWarning,
} from 'lib/strings/url-helpers'
import {isAndroid} from 'platform/detection' import {isAndroid} from 'platform/detection'
import {sanitizeUrl} from '@braintree/sanitize-url' import {sanitizeUrl} from '@braintree/sanitize-url'
import {PressableWithHover} from './PressableWithHover' import {PressableWithHover} from './PressableWithHover'
@ -143,6 +147,7 @@ export const TextLink = observer(function TextLink({
dataSet, dataSet,
title, title,
onPress, onPress,
warnOnMismatchingLabel,
...orgProps ...orgProps
}: { }: {
testID?: string testID?: string
@ -154,13 +159,29 @@ export const TextLink = observer(function TextLink({
lineHeight?: number lineHeight?: number
dataSet?: any dataSet?: any
title?: string title?: string
warnOnMismatchingLabel?: boolean
} & TextProps) { } & TextProps) {
const {...props} = useLinkProps({to: sanitizeUrl(href)}) const {...props} = useLinkProps({to: sanitizeUrl(href)})
const store = useStores() const store = useStores()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
if (warnOnMismatchingLabel && typeof text !== 'string') {
console.error('Unable to detect mismatching label')
}
props.onPress = React.useCallback( props.onPress = React.useCallback(
(e?: Event) => { (e?: Event) => {
const requiresWarning =
warnOnMismatchingLabel &&
linkRequiresWarning(href, typeof text === 'string' ? text : '')
if (requiresWarning) {
e?.preventDefault?.()
store.shell.openModal({
name: 'link-warning',
text: typeof text === 'string' ? text : '',
href,
})
}
if (onPress) { if (onPress) {
e?.preventDefault?.() e?.preventDefault?.()
// @ts-ignore function signature differs by platform -prf // @ts-ignore function signature differs by platform -prf
@ -168,7 +189,7 @@ export const TextLink = observer(function TextLink({
} }
return onPressInner(store, navigation, sanitizeUrl(href), e) return onPressInner(store, navigation, sanitizeUrl(href), e)
}, },
[onPress, store, navigation, href], [onPress, store, navigation, href, text, warnOnMismatchingLabel],
) )
const hrefAttrs = useMemo(() => { const hrefAttrs = useMemo(() => {
const isExternal = isExternalUrl(href) const isExternal = isExternalUrl(href)

View File

@ -89,6 +89,7 @@ export function RichText({
href={link.uri} href={link.uri}
style={[style, lineHeightStyle, pal.link]} style={[style, lineHeightStyle, pal.link]}
dataSet={WORD_WRAP} dataSet={WORD_WRAP}
warnOnMismatchingLabel
/>, />,
) )
} else { } else {

View File

@ -5412,6 +5412,11 @@
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf" resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf"
integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w== integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==
"@types/psl@^1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@types/psl/-/psl-1.1.1.tgz#3ba9e6d4bd2a32652a639fd5df7e539151d0a3b2"
integrity sha512-nHPbucWhAfVSuJ+xVc4AjjtM/y6U/eLHeXxyjzPHzKVr+j8uHvGg2wlXjmReSE2p851ltEWKGNQOtBK0beF/Eg==
"@types/q@^1.5.1": "@types/q@^1.5.1":
version "1.5.5" version "1.5.5"
resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.5.tgz#75a2a8e7d8ab4b230414505d92335d1dcb53a6df" resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.5.tgz#75a2a8e7d8ab4b230414505d92335d1dcb53a6df"
@ -15525,7 +15530,7 @@ pseudomap@^1.0.2:
resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3"
integrity sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ== integrity sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==
psl@^1.1.33: psl@^1.1.33, psl@^1.9.0:
version "1.9.0" version "1.9.0"
resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7" resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7"
integrity sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag== integrity sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==