code:
const fetch = require('node-fetch');

module.exports = () => describe('test some global form submissions', () => {

	let sessionCookie
		, csrfToken;

	test('login as admin',  async () => {
		const params = new URLSearchParams();
		params.append('username', 'admin');
		params.append('password', process.env.TEST_ADMIN_PASSWORD);
		const response = await fetch('http://localhost/forms/login', {
			headers: {
				'x-using-xhr': 'true',
			},
			method: 'POST',
			body: params,
			redirect: 'manual',
		});
		const rawHeaders = response.headers.raw();
		expect(rawHeaders['set-cookie']).toBeDefined();
		expect(rawHeaders['set-cookie'][0]).toMatch(/^connect\.sid/);
		sessionCookie = rawHeaders['set-cookie'][0];
		csrfToken = await fetch('http://localhost/csrf.json', { headers: { 'cookie': sessionCookie }})
			.then(res => res.json())
			.then(json => json.token);
	});

	let newsId;
	test('add news post',  async () => {
		const params = new URLSearchParams({
			_csrf: csrfToken,
			title: 'Newspost title~',
			message: `==This is news==
wow
>very cool
testing 123`
		});
		const response = await fetch('http://localhost/forms/global/addnews', {
			headers: {
				'x-using-xhr': 'true',
				'cookie': sessionCookie,
			},
			method: 'POST',
			body: params,
			redirect: 'manual',
		});
		expect(response.ok).toBe(true);
		const newsPage = await fetch('http://localhost/globalmanage/news.html', {
			headers: {
				'cookie': sessionCookie,
			},
		}).then(res => res.text());
		const checkIndex = newsPage.indexOf('name="checkednews" value="');
		newsId = newsPage.substring(checkIndex+26, checkIndex+26+24);
	});

	test('edit news post',  async () => {
		const params = new URLSearchParams({
			_csrf: csrfToken,
			news_id: newsId,
			title: 'edited title',
			message: 'edited message',
		});
		const response = await fetch('http://localhost/forms/global/editnews', {
			headers: {
				'x-using-xhr': 'true',
				'cookie': sessionCookie,
			},
			method: 'POST',
			body: params,
			redirect: 'manual',
		});
		expect(response.ok).toBe(true);
		const newsPage = await fetch('http://localhost/globalmanage/news.html', {
			headers: {
				'cookie': sessionCookie,
			},
		}).then(res => res.text());
		const editTextIndex = newsPage.indexOf('edited title');
		expect(editTextIndex).not.toBe(-1);
	});

	test('delete news post',  async () => {
		const params = new URLSearchParams({
			_csrf: csrfToken,
			checkednews: newsId,
		});
		const response = await fetch('http://localhost/forms/global/deletenews', {
			headers: {
				'x-using-xhr': 'true',
				'cookie': sessionCookie,
			},
			method: 'POST',
			body: params,
			redirect: 'manual',
		});
		expect(response.ok).toBe(true);
	});

	let filterId;
	test('add global filter',  async () => {
		const params = new URLSearchParams({
			_csrf: csrfToken,
			filters: `notgood
bad words`,
			strict_filtering: 'true',
			filter_mode: '1',
			filter_message: 'Rule+1:+No+fun+allowed',
			filter_ban_duration: '1s',
			filter_ban_appealable: 'true',
		});
		const response = await fetch('http://localhost/forms/global/addfilter', {
			headers: {
				'x-using-xhr': 'true',
				'cookie': sessionCookie,
			},
			method: 'POST',
			body: params,
			redirect: 'manual',
		});
		expect(response.ok).toBe(true);
		const filterPage = await fetch('http://localhost/globalmanage/filters.html', {
			headers: {
				'cookie': sessionCookie,
			},
		}).then(res => res.text());
		const checkIndex = filterPage.indexOf('name="checkedfilters" value="');
		filterId = filterPage.substring(checkIndex+29, checkIndex+29+24);
	});

	test('edit global filter',  async () => {
		const params = new URLSearchParams({
			_csrf: csrfToken,
			filter_id: filterId,
			filters: 'edited globalfilters',
			strict_filtering: 'true',
			filter_mode: '1',
			filter_message: 'edited message',
			filter_ban_duration: '1s'
			// filter_ban_appealable omitted to change to false
		});
		const response = await fetch('http://localhost/forms/global/editfilter', {
			headers: {
				'x-using-xhr': 'true',
				'cookie': sessionCookie,
			},
			method: 'POST',
			body: params,
			redirect: 'manual',
		});
		expect(response.ok).toBe(true);
		const filterPage = await fetch('http://localhost/globalmanage/filters.html', {
			headers: {
				'cookie': sessionCookie,
			},
		}).then(res => res.text());
		const editTextIndex = filterPage.indexOf('edited globalfilters');
		expect(editTextIndex).not.toBe(-1);
	});

	test('make post that doesnt hit global filter',  async () => {
		const params = new URLSearchParams();
		params.append('message', 'blahblahblah');
		params.append('captcha', '000000');
		const response = await fetch('http://localhost/forms/board/test/post', {
			headers: {
				'x-using-xhr': 'true',
			},
			method: 'POST',
			body: params
		});
		expect(response.ok).toBe(true);
	});

	test('make post that hits global filter',  async () => {
		const params = new URLSearchParams();
		params.append('message', 'edited globalfilters');
		params.append('captcha', '000000');
		const response = await fetch('http://localhost/forms/board/test/post', {
			headers: {
				'x-using-xhr': 'true',
			},
			method: 'POST',
			body: params
		});
		expect(response.ok).not.toBe(true);
		await new Promise(res => setTimeout(res, 10000)); //let ban expire
	});

	test('delete global filter',  async () => {
		const params = new URLSearchParams({
			_csrf: csrfToken,
			checkedfilters: filterId,
		});
		const response = await fetch('http://localhost/forms/global/deletefilter', {
			headers: {
				'x-using-xhr': 'true',
				'cookie': sessionCookie,
			},
			method: 'POST',
			body: params,
			redirect: 'manual',
		});
		expect(response.ok).toBe(true);
	});

	test('register test account',  async () => {
		const params = new URLSearchParams({
			_csrf: csrfToken,
			username: 'test',
			password: 'test',
			passwordconfirm: 'test',
			captcha: '000000',
		});
		const response = await fetch('http://localhost/forms/register', {
			headers: {
				'x-using-xhr': 'true',
				'cookie': sessionCookie,
			},
			method: 'POST',
			body: params,
			redirect: 'manual',
		});
		expect(response.status).toBe(302);
	});

	test('register test2 account',  async () => {
		const params = new URLSearchParams({
			_csrf: csrfToken,
			username: 'test2',
			password: 'test2',
			passwordconfirm: 'test2',
			captcha: '000000',
		});
		const response = await fetch('http://localhost/forms/register', {
			headers: {
				'x-using-xhr': 'true',
				'cookie': sessionCookie,
			},
			method: 'POST',
			body: params,
			redirect: 'manual',
		});
		expect(response.status).toBe(302);
	});

	test('edit test account permission',  async () => {
		const params = new URLSearchParams({
			_csrf: csrfToken,
			username: 'test',
			template: 'Px8v4B//wAA=',
		});
		const response = await fetch('http://localhost/forms/global/editaccount', {
			headers: {
				'x-using-xhr': 'true',
				'cookie': sessionCookie,
			},
			method: 'POST',
			body: params,
			redirect: 'manual',
		});
		expect(response.ok).toBe(true);
	});

	test('edit test2 account permission',  async () => {
		const params = new URLSearchParams({
			_csrf: csrfToken,
			username: 'test2',
			template: 'Px8v4B//wAA=',
		});
		const response = await fetch('http://localhost/forms/global/editaccount', {
			headers: {
				'x-using-xhr': 'true',
				'cookie': sessionCookie,
			},
			method: 'POST',
			body: params,
			redirect: 'manual',
		});
		expect(response.ok).toBe(true);
	});

	test('transfer /deleteownertest/ to test2 account',  async () => {
		const params = new URLSearchParams();
		params.append('username', 'test2');
		params.append('_csrf', csrfToken);
		const response = await fetch('http://localhost/forms/board/deleteownertest/transfer', {
			headers: {
				'x-using-xhr': 'true',
				'cookie': sessionCookie,
			},
			method: 'POST',
			body: params,
			redirect: 'manual',
		});
		expect(response.status).toBe(200);
	});

});


code:
describe('run integration tests', () => {
	require('./setup.js')();
	require('./posting.js')();
	require('./global.js')();
	require('./board.js')();
	require('./actions.js')();
	require('./pages.js')();
	require('./cleanup.js')();
	require('./twofactor.js')();
});

repo_name: Marzisoft/jschan

license: mit


code:
const fetch = require('node-fetch');

