code:
'use strict';

const path = require('path')
	, { createHash, randomBytes } = require('crypto')
	, randomBytesAsync = require('util').promisify(randomBytes)
	, { remove, pathExists, stat: fsStat } = require('fs-extra')
	, uploadDirectory = require(__dirname+'/../../helpers/files/uploadDirectory.js')
	, Mongo = require(__dirname+'/../../db/db.js')
	, Socketio = require(__dirname+'/../../socketio.js')
	, { Stats, Posts, Boards, Files, Bans } = require(__dirname+'/../../db/')
	, cache = require(__dirname+'/../../redis.js')
	, nameHandler = require(__dirname+'/../../helpers/posting/name.js')
	, { prepareMarkdown } = require(__dirname+'/../../helpers/posting/markdown.js')
	, messageHandler = require(__dirname+'/../../helpers/posting/message.js')
	, moveUpload = require(__dirname+'/../../helpers/files/moveupload.js')
	, mimeTypes = require(__dirname+'/../../helpers/files/mimetypes.js')
	, imageThumbnail = require(__dirname+'/../../helpers/files/imagethumbnail.js')
	, imageIdentify = require(__dirname+'/../../helpers/files/imageidentify.js')
	, videoThumbnail = require(__dirname+'/../../helpers/files/videothumbnail.js')
	, audioThumbnail = require(__dirname+'/../../helpers/files/audiothumbnail.js')
	, ffprobe = require(__dirname+'/../../helpers/files/ffprobe.js')
	, formatSize = require(__dirname+'/../../helpers/files/formatsize.js')
	, deleteTempFiles = require(__dirname+'/../../helpers/files/deletetempfiles.js')
	, fixGifs = require(__dirname+'/../../helpers/files/fixgifs.js')
	, timeUtils = require(__dirname+'/../../helpers/timeutils.js')
	, deletePosts = require(__dirname+'/deletepost.js')
	, spamCheck = require(__dirname+'/../../helpers/checks/spamcheck.js')
	, { checkRealMimeTypes, thumbSize, thumbExtension, videoThumbPercentage,
		postPasswordSecret, strictFiltering, animatedGifThumbnails,
		audioThumbnails } = require(__dirname+'/../../configs/main.js')
	, buildQueue = require(__dirname+'/../../queue.js')
	, dynamicResponse = require(__dirname+'/../../helpers/dynamic.js')
	, { buildThread } = require(__dirname+'/../../helpers/tasks.js');

