From fd5bbb27699942f7d741d074eafdf16bfc9ecdd6 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Mon, 2 Oct 2023 14:47:39 -0700 Subject: [PATCH] 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 --- __tests__/lib/strings/url-helpers.test.ts | 98 +++++++++++++ package.json | 2 + src/lib/strings/url-helpers.ts | 51 +++++++ src/state/models/ui/shell.ts | 7 + src/view/com/modals/LinkWarning.tsx | 162 ++++++++++++++++++++++ src/view/com/modals/Modal.tsx | 4 + src/view/com/modals/Modal.web.tsx | 3 + src/view/com/util/Link.tsx | 25 +++- src/view/com/util/text/RichText.tsx | 1 + yarn.lock | 7 +- 10 files changed, 357 insertions(+), 3 deletions(-) create mode 100644 __tests__/lib/strings/url-helpers.test.ts create mode 100644 src/view/com/modals/LinkWarning.tsx diff --git a/__tests__/lib/strings/url-helpers.test.ts b/__tests__/lib/strings/url-helpers.test.ts new file mode 100644 index 00000000..3055a9ef --- /dev/null +++ b/__tests__/lib/strings/url-helpers.test.ts @@ -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) + }, + ) +}) diff --git a/package.json b/package.json index 28e9a699..3f99aea6 100644 --- a/package.json +++ b/package.json @@ -115,6 +115,7 @@ "normalize-url": "^8.0.0", "patch-package": "^6.5.1", "postinstall-postinstall": "^2.1.0", + "psl": "^1.9.0", "react": "18.2.0", "react-avatar-editor": "^13.0.0", "react-circular-progressbar": "^2.1.0", @@ -175,6 +176,7 @@ "@types/lodash.samplesize": "^4.2.7", "@types/lodash.set": "^4.3.7", "@types/lodash.shuffle": "^4.2.7", + "@types/psl": "^1.1.1", "@types/react-avatar-editor": "^13.0.0", "@types/react-responsive": "^8.0.5", "@types/react-test-renderer": "^17.0.1", diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts index 671dc978..3c27d863 100644 --- a/src/lib/strings/url-helpers.ts +++ b/src/lib/strings/url-helpers.ts @@ -1,6 +1,7 @@ import {AtUri} from '@atproto/api' import {PROD_SERVICE} from 'state/index' import TLDs from 'tlds' +import psl from 'psl' export function isValidDomain(str: string): boolean { return !!TLDs.find(tld => { @@ -166,3 +167,53 @@ export function getYoutubeVideoId(link: string): string | undefined { } 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, + ] +} diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index bd285c8c..a8937b84 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -154,6 +154,12 @@ export interface SwitchAccountModal { name: 'switch-account' } +export interface LinkWarningModal { + name: 'link-warning' + text: string + href: string +} + export type Modal = // Account | AddAppPasswordModal @@ -191,6 +197,7 @@ export type Modal = // Generic | ConfirmModal + | LinkWarningModal interface LightboxModel {} diff --git a/src/view/com/modals/LinkWarning.tsx b/src/view/com/modals/LinkWarning.tsx new file mode 100644 index 00000000..67a156af --- /dev/null +++ b/src/view/com/modals/LinkWarning.tsx @@ -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 ( + + + + {potentiallyMisleading ? ( + <> + + + Potentially Misleading Link + + + ) : ( + + Leaving Bluesky + + )} + + + + + This link is taking you to the following website: + + + + + {potentiallyMisleading && ( + + Make sure this is where you intend to go! + + )} + + + +