Merge branch 'main' into postgres-support-testing

Update local branch to latest. This branch has the NIP-05 but it will be overriden
This commit is contained in:
P. Reis 2024-07-26 18:32:30 -03:00
commit 6551f0c677
19 changed files with 476 additions and 159 deletions

View file

@ -27,7 +27,7 @@
"@hono/hono": "jsr:@hono/hono@^4.4.6",
"@isaacs/ttlcache": "npm:@isaacs/ttlcache@^1.4.1",
"@noble/secp256k1": "npm:@noble/secp256k1@^2.0.0",
"@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.25.0",
"@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.26.3",
"@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",
@ -51,7 +51,7 @@
"iso-639-1": "npm:iso-639-1@2.1.15",
"isomorphic-dompurify": "npm:isomorphic-dompurify@^2.11.0",
"kysely": "npm:kysely@^0.27.3",
"postgres": "https://raw.githubusercontent.com/xyzshantaram/postgres.js/17469a9e5f025d112206c583a29275e93dfc1431/deno/mod.js",
"postgres": "https://raw.githubusercontent.com/xyzshantaram/postgres.js/8a9bbce88b3f6425ecaacd99a80372338b157a53/deno/mod.js",
"kysely-postgres-js": "npm:kysely-postgres-js@2.0.0",
"light-bolt11-decoder": "npm:light-bolt11-decoder",
"linkify-plugin-hashtag": "npm:linkify-plugin-hashtag@^4.1.1",

42
deno.lock generated
View file

