Break captcha package into smaller modules

This commit is contained in:
Alex Gleason 2025-02-26 14:53:13 -06:00
parent a2732642a5
commit 5f617b2d1a
No known key found for this signature in database
GPG key ID: 7211D1F99744FBB7
6 changed files with 86 additions and 84 deletions

View file

@ -0,0 +1,36 @@
import { type Image, loadImage } from '@gfx/canvas-wasm';
export interface CaptchaImages {
bgImages: Image[];
puzzleMask: Image;
puzzleHole: Image;
}
export async function getCaptchaImages(): Promise<CaptchaImages> {
const bgImages = await getBackgroundImages();
const puzzleMask = await loadImage(
await Deno.readFile(new URL('./assets/puzzle/puzzle-mask.png', import.meta.url)),
);
const puzzleHole = await loadImage(
await Deno.readFile(new URL('./assets/puzzle/puzzle-hole.png', import.meta.url)),
);
return { bgImages, puzzleMask, puzzleHole };
}
async function getBackgroundImages(): Promise<Image[]> {
const path = new URL('./assets/bg/', import.meta.url);
const images: Image[] = [];
for await (const dirEntry of Deno.readDir(path)) {
if (dirEntry.isFile && dirEntry.name.endsWith('.jpg')) {
const file = await Deno.readFile(new URL(dirEntry.name, path));
const image = await loadImage(file);
images.push(image);
}
}
return images;
}

View file

@ -0,0 +1,21 @@
import type { CanvasRenderingContext2D } from '@gfx/canvas-wasm';
/**
* Add a small amount of noise to the image.
* This protects against an attacker pregenerating every possible solution and then doing a reverse-lookup.
*/
export function addNoise(ctx: CanvasRenderingContext2D, width: number, height: number): void {
const imageData = ctx.getImageData(0, 0, width, height);
// Loop over every pixel.
for (let i = 0; i < imageData.data.length; i += 4) {
// Add/subtract a small amount from each color channel.
// We skip i+3 because that's the alpha channel, which we don't want to modify.
for (let j = 0; j < 3; j++) {
const alteration = Math.floor(Math.random() * 11) - 5; // Vary between -5 and +5
imageData.data[i + j] = Math.min(Math.max(imageData.data[i + j] + alteration, 0), 255);
}
}
ctx.putImageData(imageData, 0, 0);
}

View file

