From 7d54a5c7d08b3716a4f546ed0ddd871591946eb9 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 8 Jun 2024 11:13:15 -0500 Subject: [PATCH] Kind 30361 -> 30382 --- docs/events.md | 40 ---------------- fixtures/events/event-30361.json | 15 ------ scripts/admin-role.ts | 42 +++++++++++++---- src/controllers/api/admin.ts | 2 +- src/db/users.ts | 75 ------------------------------ src/middleware/auth98Middleware.ts | 23 ++++----- src/storages/EventsDB.ts | 2 - src/storages/hydrate.ts | 4 +- src/views/mastodon/accounts.ts | 8 ++-- 9 files changed, 52 insertions(+), 159 deletions(-) delete mode 100644 docs/events.md delete mode 100644 fixtures/events/event-30361.json delete mode 100644 src/db/users.ts diff --git a/docs/events.md b/docs/events.md deleted file mode 100644 index 1674239..0000000 --- a/docs/events.md +++ /dev/null @@ -1,40 +0,0 @@ -# Ditto custom events - -Instead of using database tables, the Ditto server publishes Nostr events that describe its state. It then reads these events using Nostr filters. - -## Ditto User (kind 30361) - -The Ditto server publishes kind `30361` events to represent users. These events 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. -- `role` - one of `admin` or `user`. - -Example: - -```json -{ - "id": "d6ae2f320ae163612bf28080e7c6e55b228ee39bfa04ad50baab2e51022d4d59", - "kind": 30361, - "pubkey": "4cfc6ceb07bbe2f5e75f746f3e6f0eda53973e0374cd6bdbce7a930e10437e06", - "content": "", - "created_at": 1691568245, - "tags": [ - ["d", "79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6"], - ["role", "user"], - ["alt", "User's account was updated by the admins of ditto.ngrok.app"] - ], - "sig": "fc12db77b1c8f8aa86c73b617f0cd4af1e6ba244239eaf3164a292de6d39363f32d6b817ffff796ace7a103d75e1d8e6a0fb7f618819b32d81a953b4a75d7507" -} -``` - -## 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.` 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. - -### `pub.ditto.pleroma.config` - -NIP-04 encrypted JSON array of Pleroma ConfigDB objects. Pleroma admin API endpoints set this config, and Ditto reads from it. diff --git a/fixtures/events/event-30361.json b/fixtures/events/event-30361.json deleted file mode 100644 index 5844000..0000000 --- a/fixtures/events/event-30361.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "id": "d6ae2f320ae163612bf28080e7c6e55b228ee39bfa04ad50baab2e51022d4d59", - "kind": 30361, - "pubkey": "4cfc6ceb07bbe2f5e75f746f3e6f0eda53973e0374cd6bdbce7a930e10437e06", - "content": "", - "created_at": 1691568245, - "tags": [ - ["d", "79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6"], - ["name", "alex"], - ["role", "user"], - ["origin", "https://ditto.ngrok.app"], - ["alt", "@alex@ditto.ngrok.app's account was updated by the admins of ditto.ngrok.app"] - ], - "sig": "fc12db77b1c8f8aa86c73b617f0cd4af1e6ba244239eaf3164a292de6d39363f32d6b817ffff796ace7a103d75e1d8e6a0fb7f618819b32d81a953b4a75d7507" -} \ No newline at end of file diff --git a/scripts/admin-role.ts b/scripts/admin-role.ts index 6e7bfc6..305b593 100644 --- a/scripts/admin-role.ts +++ b/scripts/admin-role.ts @@ -1,7 +1,6 @@ import { NSchema } from '@nostrify/nostrify'; import { DittoDB } from '@/db/DittoDB.ts'; -import { Conf } from '@/config.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts'; import { EventsDB } from '@/storages/EventsDB.ts'; import { nostrNow } from '@/utils.ts'; @@ -21,14 +20,39 @@ if (!['admin', 'user'].includes(role)) { Deno.exit(1); } -const event = await new AdminSigner().signEvent({ - kind: 30361, - tags: [ - ['d', pubkey], - ['role', role], - // NIP-31: https://github.com/nostr-protocol/nips/blob/master/31.md - ['alt', `User's account was updated by the admins of ${Conf.url.host}`], - ], +const signer = new AdminSigner(); +const admin = await signer.getPublicKey(); + +const [existing] = await eventsDB.query([{ + kinds: [30382], + authors: [admin], + '#d': [pubkey], + limit: 1, +}]); + +const prevTags = (existing?.tags ?? []).filter(([name, value]) => { + if (name === 'd') { + return false; + } + if (name === 'n' && value === 'admin') { + return false; + } + return true; +}); + +const tags: string[][] = [ + ['d', pubkey], +]; + +if (role === 'admin') { + tags.push(['n', 'admin']); +} + +tags.push(...prevTags); + +const event = await signer.signEvent({ + kind: 30382, + tags, content: '', created_at: nostrNow(), }); diff --git a/src/controllers/api/admin.ts b/src/controllers/api/admin.ts index d7cd365..ae2221f 100644 --- a/src/controllers/api/admin.ts +++ b/src/controllers/api/admin.ts @@ -44,7 +44,7 @@ const adminAccountsController: AppController = async (c) => { const { since, until, limit } = paginationSchema.parse(c.req.query()); const { signal } = c.req.raw; - const events = await store.query([{ kinds: [30361], authors: [Conf.pubkey], since, until, limit }], { signal }); + const events = await store.query([{ kinds: [30382], authors: [Conf.pubkey], since, until, limit }], { signal }); const pubkeys = events.map((event) => event.tags.find(([name]) => name === 'd')?.[1]!); const authors = await store.query([{ kinds: [0], authors: pubkeys }], { signal }); diff --git a/src/db/users.ts b/src/db/users.ts deleted file mode 100644 index bf0cab7..0000000 --- a/src/db/users.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { NostrFilter } from '@nostrify/nostrify'; -import Debug from '@soapbox/stickynotes/debug'; - -import { Conf } from '@/config.ts'; -import * as pipeline from '@/pipeline.ts'; -import { AdminSigner } from '@/signers/AdminSigner.ts'; -import { Storages } from '@/storages.ts'; - -const debug = Debug('ditto:users'); - -interface User { - pubkey: string; - inserted_at: Date; - admin: boolean; -} - -function buildUserEvent(user: User) { - const { origin, host } = Conf.url; - const signer = new AdminSigner(); - - return signer.signEvent({ - kind: 30361, - tags: [ - ['d', user.pubkey], - ['role', user.admin ? 'admin' : 'user'], - ['origin', origin], - // NIP-31: https://github.com/nostr-protocol/nips/blob/master/31.md - ['alt', `User'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, AbortSignal.timeout(1000)); -} - -/** - * Finds a single user based on one or more properties. - * - * ```ts - * await findUser({ username: 'alex' }); - * ``` - */ -async function findUser(user: Partial, signal?: AbortSignal): Promise { - const filter: NostrFilter = { kinds: [30361], authors: [Conf.pubkey], limit: 1 }; - - for (const [key, value] of Object.entries(user)) { - switch (key) { - case 'pubkey': - filter['#d'] = [String(value)]; - break; - case 'admin': - filter['#role'] = [value ? 'admin' : 'user']; - break; - } - } - - const store = await Storages.db(); - const [event] = await store.query([filter], { signal }); - - if (event) { - return { - pubkey: event.tags.find(([name]) => name === 'd')?.[1]!, - inserted_at: new Date(event.created_at * 1000), - admin: event.tags.find(([name]) => name === 'role')?.[1] === 'admin', - }; - } -} - -export { buildUserEvent, findUser, insertUser, type User }; diff --git a/src/middleware/auth98Middleware.ts b/src/middleware/auth98Middleware.ts index 34d6937..05b0681 100644 --- a/src/middleware/auth98Middleware.ts +++ b/src/middleware/auth98Middleware.ts @@ -2,8 +2,8 @@ import { NostrEvent } from '@nostrify/nostrify'; import { HTTPException } from 'hono'; import { type AppContext, type AppMiddleware } from '@/app.ts'; -import { findUser, User } from '@/db/users.ts'; import { ReadOnlySigner } from '@/signers/ReadOnlySigner.ts'; +import { Storages } from '@/storages.ts'; import { localRequest } from '@/utils/api.ts'; import { buildAuthEventTemplate, @@ -11,6 +11,7 @@ import { type ParseAuthRequestOpts, validateAuthEvent, } from '@/utils/nip98.ts'; +import { Conf } from '@/config.ts'; /** * NIP-98 auth. @@ -35,7 +36,14 @@ type UserRole = 'user' | 'admin'; /** Require the user to prove their role before invoking the controller. */ function requireRole(role: UserRole, opts?: ParseAuthRequestOpts): AppMiddleware { return withProof(async (_c, proof, next) => { - const user = await findUser({ pubkey: proof.pubkey }); + const store = await Storages.db(); + + const [user] = await store.query([{ + kinds: [30382], + authors: [Conf.pubkey], + '#d': [proof.pubkey], + limit: 1, + }]); if (user && matchesRole(user, role)) { await next(); @@ -53,15 +61,8 @@ function requireProof(opts?: ParseAuthRequestOpts): AppMiddleware { } /** Check whether the user fulfills the role. */ -function matchesRole(user: User, role: UserRole): boolean { - switch (role) { - case 'user': - return true; - case 'admin': - return user.admin; - default: - return false; - } +function matchesRole(user: NostrEvent, role: UserRole): boolean { + return user.tags.some(([tag, value]) => tag === 'n' && value === role); } /** HOC to obtain proof in middleware. */ diff --git a/src/storages/EventsDB.ts b/src/storages/EventsDB.ts index 5366178..8dcee6c 100644 --- a/src/storages/EventsDB.ts +++ b/src/storages/EventsDB.ts @@ -39,8 +39,6 @@ class EventsDB implements NStore { 'q': ({ event, count, value }) => count === 0 && event.kind === 1 && isNostrId(value), 'r': ({ event, count, value }) => (event.kind === 1985 ? count < 20 : count < 3) && isURL(value), 't': ({ event, count, value }) => (event.kind === 1985 ? count < 20 : count < 5) && value.length < 50, - 'name': ({ event, count }) => event.kind === 30361 && count === 0, - 'role': ({ event, count }) => event.kind === 30361 && count === 0, }; constructor(private kysely: Kysely) { diff --git a/src/storages/hydrate.ts b/src/storages/hydrate.ts index d80c2f4..ded03d4 100644 --- a/src/storages/hydrate.ts +++ b/src/storages/hydrate.ts @@ -82,7 +82,7 @@ export function assembleEvents( for (const event of a) { event.author = b.find((e) => matchFilter({ kinds: [0], authors: [event.pubkey] }, e)); - event.user = b.find((e) => matchFilter({ kinds: [30361], authors: [admin], '#d': [event.pubkey] }, e)); + event.user = b.find((e) => matchFilter({ kinds: [30382], authors: [admin], '#d': [event.pubkey] }, e)); if (event.kind === 1) { const id = findQuoteTag(event.tags)?.[1] || findQuoteInContent(event.content); @@ -201,7 +201,7 @@ function gatherUsers({ events, store, signal }: HydrateOpts): Promise event.pubkey)); return store.query( - [{ kinds: [30361], authors: [Conf.pubkey], '#d': [...pubkeys], limit: pubkeys.size }], + [{ kinds: [30382], authors: [Conf.pubkey], '#d': [...pubkeys], limit: pubkeys.size }], { signal }, ); } diff --git a/src/views/mastodon/accounts.ts b/src/views/mastodon/accounts.ts index 918d03b..b226915 100644 --- a/src/views/mastodon/accounts.ts +++ b/src/views/mastodon/accounts.ts @@ -6,6 +6,7 @@ import { Conf } from '@/config.ts'; import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; import { getLnurl } from '@/utils/lnurl.ts'; import { nip05Cache } from '@/utils/nip05.ts'; +import { getTagSet } from '@/utils/tags.ts'; import { Nip05, nostrDate, nostrNow, parseNip05 } from '@/utils.ts'; import { renderEmojis } from '@/views/mastodon/emojis.ts'; @@ -33,7 +34,7 @@ async function renderAccount( const npub = nip19.npubEncode(pubkey); const parsed05 = await parseAndVerifyNip05(nip05, pubkey); - const role = event.user?.tags.find(([name]) => name === 'role')?.[1] ?? 'user'; + const roles = getTagSet(event.tags, 'n'); return { id: pubkey, @@ -74,11 +75,10 @@ async function renderAccount( username: parsed05?.nickname || npub.substring(0, 8), ditto: { accepts_zaps: Boolean(getLnurl({ lud06, lud16 })), - is_registered: Boolean(event.user), }, pleroma: { - is_admin: role === 'admin', - is_moderator: ['admin', 'moderator'].includes(role), + is_admin: roles.has('admin'), + is_moderator: roles.has('admin') || roles.has('moderator'), is_local: parsed05?.domain === Conf.url.host, settings_store: undefined as unknown, },