Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,28 @@ function resolveType(content: string) {
return 'normal'
}

function getVisibleText(node: Node) {
if (node.nodeType === Node.TEXT_NODE) return node.textContent
if (node.nodeType !== Node.ELEMENT_NODE) return ''
if (
(node as Element).getAttribute('aria-hidden') === 'true' && // getBoundingClientRect() causes layout, getAttribute() is cheaper
(node as Element).getBoundingClientRect().width === 0
)
return ''
let text = ''
for (const child of node.childNodes) {
text += getVisibleText(child)
}
return text
}

export function postContentMessageParser(node: HTMLElement): TypedMessage {
function make(node: Node): TypedMessage {
if (node.nodeType === Node.TEXT_NODE) {
if (!node.nodeValue) return makeTypedMessageEmpty()
return makeTypedMessageText(node.nodeValue, getElementStyle(node.parentElement))
} else if (node instanceof HTMLAnchorElement) {
}
if (node instanceof HTMLAnchorElement) {
const anchor = node
const href = anchor.getAttribute('title') ?? anchor.getAttribute('href')
if (href?.includes('/photo/')) {
Expand Down Expand Up @@ -131,7 +147,7 @@ export function postContentMessageParser(node: HTMLElement): TypedMessage {
makeTypedMessageEmpty(),
)
}
const content = anchor.textContent
const content = getVisibleText(anchor)
if (!content) return makeTypedMessageEmpty()
const altImage = node.querySelector('img')
return makeTypedMessageAnchor(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,23 @@ import { usePopoverControl } from './usePopoverControl.js'
import { WarningCard } from './WarningCard.js'
import { SecurityProvider } from '../../constants.js'
import { GoPlusLabs } from '@masknet/web3-providers'
import { extractAddresses } from '../../utils.js'
import { useDetectAddress } from '../hooks/useDetectAddress.js'
import { AddressTag } from './TextModifier.js'

const useStyles = makeStyles()({
const useStyles = makeStyles()((theme) => ({
link: {
whiteSpace: 'nowrap',
display: 'inline-flex',
alignItems: 'center',
gap: 4,
verticalAlign: 'bottom',
'& > a': {
color: theme.palette.maskColor.danger,
},
},
address: {
display: 'contents',
},
icon: {
width: 18,
Expand All @@ -26,20 +35,20 @@ const useStyles = makeStyles()({
overflow: 'hidden',
cursor: 'pointer',
},
})
}))

function isTCO(url: string | null) {
if (!url) return false
return url.startsWith('https://t.co/')
}

export const LinkModifier = memo<PropsOf<Plugin.SiteAdaptor.Definition['LinkModifier']>>(function ModifyLink({
export const LinkModifier = memo<PropsOf<Plugin.SiteAdaptor.Definition['LinkModifier']>>(function LinkModifier({
fallback,
...props
}) {
const { classes } = useStyles()
const { data } = useQuery({
queryKey: ['scam-warning', 'check-link', props.href],
queryKey: ['scam-warning', 'check-link', props.href, props.children],
queryFn: async () => {
const resolvedLink = isTCO(props.href) ? await resolveTCOLink(props.href) : props.href
if (!resolvedLink) return { isScam: false }
Expand All @@ -50,16 +59,35 @@ export const LinkModifier = memo<PropsOf<Plugin.SiteAdaptor.Definition['LinkModi
provider: SecurityProvider.GoPlus,
resolvedLink,
}
const isEllipsis = props.children.endsWith('…')
// We assume that the link contains only one address
const address = isEllipsis ? extractAddresses(resolvedLink, true)[0] : undefined

return {
isScam: await PluginScamRPC.checkUrl(resolvedLink),
provider: SecurityProvider.ScamSniffer,
resolvedLink,
address,
}
},
})
const { data: detected } = useDetectAddress(data?.address, data?.isScam === false)
const { open, anchorEl, iconRef, onMouseEnter, onMouseLeave } = usePopoverControl()

if (!data?.isScam) return fallback
if (!data?.isScam) {
if (detected?.isScam) {
return (
<span className={classes.link}>
<AddressTag className={classes.address} address={data!.address!} nested text="" />
<Link href={props.href} target="_blank" rel="noopener noreferrer" fontSize="inherit">
{props.children}
{props.suggestedPostImage}
</Link>
</span>
)
}
return fallback
}

return (
<span className={classes.link}>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,20 @@
import { Icons } from '@masknet/icons'
import type { Plugin } from '@masknet/plugin-infra'
import { makeStyles, ShadowRootPopper } from '@masknet/theme'
import { GoPlusLabs } from '@masknet/web3-providers'
import { isValidAddress } from '@masknet/web3-shared-evm'
import { isValidAddress as isSolAddress } from '@masknet/web3-shared-solana'
import { useQuery } from '@tanstack/react-query'
import { Fragment, memo, useMemo } from 'react'
import { EVM_ADDRESS, SecurityProvider, SOLANA_ADDRESS, TRON_ADDRESS } from '../../constants.js'
import { PluginScamRPC } from '../../messages.js'
import { isTronAddress } from '../../utils.js'
import { extractAddresses } from '../../utils.js'
import { usePopoverControl } from './usePopoverControl.js'
import { WarningCard } from './WarningCard.js'
import { useDetectAddress } from '../hooks/useDetectAddress.js'

const useStyles = makeStyles()({
const useStyles = makeStyles()((theme) => ({
text: {
whiteSpace: 'nowrap',
display: 'inline-flex',
alignItems: 'center',
gap: 4,
verticalAlign: 'bottom',
color: theme.palette.maskColor.danger,
},
icon: {
width: 18,
Expand All @@ -27,48 +23,33 @@ const useStyles = makeStyles()({
overflow: 'hidden',
cursor: 'pointer',
},
})
}))

type TextModifierProps = PropsOf<Plugin.SiteAdaptor.Definition['TextModifier']>

interface AddressTagProps {
address: string
text: string
nested?: boolean
className?: string
}

const AddressTag = memo<AddressTagProps>(function AddressTag({ address, text }) {
const { classes } = useStyles()
export const AddressTag = memo<AddressTagProps>(function AddressTag({ address, text, className, nested }) {
const { classes, cx } = useStyles()
const { open, anchorEl, iconRef, onMouseEnter, onMouseLeave } = usePopoverControl()
const { data } = useQuery({
queryKey: ['detect-address', address],
queryFn: async () => {
if (isValidAddress(address)) {
return { isScam: await PluginScamRPC.checkAddress(address), provider: SecurityProvider.ScamSniffer }
}
if (isSolAddress(address))
return {
isScam: await GoPlusLabs.checkIfAddressIsScam('solana', address),
provider: SecurityProvider.GoPlus,
}
if (isTronAddress(address))
return {
isScam: GoPlusLabs.checkIfAddressIsScam('tron', address),
provider: SecurityProvider.GoPlus,
}
return { isScam: false, provider: null }
},
})
const { data } = useDetectAddress(address)

if (!data?.isScam) return text
return (
<span className={classes.text}>
<span className={cx(classes.text, className)}>
<Icons.Danger
size={18}
className={classes.icon}
ref={iconRef}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
/>
{address}
{nested ? null : address}
<ShadowRootPopper open={open} anchorEl={anchorEl}>
<WarningCard
address={address}
Expand All @@ -88,12 +69,7 @@ interface Segment {
}

export const TextModifier = memo<TextModifierProps>(function TextModifier({ fallback, children: fullText }) {
const addresses = useMemo(() => {
const evmAddresses = fullText.match(EVM_ADDRESS) || []
const solAddresses = fullText.match(SOLANA_ADDRESS) || []
const tronAddresses = fullText.match(TRON_ADDRESS) || []
return [...evmAddresses, ...solAddresses, ...tronAddresses]
}, [fullText])
const addresses = useMemo(() => extractAddresses(fullText), [fullText])

const segments = useMemo(() => {
let leftOffset = 0
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { t } from '@lingui/core/macro'
import { Trans } from '@lingui/react/macro'
import { Icons } from '@masknet/icons'
import { LoadingBase, makeStyles } from '@masknet/theme'
import { Button, Typography } from '@mui/material'
import { memo, useState, type HTMLProps } from 'react'
import { makeStyles } from '@masknet/theme'
import { Typography } from '@mui/material'
import { memo, type HTMLProps } from 'react'
import { SecurityProvider } from '../../constants.js'

const useStyles = makeStyles()((theme) => ({
Expand Down Expand Up @@ -65,22 +65,11 @@ const useStyles = makeStyles()((theme) => ({
minWidth: 0,
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
},
link: {
textDecoration: 'underline',
},
reportButton: {
padding: theme.spacing(1, 0),
width: 60,
minWidth: 60,
textAlign: 'center',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 32,
color: theme.palette.maskColor.main,
backgroundColor: theme.palette.maskColor.thirdMain,
marginLeft: 'auto',
},
description: {
fontFamily: 'Helvetica',
borderRadius: 8,
Expand All @@ -101,7 +90,6 @@ interface Props extends HTMLProps<HTMLDivElement> {

export const WarningCard = memo(function WarningCard({ link, address, securityProvider, ...rest }: Props) {
const { classes, cx } = useStyles()
const [isReporting] = useState(false)
return (
<div {...rest} className={cx(classes.card, rest.className)}>
<div className={classes.header}>
Expand Down Expand Up @@ -130,15 +118,13 @@ export const WarningCard = memo(function WarningCard({ link, address, securityPr
</div>
<div className={classes.content}>
{link ?
<a className={cx(classes.link, classes.target)} href={link}>
<a className={cx(classes.link, classes.target)} href={link} title={link}>
{link}
</a>
: <Typography className={classes.target}>{address}</Typography>}
<Button variant="text" className={classes.reportButton}>
{isReporting ?
<LoadingBase size={16} />
: <Icons.Flag size={16} />}
</Button>
: <Typography className={classes.target} title={address}>
{address}
</Typography>
}
</div>
<Typography className={classes.description}>
<Trans>
Expand Down
38 changes: 38 additions & 0 deletions packages/plugins/ScamWarning/src/SiteAdaptor/hooks/useCheckLink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { resolveTCOLink } from '@masknet/plugin-infra/dom/context'
import { GoPlusLabs } from '@masknet/web3-providers'
import { useQuery } from '@tanstack/react-query'
import { SecurityProvider } from '../../constants.js'
import { PluginScamRPC } from '../../messages.js'
import { extractAddresses } from '../../utils.js'

function isTCO(url: string | null) {
if (!url) return false
return url.startsWith('https://t.co/')
}

export function useCheckLink(link: string, text: string) {
return useQuery({
queryKey: ['scam-warning', 'check-link', link, text],
queryFn: async () => {
const resolvedLink = isTCO(link) ? await resolveTCOLink(link) : link
if (!resolvedLink) return { isScam: false }
const result = await GoPlusLabs.checkIsPhishingSite(resolvedLink)
if (result)
return {
isScam: result,
provider: SecurityProvider.GoPlus,
resolvedLink,
}
const isEllipsis = text.endsWith('…')
// We assume that the link contains only one address
const address = isEllipsis ? extractAddresses(resolvedLink)[0] : undefined

return {
isScam: await PluginScamRPC.checkUrl(resolvedLink),
provider: SecurityProvider.ScamSniffer,
resolvedLink,
address,
}
},
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { GoPlusLabs } from '@masknet/web3-providers'
import { isValidAddress } from '@masknet/web3-shared-evm'
import { isValidAddress as isSolAddress } from '@masknet/web3-shared-solana'
import { useQuery } from '@tanstack/react-query'
import { SecurityProvider } from '../../constants.js'
import { PluginScamRPC } from '../../messages.js'
import { isTronAddress } from '../../utils.js'

export function useDetectAddress(address: string | null | undefined, enabled = true) {
return useQuery({
enabled: !!address && enabled,
queryKey: ['detect-address', address],
queryFn: async () => {
if (!address) return null
if (isValidAddress(address)) {
return { isScam: await PluginScamRPC.checkAddress(address), provider: SecurityProvider.ScamSniffer }
}
if (isSolAddress(address))
return {
isScam: await GoPlusLabs.checkIfAddressIsScam('solana', address),
provider: SecurityProvider.GoPlus,
}
if (isTronAddress(address))
return {
isScam: GoPlusLabs.checkIfAddressIsScam('tron', address),
provider: SecurityProvider.GoPlus,
}
return { isScam: false, provider: null }
},
})
}
4 changes: 4 additions & 0 deletions packages/plugins/ScamWarning/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ export const EVM_ADDRESS = /(^|\s)(0x[a-fA-F0-9]{40})/gu
export const SOLANA_ADDRESS = /(^|\s)([1-9A-HJ-NP-Za-km-z]{32,44})/gu
export const TRON_ADDRESS = /(^|\s)(T[A-Za-z1-9]{33})/gu

export const EXIST_EVM_ADDRESS = /\b(0x[a-fA-F0-9]{40})/gu
export const EXIST_SOLANA_ADDRESS = /\b([1-9A-HJ-NP-Za-km-z]{32,44})/gu
export const EXIST_TRON_ADDRESS = /\b(T[A-Za-z1-9]{33})/gu

export enum SecurityProvider {
ScamSniffer = 'ScamSniffer',
GoPlus = 'GoPlus',
Expand Down
10 changes: 10 additions & 0 deletions packages/plugins/ScamWarning/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
import { EVM_ADDRESS, SOLANA_ADDRESS } from '@masknet/plugin-scam-warning'
import { EXIST_EVM_ADDRESS, EXIST_SOLANA_ADDRESS, EXIST_TRON_ADDRESS, TRON_ADDRESS } from './constants.js'

export function isTronAddress(address: string) {
return !!address.match(address)
}

export function extractAddresses(text: string, exist = false) {
const evmAddresses = text.match(exist ? EXIST_EVM_ADDRESS : EVM_ADDRESS) || []
const solAddresses = text.match(exist ? EXIST_SOLANA_ADDRESS : SOLANA_ADDRESS) || []
const tronAddresses = text.match(exist ? EXIST_TRON_ADDRESS : TRON_ADDRESS) || []
return [...evmAddresses, ...solAddresses, ...tronAddresses]
}
Loading