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:
@@ -22,6 +22,7 @@ import { IntermediateRepresentation, Opts as LinkifyOpts, OptFn } from 'linkifyj
|
||||
import Linkify from 'linkify-react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { ChildNode } from 'domhandler';
|
||||
import { stripTrackingParams } from '../utils/urlTracking';
|
||||
import * as css from '../styles/CustomHtml.css';
|
||||
import {
|
||||
getMxIdLocalPart,
|
||||
@@ -106,11 +107,30 @@ const renderMath = (
|
||||
|
||||
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 = {
|
||||
attributes: {
|
||||
target: '_blank',
|
||||
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: {
|
||||
url: (value) => /^(https?|ftp|mailto|magnet):/.test(value),
|
||||
},
|
||||
@@ -393,6 +413,12 @@ export const getReactCustomHtmlParser = (
|
||||
replace: (domNode) => {
|
||||
if (domNode instanceof Element && 'name' in 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);
|
||||
|
||||
if (name === 'h1') {
|
||||
|
||||
Reference in New Issue
Block a user