diff --git a/captcha/tj-holowaychuk.jpg b/captcha/tj-holowaychuk.jpg new file mode 100644 index 00000000..f5d15601 Binary files /dev/null and b/captcha/tj-holowaychuk.jpg differ diff --git a/deno.json b/deno.json index 7df7a010..f97d4fa7 100644 --- a/deno.json +++ b/deno.json @@ -36,6 +36,7 @@ "@b-fuze/deno-dom": "jsr:@b-fuze/deno-dom@^0.1.47", "@bradenmacdonald/s3-lite-client": "jsr:@bradenmacdonald/s3-lite-client@^0.7.4", "@electric-sql/pglite": "npm:@electric-sql/pglite@^0.2.8", + "@gfx/canvas-wasm": "jsr:@gfx/canvas-wasm@^0.4.2", "@hono/hono": "jsr:@hono/hono@^4.4.6", "@isaacs/ttlcache": "npm:@isaacs/ttlcache@^1.4.1", "@lambdalisue/async": "jsr:@lambdalisue/async@^2.1.1", diff --git a/deno.lock b/deno.lock index d11a5fbd..84969bb2 100644 --- a/deno.lock +++ b/deno.lock @@ -5,6 +5,7 @@ "jsr:@b-fuze/deno-dom@^0.1.47": "jsr:@b-fuze/deno-dom@0.1.48", "jsr:@bradenmacdonald/s3-lite-client@^0.7.4": "jsr:@bradenmacdonald/s3-lite-client@0.7.6", "jsr:@denosaurs/plug@1.0.3": "jsr:@denosaurs/plug@1.0.3", + "jsr:@gfx/canvas-wasm@^0.4.2": "jsr:@gfx/canvas-wasm@0.4.2", "jsr:@gleasonator/policy": "jsr:@gleasonator/policy@0.2.0", "jsr:@gleasonator/policy@0.2.0": "jsr:@gleasonator/policy@0.2.0", "jsr:@gleasonator/policy@0.4.0": "jsr:@gleasonator/policy@0.4.0", @@ -53,6 +54,7 @@ "jsr:@std/crypto@^0.224.0": "jsr:@std/crypto@0.224.0", "jsr:@std/dotenv@^0.224.0": "jsr:@std/dotenv@0.224.2", "jsr:@std/encoding@0.213.1": "jsr:@std/encoding@0.213.1", + "jsr:@std/encoding@1.0.5": "jsr:@std/encoding@1.0.5", "jsr:@std/encoding@^0.224.0": "jsr:@std/encoding@0.224.3", "jsr:@std/encoding@^0.224.1": "jsr:@std/encoding@0.224.3", "jsr:@std/fmt@0.213.1": "jsr:@std/fmt@0.213.1", @@ -138,6 +140,12 @@ "jsr:@std/path@0.213.1" ] }, + "@gfx/canvas-wasm@0.4.2": { + "integrity": "d653be3bd12cb2fa9bbe5d1b1f041a81b91d80b68502761204aaf60e4592532a", + "dependencies": [ + "jsr:@std/encoding@1.0.5" + ] + }, "@gleasonator/policy@0.2.0": { "integrity": "3fe58b853ab203b2b67e65b64391dbcf5c07bc1caaf46e97b2f8ed5b14f30fdf", "dependencies": [ @@ -470,6 +478,9 @@ "@std/encoding@0.224.3": { "integrity": "5e861b6d81be5359fad4155e591acf17c0207b595112d1840998bb9f476dbdaf" }, + "@std/encoding@1.0.5": { + "integrity": "ecf363d4fc25bd85bd915ff6733a7e79b67e0e7806334af15f4645c569fefc04" + }, "@std/fmt@0.213.1": { "integrity": "a06d31777566d874b9c856c10244ac3e6b660bdec4c82506cd46be052a1082c3" }, @@ -2135,6 +2146,7 @@ "dependencies": [ "jsr:@b-fuze/deno-dom@^0.1.47", "jsr:@bradenmacdonald/s3-lite-client@^0.7.4", + "jsr:@gfx/canvas-wasm@^0.4.2", "jsr:@hono/hono@^4.4.6", "jsr:@lambdalisue/async@^2.1.1", "jsr:@nostrify/db@^0.35.0", diff --git a/src/app.ts b/src/app.ts index 9fb67ced..42c5b8dd 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,6 +5,8 @@ import { logger } from '@hono/hono/logger'; import { NostrEvent, NostrSigner, NStore, NUploader } from '@nostrify/nostrify'; import Debug from '@soapbox/stickynotes/debug'; +import '@/startup.ts'; + import { Time } from '@/utils/time.ts'; import { @@ -36,6 +38,7 @@ import { import { appCredentialsController, createAppController } from '@/controllers/api/apps.ts'; import { blocksController } from '@/controllers/api/blocks.ts'; import { bookmarksController } from '@/controllers/api/bookmarks.ts'; +import { captchaController } from '@/controllers/api/captcha.ts'; import { adminRelaysController, adminSetRelaysController, @@ -113,7 +116,6 @@ import { errorHandler } from '@/controllers/error.ts'; import { frontendController } from '@/controllers/frontend.ts'; import { metricsController } from '@/controllers/metrics.ts'; import { indexController } from '@/controllers/site.ts'; -import '@/startup.ts'; import { manifestController } from '@/controllers/manifest.ts'; import { nodeInfoController, nodeInfoSchemaController } from '@/controllers/well-known/nodeinfo.ts'; import { nostrController } from '@/controllers/well-known/nostr.ts'; @@ -277,6 +279,8 @@ app.put('/api/v1/admin/ditto/relays', requireRole('admin'), adminSetRelaysContro app.post('/api/v1/ditto/names', requireSigner, nameRequestController); app.get('/api/v1/ditto/names', requireSigner, nameRequestsController); +app.get('/api/v1/ditto/captcha', captchaController); + app.get('/api/v1/ditto/zap_splits', getZapSplitsController); app.get('/api/v1/ditto/:id{[0-9a-f]{64}}/zap_splits', statusZapSplitsController); diff --git a/src/controllers/api/captcha.ts b/src/controllers/api/captcha.ts new file mode 100644 index 00000000..046dda9f --- /dev/null +++ b/src/controllers/api/captcha.ts @@ -0,0 +1,65 @@ +import { createCanvas, loadImage } from '@gfx/canvas-wasm'; + +import { AppController } from '@/app.ts'; + +export const captchaController: AppController = async (c) => { + const { puzzle, piece, solution } = await generateCaptcha( + await Deno.readFile(new URL('../../../captcha/tj-holowaychuk.jpg', import.meta.url)), + { + cw: 300, + ch: 300, + pw: 50, + ph: 50, + alpha: 0.6, + }, + ); + + return c.json({ + puzzle: puzzle.toDataURL(), + piece: piece.toDataURL(), + }); +}; + +interface Point { + x: number; + y: number; +} + +async function generateCaptcha( + from: Uint8Array, + opts: { + pw: number; + ph: number; + cw: number; + ch: number; + alpha: number; + }, +) { + const { pw, ph, cw, ch, alpha } = opts; + const puzzle = createCanvas(cw, ch); + const ctx = puzzle.getContext('2d'); + const image = await loadImage(from); + ctx.drawImage(image, 0, 0, image.width(), image.height(), 0, 0, cw, ch); + const piece = createCanvas(pw, ph); + const pctx = piece.getContext('2d'); + const solution = getPieceCoords(puzzle.width, puzzle.height, pw, ph); + pctx.drawImage(puzzle, solution.x, solution.y, pw, ph, 0, 0, pw, ph); + ctx.fillStyle = `rgba(0, 0, 0, ${alpha})`; + ctx.fillRect(solution.x, solution.y, pw, ph); + + return { + puzzle, + piece, + solution, + }; +} + +function getPieceCoords(cw: number, ch: number, pw: number, ph: number): Point { + // Random x coordinate such that the piece fits within the canvas horizontally + const x = Math.floor(Math.random() * (cw - pw)); + + // Random y coordinate such that the piece fits within the canvas vertically + const y = Math.floor(Math.random() * (ch - ph)); + + return { x, y }; +}