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:
2026-09-17 01:46:21 -04:00
co-authored by Claude Opus 5
parent 470b5217ae
commit 5b0d355417
8 changed files with 428 additions and 6 deletions
+30 -4
View File
@@ -140,6 +140,7 @@ import { DraftIndicator } from './DraftIndicator';
import { scheduledMessagesAtom } from '../../state/scheduledMessages';
import { createErrorToast, toastQueueAtom } from '../../state/toast';
import { getThreadDraftKey } from '../../state/room/thread';
import { stripTrackingParamsInHtml, stripTrackingParamsInText } from '../../utils/urlTracking';
const GifPicker = React.lazy(() =>
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
// toolbar button entirely when it's off so it never opens an empty popover.
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 showLocation = composerToolbarButtons?.showLocation ?? true;
const showPoll = composerToolbarButtons?.showPoll ?? true;
@@ -389,7 +392,25 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
[setSelectedFiles, room],
);
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 { gifApiKey } = useClientConfig();
const gifBtnRef = useRef<HTMLButtonElement>(null);
@@ -623,6 +644,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
plainText = trimCommand(commandName, plainText);
customHtml = trimCommand(commandName, customHtml);
}
if (stripTracking) {
plainText = stripTrackingParamsInText(plainText);
customHtml = stripTrackingParamsInHtml(customHtml);
}
if (commandName === Command.Me) {
msgType = MsgType.Emote;
} else if (commandName === Command.Notice) {
@@ -718,6 +743,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
isMarkdown,
commands,
setToast,
stripTracking,
]);
/**
@@ -740,8 +766,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
);
if (plainText === '') return null;
const body = plainText;
const formattedBody = customHtml;
const body = stripTracking ? stripTrackingParamsInText(plainText) : plainText;
const formattedBody = stripTracking ? stripTrackingParamsInHtml(customHtml) : customHtml;
const mentionData = getMentions(mx, roomId, editor);
const content: IContent = {
@@ -769,7 +795,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}
}
return content;
}, [editor, isMarkdown, mx, roomId, replyDraft]);
}, [editor, isMarkdown, mx, roomId, replyDraft, stripTracking]);
const handleScheduleClick = useCallback(() => {
// Defense in depth: scheduling sends an unencrypted m.room.message, so never