Greasy Fork is available in English.

Bonk NSFW map filter

Blocks NSFW bonk maps

// ==UserScript==
// @name        Bonk NSFW map filter
// @namespace   salama.xyz
// @author      Salama
// @version     1.2
// @match       https://*.bonk.io/gameframe-release.html
// @match       https://*.bonkisback.io/gameframe-release.html
// @supportURL  https://discord.gg/Dj6usq7ww3
// @grant       none
// @description Blocks NSFW bonk maps
// @run-at document-end
// ==/UserScript==

////////////////////////
////    SETTINGS    ////
////////////////////////

/* Optionally depends on code injector and BonkLIB
 * ----------------------------------------------------------------
 * https://greasyfork.org/en/scripts/433861-code-injector-bonk-io
 * - Adds warning to map suggestions
 * - Blurs or blacks out map in lobby and in game
 * ----------------------------------------------------------------
 * https://greasyfork.org/en/scripts/508104-bonklib
 * - Adds GUI settings
 * ----------------------------------------------------------------
 */

// This script connects to GitHub to get an up to date list.

// Commas in front for ease of use
// true = yes
// false = no

const settings = {
	 HIDE_NSFW_REPLAYS: true
	,DISABLE_REPLAYS: false

	// If false, maps will be blurred instead
	,HIDE_MAPS_FROM_MAP_SELECTOR: false

	// If true, map details won't be blurred
	,BLUR_ONLY_MAP_PREVIEW: false

	,UNBLUR_MAP_ON_MOUSE_HOVER: true

	,INCLUDE_REMIXES_OF_NSFW_MAPS: true

	/* You can enable this in case injection fails due to a
	 * bonk update. The mod will still work for its main purpose
	 */
	,DISABLE_INJECTION_FAIL_WARNING: false

	,WARN_ABOUT_MAP_REQUESTS: true

	,HIDE_GAME_ON_NSFW: true

	// Blocklist is cached for 5 * 60 sec = 5 minutes
	,CACHE_DURATION: 5 * 60
}

const guiSettings = {
    noWindow: true,
    // Must be defined before bonkHUD.createMod
    settingsContent: null,
    // Version (optional)
    bonkLIBVersion: "1.1.3",
    modVersion: "1.1",
}


////////////////////////
///       CODE      ////
////////////////////////

'use strict';

const NSFWLIST_VERSION = 0;

const global = {
	cacheTime: 0,
	NSFWList: [],
	NSFWMaps: new Set(),
	replays: [],
	ignoreNextReport: false
};

function requestHandler(original) {
	return function(url,body,success,type) {
		if (global.ignoreNextReport &&
			url.endsWith("/replay_report.php")
		) {
			global.ignoreNextReport = false;
			return {
				done: () => {
					return {
						fail: () => {}
					}
				}
			}
		}

		if (settings.DISABLE_REPLAYS &&
			url.endsWith("/replay_get.php")
		) {
			return {
				done: () => {
					return {
						fail: () => {}
					}
				}
			}
		}


		// Send request
		const response = original.apply(this, arguments);

		// Hijack response callback
		const responseDone = response.done;
		response.done = function(responseCallback) {
			/* The originally synchronous responseCallback can
			 * be replaced with an asynchronous function, because
			 * its return value is never saved or used anywhere.
			 */
			const originalResponseCallback = responseCallback;
			responseCallback = async function(data, status) {
				// Data is sometimes string and sometimes JSON

				let wasParsed = false;

				if (typeof data === "string") {
					try {
						let parsed = JSON.parse(data);
						wasParsed = true;

						data = parsed;
					}
					catch {
						wasParsed = false;
					}
				}

				if (typeof data === "object") {
					// If the request response contains a map array
					if (Object.keys(data).includes("maps") &&
						typeof data.maps === "object"
					   ) {
						const NSFWList = await getNSFWList();

						// TODO finish checks
						if (/^G/.test(read(pretty_top_level)) ||
							await isOK([read(pretty_top_name)]) ||
							pushOK(read(pretty_top_name))
						) {
							data.maps.ok = true;
						}

						for (let i = 0; i < data.maps.length; i++) {
							const map = data.maps[i];

							const hash = await getHash(map.id.toString(), map.authorname);

							if (await isOK(map)) continue;

							if (NSFWList.includes(hash)) {
								global.NSFWMaps.add(map.id);
							}
							else if (settings.INCLUDE_REMIXES_OF_NSFW_MAPS) {
								if (map.remixid > 0) {
									const rxhash = await getHash(map.remixid.toString(), map.remixauthor);

									if (NSFWList.includes(rxhash)) {
										global.NSFWMaps.add(map.id);
									}
								}
							}
						}
					}
					else if (Object.keys(data).includes("replays")) {
						if (body.offset === 0) {
							global.replays = [];
						}
						global.replays = global.replays.concat(data.replays);
					}
				}

				if (wasParsed) {
					data = JSON.stringify(data);
				}

				// Call original response callback
				return originalResponseCallback.apply(this, arguments);
			}

			// Set our own function as the response callback
			return responseDone.call(this, responseCallback);
		}

		return response;
	}
}

async function isOK(map) {
	return new Promise(async resolve => {
		resolve(getOK().includes(
			await sha256(map[Object.keys(map).sort((a, b) => a.localeCompare(b))[0]])
		));
	});
}

async function getNSFWList() {
	return new Promise(resolve => {
		if (Date.now() - global.cacheTime > settings.CACHE_DURATION * 1000) {
			global.cacheTime = Date.now();

			window.$.get("https://gist.githubusercontent.com/Salama/c93f26e0468aa743453339c8c993adaa/raw?" + Date.now()).done(r => {
				let NSFWList = r.split("\n");
				let version = parseInt(NSFWList.splice(0, 1));

				if (version !== NSFWLIST_VERSION) {
					alert("NSFW map blocker is outdated!");

					// Prevent future requests
					global.cacheTime = Infinity;
					resolve([]);
					return;
				}
				global.NSFWList = NSFWList;
			});
		}

		resolve(global.NSFWList);
	});
}

async function getHash(first, second) {
	return sha256(
		["Amye9CHqRTs", await sha256(first), second].join("᠎")
	);
}

async function sha256(text) {
	const encoder = new TextEncoder();
	const data = encoder.encode(text);
	const hashBuffer = await window.crypto.subtle.digest("SHA-256", data);
	const hashArray = Array.from(new Uint8Array(hashBuffer));
	const hashHex = hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
	return hashHex;
}

async function pushOK(props) {
	let current = getOK();
	current.push(await sha256(props));
	return window.localStorage.setItem("nsfwok", current.join(""));
}

function getOK() {
	let ok = window.localStorage.getItem("nsfwok");
	if (!ok) {
		window.localStorage.setItem("nsfwok", "");
		return [];
	}
	return [...ok.match(/.{64}/g)];
}

function read(e) {
	return e.textContent;
}

function addBlurStyle() {
	let blurStyle = document.createElement("style");
	blurStyle.innerHTML = `
		/* Blur map preview in map selector */
		.blurNSFW {
			overflow: hidden;
		}
		.blurNSFW > .maploadwindowtextname {
			filter: blur(6px);
		}
		.blurNSFW > .maploadwindowtextauthor {
			filter: blur(4px);
		}
		.blurNSFW > img {
			filter: blur(12px);
		}

		/* Unblur map preview in map selector on mouse hover */
		.hoverUnblurNSFW:hover > .maploadwindowtextname {
			filter: unset !important;
		}
		.hoverUnblurNSFW:hover > .maploadwindowtextauthor {
			filter: unset !important;
		}
		.hoverUnblurNSFW:hover > img {
			filter: unset !important;
		}

		/* Blur map preview in lobby */
		.blurNSFW > #newbonklobby_maptext {
			filter: blur(6px);
		}
		.blurNSFW > #newbonklobby_mapauthortext {
			filter: blur(4px);
		}
		.blurNSFW > #newbonklobby_mappreviewcontainer {
			filter: blur(12px);
		}

		/* Unblur map preview in lobby on mouse hover */
		.hoverUnblurNSFW > #newbonklobby_maptext:hover {
			filter: unset !important;
		}
		.hoverUnblurNSFW > #newbonklobby_mapauthortext:hover {
			filter: unset !important;
		}
		.hoverUnblurNSFW > #newbonklobby_mappreviewcontainer:hover {
			filter: unset !important;
		}

		/*Disable blurred map background in lobby */
		.disableMapthumbBig > #newbonklobby_mapthumb_big {
			display: none !important;
		}

		/* Disable map */
		.fullyTransparent {
			opacity: 0 !important;
		}
	`;
	document.head.appendChild(blurStyle);
}

(async () => {
	addBlurStyle();
	// Hijack requests
	const jqGet = requestHandler(window.$.get);
	const jqPost = requestHandler(window.$.post);
	window.$.get = jqGet;
	window.$.post = jqPost;

	getNSFWList();

	const mapObserver = new MutationObserver(mutations => {
		for (const mutation of mutations) {
			for (const node of mutation.addedNodes) {
				if (global.NSFWMaps.has(node.map.m.dbid)) {
					if (settings.HIDE_MAPS_FROM_MAP_SELECTOR) {
						node.remove();
					}
					else {
						node.classList.add("blurNSFW");
						if (settings.BLUR_ONLY_MAP_PREVIEW) {
							node.getElementsByClassName("maploadwindowtextname")[0].style.filter = "unset";
							node.getElementsByClassName("maploadwindowtextauthor")[0].style.filter = "unset";
						}
						if (settings.UNBLUR_MAP_ON_MOUSE_HOVER) {
							node.classList.add("hoverUnblurNSFW");
						}
					}
				}
			}
		}
	});
	mapObserver.observe(document.getElementById("maploadwindowmapscontainer"), {childList: true});

	// Replay section

	if (settings.DISABLE_REPLAYS) {
		document.getElementById("bgreplay").style.display = "none";
	}
	// TODO fix replayIndex drifting when spamming next replay.
	// Possibly fixed now?
	let replayIndex = -1;
	let ignoreReplayChange = false;
	const replayObserver = new MutationObserver(async mutations => {
		if (settings.DISABLE_REPLAYS || !settings.HIDE_NSFW_REPLAYS) return;

		for (const mutation of mutations) {
			if (mutation.type === "childList") {
				for (const node of mutation.addedNodes) {
					const index = [...mutation.target.children].indexOf(node);
					// Author
					if (index === 2) {
						if (!ignoreReplayChange) {
							replayIndex++;
						}
						ignoreReplayChange = false;


						if (!global.replays[replayIndex] ||
						    await isOK([read(node)])
						) {
							continue;
						}

						const NSFWList = await getNSFWList();

						const hash = await getHash(global.replays[replayIndex].mapid.toString(), read(node));

						if (NSFWList.includes(hash) ||
						    global.NSFWMaps.has(global.replays[replayIndex].mapid)
						) {
							global.NSFWMaps.add(global.replays[replayIndex].mapid);
							global.ignoreNextReport = true;
							document.getElementById("pretty_top_replay_report").click();
						}
					}
				}
			}
			else if (mutation.type === "attributes") {
				/* We need to override the visibility status to another visibility type
				 * to prevent bonk from periodically updating replay credits, which
				 * messes up replayIndex
				 */
				if (mutation.target.style.visibility === "inherit") {
					mutation.target.style.visibility = "visible";
				}
			}
		}
	});
	replayObserver.observe(document.getElementById("pretty_top_replay_text"), {childList: true, attributes: true});

	document.getElementById("pretty_top_replay_back").addEventListener("click", () => {
		replayIndex--;
		replayIndex = Math.max(replayIndex, 0);
		ignoreReplayChange = true;
	}, true);

	document.getElementById("pretty_top_replay_next").addEventListener("click", () => {
		replayIndex++;
		replayIndex = Math.min(replayIndex, global.replays.length - 1);
		ignoreReplayChange = true;
	}, true);

	document.getElementById("pretty_top_replay_report").addEventListener("click", () => {
		ignoreReplayChange = true;
		global.replays.splice(replayIndex, 1);
	});
})();

window.NSFWFilter = {
	wrap: () => {
		const gameLoadedWaiter = setInterval(async() => {
			if (
				window.NSFWFilter.menuFunctions !== undefined &&
				Object.keys(window.NSFWFilter.menuFunctions).length >= 27) {
				clearInterval(gameLoadedWaiter);
			}
			else return;

			for (const i of Object.keys(window.NSFWFilter.menuFunctions)) {
				if (typeof window.NSFWFilter.menuFunctions[i] !== "function") continue;
				const ogFunc = window.NSFWFilter.menuFunctions[i];

				window.NSFWFilter.menuFunctions[i] = function() {
					switch (i) {
						case "recvMapSuggest":
							if(!settings.WARN_ABOUT_MAP_REQUESTS) break;

							const suggestion = arguments[0];
							getHash(suggestion.m.dbid.toString(), suggestion.m.a).then(async hash => {
								const NSFWList = await getNSFWList();

								if (await isOK(suggestion.m)) return;

								if (NSFWList.includes(hash)) {
									global.NSFWMaps.add(suggestion.m.dbid);
								}
								else if (settings.INCLUDE_REMIXES_OF_NSFW_MAPS) {
									if (suggestion.m.rxid > 0) {
										const rxhash = await getHash(suggestion.m.rxid.toString(), map.rxa);

										if (NSFWList.includes(rxhash)) {
											global.NSFWMaps.add(suggestion.m.dbid);
										}
									}
								}
								if (global.NSFWMaps.has(suggestion.m.dbid)) {
									window.NSFWFilter.menuFunctions.showStatusMessage("* NSFW map request", "#ff0000", false);
								}
							});
							break;
						case "setGameSettings":
							handleLobbyMap(arguments[0].map.m);
							break;
					}
					let response = ogFunc.apply(window.NSFWFilter.menuFunctions, arguments);
					return response;
				}
			}

			for (const i of Object.keys(window.NSFWFilter.toolFunctions.networkEngine)) {
				if (typeof window.NSFWFilter.toolFunctions.networkEngine[i] !== "function") continue;
				const ogFunc = window.NSFWFilter.toolFunctions.networkEngine[i];

				window.NSFWFilter.toolFunctions.networkEngine[i] = function() {
					switch (i) {
						case "sendMapAdd":
							unblurLobby();
							break;
					}
					let response = ogFunc.apply(window.NSFWFilter.toolFunctions.networkEngine, arguments);
					return response;
				}
			}

			window.NSFWFilter.toolFunctions.networkEngine.on("mapAdd", async map => {
				await handleLobbyMap(map.m);
			});
		}, 50);
	},
	checkReplay: async map => {
		if(!settings.INCLUDE_REMIXES_OF_NSFW_MAPS) return;

		const NSFWList = await getNSFWList();

		const hash = await getHash(map.dbid.toString(), map.a);
		const rxhash = await getHash(map.rxid.toString(), map.rxa);

		if (await isOK(map)) {}
		else if (NSFWList.includes(hash) ||
		         NSFWList.includes(rxhash)) {
			global.NSFWMaps.add(map.dbid);
		}
	}
}

function blurLobby() {
	if(settings.HIDE_GAME_ON_NSFW) {
		document.getElementById("newbonkgamecontainer").classList.add("disableMapthumbBig");
		document.getElementById("gamerenderer").classList.add("fullyTransparent");
	}
	if (settings.HIDE_MAPS_FROM_MAP_SELECTOR) {
		document.getElementById("newbonklobby_mappreviewcontainer").style.display = "none";
	}
	else {
		document.getElementById("newbonklobby_settingsbox").classList.add("blurNSFW");
		if(settings.UNBLUR_MAP_ON_MOUSE_HOVER) {
			document.getElementById("newbonklobby_settingsbox").classList.add("hoverUnblurNSFW");
		}
		if(settings.BLUR_ONLY_MAP_PREVIEW) {
			document.getElementById("newbonklobby_maptext").style.filter = "";
			document.getElementById("newbonklobby_mapauthortext").style.filter = "";
		}
	}
}

function unblurLobby() {
	document.getElementById("newbonkgamecontainer").classList.remove("disableMapthumbBig");
	document.getElementById("newbonklobby_settingsbox").classList.remove("blurNSFW", "hoverUnblurNSFW");
	document.getElementById("gamerenderer").classList.remove("fullyTransparent");
	document.getElementById("newbonklobby_mappreviewcontainer").style.display = "";
	document.getElementById("newbonklobby_maptext").style.filter = "";
	document.getElementById("newbonklobby_mapauthortext").style.filter = "";
}

async function handleLobbyMap(map) {
	const NSFWList = await getNSFWList();

	const hash = await getHash(map.dbid.toString(), map.a);

	if (await isOK(map)) {}

	else if (NSFWList.includes(hash)) {
		global.NSFWMaps.add(map.dbid);
	}
	else if (settings.INCLUDE_REMIXES_OF_NSFW_MAPS) {
		if (map.rxid > 0) {
			const rxhash = await getHash(map.rxid.toString(), map.rxa);

			if (NSFWList.includes(rxhash)) {
				global.NSFWMaps.add(map.dbid);
			}
		}
	}

	if (global.NSFWMaps.has(map.dbid)) {
		blurLobby();
	}
	else {
		unblurLobby();
	}
}

