code:
/* globals setLocalStorage */
let customCSSString = localStorage.getItem('customcss');
let disableBoardCss = localStorage.getItem('disableboardcss') == 'true';

window.addEventListener('settingsReady', function() {

	//for main theme
	const themePicker = document.getElementById('theme-setting');
	themePicker.value = localStorage.getItem('theme');
	themePicker.addEventListener('change', () => {
		setLocalStorage('theme', themePicker.value);
		changeTheme('theme');
	}, false);

	//for code theme
	const codeThemePicker = document.getElementById('codetheme-setting');
	codeThemePicker.value = localStorage.getItem('codetheme');
	codeThemePicker.addEventListener('change', () => {
		setLocalStorage('codetheme', codeThemePicker.value);
		changeTheme('codetheme');
	}, false);

	//custom CSS for users
	const customCSSSetting = document.getElementById('customcss-setting');
	const editCustomCSS = () => {
		customCSSString = customCSSSetting.value;
		console.log('editing custom CSS', customCSSString.length);
		setLocalStorage('customcss', customCSSString); //what if this gets too long?
		changeTheme('customcss');
	};
	customCSSSetting.value = customCSSString;
	customCSSSetting.addEventListener('input', editCustomCSS, false);

	//for main theme
	const disableBoardCssInput = document.getElementById('disableboardcss-setting');
	disableBoardCssInput.addEventListener('change', () => {
		disableBoardCss = !disableBoardCss;
		setLocalStorage('disableboardcss', disableBoardCss);
		console.log('toggling disable board custom css', disableBoardCss);
		toggleBoardCss();
	}, false);
	disableBoardCssInput.checked = localStorage.getItem('disableboardcss') == 'true';

});

const customCSSLink = document.createElement('style');
customCSSLink.rel = 'stylesheet';
customCSSLink.id = 'customcss';
document.head.appendChild(customCSSLink);

toggleBoardCss();

function toggleBoardCss() {
	const boardCssLink = document.getElementById('board-customcss');
	if (boardCssLink) {
		if (disableBoardCss) {
			boardCssLink.setAttribute('media', 'screen and (max-width: 1px)');
		} else {
			boardCssLink.removeAttribute('media');
		}
	}
}

function changeTheme(type) {
	switch (type) {
		case 'theme':
		case 'codetheme': {
			const theme = localStorage.getItem(type);
			let tempLink = document.getElementById(`custom${type}`);
			let defaultLink = document.getElementById(type);
			if (theme === 'default' || theme === defaultLink.dataset.theme) {
				defaultLink.rel = 'stylesheet';
				setTimeout(() => {
					tempLink && tempLink.remove();
				}, 100);
			} else {
				//use path and try to fetch from localstorage
				const path = `/css/${type}s/${theme}.css`;
				let css = localStorage.getItem(path);
				if (!tempLink) {
					tempLink = document.createElement('style');
					document.head.appendChild(tempLink);
				}
				if (css) {
					tempLink.innerHTML = css; //set as inline style temporarily
				}
				defaultLink.rel = ''; //disable default theme
				const themeLink = document.createElement('link');
				themeLink.rel = 'stylesheet';
				themeLink.id = `custom${type}`;
				themeLink.onload = function() {
					css = '';
					const rulesKey = themeLink.sheet.rules != null ? 'rules' : 'cssRules';
					for (let i = 0; i < themeLink.sheet[rulesKey].length; i++) {
						css += themeLink.sheet[rulesKey][i].cssText; //add all the rules to the css
					}
					//update localstorage with latest version
					setLocalStorage(path, css);
					tempLink.innerHTML = css;
					//remove temp inline style since we dont need it anymore
					tempLink.remove();
				};
				themeLink.href = path;
				document.head.appendChild(themeLink);
			}
			break;
		}
		case 'customcss':
			customCSSLink.innerHTML = localStorage.getItem('customcss');
			break;
	}
}
changeTheme('theme');
changeTheme('codetheme');
changeTheme('customcss');

repo_name: diegolegion/algochan

license: mit


code:
/* globals __n isThread */
window.addEventListener('DOMContentLoaded', () => {

	const statsElem = document.getElementById('threadstats');
	const idElems = Array.from(document.getElementsByClassName('user-id'));
	const idMap = new Map();
	const isHighlightedSet = new Set();

	const incrementMap = (id) => {
		idMap.set(id, idMap.get(id)+1 || 1);
	};

	const updateCounts = (updateId) => {
		for (let i = 0; i < idElems.length; i++) {
			const idString = idElems[i].innerText;
			if (updateId && updateId !== idString) { continue; }
			const count = idMap.get(idString);
			idElems[i].setAttribute('data-count', ` (${count})`);
			idElems[i].setAttribute('title', __n('Double click to highlight (%s)', count));
			idElems[i].setAttribute('title-mobile', __n('Double tap to highlight (%s)', count));
		}
	};

	const toggleHighlightPosts = (e) => {
		const id = e.target.innerText;
		const idPosts = document.querySelectorAll(`.post-container[data-user-id="${id}"]`);
		const isHighlighted = isHighlightedSet.has(id);
		isHighlightedSet[isHighlighted ? 'delete' : 'add'](id);
		for (let i = 0; i < idPosts.length; i++) {
			idPosts[i].classList[isHighlighted ? 'remove' : 'add']('highlighted');
		}
	};

	//map count of starting ids
	for (let i = 0; i < idElems.length; i++) {
		idElems[i].addEventListener('dblclick', toggleHighlightPosts);
		incrementMap(idElems[i].innerText);
	}

	if (isThread) {
		//set the attr for css hover count
		updateCounts();

		window.addEventListener('addPost', function(e) {
			if (e.detail.hover) {
				return; //dont need to handle hovered posts for this
			}
			const newFiles = e.detail.json.files.length;
			const numPosts = +statsElem.children[0].innerText.match(/^(\d+)/g);
			const numFiles = +statsElem.children[1].innerText.match(/^(\d+)/g);
			const filesTotal = numFiles + newFiles;
			const postTotal = numPosts + 1;
			statsElem.children[0].innerText = __n('%s replies', postTotal);
			statsElem.children[1].innerText = __n('%s files', filesTotal);
			if (e.detail.json.userId) {
				const userId = e.detail.post.querySelector('.user-id');
				idElems.push(userId);
				userId.addEventListener('dblclick', toggleHighlightPosts);
				incrementMap(e.detail.json.userId);
				updateCounts(e.detail.json.userId);
				if (isHighlightedSet.has(e.detail.json.userId)) {
					e.detail.post.classList.add('highlighted');
				}
				if (!statsElem.children[2]) {
					//UIDs enabled after thread generated
					const spacer = document.createTextNode(' |  ');
					const uidSpan = document.createElement('span');
					statsElem.appendChild(spacer);
					statsElem.appendChild(uidSpan);
				}
				statsElem.children[2].innerText = __n('%s UIDs', idMap.size);
			}
		});
	}

});


code:
/* globals __ __n LANG SERVER_TIMEZONE setLocalStorage */
let relativeTime = localStorage.getItem('relative') == 'true';
let hour24 = localStorage.getItem('24hour') == 'true';
let localTime = localStorage.getItem('localtime') == 'true';

let dates = [];
const dateElems = document.getElementsByClassName('reltime');
for (let i = 0; i < dateElems.length; i++) {
	dates.push(dateElems[i]); //convert to array
}

const YEAR = 31536000000
	, MONTH = 2592000000
	, WEEK = 604800000
	, DAY = 86400000
	, HOUR = 3600000
	, MINUTE = 60000;

const relativeTimeString = (date) => {
	let difference = Date.now() - new Date(date).getTime();
	let amount = 0;
	let unit = '';
	let isFuture = false;
	if (difference < 0) {
		difference = Math.abs(difference);
		isFuture = true;
	}
	if (difference < MINUTE) {
		return __('Now');
	} else if (difference < MINUTE*59.5) {
		amount = Math.round(difference / MINUTE);
		unit = 'minute';
	} else if (difference < HOUR*23.5) {
		amount = Math.round(difference / HOUR);
		unit = 'hour';
	} else if (difference < DAY*6.5) {
		amount = Math.round(difference / DAY);
		unit = 'day';
	} else if (difference < WEEK*3.5) {
		amount = Math.round(difference / WEEK);
		unit = 'week';
	} else if (difference < MONTH*11.5) {
		amount = Math.round(difference / MONTH);
		unit = 'month';
	} else {
		amount = Math.round(difference / YEAR);
		unit = 'year';
	}
	return __n(`%s ${unit} ${isFuture ? 'from now' :  'ago'}`, amount);
};

const changeDateFormat = (date) => {
	const options = {
		hour12: !hour24
	};
	if (!localTime) {
		options.timeZone = SERVER_TIMEZONE;
	}
	const dateString = new Date(date.dateTime).toLocaleString(LANG, options);
	if (relativeTime) {
		date.innerText = relativeTimeString(date.dateTime);
		date.title = dateString;
	} else {
		date.innerText = dateString;
		date.removeAttribute('title');
	}
};

const updateDates = () => {
	for (let i = 0; i < dates.length; i++) {
		changeDateFormat(dates[i]);
	}
};

updateDates();

window.addEventListener('settingsReady', function() {

	let relativeInterval = relativeTime ? setInterval(updateDates, MINUTE) : void 0;

	const hour24Setting = document.getElementById('24hour-setting');
	const togglehour24 = () => {
		hour24 = !hour24;
		setLocalStorage('24hour', hour24);
		updateDates();
		console.log('toggling 24h time', hour24);
	};
	hour24Setting.checked = hour24;
	hour24Setting.addEventListener('change', togglehour24, false);

	const localTimeSetting = document.getElementById('localtime-setting');
	const toggleLocalTime = () => {
		localTime = !localTime;
		setLocalStorage('localtime', localTime);
		updateDates();
		console.log('toggling local time', localTime);
	};
	localTimeSetting.checked = localTime;
	localTimeSetting.addEventListener('change', toggleLocalTime, false);

	const relativeTimeSetting = document.getElementById('relative-setting');
	const togglerelativeTime = () => {
		relativeTime = !relativeTime;
		setLocalStorage('relative', relativeTime);
		updateDates();
		if (relativeTime) {
			relativeInterval = setInterval(updateDates, MINUTE);
		} else {
			clearInterval(relativeInterval);
		}
		console.log('toggling relative time', relativeTime);
	};
	relativeTimeSetting.checked = relativeTime;
	relativeTimeSetting.addEventListener('change', togglerelativeTime, false);

});

const handleDateUpdates = (parentElem, temporary = false) => {
	const newDateElems = parentElem.querySelectorAll('.reltime');
	for (let date of newDateElems) {
		if (!temporary) {
			/* temporary e.g. floated posts, don't want to have their
				date elements pushed/tracked in the dates array */
			dates.push(date);
		}
		changeDateFormat(date);
	}
};

window.addEventListener('addPost', function(e) {
	handleDateUpdates(e.detail.post, e.detail.hover);
});

window.addEventListener('showModal', function(e) {
	handleDateUpdates(e.detail.modal);
});


code:
window.addEventListener('DOMContentLoaded', () => {

	let focused = document.hasFocus();
	let unread = [];
	const originalTitle = document.title;

	/*const changeFavicon = (href) => {
		const currentFav = document.head.querySelector('link[type="image/x-icon"]');
		const newFav = document.createElement('link');
		newFav.type = 'image/x-icon';
		newFav.rel = 'shortcut icon';
		newFav.href = href;
		currentFav.remove();
		document.head.appendChild(newFav);
	};*/

	const isVisible = (e) => {
		const top = e.getBoundingClientRect().top;
		const bottom = e.getBoundingClientRect().bottom;
		const height = window.innerHeight;
		return (top >= 0 || bottom-top > height) && bottom <= height;
	};

	const updateTitle = () => {
		if (unread.length === 0) {
			document.title = originalTitle;
		} else {
			document.title = `(${unread.length}) ${originalTitle}`;
		}
	};

	const focusChange = () => {
		focused = !focused;
	};

	const updateVisible = () => {
		const unreadBefore = unread.length;
		unread = unread.filter(p => {
			if (isVisible(p)) {
				p.classList.remove('highlighted');
				return false;
			}
			return true;
		});
		if (unreadBefore !== unread.length) {
			updateTitle();
		}
	};

	window.addEventListener('focus', focusChange);
	window.addEventListener('blur', focusChange);
	window.addEventListener('scroll', updateVisible);

	window.addEventListener('addPost', function(e) {
		if (e.detail.hover) {
			return; //dont need to handle hovered posts for this
		}
		const post = e.detail.post;
		//if browsing another tab or the post is out of scroll view
		if (!focused || !isVisible(post)) {
			post.classList.add('highlighted');
			unread.push(post);
			updateTitle();
		}
	});

});


