From 65846d062ab0e3851458ea68a6ddbdfabeee958d Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 16 Feb 2025 23:27:13 -0600 Subject: [PATCH] Rewrite literally everything --- packages/api/middleware/confMw.test.ts | 19 - packages/api/middleware/confMw.ts | 15 - .../api/middleware/confRequiredMw.test.ts | 22 - packages/api/middleware/confRequiredMw.ts | 15 - packages/api/middleware/mod.ts | 2 - packages/conf/DittoConf.ts | 10 + packages/ditto/DittoPipeline.ts | 412 ++++++++++++++++++ packages/ditto/DittoPush.ts | 26 +- .../ditto/{storages.ts => DittoStorages.ts} | 69 ++- packages/ditto/app.ts | 97 ++++- packages/ditto/caches/pipelineEncounters.ts | 3 - packages/ditto/caches/translationCache.ts | 2 +- packages/ditto/config.ts | 4 - packages/ditto/controllers/api/accounts.ts | 217 +++++---- packages/ditto/controllers/api/admin.ts | 90 ++-- packages/ditto/controllers/api/bookmarks.ts | 7 +- packages/ditto/controllers/api/cashu.ts | 1 - packages/ditto/controllers/api/ditto.ts | 86 ++-- packages/ditto/controllers/api/instance.ts | 7 +- packages/ditto/controllers/api/mutes.ts | 6 +- packages/ditto/controllers/api/oauth.ts | 32 +- packages/ditto/controllers/api/pleroma.ts | 50 +-- packages/ditto/controllers/api/push.ts | 13 +- packages/ditto/controllers/api/reactions.ts | 62 +-- packages/ditto/controllers/api/search.ts | 93 ++-- packages/ditto/controllers/api/statuses.ts | 224 +++++----- packages/ditto/controllers/api/streaming.ts | 43 +- packages/ditto/controllers/api/translate.ts | 14 +- packages/ditto/controllers/api/trends.ts | 62 ++- packages/ditto/controllers/frontend.ts | 43 +- packages/ditto/controllers/manifest.ts | 3 +- packages/ditto/controllers/metrics.ts | 4 +- .../ditto/controllers/nostr/relay-info.ts | 5 +- packages/ditto/controllers/nostr/relay.ts | 24 +- .../ditto/controllers/well-known/nostr.ts | 4 +- packages/ditto/cron.ts | 36 +- packages/ditto/firehose.ts | 25 +- packages/ditto/interfaces/DittoFilter.ts | 5 - packages/ditto/middleware/auth98Middleware.ts | 55 +-- packages/ditto/middleware/cspMiddleware.ts | 6 +- .../ditto/middleware/paginationMiddleware.ts | 5 +- packages/ditto/middleware/requireSigner.ts | 27 +- packages/ditto/middleware/signerMiddleware.ts | 13 +- packages/ditto/middleware/storeMiddleware.ts | 28 -- .../ditto/middleware/swapNutzapsMiddleware.ts | 38 +- .../ditto/middleware/uploaderMiddleware.ts | 11 +- packages/ditto/notify.ts | 33 +- packages/ditto/pipeline.ts | 401 ----------------- packages/ditto/precheck.ts | 22 - packages/ditto/queries.ts | 73 ++-- packages/ditto/sentry.ts | 22 +- packages/ditto/server.ts | 23 +- packages/ditto/signers/AdminSigner.ts | 7 +- packages/ditto/signers/ConnectSigner.ts | 8 +- packages/ditto/startup.ts | 17 - packages/ditto/storages/AdminStore.ts | 9 +- packages/ditto/storages/EventsDB.test.ts | 18 +- packages/ditto/storages/hydrate.bench.ts | 6 +- packages/ditto/storages/hydrate.test.ts | 93 ++-- packages/ditto/storages/hydrate.ts | 42 +- packages/ditto/storages/search-store.ts | 60 --- packages/ditto/test.ts | 23 +- .../ditto/translators/DeepLTranslator.test.ts | 4 +- .../LibreTranslateTranslator.test.ts | 4 +- packages/ditto/trends.test.ts | 20 +- packages/ditto/trends.ts | 287 ++++++------ packages/ditto/utils/api.ts | 143 +++--- packages/ditto/utils/connect.ts | 28 -- packages/ditto/utils/favicon.ts | 44 +- packages/ditto/utils/instance.ts | 14 +- packages/ditto/utils/lookup.ts | 31 +- packages/ditto/utils/nip05.ts | 53 ++- packages/ditto/utils/note.test.ts | 22 +- packages/ditto/utils/note.ts | 8 +- packages/ditto/utils/outbox.test.ts | 17 +- packages/ditto/utils/outbox.ts | 7 +- packages/ditto/utils/pleroma.ts | 15 +- packages/ditto/utils/stats.test.ts | 42 +- packages/ditto/utils/stats.ts | 49 ++- packages/ditto/utils/unfurl.ts | 19 +- packages/ditto/utils/upload.ts | 8 +- packages/ditto/utils/zap-split.ts | 9 +- packages/ditto/views.ts | 60 +-- packages/ditto/views/mastodon/AccountView.ts | 170 ++++++++ .../ditto/views/mastodon/AdminAccountView.ts | 80 ++++ .../ditto/views/mastodon/NotificationView.ts | 165 +++++++ packages/ditto/views/mastodon/StatusView.ts | 200 +++++++++ packages/ditto/views/mastodon/accounts.ts | 158 ------- .../ditto/views/mastodon/admin-accounts.ts | 64 --- .../ditto/views/mastodon/notifications.ts | 142 ------ packages/ditto/views/mastodon/reports.ts | 5 +- packages/ditto/views/mastodon/statuses.ts | 181 -------- packages/ditto/views/meta.ts | 8 +- packages/ditto/workers/policy.ts | 24 +- packages/ditto/workers/verify.worker.ts | 7 +- scripts/admin-event.ts | 9 +- scripts/admin-role.ts | 9 +- scripts/db-export.ts | 8 +- scripts/db-import.ts | 10 +- scripts/db-migrate.ts | 9 +- scripts/db-policy.ts | 14 +- scripts/db-populate-extensions.ts | 7 +- scripts/db-populate-nip05.ts | 19 +- scripts/db-populate-search.ts | 9 +- scripts/db-streak-recompute.ts | 12 +- scripts/nostr-pull.ts | 7 +- scripts/setup-kind0.ts | 13 +- scripts/setup.ts | 25 +- scripts/stats-recompute.ts | 10 +- scripts/trends.ts | 37 +- 110 files changed, 2646 insertions(+), 2532 deletions(-) delete mode 100644 packages/api/middleware/confMw.test.ts delete mode 100644 packages/api/middleware/confMw.ts delete mode 100644 packages/api/middleware/confRequiredMw.test.ts delete mode 100644 packages/api/middleware/confRequiredMw.ts delete mode 100644 packages/api/middleware/mod.ts create mode 100644 packages/ditto/DittoPipeline.ts rename packages/ditto/{storages.ts => DittoStorages.ts} (62%) delete mode 100644 packages/ditto/caches/pipelineEncounters.ts delete mode 100644 packages/ditto/config.ts delete mode 100644 packages/ditto/interfaces/DittoFilter.ts delete mode 100644 packages/ditto/middleware/storeMiddleware.ts delete mode 100644 packages/ditto/pipeline.ts delete mode 100644 packages/ditto/precheck.ts delete mode 100644 packages/ditto/startup.ts delete mode 100644 packages/ditto/storages/search-store.ts delete mode 100644 packages/ditto/utils/connect.ts create mode 100644 packages/ditto/views/mastodon/AccountView.ts create mode 100644 packages/ditto/views/mastodon/AdminAccountView.ts create mode 100644 packages/ditto/views/mastodon/NotificationView.ts create mode 100644 packages/ditto/views/mastodon/StatusView.ts delete mode 100644 packages/ditto/views/mastodon/accounts.ts delete mode 100644 packages/ditto/views/mastodon/admin-accounts.ts delete mode 100644 packages/ditto/views/mastodon/notifications.ts delete mode 100644 packages/ditto/views/mastodon/statuses.ts diff --git a/packages/api/middleware/confMw.test.ts b/packages/api/middleware/confMw.test.ts deleted file mode 100644 index 5eac707c..00000000 --- a/packages/api/middleware/confMw.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Hono } from '@hono/hono'; -import { assertEquals } from '@std/assert'; - -import { confMw } from './confMw.ts'; - -Deno.test('confMw', async () => { - const env = new Map([ - ['DITTO_NSEC', 'nsec19shyxpuzd0cq2p5078fwnws7tyykypud6z205fzhlmlrs2vpz6hs83zwkw'], - ]); - - const app = new Hono(); - - app.get('/', confMw(env), (c) => c.text(c.var.conf.pubkey)); - - const response = await app.request('/'); - const body = await response.text(); - - assertEquals(body, '1ba0c5ed1bbbf3b7eb0d7843ba16836a0201ea68a76bafcba507358c45911ff6'); -}); diff --git a/packages/api/middleware/confMw.ts b/packages/api/middleware/confMw.ts deleted file mode 100644 index ebfdfe4b..00000000 --- a/packages/api/middleware/confMw.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { DittoConf } from '@ditto/conf'; - -import type { MiddlewareHandler } from '@hono/hono'; - -/** Set Ditto config. */ -export function confMw( - env: { get(key: string): string | undefined }, -): MiddlewareHandler<{ Variables: { conf: DittoConf } }> { - const conf = new DittoConf(env); - - return async (c, next) => { - c.set('conf', conf); - await next(); - }; -} diff --git a/packages/api/middleware/confRequiredMw.test.ts b/packages/api/middleware/confRequiredMw.test.ts deleted file mode 100644 index 9dfcc096..00000000 --- a/packages/api/middleware/confRequiredMw.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Hono } from '@hono/hono'; -import { assertEquals } from '@std/assert'; - -import { confMw } from './confMw.ts'; -import { confRequiredMw } from './confRequiredMw.ts'; - -Deno.test('confRequiredMw', async (t) => { - const app = new Hono(); - - app.get('/without', confRequiredMw, (c) => c.text('ok')); - app.get('/with', confMw(new Map()), confRequiredMw, (c) => c.text('ok')); - - await t.step('without conf returns 500', async () => { - const response = await app.request('/without'); - assertEquals(response.status, 500); - }); - - await t.step('with conf returns 200', async () => { - const response = await app.request('/with'); - assertEquals(response.status, 200); - }); -}); diff --git a/packages/api/middleware/confRequiredMw.ts b/packages/api/middleware/confRequiredMw.ts deleted file mode 100644 index dc4d661d..00000000 --- a/packages/api/middleware/confRequiredMw.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { HTTPException } from '@hono/hono/http-exception'; - -import type { DittoConf } from '@ditto/conf'; -import type { MiddlewareHandler } from '@hono/hono'; - -/** Throws an error if conf isn't set. */ -export const confRequiredMw: MiddlewareHandler<{ Variables: { conf: DittoConf } }> = async (c, next) => { - const { conf } = c.var; - - if (!conf) { - throw new HTTPException(500, { message: 'Ditto config not set in request.' }); - } - - await next(); -}; diff --git a/packages/api/middleware/mod.ts b/packages/api/middleware/mod.ts deleted file mode 100644 index 54a1b35c..00000000 --- a/packages/api/middleware/mod.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { confMw } from './confMw.ts'; -export { confRequiredMw } from './confRequiredMw.ts'; diff --git a/packages/conf/DittoConf.ts b/packages/conf/DittoConf.ts index 6d4b45d7..08c66f4d 100644 --- a/packages/conf/DittoConf.ts +++ b/packages/conf/DittoConf.ts @@ -329,6 +329,11 @@ export class DittoConf { .map(Number); } + /** Whether to perform prechecks when Ditto is starting. Setting this to `false` can suppress errors, but is not recommended. */ + get precheck(): boolean { + return optionalBooleanSchema.parse(this.env.get('DITTO_PRECHECK')) ?? true; + } + /** * Whether Ditto should subscribe to Nostr events from the Postgres database itself. * This would make Nostr events inserted directly into Postgres available to the streaming API and relay. @@ -357,6 +362,11 @@ export class DittoConf { return this.env.get('DITTO_DATA_DIR') || path.join(cwd(), 'data'); } + /** Directory to serve the frontend from. */ + get publicDir(): string { + return this.env.get('DITTO_PUBLIC_DIR') || path.join(this.dataDir, 'public'); + } + /** Absolute path of the Deno directory. */ get denoDir(): string { return this.env.get('DENO_DIR') || `${os.userInfo().homedir}/.cache/deno`; diff --git a/packages/ditto/DittoPipeline.ts b/packages/ditto/DittoPipeline.ts new file mode 100644 index 00000000..ee8c339c --- /dev/null +++ b/packages/ditto/DittoPipeline.ts @@ -0,0 +1,412 @@ +import { type DittoConf } from '@ditto/conf'; +import { DittoTables } from '@ditto/db'; +import { pipelineEventsCounter, policyEventsCounter, webPushNotificationsCounter } from '@ditto/metrics'; +import { NKinds, NostrEvent, NSchema as n, NStore } from '@nostrify/nostrify'; +import { logi } from '@soapbox/logi'; +import { Kysely, UpdateObject } from 'kysely'; +import { LRUCache } from 'lru-cache'; +import tldts from 'tldts'; +import { z } from 'zod'; + +import { DittoPush } from '@/DittoPush.ts'; +import { DittoEvent } from '@/interfaces/DittoEvent.ts'; +import { RelayError } from '@/RelayError.ts'; +import { AdminSigner } from '@/signers/AdminSigner.ts'; +import { type EventsDB } from '@/storages/EventsDB.ts'; +import { hydrateEvents } from '@/storages/hydrate.ts'; +import { eventAge, Time } from '@/utils.ts'; +import { getAmount } from '@/utils/bolt11.ts'; +import { faviconCache } from '@/utils/favicon.ts'; +import { errorJson } from '@/utils/log.ts'; +import { resolveNip05 } from '@/utils/nip05.ts'; +import { parseNoteContent, stripimeta } from '@/utils/note.ts'; +import { purifyEvent } from '@/utils/purify.ts'; +import { updateStats } from '@/utils/stats.ts'; +import { getTagSet } from '@/utils/tags.ts'; +import { unfurlCardCached } from '@/utils/unfurl.ts'; +import { renderWebPushNotification } from '@/views/mastodon/push.ts'; +import { PolicyWorker } from '@/workers/policy.ts'; +import { verifyEventWorker } from '@/workers/verify.ts'; + +interface PipelineOpts { + conf: DittoConf; + kysely: Kysely; + store: EventsDB; + pubsub: NStore; +} + +export class DittoPipeline { + private push: DittoPush; + private policyWorker: PolicyWorker; + + encounters = new LRUCache({ max: 5000 }); + + constructor(private opts: PipelineOpts) { + this.push = new DittoPush(opts); + this.policyWorker = new PolicyWorker(opts.conf); + } + + /** + * Common pipeline function to process (and maybe store) events. + * It is idempotent, so it can be called multiple times for the same event. + */ + async event(event: DittoEvent, opts?: { signal: AbortSignal; source?: string }): Promise { + const { kysely, conf } = this.opts; + + // Skip events that have already been encountered. + if (this.encounters.get(event.id)) { + throw new RelayError('duplicate', 'already have this event'); + } + // Reject events that are too far in the future. + if (eventAge(event) < -Time.minutes(1)) { + throw new RelayError('invalid', 'event too far in the future'); + } + // Integer max value for Postgres. + if (event.kind >= 2_147_483_647) { + throw new RelayError('invalid', 'event kind too large'); + } + // The only point of ephemeral events is to stream them, + // so throw an error if we're not even going to do that. + if (NKinds.ephemeral(event.kind) && !this.isFresh(event)) { + throw new RelayError('invalid', 'event too old'); + } + // Block NIP-70 events, because we have no way to `AUTH`. + if (event.tags.some(([name]) => name === '-')) { + throw new RelayError('invalid', 'protected event'); + } + // Validate the event's signature. + if (!(await verifyEventWorker(event))) { + throw new RelayError('invalid', 'invalid signature'); + } + // Recheck encountered after async ops. + if (this.encounters.has(event.id)) { + throw new RelayError('duplicate', 'already have this event'); + } + // Set the event as encountered after verifying the signature. + this.encounters.set(event.id, true); + + // Log the event. + logi({ level: 'debug', ns: 'ditto.event', source: 'pipeline', id: event.id, kind: event.kind }); + pipelineEventsCounter.inc({ kind: event.kind }); + + // NIP-46 events get special treatment. + // They are exempt from policies and other side-effects, and should be streamed out immediately. + // If streaming fails, an error should be returned. + if (event.kind === 24133) { + await this.streamOut(event); + return; + } + + // Ensure the event doesn't violate the policy. + if (event.pubkey !== conf.pubkey) { + await this.policyFilter(event, opts?.signal); + } + + // Prepare the event for additional checks. + // FIXME: This is kind of hacky. Should be reorganized to fetch only what's needed for each stage. + await this.hydrateEvent(event, opts?.signal); + + // Ensure that the author is not banned. + const n = getTagSet(event.user?.tags ?? [], 'n'); + if (n.has('disabled')) { + throw new RelayError('blocked', 'author is blocked'); + } + + // Ephemeral events must throw if they are not streamed out. + if (NKinds.ephemeral(event.kind)) { + await Promise.all([ + this.streamOut(event), + this.webPush(event), + ]); + return; + } + + // Events received through notify are thought to already be in the database, so they only need to be streamed. + if (opts?.source === 'notify') { + await Promise.all([ + this.streamOut(event), + this.webPush(event), + ]); + return; + } + + try { + await this.storeEvent(purifyEvent(event), opts?.signal); + } finally { + // This needs to run in steps, and should not block the API from responding. + Promise.allSettled([ + this.handleZaps(kysely, event), + this.updateAuthorData(event, opts?.signal), + this.prewarmLinkPreview(event, opts?.signal), + this.generateSetEvents(event), + ]) + .then(() => + Promise.allSettled([ + this.streamOut(event), + this.webPush(event), + ]) + ); + } + } + + async policyFilter(event: NostrEvent, signal?: AbortSignal): Promise { + try { + const result = await this.policyWorker.call(event, signal); + const [, , ok, reason] = result; + logi({ level: 'debug', ns: 'ditto.policy', id: event.id, kind: event.kind, ok, reason }); + policyEventsCounter.inc({ ok: String(ok) }); + RelayError.assert(result); + } catch (e) { + if (e instanceof RelayError) { + throw e; + } else { + logi({ level: 'error', ns: 'ditto.policy', id: event.id, kind: event.kind, error: errorJson(e) }); + throw new RelayError('blocked', 'policy error'); + } + } + } + + /** Hydrate the event with the user, if applicable. */ + async hydrateEvent(event: DittoEvent, signal?: AbortSignal): Promise { + await hydrateEvents({ ...this.opts, signal }, [event]); + } + + /** Maybe store the event, if eligible. */ + async storeEvent(event: NostrEvent, signal?: AbortSignal): Promise { + if (NKinds.ephemeral(event.kind)) return; + const { conf, store } = this.opts; + + try { + await store.transaction(async (store, kysely) => { + await updateStats({ conf, store, kysely }, event); + await store.event(event, { signal }); + }); + } catch (e) { + // If the failure is only because of updateStats (which runs first), insert the event anyway. + // We can't catch this in the transaction because the error aborts the transaction on the Postgres side. + if (e instanceof Error && e.message.includes('event_stats' satisfies keyof DittoTables)) { + await store.event(event, { signal }); + } else { + throw e; + } + } + } + + /** Parse kind 0 metadata and track indexes in the database. */ + async updateAuthorData(event: NostrEvent, signal?: AbortSignal): Promise { + if (event.kind !== 0) return; + + const { conf, kysely, store } = this.opts; + + // Parse metadata. + const metadata = n.json().pipe(n.metadata()).catch({}).safeParse(event.content); + if (!metadata.success) return; + + const { name, nip05 } = metadata.data; + + const updates: UpdateObject = {}; + + const authorStats = await kysely + .selectFrom('author_stats') + .selectAll() + .where('pubkey', '=', event.pubkey) + .executeTakeFirst(); + + const lastVerified = authorStats?.nip05_last_verified_at; + const eventNewer = !lastVerified || event.created_at > lastVerified; + + try { + if (nip05 !== authorStats?.nip05 && eventNewer || !lastVerified) { + if (nip05) { + const tld = tldts.parse(nip05); + if (tld.isIcann && !tld.isIp && !tld.isPrivate) { + const pointer = await resolveNip05({ conf, store, signal }, nip05.toLowerCase()); + if (pointer.pubkey === event.pubkey) { + updates.nip05 = nip05; + updates.nip05_domain = tld.domain; + updates.nip05_hostname = tld.hostname; + updates.nip05_last_verified_at = event.created_at; + } + } + } else { + updates.nip05 = null; + updates.nip05_domain = null; + updates.nip05_hostname = null; + updates.nip05_last_verified_at = event.created_at; + } + } + } catch { + // Fallthrough. + } + + // Fetch favicon. + const domain = nip05?.split('@')[1].toLowerCase(); + if (domain) { + try { + await faviconCache.fetch(domain, { signal }); + } catch { + // Fallthrough. + } + } + + const search = [name, nip05].filter(Boolean).join(' ').trim(); + + if (search !== authorStats?.search) { + updates.search = search; + } + + if (Object.keys(updates).length) { + await kysely.insertInto('author_stats') + .values({ + pubkey: event.pubkey, + followers_count: 0, + following_count: 0, + notes_count: 0, + search, + ...updates, + }) + .onConflict((oc) => oc.column('pubkey').doUpdateSet(updates)) + .execute(); + } + } + + async prewarmLinkPreview(event: NostrEvent, signal?: AbortSignal): Promise { + const { conf } = this.opts; + const { firstUrl } = parseNoteContent(conf, stripimeta(event.content, event.tags), []); + if (firstUrl) { + await unfurlCardCached(conf, firstUrl, signal); + } + } + + /** Determine if the event is being received in a timely manner. */ + isFresh(event: NostrEvent): boolean { + return eventAge(event) < Time.minutes(1); + } + + /** Distribute the event through active subscriptions. */ + async streamOut(event: NostrEvent): Promise { + if (!this.isFresh(event)) { + throw new RelayError('invalid', 'event too old'); + } + + const { pubsub } = this.opts; + + await pubsub.event(event); + } + + async webPush(event: NostrEvent): Promise { + if (!this.isFresh(event)) { + throw new RelayError('invalid', 'event too old'); + } + + const { kysely } = this.opts; + const pubkeys = getTagSet(event.tags, 'p'); + + if (!pubkeys.size) { + return; + } + + const rows = await kysely + .selectFrom('push_subscriptions') + .selectAll() + .where('pubkey', 'in', [...pubkeys]) + .execute(); + + for (const row of rows) { + const viewerPubkey = row.pubkey; + + if (viewerPubkey === event.pubkey) { + continue; // Don't notify authors about their own events. + } + + const message = await renderWebPushNotification(event, viewerPubkey); + if (!message) { + continue; + } + + const subscription = { + endpoint: row.endpoint, + keys: { + auth: row.auth, + p256dh: row.p256dh, + }, + }; + + await this.push.push(subscription, message); + webPushNotificationsCounter.inc({ type: message.notification_type }); + } + } + + async generateSetEvents(event: NostrEvent): Promise { + const { conf } = this.opts; + + const tagsAdmin = event.tags.some(([name, value]) => ['p', 'P'].includes(name) && value === conf.pubkey); + + if (event.kind === 1984 && tagsAdmin) { + const signer = new AdminSigner(conf); + + const rel = await signer.signEvent({ + kind: 30383, + content: '', + tags: [ + ['d', event.id], + ['p', event.pubkey], + ['k', '1984'], + ['n', 'open'], + ...[...getTagSet(event.tags, 'p')].map((pubkey) => ['P', pubkey]), + ...[...getTagSet(event.tags, 'e')].map((pubkey) => ['e', pubkey]), + ], + created_at: Math.floor(Date.now() / 1000), + }); + + await this.event(rel, { source: 'pipeline', signal: AbortSignal.timeout(1000) }); + } + + if (event.kind === 3036 && tagsAdmin) { + const signer = new AdminSigner(conf); + + const rel = await signer.signEvent({ + kind: 30383, + content: '', + tags: [ + ['d', event.id], + ['p', event.pubkey], + ['k', '3036'], + ['n', 'pending'], + ], + created_at: Math.floor(Date.now() / 1000), + }); + + await this.event(rel, { source: 'pipeline', signal: AbortSignal.timeout(1000) }); + } + } + + /** Stores the event in the 'event_zaps' table */ + async handleZaps(kysely: Kysely, event: NostrEvent) { + if (event.kind !== 9735) return; + + const zapRequestString = event?.tags?.find(([name]) => name === 'description')?.[1]; + if (!zapRequestString) return; + const zapRequest = n.json().pipe(n.event()).optional().catch(undefined).parse(zapRequestString); + if (!zapRequest) return; + + const amountSchema = z.coerce.number().int().nonnegative().catch(0); + const amount_millisats = amountSchema.parse(getAmount(event?.tags.find(([name]) => name === 'bolt11')?.[1])); + if (!amount_millisats || amount_millisats < 1) return; + + const zappedEventId = zapRequest.tags.find(([name]) => name === 'e')?.[1]; + if (!zappedEventId) return; + + try { + await kysely.insertInto('event_zaps').values({ + receipt_id: event.id, + target_event_id: zappedEventId, + sender_pubkey: zapRequest.pubkey, + amount_millisats, + comment: zapRequest.content, + }).execute(); + } catch { + // receipt_id is unique, do nothing + } + } +} diff --git a/packages/ditto/DittoPush.ts b/packages/ditto/DittoPush.ts index 7f5dafa0..8af46f03 100644 --- a/packages/ditto/DittoPush.ts +++ b/packages/ditto/DittoPush.ts @@ -1,19 +1,27 @@ +import { type DittoConf } from '@ditto/conf'; import { ApplicationServer, PushMessageOptions, PushSubscriber, PushSubscription } from '@negrel/webpush'; +import { type NStore } from '@nostrify/nostrify'; import { logi } from '@soapbox/logi'; -import { Conf } from '@/config.ts'; -import { Storages } from '@/storages.ts'; import { getInstanceMetadata } from '@/utils/instance.ts'; -export class DittoPush { - static _server: Promise | undefined; +interface DittoPushOpts { + conf: DittoConf; + store: NStore; +} - static get server(): Promise { +export class DittoPush { + _server: Promise | undefined; + + constructor(private opts: DittoPushOpts) {} + + get server(): Promise { if (!this._server) { this._server = (async () => { - const store = await Storages.db(); - const meta = await getInstanceMetadata(store); - const keys = await Conf.vapidKeys; + const { conf } = this.opts; + + const meta = await getInstanceMetadata(this.opts); + const keys = await conf.vapidKeys; if (keys) { return await ApplicationServer.new({ @@ -33,7 +41,7 @@ export class DittoPush { return this._server; } - static async push( + async push( subscription: PushSubscription, json: object, opts: PushMessageOptions = {}, diff --git a/packages/ditto/storages.ts b/packages/ditto/DittoStorages.ts similarity index 62% rename from packages/ditto/storages.ts rename to packages/ditto/DittoStorages.ts index be61beb6..46306607 100644 --- a/packages/ditto/storages.ts +++ b/packages/ditto/DittoStorages.ts @@ -1,32 +1,34 @@ // deno-lint-ignore-file require-await +import { DittoConf } from '@ditto/conf'; import { type DittoDatabase, DittoDB } from '@ditto/db'; import { internalSubscriptionsSizeGauge } from '@ditto/metrics'; import { logi } from '@soapbox/logi'; -import { Conf } from '@/config.ts'; import { wsUrlSchema } from '@/schema.ts'; import { AdminStore } from '@/storages/AdminStore.ts'; import { EventsDB } from '@/storages/EventsDB.ts'; -import { SearchStore } from '@/storages/search-store.ts'; import { InternalRelay } from '@/storages/InternalRelay.ts'; import { NPool, NRelay1 } from '@nostrify/nostrify'; import { getRelays } from '@/utils/outbox.ts'; import { seedZapSplits } from '@/utils/zap-split.ts'; -export class Storages { - private static _db: Promise | undefined; - private static _database: Promise | undefined; - private static _admin: Promise | undefined; - private static _client: Promise> | undefined; - private static _pubsub: Promise | undefined; - private static _search: Promise | undefined; +export class DittoStorages { + private _db: Promise | undefined; + private _database: Promise | undefined; + private _admin: Promise | undefined; + private _pool: Promise> | undefined; + private _pubsub: Promise | undefined; + + constructor(private conf: DittoConf) {} + + public async database(): Promise { + const { conf } = this; - public static async database(): Promise { if (!this._database) { this._database = (async () => { - const db = DittoDB.create(Conf.databaseUrl, { - poolSize: Conf.pg.poolSize, - debug: Conf.pgliteDebug, + const db = DittoDB.create(conf.databaseUrl, { + poolSize: conf.pg.poolSize, + debug: conf.pgliteDebug, }); await DittoDB.migrate(db.kysely); return db; @@ -35,17 +37,19 @@ export class Storages { return this._database; } - public static async kysely(): Promise { + public async kysely(): Promise { const { kysely } = await this.database(); return kysely; } /** SQL database to store events this Ditto server cares about. */ - public static async db(): Promise { + public async db(): Promise { + const { conf } = this; + if (!this._db) { this._db = (async () => { const kysely = await this.kysely(); - const store = new EventsDB({ kysely, pubkey: Conf.pubkey, timeout: Conf.db.timeouts.default }); + const store = new EventsDB({ kysely, pubkey: conf.pubkey, timeout: conf.db.timeouts.default }); await seedZapSplits(store); return store; })(); @@ -54,7 +58,7 @@ export class Storages { } /** Admin user storage. */ - public static async admin(): Promise { + public async admin(): Promise { if (!this._admin) { this._admin = Promise.resolve(new AdminStore(await this.db())); } @@ -62,7 +66,7 @@ export class Storages { } /** Internal pubsub relay between controllers and the pipeline. */ - public static async pubsub(): Promise { + public async pubsub(): Promise { if (!this._pubsub) { this._pubsub = Promise.resolve(new InternalRelay({ gauge: internalSubscriptionsSizeGauge })); } @@ -70,13 +74,15 @@ export class Storages { } /** Relay pool storage. */ - public static async client(): Promise> { - if (!this._client) { - this._client = (async () => { + public async pool(): Promise> { + const { conf } = this; + + if (!this._pool) { + this._pool = (async () => { const db = await this.db(); const [relayList] = await db.query([ - { kinds: [10002], authors: [Conf.pubkey], limit: 1 }, + { kinds: [10002], authors: [conf.pubkey], limit: 1 }, ]); const tags = relayList?.tags ?? []; @@ -113,8 +119,8 @@ export class Storages { })); }, eventRouter: async (event) => { - const relaySet = await getRelays(await Storages.db(), event.pubkey); - relaySet.delete(Conf.relay); + const relaySet = await getRelays(await this.db(), event.pubkey); + relaySet.delete(conf.relay); const relays = [...relaySet].slice(0, 4); return relays; @@ -122,19 +128,6 @@ export class Storages { }); })(); } - return this._client; - } - - /** Storage to use for remote search. */ - public static async search(): Promise { - if (!this._search) { - this._search = Promise.resolve( - new SearchStore({ - relay: Conf.searchRelay, - fallback: await this.db(), - }), - ); - } - return this._search; + return this._pool; } } diff --git a/packages/ditto/app.ts b/packages/ditto/app.ts index 6a54f66f..4083770a 100644 --- a/packages/ditto/app.ts +++ b/packages/ditto/app.ts @@ -1,15 +1,18 @@ -import { confMw } from '@ditto/api/middleware'; -import { type DittoConf } from '@ditto/conf'; -import { DittoTables } from '@ditto/db'; +import { DittoConf } from '@ditto/conf'; +import { DittoDatabase, DittoTables } from '@ditto/db'; import { type Context, Env as HonoEnv, Handler, Hono, Input as HonoInput, MiddlewareHandler } from '@hono/hono'; import { every } from '@hono/hono/combine'; import { cors } from '@hono/hono/cors'; import { serveStatic } from '@hono/hono/deno'; -import { NostrEvent, NostrSigner, NStore, NUploader } from '@nostrify/nostrify'; +import { NostrSigner, NPool, NRelay, NRelay1, NStore, NUploader } from '@nostrify/nostrify'; import { Kysely } from 'kysely'; -import '@/startup.ts'; - +import { cron } from '@/cron.ts'; +import { DittoPipeline } from '@/DittoPipeline.ts'; +import { DittoStorages } from '@/DittoStorages.ts'; +import { startFirehose } from '@/firehose.ts'; +import { startNotify } from '@/notify.ts'; +import { EventsDB } from '@/storages/EventsDB.ts'; import { Time } from '@/utils/time.ts'; import { @@ -135,7 +138,7 @@ import { manifestController } from '@/controllers/manifest.ts'; import { nodeInfoController, nodeInfoSchemaController } from '@/controllers/well-known/nodeinfo.ts'; import { nostrController } from '@/controllers/well-known/nostr.ts'; import { DittoTranslator } from '@/interfaces/DittoTranslator.ts'; -import { auth98Middleware, requireProof, requireRole } from '@/middleware/auth98Middleware.ts'; +import { requireProof, requireRole } from '@/middleware/auth98Middleware.ts'; import { cacheControlMiddleware } from '@/middleware/cacheControlMiddleware.ts'; import { cspMiddleware } from '@/middleware/cspMiddleware.ts'; import { metricsMiddleware } from '@/middleware/metricsMiddleware.ts'; @@ -144,30 +147,73 @@ import { paginationMiddleware } from '@/middleware/paginationMiddleware.ts'; import { rateLimitMiddleware } from '@/middleware/rateLimitMiddleware.ts'; import { requireSigner } from '@/middleware/requireSigner.ts'; import { signerMiddleware } from '@/middleware/signerMiddleware.ts'; -import { storeMiddleware } from '@/middleware/storeMiddleware.ts'; import { uploaderMiddleware } from '@/middleware/uploaderMiddleware.ts'; import { translatorMiddleware } from '@/middleware/translatorMiddleware.ts'; import { logiMiddleware } from '@/middleware/logiMiddleware.ts'; +const conf = new DittoConf(Deno.env); +const storages = new DittoStorages(conf); + +const [kysely, store, pool, pubsub, db] = await Promise.all([ + storages.kysely(), + storages.db(), + storages.pool(), + storages.pubsub(), + storages.database(), +]); + +const pipeline = new DittoPipeline({ conf, kysely, store, pubsub }); + +if (conf.firehoseEnabled) { + startFirehose( + { concurrency: conf.firehoseConcurrency, kinds: conf.firehoseKinds, store: pool }, + (event) => pipeline.event(event, { signal: AbortSignal.timeout(5000) }), + ); +} + +if (conf.notifyEnabled) { + startNotify({ conf, db, store, pipeline }); +} + +if (conf.cronEnabled) { + cron({ conf, kysely, store }); +} + export interface AppEnv extends HonoEnv { Variables: { conf: DittoConf; - /** Signer to get the logged-in user's pubkey, relays, and to sign events, or `undefined` if the user isn't logged in. */ - signer?: NostrSigner; + user?: { + /** Signer to get the logged-in user's pubkey, relays, and to sign events, or `undefined` if the user isn't logged in. */ + signer: NostrSigner; + /** Storage for the user, might filter out unwanted content. */ + store: NStore; + }; + service?: { + /** Service signer. */ + signer: NostrSigner; + /** Store for service actions. */ + store: NStore; + }; /** Uploader for the user to upload files. */ uploader?: NUploader; - /** NIP-98 signed event proving the pubkey is owned by the user. */ - proof?: NostrEvent; /** Kysely instance for the database. */ kysely: Kysely; - /** Storage for the user, might filter out unwanted content. */ - store: NStore; + /** Main database. */ + store: EventsDB; + /** Internal Nostr relay for realtime subscriptions. */ + pubsub: NRelay; + /** Nostr relay pool. */ + pool: NPool; + /** Database object. */ + db: DittoDatabase; /** Normalized pagination params. */ pagination: { since?: number; until?: number; limit: number }; /** Normalized list pagination params. */ listPagination: { offset: number; limit: number }; /** Translation service. */ translator?: DittoTranslator; + signal: AbortSignal; + pipeline: DittoPipeline; }; } @@ -179,11 +225,24 @@ type AppController

= Handler({ strict: false }); /** User-provided files in the gitignored `public/` directory. */ -const publicFiles = serveStatic({ root: './public/' }); +const publicFiles = serveStatic({ root: conf.publicDir }); /** Static files provided by the Ditto repo, checked into git. */ -const staticFiles = serveStatic({ root: new URL('./static/', import.meta.url).pathname }); +const staticFiles = serveStatic({ root: new URL('./static', import.meta.url).pathname }); -app.use(confMw(Deno.env), cacheControlMiddleware({ noStore: true })); +// Set up the base context. +app.use((c, next) => { + c.set('db', db); + c.set('conf', conf); + c.set('kysely', kysely); + c.set('pool', pool); + c.set('store', store); + c.set('pubsub', pubsub); + c.set('signal', c.req.raw.signal); + c.set('pipeline', pipeline); + return next(); +}); + +app.use(cacheControlMiddleware({ noStore: true })); const ratelimit = every( rateLimitMiddleware(30, Time.seconds(5), false), @@ -203,8 +262,6 @@ app.use( cors({ origin: '*', exposeHeaders: ['link'] }), signerMiddleware, uploaderMiddleware, - auth98Middleware(), - storeMiddleware, ); app.get('/metrics', metricsController); @@ -251,7 +308,7 @@ app.post('/oauth/revoke', revokeTokenController); app.post('/oauth/authorize', oauthAuthorizeController); app.get('/oauth/authorize', oauthController); -app.post('/api/v1/accounts', requireProof({ pow: 20 }), createAccountController); +app.post('/api/v1/accounts', requireProof(), createAccountController); app.get('/api/v1/accounts/verify_credentials', requireSigner, verifyCredentialsController); app.patch('/api/v1/accounts/update_credentials', requireSigner, updateCredentialsController); app.get('/api/v1/accounts/search', accountSearchController); diff --git a/packages/ditto/caches/pipelineEncounters.ts b/packages/ditto/caches/pipelineEncounters.ts deleted file mode 100644 index 491a416f..00000000 --- a/packages/ditto/caches/pipelineEncounters.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { LRUCache } from 'lru-cache'; - -export const pipelineEncounters = new LRUCache({ max: 5000 }); diff --git a/packages/ditto/caches/translationCache.ts b/packages/ditto/caches/translationCache.ts index 7bd27946..c82f4928 100644 --- a/packages/ditto/caches/translationCache.ts +++ b/packages/ditto/caches/translationCache.ts @@ -1,7 +1,7 @@ import { LanguageCode } from 'iso-639-1'; import { LRUCache } from 'lru-cache'; -import { Conf } from '@/config.ts'; +import { DittoConf } from '@ditto/conf'; import { MastodonTranslation } from '@/entities/MastodonTranslation.ts'; /** Translations LRU cache. */ diff --git a/packages/ditto/config.ts b/packages/ditto/config.ts deleted file mode 100644 index 59554920..00000000 --- a/packages/ditto/config.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { DittoConf } from '@ditto/conf'; - -/** @deprecated Use middleware to set/get the config instead. */ -export const Conf = new DittoConf(Deno.env); diff --git a/packages/ditto/controllers/api/accounts.ts b/packages/ditto/controllers/api/accounts.ts index 252ddad6..d6d57824 100644 --- a/packages/ditto/controllers/api/accounts.ts +++ b/packages/ditto/controllers/api/accounts.ts @@ -1,39 +1,39 @@ -import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify'; +import { NostrEvent, NostrFilter, NSchema as n, NStore } from '@nostrify/nostrify'; import { nip19 } from 'nostr-tools'; import { z } from 'zod'; import { type AppController } from '@/app.ts'; import { getAuthor, getFollowedPubkeys } from '@/queries.ts'; import { booleanParamSchema, fileSchema } from '@/schema.ts'; -import { Storages } from '@/storages.ts'; import { uploadFile } from '@/utils/upload.ts'; import { nostrNow } from '@/utils.ts'; import { assertAuthenticated, createEvent, paginated, parseBody, updateEvent, updateListEvent } from '@/utils/api.ts'; import { extractIdentifier, lookupAccount, lookupPubkey } from '@/utils/lookup.ts'; import { renderAccounts, renderEventAccounts, renderStatuses } from '@/views.ts'; -import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; +import { AccountView } from '@/views/mastodon/AccountView.ts'; import { renderRelationship } from '@/views/mastodon/relationships.ts'; -import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts'; +import { StatusView } from '@/views/mastodon/StatusView.ts'; import { metadataSchema } from '@/schemas/nostr.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; import { bech32ToPubkey } from '@/utils.ts'; import { addTag, deleteTag, findReplyTag, getTagSet } from '@/utils/tags.ts'; import { getPubkeysBySearch } from '@/utils/search.ts'; -import { MastodonAccount } from '@/entities/MastodonAccount.ts'; const createAccountSchema = z.object({ username: z.string().min(1).max(30).regex(/^[a-z0-9_]+$/i), }); const createAccountController: AppController = async (c) => { - const pubkey = await c.get('signer')?.getPublicKey()!; + const { conf, user } = c.var; + + const pubkey = await user!.signer.getPublicKey(); const result = createAccountSchema.safeParse(await c.req.json()); if (!result.success) { return c.json({ error: 'Bad request', schema: result.error }, 400); } - if (c.var.conf.forbiddenUsernames.includes(result.data.username)) { + if (conf.forbiddenUsernames.includes(result.data.username)) { return c.json({ error: 'Username is reserved.' }, 422); } @@ -46,13 +46,11 @@ const createAccountController: AppController = async (c) => { }; const verifyCredentialsController: AppController = async (c) => { - const signer = c.get('signer')!; - const pubkey = await signer.getPublicKey(); - - const store = await Storages.db(); + const { user, store } = c.var; + const pubkey = await user!.signer.getPublicKey(); const [author, [settingsEvent]] = await Promise.all([ - getAuthor(pubkey, { signal: AbortSignal.timeout(5000) }), + getAuthor(c.var, pubkey), store.query([{ kinds: [30078], @@ -69,23 +67,24 @@ const verifyCredentialsController: AppController = async (c) => { // Do nothing } - const account = author - ? await renderAccount(author, { withSource: true, settingsStore }) - : await accountFromPubkey(pubkey, { withSource: true, settingsStore }); + const view = new AccountView(c.var); + const account = view.render(author, pubkey, { withSource: true, settingsStore }); return c.json(account); }; const accountController: AppController = async (c) => { const pubkey = c.req.param('pubkey'); + const event = await getAuthor(c.var, pubkey); - const event = await getAuthor(pubkey); if (event) { assertAuthenticated(c, event); - return c.json(await renderAccount(event)); - } else { - return c.json(await accountFromPubkey(pubkey)); } + + const view = new AccountView(c.var); + const account = view.render(event, pubkey); + + return c.json(account); }; const accountLookupController: AppController = async (c) => { @@ -95,17 +94,23 @@ const accountLookupController: AppController = async (c) => { return c.json({ error: 'Missing `acct` query parameter.' }, 422); } - const event = await lookupAccount(decodeURIComponent(acct)); + const view = new AccountView(c.var); + const event = await lookupAccount(c.var, decodeURIComponent(acct)); + if (event) { assertAuthenticated(c, event); - return c.json(await renderAccount(event)); + const account = view.render(event); + return c.json(account); } - try { - const pubkey = bech32ToPubkey(decodeURIComponent(acct)); - return c.json(await accountFromPubkey(pubkey!)); - } catch { - return c.json({ error: 'Could not find user.' }, 404); + + const pubkey = bech32ToPubkey(decodeURIComponent(acct)); + + if (pubkey) { + const account = view.render(undefined, pubkey); + return c.json(account); } + + return c.json({ error: 'Could not find user.' }, 404); }; const accountSearchQuerySchema = z.object({ @@ -115,11 +120,8 @@ const accountSearchQuerySchema = z.object({ }); const accountSearchController: AppController = async (c) => { - const { signal } = c.req.raw; - const { limit } = c.get('pagination'); - - const kysely = await Storages.kysely(); - const viewerPubkey = await c.get('signer')?.getPublicKey(); + const { store, kysely, user, pagination, signal } = c.var; + const { limit } = pagination; const result = accountSearchQuerySchema.safeParse(c.req.query()); @@ -128,14 +130,14 @@ const accountSearchController: AppController = async (c) => { } const query = decodeURIComponent(result.data.q); - const store = await Storages.search(); - const lookup = extractIdentifier(query); - const event = await lookupAccount(lookup ?? query); + const event = await lookupAccount(c.var, lookup ?? query); + const view = new AccountView(c.var); + const viewerPubkey = await user?.signer.getPublicKey(); if (!event && lookup) { - const pubkey = await lookupPubkey(lookup); - return c.json(pubkey ? [accountFromPubkey(pubkey)] : []); + const pubkey = await lookupPubkey(c.var, lookup); + return c.json(pubkey ? [view.render(undefined, pubkey)] : []); } const events: NostrEvent[] = []; @@ -143,7 +145,7 @@ const accountSearchController: AppController = async (c) => { if (event) { events.push(event); } else { - const following = viewerPubkey ? await getFollowedPubkeys(viewerPubkey) : new Set(); + const following = viewerPubkey ? await getFollowedPubkeys(c.var, viewerPubkey) : new Set(); const authors = [...await getPubkeysBySearch(kysely, { q: query, limit, offset: 0, following })]; const profiles = await store.query([{ kinds: [0], authors, limit }], { signal }); @@ -155,25 +157,25 @@ const accountSearchController: AppController = async (c) => { } } - const accounts = await hydrateEvents({ events, store, signal }) - .then((events) => events.map((event) => renderAccount(event))); + const accounts = await hydrateEvents(c.var, events) + .then((events) => events.map((event) => view.render(event))); return c.json(accounts); }; const relationshipsController: AppController = async (c) => { - const pubkey = await c.get('signer')?.getPublicKey()!; + const { store, user } = c.var; + + const pubkey = await user!.signer.getPublicKey(); const ids = z.array(z.string()).safeParse(c.req.queries('id[]')); if (!ids.success) { return c.json({ error: 'Missing `id[]` query parameters.' }, 422); } - const db = await Storages.db(); - const [sourceEvents, targetEvents] = await Promise.all([ - db.query([{ kinds: [3, 10000], authors: [pubkey] }]), - db.query([{ kinds: [3], authors: ids.data }]), + store.query([{ kinds: [3, 10000], authors: [pubkey] }]), + store.query([{ kinds: [3], authors: ids.data }]), ]); const event3 = sourceEvents.find((event) => event.kind === 3 && event.pubkey === pubkey); @@ -194,7 +196,6 @@ const relationshipsController: AppController = async (c) => { const accountStatusesQuerySchema = z.object({ pinned: booleanParamSchema.optional(), - limit: z.coerce.number().nonnegative().transform((v) => Math.min(v, 40)).catch(20), exclude_replies: booleanParamSchema.optional(), tagged: z.string().optional(), only_media: booleanParamSchema.optional(), @@ -202,12 +203,9 @@ const accountStatusesQuerySchema = z.object({ const accountStatusesController: AppController = async (c) => { const pubkey = c.req.param('pubkey'); - const { conf } = c.var; - const { since, until } = c.var.pagination; - const { pinned, limit, exclude_replies, tagged, only_media } = accountStatusesQuerySchema.parse(c.req.query()); - const { signal } = c.req.raw; - const store = await Storages.db(); + const { conf, store, pagination, signal } = c.var; + const { pinned, exclude_replies, tagged, only_media } = accountStatusesQuerySchema.parse(c.req.query()); const [[author], [user]] = await Promise.all([ store.query([{ kinds: [0], authors: [pubkey], limit: 1 }], { signal }), @@ -237,9 +235,7 @@ const accountStatusesController: AppController = async (c) => { const filter: NostrFilter = { authors: [pubkey], kinds: [1, 6, 20], - since, - until, - limit, + ...pagination, }; const search: string[] = []; @@ -260,10 +256,10 @@ const accountStatusesController: AppController = async (c) => { filter.search = search.join(' '); } - const opts = { signal, limit, timeout: conf.db.timeouts.timelines }; + const opts = { signal, timeout: conf.db.timeouts.timelines }; const events = await store.query([filter], opts) - .then((events) => hydrateEvents({ events, store, signal })) + .then((events) => hydrateEvents(c.var, events)) .then((events) => { if (exclude_replies) { return events.filter((event) => { @@ -274,14 +270,12 @@ const accountStatusesController: AppController = async (c) => { return events; }); - const viewerPubkey = await c.get('signer')?.getPublicKey(); + const view = new StatusView(c.var); const statuses = await Promise.all( - events.map((event) => { - if (event.kind === 6) return renderReblog(event, { viewerPubkey }); - return renderStatus(event, { viewerPubkey }); - }), + events.map((event) => view.render(event)), ); + return paginated(c, events, statuses); }; @@ -301,12 +295,11 @@ const updateCredentialsSchema = z.object({ }); const updateCredentialsController: AppController = async (c) => { - const signer = c.get('signer')!; - const pubkey = await signer.getPublicKey(); + const { store, user } = c.var; + + const pubkey = await user!.signer.getPublicKey(); const body = await parseBody(c.req.raw); const result = updateCredentialsSchema.safeParse(body); - const store = await Storages.db(); - const signal = c.req.raw.signal; if (!result.success) { return c.json(result.error, 422); @@ -319,6 +312,7 @@ const updateCredentialsController: AppController = async (c) => { event = (await store.query([{ kinds: [0], authors: [pubkey] }]))[0]; } else { event = await updateEvent( + c.var, { kinds: [0], authors: [pubkey], limit: 1 }, async (prev) => { const meta = n.json().pipe(metadataSchema).catch({}).parse(prev.content); @@ -364,26 +358,22 @@ const updateCredentialsController: AppController = async (c) => { tags: [], }; }, - c, ); } const settingsStore = result.data.pleroma_settings_store; - let account: MastodonAccount; - if (event) { - await hydrateEvents({ events: [event], store, signal }); - account = await renderAccount(event, { withSource: true, settingsStore }); - } else { - account = await accountFromPubkey(pubkey, { withSource: true, settingsStore }); - } + await hydrateEvents(c.var, [event]); + + const view = new AccountView(c.var); + const account = view.render(event, pubkey, { withSource: true, settingsStore }); if (settingsStore) { - await createEvent({ + await createEvent(c.var, { kind: 30078, tags: [['d', 'pub.ditto.pleroma_settings_store']], content: JSON.stringify(settingsStore), - }, c); + }); } return c.json(account); @@ -391,16 +381,19 @@ const updateCredentialsController: AppController = async (c) => { /** https://docs.joinmastodon.org/methods/accounts/#follow */ const followController: AppController = async (c) => { - const sourcePubkey = await c.get('signer')?.getPublicKey()!; + const { store, user } = c.var; + + const sourcePubkey = await user!.signer.getPublicKey(); const targetPubkey = c.req.param('pubkey'); await updateListEvent( + c.var, { kinds: [3], authors: [sourcePubkey], limit: 1 }, (tags) => addTag(tags, ['p', targetPubkey]), - c, ); - const relationship = await getRelationship(sourcePubkey, targetPubkey); + const relationship = await getRelationship(store, sourcePubkey, targetPubkey); + relationship.following = true; return c.json(relationship); @@ -408,16 +401,18 @@ const followController: AppController = async (c) => { /** https://docs.joinmastodon.org/methods/accounts/#unfollow */ const unfollowController: AppController = async (c) => { - const sourcePubkey = await c.get('signer')?.getPublicKey()!; + const { store, user } = c.var; + + const sourcePubkey = await user!.signer.getPublicKey(); const targetPubkey = c.req.param('pubkey'); await updateListEvent( + c.var, { kinds: [3], authors: [sourcePubkey], limit: 1 }, (tags) => deleteTag(tags, ['p', targetPubkey]), - c, ); - const relationship = await getRelationship(sourcePubkey, targetPubkey); + const relationship = await getRelationship(store, sourcePubkey, targetPubkey); return c.json(relationship); }; @@ -429,7 +424,7 @@ const followersController: AppController = (c) => { const followingController: AppController = async (c) => { const pubkey = c.req.param('pubkey'); - const pubkeys = await getFollowedPubkeys(pubkey); + const pubkeys = await getFollowedPubkeys(c.var, pubkey); return renderAccounts(c, [...pubkeys]); }; @@ -445,43 +440,45 @@ const unblockController: AppController = (c) => { /** https://docs.joinmastodon.org/methods/accounts/#mute */ const muteController: AppController = async (c) => { - const sourcePubkey = await c.get('signer')?.getPublicKey()!; + const { store, user } = c.var; + + const sourcePubkey = await user!.signer.getPublicKey(); const targetPubkey = c.req.param('pubkey'); await updateListEvent( + c.var, { kinds: [10000], authors: [sourcePubkey], limit: 1 }, (tags) => addTag(tags, ['p', targetPubkey]), - c, ); - const relationship = await getRelationship(sourcePubkey, targetPubkey); + const relationship = await getRelationship(store, sourcePubkey, targetPubkey); return c.json(relationship); }; /** https://docs.joinmastodon.org/methods/accounts/#unmute */ const unmuteController: AppController = async (c) => { - const sourcePubkey = await c.get('signer')?.getPublicKey()!; + const { store, user } = c.var; + + const sourcePubkey = await user!.signer.getPublicKey(); const targetPubkey = c.req.param('pubkey'); await updateListEvent( + c.var, { kinds: [10000], authors: [sourcePubkey], limit: 1 }, (tags) => deleteTag(tags, ['p', targetPubkey]), - c, ); - const relationship = await getRelationship(sourcePubkey, targetPubkey); + const relationship = await getRelationship(store, sourcePubkey, targetPubkey); return c.json(relationship); }; const favouritesController: AppController = async (c) => { - const pubkey = await c.get('signer')?.getPublicKey()!; - const params = c.get('pagination'); - const { signal } = c.req.raw; + const { store, user, pagination, signal } = c.var; - const store = await Storages.db(); + const pubkey = await user!.signer.getPublicKey(); const events7 = await store.query( - [{ kinds: [7], authors: [pubkey], ...params }], + [{ kinds: [7], authors: [pubkey], ...pagination }], { signal }, ); @@ -489,32 +486,32 @@ const favouritesController: AppController = async (c) => { .map((event) => event.tags.find((tag) => tag[0] === 'e')?.[1]) .filter((id): id is string => !!id); - const events1 = await store.query([{ kinds: [1, 20], ids }], { signal }) - .then((events) => hydrateEvents({ events, store, signal })); + const events = await store.query([{ kinds: [1, 20], ids }], { signal }) + .then((events) => hydrateEvents(c.var, events)); - const viewerPubkey = await c.get('signer')?.getPublicKey(); + const view = new StatusView(c.var); const statuses = await Promise.all( - events1.map((event) => renderStatus(event, { viewerPubkey })), + events.map((event) => view.render(event)), ); - return paginated(c, events1, statuses); + + return paginated(c, events, statuses); }; const familiarFollowersController: AppController = async (c) => { - const store = await Storages.db(); - const signer = c.get('signer')!; - const pubkey = await signer.getPublicKey(); + const { store, user } = c.var; const ids = z.array(z.string()).parse(c.req.queries('id[]')); - const follows = await getFollowedPubkeys(pubkey); + const pubkey = await user!.signer.getPublicKey(); + const follows = await getFollowedPubkeys(c.var, pubkey); + const view = new AccountView(c.var); const results = await Promise.all(ids.map(async (id) => { - const followLists = await store.query([{ kinds: [3], authors: [...follows], '#p': [id] }]) - .then((events) => hydrateEvents({ events, store })); + const followLists = await store + .query([{ kinds: [3], authors: [...follows], '#p': [id] }]) + .then((events) => hydrateEvents(c.var, events)); - const accounts = await Promise.all( - followLists.map((event) => event.author ? renderAccount(event.author) : accountFromPubkey(event.pubkey)), - ); + const accounts = followLists.map((event) => view.render(event.author, event.pubkey)); return { id, accounts }; })); @@ -522,12 +519,10 @@ const familiarFollowersController: AppController = async (c) => { return c.json(results); }; -async function getRelationship(sourcePubkey: string, targetPubkey: string) { - const db = await Storages.db(); - +async function getRelationship(store: NStore, sourcePubkey: string, targetPubkey: string) { const [sourceEvents, targetEvents] = await Promise.all([ - db.query([{ kinds: [3, 10000], authors: [sourcePubkey] }]), - db.query([{ kinds: [3], authors: [targetPubkey] }]), + store.query([{ kinds: [3, 10000], authors: [sourcePubkey] }]), + store.query([{ kinds: [3], authors: [targetPubkey] }]), ]); return renderRelationship({ diff --git a/packages/ditto/controllers/api/admin.ts b/packages/ditto/controllers/api/admin.ts index 1e3b4615..9906cd30 100644 --- a/packages/ditto/controllers/api/admin.ts +++ b/packages/ditto/controllers/api/admin.ts @@ -4,11 +4,10 @@ import { z } from 'zod'; import { type AppController } from '@/app.ts'; import { booleanParamSchema } from '@/schema.ts'; -import { Storages } from '@/storages.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; import { createAdminEvent, paginated, parseBody, updateEventInfo, updateUser } from '@/utils/api.ts'; import { renderNameRequest } from '@/views/ditto.ts'; -import { renderAdminAccount, renderAdminAccountFromPubkey } from '@/views/mastodon/admin-accounts.ts'; +import { AdminAccountView } from '@/views/mastodon/AdminAccountView.ts'; import { errorJson } from '@/utils/log.ts'; const adminAccountQuerySchema = z.object({ @@ -29,10 +28,8 @@ const adminAccountQuerySchema = z.object({ }); const adminAccountsController: AppController = async (c) => { - const { conf } = c.var; - const store = await Storages.db(); - const params = c.get('pagination'); - const { signal } = c.req.raw; + const { conf, store, pagination, signal } = c.var; + const { local, pending, @@ -43,13 +40,15 @@ const adminAccountsController: AppController = async (c) => { staff, } = adminAccountQuerySchema.parse(c.req.query()); + const view = new AdminAccountView(c.var); + if (pending) { if (disabled || silenced || suspended || sensitized) { return c.json([]); } const orig = await store.query( - [{ kinds: [30383], authors: [conf.pubkey], '#k': ['3036'], '#n': ['pending'], ...params }], + [{ kinds: [30383], authors: [conf.pubkey], '#k': ['3036'], '#n': ['pending'], ...pagination }], { signal }, ); @@ -59,8 +58,9 @@ const adminAccountsController: AppController = async (c) => { .filter((id): id is string => !!id), ); - const events = await store.query([{ kinds: [3036], ids: [...ids] }]) - .then((events) => hydrateEvents({ store, events, signal })); + const events = await store + .query([{ kinds: [3036], ids: [...ids] }]) + .then((events) => hydrateEvents(c.var, events)); const nameRequests = await Promise.all(events.map(renderNameRequest)); return paginated(c, orig, nameRequests); @@ -86,7 +86,10 @@ const adminAccountsController: AppController = async (c) => { n.push('moderator'); } - const events = await store.query([{ kinds: [30382], authors: [conf.pubkey], '#n': n, ...params }], { signal }); + const events = await store.query( + [{ kinds: [30382], authors: [conf.pubkey], '#n': n, ...pagination }], + { signal }, + ); const pubkeys = new Set( events @@ -94,29 +97,30 @@ const adminAccountsController: AppController = async (c) => { .filter((pubkey): pubkey is string => !!pubkey), ); - const authors = await store.query([{ kinds: [0], authors: [...pubkeys] }]) - .then((events) => hydrateEvents({ store, events, signal })); + const authors = await store + .query([{ kinds: [0], authors: [...pubkeys] }]) + .then((events) => hydrateEvents(c.var, events)); - const accounts = await Promise.all( - [...pubkeys].map((pubkey) => { - const author = authors.find((e) => e.pubkey === pubkey); - return author ? renderAdminAccount(author) : renderAdminAccountFromPubkey(pubkey); - }), - ); + const accounts = [...pubkeys].map((pubkey) => { + const author = authors.find((e) => e.pubkey === pubkey); + return view.render(author, pubkey); + }); return paginated(c, events, accounts); } - const filter: NostrFilter = { kinds: [0], ...params }; + const filter: NostrFilter = { kinds: [0], ...pagination }; if (local) { filter.search = `domain:${conf.url.host}`; } - const events = await store.query([filter], { signal }) - .then((events) => hydrateEvents({ store, events, signal })); + const events = await store + .query([filter], { signal }) + .then((events) => hydrateEvents(c.var, events)); + + const accounts = events.map((event) => view.render(event, event.pubkey)); - const accounts = await Promise.all(events.map(renderAdminAccount)); return paginated(c, events, accounts); }; @@ -125,12 +129,13 @@ const adminAccountActionSchema = z.object({ }); const adminActionController: AppController = async (c) => { - const { conf } = c.var; - const body = await parseBody(c.req.raw); - const store = await Storages.db(); - const result = adminAccountActionSchema.safeParse(body); const authorId = c.req.param('id'); + const { conf, store } = c.var; + + const body = await parseBody(c.req.raw); + const result = adminAccountActionSchema.safeParse(body); + if (!result.success) { return c.json({ error: 'This action is not allowed' }, 403); } @@ -151,46 +156,52 @@ const adminActionController: AppController = async (c) => { if (data.type === 'suspend') { n.disabled = true; n.suspended = true; - store.remove([{ authors: [authorId] }]).catch((e: unknown) => { + + store.remove!([{ authors: [authorId] }]).catch((e: unknown) => { logi({ level: 'error', ns: 'ditto.api.admin.account.action', type: data.type, error: errorJson(e) }); }); } if (data.type === 'revoke_name') { n.revoke_name = true; - store.remove([{ kinds: [30360], authors: [conf.pubkey], '#p': [authorId] }]).catch((e: unknown) => { + + store.remove!([{ kinds: [30360], authors: [conf.pubkey], '#p': [authorId] }]).catch((e: unknown) => { logi({ level: 'error', ns: 'ditto.api.admin.account.action', type: data.type, error: errorJson(e) }); }); } - await updateUser(authorId, n, c); + await updateUser(c.var, authorId, n); return c.json({}, 200); }; const adminApproveController: AppController = async (c) => { - const { conf } = c.var; const eventId = c.req.param('id'); - const store = await Storages.db(); + + const { conf, store } = c.var; const [event] = await store.query([{ kinds: [3036], ids: [eventId] }]); + if (!event) { return c.json({ error: 'Event not found' }, 404); } const r = event.tags.find(([name]) => name === 'r')?.[1]; + if (!r) { return c.json({ error: 'NIP-05 not found' }, 404); } + if (!z.string().email().safeParse(r).success) { return c.json({ error: 'Invalid NIP-05' }, 400); } const [existing] = await store.query([{ kinds: [30360], authors: [conf.pubkey], '#d': [r], limit: 1 }]); + if (existing) { return c.json({ error: 'NIP-05 already granted to another user' }, 400); } - await createAdminEvent({ + await createAdminEvent(c.var, { kind: 30360, tags: [ ['d', r], @@ -199,10 +210,10 @@ const adminApproveController: AppController = async (c) => { ['p', event.pubkey], ['e', event.id], ], - }, c); + }); - await updateEventInfo(eventId, { pending: false, approved: true, rejected: false }, c); - await hydrateEvents({ events: [event], store }); + await updateEventInfo(c.var, eventId, { pending: false, approved: true, rejected: false }); + await hydrateEvents(c.var, [event]); const nameRequest = await renderNameRequest(event); return c.json(nameRequest); @@ -210,17 +221,20 @@ const adminApproveController: AppController = async (c) => { const adminRejectController: AppController = async (c) => { const eventId = c.req.param('id'); - const store = await Storages.db(); + + const { store } = c.var; const [event] = await store.query([{ kinds: [3036], ids: [eventId] }]); + if (!event) { return c.json({ error: 'Event not found' }, 404); } - await updateEventInfo(eventId, { pending: false, approved: false, rejected: true }, c); - await hydrateEvents({ events: [event], store }); + await updateEventInfo(c.var, eventId, { pending: false, approved: false, rejected: true }); + await hydrateEvents(c.var, [event]); const nameRequest = await renderNameRequest(event); return c.json(nameRequest); }; + export { adminAccountsController, adminActionController, adminApproveController, adminRejectController }; diff --git a/packages/ditto/controllers/api/bookmarks.ts b/packages/ditto/controllers/api/bookmarks.ts index 6d80b500..0ec3877e 100644 --- a/packages/ditto/controllers/api/bookmarks.ts +++ b/packages/ditto/controllers/api/bookmarks.ts @@ -1,13 +1,12 @@ import { type AppController } from '@/app.ts'; -import { Storages } from '@/storages.ts'; import { getTagSet } from '@/utils/tags.ts'; import { renderStatuses } from '@/views.ts'; /** https://docs.joinmastodon.org/methods/bookmarks/#get */ const bookmarksController: AppController = async (c) => { - const store = await Storages.db(); - const pubkey = await c.get('signer')?.getPublicKey()!; - const { signal } = c.req.raw; + const { store, user, signal } = c.var; + + const pubkey = await user!.signer.getPublicKey(); const [event10003] = await store.query( [{ kinds: [10003], authors: [pubkey], limit: 1 }], diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index dd753884..20c5e747 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -7,7 +7,6 @@ import { z } from 'zod'; import { createEvent, parseBody } from '@/utils/api.ts'; import { requireNip44Signer } from '@/middleware/requireSigner.ts'; -import { requireStore } from '@/middleware/storeMiddleware.ts'; import { walletSchema } from '@/schema.ts'; import { swapNutzapsMiddleware } from '@/middleware/swapNutzapsMiddleware.ts'; import { isNostrId } from '@/utils.ts'; diff --git a/packages/ditto/controllers/api/ditto.ts b/packages/ditto/controllers/api/ditto.ts index 9465517c..d37b7531 100644 --- a/packages/ditto/controllers/api/ditto.ts +++ b/packages/ditto/controllers/api/ditto.ts @@ -2,7 +2,6 @@ import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify'; import { z } from 'zod'; import { AppController } from '@/app.ts'; -import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { getAuthor } from '@/queries.ts'; import { addTag } from '@/utils/tags.ts'; import { createEvent, paginated, parseBody, updateAdminEvent } from '@/utils/api.ts'; @@ -14,9 +13,7 @@ import { screenshotsSchema } from '@/schemas/nostr.ts'; import { booleanParamSchema, percentageSchema, wsUrlSchema } from '@/schema.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; import { renderNameRequest } from '@/views/ditto.ts'; -import { accountFromPubkey } from '@/views/mastodon/accounts.ts'; -import { renderAccount } from '@/views/mastodon/accounts.ts'; -import { Storages } from '@/storages.ts'; +import { AccountView } from '@/views/mastodon/AccountView.ts'; import { updateListAdminEvent } from '@/utils/api.ts'; const markerSchema = z.enum(['read', 'write']); @@ -29,8 +26,7 @@ const relaySchema = z.object({ type RelayEntity = z.infer; export const adminRelaysController: AppController = async (c) => { - const { conf } = c.var; - const store = await Storages.db(); + const { conf, store } = c.var; const [event] = await store.query([ { kinds: [10002], authors: [conf.pubkey], limit: 1 }, @@ -44,10 +40,10 @@ export const adminRelaysController: AppController = async (c) => { }; export const adminSetRelaysController: AppController = async (c) => { - const store = await Storages.db(); + const { conf, store } = c.var; const relays = relaySchema.array().parse(await c.req.json()); - const event = await new AdminSigner().signEvent({ + const event = await new AdminSigner(conf).signEvent({ kind: 10002, tags: relays.map(({ url, marker }) => marker ? ['r', url, marker] : ['r', url]), content: '', @@ -79,19 +75,18 @@ const nameRequestSchema = z.object({ }); export const nameRequestController: AppController = async (c) => { - const store = await Storages.db(); - const signer = c.get('signer')!; - const pubkey = await signer.getPublicKey(); - const { conf } = c.var; - + const { conf, store, user } = c.var; const { name, reason } = nameRequestSchema.parse(await c.req.json()); + const pubkey = await user!.signer.getPublicKey(); + const [existing] = await store.query([{ kinds: [3036], authors: [pubkey], '#r': [name], limit: 1 }]); + if (existing) { return c.json({ error: 'Name request already exists' }, 400); } - const event = await createEvent({ + const event = await createEvent(c.var, { kind: 3036, content: reason, tags: [ @@ -100,9 +95,9 @@ export const nameRequestController: AppController = async (c) => { ['l', name.split('@')[1], 'nip05.domain'], ['p', conf.pubkey], ], - }, c); + }); - await hydrateEvents({ events: [event], store: await Storages.db() }); + await hydrateEvents(c.var, [event]); const nameRequest = await renderNameRequest(event); return c.json(nameRequest); @@ -114,10 +109,9 @@ const nameRequestsSchema = z.object({ }); export const nameRequestsController: AppController = async (c) => { - const { conf } = c.var; - const store = await Storages.db(); - const signer = c.get('signer')!; - const pubkey = await signer.getPublicKey(); + const { conf, store, user } = c.var; + + const pubkey = await user!.signer.getPublicKey(); const params = c.get('pagination'); const { approved, rejected } = nameRequestsSchema.parse(c.req.query()); @@ -151,8 +145,9 @@ export const nameRequestsController: AppController = async (c) => { return c.json([]); } - const events = await store.query([{ kinds: [3036], ids: [...ids], authors: [pubkey] }]) - .then((events) => hydrateEvents({ store, events: events, signal: c.req.raw.signal })); + const events = await store + .query([{ kinds: [3036], ids: [...ids], authors: [pubkey] }]) + .then((events) => hydrateEvents(c.var, events)); const nameRequests = await Promise.all( events.map((event) => renderNameRequest(event)), @@ -170,10 +165,10 @@ const zapSplitSchema = z.record( ); export const updateZapSplitsController: AppController = async (c) => { - const { conf } = c.var; + const { conf, store } = c.var; + const body = await parseBody(c.req.raw); const result = zapSplitSchema.safeParse(body); - const store = c.get('store'); if (!result.success) { return c.json({ error: result.error }, 400); @@ -192,15 +187,15 @@ export const updateZapSplitsController: AppController = async (c) => { } await updateListAdminEvent( + c.var, { kinds: [30078], authors: [conf.pubkey], '#d': ['pub.ditto.zapSplits'], limit: 1 }, (tags) => pubkeys.reduce((accumulator, pubkey) => { return addTag(accumulator, ['p', pubkey, data[pubkey].weight.toString(), data[pubkey].message]); }, tags), - c, ); - return c.json(200); + return c.newResponse(null, 200); }; const deleteZapSplitSchema = z.array(n.id()).min(1); @@ -216,6 +211,7 @@ export const deleteZapSplitsController: AppController = async (c) => { } const dittoZapSplit = await getZapSplits(store, conf.pubkey); + if (!dittoZapSplit) { return c.json({ error: 'Zap split not activated, restart the server.' }, 404); } @@ -223,32 +219,32 @@ export const deleteZapSplitsController: AppController = async (c) => { const { data } = result; await updateListAdminEvent( + c.var, { kinds: [30078], authors: [conf.pubkey], '#d': ['pub.ditto.zapSplits'], limit: 1 }, (tags) => data.reduce((accumulator, currentValue) => { return deleteTag(accumulator, ['p', currentValue]); }, tags), - c, ); - return c.json(200); + return c.newResponse(null, 204); }; export const getZapSplitsController: AppController = async (c) => { - const { conf } = c.var; - const store = c.get('store'); + const { conf, store } = c.var; const dittoZapSplit: DittoZapSplits | undefined = await getZapSplits(store, conf.pubkey) ?? {}; + if (!dittoZapSplit) { return c.json({ error: 'Zap split not activated, restart the server.' }, 404); } const pubkeys = Object.keys(dittoZapSplit); + const view = new AccountView(c.var); const zapSplits = await Promise.all(pubkeys.map(async (pubkey) => { - const author = await getAuthor(pubkey); - - const account = author ? renderAccount(author) : accountFromPubkey(pubkey); + const author = await getAuthor(c.var, pubkey); + const account = view.render(author, pubkey); return { account, @@ -261,11 +257,12 @@ export const getZapSplitsController: AppController = async (c) => { }; export const statusZapSplitsController: AppController = async (c) => { - const store = c.get('store'); + const { store, signal } = c.var; + const id = c.req.param('id'); - const { signal } = c.req.raw; const [event] = await store.query([{ kinds: [1, 20], ids: [id], limit: 1 }], { signal }); + if (!event) { return c.json({ error: 'Event not found' }, 404); } @@ -275,14 +272,15 @@ export const statusZapSplitsController: AppController = async (c) => { const pubkeys = zapsTag.map((name) => name[1]); const users = await store.query([{ authors: pubkeys, kinds: [0], limit: pubkeys.length }], { signal }); - await hydrateEvents({ events: users, store, signal }); + await hydrateEvents(c.var, users); - const zapSplits = (await Promise.all(pubkeys.map((pubkey) => { - const author = (users.find((event) => event.pubkey === pubkey) as DittoEvent | undefined)?.author; - const account = author ? renderAccount(author) : accountFromPubkey(pubkey); + const view = new AccountView(c.var); + + const zapSplits = pubkeys.map((pubkey) => { + const author = users.find((event) => event.pubkey === pubkey)?.author; + const account = view.render(author, pubkey); const weight = percentageSchema.catch(0).parse(zapsTag.find((name) => name[1] === pubkey)![3]) ?? 0; - const message = zapsTag.find((name) => name[1] === pubkey)![4] ?? ''; return { @@ -290,7 +288,7 @@ export const statusZapSplitsController: AppController = async (c) => { message, weight, }; - }))).filter((zapSplit) => zapSplit.weight > 0); + }).filter((zapSplit) => zapSplit.weight > 0); return c.json(zapSplits, 200); }; @@ -317,9 +315,10 @@ export const updateInstanceController: AppController = async (c) => { return c.json(result.error, 422); } - const meta = await getInstanceMetadata(await Storages.db(), c.req.raw.signal); + const meta = await getInstanceMetadata(c.var); await updateAdminEvent( + c.var, { kinds: [0], authors: [pubkey], limit: 1 }, (_) => { const { @@ -343,8 +342,7 @@ export const updateInstanceController: AppController = async (c) => { tags: [], }; }, - c, ); - return c.json(204); + return c.newResponse(null, 204); }; diff --git a/packages/ditto/controllers/api/instance.ts b/packages/ditto/controllers/api/instance.ts index d17a91c1..5c5a7345 100644 --- a/packages/ditto/controllers/api/instance.ts +++ b/packages/ditto/controllers/api/instance.ts @@ -1,7 +1,6 @@ import denoJson from 'deno.json' with { type: 'json' }; import { AppController } from '@/app.ts'; -import { Storages } from '@/storages.ts'; import { getInstanceMetadata } from '@/utils/instance.ts'; const version = `3.0.0 (compatible; Ditto ${denoJson.version})`; @@ -18,7 +17,7 @@ const features = [ const instanceV1Controller: AppController = async (c) => { const { conf } = c.var; const { host, protocol } = conf.url; - const meta = await getInstanceMetadata(await Storages.db(), c.req.raw.signal); + const meta = await getInstanceMetadata(c.var); /** Protocol to use for WebSocket URLs, depending on the protocol of the `LOCAL_DOMAIN`. */ const wsProtocol = protocol === 'http:' ? 'ws:' : 'wss:'; @@ -78,7 +77,7 @@ const instanceV1Controller: AppController = async (c) => { const instanceV2Controller: AppController = async (c) => { const { conf } = c.var; const { host, protocol } = conf.url; - const meta = await getInstanceMetadata(await Storages.db(), c.req.raw.signal); + const meta = await getInstanceMetadata(c.var); /** Protocol to use for WebSocket URLs, depending on the protocol of the `LOCAL_DOMAIN`. */ const wsProtocol = protocol === 'http:' ? 'ws:' : 'wss:'; @@ -165,7 +164,7 @@ const instanceV2Controller: AppController = async (c) => { }; const instanceDescriptionController: AppController = async (c) => { - const meta = await getInstanceMetadata(await Storages.db(), c.req.raw.signal); + const meta = await getInstanceMetadata(c.var); return c.json({ content: meta.about, diff --git a/packages/ditto/controllers/api/mutes.ts b/packages/ditto/controllers/api/mutes.ts index 90b5f545..df683524 100644 --- a/packages/ditto/controllers/api/mutes.ts +++ b/packages/ditto/controllers/api/mutes.ts @@ -1,13 +1,11 @@ import { type AppController } from '@/app.ts'; -import { Storages } from '@/storages.ts'; import { getTagSet } from '@/utils/tags.ts'; import { renderAccounts } from '@/views.ts'; /** https://docs.joinmastodon.org/methods/mutes/#get */ const mutesController: AppController = async (c) => { - const store = await Storages.db(); - const pubkey = await c.get('signer')?.getPublicKey()!; - const { signal } = c.req.raw; + const { store, user, signal } = c.var; + const pubkey = await user!.signer.getPublicKey(); const [event10000] = await store.query( [{ kinds: [10000], authors: [pubkey], limit: 1 }], diff --git a/packages/ditto/controllers/api/oauth.ts b/packages/ditto/controllers/api/oauth.ts index 7ac2c2b2..c87cfa8b 100644 --- a/packages/ditto/controllers/api/oauth.ts +++ b/packages/ditto/controllers/api/oauth.ts @@ -1,14 +1,16 @@ -import { NConnectSigner, NSchema as n, NSecSigner } from '@nostrify/nostrify'; +import { DittoConf } from '@ditto/conf'; +import { DittoTables } from '@ditto/db'; +import { NConnectSigner, NRelay, NSchema as n, NSecSigner } from '@nostrify/nostrify'; import { escape } from 'entities'; import { generateSecretKey } from 'nostr-tools'; import { z } from 'zod'; import { AppController } from '@/app.ts'; -import { Storages } from '@/storages.ts'; import { nostrNow } from '@/utils.ts'; import { parseBody } from '@/utils/api.ts'; import { aesEncrypt } from '@/utils/aes.ts'; import { generateToken, getTokenHash } from '@/utils/auth.ts'; +import { Kysely } from 'kysely'; const passwordGrantSchema = z.object({ grant_type: z.literal('password'), @@ -39,7 +41,6 @@ const createTokenSchema = z.discriminatedUnion('grant_type', [ ]); const createTokenController: AppController = async (c) => { - const { conf } = c.var; const body = await parseBody(c.req.raw); const result = createTokenSchema.safeParse(body); @@ -50,7 +51,7 @@ const createTokenController: AppController = async (c) => { switch (result.data.grant_type) { case 'nostr_bunker': return c.json({ - access_token: await getToken(result.data, conf.seckey), + access_token: await getToken(c.var, result.data), token_type: 'Bearer', scope: 'read write follow push', created_at: nostrNow(), @@ -90,6 +91,8 @@ const revokeTokenSchema = z.object({ * https://docs.joinmastodon.org/methods/oauth/#revoke */ const revokeTokenController: AppController = async (c) => { + const { kysely } = c.var; + const body = await parseBody(c.req.raw); const result = revokeTokenSchema.safeParse(body); @@ -99,7 +102,6 @@ const revokeTokenController: AppController = async (c) => { const { token } = result.data; - const kysely = await Storages.kysely(); const tokenHash = await getTokenHash(token as `token1${string}`); await kysely @@ -110,11 +112,17 @@ const revokeTokenController: AppController = async (c) => { return c.json({}); }; +interface GetTokenOpts { + conf: DittoConf; + kysely: Kysely; + pubsub: NRelay; +} + async function getToken( + opts: GetTokenOpts, { pubkey: bunkerPubkey, secret, relays = [] }: { pubkey: string; secret?: string; relays?: string[] }, - dittoSeckey: Uint8Array, ): Promise<`token1${string}`> { - const kysely = await Storages.kysely(); + const { conf, kysely, pubsub } = opts; const { token, hash } = await generateToken(); const nip46Seckey = generateSecretKey(); @@ -123,7 +131,7 @@ async function getToken( encryption: 'nip44', pubkey: bunkerPubkey, signer: new NSecSigner(nip46Seckey), - relay: await Storages.pubsub(), // TODO: Use the relays from the request. + relay: pubsub, // TODO: Use the relays from the request. timeout: 60_000, }); @@ -134,7 +142,7 @@ async function getToken( token_hash: hash, pubkey: userPubkey, bunker_pubkey: bunkerPubkey, - nip46_sk_enc: await aesEncrypt(dittoSeckey, nip46Seckey), + nip46_sk_enc: await aesEncrypt(conf.seckey, nip46Seckey), nip46_relays: relays, created_at: new Date(), }).execute(); @@ -222,8 +230,6 @@ const oauthAuthorizeSchema = z.object({ /** Controller the OAuth form is POSTed to. */ const oauthAuthorizeController: AppController = async (c) => { - const { conf } = c.var; - /** FormData results in JSON. */ const result = oauthAuthorizeSchema.safeParse(await parseBody(c.req.raw)); @@ -236,11 +242,11 @@ const oauthAuthorizeController: AppController = async (c) => { const bunker = new URL(bunker_uri); - const token = await getToken({ + const token = await getToken(c.var, { pubkey: bunker.hostname, secret: bunker.searchParams.get('secret') || undefined, relays: bunker.searchParams.getAll('relay'), - }, conf.seckey); + }); if (redirectUri === 'urn:ietf:wg:oauth:2.0:oob') { return c.text(token); diff --git a/packages/ditto/controllers/api/pleroma.ts b/packages/ditto/controllers/api/pleroma.ts index 976c2c0a..9bcb7e76 100644 --- a/packages/ditto/controllers/api/pleroma.ts +++ b/packages/ditto/controllers/api/pleroma.ts @@ -3,14 +3,12 @@ import { z } from 'zod'; import { type AppController } from '@/app.ts'; import { configSchema, elixirTupleSchema } from '@/schemas/pleroma-api.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts'; -import { Storages } from '@/storages.ts'; import { createAdminEvent, updateAdminEvent, updateUser } from '@/utils/api.ts'; import { lookupPubkey } from '@/utils/lookup.ts'; import { getPleromaConfigs } from '@/utils/pleroma.ts'; const frontendConfigController: AppController = async (c) => { - const store = await Storages.db(); - const configDB = await getPleromaConfigs(store, c.req.raw.signal); + const configDB = await getPleromaConfigs(c.var); const frontendConfig = configDB.get(':pleroma', ':frontend_configurations'); if (frontendConfig) { @@ -26,8 +24,7 @@ const frontendConfigController: AppController = async (c) => { }; const configController: AppController = async (c) => { - const store = await Storages.db(); - const configs = await getPleromaConfigs(store, c.req.raw.signal); + const configs = await getPleromaConfigs(c.var); return c.json({ configs, need_reboot: false }); }; @@ -36,29 +33,28 @@ const updateConfigController: AppController = async (c) => { const { conf } = c.var; const { pubkey } = conf; - const store = await Storages.db(); - const configs = await getPleromaConfigs(store, c.req.raw.signal); + const configs = await getPleromaConfigs(c.var); const { configs: newConfigs } = z.object({ configs: z.array(configSchema) }).parse(await c.req.json()); configs.merge(newConfigs); - await createAdminEvent({ + await createAdminEvent(c.var, { kind: 30078, - content: await new AdminSigner().nip44.encrypt(pubkey, JSON.stringify(configs)), + content: await new AdminSigner(conf).nip44.encrypt(pubkey, JSON.stringify(configs)), tags: [ ['d', 'pub.ditto.pleroma.config'], ['encrypted', 'nip44'], ], - }, c); + }); return c.json({ configs: newConfigs, need_reboot: false }); }; const pleromaAdminDeleteStatusController: AppController = async (c) => { - await createAdminEvent({ + await createAdminEvent(c.var, { kind: 5, tags: [['e', c.req.param('id')]], - }, c); + }); return c.json({}); }; @@ -73,10 +69,11 @@ const pleromaAdminTagController: AppController = async (c) => { const params = pleromaAdminTagSchema.parse(await c.req.json()); for (const nickname of params.nicknames) { - const pubkey = await lookupPubkey(nickname); + const pubkey = await lookupPubkey(c.var, nickname); if (!pubkey) continue; await updateAdminEvent( + c, { kinds: [30382], authors: [conf.pubkey], '#d': [pubkey], limit: 1 }, (prev) => { const tags = prev?.tags ?? [['d', pubkey]]; @@ -94,11 +91,10 @@ const pleromaAdminTagController: AppController = async (c) => { tags, }; }, - c, ); } - return new Response(null, { status: 204 }); + return c.newResponse(null, { status: 204 }); }; const pleromaAdminUntagController: AppController = async (c) => { @@ -106,10 +102,11 @@ const pleromaAdminUntagController: AppController = async (c) => { const params = pleromaAdminTagSchema.parse(await c.req.json()); for (const nickname of params.nicknames) { - const pubkey = await lookupPubkey(nickname); + const pubkey = await lookupPubkey(c.var, nickname); if (!pubkey) continue; await updateAdminEvent( + c, { kinds: [30382], authors: [conf.pubkey], '#d': [pubkey], limit: 1 }, (prev) => ({ kind: 30382, @@ -117,11 +114,10 @@ const pleromaAdminUntagController: AppController = async (c) => { tags: (prev?.tags ?? [['d', pubkey]]) .filter(([name, value]) => !(name === 't' && params.tags.includes(value))), }), - c, ); } - return new Response(null, { status: 204 }); + return c.newResponse(null, { status: 204 }); }; const pleromaAdminSuggestSchema = z.object({ @@ -132,24 +128,26 @@ const pleromaAdminSuggestController: AppController = async (c) => { const { nicknames } = pleromaAdminSuggestSchema.parse(await c.req.json()); for (const nickname of nicknames) { - const pubkey = await lookupPubkey(nickname); - if (!pubkey) continue; - await updateUser(pubkey, { suggested: true }, c); + const pubkey = await lookupPubkey(c.var, nickname); + if (pubkey) { + await updateUser(c.var, pubkey, { suggested: true }); + } } - return new Response(null, { status: 204 }); + return c.newResponse(null, { status: 204 }); }; const pleromaAdminUnsuggestController: AppController = async (c) => { const { nicknames } = pleromaAdminSuggestSchema.parse(await c.req.json()); for (const nickname of nicknames) { - const pubkey = await lookupPubkey(nickname); - if (!pubkey) continue; - await updateUser(pubkey, { suggested: false }, c); + const pubkey = await lookupPubkey(c.var, nickname); + if (pubkey) { + await updateUser(c.var, pubkey, { suggested: false }); + } } - return new Response(null, { status: 204 }); + return c.newResponse(null, { status: 204 }); }; export { diff --git a/packages/ditto/controllers/api/push.ts b/packages/ditto/controllers/api/push.ts index 79063622..4d5df752 100644 --- a/packages/ditto/controllers/api/push.ts +++ b/packages/ditto/controllers/api/push.ts @@ -3,7 +3,6 @@ import { nip19 } from 'nostr-tools'; import { z } from 'zod'; import { AppController } from '@/app.ts'; -import { Storages } from '@/storages.ts'; import { parseBody } from '@/utils/api.ts'; import { getTokenHash } from '@/utils/auth.ts'; @@ -42,7 +41,8 @@ const pushSubscribeSchema = z.object({ }); export const pushSubscribeController: AppController = async (c) => { - const { conf } = c.var; + const { conf, kysely, user } = c.var; + const vapidPublicKey = await conf.vapidPublicKey; if (!vapidPublicKey) { @@ -50,10 +50,6 @@ export const pushSubscribeController: AppController = async (c) => { } const accessToken = getAccessToken(c.req.raw); - - const kysely = await Storages.kysely(); - const signer = c.get('signer')!; - const result = pushSubscribeSchema.safeParse(await parseBody(c.req.raw)); if (!result.success) { @@ -62,7 +58,7 @@ export const pushSubscribeController: AppController = async (c) => { const { subscription, data } = result.data; - const pubkey = await signer.getPublicKey(); + const pubkey = await user!.signer.getPublicKey(); const tokenHash = await getTokenHash(accessToken); const { id } = await kysely.transaction().execute(async (trx) => { @@ -97,7 +93,7 @@ export const pushSubscribeController: AppController = async (c) => { }; export const getSubscriptionController: AppController = async (c) => { - const { conf } = c.var; + const { conf, kysely } = c.var; const vapidPublicKey = await conf.vapidPublicKey; if (!vapidPublicKey) { @@ -106,7 +102,6 @@ export const getSubscriptionController: AppController = async (c) => { const accessToken = getAccessToken(c.req.raw); - const kysely = await Storages.kysely(); const tokenHash = await getTokenHash(accessToken); const row = await kysely diff --git a/packages/ditto/controllers/api/reactions.ts b/packages/ditto/controllers/api/reactions.ts index 0beb985d..06b07c92 100644 --- a/packages/ditto/controllers/api/reactions.ts +++ b/packages/ditto/controllers/api/reactions.ts @@ -1,10 +1,9 @@ import { AppController } from '@/app.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; -import { Storages } from '@/storages.ts'; import { createEvent } from '@/utils/api.ts'; -import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; -import { renderStatus } from '@/views/mastodon/statuses.ts'; +import { AccountView } from '@/views/mastodon/AccountView.ts'; +import { StatusView } from '@/views/mastodon/StatusView.ts'; /** * React to a status. @@ -13,29 +12,30 @@ import { renderStatus } from '@/views/mastodon/statuses.ts'; const reactionController: AppController = async (c) => { const id = c.req.param('id'); const emoji = c.req.param('emoji'); - const signer = c.get('signer')!; + + const { store } = c.var; if (!/^\p{RGI_Emoji}$/v.test(emoji)) { return c.json({ error: 'Invalid emoji' }, 400); } - const store = await Storages.db(); const [event] = await store.query([{ kinds: [1, 20], ids: [id], limit: 1 }]); if (!event) { return c.json({ error: 'Status not found' }, 404); } - await createEvent({ + await createEvent(c.var, { kind: 7, content: emoji, created_at: Math.floor(Date.now() / 1000), tags: [['e', id], ['p', event.pubkey]], - }, c); + }); - await hydrateEvents({ events: [event], store }); + await hydrateEvents(c.var, [event]); - const status = await renderStatus(event, { viewerPubkey: await signer.getPublicKey() }); + const view = new StatusView(c.var); + const status = await view.render(event); return c.json(status); }; @@ -47,9 +47,10 @@ const reactionController: AppController = async (c) => { const deleteReactionController: AppController = async (c) => { const id = c.req.param('id'); const emoji = c.req.param('emoji'); - const signer = c.get('signer')!; - const pubkey = await signer.getPublicKey(); - const store = await Storages.db(); + + const { store, user } = c.var; + + const pubkey = await user!.signer.getPublicKey(); if (!/^\p{RGI_Emoji}$/v.test(emoji)) { return c.json({ error: 'Invalid emoji' }, 400); @@ -71,14 +72,15 @@ const deleteReactionController: AppController = async (c) => { .filter((event) => event.content === emoji) .map((event) => ['e', event.id]); - await createEvent({ + await createEvent(c.var, { kind: 5, content: '', created_at: Math.floor(Date.now() / 1000), tags, - }, c); + }); - const status = renderStatus(event, { viewerPubkey: pubkey }); + const view = new StatusView(c.var); + const status = await view.render(event); return c.json(status); }; @@ -89,10 +91,12 @@ const deleteReactionController: AppController = async (c) => { */ const reactionsController: AppController = async (c) => { const id = c.req.param('id'); - const store = await Storages.db(); - const pubkey = await c.get('signer')?.getPublicKey(); const emoji = c.req.param('emoji') as string | undefined; + const { store, user } = c.var; + + const pubkey = await user?.signer.getPublicKey(); + if (typeof emoji === 'string' && !/^\p{RGI_Emoji}$/v.test(emoji)) { return c.json({ error: 'Invalid emoji' }, 400); } @@ -100,7 +104,7 @@ const reactionsController: AppController = async (c) => { const events = await store.query([{ kinds: [7], '#e': [id], limit: 100 }]) .then((events) => events.filter(({ content }) => /^\p{RGI_Emoji}$/v.test(content))) .then((events) => events.filter((event) => !emoji || event.content === emoji)) - .then((events) => hydrateEvents({ events, store })); + .then((events) => hydrateEvents(c.var, events)); /** Events grouped by emoji. */ const byEmoji = events.reduce((acc, event) => { @@ -110,18 +114,16 @@ const reactionsController: AppController = async (c) => { return acc; }, {} as Record); - const results = await Promise.all( - Object.entries(byEmoji).map(async ([name, events]) => { - return { - name, - count: events.length, - me: pubkey && events.some((event) => event.pubkey === pubkey), - accounts: await Promise.all( - events.map((event) => event.author ? renderAccount(event.author) : accountFromPubkey(event.pubkey)), - ), - }; - }), - ); + const view = new AccountView(c.var); + + const results = Object.entries(byEmoji).map(([name, events]) => { + return { + name, + count: events.length, + me: pubkey && events.some((event) => event.pubkey === pubkey), + accounts: events.map((event) => view.render(event.author, event.pubkey)), + }; + }); return c.json(results); }; diff --git a/packages/ditto/controllers/api/search.ts b/packages/ditto/controllers/api/search.ts index e5761f32..24b2f192 100644 --- a/packages/ditto/controllers/api/search.ts +++ b/packages/ditto/controllers/api/search.ts @@ -1,15 +1,17 @@ -import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify'; +import { DittoConf } from '@ditto/conf'; +import { DittoTables } from '@ditto/db'; +import { NostrEvent, NostrFilter, NSchema as n, NStore } from '@nostrify/nostrify'; +import { Kysely } from 'kysely'; import { nip19 } from 'nostr-tools'; import { z } from 'zod'; import { AppController } from '@/app.ts'; import { booleanParamSchema } from '@/schema.ts'; -import { Storages } from '@/storages.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; import { extractIdentifier, lookupPubkey } from '@/utils/lookup.ts'; -import { nip05Cache } from '@/utils/nip05.ts'; -import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; -import { renderStatus } from '@/views/mastodon/statuses.ts'; +import { resolveNip05 } from '@/utils/nip05.ts'; +import { AccountView } from '@/views/mastodon/AccountView.ts'; +import { StatusView } from '@/views/mastodon/StatusView.ts'; import { getFollowedPubkeys } from '@/queries.ts'; import { getPubkeysBySearch } from '@/utils/search.ts'; import { paginated, paginatedList } from '@/utils/api.ts'; @@ -26,23 +28,26 @@ const searchQuerySchema = z.object({ type SearchQuery = z.infer & { since?: number; until?: number; limit: number }; const searchController: AppController = async (c) => { + const { pagination } = c.var; + const result = searchQuerySchema.safeParse(c.req.query()); - const params = c.get('pagination'); - const { signal } = c.req.raw; - const viewerPubkey = await c.get('signer')?.getPublicKey(); if (!result.success) { return c.json({ error: 'Bad request', schema: result.error }, 422); } - const event = await lookupEvent({ ...result.data, ...params }, signal); + const event = await lookupEvent(c.var, { ...result.data, ...pagination }); const lookup = extractIdentifier(result.data.q); + const accountView = new AccountView(c.var); + const statusView = new StatusView(c.var); + // Render account from pubkey. if (!event && lookup) { - const pubkey = await lookupPubkey(lookup); + const pubkey = await lookupPubkey(c.var, lookup); + return c.json({ - accounts: pubkey ? [accountFromPubkey(pubkey)] : [], + accounts: pubkey ? [accountView.render(undefined, pubkey)] : [], statuses: [], hashtags: [], }); @@ -54,19 +59,19 @@ const searchController: AppController = async (c) => { events = [event]; } - events.push(...(await searchEvents({ ...result.data, ...params, viewerPubkey }, signal))); + events.push(...(await searchEvents(c.var, { ...result.data, ...pagination }))); const [accounts, statuses] = await Promise.all([ Promise.all( events .filter((event) => event.kind === 0) - .map((event) => renderAccount(event)) + .map((event) => accountView.render(event)) .filter(Boolean), ), Promise.all( events .filter((event) => event.kind === 1) - .map((event) => renderStatus(event, { viewerPubkey })) + .map((event) => statusView.render(event)) .filter(Boolean), ), ]); @@ -78,24 +83,31 @@ const searchController: AppController = async (c) => { }; if (result.data.type === 'accounts') { - return paginatedList(c, { ...result.data, ...params }, body); + return paginatedList(c, { ...result.data, ...pagination }, body); } else { return paginated(c, events, body); } }; +interface SearchEventsOpts { + conf: DittoConf; + store: NStore; + kysely: Kysely; + signal?: AbortSignal; +} + /** Get events for the search params. */ async function searchEvents( + opts: SearchEventsOpts, { q, type, since, until, limit, offset, account_id, viewerPubkey }: SearchQuery & { viewerPubkey?: string }, - signal: AbortSignal, ): Promise { + const { store, kysely, signal } = opts; + // Hashtag search is not supported. if (type === 'hashtags') { return Promise.resolve([]); } - const store = await Storages.search(); - const filter: NostrFilter = { kinds: typeToKinds(type), search: q, @@ -104,11 +116,9 @@ async function searchEvents( limit, }; - const kysely = await Storages.kysely(); - // For account search, use a special index, and prioritize followed accounts. if (type === 'accounts') { - const following = viewerPubkey ? await getFollowedPubkeys(viewerPubkey) : new Set(); + const following = viewerPubkey ? await getFollowedPubkeys(store, viewerPubkey) : new Set(); const searchPubkeys = await getPubkeysBySearch(kysely, { q, limit, offset, following }); filter.authors = [...searchPubkeys]; @@ -123,7 +133,7 @@ async function searchEvents( // Query the events. let events = await store .query([filter], { signal }) - .then((events) => hydrateEvents({ events, store, signal })); + .then((events) => hydrateEvents(opts, events)); // When using an authors filter, return the events in the same order as the filter. if (filter.authors) { @@ -147,18 +157,27 @@ function typeToKinds(type: SearchQuery['type']): number[] { } } -/** Resolve a searched value into an event, if applicable. */ -async function lookupEvent(query: SearchQuery, signal: AbortSignal): Promise { - const filters = await getLookupFilters(query, signal); - const store = await Storages.search(); +interface LookupEventOpts { + conf: DittoConf; + store: NStore; + kysely: Kysely; + signal?: AbortSignal; +} - return store.query(filters, { limit: 1, signal }) - .then((events) => hydrateEvents({ events, store, signal })) +/** Resolve a searched value into an event, if applicable. */ +async function lookupEvent(opts: LookupEventOpts, query: SearchQuery): Promise { + const { store, signal } = opts; + const filters = await getLookupFilters(opts, query); + + const _opts = { limit: 1, signal }; + + return store.query(filters, _opts) + .then((events) => hydrateEvents(opts, events)) .then(([event]) => event); } /** Get filters to lookup the input value. */ -async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: AbortSignal): Promise { +async function getLookupFilters(opts: LookupEventOpts, { q, type, resolve }: SearchQuery): Promise { const accounts = !type || type === 'accounts'; const statuses = !type || type === 'statuses'; @@ -168,8 +187,8 @@ async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: Abort if (n.id().safeParse(q).success) { const filters: NostrFilter[] = []; - if (accounts) filters.push({ kinds: [0], authors: [q] }); - if (statuses) filters.push({ kinds: [1, 20], ids: [q] }); + if (accounts) filters.push({ kinds: [0], authors: [q], limit: 1 }); + if (statuses) filters.push({ kinds: [1, 20], ids: [q], limit: 1 }); return filters; } @@ -181,16 +200,16 @@ async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: Abort const filters: NostrFilter[] = []; switch (result.type) { case 'npub': - if (accounts) filters.push({ kinds: [0], authors: [result.data] }); + if (accounts) filters.push({ kinds: [0], authors: [result.data], limit: 1 }); break; case 'nprofile': - if (accounts) filters.push({ kinds: [0], authors: [result.data.pubkey] }); + if (accounts) filters.push({ kinds: [0], authors: [result.data.pubkey], limit: 1 }); break; case 'note': - if (statuses) filters.push({ kinds: [1, 20], ids: [result.data] }); + if (statuses) filters.push({ kinds: [1, 20], ids: [result.data], limit: 1 }); break; case 'nevent': - if (statuses) filters.push({ kinds: [1, 20], ids: [result.data.id] }); + if (statuses) filters.push({ kinds: [1, 20], ids: [result.data.id], limit: 1 }); break; } return filters; @@ -199,9 +218,9 @@ async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: Abort } try { - const { pubkey } = await nip05Cache.fetch(lookup, { signal }); + const { pubkey } = await resolveNip05(opts, lookup); if (pubkey) { - return [{ kinds: [0], authors: [pubkey] }]; + return [{ kinds: [0], authors: [pubkey], limit: 1 }]; } } catch { // fall through diff --git a/packages/ditto/controllers/api/statuses.ts b/packages/ditto/controllers/api/statuses.ts index 7c2276c7..e29c3444 100644 --- a/packages/ditto/controllers/api/statuses.ts +++ b/packages/ditto/controllers/api/statuses.ts @@ -13,15 +13,14 @@ import { addTag, deleteTag } from '@/utils/tags.ts'; import { asyncReplaceAll } from '@/utils/text.ts'; import { lookupPubkey } from '@/utils/lookup.ts'; import { languageSchema } from '@/schema.ts'; -import { Storages } from '@/storages.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; import { assertAuthenticated, createEvent, paginated, paginatedList, parseBody, updateListEvent } from '@/utils/api.ts'; import { getInvoice, getLnurl } from '@/utils/lnurl.ts'; import { purifyEvent } from '@/utils/purify.ts'; import { getZapSplits } from '@/utils/zap-split.ts'; import { renderEventAccounts } from '@/views.ts'; -import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; -import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts'; +import { AccountView } from '@/views/mastodon/AccountView.ts'; +import { StatusView } from '@/views/mastodon/StatusView.ts'; const createStatusSchema = z.object({ in_reply_to_id: n.id().nullish(), @@ -47,17 +46,15 @@ const createStatusSchema = z.object({ const statusController: AppController = async (c) => { const id = c.req.param('id'); - const signal = AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(1500)]); - - const event = await getEvent(id, { signal }); + const event = await getEvent(c.var, id); if (event?.author) { assertAuthenticated(c, event.author); } if (event) { - const viewerPubkey = await c.get('signer')?.getPublicKey(); - const status = await renderStatus(event, { viewerPubkey }); + const view = new StatusView(c.var); + const status = await view.render(event); return c.json(status); } @@ -65,7 +62,7 @@ const statusController: AppController = async (c) => { }; const createStatusController: AppController = async (c) => { - const { conf } = c.var; + const { conf, user } = c.var; const body = await parseBody(c.req.raw); const result = createStatusSchema.safeParse(body); const store = c.get('store'); @@ -190,8 +187,8 @@ const createStatusController: AppController = async (c) => { } } - const pubkey = await c.get('signer')?.getPublicKey()!; - const author = pubkey ? await getAuthor(pubkey) : undefined; + const pubkey = await user!.signer.getPublicKey(); + const author = await getAuthor(c.var, pubkey); if (conf.zapSplitsEnabled) { const meta = n.json().pipe(n.metadata()).catch({}).parse(author?.content); @@ -247,39 +244,38 @@ const createStatusController: AppController = async (c) => { content += mediaUrls.join('\n'); } - const event = await createEvent({ + const event = await createEvent(c.var, { kind: 1, content, tags, - }, c); + }); if (data.quote_id) { - await hydrateEvents({ - events: [event], - store: await Storages.db(), - signal: c.req.raw.signal, - }); + await hydrateEvents(c.var, [event]); } - return c.json(await renderStatus({ ...event, author }, { viewerPubkey: author?.pubkey })); + const view = new StatusView(c.var); + + return c.json(await view.render({ ...event, author })); }; const deleteStatusController: AppController = async (c) => { - const { conf } = c.var; + const { conf, user } = c.var; const id = c.req.param('id'); - const pubkey = await c.get('signer')?.getPublicKey(); + const pubkey = await user!.signer.getPublicKey(); - const event = await getEvent(id, { signal: c.req.raw.signal }); + const event = await getEvent(c.var, id); if (event) { if (event.pubkey === pubkey) { - await createEvent({ + await createEvent(c.var, { kind: 5, tags: [['e', id, conf.relay, '', pubkey]], - }, c); + }); - const author = await getAuthor(event.pubkey); - return c.json(await renderStatus({ ...event, author }, { viewerPubkey: pubkey })); + const author = await getAuthor(c.var, event.pubkey); + const view = new StatusView(c.var); + return c.json(await view.render({ ...event, author })); } else { return c.json({ error: 'Unauthorized' }, 403); } @@ -289,14 +285,16 @@ const deleteStatusController: AppController = async (c) => { }; const contextController: AppController = async (c) => { + const { store } = c.var; + const id = c.req.param('id'); - const store = c.get('store'); const [event] = await store.query([{ kinds: [1, 20], ids: [id] }]); - const viewerPubkey = await c.get('signer')?.getPublicKey(); + + const view = new StatusView(c.var); async function renderStatuses(events: NostrEvent[]) { const statuses = await Promise.all( - events.map((event) => renderStatus(event, { viewerPubkey })), + events.map((event) => view.render(event)), ); return statuses.filter(Boolean); } @@ -307,11 +305,7 @@ const contextController: AppController = async (c) => { getDescendants(store, event), ]); - await hydrateEvents({ - events: [...ancestorEvents, ...descendantEvents], - signal: c.req.raw.signal, - store, - }); + await hydrateEvents(c.var, [...ancestorEvents, ...descendantEvents]); const [ancestors, descendants] = await Promise.all([ renderStatuses(ancestorEvents), @@ -325,24 +319,25 @@ const contextController: AppController = async (c) => { }; const favouriteController: AppController = async (c) => { - const { conf } = c.var; + const { conf, store } = c.var; const id = c.req.param('id'); - const store = await Storages.db(); - const [target] = await store.query([{ ids: [id], kinds: [1, 20] }]); + + const [target] = await store.query([{ ids: [id], kinds: [1, 20] }], c.var); if (target) { - await createEvent({ + await createEvent(c.var, { kind: 7, content: '+', tags: [ ['e', target.id, conf.relay, '', target.pubkey], ['p', target.pubkey, conf.relay], ], - }, c); + }); - await hydrateEvents({ events: [target], store }); + await hydrateEvents(c.var, [target]); - const status = await renderStatus(target, { viewerPubkey: await c.get('signer')?.getPublicKey() }); + const view = new StatusView(c.var); + const status = await view.render(target); if (status) { status.favourited = true; @@ -368,43 +363,38 @@ const favouritedByController: AppController = (c) => { const reblogStatusController: AppController = async (c) => { const { conf } = c.var; const eventId = c.req.param('id'); - const { signal } = c.req.raw; - const event = await getEvent(eventId, { - kind: 1, - }); + const event = await getEvent(c.var, eventId); if (!event) { return c.json({ error: 'Event not found.' }, 404); } - const reblogEvent = await createEvent({ + const reblogEvent = await createEvent(c.var, { kind: 6, tags: [ ['e', event.id, conf.relay, '', event.pubkey], ['p', event.pubkey, conf.relay], ], - }, c); - - await hydrateEvents({ - events: [reblogEvent], - store: await Storages.db(), - signal: signal, }); - const status = await renderReblog(reblogEvent, { viewerPubkey: await c.get('signer')?.getPublicKey() }); + await hydrateEvents(c.var, [reblogEvent]); + + const view = new StatusView(c.var); + const status = await view.renderReblog(reblogEvent); return c.json(status); }; /** https://docs.joinmastodon.org/methods/statuses/#unreblog */ const unreblogStatusController: AppController = async (c) => { - const { conf } = c.var; + const { conf, store, user } = c.var; + const eventId = c.req.param('id'); - const pubkey = await c.get('signer')?.getPublicKey()!; - const store = await Storages.db(); + const pubkey = await user!.signer.getPublicKey(); const [event] = await store.query([{ ids: [eventId], kinds: [1, 20] }]); + if (!event) { return c.json({ error: 'Record not found' }, 404); } @@ -417,38 +407,41 @@ const unreblogStatusController: AppController = async (c) => { return c.json({ error: 'Record not found' }, 404); } - await createEvent({ + await createEvent(c.var, { kind: 5, tags: [['e', repostEvent.id, conf.relay, '', repostEvent.pubkey]], - }, c); + }); - return c.json(await renderStatus(event, { viewerPubkey: pubkey })); + const view = new StatusView(c.var); + const status = await view.render(event); + + return c.json(status); }; const rebloggedByController: AppController = (c) => { const id = c.req.param('id'); - const params = c.get('pagination'); - return renderEventAccounts(c, [{ kinds: [6], '#e': [id], ...params }]); + const { pagination } = c.var; + return renderEventAccounts(c, [{ kinds: [6], '#e': [id], ...pagination }]); }; const quotesController: AppController = async (c) => { const id = c.req.param('id'); - const params = c.get('pagination'); - const store = await Storages.db(); + const { store, pagination } = c.var; const [event] = await store.query([{ ids: [id], kinds: [1, 20] }]); + if (!event) { return c.json({ error: 'Event not found.' }, 404); } const quotes = await store - .query([{ kinds: [1, 20], '#q': [event.id], ...params }]) - .then((events) => hydrateEvents({ events, store })); + .query([{ kinds: [1, 20], '#q': [event.id], ...pagination }]) + .then((events) => hydrateEvents(c.var, events)); - const viewerPubkey = await c.get('signer')?.getPublicKey(); + const view = new StatusView(c.var); const statuses = await Promise.all( - quotes.map((event) => renderStatus(event, { viewerPubkey })), + quotes.map((event) => view.render(event)), ); if (!statuses.length) { @@ -460,23 +453,22 @@ const quotesController: AppController = async (c) => { /** https://docs.joinmastodon.org/methods/statuses/#bookmark */ const bookmarkController: AppController = async (c) => { - const { conf } = c.var; - const pubkey = await c.get('signer')?.getPublicKey()!; + const { conf, user } = c.var; + const pubkey = await user!.signer.getPublicKey(); const eventId = c.req.param('id'); - const event = await getEvent(eventId, { - kind: 1, - relations: ['author', 'event_stats', 'author_stats'], - }); + const event = await getEvent(c.var, eventId); if (event) { await updateListEvent( + c.var, { kinds: [10003], authors: [pubkey], limit: 1 }, (tags) => addTag(tags, ['e', event.id, conf.relay, '', event.pubkey]), - c, ); - const status = await renderStatus(event, { viewerPubkey: pubkey }); + const view = new StatusView(c.var); + const status = await view.render(event); + if (status) { status.bookmarked = true; } @@ -488,23 +480,22 @@ const bookmarkController: AppController = async (c) => { /** https://docs.joinmastodon.org/methods/statuses/#unbookmark */ const unbookmarkController: AppController = async (c) => { - const { conf } = c.var; - const pubkey = await c.get('signer')?.getPublicKey()!; + const { conf, user } = c.var; + const pubkey = await user!.signer.getPublicKey(); const eventId = c.req.param('id'); - const event = await getEvent(eventId, { - kind: 1, - relations: ['author', 'event_stats', 'author_stats'], - }); + const event = await getEvent(c.var, eventId); if (event) { await updateListEvent( + c.var, { kinds: [10003], authors: [pubkey], limit: 1 }, (tags) => deleteTag(tags, ['e', event.id, conf.relay, '', event.pubkey]), - c, ); - const status = await renderStatus(event, { viewerPubkey: pubkey }); + const view = new StatusView(c.var); + const status = await view.render(event); + if (status) { status.bookmarked = false; } @@ -516,23 +507,22 @@ const unbookmarkController: AppController = async (c) => { /** https://docs.joinmastodon.org/methods/statuses/#pin */ const pinController: AppController = async (c) => { - const { conf } = c.var; - const pubkey = await c.get('signer')?.getPublicKey()!; + const { conf, user } = c.var; + const pubkey = await user!.signer.getPublicKey(); const eventId = c.req.param('id'); - const event = await getEvent(eventId, { - kind: 1, - relations: ['author', 'event_stats', 'author_stats'], - }); + const event = await getEvent(c.var, eventId); if (event) { await updateListEvent( + c.var, { kinds: [10001], authors: [pubkey], limit: 1 }, (tags) => addTag(tags, ['e', event.id, conf.relay, '', event.pubkey]), - c, ); - const status = await renderStatus(event, { viewerPubkey: pubkey }); + const view = new StatusView(c.var); + const status = await view.render(event); + if (status) { status.pinned = true; } @@ -544,25 +534,22 @@ const pinController: AppController = async (c) => { /** https://docs.joinmastodon.org/methods/statuses/#unpin */ const unpinController: AppController = async (c) => { - const { conf } = c.var; - const pubkey = await c.get('signer')?.getPublicKey()!; + const { conf, user } = c.var; + const pubkey = await user!.signer.getPublicKey(); const eventId = c.req.param('id'); - const { signal } = c.req.raw; - const event = await getEvent(eventId, { - kind: 1, - relations: ['author', 'event_stats', 'author_stats'], - signal, - }); + const event = await getEvent(c.var, eventId); if (event) { await updateListEvent( + c.var, { kinds: [10001], authors: [pubkey], limit: 1 }, (tags) => deleteTag(tags, ['e', event.id, conf.relay, '', event.pubkey]), - c, ); - const status = await renderStatus(event, { viewerPubkey: pubkey }); + const view = new StatusView(c.var); + const status = await view.render(event); + if (status) { status.pinned = false; } @@ -597,7 +584,7 @@ const zapController: AppController = async (c) => { let lnurl: undefined | string; if (status_id) { - target = await getEvent(status_id, { kind: 1, relations: ['author'], signal }); + target = await getEvent(c.var, status_id); const author = target?.author; const meta = n.json().pipe(n.metadata()).catch({}).parse(author?.content); lnurl = getLnurl(meta); @@ -625,11 +612,11 @@ const zapController: AppController = async (c) => { } if (target && lnurl) { - const nostr = await createEvent({ + const nostr = await createEvent(c.var, { kind: 9734, content: comment ?? '', tags, - }, c); + }); return c.json({ invoice: await getInvoice({ amount, nostr: purifyEvent(nostr), lnurl }, signal) }); } else { @@ -640,8 +627,8 @@ const zapController: AppController = async (c) => { const zappedByController: AppController = async (c) => { const id = c.req.param('id'); const params = c.get('listPagination'); - const store = await Storages.db(); - const kysely = await Storages.kysely(); + + const { kysely, store } = c.var; const zaps = await kysely.selectFrom('event_zaps') .selectAll() @@ -651,22 +638,21 @@ const zappedByController: AppController = async (c) => { .offset(params.offset).execute(); const authors = await store.query([{ kinds: [0], authors: zaps.map((zap) => zap.sender_pubkey) }]); + const view = new AccountView(c.var); - const results = (await Promise.all( - zaps.map(async (zap) => { - const amount = zap.amount_millisats; - const comment = zap.comment; + const results = zaps.map((zap) => { + const amount = zap.amount_millisats; + const comment = zap.comment; - const sender = authors.find((author) => author.pubkey === zap.sender_pubkey); - const account = sender ? await renderAccount(sender) : await accountFromPubkey(zap.sender_pubkey); + const sender = authors.find((author) => author.pubkey === zap.sender_pubkey); + const account = view.render(sender, zap.sender_pubkey); - return { - comment, - amount, - account, - }; - }), - )).filter(Boolean); + return { + comment, + amount, + account, + }; + }).filter(Boolean); return paginatedList(c, params, results); }; diff --git a/packages/ditto/controllers/api/streaming.ts b/packages/ditto/controllers/api/streaming.ts index 7f2f8b64..9f0f9085 100644 --- a/packages/ditto/controllers/api/streaming.ts +++ b/packages/ditto/controllers/api/streaming.ts @@ -1,23 +1,25 @@ +import { DittoTables } from '@ditto/db'; import { streamingClientMessagesCounter, streamingConnectionsGauge, streamingServerMessagesCounter, } from '@ditto/metrics'; import TTLCache from '@isaacs/ttlcache'; -import { NostrEvent, NostrFilter } from '@nostrify/nostrify'; +import { NostrEvent, NostrFilter, NStore } from '@nostrify/nostrify'; import { logi } from '@soapbox/logi'; +import { Kysely } from 'kysely'; import { z } from 'zod'; import { type AppController } from '@/app.ts'; import { MuteListPolicy } from '@/policies/MuteListPolicy.ts'; import { getFeedPubkeys } from '@/queries.ts'; +import { AdminStore } from '@/storages/AdminStore.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; -import { Storages } from '@/storages.ts'; import { getTokenHash } from '@/utils/auth.ts'; import { errorJson } from '@/utils/log.ts'; import { bech32ToPubkey, Time } from '@/utils.ts'; -import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts'; -import { renderNotification } from '@/views/mastodon/notifications.ts'; +import { StatusView } from '@/views/mastodon/StatusView.ts'; +import { NotificationView } from '@/views/mastodon/NotificationView.ts'; import { HTTPException } from '@hono/hono/http-exception'; /** @@ -68,7 +70,8 @@ const limiter = new TTLCache(); const connections = new Set(); const streamingController: AppController = async (c) => { - const { conf } = c.var; + const { conf, kysely, store, pubsub } = c.var; + const upgrade = c.req.header('upgrade'); const token = c.req.header('sec-websocket-protocol'); const stream = streamSchema.optional().catch(undefined).parse(c.req.query('stream')); @@ -78,7 +81,7 @@ const streamingController: AppController = async (c) => { return c.text('Please use websocket protocol', 400); } - const pubkey = token ? await getTokenPubkey(token) : undefined; + const pubkey = token ? await getTokenPubkey(kysely, token) : undefined; if (token && !pubkey) { return c.json({ error: 'Invalid access token' }, 401); } @@ -93,10 +96,10 @@ const streamingController: AppController = async (c) => { const { socket, response } = Deno.upgradeWebSocket(c.req.raw, { protocol: token, idleTimeout: 30 }); - const store = await Storages.db(); - const pubsub = await Storages.pubsub(); - - const policy = pubkey ? new MuteListPolicy(pubkey, await Storages.admin()) : undefined; + const statusView = new StatusView(c.var); + const adminStore = new AdminStore(conf, store); + const notificationView = new NotificationView(c.var); + const policy = pubkey ? new MuteListPolicy(pubkey, adminStore) : undefined; function send(e: StreamingEvent) { if (socket.readyState === WebSocket.OPEN) { @@ -118,7 +121,7 @@ const streamingController: AppController = async (c) => { } } - await hydrateEvents({ events: [event], store, signal: AbortSignal.timeout(1000) }); + await hydrateEvents({ conf, kysely, store, signal: AbortSignal.timeout(1000) }, [event]); const result = await render(event); @@ -137,17 +140,14 @@ const streamingController: AppController = async (c) => { streamingConnectionsGauge.set(connections.size); if (!stream) return; - const topicFilter = await topicToFilter(stream, c.req.query(), pubkey, conf.url.host); + const topicFilter = await topicToFilter(store, stream, c.req.query(), pubkey, conf.url.host); if (topicFilter) { sub([topicFilter], async (event) => { let payload: object | undefined; - if (event.kind === 1) { - payload = await renderStatus(event, { viewerPubkey: pubkey }); - } - if (event.kind === 6) { - payload = await renderReblog(event, { viewerPubkey: pubkey }); + if ([1, 6, 20, 1111].includes(event.kind)) { + payload = await statusView.render(event); } if (payload) { @@ -163,7 +163,7 @@ const streamingController: AppController = async (c) => { if (['user', 'user:notification'].includes(stream) && pubkey) { sub([{ '#p': [pubkey] }], async (event) => { if (event.pubkey === pubkey) return; // skip own events - const payload = await renderNotification(event, { viewerPubkey: pubkey }); + const payload = await notificationView.render(event); if (payload) { return { event: 'notification', @@ -205,6 +205,7 @@ const streamingController: AppController = async (c) => { }; async function topicToFilter( + store: NStore, topic: Stream, query: Record, pubkey: string | undefined, @@ -225,19 +226,19 @@ async function topicToFilter( // HACK: this puts the user's entire contacts list into RAM, // and then calls `matchFilters` over it. Refreshing the page // is required after following a new user. - return pubkey ? { kinds: [1, 6, 20], authors: [...await getFeedPubkeys(pubkey)] } : undefined; + return pubkey ? { kinds: [1, 6, 20], authors: [...await getFeedPubkeys(store, pubkey)] } : undefined; } } -async function getTokenPubkey(token: string): Promise { +async function getTokenPubkey(kysely: Kysely, token: string): Promise { if (token.startsWith('token1')) { - const kysely = await Storages.kysely(); const tokenHash = await getTokenHash(token as `token1${string}`); const row = await kysely .selectFrom('auth_tokens') .select('pubkey') .where('token_hash', '=', tokenHash) + .limit(1) .executeTakeFirst(); if (!row) { diff --git a/packages/ditto/controllers/api/translate.ts b/packages/ditto/controllers/api/translate.ts index 7395ff2f..f72647b3 100644 --- a/packages/ditto/controllers/api/translate.ts +++ b/packages/ditto/controllers/api/translate.ts @@ -8,16 +8,18 @@ import { MastodonTranslation } from '@/entities/MastodonTranslation.ts'; import { getEvent } from '@/queries.ts'; import { localeSchema } from '@/schema.ts'; import { parseBody } from '@/utils/api.ts'; -import { renderStatus } from '@/views/mastodon/statuses.ts'; +import { StatusView } from '@/views/mastodon/StatusView.ts'; const translateSchema = z.object({ lang: localeSchema, }); const translateController: AppController = async (c) => { - const result = translateSchema.safeParse(await parseBody(c.req.raw)); + const { store, user } = c.var; const { signal } = c.req.raw; + const result = translateSchema.safeParse(await parseBody(c.req.raw)); + if (!result.success) { return c.json({ error: 'Bad request.', schema: result.error }, 422); } @@ -31,18 +33,20 @@ const translateController: AppController = async (c) => { const id = c.req.param('id'); - const event = await getEvent(id, { signal }); + const event = await getEvent(store, id, { signal }); if (!event) { return c.json({ error: 'Record not found' }, 400); } - const viewerPubkey = await c.get('signer')?.getPublicKey(); + const viewerPubkey = await user?.signer.getPublicKey(); if (lang.toLowerCase() === event?.language?.toLowerCase()) { return c.json({ error: 'Source and target languages are the same. No translation needed.' }, 400); } - const status = await renderStatus(event, { viewerPubkey }); + const view = new StatusView(c.var); + const status = await view.render(event, { viewerPubkey }); + if (!status?.content) { return c.json({ error: 'Bad request.', schema: result.error }, 400); } diff --git a/packages/ditto/controllers/api/trends.ts b/packages/ditto/controllers/api/trends.ts index 88ea335e..8fb397b0 100644 --- a/packages/ditto/controllers/api/trends.ts +++ b/packages/ditto/controllers/api/trends.ts @@ -4,26 +4,39 @@ import { logi } from '@soapbox/logi'; import { z } from 'zod'; import { AppController } from '@/app.ts'; -import { Conf } from '@/config.ts'; +import { PreviewCard } from '@/entities/PreviewCard.ts'; import { paginationSchema } from '@/schemas/pagination.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; -import { Storages } from '@/storages.ts'; import { generateDateRange, Time } from '@/utils/time.ts'; import { unfurlCardCached } from '@/utils/unfurl.ts'; import { paginated } from '@/utils/api.ts'; import { errorJson } from '@/utils/log.ts'; -import { renderStatus } from '@/views/mastodon/statuses.ts'; +import { StatusView } from '@/views/mastodon/StatusView.ts'; -let trendingHashtagsCache = getTrendingHashtags(Conf).catch((e: unknown) => { - logi({ - level: 'error', - ns: 'ditto.trends.api', - type: 'tags', - msg: 'Failed to get trending hashtags', - error: errorJson(e), +interface MastodonTrendingHashtag { + name: string; + url: string; + history: { + day: string; + accounts: string; + uses: string; + }[]; +} + +let trendingHashtagsCache: Promise | undefined; + +function updateTrendingHashtagsCache(conf: DittoConf, store: NStore) { + trendingHashtagsCache = getTrendingHashtags(conf, store).catch((e: unknown) => { + logi({ + level: 'error', + ns: 'ditto.trends.api', + type: 'tags', + msg: 'Failed to get trending hashtags', + error: errorJson(e), + }); + return Promise.resolve([]); }); - return Promise.resolve([]); -}); +} Deno.cron('update trending hashtags cache', '35 * * * *', async () => { try { @@ -51,8 +64,7 @@ const trendingTagsController: AppController = async (c) => { return c.json(trends.slice(offset, offset + limit)); }; -async function getTrendingHashtags(conf: DittoConf) { - const store = await Storages.db(); +async function getTrendingHashtags(conf: DittoConf, store: NStore): Promise { const trends = await getTrendingTags(store, 't', conf.pubkey); return trends.map((trend) => { @@ -104,13 +116,20 @@ const trendingLinksController: AppController = async (c) => { return c.json(trends.slice(offset, offset + limit)); }; -async function getTrendingLinks(conf: DittoConf) { - const store = await Storages.db(); +interface MastodonTrendingLink extends PreviewCard { + history: { + day: string; + accounts: string; + uses: string; + }[]; +} + +async function getTrendingLinks(conf: DittoConf, store: NStore): Promise { const trends = await getTrendingTags(store, 'r', conf.pubkey); return Promise.all(trends.map(async (trend) => { const link = trend.value; - const card = await unfurlCardCached(link); + const card = await unfurlCardCached(conf, link); const history = trend.history.map(({ day, authors, uses }) => ({ day: String(day), @@ -140,8 +159,7 @@ async function getTrendingLinks(conf: DittoConf) { } const trendingStatusesController: AppController = async (c) => { - const { conf } = c.var; - const store = await Storages.db(); + const { conf, store } = c.var; const { limit, offset, until } = paginationSchema.parse(c.req.query()); const [label] = await store.query([{ @@ -163,15 +181,17 @@ const trendingStatusesController: AppController = async (c) => { } const results = await store.query([{ kinds: [1, 20], ids }]) - .then((events) => hydrateEvents({ events, store })); + .then((events) => hydrateEvents(c.var, events)); // Sort events in the order they appear in the label. const events = ids .map((id) => results.find((event) => event.id === id)) .filter((event): event is NostrEvent => !!event); + const view = new StatusView(c.var); + const statuses = await Promise.all( - events.map((event) => renderStatus(event, {})), + events.map((event) => view.render(event)), ); return paginated(c, results, statuses); diff --git a/packages/ditto/controllers/frontend.ts b/packages/ditto/controllers/frontend.ts index ec9f11a5..cfd35e99 100644 --- a/packages/ditto/controllers/frontend.ts +++ b/packages/ditto/controllers/frontend.ts @@ -1,20 +1,25 @@ +import { DittoConf } from '@ditto/conf'; +import { DittoTables } from '@ditto/db'; import { logi } from '@soapbox/logi'; +import { Kysely } from 'kysely'; import { AppMiddleware } from '@/app.ts'; -import { Storages } from '@/storages.ts'; import { getPathParams, MetadataEntities } from '@/utils/og-metadata.ts'; import { getInstanceMetadata } from '@/utils/instance.ts'; import { errorJson } from '@/utils/log.ts'; import { lookupPubkey } from '@/utils/lookup.ts'; import { renderMetadata } from '@/views/meta.ts'; import { getAuthor, getEvent } from '@/queries.ts'; -import { renderStatus } from '@/views/mastodon/statuses.ts'; -import { renderAccount } from '@/views/mastodon/accounts.ts'; +import { StatusView } from '@/views/mastodon/StatusView.ts'; +import { AccountView } from '@/views/mastodon/AccountView.ts'; +import { NStore } from '@nostrify/types'; /** Placeholder to find & replace with metadata. */ const META_PLACEHOLDER = '' as const; export const frontendController: AppMiddleware = async (c) => { + const { conf } = c.var; + c.header('Cache-Control', 'max-age=86400, s-maxage=30, public, stale-if-error=604800'); try { @@ -23,8 +28,8 @@ export const frontendController: AppMiddleware = async (c) => { if (content.includes(META_PLACEHOLDER)) { const params = getPathParams(c.req.path); try { - const entities = await getEntities(params ?? {}); - const meta = renderMetadata(c.req.url, entities); + const entities = await getEntities(c.var, params ?? {}); + const meta = renderMetadata(conf, c.req.raw, entities); return c.html(content.replace(META_PLACEHOLDER, meta)); } catch (e) { logi({ level: 'error', ns: 'ditto.frontend', msg: 'Error building meta tags', error: errorJson(e) }); @@ -37,27 +42,39 @@ export const frontendController: AppMiddleware = async (c) => { } }; -async function getEntities(params: { acct?: string; statusId?: string }): Promise { - const store = await Storages.db(); +interface GetEntitiesOpts { + conf: DittoConf; + store: NStore; + kysely: Kysely; + signal?: AbortSignal; +} +async function getEntities( + opts: GetEntitiesOpts, + params: { acct?: string; statusId?: string }, +): Promise { const entities: MetadataEntities = { - instance: await getInstanceMetadata(store), + instance: await getInstanceMetadata(opts), }; if (params.statusId) { - const event = await getEvent(params.statusId, { kind: 1 }); + const event = await getEvent(opts, params.statusId); + if (event) { - entities.status = await renderStatus(event, {}); + const view = new StatusView(opts); + entities.status = await view.render(event); entities.account = entities.status?.account; } return entities; } if (params.acct) { - const pubkey = await lookupPubkey(params.acct.replace(/^@/, '')); - const event = pubkey ? await getAuthor(pubkey) : undefined; + const pubkey = await lookupPubkey(opts, params.acct.replace(/^@/, '')); + const event = pubkey ? await getAuthor(opts, pubkey) : undefined; + if (event) { - entities.account = await renderAccount(event); + const view = new AccountView(opts); + entities.account = view.render(event); } } diff --git a/packages/ditto/controllers/manifest.ts b/packages/ditto/controllers/manifest.ts index 2e75de04..7d49ba1e 100644 --- a/packages/ditto/controllers/manifest.ts +++ b/packages/ditto/controllers/manifest.ts @@ -1,10 +1,9 @@ import { AppController } from '@/app.ts'; -import { Storages } from '@/storages.ts'; import { WebManifestCombined } from '@/types/webmanifest.ts'; import { getInstanceMetadata } from '@/utils/instance.ts'; export const manifestController: AppController = async (c) => { - const meta = await getInstanceMetadata(await Storages.db(), c.req.raw.signal); + const meta = await getInstanceMetadata(c.var); const manifest: WebManifestCombined = { description: meta.about, diff --git a/packages/ditto/controllers/metrics.ts b/packages/ditto/controllers/metrics.ts index 32a8783d..002cf673 100644 --- a/packages/ditto/controllers/metrics.ts +++ b/packages/ditto/controllers/metrics.ts @@ -7,12 +7,10 @@ import { import { register } from 'prom-client'; import { AppController } from '@/app.ts'; -import { Storages } from '@/storages.ts'; /** Prometheus/OpenMetrics controller. */ export const metricsController: AppController = async (c) => { - const db = await Storages.database(); - const pool = await Storages.client(); + const { db, pool } = c.var; // Update some metrics at request time. dbPoolSizeGauge.set(db.poolSize); diff --git a/packages/ditto/controllers/nostr/relay-info.ts b/packages/ditto/controllers/nostr/relay-info.ts index 54576b38..cd59827d 100644 --- a/packages/ditto/controllers/nostr/relay-info.ts +++ b/packages/ditto/controllers/nostr/relay-info.ts @@ -1,13 +1,12 @@ import denoJson from 'deno.json' with { type: 'json' }; import { AppController } from '@/app.ts'; -import { Storages } from '@/storages.ts'; import { getInstanceMetadata } from '@/utils/instance.ts'; const relayInfoController: AppController = async (c) => { const { conf } = c.var; - const store = await Storages.db(); - const meta = await getInstanceMetadata(store, c.req.raw.signal); + + const meta = await getInstanceMetadata(c.var); c.res.headers.set('access-control-allow-origin', '*'); diff --git a/packages/ditto/controllers/nostr/relay.ts b/packages/ditto/controllers/nostr/relay.ts index 92906d04..739ee0e8 100644 --- a/packages/ditto/controllers/nostr/relay.ts +++ b/packages/ditto/controllers/nostr/relay.ts @@ -10,20 +10,21 @@ import { NostrClientMsg, NostrClientREQ, NostrRelayMsg, + NRelay, NSchema as n, } from '@nostrify/nostrify'; import { AppController } from '@/app.ts'; import { relayInfoController } from '@/controllers/nostr/relay-info.ts'; -import * as pipeline from '@/pipeline.ts'; import { RelayError } from '@/RelayError.ts'; -import { Storages } from '@/storages.ts'; import { errorJson } from '@/utils/log.ts'; import { purifyEvent } from '@/utils/purify.ts'; import { MemoryRateLimiter } from '@/utils/ratelimiter/MemoryRateLimiter.ts'; import { MultiRateLimiter } from '@/utils/ratelimiter/MultiRateLimiter.ts'; import { RateLimiter } from '@/utils/ratelimiter/types.ts'; import { Time } from '@/utils/time.ts'; +import { DittoPipeline } from '@/DittoPipeline.ts'; +import { EventsDB } from '@/storages/EventsDB.ts'; /** Limit of initial events returned for a subscription. */ const FILTER_LIMIT = 100; @@ -46,8 +47,17 @@ const limiters = { /** Connections for metrics purposes. */ const connections = new Set(); +interface ConnectStreamOpts { + conf: DittoConf; + store: EventsDB; + pubsub: NRelay; + pipeline: DittoPipeline; +} + /** Set up the Websocket connection. */ -function connectStream(socket: WebSocket, ip: string | undefined, conf: DittoConf) { +function connectStream(opts: ConnectStreamOpts, socket: WebSocket, ip: string | undefined) { + const { conf, store, pubsub, pipeline } = opts; + const controllers = new Map(); socket.onopen = () => { @@ -127,9 +137,6 @@ function connectStream(socket: WebSocket, ip: string | undefined, conf: DittoCon controllers.get(subId)?.abort(); controllers.set(subId, controller); - const store = await Storages.db(); - const pubsub = await Storages.pubsub(); - try { for (const event of await store.query(filters, { limit: FILTER_LIMIT, timeout: conf.db.timeouts.relay })) { send(['EVENT', subId, purifyEvent(event)]); @@ -168,7 +175,7 @@ function connectStream(socket: WebSocket, ip: string | undefined, conf: DittoCon try { // This will store it (if eligible) and run other side-effects. - await pipeline.handleEvent(purifyEvent(event), { source: 'relay', signal: AbortSignal.timeout(1000) }); + await pipeline.event(purifyEvent(event), { source: 'relay', signal: AbortSignal.timeout(1000) }); send(['OK', event.id, true, '']); } catch (e) { if (e instanceof RelayError) { @@ -192,7 +199,6 @@ function connectStream(socket: WebSocket, ip: string | undefined, conf: DittoCon /** Handle COUNT. Return the number of events matching the filters. */ async function handleCount([_, subId, ...filters]: NostrClientCOUNT): Promise { if (rateLimited(limiters.req)) return; - const store = await Storages.db(); const { count } = await store.count(filters, { timeout: conf.db.timeouts.relay }); send(['COUNT', subId, { count, approximate: false }]); } @@ -235,7 +241,7 @@ const relayController: AppController = (c, next) => { } const { socket, response } = Deno.upgradeWebSocket(c.req.raw, { idleTimeout: 30 }); - connectStream(socket, ip, conf); + connectStream(c.var, socket, ip); return response; }; diff --git a/packages/ditto/controllers/well-known/nostr.ts b/packages/ditto/controllers/well-known/nostr.ts index 4fd366e7..24bfb994 100644 --- a/packages/ditto/controllers/well-known/nostr.ts +++ b/packages/ditto/controllers/well-known/nostr.ts @@ -18,11 +18,9 @@ const nostrController: AppController = async (c) => { return c.json(emptyResult); } - const store = c.get('store'); - const result = nameSchema.safeParse(c.req.query('name')); const name = result.success ? result.data : undefined; - const pointer = name ? await localNip05Lookup(store, name) : undefined; + const pointer = name ? await localNip05Lookup(c.var, name) : undefined; if (!name || !pointer) { // Not found, cache for 5 minutes. diff --git a/packages/ditto/cron.ts b/packages/ditto/cron.ts index ba8a18d5..1bde4eba 100644 --- a/packages/ditto/cron.ts +++ b/packages/ditto/cron.ts @@ -1,24 +1,28 @@ -import { sql } from 'kysely'; +import { DittoConf } from '@ditto/conf'; +import { DittoTables } from '@ditto/db'; +import { NStore } from '@nostrify/nostrify'; +import { Kysely, sql } from 'kysely'; -import { Storages } from '@/storages.ts'; -import { - updateTrendingEvents, - updateTrendingHashtags, - updateTrendingLinks, - updateTrendingPubkeys, - updateTrendingZappedEvents, -} from '@/trends.ts'; +import { DittoTrends } from '@/trends.ts'; + +interface CronOpts { + conf: DittoConf; + kysely: Kysely; + store: NStore; +} /** Start cron jobs for the application. */ -export function cron() { - Deno.cron('update trending pubkeys', '0 * * * *', updateTrendingPubkeys); - Deno.cron('update trending zapped events', '7 * * * *', updateTrendingZappedEvents); - Deno.cron('update trending events', '15 * * * *', updateTrendingEvents); - Deno.cron('update trending hashtags', '30 * * * *', updateTrendingHashtags); - Deno.cron('update trending links', '45 * * * *', updateTrendingLinks); +export function cron(opts: CronOpts) { + const trends = new DittoTrends(opts); + + Deno.cron('update trending pubkeys', '0 * * * *', () => trends.updateTrendingPubkeys()); + Deno.cron('update trending zapped events', '7 * * * *', () => trends.updateTrendingZappedEvents()); + Deno.cron('update trending events', '15 * * * *', () => trends.updateTrendingEvents()); + Deno.cron('update trending hashtags', '30 * * * *', () => trends.updateTrendingHashtags()); + Deno.cron('update trending links', '45 * * * *', () => trends.updateTrendingLinks()); Deno.cron('refresh top authors', '20 * * * *', async () => { - const kysely = await Storages.kysely(); + const { kysely } = opts; await sql`refresh materialized view top_authors`.execute(kysely); }); } diff --git a/packages/ditto/firehose.ts b/packages/ditto/firehose.ts index e967e1f2..8933ae54 100644 --- a/packages/ditto/firehose.ts +++ b/packages/ditto/firehose.ts @@ -1,32 +1,39 @@ import { firehoseEventsCounter } from '@ditto/metrics'; import { Semaphore } from '@core/asyncutil'; +import { NostrEvent, NRelay } from '@nostrify/nostrify'; import { logi } from '@soapbox/logi'; -import { Conf } from '@/config.ts'; -import { Storages } from '@/storages.ts'; import { nostrNow } from '@/utils.ts'; -import * as pipeline from '@/pipeline.ts'; - -const sem = new Semaphore(Conf.firehoseConcurrency); +interface StartFirehoseOpts { + store: Pick; + kinds?: number[]; + concurrency: number; +} /** * This function watches events on all known relays and performs * side-effects based on them, such as trending hashtag tracking * and storing events for notifications and the home feed. */ -export async function startFirehose(): Promise { - const store = await Storages.client(); +export async function startFirehose( + opts: StartFirehoseOpts, + onEvent: (event: NostrEvent) => Promise | void, +): Promise { + const { store, kinds, concurrency } = opts; - for await (const msg of store.req([{ kinds: Conf.firehoseKinds, limit: 0, since: nostrNow() }])) { + const sem = new Semaphore(concurrency); + + for await (const msg of store.req([{ kinds, limit: 0, since: nostrNow() }])) { if (msg[0] === 'EVENT') { const event = msg[2]; + logi({ level: 'debug', ns: 'ditto.event', source: 'firehose', id: event.id, kind: event.kind }); firehoseEventsCounter.inc({ kind: event.kind }); sem.lock(async () => { try { - await pipeline.handleEvent(event, { source: 'firehose', signal: AbortSignal.timeout(5000) }); + await onEvent(event); } catch { // Ignore } diff --git a/packages/ditto/interfaces/DittoFilter.ts b/packages/ditto/interfaces/DittoFilter.ts deleted file mode 100644 index f7f1a9ea..00000000 --- a/packages/ditto/interfaces/DittoFilter.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { NostrEvent } from '@nostrify/nostrify'; -import { DittoEvent } from '@/interfaces/DittoEvent.ts'; - -/** Additional properties that may be added by Ditto to events. */ -export type DittoRelation = Exclude; diff --git a/packages/ditto/middleware/auth98Middleware.ts b/packages/ditto/middleware/auth98Middleware.ts index 889e5ea9..d732b81d 100644 --- a/packages/ditto/middleware/auth98Middleware.ts +++ b/packages/ditto/middleware/auth98Middleware.ts @@ -2,41 +2,15 @@ import { HTTPException } from '@hono/hono/http-exception'; import { NostrEvent } from '@nostrify/nostrify'; import { type AppContext, type AppMiddleware } from '@/app.ts'; -import { ReadOnlySigner } from '@/signers/ReadOnlySigner.ts'; -import { Storages } from '@/storages.ts'; import { localRequest } from '@/utils/api.ts'; -import { - buildAuthEventTemplate, - parseAuthRequest, - type ParseAuthRequestOpts, - validateAuthEvent, -} from '@/utils/nip98.ts'; - -/** - * NIP-98 auth. - * https://github.com/nostr-protocol/nips/blob/master/98.md - */ -function auth98Middleware(opts: ParseAuthRequestOpts = {}): AppMiddleware { - return async (c, next) => { - const req = localRequest(c); - const result = await parseAuthRequest(req, opts); - - if (result.success) { - c.set('signer', new ReadOnlySigner(result.data.pubkey)); - c.set('proof', result.data); - } - - await next(); - }; -} +import { buildAuthEventTemplate, type ParseAuthRequestOpts, validateAuthEvent } from '@/utils/nip98.ts'; type UserRole = 'user' | 'admin'; /** Require the user to prove their role before invoking the controller. */ function requireRole(role: UserRole, opts?: ParseAuthRequestOpts): AppMiddleware { return withProof(async (c, proof, next) => { - const { conf } = c.var; - const store = await Storages.db(); + const { conf, store } = c.var; const [user] = await store.query([{ kinds: [30382], @@ -71,22 +45,8 @@ function withProof( opts?: ParseAuthRequestOpts, ): AppMiddleware { return async (c, next) => { - const signer = c.get('signer'); - const pubkey = await signer?.getPublicKey(); - const proof = c.get('proof') || await obtainProof(c, opts); - - // Prevent people from accidentally using the wrong account. This has no other security implications. - if (proof && pubkey && pubkey !== proof.pubkey) { - throw new HTTPException(401, { message: 'Pubkey mismatch' }); - } - + const proof = await obtainProof(c, opts); if (proof) { - c.set('proof', proof); - - if (!signer) { - c.set('signer', new ReadOnlySigner(proof.pubkey)); - } - await handler(c, proof, next); } else { throw new HTTPException(401, { message: 'No proof' }); @@ -96,8 +56,9 @@ function withProof( /** Get the proof over Nostr Connect. */ async function obtainProof(c: AppContext, opts?: ParseAuthRequestOpts) { - const signer = c.get('signer'); - if (!signer) { + const { user } = c.var; + + if (!user) { throw new HTTPException(401, { res: c.json({ error: 'No way to sign Nostr event' }, 401), }); @@ -105,7 +66,7 @@ async function obtainProof(c: AppContext, opts?: ParseAuthRequestOpts) { const req = localRequest(c); const reqEvent = await buildAuthEventTemplate(req, opts); - const resEvent = await signer.signEvent(reqEvent); + const resEvent = await user.signer.signEvent(reqEvent); const result = await validateAuthEvent(req, resEvent, opts); if (result.success) { @@ -113,4 +74,4 @@ async function obtainProof(c: AppContext, opts?: ParseAuthRequestOpts) { } } -export { auth98Middleware, requireProof, requireRole }; +export { requireProof, requireRole }; diff --git a/packages/ditto/middleware/cspMiddleware.ts b/packages/ditto/middleware/cspMiddleware.ts index e16829cc..208fe466 100644 --- a/packages/ditto/middleware/cspMiddleware.ts +++ b/packages/ditto/middleware/cspMiddleware.ts @@ -1,17 +1,15 @@ import { AppMiddleware } from '@/app.ts'; import { PleromaConfigDB } from '@/utils/PleromaConfigDB.ts'; -import { Storages } from '@/storages.ts'; import { getPleromaConfigs } from '@/utils/pleroma.ts'; let configDBCache: Promise | undefined; export const cspMiddleware = (): AppMiddleware => { return async (c, next) => { - const { conf } = c.var; - const store = await Storages.db(); + const { conf, store } = c.var; if (!configDBCache) { - configDBCache = getPleromaConfigs(store); + configDBCache = getPleromaConfigs(conf, store); } const { host, protocol, origin } = conf.url; diff --git a/packages/ditto/middleware/paginationMiddleware.ts b/packages/ditto/middleware/paginationMiddleware.ts index b1f1e2f3..498a2d76 100644 --- a/packages/ditto/middleware/paginationMiddleware.ts +++ b/packages/ditto/middleware/paginationMiddleware.ts @@ -1,9 +1,10 @@ import { AppMiddleware } from '@/app.ts'; import { paginationSchema } from '@/schemas/pagination.ts'; -import { Storages } from '@/storages.ts'; /** Fixes compatibility with Mastodon apps by that don't use `Link` headers. */ export const paginationMiddleware: AppMiddleware = async (c, next) => { + const { store } = c.var; + const pagination = paginationSchema.parse(c.req.query()); const { @@ -20,8 +21,6 @@ export const paginationMiddleware: AppMiddleware = async (c, next) => { if (minId) ids.push(minId); if (ids.length) { - const store = await Storages.db(); - const events = await store.query( [{ ids, limit: ids.length }], { signal: c.req.raw.signal }, diff --git a/packages/ditto/middleware/requireSigner.ts b/packages/ditto/middleware/requireSigner.ts index 7733b26f..1a4424f5 100644 --- a/packages/ditto/middleware/requireSigner.ts +++ b/packages/ditto/middleware/requireSigner.ts @@ -4,8 +4,8 @@ import { NostrSigner } from '@nostrify/nostrify'; import { SetRequired } from 'type-fest'; /** Throw a 401 if a signer isn't set. */ -export const requireSigner: MiddlewareHandler<{ Variables: { signer: NostrSigner } }> = async (c, next) => { - if (!c.get('signer')) { +export const requireSigner: MiddlewareHandler<{ Variables: { user: { signer: NostrSigner } } }> = async (c, next) => { + if (!c.var.user) { throw new HTTPException(401, { message: 'No pubkey provided' }); } @@ -13,17 +13,18 @@ export const requireSigner: MiddlewareHandler<{ Variables: { signer: NostrSigner }; /** Throw a 401 if a NIP-44 signer isn't set. */ -export const requireNip44Signer: MiddlewareHandler<{ Variables: { signer: SetRequired } }> = - async (c, next) => { - const signer = c.get('signer'); +export const requireNip44Signer: MiddlewareHandler< + { Variables: { user: { signer: SetRequired } } } +> = async (c, next) => { + const { user } = c.var; - if (!signer) { - throw new HTTPException(401, { message: 'No pubkey provided' }); - } + if (!user) { + throw new HTTPException(401, { message: 'No pubkey provided' }); + } - if (!signer.nip44) { - throw new HTTPException(401, { message: 'No NIP-44 signer provided' }); - } + if (!user.signer.nip44) { + throw new HTTPException(401, { message: 'No NIP-44 signer provided' }); + } - await next(); - }; + await next(); +}; diff --git a/packages/ditto/middleware/signerMiddleware.ts b/packages/ditto/middleware/signerMiddleware.ts index deea86b3..856be594 100644 --- a/packages/ditto/middleware/signerMiddleware.ts +++ b/packages/ditto/middleware/signerMiddleware.ts @@ -1,12 +1,13 @@ import { type DittoConf } from '@ditto/conf'; +import { DittoTables } from '@ditto/db'; import { MiddlewareHandler } from '@hono/hono'; import { HTTPException } from '@hono/hono/http-exception'; -import { NostrSigner, NSecSigner } from '@nostrify/nostrify'; +import { NostrSigner, NRelay, NSecSigner } from '@nostrify/nostrify'; +import { Kysely } from 'kysely'; import { nip19 } from 'nostr-tools'; import { ConnectSigner } from '@/signers/ConnectSigner.ts'; import { ReadOnlySigner } from '@/signers/ReadOnlySigner.ts'; -import { Storages } from '@/storages.ts'; import { aesDecrypt } from '@/utils/aes.ts'; import { getTokenHash } from '@/utils/auth.ts'; @@ -14,11 +15,13 @@ import { getTokenHash } from '@/utils/auth.ts'; const BEARER_REGEX = new RegExp(`^Bearer (${nip19.BECH32_REGEX.source})$`); /** Make a `signer` object available to all controllers, or unset if the user isn't logged in. */ -export const signerMiddleware: MiddlewareHandler<{ Variables: { signer: NostrSigner; conf: DittoConf } }> = async ( +export const signerMiddleware: MiddlewareHandler< + { Variables: { signer: NostrSigner; conf: DittoConf; kysely: Kysely; pubsub: NRelay } } +> = async ( c, next, ) => { - const { conf } = c.var; + const { conf, kysely } = c.var; const header = c.req.header('authorization'); const match = header?.match(BEARER_REGEX); @@ -27,7 +30,6 @@ export const signerMiddleware: MiddlewareHandler<{ Variables: { signer: NostrSig if (bech32.startsWith('token1')) { try { - const kysely = await Storages.kysely(); const tokenHash = await getTokenHash(bech32 as `token1${string}`); const { pubkey: userPubkey, bunker_pubkey: bunkerPubkey, nip46_sk_enc, nip46_relays } = await kysely @@ -45,6 +47,7 @@ export const signerMiddleware: MiddlewareHandler<{ Variables: { signer: NostrSig userPubkey, signer: new NSecSigner(nep46Seckey), relays: nip46_relays, + relay: c.var.pubsub, }), ); } catch { diff --git a/packages/ditto/middleware/storeMiddleware.ts b/packages/ditto/middleware/storeMiddleware.ts deleted file mode 100644 index f69712a3..00000000 --- a/packages/ditto/middleware/storeMiddleware.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { MiddlewareHandler } from '@hono/hono'; -import { NostrSigner, NStore } from '@nostrify/nostrify'; - -import { UserStore } from '@/storages/UserStore.ts'; -import { Storages } from '@/storages.ts'; - -export const requireStore: MiddlewareHandler<{ Variables: { store: NStore } }> = async (c, next) => { - if (!c.get('store')) { - throw new Error('Store is required'); - } - await next(); -}; - -/** Store middleware. */ -export const storeMiddleware: MiddlewareHandler<{ Variables: { signer?: NostrSigner; store: NStore } }> = async ( - c, - next, -) => { - const pubkey = await c.get('signer')?.getPublicKey(); - - if (pubkey) { - const store = new UserStore(pubkey, await Storages.admin()); - c.set('store', store); - } else { - c.set('store', await Storages.admin()); - } - await next(); -}; diff --git a/packages/ditto/middleware/swapNutzapsMiddleware.ts b/packages/ditto/middleware/swapNutzapsMiddleware.ts index aa68c1c1..dfb637ec 100644 --- a/packages/ditto/middleware/swapNutzapsMiddleware.ts +++ b/packages/ditto/middleware/swapNutzapsMiddleware.ts @@ -1,10 +1,9 @@ +import { AppEnv } from '@/app.ts'; import { CashuMint, CashuWallet, getEncodedToken, type Proof } from '@cashu/cashu-ts'; -import { type DittoConf } from '@ditto/conf'; import { MiddlewareHandler } from '@hono/hono'; import { HTTPException } from '@hono/hono/http-exception'; import { getPublicKey } from 'nostr-tools'; -import { NostrFilter, NostrSigner, NSchema as n, NStore } from '@nostrify/nostrify'; -import { SetRequired } from 'type-fest'; +import { NostrFilter, NSchema as n } from '@nostrify/nostrify'; import { stringToBytes } from '@scure/base'; import { logi } from '@soapbox/logi'; @@ -17,33 +16,24 @@ import { z } from 'zod'; * Swap nutzaps into wallet (create new events) if the user has a wallet, otheriwse, just fallthrough. * Errors are only thrown if 'signer' and 'store' middlewares are not set. */ -export const swapNutzapsMiddleware: MiddlewareHandler< - { Variables: { signer: SetRequired; store: NStore; conf: DittoConf } } -> = async (c, next) => { - const { conf } = c.var; - const signer = c.get('signer'); - const store = c.get('store'); +export const swapNutzapsMiddleware: MiddlewareHandler = async (c, next) => { + const { conf, store, user, signal } = c.var; - if (!signer) { + if (!user) { throw new HTTPException(401, { message: 'No pubkey provided' }); } - if (!signer.nip44) { + if (!user.signer.nip44) { throw new HTTPException(401, { message: 'No NIP-44 signer provided' }); } - if (!store) { - throw new HTTPException(401, { message: 'No store provided' }); - } - - const { signal } = c.req.raw; - const pubkey = await signer.getPublicKey(); + const pubkey = await user.signer.getPublicKey(); const [wallet] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal }); if (wallet) { let decryptedContent: string; try { - decryptedContent = await signer.nip44.decrypt(pubkey, wallet.content); + decryptedContent = await user.signer.nip44.decrypt(pubkey, wallet.content); } catch (e) { logi({ level: 'error', @@ -152,24 +142,24 @@ export const swapNutzapsMiddleware: MiddlewareHandler< const cashuWallet = new CashuWallet(new CashuMint(mint)); const receiveProofs = await cashuWallet.receive(token, { privkey }); - const unspentProofs = await createEvent({ + const unspentProofs = await createEvent({ ...c.var, user }, { kind: 7375, - content: await signer.nip44.encrypt( + content: await user.signer.nip44.encrypt( pubkey, JSON.stringify({ mint, proofs: receiveProofs, }), ), - }, c); + }); const amount = receiveProofs.reduce((accumulator, current) => { return accumulator + current.amount; }, 0); - await createEvent({ + await createEvent({ ...c.var, user }, { kind: 7376, - content: await signer.nip44.encrypt( + content: await user.signer.nip44.encrypt( pubkey, JSON.stringify([ ['direction', 'in'], @@ -178,7 +168,7 @@ export const swapNutzapsMiddleware: MiddlewareHandler< ]), ), tags: mintsToProofs[mint].redeemed, - }, c); + }); } catch (e) { logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: errorJson(e) }); } diff --git a/packages/ditto/middleware/uploaderMiddleware.ts b/packages/ditto/middleware/uploaderMiddleware.ts index 056106c1..f66a1fb2 100644 --- a/packages/ditto/middleware/uploaderMiddleware.ts +++ b/packages/ditto/middleware/uploaderMiddleware.ts @@ -8,7 +8,7 @@ import { S3Uploader } from '@/uploaders/S3Uploader.ts'; /** Set an uploader for the user. */ export const uploaderMiddleware: AppMiddleware = async (c, next) => { - const { signer, conf } = c.var; + const { conf, user } = c.var; switch (conf.uploader) { case 's3': @@ -35,11 +35,14 @@ export const uploaderMiddleware: AppMiddleware = async (c, next) => { c.set('uploader', new DenoUploader({ baseUrl: conf.mediaDomain, dir: conf.uploadsDir })); break; case 'nostrbuild': - c.set('uploader', new NostrBuildUploader({ endpoint: conf.nostrbuildEndpoint, signer, fetch: safeFetch })); + c.set( + 'uploader', + new NostrBuildUploader({ endpoint: conf.nostrbuildEndpoint, signer: user?.signer, fetch: safeFetch }), + ); break; case 'blossom': - if (signer) { - c.set('uploader', new BlossomUploader({ servers: conf.blossomServers, signer, fetch: safeFetch })); + if (user) { + c.set('uploader', new BlossomUploader({ servers: conf.blossomServers, signer: user.signer, fetch: safeFetch })); } break; } diff --git a/packages/ditto/notify.ts b/packages/ditto/notify.ts index 44ed5619..767aefd9 100644 --- a/packages/ditto/notify.ts +++ b/packages/ditto/notify.ts @@ -1,19 +1,26 @@ import { Semaphore } from '@core/asyncutil'; - -import { pipelineEncounters } from '@/caches/pipelineEncounters.ts'; -import { Conf } from '@/config.ts'; -import * as pipeline from '@/pipeline.ts'; -import { Storages } from '@/storages.ts'; +import { DittoDatabase } from '@ditto/db'; +import { NStore } from '@nostrify/nostrify'; import { logi } from '@soapbox/logi'; -const sem = new Semaphore(1); +import type { DittoConf } from '@ditto/conf'; +import type { DittoPipeline } from '@/DittoPipeline.ts'; -export async function startNotify(): Promise { - const { listen } = await Storages.database(); - const store = await Storages.db(); +interface StartNotifyOpts { + db: DittoDatabase; + store: NStore; + conf: DittoConf; + pipeline: DittoPipeline; + concurrency?: number; +} - listen('nostr_event', (id) => { - if (pipelineEncounters.has(id)) { +export function startNotify(opts: StartNotifyOpts): void { + const { conf, db, store, pipeline, concurrency = 1 } = opts; + + const sem = new Semaphore(concurrency); + + db.listen('nostr_event', (id) => { + if (pipeline.encounters.has(id)) { logi({ level: 'debug', ns: 'ditto.notify', id, skipped: true }); return; } @@ -22,13 +29,13 @@ export async function startNotify(): Promise { sem.lock(async () => { try { - const signal = AbortSignal.timeout(Conf.db.timeouts.default); + const signal = AbortSignal.timeout(conf.db.timeouts.default); const [event] = await store.query([{ ids: [id], limit: 1 }], { signal }); if (event) { logi({ level: 'debug', ns: 'ditto.event', source: 'notify', id: event.id, kind: event.kind }); - await pipeline.handleEvent(event, { source: 'notify', signal }); + await pipeline.event(event, { source: 'notify', signal }); } } catch { // Ignore diff --git a/packages/ditto/pipeline.ts b/packages/ditto/pipeline.ts deleted file mode 100644 index d3168c0e..00000000 --- a/packages/ditto/pipeline.ts +++ /dev/null @@ -1,401 +0,0 @@ -import { DittoTables } from '@ditto/db'; -import { pipelineEventsCounter, policyEventsCounter, webPushNotificationsCounter } from '@ditto/metrics'; -import { NKinds, NostrEvent, NSchema as n } from '@nostrify/nostrify'; -import { logi } from '@soapbox/logi'; -import { Kysely, UpdateObject } from 'kysely'; -import tldts from 'tldts'; -import { z } from 'zod'; - -import { pipelineEncounters } from '@/caches/pipelineEncounters.ts'; -import { Conf } from '@/config.ts'; -import { DittoPush } from '@/DittoPush.ts'; -import { DittoEvent } from '@/interfaces/DittoEvent.ts'; -import { RelayError } from '@/RelayError.ts'; -import { AdminSigner } from '@/signers/AdminSigner.ts'; -import { hydrateEvents } from '@/storages/hydrate.ts'; -import { Storages } from '@/storages.ts'; -import { eventAge, Time } from '@/utils.ts'; -import { getAmount } from '@/utils/bolt11.ts'; -import { faviconCache } from '@/utils/favicon.ts'; -import { errorJson } from '@/utils/log.ts'; -import { nip05Cache } from '@/utils/nip05.ts'; -import { parseNoteContent, stripimeta } from '@/utils/note.ts'; -import { purifyEvent } from '@/utils/purify.ts'; -import { updateStats } from '@/utils/stats.ts'; -import { getTagSet } from '@/utils/tags.ts'; -import { unfurlCardCached } from '@/utils/unfurl.ts'; -import { renderWebPushNotification } from '@/views/mastodon/push.ts'; -import { policyWorker } from '@/workers/policy.ts'; -import { verifyEventWorker } from '@/workers/verify.ts'; - -interface PipelineOpts { - signal: AbortSignal; - source: 'relay' | 'api' | 'firehose' | 'pipeline' | 'notify' | 'internal'; -} - -/** - * Common pipeline function to process (and maybe store) events. - * It is idempotent, so it can be called multiple times for the same event. - */ -async function handleEvent(event: DittoEvent, opts: PipelineOpts): Promise { - // Skip events that have already been encountered. - if (pipelineEncounters.get(event.id)) { - throw new RelayError('duplicate', 'already have this event'); - } - // Reject events that are too far in the future. - if (eventAge(event) < -Time.minutes(1)) { - throw new RelayError('invalid', 'event too far in the future'); - } - // Integer max value for Postgres. - if (event.kind >= 2_147_483_647) { - throw new RelayError('invalid', 'event kind too large'); - } - // The only point of ephemeral events is to stream them, - // so throw an error if we're not even going to do that. - if (NKinds.ephemeral(event.kind) && !isFresh(event)) { - throw new RelayError('invalid', 'event too old'); - } - // Block NIP-70 events, because we have no way to `AUTH`. - if (isProtectedEvent(event)) { - throw new RelayError('invalid', 'protected event'); - } - // Validate the event's signature. - if (!(await verifyEventWorker(event))) { - throw new RelayError('invalid', 'invalid signature'); - } - // Recheck encountered after async ops. - if (pipelineEncounters.has(event.id)) { - throw new RelayError('duplicate', 'already have this event'); - } - // Set the event as encountered after verifying the signature. - pipelineEncounters.set(event.id, true); - - // Log the event. - logi({ level: 'debug', ns: 'ditto.event', source: 'pipeline', id: event.id, kind: event.kind }); - pipelineEventsCounter.inc({ kind: event.kind }); - - // NIP-46 events get special treatment. - // They are exempt from policies and other side-effects, and should be streamed out immediately. - // If streaming fails, an error should be returned. - if (event.kind === 24133) { - await streamOut(event); - return; - } - - // Ensure the event doesn't violate the policy. - if (event.pubkey !== Conf.pubkey) { - await policyFilter(event, opts.signal); - } - - // Prepare the event for additional checks. - // FIXME: This is kind of hacky. Should be reorganized to fetch only what's needed for each stage. - await hydrateEvent(event, opts.signal); - - // Ensure that the author is not banned. - const n = getTagSet(event.user?.tags ?? [], 'n'); - if (n.has('disabled')) { - throw new RelayError('blocked', 'author is blocked'); - } - - // Ephemeral events must throw if they are not streamed out. - if (NKinds.ephemeral(event.kind)) { - await Promise.all([ - streamOut(event), - webPush(event), - ]); - return; - } - - // Events received through notify are thought to already be in the database, so they only need to be streamed. - if (opts.source === 'notify') { - await Promise.all([ - streamOut(event), - webPush(event), - ]); - return; - } - - const kysely = await Storages.kysely(); - - try { - await storeEvent(purifyEvent(event), opts.signal); - } finally { - // This needs to run in steps, and should not block the API from responding. - Promise.allSettled([ - handleZaps(kysely, event), - updateAuthorData(event, opts.signal), - prewarmLinkPreview(event, opts.signal), - generateSetEvents(event), - ]) - .then(() => - Promise.allSettled([ - streamOut(event), - webPush(event), - ]) - ); - } -} - -async function policyFilter(event: NostrEvent, signal: AbortSignal): Promise { - try { - const result = await policyWorker.call(event, signal); - const [, , ok, reason] = result; - logi({ level: 'debug', ns: 'ditto.policy', id: event.id, kind: event.kind, ok, reason }); - policyEventsCounter.inc({ ok: String(ok) }); - RelayError.assert(result); - } catch (e) { - if (e instanceof RelayError) { - throw e; - } else { - logi({ level: 'error', ns: 'ditto.policy', id: event.id, kind: event.kind, error: errorJson(e) }); - throw new RelayError('blocked', 'policy error'); - } - } -} - -/** Check whether the event has a NIP-70 `-` tag. */ -function isProtectedEvent(event: NostrEvent): boolean { - return event.tags.some(([name]) => name === '-'); -} - -/** Hydrate the event with the user, if applicable. */ -async function hydrateEvent(event: DittoEvent, signal: AbortSignal): Promise { - await hydrateEvents({ events: [event], store: await Storages.db(), signal }); -} - -/** Maybe store the event, if eligible. */ -async function storeEvent(event: NostrEvent, signal?: AbortSignal): Promise { - if (NKinds.ephemeral(event.kind)) return; - const store = await Storages.db(); - - try { - await store.transaction(async (store, kysely) => { - await updateStats({ event, store, kysely }); - await store.event(event, { signal }); - }); - } catch (e) { - // If the failure is only because of updateStats (which runs first), insert the event anyway. - // We can't catch this in the transaction because the error aborts the transaction on the Postgres side. - if (e instanceof Error && e.message.includes('event_stats' satisfies keyof DittoTables)) { - await store.event(event, { signal }); - } else { - throw e; - } - } -} - -/** Parse kind 0 metadata and track indexes in the database. */ -async function updateAuthorData(event: NostrEvent, signal: AbortSignal): Promise { - if (event.kind !== 0) return; - - // Parse metadata. - const metadata = n.json().pipe(n.metadata()).catch({}).safeParse(event.content); - if (!metadata.success) return; - - const { name, nip05 } = metadata.data; - - const kysely = await Storages.kysely(); - - const updates: UpdateObject = {}; - - const authorStats = await kysely - .selectFrom('author_stats') - .selectAll() - .where('pubkey', '=', event.pubkey) - .executeTakeFirst(); - - const lastVerified = authorStats?.nip05_last_verified_at; - const eventNewer = !lastVerified || event.created_at > lastVerified; - - try { - if (nip05 !== authorStats?.nip05 && eventNewer || !lastVerified) { - if (nip05) { - const tld = tldts.parse(nip05); - if (tld.isIcann && !tld.isIp && !tld.isPrivate) { - const pointer = await nip05Cache.fetch(nip05.toLowerCase(), { signal }); - if (pointer.pubkey === event.pubkey) { - updates.nip05 = nip05; - updates.nip05_domain = tld.domain; - updates.nip05_hostname = tld.hostname; - updates.nip05_last_verified_at = event.created_at; - } - } - } else { - updates.nip05 = null; - updates.nip05_domain = null; - updates.nip05_hostname = null; - updates.nip05_last_verified_at = event.created_at; - } - } - } catch { - // Fallthrough. - } - - // Fetch favicon. - const domain = nip05?.split('@')[1].toLowerCase(); - if (domain) { - try { - await faviconCache.fetch(domain, { signal }); - } catch { - // Fallthrough. - } - } - - const search = [name, nip05].filter(Boolean).join(' ').trim(); - - if (search !== authorStats?.search) { - updates.search = search; - } - - if (Object.keys(updates).length) { - await kysely.insertInto('author_stats') - .values({ - pubkey: event.pubkey, - followers_count: 0, - following_count: 0, - notes_count: 0, - search, - ...updates, - }) - .onConflict((oc) => oc.column('pubkey').doUpdateSet(updates)) - .execute(); - } -} - -async function prewarmLinkPreview(event: NostrEvent, signal: AbortSignal): Promise { - const { firstUrl } = parseNoteContent(stripimeta(event.content, event.tags), []); - if (firstUrl) { - await unfurlCardCached(firstUrl, signal); - } -} - -/** Determine if the event is being received in a timely manner. */ -function isFresh(event: NostrEvent): boolean { - return eventAge(event) < Time.minutes(1); -} - -/** Distribute the event through active subscriptions. */ -async function streamOut(event: NostrEvent): Promise { - if (!isFresh(event)) { - throw new RelayError('invalid', 'event too old'); - } - - const pubsub = await Storages.pubsub(); - await pubsub.event(event); -} - -async function webPush(event: NostrEvent): Promise { - if (!isFresh(event)) { - throw new RelayError('invalid', 'event too old'); - } - - const kysely = await Storages.kysely(); - const pubkeys = getTagSet(event.tags, 'p'); - - if (!pubkeys.size) { - return; - } - - const rows = await kysely - .selectFrom('push_subscriptions') - .selectAll() - .where('pubkey', 'in', [...pubkeys]) - .execute(); - - for (const row of rows) { - const viewerPubkey = row.pubkey; - - if (viewerPubkey === event.pubkey) { - continue; // Don't notify authors about their own events. - } - - const message = await renderWebPushNotification(event, viewerPubkey); - if (!message) { - continue; - } - - const subscription = { - endpoint: row.endpoint, - keys: { - auth: row.auth, - p256dh: row.p256dh, - }, - }; - - await DittoPush.push(subscription, message); - webPushNotificationsCounter.inc({ type: message.notification_type }); - } -} - -async function generateSetEvents(event: NostrEvent): Promise { - const tagsAdmin = event.tags.some(([name, value]) => ['p', 'P'].includes(name) && value === Conf.pubkey); - - if (event.kind === 1984 && tagsAdmin) { - const signer = new AdminSigner(); - - const rel = await signer.signEvent({ - kind: 30383, - content: '', - tags: [ - ['d', event.id], - ['p', event.pubkey], - ['k', '1984'], - ['n', 'open'], - ...[...getTagSet(event.tags, 'p')].map((pubkey) => ['P', pubkey]), - ...[...getTagSet(event.tags, 'e')].map((pubkey) => ['e', pubkey]), - ], - created_at: Math.floor(Date.now() / 1000), - }); - - await handleEvent(rel, { source: 'pipeline', signal: AbortSignal.timeout(1000) }); - } - - if (event.kind === 3036 && tagsAdmin) { - const signer = new AdminSigner(); - - const rel = await signer.signEvent({ - kind: 30383, - content: '', - tags: [ - ['d', event.id], - ['p', event.pubkey], - ['k', '3036'], - ['n', 'pending'], - ], - created_at: Math.floor(Date.now() / 1000), - }); - - await handleEvent(rel, { source: 'pipeline', signal: AbortSignal.timeout(1000) }); - } -} - -/** Stores the event in the 'event_zaps' table */ -async function handleZaps(kysely: Kysely, event: NostrEvent) { - if (event.kind !== 9735) return; - - const zapRequestString = event?.tags?.find(([name]) => name === 'description')?.[1]; - if (!zapRequestString) return; - const zapRequest = n.json().pipe(n.event()).optional().catch(undefined).parse(zapRequestString); - if (!zapRequest) return; - - const amountSchema = z.coerce.number().int().nonnegative().catch(0); - const amount_millisats = amountSchema.parse(getAmount(event?.tags.find(([name]) => name === 'bolt11')?.[1])); - if (!amount_millisats || amount_millisats < 1) return; - - const zappedEventId = zapRequest.tags.find(([name]) => name === 'e')?.[1]; - if (!zappedEventId) return; - - try { - await kysely.insertInto('event_zaps').values({ - receipt_id: event.id, - target_event_id: zappedEventId, - sender_pubkey: zapRequest.pubkey, - amount_millisats, - comment: zapRequest.content, - }).execute(); - } catch { - // receipt_id is unique, do nothing - } -} - -export { handleEvent, handleZaps, updateAuthorData }; diff --git a/packages/ditto/precheck.ts b/packages/ditto/precheck.ts deleted file mode 100644 index 40ab2fdb..00000000 --- a/packages/ditto/precheck.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Conf } from '@/config.ts'; - -/** Ensure the media URL is not on the same host as the local domain. */ -function checkMediaHost() { - const { url, mediaDomain } = Conf; - const mediaUrl = new URL(mediaDomain); - - if (url.host === mediaUrl.host) { - throw new PrecheckError('For security reasons, MEDIA_DOMAIN cannot be on the same host as LOCAL_DOMAIN.'); - } -} - -/** Error class for precheck errors. */ -class PrecheckError extends Error { - constructor(message: string) { - super(`${message}\nTo disable this check, set DITTO_PRECHECK="false"`); - } -} - -if (Deno.env.get('DITTO_PRECHECK') !== 'false') { - checkMediaHost(); -} diff --git a/packages/ditto/queries.ts b/packages/ditto/queries.ts index f60d3daa..f3d52b6b 100644 --- a/packages/ditto/queries.ts +++ b/packages/ditto/queries.ts @@ -1,76 +1,60 @@ +import { DittoConf } from '@ditto/conf'; +import { DittoTables } from '@ditto/db'; import { NostrEvent, NostrFilter, NStore } from '@nostrify/nostrify'; +import { Kysely } from 'kysely'; -import { Conf } from '@/config.ts'; -import { Storages } from '@/storages.ts'; import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; -import { type DittoRelation } from '@/interfaces/DittoFilter.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; import { fallbackAuthor } from '@/utils.ts'; import { findReplyTag, getTagSet } from '@/utils/tags.ts'; interface GetEventOpts { - /** Signal to abort the request. */ + conf: DittoConf; + store: NStore; + kysely: Kysely; signal?: AbortSignal; - /** Event kind. */ - kind?: number; - /** @deprecated Relations to include on the event. */ - relations?: DittoRelation[]; } -/** - * Get a Nostr event by its ID. - * @deprecated Use `store.query` directly. - */ -const getEvent = async ( - id: string, - opts: GetEventOpts = {}, -): Promise => { - const store = await Storages.db(); - const { kind, signal = AbortSignal.timeout(1000) } = opts; +/** Get a Nostr event by its ID. */ +async function getEvent(opts: GetEventOpts, id: string): Promise { + const { store, signal } = opts; const filter: NostrFilter = { ids: [id], limit: 1 }; - if (kind) { - filter.kinds = [kind]; - } - return await store.query([filter], { limit: 1, signal }) - .then((events) => hydrateEvents({ events, store, signal })) + return await store.query([{ ...filter, limit: 1 }], { signal }) + .then((events) => hydrateEvents(opts, events)) .then(([event]) => event); -}; +} -/** - * Get a Nostr `set_medatadata` event for a user's pubkey. - * @deprecated Use `store.query` directly. - */ -async function getAuthor(pubkey: string, opts: GetEventOpts = {}): Promise { - const store = await Storages.db(); - const { signal = AbortSignal.timeout(1000) } = opts; +/** Get a Nostr `set_medatadata` event for a user's pubkey. */ +async function getAuthor(opts: GetEventOpts, pubkey: string): Promise { + const { store, signal } = opts; - const events = await store.query([{ authors: [pubkey], kinds: [0], limit: 1 }], { limit: 1, signal }); + const events = await store.query([{ authors: [pubkey], kinds: [0], limit: 1 }], { signal }); const event = events[0] ?? fallbackAuthor(pubkey); - await hydrateEvents({ events: [event], store, signal }); + await hydrateEvents(opts, [event]); return event; } /** Get users the given pubkey follows. */ -const getFollows = async (pubkey: string, signal?: AbortSignal): Promise => { - const store = await Storages.db(); - const [event] = await store.query([{ authors: [pubkey], kinds: [3], limit: 1 }], { limit: 1, signal }); +async function getFollows(opts: GetEventOpts, pubkey: string): Promise { + const { store, signal } = opts; + const [event] = await store.query([{ authors: [pubkey], kinds: [3], limit: 1 }], { signal }); return event; -}; +} /** Get pubkeys the user follows. */ -async function getFollowedPubkeys(pubkey: string, signal?: AbortSignal): Promise> { - const event = await getFollows(pubkey, signal); +async function getFollowedPubkeys(opts: GetEventOpts, pubkey: string): Promise> { + const event = await getFollows(opts, pubkey); if (!event) return new Set(); return getTagSet(event.tags, 'p'); } /** Get pubkeys the user follows, including the user's own pubkey. */ -async function getFeedPubkeys(pubkey: string): Promise> { - const authors = await getFollowedPubkeys(pubkey); +async function getFeedPubkeys(opts: GetEventOpts, pubkey: string): Promise> { + const authors = await getFollowedPubkeys(opts, pubkey); return authors.add(pubkey); } @@ -103,14 +87,11 @@ async function getDescendants( } /** Returns whether the pubkey is followed by a local user. */ -async function isLocallyFollowed(pubkey: string): Promise { - const { host } = Conf.url; - - const store = await Storages.db(); +async function isLocallyFollowed(conf: DittoConf, store: NStore, pubkey: string): Promise { + const { host } = conf.url; const [event] = await store.query( [{ kinds: [3], '#p': [pubkey], search: `domain:${host}`, limit: 1 }], - { limit: 1 }, ); return Boolean(event); diff --git a/packages/ditto/sentry.ts b/packages/ditto/sentry.ts index 4875a12e..9d317ddf 100644 --- a/packages/ditto/sentry.ts +++ b/packages/ditto/sentry.ts @@ -1,15 +1,15 @@ +import { DittoConf } from '@ditto/conf'; import * as Sentry from '@sentry/deno'; import { logi } from '@soapbox/logi'; -import { Conf } from '@/config.ts'; - -// Sentry -if (Conf.sentryDsn) { - logi({ level: 'info', ns: 'ditto.sentry', msg: 'Sentry enabled.', enabled: true }); - Sentry.init({ - dsn: Conf.sentryDsn, - tracesSampleRate: 1.0, - }); -} else { - logi({ level: 'info', ns: 'ditto.sentry', msg: 'Sentry not configured. Skipping.', enabled: false }); +export function startSentry(conf: DittoConf): void { + if (conf.sentryDsn) { + logi({ level: 'info', ns: 'ditto.sentry', msg: 'Sentry enabled.', enabled: true }); + Sentry.init({ + dsn: conf.sentryDsn, + tracesSampleRate: 1.0, + }); + } else { + logi({ level: 'info', ns: 'ditto.sentry', msg: 'Sentry not configured. Skipping.', enabled: false }); + } } diff --git a/packages/ditto/server.ts b/packages/ditto/server.ts index c5815537..554c6b41 100644 --- a/packages/ditto/server.ts +++ b/packages/ditto/server.ts @@ -1,13 +1,28 @@ +import { DittoConf } from '@ditto/conf'; import { logi } from '@soapbox/logi'; -import '@/precheck.ts'; -import '@/sentry.ts'; +import { startSentry } from '@/sentry.ts'; import '@/nostr-wasm.ts'; import app from '@/app.ts'; -import { Conf } from '@/config.ts'; + +const conf = new DittoConf(Deno.env); + +startSentry(conf); + +// Ensure the media URL is not on the same host as the local domain. +if (conf.precheck) { + const { url, mediaDomain } = conf; + const mediaUrl = new URL(mediaDomain); + + if (url.host === mediaUrl.host) { + throw new Error( + 'For security reasons, MEDIA_DOMAIN cannot be on the same host as LOCAL_DOMAIN. To disable this check, set DITTO_PRECHECK="false"', + ); + } +} Deno.serve({ - port: Conf.port, + port: conf.port, onListen({ hostname, port }): void { logi({ level: 'info', ns: 'ditto.server', msg: `Listening on http://${hostname}:${port}`, hostname, port }); }, diff --git a/packages/ditto/signers/AdminSigner.ts b/packages/ditto/signers/AdminSigner.ts index 5aea2e21..5adf6f62 100644 --- a/packages/ditto/signers/AdminSigner.ts +++ b/packages/ditto/signers/AdminSigner.ts @@ -1,9 +1,10 @@ import { NSecSigner } from '@nostrify/nostrify'; -import { Conf } from '@/config.ts'; + +import type { DittoConf } from '@ditto/conf'; /** Sign events as the Ditto server. */ export class AdminSigner extends NSecSigner { - constructor() { - super(Conf.seckey); + constructor(conf: DittoConf) { + super(conf.seckey); } } diff --git a/packages/ditto/signers/ConnectSigner.ts b/packages/ditto/signers/ConnectSigner.ts index 89c62679..e339b940 100644 --- a/packages/ditto/signers/ConnectSigner.ts +++ b/packages/ditto/signers/ConnectSigner.ts @@ -1,10 +1,9 @@ // deno-lint-ignore-file require-await import { HTTPException } from '@hono/hono/http-exception'; -import { NConnectSigner, NostrEvent, NostrSigner } from '@nostrify/nostrify'; - -import { Storages } from '@/storages.ts'; +import { NConnectSigner, NostrEvent, NostrSigner, NRelay } from '@nostrify/nostrify'; interface ConnectSignerOpts { + relay: NRelay; bunkerPubkey: string; userPubkey: string; signer: NostrSigner; @@ -27,8 +26,7 @@ export class ConnectSigner implements NostrSigner { return new NConnectSigner({ encryption: 'nip44', pubkey: this.opts.bunkerPubkey, - // TODO: use a remote relay for `nprofile` signing (if present and `Conf.relay` isn't already in the list) - relay: await Storages.pubsub(), + relay: this.opts.relay, signer, timeout: 60_000, }); diff --git a/packages/ditto/startup.ts b/packages/ditto/startup.ts deleted file mode 100644 index 0cc2f26a..00000000 --- a/packages/ditto/startup.ts +++ /dev/null @@ -1,17 +0,0 @@ -// Starts up applications required to run before the HTTP server is on. -import { Conf } from '@/config.ts'; -import { cron } from '@/cron.ts'; -import { startFirehose } from '@/firehose.ts'; -import { startNotify } from '@/notify.ts'; - -if (Conf.firehoseEnabled) { - startFirehose(); -} - -if (Conf.notifyEnabled) { - startNotify(); -} - -if (Conf.cronEnabled) { - cron(); -} diff --git a/packages/ditto/storages/AdminStore.ts b/packages/ditto/storages/AdminStore.ts index 4ebe2743..c3fd5999 100644 --- a/packages/ditto/storages/AdminStore.ts +++ b/packages/ditto/storages/AdminStore.ts @@ -1,24 +1,25 @@ +import { DittoConf } from '@ditto/conf'; import { NostrEvent, NostrFilter, NStore } from '@nostrify/nostrify'; -import { Conf } from '@/config.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { getTagSet } from '@/utils/tags.ts'; /** A store that prevents banned users from being displayed. */ export class AdminStore implements NStore { - constructor(private store: NStore) {} + constructor(private conf: DittoConf, private store: NStore) {} async event(event: NostrEvent, opts?: { signal?: AbortSignal }): Promise { return await this.store.event(event, opts); } async query(filters: NostrFilter[], opts: { signal?: AbortSignal; limit?: number } = {}): Promise { + const { conf } = this; const events = await this.store.query(filters, opts); const pubkeys = new Set(events.map((event) => event.pubkey)); const users = await this.store.query([{ kinds: [30382], - authors: [Conf.pubkey], + authors: [conf.pubkey], '#d': [...pubkeys], limit: pubkeys.size, }]); @@ -26,7 +27,7 @@ export class AdminStore implements NStore { return events.filter((event) => { const user = users.find( ({ kind, pubkey, tags }) => - kind === 30382 && pubkey === Conf.pubkey && tags.find(([name]) => name === 'd')?.[1] === event.pubkey, + kind === 30382 && pubkey === conf.pubkey && tags.find(([name]) => name === 'd')?.[1] === event.pubkey, ); const n = getTagSet(user?.tags ?? [], 'n'); diff --git a/packages/ditto/storages/EventsDB.test.ts b/packages/ditto/storages/EventsDB.test.ts index d0947075..ce138b2f 100644 --- a/packages/ditto/storages/EventsDB.test.ts +++ b/packages/ditto/storages/EventsDB.test.ts @@ -1,9 +1,9 @@ +import { DittoConf } from '@ditto/conf'; import { assertEquals, assertRejects } from '@std/assert'; -import { generateSecretKey } from 'nostr-tools'; +import { generateSecretKey, nip19 } from 'nostr-tools'; import { RelayError } from '@/RelayError.ts'; import { eventFixture, genEvent } from '@/test.ts'; -import { Conf } from '@/config.ts'; import { EventsDB } from '@/storages/EventsDB.ts'; import { createTestDB } from '@/test.ts'; @@ -122,7 +122,10 @@ Deno.test("user cannot delete another user's event", async () => { }); Deno.test('admin can delete any event', async () => { - await using db = await createTestDB({ pure: true }); + const admin = generateSecretKey(); + const conf = new DittoConf(new Map([['DITTO_NSEC', nip19.nsecEncode(admin)]])); + + await using db = await createTestDB({ conf, pure: true }); const { store } = db; const sk = generateSecretKey(); @@ -139,20 +142,23 @@ Deno.test('admin can delete any event', async () => { assertEquals(await store.query([{ kinds: [1] }]), [two, one]); await store.event( - genEvent({ kind: 5, tags: [['e', one.id]] }, Conf.seckey), // admin sk + genEvent({ kind: 5, tags: [['e', one.id]] }, conf.seckey), // admin sk ); assertEquals(await store.query([{ kinds: [1] }]), [two]); }); Deno.test('throws a RelayError when inserting an event deleted by the admin', async () => { - await using db = await createTestDB({ pure: true }); + const admin = generateSecretKey(); + const conf = new DittoConf(new Map([['DITTO_NSEC', nip19.nsecEncode(admin)]])); + + await using db = await createTestDB({ conf, pure: true }); const { store } = db; const event = genEvent(); await store.event(event); - const deletion = genEvent({ kind: 5, tags: [['e', event.id]] }, Conf.seckey); + const deletion = genEvent({ kind: 5, tags: [['e', event.id]] }, conf.seckey); await store.event(deletion); await assertRejects( diff --git a/packages/ditto/storages/hydrate.bench.ts b/packages/ditto/storages/hydrate.bench.ts index eeacec50..117e320d 100644 --- a/packages/ditto/storages/hydrate.bench.ts +++ b/packages/ditto/storages/hydrate.bench.ts @@ -1,6 +1,10 @@ +import { DittoConf } from '@ditto/conf'; + import { assembleEvents } from '@/storages/hydrate.ts'; import { jsonlEvents } from '@/test.ts'; +const conf = new DittoConf(new Map()); + const testEvents = await jsonlEvents('fixtures/hydrated.jsonl'); const testStats = JSON.parse(await Deno.readTextFile('fixtures/stats.json')); @@ -9,5 +13,5 @@ const testStats = JSON.parse(await Deno.readTextFile('fixtures/stats.json')); const events = testEvents.slice(0, 20); Deno.bench('assembleEvents with home feed', () => { - assembleEvents(events, testEvents, testStats); + assembleEvents(conf, events, testEvents, testStats); }); diff --git a/packages/ditto/storages/hydrate.test.ts b/packages/ditto/storages/hydrate.test.ts index 1527f321..75836ab3 100644 --- a/packages/ditto/storages/hydrate.test.ts +++ b/packages/ditto/storages/hydrate.test.ts @@ -1,4 +1,3 @@ -import { MockRelay } from '@nostrify/nostrify/test'; import { assertEquals } from '@std/assert'; import { DittoEvent } from '@/interfaces/DittoEvent.ts'; @@ -6,29 +5,25 @@ import { hydrateEvents } from '@/storages/hydrate.ts'; import { createTestDB, eventFixture } from '@/test.ts'; Deno.test('hydrateEvents(): author --- WITHOUT stats', async () => { - const relay = new MockRelay(); await using db = await createTestDB(); + const { store } = db; const event0 = await eventFixture('event-0'); const event1 = await eventFixture('event-1'); // Save events to database - await relay.event(event0); - await relay.event(event1); + await store.event(event0); + await store.event(event1); - await hydrateEvents({ - events: [event1], - store: relay, - kysely: db.kysely, - }); + await hydrateEvents(db, [event1]); const expectedEvent = { ...event1, author: event0 }; assertEquals(event1, expectedEvent); }); Deno.test('hydrateEvents(): repost --- WITHOUT stats', async () => { - const relay = new MockRelay(); await using db = await createTestDB(); + const { store } = db; const event0madePost = await eventFixture('event-0-the-one-who-post-and-users-repost'); const event0madeRepost = await eventFixture('event-0-the-one-who-repost'); @@ -36,16 +31,12 @@ Deno.test('hydrateEvents(): repost --- WITHOUT stats', async () => { const event6 = await eventFixture('event-6'); // Save events to database - await relay.event(event0madePost); - await relay.event(event0madeRepost); - await relay.event(event1reposted); - await relay.event(event6); + await store.event(event0madePost); + await store.event(event0madeRepost); + await store.event(event1reposted); + await store.event(event6); - await hydrateEvents({ - events: [event6], - store: relay, - kysely: db.kysely, - }); + await hydrateEvents(db, [event6]); const expectedEvent6 = { ...event6, @@ -56,8 +47,8 @@ Deno.test('hydrateEvents(): repost --- WITHOUT stats', async () => { }); Deno.test('hydrateEvents(): quote repost --- WITHOUT stats', async () => { - const relay = new MockRelay(); await using db = await createTestDB(); + const { store } = db; const event0madeQuoteRepost = await eventFixture('event-0-the-one-who-quote-repost'); const event0 = await eventFixture('event-0'); @@ -65,16 +56,12 @@ Deno.test('hydrateEvents(): quote repost --- WITHOUT stats', async () => { const event1willBeQuoteReposted = await eventFixture('event-1-that-will-be-quote-reposted'); // Save events to database - await relay.event(event0madeQuoteRepost); - await relay.event(event0); - await relay.event(event1quoteRepost); - await relay.event(event1willBeQuoteReposted); + await store.event(event0madeQuoteRepost); + await store.event(event0); + await store.event(event1quoteRepost); + await store.event(event1willBeQuoteReposted); - await hydrateEvents({ - events: [event1quoteRepost], - store: relay, - kysely: db.kysely, - }); + await hydrateEvents(db, [event1quoteRepost]); const expectedEvent1quoteRepost = { ...event1quoteRepost, @@ -86,8 +73,8 @@ Deno.test('hydrateEvents(): quote repost --- WITHOUT stats', async () => { }); Deno.test('hydrateEvents(): repost of quote repost --- WITHOUT stats', async () => { - const relay = new MockRelay(); await using db = await createTestDB(); + const { store } = db; const author = await eventFixture('event-0-makes-repost-with-quote-repost'); const event1 = await eventFixture('event-1-will-be-reposted-with-quote-repost'); @@ -95,16 +82,12 @@ Deno.test('hydrateEvents(): repost of quote repost --- WITHOUT stats', async () const event1quote = await eventFixture('event-1-quote-repost-will-be-reposted'); // Save events to database - await relay.event(author); - await relay.event(event1); - await relay.event(event1quote); - await relay.event(event6); + await store.event(author); + await store.event(event1); + await store.event(event1quote); + await store.event(event6); - await hydrateEvents({ - events: [event6], - store: relay, - kysely: db.kysely, - }); + await hydrateEvents(db, [event6]); const expectedEvent6 = { ...event6, @@ -115,8 +98,8 @@ Deno.test('hydrateEvents(): repost of quote repost --- WITHOUT stats', async () }); Deno.test('hydrateEvents(): report pubkey and post // kind 1984 --- WITHOUT stats', async () => { - const relay = new MockRelay(); await using db = await createTestDB(); + const { store } = db; const authorDictator = await eventFixture('kind-0-dictator'); const authorVictim = await eventFixture('kind-0-george-orwell'); @@ -124,16 +107,12 @@ Deno.test('hydrateEvents(): report pubkey and post // kind 1984 --- WITHOUT stat const event1 = await eventFixture('kind-1-author-george-orwell'); // Save events to database - await relay.event(authorDictator); - await relay.event(authorVictim); - await relay.event(reportEvent); - await relay.event(event1); + await store.event(authorDictator); + await store.event(authorVictim); + await store.event(reportEvent); + await store.event(event1); - await hydrateEvents({ - events: [reportEvent], - store: relay, - kysely: db.kysely, - }); + await hydrateEvents(db, [reportEvent]); const expectedEvent: DittoEvent = { ...reportEvent, @@ -145,8 +124,8 @@ Deno.test('hydrateEvents(): report pubkey and post // kind 1984 --- WITHOUT stat }); Deno.test('hydrateEvents(): zap sender, zap amount, zapped post // kind 9735 --- WITHOUT stats', async () => { - const relay = new MockRelay(); await using db = await createTestDB(); + const { store } = db; const zapSender = await eventFixture('kind-0-jack'); const zapReceipt = await eventFixture('kind-9735-jack-zap-patrick'); @@ -154,16 +133,12 @@ Deno.test('hydrateEvents(): zap sender, zap amount, zapped post // kind 9735 --- const zapReceiver = await eventFixture('kind-0-patrick'); // Save events to database - await relay.event(zapSender); - await relay.event(zapReceipt); - await relay.event(zappedPost); - await relay.event(zapReceiver); + await store.event(zapSender); + await store.event(zapReceipt); + await store.event(zappedPost); + await store.event(zapReceiver); - await hydrateEvents({ - events: [zapReceipt], - store: relay, - kysely: db.kysely, - }); + await hydrateEvents(db, [zapReceipt]); const expectedEvent: DittoEvent = { ...zapReceipt, diff --git a/packages/ditto/storages/hydrate.ts b/packages/ditto/storages/hydrate.ts index 0836bd76..1c71de57 100644 --- a/packages/ditto/storages/hydrate.ts +++ b/packages/ditto/storages/hydrate.ts @@ -1,3 +1,4 @@ +import { DittoConf } from '@ditto/conf'; import { DittoTables } from '@ditto/db'; import { NStore } from '@nostrify/nostrify'; import { Kysely } from 'kysely'; @@ -5,24 +6,22 @@ import { matchFilter } from 'nostr-tools'; import { NSchema as n } from '@nostrify/nostrify'; import { z } from 'zod'; -import { Conf } from '@/config.ts'; import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; import { fallbackAuthor } from '@/utils.ts'; import { findQuoteTag } from '@/utils/tags.ts'; import { findQuoteInContent } from '@/utils/note.ts'; import { getAmount } from '@/utils/bolt11.ts'; -import { Storages } from '@/storages.ts'; interface HydrateOpts { - events: DittoEvent[]; + conf: DittoConf; store: NStore; + kysely: Kysely; signal?: AbortSignal; - kysely?: Kysely; } /** Hydrate events using the provided storage. */ -async function hydrateEvents(opts: HydrateOpts): Promise { - const { events, store, signal, kysely = await Storages.kysely() } = opts; +async function hydrateEvents(opts: HydrateOpts, events: DittoEvent[]): Promise { + const { conf, kysely } = opts; if (!events.length) { return events; @@ -30,23 +29,23 @@ async function hydrateEvents(opts: HydrateOpts): Promise { const cache = [...events]; - for (const event of await gatherRelatedEvents({ events: cache, store, signal })) { + for (const event of await gatherRelatedEvents(opts, cache)) { cache.push(event); } - for (const event of await gatherQuotes({ events: cache, store, signal })) { + for (const event of await gatherQuotes(opts, cache)) { cache.push(event); } - for (const event of await gatherProfiles({ events: cache, store, signal })) { + for (const event of await gatherProfiles(opts, cache)) { cache.push(event); } - for (const event of await gatherUsers({ events: cache, store, signal })) { + for (const event of await gatherUsers(opts, cache)) { cache.push(event); } - for (const event of await gatherInfo({ events: cache, store, signal })) { + for (const event of await gatherInfo(opts, cache)) { cache.push(event); } @@ -80,14 +79,15 @@ async function hydrateEvents(opts: HydrateOpts): Promise { const results = [...new Map(cache.map((event) => [event.id, event])).values()]; // First connect all the events to each-other, then connect the connected events to the original list. - assembleEvents(results, results, stats); - assembleEvents(events, results, stats); + assembleEvents(conf, results, results, stats); + assembleEvents(conf, events, results, stats); return events; } /** Connect the events in list `b` to the DittoEvent fields in list `a`. */ export function assembleEvents( + conf: DittoConf, a: DittoEvent[], b: DittoEvent[], stats: { @@ -96,7 +96,7 @@ export function assembleEvents( favicons: Record; }, ): DittoEvent[] { - const admin = Conf.pubkey; + const admin = conf.pubkey; const authorStats = stats.authors.reduce((result, { pubkey, ...stat }) => { result[pubkey] = { @@ -198,7 +198,7 @@ export function assembleEvents( } /** Collect event targets (eg reposts, quote posts, reacted posts, etc.) */ -function gatherRelatedEvents({ events, store, signal }: HydrateOpts): Promise { +function gatherRelatedEvents({ store, signal }: HydrateOpts, events: DittoEvent[]): Promise { const ids = new Set(); for (const event of events) { @@ -240,7 +240,7 @@ function gatherRelatedEvents({ events, store, signal }: HydrateOpts): Promise { +function gatherQuotes({ store, signal }: HydrateOpts, events: DittoEvent[]): Promise { const ids = new Set(); for (const event of events) { @@ -259,7 +259,7 @@ function gatherQuotes({ events, store, signal }: HydrateOpts): Promise { +async function gatherProfiles({ store, signal }: HydrateOpts, events: DittoEvent[]): Promise { const pubkeys = new Set(); for (const event of events) { @@ -316,7 +316,7 @@ async function gatherProfiles({ events, store, signal }: HydrateOpts): Promise { +function gatherUsers({ conf, store, signal }: HydrateOpts, events: DittoEvent[]): Promise { const pubkeys = new Set(events.map((event) => event.pubkey)); if (!pubkeys.size) { @@ -324,13 +324,13 @@ function gatherUsers({ events, store, signal }: HydrateOpts): Promise { +function gatherInfo({ conf, store, signal }: HydrateOpts, events: DittoEvent[]): Promise { const ids = new Set(); for (const event of events) { @@ -344,7 +344,7 @@ function gatherInfo({ events, store, signal }: HydrateOpts): Promise { - return Promise.reject(new Error('EVENT not implemented.')); - } - - async query(filters: NostrFilter[], opts?: { signal?: AbortSignal; limit?: number }): Promise { - filters = normalizeFilters(filters); - - if (opts?.signal?.aborted) return Promise.reject(abortError()); - if (!filters.length) return Promise.resolve([]); - - logi({ level: 'debug', ns: 'ditto.req', source: 'search', filters: filters as JsonValue }); - const query = filters[0]?.search; - - if (this.#relay && this.#relay.socket.readyState === WebSocket.OPEN) { - logi({ level: 'debug', ns: 'ditto.search', query, source: 'relay', relay: this.#relay.socket.url }); - - const events = await this.#relay.query(filters, opts); - - return hydrateEvents({ - events, - store: this.#hydrator, - signal: opts?.signal, - }); - } else { - logi({ level: 'debug', ns: 'ditto.search', query, source: 'db' }); - return this.#fallback.query(filters, opts); - } - } -} - -export { SearchStore }; diff --git a/packages/ditto/test.ts b/packages/ditto/test.ts index bc9a6787..5eebe62a 100644 --- a/packages/ditto/test.ts +++ b/packages/ditto/test.ts @@ -1,10 +1,10 @@ +import { DittoConf } from '@ditto/conf'; import { DittoDB } from '@ditto/db'; import ISO6391, { LanguageCode } from 'iso-639-1'; import lande from 'lande'; import { NostrEvent } from '@nostrify/nostrify'; -import { finalizeEvent, generateSecretKey } from 'nostr-tools'; +import { finalizeEvent, generateSecretKey, nip19 } from 'nostr-tools'; -import { Conf } from '@/config.ts'; import { EventsDB } from '@/storages/EventsDB.ts'; import { purifyEvent } from '@/utils/purify.ts'; import { sql } from 'kysely'; @@ -35,19 +35,21 @@ export function genEvent(t: Partial = {}, sk: Uint8Array = generateS } /** Create a database for testing. It uses `DATABASE_URL`, or creates an in-memory database by default. */ -export async function createTestDB(opts?: { pure?: boolean }) { - const { kysely } = DittoDB.create(Conf.databaseUrl, { poolSize: 1 }); +export async function createTestDB(opts?: { conf?: DittoConf; pure?: boolean }) { + const conf = opts?.conf ?? testConf(); + const { kysely } = DittoDB.create(conf.databaseUrl, { poolSize: 1 }); await DittoDB.migrate(kysely); const store = new EventsDB({ kysely, - timeout: Conf.db.timeouts.default, - pubkey: Conf.pubkey, + timeout: conf.db.timeouts.default, + pubkey: conf.pubkey, pure: opts?.pure ?? false, }); return { + conf, store, kysely, [Symbol.asyncDispose]: async () => { @@ -65,6 +67,15 @@ export async function createTestDB(opts?: { pure?: boolean }) { }; } +export function testConf(): DittoConf { + const env = new Map(); + + env.set('DITTO_NSEC', nip19.nsecEncode(generateSecretKey())); + env.set('LOCAL_DOMAIN', 'https://ditto.test'); + + return new DittoConf(env); +} + export function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/packages/ditto/translators/DeepLTranslator.test.ts b/packages/ditto/translators/DeepLTranslator.test.ts index 08f16a66..3006e966 100644 --- a/packages/ditto/translators/DeepLTranslator.test.ts +++ b/packages/ditto/translators/DeepLTranslator.test.ts @@ -1,6 +1,6 @@ +import { DittoConf } from '@ditto/conf'; import { assert, assertEquals } from '@std/assert'; -import { Conf } from '@/config.ts'; import { DeepLTranslator } from '@/translators/DeepLTranslator.ts'; import { getLanguage } from '@/test.ts'; @@ -8,7 +8,7 @@ const { deeplBaseUrl: baseUrl, deeplApiKey: apiKey, translationProvider, -} = Conf; +} = new DittoConf(Deno.env); const deepl = 'deepl'; diff --git a/packages/ditto/translators/LibreTranslateTranslator.test.ts b/packages/ditto/translators/LibreTranslateTranslator.test.ts index edda3039..47253328 100644 --- a/packages/ditto/translators/LibreTranslateTranslator.test.ts +++ b/packages/ditto/translators/LibreTranslateTranslator.test.ts @@ -1,6 +1,6 @@ +import { DittoConf } from '@ditto/conf'; import { assertEquals } from '@std/assert'; -import { Conf } from '@/config.ts'; import { LibreTranslateTranslator } from '@/translators/LibreTranslateTranslator.ts'; import { getLanguage } from '@/test.ts'; @@ -8,7 +8,7 @@ const { libretranslateBaseUrl: baseUrl, libretranslateApiKey: apiKey, translationProvider, -} = Conf; +} = new DittoConf(Deno.env); const libretranslate = 'libretranslate'; diff --git a/packages/ditto/trends.test.ts b/packages/ditto/trends.test.ts index 79eaf8e0..be6ca4bc 100644 --- a/packages/ditto/trends.test.ts +++ b/packages/ditto/trends.test.ts @@ -1,12 +1,17 @@ +import { DittoConf } from '@ditto/conf'; import { assertEquals } from '@std/assert'; import { generateSecretKey, NostrEvent } from 'nostr-tools'; -import { getTrendingTagValues } from '@/trends.ts'; +import { DittoTrends } from '@/trends.ts'; import { createTestDB, genEvent } from '@/test.ts'; -Deno.test("getTrendingTagValues(): 'e' tag and WITHOUT language parameter", async () => { +const conf = new DittoConf(new Map()); + +Deno.test("Trends.getTrendingTagValues(): 'e' tag and WITHOUT language parameter", async () => { await using db = await createTestDB(); + const trends = new DittoTrends({ ...db, conf }); + const events: NostrEvent[] = []; let sk = generateSecretKey(); @@ -43,7 +48,7 @@ Deno.test("getTrendingTagValues(): 'e' tag and WITHOUT language parameter", asyn await db.store.event(event); } - const trends = await getTrendingTagValues(db.kysely, ['e'], { kinds: [1, 7] }); + const results = await trends.getTrendingTagValues(['e'], { kinds: [1, 7] }); const expected = [{ value: post1.id, authors: numberOfAuthorsWhoLikedPost1, uses: post1uses }, { value: post2.id, @@ -51,12 +56,13 @@ Deno.test("getTrendingTagValues(): 'e' tag and WITHOUT language parameter", asyn uses: post2uses, }]; - assertEquals(trends, expected); + assertEquals(results, expected); }); -Deno.test("getTrendingTagValues(): 'e' tag and WITH language parameter", async () => { +Deno.test("Trends.getTrendingTagValues(): 'e' tag and WITH language parameter", async () => { await using db = await createTestDB(); + const trends = new DittoTrends({ ...db, conf }); const events: NostrEvent[] = []; let sk = generateSecretKey(); @@ -104,10 +110,10 @@ Deno.test("getTrendingTagValues(): 'e' tag and WITH language parameter", async ( const languagesIds = (await db.store.query([{ search: 'language:pt' }])).map((event) => event.id); - const trends = await getTrendingTagValues(db.kysely, ['e'], { kinds: [1, 7] }, languagesIds); + const results = await trends.getTrendingTagValues(['e'], { kinds: [1, 7] }, languagesIds); // portuguese post const expected = [{ value: post1.id, authors: numberOfAuthorsWhoLikedPost1, uses: post1uses }]; - assertEquals(trends, expected); + assertEquals(results, expected); }); diff --git a/packages/ditto/trends.ts b/packages/ditto/trends.ts index 8dfdb5ae..5a316182 100644 --- a/packages/ditto/trends.ts +++ b/packages/ditto/trends.ts @@ -1,169 +1,180 @@ +import { DittoConf } from '@ditto/conf'; import { DittoTables } from '@ditto/db'; -import { NostrFilter } from '@nostrify/nostrify'; +import { NostrFilter, NStore } from '@nostrify/nostrify'; import { logi } from '@soapbox/logi'; import { Kysely, sql } from 'kysely'; -import { Conf } from '@/config.ts'; -import { handleEvent } from '@/pipeline.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts'; -import { Storages } from '@/storages.ts'; import { errorJson } from '@/utils/log.ts'; import { Time } from '@/utils/time.ts'; -/** Get trending tag values for a given tag in the given time frame. */ -export async function getTrendingTagValues( - /** Kysely instance to execute queries on. */ - kysely: Kysely, - /** Tag name to filter by, eg `t` or `r`. */ - 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([ - 'nostr_events', - sql<{ key: string; value: string }>`jsonb_each_text(nostr_events.tags_index)`.as('kv'), - sql<{ key: string; value: string }>`jsonb_array_elements_text(kv.value::jsonb)`.as('element'), - ]) - .select(({ fn }) => [ - fn('lower', ['element.value']).as('value'), - fn.agg('count', ['nostr_events.pubkey']).distinct().as('authors'), - fn.countAll().as('uses'), - ]) - .where('kv.key', '=', (eb) => eb.fn.any(eb.val(tagNames))) - .groupBy((eb) => eb.fn('lower', ['element.value'])) - .orderBy('authors desc').orderBy('uses desc'); - - if (filter.kinds) { - query = query.where('nostr_events.kind', '=', ({ fn, val }) => fn.any(val(filter.kinds))); - } - if (filter.authors) { - query = query.where('nostr_events.pubkey', '=', ({ fn, val }) => fn.any(val(filter.authors))); - } - if (typeof filter.since === 'number') { - query = query.where('nostr_events.created_at', '>=', filter.since); - } - 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); - } - - const rows = await query.execute(); - - return rows.map((row) => ({ - value: row.value, - authors: Number(row.authors), - uses: Number(row.uses), - })); +interface DittoTrendsOpts { + conf: DittoConf; + kysely: Kysely; + store: NStore; } -/** Get trending tags and publish an event with them. */ -export async function updateTrendingTags( - l: string, - tagName: string, - kinds: number[], - limit: number, - extra = '', - aliases?: string[], - values?: string[], -) { - const params = { l, tagName, kinds, limit, extra, aliases, values }; - logi({ level: 'info', ns: 'ditto.trends', msg: 'Updating trending', ...params }); +export class DittoTrends { + constructor(private opts: DittoTrendsOpts) {} - const kysely = await Storages.kysely(); - const signal = AbortSignal.timeout(1000); + /** Get trending tag values for a given tag in the given time frame. */ + async getTrendingTagValues( + /** Tag name to filter by, eg `t` or `r`. */ + 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 }[]> { + const { kysely } = this.opts; - const yesterday = Math.floor((Date.now() - Time.days(1)) / 1000); - const now = Math.floor(Date.now() / 1000); + let query = kysely + .selectFrom([ + 'nostr_events', + sql<{ key: string; value: string }>`jsonb_each_text(nostr_events.tags_index)`.as('kv'), + sql<{ key: string; value: string }>`jsonb_array_elements_text(kv.value::jsonb)`.as('element'), + ]) + .select(({ fn }) => [ + fn('lower', ['element.value']).as('value'), + fn.agg('count', ['nostr_events.pubkey']).distinct().as('authors'), + fn.countAll().as('uses'), + ]) + .where('kv.key', '=', (eb) => eb.fn.any(eb.val(tagNames))) + .groupBy((eb) => eb.fn('lower', ['element.value'])) + .orderBy('authors desc').orderBy('uses desc'); - const tagNames = aliases ? [tagName, ...aliases] : [tagName]; - - try { - const trends = await getTrendingTagValues(kysely, tagNames, { - kinds, - since: yesterday, - until: now, - limit, - }, values); - - if (trends.length) { - logi({ level: 'info', ns: 'ditto.trends', msg: 'Trends found', trends, ...params }); - } else { - logi({ level: 'info', ns: 'ditto.trends', msg: 'No trends found. Skipping.', ...params }); - return; + if (filter.kinds) { + query = query.where('nostr_events.kind', '=', ({ fn, val }) => fn.any(val(filter.kinds))); + } + if (filter.authors) { + query = query.where('nostr_events.pubkey', '=', ({ fn, val }) => fn.any(val(filter.authors))); + } + if (typeof filter.since === 'number') { + query = query.where('nostr_events.created_at', '>=', filter.since); + } + 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); } - const signer = new AdminSigner(); + const rows = await query.execute(); - const label = await signer.signEvent({ - kind: 1985, - content: '', - tags: [ - ['L', 'pub.ditto.trends'], - ['l', l, 'pub.ditto.trends'], - ...trends.map(({ value, authors, uses }) => [tagName, value, extra, authors.toString(), uses.toString()]), - ], - created_at: Math.floor(Date.now() / 1000), - }); - - await handleEvent(label, { source: 'internal', signal }); - logi({ level: 'info', ns: 'ditto.trends', msg: 'Trends updated', ...params }); - } catch (e) { - logi({ level: 'error', ns: 'ditto.trends', msg: 'Error updating trends', ...params, error: errorJson(e) }); + return rows.map((row) => ({ + value: row.value, + authors: Number(row.authors), + uses: Number(row.uses), + })); } -} -/** Update trending pubkeys. */ -export function updateTrendingPubkeys(): Promise { - return updateTrendingTags('#p', 'p', [1, 3, 6, 7, 9735], 40, Conf.relay); -} + /** Get trending tags and publish an event with them. */ + async updateTrendingTags( + l: string, + tagName: string, + kinds: number[], + limit: number, + extra = '', + aliases?: string[], + values?: string[], + ) { + const { conf, store } = this.opts; -/** Update trending zapped events. */ -export function updateTrendingZappedEvents(): Promise { - return updateTrendingTags('zapped', 'e', [9735], 40, Conf.relay, ['q']); -} + const params = { l, tagName, kinds, limit, extra, aliases, values }; + logi({ level: 'info', ns: 'ditto.trends', msg: 'Updating trending', ...params }); -/** Update trending events. */ -export async function updateTrendingEvents(): Promise { - const results: Promise[] = [ - updateTrendingTags('#e', 'e', [1, 6, 7, 9735], 40, Conf.relay, ['q']), - ]; + const signal = AbortSignal.timeout(1000); - 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(sql`nostr_events.search_ext->>'language'`, '=', language) - .where('nostr_events.created_at', '>=', yesterday) - .where('nostr_events.created_at', '<=', now) - .execute(); + const tagNames = aliases ? [tagName, ...aliases] : [tagName]; - const ids = rows.map((row) => row.id); + try { + const trends = await this.getTrendingTagValues(tagNames, { + kinds, + since: yesterday, + until: now, + limit, + }, values); - results.push(updateTrendingTags(`#e.${language}`, 'e', [1, 6, 7, 9735], 40, Conf.relay, ['q'], ids)); + if (trends.length) { + logi({ level: 'info', ns: 'ditto.trends', msg: 'Trends found', trends, ...params }); + } else { + logi({ level: 'info', ns: 'ditto.trends', msg: 'No trends found. Skipping.', ...params }); + return; + } + + const signer = new AdminSigner(conf); + + const label = await signer.signEvent({ + kind: 1985, + content: '', + tags: [ + ['L', 'pub.ditto.trends'], + ['l', l, 'pub.ditto.trends'], + ...trends.map(({ value, authors, uses }) => [tagName, value, extra, authors.toString(), uses.toString()]), + ], + created_at: Math.floor(Date.now() / 1000), + }); + + await store.event(label, { signal }); + logi({ level: 'info', ns: 'ditto.trends', msg: 'Trends updated', ...params }); + } catch (e) { + logi({ level: 'error', ns: 'ditto.trends', msg: 'Error updating trends', ...params, error: errorJson(e) }); + } } - await Promise.allSettled(results); -} + /** Update trending pubkeys. */ + updateTrendingPubkeys(): Promise { + const { conf } = this.opts; + return this.updateTrendingTags('#p', 'p', [1, 3, 6, 7, 9735], 40, conf.relay); + } -/** Update trending hashtags. */ -export function updateTrendingHashtags(): Promise { - return updateTrendingTags('#t', 't', [1], 20); -} + /** Update trending zapped events. */ + updateTrendingZappedEvents(): Promise { + const { conf } = this.opts; + return this.updateTrendingTags('zapped', 'e', [9735], 40, conf.relay, ['q']); + } -/** Update trending links. */ -export function updateTrendingLinks(): Promise { - return updateTrendingTags('#r', 'r', [1], 20); + /** Update trending events. */ + async updateTrendingEvents(): Promise { + const { conf, kysely } = this.opts; + + const results: Promise[] = [ + this.updateTrendingTags('#e', 'e', [1, 6, 7, 9735], 40, conf.relay, ['q']), + ]; + + 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(sql`nostr_events.search_ext->>'language'`, '=', language) + .where('nostr_events.created_at', '>=', yesterday) + .where('nostr_events.created_at', '<=', now) + .execute(); + + const ids = rows.map((row) => row.id); + + results.push(this.updateTrendingTags(`#e.${language}`, 'e', [1, 6, 7, 9735], 40, conf.relay, ['q'], ids)); + } + + await Promise.allSettled(results); + } + + /** Update trending hashtags. */ + updateTrendingHashtags(): Promise { + return this.updateTrendingTags('#t', 't', [1], 20); + } + + /** Update trending links. */ + updateTrendingLinks(): Promise { + return this.updateTrendingTags('#r', 'r', [1], 20); + } } diff --git a/packages/ditto/utils/api.ts b/packages/ditto/utils/api.ts index 79512190..bbb075cd 100644 --- a/packages/ditto/utils/api.ts +++ b/packages/ditto/utils/api.ts @@ -1,16 +1,14 @@ +import { DittoConf } from '@ditto/conf'; import { type Context } from '@hono/hono'; import { HTTPException } from '@hono/hono/http-exception'; -import { NostrEvent, NostrFilter } from '@nostrify/nostrify'; +import { NostrEvent, NostrFilter, NostrSigner, NStore } from '@nostrify/nostrify'; import { logi } from '@soapbox/logi'; import { EventTemplate } from 'nostr-tools'; import * as TypeFest from 'type-fest'; import { type AppContext } from '@/app.ts'; -import { Conf } from '@/config.ts'; -import * as pipeline from '@/pipeline.ts'; import { RelayError } from '@/RelayError.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts'; -import { Storages } from '@/storages.ts'; import { nostrNow } from '@/utils.ts'; import { parseFormData } from '@/utils/formdata.ts'; import { purifyEvent } from '@/utils/purify.ts'; @@ -18,24 +16,31 @@ import { purifyEvent } from '@/utils/purify.ts'; /** EventTemplate with defaults. */ type EventStub = TypeFest.SetOptional; +interface CreateEventOpts { + conf: DittoConf; + store: NStore; + pool: NStore; + user: { + signer: NostrSigner; + }; + signal?: AbortSignal; +} + /** Publish an event through the pipeline. */ -async function createEvent(t: EventStub, c: Context): Promise { - const signer = c.get('signer'); +async function createEvent( + opts: CreateEventOpts, + t: EventStub, +): Promise { + const { user } = opts; - if (!signer) { - throw new HTTPException(401, { - res: c.json({ error: 'No way to sign Nostr event' }, 401), - }); - } - - const event = await signer.signEvent({ + const event = await user.signer.signEvent({ content: '', created_at: nostrNow(), tags: [], ...t, }); - return publishEvent(event, c); + return publishEvent(opts, event); } /** Filter for fetching an existing event to update. */ @@ -46,19 +51,17 @@ interface UpdateEventFilter extends NostrFilter { /** Update a replaceable event, or throw if no event exists yet. */ async function updateEvent( + opts: CreateEventOpts, filter: UpdateEventFilter, fn: (prev: NostrEvent) => E | Promise, - c: AppContext, + signal?: AbortSignal, ): Promise { - const store = await Storages.db(); + const { store } = opts; - const [prev] = await store.query( - [filter], - { signal: c.req.raw.signal }, - ); + const [prev] = await store.query([filter], { signal }); if (prev) { - return createEvent(await fn(prev), c); + return createEvent(opts, await fn(prev)); } else { throw new HTTPException(422, { message: 'No event to update', @@ -68,20 +71,22 @@ async function updateEvent( /** Update a replaceable list event, or throw if no event exists yet. */ function updateListEvent( + opts: CreateEventOpts, filter: UpdateEventFilter, fn: (tags: string[][]) => string[][], - c: AppContext, ): Promise { - return updateEvent(filter, ({ content, tags }) => ({ + return updateEvent(opts, filter, ({ content, tags }) => ({ kind: filter.kinds[0], content, tags: fn(tags), - }), c); + })); } /** Publish an admin event through the pipeline. */ -async function createAdminEvent(t: EventStub, c: AppContext): Promise { - const signer = new AdminSigner(); +async function createAdminEvent(opts: CreateEventOpts, t: EventStub): Promise { + const { conf } = opts; + + const signer = new AdminSigner(conf); const event = await signer.signEvent({ content: '', @@ -90,46 +95,59 @@ async function createAdminEvent(t: EventStub, c: AppContext): Promise string[][], - c: AppContext, ): Promise { - return updateAdminEvent(filter, (prev) => ({ + return updateAdminEvent(opts, filter, (prev) => ({ kind: filter.kinds[0], content: prev?.content ?? '', tags: fn(prev?.tags ?? []), - }), c); + })); } /** Fetch existing event, update it, then publish the new admin event. */ async function updateAdminEvent( + opts: CreateEventOpts, filter: UpdateEventFilter, fn: (prev: NostrEvent | undefined) => E, - c: AppContext, ): Promise { - const store = await Storages.db(); - const [prev] = await store.query([filter], { limit: 1, signal: c.req.raw.signal }); - return createAdminEvent(fn(prev), c); + const { store, signal } = opts; + + const [prev] = await store.query( + [{ ...filter, limit: 1 }], + { signal }, + ); + + return createAdminEvent(opts, fn(prev)); } -function updateUser(pubkey: string, n: Record, c: AppContext): Promise { - return updateNames(30382, pubkey, n, c); +function updateUser(opts: CreateEventOpts, pubkey: string, n: Record): Promise { + return updateNames(opts, 30382, pubkey, n); } -function updateEventInfo(id: string, n: Record, c: AppContext): Promise { - return updateNames(30383, id, n, c); +function updateEventInfo(opts: CreateEventOpts, id: string, n: Record): Promise { + return updateNames(opts, 30383, id, n); } -async function updateNames(k: number, d: string, n: Record, c: AppContext): Promise { - const signer = new AdminSigner(); +async function updateNames( + opts: CreateEventOpts, + k: number, + d: string, + n: Record, +): Promise { + const { conf } = opts; + + const signer = new AdminSigner(conf); const admin = await signer.getPublicKey(); return updateAdminEvent( + opts, { kinds: [k], authors: [admin], '#d': [d], limit: 1 }, (prev) => { const prevNames = prev?.tags.reduce((acc, [name, value]) => { @@ -151,22 +169,25 @@ async function updateNames(k: number, d: string, n: Record, c: ], }; }, - c, ); } /** Push the event through the pipeline, rethrowing any RelayError. */ -async function publishEvent(event: NostrEvent, c: AppContext): Promise { +async function publishEvent( + opts: CreateEventOpts, + event: NostrEvent, +): Promise { + const { store, pool, signal } = opts; + logi({ level: 'info', ns: 'ditto.event', source: 'api', id: event.id, kind: event.kind }); + try { - await pipeline.handleEvent(event, { source: 'api', signal: c.req.raw.signal }); - const client = await Storages.client(); - await client.event(purifyEvent(event)); + event = purifyEvent(event); + await store.event(event, { signal }); + await pool.event(event, { signal }); } catch (e) { if (e instanceof RelayError) { - throw new HTTPException(422, { - res: c.json({ error: e.message }, 422), - }); + throw new HTTPException(422, e); } else { throw e; } @@ -191,12 +212,11 @@ async function parseBody(req: Request): Promise { } /** Build HTTP Link header for Mastodon API pagination. */ -function buildLinkHeader(url: string, events: NostrEvent[]): string | undefined { +function buildLinkHeader(origin: string, url: string, events: NostrEvent[]): string | undefined { if (events.length <= 1) return; const firstEvent = events[0]; const lastEvent = events[events.length - 1]; - const { origin } = Conf.url; const { pathname, search } = new URL(url); const next = new URL(pathname + search, origin); const prev = new URL(pathname + search, origin); @@ -211,7 +231,10 @@ type HeaderRecord = Record; /** Return results with pagination headers. Assumes chronological sorting of events. */ function paginated(c: AppContext, events: NostrEvent[], body: object | unknown[], headers: HeaderRecord = {}) { - const link = buildLinkHeader(c.req.url, events); + const { conf } = c.var; + const { origin } = conf.url; + + const link = buildLinkHeader(origin, c.req.url, events); if (link) { headers.link = link; @@ -223,8 +246,11 @@ function paginated(c: AppContext, events: NostrEvent[], body: object | unknown[] } /** Build HTTP Link header for paginating Nostr lists. */ -function buildListLinkHeader(url: string, params: { offset: number; limit: number }): string | undefined { - const { origin } = Conf.url; +function buildListLinkHeader( + origin: string, + url: string, + params: { offset: number; limit: number }, +): string | undefined { const { pathname, search } = new URL(url); const { offset, limit } = params; const next = new URL(pathname + search, origin); @@ -246,7 +272,10 @@ function paginatedList( body: object | unknown[], headers: HeaderRecord = {}, ) { - const link = buildListLinkHeader(c.req.url, params); + const { conf } = c.var; + const { origin } = conf.url; + + const link = buildListLinkHeader(origin, c.req.url, params); const hasMore = Array.isArray(body) ? body.length > 0 : true; if (link) { @@ -260,15 +289,17 @@ function paginatedList( /** Rewrite the URL of the request object to use the local domain. */ function localRequest(c: Context): Request { + const { conf } = c.var; + return Object.create(c.req.raw, { - url: { value: Conf.local(c.req.url) }, + url: { value: conf.local(c.req.url) }, }); } /** Actors with Bluesky's `!no-unauthenticated` self-label should require authorization to view. */ function assertAuthenticated(c: AppContext, author: NostrEvent): void { if ( - !c.get('signer') && author.tags.some(([name, value, ns]) => + !c.var.user && author.tags.some(([name, value, ns]) => name === 'l' && value === '!no-unauthenticated' && ns === 'com.atproto.label.defs#selfLabel' diff --git a/packages/ditto/utils/connect.ts b/packages/ditto/utils/connect.ts deleted file mode 100644 index 7726fa89..00000000 --- a/packages/ditto/utils/connect.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Conf } from '@/config.ts'; -import { Storages } from '@/storages.ts'; -import { getInstanceMetadata } from '@/utils/instance.ts'; - -/** NIP-46 client-connect metadata. */ -interface ConnectMetadata { - name: string; - description: string; - url: string; -} - -/** Get NIP-46 `nostrconnect://` URI for the Ditto server. */ -export async function getClientConnectUri(signal?: AbortSignal): Promise { - const uri = new URL('nostrconnect://'); - const { name, tagline } = await getInstanceMetadata(await Storages.db(), signal); - - const metadata: ConnectMetadata = { - name, - description: tagline, - url: Conf.localDomain, - }; - - uri.host = Conf.pubkey; - uri.searchParams.set('relay', Conf.relay); - uri.searchParams.set('metadata', JSON.stringify(metadata)); - - return uri.toString(); -} diff --git a/packages/ditto/utils/favicon.ts b/packages/ditto/utils/favicon.ts index ed218cfa..999439ac 100644 --- a/packages/ditto/utils/favicon.ts +++ b/packages/ditto/utils/favicon.ts @@ -1,4 +1,5 @@ import { DOMParser } from '@b-fuze/deno-dom'; +import { DittoConf } from '@ditto/conf'; import { DittoTables } from '@ditto/db'; import { cachedFaviconsSizeGauge } from '@ditto/metrics'; import { logi } from '@soapbox/logi'; @@ -6,29 +7,41 @@ import { safeFetch } from '@soapbox/safe-fetch'; import { Kysely } from 'kysely'; import tldts from 'tldts'; -import { Conf } from '@/config.ts'; -import { Storages } from '@/storages.ts'; import { nostrNow } from '@/utils.ts'; import { SimpleLRU } from '@/utils/SimpleLRU.ts'; -export const faviconCache = new SimpleLRU( - async (domain, { signal }) => { - const kysely = await Storages.kysely(); +let faviconCache: SimpleLRU | undefined; - const row = await queryFavicon(kysely, domain); +interface ResolveFaviconOpts { + conf: DittoConf; + kysely: Kysely; + signal?: AbortSignal; +} - if (row && (nostrNow() - row.last_updated_at) < (Conf.caches.favicon.ttl / 1000)) { - return new URL(row.favicon); - } +export function resolveFavicon(opts: ResolveFaviconOpts, domain: string): Promise { + const { conf, kysely } = opts; - const url = await fetchFavicon(domain, signal); + if (!faviconCache) { + faviconCache = new SimpleLRU( + async (domain, { signal }) => { + const row = await queryFavicon(kysely, domain); - await insertFavicon(kysely, domain, url.href); + if (row && (nostrNow() - row.last_updated_at) < (conf.caches.favicon.ttl / 1000)) { + return new URL(row.favicon); + } - return url; - }, - { ...Conf.caches.favicon, gauge: cachedFaviconsSizeGauge }, -); + const url = await fetchFavicon(domain, signal); + + await insertFavicon(kysely, domain, url.href); + + return url; + }, + { ...conf.caches.favicon, gauge: cachedFaviconsSizeGauge }, + ); + } + + return faviconCache.fetch(domain, opts); +} async function queryFavicon( kysely: Kysely, @@ -38,6 +51,7 @@ async function queryFavicon( .selectFrom('domain_favicons') .selectAll() .where('domain', '=', domain) + .limit(1) .executeTakeFirst(); } diff --git a/packages/ditto/utils/instance.ts b/packages/ditto/utils/instance.ts index c0b9c0d4..1c60fcad 100644 --- a/packages/ditto/utils/instance.ts +++ b/packages/ditto/utils/instance.ts @@ -1,7 +1,7 @@ +import { type DittoConf } from '@ditto/conf'; import { NostrEvent, NostrMetadata, NSchema as n, NStore } from '@nostrify/nostrify'; import { z } from 'zod'; -import { Conf } from '@/config.ts'; import { screenshotsSchema, serverMetaSchema } from '@/schemas/nostr.ts'; /** Like NostrMetadata, but some fields are required and also contains some extra fields. */ @@ -16,9 +16,13 @@ export interface InstanceMetadata extends NostrMetadata { } /** Get and parse instance metadata from the kind 0 of the admin user. */ -export async function getInstanceMetadata(store: NStore, signal?: AbortSignal): Promise { +export async function getInstanceMetadata( + opts: { conf: DittoConf; store: NStore; signal?: AbortSignal }, +): Promise { + const { conf, store, signal } = opts; + const [event] = await store.query( - [{ kinds: [0], authors: [Conf.pubkey], limit: 1 }], + [{ kinds: [0], authors: [conf.pubkey], limit: 1 }], { signal }, ); @@ -33,8 +37,8 @@ export async function getInstanceMetadata(store: NStore, signal?: AbortSignal): name: meta.name ?? 'Ditto', about: meta.about ?? 'Nostr community server', tagline: meta.tagline ?? meta.about ?? 'Nostr community server', - email: meta.email ?? `postmaster@${Conf.url.host}`, - picture: meta.picture ?? Conf.local('/images/thumbnail.png'), + email: meta.email ?? `postmaster@${conf.url.host}`, + picture: meta.picture ?? conf.local('/images/thumbnail.png'), event, screenshots: meta.screenshots ?? [], }; diff --git a/packages/ditto/utils/lookup.ts b/packages/ditto/utils/lookup.ts index 9afd8a08..d30075e0 100644 --- a/packages/ditto/utils/lookup.ts +++ b/packages/ditto/utils/lookup.ts @@ -1,32 +1,49 @@ -import { NostrEvent, NSchema as n } from '@nostrify/nostrify'; +import { DittoConf } from '@ditto/conf'; +import { DittoTables } from '@ditto/db'; +import { NostrEvent, NSchema as n, NStore } from '@nostrify/nostrify'; +import { Kysely } from 'kysely'; import { nip19 } from 'nostr-tools'; import { match } from 'path-to-regexp'; import tldts from 'tldts'; import { getAuthor } from '@/queries.ts'; import { bech32ToPubkey } from '@/utils.ts'; -import { nip05Cache } from '@/utils/nip05.ts'; +import { resolveNip05 } from '@/utils/nip05.ts'; + +interface LookupAccountOpts { + conf: DittoConf; + store: NStore; + kysely: Kysely; + signal?: AbortSignal; +} /** Resolve a bech32 or NIP-05 identifier to an account. */ export async function lookupAccount( + opts: LookupAccountOpts, value: string, - signal = AbortSignal.timeout(3000), ): Promise { - const pubkey = await lookupPubkey(value, signal); + const pubkey = await lookupPubkey(opts, value); if (pubkey) { - return getAuthor(pubkey); + return getAuthor(opts, pubkey); } } +interface LookupPubkeyOpts { + conf: DittoConf; + store: NStore; + kysely: Kysely; + signal?: AbortSignal; +} + /** Resolve a bech32 or NIP-05 identifier to a pubkey. */ -export async function lookupPubkey(value: string, signal?: AbortSignal): Promise { +export async function lookupPubkey(opts: LookupPubkeyOpts, value: string): Promise { if (n.bech32().safeParse(value).success) { return bech32ToPubkey(value); } try { - const { pubkey } = await nip05Cache.fetch(value, { signal }); + const { pubkey } = await resolveNip05(opts, value); return pubkey; } catch { return; diff --git a/packages/ditto/utils/nip05.ts b/packages/ditto/utils/nip05.ts index 798fabdf..3b451fff 100644 --- a/packages/ditto/utils/nip05.ts +++ b/packages/ditto/utils/nip05.ts @@ -5,24 +5,38 @@ import { safeFetch } from '@soapbox/safe-fetch'; import { nip19 } from 'nostr-tools'; import tldts from 'tldts'; -import { Conf } from '@/config.ts'; -import { Storages } from '@/storages.ts'; import { errorJson } from '@/utils/log.ts'; import { SimpleLRU } from '@/utils/SimpleLRU.ts'; +import { DittoConf } from '../../conf/mod.ts'; -export const nip05Cache = new SimpleLRU( - async (nip05, { signal }) => { - const store = await Storages.db(); - return getNip05(store, nip05, signal); - }, - { ...Conf.caches.nip05, gauge: cachedNip05sSizeGauge }, -); +let nip05Cache: SimpleLRU | undefined; + +interface ResolveNip05Opts { + conf: DittoConf; + store: NStore; + signal?: AbortSignal; +} + +export function resolveNip05(opts: ResolveNip05Opts, nip05: string): Promise { + const { conf } = opts; + + if (!nip05Cache) { + nip05Cache = new SimpleLRU( + async (nip05, { signal }) => { + return await getNip05({ ...opts, signal }, nip05); + }, + { ...conf.caches.nip05, gauge: cachedNip05sSizeGauge }, + ); + } + + return nip05Cache.fetch(nip05, opts); +} async function getNip05( - store: NStore, + opts: ResolveNip05Opts, nip05: string, - signal?: AbortSignal, ): Promise { + const { conf, signal } = opts; const tld = tldts.parse(nip05); if (!tld.isIcann || tld.isIp || tld.isPrivate) { @@ -34,8 +48,8 @@ async function getNip05( const [name, domain] = nip05.split('@'); try { - if (domain === Conf.url.host) { - const pointer = await localNip05Lookup(store, name); + if (domain === conf.url.host) { + const pointer = await localNip05Lookup(opts, name); if (pointer) { logi({ level: 'info', ns: 'ditto.nip05', nip05, state: 'found', source: 'local', pubkey: pointer.pubkey }); return pointer; @@ -53,17 +67,22 @@ async function getNip05( } } -export async function localNip05Lookup(store: NStore, localpart: string): Promise { +export async function localNip05Lookup( + opts: ResolveNip05Opts, + localpart: string, +): Promise { + const { conf, store } = opts; + const [grant] = await store.query([{ kinds: [30360], - '#d': [`${localpart}@${Conf.url.host}`], - authors: [Conf.pubkey], + '#d': [`${localpart}@${conf.url.host}`], + authors: [conf.pubkey], limit: 1, }]); const pubkey = grant?.tags.find(([name]) => name === 'p')?.[1]; if (pubkey) { - return { pubkey, relays: [Conf.relay] }; + return { pubkey, relays: [conf.relay] }; } } diff --git a/packages/ditto/utils/note.test.ts b/packages/ditto/utils/note.test.ts index 699c4c5e..d13ea04c 100644 --- a/packages/ditto/utils/note.test.ts +++ b/packages/ditto/utils/note.test.ts @@ -1,27 +1,32 @@ import { assertEquals } from '@std/assert'; -import { eventFixture } from '@/test.ts'; +import { eventFixture, testConf } from '@/test.ts'; import { getMediaLinks, parseNoteContent, stripimeta } from '@/utils/note.ts'; Deno.test('parseNoteContent', () => { - const { html, links, firstUrl } = parseNoteContent('Hello, world!', []); + const conf = testConf(); + const { html, links, firstUrl } = parseNoteContent(conf, 'Hello, world!', []); assertEquals(html, 'Hello, world!'); assertEquals(links, []); assertEquals(firstUrl, undefined); }); Deno.test('parseNoteContent parses URLs', () => { - const { html } = parseNoteContent('check out my website: https://alexgleason.me', []); + const conf = testConf(); + const { html } = parseNoteContent(conf, 'check out my website: https://alexgleason.me', []); assertEquals(html, 'check out my website: https://alexgleason.me'); }); Deno.test('parseNoteContent parses bare URLs', () => { - const { html } = parseNoteContent('have you seen ditto.pub?', []); + const conf = testConf(); + const { html } = parseNoteContent(conf, 'have you seen ditto.pub?', []); assertEquals(html, 'have you seen ditto.pub?'); }); Deno.test('parseNoteContent parses mentions with apostrophes', () => { + const conf = testConf(); const { html } = parseNoteContent( + conf, `did you see nostr:nprofile1qqsqgc0uhmxycvm5gwvn944c7yfxnnxm0nyh8tt62zhrvtd3xkj8fhgprdmhxue69uhkwmr9v9ek7mnpw3hhytnyv4mz7un9d3shjqgcwaehxw309ahx7umywf5hvefwv9c8qtmjv4kxz7gpzemhxue69uhhyetvv9ujumt0wd68ytnsw43z7s3al0v's speech?`, [{ id: '0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd', @@ -37,7 +42,9 @@ Deno.test('parseNoteContent parses mentions with apostrophes', () => { }); Deno.test('parseNoteContent parses mentions with commas', () => { + const conf = testConf(); const { html } = parseNoteContent( + conf, `Sim. Hi nostr:npub1q3sle0kvfsehgsuexttt3ugjd8xdklxfwwkh559wxckmzddywnws6cd26p and nostr:npub1gujeqakgt7fyp6zjggxhyy7ft623qtcaay5lkc8n8gkry4cvnrzqd3f67z, any chance to have Cobrafuma as PWA?`, [{ id: '0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd', @@ -58,12 +65,15 @@ Deno.test('parseNoteContent parses mentions with commas', () => { }); Deno.test("parseNoteContent doesn't parse invalid nostr URIs", () => { - const { html } = parseNoteContent('nip19 has URIs like nostr:npub and nostr:nevent, etc.', []); + const conf = testConf(); + const { html } = parseNoteContent(conf, 'nip19 has URIs like nostr:npub and nostr:nevent, etc.', []); assertEquals(html, 'nip19 has URIs like nostr:npub and nostr:nevent, etc.'); }); Deno.test('parseNoteContent renders empty for non-profile nostr URIs', () => { + const conf = testConf(); const { html } = parseNoteContent( + conf, 'nostr:nevent1qgsr9cvzwc652r4m83d86ykplrnm9dg5gwdvzzn8ameanlvut35wy3gpz3mhxue69uhhztnnwashymtnw3ezucm0d5qzqru8mkz2q4gzsxg99q7pdneyx7n8p5u0afe3ntapj4sryxxmg4gpcdvgce', [], ); @@ -71,7 +81,9 @@ Deno.test('parseNoteContent renders empty for non-profile nostr URIs', () => { }); Deno.test("parseNoteContent doesn't fuck up links to my own post", () => { + const conf = testConf(); const { html } = parseNoteContent( + conf, 'Check this post: https://gleasonator.dev/@alex@gleasonator.dev/posts/a8badb480d88f9e7b6a090342279ef47ed0e0a3989ed85f898dfedc6be94225f', [{ id: '0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd', diff --git a/packages/ditto/utils/note.ts b/packages/ditto/utils/note.ts index 45fcf94a..8c12945c 100644 --- a/packages/ditto/utils/note.ts +++ b/packages/ditto/utils/note.ts @@ -1,9 +1,9 @@ +import { DittoConf } from '@ditto/conf'; import 'linkify-plugin-hashtag'; import linkifyStr from 'linkify-string'; import linkify from 'linkifyjs'; import { nip19, nip27 } from 'nostr-tools'; -import { Conf } from '@/config.ts'; import { MastodonMention } from '@/entities/MastodonMention.ts'; import { html } from '@/utils/html.ts'; import { getUrlMediaType, isPermittedMediaType } from '@/utils/media.ts'; @@ -21,7 +21,7 @@ interface ParsedNoteContent { } /** Convert Nostr content to Mastodon API HTML. Also return parsed data. */ -function parseNoteContent(content: string, mentions: MastodonMention[]): ParsedNoteContent { +function parseNoteContent(conf: DittoConf, content: string, mentions: MastodonMention[]): ParsedNoteContent { const links = linkify.find(content).filter(({ type }) => type === 'url'); const firstUrl = links.find(isNonMediaLink)?.href; @@ -29,7 +29,7 @@ function parseNoteContent(content: string, mentions: MastodonMention[]): ParsedN render: { hashtag: ({ content }) => { const tag = content.replace(/^#/, ''); - const href = Conf.local(`/tags/${tag}`); + const href = conf.local(`/tags/${tag}`); return html``; }, url: ({ attributes, content }) => { @@ -48,7 +48,7 @@ function parseNoteContent(content: string, mentions: MastodonMention[]): ParsedN const npub = nip19.npubEncode(pubkey); const acct = mention?.acct ?? npub; const name = mention?.acct ?? npub.substring(0, 8); - const href = mention?.url ?? Conf.local(`/@${acct}`); + const href = mention?.url ?? conf.local(`/@${acct}`); return html`@${name}${extra}`; } else { return ''; diff --git a/packages/ditto/utils/outbox.test.ts b/packages/ditto/utils/outbox.test.ts index 62dac2d0..61a871be 100644 --- a/packages/ditto/utils/outbox.test.ts +++ b/packages/ditto/utils/outbox.test.ts @@ -1,29 +1,30 @@ -import { MockRelay } from '@nostrify/nostrify/test'; -import { eventFixture } from '@/test.ts'; +import { createTestDB, eventFixture } from '@/test.ts'; import { getRelays } from '@/utils/outbox.ts'; import { assertEquals } from '@std/assert'; Deno.test('Get write relays - kind 10002', async () => { - const db = new MockRelay(); + await using db = await createTestDB(); + const { conf, store } = db; const relayListMetadata = await eventFixture('kind-10002-alex'); - await db.event(relayListMetadata); + await store.event(relayListMetadata); - const relays = await getRelays(db, relayListMetadata.pubkey); + const relays = await getRelays(conf, store, relayListMetadata.pubkey); assertEquals(relays.size, 6); }); Deno.test('Get write relays with invalid URL - kind 10002', async () => { - const db = new MockRelay(); + await using db = await createTestDB(); + const { conf, store } = db; const relayListMetadata = await eventFixture('kind-10002-alex'); relayListMetadata.tags[0] = ['r', 'yolo']; - await db.event(relayListMetadata); + await store.event(relayListMetadata); - const relays = await getRelays(db, relayListMetadata.pubkey); + const relays = await getRelays(conf, store, relayListMetadata.pubkey); assertEquals(relays.size, 5); }); diff --git a/packages/ditto/utils/outbox.ts b/packages/ditto/utils/outbox.ts index 891cccb8..50546d4b 100644 --- a/packages/ditto/utils/outbox.ts +++ b/packages/ditto/utils/outbox.ts @@ -1,12 +1,11 @@ +import { DittoConf } from '@ditto/conf'; import { NStore } from '@nostrify/nostrify'; -import { Conf } from '@/config.ts'; - -export async function getRelays(store: NStore, pubkey: string): Promise> { +export async function getRelays(conf: DittoConf, store: NStore, pubkey: string): Promise> { const relays = new Set<`wss://${string}`>(); const events = await store.query([ - { kinds: [10002], authors: [pubkey, Conf.pubkey], limit: 2 }, + { kinds: [10002], authors: [pubkey, conf.pubkey], limit: 2 }, ]); for (const event of events) { diff --git a/packages/ditto/utils/pleroma.ts b/packages/ditto/utils/pleroma.ts index 05c35b7c..6950866c 100644 --- a/packages/ditto/utils/pleroma.ts +++ b/packages/ditto/utils/pleroma.ts @@ -1,12 +1,19 @@ +import { DittoConf } from '@ditto/conf'; import { NSchema as n, NStore } from '@nostrify/nostrify'; -import { Conf } from '@/config.ts'; import { configSchema } from '@/schemas/pleroma-api.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts'; import { PleromaConfigDB } from '@/utils/PleromaConfigDB.ts'; -export async function getPleromaConfigs(store: NStore, signal?: AbortSignal): Promise { - const { pubkey } = Conf; +interface GetPleromaConfigOpts { + conf: DittoConf; + store: NStore; + signal?: AbortSignal; +} + +export async function getPleromaConfigs(opts: GetPleromaConfigOpts): Promise { + const { conf, store, signal } = opts; + const { pubkey } = conf; const [event] = await store.query([{ kinds: [30078], @@ -20,7 +27,7 @@ export async function getPleromaConfigs(store: NStore, signal?: AbortSignal): Pr } try { - const decrypted = await new AdminSigner().nip44.decrypt(Conf.pubkey, event.content); + const decrypted = await new AdminSigner(conf).nip44.decrypt(conf.pubkey, event.content); const configs = n.json().pipe(configSchema.array()).catch([]).parse(decrypted); return new PleromaConfigDB(configs); } catch (_e) { diff --git a/packages/ditto/utils/stats.test.ts b/packages/ditto/utils/stats.test.ts index 797f78da..aef66c1c 100644 --- a/packages/ditto/utils/stats.test.ts +++ b/packages/ditto/utils/stats.test.ts @@ -10,7 +10,7 @@ Deno.test('updateStats with kind 1 increments notes count', async () => { const sk = generateSecretKey(); const pubkey = getPublicKey(sk); - await updateStats({ ...db, event: genEvent({ kind: 1 }, sk) }); + await updateStats(db, genEvent({ kind: 1 }, sk)); const stats = await getAuthorStats(db.kysely, pubkey); @@ -23,11 +23,11 @@ Deno.test('updateStats with kind 1 increments replies count', async () => { const sk = generateSecretKey(); const note = genEvent({ kind: 1 }, sk); - await updateStats({ ...db, event: note }); + await updateStats(db, note); await db.store.event(note); const reply = genEvent({ kind: 1, tags: [['e', note.id]] }, sk); - await updateStats({ ...db, event: reply }); + await updateStats(db, reply); await db.store.event(reply); const stats = await getEventStats(db.kysely, note.id); @@ -44,11 +44,11 @@ Deno.test('updateStats with kind 5 decrements notes count', async () => { const create = genEvent({ kind: 1 }, sk); const remove = genEvent({ kind: 5, tags: [['e', create.id]] }, sk); - await updateStats({ ...db, event: create }); + await updateStats(db, create); assertEquals((await getAuthorStats(db.kysely, pubkey))!.notes_count, 1); await db.store.event(create); - await updateStats({ ...db, event: remove }); + await updateStats(db, remove); assertEquals((await getAuthorStats(db.kysely, pubkey))!.notes_count, 0); await db.store.event(remove); }); @@ -56,9 +56,9 @@ Deno.test('updateStats with kind 5 decrements notes count', async () => { Deno.test('updateStats with kind 3 increments followers count', async () => { await using db = await createTestDB(); - await updateStats({ ...db, event: genEvent({ kind: 3, tags: [['p', 'alex']] }) }); - await updateStats({ ...db, event: genEvent({ kind: 3, tags: [['p', 'alex']] }) }); - await updateStats({ ...db, event: genEvent({ kind: 3, tags: [['p', 'alex']] }) }); + await updateStats(db, genEvent({ kind: 3, tags: [['p', 'alex']] })); + await updateStats(db, genEvent({ kind: 3, tags: [['p', 'alex']] })); + await updateStats(db, genEvent({ kind: 3, tags: [['p', 'alex']] })); const stats = await getAuthorStats(db.kysely, 'alex'); @@ -72,11 +72,11 @@ Deno.test('updateStats with kind 3 decrements followers count', async () => { const follow = genEvent({ kind: 3, tags: [['p', 'alex']], created_at: 0 }, sk); const remove = genEvent({ kind: 3, tags: [], created_at: 1 }, sk); - await updateStats({ ...db, event: follow }); + await updateStats(db, follow); assertEquals((await getAuthorStats(db.kysely, 'alex'))!.followers_count, 1); await db.store.event(follow); - await updateStats({ ...db, event: remove }); + await updateStats(db, remove); assertEquals((await getAuthorStats(db.kysely, 'alex'))!.followers_count, 0); await db.store.event(remove); }); @@ -95,11 +95,11 @@ Deno.test('updateStats with kind 6 increments reposts count', async () => { await using db = await createTestDB(); const note = genEvent({ kind: 1 }); - await updateStats({ ...db, event: note }); + await updateStats(db, note); await db.store.event(note); const repost = genEvent({ kind: 6, tags: [['e', note.id]] }); - await updateStats({ ...db, event: repost }); + await updateStats(db, repost); await db.store.event(repost); const stats = await getEventStats(db.kysely, note.id); @@ -111,15 +111,15 @@ Deno.test('updateStats with kind 5 decrements reposts count', async () => { await using db = await createTestDB(); const note = genEvent({ kind: 1 }); - await updateStats({ ...db, event: note }); + await updateStats(db, note); await db.store.event(note); const sk = generateSecretKey(); const repost = genEvent({ kind: 6, tags: [['e', note.id]] }, sk); - await updateStats({ ...db, event: repost }); + await updateStats(db, repost); await db.store.event(repost); - await updateStats({ ...db, event: genEvent({ kind: 5, tags: [['e', repost.id]] }, sk) }); + await updateStats(db, genEvent({ kind: 5, tags: [['e', repost.id]] }, sk)); const stats = await getEventStats(db.kysely, note.id); @@ -130,11 +130,11 @@ Deno.test('updateStats with kind 7 increments reactions count', async () => { await using db = await createTestDB(); const note = genEvent({ kind: 1 }); - await updateStats({ ...db, event: note }); + await updateStats(db, note); await db.store.event(note); - await updateStats({ ...db, event: genEvent({ kind: 7, content: '+', tags: [['e', note.id]] }) }); - await updateStats({ ...db, event: genEvent({ kind: 7, content: '😂', tags: [['e', note.id]] }) }); + await updateStats(db, genEvent({ kind: 7, content: '+', tags: [['e', note.id]] })); + await updateStats(db, genEvent({ kind: 7, content: '😂', tags: [['e', note.id]] })); const stats = await getEventStats(db.kysely, note.id); @@ -146,15 +146,15 @@ Deno.test('updateStats with kind 5 decrements reactions count', async () => { await using db = await createTestDB(); const note = genEvent({ kind: 1 }); - await updateStats({ ...db, event: note }); + await updateStats(db, note); await db.store.event(note); const sk = generateSecretKey(); const reaction = genEvent({ kind: 7, content: '+', tags: [['e', note.id]] }, sk); - await updateStats({ ...db, event: reaction }); + await updateStats(db, reaction); await db.store.event(reaction); - await updateStats({ ...db, event: genEvent({ kind: 5, tags: [['e', reaction.id]] }, sk) }); + await updateStats(db, genEvent({ kind: 5, tags: [['e', reaction.id]] }, sk)); const stats = await getEventStats(db.kysely, note.id); diff --git a/packages/ditto/utils/stats.ts b/packages/ditto/utils/stats.ts index 01ec80d9..961a46af 100644 --- a/packages/ditto/utils/stats.ts +++ b/packages/ditto/utils/stats.ts @@ -1,43 +1,44 @@ +import { DittoConf } from '@ditto/conf'; import { DittoTables } from '@ditto/db'; import { NostrEvent, NSchema as n, NStore } from '@nostrify/nostrify'; import { Insertable, Kysely, UpdateObject } from 'kysely'; import { SetRequired } from 'type-fest'; import { z } from 'zod'; -import { Conf } from '@/config.ts'; import { findQuoteTag, findReplyTag, getTagSet } from '@/utils/tags.ts'; interface UpdateStatsOpts { + conf: DittoConf; kysely: Kysely; store: NStore; - event: NostrEvent; - x?: 1 | -1; } /** Handle one event at a time and update relevant stats for it. */ // deno-lint-ignore require-await -export async function updateStats({ event, kysely, store, x = 1 }: UpdateStatsOpts): Promise { +export async function updateStats(opts: UpdateStatsOpts, event: NostrEvent, x = 1): Promise { switch (event.kind) { case 1: case 20: case 1111: case 30023: - return handleEvent1(kysely, event, x); + return handleEvent1(opts, event, x); case 3: - return handleEvent3(kysely, event, x, store); + return handleEvent3(opts, event, x); case 5: - return handleEvent5(kysely, event, -1, store); + return handleEvent5(opts, event, -1); case 6: - return handleEvent6(kysely, event, x); + return handleEvent6(opts, event, x); case 7: - return handleEvent7(kysely, event, x); + return handleEvent7(opts, event, x); case 9735: - return handleEvent9735(kysely, event); + return handleEvent9735(opts, event); } } /** Update stats for kind 1 event. */ -async function handleEvent1(kysely: Kysely, event: NostrEvent, x: number): Promise { +async function handleEvent1(opts: UpdateStatsOpts, event: NostrEvent, x: number): Promise { + const { conf, kysely } = opts; + await updateAuthorStats(kysely, event.pubkey, (prev) => { const now = event.created_at; @@ -47,7 +48,7 @@ async function handleEvent1(kysely: Kysely, event: NostrEvent, x: n if (start && end) { // Streak exists. if (now <= end) { // Streak cannot go backwards in time. Skip it. - } else if (now - end > Conf.streakWindow) { + } else if (now - end > conf.streakWindow) { // Streak is broken. Start a new streak. start = now; end = now; @@ -88,7 +89,9 @@ async function handleEvent1(kysely: Kysely, event: NostrEvent, x: n } /** Update stats for kind 3 event. */ -async function handleEvent3(kysely: Kysely, event: NostrEvent, x: number, store: NStore): Promise { +async function handleEvent3(opts: UpdateStatsOpts, event: NostrEvent, x: number): Promise { + const { kysely, store } = opts; + const following = getTagSet(event.tags, 'p'); await updateAuthorStats(kysely, event.pubkey, () => ({ following_count: following.size })); @@ -117,26 +120,34 @@ async function handleEvent3(kysely: Kysely, event: NostrEvent, x: n } /** Update stats for kind 5 event. */ -async function handleEvent5(kysely: Kysely, event: NostrEvent, x: -1, store: NStore): Promise { +async function handleEvent5(opts: UpdateStatsOpts, event: NostrEvent, x: number): Promise { + const { store } = opts; + const id = event.tags.find(([name]) => name === 'e')?.[1]; + if (id) { const [target] = await store.query([{ ids: [id], authors: [event.pubkey], limit: 1 }]); if (target) { - await updateStats({ event: target, kysely, store, x }); + await updateStats(opts, event, x); } } } /** Update stats for kind 6 event. */ -async function handleEvent6(kysely: Kysely, event: NostrEvent, x: number): Promise { +async function handleEvent6(opts: UpdateStatsOpts, event: NostrEvent, x: number): Promise { + const { kysely } = opts; + const id = event.tags.find(([name]) => name === 'e')?.[1]; + if (id) { await updateEventStats(kysely, id, ({ reposts_count }) => ({ reposts_count: Math.max(0, reposts_count + x) })); } } /** Update stats for kind 7 event. */ -async function handleEvent7(kysely: Kysely, event: NostrEvent, x: number): Promise { +async function handleEvent7(opts: UpdateStatsOpts, event: NostrEvent, x: number): Promise { + const { kysely } = opts; + const id = event.tags.findLast(([name]) => name === 'e')?.[1]; const emoji = event.content; @@ -166,7 +177,9 @@ async function handleEvent7(kysely: Kysely, event: NostrEvent, x: n } /** Update stats for kind 9735 event. */ -async function handleEvent9735(kysely: Kysely, event: NostrEvent): Promise { +async function handleEvent9735(opts: UpdateStatsOpts, event: NostrEvent): Promise { + const { kysely } = opts; + // https://github.com/nostr-protocol/nips/blob/master/57.md#appendix-f-validating-zap-receipts const id = event.tags.find(([name]) => name === 'e')?.[1]; if (!id) return; diff --git a/packages/ditto/utils/unfurl.ts b/packages/ditto/utils/unfurl.ts index e2d4f855..79862c0e 100644 --- a/packages/ditto/utils/unfurl.ts +++ b/packages/ditto/utils/unfurl.ts @@ -1,3 +1,4 @@ +import { DittoConf } from '@ditto/conf'; import { cachedLinkPreviewSizeGauge } from '@ditto/metrics'; import TTLCache from '@isaacs/ttlcache'; import { logi } from '@soapbox/logi'; @@ -5,18 +6,17 @@ import { safeFetch } from '@soapbox/safe-fetch'; import DOMPurify from 'isomorphic-dompurify'; import { unfurl } from 'unfurl.js'; -import { Conf } from '@/config.ts'; import { PreviewCard } from '@/entities/PreviewCard.ts'; import { errorJson } from '@/utils/log.ts'; -async function unfurlCard(url: string, signal: AbortSignal): Promise { +async function unfurlCard(conf: DittoConf, url: string, signal: AbortSignal): Promise { try { const result = await unfurl(url, { fetch: (url) => safeFetch(url, { headers: { 'Accept': 'text/html, application/xhtml+xml', - 'User-Agent': Conf.fetchUserAgent, + 'User-Agent': conf.fetchUserAgent, }, signal, }), @@ -55,15 +55,22 @@ async function unfurlCard(url: string, signal: AbortSignal): Promise>(Conf.caches.linkPreview); +let previewCardCache: TTLCache> | undefined; /** Unfurl card from cache if available, otherwise fetch it. */ -function unfurlCardCached(url: string, signal = AbortSignal.timeout(1000)): Promise { +function unfurlCardCached( + conf: DittoConf, + url: string, + signal = AbortSignal.timeout(1000), +): Promise { + if (!previewCardCache) { + previewCardCache = new TTLCache>(conf.caches.linkPreview); + } const cached = previewCardCache.get(url); if (cached !== undefined) { return cached; } else { - const card = unfurlCard(url, signal); + const card = unfurlCard(conf, url, signal); previewCardCache.set(url, card); cachedLinkPreviewSizeGauge.set(previewCardCache.size); return card; diff --git a/packages/ditto/utils/upload.ts b/packages/ditto/utils/upload.ts index 6c160bb4..1dcce807 100644 --- a/packages/ditto/utils/upload.ts +++ b/packages/ditto/utils/upload.ts @@ -6,7 +6,6 @@ import { encode } from 'blurhash'; import sharp from 'sharp'; import { AppContext } from '@/app.ts'; -import { Conf } from '@/config.ts'; import { DittoUpload, dittoUploads } from '@/DittoUploads.ts'; import { errorJson } from '@/utils/log.ts'; @@ -22,7 +21,8 @@ export async function uploadFile( meta: FileMeta, signal?: AbortSignal, ): Promise { - const uploader = c.get('uploader'); + const { conf, uploader } = c.var; + if (!uploader) { throw new HTTPException(500, { res: c.json({ error: 'No uploader configured.' }), @@ -31,7 +31,7 @@ export async function uploadFile( const { pubkey, description } = meta; - if (file.size > Conf.maxUploadSize) { + if (file.size > conf.maxUploadSize) { throw new Error('File size is too large.'); } @@ -63,7 +63,7 @@ export async function uploadFile( // If the uploader didn't already, try to get a blurhash and media dimensions. // This requires `MEDIA_ANALYZE=true` to be configured because it comes with security tradeoffs. - if (Conf.mediaAnalyze && (!blurhash || !dim)) { + if (conf.mediaAnalyze && (!blurhash || !dim)) { try { const bytes = await new Response(file.stream()).bytes(); const img = sharp(bytes); diff --git a/packages/ditto/utils/zap-split.ts b/packages/ditto/utils/zap-split.ts index e5df1538..334c109a 100644 --- a/packages/ditto/utils/zap-split.ts +++ b/packages/ditto/utils/zap-split.ts @@ -1,5 +1,6 @@ +import { DittoConf } from '@ditto/conf'; + import { AdminSigner } from '@/signers/AdminSigner.ts'; -import { Conf } from '@/config.ts'; import { NSchema as n, NStore } from '@nostrify/nostrify'; import { nostrNow } from '@/utils.ts'; import { percentageSchema } from '@/schema.ts'; @@ -37,14 +38,14 @@ export async function getZapSplits(store: NStore, pubkey: string): Promise Array.from(new Map(events.map((event) => [event.pubkey, event])).values())) - .then((events) => hydrateEvents({ events, store, signal })) + .then((events) => hydrateEvents(c.var, events)) .then((events) => filterFn ? events.filter(filterFn) : events); - const accounts = await Promise.all( - events.map(({ author, pubkey }) => { - if (author) { - return renderAccount(author); - } else { - return accountFromPubkey(pubkey); - } - }), - ); + const view = new AccountView(c.var); + const accounts = events.map(({ author, pubkey }) => view.render(author, pubkey)); return paginated(c, events, accounts); } @@ -46,22 +36,19 @@ async function renderAccounts(c: AppContext, pubkeys: string[]) { const { offset, limit } = c.get('listPagination'); const authors = pubkeys.reverse().slice(offset, offset + limit); - const store = await Storages.db(); + const { store } = c.var; const signal = c.req.raw.signal; - const events = await store.query([{ kinds: [0], authors }], { signal }) - .then((events) => hydrateEvents({ events, store, signal })); + const events = await store + .query([{ kinds: [0], authors }], { signal }) + .then((events) => hydrateEvents(c.var, events)); - const accounts = await Promise.all( - authors.map((pubkey) => { - const event = events.find((event) => event.pubkey === pubkey); - if (event) { - return renderAccount(event); - } else { - return accountFromPubkey(pubkey); - } - }), - ); + const view = new AccountView(c.var); + + const accounts = authors.map((pubkey) => { + const event = events.find((event) => event.pubkey === pubkey); + return view.render(event, pubkey); + }); return paginatedList(c, { offset, limit }, accounts); } @@ -72,11 +59,12 @@ async function renderStatuses(c: AppContext, ids: string[], signal = AbortSignal return c.json([]); } - const store = await Storages.db(); - const { limit } = c.get('pagination'); + const { store, pagination } = c.var; + const { limit } = pagination; - const events = await store.query([{ kinds: [1, 20], ids, limit }], { signal }) - .then((events) => hydrateEvents({ events, store, signal })); + const events = await store + .query([{ kinds: [1, 20], ids, limit }], { signal }) + .then((events) => hydrateEvents(c.var, events)); if (!events.length) { return c.json([]); @@ -84,10 +72,10 @@ async function renderStatuses(c: AppContext, ids: string[], signal = AbortSignal const sortedEvents = [...events].sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id)); - const viewerPubkey = await c.get('signer')?.getPublicKey(); + const view = new StatusView(c.var); const statuses = await Promise.all( - sortedEvents.map((event) => renderStatus(event, { viewerPubkey })), + sortedEvents.map((event) => view.render(event)), ); // TODO: pagination with min_id and max_id based on the order of `ids`. diff --git a/packages/ditto/views/mastodon/AccountView.ts b/packages/ditto/views/mastodon/AccountView.ts new file mode 100644 index 00000000..dff0cb5c --- /dev/null +++ b/packages/ditto/views/mastodon/AccountView.ts @@ -0,0 +1,170 @@ +import { DittoConf } from '@ditto/conf'; +import { NSchema as n } from '@nostrify/nostrify'; +import { nip19, UnsignedEvent } from 'nostr-tools'; + +import { MastodonAccount } from '@/entities/MastodonAccount.ts'; +import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; +import { metadataSchema } from '@/schemas/nostr.ts'; +import { getLnurl } from '@/utils/lnurl.ts'; +import { parseNoteContent } from '@/utils/note.ts'; +import { getTagSet } from '@/utils/tags.ts'; +import { nostrDate, nostrNow, parseNip05 } from '@/utils.ts'; +import { renderEmojis } from '@/views/mastodon/emojis.ts'; + +type ToAccountOpts = { + withSource: true; + settingsStore: Record | undefined; +} | { + withSource?: false; +}; + +interface AccountViewOpts { + conf: DittoConf; +} + +export class AccountView { + constructor(private opts: AccountViewOpts) {} + + render(event: Omit, pubkey?: string, opts?: ToAccountOpts): MastodonAccount; + render(event: Omit | undefined, pubkey: string, opts?: ToAccountOpts): MastodonAccount; + render(event: Omit | undefined, pubkey: string, opts: ToAccountOpts = {}): MastodonAccount { + const { conf } = this.opts; + + if (!event) { + return this.accountFromPubkey(pubkey, opts); + } + + const stats = event.author_stats; + const names = getTagSet(event.user?.tags ?? [], 'n'); + + if (names.has('disabled')) { + const account = this.accountFromPubkey(pubkey, opts); + account.pleroma.deactivated = true; + return account; + } + + const { + name, + nip05, + picture = conf.local('/images/avi.png'), + banner = conf.local('/images/banner.png'), + about, + lud06, + lud16, + website, + fields: _fields, + } = n.json().pipe(metadataSchema).catch({}).parse(event.content); + + const npub = nip19.npubEncode(pubkey); + const nprofile = nip19.nprofileEncode({ pubkey, relays: [conf.relay] }); + const parsed05 = stats?.nip05 ? parseNip05(stats.nip05) : undefined; + const acct = parsed05?.handle || npub; + + const { html } = parseNoteContent(conf, about || '', []); + + const fields = _fields + ?.slice(0, conf.profileFields.maxFields) + .map(([name, value]) => ({ + name: name.slice(0, conf.profileFields.nameLength), + value: value.slice(0, conf.profileFields.valueLength), + verified_at: null, + })) ?? []; + + let streakDays = 0; + let streakStart = stats?.streak_start ?? null; + let streakEnd = stats?.streak_end ?? null; + const { streakWindow } = conf; + + if (streakStart && streakEnd) { + const broken = nostrNow() - streakEnd > streakWindow; + if (broken) { + streakStart = null; + streakEnd = null; + } else { + const delta = streakEnd - streakStart; + streakDays = Math.max(Math.ceil(delta / 86400), 1); + } + } + + return { + id: pubkey, + acct, + avatar: picture, + avatar_static: picture, + bot: false, + created_at: nostrDate(event.user?.created_at ?? event.created_at).toISOString(), + discoverable: true, + display_name: name ?? '', + emojis: renderEmojis(event), + fields: fields.map((field) => ({ ...field, value: parseNoteContent(conf, field.value, []).html })), + follow_requests_count: 0, + followers_count: stats?.followers_count ?? 0, + following_count: stats?.following_count ?? 0, + fqn: parsed05?.handle || npub, + header: banner, + header_static: banner, + last_status_at: null, + locked: false, + note: html, + roles: [], + source: opts.withSource + ? { + fields, + language: '', + note: about || '', + privacy: 'public', + sensitive: false, + follow_requests_count: 0, + nostr: { + nip05, + }, + ditto: { + captcha_solved: names.has('captcha_solved'), + }, + } + : undefined, + statuses_count: stats?.notes_count ?? 0, + uri: conf.local(`/users/${acct}`), + url: conf.local(`/@${acct}`), + username: parsed05?.nickname || npub.substring(0, 8), + ditto: { + accepts_zaps: Boolean(getLnurl({ lud06, lud16 })), + external_url: conf.external(nprofile), + streak: { + days: streakDays, + start: streakStart ? nostrDate(streakStart).toISOString() : null, + end: streakEnd ? nostrDate(streakEnd).toISOString() : null, + expires: streakEnd ? nostrDate(streakEnd + streakWindow).toISOString() : null, + }, + }, + domain: parsed05?.domain, + pleroma: { + deactivated: names.has('disabled'), + is_admin: names.has('admin'), + is_moderator: names.has('admin') || names.has('moderator'), + is_suggested: names.has('suggested'), + is_local: parsed05?.domain === conf.url.host, + settings_store: opts.withSource ? opts.settingsStore : undefined, + tags: [...getTagSet(event.user?.tags ?? [], 't')], + favicon: stats?.favicon, + }, + nostr: { + pubkey, + lud16, + }, + website: website && /^https?:\/\//.test(website) ? website : undefined, + }; + } + + private accountFromPubkey(pubkey: string, opts: ToAccountOpts = {}): MastodonAccount { + const event: UnsignedEvent = { + kind: 0, + pubkey, + content: '', + tags: [], + created_at: nostrNow(), + }; + + return this.render(event, pubkey, opts); + } +} diff --git a/packages/ditto/views/mastodon/AdminAccountView.ts b/packages/ditto/views/mastodon/AdminAccountView.ts new file mode 100644 index 00000000..20403ddb --- /dev/null +++ b/packages/ditto/views/mastodon/AdminAccountView.ts @@ -0,0 +1,80 @@ +import { DittoConf } from '@ditto/conf'; + +import { DittoEvent } from '@/interfaces/DittoEvent.ts'; +import { getTagSet } from '@/utils/tags.ts'; +import { AccountView } from '@/views/mastodon/AccountView.ts'; + +interface AdminAccountViewOpts { + conf: DittoConf; +} + +export class AdminAccountView { + private accountView: AccountView; + + constructor(opts: AdminAccountViewOpts) { + this.accountView = new AccountView(opts); + } + + /** Expects a kind 0 fully hydrated */ + render(event: DittoEvent | undefined, pubkey: string) { + if (!event) { + return this.renderAdminAccountFromPubkey(pubkey); + } + + const account = this.accountView.render(event, pubkey); + const names = getTagSet(event.user?.tags ?? [], 'n'); + + let role = 'user'; + + if (names.has('admin')) { + role = 'admin'; + } + if (names.has('moderator')) { + role = 'moderator'; + } + + return { + id: account.id, + username: account.username, + domain: account.acct.split('@')[1] || null, + created_at: account.created_at, + email: '', + ip: null, + ips: [], + locale: '', + invite_request: null, + role, + confirmed: true, + approved: true, + disabled: names.has('disabled'), + silenced: names.has('silenced'), + suspended: names.has('suspended'), + sensitized: names.has('sensitized'), + account, + }; + } + + /** Expects a target pubkey */ + private renderAdminAccountFromPubkey(pubkey: string) { + const account = this.accountView.render(undefined, pubkey); + + return { + id: account.id, + username: account.username, + domain: account.acct.split('@')[1] || null, + created_at: account.created_at, + email: '', + ip: null, + ips: [], + locale: '', + invite_request: null, + role: 'user', + confirmed: true, + approved: true, + disabled: false, + silenced: false, + suspended: false, + account, + }; + } +} diff --git a/packages/ditto/views/mastodon/NotificationView.ts b/packages/ditto/views/mastodon/NotificationView.ts new file mode 100644 index 00000000..6aea349a --- /dev/null +++ b/packages/ditto/views/mastodon/NotificationView.ts @@ -0,0 +1,165 @@ +import { DittoConf } from '@ditto/conf'; +import { NostrEvent, NostrSigner, NStore } from '@nostrify/nostrify'; + +import { DittoEvent } from '@/interfaces/DittoEvent.ts'; +import { nostrDate } from '@/utils.ts'; +import { AccountView } from '@/views/mastodon/AccountView.ts'; +import { StatusView } from '@/views/mastodon/StatusView.ts'; + +interface NotificationViewOpts { + conf: DittoConf; + store: NStore; + user?: { + signer: NostrSigner; + }; +} + +export class NotificationView { + private accountView: AccountView; + private statusView: StatusView; + + constructor(private opts: NotificationViewOpts) { + this.accountView = new AccountView(opts); + this.statusView = new StatusView(opts); + } + + async render(event: DittoEvent) { + const { conf, user } = this.opts; + + const viewerPubkey = await user?.signer.getPublicKey(); + const mentioned = !!event.tags.find(([name, value]) => name === 'p' && value === viewerPubkey); + + if (event.kind === 1 && mentioned) { + return this.renderMention(event); + } + + if (event.kind === 6) { + return this.renderReblog(event); + } + + if (event.kind === 7 && event.content === '+') { + return this.renderFavourite(event); + } + + if (event.kind === 7) { + return this.renderReaction(event); + } + + if (event.kind === 30360 && event.pubkey === conf.pubkey) { + return this.renderNameGrant(event); + } + + if (event.kind === 9735) { + return this.renderZap(event); + } + } + + async renderMention(event: DittoEvent) { + const status = await this.statusView.render(event); + if (!status) return; + + return { + id: this.notificationId(event), + type: 'mention' as const, + created_at: nostrDate(event.created_at).toISOString(), + account: status.account, + status: status, + }; + } + + async renderReblog(event: DittoEvent) { + if (event.repost?.kind !== 1) return; + const status = await this.statusView.render(event.repost); + if (!status) return; + const account = this.accountView.render(event.author, event.pubkey); + + return { + id: this.notificationId(event), + type: 'reblog' as const, + created_at: nostrDate(event.created_at).toISOString(), + account, + status, + }; + } + + async renderFavourite(event: DittoEvent) { + if (event.reacted?.kind !== 1) return; + const status = await this.statusView.render(event.reacted); + if (!status) return; + const account = this.accountView.render(event.author, event.pubkey); + + return { + id: this.notificationId(event), + type: 'favourite' as const, + created_at: nostrDate(event.created_at).toISOString(), + account, + status, + }; + } + + async renderReaction(event: DittoEvent) { + if (event.reacted?.kind !== 1) return; + const status = await this.statusView.render(event.reacted); + if (!status) return; + const account = this.accountView.render(event.author, event.pubkey); + + return { + id: this.notificationId(event), + type: 'pleroma:emoji_reaction' as const, + emoji: event.content, + emoji_url: event.tags.find(([name, value]) => name === 'emoji' && `:${value}:` === event.content)?.[2], + created_at: nostrDate(event.created_at).toISOString(), + account, + status, + }; + } + + renderNameGrant(event: DittoEvent) { + const d = event.tags.find(([name]) => name === 'd')?.[1]; + const account = this.accountView.render(event.author, event.pubkey); + + if (!d) return; + + return { + id: this.notificationId(event), + type: 'ditto:name_grant' as const, + name: d, + created_at: nostrDate(event.created_at).toISOString(), + account, + }; + } + + async renderZap(event: DittoEvent) { + if (!event.zap_sender) return; + + const { zap_amount = 0, zap_message = '' } = event; + if (zap_amount < 1) return; + + let zapSender: NostrEvent | undefined; + let zapSenderPubkey: string; + + if (typeof event.zap_sender === 'string') { + zapSenderPubkey = event.zap_sender; + } else { + zapSender = event.zap_sender; + zapSenderPubkey = zapSender.pubkey; + } + + const account = this.accountView.render(zapSender, zapSenderPubkey); + + return { + id: this.notificationId(event), + type: 'ditto:zap' as const, + amount: zap_amount, + message: zap_message, + created_at: nostrDate(event.created_at).toISOString(), + account, + ...(event.zapped ? { status: await this.statusView.render(event.zapped) } : {}), + }; + } + + /** This helps notifications be sorted in the correct order. */ + notificationId({ id, created_at }: NostrEvent): string { + return `${created_at}-${id}`; + } +} diff --git a/packages/ditto/views/mastodon/StatusView.ts b/packages/ditto/views/mastodon/StatusView.ts new file mode 100644 index 00000000..903dbf64 --- /dev/null +++ b/packages/ditto/views/mastodon/StatusView.ts @@ -0,0 +1,200 @@ +import { DittoConf } from '@ditto/conf'; +import { NostrEvent, NostrSigner, NStore } from '@nostrify/nostrify'; +import { nip19 } from 'nostr-tools'; + +import { MastodonAttachment } from '@/entities/MastodonAttachment.ts'; +import { MastodonMention } from '@/entities/MastodonMention.ts'; +import { MastodonStatus } from '@/entities/MastodonStatus.ts'; +import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; +import { nostrDate } from '@/utils.ts'; +import { getMediaLinks, parseNoteContent, stripimeta } from '@/utils/note.ts'; +import { findReplyTag } from '@/utils/tags.ts'; +import { unfurlCardCached } from '@/utils/unfurl.ts'; +import { AccountView } from '@/views/mastodon/AccountView.ts'; +import { renderAttachment } from '@/views/mastodon/attachments.ts'; +import { renderEmojis } from '@/views/mastodon/emojis.ts'; + +interface RenderStatusOpts { + depth?: number; +} + +interface StatusViewOpts { + conf: DittoConf; + store: NStore; + user?: { + signer: NostrSigner; + }; +} + +export class StatusView { + private accountView: AccountView; + + constructor(private opts: StatusViewOpts) { + this.accountView = new AccountView(opts); + } + + async render(event: DittoEvent, opts?: RenderStatusOpts): Promise { + if (event.kind === 6) { + return await this.renderReblog(event, opts); + } + return await this.renderStatus(event, opts); + } + + async renderStatus(event: DittoEvent, opts?: RenderStatusOpts): Promise { + const { conf, store, user } = this.opts; + const { depth = 1 } = opts ?? {}; + + if (depth > 2 || depth < 0) return; + + const nevent = nip19.neventEncode({ + id: event.id, + author: event.pubkey, + kind: event.kind, + relays: [conf.relay], + }); + + const account = this.accountView.render(event.author, event.pubkey); + + const viewerPubkey = await user?.signer.getPublicKey(); + + const replyId = findReplyTag(event.tags)?.[1]; + + const mentions = event.mentions?.map((event) => this.renderMention(event)) ?? []; + + const { html, links, firstUrl } = parseNoteContent(conf, stripimeta(event.content, event.tags), mentions); + + const [card, relatedEvents] = await Promise + .all([ + firstUrl ? unfurlCardCached(conf, firstUrl, AbortSignal.timeout(500)) : null, + viewerPubkey + ? await store.query([ + { kinds: [6], '#e': [event.id], authors: [viewerPubkey], limit: 1 }, + { kinds: [7], '#e': [event.id], authors: [viewerPubkey], limit: 1 }, + { kinds: [9734], '#e': [event.id], authors: [viewerPubkey], limit: 1 }, + { kinds: [10001], '#e': [event.id], authors: [viewerPubkey], limit: 1 }, + { kinds: [10003], '#e': [event.id], authors: [viewerPubkey], limit: 1 }, + ]) + : [], + ]); + + const reactionEvent = relatedEvents.find((event) => event.kind === 7); + const repostEvent = relatedEvents.find((event) => event.kind === 6); + const pinEvent = relatedEvents.find((event) => event.kind === 10001); + const bookmarkEvent = relatedEvents.find((event) => event.kind === 10003); + const zapEvent = relatedEvents.find((event) => event.kind === 9734); + + const compatMentions = this.buildInlineRecipients(mentions.filter((m) => { + if (m.id === account.id) return false; + if (html.includes(m.url)) return false; + return true; + })); + + const cw = event.tags.find(([name]) => name === 'content-warning'); + const subject = event.tags.find(([name]) => name === 'subject'); + + const imeta: string[][][] = event.tags + .filter(([name]) => name === 'imeta') + .map(([_, ...entries]) => + entries.map((entry) => { + const split = entry.split(' '); + return [split[0], split.splice(1).join(' ')]; + }) + ); + + const media = imeta.length ? imeta : getMediaLinks(links); + + /** Pleroma emoji reactions object. */ + const reactions = Object.entries(event.event_stats?.reactions ?? {}).reduce((acc, [emoji, count]) => { + if (['+', '-'].includes(emoji)) return acc; + acc.push({ name: emoji, count, me: reactionEvent?.content === emoji }); + return acc; + }, [] as { name: string; count: number; me: boolean }[]); + + const expiresAt = new Date(Number(event.tags.find(([name]) => name === 'expiration')?.[1]) * 1000); + + return { + id: event.id, + account, + card, + content: compatMentions + html, + created_at: nostrDate(event.created_at).toISOString(), + in_reply_to_id: replyId ?? null, + in_reply_to_account_id: null, + sensitive: !!cw, + spoiler_text: (cw ? cw[1] : subject?.[1]) || '', + visibility: 'public', + language: event.language ?? null, + replies_count: event.event_stats?.replies_count ?? 0, + reblogs_count: event.event_stats?.reposts_count ?? 0, + favourites_count: event.event_stats?.reactions['+'] ?? 0, + zaps_amount: event.event_stats?.zaps_amount ?? 0, + favourited: reactionEvent?.content === '+', + reblogged: Boolean(repostEvent), + muted: false, + bookmarked: Boolean(bookmarkEvent), + pinned: Boolean(pinEvent), + reblog: null, + application: null, + media_attachments: media + .map((m) => renderAttachment({ tags: m })) + .filter((m): m is MastodonAttachment => Boolean(m)), + mentions, + tags: [], + emojis: renderEmojis(event), + poll: null, + quote: !event.quote ? null : await this.renderStatus(event.quote, { depth: depth + 1 }), + quote_id: event.quote?.id ?? null, + uri: conf.local(`/users/${account.acct}/statuses/${event.id}`), + url: conf.local(`/@${account.acct}/${event.id}`), + zapped: Boolean(zapEvent), + ditto: { + external_url: conf.external(nevent), + }, + pleroma: { + emoji_reactions: reactions, + expires_at: !isNaN(expiresAt.getTime()) ? expiresAt.toISOString() : undefined, + quotes_count: event.event_stats?.quotes_count ?? 0, + }, + }; + } + + async renderReblog(event: DittoEvent, opts?: RenderStatusOpts): Promise { + if (!event.repost) return; + + const status = await this.renderStatus(event, opts); + if (!status) return; + + const reblog = await this.renderStatus(event.repost, opts) ?? null; + + return { + ...status, + in_reply_to_id: null, + in_reply_to_account_id: null, + reblog, + }; + } + + renderMention(event: NostrEvent): MastodonMention { + const account = this.accountView.render(event, event.pubkey); + return { + id: account.id, + acct: account.acct, + username: account.username, + url: account.url, + }; + } + + buildInlineRecipients(mentions: MastodonMention[]): string { + if (!mentions.length) return ''; + + const elements = mentions.reduce((acc, { url, username }) => { + const name = nip19.BECH32_REGEX.test(username) ? username.substring(0, 8) : username; + acc.push( + `@${name}`, + ); + return acc; + }, []); + + return `${elements.join(' ')} `; + } +} diff --git a/packages/ditto/views/mastodon/accounts.ts b/packages/ditto/views/mastodon/accounts.ts deleted file mode 100644 index d541e633..00000000 --- a/packages/ditto/views/mastodon/accounts.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { NSchema as n } from '@nostrify/nostrify'; -import { nip19, UnsignedEvent } from 'nostr-tools'; - -import { Conf } from '@/config.ts'; -import { MastodonAccount } from '@/entities/MastodonAccount.ts'; -import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; -import { metadataSchema } from '@/schemas/nostr.ts'; -import { getLnurl } from '@/utils/lnurl.ts'; -import { parseNoteContent } from '@/utils/note.ts'; -import { getTagSet } from '@/utils/tags.ts'; -import { nostrDate, nostrNow, parseNip05 } from '@/utils.ts'; -import { renderEmojis } from '@/views/mastodon/emojis.ts'; - -type ToAccountOpts = { - withSource: true; - settingsStore: Record | undefined; -} | { - withSource?: false; -}; - -function renderAccount(event: Omit, opts: ToAccountOpts = {}): MastodonAccount { - const { pubkey } = event; - - const stats = event.author_stats; - const names = getTagSet(event.user?.tags ?? [], 'n'); - - if (names.has('disabled')) { - const account = accountFromPubkey(pubkey, opts); - account.pleroma.deactivated = true; - return account; - } - - const { - name, - nip05, - picture = Conf.local('/images/avi.png'), - banner = Conf.local('/images/banner.png'), - about, - lud06, - lud16, - website, - fields: _fields, - } = n.json().pipe(metadataSchema).catch({}).parse(event.content); - - const npub = nip19.npubEncode(pubkey); - const nprofile = nip19.nprofileEncode({ pubkey, relays: [Conf.relay] }); - const parsed05 = stats?.nip05 ? parseNip05(stats.nip05) : undefined; - const acct = parsed05?.handle || npub; - - const { html } = parseNoteContent(about || '', []); - - const fields = _fields - ?.slice(0, Conf.profileFields.maxFields) - .map(([name, value]) => ({ - name: name.slice(0, Conf.profileFields.nameLength), - value: value.slice(0, Conf.profileFields.valueLength), - verified_at: null, - })) ?? []; - - let streakDays = 0; - let streakStart = stats?.streak_start ?? null; - let streakEnd = stats?.streak_end ?? null; - const { streakWindow } = Conf; - - if (streakStart && streakEnd) { - const broken = nostrNow() - streakEnd > streakWindow; - if (broken) { - streakStart = null; - streakEnd = null; - } else { - const delta = streakEnd - streakStart; - streakDays = Math.max(Math.ceil(delta / 86400), 1); - } - } - - return { - id: pubkey, - acct, - avatar: picture, - avatar_static: picture, - bot: false, - created_at: nostrDate(event.user?.created_at ?? event.created_at).toISOString(), - discoverable: true, - display_name: name ?? '', - emojis: renderEmojis(event), - fields: fields.map((field) => ({ ...field, value: parseNoteContent(field.value, []).html })), - follow_requests_count: 0, - followers_count: stats?.followers_count ?? 0, - following_count: stats?.following_count ?? 0, - fqn: parsed05?.handle || npub, - header: banner, - header_static: banner, - last_status_at: null, - locked: false, - note: html, - roles: [], - source: opts.withSource - ? { - fields, - language: '', - note: about || '', - privacy: 'public', - sensitive: false, - follow_requests_count: 0, - nostr: { - nip05, - }, - ditto: { - captcha_solved: names.has('captcha_solved'), - }, - } - : undefined, - statuses_count: stats?.notes_count ?? 0, - uri: Conf.local(`/users/${acct}`), - url: Conf.local(`/@${acct}`), - username: parsed05?.nickname || npub.substring(0, 8), - ditto: { - accepts_zaps: Boolean(getLnurl({ lud06, lud16 })), - external_url: Conf.external(nprofile), - streak: { - days: streakDays, - start: streakStart ? nostrDate(streakStart).toISOString() : null, - end: streakEnd ? nostrDate(streakEnd).toISOString() : null, - expires: streakEnd ? nostrDate(streakEnd + streakWindow).toISOString() : null, - }, - }, - domain: parsed05?.domain, - pleroma: { - deactivated: names.has('disabled'), - is_admin: names.has('admin'), - is_moderator: names.has('admin') || names.has('moderator'), - is_suggested: names.has('suggested'), - is_local: parsed05?.domain === Conf.url.host, - settings_store: opts.withSource ? opts.settingsStore : undefined, - tags: [...getTagSet(event.user?.tags ?? [], 't')], - favicon: stats?.favicon, - }, - nostr: { - pubkey, - lud16, - }, - website: website && /^https?:\/\//.test(website) ? website : undefined, - }; -} - -function accountFromPubkey(pubkey: string, opts: ToAccountOpts = {}): MastodonAccount { - const event: UnsignedEvent = { - kind: 0, - pubkey, - content: '', - tags: [], - created_at: nostrNow(), - }; - - return renderAccount(event, opts); -} - -export { accountFromPubkey, renderAccount }; diff --git a/packages/ditto/views/mastodon/admin-accounts.ts b/packages/ditto/views/mastodon/admin-accounts.ts deleted file mode 100644 index 34b6860a..00000000 --- a/packages/ditto/views/mastodon/admin-accounts.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; -import { DittoEvent } from '@/interfaces/DittoEvent.ts'; -import { getTagSet } from '@/utils/tags.ts'; - -/** Expects a kind 0 fully hydrated */ -async function renderAdminAccount(event: DittoEvent) { - const account = await renderAccount(event); - const names = getTagSet(event.user?.tags ?? [], 'n'); - - let role = 'user'; - - if (names.has('admin')) { - role = 'admin'; - } - if (names.has('moderator')) { - role = 'moderator'; - } - - return { - id: account.id, - username: account.username, - domain: account.acct.split('@')[1] || null, - created_at: account.created_at, - email: '', - ip: null, - ips: [], - locale: '', - invite_request: null, - role, - confirmed: true, - approved: true, - disabled: names.has('disabled'), - silenced: names.has('silenced'), - suspended: names.has('suspended'), - sensitized: names.has('sensitized'), - account, - }; -} - -/** Expects a target pubkey */ -async function renderAdminAccountFromPubkey(pubkey: string) { - const account = await accountFromPubkey(pubkey); - - return { - id: account.id, - username: account.username, - domain: account.acct.split('@')[1] || null, - created_at: account.created_at, - email: '', - ip: null, - ips: [], - locale: '', - invite_request: null, - role: 'user', - confirmed: true, - approved: true, - disabled: false, - silenced: false, - suspended: false, - account, - }; -} - -export { renderAdminAccount, renderAdminAccountFromPubkey }; diff --git a/packages/ditto/views/mastodon/notifications.ts b/packages/ditto/views/mastodon/notifications.ts deleted file mode 100644 index 4cf6eb5c..00000000 --- a/packages/ditto/views/mastodon/notifications.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { NostrEvent } from '@nostrify/nostrify'; - -import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; -import { Conf } from '@/config.ts'; -import { DittoEvent } from '@/interfaces/DittoEvent.ts'; -import { nostrDate } from '@/utils.ts'; -import { renderStatus } from '@/views/mastodon/statuses.ts'; - -interface RenderNotificationOpts { - viewerPubkey: string; -} - -function renderNotification(event: DittoEvent, opts: RenderNotificationOpts) { - const mentioned = !!event.tags.find(([name, value]) => name === 'p' && value === opts.viewerPubkey); - - if (event.kind === 1 && mentioned) { - return renderMention(event, opts); - } - - if (event.kind === 6) { - return renderReblog(event, opts); - } - - if (event.kind === 7 && event.content === '+') { - return renderFavourite(event, opts); - } - - if (event.kind === 7) { - return renderReaction(event, opts); - } - - if (event.kind === 30360 && event.pubkey === Conf.pubkey) { - return renderNameGrant(event); - } - - if (event.kind === 9735) { - return renderZap(event, opts); - } -} - -async function renderMention(event: DittoEvent, opts: RenderNotificationOpts) { - const status = await renderStatus(event, opts); - if (!status) return; - - return { - id: notificationId(event), - type: 'mention' as const, - created_at: nostrDate(event.created_at).toISOString(), - account: status.account, - status: status, - }; -} - -async function renderReblog(event: DittoEvent, opts: RenderNotificationOpts) { - if (event.repost?.kind !== 1) return; - const status = await renderStatus(event.repost, opts); - if (!status) return; - const account = event.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey); - - return { - id: notificationId(event), - type: 'reblog' as const, - created_at: nostrDate(event.created_at).toISOString(), - account, - status, - }; -} - -async function renderFavourite(event: DittoEvent, opts: RenderNotificationOpts) { - if (event.reacted?.kind !== 1) return; - const status = await renderStatus(event.reacted, opts); - if (!status) return; - const account = event.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey); - - return { - id: notificationId(event), - type: 'favourite' as const, - created_at: nostrDate(event.created_at).toISOString(), - account, - status, - }; -} - -async function renderReaction(event: DittoEvent, opts: RenderNotificationOpts) { - if (event.reacted?.kind !== 1) return; - const status = await renderStatus(event.reacted, opts); - if (!status) return; - const account = event.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey); - - return { - id: notificationId(event), - type: 'pleroma:emoji_reaction' as const, - emoji: event.content, - emoji_url: event.tags.find(([name, value]) => name === 'emoji' && `:${value}:` === event.content)?.[2], - created_at: nostrDate(event.created_at).toISOString(), - account, - status, - }; -} - -async function renderNameGrant(event: DittoEvent) { - const d = event.tags.find(([name]) => name === 'd')?.[1]; - const account = event.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey); - - if (!d) return; - - return { - id: notificationId(event), - type: 'ditto:name_grant' as const, - name: d, - created_at: nostrDate(event.created_at).toISOString(), - account, - }; -} - -async function renderZap(event: DittoEvent, opts: RenderNotificationOpts) { - if (!event.zap_sender) return; - - const { zap_amount = 0, zap_message = '' } = event; - if (zap_amount < 1) return; - - const account = typeof event.zap_sender !== 'string' - ? await renderAccount(event.zap_sender) - : await accountFromPubkey(event.zap_sender); - - return { - id: notificationId(event), - type: 'ditto:zap' as const, - amount: zap_amount, - message: zap_message, - created_at: nostrDate(event.created_at).toISOString(), - account, - ...(event.zapped ? { status: await renderStatus(event.zapped, opts) } : {}), - }; -} - -/** This helps notifications be sorted in the correct order. */ -function notificationId({ id, created_at }: NostrEvent): string { - return `${created_at}-${id}`; -} - -export { renderNotification }; diff --git a/packages/ditto/views/mastodon/reports.ts b/packages/ditto/views/mastodon/reports.ts index 48baa42f..d494b231 100644 --- a/packages/ditto/views/mastodon/reports.ts +++ b/packages/ditto/views/mastodon/reports.ts @@ -1,3 +1,4 @@ +import { AppContext } from '@/app.ts'; import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { nostrDate } from '@/utils.ts'; @@ -36,7 +37,7 @@ interface RenderAdminReportOpts { /** Admin-level information about a filed report. * Expects an event of kind 1984 fully hydrated. * https://docs.joinmastodon.org/entities/Admin_Report */ -async function renderAdminReport(event: DittoEvent, opts: RenderAdminReportOpts) { +async function renderAdminReport(c: AppContext, event: DittoEvent, opts: RenderAdminReportOpts) { const { viewerPubkey } = opts; // The category is present in both the 'e' and 'p' tag, however, it is possible to report a user without reporting a note, so it's better to get the category from the 'p' tag @@ -45,7 +46,7 @@ async function renderAdminReport(event: DittoEvent, opts: RenderAdminReportOpts) const statuses = []; if (event.reported_notes) { for (const status of event.reported_notes) { - statuses.push(await renderStatus(status, { viewerPubkey })); + statuses.push(await renderStatus(c, status, { viewerPubkey })); } } diff --git a/packages/ditto/views/mastodon/statuses.ts b/packages/ditto/views/mastodon/statuses.ts deleted file mode 100644 index 00f7dd55..00000000 --- a/packages/ditto/views/mastodon/statuses.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { NostrEvent } from '@nostrify/nostrify'; -import { nip19 } from 'nostr-tools'; - -import { Conf } from '@/config.ts'; -import { MastodonAttachment } from '@/entities/MastodonAttachment.ts'; -import { MastodonMention } from '@/entities/MastodonMention.ts'; -import { MastodonStatus } from '@/entities/MastodonStatus.ts'; -import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; -import { Storages } from '@/storages.ts'; -import { nostrDate } from '@/utils.ts'; -import { getMediaLinks, parseNoteContent, stripimeta } from '@/utils/note.ts'; -import { findReplyTag } from '@/utils/tags.ts'; -import { unfurlCardCached } from '@/utils/unfurl.ts'; -import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; -import { renderAttachment } from '@/views/mastodon/attachments.ts'; -import { renderEmojis } from '@/views/mastodon/emojis.ts'; - -interface RenderStatusOpts { - viewerPubkey?: string; - depth?: number; -} - -async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise { - const { viewerPubkey, depth = 1 } = opts; - - if (depth > 2 || depth < 0) return; - - const nevent = nip19.neventEncode({ - id: event.id, - author: event.pubkey, - kind: event.kind, - relays: [Conf.relay], - }); - - const account = event.author - ? renderAccount({ ...event.author, author_stats: event.author_stats }) - : accountFromPubkey(event.pubkey); - - const replyId = findReplyTag(event.tags)?.[1]; - - const store = await Storages.db(); - - const mentions = event.mentions?.map((event) => renderMention(event)) ?? []; - - const { html, links, firstUrl } = parseNoteContent(stripimeta(event.content, event.tags), mentions); - - const [card, relatedEvents] = await Promise - .all([ - firstUrl ? unfurlCardCached(firstUrl, AbortSignal.timeout(500)) : null, - viewerPubkey - ? await store.query([ - { kinds: [6], '#e': [event.id], authors: [viewerPubkey], limit: 1 }, - { kinds: [7], '#e': [event.id], authors: [viewerPubkey], limit: 1 }, - { kinds: [9734], '#e': [event.id], authors: [viewerPubkey], limit: 1 }, - { kinds: [10001], '#e': [event.id], authors: [viewerPubkey], limit: 1 }, - { kinds: [10003], '#e': [event.id], authors: [viewerPubkey], limit: 1 }, - ]) - : [], - ]); - - const reactionEvent = relatedEvents.find((event) => event.kind === 7); - const repostEvent = relatedEvents.find((event) => event.kind === 6); - const pinEvent = relatedEvents.find((event) => event.kind === 10001); - const bookmarkEvent = relatedEvents.find((event) => event.kind === 10003); - const zapEvent = relatedEvents.find((event) => event.kind === 9734); - - const compatMentions = buildInlineRecipients(mentions.filter((m) => { - if (m.id === account.id) return false; - if (html.includes(m.url)) return false; - return true; - })); - - const cw = event.tags.find(([name]) => name === 'content-warning'); - const subject = event.tags.find(([name]) => name === 'subject'); - - const imeta: string[][][] = event.tags - .filter(([name]) => name === 'imeta') - .map(([_, ...entries]) => - entries.map((entry) => { - const split = entry.split(' '); - return [split[0], split.splice(1).join(' ')]; - }) - ); - - const media = imeta.length ? imeta : getMediaLinks(links); - - /** Pleroma emoji reactions object. */ - const reactions = Object.entries(event.event_stats?.reactions ?? {}).reduce((acc, [emoji, count]) => { - if (['+', '-'].includes(emoji)) return acc; - acc.push({ name: emoji, count, me: reactionEvent?.content === emoji }); - return acc; - }, [] as { name: string; count: number; me: boolean }[]); - - const expiresAt = new Date(Number(event.tags.find(([name]) => name === 'expiration')?.[1]) * 1000); - - return { - id: event.id, - account, - card, - content: compatMentions + html, - created_at: nostrDate(event.created_at).toISOString(), - in_reply_to_id: replyId ?? null, - in_reply_to_account_id: null, - sensitive: !!cw, - spoiler_text: (cw ? cw[1] : subject?.[1]) || '', - visibility: 'public', - language: event.language ?? null, - replies_count: event.event_stats?.replies_count ?? 0, - reblogs_count: event.event_stats?.reposts_count ?? 0, - favourites_count: event.event_stats?.reactions['+'] ?? 0, - zaps_amount: event.event_stats?.zaps_amount ?? 0, - favourited: reactionEvent?.content === '+', - reblogged: Boolean(repostEvent), - muted: false, - bookmarked: Boolean(bookmarkEvent), - pinned: Boolean(pinEvent), - reblog: null, - application: null, - media_attachments: media - .map((m) => renderAttachment({ tags: m })) - .filter((m): m is MastodonAttachment => Boolean(m)), - mentions, - tags: [], - emojis: renderEmojis(event), - poll: null, - quote: !event.quote ? null : await renderStatus(event.quote, { depth: depth + 1 }), - quote_id: event.quote?.id ?? null, - uri: Conf.local(`/users/${account.acct}/statuses/${event.id}`), - url: Conf.local(`/@${account.acct}/${event.id}`), - zapped: Boolean(zapEvent), - ditto: { - external_url: Conf.external(nevent), - }, - pleroma: { - emoji_reactions: reactions, - expires_at: !isNaN(expiresAt.getTime()) ? expiresAt.toISOString() : undefined, - quotes_count: event.event_stats?.quotes_count ?? 0, - }, - }; -} - -async function renderReblog(event: DittoEvent, opts: RenderStatusOpts): Promise { - const { viewerPubkey } = opts; - if (!event.repost) return; - - const status = await renderStatus(event, {}); // omit viewerPubkey intentionally - if (!status) return; - - const reblog = await renderStatus(event.repost, { viewerPubkey }) ?? null; - - return { - ...status, - in_reply_to_id: null, - in_reply_to_account_id: null, - reblog, - }; -} - -function renderMention(event: NostrEvent): MastodonMention { - const account = renderAccount(event); - return { - id: account.id, - acct: account.acct, - username: account.username, - url: account.url, - }; -} - -function buildInlineRecipients(mentions: MastodonMention[]): string { - if (!mentions.length) return ''; - - const elements = mentions.reduce((acc, { url, username }) => { - const name = nip19.BECH32_REGEX.test(username) ? username.substring(0, 8) : username; - acc.push(`@${name}`); - return acc; - }, []); - - return `${elements.join(' ')} `; -} - -export { renderReblog, renderStatus }; diff --git a/packages/ditto/views/meta.ts b/packages/ditto/views/meta.ts index 3205237b..a489877a 100644 --- a/packages/ditto/views/meta.ts +++ b/packages/ditto/views/meta.ts @@ -1,6 +1,6 @@ +import { DittoConf } from '@ditto/conf'; import DOMPurify from 'isomorphic-dompurify'; -import { Conf } from '@/config.ts'; import { html } from '@/utils/html.ts'; import { MetadataEntities } from '@/utils/og-metadata.ts'; @@ -9,13 +9,13 @@ import { MetadataEntities } from '@/utils/og-metadata.ts'; * @param opts the metadata to use to fill the template. * @returns the built OpenGraph metadata. */ -export function renderMetadata(url: string, { account, status, instance }: MetadataEntities): string { +export function renderMetadata(conf: DittoConf, req: Request, { account, status, instance }: MetadataEntities): string { const tags: string[] = []; const title = account ? `${account.display_name} (@${account.acct})` : instance.name; const attachment = status?.media_attachments?.find((a) => a.type === 'image'); const description = DOMPurify.sanitize(status?.content || account?.note || instance.tagline, { ALLOWED_TAGS: [] }); - const image = attachment?.preview_url || account?.avatar_static || instance.picture || Conf.local('/favicon.ico'); + const image = attachment?.preview_url || account?.avatar_static || instance.picture || conf.local('/favicon.ico'); const siteName = instance?.name; const width = attachment?.meta?.original?.width; const height = attachment?.meta?.original?.height; @@ -48,7 +48,7 @@ export function renderMetadata(url: string, { account, status, instance }: Metad // Extra tags (always present if other tags exist). if (tags.length > 0) { - tags.push(html``); + tags.push(html``); tags.push(''); tags.push(''); } diff --git a/packages/ditto/workers/policy.ts b/packages/ditto/workers/policy.ts index 7b3d23b0..d2aa3740 100644 --- a/packages/ditto/workers/policy.ts +++ b/packages/ditto/workers/policy.ts @@ -1,16 +1,16 @@ +import { DittoConf } from '@ditto/conf'; import { NostrEvent, NostrRelayOK, NPolicy } from '@nostrify/nostrify'; import { logi } from '@soapbox/logi'; import * as Comlink from 'comlink'; -import { Conf } from '@/config.ts'; import type { CustomPolicy } from '@/workers/policy.worker.ts'; -class PolicyWorker implements NPolicy { +export class PolicyWorker implements NPolicy { private worker: Comlink.Remote; private ready: Promise; private enabled = true; - constructor() { + constructor(private conf: DittoConf) { this.worker = Comlink.wrap( new Worker( new URL('./policy.worker.ts', import.meta.url), @@ -19,8 +19,8 @@ class PolicyWorker implements NPolicy { name: 'PolicyWorker', deno: { permissions: { - read: [Conf.denoDir, Conf.policy, Conf.dataDir], - write: [Conf.dataDir], + read: [conf.denoDir, conf.policy, conf.dataDir], + write: [conf.dataDir], net: 'inherit', env: false, import: true, @@ -46,16 +46,16 @@ class PolicyWorker implements NPolicy { private async init(): Promise { try { await this.worker.init({ - path: Conf.policy, - databaseUrl: Conf.databaseUrl, - pubkey: Conf.pubkey, + path: this.conf.policy, + databaseUrl: this.conf.databaseUrl, + pubkey: this.conf.pubkey, }); logi({ level: 'info', ns: 'ditto.system.policy', msg: 'Using custom policy', - path: Conf.policy, + path: this.conf.policy, enabled: true, }); } catch (e) { @@ -76,16 +76,14 @@ class PolicyWorker implements NPolicy { level: 'warn', ns: 'ditto.system.policy', msg: 'Custom policies are not supported with PGlite. The policy is disabled.', - path: Conf.policy, + path: this.conf.policy, enabled: false, }); this.enabled = false; return; } - throw new Error(`DITTO_POLICY (error importing policy): ${Conf.policy}`); + throw new Error(`DITTO_POLICY (error importing policy): ${this.conf.policy}`); } } } - -export const policyWorker = new PolicyWorker(); diff --git a/packages/ditto/workers/verify.worker.ts b/packages/ditto/workers/verify.worker.ts index 3e71215d..13371918 100644 --- a/packages/ditto/workers/verify.worker.ts +++ b/packages/ditto/workers/verify.worker.ts @@ -1,9 +1,14 @@ +import { DittoConf } from '@ditto/conf'; import { NostrEvent } from '@nostrify/nostrify'; import * as Comlink from 'comlink'; import { VerifiedEvent, verifyEvent } from 'nostr-tools'; import '@/nostr-wasm.ts'; -import '@/sentry.ts'; +import { startSentry } from '@/sentry.ts'; + +const conf = new DittoConf(Deno.env); + +startSentry(conf); export const VerifyWorker = { verifyEvent(event: NostrEvent): event is VerifiedEvent { diff --git a/scripts/admin-event.ts b/scripts/admin-event.ts index 70f8ed48..6d483a8c 100644 --- a/scripts/admin-event.ts +++ b/scripts/admin-event.ts @@ -1,13 +1,16 @@ +import { DittoConf } from '@ditto/conf'; import { JsonParseStream } from '@std/json/json-parse-stream'; import { TextLineStream } from '@std/streams/text-line-stream'; import { AdminSigner } from '../packages/ditto/signers/AdminSigner.ts'; -import { Storages } from '../packages/ditto/storages.ts'; +import { DittoStorages } from '../packages/ditto/DittoStorages.ts'; import { type EventStub } from '../packages/ditto/utils/api.ts'; import { nostrNow } from '../packages/ditto/utils.ts'; -const signer = new AdminSigner(); -const store = await Storages.db(); +const conf = new DittoConf(Deno.env); +const signer = new AdminSigner(conf); +const storages = new DittoStorages(conf); +const store = await storages.db(); const readable = Deno.stdin.readable .pipeThrough(new TextDecoderStream()) diff --git a/scripts/admin-role.ts b/scripts/admin-role.ts index 369440c9..60b59b90 100644 --- a/scripts/admin-role.ts +++ b/scripts/admin-role.ts @@ -1,11 +1,14 @@ +import { DittoConf } from '@ditto/conf'; import { NSchema } from '@nostrify/nostrify'; import { nip19 } from 'nostr-tools'; import { AdminSigner } from '../packages/ditto/signers/AdminSigner.ts'; -import { Storages } from '../packages/ditto/storages.ts'; +import { DittoStorages } from '../packages/ditto/DittoStorages.ts'; import { nostrNow } from '../packages/ditto/utils.ts'; -const store = await Storages.db(); +const conf = new DittoConf(Deno.env); +const storages = new DittoStorages(conf); +const store = await storages.db(); const [pubkeyOrNpub, role] = Deno.args; const pubkey = pubkeyOrNpub.startsWith('npub1') ? nip19.decode(pubkeyOrNpub as `npub1${string}`).data : pubkeyOrNpub; @@ -20,7 +23,7 @@ if (!['admin', 'user'].includes(role)) { Deno.exit(1); } -const signer = new AdminSigner(); +const signer = new AdminSigner(conf); const admin = await signer.getPublicKey(); const [existing] = await store.query([{ diff --git a/scripts/db-export.ts b/scripts/db-export.ts index d36d4f3f..35bf44a4 100644 --- a/scripts/db-export.ts +++ b/scripts/db-export.ts @@ -1,7 +1,11 @@ +import { DittoConf } from '@ditto/conf'; import { NostrFilter } from '@nostrify/nostrify'; import { Command, InvalidOptionArgumentError } from 'commander'; -import { Storages } from '../packages/ditto/storages.ts'; +import { DittoStorages } from '../packages/ditto/DittoStorages.ts'; + +const conf = new DittoConf(Deno.env); +const storages = new DittoStorages(conf); interface ExportFilter { authors?: string[]; @@ -98,7 +102,7 @@ export function buildFilter(args: ExportFilter) { } async function exportEvents(args: ExportFilter) { - const store = await Storages.db(); + const store = await storages.db(); let filter: NostrFilter = {}; try { diff --git a/scripts/db-import.ts b/scripts/db-import.ts index 2f6c1595..67d6b6fe 100644 --- a/scripts/db-import.ts +++ b/scripts/db-import.ts @@ -1,13 +1,15 @@ import { Semaphore } from '@core/asyncutil'; +import { DittoConf } from '@ditto/conf'; import { NostrEvent } from '@nostrify/nostrify'; import { JsonParseStream } from '@std/json/json-parse-stream'; import { TextLineStream } from '@std/streams/text-line-stream'; -import { Conf } from '../packages/ditto/config.ts'; -import { Storages } from '../packages/ditto/storages.ts'; +import { DittoStorages } from '../packages/ditto/DittoStorages.ts'; -const store = await Storages.db(); -const sem = new Semaphore(Conf.pg.poolSize); +const conf = new DittoConf(Deno.env); +const storages = new DittoStorages(conf); +const store = await storages.db(); +const sem = new Semaphore(conf.pg.poolSize); console.warn('Importing events...'); diff --git a/scripts/db-migrate.ts b/scripts/db-migrate.ts index 21b8db22..fcac7e61 100644 --- a/scripts/db-migrate.ts +++ b/scripts/db-migrate.ts @@ -1,7 +1,12 @@ -import { Storages } from '../packages/ditto/storages.ts'; +import { DittoConf } from '@ditto/conf'; + +import { DittoStorages } from '../packages/ditto/DittoStorages.ts'; + +const conf = new DittoConf(Deno.env); +const storages = new DittoStorages(conf); // This migrates kysely internally. -const kysely = await Storages.kysely(); +const kysely = await storages.kysely(); // Close the connection before exiting. await kysely.destroy(); diff --git a/scripts/db-policy.ts b/scripts/db-policy.ts index caab55af..523b8902 100644 --- a/scripts/db-policy.ts +++ b/scripts/db-policy.ts @@ -1,14 +1,20 @@ -import { policyWorker } from '../packages/ditto/workers/policy.ts'; -import { Storages } from '../packages/ditto/storages.ts'; +import { DittoConf } from '@ditto/conf'; -const db = await Storages.db(); +import { DittoStorages } from '../packages/ditto/DittoStorages.ts'; +import { PolicyWorker } from '../packages/ditto/workers/policy.ts'; + +const conf = new DittoConf(Deno.env); +const storages = new DittoStorages(conf); +const policy = new PolicyWorker(conf); + +const db = await storages.db(); let count = 0; for await (const msg of db.req([{}])) { const [type, , event] = msg; if (type === 'EOSE') console.log('EOSE'); if (type !== 'EVENT') continue; - const [, , ok] = await policyWorker.call(event, AbortSignal.timeout(5000)); + const [, , ok] = await policy.call(event, AbortSignal.timeout(5000)); if (!ok) { await db.remove([{ ids: [event.id] }]); count += 1; diff --git a/scripts/db-populate-extensions.ts b/scripts/db-populate-extensions.ts index 2b40bd3d..c0bfe6bb 100644 --- a/scripts/db-populate-extensions.ts +++ b/scripts/db-populate-extensions.ts @@ -1,9 +1,12 @@ +import { DittoConf } from '@ditto/conf'; import { NostrEvent } from '@nostrify/nostrify'; -import { Storages } from '../packages/ditto/storages.ts'; +import { DittoStorages } from '../packages/ditto/DittoStorages.ts'; import { EventsDB } from '../packages/ditto/storages/EventsDB.ts'; -const kysely = await Storages.kysely(); +const conf = new DittoConf(Deno.env); +const storages = new DittoStorages(conf); +const kysely = await storages.kysely(); const query = kysely .selectFrom('nostr_events') diff --git a/scripts/db-populate-nip05.ts b/scripts/db-populate-nip05.ts index acfe70da..95f465d8 100644 --- a/scripts/db-populate-nip05.ts +++ b/scripts/db-populate-nip05.ts @@ -1,10 +1,21 @@ import { Semaphore } from '@core/asyncutil'; +import { DittoConf } from '@ditto/conf'; import { NostrEvent } from '@nostrify/nostrify'; -import { updateAuthorData } from '../packages/ditto/pipeline.ts'; -import { Storages } from '../packages/ditto/storages.ts'; +import { DittoPipeline } from '../packages/ditto/DittoPipeline.ts'; +import { DittoStorages } from '../packages/ditto/DittoStorages.ts'; -const kysely = await Storages.kysely(); +const conf = new DittoConf(Deno.env); +const storages = new DittoStorages(conf); + +const pipeline = new DittoPipeline({ + conf, + kysely: await storages.kysely(), + store: await storages.db(), + pubsub: await storages.pubsub(), +}); + +const kysely = await storages.kysely(); const sem = new Semaphore(5); const query = kysely @@ -19,7 +30,7 @@ for await (const row of query.stream(100)) { sem.lock(async () => { const event: NostrEvent = { ...row, created_at: Number(row.created_at) }; - await updateAuthorData(event, AbortSignal.timeout(3000)); + await pipeline.updateAuthorData(event, AbortSignal.timeout(3000)); }); } diff --git a/scripts/db-populate-search.ts b/scripts/db-populate-search.ts index e73f79ac..89d2979b 100644 --- a/scripts/db-populate-search.ts +++ b/scripts/db-populate-search.ts @@ -1,9 +1,12 @@ +import { DittoConf } from '@ditto/conf'; import { NSchema as n } from '@nostrify/nostrify'; -import { Storages } from '../packages/ditto/storages.ts'; +import { DittoStorages } from '../packages/ditto/DittoStorages.ts'; -const store = await Storages.db(); -const kysely = await Storages.kysely(); +const conf = new DittoConf(Deno.env); +const storages = new DittoStorages(conf); +const store = await storages.db(); +const kysely = await storages.kysely(); for await (const msg of store.req([{ kinds: [0] }])) { if (msg[0] === 'EVENT') { diff --git a/scripts/db-streak-recompute.ts b/scripts/db-streak-recompute.ts index e45d4f64..5e23659c 100644 --- a/scripts/db-streak-recompute.ts +++ b/scripts/db-streak-recompute.ts @@ -1,9 +1,13 @@ -import { Conf } from '../packages/ditto/config.ts'; -import { Storages } from '../packages/ditto/storages.ts'; +import { DittoConf } from '@ditto/conf'; -const kysely = await Storages.kysely(); +import { DittoStorages } from '../packages/ditto/DittoStorages.ts'; + +const conf = new DittoConf(Deno.env); +const storages = new DittoStorages(conf); + +const kysely = await storages.kysely(); const statsQuery = kysely.selectFrom('author_stats').select('pubkey'); -const { streakWindow } = Conf; +const { streakWindow } = conf; for await (const { pubkey } of statsQuery.stream(10)) { const eventsQuery = kysely diff --git a/scripts/nostr-pull.ts b/scripts/nostr-pull.ts index 7c21cb80..9107a55c 100644 --- a/scripts/nostr-pull.ts +++ b/scripts/nostr-pull.ts @@ -3,12 +3,15 @@ * by looking them up on a list of relays. */ +import { DittoConf } from '@ditto/conf'; import { NostrEvent, NRelay1, NSchema } from '@nostrify/nostrify'; import { nip19 } from 'nostr-tools'; -import { Storages } from '../packages/ditto/storages.ts'; +import { DittoStorages } from '../packages/ditto/DittoStorages.ts'; -const store = await Storages.db(); +const conf = new DittoConf(Deno.env); +const storages = new DittoStorages(conf); +const store = await storages.db(); interface ImportEventsOpts { profilesOnly: boolean; diff --git a/scripts/setup-kind0.ts b/scripts/setup-kind0.ts index ff7cbd1a..f942a257 100644 --- a/scripts/setup-kind0.ts +++ b/scripts/setup-kind0.ts @@ -1,10 +1,13 @@ +import { DittoConf } from '@ditto/conf'; import { Command } from 'commander'; import { NostrEvent } from 'nostr-tools'; import { AdminSigner } from '../packages/ditto/signers/AdminSigner.ts'; +import { DittoStorages } from '../packages/ditto/DittoStorages.ts'; import { nostrNow } from '../packages/ditto/utils.ts'; -import { Conf } from '../packages/ditto/config.ts'; -import { Storages } from '../packages/ditto/storages.ts'; + +const conf = new DittoConf(Deno.env); +const storages = new DittoStorages(conf); function die(code: number, ...args: unknown[]) { console.error(...args); @@ -34,9 +37,9 @@ if (import.meta.main) { content.lud16 = lightning; content.name = name; content.picture = image; - content.website = Conf.localDomain; + content.website = conf.localDomain; - const signer = new AdminSigner(); + const signer = new AdminSigner(conf); const bare: Omit = { created_at: nostrNow(), kind: 0, @@ -46,7 +49,7 @@ if (import.meta.main) { const signed = await signer.signEvent(bare); console.log({ content, signed }); - await Storages.db().then((store) => store.event(signed)); + await storages.db().then((store) => store.event(signed)); }); await kind0.parseAsync(); diff --git a/scripts/setup.ts b/scripts/setup.ts index f4ccf368..46c7c658 100644 --- a/scripts/setup.ts +++ b/scripts/setup.ts @@ -1,10 +1,11 @@ +import { DittoConf } from '@ditto/conf'; import { generateVapidKeys } from '@negrel/webpush'; import { encodeBase64 } from '@std/encoding/base64'; import { exists } from '@std/fs/exists'; import { generateSecretKey, nip19 } from 'nostr-tools'; import question from 'question-deno'; -import { Conf } from '../packages/ditto/config.ts'; +const conf = new DittoConf(Deno.env); console.log(''); console.log('Hello! Welcome to the Ditto setup tool. We will ask you a few questions to generate a .env file for you.'); @@ -39,7 +40,7 @@ if (DITTO_NSEC) { console.log(' Generated secret key\n'); } -const domain = await question('input', 'What is the domain of your instance? (eg ditto.pub)', Conf.url.host); +const domain = await question('input', 'What is the domain of your instance? (eg ditto.pub)', conf.url.host); vars.LOCAL_DOMAIN = `https://${domain}`; const DATABASE_URL = Deno.env.get('DATABASE_URL'); @@ -71,28 +72,28 @@ vars.DITTO_UPLOADER = await question('list', 'How do you want to upload files?', ]); if (vars.DITTO_UPLOADER === 'nostrbuild') { - vars.NOSTRBUILD_ENDPOINT = await question('input', 'nostr.build endpoint', Conf.nostrbuildEndpoint); + vars.NOSTRBUILD_ENDPOINT = await question('input', 'nostr.build endpoint', conf.nostrbuildEndpoint); } if (vars.DITTO_UPLOADER === 'blossom') { - vars.BLOSSOM_SERVERS = await question('input', 'Blossom servers (comma separated)', Conf.blossomServers.join(',')); + vars.BLOSSOM_SERVERS = await question('input', 'Blossom servers (comma separated)', conf.blossomServers.join(',')); } if (vars.DITTO_UPLOADER === 's3') { - vars.S3_ACCESS_KEY = await question('input', 'S3 access key', Conf.s3.accessKey); - vars.S3_SECRET_KEY = await question('input', 'S3 secret key', Conf.s3.secretKey); - vars.S3_ENDPOINT = await question('input', 'S3 endpoint', Conf.s3.endPoint); - vars.S3_BUCKET = await question('input', 'S3 bucket', Conf.s3.bucket); - vars.S3_REGION = await question('input', 'S3 region', Conf.s3.region); - vars.S3_PATH_STYLE = String(await question('confirm', 'Use path style?', Conf.s3.pathStyle ?? false)); + vars.S3_ACCESS_KEY = await question('input', 'S3 access key', conf.s3.accessKey); + vars.S3_SECRET_KEY = await question('input', 'S3 secret key', conf.s3.secretKey); + vars.S3_ENDPOINT = await question('input', 'S3 endpoint', conf.s3.endPoint); + vars.S3_BUCKET = await question('input', 'S3 bucket', conf.s3.bucket); + vars.S3_REGION = await question('input', 'S3 region', conf.s3.region); + vars.S3_PATH_STYLE = String(await question('confirm', 'Use path style?', conf.s3.pathStyle ?? false)); const mediaDomain = await question('input', 'Media domain', `media.${domain}`); vars.MEDIA_DOMAIN = `https://${mediaDomain}`; } if (vars.DITTO_UPLOADER === 'ipfs') { - vars.IPFS_API_URL = await question('input', 'IPFS API URL', Conf.ipfs.apiUrl); + vars.IPFS_API_URL = await question('input', 'IPFS API URL', conf.ipfs.apiUrl); const mediaDomain = await question('input', 'Media domain', `media.${domain}`); vars.MEDIA_DOMAIN = `https://${mediaDomain}`; } if (vars.DITTO_UPLOADER === 'local') { - vars.UPLOADS_DIR = await question('input', 'Local uploads directory', Conf.uploadsDir); + vars.UPLOADS_DIR = await question('input', 'Local uploads directory', conf.uploadsDir); const mediaDomain = await question('input', 'Media domain', `media.${domain}`); vars.MEDIA_DOMAIN = `https://${mediaDomain}`; } diff --git a/scripts/stats-recompute.ts b/scripts/stats-recompute.ts index 942d0012..5f1d7233 100644 --- a/scripts/stats-recompute.ts +++ b/scripts/stats-recompute.ts @@ -1,8 +1,12 @@ +import { DittoConf } from '@ditto/conf'; import { nip19 } from 'nostr-tools'; -import { Storages } from '../packages/ditto/storages.ts'; +import { DittoStorages } from '../packages/ditto/DittoStorages.ts'; import { refreshAuthorStats } from '../packages/ditto/utils/stats.ts'; +const conf = new DittoConf(Deno.env); +const storages = new DittoStorages(conf); + let pubkey: string; try { const result = nip19.decode(Deno.args[0]); @@ -16,7 +20,7 @@ try { Deno.exit(1); } -const store = await Storages.db(); -const kysely = await Storages.kysely(); +const store = await storages.db(); +const kysely = await storages.kysely(); await refreshAuthorStats({ pubkey, kysely, store }); diff --git a/scripts/trends.ts b/scripts/trends.ts index bb9708ab..ff4550f3 100644 --- a/scripts/trends.ts +++ b/scripts/trends.ts @@ -1,41 +1,42 @@ +import { DittoConf } from '@ditto/conf'; import { z } from 'zod'; -import { - updateTrendingEvents, - updateTrendingHashtags, - updateTrendingLinks, - updateTrendingPubkeys, - updateTrendingZappedEvents, -} from '../packages/ditto/trends.ts'; +import { DittoTrends } from '../packages/ditto/trends.ts'; +import { DittoStorages } from '../packages/ditto/DittoStorages.ts'; -const trendSchema = z.enum(['pubkeys', 'zapped_events', 'events', 'hashtags', 'links']); -const trends = trendSchema.array().parse(Deno.args); +const trendNameSchema = z.enum(['pubkeys', 'zapped_events', 'events', 'hashtags', 'links']); +const names = trendNameSchema.array().parse(Deno.args); -if (!trends.length) { - trends.push('pubkeys', 'zapped_events', 'events', 'hashtags', 'links'); +if (!names.length) { + names.push('pubkeys', 'zapped_events', 'events', 'hashtags', 'links'); } -for (const trend of trends) { - switch (trend) { +const conf = new DittoConf(Deno.env); +const storages = new DittoStorages(conf); + +const trends = new DittoTrends({ conf, kysely: await storages.kysely(), store: await storages.db() }); + +for (const name of names) { + switch (name) { case 'pubkeys': console.log('Updating trending pubkeys...'); - await updateTrendingPubkeys(); + await trends.updateTrendingPubkeys(); break; case 'zapped_events': console.log('Updating trending zapped events...'); - await updateTrendingZappedEvents(); + await trends.updateTrendingZappedEvents(); break; case 'events': console.log('Updating trending events...'); - await updateTrendingEvents(); + await trends.updateTrendingEvents(); break; case 'hashtags': console.log('Updating trending hashtags...'); - await updateTrendingHashtags(); + await trends.updateTrendingHashtags(); break; case 'links': console.log('Updating trending links...'); - await updateTrendingLinks(); + await trends.updateTrendingLinks(); break; } }