Break @ditto/captcha into a separate library
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"workspace": [
|
"workspace": [
|
||||||
|
"./packages/captcha",
|
||||||
"./packages/conf",
|
"./packages/conf",
|
||||||
"./packages/db",
|
"./packages/db",
|
||||||
"./packages/ditto",
|
"./packages/ditto",
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 997 B After Width: | Height: | Size: 997 B |
|
Before Width: | Height: | Size: 696 B After Width: | Height: | Size: 696 B |
139
packages/captcha/captcha.ts
Normal file
|
|
@ -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<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. */
|
||||||
|
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)),
|
||||||
|
};
|
||||||
|
}
|
||||||
7
packages/captcha/deno.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"name": "@ditto/captcha",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"exports": {
|
||||||
|
".": "./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 TTLCache from '@isaacs/ttlcache';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
|
@ -10,13 +10,8 @@ interface Point {
|
||||||
y: number;
|
y: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Dimensions {
|
|
||||||
w: number;
|
|
||||||
h: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const captchas = new TTLCache<string, Point>();
|
const captchas = new TTLCache<string, Point>();
|
||||||
const imagesAsync = getImages();
|
const imagesAsync = getCaptchaImages();
|
||||||
|
|
||||||
const BG_SIZE = { w: 370, h: 400 };
|
const BG_SIZE = { w: 370, h: 400 };
|
||||||
const PUZZLE_SIZE = { w: 65, h: 65 };
|
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<CaptchaImages> {
|
|
||||||
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<Image[]> {
|
|
||||||
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({
|
const pointSchema = z.object({
|
||||||
x: z.number(),
|
x: z.number(),
|
||||||
y: z.number(),
|
y: z.number(),
|
||||||
|
|
@ -168,7 +65,7 @@ export const captchaVerifyController: AppController = async (c) => {
|
||||||
return c.json({ error: 'Captcha expired' }, { status: 410 });
|
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) {
|
if (solved) {
|
||||||
captchas.delete(id);
|
captchas.delete(id);
|
||||||
|
|
@ -178,23 +75,3 @@ export const captchaVerifyController: AppController = async (c) => {
|
||||||
|
|
||||||
return c.json({ error: 'Incorrect solution' }, { status: 400 });
|
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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||