Proxified hyperlinks to a proxy instance or Farside with no nonsense
// ==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; see and configure non-proxying frontends yourself before opening links // - Use a small hardcoded list of favorite proxy instances, or: // - Hold X key to use Farside redirection where possible // - Hold Z key to use the original link whenever needed // - Easily use first-party frontends with discretion or when instances are down // - 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 // - Use GET requests on supported search engines particularly with noscript // (Recommend disabled if `ENABLE_REFERER_HIDE` is off) // @run-at document-end // @version 0.3.1 // @match *://*/* // @icon data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgOTAwMCA5MDAwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbDpzcGFjZT0icHJlc2VydmUiPjxyZWN0IHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbGw9InllbGxvdyIvPjxwYXRoIGQ9Ik0uMjc5LS4xNDYuMTUgMGgtLjE3MmwuMjQtLjI2Ni0uMTMyLS4yNTNoLjE0OWwuMDc2LjE0OC4xMzEtLjE0OGguMTdsLS4yNC4yNjkuMTMuMjVILjM1NEwuMjc5LS4xNDZaIiBzdHlsZT0iZmlsbDp3aGl0ZTsiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDE2NzUgNzcyOCkgc2NhbGUoODAwMCkiLz48cGF0aCBkPSJNLjE4OCAwSC4wNDFMLjE5LS43MTZoLjI5MWEuMzQuMzQgMCAwIDEgLjEyMy4wMThjLjAzLjAxMy4wNTQuMDMzLjA3Mi4wNjFhLjE5Mi4xOTIgMCAwIDEgLjAyNi4xMDEuMjczLjI3MyAwIDAgMS0uMDIxLjEwNS4yNC4yNCAwIDAgMS0uMDUxLjA4Mi4yMzcuMjM3IDAgMCAxLS4xNjEuMDcxIDEuMTY2IDEuMTY2IDAgMCAxLS4xMy4wMDVILjI0NUwuMTg4IDBaTS4yNy0uMzkyaC4wNDVhLjUzLjUzIDAgMCAwIC4xNTYtLjAxNC4xMzEuMTMxIDAgMCAwIC4wNjEtLjA0Ny4xMjMuMTIzIDAgMCAwIC4wMjMtLjA3MS4wNy4wNyAwIDAgMC0uMDEyLS4wNDIuMDYyLjA2MiAwIDAgMC0uMDMyLS4wMjUuMzU4LjM1OCAwIDAgMC0uMDkzLS4wMDdILjMxM0wuMjctLjM5MloiIHRyYW5zZm9ybT0ibWF0cml4KDkzMjAgMCAwIDkzMTMgMTEyOCA3NzI4KSIvPjxwYXRoIGQ9Im0yNTEuNjg0IDY1NS45ODkuMTYyLjIzIDUyMi40ODYgMTczLjI5Mi0xMjEuMjk4LTE3My4zNTItLjUwMy0uMTdIMjUxLjY4NFptMTM0OS40NzYgMzE4LjYxLTI4My45OS05NS4wMTZjLTY0LjE5LS45NjItMTQ4LjMyLTEuNDQzLTI1Mi4zOS0xLjQ0M0g4MDguMzU5bC0xNy43MjMtMjUuMzI4LTIxMi4wNTQgMTIxLjc4N2gzNDYuMzg0bDEwMi4wODQtNTkuNzM3IDE3Ny4xNiA1OS43###oMzk2Ljk1WiIgc3R5bGU9ImZpbGw6eWVsbG93Z3JlZW4iIHRyYW5zZm9ybT0ibWF0cml4KDMuNDMgMCAwIC0xMS40NCA2NDIgMTUyMzUuNikiLz48cGF0aCBkPSJNMTUwMCA4MTIwaDU4MDB2MzYwSDE3NTB6Ii8+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 const ROTATE_SITE_PROXIFIED = true; // Optional: Rotate proxy instances instead of random to prevent adjacent duplicates const ENABLE_RERENDER = true; // Optional: Forcibly rerender for browser to update UI, may degrade performance // 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 4-finger tap gesture to allow all heavier elements to be proxified const TOUCH_FRAMEBITE = 4; // TODO: support multiple keys to whitelist specific keys that will trigger handlers, consider screenreader controls 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 links work but bona fide (Z) links are breaking const ENABLE_REFERER_HIDE = true; // Optional noreferer override on all links on the page, if ENABLE_REFERER_HIDE is also on // Disable if unrelated 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 const ENABLE_QUERY_PROXIFIED_OFF = ['piurl' /* Startpage image proxy */]; // blacklisted query parameters // 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 {{ [host: string]: {attributes: string[], allowFuzzy?: boolean} }} */ const ENABLE_ATTRIBUTES_SMITE_ON_SITE = { 'google.com': { attributes: ['data-sb'] } }; // Optional search engine GET requests instead of POST for improved search navigation // Used with ENABLE_REFERER_HIDE and ENABLE_REFERER_HIDE_PAGEWIDE on // CAUTION: May be unsupported, and subject to negligent server logging query string const ENABLE_SEARCH_GET = true; /** @type {{ [host: string]: { formSelector: string } }} */ const ENABLE_SEARCH_GET_ON_SITE = { 'html.duckduckgo.com': { formSelector: 'form:has(#search_form_input_homepage), form:has(.btn[type="submit"])' }, 'startpage.com': { formSelector: '#search, form:has(.header-nav-item), form:has(button[type="submit"]' }, }; /** * 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 {{ [host: 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', // down 'https://piped.privacydev.net', //'https://piped.lunar.icu', // embedded frame blocked by x-frame-options 'https://piped.adminforge.de', //'https://pd.vern.cc', // low availability // The following Invidious instances not only allow video proxy but proxy by default //'https://iv.datura.network', // proxy off by default //'https://invidious.projectsegfau.lt', // down //'https://invidious.fdn.fr', // down ], }, 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', // low availability 'https://l.opnxng.com', //'https://reddit.invak.id', // down 'https://libreddit.kavin.rocks', 'https://red.artemislena.eu', ], }, 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', }, }, // twitter.com, t.co, pbs.twimg.com, x.com 'twitter.com': { redirect: { replacements: ['https://nitter.poast.org', 'https://nitter.privacydev.net'], }, redirectToFarside: { replacements: ['https://farside.link/nitter'], }, }, 't.co': { redirect: { inherit: 'twitter.com', suffix: '/t.co', // suffix /t.co on hostname before shortlink path }, redirectToFarside: { inherit: 'twitter.com', suffix: '/t.co', }, }, 'pbs.twimg.com': { redirect: { inherit: 'twitter.com', routes: [ { regex: /^https?:\/\/(www\.)?pbs\.twimg\.com\/media\/([A-Za-z0-9]+)\?.*?format=([a-z]+).*/, suffix: '/pic/orig/media/$2.$3', // suffix /pic/orig/media/{img}.{ext} on hostname }, ], }, redirectToFarside: { inherit: 'twitter.com', routes: [ { regex: /^https?:\/\/(www\.)?pbs\.twimg\.com\/media\/([A-Za-z0-9]+)\?.*?format=([a-z]+).*/, suffix: '/pic/orig/media/$2.$3', }, ], }, }, 'x.com': { inherit: 'twitter.com' }, // stackoverflow.com, {subdomain}.stackexchange.com // superuser.com, serverfault.com, and other stack sites pending AnonymousOverflow support 'stackoverflow.com': { redirect: { replacements: [ // 'https://ao.vern.cc', // low availability 'https://overflow.smnz.de', //'https://overflow.lunar.icu', // TODO: reenable, temp disable old version incompat w/ non-stackexchange sites '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/', }, ], }, }, // non-stackexchange domains are appended to route /exchange/{uri} 'superuser.com': { redirect: { inherit: 'stackoverflow.com', routes: [ { regex: /^https?:\/\/(www\.)?(.*)/g, // take entire URI in group 2 suffix: '/exchange/$2', }, ], }, redirectToFarSide: { inherit: 'stackoverflow.com', routes: [ { regex: /^https?:\/\/(www\.)?(.*)/g, // take entire URI in group 2 suffix: '/exchange/$2', }, ], }, }, 'serverfault.com': { inherit: 'superuser.com' }, 'askubuntu.com': { inherit: 'superuser.com' }, 'stackapps.com': { inherit: 'superuser.com' }, /** * * Below are less mature or partially featured services * */ // 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', // low availability // 'https://rimgo.hostux.net', // down // 'https://rimgo.lunar.icu', // down 'https://rimgo.eu.projectsegfau.lt', ], }, 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', // low availability '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://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 ### // 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 {{ [host: 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/], }, }, // twitter.com, x.com 'twitter.com': { excludeOn: { matchingUrl: [/^https?:\/\/(www\.)?twitter\.com/, /^https?:\/\/(www\.)?x\.com/], }, }, 't.co': { inherit: 'twitter.com' }, 'pbs.img.com': { inherit: 'twitter.com' }, 'x.com': { inherit: 'twitter.com' }, // stackoverflow.com, {subdomain}.stackexchange.com, superuser.com, etc. 'stackoverflow.com': { excludeLinks: { matchingPath: ['/questions/tagged/', '/users/'], }, excludeOn: { matchingHost: [ 'stackoverflow.com', 'stackexchange.com', 'superuser.com', 'serverfault.com', 'askubuntu.com', 'stackapps.com', ], }, }, '.stackexchange.com': { allowFuzzy: true, inherit: 'stackoverflow.com', }, 'superuser.com': { inherit: 'stackoverflow.com' }, 'serverfault.com': { inherit: 'stackoverflow.com' }, 'askubuntu.com': { inherit: 'stackoverflow.com' }, 'stackapps.com': { 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 {{ [host: string]: { nodeNames: string[], steps: number } }} */ const PARENT_LOOKUP_ON_SITE = { // google.com mobile search r###lts div // TODO: improve mobile search inline media touch events swallowed 'google.com': { nodeNames: ['DIV'], steps: 4 }, // duckduckgo.com search r###lts span, hero summary SVGs and title container div 'duckduckgo.com': { nodeNames: ['SPAN', 'DIV', 'PATH', 'RECT'], steps: 4 }, // startpage.com search r###lts inline images/videos 'startpage.com': { nodeNames: ['DIV'], 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 /** @type {{ [host: string]: [tags: string[]] }} */ 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 /** * Automatic page onload and DOM modifications */ window.addEventListener('load', () => { if (ENABLE_SEARCH_GET) { let searchEngine = getByHost(ENABLE_SEARCH_GET_ON_SITE, window.location.host, false); if (!!searchEngine && !!searchEngine.formSelector) { const searchEngineFormEls = document.querySelectorAll(searchEngine.formSelector); for (let formEl of searchEngineFormEls) { formEl.method = 'get'; } } } }); /** * 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 including non-proxied links if (ENABLE_REFERER_HIDE && 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 // R###lting 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) { proxifyDenyElement(el, 'Invalid, empty, or undefined destination attribute'); return null; } if (!REGEX_URL.test(destination)) { proxifyDenyElement(el, 'Hyperlink destination is not a valid absolute URL'); return null; } // Clean up any proxify attrs before proxifying el.removeAttribute(ATTR_PROXIFIED_SITE); el.removeAttribute(ATTR_PROXIFIED_FARSIDE); el.removeAttribute(ATTR_PROXIFIED_DENIED); el.removeAttribute(ATTR_BONAFIDE_SITE); // 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}`); proxifyDenyElement(el, '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))) { proxifyDenyElement(el, 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) { // skip blacklisted query params if (ENABLE_QUERY_PROXIFIED_OFF.includes(paramKey)) { continue; } // if query param whitelist is empty, allow all 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) { proxifyDenyElement(el, '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 instanc###ffix = proxy.redirect.suffix; const instanceRoutes = proxy.redirect.routes; const urlInstanceProxified = getProxifiedUrl(url, instanceList, instanc###ffix, instanceRoutes, 'proxy'); 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 farsid###ffix = proxy.redirectToFarside.suffix; const farsideRoutes = proxy.redirectToFarside.routes; const urlFarsideProxified = getProxifiedUrl(url, farsideList, farsid###ffix, farsideRoutes, 'farside'); 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 proxifyDenyElement(el, proxifyDenied); } else { // Log unexpected failure past validated inputs error(`Failure proxifying ${url}`); } } /** * Process but mark the link element as being invalid * * @param {HTMLAnchorElement | HTMLIFrameElement} el * @param {string} reason */ function proxifyDenyElement(el, reason) { if (!(el instanceof HTMLAnchorElement) && !(el instanceof HTMLIFrameElement)) { error(`${el} is not a supported element type`); return; } // Set denial reason as attr value el.setAttribute(ATTR_PROXIFIED_DENIED, reason); // Bonafide link is still stored for posterity el.setAttribute(ATTR_BONAFIDE_SITE, getElementDest(el)); } /** * 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 * @param {string} key Optional unique key used for proxifying * @returns {URL | string | null} Proxified URL, string reason if denied, null if failed */ const replacementLast = {}; function getProxifiedUrl(url, replacements, suffix, routes, key = '') { 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) ); // Proxify the url with one of the replacements if (replacementList.length > 0) { // get a random replacement site const hostKey = url.host + key; const replacementIndex = (replacementLast[hostKey] = ROTATE_SITE_PROXIFIED && Object.keys(replacementLast).includes(hostKey) ? (replacementLast[hostKey] + 1) % replacementList.length // rotate proxies sequentially, if enabled : Math.floor(Math.random() * replacementList.length)); const replacementSite = replacementList[replacementIndex]; 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 rout###ffix = replacementRoute.suffix || ''; const hrefProxified = url.href.replace(routeRegex, replacementSite + replacementSuffix + rout###ffix); 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?:\/\/)(.*?)(\/.*)/, replacementSite + 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); } // Optional: Re-render element to encourage browsers to reflect proxified link // TODO: Consider aria-live for screenreaders if (ENABLE_RERENDER) { const isElSelected = document.activeElement == el || el.contains(document.activeElement); const elStyleDisplay = el.style.display; // hide the element and replace with a placeholder clone to prevent 1-frame flash const CLONE_CLASSNAME = 'proxi-tied'; const elClone = el.cloneNode(true); // deep clone to preserve DOM, at cost of perf elClone.classList.add(CLONE_CLASSNAME); elClone.style = 'color: black !important; background: black !important;'; if (!el.parentNode?.querySelector(`.${CLONE_CLASSNAME}`) && elStyleDisplay !== 'none') { el.parentNode?.insertBefore(elClone, el.nextSibling); if (isElSelected) el.blur(); el.style.display = 'none'; setTimeout(() => { // show and select the original element el.style.display = elStyleDisplay; if (isElSelected) el.focus(); if (elClone.parentNode === el.parentNode) el.parentNode?.removeChild(elClone); }, 0); } } } /** * 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 with non-empty content const selectorChildren = ':is(p, span, h1, h2, h3, h4, h5, h6, label, div, button):not(:empty)'; 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 // R###lts 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 r###lts let _documentHeadCleaned; // memoized cleaned <head> /** * Check if element has been fully processed already * @param {HTMLElement} el * @returns {boolean} */ function isElementProxified(el) { // bonafide attr signals completed proxification // successful: all proxified attrs set // denied: denied + bonafide attrs set const elBonafide = el?.getAttribute(ATTR_BONAFIDE_SITE); // proxification is outdated if current link does not match any stored destination // this can occur when the same link el is reused dynamically by the site scripts and must be invalidated const linkDest = getElementDest(el); const isUpToDate = linkDest === elBonafide || linkDest === el?.getAttribute(ATTR_PROXIFIED_SITE) || linkDest === el?.getAttribute(ATTR_PROXIFIED_FARSIDE); return elBonafide && isUpToDate; } /** * Get the link destination * @param {HTMLElement} el * @returns {string?} */ function getElementDest(el) { switch (el.constructor) { case HTMLAnchorElement: return el.href; case HTMLIFrameElement: return el.src; default: return null; } } /** * 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); } }