From f58c2098f04648c901dc93eff3a2c14b8dbc5acb Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 23 Jan 2024 11:17:31 -0600 Subject: [PATCH 1/4] Add DittoEvent and DittoFilter dedicated interface modules --- src/deps.ts | 2 ++ src/interfaces/DittoEvent.ts | 24 ++++++++++++++++++++++++ src/interfaces/DittoFilter.ts | 14 ++++++++++++++ 3 files changed, 40 insertions(+) create mode 100644 src/interfaces/DittoEvent.ts create mode 100644 src/interfaces/DittoFilter.ts diff --git a/src/deps.ts b/src/deps.ts index fa7ff6f..53a07e9 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -91,6 +91,8 @@ export { type LNURLDetails, type MapCache, NIP05, + type NostrEvent, + type NostrFilter, } from 'https://gitlab.com/soapbox-pub/nlib/-/raw/5d711597f3b2a163817cc1fb0f1f3ce8cede7cf7/mod.ts'; export type * as TypeFest from 'npm:type-fest@^4.3.0'; diff --git a/src/interfaces/DittoEvent.ts b/src/interfaces/DittoEvent.ts new file mode 100644 index 0000000..ed327d4 --- /dev/null +++ b/src/interfaces/DittoEvent.ts @@ -0,0 +1,24 @@ +import { type NostrEvent } from '@/deps.ts'; + +/** Ditto internal stats for the event's author. */ +export interface AuthorStats { + followers_count: number; + following_count: number; + notes_count: number; +} + +/** Ditto internal stats for the event. */ +export interface EventStats { + replies_count: number; + reposts_count: number; + reactions_count: number; +} + +/** Internal Event representation used by Ditto, including extra keys. */ +export interface DittoEvent extends NostrEvent { + author?: DittoEvent; + author_stats?: AuthorStats; + event_stats?: EventStats; + d_author?: DittoEvent; + user?: DittoEvent; +} diff --git a/src/interfaces/DittoFilter.ts b/src/interfaces/DittoFilter.ts new file mode 100644 index 0000000..4ecda96 --- /dev/null +++ b/src/interfaces/DittoFilter.ts @@ -0,0 +1,14 @@ +import { type NostrEvent, type NostrFilter } from '@/deps.ts'; + +import { type DittoEvent } from './DittoEvent.ts'; + +/** Additional properties that may be added by Ditto to events. */ +export type DittoRelation = Exclude; + +/** Custom filter interface that extends Nostr filters with extra options for Ditto. */ +export interface DittoFilter extends NostrFilter { + /** Whether the event was authored by a local user. */ + local?: boolean; + /** Additional fields to add to the returned event. */ + relations?: DittoRelation[]; +} From aaf01462c153adbd4a36176a270ebaab1e14de27 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 23 Jan 2024 12:07:22 -0600 Subject: [PATCH 2/4] Update code to use new DittoEvent and DittoFilter. Event -> NostrEvent --- src/app.ts | 4 +- src/controllers/api/accounts.ts | 10 +-- src/controllers/api/search.ts | 12 +-- src/controllers/api/statuses.ts | 4 +- src/controllers/api/streaming.ts | 4 +- src/controllers/api/timelines.ts | 9 +-- src/controllers/nostr/relay.ts | 6 +- src/db/users.ts | 4 +- src/deps.ts | 4 +- src/filter.test.ts | 3 +- src/filter.ts | 34 +++------ src/firehose.ts | 6 +- src/middleware/auth98.ts | 4 +- src/pipeline.ts | 20 ++--- src/queries.ts | 37 ++++----- src/sign.ts | 26 +++---- src/stats.ts | 21 +++--- src/storages/events-db.ts | 26 ++++--- src/storages/hydrate.ts | 13 ++-- src/storages/memorelay.ts | 25 +++--- src/storages/optimizer.ts | 23 +++--- src/storages/pool-store.ts | 17 ++--- src/storages/reqmeister.ts | 42 ++++------- src/storages/search-store.ts | 23 +++--- src/storages/types.ts | 27 ++----- src/subs.ts | 6 +- src/subscription.ts | 17 +++-- src/tags.ts | 10 ++- src/utils.ts | 12 +-- src/utils/api.ts | 34 ++++----- src/utils/event-set.test.ts | 109 --------------------------- src/utils/event-set.ts | 77 ------------------- src/utils/nip98.ts | 10 +-- src/views.ts | 4 +- src/views/activitypub/actor.ts | 4 +- src/views/mastodon/accounts.ts | 6 +- src/views/mastodon/admin-accounts.ts | 4 +- src/views/mastodon/notifications.ts | 8 +- src/views/mastodon/statuses.ts | 9 ++- src/workers/verify.ts | 4 +- src/workers/verify.worker.ts | 4 +- 41 files changed, 256 insertions(+), 466 deletions(-) delete mode 100644 src/utils/event-set.test.ts delete mode 100644 src/utils/event-set.ts diff --git a/src/app.ts b/src/app.ts index 8fa6891..159bff8 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,12 +5,12 @@ import { type Context, cors, Debug, - type Event, type Handler, Hono, type HonoEnv, logger, type MiddlewareHandler, + type NostrEvent, sentryMiddleware, serveStatic, } from '@/deps.ts'; @@ -90,7 +90,7 @@ interface AppEnv extends HonoEnv { /** Hex secret key for the current user. Optional, but easiest way to use legacy Mastodon apps. */ seckey?: string; /** NIP-98 signed event proving the pubkey is owned by the user. */ - proof?: Event<27235>; + proof?: NostrEvent; /** User associated with the pubkey, if any. */ user?: User; }; diff --git a/src/controllers/api/accounts.ts b/src/controllers/api/accounts.ts index 2424deb..f7904af 100644 --- a/src/controllers/api/accounts.ts +++ b/src/controllers/api/accounts.ts @@ -1,13 +1,12 @@ import { type AppController } from '@/app.ts'; import { Conf } from '@/config.ts'; import { insertUser } from '@/db/users.ts'; -import { findReplyTag, nip19, z } from '@/deps.ts'; -import { type DittoFilter } from '@/filter.ts'; +import { nip19, z } from '@/deps.ts'; import { getAuthor, getFollowedPubkeys } from '@/queries.ts'; import { booleanParamSchema, fileSchema } from '@/schema.ts'; import { jsonMetaContentSchema } from '@/schemas/nostr.ts'; import { eventsDB } from '@/storages.ts'; -import { addTag, deleteTag, getTagSet } from '@/tags.ts'; +import { addTag, deleteTag, findReplyTag, getTagSet } from '@/tags.ts'; import { uploadFile } from '@/upload.ts'; import { lookupAccount, nostrNow } from '@/utils.ts'; import { createEvent, paginated, paginationSchema, parseBody, updateListEvent } from '@/utils/api.ts'; @@ -15,6 +14,7 @@ import { renderAccounts, renderEventAccounts, renderStatuses } from '@/views.ts' import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { renderRelationship } from '@/views/mastodon/relationships.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts'; +import { DittoFilter } from '@/interfaces/DittoFilter.ts'; const usernameSchema = z .string().min(1).max(30) @@ -143,7 +143,7 @@ const accountStatusesController: AppController = async (c) => { } } - const filter: DittoFilter<1> = { + const filter: DittoFilter = { authors: [pubkey], kinds: [1], relations: ['author', 'event_stats', 'author_stats'], @@ -159,7 +159,7 @@ const accountStatusesController: AppController = async (c) => { let events = await eventsDB.filter([filter]); if (exclude_replies) { - events = events.filter((event) => !findReplyTag(event)); + events = events.filter((event) => !findReplyTag(event.tags)); } const statuses = await Promise.all(events.map((event) => renderStatus(event, c.get('pubkey')))); diff --git a/src/controllers/api/search.ts b/src/controllers/api/search.ts index 349f51e..87da478 100644 --- a/src/controllers/api/search.ts +++ b/src/controllers/api/search.ts @@ -1,6 +1,6 @@ import { AppController } from '@/app.ts'; -import { type Event, nip19, z } from '@/deps.ts'; -import { type DittoFilter } from '@/filter.ts'; +import { nip19, type NostrEvent, z } from '@/deps.ts'; +import { type DittoFilter } from '@/interfaces/DittoFilter.ts'; import { booleanParamSchema } from '@/schema.ts'; import { nostrIdSchema } from '@/schemas/nostr.ts'; import { searchStore } from '@/storages.ts'; @@ -46,12 +46,12 @@ const searchController: AppController = async (c) => { const [accounts, statuses] = await Promise.all([ Promise.all( results - .filter((event): event is Event<0> => event.kind === 0) + .filter((event): event is NostrEvent => event.kind === 0) .map((event) => renderAccount(event)), ), Promise.all( results - .filter((event): event is Event<1> => event.kind === 1) + .filter((event): event is NostrEvent => event.kind === 1) .map((event) => renderStatus(event, c.get('pubkey'))), ), ]); @@ -64,7 +64,7 @@ const searchController: AppController = async (c) => { }; /** Get events for the search params. */ -function searchEvents({ q, type, limit, account_id }: SearchQuery, signal: AbortSignal): Promise { +function searchEvents({ q, type, limit, account_id }: SearchQuery, signal: AbortSignal): Promise { if (type === 'hashtags') return Promise.resolve([]); const filter: DittoFilter = { @@ -94,7 +94,7 @@ function typeToKinds(type: SearchQuery['type']): number[] { } /** Resolve a searched value into an event, if applicable. */ -async function lookupEvent(query: SearchQuery, signal: AbortSignal): Promise { +async function lookupEvent(query: SearchQuery, signal: AbortSignal): Promise { const filters = await getLookupFilters(query, signal); const [event] = await searchStore.filter(filters, { limit: 1, signal }); return event; diff --git a/src/controllers/api/statuses.ts b/src/controllers/api/statuses.ts index 08b9a87..99fa1e3 100644 --- a/src/controllers/api/statuses.ts +++ b/src/controllers/api/statuses.ts @@ -1,7 +1,7 @@ import { type AppController } from '@/app.ts'; import { Conf } from '@/config.ts'; import { getUnattachedMediaByIds } from '@/db/unattached-media.ts'; -import { type Event, ISO6391, z } from '@/deps.ts'; +import { ISO6391, type NostrEvent, z } from '@/deps.ts'; import { getAncestors, getAuthor, getDescendants, getEvent } from '@/queries.ts'; import { jsonMetaContentSchema } from '@/schemas/nostr.ts'; import { addTag, deleteTag } from '@/tags.ts'; @@ -100,7 +100,7 @@ const contextController: AppController = async (c) => { const id = c.req.param('id'); const event = await getEvent(id, { kind: 1, relations: ['author', 'event_stats', 'author_stats'] }); - async function renderStatuses(events: Event<1>[]) { + async function renderStatuses(events: NostrEvent[]) { const statuses = await Promise.all(events.map((event) => renderStatus(event, c.get('pubkey')))); return statuses.filter(Boolean); } diff --git a/src/controllers/api/streaming.ts b/src/controllers/api/streaming.ts index 0751260..74c7d16 100644 --- a/src/controllers/api/streaming.ts +++ b/src/controllers/api/streaming.ts @@ -1,6 +1,6 @@ import { type AppController } from '@/app.ts'; import { Debug, z } from '@/deps.ts'; -import { type DittoFilter } from '@/filter.ts'; +import { DittoFilter } from '@/interfaces/DittoFilter.ts'; import { getAuthor, getFeedPubkeys } from '@/queries.ts'; import { Sub } from '@/subs.ts'; import { bech32ToPubkey } from '@/utils.ts'; @@ -86,7 +86,7 @@ async function topicToFilter( topic: Stream, pubkey: string, query: Record, -): Promise | undefined> { +): Promise { switch (topic) { case 'public': return { kinds: [1] }; diff --git a/src/controllers/api/timelines.ts b/src/controllers/api/timelines.ts index 00a4a55..35579dd 100644 --- a/src/controllers/api/timelines.ts +++ b/src/controllers/api/timelines.ts @@ -1,13 +1,12 @@ -import { eventsDB } from '@/storages.ts'; +import { type AppContext, type AppController } from '@/app.ts'; import { z } from '@/deps.ts'; -import { type DittoFilter } from '@/filter.ts'; +import { type DittoFilter } from '@/interfaces/DittoFilter.ts'; import { getFeedPubkeys } from '@/queries.ts'; import { booleanParamSchema } from '@/schema.ts'; +import { eventsDB } from '@/storages.ts'; import { paginated, paginationSchema } from '@/utils/api.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts'; -import type { AppContext, AppController } from '@/app.ts'; - const homeTimelineController: AppController = async (c) => { const params = paginationSchema.parse(c.req.query()); const pubkey = c.get('pubkey')!; @@ -32,7 +31,7 @@ const hashtagTimelineController: AppController = (c) => { }; /** Render statuses for timelines. */ -async function renderStatuses(c: AppContext, filters: DittoFilter<1>[], signal = AbortSignal.timeout(1000)) { +async function renderStatuses(c: AppContext, filters: DittoFilter[], signal = AbortSignal.timeout(1000)) { const events = await eventsDB.filter( filters.map((filter) => ({ ...filter, relations: ['author', 'event_stats', 'author_stats'] })), { signal }, diff --git a/src/controllers/nostr/relay.ts b/src/controllers/nostr/relay.ts index cc21b3c..bab09dc 100644 --- a/src/controllers/nostr/relay.ts +++ b/src/controllers/nostr/relay.ts @@ -13,14 +13,14 @@ import { import { Sub } from '@/subs.ts'; import type { AppController } from '@/app.ts'; -import type { Event, Filter } from '@/deps.ts'; +import type { NostrEvent, NostrFilter } from '@/deps.ts'; /** Limit of initial events returned for a subscription. */ const FILTER_LIMIT = 100; /** NIP-01 relay to client message. */ type RelayMsg = - | ['EVENT', string, Event] + | ['EVENT', string, NostrEvent] | ['NOTICE', string] | ['EOSE', string] | ['OK', string, boolean, string] @@ -109,7 +109,7 @@ function connectStream(socket: WebSocket) { } /** Enforce the filters with certain criteria. */ -function prepareFilters(filters: ClientREQ[2][]): Filter[] { +function prepareFilters(filters: ClientREQ[2][]): NostrFilter[] { return filters.map((filter) => ({ ...filter, // Return only local events unless the query is already narrow. diff --git a/src/db/users.ts b/src/db/users.ts index 53f5ff8..4b6f9e8 100644 --- a/src/db/users.ts +++ b/src/db/users.ts @@ -1,5 +1,5 @@ import { Conf } from '@/config.ts'; -import { Debug, type Filter } from '@/deps.ts'; +import { Debug, type NostrFilter } from '@/deps.ts'; import * as pipeline from '@/pipeline.ts'; import { signAdminEvent } from '@/sign.ts'; import { eventsDB } from '@/storages.ts'; @@ -49,7 +49,7 @@ async function insertUser(user: User) { * ``` */ async function findUser(user: Partial): Promise { - const filter: Filter = { kinds: [30361], authors: [Conf.pubkey], limit: 1 }; + const filter: NostrFilter = { kinds: [30361], authors: [Conf.pubkey], limit: 1 }; for (const [key, value] of Object.entries(user)) { switch (key) { diff --git a/src/deps.ts b/src/deps.ts index 53a07e9..8b2f47d 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -11,9 +11,7 @@ export { cors, logger, serveStatic } from 'https://deno.land/x/hono@v3.10.1/midd export { z } from 'https://deno.land/x/zod@v3.21.4/mod.ts'; export { RelayPoolWorker } from 'https://dev.jspm.io/nostr-relaypool@0.6.30'; export { - type Event, type EventTemplate, - type Filter, finishEvent, getEventHash, getPublicKey, @@ -29,7 +27,6 @@ export { type VerifiedEvent, verifySignature, } from 'npm:nostr-tools@^1.17.0'; -export { findReplyTag } from 'https://gitlab.com/soapbox-pub/mostr/-/raw/c67064aee5ade5e01597c6d23e22e53c628ef0e2/src/nostr/tags.ts'; export { parseFormData } from 'npm:formdata-helper@^0.3.0'; // @deno-types="npm:@types/lodash@4.14.194" export { default as lodash } from 'https://esm.sh/lodash@4.17.21'; @@ -86,6 +83,7 @@ export { EventEmitter } from 'npm:tseep@^1.1.3'; export { default as stringifyStable } from 'npm:fast-stable-stringify@^1.0.0'; // @deno-types="npm:@types/debug@^4.1.12" export { default as Debug } from 'npm:debug@^4.3.4'; +export { NSet } from 'https://gitlab.com/soapbox-pub/nset/-/raw/b3c5601612f9bd277626198c5534e0796e003884/mod.ts'; export { LNURL, type LNURLDetails, diff --git a/src/filter.test.ts b/src/filter.test.ts index b1ea872..5909fc6 100644 --- a/src/filter.test.ts +++ b/src/filter.test.ts @@ -1,4 +1,3 @@ -import { type Event } from '@/deps.ts'; import { assertEquals } from '@/deps-test.ts'; import event0 from '~/fixtures/events/event-0.json' assert { type: 'json' }; @@ -7,7 +6,7 @@ import event1 from '~/fixtures/events/event-1.json' assert { type: 'json' }; import { eventToMicroFilter, getFilterId, getFilterLimit, getMicroFilters, isMicrofilter } from './filter.ts'; Deno.test('getMicroFilters', () => { - const event = event0 as Event<0>; + const event = event0; const microfilters = getMicroFilters(event); assertEquals(microfilters.length, 2); assertEquals(microfilters[0], { authors: [event.pubkey], kinds: [0] }); diff --git a/src/filter.ts b/src/filter.ts index 7f641bb..2e4d577 100644 --- a/src/filter.ts +++ b/src/filter.ts @@ -1,24 +1,14 @@ import { Conf } from '@/config.ts'; -import { type Event, type Filter, matchFilters, stringifyStable, z } from '@/deps.ts'; +import { matchFilters, type NostrEvent, type NostrFilter, stringifyStable, z } from '@/deps.ts'; +import { DittoEvent } from '@/interfaces/DittoEvent.ts'; +import { type DittoFilter } from '@/interfaces/DittoFilter.ts'; import { isReplaceableKind } from '@/kinds.ts'; import { nostrIdSchema } from '@/schemas/nostr.ts'; -import { type DittoEvent } from '@/storages/types.ts'; - -/** Additional properties that may be added by Ditto to events. */ -type Relation = 'author' | 'author_stats' | 'event_stats'; - -/** Custom filter interface that extends Nostr filters with extra options for Ditto. */ -interface DittoFilter extends Filter { - /** Whether the event was authored by a local user. */ - local?: boolean; - /** Additional fields to add to the returned event. */ - relations?: Relation[]; -} /** Microfilter to get one specific event by ID. */ -type IdMicrofilter = { ids: [Event['id']] }; +type IdMicrofilter = { ids: [NostrEvent['id']] }; /** Microfilter to get an author. */ -type AuthorMicrofilter = { kinds: [0]; authors: [Event['pubkey']] }; +type AuthorMicrofilter = { kinds: [0]; authors: [NostrEvent['pubkey']] }; /** Filter to get one specific event. */ type MicroFilter = IdMicrofilter | AuthorMicrofilter; @@ -57,13 +47,13 @@ function getFilterId(filter: MicroFilter): string { } /** Get a microfilter from a Nostr event. */ -function eventToMicroFilter(event: Event): MicroFilter { +function eventToMicroFilter(event: NostrEvent): MicroFilter { const [microfilter] = getMicroFilters(event); return microfilter; } /** Get all the microfilters for an event, in order of priority. */ -function getMicroFilters(event: Event): MicroFilter[] { +function getMicroFilters(event: NostrEvent): MicroFilter[] { const microfilters: MicroFilter[] = []; if (event.kind === 0) { microfilters.push({ kinds: [0], authors: [event.pubkey] }); @@ -79,12 +69,12 @@ const microFilterSchema = z.union([ ]); /** Checks whether the filter is a microfilter. */ -function isMicrofilter(filter: Filter): filter is MicroFilter { +function isMicrofilter(filter: NostrFilter): filter is MicroFilter { return microFilterSchema.safeParse(filter).success; } /** Calculate the intrinsic limit of a filter. */ -function getFilterLimit(filter: Filter): number { +function getFilterLimit(filter: NostrFilter): number { if (filter.ids && !filter.ids.length) return 0; if (filter.kinds && !filter.kinds.length) return 0; if (filter.authors && !filter.authors.length) return 0; @@ -100,12 +90,12 @@ function getFilterLimit(filter: Filter): number { } /** Returns true if the filter could potentially return any stored events at all. */ -function canFilter(filter: Filter): boolean { +function canFilter(filter: NostrFilter): boolean { return getFilterLimit(filter) > 0; } /** Normalize the `limit` of each filter, and remove filters that can't produce any events. */ -function normalizeFilters(filters: F[]): F[] { +function normalizeFilters(filters: F[]): F[] { return filters.reduce((acc, filter) => { const limit = getFilterLimit(filter); if (limit > 0) { @@ -118,7 +108,6 @@ function normalizeFilters(filters: F[]): F[] { export { type AuthorMicrofilter, canFilter, - type DittoFilter, eventToMicroFilter, getFilterId, getFilterLimit, @@ -128,5 +117,4 @@ export { matchDittoFilters, type MicroFilter, normalizeFilters, - type Relation, }; diff --git a/src/firehose.ts b/src/firehose.ts index a9d1c44..f1caf8e 100644 --- a/src/firehose.ts +++ b/src/firehose.ts @@ -1,4 +1,4 @@ -import { Debug, type Event } from '@/deps.ts'; +import { Debug, type NostrEvent } from '@/deps.ts'; import { activeRelays, pool } from '@/pool.ts'; import { nostrNow } from '@/utils.ts'; @@ -18,8 +18,8 @@ pool.subscribe( ); /** Handle events through the firehose pipeline. */ -function handleEvent(event: Event): Promise { - debug(`Event<${event.kind}> ${event.id}`); +function handleEvent(event: NostrEvent): Promise { + debug(`NostrEvent<${event.kind}> ${event.id}`); return pipeline .handleEvent(event) diff --git a/src/middleware/auth98.ts b/src/middleware/auth98.ts index 67beac6..1ee73c0 100644 --- a/src/middleware/auth98.ts +++ b/src/middleware/auth98.ts @@ -1,5 +1,5 @@ import { type AppContext, type AppMiddleware } from '@/app.ts'; -import { type Event, HTTPException } from '@/deps.ts'; +import { HTTPException, type NostrEvent } from '@/deps.ts'; import { buildAuthEventTemplate, parseAuthRequest, @@ -65,7 +65,7 @@ function matchesRole(user: User, role: UserRole): boolean { /** HOC to obtain proof in middleware. */ function withProof( - handler: (c: AppContext, proof: Event<27235>, next: () => Promise) => Promise, + handler: (c: AppContext, proof: NostrEvent, next: () => Promise) => Promise, opts?: ParseAuthRequestOpts, ): AppMiddleware { return async (c, next) => { diff --git a/src/pipeline.ts b/src/pipeline.ts index d29b2d0..4804a9d 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -2,7 +2,8 @@ import { Conf } from '@/config.ts'; import { encryptAdmin } from '@/crypto.ts'; import { addRelays } from '@/db/relays.ts'; import { deleteAttachedMedia } from '@/db/unattached-media.ts'; -import { Debug, type Event, LNURL } from '@/deps.ts'; +import { Debug, LNURL, type NostrEvent } from '@/deps.ts'; +import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { isEphemeralKind } from '@/kinds.ts'; import { isLocallyFollowed } from '@/queries.ts'; import { updateStats } from '@/stats.ts'; @@ -15,7 +16,6 @@ import { TrendsWorker } from '@/workers/trends.ts'; import { verifySignatureWorker } from '@/workers/verify.ts'; import { signAdminEvent } from '@/sign.ts'; import { lnurlCache } from '@/utils/lnurl.ts'; -import { DittoEvent } from '@/storages/types.ts'; const debug = Debug('ditto:pipeline'); @@ -28,7 +28,7 @@ async function handleEvent(event: DittoEvent): Promise { if (!(await verifySignatureWorker(event))) return; const wanted = reqmeister.isWanted(event); if (await encounterEvent(event)) return; - debug(`Event<${event.kind}> ${event.id}`); + debug(`NostrEvent<${event.kind}> ${event.id}`); await hydrateEvent(event); await Promise.all([ @@ -45,7 +45,7 @@ async function handleEvent(event: DittoEvent): Promise { } /** Encounter the event, and return whether it has already been encountered. */ -async function encounterEvent(event: Event): Promise { +async function encounterEvent(event: NostrEvent): Promise { const preexisting = (await memorelay.count([{ ids: [event.id] }])) > 0; memorelay.add(event); reqmeister.add(event); @@ -59,7 +59,7 @@ async function hydrateEvent(event: DittoEvent): Promise { } /** Check if the pubkey is the `DITTO_NSEC` pubkey. */ -const isAdminEvent = ({ pubkey }: Event): boolean => pubkey === Conf.pubkey; +const isAdminEvent = ({ pubkey }: NostrEvent): boolean => pubkey === Conf.pubkey; interface StoreEventOpts { force?: boolean; @@ -89,7 +89,7 @@ async function storeEvent(event: DittoEvent, opts: StoreEventOpts = {}): Promise } /** Query to-be-deleted events, ensure their pubkey matches, then delete them from the database. */ -async function processDeletions(event: Event): Promise { +async function processDeletions(event: NostrEvent): Promise { if (event.kind === 5) { const ids = getTagSet(event.tags, 'e'); @@ -108,7 +108,7 @@ async function processDeletions(event: Event): Promise { } /** Track whenever a hashtag is used, for processing trending tags. */ -async function trackHashtags(event: Event): Promise { +async function trackHashtags(event: NostrEvent): Promise { const date = nostrDate(event.created_at); const tags = event.tags @@ -127,7 +127,7 @@ async function trackHashtags(event: Event): Promise { } /** Tracks known relays in the database. */ -function trackRelays(event: Event) { +function trackRelays(event: NostrEvent) { const relays = new Set<`wss://${string}`>(); event.tags.forEach((tag) => { @@ -208,10 +208,10 @@ async function payZap(event: DittoEvent, signal: AbortSignal) { } /** Determine if the event is being received in a timely manner. */ -const isFresh = (event: Event): boolean => eventAge(event) < Time.seconds(10); +const isFresh = (event: NostrEvent): boolean => eventAge(event) < Time.seconds(10); /** Distribute the event through active subscriptions. */ -function streamOut(event: Event) { +function streamOut(event: NostrEvent) { if (!isFresh(event)) return; for (const sub of Sub.matches(event)) { diff --git a/src/queries.ts b/src/queries.ts index 8a022fc..885cf44 100644 --- a/src/queries.ts +++ b/src/queries.ts @@ -1,37 +1,38 @@ import { eventsDB, memorelay, reqmeister } from '@/storages.ts'; -import { Debug, type Event, findReplyTag } from '@/deps.ts'; -import { type AuthorMicrofilter, type DittoFilter, type IdMicrofilter, type Relation } from '@/filter.ts'; -import { type DittoEvent } from '@/storages/types.ts'; -import { getTagSet } from '@/tags.ts'; +import { Debug, type NostrEvent } from '@/deps.ts'; +import { type AuthorMicrofilter, type IdMicrofilter } from '@/filter.ts'; +import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; +import { type DittoFilter, type DittoRelation } from '@/interfaces/DittoFilter.ts'; +import { findReplyTag, getTagSet } from '@/tags.ts'; const debug = Debug('ditto:queries'); -interface GetEventOpts { +interface GetEventOpts { /** Signal to abort the request. */ signal?: AbortSignal; /** Event kind. */ - kind?: K; + kind?: number; /** Relations to include on the event. */ - relations?: Relation[]; + relations?: DittoRelation[]; } /** Get a Nostr event by its ID. */ -const getEvent = async ( +const getEvent = async ( id: string, - opts: GetEventOpts = {}, -): Promise | undefined> => { + opts: GetEventOpts = {}, +): Promise => { debug(`getEvent: ${id}`); const { kind, relations, signal = AbortSignal.timeout(1000) } = opts; const microfilter: IdMicrofilter = { ids: [id] }; - const [memoryEvent] = await memorelay.filter([microfilter], opts) as DittoEvent[]; + const [memoryEvent] = await memorelay.filter([microfilter], opts) as DittoEvent[]; if (memoryEvent && !relations) { debug(`getEvent: ${id.slice(0, 8)} found in memory`); return memoryEvent; } - const filter: DittoFilter = { ids: [id], relations, limit: 1 }; + const filter: DittoFilter = { ids: [id], relations, limit: 1 }; if (kind) { filter.kinds = [kind]; } @@ -61,7 +62,7 @@ const getEvent = async ( return memoryEvent; } - const reqEvent = await reqmeister.req(microfilter, opts).catch(() => undefined) as Event | undefined; + const reqEvent = await reqmeister.req(microfilter, opts).catch(() => undefined); if (reqEvent) { debug(`getEvent: ${id.slice(0, 8)} found by reqmeister`); @@ -72,7 +73,7 @@ const getEvent = async ( }; /** Get a Nostr `set_medatadata` event for a user's pubkey. */ -const getAuthor = async (pubkey: string, opts: GetEventOpts<0> = {}): Promise | undefined> => { +const getAuthor = async (pubkey: string, opts: GetEventOpts = {}): Promise => { const { relations, signal = AbortSignal.timeout(1000) } = opts; const microfilter: AuthorMicrofilter = { kinds: [0], authors: [pubkey] }; @@ -94,7 +95,7 @@ const getAuthor = async (pubkey: string, opts: GetEventOpts<0> = {}): Promise | undefined> => { +const getFollows = async (pubkey: string, signal?: AbortSignal): Promise => { const [event] = await eventsDB.filter([{ authors: [pubkey], kinds: [3], limit: 1 }], { limit: 1, signal }); return event; }; @@ -112,9 +113,9 @@ async function getFeedPubkeys(pubkey: string): Promise { return [...authors, pubkey]; } -async function getAncestors(event: Event<1>, result = [] as Event<1>[]): Promise[]> { +async function getAncestors(event: NostrEvent, result: NostrEvent[] = []): Promise { if (result.length < 100) { - const replyTag = findReplyTag(event); + const replyTag = findReplyTag(event.tags); const inReplyTo = replyTag ? replyTag[1] : undefined; if (inReplyTo) { @@ -130,7 +131,7 @@ async function getAncestors(event: Event<1>, result = [] as Event<1>[]): Promise return result.reverse(); } -function getDescendants(eventId: string, signal = AbortSignal.timeout(2000)): Promise[]> { +function getDescendants(eventId: string, signal = AbortSignal.timeout(2000)): Promise { return eventsDB.filter( [{ kinds: [1], '#e': [eventId], relations: ['author', 'event_stats', 'author_stats'] }], { limit: 200, signal }, diff --git a/src/sign.ts b/src/sign.ts index 0ab9608..49b8557 100644 --- a/src/sign.ts +++ b/src/sign.ts @@ -1,7 +1,7 @@ import { type AppContext } from '@/app.ts'; import { Conf } from '@/config.ts'; import { decryptAdmin, encryptAdmin } from '@/crypto.ts'; -import { Debug, type Event, type EventTemplate, finishEvent, HTTPException } from '@/deps.ts'; +import { Debug, type EventTemplate, finishEvent, HTTPException, type NostrEvent } from '@/deps.ts'; import { connectResponseSchema } from '@/schemas/nostr.ts'; import { jsonSchema } from '@/schema.ts'; import { Sub } from '@/subs.ts'; @@ -21,11 +21,11 @@ interface SignEventOpts { * - If a secret key is provided, it will be used to sign the event. * - If `X-Nostr-Sign` is passed, it will use NIP-46 to sign the event. */ -async function signEvent( - event: EventTemplate, +async function signEvent( + event: EventTemplate, c: AppContext, opts: SignEventOpts = {}, -): Promise> { +): Promise { const seckey = c.get('seckey'); const header = c.req.header('x-nostr-sign'); @@ -45,11 +45,11 @@ async function signEvent( } /** Sign event with NIP-46, waiting in the background for the signed event. */ -async function signNostrConnect( - event: EventTemplate, +async function signNostrConnect( + event: EventTemplate, c: AppContext, opts: SignEventOpts = {}, -): Promise> { +): Promise { const pubkey = c.get('pubkey'); if (!pubkey) { @@ -73,16 +73,16 @@ async function signNostrConnect( tags: [['p', pubkey]], }, c); - return awaitSignedEvent(pubkey, messageId, event, c); + return awaitSignedEvent(pubkey, messageId, event, c); } /** Wait for signed event to be sent through Nostr relay. */ -async function awaitSignedEvent( +async function awaitSignedEvent( pubkey: string, messageId: string, - template: EventTemplate, + template: EventTemplate, c: AppContext, -): Promise> { +): Promise { const sub = Sub.sub(messageId, '1', [{ kinds: [24133], authors: [pubkey], '#p': [Conf.pubkey] }]); function close(): void { @@ -103,7 +103,7 @@ async function awaitSignedEvent( if (result.success) { close(); clearTimeout(timeout); - return result.data.result as Event; + return result.data.result; } } @@ -114,7 +114,7 @@ async function awaitSignedEvent( /** Sign event as the Ditto server. */ // deno-lint-ignore require-await -async function signAdminEvent(event: EventTemplate): Promise> { +async function signAdminEvent(event: EventTemplate): Promise { return finishEvent(event, Conf.seckey); } diff --git a/src/stats.ts b/src/stats.ts index 568fac6..df7f63c 100644 --- a/src/stats.ts +++ b/src/stats.ts @@ -1,6 +1,7 @@ import { type AuthorStatsRow, db, type DittoDB, type EventStatsRow } from '@/db.ts'; -import { Debug, type Event, findReplyTag, type InsertQueryBuilder } from '@/deps.ts'; +import { Debug, type InsertQueryBuilder, type NostrEvent } from '@/deps.ts'; import { eventsDB } from '@/storages.ts'; +import { findReplyTag } from '@/tags.ts'; type AuthorStat = keyof Omit; type EventStat = keyof Omit; @@ -12,15 +13,15 @@ type StatDiff = AuthorStatDiff | EventStatDiff; const debug = Debug('ditto:stats'); /** Store stats for the event in LMDB. */ -async function updateStats(event: Event) { - let prev: Event | undefined; +async function updateStats(event: NostrEvent) { + let prev: NostrEvent | undefined; const queries: InsertQueryBuilder[] = []; // Kind 3 is a special case - replace the count with the new list. if (event.kind === 3) { prev = await maybeGetPrev(event); if (!prev || event.created_at >= prev.created_at) { - queries.push(updateFollowingCountQuery(event as Event<3>)); + queries.push(updateFollowingCountQuery(event)); } } @@ -41,11 +42,11 @@ async function updateStats(event: Event) { } /** Calculate stats changes ahead of time so we can build an efficient query. */ -function getStatsDiff(event: Event, prev: Event | undefined): StatDiff[] { +function getStatsDiff(event: NostrEvent, prev: NostrEvent | undefined): StatDiff[] { const statDiffs: StatDiff[] = []; const firstTaggedId = event.tags.find(([name]) => name === 'e')?.[1]; - const inReplyToId = findReplyTag(event as Event<1>)?.[1]; + const inReplyToId = findReplyTag(event.tags)?.[1]; switch (event.kind) { case 1: @@ -55,7 +56,7 @@ function getStatsDiff(event: Event, prev: Event | undefi } break; case 3: - statDiffs.push(...getFollowDiff(event as Event<3>, prev as Event<3> | undefined)); + statDiffs.push(...getFollowDiff(event, prev)); break; case 6: if (firstTaggedId) { @@ -124,7 +125,7 @@ function eventStatsQuery(diffs: EventStatDiff[]) { } /** Get the last version of the event, if any. */ -async function maybeGetPrev(event: Event): Promise> { +async function maybeGetPrev(event: NostrEvent): Promise { const [prev] = await eventsDB.filter([ { kinds: [event.kind], authors: [event.pubkey], limit: 1 }, ]); @@ -133,7 +134,7 @@ async function maybeGetPrev(event: Event): Promise } /** Set the following count to the total number of unique "p" tags in the follow list. */ -function updateFollowingCountQuery({ pubkey, tags }: Event<3>) { +function updateFollowingCountQuery({ pubkey, tags }: NostrEvent) { const following_count = new Set( tags .filter(([name]) => name === 'p') @@ -155,7 +156,7 @@ function updateFollowingCountQuery({ pubkey, tags }: Event<3>) { } /** Compare the old and new follow events (if any), and return a diff array. */ -function getFollowDiff(event: Event<3>, prev?: Event<3>): AuthorStatDiff[] { +function getFollowDiff(event: NostrEvent, prev?: NostrEvent): AuthorStatDiff[] { const prevTags = prev?.tags ?? []; const prevPubkeys = new Set( diff --git a/src/storages/events-db.ts b/src/storages/events-db.ts index c24e08d..025b503 100644 --- a/src/storages/events-db.ts +++ b/src/storages/events-db.ts @@ -1,12 +1,14 @@ import { Conf } from '@/config.ts'; import { type DittoDB } from '@/db.ts'; -import { Debug, type Event, Kysely, type SelectQueryBuilder } from '@/deps.ts'; -import { type DittoFilter, normalizeFilters } from '@/filter.ts'; +import { Debug, Kysely, type NostrEvent, type SelectQueryBuilder } from '@/deps.ts'; +import { normalizeFilters } from '@/filter.ts'; +import { DittoEvent } from '@/interfaces/DittoEvent.ts'; +import { type DittoFilter } from '@/interfaces/DittoFilter.ts'; import { isDittoInternalKind, isParameterizedReplaceableKind, isReplaceableKind } from '@/kinds.ts'; import { jsonMetaContentSchema } from '@/schemas/nostr.ts'; import { isNostrId, isURL } from '@/utils.ts'; -import { type DittoEvent, EventStore, type GetEventsOpts } from './types.ts'; +import { type EventStore, type GetEventsOpts } from './types.ts'; /** Function to decide whether or not to index a tag. */ type TagCondition = ({ event, count, value }: { @@ -65,7 +67,7 @@ class EventsDB implements EventStore { } /** Insert an event (and its tags) into the database. */ - async add(event: DittoEvent): Promise { + async add(event: NostrEvent): Promise { this.#debug('EVENT', JSON.stringify(event)); if (isDittoInternalKind(event.kind) && event.pubkey !== Conf.pubkey) { @@ -264,7 +266,7 @@ class EventsDB implements EventStore { } /** Get events for filters from the database. */ - async filter(filters: DittoFilter[], opts: GetEventsOpts = {}): Promise[]> { + async filter(filters: DittoFilter[], opts: GetEventsOpts = {}): Promise { filters = normalizeFilters(filters); // Improves performance of `{ kinds: [0], authors: ['...'] }` queries. if (opts.signal?.aborted) return Promise.resolve([]); @@ -278,9 +280,9 @@ class EventsDB implements EventStore { } return (await query.execute()).map((row) => { - const event: DittoEvent = { + const event: DittoEvent = { id: row.id, - kind: row.kind as K, + kind: row.kind, pubkey: row.pubkey, content: row.content, created_at: row.created_at, @@ -337,7 +339,7 @@ class EventsDB implements EventStore { } /** Delete events based on filters from the database. */ - async deleteFilters(filters: DittoFilter[]): Promise { + async deleteFilters(filters: DittoFilter[]): Promise { if (!filters.length) return Promise.resolve(); this.#debug('DELETE', JSON.stringify(filters)); @@ -345,7 +347,7 @@ class EventsDB implements EventStore { } /** Get number of events that would be returned by filters. */ - async count(filters: DittoFilter[]): Promise { + async count(filters: DittoFilter[]): Promise { if (!filters.length) return Promise.resolve(0); this.#debug('COUNT', JSON.stringify(filters)); const query = this.getEventsQuery(filters); @@ -393,10 +395,10 @@ function filterIndexableTags(event: DittoEvent): string[][] { } /** Build a search index from the event. */ -function buildSearchContent(event: Event): string { +function buildSearchContent(event: NostrEvent): string { switch (event.kind) { case 0: - return buildUserSearchContent(event as Event<0>); + return buildUserSearchContent(event); case 1: return event.content; case 30009: @@ -407,7 +409,7 @@ function buildSearchContent(event: Event): string { } /** Build search content for a user. */ -function buildUserSearchContent(event: Event<0>): string { +function buildUserSearchContent(event: NostrEvent): string { const { name, nip05, about } = jsonMetaContentSchema.parse(event.content); return [name, nip05, about].filter(Boolean).join('\n'); } diff --git a/src/storages/hydrate.ts b/src/storages/hydrate.ts index 1e45f40..1ce0368 100644 --- a/src/storages/hydrate.ts +++ b/src/storages/hydrate.ts @@ -1,15 +1,16 @@ -import { type DittoFilter } from '@/filter.ts'; -import { type DittoEvent, type EventStore } from '@/storages/types.ts'; +import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; +import { type DittoFilter } from '@/interfaces/DittoFilter.ts'; +import { type EventStore } from '@/storages/types.ts'; -interface HydrateEventOpts { - events: DittoEvent[]; - filters: DittoFilter[]; +interface HydrateEventOpts { + events: DittoEvent[]; + filters: DittoFilter[]; storage: EventStore; signal?: AbortSignal; } /** Hydrate event relationships using the provided storage. */ -async function hydrateEvents(opts: HydrateEventOpts): Promise[]> { +async function hydrateEvents(opts: HydrateEventOpts): Promise { const { events, filters, storage, signal } = opts; if (filters.some((filter) => filter.relations?.includes('author'))) { diff --git a/src/storages/memorelay.ts b/src/storages/memorelay.ts index 550faf6..7a9d263 100644 --- a/src/storages/memorelay.ts +++ b/src/storages/memorelay.ts @@ -1,23 +1,22 @@ -import { Debug, type Event, type Filter, LRUCache, matchFilter } from '@/deps.ts'; +import { Debug, LRUCache, matchFilter, type NostrEvent, type NostrFilter, NSet } from '@/deps.ts'; import { normalizeFilters } from '@/filter.ts'; -import { EventSet } from '@/utils/event-set.ts'; import { type EventStore, type GetEventsOpts } from './types.ts'; /** In-memory data store for events. */ class Memorelay implements EventStore { #debug = Debug('ditto:memorelay'); - #cache: LRUCache; + #cache: LRUCache; /** NIPs supported by this storage method. */ supportedNips = [1, 45]; - constructor(...args: ConstructorParameters>) { - this.#cache = new LRUCache(...args); + constructor(...args: ConstructorParameters>) { + this.#cache = new LRUCache(...args); } /** Iterate stored events. */ - *#events(): Generator { + *#events(): Generator { for (const event of this.#cache.values()) { if (event && !(event instanceof Promise)) { yield event; @@ -26,7 +25,7 @@ class Memorelay implements EventStore { } /** Get events from memory. */ - filter(filters: Filter[], opts: GetEventsOpts = {}): Promise[]> { + filter(filters: NostrFilter[], opts: GetEventsOpts = {}): Promise { filters = normalizeFilters(filters); if (opts.signal?.aborted) return Promise.resolve([]); @@ -35,7 +34,7 @@ class Memorelay implements EventStore { this.#debug('REQ', JSON.stringify(filters)); /** Event results to return. */ - const results = new EventSet>(); + const results = new NSet(); /** Number of times an event has been added to results for each filter. */ const filterUsages: number[] = []; @@ -52,7 +51,7 @@ class Memorelay implements EventStore { for (const id of filter.ids) { const event = this.#cache.get(id); if (event && matchFilter(filter, event)) { - results.add(event as Event); + results.add(event); } } filterUsages[index] = Infinity; @@ -73,7 +72,7 @@ class Memorelay implements EventStore { if (usage >= limit) { return; } else if (matchFilter(filter, event)) { - results.add(event as Event); + results.add(event); this.#cache.get(event.id); filterUsages[index] = usage + 1; } @@ -91,19 +90,19 @@ class Memorelay implements EventStore { } /** Insert an event into memory. */ - add(event: Event): Promise { + add(event: NostrEvent): Promise { this.#cache.set(event.id, event); return Promise.resolve(); } /** Count events in memory for the filters. */ - async count(filters: Filter[]): Promise { + async count(filters: NostrFilter[]): Promise { const events = await this.filter(filters); return events.length; } /** Delete events from memory. */ - async deleteFilters(filters: Filter[]): Promise { + async deleteFilters(filters: NostrFilter[]): Promise { for (const event of await this.filter(filters)) { this.#cache.delete(event.id); } diff --git a/src/storages/optimizer.ts b/src/storages/optimizer.ts index 966dff2..81d63e0 100644 --- a/src/storages/optimizer.ts +++ b/src/storages/optimizer.ts @@ -1,8 +1,9 @@ -import { Debug } from '@/deps.ts'; -import { type DittoFilter, normalizeFilters } from '@/filter.ts'; -import { EventSet } from '@/utils/event-set.ts'; +import { Debug, NSet } from '@/deps.ts'; +import { normalizeFilters } from '@/filter.ts'; +import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; +import { type DittoFilter } from '@/interfaces/DittoFilter.ts'; -import { type DittoEvent, type EventStore, type GetEventsOpts, type StoreEventOpts } from './types.ts'; +import { type EventStore, type GetEventsOpts, type StoreEventOpts } from './types.ts'; interface OptimizerOpts { db: EventStore; @@ -25,17 +26,17 @@ class Optimizer implements EventStore { this.#client = opts.client; } - async add(event: DittoEvent, opts?: StoreEventOpts | undefined): Promise { + async add(event: DittoEvent, opts?: StoreEventOpts | undefined): Promise { await Promise.all([ this.#db.add(event, opts), this.#cache.add(event, opts), ]); } - async filter( - filters: DittoFilter[], + async filter( + filters: DittoFilter[], opts: GetEventsOpts | undefined = {}, - ): Promise[]> { + ): Promise { this.#debug('REQ', JSON.stringify(filters)); const { limit = Infinity } = opts; @@ -44,7 +45,7 @@ class Optimizer implements EventStore { if (opts?.signal?.aborted) return Promise.resolve([]); if (!filters.length) return Promise.resolve([]); - const results = new EventSet>(); + const results = new NSet(); // Filters with IDs are immutable, so we can take them straight from the cache if we have them. for (let i = 0; i < filters.length; i++) { @@ -99,11 +100,11 @@ class Optimizer implements EventStore { return getResults(); } - countEvents(_filters: DittoFilter[]): Promise { + countEvents(_filters: DittoFilter[]): Promise { throw new Error('COUNT not implemented.'); } - deleteEvents(_filters: DittoFilter[]): Promise { + deleteEvents(_filters: DittoFilter[]): Promise { throw new Error('DELETE not implemented.'); } } diff --git a/src/storages/pool-store.ts b/src/storages/pool-store.ts index b97fe3d..d6d9e12 100644 --- a/src/storages/pool-store.ts +++ b/src/storages/pool-store.ts @@ -1,13 +1,12 @@ -import { Debug, type Event, type Filter, matchFilters, type RelayPoolWorker } from '@/deps.ts'; +import { Debug, matchFilters, type NostrEvent, type NostrFilter, NSet, type RelayPoolWorker } from '@/deps.ts'; import { normalizeFilters } from '@/filter.ts'; import { type EventStore, type GetEventsOpts, type StoreEventOpts } from '@/storages/types.ts'; -import { EventSet } from '@/utils/event-set.ts'; interface PoolStoreOpts { pool: InstanceType; relays: WebSocket['url'][]; publisher: { - handleEvent(event: Event): Promise; + handleEvent(event: NostrEvent): Promise; }; } @@ -16,7 +15,7 @@ class PoolStore implements EventStore { #pool: InstanceType; #relays: WebSocket['url'][]; #publisher: { - handleEvent(event: Event): Promise; + handleEvent(event: NostrEvent): Promise; }; supportedNips = [1]; @@ -27,14 +26,14 @@ class PoolStore implements EventStore { this.#publisher = opts.publisher; } - add(event: Event, opts: StoreEventOpts = {}): Promise { + add(event: NostrEvent, opts: StoreEventOpts = {}): Promise { const { relays = this.#relays } = opts; this.#debug('EVENT', event); this.#pool.publish(event, relays); return Promise.resolve(); } - filter(filters: Filter[], opts: GetEventsOpts = {}): Promise[]> { + filter(filters: NostrFilter[], opts: GetEventsOpts = {}): Promise { filters = normalizeFilters(filters); if (opts.signal?.aborted) return Promise.resolve([]); @@ -43,17 +42,17 @@ class PoolStore implements EventStore { this.#debug('REQ', JSON.stringify(filters)); return new Promise((resolve) => { - const results = new EventSet>(); + const results = new NSet(); const unsub = this.#pool.subscribe( filters, opts.relays ?? this.#relays, - (event: Event | null) => { + (event: NostrEvent | null) => { if (event && matchFilters(filters, event)) { this.#publisher.handleEvent(event).catch(() => {}); results.add({ id: event.id, - kind: event.kind as K, + kind: event.kind, pubkey: event.pubkey, content: event.content, tags: event.tags, diff --git a/src/storages/reqmeister.ts b/src/storages/reqmeister.ts index b43ac14..d7d5953 100644 --- a/src/storages/reqmeister.ts +++ b/src/storages/reqmeister.ts @@ -1,12 +1,5 @@ -import { Debug, type Event, EventEmitter, type Filter } from '@/deps.ts'; -import { - AuthorMicrofilter, - eventToMicroFilter, - getFilterId, - IdMicrofilter, - isMicrofilter, - type MicroFilter, -} from '@/filter.ts'; +import { Debug, EventEmitter, type NostrEvent, type NostrFilter } from '@/deps.ts'; +import { eventToMicroFilter, getFilterId, isMicrofilter, type MicroFilter } from '@/filter.ts'; import { type EventStore, GetEventsOpts } from '@/storages/types.ts'; import { Time } from '@/utils/time.ts'; @@ -24,7 +17,7 @@ interface ReqmeisterReqOpts { type ReqmeisterQueueItem = [string, MicroFilter, WebSocket['url'][]]; /** Batches requests to Nostr relays using microfilters. */ -class Reqmeister extends EventEmitter<{ [filterId: string]: (event: Event) => any }> implements EventStore { +class Reqmeister extends EventEmitter<{ [filterId: string]: (event: NostrEvent) => any }> implements EventStore { #debug = Debug('ditto:reqmeister'); #opts: ReqmeisterOpts; @@ -55,8 +48,8 @@ class Reqmeister extends EventEmitter<{ [filterId: string]: (event: Event) => an const queue = this.#queue; this.#queue = []; - const wantedEvents = new Set(); - const wantedAuthors = new Set(); + const wantedEvents = new Set(); + const wantedAuthors = new Set(); // TODO: batch by relays. for (const [_filterId, filter, _relays] of queue) { @@ -67,7 +60,7 @@ class Reqmeister extends EventEmitter<{ [filterId: string]: (event: Event) => an } } - const filters: Filter[] = []; + const filters: NostrFilter[] = []; if (wantedEvents.size) filters.push({ ids: [...wantedEvents] }); if (wantedAuthors.size) filters.push({ kinds: [0], authors: [...wantedAuthors] }); @@ -85,10 +78,7 @@ class Reqmeister extends EventEmitter<{ [filterId: string]: (event: Event) => an this.#perform(); } - req(filter: IdMicrofilter, opts?: ReqmeisterReqOpts): Promise; - req(filter: AuthorMicrofilter, opts?: ReqmeisterReqOpts): Promise>; - req(filter: MicroFilter, opts?: ReqmeisterReqOpts): Promise; - req(filter: MicroFilter, opts: ReqmeisterReqOpts = {}): Promise { + req(filter: MicroFilter, opts: ReqmeisterReqOpts = {}): Promise { const { relays = [], signal = AbortSignal.timeout(this.#opts.timeout ?? 1000), @@ -102,8 +92,8 @@ class Reqmeister extends EventEmitter<{ [filterId: string]: (event: Event) => an this.#queue.push([filterId, filter, relays]); - return new Promise((resolve, reject) => { - const handleEvent = (event: Event) => { + return new Promise((resolve, reject) => { + const handleEvent = (event: NostrEvent) => { resolve(event); this.removeListener(filterId, handleEvent); }; @@ -119,25 +109,25 @@ class Reqmeister extends EventEmitter<{ [filterId: string]: (event: Event) => an }); } - add(event: Event): Promise { + add(event: NostrEvent): Promise { const filterId = getFilterId(eventToMicroFilter(event)); this.#queue = this.#queue.filter(([id]) => id !== filterId); this.emit(filterId, event); return Promise.resolve(); } - isWanted(event: Event): boolean { + isWanted(event: NostrEvent): boolean { const filterId = getFilterId(eventToMicroFilter(event)); return this.#queue.some(([id]) => id === filterId); } - filter(filters: Filter[], opts?: GetEventsOpts | undefined): Promise[]> { + filter(filters: NostrFilter[], opts?: GetEventsOpts | undefined): Promise { if (opts?.signal?.aborted) return Promise.resolve([]); if (!filters.length) return Promise.resolve([]); - const promises = filters.reduce>[]>((result, filter) => { + const promises = filters.reduce[]>((result, filter) => { if (isMicrofilter(filter)) { - result.push(this.req(filter) as Promise>); + result.push(this.req(filter) as Promise); } return result; }, []); @@ -145,11 +135,11 @@ class Reqmeister extends EventEmitter<{ [filterId: string]: (event: Event) => an return Promise.all(promises); } - count(_filters: Filter[]): Promise { + count(_filters: NostrFilter[]): Promise { throw new Error('COUNT not implemented.'); } - deleteFilters(_filters: Filter[]): Promise { + deleteFilters(_filters: NostrFilter[]): Promise { throw new Error('DELETE not implemented.'); } } diff --git a/src/storages/search-store.ts b/src/storages/search-store.ts index 65176fb..54cf48b 100644 --- a/src/storages/search-store.ts +++ b/src/storages/search-store.ts @@ -1,10 +1,11 @@ import { NiceRelay } from 'https://gitlab.com/soapbox-pub/nostr-machina/-/raw/5f4fb59c90c092e5aa59c01e6556a4bec264c167/mod.ts'; -import { Debug, type Event, type Filter } from '@/deps.ts'; -import { type DittoFilter, normalizeFilters } from '@/filter.ts'; +import { Debug, type NostrEvent, type NostrFilter, NSet } from '@/deps.ts'; +import { normalizeFilters } from '@/filter.ts'; +import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; +import { type DittoFilter } from '@/interfaces/DittoFilter.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; -import { type DittoEvent, type EventStore, type GetEventsOpts, type StoreEventOpts } from '@/storages/types.ts'; -import { EventSet } from '@/utils/event-set.ts'; +import { type EventStore, type GetEventsOpts, type StoreEventOpts } from '@/storages/types.ts'; interface SearchStoreOpts { relay: string | undefined; @@ -30,14 +31,14 @@ class SearchStore implements EventStore { } } - add(_event: Event, _opts?: StoreEventOpts | undefined): Promise { + add(_event: NostrEvent, _opts?: StoreEventOpts | undefined): Promise { throw new Error('EVENT not implemented.'); } - async filter( - filters: DittoFilter[], + async filter( + filters: DittoFilter[], opts?: GetEventsOpts | undefined, - ): Promise[]> { + ): Promise { filters = normalizeFilters(filters); if (opts?.signal?.aborted) return Promise.resolve([]); @@ -60,7 +61,7 @@ class SearchStore implements EventStore { opts?.signal?.addEventListener('abort', close, { once: true }); sub.eoseSignal.addEventListener('abort', close, { once: true }); - const events = new EventSet>(); + const events = new NSet(); for await (const event of sub) { events.add(event); @@ -73,11 +74,11 @@ class SearchStore implements EventStore { } } - count(_filters: Filter[]): Promise { + count(_filters: NostrFilter[]): Promise { throw new Error('COUNT not implemented.'); } - deleteFilters(_filters: Filter[]): Promise { + deleteFilters(_filters: NostrFilter[]): Promise { throw new Error('DELETE not implemented.'); } } diff --git a/src/storages/types.ts b/src/storages/types.ts index 2966464..f1b1883 100644 --- a/src/storages/types.ts +++ b/src/storages/types.ts @@ -1,6 +1,5 @@ -import { type DittoDB } from '@/db.ts'; -import { type Event } from '@/deps.ts'; -import { type DittoFilter } from '@/filter.ts'; +import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; +import { type DittoFilter } from '@/interfaces/DittoFilter.ts'; /** Additional options to apply to the whole subscription. */ interface GetEventsOpts { @@ -18,30 +17,18 @@ interface StoreEventOpts { relays?: WebSocket['url'][]; } -type AuthorStats = Omit; -type EventStats = Omit; - -/** Internal Event representation used by Ditto, including extra keys. */ -interface DittoEvent extends Event { - author?: DittoEvent<0>; - author_stats?: AuthorStats; - event_stats?: EventStats; - d_author?: DittoEvent<0>; - user?: DittoEvent<30361>; -} - /** Storage interface for Nostr events. */ interface EventStore { /** Indicates NIPs supported by this data store, similar to NIP-11. For example, `50` would indicate support for `search` filters. */ supportedNips: readonly number[]; /** Add an event to the store. */ - add(event: Event, opts?: StoreEventOpts): Promise; + add(event: DittoEvent, opts?: StoreEventOpts): Promise; /** Get events from filters. */ - filter(filters: DittoFilter[], opts?: GetEventsOpts): Promise[]>; + filter(filters: DittoFilter[], opts?: GetEventsOpts): Promise; /** Get the number of events from filters. */ - count?(filters: DittoFilter[]): Promise; + count?(filters: DittoFilter[]): Promise; /** Delete events from filters. */ - deleteFilters?(filters: DittoFilter[]): Promise; + deleteFilters?(filters: DittoFilter[]): Promise; } -export type { DittoEvent, EventStore, GetEventsOpts, StoreEventOpts }; +export type { EventStore, GetEventsOpts, StoreEventOpts }; diff --git a/src/subs.ts b/src/subs.ts index 100660e..32bdc5d 100644 --- a/src/subs.ts +++ b/src/subs.ts @@ -1,6 +1,6 @@ import { Debug } from '@/deps.ts'; -import { type DittoFilter } from '@/filter.ts'; -import { type DittoEvent } from '@/storages/types.ts'; +import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; +import { type DittoFilter } from '@/interfaces/DittoFilter.ts'; import { Subscription } from '@/subscription.ts'; const debug = Debug('ditto:subs'); @@ -21,7 +21,7 @@ class SubscriptionStore { * } * ``` */ - sub(socket: unknown, id: string, filters: DittoFilter[]): Subscription { + sub(socket: unknown, id: string, filters: DittoFilter[]): Subscription { debug('sub', id, JSON.stringify(filters)); let subs = this.#store.get(socket); diff --git a/src/subscription.ts b/src/subscription.ts index ec17dc4..0a3c820 100644 --- a/src/subscription.ts +++ b/src/subscription.ts @@ -1,17 +1,18 @@ -import { type Event, Machina } from '@/deps.ts'; -import { type DittoFilter, matchDittoFilters } from '@/filter.ts'; -import { type DittoEvent } from '@/storages/types.ts'; +import { Machina, type NostrEvent } from '@/deps.ts'; +import { matchDittoFilters } from '@/filter.ts'; +import { type DittoFilter } from '@/interfaces/DittoFilter.ts'; +import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; -class Subscription implements AsyncIterable> { - filters: DittoFilter[]; - #machina: Machina>; +class Subscription implements AsyncIterable { + filters: DittoFilter[]; + #machina: Machina; - constructor(filters: DittoFilter[]) { + constructor(filters: DittoFilter[]) { this.filters = filters; this.#machina = new Machina(); } - stream(event: Event): void { + stream(event: NostrEvent): void { this.#machina.push(event); } diff --git a/src/tags.ts b/src/tags.ts index 6027808..a683393 100644 --- a/src/tags.ts +++ b/src/tags.ts @@ -31,4 +31,12 @@ function addTag(tags: readonly string[][], tag: string[]): string[][] { } } -export { addTag, deleteTag, getTagSet, hasTag }; +const isReplyTag = (tag: string[]) => tag[0] === 'e' && tag[3] === 'reply'; +const isRootTag = (tag: string[]) => tag[0] === 'e' && tag[3] === 'root'; +const isLegacyReplyTag = (tag: string[]) => tag[0] === 'e' && !tag[3]; + +function findReplyTag(tags: string[][]) { + return tags.find(isReplyTag) || tags.find(isRootTag) || tags.findLast(isLegacyReplyTag); +} + +export { addTag, deleteTag, findReplyTag, getTagSet, hasTag }; diff --git a/src/utils.ts b/src/utils.ts index 936de92..0fcd920 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,4 @@ -import { type Event, type EventTemplate, getEventHash, nip19, z } from '@/deps.ts'; +import { type EventTemplate, getEventHash, nip19, type NostrEvent, z } from '@/deps.ts'; import { getAuthor } from '@/queries.ts'; import { nip05Cache } from '@/utils/nip05.ts'; import { nostrIdSchema } from '@/schemas/nostr.ts'; @@ -9,7 +9,7 @@ const nostrNow = (): number => Math.floor(Date.now() / 1000); const nostrDate = (seconds: number): Date => new Date(seconds * 1000); /** Pass to sort() to sort events by date. */ -const eventDateComparator = (a: Event, b: Event): number => b.created_at - a.created_at; +const eventDateComparator = (a: NostrEvent, b: NostrEvent): number => b.created_at - a.created_at; /** Get pubkey from bech32 string, if applicable. */ function bech32ToPubkey(bech32: string): string | undefined { @@ -56,7 +56,7 @@ function parseNip05(value: string): Nip05 { } /** Resolve a bech32 or NIP-05 identifier to an account. */ -async function lookupAccount(value: string, signal = AbortSignal.timeout(3000)): Promise | undefined> { +async function lookupAccount(value: string, signal = AbortSignal.timeout(3000)): Promise { console.log(`Looking up ${value}`); const pubkey = bech32ToPubkey(value) || @@ -68,7 +68,7 @@ async function lookupAccount(value: string, signal = AbortSignal.timeout(3000)): } /** Return the event's age in milliseconds. */ -function eventAge(event: Event): number { +function eventAge(event: NostrEvent): number { return Date.now() - nostrDate(event.created_at).getTime(); } @@ -97,7 +97,7 @@ const relaySchema = z.string().max(255).startsWith('wss://').url(); const isRelay = (relay: string): relay is `wss://${string}` => relaySchema.safeParse(relay).success; /** Deduplicate events by ID. */ -function dedupeEvents(events: Event[]): Event[] { +function dedupeEvents(events: NostrEvent[]): NostrEvent[] { return [...new Map(events.map((event) => [event.id, event])).values()]; } @@ -111,7 +111,7 @@ function stripTags(event: E, tags: string[] = []): E { } /** Ensure the template and event match on their shared keys. */ -function eventMatchesTemplate(event: Event, template: EventTemplate): boolean { +function eventMatchesTemplate(event: NostrEvent, template: EventTemplate): boolean { const whitelist = ['nonce']; event = stripTags(event, whitelist); diff --git a/src/utils/api.ts b/src/utils/api.ts index 0e8ae8b..1768bb3 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -3,10 +3,10 @@ import { Conf } from '@/config.ts'; import { type Context, Debug, - type Event, EventTemplate, - Filter, HTTPException, + type NostrEvent, + NostrFilter, parseFormData, type TypeFest, z, @@ -19,10 +19,10 @@ import { nostrNow } from '@/utils.ts'; const debug = Debug('ditto:api'); /** EventTemplate with defaults. */ -type EventStub = TypeFest.SetOptional, 'content' | 'created_at' | 'tags'>; +type EventStub = TypeFest.SetOptional; /** Publish an event through the pipeline. */ -async function createEvent(t: EventStub, c: AppContext): Promise> { +async function createEvent(t: EventStub, c: AppContext): Promise { const pubkey = c.get('pubkey'); if (!pubkey) { @@ -40,27 +40,27 @@ async function createEvent(t: EventStub, c: AppContext): Pr } /** Filter for fetching an existing event to update. */ -interface UpdateEventFilter extends Filter { - kinds: [K]; +interface UpdateEventFilter extends NostrFilter { + kinds: [number]; limit?: 1; } /** Fetch existing event, update it, then publish the new event. */ -async function updateEvent>( - filter: UpdateEventFilter, - fn: (prev: Event | undefined) => E, +async function updateEvent( + filter: UpdateEventFilter, + fn: (prev: NostrEvent | undefined) => E, c: AppContext, -): Promise> { +): Promise { const [prev] = await eventsDB.filter([filter], { limit: 1 }); return createEvent(fn(prev), c); } /** Fetch existing event, update its tags, then publish the new event. */ -function updateListEvent( - filter: UpdateEventFilter, +function updateListEvent( + filter: UpdateEventFilter, fn: (tags: string[][]) => string[][], c: AppContext, -): Promise> { +): Promise { return updateEvent(filter, (prev) => ({ kind: filter.kinds[0], content: prev?.content ?? '', @@ -69,7 +69,7 @@ function updateListEvent( } /** Publish an admin event through the pipeline. */ -async function createAdminEvent(t: EventStub, c: AppContext): Promise> { +async function createAdminEvent(t: EventStub, c: AppContext): Promise { const event = await signAdminEvent({ content: '', created_at: nostrNow(), @@ -81,7 +81,7 @@ async function createAdminEvent(t: EventStub, c: AppContext } /** Push the event through the pipeline, rethrowing any RelayError. */ -async function publishEvent(event: Event, c: AppContext): Promise> { +async function publishEvent(event: NostrEvent, c: AppContext): Promise { debug('EVENT', event); try { await pipeline.handleEvent(event); @@ -118,7 +118,7 @@ const paginationSchema = z.object({ type PaginationParams = z.infer; /** Build HTTP Link header for Mastodon API pagination. */ -function buildLinkHeader(url: string, events: Event[]): string | undefined { +function buildLinkHeader(url: string, events: NostrEvent[]): string | undefined { if (events.length <= 1) return; const firstEvent = events[0]; const lastEvent = events[events.length - 1]; @@ -138,7 +138,7 @@ type Entity = { id: string }; type HeaderRecord = Record; /** Return results with pagination headers. Assumes chronological sorting of events. */ -function paginated(c: AppContext, events: Event[], entities: (Entity | undefined)[], headers: HeaderRecord = {}) { +function paginated(c: AppContext, events: NostrEvent[], entities: (Entity | undefined)[], headers: HeaderRecord = {}) { const link = buildLinkHeader(c.req.url, events); if (link) { diff --git a/src/utils/event-set.test.ts b/src/utils/event-set.test.ts deleted file mode 100644 index b6e26b9..0000000 --- a/src/utils/event-set.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { assertEquals } from '@/deps-test.ts'; - -import { EventSet } from './event-set.ts'; - -Deno.test('EventSet', () => { - const set = new EventSet(); - assertEquals(set.size, 0); - - const event = { id: '1', kind: 0, pubkey: 'abc', content: '', created_at: 0, sig: '', tags: [] }; - set.add(event); - assertEquals(set.size, 1); - assertEquals(set.has(event), true); - - set.add(event); - assertEquals(set.size, 1); - assertEquals(set.has(event), true); - - set.delete(event); - assertEquals(set.size, 0); - assertEquals(set.has(event), false); - - set.delete(event); - assertEquals(set.size, 0); - assertEquals(set.has(event), false); - - set.add(event); - assertEquals(set.size, 1); - assertEquals(set.has(event), true); - - set.clear(); - assertEquals(set.size, 0); - assertEquals(set.has(event), false); -}); - -Deno.test('EventSet.add (replaceable)', () => { - const event0 = { id: '1', kind: 0, pubkey: 'abc', content: '', created_at: 0, sig: '', tags: [] }; - const event1 = { id: '2', kind: 0, pubkey: 'abc', content: '', created_at: 1, sig: '', tags: [] }; - const event2 = { id: '3', kind: 0, pubkey: 'abc', content: '', created_at: 2, sig: '', tags: [] }; - - const set = new EventSet(); - set.add(event0); - assertEquals(set.size, 1); - assertEquals(set.has(event0), true); - - set.add(event1); - assertEquals(set.size, 1); - assertEquals(set.has(event0), false); - assertEquals(set.has(event1), true); - - set.add(event2); - assertEquals(set.size, 1); - assertEquals(set.has(event0), false); - assertEquals(set.has(event1), false); - assertEquals(set.has(event2), true); -}); - -Deno.test('EventSet.add (parameterized)', () => { - const event0 = { id: '1', kind: 30000, pubkey: 'abc', content: '', created_at: 0, sig: '', tags: [['d', 'a']] }; - const event1 = { id: '2', kind: 30000, pubkey: 'abc', content: '', created_at: 1, sig: '', tags: [['d', 'a']] }; - const event2 = { id: '3', kind: 30000, pubkey: 'abc', content: '', created_at: 2, sig: '', tags: [['d', 'a']] }; - - const set = new EventSet(); - set.add(event0); - assertEquals(set.size, 1); - assertEquals(set.has(event0), true); - - set.add(event1); - assertEquals(set.size, 1); - assertEquals(set.has(event0), false); - assertEquals(set.has(event1), true); - - set.add(event2); - assertEquals(set.size, 1); - assertEquals(set.has(event0), false); - assertEquals(set.has(event1), false); - assertEquals(set.has(event2), true); -}); - -Deno.test('EventSet.eventReplaces', () => { - const event0 = { id: '1', kind: 0, pubkey: 'abc', content: '', created_at: 0, sig: '', tags: [] }; - const event1 = { id: '2', kind: 0, pubkey: 'abc', content: '', created_at: 1, sig: '', tags: [] }; - const event2 = { id: '3', kind: 0, pubkey: 'abc', content: '', created_at: 2, sig: '', tags: [] }; - const event3 = { id: '4', kind: 0, pubkey: 'def', content: '', created_at: 0, sig: '', tags: [] }; - - assertEquals(EventSet.eventReplaces(event1, event0), true); - assertEquals(EventSet.eventReplaces(event2, event0), true); - assertEquals(EventSet.eventReplaces(event2, event1), true); - - assertEquals(EventSet.eventReplaces(event0, event1), false); - assertEquals(EventSet.eventReplaces(event0, event2), false); - assertEquals(EventSet.eventReplaces(event1, event2), false); - - assertEquals(EventSet.eventReplaces(event3, event1), false); - assertEquals(EventSet.eventReplaces(event1, event3), false); -}); - -Deno.test('EventSet.eventReplaces (parameterized)', () => { - const event0 = { id: '1', kind: 30000, pubkey: 'abc', content: '', created_at: 0, sig: '', tags: [['d', 'a']] }; - const event1 = { id: '2', kind: 30000, pubkey: 'abc', content: '', created_at: 1, sig: '', tags: [['d', 'a']] }; - const event2 = { id: '3', kind: 30000, pubkey: 'abc', content: '', created_at: 2, sig: '', tags: [['d', 'a']] }; - - assertEquals(EventSet.eventReplaces(event1, event0), true); - assertEquals(EventSet.eventReplaces(event2, event0), true); - assertEquals(EventSet.eventReplaces(event2, event1), true); - - assertEquals(EventSet.eventReplaces(event0, event1), false); - assertEquals(EventSet.eventReplaces(event0, event2), false); - assertEquals(EventSet.eventReplaces(event1, event2), false); -}); diff --git a/src/utils/event-set.ts b/src/utils/event-set.ts deleted file mode 100644 index 3fd06f0..0000000 --- a/src/utils/event-set.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { type Event } from '@/deps.ts'; -import { isParameterizedReplaceableKind, isReplaceableKind } from '@/kinds.ts'; - -/** In-memory store for Nostr events with replaceable event functionality. */ -class EventSet implements Set { - #map = new Map(); - - get size() { - return this.#map.size; - } - - add(event: E): this { - if (isReplaceableKind(event.kind) || isParameterizedReplaceableKind(event.kind)) { - for (const e of this.values()) { - if (EventSet.eventReplaces(event, e)) { - this.delete(e); - } - } - } - this.#map.set(event.id, event); - return this; - } - - clear(): void { - this.#map.clear(); - } - - delete(event: E): boolean { - return this.#map.delete(event.id); - } - - forEach(callbackfn: (event: E, key: E, set: Set) => void, thisArg?: any): void { - return this.#map.forEach((event, _id) => callbackfn(event, event, this), thisArg); - } - - has(event: E): boolean { - return this.#map.has(event.id); - } - - *entries(): IterableIterator<[E, E]> { - for (const event of this.#map.values()) { - yield [event, event]; - } - } - - keys(): IterableIterator { - return this.#map.values(); - } - - values(): IterableIterator { - return this.#map.values(); - } - - [Symbol.iterator](): IterableIterator { - return this.#map.values(); - } - - [Symbol.toStringTag]: string = 'EventSet'; - - /** Returns true if both events are replaceable, belong to the same kind and pubkey (and `d` tag, for parameterized events), and the first event is newer than the second one. */ - static eventReplaces(event: Event, target: Event): boolean { - if (isReplaceableKind(event.kind)) { - return event.kind === target.kind && event.pubkey === target.pubkey && event.created_at > target.created_at; - } else if (isParameterizedReplaceableKind(event.kind)) { - const d = event.tags.find(([name]) => name === 'd')?.[1] || ''; - const d2 = target.tags.find(([name]) => name === 'd')?.[1] || ''; - - return event.kind === target.kind && - event.pubkey === target.pubkey && - d === d2 && - event.created_at > target.created_at; - } - return false; - } -} - -export { EventSet }; diff --git a/src/utils/nip98.ts b/src/utils/nip98.ts index bd7e673..0b83283 100644 --- a/src/utils/nip98.ts +++ b/src/utils/nip98.ts @@ -1,4 +1,4 @@ -import { type Event, type EventTemplate, nip13, type VerifiedEvent } from '@/deps.ts'; +import { type EventTemplate, nip13, type NostrEvent } from '@/deps.ts'; import { decode64Schema, jsonSchema } from '@/schema.ts'; import { signedEventSchema } from '@/schemas/nostr.ts'; import { eventAge, findTag, nostrNow, sha256 } from '@/utils.ts'; @@ -28,18 +28,18 @@ async function parseAuthRequest(req: Request, opts: ParseAuthRequestOpts = {}) { } /** Compare the auth event with the request, returning a zod SafeParse type. */ -function validateAuthEvent(req: Request, event: Event, opts: ParseAuthRequestOpts = {}) { +function validateAuthEvent(req: Request, event: NostrEvent, opts: ParseAuthRequestOpts = {}) { const { maxAge = Time.minutes(1), validatePayload = true, pow = 0 } = opts; const schema = signedEventSchema - .refine((event): event is VerifiedEvent<27235> => event.kind === 27235, 'Event must be kind 27235') + .refine((event) => event.kind === 27235, 'Event must be kind 27235') .refine((event) => eventAge(event) < maxAge, 'Event expired') .refine((event) => tagValue(event, 'method') === req.method, 'Event method does not match HTTP request method') .refine((event) => tagValue(event, 'u') === req.url, 'Event URL does not match request URL') .refine((event) => pow ? nip13.getPow(event.id) >= pow : true, 'Insufficient proof of work') .refine(validateBody, 'Event payload does not match request body'); - function validateBody(event: Event<27235>) { + function validateBody(event: NostrEvent) { if (!validatePayload) return true; return req.clone().text() .then(sha256) @@ -73,7 +73,7 @@ async function buildAuthEventTemplate(req: Request, opts: ParseAuthRequestOpts = } /** Get the value for the first matching tag name in the event. */ -function tagValue(event: Event, tagName: string): string | undefined { +function tagValue(event: NostrEvent, tagName: string): string | undefined { return findTag(event.tags, tagName)?.[1]; } diff --git a/src/views.ts b/src/views.ts index 4995c82..19267a9 100644 --- a/src/views.ts +++ b/src/views.ts @@ -1,12 +1,12 @@ import { AppContext } from '@/app.ts'; -import { type Filter } from '@/deps.ts'; +import { type NostrFilter } from '@/deps.ts'; import { eventsDB } from '@/storages.ts'; import { renderAccount } from '@/views/mastodon/accounts.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts'; import { paginated, paginationSchema } from '@/utils/api.ts'; /** Render account objects for the author of each event. */ -async function renderEventAccounts(c: AppContext, filters: Filter[], signal = AbortSignal.timeout(1000)) { +async function renderEventAccounts(c: AppContext, filters: NostrFilter[], signal = AbortSignal.timeout(1000)) { if (!filters.length) { return c.json([]); } diff --git a/src/views/activitypub/actor.ts b/src/views/activitypub/actor.ts index 1f2f679..648d95c 100644 --- a/src/views/activitypub/actor.ts +++ b/src/views/activitypub/actor.ts @@ -2,11 +2,11 @@ import { Conf } from '@/config.ts'; import { jsonMetaContentSchema } from '@/schemas/nostr.ts'; import { getPublicKeyPem } from '@/utils/rsa.ts'; -import type { Event } from '@/deps.ts'; +import type { NostrEvent } from '@/deps.ts'; import type { Actor } from '@/schemas/activitypub.ts'; /** Nostr metadata event to ActivityPub actor. */ -async function renderActor(event: Event<0>, username: string): Promise { +async function renderActor(event: NostrEvent, username: string): Promise { const content = jsonMetaContentSchema.parse(event.content); return { diff --git a/src/views/mastodon/accounts.ts b/src/views/mastodon/accounts.ts index 54a9121..b3a985f 100644 --- a/src/views/mastodon/accounts.ts +++ b/src/views/mastodon/accounts.ts @@ -1,8 +1,8 @@ import { Conf } from '@/config.ts'; import { findUser } from '@/db/users.ts'; import { lodash, nip19, type UnsignedEvent } from '@/deps.ts'; +import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; import { jsonMetaContentSchema } from '@/schemas/nostr.ts'; -import { type DittoEvent } from '@/storages/types.ts'; import { getLnurl } from '@/utils/lnurl.ts'; import { nip05Cache } from '@/utils/nip05.ts'; import { Nip05, nostrDate, nostrNow, parseNip05 } from '@/utils.ts'; @@ -13,7 +13,7 @@ interface ToAccountOpts { } async function renderAccount( - event: Omit, 'id' | 'sig'>, + event: Omit, opts: ToAccountOpts = {}, ) { const { withSource = false } = opts; @@ -81,7 +81,7 @@ async function renderAccount( } function accountFromPubkey(pubkey: string, opts: ToAccountOpts = {}) { - const event: UnsignedEvent<0> = { + const event: UnsignedEvent = { kind: 0, pubkey, content: '', diff --git a/src/views/mastodon/admin-accounts.ts b/src/views/mastodon/admin-accounts.ts index 0a73a99..7914776 100644 --- a/src/views/mastodon/admin-accounts.ts +++ b/src/views/mastodon/admin-accounts.ts @@ -1,9 +1,9 @@ -import { DittoEvent } from '@/storages/types.ts'; +import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; import { nostrDate } from '@/utils.ts'; import { accountFromPubkey, renderAccount } from './accounts.ts'; -async function renderAdminAccount(event: DittoEvent<30361>) { +async function renderAdminAccount(event: DittoEvent) { const d = event.tags.find(([name]) => name === 'd')?.[1]!; const account = event.d_author ? await renderAccount({ ...event.d_author, user: event }) : await accountFromPubkey(d); diff --git a/src/views/mastodon/notifications.ts b/src/views/mastodon/notifications.ts index 0e6b3c4..8ec7bf4 100644 --- a/src/views/mastodon/notifications.ts +++ b/src/views/mastodon/notifications.ts @@ -1,17 +1,17 @@ -import { type Event } from '@/deps.ts'; +import { type NostrEvent } from '@/deps.ts'; import { getAuthor } from '@/queries.ts'; import { nostrDate } from '@/utils.ts'; import { accountFromPubkey } from '@/views/mastodon/accounts.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts'; -function renderNotification(event: Event, viewerPubkey?: string) { +function renderNotification(event: NostrEvent, viewerPubkey?: string) { switch (event.kind) { case 1: - return renderNotificationMention(event as Event<1>, viewerPubkey); + return renderNotificationMention(event, viewerPubkey); } } -async function renderNotificationMention(event: Event<1>, viewerPubkey?: string) { +async function renderNotificationMention(event: NostrEvent, viewerPubkey?: string) { const author = await getAuthor(event.pubkey); const status = await renderStatus({ ...event, author }, viewerPubkey); if (!status) return; diff --git a/src/views/mastodon/statuses.ts b/src/views/mastodon/statuses.ts index 611dc48..e582a00 100644 --- a/src/views/mastodon/statuses.ts +++ b/src/views/mastodon/statuses.ts @@ -1,24 +1,25 @@ import { isCWTag } from 'https://gitlab.com/soapbox-pub/mostr/-/raw/c67064aee5ade5e01597c6d23e22e53c628ef0e2/src/nostr/tags.ts'; import { Conf } from '@/config.ts'; -import { findReplyTag, nip19 } from '@/deps.ts'; +import { nip19 } from '@/deps.ts'; +import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; import { getMediaLinks, parseNoteContent } from '@/note.ts'; import { getAuthor } from '@/queries.ts'; import { jsonMediaDataSchema } from '@/schemas/nostr.ts'; import { eventsDB } from '@/storages.ts'; -import { type DittoEvent } from '@/storages/types.ts'; +import { findReplyTag } from '@/tags.ts'; import { nostrDate } from '@/utils.ts'; import { unfurlCardCached } from '@/utils/unfurl.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { DittoAttachment, renderAttachment } from '@/views/mastodon/attachments.ts'; import { renderEmojis } from '@/views/mastodon/emojis.ts'; -async function renderStatus(event: DittoEvent<1>, viewerPubkey?: string) { +async function renderStatus(event: DittoEvent, viewerPubkey?: string) { const account = event.author ? await renderAccount({ ...event.author, author_stats: event.author_stats }) : await accountFromPubkey(event.pubkey); - const replyTag = findReplyTag(event); + const replyTag = findReplyTag(event.tags); const mentionedPubkeys = [ ...new Set( diff --git a/src/workers/verify.ts b/src/workers/verify.ts index 39f49e2..1f71322 100644 --- a/src/workers/verify.ts +++ b/src/workers/verify.ts @@ -1,4 +1,4 @@ -import { Comlink, type Event } from '@/deps.ts'; +import { Comlink, type NostrEvent } from '@/deps.ts'; import type { VerifyWorker } from './verify.worker.ts'; @@ -6,7 +6,7 @@ const worker = Comlink.wrap( new Worker(new URL('./verify.worker.ts', import.meta.url), { type: 'module' }), ); -function verifySignatureWorker(event: Event): Promise { +function verifySignatureWorker(event: NostrEvent): Promise { return worker.verifySignature(event); } diff --git a/src/workers/verify.worker.ts b/src/workers/verify.worker.ts index b9092a4..32ef126 100644 --- a/src/workers/verify.worker.ts +++ b/src/workers/verify.worker.ts @@ -1,7 +1,7 @@ -import { Comlink, type Event, type VerifiedEvent, verifySignature } from '@/deps.ts'; +import { Comlink, type NostrEvent, type VerifiedEvent, verifySignature } from '@/deps.ts'; export const VerifyWorker = { - verifySignature(event: Event): event is VerifiedEvent { + verifySignature(event: NostrEvent): event is VerifiedEvent { return verifySignature(event); }, }; From 0b6874bb44cec6dd95343bbd4f5bdfe669004483 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 23 Jan 2024 12:12:34 -0600 Subject: [PATCH 3/4] EventsDB: normalize the event to only NIP-01 event properties --- src/storages/events-db.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/storages/events-db.ts b/src/storages/events-db.ts index 025b503..4467e37 100644 --- a/src/storages/events-db.ts +++ b/src/storages/events-db.ts @@ -68,6 +68,7 @@ class EventsDB implements EventStore { /** Insert an event (and its tags) into the database. */ async add(event: NostrEvent): Promise { + event = cloneEvent(event); this.#debug('EVENT', JSON.stringify(event)); if (isDittoInternalKind(event.kind) && event.pubkey !== Conf.pubkey) { @@ -408,6 +409,19 @@ function buildSearchContent(event: NostrEvent): string { } } +/** Return a normalized event without any non-standard keys. */ +function cloneEvent(event: NostrEvent): NostrEvent { + return { + id: event.id, + pubkey: event.pubkey, + kind: event.kind, + content: event.content, + tags: event.tags, + sig: event.sig, + created_at: event.created_at, + }; +} + /** Build search content for a user. */ function buildUserSearchContent(event: NostrEvent): string { const { name, nip05, about } = jsonMetaContentSchema.parse(event.content); From 67a52c3b7d75e49d8c62632825e84e0f3cec2d9b Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 23 Jan 2024 12:15:48 -0600 Subject: [PATCH 4/4] Clean event before publishing --- src/events.ts | 16 ++++++++++++++++ src/storages/events-db.ts | 16 ++-------------- src/storages/pool-store.ts | 2 ++ 3 files changed, 20 insertions(+), 14 deletions(-) create mode 100644 src/events.ts diff --git a/src/events.ts b/src/events.ts new file mode 100644 index 0000000..6bdb404 --- /dev/null +++ b/src/events.ts @@ -0,0 +1,16 @@ +import { type NostrEvent } from '@/deps.ts'; + +/** Return a normalized event without any non-standard keys. */ +function cleanEvent(event: NostrEvent): NostrEvent { + return { + id: event.id, + pubkey: event.pubkey, + kind: event.kind, + content: event.content, + tags: event.tags, + sig: event.sig, + created_at: event.created_at, + }; +} + +export { cleanEvent }; diff --git a/src/storages/events-db.ts b/src/storages/events-db.ts index 4467e37..2413af9 100644 --- a/src/storages/events-db.ts +++ b/src/storages/events-db.ts @@ -1,6 +1,7 @@ import { Conf } from '@/config.ts'; import { type DittoDB } from '@/db.ts'; import { Debug, Kysely, type NostrEvent, type SelectQueryBuilder } from '@/deps.ts'; +import { cleanEvent } from '@/events.ts'; import { normalizeFilters } from '@/filter.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { type DittoFilter } from '@/interfaces/DittoFilter.ts'; @@ -68,7 +69,7 @@ class EventsDB implements EventStore { /** Insert an event (and its tags) into the database. */ async add(event: NostrEvent): Promise { - event = cloneEvent(event); + event = cleanEvent(event); this.#debug('EVENT', JSON.stringify(event)); if (isDittoInternalKind(event.kind) && event.pubkey !== Conf.pubkey) { @@ -409,19 +410,6 @@ function buildSearchContent(event: NostrEvent): string { } } -/** Return a normalized event without any non-standard keys. */ -function cloneEvent(event: NostrEvent): NostrEvent { - return { - id: event.id, - pubkey: event.pubkey, - kind: event.kind, - content: event.content, - tags: event.tags, - sig: event.sig, - created_at: event.created_at, - }; -} - /** Build search content for a user. */ function buildUserSearchContent(event: NostrEvent): string { const { name, nip05, about } = jsonMetaContentSchema.parse(event.content); diff --git a/src/storages/pool-store.ts b/src/storages/pool-store.ts index d6d9e12..001778c 100644 --- a/src/storages/pool-store.ts +++ b/src/storages/pool-store.ts @@ -1,4 +1,5 @@ import { Debug, matchFilters, type NostrEvent, type NostrFilter, NSet, type RelayPoolWorker } from '@/deps.ts'; +import { cleanEvent } from '@/events.ts'; import { normalizeFilters } from '@/filter.ts'; import { type EventStore, type GetEventsOpts, type StoreEventOpts } from '@/storages/types.ts'; @@ -28,6 +29,7 @@ class PoolStore implements EventStore { add(event: NostrEvent, opts: StoreEventOpts = {}): Promise { const { relays = this.#relays } = opts; + event = cleanEvent(event); this.#debug('EVENT', event); this.#pool.publish(event, relays); return Promise.resolve();