diff --git a/packages/captcha/assets.ts b/packages/captcha/assets.ts new file mode 100644 index 00000000..8bb419b8 --- /dev/null +++ b/packages/captcha/assets.ts @@ -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 { + 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 { + 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; +} diff --git a/packages/captcha/canvas.ts b/packages/captcha/canvas.ts new file mode 100644 index 00000000..b7420189 --- /dev/null +++ b/packages/captcha/canvas.ts @@ -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); +} diff --git a/packages/captcha/captcha.ts b/packages/captcha/captcha.ts index f71005c1..27bf6c91 100644 --- a/packages/captcha/captcha.ts +++ b/packages/captcha/captcha.ts @@ -1,57 +1,9 @@ -import { - type CanvasRenderingContext2D, - createCanvas, - type EmulatedCanvas2D, - type Image, - loadImage, -} from '@gfx/canvas-wasm'; +import { createCanvas, type EmulatedCanvas2D } from '@gfx/canvas-wasm'; -export interface CaptchaImages { - bgImages: Image[]; - puzzleMask: Image; - puzzleHole: Image; -} +import { addNoise } from './canvas.ts'; +import { areIntersecting, type Dimensions, type Point } from './geometry.ts'; -interface Point { - x: number; - y: number; -} - -interface Dimensions { - w: number; - h: number; -} - -type Rectangle = Point & Dimensions; - -export async function getCaptchaImages(): Promise { - 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 { - 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; -} +import type { CaptchaImages } from './assets.ts'; /** Generate a puzzle captcha, returning canvases for the board and piece. */ 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 { return areIntersecting( { ...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. */ function generateSolution(bgSize: Dimensions, puzzleSize: Dimensions): Point { return { diff --git a/packages/captcha/deno.json b/packages/captcha/deno.json index e10a6b36..ce71ebf8 100644 --- a/packages/captcha/deno.json +++ b/packages/captcha/deno.json @@ -2,6 +2,6 @@ "name": "@ditto/captcha", "version": "1.0.0", "exports": { - ".": "./captcha.ts" + ".": "./mod.ts" } } diff --git a/packages/captcha/geometry.ts b/packages/captcha/geometry.ts new file mode 100644 index 00000000..b1993e0b --- /dev/null +++ b/packages/captcha/geometry.ts @@ -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; +} diff --git a/packages/captcha/mod.ts b/packages/captcha/mod.ts new file mode 100644 index 00000000..352d6b5c --- /dev/null +++ b/packages/captcha/mod.ts @@ -0,0 +1,2 @@ +export { getCaptchaImages } from './assets.ts'; +export { generateCaptcha, verifyCaptchaSolution } from './captcha.ts';