// ==UserScript==
// @name Proxified Links
// @author proxi
// @homepageURL https://greasyfork.org/en/scripts/485274-proxified-links
// @copyright 2023 Schimon Jehudah (http://schimon.i2p)
// @license AGPL-3.0-only; https://www.gnu.org/licenses/agpl-3.0.en.html
// @namespace com.proxi.proxified
// @description Proxified hyperlinks to a proxy instance or Farside with no nonsense
// Add or remove preferred services and instances yourself!
//
// Forked from Proxify Links v23.10.17, by Schimon Jehudah, and modified with prejudice and style
// - Proxy, proxy, proxy; add or configure non-proxying frontends yourself with discretion
// - No probing HTTP requests to proxies, use a small hardcoded list of favorites, or
// hold X key to use Farside redirection where possible
// - Hold Z key to use the original link whenever needed
// - Coexist with first-party frontends that can be used with discretion,
// meaning don't mess with the official site by linking out to a proxy of itself!
// - Ignore search engines and leave it to user browser and search configuration,
// don't play around with nonexistent LMGTFY links or broken Google scrapers
// - Clearnet by default following Farside, add personal lists of decentralized nodes
// - Set noreferrer on supported links, optionally all links if `ENABLE_REFERER_HIDE_PAGEWIDE`,
// preventing referer header while browsing without breaking common ajax functionality
// @run-at document-end
// @version 0.2.3
// @match *://*/*
// @icon data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgOTAwMCA5MDAwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbDpzcGFjZT0icHJlc2VydmUiPjxyZWN0IHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbGw9InllbGxvdyIvPjxwYXRoIGQ9Ik0uMjc5LS4xNDYuMTUgMGgtLjE3MmwuMjQtLjI2Ni0uMTMyLS4yNTNoLjE0OWwuMDc2LjE0OC4xMzEtLjE0OGguMTdsLS4yNC4yNjkuMTMuMjVILjM1NEwuMjc5LS4xNDZaIiBzdHlsZT0iZmlsbDp3aGl0ZTsiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDE2NzUgNzcyOCkgc2NhbGUoODAwMCkiLz48cGF0aCBkPSJNLjE4OCAwSC4wNDFMLjE5LS43MTZoLjI5MWEuMzQuMzQgMCAwIDEgLjEyMy4wMThjLjAzLjAxMy4wNTQuMDMzLjA3Mi4wNjFhLjE5Mi4xOTIgMCAwIDEgLjAyNi4xMDEuMjczLjI3MyAwIDAgMS0uMDIxLjEwNS4yNC4yNCAwIDAgMS0uMDUxLjA4Mi4yMzcuMjM3IDAgMCAxLS4xNjEuMDcxIDEuMTY2IDEuMTY2IDAgMCAxLS4xMy4wMDVILjI0NUwuMTg4IDBaTS4yNy0uMzkyaC4wNDVhLjUzLjUzIDAgMCAwIC4xNTYtLjAxNC4xMzEuMTMxIDAgMCAwIC4wNjEtLjA0Ny4xMjMuMTIzIDAgMCAwIC4wMjMtLjA3MS4wNy4wNyAwIDAgMC0uMDEyLS4wNDIuMDYyLjA2MiAwIDAgMC0uMDMyLS4wMjUuMzU4LjM1OCAwIDAgMC0uMDkzLS4wMDdILjMxM0wuMjctLjM5MloiIHRyYW5zZm9ybT0ibWF0cml4KDkzMjAgMCAwIDkzMTMgMTEyOCA3NzI4KSIvPjxwYXRoIGQ9Im0yNTEuNjg0IDY1NS45ODkuMTYyLjIzIDUyMi40ODYgMTczLjI5Mi0xMjEuMjk4LTE3My4zNTItLjUwMy0uMTdIMjUxLjY4NFptMTM0OS40NzYgMzE4LjYxLTI4My45OS05NS4wMTZjLTY0LjE5LS45NjItMTQ4LjMyLTEuNDQzLTI1Mi4zOS0xLjQ0M0g4MDguMzU5bC0xNy43MjMtMjUuMzI4LTIxMi4wNTQgMTIxLjc4N2gzNDYuMzg0bDEwMi4wODQtNTkuNzM3IDE3Ny4xNiA1OS43MzdoMzk2Ljk1WiIgc3R5bGU9ImZpbGw6eWVsbG93Z3JlZW4iIHRyYW5zZm9ybT0ibWF0cml4KDMuNDMgMCAwIC0xMS40NCA2NDIgMTUyMzUuNikiLz48cGF0aCBkPSJNMTUwMCA4MTIwaDU4MDB2MzYwSDE3NTB6Ii8+IDwvc3ZnPg==
// ==/UserScript==
/**
* Basic configurations for functionality and performance
*/
const KEY_MODIFIED = 'x'; // X key to modify clicked link to Farside, if applicable
const KEY_BONAFIDE = 'z'; // Z key to revert clicked link to the original destination
const TOUCH_MODIFIED = 3; // Simple 3-finger tap gesture to modify tapped link to Farside, if applicable
const TOUCH_BONAFIDE = 5; // Simple 5-finger tap gesture to revert tapped link to the original destination
const TOUCH_PROXIFIED = 2; // Reserved 2-finger tap gesture to reset tapped link to static proxified
// Optional keys to limit unintentional triggering on non-links
// Set to empty '' string, proxification will happen automatically on selection
// TODO: improve touch support
const KEY_PROXIBITE = 'b'; // Optional B key to allow heavier elements to be proxified
const KEY_FRAMEBITE = 'f'; // Optional B+F key combination to trigger when iframes are updated
const TOUCH_PROXIBITE = 4; // Simple 3-finger tap gesture to allow all heavier elements to be proxified
const TOUCH_FRAMEBITE = 4;
const KEY_NAVIGATE = ''; // Optional filter for key nav used to proxify the selected element, e.g Tab
// Optional proxified iframes with supported src, proxified immediately when selected
// Best used if iframe content is already blocked by a content blocker (e.g. ublock click2load),
// so the original frame is never loaded automatically even if switching back from proxified (B+F+Z)
const ENABLE_IFRAME_PROXIFIED = true;
// Optional noreferer override on all proxified links
// If disabled, only upgrade undefined or noopener to noreferrer for proxified links
// Disable if proxified but bona fide (Z) links are breaking
const ENABLE_REFERER_HIDE = true;
// Optional noreferrer override on all links on the page
// Disable if non-proxified links or site authentication windows are breaking
const ENABLE_REFERER_HIDE_PAGEWIDE = true;
// Optional proxified links using query string if the main url does not match a proxy
// Useful for tracking links or old-fashioned HTML GET search, e.g. DuckDuckGo (uddg)
// Strongly recommend used with `ENABLE_REFERER_HIDE_PAGEWIDE` to avoid exposing search engine and keywords
// Disable if random links that shouldn't be proxified still are
const ENABLE_QUERY_PROXIFIED = true;
const ENABLE_QUERY_PROXIFIED_ON = []; // if empty, all query parameters will be included
// Optional stripping links of attributes that are necessary to proxify links (Google),
// and/or optional removal of link trackers on sites or search engines
const ENABLE_ATTRIBUTES_SMITE = true;
/** @type {Object<string, {attributes: string[], allowFuzzy?: boolean}>} */
const ENABLE_ATTRIBUTES_SMITE_ON_SITE = { 'google.com': { attributes: ['data-sb'] } };
/**
* Configure site settings or add an instance of a proxy to the site's `redirect` list
*
* As is, use a shortlist of useful instances and mature proxy services
* Out of box is an opinionated, not comprehensive or up-to-date, list of useful instances,
* not ordered alphabetically but in the order of best proxied or most commonly linked
*
* @typedef {{
* redirect?: {
* replacements?: string[],
* suffix?: string, // optional suffix to a random replacement host from `replacements`
* routes?: ProxyRoute[], // optional route whitelist by matching url regex, defaults to replacing url host only if undefined
* inherit?: string // inherit any undefined properties in `redirect`
* },
* redirectToFarside?: {
* replacements?: string[],
* suffix?: string, // optional suffix to a random replacement host from `replacements`
* routes?: ProxyRoute[], // optional route whitelist by matching url regex, defaults to replacing url host only if undefined
* inherit?: string // inherit any undefined properties in `redirectToFarside`
* },
* allowFuzzy?: boolean, // allows fuzzy host matching, for subdomains
* allowIframe?: boolean, // allows matching on iframe src
* inherit?: string // inherit any undefined redirect rules
* }} Proxy
* @typedef {{
* regex: RegExp, // route regex match
* suffix?: string, // optional route suffix on host, appending to parent `suffix` if defined
* }} ProxyRoute
* @typedef {Object<string, Proxy>} ProxyList
*/
/**
* @type {ProxyList}
*/
const PROXIES = {
// youtube.com, youtu.be, m.youtube.com, youtube-nocookie.com
// /watch, /trending, /@, /channel/
// Allows iframe matching too, which is useful when content blocking embeds
// e.g. ublock ||youtube.com^$3p,frame,redirect=click2load.html
'youtube.com': {
allowIframe: true,
redirect: {
replacements: [
'https://piped.video',
'https://piped.smnz.de',
'https://piped.projectsegfau.lt',
'https://piped.privacydev.net',
//'https://piped.lunar.icu', // embedded frame blocked by x-frame-options
'https://piped.adminforge.de',
'https://pd.vern.cc',
// The following Invidious instances not only allow video proxy but proxy by default
'https://iv.datura.network',
'https://invidious.projectsegfau.lt',
'https://invidious.fdn.fr',
],
},
redirectToFarside: {
// Only use Farside's Piped redirect since most Invidious instances do not proxy videos by default
// Aside from proxy by default, Invidious is preferred for nojs, configuration, and download functionality
// Specific Invidious instances that proxy by default are included in the instance `redirect` list
replacements: ['https://farside.link/piped'],
},
},
'youtu.be': {
redirect: {
inherit: 'youtube.com',
routes: [
{
regex: /^https?:\/\/(www\.)?youtu\.be\/([A-Za-z0-9_-]+)\??(.*)$/,
suffix: '/watch?v=$2&$3', // manually add in path and params to support invidious https://github.com/iv-org/invidious/issues/3933
},
],
},
redirectToFarside: {
inherit: 'youtube.com',
routes: [
{
regex: /^https?:\/\/(www\.)?youtu\.be\/([A-Za-z0-9_-]+)\??(.*)$/,
suffix: '/watch?v=$2&$3', // manually add in path and params to support invidious https://github.com/iv-org/invidious/issues/3933
},
],
},
},
'm.youtube.com': { inherit: 'youtube.com' },
'youtube-nocookie.com': { inherit: 'youtube.com' },
// reddit.com
'reddit.com': {
redirect: {
replacements: [
//'https://libreddit.projectsegfau.lt', // low availability
'https://libreddit.privacydev.net',
'https://l.opnxng.com',
//'https://reddit.invak.id', // down
'https://libreddit.kavin.rocks',
],
},
redirectToFarside: {
// teddit no longer actively maintained: https://codeberg.org/teddit/teddit
replacements: ['https://farside.link/libreddit'],
},
},
// redd.it image shortlinks
'i.redd.it': {
redirect: {
inherit: 'reddit.com',
suffix: '/img',
},
redirectToFarside: {
inherit: 'reddit.com',
suffix: '/img',
},
},
'preview.redd.it': {
redirect: {
inherit: 'reddit.com',
suffix: '/preview/pre',
},
redirectToFarside: {
inherit: 'reddit.com',
suffix: '/preview/pre',
},
},
'external-preview.redd.it': {
redirect: {
inherit: 'reddit.com',
suffix: '/preview/external-pre',
},
redirectToFarside: {
inherit: 'reddit.com',
suffix: '/preview/external-pre',
},
},
/**
*
* Below are less mature or partially featured services
*
*/
// stackoverflow.com, {subdomain}.stackexchange.com
// TBD: superuser.com and other stack sites pending AnonymousOverflow support
'stackoverflow.com': {
redirect: {
replacements: [
'https://ao.vern.cc',
'https://overflow.smnz.de',
'https://overflow.lunar.icu',
'https://overflow.adminforge.de',
//'https://overflow.hostux.net', // low stability
// 'https://overflow.projectsegfau.lt', // low availability
],
},
redirectToFarside: {
replacements: ['https://farside.link/anonymousoverflow'],
},
},
'.stackexchange.com': {
allowFuzzy: true, // enable matching loosely with arbitrary subdomain
redirect: {
inherit: 'stackoverflow.com',
routes: [
{
regex: /^https?:\/\/(www\.)?([a-z]+)\..*?\//g,
suffix: '/exchange/$2/', // suffix /exchange/{subdomain} on hostname
},
],
},
redirectToFarside: {
inherit: 'stackoverflow.com',
routes: [
{
regex: /^https?:\/\/(www\.)?([a-z]+)\..*?\//g,
suffix: '/exchange/$2/',
},
],
},
},
// quora.com
'quora.com': {
redirect: {
replacements: ['https://quetre.iket.me', 'https://quetre.pussthecat.org', 'https://quetre.privacydev.net'],
},
redirectToFarside: {
replacements: ['https://farside.link/quetre'],
},
},
// {artist}.bandcamp.com
// Note: bandcamp.com/search route not supported, add above for 'bandcamp.com' if this rare link is needed is the wild
// Note: {cdn}.bcbits.com routes not supported, add below for '.bcbits.com' if this rare link is needed in the wild
'.bandcamp.com': {
allowFuzzy: true,
redirect: {
replacements: ['https://tent.sny.sh', 'https://tn.vern.cc'],
routes: [
{
// {artist}.bandcamp.com with no additional path except optional /music
// exclude daily.bandcamp.com
regex: /^https?:\/\/(www\.)?((?!daily\.)[a-z0-9\-]+)\.bandcamp\.com\/?(music)?$/g,
suffix: '/artist.php?name=$2',
},
{
// {artist}.bandcamp.com/{release}/{name}
// exclude daily.bandcamp.com, e.g. daily.bandcamp.com/features/{article}
regex: /^https?:\/\/(www\.)?((?!daily\.)[a-z0-9\-]+)\.bandcamp\.com\/([a-z]+)\/([a-z0-9\-]+)/g,
suffix: '/release.php?artist=$2&type=$3&name=$4',
},
],
},
},
// instagram.com
// Low feature parity
'instagram.com': {
redirect: {
replacements: ['https://ig.opnxng.com', 'https://proxigram.lunar.icu'],
},
redirectToFarside: {
replacements: ['https://farside.link/proxigram'],
},
},
// tiktok
// Low feature parity
'tiktok.com': {
redirect: {
replacements: [
'https://proxitok.pussthecat.org',
'https://tok.artemislena.eu',
'https://tok.adminforge.de',
'https://tik.hostux.net',
'https://proxitok.lunar.icu',
],
},
redirectToFarside: {
replacements: ['https://farside.link/proxitok'],
},
},
// imgur.com, i.imgur.com, i.stack.imgur.com
'imgur.com': {
redirect: {
replacements: [
'https://rimgo.pussthecat.org',
'https://imgur.artemislena.eu',
'https://rimgo.vern.cc',
'https://rimgo.hostux.net',
'https://rimgo.lunar.icu',
//'https://rimgo.projectsegfau.lt', // low availability
],
},
redirectToFarside: {
replacements: ['https://farside.link/rimgo'],
},
},
'i.imgur.com': { inherit: 'imgur.com' },
'i.stack.imgur.com': {
inherit: 'imgur.com',
redirect: { inherit: 'imgur.com', suffix: '/stack' },
redirectToFarside: { inherit: 'imgur.com', suffix: '/stack' },
},
// github.com, gists.github.com
// /explore, /{group}/{repo}, /{group}/{repo}/archive, gists.github.com -> /gists/
// Low feature parity
// Use only for repo landing page, downloads, and gists
'github.com': {
redirect: {
replacements: [
'https://gothub.lunar.icu',
'https://g.opnxng.com',
//'https://gothub.projectsegfau.lt', // low availability
'https://gothub.dev.projectsegfau.lt',
],
},
redirectToFarside: {
replacements: ['https://farside.link/gothub'],
},
},
// gist.github.com
'gist.github.com': {
redirect: {
inherit: 'github.com',
suffix: '/gist/',
routes: [{ regex: /https?:\/\/(.*?)\//g }], // replace entire domain
},
redirectToFarside: {
inherit: 'github.com',
suffix: '/gist/',
routes: [{ regex: /https?:\/\/(.*?)\//g }],
},
},
// imdb.com, m.imdb.com
'imdb.com': {
redirect: {
replacements: [
'https://libremdb.pussthecat.org',
'https://libremdb.iket.me',
'https://ld.vern.cc',
'https://libremdb.lunar.icu',
],
},
redirectToFarside: {
replacements: ['https://farside.link/libremdb'],
},
},
'm.imdb.com': { inherit: 'imdb.com' },
// genius.com
// Low feature parity
'genius.com': {
redirect: {
replacements: ['https://dumb.privacydev.net', 'https://dm.vern.cc', 'https://dumb.lunar.icu'],
},
redirectToFarside: {
replacements: ['https://farside.link/dumb'],
},
},
// medium.com - Uncomment to use
// Low feature parity by design
// Not a proxy by design, alternative frontend still requests from the official servers
//
// Recommend setting Medium to noscript and/or loading through more standard proxies such as TOR
// Medium with JS disabled works as of now, but other proxy sites such as archive.org can be used if needed
/* --- Remove this line to use --- //
'medium.com': {
redirect: {
replacements: ['https://scribe.rip', 'https://sc.vern.cc', 'https://m.opnxng.com'],
},
redirectToFarside: {
replacements: ['https://farside.link/scribe'],
},
},
// ------------------------------- */
// fandom.com - Uncomment to use
// Not a full proxy, alternative frontend still requests from the official servers
//
// Recommend simply using a content blocker to block ads and other annoyances
/* --- Remove this line to use --- //
'.fandom.com': {
allowFuzzy: true,
redirect: {
replacements: [
'https://breezewiki.com',
'https://antifandom.com',
'https://breezewiki.pussthecat.org',
'https://bw.projectsegfau.lt',
'https://breeze.hostux.net',
'https://bw.artemislena.eu',
'https://breeze.nohost.network',
'https://z.opnxng.com',
],
routes: [
{
regex: /^https?:\/\/(www\.)?([a-z\-]+)\..*?\//g,
suffix: '/$2/', // suffix /{subdomain} on hostname
},
],
},
redirectToFarside: {
replacements: ['https://farside.link/breezewiki'],
routes: [
{
regex: /^https?:\/\/(www\.)?([a-z\-]+)\..*?\//g,
suffix: '/$2/',
},
],
},
},
// ------------------------------- */
// wikipedia.org - Uncomment to use
//
// Recommend setting Wikipedia to noscript and/or loading through more standard proxies such as TOR
// If absolutely needed, recommend rolling your own Wikiless instance routed through a proxy or VPN
// Wikipedia trustworthiness and scriptless tracking is more or less equivalent to wikiless instances
/* --- Remove this line to use --- //
'wikipedia.org': {
redirect: {
replacements: [
'https://wiki.adminforge.de',
'https://wikiless.lunar.icu',
'https://wikiless.org',
'https://wl.vern.cc',
],
},
redirectToFarside: {
replacements: ['https://farside.link/wikiless'],
},
},
// ------------------------------- */
};
/**
* Configure site link exclusions
*
* The most common exclusion will be on the first-party site itself, as many proxies are not complete replacements.
* Common unsuppoorted features and paths are excluded, though this is not intended to exhaustively track the list of
* proxied frontends and availability or configuration of individual instances.
*
* excludeLinks can exclude links that match any provided rule, namely matchingPath
* excludeOn can exclude links when host matches `self` or any provided, e.g. by matchingPath (all paths if empty)
* @typedef {{
* excludeLinks?: {
* matchingPath?: (string|RegExp)[],
* matchingHost?: (string|RegExp)[],
* matchingUrl?: (string|RegExp)[],
* matchingText?: (string|RegExp)[],
* inherit?: string // inherit any undefined properties in `excludeLinks`
* },
* excludeOn?: {
* matchingPath?: (string|RegExp)[],
* matchingHost?: (string|RegExp)[],
* matchingUrl?: (string|RegExp)[],
* matchingBody?: (string|RegExp)[],
* matchingHead?: (string|RegExp)[],
* inherit?: string // inherit any undefined properties in `excludeOn`
* },
* allowFuzzy?: boolean, // allows fuzzy host matching, for subdomains
* inherit?: string // inherits any undefined exclusion rules
* }} Exclusion
* @typedef {Object<string, Exclusion>} ExclusionList
*/
/**
* Configure per-site exclusions
* @type {ExclusionList}
*/
const EXCLUSIONS = {
// youtube.com, m.youtube.com, youtube-nocookie.com
'youtube.com': {
excludeLinks: {
matchingPath: ['/users/'],
},
excludeOn: {
// exclude linking out from the official site
matchingHost: ['youtube.com', 'youtube-nocookie.com'],
matchingHead: [
'<meta property="og:title" content="Piped">',
/<meta property="og:site_name" content=".*Invidious">/,
],
},
},
'm.youtube.com': { inherit: 'youtube.com' },
'youtube-nocookie.com': { inherit: 'youtube.com' },
// reddit.com
'reddit.com': {
excludeLinks: {
// old.reddit.com can be used almost entirely with noscript
matchingHost: ['old.reddit.com'],
},
excludeOn: {
// exclude on reddit.com but still proxy links while browsing old.reddit.com
matchingUrl: [/^https?:\/\/(www\.)?(?!old\.)reddit.com/],
},
},
// stackoverflow.com, {subdomain}.stackexchange.com
'stackoverflow.com': {
excludeLinks: {
matchingPath: ['/questions/tagged/', '/users/'],
},
excludeOn: {
matchingHost: ['stackoverflow.com', 'stackexchange.com'],
},
},
'.stackexchange.com': {
allowFuzzy: true,
inherit: 'stackoverflow.com',
},
// quora.com
'quora.com': { excludeOn: { matchingHost: ['quora.com'] } },
// {artist}.bandcamp.com
'.bandcamp.com': { allowFuzzy: true, excludeOn: { matchingHost: ['bandcamp.com'] } },
// instagram.com
'instagram.com': {
excludeOn: {
matchingHost: ['instagram.com'],
matchingHead: [
/<meta property="og:title" content="[a-zA-Z0-9 _\-+=.,:;'?\/\\`!@#$%^&*()-_\[\]{}|]+? • Proxigram">/,
],
},
},
// tiktok.com
'tiktok.com': {
excludeOn: {
matchingHost: ['tiktok.com'],
// exclude on ProxiTok itself, linking out to original
matchingHead: ['<meta property="og:site_name" content="ProxiTok">'],
},
},
// imgur.com
'imgur.com': { excludeOn: { matchingHost: ['imgur.com'] } },
'i.imgur.com': { inherit: 'imgur.com' },
'i.stack.imgur.com': { inherit: 'imgur.com' },
// github.com
// a lot to blacklist, can also whitelist limited functionality instead
'github.com': {
excludeLinks: {
matchingText: [],
// prettier-ignore
matchingPath: ['/actions','/blame/','/codespaces/','/collections/','/commit/','/commits/','/compare/','/customer-stories','/delete/','/discussions/','/enterprise/','/events/','/features/','/graphs/','/issues','/marketplace/','/notifications/','/orgs/','/projects/','/pulls', '/pull/','/pulse','/releases','/security','/sessions/','/sponsors/','/tags','/tree/','/wiki/'],
},
excludeOn: {
matchingHost: ['github.com'],
// exclude on Gothub page itself, linking out
matchingBody: ['<a href="https://codeberg.org/gothub/gothub">Source code</a>'],
},
},
'gist.github.com': {
excludeLinks: {
// gist single directory paths, e.g. users, /discover, /starred
matchingPath: [/^\/[A-Za-z0-9_.-]+\/?$/],
},
inherit: 'github.com',
},
// imdb.com
'imdb.com': {
excludeOn: {
matchingHost: ['imdb.com'],
// exclude on libremdb itself, linking out
matchingHead: ['<meta property="og:site_name" content="libremdb">'],
},
},
// genius.com
// /artist and other pages may not work, but not blacklisting any paths for now
'genius.com': { excludeOn: { matchingHost: ['genius.com'] } },
// medium.com
'medium.com': { excludeOn: { matchingHost: ['medium.com'] } },
// fandom.com
'fandom.com': { excludeOn: { matchingHost: ['fandom.com'] } },
// wikipedia.org
'wikipedia.org': { excludeOn: { matchingHost: ['wikipedia.org'] } },
};
/**
* Begin script logic
*
* Do not modify below if adding or making changes to available proxies
*/
const DEBUG = false; // Console logging enabled when true
// Tag names of hovered link elements that can be proxified
const PROXIFY_ON = ['A', 'IFRAME'];
// Tag names of hovered elements that should also lookup to the parent node for links
// TODO: per-tag and per-site configuration
const PARENT_LOOKUP_ON = [
'BUTTON',
'IMG',
'SVG',
'H1',
'H2',
'H3',
'H4',
'H5',
'H6',
'B',
'I',
'EM',
'STRONG',
'SMALL',
'SUP',
'SUB',
'S',
'U',
'LI',
];
// Default depth limit to ancestor lookup recursion
const PARENT_LOOKUP_STEPS = 1;
/**
* Additional per-site parent lookup config, to fix site-specific proxification
* TODO: further segment sites on matchesPath or other rules and per-nodeName configs
* @type { Object<string, {nodeNames: string[], steps: number} > }
*/
const PARENT_LOOKUP_ON_SITE = {
// google.com mobile search results div
// TODO: improve mobile search inline media touch events swallowed
'google.com': { nodeNames: ['DIV'], steps: 4 },
// duckduckgo.com search results span, hero summary SVGs and title container div
'duckduckgo.com': { nodeNames: ['SPAN', 'DIV', 'PATH', 'RECT'], steps: 4 },
// startpage.com search results inline images/videos
'startpage.com': { steps: 2 },
};
// Tag names of hovered elements that should also globally search all hovered elements for links
const HOVER_LOOKUP_ON = ['P', 'SPAN', 'LI', 'LABEL', /*'DIV',*/ 'BUTTON', 'IMG', 'SVG'];
// Additional tag names to search per-site, to fix site-specific proxificaton
const HOVER_LOOKUP_ON_HOST = {
'duckduckgo.com': ['DIV'],
};
const CLASS_PROXIFIED = 'proxi-fied';
const CLASS_PROXIFIED_LIVE = 'proxi-live';
const CLASS_BODY_FARSIDE = 'proxi-side';
const CLASS_BODY_BONAFIDE = 'proxi-fide';
const CLASS_BODY_BITE = 'proxi-bite';
const CLASS_BODY_FRAMEBITE = 'proxi-framebite';
const STYLE_HIGHLIGHT_ID = 'proxi-highlite';
const ATTR_PROXIFIED_SITE = 'proxi-site';
const ATTR_PROXIFIED_FARSIDE = 'proxi-site-side';
const ATTR_PROXIFIED_DENIED = 'proxi-nied';
const ATTR_BONAFIDE_SITE = 'proxi-site-bonafide';
const ATTR_IS_FARSIDE = 'proxi-side';
// https://uibakery.io/regex-library/url
const REGEX_URL =
/^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&\/=]*)$/;
// Key event modifiers for selecting destination
document.body.addEventListener('keydown', e => handleModifierKey(e));
document.body.addEventListener('keyup', e => handleModifierKey(e));
document.body.addEventListener('touchstart', e => handleModifierGesture(e));
/**
* Lazy load link processing when user interacts per-element
*
* Handle hovering over links and completed keyboard navigation over link
* Does not handle other basic redirects such as form action or onclick attributes
*/
document.body.addEventListener('mouseover', handleElement); // link hover
document.body.addEventListener('touchstart', handleElement); // link touch
document.body.addEventListener('keyup', handleElement); // keyboard navigation
/**
* Hover event handler to find an anchor hyperlink to check
* @param {Event} e
*/
function handleElement(e) {
// Currently support specific events, and optional key nav filter
if (
e.type !== 'mouseover' &&
e.type !== 'touchstart' &&
(e.type !== 'keyup' || (!!KEY_NAVIGATE && e.key !== KEY_NAVIGATE))
) {
return;
}
// Ignore target on multi-touch touch event
if (e.type === 'touchstart' && e.touches.length !== 1) {
return;
}
// Look for any element or elements that this event could be trying to proxify
const proxifiableEls = getProxifiableElements(e);
if (!proxifiableEls || !proxifiableEls.length) return;
// Prioritize anchor links
// Take the first anchor, even if it has already been processed
const targetAnchor = proxifiableEls.find(el => el.nodeName === 'A');
if (!!targetAnchor) {
// Hovered anchor found, process it unless it was already processed before
if (!isElementProxified(targetAnchor)) {
handleAnchorEl(targetAnchor);
} else {
// Otherwise, trigger an update on the link element to ensure it is current
setTimeout(() => {
updateProxifiedElement(targetAnchor, 'href');
}, 0);
}
// Only handle one element per event for now to avoid overeager proxification
return;
}
// Handle iframe, if enabled
// If both or either bite key is empty, handle on any interaction
if (ENABLE_IFRAME_PROXIFIED) {
const isProxibite = document.body.classList.contains(CLASS_BODY_BITE);
const isFramebite = document.body.classList.contains(CLASS_BODY_FRAMEBITE);
const canBite =
(!KEY_PROXIBITE || isProxibite || e.key === KEY_PROXIBITE || e.touches?.length === TOUCH_PROXIBITE) &&
(!KEY_FRAMEBITE || isFramebite || e.key === KEY_FRAMEBITE || e.touches?.length === TOUCH_FRAMEBITE);
const targetIframe = canBite && proxifiableEls.find(el => el.nodeName === 'IFRAME');
if (!!targetIframe) {
if (!isElementProxified(targetIframe)) {
handleIframe(targetIframe);
} else {
setTimeout(() => {
updateProxifiedElement(targetIframe, 'src');
}, 0);
}
}
return;
}
}
/**
* Handle an anchor element to be proxified
* Allows re-proxifying the anchor
*
* @param {HTMLAnchorElement} el Anchor element
* @returns {void}
*/
function handleAnchorEl(el) {
// Perform element and url validation and returns a valid proxy
const { url, proxy } = preproxifyElement(el, 'href') || {};
// Perform any global link modifications
if (ENABLE_REFERER_HIDE_PAGEWIDE) el.rel = 'noreferrer';
let optSmite;
if (
ENABLE_ATTRIBUTES_SMITE &&
!!el.href &&
!!(optSmite = getByHost(ENABLE_ATTRIBUTES_SMITE_ON_SITE, window.location.host, true))
) {
// If enabled on the current site, strip any click event attributes from this anchor element
// Resulting hyperlink is intended to be a primitive link with no events intercepting navigation
// TODO: Support reverting events on bonafide
for (const attr of optSmite.attributes) {
el.removeAttribute(attr);
}
}
// Proxify the link
// Error handling will have been done on preproxification
if (!!proxy) {
proxifyElement(
el,
url,
'href',
proxy,
/** @param {HTMLAnchorElement} el */ el => {
// Trigger an update on the link now that it has been proxified
updateProxifiedElement(el, 'href');
// Upgrade undefined or noopener relationship to norefererer
// If `ENABLE_REFERER_HIDE`, override on all proxified links
// TODO: Support modifier key to revert change?
if (!el.rel || el.rel === 'noopener' || ENABLE_REFERER_HIDE) el.rel = 'noreferrer';
}
);
}
}
/**
* Handle an iframe to be proxified
* Allows re-proxifying the frame
*
* @param {HTMLIFrameElement} el
* @returns {void}
*/
function handleIframe(el) {
const { url, proxy } = preproxifyElement(el, 'src') || {};
if (!!proxy)
proxifyElement(
el,
url,
'src',
proxy,
/** @param {HTMLIframeElement} el */ el => {
// Trigger an update on the link now that it has been proxified
updateProxifiedElement(el, 'src');
}
);
}
/**
* Get the element or elements that can be proxified, including those already proxified,
* from an event where the user is hovering or making a selection
*
* @param {MouseEvent | KeyboardEvent | TouchEvent} e
* @returns {Element[]?} If none, null
*/
function getProxifiableElements(e) {
if (!(e instanceof MouseEvent || e instanceof KeyboardEvent || e instanceof TouchEvent)) {
error('Invalid event');
return null;
}
if (!e.target) return null;
const nodeName = e.target.nodeName.toUpperCase();
// Anchor links get top priority if directly targeted
if (nodeName === 'A') return [e.target];
// Otherwise, build a list of candidates in the order of probable priority
// Add in other directly targeted non-anchor links to the top
const proxifiableEls = [];
if (PROXIFY_ON.includes(nodeName)) proxifiableEls.push(e.target);
// Parents may not receive propagated events, so use this event now to check if nested under a link
let optLookup = getByHost(PARENT_LOOKUP_ON_SITE, window.location.host, true);
if (PARENT_LOOKUP_ON.includes(nodeName) || optLookup?.nodeNames?.includes(nodeName)) {
// Step up through ancestors
// Or use target.closest() to search all ancestors
const lookupSteps = Math.max(optLookup?.steps ? optLookup.steps : 1, PARENT_LOOKUP_STEPS);
let currentNode = e.target;
for (let step = 0; step < lookupSteps; step++) {
if (!!currentNode.parentNode) {
currentNode = currentNode.parentNode;
// take processed or unprocessed proxifiable elements
if (PROXIFY_ON.includes(currentNode.nodeName.toUpperCase())) {
proxifiableEls.push(currentNode);
}
} else {
break;
}
}
}
// A link may be a sibling or other relative, or not even a relative, that does not receive propagated
// For mouse hover or bite event, find any link element that is currently being hovered
// TODO: Skip previously lookup elements and handle updateProxifiedElement rerender separately from proxifying
const isHoverLookup =
e instanceof MouseEvent &&
(HOVER_LOOKUP_ON.includes(nodeName) || HOVER_LOOKUP_ON_HOST[window.location.host]?.includes(nodeName));
const isProxiBite = !!KEY_PROXIBITE && document.body.classList.contains(CLASS_BODY_BITE);
if (isHoverLookup || isProxiBite) {
// Select all hovered elements and take the processed or unprocessed proxifiable elements
const hoveredEls = document.querySelectorAll(':hover');
for (const el of hoveredEls) {
if (PROXIFY_ON.includes(el.nodeName.toUpperCase())) {
proxifiableEls.push(el);
}
}
}
return !!proxifiableEls.length ? proxifiableEls : null;
}
/**
* Perform element and destination url validation and return a valid proxy
*
* @param {HTMLElement} el Target element to be validated for proxification
* @param {string} destinationAttr Element link destination attribute name
* @returns {{url, Proxy} | null} Returns a random proxy for the element, null if none
*/
function preproxifyElement(el, destinationAttr) {
const destination = el[destinationAttr];
const text = el.outerText;
// Validate anchor is intended to be a hyperlink
if (!destination.length) {
el.setAttribute(ATTR_PROXIFIED_DENIED, 'Invalid, empty, or undefined destination attribute');
return null;
}
if (!REGEX_URL.test(destination)) {
el.setAttribute(ATTR_PROXIFIED_DENIED, 'Hyperlink destination is not a valid absolute URL');
return null;
}
// Parse the destination as a URL
let url;
try {
url = new URL(destination);
} catch (ex) {
// Exit on malformed URL
error(`[${text}](${destination}) error parsing URL: ${ex}`);
el.setAttribute(ATTR_PROXIFIED_DENIED, 'Error parsing URL');
return null;
}
// Exit if the original link is excluded for any reason
// Page-wide exclusions to each destination host are memoized so may save unnecessary checking
let excludedReason;
if ((excludedReason = getExclusionReasonForLink(url, text))) {
el.setAttribute(ATTR_PROXIFIED_DENIED, excludedReason);
return null;
}
// Get a flattened proxy rule that can be applied for this link
let proxy = getProxyForLink(url);
// If no proxy can be found for the link url, optionally search its query string
// Find a proxy match for any query param that is whitelisted, if used, and is a valid url
if (!proxy && ENABLE_QUERY_PROXIFIED) {
for (const [paramKey, paramVal] of url.searchParams) {
// if query param whitelist is empty, allow all
// TODO: blacklist or other prevention on undesirable effects like callback urls
if (!ENABLE_QUERY_PROXIFIED_ON?.length || ENABLE_QUERY_PROXIFIED_ON.includes(paramKey)) {
let queryStringLinkCandidate = paramVal;
let queryStringURLCandidate, queryStringProxyCandidate;
try {
queryStringLinkCandidate = decodeURI(paramVal); // attempt to decode a full URI encoded to the query string
} catch (ex) {
warn(`Failed to decode query string param: ${ex}`);
continue;
}
// skip param if it doesn't look like an absolute url to proxify
if (!REGEX_URL.test(queryStringLinkCandidate)) {
continue;
}
try {
queryStringURLCandidate = new URL(queryStringLinkCandidate);
} catch (ex) {
error(`Failed to parse validated url for query param "${paramKey}"`);
continue;
}
if (getExclusionReasonForLink(queryStringURLCandidate, text)) {
continue;
}
queryStringProxyCandidate = getProxyForLink(queryStringURLCandidate);
// if successful, break on query string and take first match
if (!!queryStringProxyCandidate) {
url = queryStringURLCandidate;
proxy = queryStringProxyCandidate;
break;
}
}
}
}
// Automatic exclusion if no proxy found on this pass
if (!proxy) {
el.setAttribute(ATTR_PROXIFIED_DENIED, 'Not found in proxies list');
return null;
}
return { url, proxy };
}
/**
* Proxify the link element with the specified proxy settings
* Supports anchor and iframe elements for now
*
* @param {HTMLAnchorElement | HTMLIFrameElement} el
* @param {URL} url
* @param {string} destinationAttr Element link destination attribute name
* @param {Proxy} proxy
* @param {Function<HTMLAnchorElement | HTMLIFrameElement>?} onProxification Optional success callback
* @returns {void}
*/
function proxifyElement(el, url, destinationAttr, proxy, onProxification) {
if (!(el instanceof HTMLAnchorElement) && !(el instanceof HTMLIFrameElement)) {
error(`${el} is not a supported element type`);
return;
}
if (!isProxyValid(proxy)) {
error(`Invalid proxy settings for proxifying ${url}`);
return;
}
let proxifyDenied;
// Build a url to a random instance
if (!!proxy.redirect) {
const instanceList = proxy.redirect.replacements;
const instanceSuffix = proxy.redirect.suffix;
const instanceRoutes = proxy.redirect.routes;
const urlInstanceProxified = getProxifiedUrl(url, instanceList, instanceSuffix, instanceRoutes);
if (urlInstanceProxified instanceof URL) {
el.setAttribute(ATTR_PROXIFIED_SITE, urlInstanceProxified);
} else if (typeof urlInstanceProxified === 'string') {
proxifyDenied ||= urlInstanceProxified;
}
}
// Build a url to a random Farside service redirect
if (!!proxy.redirectToFarside) {
const farsideList = proxy.redirectToFarside.replacements;
const farsideSuffix = proxy.redirectToFarside.suffix;
const farsideRoutes = proxy.redirectToFarside.routes;
const urlFarsideProxified = getProxifiedUrl(url, farsideList, farsideSuffix, farsideRoutes);
if (urlFarsideProxified instanceof URL) {
el.setAttribute(ATTR_PROXIFIED_FARSIDE, urlFarsideProxified);
} else if (typeof urlFarsideProxified === 'string') {
proxifyDenied ||= urlFarsideProxified;
}
}
// Complete the proxification
if (el.hasAttribute(ATTR_PROXIFIED_SITE) || el.hasAttribute(ATTR_PROXIFIED_FARSIDE)) {
// Mark the link as successfully proxified
el.classList.add(CLASS_PROXIFIED);
// Store the original anchor href
if (!el.hasAttribute(ATTR_BONAFIDE_SITE)) {
el.setAttribute(ATTR_BONAFIDE_SITE, el[destinationAttr]);
}
// Success callback
if (!!onProxification) {
onProxification(el);
}
} else if (!!proxifyDenied) {
// No effect proxifying, but a denial reason was given
el.setAttribute(ATTR_PROXIFIED_DENIED, proxifyDenied);
} else {
// Log unexpected failure past validated inputs
error(`Failure proxifying ${url}`);
}
}
/**
* Return a proxified URL given a list of replacement hosts and optional routes
*
* @param {URL} url
* @param {string[]} replacements
* @param {string?} suffix Optional static suffix on the replacement host
* @param {ProxyRoute[]?} routes Optional route list specifying a regex match with its corresponding options
* @returns {URL | string | null} Proxified URL, string reason if denied, null if failed
*/
function getProxifiedUrl(url, replacements, suffix, routes) {
if (!url) {
error('URL null or undefined');
return null;
}
if (!replacements) {
error('Replacements list null or undefined');
return null;
}
// Get a cleaned list of replacement strings
const replacementList = replacements.filter(
replacement => typeof replacement === 'string' && REGEX_URL.test(replacement)
);
// Get a random replacement string from the list and replace
if (replacementList.length > 0) {
const replacementRandom = replacementList[Math.floor(Math.random() * replacementList.length)];
const replacementSuffix = suffix || '';
const replacementRoute = routes?.find(route => route?.regex instanceof RegExp && route.regex.test(url.href));
const failedWhitelist = !!routes && !replacementRoute; // proxy routes whitelist defined but none matched
let urlProxified = new URL(url);
if (!!replacementRoute) {
// Regex replacement
// TODO: Support replacement function as an alternative to the replacement string route suffix
try {
const routeRegex = replacementRoute.regex;
const routeSuffix = replacementRoute.suffix || '';
const hrefProxified = url.href.replace(routeRegex, replacementRandom + replacementSuffix + routeSuffix);
urlProxified = new URL(hrefProxified);
} catch (ex) {
warn(`Invalid regex replaced URL for ${url}`);
return null;
}
} else if (!failedWhitelist) {
// Default regex replacement on url host and scheme
// Skip if whitelisted routes were defined but not matched
try {
const hrefProxified = url.href.replace(
/(https?:\/\/)(.*?)(\/.*)/,
replacementRandom + replacementSuffix + '$3'
);
urlProxified = new URL(hrefProxified);
} catch (ex) {
error(`Invalid default replaced URL for ${url}`);
return null;
}
}
if (url.href !== urlProxified.href) {
return urlProxified;
} else if (failedWhitelist) {
return 'Failed routes whitelist';
} else {
error(`No effect proxifying ${url}`);
return null;
}
}
warn(`Missing or invalid replacement URLs for ${url}`);
return null;
}
/**
* Returns matching proxy for the specified link url
*
* @param {URL} url
* @returns {Proxy?} Proxy settings by url, null if not found
*/
function getProxyForLink(url) {
// Get the redirect rules from the proxy matching url host
/** @type {Proxy?} */
const proxy = getByHost(PROXIES, url.host, true);
// Inherit any redirect rules that were left completely undefined, then inherit
// any rule properties that were left undefined
/** @type {Proxy?} */
let flattenedProxyRule;
try {
flattenedProxyRule = flattenInheritance(proxy, PROXIES);
} catch (ex) {
error(`Error inheriting proxy rules for ${url}: ${ex}`);
return false;
}
if (!!flattenedProxyRule) {
return flattenedProxyRule;
}
return null;
}
/**
* Returns whether link URL is supported by a listed proxy and not explicitly excluded
*
* @param {URL} url
* @param {string} text
* @returns {string|null} Reason if link is invalid, null if valid
*/
function getExclusionReasonForLink(url, text) {
// Exit if this URL is excluded by path or innertext, or by the current page location
// Look for exclusion rules on both this host and inherited, if applicable
/** @type {Exclusion?} */
const exclusion = getByHost(EXCLUSIONS, url.host, true);
/** @type {Exclusion?} */
let flattenedExclusionRule;
try {
flattenedExclusionRule = flattenInheritance(exclusion, EXCLUSIONS);
} catch (ex) {
// Likely inheriting rule that is unexpectedly undefined, probably due to incorrect or nested inheritance
warn(`Error inheriting exclusion rules for ${url}: ${ex}`);
return 'Error inheriting exclusion rule';
}
// With the final exclusion rule set, check if the link url is excluded
if (!!flattenedExclusionRule) {
const excludedReason = isLinkExcludedByRule(url, text, flattenedExclusionRule);
if (!!excludedReason) {
return excludedReason;
}
}
return null;
}
/**
* Flatten generic object upwards with inherited data
*
* Only goes one level deep, both for inheritance and nested inheritors
* @typedef {{
* [keys: string]: Inheritor,
* inherit?: string
* }} Inheritor
*
* @param {Inheritor} source
* @param {Object<string, Inheritor>} dictionary
* @returns {Inheritor?}
* @throws {Error} Exception on copying with Object.assign()
*/
function flattenInheritance(source, dictionary) {
// Avoid destructive shallow copies on `source` or other objects
let root = source;
if (!root || !(typeof root === 'object')) return null;
const rootInheritance = dictionary[root.inherit];
if (!!rootInheritance && typeof rootInheritance === 'object') {
// Merge two rules into one
root = Object.assign({}, rootInheritance, root);
}
// For the final state of inherited nested objects that also inherit,
// flatten their inheritance as well (non-recursive)
for (const key of Object.keys(root)) {
const branch = root[key];
if (branch?.inherit && dictionary[branch.inherit]) {
// if inherited object also includes corresponding nested data, merge to
const branchInheritance = dictionary[branch.inherit]?.[key];
if (!!branchInheritance && typeof branchInheritance === 'object') {
root[key] = Object.assign({}, branchInheritance, branch);
}
}
}
// Warn if it appears that recursive inheritance is configured
// Inheritance beyond the first level of root and property data is not supported
if (!!rootInheritance?.inherit) {
warn(`Multi depth recursion hit ${rootInheritance.inherit} but is not supported`);
}
return root;
}
/**
* Validate proxy rules have enough instance or Farside rules to proxify a link
*
* @param {Proxy} proxy
* @returns {boolean} true if valid, false otherwise
*/
function isProxyValid(proxy) {
if (!proxy) return false;
const isInstanceRedirectValid =
!!proxy.redirect && proxy.redirect.replacements && proxy.redirect.replacements.length > 0;
const isFarsideRedirectValid =
!!proxy.redirectToFarside &&
proxy.redirectToFarside.replacements &&
proxy.redirectToFarside.replacements.length > 0;
if (!isInstanceRedirectValid && !isFarsideRedirectValid) {
// neither instance or Farside rules fully defined
return false;
}
return true;
}
/**
* Handle keydown and keyup event to set modifiers on proxified links
* @param {KeyboardEvent} e
* @returns {void}
*/
function handleModifierKey(e) {
if (e.type !== 'keyup' && e.type !== 'keydown') {
error('Invalid modifier key event');
return;
}
let isDomChanged = false;
toggleModifierKeyState(e, KEY_MODIFIED, CLASS_BODY_FARSIDE) && (isDomChanged = true);
toggleModifierKeyState(e, KEY_BONAFIDE, CLASS_BODY_BONAFIDE) && (isDomChanged = true);
toggleModifierKeyState(e, KEY_PROXIBITE, CLASS_BODY_BITE) && (isDomChanged = true);
toggleModifierKeyState(e, KEY_FRAMEBITE, CLASS_BODY_FRAMEBITE) && (isDomChanged = true);
// If DOM state changed, trigger an render update on proxified elements
if (isDomChanged) {
setTimeout(() => {
updateAllProxifiedAnchors(true);
}, 0);
}
}
/**
* Handle multi-touch gesture event to set modifiers on proxified links, for touch-only devices
* @param {TouchEvent} e
* @returns {void}
*/
function handleModifierGesture(e) {
if (!(e instanceof TouchEvent)) {
error('Invalid modifier touch gesture event');
return;
}
// only support multi-touch to trigger/reset modifiers
if (e.touches.length <= 1) {
return;
}
let isDomChanged = false;
toggleModifierGestureState(e, TOUCH_MODIFIED, CLASS_BODY_FARSIDE) && (isDomChanged = true);
toggleModifierGestureState(e, TOUCH_BONAFIDE, CLASS_BODY_BONAFIDE) && (isDomChanged = true);
toggleModifierGestureState(e, TOUCH_PROXIBITE, CLASS_BODY_BITE, true) && (isDomChanged = true);
toggleModifierGestureState(e, TOUCH_FRAMEBITE, CLASS_BODY_FRAMEBITE, true) && (isDomChanged = true);
// If DOM state changed, trigger an render update on proxified elements
if (isDomChanged) {
setTimeout(() => {
updateAllProxifiedAnchors(true);
}, 0);
}
}
/**
* Toggle a page-wide class by checking KeyboardEvent matches the specified toggleKey
*
* @param {KeyboardEvent} e
* @param {string} toggleKey
* @param {string} toggleClass Classname to toggle in DOM
* @returns {boolean} True if changed, false if no change to DOM
*/
function toggleModifierKeyState(e, toggleKey, toggleClass) {
if (!(e instanceof KeyboardEvent)) {
error('Event is not KeyboardEvent');
return;
}
if (e.type !== 'keyup' && e.type !== 'keydown') {
error('Invalid modifier key event');
return false;
}
if (e.key === toggleKey) {
const setModifierOn = e.type === 'keydown';
const originalState = document.body.classList.contains(toggleClass);
if (setModifierOn) {
if (!originalState) {
document.body.classList.add(toggleClass);
return true;
}
} else {
if (originalState) {
document.body.classList.remove(toggleClass);
return true;
}
}
}
return false;
}
/**
* Toggle a page-wide class by checking TouchEvent matches the specified touch gesture
*
* @param {ToggleEvent} e
* @param {number} toggleGesture Simple gesture based on number of fingers
* @param {string} toggleClass Classname to toggle in DOM
* @param {boolean} isManualOff Optional setting to require manually repeating gesture to toggle off
* @returns {boolean} True if changed, false if no change to DOM
*/
function toggleModifierGestureState(e, toggleGesture, toggleClass, isManualOff = false) {
if (!(e instanceof TouchEvent)) {
error('Event is not TouchEvent');
return;
}
// detect gesture
// only simple touch count gesture is supported currently
const gestureTouchCount = toggleGesture;
const isGestured = gestureTouchCount !== TOUCH_PROXIFIED && e.touches.length === gestureTouchCount;
// handle gesture to toggle modifier
const originalState = document.body.classList.contains(toggleClass);
if (isGestured) {
if (!originalState) {
document.body.classList.add(toggleClass);
return true;
} else if (isManualOff) {
// gesture off, if supported by this gesture
document.body.classList.remove(toggleClass);
return true;
}
} else if (!isManualOff) {
// automatic off, if supported by this gesture
if (originalState) {
document.body.classList.remove(toggleClass);
return true;
}
}
return false;
}
/**
* Rerender proxified changes to the specified link element
*
* @param {HTMLElement} el
* @param {string} destinationAttr Element link destination attribute name
* @returns {void}
*/
function updateProxifiedElement(el, destinationAttr) {
if (!(el instanceof HTMLElement)) {
error(`${el} is not an HTMLElement`);
return;
}
if (el.hasAttribute(ATTR_PROXIFIED_DENIED)) {
return; // element has been proxified but denied
}
if (!el[destinationAttr]) {
error(`Invalid attribute ${destinationAttr}`);
return;
}
if (!isElementProxified(el)) {
error(`Anchor [${destinationAttr}=${el[destinationAttr]}] is not proxified`);
return;
}
// Check DOM modifier state
// Instead of using internal state, follow what the rendered DOM has
const isBonafide = document.body.classList.contains(CLASS_BODY_BONAFIDE);
// attempt to use Farside automatically if direct site is not found
const isFarside = document.body.classList.contains(CLASS_BODY_FARSIDE) || !el.hasAttribute(ATTR_PROXIFIED_SITE);
// Reset active states before re-applying as necessary
el.removeAttribute(ATTR_IS_FARSIDE);
el.classList.remove(CLASS_PROXIFIED_LIVE);
// Update link destination
// The original, bonafide link takes precedence
if (isBonafide && el.hasAttribute(ATTR_BONAFIDE_SITE)) {
if (el[destinationAttr] !== el.getAttribute(ATTR_BONAFIDE_SITE))
el[destinationAttr] = el.getAttribute(ATTR_BONAFIDE_SITE);
} else if (isFarside && el.hasAttribute(ATTR_PROXIFIED_FARSIDE)) {
if (el[destinationAttr] !== el.getAttribute(ATTR_PROXIFIED_FARSIDE))
el[destinationAttr] = el.getAttribute(ATTR_PROXIFIED_FARSIDE);
el.setAttribute(ATTR_IS_FARSIDE, true);
el.classList.add(CLASS_PROXIFIED_LIVE);
} else if (el.hasAttribute(ATTR_PROXIFIED_SITE)) {
if (el[destinationAttr] !== el.getAttribute(ATTR_PROXIFIED_SITE))
el[destinationAttr] = el.getAttribute(ATTR_PROXIFIED_SITE);
el.classList.add(CLASS_PROXIFIED_LIVE);
}
}
/**
* Rerender state to all proxified anchor elements in the document
*
* @param {boolean} doHoveredOnly Only render proxified elements that are being hovered
* @returns {void}
*/
function updateAllProxifiedAnchors(doHoveredOnly) {
const proxifiedEls = document.querySelectorAll(
`a.${CLASS_PROXIFIED}${doHoveredOnly ? ':is(:hover, :focus-within)' : ''}`
);
for (const proxifiedEl of proxifiedEls) {
updateProxifiedElement(proxifiedEl, 'href');
}
}
/**
* Add proxified link styles to indicate functionality and readiness
*
* Highlight proxied links yellow
* Highlight Farside-redirected links green
*/
// TODO: Manually apply inline element styles to avoid style-src CSP
const isHoverOnly = true; // toggle styling on when hovering on link
const _important = true ? '!important' : ''; // toggle overriding page styles as much as possible
// Style anchor element and children under anchor element
const selectorAnchor = isHoverOnly
? `a.${CLASS_PROXIFIED_LIVE}:is(:hover, :focus-within)`
: `a.${CLASS_PROXIFIED_LIVE}`;
// add additional styles at higher specificity to child containers
const selectorChildren = ':is(p, span, h1, h2, h3, h4, h5, h6, label, div, button)';
addPageStyle(
`${selectorAnchor} { \
color: black ${_important}; \
background-color: yellow ${_important}; \
text-shadow: none ${_important}; \
\
font-style: oblique ${_important}; \
font-weight: bold ${_important}; \
\
${selectorChildren} { \
color: yellow ${_important}; \
background-color: black ${_important}; \
text-shadow: none ${_important}; \
\
font-style: oblique ${_important}; \
font-weight: bold ${_important}; \
} \
:is(img) { \
filter: sepia(1) hue-rotate(20deg) contrast(1.25) brightness(1.25); ${_important}; \
}\
}`
);
// Add optional highlighter padding at lowest specificity :where()
addPageStyle(
`:where(${selectorAnchor}) { \
// padding: 0 0.3em; \
\
${selectorChildren} { \
padding: 0 0.3em; \
} \
}`
);
// Change colors when links redirect through Farside
addPageStyle(
`${selectorAnchor}[${ATTR_IS_FARSIDE}="true"] { \
color: yellowgreen ${_important}; \
background-color: black ${_important}; \
\
${selectorChildren} { \
color: black ${_important}; \
background-color: yellowgreen ${_important}; \
} \
:is(img) { \
filter: invert(1) sepia(1) hue-rotate(45deg) contrast(1.25) brightness(1.25) ${_important}; \
} \
}`
);
// Highlight all iframes when frame bite is enabled
addPageStyle(
`body.proxi-bite.proxi-framebite { \
iframe { \
border: yellow solid 0.3em ${_important}; \
} \
iframe[${ATTR_IS_FARSIDE}="true"] { \
border-color: yellowgreen ${_important}; \
} \
}`
);
/**
* Insert a page-level style
* Adds a document stylesheet to write to when needed
*
* @param {string} css
* @returns {void}
*/
function addPageStyle(css) {
const style =
document.getElementById(STYLE_HIGHLIGHT_ID) ||
(function () {
const style = document.createElement('style');
style.id = STYLE_HIGHLIGHT_ID;
document.head.appendChild(style);
return style;
})();
const sheet = style.sheet;
try {
sheet.insertRule(css, (sheet.rules || sheet.cssRules || []).length);
} catch (ex) {
// Likely stylesheet is null from failing to add to DOM
// Continue on, even without styling
warn(`Failed to apply style: ${ex}`);
}
}
/**
* Check given link url and text against provided exclusion rule
*
* @param {URL} url
* @param {string} text
* @param {Exclusion} rule
* @returns {string?} First found exclusion reason, null if false
*/
function isLinkExcludedByRule(url, text, rule) {
if (!rule) {
error(`Invalid flattened exclusion rule for ${url}`);
return 'Invalid flatted exclusion rule';
}
// Return reason if excluding the current page that this link is on
// Check first to short circuit link processing on pages proxifying is excluded
// Results are memoized by page after being processed once, manually invalidate `_excludedOn` if needed
const currentDirection = `${window.location.href}=>${url.host}`;
if (!!_excludedOn[currentDirection]) {
return _excludedOn[currentDirection];
}
if (!!rule.excludeOn && _excludedOn[currentDirection] === undefined) {
/** @type {URL | null} */
let currentUrl = null;
try {
currentUrl = new URL(currentDirection);
} catch (ex) {
// unexpected failure on parsing current URL, error and continue
error(`Error parsing page URL: ${ex}`);
}
if (!!rule.excludeOn.matchingPath) {
for (const m of rule.excludeOn.matchingPath) {
if (!!currentUrl.pathname.match(m)) {
_excludedOn[currentDirection] = `excludeOn.matchingPath[${m}]`;
}
}
}
if (!!rule.excludeOn.matchingHost) {
for (const m of rule.excludeOn.matchingHost) {
if (!!currentUrl.host.match(m)) {
_excludedOn[currentDirection] = `excludeOn.matchingHost[${m}]`;
}
}
}
if (!!rule.excludeOn.matchingUrl) {
for (const m of rule.excludeOn.matchingUrl) {
if (!!currentUrl.href.match(m)) {
_excludedOn[currentDirection] = `excludeOn.matchingUrl[${m}]`;
}
}
}
if (!!rule.excludeOn.matchingBody) {
for (const m of rule.excludeOn.matchingBody) {
if (document.getElementsByTagName('body')[0].innerHTML.match(m)) {
_excludedOn[currentDirection] = `excludeOn.matchingBody[${m}]`;
}
}
}
if (!!rule.excludeOn.matchingHead) {
// clean before searching
// if the userscript extension mounts to head, may false positive on the userscript itself
if (_documentHeadCleaned === undefined) {
const documentHeadTemp = document.createElement('head');
documentHeadTemp.innerHTML = document.getElementsByTagName('head')[0]?.innerHTML;
let s;
for (const scriptNodes = documentHeadTemp.getElementsByTagName('script'); (s = scriptNodes[0]); ) {
// cut down scriptNodes queue
s.parentNode.removeChild(s);
}
_documentHeadCleaned = documentHeadTemp.innerHTML;
}
for (const m of rule.excludeOn.matchingHead) {
if (_documentHeadCleaned.match(m)) {
_excludedOn[currentDirection] = `excludeOn.matchingHead[${m}]`;
}
}
}
// process this page url once even if no matches found for this page
// return any non-nullish match found, or continue to find other link exclusions
if (_excludedOn[currentDirection] === undefined) {
_excludedOn[currentDirection] = null;
} else if (!!_excludedOn[currentDirection]) {
return _excludedOn[currentDirection];
}
}
// Return reason if excluding this link
if (!!rule.excludeLinks) {
if (!!rule.excludeLinks.matchingPath) {
for (const m of rule.excludeLinks.matchingPath) {
if (!!url.pathname.match(m)) return `excludeLinks.matchingPath[${m}]`;
}
}
if (!!rule.excludeLinks.matchingHost) {
for (const m of rule.excludeLinks.matchingHost) {
if (!!url.host.match(m)) return `excludeLinks.matchingHost[${m}]`;
}
}
if (!!rule.excludeLinks.matchingUrl) {
for (const m of rule.excludeLinks.matchingUrl) {
if (!!url.href.match(m)) return `excludeLinks.matchingUrl[${m}]`;
}
}
if (!!rule.excludeLinks.matchingText) {
for (const m of rule.excludeLinks.matchingText) {
if (!!text.match(m)) return `excludeLinks.matchingText[${m}]`;
}
}
}
}
const _excludedOn = {}; // memoized excludeOn results
let _documentHeadCleaned; // memoized cleaned <head>
/**
* Check if element has been fully processed already
* @param {HTMLElement} el
* @returns {boolean}
*/
function isElementProxified(el) {
return (
el?.hasAttribute(ATTR_PROXIFIED_SITE) ||
el?.hasAttribute(ATTR_PROXIFIED_FARSIDE) ||
el?.hasAttribute(ATTR_PROXIFIED_DENIED)
);
}
/**
* Returns the dictionary entry for the specified host, agnostic to WWW
* Take explicit host match if found, but optionally try searching for SLD+TLD only (strip subdomains)
*
* @param {Object<string, T?>} dictionary
* @param {string} host
* @param {boolean} allowFuzzy
* @returns {T?}
*/
function getByHost(dictionary, host, allowFuzzy) {
if (typeof dictionary !== 'object') {
error('Invalid dictionary used to look up host');
return null;
}
// Find the explicit entry for the host key
const explicit = dictionary[host];
if (!!explicit) {
return explicit;
}
// Strip WWW and try again
const agnosticWWW = dictionary[host.replace(/^www\./, '')];
if (!!agnosticWWW) {
return agnosticWWW;
}
// Non-WWW subdomains in hostname may be causing misses
// Too many TLDs to handle easily, so allow fuzzy matching on SLD+TLD if enabled
// Search for dictionary keys matching to the end of host and try the first hit
if (allowFuzzy) {
// try any dictionary key that has opted into fuzzy matching
const candidateHostKey = Object.keys(dictionary)
.filter(hostKey => !!dictionary[hostKey].allowFuzzy)
.find(hostKey => {
// try to fit the key to the end of the matching host
const SLDTLDRegex = new RegExp(escapeRegExp(hostKey) + '$');
return SLDTLDRegex.test(host);
});
const agnosticSubdomain = dictionary[candidateHostKey];
if (!!agnosticSubdomain) {
return agnosticSubdomain;
}
}
return null;
}
/**
* Escape regex special characters in a string
* TC39 X-standard
*
* @param {string} string
* @returns {string}
*/
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Print to console as error
*
* @param {...any} data
*/
function error(...data) {
if (DEBUG) {
console.error(...data);
}
}
/**
* Print to console as warning
*
* @param {...any} data
*/
function warn(...data) {
if (DEBUG) {
console.warn(...data);
}
}