/** * [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; };