diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index ab6c748..d065493 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -16,4 +16,6 @@ lint: test: stage: test - script: deno task test \ No newline at end of file + script: deno task test + variables: + DITTO_NSEC: nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqwkhnav \ No newline at end of file diff --git a/src/controllers/nostr/relay-info.ts b/src/controllers/nostr/relay-info.ts index 0edafbd..9c16cdd 100644 --- a/src/controllers/nostr/relay-info.ts +++ b/src/controllers/nostr/relay-info.ts @@ -7,7 +7,7 @@ const relayInfoController: AppController = (c) => { description: 'Nostr and the Fediverse.', pubkey: Conf.pubkey, contact: `mailto:${Conf.adminEmail}`, - supported_nips: [1, 5, 9, 11, 45, 46, 98], + supported_nips: [1, 5, 9, 11, 16, 45, 46, 98], software: 'Ditto', version: '0.0.0', limitation: { diff --git a/src/db/events.test.ts b/src/db/events.test.ts index bb3f6f4..7a5537e 100644 --- a/src/db/events.test.ts +++ b/src/db/events.test.ts @@ -1,6 +1,7 @@ -import { assertEquals } from '@/deps-test.ts'; -import { insertUser } from '@/db/users.ts'; +import { assertEquals, assertRejects } from '@/deps-test.ts'; +import { buildUserEvent } from '@/db/users.ts'; +import event0 from '~/fixtures/events/event-0.json' assert { type: 'json' }; import event1 from '~/fixtures/events/event-1.json' assert { type: 'json' }; import { eventsDB as db } from './events.ts'; @@ -38,13 +39,26 @@ Deno.test('query events with local filter', async () => { assertEquals(await db.getEvents([{ local: true }]), []); assertEquals(await db.getEvents([{ local: false }]), [event1]); - await insertUser({ + const userEvent = await buildUserEvent({ username: 'alex', pubkey: event1.pubkey, inserted_at: new Date(), admin: false, }); + await db.storeEvent(userEvent); assertEquals(await db.getEvents([{ kinds: [1], local: true }]), [event1]); assertEquals(await db.getEvents([{ kinds: [1], local: false }]), []); }); + +Deno.test('inserting replaceable events', async () => { + assertEquals(await db.countEvents([{ kinds: [0], authors: [event0.pubkey] }]), 0); + + await db.storeEvent(event0); + await assertRejects(() => db.storeEvent(event0)); + assertEquals(await db.countEvents([{ kinds: [0], authors: [event0.pubkey] }]), 1); + + const changeEvent = { ...event0, id: '123', created_at: event0.created_at + 1 }; + await db.storeEvent(changeEvent); + assertEquals(await db.getEvents([{ kinds: [0] }]), [changeEvent]); +}); diff --git a/src/db/events.ts b/src/db/events.ts index 1847d6b..a20c650 100644 --- a/src/db/events.ts +++ b/src/db/events.ts @@ -1,8 +1,8 @@ import { Conf } from '@/config.ts'; import { db, type DittoDB } from '@/db.ts'; -import { Debug, type Event, type SelectQueryBuilder } from '@/deps.ts'; +import { Debug, type Event, Kysely, type SelectQueryBuilder } from '@/deps.ts'; import { type DittoFilter } from '@/filter.ts'; -import { isDittoInternalKind, isParameterizedReplaceableKind } from '@/kinds.ts'; +import { isDittoInternalKind, isParameterizedReplaceableKind, isReplaceableKind } from '@/kinds.ts'; import { jsonMetaContentSchema } from '@/schemas/nostr.ts'; import { type DittoEvent, EventStore, type GetEventsOpts, type StoreEventOpts } from '@/store.ts'; import { isNostrId, isURL } from '@/utils.ts'; @@ -31,14 +31,14 @@ const tagConditions: Record = { }; /** Insert an event (and its tags) into the database. */ -function storeEvent(event: Event, opts: StoreEventOpts = {}): Promise { +async function storeEvent(event: Event, opts: StoreEventOpts = {}): Promise { debug('EVENT', JSON.stringify(event)); if (isDittoInternalKind(event.kind) && event.pubkey !== Conf.pubkey) { throw new Error('Internal events can only be stored by the server keypair'); } - return db.transaction().execute(async (trx) => { + return await db.transaction().execute(async (trx) => { /** Insert the event into the database. */ async function addEvent() { await trx.insertInto('events') @@ -66,6 +66,30 @@ function storeEvent(event: Event, opts: StoreEventOpts = {}): Promise { .execute(); } + if (isReplaceableKind(event.kind)) { + const prevEvents = await getFilterQuery(trx, { kinds: [event.kind], authors: [event.pubkey] }).execute(); + for (const prevEvent of prevEvents) { + if (prevEvent.created_at >= event.created_at) { + throw new Error('Cannot replace an event with an older event'); + } + } + await deleteEventsTrx(trx, [{ kinds: [event.kind], authors: [event.pubkey] }]); + } + + if (isParameterizedReplaceableKind(event.kind)) { + const d = event.tags.find(([tag]) => tag === 'd')?.[1]; + if (d) { + const prevEvents = await getFilterQuery(trx, { kinds: [event.kind], authors: [event.pubkey], '#d': [d] }) + .execute(); + for (const prevEvent of prevEvents) { + if (prevEvent.created_at >= event.created_at) { + throw new Error('Cannot replace an event with an older event'); + } + } + await deleteEventsTrx(trx, [{ kinds: [event.kind], authors: [event.pubkey], '#d': [d] }]); + } + } + // Run the queries. await Promise.all([ addEvent(), @@ -106,7 +130,7 @@ type EventQuery = SelectQueryBuilder; /** Build the query for a filter. */ -function getFilterQuery(filter: DittoFilter): EventQuery { +function getFilterQuery(db: Kysely, filter: DittoFilter): EventQuery { let query = db .selectFrom('events') .select([ @@ -215,13 +239,13 @@ function getFilterQuery(filter: DittoFilter): EventQuery { /** Combine filter queries into a single union query. */ function getEventsQuery(filters: DittoFilter[]) { return filters - .map((filter) => db.selectFrom(() => getFilterQuery(filter).as('events')).selectAll()) + .map((filter) => db.selectFrom(() => getFilterQuery(db, filter).as('events')).selectAll()) .reduce((result, query) => result.unionAll(query)); } /** Query to get user events, joined by tags. */ function usersQuery() { - return getFilterQuery({ kinds: [30361], authors: [Conf.pubkey] }) + return getFilterQuery(db, { kinds: [30361], authors: [Conf.pubkey] }) .leftJoin('tags', 'tags.event_id', 'events.id') .where('tags.tag', '=', 'd') .select('tags.value as d_tag') @@ -284,22 +308,28 @@ async function getEvents( }); } +/** Delete events from each table. Should be run in a transaction! */ +async function deleteEventsTrx(db: Kysely, filters: DittoFilter[]) { + if (!filters.length) return Promise.resolve(); + debug('DELETE', JSON.stringify(filters)); + + const query = getEventsQuery(filters).clearSelect().select('id'); + + await db.deleteFrom('events_fts') + .where('id', 'in', () => query) + .execute(); + + return db.deleteFrom('events') + .where('id', 'in', () => query) + .execute(); +} + /** Delete events based on filters from the database. */ async function deleteEvents(filters: DittoFilter[]): Promise { if (!filters.length) return Promise.resolve(); debug('DELETE', JSON.stringify(filters)); - await db.transaction().execute(async (trx) => { - const query = getEventsQuery(filters).clearSelect().select('id'); - - await trx.deleteFrom('events_fts') - .where('id', 'in', () => query) - .execute(); - - return trx.deleteFrom('events') - .where('id', 'in', () => query) - .execute(); - }); + await db.transaction().execute((trx) => deleteEventsTrx(trx, filters)); } /** Get number of events that would be returned by filters. */ diff --git a/src/db/users.ts b/src/db/users.ts index 195dfe2..d223b1c 100644 --- a/src/db/users.ts +++ b/src/db/users.ts @@ -13,12 +13,11 @@ interface User { admin: boolean; } -/** Adds a user to the database. */ -async function insertUser(user: User) { +function buildUserEvent(user: User) { debug('insertUser', JSON.stringify(user)); const { origin, host } = Conf.url; - const event = await signAdminEvent({ + return signAdminEvent({ kind: 30361, tags: [ ['d', user.pubkey], @@ -31,7 +30,12 @@ async function insertUser(user: User) { content: '', created_at: Math.floor(user.inserted_at.getTime() / 1000), }); +} +/** Adds a user to the database. */ +async function insertUser(user: User) { + debug('insertUser', JSON.stringify(user)); + const event = await buildUserEvent(user); return pipeline.handleEvent(event); } @@ -71,4 +75,4 @@ async function findUser(user: Partial): Promise { } } -export { findUser, insertUser, type User }; +export { buildUserEvent, findUser, insertUser, type User };