From a2732642a53fb419a513dca013e9d33a7d153428 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 26 Feb 2025 14:46:47 -0600 Subject: [PATCH] Break @ditto/captcha into a separate library --- deno.json | 1 + ... Body of Water Surrounded By Mountains.jpg | Bin .../bg/A Trail of Footprints In The Sand.jpg | Bin .../assets}/bg/Ashim DSilva.jpg | Bin .../assets}/bg/Canazei Granite Ridges.jpg | Bin .../assets}/bg/Martin Adams.jpg | Bin .../assets}/bg/Morskie Oko.jpg | Bin .../captcha => captcha/assets}/bg/Mr. Lee.jpg | Bin .../assets}/bg/Nattu Adnan.jpg | Bin .../assets}/bg/Photo by SpaceX.jpg | Bin .../assets}/bg/Photo of Valley.jpg | Bin .../assets}/bg/Snow-Capped Mountain.jpg | Bin .../assets}/bg/Sunset by the Pier.jpg | Bin .../assets}/bg/Tj Holowaychuk.jpg | Bin .../assets}/bg/Viktor Forgacs.jpg | Bin .../assets}/bg/copyright.txt | 0 .../assets/puzzle}/puzzle-hole.png | Bin .../assets/puzzle}/puzzle-hole.svg | 0 .../assets/puzzle}/puzzle-mask.png | Bin .../assets/puzzle}/puzzle-mask.svg | 0 packages/captcha/captcha.ts | 139 ++++++++++++++++++ packages/captcha/deno.json | 7 + packages/ditto/controllers/api/captcha.ts | 129 +--------------- 23 files changed, 150 insertions(+), 126 deletions(-) rename packages/{ditto/assets/captcha => captcha/assets}/bg/A Large Body of Water Surrounded By Mountains.jpg (100%) rename packages/{ditto/assets/captcha => captcha/assets}/bg/A Trail of Footprints In The Sand.jpg (100%) rename packages/{ditto/assets/captcha => captcha/assets}/bg/Ashim DSilva.jpg (100%) rename packages/{ditto/assets/captcha => captcha/assets}/bg/Canazei Granite Ridges.jpg (100%) rename packages/{ditto/assets/captcha => captcha/assets}/bg/Martin Adams.jpg (100%) rename packages/{ditto/assets/captcha => captcha/assets}/bg/Morskie Oko.jpg (100%) rename packages/{ditto/assets/captcha => captcha/assets}/bg/Mr. Lee.jpg (100%) rename packages/{ditto/assets/captcha => captcha/assets}/bg/Nattu Adnan.jpg (100%) rename packages/{ditto/assets/captcha => captcha/assets}/bg/Photo by SpaceX.jpg (100%) rename packages/{ditto/assets/captcha => captcha/assets}/bg/Photo of Valley.jpg (100%) rename packages/{ditto/assets/captcha => captcha/assets}/bg/Snow-Capped Mountain.jpg (100%) rename packages/{ditto/assets/captcha => captcha/assets}/bg/Sunset by the Pier.jpg (100%) rename packages/{ditto/assets/captcha => captcha/assets}/bg/Tj Holowaychuk.jpg (100%) rename packages/{ditto/assets/captcha => captcha/assets}/bg/Viktor Forgacs.jpg (100%) rename packages/{ditto/assets/captcha => captcha/assets}/bg/copyright.txt (100%) rename packages/{ditto/assets/captcha => captcha/assets/puzzle}/puzzle-hole.png (100%) rename packages/{ditto/assets/captcha => captcha/assets/puzzle}/puzzle-hole.svg (100%) rename packages/{ditto/assets/captcha => captcha/assets/puzzle}/puzzle-mask.png (100%) rename packages/{ditto/assets/captcha => captcha/assets/puzzle}/puzzle-mask.svg (100%) create mode 100644 packages/captcha/captcha.ts create mode 100644 packages/captcha/deno.json diff --git a/deno.json b/deno.json index 75f94cdd..c8a226af 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,7 @@ { "version": "1.1.0", "workspace": [ + "./packages/captcha", "./packages/conf", "./packages/db", "./packages/ditto", diff --git a/packages/ditto/assets/captcha/bg/A Large Body of Water Surrounded By Mountains.jpg b/packages/captcha/assets/bg/A Large Body of Water Surrounded By Mountains.jpg similarity index 100% rename from packages/ditto/assets/captcha/bg/A Large Body of Water Surrounded By Mountains.jpg rename to packages/captcha/assets/bg/A Large Body of Water Surrounded By Mountains.jpg diff --git a/packages/ditto/assets/captcha/bg/A Trail of Footprints In The Sand.jpg b/packages/captcha/assets/bg/A Trail of Footprints In The Sand.jpg similarity index 100% rename from packages/ditto/assets/captcha/bg/A Trail of Footprints In The Sand.jpg rename to packages/captcha/assets/bg/A Trail of Footprints In The Sand.jpg diff --git a/packages/ditto/assets/captcha/bg/Ashim DSilva.jpg b/packages/captcha/assets/bg/Ashim DSilva.jpg similarity index 100% rename from packages/ditto/assets/captcha/bg/Ashim DSilva.jpg rename to packages/captcha/assets/bg/Ashim DSilva.jpg diff --git a/packages/ditto/assets/captcha/bg/Canazei Granite Ridges.jpg b/packages/captcha/assets/bg/Canazei Granite Ridges.jpg similarity index 100% rename from packages/ditto/assets/captcha/bg/Canazei Granite Ridges.jpg rename to packages/captcha/assets/bg/Canazei Granite Ridges.jpg diff --git a/packages/ditto/assets/captcha/bg/Martin Adams.jpg b/packages/captcha/assets/bg/Martin Adams.jpg similarity index 100% rename from packages/ditto/assets/captcha/bg/Martin Adams.jpg rename to packages/captcha/assets/bg/Martin Adams.jpg diff --git a/packages/ditto/assets/captcha/bg/Morskie Oko.jpg b/packages/captcha/assets/bg/Morskie Oko.jpg similarity index 100% rename from packages/ditto/assets/captcha/bg/Morskie Oko.jpg rename to packages/captcha/assets/bg/Morskie Oko.jpg diff --git a/packages/ditto/assets/captcha/bg/Mr. Lee.jpg b/packages/captcha/assets/bg/Mr. Lee.jpg similarity index 100% rename from packages/ditto/assets/captcha/bg/Mr. Lee.jpg rename to packages/captcha/assets/bg/Mr. Lee.jpg diff --git a/packages/ditto/assets/captcha/bg/Nattu Adnan.jpg b/packages/captcha/assets/bg/Nattu Adnan.jpg similarity index 100% rename from packages/ditto/assets/captcha/bg/Nattu Adnan.jpg rename to packages/captcha/assets/bg/Nattu Adnan.jpg diff --git a/packages/ditto/assets/captcha/bg/Photo by SpaceX.jpg b/packages/captcha/assets/bg/Photo by SpaceX.jpg similarity index 100% rename from packages/ditto/assets/captcha/bg/Photo by SpaceX.jpg rename to packages/captcha/assets/bg/Photo by SpaceX.jpg diff --git a/packages/ditto/assets/captcha/bg/Photo of Valley.jpg b/packages/captcha/assets/bg/Photo of Valley.jpg similarity index 100% rename from packages/ditto/assets/captcha/bg/Photo of Valley.jpg rename to packages/captcha/assets/bg/Photo of Valley.jpg diff --git a/packages/ditto/assets/captcha/bg/Snow-Capped Mountain.jpg b/packages/captcha/assets/bg/Snow-Capped Mountain.jpg similarity index 100% rename from packages/ditto/assets/captcha/bg/Snow-Capped Mountain.jpg rename to packages/captcha/assets/bg/Snow-Capped Mountain.jpg diff --git a/packages/ditto/assets/captcha/bg/Sunset by the Pier.jpg b/packages/captcha/assets/bg/Sunset by the Pier.jpg similarity index 100% rename from packages/ditto/assets/captcha/bg/Sunset by the Pier.jpg rename to packages/captcha/assets/bg/Sunset by the Pier.jpg diff --git a/packages/ditto/assets/captcha/bg/Tj Holowaychuk.jpg b/packages/captcha/assets/bg/Tj Holowaychuk.jpg similarity index 100% rename from packages/ditto/assets/captcha/bg/Tj Holowaychuk.jpg rename to packages/captcha/assets/bg/Tj Holowaychuk.jpg diff --git a/packages/ditto/assets/captcha/bg/Viktor Forgacs.jpg b/packages/captcha/assets/bg/Viktor Forgacs.jpg similarity index 100% rename from packages/ditto/assets/captcha/bg/Viktor Forgacs.jpg rename to packages/captcha/assets/bg/Viktor Forgacs.jpg diff --git a/packages/ditto/assets/captcha/bg/copyright.txt b/packages/captcha/assets/bg/copyright.txt similarity index 100% rename from packages/ditto/assets/captcha/bg/copyright.txt rename to packages/captcha/assets/bg/copyright.txt diff --git a/packages/ditto/assets/captcha/puzzle-hole.png b/packages/captcha/assets/puzzle/puzzle-hole.png similarity index 100% rename from packages/ditto/assets/captcha/puzzle-hole.png rename to packages/captcha/assets/puzzle/puzzle-hole.png diff --git a/packages/ditto/assets/captcha/puzzle-hole.svg b/packages/captcha/assets/puzzle/puzzle-hole.svg similarity index 100% rename from packages/ditto/assets/captcha/puzzle-hole.svg rename to packages/captcha/assets/puzzle/puzzle-hole.svg diff --git a/packages/ditto/assets/captcha/puzzle-mask.png b/packages/captcha/assets/puzzle/puzzle-mask.png similarity index 100% rename from packages/ditto/assets/captcha/puzzle-mask.png rename to packages/captcha/assets/puzzle/puzzle-mask.png diff --git a/packages/ditto/assets/captcha/puzzle-mask.svg b/packages/captcha/assets/puzzle/puzzle-mask.svg similarity index 100% rename from packages/ditto/assets/captcha/puzzle-mask.svg rename to packages/captcha/assets/puzzle/puzzle-mask.svg diff --git a/packages/captcha/captcha.ts b/packages/captcha/captcha.ts new file mode 100644 index 00000000..f71005c1 --- /dev/null +++ b/packages/captcha/captcha.ts @@ -0,0 +1,139 @@ +import { + type CanvasRenderingContext2D, + createCanvas, + type EmulatedCanvas2D, + type Image, + loadImage, +} from '@gfx/canvas-wasm'; + +export interface CaptchaImages { + bgImages: Image[]; + puzzleMask: Image; + puzzleHole: Image; +} + +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; +} + +/** Generate a puzzle captcha, returning canvases for the board and piece. */ +export function generateCaptcha( + { bgImages, puzzleMask, puzzleHole }: CaptchaImages, + bgSize: Dimensions, + puzzleSize: Dimensions, +): { + bg: EmulatedCanvas2D; + puzzle: EmulatedCanvas2D; + solution: Point; +} { + const bg = createCanvas(bgSize.w, bgSize.h); + const puzzle = createCanvas(puzzleSize.w, puzzleSize.h); + + const ctx = bg.getContext('2d'); + const pctx = puzzle.getContext('2d'); + + const solution = generateSolution(bgSize, puzzleSize); + const bgImage = bgImages[Math.floor(Math.random() * bgImages.length)]; + + // Draw the background image. + ctx.drawImage(bgImage, 0, 0, bg.width, bg.height); + addNoise(ctx, bg.width, bg.height); + + // Draw the puzzle piece. + pctx.drawImage(puzzleMask, 0, 0, puzzle.width, puzzle.height); + pctx.globalCompositeOperation = 'source-in'; + pctx.drawImage(bg, solution.x, solution.y, puzzle.width, puzzle.height, 0, 0, puzzle.width, puzzle.height); + + // Draw the hole. + ctx.globalCompositeOperation = 'source-atop'; + ctx.drawImage(puzzleHole, solution.x, solution.y, puzzle.width, puzzle.height); + + return { + bg, + puzzle, + solution, + }; +} + +/** + * 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 }, + { ...solution, ...puzzleSize }, + ); +} + +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 { + x: Math.floor(Math.random() * (bgSize.w - puzzleSize.w)), + y: Math.floor(Math.random() * (bgSize.h - puzzleSize.h)), + }; +} diff --git a/packages/captcha/deno.json b/packages/captcha/deno.json new file mode 100644 index 00000000..e10a6b36 --- /dev/null +++ b/packages/captcha/deno.json @@ -0,0 +1,7 @@ +{ + "name": "@ditto/captcha", + "version": "1.0.0", + "exports": { + ".": "./captcha.ts" + } +} diff --git a/packages/ditto/controllers/api/captcha.ts b/packages/ditto/controllers/api/captcha.ts index 790913af..0f1f9ac4 100644 --- a/packages/ditto/controllers/api/captcha.ts +++ b/packages/ditto/controllers/api/captcha.ts @@ -1,4 +1,4 @@ -import { CanvasRenderingContext2D, createCanvas, Image, loadImage } from '@gfx/canvas-wasm'; +import { generateCaptcha, getCaptchaImages, verifyCaptchaSolution } from '@ditto/captcha'; import TTLCache from '@isaacs/ttlcache'; import { z } from 'zod'; @@ -10,13 +10,8 @@ interface Point { y: number; } -interface Dimensions { - w: number; - h: number; -} - const captchas = new TTLCache(); -const imagesAsync = getImages(); +const imagesAsync = getCaptchaImages(); const BG_SIZE = { w: 370, h: 400 }; const PUZZLE_SIZE = { w: 65, h: 65 }; @@ -47,104 +42,6 @@ export const captchaController: AppController = async (c) => { }); }; -interface CaptchaImages { - bgImages: Image[]; - puzzleMask: Image; - puzzleHole: Image; -} - -async function getImages(): Promise { - const bgImages = await getBackgroundImages(); - - const puzzleMask = await loadImage( - await Deno.readFile(new URL('../../assets/captcha/puzzle-mask.png', import.meta.url)), - ); - const puzzleHole = await loadImage( - await Deno.readFile(new URL('../../assets/captcha/puzzle-hole.png', import.meta.url)), - ); - - return { bgImages, puzzleMask, puzzleHole }; -} - -async function getBackgroundImages(): Promise { - const path = new URL('../../assets/captcha/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. */ -function generateCaptcha( - { bgImages, puzzleMask, puzzleHole }: CaptchaImages, - bgSize: Dimensions, - puzzleSize: Dimensions, -) { - const bg = createCanvas(bgSize.w, bgSize.h); - const puzzle = createCanvas(puzzleSize.w, puzzleSize.h); - - const ctx = bg.getContext('2d'); - const pctx = puzzle.getContext('2d'); - - const solution = generateSolution(bgSize, puzzleSize); - const bgImage = bgImages[Math.floor(Math.random() * bgImages.length)]; - - // Draw the background image. - ctx.drawImage(bgImage, 0, 0, bg.width, bg.height); - addNoise(ctx, bg.width, bg.height); - - // Draw the puzzle piece. - pctx.drawImage(puzzleMask, 0, 0, puzzle.width, puzzle.height); - pctx.globalCompositeOperation = 'source-in'; - pctx.drawImage(bg, solution.x, solution.y, puzzle.width, puzzle.height, 0, 0, puzzle.width, puzzle.height); - - // Draw the hole. - ctx.globalCompositeOperation = 'source-atop'; - ctx.drawImage(puzzleHole, solution.x, solution.y, puzzle.width, puzzle.height); - - return { - bg, - puzzle, - solution, - }; -} - -/** - * 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); -} - -/** Random coordinates such that the piece fits within the canvas. */ -function generateSolution(bgSize: Dimensions, puzzleSize: Dimensions): Point { - return { - x: Math.floor(Math.random() * (bgSize.w - puzzleSize.w)), - y: Math.floor(Math.random() * (bgSize.h - puzzleSize.h)), - }; -} - const pointSchema = z.object({ x: z.number(), y: z.number(), @@ -168,7 +65,7 @@ export const captchaVerifyController: AppController = async (c) => { return c.json({ error: 'Captcha expired' }, { status: 410 }); } - const solved = verifySolution(PUZZLE_SIZE, result.data, solution); + const solved = verifyCaptchaSolution(PUZZLE_SIZE, result.data, solution); if (solved) { captchas.delete(id); @@ -178,23 +75,3 @@ export const captchaVerifyController: AppController = async (c) => { return c.json({ error: 'Incorrect solution' }, { status: 400 }); }; - -function verifySolution(puzzleSize: Dimensions, point: Point, solution: Point): boolean { - return areIntersecting( - { ...point, ...puzzleSize }, - { ...solution, ...puzzleSize }, - ); -} - -type Rectangle = Point & Dimensions; - -function areIntersecting(rect1: Rectangle, rect2: Rectangle, threshold = 0.5) { - 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; -}