captcha: show a random image, preload backgrounds into memory
|
After Width: | Height: | Size: 26 KiB |
BIN
src/assets/captcha/bg/A Trail of Footprints In The Sand.jpg
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src/assets/captcha/bg/Ashim DSilva.jpg
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
src/assets/captcha/bg/Canazei Granite Ridges.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
src/assets/captcha/bg/Martin Adams.jpg
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
BIN
src/assets/captcha/bg/Morskie Oko.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
src/assets/captcha/bg/Mr. Lee.jpg
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src/assets/captcha/bg/Nattu Adnan.jpg
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
src/assets/captcha/bg/Photo by SpaceX.jpg
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
src/assets/captcha/bg/Photo of Valley.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
src/assets/captcha/bg/Snow-Capped Mountain.jpg
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
src/assets/captcha/bg/Sunset by the Pier.jpg
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src/assets/captcha/bg/Tj Holowaychuk.jpg
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
src/assets/captcha/bg/Viktor Forgacs.jpg
Normal file
|
After Width: | Height: | Size: 26 KiB |
22
src/assets/captcha/bg/copyright.txt
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
Unsplash photos published before June 8, 2017 are CC0 (public domain):
|
||||
|
||||
Ashim D'Silva <https://unsplash.com/photos/WeYamle9fDM>
|
||||
Canazei Granite Ridges <https://unsplash.com/photos/yrwpJwDNSHE>
|
||||
Mr. Lee <https://unsplash.com/photos/v7r8kZStqFw>
|
||||
Photo by SpaceX <https://unsplash.com/photos/VBNb52J8Trk>
|
||||
Sunset by the Pier <https://unsplash.com/photos/ces8_Bo7bhQ>
|
||||
|
||||
Unsplash photos published on or after June 8, 2017 are free to use, modify, and redistribute subject to the Unsplash license <https://unsplash.com/license>:
|
||||
|
||||
Martin Adams <https://unsplash.com/photos/MpTdvXlAsVE>
|
||||
Morskie Oko <https://unsplash.com/photos/_1UF_3TlKcQ>
|
||||
Nattu Adnan <https://unsplash.com/photos/Ai2TRdvI6gM>
|
||||
Tj Holowaychuk <https://unsplash.com/photos/iGrsa9rL11o>
|
||||
Viktor Forgacs <https://unsplash.com/photos/q8XSCZYh6D8>
|
||||
“A Large Body of Water Surrounded By Mountains” by Peter Thomas <https://unsplash.com/photos/Dxod5pdRtsk>
|
||||
“A Trail of Footprints In The Sand” by David Emrich <https://unsplash.com/photos/A9mr3TPoj0k>
|
||||
“Photo of Valley” by Aniket Doele <https://unsplash.com/photos/M6XC789HLe8>
|
||||
|
||||
Pexels photos are free to use, modify, and redistribute subject to the Pexels license <https://www.pexels.com/license/>:
|
||||
|
||||
Snow-Capped Mountain <https://www.pexels.com/photo/photo-of-snow-capped-mountain-during-evening-2440024/>
|
||||
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1 KiB After Width: | Height: | Size: 997 B |
|
|
@ -1,4 +1,4 @@
|
|||
import { createCanvas, loadImage } from '@gfx/canvas-wasm';
|
||||
import { createCanvas, Image, loadImage } from '@gfx/canvas-wasm';
|
||||
import TTLCache from '@isaacs/ttlcache';
|
||||
import { z } from 'zod';
|
||||
|
||||
|
|
@ -17,16 +17,15 @@ interface Dimensions {
|
|||
}
|
||||
|
||||
const captchas = new TTLCache<string, Point>();
|
||||
const imagesAsync = getImages();
|
||||
|
||||
const BG_SIZE = { w: 370, h: 400 };
|
||||
const PUZZLE_SIZE = { w: 65, h: 65 };
|
||||
|
||||
/** Puzzle captcha controller. */
|
||||
export const captchaController: AppController = async (c) => {
|
||||
const { bg, puzzle, solution } = await generateCaptcha(
|
||||
await Deno.readFile(new URL('../../assets/captcha/bg/tj-holowaychuk.jpg', import.meta.url)),
|
||||
await Deno.readFile(new URL('../../assets/captcha/puzzle-mask.png', import.meta.url)),
|
||||
await Deno.readFile(new URL('../../assets/captcha/puzzle-hole.png', import.meta.url)),
|
||||
const { bg, puzzle, solution } = generateCaptcha(
|
||||
await imagesAsync,
|
||||
BG_SIZE,
|
||||
PUZZLE_SIZE,
|
||||
);
|
||||
|
|
@ -47,11 +46,44 @@ 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. */
|
||||
async function generateCaptcha(
|
||||
from: Uint8Array,
|
||||
mask: Uint8Array,
|
||||
hole: Uint8Array,
|
||||
function generateCaptcha(
|
||||
{ bgImages, puzzleMask, puzzleHole }: CaptchaImages,
|
||||
bgSize: Dimensions,
|
||||
puzzleSize: Dimensions,
|
||||
) {
|
||||
|
|
@ -61,19 +93,16 @@ async function generateCaptcha(
|
|||
const ctx = bg.getContext('2d');
|
||||
const pctx = puzzle.getContext('2d');
|
||||
|
||||
const bgImage = await loadImage(from);
|
||||
const maskImage = await loadImage(mask);
|
||||
const holeImage = await loadImage(hole);
|
||||
|
||||
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);
|
||||
ctx.globalCompositeOperation = 'source-atop';
|
||||
ctx.drawImage(holeImage, solution.x, solution.y, puzzle.width, puzzle.height);
|
||||
ctx.drawImage(puzzleHole, solution.x, solution.y, puzzle.width, puzzle.height);
|
||||
|
||||
// Draw the puzzle piece.
|
||||
pctx.drawImage(maskImage, 0, 0, puzzle.width, puzzle.height);
|
||||
pctx.drawImage(puzzleMask, 0, 0, puzzle.width, puzzle.height);
|
||||
pctx.globalCompositeOperation = 'source-in';
|
||||
pctx.drawImage(bgImage, solution.x, solution.y, puzzle.width, puzzle.height, 0, 0, puzzle.width, puzzle.height);
|
||||
|
||||
|
|
|
|||