code:
/* globals isManage isModView setLocalStorage hoverCacheList */
window.addEventListener('DOMContentLoaded', () => {

	let loading = {};

	const viewFullText = async function (e) {
		e.preventDefault();
		const parentPost = this.closest('.post-container');
		if (!parentPost) {
			return;
		}
		const postId = this.hash.substring(1);
		let jsonParts = this.pathname.replace(/\.html$/, '.json').split('/');
		let jsonPath;
		if ((isManage || isModView) && jsonParts.length === 5) {
			jsonParts.splice(2,1); //remove manage from json url
		}
		jsonPath = jsonParts.join('/');
		let hovercache = localStorage.getItem(`hovercache-${jsonPath}`);
		let postJson;
		if (hovercache) {
			hovercache = JSON.parse(hovercache);
			if (hovercache.postId == postId) {
				postJson = hovercache;
			} else if (hovercache.replies.length > 0) {
				postJson = hovercache.replies.find(r => r.postId == postId);
			}
		}
		if (!postJson) { //wasnt cached or cache outdates
			this.style.cursor = 'wait';
			let json;
			try {
				if (!loading[jsonPath]) {
					loading[jsonPath] = fetch(jsonPath).then(res => res.json());
				}
				json = await loading[jsonPath];
			} catch (e) {
				return console.error(e);
			} finally {
				this.style.cursor = '';
			}
			if (json) {
				setLocalStorage(`hovercache-${jsonPath}`, JSON.stringify(json));
				hoverCacheList.value = Object.keys(localStorage).filter(k => k.startsWith('hovercache'));
				if (json.postId == postId) {
					postJson = json;
				} else {
					postJson = json.replies.find(r => r.postId == postId);
				}
			} else {
				return localStorage.removeItem(`hovercache-${jsonPath}`); //thread deleted
			}
		}
		if (!postJson) {
			return; //post was deleted or missing, handle somehow
		}
		this.parentNode.remove();
		const messageParent = parentPost.querySelector('.post-message');
		if (messageParent) {
			messageParent.innerHTML = postJson.message;
		}
		const updatePostMessageEvent = new CustomEvent('updatePostMessage', {
			detail: {
				nonotify: true,
				post: parentPost,
				postId: postJson.postId,
				json: postJson,
			}
		});
		window.dispatchEvent(updatePostMessageEvent);
	};

	const viewFullTextLinks = document.getElementsByClassName('viewfulltext');

	for (let i = 0; i < viewFullTextLinks.length; i++) {
		viewFullTextLinks[i].addEventListener('click', viewFullText, false);
	}

});


code:
/* globals setLocalStorage Dragable threadwatcher watchedthread */
class ThreadWatcher {

	init() {
		//dont bother loading if no footer, must be minimal view
		this.footer = document.getElementById('bottom');
		if (!this.footer) {
			return;
		}

		//read the watchlist map and minimised state from localstorage
		this.watchListMap = new Map(JSON.parse(localStorage.getItem('watchlist')));
		this.minimised = localStorage.getItem('threadwatcher-minimise') === 'true';
		this.threadMatch = window.location.pathname.match(/^\/(\w+)(?:\/manage)?\/thread\/(\d+)\.html$/);

		//call the updatehandler when storage changes in another context
		window.addEventListener('storage', e => this.storageEventHandler(e));

		//setup the settings menu and "clear" button
		this.settingsInput = document.getElementById('watchlist-setting');
		this.clearButton = document.getElementById('watchlist-clear');
		this.clearButton.addEventListener('click', () => this.clear(), false);

		//create and insert the watchlist
		this.createList({minimised: this.minimised});

		//add events for toggling minimised
		this.minimiseButton = this.threadWatcher.firstChild.querySelector('.close');
		this.minimiseButton.addEventListener('click', () => this.toggleMinimise());

		//check if we are in a thread, and setup events for when the tab is focused/unfocused
		this.isFocused = document.hasFocus();
		if (this.threadMatch !== null) {
			window.addEventListener('focus', () => this.focusHandler(this.threadMatch[1], this.threadMatch[2]));
			window.addEventListener('blur', () => this.blurHandler());
			if (this.isFocused === true) {
				//set unread=0 for the current thread if the tab is focused
				this.focusHandler(this.threadMatch[1], this.threadMatch[2]);
			}
		}

		//start refreshing on an interval
		this.refreshInterval = setInterval(() => this.refresh(), 60 * 1000);

	}

	//refresh all the threads in the watchlist map
	refresh() {
		this.watchListMap.size > 0 && console.log('refreshing watchlist');
		for (let t of this.watchListMap.entries()) {
			const [board, postId] = t[0].split('-');
			const data = t[1];
			this.fetchThread(board, postId, data);
		}
	}

	//fetch a thread from the API and check if there are newer posts, and set unread if necessary.
	async fetchThread(board, postId, data) {
		let res, json;
		try {
			res = await fetch(`/${board}/thread/${postId}.json`);
			json = await res.json();
		} catch (e) { /* ignore */ }
		if (json && json.replies) {
			const newData = {
				...data,
				subject: (json.subject || json.nomarkup || `#${json.postId}`).substring(0, 25),
			};
			const updatedDate = new Date(data.updatedDate);
			const newPosts = json.replies.filter(r => new Date(r.date) > updatedDate);
			if (newPosts.length > 0) {
				if (this.isFocused && this.threadMatch
					&& this.threadMatch[1] === board && this.threadMatch[2] === postId) {
					//unread=0 when fetching from inside a thread that is focused
					newData.unread = 0;
				} else {
					newData.unread += newPosts.length;
					//this.notify(newPosts);
				}
			}
			if (newData.subject !== data.subject
				|| newData.unread !== data.unread) {
				newData.updatedDate = new Date();
				const key = `${board}-${postId}`;
				this.watchListMap.set(key, newData);
				this.updateRow(board, postId, newData);
				this.commit();
			}

		} else if (res && res.status === 404) {
			console.log('removing 404 thread from watchlist');
			this.remove(board, postId);
		}
	}

	/*send notifications (if enabled and following other settings) for posts fetched by threadwatcher
	notify(newPosts) {
		if (notificationsEnabled) {
			//i dont like fetching and creating the set each time, but it could be updated cross-context so its necessary (for now)
			const yous = new Set(JSON.parse(localStorage.getItem('yous')));
			for (const reply of newPosts) {
				const isYou = yous.has(`${reply.board}-${reply.postId}`);
				const quotesYou = reply.quotes.some(q => yous.has(`${reply.board}-${q.postId}`))
				if (!isYou && !(notificationYousOnly && !quotesYou)) {
					const notificationOptions = formatNotificationOptions(reply);
					try {
						new Notification(`Post in watched thread: ${document.title}`, notificationOptions);
					} catch (e) {
						console.log('failed to send notification', e);
					}
				}
			}
		}
	}*/

	//handle event for when storage changes in another tab and update the watcher to be in sync
	storageEventHandler(e) {
		if (e.storageArea === localStorage
			&& e.key === 'watchlist') {
			console.log('updating watchlist from another context');
			const newMap = new Map(JSON.parse(e.newValue));
			this.watchListMap.forEach((data, key) => {
				if (!newMap.has(key)) {
					const [board, postId] = key.split('-');
					this.deleteRow(board, postId);
				}
			});
			//let setOwnUnread = false;
			newMap.forEach((data, key) => {
				const [board, postId] = key.split('-');
				const oldData = this.watchListMap.get(key);
				if (!oldData) {
					this.addRow(board, postId, data);
				} else if (oldData && (oldData.unread !== data.unread
					|| oldData.subject !== data.subject)) {
					/*if (this.isFocused && this.threadMatch
						&& this.threadMatch[1] === board && this.threadMatch[2] === postId) {
						setOwnUnread = true;
						data.unread = 0;
					}*/
					this.updateRow(board, postId, data);
				}
			});
			this.watchListMap = new Map(JSON.parse(e.newValue));
			this.setVisibility();
			/*if (setOwnUnread === true) {
				this.commit();
			}*/
		}
	}

	//handle event when current page becomes focused (only listens on thread pages) and set unread=0 for this thread
	focusHandler(board, postId) {
		this.isFocused = true;
		const key = `${board}-${postId}`;
		const data = this.watchListMap.get(key);
		if (data && data.unread !== 0) {
			data.unread = 0;
			data.updatedDate = new Date();
			this.watchListMap.set(key, data);
			this.updateRow(board, postId, data);
			this.commit();
		}
	}

	//self explanatory
	blurHandler() {
		this.isFocused = false;
	}

	//commit any changes to localstorage and settings menu box (readonly)
	commit() {
		const mapSpread = [...this.watchListMap];
		setLocalStorage('watchlist', JSON.stringify(mapSpread));
		this.settingsInput.value = mapSpread;
		this.setVisibility();
	}

	toggleMinimise() {
		this.minimised = !this.minimised;
		this.minimiseButton.textContent = this.minimised ? '[+]' : '[−]';
		this.threadWatcher.classList.toggle('minimised');
		setLocalStorage('threadwatcher-minimise', this.minimised);
	}

	//toggles watcher visibility
	setVisibility() {
		if (this.threadWatcher) {
			this.threadWatcher.style.display = (this.watchListMap.size === 0 ? 'none' : null);
		}
	}

	//create the actual thread watcher box and draghandle and insert it into the page
	createList() {
		const threadWatcherHtml = threadwatcher({ minimised: this.minimised });
		this.footer.insertAdjacentHTML('afterend', threadWatcherHtml);
		this.threadWatcher = document.getElementById('threadwatcher');
		if (this.watchListMap.size === 0) {
			this.threadWatcher.style.display = 'none';
		}
		//reuse the dragable class for something :^)
		new Dragable('#threadwatcher-dragHandle', '#threadwatcher');
		for (let t of this.watchListMap.entries()) {
			const [board, postId] = t[0].split('-');
			const data = t[1];
			this.addRow(board, postId, data);
		}
	}

	//add a thread to the watchlist map
	add(board, postId, data) {
		const key = `${board}-${postId}`;
		if (this.watchListMap.has(key)) {
			//dont add duplicates
			return;
		}
		this.watchListMap.set(key, data);
		this.addRow(board, postId, data);
		this.commit();
	}

	//remove a thread from the watchlist map
	remove(board, postId) {
		this.deleteRow(board, postId);
		this.watchListMap.delete(`${board}-${postId}`);
		this.commit();
	}

	//remove all threads from the watchlist
	clear() {
		for (let t of this.watchListMap.entries()) {
			const [board, postId] = t[0].split('-');
			const data = t[1];
			this.deleteRow(board, postId, data);
		}
		this.watchListMap = new Map();
		this.commit();
	}

	//add the actual row html to the watcher
	addRow(board, postId, data) {
		const isCurrentThread = this.threadMatch != null
			&& this.threadMatch[1] === board && this.threadMatch[2] === postId;
		const watchListItemHtml = watchedthread({ watchedthread: { board, postId, ...data, isCurrentThread } });
		this.threadWatcher.insertAdjacentHTML('beforeend', watchListItemHtml);
		const watchedThreadElem = this.threadWatcher.lastChild;
		const closeButton = watchedThreadElem.querySelector('.close');
		//when x button clicked, call remove
		closeButton.addEventListener('click', () => this.remove(board, postId));
	}

	//delete the actual row from the watcher
	deleteRow(board, postId) {
		const row = this.threadWatcher.querySelector(`[data-id="${board}-${postId}"]`);
		row.remove();
	}

	//update a row in the watcher for new unread count
	updateRow(board, postId, data) {
		const row = this.threadWatcher.querySelector(`[data-id="${board}-${postId}"]`);
		if (data.unread === 0) {
			//remove the attribute completely to not show (0), we only want to show (1) or more
			row.removeAttribute('data-unread');
		} else {
			row.setAttribute('data-unread', data.unread);
		}
		//subject *can* change rarely, if the op was edited
		row.children[1].textContent = `/${board}/ - ${data.subject}`;
	}

}

const threadWatcher = new ThreadWatcher();

window.addEventListener('settingsReady', () => {

	threadWatcher.init();

});


code:
/* globals __ setLocalStorage */
let notificationsEnabled = localStorage.getItem('notifications') == 'true';
let notificationYousOnly = localStorage.getItem('notification-yous-only') == 'true';
let yousEnabled = localStorage.getItem('yous-setting') == 'true';
let savedYous = new Set(JSON.parse(localStorage.getItem('yous')));
let yousList;

function clearYousList() {
	if (yousEnabled) {
		toggleAllYous(false);
	}
	savedYous = new Set();
	yousList.value = '';
	setLocalStorage('yous', '[]');
	console.log('cleared yous');
}

function toggleAllYous(state) {
	savedYous.forEach(y => toggleOne(y, state));
}

const toggleQuotes = (quotes, state) => {
	quotes.forEach(q => {
		q[state?'setAttribute':'removeAttribute']('data-label', __('You'));
		q.classList[state?'add':'remove']('you');
	});
};

const toggleOne = (you, state) => {
	const [board, postId] = you.split('-');
	const post = document.querySelector(`[data-board="${board}"][data-post-id="${postId}"]`);
	if (post) {
		const postName = post.querySelector('.post-name');
		if (postName) {
			postName[state?'setAttribute':'removeAttribute']('data-label', __('You'));
			postName.classList[state?'add':'remove']('you');
		}
	}
	const quotes = document.querySelectorAll(`.quote[href^="/${board}/"][href$="#${postId}"]`);
	if (quotes) {
		toggleQuotes(quotes, state);
	}
};

const formatNotificationOptions = (postData) => {
	const notificationOptions = {
		body: postData.nomarkup ? postData.nomarkup.substring(0,100) : ''
	};
	if (postData.files.length > 0) {
		//tries to use a thumb instead of full files, will be lighter on bandwidth and able to show for video and some audio too
		let notificationImageURL;
		const spoilerNotification = (postData.spoiler || postData.files.some(f => f.spoiler === true));
		const notificationFile = postData.files.find(f => (!f.spoiler && (f.hasThumb === true || f.mimetype.startsWith('image/'))));
		if (spoilerNotification && !notificationFile) {
			notificationImageURL = '/file/spoiler.png';
		} else {
			if (notificationFile) {
				if (notificationFile.hasThumb) {
					notificationImageURL = `/file/thumb/${notificationFile.hash}${notificationFile.thumbextension}`;
				} else {
					notificationImageURL = `/file/${notificationFile.filename}`;
				}
			}
		}
		if (notificationImageURL) {
			notificationOptions.image = notificationImageURL;
			notificationOptions.badge = notificationImageURL;
			notificationOptions.icon = notificationImageURL;
		}
	}
	return notificationOptions;
};

