// ==UserScript== // @name Uncucker // @namespace http://tampermonkey.net/ // @version 0.3.1 // @description Prevents post content from being deleted, blocks external redirects, suspends live-posting, auto-changes file hash etc. // @match https://meta.kiwi.st/* // @grant none // ==/UserScript== (function() { 'use strict'; const PREVENT_POST_PURGE = true; const PREVENT_EXTERNAL_REDIRECTS = true; const PREVENT_LIVE_POSTING = false; const SPOOF_FILENAME = true; const SPOOF_FILENAME_NEW_FILENAME = "image"; const CHANGE_FILE_HASH = true; // "true" requires allowing canvas permissions const RECREATE_IMAGE_INSTEAD_OF_RANDOM_DATA = false; const REPLACE_THUMBNAIL_WITH_ORIGINAL = false; const SHOW_TYPING_SPEED = true; function getRandomInt(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } function preventPostPurge(posts, state) { // don't hide the shadow ban message if it's for us const originalRenderModerationLog = posts.PostView.prototype.renderModerationLog; posts.PostView.prototype.renderModerationLog = function() { const prevId = this.model.id; this.model.id = null; originalRenderModerationLog.apply(this, arguments); this.model.id = prevId; }; posts.Post.prototype.applyModeration = function(entry) { console.log(`[Userscript] Intercepted moderation for post ${this.id}.`); if (!this.moderation) { this.moderation = []; } this.moderation.push(entry); if (this.view) { this.view.renderModerationLog(); } }; // run patched version so our previous shadow bans show for (const post of state.posts) { if (post.view && post.moderation) { post.view.renderModerationLog(); } } console.log('[Userscript] Post purging has been patched.'); } function preventDeletedPostHiding() { const style = document.createElement('style'); style.textContent = `.deleted > .deleted-toggle:not(:checked)~:not(hr) { display: unset !important; }`; document.head.appendChild(style); console.log('[Userscript] Deleted post hiding has been prevented.'); } function preventExternalRedirects(connection) { const originalRedirectHandler = connection.handlers[37]; connection.handlers[37] = function(msg) { const url = new URL(msg, location.origin); if (url.origin === location.origin) { // Allow same-origin redirects by calling the original handler if (originalRedirectHandler) { originalRedirectHandler.apply(this, arguments); } // Fallback if original handler didn't exist location.href = url.href; } else { console.log(`[Userscript] Blocked external redirect to: ${url.href}`); } }; console.log('[Userscript] Redirect handler has been patched.'); } function preventLivePosting(posts, identity) { let suspendSend = true; let requestImage = null; const originalSend = WebSocket.prototype.send; const originalHandleUploadResponse = posts.FormModel.prototype.handleUploadResponse; const originalRequestAlloc = posts.FormModel.prototype.requestAlloc; const originalCommitClose = posts.FormModel.prototype.commitClose; WebSocket.prototype.send = function (data) { if (suspendSend && typeof data === 'string' && (parseInt(data.slice(0, 2)) < 10)) { console.log('[Userscript] Blocked WebSocket.send with data:', data); return; } return originalSend.apply(this, arguments); }; posts.FormModel.prototype.handleUploadResponse = function(data) { if (typeof data !== "object") { console.warn('[Userscript] Recieved non-object in handleUploadResponse function'); if (typeof data === "string" && data.includes("banned")) { this.view.upload.button.innerText = `Upload Failed (Banned)`; } return; } requestImage = data; // this makes handleUploadResponse call requestAlloc if (posts.postSM.state === 4) { posts.postSM.state = 5; } originalHandleUploadResponse.apply(this, arguments); const filename = this.view.upload.bufferedFile.name; const maxLength = 16; const halfLen = Math.floor(maxLength / 2); const shortenedName = filename.length > maxLength ? `${filename.slice(0, halfLen)}...${filename.slice(-halfLen)}` : filename; this.view.upload.button.innerText = `File: ${shortenedName}`; }; posts.FormModel.prototype.requestAlloc = function(body, image) { originalRequestAlloc.apply(this, arguments); posts.postSM.feed(7); // fake receiving post id to enable Done button }; posts.FormModel.prototype.commitClose = function() { if (this.view.input.value || requestImage) { if (SPOOF_FILENAME && requestImage) { requestImage.name = `${SPOOF_FILENAME_NEW_FILENAME}.${requestImage.name.split('.').pop()}`; } suspendSend = false; originalRequestAlloc.call(this, this.view.input.value, requestImage); originalCommitClose.apply(this, arguments); suspendSend = true; } requestImage = null; }; console.log('[Userscript] Live-posting has been patched.'); } function autoHashChange(posts) { function addRandomDataToFile(file) { return new Promise((resolve) => { console.log('[Userscript] Adding random bytes to the file'); const reader = new FileReader(); reader.addEventListener('load', () => { let fileBits = []; const random = new Uint8Array(8); crypto.getRandomValues(random); if (file.type === 'image/gif' || file.type === 'video/webm') { const offset = ((file.type === 'video/webm') ? (reader.result.byteLength * 0.001) : 2); fileBits = [ reader.result.slice(0, -offset - random.length), random, reader.result.slice(-offset), ]; } else { fileBits = [reader.result, random]; } const newFile = new File(fileBits, file.name, { type: file.type }); resolve(newFile); }); reader.readAsArrayBuffer(file); }); } function changeRandomPixel(file, quality = 0.9) { return new Promise((resolve) => { console.log('[Userscript] Changing random pixel of an image'); const reader = new FileReader(); reader.addEventListener('load', () => { const img = new Image(); img.onload = () => { const cvs = document.createElement('canvas'); cvs.width = img.width; cvs.height = img.height; const ctx = cvs.getContext('2d'); ctx.drawImage(img, 0, 0); const imgData = ctx.getImageData(0, 0, cvs.width, cvs.height); const data = imgData.data; // modify random pixel's red channel const index = getRandomInt(0, data.length / 4) * 4; data[index] += ((data[index] > 245) ? -10 : 10); ctx.putImageData(imgData, 0, 0); cvs.toBlob((blob) => { const newFile = new File([blob], file.name, { type: file.type }); resolve(newFile); }, file.type, quality); }; img.src = reader.result; }); reader.readAsDataURL(file); }); } function anonFile(file, quality = 0.9) { if (RECREATE_IMAGE_INSTEAD_OF_RANDOM_DATA && (file.type === 'image/jpeg' || file.type === 'image/png')) { return changeRandomPixel(file, quality); } return addRandomDataToFile(file); } const originalUploadFile = posts.FormModel.prototype.uploadFile; posts.FormModel.prototype.uploadFile = async function(file) { const newFile = await anonFile(file); return originalUploadFile.call(this, newFile); }; console.log('[Userscript] File upload has been patched.'); } function replaceThumbnailWithOriginal(posts) { const originalRenderThumbnail = posts.ImageHandler.prototype.renderThumbnail; posts.ImageHandler.prototype.renderThumbnail = function() { originalRenderThumbnail.apply(this, arguments); const { file_type } = this.model.image; if (file_type === 0 // jpg || file_type === 1 // png || file_type === 2 // gif || file_type === 16 // webp || file_type === 20 // avif ) { const thumbLink = this.el.querySelector("figure a"); if (thumbLink) { thumbLink.firstElementChild.src = thumbLink.href; } } } } function showTypingSpeed(connection, state) { const typingSessions = new Map(); function updateTypingSpeed(postId, final = false) { const session = typingSessions.get(postId); if (!session) return; const post = state.posts.get(postId); if (!post || !post.view) return; const postElement = post.view.el; let speedElement = postElement.querySelector('.typing-speed'); if (!speedElement) { speedElement = document.createElement('span'); speedElement.className = 'typing-speed'; const header = postElement.querySelector('header'); if (header) { const timeElement = header.querySelector('time'); if (timeElement) { timeElement.insertAdjacentElement('afterend', speedElement); } } } const now = Date.now(); const elapsedMinutes = (now - session.startTime) / 60000; if (elapsedMinutes > 0) { const wpm = Math.round((session.charCount / 5) / elapsedMinutes); if (final) { speedElement.textContent = ` (WPM: ${wpm})`; typingSessions.delete(postId); } else { speedElement.textContent = ` (Typing: ${wpm} WPM)`; session.lastKeystrokeTime = now; } } } // Intercept character appends const originalAppendHandler = connection.handlers[2]; connection.handlers[2] = function([id, char]) { if (!typingSessions.has(id)) { typingSessions.set(id, { startTime: Date.now(), charCount: 0, lastKeystrokeTime: Date.now() }); } const session = typingSessions.get(id); session.charCount++; updateTypingSpeed(id); if (originalAppendHandler) { originalAppendHandler.apply(this, arguments); } }; // Intercept backspaces const originalBackspaceHandler = connection.handlers[3]; connection.handlers[3] = function(id) { const session = typingSessions.get(id); if (session) { session.charCount = Math.max(0, session.charCount - 1); updateTypingSpeed(id); } if (originalBackspaceHandler) { originalBackspaceHandler.apply(this, arguments); } }; // Intercept post submissions const originalCloseHandler = connection.handlers[5]; connection.handlers[5] = function({ id, links, commands }) { if (typingSessions.has(id)) { updateTypingSpeed(id, true); } if (originalCloseHandler) { originalCloseHandler.apply(this, arguments); } }; // Inactivity check // setInterval(() => { // const now = Date.now(); // for (const [postId, session] of typingSessions.entries()) { // if (now - session.lastKeystrokeTime > 10000) { // const postElement = document.getElementById(`p${postId}`); // if (postElement) { // const speedElement = postElement.querySelector('.typing-speed'); // if (speedElement) { // speedElement.remove(); // } // } // typingSessions.delete(postId); // } // } // }, 5000); console.log('[Userscript] Post typing has been patched.'); } function main(posts, identity, connection, state) { const startTime = performance.now(); if (PREVENT_POST_PURGE) { preventPostPurge(posts, state); preventDeletedPostHiding(); } if (PREVENT_EXTERNAL_REDIRECTS) { preventExternalRedirects(connection); } if (PREVENT_LIVE_POSTING) { preventLivePosting(posts, identity); } if (CHANGE_FILE_HASH) { autoHashChange(posts); } if (REPLACE_THUMBNAIL_WITH_ORIGINAL) { replaceThumbnailWithOriginal(posts); } if (SHOW_TYPING_SPEED) { showTypingSpeed(connection, state); } const endTime = performance.now(); const passed = Math.round(endTime - startTime); console.log(`[Userscript] main() took ${passed} milliseconds.`); } const userscriptStartTime = performance.now(); function waitForModules() { const retryInterval = 200; try { window.require( ['mod/index', 'posts/index', 'posts/posting/identity', 'connection/index', 'state'], function(mod, posts, identity, connection, state) { // final thing that gets initialized if (!mod.accountPanel) { setTimeout(waitForModules, retryInterval); return; } const modulesLoadTime = performance.now(); const passed = Math.round(modulesLoadTime - userscriptStartTime); console.log(`[Userscript] Modules took ${passed} milliseconds to initialize.`); main(posts, identity, connection, state); }, undefined, true // forceSync to catch exceptions ); } catch (e) { console.warn('[Userscript] Exception caught while loading modules, retrying...', e); const modulesLoadTime = performance.now(); const passed = Math.round(modulesLoadTime - userscriptStartTime); if (passed < 2000) { setTimeout(waitForModules, retryInterval); } } } waitForModules(); })();