// ==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);
}
})();