Merge branch 'admin-dashboard-updates' into 'main'

Admin dashboard updates

See merge request soapbox-pub/ditto!731
This commit is contained in:
Siddharth Singh 2025-04-22 18:22:30 +00:00
commit b29cc27858
15 changed files with 1272 additions and 1080 deletions

View file

@ -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",

1441
deno.lock generated

File diff suppressed because it is too large Load diff

9
fixtures/policy.ts Normal file
View 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;
}

View file

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

View file

@ -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}`,

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

View file

@ -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 &apos;
return htmlString.replace(/'/g, '&apos;');
} }
/** Remove the tokens from the _end_ of the content. */ /** Remove the tokens from the _end_ of the content. */

View 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: [],
});
};

View 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' },
});
});

View 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;
}

View 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,
},
};
}

View 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>> {}

View file

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

View file

@ -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',

View file

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