if (yousEnabled) {
	toggleAllYous(yousEnabled);
}

//handle when (you)s change in a different tab
const yousStorageEventHandler = (e) => {
	if (e.storageArea === localStorage
		&& e.key === 'yous') {
		const newArrayYous = [...JSON.parse(e.newValue)];
		newArrayYous.forEach(ny => {
			if (!savedYous.has(ny)) {
				savedYous.add(ny);
				console.log('handle new (you) from other context:', ny);
				toggleOne(ny, yousEnabled);
			}
		});
		if (newArrayYous.length === 0) {
			console.log('(you)s were cleared in another tab');
			clearYousList();
		}
		yousList.value = newArrayYous.toString();
	}
};

const handleNewYous = (e) => {
	const postYou = `${e.detail.json.board}-${e.detail.postId}`;
	const isYou = window.myPostId == e.detail.postId;
	if (isYou) {
		//save you
		savedYous.add(postYou);
		const arrayYous = [...savedYous];
		yousList.value = arrayYous.toString();
		setLocalStorage('yous', JSON.stringify(arrayYous));
	}
	if (savedYous.has(postYou)) {
		//toggle forn own post for name field
		toggleOne(postYou, yousEnabled);
	}
	const quotesYou = e.detail.json.quotes
		.map(q => `${e.detail.json.board}-${q.postId}`)
		.filter(y => savedYous.has(y))
		.length > 0;
	const youHoverQuotes = e.detail.json.quotes
		.concat(e.detail.json.backlinks)
		.map(q => `${e.detail.json.board}-${q.postId}`)
		.filter(y => savedYous.has(y))
		.map(y => {
			const [board, postId] = y.split('-');
			return e.detail.post.querySelectorAll(`.quote[href^="/${board}/"][href$="#${postId}"]`);
		}).reduce((acc, array) => {
			return acc.concat(Array.from(array)); //handle duplicate of same quote
		}, []);
	//toggle for any quotes in a new post that quote (you)
	toggleQuotes(youHoverQuotes, yousEnabled);
	//if not a hover newpost, and enabled/for yous, send notification
	if (!e.detail.nonotify && !e.detail.hover && notificationsEnabled && !isYou) {
		if (notificationYousOnly && !quotesYou) {
			return; //only send notif for (you) if setting
		}
		try {
			console.log('attempting to send notification', postYou);
			const postData = e.detail.json;
			const notificationOptions = formatNotificationOptions(postData);
			new Notification(`${quotesYou ? 'New quote in: ' : ''}${document.title}`, notificationOptions);
		} catch (e) {
			// notification cant send for some reason -- user revoked perms in browser?
			console.log('failed to send notification', e);
		}
	}
};

window.addEventListener('addPost', handleNewYous, false);
window.addEventListener('storage', yousStorageEventHandler, false);
window.addEventListener('updatePostMessage', handleNewYous, false);

window.addEventListener('settingsReady', () => {

	yousList = document.getElementById('youslist-setting');
	yousList.value = [...savedYous];
	const yousListClearButton = document.getElementById('youslist-clear');
	yousListClearButton.addEventListener('click', clearYousList, false);

	const yousSetting = document.getElementById('yous-setting');
	const toggleYousSetting = () => {
		yousEnabled = !yousEnabled;
		setLocalStorage('yous-setting', yousEnabled);
		toggleAllYous(yousEnabled);
		console.log('toggling yous', yousEnabled);
	};
	yousSetting.checked = yousEnabled;
	yousSetting.addEventListener('change', toggleYousSetting, false);

	const notificationYousOnlySetting = document.getElementById('notification-yous-only');
	const toggleNotificationYousOnlySetting = () => {
		notificationYousOnly = !notificationYousOnly;
		setLocalStorage('notification-yous-only', notificationYousOnly);
		console.log('toggling notification only for yous', yousEnabled);
	};
	notificationYousOnlySetting.checked = notificationYousOnly;
	notificationYousOnlySetting.addEventListener('change', toggleNotificationYousOnlySetting, false);

	const notificationSetting = document.getElementById('notification-setting');
	const toggleNotifications = async () => {
		notificationsEnabled = !notificationsEnabled;
		if (notificationsEnabled) {
			const result = await Notification.requestPermission();
			if (result != 'granted') {
				//user denied permission popup
				notificationsEnabled = false;
				notificationSetting.checked = false;
				return;
			}
		}
		console.log('toggling notifications', notificationsEnabled);
		setLocalStorage('notifications', notificationsEnabled);
	};
	notificationSetting.checked = notificationsEnabled;
	notificationSetting.addEventListener('change', toggleNotifications, false);

});


code:
'use strict';

const config = require(__dirname+'/lib/misc/config.js')
	, { Binary } = require('mongodb')
	, Permission = require(__dirname+'/lib/permission/permission.js')
	, { Permissions } = require(__dirname+'/lib/permission/permissions.js')
	, { hcaptcha, google, yandex } = require(__dirname+'/configs/secrets.js')
	, gulp = require('gulp')
//	, pugRuntime = require('pug-runtime/build')
	, fs = require('fs-extra')
	, semver = require('semver')
	, uploadDirectory = require(__dirname+'/lib/file/uploaddirectory.js')
	, commit = require(__dirname+'/lib/misc/commit.js')
	, replace = require('gulp-replace')
	, less = require('gulp-less')
	, concat = require('gulp-concat')
	, cleanCSS = require('gulp-clean-css')
	, uglify = require('gulp-uglify-es').default
	, realFavicon = require('gulp-real-favicon')
	, del = require('del')
	, pug = require('pug')
	, gulppug = require('gulp-pug')
	, { migrateVersion, version } = require(__dirname+'/package.json')
	, { randomBytes } = require('crypto')
	, Redis = require(__dirname+'/lib/redis/redis.js')
	, Mongo = require(__dirname+'/db/db.js')
	, paths = {
		styles: {
			src: 'gulp/res/css/',
			dest: 'static/css/'
		},
		images: {
			src: 'gulp/res/img/*',
			dest: 'static/file/'
		},
		icons: {
			src: 'gulp/res/icons/*',
			dest: 'static/file/'
		},
		scripts: {
			src: 'gulp/res/js',
			dest: 'static/js/'
		},
		pug: {
			src: 'views/',
			dest: 'static/html/'
		}
	};

// File where the favicon markups are stored
var FAVICON_DATA_FILE = 'gulp/res/icons/faviconData.json';

// Generate the icons. This task takes a few seconds to complete.
// You should run it at least once to create the icons. Then,
// you should run it whenever RealFaviconGenerator updates its
// package (see the check-for-favicon-update task below).
gulp.task('generate-favicon', function(done) {
	realFavicon.generateFavicon({
		masterPicture: 'gulp/res/icons/master.png',
		dest: 'gulp/res/icons',
		iconsPath: '/file',
		design: {
			ios: {
				pictureAspect: 'backgroundAndMargin',
				backgroundColor: '#ffffff',
				margin: '14%',
				assets: {
					ios6AndPriorIcons: false,
					ios7AndLaterIcons: false,
					precomposedIcons: false,
					declareOnlyDefaultIcon: true
				}
			},
			desktopBrowser: {
				design: 'raw'
			},
			windows: {
				pictureAspect: 'whiteSilhouette',
				backgroundColor: '#da532c',
				onConflict: 'override',
				assets: {
					windows80Ie10Tile: false,
					windows10Ie11EdgeTiles: {
						small: false,
						medium: true,
						big: false,
						rectangle: false
					}
				}
			},
			androidChrome: {
				pictureAspect: 'shadow',
				themeColor: '#ffffff',
				manifest: {
					display: 'standalone',
					orientation: 'notSet',
					onConflict: 'override',
					declared: true
				},
				assets: {
					legacyIcon: false,
					lowResolutionIcons: false
				}
			},
			safariPinnedTab: {
				pictureAspect: 'blackAndWhite',
				threshold: 30,
				themeColor: '#990000'
			}
		},
		settings: {
			scalingAlgorithm: 'Lanczos',
			errorOnImageTooSmall: false,
			readmeFile: false,
			htmlCodeFile: true,
			usePathAsIs: false
		},
		versioning: {
			paramName: 'v',
			paramValue: commit
		},
		markupFile: FAVICON_DATA_FILE
	}, function() {
		done();
	});
});

// Check for updates on RealFaviconGenerator (think: Apple has just
// released a new Touch icon along with the latest version of iOS).
// Run this task from time to time. Ideally, make it part of your
// continuous integration system.
gulp.task('check-for-favicon-update', function(done) {
	var currentVersion = JSON.parse(fs.readFileSync(FAVICON_DATA_FILE)).version;
	realFavicon.checkForUpdates(currentVersion, function(err) {
		if (err) {
			throw err;
		}
		done();
	});
});

async function password() {
	const { Accounts } = require(__dirname+'/db/');
	const randomPassword = randomBytes(20).toString('base64');
	await Accounts.changePassword('admin', randomPassword);
	const ROOT = new Permission();
	ROOT.setAll(Permission.allPermissions);
	await Accounts.setAccountPermissions('admin', ROOT);
	console.log('=====LOGIN DETAILS=====\nusername: admin\npassword:', randomPassword, '\n=======================');
}

async function ips() {
	const { func: ipSchedule } = require(__dirname+'/schedules/tasks/ips.js');
	await ipSchedule();
}

async function wipe() {
	const db = Mongo.db;

	const defaultConfig = require(__dirname+'/configs/template.js.example');
	await Mongo.setConfig(defaultConfig);

	const collectionNames = ['accounts', 'bans', 'custompages', 'boards', 'captcha', 'files',
		'modlog', 'filters', 'news', 'posts', 'poststats', 'ratelimit', 'bypass', 'roles'];
	for (const name of collectionNames) {
		//drop collection so gulp reset can be run again. ignores error of dropping non existing collection first time
		await db.dropCollection(name).catch(() => {});
		await db.createCollection(name);
	}

	const { Boards, Posts, Captchas, Ratelimits, News, CustomPages,
		Accounts, Files, Stats, Modlogs, Filters, Bans, Bypass, Roles } = require(__dirname+'/db/');

	//wipe db shit
	await Promise.all([
		Redis.deletePattern('*'),
		Captchas.deleteAll(),
		Ratelimits.deleteAll(),
		Accounts.deleteAll(),
		Roles.deleteAll(),
		Posts.deleteAll(),
		Boards.deleteAll(),
		Bans.deleteAll(),
		Files.deleteAll(),
		Stats.deleteAll(),
		Modlogs.deleteAll(),
		Bypass.deleteAll(),
		News.deleteAll(),
		Filters.deleteAll(),
	]);

	//add indexes - should profiled and changed at some point if necessary
	await Stats.db.createIndex({board:1, hour:1});
	await Boards.db.createIndex({ips: 1, pph:1, sequence_value:1});
	await Boards.db.createIndex({tags: 1});
	await Boards.db.createIndex({uri: 1});
	await Boards.db.createIndex({lastPostTimestamp:1});
	await Roles.db.dropIndexes();
	await Bans.db.dropIndexes();
	await Captchas.db.dropIndexes();
	await Ratelimits.db.dropIndexes();
	await Posts.db.dropIndexes();
	await Modlogs.db.dropIndexes();
	await CustomPages.db.dropIndexes();
	await Filters.db.dropIndexes();
	await CustomPages.db.createIndex({ 'board': 1, 'page': 1 }, { unique: true });
	await Roles.db.createIndex({ 'permissions': 1 }, { unique: true });
	await Modlogs.db.createIndex({ 'board': 1 });
	await Files.db.createIndex({ 'count': 1 });
	await Filters.db.createIndex({ 'board': 1 });
	await Bans.db.createIndex({ 'ip.cloak': 1 , 'board': 1 });
	await Bans.db.createIndex({ 'expireAt': 1 }, { expireAfterSeconds: 0 }); //custom expiry, i.e. it will expire when current date > than this date
	await Bypass.db.createIndex({ 'expireAt': 1 }, { expireAfterSeconds: 0 });
	await Captchas.db.createIndex({ 'expireAt': 1 }, { expireAfterSeconds: 300 }); //captchas valid for 5 minutes
	await Ratelimits.db.createIndex({ 'expireAt': 1 }, { expireAfterSeconds: 60 }); //per minute captcha ratelimit
	await Posts.db.createIndex({ 'postId': 1,'board': 1,});
	await Posts.db.createIndex({ 'board': 1,	'thread': 1, 'bumped': -1 });
	await Posts.db.createIndex({ 'board': 1, 'reports.0': 1 }, { 'partialFilterExpression': { 'reports.0': { '$exists': true } } });
	await Posts.db.createIndex({ 'globalreports.0': 1 }, { 'partialFilterExpression': {	'globalreports.0': { '$exists': true } } });

	const ANON = new Permission();
	ANON.setAll([
		Permissions.USE_MARKDOWN_PINKTEXT, Permissions.USE_MARKDOWN_GREENTEXT, Permissions.USE_MARKDOWN_BOLD,
		Permissions.USE_MARKDOWN_UNDERLINE, Permissions.USE_MARKDOWN_STRIKETHROUGH, Permissions.USE_MARKDOWN_TITLE,
		Permissions.USE_MARKDOWN_ITALIC, Permissions.USE_MARKDOWN_SPOILER, Permissions.USE_MARKDOWN_MONO,
		Permissions.USE_MARKDOWN_CODE, Permissions.USE_MARKDOWN_DETECTED, Permissions.USE_MARKDOWN_LINK,
		Permissions.USE_MARKDOWN_DICE, Permissions.USE_MARKDOWN_FORTUNE, Permissions.CREATE_BOARD,
		Permissions.CREATE_ACCOUNT
	]);
	const BOARD_STAFF = new Permission(ANON.base64);
	BOARD_STAFF.setAll([
		Permissions.MANAGE_BOARD_GENERAL, Permissions.MANAGE_BOARD_BANS, Permissions.MANAGE_BOARD_LOGS,
	]);
	const BOARD_OWNER = new Permission(BOARD_STAFF.base64);
	BOARD_OWNER.setAll([
		Permissions.MANAGE_BOARD_OWNER, Permissions.MANAGE_BOARD_STAFF, Permissions.MANAGE_BOARD_CUSTOMISATION,
		Permissions.MANAGE_BOARD_SETTINGS,
	]);
	const GLOBAL_STAFF = new Permission(BOARD_OWNER.base64);
	GLOBAL_STAFF.setAll([
		Permissions.MANAGE_GLOBAL_GENERAL, Permissions.MANAGE_GLOBAL_BANS, Permissions.MANAGE_GLOBAL_LOGS, Permissions.MANAGE_GLOBAL_NEWS,
		Permissions.MANAGE_GLOBAL_BOARDS, Permissions.MANAGE_GLOBAL_SETTINGS, Permissions.MANAGE_BOARD_OWNER, Permissions.BYPASS_FILTERS,
		Permissions.BYPASS_BANS, Permissions.BYPASS_SPAMCHECK, Permissions.BYPASS_RATELIMITS,
	]);
	const ADMIN = new Permission(GLOBAL_STAFF.base64);
	ADMIN.setAll([
		Permissions.MANAGE_GLOBAL_ACCOUNTS, Permissions.MANAGE_GLOBAL_ROLES, Permissions.VIEW_RAW_IP,
	]);
	const ROOT = new Permission();
	ROOT.setAll(Permission.allPermissions);
	await Roles.db.insertMany([
		{ name: 'ANON', permissions: Binary(ANON.array) },
		{ name: 'BOARD_STAFF', permissions: Binary(BOARD_STAFF.array) },
		{ name: 'BOARD_OWNER', permissions: Binary(BOARD_OWNER.array) },
		{ name: 'GLOBAL_STAFF', permissions: Binary(GLOBAL_STAFF.array) },
		{ name: 'ADMIN', permissions: Binary(ADMIN.array) },
		{ name: 'ROOT', permissions: Binary(ROOT.array) },
	]);

	const randomPassword = randomBytes(20).toString('base64');
	await Accounts.insertOne('admin', 'admin', randomPassword, ROOT);
	console.log('=====LOGIN DETAILS=====\nusername: admin\npassword:', randomPassword, '\n=======================');

	await db.collection('version').replaceOne({
		'_id': 'version'
	}, {
		'_id': 'version',
		'version': migrateVersion
	}, {
		upsert: true
	});

	await Promise.all([
		del([ 'static/file/*' ]),
		del([ 'static/captcha/*' ]),
		del([ 'static/html/*' ]),
		del([ 'static/json/*' ]),
		del([ 'static/banner/*' ]),
		del([ 'static/flag/*' ]),
		del([ 'static/asset/*' ]),
		del([ 'static/css/*' ]),
		del([ 'static/js/*' ]),
	]);

	return Promise.all([
		fs.ensureDir(`${uploadDirectory}/captcha`),
		fs.ensureDir(`${uploadDirectory}/file/thumb`),
		fs.ensureDir(paths.scripts.dest),
	]);

}

