diff --git a/src/app.ts b/src/app.ts index 1006862..8fa6891 100644 --- a/src/app.ts +++ b/src/app.ts @@ -64,6 +64,7 @@ import { statusController, unbookmarkController, unpinController, + zapController, } from './controllers/api/statuses.ts'; import { streamingController } from './controllers/api/streaming.ts'; import { @@ -168,6 +169,7 @@ app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/bookmark', requirePubkey, bookmarkC app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unbookmark', requirePubkey, unbookmarkController); app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/pin', requirePubkey, pinController); app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unpin', requirePubkey, unpinController); +app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/zap', requirePubkey, zapController); app.post('/api/v1/statuses', requirePubkey, createStatusController); app.post('/api/v1/media', requireRole('user', { validatePayload: false }), mediaController); diff --git a/src/controllers/api/statuses.ts b/src/controllers/api/statuses.ts index 82be6ad..08b9a87 100644 --- a/src/controllers/api/statuses.ts +++ b/src/controllers/api/statuses.ts @@ -1,11 +1,14 @@ import { type AppController } from '@/app.ts'; +import { Conf } from '@/config.ts'; import { getUnattachedMediaByIds } from '@/db/unattached-media.ts'; import { type Event, ISO6391, z } from '@/deps.ts'; import { getAncestors, getAuthor, getDescendants, getEvent } from '@/queries.ts'; +import { jsonMetaContentSchema } from '@/schemas/nostr.ts'; import { addTag, deleteTag } from '@/tags.ts'; import { createEvent, paginationSchema, parseBody, updateListEvent } from '@/utils/api.ts'; import { renderEventAccounts } from '@/views.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts'; +import { getLnurl } from '@/utils/lnurl.ts'; const createStatusSchema = z.object({ in_reply_to_id: z.string().regex(/[0-9a-f]{64}/).nullish(), @@ -261,6 +264,47 @@ const unpinController: AppController = async (c) => { } }; +const zapSchema = z.object({ + amount: z.number().int().positive(), + comment: z.string().optional(), +}); + +const zapController: AppController = async (c) => { + const id = c.req.param('id'); + const body = await parseBody(c.req.raw); + const params = zapSchema.safeParse(body); + + if (!params.success) { + return c.json({ error: 'Bad request', schema: params.error }, 400); + } + + const target = await getEvent(id, { kind: 1, relations: ['author', 'event_stats', 'author_stats'] }); + const author = target?.author; + const meta = jsonMetaContentSchema.parse(author?.content); + const lnurl = getLnurl(meta); + + if (target && lnurl) { + await createEvent({ + kind: 9734, + content: params.data.comment ?? '', + tags: [ + ['e', target.id], + ['p', target.pubkey], + ['amount', params.data.amount.toString()], + ['relays', Conf.relay], + ['lnurl', lnurl], + ], + }, c); + + const status = await renderStatus(target, c.get('pubkey')); + status.zapped = true; + + return c.json(status); + } else { + return c.json({ error: 'Event not found.' }, 404); + } +}; + export { bookmarkController, contextController, @@ -272,4 +316,5 @@ export { statusController, unbookmarkController, unpinController, + zapController, }; diff --git a/src/deps.ts b/src/deps.ts index 164af3a..0cc1b66 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -86,5 +86,6 @@ export { EventEmitter } from 'npm:tseep@^1.1.3'; export { default as stringifyStable } from 'npm:fast-stable-stringify@^1.0.0'; // @deno-types="npm:@types/debug@^4.1.12" export { default as Debug } from 'npm:debug@^4.3.4'; +export { bech32 } from 'npm:@scure/base@^1.1.1'; export type * as TypeFest from 'npm:type-fest@^4.3.0'; diff --git a/src/queries.ts b/src/queries.ts index 3dc27d7..8a022fc 100644 --- a/src/queries.ts +++ b/src/queries.ts @@ -19,7 +19,7 @@ interface GetEventOpts { const getEvent = async ( id: string, opts: GetEventOpts = {}, -): Promise | undefined> => { +): Promise | undefined> => { debug(`getEvent: ${id}`); const { kind, relations, signal = AbortSignal.timeout(1000) } = opts; const microfilter: IdMicrofilter = { ids: [id] }; diff --git a/src/schemas/nostr.ts b/src/schemas/nostr.ts index 1e8c0af..c6decba 100644 --- a/src/schemas/nostr.ts +++ b/src/schemas/nostr.ts @@ -70,6 +70,7 @@ const metaContentSchema = z.object({ picture: z.string().optional().catch(undefined), banner: z.string().optional().catch(undefined), nip05: z.string().optional().catch(undefined), + lud06: z.string().optional().catch(undefined), lud16: z.string().optional().catch(undefined), }).partial().passthrough(); diff --git a/src/utils/lnurl.ts b/src/utils/lnurl.ts index 6d8e725..f053bc4 100644 --- a/src/utils/lnurl.ts +++ b/src/utils/lnurl.ts @@ -15,4 +15,16 @@ function lnurlDecode(lnurl: string): string { return new TextDecoder().decode(data); } -export { lnurlDecode, lnurlEncode }; +/** Get an LNURL from a lud06 or lud16. */ +function getLnurl({ lud06, lud16 }: { lud06?: string; lud16?: string }): string | undefined { + if (lud06) return lud06; + if (lud16) { + const [name, host] = lud16.split('@'); + if (name && host) { + const url = new URL(`/.well-known/lnurlp/${name}`, `https://${host}`).toString(); + return lnurlEncode(url); + } + } +} + +export { getLnurl, lnurlDecode, lnurlEncode }; diff --git a/src/views/mastodon/accounts.ts b/src/views/mastodon/accounts.ts index 0030f3b..ce0e8c7 100644 --- a/src/views/mastodon/accounts.ts +++ b/src/views/mastodon/accounts.ts @@ -3,6 +3,7 @@ import { findUser } from '@/db/users.ts'; import { lodash, nip19, type UnsignedEvent } from '@/deps.ts'; import { jsonMetaContentSchema } from '@/schemas/nostr.ts'; import { type DittoEvent } from '@/storages/types.ts'; +import { getLnurl } from '@/utils/lnurl.ts'; import { verifyNip05Cached } from '@/utils/nip05.ts'; import { Nip05, nostrDate, nostrNow, parseNip05 } from '@/utils.ts'; import { renderEmojis } from '@/views/mastodon/emojis.ts'; @@ -24,6 +25,8 @@ async function renderAccount( picture = Conf.local('/images/avi.png'), banner = Conf.local('/images/banner.png'), about, + lud06, + lud16, } = jsonMetaContentSchema.parse(event.content); const npub = nip19.npubEncode(pubkey); @@ -67,6 +70,9 @@ async function renderAccount( statuses_count: event.author_stats?.notes_count ?? 0, url: Conf.local(`/users/${pubkey}`), username: parsed05?.nickname || npub.substring(0, 8), + ditto: { + accepts_zaps: Boolean(getLnurl({ lud06, lud16 })), + }, pleroma: { is_admin: user?.admin || false, is_moderator: user?.admin || false, diff --git a/src/views/mastodon/statuses.ts b/src/views/mastodon/statuses.ts index bb8b95e..611dc48 100644 --- a/src/views/mastodon/statuses.ts +++ b/src/views/mastodon/statuses.ts @@ -38,6 +38,7 @@ async function renderStatus(event: DittoEvent<1>, viewerPubkey?: string) { ? await eventsDB.filter([ { kinds: [6], '#e': [event.id], authors: [viewerPubkey], limit: 1 }, { kinds: [7], '#e': [event.id], authors: [viewerPubkey], limit: 1 }, + { kinds: [9734], '#e': [event.id], authors: [viewerPubkey], limit: 1 }, { kinds: [10001], '#e': [event.id], authors: [viewerPubkey], limit: 1 }, { kinds: [10003], '#e': [event.id], authors: [viewerPubkey], limit: 1 }, ]) @@ -48,6 +49,7 @@ async function renderStatus(event: DittoEvent<1>, viewerPubkey?: string) { const repostEvent = relatedEvents.find((event) => event.kind === 6); const pinEvent = relatedEvents.find((event) => event.kind === 10001); const bookmarkEvent = relatedEvents.find((event) => event.kind === 10003); + const zapEvent = relatedEvents.find((event) => event.kind === 9734); const content = buildInlineRecipients(mentions) + html; @@ -91,6 +93,7 @@ async function renderStatus(event: DittoEvent<1>, viewerPubkey?: string) { poll: null, uri: Conf.local(`/posts/${event.id}`), url: Conf.local(`/posts/${event.id}`), + zapped: Boolean(zapEvent), }; }