Merge branch 'feat-promove-admin' into 'main'

feat: promote users to admin

Closes #298

See merge request soapbox-pub/ditto!707
This commit is contained in:
P. Reis 2025-03-03 19:54:07 -03:00
commit 9fae784661
9 changed files with 150 additions and 16 deletions

View file

@ -148,6 +148,7 @@ import { uploaderMiddleware } from '@/middleware/uploaderMiddleware.ts';
import { translatorMiddleware } from '@/middleware/translatorMiddleware.ts'; import { translatorMiddleware } from '@/middleware/translatorMiddleware.ts';
import { logiMiddleware } from '@/middleware/logiMiddleware.ts'; import { logiMiddleware } from '@/middleware/logiMiddleware.ts';
import dittoNamesRoute from '@/routes/dittoNamesRoute.ts'; import dittoNamesRoute from '@/routes/dittoNamesRoute.ts';
import pleromaAdminPermissionGroupsRoute from '@/routes/pleromaAdminPermissionGroupsRoute.ts';
import { DittoRelayStore } from '@/storages/DittoRelayStore.ts'; import { DittoRelayStore } from '@/storages/DittoRelayStore.ts';
export interface AppEnv extends DittoEnv { 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.get('/api/v1/pleroma/admin/config', userMiddleware({ role: 'admin' }), configController);
app.post('/api/v1/pleroma/admin/config', userMiddleware({ role: 'admin' }), updateConfigController); app.post('/api/v1/pleroma/admin/config', userMiddleware({ role: 'admin' }), updateConfigController);
app.delete('/api/v1/pleroma/admin/statuses/:id', userMiddleware({ role: 'admin' }), pleromaAdminDeleteStatusController); 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.get('/api/v1/admin/ditto/relays', userMiddleware({ role: 'admin' }), adminRelaysController);
app.put('/api/v1/admin/ditto/relays', userMiddleware({ role: 'admin' }), adminSetRelaysController); app.put('/api/v1/admin/ditto/relays', userMiddleware({ role: 'admin' }), adminSetRelaysController);

View file

@ -1,10 +1,10 @@
import { z } from 'zod'; import { z } from 'zod';
import { type AppController } from '@/app.ts'; import { type AppController } from '@/app.ts';
import { configSchema, elixirTupleSchema } from '@/schemas/pleroma-api.ts';
import { createAdminEvent, updateAdminEvent, updateUser } from '@/utils/api.ts'; import { createAdminEvent, updateAdminEvent, updateUser } from '@/utils/api.ts';
import { lookupPubkey } from '@/utils/lookup.ts'; import { lookupPubkey } from '@/utils/lookup.ts';
import { getPleromaConfigs } from '@/utils/pleroma.ts'; import { getPleromaConfigs } from '@/utils/pleroma.ts';
import { configSchema, elixirTupleSchema } from '@/schemas/pleroma-api.ts';
const frontendConfigController: AppController = async (c) => { const frontendConfigController: AppController = async (c) => {
const configDB = await getPleromaConfigs(c.var); const configDB = await getPleromaConfigs(c.var);

View file

@ -4,11 +4,10 @@ import { assertEquals } from '@std/assert';
import route from './dittoNamesRoute.ts'; import route from './dittoNamesRoute.ts';
Deno.test('POST / creates a name request event', async () => { 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 { conf, relay } = app.var;
const user = app.user(); const user = app.user();
app.route('/', route);
const response = await app.api.post('/', { name: 'Alex@Ditto.pub', reason: 'for testing' }); 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 () => { 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.user();
app.route('/', route);
const response1 = await app.api.post('/', { name: 'alex@ditto.pub' }); const response1 = await app.api.post('/', { name: 'alex@ditto.pub' });
const response2 = 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 () => { 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; const { conf, relay } = app.var;
app.user(); app.user();
app.route('/', route);
const grant = await conf.signer.signEvent({ const grant = await conf.signer.signEvent({
kind: 30360, kind: 30360,

View file

@ -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);
});

View file

@ -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;

View file

@ -118,7 +118,7 @@ async function updateAdminEvent<E extends EventStub>(
return createAdminEvent(fn(prev), c); return createAdminEvent(fn(prev), c);
} }
function updateUser(pubkey: string, n: Record<string, boolean>, c: AppContext): Promise<NostrEvent> { function updateUser(pubkey: string, n: Record<string, boolean>, c: Context): Promise<NostrEvent> {
return updateNames(30382, pubkey, n, c); return updateNames(30382, pubkey, n, c);
} }

View file

@ -1,12 +1,15 @@
import { assertEquals } from '@std/assert'; import { assertRejects } from '@std/assert';
import { DittoRoute } from './DittoRoute.ts'; import { DittoRoute } from './DittoRoute.ts';
Deno.test('DittoRoute', async () => { Deno.test('DittoRoute', async () => {
const route = new DittoRoute(); const route = new DittoRoute();
const response = await route.request('/');
const body = await response.json();
assertEquals(response.status, 500); await assertRejects(
assertEquals(body, { error: 'Missing required variable: db' }); async () => {
await route.request('/');
},
Error,
'Missing required variable: db',
);
}); });

View file

@ -38,7 +38,7 @@ export class DittoRoute extends Hono<DittoEnv> {
} }
private throwMissingVar(name: string): never { 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) => { private _errorHandler: ErrorHandler = (error, c) => {

View file

@ -2,13 +2,14 @@ import { DittoConf } from '@ditto/conf';
import { type DittoDB, DummyDB } from '@ditto/db'; import { type DittoDB, DummyDB } from '@ditto/db';
import { HTTPException } from '@hono/hono/http-exception'; import { HTTPException } from '@hono/hono/http-exception';
import { type NRelay, NSecSigner } from '@nostrify/nostrify'; import { type NRelay, NSecSigner } from '@nostrify/nostrify';
import { MockRelay } from '@nostrify/nostrify/test';
import { generateSecretKey, nip19 } from 'nostr-tools'; import { generateSecretKey, nip19 } from 'nostr-tools';
import { DittoApp, type DittoAppOpts } from '../router/DittoApp.ts'; import { DittoApp, type DittoAppOpts } from '../router/DittoApp.ts';
import type { Context } from '@hono/hono'; import type { Context } from '@hono/hono';
import type { User } from '../middleware/User.ts'; import type { User } from '../middleware/User.ts';
import { MockRelay } from '@nostrify/nostrify/test'; import type { DittoRoute } from '../router/DittoRoute.ts';
interface DittoVars { interface DittoVars {
db: DittoDB; db: DittoDB;
@ -19,7 +20,7 @@ interface DittoVars {
export class TestApp extends DittoApp implements AsyncDisposable { export class TestApp extends DittoApp implements AsyncDisposable {
private _user?: User; private _user?: User;
constructor(opts?: Partial<DittoAppOpts>) { constructor(route?: DittoRoute, opts?: Partial<DittoAppOpts>) {
const nsec = nip19.nsecEncode(generateSecretKey()); const nsec = nip19.nsecEncode(generateSecretKey());
const conf = opts?.conf ?? new DittoConf( const conf = opts?.conf ?? new DittoConf(
@ -44,6 +45,10 @@ export class TestApp extends DittoApp implements AsyncDisposable {
await next(); await next();
}); });
if (route) {
this.route('/', route);
}
this.onError((err, c) => { this.onError((err, c) => {
if (err instanceof HTTPException) { if (err instanceof HTTPException) {
if (err.res) { if (err.res) {
@ -65,6 +70,25 @@ export class TestApp extends DittoApp implements AsyncDisposable {
}; };
} }
async admin(user?: User): Promise<User> {
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(user?: User): User {
user ??= this.createUser(); user ??= this.createUser();
this._user = user; this._user = user;