Merge branch 'main' into feat-zap-counter
This commit is contained in:
commit
c77da12f9d
|
@ -22,7 +22,7 @@
|
|||
"@db/sqlite": "jsr:@db/sqlite@^0.11.1",
|
||||
"@isaacs/ttlcache": "npm:@isaacs/ttlcache@^1.4.1",
|
||||
"@noble/secp256k1": "npm:@noble/secp256k1@^2.0.0",
|
||||
"@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.22.5",
|
||||
"@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.23.1",
|
||||
"@scure/base": "npm:@scure/base@^1.1.6",
|
||||
"@sentry/deno": "https://deno.land/x/sentry@7.112.2/index.mjs",
|
||||
"@soapbox/kysely-deno-sqlite": "jsr:@soapbox/kysely-deno-sqlite@^2.1.0",
|
||||
|
|
29
deno.lock
29
deno.lock
|
@ -10,6 +10,7 @@
|
|||
"jsr:@nostrify/nostrify@^0.22.1": "jsr:@nostrify/nostrify@0.22.5",
|
||||
"jsr:@nostrify/nostrify@^0.22.4": "jsr:@nostrify/nostrify@0.22.4",
|
||||
"jsr:@nostrify/nostrify@^0.22.5": "jsr:@nostrify/nostrify@0.22.5",
|
||||
"jsr:@nostrify/nostrify@^0.23.1": "jsr:@nostrify/nostrify@0.23.1",
|
||||
"jsr:@soapbox/kysely-deno-sqlite@^2.1.0": "jsr:@soapbox/kysely-deno-sqlite@2.2.0",
|
||||
"jsr:@soapbox/stickynotes@^0.4.0": "jsr:@soapbox/stickynotes@0.4.0",
|
||||
"jsr:@std/assert@^0.217.0": "jsr:@std/assert@0.217.0",
|
||||
|
@ -17,6 +18,7 @@
|
|||
"jsr:@std/assert@^0.224.0": "jsr:@std/assert@0.224.0",
|
||||
"jsr:@std/assert@^0.225.1": "jsr:@std/assert@0.225.3",
|
||||
"jsr:@std/bytes@^0.224.0": "jsr:@std/bytes@0.224.0",
|
||||
"jsr:@std/bytes@^1.0.0-rc.3": "jsr:@std/bytes@1.0.0",
|
||||
"jsr:@std/crypto@^0.224.0": "jsr:@std/crypto@0.224.0",
|
||||
"jsr:@std/dotenv@^0.224.0": "jsr:@std/dotenv@0.224.0",
|
||||
"jsr:@std/encoding@^0.221.0": "jsr:@std/encoding@0.221.0",
|
||||
|
@ -25,7 +27,7 @@
|
|||
"jsr:@std/fmt@^0.221.0": "jsr:@std/fmt@0.221.0",
|
||||
"jsr:@std/fs@^0.221.0": "jsr:@std/fs@0.221.0",
|
||||
"jsr:@std/internal@^1.0.0": "jsr:@std/internal@1.0.0",
|
||||
"jsr:@std/io@^0.224": "jsr:@std/io@0.224.0",
|
||||
"jsr:@std/io@^0.224": "jsr:@std/io@0.224.1",
|
||||
"jsr:@std/media-types@^0.224.1": "jsr:@std/media-types@0.224.1",
|
||||
"jsr:@std/path@0.217": "jsr:@std/path@0.217.0",
|
||||
"jsr:@std/path@^0.221.0": "jsr:@std/path@0.221.0",
|
||||
|
@ -119,6 +121,20 @@
|
|||
"npm:zod@^3.23.8"
|
||||
]
|
||||
},
|
||||
"@nostrify/nostrify@0.23.1": {
|
||||
"integrity": "7a242dedfe33cf38131696ad96d789d54257cfbfd5b5e63748fe5d53c057d99a",
|
||||
"dependencies": [
|
||||
"jsr:@std/encoding@^0.224.1",
|
||||
"npm:@scure/base@^1.1.6",
|
||||
"npm:@scure/bip32@^1.4.0",
|
||||
"npm:@scure/bip39@^1.3.0",
|
||||
"npm:kysely@^0.27.3",
|
||||
"npm:lru-cache@^10.2.0",
|
||||
"npm:nostr-tools@^2.7.0",
|
||||
"npm:websocket-ts@^2.1.5",
|
||||
"npm:zod@^3.23.8"
|
||||
]
|
||||
},
|
||||
"@soapbox/kysely-deno-sqlite@2.2.0": {
|
||||
"integrity": "668ec94600bc4b4d7bd618dd7ca65d4ef30ee61c46ffcb379b6f45203c08517a",
|
||||
"dependencies": [
|
||||
|
@ -146,6 +162,9 @@
|
|||
"@std/bytes@0.224.0": {
|
||||
"integrity": "a2250e1d0eb7d1c5a426f21267ab9bdeac2447fa87a3d0d1a467d3f7a6058e49"
|
||||
},
|
||||
"@std/bytes@1.0.0": {
|
||||
"integrity": "9392e72af80adccaa1197912fa19990ed091cb98d5c9c4344b0c301b22d7c632"
|
||||
},
|
||||
"@std/crypto@0.224.0": {
|
||||
"integrity": "154ef3ff08ef535562ef1a718718c5b2c5fc3808f0f9100daad69e829bfcdf2d",
|
||||
"dependencies": [
|
||||
|
@ -181,6 +200,12 @@
|
|||
"jsr:@std/bytes@^0.224.0"
|
||||
]
|
||||
},
|
||||
"@std/io@0.224.1": {
|
||||
"integrity": "73de242551a5c0965eb33e36b1fc7df4834ffbc836a1a643a410ccd11253d6be",
|
||||
"dependencies": [
|
||||
"jsr:@std/bytes@^1.0.0-rc.3"
|
||||
]
|
||||
},
|
||||
"@std/media-types@0.224.1": {
|
||||
"integrity": "9e69a5daed37c5b5c6d3ce4731dc191f80e67f79bed392b0957d1d03b87f11e1"
|
||||
},
|
||||
|
@ -1318,7 +1343,7 @@
|
|||
"dependencies": [
|
||||
"jsr:@bradenmacdonald/s3-lite-client@^0.7.4",
|
||||
"jsr:@db/sqlite@^0.11.1",
|
||||
"jsr:@nostrify/nostrify@^0.22.5",
|
||||
"jsr:@nostrify/nostrify@^0.23.1",
|
||||
"jsr:@soapbox/kysely-deno-sqlite@^2.1.0",
|
||||
"jsr:@soapbox/stickynotes@^0.4.0",
|
||||
"jsr:@std/assert@^0.225.1",
|
||||
|
|
|
@ -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.
|
|
@ -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"
|
||||
}
|
|
@ -1,7 +1,6 @@
|
|||
import { NSchema } from '@nostrify/nostrify';
|
||||
|
||||
import { DittoDB } from '@/db/DittoDB.ts';
|
||||
import { Conf } from '@/config.ts';
|
||||
import { AdminSigner } from '@/signers/AdminSigner.ts';
|
||||
import { EventsDB } from '@/storages/EventsDB.ts';
|
||||
import { nostrNow } from '@/utils.ts';
|
||||
|
@ -21,14 +20,39 @@ if (!['admin', 'user'].includes(role)) {
|
|||
Deno.exit(1);
|
||||
}
|
||||
|
||||
const event = await new AdminSigner().signEvent({
|
||||
kind: 30361,
|
||||
tags: [
|
||||
const signer = new AdminSigner();
|
||||
const admin = await signer.getPublicKey();
|
||||
|
||||
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],
|
||||
['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: '',
|
||||
created_at: nostrNow(),
|
||||
});
|
||||
|
|
44
src/app.ts
44
src/app.ts
|
@ -26,11 +26,21 @@ import {
|
|||
updateCredentialsController,
|
||||
verifyCredentialsController,
|
||||
} from '@/controllers/api/accounts.ts';
|
||||
import { adminAccountAction, adminAccountsController } from '@/controllers/api/admin.ts';
|
||||
import {
|
||||
adminAccountsController,
|
||||
adminActionController,
|
||||
adminApproveController,
|
||||
adminRejectController,
|
||||
} from '@/controllers/api/admin.ts';
|
||||
import { appCredentialsController, createAppController } from '@/controllers/api/apps.ts';
|
||||
import { blocksController } from '@/controllers/api/blocks.ts';
|
||||
import { bookmarksController } from '@/controllers/api/bookmarks.ts';
|
||||
import { adminRelaysController, adminSetRelaysController } from '@/controllers/api/ditto.ts';
|
||||
import {
|
||||
adminRelaysController,
|
||||
adminSetRelaysController,
|
||||
nameRequestController,
|
||||
nameRequestsController,
|
||||
} from '@/controllers/api/ditto.ts';
|
||||
import { emptyArrayController, emptyObjectController, notImplementedController } from '@/controllers/api/fallback.ts';
|
||||
import { instanceController } from '@/controllers/api/instance.ts';
|
||||
import { markersController, updateMarkersController } from '@/controllers/api/markers.ts';
|
||||
|
@ -42,6 +52,10 @@ import {
|
|||
configController,
|
||||
frontendConfigController,
|
||||
pleromaAdminDeleteStatusController,
|
||||
pleromaAdminSuggestController,
|
||||
pleromaAdminTagController,
|
||||
pleromaAdminUnsuggestController,
|
||||
pleromaAdminUntagController,
|
||||
updateConfigController,
|
||||
} from '@/controllers/api/pleroma.ts';
|
||||
import { preferencesController } from '@/controllers/api/preferences.ts';
|
||||
|
@ -49,6 +63,7 @@ import { deleteReactionController, reactionController, reactionsController } fro
|
|||
import { relayController } from '@/controllers/nostr/relay.ts';
|
||||
import {
|
||||
adminReportController,
|
||||
adminReportReopenController,
|
||||
adminReportResolveController,
|
||||
adminReportsController,
|
||||
reportController,
|
||||
|
@ -231,7 +246,6 @@ app.get('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', reactions
|
|||
app.put('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', requireSigner, reactionController);
|
||||
app.delete('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', requireSigner, deleteReactionController);
|
||||
|
||||
app.get('/api/v1/admin/accounts', requireRole('admin'), adminAccountsController);
|
||||
app.get('/api/v1/pleroma/admin/config', requireRole('admin'), configController);
|
||||
app.post('/api/v1/pleroma/admin/config', requireRole('admin'), updateConfigController);
|
||||
app.delete('/api/v1/pleroma/admin/statuses/:id', requireRole('admin'), pleromaAdminDeleteStatusController);
|
||||
|
@ -239,6 +253,9 @@ app.delete('/api/v1/pleroma/admin/statuses/:id', requireRole('admin'), pleromaAd
|
|||
app.get('/api/v1/admin/ditto/relays', requireRole('admin'), adminRelaysController);
|
||||
app.put('/api/v1/admin/ditto/relays', requireRole('admin'), adminSetRelaysController);
|
||||
|
||||
app.post('/api/v1/ditto/names', requireSigner, nameRequestController);
|
||||
app.get('/api/v1/ditto/names', requireSigner, nameRequestsController);
|
||||
|
||||
app.post('/api/v1/ditto/zap', requireSigner, zapController);
|
||||
|
||||
app.post('/api/v1/reports', requireSigner, reportController);
|
||||
|
@ -250,8 +267,27 @@ app.post(
|
|||
requireRole('admin'),
|
||||
adminReportResolveController,
|
||||
);
|
||||
app.post(
|
||||
'/api/v1/admin/reports/:id{[0-9a-f]{64}}/reopen',
|
||||
requireSigner,
|
||||
requireRole('admin'),
|
||||
adminReportReopenController,
|
||||
);
|
||||
|
||||
app.post('/api/v1/admin/accounts/:id{[0-9a-f]{64}}/action', requireSigner, requireRole('admin'), adminAccountAction);
|
||||
app.get('/api/v1/admin/accounts', requireRole('admin'), adminAccountsController);
|
||||
app.post('/api/v1/admin/accounts/:id{[0-9a-f]{64}}/action', requireSigner, requireRole('admin'), adminActionController);
|
||||
app.post(
|
||||
'/api/v1/admin/accounts/:id{[0-9a-f]{64}}/approve',
|
||||
requireSigner,
|
||||
requireRole('admin'),
|
||||
adminApproveController,
|
||||
);
|
||||
app.post('/api/v1/admin/accounts/:id{[0-9a-f]{64}}/reject', requireSigner, requireRole('admin'), adminRejectController);
|
||||
|
||||
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.
|
||||
app.get('/api/v1/custom_emojis', emptyArrayController);
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
import { NostrFilter } from '@nostrify/nostrify';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type AppController } from '@/app.ts';
|
||||
import { Conf } from '@/config.ts';
|
||||
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
|
||||
import { booleanParamSchema } from '@/schema.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { paginated, paginationSchema, parseBody, updateListAdminEvent } from '@/utils/api.ts';
|
||||
import { addTag } from '@/utils/tags.ts';
|
||||
import { renderAdminAccount } from '@/views/mastodon/admin-accounts.ts';
|
||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||
import { createAdminEvent, paginated, paginationSchema, parseBody, updateEventInfo, updateUser } from '@/utils/api.ts';
|
||||
import { renderNameRequest } from '@/views/ditto.ts';
|
||||
import { renderAdminAccount, renderAdminAccountFromPubkey } from '@/views/mastodon/admin-accounts.ts';
|
||||
|
||||
const adminAccountQuerySchema = z.object({
|
||||
local: booleanParamSchema.optional(),
|
||||
|
@ -27,45 +28,93 @@ const adminAccountQuerySchema = z.object({
|
|||
});
|
||||
|
||||
const adminAccountsController: AppController = async (c) => {
|
||||
const store = await Storages.db();
|
||||
const params = paginationSchema.parse(c.req.query());
|
||||
const { signal } = c.req.raw;
|
||||
const {
|
||||
local,
|
||||
pending,
|
||||
disabled,
|
||||
silenced,
|
||||
suspended,
|
||||
sensitized,
|
||||
staff,
|
||||
} = adminAccountQuerySchema.parse(c.req.query());
|
||||
|
||||
// Not supported.
|
||||
if (pending || disabled || silenced || suspended || sensitized) {
|
||||
if (pending) {
|
||||
if (disabled || silenced || suspended || sensitized) {
|
||||
return c.json([]);
|
||||
}
|
||||
|
||||
const store = await Storages.db();
|
||||
const { since, until, limit } = paginationSchema.parse(c.req.query());
|
||||
const { signal } = c.req.raw;
|
||||
|
||||
const events = await store.query([{ kinds: [30361], authors: [Conf.pubkey], since, until, limit }], { signal });
|
||||
const pubkeys = events.map((event) => event.tags.find(([name]) => name === 'd')?.[1]!);
|
||||
const authors = await store.query([{ kinds: [0], authors: pubkeys }], { signal });
|
||||
|
||||
for (const event of events) {
|
||||
const d = event.tags.find(([name]) => name === 'd')?.[1];
|
||||
(event as DittoEvent).d_author = authors.find((author) => author.pubkey === d);
|
||||
}
|
||||
|
||||
const accounts = await Promise.all(
|
||||
events.map((event) => renderAdminAccount(event)),
|
||||
const orig = await store.query(
|
||||
[{ kinds: [30383], authors: [Conf.pubkey], '#k': ['3036'], '#n': ['pending'], ...params }],
|
||||
{ signal },
|
||||
);
|
||||
|
||||
const ids = new Set<string>(
|
||||
orig
|
||||
.map(({ tags }) => tags.find(([name]) => name === 'd')?.[1])
|
||||
.filter((id): id is string => !!id),
|
||||
);
|
||||
|
||||
const events = await store.query([{ kinds: [3036], ids: [...ids] }])
|
||||
.then((events) => hydrateEvents({ store, events, signal }));
|
||||
|
||||
const nameRequests = await Promise.all(events.map(renderNameRequest));
|
||||
return paginated(c, orig, nameRequests);
|
||||
}
|
||||
|
||||
if (disabled || silenced || suspended || sensitized) {
|
||||
const n = [];
|
||||
|
||||
if (disabled) {
|
||||
n.push('disabled');
|
||||
}
|
||||
if (silenced) {
|
||||
n.push('silenced');
|
||||
}
|
||||
if (suspended) {
|
||||
n.push('suspended');
|
||||
}
|
||||
if (sensitized) {
|
||||
n.push('sensitized');
|
||||
}
|
||||
if (staff) {
|
||||
n.push('admin');
|
||||
n.push('moderator');
|
||||
}
|
||||
|
||||
const events = await store.query([{ kinds: [30382], authors: [Conf.pubkey], '#n': n, ...params }], { signal });
|
||||
const pubkeys = new Set<string>(events.map(({ pubkey }) => pubkey));
|
||||
const authors = await store.query([{ kinds: [0], authors: [...pubkeys] }])
|
||||
.then((events) => hydrateEvents({ store, events, signal }));
|
||||
|
||||
const accounts = await Promise.all(
|
||||
[...pubkeys].map((pubkey) => {
|
||||
const author = authors.find((e) => e.pubkey === pubkey);
|
||||
return author ? renderAdminAccount(author) : renderAdminAccountFromPubkey(pubkey);
|
||||
}),
|
||||
);
|
||||
|
||||
return paginated(c, events, accounts);
|
||||
}
|
||||
|
||||
const filter: NostrFilter = { kinds: [0], ...params };
|
||||
if (local) {
|
||||
filter.search = `domain:${Conf.url.host}`;
|
||||
}
|
||||
const events = await store.query([filter], { signal });
|
||||
const accounts = await Promise.all(events.map(renderAdminAccount));
|
||||
return paginated(c, events, accounts);
|
||||
};
|
||||
|
||||
const adminAccountActionSchema = z.object({
|
||||
type: z.enum(['none', 'sensitive', 'disable', 'silence', 'suspend']),
|
||||
type: z.enum(['none', 'sensitive', 'disable', 'silence', 'suspend', 'revoke_name']),
|
||||
});
|
||||
|
||||
const adminAccountAction: AppController = async (c) => {
|
||||
const adminActionController: AppController = async (c) => {
|
||||
const body = await parseBody(c.req.raw);
|
||||
const store = await Storages.db();
|
||||
const result = adminAccountActionSchema.safeParse(body);
|
||||
const authorId = c.req.param('id');
|
||||
|
||||
|
@ -75,17 +124,84 @@ const adminAccountAction: AppController = async (c) => {
|
|||
|
||||
const { data } = result;
|
||||
|
||||
if (data.type !== 'disable') {
|
||||
return c.json({ error: 'Record invalid' }, 422);
|
||||
const n: Record<string, boolean> = {};
|
||||
|
||||
if (data.type === 'sensitive') {
|
||||
n.sensitized = true;
|
||||
}
|
||||
if (data.type === 'disable') {
|
||||
n.disabled = true;
|
||||
}
|
||||
if (data.type === 'silence') {
|
||||
n.silenced = true;
|
||||
}
|
||||
if (data.type === 'suspend') {
|
||||
n.suspended = true;
|
||||
store.remove([{ authors: [authorId] }]).catch(console.warn);
|
||||
}
|
||||
if (data.type === 'revoke_name') {
|
||||
n.revoke_name = true;
|
||||
store.remove([{ kinds: [30360], authors: [Conf.pubkey], '#p': [authorId] }]).catch(console.warn);
|
||||
}
|
||||
|
||||
await updateListAdminEvent(
|
||||
{ kinds: [10000], authors: [Conf.pubkey], limit: 1 },
|
||||
(tags) => addTag(tags, ['p', authorId]),
|
||||
c,
|
||||
);
|
||||
await updateUser(authorId, n, c);
|
||||
|
||||
return c.json({}, 200);
|
||||
};
|
||||
|
||||
export { adminAccountAction, adminAccountsController };
|
||||
const adminApproveController: AppController = async (c) => {
|
||||
const eventId = c.req.param('id');
|
||||
const store = await Storages.db();
|
||||
|
||||
const [event] = await store.query([{ kinds: [3036], ids: [eventId] }]);
|
||||
if (!event) {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
const r = event.tags.find(([name]) => name === 'r')?.[1];
|
||||
if (!r) {
|
||||
return c.json({ error: 'NIP-05 not found' }, 404);
|
||||
}
|
||||
if (!z.string().email().safeParse(r).success) {
|
||||
return c.json({ error: 'Invalid NIP-05' }, 400);
|
||||
}
|
||||
|
||||
const [existing] = await store.query([{ kinds: [30360], authors: [Conf.pubkey], '#d': [r], limit: 1 }]);
|
||||
if (existing) {
|
||||
return c.json({ error: 'NIP-05 already granted to another user' }, 400);
|
||||
}
|
||||
|
||||
await createAdminEvent({
|
||||
kind: 30360,
|
||||
tags: [
|
||||
['d', r],
|
||||
['L', 'nip05.domain'],
|
||||
['l', r.split('@')[1], 'nip05.domain'],
|
||||
['p', event.pubkey],
|
||||
['e', event.id],
|
||||
],
|
||||
}, c);
|
||||
|
||||
await updateEventInfo(eventId, { pending: false, approved: true, rejected: false }, c);
|
||||
await hydrateEvents({ events: [event], store });
|
||||
|
||||
const nameRequest = await renderNameRequest(event);
|
||||
return c.json(nameRequest);
|
||||
};
|
||||
|
||||
const adminRejectController: AppController = async (c) => {
|
||||
const eventId = c.req.param('id');
|
||||
const store = await Storages.db();
|
||||
|
||||
const [event] = await store.query([{ kinds: [3036], ids: [eventId] }]);
|
||||
if (!event) {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
await updateEventInfo(eventId, { pending: false, approved: false, rejected: true }, c);
|
||||
await hydrateEvents({ events: [event], store });
|
||||
|
||||
const nameRequest = await renderNameRequest(event);
|
||||
return c.json(nameRequest);
|
||||
};
|
||||
export { adminAccountsController, adminActionController, adminApproveController, adminRejectController };
|
||||
|
|
|
@ -1,10 +1,14 @@
|
|||
import { NostrEvent } from '@nostrify/nostrify';
|
||||
import { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppController } from '@/app.ts';
|
||||
import { Conf } from '@/config.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { booleanParamSchema } from '@/schema.ts';
|
||||
import { AdminSigner } from '@/signers/AdminSigner.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||
import { createEvent, paginated, paginationSchema } from '@/utils/api.ts';
|
||||
import { renderNameRequest } from '@/views/ditto.ts';
|
||||
|
||||
const markerSchema = z.enum(['read', 'write']);
|
||||
|
||||
|
@ -58,3 +62,76 @@ function renderRelays(event: NostrEvent): RelayEntity[] {
|
|||
return acc;
|
||||
}, [] as RelayEntity[]);
|
||||
}
|
||||
|
||||
const nameRequestSchema = z.object({
|
||||
name: z.string().email(),
|
||||
reason: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
export const nameRequestController: AppController = async (c) => {
|
||||
const { name, reason } = nameRequestSchema.parse(await c.req.json());
|
||||
|
||||
const event = await createEvent({
|
||||
kind: 3036,
|
||||
content: reason,
|
||||
tags: [
|
||||
['r', name],
|
||||
['L', 'nip05.domain'],
|
||||
['l', name.split('@')[1], 'nip05.domain'],
|
||||
['p', Conf.pubkey],
|
||||
],
|
||||
}, c);
|
||||
|
||||
await hydrateEvents({ events: [event], store: await Storages.db() });
|
||||
|
||||
const nameRequest = await renderNameRequest(event);
|
||||
return c.json(nameRequest);
|
||||
};
|
||||
|
||||
const nameRequestsSchema = z.object({
|
||||
approved: booleanParamSchema.optional(),
|
||||
rejected: booleanParamSchema.optional(),
|
||||
});
|
||||
|
||||
export const nameRequestsController: AppController = async (c) => {
|
||||
const store = await Storages.db();
|
||||
const signer = c.get('signer')!;
|
||||
const pubkey = await signer.getPublicKey();
|
||||
|
||||
const params = paginationSchema.parse(c.req.query());
|
||||
const { approved, rejected } = nameRequestsSchema.parse(c.req.query());
|
||||
|
||||
const filter: NostrFilter = {
|
||||
kinds: [30383],
|
||||
authors: [Conf.pubkey],
|
||||
'#k': ['3036'],
|
||||
'#p': [pubkey],
|
||||
...params,
|
||||
};
|
||||
|
||||
if (approved) {
|
||||
filter['#n'] = ['approved'];
|
||||
}
|
||||
if (rejected) {
|
||||
filter['#n'] = ['rejected'];
|
||||
}
|
||||
|
||||
const orig = await store.query([filter]);
|
||||
const ids = new Set<string>();
|
||||
|
||||
for (const event of orig) {
|
||||
const d = event.tags.find(([name]) => name === 'd')?.[1];
|
||||
if (d) {
|
||||
ids.add(d);
|
||||
}
|
||||
}
|
||||
|
||||
const events = await store.query([{ kinds: [3036], ids: [...ids] }])
|
||||
.then((events) => hydrateEvents({ store, events: events, signal: c.req.raw.signal }));
|
||||
|
||||
const nameRequests = await Promise.all(
|
||||
events.map((event) => renderNameRequest(event)),
|
||||
);
|
||||
|
||||
return paginated(c, orig, nameRequests);
|
||||
};
|
||||
|
|
|
@ -1,24 +1,87 @@
|
|||
import { NostrFilter } from '@nostrify/nostrify';
|
||||
import { NostrFilter, NSchema as n } from '@nostrify/nostrify';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppContext, AppController } from '@/app.ts';
|
||||
import { Conf } from '@/config.ts';
|
||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||
import { paginated, paginationSchema } from '@/utils/api.ts';
|
||||
import { paginated, PaginationParams, paginationSchema } from '@/utils/api.ts';
|
||||
import { renderNotification } from '@/views/mastodon/notifications.ts';
|
||||
|
||||
/** Set of known notification types across backends. */
|
||||
const notificationTypes = new Set([
|
||||
'mention',
|
||||
'status',
|
||||
'reblog',
|
||||
'follow',
|
||||
'follow_request',
|
||||
'favourite',
|
||||
'poll',
|
||||
'update',
|
||||
'admin.sign_up',
|
||||
'admin.report',
|
||||
'severed_relationships',
|
||||
'pleroma:emoji_reaction',
|
||||
'ditto:name_grant',
|
||||
]);
|
||||
|
||||
const notificationsSchema = z.object({
|
||||
account_id: n.id().optional(),
|
||||
});
|
||||
|
||||
const notificationsController: AppController = async (c) => {
|
||||
const pubkey = await c.get('signer')?.getPublicKey()!;
|
||||
const { since, until } = paginationSchema.parse(c.req.query());
|
||||
const params = paginationSchema.parse(c.req.query());
|
||||
|
||||
return renderNotifications(c, [{ kinds: [1, 6, 7], '#p': [pubkey], since, until }]);
|
||||
const types = notificationTypes
|
||||
.intersection(new Set(c.req.queries('types[]') ?? notificationTypes))
|
||||
.difference(new Set(c.req.queries('exclude_types[]')));
|
||||
|
||||
const { account_id } = notificationsSchema.parse(c.req.query());
|
||||
|
||||
const kinds = new Set<number>();
|
||||
|
||||
if (types.has('mention')) {
|
||||
kinds.add(1);
|
||||
}
|
||||
if (types.has('reblog')) {
|
||||
kinds.add(6);
|
||||
}
|
||||
if (types.has('favourite') || types.has('pleroma:emoji_reaction')) {
|
||||
kinds.add(7);
|
||||
}
|
||||
|
||||
const filter: NostrFilter = {
|
||||
kinds: [...kinds],
|
||||
'#p': [pubkey],
|
||||
...params,
|
||||
};
|
||||
|
||||
async function renderNotifications(c: AppContext, filters: NostrFilter[]) {
|
||||
const filters: NostrFilter[] = [filter];
|
||||
|
||||
if (account_id) {
|
||||
filter.authors = [account_id];
|
||||
}
|
||||
|
||||
if (types.has('ditto:name_grant') && !account_id) {
|
||||
filters.push({ kinds: [30360], authors: [Conf.pubkey], '#p': [pubkey], ...params });
|
||||
}
|
||||
|
||||
return renderNotifications(filters, types, params, c);
|
||||
};
|
||||
|
||||
async function renderNotifications(
|
||||
filters: NostrFilter[],
|
||||
types: Set<string>,
|
||||
params: PaginationParams,
|
||||
c: AppContext,
|
||||
) {
|
||||
const store = c.get('store');
|
||||
const pubkey = await c.get('signer')?.getPublicKey()!;
|
||||
const { signal } = c.req.raw;
|
||||
const opts = { signal, limit: params.limit };
|
||||
|
||||
const events = await store
|
||||
.query(filters, { signal })
|
||||
.query(filters, opts)
|
||||
.then((events) => events.filter((event) => event.pubkey !== pubkey))
|
||||
.then((events) => hydrateEvents({ events, store, signal }));
|
||||
|
||||
|
@ -26,9 +89,8 @@ async function renderNotifications(c: AppContext, filters: NostrFilter[]) {
|
|||
return c.json([]);
|
||||
}
|
||||
|
||||
const notifications = (await Promise
|
||||
.all(events.map((event) => renderNotification(event, { viewerPubkey: pubkey }))))
|
||||
.filter(Boolean);
|
||||
const notifications = (await Promise.all(events.map((event) => renderNotification(event, { viewerPubkey: pubkey }))))
|
||||
.filter((notification) => notification && types.has(notification.type));
|
||||
|
||||
if (!notifications.length) {
|
||||
return c.json([]);
|
||||
|
|
|
@ -6,7 +6,8 @@ import { Conf } from '@/config.ts';
|
|||
import { configSchema, elixirTupleSchema, type PleromaConfig } from '@/schemas/pleroma-api.ts';
|
||||
import { AdminSigner } from '@/signers/AdminSigner.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 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, { suggested: 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, { suggested: false }, c);
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
};
|
||||
|
||||
export {
|
||||
configController,
|
||||
frontendConfigController,
|
||||
pleromaAdminDeleteStatusController,
|
||||
pleromaAdminSuggestController,
|
||||
pleromaAdminTagController,
|
||||
pleromaAdminUnsuggestController,
|
||||
pleromaAdminUntagController,
|
||||
updateConfigController,
|
||||
};
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
import { NSchema as n } from '@nostrify/nostrify';
|
||||
import { NostrFilter, NSchema as n } from '@nostrify/nostrify';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type AppController } from '@/app.ts';
|
||||
import { Conf } from '@/config.ts';
|
||||
import { createAdminEvent, createEvent, parseBody } from '@/utils/api.ts';
|
||||
import { createEvent, paginated, paginationSchema, parseBody, updateEventInfo } from '@/utils/api.ts';
|
||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||
import { renderAdminReport } from '@/views/mastodon/reports.ts';
|
||||
import { renderReport } from '@/views/mastodon/reports.ts';
|
||||
import { booleanParamSchema } from '@/schema.ts';
|
||||
|
||||
const reportSchema = z.object({
|
||||
account_id: n.id(),
|
||||
|
@ -52,20 +53,55 @@ const reportController: AppController = async (c) => {
|
|||
return c.json(await renderReport(event));
|
||||
};
|
||||
|
||||
const adminReportsSchema = z.object({
|
||||
resolved: booleanParamSchema.optional(),
|
||||
account_id: n.id().optional(),
|
||||
target_account_id: n.id().optional(),
|
||||
});
|
||||
|
||||
/** https://docs.joinmastodon.org/methods/admin/reports/#get */
|
||||
const adminReportsController: AppController = async (c) => {
|
||||
const store = c.get('store');
|
||||
const viewerPubkey = await c.get('signer')?.getPublicKey();
|
||||
|
||||
const reports = await store.query([{ kinds: [1984], '#P': [Conf.pubkey] }])
|
||||
.then((events) => hydrateEvents({ store, events: events, signal: c.req.raw.signal }))
|
||||
.then((events) =>
|
||||
Promise.all(
|
||||
const params = paginationSchema.parse(c.req.query());
|
||||
const { resolved, account_id, target_account_id } = adminReportsSchema.parse(c.req.query());
|
||||
|
||||
const filter: NostrFilter = {
|
||||
kinds: [30383],
|
||||
authors: [Conf.pubkey],
|
||||
'#k': ['1984'],
|
||||
...params,
|
||||
};
|
||||
|
||||
if (typeof resolved === 'boolean') {
|
||||
filter['#n'] = [resolved ? 'closed' : 'open'];
|
||||
}
|
||||
if (account_id) {
|
||||
filter['#p'] = [account_id];
|
||||
}
|
||||
if (target_account_id) {
|
||||
filter['#P'] = [target_account_id];
|
||||
}
|
||||
|
||||
const orig = await store.query([filter]);
|
||||
const ids = new Set<string>();
|
||||
|
||||
for (const event of orig) {
|
||||
const d = event.tags.find(([name]) => name === 'd')?.[1];
|
||||
if (d) {
|
||||
ids.add(d);
|
||||
}
|
||||
}
|
||||
|
||||
const events = await store.query([{ kinds: [1984], ids: [...ids] }])
|
||||
.then((events) => hydrateEvents({ store, events: events, signal: c.req.raw.signal }));
|
||||
|
||||
const reports = await Promise.all(
|
||||
events.map((event) => renderAdminReport(event, { viewerPubkey })),
|
||||
)
|
||||
);
|
||||
|
||||
return c.json(reports);
|
||||
return paginated(c, orig, reports);
|
||||
};
|
||||
|
||||
/** https://docs.joinmastodon.org/methods/admin/reports/#get-one */
|
||||
|
@ -82,12 +118,13 @@ const adminReportController: AppController = async (c) => {
|
|||
}], { signal });
|
||||
|
||||
if (!event) {
|
||||
return c.json({ error: 'This action is not allowed' }, 403);
|
||||
return c.json({ error: 'Not found' }, 404);
|
||||
}
|
||||
|
||||
await hydrateEvents({ events: [event], store, signal });
|
||||
|
||||
return c.json(await renderAdminReport(event, { viewerPubkey: pubkey }));
|
||||
const report = await renderAdminReport(event, { viewerPubkey: pubkey });
|
||||
return c.json(report);
|
||||
};
|
||||
|
||||
/** https://docs.joinmastodon.org/methods/admin/reports/#resolve */
|
||||
|
@ -104,18 +141,43 @@ const adminReportResolveController: AppController = async (c) => {
|
|||
}], { signal });
|
||||
|
||||
if (!event) {
|
||||
return c.json({ error: 'This action is not allowed' }, 403);
|
||||
return c.json({ error: 'Not found' }, 404);
|
||||
}
|
||||
|
||||
await updateEventInfo(eventId, { open: false, closed: true }, c);
|
||||
await hydrateEvents({ events: [event], store, signal });
|
||||
|
||||
await createAdminEvent({
|
||||
kind: 5,
|
||||
tags: [['e', event.id]],
|
||||
content: 'Report closed.',
|
||||
}, c);
|
||||
|
||||
return c.json(await renderAdminReport(event, { viewerPubkey: pubkey, actionTaken: true }));
|
||||
const report = await renderAdminReport(event, { viewerPubkey: pubkey });
|
||||
return c.json(report);
|
||||
};
|
||||
|
||||
export { adminReportController, adminReportResolveController, adminReportsController, reportController };
|
||||
const adminReportReopenController: AppController = async (c) => {
|
||||
const eventId = c.req.param('id');
|
||||
const { signal } = c.req.raw;
|
||||
const store = c.get('store');
|
||||
const pubkey = await c.get('signer')?.getPublicKey();
|
||||
|
||||
const [event] = await store.query([{
|
||||
kinds: [1984],
|
||||
ids: [eventId],
|
||||
limit: 1,
|
||||
}], { signal });
|
||||
|
||||
if (!event) {
|
||||
return c.json({ error: 'Not found' }, 404);
|
||||
}
|
||||
|
||||
await updateEventInfo(eventId, { open: true, closed: false }, c);
|
||||
await hydrateEvents({ events: [event], store, signal });
|
||||
|
||||
const report = await renderAdminReport(event, { viewerPubkey: pubkey });
|
||||
return c.json(report);
|
||||
};
|
||||
|
||||
export {
|
||||
adminReportController,
|
||||
adminReportReopenController,
|
||||
adminReportResolveController,
|
||||
adminReportsController,
|
||||
reportController,
|
||||
};
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { NostrFilter } from '@nostrify/nostrify';
|
||||
import { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||
import Debug from '@soapbox/stickynotes/debug';
|
||||
import { z } from 'zod';
|
||||
|
||||
|
@ -11,6 +11,7 @@ import { hydrateEvents } from '@/storages/hydrate.ts';
|
|||
import { Storages } from '@/storages.ts';
|
||||
import { bech32ToPubkey } from '@/utils.ts';
|
||||
import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts';
|
||||
import { renderNotification } from '@/views/mastodon/notifications.ts';
|
||||
|
||||
const debug = Debug('ditto:streaming');
|
||||
|
||||
|
@ -52,6 +53,11 @@ const streamingController: AppController = async (c) => {
|
|||
|
||||
const { socket, response } = Deno.upgradeWebSocket(c.req.raw, { protocol: token, idleTimeout: 30 });
|
||||
|
||||
const store = await Storages.db();
|
||||
const pubsub = await Storages.pubsub();
|
||||
|
||||
const policy = pubkey ? new MuteListPolicy(pubkey, await Storages.admin()) : undefined;
|
||||
|
||||
function send(name: string, payload: object) {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
debug('send', name, JSON.stringify(payload));
|
||||
|
@ -63,52 +69,54 @@ const streamingController: AppController = async (c) => {
|
|||
}
|
||||
}
|
||||
|
||||
socket.onopen = async () => {
|
||||
if (!stream) return;
|
||||
|
||||
const filter = await topicToFilter(stream, c.req.query(), pubkey);
|
||||
if (!filter) return;
|
||||
|
||||
async function sub(type: string, filters: NostrFilter[], render: (event: NostrEvent) => Promise<unknown>) {
|
||||
try {
|
||||
const db = await Storages.db();
|
||||
const pubsub = await Storages.pubsub();
|
||||
|
||||
for await (const msg of pubsub.req([filter], { signal: controller.signal })) {
|
||||
for await (const msg of pubsub.req(filters, { signal: controller.signal })) {
|
||||
if (msg[0] === 'EVENT') {
|
||||
const event = msg[2];
|
||||
|
||||
if (pubkey) {
|
||||
const policy = new MuteListPolicy(pubkey, await Storages.admin());
|
||||
if (policy) {
|
||||
const [, , ok] = await policy.call(event);
|
||||
if (!ok) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
await hydrateEvents({
|
||||
events: [event],
|
||||
store: db,
|
||||
signal: AbortSignal.timeout(1000),
|
||||
});
|
||||
await hydrateEvents({ events: [event], store, signal: AbortSignal.timeout(1000) });
|
||||
|
||||
if (event.kind === 1) {
|
||||
const status = await renderStatus(event, { viewerPubkey: pubkey });
|
||||
if (status) {
|
||||
send('update', status);
|
||||
}
|
||||
}
|
||||
const result = await render(event);
|
||||
|
||||
if (event.kind === 6) {
|
||||
const status = await renderReblog(event, { viewerPubkey: pubkey });
|
||||
if (status) {
|
||||
send('update', status);
|
||||
}
|
||||
if (result) {
|
||||
send(type, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debug('streaming error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
socket.onopen = async () => {
|
||||
if (!stream) return;
|
||||
const topicFilter = await topicToFilter(stream, c.req.query(), pubkey);
|
||||
|
||||
if (topicFilter) {
|
||||
sub('update', [topicFilter], async (event) => {
|
||||
if (event.kind === 1) {
|
||||
return await renderStatus(event, { viewerPubkey: pubkey });
|
||||
}
|
||||
if (event.kind === 6) {
|
||||
return await renderReblog(event, { viewerPubkey: pubkey });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (['user', 'user:notification'].includes(stream) && pubkey) {
|
||||
sub('notification', [{ '#p': [pubkey] }], async (event) => {
|
||||
return await renderNotification(event, { viewerPubkey: pubkey });
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
|
|
|
@ -31,7 +31,7 @@ async function renderV2Suggestions(c: AppContext, params: PaginatedListParams, s
|
|||
const pubkey = await signer?.getPublicKey();
|
||||
|
||||
const filters: NostrFilter[] = [
|
||||
{ kinds: [3], authors: [Conf.pubkey], limit: 1 },
|
||||
{ kinds: [30382], authors: [Conf.pubkey], '#n': ['suggested'], limit },
|
||||
{ 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 [suggestedEvent, followsEvent, mutesEvent, trendingEvent] = [
|
||||
events.find((event) => matchFilter({ kinds: [3], authors: [Conf.pubkey] }, event)),
|
||||
const [userEvents, followsEvent, mutesEvent, trendingEvent] = [
|
||||
events.filter((event) => matchFilter({ kinds: [30382], authors: [Conf.pubkey], '#n': ['suggested'] }, event)),
|
||||
pubkey ? events.find((event) => matchFilter({ kinds: [3], authors: [pubkey] }, event)) : undefined,
|
||||
pubkey ? events.find((event) => matchFilter({ kinds: [10000], authors: [pubkey] }, event)) : undefined,
|
||||
events.find((event) =>
|
||||
|
@ -51,8 +51,13 @@ async function renderV2Suggestions(c: AppContext, params: PaginatedListParams, s
|
|||
),
|
||||
];
|
||||
|
||||
const [suggested, trending, follows, mutes] = [
|
||||
getTagSet(suggestedEvent?.tags ?? [], 'p'),
|
||||
const suggested = new Set(
|
||||
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(followsEvent?.tags ?? [], 'p'),
|
||||
getTagSet(mutesEvent?.tags ?? [], 'p'),
|
||||
|
|
|
@ -10,9 +10,12 @@ const nameSchema = z.string().min(1).regex(/^\w+$/);
|
|||
* https://github.com/nostr-protocol/nips/blob/master/05.md
|
||||
*/
|
||||
const nostrController: AppController = async (c) => {
|
||||
const store = c.get('store');
|
||||
|
||||
const result = nameSchema.safeParse(c.req.query('name'));
|
||||
const name = result.success ? result.data : undefined;
|
||||
const pointer = name ? await localNip05Lookup(c.get('store'), name) : undefined;
|
||||
|
||||
const pointer = name ? await localNip05Lookup(store, name) : undefined;
|
||||
|
||||
if (!name || !pointer) {
|
||||
return c.json({ names: {}, relays: {} });
|
||||
|
|
|
@ -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 };
|
|
@ -21,7 +21,6 @@ export interface DittoEvent extends NostrEvent {
|
|||
author_domain?: string;
|
||||
author_stats?: AuthorStats;
|
||||
event_stats?: EventStats;
|
||||
d_author?: DittoEvent;
|
||||
user?: DittoEvent;
|
||||
repost?: DittoEvent;
|
||||
quote?: DittoEvent;
|
||||
|
@ -35,4 +34,6 @@ export interface DittoEvent extends NostrEvent {
|
|||
* https://github.com/nostr-protocol/nips/blob/master/56.md
|
||||
*/
|
||||
reported_notes?: DittoEvent[];
|
||||
/** Admin event relationship. */
|
||||
info?: DittoEvent;
|
||||
}
|
||||
|
|
|
@ -2,8 +2,8 @@ import { NostrEvent } from '@nostrify/nostrify';
|
|||
import { HTTPException } from 'hono';
|
||||
|
||||
import { type AppContext, type AppMiddleware } from '@/app.ts';
|
||||
import { findUser, User } from '@/db/users.ts';
|
||||
import { ReadOnlySigner } from '@/signers/ReadOnlySigner.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { localRequest } from '@/utils/api.ts';
|
||||
import {
|
||||
buildAuthEventTemplate,
|
||||
|
@ -11,6 +11,7 @@ import {
|
|||
type ParseAuthRequestOpts,
|
||||
validateAuthEvent,
|
||||
} from '@/utils/nip98.ts';
|
||||
import { Conf } from '@/config.ts';
|
||||
|
||||
/**
|
||||
* NIP-98 auth.
|
||||
|
@ -35,7 +36,14 @@ type UserRole = 'user' | 'admin';
|
|||
/** Require the user to prove their role before invoking the controller. */
|
||||
function requireRole(role: UserRole, opts?: ParseAuthRequestOpts): AppMiddleware {
|
||||
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)) {
|
||||
await next();
|
||||
|
@ -53,15 +61,8 @@ function requireProof(opts?: ParseAuthRequestOpts): AppMiddleware {
|
|||
}
|
||||
|
||||
/** Check whether the user fulfills the role. */
|
||||
function matchesRole(user: User, role: UserRole): boolean {
|
||||
switch (role) {
|
||||
case 'user':
|
||||
return true;
|
||||
case 'admin':
|
||||
return user.admin;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
function matchesRole(user: NostrEvent, role: UserRole): boolean {
|
||||
return user.tags.some(([tag, value]) => tag === 'n' && value === role);
|
||||
}
|
||||
|
||||
/** HOC to obtain proof in middleware. */
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import { NKinds, NostrEvent, NSchema as n } from '@nostrify/nostrify';
|
||||
import { PipePolicy } from '@nostrify/nostrify/policies';
|
||||
import Debug from '@soapbox/stickynotes/debug';
|
||||
import { sql } from 'kysely';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
|
@ -8,8 +7,8 @@ import { Conf } from '@/config.ts';
|
|||
import { DittoDB } from '@/db/DittoDB.ts';
|
||||
import { deleteAttachedMedia } from '@/db/unattached-media.ts';
|
||||
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
|
||||
import { MuteListPolicy } from '@/policies/MuteListPolicy.ts';
|
||||
import { RelayError } from '@/RelayError.ts';
|
||||
import { AdminSigner } from '@/signers/AdminSigner.ts';
|
||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { eventAge, parseNip05, Time } from '@/utils.ts';
|
||||
|
@ -35,6 +34,7 @@ async function handleEvent(event: DittoEvent, signal: AbortSignal): Promise<void
|
|||
}
|
||||
if (!(await verifyEventWorker(event))) return;
|
||||
if (encounterEvent(event)) return;
|
||||
if (await existsInDB(event)) return;
|
||||
debug(`NostrEvent<${event.kind}> ${event.id}`);
|
||||
|
||||
if (event.kind !== 24133) {
|
||||
|
@ -43,9 +43,19 @@ async function handleEvent(event: DittoEvent, signal: AbortSignal): Promise<void
|
|||
|
||||
await hydrateEvent(event, signal);
|
||||
|
||||
const n = getTagSet(event.user?.tags ?? [], 'n');
|
||||
|
||||
if (n.has('disabled')) {
|
||||
throw new RelayError('blocked', 'user is disabled');
|
||||
}
|
||||
if (n.has('suspended')) {
|
||||
throw new RelayError('blocked', 'user is suspended');
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
storeEvent(event, signal),
|
||||
parseMetadata(event, signal),
|
||||
generateSetEvents(event),
|
||||
processMedia(event),
|
||||
streamOut(event),
|
||||
]);
|
||||
|
@ -54,13 +64,8 @@ async function handleEvent(event: DittoEvent, signal: AbortSignal): Promise<void
|
|||
async function policyFilter(event: NostrEvent): Promise<void> {
|
||||
const debug = Debug('ditto:policy');
|
||||
|
||||
const policy = new PipePolicy([
|
||||
new MuteListPolicy(Conf.pubkey, await Storages.admin()),
|
||||
policyWorker,
|
||||
]);
|
||||
|
||||
try {
|
||||
const result = await policy.call(event);
|
||||
const result = await policyWorker.call(event);
|
||||
debug(JSON.stringify(result));
|
||||
RelayError.assert(result);
|
||||
} catch (e) {
|
||||
|
@ -84,6 +89,13 @@ function encounterEvent(event: NostrEvent): boolean {
|
|||
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. */
|
||||
async function hydrateEvent(event: DittoEvent, signal: AbortSignal): Promise<void> {
|
||||
await hydrateEvents({ events: [event], store: await Storages.db(), signal });
|
||||
|
@ -167,4 +179,46 @@ async function streamOut(event: NostrEvent): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
async function generateSetEvents(event: NostrEvent): Promise<void> {
|
||||
const tagsAdmin = event.tags.some(([name, value]) => ['p', 'P'].includes(name) && value === Conf.pubkey);
|
||||
|
||||
if (event.kind === 1984 && tagsAdmin) {
|
||||
const signer = new AdminSigner();
|
||||
|
||||
const rel = await signer.signEvent({
|
||||
kind: 30383,
|
||||
content: '',
|
||||
tags: [
|
||||
['d', event.id],
|
||||
['p', event.pubkey],
|
||||
['k', '1984'],
|
||||
['n', 'open'],
|
||||
...[...getTagSet(event.tags, 'p')].map((pubkey) => ['P', pubkey]),
|
||||
...[...getTagSet(event.tags, 'e')].map((pubkey) => ['e', pubkey]),
|
||||
],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
|
||||
await handleEvent(rel, AbortSignal.timeout(1000));
|
||||
}
|
||||
|
||||
if (event.kind === 3036 && tagsAdmin) {
|
||||
const signer = new AdminSigner();
|
||||
|
||||
const rel = await signer.signEvent({
|
||||
kind: 30383,
|
||||
content: '',
|
||||
tags: [
|
||||
['d', event.id],
|
||||
['p', event.pubkey],
|
||||
['k', '3036'],
|
||||
['n', 'pending'],
|
||||
],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
|
||||
await handleEvent(rel, AbortSignal.timeout(1000));
|
||||
}
|
||||
}
|
||||
|
||||
export { handleEvent };
|
||||
|
|
|
@ -3,15 +3,15 @@ import { RelayPoolWorker } from 'nostr-relaypool';
|
|||
|
||||
import { Conf } from '@/config.ts';
|
||||
import { DittoDB } from '@/db/DittoDB.ts';
|
||||
import { AdminStore } from '@/storages/AdminStore.ts';
|
||||
import { EventsDB } from '@/storages/EventsDB.ts';
|
||||
import { PoolStore } from '@/storages/pool-store.ts';
|
||||
import { SearchStore } from '@/storages/search-store.ts';
|
||||
import { InternalRelay } from '@/storages/InternalRelay.ts';
|
||||
import { UserStore } from '@/storages/UserStore.ts';
|
||||
|
||||
export class Storages {
|
||||
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 _pubsub: Promise<InternalRelay> | undefined;
|
||||
private static _search: Promise<SearchStore> | undefined;
|
||||
|
@ -28,9 +28,9 @@ export class Storages {
|
|||
}
|
||||
|
||||
/** Admin user storage. */
|
||||
public static async admin(): Promise<UserStore> {
|
||||
public static async admin(): Promise<AdminStore> {
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
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 pubkeys = new Set(events.map((event) => event.pubkey));
|
||||
|
||||
const users = await this.store.query([{
|
||||
kinds: [30382],
|
||||
authors: [Conf.pubkey],
|
||||
'#d': [...pubkeys],
|
||||
limit: pubkeys.size,
|
||||
}]);
|
||||
|
||||
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('disabled') || n.has('suspended')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
|
@ -11,7 +11,6 @@ import { RelayError } from '@/RelayError.ts';
|
|||
import { purifyEvent } from '@/storages/hydrate.ts';
|
||||
import { isNostrId, isURL } from '@/utils.ts';
|
||||
import { abortError } from '@/utils/abort.ts';
|
||||
import { getTagSet } from '@/utils/tags.ts';
|
||||
|
||||
/** Function to decide whether or not to index a tag. */
|
||||
type TagCondition = ({ event, count, value }: {
|
||||
|
@ -27,11 +26,12 @@ class EventsDB implements NStore {
|
|||
|
||||
/** Conditions for when to index certain tags. */
|
||||
static tagConditions: Record<string, TagCondition> = {
|
||||
'a': ({ count }) => count < 15,
|
||||
'd': ({ event, count }) => count === 0 && NKinds.parameterizedReplaceable(event.kind),
|
||||
'e': ({ event, count, value }) => ((event.kind === 10003) || count < 15) && isNostrId(value),
|
||||
'k': ({ count, value }) => count === 0 && Number.isInteger(Number(value)),
|
||||
'L': ({ event, count }) => event.kind === 1985 || count === 0,
|
||||
'l': ({ event, count }) => event.kind === 1985 || count === 0,
|
||||
'media': ({ count, value }) => (count < 4) && isURL(value),
|
||||
'n': ({ count, value }) => count < 50 && value.length < 50,
|
||||
'P': ({ count, value }) => count === 0 && isNostrId(value),
|
||||
'p': ({ event, count, value }) => (count < 15 || event.kind === 3) && isNostrId(value),
|
||||
|
@ -39,8 +39,6 @@ class EventsDB implements NStore {
|
|||
'q': ({ event, count, value }) => count === 0 && event.kind === 1 && isNostrId(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,
|
||||
'name': ({ event, count }) => event.kind === 30361 && count === 0,
|
||||
'role': ({ event, count }) => event.kind === 30361 && count === 0,
|
||||
};
|
||||
|
||||
constructor(private kysely: Kysely<DittoTables>) {
|
||||
|
@ -77,17 +75,62 @@ class EventsDB implements NStore {
|
|||
|
||||
/** Check if an event has been deleted by the admin. */
|
||||
private async isDeletedAdmin(event: NostrEvent): Promise<boolean> {
|
||||
const [deletion] = await this.query([
|
||||
const filters: NostrFilter[] = [
|
||||
{ kinds: [5], authors: [Conf.pubkey], '#e': [event.id], limit: 1 },
|
||||
]);
|
||||
return !!deletion;
|
||||
];
|
||||
|
||||
if (NKinds.replaceable(event.kind) || NKinds.parameterizedReplaceable(event.kind)) {
|
||||
const d = event.tags.find(([tag]) => tag === 'd')?.[1] ?? '';
|
||||
|
||||
filters.push({
|
||||
kinds: [5],
|
||||
authors: [Conf.pubkey],
|
||||
'#a': [`${event.kind}:${event.pubkey}:${d}`],
|
||||
since: event.created_at,
|
||||
limit: 1,
|
||||
});
|
||||
}
|
||||
|
||||
const events = await this.query(filters);
|
||||
return events.length > 0;
|
||||
}
|
||||
|
||||
/** The DITTO_NSEC can delete any event from the database. NDatabase already handles user deletions. */
|
||||
private async deleteEventsAdmin(event: NostrEvent): Promise<void> {
|
||||
if (event.kind === 5 && event.pubkey === Conf.pubkey) {
|
||||
const ids = getTagSet(event.tags, 'e');
|
||||
await this.remove([{ ids: [...ids] }]);
|
||||
const ids = new Set(event.tags.filter(([name]) => name === 'e').map(([_name, value]) => value));
|
||||
const addrs = new Set(event.tags.filter(([name]) => name === 'a').map(([_name, value]) => value));
|
||||
|
||||
const filters: NostrFilter[] = [];
|
||||
|
||||
if (ids.size) {
|
||||
filters.push({ ids: [...ids] });
|
||||
}
|
||||
|
||||
for (const addr of addrs) {
|
||||
const [k, pubkey, d] = addr.split(':');
|
||||
const kind = Number(k);
|
||||
|
||||
if (!(Number.isInteger(kind) && kind >= 0)) continue;
|
||||
if (!isNostrId(pubkey)) continue;
|
||||
if (d === undefined) continue;
|
||||
|
||||
const filter: NostrFilter = {
|
||||
kinds: [kind],
|
||||
authors: [pubkey],
|
||||
until: event.created_at,
|
||||
};
|
||||
|
||||
if (d) {
|
||||
filter['#d'] = [d];
|
||||
}
|
||||
|
||||
filters.push(filter);
|
||||
}
|
||||
|
||||
if (filters.length) {
|
||||
await this.remove(filters);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -180,6 +223,8 @@ class EventsDB implements NStore {
|
|||
return event.content;
|
||||
case 30009:
|
||||
return EventsDB.buildTagsSearchContent(event.tags.filter(([t]) => t !== 'alt'));
|
||||
case 30360:
|
||||
return event.tags.find(([name]) => name === 'd')?.[1] || '';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
|
|
|
@ -44,6 +44,10 @@ async function hydrateEvents(opts: HydrateOpts): Promise<DittoEvent[]> {
|
|||
cache.push(event);
|
||||
}
|
||||
|
||||
for (const event of await gatherInfo({ events: cache, store, signal })) {
|
||||
cache.push(event);
|
||||
}
|
||||
|
||||
for (const event of await gatherReportedProfiles({ events: cache, store, signal })) {
|
||||
cache.push(event);
|
||||
}
|
||||
|
@ -82,7 +86,8 @@ export function assembleEvents(
|
|||
|
||||
for (const event of a) {
|
||||
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));
|
||||
event.info = b.find((e) => matchFilter({ kinds: [30383], authors: [admin], '#d': [event.id] }, e));
|
||||
|
||||
if (event.kind === 1) {
|
||||
const id = findQuoteTag(event.tags)?.[1] || findQuoteInContent(event.content);
|
||||
|
@ -106,21 +111,22 @@ export function assembleEvents(
|
|||
}
|
||||
|
||||
if (event.kind === 1984) {
|
||||
const targetAccountId = event.tags.find(([name]) => name === 'p')?.[1];
|
||||
if (targetAccountId) {
|
||||
event.reported_profile = b.find((e) => matchFilter({ kinds: [0], authors: [targetAccountId] }, e));
|
||||
const pubkey = event.tags.find(([name]) => name === 'p')?.[1];
|
||||
if (pubkey) {
|
||||
event.reported_profile = b.find((e) => matchFilter({ kinds: [0], authors: [pubkey] }, e));
|
||||
}
|
||||
const reportedEvents: DittoEvent[] = [];
|
||||
|
||||
const status_ids = event.tags.filter(([name]) => name === 'e').map((tag) => tag[1]);
|
||||
if (status_ids.length > 0) {
|
||||
for (const id of status_ids) {
|
||||
const reportedEvent = b.find((e) => matchFilter({ kinds: [1], ids: [id] }, e));
|
||||
if (reportedEvent) reportedEvents.push(reportedEvent);
|
||||
const reportedEvents: DittoEvent[] = [];
|
||||
const ids = event.tags.filter(([name]) => name === 'e').map(([_name, value]) => value);
|
||||
|
||||
for (const id of ids) {
|
||||
const reported = b.find((e) => matchFilter({ kinds: [1], ids: [id] }, e));
|
||||
if (reported) {
|
||||
reportedEvents.push(reported);
|
||||
}
|
||||
}
|
||||
event.reported_notes = reportedEvents;
|
||||
}
|
||||
}
|
||||
|
||||
event.author_stats = stats.authors.find((stats) => stats.pubkey === event.pubkey);
|
||||
event.event_stats = eventStats.find((stats) => stats.event_id === event.id);
|
||||
|
@ -200,8 +206,32 @@ function gatherAuthors({ events, store, signal }: HydrateOpts): Promise<DittoEve
|
|||
function gatherUsers({ events, store, signal }: HydrateOpts): Promise<DittoEvent[]> {
|
||||
const pubkeys = new Set(events.map((event) => event.pubkey));
|
||||
|
||||
if (!pubkeys.size) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
return store.query(
|
||||
[{ kinds: [30361], authors: [Conf.pubkey], '#d': [...pubkeys], limit: pubkeys.size }],
|
||||
[{ kinds: [30382], authors: [Conf.pubkey], '#d': [...pubkeys], limit: pubkeys.size }],
|
||||
{ signal },
|
||||
);
|
||||
}
|
||||
|
||||
/** Collect info events from the events. */
|
||||
function gatherInfo({ events, store, signal }: HydrateOpts): Promise<DittoEvent[]> {
|
||||
const ids = new Set<string>();
|
||||
|
||||
for (const event of events) {
|
||||
if (event.kind === 1984 || event.kind === 3036) {
|
||||
ids.add(event.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (!ids.size) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
return store.query(
|
||||
[{ kinds: [30383], authors: [Conf.pubkey], '#d': [...ids], limit: ids.size }],
|
||||
{ signal },
|
||||
);
|
||||
}
|
||||
|
|
|
@ -107,6 +107,44 @@ async function updateAdminEvent<E extends EventStub>(
|
|||
return createAdminEvent(fn(prev), c);
|
||||
}
|
||||
|
||||
function updateUser(pubkey: string, n: Record<string, boolean>, c: AppContext): Promise<NostrEvent> {
|
||||
return updateNames(30382, pubkey, n, c);
|
||||
}
|
||||
|
||||
function updateEventInfo(id: string, n: Record<string, boolean>, c: AppContext): Promise<NostrEvent> {
|
||||
return updateNames(30383, id, n, c);
|
||||
}
|
||||
|
||||
async function updateNames(k: number, d: string, n: Record<string, boolean>, c: AppContext): Promise<NostrEvent> {
|
||||
const signer = new AdminSigner();
|
||||
const admin = await signer.getPublicKey();
|
||||
|
||||
return updateAdminEvent(
|
||||
{ kinds: [k], authors: [admin], '#d': [d], 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: k,
|
||||
content: prev?.content ?? '',
|
||||
tags: [
|
||||
['d', d],
|
||||
...nTags,
|
||||
...other,
|
||||
],
|
||||
};
|
||||
},
|
||||
c,
|
||||
);
|
||||
}
|
||||
|
||||
/** Push the event through the pipeline, rethrowing any RelayError. */
|
||||
async function publishEvent(event: NostrEvent, c: AppContext): Promise<NostrEvent> {
|
||||
debug('EVENT', event);
|
||||
|
@ -264,7 +302,10 @@ export {
|
|||
type PaginationParams,
|
||||
paginationSchema,
|
||||
parseBody,
|
||||
updateAdminEvent,
|
||||
updateEvent,
|
||||
updateEventInfo,
|
||||
updateListAdminEvent,
|
||||
updateListEvent,
|
||||
updateUser,
|
||||
};
|
||||
|
|
|
@ -45,16 +45,15 @@ const nip05Cache = new SimpleLRU<string, nip19.ProfilePointer>(
|
|||
{ max: 500, ttl: Time.hours(1) },
|
||||
);
|
||||
|
||||
async function localNip05Lookup(store: NStore, name: string): Promise<nip19.ProfilePointer | undefined> {
|
||||
const [label] = await store.query([{
|
||||
kinds: [1985],
|
||||
async function localNip05Lookup(store: NStore, localpart: string): Promise<nip19.ProfilePointer | undefined> {
|
||||
const [grant] = await store.query([{
|
||||
kinds: [30360],
|
||||
'#d': [`${localpart}@${Conf.url.host}`],
|
||||
authors: [Conf.pubkey],
|
||||
'#L': ['nip05'],
|
||||
'#l': [`${name}@${Conf.url.host}`],
|
||||
limit: 1,
|
||||
}]);
|
||||
|
||||
const pubkey = label?.tags.find(([name]) => name === 'p')?.[1];
|
||||
const pubkey = grant?.tags.find(([name]) => name === 'p')?.[1];
|
||||
|
||||
if (pubkey) {
|
||||
return { pubkey, relays: [Conf.relay] };
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
|
||||
import { getTagSet } from '@/utils/tags.ts';
|
||||
import { renderAdminAccount, renderAdminAccountFromPubkey } from '@/views/mastodon/admin-accounts.ts';
|
||||
|
||||
/** Renders an Admin::Account entity from a name request event. */
|
||||
export async function renderNameRequest(event: DittoEvent) {
|
||||
const n = getTagSet(event.info?.tags ?? [], 'n');
|
||||
const [username, domain] = event.tags.find(([name]) => name === 'r')?.[1]?.split('@') ?? [];
|
||||
|
||||
const adminAccount = event.author
|
||||
? await renderAdminAccount(event.author)
|
||||
: await renderAdminAccountFromPubkey(event.pubkey);
|
||||
|
||||
return {
|
||||
...adminAccount,
|
||||
id: event.id,
|
||||
approved: n.has('approved'),
|
||||
username,
|
||||
domain,
|
||||
invite_request: event.content,
|
||||
};
|
||||
}
|
|
@ -6,6 +6,7 @@ import { Conf } from '@/config.ts';
|
|||
import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
|
||||
import { getLnurl } from '@/utils/lnurl.ts';
|
||||
import { nip05Cache } from '@/utils/nip05.ts';
|
||||
import { getTagSet } from '@/utils/tags.ts';
|
||||
import { Nip05, nostrDate, nostrNow, parseNip05 } from '@/utils.ts';
|
||||
import { renderEmojis } from '@/views/mastodon/emojis.ts';
|
||||
|
||||
|
@ -33,7 +34,7 @@ async function renderAccount(
|
|||
|
||||
const npub = nip19.npubEncode(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 {
|
||||
id: pubkey,
|
||||
|
@ -74,13 +75,14 @@ async function renderAccount(
|
|||
username: parsed05?.nickname || npub.substring(0, 8),
|
||||
ditto: {
|
||||
accepts_zaps: Boolean(getLnurl({ lud06, lud16 })),
|
||||
is_registered: Boolean(event.user),
|
||||
},
|
||||
pleroma: {
|
||||
is_admin: role === 'admin',
|
||||
is_moderator: ['admin', 'moderator'].includes(role),
|
||||
is_admin: names.has('admin'),
|
||||
is_moderator: names.has('admin') || names.has('moderator'),
|
||||
is_suggested: names.has('suggested'),
|
||||
is_local: parsed05?.domain === Conf.url.host,
|
||||
settings_store: undefined as unknown,
|
||||
tags: [...getTagSet(event.user?.tags ?? [], 't')],
|
||||
},
|
||||
nostr: {
|
||||
pubkey,
|
||||
|
|
|
@ -1,9 +1,20 @@
|
|||
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
|
||||
import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
|
||||
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
|
||||
import { getTagSet } from '@/utils/tags.ts';
|
||||
|
||||
/** Expects a kind 0 fully hydrated */
|
||||
async function renderAdminAccount(event: DittoEvent) {
|
||||
const account = await renderAccount(event);
|
||||
const names = getTagSet(event.user?.tags ?? [], 'n');
|
||||
|
||||
let role = 'user';
|
||||
|
||||
if (names.has('admin')) {
|
||||
role = 'admin';
|
||||
}
|
||||
if (names.has('moderator')) {
|
||||
role = 'moderator';
|
||||
}
|
||||
|
||||
return {
|
||||
id: account.id,
|
||||
|
@ -15,12 +26,13 @@ async function renderAdminAccount(event: DittoEvent) {
|
|||
ips: [],
|
||||
locale: '',
|
||||
invite_request: null,
|
||||
role: event.tags.find(([name]) => name === 'role')?.[1],
|
||||
role,
|
||||
confirmed: true,
|
||||
approved: true,
|
||||
disabled: false,
|
||||
silenced: false,
|
||||
suspended: false,
|
||||
disabled: names.has('disabled'),
|
||||
silenced: names.has('silenced'),
|
||||
suspended: names.has('suspended'),
|
||||
sensitized: names.has('sensitized'),
|
||||
account,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { Conf } from '@/config.ts';
|
||||
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
|
||||
import { nostrDate } from '@/utils.ts';
|
||||
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
|
||||
import { renderStatus } from '@/views/mastodon/statuses.ts';
|
||||
import { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
interface RenderNotificationOpts {
|
||||
viewerPubkey: string;
|
||||
|
@ -26,6 +28,10 @@ function renderNotification(event: DittoEvent, opts: RenderNotificationOpts) {
|
|||
if (event.kind === 7) {
|
||||
return renderReaction(event, opts);
|
||||
}
|
||||
|
||||
if (event.kind === 30360 && event.pubkey === Conf.pubkey) {
|
||||
return renderNameGrant(event);
|
||||
}
|
||||
}
|
||||
|
||||
async function renderMention(event: DittoEvent, opts: RenderNotificationOpts) {
|
||||
|
@ -45,7 +51,7 @@ async function renderReblog(event: DittoEvent, opts: RenderNotificationOpts) {
|
|||
if (event.repost?.kind !== 1) return;
|
||||
const status = await renderStatus(event.repost, opts);
|
||||
if (!status) return;
|
||||
const account = event.author ? await renderAccount(event.author) : accountFromPubkey(event.pubkey);
|
||||
const account = event.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey);
|
||||
|
||||
return {
|
||||
id: notificationId(event),
|
||||
|
@ -60,7 +66,7 @@ async function renderFavourite(event: DittoEvent, opts: RenderNotificationOpts)
|
|||
if (event.reacted?.kind !== 1) return;
|
||||
const status = await renderStatus(event.reacted, opts);
|
||||
if (!status) return;
|
||||
const account = event.author ? await renderAccount(event.author) : accountFromPubkey(event.pubkey);
|
||||
const account = event.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey);
|
||||
|
||||
return {
|
||||
id: notificationId(event),
|
||||
|
@ -75,7 +81,7 @@ async function renderReaction(event: DittoEvent, opts: RenderNotificationOpts) {
|
|||
if (event.reacted?.kind !== 1) return;
|
||||
const status = await renderStatus(event.reacted, opts);
|
||||
if (!status) return;
|
||||
const account = event.author ? await renderAccount(event.author) : accountFromPubkey(event.pubkey);
|
||||
const account = event.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey);
|
||||
|
||||
return {
|
||||
id: notificationId(event),
|
||||
|
@ -87,6 +93,21 @@ async function renderReaction(event: DittoEvent, opts: RenderNotificationOpts) {
|
|||
};
|
||||
}
|
||||
|
||||
async function renderNameGrant(event: DittoEvent) {
|
||||
const d = event.tags.find(([name]) => name === 'd')?.[1];
|
||||
const account = event.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey);
|
||||
|
||||
if (!d) return;
|
||||
|
||||
return {
|
||||
id: notificationId(event),
|
||||
type: 'ditto:name_grant',
|
||||
name: d,
|
||||
created_at: nostrDate(event.created_at).toISOString(),
|
||||
account,
|
||||
};
|
||||
}
|
||||
|
||||
/** This helps notifications be sorted in the correct order. */
|
||||
function notificationId({ id, created_at }: NostrEvent): string {
|
||||
return `${created_at}-${id}`;
|
||||
|
|
|
@ -3,6 +3,7 @@ import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
|
|||
import { nostrDate } from '@/utils.ts';
|
||||
import { renderAdminAccount, renderAdminAccountFromPubkey } from '@/views/mastodon/admin-accounts.ts';
|
||||
import { renderStatus } from '@/views/mastodon/statuses.ts';
|
||||
import { getTagSet } from '@/utils/tags.ts';
|
||||
|
||||
/** Expects a `reportEvent` of kind 1984 and a `profile` of kind 0 of the person being reported */
|
||||
async function renderReport(event: DittoEvent) {
|
||||
|
@ -30,43 +31,42 @@ async function renderReport(event: DittoEvent) {
|
|||
|
||||
interface RenderAdminReportOpts {
|
||||
viewerPubkey?: string;
|
||||
actionTaken?: boolean;
|
||||
}
|
||||
|
||||
/** Admin-level information about a filed report.
|
||||
* Expects an event of kind 1984 fully hydrated.
|
||||
* https://docs.joinmastodon.org/entities/Admin_Report */
|
||||
async function renderAdminReport(reportEvent: DittoEvent, opts: RenderAdminReportOpts) {
|
||||
const { viewerPubkey, actionTaken = false } = opts;
|
||||
async function renderAdminReport(event: DittoEvent, opts: RenderAdminReportOpts) {
|
||||
const { viewerPubkey } = opts;
|
||||
|
||||
// The category is present in both the 'e' and 'p' tag, however, it is possible to report a user without reporting a note, so it's better to get the category from the 'p' tag
|
||||
const category = reportEvent.tags.find(([name]) => name === 'p')?.[2];
|
||||
const category = event.tags.find(([name]) => name === 'p')?.[2];
|
||||
|
||||
const statuses = [];
|
||||
if (reportEvent.reported_notes) {
|
||||
for (const status of reportEvent.reported_notes) {
|
||||
if (event.reported_notes) {
|
||||
for (const status of event.reported_notes) {
|
||||
statuses.push(await renderStatus(status, { viewerPubkey }));
|
||||
}
|
||||
}
|
||||
|
||||
const reportedPubkey = reportEvent.tags.find(([name]) => name === 'p')?.[1];
|
||||
const reportedPubkey = event.tags.find(([name]) => name === 'p')?.[1];
|
||||
if (!reportedPubkey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const names = getTagSet(event.info?.tags ?? [], 'n');
|
||||
|
||||
return {
|
||||
id: reportEvent.id,
|
||||
action_taken: actionTaken,
|
||||
id: event.id,
|
||||
action_taken: names.has('closed'),
|
||||
action_taken_at: null,
|
||||
category,
|
||||
comment: reportEvent.content,
|
||||
comment: event.content,
|
||||
forwarded: false,
|
||||
created_at: nostrDate(reportEvent.created_at).toISOString(),
|
||||
account: reportEvent.author
|
||||
? await renderAdminAccount(reportEvent.author)
|
||||
: await renderAdminAccountFromPubkey(reportEvent.pubkey),
|
||||
target_account: reportEvent.reported_profile
|
||||
? await renderAdminAccount(reportEvent.reported_profile)
|
||||
created_at: nostrDate(event.created_at).toISOString(),
|
||||
account: event.author ? await renderAdminAccount(event.author) : await renderAdminAccountFromPubkey(event.pubkey),
|
||||
target_account: event.reported_profile
|
||||
? await renderAdminAccount(event.reported_profile)
|
||||
: await renderAdminAccountFromPubkey(reportedPubkey),
|
||||
assigned_account: null,
|
||||
action_taken_by_account: null,
|
||||
|
|
Loading…
Reference in New Issue