module.exports = () => describe('Test loading a bunch of pages', () => {

	const urls = [
		'boards.html',
		'boards.json',
		'boards.json?search=test&sort=popularity&direction=desc',
		'boards.html?search=test&sort=popularity&direction=desc',
		'overboard.html',
		'overboard.json',
		'overboard.html?add=test&rem=abc',
		'overboard.json?add=test&rem=abc',
		'index.html',
		'news.html',
		'rules.html',
		'faq.html',
		'globalmanage/recent.html',
		'globalmanage/recent.json',
		'globalmanage/reports.html',
		'globalmanage/reports.json',
		'globalmanage/bans.html',
		'globalmanage/boards.html',
		'globalmanage/boards.json',
		'globalmanage/globallogs.html',
		'globalmanage/accounts.html',
		'globalmanage/roles.html',
		'globalmanage/roles.json',
		'globalmanage/news.html',
		'globalmanage/filters.html',
		'globalmanage/settings.html',
	];

	let sessionCookie;
	test('login as admin',  async () => {
		const params = new URLSearchParams();
		params.append('username', 'admin');
		params.append('password', process.env.TEST_ADMIN_PASSWORD);
		const response = await fetch('http://localhost/forms/login', {
			headers: {
				'x-using-xhr': 'true',
			},
			method: 'POST',
			body: params,
			redirect: 'manual',
		});
		const rawHeaders = response.headers.raw();
		expect(rawHeaders['set-cookie']).toBeDefined();
		expect(rawHeaders['set-cookie'][0]).toMatch(/^connect\.sid/);
		sessionCookie = rawHeaders['set-cookie'][0];
	});

	urls.forEach(u => {
		test(u,  async () => {
			const response = await fetch(`http://localhost/${u}`, {
				headers: {
					cookie: sessionCookie,
				},
			});
			expect(response.ok).toBe(true);
		});
	});

});


code:
const fetch = require('node-fetch');

module.exports = () => describe('Test posting', () => {

	let threadId;
	test('post new thread',  async () => {
		const params = new URLSearchParams();
		params.append('message', Math.random());
		params.append('captcha', '000000');
		const response = await fetch('http://localhost/forms/board/test/post', {
			headers: {
				'x-using-xhr': 'true',
			},
			method: 'POST',
			body: params
		});
		expect(response.ok).toBe(true);
		threadId = (await response.json()).postId;
	});

	let replyId;
	test('post reply',  async () => {
		const params = new URLSearchParams();
		params.append('message', Math.random());
		params.append('thread', threadId);
		params.append('captcha', '000000');
		const response = await fetch('http://localhost/forms/board/test/post', {
			headers: {
				'x-using-xhr': 'true',
			},
			method: 'POST',
			body: params
		});
		expect(response.ok).toBe(true);
		replyId = (await response.json()).postId;
	});

	test('post reply with quotes',  async () => {
		const params = new URLSearchParams();
		params.append('message', `>>${threadId}
>>${replyId}`);
		params.append('thread', threadId);
		params.append('captcha', '000000');
		const response = await fetch('http://localhost/forms/board/test/post', {
			headers: {
				'x-using-xhr': 'true',
			},
			method: 'POST',
			body: params
		});
		expect(response.ok).toBe(true);
	});

	test('post reply with all markdowns',  async () => {
		const params = new URLSearchParams();
		params.append('captcha', '000000');
		params.append('message', `>greentext
<pinktext
==title==
''bold''
__underline__
~strikethrough~~
||spoiler text||
**italic**
(((detected)))
##2d9+3
https://example.com
[Board Rules](https://your.imageboard/a/page/rules.html)(staff only)
>>${threadId}
>>>/test/
>>>/test/${threadId}
\`inline monospace\`
[code]language
int main() {...}
[/code]

[code]aa
∧＿∧
( ・ω・) Let's try that again.
[/code]
`);
		params.append('thread', threadId);
		const response = await fetch('http://localhost/forms/board/test/post', {
			headers: {
				'x-using-xhr': 'true',
			},
			method: 'POST',
			body: params
		});
		expect(response.ok).toBe(true);
	});

	async function postThreadsWithReplies(board, threads, replies) {
		const threadParams = new URLSearchParams();
		threadParams.append('message', Math.random());
		threadParams.append('captcha', '000000');
		for (let t = 0; t < threads; t++) {
			const response = await fetch(`http://localhost/forms/board/${board}/post`, {
				method: 'POST',
				body: threadParams
			});
			expect(response.ok).toBe(true);
			let thread = await fetch(`http://localhost/${board}/catalog.json`)
				.then(res => res.json())
				.then(json => json[Math.floor(Math.random()*json.length)].postId);
			for (let r = 0; r < replies; r++) {
				const replyParams = new URLSearchParams();
				replyParams.append('message', Math.random());
				replyParams.append('thread', thread);
				replyParams.append('captcha', '000000');
				const response2 = await fetch(`http://localhost/forms/board/${board}/post`, {
					method: 'POST',
					body: replyParams
				});
				expect(response2.ok).toBe(true);
			}
		}
	}

	jest.setTimeout(5*60*1000); //give a generous timeout
	test('post some threads & replies on test boards',  async () => {
		try {
			await postThreadsWithReplies('test', 30, 5);
			await postThreadsWithReplies('test2', 10, 5);
		} catch (e) {
			console.error(e);
		}
		jest.setTimeout(5*1000); //back to normal timeout
	});

});


code:
module.exports = {
	allowed_hosts: '',
	prune_ips: '0',
	global_announcement: '',
	country_code_header: 'x-country-code',
	ip_header: 'x-real-ip',
	meta_site_name: '',
	meta_url: '',
	stats_count_anonymizers: 'true',
	prune_immediately: 'true',
	thumb_extension: '.jpg',
	cache_templates: 'true',
	lock_wait: '3000',
	overboard_limit: '20',
	overboard_catalog_limit: '100',
	allow_custom_overboard: 'true',
	language: 'en-GB',
	archive_links: 'https://archive.today/?run=1&url=%s',
	reverse_links: 'https://tineye.com/search?url=%s',
	prune_modlogs: '30',
	default_ban_duration: '1000',
	quote_limit: '25',
	preview_replies: '5',
	sticky_preview_replies: '5',
	early_404_fraction: '3',
	early_404_replies: '5',
	max_recent_news: '5',
	inactive_account_time: '7889400000',
	inactive_account_action: '0',
	abandoned_board_action: '0',
	hot_threads_limit: '5',
	hot_threads_threshold: '10',
	hot_threads_max_age: '2629800000',
	captcha_options_font: 'default',
	captcha_options_type: 'text',
	captcha_options_generate_limit: '250',
	captcha_options_text_font: 'default',
	captcha_options_text_line: 'true',
	captcha_options_text_wave: '0',
	captcha_options_text_paint: '2',
	captcha_options_text_noise: '0',
	captcha_options_grid_image_size: '120',
	captcha_options_grid_size: '4',
	captcha_options_grid_icon_y_offset: '15',
	captcha_options_grid_trues: '●\n■\n♞\n♦\n▼\n▲\n♜\n✦\n♚\n♞\n♛\n♝\n♟\n♣',
	captcha_options_grid_falses: '○\n□\n♘\n♢\n▽\n△\n♖\n✧\n♔\n♘\n♕\n♗\n♙\n♧',
	captcha_options_grid_question: 'Select+the+solid/filled+icons',
	captcha_options_grid_edge: '25',
	captcha_options_grid_noise: '0',
	captcha_options_num_distorts_min: '2',
	captcha_options_num_distorts_max: '3',
	captcha_options_distortion: '7',
	block_bypass_force_anonymizers: 'true',
	block_bypass_expire_after_uses: '50',
	block_bypass_expire_after_time: '86400000',
	flood_timers_same_content_same_ip: '0',
	flood_timers_same_content_any_ip: '0',
	flood_timers_any_content_same_ip: '0',
	dnsbl_blacklists: 'tor.dan.me.uk',
	dnsbl_cache_time: '3600',
	rate_limit_cost_captcha: '10',
	rate_limit_cost_board_settings: '30',
	rate_limit_cost_edit_post: '30',
	highlight_options_language_subset: 'javascript\n' +
				'typescript\n' +
				'perl\n' +
				'js\n' +
				'c++\n' +
				'c\n' +
				'java\n' +
				'kotlin\n' +
				'php\n' +
				'h\n' +
				'csharp\n' +
				'bash\n' +
				'sh\n' +
				'zsh\n' +
				'python\n' +
				'ruby\n' +
				'css\n' +
				'html\n' +
				'json\n' +
				'golang\n' +
				'rust\n' +
				'aa',
	highlight_options_threshold: '5',
	themes: '',
	code_themes: '',
	board_defaults_language: 'en-GB',
	board_defaults_theme: 'yotsuba-b',
	board_defaults_code_theme: 'ir-black',
	global_limits_custom_css_enabled: 'true',
	global_limits_custom_css_filters: '@\nurl(',
	global_limits_custom_css_strict: 'true',
	global_limits_custom_css_max: '10000',
	global_limits_filters_max: 100,
	global_limits_field_length_name: '100',
	global_limits_field_length_email: '100',
	global_limits_field_length_subject: '100',
	global_limits_field_length_postpassword: '100',
	global_limits_field_length_message: '20000',
	global_limits_field_length_report_reason: '100',
	global_limits_field_length_ban_reason: '100',
	global_limits_field_length_log_message: '100',
	global_limits_field_length_uri: '50',
	global_limits_field_length_boardname: '50',
	global_limits_field_length_description: '100',
	global_limits_multi_input_posts_anon: '20',
	global_limits_multi_input_posts_staff: '100',
	frontend_script_default_embeds_enabled: 'true',
	frontend_script_default_hide_recursive: 'true',
	frontend_script_default_smooth_scrolling: 'true',
	frontend_script_default_thread_watcher: 'true',
	frontend_script_default_volume: '100',
	frontend_script_default_loop: 'true',
	frontend_script_default_image_loading_bars: 'true',
	frontend_script_default_live: 'true',
	frontend_script_default_local_time: 'true',
	frontend_script_default_relative_time: 'true',
	frontend_script_default_show_yous: 'true',
	frontend_script_default_notifications_yous_only: 'true',
	frontend_script_default_tegaki_width: '500',
	frontend_script_default_tegaki_height: '500',
	audio_thumbnails: 'true',
	ffmpeg_gif_thumbnails: 'true',
	thumb_size: '250',
	video_thumb_percentage: '5',
	other_mime_types: 'text/plain\napplication/pdf',
	space_file_name_replacement: '_',
	global_limits_reply_limit_min: '10',
	global_limits_reply_limit_max: '1000',
	global_limits_thread_limit_min: '100',
	global_limits_thread_limit_max: '200',
	global_limits_bump_limit_min: '10',
	global_limits_bump_limit_max: '1000',
	global_limits_post_files_max: '5',
	global_limits_post_files_size_max: '10485760',
	global_limits_custom_pages_max_length: '10000',
	global_limits_post_files_size_video_resolution: '100000000',
	global_limits_post_files_size_image_resolution: '100000000',
	global_limits_custom_pages_max: '10',
	global_limits_banner_files_width: '500',
	global_limits_banner_files_height: '500',
	global_limits_banner_files_size_max: '10485760',
	global_limits_banner_files_max: '10',
	global_limits_banner_files_total: '100',
	global_limits_flag_files_size_max: '1048576',
	global_limits_flag_files_max: '10',
	global_limits_flag_files_total: '100',
	global_limits_asset_files_size_max: '10485760',
	global_limits_asset_files_max: '10',
	global_limits_asset_files_total: '50',
	proxy_address: '',
	webring_following: '',
	webring_logos: '',
	webring_blacklist: '',
	board_defaults_message_r9k_mode: '0',
	board_defaults_file_r9k_mode: '0',
	board_defaults_lock_mode: '0',
	board_defaults_captcha_mode: '0',
	board_defaults_pph_trigger: '50',
	board_defaults_pph_trigger_action: '2',
	board_defaults_tph_trigger: '10',
	board_defaults_tph_trigger_action: '1',
	board_defaults_lock_reset: '0',
	board_defaults_captcha_reset: '0',
	board_defaults_default_name: 'Anon',
	board_defaults_enable_tegaki: 'true',
	board_defaults_user_post_delete: 'true',
	board_defaults_user_post_spoiler: 'true',
	board_defaults_user_post_unlink: 'true',
	board_defaults_thread_limit: '100',
	board_defaults_reply_limit: '1000',
	board_defaults_bump_limit: '500',
	board_defaults_max_files: '5',
	board_defaults_min_thread_message_length: '0',
	board_defaults_min_reply_message_length: '0',
	board_defaults_max_thread_message_length: '20000',
	board_defaults_max_reply_message_length: '20000',
	board_defaults_delete_protection_count: '0',
	board_defaults_delete_protection_age: '0',
	board_defaults_allowed_file_types_video: 'true',
	board_defaults_allowed_file_types_image: 'true',
	board_defaults_allowed_file_types_animated_image: 'true',
	board_defaults_allowed_file_types_audio: 'true',
	ethereum_links: 'https://example.com/%s',
};


