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;