Merge branch '469-captcha-improvements' into 'develop'

Captcha improvements

See merge request fatchan/jschan!258
indiachan-spamvector
Thomas Lynch 2 years ago
commit 51de38f757
  1. 2
      .gitlab-ci.yml
  2. 12
      configs/template.js.example
  3. 22
      controllers/forms/globalsettings.js
  4. 36
      gulpfile.js
  5. 3
      lib/build/render.js
  6. 3
      lib/captcha/captcha.js
  7. 67
      lib/captcha/generators/grid.js
  8. 52
      lib/captcha/generators/grid.test.js
  9. 127
      lib/captcha/generators/grid2.js
  10. 55
      lib/captcha/generators/text.js
  11. 28
      lib/captcha/generators/text.test.js
  12. 21
      lib/misc/fonts.js
  13. 20
      migrations/0.8.0.js
  14. 12
      models/forms/changeglobalsettings.js
  15. 15
      models/pages/captcha.js
  16. 2
      models/pages/globalmanage/settings.js
  17. 3
      server.js
  18. 9
      views/custompages/faq.pug
  19. 2
      views/includes/banform.pug
  20. 8
      views/includes/captcha.pug
  21. 2
      views/includes/head.pug
  22. 2
      views/includes/postform.pug
  23. 2
      views/pages/changepassword.pug
  24. 2
      views/pages/create.pug
  25. 58
      views/pages/globalmanagesettings.pug
  26. 2
      views/pages/register.pug

@ -25,6 +25,8 @@ unit-tests:
stage: test
rules:
- if: $CI_PIPELINE_SOURCE == 'merge_request_event'
before_script:
- apt-get update -y && apt-get install -y fonts-urw-base35 gsfonts
script:
- 'npm install'
- 'npm install jest -g'