code:
const fetch = require('node-fetch');
process.env.NO_CAPTCHA = 1;

module.exports = () => describe('login and create test board', () => {

	let sessionCookie
		, csrfToken;

	test('login as admin',  async () => {
		const params = new URLSearchParams();
		params.append('username', 'admin');
		params.append('password', process.env.TEST_ADMIN_PASSWORD);
		const response = await fetch('http://localhost/forms/login', {
			headers: {
				'x-using-xhr': 'true',
			},
			method: 'POST',
			body: params,
			redirect: 'manual',
		});
		const rawHeaders = response.headers.raw();
		expect(rawHeaders['set-cookie']).toBeDefined();
		expect(rawHeaders['set-cookie'][0]).toMatch(/^connect\.sid/);
		sessionCookie = rawHeaders['set-cookie'][0];
		csrfToken = await fetch('http://localhost/csrf.json', { headers: { 'cookie': sessionCookie }})
			.then(res => res.json())
			.then(json => json.token);
	});

	test('delete test board if exists',  async () => {
		const params = new URLSearchParams();
		params.append('_csrf', csrfToken);
		params.append('uri', 'test');
		params.append('confirm', 'true');
		const response = await fetch('http://localhost/forms/board/test/deleteboard', {
			headers: {
				'x-using-xhr': 'true',
				'cookie': sessionCookie,
			},
			method: 'POST',
			body: params,
			redirect: 'manual',
		});
		expect([200, 404]).toContain(response.status);
	});

	test('create /test/ board',  async () => {
		const params = new URLSearchParams();
		params.set('uri', 'test');
		params.set('name', 'test');
		const options = {
			headers: {
				'x-using-xhr': 'true',
				'cookie': sessionCookie,
			},
			method: 'POST',
			body: params,
			redirect: 'manual',
		};
		const response1 = await fetch('http://localhost/forms/create', options);
		expect([302]).toContain(response1.status);
	});

	test('create /test2/ board',  async () => {
		const params = new URLSearchParams();
		params.append('uri', 'test2');
		params.append('name', 'test2');
		const response = await fetch('http://localhost/forms/create', {
			headers: {
				'x-using-xhr': 'true',
				'cookie': sessionCookie,
			},
			method: 'POST',
			body: params,
			redirect: 'manual',
		});
		expect([302, 409]).toContain(response.status);
	});

	test('create /deleteownertest/ board',  async () => {
		const params = new URLSearchParams();
		params.append('uri', 'deleteownertest');
		params.append('name', 'deleteownertest');
		const response = await fetch('http://localhost/forms/create', {
			headers: {
				'x-using-xhr': 'true',
				'cookie': sessionCookie,
			},
			method: 'POST',
			body: params,
			redirect: 'manual',
		});
		expect([302]).toContain(response.status);
	});

	test('change global settings, disable antispam',  async () => {
		const params = new URLSearchParams({
			_csrf: csrfToken,
			allowed_hosts: '',
			prune_ips: '0',
			global_announcement: '',
			country_code_header: 'x-country-code',
			ip_header: 'x-real-ip',
			meta_site_name: '',
			meta_url: '',
			stats_count_anonymizers: 'true',
			prune_immediately: 'true',
			thumb_extension: '.jpg',
			cache_templates: 'true',
			lock_wait: '3000',
			overboard_limit: '20',
			overboard_catalog_limit: '100',
			allow_custom_overboard: 'true',
			language: 'en-GB',
			archive_links: 'https://archive.today/?run=1&url=%s',
			reverse_links: 'https://tineye.com/search?url=%s',
			prune_modlogs: '30',
			default_ban_duration: '1000',
			quote_limit: '25',
			preview_replies: '5',
			sticky_preview_replies: '5',
			early_404_fraction: '3',
			early_404_replies: '5',
			max_recent_news: '5',
			inactive_account_time: '7889400000',
			inactive_account_action: '0',
			abandoned_board_action: '0',
			hot_threads_limit: '5',
			hot_threads_threshold: '10',
			hot_threads_max_age: '2629800000',
			captcha_options_font: 'default',
			captcha_options_type: 'text',
			captcha_options_generate_limit: '250',
			captcha_options_text_font: 'default',
			captcha_options_text_line: 'true',
			captcha_options_text_wave: '0',
			captcha_options_text_paint: '2',
			captcha_options_text_noise: '0',
			captcha_options_grid_image_size: '120',
			captcha_options_grid_size: '4',
			captcha_options_grid_icon_y_offset: '15',
			captcha_options_grid_trues: '●\n■\n♞\n♦\n▼\n▲\n♜\n✦\n♚\n♞\n♛\n♝\n♟\n♣',
			captcha_options_grid_falses: '○\n□\n♘\n♢\n▽\n△\n♖\n✧\n♔\n♘\n♕\n♗\n♙\n♧',
			captcha_options_grid_question: 'Select+the+solid/filled+icons',
			captcha_options_grid_edge: '25',
			captcha_options_grid_noise: '0',
			captcha_options_num_distorts_min: '2',
			captcha_options_num_distorts_max: '3',
			captcha_options_distortion: '7',
			block_bypass_force_anonymizers: 'true',
			block_bypass_expire_after_uses: '50',
			block_bypass_expire_after_time: '86400000',
			flood_timers_same_content_same_ip: '0',
			flood_timers_same_content_any_ip: '0',
			flood_timers_any_content_same_ip: '0',
			dnsbl_blacklists: 'tor.dan.me.uk',
			dnsbl_cache_time: '3600',
			rate_limit_cost_captcha: '10',
			rate_limit_cost_board_settings: '30',
			rate_limit_cost_edit_post: '30',
			highlight_options_language_subset: 'javascript\n' +
						'typescript\n' +
						'perl\n' +
						'js\n' +
						'c++\n' +
						'c\n' +
						'java\n' +
						'kotlin\n' +
						'php\n' +
						'h\n' +
						'csharp\n' +
						'bash\n' +
						'sh\n' +
						'zsh\n' +
						'python\n' +
						'ruby\n' +
						'css\n' +
						'html\n' +
						'json\n' +
						'golang\n' +
						'rust\n' +
						'aa',
			highlight_options_threshold: '5',
			themes: '',
			code_themes: '',
			board_defaults_language: 'en-GB',
			board_defaults_theme: 'yotsuba-b',
			board_defaults_code_theme: 'ir-black',
			global_limits_custom_css_enabled: 'true',
			global_limits_custom_css_filters: '@\nurl(',
			global_limits_custom_css_strict: 'true',
			global_limits_custom_css_max: '10000',
			global_limits_filters_max: 100,
			global_limits_field_length_name: '100',
			global_limits_field_length_email: '100',
			global_limits_field_length_subject: '100',
			global_limits_field_length_postpassword: '100',
			global_limits_field_length_message: '20000',
			global_limits_field_length_report_reason: '100',
			global_limits_field_length_ban_reason: '100',
			global_limits_field_length_log_message: '100',
			global_limits_field_length_uri: '50',
			global_limits_field_length_boardname: '50',
			global_limits_field_length_description: '100',
			global_limits_multi_input_posts_anon: '20',
			global_limits_multi_input_posts_staff: '100',
			frontend_script_default_embeds_enabled: 'true',
			frontend_script_default_hide_recursive: 'true',
			frontend_script_default_smooth_scrolling: 'true',
			frontend_script_default_thread_watcher: 'true',
			frontend_script_default_volume: '100',
			frontend_script_default_loop: 'true',
			frontend_script_default_image_loading_bars: 'true',
			frontend_script_default_live: 'true',
			frontend_script_default_local_time: 'true',
			frontend_script_default_relative_time: 'true',
			frontend_script_default_show_yous: 'true',
			frontend_script_default_notifications_yous_only: 'true',
			frontend_script_default_tegaki_width: '500',
			frontend_script_default_tegaki_height: '500',
			audio_thumbnails: 'true',
			ffmpeg_gif_thumbnails: 'true',
			thumb_size: '250',
			video_thumb_percentage: '5',
			other_mime_types: 'text/plain\napplication/pdf',
			space_file_name_replacement: '_',
			global_limits_reply_limit_min: '10',
			global_limits_reply_limit_max: '1000',
			global_limits_thread_limit_min: '100',
			global_limits_thread_limit_max: '200',
			global_limits_bump_limit_min: '10',
			global_limits_bump_limit_max: '1000',
			global_limits_post_files_max: '5',
			global_limits_post_files_size_max: '10485760',
			global_limits_custom_pages_max_length: '10000',
			global_limits_post_files_size_video_resolution: '100000000',
			global_limits_post_files_size_image_resolution: '100000000',
			global_limits_custom_pages_max: '10',
			global_limits_banner_files_width: '500',
			global_limits_banner_files_height: '500',
			global_limits_banner_files_size_max: '10485760',
			global_limits_banner_files_max: '10',
			global_limits_banner_files_total: '100',
			global_limits_flag_files_size_max: '1048576',
			global_limits_flag_files_max: '10',
			global_limits_flag_files_total: '100',
			global_limits_asset_files_size_max: '10485760',
			global_limits_asset_files_max: '10',
			global_limits_asset_files_total: '50',
			proxy_address: '',
			webring_following: '',
			webring_logos: '',
			webring_blacklist: '',
			board_defaults_message_r9k_mode: '0',
			board_defaults_file_r9k_mode: '0',
			board_defaults_lock_mode: '0',
			board_defaults_captcha_mode: '0',
			board_defaults_pph_trigger: '50',
			board_defaults_pph_trigger_action: '2',
			board_defaults_tph_trigger: '10',
			board_defaults_tph_trigger_action: '1',
			board_defaults_lock_reset: '0',
			board_defaults_captcha_reset: '0',
			board_defaults_default_name: 'Anon',
			board_defaults_enable_tegaki: 'true',
			board_defaults_user_post_delete: 'true',
			board_defaults_user_post_spoiler: 'true',
			board_defaults_user_post_unlink: 'true',
			board_defaults_thread_limit: '100',
			board_defaults_reply_limit: '1000',
			board_defaults_bump_limit: '500',
			board_defaults_max_files: '5',
			board_defaults_min_thread_message_length: '0',
			board_defaults_min_reply_message_length: '0',
			board_defaults_max_thread_message_length: '20000',
			board_defaults_max_reply_message_length: '20000',
			board_defaults_delete_protection_count: '0',
			board_defaults_delete_protection_age: '0',
			board_defaults_allowed_file_types_video: 'true',
			board_defaults_allowed_file_types_image: 'true',
			board_defaults_allowed_file_types_animated_image: 'true',
			board_defaults_allowed_file_types_audio: 'true',
			ethereum_links: 'https://example.com/%s',
		});
		const response = await fetch('http://localhost/forms/global/settings', {
			headers: {
				'x-using-xhr': 'true',
				'cookie': sessionCookie,
			},
			method: 'POST',
			body: params,
			redirect: 'manual',
		});
		expect(response.status).toBe(200);
	});

	test('edit default role, allow bypass captcha',  async () => {
		const roles = await fetch('http://localhost/globalmanage/roles.json', {
			headers: {
				'cookie': sessionCookie,
			}
		}).then(res => res.json());
		const anonRole = roles.find(r => r.name === 'ANON');
		const params = new URLSearchParams({
			_csrf: csrfToken,
			roleid: anonRole._id,
			CREATE_BOARD: '2',
			CREATE_ACCOUNT: '3',
			BYPASS_CAPTCHA: '8',
			USE_MARKDOWN_PINKTEXT: '35',
			USE_MARKDOWN_GREENTEXT: '36',
			USE_MARKDOWN_BOLD: '37',
			USE_MARKDOWN_UNDERLINE: '38',
			USE_MARKDOWN_STRIKETHROUGH: '39',
			USE_MARKDOWN_TITLE: '40',
			USE_MARKDOWN_ITALIC: '41',
			USE_MARKDOWN_SPOILER: '42',
			USE_MARKDOWN_MONO: '43',
			USE_MARKDOWN_CODE: '44',
			USE_MARKDOWN_DETECTED: '45',
			USE_MARKDOWN_LINK: '46',
			USE_MARKDOWN_DICE: '47',
			USE_MARKDOWN_FORTUNE: '48'
		});
		const response = await fetch('http://localhost/forms/global/editrole', {
			headers: {
				'x-using-xhr': 'true',
				'cookie': sessionCookie,
			},
			method: 'POST',
			body: params,
			redirect: 'manual',
		});
		expect([200, 302, 409]).toContain(response.status);
	});

});


