feat(privacy): strip tracking parameters from links on paste, send and render (#103)
Shared links routinely carry ad/analytics identifiers (utm_*, fbclid, gclid, YouTube si=, Amazon ref=/tag=, X s=/t=, TikTok _r/_t, …) that tie every recipient's click back to the person who shared the link. New src/app/utils/urlTracking.ts is a pure, local stripper: a global list + utm_/pk_/matomo_ prefixes, plus host-scoped rules so e.g. `si` is only removed on youtube/spotify. matrix.to and non-http(s) schemes are never rewritten; unparseable input is returned unchanged; Amazon's `th`/`psc` variant selectors are deliberately kept. 13 unit tests. Wired at three points, all behind a new Settings → Privacy toggle (`stripTrackingParams`, default on): - paste: plain-text pastes are cleaned and re-inserted through Slate's own insertData so multi-line pastes still split into paragraphs; - send: RoomInput submit + schedule paths and MessageEditor saves clean both `body` and `formatted_body` (the HTML variant unescapes `&` around each URL and re-escapes it so the markup is untouched); - render: linkify `formatHref`/`format` and explicit `<a href>` in formatted_body are cleaned, so links sent from other clients are safe to click too. LINKIFY_OPTS is spread into memoised per-timeline objects, so the toggle is a module flag kept current by ClientNonUIFeatures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -140,6 +140,7 @@ import { DraftIndicator } from './DraftIndicator';
|
|||||||
import { scheduledMessagesAtom } from '../../state/scheduledMessages';
|
import { scheduledMessagesAtom } from '../../state/scheduledMessages';
|
||||||
import { createErrorToast, toastQueueAtom } from '../../state/toast';
|
import { createErrorToast, toastQueueAtom } from '../../state/toast';
|
||||||
import { getThreadDraftKey } from '../../state/room/thread';
|
import { getThreadDraftKey } from '../../state/room/thread';
|
||||||
|
import { stripTrackingParamsInHtml, stripTrackingParamsInText } from '../../utils/urlTracking';
|
||||||
|
|
||||||
const GifPicker = React.lazy(() =>
|
const GifPicker = React.lazy(() =>
|
||||||
import('../../components/GifPicker').then((m) => ({ default: m.GifPicker })),
|
import('../../components/GifPicker').then((m) => ({ default: m.GifPicker })),
|
||||||
@@ -258,6 +259,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
// [Gitea #68] The GIF picker is opt-in (searches go to Giphy); hide the
|
// [Gitea #68] The GIF picker is opt-in (searches go to Giphy); hide the
|
||||||
// toolbar button entirely when it's off so it never opens an empty popover.
|
// toolbar button entirely when it's off so it never opens an empty popover.
|
||||||
const [gifPickerEnabled] = useSetting(settingsAtom, 'gifPickerEnabled');
|
const [gifPickerEnabled] = useSetting(settingsAtom, 'gifPickerEnabled');
|
||||||
|
// [Gitea #103] Privacy: drop tracking params from links on paste and on send.
|
||||||
|
const [stripTracking] = useSetting(settingsAtom, 'stripTrackingParams');
|
||||||
const showGif = (composerToolbarButtons?.showGif ?? true) && gifPickerEnabled;
|
const showGif = (composerToolbarButtons?.showGif ?? true) && gifPickerEnabled;
|
||||||
const showLocation = composerToolbarButtons?.showLocation ?? true;
|
const showLocation = composerToolbarButtons?.showLocation ?? true;
|
||||||
const showPoll = composerToolbarButtons?.showPoll ?? true;
|
const showPoll = composerToolbarButtons?.showPoll ?? true;
|
||||||
@@ -389,7 +392,25 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
[setSelectedFiles, room],
|
[setSelectedFiles, room],
|
||||||
);
|
);
|
||||||
const pickFile = useFilePicker(handleFiles, true);
|
const pickFile = useFilePicker(handleFiles, true);
|
||||||
const handlePaste = useFilePasteHandler(handleFiles);
|
const handleFilePaste = useFilePasteHandler(handleFiles);
|
||||||
|
const handlePaste = useCallback<React.ClipboardEventHandler>(
|
||||||
|
(evt) => {
|
||||||
|
handleFilePaste(evt);
|
||||||
|
if (evt.defaultPrevented || !stripTracking) return;
|
||||||
|
const text = evt.clipboardData?.getData('text/plain');
|
||||||
|
if (!text) return;
|
||||||
|
const cleaned = stripTrackingParamsInText(text);
|
||||||
|
if (cleaned === text) return;
|
||||||
|
// Re-run Slate's own plain-text insertion with the cleaned string so
|
||||||
|
// multi-line pastes still split into paragraphs exactly as before.
|
||||||
|
if (typeof DataTransfer === 'undefined') return;
|
||||||
|
evt.preventDefault();
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
dt.setData('text/plain', cleaned);
|
||||||
|
ReactEditor.insertData(editor, dt);
|
||||||
|
},
|
||||||
|
[handleFilePaste, stripTracking, editor],
|
||||||
|
);
|
||||||
const dropZoneVisible = useFileDropZone(fileDropContainerRef, handleFiles);
|
const dropZoneVisible = useFileDropZone(fileDropContainerRef, handleFiles);
|
||||||
const { gifApiKey } = useClientConfig();
|
const { gifApiKey } = useClientConfig();
|
||||||
const gifBtnRef = useRef<HTMLButtonElement>(null);
|
const gifBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
@@ -623,6 +644,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
plainText = trimCommand(commandName, plainText);
|
plainText = trimCommand(commandName, plainText);
|
||||||
customHtml = trimCommand(commandName, customHtml);
|
customHtml = trimCommand(commandName, customHtml);
|
||||||
}
|
}
|
||||||
|
if (stripTracking) {
|
||||||
|
plainText = stripTrackingParamsInText(plainText);
|
||||||
|
customHtml = stripTrackingParamsInHtml(customHtml);
|
||||||
|
}
|
||||||
if (commandName === Command.Me) {
|
if (commandName === Command.Me) {
|
||||||
msgType = MsgType.Emote;
|
msgType = MsgType.Emote;
|
||||||
} else if (commandName === Command.Notice) {
|
} else if (commandName === Command.Notice) {
|
||||||
@@ -718,6 +743,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
isMarkdown,
|
isMarkdown,
|
||||||
commands,
|
commands,
|
||||||
setToast,
|
setToast,
|
||||||
|
stripTracking,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -740,8 +766,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
);
|
);
|
||||||
if (plainText === '') return null;
|
if (plainText === '') return null;
|
||||||
|
|
||||||
const body = plainText;
|
const body = stripTracking ? stripTrackingParamsInText(plainText) : plainText;
|
||||||
const formattedBody = customHtml;
|
const formattedBody = stripTracking ? stripTrackingParamsInHtml(customHtml) : customHtml;
|
||||||
const mentionData = getMentions(mx, roomId, editor);
|
const mentionData = getMentions(mx, roomId, editor);
|
||||||
|
|
||||||
const content: IContent = {
|
const content: IContent = {
|
||||||
@@ -769,7 +795,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return content;
|
return content;
|
||||||
}, [editor, isMarkdown, mx, roomId, replyDraft]);
|
}, [editor, isMarkdown, mx, roomId, replyDraft, stripTracking]);
|
||||||
|
|
||||||
const handleScheduleClick = useCallback(() => {
|
const handleScheduleClick = useCallback(() => {
|
||||||
// Defense in depth: scheduling sends an unencrypted m.room.message, so never
|
// Defense in depth: scheduling sends an unencrypted m.room.message, so never
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ import {
|
|||||||
trimReplyFromFormattedBody,
|
trimReplyFromFormattedBody,
|
||||||
} from '../../../utils/room';
|
} from '../../../utils/room';
|
||||||
import { mobileOrTablet } from '../../../utils/user-agent';
|
import { mobileOrTablet } from '../../../utils/user-agent';
|
||||||
|
import { stripTrackingParamsInHtml, stripTrackingParamsInText } from '../../../utils/urlTracking';
|
||||||
import { useComposingCheck } from '../../../hooks/useComposingCheck';
|
import { useComposingCheck } from '../../../hooks/useComposingCheck';
|
||||||
|
|
||||||
type MessageEditorProps = {
|
type MessageEditorProps = {
|
||||||
@@ -87,6 +88,8 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
|||||||
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||||
const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||||||
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||||
|
// [Gitea #103] Same paste/send stripping as RoomInput, for edits.
|
||||||
|
const [stripTracking] = useSetting(settingsAtom, 'stripTrackingParams');
|
||||||
const [toolbar, setToolbar] = useState(globalToolbar);
|
const [toolbar, setToolbar] = useState(globalToolbar);
|
||||||
const isComposing = useComposingCheck();
|
const isComposing = useComposingCheck();
|
||||||
|
|
||||||
@@ -117,8 +120,8 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
|||||||
|
|
||||||
const [saveState, save] = useAsyncCallback(
|
const [saveState, save] = useAsyncCallback(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const plainText = toPlainText(editor.children, isMarkdown).trim();
|
const rawPlainText = toPlainText(editor.children, isMarkdown).trim();
|
||||||
const customHtml = trimCustomHtml(
|
const rawCustomHtml = trimCustomHtml(
|
||||||
toMatrixCustomHTML(editor.children, {
|
toMatrixCustomHTML(editor.children, {
|
||||||
allowTextFormatting: true,
|
allowTextFormatting: true,
|
||||||
allowBlockMarkdown: isMarkdown,
|
allowBlockMarkdown: isMarkdown,
|
||||||
@@ -126,6 +129,8 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
|||||||
allowMath: true,
|
allowMath: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
const plainText = stripTracking ? stripTrackingParamsInText(rawPlainText) : rawPlainText;
|
||||||
|
const customHtml = stripTracking ? stripTrackingParamsInHtml(rawCustomHtml) : rawCustomHtml;
|
||||||
|
|
||||||
// Media caption edit: preserve the media, change only body/formatted_body.
|
// Media caption edit: preserve the media, change only body/formatted_body.
|
||||||
// An empty caption is valid (it removes the caption → body falls back to
|
// An empty caption is valid (it removes the caption → body falls back to
|
||||||
@@ -239,6 +244,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
return mx.sendMessage(roomId, content as any);
|
return mx.sendMessage(roomId, content as any);
|
||||||
}, [
|
}, [
|
||||||
|
stripTracking,
|
||||||
mx,
|
mx,
|
||||||
editor,
|
editor,
|
||||||
roomId,
|
roomId,
|
||||||
|
|||||||
@@ -1439,10 +1439,18 @@ function Privacy() {
|
|||||||
settingsAtom,
|
settingsAtom,
|
||||||
'warnOnUnverifiedDevices',
|
'warnOnUnverifiedDevices',
|
||||||
);
|
);
|
||||||
|
const [stripTracking, setStripTracking] = useSetting(settingsAtom, 'stripTrackingParams');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Privacy</Text>
|
<Text size="L400">Privacy</Text>
|
||||||
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
|
<SettingTile
|
||||||
|
title="Strip Tracking Parameters from Links"
|
||||||
|
description="Remove utm_, fbclid, YouTube si= and other ad/analytics identifiers from links you paste or send, and from links shown in chat. Runs entirely on this device."
|
||||||
|
after={<Switch variant="Primary" value={stripTracking} onChange={setStripTracking} />}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Hide Typing & Read Receipts"
|
title="Hide Typing & Read Receipts"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { notificationPermission, setFavicon, showOsNotification } from '../../ut
|
|||||||
import { NOTIFICATION_SOUND_MAP } from '../../utils/notificationSounds';
|
import { NOTIFICATION_SOUND_MAP } from '../../utils/notificationSounds';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
|
import { setStripTrackingOnRender } from '../../plugins/react-custom-html-parser';
|
||||||
import { allInvitesAtom } from '../../state/room-list/inviteList';
|
import { allInvitesAtom } from '../../state/room-list/inviteList';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useHydrateMsgDrafts } from '../../hooks/useHydrateMsgDrafts';
|
import { useHydrateMsgDrafts } from '../../hooks/useHydrateMsgDrafts';
|
||||||
@@ -90,6 +91,11 @@ function SystemEmojiFeature() {
|
|||||||
|
|
||||||
function PageZoomFeature() {
|
function PageZoomFeature() {
|
||||||
const [pageZoom] = useSetting(settingsAtom, 'pageZoom');
|
const [pageZoom] = useSetting(settingsAtom, 'pageZoom');
|
||||||
|
// [Gitea #103] Mirror the privacy toggle into the html parser's module flag.
|
||||||
|
const [stripTracking] = useSetting(settingsAtom, 'stripTrackingParams');
|
||||||
|
useEffect(() => {
|
||||||
|
setStripTrackingOnRender(stripTracking);
|
||||||
|
}, [stripTracking]);
|
||||||
|
|
||||||
if (pageZoom === 100) {
|
if (pageZoom === 100) {
|
||||||
document.documentElement.style.removeProperty('font-size');
|
document.documentElement.style.removeProperty('font-size');
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { IntermediateRepresentation, Opts as LinkifyOpts, OptFn } from 'linkifyj
|
|||||||
import Linkify from 'linkify-react';
|
import Linkify from 'linkify-react';
|
||||||
import { ErrorBoundary } from 'react-error-boundary';
|
import { ErrorBoundary } from 'react-error-boundary';
|
||||||
import { ChildNode } from 'domhandler';
|
import { ChildNode } from 'domhandler';
|
||||||
|
import { stripTrackingParams } from '../utils/urlTracking';
|
||||||
import * as css from '../styles/CustomHtml.css';
|
import * as css from '../styles/CustomHtml.css';
|
||||||
import {
|
import {
|
||||||
getMxIdLocalPart,
|
getMxIdLocalPart,
|
||||||
@@ -106,11 +107,30 @@ const renderMath = (
|
|||||||
|
|
||||||
const EMOJI_REG_G = new RegExp(`${URL_NEG_LB}(${EMOJI_PATTERN})`, 'g');
|
const EMOJI_REG_G = new RegExp(`${URL_NEG_LB}(${EMOJI_PATTERN})`, 'g');
|
||||||
|
|
||||||
|
// [Gitea #103] Render-time tracking-param stripping. LINKIFY_OPTS is spread
|
||||||
|
// into memoised per-timeline option objects, so the toggle lives in a module
|
||||||
|
// flag that the format callbacks read lazily on every render; the setting's
|
||||||
|
// subscriber (ClientNonUIFeatures) keeps it current.
|
||||||
|
let stripTrackingOnRender = true;
|
||||||
|
export const setStripTrackingOnRender = (enabled: boolean): void => {
|
||||||
|
stripTrackingOnRender = enabled;
|
||||||
|
};
|
||||||
|
const cleanHref = (href: string): string =>
|
||||||
|
stripTrackingOnRender ? stripTrackingParams(href) : href;
|
||||||
|
|
||||||
export const LINKIFY_OPTS: LinkifyOpts = {
|
export const LINKIFY_OPTS: LinkifyOpts = {
|
||||||
attributes: {
|
attributes: {
|
||||||
target: '_blank',
|
target: '_blank',
|
||||||
rel: 'noreferrer noopener',
|
rel: 'noreferrer noopener',
|
||||||
},
|
},
|
||||||
|
formatHref: {
|
||||||
|
url: cleanHref,
|
||||||
|
},
|
||||||
|
// Only the visible text of bare URLs is a URL, so cleaning it keeps the
|
||||||
|
// label honest about where the link goes.
|
||||||
|
format: {
|
||||||
|
url: cleanHref,
|
||||||
|
},
|
||||||
validate: {
|
validate: {
|
||||||
url: (value) => /^(https?|ftp|mailto|magnet):/.test(value),
|
url: (value) => /^(https?|ftp|mailto|magnet):/.test(value),
|
||||||
},
|
},
|
||||||
@@ -393,6 +413,12 @@ export const getReactCustomHtmlParser = (
|
|||||||
replace: (domNode) => {
|
replace: (domNode) => {
|
||||||
if (domNode instanceof Element && 'name' in domNode) {
|
if (domNode instanceof Element && 'name' in domNode) {
|
||||||
const { name, attribs, children, parent } = domNode;
|
const { name, attribs, children, parent } = domNode;
|
||||||
|
// [Gitea #103] Clean explicit <a href> targets from formatted_body
|
||||||
|
// before any branch below (mention detection, default render) sees them.
|
||||||
|
if (name === 'a' && stripTrackingOnRender && typeof attribs.href === 'string') {
|
||||||
|
const cleaned = stripTrackingParams(attribs.href);
|
||||||
|
if (cleaned !== attribs.href) attribs.href = cleaned;
|
||||||
|
}
|
||||||
const props = attributesToProps(attribs);
|
const props = attributesToProps(attribs);
|
||||||
|
|
||||||
if (name === 'h1') {
|
if (name === 'h1') {
|
||||||
|
|||||||
@@ -263,6 +263,10 @@ export interface Settings {
|
|||||||
|
|
||||||
warnOnUnverifiedDevices: boolean;
|
warnOnUnverifiedDevices: boolean;
|
||||||
|
|
||||||
|
// [Gitea #103] Remove utm_/fbclid/… tracking params from links you paste or
|
||||||
|
// send, and from links rendered in the timeline. Local only.
|
||||||
|
stripTrackingParams: boolean;
|
||||||
|
|
||||||
pauseAnimations: boolean;
|
pauseAnimations: boolean;
|
||||||
|
|
||||||
composerToolbarButtons: ComposerToolbarSettings;
|
composerToolbarButtons: ComposerToolbarSettings;
|
||||||
@@ -372,6 +376,8 @@ const defaultSettings: Settings = {
|
|||||||
|
|
||||||
warnOnUnverifiedDevices: false,
|
warnOnUnverifiedDevices: false,
|
||||||
|
|
||||||
|
stripTrackingParams: true,
|
||||||
|
|
||||||
pauseAnimations: false,
|
pauseAnimations: false,
|
||||||
|
|
||||||
composerToolbarButtons: DEFAULT_COMPOSER_TOOLBAR,
|
composerToolbarButtons: DEFAULT_COMPOSER_TOOLBAR,
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
stripTrackingParams,
|
||||||
|
stripTrackingParamsInHtml,
|
||||||
|
stripTrackingParamsInText,
|
||||||
|
} from './urlTracking';
|
||||||
|
|
||||||
|
describe('stripTrackingParams', () => {
|
||||||
|
it('removes utm_* and known global params, keeps the rest', () => {
|
||||||
|
const out = stripTrackingParams(
|
||||||
|
'https://example.com/a?utm_source=x&utm_medium=y&fbclid=abc&page=2&gclid=1',
|
||||||
|
);
|
||||||
|
assert.equal(out, 'https://example.com/a?page=2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops the dangling ? when every param was tracking', () => {
|
||||||
|
assert.equal(
|
||||||
|
stripTrackingParams('https://example.com/a?utm_source=x'),
|
||||||
|
'https://example.com/a',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the identical string when nothing changed', () => {
|
||||||
|
const url = 'https://example.com/a?page=2#frag';
|
||||||
|
assert.equal(stripTrackingParams(url), url);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the fragment', () => {
|
||||||
|
assert.equal(
|
||||||
|
stripTrackingParams('https://example.com/a?utm_source=x#section-3'),
|
||||||
|
'https://example.com/a#section-3',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies host rules only on that host', () => {
|
||||||
|
assert.equal(
|
||||||
|
stripTrackingParams('https://youtu.be/dQw4w9WgXcQ?si=abc123&t=42'),
|
||||||
|
'https://youtu.be/dQw4w9WgXcQ?t=42',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
stripTrackingParams('https://www.youtube.com/watch?v=dQw4w9WgXcQ&feature=share&si=x'),
|
||||||
|
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||||
|
);
|
||||||
|
// `si` is not tracking elsewhere.
|
||||||
|
const other = 'https://example.com/?si=keep';
|
||||||
|
assert.equal(stripTrackingParams(other), other);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips amazon affiliate/telemetry params and the /ref= path segment but keeps variant selectors', () => {
|
||||||
|
assert.equal(
|
||||||
|
stripTrackingParams(
|
||||||
|
'https://www.amazon.com/dp/B000000000/ref=sr_1_3?crid=ABC&keywords=x&qid=1&sr=8-3&tag=aff-20&th=1&psc=1&pd_rd_r=zz',
|
||||||
|
),
|
||||||
|
'https://www.amazon.com/dp/B000000000?keywords=x&th=1&psc=1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never rewrites matrix.to permalinks or non-http schemes', () => {
|
||||||
|
const permalink = 'https://matrix.to/#/!room:example.org/$event?via=example.org&utm_source=x';
|
||||||
|
assert.equal(stripTrackingParams(permalink), permalink);
|
||||||
|
assert.equal(
|
||||||
|
stripTrackingParams('mxc://example.org/abc?utm_source=x'),
|
||||||
|
'mxc://example.org/abc?utm_source=x',
|
||||||
|
);
|
||||||
|
assert.equal(stripTrackingParams('mailto:a@b.c?utm_source=x'), 'mailto:a@b.c?utm_source=x');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unparseable input unchanged', () => {
|
||||||
|
assert.equal(stripTrackingParams('https://'), 'https://');
|
||||||
|
assert.equal(stripTrackingParams('not a url'), 'not a url');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent', () => {
|
||||||
|
const once = stripTrackingParams('https://x.com/user/status/1?s=20&t=abc');
|
||||||
|
assert.equal(once, 'https://x.com/user/status/1');
|
||||||
|
assert.equal(stripTrackingParams(once), once);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stripTrackingParamsInText', () => {
|
||||||
|
it('cleans every URL in a block of text and keeps trailing punctuation', () => {
|
||||||
|
const text =
|
||||||
|
'see https://a.example/p?utm_source=x. and (https://b.example/?id=1&fbclid=2) plus https://c.example/?keep=1';
|
||||||
|
assert.equal(
|
||||||
|
stripTrackingParamsInText(text),
|
||||||
|
'see https://a.example/p. and (https://b.example/?id=1) plus https://c.example/?keep=1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the identical string when no URL changed', () => {
|
||||||
|
const text = 'nothing https://c.example/?keep=1 here';
|
||||||
|
assert.equal(stripTrackingParamsInText(text), text);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stripTrackingParamsInHtml', () => {
|
||||||
|
it('unescapes & for parsing and re-escapes in the output', () => {
|
||||||
|
const html =
|
||||||
|
'<a href="https://a.example/p?id=1&utm_source=x&page=2">https://a.example/p?id=1&utm_source=x&page=2</a>';
|
||||||
|
assert.equal(
|
||||||
|
stripTrackingParamsInHtml(html),
|
||||||
|
'<a href="https://a.example/p?id=1&page=2">https://a.example/p?id=1&page=2</a>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves markup untouched when nothing is stripped', () => {
|
||||||
|
const html = '<p>hi <a href="https://a.example/?x=1">link</a></p>';
|
||||||
|
assert.equal(stripTrackingParamsInHtml(html), html);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
/**
|
||||||
|
* [Gitea #103] Tracking-parameter stripping.
|
||||||
|
*
|
||||||
|
* Shared links routinely carry ad/analytics identifiers (`utm_*`, `fbclid`,
|
||||||
|
* YouTube `si=`, Amazon `ref=`…) that tie every recipient's click back to the
|
||||||
|
* person who shared the link. Everything here is pure and local: a URL goes in,
|
||||||
|
* the same URL minus known tracking params comes out. Unknown params are never
|
||||||
|
* touched, and anything that fails to parse is returned unchanged.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Exact query-parameter names stripped on every host.
|
||||||
|
const GLOBAL_PARAMS = new Set([
|
||||||
|
// Google Ads / Analytics
|
||||||
|
'gclid',
|
||||||
|
'gclsrc',
|
||||||
|
'dclid',
|
||||||
|
'gbraid',
|
||||||
|
'wbraid',
|
||||||
|
'srsltid',
|
||||||
|
'_ga',
|
||||||
|
'_gl',
|
||||||
|
// Meta / Instagram
|
||||||
|
'fbclid',
|
||||||
|
'igshid',
|
||||||
|
'igsh',
|
||||||
|
// Microsoft / Bing
|
||||||
|
'msclkid',
|
||||||
|
// X / Twitter
|
||||||
|
'twclid',
|
||||||
|
// TikTok
|
||||||
|
'ttclid',
|
||||||
|
// Yandex
|
||||||
|
'yclid',
|
||||||
|
// Mailchimp
|
||||||
|
'mc_cid',
|
||||||
|
'mc_eid',
|
||||||
|
// HubSpot
|
||||||
|
'_hsenc',
|
||||||
|
'_hsmi',
|
||||||
|
'hsCtaTracking',
|
||||||
|
// Marketo / Adobe
|
||||||
|
'mkt_tok',
|
||||||
|
's_kwcid',
|
||||||
|
'ef_id',
|
||||||
|
// Vero / Wicked Reports / Omeda
|
||||||
|
'vero_id',
|
||||||
|
'vero_conv',
|
||||||
|
'wickedid',
|
||||||
|
'oly_anon_id',
|
||||||
|
'oly_enc_id',
|
||||||
|
// Generic referrer tags
|
||||||
|
'ref_src',
|
||||||
|
'ref_url',
|
||||||
|
'spm',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Query-parameter name prefixes stripped on every host.
|
||||||
|
const GLOBAL_PREFIXES = ['utm_', 'pk_', 'piwik_', 'matomo_'];
|
||||||
|
|
||||||
|
// Host-specific params that only act as tracking on that site. Matched by
|
||||||
|
// hostname suffix (so `www.youtube.com` and `m.youtube.com` both hit `youtube.com`).
|
||||||
|
const AMAZON_HOSTS = [
|
||||||
|
'amazon.com',
|
||||||
|
'amazon.co.uk',
|
||||||
|
'amazon.de',
|
||||||
|
'amazon.ca',
|
||||||
|
'amazon.fr',
|
||||||
|
'amazon.co.jp',
|
||||||
|
];
|
||||||
|
|
||||||
|
const HOST_PARAMS: Array<{ hosts: string[]; params: string[]; prefixes?: string[] }> = [
|
||||||
|
{
|
||||||
|
hosts: ['youtube.com', 'youtu.be'],
|
||||||
|
params: ['si', 'feature', 'pp'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hosts: AMAZON_HOSTS,
|
||||||
|
// Affiliate + search-result telemetry only. `th`/`psc` pick the product
|
||||||
|
// variant that loads and are deliberately NOT stripped.
|
||||||
|
params: [
|
||||||
|
'ref',
|
||||||
|
'ref_',
|
||||||
|
'tag',
|
||||||
|
'linkCode',
|
||||||
|
'linkId',
|
||||||
|
'crid',
|
||||||
|
'sprefix',
|
||||||
|
'qid',
|
||||||
|
'sr',
|
||||||
|
'dib',
|
||||||
|
'dib_tag',
|
||||||
|
'content-id',
|
||||||
|
],
|
||||||
|
prefixes: ['pd_rd_', 'pf_rd_'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hosts: ['x.com', 'twitter.com'],
|
||||||
|
params: ['s', 't', 'ref_src'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hosts: ['spotify.com'],
|
||||||
|
params: ['si', 'nd'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hosts: ['reddit.com'],
|
||||||
|
params: ['share_id', 'rdt', 'context'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hosts: ['tiktok.com'],
|
||||||
|
params: ['_r', '_t', 'is_from_webapp', 'sender_device', 'web_id'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hosts: ['instagram.com'],
|
||||||
|
params: ['igsh', 'img_index'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hosts: ['threads.net'],
|
||||||
|
params: ['igshid'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hosts: ['bilibili.com'],
|
||||||
|
params: ['spm_id_from', 'vd_source'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hosts: ['ebay.com', 'ebay.co.uk', 'ebay.de'],
|
||||||
|
params: ['_trkparms', '_trksid', 'mkcid', 'mkevt', 'mkrid', 'campid', 'toolid', 'customid'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Hosts whose URLs are never rewritten: Matrix permalinks encode room/event
|
||||||
|
// ids after a fragment and have no tracking, but a false positive would break
|
||||||
|
// navigation, so leave the whole family alone.
|
||||||
|
const SKIP_HOSTS = ['matrix.to'];
|
||||||
|
|
||||||
|
const hostMatches = (hostname: string, suffixes: string[]): boolean =>
|
||||||
|
suffixes.some((s) => hostname === s || hostname.endsWith(`.${s}`));
|
||||||
|
|
||||||
|
const isTrackingParam = (name: string, hostname: string): boolean => {
|
||||||
|
if (GLOBAL_PARAMS.has(name)) return true;
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
if (GLOBAL_PREFIXES.some((p) => lower.startsWith(p))) return true;
|
||||||
|
return HOST_PARAMS.some(
|
||||||
|
(rule) =>
|
||||||
|
hostMatches(hostname, rule.hosts) &&
|
||||||
|
(rule.params.includes(name) || (rule.prefixes ?? []).some((p) => name.startsWith(p))),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove known tracking query parameters from an `http(s)` URL. Returns the
|
||||||
|
* input unchanged when it is not an http(s) URL, cannot be parsed, or has
|
||||||
|
* nothing to strip — so callers can compare by identity to know whether
|
||||||
|
* anything happened. Never touches the fragment or the path (except Amazon's
|
||||||
|
* `/ref=…` path segment, which is a pure referrer tag).
|
||||||
|
*/
|
||||||
|
export const stripTrackingParams = (url: string): string => {
|
||||||
|
if (!/^https?:\/\//i.test(url)) return url;
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(url);
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
const hostname = parsed.hostname.toLowerCase();
|
||||||
|
if (hostMatches(hostname, SKIP_HOSTS)) return url;
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
const params = parsed.searchParams;
|
||||||
|
Array.from(params.keys()).forEach((name) => {
|
||||||
|
if (isTrackingParam(name, hostname)) {
|
||||||
|
params.delete(name);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hostMatches(hostname, AMAZON_HOSTS)) {
|
||||||
|
const cleanedPath = parsed.pathname.replace(/\/ref=[^/]*/i, '');
|
||||||
|
if (cleanedPath !== parsed.pathname) {
|
||||||
|
parsed.pathname = cleanedPath;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed) return url;
|
||||||
|
// URLSearchParams re-serialises the remaining query; when it is now empty,
|
||||||
|
// drop the dangling `?` entirely.
|
||||||
|
if (params.toString() === '') parsed.search = '';
|
||||||
|
return parsed.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Matches an http(s) URL inside free text, stopping before whitespace, quotes,
|
||||||
|
// angle brackets or a closing paren/bracket. Trailing sentence punctuation is
|
||||||
|
// trimmed separately so "see https://a.b/?utm_source=x." keeps its full stop.
|
||||||
|
const URL_IN_TEXT = /https?:\/\/[^\s<>"'()[\]]+/gi;
|
||||||
|
const TRAILING_PUNCT = /[.,;:!?]+$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip tracking params from every http(s) URL found in a block of text (the
|
||||||
|
* plaintext `body`, or a pasted clipboard string). Returns the input unchanged
|
||||||
|
* if nothing was stripped.
|
||||||
|
*/
|
||||||
|
export const stripTrackingParamsInText = (text: string): string => {
|
||||||
|
let changed = false;
|
||||||
|
const out = text.replace(URL_IN_TEXT, (match) => {
|
||||||
|
const punct = match.match(TRAILING_PUNCT)?.[0] ?? '';
|
||||||
|
const url = punct ? match.slice(0, -punct.length) : match;
|
||||||
|
const cleaned = stripTrackingParams(url);
|
||||||
|
if (cleaned === url) return match;
|
||||||
|
changed = true;
|
||||||
|
return cleaned + punct;
|
||||||
|
});
|
||||||
|
return changed ? out : text;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as `stripTrackingParamsInText` for an HTML string (`formatted_body`),
|
||||||
|
* where `&` inside attribute values and text is escaped as `&`. Each URL is
|
||||||
|
* unescaped before stripping and re-escaped after, so the surrounding markup is
|
||||||
|
* left byte-for-byte intact.
|
||||||
|
*/
|
||||||
|
export const stripTrackingParamsInHtml = (html: string): string => {
|
||||||
|
let changed = false;
|
||||||
|
const out = html.replace(URL_IN_TEXT, (match) => {
|
||||||
|
const punct = match.match(TRAILING_PUNCT)?.[0] ?? '';
|
||||||
|
const raw = punct ? match.slice(0, -punct.length) : match;
|
||||||
|
const url = raw.replace(/&/g, '&');
|
||||||
|
const cleaned = stripTrackingParams(url);
|
||||||
|
if (cleaned === url) return match;
|
||||||
|
changed = true;
|
||||||
|
return cleaned.replace(/&/g, '&') + punct;
|
||||||
|
});
|
||||||
|
return changed ? out : html;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user