mirror of
https://github.com/glitch-soc/mastodon.git
synced 2025-12-16 17:29:14 +00:00
Reimplement misleading link tagging in new HTML handling code (#3254)
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import type { ComponentProps, FC } from 'react';
|
import type { ComponentProps, FC } from 'react';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
@@ -7,6 +7,7 @@ import { Link } from 'react-router-dom';
|
|||||||
import type { ApiMentionJSON } from '@/flavours/glitch/api_types/statuses';
|
import type { ApiMentionJSON } from '@/flavours/glitch/api_types/statuses';
|
||||||
import { useAppSelector } from '@/flavours/glitch/store';
|
import { useAppSelector } from '@/flavours/glitch/store';
|
||||||
import type { OnElementHandler } from '@/flavours/glitch/utils/html';
|
import type { OnElementHandler } from '@/flavours/glitch/utils/html';
|
||||||
|
import { decode as decodeIDNA } from 'flavours/glitch/utils/idna';
|
||||||
|
|
||||||
export interface HandledLinkProps {
|
export interface HandledLinkProps {
|
||||||
href: string;
|
href: string;
|
||||||
@@ -16,6 +17,94 @@ export interface HandledLinkProps {
|
|||||||
mention?: Pick<ApiMentionJSON, 'id' | 'acct' | 'username'>;
|
mention?: Pick<ApiMentionJSON, 'id' | 'acct' | 'username'>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const textMatchesTarget = (text: string, origin: string, host: string) => {
|
||||||
|
return (
|
||||||
|
text === origin ||
|
||||||
|
text === host ||
|
||||||
|
text.startsWith(origin + '/') ||
|
||||||
|
text.startsWith(host + '/') ||
|
||||||
|
'www.' + text === host ||
|
||||||
|
('www.' + text).startsWith(host + '/')
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isLinkMisleading = (link: HTMLAnchorElement) => {
|
||||||
|
const linkTextParts: string[] = [];
|
||||||
|
|
||||||
|
// Reconstruct visible text, as we do not have much control over how links
|
||||||
|
// from remote software look, and we can't rely on `innerText` because the
|
||||||
|
// `invisible` class does not set `display` to `none`.
|
||||||
|
|
||||||
|
const walk = (node: Node) => {
|
||||||
|
if (node instanceof Text) {
|
||||||
|
linkTextParts.push(node.textContent);
|
||||||
|
} else if (node instanceof HTMLElement) {
|
||||||
|
if (node.classList.contains('invisible')) return;
|
||||||
|
for (const child of node.childNodes) {
|
||||||
|
walk(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
walk(link);
|
||||||
|
|
||||||
|
const linkText = linkTextParts.join('');
|
||||||
|
const targetURL = new URL(link.href);
|
||||||
|
|
||||||
|
if (targetURL.protocol === 'magnet:') {
|
||||||
|
return !linkText.startsWith('magnet:');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetURL.protocol === 'xmpp:') {
|
||||||
|
return !(
|
||||||
|
linkText === targetURL.href || 'xmpp:' + linkText === targetURL.href
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The following may not work with international domain names
|
||||||
|
if (
|
||||||
|
textMatchesTarget(linkText, targetURL.origin, targetURL.host) ||
|
||||||
|
textMatchesTarget(linkText.toLowerCase(), targetURL.origin, targetURL.host)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The link hasn't been recognized, maybe it features an international domain name
|
||||||
|
const hostname = decodeIDNA(targetURL.hostname).normalize('NFKC');
|
||||||
|
const host = targetURL.host.replace(targetURL.hostname, hostname);
|
||||||
|
const origin = targetURL.origin.replace(targetURL.host, host);
|
||||||
|
const text = linkText.normalize('NFKC');
|
||||||
|
return !(
|
||||||
|
textMatchesTarget(text, origin, host) ||
|
||||||
|
textMatchesTarget(text.toLowerCase(), origin, host)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const tagMisleadingLink = (link: HTMLAnchorElement) => {
|
||||||
|
try {
|
||||||
|
if (isLinkMisleading(link)) {
|
||||||
|
const url = new URL(link.href);
|
||||||
|
const tag = document.createElement('span');
|
||||||
|
tag.classList.add('link-origin-tag');
|
||||||
|
switch (url.protocol) {
|
||||||
|
case 'xmpp:':
|
||||||
|
tag.textContent = `[${url.href}]`;
|
||||||
|
break;
|
||||||
|
case 'magnet:':
|
||||||
|
tag.textContent = '(magnet)';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
tag.textContent = `[${url.host}]`;
|
||||||
|
}
|
||||||
|
link.insertAdjacentText('beforeend', ' ');
|
||||||
|
link.insertAdjacentElement('beforeend', tag);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// The URL is invalid, remove the href just to be safe
|
||||||
|
if (e instanceof TypeError) link.removeAttribute('href');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const HandledLink: FC<HandledLinkProps & ComponentProps<'a'>> = ({
|
export const HandledLink: FC<HandledLinkProps & ComponentProps<'a'>> = ({
|
||||||
href,
|
href,
|
||||||
text,
|
text,
|
||||||
@@ -30,6 +119,17 @@ export const HandledLink: FC<HandledLinkProps & ComponentProps<'a'>> = ({
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||||
(state) => state.local_settings.get('rewrite_mentions', 'no') as string,
|
(state) => state.local_settings.get('rewrite_mentions', 'no') as string,
|
||||||
);
|
);
|
||||||
|
const tagLinks = useAppSelector(
|
||||||
|
(state) =>
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||||
|
state.local_settings.get('tag_misleading_links', false) as string,
|
||||||
|
);
|
||||||
|
|
||||||
|
const linkRef = useRef<HTMLAnchorElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tagLinks && linkRef.current) tagMisleadingLink(linkRef.current);
|
||||||
|
}, [tagLinks]);
|
||||||
|
|
||||||
// Handle hashtags
|
// Handle hashtags
|
||||||
if (text.startsWith('#') || prevText?.endsWith('#')) {
|
if (text.startsWith('#') || prevText?.endsWith('#')) {
|
||||||
@@ -93,6 +193,7 @@ export const HandledLink: FC<HandledLinkProps & ComponentProps<'a'>> = ({
|
|||||||
target='_blank'
|
target='_blank'
|
||||||
rel='noreferrer noopener'
|
rel='noreferrer noopener'
|
||||||
translate='no'
|
translate='no'
|
||||||
|
ref={linkRef}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -14,70 +14,14 @@ import { Icon } from 'flavours/glitch/components/icon';
|
|||||||
import { Poll } from 'flavours/glitch/components/poll';
|
import { Poll } from 'flavours/glitch/components/poll';
|
||||||
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
||||||
import { languages as preloadedLanguages } from 'flavours/glitch/initial_state';
|
import { languages as preloadedLanguages } from 'flavours/glitch/initial_state';
|
||||||
import { decode as decodeIDNA } from 'flavours/glitch/utils/idna';
|
|
||||||
|
|
||||||
import { isModernEmojiEnabled } from '../utils/environment';
|
import { isModernEmojiEnabled } from '../utils/environment';
|
||||||
|
|
||||||
import { EmojiHTML } from './emoji/html';
|
import { EmojiHTML } from './emoji/html';
|
||||||
import { HandledLink } from './status/handled_link';
|
import { HandledLink, isLinkMisleading, tagMisleadingLink } from './status/handled_link';
|
||||||
|
|
||||||
const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
|
const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
|
||||||
|
|
||||||
const textMatchesTarget = (text, origin, host) => {
|
|
||||||
return (text === origin || text === host
|
|
||||||
|| text.startsWith(origin + '/') || text.startsWith(host + '/')
|
|
||||||
|| 'www.' + text === host || ('www.' + text).startsWith(host + '/'));
|
|
||||||
};
|
|
||||||
|
|
||||||
const isLinkMisleading = (link) => {
|
|
||||||
let linkTextParts = [];
|
|
||||||
|
|
||||||
// Reconstruct visible text, as we do not have much control over how links
|
|
||||||
// from remote software look, and we can't rely on `innerText` because the
|
|
||||||
// `invisible` class does not set `display` to `none`.
|
|
||||||
|
|
||||||
const walk = (node) => {
|
|
||||||
switch (node.nodeType) {
|
|
||||||
case Node.TEXT_NODE:
|
|
||||||
linkTextParts.push(node.textContent);
|
|
||||||
break;
|
|
||||||
case Node.ELEMENT_NODE: {
|
|
||||||
if (node.classList.contains('invisible')) return;
|
|
||||||
const children = node.childNodes;
|
|
||||||
for (let i = 0; i < children.length; i++) {
|
|
||||||
walk(children[i]);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
walk(link);
|
|
||||||
|
|
||||||
const linkText = linkTextParts.join('');
|
|
||||||
const targetURL = new URL(link.href);
|
|
||||||
|
|
||||||
if (targetURL.protocol === 'magnet:') {
|
|
||||||
return !linkText.startsWith('magnet:');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetURL.protocol === 'xmpp:') {
|
|
||||||
return !(linkText === targetURL.href || 'xmpp:' + linkText === targetURL.href);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The following may not work with international domain names
|
|
||||||
if (textMatchesTarget(linkText, targetURL.origin, targetURL.host) || textMatchesTarget(linkText.toLowerCase(), targetURL.origin, targetURL.host)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The link hasn't been recognized, maybe it features an international domain name
|
|
||||||
const hostname = decodeIDNA(targetURL.hostname).normalize('NFKC');
|
|
||||||
const host = targetURL.host.replace(targetURL.hostname, hostname);
|
|
||||||
const origin = targetURL.origin.replace(targetURL.host, host);
|
|
||||||
const text = linkText.normalize('NFKC');
|
|
||||||
return !(textMatchesTarget(text, origin, host) || textMatchesTarget(text.toLowerCase(), origin, host));
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {any} status
|
* @param {any} status
|
||||||
@@ -214,30 +158,7 @@ class StatusContent extends PureComponent {
|
|||||||
link.setAttribute('target', '_blank');
|
link.setAttribute('target', '_blank');
|
||||||
link.setAttribute('rel', 'noopener nofollow');
|
link.setAttribute('rel', 'noopener nofollow');
|
||||||
|
|
||||||
try {
|
if (tagLinks) tagMisleadingLink(link);
|
||||||
if (tagLinks && isLinkMisleading(link)) {
|
|
||||||
// Add a tag besides the link to display its origin
|
|
||||||
|
|
||||||
const url = new URL(link.href);
|
|
||||||
const tag = document.createElement('span');
|
|
||||||
tag.classList.add('link-origin-tag');
|
|
||||||
switch (url.protocol) {
|
|
||||||
case 'xmpp:':
|
|
||||||
tag.textContent = `[${url.href}]`;
|
|
||||||
break;
|
|
||||||
case 'magnet:':
|
|
||||||
tag.textContent = '(magnet)';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
tag.textContent = `[${url.host}]`;
|
|
||||||
}
|
|
||||||
link.insertAdjacentText('beforeend', ' ');
|
|
||||||
link.insertAdjacentElement('beforeend', tag);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// The URL is invalid, remove the href just to be safe
|
|
||||||
if (tagLinks && e instanceof TypeError) link.removeAttribute('href');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import punycode from 'punycode';
|
|
||||||
|
|
||||||
const IDNA_PREFIX = 'xn--';
|
|
||||||
|
|
||||||
export const decode = domain => {
|
|
||||||
return domain
|
|
||||||
.split('.')
|
|
||||||
.map(part => part.indexOf(IDNA_PREFIX) === 0 ? punycode.decode(part.slice(IDNA_PREFIX.length)) : part)
|
|
||||||
.join('.');
|
|
||||||
};
|
|
||||||
14
app/javascript/flavours/glitch/utils/idna.ts
Normal file
14
app/javascript/flavours/glitch/utils/idna.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import punycode from 'punycode/';
|
||||||
|
|
||||||
|
const IDNA_PREFIX = 'xn--';
|
||||||
|
|
||||||
|
export const decode = (domain: string) => {
|
||||||
|
return domain
|
||||||
|
.split('.')
|
||||||
|
.map((part) =>
|
||||||
|
part.startsWith(IDNA_PREFIX)
|
||||||
|
? punycode.decode(part.slice(IDNA_PREFIX.length))
|
||||||
|
: part,
|
||||||
|
)
|
||||||
|
.join('.');
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user