diff --git a/deno.json b/deno.json index 317826f5..ed5eca51 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", @@ -64,7 +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@6.2.1", + "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 dc6ecaa2..880f8d69 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", @@ -71,8 +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@6.2.1": "npm:path-to-regexp@6.2.1", - "npm:path-to-regexp@7.1.0": "npm:path-to-regexp@7.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", @@ -137,6 +138,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": [ @@ -166,21 +176,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": [ @@ -935,10 +948,6 @@ "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", "dependencies": {} }, - "path-to-regexp@6.2.1": { - "integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==", - "dependencies": {} - }, "path-to-regexp@7.1.0": { "integrity": "sha512-ZToe+MbUF4lBqk6dV8GKot4DKfzrxXsplOddH8zN3YK+qw9/McvP7+4ICjZvOne0jQhN4eJwHsX6tT0Ns19fvw==", "dependencies": {} @@ -1818,7 +1827,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", @@ -1851,7 +1861,7 @@ "npm:nostr-relaypool2@0.6.34", "npm:nostr-tools@2.5.1", "npm:nostr-wasm@^0.1.0", - "npm:path-to-regexp@6.2.1", + "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/app.ts b/src/app.ts index a99fd255..9cbc0238 100644 --- a/src/app.ts +++ b/src/app.ts @@ -40,8 +40,10 @@ import { adminRelaysController, adminSetRelaysController, deleteZapSplitsController, + getZapSplitsController, nameRequestController, nameRequestsController, + statusZapSplitsController, updateZapSplitsController, } from '@/controllers/api/ditto.ts'; import { emptyArrayController, emptyObjectController, notImplementedController } from '@/controllers/api/fallback.ts'; @@ -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,12 @@ 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: { since?: number; until?: number; limit: number }; + /** Normalized list pagination params. */ + listPagination: { offset: number; limit: number }; }; } @@ -146,7 +153,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)); @@ -264,6 +271,9 @@ app.put('/api/v1/admin/ditto/relays', requireRole('admin'), adminSetRelaysContro app.post('/api/v1/ditto/names', requireSigner, nameRequestController); app.get('/api/v1/ditto/names', requireSigner, nameRequestsController); +app.get('/api/v1/ditto/zap_splits', getZapSplitsController); +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/accounts.ts b/src/controllers/api/accounts.ts index 460aa6dd..7d9e7641 100644 --- a/src/controllers/api/accounts.ts +++ b/src/controllers/api/accounts.ts @@ -9,8 +9,8 @@ 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 { lookupAccount } from '@/utils/lookup.ts'; +import { createEvent, paginated, parseBody, updateListEvent } from '@/utils/api.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'; @@ -110,45 +110,38 @@ 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 [event, events] = await Promise.all([ - lookupAccount(query), - store.query([{ kinds: [0], search: query, limit }], { signal }), - ]); + const lookup = extractIdentifier(query); + const event = await lookupAccount(lookup ?? query); - const results = await hydrateEvents({ - events: event ? [event, ...events] : 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([]); - } + if (!event && lookup) { + const pubkey = await lookupPubkey(lookup); + return c.json(pubkey ? [await accountFromPubkey(pubkey)] : []); } - const accounts = await Promise.all(results.map((event) => renderAccount(event))); + const events = event ? [event] : await store.query([{ kinds: [0], search: query, limit }], { signal }); + + const accounts = await hydrateEvents({ events, store, signal }).then( + (events) => + Promise.all( + events.map((event) => renderAccount(event)), + ), + ); + return c.json(accounts); }; @@ -160,7 +153,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); }; @@ -174,7 +185,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; @@ -325,7 +336,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,13 +353,13 @@ const unfollowController: AppController = async (c) => { c, ); - const relationship = await renderRelationship(sourcePubkey, targetPubkey); + const relationship = await getRelationship(sourcePubkey, targetPubkey); return c.json(relationship); }; 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 }]); }; @@ -379,7 +390,7 @@ const muteController: AppController = async (c) => { c, ); - const relationship = await renderRelationship(sourcePubkey, targetPubkey); + const relationship = await getRelationship(sourcePubkey, targetPubkey); return c.json(relationship); }; @@ -394,13 +405,13 @@ const unmuteController: AppController = async (c) => { c, ); - const relationship = await renderRelationship(sourcePubkey, targetPubkey); + const relationship = await getRelationship(sourcePubkey, targetPubkey); return c.json(relationship); }; 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(); @@ -447,6 +458,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/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/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, diff --git a/src/controllers/api/ditto.ts b/src/controllers/api/ditto.ts index d1ba002b..899456a4 100644 --- a/src/controllers/api/ditto.ts +++ b/src/controllers/api/ditto.ts @@ -1,18 +1,22 @@ 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 { 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 { AdminSigner } from '@/signers/AdminSigner.ts'; +import { booleanParamSchema, percentageSchema } from '@/schema.ts'; +import { Conf } from '@/config.ts'; +import { createEvent, paginated, 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'; +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']); @@ -111,7 +115,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 = { @@ -156,7 +160,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), }), ); @@ -170,8 +174,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); } @@ -186,7 +190,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, ); @@ -205,8 +209,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); } @@ -223,3 +227,61 @@ export const deleteZapSplitsController: AppController = async (c) => { return c.json(200); }; + +export const getZapSplitsController: AppController = async (c) => { + const store = c.get('store'); + + 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(dittoZapSplit); + + 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: dittoZapSplit[pubkey].weight, + message: dittoZapSplit[pubkey].message, + }; + })); + + return c.json(zapSplits, 200); +}; + +export const statusZapSplitsController: 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); +}; 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, - }, }); }; 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/search.ts b/src/controllers/api/search.ts index 0151f7de..23eba60f 100644 --- a/src/controllers/api/search.ts +++ b/src/controllers/api/search.ts @@ -5,14 +5,11 @@ 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 { hydrateEvents } from '@/storages/hydrate.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'; -import { hydrateEvents } from '@/storages/hydrate.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), @@ -33,43 +30,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 lookup = extractIdentifier(result.data.q); - if (event) { - events.push(event); + // Render account from pubkey. + if (!event && lookup) { + const pubkey = await lookupPubkey(lookup); + 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), ), ]); - 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); - } - } - return c.json({ accounts, statuses, @@ -121,54 +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; } - if (new RegExp(`^${nip19.BECH32_REGEX.source}$`).test(q)) { - try { - const result = nip19.decode(q); - 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 (_e) { - // do nothing - } - } else if (/^[0-9a-f]{64}$/.test(q)) { - 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 (_e) { - // do nothing + const lookup = extractIdentifier(q); + 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/controllers/api/statuses.ts b/src/controllers/api/statuses.ts index 4e8d051f..00aa9a31 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'; @@ -179,12 +171,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].amount; - tags.push(['zap', pubkey, Conf.relay, zap_split[pubkey].amount.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()]); @@ -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('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 7e461c45..c047c415 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('listPagination'); 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('listPagination'); 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/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/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..b1f1e2f3 --- /dev/null +++ b/src/middleware/paginationMiddleware.ts @@ -0,0 +1,49 @@ +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', { + since: pagination.since, + until: pagination.until, + limit: pagination.limit, + }); + + c.set('listPagination', { + limit: pagination.limit, + offset: pagination.offset, + }); + + 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/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'; diff --git a/src/utils.ts b/src/utils.ts index e9213ed1..e361109d 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 { // } } @@ -78,11 +78,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; @@ -93,18 +88,6 @@ function isURL(value: unknown): boolean { return z.string().url().safeParse(value).success; } -export { - bech32ToPubkey, - dedupeEvents, - eventAge, - 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/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/utils/lookup.ts b/src/utils/lookup.ts index 90b30c2b..a824949a 100644 --- a/src/utils/lookup.ts +++ b/src/utils/lookup.ts @@ -1,4 +1,6 @@ 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'; @@ -35,3 +37,54 @@ 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) { + // 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 + } + + value = value.replace(/^@/, ''); + + if (n.bech32().safeParse(value).success) { + return value; + } + + if (NIP05.regex().test(value)) { + return value; + } +} 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); 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/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 hydrateEvents({ events, store, signal })); diff --git a/src/views/mastodon/accounts.ts b/src/views/mastodon/accounts.ts index eb2d6891..a1340e80 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/relationships.ts b/src/views/mastodon/relationships.ts index 425ea563..34d6c569 100644 --- a/src/views/mastodon/relationships.ts +++ b/src/views/mastodon/relationships.ts @@ -1,19 +1,16 @@ -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(); - - 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); +interface RenderRelationshipOpts { + sourcePubkey: string; + targetPubkey: string; + event3: NostrEvent | undefined; + target3: NostrEvent | undefined; + event10000: NostrEvent | undefined; +} +function renderRelationship({ sourcePubkey, targetPubkey, event3, target3, event10000 }: RenderRelationshipOpts) { return { id: targetPubkey, following: event3 ? hasTag(event3.tags, ['p', targetPubkey]) : false, diff --git a/src/views/mastodon/statuses.ts b/src/views/mastodon/statuses.ts index 2fa8f313..f5d8d5bb 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([ @@ -71,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'); @@ -95,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, @@ -121,8 +126,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), @@ -152,25 +157,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 { @@ -178,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; }, []);