Merge remote-tracking branch 'origin/main' into push
|
|
@ -1,8 +1,10 @@
|
||||||
FROM denoland/deno:1.44.2
|
FROM denoland/deno:1.44.2
|
||||||
EXPOSE 4036
|
ENV PORT 5000
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN mkdir -p data && chown -R deno data
|
RUN mkdir -p data && chown -R deno data
|
||||||
USER deno
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN deno cache src/server.ts
|
RUN deno cache src/server.ts
|
||||||
|
RUN apt-get update && apt-get install -y unzip curl
|
||||||
|
RUN deno task soapbox
|
||||||
CMD deno task start
|
CMD deno task start
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@
|
||||||
"@b-fuze/deno-dom": "jsr:@b-fuze/deno-dom@^0.1.47",
|
"@b-fuze/deno-dom": "jsr:@b-fuze/deno-dom@^0.1.47",
|
||||||
"@bradenmacdonald/s3-lite-client": "jsr:@bradenmacdonald/s3-lite-client@^0.7.4",
|
"@bradenmacdonald/s3-lite-client": "jsr:@bradenmacdonald/s3-lite-client@^0.7.4",
|
||||||
"@electric-sql/pglite": "npm:@electric-sql/pglite@^0.2.8",
|
"@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",
|
"@hono/hono": "jsr:@hono/hono@^4.4.6",
|
||||||
"@isaacs/ttlcache": "npm:@isaacs/ttlcache@^1.4.1",
|
"@isaacs/ttlcache": "npm:@isaacs/ttlcache@^1.4.1",
|
||||||
"@lambdalisue/async": "jsr:@lambdalisue/async@^2.1.1",
|
"@lambdalisue/async": "jsr:@lambdalisue/async@^2.1.1",
|
||||||
|
|
@ -44,6 +45,7 @@
|
||||||
"@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.36.0",
|
"@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.36.0",
|
||||||
"@nostrify/policies": "jsr:@nostrify/policies@^0.35.0",
|
"@nostrify/policies": "jsr:@nostrify/policies@^0.35.0",
|
||||||
"@scure/base": "npm:@scure/base@^1.1.6",
|
"@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",
|
"@sentry/deno": "https://deno.land/x/sentry@7.112.2/index.mjs",
|
||||||
"@soapbox/kysely-pglite": "jsr:@soapbox/kysely-pglite@^1.0.0",
|
"@soapbox/kysely-pglite": "jsr:@soapbox/kysely-pglite@^1.0.0",
|
||||||
"@soapbox/stickynotes": "jsr:@soapbox/stickynotes@^0.4.0",
|
"@soapbox/stickynotes": "jsr:@soapbox/stickynotes@^0.4.0",
|
||||||
|
|
|
||||||
36
deno.lock
generated
|
|
@ -5,6 +5,7 @@
|
||||||
"jsr:@b-fuze/deno-dom@^0.1.47": "jsr:@b-fuze/deno-dom@0.1.48",
|
"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:@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:@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": "jsr:@gleasonator/policy@0.2.0",
|
||||||
"jsr:@gleasonator/policy@0.2.0": "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",
|
"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/crypto@^0.224.0": "jsr:@std/crypto@0.224.0",
|
||||||
"jsr:@std/dotenv@^0.224.0": "jsr:@std/dotenv@0.224.2",
|
"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@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.0": "jsr:@std/encoding@0.224.3",
|
||||||
"jsr:@std/encoding@^0.224.1": "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",
|
"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:@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/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.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:@scure/bip39@^1.3.0": "npm:@scure/bip39@1.3.0",
|
||||||
"npm:@types/node": "npm:@types/node@18.16.19",
|
"npm:@types/node": "npm:@types/node@18.16.19",
|
||||||
"npm:comlink-async-generator": "npm:comlink-async-generator@0.0.1",
|
"npm:comlink-async-generator": "npm:comlink-async-generator@0.0.1",
|
||||||
|
|
@ -137,6 +140,12 @@
|
||||||
"jsr:@std/path@0.213.1"
|
"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": {
|
"@gleasonator/policy@0.2.0": {
|
||||||
"integrity": "3fe58b853ab203b2b67e65b64391dbcf5c07bc1caaf46e97b2f8ed5b14f30fdf",
|
"integrity": "3fe58b853ab203b2b67e65b64391dbcf5c07bc1caaf46e97b2f8ed5b14f30fdf",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
|
|
@ -469,6 +478,9 @@
|
||||||
"@std/encoding@0.224.3": {
|
"@std/encoding@0.224.3": {
|
||||||
"integrity": "5e861b6d81be5359fad4155e591acf17c0207b595112d1840998bb9f476dbdaf"
|
"integrity": "5e861b6d81be5359fad4155e591acf17c0207b595112d1840998bb9f476dbdaf"
|
||||||
},
|
},
|
||||||
|
"@std/encoding@1.0.5": {
|
||||||
|
"integrity": "ecf363d4fc25bd85bd915ff6733a7e79b67e0e7806334af15f4645c569fefc04"
|
||||||
|
},
|
||||||
"@std/fmt@0.213.1": {
|
"@std/fmt@0.213.1": {
|
||||||
"integrity": "a06d31777566d874b9c856c10244ac3e6b660bdec4c82506cd46be052a1082c3"
|
"integrity": "a06d31777566d874b9c856c10244ac3e6b660bdec4c82506cd46be052a1082c3"
|
||||||
},
|
},
|
||||||
|
|
@ -604,6 +616,12 @@
|
||||||
"@noble/hashes": "@noble/hashes@1.4.0"
|
"@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": {
|
"@noble/hashes@1.3.1": {
|
||||||
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
|
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
|
||||||
"dependencies": {}
|
"dependencies": {}
|
||||||
|
|
@ -616,6 +634,10 @@
|
||||||
"integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==",
|
"integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==",
|
||||||
"dependencies": {}
|
"dependencies": {}
|
||||||
},
|
},
|
||||||
|
"@noble/hashes@1.5.0": {
|
||||||
|
"integrity": "sha512-1j6kQFb7QRru7eKN3ZDvRcP13rugwdxZqCjbiAVZfIJwgj2A65UmT4TgARXGlXgnRkORLTDTrO19ZErt7+QXgA==",
|
||||||
|
"dependencies": {}
|
||||||
|
},
|
||||||
"@noble/secp256k1@2.1.0": {
|
"@noble/secp256k1@2.1.0": {
|
||||||
"integrity": "sha512-XLEQQNdablO0XZOIniFQimiXsZDNwaYgL96dZwC54Q30imSbAOFf3NKtepc+cXyuZf5Q1HCgbqgZ2UFFuHVcEw==",
|
"integrity": "sha512-XLEQQNdablO0XZOIniFQimiXsZDNwaYgL96dZwC54Q30imSbAOFf3NKtepc+cXyuZf5Q1HCgbqgZ2UFFuHVcEw==",
|
||||||
"dependencies": {}
|
"dependencies": {}
|
||||||
|
|
@ -632,6 +654,10 @@
|
||||||
"integrity": "sha512-ok9AWwhcgYuGG3Zfhyqg+zwl+Wn5uE+dwC0NV/2qQkx4dABbb/bx96vWu8NSj+BNjjSjno+JRYRjle1jV08k3g==",
|
"integrity": "sha512-ok9AWwhcgYuGG3Zfhyqg+zwl+Wn5uE+dwC0NV/2qQkx4dABbb/bx96vWu8NSj+BNjjSjno+JRYRjle1jV08k3g==",
|
||||||
"dependencies": {}
|
"dependencies": {}
|
||||||
},
|
},
|
||||||
|
"@scure/base@1.1.9": {
|
||||||
|
"integrity": "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==",
|
||||||
|
"dependencies": {}
|
||||||
|
},
|
||||||
"@scure/bip32@1.3.1": {
|
"@scure/bip32@1.3.1": {
|
||||||
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
|
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -648,6 +674,14 @@
|
||||||
"@scure/base": "@scure/base@1.1.6"
|
"@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": {
|
"@scure/bip39@1.2.1": {
|
||||||
"integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==",
|
"integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -2112,6 +2146,7 @@
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"jsr:@b-fuze/deno-dom@^0.1.47",
|
"jsr:@b-fuze/deno-dom@^0.1.47",
|
||||||
"jsr:@bradenmacdonald/s3-lite-client@^0.7.4",
|
"jsr:@bradenmacdonald/s3-lite-client@^0.7.4",
|
||||||
|
"jsr:@gfx/canvas-wasm@^0.4.2",
|
||||||
"jsr:@hono/hono@^4.4.6",
|
"jsr:@hono/hono@^4.4.6",
|
||||||
"jsr:@lambdalisue/async@^2.1.1",
|
"jsr:@lambdalisue/async@^2.1.1",
|
||||||
"jsr:@nostrify/db@^0.35.0",
|
"jsr:@nostrify/db@^0.35.0",
|
||||||
|
|
@ -2132,6 +2167,7 @@
|
||||||
"npm:@isaacs/ttlcache@^1.4.1",
|
"npm:@isaacs/ttlcache@^1.4.1",
|
||||||
"npm:@noble/secp256k1@^2.0.0",
|
"npm:@noble/secp256k1@^2.0.0",
|
||||||
"npm:@scure/base@^1.1.6",
|
"npm:@scure/base@^1.1.6",
|
||||||
|
"npm:@scure/bip32@^1.5.0",
|
||||||
"npm:comlink-async-generator@^0.0.1",
|
"npm:comlink-async-generator@^0.0.1",
|
||||||
"npm:comlink@^4.4.1",
|
"npm:comlink@^4.4.1",
|
||||||
"npm:commander@12.1.0",
|
"npm:commander@12.1.0",
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ export function buildFilter(args: ExportFilter) {
|
||||||
if (invalid) throw new Error(`ERROR: Invalid pubkey ${invalid} supplied.`);
|
if (invalid) throw new Error(`ERROR: Invalid pubkey ${invalid} supplied.`);
|
||||||
filter.authors = authors;
|
filter.authors = authors;
|
||||||
}
|
}
|
||||||
if (ids) {
|
if (ids && ids.length) {
|
||||||
const invalid = findInvalid(ids);
|
const invalid = findInvalid(ids);
|
||||||
if (invalid) throw new Error(`ERROR: Invalid event ID ${invalid} supplied.`);
|
if (invalid) throw new Error(`ERROR: Invalid event ID ${invalid} supplied.`);
|
||||||
filter.ids = ids;
|
filter.ids = ids;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import '@/config.ts';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
updateTrendingEvents,
|
updateTrendingEvents,
|
||||||
|
|
|
||||||
46
src/DittoWallet.ts
Normal file
|
|
@ -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<string, HDKey>();
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/app.ts
|
|
@ -5,6 +5,8 @@ import { logger } from '@hono/hono/logger';
|
||||||
import { NostrEvent, NostrSigner, NStore, NUploader } from '@nostrify/nostrify';
|
import { NostrEvent, NostrSigner, NStore, NUploader } from '@nostrify/nostrify';
|
||||||
import Debug from '@soapbox/stickynotes/debug';
|
import Debug from '@soapbox/stickynotes/debug';
|
||||||
|
|
||||||
|
import '@/startup.ts';
|
||||||
|
|
||||||
import { Time } from '@/utils/time.ts';
|
import { Time } from '@/utils/time.ts';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -36,6 +38,7 @@ import {
|
||||||
import { appCredentialsController, createAppController } from '@/controllers/api/apps.ts';
|
import { appCredentialsController, createAppController } from '@/controllers/api/apps.ts';
|
||||||
import { blocksController } from '@/controllers/api/blocks.ts';
|
import { blocksController } from '@/controllers/api/blocks.ts';
|
||||||
import { bookmarksController } from '@/controllers/api/bookmarks.ts';
|
import { bookmarksController } from '@/controllers/api/bookmarks.ts';
|
||||||
|
import { captchaController, captchaVerifyController } from '@/controllers/api/captcha.ts';
|
||||||
import {
|
import {
|
||||||
adminRelaysController,
|
adminRelaysController,
|
||||||
adminSetRelaysController,
|
adminSetRelaysController,
|
||||||
|
|
@ -114,7 +117,6 @@ import { errorHandler } from '@/controllers/error.ts';
|
||||||
import { frontendController } from '@/controllers/frontend.ts';
|
import { frontendController } from '@/controllers/frontend.ts';
|
||||||
import { metricsController } from '@/controllers/metrics.ts';
|
import { metricsController } from '@/controllers/metrics.ts';
|
||||||
import { indexController } from '@/controllers/site.ts';
|
import { indexController } from '@/controllers/site.ts';
|
||||||
import '@/startup.ts';
|
|
||||||
import { manifestController } from '@/controllers/manifest.ts';
|
import { manifestController } from '@/controllers/manifest.ts';
|
||||||
import { nodeInfoController, nodeInfoSchemaController } from '@/controllers/well-known/nodeinfo.ts';
|
import { nodeInfoController, nodeInfoSchemaController } from '@/controllers/well-known/nodeinfo.ts';
|
||||||
import { nostrController } from '@/controllers/well-known/nostr.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.post('/api/v1/ditto/names', requireSigner, nameRequestController);
|
||||||
app.get('/api/v1/ditto/names', requireSigner, nameRequestsController);
|
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/zap_splits', getZapSplitsController);
|
||||||
app.get('/api/v1/ditto/:id{[0-9a-f]{64}}/zap_splits', statusZapSplitsController);
|
app.get('/api/v1/ditto/:id{[0-9a-f]{64}}/zap_splits', statusZapSplitsController);
|
||||||
|
|
||||||
|
|
|
||||||
|
After Width: | Height: | Size: 26 KiB |
BIN
src/assets/captcha/bg/A Trail of Footprints In The Sand.jpg
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src/assets/captcha/bg/Ashim DSilva.jpg
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
src/assets/captcha/bg/Canazei Granite Ridges.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
src/assets/captcha/bg/Martin Adams.jpg
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
BIN
src/assets/captcha/bg/Morskie Oko.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
src/assets/captcha/bg/Mr. Lee.jpg
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src/assets/captcha/bg/Nattu Adnan.jpg
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
src/assets/captcha/bg/Photo by SpaceX.jpg
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
src/assets/captcha/bg/Photo of Valley.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
src/assets/captcha/bg/Snow-Capped Mountain.jpg
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
src/assets/captcha/bg/Sunset by the Pier.jpg
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src/assets/captcha/bg/Tj Holowaychuk.jpg
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
src/assets/captcha/bg/Viktor Forgacs.jpg
Normal file
|
After Width: | Height: | Size: 26 KiB |
22
src/assets/captcha/bg/copyright.txt
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
Unsplash photos published before June 8, 2017 are CC0 (public domain):
|
||||||
|
|
||||||
|
Ashim D'Silva <https://unsplash.com/photos/WeYamle9fDM>
|
||||||
|
Canazei Granite Ridges <https://unsplash.com/photos/yrwpJwDNSHE>
|
||||||
|
Mr. Lee <https://unsplash.com/photos/v7r8kZStqFw>
|
||||||
|
Photo by SpaceX <https://unsplash.com/photos/VBNb52J8Trk>
|
||||||
|
Sunset by the Pier <https://unsplash.com/photos/ces8_Bo7bhQ>
|
||||||
|
|
||||||
|
Unsplash photos published on or after June 8, 2017 are free to use, modify, and redistribute subject to the Unsplash license <https://unsplash.com/license>:
|
||||||
|
|
||||||
|
Martin Adams <https://unsplash.com/photos/MpTdvXlAsVE>
|
||||||
|
Morskie Oko <https://unsplash.com/photos/_1UF_3TlKcQ>
|
||||||
|
Nattu Adnan <https://unsplash.com/photos/Ai2TRdvI6gM>
|
||||||
|
Tj Holowaychuk <https://unsplash.com/photos/iGrsa9rL11o>
|
||||||
|
Viktor Forgacs <https://unsplash.com/photos/q8XSCZYh6D8>
|
||||||
|
“A Large Body of Water Surrounded By Mountains” by Peter Thomas <https://unsplash.com/photos/Dxod5pdRtsk>
|
||||||
|
“A Trail of Footprints In The Sand” by David Emrich <https://unsplash.com/photos/A9mr3TPoj0k>
|
||||||
|
“Photo of Valley” by Aniket Doele <https://unsplash.com/photos/M6XC789HLe8>
|
||||||
|
|
||||||
|
Pexels photos are free to use, modify, and redistribute subject to the Pexels license <https://www.pexels.com/license/>:
|
||||||
|
|
||||||
|
Snow-Capped Mountain <https://www.pexels.com/photo/photo-of-snow-capped-mountain-during-evening-2440024/>
|
||||||
BIN
src/assets/captcha/puzzle-hole.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
23
src/assets/captcha/puzzle-hole.svg
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
class="icon icon-tabler icons-tabler-filled icon-tabler-puzzle"
|
||||||
|
version="1.1"
|
||||||
|
id="svg2"
|
||||||
|
xml:space="preserve"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||||
|
id="defs2" /><path
|
||||||
|
stroke="none"
|
||||||
|
d="M0 0h24v24H0z"
|
||||||
|
fill="none"
|
||||||
|
id="path1" /><path
|
||||||
|
d="M10 2a3 3 0 0 1 2.995 2.824l.005 .176v1h3a2 2 0 0 1 1.995 1.85l.005 .15v3h1a3 3 0 0 1 .176 5.995l-.176 .005h-1v3a2 2 0 0 1 -1.85 1.995l-.15 .005h-3a2 2 0 0 1 -1.995 -1.85l-.005 -.15v-1a1 1 0 0 0 -1.993 -.117l-.007 .117v1a2 2 0 0 1 -1.85 1.995l-.15 .005h-3a2 2 0 0 1 -1.995 -1.85l-.005 -.15v-3a2 2 0 0 1 1.85 -1.995l.15 -.005h1a1 1 0 0 0 .117 -1.993l-.117 -.007h-1a2 2 0 0 1 -1.995 -1.85l-.005 -.15v-3a2 2 0 0 1 1.85 -1.995l.15 -.005h3v-1a3 3 0 0 1 3 -3z"
|
||||||
|
id="path2"
|
||||||
|
style="opacity:0.7" /><path
|
||||||
|
id="path2-3"
|
||||||
|
style="color:#000000;fill:#ffffff;-inkscape-stroke:none;opacity:0.9"
|
||||||
|
d="M 10,1 C 7.80271,1 6,2.80271 6,5 H 3.9824219 l -0.1875,0.00586 -0.019531,0.00195 C 2.2191757,5.1248607 0.99950612,6.4393901 1,8 v 3.017578 l 0.00586,0.1875 0.00195,0.01953 C 1.1244168,12.774924 2.429603,13.991286 3.9824219,14 l -0.1875,0.0059 -0.019531,0.002 C 2.2191756,14.12477 0.99950612,15.43939 1,17 v 3.017578 l 0.00586,0.1875 0.00195,0.01953 C 1.1248607,21.780826 2.4393901,23.000494 4,23 h 3.0175781 l 0.1875,-0.0059 0.019531,-0.002 C 8.7749229,22.875673 9.9912857,21.570397 10,20.017578 l 0.0059,0.1875 0.002,0.01953 C 10.12477,21.780824 11.43939,23.000494 13,23 h 3.017578 l 0.1875,-0.0059 0.01953,-0.002 C 17.780826,22.87523 19.000494,21.56061 19,20 v -2 h 0.01367 l 0.205078,-0.0059 h 0.01563 c 1.21751,-0.07037 2.231032,-0.615044 2.871094,-1.396485 0.64006,-0.78144 0.924537,-1.759302 0.896484,-2.714844 C 22.973903,12.92727 22.63402,11.969108 21.949219,11.226562 21.264413,10.484057 20.219542,9.9988247 19,10 V 7.9824219 l -0.0059,-0.1875 -0.002,-0.019531 C 18.87523,6.2191757 17.56061,4.9995061 16,5 H 14 V 4.9863281 L 13.994141,4.78125 V 4.765625 C 13.870457,2.6610112 12.108245,0.99988256 10,1 Z m 0,1 a 3,3 0 0 1 2.994141,2.8242188 L 13,5 v 1 h 3 a 2,2 0 0 1 1.994141,1.8496094 L 18,8 v 3 h 1 a 3,3 0 0 1 0.175781,5.994141 L 19,17 h -1 v 3 a 2,2 0 0 1 -1.849609,1.994141 L 16,22 H 13 A 2,2 0 0 1 11.005859,20.150391 L 11,20 V 19 A 1,1 0 0 0 9.0078125,18.882812 L 9,19 v 1 A 2,2 0 0 1 7.1503906,21.994141 L 7,22 H 4 A 2,2 0 0 1 2.0058594,20.150391 L 2,20 V 17 A 2,2 0 0 1 3.8496094,15.005859 L 4,15 H 5 A 1,1 0 0 0 5.1171875,13.007812 L 5,13 H 4 A 2,2 0 0 1 2.0058594,11.150391 L 2,11 V 8 A 2,2 0 0 1 3.8496094,6.0058594 L 4,6 H 7 V 5 a 3,3 0 0 1 3,-3 z" /></svg>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
BIN
src/assets/captcha/puzzle-mask.png
Normal file
|
After Width: | Height: | Size: 997 B |
1
src/assets/captcha/puzzle-mask.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" class="icon icon-tabler icons-tabler-filled icon-tabler-puzzle"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10 2a3 3 0 0 1 2.995 2.824l.005 .176v1h3a2 2 0 0 1 1.995 1.85l.005 .15v3h1a3 3 0 0 1 .176 5.995l-.176 .005h-1v3a2 2 0 0 1 -1.85 1.995l-.15 .005h-3a2 2 0 0 1 -1.995 -1.85l-.005 -.15v-1a1 1 0 0 0 -1.993 -.117l-.007 .117v1a2 2 0 0 1 -1.85 1.995l-.15 .005h-3a2 2 0 0 1 -1.995 -1.85l-.005 -.15v-3a2 2 0 0 1 1.85 -1.995l.15 -.005h1a1 1 0 0 0 .117 -1.993l-.117 -.007h-1a2 2 0 0 1 -1.995 -1.85l-.005 -.15v-3a2 2 0 0 1 1.85 -1.995l.15 -.005h3v-1a3 3 0 0 1 3 -3z" /></svg>
|
||||||
|
After Width: | Height: | Size: 696 B |
|
|
@ -1,4 +1,5 @@
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
|
import ISO6391, { LanguageCode } from 'iso-639-1';
|
||||||
import * as dotenv from '@std/dotenv';
|
import * as dotenv from '@std/dotenv';
|
||||||
import { getPublicKey, nip19 } from 'nostr-tools';
|
import { getPublicKey, nip19 } from 'nostr-tools';
|
||||||
import { z } from 'zod';
|
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. */
|
/** Character limit to enforce for posts made through Mastodon API. */
|
||||||
static get postCharLimit(): number {
|
static get postCharLimit(): number {
|
||||||
return Number(Deno.env.get('POST_CHAR_LIMIT') || 5000);
|
return Number(Deno.env.get('POST_CHAR_LIMIT') || 5000);
|
||||||
|
|
@ -247,6 +267,10 @@ class Conf {
|
||||||
static get zapSplitsEnabled(): boolean {
|
static get zapSplitsEnabled(): boolean {
|
||||||
return optionalBooleanSchema.parse(Deno.env.get('ZAP_SPLITS_ENABLED')) ?? false;
|
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. */
|
/** Cache settings. */
|
||||||
static caches = {
|
static caches = {
|
||||||
/** NIP-05 cache settings. */
|
/** NIP-05 cache settings. */
|
||||||
|
|
|
||||||
|
|
@ -49,17 +49,26 @@ const verifyCredentialsController: AppController = async (c) => {
|
||||||
const signer = c.get('signer')!;
|
const signer = c.get('signer')!;
|
||||||
const pubkey = await signer.getPublicKey();
|
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) }),
|
getAuthor(pubkey, { signal: AbortSignal.timeout(5000) }),
|
||||||
|
|
||||||
eventsDB.query([{
|
store.query([{
|
||||||
authors: [pubkey],
|
|
||||||
kinds: [30078],
|
kinds: [30078],
|
||||||
|
authors: [pubkey],
|
||||||
'#d': ['pub.ditto.pleroma_settings_store'],
|
'#d': ['pub.ditto.pleroma_settings_store'],
|
||||||
limit: 1,
|
limit: 1,
|
||||||
}]),
|
}]),
|
||||||
|
|
||||||
|
store.query([{
|
||||||
|
kinds: [1985],
|
||||||
|
authors: [Conf.pubkey],
|
||||||
|
'#L': ['pub.ditto.captcha'],
|
||||||
|
'#l': ['solved'],
|
||||||
|
'#p': [pubkey],
|
||||||
|
limit: 1,
|
||||||
|
}]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const account = author
|
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);
|
return c.json(account);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
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 { Storages } from '@/storages.ts';
|
||||||
import { nostrNow } from '@/utils.ts';
|
import { nostrNow } from '@/utils.ts';
|
||||||
import { parseBody } from '@/utils/api.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({
|
const passwordGrantSchema = z.object({
|
||||||
grant_type: z.literal('password'),
|
grant_type: z.literal('password'),
|
||||||
|
|
@ -98,7 +99,7 @@ async function getToken(
|
||||||
await kysely.insertInto('auth_tokens').values({
|
await kysely.insertInto('auth_tokens').values({
|
||||||
token_hash: hash,
|
token_hash: hash,
|
||||||
pubkey,
|
pubkey,
|
||||||
nip46_sk_enc: await encryptSecretKey(Conf.seckey, nip46Seckey),
|
nip46_sk_enc: await aesEncrypt(Conf.seckey, nip46Seckey),
|
||||||
nip46_relays: relays,
|
nip46_relays: relays,
|
||||||
created_at: new Date(),
|
created_at: new Date(),
|
||||||
}).execute();
|
}).execute();
|
||||||
|
|
|
||||||
|
|
@ -88,14 +88,28 @@ const createStatusController: AppController = async (c) => {
|
||||||
return c.json({ error: 'Original post not found.' }, 404);
|
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']);
|
if (root) {
|
||||||
tags.push(['e', data.in_reply_to_id, Conf.relay, 'reply']);
|
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) {
|
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) {
|
if (data.sensitive && data.spoiler_text) {
|
||||||
|
|
@ -143,7 +157,7 @@ const createStatusController: AppController = async (c) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return `nostr:${nip19.npubEncode(pubkey)}`;
|
return `nostr:${nip19.nprofileEncode({ pubkey, relays: [Conf.relay] })}`;
|
||||||
} catch {
|
} catch {
|
||||||
return match;
|
return match;
|
||||||
}
|
}
|
||||||
|
|
@ -159,7 +173,7 @@ const createStatusController: AppController = async (c) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const pubkey of pubkeys) {
|
for (const pubkey of pubkeys) {
|
||||||
tags.push(['p', pubkey]);
|
tags.push(['p', pubkey, Conf.relay]);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const link of linkify.find(data.status ?? '')) {
|
for (const link of linkify.find(data.status ?? '')) {
|
||||||
|
|
@ -175,10 +189,16 @@ const createStatusController: AppController = async (c) => {
|
||||||
.map(({ url }) => url)
|
.map(({ url }) => url)
|
||||||
.filter((url): url is string => Boolean(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 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) {
|
if (Conf.zapSplitsEnabled) {
|
||||||
const meta = n.json().pipe(n.metadata()).catch({}).parse(author?.content);
|
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]);
|
tags.push(['zap', pubkey, Conf.relay, dittoZapSplit[pubkey].weight.toString(), dittoZapSplit[pubkey].message]);
|
||||||
}
|
}
|
||||||
if (totalSplit) {
|
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) {
|
if (event.pubkey === pubkey) {
|
||||||
await createEvent({
|
await createEvent({
|
||||||
kind: 5,
|
kind: 5,
|
||||||
tags: [['e', id, Conf.relay]],
|
tags: [['e', id, Conf.relay, '', pubkey]],
|
||||||
}, c);
|
}, c);
|
||||||
|
|
||||||
const author = await getAuthor(event.pubkey);
|
const author = await getAuthor(event.pubkey);
|
||||||
|
|
@ -281,7 +301,7 @@ const favouriteController: AppController = async (c) => {
|
||||||
kind: 7,
|
kind: 7,
|
||||||
content: '+',
|
content: '+',
|
||||||
tags: [
|
tags: [
|
||||||
['e', target.id, Conf.relay],
|
['e', target.id, Conf.relay, '', target.pubkey],
|
||||||
['p', target.pubkey, Conf.relay],
|
['p', target.pubkey, Conf.relay],
|
||||||
],
|
],
|
||||||
}, c);
|
}, c);
|
||||||
|
|
@ -324,7 +344,7 @@ const reblogStatusController: AppController = async (c) => {
|
||||||
const reblogEvent = await createEvent({
|
const reblogEvent = await createEvent({
|
||||||
kind: 6,
|
kind: 6,
|
||||||
tags: [
|
tags: [
|
||||||
['e', event.id, Conf.relay],
|
['e', event.id, Conf.relay, '', event.pubkey],
|
||||||
['p', event.pubkey, Conf.relay],
|
['p', event.pubkey, Conf.relay],
|
||||||
],
|
],
|
||||||
}, c);
|
}, c);
|
||||||
|
|
@ -361,7 +381,7 @@ const unreblogStatusController: AppController = async (c) => {
|
||||||
|
|
||||||
await createEvent({
|
await createEvent({
|
||||||
kind: 5,
|
kind: 5,
|
||||||
tags: [['e', repostEvent.id, Conf.relay]],
|
tags: [['e', repostEvent.id, Conf.relay, '', repostEvent.pubkey]],
|
||||||
}, c);
|
}, c);
|
||||||
|
|
||||||
return c.json(await renderStatus(event, { viewerPubkey: pubkey }));
|
return c.json(await renderStatus(event, { viewerPubkey: pubkey }));
|
||||||
|
|
@ -413,7 +433,7 @@ const bookmarkController: AppController = async (c) => {
|
||||||
if (event) {
|
if (event) {
|
||||||
await updateListEvent(
|
await updateListEvent(
|
||||||
{ kinds: [10003], authors: [pubkey], limit: 1 },
|
{ kinds: [10003], authors: [pubkey], limit: 1 },
|
||||||
(tags) => addTag(tags, ['e', eventId, Conf.relay]),
|
(tags) => addTag(tags, ['e', event.id, Conf.relay, '', event.pubkey]),
|
||||||
c,
|
c,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -440,7 +460,7 @@ const unbookmarkController: AppController = async (c) => {
|
||||||
if (event) {
|
if (event) {
|
||||||
await updateListEvent(
|
await updateListEvent(
|
||||||
{ kinds: [10003], authors: [pubkey], limit: 1 },
|
{ kinds: [10003], authors: [pubkey], limit: 1 },
|
||||||
(tags) => deleteTag(tags, ['e', eventId, Conf.relay]),
|
(tags) => deleteTag(tags, ['e', event.id, Conf.relay, '', event.pubkey]),
|
||||||
c,
|
c,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -467,7 +487,7 @@ const pinController: AppController = async (c) => {
|
||||||
if (event) {
|
if (event) {
|
||||||
await updateListEvent(
|
await updateListEvent(
|
||||||
{ kinds: [10001], authors: [pubkey], limit: 1 },
|
{ kinds: [10001], authors: [pubkey], limit: 1 },
|
||||||
(tags) => addTag(tags, ['e', eventId, Conf.relay]),
|
(tags) => addTag(tags, ['e', event.id, Conf.relay, '', event.pubkey]),
|
||||||
c,
|
c,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -496,7 +516,7 @@ const unpinController: AppController = async (c) => {
|
||||||
if (event) {
|
if (event) {
|
||||||
await updateListEvent(
|
await updateListEvent(
|
||||||
{ kinds: [10001], authors: [pubkey], limit: 1 },
|
{ kinds: [10001], authors: [pubkey], limit: 1 },
|
||||||
(tags) => deleteTag(tags, ['e', eventId, Conf.relay]),
|
(tags) => deleteTag(tags, ['e', event.id, Conf.relay, '', event.pubkey]),
|
||||||
c,
|
c,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -540,8 +560,8 @@ const zapController: AppController = async (c) => {
|
||||||
lnurl = getLnurl(meta);
|
lnurl = getLnurl(meta);
|
||||||
if (target && lnurl) {
|
if (target && lnurl) {
|
||||||
tags.push(
|
tags.push(
|
||||||
['e', target.id, Conf.relay],
|
['e', target.id, Conf.relay, '', target.pubkey],
|
||||||
['p', target.pubkey],
|
['p', target.pubkey, Conf.relay],
|
||||||
['amount', amount.toString()],
|
['amount', amount.toString()],
|
||||||
['relays', Conf.relay],
|
['relays', Conf.relay],
|
||||||
['lnurl', lnurl],
|
['lnurl', lnurl],
|
||||||
|
|
@ -553,7 +573,7 @@ const zapController: AppController = async (c) => {
|
||||||
lnurl = getLnurl(meta);
|
lnurl = getLnurl(meta);
|
||||||
if (target && lnurl) {
|
if (target && lnurl) {
|
||||||
tags.push(
|
tags.push(
|
||||||
['p', target.pubkey],
|
['p', target.pubkey, Conf.relay],
|
||||||
['amount', amount.toString()],
|
['amount', amount.toString()],
|
||||||
['relays', Conf.relay],
|
['relays', Conf.relay],
|
||||||
['lnurl', lnurl],
|
['lnurl', lnurl],
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { Kysely, sql } from 'kysely';
|
import { Kysely, sql } from 'kysely';
|
||||||
|
|
||||||
import { encryptSecretKey, getTokenHash } from '@/utils/auth.ts';
|
|
||||||
import { Conf } from '@/config.ts';
|
import { Conf } from '@/config.ts';
|
||||||
|
import { aesEncrypt } from '@/utils/aes.ts';
|
||||||
|
import { getTokenHash } from '@/utils/auth.ts';
|
||||||
|
|
||||||
interface DB {
|
interface DB {
|
||||||
nip46_tokens: {
|
nip46_tokens: {
|
||||||
|
|
@ -38,7 +39,7 @@ export async function up(db: Kysely<DB>): Promise<void> {
|
||||||
await db.insertInto('auth_tokens').values({
|
await db.insertInto('auth_tokens').values({
|
||||||
token_hash: await getTokenHash(token.api_token),
|
token_hash: await getTokenHash(token.api_token),
|
||||||
pubkey: token.user_pubkey,
|
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),
|
nip46_relays: JSON.parse(token.relays),
|
||||||
created_at: token.connected_at,
|
created_at: token.connected_at,
|
||||||
}).execute();
|
}).execute();
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,9 @@ export interface MastodonAccount {
|
||||||
nostr: {
|
nostr: {
|
||||||
nip05?: string;
|
nip05?: string;
|
||||||
};
|
};
|
||||||
|
ditto: {
|
||||||
|
captcha_solved: boolean;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
statuses_count: number;
|
statuses_count: number;
|
||||||
uri: string;
|
uri: string;
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@ import { Conf } from '@/config.ts';
|
||||||
import { ConnectSigner } from '@/signers/ConnectSigner.ts';
|
import { ConnectSigner } from '@/signers/ConnectSigner.ts';
|
||||||
import { ReadOnlySigner } from '@/signers/ReadOnlySigner.ts';
|
import { ReadOnlySigner } from '@/signers/ReadOnlySigner.ts';
|
||||||
import { Storages } from '@/storages.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. */
|
/** We only accept "Bearer" type. */
|
||||||
const BEARER_REGEX = new RegExp(`^Bearer (${nip19.BECH32_REGEX.source})$`);
|
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)
|
.where('token_hash', '=', tokenHash)
|
||||||
.executeTakeFirstOrThrow();
|
.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));
|
c.set('signer', new ConnectSigner(pubkey, new NSecSigner(nep46Seckey), nip46_relays));
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
105
src/trends.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -19,6 +19,8 @@ export async function getTrendingTagValues(
|
||||||
tagNames: string[],
|
tagNames: string[],
|
||||||
/** Filter of eligible events. */
|
/** Filter of eligible events. */
|
||||||
filter: NostrFilter,
|
filter: NostrFilter,
|
||||||
|
/** If present, only tag values in this list are permitted to trend. */
|
||||||
|
values?: string[],
|
||||||
): Promise<{ value: string; authors: number; uses: number }[]> {
|
): Promise<{ value: string; authors: number; uses: number }[]> {
|
||||||
let query = kysely
|
let query = kysely
|
||||||
.selectFrom([
|
.selectFrom([
|
||||||
|
|
@ -33,7 +35,7 @@ export async function getTrendingTagValues(
|
||||||
])
|
])
|
||||||
.where('kv.key', '=', (eb) => eb.fn.any(eb.val(tagNames)))
|
.where('kv.key', '=', (eb) => eb.fn.any(eb.val(tagNames)))
|
||||||
.groupBy((eb) => eb.fn<string>('lower', ['element.value']))
|
.groupBy((eb) => eb.fn<string>('lower', ['element.value']))
|
||||||
.orderBy((eb) => eb.fn.agg('count', ['nostr_events.pubkey']).distinct(), 'desc');
|
.orderBy('authors desc').orderBy('uses desc');
|
||||||
|
|
||||||
if (filter.kinds) {
|
if (filter.kinds) {
|
||||||
query = query.where('nostr_events.kind', '=', ({ fn, val }) => fn.any(val(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') {
|
if (typeof filter.until === 'number') {
|
||||||
query = query.where('nostr_events.created_at', '<=', filter.until);
|
query = query.where('nostr_events.created_at', '<=', filter.until);
|
||||||
}
|
}
|
||||||
|
if (values) {
|
||||||
|
query = query.where('element.value', 'in', values);
|
||||||
|
}
|
||||||
if (typeof filter.limit === 'number') {
|
if (typeof filter.limit === 'number') {
|
||||||
query = query.limit(filter.limit);
|
query = query.limit(filter.limit);
|
||||||
}
|
}
|
||||||
|
|
@ -68,6 +73,7 @@ export async function updateTrendingTags(
|
||||||
limit: number,
|
limit: number,
|
||||||
extra = '',
|
extra = '',
|
||||||
aliases?: string[],
|
aliases?: string[],
|
||||||
|
values?: string[],
|
||||||
) {
|
) {
|
||||||
console.info(`Updating trending ${l}...`);
|
console.info(`Updating trending ${l}...`);
|
||||||
const kysely = await Storages.kysely();
|
const kysely = await Storages.kysely();
|
||||||
|
|
@ -84,8 +90,9 @@ export async function updateTrendingTags(
|
||||||
since: yesterday,
|
since: yesterday,
|
||||||
until: now,
|
until: now,
|
||||||
limit,
|
limit,
|
||||||
});
|
}, values);
|
||||||
|
|
||||||
|
console.log(trends);
|
||||||
if (!trends.length) {
|
if (!trends.length) {
|
||||||
console.info(`No trending ${l} found. Skipping.`);
|
console.info(`No trending ${l} found. Skipping.`);
|
||||||
return;
|
return;
|
||||||
|
|
@ -122,8 +129,31 @@ export function updateTrendingZappedEvents(): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update trending events. */
|
/** Update trending events. */
|
||||||
export function updateTrendingEvents(): Promise<void> {
|
export async function updateTrendingEvents(): Promise<void> {
|
||||||
return updateTrendingTags('#e', 'e', [1, 6, 7, 9735], 40, Conf.relay, ['q']);
|
const results: Promise<void>[] = [
|
||||||
|
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. */
|
/** Update trending hashtags. */
|
||||||
|
|
|
||||||
18
src/utils/aes.bench.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
15
src/utils/aes.test.ts
Normal file
|
|
@ -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));
|
||||||
|
});
|
||||||
17
src/utils/aes.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
/** Encrypt data with AES-GCM and a secret key. */
|
||||||
|
export async function aesEncrypt(sk: Uint8Array, plaintext: Uint8Array): Promise<Uint8Array> {
|
||||||
|
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<Uint8Array> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import { generateSecretKey } from 'nostr-tools';
|
import { generateToken, getTokenHash } from '@/utils/auth.ts';
|
||||||
|
|
||||||
import { decryptSecretKey, encryptSecretKey, generateToken, getTokenHash } from '@/utils/auth.ts';
|
|
||||||
|
|
||||||
Deno.bench('generateToken', async () => {
|
Deno.bench('generateToken', async () => {
|
||||||
await generateToken();
|
await generateToken();
|
||||||
|
|
@ -11,18 +9,3 @@ Deno.bench('getTokenHash', async (b) => {
|
||||||
b.start();
|
b.start();
|
||||||
await getTokenHash(token);
|
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);
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { assertEquals } from '@std/assert';
|
import { assertEquals } from '@std/assert';
|
||||||
import { decodeHex, encodeHex } from '@std/encoding/hex';
|
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 () => {
|
Deno.test('generateToken', async () => {
|
||||||
const sk = decodeHex('a0968751df8fd42f362213f08751911672f2a037113b392403bbb7dd31b71c95');
|
const sk = decodeHex('a0968751df8fd42f362213f08751911672f2a037113b392403bbb7dd31b71c95');
|
||||||
|
|
@ -17,13 +16,3 @@ Deno.test('getTokenHash', async () => {
|
||||||
const hash = await getTokenHash('token15ztgw5wl3l2z7d3zz0cgw5v3zee09gphzyanjfqrhwma6vdhrj2sauwknd');
|
const hash = await getTokenHash('token15ztgw5wl3l2z7d3zz0cgw5v3zee09gphzyanjfqrhwma6vdhrj2sauwknd');
|
||||||
assertEquals(encodeHex(hash), 'ab4c4ead4d1c72a38fffd45b999937b7e3f25f867b19aaf252df858e77b66a8a');
|
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));
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -28,27 +28,3 @@ export async function getTokenHash(token: `token1${string}`): Promise<Uint8Array
|
||||||
|
|
||||||
return new Uint8Array(buffer);
|
return new Uint8Array(buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Encrypt a secret key with AES-GCM.
|
|
||||||
* This function is used to store the secret key in the database.
|
|
||||||
*/
|
|
||||||
export async function encryptSecretKey(sk: Uint8Array, decrypted: Uint8Array): Promise<Uint8Array> {
|
|
||||||
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<Uint8Array> {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { LNURL, LNURLDetails } from '@nostrify/nostrify/ln';
|
import { LNURL, LNURLDetails } from '@nostrify/nostrify/ln';
|
||||||
import Debug from '@soapbox/stickynotes/debug';
|
import { Stickynotes } from '@soapbox/stickynotes';
|
||||||
|
|
||||||
import { cachedLnurlsSizeGauge } from '@/metrics.ts';
|
import { cachedLnurlsSizeGauge } from '@/metrics.ts';
|
||||||
import { SimpleLRU } from '@/utils/SimpleLRU.ts';
|
import { SimpleLRU } from '@/utils/SimpleLRU.ts';
|
||||||
|
|
@ -7,17 +7,17 @@ import { Time } from '@/utils/time.ts';
|
||||||
import { fetchWorker } from '@/workers/fetch.ts';
|
import { fetchWorker } from '@/workers/fetch.ts';
|
||||||
import { NostrEvent } from '@nostrify/nostrify';
|
import { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
|
||||||
const debug = Debug('ditto:lnurl');
|
const console = new Stickynotes('ditto:lnurl');
|
||||||
|
|
||||||
const lnurlCache = new SimpleLRU<string, LNURLDetails>(
|
const lnurlCache = new SimpleLRU<string, LNURLDetails>(
|
||||||
async (lnurl, { signal }) => {
|
async (lnurl, { signal }) => {
|
||||||
debug(`Lookup ${lnurl}`);
|
console.debug(`Lookup ${lnurl}`);
|
||||||
try {
|
try {
|
||||||
const result = await LNURL.lookup(lnurl, { fetch: fetchWorker, signal });
|
const result = await LNURL.lookup(lnurl, { fetch: fetchWorker, signal });
|
||||||
debug(`Found: ${lnurl}`);
|
console.debug(`Found: ${lnurl}`);
|
||||||
return result;
|
return result;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debug(`Not found: ${lnurl}`);
|
console.debug(`Not found: ${lnurl}`);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,10 @@ Deno.test('getUrlMediaType', () => {
|
||||||
assertEquals(getUrlMediaType('https://example.com/index.html'), 'text/html');
|
assertEquals(getUrlMediaType('https://example.com/index.html'), 'text/html');
|
||||||
assertEquals(getUrlMediaType('https://example.com/yolo'), undefined);
|
assertEquals(getUrlMediaType('https://example.com/yolo'), undefined);
|
||||||
assertEquals(getUrlMediaType('https://example.com/'), 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', () => {
|
Deno.test('isPermittedMediaType', () => {
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
/** Get media type of the filename in the URL by its extension, if any. */
|
||||||
export function getUrlMediaType(url: string): string | undefined {
|
export function getUrlMediaType(url: string): string | undefined {
|
||||||
|
|
@ -22,3 +22,13 @@ export function isPermittedMediaType(mediaType: string, permitted: string[]): bo
|
||||||
const [baseType, _subType] = mediaType.split('/');
|
const [baseType, _subType] = mediaType.split('/');
|
||||||
return permitted.includes(baseType);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,9 @@ async function renderAccount(
|
||||||
nostr: {
|
nostr: {
|
||||||
nip05,
|
nip05,
|
||||||
},
|
},
|
||||||
|
ditto: {
|
||||||
|
captcha_solved: false,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
statuses_count: event.author_stats?.notes_count ?? 0,
|
statuses_count: event.author_stats?.notes_count ?? 0,
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ class PolicyWorker implements NPolicy {
|
||||||
await this.worker.init({
|
await this.worker.init({
|
||||||
path: Conf.policy,
|
path: Conf.policy,
|
||||||
databaseUrl: Conf.databaseUrl,
|
databaseUrl: Conf.databaseUrl,
|
||||||
adminPubkey: Conf.pubkey,
|
pubkey: Conf.pubkey,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.warn(`Using custom policy: ${Conf.policy}`);
|
console.warn(`Using custom policy: ${Conf.policy}`);
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ interface PolicyInit {
|
||||||
/** Database URL to connect to. */
|
/** Database URL to connect to. */
|
||||||
databaseUrl: string;
|
databaseUrl: string;
|
||||||
/** Admin pubkey to use for EventsDB checks. */
|
/** Admin pubkey to use for EventsDB checks. */
|
||||||
adminPubkey: string;
|
pubkey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CustomPolicy implements NPolicy {
|
export class CustomPolicy implements NPolicy {
|
||||||
|
|
@ -29,18 +29,18 @@ export class CustomPolicy implements NPolicy {
|
||||||
return this.policy.call(event, signal);
|
return this.policy.call(event, signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
async init({ path, databaseUrl, adminPubkey }: PolicyInit): Promise<void> {
|
async init({ path, databaseUrl, pubkey }: PolicyInit): Promise<void> {
|
||||||
const Policy = (await import(path)).default;
|
const Policy = (await import(path)).default;
|
||||||
|
|
||||||
const { kysely } = DittoDB.create(databaseUrl, { poolSize: 1 });
|
const { kysely } = DittoDB.create(databaseUrl, { poolSize: 1 });
|
||||||
|
|
||||||
const store = new EventsDB({
|
const store = new EventsDB({
|
||||||
kysely,
|
kysely,
|
||||||
pubkey: adminPubkey,
|
pubkey,
|
||||||
timeout: 1_000,
|
timeout: 1_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.policy = new Policy({ store });
|
this.policy = new Policy({ store, pubkey });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||