diff --git a/packages/ditto/app.ts b/packages/ditto/app.ts index a6904f1e..931b3825 100644 --- a/packages/ditto/app.ts +++ b/packages/ditto/app.ts @@ -148,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 { @@ -439,6 +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.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 b4458c6c..b9a5b561 100644 --- a/packages/ditto/controllers/api/pleroma.ts +++ b/packages/ditto/controllers/api/pleroma.ts @@ -1,10 +1,10 @@ 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 { lookupPubkey } from '@/utils/lookup.ts'; import { getPleromaConfigs } from '@/utils/pleroma.ts'; +import { configSchema, elixirTupleSchema } from '@/schemas/pleroma-api.ts'; const frontendConfigController: AppController = async (c) => { const configDB = await getPleromaConfigs(c.var); 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..84ad2e02 --- /dev/null +++ b/packages/ditto/routes/pleromaAdminPermissionGroupsRoute.test.ts @@ -0,0 +1,68 @@ +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']]); +}); + +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 new file mode 100644 index 00000000..1e7665d0 --- /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_${group}`]: 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.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', + ); }); 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;