Remove @/storages.ts (jesus christ)

This commit is contained in:
Alex Gleason 2025-02-22 19:27:53 -06:00
parent ca5c887705
commit 3b17fd9b45
No known key found for this signature in database
GPG key ID: 7211D1F99744FBB7
53 changed files with 639 additions and 1216 deletions

View file

@ -1,39 +1,41 @@
import { DittoConf } from '@ditto/conf';
import { ApplicationServer, PushMessageOptions, PushSubscriber, PushSubscription } from '@negrel/webpush'; import { ApplicationServer, PushMessageOptions, PushSubscriber, PushSubscription } from '@negrel/webpush';
import { NStore } from '@nostrify/types';
import { logi } from '@soapbox/logi'; import { logi } from '@soapbox/logi';
import { Conf } from '@/config.ts';
import { Storages } from '@/storages.ts';
import { getInstanceMetadata } from '@/utils/instance.ts'; import { getInstanceMetadata } from '@/utils/instance.ts';
interface DittoPushOpts {
conf: DittoConf;
relay: NStore;
}
export class DittoPush { export class DittoPush {
static _server: Promise<ApplicationServer | undefined> | undefined; private server: Promise<ApplicationServer | undefined>;
static get server(): Promise<ApplicationServer | undefined> { constructor(opts: DittoPushOpts) {
if (!this._server) { const { conf, relay } = opts;
this._server = (async () => {
const store = await Storages.db();
const meta = await getInstanceMetadata(store);
const keys = await Conf.vapidKeys;
if (keys) { this.server = (async () => {
return await ApplicationServer.new({ const meta = await getInstanceMetadata(relay);
contactInformation: `mailto:${meta.email}`, const keys = await conf.vapidKeys;
vapidKeys: keys,
});
} else {
logi({
level: 'warn',
ns: 'ditto.push',
msg: 'VAPID keys are not set. Push notifications will be disabled.',
});
}
})();
}
return this._server; if (keys) {
return await ApplicationServer.new({
contactInformation: `mailto:${meta.email}`,
vapidKeys: keys,
});
} else {
logi({
level: 'warn',
ns: 'ditto.push',
msg: 'VAPID keys are not set. Push notifications will be disabled.',
});
}
})();
} }
static async push( async push(
subscription: PushSubscription, subscription: PushSubscription,
json: object, json: object,
opts: PushMessageOptions = {}, opts: PushMessageOptions = {},

View file

@ -1,7 +1,8 @@
import { DittoConf } from '@ditto/conf'; import { DittoConf } from '@ditto/conf';
import { DittoDB } from '@ditto/db'; import { DittoDB, DittoPolyPg } from '@ditto/db';
import { paginationMiddleware, tokenMiddleware, userMiddleware } from '@ditto/mastoapi/middleware'; import { paginationMiddleware, tokenMiddleware, userMiddleware } from '@ditto/mastoapi/middleware';
import { DittoApp, type DittoEnv } from '@ditto/mastoapi/router'; import { DittoApp, type DittoEnv } from '@ditto/mastoapi/router';
import { relayPoolRelaysSizeGauge, relayPoolSubscriptionsSizeGauge } from '@ditto/metrics';
import { type DittoTranslator } from '@ditto/translators'; import { type DittoTranslator } from '@ditto/translators';
import { type Context, Handler, Input as HonoInput, MiddlewareHandler } from '@hono/hono'; import { type Context, Handler, Input as HonoInput, MiddlewareHandler } from '@hono/hono';
import { every } from '@hono/hono/combine'; import { every } from '@hono/hono/combine';
@ -9,11 +10,13 @@ import { cors } from '@hono/hono/cors';
import { serveStatic } from '@hono/hono/deno'; import { serveStatic } from '@hono/hono/deno';
import { NostrEvent, NostrSigner, NRelay, NUploader } from '@nostrify/nostrify'; import { NostrEvent, NostrSigner, NRelay, NUploader } from '@nostrify/nostrify';
import '@/startup.ts'; import { cron } from '@/cron.ts';
import { startFirehose } from '@/firehose.ts';
import { Conf } from '@/config.ts'; import { DittoAPIStore } from '@/storages/DittoAPIStore.ts';
import { Storages } from '@/storages.ts'; import { DittoPgStore } from '@/storages/DittoPgStore.ts';
import { DittoPool } from '@/storages/DittoPool.ts';
import { Time } from '@/utils/time.ts'; import { Time } from '@/utils/time.ts';
import { seedZapSplits } from '@/utils/zap-split.ts';
import { import {
accountController, accountController,
@ -176,14 +179,42 @@ type AppMiddleware = MiddlewareHandler<AppEnv>;
// deno-lint-ignore no-explicit-any // deno-lint-ignore no-explicit-any
type AppController<P extends string = any> = Handler<AppEnv, P, HonoInput, Response | Promise<Response>>; type AppController<P extends string = any> = Handler<AppEnv, P, HonoInput, Response | Promise<Response>>;
const app = new DittoApp({ const conf = new DittoConf(Deno.env);
conf: Conf,
db: await Storages.database(), const db = new DittoPolyPg(conf.databaseUrl, {
relay: await Storages.db(), poolSize: conf.pg.poolSize,
}, { debug: conf.pgliteDebug,
strict: false,
}); });
await db.migrate();
const store = new DittoPgStore({
db,
pubkey: await conf.signer.getPublicKey(),
timeout: conf.db.timeouts.default,
notify: conf.notifyEnabled,
});
const pool = new DittoPool({ conf, relay: store });
const relay = new DittoAPIStore({ db, conf, relay: store, pool });
await seedZapSplits(relay);
if (conf.firehoseEnabled) {
startFirehose({
pool,
store: relay,
concurrency: conf.firehoseConcurrency,
kinds: conf.firehoseKinds,
});
}
if (conf.cronEnabled) {
cron({ conf, db, relay });
}
const app = new DittoApp({ conf, db, relay }, { strict: false });
/** User-provided files in the gitignored `public/` directory. */ /** User-provided files in the gitignored `public/` directory. */
const publicFiles = serveStatic({ root: './public/' }); const publicFiles = serveStatic({ root: './public/' });
/** Static files provided by the Ditto repo, checked into git. */ /** Static files provided by the Ditto repo, checked into git. */
@ -218,7 +249,17 @@ app.use(
uploaderMiddleware, uploaderMiddleware,
); );
app.get('/metrics', metricsController); app.get('/metrics', async (_c, next) => {
relayPoolRelaysSizeGauge.reset();
relayPoolSubscriptionsSizeGauge.reset();
for (const relay of pool.relays.values()) {
relayPoolRelaysSizeGauge.inc({ ready_state: relay.socket.readyState });
relayPoolSubscriptionsSizeGauge.inc(relay.subscriptions.length);
}
await next();
}, metricsController);
app.get( app.get(
'/.well-known/nodeinfo', '/.well-known/nodeinfo',

View file

@ -1,14 +1,14 @@
import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify'; import { paginated } from '@ditto/mastoapi/pagination';
import { NostrEvent, NostrFilter, NSchema as n, NStore } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { z } from 'zod'; import { z } from 'zod';
import { type AppController } from '@/app.ts'; import { type AppController } from '@/app.ts';
import { getAuthor, getFollowedPubkeys } from '@/queries.ts'; import { getAuthor, getFollowedPubkeys } from '@/queries.ts';
import { booleanParamSchema, fileSchema } from '@/schema.ts'; import { booleanParamSchema, fileSchema } from '@/schema.ts';
import { Storages } from '@/storages.ts';
import { uploadFile } from '@/utils/upload.ts'; import { uploadFile } from '@/utils/upload.ts';
import { nostrNow } from '@/utils.ts'; import { nostrNow } from '@/utils.ts';
import { assertAuthenticated, createEvent, paginated, parseBody, updateEvent, updateListEvent } from '@/utils/api.ts'; import { assertAuthenticated, createEvent, parseBody, updateEvent, updateListEvent } from '@/utils/api.ts';
import { extractIdentifier, lookupAccount, lookupPubkey } from '@/utils/lookup.ts'; import { extractIdentifier, lookupAccount, lookupPubkey } from '@/utils/lookup.ts';
import { renderAccounts, renderEventAccounts, renderStatuses } from '@/views.ts'; import { renderAccounts, renderEventAccounts, renderStatuses } from '@/views.ts';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
@ -54,7 +54,7 @@ const verifyCredentialsController: AppController = async (c) => {
const pubkey = await signer.getPublicKey(); const pubkey = await signer.getPublicKey();
const [author, [settingsEvent]] = await Promise.all([ const [author, [settingsEvent]] = await Promise.all([
getAuthor(pubkey, { signal: AbortSignal.timeout(5000) }), getAuthor(pubkey, c.var),
relay.query([{ relay.query([{
kinds: [30078], kinds: [30078],
@ -81,7 +81,7 @@ const verifyCredentialsController: AppController = async (c) => {
const accountController: AppController = async (c) => { const accountController: AppController = async (c) => {
const pubkey = c.req.param('pubkey'); const pubkey = c.req.param('pubkey');
const event = await getAuthor(pubkey); const event = await getAuthor(pubkey, c.var);
if (event) { if (event) {
assertAuthenticated(c, event); assertAuthenticated(c, event);
return c.json(await renderAccount(event)); return c.json(await renderAccount(event));
@ -97,7 +97,7 @@ const accountLookupController: AppController = async (c) => {
return c.json({ error: 'Missing `acct` query parameter.' }, 422); return c.json({ error: 'Missing `acct` query parameter.' }, 422);
} }
const event = await lookupAccount(decodeURIComponent(acct)); const event = await lookupAccount(decodeURIComponent(acct), c.var);
if (event) { if (event) {
assertAuthenticated(c, event); assertAuthenticated(c, event);
return c.json(await renderAccount(event)); return c.json(await renderAccount(event));
@ -131,10 +131,10 @@ const accountSearchController: AppController = async (c) => {
const query = decodeURIComponent(result.data.q); const query = decodeURIComponent(result.data.q);
const lookup = extractIdentifier(query); const lookup = extractIdentifier(query);
const event = await lookupAccount(lookup ?? query); const event = await lookupAccount(lookup ?? query, c.var);
if (!event && lookup) { if (!event && lookup) {
const pubkey = await lookupPubkey(lookup); const pubkey = await lookupPubkey(lookup, c.var);
return c.json(pubkey ? [accountFromPubkey(pubkey)] : []); return c.json(pubkey ? [accountFromPubkey(pubkey)] : []);
} }
@ -143,7 +143,7 @@ const accountSearchController: AppController = async (c) => {
if (event) { if (event) {
events.push(event); events.push(event);
} else { } else {
const following = viewerPubkey ? await getFollowedPubkeys(viewerPubkey) : new Set<string>(); const following = viewerPubkey ? await getFollowedPubkeys(relay, viewerPubkey, signal) : new Set<string>();
const authors = [...await getPubkeysBySearch(db.kysely, { q: query, limit, offset: 0, following })]; const authors = [...await getPubkeysBySearch(db.kysely, { q: query, limit, offset: 0, following })];
const profiles = await relay.query([{ kinds: [0], authors, limit }], { signal }); const profiles = await relay.query([{ kinds: [0], authors, limit }], { signal });
@ -155,14 +155,14 @@ const accountSearchController: AppController = async (c) => {
} }
} }
const accounts = await hydrateEvents({ events, relay, signal }) const accounts = await hydrateEvents({ ...c.var, events })
.then((events) => events.map((event) => renderAccount(event))); .then((events) => events.map((event) => renderAccount(event)));
return c.json(accounts); return c.json(accounts);
}; };
const relationshipsController: AppController = async (c) => { const relationshipsController: AppController = async (c) => {
const { user } = c.var; const { relay, user } = c.var;
const pubkey = await user!.signer.getPublicKey(); const pubkey = await user!.signer.getPublicKey();
const ids = z.array(z.string()).safeParse(c.req.queries('id[]')); const ids = z.array(z.string()).safeParse(c.req.queries('id[]'));
@ -171,11 +171,9 @@ const relationshipsController: AppController = async (c) => {
return c.json({ error: 'Missing `id[]` query parameters.' }, 422); return c.json({ error: 'Missing `id[]` query parameters.' }, 422);
} }
const db = await Storages.db();
const [sourceEvents, targetEvents] = await Promise.all([ const [sourceEvents, targetEvents] = await Promise.all([
db.query([{ kinds: [3, 10000], authors: [pubkey] }]), relay.query([{ kinds: [3, 10000], authors: [pubkey] }]),
db.query([{ kinds: [3], authors: ids.data }]), relay.query([{ kinds: [3], authors: ids.data }]),
]); ]);
const event3 = sourceEvents.find((event) => event.kind === 3 && event.pubkey === pubkey); const event3 = sourceEvents.find((event) => event.kind === 3 && event.pubkey === pubkey);
@ -267,7 +265,7 @@ const accountStatusesController: AppController = async (c) => {
const opts = { signal, limit, timeout: conf.db.timeouts.timelines }; const opts = { signal, limit, timeout: conf.db.timeouts.timelines };
const events = await relay.query([filter], opts) const events = await relay.query([filter], opts)
.then((events) => hydrateEvents({ events, relay, signal })) .then((events) => hydrateEvents({ ...c.var, events }))
.then((events) => { .then((events) => {
if (exclude_replies) { if (exclude_replies) {
return events.filter((event) => { return events.filter((event) => {
@ -282,8 +280,8 @@ const accountStatusesController: AppController = async (c) => {
const statuses = await Promise.all( const statuses = await Promise.all(
events.map((event) => { events.map((event) => {
if (event.kind === 6) return renderReblog(event, { viewerPubkey }); if (event.kind === 6) return renderReblog(relay, event, { viewerPubkey });
return renderStatus(event, { viewerPubkey }); return renderStatus(relay, event, { viewerPubkey });
}), }),
); );
return paginated(c, events, statuses); return paginated(c, events, statuses);
@ -305,7 +303,7 @@ const updateCredentialsSchema = z.object({
}); });
const updateCredentialsController: AppController = async (c) => { const updateCredentialsController: AppController = async (c) => {
const { relay, user, signal } = c.var; const { relay, user } = c.var;
const pubkey = await user!.signer.getPublicKey(); const pubkey = await user!.signer.getPublicKey();
const body = await parseBody(c.req.raw); const body = await parseBody(c.req.raw);
@ -375,7 +373,7 @@ const updateCredentialsController: AppController = async (c) => {
let account: MastodonAccount; let account: MastodonAccount;
if (event) { if (event) {
await hydrateEvents({ events: [event], relay, signal }); await hydrateEvents({ ...c.var, events: [event] });
account = await renderAccount(event, { withSource: true, settingsStore }); account = await renderAccount(event, { withSource: true, settingsStore });
} else { } else {
account = await accountFromPubkey(pubkey, { withSource: true, settingsStore }); account = await accountFromPubkey(pubkey, { withSource: true, settingsStore });
@ -394,7 +392,7 @@ const updateCredentialsController: AppController = async (c) => {
/** https://docs.joinmastodon.org/methods/accounts/#follow */ /** https://docs.joinmastodon.org/methods/accounts/#follow */
const followController: AppController = async (c) => { const followController: AppController = async (c) => {
const { user } = c.var; const { relay, user } = c.var;
const sourcePubkey = await user!.signer.getPublicKey(); const sourcePubkey = await user!.signer.getPublicKey();
const targetPubkey = c.req.param('pubkey'); const targetPubkey = c.req.param('pubkey');
@ -405,7 +403,7 @@ const followController: AppController = async (c) => {
c, c,
); );
const relationship = await getRelationship(sourcePubkey, targetPubkey); const relationship = await getRelationship(relay, sourcePubkey, targetPubkey);
relationship.following = true; relationship.following = true;
return c.json(relationship); return c.json(relationship);
@ -413,7 +411,7 @@ const followController: AppController = async (c) => {
/** https://docs.joinmastodon.org/methods/accounts/#unfollow */ /** https://docs.joinmastodon.org/methods/accounts/#unfollow */
const unfollowController: AppController = async (c) => { const unfollowController: AppController = async (c) => {
const { user } = c.var; const { relay, user } = c.var;
const sourcePubkey = await user!.signer.getPublicKey(); const sourcePubkey = await user!.signer.getPublicKey();
const targetPubkey = c.req.param('pubkey'); const targetPubkey = c.req.param('pubkey');
@ -424,7 +422,7 @@ const unfollowController: AppController = async (c) => {
c, c,
); );
const relationship = await getRelationship(sourcePubkey, targetPubkey); const relationship = await getRelationship(relay, sourcePubkey, targetPubkey);
return c.json(relationship); return c.json(relationship);
}; };
@ -435,8 +433,9 @@ const followersController: AppController = (c) => {
}; };
const followingController: AppController = async (c) => { const followingController: AppController = async (c) => {
const { relay, signal } = c.var;
const pubkey = c.req.param('pubkey'); const pubkey = c.req.param('pubkey');
const pubkeys = await getFollowedPubkeys(pubkey); const pubkeys = await getFollowedPubkeys(relay, pubkey, signal);
return renderAccounts(c, [...pubkeys]); return renderAccounts(c, [...pubkeys]);
}; };
@ -452,7 +451,7 @@ const unblockController: AppController = (c) => {
/** https://docs.joinmastodon.org/methods/accounts/#mute */ /** https://docs.joinmastodon.org/methods/accounts/#mute */
const muteController: AppController = async (c) => { const muteController: AppController = async (c) => {
const { user } = c.var; const { relay, user } = c.var;
const sourcePubkey = await user!.signer.getPublicKey(); const sourcePubkey = await user!.signer.getPublicKey();
const targetPubkey = c.req.param('pubkey'); const targetPubkey = c.req.param('pubkey');
@ -463,13 +462,13 @@ const muteController: AppController = async (c) => {
c, c,
); );
const relationship = await getRelationship(sourcePubkey, targetPubkey); const relationship = await getRelationship(relay, sourcePubkey, targetPubkey);
return c.json(relationship); return c.json(relationship);
}; };
/** https://docs.joinmastodon.org/methods/accounts/#unmute */ /** https://docs.joinmastodon.org/methods/accounts/#unmute */
const unmuteController: AppController = async (c) => { const unmuteController: AppController = async (c) => {
const { user } = c.var; const { relay, user } = c.var;
const sourcePubkey = await user!.signer.getPublicKey(); const sourcePubkey = await user!.signer.getPublicKey();
const targetPubkey = c.req.param('pubkey'); const targetPubkey = c.req.param('pubkey');
@ -480,7 +479,7 @@ const unmuteController: AppController = async (c) => {
c, c,
); );
const relationship = await getRelationship(sourcePubkey, targetPubkey); const relationship = await getRelationship(relay, sourcePubkey, targetPubkey);
return c.json(relationship); return c.json(relationship);
}; };
@ -499,26 +498,26 @@ const favouritesController: AppController = async (c) => {
.filter((id): id is string => !!id); .filter((id): id is string => !!id);
const events1 = await relay.query([{ kinds: [1, 20], ids }], { signal }) const events1 = await relay.query([{ kinds: [1, 20], ids }], { signal })
.then((events) => hydrateEvents({ events, relay, signal })); .then((events) => hydrateEvents({ ...c.var, events }));
const viewerPubkey = await user?.signer.getPublicKey(); const viewerPubkey = await user?.signer.getPublicKey();
const statuses = await Promise.all( const statuses = await Promise.all(
events1.map((event) => renderStatus(event, { viewerPubkey })), events1.map((event) => renderStatus(relay, event, { viewerPubkey })),
); );
return paginated(c, events1, statuses); return paginated(c, events1, statuses);
}; };
const familiarFollowersController: AppController = async (c) => { const familiarFollowersController: AppController = async (c) => {
const { relay, user } = c.var; const { relay, user, signal } = c.var;
const pubkey = await user!.signer.getPublicKey(); const pubkey = await user!.signer.getPublicKey();
const ids = z.array(z.string()).parse(c.req.queries('id[]')); const ids = z.array(z.string()).parse(c.req.queries('id[]'));
const follows = await getFollowedPubkeys(pubkey); const follows = await getFollowedPubkeys(relay, pubkey, signal);
const results = await Promise.all(ids.map(async (id) => { const results = await Promise.all(ids.map(async (id) => {
const followLists = await relay.query([{ kinds: [3], authors: [...follows], '#p': [id] }]) const followLists = await relay.query([{ kinds: [3], authors: [...follows], '#p': [id] }])
.then((events) => hydrateEvents({ events, relay })); .then((events) => hydrateEvents({ ...c.var, events }));
const accounts = await Promise.all( const accounts = await Promise.all(
followLists.map((event) => event.author ? renderAccount(event.author) : accountFromPubkey(event.pubkey)), followLists.map((event) => event.author ? renderAccount(event.author) : accountFromPubkey(event.pubkey)),
@ -530,12 +529,10 @@ const familiarFollowersController: AppController = async (c) => {
return c.json(results); return c.json(results);
}; };
async function getRelationship(sourcePubkey: string, targetPubkey: string) { async function getRelationship(relay: NStore, sourcePubkey: string, targetPubkey: string) {
const db = await Storages.db();
const [sourceEvents, targetEvents] = await Promise.all([ const [sourceEvents, targetEvents] = await Promise.all([
db.query([{ kinds: [3, 10000], authors: [sourcePubkey] }]), relay.query([{ kinds: [3, 10000], authors: [sourcePubkey] }]),
db.query([{ kinds: [3], authors: [targetPubkey] }]), relay.query([{ kinds: [3], authors: [targetPubkey] }]),
]); ]);
return renderRelationship({ return renderRelationship({

View file

@ -1,3 +1,4 @@
import { paginated } from '@ditto/mastoapi/pagination';
import { NostrFilter } from '@nostrify/nostrify'; import { NostrFilter } from '@nostrify/nostrify';
import { logi } from '@soapbox/logi'; import { logi } from '@soapbox/logi';
import { z } from 'zod'; import { z } from 'zod';
@ -5,7 +6,7 @@ import { z } from 'zod';
import { type AppController } from '@/app.ts'; import { type AppController } from '@/app.ts';
import { booleanParamSchema } from '@/schema.ts'; import { booleanParamSchema } from '@/schema.ts';
import { hydrateEvents } from '@/storages/hydrate.ts'; import { hydrateEvents } from '@/storages/hydrate.ts';
import { createAdminEvent, paginated, parseBody, updateEventInfo, updateUser } from '@/utils/api.ts'; import { createAdminEvent, parseBody, updateEventInfo, updateUser } from '@/utils/api.ts';
import { renderNameRequest } from '@/views/ditto.ts'; import { renderNameRequest } from '@/views/ditto.ts';
import { renderAdminAccount, renderAdminAccountFromPubkey } from '@/views/mastodon/admin-accounts.ts'; import { renderAdminAccount, renderAdminAccountFromPubkey } from '@/views/mastodon/admin-accounts.ts';
import { errorJson } from '@/utils/log.ts'; import { errorJson } from '@/utils/log.ts';
@ -59,7 +60,7 @@ const adminAccountsController: AppController = async (c) => {
); );
const events = await relay.query([{ kinds: [3036], ids: [...ids] }]) const events = await relay.query([{ kinds: [3036], ids: [...ids] }])
.then((events) => hydrateEvents({ relay, events, signal })); .then((events) => hydrateEvents({ ...c.var, events }));
const nameRequests = await Promise.all(events.map(renderNameRequest)); const nameRequests = await Promise.all(events.map(renderNameRequest));
return paginated(c, orig, nameRequests); return paginated(c, orig, nameRequests);
@ -97,7 +98,7 @@ const adminAccountsController: AppController = async (c) => {
); );
const authors = await relay.query([{ kinds: [0], authors: [...pubkeys] }]) const authors = await relay.query([{ kinds: [0], authors: [...pubkeys] }])
.then((events) => hydrateEvents({ relay, events, signal })); .then((events) => hydrateEvents({ ...c.var, events }));
const accounts = await Promise.all( const accounts = await Promise.all(
[...pubkeys].map((pubkey) => { [...pubkeys].map((pubkey) => {
@ -116,7 +117,7 @@ const adminAccountsController: AppController = async (c) => {
} }
const events = await relay.query([filter], { signal }) const events = await relay.query([filter], { signal })
.then((events) => hydrateEvents({ relay, events, signal })); .then((events) => hydrateEvents({ ...c.var, events }));
const accounts = await Promise.all(events.map(renderAdminAccount)); const accounts = await Promise.all(events.map(renderAdminAccount));
return paginated(c, events, accounts); return paginated(c, events, accounts);
@ -210,7 +211,7 @@ const adminApproveController: AppController = async (c) => {
}, c); }, c);
await updateEventInfo(eventId, { pending: false, approved: true, rejected: false }, c); await updateEventInfo(eventId, { pending: false, approved: true, rejected: false }, c);
await hydrateEvents({ events: [event], relay }); await hydrateEvents({ ...c.var, events: [event] });
const nameRequest = await renderNameRequest(event); const nameRequest = await renderNameRequest(event);
return c.json(nameRequest); return c.json(nameRequest);
@ -226,7 +227,7 @@ const adminRejectController: AppController = async (c) => {
} }
await updateEventInfo(eventId, { pending: false, approved: false, rejected: true }, c); await updateEventInfo(eventId, { pending: false, approved: false, rejected: true }, c);
await hydrateEvents({ events: [event], relay }); await hydrateEvents({ ...c.var, events: [event] });
const nameRequest = await renderNameRequest(event); const nameRequest = await renderNameRequest(event);
return c.json(nameRequest); return c.json(nameRequest);

View file

@ -252,7 +252,7 @@ async function createTestRoute() {
const sk = generateSecretKey(); const sk = generateSecretKey();
const signer = new NSecSigner(sk); const signer = new NSecSigner(sk);
const route = new DittoApp({ db, relay, conf }); const route = new DittoApp({ db: db.db, relay, conf });
route.use(testUserMiddleware({ signer, relay })); route.use(testUserMiddleware({ signer, relay }));
route.route('/', cashuRoute); route.route('/', cashuRoute);

View file

@ -1,3 +1,4 @@
import { paginated, paginatedList } from '@ditto/mastoapi/pagination';
import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify'; import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify';
import { z } from 'zod'; import { z } from 'zod';
@ -5,7 +6,7 @@ import { AppController } from '@/app.ts';
import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { getAuthor } from '@/queries.ts'; import { getAuthor } from '@/queries.ts';
import { addTag } from '@/utils/tags.ts'; import { addTag } from '@/utils/tags.ts';
import { createEvent, paginated, parseBody, updateAdminEvent } from '@/utils/api.ts'; import { createEvent, parseBody, updateAdminEvent } from '@/utils/api.ts';
import { getInstanceMetadata } from '@/utils/instance.ts'; import { getInstanceMetadata } from '@/utils/instance.ts';
import { deleteTag } from '@/utils/tags.ts'; import { deleteTag } from '@/utils/tags.ts';
import { DittoZapSplits, getZapSplits } from '@/utils/zap-split.ts'; import { DittoZapSplits, getZapSplits } from '@/utils/zap-split.ts';
@ -15,7 +16,6 @@ import { hydrateEvents } from '@/storages/hydrate.ts';
import { renderNameRequest } from '@/views/ditto.ts'; import { renderNameRequest } from '@/views/ditto.ts';
import { accountFromPubkey } from '@/views/mastodon/accounts.ts'; import { accountFromPubkey } from '@/views/mastodon/accounts.ts';
import { renderAccount } from '@/views/mastodon/accounts.ts'; import { renderAccount } from '@/views/mastodon/accounts.ts';
import { Storages } from '@/storages.ts';
import { updateListAdminEvent } from '@/utils/api.ts'; import { updateListAdminEvent } from '@/utils/api.ts';
const markerSchema = z.enum(['read', 'write']); const markerSchema = z.enum(['read', 'write']);
@ -120,7 +120,7 @@ export const nameRequestController: AppController = async (c) => {
], ],
}, c); }, c);
await hydrateEvents({ events: [event], relay }); await hydrateEvents({ ...c.var, events: [event] });
const nameRequest = await renderNameRequest(event); const nameRequest = await renderNameRequest(event);
return c.json(nameRequest); return c.json(nameRequest);
@ -132,7 +132,7 @@ const nameRequestsSchema = z.object({
}); });
export const nameRequestsController: AppController = async (c) => { export const nameRequestsController: AppController = async (c) => {
const { conf, relay, user, signal } = c.var; const { conf, relay, user } = c.var;
const pubkey = await user!.signer.getPublicKey(); const pubkey = await user!.signer.getPublicKey();
const params = c.get('pagination'); const params = c.get('pagination');
@ -168,7 +168,7 @@ export const nameRequestsController: AppController = async (c) => {
} }
const events = await relay.query([{ kinds: [3036], ids: [...ids], authors: [pubkey] }]) const events = await relay.query([{ kinds: [3036], ids: [...ids], authors: [pubkey] }])
.then((events) => hydrateEvents({ relay, events: events, signal })); .then((events) => hydrateEvents({ ...c.var, events }));
const nameRequests = await Promise.all( const nameRequests = await Promise.all(
events.map((event) => renderNameRequest(event)), events.map((event) => renderNameRequest(event)),
@ -263,7 +263,7 @@ export const getZapSplitsController: AppController = async (c) => {
const pubkeys = Object.keys(dittoZapSplit); const pubkeys = Object.keys(dittoZapSplit);
const zapSplits = await Promise.all(pubkeys.map(async (pubkey) => { const zapSplits = await Promise.all(pubkeys.map(async (pubkey) => {
const author = await getAuthor(pubkey); const author = await getAuthor(pubkey, c.var);
const account = author ? renderAccount(author) : accountFromPubkey(pubkey); const account = author ? renderAccount(author) : accountFromPubkey(pubkey);
@ -292,7 +292,7 @@ export const statusZapSplitsController: AppController = async (c) => {
const pubkeys = zapsTag.map((name) => name[1]); const pubkeys = zapsTag.map((name) => name[1]);
const users = await relay.query([{ authors: pubkeys, kinds: [0], limit: pubkeys.length }], { signal }); const users = await relay.query([{ authors: pubkeys, kinds: [0], limit: pubkeys.length }], { signal });
await hydrateEvents({ events: users, relay, signal }); await hydrateEvents({ ...c.var, events: users });
const zapSplits = (await Promise.all(pubkeys.map((pubkey) => { const zapSplits = (await Promise.all(pubkeys.map((pubkey) => {
const author = (users.find((event) => event.pubkey === pubkey) as DittoEvent | undefined)?.author; const author = (users.find((event) => event.pubkey === pubkey) as DittoEvent | undefined)?.author;
@ -325,7 +325,8 @@ const updateInstanceSchema = z.object({
}); });
export const updateInstanceController: AppController = async (c) => { export const updateInstanceController: AppController = async (c) => {
const { conf } = c.var; const { conf, relay, signal } = c.var;
const body = await parseBody(c.req.raw); const body = await parseBody(c.req.raw);
const result = updateInstanceSchema.safeParse(body); const result = updateInstanceSchema.safeParse(body);
const pubkey = await conf.signer.getPublicKey(); const pubkey = await conf.signer.getPublicKey();
@ -334,7 +335,7 @@ export const updateInstanceController: AppController = async (c) => {
return c.json(result.error, 422); return c.json(result.error, 422);
} }
const meta = await getInstanceMetadata(await Storages.db(), c.req.raw.signal); const meta = await getInstanceMetadata(relay, signal);
await updateAdminEvent( await updateAdminEvent(
{ kinds: [0], authors: [pubkey], limit: 1 }, { kinds: [0], authors: [pubkey], limit: 1 },

View file

@ -1,7 +1,6 @@
import denoJson from 'deno.json' with { type: 'json' }; import denoJson from 'deno.json' with { type: 'json' };
import { AppController } from '@/app.ts'; import { AppController } from '@/app.ts';
import { Storages } from '@/storages.ts';
import { getInstanceMetadata } from '@/utils/instance.ts'; import { getInstanceMetadata } from '@/utils/instance.ts';
const version = `3.0.0 (compatible; Ditto ${denoJson.version})`; const version = `3.0.0 (compatible; Ditto ${denoJson.version})`;
@ -16,9 +15,9 @@ const features = [
]; ];
const instanceV1Controller: AppController = async (c) => { const instanceV1Controller: AppController = async (c) => {
const { conf } = c.var; const { conf, relay, signal } = c.var;
const { host, protocol } = conf.url; const { host, protocol } = conf.url;
const meta = await getInstanceMetadata(await Storages.db(), c.req.raw.signal); const meta = await getInstanceMetadata(relay, signal);
/** Protocol to use for WebSocket URLs, depending on the protocol of the `LOCAL_DOMAIN`. */ /** Protocol to use for WebSocket URLs, depending on the protocol of the `LOCAL_DOMAIN`. */
const wsProtocol = protocol === 'http:' ? 'ws:' : 'wss:'; const wsProtocol = protocol === 'http:' ? 'ws:' : 'wss:';
@ -76,9 +75,9 @@ const instanceV1Controller: AppController = async (c) => {
}; };
const instanceV2Controller: AppController = async (c) => { const instanceV2Controller: AppController = async (c) => {
const { conf } = c.var; const { conf, relay, signal } = c.var;
const { host, protocol } = conf.url; const { host, protocol } = conf.url;
const meta = await getInstanceMetadata(await Storages.db(), c.req.raw.signal); const meta = await getInstanceMetadata(relay, signal);
/** Protocol to use for WebSocket URLs, depending on the protocol of the `LOCAL_DOMAIN`. */ /** Protocol to use for WebSocket URLs, depending on the protocol of the `LOCAL_DOMAIN`. */
const wsProtocol = protocol === 'http:' ? 'ws:' : 'wss:'; const wsProtocol = protocol === 'http:' ? 'ws:' : 'wss:';
@ -165,7 +164,9 @@ const instanceV2Controller: AppController = async (c) => {
}; };
const instanceDescriptionController: AppController = async (c) => { const instanceDescriptionController: AppController = async (c) => {
const meta = await getInstanceMetadata(await Storages.db(), c.req.raw.signal); const { relay, signal } = c.var;
const meta = await getInstanceMetadata(relay, signal);
return c.json({ return c.json({
content: meta.about, content: meta.about,

View file

@ -1,10 +1,10 @@
import { paginated } from '@ditto/mastoapi/pagination';
import { NostrFilter, NSchema as n } from '@nostrify/nostrify'; import { NostrFilter, NSchema as n } from '@nostrify/nostrify';
import { z } from 'zod'; import { z } from 'zod';
import { AppContext, AppController } from '@/app.ts'; import { AppContext, AppController } from '@/app.ts';
import { DittoPagination } from '@/interfaces/DittoPagination.ts'; import { DittoPagination } from '@/interfaces/DittoPagination.ts';
import { hydrateEvents } from '@/storages/hydrate.ts'; import { hydrateEvents } from '@/storages/hydrate.ts';
import { paginated } from '@/utils/api.ts';
import { renderNotification } from '@/views/mastodon/notifications.ts'; import { renderNotification } from '@/views/mastodon/notifications.ts';
/** Set of known notification types across backends. */ /** Set of known notification types across backends. */
@ -90,9 +90,9 @@ const notificationController: AppController = async (c) => {
return c.json({ error: 'Event not found' }, { status: 404 }); return c.json({ error: 'Event not found' }, { status: 404 });
} }
await hydrateEvents({ events: [event], relay }); await hydrateEvents({ ...c.var, events: [event] });
const notification = await renderNotification(event, { viewerPubkey: pubkey }); const notification = await renderNotification(relay, event, { viewerPubkey: pubkey });
if (!notification) { if (!notification) {
return c.json({ error: 'Notification not found' }, { status: 404 }); return c.json({ error: 'Notification not found' }, { status: 404 });
@ -116,14 +116,14 @@ async function renderNotifications(
const events = await relay const events = await relay
.query(filters, opts) .query(filters, opts)
.then((events) => events.filter((event) => event.pubkey !== pubkey)) .then((events) => events.filter((event) => event.pubkey !== pubkey))
.then((events) => hydrateEvents({ events, relay, signal })); .then((events) => hydrateEvents({ ...c.var, events }));
if (!events.length) { if (!events.length) {
return c.json([]); return c.json([]);
} }
const notifications = (await Promise.all(events.map((event) => { const notifications = (await Promise.all(events.map((event) => {
return renderNotification(event, { viewerPubkey: pubkey }); return renderNotification(relay, event, { viewerPubkey: pubkey });
}))) })))
.filter((notification) => notification && types.has(notification.type)); .filter((notification) => notification && types.has(notification.type));

View file

@ -3,8 +3,7 @@ import { escape } from 'entities';
import { generateSecretKey } from 'nostr-tools'; import { generateSecretKey } from 'nostr-tools';
import { z } from 'zod'; import { z } from 'zod';
import { AppController } from '@/app.ts'; import { AppContext, AppController } from '@/app.ts';
import { Storages } from '@/storages.ts';
import { nostrNow } from '@/utils.ts'; import { nostrNow } from '@/utils.ts';
import { parseBody } from '@/utils/api.ts'; import { parseBody } from '@/utils/api.ts';
import { aesEncrypt } from '@/utils/aes.ts'; import { aesEncrypt } from '@/utils/aes.ts';
@ -40,6 +39,7 @@ const createTokenSchema = z.discriminatedUnion('grant_type', [
const createTokenController: AppController = async (c) => { const createTokenController: AppController = async (c) => {
const { conf } = c.var; const { conf } = c.var;
const body = await parseBody(c.req.raw); const body = await parseBody(c.req.raw);
const result = createTokenSchema.safeParse(body); const result = createTokenSchema.safeParse(body);
@ -50,7 +50,7 @@ const createTokenController: AppController = async (c) => {
switch (result.data.grant_type) { switch (result.data.grant_type) {
case 'nostr_bunker': case 'nostr_bunker':
return c.json({ return c.json({
access_token: await getToken(result.data, conf.seckey), access_token: await getToken(c, result.data, conf.seckey),
token_type: 'Bearer', token_type: 'Bearer',
scope: 'read write follow push', scope: 'read write follow push',
created_at: nostrNow(), created_at: nostrNow(),
@ -90,6 +90,8 @@ const revokeTokenSchema = z.object({
* https://docs.joinmastodon.org/methods/oauth/#revoke * https://docs.joinmastodon.org/methods/oauth/#revoke
*/ */
const revokeTokenController: AppController = async (c) => { const revokeTokenController: AppController = async (c) => {
const { db } = c.var;
const body = await parseBody(c.req.raw); const body = await parseBody(c.req.raw);
const result = revokeTokenSchema.safeParse(body); const result = revokeTokenSchema.safeParse(body);
@ -99,10 +101,9 @@ const revokeTokenController: AppController = async (c) => {
const { token } = result.data; const { token } = result.data;
const kysely = await Storages.kysely();
const tokenHash = await getTokenHash(token as `token1${string}`); const tokenHash = await getTokenHash(token as `token1${string}`);
await kysely await db.kysely
.deleteFrom('auth_tokens') .deleteFrom('auth_tokens')
.where('token_hash', '=', tokenHash) .where('token_hash', '=', tokenHash)
.execute(); .execute();
@ -111,10 +112,11 @@ const revokeTokenController: AppController = async (c) => {
}; };
async function getToken( async function getToken(
c: AppContext,
{ pubkey: bunkerPubkey, secret, relays = [] }: { pubkey: string; secret?: string; relays?: string[] }, { pubkey: bunkerPubkey, secret, relays = [] }: { pubkey: string; secret?: string; relays?: string[] },
dittoSeckey: Uint8Array, dittoSeckey: Uint8Array,
): Promise<`token1${string}`> { ): Promise<`token1${string}`> {
const kysely = await Storages.kysely(); const { db, relay } = c.var;
const { token, hash } = await generateToken(); const { token, hash } = await generateToken();
const nip46Seckey = generateSecretKey(); const nip46Seckey = generateSecretKey();
@ -123,14 +125,14 @@ async function getToken(
encryption: 'nip44', encryption: 'nip44',
pubkey: bunkerPubkey, pubkey: bunkerPubkey,
signer: new NSecSigner(nip46Seckey), signer: new NSecSigner(nip46Seckey),
relay: await Storages.db(), // TODO: Use the relays from the request. relay,
timeout: 60_000, timeout: 60_000,
}); });
await signer.connect(secret); await signer.connect(secret);
const userPubkey = await signer.getPublicKey(); const userPubkey = await signer.getPublicKey();
await kysely.insertInto('auth_tokens').values({ await db.kysely.insertInto('auth_tokens').values({
token_hash: hash, token_hash: hash,
pubkey: userPubkey, pubkey: userPubkey,
bunker_pubkey: bunkerPubkey, bunker_pubkey: bunkerPubkey,
@ -236,7 +238,7 @@ const oauthAuthorizeController: AppController = async (c) => {
const bunker = new URL(bunker_uri); const bunker = new URL(bunker_uri);
const token = await getToken({ const token = await getToken(c, {
pubkey: bunker.hostname, pubkey: bunker.hostname,
secret: bunker.searchParams.get('secret') || undefined, secret: bunker.searchParams.get('secret') || undefined,
relays: bunker.searchParams.getAll('relay'), relays: bunker.searchParams.getAll('relay'),

View file

@ -71,7 +71,7 @@ const pleromaAdminTagController: AppController = async (c) => {
const params = pleromaAdminTagSchema.parse(await c.req.json()); const params = pleromaAdminTagSchema.parse(await c.req.json());
for (const nickname of params.nicknames) { for (const nickname of params.nicknames) {
const pubkey = await lookupPubkey(nickname); const pubkey = await lookupPubkey(nickname, c.var);
if (!pubkey) continue; if (!pubkey) continue;
await updateAdminEvent( await updateAdminEvent(
@ -104,7 +104,7 @@ const pleromaAdminUntagController: AppController = async (c) => {
const params = pleromaAdminTagSchema.parse(await c.req.json()); const params = pleromaAdminTagSchema.parse(await c.req.json());
for (const nickname of params.nicknames) { for (const nickname of params.nicknames) {
const pubkey = await lookupPubkey(nickname); const pubkey = await lookupPubkey(nickname, c.var);
if (!pubkey) continue; if (!pubkey) continue;
await updateAdminEvent( await updateAdminEvent(
@ -130,7 +130,7 @@ const pleromaAdminSuggestController: AppController = async (c) => {
const { nicknames } = pleromaAdminSuggestSchema.parse(await c.req.json()); const { nicknames } = pleromaAdminSuggestSchema.parse(await c.req.json());
for (const nickname of nicknames) { for (const nickname of nicknames) {
const pubkey = await lookupPubkey(nickname); const pubkey = await lookupPubkey(nickname, c.var);
if (!pubkey) continue; if (!pubkey) continue;
await updateUser(pubkey, { suggested: true }, c); await updateUser(pubkey, { suggested: true }, c);
} }
@ -142,7 +142,7 @@ const pleromaAdminUnsuggestController: AppController = async (c) => {
const { nicknames } = pleromaAdminSuggestSchema.parse(await c.req.json()); const { nicknames } = pleromaAdminSuggestSchema.parse(await c.req.json());
for (const nickname of nicknames) { for (const nickname of nicknames) {
const pubkey = await lookupPubkey(nickname); const pubkey = await lookupPubkey(nickname, c.var);
if (!pubkey) continue; if (!pubkey) continue;
await updateUser(pubkey, { suggested: false }, c); await updateUser(pubkey, { suggested: false }, c);
} }

View file

@ -3,7 +3,6 @@ import { nip19 } from 'nostr-tools';
import { z } from 'zod'; import { z } from 'zod';
import { AppController } from '@/app.ts'; import { AppController } from '@/app.ts';
import { Storages } from '@/storages.ts';
import { parseBody } from '@/utils/api.ts'; import { parseBody } from '@/utils/api.ts';
import { getTokenHash } from '@/utils/auth.ts'; import { getTokenHash } from '@/utils/auth.ts';
@ -42,7 +41,7 @@ const pushSubscribeSchema = z.object({
}); });
export const pushSubscribeController: AppController = async (c) => { export const pushSubscribeController: AppController = async (c) => {
const { conf, user } = c.var; const { conf, db, user } = c.var;
const vapidPublicKey = await conf.vapidPublicKey; const vapidPublicKey = await conf.vapidPublicKey;
if (!vapidPublicKey) { if (!vapidPublicKey) {
@ -50,8 +49,6 @@ export const pushSubscribeController: AppController = async (c) => {
} }
const accessToken = getAccessToken(c.req.raw); const accessToken = getAccessToken(c.req.raw);
const kysely = await Storages.kysely();
const signer = user!.signer; const signer = user!.signer;
const result = pushSubscribeSchema.safeParse(await parseBody(c.req.raw)); const result = pushSubscribeSchema.safeParse(await parseBody(c.req.raw));
@ -65,7 +62,7 @@ export const pushSubscribeController: AppController = async (c) => {
const pubkey = await signer.getPublicKey(); const pubkey = await signer.getPublicKey();
const tokenHash = await getTokenHash(accessToken); const tokenHash = await getTokenHash(accessToken);
const { id } = await kysely.transaction().execute(async (trx) => { const { id } = await db.kysely.transaction().execute(async (trx) => {
await trx await trx
.deleteFrom('push_subscriptions') .deleteFrom('push_subscriptions')
.where('token_hash', '=', tokenHash) .where('token_hash', '=', tokenHash)
@ -97,7 +94,7 @@ export const pushSubscribeController: AppController = async (c) => {
}; };
export const getSubscriptionController: AppController = async (c) => { export const getSubscriptionController: AppController = async (c) => {
const { conf } = c.var; const { conf, db } = c.var;
const vapidPublicKey = await conf.vapidPublicKey; const vapidPublicKey = await conf.vapidPublicKey;
if (!vapidPublicKey) { if (!vapidPublicKey) {
@ -106,10 +103,9 @@ export const getSubscriptionController: AppController = async (c) => {
const accessToken = getAccessToken(c.req.raw); const accessToken = getAccessToken(c.req.raw);
const kysely = await Storages.kysely();
const tokenHash = await getTokenHash(accessToken); const tokenHash = await getTokenHash(accessToken);
const row = await kysely const row = await db.kysely
.selectFrom('push_subscriptions') .selectFrom('push_subscriptions')
.selectAll() .selectAll()
.where('token_hash', '=', tokenHash) .where('token_hash', '=', tokenHash)

View file

@ -31,9 +31,9 @@ const reactionController: AppController = async (c) => {
tags: [['e', id], ['p', event.pubkey]], tags: [['e', id], ['p', event.pubkey]],
}, c); }, c);
await hydrateEvents({ events: [event], relay }); await hydrateEvents({ ...c.var, events: [event] });
const status = await renderStatus(event, { viewerPubkey: await user!.signer.getPublicKey() }); const status = await renderStatus(relay, event, { viewerPubkey: await user!.signer.getPublicKey() });
return c.json(status); return c.json(status);
}; };
@ -76,7 +76,7 @@ const deleteReactionController: AppController = async (c) => {
tags, tags,
}, c); }, c);
const status = renderStatus(event, { viewerPubkey: pubkey }); const status = renderStatus(relay, event, { viewerPubkey: pubkey });
return c.json(status); return c.json(status);
}; };
@ -99,7 +99,7 @@ const reactionsController: AppController = async (c) => {
const events = await relay.query([{ kinds: [7], '#e': [id], limit: 100 }]) const events = await relay.query([{ kinds: [7], '#e': [id], limit: 100 }])
.then((events) => events.filter(({ content }) => /^\p{RGI_Emoji}$/v.test(content))) .then((events) => events.filter(({ content }) => /^\p{RGI_Emoji}$/v.test(content)))
.then((events) => events.filter((event) => !emoji || event.content === emoji)) .then((events) => events.filter((event) => !emoji || event.content === emoji))
.then((events) => hydrateEvents({ events, relay })); .then((events) => hydrateEvents({ ...c.var, events }));
/** Events grouped by emoji. */ /** Events grouped by emoji. */
const byEmoji = events.reduce((acc, event) => { const byEmoji = events.reduce((acc, event) => {

View file

@ -1,8 +1,9 @@
import { paginated } from '@ditto/mastoapi/pagination';
import { NostrFilter, NSchema as n } from '@nostrify/nostrify'; import { NostrFilter, NSchema as n } from '@nostrify/nostrify';
import { z } from 'zod'; import { z } from 'zod';
import { type AppController } from '@/app.ts'; import { type AppController } from '@/app.ts';
import { createEvent, paginated, parseBody, updateEventInfo } from '@/utils/api.ts'; import { createEvent, parseBody, updateEventInfo } from '@/utils/api.ts';
import { hydrateEvents } from '@/storages/hydrate.ts'; import { hydrateEvents } from '@/storages/hydrate.ts';
import { renderAdminReport } from '@/views/mastodon/reports.ts'; import { renderAdminReport } from '@/views/mastodon/reports.ts';
import { renderReport } from '@/views/mastodon/reports.ts'; import { renderReport } from '@/views/mastodon/reports.ts';
@ -18,7 +19,7 @@ const reportSchema = z.object({
/** https://docs.joinmastodon.org/methods/reports/#post */ /** https://docs.joinmastodon.org/methods/reports/#post */
const reportController: AppController = async (c) => { const reportController: AppController = async (c) => {
const { conf, relay } = c.var; const { conf } = c.var;
const body = await parseBody(c.req.raw); const body = await parseBody(c.req.raw);
const result = reportSchema.safeParse(body); const result = reportSchema.safeParse(body);
@ -49,7 +50,7 @@ const reportController: AppController = async (c) => {
tags, tags,
}, c); }, c);
await hydrateEvents({ events: [event], relay }); await hydrateEvents({ ...c.var, events: [event] });
return c.json(await renderReport(event)); return c.json(await renderReport(event));
}; };
@ -94,10 +95,10 @@ const adminReportsController: AppController = async (c) => {
} }
const events = await relay.query([{ kinds: [1984], ids: [...ids] }]) const events = await relay.query([{ kinds: [1984], ids: [...ids] }])
.then((events) => hydrateEvents({ relay, events: events, signal: c.req.raw.signal })); .then((events) => hydrateEvents({ ...c.var, events }));
const reports = await Promise.all( const reports = await Promise.all(
events.map((event) => renderAdminReport(event, { viewerPubkey })), events.map((event) => renderAdminReport(relay, event, { viewerPubkey })),
); );
return paginated(c, orig, reports); return paginated(c, orig, reports);
@ -120,9 +121,9 @@ const adminReportController: AppController = async (c) => {
return c.json({ error: 'Not found' }, 404); return c.json({ error: 'Not found' }, 404);
} }
await hydrateEvents({ events: [event], relay, signal }); await hydrateEvents({ ...c.var, events: [event] });
const report = await renderAdminReport(event, { viewerPubkey: pubkey }); const report = await renderAdminReport(relay, event, { viewerPubkey: pubkey });
return c.json(report); return c.json(report);
}; };
@ -144,9 +145,9 @@ const adminReportResolveController: AppController = async (c) => {
} }
await updateEventInfo(eventId, { open: false, closed: true }, c); await updateEventInfo(eventId, { open: false, closed: true }, c);
await hydrateEvents({ events: [event], relay, signal }); await hydrateEvents({ ...c.var, events: [event] });
const report = await renderAdminReport(event, { viewerPubkey: pubkey }); const report = await renderAdminReport(relay, event, { viewerPubkey: pubkey });
return c.json(report); return c.json(report);
}; };
@ -167,9 +168,9 @@ const adminReportReopenController: AppController = async (c) => {
} }
await updateEventInfo(eventId, { open: true, closed: false }, c); await updateEventInfo(eventId, { open: true, closed: false }, c);
await hydrateEvents({ events: [event], relay, signal }); await hydrateEvents({ ...c.var, events: [event] });
const report = await renderAdminReport(event, { viewerPubkey: pubkey }); const report = await renderAdminReport(relay, event, { viewerPubkey: pubkey });
return c.json(report); return c.json(report);
}; };

View file

@ -1,18 +1,17 @@
import { paginated, paginatedList } from '@ditto/mastoapi/pagination';
import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify'; import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { z } from 'zod'; import { z } from 'zod';
import { AppController } from '@/app.ts'; import { AppContext, AppController } from '@/app.ts';
import { booleanParamSchema } from '@/schema.ts'; import { booleanParamSchema } from '@/schema.ts';
import { Storages } from '@/storages.ts';
import { hydrateEvents } from '@/storages/hydrate.ts'; import { hydrateEvents } from '@/storages/hydrate.ts';
import { extractIdentifier, lookupPubkey } from '@/utils/lookup.ts'; import { extractIdentifier, lookupPubkey } from '@/utils/lookup.ts';
import { nip05Cache } from '@/utils/nip05.ts'; import { lookupNip05 } from '@/utils/nip05.ts';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
import { renderStatus } from '@/views/mastodon/statuses.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts';
import { getFollowedPubkeys } from '@/queries.ts'; import { getFollowedPubkeys } from '@/queries.ts';
import { getPubkeysBySearch } from '@/utils/search.ts'; import { getPubkeysBySearch } from '@/utils/search.ts';
import { paginated, paginatedList } from '@/utils/api.ts';
const searchQuerySchema = z.object({ const searchQuerySchema = z.object({
q: z.string().transform(decodeURIComponent), q: z.string().transform(decodeURIComponent),
@ -26,7 +25,7 @@ const searchQuerySchema = z.object({
type SearchQuery = z.infer<typeof searchQuerySchema> & { since?: number; until?: number; limit: number }; type SearchQuery = z.infer<typeof searchQuerySchema> & { since?: number; until?: number; limit: number };
const searchController: AppController = async (c) => { const searchController: AppController = async (c) => {
const { user, pagination, signal } = c.var; const { relay, user, pagination, signal } = c.var;
const result = searchQuerySchema.safeParse(c.req.query()); const result = searchQuerySchema.safeParse(c.req.query());
const viewerPubkey = await user?.signer.getPublicKey(); const viewerPubkey = await user?.signer.getPublicKey();
@ -35,12 +34,12 @@ const searchController: AppController = async (c) => {
return c.json({ error: 'Bad request', schema: result.error }, 422); return c.json({ error: 'Bad request', schema: result.error }, 422);
} }
const event = await lookupEvent({ ...result.data, ...pagination }, signal); const event = await lookupEvent(c, { ...result.data, ...pagination });
const lookup = extractIdentifier(result.data.q); const lookup = extractIdentifier(result.data.q);
// Render account from pubkey. // Render account from pubkey.
if (!event && lookup) { if (!event && lookup) {
const pubkey = await lookupPubkey(lookup); const pubkey = await lookupPubkey(lookup, c.var);
return c.json({ return c.json({
accounts: pubkey ? [accountFromPubkey(pubkey)] : [], accounts: pubkey ? [accountFromPubkey(pubkey)] : [],
statuses: [], statuses: [],
@ -54,7 +53,7 @@ const searchController: AppController = async (c) => {
events = [event]; events = [event];
} }
events.push(...(await searchEvents({ ...result.data, ...pagination, viewerPubkey }, signal))); events.push(...(await searchEvents(c, { ...result.data, ...pagination, viewerPubkey }, signal)));
const [accounts, statuses] = await Promise.all([ const [accounts, statuses] = await Promise.all([
Promise.all( Promise.all(
@ -66,7 +65,7 @@ const searchController: AppController = async (c) => {
Promise.all( Promise.all(
events events
.filter((event) => event.kind === 1) .filter((event) => event.kind === 1)
.map((event) => renderStatus(event, { viewerPubkey })) .map((event) => renderStatus(relay, event, { viewerPubkey }))
.filter(Boolean), .filter(Boolean),
), ),
]); ]);
@ -86,16 +85,17 @@ const searchController: AppController = async (c) => {
/** Get events for the search params. */ /** Get events for the search params. */
async function searchEvents( async function searchEvents(
c: AppContext,
{ q, type, since, until, limit, offset, account_id, viewerPubkey }: SearchQuery & { viewerPubkey?: string }, { q, type, since, until, limit, offset, account_id, viewerPubkey }: SearchQuery & { viewerPubkey?: string },
signal: AbortSignal, signal: AbortSignal,
): Promise<NostrEvent[]> { ): Promise<NostrEvent[]> {
const { relay, db } = c.var;
// Hashtag search is not supported. // Hashtag search is not supported.
if (type === 'hashtags') { if (type === 'hashtags') {
return Promise.resolve([]); return Promise.resolve([]);
} }
const relay = await Storages.db();
const filter: NostrFilter = { const filter: NostrFilter = {
kinds: typeToKinds(type), kinds: typeToKinds(type),
search: q, search: q,
@ -104,12 +104,10 @@ async function searchEvents(
limit, limit,
}; };
const kysely = await Storages.kysely();
// For account search, use a special index, and prioritize followed accounts. // For account search, use a special index, and prioritize followed accounts.
if (type === 'accounts') { if (type === 'accounts') {
const following = viewerPubkey ? await getFollowedPubkeys(viewerPubkey) : new Set<string>(); const following = viewerPubkey ? await getFollowedPubkeys(relay, viewerPubkey) : new Set<string>();
const searchPubkeys = await getPubkeysBySearch(kysely, { q, limit, offset, following }); const searchPubkeys = await getPubkeysBySearch(db.kysely, { q, limit, offset, following });
filter.authors = [...searchPubkeys]; filter.authors = [...searchPubkeys];
filter.search = undefined; filter.search = undefined;
@ -123,7 +121,7 @@ async function searchEvents(
// Query the events. // Query the events.
let events = await relay let events = await relay
.query([filter], { signal }) .query([filter], { signal })
.then((events) => hydrateEvents({ events, relay, signal })); .then((events) => hydrateEvents({ ...c.var, events }));
// When using an authors filter, return the events in the same order as the filter. // When using an authors filter, return the events in the same order as the filter.
if (filter.authors) { if (filter.authors) {
@ -148,17 +146,17 @@ function typeToKinds(type: SearchQuery['type']): number[] {
} }
/** Resolve a searched value into an event, if applicable. */ /** Resolve a searched value into an event, if applicable. */
async function lookupEvent(query: SearchQuery, signal: AbortSignal): Promise<NostrEvent | undefined> { async function lookupEvent(c: AppContext, query: SearchQuery): Promise<NostrEvent | undefined> {
const filters = await getLookupFilters(query, signal); const { relay, signal } = c.var;
const relay = await Storages.db(); const filters = await getLookupFilters(c, query);
return relay.query(filters, { limit: 1, signal }) return relay.query(filters, { signal })
.then((events) => hydrateEvents({ events, relay, signal })) .then((events) => hydrateEvents({ ...c.var, events }))
.then(([event]) => event); .then(([event]) => event);
} }
/** Get filters to lookup the input value. */ /** Get filters to lookup the input value. */
async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: AbortSignal): Promise<NostrFilter[]> { async function getLookupFilters(c: AppContext, { q, type, resolve }: SearchQuery): Promise<NostrFilter[]> {
const accounts = !type || type === 'accounts'; const accounts = !type || type === 'accounts';
const statuses = !type || type === 'statuses'; const statuses = !type || type === 'statuses';
@ -199,7 +197,7 @@ async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: Abort
} }
try { try {
const { pubkey } = await nip05Cache.fetch(lookup, { signal }); const { pubkey } = await lookupNip05(lookup, c.var);
if (pubkey) { if (pubkey) {
return [{ kinds: [0], authors: [pubkey] }]; return [{ kinds: [0], authors: [pubkey] }];
} }

View file

@ -1,4 +1,5 @@
import { HTTPException } from '@hono/hono/http-exception'; import { HTTPException } from '@hono/hono/http-exception';
import { paginated, paginatedList } from '@ditto/mastoapi/pagination';
import { NostrEvent, NSchema as n } from '@nostrify/nostrify'; import { NostrEvent, NSchema as n } from '@nostrify/nostrify';
import 'linkify-plugin-hashtag'; import 'linkify-plugin-hashtag';
import linkify from 'linkifyjs'; import linkify from 'linkifyjs';
@ -15,7 +16,7 @@ import { asyncReplaceAll } from '@/utils/text.ts';
import { lookupPubkey } from '@/utils/lookup.ts'; import { lookupPubkey } from '@/utils/lookup.ts';
import { languageSchema } from '@/schema.ts'; import { languageSchema } from '@/schema.ts';
import { hydrateEvents } from '@/storages/hydrate.ts'; import { hydrateEvents } from '@/storages/hydrate.ts';
import { assertAuthenticated, createEvent, paginated, paginatedList, parseBody, updateListEvent } from '@/utils/api.ts'; import { assertAuthenticated, createEvent, parseBody, updateListEvent } from '@/utils/api.ts';
import { getInvoice, getLnurl } from '@/utils/lnurl.ts'; import { getInvoice, getLnurl } from '@/utils/lnurl.ts';
import { purifyEvent } from '@/utils/purify.ts'; import { purifyEvent } from '@/utils/purify.ts';
import { getZapSplits } from '@/utils/zap-split.ts'; import { getZapSplits } from '@/utils/zap-split.ts';
@ -46,10 +47,10 @@ const createStatusSchema = z.object({
); );
const statusController: AppController = async (c) => { const statusController: AppController = async (c) => {
const { user, signal } = c.var; const { relay, user } = c.var;
const id = c.req.param('id'); const id = c.req.param('id');
const event = await getEvent(id, { signal }); const event = await getEvent(id, c.var);
if (event?.author) { if (event?.author) {
assertAuthenticated(c, event.author); assertAuthenticated(c, event.author);
@ -57,7 +58,7 @@ const statusController: AppController = async (c) => {
if (event) { if (event) {
const viewerPubkey = await user?.signer.getPublicKey(); const viewerPubkey = await user?.signer.getPublicKey();
const status = await renderStatus(event, { viewerPubkey }); const status = await renderStatus(relay, event, { viewerPubkey });
return c.json(status); return c.json(status);
} }
@ -65,7 +66,7 @@ const statusController: AppController = async (c) => {
}; };
const createStatusController: AppController = async (c) => { const createStatusController: AppController = async (c) => {
const { conf, relay, user, signal } = c.var; const { conf, relay, user } = c.var;
const body = await parseBody(c.req.raw); const body = await parseBody(c.req.raw);
const result = createStatusSchema.safeParse(body); const result = createStatusSchema.safeParse(body);
@ -153,7 +154,7 @@ const createStatusController: AppController = async (c) => {
data.status ?? '', data.status ?? '',
/(?<![\w/])@([\w@+._-]+)(?![\w/\.])/g, /(?<![\w/])@([\w@+._-]+)(?![\w/\.])/g,
async (match, username) => { async (match, username) => {
const pubkey = await lookupPubkey(username); const pubkey = await lookupPubkey(username, c.var);
if (!pubkey) return match; if (!pubkey) return match;
// Content addressing (default) // Content addressing (default)
@ -171,7 +172,7 @@ const createStatusController: AppController = async (c) => {
// Explicit addressing // Explicit addressing
for (const to of data.to ?? []) { for (const to of data.to ?? []) {
const pubkey = await lookupPubkey(to); const pubkey = await lookupPubkey(to, c.var);
if (pubkey) { if (pubkey) {
pubkeys.add(pubkey); pubkeys.add(pubkey);
} }
@ -191,7 +192,7 @@ const createStatusController: AppController = async (c) => {
} }
const pubkey = await user!.signer.getPublicKey(); const pubkey = await user!.signer.getPublicKey();
const author = pubkey ? await getAuthor(pubkey) : undefined; const author = pubkey ? await getAuthor(pubkey, c.var) : undefined;
if (conf.zapSplitsEnabled) { if (conf.zapSplitsEnabled) {
const meta = n.json().pipe(n.metadata()).catch({}).parse(author?.content); const meta = n.json().pipe(n.metadata()).catch({}).parse(author?.content);
@ -254,22 +255,18 @@ const createStatusController: AppController = async (c) => {
}, c); }, c);
if (data.quote_id) { if (data.quote_id) {
await hydrateEvents({ await hydrateEvents({ ...c.var, events: [event] });
events: [event],
relay,
signal,
});
} }
return c.json(await renderStatus({ ...event, author }, { viewerPubkey: author?.pubkey })); return c.json(await renderStatus(relay, { ...event, author }, { viewerPubkey: author?.pubkey }));
}; };
const deleteStatusController: AppController = async (c) => { const deleteStatusController: AppController = async (c) => {
const { conf, user, signal } = c.var; const { conf, relay, user } = c.var;
const id = c.req.param('id'); const id = c.req.param('id');
const pubkey = await user?.signer.getPublicKey(); const pubkey = await user?.signer.getPublicKey();
const event = await getEvent(id, { signal }); const event = await getEvent(id, c.var);
if (event) { if (event) {
if (event.pubkey === pubkey) { if (event.pubkey === pubkey) {
@ -278,8 +275,8 @@ const deleteStatusController: AppController = async (c) => {
tags: [['e', id, conf.relay, '', pubkey]], tags: [['e', id, conf.relay, '', pubkey]],
}, c); }, c);
const author = await getAuthor(event.pubkey); const author = await getAuthor(event.pubkey, c.var);
return c.json(await renderStatus({ ...event, author }, { viewerPubkey: pubkey })); return c.json(await renderStatus(relay, { ...event, author }, { viewerPubkey: pubkey }));
} else { } else {
return c.json({ error: 'Unauthorized' }, 403); return c.json({ error: 'Unauthorized' }, 403);
} }
@ -297,7 +294,7 @@ const contextController: AppController = async (c) => {
async function renderStatuses(events: NostrEvent[]) { async function renderStatuses(events: NostrEvent[]) {
const statuses = await Promise.all( const statuses = await Promise.all(
events.map((event) => renderStatus(event, { viewerPubkey })), events.map((event) => renderStatus(relay, event, { viewerPubkey })),
); );
return statuses.filter(Boolean); return statuses.filter(Boolean);
} }
@ -308,11 +305,7 @@ const contextController: AppController = async (c) => {
getDescendants(relay, event), getDescendants(relay, event),
]); ]);
await hydrateEvents({ await hydrateEvents({ ...c.var, events: [...ancestorEvents, ...descendantEvents] });
events: [...ancestorEvents, ...descendantEvents],
signal: c.req.raw.signal,
relay,
});
const [ancestors, descendants] = await Promise.all([ const [ancestors, descendants] = await Promise.all([
renderStatuses(ancestorEvents), renderStatuses(ancestorEvents),
@ -341,9 +334,9 @@ const favouriteController: AppController = async (c) => {
], ],
}, c); }, c);
await hydrateEvents({ events: [target], relay }); await hydrateEvents({ ...c.var, events: [target] });
const status = await renderStatus(target, { viewerPubkey: await user?.signer.getPublicKey() }); const status = await renderStatus(relay, target, { viewerPubkey: await user?.signer.getPublicKey() });
if (status) { if (status) {
status.favourited = true; status.favourited = true;
@ -367,10 +360,10 @@ const favouritedByController: AppController = (c) => {
/** https://docs.joinmastodon.org/methods/statuses/#boost */ /** https://docs.joinmastodon.org/methods/statuses/#boost */
const reblogStatusController: AppController = async (c) => { const reblogStatusController: AppController = async (c) => {
const { conf, relay, user, signal } = c.var; const { conf, relay, user } = c.var;
const eventId = c.req.param('id'); const eventId = c.req.param('id');
const event = await getEvent(eventId); const event = await getEvent(eventId, c.var);
if (!event) { if (!event) {
return c.json({ error: 'Event not found.' }, 404); return c.json({ error: 'Event not found.' }, 404);
@ -384,13 +377,9 @@ const reblogStatusController: AppController = async (c) => {
], ],
}, c); }, c);
await hydrateEvents({ await hydrateEvents({ ...c.var, events: [reblogEvent] });
events: [reblogEvent],
relay,
signal: signal,
});
const status = await renderReblog(reblogEvent, { viewerPubkey: await user?.signer.getPublicKey() }); const status = await renderReblog(relay, reblogEvent, { viewerPubkey: await user?.signer.getPublicKey() });
return c.json(status); return c.json(status);
}; };
@ -420,7 +409,7 @@ const unreblogStatusController: AppController = async (c) => {
tags: [['e', repostEvent.id, conf.relay, '', repostEvent.pubkey]], tags: [['e', repostEvent.id, conf.relay, '', repostEvent.pubkey]],
}, c); }, c);
return c.json(await renderStatus(event, { viewerPubkey: pubkey })); return c.json(await renderStatus(relay, event, { viewerPubkey: pubkey }));
}; };
const rebloggedByController: AppController = (c) => { const rebloggedByController: AppController = (c) => {
@ -441,12 +430,12 @@ const quotesController: AppController = async (c) => {
const quotes = await relay const quotes = await relay
.query([{ kinds: [1, 20], '#q': [event.id], ...pagination }]) .query([{ kinds: [1, 20], '#q': [event.id], ...pagination }])
.then((events) => hydrateEvents({ events, relay })); .then((events) => hydrateEvents({ ...c.var, events }));
const viewerPubkey = await user?.signer.getPublicKey(); const viewerPubkey = await user?.signer.getPublicKey();
const statuses = await Promise.all( const statuses = await Promise.all(
quotes.map((event) => renderStatus(event, { viewerPubkey })), quotes.map((event) => renderStatus(relay, event, { viewerPubkey })),
); );
if (!statuses.length) { if (!statuses.length) {
@ -458,11 +447,11 @@ const quotesController: AppController = async (c) => {
/** https://docs.joinmastodon.org/methods/statuses/#bookmark */ /** https://docs.joinmastodon.org/methods/statuses/#bookmark */
const bookmarkController: AppController = async (c) => { const bookmarkController: AppController = async (c) => {
const { conf, user } = c.var; const { conf, relay, user } = c.var;
const pubkey = await user!.signer.getPublicKey(); const pubkey = await user!.signer.getPublicKey();
const eventId = c.req.param('id'); const eventId = c.req.param('id');
const event = await getEvent(eventId); const event = await getEvent(eventId, c.var);
if (event) { if (event) {
await updateListEvent( await updateListEvent(
@ -471,7 +460,7 @@ const bookmarkController: AppController = async (c) => {
c, c,
); );
const status = await renderStatus(event, { viewerPubkey: pubkey }); const status = await renderStatus(relay, event, { viewerPubkey: pubkey });
if (status) { if (status) {
status.bookmarked = true; status.bookmarked = true;
} }
@ -483,12 +472,12 @@ const bookmarkController: AppController = async (c) => {
/** https://docs.joinmastodon.org/methods/statuses/#unbookmark */ /** https://docs.joinmastodon.org/methods/statuses/#unbookmark */
const unbookmarkController: AppController = async (c) => { const unbookmarkController: AppController = async (c) => {
const { conf, user } = c.var; const { conf, relay, user } = c.var;
const pubkey = await user!.signer.getPublicKey(); const pubkey = await user!.signer.getPublicKey();
const eventId = c.req.param('id'); const eventId = c.req.param('id');
const event = await getEvent(eventId); const event = await getEvent(eventId, c.var);
if (event) { if (event) {
await updateListEvent( await updateListEvent(
@ -497,7 +486,7 @@ const unbookmarkController: AppController = async (c) => {
c, c,
); );
const status = await renderStatus(event, { viewerPubkey: pubkey }); const status = await renderStatus(relay, event, { viewerPubkey: pubkey });
if (status) { if (status) {
status.bookmarked = false; status.bookmarked = false;
} }
@ -509,12 +498,12 @@ const unbookmarkController: AppController = async (c) => {
/** https://docs.joinmastodon.org/methods/statuses/#pin */ /** https://docs.joinmastodon.org/methods/statuses/#pin */
const pinController: AppController = async (c) => { const pinController: AppController = async (c) => {
const { conf, user } = c.var; const { conf, relay, user } = c.var;
const pubkey = await user!.signer.getPublicKey(); const pubkey = await user!.signer.getPublicKey();
const eventId = c.req.param('id'); const eventId = c.req.param('id');
const event = await getEvent(eventId); const event = await getEvent(eventId, c.var);
if (event) { if (event) {
await updateListEvent( await updateListEvent(
@ -523,7 +512,7 @@ const pinController: AppController = async (c) => {
c, c,
); );
const status = await renderStatus(event, { viewerPubkey: pubkey }); const status = await renderStatus(relay, event, { viewerPubkey: pubkey });
if (status) { if (status) {
status.pinned = true; status.pinned = true;
} }
@ -535,15 +524,12 @@ const pinController: AppController = async (c) => {
/** https://docs.joinmastodon.org/methods/statuses/#unpin */ /** https://docs.joinmastodon.org/methods/statuses/#unpin */
const unpinController: AppController = async (c) => { const unpinController: AppController = async (c) => {
const { conf, user, signal } = c.var; const { conf, relay, user } = c.var;
const pubkey = await user!.signer.getPublicKey(); const pubkey = await user!.signer.getPublicKey();
const eventId = c.req.param('id'); const eventId = c.req.param('id');
const event = await getEvent(eventId, { const event = await getEvent(eventId, c.var);
kind: 1,
signal,
});
if (event) { if (event) {
await updateListEvent( await updateListEvent(
@ -552,7 +538,7 @@ const unpinController: AppController = async (c) => {
c, c,
); );
const status = await renderStatus(event, { viewerPubkey: pubkey }); const status = await renderStatus(relay, event, { viewerPubkey: pubkey });
if (status) { if (status) {
status.pinned = false; status.pinned = false;
} }
@ -586,7 +572,7 @@ const zapController: AppController = async (c) => {
let lnurl: undefined | string; let lnurl: undefined | string;
if (status_id) { if (status_id) {
target = await getEvent(status_id, { kind: 1, signal }); target = await getEvent(status_id, c.var);
const author = target?.author; const author = target?.author;
const meta = n.json().pipe(n.metadata()).catch({}).parse(author?.content); const meta = n.json().pipe(n.metadata()).catch({}).parse(author?.content);
lnurl = getLnurl(meta); lnurl = getLnurl(meta);

View file

@ -5,7 +5,7 @@ import {
streamingServerMessagesCounter, streamingServerMessagesCounter,
} from '@ditto/metrics'; } from '@ditto/metrics';
import TTLCache from '@isaacs/ttlcache'; import TTLCache from '@isaacs/ttlcache';
import { NostrEvent, NostrFilter } from '@nostrify/nostrify'; import { NostrEvent, NostrFilter, NStore } from '@nostrify/nostrify';
import { logi } from '@soapbox/logi'; import { logi } from '@soapbox/logi';
import { z } from 'zod'; import { z } from 'zod';
@ -111,7 +111,7 @@ const streamingController: AppController = async (c) => {
} }
} }
await hydrateEvents({ events: [event], relay, signal: AbortSignal.timeout(1000) }); await hydrateEvents({ ...c.var, events: [event] });
const result = await render(event); const result = await render(event);
@ -130,17 +130,17 @@ const streamingController: AppController = async (c) => {
streamingConnectionsGauge.set(connections.size); streamingConnectionsGauge.set(connections.size);
if (!stream) return; if (!stream) return;
const topicFilter = await topicToFilter(stream, c.req.query(), pubkey, conf.url.host); const topicFilter = await topicToFilter(relay, stream, c.req.query(), pubkey, conf.url.host);
if (topicFilter) { if (topicFilter) {
sub(topicFilter, async (event) => { sub(topicFilter, async (event) => {
let payload: object | undefined; let payload: object | undefined;
if (event.kind === 1) { if (event.kind === 1) {
payload = await renderStatus(event, { viewerPubkey: pubkey }); payload = await renderStatus(relay, event, { viewerPubkey: pubkey });
} }
if (event.kind === 6) { if (event.kind === 6) {
payload = await renderReblog(event, { viewerPubkey: pubkey }); payload = await renderReblog(relay, event, { viewerPubkey: pubkey });
} }
if (payload) { if (payload) {
@ -156,13 +156,13 @@ const streamingController: AppController = async (c) => {
if (['user', 'user:notification'].includes(stream) && pubkey) { if (['user', 'user:notification'].includes(stream) && pubkey) {
sub({ '#p': [pubkey], limit: 0 }, async (event) => { sub({ '#p': [pubkey], limit: 0 }, async (event) => {
if (event.pubkey === pubkey) return; // skip own events if (event.pubkey === pubkey) return; // skip own events
const payload = await renderNotification(event, { viewerPubkey: pubkey }); const payload = await renderNotification(relay, event, { viewerPubkey: pubkey });
if (payload) { if (payload) {
return { return {
event: 'notification', event: 'notification',
payload: JSON.stringify(payload), payload: JSON.stringify(payload),
stream: [stream], stream: [stream],
}; } satisfies StreamingEvent;
} }
}); });
return; return;
@ -198,6 +198,7 @@ const streamingController: AppController = async (c) => {
}; };
async function topicToFilter( async function topicToFilter(
relay: NStore,
topic: Stream, topic: Stream,
query: Record<string, string>, query: Record<string, string>,
pubkey: string | undefined, pubkey: string | undefined,
@ -218,7 +219,7 @@ async function topicToFilter(
// HACK: this puts the user's entire contacts list into RAM, // HACK: this puts the user's entire contacts list into RAM,
// and then calls `matchFilters` over it. Refreshing the page // and then calls `matchFilters` over it. Refreshing the page
// is required after following a new user. // is required after following a new user.
return pubkey ? { kinds: [1, 6, 20], authors: [...await getFeedPubkeys(pubkey)], limit: 0 } : undefined; return pubkey ? { kinds: [1, 6, 20], authors: [...await getFeedPubkeys(relay, pubkey)], limit: 0 } : undefined;
} }
} }

View file

@ -1,10 +1,10 @@
import { paginated, paginatedList } from '@ditto/mastoapi/pagination';
import { NostrFilter } from '@nostrify/nostrify'; import { NostrFilter } from '@nostrify/nostrify';
import { matchFilter } from 'nostr-tools'; import { matchFilter } from 'nostr-tools';
import { AppContext, AppController } from '@/app.ts'; import { AppContext, AppController } from '@/app.ts';
import { paginationSchema } from '@/schemas/pagination.ts'; import { paginationSchema } from '@/schemas/pagination.ts';
import { hydrateEvents } from '@/storages/hydrate.ts'; import { hydrateEvents } from '@/storages/hydrate.ts';
import { paginated, paginatedList } from '@/utils/api.ts';
import { getTagSet } from '@/utils/tags.ts'; import { getTagSet } from '@/utils/tags.ts';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
@ -82,7 +82,7 @@ async function renderV2Suggestions(c: AppContext, params: { offset: number; limi
[{ kinds: [0], authors, limit: authors.length }], [{ kinds: [0], authors, limit: authors.length }],
{ signal }, { signal },
) )
.then((events) => hydrateEvents({ events, relay, signal })); .then((events) => hydrateEvents({ ...c.var, events }));
return Promise.all(authors.map(async (pubkey) => { return Promise.all(authors.map(async (pubkey) => {
const profile = profiles.find((event) => event.pubkey === pubkey); const profile = profiles.find((event) => event.pubkey === pubkey);
@ -115,7 +115,7 @@ export const localSuggestionsController: AppController = async (c) => {
[{ kinds: [0], authors: [...pubkeys], search: `domain:${conf.url.host}`, ...pagination }], [{ kinds: [0], authors: [...pubkeys], search: `domain:${conf.url.host}`, ...pagination }],
{ signal }, { signal },
) )
.then((events) => hydrateEvents({ relay, events, signal })); .then((events) => hydrateEvents({ ...c.var, events }));
const suggestions = [...pubkeys].map((pubkey) => { const suggestions = [...pubkeys].map((pubkey) => {
const profile = profiles.find((event) => event.pubkey === pubkey); const profile = profiles.find((event) => event.pubkey === pubkey);

View file

@ -1,3 +1,4 @@
import { paginated } from '@ditto/mastoapi/pagination';
import { NostrFilter } from '@nostrify/nostrify'; import { NostrFilter } from '@nostrify/nostrify';
import { z } from 'zod'; import { z } from 'zod';
@ -5,7 +6,6 @@ import { type AppContext, type AppController } from '@/app.ts';
import { getFeedPubkeys } from '@/queries.ts'; import { getFeedPubkeys } from '@/queries.ts';
import { booleanParamSchema, languageSchema } from '@/schema.ts'; import { booleanParamSchema, languageSchema } from '@/schema.ts';
import { hydrateEvents } from '@/storages/hydrate.ts'; import { hydrateEvents } from '@/storages/hydrate.ts';
import { paginated } from '@/utils/api.ts';
import { getTagSet } from '@/utils/tags.ts'; import { getTagSet } from '@/utils/tags.ts';
import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts'; import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts';
@ -15,7 +15,7 @@ const homeQuerySchema = z.object({
}); });
const homeTimelineController: AppController = async (c) => { const homeTimelineController: AppController = async (c) => {
const { user, pagination } = c.var; const { relay, user, pagination } = c.var;
const pubkey = await user?.signer.getPublicKey()!; const pubkey = await user?.signer.getPublicKey()!;
const result = homeQuerySchema.safeParse(c.req.query()); const result = homeQuerySchema.safeParse(c.req.query());
@ -25,7 +25,7 @@ const homeTimelineController: AppController = async (c) => {
const { exclude_replies, only_media } = result.data; const { exclude_replies, only_media } = result.data;
const authors = [...await getFeedPubkeys(pubkey)]; const authors = [...await getFeedPubkeys(relay, pubkey)];
const filter: NostrFilter = { authors, kinds: [1, 6, 20], ...pagination }; const filter: NostrFilter = { authors, kinds: [1, 6, 20], ...pagination };
const search: string[] = []; const search: string[] = [];
@ -110,7 +110,7 @@ async function renderStatuses(c: AppContext, filters: NostrFilter[]) {
const events = await relay const events = await relay
.query(filters, opts) .query(filters, opts)
.then((events) => hydrateEvents({ events, relay, signal })); .then((events) => hydrateEvents({ ...c.var, events }));
if (!events.length) { if (!events.length) {
return c.json([]); return c.json([]);
@ -120,9 +120,9 @@ async function renderStatuses(c: AppContext, filters: NostrFilter[]) {
const statuses = (await Promise.all(events.map((event) => { const statuses = (await Promise.all(events.map((event) => {
if (event.kind === 6) { if (event.kind === 6) {
return renderReblog(event, { viewerPubkey }); return renderReblog(relay, event, { viewerPubkey });
} }
return renderStatus(event, { viewerPubkey }); return renderStatus(relay, event, { viewerPubkey });
}))).filter(Boolean); }))).filter(Boolean);
if (!statuses.length) { if (!statuses.length) {

View file

@ -17,7 +17,7 @@ const translateSchema = z.object({
}); });
const translateController: AppController = async (c) => { const translateController: AppController = async (c) => {
const { user, signal } = c.var; const { relay, user, signal } = c.var;
const result = translateSchema.safeParse(await parseBody(c.req.raw)); const result = translateSchema.safeParse(await parseBody(c.req.raw));
@ -34,7 +34,7 @@ const translateController: AppController = async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const event = await getEvent(id, { signal }); const event = await getEvent(id, c.var);
if (!event) { if (!event) {
return c.json({ error: 'Record not found' }, 400); return c.json({ error: 'Record not found' }, 400);
} }
@ -45,7 +45,7 @@ const translateController: AppController = async (c) => {
return c.json({ error: 'Source and target languages are the same. No translation needed.' }, 400); return c.json({ error: 'Source and target languages are the same. No translation needed.' }, 400);
} }
const status = await renderStatus(event, { viewerPubkey }); const status = await renderStatus(relay, event, { viewerPubkey });
if (!status?.content) { if (!status?.content) {
return c.json({ error: 'Bad request.', schema: result.error }, 400); return c.json({ error: 'Bad request.', schema: result.error }, 400);
} }

View file

@ -1,34 +1,45 @@
import { type DittoConf } from '@ditto/conf'; import { type DittoConf } from '@ditto/conf';
import { paginated } from '@ditto/mastoapi/pagination';
import { NostrEvent, NostrFilter, NStore } from '@nostrify/nostrify'; import { NostrEvent, NostrFilter, NStore } from '@nostrify/nostrify';
import { logi } from '@soapbox/logi'; import { logi } from '@soapbox/logi';
import { z } from 'zod'; import { z } from 'zod';
import { AppController } from '@/app.ts'; import { AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { paginationSchema } from '@/schemas/pagination.ts'; import { paginationSchema } from '@/schemas/pagination.ts';
import { hydrateEvents } from '@/storages/hydrate.ts'; import { hydrateEvents } from '@/storages/hydrate.ts';
import { Storages } from '@/storages.ts';
import { generateDateRange, Time } from '@/utils/time.ts'; import { generateDateRange, Time } from '@/utils/time.ts';
import { unfurlCardCached } from '@/utils/unfurl.ts'; import { PreviewCard, unfurlCardCached } from '@/utils/unfurl.ts';
import { paginated } from '@/utils/api.ts';
import { errorJson } from '@/utils/log.ts'; import { errorJson } from '@/utils/log.ts';
import { renderStatus } from '@/views/mastodon/statuses.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts';
let trendingHashtagsCache = getTrendingHashtags(Conf).catch((e: unknown) => { interface TrendHistory {
logi({ day: string;
level: 'error', accounts: string;
ns: 'ditto.trends.api', uses: string;
type: 'tags', }
msg: 'Failed to get trending hashtags',
error: errorJson(e), interface TrendingHashtag {
}); name: string;
return Promise.resolve([]); url: string;
history: TrendHistory[];
}
interface TrendingLink extends PreviewCard {
history: TrendHistory[];
}
const trendingTagsQuerySchema = z.object({
limit: z.coerce.number().catch(10).transform((value) => Math.min(Math.max(value, 0), 20)),
offset: z.number().nonnegative().catch(0),
}); });
Deno.cron('update trending hashtags cache', '35 * * * *', async () => { const trendingTagsController: AppController = async (c) => {
const { conf, relay } = c.var;
const { limit, offset } = trendingTagsQuerySchema.parse(c.req.query());
try { try {
const trends = await getTrendingHashtags(Conf); const trends = await getTrendingHashtags(conf, relay);
trendingHashtagsCache = Promise.resolve(trends); return c.json(trends.slice(offset, offset + limit));
} catch (e) { } catch (e) {
logi({ logi({
level: 'error', level: 'error',
@ -37,22 +48,11 @@ Deno.cron('update trending hashtags cache', '35 * * * *', async () => {
msg: 'Failed to get trending hashtags', msg: 'Failed to get trending hashtags',
error: errorJson(e), error: errorJson(e),
}); });
return c.json([]);
} }
});
const trendingTagsQuerySchema = z.object({
limit: z.coerce.number().catch(10).transform((value) => Math.min(Math.max(value, 0), 20)),
offset: z.number().nonnegative().catch(0),
});
const trendingTagsController: AppController = async (c) => {
const { limit, offset } = trendingTagsQuerySchema.parse(c.req.query());
const trends = await trendingHashtagsCache;
return c.json(trends.slice(offset, offset + limit));
}; };
async function getTrendingHashtags(conf: DittoConf) { async function getTrendingHashtags(conf: DittoConf, relay: NStore): Promise<TrendingHashtag[]> {
const relay = await Storages.db();
const trends = await getTrendingTags(relay, 't', await conf.signer.getPublicKey()); const trends = await getTrendingTags(relay, 't', await conf.signer.getPublicKey());
return trends.map((trend) => { return trends.map((trend) => {
@ -72,21 +72,12 @@ async function getTrendingHashtags(conf: DittoConf) {
}); });
} }
let trendingLinksCache = getTrendingLinks(Conf).catch((e: unknown) => { const trendingLinksController: AppController = async (c) => {
logi({ const { conf, relay } = c.var;
level: 'error', const { limit, offset } = trendingTagsQuerySchema.parse(c.req.query());
ns: 'ditto.trends.api',
type: 'links',
msg: 'Failed to get trending links',
error: errorJson(e),
});
return Promise.resolve([]);
});
Deno.cron('update trending links cache', '50 * * * *', async () => {
try { try {
const trends = await getTrendingLinks(Conf); const trends = await getTrendingLinks(conf, relay);
trendingLinksCache = Promise.resolve(trends); return c.json(trends.slice(offset, offset + limit));
} catch (e) { } catch (e) {
logi({ logi({
level: 'error', level: 'error',
@ -95,17 +86,11 @@ Deno.cron('update trending links cache', '50 * * * *', async () => {
msg: 'Failed to get trending links', msg: 'Failed to get trending links',
error: errorJson(e), error: errorJson(e),
}); });
return c.json([]);
} }
});
const trendingLinksController: AppController = async (c) => {
const { limit, offset } = trendingTagsQuerySchema.parse(c.req.query());
const trends = await trendingLinksCache;
return c.json(trends.slice(offset, offset + limit));
}; };
async function getTrendingLinks(conf: DittoConf) { async function getTrendingLinks(conf: DittoConf, relay: NStore): Promise<TrendingLink[]> {
const relay = await Storages.db();
const trends = await getTrendingTags(relay, 'r', await conf.signer.getPublicKey()); const trends = await getTrendingTags(relay, 'r', await conf.signer.getPublicKey());
return Promise.all(trends.map(async (trend) => { return Promise.all(trends.map(async (trend) => {
@ -162,7 +147,7 @@ const trendingStatusesController: AppController = async (c) => {
} }
const results = await relay.query([{ kinds: [1, 20], ids }]) const results = await relay.query([{ kinds: [1, 20], ids }])
.then((events) => hydrateEvents({ events, relay })); .then((events) => hydrateEvents({ ...c.var, events }));
// Sort events in the order they appear in the label. // Sort events in the order they appear in the label.
const events = ids const events = ids
@ -170,7 +155,7 @@ const trendingStatusesController: AppController = async (c) => {
.filter((event): event is NostrEvent => !!event); .filter((event): event is NostrEvent => !!event);
const statuses = await Promise.all( const statuses = await Promise.all(
events.map((event) => renderStatus(event, {})), events.map((event) => renderStatus(relay, event, {})),
); );
return paginated(c, results, statuses); return paginated(c, results, statuses);

View file

@ -1,6 +1,6 @@
import { logi } from '@soapbox/logi'; import { logi } from '@soapbox/logi';
import { AppMiddleware } from '@/app.ts'; import { AppContext, AppMiddleware } from '@/app.ts';
import { getPathParams, MetadataEntities } from '@/utils/og-metadata.ts'; import { getPathParams, MetadataEntities } from '@/utils/og-metadata.ts';
import { getInstanceMetadata } from '@/utils/instance.ts'; import { getInstanceMetadata } from '@/utils/instance.ts';
import { errorJson } from '@/utils/log.ts'; import { errorJson } from '@/utils/log.ts';
@ -9,14 +9,11 @@ import { renderMetadata } from '@/views/meta.ts';
import { getAuthor, getEvent } from '@/queries.ts'; import { getAuthor, getEvent } from '@/queries.ts';
import { renderStatus } from '@/views/mastodon/statuses.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts';
import { renderAccount } from '@/views/mastodon/accounts.ts'; import { renderAccount } from '@/views/mastodon/accounts.ts';
import { NStore } from '@nostrify/nostrify';
/** Placeholder to find & replace with metadata. */ /** Placeholder to find & replace with metadata. */
const META_PLACEHOLDER = '<!--server-generated-meta-->' as const; const META_PLACEHOLDER = '<!--server-generated-meta-->' as const;
export const frontendController: AppMiddleware = async (c) => { export const frontendController: AppMiddleware = async (c) => {
const { relay } = c.var;
c.header('Cache-Control', 'max-age=86400, s-maxage=30, public, stale-if-error=604800'); c.header('Cache-Control', 'max-age=86400, s-maxage=30, public, stale-if-error=604800');
try { try {
@ -25,7 +22,7 @@ export const frontendController: AppMiddleware = async (c) => {
if (content.includes(META_PLACEHOLDER)) { if (content.includes(META_PLACEHOLDER)) {
const params = getPathParams(c.req.path); const params = getPathParams(c.req.path);
try { try {
const entities = await getEntities(relay, params ?? {}); const entities = await getEntities(c, params ?? {});
const meta = renderMetadata(c.req.url, entities); const meta = renderMetadata(c.req.url, entities);
return c.html(content.replace(META_PLACEHOLDER, meta)); return c.html(content.replace(META_PLACEHOLDER, meta));
} catch (e) { } catch (e) {
@ -39,25 +36,27 @@ export const frontendController: AppMiddleware = async (c) => {
} }
}; };
async function getEntities(relay: NStore, params: { acct?: string; statusId?: string }): Promise<MetadataEntities> { async function getEntities(c: AppContext, params: { acct?: string; statusId?: string }): Promise<MetadataEntities> {
const { relay } = c.var;
const entities: MetadataEntities = { const entities: MetadataEntities = {
instance: await getInstanceMetadata(relay), instance: await getInstanceMetadata(relay),
}; };
if (params.statusId) { if (params.statusId) {
const event = await getEvent(params.statusId, { kind: 1 }); const event = await getEvent(params.statusId, c.var);
if (event) { if (event) {
entities.status = await renderStatus(event, {}); entities.status = await renderStatus(relay, event, {});
entities.account = entities.status?.account; entities.account = entities.status?.account;
} }
return entities; return entities;
} }
if (params.acct) { if (params.acct) {
const pubkey = await lookupPubkey(params.acct.replace(/^@/, '')); const pubkey = await lookupPubkey(params.acct.replace(/^@/, ''), c.var);
const event = pubkey ? await getAuthor(pubkey) : undefined; const event = pubkey ? await getAuthor(pubkey, c.var) : undefined;
if (event) { if (event) {
entities.account = await renderAccount(event); entities.account = renderAccount(event);
} }
} }

View file

@ -1,31 +1,16 @@
import { import { dbAvailableConnectionsGauge, dbPoolSizeGauge } from '@ditto/metrics';
dbAvailableConnectionsGauge,
dbPoolSizeGauge,
relayPoolRelaysSizeGauge,
relayPoolSubscriptionsSizeGauge,
} from '@ditto/metrics';
import { register } from 'prom-client'; import { register } from 'prom-client';
import { AppController } from '@/app.ts'; import { AppController } from '@/app.ts';
import { Storages } from '@/storages.ts';
/** Prometheus/OpenMetrics controller. */ /** Prometheus/OpenMetrics controller. */
export const metricsController: AppController = async (c) => { export const metricsController: AppController = async (c) => {
const db = await Storages.database(); const { db } = c.var;
const pool = await Storages.client();
// Update some metrics at request time. // Update some metrics at request time.
dbPoolSizeGauge.set(db.poolSize); dbPoolSizeGauge.set(db.poolSize);
dbAvailableConnectionsGauge.set(db.availableConnections); dbAvailableConnectionsGauge.set(db.availableConnections);
relayPoolRelaysSizeGauge.reset();
relayPoolSubscriptionsSizeGauge.reset();
for (const relay of pool.relays.values()) {
relayPoolRelaysSizeGauge.inc({ ready_state: relay.socket.readyState });
relayPoolSubscriptionsSizeGauge.inc(relay.subscriptions.length);
}
// Serve the metrics. // Serve the metrics.
const metrics = await register.metrics(); const metrics = await register.metrics();

View file

@ -16,7 +16,6 @@ import {
import { AppController } from '@/app.ts'; import { AppController } from '@/app.ts';
import { relayInfoController } from '@/controllers/nostr/relay-info.ts'; import { relayInfoController } from '@/controllers/nostr/relay-info.ts';
import * as pipeline from '@/pipeline.ts';
import { RelayError } from '@/RelayError.ts'; import { RelayError } from '@/RelayError.ts';
import { type DittoPgStore } from '@/storages/DittoPgStore.ts'; import { type DittoPgStore } from '@/storages/DittoPgStore.ts';
import { errorJson } from '@/utils/log.ts'; import { errorJson } from '@/utils/log.ts';
@ -159,7 +158,7 @@ function connectStream(conf: DittoConf, relay: DittoPgStore, socket: WebSocket,
try { try {
// This will store it (if eligible) and run other side-effects. // This will store it (if eligible) and run other side-effects.
await pipeline.handleEvent(purifyEvent(event), { source: 'relay', signal: AbortSignal.timeout(1000) }); await relay.event(purifyEvent(event), { signal: AbortSignal.timeout(1000) });
send(['OK', event.id, true, '']); send(['OK', event.id, true, '']);
} catch (e) { } catch (e) {
if (e instanceof RelayError) { if (e instanceof RelayError) {

View file

@ -12,8 +12,6 @@ const emptyResult: NostrJson = { names: {}, relays: {} };
* https://github.com/nostr-protocol/nips/blob/master/05.md * https://github.com/nostr-protocol/nips/blob/master/05.md
*/ */
const nostrController: AppController = async (c) => { const nostrController: AppController = async (c) => {
const { relay } = c.var;
// If there are no query parameters, this will always return an empty result. // If there are no query parameters, this will always return an empty result.
if (!Object.entries(c.req.queries()).length) { if (!Object.entries(c.req.queries()).length) {
c.header('Cache-Control', 'max-age=31536000, public, immutable, stale-while-revalidate=86400'); c.header('Cache-Control', 'max-age=31536000, public, immutable, stale-while-revalidate=86400');
@ -22,7 +20,7 @@ const nostrController: AppController = async (c) => {
const result = nameSchema.safeParse(c.req.query('name')); const result = nameSchema.safeParse(c.req.query('name'));
const name = result.success ? result.data : undefined; const name = result.success ? result.data : undefined;
const pointer = name ? await localNip05Lookup(relay, name) : undefined; const pointer = name ? await localNip05Lookup(name, c.var) : undefined;
if (!name || !pointer) { if (!name || !pointer) {
// Not found, cache for 5 minutes. // Not found, cache for 5 minutes.

View file

@ -1,7 +1,7 @@
import { sql } from 'kysely'; import { sql } from 'kysely';
import { Storages } from '@/storages.ts';
import { import {
type TrendsCtx,
updateTrendingEvents, updateTrendingEvents,
updateTrendingHashtags, updateTrendingHashtags,
updateTrendingLinks, updateTrendingLinks,
@ -10,15 +10,15 @@ import {
} from '@/trends.ts'; } from '@/trends.ts';
/** Start cron jobs for the application. */ /** Start cron jobs for the application. */
export function cron() { export function cron(ctx: TrendsCtx) {
Deno.cron('update trending pubkeys', '0 * * * *', updateTrendingPubkeys); Deno.cron('update trending pubkeys', '0 * * * *', () => updateTrendingPubkeys(ctx));
Deno.cron('update trending zapped events', '7 * * * *', updateTrendingZappedEvents); Deno.cron('update trending zapped events', '7 * * * *', () => updateTrendingZappedEvents(ctx));
Deno.cron('update trending events', '15 * * * *', updateTrendingEvents); Deno.cron('update trending events', '15 * * * *', () => updateTrendingEvents(ctx));
Deno.cron('update trending hashtags', '30 * * * *', updateTrendingHashtags); Deno.cron('update trending hashtags', '30 * * * *', () => updateTrendingHashtags(ctx));
Deno.cron('update trending links', '45 * * * *', updateTrendingLinks); Deno.cron('update trending links', '45 * * * *', () => updateTrendingLinks(ctx));
Deno.cron('refresh top authors', '20 * * * *', async () => { Deno.cron('refresh top authors', '20 * * * *', async () => {
const kysely = await Storages.kysely(); const { kysely } = ctx.db;
await sql`refresh materialized view top_authors`.execute(kysely); await sql`refresh materialized view top_authors`.execute(kysely);
}); });
} }

View file

@ -1,32 +1,38 @@
import { firehoseEventsCounter } from '@ditto/metrics'; import { firehoseEventsCounter } from '@ditto/metrics';
import { Semaphore } from '@core/asyncutil'; import { Semaphore } from '@core/asyncutil';
import { NRelay, NStore } from '@nostrify/nostrify';
import { logi } from '@soapbox/logi'; import { logi } from '@soapbox/logi';
import { Conf } from '@/config.ts';
import { Storages } from '@/storages.ts';
import { nostrNow } from '@/utils.ts'; import { nostrNow } from '@/utils.ts';
import * as pipeline from '@/pipeline.ts'; interface FirehoseOpts {
pool: NRelay;
const sem = new Semaphore(Conf.firehoseConcurrency); store: NStore;
concurrency: number;
kinds: number[];
timeout?: number;
}
/** /**
* This function watches events on all known relays and performs * This function watches events on all known relays and performs
* side-effects based on them, such as trending hashtag tracking * side-effects based on them, such as trending hashtag tracking
* and storing events for notifications and the home feed. * and storing events for notifications and the home feed.
*/ */
export async function startFirehose(): Promise<void> { export async function startFirehose(opts: FirehoseOpts): Promise<void> {
const store = await Storages.client(); const { pool, store, kinds, concurrency, timeout = 5000 } = opts;
for await (const msg of store.req([{ kinds: Conf.firehoseKinds, limit: 0, since: nostrNow() }])) { const sem = new Semaphore(concurrency);
for await (const msg of pool.req([{ kinds, limit: 0, since: nostrNow() }])) {
if (msg[0] === 'EVENT') { if (msg[0] === 'EVENT') {
const event = msg[2]; const event = msg[2];
logi({ level: 'debug', ns: 'ditto.event', source: 'firehose', id: event.id, kind: event.kind }); logi({ level: 'debug', ns: 'ditto.event', source: 'firehose', id: event.id, kind: event.kind });
firehoseEventsCounter.inc({ kind: event.kind }); firehoseEventsCounter.inc({ kind: event.kind });
sem.lock(async () => { sem.lock(async () => {
try { try {
await pipeline.handleEvent(event, { source: 'firehose', signal: AbortSignal.timeout(5000) }); await store.event(event, { signal: AbortSignal.timeout(timeout) });
} catch { } catch {
// Ignore // Ignore
} }

View file

@ -1,17 +1,15 @@
import { AppMiddleware } from '@/app.ts'; import { AppMiddleware } from '@/app.ts';
import { PleromaConfigDB } from '@/utils/PleromaConfigDB.ts'; import { PleromaConfigDB } from '@/utils/PleromaConfigDB.ts';
import { Storages } from '@/storages.ts';
import { getPleromaConfigs } from '@/utils/pleroma.ts'; import { getPleromaConfigs } from '@/utils/pleroma.ts';
let configDBCache: Promise<PleromaConfigDB> | undefined;
export const cspMiddleware = (): AppMiddleware => { export const cspMiddleware = (): AppMiddleware => {
let configDBCache: Promise<PleromaConfigDB> | undefined;
return async (c, next) => { return async (c, next) => {
const { conf } = c.var; const { conf, relay } = c.var;
const store = await Storages.db();
if (!configDBCache) { if (!configDBCache) {
configDBCache = getPleromaConfigs(store); configDBCache = getPleromaConfigs(relay);
} }
const { host, protocol, origin } = conf.url; const { host, protocol, origin } = conf.url;

View file

@ -1,368 +0,0 @@
import { DittoTables } from '@ditto/db';
import { pipelineEventsCounter, policyEventsCounter, webPushNotificationsCounter } from '@ditto/metrics';
import { NKinds, NostrEvent, NSchema as n } from '@nostrify/nostrify';
import { logi } from '@soapbox/logi';
import { Kysely, UpdateObject } from 'kysely';
import tldts from 'tldts';
import { z } from 'zod';
import { pipelineEncounters } from '@/caches/pipelineEncounters.ts';
import { Conf } from '@/config.ts';
import { DittoPush } from '@/DittoPush.ts';
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { RelayError } from '@/RelayError.ts';
import { hydrateEvents } from '@/storages/hydrate.ts';
import { Storages } from '@/storages.ts';
import { eventAge, Time } from '@/utils.ts';
import { getAmount } from '@/utils/bolt11.ts';
import { faviconCache } from '@/utils/favicon.ts';
import { errorJson } from '@/utils/log.ts';
import { nip05Cache } from '@/utils/nip05.ts';
import { parseNoteContent, stripimeta } from '@/utils/note.ts';
import { purifyEvent } from '@/utils/purify.ts';
import { updateStats } from '@/utils/stats.ts';
import { getTagSet } from '@/utils/tags.ts';
import { unfurlCardCached } from '@/utils/unfurl.ts';
import { renderWebPushNotification } from '@/views/mastodon/push.ts';
import { policyWorker } from '@/workers/policy.ts';
import { verifyEventWorker } from '@/workers/verify.ts';
interface PipelineOpts {
signal: AbortSignal;
source: 'relay' | 'api' | 'firehose' | 'pipeline' | 'notify' | 'internal';
}
/**
* Common pipeline function to process (and maybe store) events.
* It is idempotent, so it can be called multiple times for the same event.
*/
async function handleEvent(event: DittoEvent, opts: PipelineOpts): Promise<void> {
// Skip events that have already been encountered.
if (pipelineEncounters.get(event.id)) {
throw new RelayError('duplicate', 'already have this event');
}
// Reject events that are too far in the future.
if (eventAge(event) < -Time.minutes(1)) {
throw new RelayError('invalid', 'event too far in the future');
}
// Integer max value for Postgres.
if (event.kind >= 2_147_483_647) {
throw new RelayError('invalid', 'event kind too large');
}
// The only point of ephemeral events is to stream them,
// so throw an error if we're not even going to do that.
if (NKinds.ephemeral(event.kind) && !isFresh(event)) {
throw new RelayError('invalid', 'event too old');
}
// Block NIP-70 events, because we have no way to `AUTH`.
if (isProtectedEvent(event)) {
throw new RelayError('invalid', 'protected event');
}
// Validate the event's signature.
if (!(await verifyEventWorker(event))) {
throw new RelayError('invalid', 'invalid signature');
}
// Recheck encountered after async ops.
if (pipelineEncounters.has(event.id)) {
throw new RelayError('duplicate', 'already have this event');
}
// Set the event as encountered after verifying the signature.
pipelineEncounters.set(event.id, true);
// Log the event.
logi({ level: 'debug', ns: 'ditto.event', source: 'pipeline', id: event.id, kind: event.kind });
pipelineEventsCounter.inc({ kind: event.kind });
// NIP-46 events get special treatment.
// They are exempt from policies and other side-effects, and should be streamed out immediately.
// If streaming fails, an error should be returned.
if (event.kind === 24133) {
const store = await Storages.db();
await store.event(event, { signal: opts.signal });
}
// Ensure the event doesn't violate the policy.
if (event.pubkey !== await Conf.signer.getPublicKey()) {
await policyFilter(event, opts.signal);
}
// Prepare the event for additional checks.
// FIXME: This is kind of hacky. Should be reorganized to fetch only what's needed for each stage.
await hydrateEvent(event, opts.signal);
// Ensure that the author is not banned.
const n = getTagSet(event.user?.tags ?? [], 'n');
if (n.has('disabled')) {
throw new RelayError('blocked', 'author is blocked');
}
const kysely = await Storages.kysely();
try {
await storeEvent(purifyEvent(event), opts.signal);
} finally {
// This needs to run in steps, and should not block the API from responding.
Promise.allSettled([
handleZaps(kysely, event),
updateAuthorData(event, opts.signal),
prewarmLinkPreview(event, opts.signal),
generateSetEvents(event),
])
.then(() => webPush(event))
.catch(() => {});
}
}
async function policyFilter(event: NostrEvent, signal: AbortSignal): Promise<void> {
try {
const result = await policyWorker.call(event, signal);
const [, , ok, reason] = result;
logi({ level: 'debug', ns: 'ditto.policy', id: event.id, kind: event.kind, ok, reason });
policyEventsCounter.inc({ ok: String(ok) });
RelayError.assert(result);
} catch (e) {
if (e instanceof RelayError) {
throw e;
} else {
logi({ level: 'error', ns: 'ditto.policy', id: event.id, kind: event.kind, error: errorJson(e) });
throw new RelayError('blocked', 'policy error');
}
}
}
/** Check whether the event has a NIP-70 `-` tag. */
function isProtectedEvent(event: NostrEvent): boolean {
return event.tags.some(([name]) => name === '-');
}
/** Hydrate the event with the user, if applicable. */
async function hydrateEvent(event: DittoEvent, signal: AbortSignal): Promise<void> {
await hydrateEvents({ events: [event], relay: await Storages.db(), signal });
}
/** Maybe store the event, if eligible. */
async function storeEvent(event: NostrEvent, signal?: AbortSignal): Promise<undefined> {
const store = await Storages.db();
try {
await store.transaction(async (store, kysely) => {
if (!NKinds.ephemeral(event.kind)) {
await updateStats({ event, store, kysely });
}
await store.event(event, { signal });
});
} catch (e) {
// If the failure is only because of updateStats (which runs first), insert the event anyway.
// We can't catch this in the transaction because the error aborts the transaction on the Postgres side.
if (e instanceof Error && e.message.includes('event_stats' satisfies keyof DittoTables)) {
await store.event(event, { signal });
} else {
throw e;
}
}
}
/** Parse kind 0 metadata and track indexes in the database. */
async function updateAuthorData(event: NostrEvent, signal: AbortSignal): Promise<void> {
if (event.kind !== 0) return;
// Parse metadata.
const metadata = n.json().pipe(n.metadata()).catch({}).safeParse(event.content);
if (!metadata.success) return;
const { name, nip05 } = metadata.data;
const kysely = await Storages.kysely();
const updates: UpdateObject<DittoTables, 'author_stats'> = {};
const authorStats = await kysely
.selectFrom('author_stats')
.selectAll()
.where('pubkey', '=', event.pubkey)
.executeTakeFirst();
const lastVerified = authorStats?.nip05_last_verified_at;
const eventNewer = !lastVerified || event.created_at > lastVerified;
try {
if (nip05 !== authorStats?.nip05 && eventNewer || !lastVerified) {
if (nip05) {
const tld = tldts.parse(nip05);
if (tld.isIcann && !tld.isIp && !tld.isPrivate) {
const pointer = await nip05Cache.fetch(nip05, { signal });
if (pointer.pubkey === event.pubkey) {
updates.nip05 = nip05;
updates.nip05_domain = tld.domain;
updates.nip05_hostname = tld.hostname;
updates.nip05_last_verified_at = event.created_at;
}
}
} else {
updates.nip05 = null;
updates.nip05_domain = null;
updates.nip05_hostname = null;
updates.nip05_last_verified_at = event.created_at;
}
}
} catch {
// Fallthrough.
}
// Fetch favicon.
const domain = nip05?.split('@')[1].toLowerCase();
if (domain) {
try {
await faviconCache.fetch(domain, { signal });
} catch {
// Fallthrough.
}
}
const search = [name, nip05].filter(Boolean).join(' ').trim();
if (search !== authorStats?.search) {
updates.search = search;
}
if (Object.keys(updates).length) {
await kysely.insertInto('author_stats')
.values({
pubkey: event.pubkey,
followers_count: 0,
following_count: 0,
notes_count: 0,
search,
...updates,
})
.onConflict((oc) => oc.column('pubkey').doUpdateSet(updates))
.execute();
}
}
async function prewarmLinkPreview(event: NostrEvent, signal: AbortSignal): Promise<void> {
const { firstUrl } = parseNoteContent(stripimeta(event.content, event.tags), []);
if (firstUrl) {
await unfurlCardCached(firstUrl, signal);
}
}
/** Determine if the event is being received in a timely manner. */
function isFresh(event: NostrEvent): boolean {
return eventAge(event) < Time.minutes(1);
}
async function webPush(event: NostrEvent): Promise<void> {
if (!isFresh(event)) {
throw new RelayError('invalid', 'event too old');
}
const kysely = await Storages.kysely();
const pubkeys = getTagSet(event.tags, 'p');
if (!pubkeys.size) {
return;
}
const rows = await kysely
.selectFrom('push_subscriptions')
.selectAll()
.where('pubkey', 'in', [...pubkeys])
.execute();
for (const row of rows) {
const viewerPubkey = row.pubkey;
if (viewerPubkey === event.pubkey) {
continue; // Don't notify authors about their own events.
}
const message = await renderWebPushNotification(event, viewerPubkey);
if (!message) {
continue;
}
const subscription = {
endpoint: row.endpoint,
keys: {
auth: row.auth,
p256dh: row.p256dh,
},
};
await DittoPush.push(subscription, message);
webPushNotificationsCounter.inc({ type: message.notification_type });
}
}
async function generateSetEvents(event: NostrEvent): Promise<void> {
const signer = Conf.signer;
const pubkey = await signer.getPublicKey();
const tagsAdmin = event.tags.some(([name, value]) => ['p', 'P'].includes(name) && value === pubkey);
if (event.kind === 1984 && tagsAdmin) {
const rel = await signer.signEvent({
kind: 30383,
content: '',
tags: [
['d', event.id],
['p', event.pubkey],
['k', '1984'],
['n', 'open'],
...[...getTagSet(event.tags, 'p')].map((value) => ['P', value]),
...[...getTagSet(event.tags, 'e')].map((value) => ['e', value]),
],
created_at: Math.floor(Date.now() / 1000),
});
await handleEvent(rel, { source: 'pipeline', signal: AbortSignal.timeout(1000) });
}
if (event.kind === 3036 && tagsAdmin) {
const rel = await signer.signEvent({
kind: 30383,
content: '',
tags: [
['d', event.id],
['p', event.pubkey],
['k', '3036'],
['n', 'pending'],
],
created_at: Math.floor(Date.now() / 1000),
});
await handleEvent(rel, { source: 'pipeline', signal: AbortSignal.timeout(1000) });
}
}
/** Stores the event in the 'event_zaps' table */
async function handleZaps(kysely: Kysely<DittoTables>, event: NostrEvent) {
if (event.kind !== 9735) return;
const zapRequestString = event?.tags?.find(([name]) => name === 'description')?.[1];
if (!zapRequestString) return;
const zapRequest = n.json().pipe(n.event()).optional().catch(undefined).parse(zapRequestString);
if (!zapRequest) return;
const amountSchema = z.coerce.number().int().nonnegative().catch(0);
const amount_millisats = amountSchema.parse(getAmount(event?.tags.find(([name]) => name === 'bolt11')?.[1]));
if (!amount_millisats || amount_millisats < 1) return;
const zappedEventId = zapRequest.tags.find(([name]) => name === 'e')?.[1];
if (!zappedEventId) return;
try {
await kysely.insertInto('event_zaps').values({
receipt_id: event.id,
target_event_id: zappedEventId,
sender_pubkey: zapRequest.pubkey,
amount_millisats,
comment: zapRequest.content,
}).execute();
} catch {
// receipt_id is unique, do nothing
}
}
export { handleEvent, handleZaps, updateAuthorData };

View file

@ -1,73 +1,55 @@
import { DittoDB } from '@ditto/db';
import { DittoConf } from '@ditto/conf';
import { NostrEvent, NostrFilter, NStore } from '@nostrify/nostrify'; import { NostrEvent, NostrFilter, NStore } from '@nostrify/nostrify';
import { Conf } from '@/config.ts';
import { Storages } from '@/storages.ts';
import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
import { hydrateEvents } from '@/storages/hydrate.ts'; import { hydrateEvents } from '@/storages/hydrate.ts';
import { fallbackAuthor } from '@/utils.ts';
import { findReplyTag, getTagSet } from '@/utils/tags.ts'; import { findReplyTag, getTagSet } from '@/utils/tags.ts';
interface GetEventOpts { interface GetEventOpts {
/** Signal to abort the request. */ db: DittoDB;
conf: DittoConf;
relay: NStore;
signal?: AbortSignal; signal?: AbortSignal;
/** Event kind. */
kind?: number;
} }
/** /**
* Get a Nostr event by its ID. * Get a Nostr event by its ID.
* @deprecated Use `relay.query` directly. * @deprecated Use `relay.query` directly.
*/ */
const getEvent = async ( async function getEvent(id: string, opts: GetEventOpts): Promise<DittoEvent | undefined> {
id: string,
opts: GetEventOpts = {},
): Promise<DittoEvent | undefined> => {
const relay = await Storages.db();
const { kind, signal = AbortSignal.timeout(1000) } = opts;
const filter: NostrFilter = { ids: [id], limit: 1 }; const filter: NostrFilter = { ids: [id], limit: 1 };
if (kind) { const [event] = await opts.relay.query([filter], opts);
filter.kinds = [kind]; hydrateEvents({ ...opts, events: [event] });
} return event;
}
return await relay.query([filter], { limit: 1, signal })
.then((events) => hydrateEvents({ events, relay, signal }))
.then(([event]) => event);
};
/** /**
* Get a Nostr `set_medatadata` event for a user's pubkey. * Get a Nostr `set_medatadata` event for a user's pubkey.
* @deprecated Use `relay.query` directly. * @deprecated Use `relay.query` directly.
*/ */
async function getAuthor(pubkey: string, opts: GetEventOpts = {}): Promise<NostrEvent | undefined> { async function getAuthor(pubkey: string, opts: GetEventOpts): Promise<NostrEvent | undefined> {
const relay = await Storages.db(); const [event] = await opts.relay.query([{ authors: [pubkey], kinds: [0], limit: 1 }], opts);
const { signal = AbortSignal.timeout(1000) } = opts; hydrateEvents({ ...opts, events: [event] });
const events = await relay.query([{ authors: [pubkey], kinds: [0], limit: 1 }], { limit: 1, signal });
const event = events[0] ?? fallbackAuthor(pubkey);
await hydrateEvents({ events: [event], relay, signal });
return event; return event;
} }
/** Get users the given pubkey follows. */ /** Get users the given pubkey follows. */
const getFollows = async (pubkey: string, signal?: AbortSignal): Promise<NostrEvent | undefined> => { const getFollows = async (relay: NStore, pubkey: string, signal?: AbortSignal): Promise<NostrEvent | undefined> => {
const store = await Storages.db(); const [event] = await relay.query([{ authors: [pubkey], kinds: [3], limit: 1 }], { signal });
const [event] = await store.query([{ authors: [pubkey], kinds: [3], limit: 1 }], { limit: 1, signal });
return event; return event;
}; };
/** Get pubkeys the user follows. */ /** Get pubkeys the user follows. */
async function getFollowedPubkeys(pubkey: string, signal?: AbortSignal): Promise<Set<string>> { async function getFollowedPubkeys(relay: NStore, pubkey: string, signal?: AbortSignal): Promise<Set<string>> {
const event = await getFollows(pubkey, signal); const event = await getFollows(relay, pubkey, signal);
if (!event) return new Set(); if (!event) return new Set();
return getTagSet(event.tags, 'p'); return getTagSet(event.tags, 'p');
} }
/** Get pubkeys the user follows, including the user's own pubkey. */ /** Get pubkeys the user follows, including the user's own pubkey. */
async function getFeedPubkeys(pubkey: string): Promise<Set<string>> { async function getFeedPubkeys(relay: NStore, pubkey: string): Promise<Set<string>> {
const authors = await getFollowedPubkeys(pubkey); const authors = await getFollowedPubkeys(relay, pubkey);
return authors.add(pubkey); return authors.add(pubkey);
} }
@ -92,34 +74,11 @@ async function getAncestors(store: NStore, event: NostrEvent, result: NostrEvent
async function getDescendants( async function getDescendants(
store: NStore, store: NStore,
event: NostrEvent, event: NostrEvent,
signal = AbortSignal.timeout(2000), signal?: AbortSignal,
): Promise<NostrEvent[]> { ): Promise<NostrEvent[]> {
return await store return await store
.query([{ kinds: [1], '#e': [event.id], since: event.created_at, limit: 200 }], { signal }) .query([{ kinds: [1], '#e': [event.id], since: event.created_at, limit: 200 }], { signal })
.then((events) => events.filter(({ tags }) => findReplyTag(tags)?.[1] === event.id)); .then((events) => events.filter(({ tags }) => findReplyTag(tags)?.[1] === event.id));
} }
/** Returns whether the pubkey is followed by a local user. */ export { getAncestors, getAuthor, getDescendants, getEvent, getFeedPubkeys, getFollowedPubkeys, getFollows };
async function isLocallyFollowed(pubkey: string): Promise<boolean> {
const { host } = Conf.url;
const store = await Storages.db();
const [event] = await store.query(
[{ kinds: [3], '#p': [pubkey], search: `domain:${host}`, limit: 1 }],
{ limit: 1 },
);
return Boolean(event);
}
export {
getAncestors,
getAuthor,
getDescendants,
getEvent,
getFeedPubkeys,
getFollowedPubkeys,
getFollows,
isLocallyFollowed,
};

View file

@ -1,13 +1,12 @@
// deno-lint-ignore-file require-await // deno-lint-ignore-file require-await
import { HTTPException } from '@hono/hono/http-exception'; import { HTTPException } from '@hono/hono/http-exception';
import { NConnectSigner, NostrEvent, NostrSigner } from '@nostrify/nostrify'; import { NConnectSigner, NostrEvent, NostrSigner, NRelay } from '@nostrify/nostrify';
import { Storages } from '@/storages.ts';
interface ConnectSignerOpts { interface ConnectSignerOpts {
bunkerPubkey: string; bunkerPubkey: string;
userPubkey: string; userPubkey: string;
signer: NostrSigner; signer: NostrSigner;
relay: NRelay;
relays?: string[]; relays?: string[];
} }
@ -17,27 +16,23 @@ interface ConnectSignerOpts {
* Simple extension of nostrify's `NConnectSigner`, with our options to keep it DRY. * Simple extension of nostrify's `NConnectSigner`, with our options to keep it DRY.
*/ */
export class ConnectSigner implements NostrSigner { export class ConnectSigner implements NostrSigner {
private signer: Promise<NConnectSigner>; private signer: NConnectSigner;
constructor(private opts: ConnectSignerOpts) { constructor(private opts: ConnectSignerOpts) {
this.signer = this.init(opts.signer); const { relay, signer } = this.opts;
}
async init(signer: NostrSigner): Promise<NConnectSigner> { this.signer = new NConnectSigner({
return new NConnectSigner({
encryption: 'nip44', encryption: 'nip44',
pubkey: this.opts.bunkerPubkey, pubkey: this.opts.bunkerPubkey,
// TODO: use a remote relay for `nprofile` signing (if present and `Conf.relay` isn't already in the list) relay,
relay: await Storages.db(),
signer, signer,
timeout: 60_000, timeout: 60_000,
}); });
} }
async signEvent(event: Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>): Promise<NostrEvent> { async signEvent(event: Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>): Promise<NostrEvent> {
const signer = await this.signer;
try { try {
return await signer.signEvent(event); return await this.signer.signEvent(event);
} catch (e) { } catch (e) {
if (e instanceof Error && e.name === 'AbortError') { if (e instanceof Error && e.name === 'AbortError') {
throw new HTTPException(408, { message: 'The event was not signed quickly enough' }); throw new HTTPException(408, { message: 'The event was not signed quickly enough' });
@ -49,9 +44,8 @@ export class ConnectSigner implements NostrSigner {
readonly nip04 = { readonly nip04 = {
encrypt: async (pubkey: string, plaintext: string): Promise<string> => { encrypt: async (pubkey: string, plaintext: string): Promise<string> => {
const signer = await this.signer;
try { try {
return await signer.nip04.encrypt(pubkey, plaintext); return await this.signer.nip04.encrypt(pubkey, plaintext);
} catch (e) { } catch (e) {
if (e instanceof Error && e.name === 'AbortError') { if (e instanceof Error && e.name === 'AbortError') {
throw new HTTPException(408, { throw new HTTPException(408, {
@ -64,9 +58,8 @@ export class ConnectSigner implements NostrSigner {
}, },
decrypt: async (pubkey: string, ciphertext: string): Promise<string> => { decrypt: async (pubkey: string, ciphertext: string): Promise<string> => {
const signer = await this.signer;
try { try {
return await signer.nip04.decrypt(pubkey, ciphertext); return await this.signer.nip04.decrypt(pubkey, ciphertext);
} catch (e) { } catch (e) {
if (e instanceof Error && e.name === 'AbortError') { if (e instanceof Error && e.name === 'AbortError') {
throw new HTTPException(408, { throw new HTTPException(408, {
@ -81,9 +74,8 @@ export class ConnectSigner implements NostrSigner {
readonly nip44 = { readonly nip44 = {
encrypt: async (pubkey: string, plaintext: string): Promise<string> => { encrypt: async (pubkey: string, plaintext: string): Promise<string> => {
const signer = await this.signer;
try { try {
return await signer.nip44.encrypt(pubkey, plaintext); return await this.signer.nip44.encrypt(pubkey, plaintext);
} catch (e) { } catch (e) {
if (e instanceof Error && e.name === 'AbortError') { if (e instanceof Error && e.name === 'AbortError') {
throw new HTTPException(408, { throw new HTTPException(408, {
@ -96,9 +88,8 @@ export class ConnectSigner implements NostrSigner {
}, },
decrypt: async (pubkey: string, ciphertext: string): Promise<string> => { decrypt: async (pubkey: string, ciphertext: string): Promise<string> => {
const signer = await this.signer;
try { try {
return await signer.nip44.decrypt(pubkey, ciphertext); return await this.signer.nip44.decrypt(pubkey, ciphertext);
} catch (e) { } catch (e) {
if (e instanceof Error && e.name === 'AbortError') { if (e instanceof Error && e.name === 'AbortError') {
throw new HTTPException(408, { throw new HTTPException(408, {

View file

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

View file

@ -1,62 +0,0 @@
// deno-lint-ignore-file require-await
import { type DittoDB, DittoPolyPg } from '@ditto/db';
import { NPool, NRelay1 } from '@nostrify/nostrify';
import { Conf } from '@/config.ts';
import { DittoPgStore } from '@/storages/DittoPgStore.ts';
import { seedZapSplits } from '@/utils/zap-split.ts';
import { DittoPool } from '@/storages/DittoPool.ts';
export class Storages {
private static _db: Promise<DittoPgStore> | undefined;
private static _database: Promise<DittoDB> | undefined;
private static _client: Promise<NPool<NRelay1>> | undefined;
public static async database(): Promise<DittoDB> {
if (!this._database) {
this._database = (async () => {
const db = DittoPolyPg.create(Conf.databaseUrl, {
poolSize: Conf.pg.poolSize,
debug: Conf.pgliteDebug,
});
await DittoPolyPg.migrate(db.kysely);
return db;
})();
}
return this._database;
}
public static async kysely(): Promise<DittoDB['kysely']> {
const { kysely } = await this.database();
return kysely;
}
/** SQL database to store events this Ditto server cares about. */
public static async db(): Promise<DittoPgStore> {
if (!this._db) {
this._db = (async () => {
const db = await this.database();
const store = new DittoPgStore({
db,
pubkey: await Conf.signer.getPublicKey(),
timeout: Conf.db.timeouts.default,
notify: Conf.notifyEnabled,
});
await seedZapSplits(store);
return store;
})();
}
return this._db;
}
/** Relay pool storage. */
public static async client(): Promise<NPool<NRelay1>> {
if (!this._client) {
this._client = (async () => {
const relay = await this.db();
return new DittoPool({ conf: Conf, relay });
})();
}
return this._client;
}
}

View file

@ -1,6 +1,12 @@
import { DittoConf } from '@ditto/conf'; import { DittoConf } from '@ditto/conf';
import { DittoDB, DittoTables } from '@ditto/db'; import { DittoDB, DittoTables } from '@ditto/db';
import { pipelineEventsCounter, policyEventsCounter, webPushNotificationsCounter } from '@ditto/metrics'; import {
cachedFaviconsSizeGauge,
cachedNip05sSizeGauge,
pipelineEventsCounter,
policyEventsCounter,
webPushNotificationsCounter,
} from '@ditto/metrics';
import { import {
NKinds, NKinds,
NostrEvent, NostrEvent,
@ -22,18 +28,20 @@ import { DittoPush } from '@/DittoPush.ts';
import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { RelayError } from '@/RelayError.ts'; import { RelayError } from '@/RelayError.ts';
import { hydrateEvents } from '@/storages/hydrate.ts'; import { hydrateEvents } from '@/storages/hydrate.ts';
import { eventAge, Time } from '@/utils.ts'; import { eventAge, nostrNow, Time } from '@/utils.ts';
import { getAmount } from '@/utils/bolt11.ts'; import { getAmount } from '@/utils/bolt11.ts';
import { errorJson } from '@/utils/log.ts'; import { errorJson } from '@/utils/log.ts';
import { purifyEvent } from '@/utils/purify.ts'; import { purifyEvent } from '@/utils/purify.ts';
import { getTagSet } from '@/utils/tags.ts'; import { getTagSet } from '@/utils/tags.ts';
import { policyWorker } from '@/workers/policy.ts'; import { policyWorker } from '@/workers/policy.ts';
import { verifyEventWorker } from '@/workers/verify.ts'; import { verifyEventWorker } from '@/workers/verify.ts';
import { faviconCache } from '@/utils/favicon.ts'; import { fetchFavicon, insertFavicon, queryFavicon } from '@/utils/favicon.ts';
import { lookupNip05 } from '@/utils/nip05.ts';
import { parseNoteContent, stripimeta } from '@/utils/note.ts'; import { parseNoteContent, stripimeta } from '@/utils/note.ts';
import { SimpleLRU } from '@/utils/SimpleLRU.ts';
import { unfurlCardCached } from '@/utils/unfurl.ts'; import { unfurlCardCached } from '@/utils/unfurl.ts';
import { nip05Cache } from '@/utils/nip05.ts';
import { renderWebPushNotification } from '@/views/mastodon/push.ts'; import { renderWebPushNotification } from '@/views/mastodon/push.ts';
import { nip19 } from 'nostr-tools';
interface DittoAPIStoreOpts { interface DittoAPIStoreOpts {
db: DittoDB; db: DittoDB;
@ -43,15 +51,45 @@ interface DittoAPIStoreOpts {
} }
export class DittoAPIStore implements NRelay { export class DittoAPIStore implements NRelay {
private push: DittoPush;
private encounters = new LRUCache<string, true>({ max: 5000 }); private encounters = new LRUCache<string, true>({ max: 5000 });
private controller = new AbortController(); private controller = new AbortController();
private faviconCache: SimpleLRU<string, URL>;
private nip05Cache: SimpleLRU<string, nip19.ProfilePointer>;
private ns = 'ditto.apistore'; private ns = 'ditto.apistore';
constructor(private opts: DittoAPIStoreOpts) { constructor(private opts: DittoAPIStoreOpts) {
const { conf, db } = this.opts;
this.push = new DittoPush(opts);
this.listen().catch((e: unknown) => { this.listen().catch((e: unknown) => {
logi({ level: 'error', ns: this.ns, source: 'listen', error: errorJson(e) }); logi({ level: 'error', ns: this.ns, source: 'listen', error: errorJson(e) });
}); });
this.faviconCache = new SimpleLRU<string, URL>(
async (domain, { signal }) => {
const row = await queryFavicon(db.kysely, domain);
if (row && (nostrNow() - row.last_updated_at) < (conf.caches.favicon.ttl / 1000)) {
return new URL(row.favicon);
}
const url = await fetchFavicon(domain, signal);
await insertFavicon(db.kysely, domain, url.href);
return url;
},
{ ...conf.caches.favicon, gauge: cachedFaviconsSizeGauge },
);
this.nip05Cache = new SimpleLRU<string, nip19.ProfilePointer>(
(nip05, { signal }) => {
return lookupNip05(nip05, { ...this.opts, signal });
},
{ ...conf.caches.nip05, gauge: cachedNip05sSizeGauge },
);
} }
req( req(
@ -220,7 +258,7 @@ export class DittoAPIStore implements NRelay {
} }
/** Parse kind 0 metadata and track indexes in the database. */ /** Parse kind 0 metadata and track indexes in the database. */
private async updateAuthorData(event: NostrEvent, signal?: AbortSignal): Promise<void> { async updateAuthorData(event: NostrEvent, signal?: AbortSignal): Promise<void> {
if (event.kind !== 0) return; if (event.kind !== 0) return;
const { db } = this.opts; const { db } = this.opts;
@ -247,7 +285,7 @@ export class DittoAPIStore implements NRelay {
if (nip05) { if (nip05) {
const tld = tldts.parse(nip05); const tld = tldts.parse(nip05);
if (tld.isIcann && !tld.isIp && !tld.isPrivate) { if (tld.isIcann && !tld.isIp && !tld.isPrivate) {
const pointer = await nip05Cache.fetch(nip05, { signal }); const pointer = await this.nip05Cache.fetch(nip05, { signal });
if (pointer.pubkey === event.pubkey) { if (pointer.pubkey === event.pubkey) {
updates.nip05 = nip05; updates.nip05 = nip05;
updates.nip05_domain = tld.domain; updates.nip05_domain = tld.domain;
@ -270,7 +308,7 @@ export class DittoAPIStore implements NRelay {
const domain = nip05?.split('@')[1].toLowerCase(); const domain = nip05?.split('@')[1].toLowerCase();
if (domain) { if (domain) {
try { try {
await faviconCache.fetch(domain, { signal }); await this.faviconCache.fetch(domain, { signal });
} catch { } catch {
// Fallthrough. // Fallthrough.
} }
@ -352,7 +390,7 @@ export class DittoAPIStore implements NRelay {
throw new RelayError('invalid', 'event too old'); throw new RelayError('invalid', 'event too old');
} }
const { db } = this.opts; const { db, relay } = this.opts;
const pubkeys = getTagSet(event.tags, 'p'); const pubkeys = getTagSet(event.tags, 'p');
if (!pubkeys.size) { if (!pubkeys.size) {
@ -372,7 +410,7 @@ export class DittoAPIStore implements NRelay {
continue; // Don't notify authors about their own events. continue; // Don't notify authors about their own events.
} }
const message = await renderWebPushNotification(event, viewerPubkey); const message = await renderWebPushNotification(relay, event, viewerPubkey);
if (!message) { if (!message) {
continue; continue;
} }
@ -385,15 +423,14 @@ export class DittoAPIStore implements NRelay {
}, },
}; };
await DittoPush.push(subscription, message); await this.push.push(subscription, message);
webPushNotificationsCounter.inc({ type: message.notification_type }); webPushNotificationsCounter.inc({ type: message.notification_type });
} }
} }
/** Hydrate the event with the user, if applicable. */ /** Hydrate the event with the user, if applicable. */
private async hydrateEvent(event: NostrEvent, signal?: AbortSignal): Promise<DittoEvent> { private async hydrateEvent(event: NostrEvent, signal?: AbortSignal): Promise<DittoEvent> {
const { relay } = this.opts; const [hydrated] = await hydrateEvents({ ...this.opts, events: [event], signal });
const [hydrated] = await hydrateEvents({ events: [event], relay, signal });
return hydrated; return hydrated;
} }
@ -402,9 +439,17 @@ export class DittoAPIStore implements NRelay {
return eventAge(event) < Time.minutes(1); return eventAge(event) < Time.minutes(1);
} }
query(filters: NostrFilter[], opts?: { signal?: AbortSignal }): Promise<NostrEvent[]> { async query(filters: NostrFilter[], opts: { pure?: boolean; signal?: AbortSignal } = {}): Promise<DittoEvent[]> {
const { relay } = this.opts; const { relay } = this.opts;
return relay.query(filters, opts); const { pure = true, signal } = opts; // TODO: make pure `false` by default
const events = await relay.query(filters, opts);
if (!pure) {
return hydrateEvents({ ...this.opts, events, signal });
}
return events;
} }
count(filters: NostrFilter[], opts?: { signal?: AbortSignal }): Promise<NostrRelayCOUNT[2]> { count(filters: NostrFilter[], opts?: { signal?: AbortSignal }): Promise<NostrRelayCOUNT[2]> {

View file

@ -55,7 +55,7 @@ interface DittoPgStoreOpts {
/** Pubkey of the admin account. */ /** Pubkey of the admin account. */
pubkey: string; pubkey: string;
/** Timeout in milliseconds for database queries. */ /** Timeout in milliseconds for database queries. */
timeout: number; timeout?: number;
/** Whether the event returned should be a Nostr event or a Ditto event. Defaults to false. */ /** Whether the event returned should be a Nostr event or a Ditto event. Defaults to false. */
pure?: boolean; pure?: boolean;
/** Chunk size for streaming events. Defaults to 20. */ /** Chunk size for streaming events. Defaults to 20. */

View file

@ -1,13 +1,15 @@
import { DittoConf } from '@ditto/conf';
import { DummyDB } from '@ditto/db';
import { MockRelay } from '@nostrify/nostrify/test'; import { MockRelay } from '@nostrify/nostrify/test';
import { assertEquals } from '@std/assert'; import { assertEquals } from '@std/assert';
import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { hydrateEvents } from '@/storages/hydrate.ts'; import { hydrateEvents } from '@/storages/hydrate.ts';
import { createTestDB, eventFixture } from '@/test.ts'; import { eventFixture } from '@/test.ts';
Deno.test('hydrateEvents(): author --- WITHOUT stats', async () => { Deno.test('hydrateEvents(): author --- WITHOUT stats', async () => {
const relay = new MockRelay(); const opts = setupTest();
await using db = await createTestDB(); const { relay } = opts;
const event0 = await eventFixture('event-0'); const event0 = await eventFixture('event-0');
const event1 = await eventFixture('event-1'); const event1 = await eventFixture('event-1');
@ -16,19 +18,15 @@ Deno.test('hydrateEvents(): author --- WITHOUT stats', async () => {
await relay.event(event0); await relay.event(event0);
await relay.event(event1); await relay.event(event1);
await hydrateEvents({ await hydrateEvents({ ...opts, events: [event1] });
events: [event1],
relay,
kysely: db.kysely,
});
const expectedEvent = { ...event1, author: event0 }; const expectedEvent = { ...event1, author: event0 };
assertEquals(event1, expectedEvent); assertEquals(event1, expectedEvent);
}); });
Deno.test('hydrateEvents(): repost --- WITHOUT stats', async () => { Deno.test('hydrateEvents(): repost --- WITHOUT stats', async () => {
const relay = new MockRelay(); const opts = setupTest();
await using db = await createTestDB(); const { relay } = opts;
const event0madePost = await eventFixture('event-0-the-one-who-post-and-users-repost'); const event0madePost = await eventFixture('event-0-the-one-who-post-and-users-repost');
const event0madeRepost = await eventFixture('event-0-the-one-who-repost'); const event0madeRepost = await eventFixture('event-0-the-one-who-repost');
@ -41,23 +39,20 @@ Deno.test('hydrateEvents(): repost --- WITHOUT stats', async () => {
await relay.event(event1reposted); await relay.event(event1reposted);
await relay.event(event6); await relay.event(event6);
await hydrateEvents({ await hydrateEvents({ ...opts, events: [event6] });
events: [event6],
relay,
kysely: db.kysely,
});
const expectedEvent6 = { const expectedEvent6 = {
...event6, ...event6,
author: event0madeRepost, author: event0madeRepost,
repost: { ...event1reposted, author: event0madePost }, repost: { ...event1reposted, author: event0madePost },
}; };
assertEquals(event6, expectedEvent6); assertEquals(event6, expectedEvent6);
}); });
Deno.test('hydrateEvents(): quote repost --- WITHOUT stats', async () => { Deno.test('hydrateEvents(): quote repost --- WITHOUT stats', async () => {
const relay = new MockRelay(); const opts = setupTest();
await using db = await createTestDB(); const { relay } = opts;
const event0madeQuoteRepost = await eventFixture('event-0-the-one-who-quote-repost'); const event0madeQuoteRepost = await eventFixture('event-0-the-one-who-quote-repost');
const event0 = await eventFixture('event-0'); const event0 = await eventFixture('event-0');
@ -70,11 +65,7 @@ Deno.test('hydrateEvents(): quote repost --- WITHOUT stats', async () => {
await relay.event(event1quoteRepost); await relay.event(event1quoteRepost);
await relay.event(event1willBeQuoteReposted); await relay.event(event1willBeQuoteReposted);
await hydrateEvents({ await hydrateEvents({ ...opts, events: [event1quoteRepost] });
events: [event1quoteRepost],
relay,
kysely: db.kysely,
});
const expectedEvent1quoteRepost = { const expectedEvent1quoteRepost = {
...event1quoteRepost, ...event1quoteRepost,
@ -86,8 +77,8 @@ Deno.test('hydrateEvents(): quote repost --- WITHOUT stats', async () => {
}); });
Deno.test('hydrateEvents(): repost of quote repost --- WITHOUT stats', async () => { Deno.test('hydrateEvents(): repost of quote repost --- WITHOUT stats', async () => {
const relay = new MockRelay(); const opts = setupTest();
await using db = await createTestDB(); const { relay } = opts;
const author = await eventFixture('event-0-makes-repost-with-quote-repost'); const author = await eventFixture('event-0-makes-repost-with-quote-repost');
const event1 = await eventFixture('event-1-will-be-reposted-with-quote-repost'); const event1 = await eventFixture('event-1-will-be-reposted-with-quote-repost');
@ -100,23 +91,20 @@ Deno.test('hydrateEvents(): repost of quote repost --- WITHOUT stats', async ()
await relay.event(event1quote); await relay.event(event1quote);
await relay.event(event6); await relay.event(event6);
await hydrateEvents({ await hydrateEvents({ ...opts, events: [event6] });
events: [event6],
relay,
kysely: db.kysely,
});
const expectedEvent6 = { const expectedEvent6 = {
...event6, ...event6,
author, author,
repost: { ...event1quote, author, quote: { author, ...event1 } }, repost: { ...event1quote, author, quote: { author, ...event1 } },
}; };
assertEquals(event6, expectedEvent6); assertEquals(event6, expectedEvent6);
}); });
Deno.test('hydrateEvents(): report pubkey and post // kind 1984 --- WITHOUT stats', async () => { Deno.test('hydrateEvents(): report pubkey and post // kind 1984 --- WITHOUT stats', async () => {
const relay = new MockRelay(); const opts = setupTest();
await using db = await createTestDB(); const { relay } = opts;
const authorDictator = await eventFixture('kind-0-dictator'); const authorDictator = await eventFixture('kind-0-dictator');
const authorVictim = await eventFixture('kind-0-george-orwell'); const authorVictim = await eventFixture('kind-0-george-orwell');
@ -129,11 +117,7 @@ Deno.test('hydrateEvents(): report pubkey and post // kind 1984 --- WITHOUT stat
await relay.event(reportEvent); await relay.event(reportEvent);
await relay.event(event1); await relay.event(event1);
await hydrateEvents({ await hydrateEvents({ ...opts, events: [reportEvent] });
events: [reportEvent],
relay,
kysely: db.kysely,
});
const expectedEvent: DittoEvent = { const expectedEvent: DittoEvent = {
...reportEvent, ...reportEvent,
@ -141,12 +125,13 @@ Deno.test('hydrateEvents(): report pubkey and post // kind 1984 --- WITHOUT stat
reported_notes: [event1], reported_notes: [event1],
reported_profile: authorVictim, reported_profile: authorVictim,
}; };
assertEquals(reportEvent, expectedEvent); assertEquals(reportEvent, expectedEvent);
}); });
Deno.test('hydrateEvents(): zap sender, zap amount, zapped post // kind 9735 --- WITHOUT stats', async () => { Deno.test('hydrateEvents(): zap sender, zap amount, zapped post // kind 9735 --- WITHOUT stats', async () => {
const relay = new MockRelay(); const opts = setupTest();
await using db = await createTestDB(); const { relay } = opts;
const zapSender = await eventFixture('kind-0-jack'); const zapSender = await eventFixture('kind-0-jack');
const zapReceipt = await eventFixture('kind-9735-jack-zap-patrick'); const zapReceipt = await eventFixture('kind-9735-jack-zap-patrick');
@ -159,11 +144,7 @@ Deno.test('hydrateEvents(): zap sender, zap amount, zapped post // kind 9735 ---
await relay.event(zappedPost); await relay.event(zappedPost);
await relay.event(zapReceiver); await relay.event(zapReceiver);
await hydrateEvents({ await hydrateEvents({ ...opts, events: [zapReceipt] });
events: [zapReceipt],
relay,
kysely: db.kysely,
});
const expectedEvent: DittoEvent = { const expectedEvent: DittoEvent = {
...zapReceipt, ...zapReceipt,
@ -175,5 +156,14 @@ Deno.test('hydrateEvents(): zap sender, zap amount, zapped post // kind 9735 ---
zap_amount: 5225000, // millisats zap_amount: 5225000, // millisats
zap_message: '🫂', zap_message: '🫂',
}; };
assertEquals(zapReceipt, expectedEvent); assertEquals(zapReceipt, expectedEvent);
}); });
function setupTest() {
const db = new DummyDB();
const conf = new DittoConf(new Map());
const relay = new MockRelay();
return { conf, db, relay };
}

View file

@ -1,28 +1,28 @@
import { DittoTables } from '@ditto/db'; import { DittoDB, DittoTables } from '@ditto/db';
import { DittoConf } from '@ditto/conf';
import { NStore } from '@nostrify/nostrify'; import { NStore } from '@nostrify/nostrify';
import { Kysely } from 'kysely'; import { Kysely } from 'kysely';
import { matchFilter } from 'nostr-tools'; import { matchFilter } from 'nostr-tools';
import { NSchema as n } from '@nostrify/nostrify'; import { NSchema as n } from '@nostrify/nostrify';
import { z } from 'zod'; import { z } from 'zod';
import { Conf } from '@/config.ts';
import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
import { fallbackAuthor } from '@/utils.ts'; import { fallbackAuthor } from '@/utils.ts';
import { findQuoteTag } from '@/utils/tags.ts'; import { findQuoteTag } from '@/utils/tags.ts';
import { findQuoteInContent } from '@/utils/note.ts'; import { findQuoteInContent } from '@/utils/note.ts';
import { getAmount } from '@/utils/bolt11.ts'; import { getAmount } from '@/utils/bolt11.ts';
import { Storages } from '@/storages.ts';
interface HydrateOpts { interface HydrateOpts {
events: DittoEvent[]; db: DittoDB;
conf: DittoConf;
relay: NStore; relay: NStore;
events: DittoEvent[];
signal?: AbortSignal; signal?: AbortSignal;
kysely?: Kysely<DittoTables>;
} }
/** Hydrate events using the provided storage. */ /** Hydrate events using the provided storage. */
async function hydrateEvents(opts: HydrateOpts): Promise<DittoEvent[]> { async function hydrateEvents(opts: HydrateOpts): Promise<DittoEvent[]> {
const { events, relay, signal, kysely = await Storages.kysely() } = opts; const { conf, db, events } = opts;
if (!events.length) { if (!events.length) {
return events; return events;
@ -30,28 +30,28 @@ async function hydrateEvents(opts: HydrateOpts): Promise<DittoEvent[]> {
const cache = [...events]; const cache = [...events];
for (const event of await gatherRelatedEvents({ events: cache, relay, signal })) { for (const event of await gatherRelatedEvents({ ...opts, events: cache })) {
cache.push(event); cache.push(event);
} }
for (const event of await gatherQuotes({ events: cache, relay, signal })) { for (const event of await gatherQuotes({ ...opts, events: cache })) {
cache.push(event); cache.push(event);
} }
for (const event of await gatherProfiles({ events: cache, relay, signal })) { for (const event of await gatherProfiles({ ...opts, events: cache })) {
cache.push(event); cache.push(event);
} }
for (const event of await gatherUsers({ events: cache, relay, signal })) { for (const event of await gatherUsers({ ...opts, events: cache })) {
cache.push(event); cache.push(event);
} }
for (const event of await gatherInfo({ events: cache, relay, signal })) { for (const event of await gatherInfo({ ...opts, events: cache })) {
cache.push(event); cache.push(event);
} }
const authorStats = await gatherAuthorStats(cache, kysely as Kysely<DittoTables>); const authorStats = await gatherAuthorStats(cache, db.kysely);
const eventStats = await gatherEventStats(cache, kysely as Kysely<DittoTables>); const eventStats = await gatherEventStats(cache, db.kysely);
const domains = authorStats.reduce((result, { nip05_hostname }) => { const domains = authorStats.reduce((result, { nip05_hostname }) => {
if (nip05_hostname) result.add(nip05_hostname); if (nip05_hostname) result.add(nip05_hostname);
@ -59,7 +59,7 @@ async function hydrateEvents(opts: HydrateOpts): Promise<DittoEvent[]> {
}, new Set<string>()); }, new Set<string>());
const favicons = ( const favicons = (
await kysely await db.kysely
.selectFrom('domain_favicons') .selectFrom('domain_favicons')
.select(['domain', 'favicon']) .select(['domain', 'favicon'])
.where('domain', 'in', [...domains]) .where('domain', 'in', [...domains])
@ -79,7 +79,7 @@ async function hydrateEvents(opts: HydrateOpts): Promise<DittoEvent[]> {
// Dedupe events. // Dedupe events.
const results = [...new Map(cache.map((event) => [event.id, event])).values()]; const results = [...new Map(cache.map((event) => [event.id, event])).values()];
const admin = await Conf.signer.getPublicKey(); const admin = await conf.signer.getPublicKey();
// First connect all the events to each-other, then connect the connected events to the original list. // First connect all the events to each-other, then connect the connected events to the original list.
assembleEvents(admin, results, results, stats); assembleEvents(admin, results, results, stats);
@ -317,7 +317,7 @@ async function gatherProfiles({ events, relay, signal }: HydrateOpts): Promise<D
} }
/** Collect users from the events. */ /** Collect users from the events. */
async function gatherUsers({ events, relay, signal }: HydrateOpts): Promise<DittoEvent[]> { async function gatherUsers({ conf, events, relay, signal }: HydrateOpts): Promise<DittoEvent[]> {
const pubkeys = new Set(events.map((event) => event.pubkey)); const pubkeys = new Set(events.map((event) => event.pubkey));
if (!pubkeys.size) { if (!pubkeys.size) {
@ -325,13 +325,13 @@ async function gatherUsers({ events, relay, signal }: HydrateOpts): Promise<Ditt
} }
return relay.query( return relay.query(
[{ kinds: [30382], authors: [await Conf.signer.getPublicKey()], '#d': [...pubkeys], limit: pubkeys.size }], [{ kinds: [30382], authors: [await conf.signer.getPublicKey()], '#d': [...pubkeys], limit: pubkeys.size }],
{ signal }, { signal },
); );
} }
/** Collect info events from the events. */ /** Collect info events from the events. */
async function gatherInfo({ events, relay, signal }: HydrateOpts): Promise<DittoEvent[]> { async function gatherInfo({ conf, events, relay, signal }: HydrateOpts): Promise<DittoEvent[]> {
const ids = new Set<string>(); const ids = new Set<string>();
for (const event of events) { for (const event of events) {
@ -345,7 +345,7 @@ async function gatherInfo({ events, relay, signal }: HydrateOpts): Promise<Ditto
} }
return relay.query( return relay.query(
[{ kinds: [30383], authors: [await Conf.signer.getPublicKey()], '#d': [...ids], limit: ids.size }], [{ kinds: [30383], authors: [await conf.signer.getPublicKey()], '#d': [...ids], limit: ids.size }],
{ signal }, { signal },
); );
} }

View file

@ -13,9 +13,8 @@ export async function eventFixture(name: string): Promise<NostrEvent> {
/** Create a database for testing. It uses `DATABASE_URL`, or creates an in-memory database by default. */ /** Create a database for testing. It uses `DATABASE_URL`, or creates an in-memory database by default. */
export async function createTestDB(opts?: { pure?: boolean }) { export async function createTestDB(opts?: { pure?: boolean }) {
const db = DittoPolyPg.create(Conf.databaseUrl, { poolSize: 1 }); const db = new DittoPolyPg(Conf.databaseUrl, { poolSize: 1 });
await db.migrate();
await DittoPolyPg.migrate(db.kysely);
const store = new DittoPgStore({ const store = new DittoPgStore({
db, db,
@ -26,8 +25,10 @@ export async function createTestDB(opts?: { pure?: boolean }) {
}); });
return { return {
db,
...db, ...db,
store, store,
kysely: db.kysely,
[Symbol.asyncDispose]: async () => { [Symbol.asyncDispose]: async () => {
const { rows } = await sql< const { rows } = await sql<
{ tablename: string } { tablename: string }

View file

@ -1,11 +1,9 @@
import { DittoTables } from '@ditto/db'; import { DittoConf } from '@ditto/conf';
import { NostrFilter } from '@nostrify/nostrify'; import { DittoDB, DittoTables } from '@ditto/db';
import { NostrFilter, NStore } from '@nostrify/nostrify';
import { logi } from '@soapbox/logi'; import { logi } from '@soapbox/logi';
import { Kysely, sql } from 'kysely'; import { Kysely, sql } from 'kysely';
import { Conf } from '@/config.ts';
import { handleEvent } from '@/pipeline.ts';
import { Storages } from '@/storages.ts';
import { errorJson } from '@/utils/log.ts'; import { errorJson } from '@/utils/log.ts';
import { Time } from '@/utils/time.ts'; import { Time } from '@/utils/time.ts';
@ -63,8 +61,15 @@ export async function getTrendingTagValues(
})); }));
} }
export interface TrendsCtx {
conf: DittoConf;
db: DittoDB;
relay: NStore;
}
/** Get trending tags and publish an event with them. */ /** Get trending tags and publish an event with them. */
export async function updateTrendingTags( export async function updateTrendingTags(
ctx: TrendsCtx,
l: string, l: string,
tagName: string, tagName: string,
kinds: number[], kinds: number[],
@ -73,10 +78,11 @@ export async function updateTrendingTags(
aliases?: string[], aliases?: string[],
values?: string[], values?: string[],
) { ) {
const { conf, db, relay } = ctx;
const params = { l, tagName, kinds, limit, extra, aliases, values }; const params = { l, tagName, kinds, limit, extra, aliases, values };
logi({ level: 'info', ns: 'ditto.trends', msg: 'Updating trending', ...params }); logi({ level: 'info', ns: 'ditto.trends', msg: 'Updating trending', ...params });
const kysely = await Storages.kysely();
const signal = AbortSignal.timeout(1000); const signal = AbortSignal.timeout(1000);
const yesterday = Math.floor((Date.now() - Time.days(1)) / 1000); const yesterday = Math.floor((Date.now() - Time.days(1)) / 1000);
@ -85,7 +91,7 @@ export async function updateTrendingTags(
const tagNames = aliases ? [tagName, ...aliases] : [tagName]; const tagNames = aliases ? [tagName, ...aliases] : [tagName];
try { try {
const trends = await getTrendingTagValues(kysely, tagNames, { const trends = await getTrendingTagValues(db.kysely, tagNames, {
kinds, kinds,
since: yesterday, since: yesterday,
until: now, until: now,
@ -99,7 +105,7 @@ export async function updateTrendingTags(
return; return;
} }
const signer = Conf.signer; const signer = conf.signer;
const label = await signer.signEvent({ const label = await signer.signEvent({
kind: 1985, kind: 1985,
@ -112,7 +118,7 @@ export async function updateTrendingTags(
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
}); });
await handleEvent(label, { source: 'internal', signal }); await relay.event(label, { signal });
logi({ level: 'info', ns: 'ditto.trends', msg: 'Trends updated', ...params }); logi({ level: 'info', ns: 'ditto.trends', msg: 'Trends updated', ...params });
} catch (e) { } catch (e) {
logi({ level: 'error', ns: 'ditto.trends', msg: 'Error updating trends', ...params, error: errorJson(e) }); logi({ level: 'error', ns: 'ditto.trends', msg: 'Error updating trends', ...params, error: errorJson(e) });
@ -120,28 +126,28 @@ export async function updateTrendingTags(
} }
/** Update trending pubkeys. */ /** Update trending pubkeys. */
export function updateTrendingPubkeys(): Promise<void> { export function updateTrendingPubkeys(ctx: TrendsCtx): Promise<void> {
return updateTrendingTags('#p', 'p', [1, 3, 6, 7, 9735], 40, Conf.relay); return updateTrendingTags(ctx, '#p', 'p', [1, 3, 6, 7, 9735], 40, ctx.conf.relay);
} }
/** Update trending zapped events. */ /** Update trending zapped events. */
export function updateTrendingZappedEvents(): Promise<void> { export function updateTrendingZappedEvents(ctx: TrendsCtx): Promise<void> {
return updateTrendingTags('zapped', 'e', [9735], 40, Conf.relay, ['q']); return updateTrendingTags(ctx, 'zapped', 'e', [9735], 40, ctx.conf.relay, ['q']);
} }
/** Update trending events. */ /** Update trending events. */
export async function updateTrendingEvents(): Promise<void> { export async function updateTrendingEvents(ctx: TrendsCtx): Promise<void> {
const { conf, db } = ctx;
const results: Promise<void>[] = [ const results: Promise<void>[] = [
updateTrendingTags('#e', 'e', [1, 6, 7, 9735], 40, Conf.relay, ['q']), updateTrendingTags(ctx, '#e', 'e', [1, 6, 7, 9735], 40, ctx.conf.relay, ['q']),
]; ];
const kysely = await Storages.kysely(); for (const language of conf.preferredLanguages ?? []) {
for (const language of Conf.preferredLanguages ?? []) {
const yesterday = Math.floor((Date.now() - Time.days(1)) / 1000); const yesterday = Math.floor((Date.now() - Time.days(1)) / 1000);
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const rows = await kysely const rows = await db.kysely
.selectFrom('nostr_events') .selectFrom('nostr_events')
.select('nostr_events.id') .select('nostr_events.id')
.where(sql`nostr_events.search_ext->>'language'`, '=', language) .where(sql`nostr_events.search_ext->>'language'`, '=', language)
@ -151,18 +157,20 @@ export async function updateTrendingEvents(): Promise<void> {
const ids = rows.map((row) => row.id); const ids = rows.map((row) => row.id);
results.push(updateTrendingTags(`#e.${language}`, 'e', [1, 6, 7, 9735], 40, Conf.relay, ['q'], ids)); results.push(
updateTrendingTags(ctx, `#e.${language}`, 'e', [1, 6, 7, 9735], 40, conf.relay, ['q'], ids),
);
} }
await Promise.allSettled(results); await Promise.allSettled(results);
} }
/** Update trending hashtags. */ /** Update trending hashtags. */
export function updateTrendingHashtags(): Promise<void> { export function updateTrendingHashtags(ctx: TrendsCtx): Promise<void> {
return updateTrendingTags('#t', 't', [1], 20); return updateTrendingTags(ctx, '#t', 't', [1], 20);
} }
/** Update trending links. */ /** Update trending links. */
export function updateTrendingLinks(): Promise<void> { export function updateTrendingLinks(ctx: TrendsCtx): Promise<void> {
return updateTrendingTags('#r', 'r', [1], 20); return updateTrendingTags(ctx, '#r', 'r', [1], 20);
} }

View file

@ -1,25 +1,18 @@
import { HTTPException } from '@hono/hono/http-exception'; import { HTTPException } from '@hono/hono/http-exception';
import { NostrEvent, NostrFilter } from '@nostrify/nostrify'; import { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { logi } from '@soapbox/logi';
import { EventTemplate } from 'nostr-tools'; import { EventTemplate } from 'nostr-tools';
import * as TypeFest from 'type-fest'; import * as TypeFest from 'type-fest';
import { type AppContext } from '@/app.ts'; import { type AppContext } from '@/app.ts';
import { Conf } from '@/config.ts';
import * as pipeline from '@/pipeline.ts';
import { RelayError } from '@/RelayError.ts';
import { Storages } from '@/storages.ts';
import { nostrNow } from '@/utils.ts'; import { nostrNow } from '@/utils.ts';
import { parseFormData } from '@/utils/formdata.ts'; import { parseFormData } from '@/utils/formdata.ts';
import { errorJson } from '@/utils/log.ts';
import { purifyEvent } from '@/utils/purify.ts';
/** EventTemplate with defaults. */ /** EventTemplate with defaults. */
type EventStub = TypeFest.SetOptional<EventTemplate, 'content' | 'created_at' | 'tags'>; type EventStub = TypeFest.SetOptional<EventTemplate, 'content' | 'created_at' | 'tags'>;
/** Publish an event through the pipeline. */ /** Publish an event through the pipeline. */
async function createEvent(t: EventStub, c: AppContext): Promise<NostrEvent> { async function createEvent(t: EventStub, c: AppContext): Promise<NostrEvent> {
const { user } = c.var; const { user, relay, signal } = c.var;
if (!user) { if (!user) {
throw new HTTPException(401, { throw new HTTPException(401, {
@ -34,7 +27,8 @@ async function createEvent(t: EventStub, c: AppContext): Promise<NostrEvent> {
...t, ...t,
}); });
return publishEvent(event, c); await relay.event(event, { signal });
return event;
} }
/** Filter for fetching an existing event to update. */ /** Filter for fetching an existing event to update. */
@ -49,9 +43,9 @@ async function updateEvent<E extends EventStub>(
fn: (prev: NostrEvent) => E | Promise<E>, fn: (prev: NostrEvent) => E | Promise<E>,
c: AppContext, c: AppContext,
): Promise<NostrEvent> { ): Promise<NostrEvent> {
const store = await Storages.db(); const { relay } = c.var;
const [prev] = await store.query( const [prev] = await relay.query(
[filter], [filter],
{ signal: c.req.raw.signal }, { signal: c.req.raw.signal },
); );
@ -80,16 +74,17 @@ function updateListEvent(
/** Publish an admin event through the pipeline. */ /** Publish an admin event through the pipeline. */
async function createAdminEvent(t: EventStub, c: AppContext): Promise<NostrEvent> { async function createAdminEvent(t: EventStub, c: AppContext): Promise<NostrEvent> {
const signer = Conf.signer; const { conf, relay, signal } = c.var;
const event = await signer.signEvent({ const event = await conf.signer.signEvent({
content: '', content: '',
created_at: nostrNow(), created_at: nostrNow(),
tags: [], tags: [],
...t, ...t,
}); });
return publishEvent(event, c); await relay.event(event, { signal });
return event;
} }
/** Fetch existing event, update its tags, then publish the new admin event. */ /** Fetch existing event, update its tags, then publish the new admin event. */
@ -111,8 +106,8 @@ async function updateAdminEvent<E extends EventStub>(
fn: (prev: NostrEvent | undefined) => E, fn: (prev: NostrEvent | undefined) => E,
c: AppContext, c: AppContext,
): Promise<NostrEvent> { ): Promise<NostrEvent> {
const store = await Storages.db(); const { relay, signal } = c.var;
const [prev] = await store.query([filter], { limit: 1, signal: c.req.raw.signal }); const [prev] = await relay.query([filter], { signal });
return createAdminEvent(fn(prev), c); return createAdminEvent(fn(prev), c);
} }
@ -125,8 +120,8 @@ function updateEventInfo(id: string, n: Record<string, boolean>, c: AppContext):
} }
async function updateNames(k: number, d: string, n: Record<string, boolean>, c: AppContext): Promise<NostrEvent> { async function updateNames(k: number, d: string, n: Record<string, boolean>, c: AppContext): Promise<NostrEvent> {
const signer = Conf.signer; const { conf } = c.var;
const admin = await signer.getPublicKey(); const admin = await conf.signer.getPublicKey();
return updateAdminEvent( return updateAdminEvent(
{ kinds: [k], authors: [admin], '#d': [d], limit: 1 }, { kinds: [k], authors: [admin], '#d': [d], limit: 1 },
@ -154,33 +149,6 @@ async function updateNames(k: number, d: string, n: Record<string, boolean>, c:
); );
} }
/** Push the event through the pipeline, rethrowing any RelayError. */
async function publishEvent(event: NostrEvent, c: AppContext): Promise<NostrEvent> {
logi({ level: 'info', ns: 'ditto.event', source: 'api', id: event.id, kind: event.kind });
try {
const promise = pipeline.handleEvent(event, { source: 'api', signal: c.req.raw.signal });
promise.then(async () => {
const client = await Storages.client();
await client.event(purifyEvent(event));
}).catch((e: unknown) => {
logi({ level: 'error', ns: 'ditto.pool', id: event.id, kind: event.kind, error: errorJson(e) });
});
await promise;
} catch (e) {
if (e instanceof RelayError) {
throw new HTTPException(422, {
res: c.json({ error: e.message }, 422),
});
} else {
throw e;
}
}
return event;
}
/** Parse request body to JSON, depending on the content-type of the request. */ /** Parse request body to JSON, depending on the content-type of the request. */
async function parseBody(req: Request): Promise<unknown> { async function parseBody(req: Request): Promise<unknown> {
switch (req.headers.get('content-type')?.split(';')[0]) { switch (req.headers.get('content-type')?.split(';')[0]) {
@ -196,74 +164,8 @@ async function parseBody(req: Request): Promise<unknown> {
} }
} }
/** Build HTTP Link header for Mastodon API pagination. */
function buildLinkHeader(url: string, events: NostrEvent[]): string | undefined {
if (events.length <= 1) return;
const firstEvent = events[0];
const lastEvent = events[events.length - 1];
const { origin } = Conf.url;
const { pathname, search } = new URL(url);
const next = new URL(pathname + search, origin);
const prev = new URL(pathname + search, origin);
next.searchParams.set('until', String(lastEvent.created_at));
prev.searchParams.set('since', String(firstEvent.created_at));
return `<${next}>; rel="next", <${prev}>; rel="prev"`;
}
type HeaderRecord = Record<string, string | string[]>; type HeaderRecord = Record<string, string | string[]>;
/** Return results with pagination headers. Assumes chronological sorting of events. */
function paginated(c: AppContext, events: NostrEvent[], body: object | unknown[], headers: HeaderRecord = {}) {
const link = buildLinkHeader(c.req.url, events);
if (link) {
headers.link = link;
}
// Filter out undefined entities.
const results = Array.isArray(body) ? body.filter(Boolean) : body;
return c.json(results, 200, headers);
}
/** Build HTTP Link header for paginating Nostr lists. */
function buildListLinkHeader(url: string, params: { offset: number; limit: number }): string | undefined {
const { origin } = Conf.url;
const { pathname, search } = new URL(url);
const { offset, limit } = params;
const next = new URL(pathname + search, origin);
const prev = new URL(pathname + search, origin);
next.searchParams.set('offset', String(offset + limit));
prev.searchParams.set('offset', String(Math.max(offset - limit, 0)));
next.searchParams.set('limit', String(limit));
prev.searchParams.set('limit', String(limit));
return `<${next}>; rel="next", <${prev}>; rel="prev"`;
}
/** paginate a list of tags. */
function paginatedList(
c: AppContext,
params: { offset: number; limit: number },
body: object | unknown[],
headers: HeaderRecord = {},
) {
const link = buildListLinkHeader(c.req.url, params);
const hasMore = Array.isArray(body) ? body.length > 0 : true;
if (link) {
headers.link = hasMore ? link : link.split(', ').find((link) => link.endsWith('; rel="prev"'))!;
}
// Filter out undefined entities.
const results = Array.isArray(body) ? body.filter(Boolean) : body;
return c.json(results, 200, headers);
}
/** Actors with Bluesky's `!no-unauthenticated` self-label should require authorization to view. */ /** Actors with Bluesky's `!no-unauthenticated` self-label should require authorization to view. */
function assertAuthenticated(c: AppContext, author: NostrEvent): void { function assertAuthenticated(c: AppContext, author: NostrEvent): void {
if ( if (
@ -282,8 +184,6 @@ export {
createAdminEvent, createAdminEvent,
createEvent, createEvent,
type EventStub, type EventStub,
paginated,
paginatedList,
parseBody, parseBody,
updateAdminEvent, updateAdminEvent,
updateEvent, updateEvent,

View file

@ -1,28 +0,0 @@
import { Conf } from '@/config.ts';
import { Storages } from '@/storages.ts';
import { getInstanceMetadata } from '@/utils/instance.ts';
/** NIP-46 client-connect metadata. */
interface ConnectMetadata {
name: string;
description: string;
url: string;
}
/** Get NIP-46 `nostrconnect://` URI for the Ditto server. */
export async function getClientConnectUri(signal?: AbortSignal): Promise<string> {
const uri = new URL('nostrconnect://');
const { name, tagline } = await getInstanceMetadata(await Storages.db(), signal);
const metadata: ConnectMetadata = {
name,
description: tagline,
url: Conf.localDomain,
};
uri.host = await Conf.signer.getPublicKey();
uri.searchParams.set('relay', Conf.relay);
uri.searchParams.set('metadata', JSON.stringify(metadata));
return uri.toString();
}

View file

@ -1,36 +1,13 @@
import { DOMParser } from '@b-fuze/deno-dom'; import { DOMParser } from '@b-fuze/deno-dom';
import { DittoTables } from '@ditto/db'; import { DittoTables } from '@ditto/db';
import { cachedFaviconsSizeGauge } from '@ditto/metrics';
import { logi } from '@soapbox/logi'; import { logi } from '@soapbox/logi';
import { safeFetch } from '@soapbox/safe-fetch'; import { safeFetch } from '@soapbox/safe-fetch';
import { Kysely } from 'kysely'; import { Kysely } from 'kysely';
import tldts from 'tldts'; import tldts from 'tldts';
import { Conf } from '@/config.ts';
import { Storages } from '@/storages.ts';
import { nostrNow } from '@/utils.ts'; import { nostrNow } from '@/utils.ts';
import { SimpleLRU } from '@/utils/SimpleLRU.ts';
export const faviconCache = new SimpleLRU<string, URL>( export async function queryFavicon(
async (domain, { signal }) => {
const kysely = await Storages.kysely();
const row = await queryFavicon(kysely, domain);
if (row && (nostrNow() - row.last_updated_at) < (Conf.caches.favicon.ttl / 1000)) {
return new URL(row.favicon);
}
const url = await fetchFavicon(domain, signal);
await insertFavicon(kysely, domain, url.href);
return url;
},
{ ...Conf.caches.favicon, gauge: cachedFaviconsSizeGauge },
);
async function queryFavicon(
kysely: Kysely<DittoTables>, kysely: Kysely<DittoTables>,
domain: string, domain: string,
): Promise<DittoTables['domain_favicons'] | undefined> { ): Promise<DittoTables['domain_favicons'] | undefined> {
@ -41,7 +18,7 @@ async function queryFavicon(
.executeTakeFirst(); .executeTakeFirst();
} }
async function insertFavicon(kysely: Kysely<DittoTables>, domain: string, favicon: string): Promise<void> { export async function insertFavicon(kysely: Kysely<DittoTables>, domain: string, favicon: string): Promise<void> {
await kysely await kysely
.insertInto('domain_favicons') .insertInto('domain_favicons')
.values({ domain, favicon, last_updated_at: nostrNow() }) .values({ domain, favicon, last_updated_at: nostrNow() })
@ -49,7 +26,7 @@ async function insertFavicon(kysely: Kysely<DittoTables>, domain: string, favico
.execute(); .execute();
} }
async function fetchFavicon(domain: string, signal?: AbortSignal): Promise<URL> { export async function fetchFavicon(domain: string, signal?: AbortSignal): Promise<URL> {
logi({ level: 'info', ns: 'ditto.favicon', domain, state: 'started' }); logi({ level: 'info', ns: 'ditto.favicon', domain, state: 'started' });
const tld = tldts.parse(domain); const tld = tldts.parse(domain);

View file

@ -1,32 +1,42 @@
import { NostrEvent, NSchema as n } from '@nostrify/nostrify'; import { NostrEvent, NSchema as n, NStore } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { match } from 'path-to-regexp'; import { match } from 'path-to-regexp';
import tldts from 'tldts'; import tldts from 'tldts';
import { getAuthor } from '@/queries.ts'; import { getAuthor } from '@/queries.ts';
import { bech32ToPubkey } from '@/utils.ts'; import { bech32ToPubkey } from '@/utils.ts';
import { nip05Cache } from '@/utils/nip05.ts'; import { lookupNip05 } from '@/utils/nip05.ts';
import type { DittoConf } from '@ditto/conf';
import type { DittoDB } from '@ditto/db';
interface LookupAccountOpts {
db: DittoDB;
conf: DittoConf;
relay: NStore;
signal?: AbortSignal;
}
/** Resolve a bech32 or NIP-05 identifier to an account. */ /** Resolve a bech32 or NIP-05 identifier to an account. */
export async function lookupAccount( export async function lookupAccount(
value: string, value: string,
signal = AbortSignal.timeout(3000), opts: LookupAccountOpts,
): Promise<NostrEvent | undefined> { ): Promise<NostrEvent | undefined> {
const pubkey = await lookupPubkey(value, signal); const pubkey = await lookupPubkey(value, opts);
if (pubkey) { if (pubkey) {
return getAuthor(pubkey); return getAuthor(pubkey, opts);
} }
} }
/** Resolve a bech32 or NIP-05 identifier to a pubkey. */ /** Resolve a bech32 or NIP-05 identifier to a pubkey. */
export async function lookupPubkey(value: string, signal?: AbortSignal): Promise<string | undefined> { export async function lookupPubkey(value: string, opts: LookupAccountOpts): Promise<string | undefined> {
if (n.bech32().safeParse(value).success) { if (n.bech32().safeParse(value).success) {
return bech32ToPubkey(value); return bech32ToPubkey(value);
} }
try { try {
const { pubkey } = await nip05Cache.fetch(value, { signal }); const { pubkey } = await lookupNip05(value, opts);
return pubkey; return pubkey;
} catch { } catch {
return; return;

View file

@ -1,28 +1,20 @@
import { cachedNip05sSizeGauge } from '@ditto/metrics'; import { DittoConf } from '@ditto/conf';
import { NIP05, NStore } from '@nostrify/nostrify'; import { NIP05, NStore } from '@nostrify/nostrify';
import { logi } from '@soapbox/logi'; import { logi } from '@soapbox/logi';
import { safeFetch } from '@soapbox/safe-fetch'; import { safeFetch } from '@soapbox/safe-fetch';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import tldts from 'tldts'; import tldts from 'tldts';
import { Conf } from '@/config.ts';
import { Storages } from '@/storages.ts';
import { errorJson } from '@/utils/log.ts'; import { errorJson } from '@/utils/log.ts';
import { SimpleLRU } from '@/utils/SimpleLRU.ts';
export const nip05Cache = new SimpleLRU<string, nip19.ProfilePointer>( interface GetNip05Opts {
async (nip05, { signal }) => { conf: DittoConf;
const store = await Storages.db(); relay: NStore;
return getNip05(store, nip05, signal); signal?: AbortSignal;
}, }
{ ...Conf.caches.nip05, gauge: cachedNip05sSizeGauge },
);
async function getNip05( export async function lookupNip05(nip05: string, opts: GetNip05Opts): Promise<nip19.ProfilePointer> {
store: NStore, const { conf, signal } = opts;
nip05: string,
signal?: AbortSignal,
): Promise<nip19.ProfilePointer> {
const tld = tldts.parse(nip05); const tld = tldts.parse(nip05);
if (!tld.isIcann || tld.isIp || tld.isPrivate) { if (!tld.isIcann || tld.isIp || tld.isPrivate) {
@ -34,8 +26,8 @@ async function getNip05(
const [name, domain] = nip05.split('@'); const [name, domain] = nip05.split('@');
try { try {
if (domain === Conf.url.host) { if (domain === conf.url.host) {
const pointer = await localNip05Lookup(store, name); const pointer = await localNip05Lookup(name, opts);
if (pointer) { if (pointer) {
logi({ level: 'info', ns: 'ditto.nip05', nip05, state: 'found', source: 'local', pubkey: pointer.pubkey }); logi({ level: 'info', ns: 'ditto.nip05', nip05, state: 'found', source: 'local', pubkey: pointer.pubkey });
return pointer; return pointer;
@ -53,19 +45,24 @@ async function getNip05(
} }
} }
export async function localNip05Lookup(store: NStore, localpart: string): Promise<nip19.ProfilePointer | undefined> { export async function localNip05Lookup(
const name = `${localpart}@${Conf.url.host}`; localpart: string,
opts: GetNip05Opts,
): Promise<nip19.ProfilePointer | undefined> {
const { conf, relay, signal } = opts;
const [grant] = await store.query([{ const name = `${localpart}@${conf.url.host}`;
const [grant] = await relay.query([{
kinds: [30360], kinds: [30360],
'#d': [name, name.toLowerCase()], '#d': [name, name.toLowerCase()],
authors: [await Conf.signer.getPublicKey()], authors: [await conf.signer.getPublicKey()],
limit: 1, limit: 1,
}]); }], { signal });
const pubkey = grant?.tags.find(([name]) => name === 'p')?.[1]; const pubkey = grant?.tags.find(([name]) => name === 'p')?.[1];
if (pubkey) { if (pubkey) {
return { pubkey, relays: [Conf.relay] }; return { pubkey, relays: [conf.relay] };
} }
} }

View file

@ -1,10 +1,10 @@
import { paginated, paginatedList } from '@ditto/mastoapi/pagination';
import { NostrEvent, NostrFilter } from '@nostrify/nostrify'; import { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { AppContext } from '@/app.ts'; import { AppContext } from '@/app.ts';
import { paginationSchema } from '@/schemas/pagination.ts'; import { paginationSchema } from '@/schemas/pagination.ts';
import { renderAccount } from '@/views/mastodon/accounts.ts'; import { renderAccount } from '@/views/mastodon/accounts.ts';
import { renderStatus } from '@/views/mastodon/statuses.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts';
import { paginated, paginatedList } from '@/utils/api.ts';
import { hydrateEvents } from '@/storages/hydrate.ts'; import { hydrateEvents } from '@/storages/hydrate.ts';
import { accountFromPubkey } from '@/views/mastodon/accounts.ts'; import { accountFromPubkey } from '@/views/mastodon/accounts.ts';
@ -25,7 +25,7 @@ async function renderEventAccounts(c: AppContext, filters: NostrFilter[], opts?:
const events = await relay.query(filters, { signal }) const events = await relay.query(filters, { signal })
// Deduplicate by author. // Deduplicate by author.
.then((events) => Array.from(new Map(events.map((event) => [event.pubkey, event])).values())) .then((events) => Array.from(new Map(events.map((event) => [event.pubkey, event])).values()))
.then((events) => hydrateEvents({ events, relay, signal })) .then((events) => hydrateEvents({ ...c.var, events, relay, signal }))
.then((events) => filterFn ? events.filter(filterFn) : events); .then((events) => filterFn ? events.filter(filterFn) : events);
const accounts = await Promise.all( const accounts = await Promise.all(
@ -48,7 +48,7 @@ async function renderAccounts(c: AppContext, pubkeys: string[]) {
const { relay, signal } = c.var; const { relay, signal } = c.var;
const events = await relay.query([{ kinds: [0], authors }], { signal }) const events = await relay.query([{ kinds: [0], authors }], { signal })
.then((events) => hydrateEvents({ events, relay, signal })); .then((events) => hydrateEvents({ ...c.var, events }));
const accounts = await Promise.all( const accounts = await Promise.all(
authors.map((pubkey) => { authors.map((pubkey) => {
@ -74,7 +74,7 @@ async function renderStatuses(c: AppContext, ids: string[], signal = AbortSignal
const { limit } = pagination; const { limit } = pagination;
const events = await relay.query([{ kinds: [1, 20], ids, limit }], { signal }) const events = await relay.query([{ kinds: [1, 20], ids, limit }], { signal })
.then((events) => hydrateEvents({ events, relay, signal })); .then((events) => hydrateEvents({ ...c.var, events }));
if (!events.length) { if (!events.length) {
return c.json([]); return c.json([]);
@ -85,7 +85,7 @@ async function renderStatuses(c: AppContext, ids: string[], signal = AbortSignal
const viewerPubkey = await user?.signer.getPublicKey(); const viewerPubkey = await user?.signer.getPublicKey();
const statuses = await Promise.all( const statuses = await Promise.all(
sortedEvents.map((event) => renderStatus(event, { viewerPubkey })), sortedEvents.map((event) => renderStatus(relay, event, { viewerPubkey })),
); );
// TODO: pagination with min_id and max_id based on the order of `ids`. // TODO: pagination with min_id and max_id based on the order of `ids`.

View file

@ -1,4 +1,4 @@
import { NostrEvent } from '@nostrify/nostrify'; import { NostrEvent, NStore } from '@nostrify/nostrify';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
@ -10,23 +10,23 @@ interface RenderNotificationOpts {
viewerPubkey: string; viewerPubkey: string;
} }
async function renderNotification(event: DittoEvent, opts: RenderNotificationOpts) { async function renderNotification(store: NStore, event: DittoEvent, opts: RenderNotificationOpts) {
const mentioned = !!event.tags.find(([name, value]) => name === 'p' && value === opts.viewerPubkey); const mentioned = !!event.tags.find(([name, value]) => name === 'p' && value === opts.viewerPubkey);
if (event.kind === 1 && mentioned) { if (event.kind === 1 && mentioned) {
return renderMention(event, opts); return renderMention(store, event, opts);
} }
if (event.kind === 6) { if (event.kind === 6) {
return renderReblog(event, opts); return renderReblog(store, event, opts);
} }
if (event.kind === 7 && event.content === '+') { if (event.kind === 7 && event.content === '+') {
return renderFavourite(event, opts); return renderFavourite(store, event, opts);
} }
if (event.kind === 7) { if (event.kind === 7) {
return renderReaction(event, opts); return renderReaction(store, event, opts);
} }
if (event.kind === 30360 && event.pubkey === await Conf.signer.getPublicKey()) { if (event.kind === 30360 && event.pubkey === await Conf.signer.getPublicKey()) {
@ -34,12 +34,12 @@ async function renderNotification(event: DittoEvent, opts: RenderNotificationOpt
} }
if (event.kind === 9735) { if (event.kind === 9735) {
return renderZap(event, opts); return renderZap(store, event, opts);
} }
} }
async function renderMention(event: DittoEvent, opts: RenderNotificationOpts) { async function renderMention(store: NStore, event: DittoEvent, opts: RenderNotificationOpts) {
const status = await renderStatus(event, opts); const status = await renderStatus(store, event, opts);
if (!status) return; if (!status) return;
return { return {
@ -51,9 +51,9 @@ async function renderMention(event: DittoEvent, opts: RenderNotificationOpts) {
}; };
} }
async function renderReblog(event: DittoEvent, opts: RenderNotificationOpts) { async function renderReblog(store: NStore, event: DittoEvent, opts: RenderNotificationOpts) {
if (event.repost?.kind !== 1) return; if (event.repost?.kind !== 1) return;
const status = await renderStatus(event.repost, opts); const status = await renderStatus(store, event.repost, opts);
if (!status) return; if (!status) return;
const account = event.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey); const account = event.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey);
@ -66,9 +66,9 @@ async function renderReblog(event: DittoEvent, opts: RenderNotificationOpts) {
}; };
} }
async function renderFavourite(event: DittoEvent, opts: RenderNotificationOpts) { async function renderFavourite(store: NStore, event: DittoEvent, opts: RenderNotificationOpts) {
if (event.reacted?.kind !== 1) return; if (event.reacted?.kind !== 1) return;
const status = await renderStatus(event.reacted, opts); const status = await renderStatus(store, event.reacted, opts);
if (!status) return; if (!status) return;
const account = event.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey); const account = event.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey);
@ -81,9 +81,9 @@ async function renderFavourite(event: DittoEvent, opts: RenderNotificationOpts)
}; };
} }
async function renderReaction(event: DittoEvent, opts: RenderNotificationOpts) { async function renderReaction(store: NStore, event: DittoEvent, opts: RenderNotificationOpts) {
if (event.reacted?.kind !== 1) return; if (event.reacted?.kind !== 1) return;
const status = await renderStatus(event.reacted, opts); const status = await renderStatus(store, event.reacted, opts);
if (!status) return; if (!status) return;
const account = event.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey); const account = event.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey);
@ -116,7 +116,7 @@ async function renderNameGrant(event: DittoEvent) {
}; };
} }
async function renderZap(event: DittoEvent, opts: RenderNotificationOpts) { async function renderZap(store: NStore, event: DittoEvent, opts: RenderNotificationOpts) {
if (!event.zap_sender) return; if (!event.zap_sender) return;
const { zap_amount = 0, zap_message = '' } = event; const { zap_amount = 0, zap_message = '' } = event;
@ -133,7 +133,7 @@ async function renderZap(event: DittoEvent, opts: RenderNotificationOpts) {
message: zap_message, message: zap_message,
created_at: nostrDate(event.created_at).toISOString(), created_at: nostrDate(event.created_at).toISOString(),
account, account,
...(event.zapped ? { status: await renderStatus(event.zapped, opts) } : {}), ...(event.zapped ? { status: await renderStatus(store, event.zapped, opts) } : {}),
}; };
} }

View file

@ -1,4 +1,4 @@
import type { NostrEvent } from '@nostrify/nostrify'; import type { NostrEvent, NStore } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { MastodonPush } from '@/types/MastodonPush.ts'; import { MastodonPush } from '@/types/MastodonPush.ts';
@ -9,10 +9,11 @@ import { renderNotification } from '@/views/mastodon/notifications.ts';
* Unlike other views, only one will be rendered at a time, so making use of async calls is okay. * Unlike other views, only one will be rendered at a time, so making use of async calls is okay.
*/ */
export async function renderWebPushNotification( export async function renderWebPushNotification(
store: NStore,
event: NostrEvent, event: NostrEvent,
viewerPubkey: string, viewerPubkey: string,
): Promise<MastodonPush | undefined> { ): Promise<MastodonPush | undefined> {
const notification = await renderNotification(event, { viewerPubkey }); const notification = await renderNotification(store, event, { viewerPubkey });
if (!notification) { if (!notification) {
return; return;
} }

View file

@ -1,3 +1,5 @@
import { NStore } from '@nostrify/nostrify';
import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
import { nostrDate } from '@/utils.ts'; import { nostrDate } from '@/utils.ts';
@ -6,7 +8,7 @@ import { renderStatus } from '@/views/mastodon/statuses.ts';
import { getTagSet } from '@/utils/tags.ts'; import { getTagSet } from '@/utils/tags.ts';
/** Expects a `reportEvent` of kind 1984 and a `profile` of kind 0 of the person being reported */ /** Expects a `reportEvent` of kind 1984 and a `profile` of kind 0 of the person being reported */
async function renderReport(event: DittoEvent) { function renderReport(event: DittoEvent) {
// The category is present in both the 'e' and 'p' tag, however, it is possible to report a user without reporting a note, so it's better to get the category from the 'p' tag // The category is present in both the 'e' and 'p' tag, however, it is possible to report a user without reporting a note, so it's better to get the category from the 'p' tag
const category = event.tags.find(([name]) => name === 'p')?.[2]; const category = event.tags.find(([name]) => name === 'p')?.[2];
const statusIds = event.tags.filter(([name]) => name === 'e').map((tag) => tag[1]) ?? []; const statusIds = event.tags.filter(([name]) => name === 'e').map((tag) => tag[1]) ?? [];
@ -23,9 +25,7 @@ async function renderReport(event: DittoEvent) {
created_at: nostrDate(event.created_at).toISOString(), created_at: nostrDate(event.created_at).toISOString(),
status_ids: statusIds, status_ids: statusIds,
rules_ids: null, rules_ids: null,
target_account: event.reported_profile target_account: event.reported_profile ? renderAccount(event.reported_profile) : accountFromPubkey(reportedPubkey),
? await renderAccount(event.reported_profile)
: await accountFromPubkey(reportedPubkey),
}; };
} }
@ -36,7 +36,7 @@ interface RenderAdminReportOpts {
/** Admin-level information about a filed report. /** Admin-level information about a filed report.
* Expects an event of kind 1984 fully hydrated. * Expects an event of kind 1984 fully hydrated.
* https://docs.joinmastodon.org/entities/Admin_Report */ * https://docs.joinmastodon.org/entities/Admin_Report */
async function renderAdminReport(event: DittoEvent, opts: RenderAdminReportOpts) { async function renderAdminReport(store: NStore, event: DittoEvent, opts: RenderAdminReportOpts) {
const { viewerPubkey } = opts; const { viewerPubkey } = opts;
// The category is present in both the 'e' and 'p' tag, however, it is possible to report a user without reporting a note, so it's better to get the category from the 'p' tag // The category is present in both the 'e' and 'p' tag, however, it is possible to report a user without reporting a note, so it's better to get the category from the 'p' tag
@ -45,7 +45,7 @@ async function renderAdminReport(event: DittoEvent, opts: RenderAdminReportOpts)
const statuses = []; const statuses = [];
if (event.reported_notes) { if (event.reported_notes) {
for (const status of event.reported_notes) { for (const status of event.reported_notes) {
statuses.push(await renderStatus(status, { viewerPubkey })); statuses.push(await renderStatus(store, status, { viewerPubkey }));
} }
} }

View file

@ -1,4 +1,4 @@
import { NostrEvent } from '@nostrify/nostrify'; import { NostrEvent, NStore } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
@ -6,7 +6,6 @@ import { MastodonAttachment } from '@/entities/MastodonAttachment.ts';
import { MastodonMention } from '@/entities/MastodonMention.ts'; import { MastodonMention } from '@/entities/MastodonMention.ts';
import { MastodonStatus } from '@/entities/MastodonStatus.ts'; import { MastodonStatus } from '@/entities/MastodonStatus.ts';
import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
import { Storages } from '@/storages.ts';
import { nostrDate } from '@/utils.ts'; import { nostrDate } from '@/utils.ts';
import { getMediaLinks, parseNoteContent, stripimeta } from '@/utils/note.ts'; import { getMediaLinks, parseNoteContent, stripimeta } from '@/utils/note.ts';
import { findReplyTag } from '@/utils/tags.ts'; import { findReplyTag } from '@/utils/tags.ts';
@ -20,7 +19,11 @@ interface RenderStatusOpts {
depth?: number; depth?: number;
} }
async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise<MastodonStatus | undefined> { async function renderStatus(
store: NStore,
event: DittoEvent,
opts: RenderStatusOpts,
): Promise<MastodonStatus | undefined> {
const { viewerPubkey, depth = 1 } = opts; const { viewerPubkey, depth = 1 } = opts;
if (depth > 2 || depth < 0) return; if (depth > 2 || depth < 0) return;
@ -38,8 +41,6 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise<
const replyId = findReplyTag(event.tags)?.[1]; const replyId = findReplyTag(event.tags)?.[1];
const store = await Storages.db();
const mentions = event.mentions?.map((event) => renderMention(event)) ?? []; const mentions = event.mentions?.map((event) => renderMention(event)) ?? [];
const { html, links, firstUrl } = parseNoteContent(stripimeta(event.content, event.tags), mentions); const { html, links, firstUrl } = parseNoteContent(stripimeta(event.content, event.tags), mentions);
@ -123,7 +124,7 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise<
tags: [], tags: [],
emojis: renderEmojis(event), emojis: renderEmojis(event),
poll: null, poll: null,
quote: !event.quote ? null : await renderStatus(event.quote, { depth: depth + 1 }), quote: !event.quote ? null : await renderStatus(store, event.quote, { depth: depth + 1 }),
quote_id: event.quote?.id ?? null, quote_id: event.quote?.id ?? null,
uri: Conf.local(`/users/${account.acct}/statuses/${event.id}`), uri: Conf.local(`/users/${account.acct}/statuses/${event.id}`),
url: Conf.local(`/@${account.acct}/${event.id}`), url: Conf.local(`/@${account.acct}/${event.id}`),
@ -139,14 +140,18 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise<
}; };
} }
async function renderReblog(event: DittoEvent, opts: RenderStatusOpts): Promise<MastodonStatus | undefined> { async function renderReblog(
store: NStore,
event: DittoEvent,
opts: RenderStatusOpts,
): Promise<MastodonStatus | undefined> {
const { viewerPubkey } = opts; const { viewerPubkey } = opts;
if (!event.repost) return; if (!event.repost) return;
const status = await renderStatus(event, {}); // omit viewerPubkey intentionally const status = await renderStatus(store, event, {}); // omit viewerPubkey intentionally
if (!status) return; if (!status) return;
const reblog = await renderStatus(event.repost, { viewerPubkey }) ?? null; const reblog = await renderStatus(store, event.repost, { viewerPubkey }) ?? null;
return { return {
...status, ...status,

View file

@ -30,7 +30,7 @@ export class CustomPolicy implements NPolicy {
async init({ path, databaseUrl, pubkey }: PolicyInit): Promise<void> { async init({ path, databaseUrl, pubkey }: PolicyInit): Promise<void> {
const Policy = (await import(path)).default; const Policy = (await import(path)).default;
const db = DittoPolyPg.create(databaseUrl, { poolSize: 1 }); const db = new DittoPolyPg(databaseUrl, { poolSize: 1 });
const store = new DittoPgStore({ const store = new DittoPgStore({
db, db,

View file

@ -3,6 +3,7 @@
"version": "1.1.0", "version": "1.1.0",
"exports": { "exports": {
"./middleware": "./middleware/mod.ts", "./middleware": "./middleware/mod.ts",
"./pagination": "./pagination/mod.ts",
"./router": "./router/mod.ts", "./router": "./router/mod.ts",
"./test": "./test.ts" "./test": "./test.ts"
} }

View file

@ -0,0 +1,3 @@
export { buildLinkHeader, buildListLinkHeader } from './link-header.ts';
export { paginated, paginatedList } from './paginate.ts';
export { paginationSchema } from './schema.ts';

View file

@ -7,7 +7,7 @@ import { DittoApp } from './DittoApp.ts';
import { DittoRoute } from './DittoRoute.ts'; import { DittoRoute } from './DittoRoute.ts';
Deno.test('DittoApp', async () => { Deno.test('DittoApp', async () => {
await using db = DittoPolyPg.create('memory://'); await using db = new DittoPolyPg('memory://');
const conf = new DittoConf(new Map()); const conf = new DittoConf(new Map());
const relay = new MockRelay(); const relay = new MockRelay();

View file

@ -1,5 +1,8 @@
import { DittoConf } from '@ditto/conf';
import { DittoPolyPg } from '@ditto/db';
import { z } from 'zod'; import { z } from 'zod';
import { DittoPgStore } from '../packages/ditto/storages/DittoPgStore.ts';
import { import {
updateTrendingEvents, updateTrendingEvents,
updateTrendingHashtags, updateTrendingHashtags,
@ -8,6 +11,11 @@ import {
updateTrendingZappedEvents, updateTrendingZappedEvents,
} from '../packages/ditto/trends.ts'; } from '../packages/ditto/trends.ts';
const conf = new DittoConf(Deno.env);
const db = new DittoPolyPg(conf.databaseUrl);
const relay = new DittoPgStore({ db, pubkey: await conf.signer.getPublicKey() });
const ctx = { conf, db, relay };
const trendSchema = z.enum(['pubkeys', 'zapped_events', 'events', 'hashtags', 'links']); const trendSchema = z.enum(['pubkeys', 'zapped_events', 'events', 'hashtags', 'links']);
const trends = trendSchema.array().parse(Deno.args); const trends = trendSchema.array().parse(Deno.args);
@ -19,23 +27,23 @@ for (const trend of trends) {
switch (trend) { switch (trend) {
case 'pubkeys': case 'pubkeys':
console.log('Updating trending pubkeys...'); console.log('Updating trending pubkeys...');
await updateTrendingPubkeys(); await updateTrendingPubkeys(ctx);
break; break;
case 'zapped_events': case 'zapped_events':
console.log('Updating trending zapped events...'); console.log('Updating trending zapped events...');
await updateTrendingZappedEvents(); await updateTrendingZappedEvents(ctx);
break; break;
case 'events': case 'events':
console.log('Updating trending events...'); console.log('Updating trending events...');
await updateTrendingEvents(); await updateTrendingEvents(ctx);
break; break;
case 'hashtags': case 'hashtags':
console.log('Updating trending hashtags...'); console.log('Updating trending hashtags...');
await updateTrendingHashtags(); await updateTrendingHashtags(ctx);
break; break;
case 'links': case 'links':
console.log('Updating trending links...'); console.log('Updating trending links...');
await updateTrendingLinks(); await updateTrendingLinks(ctx);
break; break;
} }
} }