From baa00e45a66bed819c8588c7d097f242c6467205 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Fri, 2 Aug 2024 18:47:21 -0300 Subject: [PATCH 01/28] feat: create getZapSplitsController --- src/app.ts | 3 +++ src/controllers/api/ditto.ts | 40 ++++++++++++++++++++++++++++++------ 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/app.ts b/src/app.ts index 66c33424..4fd1a52a 100644 --- a/src/app.ts +++ b/src/app.ts @@ -40,6 +40,7 @@ import { adminRelaysController, adminSetRelaysController, deleteZapSplitsController, + getZapSplitsController, nameRequestController, nameRequestsController, updateZapSplitsController, @@ -263,6 +264,8 @@ 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.get('/api/v1/ditto/zap_splits', getZapSplitsController); + app.put('/api/v1/admin/ditto/zap_splits', requireRole('admin'), updateZapSplitsController); app.delete('/api/v1/admin/ditto/zap_splits', requireRole('admin'), deleteZapSplitsController); diff --git a/src/controllers/api/ditto.ts b/src/controllers/api/ditto.ts index d1ba002b..1ae35aee 100644 --- a/src/controllers/api/ditto.ts +++ b/src/controllers/api/ditto.ts @@ -1,18 +1,21 @@ import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify'; import { z } from 'zod'; +import { accountFromPubkey } from '@/views/mastodon/accounts.ts'; import { AppController } from '@/app.ts'; +import { addTag } from '@/utils/tags.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, 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'; +import { DittoZapSplits, getZapSplits } from '@/utils/zap-split.ts'; +import { getAuthor } from '@/queries.ts'; +import { hydrateEvents } from '@/storages/hydrate.ts'; +import { renderNameRequest } from '@/views/ditto.ts'; +import { renderAccount } from '@/views/mastodon/accounts.ts'; +import { Storages } from '@/storages.ts'; +import { updateListAdminEvent } from '@/utils/api.ts'; const markerSchema = z.enum(['read', 'write']); @@ -223,3 +226,28 @@ export const deleteZapSplitsController: AppController = async (c) => { return c.json(200); }; + +export const getZapSplitsController: AppController = async (c) => { + const store = c.get('store'); + + const zap_split: DittoZapSplits | undefined = await getZapSplits(store, Conf.pubkey) ?? {}; + if (!zap_split) { + return c.json({ error: 'Zap split not activated, restart the server.' }, 404); + } + + const pubkeys = Object.keys(zap_split); + + const zap_splits_mastodon = await Promise.all(pubkeys.map(async (pubkey) => { + const author = await getAuthor(pubkey); + + const account = author ? await renderAccount(author) : await accountFromPubkey(pubkey); + + return { + account, + amount: zap_split[pubkey].amount, + message: zap_split[pubkey].message, + }; + })); + + return c.json(zap_splits_mastodon, 200); +}; From 6277b8ab0faf9453f6816609554f0286de43cc6f Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Fri, 2 Aug 2024 18:47:59 -0300 Subject: [PATCH 02/28] refactor: remove zap split from instance V1 endpoint --- src/controllers/api/instance.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/controllers/api/instance.ts b/src/controllers/api/instance.ts index 5f7054ee..faca9c9f 100644 --- a/src/controllers/api/instance.ts +++ b/src/controllers/api/instance.ts @@ -4,16 +4,12 @@ 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:'; @@ -72,9 +68,6 @@ const instanceV1Controller: AppController = async (c) => { }, }, rules: [], - ditto: { - zap_split, - }, }); }; From 25bbeceb8d158ddaa2398cab63c33d753606bcd5 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Mon, 5 Aug 2024 14:29:30 -0300 Subject: [PATCH 03/28] refactor: rename zap_splits_mastodon to zapSplitEntity --- src/controllers/api/ditto.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/controllers/api/ditto.ts b/src/controllers/api/ditto.ts index 1ae35aee..fdeb43bd 100644 --- a/src/controllers/api/ditto.ts +++ b/src/controllers/api/ditto.ts @@ -237,7 +237,7 @@ export const getZapSplitsController: AppController = async (c) => { const pubkeys = Object.keys(zap_split); - const zap_splits_mastodon = await Promise.all(pubkeys.map(async (pubkey) => { + const zapSplitEntity = await Promise.all(pubkeys.map(async (pubkey) => { const author = await getAuthor(pubkey); const account = author ? await renderAccount(author) : await accountFromPubkey(pubkey); @@ -249,5 +249,5 @@ export const getZapSplitsController: AppController = async (c) => { }; })); - return c.json(zap_splits_mastodon, 200); + return c.json(zapSplitEntity, 200); }; From 0a3ed4e160a8984c6e63a61b55580cfa83d9018b Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Mon, 5 Aug 2024 14:33:47 -0300 Subject: [PATCH 04/28] refactor(zap split): rename amount to weight --- src/controllers/api/ditto.ts | 6 +++--- src/controllers/api/statuses.ts | 4 ++-- src/utils/zap-split.ts | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/controllers/api/ditto.ts b/src/controllers/api/ditto.ts index fdeb43bd..e181f11e 100644 --- a/src/controllers/api/ditto.ts +++ b/src/controllers/api/ditto.ts @@ -159,7 +159,7 @@ export const nameRequestsController: AppController = async (c) => { const zapSplitSchema = z.record( n.id(), z.object({ - amount: z.number().int().min(1).max(100), + weight: z.number().int().min(1).max(100), message: z.string().max(500), }), ); @@ -189,7 +189,7 @@ export const updateZapSplitsController: AppController = async (c) => { { 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]); + return addTag(accumulator, ['p', pubkey, data[pubkey].weight.toString(), data[pubkey].message]); }, tags), c, ); @@ -244,7 +244,7 @@ export const getZapSplitsController: AppController = async (c) => { return { account, - amount: zap_split[pubkey].amount, + weight: zap_split[pubkey].weight, message: zap_split[pubkey].message, }; })); diff --git a/src/controllers/api/statuses.ts b/src/controllers/api/statuses.ts index 4e8d051f..f875b785 100644 --- a/src/controllers/api/statuses.ts +++ b/src/controllers/api/statuses.ts @@ -183,8 +183,8 @@ const createStatusController: AppController = async (c) => { 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()]); + totalSplit += zap_split[pubkey].weight; + tags.push(['zap', pubkey, Conf.relay, zap_split[pubkey].weight.toString()]); } if (totalSplit) { tags.push(['zap', author?.pubkey as string, Conf.relay, Math.max(0, 100 - totalSplit).toString()]); diff --git a/src/utils/zap-split.ts b/src/utils/zap-split.ts index 80d6cb2e..e5df1538 100644 --- a/src/utils/zap-split.ts +++ b/src/utils/zap-split.ts @@ -10,7 +10,7 @@ type ExtraMessage = string; type splitPercentages = number; export type DittoZapSplits = { - [key: Pubkey]: { amount: splitPercentages; message: ExtraMessage }; + [key: Pubkey]: { weight: splitPercentages; message: ExtraMessage }; }; /** Gets zap splits from NIP-78 in DittoZapSplits format. */ @@ -30,7 +30,7 @@ export async function getZapSplits(store: NStore, pubkey: string): Promise Date: Mon, 5 Aug 2024 16:20:38 -0300 Subject: [PATCH 05/28] refactor: rename zap_split to dittoZapSplit --- src/controllers/api/ditto.ts | 22 +++++++++++----------- src/controllers/api/statuses.ts | 10 +++++----- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/controllers/api/ditto.ts b/src/controllers/api/ditto.ts index e181f11e..ed1f500d 100644 --- a/src/controllers/api/ditto.ts +++ b/src/controllers/api/ditto.ts @@ -173,8 +173,8 @@ export const updateZapSplitsController: AppController = async (c) => { return c.json({ error: result.error }, 400); } - const zap_split = await getZapSplits(store, Conf.pubkey); - if (!zap_split) { + const dittoZapSplit = await getZapSplits(store, Conf.pubkey); + if (!dittoZapSplit) { return c.json({ error: 'Zap split not activated, restart the server.' }, 404); } @@ -208,8 +208,8 @@ export const deleteZapSplitsController: AppController = async (c) => { return c.json({ error: result.error }, 400); } - const zap_split = await getZapSplits(store, Conf.pubkey); - if (!zap_split) { + const dittoZapSplit = await getZapSplits(store, Conf.pubkey); + if (!dittoZapSplit) { return c.json({ error: 'Zap split not activated, restart the server.' }, 404); } @@ -230,24 +230,24 @@ export const deleteZapSplitsController: AppController = async (c) => { export const getZapSplitsController: AppController = async (c) => { const store = c.get('store'); - const zap_split: DittoZapSplits | undefined = await getZapSplits(store, Conf.pubkey) ?? {}; - if (!zap_split) { + const dittoZapSplit: DittoZapSplits | undefined = await getZapSplits(store, Conf.pubkey) ?? {}; + if (!dittoZapSplit) { return c.json({ error: 'Zap split not activated, restart the server.' }, 404); } - const pubkeys = Object.keys(zap_split); + const pubkeys = Object.keys(dittoZapSplit); - const zapSplitEntity = await Promise.all(pubkeys.map(async (pubkey) => { + const zapSplits = await Promise.all(pubkeys.map(async (pubkey) => { const author = await getAuthor(pubkey); const account = author ? await renderAccount(author) : await accountFromPubkey(pubkey); return { account, - weight: zap_split[pubkey].weight, - message: zap_split[pubkey].message, + weight: dittoZapSplit[pubkey].weight, + message: dittoZapSplit[pubkey].message, }; })); - return c.json(zapSplitEntity, 200); + return c.json(zapSplits, 200); }; diff --git a/src/controllers/api/statuses.ts b/src/controllers/api/statuses.ts index f875b785..16c4c8bf 100644 --- a/src/controllers/api/statuses.ts +++ b/src/controllers/api/statuses.ts @@ -179,12 +179,12 @@ const createStatusController: AppController = async (c) => { 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) { + const dittoZapSplit = await getZapSplits(store, Conf.pubkey); + if (lnurl && dittoZapSplit) { let totalSplit = 0; - for (const pubkey in zap_split) { - totalSplit += zap_split[pubkey].weight; - tags.push(['zap', pubkey, Conf.relay, zap_split[pubkey].weight.toString()]); + for (const pubkey in dittoZapSplit) { + totalSplit += dittoZapSplit[pubkey].weight; + tags.push(['zap', pubkey, Conf.relay, dittoZapSplit[pubkey].weight.toString()]); } if (totalSplit) { tags.push(['zap', author?.pubkey as string, Conf.relay, Math.max(0, 100 - totalSplit).toString()]); From db6417bad9123cce0ed98d602d688e48366b9bf7 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 5 Aug 2024 15:45:02 -0500 Subject: [PATCH 06/28] Upgrade to Nostrify v0.30.0 --- deno.json | 3 ++- deno.lock | 25 ++++++++++++++++++++----- src/storages/EventsDB.ts | 2 +- src/test.ts | 3 ++- 4 files changed, 25 insertions(+), 8 deletions(-) diff --git a/deno.json b/deno.json index 964490c6..0c41a378 100644 --- a/deno.json +++ b/deno.json @@ -30,7 +30,8 @@ "@isaacs/ttlcache": "npm:@isaacs/ttlcache@^1.4.1", "@lambdalisue/async": "jsr:@lambdalisue/async@^2.1.1", "@noble/secp256k1": "npm:@noble/secp256k1@^2.0.0", - "@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.29.0", + "@nostrify/db": "jsr:@nostrify/db@^0.30.0", + "@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.30.0", "@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", diff --git a/deno.lock b/deno.lock index 5b403d44..101362a4 100644 --- a/deno.lock +++ b/deno.lock @@ -11,10 +11,12 @@ "jsr:@gleasonator/policy@0.4.1": "jsr:@gleasonator/policy@0.4.1", "jsr:@hono/hono@^4.4.6": "jsr:@hono/hono@4.5.3", "jsr:@lambdalisue/async@^2.1.1": "jsr:@lambdalisue/async@2.1.1", + "jsr:@nostrify/db@^0.30.0": "jsr:@nostrify/db@0.30.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.29.0": "jsr:@nostrify/nostrify@0.29.0", + "jsr:@nostrify/nostrify@^0.30.0": "jsr:@nostrify/nostrify@0.30.0", + "jsr:@nostrify/types@^0.30.0": "jsr:@nostrify/types@0.30.0", "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", @@ -135,6 +137,15 @@ "@lambdalisue/async@2.1.1": { "integrity": "1fc9bc6f4ed50215cd2f7217842b18cea80f81c25744f88f8c5eb4be5a1c9ab4" }, + "@nostrify/db@0.30.0": { + "integrity": "a75ba78be89d57c54c3d47e9e94c7142817c5b50daec27bf7f9a4af4629be20b", + "dependencies": [ + "jsr:@nostrify/nostrify@^0.30.0", + "jsr:@nostrify/types@^0.30.0", + "npm:kysely@^0.27.3", + "npm:nostr-tools@^2.7.0" + ] + }, "@nostrify/nostrify@0.22.4": { "integrity": "1c8a7847e5773213044b491e85fd7cafae2ad194ce59da4d957d2b27c776b42d", "dependencies": [ @@ -164,21 +175,24 @@ "npm:zod@^3.23.8" ] }, - "@nostrify/nostrify@0.29.0": { - "integrity": "d0489b62441c891324cce60c14bb398013259494b5ad9d21ec6dfbf0ca7368c9", + "@nostrify/nostrify@0.30.0": { + "integrity": "7c29e7d8b5a0a81e238170ac1e7ad708bc72dd8f478d8d82c30598fb4eff9b9c", "dependencies": [ + "jsr:@nostrify/types@^0.30.0", "jsr:@std/crypto@^0.224.0", "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" ] }, + "@nostrify/types@0.30.0": { + "integrity": "1f38fa849cff930bd709edbf94ef9ac02f46afb8b851f86c8736517b354616da" + }, "@soapbox/kysely-deno-sqlite@2.2.0": { "integrity": "668ec94600bc4b4d7bd618dd7ca65d4ef30ee61c46ffcb379b6f45203c08517a", "dependencies": [ @@ -1808,7 +1822,8 @@ "jsr:@db/sqlite@^0.11.1", "jsr:@hono/hono@^4.4.6", "jsr:@lambdalisue/async@^2.1.1", - "jsr:@nostrify/nostrify@^0.29.0", + "jsr:@nostrify/db@^0.30.0", + "jsr:@nostrify/nostrify@^0.30.0", "jsr:@soapbox/kysely-deno-sqlite@^2.1.0", "jsr:@soapbox/stickynotes@^0.4.0", "jsr:@std/assert@^0.225.1", diff --git a/src/storages/EventsDB.ts b/src/storages/EventsDB.ts index 011f5a02..ba9e93a4 100644 --- a/src/storages/EventsDB.ts +++ b/src/storages/EventsDB.ts @@ -1,7 +1,7 @@ // deno-lint-ignore-file require-await +import { NDatabase } from '@nostrify/db'; import { - NDatabase, NIP50, NKinds, NostrEvent, diff --git a/src/test.ts b/src/test.ts index 5156d3ff..c3c0db7b 100644 --- a/src/test.ts +++ b/src/test.ts @@ -2,9 +2,10 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { Database as Sqlite } from '@db/sqlite'; +import { NDatabase } from '@nostrify/db'; +import { NostrEvent } from '@nostrify/nostrify'; import { DenoSqlite3Dialect } from '@soapbox/kysely-deno-sqlite'; import { finalizeEvent, generateSecretKey } from 'nostr-tools'; -import { NDatabase, NostrEvent } from '@nostrify/nostrify'; import { FileMigrationProvider, Kysely, Migrator } from 'kysely'; import { PostgresJSDialect, PostgresJSDialectConfig } from 'kysely-postgres-js'; import postgres from 'postgres'; From 41bcd77853a145f0ec530bc46ed077043cde2308 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 6 Aug 2024 15:34:10 -0500 Subject: [PATCH 07/28] Optimize relationships database calls --- src/controllers/api/accounts.ts | 45 +++++++++++++++++++++++++---- src/views/mastodon/relationships.ts | 31 +++++++++++++------- 2 files changed, 60 insertions(+), 16 deletions(-) diff --git a/src/controllers/api/accounts.ts b/src/controllers/api/accounts.ts index 460aa6dd..d7541f8b 100644 --- a/src/controllers/api/accounts.ts +++ b/src/controllers/api/accounts.ts @@ -160,7 +160,25 @@ const relationshipsController: AppController = async (c) => { return c.json({ error: 'Missing `id[]` query parameters.' }, 422); } - const result = await Promise.all(ids.data.map((id) => renderRelationship(pubkey, id))); + const db = await Storages.db(); + + const [sourceEvents, targetEvents] = await Promise.all([ + db.query([{ kinds: [3, 10000], authors: [pubkey] }]), + db.query([{ kinds: [3], authors: ids.data }]), + ]); + + const event3 = sourceEvents.find((event) => event.kind === 3 && event.pubkey === pubkey); + const event10000 = sourceEvents.find((event) => event.kind === 10000 && event.pubkey === pubkey); + + const result = ids.data.map((id) => + renderRelationship({ + sourcePubkey: pubkey, + targetPubkey: id, + event3, + target3: targetEvents.find((event) => event.kind === 3 && event.pubkey === id), + event10000, + }) + ); return c.json(result); }; @@ -325,7 +343,7 @@ const followController: AppController = async (c) => { c, ); - const relationship = await renderRelationship(sourcePubkey, targetPubkey); + const relationship = await getRelationship(sourcePubkey, targetPubkey); relationship.following = true; return c.json(relationship); @@ -342,7 +360,7 @@ const unfollowController: AppController = async (c) => { c, ); - const relationship = await renderRelationship(sourcePubkey, targetPubkey); + const relationship = await getRelationship(sourcePubkey, targetPubkey); return c.json(relationship); }; @@ -379,7 +397,7 @@ const muteController: AppController = async (c) => { c, ); - const relationship = await renderRelationship(sourcePubkey, targetPubkey); + const relationship = await getRelationship(sourcePubkey, targetPubkey); return c.json(relationship); }; @@ -394,7 +412,7 @@ const unmuteController: AppController = async (c) => { c, ); - const relationship = await renderRelationship(sourcePubkey, targetPubkey); + const relationship = await getRelationship(sourcePubkey, targetPubkey); return c.json(relationship); }; @@ -447,6 +465,23 @@ const familiarFollowersController: AppController = async (c) => { return c.json(results); }; +async function getRelationship(sourcePubkey: string, targetPubkey: string) { + const db = await Storages.db(); + + const [sourceEvents, targetEvents] = await Promise.all([ + db.query([{ kinds: [3, 10000], authors: [sourcePubkey] }]), + db.query([{ kinds: [3], authors: [targetPubkey] }]), + ]); + + return renderRelationship({ + sourcePubkey, + targetPubkey, + event3: sourceEvents.find((event) => event.kind === 3 && event.pubkey === sourcePubkey), + target3: targetEvents.find((event) => event.kind === 3 && event.pubkey === targetPubkey), + event10000: sourceEvents.find((event) => event.kind === 10000 && event.pubkey === sourcePubkey), + }); +} + export { accountController, accountLookupController, diff --git a/src/views/mastodon/relationships.ts b/src/views/mastodon/relationships.ts index 425ea563..833a2d56 100644 --- a/src/views/mastodon/relationships.ts +++ b/src/views/mastodon/relationships.ts @@ -1,18 +1,27 @@ -import { Storages } from '@/storages.ts'; +import { NostrEvent } from '@nostrify/nostrify'; + import { hasTag } from '@/utils/tags.ts'; -async function renderRelationship(sourcePubkey: string, targetPubkey: string) { - const db = await Storages.db(); +interface RenderRelationshipOpts { + sourcePubkey: string; + targetPubkey: string; + event3: NostrEvent | undefined; + target3: NostrEvent | undefined; + event10000: NostrEvent | undefined; +} - const events = await db.query([ - { kinds: [3], authors: [sourcePubkey], limit: 1 }, - { kinds: [3], authors: [targetPubkey], limit: 1 }, - { kinds: [10000], authors: [sourcePubkey], limit: 1 }, - ]); +function renderRelationship({ sourcePubkey, targetPubkey, event3, target3, event10000 }: RenderRelationshipOpts) { + // const db = await Storages.db(); - const event3 = events.find((event) => event.kind === 3 && event.pubkey === sourcePubkey); - const target3 = events.find((event) => event.kind === 3 && event.pubkey === targetPubkey); - const event10000 = events.find((event) => event.kind === 10000 && event.pubkey === sourcePubkey); + // const events = await db.query([ + // { kinds: [3], authors: [sourcePubkey], limit: 1 }, + // { kinds: [3], authors: [targetPubkey], limit: 1 }, + // { kinds: [10000], authors: [sourcePubkey], limit: 1 }, + // ]); + + // const event3 = events.find((event) => event.kind === 3 && event.pubkey === sourcePubkey); + // const target3 = events.find((event) => event.kind === 3 && event.pubkey === targetPubkey); + // const event10000 = events.find((event) => event.kind === 10000 && event.pubkey === sourcePubkey); return { id: targetPubkey, From 09ea0856a60bfac6ced58712f13cebc1a26eac01 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 6 Aug 2024 15:58:04 -0500 Subject: [PATCH 08/28] renderRelationship: delete accidental comment left in --- src/views/mastodon/relationships.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/views/mastodon/relationships.ts b/src/views/mastodon/relationships.ts index 833a2d56..34d6c569 100644 --- a/src/views/mastodon/relationships.ts +++ b/src/views/mastodon/relationships.ts @@ -11,18 +11,6 @@ interface RenderRelationshipOpts { } function renderRelationship({ sourcePubkey, targetPubkey, event3, target3, event10000 }: RenderRelationshipOpts) { - // const db = await Storages.db(); - - // const events = await db.query([ - // { kinds: [3], authors: [sourcePubkey], limit: 1 }, - // { kinds: [3], authors: [targetPubkey], limit: 1 }, - // { kinds: [10000], authors: [sourcePubkey], limit: 1 }, - // ]); - - // const event3 = events.find((event) => event.kind === 3 && event.pubkey === sourcePubkey); - // const target3 = events.find((event) => event.kind === 3 && event.pubkey === targetPubkey); - // const event10000 = events.find((event) => event.kind === 10000 && event.pubkey === sourcePubkey); - return { id: targetPubkey, following: event3 ? hasTag(event3.tags, ['p', targetPubkey]) : false, From d285b7dced2aaa7223ba53ea098219af22b8bf40 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 6 Aug 2024 18:17:03 -0500 Subject: [PATCH 09/28] Fix Mastodon legacy pagination --- src/app.ts | 9 ++++-- src/controllers/api/accounts.ts | 8 +++--- src/controllers/api/admin.ts | 4 +-- src/controllers/api/ditto.ts | 4 +-- src/controllers/api/notifications.ts | 7 +++-- src/controllers/api/reports.ts | 4 +-- src/controllers/api/statuses.ts | 18 ++++-------- src/controllers/api/suggestions.ts | 8 +++--- src/controllers/api/timelines.ts | 10 +++---- src/interfaces/DittoPagination.ts | 15 ++++++++++ src/middleware/paginationMiddleware.ts | 40 ++++++++++++++++++++++++++ src/schemas/pagination.ts | 11 +++++++ src/utils/api.ts | 28 +----------------- src/views.ts | 6 ++-- 14 files changed, 105 insertions(+), 67 deletions(-) create mode 100644 src/interfaces/DittoPagination.ts create mode 100644 src/middleware/paginationMiddleware.ts create mode 100644 src/schemas/pagination.ts diff --git a/src/app.ts b/src/app.ts index 4fd1a52a..08758503 100644 --- a/src/app.ts +++ b/src/app.ts @@ -4,7 +4,9 @@ import { serveStatic } from '@hono/hono/deno'; import { logger } from '@hono/hono/logger'; import { NostrEvent, NostrSigner, NStore, NUploader } from '@nostrify/nostrify'; import Debug from '@soapbox/stickynotes/debug'; +import { SetRequired } from 'type-fest'; +import { DittoPagination } from '@/interfaces/DittoPagination.ts'; import { Time } from '@/utils/time.ts'; import { @@ -117,6 +119,7 @@ import { nostrController } from '@/controllers/well-known/nostr.ts'; import { auth98Middleware, requireProof, requireRole } from '@/middleware/auth98Middleware.ts'; import { cspMiddleware } from '@/middleware/cspMiddleware.ts'; import { metricsMiddleware } from '@/middleware/metricsMiddleware.ts'; +import { paginationMiddleware } from '@/middleware/paginationMiddleware.ts'; import { rateLimitMiddleware } from '@/middleware/rateLimitMiddleware.ts'; import { requireSigner } from '@/middleware/requireSigner.ts'; import { signerMiddleware } from '@/middleware/signerMiddleware.ts'; @@ -131,8 +134,10 @@ interface AppEnv extends HonoEnv { uploader?: NUploader; /** NIP-98 signed event proving the pubkey is owned by the user. */ proof?: NostrEvent; - /** Store */ + /** Storage for the user, might filter out unwanted content. */ store: NStore; + /** Normalized pagination params. */ + pagination: SetRequired; }; } @@ -146,7 +151,7 @@ const debug = Debug('ditto:http'); app.use('*', rateLimitMiddleware(300, Time.minutes(5))); -app.use('/api/*', metricsMiddleware, logger(debug)); +app.use('/api/*', metricsMiddleware, paginationMiddleware, logger(debug)); app.use('/.well-known/*', metricsMiddleware, logger(debug)); app.use('/users/*', metricsMiddleware, logger(debug)); app.use('/nodeinfo/*', metricsMiddleware, logger(debug)); diff --git a/src/controllers/api/accounts.ts b/src/controllers/api/accounts.ts index d7541f8b..20073eec 100644 --- a/src/controllers/api/accounts.ts +++ b/src/controllers/api/accounts.ts @@ -9,7 +9,7 @@ import { booleanParamSchema, fileSchema } from '@/schema.ts'; import { Storages } from '@/storages.ts'; import { uploadFile } from '@/utils/upload.ts'; import { nostrNow } from '@/utils.ts'; -import { createEvent, paginated, paginationSchema, parseBody, updateListEvent } from '@/utils/api.ts'; +import { createEvent, paginated, parseBody, updateListEvent } from '@/utils/api.ts'; import { lookupAccount } from '@/utils/lookup.ts'; import { renderAccounts, renderEventAccounts, renderStatuses } from '@/views.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; @@ -192,7 +192,7 @@ const accountStatusesQuerySchema = z.object({ const accountStatusesController: AppController = async (c) => { const pubkey = c.req.param('pubkey'); - const { since, until } = paginationSchema.parse(c.req.query()); + const { since, until } = c.get('pagination'); const { pinned, limit, exclude_replies, tagged } = accountStatusesQuerySchema.parse(c.req.query()); const { signal } = c.req.raw; @@ -366,7 +366,7 @@ const unfollowController: AppController = async (c) => { const followersController: AppController = (c) => { const pubkey = c.req.param('pubkey'); - const params = paginationSchema.parse(c.req.query()); + const params = c.get('pagination'); return renderEventAccounts(c, [{ kinds: [3], '#p': [pubkey], ...params }]); }; @@ -418,7 +418,7 @@ const unmuteController: AppController = async (c) => { const favouritesController: AppController = async (c) => { const pubkey = await c.get('signer')?.getPublicKey()!; - const params = paginationSchema.parse(c.req.query()); + const params = c.get('pagination'); const { signal } = c.req.raw; const store = await Storages.db(); diff --git a/src/controllers/api/admin.ts b/src/controllers/api/admin.ts index cda4ff61..2a9dae1f 100644 --- a/src/controllers/api/admin.ts +++ b/src/controllers/api/admin.ts @@ -6,7 +6,7 @@ import { Conf } from '@/config.ts'; import { booleanParamSchema } from '@/schema.ts'; import { Storages } from '@/storages.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; -import { createAdminEvent, paginated, paginationSchema, parseBody, updateEventInfo, updateUser } from '@/utils/api.ts'; +import { createAdminEvent, paginated, parseBody, updateEventInfo, updateUser } from '@/utils/api.ts'; import { renderNameRequest } from '@/views/ditto.ts'; import { renderAdminAccount, renderAdminAccountFromPubkey } from '@/views/mastodon/admin-accounts.ts'; @@ -29,7 +29,7 @@ const adminAccountQuerySchema = z.object({ const adminAccountsController: AppController = async (c) => { const store = await Storages.db(); - const params = paginationSchema.parse(c.req.query()); + const params = c.get('pagination'); const { signal } = c.req.raw; const { local, diff --git a/src/controllers/api/ditto.ts b/src/controllers/api/ditto.ts index ed1f500d..49faebb9 100644 --- a/src/controllers/api/ditto.ts +++ b/src/controllers/api/ditto.ts @@ -7,7 +7,7 @@ import { addTag } from '@/utils/tags.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts'; import { booleanParamSchema } from '@/schema.ts'; import { Conf } from '@/config.ts'; -import { createEvent, paginated, paginationSchema, parseBody } from '@/utils/api.ts'; +import { createEvent, paginated, parseBody } from '@/utils/api.ts'; import { deleteTag } from '@/utils/tags.ts'; import { DittoZapSplits, getZapSplits } from '@/utils/zap-split.ts'; import { getAuthor } from '@/queries.ts'; @@ -114,7 +114,7 @@ export const nameRequestsController: AppController = async (c) => { const signer = c.get('signer')!; const pubkey = await signer.getPublicKey(); - const params = paginationSchema.parse(c.req.query()); + const params = c.get('pagination'); const { approved, rejected } = nameRequestsSchema.parse(c.req.query()); const filter: NostrFilter = { diff --git a/src/controllers/api/notifications.ts b/src/controllers/api/notifications.ts index 6f6036f2..54e88edf 100644 --- a/src/controllers/api/notifications.ts +++ b/src/controllers/api/notifications.ts @@ -3,8 +3,9 @@ import { z } from 'zod'; import { AppContext, AppController } from '@/app.ts'; import { Conf } from '@/config.ts'; +import { DittoPagination } from '@/interfaces/DittoPagination.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; -import { paginated, PaginationParams, paginationSchema } from '@/utils/api.ts'; +import { paginated } from '@/utils/api.ts'; import { renderNotification } from '@/views/mastodon/notifications.ts'; /** Set of known notification types across backends. */ @@ -30,7 +31,7 @@ const notificationsSchema = z.object({ const notificationsController: AppController = async (c) => { const pubkey = await c.get('signer')?.getPublicKey()!; - const params = paginationSchema.parse(c.req.query()); + const params = c.get('pagination'); const types = notificationTypes .intersection(new Set(c.req.queries('types[]') ?? notificationTypes)) @@ -72,7 +73,7 @@ const notificationsController: AppController = async (c) => { async function renderNotifications( filters: NostrFilter[], types: Set, - params: PaginationParams, + params: DittoPagination, c: AppContext, ) { const store = c.get('store'); diff --git a/src/controllers/api/reports.ts b/src/controllers/api/reports.ts index da107ed9..97d08751 100644 --- a/src/controllers/api/reports.ts +++ b/src/controllers/api/reports.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { type AppController } from '@/app.ts'; import { Conf } from '@/config.ts'; -import { createEvent, paginated, paginationSchema, parseBody, updateEventInfo } from '@/utils/api.ts'; +import { createEvent, paginated, 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'; @@ -64,7 +64,7 @@ const adminReportsController: AppController = async (c) => { const store = c.get('store'); const viewerPubkey = await c.get('signer')?.getPublicKey(); - const params = paginationSchema.parse(c.req.query()); + const params = c.get('pagination'); const { resolved, account_id, target_account_id } = adminReportsSchema.parse(c.req.query()); const filter: NostrFilter = { diff --git a/src/controllers/api/statuses.ts b/src/controllers/api/statuses.ts index 16c4c8bf..750f349b 100644 --- a/src/controllers/api/statuses.ts +++ b/src/controllers/api/statuses.ts @@ -19,15 +19,7 @@ import { renderEventAccounts } from '@/views.ts'; import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts'; import { Storages } from '@/storages.ts'; import { hydrateEvents, purifyEvent } from '@/storages/hydrate.ts'; -import { - createEvent, - listPaginationSchema, - paginated, - paginatedList, - paginationSchema, - parseBody, - updateListEvent, -} from '@/utils/api.ts'; +import { createEvent, paginated, paginatedList, parseBody, updateListEvent } from '@/utils/api.ts'; import { getInvoice, getLnurl } from '@/utils/lnurl.ts'; import { getZapSplits } from '@/utils/zap-split.ts'; @@ -296,7 +288,7 @@ const favouriteController: AppController = async (c) => { const favouritedByController: AppController = (c) => { const id = c.req.param('id'); - const params = paginationSchema.parse(c.req.query()); + const params = c.get('pagination'); return renderEventAccounts(c, [{ kinds: [7], '#e': [id], ...params }], { filterFn: ({ content }) => content === '+', @@ -364,13 +356,13 @@ const unreblogStatusController: AppController = async (c) => { const rebloggedByController: AppController = (c) => { const id = c.req.param('id'); - const params = paginationSchema.parse(c.req.query()); + const params = c.get('pagination'); return renderEventAccounts(c, [{ kinds: [6], '#e': [id], ...params }]); }; const quotesController: AppController = async (c) => { const id = c.req.param('id'); - const params = paginationSchema.parse(c.req.query()); + const params = c.get('pagination'); const store = await Storages.db(); const [event] = await store.query([{ ids: [id], kinds: [1] }]); @@ -571,7 +563,7 @@ const zapController: AppController = async (c) => { const zappedByController: AppController = async (c) => { const id = c.req.param('id'); - const params = listPaginationSchema.parse(c.req.query()); + const params = c.get('pagination'); const store = await Storages.db(); const db = await DittoDB.getInstance(); diff --git a/src/controllers/api/suggestions.ts b/src/controllers/api/suggestions.ts index 7e461c45..347ec827 100644 --- a/src/controllers/api/suggestions.ts +++ b/src/controllers/api/suggestions.ts @@ -4,13 +4,13 @@ import { matchFilter } from 'nostr-tools'; import { AppContext, AppController } from '@/app.ts'; import { Conf } from '@/config.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; -import { listPaginationSchema, paginatedList, PaginatedListParams } from '@/utils/api.ts'; +import { paginatedList } from '@/utils/api.ts'; import { getTagSet } from '@/utils/tags.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; export const suggestionsV1Controller: AppController = async (c) => { const signal = c.req.raw.signal; - const params = listPaginationSchema.parse(c.req.query()); + const params = c.get('pagination'); const suggestions = await renderV2Suggestions(c, params, signal); const accounts = suggestions.map(({ account }) => account); return paginatedList(c, params, accounts); @@ -18,12 +18,12 @@ export const suggestionsV1Controller: AppController = async (c) => { export const suggestionsV2Controller: AppController = async (c) => { const signal = c.req.raw.signal; - const params = listPaginationSchema.parse(c.req.query()); + const params = c.get('pagination'); const suggestions = await renderV2Suggestions(c, params, signal); return paginatedList(c, params, suggestions); }; -async function renderV2Suggestions(c: AppContext, params: PaginatedListParams, signal?: AbortSignal) { +async function renderV2Suggestions(c: AppContext, params: { offset: number; limit: number }, signal?: AbortSignal) { const { offset, limit } = params; const store = c.get('store'); diff --git a/src/controllers/api/timelines.ts b/src/controllers/api/timelines.ts index 848ae63f..5fce4602 100644 --- a/src/controllers/api/timelines.ts +++ b/src/controllers/api/timelines.ts @@ -6,12 +6,12 @@ import { Conf } from '@/config.ts'; import { getFeedPubkeys } from '@/queries.ts'; import { booleanParamSchema } from '@/schema.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; -import { paginated, paginationSchema } from '@/utils/api.ts'; +import { paginated } from '@/utils/api.ts'; import { getTagSet } from '@/utils/tags.ts'; import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts'; const homeTimelineController: AppController = async (c) => { - const params = paginationSchema.parse(c.req.query()); + const params = c.get('pagination'); const pubkey = await c.get('signer')?.getPublicKey()!; const authors = await getFeedPubkeys(pubkey); return renderStatuses(c, [{ authors, kinds: [1, 6], ...params }]); @@ -23,7 +23,7 @@ const publicQuerySchema = z.object({ }); const publicTimelineController: AppController = (c) => { - const params = paginationSchema.parse(c.req.query()); + const params = c.get('pagination'); const { local, instance } = publicQuerySchema.parse(c.req.query()); const filter: NostrFilter = { kinds: [1], ...params }; @@ -39,13 +39,13 @@ const publicTimelineController: AppController = (c) => { const hashtagTimelineController: AppController = (c) => { const hashtag = c.req.param('hashtag')!.toLowerCase(); - const params = paginationSchema.parse(c.req.query()); + const params = c.get('pagination'); return renderStatuses(c, [{ kinds: [1], '#t': [hashtag], ...params }]); }; const suggestedTimelineController: AppController = async (c) => { const store = c.get('store'); - const params = paginationSchema.parse(c.req.query()); + const params = c.get('pagination'); const [follows] = await store.query( [{ kinds: [3], authors: [Conf.pubkey], limit: 1 }], diff --git a/src/interfaces/DittoPagination.ts b/src/interfaces/DittoPagination.ts new file mode 100644 index 00000000..e4c4520c --- /dev/null +++ b/src/interfaces/DittoPagination.ts @@ -0,0 +1,15 @@ +/** Based on Mastodon pagination. */ +export interface DittoPagination { + /** Lowest Nostr event `created_at` timestamp. */ + since?: number; + /** Highest Nostr event `created_at` timestamp. */ + until?: number; + /** @deprecated Mastodon apps are supposed to use the `Link` header. */ + max_id?: string; + /** @deprecated Mastodon apps are supposed to use the `Link` header. */ + min_id?: string; + /** Maximum number of results to return. Default 20, maximum 40. */ + limit?: number; + /** Used by Ditto to offset tag values in Nostr list events. */ + offset?: number; +} diff --git a/src/middleware/paginationMiddleware.ts b/src/middleware/paginationMiddleware.ts new file mode 100644 index 00000000..6d5227db --- /dev/null +++ b/src/middleware/paginationMiddleware.ts @@ -0,0 +1,40 @@ +import { AppMiddleware } from '@/app.ts'; +import { paginationSchema } from '@/schemas/pagination.ts'; +import { Storages } from '@/storages.ts'; + +/** Fixes compatibility with Mastodon apps by that don't use `Link` headers. */ +export const paginationMiddleware: AppMiddleware = async (c, next) => { + const pagination = paginationSchema.parse(c.req.query()); + + const { + max_id: maxId, + min_id: minId, + since, + until, + } = pagination; + + if ((maxId && !until) || (minId && !since)) { + const ids: string[] = []; + + if (maxId) ids.push(maxId); + if (minId) ids.push(minId); + + if (ids.length) { + const store = await Storages.db(); + + const events = await store.query( + [{ ids, limit: ids.length }], + { signal: c.req.raw.signal }, + ); + + for (const event of events) { + if (!until && maxId === event.id) pagination.until = event.created_at; + if (!since && minId === event.id) pagination.since = event.created_at; + } + } + } + + c.set('pagination', pagination); + + await next(); +}; diff --git a/src/schemas/pagination.ts b/src/schemas/pagination.ts new file mode 100644 index 00000000..d7a6cf68 --- /dev/null +++ b/src/schemas/pagination.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +/** Schema to parse pagination query params. */ +export const paginationSchema = z.object({ + max_id: z.string().optional().catch(undefined), + min_id: z.string().optional().catch(undefined), + since: z.coerce.number().nonnegative().optional().catch(undefined), + until: z.coerce.number().nonnegative().optional().catch(undefined), + limit: z.coerce.number().catch(20).transform((value) => Math.min(Math.max(value, 0), 40)), + offset: z.coerce.number().nonnegative().catch(0), +}); diff --git a/src/utils/api.ts b/src/utils/api.ts index 069442c9..ac00afb5 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -5,7 +5,6 @@ import Debug from '@soapbox/stickynotes/debug'; import { parseFormData } from 'formdata-helper'; import { EventTemplate } from 'nostr-tools'; import * as TypeFest from 'type-fest'; -import { z } from 'zod'; import { type AppContext } from '@/app.ts'; import { Conf } from '@/config.ts'; @@ -176,16 +175,6 @@ async function parseBody(req: Request): Promise { } } -/** Schema to parse pagination query params. */ -const paginationSchema = z.object({ - since: z.coerce.number().nonnegative().optional().catch(undefined), - until: z.coerce.number().nonnegative().optional().catch(undefined), - limit: z.coerce.number().catch(20).transform((value) => Math.min(Math.max(value, 0), 40)), -}); - -/** Mastodon API pagination query params. */ -type PaginationParams = z.infer; - /** Build HTTP Link header for Mastodon API pagination. */ function buildLinkHeader(url: string, events: NostrEvent[]): string | undefined { if (events.length <= 1) return; @@ -219,12 +208,6 @@ function paginated(c: AppContext, events: NostrEvent[], entities: (Entity | unde return c.json(results, 200, headers); } -/** Query params for paginating a list. */ -const listPaginationSchema = z.object({ - offset: z.coerce.number().nonnegative().catch(0), - limit: z.coerce.number().catch(20).transform((value) => Math.min(Math.max(value, 0), 40)), -}); - /** Build HTTP Link header for paginating Nostr lists. */ function buildListLinkHeader(url: string, params: { offset: number; limit: number }): string | undefined { const { origin } = Conf.url; @@ -242,15 +225,10 @@ function buildListLinkHeader(url: string, params: { offset: number; limit: numbe return `<${next}>; rel="next", <${prev}>; rel="prev"`; } -interface PaginatedListParams { - offset: number; - limit: number; -} - /** paginate a list of tags. */ function paginatedList( c: AppContext, - params: PaginatedListParams, + params: { offset: number; limit: number }, entities: unknown[], headers: HeaderRecord = {}, ) { @@ -296,13 +274,9 @@ export { createAdminEvent, createEvent, type EventStub, - listPaginationSchema, localRequest, paginated, paginatedList, - type PaginatedListParams, - type PaginationParams, - paginationSchema, parseBody, updateAdminEvent, updateEvent, diff --git a/src/views.ts b/src/views.ts index e1172306..6545ad94 100644 --- a/src/views.ts +++ b/src/views.ts @@ -4,7 +4,7 @@ import { AppContext } from '@/app.ts'; import { Storages } from '@/storages.ts'; import { renderAccount } from '@/views/mastodon/accounts.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts'; -import { listPaginationSchema, paginated, paginatedList, paginationSchema } from '@/utils/api.ts'; +import { paginated, paginatedList } from '@/utils/api.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; import { accountFromPubkey } from '@/views/mastodon/accounts.ts'; @@ -43,7 +43,7 @@ async function renderEventAccounts(c: AppContext, filters: NostrFilter[], opts?: } async function renderAccounts(c: AppContext, pubkeys: string[]) { - const { offset, limit } = listPaginationSchema.parse(c.req.query()); + const { offset, limit } = c.get('pagination'); const authors = pubkeys.reverse().slice(offset, offset + limit); const store = await Storages.db(); @@ -73,7 +73,7 @@ async function renderStatuses(c: AppContext, ids: string[], signal = AbortSignal } const store = await Storages.db(); - const { limit } = paginationSchema.parse(c.req.query()); + const { limit } = c.get('pagination'); const events = await store.query([{ kinds: [1], ids, limit }], { signal }) .then((events) => hydrateEvents({ events, store, signal })); From 32cecb5e6bf26e4c2a4ba261b3cfd37e75208a54 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Tue, 6 Aug 2024 20:55:40 -0300 Subject: [PATCH 10/28] feat: create zap split endpoint based in post/status id --- src/app.ts | 2 ++ src/controllers/api/ditto.ts | 36 +++++++++++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/app.ts b/src/app.ts index 4fd1a52a..71d1b4cc 100644 --- a/src/app.ts +++ b/src/app.ts @@ -41,6 +41,7 @@ import { adminSetRelaysController, deleteZapSplitsController, getZapSplitsController, + getZapSplitsOfStatusController, nameRequestController, nameRequestsController, updateZapSplitsController, @@ -265,6 +266,7 @@ app.post('/api/v1/ditto/names', requireSigner, nameRequestController); app.get('/api/v1/ditto/names', requireSigner, nameRequestsController); app.get('/api/v1/ditto/zap_splits', getZapSplitsController); +app.get('/api/v1/ditto/:id{[0-9a-f]{64}}/zap_splits', getZapSplitsOfStatusController); app.put('/api/v1/admin/ditto/zap_splits', requireRole('admin'), updateZapSplitsController); app.delete('/api/v1/admin/ditto/zap_splits', requireRole('admin'), deleteZapSplitsController); diff --git a/src/controllers/api/ditto.ts b/src/controllers/api/ditto.ts index ed1f500d..e3361354 100644 --- a/src/controllers/api/ditto.ts +++ b/src/controllers/api/ditto.ts @@ -5,10 +5,11 @@ import { accountFromPubkey } from '@/views/mastodon/accounts.ts'; import { AppController } from '@/app.ts'; import { addTag } from '@/utils/tags.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts'; -import { booleanParamSchema } from '@/schema.ts'; +import { booleanParamSchema, percentageSchema } from '@/schema.ts'; import { Conf } from '@/config.ts'; import { createEvent, paginated, paginationSchema, parseBody } from '@/utils/api.ts'; import { deleteTag } from '@/utils/tags.ts'; +import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { DittoZapSplits, getZapSplits } from '@/utils/zap-split.ts'; import { getAuthor } from '@/queries.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; @@ -251,3 +252,36 @@ export const getZapSplitsController: AppController = async (c) => { return c.json(zapSplits, 200); }; + +export const getZapSplitsOfStatusController: AppController = async (c) => { + const store = c.get('store'); + const id = c.req.param('id'); + const { signal } = c.req.raw; + + const [event] = await store.query([{ kinds: [1], ids: [id], limit: 1 }], { signal }); + if (!event) { + return c.json({ error: 'Event not found' }, 404); + } + + const zapsTag = event.tags.filter(([name]) => name === 'zap'); + + const pubkeys = zapsTag.map((name) => name[1]); + + const users = await store.query([{ authors: pubkeys, kinds: [0], limit: pubkeys.length }], { signal }); + await hydrateEvents({ events: users, store, signal }); + + const zapSplits = (await Promise.all(pubkeys.map(async (pubkey) => { + const author = (users.find((event) => event.pubkey === pubkey) as DittoEvent | undefined)?.author; + const account = author ? await renderAccount(author) : await accountFromPubkey(pubkey); + + const weight = percentageSchema.catch(0).parse(zapsTag.find((name) => name[1] === pubkey)![3]) ?? 0; + + return { + account, + message: '', + weight: weight, + }; + }))).filter((zapSplit) => zapSplit.weight > 0); + + return c.json(zapSplits, 200); +}; From 5d6ab9f63ba9efcc6a7f84b535f40499a1b2385b Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 6 Aug 2024 19:04:27 -0500 Subject: [PATCH 11/28] Separate listPagination --- src/app.ts | 6 +++--- src/controllers/api/statuses.ts | 2 +- src/controllers/api/suggestions.ts | 4 ++-- src/middleware/paginationMiddleware.ts | 11 ++++++++++- src/views.ts | 2 +- 5 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/app.ts b/src/app.ts index 08758503..25b7a232 100644 --- a/src/app.ts +++ b/src/app.ts @@ -4,9 +4,7 @@ import { serveStatic } from '@hono/hono/deno'; import { logger } from '@hono/hono/logger'; import { NostrEvent, NostrSigner, NStore, NUploader } from '@nostrify/nostrify'; import Debug from '@soapbox/stickynotes/debug'; -import { SetRequired } from 'type-fest'; -import { DittoPagination } from '@/interfaces/DittoPagination.ts'; import { Time } from '@/utils/time.ts'; import { @@ -137,7 +135,9 @@ interface AppEnv extends HonoEnv { /** Storage for the user, might filter out unwanted content. */ store: NStore; /** Normalized pagination params. */ - pagination: SetRequired; + pagination: { since?: number; until?: number; limit: number }; + /** Normalized list pagination params. */ + listPagination: { offset: number; limit: number }; }; } diff --git a/src/controllers/api/statuses.ts b/src/controllers/api/statuses.ts index 750f349b..00aa9a31 100644 --- a/src/controllers/api/statuses.ts +++ b/src/controllers/api/statuses.ts @@ -563,7 +563,7 @@ const zapController: AppController = async (c) => { const zappedByController: AppController = async (c) => { const id = c.req.param('id'); - const params = c.get('pagination'); + const params = c.get('listPagination'); const store = await Storages.db(); const db = await DittoDB.getInstance(); diff --git a/src/controllers/api/suggestions.ts b/src/controllers/api/suggestions.ts index 347ec827..c047c415 100644 --- a/src/controllers/api/suggestions.ts +++ b/src/controllers/api/suggestions.ts @@ -10,7 +10,7 @@ import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; export const suggestionsV1Controller: AppController = async (c) => { const signal = c.req.raw.signal; - const params = c.get('pagination'); + const params = c.get('listPagination'); const suggestions = await renderV2Suggestions(c, params, signal); const accounts = suggestions.map(({ account }) => account); return paginatedList(c, params, accounts); @@ -18,7 +18,7 @@ export const suggestionsV1Controller: AppController = async (c) => { export const suggestionsV2Controller: AppController = async (c) => { const signal = c.req.raw.signal; - const params = c.get('pagination'); + const params = c.get('listPagination'); const suggestions = await renderV2Suggestions(c, params, signal); return paginatedList(c, params, suggestions); }; diff --git a/src/middleware/paginationMiddleware.ts b/src/middleware/paginationMiddleware.ts index 6d5227db..b1f1e2f3 100644 --- a/src/middleware/paginationMiddleware.ts +++ b/src/middleware/paginationMiddleware.ts @@ -34,7 +34,16 @@ export const paginationMiddleware: AppMiddleware = async (c, next) => { } } - c.set('pagination', pagination); + c.set('pagination', { + since: pagination.since, + until: pagination.until, + limit: pagination.limit, + }); + + c.set('listPagination', { + limit: pagination.limit, + offset: pagination.offset, + }); await next(); }; diff --git a/src/views.ts b/src/views.ts index 6545ad94..e333eebd 100644 --- a/src/views.ts +++ b/src/views.ts @@ -43,7 +43,7 @@ async function renderEventAccounts(c: AppContext, filters: NostrFilter[], opts?: } async function renderAccounts(c: AppContext, pubkeys: string[]) { - const { offset, limit } = c.get('pagination'); + const { offset, limit } = c.get('listPagination'); const authors = pubkeys.reverse().slice(offset, offset + limit); const store = await Storages.db(); From 9c645cf6164d54e3e37a87bc036d5e4c3c0b8a73 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 6 Aug 2024 19:47:26 -0500 Subject: [PATCH 12/28] createAppController: parse formdata body --- src/controllers/api/apps.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/controllers/api/apps.ts b/src/controllers/api/apps.ts index 4f60a199..af755ba5 100644 --- a/src/controllers/api/apps.ts +++ b/src/controllers/api/apps.ts @@ -1,4 +1,7 @@ +import { z } from 'zod'; + import type { AppController } from '@/app.ts'; +import { parseBody } from '@/utils/api.ts'; /** * Apps are unnecessary cruft in Mastodon API, but necessary to make clients work. @@ -14,10 +17,14 @@ const FAKE_APP = { vapid_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', }; +const createAppSchema = z.object({ + redirect_uris: z.string().url().optional(), +}); + const createAppController: AppController = async (c) => { // TODO: Handle both formData and json. 422 on parsing error. try { - const { redirect_uris } = await c.req.json(); + const { redirect_uris } = createAppSchema.parse(await parseBody(c.req.raw)); return c.json({ ...FAKE_APP, From 1efd4fad1233d27fefff04d04ca178b2bbcd831c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 7 Aug 2024 12:11:13 -0500 Subject: [PATCH 13/28] search: parse bech32 ids from pasted URLs --- src/controllers/api/search.ts | 52 +++++++++++++++++++++++++---------- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/src/controllers/api/search.ts b/src/controllers/api/search.ts index 0151f7de..6ae05f1c 100644 --- a/src/controllers/api/search.ts +++ b/src/controllers/api/search.ts @@ -5,7 +5,7 @@ import { z } from 'zod'; import { AppController } from '@/app.ts'; import { booleanParamSchema } from '@/schema.ts'; import { Storages } from '@/storages.ts'; -import { dedupeEvents } from '@/utils.ts'; +import { bech32ToPubkey, dedupeEvents } from '@/utils.ts'; import { nip05Cache } from '@/utils/nip05.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts'; @@ -60,14 +60,10 @@ const searchController: AppController = async (c) => { ), ]); - if ((result.data.type === 'accounts') && (accounts.length < 1) && (result.data.q.match(/npub1\w+/))) { - const possibleNpub = result.data.q; - try { - const npubHex = nip19.decode(possibleNpub); - accounts.push(await accountFromPubkey(String(npubHex.data))); - } catch (e) { - console.log(e); - } + // Render account from pubkey. + const pubkey = bech32ToPubkey(result.data.q); + if (pubkey && !accounts.find((account) => account.id === pubkey)) { + accounts.unshift(await accountFromPubkey(pubkey)); } return c.json({ @@ -130,9 +126,11 @@ async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: Abort return filters; } - if (new RegExp(`^${nip19.BECH32_REGEX.source}$`).test(q)) { + const bech32 = extractBech32(q); + + if (bech32) { try { - const result = nip19.decode(q); + const result = nip19.decode(bech32); switch (result.type) { case 'npub': if (accounts) filters.push({ kinds: [0], authors: [result.data] }); @@ -151,10 +149,10 @@ async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: Abort } break; } - } catch (_e) { + } catch { // do nothing } - } else if (/^[0-9a-f]{64}$/.test(q)) { + } else if (n.id().safeParse(q).success) { if (accounts) filters.push({ kinds: [0], authors: [q] }); if (statuses) filters.push({ kinds: [1], ids: [q] }); } else if (accounts && ACCT_REGEX.test(q)) { @@ -163,7 +161,7 @@ async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: Abort if (pubkey) { filters.push({ kinds: [0], authors: [pubkey] }); } - } catch (_e) { + } catch { // do nothing } } @@ -171,4 +169,30 @@ async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: Abort return filters; } +/** Extract a bech32 ID out of a search query string. */ +function extractBech32(value: string): string | undefined { + let bech32: string = value; + + try { + const uri = new URL(value); + switch (uri.protocol) { + // Extract from NIP-19 URI, eg `nostr:npub1q3sle0kvfsehgsuexttt3ugjd8xdklxfwwkh559wxckmzddywnws6cd26p`. + case 'nostr:': + bech32 = uri.pathname; + break; + // Extract from URL, eg `https://njump.me/npub1q3sle0kvfsehgsuexttt3ugjd8xdklxfwwkh559wxckmzddywnws6cd26p`. + case 'http:': + case 'https:': + bech32 = uri.pathname.slice(1); + break; + } + } catch { + // do nothing + } + + if (n.bech32().safeParse(bech32).success) { + return bech32; + } +} + export { searchController }; From 10052230e5bdf6fee4e758b659ec33ff9d5f1a4c Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Wed, 7 Aug 2024 14:24:32 -0300 Subject: [PATCH 14/28] refactor: rename getZapSplitsOfStatusController to statusZapSplitsController --- src/app.ts | 4 ++-- src/controllers/api/ditto.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app.ts b/src/app.ts index 71d1b4cc..1cae8031 100644 --- a/src/app.ts +++ b/src/app.ts @@ -41,9 +41,9 @@ import { adminSetRelaysController, deleteZapSplitsController, getZapSplitsController, - getZapSplitsOfStatusController, nameRequestController, nameRequestsController, + statusZapSplitsController, updateZapSplitsController, } from '@/controllers/api/ditto.ts'; import { emptyArrayController, emptyObjectController, notImplementedController } from '@/controllers/api/fallback.ts'; @@ -266,7 +266,7 @@ app.post('/api/v1/ditto/names', requireSigner, nameRequestController); app.get('/api/v1/ditto/names', requireSigner, nameRequestsController); app.get('/api/v1/ditto/zap_splits', getZapSplitsController); -app.get('/api/v1/ditto/:id{[0-9a-f]{64}}/zap_splits', getZapSplitsOfStatusController); +app.get('/api/v1/ditto/:id{[0-9a-f]{64}}/zap_splits', statusZapSplitsController); app.put('/api/v1/admin/ditto/zap_splits', requireRole('admin'), updateZapSplitsController); app.delete('/api/v1/admin/ditto/zap_splits', requireRole('admin'), deleteZapSplitsController); diff --git a/src/controllers/api/ditto.ts b/src/controllers/api/ditto.ts index e3361354..e6715e2c 100644 --- a/src/controllers/api/ditto.ts +++ b/src/controllers/api/ditto.ts @@ -253,7 +253,7 @@ export const getZapSplitsController: AppController = async (c) => { return c.json(zapSplits, 200); }; -export const getZapSplitsOfStatusController: AppController = async (c) => { +export const statusZapSplitsController: AppController = async (c) => { const store = c.get('store'); const id = c.req.param('id'); const { signal } = c.req.raw; From cdee2604a1cd76390132fd86447d8e938d4618f7 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 7 Aug 2024 12:31:22 -0500 Subject: [PATCH 15/28] Apply same search improvements to accountSearchController --- src/controllers/api/accounts.ts | 29 ++++++++++++++++------------- src/controllers/api/search.ts | 30 ++---------------------------- src/utils.ts | 27 +++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 41 deletions(-) diff --git a/src/controllers/api/accounts.ts b/src/controllers/api/accounts.ts index 20073eec..87bef328 100644 --- a/src/controllers/api/accounts.ts +++ b/src/controllers/api/accounts.ts @@ -8,7 +8,7 @@ import { getAuthor, getFollowedPubkeys } from '@/queries.ts'; import { booleanParamSchema, fileSchema } from '@/schema.ts'; import { Storages } from '@/storages.ts'; import { uploadFile } from '@/utils/upload.ts'; -import { nostrNow } from '@/utils.ts'; +import { dedupeEvents, extractBech32, nostrNow } from '@/utils.ts'; import { createEvent, paginated, parseBody, updateListEvent } from '@/utils/api.ts'; import { lookupAccount } from '@/utils/lookup.ts'; import { renderAccounts, renderEventAccounts, renderStatuses } from '@/views.ts'; @@ -125,30 +125,33 @@ const accountSearchController: AppController = async (c) => { const query = decodeURIComponent(q); const store = await Storages.search(); + const bech32 = extractBech32(query); const [event, events] = await Promise.all([ - lookupAccount(query), + lookupAccount(bech32 ?? query), store.query([{ kinds: [0], search: query, limit }], { signal }), ]); + if (event) { + events.unshift(event); + } + const results = await hydrateEvents({ - events: event ? [event, ...events] : events, + events: dedupeEvents(events), store, signal, }); - if ((results.length < 1) && query.match(/npub1\w+/)) { - const possibleNpub = query; - try { - const npubHex = nip19.decode(possibleNpub); - return c.json([await accountFromPubkey(String(npubHex.data))]); - } catch (e) { - console.log(e); - return c.json([]); - } + const accounts = await Promise.all( + results.map((event) => renderAccount(event)), + ); + + // Render account from pubkey. + const pubkey = bech32ToPubkey(result.data.q); + if (pubkey && !accounts.find((account) => account.id === pubkey)) { + accounts.unshift(await accountFromPubkey(pubkey)); } - const accounts = await Promise.all(results.map((event) => renderAccount(event))); return c.json(accounts); }; diff --git a/src/controllers/api/search.ts b/src/controllers/api/search.ts index 6ae05f1c..3ca2aa3d 100644 --- a/src/controllers/api/search.ts +++ b/src/controllers/api/search.ts @@ -5,7 +5,7 @@ import { z } from 'zod'; import { AppController } from '@/app.ts'; import { booleanParamSchema } from '@/schema.ts'; import { Storages } from '@/storages.ts'; -import { bech32ToPubkey, dedupeEvents } from '@/utils.ts'; +import { bech32ToPubkey, dedupeEvents, extractBech32 } from '@/utils.ts'; import { nip05Cache } from '@/utils/nip05.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts'; @@ -39,7 +39,7 @@ const searchController: AppController = async (c) => { ]); if (event) { - events.push(event); + events.unshift(event); } const results = dedupeEvents(events); @@ -169,30 +169,4 @@ async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: Abort return filters; } -/** Extract a bech32 ID out of a search query string. */ -function extractBech32(value: string): string | undefined { - let bech32: string = value; - - try { - const uri = new URL(value); - switch (uri.protocol) { - // Extract from NIP-19 URI, eg `nostr:npub1q3sle0kvfsehgsuexttt3ugjd8xdklxfwwkh559wxckmzddywnws6cd26p`. - case 'nostr:': - bech32 = uri.pathname; - break; - // Extract from URL, eg `https://njump.me/npub1q3sle0kvfsehgsuexttt3ugjd8xdklxfwwkh559wxckmzddywnws6cd26p`. - case 'http:': - case 'https:': - bech32 = uri.pathname.slice(1); - break; - } - } catch { - // do nothing - } - - if (n.bech32().safeParse(bech32).success) { - return bech32; - } -} - export { searchController }; diff --git a/src/utils.ts b/src/utils.ts index e9213ed1..5abc2360 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -24,6 +24,32 @@ function bech32ToPubkey(bech32: string): string | undefined { } } +/** Extract a bech32 ID out of a search query string. */ +function extractBech32(value: string): string | undefined { + let bech32: string = value; + + try { + const uri = new URL(value); + switch (uri.protocol) { + // Extract from NIP-19 URI, eg `nostr:npub1q3sle0kvfsehgsuexttt3ugjd8xdklxfwwkh559wxckmzddywnws6cd26p`. + case 'nostr:': + bech32 = uri.pathname; + break; + // Extract from URL, eg `https://njump.me/npub1q3sle0kvfsehgsuexttt3ugjd8xdklxfwwkh559wxckmzddywnws6cd26p`. + case 'http:': + case 'https:': + bech32 = uri.pathname.slice(1); + break; + } + } catch { + // do nothing + } + + if (n.bech32().safeParse(bech32).success) { + return bech32; + } +} + interface Nip05 { /** Localpart of the nip05, eg `alex` in `alex@alexgleason.me`. */ local: string | undefined; @@ -97,6 +123,7 @@ export { bech32ToPubkey, dedupeEvents, eventAge, + extractBech32, findTag, isNostrId, isURL, From 385127761d55734413e80f5cbedb8ab70466b481 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 7 Aug 2024 14:41:16 -0500 Subject: [PATCH 16/28] Strictly follow Mastodon API's way of only returning one result of a lookup succeeds --- src/controllers/api/accounts.ts | 38 +++++++++----------------- src/controllers/api/search.ts | 47 +++++++++++++++++---------------- src/utils.ts | 8 +----- 3 files changed, 38 insertions(+), 55 deletions(-) diff --git a/src/controllers/api/accounts.ts b/src/controllers/api/accounts.ts index 87bef328..c4fd6721 100644 --- a/src/controllers/api/accounts.ts +++ b/src/controllers/api/accounts.ts @@ -8,7 +8,7 @@ import { getAuthor, getFollowedPubkeys } from '@/queries.ts'; import { booleanParamSchema, fileSchema } from '@/schema.ts'; import { Storages } from '@/storages.ts'; import { uploadFile } from '@/utils/upload.ts'; -import { dedupeEvents, extractBech32, nostrNow } from '@/utils.ts'; +import { extractBech32, nostrNow } from '@/utils.ts'; import { createEvent, paginated, parseBody, updateListEvent } from '@/utils/api.ts'; import { lookupAccount } from '@/utils/lookup.ts'; import { renderAccounts, renderEventAccounts, renderStatuses } from '@/views.ts'; @@ -110,48 +110,36 @@ const accountSearchQuerySchema = z.object({ q: z.string().transform(decodeURIComponent), resolve: booleanParamSchema.optional().transform(Boolean), following: z.boolean().default(false), - limit: z.coerce.number().catch(20).transform((value) => Math.min(Math.max(value, 0), 40)), }); const accountSearchController: AppController = async (c) => { - const result = accountSearchQuerySchema.safeParse(c.req.query()); const { signal } = c.req.raw; + const { limit } = c.get('pagination'); + + const result = accountSearchQuerySchema.safeParse(c.req.query()); if (!result.success) { return c.json({ error: 'Bad request', schema: result.error }, 422); } - const { q, limit } = result.data; - - const query = decodeURIComponent(q); + const query = decodeURIComponent(result.data.q); const store = await Storages.search(); + const bech32 = extractBech32(query); + const event = await lookupAccount(bech32 ?? query); - const [event, events] = await Promise.all([ - lookupAccount(bech32 ?? query), - store.query([{ kinds: [0], search: query, limit }], { signal }), - ]); - - if (event) { - events.unshift(event); + if (!event && bech32) { + const pubkey = bech32ToPubkey(bech32); + return c.json(pubkey ? [await accountFromPubkey(pubkey)] : []); } - const results = await hydrateEvents({ - events: dedupeEvents(events), - store, - signal, - }); + const events = await store.query([{ kinds: [0], search: query, limit }], { signal }) + .then((events) => hydrateEvents({ events, store, signal })); const accounts = await Promise.all( - results.map((event) => renderAccount(event)), + events.map((event) => renderAccount(event)), ); - // Render account from pubkey. - const pubkey = bech32ToPubkey(result.data.q); - if (pubkey && !accounts.find((account) => account.id === pubkey)) { - accounts.unshift(await accountFromPubkey(pubkey)); - } - return c.json(accounts); }; diff --git a/src/controllers/api/search.ts b/src/controllers/api/search.ts index 3ca2aa3d..075843f3 100644 --- a/src/controllers/api/search.ts +++ b/src/controllers/api/search.ts @@ -5,11 +5,11 @@ import { z } from 'zod'; import { AppController } from '@/app.ts'; import { booleanParamSchema } from '@/schema.ts'; import { Storages } from '@/storages.ts'; -import { bech32ToPubkey, dedupeEvents, extractBech32 } from '@/utils.ts'; +import { hydrateEvents } from '@/storages/hydrate.ts'; +import { bech32ToPubkey, extractBech32 } from '@/utils.ts'; import { nip05Cache } from '@/utils/nip05.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts'; -import { hydrateEvents } from '@/storages/hydrate.ts'; /** Matches NIP-05 names with or without an @ in front. */ const ACCT_REGEX = /^@?(?:([\w.+-]+)@)?([\w.-]+)$/; @@ -33,39 +33,44 @@ const searchController: AppController = async (c) => { return c.json({ error: 'Bad request', schema: result.error }, 422); } - const [event, events] = await Promise.all([ - lookupEvent(result.data, signal), - searchEvents(result.data, signal), - ]); + const event = await lookupEvent(result.data, signal); + const bech32 = extractBech32(result.data.q); - if (event) { - events.unshift(event); + // Render account from pubkey. + if (!event && bech32) { + const pubkey = bech32ToPubkey(bech32); + return c.json({ + accounts: pubkey ? [await accountFromPubkey(pubkey)] : [], + statuses: [], + hashtags: [], + }); + } + + let events: NostrEvent[] = []; + + if (event) { + events = [event]; + } else { + events = await searchEvents(result.data, signal); } - const results = dedupeEvents(events); const viewerPubkey = await c.get('signer')?.getPublicKey(); const [accounts, statuses] = await Promise.all([ Promise.all( - results + events .filter((event) => event.kind === 0) .map((event) => renderAccount(event)) .filter(Boolean), ), Promise.all( - results + events .filter((event) => event.kind === 1) .map((event) => renderStatus(event, { viewerPubkey })) .filter(Boolean), ), ]); - // Render account from pubkey. - const pubkey = bech32ToPubkey(result.data.q); - if (pubkey && !accounts.find((account) => account.id === pubkey)) { - accounts.unshift(await accountFromPubkey(pubkey)); - } - return c.json({ accounts, statuses, @@ -139,14 +144,10 @@ async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: Abort if (accounts) filters.push({ kinds: [0], authors: [result.data.pubkey] }); break; case 'note': - if (statuses) { - filters.push({ kinds: [1], ids: [result.data] }); - } + if (statuses) filters.push({ kinds: [1], ids: [result.data] }); break; case 'nevent': - if (statuses) { - filters.push({ kinds: [1], ids: [result.data.id] }); - } + if (statuses) filters.push({ kinds: [1], ids: [result.data.id] }); break; } } catch { diff --git a/src/utils.ts b/src/utils.ts index 5abc2360..eddf2623 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -19,7 +19,7 @@ function bech32ToPubkey(bech32: string): string | undefined { case 'npub': return decoded.data; } - } catch (_) { + } catch { // } } @@ -104,11 +104,6 @@ async function sha256(message: string): Promise { return hashHex; } -/** Deduplicate events by ID. */ -function dedupeEvents(events: NostrEvent[]): NostrEvent[] { - return [...new Map(events.map((event) => [event.id, event])).values()]; -} - /** Test whether the value is a Nostr ID. */ function isNostrId(value: unknown): boolean { return n.id().safeParse(value).success; @@ -121,7 +116,6 @@ function isURL(value: unknown): boolean { export { bech32ToPubkey, - dedupeEvents, eventAge, extractBech32, findTag, From 8f704e4ea2ccaf8de692c3a8fa90fcf0c99948b1 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 7 Aug 2024 14:52:13 -0500 Subject: [PATCH 17/28] Fix account URL lookup with @ in the URL --- src/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils.ts b/src/utils.ts index eddf2623..3dae4e1c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -38,7 +38,7 @@ function extractBech32(value: string): string | undefined { // Extract from URL, eg `https://njump.me/npub1q3sle0kvfsehgsuexttt3ugjd8xdklxfwwkh559wxckmzddywnws6cd26p`. case 'http:': case 'https:': - bech32 = uri.pathname.slice(1); + bech32 = uri.pathname.replace(/^\/@?/, ''); break; } } catch { From dbd40357af3053662bc9ae57f393dc6e06482427 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 7 Aug 2024 15:23:33 -0500 Subject: [PATCH 18/28] Mimic Mastodon's uri/url fields exactly on Accounts and Statuses --- src/entities/MastodonAccount.ts | 1 + src/views/mastodon/accounts.ts | 6 ++++-- src/views/mastodon/statuses.ts | 4 ++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/entities/MastodonAccount.ts b/src/entities/MastodonAccount.ts index a7fef5de..89d08d72 100644 --- a/src/entities/MastodonAccount.ts +++ b/src/entities/MastodonAccount.ts @@ -36,6 +36,7 @@ export interface MastodonAccount { }; }; statuses_count: number; + uri: string; url: string; username: string; ditto: { diff --git a/src/views/mastodon/accounts.ts b/src/views/mastodon/accounts.ts index 5abb1aca..4e72d35c 100644 --- a/src/views/mastodon/accounts.ts +++ b/src/views/mastodon/accounts.ts @@ -42,10 +42,11 @@ async function renderAccount( const npub = nip19.npubEncode(pubkey); const parsed05 = await parseAndVerifyNip05(nip05, pubkey); + const acct = parsed05?.handle || npub; return { id: pubkey, - acct: parsed05?.handle || npub, + acct, avatar: picture, avatar_static: picture, bot: false, @@ -78,7 +79,8 @@ async function renderAccount( } : undefined, statuses_count: event.author_stats?.notes_count ?? 0, - url: Conf.local(`/users/${pubkey}`), + uri: Conf.local(`/users/${acct}`), + url: Conf.local(`/@${acct}`), username: parsed05?.nickname || npub.substring(0, 8), ditto: { accepts_zaps: Boolean(getLnurl({ lud06, lud16 })), diff --git a/src/views/mastodon/statuses.ts b/src/views/mastodon/statuses.ts index 2fa8f313..27bffdfb 100644 --- a/src/views/mastodon/statuses.ts +++ b/src/views/mastodon/statuses.ts @@ -121,8 +121,8 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise< poll: null, quote: !event.quote ? null : await renderStatus(event.quote, { depth: depth + 1 }), quote_id: event.quote?.id ?? null, - uri: Conf.local(`/${note}`), - url: Conf.local(`/${note}`), + uri: Conf.local(`/users/${account.acct}/statuses/${event.id}`), + url: Conf.local(`/@${account.acct}/${event.id}`), zapped: Boolean(zapEvent), ditto: { external_url: Conf.external(note), From ff900341d51a1c70e935f78dff3271e27d345907 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 7 Aug 2024 15:53:59 -0500 Subject: [PATCH 19/28] Match every possible goddamn URL format in search --- deno.json | 1 + deno.lock | 6 ++++++ src/utils.ts | 24 ++++++++++++++++++++++-- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/deno.json b/deno.json index 0c41a378..ed5eca51 100644 --- a/deno.json +++ b/deno.json @@ -65,6 +65,7 @@ "nostr-relaypool": "npm:nostr-relaypool2@0.6.34", "nostr-tools": "npm:nostr-tools@2.5.1", "nostr-wasm": "npm:nostr-wasm@^0.1.0", + "path-to-regexp": "npm:path-to-regexp@^7.1.0", "postgres": "https://raw.githubusercontent.com/xyzshantaram/postgres.js/8a9bbce88b3f6425ecaacd99a80372338b157a53/deno/mod.js", "prom-client": "npm:prom-client@^15.1.2", "question-deno": "https://raw.githubusercontent.com/ocpu/question-deno/10022b8e52555335aa510adb08b0a300df3cf904/mod.ts", diff --git a/deno.lock b/deno.lock index 101362a4..880f8d69 100644 --- a/deno.lock +++ b/deno.lock @@ -73,6 +73,7 @@ "npm:nostr-tools@^2.5.0": "npm:nostr-tools@2.5.1", "npm:nostr-tools@^2.7.0": "npm:nostr-tools@2.7.0", "npm:nostr-wasm@^0.1.0": "npm:nostr-wasm@0.1.0", + "npm:path-to-regexp@^7.1.0": "npm:path-to-regexp@7.1.0", "npm:postgres@3.4.4": "npm:postgres@3.4.4", "npm:prom-client@^15.1.2": "npm:prom-client@15.1.2", "npm:tldts@^6.0.14": "npm:tldts@6.1.18", @@ -947,6 +948,10 @@ "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", "dependencies": {} }, + "path-to-regexp@7.1.0": { + "integrity": "sha512-ZToe+MbUF4lBqk6dV8GKot4DKfzrxXsplOddH8zN3YK+qw9/McvP7+4ICjZvOne0jQhN4eJwHsX6tT0Ns19fvw==", + "dependencies": {} + }, "picomatch@2.3.1": { "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dependencies": {} @@ -1856,6 +1861,7 @@ "npm:nostr-relaypool2@0.6.34", "npm:nostr-tools@2.5.1", "npm:nostr-wasm@^0.1.0", + "npm:path-to-regexp@^7.1.0", "npm:prom-client@^15.1.2", "npm:tldts@^6.0.14", "npm:tseep@^1.2.1", diff --git a/src/utils.ts b/src/utils.ts index 3dae4e1c..6613ac5d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,6 @@ import { NostrEvent, NSchema as n } from '@nostrify/nostrify'; import { nip19 } from 'nostr-tools'; +import { match } from 'path-to-regexp'; import { z } from 'zod'; /** Get the current time in Nostr format. */ @@ -37,9 +38,28 @@ function extractBech32(value: string): string | undefined { break; // Extract from URL, eg `https://njump.me/npub1q3sle0kvfsehgsuexttt3ugjd8xdklxfwwkh559wxckmzddywnws6cd26p`. case 'http:': - case 'https:': - bech32 = uri.pathname.replace(/^\/@?/, ''); + case 'https:': { + const accountUriMatch = match<{ acct: string }>('/users/:acct')(uri.pathname); + const accountUrlMatch = match<{ acct: string }>('/@:acct')(uri.pathname); + const statusUriMatch = match<{ acct: string; id: string }>('/users/:acct/statuses/:id')(uri.pathname); + const statusUrlMatch = match<{ acct: string; id: string }>('/@:acct/:id')(uri.pathname); + const soapboxMatch = match<{ acct: string; id: string }>('/@:acct/posts/:id')(uri.pathname); + const nostrMatch = match<{ bech32: string }>('/:bech32')(uri.pathname); + if (accountUriMatch) { + bech32 = accountUriMatch.params.acct; + } else if (accountUrlMatch) { + bech32 = accountUrlMatch.params.acct; + } else if (statusUriMatch) { + bech32 = nip19.noteEncode(statusUriMatch.params.id); + } else if (statusUrlMatch) { + bech32 = nip19.noteEncode(statusUrlMatch.params.id); + } else if (soapboxMatch) { + bech32 = nip19.noteEncode(soapboxMatch.params.id); + } else if (nostrMatch) { + bech32 = nostrMatch.params.bech32; + } break; + } } } catch { // do nothing From d3780037dfded190e74181c334738a3904f9d00e Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 7 Aug 2024 16:05:04 -0500 Subject: [PATCH 20/28] search: escape @ signs in matchers --- src/utils.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index 6613ac5d..d40b2eb6 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -40,10 +40,10 @@ function extractBech32(value: string): string | undefined { case 'http:': case 'https:': { const accountUriMatch = match<{ acct: string }>('/users/:acct')(uri.pathname); - const accountUrlMatch = match<{ acct: string }>('/@:acct')(uri.pathname); + const accountUrlMatch = match<{ acct: string }>('/\\@:acct')(uri.pathname); const statusUriMatch = match<{ acct: string; id: string }>('/users/:acct/statuses/:id')(uri.pathname); - const statusUrlMatch = match<{ acct: string; id: string }>('/@:acct/:id')(uri.pathname); - const soapboxMatch = match<{ acct: string; id: string }>('/@:acct/posts/:id')(uri.pathname); + const statusUrlMatch = match<{ acct: string; id: string }>('/\\@:acct/:id')(uri.pathname); + const soapboxMatch = match<{ acct: string; id: string }>('/\\@:acct/posts/:id')(uri.pathname); const nostrMatch = match<{ bech32: string }>('/:bech32')(uri.pathname); if (accountUriMatch) { bech32 = accountUriMatch.params.acct; From bc603188fa6ce59adc3cc677541c0efcfe20e4ba Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 7 Aug 2024 16:22:10 -0500 Subject: [PATCH 21/28] extractBech32 -> extractIdentifier, support extracting nip05 names --- src/controllers/api/accounts.ts | 12 +++---- src/controllers/api/search.ts | 19 +++++------ src/utils.ts | 60 +-------------------------------- src/utils/lookup.ts | 52 ++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 76 deletions(-) diff --git a/src/controllers/api/accounts.ts b/src/controllers/api/accounts.ts index c4fd6721..d55e0c19 100644 --- a/src/controllers/api/accounts.ts +++ b/src/controllers/api/accounts.ts @@ -8,9 +8,9 @@ import { getAuthor, getFollowedPubkeys } from '@/queries.ts'; import { booleanParamSchema, fileSchema } from '@/schema.ts'; import { Storages } from '@/storages.ts'; import { uploadFile } from '@/utils/upload.ts'; -import { extractBech32, nostrNow } from '@/utils.ts'; +import { nostrNow } from '@/utils.ts'; import { createEvent, paginated, parseBody, updateListEvent } from '@/utils/api.ts'; -import { lookupAccount } from '@/utils/lookup.ts'; +import { extractIdentifier, lookupAccount } from '@/utils/lookup.ts'; import { renderAccounts, renderEventAccounts, renderStatuses } from '@/views.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { renderRelationship } from '@/views/mastodon/relationships.ts'; @@ -125,11 +125,11 @@ const accountSearchController: AppController = async (c) => { const query = decodeURIComponent(result.data.q); const store = await Storages.search(); - const bech32 = extractBech32(query); - const event = await lookupAccount(bech32 ?? query); + const lookup = extractIdentifier(query); + const event = await lookupAccount(lookup ?? query); - if (!event && bech32) { - const pubkey = bech32ToPubkey(bech32); + if (!event && lookup) { + const pubkey = bech32ToPubkey(lookup); return c.json(pubkey ? [await accountFromPubkey(pubkey)] : []); } diff --git a/src/controllers/api/search.ts b/src/controllers/api/search.ts index 075843f3..ef7f84c7 100644 --- a/src/controllers/api/search.ts +++ b/src/controllers/api/search.ts @@ -6,14 +6,12 @@ import { AppController } from '@/app.ts'; import { booleanParamSchema } from '@/schema.ts'; import { Storages } from '@/storages.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; -import { bech32ToPubkey, extractBech32 } from '@/utils.ts'; +import { bech32ToPubkey } from '@/utils.ts'; +import { ACCT_REGEX, extractIdentifier } from '@/utils/lookup.ts'; import { nip05Cache } from '@/utils/nip05.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts'; -/** Matches NIP-05 names with or without an @ in front. */ -const ACCT_REGEX = /^@?(?:([\w.+-]+)@)?([\w.-]+)$/; - const searchQuerySchema = z.object({ q: z.string().transform(decodeURIComponent), type: z.enum(['accounts', 'statuses', 'hashtags']).optional(), @@ -34,11 +32,11 @@ const searchController: AppController = async (c) => { } const event = await lookupEvent(result.data, signal); - const bech32 = extractBech32(result.data.q); + const lookup = extractIdentifier(result.data.q); // Render account from pubkey. - if (!event && bech32) { - const pubkey = bech32ToPubkey(bech32); + if (!event && lookup) { + const pubkey = bech32ToPubkey(lookup); return c.json({ accounts: pubkey ? [await accountFromPubkey(pubkey)] : [], statuses: [], @@ -131,11 +129,10 @@ async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: Abort return filters; } - const bech32 = extractBech32(q); - - if (bech32) { + const lookup = extractIdentifier(q); + if (lookup) { try { - const result = nip19.decode(bech32); + const result = nip19.decode(lookup); switch (result.type) { case 'npub': if (accounts) filters.push({ kinds: [0], authors: [result.data] }); diff --git a/src/utils.ts b/src/utils.ts index d40b2eb6..e361109d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,5 @@ import { NostrEvent, NSchema as n } from '@nostrify/nostrify'; import { nip19 } from 'nostr-tools'; -import { match } from 'path-to-regexp'; import { z } from 'zod'; /** Get the current time in Nostr format. */ @@ -25,51 +24,6 @@ function bech32ToPubkey(bech32: string): string | undefined { } } -/** Extract a bech32 ID out of a search query string. */ -function extractBech32(value: string): string | undefined { - let bech32: string = value; - - try { - const uri = new URL(value); - switch (uri.protocol) { - // Extract from NIP-19 URI, eg `nostr:npub1q3sle0kvfsehgsuexttt3ugjd8xdklxfwwkh559wxckmzddywnws6cd26p`. - case 'nostr:': - bech32 = uri.pathname; - break; - // Extract from URL, eg `https://njump.me/npub1q3sle0kvfsehgsuexttt3ugjd8xdklxfwwkh559wxckmzddywnws6cd26p`. - case 'http:': - case 'https:': { - const accountUriMatch = match<{ acct: string }>('/users/:acct')(uri.pathname); - const accountUrlMatch = match<{ acct: string }>('/\\@:acct')(uri.pathname); - const statusUriMatch = match<{ acct: string; id: string }>('/users/:acct/statuses/:id')(uri.pathname); - const statusUrlMatch = match<{ acct: string; id: string }>('/\\@:acct/:id')(uri.pathname); - const soapboxMatch = match<{ acct: string; id: string }>('/\\@:acct/posts/:id')(uri.pathname); - const nostrMatch = match<{ bech32: string }>('/:bech32')(uri.pathname); - if (accountUriMatch) { - bech32 = accountUriMatch.params.acct; - } else if (accountUrlMatch) { - bech32 = accountUrlMatch.params.acct; - } else if (statusUriMatch) { - bech32 = nip19.noteEncode(statusUriMatch.params.id); - } else if (statusUrlMatch) { - bech32 = nip19.noteEncode(statusUrlMatch.params.id); - } else if (soapboxMatch) { - bech32 = nip19.noteEncode(soapboxMatch.params.id); - } else if (nostrMatch) { - bech32 = nostrMatch.params.bech32; - } - break; - } - } - } catch { - // do nothing - } - - if (n.bech32().safeParse(bech32).success) { - return bech32; - } -} - interface Nip05 { /** Localpart of the nip05, eg `alex` in `alex@alexgleason.me`. */ local: string | undefined; @@ -134,18 +88,6 @@ function isURL(value: unknown): boolean { return z.string().url().safeParse(value).success; } -export { - bech32ToPubkey, - eventAge, - extractBech32, - findTag, - isNostrId, - isURL, - type Nip05, - nostrDate, - nostrNow, - parseNip05, - sha256, -}; +export { bech32ToPubkey, eventAge, findTag, isNostrId, isURL, type Nip05, nostrDate, nostrNow, parseNip05, sha256 }; export { Time } from '@/utils/time.ts'; diff --git a/src/utils/lookup.ts b/src/utils/lookup.ts index 90b30c2b..afcd384d 100644 --- a/src/utils/lookup.ts +++ b/src/utils/lookup.ts @@ -1,10 +1,15 @@ import { NIP05, NostrEvent, NSchema as n } from '@nostrify/nostrify'; +import { nip19 } from 'nostr-tools'; +import { match } from 'path-to-regexp'; import { getAuthor } from '@/queries.ts'; import { bech32ToPubkey } from '@/utils.ts'; import { nip05Cache } from '@/utils/nip05.ts'; import { Stickynotes } from '@soapbox/stickynotes'; +/** Matches NIP-05 names with or without an @ in front. */ +export const ACCT_REGEX = /^@?(?:([\w.+-]+)@)?([\w.-]+)$/; + /** Resolve a bech32 or NIP-05 identifier to an account. */ export async function lookupAccount( value: string, @@ -35,3 +40,50 @@ export async function lookupPubkey(value: string, signal?: AbortSignal): Promise } } } + +/** Extract an acct or bech32 identifier out of a URL or of itself. */ +export function extractIdentifier(value: string): string | undefined { + try { + const uri = new URL(value); + switch (uri.protocol) { + // Extract from NIP-19 URI, eg `nostr:npub1q3sle0kvfsehgsuexttt3ugjd8xdklxfwwkh559wxckmzddywnws6cd26p`. + case 'nostr:': + value = uri.pathname; + break; + // Extract from URL, eg `https://njump.me/npub1q3sle0kvfsehgsuexttt3ugjd8xdklxfwwkh559wxckmzddywnws6cd26p`. + case 'http:': + case 'https:': { + const accountUriMatch = match<{ acct: string }>('/users/:acct')(uri.pathname); + const accountUrlMatch = match<{ acct: string }>('/\\@:acct')(uri.pathname); + const statusUriMatch = match<{ acct: string; id: string }>('/users/:acct/statuses/:id')(uri.pathname); + const statusUrlMatch = match<{ acct: string; id: string }>('/\\@:acct/:id')(uri.pathname); + const soapboxMatch = match<{ acct: string; id: string }>('/\\@:acct/posts/:id')(uri.pathname); + const nostrMatch = match<{ bech32: string }>('/:bech32')(uri.pathname); + if (accountUriMatch) { + value = accountUriMatch.params.acct; + } else if (accountUrlMatch) { + value = accountUrlMatch.params.acct; + } else if (statusUriMatch) { + value = nip19.noteEncode(statusUriMatch.params.id); + } else if (statusUrlMatch) { + value = nip19.noteEncode(statusUrlMatch.params.id); + } else if (soapboxMatch) { + value = nip19.noteEncode(soapboxMatch.params.id); + } else if (nostrMatch) { + value = nostrMatch.params.bech32; + } + break; + } + } + } catch { + // do nothing + } + + if (n.bech32().safeParse(value).success) { + return value; + } + + if (ACCT_REGEX.test(value)) { + return value; + } +} From 8f5ec50a25594d17b44ccb9182384051db6e4763 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 7 Aug 2024 16:41:46 -0500 Subject: [PATCH 22/28] search: fix nip05 lookups --- src/controllers/api/search.ts | 77 ++++++++++++++++++----------------- src/utils/lookup.ts | 9 ++-- 2 files changed, 45 insertions(+), 41 deletions(-) diff --git a/src/controllers/api/search.ts b/src/controllers/api/search.ts index ef7f84c7..23eba60f 100644 --- a/src/controllers/api/search.ts +++ b/src/controllers/api/search.ts @@ -6,8 +6,7 @@ import { AppController } from '@/app.ts'; import { booleanParamSchema } from '@/schema.ts'; import { Storages } from '@/storages.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; -import { bech32ToPubkey } from '@/utils.ts'; -import { ACCT_REGEX, extractIdentifier } from '@/utils/lookup.ts'; +import { extractIdentifier, lookupPubkey } from '@/utils/lookup.ts'; import { nip05Cache } from '@/utils/nip05.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts'; @@ -36,7 +35,7 @@ const searchController: AppController = async (c) => { // Render account from pubkey. if (!event && lookup) { - const pubkey = bech32ToPubkey(lookup); + const pubkey = await lookupPubkey(lookup); return c.json({ accounts: pubkey ? [await accountFromPubkey(pubkey)] : [], statuses: [], @@ -120,51 +119,55 @@ async function lookupEvent(query: SearchQuery, signal: AbortSignal): Promise { - const filters: NostrFilter[] = []; - const accounts = !type || type === 'accounts'; const statuses = !type || type === 'statuses'; if (!resolve || type === 'hashtags') { + return []; + } + + if (n.id().safeParse(q).success) { + const filters: NostrFilter[] = []; + if (accounts) filters.push({ kinds: [0], authors: [q] }); + if (statuses) filters.push({ kinds: [1], ids: [q] }); return filters; } const lookup = extractIdentifier(q); - if (lookup) { - try { - const result = nip19.decode(lookup); - switch (result.type) { - case 'npub': - if (accounts) filters.push({ kinds: [0], authors: [result.data] }); - break; - case 'nprofile': - if (accounts) filters.push({ kinds: [0], authors: [result.data.pubkey] }); - break; - case 'note': - if (statuses) filters.push({ kinds: [1], ids: [result.data] }); - break; - case 'nevent': - if (statuses) filters.push({ kinds: [1], ids: [result.data.id] }); - break; - } - } catch { - // do nothing - } - } else if (n.id().safeParse(q).success) { - if (accounts) filters.push({ kinds: [0], authors: [q] }); - if (statuses) filters.push({ kinds: [1], ids: [q] }); - } else if (accounts && ACCT_REGEX.test(q)) { - try { - const { pubkey } = await nip05Cache.fetch(q, { signal }); - if (pubkey) { - filters.push({ kinds: [0], authors: [pubkey] }); - } - } catch { - // do nothing + if (!lookup) return []; + + try { + const result = nip19.decode(lookup); + const filters: NostrFilter[] = []; + switch (result.type) { + case 'npub': + if (accounts) filters.push({ kinds: [0], authors: [result.data] }); + break; + case 'nprofile': + if (accounts) filters.push({ kinds: [0], authors: [result.data.pubkey] }); + break; + case 'note': + if (statuses) filters.push({ kinds: [1], ids: [result.data] }); + break; + case 'nevent': + if (statuses) filters.push({ kinds: [1], ids: [result.data.id] }); + break; } + return filters; + } catch { + // do nothing } - return filters; + try { + const { pubkey } = await nip05Cache.fetch(lookup, { signal }); + if (pubkey) { + return [{ kinds: [0], authors: [pubkey] }]; + } + } catch { + // do nothing + } + + return []; } export { searchController }; diff --git a/src/utils/lookup.ts b/src/utils/lookup.ts index afcd384d..a824949a 100644 --- a/src/utils/lookup.ts +++ b/src/utils/lookup.ts @@ -7,9 +7,6 @@ import { bech32ToPubkey } from '@/utils.ts'; import { nip05Cache } from '@/utils/nip05.ts'; import { Stickynotes } from '@soapbox/stickynotes'; -/** Matches NIP-05 names with or without an @ in front. */ -export const ACCT_REGEX = /^@?(?:([\w.+-]+)@)?([\w.-]+)$/; - /** Resolve a bech32 or NIP-05 identifier to an account. */ export async function lookupAccount( value: string, @@ -43,6 +40,8 @@ export async function lookupPubkey(value: string, signal?: AbortSignal): Promise /** Extract an acct or bech32 identifier out of a URL or of itself. */ export function extractIdentifier(value: string): string | undefined { + value = value.trim(); + try { const uri = new URL(value); switch (uri.protocol) { @@ -79,11 +78,13 @@ export function extractIdentifier(value: string): string | undefined { // do nothing } + value = value.replace(/^@/, ''); + if (n.bech32().safeParse(value).success) { return value; } - if (ACCT_REGEX.test(value)) { + if (NIP05.regex().test(value)) { return value; } } From fd90c199f5ef3a13bf63954e7270b06b49d2174c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 7 Aug 2024 16:44:32 -0500 Subject: [PATCH 23/28] search: fix lookupPubkey for account search endpoint --- src/controllers/api/accounts.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/controllers/api/accounts.ts b/src/controllers/api/accounts.ts index d55e0c19..221813ac 100644 --- a/src/controllers/api/accounts.ts +++ b/src/controllers/api/accounts.ts @@ -10,7 +10,7 @@ import { Storages } from '@/storages.ts'; import { uploadFile } from '@/utils/upload.ts'; import { nostrNow } from '@/utils.ts'; import { createEvent, paginated, parseBody, updateListEvent } from '@/utils/api.ts'; -import { extractIdentifier, lookupAccount } from '@/utils/lookup.ts'; +import { extractIdentifier, lookupAccount, lookupPubkey } from '@/utils/lookup.ts'; import { renderAccounts, renderEventAccounts, renderStatuses } from '@/views.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { renderRelationship } from '@/views/mastodon/relationships.ts'; @@ -129,7 +129,7 @@ const accountSearchController: AppController = async (c) => { const event = await lookupAccount(lookup ?? query); if (!event && lookup) { - const pubkey = bech32ToPubkey(lookup); + const pubkey = await lookupPubkey(lookup); return c.json(pubkey ? [await accountFromPubkey(pubkey)] : []); } From a24c119c7b503964d00cc3c29d302afb1398beec Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 7 Aug 2024 16:56:15 -0500 Subject: [PATCH 24/28] accountSearchController: actually use the looked up event, whoops --- src/controllers/api/accounts.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/controllers/api/accounts.ts b/src/controllers/api/accounts.ts index 221813ac..7d9e7641 100644 --- a/src/controllers/api/accounts.ts +++ b/src/controllers/api/accounts.ts @@ -133,11 +133,13 @@ const accountSearchController: AppController = async (c) => { return c.json(pubkey ? [await accountFromPubkey(pubkey)] : []); } - const events = await store.query([{ kinds: [0], search: query, limit }], { signal }) - .then((events) => hydrateEvents({ events, store, signal })); + const events = event ? [event] : await store.query([{ kinds: [0], search: query, limit }], { signal }); - const accounts = await Promise.all( - events.map((event) => renderAccount(event)), + const accounts = await hydrateEvents({ events, store, signal }).then( + (events) => + Promise.all( + events.map((event) => renderAccount(event)), + ), ); return c.json(accounts); From 529e61be6d286e6d64ad24ee9d9cd78b98b0ee68 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 7 Aug 2024 17:27:22 -0500 Subject: [PATCH 25/28] Return properly formatted mentions in Status API --- src/utils/note.ts | 66 +++++++++++++++++----------------- src/views/mastodon/statuses.ts | 38 ++++++++------------ 2 files changed, 48 insertions(+), 56 deletions(-) diff --git a/src/utils/note.ts b/src/utils/note.ts index 6e0d8d41..00be4b1a 100644 --- a/src/utils/note.ts +++ b/src/utils/note.ts @@ -4,40 +4,12 @@ import linkify from 'linkifyjs'; import { nip19, nip21, nip27 } from 'nostr-tools'; import { Conf } from '@/config.ts'; +import { MastodonMention } from '@/entities/MastodonMention.ts'; import { getUrlMediaType, isPermittedMediaType } from '@/utils/media.ts'; linkify.registerCustomProtocol('nostr', true); linkify.registerCustomProtocol('wss'); -const linkifyOpts: linkify.Opts = { - render: { - hashtag: ({ content }) => { - const tag = content.replace(/^#/, ''); - const href = Conf.local(`/tags/${tag}`); - return `#${tag}`; - }, - url: ({ attributes, content }) => { - try { - const { decoded } = nip21.parse(content); - const pubkey = getDecodedPubkey(decoded); - if (pubkey) { - const name = pubkey.substring(0, 8); - const href = Conf.local(`/users/${pubkey}`); - return `@${name}`; - } else { - return ''; - } - } catch { - const attr = Object.entries(attributes) - .map(([name, value]) => `${name}="${value}"`) - .join(' '); - - return `${content}`; - } - }, - }, -}; - type Link = ReturnType[0]; interface ParsedNoteContent { @@ -48,12 +20,42 @@ interface ParsedNoteContent { } /** Convert Nostr content to Mastodon API HTML. Also return parsed data. */ -function parseNoteContent(content: string): ParsedNoteContent { - // Parsing twice is ineffecient, but I don't know how to do only once. - const html = linkifyStr(content, linkifyOpts).replace(/\n+$/, ''); +function parseNoteContent(content: string, mentions: MastodonMention[]): ParsedNoteContent { const links = linkify.find(content).filter(isLinkURL); const firstUrl = links.find(isNonMediaLink)?.href; + const html = linkifyStr(content, { + render: { + hashtag: ({ content }) => { + const tag = content.replace(/^#/, ''); + const href = Conf.local(`/tags/${tag}`); + return `#${tag}`; + }, + url: ({ attributes, content }) => { + try { + const { decoded } = nip21.parse(content); + const pubkey = getDecodedPubkey(decoded); + if (pubkey) { + const mention = mentions.find((m) => m.id === pubkey); + const npub = nip19.npubEncode(pubkey); + const acct = mention?.acct ?? npub; + const name = mention?.acct ?? npub.substring(0, 8); + const href = mention?.url ?? Conf.local(`/@${acct}`); + return `@${name}`; + } else { + return ''; + } + } catch { + const attr = Object.entries(attributes) + .map(([name, value]) => `${name}="${value}"`) + .join(' '); + + return `${content}`; + } + }, + }, + }).replace(/\n+$/, ''); + return { html, links, diff --git a/src/views/mastodon/statuses.ts b/src/views/mastodon/statuses.ts index 27bffdfb..7583ea95 100644 --- a/src/views/mastodon/statuses.ts +++ b/src/views/mastodon/statuses.ts @@ -46,13 +46,14 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise< [{ kinds: [0], authors: mentionedPubkeys, limit: mentionedPubkeys.length }], ); - const { html, links, firstUrl } = parseNoteContent(stripimeta(event.content, event.tags)); + const mentions = await Promise.all( + mentionedPubkeys.map((pubkey) => renderMention(pubkey, mentionedProfiles.find((event) => event.pubkey === pubkey))), + ); - const [mentions, card, relatedEvents] = await Promise + const { html, links, firstUrl } = parseNoteContent(stripimeta(event.content, event.tags), mentions); + + const [card, relatedEvents] = await Promise .all([ - Promise.all( - mentionedPubkeys.map((pubkey) => toMention(pubkey, mentionedProfiles.find((event) => event.pubkey === pubkey))), - ), firstUrl ? unfurlCardCached(firstUrl) : null, viewerPubkey ? await store.query([ @@ -152,25 +153,14 @@ async function renderReblog(event: DittoEvent, opts: RenderStatusOpts): Promise< }; } -async function toMention(pubkey: string, event?: NostrEvent): Promise { - const account = event ? await renderAccount(event) : undefined; - - if (account) { - return { - id: account.id, - acct: account.acct, - username: account.username, - url: account.url, - }; - } else { - const npub = nip19.npubEncode(pubkey); - return { - id: pubkey, - acct: npub, - username: npub.substring(0, 8), - url: Conf.local(`/users/${pubkey}`), - }; - } +async function renderMention(pubkey: string, event?: NostrEvent): Promise { + const account = event ? await renderAccount(event) : await accountFromPubkey(pubkey); + return { + id: account.id, + acct: account.acct, + username: account.username, + url: account.url, + }; } function buildInlineRecipients(mentions: MastodonMention[]): string { From 2f5b4557b77ada4af59c2e730933437d0ebf27d5 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 7 Aug 2024 17:34:36 -0500 Subject: [PATCH 26/28] compatMentions: remove post author and explicit text mentions --- src/views/mastodon/statuses.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/views/mastodon/statuses.ts b/src/views/mastodon/statuses.ts index 7583ea95..86a79cac 100644 --- a/src/views/mastodon/statuses.ts +++ b/src/views/mastodon/statuses.ts @@ -72,7 +72,11 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise< const bookmarkEvent = relatedEvents.find((event) => event.kind === 10003); const zapEvent = relatedEvents.find((event) => event.kind === 9734); - const content = buildInlineRecipients(mentions) + html; + const compatMentions = buildInlineRecipients(mentions.filter((m) => { + if (m.id === account.id) return false; + if (html.includes(m.url)) return false; + return true; + })); const cw = event.tags.find(([name]) => name === 'content-warning'); const subject = event.tags.find(([name]) => name === 'subject'); @@ -96,7 +100,7 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise< id: event.id, account, card, - content, + content: compatMentions + html, created_at: nostrDate(event.created_at).toISOString(), in_reply_to_id: replyId ?? null, in_reply_to_account_id: null, From ab17be219c24f53e06078f7d6ad8e4a627381473 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 7 Aug 2024 17:37:44 -0500 Subject: [PATCH 27/28] Fix parseNoteContent test --- src/utils/note.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/note.test.ts b/src/utils/note.test.ts index b351dbfd..0c9c6bf8 100644 --- a/src/utils/note.test.ts +++ b/src/utils/note.test.ts @@ -4,7 +4,7 @@ import { eventFixture } from '@/test.ts'; import { getMediaLinks, parseNoteContent, stripimeta } from '@/utils/note.ts'; Deno.test('parseNoteContent', () => { - const { html, links, firstUrl } = parseNoteContent('Hello, world!'); + const { html, links, firstUrl } = parseNoteContent('Hello, world!', []); assertEquals(html, 'Hello, world!'); assertEquals(links, []); assertEquals(firstUrl, undefined); From 2e38a7b9ec303bcaf4d0a9227468dc5087c3db60 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 7 Aug 2024 17:54:45 -0500 Subject: [PATCH 28/28] mentionsCompat: wrap each mention in an h-card span --- src/views/mastodon/statuses.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/mastodon/statuses.ts b/src/views/mastodon/statuses.ts index 86a79cac..f5d8d5bb 100644 --- a/src/views/mastodon/statuses.ts +++ b/src/views/mastodon/statuses.ts @@ -172,7 +172,7 @@ function buildInlineRecipients(mentions: MastodonMention[]): string { const elements = mentions.reduce((acc, { url, username }) => { const name = nip19.BECH32_REGEX.test(username) ? username.substring(0, 8) : username; - acc.push(`@${name}`); + acc.push(`@${name}`); return acc; }, []);