module.exports = async (req, res, next) => {

	//spam/flood check
	const flood = await spamCheck(req, res);
	if (flood) {
		deleteTempFiles(req).catch(e => console.error);
		return dynamicResponse(req, res, 429, 'message', {
			'title': 'Flood detected',
			'message': 'Please wait before making another post, or a post similar to another user',
			'redirect': `/${req.params.board}${req.body.thread ? '/thread/' + req.body.thread + '.html' : ''}`
		});
	}

	// check if this is responding to an existing thread
	let redirect = `/${req.params.board}/`
	let salt = null;
	let thread = null;
	const { filterBanDuration, filterMode, filters, blockedCountries, threadLimit, ids, userPostSpoiler,
			lockReset, captchaReset, pphTrigger, tphTrigger, tphTriggerAction, pphTriggerAction,
			maxFiles, sageOnlyEmail, forceAnon, replyLimit, disableReplySubject,
			captchaMode, lockMode, allowedFileTypes, flags, fileR9KMode, messageR9KMode } = res.locals.board.settings;
	if (res.locals.permLevel >= 4
		&& res.locals.country
		&& blockedCountries.includes(res.locals.country.code)) {
		return dynamicResponse(req, res, 403, 'message', {
			'title': 'Forbidden',
			'message': `Your country "${res.locals.country.name}" is not allowed to post on this board`,
			'redirect': redirect
		});
	}
	if ((lockMode === 2 || (lockMode === 1 && !req.body.thread)) //if board lock, or thread lock and its a new thread
		&& res.locals.permLevel >= 4) { //and not staff
		await deleteTempFiles(req).catch(e => console.error);
		return dynamicResponse(req, res, 400, 'message', {
			'title': 'Bad request',
			'message': lockMode === 1 ? 'Thread creation locked' : 'Board locked',
			'redirect': redirect
		});
	}
	if (req.body.thread) {
		thread = await Posts.getPost(req.params.board, req.body.thread, true);
		if (!thread || thread.thread != null) {
			await deleteTempFiles(req).catch(e => console.error);
			return dynamicResponse(req, res, 400, 'message', {
				'title': 'Bad request',
				'message': 'Thread does not exist',
				'redirect': redirect
			});
		}
		salt = thread.salt;
		redirect += `thread/${req.body.thread}.html`
		if (thread.locked && res.locals.permLevel >= 4) {
			await deleteTempFiles(req).catch(e => console.error);
			return dynamicResponse(req, res, 400, 'message', {
				'title': 'Bad request',
				'message': 'Thread Locked',
				'redirect': redirect
			});
		}
		if (thread.replyposts >= replyLimit && !thread.cyclic) { //reply limit
			await deleteTempFiles(req).catch(e => console.error);
			return dynamicResponse(req, res, 400, 'message', {
				'title': 'Bad request',
				'message': 'Thread reached reply limit',
				'redirect': redirect
			});
		}
	}
	//filters
	if (res.locals.permLevel > 1) { //global staff bypass filters
		const globalSettings = await cache.get('globalsettings');
		let hitGlobalFilter = false
			, hitLocalFilter = false
			, ban;
		let concatContents = `|${req.body.name}|${req.body.message}|${req.body.subject}|${req.body.email}|${res.locals.numFiles > 0 ? req.files.file.map(f => f.name).join('|') : ''}`.toLowerCase();
		let allContents = concatContents;
		if (strictFiltering || res.locals.board.settings.strictFiltering) { //strict filtering adds a few transformations of the text to try and match filters when sers use techniques like zalgo, ZWS, markdown, multi-line, etc.
			allContents += concatContents.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); //removing diacritics
			allContents += concatContents.replace(/[\u200B-\u200D\uFEFF]/g, ''); //removing ZWS
			allContents += concatContents.replace(/[^a-zA-Z0-9.-]+/gm, ''); //removing anything thats not alphamnumeric or . and -
		}
		//global filters
		if (globalSettings && globalSettings.filters.length > 0 && globalSettings.filterMode > 0) {
			hitGlobalFilter = globalSettings.filters.some(filter => { return allContents.includes(filter.toLowerCase()) });
		}
		//board-specific filters (doesnt use strict filtering)
		if (!hitGlobalFilter && res.locals.permLevel >= 4 && filterMode > 0 && filters && filters.length > 0) {
			const localFilterContents = res.locals.board.settings.strictFiltering ? allContents : concatContents;
			hitLocalFilter = filters.some(filter => { return localFilterContents.includes(filter.toLowerCase()) });
		}
		if (hitGlobalFilter || hitLocalFilter) {
			await deleteTempFiles(req).catch(e => console.error);
			const useFilterMode = hitGlobalFilter ? globalSettings.filterMode : filterMode; //global override local filter
			if (useFilterMode === 1) {
				return dynamicResponse(req, res, 400, 'message', {
					'title': 'Bad request',
					'message': 'Your post was blocked by a word filter',
					'redirect': redirect
				});
			} else { //otherwise filter mode must be 2
				const useFilterBanDuration = hitGlobalFilter ? globalSettings.filterBanDuration : filterBanDuration;
				const banBoard = hitGlobalFilter ? null : res.locals.board._id;
				const banDate = new Date();
				const banExpiry = new Date(useFilterBanDuration + banDate.getTime());
				const ban = {
					'ip': {
						'single': res.locals.ip.single,
						'raw': res.locals.ip.raw,
					},
					'type': 'single',
					'reason': `${hitGlobalFilter ? 'global ' :''}word filter auto ban`,
					'board': banBoard,
					'posts': null,
					'issuer': 'system', //what should i call this
					'date': banDate,
					'expireAt': banExpiry,
					'allowAppeal': true, //should i make this configurable if appealable?
					'seen': false
				};
				const insertedResult = await Bans.insertOne(ban);
				ban._id = insertedResult.insertedId;
				return res.status(403).render('ban', {
					bans: [ban]
				});
			}
		}

	}

	//for r9k messages. usually i wouldnt process these if its not enabled e.g. flags and IDs but in this case I think its necessary
	let messageHash = null;
	if (req.body.message && req.body.message.length > 0) {
		const noQuoteMessage = req.body.message.replace(/>>\d+/g, '').replace(/>>>\/\w+(\/\d*)?/gm, '').trim();
		messageHash = createHash('sha256').update(noQuoteMessage).digest('base64');
		if (res.locals.permLevel >= 4 && (req.body.thread && messageR9KMode === 1) || messageR9KMode === 2) {
			const postWithExistingMessage = await Posts.checkExistingMessage(res.locals.board._id, (messageR9KMode === 2 ? null : req.body.thread), messageHash);
			if (postWithExistingMessage != null) {
				await deleteTempFiles(req).catch(e => console.error);
				return dynamicResponse(req, res, 409, 'message', {
					'title': 'Conflict',
					'message': `Messages must be unique ${messageR9KMode === 1 ? 'in this thread' : 'on this board'}. Your message is not unique.`,
					'redirect': redirect
				});
			}
		}
	}

	let files = [];
	// if we got a file
	if (res.locals.numFiles > 0) {
		if (res.locals.permLevel >= 4 && (req.body.thread && fileR9KMode === 1) || fileR9KMode === 2) {
			const filesHashes = req.files.file.map(f => f.sha256);
			const postWithExistingFiles = await Posts.checkExistingFiles(res.locals.board._id, (fileR9KMode === 2 ? null : req.body.thread), filesHashes);
			if (postWithExistingFiles != null) {
				await deleteTempFiles(req).catch(e => console.error);
				const conflictingFiles = req.files.file
					.filter(f => postWithExistingFiles.files.some(fx => fx.hash === f.sha256))
					.map(f => f.name)
					.join(', ');
				return dynamicResponse(req, res, 409, 'message', {
					'title': 'Conflict',
					'message': `Uploaded files must be unique ${fileR9KMode === 1 ? 'in this thread' : 'on this board'}.\nAt least the following file${conflictingFiles.length > 1 ? 's are': ' is'} not unique: ${conflictingFiles}`,
					'redirect': redirect
				});
			}
		}
		// check all mime types before we try saving anything
		for (let i = 0; i < res.locals.numFiles; i++) {
			if (!mimeTypes.allowed(req.files.file[i].mimetype, allowedFileTypes)) {
				await deleteTempFiles(req).catch(e => console.error);
				return dynamicResponse(req, res, 400, 'message', {
					'title': 'Bad request',
					'message': `Mime type "${req.files.file[i].mimetype}" for "${req.files.file[i].name}" not allowed`,
					'redirect': redirect
				});
			}
		}
		// check for any mismatching supposed mimetypes from the actual file mimetype
		if (checkRealMimeTypes) {
			for (let i = 0; i < res.locals.numFiles; i++) {
				if (!(await mimeTypes.realMimeCheck(req.files.file[i]))) {
					deleteTempFiles(req).catch(e => console.error);
					return dynamicResponse(req, res, 400, 'message', {
						'title': 'Bad request',
						'message': `Mime type mismatch for file "${req.files.file[i].name}"`,
						'redirect': redirect
					});
				}
			}
		}
		// then upload, thumb, get metadata, etc.
		for (let i = 0; i < res.locals.numFiles; i++) {
			const file = req.files.file[i];
			let extension = path.extname(file.name) || file.name.substring(file.name.indexOf('.'));
			file.filename = file.sha256 + extension;

			//get metadata
			let processedFile = {
				spoiler: (res.locals.permLevel >= 4 || userPostSpoiler) && req.body.spoiler && req.body.spoiler.includes(file.name),
				hash: file.sha256,
				filename: file.filename, //could probably remove since we have hash and extension
				originalFilename: req.body.strip_filename && req.body.strip_filename.includes(file.name) ? file.filename : file.name,
				mimetype: file.mimetype,
				size: file.size,
				extension,
			};

			//type and subtype
			const [type, subtype] = processedFile.mimetype.split('/');
			let imageData;
			let firstFrameOnly = true;
			if (type === 'image') {
				processedFile.thumbextension = thumbExtension;

				///detect images with opacity for PNG thumbnails, set thumbextension before increment
				try {
					imageData = await imageIdentify(req.files.file[i].tempFilePath, null, true);
				} catch (e) {
					await deleteTempFiles(req).catch(e => console.error);
					return dynamicResponse(req, res, 400, 'message', {
						'title': 'Bad request',
						'message': `The server failed to process "${req.files.file[i].name}". Possible unsupported or corrupt file.`,
						'redirect': redirect
					});
				}

				if (imageData['Channel Statistics'] && imageData['Channel Statistics']['Opacity']) {//does this depend on GM version or anything?
					const opacityMaximum = imageData['Channel Statistics']['Opacity']['Maximum'];
					if (opacityMaximum !== '0.00 (0.0000)') {
						processedFile.thumbextension = '.png';
					}
				}
				processedFile.geometry = imageData.size;
				processedFile.geometryString = imageData.Geometry;
				const lteThumbSize = (processedFile.geometry.height <= thumbSize
					&& processedFile.geometry.width <= thumbSize);
				processedFile.hasThumb = !(mimeTypes.allowed(file.mimetype, {image: true})
					&& subtype !== 'png'
					&& lteThumbSize);
				if (processedFile.hasThumb //if it needs thumbnailing
					&& (!lteThumbSize //and its big enough
					&& file.mimetype === 'image/gif' //and its a gif
					&& (imageData['Delay'] != null || imageData['Iterations'] != null) //and its not a static gif (naive check)
					&& animatedGifThumbnails === true)) { //and animated thumbnails for gifs are enabled
					firstFrameOnly = false;
					processedFile.thumbextension = '.gif';
				}
			} else if (type === 'audio') {
				if (audioThumbnails) {
					// waveform has a transparent background, so force png
					processedFile.thumbextension = '.png';
				}
			} else {
				processedFile.thumbextension = thumbExtension;
			}

			//increment file count
			await Files.increment(processedFile);
			req.files.file[i].inced = true;
			//check if already exists
			const existsFull = await pathExists(`${uploadDirectory}/file/${processedFile.filename}`);
			processedFile.sizeString = formatSize(processedFile.size)

			if (mimeTypes.other.has(processedFile.mimetype)) {
				//"other" mimes from config, overrides main type to avoid codec issues in browser or ffmpeg for unsupported filetypes
				processedFile.hasThumb = false;
				processedFile.attachment = true;
				if (!existsFull) {
					await moveUpload(file, processedFile.filename, 'file');
				}
			} else {
				const existsThumb = await pathExists(`${uploadDirectory}/file/thumb-${processedFile.hash}${processedFile.thumbextension}`);
				switch (type) {
					case 'image': {
						if (!existsFull) {
							await moveUpload(file, processedFile.filename, 'file');
						}
						if (!existsThumb) {
							await imageThumbnail(processedFile, firstFrameOnly);
						}
						processedFile = fixGifs(processedFile);
						break;
					}
					case 'video': {
						//video metadata
						const videoData = await ffprobe(req.files.file[i].tempFilePath, null, true);
						videoData.streams = videoData.streams.filter(stream => stream.width != null); //filter to only video streams or something with a resolution
						if (videoData.streams.length <= 0) {
							//corrupt, or audio only?
							await deleteTempFiles(req).catch(e => console.error);
							return dynamicResponse(req, res, 400, 'message', {
								'title': 'Bad request',
								'message': 'Audio only video file not supported',
								'redirect': redirect
							});
						}
						processedFile.duration = videoData.format.duration;
						processedFile.durationString = timeUtils.durationString(videoData.format.duration*1000);
						processedFile.geometry = {width: videoData.streams[0].coded_width, height: videoData.streams[0].coded_height};
						processedFile.geometryString = `${processedFile.geometry.width}x${processedFile.geometry.height}`
						processedFile.hasThumb = true;
						if (!existsFull) {
							await moveUpload(file, processedFile.filename, 'file');
						}
						if (!existsThumb) {
							const numFrames = videoData.streams[0].nb_frames;
							if (numFrames === 'N/A' && subtype === 'webm') {
								await videoThumbnail(processedFile, processedFile.geometry, videoThumbPercentage+'%');
								let videoThumbStat = null;
								try {
									videoThumbStat = await fsStat(`${uploadDirectory}/file/thumb-${processedFile.hash}${processedFile.thumbextension}`);
								} catch (err) { /*ENOENT, the thumb failed to create. No need to handle this.*/	}
								if (!videoThumbStat || videoThumbStat.size === 0) {
									await videoThumbnail(processedFile, processedFile.geometry, 0);
								}
							} else {
								await videoThumbnail(processedFile, processedFile.geometry, ((numFrames === 'N/A' || numFrames <= 1) ? 0 : videoThumbPercentage+'%'));
							}
						}
						break;
					}
					case 'audio': {
						//audio metadata
						const audioData = await ffprobe(req.files.file[i].tempFilePath, null, true);
						processedFile.duration = audioData.format.duration;
						processedFile.durationString = timeUtils.durationString(audioData.format.duration*1000);
						processedFile.hasThumb = audioThumbnails;
						if (!existsFull) {
							await moveUpload(file, processedFile.filename, 'file');
						}
						if (audioThumbnails) {
							// audio thumbnail is always thumbSize x thumbSize
							processedFile.geometry = {
								thumbwidth: thumbSize, thumbheight: thumbSize,
							};
							if (!existsThumb) {
								await audioThumbnail(processedFile);
							}
						}
						break;
					}
					default:
						throw new Error(`invalid file mime type: ${processedFile}`);
				}
			}

			if (processedFile.hasThumb === true && type !== 'audio') {
				if (processedFile.geometry.width < thumbSize && processedFile.geometry.height < thumbSize) {
					//dont scale up thumbnail for smaller images
					processedFile.geometry.thumbwidth = processedFile.geometry.width;
					processedFile.geometry.thumbheight = processedFile.geometry.height;
				} else {
					const ratio = Math.min(thumbSize/processedFile.geometry.width, thumbSize/processedFile.geometry.height);
					processedFile.geometry.thumbwidth = Math.floor(Math.min(processedFile.geometry.width*ratio, thumbSize));
					processedFile.geometry.thumbheight = Math.floor(Math.min(processedFile.geometry.height*ratio, thumbSize));
				}
			}

			//delete the temp file
			await remove(file.tempFilePath);

			files.push(processedFile);
		}
	}
	// because express middleware is autistic i need to do this
	deleteTempFiles(req).catch(e => console.error);

	let userId = null;
	if (!salt) {
		//thread salt for IDs
		salt = (await randomBytesAsync(128)).toString('base64');
	}
	if (ids === true) {
//		if (res.locals.country.code === 'TOR') {
//			userId = '000000';
//		} else {
			const fullUserIdHash = createHash('sha256').update(salt + res.locals.ip.raw).digest('hex');
			userId = fullUserIdHash.substring(fullUserIdHash.length-6);
//		}
	}
	let country = null;
	if (flags === true) {
		country = res.locals.country;
	}
	let password = null;
	if (req.body.postpassword) {
		password = createHash('sha256').update(postPasswordSecret + req.body.postpassword).digest('base64');
	}

	//spoiler files only if board settings allow
	const spoiler = (res.locals.permLevel >= 4 || userPostSpoiler) && req.body.spoiler_all ? true : false;

	//forceanon hide reply subjects so cant be used as name for replies
	//forceanon and sageonlyemail only allow sage email
	let subject = (res.locals.permLevel >= 4 && req.body.thread && (disableReplySubject || forceAnon)) ? null : req.body.subject;
	let email = (res.locals.permLevel < 4 || (!forceAnon && !sageOnlyEmail) || req.body.email === 'sage') ? req.body.email : null;

	//get name, trip and cap
	const { name, tripcode, capcode } = await nameHandler(req.body.name, res.locals.permLevel,
		res.locals.board.settings, res.locals.board.owner, res.locals.user ? res.locals.user.username : null);
	//get message, quotes and crossquote array
	const nomarkup = prepareMarkdown(req.body.message, true);
	const { message, quotes, crossquotes } = await messageHandler(nomarkup, req.params.board, req.body.thread);

	//build post data for db. for some reason all the property names are lower case :^)
	const data = {
		'date': new Date(),
		name,
		country,
		'board': req.params.board,
		tripcode,
		capcode,
		subject,
		'message': message || null,
		'messagehash': messageHash || null,
		'nomarkup': nomarkup || null,
		'thread': req.body.thread || null,
		password,
		email,
		spoiler,
		'banmessage': null,
		userId,
		'ip': res.locals.ip,
		files,
		'reports': [],
		'globalreports': [],
		quotes, //posts this post replies to
		crossquotes, //quotes to other threads in same board
		'backlinks': [], //posts replying to this post
	}

	if (!req.body.thread) {
		//if this is a thread, add thread specific properties
		Object.assign(data, {
			'replyposts': 0,
			'replyfiles': 0,
			//NOTE: these are numbers because we XOR them for toggling in action handler
			'sticky': Mongo.NumberInt(0),
			'locked': Mongo.NumberInt(0),
			'bumplocked': Mongo.NumberInt(0),
			'cyclic': Mongo.NumberInt(0),
			'salt': salt
		});
	}

	const postId = await Posts.insertOne(res.locals.board, data, thread);

	let enableCaptcha = false; //make this returned from some function, refactor and move the next section to another file
	const pphTriggerActive = (pphTriggerAction > 0 && pphTrigger > 0);
	const tphTriggerActive = (tphTriggerAction > 0 && tphTrigger > 0);
	if (pphTriggerAction || tphTriggerActive) { //if a trigger is enabled
		const triggerUpdate = {
			'$set': {},
		};
		//and a setting needs to be updated
		const pphTriggerUpdate = (pphTriggerAction < 3 && captchaMode < pphTriggerAction)
			|| (pphTriggerAction === 3 && lockMode < 1)
			|| (pphTriggerAction === 4 && lockMode < 2);
		const tphTriggerUpdate = (tphTriggerAction < 3 && captchaMode < tphTriggerAction)
			|| (tphTriggerAction === 3 && lockMode < 1)
			|| (tphTriggerAction === 4 && lockMode < 2);
		if (tphTriggerUpdate || pphTriggerUpdate) {
			const hourPosts = await Stats.getHourPosts(res.locals.board._id);
			const calcTriggerMode = (update, trigger, triggerAction, stat) => { //todo: move this somewhere else
				if (trigger > 0 && stat >= trigger) {
					//update in memory for other stuff done e.g. rebuilds
					if (triggerAction < 3) {
						res.locals.board.settings.captchaMode = triggerAction;
						update['$set']['settings.captchaMode'] = triggerAction;
						enableCaptcha = true; //todo make this also returned after moving/refactoring this
					} else {
						res.locals.board.settings.lockMode = triggerAction-2;
						update['$set']['settings.lockMode'] = triggerAction-2;
					}
					return true;
				}
				return false;
			}
			const updatedPphTrigger = pphTriggerUpdate && calcTriggerMode(triggerUpdate, pphTrigger, pphTriggerAction, hourPosts.pph);
			const updatedTphTrigger = tphTriggerUpdate && calcTriggerMode(triggerUpdate, tphTrigger, tphTriggerAction, hourPosts.tph);
			if (updatedPphTrigger || updatedTphTrigger) {
				//set it in the db
				await Boards.updateOne(res.locals.board._id, triggerUpdate);
				await cache.sadd('triggered', res.locals.board._id);
			}
		}
	}

	//for cyclic threads, delete posts beyond bump limit
	if (thread && thread.cyclic && thread.replyposts > replyLimit) {
		const cyclicOverflowPosts = await Posts.db.find({
			'thread': data.thread,
			'board': req.params.board
		}).sort({
			'postId': -1,
		}).skip(replyLimit).toArray();
		if (cyclicOverflowPosts.length > 0) {
			await deletePosts(cyclicOverflowPosts, req.params.board);
			const fileCount = cyclicOverflowPosts.reduce((acc, post) => {
				return acc + (post.files ? post.files.length : 0);
			}, 0);
			//reduce amount counted in post by number of posts deleted
			await Posts.db.updateOne({
				'postId': thread.postId,
				'board': res.locals.board._id
			}, {
				'$inc': { //negative increment
					'replyposts': -cyclicOverflowPosts.length,
					'replyfiles': -fileCount
				}
			});
		}
	}

	const successRedirect = `/${req.params.board}/${req.path.endsWith('/modpost') ? 'manage/' : ''}thread/${req.body.thread || postId}.html#${postId}`;

	const buildOptions = {
		'threadId': data.thread || postId,
		'board': res.locals.board
	};

	if (req.headers['x-using-live'] != null && data.thread) {
		//defer build and post will come live
		res.json({
			'postId': postId,
			'redirect': successRedirect
		});
		buildQueue.push({
			'task': 'buildThread',
			'options': buildOptions
		});
	} else {
		//build immediately and refresh when built
		await buildThread(buildOptions);
		if (req.headers['x-using-xhr'] != null) {
			res.json({
				'postId': postId,
				'redirect': successRedirect
			});
		} else {
			res.redirect(successRedirect);
		}
	}

	if (data.thread) {
		//only emit for replies and with some omissions
		const projectedPost = {
			'date': data.date,
			'name': data.name,
			'country': data.country,
			'board': req.params.board,
			'tripcode': data.tripcode,
			'capcode': data.capcode,
			'subject': data.subject,
			'message': data.message,
			'nomarkup': data.nomarkup,
			'thread': data.thread,
			'postId': postId,
			'email': data.email,
			'spoiler': data.spoiler,
			'banmessage': null,
			'userId': data.userId,
			'files': data.files,
			'reports': [],
			'globalreports': [],
			'quotes': data.quotes,
			'backlinks': [],
			'replyposts': 0,
			'replyfiles': 0,
			'sticky': data.sticky,
			'locked': data.locked,
			'bumplocked': data.bumplocked,
			'cyclic': data.cyclic,
		}
		Socketio.emitRoom(`${res.locals.board._id}-${data.thread}`, 'newPost', projectedPost);
	}

	//now add other pages to be built in background
	if (enableCaptcha) {
		if (res.locals.board.settings.captchaMode == 2) {
			//only delete threads if all posts require threads, otherwise just build board pages for thread captcha
			await remove(`${uploadDirectory}/html/${req.params.board}/thread/`); //not deleting json cos it doesnt need to be
		}
		const endPage = Math.ceil(threadLimit/10);
		buildQueue.push({
			'task': 'buildBoardMultiple',
			'options': {
				'board': res.locals.board,
				'startpage': 1,
				'endpage': endPage
			}
		});
	} else if (data.thread) {
		//refersh pages
		const threadPage = await Posts.getThreadPage(req.params.board, thread);
		if (data.email === 'sage' || thread.bumplocked) {
			//refresh the page that the thread is on
			buildQueue.push({
				'task': 'buildBoard',
				'options': {
					'board': res.locals.board,
					'page': threadPage
				}
			});
		} else {
			//if not saged, it will bump so we should refresh any pages above it as well
			buildQueue.push({
				'task': 'buildBoardMultiple',
				'options': {
					'board': res.locals.board,
					'startpage': 1,
					'endpage': threadPage
				}
			});
		}
	} else if (!data.thread) {
		//new thread, prunes any old threads before rebuilds
		const prunedThreads = await Posts.pruneThreads(res.locals.board);
		if (prunedThreads.length > 0) {
			await deletePosts(prunedThreads, req.params.board);
		}
		if (!enableCaptcha) {
			const endPage = Math.ceil(threadLimit/10);
			buildQueue.push({
				'task': 'buildBoardMultiple',
				'options': {
					'board': res.locals.board,
					'startpage': 1,
					'endpage': endPage
				}
			});
		}
	}

	//always rebuild catalog for post counts and ordering
	buildQueue.push({
		'task': 'buildCatalog',
		'options': {
			'board': res.locals.board,
		}
	});

}


