Merge branch 'users-to-events' into 'main'

Convert users to events

See merge request soapbox-pub/ditto!89
This commit is contained in:
Alex Gleason 2023-12-30 06:06:48 +00:00
commit a564a03ee3
15 changed files with 219 additions and 60 deletions

View File

@ -17,3 +17,5 @@ lint:
test: test:
stage: test stage: test
script: deno task test script: deno task test
variables:
DITTO_NSEC: nsec1zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs4rm7hz

View File

@ -1,13 +1,25 @@
# Ditto NIP-78 events # Ditto custom events
## Users
Ditto user events describe a pubkey's relationship with the Ditto server. They are parameterized replaceable events of kind `30361` where the `d` tag is a pubkey. These events are published by Ditto's internal admin keypair.
User events have the following tags:
- `d` - pubkey of the user.
- `name` - NIP-05 username granted to the user, without the domain.
- `role` - one of `admin` or `user`.
## NIP-78
[NIP-78](https://github.com/nostr-protocol/nips/blob/master/78.md) defines events of kind `30078` with a globally unique `d` tag. These events are queried by the `d` tag, which allows Ditto to store custom data on relays. Ditto uses reverse DNS names like `pub.ditto.<thing>` for `d` tags. [NIP-78](https://github.com/nostr-protocol/nips/blob/master/78.md) defines events of kind `30078` with a globally unique `d` tag. These events are queried by the `d` tag, which allows Ditto to store custom data on relays. Ditto uses reverse DNS names like `pub.ditto.<thing>` for `d` tags.
The sections below describe the `content` field. Some are encrypted and some are not, depending on whether the data should be public. Also, some events are user events, and some are admin events. The sections below describe the `content` field. Some are encrypted and some are not, depending on whether the data should be public. Also, some events are user events, and some are admin events.
## `pub.ditto.blocks` ### `pub.ditto.blocks`
An encrypted array of blocked pubkeys, JSON stringified and encrypted with `nip07.encrypt`. An encrypted array of blocked pubkeys, JSON stringified in `content` and encrypted with `nip04.encrypt`.
## `pub.ditto.frontendConfig` ### `pub.ditto.frontendConfig`
JSON data for Pleroma frontends served on `/api/pleroma/frontend_configurations`. Each key contains arbitrary data used by a different frontend. JSON data for Pleroma frontends served on `/api/pleroma/frontend_configurations`. Each key contains arbitrary data used by a different frontend.

44
scripts/db.ts Normal file
View File

@ -0,0 +1,44 @@
import { Conf } from '@/config.ts';
import { db } from '@/db.ts';
import { eventsDB } from '@/db/events.ts';
import { type Kysely } from '@/deps.ts';
import { signAdminEvent } from '@/sign.ts';
interface DB {
users: {
pubkey: string;
username: string;
inserted_at: Date;
admin: 0 | 1;
};
}
switch (Deno.args[0]) {
case 'users-to-events':
await usersToEvents();
break;
default:
console.log('Usage: deno run -A scripts/db.ts <command>');
}
async function usersToEvents() {
const { origin, host } = Conf.url;
for (const row of await (db as unknown as Kysely<DB>).selectFrom('users').selectAll().execute()) {
const event = await signAdminEvent({
kind: 30361,
tags: [
['d', row.pubkey],
['name', row.username],
['role', row.admin ? 'admin' : 'user'],
['origin', origin],
// NIP-31: https://github.com/nostr-protocol/nips/blob/master/31.md
['alt', `@${row.username}@${host}'s account was updated by the admins of ${host}`],
],
content: '',
created_at: Math.floor(new Date(row.inserted_at).getTime() / 1000),
});
await eventsDB.storeEvent(event);
}
}

View File

@ -42,7 +42,7 @@ const createAccountController: AppController = async (c) => {
pubkey, pubkey,
username: result.data.username, username: result.data.username,
inserted_at: new Date(), inserted_at: new Date(),
admin: 0, admin: false,
}); });
return c.json({ return c.json({

View File

@ -7,7 +7,7 @@ const relayInfoController: AppController = (c) => {
description: 'Nostr and the Fediverse.', description: 'Nostr and the Fediverse.',
pubkey: Conf.pubkey, pubkey: Conf.pubkey,
contact: `mailto:${Conf.adminEmail}`, 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', software: 'Ditto',
version: '0.0.0', version: '0.0.0',
limitation: { limitation: {

View File

@ -96,7 +96,7 @@ function connectStream(socket: WebSocket) {
/** Handle COUNT. Return the number of events matching the filters. */ /** Handle COUNT. Return the number of events matching the filters. */
async function handleCount([_, subId, ...rest]: ClientCOUNT): Promise<void> { async function handleCount([_, subId, ...rest]: ClientCOUNT): Promise<void> {
const count = await eventsDB.countFilters(prepareFilters(rest)); const count = await eventsDB.countEvents(prepareFilters(rest));
send(['COUNT', subId, { count, approximate: false }]); send(['COUNT', subId, { count, approximate: false }]);
} }

View File

@ -10,7 +10,6 @@ interface DittoDB {
events: EventRow; events: EventRow;
events_fts: EventFTSRow; events_fts: EventFTSRow;
tags: TagRow; tags: TagRow;
users: UserRow;
relays: RelayRow; relays: RelayRow;
unattached_media: UnattachedMediaRow; unattached_media: UnattachedMediaRow;
author_stats: AuthorStatsRow; author_stats: AuthorStatsRow;
@ -52,13 +51,6 @@ interface TagRow {
event_id: string; event_id: string;
} }
interface UserRow {
pubkey: string;
username: string;
inserted_at: Date;
admin: 0 | 1;
}
interface RelayRow { interface RelayRow {
url: string; url: string;
domain: string; domain: string;
@ -120,4 +112,4 @@ async function migrate() {
await migrate(); await migrate();
export { type AuthorStatsRow, db, type DittoDB, type EventRow, type EventStatsRow, type TagRow, type UserRow }; export { type AuthorStatsRow, db, type DittoDB, type EventRow, type EventStatsRow, type TagRow };

View File

@ -1,6 +1,7 @@
import { assertEquals } from '@/deps-test.ts'; import { assertEquals, assertRejects } from '@/deps-test.ts';
import { insertUser } from '@/db/users.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 event1 from '~/fixtures/events/event-1.json' assert { type: 'json' };
import { eventsDB as db } from './events.ts'; 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: true }]), []);
assertEquals(await db.getEvents([{ local: false }]), [event1]); assertEquals(await db.getEvents([{ local: false }]), [event1]);
await insertUser({ const userEvent = await buildUserEvent({
username: 'alex', username: 'alex',
pubkey: event1.pubkey, pubkey: event1.pubkey,
inserted_at: new Date(), inserted_at: new Date(),
admin: 0, admin: false,
}); });
await db.storeEvent(userEvent);
assertEquals(await db.getEvents([{ local: true }]), [event1]); assertEquals(await db.getEvents([{ kinds: [1], local: true }]), [event1]);
assertEquals(await db.getEvents([{ local: false }]), []); 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]);
}); });

View File

@ -1,7 +1,8 @@
import { Conf } from '@/config.ts';
import { db, type DittoDB } from '@/db.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 { type DittoFilter } from '@/filter.ts';
import { isParameterizedReplaceableKind } from '@/kinds.ts'; import { isDittoInternalKind, isParameterizedReplaceableKind, isReplaceableKind } from '@/kinds.ts';
import { jsonMetaContentSchema } from '@/schemas/nostr.ts'; import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
import { type DittoEvent, EventStore, type GetEventsOpts, type StoreEventOpts } from '@/store.ts'; import { type DittoEvent, EventStore, type GetEventsOpts, type StoreEventOpts } from '@/store.ts';
import { isNostrId, isURL } from '@/utils.ts'; import { isNostrId, isURL } from '@/utils.ts';
@ -25,13 +26,19 @@ const tagConditions: Record<string, TagCondition> = {
'proxy': ({ count, value }) => count === 0 && isURL(value), 'proxy': ({ count, value }) => count === 0 && isURL(value),
'q': ({ event, count, value }) => count === 0 && event.kind === 1 && isNostrId(value), 'q': ({ event, count, value }) => count === 0 && event.kind === 1 && isNostrId(value),
't': ({ count, value }) => count < 5 && value.length < 50, 't': ({ count, value }) => count < 5 && value.length < 50,
'name': ({ event, count }) => event.kind === 30361 && count === 0,
'role': ({ event, count }) => event.kind === 30361 && count === 0,
}; };
/** Insert an event (and its tags) into the database. */ /** Insert an event (and its tags) into the database. */
function storeEvent(event: Event, opts: StoreEventOpts = {}): Promise<void> { async function storeEvent(event: Event, opts: StoreEventOpts = {}): Promise<void> {
debug('EVENT', JSON.stringify(event)); debug('EVENT', JSON.stringify(event));
return db.transaction().execute(async (trx) => { if (isDittoInternalKind(event.kind) && event.pubkey !== Conf.pubkey) {
throw new Error('Internal events can only be stored by the server keypair');
}
return await db.transaction().execute(async (trx) => {
/** Insert the event into the database. */ /** Insert the event into the database. */
async function addEvent() { async function addEvent() {
await trx.insertInto('events') await trx.insertInto('events')
@ -59,6 +66,30 @@ function storeEvent(event: Event, opts: StoreEventOpts = {}): Promise<void> {
.execute(); .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. // Run the queries.
await Promise.all([ await Promise.all([
addEvent(), addEvent(),
@ -99,7 +130,7 @@ type EventQuery = SelectQueryBuilder<DittoDB, 'events', {
}>; }>;
/** Build the query for a filter. */ /** Build the query for a filter. */
function getFilterQuery(filter: DittoFilter): EventQuery { function getFilterQuery(db: Kysely<DittoDB>, filter: DittoFilter): EventQuery {
let query = db let query = db
.selectFrom('events') .selectFrom('events')
.select([ .select([
@ -148,9 +179,9 @@ function getFilterQuery(filter: DittoFilter): EventQuery {
} }
if (typeof filter.local === 'boolean') { if (typeof filter.local === 'boolean') {
query = filter.local query = query
? query.innerJoin('users', 'users.pubkey', 'events.pubkey') as typeof query .leftJoin(usersQuery, (join) => join.onRef('users.d_tag', '=', 'events.pubkey'))
: query.leftJoin('users', 'users.pubkey', 'events.pubkey').where('users.pubkey', 'is', null) as typeof query; .where('users.d_tag', filter.local ? 'is not' : 'is', null);
} }
if (filter.relations?.includes('author')) { if (filter.relations?.includes('author')) {
@ -208,10 +239,19 @@ function getFilterQuery(filter: DittoFilter): EventQuery {
/** Combine filter queries into a single union query. */ /** Combine filter queries into a single union query. */
function getEventsQuery(filters: DittoFilter[]) { function getEventsQuery(filters: DittoFilter[]) {
return filters 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)); .reduce((result, query) => result.unionAll(query));
} }
/** Query to get user events, joined by tags. */
function usersQuery() {
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')
.as('users');
}
/** Get events for filters from the database. */ /** Get events for filters from the database. */
async function getEvents<K extends number>( async function getEvents<K extends number>(
filters: DittoFilter<K>[], filters: DittoFilter<K>[],
@ -268,22 +308,28 @@ async function getEvents<K extends number>(
}); });
} }
/** Delete events from each table. Should be run in a transaction! */
async function deleteEventsTrx(db: Kysely<DittoDB>, 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. */ /** Delete events based on filters from the database. */
async function deleteEvents<K extends number>(filters: DittoFilter<K>[]): Promise<void> { async function deleteEvents<K extends number>(filters: DittoFilter<K>[]): Promise<void> {
if (!filters.length) return Promise.resolve(); if (!filters.length) return Promise.resolve();
debug('DELETE', JSON.stringify(filters)); debug('DELETE', JSON.stringify(filters));
await db.transaction().execute(async (trx) => { await db.transaction().execute((trx) => deleteEventsTrx(trx, filters));
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();
});
} }
/** Get number of events that would be returned by filters. */ /** Get number of events that would be returned by filters. */

View File

@ -0,0 +1,8 @@
import { Kysely } from '@/deps.ts';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('users').execute();
}
export async function down(_db: Kysely<any>): Promise<void> {
}

View File

@ -1,6 +1,8 @@
import { Debug, type Insertable } from '@/deps.ts'; import { Conf } from '@/config.ts';
import { Debug, type Filter } from '@/deps.ts';
import { db, type UserRow } from '../db.ts'; import { eventsDB } from '@/db/events.ts';
import * as pipeline from '@/pipeline.ts';
import { signAdminEvent } from '@/sign.ts';
const debug = Debug('ditto:users'); const debug = Debug('ditto:users');
@ -11,10 +13,30 @@ interface User {
admin: boolean; admin: boolean;
} }
/** Adds a user to the database. */ function buildUserEvent(user: User) {
function insertUser(user: Insertable<UserRow>) {
debug('insertUser', JSON.stringify(user)); debug('insertUser', JSON.stringify(user));
return db.insertInto('users').values(user).execute(); const { origin, host } = Conf.url;
return signAdminEvent({
kind: 30361,
tags: [
['d', user.pubkey],
['name', user.username],
['role', user.admin ? 'admin' : 'user'],
['origin', origin],
// NIP-31: https://github.com/nostr-protocol/nips/blob/master/31.md
['alt', `@${user.username}@${host}'s account was updated by the admins of ${host}`],
],
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);
} }
/** /**
@ -24,21 +46,33 @@ function insertUser(user: Insertable<UserRow>) {
* await findUser({ username: 'alex' }); * await findUser({ username: 'alex' });
* ``` * ```
*/ */
async function findUser(user: Partial<Insertable<UserRow>>): Promise<User | undefined> { async function findUser(user: Partial<User>): Promise<User | undefined> {
let query = db.selectFrom('users').selectAll(); const filter: Filter = { kinds: [30361], authors: [Conf.pubkey], limit: 1 };
for (const [key, value] of Object.entries(user)) { for (const [key, value] of Object.entries(user)) {
query = query.where(key as keyof UserRow, '=', value); switch (key) {
case 'pubkey':
filter['#d'] = [String(value)];
break;
case 'username':
filter['#name'] = [String(value)];
break;
case 'admin':
filter['#role'] = [value ? 'admin' : 'user'];
break;
}
} }
const row = await query.executeTakeFirst(); const [event] = await eventsDB.getEvents([filter]);
if (row) { if (event) {
return { return {
...row, pubkey: event.tags.find(([name]) => name === 'd')?.[1]!,
admin: row.admin === 1, username: event.tags.find(([name]) => name === 'name')?.[1]!,
inserted_at: new Date(event.created_at * 1000),
admin: event.tags.find(([name]) => name === 'role')?.[1] === 'admin',
}; };
} }
} }
export { findUser, insertUser, type User }; export { buildUserEvent, findUser, insertUser, type User };

View File

@ -18,6 +18,11 @@ function isParameterizedReplaceableKind(kind: number) {
return 30000 <= kind && kind < 40000; return 30000 <= kind && kind < 40000;
} }
/** These events are only valid if published by the server keypair. */
function isDittoInternalKind(kind: number) {
return kind === 30361;
}
/** Classification of the event kind. */ /** Classification of the event kind. */
type KindClassification = 'regular' | 'replaceable' | 'ephemeral' | 'parameterized' | 'unknown'; type KindClassification = 'regular' | 'replaceable' | 'ephemeral' | 'parameterized' | 'unknown';
@ -32,6 +37,7 @@ function classifyKind(kind: number): KindClassification {
export { export {
classifyKind, classifyKind,
isDittoInternalKind,
isEphemeralKind, isEphemeralKind,
isParameterizedReplaceableKind, isParameterizedReplaceableKind,
isRegularKind, isRegularKind,

View File

@ -71,7 +71,7 @@ async function storeEvent(event: Event, data: EventData, opts: StoreEventOpts =
if (force || data.user || isAdminEvent(event) || await isLocallyFollowed(event.pubkey)) { if (force || data.user || isAdminEvent(event) || await isLocallyFollowed(event.pubkey)) {
const [deletion] = await eventsDB.getEvents( const [deletion] = await eventsDB.getEvents(
[{ kinds: [5], authors: [event.pubkey], '#e': [event.id], limit: 1 }], [{ kinds: [5], authors: [event.pubkey], '#e': [event.id], limit: 1 }],
{ limit: 1, signal: AbortSignal.timeout(Time.seconds(1)) }, { limit: 1 },
); );
if (deletion) { if (deletion) {

View File

@ -1,9 +1,9 @@
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { type DittoEvent } from '@/db/events.ts';
import { findUser } from '@/db/users.ts'; import { findUser } from '@/db/users.ts';
import { lodash, nip19, type UnsignedEvent } from '@/deps.ts'; import { lodash, nip19, type UnsignedEvent } from '@/deps.ts';
import { jsonMetaContentSchema } from '@/schemas/nostr.ts'; import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
import { verifyNip05Cached } from '@/utils/nip05.ts'; import { verifyNip05Cached } from '@/utils/nip05.ts';
import { type DittoEvent } from '@/store.ts';
import { Nip05, nostrDate, nostrNow, parseNip05 } from '@/utils.ts'; import { Nip05, nostrDate, nostrNow, parseNip05 } from '@/utils.ts';
import { renderEmojis } from '@/views/mastodon/emojis.ts'; import { renderEmojis } from '@/views/mastodon/emojis.ts';

View File

@ -6,13 +6,14 @@ import { findReplyTag, nip19 } from '@/deps.ts';
import { getMediaLinks, parseNoteContent } from '@/note.ts'; import { getMediaLinks, parseNoteContent } from '@/note.ts';
import { getAuthor } from '@/queries.ts'; import { getAuthor } from '@/queries.ts';
import { jsonMediaDataSchema } from '@/schemas/nostr.ts'; import { jsonMediaDataSchema } from '@/schemas/nostr.ts';
import { DittoEvent } from '@/store.ts';
import { nostrDate } from '@/utils.ts'; import { nostrDate } from '@/utils.ts';
import { unfurlCardCached } from '@/utils/unfurl.ts'; import { unfurlCardCached } from '@/utils/unfurl.ts';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
import { DittoAttachment, renderAttachment } from '@/views/mastodon/attachments.ts'; import { DittoAttachment, renderAttachment } from '@/views/mastodon/attachments.ts';
import { renderEmojis } from '@/views/mastodon/emojis.ts'; import { renderEmojis } from '@/views/mastodon/emojis.ts';
async function renderStatus(event: eventsDB.DittoEvent<1>, viewerPubkey?: string) { async function renderStatus(event: DittoEvent<1>, viewerPubkey?: string) {
const account = event.author const account = event.author
? await renderAccount({ ...event.author, author_stats: event.author_stats }) ? await renderAccount({ ...event.author, author_stats: event.author_stats })
: await accountFromPubkey(event.pubkey); : await accountFromPubkey(event.pubkey);