Merge branch '30382' into 'main'

Remove kind 30361 events, switch to 30382, rework moderation

See merge request soapbox-pub/ditto!369
This commit is contained in:
Alex Gleason 2024-06-08 19:32:07 +00:00
commit 41d61cc8db
16 changed files with 285 additions and 192 deletions

View File

@ -1,40 +0,0 @@
# Ditto custom events
Instead of using database tables, the Ditto server publishes Nostr events that describe its state. It then reads these events using Nostr filters.
## Ditto User (kind 30361)
The Ditto server publishes kind `30361` events to represent users. These events are parameterized replaceable events of kind `30361` where the `d` tag is a pubkey. These events are published by Ditto's internal admin keypair.
User events have the following tags:
- `d` - pubkey of the user.
- `role` - one of `admin` or `user`.
Example:
```json
{
"id": "d6ae2f320ae163612bf28080e7c6e55b228ee39bfa04ad50baab2e51022d4d59",
"kind": 30361,
"pubkey": "4cfc6ceb07bbe2f5e75f746f3e6f0eda53973e0374cd6bdbce7a930e10437e06",
"content": "",
"created_at": 1691568245,
"tags": [
["d", "79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6"],
["role", "user"],
["alt", "User's account was updated by the admins of ditto.ngrok.app"]
],
"sig": "fc12db77b1c8f8aa86c73b617f0cd4af1e6ba244239eaf3164a292de6d39363f32d6b817ffff796ace7a103d75e1d8e6a0fb7f618819b32d81a953b4a75d7507"
}
```
## NIP-78
[NIP-78](https://github.com/nostr-protocol/nips/blob/master/78.md) defines events of kind `30078` with a globally unique `d` tag. These events are queried by the `d` tag, which allows Ditto to store custom data on relays. Ditto uses reverse DNS names like `pub.ditto.<thing>` for `d` tags.
The sections below describe the `content` field. Some are encrypted and some are not, depending on whether the data should be public. Also, some events are user events, and some are admin events.
### `pub.ditto.pleroma.config`
NIP-04 encrypted JSON array of Pleroma ConfigDB objects. Pleroma admin API endpoints set this config, and Ditto reads from it.

View File

@ -1,15 +0,0 @@
{
"id": "d6ae2f320ae163612bf28080e7c6e55b228ee39bfa04ad50baab2e51022d4d59",
"kind": 30361,
"pubkey": "4cfc6ceb07bbe2f5e75f746f3e6f0eda53973e0374cd6bdbce7a930e10437e06",
"content": "",
"created_at": 1691568245,
"tags": [
["d", "79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6"],
["name", "alex"],
["role", "user"],
["origin", "https://ditto.ngrok.app"],
["alt", "@alex@ditto.ngrok.app's account was updated by the admins of ditto.ngrok.app"]
],
"sig": "fc12db77b1c8f8aa86c73b617f0cd4af1e6ba244239eaf3164a292de6d39363f32d6b817ffff796ace7a103d75e1d8e6a0fb7f618819b32d81a953b4a75d7507"
}

View File

@ -1,7 +1,6 @@
import { NSchema } from '@nostrify/nostrify'; import { NSchema } from '@nostrify/nostrify';
import { DittoDB } from '@/db/DittoDB.ts'; import { DittoDB } from '@/db/DittoDB.ts';
import { Conf } from '@/config.ts';
import { AdminSigner } from '@/signers/AdminSigner.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts';
import { EventsDB } from '@/storages/EventsDB.ts'; import { EventsDB } from '@/storages/EventsDB.ts';
import { nostrNow } from '@/utils.ts'; import { nostrNow } from '@/utils.ts';
@ -21,14 +20,39 @@ if (!['admin', 'user'].includes(role)) {
Deno.exit(1); Deno.exit(1);
} }
const event = await new AdminSigner().signEvent({ const signer = new AdminSigner();
kind: 30361, const admin = await signer.getPublicKey();
tags: [
const [existing] = await eventsDB.query([{
kinds: [30382],
authors: [admin],
'#d': [pubkey],
limit: 1,
}]);
const prevTags = (existing?.tags ?? []).filter(([name, value]) => {
if (name === 'd') {
return false;
}
if (name === 'n' && value === 'admin') {
return false;
}
return true;
});
const tags: string[][] = [
['d', pubkey], ['d', pubkey],
['role', role], ];
// NIP-31: https://github.com/nostr-protocol/nips/blob/master/31.md
['alt', `User's account was updated by the admins of ${Conf.url.host}`], if (role === 'admin') {
], tags.push(['n', 'admin']);
}
tags.push(...prevTags);
const event = await signer.signEvent({
kind: 30382,
tags,
content: '', content: '',
created_at: nostrNow(), created_at: nostrNow(),
}); });