//update the css file
async function css() {
	try {
		//a little more configurable
		let bypassHeight
			, captchaHeight
			, captchaWidth;
		switch (config.get.captchaOptions.type) {
			case 'google':
			case 'hcaptcha':
				bypassHeight = 500;
				captchaHeight = 200;
				captchaWidth = 200;
				break;
			case 'yandex':
				bypassHeight = 500;
				captchaWidth = 300;
				captchaHeight = 82;
				break;
			case 'grid':
			case 'grid2':
				bypassHeight = 330;
				captchaHeight = config.get.captchaOptions.grid.imageSize+30;
				captchaWidth = config.get.captchaOptions.grid.imageSize+30;
				break;
			case 'text':
				bypassHeight = 235;
				captchaHeight = 80;
				captchaWidth = 210;
				break;
		}
		const cssLocals = `:root {
    --attachment-img: url('/file/attachment.png');
    --spoiler-img: url('/file/spoiler.png');
    --audio-img: url('/file/audio.png');
    --captcha-grid-size: ${'1fr '.repeat(config.get.captchaOptions.grid.size)};
    --thumbnail-size: ${config.get.thumbSize}px;
    --captcha-w: ${captchaWidth}px;
    --captcha-h: ${captchaHeight}px;
    --bypass-height: ${bypassHeight}px;
}`;
		fs.writeFileSync('gulp/res/css/locals.css', cssLocals);
		fs.symlinkSync(__dirname+'/node_modules/highlight.js/styles', __dirname+'/gulp/res/css/codethemes', 'dir');
	} catch (e) {
		if (e.code !== 'EEXIST') {
			//already exists, ignore error
			console.log(e);
		}
	}
	//move themes css to output folder
	await gulp.src([
		`${paths.styles.src}/themes/*.css`,
	])
		.pipe(less())
		.pipe(cleanCSS())
		.pipe(gulp.dest(`${paths.styles.dest}/themes/`));
	//replace url( in codethemes to correct basepath of images, and move to output folder
	await gulp.src([
		`${paths.styles.src}/codethemes/*.css`,
	])
		.pipe(replace('url(./', 'url(/css/codethemes/assets/'))
		.pipe(less())
		.pipe(cleanCSS())
		.pipe(gulp.dest(`${paths.styles.dest}/codethemes/`));

	//move assets for code codethemes/assets folder
	await gulp.src([
		`${paths.styles.src}/codethemes/*`,
		`!${paths.styles.src}/codethemes/*.css`,
	])
		.pipe(gulp.dest(`${paths.styles.dest}/codethemes/assets/`));
	//move assets for themes to theme/assets folder
	await gulp.src([
		`${paths.styles.src}/themes/assets/*`,
	])
		.pipe(gulp.dest(`${paths.styles.dest}/themes/assets/`));

	await gulp.src([
		`${paths.styles.src}/locals.css`,
		`${paths.styles.src}/nscaptcha.css`,
	])
		.pipe(concat('nscaptcha.css'))
		.pipe(less())
		.pipe(cleanCSS())
		.pipe(gulp.dest(paths.styles.dest));
	return gulp.src([
		`${paths.styles.src}/locals.css`,
		`${paths.styles.src}/style.css`,
		`${paths.styles.src}/tegaki.css`, //make sure any custom.css also goes after this
		`${paths.styles.src}/*.css`,
		`!${paths.styles.src}/nscaptcha.css`,
	])
		.pipe(concat('style.css'))
		.pipe(less())
		.pipe(cleanCSS())
		.pipe(gulp.dest(paths.styles.dest));
}

//spoiler/deleted image, default banner, spoiler/sticky/sage/cycle icons
function images() {
	return gulp.src(paths.images.src)
		.pipe(gulp.dest(paths.images.dest));
}

//favicon/safari/chrome/mstiles, etc
function icons() {
	return gulp.src(paths.icons.src)
		.pipe(gulp.dest(paths.icons.dest));
}

async function cache() {
	await Promise.all([
		Redis.deletePattern('boards:listed'),
		Redis.deletePattern('board:*'),
		Redis.deletePattern('boardlist:*'),
		Redis.deletePattern('banners:*'),
		Redis.deletePattern('users:*'),
		Redis.deletePattern('blacklisted:*'),
		Redis.deletePattern('overboard:*'),
		Redis.deletePattern('catalog:*'),
		Redis.deletePattern('webringsites'),
	]);
}

function deletehtml() {
	return del([ 'static/html/*' ]);
}

async function custompages() {
	const formatSize = require(__dirname+'/lib/converter/formatsize.js')
		, i18n = require(__dirname+'/lib/locale/locale.js')
		, locals = {
			Permissions,
			early404Fraction: config.get.early404Fraction,
			early404Replies: config.get.early404Replies,
			meta: config.get.meta,
			ethereumLinksURL: config.get.ethereumLinksURL,
			archiveLinksURL: config.get.archiveLinksURL,
			reverseImageLinksURL: config.get.reverseImageLinksURL,
			enableWebring: config.get.enableWebring,
			globalLimits: config.get.globalLimits,
			codeLanguages: config.get.highlightOptions.languageSubset,
			defaultTheme: config.get.boardDefaults.theme,
			defaultCodeTheme: config.get.boardDefaults.codeTheme,
			postFilesSize: formatSize(config.get.globalLimits.postFilesSize.max),
			googleRecaptchaSiteKey: google ? google.siteKey : '',
			hcaptchaSiteKey: hcaptcha ? hcaptcha.siteKey : '',
			yandexSiteKey: yandex ? yandex.siteKey : '',
			globalAnnouncement: config.get.globalAnnouncement,
			captchaOptions: config.get.captchaOptions,
			enableWeb3: config.get.enableWeb3,
			commit,
			version,
			globalLanguage: config.get.language,
		};
	i18n.init(locals);
	locals.setLocale(locals, config.get.language);
	return gulp.src([
		`${paths.pug.src}/custompages/*.pug`,
		`${paths.pug.src}/pages/404.pug`,
		`${paths.pug.src}/pages/500.pug`,
		`${paths.pug.src}/pages/502.pug`,
		`${paths.pug.src}/pages/503.pug`,
		`${paths.pug.src}/pages/504.pug`
	])
		.pipe(gulppug({ locals }))
		.pipe(gulp.dest(paths.pug.dest));
}

async function langs() {
	const i18n = require(__dirname+'/lib/locale/locale.js');
	await del([ 'static/js/lang/' ]);
	fs.mkdirSync(`${paths.scripts.dest}lang/`, { recursive: true });
	const feStrings = require(__dirname+'/tools/festrings.json');
	Object.entries(i18n.getCatalog())
		.forEach(entry => {
			const [lang, dict] = entry;
			const minimalDict = feStrings.reduce((acc, key) => {
				acc[key] = dict[key];
				return acc;
			}, {});
			const langScript = `const LANG = '${lang}';
const TRANSLATIONS = ${JSON.stringify(minimalDict)};`;
			fs.writeFileSync(`${paths.scripts.dest}lang/${lang}.js`, langScript);
		});
}

async function scripts() {
	const { themes, codeThemes } = require(__dirname+'/lib/misc/themes.js');
	try {

		// compile some locals/variables needed from configs in fe scripts
		const captchaOptions = config.get.captchaOptions;
		//smaller set of captchaoptions needed for some frontend scripts to build includes
		const reducedCaptchaOptions = {
			grid: {
				size: captchaOptions.grid.size,
				question: captchaOptions.grid.question,
			},
			type: captchaOptions.type,
		};
		const locals = `const themes = ['${themes.join('\', \'')}'];
const codeThemes = ['${codeThemes.join('\', \'')}'];
const captchaOptions = ${JSON.stringify(reducedCaptchaOptions)};
const SERVER_TIMEZONE = '${Intl.DateTimeFormat().resolvedOptions().timeZone}';
const settings = ${JSON.stringify(config.get.frontendScriptDefault)};
const extraLocals = ${JSON.stringify({ meta: config.get.meta, reverseImageLinksURL: config.get.reverseImageLinksURL, ethereumLinksURL: config.get.ethereumLinksURL })};
`;
		fs.writeFileSync('gulp/res/js/locals.js', locals);

//		const pugRuntimeFuncs = pugRuntime(['classes', 'style', 'attr', 'escape']);
//		fs.writeFileSync('gulp/res/js/pugruntime.js', pugRuntimeFuncs);

		//compile some pug client side functions
		['modal', 'post', 'uploaditem', 'pugfilters', 'captchaformsection', 'watchedthread', 'threadwatcher', 'banmessage']
			.forEach(templateName => {
				const compilationOptions = {
					compileDebug: false,
					debug: false,
					name: templateName,
					inlineRuntimeFunctions: true, //note pugRuntime above, will fix pending issue open on pug github
				};
				const compiledClient = pug.compileFileClient(`${paths.pug.src}includes/${templateName}.pug`, compilationOptions);
				fs.writeFileSync(`gulp/res/js/${templateName}.js`, compiledClient);
			});

		//symlink web3
		await fs.symlink(__dirname+'/node_modules/web3/dist/web3.min.js', __dirname+'/gulp/res/js/web3.js', 'file')
			.catch(e => { console.warn(e); });
		//symlink socket.io file
		await fs.symlink(__dirname+'/node_modules/socket.io/client-dist/socket.io.min.js', __dirname+'/gulp/res/js/socket.io.js', 'file')
			.catch(e => { console.warn(e); });

	} catch (e) {
		console.log(e);
	}

	gulp.src([
		//put scripts in order for dependencies
		`${paths.scripts.src}/locals.js`,
		`${paths.scripts.src}/i18n.js`,
		`${paths.scripts.src}/localstorage.js`,
//		`${paths.scripts.src}/pugruntime.js`,
		`${paths.scripts.src}/modal.js`,
		`${paths.scripts.src}/pugfilters.js`,
		`${paths.scripts.src}/banmessage.js`,
		`${paths.scripts.src}/post.js`,
		`${paths.scripts.src}/settings.js`,
		`${paths.scripts.src}/live.js`,
		`${paths.scripts.src}/captcha.js`,
		`${paths.scripts.src}/tegaki.js`,
		`${paths.scripts.src}/forms.js`,
		`${paths.scripts.src}/*.js`,
		`!${paths.scripts.src}/saveoverboard.js`,
		`!${paths.scripts.src}/hidefileinput.js`,
		`!${paths.scripts.src}/dragable.js`,
		`!${paths.scripts.src}/watchlist.js`,
		`!${paths.scripts.src}/filters.js`,
		`!${paths.scripts.src}/hideimages.js`,
		`!${paths.scripts.src}/yous.js`,
		`!${paths.scripts.src}/catalog.js`,
		`!${paths.scripts.src}/time.js`,
		`!${paths.scripts.src}/timezone.js`,
		`!${paths.scripts.src}/renderweb3.js`,
	])
		.pipe(concat('all.js'))
		.pipe(uglify({compress:true}))
		.pipe(gulp.dest(paths.scripts.dest));

	gulp.src([
		`${paths.scripts.src}/web3.js`,
	])
		.pipe(concat('web3.js'))
		// .pipe(uglify({compress:true})) //No need, we symlink from web3.min.js
		.pipe(gulp.dest(paths.scripts.dest));

	return gulp.src([
		`${paths.scripts.src}/saveoverboard.js`,
		`${paths.scripts.src}/hidefileinput.js`,
		`${paths.scripts.src}/dragable.js`,
		`${paths.scripts.src}/hideimages.js`,
		`${paths.scripts.src}/yous.js`,
		`${paths.scripts.src}/filters.js`,
		`${paths.scripts.src}/watchlist.js`,
		`${paths.scripts.src}/catalog.js`,
		`${paths.scripts.src}/time.js`,
		`${paths.scripts.src}/renderweb3.js`,
	])
		.pipe(concat('render.js'))
		.pipe(uglify({compress:true}))
		.pipe(gulp.dest(paths.scripts.dest));

}

