Merge branch 'add-zap-tag' into 'main'

Add zap tag to kind 1 event

Closes #133

See merge request soapbox-pub/ditto!432
This commit is contained in:
Alex Gleason 2024-07-25 18:12:57 +00:00
commit 24b950ebd2
9 changed files with 286 additions and 23 deletions

View file

@ -5,9 +5,6 @@ import { logger } from '@hono/hono/logger';
import { NostrEvent, NostrSigner, NStore, NUploader } from '@nostrify/nostrify';
import Debug from '@soapbox/stickynotes/debug';
import { Conf } from '@/config.ts';
import { cron } from '@/cron.ts';
import { startFirehose } from '@/firehose.ts';
import { Time } from '@/utils/time.ts';
import {
@ -42,8 +39,10 @@ import { bookmarksController } from '@/controllers/api/bookmarks.ts';
import {
adminRelaysController,
adminSetRelaysController,
deleteZapSplitsController,
nameRequestController,
nameRequestsController,
updateZapSplitsController,
} from '@/controllers/api/ditto.ts';
import { emptyArrayController, emptyObjectController, notImplementedController } from '@/controllers/api/fallback.ts';
import {
@ -143,13 +142,6 @@ const app = new Hono<AppEnv>({ strict: false });
const debug = Debug('ditto:http');
if (Conf.firehoseEnabled) {
startFirehose();
}
if (Conf.cronEnabled) {
cron();
}
app.use('*', rateLimitMiddleware(300, Time.minutes(5)));
app.use('/api/*', metricsMiddleware, logger(debug));
@ -270,6 +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);

View file

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

View file

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

View file

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

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

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

View file

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

16
src/startup.ts Normal file
View file

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

View file

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

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

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