code:
'use strict';

const uploadDirectory = require(__dirname+'/../../helpers/files/uploadDirectory.js')
	, { remove } = require('fs-extra')
	, { Posts } = require(__dirname+'/../../db/')
	, quoteHandler = require(__dirname+'/../../helpers/posting/quotes.js')
	, { markdown } = require(__dirname+'/../../helpers/posting/markdown.js')
	, sanitize = require('sanitize-html')
	, sanitizeOptions = require(__dirname+'/../../helpers/posting/sanitizeoptions.js');

module.exports = async (req, res) => {

	const { threads, postIds, postMongoIds } = res.locals.posts.reduce((acc, p) => {
		acc.postIds.push(p.postId);
		acc.postMongoIds.push(p._id);
		if (p.thread === null) {
			acc.threads.push(p);
		}
		return acc;
	}, { threads: [], postIds: [], postMongoIds: [] });

//console.log(threads, postIds, postMongoIds)

	const backlinkRebuilds = new Set();
	const bulkWrites = [];

	if (threads.length > 0) {
		//threads moved, so their html/json doesnt need to exist anymore
		await Promise.all(threads.map(thread => {
			return Promise.all([
				remove(`${uploadDirectory}/html/${thread.board}/thread/${thread.postId}.html`),
				remove(`${uploadDirectory}/json/${thread.board}/thread/${thread.postId}.json`)
			]);
		}));
   	}

	//remove backlinks from selected posts that link to unselected posts
	bulkWrites.push({
		'updateMany': {
			'filter': {
				'_id': {
					'$in': postMongoIds
				}
			},
			'update': {
				'$pull': {
					'backlinks': {
						'postId': {
							'$nin': postIds
						}
					}
				}
			}
		}
	});

	for (let j = 0; j < res.locals.posts.length; j++) {
		const post = res.locals.posts[j];
//note: needs debugging
//		if (post.crossquotes.filter(c => c.thread === req.body.move_to_thread).length > 0) {
			//a crossquote is in the thread we move to, so need to remarkup and add backlinks to those posts
			backlinkRebuilds.add(post._id);
//		}
		//get backlinks for posts to remarkup
		for (let i = 0; i < post.backlinks.length; i++) {
			backlinkRebuilds.add(post.backlinks[i]._id);
		}
		//remove dead backlinks to this post
		if (post.quotes.length > 0) {
			backlinkRebuilds.add(post._id);
			bulkWrites.push({
				'updateMany': {
					'filter': {
						'_id': {
							'$in': post.quotes.map(q => q._id)
						}
					},
					'update': {
						'$pull': {
							'backlinks': {
								'postId': post.postId
							}
						}
					}
				}
			});
		}
	}

	//increase file/reply count in thread we are moving the posts to
	const { replyposts, replyfiles } = res.locals.posts.reduce((acc, p) => {
		acc.replyposts += 1;
		acc.replyfiles += p.files.length;
		return acc;
	}, { replyposts: 0, replyfiles: 0 });
	bulkWrites.push({
		'updateOne': {
			'filter': {
				'postId': req.body.move_to_thread,
                'board': req.params.board
			},
			'update': {
				'$inc': {
					'replyposts': replyposts,
					'replyfiles': replyfiles,
				}
			}
		}
	});

	const movedPosts = await Posts.move(postMongoIds, req.body.move_to_thread).then(result => result.modifiedCount);

	//get posts that quoted moved posts so we can remarkup them
	if (backlinkRebuilds.size > 0) {
		const remarkupPosts = await Posts.globalGetPosts([...backlinkRebuilds]);
		await Promise.all(remarkupPosts.map(async post => { //doing these all at once
			if (post.nomarkup && post.nomarkup.length > 0) {
				//redo the markup
				let message = markdown(post.nomarkup);
				let { quotedMessage, threadQuotes, crossQuotes } = await quoteHandler.process(post.board, message, post.thread); // req.body.move_to_thread);
//console.log(quotedMessage, threadQuotes, crossQuotes)
				message = sanitize(quotedMessage, sanitizeOptions.after);
				bulkWrites.push({
					'updateMany': {
						'filter': {
							'_id': {
								'$in': threadQuotes.map(q => q._id)
							}
						},
						'update': {
							'$push': {
								'backlinks': { _id: post._id, postId: post.postId }
							}
						}
					}
				});
				bulkWrites.push({
					'updateOne': {
						'filter': {
							'_id': post._id
						},
						'update': {
							'$set': {
								'quotes': threadQuotes,
								'crossquotes': crossQuotes,
								'message': message
							}
						}
					}
				});
			}
		}));
	}

//console.log(require('util').inspect(bulkWrites, {depth:null}))

/*
- post A quotes B, then A is moved to another thread: WORKS (removes backlink on B)
- moving post A back into thread with B and backlink gets readded: WORKS
- move post B out and backlink from A gets removed: WORKS
- moving post B post back into thread with A and backlinks re-added: FAIL (need to come up with solution, but this is an uncommon occurence)
*/

	//bulkwrite it all
	if (bulkWrites.length > 0) {
		await Posts.db.bulkWrite(bulkWrites);
	}

	return {
		message: 'Moved posts',
		action: movedPosts > 0,
	};

}

repo_name: dolphin/jschan

license: mit


code:
'use strict';

module.exports = (req, res, next) => {

	res.clearCookie('captchaid');
	return res.redirect('/captcha.html');

}


code:
'use strict';

const { Accounts } = require(__dirname+'/../../db/')
	, dynamicResponse = require(__dirname+'/../../helpers/dynamic.js');

module.exports = async (req, res, next) => {

	const original = req.body.username; //stored but not used yet
	const username = original.toLowerCase(); //lowercase to prevent duplicates with mixed case
	const password = req.body.password;

	const account = await Accounts.findOne(username);

	// if the account exists reject
	if (account != null) {
		return dynamicResponse(req, res, 409, 'message', {
			'title': 'Conflict',
			'message': 'Account with this username already exists',
			'redirect': '/register.html'
		});
	}

	// add account to db. password is hashed in db model func for easier tests
	await Accounts.insertOne(original, username, password, 4);

	return res.redirect('/login.html');

}


code:
'use strict';

const { Bans } = require(__dirname+'/../../db/');

module.exports = async (req, res, next) => {

	return Bans.removeMany(req.params.board, req.body.checkedbans).then(result => result.deletedCount);

}


code:
'use strict';

const { ObjectId } = require(__dirname+'/../../db/db.js');

module.exports = (req, res) => {

	const report = {
		'id': ObjectId(),
		'reason': req.body.report_reason,
		'date': new Date(),
		'ip': {
			'single': res.locals.ip.single,
			'raw': res.locals.ip.raw
		}
	}

	const ret = {
		message: `Reported ${res.locals.posts.length} post${res.locals.posts.length > 1 ? 's' : ''}`,
		action: '$push',
		query: {}
	};
	const query = {
		'$each': [report],
		'$slice': -5 //limit number of  reports
	}
	if (req.body.global_report) {
		ret.query['globalreports'] = query;
	}
	if (req.body.report) {
		ret.query['reports'] = query;
	}

	return ret;

}


code:
'use strict';

const { Boards, Accounts } = require(__dirname+'/../../db/')
	, dynamicResponse = require(__dirname+'/../../helpers/dynamic.js');

module.exports = async (req, res, next) => {

	const moderatesBoard = res.locals.user.modBoards.includes(req.body.board);
	const ownsBoard = res.locals.user.ownedBoards.includes(req.body.board);
	if (!ownsBoard && !moderatesBoard) {
		return dynamicResponse(req, res, 400, 'message', {
			'title': 'Bad request',
			'message': 'You do not own or moderate that board',
			'redirect': `/account.html`
		});
	}

	if (ownsBoard) {
		await Promise.all([
			Accounts.removeOwnedBoard(res.locals.user.username, req.body.board),
			Boards.setOwner(req.body.board, null),
		]);
	} else if (moderatesBoard) {
		await Promise.all([
			Boards.removeModerator(req.body.board, res.locals.user.username),
			Accounts.removeModBoard([res.locals.user.username], req.body.board),
		]);
	}

	return dynamicResponse(req, res, 200, 'message', {
		'title': 'Success',
		'message': `Resigned from ${ownsBoard ? 'owner' : 'moderator'} position on /${req.body.board}/`,
		'redirect': `/account.html`
	});

}


code:
'use strict';

module.exports = (posts) => {

	// filter to ones not spoilered
	const filteredPosts = posts.filter(post => {
  		return !post.spoiler && post.files.length > 0;
	});

	if (filteredPosts.length === 0) {
		return {
			message:'No post(s) to spoiler'
		};
	}

	return {
		message: `Spoilered ${filteredPosts.length} post(s)`,
		action: '$set',
		query: {
			'spoiler': true
		}
	};

}


code:
'use strict';

const { NumberInt } = require(__dirname+'/../../db/db.js')

module.exports = (posts) => {

	const filteredposts = posts.filter(post => {
		return !post.thread
	})

	if (filteredposts.length === 0) {
		return {
			message: 'No thread(s) to sticky',
		};
	}

	return {
		message: `Toggled sticky for ${filteredposts.length} thread(s)`,
		action: '$bit',
		query: {
			'sticky': {
				'xor': NumberInt(1)
			},
		}
	};

}


code:
'use strict';

const { Boards, Accounts } = require(__dirname+'/../../db/')
	, dynamicResponse = require(__dirname+'/../../helpers/dynamic.js');

module.exports = async (req, res, next) => {

	const newOwner = await Accounts.findOne(req.body.username.toLowerCase());

	if (!newOwner) {
		return dynamicResponse(req, res, 400, 'message', {
			'title': 'Bad request',
			'message': 'Cannot transfer to account that does not exist',
			'redirect': `/${req.params.board}/manage/settings.html`
		});
	}

	//remove owned board from current account
	await Accounts.removeOwnedBoard(res.locals.board.owner, req.params.board)

	//remove new owner as moderator if they were one
	if (res.locals.board.settings.moderators.includes(newOwner._id)) {
		await Boards.removeModerator(req.params.board, res.locals.user.username)
		await Accounts.removeModBoard([newOwner._id], req.params.board)
	}

	//set owner in memory and in db
	res.locals.board.owner = newOwner._id;
	await Boards.setOwner(req.params.board, res.locals.board.owner);
	//add ownership to new owner account
	await Accounts.addOwnedBoard(newOwner._id, req.params.board);

	return dynamicResponse(req, res, 200, 'message', {
		'title': 'Success',
		'message': 'Transferred ownership',
		'redirect': `/${req.params.board}/index.html`
	});

}