async function migrate() {
	const db = Mongo.db;

	//get current version from db if present (set in 'reset' task in recent versions)
	let currentVersion = await db.collection('version').findOne({
		'_id': 'version'
	}).then(res => res ? res.version : '0.0.0'); // 0.0.0 for old versions

	if (semver.lt(currentVersion, migrateVersion)) {
		console.log(`Current version: ${currentVersion}`);
		const migrations = require(__dirname+'/migrations/');
		const migrationVersions = Object.keys(migrations)
			.sort(semver.compare)
			.filter(v => semver.gt(v, currentVersion));
		console.log(`Migrations needed: ${currentVersion} -> ${migrationVersions.join(' -> ')}`);
		for (let ver of migrationVersions) {
			console.log(`=====\nStarting migration to version ${ver}`);
			try {
				await migrations[ver](db, Redis);
				await db.collection('version').replaceOne({
					'_id': 'version'
				}, {
					'_id': 'version',
					'version': ver
				}, {
					upsert: true
				});
			} catch (e) {
				console.error(e);
				console.warn(`Migration to ${ver} encountered an error`);
				process.exit(1);
			}
			console.log(`Finished migrating to version ${ver}`);
		}
	} else {
		console.log(`Migration not required, you are already on the current version (${migrateVersion})`);
	}
}

async function init() {
	const defaultConfig = require(__dirname+'/configs/template.js.example');
	await Mongo.connect();
	const globalSettings = await Mongo.getConfig();
	if (!globalSettings) {
		await Mongo.setConfig(defaultConfig);
	}
	await config.load();
}

async function closeConnections() {
	Redis.close();
	if (Mongo.client) {
		await Mongo.client.close();
	}
}

const build = gulp.parallel(gulp.series(scripts, langs, css), images, icons, gulp.series(deletehtml, custompages));

//godhelpme
module.exports = {
	'generate-favicon': gulp.series('generate-favicon', closeConnections),
	html: gulp.series(init, deletehtml, custompages, closeConnections),
	css: gulp.series(init, css, closeConnections),
	images: gulp.series(images, closeConnections),
	icons: gulp.series('check-for-favicon-update', icons, closeConnections),
	reset: gulp.series(init, wipe, build, closeConnections),
	custompages: gulp.series(init, custompages, closeConnections),
	scripts: gulp.series(init, scripts, closeConnections),
	cache: gulp.series(cache, closeConnections),
	migrate: gulp.series(init, migrate, closeConnections),
	password: gulp.series(init, password, closeConnections),
	langs: gulp.series(init, langs, closeConnections),
	ips: gulp.series(init, ips, closeConnections),
	default: gulp.series(init, build, closeConnections),
	buildTasks: { //dont include init, etc
		deletehtml,
		css,
		scripts,
		custompages,
	}
};


code:
'use strict';

const Queue = require('bull')
	, { redis } = require(__dirname+'/../../configs/secrets.js')
	, taskQueue = new Queue('task', { redis });

module.exports = {

	queue: taskQueue,

	push: (data, options) => {
		taskQueue.add(data, { ...options, removeOnComplete: true});
	}

};


code:
'use strict';

const { outputFile } = require('fs-extra')
	, formatSize = require(__dirname+'/../converter/formatsize.js')
	, pug = require('pug')
	, path = require('path')
	, commit = require(__dirname+'/../misc/commit.js')
	, uploadDirectory = require(__dirname+'/../file/uploaddirectory.js')
	, { yandex, hcaptcha, google } = require(__dirname+'/../../configs/secrets.js')
	, redlock = require(__dirname+'/../redis/redlock.js')
	, { addCallback } = require(__dirname+'/../redis/redis.js')
	, { version } = require(__dirname+'/../../package.json')
	, templateDirectory = path.join(__dirname+'/../../views/pages/')
	, { Permissions } = require(__dirname+'/../permission/permissions.js')
	, i18n = require(__dirname+'/../locale/locale.js')
	, config = require(__dirname+'/../../lib/misc/config.js');

let { language, archiveLinksURL, ethereumLinksURL, lockWait, globalLimits, boardDefaults, cacheTemplates,
		reverseImageLinksURL, meta, enableWebring, captchaOptions, globalAnnouncement, enableWeb3 } = config.get
	, renderLocals = null;

const updateLocals = () => {
	({ language, archiveLinksURL, ethereumLinksURL, lockWait, globalLimits, boardDefaults, cacheTemplates,
		reverseImageLinksURL, meta, enableWebring, captchaOptions, globalAnnouncement, enableWeb3 } = config.get);
	renderLocals = {
		Permissions,
		cache: cacheTemplates,
		ethereumLinksURL,
		archiveLinksURL,
		reverseImageLinksURL,
		meta,
		commit,
		version,
		enableWeb3,
		defaultTheme: boardDefaults.theme,
		defaultCodeTheme: boardDefaults.codeTheme,
		postFilesSize: formatSize(globalLimits.postFilesSize.max),
		globalLimits,
		enableWebring,
		googleRecaptchaSiteKey: google ? google.siteKey : '',
		hcaptchaSiteKey: hcaptcha ? hcaptcha.siteKey : '',
		yandexSiteKey: yandex ? yandex.siteKey : '',
		captchaOptions,
		globalAnnouncement,
		globalLanguage: language,
	};
	i18n.init(renderLocals);
	renderLocals.setLocale(renderLocals, language);
};

updateLocals();
addCallback('config', updateLocals);

module.exports = async (htmlName=null, templateName=null, options=null, json=null) => {

	//generate html if applicable
	let html = null;
	if (templateName !== null) {
		const mergedLocals = {
			...options,
			...renderLocals,
		};
		//NOTE: will this cause issues with global locale?
		if (options && options.board && options.board.settings) {
			renderLocals.setLocale(renderLocals, options.board.settings.language);
		} else {
			renderLocals.setLocale(renderLocals, language);
		}
		html = pug.renderFile(`${templateDirectory}${templateName}`, mergedLocals);
	}

	//lock to prevent concurrent disk write
	const lock = await redlock.lock(`locks:${htmlName || json.name}`, lockWait);

	//write html/jsons
	let htmlPromise, jsonPromise;
	if (html !== null) {
		htmlPromise= outputFile(`${uploadDirectory}/html/${htmlName}`, html);
	}
	if (json !== null) {
		jsonPromise = outputFile(`${uploadDirectory}/json/${json.name}`, JSON.stringify(json.data));
	}
	await Promise.all([htmlPromise, jsonPromise]);

	//unlock after finishing
	await lock.unlock();

	return { html, json: json ? json.data : null };

};


code:
'use strict';

const timeUtils = require(__dirname+'/../converter/timeutils.js')
	, uploadDirectory = require(__dirname+'/../file/uploaddirectory.js')
	, { remove } = require('fs-extra')
	, config = require(__dirname+'/../misc/config.js')
	, { debugLogs } = require(__dirname+'/../../configs/secrets.js')
	, { CustomPages, Stats, Posts, Files, Boards, News, Modlogs } = require(__dirname+'/../../db/')
	, cache = require(__dirname+'/../redis/redis.js')
	, render = require(__dirname+'/../build/render.js')
	, buildQueue = require(__dirname+'/../build/queue.js')
	, gulp = require('gulp')
	, { buildTasks } = require(__dirname+'/../../gulpfile.js')
	, { timeDiffString } = require(__dirname+'/../converter/timeutils.js');

