mirror of
https://github.com/aljazceru/ditto.git
synced 2026-01-16 03:44:26 +01:00
Merge remote-tracking branch 'origin/main' into push
This commit is contained in:
@@ -49,17 +49,26 @@ const verifyCredentialsController: AppController = async (c) => {
|
||||
const signer = c.get('signer')!;
|
||||
const pubkey = await signer.getPublicKey();
|
||||
|
||||
const eventsDB = await Storages.db();
|
||||
const store = await Storages.db();
|
||||
|
||||
const [author, [settingsStore]] = await Promise.all([
|
||||
const [author, [settingsStore], [captcha]] = await Promise.all([
|
||||
getAuthor(pubkey, { signal: AbortSignal.timeout(5000) }),
|
||||
|
||||
eventsDB.query([{
|
||||
authors: [pubkey],
|
||||
store.query([{
|
||||
kinds: [30078],
|
||||
authors: [pubkey],
|
||||
'#d': ['pub.ditto.pleroma_settings_store'],
|
||||
limit: 1,
|
||||
}]),
|
||||
|
||||
store.query([{
|
||||
kinds: [1985],
|
||||
authors: [Conf.pubkey],
|
||||
'#L': ['pub.ditto.captcha'],
|
||||
'#l': ['solved'],
|
||||
'#p': [pubkey],
|
||||
limit: 1,
|
||||
}]),
|
||||
]);
|
||||
|
||||
const account = author
|
||||
@@ -74,6 +83,10 @@ const verifyCredentialsController: AppController = async (c) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (captcha && account.source) {
|
||||
account.source.ditto.captcha_solved = true;
|
||||
}
|
||||
|
||||
return c.json(account);
|
||||
};
|
||||
|
||||
|
||||
206
src/controllers/api/captcha.ts
Normal file
206
src/controllers/api/captcha.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { CanvasRenderingContext2D, createCanvas, Image, loadImage } from '@gfx/canvas-wasm';
|
||||
import TTLCache from '@isaacs/ttlcache';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppController } from '@/app.ts';
|
||||
import { Conf } from '@/config.ts';
|
||||
import { createAdminEvent } from '@/utils/api.ts';
|
||||
|
||||
interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface Dimensions {
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
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 } = generateCaptcha(
|
||||
await imagesAsync,
|
||||
BG_SIZE,
|
||||
PUZZLE_SIZE,
|
||||
);
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date();
|
||||
const ttl = Conf.captchaTTL;
|
||||
|
||||
captchas.set(id, solution, { ttl });
|
||||
|
||||
return c.json({
|
||||
id,
|
||||
type: 'puzzle',
|
||||
bg: bg.toDataURL(),
|
||||
puzzle: puzzle.toDataURL(),
|
||||
created_at: now.toISOString(),
|
||||
expires_at: new Date(now.getTime() + ttl).toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
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 id = c.req.param('id');
|
||||
const result = pointSchema.safeParse(await c.req.json());
|
||||
const pubkey = await c.get('signer')!.getPublicKey();
|
||||
|
||||
if (!result.success) {
|
||||
return c.json({ error: 'Invalid input' }, { status: 422 });
|
||||
}
|
||||
|
||||
const solution = captchas.get(id);
|
||||
|
||||
if (!solution) {
|
||||
return c.json({ error: 'Captcha expired' }, { status: 410 });
|
||||
}
|
||||
|
||||
const solved = verifySolution(PUZZLE_SIZE, result.data, solution);
|
||||
|
||||
if (solved) {
|
||||
captchas.delete(id);
|
||||
|
||||
await createAdminEvent({
|
||||
kind: 1985,
|
||||
tags: [
|
||||
['L', 'pub.ditto.captcha'],
|
||||
['l', 'solved', 'pub.ditto.captcha'],
|
||||
['p', pubkey, Conf.relay],
|
||||
],
|
||||
}, c);
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -8,7 +8,8 @@ import { Conf } from '@/config.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { nostrNow } from '@/utils.ts';
|
||||
import { parseBody } from '@/utils/api.ts';
|
||||
import { encryptSecretKey, generateToken } from '@/utils/auth.ts';
|
||||
import { aesEncrypt } from '@/utils/aes.ts';
|
||||
import { generateToken } from '@/utils/auth.ts';
|
||||
|
||||
const passwordGrantSchema = z.object({
|
||||
grant_type: z.literal('password'),
|
||||
@@ -98,7 +99,7 @@ async function getToken(
|
||||
await kysely.insertInto('auth_tokens').values({
|
||||
token_hash: hash,
|
||||
pubkey,
|
||||
nip46_sk_enc: await encryptSecretKey(Conf.seckey, nip46Seckey),
|
||||
nip46_sk_enc: await aesEncrypt(Conf.seckey, nip46Seckey),
|
||||
nip46_relays: relays,
|
||||
created_at: new Date(),
|
||||
}).execute();
|
||||
|
||||
@@ -88,14 +88,28 @@ const createStatusController: AppController = async (c) => {
|
||||
return c.json({ error: 'Original post not found.' }, 404);
|
||||
}
|
||||
|
||||
const root = ancestor.tags.find((tag) => tag[0] === 'e' && tag[3] === 'root')?.[1] ?? ancestor.id;
|
||||
const rootId = ancestor.tags.find((tag) => tag[0] === 'e' && tag[3] === 'root')?.[1] ?? ancestor.id;
|
||||
const root = rootId === ancestor.id ? ancestor : await getEvent(rootId);
|
||||
|
||||
tags.push(['e', root, Conf.relay, 'root']);
|
||||
tags.push(['e', data.in_reply_to_id, Conf.relay, 'reply']);
|
||||
if (root) {
|
||||
tags.push(['e', root.id, Conf.relay, 'root', root.pubkey]);
|
||||
} else {
|
||||
tags.push(['e', rootId, Conf.relay, 'root']);
|
||||
}
|
||||
|
||||
tags.push(['e', ancestor.id, Conf.relay, 'reply', ancestor.pubkey]);
|
||||
}
|
||||
|
||||
let quoted: DittoEvent | undefined;
|
||||
|
||||
if (data.quote_id) {
|
||||
tags.push(['q', data.quote_id]);
|
||||
quoted = await getEvent(data.quote_id);
|
||||
|
||||
if (!quoted) {
|
||||
return c.json({ error: 'Quoted post not found.' }, 404);
|
||||
}
|
||||
|
||||
tags.push(['q', quoted.id, Conf.relay, '', quoted.pubkey]);
|
||||
}
|
||||
|
||||
if (data.sensitive && data.spoiler_text) {
|
||||
@@ -143,7 +157,7 @@ const createStatusController: AppController = async (c) => {
|
||||
}
|
||||
|
||||
try {
|
||||
return `nostr:${nip19.npubEncode(pubkey)}`;
|
||||
return `nostr:${nip19.nprofileEncode({ pubkey, relays: [Conf.relay] })}`;
|
||||
} catch {
|
||||
return match;
|
||||
}
|
||||
@@ -159,7 +173,7 @@ const createStatusController: AppController = async (c) => {
|
||||
}
|
||||
|
||||
for (const pubkey of pubkeys) {
|
||||
tags.push(['p', pubkey]);
|
||||
tags.push(['p', pubkey, Conf.relay]);
|
||||
}
|
||||
|
||||
for (const link of linkify.find(data.status ?? '')) {
|
||||
@@ -175,10 +189,16 @@ const createStatusController: AppController = async (c) => {
|
||||
.map(({ url }) => url)
|
||||
.filter((url): url is string => Boolean(url));
|
||||
|
||||
const quoteCompat = data.quote_id ? `\n\nnostr:${nip19.noteEncode(data.quote_id)}` : '';
|
||||
const quoteCompat = quoted
|
||||
? `\n\nnostr:${
|
||||
nip19.neventEncode({ id: quoted.id, kind: quoted.kind, author: quoted.pubkey, relays: [Conf.relay] })
|
||||
}`
|
||||
: '';
|
||||
|
||||
const mediaCompat = mediaUrls.length ? `\n\n${mediaUrls.join('\n')}` : '';
|
||||
|
||||
const author = await getAuthor(await c.get('signer')?.getPublicKey()!);
|
||||
const pubkey = await c.get('signer')?.getPublicKey()!;
|
||||
const author = pubkey ? await getAuthor(pubkey) : undefined;
|
||||
|
||||
if (Conf.zapSplitsEnabled) {
|
||||
const meta = n.json().pipe(n.metadata()).catch({}).parse(author?.content);
|
||||
@@ -191,7 +211,7 @@ const createStatusController: AppController = async (c) => {
|
||||
tags.push(['zap', pubkey, Conf.relay, dittoZapSplit[pubkey].weight.toString(), dittoZapSplit[pubkey].message]);
|
||||
}
|
||||
if (totalSplit) {
|
||||
tags.push(['zap', author?.pubkey as string, Conf.relay, Math.max(0, 100 - totalSplit).toString()]);
|
||||
tags.push(['zap', pubkey, Conf.relay, Math.max(0, 100 - totalSplit).toString()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -223,7 +243,7 @@ const deleteStatusController: AppController = async (c) => {
|
||||
if (event.pubkey === pubkey) {
|
||||
await createEvent({
|
||||
kind: 5,
|
||||
tags: [['e', id, Conf.relay]],
|
||||
tags: [['e', id, Conf.relay, '', pubkey]],
|
||||
}, c);
|
||||
|
||||
const author = await getAuthor(event.pubkey);
|
||||
@@ -281,7 +301,7 @@ const favouriteController: AppController = async (c) => {
|
||||
kind: 7,
|
||||
content: '+',
|
||||
tags: [
|
||||
['e', target.id, Conf.relay],
|
||||
['e', target.id, Conf.relay, '', target.pubkey],
|
||||
['p', target.pubkey, Conf.relay],
|
||||
],
|
||||
}, c);
|
||||
@@ -324,7 +344,7 @@ const reblogStatusController: AppController = async (c) => {
|
||||
const reblogEvent = await createEvent({
|
||||
kind: 6,
|
||||
tags: [
|
||||
['e', event.id, Conf.relay],
|
||||
['e', event.id, Conf.relay, '', event.pubkey],
|
||||
['p', event.pubkey, Conf.relay],
|
||||
],
|
||||
}, c);
|
||||
@@ -361,7 +381,7 @@ const unreblogStatusController: AppController = async (c) => {
|
||||
|
||||
await createEvent({
|
||||
kind: 5,
|
||||
tags: [['e', repostEvent.id, Conf.relay]],
|
||||
tags: [['e', repostEvent.id, Conf.relay, '', repostEvent.pubkey]],
|
||||
}, c);
|
||||
|
||||
return c.json(await renderStatus(event, { viewerPubkey: pubkey }));
|
||||
@@ -413,7 +433,7 @@ const bookmarkController: AppController = async (c) => {
|
||||
if (event) {
|
||||
await updateListEvent(
|
||||
{ kinds: [10003], authors: [pubkey], limit: 1 },
|
||||
(tags) => addTag(tags, ['e', eventId, Conf.relay]),
|
||||
(tags) => addTag(tags, ['e', event.id, Conf.relay, '', event.pubkey]),
|
||||
c,
|
||||
);
|
||||
|
||||
@@ -440,7 +460,7 @@ const unbookmarkController: AppController = async (c) => {
|
||||
if (event) {
|
||||
await updateListEvent(
|
||||
{ kinds: [10003], authors: [pubkey], limit: 1 },
|
||||
(tags) => deleteTag(tags, ['e', eventId, Conf.relay]),
|
||||
(tags) => deleteTag(tags, ['e', event.id, Conf.relay, '', event.pubkey]),
|
||||
c,
|
||||
);
|
||||
|
||||
@@ -467,7 +487,7 @@ const pinController: AppController = async (c) => {
|
||||
if (event) {
|
||||
await updateListEvent(
|
||||
{ kinds: [10001], authors: [pubkey], limit: 1 },
|
||||
(tags) => addTag(tags, ['e', eventId, Conf.relay]),
|
||||
(tags) => addTag(tags, ['e', event.id, Conf.relay, '', event.pubkey]),
|
||||
c,
|
||||
);
|
||||
|
||||
@@ -496,7 +516,7 @@ const unpinController: AppController = async (c) => {
|
||||
if (event) {
|
||||
await updateListEvent(
|
||||
{ kinds: [10001], authors: [pubkey], limit: 1 },
|
||||
(tags) => deleteTag(tags, ['e', eventId, Conf.relay]),
|
||||
(tags) => deleteTag(tags, ['e', event.id, Conf.relay, '', event.pubkey]),
|
||||
c,
|
||||
);
|
||||
|
||||
@@ -540,8 +560,8 @@ const zapController: AppController = async (c) => {
|
||||
lnurl = getLnurl(meta);
|
||||
if (target && lnurl) {
|
||||
tags.push(
|
||||
['e', target.id, Conf.relay],
|
||||
['p', target.pubkey],
|
||||
['e', target.id, Conf.relay, '', target.pubkey],
|
||||
['p', target.pubkey, Conf.relay],
|
||||
['amount', amount.toString()],
|
||||
['relays', Conf.relay],
|
||||
['lnurl', lnurl],
|
||||
@@ -553,7 +573,7 @@ const zapController: AppController = async (c) => {
|
||||
lnurl = getLnurl(meta);
|
||||
if (target && lnurl) {
|
||||
tags.push(
|
||||
['p', target.pubkey],
|
||||
['p', target.pubkey, Conf.relay],
|
||||
['amount', amount.toString()],
|
||||
['relays', Conf.relay],
|
||||
['lnurl', lnurl],
|
||||
|
||||
Reference in New Issue
Block a user