Kind 30361 -> 30382
This commit is contained in:
parent
dde020f22b
commit
7d54a5c7d0
|
@ -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: [
|
||||
['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}`],
|
||||
],
|
||||
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],
|
||||
];
|
||||
|
||||
if (role === 'admin') {
|
||||
tags.push(['n', 'admin']);
|
||||
}
|
||||
|
||||
tags.push(...prevTags);
|
||||
|
||||
const event = await signer.signEvent({
|
||||
kind: 30382,
|
||||
tags,
|
||||
content: '',
|
||||
created_at: nostrNow(),
|
||||
});
|
||||
|
|
|
@ -44,7 +44,7 @@ const adminAccountsController: AppController = async (c) => {
|
|||
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 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 authors = await store.query([{ kinds: [0], authors: pubkeys }], { signal });
|
||||
|
||||
|
|
|
@ -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 };
|
|
@ -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. */
|
||||
|
|
|
@ -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>) {
|
||||
|
|
|
@ -82,7 +82,7 @@ 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));
|
||||
|
||||
if (event.kind === 1) {
|
||||
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));
|
||||
|
||||
return store.query(
|
||||
[{ kinds: [30361], authors: [Conf.pubkey], '#d': [...pubkeys], limit: pubkeys.size }],
|
||||
[{ kinds: [30382], authors: [Conf.pubkey], '#d': [...pubkeys], limit: pubkeys.size }],
|
||||
{ signal },
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 roles = getTagSet(event.tags, 'n');
|
||||
|
||||
return {
|
||||
id: pubkey,
|
||||
|
@ -74,11 +75,10 @@ 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: roles.has('admin'),
|
||||
is_moderator: roles.has('admin') || roles.has('moderator'),
|
||||
is_local: parsed05?.domain === Conf.url.host,
|
||||
settings_store: undefined as unknown,
|
||||
},
|
||||
|
|
Loading…
Reference in New Issue