code:
'use strict';

const path = require('path')
	, { remove, pathExists } = require('fs-extra')
	, { checkRealMimeTypes } = require(__dirname+'/../../configs/main.js')
	, uploadDirectory = require(__dirname+'/../../helpers/files/uploadDirectory.js')
	, moveUpload = require(__dirname+'/../../helpers/files/moveupload.js')
	, mimeTypes = require(__dirname+'/../../helpers/files/mimetypes.js')
	, imageIdentify = require(__dirname+'/../../helpers/files/imageidentify.js')
	, deleteTempFiles = require(__dirname+'/../../helpers/files/deletetempfiles.js')
	, dynamicResponse = require(__dirname+'/../../helpers/dynamic.js')
	, { Boards } = require(__dirname+'/../../db/')
	, buildQueue = require(__dirname+'/../../queue.js');

module.exports = async (req, res, next) => {

	const redirect = `/${req.params.board}/manage/banners.html`

	// check all mime types before we try saving anything
	for (let i = 0; i < res.locals.numFiles; i++) {
		if (!mimeTypes.allowed(req.files.file[i].mimetype, {
				//banners can be static image or animated (gif, apng, etc)
				image: true,
				animatedImage: true,
				video: false,
				audio: false,
				other: false
			})) {
			await deleteTempFiles(req).catch(e => console.error);
			return dynamicResponse(req, res, 400, 'message', {
				'title': 'Bad request',
				'message': `Invalid file type for ${req.files.file[i].name}. Mimetype ${req.files.file[i].mimetype} not allowed.`,
				'redirect': redirect
			});
		}
	}

	// check for any mismatching supposed mimetypes from the actual file mimetype
	if (checkRealMimeTypes) {
		for (let i = 0; i < res.locals.numFiles; i++) {
			if (!(await mimeTypes.realMimeCheck(req.files.file[i]))) {
				deleteTempFiles(req).catch(e => console.error);
				return dynamicResponse(req, res, 400, 'message', {
					'title': 'Bad request',
					'message': `Mime type mismatch for file "${req.files.file[i].name}"`,
					'redirect': redirect
				});
			}
		}
	}

	const filenames = [];
	for (let i = 0; i < res.locals.numFiles; i++) {
		const file = req.files.file[i];
		const filename = file.sha256 + path.extname(file.name);
		file.filename = filename;

		//check if already exists
		const exists = await pathExists(`${uploadDirectory}/banner/${req.params.board}/${filename}`);

		if (exists) {
			await remove(file.tempFilePath);
			continue;
		}

		//add to list after checking it doesnt already exist
		filenames.push(filename);

		//get metadata from tempfile
		const imageData = await imageIdentify(req.files.file[i].tempFilePath, null, true);
		let geometry = imageData.size;
		if (Array.isArray(geometry)) {
			geometry = geometry[0];
		}

		//make sure its 300x100 banner
		if (geometry.width !== 300 || geometry.height !== 100) {
			await deleteTempFiles(req).catch(e => console.error);
			return dynamicResponse(req, res, 400, 'message', {
				'title': 'Bad request',
				'message': `Invalid file ${file.name}. Banners must be 300x100.`,
				'redirect': redirect
			});
		}

		//then upload it
		await moveUpload(file, filename, `banner/${req.params.board}`);

		//and delete the temp file
		await remove(file.tempFilePath);

	}

	deleteTempFiles(req).catch(e => console.error);

	// no new banners
	if (filenames.length === 0) {
		return dynamicResponse(req, res, 400, 'message', {
			'title': 'Bad request',
			'message': `Banner${res.locals.numFiles > 1 ? 's' : ''} already exist${res.locals.numFiles > 1 ? '' : 's'}`,
			'redirect': redirect
		});
	}

	// add banners to the db
	await Boards.addBanners(req.params.board, filenames);

	// add banners to board in memory
	res.locals.board.banners = res.locals.board.banners.concat(filenames);

	if (filenames.length > 0) {
		//add public banners page to build queue
		buildQueue.push({
	        'task': 'buildBanners',
			'options': {
				'board': res.locals.board,
			}
		});
	}

	return dynamicResponse(req, res, 200, 'message', {
		'title': 'Success',
		'message': `Uploaded ${filenames.length} new banners.`,
		'redirect': redirect
	});

}


code:
'use strict';

module.exports = async (req, res, next) => {

	res
	.set('Cache-Control', 'private, max-age=5')
	.render('account', {
		csrf: req.csrfToken(),
		user: res.locals.user,
	});

}


code:
'use strict';

const { buildBanners } = require(__dirname+'/../../helpers/tasks.js');

module.exports = async (req, res, next) => {

	let html;
	try {
		html = await buildBanners({ board: res.locals.board });
	} catch (err) {
		return next(err);
	}

	return res.send(html);

}


code:
'use strict';

const { buildBypass } = require(__dirname+'/../../helpers/tasks.js');

module.exports = async (req, res, next) => {

	let html;
	try {
		html = await buildBypass(res.locals.minimal);
	} catch (err) {
		return next(err);
	}

	return res.send(html);

}


code:
'use strict';

const Posts = require(__dirname+'/../../db/posts.js')
	, buildQueue = require(__dirname+'/../../queue.js')
	, { buildBoard } = require(__dirname+'/../../helpers/tasks.js');

module.exports = async (req, res, next) => {

	const page = req.params.page === 'index' ? 1 : Number(req.params.page);
	let html, json;
	try {
		const maxPage = Math.min(Math.ceil((await Posts.getPages(req.params.board)) / 10), Math.ceil(res.locals.board.settings.threadLimit/10)) || 1;
		if (page > maxPage) {
			return next();
		}
		({ html, json } = await buildBoard({
			board: res.locals.board,
			page,
			maxPage
		}));
	} catch (err) {
		return next(err);
	}

	if (req.path.endsWith('.json')) {
		return res.json(json);
	} else {
		return res.send(html);
	}

}


code:
'use strict';

const { enableWebring } = require(__dirname+'/../../configs/main.js')
	, { Boards, Webring } = require(__dirname+'/../../db/')
	, cache = require(__dirname+'/../../redis.js')
	, { relativeColor, relativeString } = require(__dirname+'/../../helpers/timeutils.js')
	, pageQueryConverter = require(__dirname+'/../../helpers/pagequeryconverter.js')
	, limit = 20;

module.exports = async (req, res, next) => {

	const { page, offset, queryString } = pageQueryConverter(req.query, limit);
	const direction = req.query.direction && req.query.direction === 'asc' ? 1 : -1;
	const search = (typeof req.query.search === 'string' ? req.query.search : null);
	let sort = req.query.sort && req.query.sort === 'activity' ? 'activity' : 'popularity';

	const cacheQuery = new URLSearchParams({ direction, sort, search, page });
	cacheQuery.sort();
	const cacheQueryString = cacheQuery.toString();
	const cached = await cache.get(`boardlist:${cacheQueryString}`);

	let localBoards, webringBoards, localPages, webringPages, maxPage;
	if (cached) {
		({ localBoards, webringBoards, localPages, webringPages, maxPage } = cached);
	} else {
		if (sort === 'activity') {
			sort = {
				'lastPostTimestamp': direction
			}
		} else {
			sort = {
				'ips': direction,
				'pph': direction,
				'sequence_value': direction
			};
		}

		let filter = {};
		if (req.query.search && search) {
			filter = {
				'search': search
			}
		}

		try {
			[ localBoards, localPages, webringBoards, webringPages ] = await Promise.all([
				Boards.boardSort(offset, limit, sort, filter),
				Boards.count(filter),
				enableWebring ? Webring.boardSort(offset, limit, sort, filter) : null,
				enableWebring ? Webring.count(filter) : 0,
			]);
			localPages = Math.ceil(localPages / limit);
			webringPages = Math.ceil(webringPages / limit);
			maxPage = Math.max(localPages, webringPages);
		} catch (err) {
			return next(err);
		}
		cache.set(`boardlist:${cacheQueryString}`, { localBoards, localPages, webringBoards, webringPages, maxPage }, 60);
	}

	const now = new Date();
	if (localBoards) {
		for (let i = 0; i < localBoards.length; i++) {
			if (localBoards[i].lastPostTimestamp) {
				const lastPostDate = new Date(localBoards[i].lastPostTimestamp);
				localBoards[i].lastPostTimestamp = {
					text: relativeString(now, lastPostDate),
					color: relativeColor(now, lastPostDate)
				}
			}
		}
	}
	if (webringBoards) {
		for (let i = 0; i < webringBoards.length; i++) {
			if (webringBoards[i].lastPostTimestamp) {
				const lastPostDate = new Date(webringBoards[i].lastPostTimestamp);
				webringBoards[i].lastPostTimestamp = {
					text: relativeString(now, lastPostDate),
					color: relativeColor(now, lastPostDate)
				}
			}
		}
	}

	res
	.set('Cache-Control', 'public, max-age=60');

	if (req.path === '/boards.json') {
		res.json({
			localBoards,
			webringBoards,
			page,
			maxPage,
		});
	} else {
		res.render('boardlist', {
			localBoards,
			webringBoards,
			page,
			maxPage,
			query: req.query,
			search,
			queryString,
		});
	}

}


code:
'use strict';

const { Captchas, Ratelimits } = require(__dirname+'/../../db/')
	, { secureCookies, rateLimitCost, captchaOptions } = require(__dirname+'/../../configs/main.js')
	, generateCaptcha = captchaOptions.type !== 'google' && captchaOptions !== "hcaptcha" ? require(__dirname+`/../../helpers/captcha/generators/${captchaOptions.type}.js`) : null
	, production = process.env.NODE_ENV === 'production';

module.exports = async (req, res, next) => {

	if (!production && req.cookies['captchaid'] != null) {
		return res.redirect(`/captcha/${req.cookies['captchaid']}.jpg`);
	}

	let captchaId;
	let maxAge = 5*60*1000;
	try {
		if (!res.locals.tor) {
			const ratelimit = await Ratelimits.incrmentQuota(res.locals.ip.single, 'captcha', rateLimitCost.captcha);
			if (ratelimit > 100) {
				return res.status(429).redirect('/file/ratelimit.png');
			}
		}
		let id;
		const captchaCount = await Captchas.db.estimatedDocumentCount();
		if (captchaCount >= captchaOptions.generateLimit) {
			//TODOs: round robin sample? store in redis? only sample random with longer than x expiry?
			const captchaSample = await Captchas.randomSample();
			const randomCaptcha = captchaSample[0];
			captchaId = randomCaptcha._id;
			maxAge = Math.abs((randomCaptcha.expireAt.getTime()+maxAge) - Date.now()); //abs in case mongo hasn't pruned, and will not be too big since it can't be too far away from pruning anyway
		} else {
			({ captchaId } = await generateCaptcha());
		}
	} catch (err) {
		return next(err);
	}

	return res
		.cookie('captchaid', captchaId.toString(), {
			'secure': production && secureCookies && (req.headers['x-forwarded-proto'] === 'https'),
			'sameSite': 'strict',
			maxAge,
		})
		.redirect(`/captcha/${captchaId}.jpg`);

}


code:
'use strict';

const { buildCaptcha } = require(__dirname+'/../../helpers/tasks.js');

module.exports = async (req, res, next) => {

	let html;
	try {
		html = await buildCaptcha();
	} catch (err) {
		return next(err);
	}

	return res.send(html);

}


code:
'use strict';

const { buildCatalog } = require(__dirname+'/../../helpers/tasks.js');

module.exports = async (req, res, next) => {

	let html;
	try {
		({ html } = await buildCatalog({ board: res.locals.board }));
	} catch (err) {
		return next(err);
	}

	if (req.path.endsWith('.json')) {
		return res.json(json);
	} else {
		return res.send(html);
	}

}


code:
'use strict';

const { buildChangePassword } = require(__dirname+'/../../helpers/tasks.js');

module.exports = async (req, res, next) => {

	let html;
	try {
		html = await buildChangePassword();
	} catch (err) {
		return next(err);
	}

	return res.send(html);

}


code:
'use strict';

const { buildCreate } = require(__dirname+'/../../helpers/tasks.js');

module.exports = async (req, res, next) => {

	return res.render('create');

/*
	let html;
	try {
		html = await buildCreate();
	} catch (err) {
		return next(err);
	}

	return res.send(html);
*/

}


code:
'use strict';

