mirror of
https://gitlab.com/soapbox-pub/ditto.git
synced 2025-12-06 03:19:46 +00:00
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:
commit
ff4c3381ba
9 changed files with 150 additions and 16 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
40
packages/ditto/routes/pleromaAdminPermissionGroupsRoute.ts
Normal file
40
packages/ditto/routes/pleromaAdminPermissionGroupsRoute.ts
Normal 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;
|
||||
|
|
@ -118,7 +118,7 @@ async function updateAdminEvent<E extends EventStub>(
|
|||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export class DittoRoute extends Hono<DittoEnv> {
|
|||
}
|
||||
|
||||
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) => {
|
||||
|
|
|
|||
|
|
@ -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<DittoAppOpts>) {
|
||||
constructor(route?: DittoRoute, opts?: Partial<DittoAppOpts>) {
|
||||
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<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 ??= this.createUser();
|
||||
this._user = user;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue