From 05a4a5a5c9710da9e555a82622adc09c6251cdb2 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Tue, 25 Feb 2025 20:04:28 -0300 Subject: [PATCH 01/12] fix: filter out invalid pubkeys --- packages/ditto/storages/hydrate.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ditto/storages/hydrate.ts b/packages/ditto/storages/hydrate.ts index 5fdb691f..cd575bc2 100644 --- a/packages/ditto/storages/hydrate.ts +++ b/packages/ditto/storages/hydrate.ts @@ -7,7 +7,7 @@ import { NSchema as n } from '@nostrify/nostrify'; import { z } from 'zod'; import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; -import { fallbackAuthor } from '@/utils.ts'; +import { fallbackAuthor, isNostrId } from '@/utils.ts'; import { findQuoteTag } from '@/utils/tags.ts'; import { findQuoteInContent } from '@/utils/note.ts'; import { getAmount } from '@/utils/bolt11.ts'; @@ -132,7 +132,9 @@ export function assembleEvents( event.quote = b.find((e) => matchFilter({ kinds: [1, 20], ids: [id] }, e)); } - const pubkeys = event.tags.filter(([name]) => name === 'p').map(([_name, value]) => value); + const pubkeys = event.tags.filter(([name]) => name === 'p') + .map(([_name, value]) => value) + .filter((pubkey) => isNostrId(pubkey)); event.mentions = b.filter((e) => matchFilter({ kinds: [0], authors: pubkeys }, e)); } From c82cfb9e8b8db05ba6c984bb5236803a1adeba27 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Tue, 25 Feb 2025 20:16:28 -0300 Subject: [PATCH 02/12] refactor: remove duplicate filter --- packages/ditto/storages/hydrate.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/ditto/storages/hydrate.ts b/packages/ditto/storages/hydrate.ts index cd575bc2..a4dfe7ab 100644 --- a/packages/ditto/storages/hydrate.ts +++ b/packages/ditto/storages/hydrate.ts @@ -132,9 +132,8 @@ export function assembleEvents( event.quote = b.find((e) => matchFilter({ kinds: [1, 20], ids: [id] }, e)); } - const pubkeys = event.tags.filter(([name]) => name === 'p') - .map(([_name, value]) => value) - .filter((pubkey) => isNostrId(pubkey)); + const pubkeys = event.tags.filter(([name, value]) => name === 'p' && isNostrId(value)) + .map(([_name, value]) => value); event.mentions = b.filter((e) => matchFilter({ kinds: [0], authors: pubkeys }, e)); } From 1730274e70d051209b4019fdf6846193fb357943 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Wed, 26 Feb 2025 11:19:15 -0300 Subject: [PATCH 03/12] refactor: remove await --- packages/ditto/controllers/api/accounts.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ditto/controllers/api/accounts.ts b/packages/ditto/controllers/api/accounts.ts index 495e79b5..ad9dde19 100644 --- a/packages/ditto/controllers/api/accounts.ts +++ b/packages/ditto/controllers/api/accounts.ts @@ -100,11 +100,11 @@ const accountLookupController: AppController = async (c) => { const event = await lookupAccount(decodeURIComponent(acct), c.var); if (event) { assertAuthenticated(c, event); - return c.json(await renderAccount(event)); + return c.json(renderAccount(event)); } try { const pubkey = bech32ToPubkey(decodeURIComponent(acct)); - return c.json(await accountFromPubkey(pubkey!)); + return c.json(accountFromPubkey(pubkey!)); } catch { return c.json({ error: 'Could not find user.' }, 404); } From a2732642a53fb419a513dca013e9d33a7d153428 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 26 Feb 2025 14:46:47 -0600 Subject: [PATCH 04/12] 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; -} From 5f617b2d1ad9ac3b3fd4a307487f08a7d1ebe838 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 26 Feb 2025 14:53:13 -0600 Subject: [PATCH 05/12] Break captcha package into smaller modules --- packages/captcha/assets.ts | 36 +++++++++++++++ packages/captcha/canvas.ts | 21 +++++++++ packages/captcha/captcha.ts | 87 ++---------------------------------- packages/captcha/deno.json | 2 +- packages/captcha/geometry.ts | 22 +++++++++ packages/captcha/mod.ts | 2 + 6 files changed, 86 insertions(+), 84 deletions(-) create mode 100644 packages/captcha/assets.ts create mode 100644 packages/captcha/canvas.ts create mode 100644 packages/captcha/geometry.ts create mode 100644 packages/captcha/mod.ts 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'; From a2aaa55b89a582afa64df051df0b8decbe4f4943 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 26 Feb 2025 15:09:01 -0600 Subject: [PATCH 06/12] Add tests to captcha modules --- packages/captcha/assets.test.ts | 9 +++++++++ packages/captcha/canvas.test.ts | 22 ++++++++++++++++++++++ packages/captcha/captcha.test.ts | 12 ++++++++++++ packages/captcha/geometry.test.ts | 8 ++++++++ packages/captcha/geometry.ts | 9 +++++++-- 5 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 packages/captcha/assets.test.ts create mode 100644 packages/captcha/canvas.test.ts create mode 100644 packages/captcha/captcha.test.ts create mode 100644 packages/captcha/geometry.test.ts diff --git a/packages/captcha/assets.test.ts b/packages/captcha/assets.test.ts new file mode 100644 index 00000000..c86eb203 --- /dev/null +++ b/packages/captcha/assets.test.ts @@ -0,0 +1,9 @@ +import { assert } from '@std/assert'; + +import { getCaptchaImages } from './assets.ts'; + +Deno.test('getCaptchaImages', async () => { + // If this function runs at all, it most likely worked. + const { bgImages } = await getCaptchaImages(); + assert(bgImages.length); +}); diff --git a/packages/captcha/canvas.test.ts b/packages/captcha/canvas.test.ts new file mode 100644 index 00000000..6e066a1d --- /dev/null +++ b/packages/captcha/canvas.test.ts @@ -0,0 +1,22 @@ +import { createCanvas } from '@gfx/canvas-wasm'; +import { assertNotEquals } from '@std/assert'; +import { encodeHex } from '@std/encoding/hex'; + +import { addNoise } from './canvas.ts'; + +// This is almost impossible to truly test, +// but we can at least check that the image on the canvas changes. +Deno.test('addNoise', async () => { + const canvas = createCanvas(100, 100); + const ctx = canvas.getContext('2d'); + + const dataBefore = ctx.getImageData(0, 0, canvas.width, canvas.height); + const hashBefore = await crypto.subtle.digest('SHA-256', dataBefore.data); + + addNoise(ctx, canvas.width, canvas.height); + + const dataAfter = ctx.getImageData(0, 0, canvas.width, canvas.height); + const hashAfter = await crypto.subtle.digest('SHA-256', dataAfter.data); + + assertNotEquals(encodeHex(hashBefore), encodeHex(hashAfter)); +}); diff --git a/packages/captcha/captcha.test.ts b/packages/captcha/captcha.test.ts new file mode 100644 index 00000000..285adae7 --- /dev/null +++ b/packages/captcha/captcha.test.ts @@ -0,0 +1,12 @@ +import { getCaptchaImages } from './assets.ts'; +import { generateCaptcha, verifyCaptchaSolution } from './captcha.ts'; + +Deno.test('generateCaptcha', async () => { + const images = await getCaptchaImages(); + generateCaptcha(images, { w: 370, h: 400 }, { w: 65, h: 65 }); +}); + +Deno.test('verifyCaptchaSolution', () => { + verifyCaptchaSolution({ w: 65, h: 65 }, { x: 0, y: 0 }, { x: 0, y: 0 }); + verifyCaptchaSolution({ w: 65, h: 65 }, { x: 0, y: 0 }, { x: 10, y: 10 }); +}); diff --git a/packages/captcha/geometry.test.ts b/packages/captcha/geometry.test.ts new file mode 100644 index 00000000..057ebf3d --- /dev/null +++ b/packages/captcha/geometry.test.ts @@ -0,0 +1,8 @@ +import { assertEquals } from '@std/assert'; + +import { areIntersecting } from './geometry.ts'; + +Deno.test('areIntersecting', () => { + assertEquals(areIntersecting({ x: 0, y: 0, w: 10, h: 10 }, { x: 5, y: 5, w: 10, h: 10 }), true); + assertEquals(areIntersecting({ x: 0, y: 0, w: 10, h: 10 }, { x: 15, y: 15, w: 10, h: 10 }), false); +}); diff --git a/packages/captcha/geometry.ts b/packages/captcha/geometry.ts index b1993e0b..40e5f2fd 100644 --- a/packages/captcha/geometry.ts +++ b/packages/captcha/geometry.ts @@ -8,15 +8,20 @@ export interface Dimensions { h: number; } -export type Rectangle = Point & Dimensions; +type Rectangle = Point & Dimensions; +/** Check if the two rectangles intersect by at least `threshold` percent. */ 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; + + return dist <= (e1 + e2) * threshold; } From 4b55acb796dfd8107e0f5e68515a3961a049e74c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 26 Feb 2025 15:10:41 -0600 Subject: [PATCH 07/12] Clean up captchaController --- packages/ditto/controllers/api/captcha.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/ditto/controllers/api/captcha.ts b/packages/ditto/controllers/api/captcha.ts index 0f1f9ac4..d96e684a 100644 --- a/packages/ditto/controllers/api/captcha.ts +++ b/packages/ditto/controllers/api/captcha.ts @@ -10,6 +10,11 @@ interface Point { y: number; } +const pointSchema: z.ZodType = z.object({ + x: z.number(), + y: z.number(), +}); + const captchas = new TTLCache(); const imagesAsync = getCaptchaImages(); @@ -42,11 +47,6 @@ export const captchaController: AppController = async (c) => { }); }; -const pointSchema = z.object({ - x: z.number(), - y: z.number(), -}); - /** Verify the captcha solution and sign an event in the database. */ export const captchaVerifyController: AppController = async (c) => { const { user } = c.var; From 89905d76a6170f8e518775821283be47f2822916 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 26 Feb 2025 15:19:20 -0600 Subject: [PATCH 08/12] DittoApp: combine opts and vars into one object --- packages/ditto/app.ts | 2 +- packages/mastoapi/router/DittoApp.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/ditto/app.ts b/packages/ditto/app.ts index 5a84a80d..f89448c4 100644 --- a/packages/ditto/app.ts +++ b/packages/ditto/app.ts @@ -214,7 +214,7 @@ if (conf.cronEnabled) { cron({ conf, db, relay }); } -const app = new DittoApp({ conf, db, relay }, { strict: false }); +const app = new DittoApp({ conf, db, relay, strict: false }); /** User-provided files in the gitignored `public/` directory. */ const publicFiles = serveStatic({ root: './public/' }); diff --git a/packages/mastoapi/router/DittoApp.ts b/packages/mastoapi/router/DittoApp.ts index 3309f65d..2d3c0107 100644 --- a/packages/mastoapi/router/DittoApp.ts +++ b/packages/mastoapi/router/DittoApp.ts @@ -7,13 +7,13 @@ export class DittoApp extends Hono { // @ts-ignore Require a DittoRoute for type safety. declare route: (path: string, app: Hono) => Hono; - constructor(vars: Omit, opts: HonoOptions = {}) { + constructor(opts: Omit & HonoOptions) { super(opts); this.use((c, next) => { - c.set('db', vars.db); - c.set('conf', vars.conf); - c.set('relay', vars.relay); + c.set('db', opts.db); + c.set('conf', opts.conf); + c.set('relay', opts.relay); c.set('signal', c.req.raw.signal); return next(); }); From f650541b235f87852d261e3f0a8a4c8bbc4554a5 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 26 Feb 2025 16:16:40 -0600 Subject: [PATCH 09/12] Remove precheck.ts, perform check in DittoConf constructor --- packages/conf/DittoConf.test.ts | 23 +++++++++++++++++++++++ packages/conf/DittoConf.ts | 17 ++++++++++++++++- packages/ditto/precheck.ts | 22 ---------------------- packages/ditto/server.ts | 1 - 4 files changed, 39 insertions(+), 24 deletions(-) delete mode 100644 packages/ditto/precheck.ts diff --git a/packages/conf/DittoConf.test.ts b/packages/conf/DittoConf.test.ts index b6c2b707..0d7682f0 100644 --- a/packages/conf/DittoConf.test.ts +++ b/packages/conf/DittoConf.test.ts @@ -29,3 +29,26 @@ Deno.test('DittoConfig defaults', async (t) => { assertEquals(config.port, 4036); }); }); + +Deno.test('DittoConfig with insecure media host', () => { + const env = new Map([ + ['LOCAL_DOMAIN', 'https://ditto.test'], + ['MEDIA_DOMAIN', 'https://ditto.test'], + ]); + + assertThrows( + () => new DittoConf(env), + Error, + 'For security reasons, MEDIA_DOMAIN cannot be on the same host as LOCAL_DOMAIN', + ); +}); + +Deno.test('DittoConfig with insecure media host and precheck disabled', () => { + const env = new Map([ + ['LOCAL_DOMAIN', 'https://ditto.test'], + ['MEDIA_DOMAIN', 'https://ditto.test'], + ['DITTO_PRECHECK', 'false'], + ]); + + new DittoConf(env); +}); diff --git a/packages/conf/DittoConf.ts b/packages/conf/DittoConf.ts index b7f5be79..f775a861 100644 --- a/packages/conf/DittoConf.ts +++ b/packages/conf/DittoConf.ts @@ -13,7 +13,17 @@ import { mergeURLPath } from './utils/url.ts'; /** Ditto application-wide configuration. */ export class DittoConf { - constructor(private env: { get(key: string): string | undefined }) {} + constructor(private env: { get(key: string): string | undefined }) { + if (this.precheck) { + const mediaUrl = new URL(this.mediaDomain); + + if (this.url.host === mediaUrl.host) { + throw new Error( + 'For security reasons, MEDIA_DOMAIN cannot be on the same host as LOCAL_DOMAIN.\n\nTo disable this check, set DITTO_PRECHECK="false"', + ); + } + } + } /** Cached parsed admin signer. */ private _signer: NSecSigner | undefined; @@ -465,4 +475,9 @@ export class DittoConf { get streakWindow(): number { return Number(this.env.get('STREAK_WINDOW') || 129600); } + + /** Whether to perform security/configuration checks on startup. */ + get precheck(): boolean { + return optionalBooleanSchema.parse(this.env.get('DITTO_PRECHECK')) ?? true; + } } diff --git a/packages/ditto/precheck.ts b/packages/ditto/precheck.ts deleted file mode 100644 index 40ab2fdb..00000000 --- a/packages/ditto/precheck.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Conf } from '@/config.ts'; - -/** Ensure the media URL is not on the same host as the local domain. */ -function checkMediaHost() { - const { url, mediaDomain } = Conf; - const mediaUrl = new URL(mediaDomain); - - if (url.host === mediaUrl.host) { - throw new PrecheckError('For security reasons, MEDIA_DOMAIN cannot be on the same host as LOCAL_DOMAIN.'); - } -} - -/** Error class for precheck errors. */ -class PrecheckError extends Error { - constructor(message: string) { - super(`${message}\nTo disable this check, set DITTO_PRECHECK="false"`); - } -} - -if (Deno.env.get('DITTO_PRECHECK') !== 'false') { - checkMediaHost(); -} diff --git a/packages/ditto/server.ts b/packages/ditto/server.ts index c5815537..69dc1e02 100644 --- a/packages/ditto/server.ts +++ b/packages/ditto/server.ts @@ -1,6 +1,5 @@ import { logi } from '@soapbox/logi'; -import '@/precheck.ts'; import '@/sentry.ts'; import '@/nostr-wasm.ts'; import app from '@/app.ts'; From 40824280b460b29e1a29a3052147d73f51c6659a Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Wed, 26 Feb 2025 19:53:55 -0300 Subject: [PATCH 10/12] fix: call remove if it's available --- packages/ditto/controllers/api/admin.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/ditto/controllers/api/admin.ts b/packages/ditto/controllers/api/admin.ts index 411aa841..e393df46 100644 --- a/packages/ditto/controllers/api/admin.ts +++ b/packages/ditto/controllers/api/admin.ts @@ -160,11 +160,13 @@ const adminActionController: AppController = async (c) => { } if (data.type === 'revoke_name') { n.revoke_name = true; - relay.remove!([{ kinds: [30360], authors: [await conf.signer.getPublicKey()], '#p': [authorId] }]).catch( - (e: unknown) => { - logi({ level: 'error', ns: 'ditto.api.admin.account.action', type: data.type, error: errorJson(e) }); - }, - ); + if (relay.remove) { + relay.remove([{ kinds: [30360], authors: [await conf.signer.getPublicKey()], '#p': [authorId] }]).catch( + (e: unknown) => { + logi({ level: 'error', ns: 'ditto.api.admin.account.action', type: data.type, error: errorJson(e) }); + }, + ); + } } await updateUser(authorId, n, c); From 0c7ab0364339511ab72079544bb43346ff236620 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Wed, 26 Feb 2025 20:06:57 -0300 Subject: [PATCH 11/12] refactor: use ?. --- packages/ditto/controllers/api/admin.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/ditto/controllers/api/admin.ts b/packages/ditto/controllers/api/admin.ts index e393df46..d8d03eb9 100644 --- a/packages/ditto/controllers/api/admin.ts +++ b/packages/ditto/controllers/api/admin.ts @@ -160,13 +160,11 @@ const adminActionController: AppController = async (c) => { } if (data.type === 'revoke_name') { n.revoke_name = true; - if (relay.remove) { - relay.remove([{ kinds: [30360], authors: [await conf.signer.getPublicKey()], '#p': [authorId] }]).catch( - (e: unknown) => { - logi({ level: 'error', ns: 'ditto.api.admin.account.action', type: data.type, error: errorJson(e) }); - }, - ); - } + relay.remove?.([{ kinds: [30360], authors: [await conf.signer.getPublicKey()], '#p': [authorId] }]).catch( + (e: unknown) => { + logi({ level: 'error', ns: 'ditto.api.admin.account.action', type: data.type, error: errorJson(e) }); + }, + ); } await updateUser(authorId, n, c); From 0f20f1b26b156190462186b52cb2f80a7c8ccd16 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Wed, 26 Feb 2025 20:21:42 -0300 Subject: [PATCH 12/12] refactor: return 500 http code in case of error --- packages/ditto/controllers/api/admin.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/ditto/controllers/api/admin.ts b/packages/ditto/controllers/api/admin.ts index d8d03eb9..f3611035 100644 --- a/packages/ditto/controllers/api/admin.ts +++ b/packages/ditto/controllers/api/admin.ts @@ -160,11 +160,12 @@ const adminActionController: AppController = async (c) => { } if (data.type === 'revoke_name') { n.revoke_name = true; - relay.remove?.([{ kinds: [30360], authors: [await conf.signer.getPublicKey()], '#p': [authorId] }]).catch( - (e: unknown) => { - logi({ level: 'error', ns: 'ditto.api.admin.account.action', type: data.type, error: errorJson(e) }); - }, - ); + try { + await relay.remove!([{ kinds: [30360], authors: [await conf.signer.getPublicKey()], '#p': [authorId] }]); + } catch (e) { + logi({ level: 'error', ns: 'ditto.api.admin.account.action', type: data.type, error: errorJson(e) }); + return c.json({ error: 'Unexpected runtime error' }, 500); + } } await updateUser(authorId, n, c);