code:
const fetch = require('node-fetch')
	, OTPAuth = require('otpauth')
	, redis = require(__dirname+'/../lib/redis/redis.js')
	, globalSettings = require(__dirname+'/res/globalsettings.js');

module.exports = () => describe('test two factor authentication', () => {

	let sessionCookie
		, csrfToken
		, twofactorSecret
		, twofactorToken;

	test('login as admin without 2fa',  async () => {
		const params = new URLSearchParams();
		params.append('username', 'admin');
		params.append('password', process.env.TEST_ADMIN_PASSWORD);
		const response = await fetch('http://localhost/forms/login', {
			headers: {
				'x-using-xhr': 'true',
			},
			method: 'POST',
			body: params,
			redirect: 'manual',
		});
		const rawHeaders = response.headers.raw();
		expect(rawHeaders['set-cookie']).toBeDefined();
		expect(rawHeaders['set-cookie'][0]).toMatch(/^connect\.sid/);
		sessionCookie = rawHeaders['set-cookie'][0];
		csrfToken = await fetch('http://localhost/csrf.json', { headers: { 'cookie': sessionCookie }})
			.then(res => res.json())
			.then(json => json.token);
	});

	test('check if twofactor setup link is on account page',  async () => {
		const response = await fetch('http://localhost/account.html', {
			headers: {
				'cookie': sessionCookie,
			},
		});
		const text = await response.text();
		expect(text).toContain(' {
		const response = await fetch('http://localhost/account.html', {
			headers: {
				'cookie': sessionCookie,
			},
		});
		expect((await response.text())).not.toContain(''); //Note: .not
	});

	test('enable account 2fa enforcement',  async () => {
		const params = new URLSearchParams({
			_csrf: csrfToken,
			...globalSettings,
			// force_action_twofactor: true,
			force_account_twofactor: true
		});
		const response = await fetch('http://localhost/forms/global/settings', {
			headers: {
				'cookie': sessionCookie,
			},
			method: 'POST',
			body: params,
			redirect: 'manual',
		});
		expect(response.status).toBe(200);
	});

	test('make sure force redirected to twofactor page',  async () => {
		const response = await fetch('http://localhost/account.html', {
			headers: {
				'cookie': sessionCookie,
			},
			// redirect: 'manual', //Note: allow to be redirected to /twofactor.html
		});
		expect((await response.text())).toContain('');
	});

	test('load twofactor page and fetch secret',  async () => {
		const response = await fetch('http://localhost/twofactor.html', {
			headers: {
				'cookie': sessionCookie,
			},
		});
		const text = await response.text();
		const indexOfSecret = text.indexOf('');
		twofactorSecret = text.substring(indexOfSecret+19, indexOfSecret+19+32);
		twofactorToken = OTPAuth.TOTP.generate({
			algorithm: 'SHA256',
			secret: OTPAuth.Secret.fromBase32(twofactorSecret)
		});
		expect(twofactorToken).toBeDefined();
	});

	test('submit 2fa form to enable 2fa',  async () => {
		const params = new URLSearchParams();
		params.append('_csrf', csrfToken);
		params.append('twofactor', twofactorToken);
		const response = await fetch('http://localhost/forms/twofactor', {
			headers: {
				'x-using-xhr': 'true',
				'cookie': sessionCookie,
			},
			method: 'POST',
			body: params,
			redirect: 'manual',
		});
		expect(response.status).toBe(200);
	});

	test('submit 2fa form again (should be rejected)',  async () => {
		const params = new URLSearchParams();
		params.append('_csrf', csrfToken);
		params.append('twofactor', twofactorToken);
		const response = await fetch('http://localhost/forms/twofactor', {
			headers: {
				'x-using-xhr': 'true',
				'cookie': sessionCookie,
			},
			method: 'POST',
			body: params,
			redirect: 'manual',
		});
		expect(response.status).toBe(403);
	});

	test('login as admin with missing 2fa',  async () => {
		const params = new URLSearchParams();
		params.append('username', 'admin');
		params.append('password', process.env.TEST_ADMIN_PASSWORD);
		params.append('twofactor', '');
		const response = await fetch('http://localhost/forms/login', {
			headers: {
				'x-using-xhr': 'true',
				'cookie': sessionCookie,
			},
			method: 'POST',
			body: params,
			redirect: 'manual',
		});
		expect(response.status).toBe(403);
	});

	test('login as admin with incorrect 2fa',  async () => {
		const params = new URLSearchParams();
		params.append('username', 'admin');
		params.append('password', process.env.TEST_ADMIN_PASSWORD);
		params.append('twofactor', '000000');
		const response = await fetch('http://localhost/forms/login', {
			headers: {
				'x-using-xhr': 'true',
				'cookie': sessionCookie,
			},
			method: 'POST',
			body: params,
			redirect: 'manual',
		});
		expect(response.status).toBe(403);
	});

	test('login as admin with correct 2fa',  async () => {
		await redis.deletePattern('twofactor_success:admin:*'); //delete used 2fa code before trying to login again (same token at this point)
		const params = new URLSearchParams();
		params.append('username', 'admin');
		params.append('password', process.env.TEST_ADMIN_PASSWORD);
		params.append('twofactor', twofactorToken);
		const response = await fetch('http://localhost/forms/login', {
			headers: {
				'x-using-xhr': 'true',
				'cookie': sessionCookie,
			},
			method: 'POST',
			body: params,
			redirect: 'manual',
		});
		const rawHeaders = response.headers.raw();
		expect(rawHeaders['set-cookie']).toBeDefined();
		expect(rawHeaders['set-cookie'][0]).toMatch(/^connect\.sid/);
		// Set updated session cookie and token for 2fa enforcement
		sessionCookie = rawHeaders['set-cookie'][0];
		csrfToken = await fetch('http://localhost/csrf.json', { headers: { 'cookie': sessionCookie }})
			.then(res => res.json())
			.then(json => json.token);
	});

	test('enable action 2fa enforcement',  async () => {
		const params = new URLSearchParams({
			_csrf: csrfToken,
			...globalSettings,
			force_action_twofactor: true,
			// force_account_twofactor: true
		});
		const response = await fetch('http://localhost/forms/global/settings', {
			headers: {
				'cookie': sessionCookie,
			},
			method: 'POST',
			body: params,
			redirect: 'manual',
		});
		expect(response.status).toBe(200);
	});

	test('settings change blocked when missing twofactor in body',  async () => {
		const params = new URLSearchParams({
			_csrf: csrfToken,
			...globalSettings,
			// force_action_twofactor: true,
			// force_account_twofactor: true
		});
		const response = await fetch('http://localhost/forms/global/settings', {
			headers: {
				'cookie': sessionCookie,
			},
			method: 'POST',
			body: params,
			redirect: 'manual',
		});
		console.log((await response.text()));
		expect(response.status).toBe(400);
	});

	test('settings change works with twofactor in body, and disabling 2fa enforcement',  async () => {
		await redis.deletePattern('twofactor_success:admin:*'); //delete used 2fa code before trying to login again (same token at this point)
		const params = new URLSearchParams({
			_csrf: csrfToken,
			...globalSettings,
			// force_action_twofactor: true,
			// force_account_twofactor: true
			twofactor: twofactorToken,
		});
		const response = await fetch('http://localhost/forms/global/settings', {
			headers: {
				'cookie': sessionCookie,
			},
			method: 'POST',
			body: params,
			redirect: 'manual',
		});
		console.log((await response.text()));
		expect(response.status).toBe(200);
	});

	test('login as admin again with correct 2fa (rejected for reuse)',  async () => {
		const params = new URLSearchParams();
		params.append('username', 'admin');
		params.append('password', process.env.TEST_ADMIN_PASSWORD);
		params.append('twofactor', twofactorToken);
		const response = await fetch('http://localhost/forms/login', {
			headers: {
				'x-using-xhr': 'true',
				'cookie': sessionCookie,
			},
			method: 'POST',
			body: params,
			redirect: 'manual',
		});
		expect(response.status).toBe(403);
	});

	test('change admin password with missing 2fa',  async () => {
		const params = new URLSearchParams();
		params.append('username', 'admin');
		params.append('password', process.env.TEST_ADMIN_PASSWORD);
		params.append('newpassword', process.env.TEST_ADMIN_PASSWORD);
		params.append('newpasswordconfirm', process.env.TEST_ADMIN_PASSWORD);
		params.append('twofactor', '');
		const response = await fetch('http://localhost/forms/changepassword', {
			headers: {
				'x-using-xhr': 'true',
				'cookie': sessionCookie,
			},
			method: 'POST',
			body: params,
			redirect: 'manual',
		});
		expect(response.status).toBe(403);
	});

	test('change admin password with incorrect 2fa',  async () => {
		const params = new URLSearchParams();
		params.append('username', 'admin');
		params.append('password', process.env.TEST_ADMIN_PASSWORD);
		params.append('newpassword', process.env.TEST_ADMIN_PASSWORD);
		params.append('newpasswordconfirm', process.env.TEST_ADMIN_PASSWORD);
		params.append('twofactor', '000000');
		const response = await fetch('http://localhost/forms/changepassword', {
			headers: {
				'x-using-xhr': 'true',
				'cookie': sessionCookie,
			},
			method: 'POST',
			body: params,
			redirect: 'manual',
		});
		expect(response.status).toBe(403);
	});

	test('change admin password with correct 2fa',  async () => {
		await redis.deletePattern('twofactor_success:admin:*'); //delete used 2fa code before trying to login again (same token at this point)
		const params = new URLSearchParams();
		params.append('username', 'admin');
		params.append('password', process.env.TEST_ADMIN_PASSWORD);
		params.append('newpassword', process.env.TEST_ADMIN_PASSWORD);
		params.append('newpasswordconfirm', process.env.TEST_ADMIN_PASSWORD);
		params.append('twofactor', twofactorToken);
		params.append('captcha', '000000');
		const response = await fetch('http://localhost/forms/changepassword', {
			headers: {
				'x-using-xhr': 'true',
				'cookie': sessionCookie,
			},
			method: 'POST',
			body: params,
			redirect: 'manual',
		});
		await redis.close();
		expect(response.status).toBe(200);
	});

});


code:
#!/bin/bash
#
# Dumps jschan database to gzip archive, and archives the static folder
#

#variables, change me
APP_NAME="whatever"
MONGO_DATABASE="jschan"
MONGO_HOST=""
MONGO_PORT=""
MONGO_USER=""
MONGO_PASSWORD=""
TIMESTAMP=`date +%F-%H%M`
BACKUPS_DIR="/path/to/backups/folder/$APP_NAME"

#probably dont change these
DB_BACKUP_NAME="$APP_NAME-$TIMESTAMP.gz"
FILE_BACKUP_NAME="$APP_NAME-$TIMESTAMP-files.zip"
DB_ARCHIVE_PATH="$BACKUPS_DIR/$DB_BACKUP_NAME"
FILE_ARCHIVE_PATH="$BACKUPS_DIR/$FILE_BACKUP_NAME"

#create backup folder
mkdir -p $BACKUPS_DIR

#archive (no compression) files
zip -r -0 $FILE_ARCHIVE_PATH ./static

#dump database to .gz archive
mongodump --username $MONGO_USER --password $MONGO_PASSWORD --authenticationDatabase admin --db $MONGO_DATABASE --archive=$DB_ARCHIVE_PATH --gzip
rm -rf dump

#delete backups older than 7 days
sudo find $ARCHIVE_PATH -type f -name "*.gz" -mtime +7 -exec rm -f {} \;
sudo find $ARCHIVE_PATH -type f -name "*.zip" -mtime +7 -exec rm -f {} \;


code:
const fs = require('fs')
	, locales = ['en-GB', 'pt-PT', 'pt-BR', 'ru-RU', 'it-IT']
	, enGBData = require(__dirname+'/../locales/en-GB.json');

locales.forEach(locale => {
	const localeData = require(__dirname+`/../locales/${locale}.json`);
	// Take any missing values from en-GB
	for (const key in enGBData) {
		if (!localeData[key]) {
			localeData[key] = enGBData[key];
		}
	}
	// Sort and write updated data
	const sortedEntries = Object.entries(localeData)
		.sort((a, b) => a[0].localeCompare(b[0]))
		.reduce((acc, [key, value]) => {
			acc[key] = value;
			return acc;
		}, {});
	fs.writeFileSync(__dirname+`/../locales/${locale}.json`, JSON.stringify(sortedEntries, null, '\t'));
});


code:
#!/bin/bash
#
# Send an authenticated request to jschan
#

# Variables, change me
DOMAIN="your.ib"
USERNAME="username"
PASSWORD="password"
FORM_PATH="/forms/whatever"
FORM_DATA="something=ABC&something_else=123"

# Login
LOGIN_REQUEST=$(curl -si "https://$DOMAIN/forms/login" \
	-H "Referer: https://$DOMAIN" \
	-H "x-using-xhr: 1" \
	--data "username=$USERNAME" \
	--data "password=$PASSWORD")
RESPONSE_CODE=$(echo "$LOGIN_REQUEST" | grep HTTP |  awk '{print $2}')
[[ "$RESPONSE_CODE" != "302" ]] && echo "Login failed" && exit;
SESSION_COOKIE=$(echo "$LOGIN_REQUEST" | grep 'set-cookie' | awk '{print $2}')

# Get CSRF token
CSRF_REQUEST=$(curl -s "https://$DOMAIN/csrf.json" \
	-H "Cookie: $SESSION_COOKIE" \
	-H "Referer: https://$DOMAIN" \
	-H "x-using-xhr: 1")
CSRF_TOKEN=$(echo "$CSRF_REQUEST" | jq -j '.token')

# Send authed request
ANNOUNCEMENT_REQUEST=$(curl -si "https://$DOMAIN$FORM_PATH" \
	-H "Cookie: $SESSION_COOKIE" \
	-H "Referer: https://$DOMAIN" \
	-H "x-using-xhr: 1" \
	--data "_csrf=$CSRF_TOKEN" \
	--data "$FORM_DATA")


code: Are you sure? Changing the canvas will clear all layers and history and disable replay recording.

code: Export does not include post password or (You)'s

code: Please enable JavaScript to solve the captcha.

code: Right click to replace with the current color


code:
#!/bin/bash
# roughly extract all translation calls used in frontend scripts
grep -ri '__' gulp/res/js/*.js \
	| sed 's/__/\n/g' \
	| awk -F'))' '{ print $1 }' \
	| grep -Po "(?<=')[^',]+(?=')" \
	| sort \
	| uniq \
	| awk '{ print "\""$0"\"""," }'

#	| awk -F'__' '{ print $2 }' \


code:
'use strict';

/*
 * Restore a post or a full thread from a mongodump backup.
 */

(async () => {

	const Mongo = require(__dirname+'/../db/db.js')
		, { execSync } = require('child_process')
		, { EJSON } = require('bson')
		, config = require(__dirname+'/../lib/misc/config.js');

	console.log('CONNECTING TO MONGODB');
	await Mongo.connect();
	await Mongo.checkVersion();
	await config.load();
	const db = Mongo.db.collection('posts');

	if (process.argv.length !== 4) {
		console.error('Usage: node restore_posts.js PATH_TO_posts.bson OBJECT_ID_OF_POST');
		process.exit(2);
	}
	const path = process.argv[2];
	const post_object_id = process.argv[3];

	// for some reason, the node BSON package can't handle some mongodump BSON files like posts.bson
	// so we'll run bsondump to convert to JSON, then use EJSON from the BSON package to parse the special type information

	const postsdata = execSync(`bsondump --quiet --type json ${path}`, (error, stdout, stderr) => {
		if (error) {
			console.error(`error: ${error.message}`);
			process.exit(1);
		}

		if (stderr) {
			console.error(`stderr: ${stderr}`);
			process.exit(1);
		}

		return stdout;
	});

	// and, because it's a debug tool, bsondump outputs each document as a different json on a new line, instead of printing a valid json file
	const posts_json = EJSON.parse('['+postsdata.toString().replace(/}(\r\n|\n|\r){/gm, '},{')+']');

	// iterate over all the posts and find the one with the correct ID
	let i = 0;
	while (i < posts_json.length && posts_json[i]['_id'] != post_object_id) { i += 1; }
	if (i == posts_json.length) { console.log('Object ID not found in posts'); process.exit(2); }
	const data = posts_json[i];

	let res = await insertPost(db, data);
	console.log(res);

	// if the post doesn't have a parent thread, it is a thread. we need to insert the child posts too
	if (data.thread == null) {
		for (const e of posts_json) {
			if (e['thread'] == data['postId']) {
				// since posts are already indexed by postID, which contains a timestamp to the nearest second, there's no risk for a post trying to add a backlink before the target exists
				res = await insertPost(db, e);
				console.log(res);
			}
		}
	}
	console.log('Make sure to run `gulp html` and restore the attached files');
	process.exit();

})();

async function insertPost(db, data) {
	const postMongoId = await db.insertOne(data).then(result => result.insertedId); //_id of post
	const postId = data['postId'];
	const board = data['board'];

	//add backlinks to the posts this post quotes
	if (data.thread && data.quotes.length > 0) {
		await db.updateMany({
			'_id': {
				'$in': data.quotes.map(q => q._id)
			},

		}, {
			'$addToSet': {
				'backlinks': { _id: postMongoId, postId: postId }
			}
		});
	}

	//restore invalidated quotes now this post exists again
	if (data.thread && data.backlinks.length > 0) {
		const threadId = data['thread'];
		await db.updateMany({
			'_id': {
				'$in': data.backlinks.map(q => q._id)
			}
		},
		{
			'$addToSet': {
				'quotes': { _id: postMongoId, thread: threadId, postId: postId }
			}
		});
		await db.updateMany({
			'_id': {
				'$in': data.backlinks.map(q => q._id)
			}
		},

		[{
			'$set': {
				message: {
					$replaceOne: { input: '$message', find: `<span class="invalid-quote">>>${postId}</span>`, replacement: `<a class="quote" href="/${board}/thread/${threadId}.html#${postId}">>>${postId}</a>` }
				}
			}
		}]
		);
	}

	return ({ postId, postMongoId });
}


code:
#!/bin/bash
#
# for development, generate a simple self signed ssl cert and make /etc/nginx/snippets/self-signed.conf
#

sudo openssl req -x509 -nodes -days 10000 -newkey rsa:2048 -keyout /etc/ssl/private/nginx-selfsigned.key -out /etc/ssl/certs/nginx-selfsigned.crt
sudo openssl dhparam -out /etc/ssl/certs/dhparam.pem 2048
cat > /etc/nginx/snippets/self-signed.conf <<EOF
ssl_certificate /etc/ssl/certs/nginx-selfsigned.crt;
ssl_certificate_key /etc/ssl/private/nginx-selfsigned.key;
EOF


code:
#!/bin/bash
#
# Script to update geoip database for nginx/jschan. Can be added as a cronjob
#

#go to geoip folder
cd /usr/share/GeoIP
#move the existing db to a .bak just in case
mv GeoIP.dat GeoIP.dat.bak
#try and download the DBIP database
wget --retry-connrefused https://dl.miyuru.lk/geoip/dbip/country/dbip.dat.gz
#extract and move it
gunzip dbip.dat.gz
mv dbip.dat GeoIP.dat
#make sure www-data (debian nginx user:group) has permissions
chown www-data:www-data /usr/share/GeoIP/GeoIP.dat


code:
#!/bin/bash
#
# A way to map IPs to the "T1" country code, for tor exit node identification. For sites not using cloudflare.
# 1. (optional) Add this to a cronjob
# 2. Add "include /etc/nginx/snippets/map.conf;" to the http block of your /etc/nginx/nginx.conf
# 3. Change all the proxy_set_header directives in your nginx config to use the $mapped_country_code variable instead of $geoip_country_code or a fixed value like 'TOR'
# 4. (optional) If you want to instead redirect all these known exits to your .onion instead, add "if ($mapped_country_code = "T1") { return 302 'http://xxxx.onion$request_uri'; }" to your clearnet vhost (server block) of your jschan nginx sites-available config
#

#get secops institute tor exit list to temp file
wget --retry-connrefused -qO- https://raw.githubusercontent.com/SecOps-Institute/Tor-IP-Addresses/master/tor-exit-nodes.lst |  tee /tmp/tor-exits >/dev/null
#get fissionrelays tor exit lists to temp file
wget --retry-connrefused -qO- https://lists.fissionrelays.net/tor/exits-ipv6.txt |  tee -a /tmp/tor-exits >/dev/null
wget --retry-connrefused -qO- https://lists.fissionrelays.net/tor/exits-ipv4.txt |  tee -a /tmp/tor-exits >/dev/null
#add start of nginx conf file for default, loki and .onion
printf "map \$remote_addr \$mapped_country_code {\nhostnames;\ndefault \$geoip_country_code;\n172.16.0.* \"LOKI\";\n\"unix:\" \"TOR\";\n" | tee /etc/nginx/snippets/map.conf
#add exits list and set T1 code, prefix.suffix lines, etc
cat /tmp/tor-exits | sort -h | uniq | sed "s/$/\ \"T1\";/g;" | sed -r 's/([0-9]{1,3}\.){3}[0-9]{1,3}/::ffff:&/g' | tee -a /etc/nginx/snippets/map.conf
rm /tmp/tor-exits
#close off nginx conf file
printf "\n}" | tee -a /etc/nginx/snippets/map.conf


code:
#!/bin/bash
# roughly extract all translation calls used in pug view file into key: value
grep -ri '__' $1 \
	| sed 's/__/\n/g' \
	| grep -Po "(?<=')[^',]+(?=')" \
	| awk '{ print "\""$0"\""": ""\""$0"\"""," }'


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='#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?
					b Boards, users & permissions
					ul.mv-0
						li: a(href='#make-a-board') How do I make my own board?
						li: a(href='#antispam') What do the board settings for antispam do?
						li: a(href='#archive-reverse-url-format') What is the archive/reverse image search link url format?
						li: a(href='#twofactor-enforcement') How does 2FA enforcement 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#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. Please note the whitespace before capcodes is significant.
					p Valid examples:
					ol.mv-0
						li name
						li #tripcode
						li ##tripcode
						li ## capcode
						li name#tripcode
						li name##tripcode
						li name## capcode
						li name#tripcode## capcode
						li name##tripcode## capcode
						li #tripcode## capcode
						li ##tripcode## capcode
						li ##
					p The last example is considered a blank capcode and can be used as a shortcut to display your role. Additionally, if a user has multiple aplicable roles (e.g. a board owner, but the user is also global staff) capcodes will default to their lowest role. To show the higher role, you must be explicit and precede any capcode with the role name e.g. ## Global Staff or ## Global Staff capcode
					p Each component can be used in combination or independently. In a post number 9 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 each other

					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. Single # before tripcodes will use the traditional (what is now sometimes known as "insecure") tripcode algorithm shared by many imageboard softwares and websites. Double # before tripcodes will use a sha256 hash with server-side secret for a more secure, non-portable tripcode.

					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 staff. 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.strike 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 ##2%9+3
				td
					img(src='/file/dice.png' height='16' width='16')
					span.dice (##2%9+3) = 10
			tr
				td https://example.com
				td: a(href='#!') https://example.com
			tr
				td
					| [Board Rules](https://your.imageboard/a/page/rules.html)
					div
						small (requires permission + staff only)
				td: a(href='#!') Board Rules
			tr
				td
					| ![Alt text](https://your.imageboard/file/example.jpg)
					div
						small (requires permission)
				td: img(src='/file/attachment.png' alt='Alt text' title='alt-text' class='embimg' height='32' width='32')
			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
					| [code]language
					br
					| int main() {...}
					br
					| [/code]
				td
					span.code int main() {...}
			tr
				td
					pre
						| [code]aa
						| ∧＿∧
						| ( ・ω・) Let's try that again.
						| [/code]
				td
					pre.aa
						| ∧＿∧
						| ( ・ω・) Let's try that again.
			tr
				td(colspan=2)
					| Supported languages for code block syntax highlighting:
					a(rel='nofollow' referrerpolicy='same-origin' target='_blank' href='https://github.com/highlightjs/highlight.js/blob/master/SUPPORTED_LANGUAGES.md') https://github.com/highlightjs/highlight.js/blob/master/SUPPORTED_LANGUAGES.md
					| .
					| If you do not specify a language, a subset of languages is supported for auto-detection: #{codeLanguages.join(', ')}.
					| If the language is "plain", an unsupported value, or the auto-detect confidence is too low, highlighting is disabled for the code block.
					| If the language is "aa", the font will be adjusted for Japanese Shift JIS art.
	.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 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#antispam
		table
			tr
				th: a(href='#antispam') What do the board settings for antispam do?
			tr
				td
					p Lock Mode: Choose to lock posting new threads or all posts.
					p Captcha Mode: Choose to enforce captchas for posting threads or all posts.
					p PPH Trigger Threshold: Trigger an action after a certain amount of PPH.
					p PPH Trigger Action: The action to trigger.
					p TPH Trigger Threshold: Trigger an action after a certain amount of TPH.
					p TPH Trigger Action: The action to trigger.
					p Trigger Reset Lock Mode: If a trigger threshold was reached, reset the lock mode to this at the end of the hour.
					p Trigger Reset Captcha Mode: If a trigger threshold was reached, reset the captcha mode to this at the end of the hour.
					p Early 404: When a new thread is posted, delete any existing threads with less than #{early404Replies} replies beyond the first 1/#{early404Fraction} of threads.
					p Disable anonymizer file posting: Prevent users posting images through anonymizers such as Tor hidden services, lokinet SNApps or i2p eepsites.
					p Blocked Countries: Block country codes (based on geo Ip data) from posting.
	.table-container.flex-center.mv-5
		.anchor#filters
		table
			tr
				th: a(href='#filters') What do the filter options do?
			tr
				td
					p Filters: Newline separated list of words or phrases to match in posts. Checks name, message, email, subject, and filenames.
					p Strict Filtering: More aggressively match filters, by normalising the input compared against the filters.
					p Filter Mode: What to do when a post matches a filter.
					p Block/Ban Message: The message returned if a post is blocked, or the ban reason if a poster is banned.
					p Filter Auto Ban Duration: How long to automatically ban for when filter mode is set to ban. Input the duration in time format described in the #[a(href='#moderation') moderation section].
					p Filter Bans Appealable: Whether or not a ban given by this filter can be appealed. This can be useful if the ban hits an innocent user by mistake.
					p Replace Text: Text to substitute in for a replace filter. This is how word filters are added.
	.table-container.flex-center.mv-5
		.anchor#archive-reverse-url-format
		table
			tr
				th: a(href='#archive-reverse-url-format') What is the archive/reverse image search link url format?
			tr
				td
					p
						| Put a link with
						span.mono %s
						|  where the url of the page/file should go for reverse image search or archive links. For example
						span.mono https://tineye.com/search?url=#[span.bold %s]
						| .
	.table-container.flex-center.mv-5
		.anchor#twofactor-enforcement
		table
			tr
				th: a(href='#twofactor-enforcement') How does 2FA enforcement work?
			tr
				td
					p There are two options included in global management settings that allow admins to increase security by enforcing the use of 2FA for all accounts and/or actions.
					p Force Account 2FA: Forces all accounts to have 2FA, or else the user is redirected to the 2FA setup page.
					p Force Action 2FA: If an account has 2FA enabled, the 2FA code must be input for: changing global settings, changing board settings, resigning from a staff position, deleting accounts, deleting your own account, or deleting a board.
					p The granularity of what actions require 2FA will be expanded and customisable in a future release.
	.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]} (#{code})
	optgroup(label=__('Not blocked'))
		each code in countryCodes.filter(c => !blockedCountries.has(c))
			option(value=code) #{countryNamesMap[code]} (#{code})


code:
details.toggle-label#actionform
	summary.collapse#action-menu #{__('Show Post Actions')}
	.actions
		input(type='hidden' name='hide_name' value='1')
		h4.no-m-p #{__('Actions')}:
		if board.settings.userPostDelete
			label
				input.post-check(type='checkbox', name='delete' value='1')
				| #{__('Delete Posts')}
		if board.settings.userPostUnlink
			label
				input.post-check(type='checkbox', name='unlink_file' value='1')
				| #{__('Unlink Files')}
		if board.settings.userPostSpoiler
			label
				input.post-check(type='checkbox', name='spoiler' value='1')
				| #{__('Spoiler Files')}
		if board.settings.userPostSpoiler || board.settings.userPostUnlink || board.settings.userPostDelete
			label
				input#password(type='password', name='postpassword', placeholder=__('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.collapse#action-menu #{__('Show Post Actions')}
	.actions
		h4.no-m-p #{__('Moderate')}:
		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')
			| #{__('Narrow Range')}
		label
			input.post-check(type='checkbox', name='ban_h' value='1')
			| #{__('Wide 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'))


code:
details.toggle-label#actionform
	summary.collapse#action-menu #{__('Show Post Actions')}
	.actions
		h4.no-m-p #{__('Moderate')}:
		.row.wrap
			.col.mr-5
				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='delete_ip_thread' value='1')
					| #{__('Delete from IP in thread')}
				label
					input.post-check(type='checkbox', name='delete_ip_board' value='1')
					| #{__('Delete from IP on board')}
				label
					input.post-check(type='checkbox', name='delete_ip_global' value='1')
					| #{__('Delete from IP globally')}
				label
					input.post-check(type='checkbox', name='spoiler' value='1')
					| #{__('Spoiler Files')}
				label
					input.post-check(type='checkbox', name='dismiss' value='1')
					| #{__('Dismiss Reports')}
				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')
				label
					input.post-check(type='checkbox', name='move' value='1')
					| #{__('Move')}
				.row
					label.mr-1
						input(type='text', name='move_to_board', placeholder=__('Board') autocomplete='off')
					label
						input(type='number', name='move_to_thread', placeholder=__('Thread No.') autocomplete='off')
			.col.mr-5
				label
					input.post-check(type='checkbox', name='report_ban' value='1')
					| #{__('Ban Reporters')}
				label
					input.post-check(type='checkbox', name='ban' value='1')
					| #{__('Ban Poster')}
				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')
					| #{__('Narrow Range')}
				label
					input.post-check(type='checkbox', name='ban_h' value='1')
					| #{__('Wide 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')
			.col
				label
					input.post-check(type='checkbox', name='edit' value='1')
					| #{__('Edit Post')}
				label
					input(type='text', name='sticky' placeholder='Sticky priority, 0 = unsticky')
				label
					input.post-check(type='checkbox', name='lock' value='1')
					| #{__('Toggle Lock')}
				label
					input.post-check(type='checkbox', name='bumplock' value='1')
					| #{__('Toggle Bumplock')}
				label
					input.post-check(type='checkbox', name='cyclic' value='1')
					| #{__('Toggle Cycle')}
	input(type='submit', value=__('Submit'))


code:
.row
	.label #{__('IP/Hash')}
	input(type='text' name='ip' required)
.row
	.label #{__('Ban Reason')}
	input(type='text' name='ban_reason')
.row
	.label #{__('Modlog Message')}
	input(type='text' name='log_message')
.row
	.label #{__('Ban Duration')}
	input(type='text' name='ban_duration' placeholder='e.g. 7d')
.row
	.label #{__('Non-appealable Ban')}
	label.postform-style.ph-5
		input(type='checkbox', name='no_appeal' value='1')
.row
	.label #{__('Hide Username In Modlog')}
	label.postform-style.ph-5
		input(type='checkbox', name='hide_name' value='1')
input(type='submit', value=__('Submit'))


code:
form.form-post(action=`/forms/appeal`, enctype='application/x-www-form-urlencoded', method='POST' data-captcha-preload='true')
	include ./bantable.pug
		for ban in bans
			+ban(ban, true)
	- const allowAppeal = bans.filter(ban => ban.allowAppeal === true && !ban.appeal).length > 0;
	if allowAppeal === true
		h4.no-m-p #{__('Appeal')}:
			.form-wrapper.flexleft.mt-10
				input(type='hidden' name='_csrf' value=csrf)
				.row
					.label #{__('Message')}
					textarea(rows='5' name='message' required)
				if captchaOptions.type === 'text'
					include ./captchasidelabel.pug
				else
					include ./captchafieldrow.pug
				input(type='submit', value=__('Submit'))


code:
.table-container.mv-10.text-center.horscroll
	table.bantable
		tr
			th
			th #{__('Board')}
			th #{__('Reason')}
			th #{__('IP/ID')}
			th #{__('Type')}
			th #{__('Range')}
			th #{__('Issuer')}
			th #{__('Issue Date')}
			th #{__('Expiry')}
			th #{__('Post(s)')}
			th #{__('Seen?')}
			th #{__('Appealable?')}
			th #{__('Appeal')}
			th #{__('Note')}


code:
| #{__('Page')}:
if maxPage === 0
	a.bold(href=`index.html`) [1]
- for(let i = 1; i <= maxPage; i++)
	if page === i
		a.bold(href=`${i === 1 ? 'index' : i}.html`) [#{i}]
	else
		a(href=`${i === 1 ? 'index' : i}.html`) [#{i}]
	|
|   |


code:
case captchaOptions.type
	when 'google'
		div(class='g-recaptcha' data-sitekey=`${googleRecaptchaSiteKey}` data-theme='dark' data-size='compact' data-callback='recaptchaCallback')
		noscript #{__('Please enable JavaScript to solve the captcha.')}
	when 'hcaptcha'
		div(class='h-captcha' data-sitekey=`${hcaptchaSiteKey}` data-theme='dark' data-size='compact' data-callback='recaptchaCallback')
		noscript #{__('Please enable JavaScript to solve the captcha.')}
	when 'yandex'
		div(class='smart-captcha' data-sitekey=`${yandexSiteKey}` data-callback='recaptchaCallback')
		noscript #{__('Please enable JavaScript to solve the captcha.')}
	when 'text'
		noscript.no-m-p
			iframe.captcha(src='/captcha.html' 'width=210' height='80' scrolling='no' loading='lazy')
		.jsonly.captcha(style='display:none;')
		input.captchafield(type='text' name='captcha' autocomplete='off' placeholder=__('Captcha text') pattern='.{6}' required title=__n('%s characters', 6))
	when 'grid'
	when 'grid2'
		span.text-center #{captchaOptions.grid.question}
		.catalog
			noscript.no-m-p
				iframe.captcha(src='/captcha.html' width='150' height='150' scrolling='no' loading='lazy')
			.jsonly.captcha(style='display:none')
			.captchafield.noselect
				- for(let i = 0; i < captchaOptions.grid.size**2; i++) {
				label.captchachecklabel
					input(type='checkbox' name='captcha' value=i)
					span.captchacheckbox
				- }


code:
details.row.label.mr-0
	summary.pv-5 #{__('Captcha')}
		span.required *
	include ./captcha.pug


code:
.row.label.mr-0
	.pv-5 #{__('Captcha')}
		span.required *
	include ./captcha.pug


code:
section.row
	.label
		span #{__('Captcha')}
			span.required *
	.col.p-0
		include ./captcha.pug


code:
input(type='hidden' name='_csrf' value=csrf)
input(type='hidden' name='filter_id' value=filter._id)
.table-container.flex-center.mv-5.horscroll
	table
		tr
			th #{__('Filters')}
			th #{__('Strict Filtering')}
			th #{__('Filter Mode')}
			th #{__('Block/Ban Message')}
			th #{__('Filter Auto Ban Duration')}
			th #{__('Filter Bans Appealable')}
			th #{__('Replace Text')}
		tr
			td
				textarea(name='filters' rows='10' placeholder=__('Newline separated') required) #{filter.filters.join('\n')}
			td.text-center
				input(type='checkbox', name='strict_filtering', value='true', checked=filter.strictFiltering)
			td
				select(name='filter_mode')
					option(value='0', selected=filter.filterMode === 0) #{__('Do nothing')}
					option(value='1', selected=filter.filterMode === 1) #{__('Block post')}
					option(value='2', selected=filter.filterMode === 2) #{__('Ban')}
					option(value='3', selected=filter.filterMode === 3) #{__('Replace')}
			td
				input(type='text' name='filter_message' placeholder=__('e.g. Rule 1: No ad spam') value=filter.filterMessage)
			td
				input(type='text' name='filter_ban_duration' placeholder=__('e.g. 3d') value=filter.filterBanDuration)
			td.text-center
				input(type='checkbox', name='filter_ban_appealable', value='true', checked=filter.filterBanAppealable)
			td
				input(type='text' name='replace_text' value=filter.replaceText)
input(type='submit', value=__('Save'))


code:
input(type='hidden' name='_csrf' value=csrf)
.table-container.mv-5.horscroll
	table
		tr
			th #{__('Filters')}
			th #{__('Strict Filtering')}
			th #{__('Filter Mode')}
			th #{__('Block/Ban Message')}
			th #{__('Filter Auto Ban Duration')}
			th #{__('Filter Bans Appealable')}
			th #{__('Replace Text')}
		tr
			td
				textarea(name='filters' rows='3' placeholder=__('Newline separated') required)
			td.text-center
				label
					input(type='checkbox', name='strict_filtering', value='true')
			td
				select(name='filter_mode')
					option(value='0', selected=true) #{__('Do nothing')}
					option(value='1') #{__('Block post')}
					option(value='2') #{__('Ban')}
					option(value='3') #{__('Replace')}
			td
				input(type='text' name='filter_message' placeholder=__('e.g. Rule 1: No ad spam'))
			td
				input(type='text' name='filter_ban_duration' placeholder=__('e.g. 3d'))
			td.text-center
				label
					input(type='checkbox', name='filter_ban_appealable', value='true', checked='checked')
			td
				input(type='text' name='replace_text')
input(type='submit', value=__('Submit'))


code:
unless minimal
	small.footer#bottom
		| -
		a(href='/news.html') #{__('news')}
		|  -
		a(href='/rules.html') #{__('rules')}
		|  -
		a(href='/faq.html') #{__('faq')}
		|  -
		div
			a(href='https://gitgud.io/fatchan/jschan/') jschan
			|  #{version}

//- "render" script - for scripted things that are blocking
script(src=`/js/render.js?v=${commit}`)


code:
if forceActionTwofactor === true
	.row
		.label #{__('2FA Code')}
		input(type='text' name='twofactor' required)