const { Accounts } = require(__dirname+'/../../../db/')
	, pageQueryConverter = require(__dirname+'/../../../helpers/pagequeryconverter.js')
	, limit = 20;

module.exports = async (req, res, next) => {

	const { page, offset, queryString } = pageQueryConverter(req.query, limit);

	let filter = {};
	const username = (typeof req.query.username === 'string' ? req.query.username : null);
	if (username) {
		filter['_id'] = username;
	}
	const uri = (typeof req.query.uri === 'string' ? req.query.uri  : null);
	if (uri) {
		filter['$or'] = [
			{
				'ownedBoards': uri
			},
			{
				'modBoards': uri
			},
		];
	}

	let accounts, maxPage;
	try {
		[accounts, maxPage] = await Promise.all([
			Accounts.find(filter, offset, limit),
			Accounts.count(filter),
		]);
		maxPage = Math.ceil(maxPage/limit);
	} catch (err) {
		return next(err)
	}

	res
	.set('Cache-Control', 'private, max-age=5')
	.render('globalmanageaccounts', {
		csrf: req.csrfToken(),
		queryString,
		username,
		uri,
		accounts,
		page,
		maxPage,
	});

}


code:
'use strict';

const { Bans } = require(__dirname+'/../../../db/');

module.exports = async (req, res, next) => {

	let bans;
	try {
		bans = await Bans.getGlobalBans();
	} catch (err) {
		return next(err)
	}

	res
	.set('Cache-Control', 'private, max-age=5')
	.render('globalmanagebans', {
		csrf: req.csrfToken(),
		bans,
	});

}


code:
'use strict';

const { Boards } = require(__dirname+'/../../../db/')
	, { relativeColor, relativeString } = require(__dirname+'/../../../helpers/timeutils.js')
	, pageQueryConverter = require(__dirname+'/../../../helpers/pagequeryconverter.js')
	, limit = 20;

module.exports = async (req, res, next) => {

	const { page, offset, queryString } = pageQueryConverter(req.query, limit);
	const direction = req.query.direction && req.query.direction === 'asc' ? 1 : -1;
	const search = (typeof req.query.search === 'string' ? req.query.search : null);
	let sort = req.query.sort && req.query.sort === 'activity' ? 'activity' : 'popularity';

	if (sort === 'activity') {
		sort = {
			'lastPostTimestamp': direction
		}
	} else {
		sort = {
			'ips': direction,
			'pph': direction,
			'sequence_value': direction
		};
	}

	let filter = {};
	if (req.query.search && search) {
		filter = {
			'search': search
		}
	}
	filter.filter_sfw = req.query.filter_sfw != null;
	filter.filter_abandoned = req.query.filter_abandoned != null;
	filter.filter_unlisted = req.query.filter_unlisted != null;

	let localBoards, pages, maxPage;
	try {
		[ localBoards, pages ] = await Promise.all([
			Boards.boardSort(offset, limit, sort, filter, true),
			Boards.count(filter, true),
		]);
		maxPage = Math.ceil(pages / limit);
	} catch (err) {
		return next(err);
	}

	const now = new Date();
	if (localBoards) {
		for (let i = 0; i < localBoards.length; i++) {
			if (localBoards[i].lastPostTimestamp) {
				const lastPostDate = new Date(localBoards[i].lastPostTimestamp);
				localBoards[i].lastPostTimestamp = {
					text: relativeString(now, lastPostDate),
					color: relativeColor(now, lastPostDate)
				}
			}
		}
	}
	res.set('Cache-Control', 'private, max-age=60');

	if (req.path.endsWith('/boards.json')) {
		res.json({
			localBoards,
			page,
			maxPage,
		});
	} else {
		res.render('globalmanageboardlist', {
			localBoards,
			page,
			maxPage,
			query: req.query,
			search,
			queryString,
		});
	}

}


code:
'use strict';

module.exports = {
	globalManageReports: require(__dirname+'/reports.js'),
	globalManageBans: require(__dirname+'/bans.js'),
	globalManageLogs: require(__dirname+'/logs.js'),
	globalManageBoards: require(__dirname+'/boards.js'),
	globalManageRecent: require(__dirname+'/recent.js'),
	globalManageNews: require(__dirname+'/news.js'),
	globalManageAccounts: require(__dirname+'/accounts.js'),
	globalManageSettings: require(__dirname+'/settings.js'),
}


code:
'use strict';

const { Modlogs } = require(__dirname+'/../../../db/')
	, pageQueryConverter = require(__dirname+'/../../../helpers/pagequeryconverter.js')
	, decodeQueryIP = require(__dirname+'/../../../helpers/decodequeryip.js')
	, limit = 50;

module.exports = async (req, res, next) => {

	const { page, offset, queryString } = pageQueryConverter(req.query, limit);

    let filter = {};
	const username = (typeof req.query.username === 'string' ? req.query.username : null);
    if (username && !Array.isArray(username)) {
        filter.user = username;
    }
	const uri = (typeof req.query.uri === 'string' ? req.query.uri : null);
    if (uri && !Array.isArray(uri)) {
        filter.board = uri;
    }
	const ipMatch = decodeQueryIP(req.query, res.locals.permLevel);
	if (ipMatch instanceof RegExp) {
		filter['ip.single'] = ipMatch;
	} else if (typeof ipMatch === 'string') {
		filter['ip.raw'] = ipMatch;
	}

	let logs, maxPage;
	try {
		[logs, maxPage] = await Promise.all([
			Modlogs.find(filter, offset, limit),
			Modlogs.count(filter),
		]);
		maxPage = Math.ceil(maxPage/limit);
	} catch (err) {
		return next(err)
	}

	res
	.set('Cache-Control', 'private, max-age=5')
	.render('globalmanagelogs', {
		csrf: req.csrfToken(),
		queryString,
		username,
		uri,
		ip: ipMatch ? req.query.ip : null,
		logs,
		page,
		maxPage,
	});

}


code:
'use strict';

const { News } = require(__dirname+'/../../../db/');

module.exports = async (req, res, next) => {

	let news;
	try {
		news = await News.find();
	} catch (err) {
		return next(err)
	}

	res
	.set('Cache-Control', 'private, max-age=5')
	.render('globalmanagenews', {
		csrf: req.csrfToken(),
		news,
	});

}


code:
'use strict';

const { Posts } = require(__dirname+'/../../../db/')
	, pageQueryConverter = require(__dirname+'/../../../helpers/pagequeryconverter.js')
	, decodeQueryIP = require(__dirname+'/../../../helpers/decodequeryip.js')
	, limit = 20;

module.exports = async (req, res, next) => {

	const { page, offset, queryString } = pageQueryConverter(req.query, limit);
	let ipMatch = decodeQueryIP(req.query, res.locals.permLevel);

	let posts;
	try {
		posts = await Posts.getGlobalRecent(offset, limit, ipMatch);
	} catch (err) {
		return next(err)
	}

	res
	.set('Cache-Control', 'private, max-age=5')
	.render('globalmanagerecent', {
		csrf: req.csrfToken(),
		posts,
		page,
		ip: ipMatch ? req.query.ip : null,
		queryString,
	});

}


code:
'use strict';

const { Posts } = require(__dirname+'/../../../db/')
	, pageQueryConverter = require(__dirname+'/../../../helpers/pagequeryconverter.js')
	, decodeQueryIP = require(__dirname+'/../../../helpers/decodequeryip.js')
	, limit = 20;

module.exports = async (req, res, next) => {

	const { page, offset, queryString } = pageQueryConverter(req.query, limit);
	let ipMatch = decodeQueryIP(req.query, res.locals.permLevel);

	let reports;
	try {
		reports = await Posts.getGlobalReports(offset, limit, ipMatch);
	} catch (err) {
		return next(err)
	}

	res
	.set('Cache-Control', 'private, max-age=5')
	.render('globalmanagereports', {
		csrf: req.csrfToken(),
		reports,
		page,
		ip: ipMatch ? req.query.ip : null,
		queryString,
	});

}


code:
'use strict';

const cache = require(__dirname+'/../../../redis.js');

module.exports = async (req, res, next) => {

	let settings = await cache.get('globalsettings');
	if (!settings) {
		settings = {
			captchaMode: 0,
			filters: [],
			filterMode: 0,
			filterBanDuration: 0,
		}
		cache.setnx('globalsettings', settings);
	}

	res
	.set('Cache-Control', 'private, max-age=5')
	.render('globalmanagesettings', {
		csrf: req.csrfToken(),
		settings,
	});

}


code:
'use strict';

const { buildHomepage } = require(__dirname+'/../../helpers/tasks.js');

module.exports = async (req, res, next) => {

	let html;
	try {
		html = await buildHomepage();
	} catch (err) {
		return next(err);
	}

	return res.send(html);

}


code:
'use strict';

module.exports = {
	changePassword: require(__dirname+'/changepassword.js'),
	blockBypass: require(__dirname+'/blockbypass.js'),
	register: require(__dirname+'/register.js'),
	account: require(__dirname+'/account.js'),
	home: require(__dirname+'/home.js'),
	login: require(__dirname+'/login.js'),
	create: require(__dirname+'/create.js'),
	board: require(__dirname+'/board.js'),
	catalog: require(__dirname+'/catalog.js'),
	banners: require(__dirname+'/banners.js'),
	randombanner: require(__dirname+'/randombanner.js'),
	news: require(__dirname+'/news.js'),
	captchaPage: require(__dirname+'/captchapage.js'),
	captcha: require(__dirname+'/captcha.js'),
	thread: require(__dirname+'/thread.js'),
	modlog: require(__dirname+'/modlog.js'),
	modloglist: require(__dirname+'/modloglist.js'),
	boardlist: require(__dirname+'/boardlist.js'),
	overboard: require(__dirname+'/overboard.js'),
}


code:
'use strict';

const { buildLogin } = require(__dirname+'/../../helpers/tasks.js')
	, uploadDirectory = require(__dirname+'/../../helpers/files/uploadDirectory.js');

module.exports = async (req, res, next) => {

	res.render('login', {
		'goto': (typeof req.query.goto === 'string' ? req.query.goto : null)
	});

}


code:
'use strict';

module.exports = async (req, res, next) => {

	res
	.set('Cache-Control', 'private, max-age=5')
	.render('managebanners', {
		csrf: req.csrfToken(),
	});

}


code:
'use strict';

const Bans = require(__dirname+'/../../../db/bans.js');

module.exports = async (req, res, next) => {

	let bans;
	try {
		bans = await Bans.getBoardBans(req.params.board);
	} catch (err) {
		return next(err)
	}

	res
	.set('Cache-Control', 'private, max-age=5')
	.render('managebans', {
		csrf: req.csrfToken(),
		bans,
	});

}


code:
'use strict';

const Posts = require(__dirname+'/../../../db/posts.js');

module.exports = async (req, res, next) => {

	const page = req.params.page === 'index' ? 1 : Number(req.params.page);
	let maxPage;
	let threads;
	try {
		maxPage = Math.min(Math.ceil((await Posts.getPages(req.params.board)) / 10), Math.ceil(res.locals.board.settings.threadLimit/10)) || 1;
		if (page > maxPage) {
			return next();
		}
		threads = await Posts.getRecent(req.params.board, page, 10, true);
	} catch (err) {
		return next(err);
	}

	res
	.set('Cache-Control', 'private, max-age=5')
	.render('board', {
		modview: true,
		page,
		maxPage,
		threads,
		board: res.locals.board,
		csrf: req.csrfToken(),
	});

}


code:
'use strict';

const Posts = require(__dirname+'/../../../db/posts.js');

module.exports = async (req, res, next) => {

	let threads;
	try {
		threads = await Posts.getCatalog(req.params.board);
	} catch (err) {
		return next(err);
	}

	res
	.set('Cache-Control', 'private, max-age=5')
	.render('catalog', {
		modview: true,
		threads,
		board: res.locals.board,
		csrf: req.csrfToken(),
	});

}


code:
'use strict';

module.exports = {
	manageReports: require(__dirname+'/reports.js'),
	manageRecent: require(__dirname+'/recent.js'),
	manageSettings: require(__dirname+'/settings.js'),
	manageBans: require(__dirname+'/bans.js'),
	manageLogs: require(__dirname+'/logs.js'),
	manageBanners: require(__dirname+'/banners.js'),
	manageBoard: require(__dirname+'/board.js'),
	manageCatalog: require(__dirname+'/catalog.js'),
	manageThread: require(__dirname+'/thread.js'),
}


code:
'use strict';

const { Modlogs } = require(__dirname+'/../../../db/')
	, pageQueryConverter = require(__dirname+'/../../../helpers/pagequeryconverter.js')
//	, decodeQueryIP = require(__dirname+'/../../../helpers/decodequeryip.js')
	, limit = 50;

