From 2245263011a70b8f7a0ce1e5560834d1607cc6e9 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 9 Jun 2024 14:50:37 -0500 Subject: [PATCH] Add ditto:name_grant notification --- src/app.ts | 23 +++--- src/controllers/api/admin.ts | 61 ++++++++++++++- src/controllers/api/ditto.ts | 108 +-------------------------- src/controllers/api/notifications.ts | 80 +++++++++++++++++--- src/views/mastodon/notifications.ts | 19 +++++ 5 files changed, 164 insertions(+), 127 deletions(-) diff --git a/src/app.ts b/src/app.ts index 7bf29ca..074359d 100644 --- a/src/app.ts +++ b/src/app.ts @@ -26,14 +26,16 @@ import { updateCredentialsController, verifyCredentialsController, } from '@/controllers/api/accounts.ts'; -import { adminAccountsController, adminActionController } from '@/controllers/api/admin.ts'; +import { + adminAccountsController, + adminActionController, + adminApproveController, + adminRejectController, +} 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'; import { - adminNameApproveController, - adminNameRejectController, - adminNameRequestsController, adminRelaysController, adminSetRelaysController, nameRequestController, @@ -244,7 +246,6 @@ app.get('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', reactions app.put('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', requireSigner, reactionController); app.delete('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', requireSigner, deleteReactionController); -app.get('/api/v1/admin/accounts', requireRole('admin'), adminAccountsController); app.get('/api/v1/pleroma/admin/config', requireRole('admin'), configController); app.post('/api/v1/pleroma/admin/config', requireRole('admin'), updateConfigController); app.delete('/api/v1/pleroma/admin/statuses/:id', requireRole('admin'), pleromaAdminDeleteStatusController); @@ -255,10 +256,6 @@ app.put('/api/v1/admin/ditto/relays', requireRole('admin'), adminSetRelaysContro app.post('/api/v1/ditto/names', requireSigner, nameRequestController); app.get('/api/v1/ditto/names', requireSigner, nameRequestsController); -app.get('/api/v1/admin/ditto/names', requireRole('admin'), adminNameRequestsController); -app.post('/api/v1/admin/ditto/names/:id{[0-9a-f]{64}}/approve', requireRole('admin'), adminNameApproveController); -app.post('/api/v1/admin/ditto/names/:id{[0-9a-f]{64}}/reject', requireRole('admin'), adminNameRejectController); - app.post('/api/v1/ditto/zap', requireSigner, zapController); app.post('/api/v1/reports', requireSigner, reportController); @@ -277,7 +274,15 @@ app.post( adminReportReopenController, ); +app.get('/api/v1/admin/accounts', requireRole('admin'), adminAccountsController); app.post('/api/v1/admin/accounts/:id{[0-9a-f]{64}}/action', requireSigner, requireRole('admin'), adminActionController); +app.post( + '/api/v1/admin/accounts/:id{[0-9a-f]{64}}/approve', + requireSigner, + requireRole('admin'), + adminApproveController, +); +app.post('/api/v1/admin/accounts/:id{[0-9a-f]{64}}/reject', requireSigner, requireRole('admin'), adminRejectController); app.put('/api/v1/pleroma/admin/users/tag', requireRole('admin'), pleromaAdminTagController); app.delete('/api/v1/pleroma/admin/users/tag', requireRole('admin'), pleromaAdminUntagController); diff --git a/src/controllers/api/admin.ts b/src/controllers/api/admin.ts index df5bf96..90afd52 100644 --- a/src/controllers/api/admin.ts +++ b/src/controllers/api/admin.ts @@ -6,7 +6,7 @@ import { Conf } from '@/config.ts'; import { booleanParamSchema } from '@/schema.ts'; import { Storages } from '@/storages.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; -import { paginated, paginationSchema, parseBody, updateUser } from '@/utils/api.ts'; +import { createAdminEvent, paginated, paginationSchema, parseBody, updateEventInfo, updateUser } from '@/utils/api.ts'; import { renderNameRequest } from '@/views/ditto.ts'; import { renderAdminAccount, renderAdminAccountFromPubkey } from '@/views/mastodon/admin-accounts.ts'; @@ -47,7 +47,7 @@ const adminAccountsController: AppController = async (c) => { } const orig = await store.query( - [{ kinds: [30383], authors: [Conf.pubkey], '#k': ['3036'], ...params }], + [{ kinds: [30383], authors: [Conf.pubkey], '#k': ['3036'], '#n': ['pending'], ...params }], { signal }, ); @@ -143,4 +143,59 @@ const adminActionController: AppController = async (c) => { return c.json({}, 200); }; -export { adminAccountsController, adminActionController }; +const adminApproveController: AppController = async (c) => { + const eventId = c.req.param('id'); + const store = await Storages.db(); + + const [event] = await store.query([{ kinds: [3036], ids: [eventId] }]); + if (!event) { + return c.json({ error: 'Event not found' }, 404); + } + + const r = event.tags.find(([name]) => name === 'r')?.[1]; + if (!r) { + return c.json({ error: 'NIP-05 not found' }, 404); + } + if (!z.string().email().safeParse(r).success) { + return c.json({ error: 'Invalid NIP-05' }, 400); + } + + const [existing] = await store.query([{ kinds: [30360], authors: [Conf.pubkey], '#d': [r], limit: 1 }]); + if (existing) { + return c.json({ error: 'NIP-05 already granted to another user' }, 400); + } + + await createAdminEvent({ + kind: 30360, + tags: [ + ['d', r], + ['L', 'nip05.domain'], + ['l', r.split('@')[1], 'nip05.domain'], + ['p', event.pubkey], + ['e', event.id], + ], + }, c); + + await updateEventInfo(eventId, { pending: false, approved: true, rejected: false }, c); + await hydrateEvents({ events: [event], store }); + + const nameRequest = await renderNameRequest(event); + return c.json(nameRequest); +}; + +const adminRejectController: AppController = async (c) => { + const eventId = c.req.param('id'); + const store = await Storages.db(); + + const [event] = await store.query([{ kinds: [3036], ids: [eventId] }]); + if (!event) { + return c.json({ error: 'Event not found' }, 404); + } + + await updateEventInfo(eventId, { pending: false, approved: false, rejected: true }, c); + await hydrateEvents({ events: [event], store }); + + const nameRequest = await renderNameRequest(event); + return c.json(nameRequest); +}; +export { adminAccountsController, adminActionController, adminApproveController, adminRejectController }; diff --git a/src/controllers/api/ditto.ts b/src/controllers/api/ditto.ts index 5c8ea9c..5723a9e 100644 --- a/src/controllers/api/ditto.ts +++ b/src/controllers/api/ditto.ts @@ -1,4 +1,4 @@ -import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify'; +import { NostrEvent, NostrFilter } from '@nostrify/nostrify'; import { z } from 'zod'; import { AppController } from '@/app.ts'; @@ -7,7 +7,7 @@ import { booleanParamSchema } from '@/schema.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts'; import { Storages } from '@/storages.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; -import { createAdminEvent, createEvent, paginated, paginationSchema, updateEventInfo } from '@/utils/api.ts'; +import { createEvent, paginated, paginationSchema } from '@/utils/api.ts'; import { renderNameRequest } from '@/views/ditto.ts'; const markerSchema = z.enum(['read', 'write']); @@ -135,107 +135,3 @@ export const nameRequestsController: AppController = async (c) => { return paginated(c, orig, nameRequests); }; - -const adminNameRequestsSchema = z.object({ - account_id: n.id().optional(), - approved: booleanParamSchema.optional(), - rejected: booleanParamSchema.optional(), -}); - -export const adminNameRequestsController: AppController = async (c) => { - const store = await Storages.db(); - const params = paginationSchema.parse(c.req.query()); - const { account_id, approved, rejected } = adminNameRequestsSchema.parse(c.req.query()); - - const filter: NostrFilter = { - kinds: [30383], - authors: [Conf.pubkey], - '#k': ['3036'], - ...params, - }; - - if (account_id) { - filter['#p'] = [account_id]; - } - if (approved) { - filter['#n'] = ['approved']; - } - if (rejected) { - filter['#n'] = ['rejected']; - } - - const orig = await store.query([filter]); - const ids = new Set(); - - for (const event of orig) { - const d = event.tags.find(([name]) => name === 'd')?.[1]; - if (d) { - ids.add(d); - } - } - - const events = await store.query([{ kinds: [3036], ids: [...ids] }]) - .then((events) => hydrateEvents({ store, events: events, signal: c.req.raw.signal })); - - const nameRequests = await Promise.all( - events.map((event) => renderNameRequest(event)), - ); - - return paginated(c, orig, nameRequests); -}; - -export const adminNameApproveController: AppController = async (c) => { - const eventId = c.req.param('id'); - const store = await Storages.db(); - - const [event] = await store.query([{ kinds: [3036], ids: [eventId] }]); - if (!event) { - return c.json({ error: 'Event not found' }, 404); - } - - const r = event.tags.find(([name]) => name === 'r')?.[1]; - if (!r) { - return c.json({ error: 'NIP-05 not found' }, 404); - } - if (!z.string().email().safeParse(r).success) { - return c.json({ error: 'Invalid NIP-05' }, 400); - } - - const [existing] = await store.query([{ kinds: [30360], authors: [Conf.pubkey], '#d': [r], limit: 1 }]); - if (existing) { - return c.json({ error: 'NIP-05 already granted to another user' }, 400); - } - - await createAdminEvent({ - kind: 30360, - tags: [ - ['d', r], - ['L', 'nip05.domain'], - ['l', r.split('@')[1], 'nip05.domain'], - ['p', event.pubkey], - ['e', event.id], - ], - }, c); - - await updateEventInfo(eventId, { pending: false, approved: true, rejected: false }, c); - await hydrateEvents({ events: [event], store }); - - const nameRequest = await renderNameRequest(event); - return c.json(nameRequest); -}; - -export const adminNameRejectController: AppController = async (c) => { - const eventId = c.req.param('id'); - const store = await Storages.db(); - - const [event] = await store.query([{ kinds: [3036], ids: [eventId] }]); - if (!event) { - return c.json({ error: 'Event not found' }, 404); - } - - await updateEventInfo(eventId, { pending: false, approved: false, rejected: true }, c); - await hydrateEvents({ events: [event], store }); - - const nameRequest = await renderNameRequest(event); - return c.json(nameRequest); -}; diff --git a/src/controllers/api/notifications.ts b/src/controllers/api/notifications.ts index ba15bd0..d92ccf4 100644 --- a/src/controllers/api/notifications.ts +++ b/src/controllers/api/notifications.ts @@ -1,24 +1,87 @@ -import { NostrFilter } from '@nostrify/nostrify'; +import { NostrFilter, NSchema as n } from '@nostrify/nostrify'; +import { z } from 'zod'; import { AppContext, AppController } from '@/app.ts'; +import { Conf } from '@/config.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; -import { paginated, paginationSchema } from '@/utils/api.ts'; +import { paginated, PaginationParams, paginationSchema } from '@/utils/api.ts'; import { renderNotification } from '@/views/mastodon/notifications.ts'; +/** Set of known notification types across backends. */ +const notificationTypes = new Set([ + 'mention', + 'status', + 'reblog', + 'follow', + 'follow_request', + 'favourite', + 'poll', + 'update', + 'admin.sign_up', + 'admin.report', + 'severed_relationships', + 'pleroma:emoji_reaction', + 'ditto:name_grant', +]); + +const notificationsSchema = z.object({ + account_id: n.id().optional(), +}); + const notificationsController: AppController = async (c) => { const pubkey = await c.get('signer')?.getPublicKey()!; - const { since, until } = paginationSchema.parse(c.req.query()); + const params = paginationSchema.parse(c.req.query()); - return renderNotifications(c, [{ kinds: [1, 6, 7], '#p': [pubkey], since, until }]); + const types = notificationTypes + .intersection(new Set(c.req.queries('types[]') ?? notificationTypes)) + .difference(new Set(c.req.queries('exclude_types[]'))); + + const { account_id } = notificationsSchema.parse(c.req.query()); + + const kinds = new Set(); + + if (types.has('mention')) { + kinds.add(1); + } + if (types.has('reblog')) { + kinds.add(6); + } + if (types.has('favourite') || types.has('pleroma:emoji_reaction')) { + kinds.add(7); + } + + const filter: NostrFilter = { + kinds: [...kinds], + '#p': [pubkey], + ...params, + }; + + const filters: NostrFilter[] = [filter]; + + if (account_id) { + filter.authors = [account_id]; + } + + if (types.has('ditto:name_grant') && !account_id) { + filters.push({ kinds: [30360], authors: [Conf.pubkey], '#p': [pubkey], ...params }); + } + + return renderNotifications(filters, types, params, c); }; -async function renderNotifications(c: AppContext, filters: NostrFilter[]) { +async function renderNotifications( + filters: NostrFilter[], + types: Set, + params: PaginationParams, + c: AppContext, +) { const store = c.get('store'); const pubkey = await c.get('signer')?.getPublicKey()!; const { signal } = c.req.raw; + const opts = { signal, limit: params.limit }; const events = await store - .query(filters, { signal }) + .query(filters, opts) .then((events) => events.filter((event) => event.pubkey !== pubkey)) .then((events) => hydrateEvents({ events, store, signal })); @@ -26,9 +89,8 @@ async function renderNotifications(c: AppContext, filters: NostrFilter[]) { return c.json([]); } - const notifications = (await Promise - .all(events.map((event) => renderNotification(event, { viewerPubkey: pubkey })))) - .filter(Boolean); + const notifications = (await Promise.all(events.map((event) => renderNotification(event, { viewerPubkey: pubkey })))) + .filter((notification) => notification && types.has(notification.type)); if (!notifications.length) { return c.json([]); diff --git a/src/views/mastodon/notifications.ts b/src/views/mastodon/notifications.ts index 5b618d7..e11d45a 100644 --- a/src/views/mastodon/notifications.ts +++ b/src/views/mastodon/notifications.ts @@ -26,6 +26,10 @@ function renderNotification(event: DittoEvent, opts: RenderNotificationOpts) { if (event.kind === 7) { return renderReaction(event, opts); } + + if (event.kind === 30360) { + return renderNameGrant(event); + } } async function renderMention(event: DittoEvent, opts: RenderNotificationOpts) { @@ -87,6 +91,21 @@ async function renderReaction(event: DittoEvent, opts: RenderNotificationOpts) { }; } +async function renderNameGrant(event: DittoEvent) { + const d = event.tags.find(([name]) => name === 'd')?.[1]; + const account = event.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey); + + if (!d) return; + + return { + id: notificationId(event), + type: 'ditto:name_grant', + name: d, + created_at: nostrDate(event.created_at).toISOString(), + account, + }; +} + /** This helps notifications be sorted in the correct order. */ function notificationId({ id, created_at }: NostrEvent): string { return `${created_at}-${id}`;