// ==UserScript== // @name 水源解码 // @namespace CCCC_David // @version 0.1.1 // @description 可在水源论坛 base64 解码选中内容 // @author CCCC_David // @match https://shuiyuan.sjtu.edu.cn/* // @grant none // @license MIT // @downloadURL none // ==/UserScript== (() => { 'use strict'; // From Font Awesome Free v5.15 by @fontawesome - https://fontawesome.com // License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) // Modified class attribute to fit in. const DECODE_ICON = ''; // Parameters. const APPEND_DECODE_BUTTON_TARGET_CLASS = 'buttons'; // Utility functions. const escapeRegExpOutsideCharacterClass = (s) => s.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); const allowedPolicy = window.trustedTypes?.createPolicy?.('allowedPolicy', {createHTML: (x) => x}); const createTrustedHTML = (html) => (allowedPolicy ? allowedPolicy.createHTML(html) : html); const utf8Decoder = new TextDecoder('utf-8', {fatal: true}); const htmlParser = new DOMParser(); const isBinaryString = (s) => s.split('').every((c) => c.charCodeAt(0) < 256); const decodeUTF8BinaryString = (s) => { // Assuming input is binary string. const byteArray = new Uint8Array(s.split('').map((c) => c.charCodeAt(0))); try { return utf8Decoder.decode(byteArray); } catch { return null; } }; const decodeBase64AndURI = (data) => { let result = data, prevResult = data; // eslint-disable-next-line no-constant-condition while (true) { const tempResult = result; try { result = atob(result); } catch { break; } prevResult = tempResult; if (result === prevResult) { break; } } if (isBinaryString(result)) { result = decodeUTF8BinaryString(result) ?? prevResult; } try { result = decodeURIComponent(result); } catch { } return result; }; const lookupShortURLs = async (shortURLs) => { if (!shortURLs) { return new Map(); } try { const response = await fetch('/uploads/lookup-urls', { method: 'POST', body: shortURLs.map((url) => `short_urls%5B%5D=${encodeURIComponent(url)}`).join('&'), headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'Discourse-Present': 'true', 'Discourse-Logged-In': 'true', 'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').content, }, mode: 'same-origin', credentials: 'include', }); if (!response.ok) { // eslint-disable-next-line no-console console.error(`lookupShortURLs fetch failure: ${response.status}${response.statusText ? ` ${response.statusText}` : ''}`); return new Map(); } const result = await response.json(); return new Map(result.map((item) => [item.short_url, item.url])); } catch (e) { // eslint-disable-next-line no-console console.error(e); return new Map(); } }; const renderContent = async (content) => { // First cook the content. // eslint-disable-next-line no-undef const cookedContent = await require('discourse/lib/text').cookAsync(content); let tree; try { tree = htmlParser.parseFromString(cookedContent, 'text/html'); } catch (e) { // eslint-disable-next-line no-console console.error(e); return '(Parse error)'; } // Extract all short URLs and look up in batch. const shortURLs = []; for (const el of tree.querySelectorAll('img[data-orig-src], source[data-orig-src]')) { shortURLs.push(el.getAttribute('data-orig-src')); } for (const el of tree.querySelectorAll('a[data-orig-href]')) { shortURLs.push(el.getAttribute('data-orig-href')); } const shortURLMapping = await lookupShortURLs(shortURLs); // Replace short URLs with real URLs. for (const el of tree.querySelectorAll('img[data-orig-src], source[data-orig-src]')) { const src = el.getAttribute('data-orig-src'); if (shortURLMapping.has(src)) { el.src = shortURLMapping.get(src); el.removeAttribute('data-orig-src'); } } for (const el of tree.querySelectorAll('a[data-orig-href]')) { const href = el.getAttribute('data-orig-href'); if (shortURLMapping.has(href)) { el.href = shortURLMapping.get(href); el.removeAttribute('data-orig-href'); } } return tree.body.innerHTML; }; const convertSelection = async () => { const selection = window.getSelection(); const selectionString = selection.toString(); const {anchorNode, focusNode} = selection; if (!selectionString || !anchorNode || !focusNode) { return; } let targetNode; if (anchorNode === focusNode) { targetNode = anchorNode; } else if (anchorNode.contains(focusNode)) { targetNode = focusNode; } else if (focusNode.contains(anchorNode)) { targetNode = anchorNode; } else { targetNode = focusNode; } if (targetNode.outerHTML === undefined) { targetNode = targetNode.parentNode; } targetNode.outerHTML = createTrustedHTML(await renderContent(decodeBase64AndURI(selectionString))); selection.removeAllRanges(); }; const addDecodeButton = (quoteButtonContainer) => { if (quoteButtonContainer?.nodeName.toLowerCase() !== 'div' || document.getElementById('decode-selection-button')) { return; } const decodeButtonContainer = document.createElement('span'); decodeButtonContainer.innerHTML = createTrustedHTML(` `); quoteButtonContainer.appendChild(decodeButtonContainer); document.getElementById('decode-selection-button').addEventListener('click', convertSelection); }; const observer = new MutationObserver((mutationsList) => { for (const mutation of mutationsList) { if (mutation.type === 'childList') { for (const el of mutation.addedNodes) { if (el.classList?.contains(APPEND_DECODE_BUTTON_TARGET_CLASS)) { addDecodeButton(el); } } } else if (mutation.type === 'attributes') { if (!mutation.oldValue?.match(new RegExp(`(?:^|\\s)${escapeRegExpOutsideCharacterClass(APPEND_DECODE_BUTTON_TARGET_CLASS)}(?:\\s|$)`, 'u')) && mutation.target.classList?.contains(APPEND_DECODE_BUTTON_TARGET_CLASS)) { addDecodeButton(mutation.target); } } } }); observer.observe(document.documentElement, { subtree: true, childList: true, attributeFilter: ['class'], attributeOldValue: true, }); for (const el of document.getElementsByClassName(APPEND_DECODE_BUTTON_TARGET_CLASS)) { addDecodeButton(el); } })();