module.exports = async (req, res, next) => {

	const { page, offset, queryString } = pageQueryConverter(req.query, limit);

    let filter = {
		board: req.params.board
	};
	const username = typeof req.query.username === 'string' ? req.query.username : null;
    if (username) {
        filter.user = username;
    }
	const uri = typeof req.query.uri === 'string' ? req.query.uri : null;
    if (uri) {
        filter.board = uri;
    }
//todo fetch log entry by id and then get ip and hash

	let logs, maxPage;
	try {
		[logs, maxPage] = await Promise.all([
			Modlogs.find(filter, offset, limit),
			Modlogs.count(filter),
		]);
		maxPage = Math.ceil(maxPage/limit);
	} catch (err) {
		return next(err)
	}

	res
	.set('Cache-Control', 'private, max-age=5')
	.render('managelogs', {
		csrf: req.csrfToken(),
		queryString,
		username,
		uri,
//posterid here
		logs,
		page,
		maxPage,
	});

}


code:
'use strict';

const { Posts } = require(__dirname+'/../../../db/')
	, decodeQueryIP = require(__dirname+'/../../../helpers/decodequeryip.js')
	, pageQueryConverter = require(__dirname+'/../../../helpers/pagequeryconverter.js')
	, limit = 20;

module.exports = async (req, res, next) => {

	const { page, offset, queryString } = pageQueryConverter(req.query, limit);
	let ip = decodeQueryIP(req.query, res.locals.permLevel);
	const postId = typeof req.query.postid === 'string' ? req.query.postid : null;
	if (postId && +postId === parseInt(postId) && Number.isSafeInteger(+postId)) {
		const fetchedPost = await Posts.getPost(req.params.board, +postId, true);
		if (fetchedPost) {
			ip = decodeQueryIP({ ip: fetchedPost.ip.single.slice(-10) }, res.locals.permlevel);
		}
	}

	let posts;
	try {
		posts = await Posts.getBoardRecent(offset, limit, ip, req.params.board);
	} catch (err) {
		return next(err)
	}

	res
	.set('Cache-Control', 'private, max-age=5')
	.render('managerecent', {
		csrf: req.csrfToken(),
		posts,
		page,
		postId,
		queryIp: ip ? req.query.ip : null,
		queryString,
	});

}


code:
'use strict';

const Posts = require(__dirname+'/../../../db/posts.js');

module.exports = async (req, res, next) => {

	let reports;
	try {
		reports = await Posts.getReports(req.params.board);
	} catch (err) {
		return next(err)
	}

	res
	.set('Cache-Control', 'private, max-age=5')
	.render('managereports', {
		csrf: req.csrfToken(),
		reports,
	});

}


code:
'use strict';

const { themes, codeThemes } = require(__dirname+'/../../../helpers/themes.js')
	, { countryNamesMap, countryCodes } = require(__dirname+'/../../../helpers/countries.js')

module.exports = async (req, res, next) => {

	res
	.set('Cache-Control', 'private, max-age=5')
	.render('managesettings', {
		csrf: req.csrfToken(),
		countryNamesMap,
		countryCodes,
		themes,
		codeThemes,
	});

}


code:
'use strict';

const Posts = require(__dirname+'/../../../db/posts.js');

module.exports = async (req, res, next) => {

	let thread;
	try {
		thread = await Posts.getThread(res.locals.board._id, res.locals.thread.postId, true);
		if (!thread) {
			return next(); //deleted between exists
		}
	} catch (err) {
		return next(err);
	}

	res
	.set('Cache-Control', 'private, max-age=5')
	.render('thread', {
		modview: true,
		upLevel: true,
		board: res.locals.board,
		thread,
		csrf: req.csrfToken(),
	});

}


code:
'use strict';

const { Modlogs } = require(__dirname+'/../../db/')
	, { buildModLog } = require(__dirname+'/../../helpers/tasks.js');

module.exports = async (req, res, next) => {

	if (!res.locals.date) {
		return next();
	}

	const startDate = res.locals.date.date;
	const { year, month, day } = res.locals.date;
	const endDate = new Date(Date.UTC(year, month, day, 23, 59, 59, 999));

	let html;
	try {
		const logs = await Modlogs.findBetweenDate(res.locals.board, startDate, endDate);
		if (!logs || logs.length === 0) {
			return next();
		}
		html = await buildModLog({
			board: res.locals.board,
			startDate,
			endDate,
			logs
		});
	} catch (err) {
		return next(err);
	}

	return res.send(html);

}


code:
'use strict';

const { buildModLogList } = require(__dirname+'/../../helpers/tasks.js');

module.exports = async (req, res, next) => {

	let html;
	try {
		html = await buildModLogList({ board: res.locals.board });
	} catch (err) {
		return next(err);
	}

	return res.send(html);

}


code:
'use strict';

const { buildNews } = require(__dirname+'/../../helpers/tasks.js');

module.exports = async (req, res, next) => {

	let html;
	try {
		html = await buildNews();
	} catch (err) {
		return next(err);
	}

	return res.send(html);

}


code:
'use strict';

const { Posts, Boards } = require(__dirname+'/../../db/')
	, cache = require(__dirname+'/../../redis.js')
	, { overboardLimit } = require(__dirname+'/../../configs/main.js');

module.exports = async (req, res, next) => {

	let threads = (await cache.get('overboard')) || [];
	if (!threads || threads.length === 0) {
		try {
			const listedBoards = await Boards.getLocalListed();
			threads = await Posts.getRecent(listedBoards, 1, overboardLimit, false, false);
			cache.set('overboard', threads, 60);
		} catch (err) {
			return next(err);
		}
	}

	res
	.set('Cache-Control', 'public, max-age=60')
	.render('overboard', {
		threads,
	});

}


code:
'use strict';

const Boards = require(__dirname+'/../../db/boards.js')

module.exports = async (req, res, next) => {

	if (!req.query.board || typeof req.query.board !== 'string') {
		return next();
	}

	let banner;
	try {
		banner = await Boards.randomBanner(req.query.board);
	} catch (err) {
		return next(err);
	}

	if (!banner) {
		//non existing boards will show default banner, but it doesnt really matter.
		return res.redirect('/file/defaultbanner.png');
	}

	return res.redirect(`/banner/${req.query.board}/${banner}`);

}


code:
'use strict';

const { buildRegister } = require(__dirname+'/../../helpers/tasks.js');

module.exports = async (req, res, next) => {

	let html;
	try {
		html = await buildRegister();
	} catch (err) {
		return next(err);
	}

	return res.send(html);

}


code:
'use strict';

const { buildThread } = require(__dirname+'/../../helpers/tasks.js');

module.exports = async (req, res, next) => {

	let html, json;
	try {
		const buildThreadData = await buildThread({
			threadId: res.locals.thread.postId,
			board: res.locals.board
		});
		/* unlikely, but postsExists middleware can be true, but this can be null if deleted. so just next() to 404
		wont matter in the build-workers that call this because they dont destructure and never cause the bug */
		if (!buildThreadData) {
			return next();
		}
		({ html, json } = buildThreadData);
	} catch (err) {
		return next(err);
	}

	if (req.path.endsWith('.json')) {
		return res.json(json);
	} else {
		return res.send(html);
	}

}


jschan

test: echo "Error: no test specified" && exit 1

setup: npm i -g pm2 gulp && gulp

start: pm2 start ecosystem.config.js --env production

start-dev: pm2 start ecosystem.config.js --env development

author: fatchan


code:
'use strict';

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

module.exports = {

	queue: taskQueue,

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

}


code:
'use strict';

const Redis = require('ioredis')
	, configs = require(__dirname+'/configs/main.js')
	, client = new Redis(configs.redis);

module.exports = {

	redisClient: client,

	//get a value with key
	get: (key) => {
		return client.get(key).then(res => { return JSON.parse(res) });
	},

	//set a value on key
	set: (key, value, ttl) => {
		if (ttl) {
			client.set(key, JSON.stringify(value), 'EX', ttl);
		} else {
			client.set(key, JSON.stringify(value));
		}
	},

	//set a value on key if not exist
	setnx: (key, value) => {
		client.setnx(key, JSON.stringify(value));
	},

	//add items to a set
	sadd: (key, value) => {
		return client.sadd(key, value);
	},

	//get all members of a set
	sgetall: (key) => {
		return client.smembers(key);
	},

	//remove an item from a set
	srem: (key, value) => {
		return client.srem(key, value);
	},

	//get random item from set
	srand: (key) => {
		return client.srandmember(key);
	},

	//delete value with key
	del: (keyOrKeys) => {
		if (Array.isArray(keyOrKeys))	{
			return client.del(...keyOrKeys);
		} else {
			return client.del(keyOrKeys);
		}
	},

	deletePattern: (pattern) => {
		return new Promise((resolve, reject) => {
			const stream = client.scanStream({
				match: pattern
			});
			stream.on('data', (keys) => {
				if (keys.length > 0) {
					const pipeline = client.pipeline();
					for (let i = 0; i < keys.length; i++) {
						pipeline.del(keys[i]);
					}
					pipeline.exec();
				}
			});
			stream.on('end', () => {
				resolve();
			});
			stream.on('error', (err) => {
				reject(err);
			});
		});
	},

}


code:
'use strict';

const Redlock = require('redlock')
	, { redisClient } = require(__dirname+'/redis.js')
	, redlock = new Redlock([redisClient]);

redlock.on('clientError', console.error);

module.exports = redlock;


code:
'use strict';

process
	.on('uncaughtException', console.error)
	.on('unhandledRejection', console.error);

const Mongo = require(__dirname+'/db/db.js');

(async () => {

	await Mongo.connect();
	const { Posts } = require(__dirname+'/db/')
		, quoteHandler = require(__dirname+'/helpers/posting/quotes.js')
		, { markdown } = require(__dirname+'/helpers/posting/markdown.js')
		, sanitizeOptions = require(__dirname+'/helpers/posting/sanitizeoptions.js')
		, sanitize = require('sanitize-html');

	const posts = await Posts.db.find({/*query here*/}).toArray();
	await Promise.all(posts.map(async (post) => {
		let message = markdown(post.nomarkup);
		const { quotedMessage, threadQuotes, crossQuotes } = await quoteHandler.process(post.board, message, null);
		message = sanitize(quotedMessage, sanitizeOptions.after);
		console.log(post.postId, message.substring(0,10)+'...');
		return Posts.db.updateOne({board:post.board, postId:post.postId}, {$set:{message:message}});
	}));

})();


code:
'use strict';

const deleteOld = require(__dirname+'/../helpers/files/deleteold.js')
	, timeUtils = require(__dirname+'/../helpers/timeutils.js')

module.exports = () => {
	return deleteOld('captcha', Date.now()-(timeUtils.MINUTE*5));
}


code:
'use strict';

process
	.on('uncaughtException', console.error)
	.on('unhandledRejection', console.error);

const timeUtils = require(__dirname+'/../helpers/timeutils.js')
	, Mongo = require(__dirname+'/../db/db.js')
	, { pruneImmediately, debugLogs, enableWebring } = require(__dirname+'/../configs/main.js')
	, doInterval = require(__dirname+'/../helpers/dointerval.js');

(async () => {

	debugLogs && console.log('CONNECTING TO MONGODB');
	await Mongo.connect();
	await Mongo.checkVersion();
	debugLogs && console.log('STARTING SCHEDULES');

	//update board stats and homepage
	const taskQueue = require(__dirname+'/../queue.js');
	taskQueue.push({
		'task': 'updateStats',
		'options': {}
	}, {
		'repeat': {
			'cron': '0 * * * *'
		}
	});

	//delete files for expired captchas
	const deleteCaptchas = require(__dirname+'/deletecaptchas.js');
	doInterval(deleteCaptchas, timeUtils.MINUTE*5, true);

	//file pruning
	if (!pruneImmediately) {
		const pruneFiles = require(__dirname+'/prune.js');
		doInterval(pruneFiles, timeUtils.DAY, true);
	}

	//update the webring
	if (enableWebring) {
		const updateWebring = require(__dirname+'/webring.js');
		doInterval(updateWebring, timeUtils.MINUTE*15, true);
	}

})();


code:
'use strict';

const Files = require(__dirname+'/../db/files.js')
	, { debugLogs } =  require(__dirname+'/../configs/main.js')
	, { remove } = require('fs-extra')
	, uploadDirectory = require(__dirname+'/../helpers/files/uploadDirectory.js');

module.exports = async(fileNames) => {
	const query = {
		'count': {
			'$lte': 0
		}
	}
	if (fileNames) {
		query['_id'] = {
			'$in': fileNames
		};
	}
	const unreferenced = await Files.db.find(query, {
		'projection': {
			'count': 0,
			'size': 0
		}
	}).toArray();
	await Files.db.removeMany(query);
	await Promise.all(unreferenced.map(async file => {
		debugLogs && console.log('Pruning', file._id);
		return Promise.all(
			[remove(`${uploadDirectory}/file/${file._id}`)]
			.concat(file.exts ? file.exts.filter(ext => ext).map(ext => {
				remove(`${uploadDirectory}/file/thumb-${file._id.split('.')[0]}${ext}`)
			}) : [])
		)
	}));
}