function injector(str) {
	let newStr = str;

	const menuRegex = newStr.match(/== 13\){...\(\);}}/)[0];
	newStr = newStr.replace(menuRegex, menuRegex + "window.NSFWFilter.menuFunctions = this; window.NSFWFilter.wrap();");
	const toolRegex = newStr.match(/=new [A-Za-z0-9\$_]{1,3}\(this,[A-Za-z0-9\$_]{1,3}\[0\]\[0\],[A-Za-z0-9\$_]{1,3}\[0\]\[1\]\);/);
	newStr = newStr.replace(toolRegex, toolRegex + "window.NSFWFilter.toolFunctions = this;");
	const replayRegex = newStr.match(/if\(([A-Za-z0-9\$_]{1,3}\[[0-9]+\])[^\)]+? < 5 \|\| [^\)]+? > 30\)/);
	newStr = newStr.replace(replayRegex[0], `{
		window.NSFWFilter.checkReplay(${replayRegex[1]}.startingState.mm);
	}` + replayRegex[0]);

	return newStr;
}

if (!window.bonkCodeInjectors) window.bonkCodeInjectors = [];

window.bonkCodeInjectors.push(bonkCode => {
	try {
		return injector(bonkCode);
	}
	catch (e) {
		if (settings.DISABLE_INJECTION_FAIL_WARNING) return;
		throw e;
	}
});

if (window.bonkHUD) {
	const addCheckbox = (target, name, variable) => {
		const label = document.createElement("label");
		label.classList.add("bonkhud-settings-label");
		label.textContent = name;
		label.style.marginRight = "5px";
		label.style.display = "inline-block";
		label.style.verticalAlign = "middle";

		const checkbox = document.createElement("input");
		checkbox.type = "checkbox";
		checkbox.style.display = "inline-block";
		checkbox.style.verticalAlign = "middle";
		checkbox.checked = settings[variable];

		checkbox.oninput = () => {
			settings[variable] = checkbox.checked;
			window.bonkHUD.saveModSetting(ind, settings);
		}

		const container = document.createElement("div");
		container.id = "nsfw_settings_" + variable;
		container.appendChild(label);
		container.appendChild(checkbox);

		target.appendChild(container);
	}

	let nsfwSettings = window.bonkHUD.generateSection();
	guiSettings.settingsContent = nsfwSettings;

	const ind = window.bonkHUD.createMod("NSFW Settings", guiSettings);

	if (window.bonkHUD.getModSetting(ind) != null) {
		const savedSettings = window.bonkHUD.getModSetting(ind);
		for(const setting of Object.keys(savedSettings)) {
			settings[setting] = savedSettings[setting];
		}
	}

	addCheckbox(nsfwSettings, "Hide NSFW replays",                "HIDE_NSFW_REPLAYS");
	addCheckbox(nsfwSettings, "Hide all replays",                 "DISABLE_REPLAYS");
	addCheckbox(nsfwSettings, "Hide NSFW maps from map selector", "HIDE_MAPS_FROM_MAP_SELECTOR");
	addCheckbox(nsfwSettings, "Black out nsfw while in game",     "HIDE_GAME_ON_NSFW");
	addCheckbox(nsfwSettings, "Blur only map preview",            "BLUR_ONLY_MAP_PREVIEW");
	addCheckbox(nsfwSettings, "Unblur on mouse hover",            "UNBLUR_MAP_ON_MOUSE_HOVER");
	addCheckbox(nsfwSettings, "Include remixes",                  "INCLUDE_REMIXES_OF_NSFW_MAPS");
	addCheckbox(nsfwSettings, "Disable injection fail warning",   "DISABLE_INJECTION_FAIL_WARNING");

	const clearCache = document.createElement("button");
	clearCache.textContent = "Clear Cache";
	clearCache.onclick = () => {
		global.cacheTime = 0;
		global.NSFWList = [];
		global.NSFWMaps = new Set();
	}
	nsfwSettings.appendChild(clearCache);

	window.bonkHUD.updateStyleSettings();
}