module.exports = {

	gulp: async (options) => {
		const label = `running gulp tasks [${options.tasks.join(', ')}] after global config change`;
		const start = process.hrtime();
		gulp.series(options.tasks.map(t => buildTasks[t]), () => {
			const end = process.hrtime(start);
			debugLogs && console.log(timeDiffString(label, end));
		})();
	},

	buildBanners: async (options) => {
		const label = `/${options.board._id}/banners.html`;
		const start = process.hrtime();
		options.managePage = 'assets.html';
		const { html, json } = await render(label, 'banners.pug', options, {
			'name': `/${options.board._id}/banners.json`,
			'data': options.board.banners
		});
		const end = process.hrtime(start);
		debugLogs && console.log(timeDiffString(label, end));
		return { html, json };
	},

	buildBoardSettings: async (options) => {
		const label = `/${options.board._id}/settings.json`;
		const start = process.hrtime();
		const customPages = await CustomPages.find(options.board._id);
		const bs = options.board.settings;
		const projectedSettings = {
			customPages: customPages.map(p => p.page), //list of custompage names
			announcement: bs.announcement,
			allowedFileTypes: bs.allowedFileTypes,
			maxFiles: bs.maxFiles,
			captchaMode: bs.captchaMode,
			forceAnon: bs.forceAnon,
			sageOnlyEmail: bs.sageOnlyEmail,
			customFlags: bs.customFlags,
			forceThreadMessage: bs.forceThreadMessage,
			forceThreadFile: bs.forceThreadFile,
			forceThreadSubject: bs.forceThreadSubject,
			disableReplySubject: bs.disableReplySubject,
			minThreadMessageLength: bs.minThreadMessageLength,
			minReplyMessageLength: bs.minReplyMessageLength,
			maxThreadMessageLength: bs.maxThreadMessageLength,
			maxReplyMessageLength: bs.maxReplyMessageLength,
			defaultName: bs.defaultName,
			language: bs.language,
		};
		const { json } = await render(null, null, null, {
			'name': `/${options.board._id}/settings.json`,
			'data': projectedSettings
		});
		const end = process.hrtime(start);
		debugLogs && console.log(timeDiffString(label, end));
		return json;
	},

	buildGlobalSettings: async () => {
		const label = '/settings.json';
		const start = process.hrtime();
		const { captchaOptions: co, language } = config.get;
		const projectedSettings = {
			captchaOptions: {
				language: language,
				type: co.type,
				grid: {
					size: co.grid.size,
					question: co.grid.question,
				}
			}
		};
		const { json } = await render(null, null, null, {
			name: '/settings.json',
			data: projectedSettings
		});
		const end = process.hrtime(start);
		debugLogs && console.log(timeDiffString(label, end));
		return json;
	},

	buildCatalog: async (options) => {
		const label = `/${options.board._id || options.board}/catalog.html`;
		const start = process.hrtime();
		if (!options.board._id) {
			options.board = await Boards.findOne(options.board);
		}
		options.managePage = 'catalog.html';
		const threads = await Posts.getCatalog(options.board._id);
		const { html, json } = await render(label, 'catalog.pug', {
			...options,
			threads,
		}, {
			'name': `/${options.board._id}/catalog.json`,
			'data': threads
		});
		const end = process.hrtime(start);
		debugLogs && console.log(timeDiffString(label, end));
		return { html, json };
	},

	buildThread: async (options) => {
		const label = `/${options.board._id || options.board}/thread/${options.threadId}.html`;
		const start = process.hrtime();
		if (!options.board._id) {
			options.board = await Boards.findOne(options.board);
		}
		options.managePage = `thread/${options.threadId}.html`;
		const thread = await Posts.getThread(options.board._id, options.threadId);
		if (!thread) {
			return; //this thread may have been an OP that was deleted
		}
		const { html, json } = await render(label, 'thread.pug', {
			...options,
			thread,
		}, {
			'name': `/${options.board._id}/thread/${options.threadId}.json`,
			'data': thread
		});
		const end = process.hrtime(start);
		debugLogs && console.log(timeDiffString(label, end));
		return { html, json };
	},

	buildBoard: async (options) => {
		const label = `/${options.board._id}/${options.page === 1 ? 'index' : options.page}.html`;
		const start = process.hrtime();
		const threads = await Posts.getRecent(options.board._id, options.page);
		if (!options.maxPage) {
			options.maxPage = Math.min(Math.ceil((await Posts.getPages(options.board._id)) / 10), Math.ceil(options.board.settings.threadLimit/10));
		}
		const pageText = options.page === 1 ? 'index' : options.page;
		options.managePage = `${pageText}.html`;
		const { html, json } = await render(label, 'board.pug', {
			...options,
			threads,
		}, {
			'name': `/${options.board._id}/${pageText}.json`,
			'data': threads
		});
		const end = process.hrtime(start);
		debugLogs && console.log(timeDiffString(label, end));
		return { html, json };
	},

	//building multiple pages (for rebuilds)
	buildBoardMultiple: async (options) => {
		const start = process.hrtime();
		if (!options.board._id) {
			options.board = await Boards.findOne(options.board);
		}
		const maxPage = Math.min(Math.ceil((await Posts.getPages(options.board._id)) / 10), Math.ceil(options.board.settings.threadLimit/10)) || 1;
		if (options.endpage === 0) {
			//deleted only/all posts, so only 1 page will remain
			options.endpage = 1;
		} else if (maxPage < options.endpage || !options.endpage) {
			//else just build up to the max page if it is greater than input page number
			options.endpage = maxPage;
		}
		const difference = options.endpage-options.startpage + 1; //+1 because for single pagemust be > 0
		const threads = await Posts.getRecent(options.board._id, options.startpage, difference*10);
		const label = `/${options.board._id}/${options.startpage === 1 ? 'index' : options.startpage}${options.endpage === options.startpage ? '' : '->'+(options.endpage === 1 ? 'index' : options.endpage)}.html`;
		const buildArray = [];
		for (let i = options.startpage; i <= options.endpage; i++) {
			let spliceStart = (i-1)*10;
			if (spliceStart > 0) {
				spliceStart = spliceStart - 1;
			}
			const pageThreads = threads.splice(0,10);
			const pageText = i === 1 ? 'index' : i;
			buildArray.push(
				render(`${options.board._id}/${pageText}.html`, 'board.pug', {
					board: options.board,
					threads: pageThreads,
					maxPage,
					page: i,
					managePage: `${pageText}.html`,
				}, {
					'name': `/${options.board._id}/${i === 1 ? 'index' : i}.json`,
					'data': pageThreads
				})
			);
		}
		await Promise.all(buildArray);
		const end = process.hrtime(start);
		debugLogs && console.log(timeDiffString(label, end));
	},

	buildNews: async () => {
		const label = '/news.html';
		const start = process.hrtime();
		const news = await News.find();
		const { html } = await render('news.html', 'news.pug', {
			news
		});
		const end = process.hrtime(start);
		debugLogs && console.log(timeDiffString(label, end));
		return html;
	},

	buildCustomPage: async (options) => {
		const label = `/${options.board._id || options.board}/custompage/${options.page}.html`;
		const start = process.hrtime();
		if (!options.customPage) {
			const customPage = await CustomPages.findOne(options.board._id || options.board, options.page);
			if (!customPage) {
				return {};
			}
			options.customPage = customPage;
		}
		if (!options.board._id) {
			options.board = await Boards.findOne(options.board);
		}
		options.managePage = 'custompages.html';
		const { html, json } = await render(label, 'custompage.pug', {
			...options,
		}, {
			name: `/${options.board._id}/custompage/${options.page}.json`,
			data: options.customPage
		});
		const end = process.hrtime(start);
		debugLogs && console.log(timeDiffString(label, end));
		return { html, json };
	},

	buildModLog: async (options) => {
		if (!options.startDate || !options.endDate) {
			const d = new Date();
			const month = d.getUTCMonth()
				, day = d.getUTCDate()
				, year = d.getUTCFullYear();
			options.startDate = new Date(Date.UTC(year, month, day, 0, 0, 0, 0));
			options.endDate = new Date(Date.UTC(year, month, day, 23, 59, 59, 999));
		}
		const day = ('0'+options.startDate.getDate()).slice(-2);
		const month = ('0'+(options.startDate.getMonth()+1)).slice(-2);
		const year = options.startDate.getFullYear();
		const label = `/${options.board._id}/logs/${month}-${day}-${year}.html`;
		const start = process.hrtime();
		if (!options.logs) {
			options.logs = await Modlogs.findBetweenDate(options.board, options.startDate, options.endDate);
		}
		options.managePage = 'logs.html';
		const projectedLogs = options.logs.map(l => {
			const pl = l;
			if (pl.showUser === false) {
				pl.user = null;
			}
			return pl;
		});
		const { html, json } = await render(label, 'modlog.pug', {
			...options
		}, {
			'name': `/${options.board._id}/logs/${month}-${day}-${year}.json`,
			'data': projectedLogs,
		});
		const end = process.hrtime(start);
		debugLogs && console.log(timeDiffString(label, end));
		return { html, json };
	},

	buildModLogList: async (options) => {
		const label = `/${options.board._id}/logs.html`;
		const start = process.hrtime();
		let dates = await Modlogs.getDates(options.board._id);
		const { pruneModlogs } = config.get;
		if (pruneModlogs) {
			const pruneLogs = [];
			const pruneAfter = new Date(Date.now()-timeUtils.DAY*pruneModlogs);
			dates = dates.filter(date => {
				const { year, month, day } = date.date;
				if (new Date(year, month-1, day) > pruneAfter) { //-1 for 0-index months
					return true;
				}
				const logName = `${month}-${day}-${year}`;
				debugLogs && console.log('Pruning log', `${options.board._id}/${logName}`);
				pruneLogs.push(logName);
				return false;
			});
			if (pruneLogs.length > 0) {
				await Promise.all(pruneLogs.map(log => {
					return Promise.all([
						remove(`${uploadDirectory}/html/${options.board._id}/logs/${log}.html`),
						remove(`${uploadDirectory}/json/${options.board._id}/logs/${log}.json`),
					]);
				}));
				await Modlogs.deleteOld(options.board._id, pruneAfter);
			}
		}
		const { html, json } = await render(label, 'modloglist.pug', {
			board: options.board,
			dates,
			managePage: 'logs.html',
		}, {
			'name': `/${options.board._id}/logs.json`,
			'data': dates,
		});
		const end = process.hrtime(start);
		debugLogs && console.log(timeDiffString(label, end));
		return { html, json };
	},

	buildHomepage: async () => {
		const { maxRecentNews } = config.get;
		const label = '/index.html';
		const start = process.hrtime();
		let [ totalStats, boards, fileStats, recentNews, hotThreads ] = await Promise.all([
			Boards.totalStats(), //overall total posts ever made
			Boards.boardSort(0, 20), //top 20 boards sorted by users, pph, total posts
			Files.activeContent(), //size and number of files
			News.find(maxRecentNews), //some recent newsposts
			Posts.hotThreads(), //top 5 threads last 7 days
		]);
		const [ localStats, webringStats ] = totalStats;
		const { html } = await render('index.html', 'home.pug', {
			localStats,
			webringStats,
			boards,
			fileStats,
			recentNews,
			hotThreads,
		});
		const end = process.hrtime(start);
		debugLogs && console.log(timeDiffString(label, end));
		return html;
	},

	updateStats: async () => {
		const label = 'Hourly stats rollover';
		const start = process.hrtime();
		await Stats.updateBoards();
		await Stats.resetStats();
		buildQueue.push({
			'task': 'buildHomepage',
		});
		const end = process.hrtime(start);
		debugLogs && console.log(timeDiffString(label, end));
		module.exports.resetTriggers();
	},

	resetTriggers: async() => {
		const label = 'Resetting pph/tph triggers';
		const start = process.hrtime();
		const triggeredBoards = await cache.sgetall('triggered'); //boards triggered pph/tph mode
		if (triggeredBoards.length === 0) {
			return; //no label is no triggers
		}
		await cache.del('triggered');
		const triggerModes = await Boards.triggerModes(triggeredBoards);
		const bulkWrites = triggerModes.map(p => {
			return {
				'updateOne': {
					'filter': {
						'_id': p._id
					},
					'update': {
						'$set': {
							/* reset=0 is "no change", the options go from 0-2, and get reset to 0 or 1,
							so if >0, we subtract 1 otherwise no change */
							'settings.lockMode': (p.lockReset > 0 ? Math.min(p.lockReset-1, p.lockMode) : p.lockMode),
							'settings.captchaMode': (p.captchaReset > 0 ? Math.min(p.captchaReset-1, p.captchaMode) : p.captchaMode),
						}
					}
				}
			};
		});
		await Boards.db.bulkWrite(bulkWrites);
		const promises = [];
		triggerModes.forEach(async (p) => {
			await cache.del(`board:${p._id}`);
			if (p.captchaReset > 0 && p.captchaReset-1 < p.captchaMode) {
				if (p.captchaReset-1 <= 1) {
					promises.push(remove(`${uploadDirectory}/html/${p._id}/thread/`));
				}
				if (p.captchaReset-1 === 0) {
					buildQueue.push({
						'task': 'buildBoardMultiple',
						'options': {
							'board': p._id,
							'startpage': 1,
							'endpage': Math.ceil(p.threadLimit/10)
						}
					});
					buildQueue.push({
						'task': 'buildCatalog',
						'options': {
							'board': p._id
						}
					});
				}
			}
		});
		await Promise.all(promises);
		const end = process.hrtime(start);
		debugLogs && console.log(timeDiffString(label, end));
	},

	buildChangePassword: async () => {
		const { html } = await render('changepassword.html', 'changepassword.pug');
		return html;
	},

	buildRegister: async () => {
		const { html } = await render('register.html', 'register.pug');
		return html;
	},

	buildBypass: async () => {
		const { html } = await render('bypass.html', 'bypass.pug');
		return html;
	},

	buildCreate: async () => {
		const { html } = await render('create.html', 'create.pug');
		return html;
	},

	buildCaptcha: async() => {
		const { html } = await render('captcha.html', 'captcha.pug');
		return html;
	},

};


code:
'use strict';

const { Captchas } = require(__dirname+'/../../db/')
	, { ObjectId } = require(__dirname+'/../../db/db.js')
	, config = require(__dirname+'/../misc/config.js')
	, { yandex, hcaptcha, google } = require(__dirname+'/../../configs/secrets.js')
	, FormData = require('form-data')
	, fetch = require('node-fetch')
	, { timingSafeEqual } = require('crypto');

module.exports = async (req, captchaInput, captchaId) => {

	if (process.env.NO_CAPTCHA) {
		return true;
	}

	const { captchaOptions, ipHeader } = config.get;

	//check if captcha field in form is valid
	if (!captchaInput
		|| (captchaInput.length !== 6 && !captchaOptions.type === 'text')) {
		throw 'Incorrect captcha answer';
	}

	//make sure they have captcha cookie and its 24 chars
	if (!['google', 'yandex', 'hcaptcha'].includes(captchaOptions.type)
		&& (!captchaId || captchaId.length !== 24)) {
		throw 'Captcha expired';
	}

	captchaInput = Array.isArray(captchaInput) ? captchaInput : [captchaInput];

	switch (captchaOptions.type) {
		case 'grid':
		case 'grid2': { //grid captcha
			const gridCaptchaMongoId = ObjectId(captchaId);
			const normalisedAnswer = new Array(captchaOptions.grid.size**2).fill(false);
			captchaInput.forEach(num => {
				normalisedAnswer[+num] = true;
			});
			let gridCaptcha = await Captchas.findOneAndDelete(gridCaptchaMongoId, normalisedAnswer);
			if (!gridCaptcha || !gridCaptcha.value
				|| !timingSafeEqual(
					Buffer.from(gridCaptcha.value.answer.join(',')),
					Buffer.from(normalisedAnswer.join(','))
				)
			) {
				throw 'Incorrect captcha answer';
			}
			break;
		}
		case 'text': { //text captcha
			const textCaptchaMongoId = ObjectId(captchaId);
			let textCaptcha = await Captchas.findOneAndDelete(textCaptchaMongoId, captchaInput[0]);
			if (!textCaptcha || !textCaptcha.value
				|| !timingSafeEqual(
					Buffer.from(textCaptcha.value.answer),
					Buffer.from(captchaInput[0])
				)
			) {
				throw 'Incorrect captcha answer';
			}
			break;
		}
		case 'google': { //google captcha
			let recaptchaResponse;
			try {
				const form = new FormData();
				form.append('secret', google.secretKey);
				form.append('response', captchaInput[0]);
				recaptchaResponse = await fetch('https://www.google.com/recaptcha/api/siteverify', {
					method: 'POST',
					body: form,
				}).then(res => res.json());
			} catch (e) {
				console.error(e);
				throw 'Captcha error occurred';
			}
			if (!recaptchaResponse || !recaptchaResponse.success) {
				throw 'Incorrect captcha answer';
			}
			break;
		}
		case 'hcaptcha': {
			let hcaptchaResponse;
			try {
				const form = new FormData();
				form.append('secret', hcaptcha.secretKey);
				form.append('sitekey', hcaptcha.siteKey);
				form.append('response', captchaInput[0]);
				hcaptchaResponse = await fetch('https://hcaptcha.com/siteverify', {
					method: 'POST',
					body: form,
				}).then(res => res.json());
			} catch (e) {
				console.error(e);
				throw 'Captcha error occurred';
			}
			if (!hcaptchaResponse || !hcaptchaResponse.success) {
				throw 'Incorrect captcha answer';
			}
			break;
		}
		case 'yandex': {
			let yandexResponse;
			try {
				const userIp = req.headers[ipHeader] || req.connection.remoteAddress;
				yandexResponse = await fetch(
					`https://smartcaptcha.yandexcloud.net/validate?secret=${encodeURIComponent(yandex.secretKey)}&token=${encodeURIComponent(captchaInput[0])}&ip=${userIp}`)
					.then(res => res.json());
			} catch (e) {
				console.error(e);
				throw 'Captcha error occurred';
			}
			if (!yandexResponse || yandexResponse.success !== 'ok') {
				throw 'Incorrect captcha answer';
			}
			break;
		}
		default:
			throw 'Captcha config error';
	}

	return true;

};