@ -8,11 +8,11 @@
"jsr:@gleasonator/policy": "jsr:@gleasonator/policy@0.2.0",
"jsr:@gleasonator/policy@0.2.0": "jsr:@gleasonator/policy@0.2.0",
"jsr:@gleasonator/policy@0.4.0": "jsr:@gleasonator/policy@0.4.0",
"jsr:@hono/hono@^4.4.6": "jsr:@hono/hono@4.4.6",
"jsr:@hono/hono@^4.4.6": "jsr:@hono/hono@4.5.0",
"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.25.0": "jsr:@nostrify/nostrify@0.25.0",
"jsr:@nostrify/nostrify@^0.26.3": "jsr:@nostrify/nostrify@0.26.3",
"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",
@ -21,8 +21,9 @@
"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/bytes@^1.0.1-rc.3": "jsr:@std/bytes@1.0.2",
"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/dotenv@^0.224.0": "jsr:@std/dotenv@0.224.2",
"jsr:@std/encoding@^0.221.0": "jsr:@std/encoding@0.221.0",
"jsr:@std/encoding@^0.224.0": "jsr:@std/encoding@0.224.3",
"jsr:@std/encoding@^0.224.1": "jsr:@std/encoding@0.224.3",
@ -30,7 +31,7 @@
"jsr:@std/fs@^0.221.0": "jsr:@std/fs@0.221.0",
"jsr:@std/fs@^0.229.3": "jsr:@std/fs@0.229.3",
"jsr:@std/internal@^1.0.0": "jsr:@std/internal@1.0.0",
"jsr:@std/io@^0.224": "jsr:@std/io@0.224.1",
"jsr:@std/io@^0.224": "jsr:@std/io@0.224.3",
"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",
@ -109,6 +110,9 @@
"@hono/hono@4.4.6": {
"integrity": "aa557ca9930787ee86b9ca1730691f1ce1c379174c2cb244d5934db2b6314453"
},
"@hono/hono@4.5.0": {
"integrity": "4a410f7773ac4b5b0eb4520b26c7ab7795a271d57a9df7fa1953ded6b90ccaf7"
},
"@nostrify/nostrify@0.22.4": {
"integrity": "1c8a7847e5773213044b491e85fd7cafae2ad194ce59da4d957d2b27c776b42d",
"dependencies": [
@ -138,8 +142,8 @@
"npm:zod@^3.23.8"
]
},
"@nostrify/nostrify@0.25.0": {
"integrity": "98f26f44e95ac87fc91b3f3809d38432e1a7f6aebf10380b2554b6f9526313c6",
"@nostrify/nostrify@0.26.3": {
"integrity": "3e13e30f4fa3f76dcbcf9178630a9b2871186eb1d226d66234c0cdfd4841f548",
"dependencies": [
"jsr:@std/crypto@^0.224.0",
"jsr:@std/encoding@^0.224.1",
@ -183,6 +187,9 @@
"@std/bytes@1.0.0": {
"integrity": "9392e72af80adccaa1197912fa19990ed091cb98d5c9c4344b0c301b22d7c632"
},
"@std/bytes@1.0.2": {
"integrity": "fbdee322bbd8c599a6af186a1603b3355e59a5fb1baa139f8f4c3c9a1b3e3d57"
},
"@std/crypto@0.224.0": {
"integrity": "154ef3ff08ef535562ef1a718718c5b2c5fc3808f0f9100daad69e829bfcdf2d",
"dependencies": [
@ -193,6 +200,9 @@
"@std/dotenv@0.224.0": {
"integrity": "d9234cdf551507dcda60abb6c474289843741d8c07ee8ce540c60f5c1b220a1d"
},
"@std/dotenv@0.224.2": {
"integrity": "29081695357e4534696c9e986b2560be29c141ccf52daa32b6c20ff5b5c64ab9"
},
"@std/encoding@0.221.0": {
"integrity": "d1dd76ef0dc5d14088411e6dc1dede53bf8308c95d1537df1214c97137208e45"
},
@ -227,6 +237,12 @@
"jsr:@std/bytes@^1.0.0-rc.3"
]
},
"@std/io@0.224.3": {
"integrity": "b402edeb99c6b3778d9ae3e9927bc9085b170b41e5a09bbb7064ab2ee394ae2f",
"dependencies": [
"jsr:@std/bytes@^1.0.1-rc.3"
]
},
"@std/media-types@0.224.1": {
"integrity": "9e69a5daed37c5b5c6d3ce4731dc191f80e67f79bed392b0957d1d03b87f11e1"
},
@ -1713,6 +1729,18 @@
"https://raw.githubusercontent.com/xyzshantaram/postgres.js/17469a9e5f025d112206c583a29275e93dfc1431/deno/src/result.js": "001ff5e0c8d634674f483d07fbcd620a797e3101f842d6c20ca3ace936260465",
"https://raw.githubusercontent.com/xyzshantaram/postgres.js/17469a9e5f025d112206c583a29275e93dfc1431/deno/src/subscribe.js": "9e4d0c3e573a6048e77ee2f15abbd5bcd17da9ca85a78c914553472c6d6c169b",
"https://raw.githubusercontent.com/xyzshantaram/postgres.js/17469a9e5f025d112206c583a29275e93dfc1431/deno/src/types.js": "471f4a6c35412aa202a7c177c0a7e5a7c3bd225f01bbde67c947894c1b8bf6ed",
"https://raw.githubusercontent.com/xyzshantaram/postgres.js/8a9bbce88b3f6425ecaacd99a80372338b157a53/deno/mod.js": "cb68f17d6d90df318934deccdb469d740be0888e7a597a9e7eea7100ce36a252",
"https://raw.githubusercontent.com/xyzshantaram/postgres.js/8a9bbce88b3f6425ecaacd99a80372338b157a53/deno/polyfills.js": "318eb01f2b4cc33a46c59f3ddc11f22a56d6b1db8b7719b2ad7decee63a5bd47",
"https://raw.githubusercontent.com/xyzshantaram/postgres.js/8a9bbce88b3f6425ecaacd99a80372338b157a53/deno/src/bytes.js": "f2de43bdc8fa5dc4b169f2c70d5d8b053a3dea8f85ef011d7b27dec69e14ebb7",
"https://raw.githubusercontent.com/xyzshantaram/postgres.js/8a9bbce88b3f6425ecaacd99a80372338b157a53/deno/src/connection.js": "c63d53a0f35a7eb2670befef551f23fe914bbe9f0590de974e3e210c50527a29",
"https://raw.githubusercontent.com/xyzshantaram/postgres.js/8a9bbce88b3f6425ecaacd99a80372338b157a53/deno/src/errors.js": "85cfbed9a5ab0db41ab8e97b806c881af29807dfe99bc656fdf1a18c1c13b6c6",
"https://raw.githubusercontent.com/xyzshantaram/postgres.js/8a9bbce88b3f6425ecaacd99a80372338b157a53/deno/src/index.js": "4e8b09c7d0ce6e9eea386f59337867266498d5bb60ccd567d0bea5da03f6094d",
"https://raw.githubusercontent.com/xyzshantaram/postgres.js/8a9bbce88b3f6425ecaacd99a80372338b157a53/deno/src/large.js": "f3e770cdb7cc695f7b50687b4c6c4b7252129515486ec8def98b7582ee7c54ef",
"https://raw.githubusercontent.com/xyzshantaram/postgres.js/8a9bbce88b3f6425ecaacd99a80372338b157a53/deno/src/query.js": "67c45a5151032aa46b587abc15381fe4efd97c696e5c1b53082b8161309c4ee2",
"https://raw.githubusercontent.com/xyzshantaram/postgres.js/8a9bbce88b3f6425ecaacd99a80372338b157a53/deno/src/queue.js": "709624843223ea842bf095f6934080f19f1a059a51cbbf82e9827f3bb1bf2ca7",
"https://raw.githubusercontent.com/xyzshantaram/postgres.js/8a9bbce88b3f6425ecaacd99a80372338b157a53/deno/src/result.js": "001ff5e0c8d634674f483d07fbcd620a797e3101f842d6c20ca3ace936260465",
"https://raw.githubusercontent.com/xyzshantaram/postgres.js/8a9bbce88b3f6425ecaacd99a80372338b157a53/deno/src/subscribe.js": "9e4d0c3e573a6048e77ee2f15abbd5bcd17da9ca85a78c914553472c6d6c169b",
"https://raw.githubusercontent.com/xyzshantaram/postgres.js/8a9bbce88b3f6425ecaacd99a80372338b157a53/deno/src/types.js": "471f4a6c35412aa202a7c177c0a7e5a7c3bd225f01bbde67c947894c1b8bf6ed",
"https://raw.githubusercontent.com/xyzshantaram/postgres.js/master/deno/mod.js": "cb68f17d6d90df318934deccdb469d740be0888e7a597a9e7eea7100ce36a252",
"https://raw.githubusercontent.com/xyzshantaram/postgres.js/master/deno/polyfills.js": "318eb01f2b4cc33a46c59f3ddc11f22a56d6b1db8b7719b2ad7decee63a5bd47",
"https://raw.githubusercontent.com/xyzshantaram/postgres.js/master/deno/src/bytes.js": "f2de43bdc8fa5dc4b169f2c70d5d8b053a3dea8f85ef011d7b27dec69e14ebb7",
@ -1732,7 +1760,7 @@
"jsr:@bradenmacdonald/s3-lite-client@^0.7.4",
"jsr:@db/sqlite@^0.11.1",
"jsr:@hono/hono@^4.4.6",
"jsr:@nostrify/nostrify@^0.25.0",
"jsr:@nostrify/nostrify@^0.26.3",
"jsr:@soapbox/kysely-deno-sqlite@^2.1.0",
"jsr:@soapbox/stickynotes@^0.4.0",
"jsr:@std/assert@^0.225.1",

View file

@ -0,0 +1,34 @@
{
"kind": 10002,
"id": "68fc04e23b07219f153a10947663b9dd7b271acbc03b82200e364e35de3e0bdd",
"pubkey": "0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd",
"created_at": 1714969354,
"tags": [
[
"r",
"wss://gleasonator.dev/relay"
],
[
"r",
"wss://nosdrive.app/relay"
],
[
"r",
"wss://relay.mostr.pub/"
],
[
"r",
"wss://relay.primal.net/"
],
[
"r",
"wss://relay.snort.social/"
],
[
"r",
"wss://relay.damus.io/"
]
],
"content": "",
"sig": "cb7b1a75fe015d5c9481651379365bd5d098665b1bc7a453522177e2686eaa83581ec36f7a17429aad2541dad02c2c81023b81612f87f28fc57447fef1efab13"
}

23
src/RelayError.test.ts Normal file
View file

@ -0,0 +1,23 @@
import { assertThrows } from '@std/assert';
import { RelayError } from '@/RelayError.ts';
Deno.test('Construct a RelayError from the reason message', () => {
assertThrows(
() => {
throw RelayError.fromReason('duplicate: already exists');
},
RelayError,
'duplicate: already exists',
);
});
Deno.test('Throw a new RelayError if the OK message is false', () => {
assertThrows(
() => {
RelayError.assert(['OK', 'yolo', false, 'error: bla bla bla']);
},
RelayError,
'error: bla bla bla',
);
});

View file

@ -5,9 +5,6 @@ import { logger } from '@hono/hono/logger';
import { NostrEvent, NostrSigner, NStore, NUploader } from '@nostrify/nostrify';
import Debug from '@soapbox/stickynotes/debug';
import { Conf } from '@/config.ts';
import { cron } from '@/cron.ts';
import { startFirehose } from '@/firehose.ts';
import { Time } from '@/utils/time.ts';
import {
@ -42,8 +39,10 @@ import { bookmarksController } from '@/controllers/api/bookmarks.ts';
import {
adminRelaysController,
adminSetRelaysController,
deleteZapSplitsController,
nameRequestController,
nameRequestsController,
updateZapSplitsController,
} from '@/controllers/api/ditto.ts';
import { emptyArrayController, emptyObjectController, notImplementedController } from '@/controllers/api/fallback.ts';
import {
@ -111,6 +110,7 @@ import {
import { errorHandler } from '@/controllers/error.ts';
import { metricsController } from '@/controllers/metrics.ts';
import { indexController } from '@/controllers/site.ts';
import '@/startup.ts';
import { nodeInfoController, nodeInfoSchemaController } from '@/controllers/well-known/nodeinfo.ts';
import { nostrController } from '@/controllers/well-known/nostr.ts';
import { auth98Middleware, requireProof, requireRole } from '@/middleware/auth98Middleware.ts';
@ -143,13 +143,6 @@ const app = new Hono<AppEnv>({ strict: false });
const debug = Debug('ditto:http');
if (Conf.firehoseEnabled) {
startFirehose();
}
if (Conf.cronEnabled) {
cron();
}
app.use('*', rateLimitMiddleware(300, Time.minutes(5)));
app.use('/api/*', metricsMiddleware, logger(debug));
@ -270,6 +263,9 @@ app.put('/api/v1/admin/ditto/relays', requireRole('admin'), adminSetRelaysContro
app.post('/api/v1/ditto/names', requireSigner, nameRequestController);
app.get('/api/v1/ditto/names', requireSigner, nameRequestsController);
app.put('/api/v1/admin/ditto/zap_splits', requireRole('admin'), updateZapSplitsController);
app.delete('/api/v1/admin/ditto/zap_splits', requireRole('admin'), deleteZapSplitsController);
app.post('/api/v1/ditto/zap', requireSigner, zapController);
app.get('/api/v1/ditto/statuses/:id{[0-9a-f]{64}}/zapped_by', zappedByController);

View file

@ -1,14 +1,18 @@
import { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify';
import { z } from 'zod';
import { AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { booleanParamSchema } from '@/schema.ts';
import { AdminSigner } from '@/signers/AdminSigner.ts';
import { booleanParamSchema } from '@/schema.ts';
import { Conf } from '@/config.ts';
import { Storages } from '@/storages.ts';
import { hydrateEvents } from '@/storages/hydrate.ts';
import { createEvent, paginated, paginationSchema } from '@/utils/api.ts';
import { createEvent, paginated, paginationSchema, parseBody } from '@/utils/api.ts';
import { renderNameRequest } from '@/views/ditto.ts';
import { getZapSplits } from '@/utils/zap-split.ts';
import { updateListAdminEvent } from '@/utils/api.ts';
import { addTag } from '@/utils/tags.ts';
import { deleteTag } from '@/utils/tags.ts';
const markerSchema = z.enum(['read', 'write']);
@ -148,3 +152,74 @@ export const nameRequestsController: AppController = async (c) => {
return paginated(c, orig, nameRequests);
};
const zapSplitSchema = z.record(
n.id(),
z.object({
amount: z.number().int().min(1).max(100),
message: z.string().max(500),
}),
);
export const updateZapSplitsController: AppController = async (c) => {
const body = await parseBody(c.req.raw);
const result = zapSplitSchema.safeParse(body);
const store = c.get('store');
if (!result.success) {
return c.json({ error: result.error }, 400);
}
const zap_split = await getZapSplits(store, Conf.pubkey);
if (!zap_split) {
return c.json({ error: 'Zap split not activated, restart the server.' }, 404);
}
const { data } = result;
const pubkeys = Object.keys(data);
if (pubkeys.length < 1) {
return c.json(200);
}
await updateListAdminEvent(
{ kinds: [30078], authors: [Conf.pubkey], '#d': ['pub.ditto.zapSplits'], limit: 1 },
(tags) =>
pubkeys.reduce((accumulator, pubkey) => {
return addTag(accumulator, ['p', pubkey, data[pubkey].amount.toString(), data[pubkey].message]);
}, tags),
c,
);
return c.json(200);
};
const deleteZapSplitSchema = z.array(n.id()).min(1);
export const deleteZapSplitsController: AppController = async (c) => {
const body = await parseBody(c.req.raw);
const result = deleteZapSplitSchema.safeParse(body);
const store = c.get('store');
if (!result.success) {
return c.json({ error: result.error }, 400);
}
const zap_split = await getZapSplits(store, Conf.pubkey);
if (!zap_split) {
return c.json({ error: 'Zap split not activated, restart the server.' }, 404);
}
const { data } = result;
await updateListAdminEvent(
{ kinds: [30078], authors: [Conf.pubkey], '#d': ['pub.ditto.zapSplits'], limit: 1 },
(tags) =>
data.reduce((accumulator, currentValue) => {
return deleteTag(accumulator, ['p', currentValue]);
}, tags),
c,
);
return c.json(200);
};

View file

@ -4,12 +4,16 @@ import { AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { Storages } from '@/storages.ts';
import { getInstanceMetadata } from '@/utils/instance.ts';
import { DittoZapSplits, getZapSplits } from '@/utils/zap-split.ts';
const version = `3.0.0 (compatible; Ditto ${denoJson.version})`;
const instanceV1Controller: AppController = async (c) => {
const { host, protocol } = Conf.url;
const meta = await getInstanceMetadata(await Storages.db(), c.req.raw.signal);
const store = c.get('store');
const zap_split: DittoZapSplits | undefined = await getZapSplits(store, Conf.pubkey) ?? {};
/** Protocol to use for WebSocket URLs, depending on the protocol of the `LOCAL_DOMAIN`. */
const wsProtocol = protocol === 'http:' ? 'ws:' : 'wss:';
@ -68,6 +72,9 @@ const instanceV1Controller: AppController = async (c) => {
},
},
rules: [],
ditto: {
zap_split,
},
});
};

View file

@ -6,10 +6,15 @@ import { nip19 } from 'nostr-tools';
import { z } from 'zod';
import { type AppController } from '@/app.ts';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
import { addTag, deleteTag } from '@/utils/tags.ts';
import { asyncReplaceAll } from '@/utils/text.ts';
import { Conf } from '@/config.ts';
import { DittoDB } from '@/db/DittoDB.ts';
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { getAncestors, getAuthor, getDescendants, getEvent } from '@/queries.ts';
import { getUnattachedMediaByIds } from '@/db/unattached-media.ts';
import { lookupPubkey } from '@/utils/lookup.ts';
import { renderEventAccounts } from '@/views.ts';
import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts';
import { Storages } from '@/storages.ts';
@ -24,11 +29,7 @@ import {
updateListEvent,
} from '@/utils/api.ts';
import { getInvoice, getLnurl } from '@/utils/lnurl.ts';
import { lookupPubkey } from '@/utils/lookup.ts';
import { addTag, deleteTag } from '@/utils/tags.ts';
import { asyncReplaceAll } from '@/utils/text.ts';
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
import { getZapSplits } from '@/utils/zap-split.ts';
const createStatusSchema = z.object({
in_reply_to_id: n.id().nullish(),
@ -71,6 +72,7 @@ const createStatusController: AppController = async (c) => {
const body = await parseBody(c.req.raw);
const result = createStatusSchema.safeParse(body);
const kysely = await DittoDB.getInstance();
const store = c.get('store');
if (!result.success) {
return c.json({ error: 'Bad request', schema: result.error }, 400);
@ -173,14 +175,28 @@ const createStatusController: AppController = async (c) => {
const quoteCompat = data.quote_id ? `\n\nnostr:${nip19.noteEncode(data.quote_id)}` : '';
const mediaCompat = mediaUrls.length ? `\n\n${mediaUrls.join('\n')}` : '';
const author = await getAuthor(await c.get('signer')?.getPublicKey()!);
const meta = n.json().pipe(n.metadata()).catch({}).parse(author?.content);
const lnurl = getLnurl(meta);
const zap_split = await getZapSplits(store, Conf.pubkey);
if (lnurl && zap_split) {
let totalSplit = 0;
for (const pubkey in zap_split) {
totalSplit += zap_split[pubkey].amount;
tags.push(['zap', pubkey, Conf.relay, zap_split[pubkey].amount.toString()]);
}
if (totalSplit) {
tags.push(['zap', author?.pubkey as string, Conf.relay, Math.max(0, 100 - totalSplit).toString()]);
}
}
const event = await createEvent({
kind: 1,
content: content + quoteCompat + mediaCompat,
tags,
}, c);
const author = await getAuthor(event.pubkey);
if (data.quote_id) {
await hydrateEvents({
events: [event],
@ -189,7 +205,7 @@ const createStatusController: AppController = async (c) => {
});
}
return c.json(await renderStatus({ ...event, author }, { viewerPubkey: await c.get('signer')?.getPublicKey() }));
return c.json(await renderStatus({ ...event, author }, { viewerPubkey: author?.pubkey }));
};
const deleteStatusController: AppController = async (c) => {

View file

@ -0,0 +1,39 @@
import { Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createIndex('nostr_events_created_at_kind')
.on('nostr_events')
.ifNotExists()
.columns(['created_at desc', 'id asc', 'kind'])
.execute();
await db.schema
.createIndex('nostr_events_kind_pubkey_created_at')
.on('nostr_events')
.ifNotExists()
.columns(['kind', 'pubkey', 'created_at desc', 'id asc'])
.execute();
await db.schema.dropIndex('idx_events_created_at_kind').execute();
await db.schema.dropIndex('idx_events_kind_pubkey_created_at').execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropIndex('nostr_events_created_at_kind').execute();
await db.schema.dropIndex('nostr_events_kind_pubkey_created_at').execute();
await db.schema
.createIndex('idx_events_created_at_kind')
.on('nostr_events')
.ifNotExists()
.columns(['created_at desc', 'kind'])
.execute();
await db.schema
.createIndex('idx_events_kind_pubkey_created_at')
.on('nostr_events')
.ifNotExists()
.columns(['kind', 'pubkey', 'created_at desc'])
.execute();
}

22
src/schema.test.ts Normal file
View file

@ -0,0 +1,22 @@
import { assertEquals } from '@std/assert';
import { percentageSchema } from '@/schema.ts';
Deno.test('Value is any percentage from 1 to 100', () => {
assertEquals(percentageSchema.safeParse('latvia' as unknown).success, false);
assertEquals(percentageSchema.safeParse(1.5).success, false);
assertEquals(percentageSchema.safeParse(Infinity).success, false);
assertEquals(percentageSchema.safeParse('Infinity').success, false);
assertEquals(percentageSchema.safeParse('0').success, false);
assertEquals(percentageSchema.safeParse(0).success, false);
assertEquals(percentageSchema.safeParse(-1).success, false);
assertEquals(percentageSchema.safeParse('-10').success, false);
assertEquals(percentageSchema.safeParse([]).success, false);
assertEquals(percentageSchema.safeParse(undefined).success, false);
for (let i = 1; i < 100; i++) {
assertEquals(percentageSchema.safeParse(String(i)).success, true);
}
assertEquals(percentageSchema.safeParse('1e1').success, true);
});

View file

@ -38,4 +38,14 @@ const booleanParamSchema = z.enum(['true', 'false']).transform((value) => value
/** Schema for `File` objects. */
const fileSchema = z.custom<File>((value) => value instanceof File);
export { booleanParamSchema, decode64Schema, fileSchema, filteredArray, hashtagSchema, safeUrlSchema };
const percentageSchema = z.coerce.number().int().gte(1).lte(100);
export {
booleanParamSchema,
decode64Schema,
fileSchema,
filteredArray,
hashtagSchema,
percentageSchema,
safeUrlSchema,
};

16
src/startup.ts Normal file
View file

@ -0,0 +1,16 @@
// Starts up applications required to run before the HTTP server is on.
import { Conf } from '@/config.ts';
import { seedZapSplits } from '@/utils/zap-split.ts';
import { cron } from '@/cron.ts';
import { startFirehose } from '@/firehose.ts';
if (Conf.firehoseEnabled) {
startFirehose();
}
if (Conf.cronEnabled) {
cron();
}
await seedZapSplits();

View file

@ -1,18 +1,17 @@
// deno-lint-ignore-file require-await
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 { NPool, NRelay1 } from '@nostrify/nostrify';
import { getRelays } from '@/utils/outbox.ts';
export class Storages {
private static _db: Promise<EventsDB> | undefined;
private static _admin: Promise<AdminStore> | undefined;
private static _client: Promise<PoolStore> | undefined;
private static _client: Promise<NPool> | undefined;
private static _pubsub: Promise<InternalRelay> | undefined;
private static _search: Promise<SearchStore> | undefined;
@ -44,7 +43,7 @@ export class Storages {
}
/** Relay pool storage. */
public static async client(): Promise<PoolStore> {
public static async client(): Promise<NPool> {
if (!this._client) {
this._client = (async () => {
const db = await this.db();
@ -56,7 +55,7 @@ export class Storages {
const tags = relayList?.tags ?? [];
const activeRelays = tags.reduce((acc, [name, url, marker]) => {
if (name === 'r' && !marker) {
if (name === 'r' && (!marker || marker === 'write')) {
acc.push(url);
}
return acc;
@ -64,22 +63,25 @@ export class Storages {
console.log(`pool: connecting to ${activeRelays.length} relays.`);
const worker = new Worker('https://unpkg.com/nostr-relaypool2@0.6.34/lib/nostr-relaypool.worker.js', {
type: 'module',
return new NPool({
open(url) {
return new NRelay1(url, {
// Skip event verification (it's done in the pipeline).
verifyEvent: () => true,
});
},
reqRouter: async (filters) => {
return new Map(activeRelays.map((relay) => {
return [relay, filters];
}));
},
eventRouter: async (event) => {
const relaySet = await getRelays(await Storages.db(), event.pubkey);
relaySet.delete(Conf.relay);
// @ts-ignore Wrong types.
const pool = new RelayPoolWorker(worker, activeRelays, {
autoReconnect: true,
// The pipeline verifies events.
skipVerification: true,
// The logging feature overwhelms the CPU and creates too many logs.
logErrorsAndNotices: false,
});
return new PoolStore({
pool,
relays: activeRelays,
const relays = [...relaySet].slice(0, 4);
return relays;
},
});
})();
}

View file

@ -1,103 +0,0 @@
import {
NostrEvent,
NostrFilter,
NostrRelayCLOSED,
NostrRelayEOSE,
NostrRelayEVENT,
NRelay,
NSet,
} from '@nostrify/nostrify';
import { Machina } from '@nostrify/nostrify/utils';
import Debug from '@soapbox/stickynotes/debug';
import { RelayPoolWorker } from 'nostr-relaypool';
import { getFilterLimit, matchFilters } from 'nostr-tools';
import { Conf } from '@/config.ts';
import { Storages } from '@/storages.ts';
import { purifyEvent } from '@/storages/hydrate.ts';
import { abortError } from '@/utils/abort.ts';
import { getRelays } from '@/utils/outbox.ts';
interface PoolStoreOpts {
pool: InstanceType<typeof RelayPoolWorker>;
relays: WebSocket['url'][];
}
class PoolStore implements NRelay {
private debug = Debug('ditto:client');
private pool: InstanceType<typeof RelayPoolWorker>;
private relays: WebSocket['url'][];
constructor(opts: PoolStoreOpts) {
this.pool = opts.pool;
this.relays = opts.relays;
}
async event(event: NostrEvent, opts: { signal?: AbortSignal } = {}): Promise<void> {
if (opts.signal?.aborted) return Promise.reject(abortError());
const relaySet = await getRelays(await Storages.db(), event.pubkey);
relaySet.delete(Conf.relay);
const relays = [...relaySet].slice(0, 4);
event = purifyEvent(event);
this.debug('EVENT', event, relays);
this.pool.publish(event, relays);
return Promise.resolve();
}
async *req(
filters: NostrFilter[],
opts: { signal?: AbortSignal; limit?: number } = {},
): AsyncIterable<NostrRelayEVENT | NostrRelayEOSE | NostrRelayCLOSED> {
this.debug('REQ', JSON.stringify(filters));
const uuid = crypto.randomUUID();
const machina = new Machina<NostrRelayEVENT | NostrRelayEOSE | NostrRelayCLOSED>(opts.signal);
const unsub = this.pool.subscribe(
filters,
this.relays,
(event: NostrEvent | null) => {
if (event && matchFilters(filters, event)) {
machina.push(['EVENT', uuid, purifyEvent(event)]);
}
},
undefined,
() => {
machina.push(['EOSE', uuid]);
},
);
try {
for await (const msg of machina) {
yield msg;
}
} finally {
unsub();
}
}
async query(filters: NostrFilter[], opts: { signal?: AbortSignal; limit?: number } = {}): Promise<NostrEvent[]> {
const events = new NSet();
const limit = filters.reduce((result, filter) => result + getFilterLimit(filter), 0);
if (limit === 0) return [];
for await (const msg of this.req(filters, opts)) {
if (msg[0] === 'EOSE') break;
if (msg[0] === 'EVENT') events.add(msg[2]);
if (msg[0] === 'CLOSED') throw new Error('Subscription closed');
if (events.size >= limit) {
break;
}
}
return [...events];
}
}
export { PoolStore };

View file

@ -14,6 +14,7 @@ import { RelayError } from '@/RelayError.ts';
import { AdminSigner } from '@/signers/AdminSigner.ts';
import { Storages } from '@/storages.ts';
import { nostrNow } from '@/utils.ts';
import { purifyEvent } from '@/storages/hydrate.ts';
const debug = Debug('ditto:api');
@ -152,7 +153,7 @@ async function publishEvent(event: NostrEvent, c: AppContext): Promise<NostrEven
try {
await pipeline.handleEvent(event, c.req.raw.signal);
const client = await Storages.client();
await client.event(event);
await client.event(purifyEvent(event));
} catch (e) {
if (e instanceof RelayError) {
throw new HTTPException(422, {

29
src/utils/outbox.test.ts Normal file
View file

@ -0,0 +1,29 @@
import { MockRelay } from '@nostrify/nostrify/test';
import { eventFixture } from '@/test.ts';
import { getRelays } from '@/utils/outbox.ts';
import { assertEquals } from '@std/assert';
Deno.test('Get write relays - kind 10002', async () => {
const db = new MockRelay();
const relayListMetadata = await eventFixture('kind-10002-alex');
await db.event(relayListMetadata);
const relays = await getRelays(db, relayListMetadata.pubkey);
assertEquals(relays.size, 6);
});
Deno.test('Get write relays with invalid URL - kind 10002', async () => {
const db = new MockRelay();
const relayListMetadata = await eventFixture('kind-10002-alex');
relayListMetadata.tags[0] = ['r', 'yolo'];
await db.event(relayListMetadata);
const relays = await getRelays(db, relayListMetadata.pubkey);
assertEquals(relays.size, 5);
});

View file

@ -2,11 +2,11 @@ import { NStore } from '@nostrify/nostrify';
import { Conf } from '@/config.ts';
export async function getRelays(store: NStore, _pubkey: string): Promise<Set<string>> {
export async function getRelays(store: NStore, pubkey: string): Promise<Set<string>> {
const relays = new Set<`wss://${string}`>();
const events = await store.query([
{ kinds: [10002], authors: [/*pubkey, */ Conf.pubkey], limit: 2 },
{ kinds: [10002], authors: [pubkey, Conf.pubkey], limit: 2 },
]);
for (const event of events) {

View file

@ -0,0 +1,60 @@
import { assertEquals } from '@std/assert';
import { generateSecretKey, getPublicKey } from 'nostr-tools';
import { genEvent } from '@/test.ts';
import { getZapSplits } from '@/utils/zap-split.ts';
import { getTestDB } from '@/test.ts';
Deno.test('Get zap splits in DittoZapSplits format', async () => {
const { store } = await getTestDB();
const sk = generateSecretKey();
const pubkey = getPublicKey(sk);
const event = genEvent({
kind: 30078,
tags: [
['d', 'pub.ditto.zapSplits'],
['p', '47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4', '2', 'Patrick developer'],
['p', '0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd', '3', 'Alex creator of Ditto'],
],
}, sk);
await store.event(event);
const eventFromDb = await store.query([{ kinds: [30078], authors: [pubkey] }]);
assertEquals(eventFromDb.length, 1);
const zapSplits = await getZapSplits(store, pubkey);
assertEquals(zapSplits, {
'0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd': { amount: 3, message: 'Alex creator of Ditto' },
'47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4': { amount: 2, message: 'Patrick developer' },
});
assertEquals(await getZapSplits(store, 'garbage'), undefined);
});
Deno.test('Zap split is empty', async () => {
const { store } = await getTestDB();
const sk = generateSecretKey();
const pubkey = getPublicKey(sk);
const event = genEvent({
kind: 30078,
tags: [
['d', 'pub.ditto.zapSplits'],
['p', 'baka'],
],
}, sk);
await store.event(event);
const eventFromDb = await store.query([{ kinds: [30078], authors: [pubkey] }]);
assertEquals(eventFromDb.length, 1);
const zapSplits = await getZapSplits(store, pubkey);
assertEquals(zapSplits, {});
});

62
src/utils/zap-split.ts Normal file
View file

@ -0,0 +1,62 @@
import { AdminSigner } from '@/signers/AdminSigner.ts';
import { Conf } from '@/config.ts';
import { handleEvent } from '@/pipeline.ts';
import { NSchema as n, NStore } from '@nostrify/nostrify';
import { nostrNow } from '@/utils.ts';
import { percentageSchema } from '@/schema.ts';
import { Storages } from '@/storages.ts';
type Pubkey = string;
type ExtraMessage = string;
/** Number from 1 to 100, stringified. */
type splitPercentages = number;
export type DittoZapSplits = {
[key: Pubkey]: { amount: splitPercentages; message: ExtraMessage };
};
/** Gets zap splits from NIP-78 in DittoZapSplits format. */
export async function getZapSplits(store: NStore, pubkey: string): Promise<DittoZapSplits | undefined> {
const zapSplits: DittoZapSplits = {};
const [event] = await store.query([{
authors: [pubkey],
kinds: [30078],
'#d': ['pub.ditto.zapSplits'],
limit: 1,
}]);
if (!event) return;
for (const tag of event.tags) {
if (
tag[0] === 'p' && n.id().safeParse(tag[1]).success &&
percentageSchema.safeParse(tag[2]).success
) {
zapSplits[tag[1]] = { amount: Number(tag[2]), message: tag[3] };
}
}
return zapSplits;
}
export async function seedZapSplits() {
const store = await Storages.admin();
const zap_split: DittoZapSplits | undefined = await getZapSplits(store, Conf.pubkey);
if (!zap_split) {
const dittoPubkey = '781a1527055f74c1f70230f10384609b34548f8ab6a0a6caa74025827f9fdae5';
const dittoMsg = 'Official Ditto Account';
const signer = new AdminSigner();
const event = await signer.signEvent({
content: '',
created_at: nostrNow(),
kind: 30078,
tags: [
['d', 'pub.ditto.zapSplits'],
['p', dittoPubkey, '5', dittoMsg],
],
});
await handleEvent(event, AbortSignal.timeout(5000));
}
}