From 7d54a5c7d08b3716a4f546ed0ddd871591946eb9 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 8 Jun 2024 11:13:15 -0500 Subject: [PATCH 1/9] 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, }, From b9922f96a0fa7a69c4748b906467366ba49b6c04 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 8 Jun 2024 12:17:06 -0500 Subject: [PATCH 2/9] adminActionController: mark "n" tags on the user --- src/app.ts | 4 ++-- src/controllers/api/admin.ts | 28 +++++++++++++++++----------- src/utils/api.ts | 31 +++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 13 deletions(-) diff --git a/src/app.ts b/src/app.ts index bb1c5b6..771b887 100644 --- a/src/app.ts +++ b/src/app.ts @@ -26,7 +26,7 @@ import { updateCredentialsController, verifyCredentialsController, } from '@/controllers/api/accounts.ts'; -import { adminAccountAction, adminAccountsController } from '@/controllers/api/admin.ts'; +import { adminAccountsController, adminActionController } from '@/controllers/api/admin.ts'; import { appCredentialsController, createAppController } from '@/controllers/api/apps.ts'; import { blocksController } from '@/controllers/api/blocks.ts'; import { bookmarksController } from '@/controllers/api/bookmarks.ts'; @@ -251,7 +251,7 @@ app.post( adminReportResolveController, ); -app.post('/api/v1/admin/accounts/:id{[0-9a-f]{64}}/action', requireSigner, requireRole('admin'), adminAccountAction); +app.post('/api/v1/admin/accounts/:id{[0-9a-f]{64}}/action', requireSigner, requireRole('admin'), adminActionController); // Not (yet) implemented. app.get('/api/v1/custom_emojis', emptyArrayController); diff --git a/src/controllers/api/admin.ts b/src/controllers/api/admin.ts index ae2221f..1b5794c 100644 --- a/src/controllers/api/admin.ts +++ b/src/controllers/api/admin.ts @@ -5,8 +5,7 @@ import { Conf } from '@/config.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { booleanParamSchema } from '@/schema.ts'; import { Storages } from '@/storages.ts'; -import { paginated, paginationSchema, parseBody, updateListAdminEvent } from '@/utils/api.ts'; -import { addTag } from '@/utils/tags.ts'; +import { paginated, paginationSchema, parseBody, updateUser } from '@/utils/api.ts'; import { renderAdminAccount } from '@/views/mastodon/admin-accounts.ts'; const adminAccountQuerySchema = z.object({ @@ -64,7 +63,7 @@ const adminAccountActionSchema = z.object({ type: z.enum(['none', 'sensitive', 'disable', 'silence', 'suspend']), }); -const adminAccountAction: AppController = async (c) => { +const adminActionController: AppController = async (c) => { const body = await parseBody(c.req.raw); const result = adminAccountActionSchema.safeParse(body); const authorId = c.req.param('id'); @@ -75,17 +74,24 @@ const adminAccountAction: AppController = async (c) => { const { data } = result; - if (data.type !== 'disable') { - return c.json({ error: 'Record invalid' }, 422); + const n: Record = {}; + + if (data.type === 'sensitive') { + n.sensitive = true; + } + if (data.type === 'disable') { + n.disable = true; + } + if (data.type === 'silence') { + n.silence = true; + } + if (data.type === 'suspend') { + n.suspend = true; } - await updateListAdminEvent( - { kinds: [10000], authors: [Conf.pubkey], limit: 1 }, - (tags) => addTag(tags, ['p', authorId]), - c, - ); + await updateUser(authorId, n, c); return c.json({}, 200); }; -export { adminAccountAction, adminAccountsController }; +export { adminAccountsController, adminActionController }; diff --git a/src/utils/api.ts b/src/utils/api.ts index 3cc8b7d..8010615 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -107,6 +107,36 @@ async function updateAdminEvent( return createAdminEvent(fn(prev), c); } +async function updateUser(pubkey: string, n: Record, c: AppContext): Promise { + const signer = new AdminSigner(); + const admin = await signer.getPublicKey(); + + return updateAdminEvent( + { kinds: [30382], authors: [admin], '#d': [pubkey], limit: 1 }, + (prev) => { + const prevNames = prev?.tags.reduce((acc, [name, value]) => { + if (name === 'n') acc[value] = true; + return acc; + }, {} as Record); + + const names = { ...prevNames, ...n }; + const nTags = Object.entries(names).filter(([, value]) => value).map(([name]) => ['n', name]); + const other = prev?.tags.filter(([name]) => !['d', 'n'].includes(name)) ?? []; + + return { + kind: 30382, + content: prev?.content, + tags: [ + ['d', pubkey], + ...nTags, + ...other, + ], + }; + }, + c, + ); +} + /** Push the event through the pipeline, rethrowing any RelayError. */ async function publishEvent(event: NostrEvent, c: AppContext): Promise { debug('EVENT', event); @@ -267,4 +297,5 @@ export { updateEvent, updateListAdminEvent, updateListEvent, + updateUser, }; From a30cdec79b00d0feb7327022d6a1034e3a2a396c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 8 Jun 2024 12:22:00 -0500 Subject: [PATCH 3/9] pipeline: ensure event doesn't already exist in DB --- src/pipeline.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/pipeline.ts b/src/pipeline.ts index 3255aa7..c85cc0b 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -35,6 +35,7 @@ async function handleEvent(event: DittoEvent, signal: AbortSignal): Promise ${event.id}`); if (event.kind !== 24133) { @@ -84,6 +85,13 @@ function encounterEvent(event: NostrEvent): boolean { return encountered; } +/** Check if the event already exists in the database. */ +async function existsInDB(event: DittoEvent): Promise { + const store = await Storages.db(); + const events = await store.query([{ ids: [event.id], limit: 1 }]); + return events.length > 0; +} + /** Hydrate the event with the user, if applicable. */ async function hydrateEvent(event: DittoEvent, signal: AbortSignal): Promise { await hydrateEvents({ events: [event], store: await Storages.db(), signal }); From e5fadafc7aa9a445c08e50429d04501d82ec4d1a Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 8 Jun 2024 12:58:59 -0500 Subject: [PATCH 4/9] Create AdminStore to filter out banned users --- src/pipeline.ts | 19 +++++++++--------- src/storages.ts | 8 ++++---- src/storages/AdminStore.ts | 40 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 13 deletions(-) create mode 100644 src/storages/AdminStore.ts diff --git a/src/pipeline.ts b/src/pipeline.ts index c85cc0b..f59a1eb 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -1,14 +1,11 @@ import { NKinds, NostrEvent, NSchema as n } from '@nostrify/nostrify'; -import { PipePolicy } from '@nostrify/nostrify/policies'; import Debug from '@soapbox/stickynotes/debug'; import { sql } from 'kysely'; import { LRUCache } from 'lru-cache'; -import { Conf } from '@/config.ts'; import { DittoDB } from '@/db/DittoDB.ts'; import { deleteAttachedMedia } from '@/db/unattached-media.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts'; -import { MuteListPolicy } from '@/policies/MuteListPolicy.ts'; import { RelayError } from '@/RelayError.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; import { Storages } from '@/storages.ts'; @@ -44,6 +41,15 @@ async function handleEvent(event: DittoEvent, signal: AbortSignal): Promise { const debug = Debug('ditto:policy'); - const policy = new PipePolicy([ - new MuteListPolicy(Conf.pubkey, await Storages.admin()), - policyWorker, - ]); - try { - const result = await policy.call(event); + const result = await policyWorker.call(event); debug(JSON.stringify(result)); RelayError.assert(result); } catch (e) { diff --git a/src/storages.ts b/src/storages.ts index f8f206d..4aaca1c 100644 --- a/src/storages.ts +++ b/src/storages.ts @@ -3,15 +3,15 @@ import { RelayPoolWorker } from 'nostr-relaypool'; import { Conf } from '@/config.ts'; import { DittoDB } from '@/db/DittoDB.ts'; +import { AdminStore } from '@/storages/AdminStore.ts'; import { EventsDB } from '@/storages/EventsDB.ts'; import { PoolStore } from '@/storages/pool-store.ts'; import { SearchStore } from '@/storages/search-store.ts'; import { InternalRelay } from '@/storages/InternalRelay.ts'; -import { UserStore } from '@/storages/UserStore.ts'; export class Storages { private static _db: Promise | undefined; - private static _admin: Promise | undefined; + private static _admin: Promise | undefined; private static _client: Promise | undefined; private static _pubsub: Promise | undefined; private static _search: Promise | undefined; @@ -28,9 +28,9 @@ export class Storages { } /** Admin user storage. */ - public static async admin(): Promise { + public static async admin(): Promise { if (!this._admin) { - this._admin = Promise.resolve(new UserStore(Conf.pubkey, await this.db())); + this._admin = Promise.resolve(new AdminStore(await this.db())); } return this._admin; } diff --git a/src/storages/AdminStore.ts b/src/storages/AdminStore.ts new file mode 100644 index 0000000..6285a14 --- /dev/null +++ b/src/storages/AdminStore.ts @@ -0,0 +1,40 @@ +import { NostrEvent, NostrFilter, NStore } from '@nostrify/nostrify'; + +import { Conf } from '@/config.ts'; +import { DittoEvent } from '@/interfaces/DittoEvent.ts'; +import { getTagSet } from '@/utils/tags.ts'; + +/** A store that prevents banned users from being displayed. */ +export class AdminStore implements NStore { + constructor(private store: NStore) {} + + async event(event: NostrEvent, opts?: { signal?: AbortSignal }): Promise { + return await this.store.event(event, opts); + } + + async query(filters: NostrFilter[], opts: { signal?: AbortSignal; limit?: number } = {}): Promise { + const events = await this.store.query(filters, opts); + + const users = await this.store.query([{ + kinds: [30382], + authors: [Conf.pubkey], + '#d': events.map((event) => event.pubkey), + limit: 1, + }]); + + return events.filter((event) => { + const user = users.find( + ({ kind, pubkey, tags }) => + kind === 30382 && pubkey === Conf.pubkey && tags.find(([name]) => name === 'd')?.[1] === event.pubkey, + ); + + const n = getTagSet(user?.tags ?? [], 'n'); + + if (n.has('disable') || n.has('suspend')) { + return false; + } + + return true; + }); + } +} From 284ae9aab7e479626a0db8abfacba99cdedf7267 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 8 Jun 2024 13:14:07 -0500 Subject: [PATCH 5/9] renderAccount: fix display of roles --- src/views/mastodon/accounts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/mastodon/accounts.ts b/src/views/mastodon/accounts.ts index b226915..5c7b776 100644 --- a/src/views/mastodon/accounts.ts +++ b/src/views/mastodon/accounts.ts @@ -34,7 +34,7 @@ async function renderAccount( const npub = nip19.npubEncode(pubkey); const parsed05 = await parseAndVerifyNip05(nip05, pubkey); - const roles = getTagSet(event.tags, 'n'); + const roles = getTagSet(event.user?.tags ?? [], 'n'); return { id: pubkey, From d2238e80f9e12f9890a629ed1770a2b3cb804af7 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 8 Jun 2024 13:44:57 -0500 Subject: [PATCH 6/9] Support Pleroma admin tags --- src/app.ts | 5 +++ src/controllers/api/pleroma.ts | 71 +++++++++++++++++++++++++++++++++- src/utils/api.ts | 5 ++- src/views/mastodon/accounts.ts | 1 + 4 files changed, 79 insertions(+), 3 deletions(-) diff --git a/src/app.ts b/src/app.ts index 771b887..2657a28 100644 --- a/src/app.ts +++ b/src/app.ts @@ -42,6 +42,8 @@ import { configController, frontendConfigController, pleromaAdminDeleteStatusController, + pleromaAdminTagController, + pleromaAdminUntagController, updateConfigController, } from '@/controllers/api/pleroma.ts'; import { preferencesController } from '@/controllers/api/preferences.ts'; @@ -253,6 +255,9 @@ app.post( app.post('/api/v1/admin/accounts/:id{[0-9a-f]{64}}/action', requireSigner, requireRole('admin'), adminActionController); +app.put('/api/v1/pleroma/admin/users/tag', requireRole('admin'), pleromaAdminTagController); +app.delete('/api/v1/pleroma/admin/users/tag', requireRole('admin'), pleromaAdminUntagController); + // Not (yet) implemented. app.get('/api/v1/custom_emojis', emptyArrayController); app.get('/api/v1/filters', emptyArrayController); diff --git a/src/controllers/api/pleroma.ts b/src/controllers/api/pleroma.ts index 31b4fc5..57b77cf 100644 --- a/src/controllers/api/pleroma.ts +++ b/src/controllers/api/pleroma.ts @@ -6,7 +6,8 @@ import { Conf } from '@/config.ts'; import { configSchema, elixirTupleSchema, type PleromaConfig } from '@/schemas/pleroma-api.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts'; import { Storages } from '@/storages.ts'; -import { createAdminEvent } from '@/utils/api.ts'; +import { createAdminEvent, updateAdminEvent } from '@/utils/api.ts'; +import { lookupPubkey } from '@/utils/lookup.ts'; const frontendConfigController: AppController = async (c) => { const store = await Storages.db(); @@ -87,4 +88,70 @@ async function getConfigs(store: NStore, signal: AbortSignal): Promise { + const params = pleromaAdminTagsSchema.parse(await c.req.json()); + + for (const nickname of params.nicknames) { + const pubkey = await lookupPubkey(nickname); + if (!pubkey) continue; + + await updateAdminEvent( + { kinds: [30382], authors: [Conf.pubkey], '#d': [pubkey], limit: 1 }, + (prev) => { + const tags = prev?.tags ?? [['d', pubkey]]; + + for (const tag of params.tags) { + const existing = prev?.tags.some(([name, value]) => name === 't' && value === tag); + if (!existing) { + tags.push(['t', tag]); + } + } + + return { + kind: 30382, + content: prev?.content ?? '', + tags, + }; + }, + c, + ); + } + + return new Response(null, { status: 204 }); +}; + +const pleromaAdminUntagController: AppController = async (c) => { + const params = pleromaAdminTagsSchema.parse(await c.req.json()); + + for (const nickname of params.nicknames) { + const pubkey = await lookupPubkey(nickname); + if (!pubkey) continue; + + await updateAdminEvent( + { kinds: [30382], authors: [Conf.pubkey], '#d': [pubkey], limit: 1 }, + (prev) => ({ + kind: 30382, + content: prev?.content ?? '', + tags: (prev?.tags ?? [['d', pubkey]]) + .filter(([name, value]) => !(name === 't' && params.tags.includes(value))), + }), + c, + ); + } + + return new Response(null, { status: 204 }); +}; + +export { + configController, + frontendConfigController, + pleromaAdminDeleteStatusController, + pleromaAdminTagController, + pleromaAdminUntagController, + updateConfigController, +}; diff --git a/src/utils/api.ts b/src/utils/api.ts index 8010615..fdbfbfd 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -73,6 +73,8 @@ function updateListEvent( async function createAdminEvent(t: EventStub, c: AppContext): Promise { const signer = new AdminSigner(); + console.log(t); + const event = await signer.signEvent({ content: '', created_at: nostrNow(), @@ -125,7 +127,7 @@ async function updateUser(pubkey: string, n: Record, c: AppCont return { kind: 30382, - content: prev?.content, + content: prev?.content ?? '', tags: [ ['d', pubkey], ...nTags, @@ -294,6 +296,7 @@ export { type PaginationParams, paginationSchema, parseBody, + updateAdminEvent, updateEvent, updateListAdminEvent, updateListEvent, diff --git a/src/views/mastodon/accounts.ts b/src/views/mastodon/accounts.ts index 5c7b776..3974c3c 100644 --- a/src/views/mastodon/accounts.ts +++ b/src/views/mastodon/accounts.ts @@ -81,6 +81,7 @@ async function renderAccount( is_moderator: roles.has('admin') || roles.has('moderator'), is_local: parsed05?.domain === Conf.url.host, settings_store: undefined as unknown, + tags: [...getTagSet(event.user?.tags ?? [], 't')], }, nostr: { pubkey, From d2df7522c4184c94ba3fa28c01bcc74a9156ae01 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 8 Jun 2024 13:57:50 -0500 Subject: [PATCH 7/9] Add Pleroma suggest/unsuggest endpoints --- src/app.ts | 4 ++++ src/controllers/api/pleroma.ts | 38 ++++++++++++++++++++++++++++++---- src/views/mastodon/accounts.ts | 7 ++++--- 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/app.ts b/src/app.ts index 2657a28..f9ea03a 100644 --- a/src/app.ts +++ b/src/app.ts @@ -42,7 +42,9 @@ import { configController, frontendConfigController, pleromaAdminDeleteStatusController, + pleromaAdminSuggestController, pleromaAdminTagController, + pleromaAdminUnsuggestController, pleromaAdminUntagController, updateConfigController, } from '@/controllers/api/pleroma.ts'; @@ -257,6 +259,8 @@ app.post('/api/v1/admin/accounts/:id{[0-9a-f]{64}}/action', requireSigner, requi app.put('/api/v1/pleroma/admin/users/tag', requireRole('admin'), pleromaAdminTagController); app.delete('/api/v1/pleroma/admin/users/tag', requireRole('admin'), pleromaAdminUntagController); +app.patch('/api/v1/pleroma/admin/users/suggest', requireRole('admin'), pleromaAdminSuggestController); +app.patch('/api/v1/pleroma/admin/users/unsuggest', requireRole('admin'), pleromaAdminUnsuggestController); // Not (yet) implemented. app.get('/api/v1/custom_emojis', emptyArrayController); diff --git a/src/controllers/api/pleroma.ts b/src/controllers/api/pleroma.ts index 57b77cf..f428ce9 100644 --- a/src/controllers/api/pleroma.ts +++ b/src/controllers/api/pleroma.ts @@ -6,7 +6,7 @@ import { Conf } from '@/config.ts'; import { configSchema, elixirTupleSchema, type PleromaConfig } from '@/schemas/pleroma-api.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts'; import { Storages } from '@/storages.ts'; -import { createAdminEvent, updateAdminEvent } from '@/utils/api.ts'; +import { createAdminEvent, updateAdminEvent, updateUser } from '@/utils/api.ts'; import { lookupPubkey } from '@/utils/lookup.ts'; const frontendConfigController: AppController = async (c) => { @@ -88,13 +88,13 @@ async function getConfigs(store: NStore, signal: AbortSignal): Promise { - const params = pleromaAdminTagsSchema.parse(await c.req.json()); + const params = pleromaAdminTagSchema.parse(await c.req.json()); for (const nickname of params.nicknames) { const pubkey = await lookupPubkey(nickname); @@ -126,7 +126,7 @@ const pleromaAdminTagController: AppController = async (c) => { }; const pleromaAdminUntagController: AppController = async (c) => { - const params = pleromaAdminTagsSchema.parse(await c.req.json()); + const params = pleromaAdminTagSchema.parse(await c.req.json()); for (const nickname of params.nicknames) { const pubkey = await lookupPubkey(nickname); @@ -147,11 +147,41 @@ const pleromaAdminUntagController: AppController = async (c) => { return new Response(null, { status: 204 }); }; +const pleromaAdminSuggestSchema = z.object({ + nicknames: z.string().array(), +}); + +const pleromaAdminSuggestController: AppController = async (c) => { + const { nicknames } = pleromaAdminSuggestSchema.parse(await c.req.json()); + + for (const nickname of nicknames) { + const pubkey = await lookupPubkey(nickname); + if (!pubkey) continue; + await updateUser(pubkey, { suggest: true }, c); + } + + return new Response(null, { status: 204 }); +}; + +const pleromaAdminUnsuggestController: AppController = async (c) => { + const { nicknames } = pleromaAdminSuggestSchema.parse(await c.req.json()); + + for (const nickname of nicknames) { + const pubkey = await lookupPubkey(nickname); + if (!pubkey) continue; + await updateUser(pubkey, { suggest: false }, c); + } + + return new Response(null, { status: 204 }); +}; + export { configController, frontendConfigController, pleromaAdminDeleteStatusController, + pleromaAdminSuggestController, pleromaAdminTagController, + pleromaAdminUnsuggestController, pleromaAdminUntagController, updateConfigController, }; diff --git a/src/views/mastodon/accounts.ts b/src/views/mastodon/accounts.ts index 3974c3c..9f2f052 100644 --- a/src/views/mastodon/accounts.ts +++ b/src/views/mastodon/accounts.ts @@ -34,7 +34,7 @@ async function renderAccount( const npub = nip19.npubEncode(pubkey); const parsed05 = await parseAndVerifyNip05(nip05, pubkey); - const roles = getTagSet(event.user?.tags ?? [], 'n'); + const names = getTagSet(event.user?.tags ?? [], 'n'); return { id: pubkey, @@ -77,8 +77,9 @@ async function renderAccount( accepts_zaps: Boolean(getLnurl({ lud06, lud16 })), }, pleroma: { - is_admin: roles.has('admin'), - is_moderator: roles.has('admin') || roles.has('moderator'), + is_admin: names.has('admin'), + is_moderator: names.has('admin') || names.has('moderator'), + is_suggested: names.has('suggest'), is_local: parsed05?.domain === Conf.url.host, settings_store: undefined as unknown, tags: [...getTagSet(event.user?.tags ?? [], 't')], From 9c24bac0ca7448f3bdad8931171c41c4816d5251 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 8 Jun 2024 14:07:14 -0500 Subject: [PATCH 8/9] Pull suggested profiles from kind 30382 events --- src/controllers/api/suggestions.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/controllers/api/suggestions.ts b/src/controllers/api/suggestions.ts index b56851a..c31ffc0 100644 --- a/src/controllers/api/suggestions.ts +++ b/src/controllers/api/suggestions.ts @@ -31,7 +31,7 @@ async function renderV2Suggestions(c: AppContext, params: PaginatedListParams, s const pubkey = await signer?.getPublicKey(); const filters: NostrFilter[] = [ - { kinds: [3], authors: [Conf.pubkey], limit: 1 }, + { kinds: [30382], authors: [Conf.pubkey], '#n': ['suggest'], limit }, { kinds: [1985], '#L': ['pub.ditto.trends'], '#l': [`#p`], authors: [Conf.pubkey], limit: 1 }, ]; @@ -42,8 +42,8 @@ async function renderV2Suggestions(c: AppContext, params: PaginatedListParams, s const events = await store.query(filters, { signal }); - const [suggestedEvent, followsEvent, mutesEvent, trendingEvent] = [ - events.find((event) => matchFilter({ kinds: [3], authors: [Conf.pubkey] }, event)), + const [userEvents, followsEvent, mutesEvent, trendingEvent] = [ + events.filter((event) => matchFilter({ kinds: [30382], authors: [Conf.pubkey], '#n': ['suggest'] }, event)), pubkey ? events.find((event) => matchFilter({ kinds: [3], authors: [pubkey] }, event)) : undefined, pubkey ? events.find((event) => matchFilter({ kinds: [10000], authors: [pubkey] }, event)) : undefined, events.find((event) => @@ -51,8 +51,13 @@ async function renderV2Suggestions(c: AppContext, params: PaginatedListParams, s ), ]; - const [suggested, trending, follows, mutes] = [ - getTagSet(suggestedEvent?.tags ?? [], 'p'), + const suggested = new Set( + userEvents + .map((event) => event.tags.find(([name]) => name === 'd')?.[1]) + .filter((pubkey): pubkey is string => !!pubkey), + ); + + const [trending, follows, mutes] = [ getTagSet(trendingEvent?.tags ?? [], 'p'), getTagSet(followsEvent?.tags ?? [], 'p'), getTagSet(mutesEvent?.tags ?? [], 'p'), From ca57d1be105bb5ec61bfae14b0e2d128959a23f3 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 8 Jun 2024 14:28:39 -0500 Subject: [PATCH 9/9] Remove stray console.log --- src/utils/api.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/utils/api.ts b/src/utils/api.ts index fdbfbfd..a9390b5 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -73,8 +73,6 @@ function updateListEvent( async function createAdminEvent(t: EventStub, c: AppContext): Promise { const signer = new AdminSigner(); - console.log(t); - const event = await signer.signEvent({ content: '', created_at: nostrNow(),