code:
'use strict';

const gm = require('@fatchan/gm').subClass({ imageMagick: true })
	, { promisify } = require('util')
	, randomBytes = promisify(require('crypto').randomBytes)
	, getDistorts = require(__dirname+'/../getdistorts.js')
	, { DejaVuSans } = require(__dirname+'/../../misc/fonts.js')
	, randomRange = promisify(require('crypto').randomInt)
	, padding = 30; //pad edge of image to account for character size + distortion

module.exports = async (captchaOptions) => {

	const { size, trues, falses, imageSize, noise, edge, iconYOffset } = captchaOptions.grid;
	const width = imageSize+padding; //TODO: these will never be different, right?
	const height = imageSize+padding;

	//number of inputs in grid, just square of size
	const numInputs = size ** 2;

	//create an array of true/false for grid from random bytes
	const randBuffer = await randomBytes(numInputs);
	const boolArray = Array.from(randBuffer).map(x => x < 80); //TODO: make 80 variable?
	//make sure there is at least one true in the array
	if (!boolArray.some(b => b === true)) {
		boolArray[(await randomRange(0, numInputs))] = true;
	}

	const captcha = gm(width, height, '#ffffff')
		.fill('#000000');

	if (captchaOptions.font !== 'default') {
		captcha.font(captchaOptions.font);
	} else {
		captcha.font(DejaVuSans.path);
	}

	//divide the space by grid size, accounting for padding
	const spaceSize = (width-padding)/size;
	const fontMinSize = Math.floor(width*0.16);
	const fontMaxSize = Math.floor(width*0.25);
	for (let j = 0; j < size; j++) { //for each row

		//x offset for whole row (not per character or it gets way too difficult to solve)
		let cxOffset = await randomRange(0, Math.floor(spaceSize * 1.5));

		for (let i = 0; i < size; i++) { //for character in row
			const index = (j*size)+i;
			const cyOffset = iconYOffset > 0 ? (await randomRange(0, iconYOffset)) : 0;
			let character;
			if (boolArray[index] === true) {
				character = trues[(await randomRange(0, trues.length))];
			} else {
				character = falses[(await randomRange(0, falses.length))];
			}
			captcha.fontSize((await randomRange(fontMinSize, fontMaxSize)));
			captcha.drawText(
				(spaceSize * i) + cxOffset,
				(spaceSize * (j + 1)) + cyOffset,
				character
			);
		}

	}

	//create an array of distortions and apply to the image, if distortion is enabled
	const { distortion, numDistorts } = captchaOptions;
	if (distortion > 0) {
		const distorts = await getDistorts(width, height, numDistorts, distortion);
		captcha.distort(distorts, 'Shepards');
	}

	//add optional edge effect
	if (edge > 0) {
		captcha.edge(edge);
	}

	//add optional noise effect
	if (noise > 0) {
		captcha.noise(noise);
	}

	return { captcha, solution: boolArray };

};


code:
const gridv1 = require('./grid.js')
	, gridv2 = require('./grid2.js')
	, falses = ['○','□','♘','♢','▽','△','♖','✧','♔','♘','♕','♗','♙','♧']
	, trues = ['●','■','♞','♦','▼','▲','♜','✦','♚','♞','♛','♝','♟','♣'];

const cases = [
	{ name: '3n grid captcha', font: 'default', grid: { falses, trues, question: 'whatever', size: 3, imageSize: 120, iconYOffset: 0, edge: 0, noise: 0 }, numDistorts: { min: 0, max: 1 }, distortion: 0 },
	{ name: '4n grid captcha', font: 'default', grid: { falses, trues, question: 'whatever', size: 4, imageSize: 120, iconYOffset: 0, edge: 0, noise: 0 }, numDistorts: { min: 0, max: 1 }, distortion: 0 },
	{ name: '6n grid captcha', font: 'default', grid: { falses, trues, question: 'whatever', size: 6, imageSize: 120, iconYOffset: 0, edge: 0, noise: 0 }, numDistorts: { min: 0, max: 1 }, distortion: 0 },
	{ name: '4n grid captcha with distortion', font: 'default', grid: { falses, trues, question: 'whatever', size: 4, imageSize: 120, iconYOffset: 0, edge: 0, noise: 0 }, numDistorts: { min: 1, max: 10 }, distortion: 10 },
	{ name: '4n grid captcha with edge', font: 'default', grid: { falses, trues, question: 'whatever', size: 4, imageSize: 120, iconYOffset: 0, edge: 10, noise: 0 }, numDistorts: { min: 0, max: 1 }, distortion: 0 },
	{ name: '4n grid captcha with noise', font: 'default', grid: { falses, trues, question: 'whatever', size: 4, imageSize: 120, iconYOffset: 0, edge: 0, noise: 10 }, numDistorts: { min: 0, max: 1 }, distortion: 0 },
	{ name: '4n grid captcha with y offset', font: 'default', grid: { falses, trues, question: 'whatever', size: 4, imageSize: 120, iconYOffset: 15, edge: 0, noise: 0 }, numDistorts: { min: 0, max: 1 }, distortion: 0 },
	{ name: '4n grid captcha with all effects', font: 'default', grid: { falses, trues, question: 'whatever', size: 4, imageSize: 120, iconYOffset: 0, edge: 0, noise: 0 }, numDistorts: { min: 0, max: 1 }, distortion: 0 },
	{ name: '4n grid captcha with all effects and distortion', font: 'default', grid: { falses, trues, question: 'whatever', size: 4, imageSize: 120, iconYOffset: 150, edge: 10, noise: 10 }, numDistorts: { min: 1, max: 10 }, distortion: 10 },
	{ name: '250px 6n grid captcha with all effects and distortion', font: 'default', grid: { falses, trues, question: 'whatever', size: 6, imageSize: 250, iconYOffset: 150, edge: 10, noise: 10 }, numDistorts: { min: 1, max: 10 }, distortion: 10 },
	{ name: '123px 4n grid captcha with all effects and distortion', font: 'default', grid: { falses, trues, question: 'whatever', size: 4, imageSize: 123, iconYOffset: 150, edge: 10, noise: 10 }, numDistorts: { min: 1, max: 10 }, distortion: 10 },
	{ name: '90px 3n grid captcha with all effects and distortion', font: 'default', grid: { falses, trues, question: 'whatever', size: 3, imageSize: 90, iconYOffset: 150, edge: 10, noise: 10 }, numDistorts: { min: 1, max: 10 }, distortion: 10 },

];

describe('generate gridv1 captcha', () => {
	for (let captchaOptions of cases) {
		test(captchaOptions.name, async () => {
			const { captcha } = await gridv1(captchaOptions);
			expect(await new Promise((res, rej) => {
				captcha.write('/tmp/captcha.jpg', (err) => {
					if (err) {
						return rej(err);
					}
					res();
				});
			}));
		});
	}
});

describe('generate gridv2 captcha', () => {
	for (let captchaOptions of cases) {
		test(captchaOptions.name, async () => {
			const { captcha } = await gridv2(captchaOptions);
			expect(await new Promise((res, rej) => {
				captcha.write('/tmp/captcha.jpg', (err) => {
					if (err) {
						return rej(err);
					}
					res();
				});
			}));
		});
	}
});


code:
'use strict';

const gm = require('@fatchan/gm').subClass({ imageMagick: true })
	, { promisify } = require('util')
	, { DejaVuSans } = require(__dirname+'/../../misc/fonts.js')
	, getDistorts = require(__dirname+'/../getdistorts.js')
	, randomRange = promisify(require('crypto').randomInt)
	, randomBytes = promisify(require('crypto').randomBytes)
	, padding = 30 //pad edge of image to account for character size + distortion
	, nArrows = ['↑', '↟', '↥', '↾', '↿', '⇑', '⇡']
	, eArrows = ['➸', '→', '➳', '➵', '→', '↛', '↠', '↣', '↦', '↪', '↬', '↱', '↳', '⇉', '⇏', '⇒', '⇛', '⇝', '⇢']
	, wArrows = [ '←', '↚', '↞', '↜', '↢', '↩', '↤', '↫', '↰', '↲', '↵', '⇇', '⇍', '⇐', '⇚', '⇜', '⇠']
	, sArrows = ['↓', '↡', '↧', '↴', '⇂', '⇃', '⇊', '⇓', '⇣']
	, allArrows = [...nArrows, ...eArrows, ...wArrows, ...sArrows]
	, randomBool = async (p) => { return ((await randomBytes(1))[0] > p); }
	, randomOf = async (arr) => { return arr[(await randomRange(0, arr.length))]; };
	//TODO: last two could belong in lib/misc/(random?)

module.exports = async (captchaOptions) => {

	const { size, trues, falses, imageSize, noise, edge } = captchaOptions.grid;
	const width = imageSize+padding; //TODO: these will never be different, right?
	const height = imageSize+padding;

	const charMatrix = new Array(size).fill(false)
		.map(() => new Array(size).fill(false));
	const answerMatrix = new Array(size).fill(false)
		.map(() => new Array(size).fill(false));

	//put the icon arrows should point at
	const correctRow = await randomRange(0, size);
	const correctCol = await randomRange(0, size);
	charMatrix[correctRow][correctCol] = await randomOf(trues);

	//put correct and incorrect arrows in the row/column
	const numArray = [...new Array(size).keys()];
	const perpendicularRows = numArray.filter(x => x !== correctRow);
	for (let row of perpendicularRows) {
		/*TODO: necessary to memoize these "inverse" sets of arrows? or maybe instead of even doing a 50/50
		random, it should just pick a random from allArrows then set the isCorrect based on if its in the correct set?*/
		let arrows;
		const isCorrect = await randomBool(127);
		if (row < correctRow) {
			arrows = isCorrect ? sArrows : [...nArrows, ...eArrows, ...wArrows];
		} else if (row > correctRow) {
			arrows = isCorrect ? nArrows : [...sArrows, ...eArrows, ...wArrows];
		}
		charMatrix[row][correctCol] = await randomOf(arrows);
		answerMatrix[row][correctCol] = isCorrect;
	}
	const perpendicularCols = numArray.filter(x => x !== correctCol);
	for (let col of perpendicularCols) {
		let arrows;
		const isCorrect = await randomBool(127);
		if (col < correctCol) {
			arrows = isCorrect ? eArrows : [...sArrows, ...nArrows, ...wArrows];
		} else if (col > correctCol) {
			arrows = isCorrect ? wArrows : [...sArrows, ...nArrows, ...eArrows];
		}
		charMatrix[correctRow][col] = await randomOf(arrows);
		answerMatrix[correctRow][col] = isCorrect;
	}
//TODO: diagonals? need more arrows

	//this sucks
	if (!answerMatrix.flat().some(x => x === true)) {
		if ((await randomBool(127)) === true) {
			const randomRow = await randomOf(perpendicularRows);
			const arrows = randomRow < correctRow ? sArrows : nArrows;
			charMatrix[randomRow][correctCol] = await randomOf(arrows);
			answerMatrix[randomRow][correctCol] = true;
		} else {
			const randomCol = await randomOf(perpendicularCols);
			const arrows = randomCol < correctCol ? eArrows : wArrows;
			charMatrix[correctRow][randomCol] = await randomOf(arrows);
			answerMatrix[correctRow][randomCol] = true;
		}
	}

	//fill the rest with junk arrows/falses
	for (let row = 0; row < size; row++) {
		for (let col = 0; col < size; col++) {
			if (charMatrix[row][col] === false) {
				charMatrix[row][col] = (await randomBool(80))
					? (await randomOf(allArrows))
					: (await randomOf(falses));
			}
		}
	}

	const captcha = gm(width, height, '#ffffff')
		.fill('#000000');

	if (captchaOptions.font !== 'default') {
		captcha.font(captchaOptions.font);
	} else {
		captcha.font(DejaVuSans.path);
	}

	const spaceSize = (width-padding)/size;
	const fontMinSize = Math.floor(width*0.16);
	const fontMaxSize = Math.floor(width*0.25);
	for (let row = 0; row < size; row++) {
		let cxOffset = Math.floor(spaceSize * 1.5);
		for (let col = 0; col < size; col++) {
			const cyOffset = captchaOptions.grid.iconYOffset;
			captcha.fontSize((await randomRange(fontMinSize, fontMaxSize)));
			captcha.drawText(
				(spaceSize * col) + cyOffset,
				(spaceSize * row) + cxOffset,
				charMatrix[row][col]
			);
		}
	}

	//create an array of distortions and apply to the image, if distortion is enabled
	const { distortion, numDistorts } = captchaOptions;
	if (distortion > 0) {
		const distorts = await getDistorts(width, height, numDistorts, distortion);
		captcha.distort(distorts, 'Shepards');
	}

	//add optional edge effect
	if (edge > 0) {
		captcha.edge(edge);
	}

	//add optional noise effect
	if (noise > 0) {
		captcha.noise(noise);
	}

	return { captcha, solution: answerMatrix.flat() };

};


code:
'use strict';

const gm = require('@fatchan/gm').subClass({ imageMagick: true })
	, characterWidth = (char) => {
		switch (char) {
			case 'w':
			case 'm':
				return 45;
			case 'i':
			case 'l':
				return 12;
			case 'f':
			case 'j':
			case 't':
				return 15;
			default:
				return 30;
		}
	}
	, totalTextWidth = (text) => {
		return text.split('').reduce((acc, char) => {
			return characterWidth(char) + acc + 1;
		}, 0);
	}
	, width = 210
	, height = 80
	, minVal = parseInt('1000000', 36)
	, maxVal = parseInt('1zzzzzz', 36)
	, { promisify } = require('util')
	, randomRange = promisify(require('crypto').randomInt)
	, getDistorts = require(__dirname+'/../getdistorts.js');