View File

@ -26,7 +26,7 @@ import {
updateCredentialsController, updateCredentialsController,
verifyCredentialsController, verifyCredentialsController,
} from '@/controllers/api/accounts.ts'; } from '@/controllers/api/accounts.ts';
import { adminAccountAction, adminAccountsController } from '@/controllers/api/admin.ts'; import { adminAccountsController, adminActionController } from '@/controllers/api/admin.ts';
import { appCredentialsController, createAppController } from '@/controllers/api/apps.ts'; import { appCredentialsController, createAppController } from '@/controllers/api/apps.ts';
import { blocksController } from '@/controllers/api/blocks.ts'; import { blocksController } from '@/controllers/api/blocks.ts';
import { bookmarksController } from '@/controllers/api/bookmarks.ts'; import { bookmarksController } from '@/controllers/api/bookmarks.ts';
@ -42,6 +42,10 @@ import {
configController, configController,
frontendConfigController, frontendConfigController,
pleromaAdminDeleteStatusController, pleromaAdminDeleteStatusController,
pleromaAdminSuggestController,
pleromaAdminTagController,
pleromaAdminUnsuggestController,
pleromaAdminUntagController,
updateConfigController, updateConfigController,
} from '@/controllers/api/pleroma.ts'; } from '@/controllers/api/pleroma.ts';
import { preferencesController } from '@/controllers/api/preferences.ts'; import { preferencesController } from '@/controllers/api/preferences.ts';
@ -251,7 +255,12 @@ app.post(
adminReportResolveController, adminReportResolveController,
); );
app.post('/api/v1/admin/accounts/:id{[0-9a-f]{64}}/action', requireSigner, requireRole('admin'), adminAccountAction); app.post('/api/v1/admin/accounts/:id{[0-9a-f]{64}}/action', requireSigner, requireRole('admin'), adminActionController);
app.put('/api/v1/pleroma/admin/users/tag', requireRole('admin'), pleromaAdminTagController);
app.delete('/api/v1/pleroma/admin/users/tag', requireRole('admin'), pleromaAdminUntagController);
app.patch('/api/v1/pleroma/admin/users/suggest', requireRole('admin'), pleromaAdminSuggestController);
app.patch('/api/v1/pleroma/admin/users/unsuggest', requireRole('admin'), pleromaAdminUnsuggestController);
// Not (yet) implemented. // Not (yet) implemented.
app.get('/api/v1/custom_emojis', emptyArrayController); app.get('/api/v1/custom_emojis', emptyArrayController);

View File