@ -34,10 +34,22 @@ module.exports = {
captchaOptions: {
type: 'text',
generateLimit: 250,
text: {
font: 'default',
line: true,
wave: 0,
paint: 2,
noise: 0,
},
grid: {
falses: ['○','□','♘','♢','▽','△','♖','✧','♔','♘','♕','♗','♙','♧'],
trues: ['●','■','♞','♦','▼','▲','♜','✦','♚','♞','♛','♝','♟','♣'],
question: 'Select the solid/filled icons',
size: 4,
imageSize: 120,
iconYOffset: 15,
edge: 25,
noise: 0,
},
numDistorts: {
min: 2,

@ -4,6 +4,7 @@ const changeGlobalSettings = require(__dirname+'/../../models/forms/changeglobal
, dynamicResponse = require(__dirname+'/../../lib/misc/dynamic.js')
, themeHelper = require(__dirname+'/../../lib/misc/themes.js')
, config = require(__dirname+'/../../lib/misc/config.js')
, { fontPaths } = require(__dirname+'/../../lib/misc/fonts.js')
, paramConverter = require(__dirname+'/../../lib/middleware/input/paramconverter.js')
, { checkSchema, lengthBody, numberBody, minmaxBody, numberBodyVariable,
inArrayBody } = require(__dirname+'/../../lib/input/schema.js');
@ -12,11 +13,12 @@ module.exports = {
paramConverter: paramConverter({
timeFields: ['inactive_account_time', 'ban_duration', 'board_defaults_filter_ban_duration', 'default_ban_duration', 'block_bypass_expire_after_time', 'dnsbl_cache_time', 'board_defaults_delete_protection_age'],
trimFields: ['allowed_hosts', 'dnsbl_blacklists', 'other_mime_types', 'highlight_options_language_subset', 'global_limits_custom_css_filters', 'board_defaults_filters', 'filters', 'archive_links', 'reverse_links'],
numberFields: ['inactive_account_action', 'abandoned_board_action','filter_mode', 'auth_level',
'captcha_options_generate_limit', 'captcha_options_grid_size', 'captcha_options_grid_image_size', 'captcha_options_num_distorts_min', 'captcha_options_num_distorts_max',
'captcha_options_distortion', 'captcha_options_grid_icon_y_offset', 'flood_timers_same_content_same_ip', 'flood_timers_same_content_any_ip', 'flood_timers_any_content_same_ip',
'block_bypass_expire_after_uses', 'rate_limit_cost_captcha', 'rate_limit_cost_board_settings', 'rate_limit_cost_edit_post',
trimFields: ['captcha_options_grid_question', 'captcha_options_grid_trues', 'captcha_options_grid_falses', 'captcha_options_text_font', 'allowed_hosts', 'dnsbl_blacklists', 'other_mime_types',
'highlight_options_language_subset', 'global_limits_custom_css_filters', 'board_defaults_filters', 'filters', 'archive_links', 'reverse_links'],
numberFields: ['inactive_account_action', 'abandoned_board_action', 'filter_mode', 'auth_level', 'captcha_options_text_wave', 'captcha_options_text_paint', 'captcha_options_text_noise',
'captcha_options_grid_noise', 'captcha_options_grid_edge', 'captcha_options_generate_limit', 'captcha_options_grid_size', 'captcha_options_grid_image_size',
'captcha_options_num_distorts_min', 'captcha_options_num_distorts_max', 'captcha_options_distortion', 'captcha_options_grid_icon_y_offset', 'flood_timers_same_content_same_ip', 'flood_timers_same_content_any_ip',
'flood_timers_any_content_same_ip', 'block_bypass_expire_after_uses', 'rate_limit_cost_captcha', 'rate_limit_cost_board_settings', 'rate_limit_cost_edit_post',
'overboard_limit', 'hot_threads_limit', 'hot_threads_threshold', 'overboard_catalog_limit', 'lock_wait', 'prune_modlogs', 'prune_ips', 'thumb_size', 'video_thumb_percentage', 'quote_limit', 'preview_replies',
'sticky_preview_replies', 'early_404_fraction', 'early_404_replies', 'max_recent_news', 'highlight_options_threshold', 'global_limits_thread_limit_min',
'global_limits_thread_limit_max', 'global_limits_reply_limit_min', 'global_limits_reply_limit_max', 'global_limits_bump_limit_min', 'global_limits_bump_limit_max',
@ -57,7 +59,7 @@ module.exports = {
}, expected: false, error: 'Extra mime types must be like type/subtype' },
{ result: () => {
if (req.body.archive_links) {
/* eslint-disable no-useless-escape */
/* eslint-disable no-useless-escape */
return /https?\:\/\/[^\s<>\[\]{}|\\^]+%s[^\s<>\[\]{}|\\^]*/i.test(req.body.archive_links);
}
return false;
@ -80,7 +82,7 @@ module.exports = {
{ result: lengthBody(req.body.ip_header, 0, 100), expected: false, error: 'IP header length must not exceed 100 characters' },
{ result: lengthBody(req.body.meta_site_name, 0, 100), expected: false, error: 'Meta site name must not exceed 100 characters' },
{ result: lengthBody(req.body.meta_url, 0, 100), expected: false, error: 'Meta url must not exceed 100 characters' },
{ result: inArrayBody(req.body.captcha_options_type, ['grid', 'text', 'google', 'hcaptcha']), expected: true, error: 'Invalid captcha options type' },
{ result: inArrayBody(req.body.captcha_options_type, ['grid', 'grid2', 'text', 'google', 'hcaptcha']), expected: true, error: 'Invalid captcha options type' },
{ result: numberBody(req.body.captcha_options_generate_limit, 1), expected: true, error: 'Captcha options generate limit must be a number > 0' },
{ result: numberBody(req.body.captcha_options_grid_size, 2, 6), expected: true, error: 'Captcha options grid size must be a number from 2-6' },
{ result: numberBody(req.body.captcha_options_grid_image_size, 50, 500), expected: true, error: 'Captcha options grid image size must be a number from 50-500' },
@ -89,6 +91,12 @@ module.exports = {
{ result: numberBody(req.body.captcha_options_num_distorts_max, 0, 10), expected: true, error: 'Captcha options max distorts must be a number from 0-10' },
{ result: minmaxBody(req.body.captcha_options_num_distorts_min, req.body.captcha_options_num_distorts_max), expected: true, error: 'Captcha options distorts min must be less than max' },
{ result: numberBody(req.body.captcha_options_distortion, 0, 50), expected: true, error: 'Captcha options distortion must be a number from 0-50' },
{ result: inArrayBody(req.body.captcha_options_text_font, fontPaths), expected: true, error: 'Invalid captcha options text font' },
{ result: numberBody(req.body.captcha_options_text_wave, 0, 10), expected: true, error: 'Captcha options text wave effect strength must be a number form 0-10' },
{ result: numberBody(req.body.captcha_options_text_paint, 0, 10), expected: true, error: 'Captcha options text paint effect strength must be a number from 0-10' },
{ result: numberBody(req.body.captcha_options_text_noise, 0, 10), expected: true, error: 'Captcha options text noise effect strength must be a number from 0-10' },
{ result: numberBody(req.body.captcha_options_grid_noise, 0, 10), expected: true, error: 'Captcha options grid noise effect strength must be a number from 0-10' },
{ result: numberBody(req.body.captcha_options_grid_edge, 0, 50), expected: true, error: 'Captcha options grid edge effect strength must be a number from 0-50' },
{ result: numberBody(req.body.dnsbl_cache_time), expected: true, error: 'Invalid dnsbl cache time' },
{ result: numberBody(req.body.flood_timers_same_content_same_ip), expected: true, error: 'Invalid flood time same content same ip' },
{ result: numberBody(req.body.flood_timers_same_content_any_ip), expected: true, error: 'Invalid flood time same contenet any ip' },

@ -286,17 +286,28 @@ async function wipe() {
async function css() {
try {
//a little more configurable
let bypassHeight = (config.get.captchaOptions.type === 'google' || config.get.captchaOptions.type === 'hcaptcha')
? 500
: config.get.captchaOptions.type === 'grid'
? 330
: 235;
let captchaHeight = config.get.captchaOptions.type === 'text' ? 80
: config.get.captchaOptions.type === 'grid' ? config.get.captchaOptions.grid.imageSize+30
: 200; //google/hcaptcha doesnt need this set
let captchaWidth = config.get.captchaOptions.type === 'text' ? 210
: config.get.captchaOptions.type === 'grid' ? config.get.captchaOptions.grid.imageSize+30
: 200; //google/hcaptcha doesnt need this set
let bypassHeight
, captchaHeight
, captchaWidth;
switch (config.get.captchaOptions.type) {
case 'google':
case 'hcaptcha':
bypassHeight = 500;
captchaHeight = 200;
captchaWidth = 200;
break;
case 'grid':
case 'grid2':
bypassHeight = 330;
captchaHeight = config.get.captchaOptions.grid.imageSize+30;
captchaWidth = config.get.captchaOptions.grid.imageSize+30;
break;
case 'text':
bypassHeight = 235;
captchaHeight = 80;
captchaWidth = 210;
break;
}
const cssLocals = `:root {
--attachment-img: url('/file/attachment.png');
--spoiler-img: url('/file/spoiler.png');
@ -417,11 +428,10 @@ async function custompages() {
defaultTheme: config.get.boardDefaults.theme,
defaultCodeTheme: config.get.boardDefaults.codeTheme,
postFilesSize: formatSize(config.get.globalLimits.postFilesSize.max),
captchaType: config.get.captchaOptions.type,
googleRecaptchaSiteKey: google.siteKey,
hcaptchaSiteKey: hcaptcha.siteKey,
captchaGridSize: config.get.captchaOptions.grid.size,
globalAnnouncement: config.get.globalAnnouncement,
captchaOptions: config.get.captchaOptions,
commit,
version,
}

@ -34,10 +34,9 @@ const updateLocals = () => {
postFilesSize: formatSize(globalLimits.postFilesSize.max),
globalLimits,
enableWebring,
captchaType: captchaOptions.type,
googleRecaptchaSiteKey: google.siteKey,
hcaptchaSiteKey: hcaptcha.siteKey,
captchaGridSize: captchaOptions.grid.size,
captchaOptions,
globalAnnouncement,
};
};

@ -31,7 +31,8 @@ module.exports = async (captchaInput, captchaId) => {
captchaInput = Array.isArray(captchaInput) ? captchaInput : [captchaInput];
switch (captchaOptions.type) {
case 'grid': { //grid captcha
case 'grid':
case 'grid2': { //grid captcha
const gridCaptchaMongoId = ObjectId(captchaId);
const normalisedAnswer = new Array(captchaOptions.grid.size**2).fill(false);
captchaInput.forEach(num => {

@ -3,25 +3,18 @@
const gm = require('@fatchan/gm').subClass({ imageMagick: true })
, { promisify } = require('util')
, randomBytes = promisify(require('crypto').randomBytes)
, { Captchas } = require(__dirname+'/../../../db/')
, config = require(__dirname+'/../../misc/config.js')
, uploadDirectory = require(__dirname+'/../../file/uploaddirectory.js')
, getDistorts = require(__dirname+'/../getdistorts.js')
, randomRange = promisify(require('crypto').randomInt)
, padding = 30 //pad edge of image to account for character size + distortion
, zeros = ['○','□','♘','♢','▽','△','♖','✧','♔','♘','♕','♗','♙','♧']
, ones = ['●','■','♞','♦','▼','▲','♜','✦','♚','♞','♛','♝','♟','♣'];
, padding = 30; //pad edge of image to account for character size + distortion
module.exports = async () => {
module.exports = async (captchaOptions) => {
const { captchaOptions } = config.get;
const { size, trues, falses, imageSize, noise, edge, iconYOffset } = captchaOptions.grid;
const width = imageSize+padding; //TODO: these will never be different, right?
const height = imageSize+padding;
const gridSize = captchaOptions.grid.size;
const width = captchaOptions.grid.imageSize+padding;
const height = captchaOptions.grid.imageSize+padding;
//number of inputs in grid, just square of gridSize
const numInputs = gridSize ** 2;
//number of inputs in grid, just square of size
const numInputs = size ** 2;
//create an array of true/false for grid from random bytes
const randBuffer = await randomBytes(numInputs);
@ -31,25 +24,26 @@ module.exports = async () => {
boolArray[(await randomRange(0, numInputs))] = true;
}
//insert the captcha to db and get id
const captchaId = await Captchas.insertOne(boolArray).then(r => r.insertedId);
const captcha = gm(width, height, '#ffffff')
.fill('#000000')
.font(__dirname+'/../font.ttf');
//divide the space by grid size, accounting for padding
const spaceSize = (width-padding)/gridSize;
for(let j = 0; j < gridSize; j++) { //for each row
const spaceSize = (width-padding)/size;
for(let j = 0; j < size; j++) { //for each row
//x offset for whole row (not per character or it gets way too difficult to solve)
let cxOffset = await randomRange(0, spaceSize * 1.5);
for(let i = 0; i < gridSize; i++) { //for character in row
const index = (j*gridSize)+i;
const cyOffset = await randomRange(0, captchaOptions.grid.iconYOffset);
const charIndex = await randomRange(0, ones.length);
const character = (boolArray[index] ? ones : zeros)[charIndex];
let cxOffset = await randomRange(0, Math.floor(spaceSize * 1.5));
for(let i = 0; i < size; i++) { //for character in row
const index = (j*size)+i;
const cyOffset = iconYOffset > 0 ? (await randomRange(0, iconYOffset)) : 0;
let character;
if (boolArray[index] === true) {
character = trues[(await randomRange(0, trues.length))];
} else {
character = falses[(await randomRange(0, falses.length))];
}
captcha.fontSize((await randomRange(20, 30)));
captcha.drawText(
(spaceSize * i) + cxOffset,
@ -67,17 +61,16 @@ module.exports = async () => {
captcha.distort(distorts, 'Shepards');
}
//apply strong edge detection, makes it harder and fills empty areas
captcha.edge(25);
//add optional edge effect
if (edge > 0) {
captcha.edge(edge);
}
//add optional noise effect
if (noise > 0) {
captcha.noise(noise);
}
return new Promise((resolve, reject) => {
captcha
.write(`${uploadDirectory}/captcha/${captchaId}.jpg`, (err) => {
if (err) {
return reject(err);
}
return resolve({ captchaId });
});
});
return { captcha, solution: boolArray };
};

@ -0,0 +1,52 @@
const gridv1 = require('./grid.js')
, gridv2 = require('./grid2.js')
, falses = ['○','□','♘','♢','▽','△','♖','✧','♔','♘','♕','♗','♙','♧']
, trues = ['●','■','♞','♦','▼','▲','♜','✦','♚','♞','♛','♝','♟','♣'];
const cases = [
{ name: '3n grid captcha', grid: { falses, trues, question: 'whatever', size: 3, imageSize: 120, iconYOffset: 0, edge: 0, noise: 0 }, numDistorts: { min: 0, max: 1 }, distortion: 0 },
{ name: '4n grid captcha', grid: { falses, trues, question: 'whatever', size: 4, imageSize: 120, iconYOffset: 0, edge: 0, noise: 0 }, numDistorts: { min: 0, max: 1 }, distortion: 0 },
{ name: '6n grid captcha', grid: { falses, trues, question: 'whatever', size: 6, imageSize: 120, iconYOffset: 0, edge: 0, noise: 0 }, numDistorts: { min: 0, max: 1 }, distortion: 0 },
{ name: '4n grid captcha with distortion', grid: { falses, trues, question: 'whatever', size: 4, imageSize: 120, iconYOffset: 0, edge: 0, noise: 0 }, numDistorts: { min: 1, max: 10 }, distortion: 10 },
{ name: '4n grid captcha with edge', grid: { falses, trues, question: 'whatever', size: 4, imageSize: 120, iconYOffset: 0, edge: 10, noise: 0 }, numDistorts: { min: 0, max: 1 }, distortion: 0 },
{ name: '4n grid captcha with noise', grid: { falses, trues, question: 'whatever', size: 4, imageSize: 120, iconYOffset: 0, edge: 0, noise: 10 }, numDistorts: { min: 0, max: 1 }, distortion: 0 },
{ name: '4n grid captcha with y offset', grid: { falses, trues, question: 'whatever', size: 4, imageSize: 120, iconYOffset: 15, edge: 0, noise: 0 }, numDistorts: { min: 0, max: 1 }, distortion: 0 },
{ name: '4n grid captcha with all effects', grid: { falses, trues, question: 'whatever', size: 4, imageSize: 120, iconYOffset: 0, edge: 0, noise: 0 }, numDistorts: { min: 0, max: 1 }, distortion: 0 },
{ name: '4n grid captcha with all effects and distortion', grid: { falses, trues, question: 'whatever', size: 4, imageSize: 120, iconYOffset: 150, edge: 10, noise: 10 }, numDistorts: { min: 1, max: 10 }, distortion: 10 },
{ name: '250px 6n grid captcha with all effects and distortion', grid: { falses, trues, question: 'whatever', size: 6, imageSize: 250, iconYOffset: 150, edge: 10, noise: 10 }, numDistorts: { min: 1, max: 10 }, distortion: 10 },
{ name: '123px 4n grid captcha with all effects and distortion', grid: { falses, trues, question: 'whatever', size: 4, imageSize: 123, iconYOffset: 150, edge: 10, noise: 10 }, numDistorts: { min: 1, max: 10 }, distortion: 10 },
{ name: '90px 3n grid captcha with all effects and distortion', grid: { falses, trues, question: 'whatever', size: 3, imageSize: 90, iconYOffset: 150, edge: 10, noise: 10 }, numDistorts: { min: 1, max: 10 }, distortion: 10 },
];
describe('generate gridv1 captcha', () => {
for(let captchaOptions of cases) {
test(captchaOptions.name, async () => {
const { captcha } = await gridv1(captchaOptions);
expect(await new Promise((res, rej) => {
captcha.write('/tmp/captcha.jpg', (err) => {
if (err) {
return rej(err);
}
res();
});
}));
});
}
});
describe('generate gridv2 captcha', () => {
for(let captchaOptions of cases) {
test(captchaOptions.name, async () => {
const { captcha } = await gridv2(captchaOptions);
expect(await new Promise((res, rej) => {
captcha.write('/tmp/captcha.jpg', (err) => {
if (err) {
return rej(err);
}
res();
});
}));
});
}
});

@ -0,0 +1,127 @@
'use strict';
const gm = require('@fatchan/gm').subClass({ imageMagick: true })
, { promisify } = require('util')
, getDistorts = require(__dirname+'/../getdistorts.js')
, randomRange = promisify(require('crypto').randomInt)
, randomBytes = promisify(require('crypto').randomBytes)
, padding = 30 //pad edge of image to account for character size + distortion
, nArrows = ['↑', '↟', '↥', '↾', '↿', '⇑', '⇡']
, eArrows = ['➸', '→', '➳', '➵', '→', '↛', '↠', '↣', '↦', '↪', '↬', '↱', '↳', '⇉', '⇏', '⇒', '⇛', '⇝', '⇢']
, wArrows = [ '←', '↚', '↞', '↜', '↢', '↩', '↤', '↫', '↰', '↲', '↵', '⇇', '⇍', '⇐', '⇚', '⇜', '⇠']
, sArrows = ['↓', '↡', '↧', '↴', '⇂', '⇃', '⇊', '⇓', '⇣']
, allArrows = [...nArrows, ...eArrows, ...wArrows, ...sArrows]
, randomBool = async (p) => { return ((await randomBytes(1))[0] > p); }
, randomOf = async (arr) => { return arr[(await randomRange(0, arr.length))]; };
//TODO: last two could belong in lib/misc/(random?)
module.exports = async (captchaOptions) => {
const { size, trues, falses, imageSize, noise, edge } = captchaOptions.grid;
const width = imageSize+padding; //TODO: these will never be different, right?
const height = imageSize+padding;
const charMatrix = new Array(size).fill(false)
.map(() => new Array(size).fill(false));
const answerMatrix = new Array(size).fill(false)
.map(() => new Array(size).fill(false));
//put the icon arrows should point at
const correctRow = await randomRange(0, size);
const correctCol = await randomRange(0, size);
charMatrix[correctRow][correctCol] = await randomOf(trues);
//put correct and incorrect arrows in the row/column
const numArray = [...new Array(size).keys()];
const perpendicularRows = numArray.filter(x => x !== correctRow);
for (let row of perpendicularRows) {
/*TODO: necessary to memoize these "inverse" sets of arrows? or maybe instead of even doing a 50/50
random, it should just pick a random from allArrows then set the isCorrect based on if its in the correct set?*/
let arrows;
const isCorrect = await randomBool(127);
if (row < correctRow) {
arrows = isCorrect ? sArrows : [...nArrows, ...eArrows, ...wArrows];
} else if (row > correctRow) {
arrows = isCorrect ? nArrows : [...sArrows, ...eArrows, ...wArrows];
}
charMatrix[row][correctCol] = await randomOf(arrows);
answerMatrix[row][correctCol] = isCorrect;
}
const perpendicularCols = numArray.filter(x => x !== correctCol);
for (let col of perpendicularCols) {
let arrows;
const isCorrect = await randomBool(127);
if (col < correctCol) {
arrows = isCorrect ? eArrows : [...sArrows, ...nArrows, ...wArrows];
} else if (col > correctCol) {
arrows = isCorrect ? wArrows : [...sArrows, ...nArrows, ...eArrows];
}
charMatrix[correctRow][col] = await randomOf(arrows);
answerMatrix[correctRow][col] = isCorrect;
}
//TODO: diagonals? need more arrows
//this sucks
if (!answerMatrix.flat().some(x => x === true)) {
if ((await randomBool(127)) === true) {
const randomRow = await randomOf(perpendicularRows);
const arrows = randomRow < correctRow ? sArrows : nArrows;
charMatrix[randomRow][correctCol] = await randomOf(arrows);
answerMatrix[randomRow][correctCol] = true;
} else {
const randomCol = await randomOf(perpendicularCols);
const arrows = randomCol < correctCol ? eArrows : wArrows;
charMatrix[correctRow][randomCol] = await randomOf(arrows);
answerMatrix[correctRow][randomCol] = true;
}
}
//fill the rest with junk arrows/falses
for (let row = 0; row < size; row++) {
for (let col = 0; col < size; col++) {
if (charMatrix[row][col] === false) {
charMatrix[row][col] = (await randomBool(80))
? (await randomOf(allArrows))
: (await randomOf(falses));
}
}
}
const captcha = gm(width, height, '#ffffff')
.fill('#000000')
.font(__dirname+'/../font.ttf');
const spaceSize = (width-padding)/size;
for (let row = 0; row < size; row++) {
let cxOffset = Math.floor(spaceSize * 1.5);
for (let col = 0; col < size; col++) {
const cyOffset = captchaOptions.grid.iconYOffset;
captcha.fontSize((await randomRange(20, 30)));
captcha.drawText(
(spaceSize * col) + cyOffset,
(spaceSize * row) + cxOffset,
charMatrix[row][col]
);
}
}
//create an array of distortions and apply to the image, if distortion is enabled
const { distortion, numDistorts } = captchaOptions;
if (distortion > 0) {
const distorts = await getDistorts(width, height, numDistorts, distortion);
captcha.distort(distorts, 'Shepards');
}
//add optional edge effect
if (edge > 0) {
captcha.edge(edge);
}
//add optional noise effect
if (noise > 0) {
captcha.noise(noise);
}
return { captcha, solution: answerMatrix.flat() };
};

@ -1,9 +1,6 @@
'use strict';
const gm = require('@fatchan/gm').subClass({ imageMagick: true })
, { Captchas } = require(__dirname+'/../../../db/')
, config = require(__dirname+'/../../misc/config.js')
, uploadDirectory = require(__dirname+'/../../file/uploaddirectory.js')
, characterWidth = (char) => {
switch (char) {
case 'w':
@ -20,7 +17,7 @@ const gm = require('@fatchan/gm').subClass({ imageMagick: true })
return 30;
}
}
, totalWidth = (text) => {
, totalTextWidth = (text) => {
return text.split('').reduce((acc, char) => {
return characterWidth(char) + acc + 1;
}, 0);
@ -33,34 +30,50 @@ const gm = require('@fatchan/gm').subClass({ imageMagick: true })
, randomRange = promisify(require('crypto').randomInt)
, getDistorts = require(__dirname+'/../getdistorts.js');
module.exports = async () => {
const { captchaOptions } = config.get;
module.exports = async (captchaOptions) => {
/* generate random between 1000000 and 1zzzzzz and not 0 and zzzzzz, so output will have
enough characters for 000000-zzzzzz */
const textInt = await randomRange(minVal, maxVal+1);
const text = textInt.toString(36).substr(-6, 6);
//insert the captcha to db and get id
const captchaId = await Captchas.insertOne(text).then(r => r.insertedId);
//y position for line through text
const lineY = await randomRange(35,45);
const captcha = gm(width,height,'#ffffff')
.fill('#000000')
.fontSize(65);
if (captchaOptions.text.font !== 'default') {
captcha.font(captchaOptions.text.font);
}
//draw each character at their x based on the characterWidth()
const startX = (width-totalWidth(text))/2;
const textWidth = totalTextWidth(text);
const startX = (width-textWidth)/2;
let charX = startX;
for (let i = 0; i < 6; i++) {
captcha.drawText(charX, 60, text[i]);
charX += characterWidth(text[i]);
}
captcha.drawRectangle(startX, lineY, charX, lineY+4);
//draw optional line/strikethrough
if (captchaOptions.text.line === true) {
const lineY = await randomRange(35,45);
captcha.drawRectangle(startX, lineY, startX+textWidth, lineY+4);
}
//add optional wave effect
if (captchaOptions.text.wave > 0) {
captcha.wave(captchaOptions.text.wave, width/4);
}
//add optional paint effect
if (captchaOptions.text.paint > 0) {
captcha.paint(captchaOptions.text.paint);
}
//add optional noise effect
if (captchaOptions.text.noise > 0) {
captcha.noise(captchaOptions.text.noise);
}
//create an array of distortions and apply to the image, if distortion is enabled
const { distortion, numDistorts } = captchaOptions;
@ -69,17 +82,7 @@ module.exports = async () => {
captcha.distort(distorts, 'Shepards');
}
captcha.paint(2); //paint effect makes a bit harder
return new Promise((resolve, reject) => {
captcha
.write(`${uploadDirectory}/captcha/${captchaId}.jpg`, (err) => {
if (err) {
return reject(err);
}
return resolve({ captchaId });
});
});
return { captcha, solution: text };
};

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

@ -0,0 +1,21 @@
'use strict';
const fontList = require('child_process')
.execSync('fc-list -f "%{file}:%{family[0]} %{style[0]}\n"')
.toString()
.split('\n') //split by newlines, like here ^
.filter(line => line) //filter empty lines
.map(line => {
//map to an object with path and name
const [path, name] = line.split(':');
return { path, name };
})
.sort((a, b) => {
//alphabetical name sort
return a.name.localeCompare(b.name);
});
module.exports = {
fontList,
fontPaths: new Set(['default', ...fontList.map(f => f.path)]), //memoize paths
};

@ -3,12 +3,24 @@
const timeUtils = require(__dirname+'/../lib/converter/timeutils.js');
module.exports = async(db, redis) => {
console.log('add inactive account and board auto handling');
console.log('add more captcha options and add inactive account and board auto handling');
await db.collection('globalsettings').updateOne({ _id: 'globalsettings' }, {
'$set': {
inactiveAccountTime: timeUtils.MONTH * 3,
inactiveAccountAction: 0, //no actions by default
abandonedBoardAction: 0,
'captchaOptions.text': {
'font': 'default',
'line': true,
'wave': 0,
'paint': 2,
'noise': 0,
},
'captchaOptions.grid.falses': ['○','□','♘','♢','▽','△','♖','✧','♔','♘','♕','♗','♙','♧'],
'captchaOptions.grid.trues': ['●','■','♞','♦','▼','▲','♜','✦','♚','♞','♛','♝','♟','♣'],
'captchaOptions.grid.question': 'Select the solid/filled icons',
'captchaOptions.grid.noise': 0,
'captchaOptions.grid.edge': 25,
'inactiveAccountTime': timeUtils.MONTH * 3,
'inactiveAccountAction': 0, //no actions by default
'abandonedBoardAction': 0,
},
});
console.log('Clearing globalsettings cache');

@ -73,6 +73,18 @@ module.exports = async (req, res) => {
size: numberSetting(req.body.captcha_options_grid_size, oldSettings.captchaOptions.grid.size),
imageSize: numberSetting(req.body.captcha_options_grid_image_size, oldSettings.captchaOptions.grid.imageSize),
iconYOffset: numberSetting(req.body.captcha_options_grid_icon_y_offset, oldSettings.captchaOptions.grid.iconYOffset),
question: trimSetting(req.body.captcha_options_grid_question, oldSettings.captchaOptions.grid.question),
trues: arraySetting(req.body.captcha_options_grid_trues, oldSettings.captchaOptions.grid.trues),
falses: arraySetting(req.body.captcha_options_grid_falses, oldSettings.captchaOptions.grid.falses),
edge: numberSetting(req.body.captcha_options_grid_edge, oldSettings.captchaOptions.grid.edge),
noise: numberSetting(req.body.captcha_options_grid_noise, oldSettings.captchaOptions.grid.noise),
},
text: {
font: trimSetting(req.body.captcha_options_text_font, oldSettings.captchaOptions.text.font),
line: booleanSetting(req.body.captcha_options_text_line, oldSettings.captchaOptions.text.line),
wave: numberSetting(req.body.captcha_options_text_wave, oldSettings.captchaOptions.text.wave),
paint: numberSetting(req.body.captcha_options_text_paint, oldSettings.captchaOptions.text.paint),
noise: numberSetting(req.body.captcha_options_text_noise, oldSettings.captchaOptions.text.noise),
},
numDistorts: {
min: numberSetting(req.body.captcha_options_num_distorts_min, oldSettings.captchaOptions.numDistorts.min),

@ -2,12 +2,13 @@
const { Captchas, Ratelimits } = require(__dirname+'/../../db/')
, config = require(__dirname+'/../../lib/misc/config.js')
, uploadDirectory = require(__dirname+'/../../lib/file/uploaddirectory.js')
, production = process.env.NODE_ENV === 'production';
module.exports = async (req, res, next) => {
const { secureCookies, rateLimitCost, captchaOptions } = config.get;
if (!['grid', 'text'].includes(captchaOptions.type)) {
if (!['text', 'grid', 'grid2'].includes(captchaOptions.type)) {
return next(); //only grid and text captcha continue
}
@ -33,7 +34,17 @@ module.exports = async (req, res, next) => {
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());
const { captcha, solution } = await generateCaptcha(captchaOptions);
captchaId = await Captchas.insertOne(solution).then(r => r.insertedId);
//captcha.write doesn't like to be util.promisify'd
await (new Promise((resolve, reject) => {
captcha.write(`${uploadDirectory}/captcha/${captchaId}.jpg`, (err) => {
if (err) {
return reject(err);
}
resolve();
});
}));
}
} catch (err) {
return next(err);

@ -1,6 +1,7 @@
'use strict';
const config = require(__dirname+'/../../../lib/misc/config.js')
, { fontList } = require(__dirname+'/../../../lib/misc/fonts.js')
, { themes, codeThemes } = require(__dirname+'/../../../lib/misc/themes.js')
, { countryNamesMap, countryCodes } = require(__dirname+'/../../../lib/misc/countries.js');
@ -16,6 +17,7 @@ module.exports = async (req, res) => {
countryCodes,
themes,
codeThemes,
fontList,
});
};

@ -85,12 +85,11 @@ const config = require(__dirname+'/lib/misc/config.js')
app.locals.commit = commit;
app.locals.version = version;
app.locals.meta = meta;
app.locals.captchaType = captchaOptions.type;
app.locals.postFilesSize = formatSize(globalLimits.postFilesSize.max);
app.locals.googleRecaptchaSiteKey = google.siteKey;
app.locals.hcaptchaSiteKey = hcaptcha.siteKey;
app.locals.captchaGridSize = captchaOptions.grid.size;
app.locals.globalAnnouncement = globalAnnouncement;
app.locals.captchaOptions = captchaOptions;
};
loadAppLocals();
redis.addCallback('config', loadAppLocals);

@ -21,7 +21,6 @@ block content
li: a(href='#contact') How can I contact the administration?
b Making posts
ul.mv-0
li: a(href='#captcha') How do I solve the 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?
@ -42,14 +41,6 @@ block content
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 the CAPTCHA?
tr
td.post-message
| See the #[a(rel='nofollow' referrerpolicy='same-origin' target='_blank' href='http://fatchan.gitgud.site/jschan-docs/#captcha-block-bypass') API docs] for example captchas and solutions.
.table-container.flex-center.mv-5
.anchor#name-formatting
table

@ -10,7 +10,7 @@ form.form-post(action=`/forms/appeal`, enctype='application/x-www-form-urlencode
.row
.label Message
textarea(rows='5' name='message' required)
if captchaType === 'text'
if captchaOptions.type === 'text'
include ./captchasidelabel.pug
else
include ./captchafieldrow.pug

@ -1,4 +1,4 @@
case captchaType
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.
@ -11,14 +11,14 @@ case captchaType
.jsonly.captcha(style='display:none;')
input.captchafield(type='text' name='captcha' autocomplete='off' placeholder='Captcha text' pattern='.{6}' required title='6 characters')
when 'grid'
unless minimal
a.text-center(href='/faq.html#captcha') Instructions
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 < captchaGridSize**2; i++) {
- for(let i = 0; i < captchaOptions.grid.size**2; i++) {
label.captchachecklabel
input(type='checkbox' name='captcha' value=i)
span.captchacheckbox

@ -18,7 +18,7 @@ meta(property='og:site_name', value=meta.siteName)
meta(property='og:url', content=meta.url)
//- main stylesheet
link(rel='stylesheet' href=`/css/style.css?v=${commit}&ct=${captchaType}`)
link(rel='stylesheet' href=`/css/style.css?v=${commit}&ct=${captchaOptions.type}`)
//- theme stylesheets
- const theme = isBoard ? board.settings.theme : defaultTheme;

@ -79,7 +79,7 @@ section.form-wrapper.flex-center
option(value=flag[0] data-src=`/flag/${board._id}/${flag[1]}`) #{flag[0]}
img.jsonly#selected-flag
if ((board.settings.captchaMode === 1 && !isThread) || board.settings.captchaMode === 2) && !modview
if captchaType === 'text'
if captchaOptions.type === 'text'
include ./captchasidelabel.pug
else
include ./captchaexpand.pug

@ -19,7 +19,7 @@ block content
.row
.label Confirm New Password
input(type='password', name='newpasswordconfirm', maxlength='100' required)
if captchaType === 'text'
if captchaOptions.type === 'text'
include ../includes/captchasidelabel.pug
else
include ../includes/captchafieldrow.pug

@ -19,7 +19,7 @@ block content
.row
.label Tags
textarea(name='tags' placeholder='Newline separated, max 10')
if captchaType === 'text'
if captchaOptions.type === 'text'
include ../includes/captchasidelabel.pug
else
include ../includes/captchafieldrow.pug

@ -26,6 +26,7 @@ block content
.form-wrapper.flexleft.mt-10
form.form-post(action=`/forms/global/settings`, enctype='application/x-www-form-urlencoded', method='POST')
input(type='hidden' name='_csrf' value=csrf)
input.row(type='submit', value='save settings')
.row.wrap.sb
.col.mr-5
@ -178,22 +179,67 @@ block content
.label Type
select(name='captcha_options_type')
option(value='text', selected=settings.captchaOptions.type === 'text') Text
option(value='grid', selected=settings.captchaOptions.type === 'grid') Grid
option(value='grid', selected=settings.captchaOptions.type === 'grid') Grid v1
option(value='grid2', selected=settings.captchaOptions.type === 'grid2') Grid v2
option(value='google', selected=settings.captchaOptions.type === 'google') Google
option(value='hcaptcha', selected=settings.captchaOptions.type === 'hcaptcha') Hcaptcha
.row
.label Grid Image Size
.label Generate Limit
input(type='number' name='captcha_options_generate_limit' value=settings.captchaOptions.generateLimit)
.row
h4.mv-5 Grid Captcha Options
.row
.label Image Size
input(type='number' name='captcha_options_grid_image_size' value=settings.captchaOptions.grid.imageSize)
.row
.label Grid Size
input(type='number' name='captcha_options_grid_size' value=settings.captchaOptions.grid.size)
.row
.label Grid Icon Offset
.label Icon Offset
input(type='number' name='captcha_options_grid_icon_y_offset' value=settings.captchaOptions.grid.iconYOffset)
.row
.label Generate Limit
input(type='number' name='captcha_options_generate_limit' value=settings.captchaOptions.generateLimit)
.label True Characters
textarea(name='captcha_options_grid_trues' placeholder='Newline separated') #{settings.captchaOptions.grid.trues.join('\n')}
.row
.label False Characters
textarea(name='captcha_options_grid_falses' placeholder='Newline separated') #{settings.captchaOptions.grid.falses.join('\n')}
.row
.label Question Text
input(type='text' name='captcha_options_grid_question' value=settings.captchaOptions.grid.question)
.row
.label Edge Effect Strength
label.postform-style.ph-5
input(type='range' name='captcha_options_grid_edge' min='0' max='50' value=settings.captchaOptions.grid.edge)
.row
.label Noise Effect Strength
label.postform-style.ph-5
input(type='range' name='captcha_options_grid_noise' min='0' max='10' value=settings.captchaOptions.grid.noise)
.row
h4.mv-5 Text Captcha Options
.row
.label Font
select(name='captcha_options_text_font')
option(value='default' selected=(settings.captchaOptions.text.font === 'default')) Default
each font in fontList
option(value=font.path selected=(settings.captchaOptions.text.font === font.path)) #{font.name}
.row
.label Strikethrough Effect
label.postform-style.ph-5
input(type='checkbox', name='captcha_options_text_line', value='true' checked=settings.captchaOptions.text.line)
.row
.label Wave Effect Strength
label.postform-style.ph-5
input(type='range' name='captcha_options_text_wave' min='0' max='10' value=settings.captchaOptions.text.wave)
.row
.label Paint Effect Strength
label.postform-style.ph-5
input(type='range' name='captcha_options_text_paint' min='0' max='10' value=settings.captchaOptions.text.paint)
.row
.label Noise Effect Strength
label.postform-style.ph-5
input(type='range' name='captcha_options_text_noise' min='0' max='10' value=settings.captchaOptions.text.noise)
.row
h4.mv-5 Captcha Distortion
.row
.label Minimum Distortions
input(type='number' name='captcha_options_num_distorts_min' value=settings.captchaOptions.numDistorts.min)

@ -16,7 +16,7 @@ block content
.row
.label Confirm Password
input(type='password', name='passwordconfirm', maxlength='100' required)
if captchaType === 'text'
if captchaOptions.type === 'text'
include ../includes/captchasidelabel.pug
else
include ../includes/captchafieldrow.pug

Loading…
Cancel
Save