diff --git a/src/app.ts b/src/app.ts index 19641d4..074359d 100644 --- a/src/app.ts +++ b/src/app.ts @@ -26,11 +26,21 @@ 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 { adminRelaysController, adminSetRelaysController, nameRequestController } from '@/controllers/api/ditto.ts'; +import { + adminRelaysController, + adminSetRelaysController, + nameRequestController, + nameRequestsController, +} from '@/controllers/api/ditto.ts'; import { emptyArrayController, emptyObjectController, notImplementedController } from '@/controllers/api/fallback.ts'; import { instanceController } from '@/controllers/api/instance.ts'; import { markersController, updateMarkersController } from '@/controllers/api/markers.ts'; @@ -53,6 +63,7 @@ import { deleteReactionController, reactionController, reactionsController } fro import { relayController } from '@/controllers/nostr/relay.ts'; import { adminReportController, + adminReportReopenController, adminReportResolveController, adminReportsController, reportController, @@ -235,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); @@ -244,6 +254,8 @@ app.get('/api/v1/admin/ditto/relays', requireRole('admin'), adminRelaysControlle app.put('/api/v1/admin/ditto/relays', requireRole('admin'), adminSetRelaysController); app.post('/api/v1/ditto/names', requireSigner, nameRequestController); +app.get('/api/v1/ditto/names', requireSigner, nameRequestsController); + app.post('/api/v1/ditto/zap', requireSigner, zapController); app.post('/api/v1/reports', requireSigner, reportController); @@ -255,8 +267,22 @@ app.post( requireRole('admin'), adminReportResolveController, ); +app.post( + '/api/v1/admin/reports/:id{[0-9a-f]{64}}/reopen', + requireSigner, + requireRole('admin'), + 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 9da15bc..90afd52 100644 --- a/src/controllers/api/admin.ts +++ b/src/controllers/api/admin.ts @@ -1,13 +1,14 @@ -import { NostrEvent } from '@nostrify/nostrify'; +import { NostrFilter } from '@nostrify/nostrify'; import { z } from 'zod'; import { type AppController } from '@/app.ts'; import { Conf } from '@/config.ts'; import { booleanParamSchema } from '@/schema.ts'; import { Storages } from '@/storages.ts'; -import { paginated, paginationSchema, parseBody, updateUser } from '@/utils/api.ts'; -import { renderAdminAccount, renderAdminAccountFromPubkey } from '@/views/mastodon/admin-accounts.ts'; import { hydrateEvents } from '@/storages/hydrate.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'; const adminAccountQuerySchema = z.object({ local: booleanParamSchema.optional(), @@ -27,62 +28,83 @@ const adminAccountQuerySchema = z.object({ }); const adminAccountsController: AppController = async (c) => { + const store = await Storages.db(); + const params = paginationSchema.parse(c.req.query()); + const { signal } = c.req.raw; const { + local, pending, disabled, silenced, suspended, sensitized, + staff, } = adminAccountQuerySchema.parse(c.req.query()); - // Not supported. - if (disabled || silenced || suspended || sensitized) { - return c.json([]); - } - - const store = await Storages.db(); - const params = paginationSchema.parse(c.req.query()); - const { signal } = c.req.raw; - - const pubkeys = new Set(); - const events: NostrEvent[] = []; - if (pending) { - for (const event of await store.query([{ kinds: [3036], '#p': [Conf.pubkey], ...params }], { signal })) { - pubkeys.add(event.pubkey); - events.push(event); - } - } else { - for (const event of await store.query([{ kinds: [30360], authors: [Conf.pubkey], ...params }], { signal })) { - const pubkey = event.tags.find(([name]) => name === 'd')?.[1]; - if (pubkey) { - pubkeys.add(pubkey); - events.push(event); - } + if (disabled || silenced || suspended || sensitized) { + return c.json([]); } + + const orig = await store.query( + [{ kinds: [30383], authors: [Conf.pubkey], '#k': ['3036'], '#n': ['pending'], ...params }], + { signal }, + ); + + const ids = new Set( + orig + .map(({ tags }) => tags.find(([name]) => name === 'd')?.[1]) + .filter((id): id is string => !!id), + ); + + const events = await store.query([{ kinds: [3036], ids: [...ids] }]) + .then((events) => hydrateEvents({ store, events, signal })); + + const nameRequests = await Promise.all(events.map(renderNameRequest)); + return paginated(c, orig, nameRequests); } - const authors = await store.query([{ kinds: [0], authors: [...pubkeys] }], { signal }) - .then((events) => hydrateEvents({ store, events, signal })); + if (disabled || silenced || suspended || sensitized) { + const n = []; - const accounts = await Promise.all( - [...pubkeys].map(async (pubkey) => { - const author = authors.find((event) => event.pubkey === pubkey); - const account = author ? await renderAdminAccount(author) : await renderAdminAccountFromPubkey(pubkey); - const request = events.find((event) => event.kind === 3036 && event.pubkey === pubkey); - const grant = events.find( - (event) => event.kind === 30360 && event.tags.find(([name]) => name === 'd')?.[1] === pubkey, - ); + if (disabled) { + n.push('disabled'); + } + if (silenced) { + n.push('silenced'); + } + if (suspended) { + n.push('suspended'); + } + if (sensitized) { + n.push('sensitized'); + } + if (staff) { + n.push('admin'); + n.push('moderator'); + } - return { - ...account, - invite_request: request?.content ?? null, - invite_request_username: request?.tags.find(([name]) => name === 'r')?.[1] ?? null, - approved: !!grant, - }; - }), - ); + const events = await store.query([{ kinds: [30382], authors: [Conf.pubkey], '#n': n, ...params }], { signal }); + const pubkeys = new Set(events.map(({ pubkey }) => pubkey)); + const authors = await store.query([{ kinds: [0], authors: [...pubkeys] }]) + .then((events) => hydrateEvents({ store, events, signal })); + const accounts = await Promise.all( + [...pubkeys].map((pubkey) => { + const author = authors.find((e) => e.pubkey === pubkey); + return author ? renderAdminAccount(author) : renderAdminAccountFromPubkey(pubkey); + }), + ); + + return paginated(c, events, accounts); + } + + const filter: NostrFilter = { kinds: [0], ...params }; + if (local) { + filter.search = `domain:${Conf.url.host}`; + } + const events = await store.query([filter], { signal }); + const accounts = await Promise.all(events.map(renderAdminAccount)); return paginated(c, events, accounts); }; @@ -104,16 +126,16 @@ const adminActionController: AppController = async (c) => { const n: Record = {}; if (data.type === 'sensitive') { - n.sensitive = true; + n.sensitized = true; } if (data.type === 'disable') { - n.disable = true; + n.disabled = true; } if (data.type === 'silence') { - n.silence = true; + n.silenced = true; } if (data.type === 'suspend') { - n.suspend = true; + n.suspended = true; } await updateUser(authorId, n, c); @@ -121,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 4cf2cd4..5723a9e 100644 --- a/src/controllers/api/ditto.ts +++ b/src/controllers/api/ditto.ts @@ -1,12 +1,13 @@ -import { NostrEvent } from '@nostrify/nostrify'; +import { NostrEvent, NostrFilter } from '@nostrify/nostrify'; import { z } from 'zod'; import { AppController } from '@/app.ts'; import { Conf } from '@/config.ts'; +import { booleanParamSchema } from '@/schema.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts'; import { Storages } from '@/storages.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; -import { createEvent } from '@/utils/api.ts'; +import { createEvent, paginated, paginationSchema } from '@/utils/api.ts'; import { renderNameRequest } from '@/views/ditto.ts'; const markerSchema = z.enum(['read', 'write']); @@ -63,20 +64,20 @@ function renderRelays(event: NostrEvent): RelayEntity[] { } const nameRequestSchema = z.object({ - nip05: z.string().email(), + name: z.string().email(), reason: z.string().max(500).optional(), }); export const nameRequestController: AppController = async (c) => { - const { nip05, reason } = nameRequestSchema.parse(await c.req.json()); + const { name, reason } = nameRequestSchema.parse(await c.req.json()); const event = await createEvent({ kind: 3036, content: reason, tags: [ - ['r', nip05], + ['r', name], ['L', 'nip05.domain'], - ['l', nip05.split('@')[1], 'nip05.domain'], + ['l', name.split('@')[1], 'nip05.domain'], ['p', Conf.pubkey], ], }, c); @@ -87,14 +88,50 @@ export const nameRequestController: AppController = async (c) => { return c.json(nameRequest); }; +const nameRequestsSchema = z.object({ + approved: booleanParamSchema.optional(), + rejected: booleanParamSchema.optional(), +}); + export const nameRequestsController: AppController = async (c) => { const store = await Storages.db(); const signer = c.get('signer')!; const pubkey = await signer.getPublicKey(); - const events = await store.query([{ kinds: [3036], authors: [pubkey], limit: 20 }]) - .then((events) => hydrateEvents({ events, store })); + const params = paginationSchema.parse(c.req.query()); + const { approved, rejected } = nameRequestsSchema.parse(c.req.query()); - const nameRequests = await Promise.all(events.map(renderNameRequest)); - return c.json(nameRequests); + const filter: NostrFilter = { + kinds: [30383], + authors: [Conf.pubkey], + '#k': ['3036'], + '#p': [pubkey], + ...params, + }; + + 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); }; 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/controllers/api/pleroma.ts b/src/controllers/api/pleroma.ts index f428ce9..31d8545 100644 --- a/src/controllers/api/pleroma.ts +++ b/src/controllers/api/pleroma.ts @@ -157,7 +157,7 @@ const pleromaAdminSuggestController: AppController = async (c) => { for (const nickname of nicknames) { const pubkey = await lookupPubkey(nickname); if (!pubkey) continue; - await updateUser(pubkey, { suggest: true }, c); + await updateUser(pubkey, { suggested: true }, c); } return new Response(null, { status: 204 }); @@ -169,7 +169,7 @@ const pleromaAdminUnsuggestController: AppController = async (c) => { for (const nickname of nicknames) { const pubkey = await lookupPubkey(nickname); if (!pubkey) continue; - await updateUser(pubkey, { suggest: false }, c); + await updateUser(pubkey, { suggested: false }, c); } return new Response(null, { status: 204 }); diff --git a/src/controllers/api/reports.ts b/src/controllers/api/reports.ts index 2092b8a..da107ed 100644 --- a/src/controllers/api/reports.ts +++ b/src/controllers/api/reports.ts @@ -3,11 +3,10 @@ import { z } from 'zod'; import { type AppController } from '@/app.ts'; import { Conf } from '@/config.ts'; -import { createEvent, paginationSchema, parseBody, updateEventInfo } from '@/utils/api.ts'; +import { createEvent, paginated, paginationSchema, parseBody, updateEventInfo } from '@/utils/api.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; import { renderAdminReport } from '@/views/mastodon/reports.ts'; import { renderReport } from '@/views/mastodon/reports.ts'; -import { getTagSet } from '@/utils/tags.ts'; import { booleanParamSchema } from '@/schema.ts'; const reportSchema = z.object({ @@ -71,6 +70,7 @@ const adminReportsController: AppController = async (c) => { const filter: NostrFilter = { kinds: [30383], authors: [Conf.pubkey], + '#k': ['1984'], ...params, }; @@ -98,18 +98,10 @@ const adminReportsController: AppController = async (c) => { .then((events) => hydrateEvents({ store, events: events, signal: c.req.raw.signal })); const reports = await Promise.all( - events.map((event) => { - const internal = orig.find(({ tags }) => tags.some(([name, value]) => name === 'd' && value === event.id)); - const names = getTagSet(internal?.tags ?? [], 'n'); - - return renderAdminReport(event, { - viewerPubkey, - actionTaken: names.has('closed'), - }); - }), + events.map((event) => renderAdminReport(event, { viewerPubkey })), ); - return c.json(reports); + return paginated(c, orig, reports); }; /** https://docs.joinmastodon.org/methods/admin/reports/#get-one */ @@ -153,11 +145,39 @@ const adminReportResolveController: AppController = async (c) => { } await updateEventInfo(eventId, { open: false, closed: true }, c); - await hydrateEvents({ events: [event], store, signal }); - const report = await renderAdminReport(event, { viewerPubkey: pubkey, actionTaken: true }); + const report = await renderAdminReport(event, { viewerPubkey: pubkey }); return c.json(report); }; -export { adminReportController, adminReportResolveController, adminReportsController, reportController }; +const adminReportReopenController: AppController = async (c) => { + const eventId = c.req.param('id'); + const { signal } = c.req.raw; + const store = c.get('store'); + const pubkey = await c.get('signer')?.getPublicKey(); + + const [event] = await store.query([{ + kinds: [1984], + ids: [eventId], + limit: 1, + }], { signal }); + + if (!event) { + return c.json({ error: 'Not found' }, 404); + } + + await updateEventInfo(eventId, { open: true, closed: false }, c); + await hydrateEvents({ events: [event], store, signal }); + + const report = await renderAdminReport(event, { viewerPubkey: pubkey }); + return c.json(report); +}; + +export { + adminReportController, + adminReportReopenController, + adminReportResolveController, + adminReportsController, + reportController, +}; diff --git a/src/controllers/api/streaming.ts b/src/controllers/api/streaming.ts index 427c350..552ea3b 100644 --- a/src/controllers/api/streaming.ts +++ b/src/controllers/api/streaming.ts @@ -1,4 +1,4 @@ -import { NostrFilter } from '@nostrify/nostrify'; +import { NostrEvent, NostrFilter } from '@nostrify/nostrify'; import Debug from '@soapbox/stickynotes/debug'; import { z } from 'zod'; @@ -11,6 +11,7 @@ import { hydrateEvents } from '@/storages/hydrate.ts'; import { Storages } from '@/storages.ts'; import { bech32ToPubkey } from '@/utils.ts'; import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts'; +import { renderNotification } from '@/views/mastodon/notifications.ts'; const debug = Debug('ditto:streaming'); @@ -52,6 +53,11 @@ const streamingController: AppController = async (c) => { const { socket, response } = Deno.upgradeWebSocket(c.req.raw, { protocol: token, idleTimeout: 30 }); + const store = await Storages.db(); + const pubsub = await Storages.pubsub(); + + const policy = pubkey ? new MuteListPolicy(pubkey, await Storages.admin()) : undefined; + function send(name: string, payload: object) { if (socket.readyState === WebSocket.OPEN) { debug('send', name, JSON.stringify(payload)); @@ -63,52 +69,54 @@ const streamingController: AppController = async (c) => { } } - socket.onopen = async () => { - if (!stream) return; - - const filter = await topicToFilter(stream, c.req.query(), pubkey); - if (!filter) return; - + async function sub(type: string, filters: NostrFilter[], render: (event: NostrEvent) => Promise) { try { - const db = await Storages.db(); - const pubsub = await Storages.pubsub(); - - for await (const msg of pubsub.req([filter], { signal: controller.signal })) { + for await (const msg of pubsub.req(filters, { signal: controller.signal })) { if (msg[0] === 'EVENT') { const event = msg[2]; - if (pubkey) { - const policy = new MuteListPolicy(pubkey, await Storages.admin()); + if (policy) { const [, , ok] = await policy.call(event); if (!ok) { continue; } } - await hydrateEvents({ - events: [event], - store: db, - signal: AbortSignal.timeout(1000), - }); + await hydrateEvents({ events: [event], store, signal: AbortSignal.timeout(1000) }); - if (event.kind === 1) { - const status = await renderStatus(event, { viewerPubkey: pubkey }); - if (status) { - send('update', status); - } - } + const result = await render(event); - if (event.kind === 6) { - const status = await renderReblog(event, { viewerPubkey: pubkey }); - if (status) { - send('update', status); - } + if (result) { + send(type, result); } } } } catch (e) { debug('streaming error:', e); } + } + + socket.onopen = async () => { + if (!stream) return; + const topicFilter = await topicToFilter(stream, c.req.query(), pubkey); + + if (topicFilter) { + sub('update', [topicFilter], async (event) => { + if (event.kind === 1) { + return await renderStatus(event, { viewerPubkey: pubkey }); + } + if (event.kind === 6) { + return await renderReblog(event, { viewerPubkey: pubkey }); + } + }); + } + + if (['user', 'user:notification'].includes(stream) && pubkey) { + sub('notification', [{ '#p': [pubkey] }], async (event) => { + return await renderNotification(event, { viewerPubkey: pubkey }); + }); + return; + } }; socket.onclose = () => { diff --git a/src/controllers/api/suggestions.ts b/src/controllers/api/suggestions.ts index c31ffc0..7e461c4 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: [30382], authors: [Conf.pubkey], '#n': ['suggest'], limit }, + { kinds: [30382], authors: [Conf.pubkey], '#n': ['suggested'], limit }, { kinds: [1985], '#L': ['pub.ditto.trends'], '#l': [`#p`], authors: [Conf.pubkey], limit: 1 }, ]; @@ -43,7 +43,7 @@ async function renderV2Suggestions(c: AppContext, params: PaginatedListParams, s const events = await store.query(filters, { signal }); const [userEvents, followsEvent, mutesEvent, trendingEvent] = [ - events.filter((event) => matchFilter({ kinds: [30382], authors: [Conf.pubkey], '#n': ['suggest'] }, event)), + events.filter((event) => matchFilter({ kinds: [30382], authors: [Conf.pubkey], '#n': ['suggested'] }, 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) => diff --git a/src/pipeline.ts b/src/pipeline.ts index 20fed92..9f99520 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -45,10 +45,10 @@ async function handleEvent(event: DittoEvent, signal: AbortSignal): Promise t !== 'alt')); + case 30360: + return event.tags.find(([name]) => name === 'd')?.[1] || ''; default: return ''; } diff --git a/src/storages/hydrate.ts b/src/storages/hydrate.ts index a3821aa..9ec9e8c 100644 --- a/src/storages/hydrate.ts +++ b/src/storages/hydrate.ts @@ -206,6 +206,10 @@ function gatherAuthors({ events, store, signal }: HydrateOpts): Promise { const pubkeys = new Set(events.map((event) => event.pubkey)); + if (!pubkeys.size) { + return Promise.resolve([]); + } + return store.query( [{ kinds: [30382], authors: [Conf.pubkey], '#d': [...pubkeys], limit: pubkeys.size }], { signal }, @@ -217,7 +221,7 @@ function gatherInfo({ events, store, signal }: HydrateOpts): Promise(); for (const event of events) { - if (event.kind === 3036) { + if (event.kind === 1984 || event.kind === 3036) { ids.add(event.id); } } @@ -227,7 +231,7 @@ function gatherInfo({ events, store, signal }: HydrateOpts): Promise name === 'r')?.[1]?.split('@') ?? []; - let approvalStatus = 'pending'; - - if (n.has('approved')) { - approvalStatus = 'approved'; - } - if (n.has('rejected')) { - approvalStatus = 'rejected'; - } + const adminAccount = event.author + ? await renderAdminAccount(event.author) + : await renderAdminAccountFromPubkey(event.pubkey); return { + ...adminAccount, id: event.id, - account: event.author ? await renderAccount(event.author) : accountFromPubkey(event.pubkey), - name: event.tags.find(([name]) => name === 'r')?.[1] || '', - reason: event.content, - approval_status: approvalStatus, - created_at: new Date(event.created_at * 1000).toISOString(), + approved: n.has('approved'), + username, + domain, + invite_request: event.content, }; } diff --git a/src/views/mastodon/accounts.ts b/src/views/mastodon/accounts.ts index 9f2f052..99f69f0 100644 --- a/src/views/mastodon/accounts.ts +++ b/src/views/mastodon/accounts.ts @@ -79,7 +79,7 @@ async function renderAccount( pleroma: { is_admin: names.has('admin'), is_moderator: names.has('admin') || names.has('moderator'), - is_suggested: names.has('suggest'), + is_suggested: names.has('suggested'), is_local: parsed05?.domain === Conf.url.host, settings_store: undefined as unknown, tags: [...getTagSet(event.user?.tags ?? [], 't')], diff --git a/src/views/mastodon/admin-accounts.ts b/src/views/mastodon/admin-accounts.ts index 4dc8569..34b6860 100644 --- a/src/views/mastodon/admin-accounts.ts +++ b/src/views/mastodon/admin-accounts.ts @@ -1,9 +1,20 @@ import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; -import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; +import { DittoEvent } from '@/interfaces/DittoEvent.ts'; +import { getTagSet } from '@/utils/tags.ts'; /** Expects a kind 0 fully hydrated */ async function renderAdminAccount(event: DittoEvent) { const account = await renderAccount(event); + const names = getTagSet(event.user?.tags ?? [], 'n'); + + let role = 'user'; + + if (names.has('admin')) { + role = 'admin'; + } + if (names.has('moderator')) { + role = 'moderator'; + } return { id: account.id, @@ -15,12 +26,13 @@ async function renderAdminAccount(event: DittoEvent) { ips: [], locale: '', invite_request: null, - role: event.tags.find(([name]) => name === 'role')?.[1], + role, confirmed: true, approved: true, - disabled: false, - silenced: false, - suspended: false, + disabled: names.has('disabled'), + silenced: names.has('silenced'), + suspended: names.has('suspended'), + sensitized: names.has('sensitized'), account, }; } diff --git a/src/views/mastodon/notifications.ts b/src/views/mastodon/notifications.ts index 5b618d7..8f2a8a6 100644 --- a/src/views/mastodon/notifications.ts +++ b/src/views/mastodon/notifications.ts @@ -1,8 +1,10 @@ +import { NostrEvent } from '@nostrify/nostrify'; + +import { Conf } from '@/config.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { nostrDate } from '@/utils.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts'; -import { NostrEvent } from '@nostrify/nostrify'; interface RenderNotificationOpts { viewerPubkey: string; @@ -26,6 +28,10 @@ function renderNotification(event: DittoEvent, opts: RenderNotificationOpts) { if (event.kind === 7) { return renderReaction(event, opts); } + + if (event.kind === 30360 && event.pubkey === Conf.pubkey) { + return renderNameGrant(event); + } } async function renderMention(event: DittoEvent, opts: RenderNotificationOpts) { @@ -45,7 +51,7 @@ async function renderReblog(event: DittoEvent, opts: RenderNotificationOpts) { if (event.repost?.kind !== 1) return; const status = await renderStatus(event.repost, opts); if (!status) return; - const account = event.author ? await renderAccount(event.author) : accountFromPubkey(event.pubkey); + const account = event.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey); return { id: notificationId(event), @@ -60,7 +66,7 @@ async function renderFavourite(event: DittoEvent, opts: RenderNotificationOpts) if (event.reacted?.kind !== 1) return; const status = await renderStatus(event.reacted, opts); if (!status) return; - const account = event.author ? await renderAccount(event.author) : accountFromPubkey(event.pubkey); + const account = event.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey); return { id: notificationId(event), @@ -75,7 +81,7 @@ async function renderReaction(event: DittoEvent, opts: RenderNotificationOpts) { if (event.reacted?.kind !== 1) return; const status = await renderStatus(event.reacted, opts); if (!status) return; - const account = event.author ? await renderAccount(event.author) : accountFromPubkey(event.pubkey); + const account = event.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey); return { id: notificationId(event), @@ -87,6 +93,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}`; diff --git a/src/views/mastodon/reports.ts b/src/views/mastodon/reports.ts index bec08b4..48baa42 100644 --- a/src/views/mastodon/reports.ts +++ b/src/views/mastodon/reports.ts @@ -3,6 +3,7 @@ import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { nostrDate } from '@/utils.ts'; import { renderAdminAccount, renderAdminAccountFromPubkey } from '@/views/mastodon/admin-accounts.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts'; +import { getTagSet } from '@/utils/tags.ts'; /** Expects a `reportEvent` of kind 1984 and a `profile` of kind 0 of the person being reported */ async function renderReport(event: DittoEvent) { @@ -30,43 +31,42 @@ async function renderReport(event: DittoEvent) { interface RenderAdminReportOpts { viewerPubkey?: string; - actionTaken?: boolean; } /** Admin-level information about a filed report. * Expects an event of kind 1984 fully hydrated. * https://docs.joinmastodon.org/entities/Admin_Report */ -async function renderAdminReport(reportEvent: DittoEvent, opts: RenderAdminReportOpts) { - const { viewerPubkey, actionTaken = false } = opts; +async function renderAdminReport(event: DittoEvent, opts: RenderAdminReportOpts) { + const { viewerPubkey } = opts; // The category is present in both the 'e' and 'p' tag, however, it is possible to report a user without reporting a note, so it's better to get the category from the 'p' tag - const category = reportEvent.tags.find(([name]) => name === 'p')?.[2]; + const category = event.tags.find(([name]) => name === 'p')?.[2]; const statuses = []; - if (reportEvent.reported_notes) { - for (const status of reportEvent.reported_notes) { + if (event.reported_notes) { + for (const status of event.reported_notes) { statuses.push(await renderStatus(status, { viewerPubkey })); } } - const reportedPubkey = reportEvent.tags.find(([name]) => name === 'p')?.[1]; + const reportedPubkey = event.tags.find(([name]) => name === 'p')?.[1]; if (!reportedPubkey) { return; } + const names = getTagSet(event.info?.tags ?? [], 'n'); + return { - id: reportEvent.id, - action_taken: actionTaken, + id: event.id, + action_taken: names.has('closed'), action_taken_at: null, category, - comment: reportEvent.content, + comment: event.content, forwarded: false, - created_at: nostrDate(reportEvent.created_at).toISOString(), - account: reportEvent.author - ? await renderAdminAccount(reportEvent.author) - : await renderAdminAccountFromPubkey(reportEvent.pubkey), - target_account: reportEvent.reported_profile - ? await renderAdminAccount(reportEvent.reported_profile) + created_at: nostrDate(event.created_at).toISOString(), + account: event.author ? await renderAdminAccount(event.author) : await renderAdminAccountFromPubkey(event.pubkey), + target_account: event.reported_profile + ? await renderAdminAccount(event.reported_profile) : await renderAdminAccountFromPubkey(reportedPubkey), assigned_account: null, action_taken_by_account: null,