@ -1,57 +1,9 @@
import { import { createCanvas, type EmulatedCanvas2D } from '@gfx/canvas-wasm';
type CanvasRenderingContext2D,
createCanvas,
type EmulatedCanvas2D,
type Image,
loadImage,
} from '@gfx/canvas-wasm';
export interface CaptchaImages { import { addNoise } from './canvas.ts';
bgImages: Image[]; import { areIntersecting, type Dimensions, type Point } from './geometry.ts';
puzzleMask: Image;
puzzleHole: Image;
}
interface Point { import type { CaptchaImages } from './assets.ts';
x: number;
y: number;
}
interface Dimensions {
w: number;
h: number;
}
type Rectangle = Point & Dimensions;
export async function getCaptchaImages(): Promise<CaptchaImages> {
const bgImages = await getBackgroundImages();
const puzzleMask = await loadImage(
await Deno.readFile(new URL('./assets/puzzle/puzzle-mask.png', import.meta.url)),
);
const puzzleHole = await loadImage(
await Deno.readFile(new URL('./assets/puzzle/puzzle-hole.png', import.meta.url)),
);
return { bgImages, puzzleMask, puzzleHole };
}
async function getBackgroundImages(): Promise<Image[]> {
const path = new URL('./assets/bg/', import.meta.url);
const images: Image[] = [];
for await (const dirEntry of Deno.readDir(path)) {
if (dirEntry.isFile && dirEntry.name.endsWith('.jpg')) {
const file = await Deno.readFile(new URL(dirEntry.name, path));
const image = await loadImage(file);
images.push(image);
}
}
return images;
}
/** Generate a puzzle captcha, returning canvases for the board and piece. */ /** Generate a puzzle captcha, returning canvases for the board and piece. */
export function generateCaptcha( export function generateCaptcha(
@ -92,26 +44,6 @@ export function generateCaptcha(
}; };
} }
/**
* Add a small amount of noise to the image.
* This protects against an attacker pregenerating every possible solution and then doing a reverse-lookup.
*/
function addNoise(ctx: CanvasRenderingContext2D, width: number, height: number): void {
const imageData = ctx.getImageData(0, 0, width, height);
// Loop over every pixel.
for (let i = 0; i < imageData.data.length; i += 4) {
// Add/subtract a small amount from each color channel.
// We skip i+3 because that's the alpha channel, which we don't want to modify.
for (let j = 0; j < 3; j++) {
const alteration = Math.floor(Math.random() * 11) - 5; // Vary between -5 and +5
imageData.data[i + j] = Math.min(Math.max(imageData.data[i + j] + alteration, 0), 255);
}
}
ctx.putImageData(imageData, 0, 0);
}
export function verifyCaptchaSolution(puzzleSize: Dimensions, point: Point, solution: Point): boolean { export function verifyCaptchaSolution(puzzleSize: Dimensions, point: Point, solution: Point): boolean {
return areIntersecting( return areIntersecting(
{ ...point, ...puzzleSize }, { ...point, ...puzzleSize },
@ -119,17 +51,6 @@ export function verifyCaptchaSolution(puzzleSize: Dimensions, point: Point, solu
); );
} }
function areIntersecting(rect1: Rectangle, rect2: Rectangle, threshold = 0.5): boolean {
const r1cx = rect1.x + rect1.w / 2;
const r2cx = rect2.x + rect2.w / 2;
const r1cy = rect1.y + rect1.h / 2;
const r2cy = rect2.y + rect2.h / 2;
const dist = Math.sqrt((r2cx - r1cx) ** 2 + (r2cy - r1cy) ** 2);
const e1 = Math.sqrt(rect1.h ** 2 + rect1.w ** 2) / 2;
const e2 = Math.sqrt(rect2.h ** 2 + rect2.w ** 2) / 2;
return dist < (e1 + e2) * threshold;
}
/** Random coordinates such that the piece fits within the canvas. */ /** Random coordinates such that the piece fits within the canvas. */
function generateSolution(bgSize: Dimensions, puzzleSize: Dimensions): Point { function generateSolution(bgSize: Dimensions, puzzleSize: Dimensions): Point {
return { return {

View file

@ -2,6 +2,6 @@
"name": "@ditto/captcha", "name": "@ditto/captcha",
"version": "1.0.0", "version": "1.0.0",
"exports": { "exports": {
".": "./captcha.ts" ".": "./mod.ts"
} }
} }

View file

@ -0,0 +1,22 @@
export interface Point {
x: number;
y: number;
}
export interface Dimensions {
w: number;
h: number;
}
export type Rectangle = Point & Dimensions;
export function areIntersecting(rect1: Rectangle, rect2: Rectangle, threshold = 0.5): boolean {
const r1cx = rect1.x + rect1.w / 2;
const r2cx = rect2.x + rect2.w / 2;
const r1cy = rect1.y + rect1.h / 2;
const r2cy = rect2.y + rect2.h / 2;
const dist = Math.sqrt((r2cx - r1cx) ** 2 + (r2cy - r1cy) ** 2);
const e1 = Math.sqrt(rect1.h ** 2 + rect1.w ** 2) / 2;
const e2 = Math.sqrt(rect2.h ** 2 + rect2.w ** 2) / 2;
return dist < (e1 + e2) * threshold;
}

2
packages/captcha/mod.ts Normal file
View file

@ -0,0 +1,2 @@
export { getCaptchaImages } from './assets.ts';
export { generateCaptcha, verifyCaptchaSolution } from './captcha.ts';