Files
cinny/src/app/utils/urlTracking.ts
T
jaredandClaude Opus 5 5b0d355417 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
2026-09-17 01:46:21 -04:00

234 lines
6.6 KiB
TypeScript

/**
* [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 `&amp;`. 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(/&amp;/g, '&');
const cleaned = stripTrackingParams(url);
if (cleaned === url) return match;
changed = true;
return cleaned.replace(/&/g, '&amp;') + punct;
});
return changed ? out : html;
};