diff --git a/Dockerfile b/Dockerfile index f2dde5ec..636a94fa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,10 @@ FROM denoland/deno:1.44.2 -EXPOSE 4036 +ENV PORT 5000 + WORKDIR /app RUN mkdir -p data && chown -R deno data -USER deno COPY . . RUN deno cache src/server.ts +RUN apt-get update && apt-get install -y unzip curl +RUN deno task soapbox CMD deno task start diff --git a/deno.json b/deno.json index b081f323..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", @@ -44,6 +45,7 @@ "@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.36.0", "@nostrify/policies": "jsr:@nostrify/policies@^0.35.0", "@scure/base": "npm:@scure/base@^1.1.6", + "@scure/bip32": "npm:@scure/bip32@^1.5.0", "@sentry/deno": "https://deno.land/x/sentry@7.112.2/index.mjs", "@soapbox/kysely-pglite": "jsr:@soapbox/kysely-pglite@^1.0.0", "@soapbox/stickynotes": "jsr:@soapbox/stickynotes@^0.4.0", diff --git a/deno.lock b/deno.lock index 7b7e7d1c..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", @@ -73,6 +75,7 @@ "npm:@noble/secp256k1@^2.0.0": "npm:@noble/secp256k1@2.1.0", "npm:@scure/base@^1.1.6": "npm:@scure/base@1.1.6", "npm:@scure/bip32@^1.4.0": "npm:@scure/bip32@1.4.0", + "npm:@scure/bip32@^1.5.0": "npm:@scure/bip32@1.5.0", "npm:@scure/bip39@^1.3.0": "npm:@scure/bip39@1.3.0", "npm:@types/node": "npm:@types/node@18.16.19", "npm:comlink-async-generator": "npm:comlink-async-generator@0.0.1", @@ -137,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": [ @@ -469,6 +478,9 @@ "@std/encoding@0.224.3": { "integrity": "5e861b6d81be5359fad4155e591acf17c0207b595112d1840998bb9f476dbdaf" }, + "@std/encoding@1.0.5": { + "integrity": "ecf363d4fc25bd85bd915ff6733a7e79b67e0e7806334af15f4645c569fefc04" + }, "@std/fmt@0.213.1": { "integrity": "a06d31777566d874b9c856c10244ac3e6b660bdec4c82506cd46be052a1082c3" }, @@ -604,6 +616,12 @@ "@noble/hashes": "@noble/hashes@1.4.0" } }, + "@noble/curves@1.6.0": { + "integrity": "sha512-TlaHRXDehJuRNR9TfZDNQ45mMEd5dwUwmicsafcIX4SsNiqnCHKjE/1alYPd/lDRVhxdhUAlv8uEhMCI5zjIJQ==", + "dependencies": { + "@noble/hashes": "@noble/hashes@1.5.0" + } + }, "@noble/hashes@1.3.1": { "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==", "dependencies": {} @@ -616,6 +634,10 @@ "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", "dependencies": {} }, + "@noble/hashes@1.5.0": { + "integrity": "sha512-1j6kQFb7QRru7eKN3ZDvRcP13rugwdxZqCjbiAVZfIJwgj2A65UmT4TgARXGlXgnRkORLTDTrO19ZErt7+QXgA==", + "dependencies": {} + }, "@noble/secp256k1@2.1.0": { "integrity": "sha512-XLEQQNdablO0XZOIniFQimiXsZDNwaYgL96dZwC54Q30imSbAOFf3NKtepc+cXyuZf5Q1HCgbqgZ2UFFuHVcEw==", "dependencies": {} @@ -632,6 +654,10 @@ "integrity": "sha512-ok9AWwhcgYuGG3Zfhyqg+zwl+Wn5uE+dwC0NV/2qQkx4dABbb/bx96vWu8NSj+BNjjSjno+JRYRjle1jV08k3g==", "dependencies": {} }, + "@scure/base@1.1.9": { + "integrity": "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==", + "dependencies": {} + }, "@scure/bip32@1.3.1": { "integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==", "dependencies": { @@ -648,6 +674,14 @@ "@scure/base": "@scure/base@1.1.6" } }, + "@scure/bip32@1.5.0": { + "integrity": "sha512-8EnFYkqEQdnkuGBVpCzKxyIwDCBLDVj3oiX0EKUFre/tOjL/Hqba1D6n/8RcmaQy4f95qQFrO2A8Sr6ybh4NRw==", + "dependencies": { + "@noble/curves": "@noble/curves@1.6.0", + "@noble/hashes": "@noble/hashes@1.5.0", + "@scure/base": "@scure/base@1.1.9" + } + }, "@scure/bip39@1.2.1": { "integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==", "dependencies": { @@ -2112,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", @@ -2132,6 +2167,7 @@ "npm:@isaacs/ttlcache@^1.4.1", "npm:@noble/secp256k1@^2.0.0", "npm:@scure/base@^1.1.6", + "npm:@scure/bip32@^1.5.0", "npm:comlink-async-generator@^0.0.1", "npm:comlink@^4.4.1", "npm:commander@12.1.0", diff --git a/scripts/db-export.ts b/scripts/db-export.ts index 780a5ebc..29d347fc 100644 --- a/scripts/db-export.ts +++ b/scripts/db-export.ts @@ -63,7 +63,7 @@ export function buildFilter(args: ExportFilter) { if (invalid) throw new Error(`ERROR: Invalid pubkey ${invalid} supplied.`); filter.authors = authors; } - if (ids) { + if (ids && ids.length) { const invalid = findInvalid(ids); if (invalid) throw new Error(`ERROR: Invalid event ID ${invalid} supplied.`); filter.ids = ids; diff --git a/scripts/trends.ts b/scripts/trends.ts index 6600f7e2..627fb332 100644 --- a/scripts/trends.ts +++ b/scripts/trends.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import '@/config.ts'; import { updateTrendingEvents, diff --git a/src/DittoWallet.ts b/src/DittoWallet.ts new file mode 100644 index 00000000..95616325 --- /dev/null +++ b/src/DittoWallet.ts @@ -0,0 +1,46 @@ +import { HDKey } from '@scure/bip32'; + +import { Conf } from '@/config.ts'; + +/** + * HD wallet based on the `DITTO_NSEC`. + * The wallet is used to derive keys for various purposes. + * It is a singleton with static methods, and the keys are cached. + */ +export class DittoWallet { + static #root = HDKey.fromMasterSeed(Conf.seckey); + static #keys = new Map(); + + /** Derive the key cached. */ + static derive(path: string): HDKey { + const existing = this.#keys.get(path); + if (existing) { + return existing; + } else { + const key = this.#root.derive(path); + this.#keys.set(path, key); + return key; + } + } + + /** Derive the key and return the bytes. */ + static deriveKey(path: string): Uint8Array { + const { privateKey } = this.derive(path); + + if (!privateKey) { + throw new Error('Private key not available'); + } + + return privateKey; + } + + /** Database encryption key for AES-GCM encryption of database columns. */ + static get dbKey(): Uint8Array { + return this.deriveKey(Conf.wallet.dbKeyPath); + } + + /** VAPID secret key, used for web push notifications. ES256. */ + static get vapidKey(): Uint8Array { + return this.deriveKey(Conf.wallet.vapidKeyPath); + } +} diff --git a/src/app.ts b/src/app.ts index 8d8b1139..0cb17d1c 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, captchaVerifyController } from '@/controllers/api/captcha.ts'; import { adminRelaysController, adminSetRelaysController, @@ -114,7 +117,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'; @@ -280,6 +282,14 @@ 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', rateLimitMiddleware(3, Time.minutes(1)), captchaController); +app.post( + '/api/v1/ditto/captcha/:id/verify', + rateLimitMiddleware(8, Time.minutes(1)), + requireProof(), + captchaVerifyController, +); + 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/assets/captcha/bg/A Large Body of Water Surrounded By Mountains.jpg b/src/assets/captcha/bg/A Large Body of Water Surrounded By Mountains.jpg new file mode 100644 index 00000000..91430877 Binary files /dev/null and b/src/assets/captcha/bg/A Large Body of Water Surrounded By Mountains.jpg differ diff --git a/src/assets/captcha/bg/A Trail of Footprints In The Sand.jpg b/src/assets/captcha/bg/A Trail of Footprints In The Sand.jpg new file mode 100644 index 00000000..a2bf0463 Binary files /dev/null and b/src/assets/captcha/bg/A Trail of Footprints In The Sand.jpg differ diff --git a/src/assets/captcha/bg/Ashim DSilva.jpg b/src/assets/captcha/bg/Ashim DSilva.jpg new file mode 100644 index 00000000..2a1f85a4 Binary files /dev/null and b/src/assets/captcha/bg/Ashim DSilva.jpg differ diff --git a/src/assets/captcha/bg/Canazei Granite Ridges.jpg b/src/assets/captcha/bg/Canazei Granite Ridges.jpg new file mode 100644 index 00000000..b8b10abe Binary files /dev/null and b/src/assets/captcha/bg/Canazei Granite Ridges.jpg differ diff --git a/src/assets/captcha/bg/Martin Adams.jpg b/src/assets/captcha/bg/Martin Adams.jpg new file mode 100644 index 00000000..1a9040d6 Binary files /dev/null and b/src/assets/captcha/bg/Martin Adams.jpg differ diff --git a/src/assets/captcha/bg/Morskie Oko.jpg b/src/assets/captcha/bg/Morskie Oko.jpg new file mode 100644 index 00000000..0a0acdf2 Binary files /dev/null and b/src/assets/captcha/bg/Morskie Oko.jpg differ diff --git a/src/assets/captcha/bg/Mr. Lee.jpg b/src/assets/captcha/bg/Mr. Lee.jpg new file mode 100644 index 00000000..1b91ece5 Binary files /dev/null and b/src/assets/captcha/bg/Mr. Lee.jpg differ diff --git a/src/assets/captcha/bg/Nattu Adnan.jpg b/src/assets/captcha/bg/Nattu Adnan.jpg new file mode 100644 index 00000000..4017b8f6 Binary files /dev/null and b/src/assets/captcha/bg/Nattu Adnan.jpg differ diff --git a/src/assets/captcha/bg/Photo by SpaceX.jpg b/src/assets/captcha/bg/Photo by SpaceX.jpg new file mode 100644 index 00000000..72ec3286 Binary files /dev/null and b/src/assets/captcha/bg/Photo by SpaceX.jpg differ diff --git a/src/assets/captcha/bg/Photo of Valley.jpg b/src/assets/captcha/bg/Photo of Valley.jpg new file mode 100644 index 00000000..ccab4249 Binary files /dev/null and b/src/assets/captcha/bg/Photo of Valley.jpg differ diff --git a/src/assets/captcha/bg/Snow-Capped Mountain.jpg b/src/assets/captcha/bg/Snow-Capped Mountain.jpg new file mode 100644 index 00000000..cb01d6ee Binary files /dev/null and b/src/assets/captcha/bg/Snow-Capped Mountain.jpg differ diff --git a/src/assets/captcha/bg/Sunset by the Pier.jpg b/src/assets/captcha/bg/Sunset by the Pier.jpg new file mode 100644 index 00000000..d2ce7269 Binary files /dev/null and b/src/assets/captcha/bg/Sunset by the Pier.jpg differ diff --git a/src/assets/captcha/bg/Tj Holowaychuk.jpg b/src/assets/captcha/bg/Tj Holowaychuk.jpg new file mode 100644 index 00000000..3524983e Binary files /dev/null and b/src/assets/captcha/bg/Tj Holowaychuk.jpg differ diff --git a/src/assets/captcha/bg/Viktor Forgacs.jpg b/src/assets/captcha/bg/Viktor Forgacs.jpg new file mode 100644 index 00000000..740e1f79 Binary files /dev/null and b/src/assets/captcha/bg/Viktor Forgacs.jpg differ diff --git a/src/assets/captcha/bg/copyright.txt b/src/assets/captcha/bg/copyright.txt new file mode 100644 index 00000000..04374e83 --- /dev/null +++ b/src/assets/captcha/bg/copyright.txt @@ -0,0 +1,22 @@ +Unsplash photos published before June 8, 2017 are CC0 (public domain): + +Ashim D'Silva +Canazei Granite Ridges +Mr. Lee +Photo by SpaceX +Sunset by the Pier + +Unsplash photos published on or after June 8, 2017 are free to use, modify, and redistribute subject to the Unsplash license : + +Martin Adams +Morskie Oko +Nattu Adnan +Tj Holowaychuk +Viktor Forgacs +“A Large Body of Water Surrounded By Mountains” by Peter Thomas +“A Trail of Footprints In The Sand” by David Emrich +“Photo of Valley” by Aniket Doele + +Pexels photos are free to use, modify, and redistribute subject to the Pexels license : + +Snow-Capped Mountain diff --git a/src/assets/captcha/puzzle-hole.png b/src/assets/captcha/puzzle-hole.png new file mode 100644 index 00000000..2dd9bb0b Binary files /dev/null and b/src/assets/captcha/puzzle-hole.png differ diff --git a/src/assets/captcha/puzzle-hole.svg b/src/assets/captcha/puzzle-hole.svg new file mode 100644 index 00000000..20eecfe6 --- /dev/null +++ b/src/assets/captcha/puzzle-hole.svg @@ -0,0 +1,23 @@ + + diff --git a/src/assets/captcha/puzzle-mask.png b/src/assets/captcha/puzzle-mask.png new file mode 100644 index 00000000..ffc3a78e Binary files /dev/null and b/src/assets/captcha/puzzle-mask.png differ diff --git a/src/assets/captcha/puzzle-mask.svg b/src/assets/captcha/puzzle-mask.svg new file mode 100644 index 00000000..a72bd3f5 --- /dev/null +++ b/src/assets/captcha/puzzle-mask.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/config.ts b/src/config.ts index 21fbbe01..c7f5e7cb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,5 @@ import os from 'node:os'; +import ISO6391, { LanguageCode } from 'iso-639-1'; import * as dotenv from '@std/dotenv'; import { getPublicKey, nip19 } from 'nostr-tools'; import { z } from 'zod'; @@ -98,6 +99,25 @@ class Conf { }, }, }; + /** Time-to-live for captchas in milliseconds. */ + static get captchaTTL(): number { + return Number(Deno.env.get('CAPTCHA_TTL') || 5 * 60 * 1000); + } + /** + * BIP-32 derivation paths for different crypto use-cases. + * The `DITTO_NSEC` is used as the seed. + * Keys can be rotated by changing the derviation path. + */ + static wallet = { + /** Private key for AES-GCM encryption in the Postgres database. */ + get dbKeyPath(): string { + return Deno.env.get('WALLET_DB_KEY_PATH') || "m/0'/1'"; + }, + /** VAPID private key path. */ + get vapidKeyPath(): string { + return Deno.env.get('WALLET_VAPID_KEY_PATH') || "m/0'/3'"; + }, + }; /** Character limit to enforce for posts made through Mastodon API. */ static get postCharLimit(): number { return Number(Deno.env.get('POST_CHAR_LIMIT') || 5000); @@ -247,6 +267,10 @@ class Conf { static get zapSplitsEnabled(): boolean { return optionalBooleanSchema.parse(Deno.env.get('ZAP_SPLITS_ENABLED')) ?? false; } + /** Languages this server wishes to highlight. Used when querying trends.*/ + static get preferredLanguages(): LanguageCode[] | undefined { + return Deno.env.get('DITTO_LANGUAGES')?.split(',')?.filter(ISO6391.validate) as LanguageCode[]; + } /** Cache settings. */ static caches = { /** NIP-05 cache settings. */ diff --git a/src/controllers/api/accounts.ts b/src/controllers/api/accounts.ts index f27b20e1..c4a2bc40 100644 --- a/src/controllers/api/accounts.ts +++ b/src/controllers/api/accounts.ts @@ -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); }; diff --git a/src/controllers/api/captcha.ts b/src/controllers/api/captcha.ts new file mode 100644 index 00000000..ef266745 --- /dev/null +++ b/src/controllers/api/captcha.ts @@ -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(); +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 { + 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(), +}); + +/** 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; +} diff --git a/src/controllers/api/oauth.ts b/src/controllers/api/oauth.ts index c4c3eca4..61f8e1cd 100644 --- a/src/controllers/api/oauth.ts +++ b/src/controllers/api/oauth.ts @@ -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(); diff --git a/src/controllers/api/statuses.ts b/src/controllers/api/statuses.ts index 486ea28b..19907895 100644 --- a/src/controllers/api/statuses.ts +++ b/src/controllers/api/statuses.ts @@ -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], diff --git a/src/db/migrations/037_auth_tokens.ts b/src/db/migrations/037_auth_tokens.ts index 71e971d3..2f6d1890 100644 --- a/src/db/migrations/037_auth_tokens.ts +++ b/src/db/migrations/037_auth_tokens.ts @@ -1,7 +1,8 @@ import { Kysely, sql } from 'kysely'; -import { encryptSecretKey, getTokenHash } from '@/utils/auth.ts'; import { Conf } from '@/config.ts'; +import { aesEncrypt } from '@/utils/aes.ts'; +import { getTokenHash } from '@/utils/auth.ts'; interface DB { nip46_tokens: { @@ -38,7 +39,7 @@ export async function up(db: Kysely): Promise { await db.insertInto('auth_tokens').values({ token_hash: await getTokenHash(token.api_token), pubkey: token.user_pubkey, - nip46_sk_enc: await encryptSecretKey(Conf.seckey, token.server_seckey), + nip46_sk_enc: await aesEncrypt(Conf.seckey, token.server_seckey), nip46_relays: JSON.parse(token.relays), created_at: token.connected_at, }).execute(); diff --git a/src/entities/MastodonAccount.ts b/src/entities/MastodonAccount.ts index d0514185..b713d70c 100644 --- a/src/entities/MastodonAccount.ts +++ b/src/entities/MastodonAccount.ts @@ -34,6 +34,9 @@ export interface MastodonAccount { nostr: { nip05?: string; }; + ditto: { + captcha_solved: boolean; + }; }; statuses_count: number; uri: string; diff --git a/src/middleware/signerMiddleware.ts b/src/middleware/signerMiddleware.ts index 8200ae1d..b4cab1ec 100644 --- a/src/middleware/signerMiddleware.ts +++ b/src/middleware/signerMiddleware.ts @@ -7,7 +7,8 @@ import { Conf } from '@/config.ts'; import { ConnectSigner } from '@/signers/ConnectSigner.ts'; import { ReadOnlySigner } from '@/signers/ReadOnlySigner.ts'; import { Storages } from '@/storages.ts'; -import { decryptSecretKey, getTokenHash } from '@/utils/auth.ts'; +import { aesDecrypt } from '@/utils/aes.ts'; +import { getTokenHash } from '@/utils/auth.ts'; /** We only accept "Bearer" type. */ const BEARER_REGEX = new RegExp(`^Bearer (${nip19.BECH32_REGEX.source})$`); @@ -31,7 +32,7 @@ export const signerMiddleware: AppMiddleware = async (c, next) => { .where('token_hash', '=', tokenHash) .executeTakeFirstOrThrow(); - const nep46Seckey = await decryptSecretKey(Conf.seckey, nip46_sk_enc); + const nep46Seckey = await aesDecrypt(Conf.seckey, nip46_sk_enc); c.set('signer', new ConnectSigner(pubkey, new NSecSigner(nep46Seckey), nip46_relays)); } catch { diff --git a/src/trends.test.ts b/src/trends.test.ts new file mode 100644 index 00000000..66cae23b --- /dev/null +++ b/src/trends.test.ts @@ -0,0 +1,105 @@ +import { assertEquals } from '@std/assert'; +import { generateSecretKey, NostrEvent } from 'nostr-tools'; + +import { getTrendingTagValues } from '@/trends.ts'; +import { createTestDB, genEvent } from '@/test.ts'; + +Deno.test("getTrendingTagValues(): 'e' tag and WITHOUT language parameter", async () => { + await using db = await createTestDB(); + + const events: NostrEvent[] = []; + + let sk = generateSecretKey(); + const post1 = genEvent({ kind: 1, content: 'SHOW ME THE MONEY' }, sk); + const numberOfAuthorsWhoLikedPost1 = 100; + const post1multiplier = 2; + const post1uses = numberOfAuthorsWhoLikedPost1 * post1multiplier; + for (let i = 0; i < numberOfAuthorsWhoLikedPost1; i++) { + const sk = generateSecretKey(); + events.push( + genEvent({ kind: 7, content: '+', tags: Array(post1multiplier).fill([...['e', post1.id]]) }, sk), + ); + } + events.push(post1); + + sk = generateSecretKey(); + const post2 = genEvent({ kind: 1, content: 'Ithaca' }, sk); + const numberOfAuthorsWhoLikedPost2 = 100; + const post2multiplier = 1; + const post2uses = numberOfAuthorsWhoLikedPost2 * post2multiplier; + for (let i = 0; i < numberOfAuthorsWhoLikedPost2; i++) { + const sk = generateSecretKey(); + events.push( + genEvent({ kind: 7, content: '+', tags: Array(post2multiplier).fill([...['e', post2.id]]) }, sk), + ); + } + events.push(post2); + + for (const event of events) { + await db.store.event(event); + } + + const trends = await getTrendingTagValues(db.kysely, ['e'], { kinds: [1, 7] }); + + const expected = [{ value: post1.id, authors: numberOfAuthorsWhoLikedPost1, uses: post1uses }, { + value: post2.id, + authors: numberOfAuthorsWhoLikedPost2, + uses: post2uses, + }]; + + assertEquals(trends, expected); +}); + +Deno.test("getTrendingTagValues(): 'e' tag and WITH language parameter", async () => { + await using db = await createTestDB(); + + const events: NostrEvent[] = []; + + let sk = generateSecretKey(); + const post1 = genEvent({ kind: 1, content: 'Irei cortar o cabelo.' }, sk); + const numberOfAuthorsWhoLikedPost1 = 100; + const post1multiplier = 2; + const post1uses = numberOfAuthorsWhoLikedPost1 * post1multiplier; + for (let i = 0; i < numberOfAuthorsWhoLikedPost1; i++) { + const sk = generateSecretKey(); + events.push( + genEvent({ kind: 7, content: '+', tags: Array(post1multiplier).fill([...['e', post1.id]]) }, sk), + ); + } + events.push(post1); + + sk = generateSecretKey(); + const post2 = genEvent({ kind: 1, content: 'Ithaca' }, sk); + const numberOfAuthorsWhoLikedPost2 = 100; + const post2multiplier = 1; + for (let i = 0; i < numberOfAuthorsWhoLikedPost2; i++) { + const sk = generateSecretKey(); + events.push( + genEvent({ kind: 7, content: '+', tags: Array(post2multiplier).fill([...['e', post2.id]]) }, sk), + ); + } + events.push(post2); + + for (const event of events) { + await db.store.event(event); + } + + await db.kysely.updateTable('nostr_events') + .set('language', 'pt') + .where('id', '=', post1.id) + .execute(); + + await db.kysely.updateTable('nostr_events') + .set('language', 'en') + .where('id', '=', post2.id) + .execute(); + + const languagesIds = (await db.store.query([{ search: 'language:pt' }])).map((event) => event.id); + + const trends = await getTrendingTagValues(db.kysely, ['e'], { kinds: [1, 7] }, languagesIds); + + // portuguese post + const expected = [{ value: post1.id, authors: numberOfAuthorsWhoLikedPost1, uses: post1uses }]; + + assertEquals(trends, expected); +}); diff --git a/src/trends.ts b/src/trends.ts index 23f7ea4d..cf4f7c96 100644 --- a/src/trends.ts +++ b/src/trends.ts @@ -19,6 +19,8 @@ export async function getTrendingTagValues( tagNames: string[], /** Filter of eligible events. */ filter: NostrFilter, + /** If present, only tag values in this list are permitted to trend. */ + values?: string[], ): Promise<{ value: string; authors: number; uses: number }[]> { let query = kysely .selectFrom([ @@ -33,7 +35,7 @@ export async function getTrendingTagValues( ]) .where('kv.key', '=', (eb) => eb.fn.any(eb.val(tagNames))) .groupBy((eb) => eb.fn('lower', ['element.value'])) - .orderBy((eb) => eb.fn.agg('count', ['nostr_events.pubkey']).distinct(), 'desc'); + .orderBy('authors desc').orderBy('uses desc'); if (filter.kinds) { query = query.where('nostr_events.kind', '=', ({ fn, val }) => fn.any(val(filter.kinds))); @@ -47,6 +49,9 @@ export async function getTrendingTagValues( if (typeof filter.until === 'number') { query = query.where('nostr_events.created_at', '<=', filter.until); } + if (values) { + query = query.where('element.value', 'in', values); + } if (typeof filter.limit === 'number') { query = query.limit(filter.limit); } @@ -68,6 +73,7 @@ export async function updateTrendingTags( limit: number, extra = '', aliases?: string[], + values?: string[], ) { console.info(`Updating trending ${l}...`); const kysely = await Storages.kysely(); @@ -84,8 +90,9 @@ export async function updateTrendingTags( since: yesterday, until: now, limit, - }); + }, values); + console.log(trends); if (!trends.length) { console.info(`No trending ${l} found. Skipping.`); return; @@ -122,8 +129,31 @@ export function updateTrendingZappedEvents(): Promise { } /** Update trending events. */ -export function updateTrendingEvents(): Promise { - return updateTrendingTags('#e', 'e', [1, 6, 7, 9735], 40, Conf.relay, ['q']); +export async function updateTrendingEvents(): Promise { + const results: Promise[] = [ + updateTrendingTags('#e', 'e', [1, 6, 7, 9735], 40, Conf.relay, ['q']), + ]; + + const kysely = await Storages.kysely(); + + for (const language of Conf.preferredLanguages ?? []) { + const yesterday = Math.floor((Date.now() - Time.days(1)) / 1000); + const now = Math.floor(Date.now() / 1000); + + const rows = await kysely + .selectFrom('nostr_events') + .select('nostr_events.id') + .where('nostr_events.language', '=', language) + .where('nostr_events.created_at', '>=', yesterday) + .where('nostr_events.created_at', '<=', now) + .execute(); + + const ids = rows.map((row) => row.id); + + results.push(updateTrendingTags(`#e.${language}`, 'e', [1, 6, 7, 9735], 40, Conf.relay, ['q'], ids)); + } + + await Promise.allSettled(results); } /** Update trending hashtags. */ diff --git a/src/utils/aes.bench.ts b/src/utils/aes.bench.ts new file mode 100644 index 00000000..9d8dd3b9 --- /dev/null +++ b/src/utils/aes.bench.ts @@ -0,0 +1,18 @@ +import { generateSecretKey } from 'nostr-tools'; + +import { aesDecrypt, aesEncrypt } from '@/utils/aes.ts'; + +Deno.bench('aesEncrypt', async (b) => { + const sk = generateSecretKey(); + const decrypted = generateSecretKey(); + b.start(); + await aesEncrypt(sk, decrypted); +}); + +Deno.bench('aesDecrypt', async (b) => { + const sk = generateSecretKey(); + const decrypted = generateSecretKey(); + const encrypted = await aesEncrypt(sk, decrypted); + b.start(); + await aesDecrypt(sk, encrypted); +}); diff --git a/src/utils/aes.test.ts b/src/utils/aes.test.ts new file mode 100644 index 00000000..583b96a2 --- /dev/null +++ b/src/utils/aes.test.ts @@ -0,0 +1,15 @@ +import { assertEquals } from '@std/assert'; +import { encodeHex } from '@std/encoding/hex'; +import { generateSecretKey } from 'nostr-tools'; + +import { aesDecrypt, aesEncrypt } from '@/utils/aes.ts'; + +Deno.test('aesDecrypt & aesEncrypt', async () => { + const sk = generateSecretKey(); + const data = generateSecretKey(); + + const encrypted = await aesEncrypt(sk, data); + const decrypted = await aesDecrypt(sk, encrypted); + + assertEquals(encodeHex(decrypted), encodeHex(data)); +}); diff --git a/src/utils/aes.ts b/src/utils/aes.ts new file mode 100644 index 00000000..983fc39c --- /dev/null +++ b/src/utils/aes.ts @@ -0,0 +1,17 @@ +/** Encrypt data with AES-GCM and a secret key. */ +export async function aesEncrypt(sk: Uint8Array, plaintext: Uint8Array): Promise { + const secretKey = await crypto.subtle.importKey('raw', sk, { name: 'AES-GCM' }, false, ['encrypt']); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const buffer = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, secretKey, plaintext); + + return new Uint8Array([...iv, ...new Uint8Array(buffer)]); +} + +/** Decrypt data with AES-GCM and a secret key. */ +export async function aesDecrypt(sk: Uint8Array, ciphertext: Uint8Array): Promise { + const secretKey = await crypto.subtle.importKey('raw', sk, { name: 'AES-GCM' }, false, ['decrypt']); + const iv = ciphertext.slice(0, 12); + const buffer = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, secretKey, ciphertext.slice(12)); + + return new Uint8Array(buffer); +} diff --git a/src/utils/auth.bench.ts b/src/utils/auth.bench.ts index 8c3da7cf..fbffc857 100644 --- a/src/utils/auth.bench.ts +++ b/src/utils/auth.bench.ts @@ -1,6 +1,4 @@ -import { generateSecretKey } from 'nostr-tools'; - -import { decryptSecretKey, encryptSecretKey, generateToken, getTokenHash } from '@/utils/auth.ts'; +import { generateToken, getTokenHash } from '@/utils/auth.ts'; Deno.bench('generateToken', async () => { await generateToken(); @@ -11,18 +9,3 @@ Deno.bench('getTokenHash', async (b) => { b.start(); await getTokenHash(token); }); - -Deno.bench('encryptSecretKey', async (b) => { - const sk = generateSecretKey(); - const decrypted = generateSecretKey(); - b.start(); - await encryptSecretKey(sk, decrypted); -}); - -Deno.bench('decryptSecretKey', async (b) => { - const sk = generateSecretKey(); - const decrypted = generateSecretKey(); - const encrypted = await encryptSecretKey(sk, decrypted); - b.start(); - await decryptSecretKey(sk, encrypted); -}); diff --git a/src/utils/auth.test.ts b/src/utils/auth.test.ts index e9e610c1..cf462d5d 100644 --- a/src/utils/auth.test.ts +++ b/src/utils/auth.test.ts @@ -1,8 +1,7 @@ import { assertEquals } from '@std/assert'; import { decodeHex, encodeHex } from '@std/encoding/hex'; -import { generateSecretKey } from 'nostr-tools'; -import { decryptSecretKey, encryptSecretKey, generateToken, getTokenHash } from '@/utils/auth.ts'; +import { generateToken, getTokenHash } from '@/utils/auth.ts'; Deno.test('generateToken', async () => { const sk = decodeHex('a0968751df8fd42f362213f08751911672f2a037113b392403bbb7dd31b71c95'); @@ -17,13 +16,3 @@ Deno.test('getTokenHash', async () => { const hash = await getTokenHash('token15ztgw5wl3l2z7d3zz0cgw5v3zee09gphzyanjfqrhwma6vdhrj2sauwknd'); assertEquals(encodeHex(hash), 'ab4c4ead4d1c72a38fffd45b999937b7e3f25f867b19aaf252df858e77b66a8a'); }); - -Deno.test('encryptSecretKey & decryptSecretKey', async () => { - const sk = generateSecretKey(); - const data = generateSecretKey(); - - const encrypted = await encryptSecretKey(sk, data); - const decrypted = await decryptSecretKey(sk, encrypted); - - assertEquals(encodeHex(decrypted), encodeHex(data)); -}); diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 05e838a9..8d71ed6f 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -28,27 +28,3 @@ export async function getTokenHash(token: `token1${string}`): Promise { - const secretKey = await crypto.subtle.importKey('raw', sk, { name: 'AES-GCM' }, false, ['encrypt']); - const iv = crypto.getRandomValues(new Uint8Array(12)); - const buffer = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, secretKey, decrypted); - - return new Uint8Array([...iv, ...new Uint8Array(buffer)]); -} - -/** - * Decrypt a secret key with AES-GCM. - * This function is used to retrieve the secret key from the database. - */ -export async function decryptSecretKey(sk: Uint8Array, encrypted: Uint8Array): Promise { - const secretKey = await crypto.subtle.importKey('raw', sk, { name: 'AES-GCM' }, false, ['decrypt']); - const iv = encrypted.slice(0, 12); - const buffer = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, secretKey, encrypted.slice(12)); - - return new Uint8Array(buffer); -} diff --git a/src/utils/lnurl.ts b/src/utils/lnurl.ts index 64e10fe3..1dd99769 100644 --- a/src/utils/lnurl.ts +++ b/src/utils/lnurl.ts @@ -1,5 +1,5 @@ import { LNURL, LNURLDetails } from '@nostrify/nostrify/ln'; -import Debug from '@soapbox/stickynotes/debug'; +import { Stickynotes } from '@soapbox/stickynotes'; import { cachedLnurlsSizeGauge } from '@/metrics.ts'; import { SimpleLRU } from '@/utils/SimpleLRU.ts'; @@ -7,17 +7,17 @@ import { Time } from '@/utils/time.ts'; import { fetchWorker } from '@/workers/fetch.ts'; import { NostrEvent } from '@nostrify/nostrify'; -const debug = Debug('ditto:lnurl'); +const console = new Stickynotes('ditto:lnurl'); const lnurlCache = new SimpleLRU( async (lnurl, { signal }) => { - debug(`Lookup ${lnurl}`); + console.debug(`Lookup ${lnurl}`); try { const result = await LNURL.lookup(lnurl, { fetch: fetchWorker, signal }); - debug(`Found: ${lnurl}`); + console.debug(`Found: ${lnurl}`); return result; } catch (e) { - debug(`Not found: ${lnurl}`); + console.debug(`Not found: ${lnurl}`); throw e; } }, diff --git a/src/utils/media.test.ts b/src/utils/media.test.ts index e88e97da..39abed23 100644 --- a/src/utils/media.test.ts +++ b/src/utils/media.test.ts @@ -7,6 +7,10 @@ Deno.test('getUrlMediaType', () => { assertEquals(getUrlMediaType('https://example.com/index.html'), 'text/html'); assertEquals(getUrlMediaType('https://example.com/yolo'), undefined); assertEquals(getUrlMediaType('https://example.com/'), undefined); + assertEquals( + getUrlMediaType('https://gitlab.com/soapbox-pub/nostrify/-/blob/main/packages/policies/WoTPolicy.ts'), + 'application/typescript', + ); }); Deno.test('isPermittedMediaType', () => { diff --git a/src/utils/media.ts b/src/utils/media.ts index 9c0ea9e3..82c9832d 100644 --- a/src/utils/media.ts +++ b/src/utils/media.ts @@ -1,4 +1,4 @@ -import { typeByExtension } from '@std/media-types'; +import { typeByExtension as _typeByExtension } from '@std/media-types'; /** Get media type of the filename in the URL by its extension, if any. */ export function getUrlMediaType(url: string): string | undefined { @@ -22,3 +22,13 @@ export function isPermittedMediaType(mediaType: string, permitted: string[]): bo const [baseType, _subType] = mediaType.split('/'); return permitted.includes(baseType); } + +/** Custom type-by-extension with overrides. */ +function typeByExtension(ext: string): string | undefined { + switch (ext) { + case 'ts': + return 'application/typescript'; + default: + return _typeByExtension(ext); + } +} diff --git a/src/views/mastodon/accounts.ts b/src/views/mastodon/accounts.ts index b3efdc82..1e10c4ff 100644 --- a/src/views/mastodon/accounts.ts +++ b/src/views/mastodon/accounts.ts @@ -87,6 +87,9 @@ async function renderAccount( nostr: { nip05, }, + ditto: { + captcha_solved: false, + }, } : undefined, statuses_count: event.author_stats?.notes_count ?? 0, diff --git a/src/workers/policy.ts b/src/workers/policy.ts index 941b5cf1..debfe242 100644 --- a/src/workers/policy.ts +++ b/src/workers/policy.ts @@ -50,7 +50,7 @@ class PolicyWorker implements NPolicy { await this.worker.init({ path: Conf.policy, databaseUrl: Conf.databaseUrl, - adminPubkey: Conf.pubkey, + pubkey: Conf.pubkey, }); console.warn(`Using custom policy: ${Conf.policy}`); diff --git a/src/workers/policy.worker.ts b/src/workers/policy.worker.ts index 6b256a21..86cea87c 100644 --- a/src/workers/policy.worker.ts +++ b/src/workers/policy.worker.ts @@ -18,7 +18,7 @@ interface PolicyInit { /** Database URL to connect to. */ databaseUrl: string; /** Admin pubkey to use for EventsDB checks. */ - adminPubkey: string; + pubkey: string; } export class CustomPolicy implements NPolicy { @@ -29,18 +29,18 @@ export class CustomPolicy implements NPolicy { return this.policy.call(event, signal); } - async init({ path, databaseUrl, adminPubkey }: PolicyInit): Promise { + async init({ path, databaseUrl, pubkey }: PolicyInit): Promise { const Policy = (await import(path)).default; const { kysely } = DittoDB.create(databaseUrl, { poolSize: 1 }); const store = new EventsDB({ kysely, - pubkey: adminPubkey, + pubkey, timeout: 1_000, }); - this.policy = new Policy({ store }); + this.policy = new Policy({ store, pubkey }); } }