module.exports = async (captchaOptions) => {

	/*	generate random between 1000000 and 1zzzzzz and not 0 and zzzzzz, so output will have
		enough characters for 000000-zzzzzz */
	const textInt = await randomRange(minVal, maxVal+1);
	const text = textInt.toString(36).substr(-6, 6);

	const captcha = gm(width,height,'#ffffff')
		.fill('#000000')
		.fontSize(65);

	if (captchaOptions.font !== 'default') {
		captcha.font(captchaOptions.font);
	}

	//draw each character at their x based on the characterWidth()
	const textWidth = totalTextWidth(text);
	const startX = (width-textWidth)/2;
	let charX = startX;
	for (let i = 0; i < 6; i++) {
		captcha.drawText(charX, 60, text[i]);
		charX += characterWidth(text[i]);
	}

	//draw optional line/strikethrough
	if (captchaOptions.text.line === true) {
		const lineY = await randomRange(35,45);
		captcha.drawRectangle(startX, lineY, startX+textWidth, lineY+4);
	}

	//add optional wave effect
	if (captchaOptions.text.wave > 0) {
		captcha.wave(captchaOptions.text.wave, width/4);
	}

	//add optional paint effect
	if (captchaOptions.text.paint > 0) {
		captcha.paint(captchaOptions.text.paint);
	}

	//add optional noise effect
	if (captchaOptions.text.noise > 0) {
		captcha.noise(captchaOptions.text.noise);
	}

	//create an array of distortions and apply to the image, if distortion is enabled
	const { distortion, numDistorts } = captchaOptions;
	if (distortion > 0) {
		const distorts = await getDistorts(width, height, numDistorts, distortion);
		captcha.distort(distorts, 'Shepards');
	}

	return { captcha, solution: text };

};


code:
const generateCaptcha = require('./text.js');

describe('generate text captcha', () => {
	const cases = [
		{ name: 'text captcha', font: 'default', text: { wave: 0, line: false, paint: 0, noise: 0 }, numDistorts: { min: 0, max: 1 }, distortion: 0 },
		{ name: 'text captcha with distortion', font: 'default', text: { wave: 0, line: false, paint: 0, noise: 0 }, numDistorts: { min: 1, max: 10 }, distortion: 10 },
		{ name: 'text captcha with wave', font: 'default', text: { wave: 5, line: false, paint: 0, noise: 0 }, numDistorts: { min: 0, max: 1 }, distortion: 0 },
		{ name: 'text captcha with line', font: 'default', text: { wave: 0, line: true, paint: 0, noise: 0 }, numDistorts: { min: 0, max: 1 }, distortion: 0 },
		{ name: 'text captcha with paint', font: 'default', text: { wave: 0, line: false, paint: 5, noise: 0 }, numDistorts: { min: 0, max: 1 }, distortion: 0 },
		{ name: 'text captcha with noise', font: 'default', text: { wave: 0, line: false, paint: 0, noise: 5 }, numDistorts: { min: 0, max: 1 }, distortion: 0 },
		{ name: 'text captcha with with all effects and distortion', font: 'default', text: { wave: 5, line: true, paint: 5, noise: 5 }, numDistorts: { min: 1, max: 10 }, distortion: 10 },
		{ name: 'text captcha with non-default font', font: '/usr/share/fonts/type1/gsfonts/p052003l.pfb', text: { wave: 0, line: false, paint: 0, noise: 0 }, numDistorts: { min: 0, max: 1 }, distortion: 0 },
		{ name: 'text captcha with all the above', font: '/usr/share/fonts/type1/gsfonts/p052003l.pfb', text: { wave: 5, line: true, paint: 5, noise: 5 }, numDistorts: { min: 1, max: 10 }, distortion: 10 },
	];
	for (let captchaOptions of cases) {
		test(captchaOptions.name, async () => {
			const { captcha } = await generateCaptcha(captchaOptions);
			expect(await new Promise((res, rej) => {
				captcha.write('/tmp/captcha.jpg', (err) => {
					if (err) {
						return rej(err);
					}
					res();
				});
			}));
		});
	}
});


code:
'use strict';

const { promisify } = require('util')
	, randomRange = promisify(require('crypto').randomInt);

module.exports = async (width, height, numDistorts, distortion) => {

	const distorts = [];
	const randNumDistorts = await randomRange(numDistorts.min, numDistorts.max + 1);

	//divide the width by number of distortions and add a distortion across the whole horizontal area
	const div = width/randNumDistorts;
	for (let i = 0; i < randNumDistorts; i++) {

		//start and end of divided width
		const divStart = Math.floor(div * i)
			, divEnd = Math.floor(div * (i + 1));

		//origin coordinate for distortion point
		const originx = await randomRange(divStart, divEnd)
			, originy = await randomRange(0, height);

		//destionation coordinate for distortion point
		const destx = await randomRange(Math.max(distortion, originx - distortion), Math.min(width - distortion, originx + distortion)+1)
			, desty = await randomRange(Math.max(distortion, originy - (distortion * 2)), Math.min(height - distortion, originy + (distortion * 2))+1);

		distorts.push([
			{x:originx,y:originy},
			{x:destx,y:desty}
		]);

	}

	return distorts;

};


code:
'use strict';

const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
	, k = 1024;

module.exports = (bytes) => {
	if (bytes === 0)  {
		return '0B';
	}
	const i = Math.min(sizes.length-1, Math.floor(Math.log(bytes) / Math.log(k)));
	return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))}${sizes[i]}`;
};


code:
const formatSize = require('./formatsize.js');

describe('formatSize() - convert bytes to human readable file size', () => {
	const cases = [
		{in: 1024, out: '1KB'},
		{in: Math.pow(1024, 2), out: '1MB'},
		{in: Math.pow(1024, 3), out: '1GB'},
		{in: Math.pow(1024, 4), out: '1TB'},
		{in: Math.pow(1024, 5), out: '1024TB'},
		{in: Math.pow(1024, 3)+(Math.pow(1024, 2)*512), out: '1.5GB'},
		{in: 100, out: '100B'},
		{in: 0, out: '0B'},
	];
	for (let i in cases) {
		test(`should output ${cases[i].out} for an input of ${cases[i].in} bytes`, () => {
			expect(formatSize(cases[i].in)).toBe(cases[i].out);
		});
	}
});


code:
'use strict';

const YEAR = 31536000000
	, MONTH = 2629800000
	, WEEK = 604800000
	, DAY = 86400000
	, HOUR = 3600000
	, MINUTE = 60000
	, SECOND = 1000;

module.exports = {

	YEAR, MONTH, WEEK, DAY, HOUR, MINUTE, SECOND,

	//ms until next hour
	'nextHour': () => {
		return 3600000 - new Date().getTime() % 3600000;
	},

	//string representing how long since date A to date B
	'relativeString': (now, relativeTo, locals) => {
		let difference = now.getTime() - relativeTo.getTime();
		let amount = 0;
		let unit = '';
		let isFuture = false;
		if (difference < 0) {
			difference = Math.abs(difference);
			isFuture = true;
		}
		if (difference < MINUTE) {
			return locals.__('Now');
		} else if (difference < MINUTE*59.5) {
			amount = Math.round(difference / MINUTE);
			unit = 'minute';
		} else if (difference < HOUR*23.5) {
			amount = Math.round(difference / HOUR);
			unit = 'hour';
		} else if (difference < DAY*6.5) {
			amount = Math.round(difference / DAY);
			unit = 'day';
		} else if (difference < WEEK*3.5) {
			amount = Math.round(difference / WEEK);
			unit = 'week';
		} else if (difference < MONTH*11.5) {
			amount = Math.round(difference / MONTH);
			unit = 'month';
		} else {
			amount = Math.round(difference / YEAR);
			unit = 'year';
		}
		return locals.__n(`%s ${unit} ${isFuture ? 'from now' :  'ago'}`, amount);
	},

	'relativeColor': (now, relativeTo) => {
		const difference = now.getTime() - relativeTo.getTime();
		const minutesAgo = Math.floor(difference / MINUTE);
		let r = 0
			, g = 0
			, b = 0;
		if (difference < MINUTE) {
			g = 0.7;
			b = 1;
		} else if (difference < HOUR) {
			r = (minutesAgo / 60) * 0.5;
			g = 1;
		} else if (difference < DAY) {
			r = 0.5 + (minutesAgo / 1440) * 0.5;
			g = 1;
		} else if (difference < WEEK) {
			g = 1 - (minutesAgo / 10080) * 0.5;
			r = 1;
		} else if (difference < MONTH) {
			g = 0.5 - (minutesAgo / 43830) * 0.5;
			r = 1;
		} else if (difference < YEAR) {
			r = 1 - (minutesAgo / 525960);
		} //else, leave it black for >1 year
		r = (Math.round(r*255*0.85).toString(16)).padStart(2, '0');
		g = (Math.round(g*255*0.85).toString(16)).padStart(2, '0');
		b = (Math.round(b*255).toString(16)).padStart(2, '0');
		return `#${r}${g}${b}`;
	},

	'durationString': (ms) => {
		const durString = new Date(ms).toISOString();
		if (ms < DAY) {
			return durString.substr(11,8).replace(/^00:/, '');
		} else {
			const hours = Math.floor(ms/HOUR);
			return `${hours}:${durString.substring(14,19)}`;
		}
	},

	'timeDiffString': (label, end) => {
		return (`${end[0] > 0 ? end[0]+'s' : ''}${Math.trunc(end[1]/1000000)}ms `).padStart(9) + label;
	},

};


code:
const ffmpeg = require('fluent-ffmpeg')
	, config = require(__dirname+'/../../misc/config.js')
	, uploadDirectory = require(__dirname+'/../uploaddirectory.js');

module.exports = (file) => {

	const { thumbSize } = config.get;
	return new Promise((resolve, reject) => {
		ffmpeg(`${uploadDirectory}/file/${file.filename}`)
			.on('end', () => {
				return resolve();
			})
			.on('error', function(err) {
				return reject(err);
			})
			.complexFilter([{
				filter: 'showwavespic',
				options: { split_channels: 1, s: `${thumbSize}x${thumbSize}` }
			}])
			.save(`${uploadDirectory}/file/thumb/${file.hash}${file.thumbextension}`);
	});

};


code:
'use strict';

const { remove } = require('fs-extra')
	, uploadDirectory = require(__dirname+'/uploaddirectory.js');

module.exports = async (filenames, folder) => {

	await Promise.all(filenames.map(async filename => {
		remove(`${uploadDirectory}/${folder}/${filename}`);
	}));

};


code:
'use strict';

const { stat, remove, readdir } = require('fs-extra')
	, uploadDirectory = require(__dirname+'/uploaddirectory.js');

//takes directory name and timestamp to delete files older than
module.exports = async (directory, olderThan) => {
	const dir = `${uploadDirectory}/${directory}`;
	const files = await readdir(dir);
	if (files.length > 0) {
		return Promise.all(files.map(async file => {
			const filePath = `${dir}/${file}`;
			try {
				const stats = await stat(filePath);
				const expiry = new Date(olderThan).getTime();
				if (stats.ctime.getTime() < expiry) {
					await remove(filePath);
				}
			} catch (e) {
				console.error(e);
			}
		}));
	}
};


code:
'use strict';

const { remove } = require('fs-extra')
	, uploadDirectory = require(__dirname+'/uploaddirectory.js');

module.exports = (files) => {

	//delete all the files and thumbs
	return Promise.all(files.map(async file => {
		return Promise.all([
			remove(`${uploadDirectory}/file/${file.filename}`),
			file.hasThumb ? remove(`${uploadDirectory}/file/thumb/${file.hash}${file.thumbextension}`) : void 0,
		]).catch(console.error);
	}));

};


code:
'use strict';

const { remove } = require('fs-extra');

module.exports = async (req) => {

	if (req.files != null) {
		let files = [];
		const keys = Object.keys(req.files);
		for (let i = 0; i < keys.length; i++) {
			const val = req.files[keys[i]];
			if (Array.isArray(val)) {
				files = files.concat(val);
			} else {
				files.push(val);
			}
		}
		return Promise.all(files.map(async file => {
			remove(file.tempFilePath);
		}));
	}

};


code:
'use strict';

const ffmpeg = require('fluent-ffmpeg')
	, uploadDirectory = require(__dirname+'/uploaddirectory.js');

module.exports = (filename, folder, temp) => {

	return new Promise((resolve, reject) => {
		ffmpeg.ffprobe(temp === true ? filename : `${uploadDirectory}/${folder}/${filename}`, (err, metadata) => {
			if (err) {
				return reject(err);
			}
			return resolve(metadata);
		});
	});

};


code:
'use strict';

module.exports = (file) => {
	//handle gifs with multiple geometry and size
	if (Array.isArray(file.geometry)) {
		file.geometry = file.geometry[0];
	}
	if (Array.isArray(file.sizeString)) {
		file.sizeString = file.sizeString[0];
	}
	if (Array.isArray(file.geometryString)) {
		file.geometryString = file.geometryString[0];
	}
	return file;
};


code:
const gm = require('@fatchan/gm')
	, uploadDirectory = require(__dirname+'/../uploaddirectory.js');

module.exports = (filename, folder, temp) => {

	return new Promise((resolve, reject) => {
		const filePath = temp === true ? filename : `${uploadDirectory}/${folder}/${filename}`;
		gm(`${filePath}[0]`) //0 for first frame of gifs, much faster
			.size(function (err, data) {
				if (err) {
					return reject(err);
				}
				return resolve(data);
			});
	});

};