From c79867d8ca398ff4f9e25155ba1b4a5630aa86fb Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Fri, 28 Feb 2025 18:25:34 -0300 Subject: [PATCH 1/7] feat: promote users to admin --- packages/ditto/app.ts | 6 ++ packages/ditto/controllers/api/pleroma.ts | 96 ++++++++++++++++++++++- 2 files changed, 100 insertions(+), 2 deletions(-) diff --git a/packages/ditto/app.ts b/packages/ditto/app.ts index 44b0b9b4..5bc27a42 100644 --- a/packages/ditto/app.ts +++ b/packages/ditto/app.ts @@ -85,6 +85,7 @@ import { pleromaAdminTagController, pleromaAdminUnsuggestController, pleromaAdminUntagController, + pleromaPromoteAdminController, updateConfigController, } from '@/controllers/api/pleroma.ts'; import { preferencesController } from '@/controllers/api/preferences.ts'; @@ -440,6 +441,11 @@ app.delete('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', userMi app.get('/api/v1/pleroma/admin/config', userMiddleware({ role: 'admin' }), configController); app.post('/api/v1/pleroma/admin/config', userMiddleware({ role: 'admin' }), updateConfigController); app.delete('/api/v1/pleroma/admin/statuses/:id', userMiddleware({ role: 'admin' }), pleromaAdminDeleteStatusController); +app.post( + '/api/v1/pleroma/admin/users/permission_group/admin', + userMiddleware({ role: 'admin' }), + pleromaPromoteAdminController, +); app.get('/api/v1/admin/ditto/relays', userMiddleware({ role: 'admin' }), adminRelaysController); app.put('/api/v1/admin/ditto/relays', userMiddleware({ role: 'admin' }), adminSetRelaysController); diff --git a/packages/ditto/controllers/api/pleroma.ts b/packages/ditto/controllers/api/pleroma.ts index b4458c6c..d42a7f3b 100644 --- a/packages/ditto/controllers/api/pleroma.ts +++ b/packages/ditto/controllers/api/pleroma.ts @@ -1,10 +1,14 @@ +import { nip19 } from 'nostr-tools'; import { z } from 'zod'; import { type AppController } from '@/app.ts'; -import { configSchema, elixirTupleSchema } from '@/schemas/pleroma-api.ts'; -import { createAdminEvent, updateAdminEvent, updateUser } from '@/utils/api.ts'; +import { createAdminEvent, parseBody, updateAdminEvent, updateUser } from '@/utils/api.ts'; +import { nostrNow } from '@/utils.ts'; import { lookupPubkey } from '@/utils/lookup.ts'; import { getPleromaConfigs } from '@/utils/pleroma.ts'; +import { renderAccount } from '@/views/mastodon/accounts.ts'; +import { configSchema, elixirTupleSchema } from '@/schemas/pleroma-api.ts'; +import { hydrateEvents } from '@/storages/hydrate.ts'; const frontendConfigController: AppController = async (c) => { const configDB = await getPleromaConfigs(c.var); @@ -62,6 +66,93 @@ const pleromaAdminTagSchema = z.object({ tags: z.string().array(), }); +const pleromaPromoteAdminSchema = z.object({ + nicknames: z.string().transform((value, ctx) => { + try { + const { type, data } = nip19.decode(value); + if (type === 'npub') { + return data; + } + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Not a valid nip 19 npub', + }); + + return z.NEVER; + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Not a valid nip 19 npub', + }); + + return z.NEVER; + } + }).array().min(1), +}); + +const pleromaPromoteAdminController: AppController = async (c) => { + const { conf, relay, signal } = c.var; + const body = await parseBody(c.req.raw); + const result = pleromaPromoteAdminSchema.safeParse(body); + + if (!result.success) { + return c.json({ error: 'Bad request', schema: result.error }, 422); + } + + const { data } = result; + const { nicknames: authors } = data; + + const events = await relay.query([{ kinds: [0], authors }], { signal }); + + if (events.length !== authors.length) { + return c.json({ error: 'User profile is missing in the database' }, 422); + } + + events.forEach(async (event) => { + const [existing] = await relay.query([{ + kinds: [30382], + authors: [await conf.signer.getPublicKey()], + '#d': [event.pubkey], + limit: 1, + }]); + + const prevTags = (existing?.tags ?? []).filter(([name, value]) => { + if (name === 'd') { + return false; + } + if (name === 'n' && value === 'admin') { + return false; + } + return true; + }); + + const tags: string[][] = [ + ['d', event.pubkey], + ['n', 'admin'], + ]; + + tags.push(...prevTags); + + const promotion = await conf.signer.signEvent({ + kind: 30382, + tags, + content: '', + created_at: nostrNow(), + }); + + await relay.event(promotion); + }); + + await hydrateEvents({ ...c.var, events }); + + const accounts = events.map((event) => { + return renderAccount(event); + }); + + return c.json(accounts, 200); +}; + const pleromaAdminTagController: AppController = async (c) => { const { conf } = c.var; const params = pleromaAdminTagSchema.parse(await c.req.json()); @@ -154,5 +245,6 @@ export { pleromaAdminTagController, pleromaAdminUnsuggestController, pleromaAdminUntagController, + pleromaPromoteAdminController, updateConfigController, }; From 3bcf7167950ffe53133d509821852a1c47704fa6 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Mon, 3 Mar 2025 17:32:28 -0300 Subject: [PATCH 2/7] refactor: simplify promote user to admin --- packages/ditto/controllers/api/pleroma.ts | 99 +++++++++-------------- 1 file changed, 36 insertions(+), 63 deletions(-) diff --git a/packages/ditto/controllers/api/pleroma.ts b/packages/ditto/controllers/api/pleroma.ts index d42a7f3b..4552c35e 100644 --- a/packages/ditto/controllers/api/pleroma.ts +++ b/packages/ditto/controllers/api/pleroma.ts @@ -6,9 +6,10 @@ import { createAdminEvent, parseBody, updateAdminEvent, updateUser } from '@/uti import { nostrNow } from '@/utils.ts'; import { lookupPubkey } from '@/utils/lookup.ts'; import { getPleromaConfigs } from '@/utils/pleroma.ts'; -import { renderAccount } from '@/views/mastodon/accounts.ts'; +import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { configSchema, elixirTupleSchema } from '@/schemas/pleroma-api.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; +import { logi } from '@soapbox/logi'; const frontendConfigController: AppController = async (c) => { const configDB = await getPleromaConfigs(c.var); @@ -67,28 +68,7 @@ const pleromaAdminTagSchema = z.object({ }); const pleromaPromoteAdminSchema = z.object({ - nicknames: z.string().transform((value, ctx) => { - try { - const { type, data } = nip19.decode(value); - if (type === 'npub') { - return data; - } - - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Not a valid nip 19 npub', - }); - - return z.NEVER; - } catch { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Not a valid nip 19 npub', - }); - - return z.NEVER; - } - }).array().min(1), + nicknames: z.string().array(), }); const pleromaPromoteAdminController: AppController = async (c) => { @@ -101,53 +81,46 @@ const pleromaPromoteAdminController: AppController = async (c) => { } const { data } = result; - const { nicknames: authors } = data; + const { nicknames } = data; - const events = await relay.query([{ kinds: [0], authors }], { signal }); + const pubkeys: string[] = []; - if (events.length !== authors.length) { - return c.json({ error: 'User profile is missing in the database' }, 422); + for (const nickname of nicknames) { + const pubkey = await lookupPubkey(nickname, c.var); + if (!pubkey) continue; + + pubkeys.push(pubkey); + + await updateAdminEvent( + { kinds: [30382], authors: [await conf.signer.getPublicKey()], '#d': [pubkey], limit: 1 }, + (prev) => { + const tags = prev?.tags ?? [['d', pubkey]]; + + const existing = prev?.tags.some(([name, value]) => name === 'n' && value === 'admin'); + if (!existing) { + tags.push(['n', 'admin']); + } + + return { + kind: 30382, + content: prev?.content ?? '', + tags, + }; + }, + c, + ); } - events.forEach(async (event) => { - const [existing] = await relay.query([{ - kinds: [30382], - authors: [await conf.signer.getPublicKey()], - '#d': [event.pubkey], - limit: 1, - }]); - - const prevTags = (existing?.tags ?? []).filter(([name, value]) => { - if (name === 'd') { - return false; - } - if (name === 'n' && value === 'admin') { - return false; - } - return true; - }); - - const tags: string[][] = [ - ['d', event.pubkey], - ['n', 'admin'], - ]; - - tags.push(...prevTags); - - const promotion = await conf.signer.signEvent({ - kind: 30382, - tags, - content: '', - created_at: nostrNow(), - }); - - await relay.event(promotion); - }); + const events = await relay.query([{ kinds: [0], authors: pubkeys }], { signal }); await hydrateEvents({ ...c.var, events }); - const accounts = events.map((event) => { - return renderAccount(event); + const accounts = pubkeys.map((pubkey) => { + const event = events.find((e) => e.pubkey === pubkey); + if (event) { + return renderAccount(event); + } + return accountFromPubkey(pubkey); }); return c.json(accounts, 200); From 28c3b07a3e9c40c60fd27df32b5277c013b9c509 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Mon, 3 Mar 2025 17:34:13 -0300 Subject: [PATCH 3/7] fix: remove unused imports --- packages/ditto/controllers/api/pleroma.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/ditto/controllers/api/pleroma.ts b/packages/ditto/controllers/api/pleroma.ts index 4552c35e..2c5a1b88 100644 --- a/packages/ditto/controllers/api/pleroma.ts +++ b/packages/ditto/controllers/api/pleroma.ts @@ -1,15 +1,12 @@ -import { nip19 } from 'nostr-tools'; import { z } from 'zod'; import { type AppController } from '@/app.ts'; import { createAdminEvent, parseBody, updateAdminEvent, updateUser } from '@/utils/api.ts'; -import { nostrNow } from '@/utils.ts'; import { lookupPubkey } from '@/utils/lookup.ts'; import { getPleromaConfigs } from '@/utils/pleroma.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { configSchema, elixirTupleSchema } from '@/schemas/pleroma-api.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; -import { logi } from '@soapbox/logi'; const frontendConfigController: AppController = async (c) => { const configDB = await getPleromaConfigs(c.var); From a2f019993d1174b98a0203fe12b232fc6e97fa62 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 3 Mar 2025 15:58:13 -0600 Subject: [PATCH 4/7] Simplify promote controller --- packages/ditto/app.ts | 6 +-- packages/ditto/controllers/api/pleroma.ts | 53 +++++------------------ 2 files changed, 14 insertions(+), 45 deletions(-) diff --git a/packages/ditto/app.ts b/packages/ditto/app.ts index 5bc27a42..ce6afe1d 100644 --- a/packages/ditto/app.ts +++ b/packages/ditto/app.ts @@ -81,11 +81,11 @@ import { configController, frontendConfigController, pleromaAdminDeleteStatusController, + pleromaAdminPromoteController, pleromaAdminSuggestController, pleromaAdminTagController, pleromaAdminUnsuggestController, pleromaAdminUntagController, - pleromaPromoteAdminController, updateConfigController, } from '@/controllers/api/pleroma.ts'; import { preferencesController } from '@/controllers/api/preferences.ts'; @@ -442,9 +442,9 @@ app.get('/api/v1/pleroma/admin/config', userMiddleware({ role: 'admin' }), confi app.post('/api/v1/pleroma/admin/config', userMiddleware({ role: 'admin' }), updateConfigController); app.delete('/api/v1/pleroma/admin/statuses/:id', userMiddleware({ role: 'admin' }), pleromaAdminDeleteStatusController); app.post( - '/api/v1/pleroma/admin/users/permission_group/admin', + '/api/v1/pleroma/admin/users/permission_group/:group', userMiddleware({ role: 'admin' }), - pleromaPromoteAdminController, + pleromaAdminPromoteController, ); app.get('/api/v1/admin/ditto/relays', userMiddleware({ role: 'admin' }), adminRelaysController); diff --git a/packages/ditto/controllers/api/pleroma.ts b/packages/ditto/controllers/api/pleroma.ts index 2c5a1b88..97cdb6d2 100644 --- a/packages/ditto/controllers/api/pleroma.ts +++ b/packages/ditto/controllers/api/pleroma.ts @@ -4,9 +4,7 @@ import { type AppController } from '@/app.ts'; import { createAdminEvent, parseBody, updateAdminEvent, updateUser } from '@/utils/api.ts'; import { lookupPubkey } from '@/utils/lookup.ts'; import { getPleromaConfigs } from '@/utils/pleroma.ts'; -import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { configSchema, elixirTupleSchema } from '@/schemas/pleroma-api.ts'; -import { hydrateEvents } from '@/storages/hydrate.ts'; const frontendConfigController: AppController = async (c) => { const configDB = await getPleromaConfigs(c.var); @@ -68,59 +66,30 @@ const pleromaPromoteAdminSchema = z.object({ nicknames: z.string().array(), }); -const pleromaPromoteAdminController: AppController = async (c) => { - const { conf, relay, signal } = c.var; +const pleromaAdminPromoteController: AppController = async (c) => { const body = await parseBody(c.req.raw); const result = pleromaPromoteAdminSchema.safeParse(body); + const group = c.req.param('group'); if (!result.success) { return c.json({ error: 'Bad request', schema: result.error }, 422); } + if (!['admin', 'moderator'].includes(group)) { + return c.json({ error: 'Bad request', schema: 'Invalid group' }, 422); + } + const { data } = result; const { nicknames } = data; - const pubkeys: string[] = []; - for (const nickname of nicknames) { const pubkey = await lookupPubkey(nickname, c.var); - if (!pubkey) continue; - - pubkeys.push(pubkey); - - await updateAdminEvent( - { kinds: [30382], authors: [await conf.signer.getPublicKey()], '#d': [pubkey], limit: 1 }, - (prev) => { - const tags = prev?.tags ?? [['d', pubkey]]; - - const existing = prev?.tags.some(([name, value]) => name === 'n' && value === 'admin'); - if (!existing) { - tags.push(['n', 'admin']); - } - - return { - kind: 30382, - content: prev?.content ?? '', - tags, - }; - }, - c, - ); + if (pubkey) { + await updateUser(pubkey, { [group]: true }, c); + } } - const events = await relay.query([{ kinds: [0], authors: pubkeys }], { signal }); - - await hydrateEvents({ ...c.var, events }); - - const accounts = pubkeys.map((pubkey) => { - const event = events.find((e) => e.pubkey === pubkey); - if (event) { - return renderAccount(event); - } - return accountFromPubkey(pubkey); - }); - - return c.json(accounts, 200); + return c.json({ is_admin: true }, 200); }; const pleromaAdminTagController: AppController = async (c) => { @@ -211,10 +180,10 @@ export { configController, frontendConfigController, pleromaAdminDeleteStatusController, + pleromaAdminPromoteController, pleromaAdminSuggestController, pleromaAdminTagController, pleromaAdminUnsuggestController, pleromaAdminUntagController, - pleromaPromoteAdminController, updateConfigController, }; From b7bf2fc76f09bb5e6433fffe8fae3f6dbccaa6b5 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 3 Mar 2025 16:33:28 -0600 Subject: [PATCH 5/7] Move Pleroma permission_groups controller to its own routes file, add tests --- packages/ditto/app.ts | 8 +--- packages/ditto/controllers/api/pleroma.ts | 33 +-------------- packages/ditto/routes/dittoNamesRoute.test.ts | 9 ++--- .../pleromaAdminPermissionGroupsRoute.test.ts | 35 ++++++++++++++++ .../pleromaAdminPermissionGroupsRoute.ts | 40 +++++++++++++++++++ packages/ditto/utils/api.ts | 2 +- packages/mastoapi/router/DittoRoute.ts | 2 +- packages/mastoapi/test/TestApp.ts | 28 ++++++++++++- 8 files changed, 109 insertions(+), 48 deletions(-) create mode 100644 packages/ditto/routes/pleromaAdminPermissionGroupsRoute.test.ts create mode 100644 packages/ditto/routes/pleromaAdminPermissionGroupsRoute.ts diff --git a/packages/ditto/app.ts b/packages/ditto/app.ts index 04446a9f..931b3825 100644 --- a/packages/ditto/app.ts +++ b/packages/ditto/app.ts @@ -79,7 +79,6 @@ import { configController, frontendConfigController, pleromaAdminDeleteStatusController, - pleromaAdminPromoteController, pleromaAdminSuggestController, pleromaAdminTagController, pleromaAdminUnsuggestController, @@ -149,6 +148,7 @@ import { uploaderMiddleware } from '@/middleware/uploaderMiddleware.ts'; import { translatorMiddleware } from '@/middleware/translatorMiddleware.ts'; import { logiMiddleware } from '@/middleware/logiMiddleware.ts'; import dittoNamesRoute from '@/routes/dittoNamesRoute.ts'; +import pleromaAdminPermissionGroupsRoute from '@/routes/pleromaAdminPermissionGroupsRoute.ts'; import { DittoRelayStore } from '@/storages/DittoRelayStore.ts'; export interface AppEnv extends DittoEnv { @@ -440,11 +440,7 @@ app.delete('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', userMi app.get('/api/v1/pleroma/admin/config', userMiddleware({ role: 'admin' }), configController); app.post('/api/v1/pleroma/admin/config', userMiddleware({ role: 'admin' }), updateConfigController); app.delete('/api/v1/pleroma/admin/statuses/:id', userMiddleware({ role: 'admin' }), pleromaAdminDeleteStatusController); -app.post( - '/api/v1/pleroma/admin/users/permission_group/:group', - userMiddleware({ role: 'admin' }), - pleromaAdminPromoteController, -); +app.route('/api/v1/pleroma/admin/users/permission_group', pleromaAdminPermissionGroupsRoute); app.get('/api/v1/admin/ditto/relays', userMiddleware({ role: 'admin' }), adminRelaysController); app.put('/api/v1/admin/ditto/relays', userMiddleware({ role: 'admin' }), adminSetRelaysController); diff --git a/packages/ditto/controllers/api/pleroma.ts b/packages/ditto/controllers/api/pleroma.ts index 97cdb6d2..b9a5b561 100644 --- a/packages/ditto/controllers/api/pleroma.ts +++ b/packages/ditto/controllers/api/pleroma.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { type AppController } from '@/app.ts'; -import { createAdminEvent, parseBody, updateAdminEvent, updateUser } from '@/utils/api.ts'; +import { createAdminEvent, updateAdminEvent, updateUser } from '@/utils/api.ts'; import { lookupPubkey } from '@/utils/lookup.ts'; import { getPleromaConfigs } from '@/utils/pleroma.ts'; import { configSchema, elixirTupleSchema } from '@/schemas/pleroma-api.ts'; @@ -62,36 +62,6 @@ const pleromaAdminTagSchema = z.object({ tags: z.string().array(), }); -const pleromaPromoteAdminSchema = z.object({ - nicknames: z.string().array(), -}); - -const pleromaAdminPromoteController: AppController = async (c) => { - const body = await parseBody(c.req.raw); - const result = pleromaPromoteAdminSchema.safeParse(body); - const group = c.req.param('group'); - - if (!result.success) { - return c.json({ error: 'Bad request', schema: result.error }, 422); - } - - if (!['admin', 'moderator'].includes(group)) { - return c.json({ error: 'Bad request', schema: 'Invalid group' }, 422); - } - - const { data } = result; - const { nicknames } = data; - - for (const nickname of nicknames) { - const pubkey = await lookupPubkey(nickname, c.var); - if (pubkey) { - await updateUser(pubkey, { [group]: true }, c); - } - } - - return c.json({ is_admin: true }, 200); -}; - const pleromaAdminTagController: AppController = async (c) => { const { conf } = c.var; const params = pleromaAdminTagSchema.parse(await c.req.json()); @@ -180,7 +150,6 @@ export { configController, frontendConfigController, pleromaAdminDeleteStatusController, - pleromaAdminPromoteController, pleromaAdminSuggestController, pleromaAdminTagController, pleromaAdminUnsuggestController, diff --git a/packages/ditto/routes/dittoNamesRoute.test.ts b/packages/ditto/routes/dittoNamesRoute.test.ts index e443be96..9974b4a4 100644 --- a/packages/ditto/routes/dittoNamesRoute.test.ts +++ b/packages/ditto/routes/dittoNamesRoute.test.ts @@ -4,11 +4,10 @@ import { assertEquals } from '@std/assert'; import route from './dittoNamesRoute.ts'; Deno.test('POST / creates a name request event', async () => { - await using app = new TestApp(); + await using app = new TestApp(route); const { conf, relay } = app.var; const user = app.user(); - app.route('/', route); const response = await app.api.post('/', { name: 'Alex@Ditto.pub', reason: 'for testing' }); @@ -28,10 +27,9 @@ Deno.test('POST / creates a name request event', async () => { }); Deno.test('POST / can be called multiple times with the same name', async () => { - await using app = new TestApp(); + await using app = new TestApp(route); app.user(); - app.route('/', route); const response1 = await app.api.post('/', { name: 'alex@ditto.pub' }); const response2 = await app.api.post('/', { name: 'alex@ditto.pub' }); @@ -41,11 +39,10 @@ Deno.test('POST / can be called multiple times with the same name', async () => }); Deno.test('POST / returns 400 if the name has already been granted', async () => { - await using app = new TestApp(); + await using app = new TestApp(route); const { conf, relay } = app.var; app.user(); - app.route('/', route); const grant = await conf.signer.signEvent({ kind: 30360, diff --git a/packages/ditto/routes/pleromaAdminPermissionGroupsRoute.test.ts b/packages/ditto/routes/pleromaAdminPermissionGroupsRoute.test.ts new file mode 100644 index 00000000..1c1fcd18 --- /dev/null +++ b/packages/ditto/routes/pleromaAdminPermissionGroupsRoute.test.ts @@ -0,0 +1,35 @@ +import { TestApp } from '@ditto/mastoapi/test'; +import { assertEquals } from '@std/assert'; +import { nip19 } from 'nostr-tools'; + +import route from './pleromaAdminPermissionGroupsRoute.ts'; + +Deno.test('POST /admin returns 403 if user is not an admin', async () => { + await using app = new TestApp(route); + + app.user(); + + const response = await app.api.post('/admin', { nicknames: ['alex@ditto.pub'] }); + + assertEquals(response.status, 403); +}); + +Deno.test('POST /admin promotes to admin', async () => { + await using app = new TestApp(route); + const { conf, relay } = app.var; + + await app.admin(); + + const pawn = app.createUser(); + const pubkey = await pawn.signer.getPublicKey(); + + const response = await app.api.post('/admin', { nicknames: [nip19.npubEncode(pubkey)] }); + const json = await response.json(); + + assertEquals(response.status, 200); + assertEquals(json, { is_admin: true }); + + const [event] = await relay.query([{ kinds: [30382], authors: [await conf.signer.getPublicKey()], '#d': [pubkey] }]); + + assertEquals(event.tags, [['d', pubkey], ['n', 'admin']]); +}); diff --git a/packages/ditto/routes/pleromaAdminPermissionGroupsRoute.ts b/packages/ditto/routes/pleromaAdminPermissionGroupsRoute.ts new file mode 100644 index 00000000..d2a0bb4f --- /dev/null +++ b/packages/ditto/routes/pleromaAdminPermissionGroupsRoute.ts @@ -0,0 +1,40 @@ +import { userMiddleware } from '@ditto/mastoapi/middleware'; +import { DittoRoute } from '@ditto/mastoapi/router'; +import { z } from 'zod'; + +import { parseBody, updateUser } from '@/utils/api.ts'; +import { lookupPubkey } from '@/utils/lookup.ts'; + +const route = new DittoRoute(); + +const pleromaPromoteAdminSchema = z.object({ + nicknames: z.string().array(), +}); + +route.post('/:group', userMiddleware({ role: 'admin' }), async (c) => { + const body = await parseBody(c.req.raw); + const result = pleromaPromoteAdminSchema.safeParse(body); + const group = c.req.param('group'); + + if (!result.success) { + return c.json({ error: 'Bad request', schema: result.error }, 422); + } + + if (!['admin', 'moderator'].includes(group)) { + return c.json({ error: 'Bad request', schema: 'Invalid group' }, 422); + } + + const { data } = result; + const { nicknames } = data; + + for (const nickname of nicknames) { + const pubkey = await lookupPubkey(nickname, c.var); + if (pubkey) { + await updateUser(pubkey, { [group]: true }, c); + } + } + + return c.json({ is_admin: true }, 200); +}); + +export default route; diff --git a/packages/ditto/utils/api.ts b/packages/ditto/utils/api.ts index a8242f73..14a5bed5 100644 --- a/packages/ditto/utils/api.ts +++ b/packages/ditto/utils/api.ts @@ -118,7 +118,7 @@ async function updateAdminEvent( return createAdminEvent(fn(prev), c); } -function updateUser(pubkey: string, n: Record, c: AppContext): Promise { +function updateUser(pubkey: string, n: Record, c: Context): Promise { return updateNames(30382, pubkey, n, c); } diff --git a/packages/mastoapi/router/DittoRoute.ts b/packages/mastoapi/router/DittoRoute.ts index 4c78c4b3..d4abb20c 100644 --- a/packages/mastoapi/router/DittoRoute.ts +++ b/packages/mastoapi/router/DittoRoute.ts @@ -38,7 +38,7 @@ export class DittoRoute extends Hono { } private throwMissingVar(name: string): never { - throw new HTTPException(500, { message: `Missing required variable: ${name}` }); + throw new Error(`Missing required variable: ${name}`); } private _errorHandler: ErrorHandler = (error, c) => { diff --git a/packages/mastoapi/test/TestApp.ts b/packages/mastoapi/test/TestApp.ts index 668957bd..a12f48a4 100644 --- a/packages/mastoapi/test/TestApp.ts +++ b/packages/mastoapi/test/TestApp.ts @@ -2,13 +2,14 @@ import { DittoConf } from '@ditto/conf'; import { type DittoDB, DummyDB } from '@ditto/db'; import { HTTPException } from '@hono/hono/http-exception'; import { type NRelay, NSecSigner } from '@nostrify/nostrify'; +import { MockRelay } from '@nostrify/nostrify/test'; import { generateSecretKey, nip19 } from 'nostr-tools'; import { DittoApp, type DittoAppOpts } from '../router/DittoApp.ts'; import type { Context } from '@hono/hono'; import type { User } from '../middleware/User.ts'; -import { MockRelay } from '@nostrify/nostrify/test'; +import type { DittoRoute } from '../router/DittoRoute.ts'; interface DittoVars { db: DittoDB; @@ -19,7 +20,7 @@ interface DittoVars { export class TestApp extends DittoApp implements AsyncDisposable { private _user?: User; - constructor(opts?: Partial) { + constructor(route?: DittoRoute, opts?: Partial) { const nsec = nip19.nsecEncode(generateSecretKey()); const conf = opts?.conf ?? new DittoConf( @@ -44,6 +45,10 @@ export class TestApp extends DittoApp implements AsyncDisposable { await next(); }); + if (route) { + this.route('/', route); + } + this.onError((err, c) => { if (err instanceof HTTPException) { if (err.res) { @@ -65,6 +70,25 @@ export class TestApp extends DittoApp implements AsyncDisposable { }; } + async admin(user?: User): Promise { + const { conf, relay } = this.opts; + user ??= this.createUser(); + + const event = await conf.signer.signEvent({ + kind: 30382, + content: '', + tags: [ + ['d', await user.signer.getPublicKey()], + ['n', 'admin'], + ], + created_at: Math.floor(Date.now() / 1000), + }); + + await relay.event(event); + + return this.user(user); + } + user(user?: User): User { user ??= this.createUser(); this._user = user; From 8528c4c39e60652bfcad19f9c0a12b82cc0a6e58 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 3 Mar 2025 16:36:38 -0600 Subject: [PATCH 6/7] Add more permission group tests --- .../pleromaAdminPermissionGroupsRoute.test.ts | 33 +++++++++++++++++++ .../pleromaAdminPermissionGroupsRoute.ts | 2 +- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/ditto/routes/pleromaAdminPermissionGroupsRoute.test.ts b/packages/ditto/routes/pleromaAdminPermissionGroupsRoute.test.ts index 1c1fcd18..84ad2e02 100644 --- a/packages/ditto/routes/pleromaAdminPermissionGroupsRoute.test.ts +++ b/packages/ditto/routes/pleromaAdminPermissionGroupsRoute.test.ts @@ -33,3 +33,36 @@ Deno.test('POST /admin promotes to admin', async () => { assertEquals(event.tags, [['d', pubkey], ['n', 'admin']]); }); + +Deno.test('POST /moderator promotes to moderator', async () => { + await using app = new TestApp(route); + const { conf, relay } = app.var; + + await app.admin(); + + const pawn = app.createUser(); + const pubkey = await pawn.signer.getPublicKey(); + + const response = await app.api.post('/moderator', { nicknames: [nip19.npubEncode(pubkey)] }); + const json = await response.json(); + + assertEquals(response.status, 200); + assertEquals(json, { is_moderator: true }); + + const [event] = await relay.query([{ kinds: [30382], authors: [await conf.signer.getPublicKey()], '#d': [pubkey] }]); + + assertEquals(event.tags, [['d', pubkey], ['n', 'moderator']]); +}); + +Deno.test('POST /:group with an invalid group returns 422', async () => { + await using app = new TestApp(route); + + await app.admin(); + + const pawn = app.createUser(); + const pubkey = await pawn.signer.getPublicKey(); + + const response = await app.api.post('/yolo', { nicknames: [nip19.npubEncode(pubkey)] }); + + assertEquals(response.status, 422); +}); diff --git a/packages/ditto/routes/pleromaAdminPermissionGroupsRoute.ts b/packages/ditto/routes/pleromaAdminPermissionGroupsRoute.ts index d2a0bb4f..1e7665d0 100644 --- a/packages/ditto/routes/pleromaAdminPermissionGroupsRoute.ts +++ b/packages/ditto/routes/pleromaAdminPermissionGroupsRoute.ts @@ -34,7 +34,7 @@ route.post('/:group', userMiddleware({ role: 'admin' }), async (c) => { } } - return c.json({ is_admin: true }, 200); + return c.json({ [`is_${group}`]: true }, 200); }); export default route; From 0d1b7b8d3709aeeeef210c54ce3168c3b531ec8f Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 3 Mar 2025 16:53:54 -0600 Subject: [PATCH 7/7] Fix DittoRoute test --- packages/mastoapi/router/DittoRoute.test.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/mastoapi/router/DittoRoute.test.ts b/packages/mastoapi/router/DittoRoute.test.ts index 737019c4..7e48c8e2 100644 --- a/packages/mastoapi/router/DittoRoute.test.ts +++ b/packages/mastoapi/router/DittoRoute.test.ts @@ -1,12 +1,15 @@ -import { assertEquals } from '@std/assert'; +import { assertRejects } from '@std/assert'; import { DittoRoute } from './DittoRoute.ts'; Deno.test('DittoRoute', async () => { const route = new DittoRoute(); - const response = await route.request('/'); - const body = await response.json(); - assertEquals(response.status, 500); - assertEquals(body, { error: 'Missing required variable: db' }); + await assertRejects( + async () => { + await route.request('/'); + }, + Error, + 'Missing required variable: db', + ); });