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
234 lines
6.6 KiB
TypeScript
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 `&`. 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;
|
|
};
|