Guest User

Untitled

a guest
Nov 17th, 2025
366
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 19.82 KB | None | 0 0
  1. // ==UserScript==
  2. // @name Secret Attachment (b4k)
  3. // @namespace http://tampermonkey.net/
  4. // @version 2.0
  5. // @description Detect and decode secret attachments hosted on catbox/fatbox from both OP and reply posts on arch.b4k.dev
  6. // @author anon
  7. // @match https://arch.b4k.dev/*
  8. // @grant GM_xmlhttpRequest
  9. // @connect catbox.moe
  10. // @connect files.catbox.moe
  11. // @connect fatbox.moe
  12. // @connect files.fatbox.moe
  13. // ==/UserScript==
  14.  
  15. /* global BigInt */
  16.  
  17. (async function() {
  18. "use strict";
  19.  
  20. const DEBUG = false; // set true if you want extra console logs
  21.  
  22. // --- compatibility: add Blob/Request/Response.bytes() ---
  23. async function bytes() {
  24. return new Uint8Array(await this.arrayBuffer());
  25. }
  26. for (const C of ['Blob', 'Request', 'Response']) {
  27. const p = globalThis[C]?.prototype;
  28. if (p && !p.bytes) p.bytes = bytes;
  29. }
  30.  
  31. // --- GM_fetch wrapper using GM_xmlhttpRequest ---
  32. function GM_fetch(url, opt = {}) {
  33. function parseHeaders(headerStr) {
  34. const headers = new Headers()
  35. if (!headerStr) return headers;
  36. headerStr.trim().split(/[\r\n]+/).forEach((line) => {
  37. const i = line.indexOf(':');
  38. if (i === -1) return;
  39. const key = line.slice(0, i).trim();
  40. const value = line.slice(i + 1).trim();
  41. if (key) headers.append(key, value)
  42. })
  43. return headers
  44. }
  45.  
  46. return new Promise((resolve, reject) => {
  47. GM_xmlhttpRequest({
  48. url,
  49. method: opt.method || "GET",
  50. data: opt.body,
  51. responseType: "blob",
  52. onload: res => {
  53. resolve(new Response(res.response, {
  54. status: res.status,
  55. headers: parseHeaders(res.responseHeaders)
  56. }))
  57. },
  58. ontimeout: () => reject(new Error("Request timed out")),
  59. onabort: () => reject(new Error("Request aborted")),
  60. onerror: () => reject(new Error("Network error. Catbox might be blocking your connection.")),
  61. onprogress: (event) => {
  62. if (opt.onProgress && event.lengthComputable) {
  63. const percent = Math.round((event.loaded / event.total) * 100);
  64. opt.onProgress(percent, event.loaded, event.total);
  65. }
  66. }
  67. })
  68. })
  69. }
  70.  
  71. // --- tiny DOM helpers ---
  72. function $(tag, ...args) {
  73. let attributes = {}, children = []
  74. if (args.length === 1) {
  75. if (Array.isArray(args[0])) children = args[0]
  76. else attributes = args[0]
  77. } else if (args.length === 2) {
  78. attributes = args[0]
  79. children = args[1]
  80. }
  81. const element = document.createElement(tag)
  82. for (const [name, value] of Object.entries(attributes)) {
  83. if (typeof value === "boolean") {
  84. if (value) element.setAttribute(name, "")
  85. } else element.setAttribute(name, value)
  86. }
  87. if (children && children.length) element.append(...children)
  88. return element
  89. }
  90. function insertAfter(newNode, referenceNode) {
  91. if (!referenceNode || !referenceNode.parentNode) return;
  92. return referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling)
  93. }
  94.  
  95. function bigIntFromBytes(bytes) {
  96. if (!bytes || bytes.length === 0) return 0n;
  97. let n = BigInt(bytes[0])
  98. for (let i = 1; i < bytes.length; ++i) n |= BigInt(bytes[i]) << (BigInt(i) << 3n)
  99. return n
  100. }
  101.  
  102. function encode(input, alphabet, minDigits = 1) {
  103. const length = BigInt(alphabet.length)
  104. let n = bigIntFromBytes(input)
  105. let result = ""
  106. while (n > 0n || result.length < minDigits) {
  107. result = alphabet[Number(n % length)] + result
  108. n /= length
  109. }
  110. return result
  111. }
  112.  
  113. function decode(input, alphabet) {
  114. const length = BigInt(alphabet.length)
  115. let n = 0n
  116. for (let i = 0; i < input.length; i++) n = n * length + BigInt(alphabet.indexOf(input[i]))
  117. const byteCount = n === 0n ? 1 : (n.toString(2).length + 7) >>> 3
  118. const bytes = new Uint8Array(byteCount)
  119. for (let i = 0; i < byteCount; i++) {
  120. bytes[i] = Number(n & 0xFFn)
  121. n = n >> 8n
  122. }
  123. return bytes
  124. }
  125.  
  126. async function sha256(input, length = 32) {
  127. const digest = await crypto.subtle.digest("sha-256", input);
  128. return new Uint8Array(digest, 0, length);
  129. }
  130.  
  131. function arraysEqual(arr1, arr2) {
  132. if (!arr1 || !arr2) return false;
  133. if (arr1.length !== arr2.length) return false
  134. for (let i = 0; i < arr1.length; ++i) if (arr1[i] !== arr2[i]) return false
  135. return true
  136. }
  137.  
  138. function basename(input) {
  139. if (!input) return input;
  140. try {
  141. return input.split("/").pop().split("?")[0].split("#")[0];
  142. } catch (e) { return input; }
  143. }
  144.  
  145. function parse(filename) {
  146. if (!filename) return { name: "", ext: "" }
  147. const lastIndex = filename.lastIndexOf(".")
  148. return lastIndex === -1
  149. ? { name: filename, ext: "" }
  150. : { name: filename.slice(0, lastIndex), ext: filename.slice(lastIndex + 1) }
  151. }
  152.  
  153. const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890"
  154.  
  155. async function embed(key, name) {
  156. const data = new Uint8Array([...key, ...new TextEncoder().encode(name)])
  157. const hash = await sha256(data, 4)
  158. return encode(new Uint8Array([...data, ...hash]), alphabet)
  159. }
  160.  
  161. async function extract(input) {
  162. const name = parse(input).name
  163. if (/^[A-Za-z0-9]+$/.test(name)) {
  164. const bytes = decode(name, alphabet)
  165. if (!bytes || bytes.length <= 4) return null;
  166. const [data, hash] = [bytes.slice(0, -4), bytes.slice(-4)]
  167. if (arraysEqual(await sha256(data, 4), hash)) {
  168. return { key: data.slice(0, 16), name: new TextDecoder().decode(data.slice(16)) }
  169. }
  170. }
  171. return null
  172. }
  173.  
  174. async function encrypt(data, key) {
  175. const cryptoKey = await crypto.subtle.importKey(
  176. "raw",
  177. key,
  178. { name: "AES-CTR" },
  179. false,
  180. ["encrypt"]
  181. )
  182. const cipher = await crypto.subtle.encrypt(
  183. { name: "AES-CTR", counter: new Uint8Array(16), length: 64 },
  184. cryptoKey,
  185. data
  186. )
  187. return new Blob([cipher])
  188. }
  189.  
  190. async function uploadFile(file, key) {
  191. const data = new Uint8Array(await file.arrayBuffer())
  192. const encryptedFile = await encrypt(data, key)
  193. const form = new FormData()
  194. form.set("reqtype", "fileupload")
  195. form.set("fileToUpload", encryptedFile, file.name)
  196. return GM_fetch("https://catbox.moe/user/api.php", { method: "POST", body: form }).then(res => res.text())
  197. }
  198.  
  199. async function downloadFile(file, key, progressCallback) {
  200. const fatboxLink = `https://files.fatbox.moe/${file}`;
  201. const catboxLink = `https://files.catbox.moe/${file}`;
  202. let response;
  203. try {
  204. response = await GM_fetch(fatboxLink, { onProgress: progressCallback });
  205. if (response.status < 200 || response.status >= 500) response = await GM_fetch(catboxLink, { onProgress: progressCallback });
  206. } catch (e) {
  207. response = await GM_fetch(catboxLink, { onProgress: progressCallback });
  208. }
  209. if (response.status < 200 || response.status >= 500) throw new Error(`Unexpected response status code ${response.status}`);
  210. return await response.blob();
  211. }
  212.  
  213. function mime(ext) {
  214. return ({
  215. "mp4": "video/mp4",
  216. "webm": "video/webm",
  217. "ogg": "video/ogg",
  218. "avi": "video/x-msvideo",
  219. "ts": "video/mp2t",
  220. "3gp": "video/3gpp",
  221. "3g2": "video/3gpp2",
  222. "aac": "audio/aac",
  223. "mp3": "audio/mpeg",
  224. "wav": "audio/wav",
  225. "weba": "audio/webm",
  226. "png": "image/png",
  227. "jpg": "image/jpeg",
  228. "jpeg": "image/jpeg",
  229. "gif": "image/gif",
  230. "bmp": "image/bmp",
  231. "webp": "image/webp"
  232. })[ext]
  233. }
  234.  
  235. // --- Helper to extract filename robustly from a file link ---
  236. function getFilenameFromLink(fileLink) {
  237. if (!fileLink) return "";
  238. let v = fileLink.getAttribute('title');
  239. if (v && v.trim()) return v.trim();
  240. v = fileLink.getAttribute('download');
  241. if (v && v.trim()) return v.trim();
  242. const tc = (fileLink.textContent || "").trim();
  243. if (tc) return tc;
  244. const href = fileLink.getAttribute('href');
  245. if (href) return basename(href);
  246. return "";
  247. }
  248.  
  249. // --- Process secret attachments in posts ---
  250. async function processSecretAttachments(shouldNotify = true) {
  251. // SELECT ALL filename links (OP and replies). don't require [title].
  252. const fileElements = Array.from(document.querySelectorAll(".post_file_filename, .op_file_filename"));
  253. if (DEBUG) console.debug("SecretAttachment: scanning fileElements", fileElements.length);
  254.  
  255. let secretsFound = 0;
  256. let newSecrets = 0;
  257.  
  258. for (const fileLink of fileElements) {
  259. if (!fileLink) continue;
  260. const filenameAttr = getFilenameFromLink(fileLink);
  261. if (!filenameAttr) {
  262. if (DEBUG) console.debug("SecretAttachment: no filename from link", fileLink);
  263. continue;
  264. }
  265.  
  266. // find the containing post element (article or .post_wrapper)
  267. const postContainer = fileLink.closest('article, .post_wrapper, .post_is_op');
  268. if (!postContainer) {
  269. if (DEBUG) console.debug("SecretAttachment: no post container for", filenameAttr);
  270. continue;
  271. }
  272. if (postContainer.dataset.secretProcessed === 'true') {
  273. if (DEBUG) console.debug("SecretAttachment: already processed", filenameAttr);
  274. continue;
  275. }
  276.  
  277. const postMessageElement = postContainer.querySelector('.text');
  278. if (!postMessageElement) {
  279. if (DEBUG) console.debug("SecretAttachment: no .text element in post", postContainer);
  280. continue;
  281. }
  282.  
  283. // Use the filename base (strip path/params)
  284. const parsedFilenameRaw = basename(filenameAttr).trim();
  285. const parsedFilename = parse(parsedFilenameRaw).name;
  286. if (DEBUG) console.debug("SecretAttachment: parsedFilenameRaw", parsedFilenameRaw, "->", parsedFilename);
  287.  
  288. const result = await extract(parsedFilename);
  289. if (!result) {
  290. if (DEBUG) console.debug("SecretAttachment: not a secret filename:", parsedFilename);
  291. continue;
  292. }
  293.  
  294. // Found a secret
  295. secretsFound++;
  296. newSecrets++;
  297. postContainer.dataset.secretProcessed = 'true';
  298. postContainer.classList.add('has-secret-attachment');
  299.  
  300. const { key, name } = result;
  301. const secretLink = $("a", { href: "javascript:void(0)" }, [`secret.${parse(name).ext}`]);
  302. const filename = secretLink.textContent;
  303. const secret = $("div", { class: "secretFile", style: "margin-top: 8px; margin-bottom: 4px;" }, ["[", secretLink, "]"]);
  304.  
  305. secretLink.addEventListener("click", async function() {
  306. secretLink.textContent = 'Loading ' + filename + '... (0%)';
  307. try {
  308. let lastPercent = 0;
  309. const encryptedBlob = await downloadFile(name, key, (percent) => {
  310. if (percent !== lastPercent) {
  311. lastPercent = percent;
  312. secretLink.textContent = `Loading ${filename}... (${percent}%)`;
  313. }
  314. });
  315.  
  316. // --- Decrypt the blob ---
  317. const encryptedBytes = new Uint8Array(await encryptedBlob.arrayBuffer());
  318. const cryptoKey = await crypto.subtle.importKey(
  319. "raw",
  320. key,
  321. { name: "AES-CTR" },
  322. false,
  323. ["decrypt"]
  324. );
  325. const decryptedBuffer = await crypto.subtle.decrypt(
  326. { name: "AES-CTR", counter: new Uint8Array(16), length: 64 },
  327. cryptoKey,
  328. encryptedBytes
  329. );
  330.  
  331. const type = mime(parse(name).ext) || '';
  332.  
  333. function arrayBufferToDataURL(buffer, type) {
  334. let binary = '';
  335. const bytes = new Uint8Array(buffer);
  336. const chunkSize = 0x8000;
  337. for (let i = 0; i < bytes.length; i += chunkSize) {
  338. const sub = bytes.subarray(i, i + chunkSize);
  339. binary += String.fromCharCode.apply(null, sub);
  340. }
  341. return `data:${type};base64,${btoa(binary)}`;
  342. }
  343.  
  344. if (type.startsWith("video") || type.startsWith("audio") || type.startsWith("image")) {
  345. const dataURL = arrayBufferToDataURL(decryptedBuffer, type);
  346. const elementTag = type.startsWith("video") ? "video" :
  347. type.startsWith("audio") ? "audio" : "img";
  348. secret.replaceWith($(elementTag, {
  349. src: dataURL,
  350. controls: type.startsWith("video") || type.startsWith("audio") ? true : undefined,
  351. style: "max-width: 100%;"
  352. }));
  353. } else {
  354. const dataURL = arrayBufferToDataURL(decryptedBuffer, type || 'application/octet-stream');
  355. const a = $("a", { href: dataURL });
  356. a.download = `${crypto.randomUUID()}.${parse(name).ext}`;
  357. document.body.appendChild(a);
  358. a.click();
  359. a.remove();
  360. }
  361. } catch (error) {
  362. console.error({script: 'SecretAttachment (b4k)', error});
  363. secretLink.textContent = `Error loading ${filename}. Error logged to console.${error && error.message ? ' ' + error.message : ''}`;
  364. }
  365. });
  366.  
  367. insertAfter(secret, postMessageElement);
  368. }
  369.  
  370. if (newSecrets > 0 && shouldNotify) showSecretFoundNotification(secretsFound);
  371. if (DEBUG) console.debug("SecretAttachment: finished scan. secretsFound:", secretsFound);
  372. return secretsFound;
  373. }
  374.  
  375. // --- Styles ---
  376. function addSecretHighlightStyles() {
  377. const style = document.createElement('style');
  378. style.textContent = `
  379. @keyframes secretPulse { 0% { box-shadow: 0 0 0 0 rgba(255,255,255,0.2); } 70% { box-shadow: 0 0 0 8px rgba(255,255,255,0); } 100% { box-shadow:0 0 0 0 rgba(255,255,255,0); } }
  380. .post_wrapper.has-secret-attachment, .post_is_op.has-secret-attachment, article.has-secret-attachment { position:relative !important; border:2px solid #555 !important; border-radius:4px !important; box-shadow:0 0 6px rgba(255,255,255,0.2) !important; animation: secretPulse 2s ease-out; animation-iteration-count:1; padding:2px !important; margin-bottom:6px !important; }
  381. .has-secret-attachment::before { content:"🔒"; position:absolute; top:2px; right:2px; font-size:14px; opacity:0.8; z-index:100; }
  382. .secretFile { display:inline-block; padding:2px 5px; background-color:rgba(0,0,0,0.2); border-radius:3px; margin-top:8px !important; margin-bottom:4px !important;}
  383. .secretFile a { color:#fff !important; text-decoration:none !important; }
  384. .secretFile a:hover { text-decoration:underline !important; }
  385. #secret-notification { font-family: arial, sans-serif; }
  386. `;
  387. document.head.appendChild(style);
  388. }
  389.  
  390. // --- notification ---
  391. function showSecretFoundNotification(count) {
  392. const existingNotification = document.getElementById('secret-notification');
  393. if (existingNotification) existingNotification.remove();
  394. const notification = $("div", { id: "secret-notification", style: "position:fixed; top:calc(3vh + 30px); right:20px; background-color:#000; border:1px solid #333; border-radius:4px; padding:10px 15px; box-shadow:0 2px 10px rgba(0,0,0,0.3); z-index:9999; font-family:arial,sans-serif; font-size:14px; color:#fff; max-width:300px; animation:fadeIn 0.3s ease-out;" }, [
  395. $("div", { style:"display:flex; align-items:center; margin-bottom:5px;" }, [
  396. $("span", { style:"font-weight:bold; margin-right:auto; color:#fff;" }, ["Secret Attachment"]),
  397. $("span", { id:"secret-notification-close", style:"cursor:pointer; font-size:18px; line-height:14px; color:#999;" }, ["×"])
  398. ]),
  399. $("div", {}, [ count===1?"1 secret attachment detected on this page.":`${count} secret attachments detected on this page.` ]),
  400. $("style", {}, [`@keyframes fadeIn { from { opacity:0; transform:translateY(-10px);} to { opacity:1; transform:translateY(0);}} @keyframes fadeOut { from{opacity:1; transform:translateY(0);} to{opacity:0; transform:translateY(-10px);} } #secret-notification-close:hover { color:#fff; }`])
  401. ]);
  402. document.body.appendChild(notification);
  403. document.getElementById('secret-notification-close').addEventListener('click', () => notification.remove());
  404. setTimeout(() => {
  405. if (notification.parentNode) {
  406. notification.style.animation='fadeOut 0.3s ease-in forwards';
  407. notification.addEventListener('animationend', () => { if(notification.parentNode) notification.remove(); });
  408. }
  409. }, 7000);
  410. }
  411.  
  412. // --- initial boot ---
  413. addSecretHighlightStyles();
  414. await processSecretAttachments(true);
  415.  
  416. // --- observe DOM ---
  417. const observer = new MutationObserver((mutations) => {
  418. let shouldProcess = false;
  419. for (const mutation of mutations) {
  420. if (mutation.type === 'childList') {
  421. for (const node of mutation.addedNodes) {
  422. if (node.nodeType !== 1) continue;
  423. try {
  424. if (node.matches && (node.matches('.post_wrapper') || node.matches('article') || node.matches('.thread') || node.matches('.post_file') )) {
  425. shouldProcess = true; break;
  426. }
  427. } catch (e) {}
  428. if (node.querySelector && (node.querySelector('.post_file_filename') || node.querySelector('.op_file_filename') || node.querySelector('.post_file'))) {
  429. shouldProcess = true; break;
  430. }
  431. }
  432. }
  433. if (shouldProcess) break;
  434. }
  435. if (shouldProcess) {
  436. // small delay to allow inner nodes to render
  437. setTimeout(()=> processSecretAttachments(false), 150);
  438. }
  439. });
  440. observer.observe(document.body, { childList:true, subtree:true });
  441.  
  442. if (DEBUG) console.debug("SecretAttachment: initialized");
  443.  
  444. })();
  445.  
  446.  
  447.  
  448.  
  449.  
Add Comment
Please, Sign In to add comment