304 lines
9.9 KiB
JavaScript
304 lines
9.9 KiB
JavaScript
import { ParameterType } from 'jspsych';
|
|
|
|
const info = {
|
|
name: 'captcha',
|
|
parameters: {
|
|
prompt: {
|
|
type: ParameterType.HTML_STRING,
|
|
default: '',
|
|
},
|
|
difficulty: {
|
|
type: ParameterType.INT,
|
|
default: 3,
|
|
},
|
|
button_label: {
|
|
type: ParameterType.STRING,
|
|
default: 'Submit',
|
|
},
|
|
allow_refresh: {
|
|
type: ParameterType.BOOL,
|
|
default: true,
|
|
},
|
|
require_correct: {
|
|
type: ParameterType.BOOL,
|
|
default: true,
|
|
},
|
|
error_text: {
|
|
type: ParameterType.HTML_STRING,
|
|
default: 'That entry did not match, please try again.',
|
|
},
|
|
},
|
|
};
|
|
|
|
class CaptchaPlugin {
|
|
constructor(jsPsych) {
|
|
this.jsPsych = jsPsych;
|
|
this.characters = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
|
}
|
|
|
|
trial(display_element, trial) {
|
|
const start_time = performance.now();
|
|
const attempt_counts = { submission_count: 0, refresh_count: 0 };
|
|
const difficulty_settings = this.resolve_difficulty(trial.difficulty);
|
|
let captcha_text = this.generate_text(difficulty_settings.length);
|
|
let last_target_at_submission = captcha_text;
|
|
|
|
display_element.innerHTML = this.build_markup(
|
|
trial.prompt,
|
|
trial.button_label,
|
|
trial.allow_refresh
|
|
);
|
|
|
|
const canvas_element = display_element.querySelector(
|
|
'.jspsych_captcha_canvas'
|
|
);
|
|
const canvas_context = canvas_element.getContext('2d');
|
|
const input_element = display_element.querySelector(
|
|
'.jspsych_captcha_input'
|
|
);
|
|
const button_element = display_element.querySelector(
|
|
'.jspsych_captcha_submit'
|
|
);
|
|
const refresh_button = display_element.querySelector(
|
|
'.jspsych_captcha_refresh'
|
|
);
|
|
const error_element = display_element.querySelector(
|
|
'.jspsych_captcha_error'
|
|
);
|
|
|
|
const draw_captcha = () => {
|
|
this.draw_captcha(canvas_context, captcha_text, difficulty_settings);
|
|
};
|
|
|
|
draw_captcha();
|
|
input_element.focus();
|
|
|
|
if (refresh_button) {
|
|
refresh_button.addEventListener('click', () => {
|
|
attempt_counts.refresh_count += 1;
|
|
captcha_text = this.generate_text(difficulty_settings.length);
|
|
draw_captcha();
|
|
error_element.textContent = '';
|
|
input_element.value = '';
|
|
input_element.focus();
|
|
});
|
|
}
|
|
|
|
const length_hint_element = display_element.querySelector(
|
|
'.jspsych_captcha_length_hint'
|
|
);
|
|
|
|
const update_button_state = () => {
|
|
const current_length = input_element.value.trim().length;
|
|
const required_length = captcha_text.length;
|
|
button_element.disabled = current_length < required_length;
|
|
if (length_hint_element) {
|
|
length_hint_element.textContent =
|
|
current_length < required_length
|
|
? `Type ${required_length - current_length} more character(s) to continue.`
|
|
: '';
|
|
}
|
|
};
|
|
|
|
const finish_trial = (response_value, is_correct) => {
|
|
const trial_data = {
|
|
rt: performance.now() - start_time,
|
|
response: response_value,
|
|
participant_entry: response_value,
|
|
participant_entry_length: response_value.length,
|
|
captcha_target: last_target_at_submission,
|
|
correct: is_correct,
|
|
submission_count: attempt_counts.submission_count,
|
|
manual_refreshes: attempt_counts.refresh_count,
|
|
difficulty_label: difficulty_settings.label,
|
|
difficulty_length: difficulty_settings.length,
|
|
difficulty_level: difficulty_settings.level,
|
|
};
|
|
display_element.innerHTML = '';
|
|
this.jsPsych.finishTrial(trial_data);
|
|
};
|
|
|
|
const validate_response = () => {
|
|
attempt_counts.submission_count += 1;
|
|
const response_value = input_element.value.trim().toUpperCase();
|
|
last_target_at_submission = captcha_text;
|
|
const is_correct = response_value === captcha_text;
|
|
|
|
if (response_value.length < captcha_text.length) {
|
|
error_element.textContent = `Please enter all ${captcha_text.length} characters shown in the captcha.`;
|
|
update_button_state();
|
|
return;
|
|
}
|
|
|
|
if (is_correct || !trial.require_correct) {
|
|
finish_trial(response_value, is_correct);
|
|
return;
|
|
}
|
|
|
|
error_element.textContent = trial.error_text;
|
|
captcha_text = this.generate_text(difficulty_settings.length);
|
|
draw_captcha();
|
|
input_element.value = '';
|
|
input_element.focus();
|
|
};
|
|
|
|
button_element.addEventListener('click', validate_response);
|
|
input_element.addEventListener('keydown', event => {
|
|
if (event.key === 'Enter') {
|
|
event.preventDefault();
|
|
validate_response();
|
|
}
|
|
});
|
|
input_element.addEventListener('input', update_button_state);
|
|
update_button_state();
|
|
}
|
|
|
|
build_markup(prompt, button_label, allow_refresh) {
|
|
const prompt_html = prompt
|
|
? `<div class="jspsych_captcha_prompt">${prompt}</div>`
|
|
: '';
|
|
const refresh_button = allow_refresh
|
|
? '<button type="button" class="jspsych_captcha_refresh" aria-label="Get a new captcha">↻</button>'
|
|
: '';
|
|
|
|
return `
|
|
<div class="jspsych_captcha">
|
|
${prompt_html}
|
|
<div class="jspsych_captcha_canvas_wrapper">
|
|
<canvas class="jspsych_captcha_canvas" width="260" height="110" role="img" aria-label="Captcha"></canvas>
|
|
${refresh_button}
|
|
</div>
|
|
<label class="jspsych_captcha_label" aria-label="Enter the characters you see into the textbox below.">
|
|
<input type="text" autocomplete="off" autocorrect="off" autocapitalize="characters" spellcheck="false" class="jspsych_captcha_input" />
|
|
<span class="jspsych_captcha_length_hint"></span>
|
|
</label>
|
|
<button type="button" class="jspsych-btn jspsych_captcha_submit">${button_label}</button>
|
|
<div class="jspsych_captcha_error" role="status" aria-live="polite"></div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
draw_captcha(canvas_context, captcha_text, settings) {
|
|
const { width, height } = canvas_context.canvas;
|
|
canvas_context.clearRect(0, 0, width, height);
|
|
const gradient = canvas_context.createLinearGradient(0, 0, width, height);
|
|
gradient.addColorStop(0, '#f8fafc');
|
|
gradient.addColorStop(1, '#e2e8f0');
|
|
canvas_context.fillStyle = gradient;
|
|
canvas_context.fillRect(0, 0, width, height);
|
|
|
|
for (let index = 0; index < settings.noise_lines; index++) {
|
|
canvas_context.beginPath();
|
|
canvas_context.moveTo(Math.random() * width, Math.random() * height);
|
|
canvas_context.bezierCurveTo(
|
|
Math.random() * width,
|
|
Math.random() * height,
|
|
Math.random() * width,
|
|
Math.random() * height,
|
|
Math.random() * width,
|
|
Math.random() * height
|
|
);
|
|
canvas_context.lineWidth = 1 + Math.random() * 2;
|
|
canvas_context.strokeStyle = `rgba(30, 41, 59, ${0.15 +
|
|
Math.random() * 0.2})`;
|
|
canvas_context.stroke();
|
|
}
|
|
|
|
canvas_context.font = `bold ${settings.font_size}px 'Courier New', monospace`;
|
|
canvas_context.textBaseline = 'middle';
|
|
canvas_context.textAlign = 'center';
|
|
|
|
const horizontal_step = width / (captcha_text.length + 1);
|
|
const center_y = height / 2;
|
|
|
|
for (let index = 0; index < captcha_text.length; index++) {
|
|
const current_character = captcha_text[index];
|
|
canvas_context.save();
|
|
const jitter_y = this.random_between(-settings.jitter, settings.jitter);
|
|
canvas_context.translate(
|
|
horizontal_step * (index + 1),
|
|
center_y + jitter_y
|
|
);
|
|
canvas_context.rotate(
|
|
this.random_between(-settings.rotation, settings.rotation)
|
|
);
|
|
canvas_context.fillStyle = '#000000';
|
|
canvas_context.fillText(current_character, 0, 0);
|
|
canvas_context.restore();
|
|
}
|
|
|
|
const noise_pixels = Math.floor(width * height * settings.speckle_density);
|
|
const image_data = canvas_context.getImageData(0, 0, width, height);
|
|
for (let index = 0; index < noise_pixels; index++) {
|
|
const pixel_offset =
|
|
(Math.floor(Math.random() * width) +
|
|
Math.floor(Math.random() * height) * width) *
|
|
4;
|
|
const shade = Math.random() > 0.5 ? 255 : 0;
|
|
image_data.data[pixel_offset] = shade;
|
|
image_data.data[pixel_offset + 1] = shade;
|
|
image_data.data[pixel_offset + 2] = shade;
|
|
image_data.data[pixel_offset + 3] = 255;
|
|
}
|
|
canvas_context.putImageData(image_data, 0, 0);
|
|
}
|
|
|
|
generate_text(length) {
|
|
let generated_text = '';
|
|
for (let index = 0; index < length; index++) {
|
|
generated_text += this.characters.charAt(
|
|
Math.floor(Math.random() * this.characters.length)
|
|
);
|
|
}
|
|
return generated_text;
|
|
}
|
|
|
|
resolve_difficulty(difficulty_input) {
|
|
const default_level = 3;
|
|
let normalized_level = Number(difficulty_input);
|
|
if (!Number.isFinite(normalized_level)) {
|
|
normalized_level = default_level;
|
|
}
|
|
normalized_level = Math.round(Math.min(6, Math.max(1, normalized_level)));
|
|
|
|
const base_length = 7;
|
|
const base_font_size = 20;
|
|
const base_rotation = 0.25;
|
|
const max_rotation = base_rotation * 3;
|
|
const base_noise_lines = 4;
|
|
const base_speckle_density = 0.01;
|
|
const base_jitter = 25;
|
|
const noise_line_step = 0.8;
|
|
const speckle_step = 0.0015;
|
|
const jitter_step = 1.6;
|
|
const rotation_step = (max_rotation - base_rotation) / 5;
|
|
|
|
const rotation = base_rotation + rotation_step * (normalized_level - 1);
|
|
const noise_lines = Math.round(
|
|
base_noise_lines + noise_line_step * (normalized_level - 1)
|
|
);
|
|
const speckle_density =
|
|
base_speckle_density + speckle_step * (normalized_level - 1);
|
|
const jitter = base_jitter + jitter_step * (normalized_level - 1);
|
|
|
|
return {
|
|
length: base_length,
|
|
noise_lines: noise_lines,
|
|
rotation: rotation,
|
|
jitter: jitter,
|
|
font_size: base_font_size,
|
|
speckle_density: speckle_density,
|
|
label: `level_${normalized_level}`,
|
|
level: normalized_level,
|
|
};
|
|
}
|
|
|
|
random_between(min, max) {
|
|
return Math.random() * (max - min) + min;
|
|
}
|
|
}
|
|
|
|
CaptchaPlugin.info = info;
|
|
|
|
export default CaptchaPlugin;
|