@ -5,8 +5,7 @@ import { Conf } from '@/config.ts';
import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { booleanParamSchema } from '@/schema.ts'; import { booleanParamSchema } from '@/schema.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import { paginated, paginationSchema, parseBody, updateListAdminEvent } from '@/utils/api.ts'; import { paginated, paginationSchema, parseBody, updateUser } from '@/utils/api.ts';
import { addTag } from '@/utils/tags.ts';
import { renderAdminAccount } from '@/views/mastodon/admin-accounts.ts'; import { renderAdminAccount } from '@/views/mastodon/admin-accounts.ts';
const adminAccountQuerySchema = z.object({ const adminAccountQuerySchema = z.object({
@ -44,7 +43,7 @@ const adminAccountsController: AppController = async (c) => {
const { since, until, limit } = paginationSchema.parse(c.req.query()); const { since, until, limit } = paginationSchema.parse(c.req.query());
const { signal } = c.req.raw; const { signal } = c.req.raw;
const events = await store.query([{ kinds: [30361], authors: [Conf.pubkey], since, until, limit }], { signal }); const events = await store.query([{ kinds: [30382], authors: [Conf.pubkey], since, until, limit }], { signal });
const pubkeys = events.map((event) => event.tags.find(([name]) => name === 'd')?.[1]!); const pubkeys = events.map((event) => event.tags.find(([name]) => name === 'd')?.[1]!);
const authors = await store.query([{ kinds: [0], authors: pubkeys }], { signal }); const authors = await store.query([{ kinds: [0], authors: pubkeys }], { signal });
@ -64,7 +63,7 @@ const adminAccountActionSchema = z.object({
type: z.enum(['none', 'sensitive', 'disable', 'silence', 'suspend']), type: z.enum(['none', 'sensitive', 'disable', 'silence', 'suspend']),
}); });
const adminAccountAction: AppController = async (c) => { const adminActionController: AppController = async (c) => {
const body = await parseBody(c.req.raw); const body = await parseBody(c.req.raw);
const result = adminAccountActionSchema.safeParse(body); const result = adminAccountActionSchema.safeParse(body);
const authorId = c.req.param('id'); const authorId = c.req.param('id');
@ -75,17 +74,24 @@ const adminAccountAction: AppController = async (c) => {
const { data } = result; const { data } = result;
if (data.type !== 'disable') { const n: Record<string, boolean> = {};
return c.json({ error: 'Record invalid' }, 422);
if (data.type === 'sensitive') {
n.sensitive = true;
}
if (data.type === 'disable') {
n.disable = true;
}
if (data.type === 'silence') {
n.silence = true;
}
if (data.type === 'suspend') {
n.suspend = true;
} }
await updateListAdminEvent( await updateUser(authorId, n, c);
{ kinds: [10000], authors: [Conf.pubkey], limit: 1 },
(tags) => addTag(tags, ['p', authorId]),
c,
);
return c.json({}, 200); return c.json({}, 200);
}; };
export { adminAccountAction, adminAccountsController }; export { adminAccountsController, adminActionController };

View File

@ -6,7 +6,8 @@ import { Conf } from '@/config.ts';
import { configSchema, elixirTupleSchema, type PleromaConfig } from '@/schemas/pleroma-api.ts'; import { configSchema, elixirTupleSchema, type PleromaConfig } from '@/schemas/pleroma-api.ts';
import { AdminSigner } from '@/signers/AdminSigner.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import { createAdminEvent } from '@/utils/api.ts'; import { createAdminEvent, updateAdminEvent, updateUser } from '@/utils/api.ts';
import { lookupPubkey } from '@/utils/lookup.ts';
const frontendConfigController: AppController = async (c) => { const frontendConfigController: AppController = async (c) => {
const store = await Storages.db(); const store = await Storages.db();
@ -87,4 +88,100 @@ async function getConfigs(store: NStore, signal: AbortSignal): Promise<PleromaCo
} }
} }
export { configController, frontendConfigController, pleromaAdminDeleteStatusController, updateConfigController }; const pleromaAdminTagSchema = z.object({
nicknames: z.string().array(),
tags: z.string().array(),
});
const pleromaAdminTagController: AppController = async (c) => {
const params = pleromaAdminTagSchema.parse(await c.req.json());
for (const nickname of params.nicknames) {
const pubkey = await lookupPubkey(nickname);
if (!pubkey) continue;
await updateAdminEvent(
{ kinds: [30382], authors: [Conf.pubkey], '#d': [pubkey], limit: 1 },
(prev) => {
const tags = prev?.tags ?? [['d', pubkey]];
for (const tag of params.tags) {
const existing = prev?.tags.some(([name, value]) => name === 't' && value === tag);
if (!existing) {
tags.push(['t', tag]);
}
}
return {
kind: 30382,
content: prev?.content ?? '',
tags,
};
},
c,
);
}
return new Response(null, { status: 204 });
};
const pleromaAdminUntagController: AppController = async (c) => {
const params = pleromaAdminTagSchema.parse(await c.req.json());
for (const nickname of params.nicknames) {
const pubkey = await lookupPubkey(nickname);
if (!pubkey) continue;
await updateAdminEvent(
{ kinds: [30382], authors: [Conf.pubkey], '#d': [pubkey], limit: 1 },
(prev) => ({
kind: 30382,
content: prev?.content ?? '',
tags: (prev?.tags ?? [['d', pubkey]])
.filter(([name, value]) => !(name === 't' && params.tags.includes(value))),
}),
c,
);
}
return new Response(null, { status: 204 });
};
const pleromaAdminSuggestSchema = z.object({
nicknames: z.string().array(),
});
const pleromaAdminSuggestController: AppController = async (c) => {
const { nicknames } = pleromaAdminSuggestSchema.parse(await c.req.json());
for (const nickname of nicknames) {
const pubkey = await lookupPubkey(nickname);
if (!pubkey) continue;
await updateUser(pubkey, { suggest: true }, c);
}
return new Response(null, { status: 204 });
};
const pleromaAdminUnsuggestController: AppController = async (c) => {
const { nicknames } = pleromaAdminSuggestSchema.parse(await c.req.json());
for (const nickname of nicknames) {
const pubkey = await lookupPubkey(nickname);
if (!pubkey) continue;
await updateUser(pubkey, { suggest: false }, c);
}
return new Response(null, { status: 204 });
};
export {
configController,
frontendConfigController,
pleromaAdminDeleteStatusController,
pleromaAdminSuggestController,
pleromaAdminTagController,
pleromaAdminUnsuggestController,
pleromaAdminUntagController,
updateConfigController,
};

View File

@ -31,7 +31,7 @@ async function renderV2Suggestions(c: AppContext, params: PaginatedListParams, s
const pubkey = await signer?.getPublicKey(); const pubkey = await signer?.getPublicKey();
const filters: NostrFilter[] = [ const filters: NostrFilter[] = [
{ kinds: [3], authors: [Conf.pubkey], limit: 1 }, { kinds: [30382], authors: [Conf.pubkey], '#n': ['suggest'], limit },
{ kinds: [1985], '#L': ['pub.ditto.trends'], '#l': [`#p`], authors: [Conf.pubkey], limit: 1 }, { kinds: [1985], '#L': ['pub.ditto.trends'], '#l': [`#p`], authors: [Conf.pubkey], limit: 1 },
]; ];
@ -42,8 +42,8 @@ async function renderV2Suggestions(c: AppContext, params: PaginatedListParams, s
const events = await store.query(filters, { signal }); const events = await store.query(filters, { signal });
const [suggestedEvent, followsEvent, mutesEvent, trendingEvent] = [ const [userEvents, followsEvent, mutesEvent, trendingEvent] = [
events.find((event) => matchFilter({ kinds: [3], authors: [Conf.pubkey] }, event)), events.filter((event) => matchFilter({ kinds: [30382], authors: [Conf.pubkey], '#n': ['suggest'] }, event)),
pubkey ? events.find((event) => matchFilter({ kinds: [3], authors: [pubkey] }, event)) : undefined, pubkey ? events.find((event) => matchFilter({ kinds: [3], authors: [pubkey] }, event)) : undefined,
pubkey ? events.find((event) => matchFilter({ kinds: [10000], authors: [pubkey] }, event)) : undefined, pubkey ? events.find((event) => matchFilter({ kinds: [10000], authors: [pubkey] }, event)) : undefined,
events.find((event) => events.find((event) =>
@ -51,8 +51,13 @@ async function renderV2Suggestions(c: AppContext, params: PaginatedListParams, s
), ),
]; ];
const [suggested, trending, follows, mutes] = [ const suggested = new Set(
getTagSet(suggestedEvent?.tags ?? [], 'p'), userEvents
.map((event) => event.tags.find(([name]) => name === 'd')?.[1])
.filter((pubkey): pubkey is string => !!pubkey),
);
const [trending, follows, mutes] = [
getTagSet(trendingEvent?.tags ?? [], 'p'), getTagSet(trendingEvent?.tags ?? [], 'p'),
getTagSet(followsEvent?.tags ?? [], 'p'), getTagSet(followsEvent?.tags ?? [], 'p'),
getTagSet(mutesEvent?.tags ?? [], 'p'), getTagSet(mutesEvent?.tags ?? [], 'p'),

View File

@ -1,75 +0,0 @@
import { NostrFilter } from '@nostrify/nostrify';
import Debug from '@soapbox/stickynotes/debug';
import { Conf } from '@/config.ts';
import * as pipeline from '@/pipeline.ts';
import { AdminSigner } from '@/signers/AdminSigner.ts';
import { Storages } from '@/storages.ts';
const debug = Debug('ditto:users');
interface User {
pubkey: string;
inserted_at: Date;
admin: boolean;
}
function buildUserEvent(user: User) {
const { origin, host } = Conf.url;
const signer = new AdminSigner();
return signer.signEvent({
kind: 30361,
tags: [
['d', user.pubkey],
['role', user.admin ? 'admin' : 'user'],
['origin', origin],
// NIP-31: https://github.com/nostr-protocol/nips/blob/master/31.md
['alt', `User's account was updated by the admins of ${host}`],
],
content: '',
created_at: Math.floor(user.inserted_at.getTime() / 1000),
});
}
/** Adds a user to the database. */
async function insertUser(user: User) {
debug('insertUser', JSON.stringify(user));
const event = await buildUserEvent(user);
return pipeline.handleEvent(event, AbortSignal.timeout(1000));
}
/**
* Finds a single user based on one or more properties.
*
* ```ts
* await findUser({ username: 'alex' });
* ```
*/
async function findUser(user: Partial<User>, signal?: AbortSignal): Promise<User | undefined> {
const filter: NostrFilter = { kinds: [30361], authors: [Conf.pubkey], limit: 1 };
for (const [key, value] of Object.entries(user)) {
switch (key) {
case 'pubkey':
filter['#d'] = [String(value)];
break;
case 'admin':
filter['#role'] = [value ? 'admin' : 'user'];
break;
}
}
const store = await Storages.db();
const [event] = await store.query([filter], { signal });
if (event) {
return {
pubkey: event.tags.find(([name]) => name === 'd')?.[1]!,
inserted_at: new Date(event.created_at * 1000),
admin: event.tags.find(([name]) => name === 'role')?.[1] === 'admin',
};
}
}
export { buildUserEvent, findUser, insertUser, type User };

View File

@ -2,8 +2,8 @@ import { NostrEvent } from '@nostrify/nostrify';
import { HTTPException } from 'hono'; import { HTTPException } from 'hono';
import { type AppContext, type AppMiddleware } from '@/app.ts'; import { type AppContext, type AppMiddleware } from '@/app.ts';
import { findUser, User } from '@/db/users.ts';
import { ReadOnlySigner } from '@/signers/ReadOnlySigner.ts'; import { ReadOnlySigner } from '@/signers/ReadOnlySigner.ts';
import { Storages } from '@/storages.ts';
import { localRequest } from '@/utils/api.ts'; import { localRequest } from '@/utils/api.ts';
import { import {
buildAuthEventTemplate, buildAuthEventTemplate,
@ -11,6 +11,7 @@ import {
type ParseAuthRequestOpts, type ParseAuthRequestOpts,
validateAuthEvent, validateAuthEvent,
} from '@/utils/nip98.ts'; } from '@/utils/nip98.ts';
import { Conf } from '@/config.ts';
/** /**
* NIP-98 auth. * NIP-98 auth.
@ -35,7 +36,14 @@ type UserRole = 'user' | 'admin';
/** Require the user to prove their role before invoking the controller. */ /** Require the user to prove their role before invoking the controller. */
function requireRole(role: UserRole, opts?: ParseAuthRequestOpts): AppMiddleware { function requireRole(role: UserRole, opts?: ParseAuthRequestOpts): AppMiddleware {
return withProof(async (_c, proof, next) => { return withProof(async (_c, proof, next) => {
const user = await findUser({ pubkey: proof.pubkey }); const store = await Storages.db();
const [user] = await store.query([{
kinds: [30382],
authors: [Conf.pubkey],
'#d': [proof.pubkey],
limit: 1,
}]);
if (user && matchesRole(user, role)) { if (user && matchesRole(user, role)) {
await next(); await next();
@ -53,15 +61,8 @@ function requireProof(opts?: ParseAuthRequestOpts): AppMiddleware {
} }
/** Check whether the user fulfills the role. */ /** Check whether the user fulfills the role. */
function matchesRole(user: User, role: UserRole): boolean { function matchesRole(user: NostrEvent, role: UserRole): boolean {
switch (role) { return user.tags.some(([tag, value]) => tag === 'n' && value === role);
case 'user':
return true;
case 'admin':
return user.admin;
default:
return false;
}
} }
/** HOC to obtain proof in middleware. */ /** HOC to obtain proof in middleware. */

View File

@ -1,14 +1,11 @@
import { NKinds, NostrEvent, NSchema as n } from '@nostrify/nostrify'; import { NKinds, NostrEvent, NSchema as n } from '@nostrify/nostrify';
import { PipePolicy } from '@nostrify/nostrify/policies';
import Debug from '@soapbox/stickynotes/debug'; import Debug from '@soapbox/stickynotes/debug';
import { sql } from 'kysely'; import { sql } from 'kysely';
import { LRUCache } from 'lru-cache'; import { LRUCache } from 'lru-cache';
import { Conf } from '@/config.ts';
import { DittoDB } from '@/db/DittoDB.ts'; import { DittoDB } from '@/db/DittoDB.ts';
import { deleteAttachedMedia } from '@/db/unattached-media.ts'; import { deleteAttachedMedia } from '@/db/unattached-media.ts';
import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { MuteListPolicy } from '@/policies/MuteListPolicy.ts';
import { RelayError } from '@/RelayError.ts'; import { RelayError } from '@/RelayError.ts';
import { hydrateEvents } from '@/storages/hydrate.ts'; import { hydrateEvents } from '@/storages/hydrate.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
@ -35,6 +32,7 @@ async function handleEvent(event: DittoEvent, signal: AbortSignal): Promise<void
} }
if (!(await verifyEventWorker(event))) return; if (!(await verifyEventWorker(event))) return;
if (encounterEvent(event)) return; if (encounterEvent(event)) return;
if (await existsInDB(event)) return;
debug(`NostrEvent<${event.kind}> ${event.id}`); debug(`NostrEvent<${event.kind}> ${event.id}`);
if (event.kind !== 24133) { if (event.kind !== 24133) {
@ -43,6 +41,15 @@ async function handleEvent(event: DittoEvent, signal: AbortSignal): Promise<void
await hydrateEvent(event, signal); await hydrateEvent(event, signal);
const n = getTagSet(event.user?.tags ?? [], 'n');
if (n.has('disable')) {
throw new RelayError('blocked', 'user is disabled');
}
if (n.has('suspend')) {
throw new RelayError('blocked', 'user is suspended');
}
await Promise.all([ await Promise.all([
storeEvent(event, signal), storeEvent(event, signal),
parseMetadata(event, signal), parseMetadata(event, signal),
@ -54,13 +61,8 @@ async function handleEvent(event: DittoEvent, signal: AbortSignal): Promise<void
async function policyFilter(event: NostrEvent): Promise<void> { async function policyFilter(event: NostrEvent): Promise<void> {
const debug = Debug('ditto:policy'); const debug = Debug('ditto:policy');
const policy = new PipePolicy([
new MuteListPolicy(Conf.pubkey, await Storages.admin()),
policyWorker,
]);
try { try {
const result = await policy.call(event); const result = await policyWorker.call(event);
debug(JSON.stringify(result)); debug(JSON.stringify(result));
RelayError.assert(result); RelayError.assert(result);
} catch (e) { } catch (e) {
@ -84,6 +86,13 @@ function encounterEvent(event: NostrEvent): boolean {
return encountered; return encountered;
} }
/** Check if the event already exists in the database. */
async function existsInDB(event: DittoEvent): Promise<boolean> {
const store = await Storages.db();
const events = await store.query([{ ids: [event.id], limit: 1 }]);
return events.length > 0;
}
/** Hydrate the event with the user, if applicable. */ /** Hydrate the event with the user, if applicable. */
async function hydrateEvent(event: DittoEvent, signal: AbortSignal): Promise<void> { async function hydrateEvent(event: DittoEvent, signal: AbortSignal): Promise<void> {
await hydrateEvents({ events: [event], store: await Storages.db(), signal }); await hydrateEvents({ events: [event], store: await Storages.db(), signal });

View File

@ -3,15 +3,15 @@ import { RelayPoolWorker } from 'nostr-relaypool';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { DittoDB } from '@/db/DittoDB.ts'; import { DittoDB } from '@/db/DittoDB.ts';
import { AdminStore } from '@/storages/AdminStore.ts';
import { EventsDB } from '@/storages/EventsDB.ts'; import { EventsDB } from '@/storages/EventsDB.ts';
import { PoolStore } from '@/storages/pool-store.ts'; import { PoolStore } from '@/storages/pool-store.ts';
import { SearchStore } from '@/storages/search-store.ts'; import { SearchStore } from '@/storages/search-store.ts';
import { InternalRelay } from '@/storages/InternalRelay.ts'; import { InternalRelay } from '@/storages/InternalRelay.ts';
import { UserStore } from '@/storages/UserStore.ts';
export class Storages { export class Storages {
private static _db: Promise<EventsDB> | undefined; private static _db: Promise<EventsDB> | undefined;
private static _admin: Promise<UserStore> | undefined; private static _admin: Promise<AdminStore> | undefined;
private static _client: Promise<PoolStore> | undefined; private static _client: Promise<PoolStore> | undefined;
private static _pubsub: Promise<InternalRelay> | undefined; private static _pubsub: Promise<InternalRelay> | undefined;
private static _search: Promise<SearchStore> | undefined; private static _search: Promise<SearchStore> | undefined;
@ -28,9 +28,9 @@ export class Storages {
} }
/** Admin user storage. */ /** Admin user storage. */
public static async admin(): Promise<UserStore> { public static async admin(): Promise<AdminStore> {
if (!this._admin) { if (!this._admin) {
this._admin = Promise.resolve(new UserStore(Conf.pubkey, await this.db())); this._admin = Promise.resolve(new AdminStore(await this.db()));
} }
return this._admin; return this._admin;
} }

View File

@ -0,0 +1,40 @@
import { NostrEvent, NostrFilter, NStore } from '@nostrify/nostrify';
import { Conf } from '@/config.ts';
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { getTagSet } from '@/utils/tags.ts';
/** A store that prevents banned users from being displayed. */
export class AdminStore implements NStore {
constructor(private store: NStore) {}
async event(event: NostrEvent, opts?: { signal?: AbortSignal }): Promise<void> {
return await this.store.event(event, opts);
}
async query(filters: NostrFilter[], opts: { signal?: AbortSignal; limit?: number } = {}): Promise<DittoEvent[]> {
const events = await this.store.query(filters, opts);
const users = await this.store.query([{
kinds: [30382],
authors: [Conf.pubkey],
'#d': events.map((event) => event.pubkey),
limit: 1,
}]);
return events.filter((event) => {
const user = users.find(
({ kind, pubkey, tags }) =>
kind === 30382 && pubkey === Conf.pubkey && tags.find(([name]) => name === 'd')?.[1] === event.pubkey,
);
const n = getTagSet(user?.tags ?? [], 'n');
if (n.has('disable') || n.has('suspend')) {
return false;
}
return true;
});
}
}

View File

@ -39,8 +39,6 @@ class EventsDB implements NStore {
'q': ({ event, count, value }) => count === 0 && event.kind === 1 && isNostrId(value), 'q': ({ event, count, value }) => count === 0 && event.kind === 1 && isNostrId(value),
'r': ({ event, count, value }) => (event.kind === 1985 ? count < 20 : count < 3) && isURL(value), 'r': ({ event, count, value }) => (event.kind === 1985 ? count < 20 : count < 3) && isURL(value),
't': ({ event, count, value }) => (event.kind === 1985 ? count < 20 : count < 5) && value.length < 50, 't': ({ event, count, value }) => (event.kind === 1985 ? count < 20 : count < 5) && value.length < 50,
'name': ({ event, count }) => event.kind === 30361 && count === 0,
'role': ({ event, count }) => event.kind === 30361 && count === 0,
}; };
constructor(private kysely: Kysely<DittoTables>) { constructor(private kysely: Kysely<DittoTables>) {

View File

@ -82,7 +82,7 @@ export function assembleEvents(
for (const event of a) { for (const event of a) {
event.author = b.find((e) => matchFilter({ kinds: [0], authors: [event.pubkey] }, e)); event.author = b.find((e) => matchFilter({ kinds: [0], authors: [event.pubkey] }, e));
event.user = b.find((e) => matchFilter({ kinds: [30361], authors: [admin], '#d': [event.pubkey] }, e)); event.user = b.find((e) => matchFilter({ kinds: [30382], authors: [admin], '#d': [event.pubkey] }, e));
if (event.kind === 1) { if (event.kind === 1) {
const id = findQuoteTag(event.tags)?.[1] || findQuoteInContent(event.content); const id = findQuoteTag(event.tags)?.[1] || findQuoteInContent(event.content);
@ -201,7 +201,7 @@ function gatherUsers({ events, store, signal }: HydrateOpts): Promise<DittoEvent
const pubkeys = new Set(events.map((event) => event.pubkey)); const pubkeys = new Set(events.map((event) => event.pubkey));
return store.query( return store.query(
[{ kinds: [30361], authors: [Conf.pubkey], '#d': [...pubkeys], limit: pubkeys.size }], [{ kinds: [30382], authors: [Conf.pubkey], '#d': [...pubkeys], limit: pubkeys.size }],
{ signal }, { signal },
); );
} }

View File

@ -107,6 +107,36 @@ async function updateAdminEvent<E extends EventStub>(
return createAdminEvent(fn(prev), c); return createAdminEvent(fn(prev), c);
} }
async function updateUser(pubkey: string, n: Record<string, boolean>, c: AppContext): Promise<NostrEvent> {
const signer = new AdminSigner();
const admin = await signer.getPublicKey();
return updateAdminEvent(
{ kinds: [30382], authors: [admin], '#d': [pubkey], limit: 1 },
(prev) => {
const prevNames = prev?.tags.reduce((acc, [name, value]) => {
if (name === 'n') acc[value] = true;
return acc;
}, {} as Record<string, boolean>);
const names = { ...prevNames, ...n };
const nTags = Object.entries(names).filter(([, value]) => value).map(([name]) => ['n', name]);
const other = prev?.tags.filter(([name]) => !['d', 'n'].includes(name)) ?? [];
return {
kind: 30382,
content: prev?.content ?? '',
tags: [
['d', pubkey],
...nTags,
...other,
],
};
},
c,
);
}
/** Push the event through the pipeline, rethrowing any RelayError. */ /** Push the event through the pipeline, rethrowing any RelayError. */
async function publishEvent(event: NostrEvent, c: AppContext): Promise<NostrEvent> { async function publishEvent(event: NostrEvent, c: AppContext): Promise<NostrEvent> {
debug('EVENT', event); debug('EVENT', event);
@ -264,7 +294,9 @@ export {
type PaginationParams, type PaginationParams,
paginationSchema, paginationSchema,
parseBody, parseBody,
updateAdminEvent,
updateEvent, updateEvent,
updateListAdminEvent, updateListAdminEvent,
updateListEvent, updateListEvent,
updateUser,
}; };

View File

@ -6,6 +6,7 @@ import { Conf } from '@/config.ts';
import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
import { getLnurl } from '@/utils/lnurl.ts'; import { getLnurl } from '@/utils/lnurl.ts';
import { nip05Cache } from '@/utils/nip05.ts'; import { nip05Cache } from '@/utils/nip05.ts';
import { getTagSet } from '@/utils/tags.ts';
import { Nip05, nostrDate, nostrNow, parseNip05 } from '@/utils.ts'; import { Nip05, nostrDate, nostrNow, parseNip05 } from '@/utils.ts';
import { renderEmojis } from '@/views/mastodon/emojis.ts'; import { renderEmojis } from '@/views/mastodon/emojis.ts';
@ -33,7 +34,7 @@ async function renderAccount(
const npub = nip19.npubEncode(pubkey); const npub = nip19.npubEncode(pubkey);
const parsed05 = await parseAndVerifyNip05(nip05, pubkey); const parsed05 = await parseAndVerifyNip05(nip05, pubkey);
const role = event.user?.tags.find(([name]) => name === 'role')?.[1] ?? 'user'; const names = getTagSet(event.user?.tags ?? [], 'n');
return { return {
id: pubkey, id: pubkey,
@ -74,13 +75,14 @@ async function renderAccount(
username: parsed05?.nickname || npub.substring(0, 8), username: parsed05?.nickname || npub.substring(0, 8),
ditto: { ditto: {
accepts_zaps: Boolean(getLnurl({ lud06, lud16 })), accepts_zaps: Boolean(getLnurl({ lud06, lud16 })),
is_registered: Boolean(event.user),
}, },
pleroma: { pleroma: {
is_admin: role === 'admin', is_admin: names.has('admin'),
is_moderator: ['admin', 'moderator'].includes(role), is_moderator: names.has('admin') || names.has('moderator'),
is_suggested: names.has('suggest'),
is_local: parsed05?.domain === Conf.url.host, is_local: parsed05?.domain === Conf.url.host,
settings_store: undefined as unknown, settings_store: undefined as unknown,
tags: [...getTagSet(event.user?.tags ?? [], 't')],
}, },
nostr: { nostr: {
pubkey, pubkey,