code:
'use strict';

const fetch = require('node-fetch')
	, { debugLogs, meta } = require(__dirname+'/../configs/main.js')
	, { logo, following, blacklist, proxy } = require(__dirname+'/../configs/webring.json')
	, Mongo = require(__dirname+'/../db/db.js')
	, { Boards, Webring } = require(__dirname+'/../db/')
	, { outputFile } = require('fs-extra')
	, uploadDirectory = require(__dirname+'/../helpers/files/uploadDirectory.js')
	, timeDiffString = require(__dirname+'/../helpers/timediffstring.js')
	, SocksProxyAgent = proxy.enabled && require('socks-proxy-agent')
	, agent = SocksProxyAgent ? new SocksProxyAgent(require('url').parse(proxy.address)) : null

module.exports = async () => {
	const label = `updating webring`;
	const start = process.hrtime();

	const visited = new Map();
	let known = new Set(following);
	let webringBoards = []; //list of webring boards
	while (known.size > visited.size) {
		//get sites we havent visited yet
		const toVisit = [...known].filter(url => !visited.has(url));
		let rings = await Promise.all(toVisit.map(url => {
			visited.set(url, (visited.get(url)||0)+1);
			return fetch(url, {
				agent,
				headers: {
					'User-Agent':''
				}
			}).then(res => res.json()).catch(e => console.error);
		}));
		for (let i = 0; i < rings.length; i++) {
			const ring = rings[i];
			if (!ring || !ring.name || !ring.endpoint || !ring.url //malformed
				|| ring.endpoint.includes(meta.url) //own site
				|| visited.get(ring.endpoint) > 1) { //already seen endpoint (for multiple domain sites)
				continue;
			}
			visited.set(ring.endpoint, visited.get(ring.endpoint)+1);
			if (ring.following && ring.following.length > 0) {
				//filter their folowing by blacklist/self and add to known sites
				ring.following
					.filter(url => !blacklist.some(x => url.includes(x)) && !url.includes(meta.url))
					.forEach(url => known.add(url));
			}
			if (ring.boards && ring.boards.length > 0) {
				//add some stuff for the boardlist and then add their boards
				ring.boards.forEach(board => {
					board.siteName = ring.name;
					//convert to numbers because old infinity webring plugin returns string
					board.totalPosts = parseInt(board.totalPosts);
					board.postsPerHour = parseInt(board.postsPerHour);
					board.uniqueUsers = parseInt(board.uniqueUsers);
				});
				webringBoards = webringBoards.concat(ring.boards);
			}
		}
	}

	if (webringBoards.length > 0) {
		//$out from temp collection to replace webring boards
		const tempCollection = Mongo.db.collection('tempwebring');
		await tempCollection.insertMany(webringBoards);
		await tempCollection.aggregate([
			{ $out : 'webring' }
		]).toArray();
		await tempCollection.drop();
	} else {
		//otherwise none found, so delete them all
		await Webring.deleteAll();
	}

	//update webring.json
	const boards = await Boards.webringBoards();
	const json = {
		name: meta.siteName,
		url: meta.url,
		endpoint: `${meta.url}/webring.json`,
		logo,
		following,
		blacklist,
		known: [...known],
		boards: boards.map(b => {
			//map local boards to webring format
			return {
				uri: b._id,
				title: b.settings.name,
				subtitle: b.settings.description,
				path: `${meta.url}/${b._id}/`,
				postsPerHour: b.pph,
				totalPosts: b.sequence_value-1,
				uniqueUsers: b.ips,
				nsfw: !b.settings.sfw,
				tags: b.settings.tags,
				lastPostTimestamp: b.lastPostTimestamp,
			};
		}),
	}
	await outputFile(`${uploadDirectory}/json/webring.json`, JSON.stringify(json));

	const end = process.hrtime(start);
	debugLogs && console.log(timeDiffString(label, end));
}


code:
'use strict';

process
	.on('uncaughtException', console.error)
	.on('unhandledRejection', console.error);

const express = require('express')
	, path = require('path')
	, app = express()
	, server = require('http').createServer(app)
	, cookieParser = require('cookie-parser')
	, { cacheTemplates, boardDefaults, globalLimits, captchaOptions,
		enableUserBoardCreation, enableUserAccountCreation, cookieSecret,
		debugLogs, ipHashPermLevel, meta, port, enableWebring } = require(__dirname+'/configs/main.js')
	, referrerCheck = require(__dirname+'/helpers/referrercheck.js')
	, { themes, codeThemes } = require(__dirname+'/helpers/themes.js')
	, Mongo = require(__dirname+'/db/db.js')
	, Socketio = require(__dirname+'/socketio.js')
	, commit = require(__dirname+'/helpers/commit.js')
	, dynamicResponse = require(__dirname+'/helpers/dynamic.js')
	, formatSize = require(__dirname+'/helpers/files/formatsize.js')
	, CachePugTemplates = require('cache-pug-templates');

(async () => {

	const env = process.env.NODE_ENV;
	const production = env === 'production';
	debugLogs && console.log('STARTING IN MODE:', env);

	// connect to mongodb
	debugLogs && console.log('CONNECTING TO MONGODB');
	await Mongo.connect();
	await Mongo.checkVersion();

	// connect to redis
	debugLogs && console.log('CONNECTING TO REDIS');
	const { redisClient } = require(__dirname+'/redis.js');

	// disable useless express header
	app.disable('x-powered-by');
	// parse forms
	app.use(express.urlencoded({extended: false}));
	// parse cookies
	app.use(cookieParser(cookieSecret));

	// session store
	const sessionMiddleware = require(__dirname+'/helpers/usesession.js');

	// connect socketio
	debugLogs && console.log('STARTING WEBSOCKET');
	Socketio.connect(server, sessionMiddleware);

	//trust proxy for nginx
	app.set('trust proxy', 1);

	//self explanatory middlewares
	app.use(referrerCheck);

	// use pug view engine
	const views = path.join(__dirname, 'views/pages');
	app.set('view engine', 'pug');
	app.set('views', views);
	//cache loaded templates
	if (cacheTemplates === true) {
		app.enable('view cache');
	}

	//default settings
	app.locals.enableUserAccountCreation = enableUserAccountCreation;
	app.locals.enableUserBoardCreation = enableUserBoardCreation;
	app.locals.defaultTheme = boardDefaults.theme;
	app.locals.defaultCodeTheme = boardDefaults.codeTheme;
	app.locals.globalLimits = globalLimits;
	app.locals.ipHashPermLevel = ipHashPermLevel;
	app.locals.enableWebring = enableWebring;
	app.locals.commit = commit;
	app.locals.meta = meta;
	app.locals.captchaType = captchaOptions.type;
	app.locals.postFilesSize = formatSize(globalLimits.postFilesSize.max);
	switch (captchaOptions.type) {
		case 'google':
			app.locals.googleRecaptchaSiteKey = captchaOptions.google.siteKey;
			break;
		case 'hcaptcha':
			app.locals.hcaptchaSiteKey = captchaOptions.hcaptcha.siteKey;
			break;
		case 'grid':
			app.locals.captchaGridSize = captchaOptions.grid.size;
			break;
		default:
			break;
	}

	// routes
	if (!production) {
		app.use(express.static(__dirname+'/static', { redirect: false }));
		app.use(express.static(__dirname+'/static/html', { redirect: false }));
		app.use(express.static(__dirname+'/static/json', { redirect: false }));
	}

	app.use('/forms', require(__dirname+'/controllers/forms.js'));
	app.use('/', require(__dirname+'/controllers/pages.js'));

	//404 catchall
	app.get('*', (req, res) => {
		res.status(404).render('404');
	})

	// catch any unhandled errors
	app.use((err, req, res, next) => {
		let errStatus = 500;
		let errMessage = 'Internal Server Error';
		if (err.code === 'EBADCSRFTOKEN') {
			errMessage = 'Invalid CSRF token';
			errStatus= 403;
		}
		if (err.type != null) {
			//body-parser errors
			errStatus = err.status;
			switch (err.type) {
				case 'charset.unsupported':
				case 'entity.parse.failed':
				case 'entity.verify.failed':
				case 'encoding.unsupported':
				case 'request.size.invalid':
				case 'parameters.too.many':
					//no need to give an error for every one, since these will never happen to a legit user anyway
					errMessage = 'Invalid request body';
					break;
				case 'request.aborted':
					errMessage = 'Client aborted request';
					break;
				case 'entity.too.large':
					errMessage = 'Your upload was too large';
					break;
				default:
					break;
			}
		}
		if (errStatus === 500 && errMessage ===  'Internal Server Error') {
			//no specific/friendly error, probably something worth logging
			console.error(err);
		}
		return dynamicResponse(req, res, errStatus, 'message', {
			'title': errStatus === 500 ? 'Internal Server Error' : 'Bad Request',
			'error': errMessage,
			'redirect': req.headers.referer || '/'
		});
	})

	//listen
	server.listen(port, '127.0.0.1', () => {
		new CachePugTemplates({ app, views }).start();
		debugLogs && console.log(`LISTENING ON :${port}`);
		//let PM2 know that this is ready for graceful reloads and to serialise startup
		if (typeof process.send === 'function') {
			//make sure we are a child process of PM2 i.e. not in dev
			debugLogs && console.log('SENT READY SIGNAL TO PM2');
			process.send('ready');
		}
	});

	const gracefulStop = () => {
		debugLogs && console.log('SIGINT SIGNAL RECEIVED');
		// Stops the server from accepting new connections and finishes existing connections.
		Socketio.io.close((err) => {
			// if error, log and exit with error (1 code)
			debugLogs && console.log('CLOSING SERVER');
			if (err) {
				console.error(err);
				process.exit(1);
			}
			// close database connection
			debugLogs && console.log('DISCONNECTING MONGODB');
			Mongo.client.close();
			//close redis connection
			debugLogs && console.log('DISCONNECTING REDIS')
			redisClient.quit();
			// now close without error
			process.exit(0);
		});
	};

	//graceful stop
	process.on('SIGINT', gracefulStop);
	process.on('message', (message) => {
		if (message === 'shutdown') {
			gracefulStop();
		}
	});

})();


code:
'use strict';

const configs = require(__dirname+'/configs/main.js');

module.exports = {

	io: null, //null to begin with

	connect: (server, sessionMiddleware) => {
		const io = require('socket.io')(server);
		const redisAdapter = require('socket.io-redis');
		io.adapter(redisAdapter({ ...configs.redis }));
		io.use((socket, next) => {
			//make session available in socket.request.session
			sessionMiddleware(socket.request, socket.request, next);
		});
		module.exports.io = io;
		module.exports.startRooms();
	},

	startRooms: () => {
		module.exports.io.on('connection', socket => {
//TODO: if we need authed socket endpoints (e.g. modview pages), we can use socket.request.session
			socket.on('room', room => {
				socket.join(room);
				socket.send('joined');
			});
		});
	},

	emitRoom: (room, event, message) => {
		module.exports.io.to(room).emit(event, message);
	},

}


code:
extends ../layout.pug
include ../mixins/post.pug

block head
	title Frequently Asked Questions

