Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for IDN (punycode) handles #7043

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@
"patch-package": "^6.5.1",
"postinstall-postinstall": "^2.1.0",
"psl": "^1.9.0",
"punycode": "2.3.1",
"react": "18.2.0",
"react-compiler-runtime": "19.0.0-beta-a7bf2bd-20241110",
"react-dom": "^18.2.0",
Expand Down
10 changes: 6 additions & 4 deletions src/components/FeedCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ export function TitleAndByline({
<Text
style={[a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={1}>
<Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans>
<Trans>Feed by {sanitizeHandle(creator.handle, '@', false)}</Trans>
</Text>
)}
</View>
Expand Down Expand Up @@ -174,14 +174,16 @@ export function Description({
description,
...rest
}: {description?: string} & Partial<RichTextProps>) {
const rt = React.useMemo(() => {
const rtMemo = React.useMemo(() => {
if (!description) return
const rt = new RichTextApi({text: description || ''})
rt.detectFacetsWithoutResolution()
return rt
}, [description])
if (!rt) return null
return <RichText value={rt} style={[a.leading_snug]} disableLinks {...rest} />
if (!rtMemo) return null
return (
<RichText value={rtMemo} style={[a.leading_snug]} disableLinks {...rest} />
)
}

export function DescriptionPlaceholder() {
Expand Down
1 change: 1 addition & 0 deletions src/components/Layout/Header/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ export function SubtitleText({children}: {children: React.ReactNode}) {
const align = useContext(AlignmentContext)
return (
<Text
emoji
style={[
a.text_sm,
a.leading_snug,
Expand Down
10 changes: 7 additions & 3 deletions src/components/StarterPack/Wizard/WizardListCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export function WizardProfileCard({
const moderationUi = moderateProfile(profile, moderationOpts).ui('avatar')
const displayName = profile.displayName
? sanitizeDisplayName(profile.displayName)
: `@${sanitizeHandle(profile.handle)}`
: sanitizeHandle(profile.handle, '@', false)

const onPress = () => {
if (disabled) return
Expand All @@ -155,7 +155,7 @@ export function WizardProfileCard({
type="user"
btnType={btnType}
displayName={displayName}
subtitle={`@${sanitizeHandle(profile.handle)}`}
subtitle={sanitizeHandle(profile.handle, '@', false)}
onPress={onPress}
avatar={profile.avatar}
included={included}
Expand Down Expand Up @@ -201,7 +201,11 @@ export function WizardFeedCard({
type="algo"
btnType={btnType}
displayName={sanitizeDisplayName(generator.displayName)}
subtitle={`Feed by @${sanitizeHandle(generator.creator.handle)}`}
subtitle={`Feed by ${sanitizeHandle(
generator.creator.handle,
'@',
false,
)}`}
onPress={onPress}
avatar={generator.avatar}
included={included}
Expand Down
8 changes: 5 additions & 3 deletions src/components/moderation/LabelsOnMeDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ function Label({
const {_} = useLingui()
const {labeler, strings} = useLabelInfo(label)
const sourceName = labeler
? sanitizeHandle(labeler.creator.handle, '@')
? sanitizeHandle(labeler.creator.handle, '@', false)
: label.src
return (
<View
Expand Down Expand Up @@ -199,7 +199,7 @@ function AppealForm({
const isAccountReport = 'did' in subject
const agent = useAgent()
const sourceName = labeler
? sanitizeHandle(labeler.creator.handle, '@')
? sanitizeHandle(labeler.creator.handle, '@', false)
: label.src

const {mutate, isPending} = useMutation({
Expand Down Expand Up @@ -263,7 +263,9 @@ function AppealForm({
label={_(msg`Text input field`)}
placeholder={_(
msg`Please explain why you think this label was incorrectly applied by ${
labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src
labeler
? sanitizeHandle(labeler.creator.handle, '@', false)
: label.src
}`,
)}
value={details}
Expand Down
2 changes: 1 addition & 1 deletion src/lib/generate-starterpack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export function useGenerateStarterPackMutation({
const displayName = enforceLen(
profile.displayName
? sanitizeDisplayName(profile.displayName)
: `@${sanitizeHandle(profile.handle)}`,
: sanitizeHandle(profile.handle, '@'),
25,
true,
)
Expand Down
124 changes: 120 additions & 4 deletions src/lib/strings/handles.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// Regex from the go implementation
// https://github.com/bluesky-social/indigo/blob/main/atproto/syntax/handle.go#L10
import {decode} from 'punycode'

import {forceLTR} from '#/lib/strings/bidi'

const VALIDATE_REGEX =
Expand All @@ -23,10 +25,124 @@ export function isInvalidHandle(handle: string): boolean {
return handle === 'handle.invalid'
}

export function sanitizeHandle(handle: string, prefix = ''): string {
return isInvalidHandle(handle)
? '⚠Invalid Handle'
: forceLTR(`${prefix}${handle}`)
export function sanitizeHandle(
asciiHandle: string,
prefix = '',
allowUnicode = true,
): string {
if (isInvalidHandle(asciiHandle)) {
return '⚠Invalid Handle'
}
const handle = allowUnicode
? toSanitizedUnicodeHandle(asciiHandle)
: asciiHandle
return forceLTR(`${prefix}${handle}`)
}

export function toSanitizedUnicodeHandle(asciiHandle: string): string {
return asciiHandle
.split('.')
.map((label: string) => {
if (!label.startsWith('xn--')) {
return label // it's not an IDN label
}
const unicodeLabel = decode(label.slice(4))
if (isPossibleHomographAttack(unicodeLabel)) {
return label
}
return unicodeLabel
})
.join('.')
}

/// Checks if the given unicode domain label may be subject to an
/// homograph attack (https://en.wikipedia.org/wiki/IDN_homograph_attack)
function isPossibleHomographAttack(unicodeLabel: string): boolean {
let hasNonRFC2181Characters = false
// We check for characters belonging to any script that has problematic homographs,
// and only allow using __at most__ one of them.
// Detection is based on the observation that legitimate domains in the wild do not mix those scripts.
// Note: you can use https://symbl.cc/en/unicode-table/ as reference
let hasLatin = false
let hasIPA = false
let hasGreekOrCoptic = false
let hasCyrillic = false
let hasArmenian = false
let hasNKo = false

const iterator = unicodeLabel[Symbol.iterator]()
let next = iterator.next()
while (!next.done) {
const codePoint = next.value.codePointAt(0) as number

if (codePoint <= 0x007f) {
// Basic Latin
const isLowercase = codePoint >= 0x0061 && codePoint <= 0x007a
if (isLowercase) {
hasLatin = true
} else {
const isUppercase = codePoint >= 0x0041 && codePoint <= 0x005a
if (isUppercase) {
if (codePoint === 0x0049 /* 'I' */) {
return true // this is confusable with 'l' in many fonts
}
hasNonRFC2181Characters = true
hasLatin = true
} else {
const isNumeric = codePoint >= 0x0030 && codePoint <= 0x0039
if (!isNumeric && codePoint !== 0x002d /* '-' */) {
hasNonRFC2181Characters = true
}
}
}
} else {
hasNonRFC2181Characters = true

if (codePoint <= 0x024f) {
// Latin-1 Suppl., Latin Extended-A, Latin Extended-B
hasLatin = true
} else if (codePoint <= 0x02ff) {
// IPA Extensions, Spacing Modifier Letters
hasIPA = true
} else if (codePoint <= 0x036f) {
// Combining Diacritical Marks (i.e. accents)
// do nothing
} else if (codePoint <= 0x03ff) {
// Greek and Coptic
hasGreekOrCoptic = true
} else if (codePoint <= 0x052f) {
// Cyrillic, Cyrillic Suppl.
hasCyrillic = true
} else if (codePoint <= 0x058f) {
// Armenian
hasArmenian = true
} else if (codePoint >= 0x070c && codePoint <= 0x07ff) {
// NKo
hasNKo = true
} else if (codePoint >= 0xd800 && codePoint <= 0xffff) {
// Surrogates, Combining and other high abuse potential codepoints.
// These are basically never legitimate parts of a label.
return true
}
}
next = iterator.next()
}

if (!hasNonRFC2181Characters) {
// The label contains only characters in [-a-z0-9] and may be a valid domain label,
// therefore it did not need to be using punycode.
// It should be regarded as a possible attack.
return true
}

const scripts =
Number(hasLatin) +
Number(hasIPA) +
Number(hasGreekOrCoptic) +
Number(hasCyrillic) +
Number(hasArmenian) +
Number(hasNKo)
return scripts > 1 // The label uses more than one confusable script
}

export interface IsValidHandle {
Expand Down
2 changes: 1 addition & 1 deletion src/screens/List/ListHiddenScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export function ListHiddenScreen({
]}>
<Trans>
This list - created by{' '}
<Text style={[a.text_md, !isOwner && a.font_bold]}>
<Text emoji style={[a.text_md, !isOwner && a.font_bold]}>
{isOwner
? _(msg`you`)
: sanitizeHandle(list.creator.handle, '@')}
Expand Down
6 changes: 4 additions & 2 deletions src/screens/Profile/Header/Handle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {AppBskyActorDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'

import {isInvalidHandle} from '#/lib/strings/handles'
import {isInvalidHandle, toSanitizedUnicodeHandle} from '#/lib/strings/handles'
import {isIOS} from '#/platform/detection'
import {Shadow} from '#/state/cache/types'
import {atoms as a, useTheme, web} from '#/alf'
Expand Down Expand Up @@ -49,7 +49,9 @@ export function ProfileHeaderHandle({
: [a.text_md, a.leading_snug, t.atoms.text_contrast_medium],
web({wordBreak: 'break-all'}),
]}>
{invalidHandle ? _(msg`⚠Invalid Handle`) : `@${profile.handle}`}
{invalidHandle
? _(msg`⚠Invalid Handle`)
: `@${toSanitizedUnicodeHandle(profile.handle)}`}
</Text>
</View>
)
Expand Down
1 change: 1 addition & 0 deletions src/view/com/composer/text-input/mobile/Autocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export function Autocomplete({
a.text_right,
{maxWidth: '50%'},
]}
emoji
numberOfLines={1}>
{sanitizeHandle(item.handle, '@')}
</Text>
Expand Down
2 changes: 1 addition & 1 deletion src/view/com/composer/text-input/web/Autocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>(
{displayName}
</Text>
</View>
<Text type="xs" style={pal.textLight} numberOfLines={1}>
<Text type="xs" style={pal.textLight} emoji numberOfLines={1}>
{sanitizeHandle(item.handle, '@')}
</Text>
</Pressable>
Expand Down
2 changes: 1 addition & 1 deletion src/view/com/feeds/FeedSourceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ export function FeedSourceCardLoaded({
<Text emoji style={[pal.text, s.bold]} numberOfLines={1}>
{feed.displayName}
</Text>
<Text style={[pal.textLight]} numberOfLines={1}>
<Text emoji style={[pal.textLight]} numberOfLines={1}>
{feed.type === 'feed' ? (
<Trans>Feed by {sanitizeHandle(feed.creatorHandle, '@')}</Trans>
) : (
Expand Down
3 changes: 2 additions & 1 deletion src/view/com/modals/ListAddRemoveUsers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -248,13 +248,14 @@ function UserResult({
<Text
type="lg"
style={[s.bold, pal.text]}
emoji
numberOfLines={1}
lineHeight={1.2}>
{sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
)}
</Text>
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
<Text type="md" emoji style={[pal.textLight]} numberOfLines={1}>
{sanitizeHandle(profile.handle, '@')}
</Text>
{!!profile.viewer?.followedBy && <View style={s.flexRow} />}
Expand Down
2 changes: 1 addition & 1 deletion src/view/com/modals/UserAddRemoveLists.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ function ListItem({
lineHeight={1.2}>
{sanitizeDisplayName(list.name)}
</Text>
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
<Text type="md" emoji style={[pal.textLight]} numberOfLines={1}>
{list.purpose === 'app.bsky.graph.defs#curatelist' &&
(list.creator.did === currentAccount?.did ? (
<Trans>User list by you</Trans>
Expand Down
2 changes: 1 addition & 1 deletion src/view/com/notifications/NotificationFeedItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -703,7 +703,7 @@ function ExpandedAuthorsList({
author.profile.displayName || author.profile.handle,
)}
</Text>{' '}
<Text style={[pal.textLight]} lineHeight={1.2}>
<Text emoji style={[pal.textLight]} lineHeight={1.2}>
{sanitizeHandle(author.profile.handle, '@')}
</Text>
</Text>
Expand Down
2 changes: 1 addition & 1 deletion src/view/com/profile/ProfileSubpageHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ export function ProfileSubpageHeader({
{isLoading || !creator ? (
<LoadingPlaceholder width={50} height={8} />
) : (
<Text type="lg" style={[pal.textLight]} numberOfLines={1}>
<Text type="lg" emoji style={[pal.textLight]} numberOfLines={1}>
{purpose === 'app.bsky.graph.defs#curatelist' ? (
isOwner ? (
<Trans>List by you</Trans>
Expand Down
2 changes: 1 addition & 1 deletion src/view/shell/desktop/Search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ let SearchProfileCard = ({
moderation.ui('displayName'),
)}
</Text>
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
<Text emoji type="md" style={[pal.textLight]} numberOfLines={1}>
{sanitizeHandle(profile.handle, '@')}
</Text>
</View>
Expand Down
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -15720,6 +15720,11 @@ punycode.js@^2.3.1:
resolved "https://registry.yarnpkg.com/punycode.js/-/punycode.js-2.3.1.tgz#6b53e56ad75588234e79f4affa90972c7dd8cdb7"
integrity sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==

[email protected]:
version "2.3.1"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==

punycode@^2.1.0, punycode@^2.1.1:
version "2.3.0"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f"
Expand Down