Merge branch 'main' into mint-cashu
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"version": "1.1.0",
|
||||
"workspace": [
|
||||
"./packages/captcha",
|
||||
"./packages/conf",
|
||||
"./packages/db",
|
||||
"./packages/ditto",
|
||||
|
|
|
|||
9
packages/captcha/assets.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
36
packages/captcha/assets.ts
Normal file
|
|
@ -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<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;
|
||||
}
|
||||
|
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 |
22
packages/captcha/canvas.test.ts
Normal file
|
|
@ -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));
|
||||
});
|
||||
21
packages/captcha/canvas.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
12
packages/captcha/captcha.test.ts
Normal file
|
|
@ -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 });
|
||||
});
|
||||
60
packages/captcha/captcha.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { createCanvas, type EmulatedCanvas2D } from '@gfx/canvas-wasm';
|
||||
|
||||
import { addNoise } from './canvas.ts';
|
||||
import { areIntersecting, type Dimensions, type Point } from './geometry.ts';
|
||||
|
||||
import type { CaptchaImages } from './assets.ts';
|
||||
|
||||
/** 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,
|
||||
};
|
||||
}
|
||||
|
||||
export function verifyCaptchaSolution(puzzleSize: Dimensions, point: Point, solution: Point): boolean {
|
||||
return areIntersecting(
|
||||
{ ...point, ...puzzleSize },
|
||||
{ ...solution, ...puzzleSize },
|
||||
);
|
||||
}
|
||||
|
||||
/** 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": {
|
||||
".": "./mod.ts"
|
||||
}
|
||||
}
|
||||
8
packages/captcha/geometry.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
27
packages/captcha/geometry.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface Dimensions {
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
2
packages/captcha/mod.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { getCaptchaImages } from './assets.ts';
|
||||
export { generateCaptcha, verifyCaptchaSolution } from './captcha.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<string, string>([
|
||||
['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<string, string>([
|
||||
['LOCAL_DOMAIN', 'https://ditto.test'],
|
||||
['MEDIA_DOMAIN', 'https://ditto.test'],
|
||||
['DITTO_PRECHECK', 'false'],
|
||||
]);
|
||||
|
||||
new DittoConf(env);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/' });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,13 @@ interface Point {
|
|||
y: number;
|
||||
}
|
||||
|
||||
interface Dimensions {
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
const pointSchema: z.ZodType<Point> = z.object({
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
});
|
||||
|
||||
const captchas = new TTLCache<string, Point>();
|
||||
const imagesAsync = getImages();
|
||||
const imagesAsync = getCaptchaImages();
|
||||
|
||||
const BG_SIZE = { w: 370, h: 400 };
|
||||
const PUZZLE_SIZE = { w: 65, h: 65 };
|
||||
|
|
@ -47,109 +47,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({
|
||||
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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import { logi } from '@soapbox/logi';
|
||||
|
||||
import '@/precheck.ts';
|
||||
import '@/sentry.ts';
|
||||
import '@/nostr-wasm.ts';
|
||||
import app from '@/app.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,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);
|
||||
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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,13 +7,13 @@ export class DittoApp extends Hono<DittoEnv> {
|
|||
// @ts-ignore Require a DittoRoute for type safety.
|
||||
declare route: (path: string, app: Hono<DittoEnv>) => Hono<DittoEnv>;
|
||||
|
||||
constructor(vars: Omit<DittoEnv['Variables'], 'signal'>, opts: HonoOptions<DittoEnv> = {}) {
|
||||
constructor(opts: Omit<DittoEnv['Variables'], 'signal'> & HonoOptions<DittoEnv>) {
|
||||
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();
|
||||
});
|
||||
|
|
|
|||