Merge branch 'names-api' into 'main'

Names API

See merge request soapbox-pub/ditto!370
This commit is contained in:
Alex Gleason 2024-06-09 20:35:05 +00:00
commit 5fdbd572f2
17 changed files with 430 additions and 164 deletions

View File

@ -26,11 +26,21 @@ import {
updateCredentialsController, updateCredentialsController,
verifyCredentialsController, verifyCredentialsController,
} from '@/controllers/api/accounts.ts'; } 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 { appCredentialsController, createAppController } from '@/controllers/api/apps.ts';
import { blocksController } from '@/controllers/api/blocks.ts'; import { blocksController } from '@/controllers/api/blocks.ts';
import { bookmarksController } from '@/controllers/api/bookmarks.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 { emptyArrayController, emptyObjectController, notImplementedController } from '@/controllers/api/fallback.ts';
import { instanceController } from '@/controllers/api/instance.ts'; import { instanceController } from '@/controllers/api/instance.ts';
import { markersController, updateMarkersController } from '@/controllers/api/markers.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 { relayController } from '@/controllers/nostr/relay.ts';
import { import {
adminReportController, adminReportController,
adminReportReopenController,
adminReportResolveController, adminReportResolveController,
adminReportsController, adminReportsController,
reportController, 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.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.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.get('/api/v1/pleroma/admin/config', requireRole('admin'), configController);
app.post('/api/v1/pleroma/admin/config', requireRole('admin'), updateConfigController); app.post('/api/v1/pleroma/admin/config', requireRole('admin'), updateConfigController);
app.delete('/api/v1/pleroma/admin/statuses/:id', requireRole('admin'), pleromaAdminDeleteStatusController); 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.put('/api/v1/admin/ditto/relays', requireRole('admin'), adminSetRelaysController);
app.post('/api/v1/ditto/names', requireSigner, nameRequestController); 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/ditto/zap', requireSigner, zapController);
app.post('/api/v1/reports', requireSigner, reportController); app.post('/api/v1/reports', requireSigner, reportController);
@ -255,8 +267,22 @@ app.post(
requireRole('admin'), requireRole('admin'),
adminReportResolveController, 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}}/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.put('/api/v1/pleroma/admin/users/tag', requireRole('admin'), pleromaAdminTagController);
app.delete('/api/v1/pleroma/admin/users/tag', requireRole('admin'), pleromaAdminUntagController); app.delete('/api/v1/pleroma/admin/users/tag', requireRole('admin'), pleromaAdminUntagController);

View File

@ -1,13 +1,14 @@
import { NostrEvent } from '@nostrify/nostrify'; import { NostrFilter } from '@nostrify/nostrify';
import { z } from 'zod'; import { z } from 'zod';
import { type AppController } from '@/app.ts'; import { type AppController } from '@/app.ts';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { booleanParamSchema } from '@/schema.ts'; import { booleanParamSchema } from '@/schema.ts';
import { Storages } from '@/storages.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 { 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({ const adminAccountQuerySchema = z.object({
local: booleanParamSchema.optional(), local: booleanParamSchema.optional(),
@ -27,62 +28,83 @@ const adminAccountQuerySchema = z.object({
}); });
const adminAccountsController: AppController = async (c) => { const adminAccountsController: AppController = async (c) => {
const store = await Storages.db();
const params = paginationSchema.parse(c.req.query());
const { signal } = c.req.raw;
const { const {
local,
pending, pending,
disabled, disabled,
silenced, silenced,
suspended, suspended,
sensitized, sensitized,
staff,
} = adminAccountQuerySchema.parse(c.req.query()); } = 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<string>();
const events: NostrEvent[] = [];
if (pending) { if (pending) {
for (const event of await store.query([{ kinds: [3036], '#p': [Conf.pubkey], ...params }], { signal })) { if (disabled || silenced || suspended || sensitized) {
pubkeys.add(event.pubkey); return c.json([]);
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);
}
} }
const orig = await store.query(
[{ kinds: [30383], authors: [Conf.pubkey], '#k': ['3036'], '#n': ['pending'], ...params }],
{ signal },
);
const ids = new Set<string>(
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 }) if (disabled || silenced || suspended || sensitized) {
.then((events) => hydrateEvents({ store, events, signal })); const n = [];
const accounts = await Promise.all( if (disabled) {
[...pubkeys].map(async (pubkey) => { n.push('disabled');
const author = authors.find((event) => event.pubkey === pubkey); }
const account = author ? await renderAdminAccount(author) : await renderAdminAccountFromPubkey(pubkey); if (silenced) {
const request = events.find((event) => event.kind === 3036 && event.pubkey === pubkey); n.push('silenced');
const grant = events.find( }
(event) => event.kind === 30360 && event.tags.find(([name]) => name === 'd')?.[1] === pubkey, if (suspended) {
); n.push('suspended');
}
if (sensitized) {
n.push('sensitized');
}
if (staff) {
n.push('admin');
n.push('moderator');
}
return { const events = await store.query([{ kinds: [30382], authors: [Conf.pubkey], '#n': n, ...params }], { signal });
...account, const pubkeys = new Set<string>(events.map(({ pubkey }) => pubkey));
invite_request: request?.content ?? null, const authors = await store.query([{ kinds: [0], authors: [...pubkeys] }])
invite_request_username: request?.tags.find(([name]) => name === 'r')?.[1] ?? null, .then((events) => hydrateEvents({ store, events, signal }));
approved: !!grant,
};
}),
);
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); return paginated(c, events, accounts);
}; };
@ -104,16 +126,16 @@ const adminActionController: AppController = async (c) => {
const n: Record<string, boolean> = {}; const n: Record<string, boolean> = {};
if (data.type === 'sensitive') { if (data.type === 'sensitive') {
n.sensitive = true; n.sensitized = true;
} }
if (data.type === 'disable') { if (data.type === 'disable') {
n.disable = true; n.disabled = true;
} }
if (data.type === 'silence') { if (data.type === 'silence') {
n.silence = true; n.silenced = true;
} }
if (data.type === 'suspend') { if (data.type === 'suspend') {
n.suspend = true; n.suspended = true;
} }
await updateUser(authorId, n, c); await updateUser(authorId, n, c);
@ -121,4 +143,59 @@ const adminActionController: AppController = async (c) => {
return c.json({}, 200); 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 };

View File

@ -1,12 +1,13 @@
import { NostrEvent } from '@nostrify/nostrify'; import { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { z } from 'zod'; import { z } from 'zod';
import { AppController } from '@/app.ts'; import { AppController } from '@/app.ts';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { booleanParamSchema } from '@/schema.ts';
import { AdminSigner } from '@/signers/AdminSigner.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import { hydrateEvents } from '@/storages/hydrate.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'; import { renderNameRequest } from '@/views/ditto.ts';
const markerSchema = z.enum(['read', 'write']); const markerSchema = z.enum(['read', 'write']);
@ -63,20 +64,20 @@ function renderRelays(event: NostrEvent): RelayEntity[] {
} }
const nameRequestSchema = z.object({ const nameRequestSchema = z.object({
nip05: z.string().email(), name: z.string().email(),
reason: z.string().max(500).optional(), reason: z.string().max(500).optional(),
}); });
export const nameRequestController: AppController = async (c) => { 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({ const event = await createEvent({
kind: 3036, kind: 3036,
content: reason, content: reason,
tags: [ tags: [
['r', nip05], ['r', name],
['L', 'nip05.domain'], ['L', 'nip05.domain'],
['l', nip05.split('@')[1], 'nip05.domain'], ['l', name.split('@')[1], 'nip05.domain'],
['p', Conf.pubkey], ['p', Conf.pubkey],
], ],
}, c); }, c);
@ -87,14 +88,50 @@ export const nameRequestController: AppController = async (c) => {
return c.json(nameRequest); return c.json(nameRequest);
}; };
const nameRequestsSchema = z.object({
approved: booleanParamSchema.optional(),
rejected: booleanParamSchema.optional(),
});
export const nameRequestsController: AppController = async (c) => { export const nameRequestsController: AppController = async (c) => {
const store = await Storages.db(); const store = await Storages.db();
const signer = c.get('signer')!; const signer = c.get('signer')!;
const pubkey = await signer.getPublicKey(); const pubkey = await signer.getPublicKey();
const events = await store.query([{ kinds: [3036], authors: [pubkey], limit: 20 }]) const params = paginationSchema.parse(c.req.query());
.then((events) => hydrateEvents({ events, store })); const { approved, rejected } = nameRequestsSchema.parse(c.req.query());
const nameRequests = await Promise.all(events.map(renderNameRequest)); const filter: NostrFilter = {
return c.json(nameRequests); 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<string>();
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);
}; };

View File

@ -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 { AppContext, AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { hydrateEvents } from '@/storages/hydrate.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'; 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 notificationsController: AppController = async (c) => {
const pubkey = await c.get('signer')?.getPublicKey()!; 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<number>();
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<string>,
params: PaginationParams,
c: AppContext,
) {
const store = c.get('store'); const store = c.get('store');
const pubkey = await c.get('signer')?.getPublicKey()!; const pubkey = await c.get('signer')?.getPublicKey()!;
const { signal } = c.req.raw; const { signal } = c.req.raw;
const opts = { signal, limit: params.limit };
const events = await store const events = await store
.query(filters, { signal }) .query(filters, opts)
.then((events) => events.filter((event) => event.pubkey !== pubkey)) .then((events) => events.filter((event) => event.pubkey !== pubkey))
.then((events) => hydrateEvents({ events, store, signal })); .then((events) => hydrateEvents({ events, store, signal }));
@ -26,9 +89,8 @@ async function renderNotifications(c: AppContext, filters: NostrFilter[]) {
return c.json([]); return c.json([]);
} }
const notifications = (await Promise const notifications = (await Promise.all(events.map((event) => renderNotification(event, { viewerPubkey: pubkey }))))
.all(events.map((event) => renderNotification(event, { viewerPubkey: pubkey })))) .filter((notification) => notification && types.has(notification.type));
.filter(Boolean);
if (!notifications.length) { if (!notifications.length) {
return c.json([]); return c.json([]);

View File

@ -157,7 +157,7 @@ const pleromaAdminSuggestController: AppController = async (c) => {
for (const nickname of nicknames) { for (const nickname of nicknames) {
const pubkey = await lookupPubkey(nickname); const pubkey = await lookupPubkey(nickname);
if (!pubkey) continue; if (!pubkey) continue;
await updateUser(pubkey, { suggest: true }, c); await updateUser(pubkey, { suggested: true }, c);
} }
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
@ -169,7 +169,7 @@ const pleromaAdminUnsuggestController: AppController = async (c) => {
for (const nickname of nicknames) { for (const nickname of nicknames) {
const pubkey = await lookupPubkey(nickname); const pubkey = await lookupPubkey(nickname);
if (!pubkey) continue; if (!pubkey) continue;
await updateUser(pubkey, { suggest: false }, c); await updateUser(pubkey, { suggested: false }, c);
} }
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });

View File

@ -3,11 +3,10 @@ import { z } from 'zod';
import { type AppController } from '@/app.ts'; import { type AppController } from '@/app.ts';
import { Conf } from '@/config.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 { hydrateEvents } from '@/storages/hydrate.ts';
import { renderAdminReport } from '@/views/mastodon/reports.ts'; import { renderAdminReport } from '@/views/mastodon/reports.ts';
import { renderReport } from '@/views/mastodon/reports.ts'; import { renderReport } from '@/views/mastodon/reports.ts';
import { getTagSet } from '@/utils/tags.ts';
import { booleanParamSchema } from '@/schema.ts'; import { booleanParamSchema } from '@/schema.ts';
const reportSchema = z.object({ const reportSchema = z.object({
@ -71,6 +70,7 @@ const adminReportsController: AppController = async (c) => {
const filter: NostrFilter = { const filter: NostrFilter = {
kinds: [30383], kinds: [30383],
authors: [Conf.pubkey], authors: [Conf.pubkey],
'#k': ['1984'],
...params, ...params,
}; };
@ -98,18 +98,10 @@ const adminReportsController: AppController = async (c) => {
.then((events) => hydrateEvents({ store, events: events, signal: c.req.raw.signal })); .then((events) => hydrateEvents({ store, events: events, signal: c.req.raw.signal }));
const reports = await Promise.all( const reports = await Promise.all(
events.map((event) => { events.map((event) => renderAdminReport(event, { viewerPubkey })),
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'),
});
}),
); );
return c.json(reports); return paginated(c, orig, reports);
}; };
/** https://docs.joinmastodon.org/methods/admin/reports/#get-one */ /** 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 updateEventInfo(eventId, { open: false, closed: true }, c);
await hydrateEvents({ events: [event], store, signal }); 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); 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,
};

View File

@ -1,4 +1,4 @@
import { NostrFilter } from '@nostrify/nostrify'; import { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import Debug from '@soapbox/stickynotes/debug'; import Debug from '@soapbox/stickynotes/debug';
import { z } from 'zod'; import { z } from 'zod';
@ -11,6 +11,7 @@ import { hydrateEvents } from '@/storages/hydrate.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import { bech32ToPubkey } from '@/utils.ts'; import { bech32ToPubkey } from '@/utils.ts';
import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts'; import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts';
import { renderNotification } from '@/views/mastodon/notifications.ts';
const debug = Debug('ditto:streaming'); 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 { 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) { function send(name: string, payload: object) {
if (socket.readyState === WebSocket.OPEN) { if (socket.readyState === WebSocket.OPEN) {
debug('send', name, JSON.stringify(payload)); debug('send', name, JSON.stringify(payload));
@ -63,52 +69,54 @@ const streamingController: AppController = async (c) => {
} }
} }
socket.onopen = async () => { async function sub(type: string, filters: NostrFilter[], render: (event: NostrEvent) => Promise<unknown>) {
if (!stream) return;
const filter = await topicToFilter(stream, c.req.query(), pubkey);
if (!filter) return;
try { try {
const db = await Storages.db(); for await (const msg of pubsub.req(filters, { signal: controller.signal })) {
const pubsub = await Storages.pubsub();
for await (const msg of pubsub.req([filter], { signal: controller.signal })) {
if (msg[0] === 'EVENT') { if (msg[0] === 'EVENT') {
const event = msg[2]; const event = msg[2];
if (pubkey) { if (policy) {
const policy = new MuteListPolicy(pubkey, await Storages.admin());
const [, , ok] = await policy.call(event); const [, , ok] = await policy.call(event);
if (!ok) { if (!ok) {
continue; continue;
} }
} }
await hydrateEvents({ await hydrateEvents({ events: [event], store, signal: AbortSignal.timeout(1000) });
events: [event],
store: db,
signal: AbortSignal.timeout(1000),
});
if (event.kind === 1) { const result = await render(event);
const status = await renderStatus(event, { viewerPubkey: pubkey });
if (status) {
send('update', status);
}
}
if (event.kind === 6) { if (result) {
const status = await renderReblog(event, { viewerPubkey: pubkey }); send(type, result);
if (status) {
send('update', status);
}
} }
} }
} }
} catch (e) { } catch (e) {
debug('streaming error:', 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 = () => { socket.onclose = () => {

View File

@ -31,7 +31,7 @@ async function renderV2Suggestions(c: AppContext, params: PaginatedListParams, s
const pubkey = await signer?.getPublicKey(); const pubkey = await signer?.getPublicKey();
const filters: NostrFilter[] = [ 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 }, { 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 events = await store.query(filters, { signal });
const [userEvents, followsEvent, mutesEvent, trendingEvent] = [ 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: [3], authors: [pubkey] }, event)) : undefined,
pubkey ? events.find((event) => matchFilter({ kinds: [10000], authors: [pubkey] }, event)) : undefined, pubkey ? events.find((event) => matchFilter({ kinds: [10000], authors: [pubkey] }, event)) : undefined,
events.find((event) => events.find((event) =>

View File

@ -45,10 +45,10 @@ async function handleEvent(event: DittoEvent, signal: AbortSignal): Promise<void
const n = getTagSet(event.user?.tags ?? [], 'n'); const n = getTagSet(event.user?.tags ?? [], 'n');
if (n.has('disable')) { if (n.has('disabled')) {
throw new RelayError('blocked', 'user is disabled'); throw new RelayError('blocked', 'user is disabled');
} }
if (n.has('suspend')) { if (n.has('suspended')) {
throw new RelayError('blocked', 'user is suspended'); throw new RelayError('blocked', 'user is suspended');
} }

View File

@ -30,7 +30,7 @@ export class AdminStore implements NStore {
const n = getTagSet(user?.tags ?? [], 'n'); const n = getTagSet(user?.tags ?? [], 'n');
if (n.has('disable') || n.has('suspend')) { if (n.has('disabled') || n.has('suspended')) {
return false; return false;
} }

View File

@ -223,6 +223,8 @@ class EventsDB implements NStore {
return event.content; return event.content;
case 30009: case 30009:
return EventsDB.buildTagsSearchContent(event.tags.filter(([t]) => t !== 'alt')); return EventsDB.buildTagsSearchContent(event.tags.filter(([t]) => t !== 'alt'));
case 30360:
return event.tags.find(([name]) => name === 'd')?.[1] || '';
default: default:
return ''; return '';
} }

View File

@ -206,6 +206,10 @@ function gatherAuthors({ events, store, signal }: HydrateOpts): Promise<DittoEve
function gatherUsers({ events, store, signal }: HydrateOpts): Promise<DittoEvent[]> { function gatherUsers({ events, store, signal }: HydrateOpts): Promise<DittoEvent[]> {
const pubkeys = new Set(events.map((event) => event.pubkey)); const pubkeys = new Set(events.map((event) => event.pubkey));
if (!pubkeys.size) {
return Promise.resolve([]);
}
return store.query( return store.query(
[{ kinds: [30382], authors: [Conf.pubkey], '#d': [...pubkeys], limit: pubkeys.size }], [{ kinds: [30382], authors: [Conf.pubkey], '#d': [...pubkeys], limit: pubkeys.size }],
{ signal }, { signal },
@ -217,7 +221,7 @@ function gatherInfo({ events, store, signal }: HydrateOpts): Promise<DittoEvent[
const ids = new Set<string>(); const ids = new Set<string>();
for (const event of events) { for (const event of events) {
if (event.kind === 3036) { if (event.kind === 1984 || event.kind === 3036) {
ids.add(event.id); ids.add(event.id);
} }
} }
@ -227,7 +231,7 @@ function gatherInfo({ events, store, signal }: HydrateOpts): Promise<DittoEvent[
} }
return store.query( return store.query(
[{ ids: [...ids], limit: ids.size }], [{ kinds: [30383], authors: [Conf.pubkey], '#d': [...ids], limit: ids.size }],
{ signal }, { signal },
); );
} }

View File

@ -1,25 +1,22 @@
import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
import { getTagSet } from '@/utils/tags.ts'; import { getTagSet } from '@/utils/tags.ts';
import { renderAdminAccount, renderAdminAccountFromPubkey } from '@/views/mastodon/admin-accounts.ts';
/** Renders an Admin::Account entity from a name request event. */
export async function renderNameRequest(event: DittoEvent) { export async function renderNameRequest(event: DittoEvent) {
const n = getTagSet(event.info?.tags ?? [], 'n'); const n = getTagSet(event.info?.tags ?? [], 'n');
const [username, domain] = event.tags.find(([name]) => name === 'r')?.[1]?.split('@') ?? [];
let approvalStatus = 'pending'; const adminAccount = event.author
? await renderAdminAccount(event.author)
if (n.has('approved')) { : await renderAdminAccountFromPubkey(event.pubkey);
approvalStatus = 'approved';
}
if (n.has('rejected')) {
approvalStatus = 'rejected';
}
return { return {
...adminAccount,
id: event.id, id: event.id,
account: event.author ? await renderAccount(event.author) : accountFromPubkey(event.pubkey), approved: n.has('approved'),
name: event.tags.find(([name]) => name === 'r')?.[1] || '', username,
reason: event.content, domain,
approval_status: approvalStatus, invite_request: event.content,
created_at: new Date(event.created_at * 1000).toISOString(),
}; };
} }

View File

@ -79,7 +79,7 @@ async function renderAccount(
pleroma: { pleroma: {
is_admin: names.has('admin'), is_admin: names.has('admin'),
is_moderator: names.has('admin') || names.has('moderator'), 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, is_local: parsed05?.domain === Conf.url.host,
settings_store: undefined as unknown, settings_store: undefined as unknown,
tags: [...getTagSet(event.user?.tags ?? [], 't')], tags: [...getTagSet(event.user?.tags ?? [], 't')],

View File

@ -1,9 +1,20 @@
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; 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 */ /** Expects a kind 0 fully hydrated */
async function renderAdminAccount(event: DittoEvent) { async function renderAdminAccount(event: DittoEvent) {
const account = await renderAccount(event); 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 { return {
id: account.id, id: account.id,
@ -15,12 +26,13 @@ async function renderAdminAccount(event: DittoEvent) {
ips: [], ips: [],
locale: '', locale: '',
invite_request: null, invite_request: null,
role: event.tags.find(([name]) => name === 'role')?.[1], role,
confirmed: true, confirmed: true,
approved: true, approved: true,
disabled: false, disabled: names.has('disabled'),
silenced: false, silenced: names.has('silenced'),
suspended: false, suspended: names.has('suspended'),
sensitized: names.has('sensitized'),
account, account,
}; };
} }

View File

@ -1,8 +1,10 @@
import { NostrEvent } from '@nostrify/nostrify';
import { Conf } from '@/config.ts';
import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { nostrDate } from '@/utils.ts'; import { nostrDate } from '@/utils.ts';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
import { renderStatus } from '@/views/mastodon/statuses.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts';
import { NostrEvent } from '@nostrify/nostrify';
interface RenderNotificationOpts { interface RenderNotificationOpts {
viewerPubkey: string; viewerPubkey: string;
@ -26,6 +28,10 @@ function renderNotification(event: DittoEvent, opts: RenderNotificationOpts) {
if (event.kind === 7) { if (event.kind === 7) {
return renderReaction(event, opts); return renderReaction(event, opts);
} }
if (event.kind === 30360 && event.pubkey === Conf.pubkey) {
return renderNameGrant(event);
}
} }
async function renderMention(event: DittoEvent, opts: RenderNotificationOpts) { async function renderMention(event: DittoEvent, opts: RenderNotificationOpts) {
@ -45,7 +51,7 @@ async function renderReblog(event: DittoEvent, opts: RenderNotificationOpts) {
if (event.repost?.kind !== 1) return; if (event.repost?.kind !== 1) return;
const status = await renderStatus(event.repost, opts); const status = await renderStatus(event.repost, opts);
if (!status) return; 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 { return {
id: notificationId(event), id: notificationId(event),
@ -60,7 +66,7 @@ async function renderFavourite(event: DittoEvent, opts: RenderNotificationOpts)
if (event.reacted?.kind !== 1) return; if (event.reacted?.kind !== 1) return;
const status = await renderStatus(event.reacted, opts); const status = await renderStatus(event.reacted, opts);
if (!status) return; 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 { return {
id: notificationId(event), id: notificationId(event),
@ -75,7 +81,7 @@ async function renderReaction(event: DittoEvent, opts: RenderNotificationOpts) {
if (event.reacted?.kind !== 1) return; if (event.reacted?.kind !== 1) return;
const status = await renderStatus(event.reacted, opts); const status = await renderStatus(event.reacted, opts);
if (!status) return; 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 { return {
id: notificationId(event), 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. */ /** This helps notifications be sorted in the correct order. */
function notificationId({ id, created_at }: NostrEvent): string { function notificationId({ id, created_at }: NostrEvent): string {
return `${created_at}-${id}`; return `${created_at}-${id}`;

View File

@ -3,6 +3,7 @@ import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
import { nostrDate } from '@/utils.ts'; import { nostrDate } from '@/utils.ts';
import { renderAdminAccount, renderAdminAccountFromPubkey } from '@/views/mastodon/admin-accounts.ts'; import { renderAdminAccount, renderAdminAccountFromPubkey } from '@/views/mastodon/admin-accounts.ts';
import { renderStatus } from '@/views/mastodon/statuses.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 */ /** Expects a `reportEvent` of kind 1984 and a `profile` of kind 0 of the person being reported */
async function renderReport(event: DittoEvent) { async function renderReport(event: DittoEvent) {
@ -30,43 +31,42 @@ async function renderReport(event: DittoEvent) {
interface RenderAdminReportOpts { interface RenderAdminReportOpts {
viewerPubkey?: string; viewerPubkey?: string;
actionTaken?: boolean;
} }
/** Admin-level information about a filed report. /** Admin-level information about a filed report.
* Expects an event of kind 1984 fully hydrated. * Expects an event of kind 1984 fully hydrated.
* https://docs.joinmastodon.org/entities/Admin_Report */ * https://docs.joinmastodon.org/entities/Admin_Report */
async function renderAdminReport(reportEvent: DittoEvent, opts: RenderAdminReportOpts) { async function renderAdminReport(event: DittoEvent, opts: RenderAdminReportOpts) {
const { viewerPubkey, actionTaken = false } = opts; 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 // 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 = []; const statuses = [];
if (reportEvent.reported_notes) { if (event.reported_notes) {
for (const status of reportEvent.reported_notes) { for (const status of event.reported_notes) {
statuses.push(await renderStatus(status, { viewerPubkey })); 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) { if (!reportedPubkey) {
return; return;
} }
const names = getTagSet(event.info?.tags ?? [], 'n');
return { return {
id: reportEvent.id, id: event.id,
action_taken: actionTaken, action_taken: names.has('closed'),
action_taken_at: null, action_taken_at: null,
category, category,
comment: reportEvent.content, comment: event.content,
forwarded: false, forwarded: false,
created_at: nostrDate(reportEvent.created_at).toISOString(), created_at: nostrDate(event.created_at).toISOString(),
account: reportEvent.author account: event.author ? await renderAdminAccount(event.author) : await renderAdminAccountFromPubkey(event.pubkey),
? await renderAdminAccount(reportEvent.author) target_account: event.reported_profile
: await renderAdminAccountFromPubkey(reportEvent.pubkey), ? await renderAdminAccount(event.reported_profile)
target_account: reportEvent.reported_profile
? await renderAdminAccount(reportEvent.reported_profile)
: await renderAdminAccountFromPubkey(reportedPubkey), : await renderAdminAccountFromPubkey(reportedPubkey),
assigned_account: null, assigned_account: null,
action_taken_by_account: null, action_taken_by_account: null,