block content
	h1.board-title FAQ
	include ../includes/stickynav.pug
	.table-container.flex-center.mv-5
		.anchor
		table
			tr
				th Frequently Asked Questions
			tr
				td.post-message
					b General
					ul.mv-0
						li: a(href='#whats-an-imageboard') What is an imageboard?
						li: a(href='/rules.html') What are the rules?
						li: a(href='#contact') How can I contact the administration?
					b Making posts
					ul.mv-0
						li: a(href='#captcha') How do I solve CAPTCHA?
						li: a(href='#name-formatting') How do names, tripcodes and capcodes work?
						li: a(href='#post-styling') What kind of styling options are available when making a post?
						li: a(href='#post-info') What is the file size limit?
						li: a(href='#moderation') How does moderation work?
					b Boards, users & permissions
					ul.mv-0
						li: a(href='#make-a-board') How do I make my own board?
						li: a(href='#staff-and-permissions') How do staff, users and permissions work?
	.table-container.flex-center.mv-5
		.anchor#whats-an-imageboard
		table
			tr
				th: a(href='#whats-an-imageboard') What is an imageboard?
			tr
				td
					p
						| An imageboard is a type of discussion board where users share images and text about various topics.
					p
						| The primary difference between imageboards and traditional forums is that anybody can make a post without registering
						| an account or providing any personal information. This lowers the barrier to entry, protects user identities and focuses on what is said, rather than who says it.
	.table-container.flex-center.mv-5
		.anchor#captcha
		table
			tr
				th: a(href='#captcha') How do I solve CAPTCHA?
			tr
				td.post-message
					p Select the boxes that correspond to each solid/filled icon in the grid. The image is shuffled and distorted, so use your brain.
	.table-container.flex-center.mv-5
		.anchor#name-formatting
		table
			tr
				th: a(href='#name-formatting') Name formatting
			tr
				td.post-message
					p When posting, you can format the name field to include a name, tripcode , capcode, any combination of the three including leaving the field completely blank. Instead of a blank name,  "Anonymous" is used, however this depends on board-specific configuration. The optional components are explained below.

					b Format
					p
						| Names should be input like:
						input(disabled='true' spellcheck='false' type='text' value='Name##Tripcode## Capcode')
						| . Tripcode and capcode are optional components. Tripcodes and capcodes may not contain "##" and the whitespace before capcodes is significant.
					p Valid examples:
					ol.mv-0
						li Name
						li Name##tripcode
						li Name## capcode
						li Name##tripcode## capcode
						li ##tripcode## capcode
						li ##tripcode
						li ## capcode
					p The capcode can also be left blank to display just your role.
					p From the examples, you can see that all components can be used in combination or independently. In a post number 4 would look like:
						-
							const examplePost = {
								"date" : new Date("2019-08-02T09:48:44.180Z"),
								"name" : "Name",
								"board" : "example",
								"tripcode" : "!!X8NXmAS44=",
								"capcode" : "##Board Owner capcode",
								"message" : "Hello, world!",
								"nomarkup" : "Hello, world!",
								"thread" : 1,
								"password" : null,
								"email" : "",
								"spoiler" : false,
								"banmessage" : null,
								"files" : [ ],
								"reports" : [ ],
								"globalreports" : [ ],
								"quotes" : [ ],
								"backlinks" : [ ],
								"postId" : 123
							}
						+post(examplePost)
					p The name appears bold in the top left, followed by the tripcode in regular weight with a !! prefix, then the capcode in a different color, bold and with a ## prefix. The colours may vary between themes but are generally distinct from eachother

					b Name
					p The name is simply what name you want to be shown alongside your post. Other users can post with the same name so there is nothing preventing impersonation. This is not related to your username (for registered users).

					b Tripcode
					p A tripcode is a password of sorts, which users can provide in the tripcode component of their name. This tripcode is used in conjunction with a server-known secret to generate a unique* tripcode portion of the name. Long, unique tripcodes can be used as a form of identity. It is important that you keep tripcodes secret if you use them for some form of identity. A compromised tripcode can be used for impersonation and cannot be revoked in any way.

					b Capcode
					p A capcode is a component of the name field only available to authenticated users. This includes admins, global staff, board owners and board moderators. If there is no text after the ##, the role will be displayed alone. Leaving a space and putting custom text will be prefixed by the role name. This way, the role is always shown to prevent role impersonation.
	.table-container.flex-center.mv-5
		.anchor#post-styling
		table
			tr
				th(colspan=2): a(href='#post-styling') Post styling
			tr
				th Input
				th Output
			tr
				td >greentext
				td
					span.greentext >greentext
			tr
				td <pinktext
				td
					span.pinktext <pinktext
			tr
				td ==title==
				td
					span.title title
			tr
				td ''bold''
				td
					span.bold bold
			tr
				td __underline__
				td
					span.underline underline
			tr
				td ~~strikethrough~~
				td
					span.strikethrough strikethrough
			tr
				td ||spoiler text||
				td
					span.spoiler spoiler text
			tr
				td **italic**
				td
					span.em italic
			tr
				td (((detected)))
				td
					span.detected (((detected)))
			tr
				td ##2d9+3
				td
					img(src='/file/dice.png' height='16' width='16')
					span.dice (##2d9+3) Rolled 2 dice with 9 sides and modifier +3 = 10
			tr
				td https://example.com
				td: a(href='#!') https://example.com
			tr
				td >>123
				td: a(class="quote" href="#!") >>123
			tr
				td >>>/b/
				td: a(class="quote" href="#!") >>>/b/
			tr
				td >>>/b/123
				td: a(class="quote" href="#!") >>>/b/123
			tr
				td `inline monospace`
				td
					span.mono inline monospace
			tr
				td
					| ```language
					br
					| code block
					br
					| ```
				td
					span.code code block
			tr
				td(colspan=2)
					| The "language" of code blocks is optional. Without it, automatic language detection is used.
					| If the language is "plain", highlighting is disabled for the code block. Not all languages are supported, a subset of popular languages is used.
					| If the language is not in the supported list, the code block will be rendered like "plain" with no highlighting.
					| Languages supported: #{codeLanguages.join(', ')}
	.table-container.flex-center.mv-5
		.anchor#post-info
		table
			tr
				th: a(href='#post-info') What is the file size limit?
			tr
				td
					p Max size of form data per-post is #{postFilesSize}. Because other fields e.g. name, message, etc contribute to this, the maximum size of file uploads will be very slightly smaller than this.
	.table-container.flex-center.mv-5
		.anchor#moderation
		table
			tr
				th: a(href='#moderation') How does moderation work?
			tr
				td.post-message
					b General
					p
						| Moderation is very simple. Moderation permissions are different between regular users, board staff (board owner or moderator) and globals (global staff or admin).
						| By default, regular users can delete, spoiler or remove the files from their own posts, provided a matching post password is supplied. They can also report or global report posts.
						| Board staff have near full power over posts on their board including removing posts, stickying, spoilering, etc. even for other users posts without the need for a password. They also have access to board specific management page.
						| Global staff have full power over all posts on all boards, can access the board specific management page for any board and additionally have access to the global management page.
						| More details on the specifics of permission levels is available
						a(href='#staff-and-permissions') here
						| .
					b Local vs. Global reports
					p
						| There exists the concept of "local" and "global" reports. Reporting a post locally will show the post along with reports on the report page for that particular board, and the reports
						| may be actioned upon by the board staff. Reporting a post globally will show the post along with reports on the global manage page available only to global staff and may be actioned upon by global staff.
						| Global reports should be used to flag posts that violate global rules such as illegal content or spam, in contrast to local reports which are for posts that abide by global rules but break
						| board-specific rules (which may be made arbitrarily by board staff). It is also possible to be banned from a board or globally for abuse of the report system.
					b Batch processing of posts
					p
						| Each post has a checkbox in the top left to select it for moderation actions. Multiple posts may be selected to allow batch processing e.g. reporting multiple offending posts in one request.
						| The same is present in moderation interfaces. Some actions for example bans (which are based on IP) may also be handled in batches. Selecting multiple posts and using the ban action will
						| apply a single ban for each unique IP of the selected posts.
					b Time format in moderation interfaces
					p
						| Some moderation interfaces, for example the ban duration when moderating posts, or the ban duration for post filtering use a shorthand for times/length. This format supports years, months, weeks, days, hours, minutes and seconds.
						| An input of "3mo" would mean 3 months and "1y2mo3w4d5h6m7s" would mean 1 year, 2 months, 3 weeks, 4 days, 5 hours, 6 minutes and 7 seconds. Units of time should be in descending order, so "2w1mo" is invalid.
						| However you may use "6w" for example to input 6 weeks, and are not required to use "1mo2w".
	.table-container.flex-center.mv-5
		.anchor#make-a-board
		table
			tr
				th: a(href='#make-a-board') How do I make my own board?
			tr
				td
					p
						a(href='/register.html') Register
						|  an account, then
						a(href='/create.html') create a board
						|. Please avoid making excessive amounts of boards, duplicate or boards similar to an existing one unless you expect that many users will migrate to the new board for whatever reason e.g. tyrannical moderation.
	.table-container.flex-center.mv-5
		.anchor#staff-and-permissions
		table
			tr
				th: a(href='#staff-and-permissions') How do staff, users and permissions work?
			tr
				td
					p
						| There are 5 levels of permissions on the site with 0 being the highest level of privilege and 4 the lowest. In this list "Regular user" refers to somebody who is either not logged in or is not owner/mod of the board. For example if a user creates their own board, they do not have the "Board owner" permissions on other boards. Each board can have one owner and multiple moderators. Ownership can be transferred.
						ol(start='0')
							li Admin: All permissions
							li Global staff: All permissions on all boards excluding account management and news updates
							li Board owner: Permissions to board-specific settings, reports, banners and bans and some post actions
							li Board moderator: Permissions to manage board-specific reports, bans and some post actions
							li Regular user: Permissions to report/delete/spoiler posts
						| Post actions refers to reporting, deleting, stickying, etc. More details on these permissions:
						ol(start='0')
							li Admin: All actions
							li Global staff: All below, delete files, delete-by-ip-global and global ban
							li Board owner: Same as board moderator
							li Board moderator: All below, move/merge, ban, delete-by-ip, sticky/sage/lock/cycle
							li Regular user: Reports, and post spoiler/delete/unlink if the board has them enabled
						| Administrators have the ability to assign a permission level directly to users through the global management page. Typically a user is level 4 (regular user), 1 (global staff) or 0 (administrator).
						| Level 2 and 3 are usually only aplicable to users when on a board they are owner or moderator. However, level 2 or 3 can be assigned manually to create global board owners or global board moderators
						| who have board owner or moderation permissions on all boards, but without access to the global moderation interfaces. If assigning global boad owners (level 2), they will have access to the board settings
						| and the ability to reassign board owners.
	.table-container.flex-center.mv-5
		.anchor#contact
		table
			tr
				th: a(href='#contact') How can I contact the administration?
			tr
				td
					p Example


code:
extends ../layout.pug

block head
	title Rules

block content
	h1.board-title Rules
	.table-container.flex-center.mv-10
		table.table-body
			tr.table-row
				td
					p 1. Put rules here


code:
- const blockedCountries = new Set(board.settings.blockedCountries);
select(name='countries' size='10' multiple)
	optgroup(label='Currently blocked')
		each code in board.settings.blockedCountries
			option(value=code selected=true) #{countryNamesMap[code]}
	optgroup(label='Not blocked')
		each code in countryCodes.filter(c => !blockedCountries.has(c))
			option(value=code) #{countryNamesMap[code]}


code:
details.toggle-label#actionform
	summary.toggle-summary Show Post Actions
	.actions
		h4.no-m-p Actions:
		label
			input.post-check(type='checkbox', name='delete' value='1')
			| Delete Posts
		label
			input.post-check(type='checkbox', name='unlink_file' value='1')
			| Unlink Files
		label
			input.post-check(type='checkbox', name='spoiler' value='1')
			| Spoiler Files
		label
			input#password(type='password', name='postpassword', placeholder='Post password' autocomplete='off')
		label
			input.post-check(type='checkbox', name='report' value='1')
			| Report
		label
			input.post-check(type='checkbox', name='global_report' value='1')
			| Global Report
		label
			input#report(type='text', name='report_reason', placeholder='Report reason' autocomplete='off')
	.actions
		h4.no-m-p Captcha:
		include ./captcha.pug
	input(type='submit', value='submit')


code:
details.toggle-label#actionform
	summary.toggle-summary Show Post Actions
	.actions
		h4.no-m-p Actions:
		label
			input.post-check(type='checkbox', name='delete' value='1')
			| Delete Posts
		label
			input.post-check(type='checkbox', name='unlink_file' value='1')
			| Unlink Files
		label
			input.post-check(type='checkbox', name='delete_file' value='1')
			| Delete Files
		label
			input.post-check(type='checkbox', name='spoiler' value='1')
			| Spoiler Files
		label
			input.post-check(type='checkbox', name='edit' value='1')
			| Edit Post
		label
			input.post-check(type='checkbox', name='delete_ip_global' value='1')
			| Delete from IP globally
		label
			input.post-check(type='checkbox', name='global_dismiss' value='1')
			| Dismiss Global Reports
		label
			input.post-check(type='checkbox', name='global_report_ban' value='1')
			| Global Ban Reporters
		label
			input.post-check(type='checkbox', name='global_ban' value='1')
			| Global Ban Poster
		label
			input.post-check(type='checkbox', name='ban_q' value='1')
			| 1/4 Range
		label
			input.post-check(type='checkbox', name='ban_h' value='1')
			| 1/2 Range
		label
			input.post-check(type='checkbox', name='no_appeal' value='1')
			| Non-appealable Ban
		label
			input.post-check(type='checkbox', name='preserve_post' value='1')
			| Show Post In Ban
		label
			input.post-check(type='checkbox', name='hide_name' value='1')
			| Hide Username In Modlog
		label
			input(type='text', name='ban_reason', placeholder='Ban reason' autocomplete='off')
		label
			input(type='text', name='ban_duration', placeholder='Ban duration e.g. 7d' autocomplete='off')
		label
			input(type='text', name='log_message', placeholder='Modlog message' autocomplete='off')
	input(type='submit', value='submit')