mirror of
https://gitlab.com/soapbox-pub/ditto.git
synced 2025-12-06 03:19:46 +00:00
Merge branch 'admin-dashboard-updates' into 'main'
Admin dashboard updates See merge request soapbox-pub/ditto!731
This commit is contained in:
commit
b29cc27858
15 changed files with 1272 additions and 1080 deletions
|
|
@ -18,7 +18,7 @@
|
||||||
],
|
],
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"start": "deno run -A --env-file --deny-read=.env packages/ditto/server.ts",
|
"start": "deno run -A --env-file --deny-read=.env packages/ditto/server.ts",
|
||||||
"dev": "deno run -A --env-file --deny-read=.env --watch packages/ditto/server.ts",
|
"dev": "deno run -A --env-file --watch packages/ditto/server.ts",
|
||||||
"hook": "deno run --allow-read --allow-run --allow-write https://deno.land/x/deno_hooks@0.1.1/mod.ts",
|
"hook": "deno run --allow-read --allow-run --allow-write https://deno.land/x/deno_hooks@0.1.1/mod.ts",
|
||||||
"db:export": "deno run -A --env-file --deny-read=.env scripts/db-export.ts",
|
"db:export": "deno run -A --env-file --deny-read=.env scripts/db-export.ts",
|
||||||
"db:import": "deno run -A --env-file --deny-read=.env scripts/db-import.ts",
|
"db:import": "deno run -A --env-file --deny-read=.env scripts/db-import.ts",
|
||||||
|
|
@ -82,6 +82,7 @@
|
||||||
"@std/fs": "jsr:@std/fs@^0.229.3",
|
"@std/fs": "jsr:@std/fs@^0.229.3",
|
||||||
"@std/json": "jsr:@std/json@^0.223.0",
|
"@std/json": "jsr:@std/json@^0.223.0",
|
||||||
"@std/media-types": "jsr:@std/media-types@^0.224.1",
|
"@std/media-types": "jsr:@std/media-types@^0.224.1",
|
||||||
|
"@std/path": "jsr:@std/path@^1.0.8",
|
||||||
"@std/streams": "jsr:@std/streams@^0.223.0",
|
"@std/streams": "jsr:@std/streams@^0.223.0",
|
||||||
"@std/testing": "jsr:@std/testing@^1.0.9",
|
"@std/testing": "jsr:@std/testing@^1.0.9",
|
||||||
"blurhash": "npm:blurhash@2.0.5",
|
"blurhash": "npm:blurhash@2.0.5",
|
||||||
|
|
|
||||||
9
fixtures/policy.ts
Normal file
9
fixtures/policy.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { NostrEvent, NostrRelayInfo, NostrRelayOK, NPolicy } from '@nostrify/types';
|
||||||
|
import { HashtagPolicy } from '@nostrify/policies';
|
||||||
|
|
||||||
|
export default class TestPolicy implements NPolicy {
|
||||||
|
call(event: NostrEvent): Promise<NostrRelayOK> {
|
||||||
|
return new HashtagPolicy(['other-blocked-tag']).call(event);
|
||||||
|
}
|
||||||
|
info?: NostrRelayInfo | undefined;
|
||||||
|
}
|
||||||
|
|
@ -152,6 +152,12 @@ import dittoNamesRoute from '@/routes/dittoNamesRoute.ts';
|
||||||
import pleromaAdminPermissionGroupsRoute from '@/routes/pleromaAdminPermissionGroupsRoute.ts';
|
import pleromaAdminPermissionGroupsRoute from '@/routes/pleromaAdminPermissionGroupsRoute.ts';
|
||||||
import pleromaStatusesRoute from '@/routes/pleromaStatusesRoute.ts';
|
import pleromaStatusesRoute from '@/routes/pleromaStatusesRoute.ts';
|
||||||
import { DittoRelayStore } from '@/storages/DittoRelayStore.ts';
|
import { DittoRelayStore } from '@/storages/DittoRelayStore.ts';
|
||||||
|
import {
|
||||||
|
adminCurrentPolicyController,
|
||||||
|
adminListPoliciesController,
|
||||||
|
adminUpdatePolicyController,
|
||||||
|
} from '@/controllers/api/policies.ts';
|
||||||
|
import { createPolicyEvent, DEFAULT_POLICY_SPEC } from '@/utils/policies/mod.ts';
|
||||||
|
|
||||||
export interface AppEnv extends DittoEnv {
|
export interface AppEnv extends DittoEnv {
|
||||||
Variables: DittoEnv['Variables'] & {
|
Variables: DittoEnv['Variables'] & {
|
||||||
|
|
@ -198,6 +204,11 @@ const pgstore = new DittoPgStore({
|
||||||
|
|
||||||
const pool = new DittoPool({ conf, relay: pgstore });
|
const pool = new DittoPool({ conf, relay: pgstore });
|
||||||
const relay = new DittoRelayStore({ db, conf, pool, relay: pgstore });
|
const relay = new DittoRelayStore({ db, conf, pool, relay: pgstore });
|
||||||
|
const havePolicy = await relay.count([{ kinds: [11984], authors: [await conf.signer.getPublicKey()] }]);
|
||||||
|
|
||||||
|
if (!havePolicy.count) {
|
||||||
|
await relay.event(await createPolicyEvent(conf, DEFAULT_POLICY_SPEC));
|
||||||
|
}
|
||||||
|
|
||||||
await createNip89({ conf, relay });
|
await createNip89({ conf, relay });
|
||||||
await seedZapSplits({ conf, relay });
|
await seedZapSplits({ conf, relay });
|
||||||
|
|
@ -498,6 +509,10 @@ app.delete('/api/v1/pleroma/admin/users/tag', userMiddleware({ role: 'admin' }),
|
||||||
app.patch('/api/v1/pleroma/admin/users/suggest', userMiddleware({ role: 'admin' }), pleromaAdminSuggestController);
|
app.patch('/api/v1/pleroma/admin/users/suggest', userMiddleware({ role: 'admin' }), pleromaAdminSuggestController);
|
||||||
app.patch('/api/v1/pleroma/admin/users/unsuggest', userMiddleware({ role: 'admin' }), pleromaAdminUnsuggestController);
|
app.patch('/api/v1/pleroma/admin/users/unsuggest', userMiddleware({ role: 'admin' }), pleromaAdminUnsuggestController);
|
||||||
|
|
||||||
|
app.get('/api/v1/admin/ditto/policies', userMiddleware({ role: 'admin' }), adminListPoliciesController);
|
||||||
|
app.get('/api/v1/admin/ditto/policies/current', userMiddleware({ role: 'admin' }), adminCurrentPolicyController);
|
||||||
|
app.put('/api/v1/admin/ditto/policies/current', userMiddleware({ role: 'admin' }), adminUpdatePolicyController);
|
||||||
|
|
||||||
app.route('/api/v1/custom_emojis', customEmojisRoute);
|
app.route('/api/v1/custom_emojis', customEmojisRoute);
|
||||||
|
|
||||||
// Not (yet) implemented.
|
// Not (yet) implemented.
|
||||||
|
|
@ -560,5 +575,4 @@ app.get('*', publicFiles, staticFiles, ratelimit, frontendController);
|
||||||
app.onError(errorHandler);
|
app.onError(errorHandler);
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|
||||||
export type { AppContext, AppController, AppMiddleware };
|
export type { AppContext, AppController, AppMiddleware };
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import denoJson from 'deno.json' with { type: 'json' };
|
||||||
|
|
||||||
import { AppController } from '@/app.ts';
|
import { AppController } from '@/app.ts';
|
||||||
import { getInstanceMetadata } from '@/utils/instance.ts';
|
import { getInstanceMetadata } from '@/utils/instance.ts';
|
||||||
|
import { logi } from '@soapbox/logi';
|
||||||
|
|
||||||
const version = `3.0.0 (compatible; Ditto ${denoJson.version})`;
|
const version = `3.0.0 (compatible; Ditto ${denoJson.version})`;
|
||||||
|
|
||||||
|
|
@ -15,13 +16,60 @@ const features = [
|
||||||
'v2_suggestions',
|
'v2_suggestions',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const cache = (f: () => Promise<number | undefined>, interval: number) => {
|
||||||
|
let lastCheck = 0;
|
||||||
|
let value: number | undefined;
|
||||||
|
|
||||||
|
return async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (value === undefined || now - lastCheck > interval) {
|
||||||
|
lastCheck = now;
|
||||||
|
try {
|
||||||
|
value = await f();
|
||||||
|
} catch (error) {
|
||||||
|
logi({
|
||||||
|
level: 'error',
|
||||||
|
ns: 'ditto.routes.instanceV1',
|
||||||
|
message: `Error fetching cached value: ${error}`,
|
||||||
|
});
|
||||||
|
value = undefined; // Ensure we retry next time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value ?? 0; // Prevent returning undefined
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const instanceV1Controller: AppController = async (c) => {
|
const instanceV1Controller: AppController = async (c) => {
|
||||||
const { conf } = c.var;
|
const { conf, db } = c.var;
|
||||||
const { host, protocol } = conf.url;
|
const { host, protocol } = conf.url;
|
||||||
const meta = await getInstanceMetadata(c.var);
|
const meta = await getInstanceMetadata(c.var);
|
||||||
|
|
||||||
/** Protocol to use for WebSocket URLs, depending on the protocol of the `LOCAL_DOMAIN`. */
|
/** Protocol to use for WebSocket URLs, depending on the protocol of the `LOCAL_DOMAIN`. */
|
||||||
const wsProtocol = protocol === 'http:' ? 'ws:' : 'wss:';
|
const wsProtocol = protocol === 'http:' ? 'ws:' : 'wss:';
|
||||||
|
const MINS_10 = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
const userCount = cache(async () => {
|
||||||
|
return await db.kysely
|
||||||
|
.selectFrom('author_stats')
|
||||||
|
.where('nip05_domain', '=', host)
|
||||||
|
.select(({ fn }) => fn.count<number>('pubkey').distinct().as('users'))
|
||||||
|
.executeTakeFirst().then((obj) => obj?.users || 0);
|
||||||
|
}, MINS_10);
|
||||||
|
|
||||||
|
const domainCount = cache(async () => {
|
||||||
|
return await db.kysely
|
||||||
|
.selectFrom('author_stats')
|
||||||
|
.select(({ fn }) => fn.count<number>('nip05_domain').distinct().as('domains'))
|
||||||
|
.executeTakeFirst().then((obj) => obj?.domains || 0);
|
||||||
|
}, MINS_10);
|
||||||
|
|
||||||
|
const statusCount = cache(async () => {
|
||||||
|
return await db.kysely
|
||||||
|
.selectFrom('nostr_events')
|
||||||
|
.where('kind', '=', 1)
|
||||||
|
.select(({ fn }) => fn.countAll<number>().as('events'))
|
||||||
|
.executeTakeFirst().then((obj) => obj?.events || 0);
|
||||||
|
}, MINS_10);
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
uri: host,
|
uri: host,
|
||||||
|
|
@ -58,9 +106,9 @@ const instanceV1Controller: AppController = async (c) => {
|
||||||
},
|
},
|
||||||
languages: ['en'],
|
languages: ['en'],
|
||||||
stats: {
|
stats: {
|
||||||
domain_count: 0,
|
domain_count: Number(await domainCount()),
|
||||||
status_count: 0,
|
status_count: Number(await statusCount()),
|
||||||
user_count: 0,
|
user_count: Number(await userCount()),
|
||||||
},
|
},
|
||||||
urls: {
|
urls: {
|
||||||
streaming_api: `${wsProtocol}//${host}`,
|
streaming_api: `${wsProtocol}//${host}`,
|
||||||
|
|
|
||||||
85
packages/ditto/controllers/api/policies.ts
Normal file
85
packages/ditto/controllers/api/policies.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { type AppController } from '@/app.ts';
|
||||||
|
import { createPolicyEvent } from '@/utils/policies/mod.ts';
|
||||||
|
import { DEFAULT_POLICY_SPEC, policyRegistry } from '@/utils/policies/mod.ts';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const adminListPoliciesController: AppController = (c) => {
|
||||||
|
return c.json(
|
||||||
|
Object.entries(policyRegistry.available)
|
||||||
|
.map(([internalName, item]) => {
|
||||||
|
return {
|
||||||
|
internalName,
|
||||||
|
...item,
|
||||||
|
instantiate: undefined,
|
||||||
|
schema: undefined,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const adminCurrentPolicyController: AppController = async (c) => {
|
||||||
|
const { relay, conf } = c.var;
|
||||||
|
const pubkey = await conf.signer.getPublicKey();
|
||||||
|
|
||||||
|
const current = await relay.query([{
|
||||||
|
authors: [pubkey],
|
||||||
|
kinds: [11984],
|
||||||
|
}]).then((events) => events[0]);
|
||||||
|
|
||||||
|
if (current) return c.json({ spec: JSON.parse(current.content) });
|
||||||
|
|
||||||
|
await relay.event(await createPolicyEvent(conf, DEFAULT_POLICY_SPEC));
|
||||||
|
return c.json({ spec: DEFAULT_POLICY_SPEC });
|
||||||
|
};
|
||||||
|
|
||||||
|
const PolicySpecSchema = z.object({
|
||||||
|
policies: z.array(z.object({
|
||||||
|
name: z.string(),
|
||||||
|
params: z.record(z.any()),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const adminUpdatePolicyController: AppController = async (c) => {
|
||||||
|
const { relay, conf } = c.var;
|
||||||
|
try {
|
||||||
|
const req = await c.req.json();
|
||||||
|
const parsed = PolicySpecSchema.parse(req);
|
||||||
|
|
||||||
|
// Validate each policy against its specific schema
|
||||||
|
const invalidPolicies = parsed.policies.filter((policy) => {
|
||||||
|
const policyItem = policyRegistry.available[policy.name];
|
||||||
|
// If policy not found in registry, it's invalid
|
||||||
|
if (!policyItem) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to parse the policy params against the specific schema
|
||||||
|
policyItem.schema.parse(policy.params);
|
||||||
|
return false; // Not invalid
|
||||||
|
} catch (_) {
|
||||||
|
return true; // Invalid policy
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// If any policies are invalid, return an error
|
||||||
|
if (invalidPolicies.length > 0) {
|
||||||
|
return c.json({
|
||||||
|
error: `Invalid policy specification for: ${invalidPolicies.map((p) => p.name).join(', ')}`,
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await relay.event(await createPolicyEvent(conf, parsed));
|
||||||
|
return c.json({
|
||||||
|
message: 'Settings saved successfully.',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SyntaxError) {
|
||||||
|
return c.json({ error: 'Invalid JSON in request body' }, 400);
|
||||||
|
}
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return c.json({ error: 'Invalid policy specification', details: error.errors }, 400);
|
||||||
|
}
|
||||||
|
return c.json({ error: 'Failed to update policy' }, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -23,7 +23,7 @@ interface ParseNoteContentOpts {
|
||||||
export function contentToHtml(content: string, mentions: MastodonMention[], opts: ParseNoteContentOpts): string {
|
export function contentToHtml(content: string, mentions: MastodonMention[], opts: ParseNoteContentOpts): string {
|
||||||
const { conf } = opts;
|
const { conf } = opts;
|
||||||
|
|
||||||
return linkifyStr(content, {
|
const htmlString = linkifyStr(content, {
|
||||||
render: {
|
render: {
|
||||||
hashtag: ({ content }) => {
|
hashtag: ({ content }) => {
|
||||||
const tag = content.replace(/^#/, '');
|
const tag = content.replace(/^#/, '');
|
||||||
|
|
@ -67,6 +67,9 @@ export function contentToHtml(content: string, mentions: MastodonMention[], opts
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}).replace(/\n+$/, '');
|
}).replace(/\n+$/, '');
|
||||||
|
|
||||||
|
// Replace apostrophes with '
|
||||||
|
return htmlString.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove the tokens from the _end_ of the content. */
|
/** Remove the tokens from the _end_ of the content. */
|
||||||
|
|
|
||||||
53
packages/ditto/utils/policies/mod.ts
Normal file
53
packages/ditto/utils/policies/mod.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { nostrNow } from '@/utils.ts';
|
||||||
|
import type { DittoConf } from '@ditto/conf';
|
||||||
|
import { PolicyRegistry } from './registry.ts';
|
||||||
|
import { MockRelay } from '@nostrify/nostrify/test';
|
||||||
|
import { nip19 } from 'nostr-tools';
|
||||||
|
|
||||||
|
type ParamValue = string | number | boolean;
|
||||||
|
|
||||||
|
export { PolicyRegistry };
|
||||||
|
|
||||||
|
export const policyRegistry = new PolicyRegistry({
|
||||||
|
antiDuplicationPolicyStore: {
|
||||||
|
get: (key: Deno.KvKey) => Promise.resolve({ key, value: null, versionstamp: null }),
|
||||||
|
set: () => Promise.resolve({ ok: true, versionstamp: '00000000000000000000' }),
|
||||||
|
},
|
||||||
|
store: new MockRelay(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PolicyParam = ParamValue | (string | number)[];
|
||||||
|
export type PolicyParams = Record<string, PolicyParam>;
|
||||||
|
|
||||||
|
interface PolicySpecItem {
|
||||||
|
name: keyof typeof policyRegistry.available;
|
||||||
|
params?: PolicyParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PolicySpec {
|
||||||
|
policies: PolicySpecItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const normalizeNpub = (itm: string) => {
|
||||||
|
if (!itm.startsWith('npub1')) return itm;
|
||||||
|
return nip19.decode(itm as `npub1${string}`).data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_POLICY_SPEC: PolicySpec = {
|
||||||
|
policies: [
|
||||||
|
{ 'name': 'SizePolicy' },
|
||||||
|
{ 'name': 'HellthreadPolicy' },
|
||||||
|
{ 'name': 'HashtagPolicy', 'params': { 'hashtags': ['NSFW', 'explicit', 'violence', 'cp', 'porn'] } },
|
||||||
|
{ 'name': 'ReplyBotPolicy' },
|
||||||
|
{ 'name': 'AntiDuplicationPolicy' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createPolicyEvent = async (conf: DittoConf, policies: PolicySpec) => {
|
||||||
|
return await conf.signer.signEvent({
|
||||||
|
kind: 11984,
|
||||||
|
content: JSON.stringify(policies),
|
||||||
|
created_at: nostrNow(),
|
||||||
|
tags: [],
|
||||||
|
});
|
||||||
|
};
|
||||||
105
packages/ditto/utils/policies/parameters.test.ts
Normal file
105
packages/ditto/utils/policies/parameters.test.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { assertEquals } from '@std/assert';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { zodSchemaToFields } from './parameters.ts';
|
||||||
|
|
||||||
|
Deno.test('zodSchemaToFields - basic types', () => {
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
age: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = zodSchemaToFields(schema);
|
||||||
|
assertEquals(result, {
|
||||||
|
name: { type: 'string' },
|
||||||
|
age: { type: 'number' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('zodSchemaToFields - array types', () => {
|
||||||
|
const schema = z.object({
|
||||||
|
tags: z.array(z.string()),
|
||||||
|
scores: z.array(z.number()),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = zodSchemaToFields(schema);
|
||||||
|
assertEquals(result, {
|
||||||
|
tags: { type: 'multi_string' },
|
||||||
|
scores: { type: 'multi_number' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('zodSchemaToFields - special-case NIP-01 filters', () => {
|
||||||
|
const schema = z.object({
|
||||||
|
filters: z.array(z.string()),
|
||||||
|
keywords: z.array(z.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = zodSchemaToFields(schema);
|
||||||
|
assertEquals(result, {
|
||||||
|
filters: { type: 'multi_string' },
|
||||||
|
keywords: { type: 'multi_string' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('zodSchemaToFields - mixed types', () => {
|
||||||
|
const schema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
values: z.array(z.number()),
|
||||||
|
flags: z.array(z.string()).describe('Test description'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = zodSchemaToFields(schema);
|
||||||
|
assertEquals(result, {
|
||||||
|
id: { type: 'string' },
|
||||||
|
values: { type: 'multi_number' },
|
||||||
|
flags: { type: 'multi_string', description: 'Test description' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('zodSchemaToFields - optional fields', () => {
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string().optional(),
|
||||||
|
age: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = zodSchemaToFields(schema);
|
||||||
|
assertEquals(result, {
|
||||||
|
name: { type: 'string', optional: true },
|
||||||
|
age: { type: 'number', optional: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('zodSchemaToFields - default values', () => {
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string().default('John Doe'),
|
||||||
|
age: z.number().default(30),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = zodSchemaToFields(schema);
|
||||||
|
assertEquals(result, {
|
||||||
|
name: { type: 'string', default: 'John Doe' },
|
||||||
|
age: { type: 'number', default: 30 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('zodSchemaToFields - boolean fields', () => {
|
||||||
|
const schema = z.object({
|
||||||
|
active: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = zodSchemaToFields(schema);
|
||||||
|
assertEquals(result, {
|
||||||
|
active: { type: 'boolean' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('zodSchemaToFields - invalid schema', () => {
|
||||||
|
const schema = z.object({
|
||||||
|
invalid: z.any(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = zodSchemaToFields(schema);
|
||||||
|
assertEquals(result, {
|
||||||
|
invalid: { type: 'unknown' },
|
||||||
|
});
|
||||||
|
});
|
||||||
93
packages/ditto/utils/policies/parameters.ts
Normal file
93
packages/ditto/utils/policies/parameters.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { PolicyParam } from '@/utils/policies/mod.ts';
|
||||||
|
|
||||||
|
type FieldType = 'string' | 'multi_string' | 'number' | 'multi_number' | 'boolean' | 'unknown';
|
||||||
|
|
||||||
|
export interface FieldItem {
|
||||||
|
type: FieldType;
|
||||||
|
description?: string;
|
||||||
|
optional?: boolean;
|
||||||
|
default?: PolicyParam;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UnwrappedZodType {
|
||||||
|
baseType: z.ZodTypeAny;
|
||||||
|
optional?: boolean;
|
||||||
|
defaultValue?: PolicyParam;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the base type from wrapped Zod types like ZodOptional and ZodDefault.
|
||||||
|
*/
|
||||||
|
function unwrapZodType(field: z.ZodTypeAny): UnwrappedZodType {
|
||||||
|
let optional = false;
|
||||||
|
let defaultValue: PolicyParam | undefined = undefined;
|
||||||
|
let description: string | undefined = undefined;
|
||||||
|
|
||||||
|
description = field.description;
|
||||||
|
|
||||||
|
while (field instanceof z.ZodOptional || field instanceof z.ZodDefault) {
|
||||||
|
if (field instanceof z.ZodOptional) optional = true;
|
||||||
|
if (field instanceof z.ZodDefault) defaultValue = field._def.defaultValue();
|
||||||
|
if (!description) description = field.description;
|
||||||
|
|
||||||
|
field = field._def.innerType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: UnwrappedZodType = { baseType: field };
|
||||||
|
|
||||||
|
if (optional) result.optional = true;
|
||||||
|
if (typeof defaultValue !== 'undefined') {
|
||||||
|
if (
|
||||||
|
typeof defaultValue === 'string' && defaultValue.length > 0 ||
|
||||||
|
(typeof defaultValue === 'number') ||
|
||||||
|
(typeof defaultValue === 'object' && Object.keys(defaultValue).length > 0) ||
|
||||||
|
(Array.isArray(defaultValue) && defaultValue.length > 0)
|
||||||
|
) {
|
||||||
|
result.defaultValue = defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (description) result.description = description;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializes a Zod schema into a record of field types. NOT meant to be used
|
||||||
|
* as a general-purpose serializer - this is specifically for generating policy
|
||||||
|
* parameter descriptions! This function also parses internal Zod properties,
|
||||||
|
* but there is precedent for this:
|
||||||
|
* https://github.com/colinhacks/zod/discussions/1953
|
||||||
|
*
|
||||||
|
* With version pinning and the Zod maintainers' knowledge of this kind of
|
||||||
|
* usage, it should be fine for it to be this way.
|
||||||
|
*
|
||||||
|
* Special-cases NIP-01 filters as `multi_string`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// deno-lint-ignore no-explicit-any
|
||||||
|
export function zodSchemaToFields(schema: z.ZodObject<any>): Record<string, FieldItem> {
|
||||||
|
const result: Record<string, FieldItem> = {};
|
||||||
|
|
||||||
|
for (const [key, field] of Object.entries(schema.shape) as [string, z.ZodTypeAny][]) {
|
||||||
|
const { baseType, optional, defaultValue, description } = unwrapZodType(field);
|
||||||
|
result[key] = { type: 'unknown' };
|
||||||
|
if (optional) result[key].optional = optional;
|
||||||
|
if (defaultValue) result[key].default = defaultValue;
|
||||||
|
if (description) result[key].description = description;
|
||||||
|
|
||||||
|
if (key === 'filters') {
|
||||||
|
result[key].type = 'multi_string';
|
||||||
|
} else if (baseType instanceof z.ZodArray) {
|
||||||
|
const elementType = unwrapZodType(baseType._def.type).baseType._def.typeName;
|
||||||
|
if (elementType === 'ZodNumber') result[key].type = 'multi_number';
|
||||||
|
else result[key].type = 'multi_string';
|
||||||
|
} else if (baseType instanceof z.ZodNumber) result[key].type = 'number';
|
||||||
|
else if (baseType instanceof z.ZodBoolean) result[key].type = 'boolean';
|
||||||
|
else if (baseType instanceof z.ZodString) result[key].type = 'string';
|
||||||
|
|
||||||
|
if (baseType.description && !result[key].description) result[key].description = baseType.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
236
packages/ditto/utils/policies/registry.ts
Normal file
236
packages/ditto/utils/policies/registry.ts
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
import {
|
||||||
|
AntiDuplicationPolicy,
|
||||||
|
AuthorPolicy,
|
||||||
|
DomainPolicy,
|
||||||
|
FiltersPolicy,
|
||||||
|
HashtagPolicy,
|
||||||
|
HellthreadPolicy,
|
||||||
|
KeywordPolicy,
|
||||||
|
OpenAIPolicy,
|
||||||
|
PowPolicy,
|
||||||
|
PubkeyBanPolicy,
|
||||||
|
RegexPolicy,
|
||||||
|
ReplyBotPolicy,
|
||||||
|
SizePolicy,
|
||||||
|
WhitelistPolicy,
|
||||||
|
WoTPolicy,
|
||||||
|
} from '@nostrify/policies';
|
||||||
|
import { FieldItem, zodSchemaToFields } from './parameters.ts';
|
||||||
|
import { NPolicy, NStore } from '@nostrify/types';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
AntiDuplicationPolicyOpts,
|
||||||
|
AntiDuplicationPolicyOptsSchema,
|
||||||
|
DomainPolicyOptsSchema,
|
||||||
|
FiltersPolicyOptsSchema,
|
||||||
|
HashtagPolicyOptsSchema,
|
||||||
|
HellthreadPolicyOptsSchema,
|
||||||
|
KeywordPolicyOptsSchema,
|
||||||
|
OpenAIPolicyOptsSchema,
|
||||||
|
PowPolicyOptsSchema,
|
||||||
|
PubkeyBanPolicyOptsSchema,
|
||||||
|
RegexPolicyOptsSchema,
|
||||||
|
ReplyBotPolicyOptsSchema,
|
||||||
|
SizePolicyOptsSchema,
|
||||||
|
WhitelistPolicyOptsSchema,
|
||||||
|
WoTPolicyOptsSchema,
|
||||||
|
} from '@/utils/policies/schemas.ts';
|
||||||
|
import { normalizeNpub, PolicyParams } from '@/utils/policies/mod.ts';
|
||||||
|
|
||||||
|
export interface PolicyItem {
|
||||||
|
instantiate: (params: PolicyParams) => NPolicy;
|
||||||
|
name: string;
|
||||||
|
parameters: Record<string, FieldItem>;
|
||||||
|
description: string;
|
||||||
|
schema: z.ZodSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PolicyRegistryOpts {
|
||||||
|
antiDuplicationPolicyStore?: AntiDuplicationPolicyOpts['kv'];
|
||||||
|
store: NStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PolicyRegistry {
|
||||||
|
constructor(private opts: PolicyRegistryOpts) {}
|
||||||
|
|
||||||
|
available: Record<string, PolicyItem> = {
|
||||||
|
'AntiDuplicationPolicy': {
|
||||||
|
instantiate: (params) => {
|
||||||
|
const parsed = AntiDuplicationPolicyOptsSchema.parse(params);
|
||||||
|
if (!this.opts.antiDuplicationPolicyStore) {
|
||||||
|
throw new Error('AntiDuplicationPolicy: tried to instantiate store but no store supplied!');
|
||||||
|
}
|
||||||
|
return new AntiDuplicationPolicy({
|
||||||
|
kv: this.opts.antiDuplicationPolicyStore,
|
||||||
|
...parsed,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
description: 'Prevent messages with the exact same content from being submitted repeatedly.',
|
||||||
|
name: 'Deduplicate messages',
|
||||||
|
parameters: zodSchemaToFields(AntiDuplicationPolicyOptsSchema),
|
||||||
|
schema: AntiDuplicationPolicyOptsSchema,
|
||||||
|
},
|
||||||
|
'AuthorPolicy': {
|
||||||
|
instantiate: () => {
|
||||||
|
return new AuthorPolicy(this.opts.store);
|
||||||
|
},
|
||||||
|
description: 'Rejects events by authors without a kind 0 event associated with their pubkey.',
|
||||||
|
name: 'Block events without associated profiles',
|
||||||
|
parameters: {},
|
||||||
|
schema: z.object({}), // Empty schema since no params are used
|
||||||
|
},
|
||||||
|
'DomainPolicy': {
|
||||||
|
instantiate: (params) => {
|
||||||
|
const parsed = DomainPolicyOptsSchema.parse(params);
|
||||||
|
return new DomainPolicy(this.opts.store, parsed);
|
||||||
|
},
|
||||||
|
description: 'Ban events by pubkeys without a valid NIP-05 domain. Domains can also be whitelisted/blacklisted',
|
||||||
|
name: 'Filter by NIP-05',
|
||||||
|
parameters: zodSchemaToFields(DomainPolicyOptsSchema),
|
||||||
|
schema: DomainPolicyOptsSchema,
|
||||||
|
},
|
||||||
|
'FiltersPolicy': {
|
||||||
|
instantiate: (params) => {
|
||||||
|
if (!params.filters || !Array.isArray(params.filters)) throw new Error('Invalid params to FiltersPolicy');
|
||||||
|
const filters = params.filters.map((item) => {
|
||||||
|
if (typeof item === 'number') return;
|
||||||
|
try {
|
||||||
|
return JSON.parse(item);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}).filter(Boolean);
|
||||||
|
const parsed = FiltersPolicyOptsSchema.parse({ filters });
|
||||||
|
return new FiltersPolicy(parsed.filters);
|
||||||
|
},
|
||||||
|
description: 'Only allow events matching a given Nostr filter',
|
||||||
|
name: 'Filter by Nostr filter',
|
||||||
|
parameters: zodSchemaToFields(FiltersPolicyOptsSchema),
|
||||||
|
schema: FiltersPolicyOptsSchema,
|
||||||
|
},
|
||||||
|
'HashtagPolicy': {
|
||||||
|
instantiate: (params) => {
|
||||||
|
const parsed = HashtagPolicyOptsSchema.parse(params);
|
||||||
|
return new HashtagPolicy(parsed.hashtags);
|
||||||
|
},
|
||||||
|
description: 'Ban events containing the specified hashtags',
|
||||||
|
name: 'Ban hashtags',
|
||||||
|
parameters: zodSchemaToFields(HashtagPolicyOptsSchema),
|
||||||
|
schema: HashtagPolicyOptsSchema,
|
||||||
|
},
|
||||||
|
'HellthreadPolicy': {
|
||||||
|
instantiate: (params) => {
|
||||||
|
const parsed = HellthreadPolicyOptsSchema.parse(params);
|
||||||
|
return new HellthreadPolicy({
|
||||||
|
...parsed,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
description: "Prevent 'hellthreads' - notes that tag hundreds of people to cause a nuisance and server load.",
|
||||||
|
name: 'Limit events with excessive mentions',
|
||||||
|
parameters: zodSchemaToFields(HellthreadPolicyOptsSchema),
|
||||||
|
schema: HellthreadPolicyOptsSchema,
|
||||||
|
},
|
||||||
|
'KeywordPolicy': {
|
||||||
|
instantiate: (params) => {
|
||||||
|
const parsed = KeywordPolicyOptsSchema.parse(params);
|
||||||
|
return new KeywordPolicy(parsed.keywords);
|
||||||
|
},
|
||||||
|
description: 'Ban events that contain specified keywords.',
|
||||||
|
name: 'Block certain words',
|
||||||
|
parameters: zodSchemaToFields(KeywordPolicyOptsSchema),
|
||||||
|
schema: KeywordPolicyOptsSchema,
|
||||||
|
},
|
||||||
|
'OpenAIPolicy': {
|
||||||
|
instantiate: (params) => {
|
||||||
|
const parsed = OpenAIPolicyOptsSchema.parse(params);
|
||||||
|
return new OpenAIPolicy({
|
||||||
|
...parsed,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
description: "Use OpenAI moderation integration to block posts that don't meet your community guidelines.",
|
||||||
|
name: 'Use OpenAI moderation',
|
||||||
|
parameters: zodSchemaToFields(OpenAIPolicyOptsSchema),
|
||||||
|
schema: OpenAIPolicyOptsSchema,
|
||||||
|
},
|
||||||
|
'PowPolicy': {
|
||||||
|
instantiate: (params) => {
|
||||||
|
const parsed = PowPolicyOptsSchema.parse(params);
|
||||||
|
return new PowPolicy({
|
||||||
|
...parsed,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
description: 'Use proof-of-work to limit events from spammers.',
|
||||||
|
name: 'Require proof-of-work for events',
|
||||||
|
parameters: zodSchemaToFields(PowPolicyOptsSchema),
|
||||||
|
schema: PowPolicyOptsSchema,
|
||||||
|
},
|
||||||
|
'PubkeyBanPolicy': {
|
||||||
|
instantiate: (params) => {
|
||||||
|
const parsed = PubkeyBanPolicyOptsSchema.parse(params);
|
||||||
|
return new PubkeyBanPolicy(parsed.pubkeys.map(normalizeNpub));
|
||||||
|
},
|
||||||
|
description: 'Ban events from certain pubkeys',
|
||||||
|
name: 'Ban certain pubkeys',
|
||||||
|
parameters: zodSchemaToFields(PubkeyBanPolicyOptsSchema),
|
||||||
|
schema: PubkeyBanPolicyOptsSchema,
|
||||||
|
},
|
||||||
|
'RegexPolicy': {
|
||||||
|
instantiate: (params) => {
|
||||||
|
const parsed = RegexPolicyOptsSchema.parse(params);
|
||||||
|
return new RegexPolicy(new RegExp(parsed.expr, parsed.flags));
|
||||||
|
},
|
||||||
|
description: 'Ban events that match a certain regular expression.',
|
||||||
|
name: 'Filter by regex',
|
||||||
|
parameters: zodSchemaToFields(RegexPolicyOptsSchema),
|
||||||
|
schema: RegexPolicyOptsSchema,
|
||||||
|
},
|
||||||
|
'ReplyBotPolicy': {
|
||||||
|
instantiate: (params) => {
|
||||||
|
const parsed = ReplyBotPolicyOptsSchema.parse(params);
|
||||||
|
return new ReplyBotPolicy({
|
||||||
|
store: this.opts.store,
|
||||||
|
...parsed,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
description: 'Block events that reply too quickly to other events.',
|
||||||
|
name: 'Block reply spambots',
|
||||||
|
parameters: zodSchemaToFields(ReplyBotPolicyOptsSchema),
|
||||||
|
schema: ReplyBotPolicyOptsSchema,
|
||||||
|
},
|
||||||
|
'SizePolicy': {
|
||||||
|
instantiate: (params) => {
|
||||||
|
const parsed = SizePolicyOptsSchema.parse(params);
|
||||||
|
return new SizePolicy({
|
||||||
|
...parsed,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
description: 'Restrict events that are too big in size.',
|
||||||
|
name: 'Block events by size',
|
||||||
|
parameters: zodSchemaToFields(SizePolicyOptsSchema),
|
||||||
|
schema: SizePolicyOptsSchema,
|
||||||
|
},
|
||||||
|
'WhitelistPolicy': {
|
||||||
|
instantiate: (params) => {
|
||||||
|
const parsed = WhitelistPolicyOptsSchema.parse(params);
|
||||||
|
return new WhitelistPolicy(parsed.pubkeys.map(normalizeNpub));
|
||||||
|
},
|
||||||
|
description: 'Allow only whitelisted pubkeys to post. All other events are rejected.',
|
||||||
|
name: 'Whitelist people',
|
||||||
|
parameters: zodSchemaToFields(WhitelistPolicyOptsSchema),
|
||||||
|
schema: WhitelistPolicyOptsSchema,
|
||||||
|
},
|
||||||
|
'WoTPolicy': {
|
||||||
|
instantiate: (params) => {
|
||||||
|
const parsed = WoTPolicyOptsSchema.parse(params);
|
||||||
|
return new WoTPolicy({
|
||||||
|
store: this.opts.store,
|
||||||
|
...parsed,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
description: 'Use a web-of-trust to only allow users a certain distance from trusted users to publish posts.',
|
||||||
|
name: 'Build a web-of-trust',
|
||||||
|
parameters: zodSchemaToFields(WoTPolicyOptsSchema),
|
||||||
|
schema: WoTPolicyOptsSchema,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
150
packages/ditto/utils/policies/schemas.ts
Normal file
150
packages/ditto/utils/policies/schemas.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
import { NostrEvent, NProfilePointer, NStore } from '@nostrify/types';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { NSchema as n } from '@nostrify/nostrify';
|
||||||
|
|
||||||
|
/** Options for the `WoTPolicy`. */
|
||||||
|
export const WoTPolicyOptsSchema = z.object({
|
||||||
|
pubkeys: z.array(z.string()),
|
||||||
|
depth: z.number().default(2),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface WoTPolicyOpts extends z.TypeOf<typeof WoTPolicyOptsSchema> {
|
||||||
|
store: NStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for `FiltersPolicy`. */
|
||||||
|
export const FiltersPolicyOptsSchema = z.object({
|
||||||
|
filters: z.array(n.filter()),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface FiltersPolicyOpts extends z.TypeOf<typeof FiltersPolicyOptsSchema> {}
|
||||||
|
|
||||||
|
/** Options for `HashtagPolicy`. */
|
||||||
|
export const HashtagPolicyOptsSchema = z.object({
|
||||||
|
hashtags: z.array(z.string()).describe('Banned hashtags (case-insensitive)'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface HashtagPolicyOpts extends z.TypeOf<typeof HashtagPolicyOptsSchema> {}
|
||||||
|
|
||||||
|
/** Options for `WhitelistPolicy`. */
|
||||||
|
export const WhitelistPolicyOptsSchema = z.object({
|
||||||
|
pubkeys: z.array(z.string()).describe('Allowed pubkeys'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface WhitelistPolicyOpts extends z.TypeOf<typeof WhitelistPolicyOptsSchema> {}
|
||||||
|
|
||||||
|
/** Options for `RegexPolicy`. */
|
||||||
|
export const RegexPolicyOptsSchema = z.object({
|
||||||
|
expr: z.string().describe('Expression'),
|
||||||
|
flags: z.string().optional().describe('Additional RegExp flags. Leave blank if unsure.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface RegexPolicyOpts extends Partial<z.TypeOf<typeof RegexPolicyOptsSchema>> {
|
||||||
|
expr: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for `KeywordPolicy`. */
|
||||||
|
export const KeywordPolicyOptsSchema = z.object({
|
||||||
|
keywords: z.array(z.string()).describe('Banned keywords (case-insensitive)'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface KeywordPolicyOpts extends z.TypeOf<typeof KeywordPolicyOptsSchema> {}
|
||||||
|
|
||||||
|
/** Options for `DomainPolicy`. */
|
||||||
|
export const DomainPolicyOptsSchema = z.object({
|
||||||
|
blacklist: z.array(z.string()).default([]).describe('Domains to blacklist'),
|
||||||
|
whitelist: z.array(z.string()).optional().describe('Domains to whitelist'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface DomainPolicyOpts extends Partial<z.TypeOf<typeof DomainPolicyOptsSchema>> {
|
||||||
|
lookup?(nip05: string, signal?: AbortSignal): Promise<NProfilePointer>;
|
||||||
|
store: NStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for `PubkeyBanPolicy`. */
|
||||||
|
export const PubkeyBanPolicyOptsSchema = z.object({
|
||||||
|
pubkeys: z.array(z.string()).describe('Banned pubkeys'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface PubkeyBanPolicyOpts extends z.TypeOf<typeof PubkeyBanPolicyOptsSchema> {}
|
||||||
|
|
||||||
|
/** Options for `OpenAIPolicy`. */
|
||||||
|
export const OpenAIPolicyOptsSchema = z.object({
|
||||||
|
endpoint: z.string().default('https://api.openai.com/v1/moderations').describe('OpenAI API endpoint'),
|
||||||
|
kinds: z.array(z.number()).default([1, 30023]).describe('Event kinds to filter through the policy'),
|
||||||
|
apiKey: z.string().describe('OpenAI API key'),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface OpenAIModerationResult {
|
||||||
|
id: string;
|
||||||
|
model: string;
|
||||||
|
results: {
|
||||||
|
categories: {
|
||||||
|
'hate': boolean;
|
||||||
|
'hate/threatening': boolean;
|
||||||
|
'self-harm': boolean;
|
||||||
|
'sexual': boolean;
|
||||||
|
'sexual/minors': boolean;
|
||||||
|
'violence': boolean;
|
||||||
|
'violence/graphic': boolean;
|
||||||
|
};
|
||||||
|
category_scores: {
|
||||||
|
'hate': number;
|
||||||
|
'hate/threatening': number;
|
||||||
|
'self-harm': number;
|
||||||
|
'sexual': number;
|
||||||
|
'sexual/minors': number;
|
||||||
|
'violence': number;
|
||||||
|
'violence/graphic': number;
|
||||||
|
};
|
||||||
|
flagged: boolean;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenAIPolicyOpts extends Partial<z.TypeOf<typeof OpenAIPolicyOptsSchema>> {
|
||||||
|
apiKey: string;
|
||||||
|
handler?(event: NostrEvent, result: OpenAIModerationResult): boolean;
|
||||||
|
fetch?: typeof fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for `ReplyBotPolicy`. */
|
||||||
|
export const ReplyBotPolicyOptsSchema = z.object({
|
||||||
|
threshold: z.number().default(1).describe('Minimum time in seconds between two posts.'),
|
||||||
|
kinds: z.array(z.number()).default([1]).describe('Event kinds to filter'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface ReplyBotPolicyOpts extends Partial<z.TypeOf<typeof ReplyBotPolicyOptsSchema>> {
|
||||||
|
store: NStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for `SizePolicy`. */
|
||||||
|
export const SizePolicyOptsSchema = z.object({
|
||||||
|
maxBytes: z.number().default(8 * 1024).describe('Max allowed message size in bytes'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface SizePolicyOpts extends Partial<z.TypeOf<typeof SizePolicyOptsSchema>> {}
|
||||||
|
|
||||||
|
/** Options for `AntiDuplicationPolicy`. */
|
||||||
|
export const AntiDuplicationPolicyOptsSchema = z.object({
|
||||||
|
expireIn: z.number().default(60000).describe('How long should we wait before an identical message can be reposted?'),
|
||||||
|
minLength: z.number().default(50).describe('Minimum length for filtered messages'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface AntiDuplicationPolicyOpts extends Partial<z.TypeOf<typeof AntiDuplicationPolicyOptsSchema>> {
|
||||||
|
kv: Pick<Deno.Kv, 'get' | 'set'>;
|
||||||
|
deobfuscate?(event: NostrEvent): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for `HellthreadPolicy`. */
|
||||||
|
export const HellthreadPolicyOptsSchema = z.object({
|
||||||
|
limit: z.number().default(100).describe('Maximum number of mentions to allow per post'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface HellthreadPolicyOpts extends Partial<z.TypeOf<typeof HellthreadPolicyOptsSchema>> {}
|
||||||
|
|
||||||
|
/** Options for `PowPolicy`. */
|
||||||
|
export const PowPolicyOptsSchema = z.object({
|
||||||
|
difficulty: z.number().default(1).describe('Number of bits of proof-of-work to require'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface PowPolicyOpts extends Partial<z.TypeOf<typeof PowPolicyOptsSchema>> {}
|
||||||
|
|
@ -1,14 +1,45 @@
|
||||||
import { DittoConf } from '@ditto/conf';
|
import { DittoConf } from '@ditto/conf';
|
||||||
import { generateSecretKey, nip19 } from 'nostr-tools';
|
import { generateSecretKey, nip19 } from 'nostr-tools';
|
||||||
|
|
||||||
import { PolicyWorker } from './policy.ts';
|
import { PolicyWorker } from '@/workers/policy.ts';
|
||||||
|
import { assertEquals } from '@std/assert/assert-equals';
|
||||||
|
import { join } from '@std/path';
|
||||||
|
|
||||||
Deno.test('PolicyWorker', () => {
|
const blocked = {
|
||||||
|
id: '19afd70437944671e7f5a02b29221ad444ef7cf60113a5731667e272e59a3979',
|
||||||
|
pubkey: '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798',
|
||||||
|
kind: 1,
|
||||||
|
tags: [['t', 'porn'], ['t', 'other-blocked-tag']],
|
||||||
|
content: 'this is a test of the policy system',
|
||||||
|
sig:
|
||||||
|
'1d73a7480cfd737b89dc1e0e7175dff67119915f31d24a279a45d56622f4b991b01e431d07b693ee6cd652f3f27274d9e203ee43ae44af7e70ce8647e5326196',
|
||||||
|
created_at: 1743685015,
|
||||||
|
};
|
||||||
|
|
||||||
|
Deno.test('PolicyWorker with script policy', async () => {
|
||||||
const conf = new DittoConf(
|
const conf = new DittoConf(
|
||||||
new Map([
|
new Map([
|
||||||
['DITTO_NSEC', nip19.nsecEncode(generateSecretKey())],
|
['DITTO_NSEC', nip19.nsecEncode(generateSecretKey())],
|
||||||
|
['DATABASE_URL', Deno.env.get('DATABASE_URL')],
|
||||||
|
['DITTO_POLICY', join(Deno.cwd(), 'fixtures', 'policy.ts')],
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
new PolicyWorker(conf);
|
const worker = new PolicyWorker(conf);
|
||||||
|
const [, , ok] = await worker.call(blocked);
|
||||||
|
assertEquals(ok, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('PolicyWorker with event policy', async () => {
|
||||||
|
const conf = new DittoConf(
|
||||||
|
new Map([
|
||||||
|
['DITTO_NSEC', nip19.nsecEncode(generateSecretKey())],
|
||||||
|
['DATABASE_URL', Deno.env.get('DATABASE_URL')],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const worker = new PolicyWorker(conf);
|
||||||
|
const [, , ok] = await worker.call(blocked);
|
||||||
|
|
||||||
|
assertEquals(ok, false);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -54,27 +54,14 @@ export class PolicyWorker implements NPolicy {
|
||||||
databaseUrl: conf.databaseUrl,
|
databaseUrl: conf.databaseUrl,
|
||||||
pubkey: await conf.signer.getPublicKey(),
|
pubkey: await conf.signer.getPublicKey(),
|
||||||
});
|
});
|
||||||
|
|
||||||
logi({
|
logi({
|
||||||
level: 'info',
|
level: 'info',
|
||||||
ns: 'ditto.system.policy',
|
ns: 'ditto.system.policy',
|
||||||
msg: 'Using custom policy',
|
msg: `Initialising custom policy`,
|
||||||
path: conf.policy,
|
path: conf.policy,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error && e.message.includes('Module not found')) {
|
|
||||||
logi({
|
|
||||||
level: 'info',
|
|
||||||
ns: 'ditto.system.policy',
|
|
||||||
msg: 'Custom policy not found <https://docs.soapbox.pub/ditto/policies/>',
|
|
||||||
path: null,
|
|
||||||
enabled: false,
|
|
||||||
});
|
|
||||||
this.enabled = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e instanceof Error && e.message.includes('PGlite is not supported in worker threads')) {
|
if (e instanceof Error && e.message.includes('PGlite is not supported in worker threads')) {
|
||||||
logi({
|
logi({
|
||||||
level: 'warn',
|
level: 'warn',
|
||||||
|
|
|
||||||
|
|
@ -2,23 +2,23 @@ import { DittoConf } from '@ditto/conf';
|
||||||
import { DittoPolyPg } from '@ditto/db';
|
import { DittoPolyPg } from '@ditto/db';
|
||||||
import '@soapbox/safe-fetch/load';
|
import '@soapbox/safe-fetch/load';
|
||||||
import { NostrEvent, NostrRelayOK, NPolicy } from '@nostrify/nostrify';
|
import { NostrEvent, NostrRelayOK, NPolicy } from '@nostrify/nostrify';
|
||||||
import { ReadOnlyPolicy } from '@nostrify/policies';
|
import { PipePolicy, ReadOnlyPolicy } from '@nostrify/policies';
|
||||||
import * as Comlink from 'comlink';
|
import * as Comlink from 'comlink';
|
||||||
|
|
||||||
import { ReadOnlySigner } from '@/signers/ReadOnlySigner.ts';
|
import { ReadOnlySigner } from '@/signers/ReadOnlySigner.ts';
|
||||||
import { DittoPgStore } from '@/storages/DittoPgStore.ts';
|
import { DittoPgStore } from '@/storages/DittoPgStore.ts';
|
||||||
|
import { DEFAULT_POLICY_SPEC, PolicyRegistry, PolicySpec } from '@/utils/policies/mod.ts';
|
||||||
|
import { logi } from '@soapbox/logi';
|
||||||
|
|
||||||
// @ts-ignore Don't try to access the env from this worker.
|
// @ts-ignore Don't try to access the env from this worker.
|
||||||
Deno.env = new Map<string, string>();
|
Deno.env = new Map<string, string>();
|
||||||
|
|
||||||
/** Serializable object the worker can use to set up the state. */
|
|
||||||
interface PolicyInit {
|
interface PolicyInit {
|
||||||
/** Path to the policy module (https, jsr, file, etc) */
|
|
||||||
path: string;
|
|
||||||
/** Database URL to connect to. */
|
/** Database URL to connect to. */
|
||||||
databaseUrl: string;
|
databaseUrl: string;
|
||||||
/** Admin pubkey to use for DittoPgStore checks. */
|
/** Admin pubkey to use for DittoPgStore checks. */
|
||||||
pubkey: string;
|
pubkey: string;
|
||||||
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CustomPolicy implements NPolicy {
|
export class CustomPolicy implements NPolicy {
|
||||||
|
|
@ -29,8 +29,8 @@ export class CustomPolicy implements NPolicy {
|
||||||
return this.policy.call(event, signal);
|
return this.policy.call(event, signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
async init({ path, databaseUrl, pubkey }: PolicyInit): Promise<void> {
|
async init(opts: PolicyInit): Promise<void> {
|
||||||
const Policy = (await import(path)).default;
|
const { databaseUrl, pubkey } = opts;
|
||||||
|
|
||||||
const db = new DittoPolyPg(databaseUrl, { poolSize: 1 });
|
const db = new DittoPolyPg(databaseUrl, { poolSize: 1 });
|
||||||
|
|
||||||
|
|
@ -43,13 +43,47 @@ export class CustomPolicy implements NPolicy {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const policies: NPolicy[] = [];
|
||||||
const store = new DittoPgStore({
|
const store = new DittoPgStore({
|
||||||
db,
|
db,
|
||||||
conf,
|
conf,
|
||||||
timeout: 5_000,
|
timeout: 5_000,
|
||||||
});
|
});
|
||||||
|
try {
|
||||||
|
const Policy = (await import(opts.path)).default;
|
||||||
|
policies.push(new Policy({ db, store, pubkey }));
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.message.includes('Module not found')) {
|
||||||
|
logi({
|
||||||
|
level: 'info',
|
||||||
|
ns: 'ditto.system.policy',
|
||||||
|
msg: 'Custom policy not found <https://docs.soapbox.pub/ditto/policies/>',
|
||||||
|
path: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const registry = new PolicyRegistry({ store, antiDuplicationPolicyStore: await Deno.openKv() });
|
||||||
|
const event = await store
|
||||||
|
.query([{ kinds: [11984], authors: [await conf.signer.getPublicKey()] }])
|
||||||
|
.then((results) => results[0]);
|
||||||
|
|
||||||
this.policy = new Policy({ db, store, pubkey });
|
const spec: PolicySpec = event ? JSON.parse(event.content) : DEFAULT_POLICY_SPEC;
|
||||||
|
|
||||||
|
for (const item of spec.policies) {
|
||||||
|
const policy = registry.available[item.name];
|
||||||
|
if (!policy) continue;
|
||||||
|
try {
|
||||||
|
policies.push(policy.instantiate(item.params || {}));
|
||||||
|
} catch (e) {
|
||||||
|
logi({
|
||||||
|
level: 'error',
|
||||||
|
ns: 'ditto.system.policy.worker',
|
||||||
|
msg: `Error instantiating policy ${item.name} with params \`${JSON.stringify(item.params)}\`: ${e}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.policy = new PipePolicy(policies);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue