diff --git a/src/app.ts b/src/app.ts index 1cb3746b..690862eb 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,9 +5,6 @@ import { logger } from '@hono/hono/logger'; import { NostrEvent, NostrSigner, NStore, NUploader } from '@nostrify/nostrify'; import Debug from '@soapbox/stickynotes/debug'; -import { Conf } from '@/config.ts'; -import { cron } from '@/cron.ts'; -import { startFirehose } from '@/firehose.ts'; import { Time } from '@/utils/time.ts'; import { @@ -42,8 +39,10 @@ import { bookmarksController } from '@/controllers/api/bookmarks.ts'; import { adminRelaysController, adminSetRelaysController, + deleteZapSplitsController, nameRequestController, nameRequestsController, + updateZapSplitsController, } from '@/controllers/api/ditto.ts'; import { emptyArrayController, emptyObjectController, notImplementedController } from '@/controllers/api/fallback.ts'; import { @@ -143,13 +142,6 @@ const app = new Hono({ strict: false }); const debug = Debug('ditto:http'); -if (Conf.firehoseEnabled) { - startFirehose(); -} -if (Conf.cronEnabled) { - cron(); -} - app.use('*', rateLimitMiddleware(300, Time.minutes(5))); app.use('/api/*', metricsMiddleware, logger(debug)); @@ -270,6 +262,9 @@ app.put('/api/v1/admin/ditto/relays', requireRole('admin'), adminSetRelaysContro app.post('/api/v1/ditto/names', requireSigner, nameRequestController); app.get('/api/v1/ditto/names', requireSigner, nameRequestsController); +app.put('/api/v1/admin/ditto/zap_splits', requireRole('admin'), updateZapSplitsController); +app.delete('/api/v1/admin/ditto/zap_splits', requireRole('admin'), deleteZapSplitsController); + app.post('/api/v1/ditto/zap', requireSigner, zapController); app.get('/api/v1/ditto/statuses/:id{[0-9a-f]{64}}/zapped_by', zappedByController); diff --git a/src/controllers/api/ditto.ts b/src/controllers/api/ditto.ts index 841eb861..d1ba002b 100644 --- a/src/controllers/api/ditto.ts +++ b/src/controllers/api/ditto.ts @@ -1,14 +1,18 @@ -import { NostrEvent, NostrFilter } from '@nostrify/nostrify'; +import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify'; import { z } from 'zod'; import { AppController } from '@/app.ts'; -import { Conf } from '@/config.ts'; -import { booleanParamSchema } from '@/schema.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts'; +import { booleanParamSchema } from '@/schema.ts'; +import { Conf } from '@/config.ts'; import { Storages } from '@/storages.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; -import { createEvent, paginated, paginationSchema } from '@/utils/api.ts'; +import { createEvent, paginated, paginationSchema, parseBody } from '@/utils/api.ts'; import { renderNameRequest } from '@/views/ditto.ts'; +import { getZapSplits } from '@/utils/zap-split.ts'; +import { updateListAdminEvent } from '@/utils/api.ts'; +import { addTag } from '@/utils/tags.ts'; +import { deleteTag } from '@/utils/tags.ts'; const markerSchema = z.enum(['read', 'write']); @@ -148,3 +152,74 @@ export const nameRequestsController: AppController = async (c) => { return paginated(c, orig, nameRequests); }; + +const zapSplitSchema = z.record( + n.id(), + z.object({ + amount: z.number().int().min(1).max(100), + message: z.string().max(500), + }), +); + +export const updateZapSplitsController: AppController = async (c) => { + const body = await parseBody(c.req.raw); + const result = zapSplitSchema.safeParse(body); + const store = c.get('store'); + + if (!result.success) { + return c.json({ error: result.error }, 400); + } + + const zap_split = await getZapSplits(store, Conf.pubkey); + if (!zap_split) { + return c.json({ error: 'Zap split not activated, restart the server.' }, 404); + } + + const { data } = result; + const pubkeys = Object.keys(data); + + if (pubkeys.length < 1) { + return c.json(200); + } + + await updateListAdminEvent( + { kinds: [30078], authors: [Conf.pubkey], '#d': ['pub.ditto.zapSplits'], limit: 1 }, + (tags) => + pubkeys.reduce((accumulator, pubkey) => { + return addTag(accumulator, ['p', pubkey, data[pubkey].amount.toString(), data[pubkey].message]); + }, tags), + c, + ); + + return c.json(200); +}; + +const deleteZapSplitSchema = z.array(n.id()).min(1); + +export const deleteZapSplitsController: AppController = async (c) => { + const body = await parseBody(c.req.raw); + const result = deleteZapSplitSchema.safeParse(body); + const store = c.get('store'); + + if (!result.success) { + return c.json({ error: result.error }, 400); + } + + const zap_split = await getZapSplits(store, Conf.pubkey); + if (!zap_split) { + return c.json({ error: 'Zap split not activated, restart the server.' }, 404); + } + + const { data } = result; + + await updateListAdminEvent( + { kinds: [30078], authors: [Conf.pubkey], '#d': ['pub.ditto.zapSplits'], limit: 1 }, + (tags) => + data.reduce((accumulator, currentValue) => { + return deleteTag(accumulator, ['p', currentValue]); + }, tags), + c, + ); + + return c.json(200); +}; diff --git a/src/controllers/api/instance.ts b/src/controllers/api/instance.ts index faca9c9f..5f7054ee 100644 --- a/src/controllers/api/instance.ts +++ b/src/controllers/api/instance.ts @@ -4,12 +4,16 @@ import { AppController } from '@/app.ts'; import { Conf } from '@/config.ts'; import { Storages } from '@/storages.ts'; import { getInstanceMetadata } from '@/utils/instance.ts'; +import { DittoZapSplits, getZapSplits } from '@/utils/zap-split.ts'; const version = `3.0.0 (compatible; Ditto ${denoJson.version})`; const instanceV1Controller: AppController = async (c) => { const { host, protocol } = Conf.url; const meta = await getInstanceMetadata(await Storages.db(), c.req.raw.signal); + const store = c.get('store'); + + const zap_split: DittoZapSplits | undefined = await getZapSplits(store, Conf.pubkey) ?? {}; /** Protocol to use for WebSocket URLs, depending on the protocol of the `LOCAL_DOMAIN`. */ const wsProtocol = protocol === 'http:' ? 'ws:' : 'wss:'; @@ -68,6 +72,9 @@ const instanceV1Controller: AppController = async (c) => { }, }, rules: [], + ditto: { + zap_split, + }, }); }; diff --git a/src/controllers/api/statuses.ts b/src/controllers/api/statuses.ts index 4604981f..6a9ed1f5 100644 --- a/src/controllers/api/statuses.ts +++ b/src/controllers/api/statuses.ts @@ -6,10 +6,15 @@ import { nip19 } from 'nostr-tools'; import { z } from 'zod'; import { type AppController } from '@/app.ts'; +import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; +import { addTag, deleteTag } from '@/utils/tags.ts'; +import { asyncReplaceAll } from '@/utils/text.ts'; import { Conf } from '@/config.ts'; import { DittoDB } from '@/db/DittoDB.ts'; +import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { getAncestors, getAuthor, getDescendants, getEvent } from '@/queries.ts'; import { getUnattachedMediaByIds } from '@/db/unattached-media.ts'; +import { lookupPubkey } from '@/utils/lookup.ts'; import { renderEventAccounts } from '@/views.ts'; import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts'; import { Storages } from '@/storages.ts'; @@ -24,11 +29,7 @@ import { updateListEvent, } from '@/utils/api.ts'; import { getInvoice, getLnurl } from '@/utils/lnurl.ts'; -import { lookupPubkey } from '@/utils/lookup.ts'; -import { addTag, deleteTag } from '@/utils/tags.ts'; -import { asyncReplaceAll } from '@/utils/text.ts'; -import { DittoEvent } from '@/interfaces/DittoEvent.ts'; -import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; +import { getZapSplits } from '@/utils/zap-split.ts'; const createStatusSchema = z.object({ in_reply_to_id: n.id().nullish(), @@ -71,6 +72,7 @@ const createStatusController: AppController = async (c) => { const body = await parseBody(c.req.raw); const result = createStatusSchema.safeParse(body); const kysely = await DittoDB.getInstance(); + const store = c.get('store'); if (!result.success) { return c.json({ error: 'Bad request', schema: result.error }, 400); @@ -173,14 +175,28 @@ const createStatusController: AppController = async (c) => { const quoteCompat = data.quote_id ? `\n\nnostr:${nip19.noteEncode(data.quote_id)}` : ''; const mediaCompat = mediaUrls.length ? `\n\n${mediaUrls.join('\n')}` : ''; + const author = await getAuthor(await c.get('signer')?.getPublicKey()!); + + const meta = n.json().pipe(n.metadata()).catch({}).parse(author?.content); + const lnurl = getLnurl(meta); + const zap_split = await getZapSplits(store, Conf.pubkey); + if (lnurl && zap_split) { + let totalSplit = 0; + for (const pubkey in zap_split) { + totalSplit += zap_split[pubkey].amount; + tags.push(['zap', pubkey, Conf.relay, zap_split[pubkey].amount.toString()]); + } + if (totalSplit) { + tags.push(['zap', author?.pubkey as string, Conf.relay, Math.max(0, 100 - totalSplit).toString()]); + } + } + const event = await createEvent({ kind: 1, content: content + quoteCompat + mediaCompat, tags, }, c); - const author = await getAuthor(event.pubkey); - if (data.quote_id) { await hydrateEvents({ events: [event], @@ -189,7 +205,7 @@ const createStatusController: AppController = async (c) => { }); } - return c.json(await renderStatus({ ...event, author }, { viewerPubkey: await c.get('signer')?.getPublicKey() })); + return c.json(await renderStatus({ ...event, author }, { viewerPubkey: author?.pubkey })); }; const deleteStatusController: AppController = async (c) => { diff --git a/src/schema.test.ts b/src/schema.test.ts new file mode 100644 index 00000000..c6b577de --- /dev/null +++ b/src/schema.test.ts @@ -0,0 +1,22 @@ +import { assertEquals } from '@std/assert'; + +import { percentageSchema } from '@/schema.ts'; + +Deno.test('Value is any percentage from 1 to 100', () => { + assertEquals(percentageSchema.safeParse('latvia' as unknown).success, false); + assertEquals(percentageSchema.safeParse(1.5).success, false); + assertEquals(percentageSchema.safeParse(Infinity).success, false); + assertEquals(percentageSchema.safeParse('Infinity').success, false); + assertEquals(percentageSchema.safeParse('0').success, false); + assertEquals(percentageSchema.safeParse(0).success, false); + assertEquals(percentageSchema.safeParse(-1).success, false); + assertEquals(percentageSchema.safeParse('-10').success, false); + assertEquals(percentageSchema.safeParse([]).success, false); + assertEquals(percentageSchema.safeParse(undefined).success, false); + + for (let i = 1; i < 100; i++) { + assertEquals(percentageSchema.safeParse(String(i)).success, true); + } + + assertEquals(percentageSchema.safeParse('1e1').success, true); +}); diff --git a/src/schema.ts b/src/schema.ts index d152a0d4..fc7efd01 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -38,4 +38,14 @@ const booleanParamSchema = z.enum(['true', 'false']).transform((value) => value /** Schema for `File` objects. */ const fileSchema = z.custom((value) => value instanceof File); -export { booleanParamSchema, decode64Schema, fileSchema, filteredArray, hashtagSchema, safeUrlSchema }; +const percentageSchema = z.coerce.number().int().gte(1).lte(100); + +export { + booleanParamSchema, + decode64Schema, + fileSchema, + filteredArray, + hashtagSchema, + percentageSchema, + safeUrlSchema, +}; diff --git a/src/startup.ts b/src/startup.ts new file mode 100644 index 00000000..21df4d50 --- /dev/null +++ b/src/startup.ts @@ -0,0 +1,16 @@ +// Starts up applications required to run before the HTTP server is on. + +import { Conf } from '@/config.ts'; +import { seedZapSplits } from '@/utils/zap-split.ts'; +import { cron } from '@/cron.ts'; +import { startFirehose } from '@/firehose.ts'; + +if (Conf.firehoseEnabled) { + startFirehose(); +} + +if (Conf.cronEnabled) { + cron(); +} + +await seedZapSplits(); diff --git a/src/utils/zap-split.test.ts b/src/utils/zap-split.test.ts new file mode 100644 index 00000000..08454160 --- /dev/null +++ b/src/utils/zap-split.test.ts @@ -0,0 +1,60 @@ +import { assertEquals } from '@std/assert'; +import { generateSecretKey, getPublicKey } from 'nostr-tools'; + +import { genEvent } from '@/test.ts'; +import { getZapSplits } from '@/utils/zap-split.ts'; +import { getTestDB } from '@/test.ts'; + +Deno.test('Get zap splits in DittoZapSplits format', async () => { + const { store } = await getTestDB(); + + const sk = generateSecretKey(); + const pubkey = getPublicKey(sk); + + const event = genEvent({ + kind: 30078, + tags: [ + ['d', 'pub.ditto.zapSplits'], + ['p', '47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4', '2', 'Patrick developer'], + ['p', '0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd', '3', 'Alex creator of Ditto'], + ], + }, sk); + await store.event(event); + + const eventFromDb = await store.query([{ kinds: [30078], authors: [pubkey] }]); + + assertEquals(eventFromDb.length, 1); + + const zapSplits = await getZapSplits(store, pubkey); + + assertEquals(zapSplits, { + '0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd': { amount: 3, message: 'Alex creator of Ditto' }, + '47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4': { amount: 2, message: 'Patrick developer' }, + }); + + assertEquals(await getZapSplits(store, 'garbage'), undefined); +}); + +Deno.test('Zap split is empty', async () => { + const { store } = await getTestDB(); + + const sk = generateSecretKey(); + const pubkey = getPublicKey(sk); + + const event = genEvent({ + kind: 30078, + tags: [ + ['d', 'pub.ditto.zapSplits'], + ['p', 'baka'], + ], + }, sk); + await store.event(event); + + const eventFromDb = await store.query([{ kinds: [30078], authors: [pubkey] }]); + + assertEquals(eventFromDb.length, 1); + + const zapSplits = await getZapSplits(store, pubkey); + + assertEquals(zapSplits, {}); +}); diff --git a/src/utils/zap-split.ts b/src/utils/zap-split.ts new file mode 100644 index 00000000..553d9c8c --- /dev/null +++ b/src/utils/zap-split.ts @@ -0,0 +1,62 @@ +import { AdminSigner } from '@/signers/AdminSigner.ts'; +import { Conf } from '@/config.ts'; +import { handleEvent } from '@/pipeline.ts'; +import { NSchema as n, NStore } from '@nostrify/nostrify'; +import { nostrNow } from '@/utils.ts'; +import { percentageSchema } from '@/schema.ts'; +import { Storages } from '@/storages.ts'; + +type Pubkey = string; +type ExtraMessage = string; +/** Number from 1 to 100, stringified. */ +type splitPercentages = number; + +export type DittoZapSplits = { + [key: Pubkey]: { amount: splitPercentages; message: ExtraMessage }; +}; + +/** Gets zap splits from NIP-78 in DittoZapSplits format. */ +export async function getZapSplits(store: NStore, pubkey: string): Promise { + const zapSplits: DittoZapSplits = {}; + + const [event] = await store.query([{ + authors: [pubkey], + kinds: [30078], + '#d': ['pub.ditto.zapSplits'], + limit: 1, + }]); + if (!event) return; + + for (const tag of event.tags) { + if ( + tag[0] === 'p' && n.id().safeParse(tag[1]).success && + percentageSchema.safeParse(tag[2]).success + ) { + zapSplits[tag[1]] = { amount: Number(tag[2]), message: tag[3] }; + } + } + + return zapSplits; +} + +export async function seedZapSplits() { + const store = await Storages.admin(); + + const zap_split: DittoZapSplits | undefined = await getZapSplits(store, Conf.pubkey); + if (!zap_split) { + const dittoPubkey = '781a1527055f74c1f70230f10384609b34548f8ab6a0a6caa74025827f9fdae5'; + const dittoMsg = 'Official Ditto Account'; + + const signer = new AdminSigner(); + const event = await signer.signEvent({ + content: '', + created_at: nostrNow(), + kind: 30078, + tags: [ + ['d', 'pub.ditto.zapSplits'], + ['p', dittoPubkey, '5', dittoMsg], + ], + }); + await handleEvent(event, AbortSignal.timeout(5000)); + } +}