Rewrite literally everything

This commit is contained in:
Alex Gleason 2025-02-16 23:27:13 -06:00
parent 9e9a784416
commit 65846d062a
No known key found for this signature in database
GPG key ID: 7211D1F99744FBB7
110 changed files with 2646 additions and 2532 deletions

View file

@ -1,19 +0,0 @@
import { Hono } from '@hono/hono';
import { assertEquals } from '@std/assert';
import { confMw } from './confMw.ts';
Deno.test('confMw', async () => {
const env = new Map([
['DITTO_NSEC', 'nsec19shyxpuzd0cq2p5078fwnws7tyykypud6z205fzhlmlrs2vpz6hs83zwkw'],
]);
const app = new Hono();
app.get('/', confMw(env), (c) => c.text(c.var.conf.pubkey));
const response = await app.request('/');
const body = await response.text();
assertEquals(body, '1ba0c5ed1bbbf3b7eb0d7843ba16836a0201ea68a76bafcba507358c45911ff6');
});

View file

@ -1,15 +0,0 @@
import { DittoConf } from '@ditto/conf';
import type { MiddlewareHandler } from '@hono/hono';
/** Set Ditto config. */
export function confMw(
env: { get(key: string): string | undefined },
): MiddlewareHandler<{ Variables: { conf: DittoConf } }> {
const conf = new DittoConf(env);
return async (c, next) => {
c.set('conf', conf);
await next();
};
}

View file

@ -1,22 +0,0 @@
import { Hono } from '@hono/hono';
import { assertEquals } from '@std/assert';
import { confMw } from './confMw.ts';
import { confRequiredMw } from './confRequiredMw.ts';
Deno.test('confRequiredMw', async (t) => {
const app = new Hono();
app.get('/without', confRequiredMw, (c) => c.text('ok'));
app.get('/with', confMw(new Map()), confRequiredMw, (c) => c.text('ok'));
await t.step('without conf returns 500', async () => {
const response = await app.request('/without');
assertEquals(response.status, 500);
});
await t.step('with conf returns 200', async () => {
const response = await app.request('/with');
assertEquals(response.status, 200);
});
});

View file

@ -1,15 +0,0 @@
import { HTTPException } from '@hono/hono/http-exception';
import type { DittoConf } from '@ditto/conf';
import type { MiddlewareHandler } from '@hono/hono';
/** Throws an error if conf isn't set. */
export const confRequiredMw: MiddlewareHandler<{ Variables: { conf: DittoConf } }> = async (c, next) => {
const { conf } = c.var;
if (!conf) {
throw new HTTPException(500, { message: 'Ditto config not set in request.' });
}
await next();
};

View file

@ -1,2 +0,0 @@
export { confMw } from './confMw.ts';
export { confRequiredMw } from './confRequiredMw.ts';

View file

@ -329,6 +329,11 @@ export class DittoConf {
.map(Number); .map(Number);
} }
/** Whether to perform prechecks when Ditto is starting. Setting this to `false` can suppress errors, but is not recommended. */
get precheck(): boolean {
return optionalBooleanSchema.parse(this.env.get('DITTO_PRECHECK')) ?? true;
}
/** /**
* Whether Ditto should subscribe to Nostr events from the Postgres database itself. * Whether Ditto should subscribe to Nostr events from the Postgres database itself.
* This would make Nostr events inserted directly into Postgres available to the streaming API and relay. * This would make Nostr events inserted directly into Postgres available to the streaming API and relay.
@ -357,6 +362,11 @@ export class DittoConf {
return this.env.get('DITTO_DATA_DIR') || path.join(cwd(), 'data'); return this.env.get('DITTO_DATA_DIR') || path.join(cwd(), 'data');
} }
/** Directory to serve the frontend from. */
get publicDir(): string {
return this.env.get('DITTO_PUBLIC_DIR') || path.join(this.dataDir, 'public');
}
/** Absolute path of the Deno directory. */ /** Absolute path of the Deno directory. */
get denoDir(): string { get denoDir(): string {
return this.env.get('DENO_DIR') || `${os.userInfo().homedir}/.cache/deno`; return this.env.get('DENO_DIR') || `${os.userInfo().homedir}/.cache/deno`;

View file

@ -0,0 +1,412 @@
import { type DittoConf } from '@ditto/conf';
import { DittoTables } from '@ditto/db';
import { pipelineEventsCounter, policyEventsCounter, webPushNotificationsCounter } from '@ditto/metrics';
import { NKinds, NostrEvent, NSchema as n, NStore } from '@nostrify/nostrify';
import { logi } from '@soapbox/logi';
import { Kysely, UpdateObject } from 'kysely';
import { LRUCache } from 'lru-cache';
import tldts from 'tldts';
import { z } from 'zod';
import { DittoPush } from '@/DittoPush.ts';
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { RelayError } from '@/RelayError.ts';
import { AdminSigner } from '@/signers/AdminSigner.ts';
import { type EventsDB } from '@/storages/EventsDB.ts';
import { hydrateEvents } from '@/storages/hydrate.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 { resolveNip05 } 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 {
conf: DittoConf;
kysely: Kysely<DittoTables>;
store: EventsDB;
pubsub: NStore;
}
export class DittoPipeline {
private push: DittoPush;
private policyWorker: PolicyWorker;
encounters = new LRUCache<string, true>({ max: 5000 });
constructor(private opts: PipelineOpts) {
this.push = new DittoPush(opts);
this.policyWorker = new PolicyWorker(opts.conf);
}
/**
* Common pipeline function to process (and maybe store) events.
* It is idempotent, so it can be called multiple times for the same event.
*/
async event(event: DittoEvent, opts?: { signal: AbortSignal; source?: string }): Promise<void> {
const { kysely, conf } = this.opts;
// Skip events that have already been encountered.
if (this.encounters.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) && !this.isFresh(event)) {
throw new RelayError('invalid', 'event too old');
}
// Block NIP-70 events, because we have no way to `AUTH`.
if (event.tags.some(([name]) => name === '-')) {
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 (this.encounters.has(event.id)) {
throw new RelayError('duplicate', 'already have this event');
}
// Set the event as encountered after verifying the signature.
this.encounters.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) {
await this.streamOut(event);
return;
}
// Ensure the event doesn't violate the policy.
if (event.pubkey !== conf.pubkey) {
await this.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 this.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');
}
// Ephemeral events must throw if they are not streamed out.
if (NKinds.ephemeral(event.kind)) {
await Promise.all([
this.streamOut(event),
this.webPush(event),
]);
return;
}
// Events received through notify are thought to already be in the database, so they only need to be streamed.
if (opts?.source === 'notify') {
await Promise.all([
this.streamOut(event),
this.webPush(event),
]);
return;
}
try {
await this.storeEvent(purifyEvent(event), opts?.signal);
} finally {
// This needs to run in steps, and should not block the API from responding.
Promise.allSettled([
this.handleZaps(kysely, event),
this.updateAuthorData(event, opts?.signal),
this.prewarmLinkPreview(event, opts?.signal),
this.generateSetEvents(event),
])
.then(() =>
Promise.allSettled([
this.streamOut(event),
this.webPush(event),
])
);
}
}
async policyFilter(event: NostrEvent, signal?: AbortSignal): Promise<void> {
try {
const result = await this.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');
}
}
}
/** Hydrate the event with the user, if applicable. */
async hydrateEvent(event: DittoEvent, signal?: AbortSignal): Promise<void> {
await hydrateEvents({ ...this.opts, signal }, [event]);
}
/** Maybe store the event, if eligible. */
async storeEvent(event: NostrEvent, signal?: AbortSignal): Promise<undefined> {
if (NKinds.ephemeral(event.kind)) return;
const { conf, store } = this.opts;
try {
await store.transaction(async (store, kysely) => {
await updateStats({ conf, store, kysely }, event);
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 updateAuthorData(event: NostrEvent, signal?: AbortSignal): Promise<void> {
if (event.kind !== 0) return;
const { conf, kysely, store } = this.opts;
// Parse metadata.
const metadata = n.json().pipe(n.metadata()).catch({}).safeParse(event.content);
if (!metadata.success) return;
const { name, nip05 } = metadata.data;
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 resolveNip05({ conf, store, signal }, nip05.toLowerCase());
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 prewarmLinkPreview(event: NostrEvent, signal?: AbortSignal): Promise<void> {
const { conf } = this.opts;
const { firstUrl } = parseNoteContent(conf, stripimeta(event.content, event.tags), []);
if (firstUrl) {
await unfurlCardCached(conf, firstUrl, signal);
}
}
/** Determine if the event is being received in a timely manner. */
isFresh(event: NostrEvent): boolean {
return eventAge(event) < Time.minutes(1);
}
/** Distribute the event through active subscriptions. */
async streamOut(event: NostrEvent): Promise<void> {
if (!this.isFresh(event)) {
throw new RelayError('invalid', 'event too old');
}
const { pubsub } = this.opts;
await pubsub.event(event);
}
async webPush(event: NostrEvent): Promise<void> {
if (!this.isFresh(event)) {
throw new RelayError('invalid', 'event too old');
}
const { kysely } = this.opts;
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 this.push.push(subscription, message);
webPushNotificationsCounter.inc({ type: message.notification_type });
}
}
async generateSetEvents(event: NostrEvent): Promise<void> {
const { conf } = this.opts;
const tagsAdmin = event.tags.some(([name, value]) => ['p', 'P'].includes(name) && value === conf.pubkey);
if (event.kind === 1984 && tagsAdmin) {
const signer = new AdminSigner(conf);
const rel = await signer.signEvent({
kind: 30383,
content: '',
tags: [
['d', event.id],
['p', event.pubkey],
['k', '1984'],
['n', 'open'],
...[...getTagSet(event.tags, 'p')].map((pubkey) => ['P', pubkey]),
...[...getTagSet(event.tags, 'e')].map((pubkey) => ['e', pubkey]),
],
created_at: Math.floor(Date.now() / 1000),
});
await this.event(rel, { source: 'pipeline', signal: AbortSignal.timeout(1000) });
}
if (event.kind === 3036 && tagsAdmin) {
const signer = new AdminSigner(conf);
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 this.event(rel, { source: 'pipeline', signal: AbortSignal.timeout(1000) });
}
}
/** Stores the event in the 'event_zaps' table */
async 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
}
}
}

View file

@ -1,19 +1,27 @@
import { type DittoConf } from '@ditto/conf';
import { ApplicationServer, PushMessageOptions, PushSubscriber, PushSubscription } from '@negrel/webpush'; import { ApplicationServer, PushMessageOptions, PushSubscriber, PushSubscription } from '@negrel/webpush';
import { type 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 { getInstanceMetadata } from '@/utils/instance.ts'; import { getInstanceMetadata } from '@/utils/instance.ts';
export class DittoPush { interface DittoPushOpts {
static _server: Promise<ApplicationServer | undefined> | undefined; conf: DittoConf;
store: NStore;
}
static get server(): Promise<ApplicationServer | undefined> { export class DittoPush {
_server: Promise<ApplicationServer | undefined> | undefined;
constructor(private opts: DittoPushOpts) {}
get server(): Promise<ApplicationServer | undefined> {
if (!this._server) { if (!this._server) {
this._server = (async () => { this._server = (async () => {
const store = await Storages.db(); const { conf } = this.opts;
const meta = await getInstanceMetadata(store);
const keys = await Conf.vapidKeys; const meta = await getInstanceMetadata(this.opts);
const keys = await conf.vapidKeys;
if (keys) { if (keys) {
return await ApplicationServer.new({ return await ApplicationServer.new({
@ -33,7 +41,7 @@ export class DittoPush {
return this._server; return this._server;
} }
static async push( async push(
subscription: PushSubscription, subscription: PushSubscription,
json: object, json: object,
opts: PushMessageOptions = {}, opts: PushMessageOptions = {},

View file

@ -1,32 +1,34 @@
// deno-lint-ignore-file require-await // deno-lint-ignore-file require-await
import { DittoConf } from '@ditto/conf';
import { type DittoDatabase, DittoDB } from '@ditto/db'; import { type DittoDatabase, DittoDB } from '@ditto/db';
import { internalSubscriptionsSizeGauge } from '@ditto/metrics'; import { internalSubscriptionsSizeGauge } from '@ditto/metrics';
import { logi } from '@soapbox/logi'; import { logi } from '@soapbox/logi';
import { Conf } from '@/config.ts';
import { wsUrlSchema } from '@/schema.ts'; import { wsUrlSchema } from '@/schema.ts';
import { AdminStore } from '@/storages/AdminStore.ts'; import { AdminStore } from '@/storages/AdminStore.ts';
import { EventsDB } from '@/storages/EventsDB.ts'; import { EventsDB } from '@/storages/EventsDB.ts';
import { SearchStore } from '@/storages/search-store.ts';
import { InternalRelay } from '@/storages/InternalRelay.ts'; import { InternalRelay } from '@/storages/InternalRelay.ts';
import { NPool, NRelay1 } from '@nostrify/nostrify'; import { NPool, NRelay1 } from '@nostrify/nostrify';
import { getRelays } from '@/utils/outbox.ts'; import { getRelays } from '@/utils/outbox.ts';
import { seedZapSplits } from '@/utils/zap-split.ts'; import { seedZapSplits } from '@/utils/zap-split.ts';
export class Storages { export class DittoStorages {
private static _db: Promise<EventsDB> | undefined; private _db: Promise<EventsDB> | undefined;
private static _database: Promise<DittoDatabase> | undefined; private _database: Promise<DittoDatabase> | undefined;
private static _admin: Promise<AdminStore> | undefined; private _admin: Promise<AdminStore> | undefined;
private static _client: Promise<NPool<NRelay1>> | undefined; private _pool: Promise<NPool<NRelay1>> | undefined;
private static _pubsub: Promise<InternalRelay> | undefined; private _pubsub: Promise<InternalRelay> | undefined;
private static _search: Promise<SearchStore> | undefined;
constructor(private conf: DittoConf) {}
public async database(): Promise<DittoDatabase> {
const { conf } = this;
public static async database(): Promise<DittoDatabase> {
if (!this._database) { if (!this._database) {
this._database = (async () => { this._database = (async () => {
const db = DittoDB.create(Conf.databaseUrl, { const db = DittoDB.create(conf.databaseUrl, {
poolSize: Conf.pg.poolSize, poolSize: conf.pg.poolSize,
debug: Conf.pgliteDebug, debug: conf.pgliteDebug,
}); });
await DittoDB.migrate(db.kysely); await DittoDB.migrate(db.kysely);
return db; return db;
@ -35,17 +37,19 @@ export class Storages {
return this._database; return this._database;
} }
public static async kysely(): Promise<DittoDatabase['kysely']> { public async kysely(): Promise<DittoDatabase['kysely']> {
const { kysely } = await this.database(); const { kysely } = await this.database();
return kysely; return kysely;
} }
/** SQL database to store events this Ditto server cares about. */ /** SQL database to store events this Ditto server cares about. */
public static async db(): Promise<EventsDB> { public async db(): Promise<EventsDB> {
const { conf } = this;
if (!this._db) { if (!this._db) {
this._db = (async () => { this._db = (async () => {
const kysely = await this.kysely(); const kysely = await this.kysely();
const store = new EventsDB({ kysely, pubkey: Conf.pubkey, timeout: Conf.db.timeouts.default }); const store = new EventsDB({ kysely, pubkey: conf.pubkey, timeout: conf.db.timeouts.default });
await seedZapSplits(store); await seedZapSplits(store);
return store; return store;
})(); })();
@ -54,7 +58,7 @@ export class Storages {
} }
/** Admin user storage. */ /** Admin user storage. */
public static async admin(): Promise<AdminStore> { public async admin(): Promise<AdminStore> {
if (!this._admin) { if (!this._admin) {
this._admin = Promise.resolve(new AdminStore(await this.db())); this._admin = Promise.resolve(new AdminStore(await this.db()));
} }
@ -62,7 +66,7 @@ export class Storages {
} }
/** Internal pubsub relay between controllers and the pipeline. */ /** Internal pubsub relay between controllers and the pipeline. */
public static async pubsub(): Promise<InternalRelay> { public async pubsub(): Promise<InternalRelay> {
if (!this._pubsub) { if (!this._pubsub) {
this._pubsub = Promise.resolve(new InternalRelay({ gauge: internalSubscriptionsSizeGauge })); this._pubsub = Promise.resolve(new InternalRelay({ gauge: internalSubscriptionsSizeGauge }));
} }
@ -70,13 +74,15 @@ export class Storages {
} }
/** Relay pool storage. */ /** Relay pool storage. */
public static async client(): Promise<NPool<NRelay1>> { public async pool(): Promise<NPool<NRelay1>> {
if (!this._client) { const { conf } = this;
this._client = (async () => {
if (!this._pool) {
this._pool = (async () => {
const db = await this.db(); const db = await this.db();
const [relayList] = await db.query([ const [relayList] = await db.query([
{ kinds: [10002], authors: [Conf.pubkey], limit: 1 }, { kinds: [10002], authors: [conf.pubkey], limit: 1 },
]); ]);
const tags = relayList?.tags ?? []; const tags = relayList?.tags ?? [];
@ -113,8 +119,8 @@ export class Storages {
})); }));
}, },
eventRouter: async (event) => { eventRouter: async (event) => {
const relaySet = await getRelays(await Storages.db(), event.pubkey); const relaySet = await getRelays(await this.db(), event.pubkey);
relaySet.delete(Conf.relay); relaySet.delete(conf.relay);
const relays = [...relaySet].slice(0, 4); const relays = [...relaySet].slice(0, 4);
return relays; return relays;
@ -122,19 +128,6 @@ export class Storages {
}); });
})(); })();
} }
return this._client; return this._pool;
}
/** Storage to use for remote search. */
public static async search(): Promise<SearchStore> {
if (!this._search) {
this._search = Promise.resolve(
new SearchStore({
relay: Conf.searchRelay,
fallback: await this.db(),
}),
);
}
return this._search;
} }
} }

View file

@ -1,15 +1,18 @@
import { confMw } from '@ditto/api/middleware'; import { DittoConf } from '@ditto/conf';
import { type DittoConf } from '@ditto/conf'; import { DittoDatabase, DittoTables } from '@ditto/db';
import { DittoTables } from '@ditto/db';
import { type Context, Env as HonoEnv, Handler, Hono, Input as HonoInput, MiddlewareHandler } from '@hono/hono'; import { type Context, Env as HonoEnv, Handler, Hono, Input as HonoInput, MiddlewareHandler } from '@hono/hono';
import { every } from '@hono/hono/combine'; import { every } from '@hono/hono/combine';
import { cors } from '@hono/hono/cors'; import { cors } from '@hono/hono/cors';
import { serveStatic } from '@hono/hono/deno'; import { serveStatic } from '@hono/hono/deno';
import { NostrEvent, NostrSigner, NStore, NUploader } from '@nostrify/nostrify'; import { NostrSigner, NPool, NRelay, NRelay1, NStore, NUploader } from '@nostrify/nostrify';
import { Kysely } from 'kysely'; import { Kysely } from 'kysely';
import '@/startup.ts'; import { cron } from '@/cron.ts';
import { DittoPipeline } from '@/DittoPipeline.ts';
import { DittoStorages } from '@/DittoStorages.ts';
import { startFirehose } from '@/firehose.ts';
import { startNotify } from '@/notify.ts';
import { EventsDB } from '@/storages/EventsDB.ts';
import { Time } from '@/utils/time.ts'; import { Time } from '@/utils/time.ts';
import { import {
@ -135,7 +138,7 @@ import { manifestController } from '@/controllers/manifest.ts';
import { nodeInfoController, nodeInfoSchemaController } from '@/controllers/well-known/nodeinfo.ts'; import { nodeInfoController, nodeInfoSchemaController } from '@/controllers/well-known/nodeinfo.ts';
import { nostrController } from '@/controllers/well-known/nostr.ts'; import { nostrController } from '@/controllers/well-known/nostr.ts';
import { DittoTranslator } from '@/interfaces/DittoTranslator.ts'; import { DittoTranslator } from '@/interfaces/DittoTranslator.ts';
import { auth98Middleware, requireProof, requireRole } from '@/middleware/auth98Middleware.ts'; import { requireProof, requireRole } from '@/middleware/auth98Middleware.ts';
import { cacheControlMiddleware } from '@/middleware/cacheControlMiddleware.ts'; import { cacheControlMiddleware } from '@/middleware/cacheControlMiddleware.ts';
import { cspMiddleware } from '@/middleware/cspMiddleware.ts'; import { cspMiddleware } from '@/middleware/cspMiddleware.ts';
import { metricsMiddleware } from '@/middleware/metricsMiddleware.ts'; import { metricsMiddleware } from '@/middleware/metricsMiddleware.ts';
@ -144,30 +147,73 @@ import { paginationMiddleware } from '@/middleware/paginationMiddleware.ts';
import { rateLimitMiddleware } from '@/middleware/rateLimitMiddleware.ts'; import { rateLimitMiddleware } from '@/middleware/rateLimitMiddleware.ts';
import { requireSigner } from '@/middleware/requireSigner.ts'; import { requireSigner } from '@/middleware/requireSigner.ts';
import { signerMiddleware } from '@/middleware/signerMiddleware.ts'; import { signerMiddleware } from '@/middleware/signerMiddleware.ts';
import { storeMiddleware } from '@/middleware/storeMiddleware.ts';
import { uploaderMiddleware } from '@/middleware/uploaderMiddleware.ts'; import { uploaderMiddleware } from '@/middleware/uploaderMiddleware.ts';
import { translatorMiddleware } from '@/middleware/translatorMiddleware.ts'; import { translatorMiddleware } from '@/middleware/translatorMiddleware.ts';
import { logiMiddleware } from '@/middleware/logiMiddleware.ts'; import { logiMiddleware } from '@/middleware/logiMiddleware.ts';
const conf = new DittoConf(Deno.env);
const storages = new DittoStorages(conf);
const [kysely, store, pool, pubsub, db] = await Promise.all([
storages.kysely(),
storages.db(),
storages.pool(),
storages.pubsub(),
storages.database(),
]);
const pipeline = new DittoPipeline({ conf, kysely, store, pubsub });
if (conf.firehoseEnabled) {
startFirehose(
{ concurrency: conf.firehoseConcurrency, kinds: conf.firehoseKinds, store: pool },
(event) => pipeline.event(event, { signal: AbortSignal.timeout(5000) }),
);
}
if (conf.notifyEnabled) {
startNotify({ conf, db, store, pipeline });
}
if (conf.cronEnabled) {
cron({ conf, kysely, store });
}
export interface AppEnv extends HonoEnv { export interface AppEnv extends HonoEnv {
Variables: { Variables: {
conf: DittoConf; conf: DittoConf;
user?: {
/** Signer to get the logged-in user's pubkey, relays, and to sign events, or `undefined` if the user isn't logged in. */ /** Signer to get the logged-in user's pubkey, relays, and to sign events, or `undefined` if the user isn't logged in. */
signer?: NostrSigner; signer: NostrSigner;
/** Uploader for the user to upload files. */
uploader?: NUploader;
/** NIP-98 signed event proving the pubkey is owned by the user. */
proof?: NostrEvent;
/** Kysely instance for the database. */
kysely: Kysely<DittoTables>;
/** Storage for the user, might filter out unwanted content. */ /** Storage for the user, might filter out unwanted content. */
store: NStore; store: NStore;
};
service?: {
/** Service signer. */
signer: NostrSigner;
/** Store for service actions. */
store: NStore;
};
/** Uploader for the user to upload files. */
uploader?: NUploader;
/** Kysely instance for the database. */
kysely: Kysely<DittoTables>;
/** Main database. */
store: EventsDB;
/** Internal Nostr relay for realtime subscriptions. */
pubsub: NRelay;
/** Nostr relay pool. */
pool: NPool<NRelay1>;
/** Database object. */
db: DittoDatabase;
/** Normalized pagination params. */ /** Normalized pagination params. */
pagination: { since?: number; until?: number; limit: number }; pagination: { since?: number; until?: number; limit: number };
/** Normalized list pagination params. */ /** Normalized list pagination params. */
listPagination: { offset: number; limit: number }; listPagination: { offset: number; limit: number };
/** Translation service. */ /** Translation service. */
translator?: DittoTranslator; translator?: DittoTranslator;
signal: AbortSignal;
pipeline: DittoPipeline;
}; };
} }
@ -179,11 +225,24 @@ type AppController<P extends string = any> = Handler<AppEnv, P, HonoInput, Respo
const app = new Hono<AppEnv>({ strict: false }); const app = new Hono<AppEnv>({ 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: conf.publicDir });
/** Static files provided by the Ditto repo, checked into git. */ /** Static files provided by the Ditto repo, checked into git. */
const staticFiles = serveStatic({ root: new URL('./static/', import.meta.url).pathname }); const staticFiles = serveStatic({ root: new URL('./static', import.meta.url).pathname });
app.use(confMw(Deno.env), cacheControlMiddleware({ noStore: true })); // Set up the base context.
app.use((c, next) => {
c.set('db', db);
c.set('conf', conf);
c.set('kysely', kysely);
c.set('pool', pool);
c.set('store', store);
c.set('pubsub', pubsub);
c.set('signal', c.req.raw.signal);
c.set('pipeline', pipeline);
return next();
});
app.use(cacheControlMiddleware({ noStore: true }));
const ratelimit = every( const ratelimit = every(
rateLimitMiddleware(30, Time.seconds(5), false), rateLimitMiddleware(30, Time.seconds(5), false),
@ -203,8 +262,6 @@ app.use(
cors({ origin: '*', exposeHeaders: ['link'] }), cors({ origin: '*', exposeHeaders: ['link'] }),
signerMiddleware, signerMiddleware,
uploaderMiddleware, uploaderMiddleware,
auth98Middleware(),
storeMiddleware,
); );
app.get('/metrics', metricsController); app.get('/metrics', metricsController);
@ -251,7 +308,7 @@ app.post('/oauth/revoke', revokeTokenController);
app.post('/oauth/authorize', oauthAuthorizeController); app.post('/oauth/authorize', oauthAuthorizeController);
app.get('/oauth/authorize', oauthController); app.get('/oauth/authorize', oauthController);
app.post('/api/v1/accounts', requireProof({ pow: 20 }), createAccountController); app.post('/api/v1/accounts', requireProof(), createAccountController);
app.get('/api/v1/accounts/verify_credentials', requireSigner, verifyCredentialsController); app.get('/api/v1/accounts/verify_credentials', requireSigner, verifyCredentialsController);
app.patch('/api/v1/accounts/update_credentials', requireSigner, updateCredentialsController); app.patch('/api/v1/accounts/update_credentials', requireSigner, updateCredentialsController);
app.get('/api/v1/accounts/search', accountSearchController); app.get('/api/v1/accounts/search', accountSearchController);

View file

@ -1,3 +0,0 @@
import { LRUCache } from 'lru-cache';
export const pipelineEncounters = new LRUCache<string, true>({ max: 5000 });

View file

@ -1,7 +1,7 @@
import { LanguageCode } from 'iso-639-1'; import { LanguageCode } from 'iso-639-1';
import { LRUCache } from 'lru-cache'; import { LRUCache } from 'lru-cache';
import { Conf } from '@/config.ts'; import { DittoConf } from '@ditto/conf';
import { MastodonTranslation } from '@/entities/MastodonTranslation.ts'; import { MastodonTranslation } from '@/entities/MastodonTranslation.ts';
/** Translations LRU cache. */ /** Translations LRU cache. */

View file

@ -1,4 +0,0 @@
import { DittoConf } from '@ditto/conf';
/** @deprecated Use middleware to set/get the config instead. */
export const Conf = new DittoConf(Deno.env);

View file

@ -1,39 +1,39 @@
import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify'; 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, paginated, 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 { AccountView } from '@/views/mastodon/AccountView.ts';
import { renderRelationship } from '@/views/mastodon/relationships.ts'; import { renderRelationship } from '@/views/mastodon/relationships.ts';
import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts'; import { StatusView } from '@/views/mastodon/StatusView.ts';
import { metadataSchema } from '@/schemas/nostr.ts'; import { metadataSchema } from '@/schemas/nostr.ts';
import { hydrateEvents } from '@/storages/hydrate.ts'; import { hydrateEvents } from '@/storages/hydrate.ts';
import { bech32ToPubkey } from '@/utils.ts'; import { bech32ToPubkey } from '@/utils.ts';
import { addTag, deleteTag, findReplyTag, getTagSet } from '@/utils/tags.ts'; import { addTag, deleteTag, findReplyTag, getTagSet } from '@/utils/tags.ts';
import { getPubkeysBySearch } from '@/utils/search.ts'; import { getPubkeysBySearch } from '@/utils/search.ts';
import { MastodonAccount } from '@/entities/MastodonAccount.ts';
const createAccountSchema = z.object({ const createAccountSchema = z.object({
username: z.string().min(1).max(30).regex(/^[a-z0-9_]+$/i), username: z.string().min(1).max(30).regex(/^[a-z0-9_]+$/i),
}); });
const createAccountController: AppController = async (c) => { const createAccountController: AppController = async (c) => {
const pubkey = await c.get('signer')?.getPublicKey()!; const { conf, user } = c.var;
const pubkey = await user!.signer.getPublicKey();
const result = createAccountSchema.safeParse(await c.req.json()); const result = createAccountSchema.safeParse(await c.req.json());
if (!result.success) { if (!result.success) {
return c.json({ error: 'Bad request', schema: result.error }, 400); return c.json({ error: 'Bad request', schema: result.error }, 400);
} }
if (c.var.conf.forbiddenUsernames.includes(result.data.username)) { if (conf.forbiddenUsernames.includes(result.data.username)) {
return c.json({ error: 'Username is reserved.' }, 422); return c.json({ error: 'Username is reserved.' }, 422);
} }
@ -46,13 +46,11 @@ const createAccountController: AppController = async (c) => {
}; };
const verifyCredentialsController: AppController = async (c) => { const verifyCredentialsController: AppController = async (c) => {
const signer = c.get('signer')!; const { user, store } = c.var;
const pubkey = await signer.getPublicKey(); const pubkey = await user!.signer.getPublicKey();
const store = await Storages.db();
const [author, [settingsEvent]] = await Promise.all([ const [author, [settingsEvent]] = await Promise.all([
getAuthor(pubkey, { signal: AbortSignal.timeout(5000) }), getAuthor(c.var, pubkey),
store.query([{ store.query([{
kinds: [30078], kinds: [30078],
@ -69,23 +67,24 @@ const verifyCredentialsController: AppController = async (c) => {
// Do nothing // Do nothing
} }
const account = author const view = new AccountView(c.var);
? await renderAccount(author, { withSource: true, settingsStore }) const account = view.render(author, pubkey, { withSource: true, settingsStore });
: await accountFromPubkey(pubkey, { withSource: true, settingsStore });
return c.json(account); return c.json(account);
}; };
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(c.var, pubkey);
const event = await getAuthor(pubkey);
if (event) { if (event) {
assertAuthenticated(c, event); assertAuthenticated(c, event);
return c.json(await renderAccount(event));
} else {
return c.json(await accountFromPubkey(pubkey));
} }
const view = new AccountView(c.var);
const account = view.render(event, pubkey);
return c.json(account);
}; };
const accountLookupController: AppController = async (c) => { const accountLookupController: AppController = async (c) => {
@ -95,17 +94,23 @@ 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 view = new AccountView(c.var);
const event = await lookupAccount(c.var, decodeURIComponent(acct));
if (event) { if (event) {
assertAuthenticated(c, event); assertAuthenticated(c, event);
return c.json(await renderAccount(event)); const account = view.render(event);
return c.json(account);
} }
try {
const pubkey = bech32ToPubkey(decodeURIComponent(acct)); const pubkey = bech32ToPubkey(decodeURIComponent(acct));
return c.json(await accountFromPubkey(pubkey!));
} catch { if (pubkey) {
return c.json({ error: 'Could not find user.' }, 404); const account = view.render(undefined, pubkey);
return c.json(account);
} }
return c.json({ error: 'Could not find user.' }, 404);
}; };
const accountSearchQuerySchema = z.object({ const accountSearchQuerySchema = z.object({
@ -115,11 +120,8 @@ const accountSearchQuerySchema = z.object({
}); });
const accountSearchController: AppController = async (c) => { const accountSearchController: AppController = async (c) => {
const { signal } = c.req.raw; const { store, kysely, user, pagination, signal } = c.var;
const { limit } = c.get('pagination'); const { limit } = pagination;
const kysely = await Storages.kysely();
const viewerPubkey = await c.get('signer')?.getPublicKey();
const result = accountSearchQuerySchema.safeParse(c.req.query()); const result = accountSearchQuerySchema.safeParse(c.req.query());
@ -128,14 +130,14 @@ const accountSearchController: AppController = async (c) => {
} }
const query = decodeURIComponent(result.data.q); const query = decodeURIComponent(result.data.q);
const store = await Storages.search();
const lookup = extractIdentifier(query); const lookup = extractIdentifier(query);
const event = await lookupAccount(lookup ?? query); const event = await lookupAccount(c.var, lookup ?? query);
const view = new AccountView(c.var);
const viewerPubkey = await user?.signer.getPublicKey();
if (!event && lookup) { if (!event && lookup) {
const pubkey = await lookupPubkey(lookup); const pubkey = await lookupPubkey(c.var, lookup);
return c.json(pubkey ? [accountFromPubkey(pubkey)] : []); return c.json(pubkey ? [view.render(undefined, pubkey)] : []);
} }
const events: NostrEvent[] = []; const events: NostrEvent[] = [];
@ -143,7 +145,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(c.var, viewerPubkey) : new Set<string>();
const authors = [...await getPubkeysBySearch(kysely, { q: query, limit, offset: 0, following })]; const authors = [...await getPubkeysBySearch(kysely, { q: query, limit, offset: 0, following })];
const profiles = await store.query([{ kinds: [0], authors, limit }], { signal }); const profiles = await store.query([{ kinds: [0], authors, limit }], { signal });
@ -155,25 +157,25 @@ const accountSearchController: AppController = async (c) => {
} }
} }
const accounts = await hydrateEvents({ events, store, signal }) const accounts = await hydrateEvents(c.var, events)
.then((events) => events.map((event) => renderAccount(event))); .then((events) => events.map((event) => view.render(event)));
return c.json(accounts); return c.json(accounts);
}; };
const relationshipsController: AppController = async (c) => { const relationshipsController: AppController = async (c) => {
const pubkey = await c.get('signer')?.getPublicKey()!; const { store, user } = c.var;
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[]'));
if (!ids.success) { if (!ids.success) {
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] }]), store.query([{ kinds: [3, 10000], authors: [pubkey] }]),
db.query([{ kinds: [3], authors: ids.data }]), store.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);
@ -194,7 +196,6 @@ const relationshipsController: AppController = async (c) => {
const accountStatusesQuerySchema = z.object({ const accountStatusesQuerySchema = z.object({
pinned: booleanParamSchema.optional(), pinned: booleanParamSchema.optional(),
limit: z.coerce.number().nonnegative().transform((v) => Math.min(v, 40)).catch(20),
exclude_replies: booleanParamSchema.optional(), exclude_replies: booleanParamSchema.optional(),
tagged: z.string().optional(), tagged: z.string().optional(),
only_media: booleanParamSchema.optional(), only_media: booleanParamSchema.optional(),
@ -202,12 +203,9 @@ const accountStatusesQuerySchema = z.object({
const accountStatusesController: AppController = async (c) => { const accountStatusesController: AppController = async (c) => {
const pubkey = c.req.param('pubkey'); const pubkey = c.req.param('pubkey');
const { conf } = c.var;
const { since, until } = c.var.pagination;
const { pinned, limit, exclude_replies, tagged, only_media } = accountStatusesQuerySchema.parse(c.req.query());
const { signal } = c.req.raw;
const store = await Storages.db(); const { conf, store, pagination, signal } = c.var;
const { pinned, exclude_replies, tagged, only_media } = accountStatusesQuerySchema.parse(c.req.query());
const [[author], [user]] = await Promise.all([ const [[author], [user]] = await Promise.all([
store.query([{ kinds: [0], authors: [pubkey], limit: 1 }], { signal }), store.query([{ kinds: [0], authors: [pubkey], limit: 1 }], { signal }),
@ -237,9 +235,7 @@ const accountStatusesController: AppController = async (c) => {
const filter: NostrFilter = { const filter: NostrFilter = {
authors: [pubkey], authors: [pubkey],
kinds: [1, 6, 20], kinds: [1, 6, 20],
since, ...pagination,
until,
limit,
}; };
const search: string[] = []; const search: string[] = [];
@ -260,10 +256,10 @@ const accountStatusesController: AppController = async (c) => {
filter.search = search.join(' '); filter.search = search.join(' ');
} }
const opts = { signal, limit, timeout: conf.db.timeouts.timelines }; const opts = { signal, timeout: conf.db.timeouts.timelines };
const events = await store.query([filter], opts) const events = await store.query([filter], opts)
.then((events) => hydrateEvents({ events, store, 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) => {
@ -274,14 +270,12 @@ const accountStatusesController: AppController = async (c) => {
return events; return events;
}); });
const viewerPubkey = await c.get('signer')?.getPublicKey(); const view = new StatusView(c.var);
const statuses = await Promise.all( const statuses = await Promise.all(
events.map((event) => { events.map((event) => view.render(event)),
if (event.kind === 6) return renderReblog(event, { viewerPubkey });
return renderStatus(event, { viewerPubkey });
}),
); );
return paginated(c, events, statuses); return paginated(c, events, statuses);
}; };
@ -301,12 +295,11 @@ const updateCredentialsSchema = z.object({
}); });
const updateCredentialsController: AppController = async (c) => { const updateCredentialsController: AppController = async (c) => {
const signer = c.get('signer')!; const { store, user } = c.var;
const pubkey = await signer.getPublicKey();
const pubkey = await user!.signer.getPublicKey();
const body = await parseBody(c.req.raw); const body = await parseBody(c.req.raw);
const result = updateCredentialsSchema.safeParse(body); const result = updateCredentialsSchema.safeParse(body);
const store = await Storages.db();
const signal = c.req.raw.signal;
if (!result.success) { if (!result.success) {
return c.json(result.error, 422); return c.json(result.error, 422);
@ -319,6 +312,7 @@ const updateCredentialsController: AppController = async (c) => {
event = (await store.query([{ kinds: [0], authors: [pubkey] }]))[0]; event = (await store.query([{ kinds: [0], authors: [pubkey] }]))[0];
} else { } else {
event = await updateEvent( event = await updateEvent(
c.var,
{ kinds: [0], authors: [pubkey], limit: 1 }, { kinds: [0], authors: [pubkey], limit: 1 },
async (prev) => { async (prev) => {
const meta = n.json().pipe(metadataSchema).catch({}).parse(prev.content); const meta = n.json().pipe(metadataSchema).catch({}).parse(prev.content);
@ -364,26 +358,22 @@ const updateCredentialsController: AppController = async (c) => {
tags: [], tags: [],
}; };
}, },
c,
); );
} }
const settingsStore = result.data.pleroma_settings_store; const settingsStore = result.data.pleroma_settings_store;
let account: MastodonAccount; await hydrateEvents(c.var, [event]);
if (event) {
await hydrateEvents({ events: [event], store, signal }); const view = new AccountView(c.var);
account = await renderAccount(event, { withSource: true, settingsStore }); const account = view.render(event, pubkey, { withSource: true, settingsStore });
} else {
account = await accountFromPubkey(pubkey, { withSource: true, settingsStore });
}
if (settingsStore) { if (settingsStore) {
await createEvent({ await createEvent(c.var, {
kind: 30078, kind: 30078,
tags: [['d', 'pub.ditto.pleroma_settings_store']], tags: [['d', 'pub.ditto.pleroma_settings_store']],
content: JSON.stringify(settingsStore), content: JSON.stringify(settingsStore),
}, c); });
} }
return c.json(account); return c.json(account);
@ -391,16 +381,19 @@ 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 sourcePubkey = await c.get('signer')?.getPublicKey()!; const { store, user } = c.var;
const sourcePubkey = await user!.signer.getPublicKey();
const targetPubkey = c.req.param('pubkey'); const targetPubkey = c.req.param('pubkey');
await updateListEvent( await updateListEvent(
c.var,
{ kinds: [3], authors: [sourcePubkey], limit: 1 }, { kinds: [3], authors: [sourcePubkey], limit: 1 },
(tags) => addTag(tags, ['p', targetPubkey]), (tags) => addTag(tags, ['p', targetPubkey]),
c,
); );
const relationship = await getRelationship(sourcePubkey, targetPubkey); const relationship = await getRelationship(store, sourcePubkey, targetPubkey);
relationship.following = true; relationship.following = true;
return c.json(relationship); return c.json(relationship);
@ -408,16 +401,18 @@ 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 sourcePubkey = await c.get('signer')?.getPublicKey()!; const { store, user } = c.var;
const sourcePubkey = await user!.signer.getPublicKey();
const targetPubkey = c.req.param('pubkey'); const targetPubkey = c.req.param('pubkey');
await updateListEvent( await updateListEvent(
c.var,
{ kinds: [3], authors: [sourcePubkey], limit: 1 }, { kinds: [3], authors: [sourcePubkey], limit: 1 },
(tags) => deleteTag(tags, ['p', targetPubkey]), (tags) => deleteTag(tags, ['p', targetPubkey]),
c,
); );
const relationship = await getRelationship(sourcePubkey, targetPubkey); const relationship = await getRelationship(store, sourcePubkey, targetPubkey);
return c.json(relationship); return c.json(relationship);
}; };
@ -429,7 +424,7 @@ const followersController: AppController = (c) => {
const followingController: AppController = async (c) => { const followingController: AppController = async (c) => {
const pubkey = c.req.param('pubkey'); const pubkey = c.req.param('pubkey');
const pubkeys = await getFollowedPubkeys(pubkey); const pubkeys = await getFollowedPubkeys(c.var, pubkey);
return renderAccounts(c, [...pubkeys]); return renderAccounts(c, [...pubkeys]);
}; };
@ -445,43 +440,45 @@ 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 sourcePubkey = await c.get('signer')?.getPublicKey()!; const { store, user } = c.var;
const sourcePubkey = await user!.signer.getPublicKey();
const targetPubkey = c.req.param('pubkey'); const targetPubkey = c.req.param('pubkey');
await updateListEvent( await updateListEvent(
c.var,
{ kinds: [10000], authors: [sourcePubkey], limit: 1 }, { kinds: [10000], authors: [sourcePubkey], limit: 1 },
(tags) => addTag(tags, ['p', targetPubkey]), (tags) => addTag(tags, ['p', targetPubkey]),
c,
); );
const relationship = await getRelationship(sourcePubkey, targetPubkey); const relationship = await getRelationship(store, 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 sourcePubkey = await c.get('signer')?.getPublicKey()!; const { store, user } = c.var;
const sourcePubkey = await user!.signer.getPublicKey();
const targetPubkey = c.req.param('pubkey'); const targetPubkey = c.req.param('pubkey');
await updateListEvent( await updateListEvent(
c.var,
{ kinds: [10000], authors: [sourcePubkey], limit: 1 }, { kinds: [10000], authors: [sourcePubkey], limit: 1 },
(tags) => deleteTag(tags, ['p', targetPubkey]), (tags) => deleteTag(tags, ['p', targetPubkey]),
c,
); );
const relationship = await getRelationship(sourcePubkey, targetPubkey); const relationship = await getRelationship(store, sourcePubkey, targetPubkey);
return c.json(relationship); return c.json(relationship);
}; };
const favouritesController: AppController = async (c) => { const favouritesController: AppController = async (c) => {
const pubkey = await c.get('signer')?.getPublicKey()!; const { store, user, pagination, signal } = c.var;
const params = c.get('pagination');
const { signal } = c.req.raw;
const store = await Storages.db(); const pubkey = await user!.signer.getPublicKey();
const events7 = await store.query( const events7 = await store.query(
[{ kinds: [7], authors: [pubkey], ...params }], [{ kinds: [7], authors: [pubkey], ...pagination }],
{ signal }, { signal },
); );
@ -489,32 +486,32 @@ const favouritesController: AppController = async (c) => {
.map((event) => event.tags.find((tag) => tag[0] === 'e')?.[1]) .map((event) => event.tags.find((tag) => tag[0] === 'e')?.[1])
.filter((id): id is string => !!id); .filter((id): id is string => !!id);
const events1 = await store.query([{ kinds: [1, 20], ids }], { signal }) const events = await store.query([{ kinds: [1, 20], ids }], { signal })
.then((events) => hydrateEvents({ events, store, signal })); .then((events) => hydrateEvents(c.var, events));
const viewerPubkey = await c.get('signer')?.getPublicKey(); const view = new StatusView(c.var);
const statuses = await Promise.all( const statuses = await Promise.all(
events1.map((event) => renderStatus(event, { viewerPubkey })), events.map((event) => view.render(event)),
); );
return paginated(c, events1, statuses);
return paginated(c, events, statuses);
}; };
const familiarFollowersController: AppController = async (c) => { const familiarFollowersController: AppController = async (c) => {
const store = await Storages.db(); const { store, user } = c.var;
const signer = c.get('signer')!;
const pubkey = await 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 pubkey = await user!.signer.getPublicKey();
const follows = await getFollowedPubkeys(c.var, pubkey);
const view = new AccountView(c.var);
const results = await Promise.all(ids.map(async (id) => { const results = await Promise.all(ids.map(async (id) => {
const followLists = await store.query([{ kinds: [3], authors: [...follows], '#p': [id] }]) const followLists = await store
.then((events) => hydrateEvents({ events, store })); .query([{ kinds: [3], authors: [...follows], '#p': [id] }])
.then((events) => hydrateEvents(c.var, events));
const accounts = await Promise.all( const accounts = followLists.map((event) => view.render(event.author, event.pubkey));
followLists.map((event) => event.author ? renderAccount(event.author) : accountFromPubkey(event.pubkey)),
);
return { id, accounts }; return { id, accounts };
})); }));
@ -522,12 +519,10 @@ const familiarFollowersController: AppController = async (c) => {
return c.json(results); return c.json(results);
}; };
async function getRelationship(sourcePubkey: string, targetPubkey: string) { async function getRelationship(store: 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] }]), store.query([{ kinds: [3, 10000], authors: [sourcePubkey] }]),
db.query([{ kinds: [3], authors: [targetPubkey] }]), store.query([{ kinds: [3], authors: [targetPubkey] }]),
]); ]);
return renderRelationship({ return renderRelationship({

View file

@ -4,11 +4,10 @@ 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 { Storages } from '@/storages.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, paginated, 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 { AdminAccountView } from '@/views/mastodon/AdminAccountView.ts';
import { errorJson } from '@/utils/log.ts'; import { errorJson } from '@/utils/log.ts';
const adminAccountQuerySchema = z.object({ const adminAccountQuerySchema = z.object({
@ -29,10 +28,8 @@ const adminAccountQuerySchema = z.object({
}); });
const adminAccountsController: AppController = async (c) => { const adminAccountsController: AppController = async (c) => {
const { conf } = c.var; const { conf, store, pagination, signal } = c.var;
const store = await Storages.db();
const params = c.get('pagination');
const { signal } = c.req.raw;
const { const {
local, local,
pending, pending,
@ -43,13 +40,15 @@ const adminAccountsController: AppController = async (c) => {
staff, staff,
} = adminAccountQuerySchema.parse(c.req.query()); } = adminAccountQuerySchema.parse(c.req.query());
const view = new AdminAccountView(c.var);
if (pending) { if (pending) {
if (disabled || silenced || suspended || sensitized) { if (disabled || silenced || suspended || sensitized) {
return c.json([]); return c.json([]);
} }
const orig = await store.query( const orig = await store.query(
[{ kinds: [30383], authors: [conf.pubkey], '#k': ['3036'], '#n': ['pending'], ...params }], [{ kinds: [30383], authors: [conf.pubkey], '#k': ['3036'], '#n': ['pending'], ...pagination }],
{ signal }, { signal },
); );
@ -59,8 +58,9 @@ const adminAccountsController: AppController = async (c) => {
.filter((id): id is string => !!id), .filter((id): id is string => !!id),
); );
const events = await store.query([{ kinds: [3036], ids: [...ids] }]) const events = await store
.then((events) => hydrateEvents({ store, events, signal })); .query([{ kinds: [3036], ids: [...ids] }])
.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);
@ -86,7 +86,10 @@ const adminAccountsController: AppController = async (c) => {
n.push('moderator'); n.push('moderator');
} }
const events = await store.query([{ kinds: [30382], authors: [conf.pubkey], '#n': n, ...params }], { signal }); const events = await store.query(
[{ kinds: [30382], authors: [conf.pubkey], '#n': n, ...pagination }],
{ signal },
);
const pubkeys = new Set<string>( const pubkeys = new Set<string>(
events events
@ -94,29 +97,30 @@ const adminAccountsController: AppController = async (c) => {
.filter((pubkey): pubkey is string => !!pubkey), .filter((pubkey): pubkey is string => !!pubkey),
); );
const authors = await store.query([{ kinds: [0], authors: [...pubkeys] }]) const authors = await store
.then((events) => hydrateEvents({ store, events, signal })); .query([{ kinds: [0], authors: [...pubkeys] }])
.then((events) => hydrateEvents(c.var, events));
const accounts = await Promise.all( const accounts = [...pubkeys].map((pubkey) => {
[...pubkeys].map((pubkey) => {
const author = authors.find((e) => e.pubkey === pubkey); const author = authors.find((e) => e.pubkey === pubkey);
return author ? renderAdminAccount(author) : renderAdminAccountFromPubkey(pubkey); return view.render(author, pubkey);
}), });
);
return paginated(c, events, accounts); return paginated(c, events, accounts);
} }
const filter: NostrFilter = { kinds: [0], ...params }; const filter: NostrFilter = { kinds: [0], ...pagination };
if (local) { if (local) {
filter.search = `domain:${conf.url.host}`; filter.search = `domain:${conf.url.host}`;
} }
const events = await store.query([filter], { signal }) const events = await store
.then((events) => hydrateEvents({ store, events, signal })); .query([filter], { signal })
.then((events) => hydrateEvents(c.var, events));
const accounts = events.map((event) => view.render(event, event.pubkey));
const accounts = await Promise.all(events.map(renderAdminAccount));
return paginated(c, events, accounts); return paginated(c, events, accounts);
}; };
@ -125,12 +129,13 @@ const adminAccountActionSchema = z.object({
}); });
const adminActionController: AppController = async (c) => { const adminActionController: AppController = async (c) => {
const { conf } = c.var;
const body = await parseBody(c.req.raw);
const store = await Storages.db();
const result = adminAccountActionSchema.safeParse(body);
const authorId = c.req.param('id'); const authorId = c.req.param('id');
const { conf, store } = c.var;
const body = await parseBody(c.req.raw);
const result = adminAccountActionSchema.safeParse(body);
if (!result.success) { if (!result.success) {
return c.json({ error: 'This action is not allowed' }, 403); return c.json({ error: 'This action is not allowed' }, 403);
} }
@ -151,46 +156,52 @@ const adminActionController: AppController = async (c) => {
if (data.type === 'suspend') { if (data.type === 'suspend') {
n.disabled = true; n.disabled = true;
n.suspended = true; n.suspended = true;
store.remove([{ authors: [authorId] }]).catch((e: unknown) => {
store.remove!([{ authors: [authorId] }]).catch((e: unknown) => {
logi({ level: 'error', ns: 'ditto.api.admin.account.action', type: data.type, error: errorJson(e) }); logi({ level: 'error', ns: 'ditto.api.admin.account.action', type: data.type, error: errorJson(e) });
}); });
} }
if (data.type === 'revoke_name') { if (data.type === 'revoke_name') {
n.revoke_name = true; n.revoke_name = true;
store.remove([{ kinds: [30360], authors: [conf.pubkey], '#p': [authorId] }]).catch((e: unknown) => {
store.remove!([{ kinds: [30360], authors: [conf.pubkey], '#p': [authorId] }]).catch((e: unknown) => {
logi({ level: 'error', ns: 'ditto.api.admin.account.action', type: data.type, error: errorJson(e) }); logi({ level: 'error', ns: 'ditto.api.admin.account.action', type: data.type, error: errorJson(e) });
}); });
} }
await updateUser(authorId, n, c); await updateUser(c.var, authorId, n);
return c.json({}, 200); return c.json({}, 200);
}; };
const adminApproveController: AppController = async (c) => { const adminApproveController: AppController = async (c) => {
const { conf } = c.var;
const eventId = c.req.param('id'); const eventId = c.req.param('id');
const store = await Storages.db();
const { conf, store } = c.var;
const [event] = await store.query([{ kinds: [3036], ids: [eventId] }]); const [event] = await store.query([{ kinds: [3036], ids: [eventId] }]);
if (!event) { if (!event) {
return c.json({ error: 'Event not found' }, 404); return c.json({ error: 'Event not found' }, 404);
} }
const r = event.tags.find(([name]) => name === 'r')?.[1]; const r = event.tags.find(([name]) => name === 'r')?.[1];
if (!r) { if (!r) {
return c.json({ error: 'NIP-05 not found' }, 404); return c.json({ error: 'NIP-05 not found' }, 404);
} }
if (!z.string().email().safeParse(r).success) { if (!z.string().email().safeParse(r).success) {
return c.json({ error: 'Invalid NIP-05' }, 400); return c.json({ error: 'Invalid NIP-05' }, 400);
} }
const [existing] = await store.query([{ kinds: [30360], authors: [conf.pubkey], '#d': [r], limit: 1 }]); const [existing] = await store.query([{ kinds: [30360], authors: [conf.pubkey], '#d': [r], limit: 1 }]);
if (existing) { if (existing) {
return c.json({ error: 'NIP-05 already granted to another user' }, 400); return c.json({ error: 'NIP-05 already granted to another user' }, 400);
} }
await createAdminEvent({ await createAdminEvent(c.var, {
kind: 30360, kind: 30360,
tags: [ tags: [
['d', r], ['d', r],
@ -199,10 +210,10 @@ const adminApproveController: AppController = async (c) => {
['p', event.pubkey], ['p', event.pubkey],
['e', event.id], ['e', event.id],
], ],
}, c); });
await updateEventInfo(eventId, { pending: false, approved: true, rejected: false }, c); await updateEventInfo(c.var, eventId, { pending: false, approved: true, rejected: false });
await hydrateEvents({ events: [event], store }); await hydrateEvents(c.var, [event]);
const nameRequest = await renderNameRequest(event); const nameRequest = await renderNameRequest(event);
return c.json(nameRequest); return c.json(nameRequest);
@ -210,17 +221,20 @@ const adminApproveController: AppController = async (c) => {
const adminRejectController: AppController = async (c) => { const adminRejectController: AppController = async (c) => {
const eventId = c.req.param('id'); const eventId = c.req.param('id');
const store = await Storages.db();
const { store } = c.var;
const [event] = await store.query([{ kinds: [3036], ids: [eventId] }]); const [event] = await store.query([{ kinds: [3036], ids: [eventId] }]);
if (!event) { if (!event) {
return c.json({ error: 'Event not found' }, 404); return c.json({ error: 'Event not found' }, 404);
} }
await updateEventInfo(eventId, { pending: false, approved: false, rejected: true }, c); await updateEventInfo(c.var, eventId, { pending: false, approved: false, rejected: true });
await hydrateEvents({ events: [event], store }); await hydrateEvents(c.var, [event]);
const nameRequest = await renderNameRequest(event); const nameRequest = await renderNameRequest(event);
return c.json(nameRequest); return c.json(nameRequest);
}; };
export { adminAccountsController, adminActionController, adminApproveController, adminRejectController }; export { adminAccountsController, adminActionController, adminApproveController, adminRejectController };

View file

@ -1,13 +1,12 @@
import { type AppController } from '@/app.ts'; import { type AppController } from '@/app.ts';
import { Storages } from '@/storages.ts';
import { getTagSet } from '@/utils/tags.ts'; import { getTagSet } from '@/utils/tags.ts';
import { renderStatuses } from '@/views.ts'; import { renderStatuses } from '@/views.ts';
/** https://docs.joinmastodon.org/methods/bookmarks/#get */ /** https://docs.joinmastodon.org/methods/bookmarks/#get */
const bookmarksController: AppController = async (c) => { const bookmarksController: AppController = async (c) => {
const store = await Storages.db(); const { store, user, signal } = c.var;
const pubkey = await c.get('signer')?.getPublicKey()!;
const { signal } = c.req.raw; const pubkey = await user!.signer.getPublicKey();
const [event10003] = await store.query( const [event10003] = await store.query(
[{ kinds: [10003], authors: [pubkey], limit: 1 }], [{ kinds: [10003], authors: [pubkey], limit: 1 }],

View file

@ -7,7 +7,6 @@ import { z } from 'zod';
import { createEvent, parseBody } from '@/utils/api.ts'; import { createEvent, parseBody } from '@/utils/api.ts';
import { requireNip44Signer } from '@/middleware/requireSigner.ts'; import { requireNip44Signer } from '@/middleware/requireSigner.ts';
import { requireStore } from '@/middleware/storeMiddleware.ts';
import { walletSchema } from '@/schema.ts'; import { walletSchema } from '@/schema.ts';
import { swapNutzapsMiddleware } from '@/middleware/swapNutzapsMiddleware.ts'; import { swapNutzapsMiddleware } from '@/middleware/swapNutzapsMiddleware.ts';
import { isNostrId } from '@/utils.ts'; import { isNostrId } from '@/utils.ts';

View file

@ -2,7 +2,6 @@ import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify';
import { z } from 'zod'; import { z } from 'zod';
import { AppController } from '@/app.ts'; import { AppController } from '@/app.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, paginated, parseBody, updateAdminEvent } from '@/utils/api.ts';
@ -14,9 +13,7 @@ import { screenshotsSchema } from '@/schemas/nostr.ts';
import { booleanParamSchema, percentageSchema, wsUrlSchema } from '@/schema.ts'; import { booleanParamSchema, percentageSchema, wsUrlSchema } from '@/schema.ts';
import { hydrateEvents } from '@/storages/hydrate.ts'; 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 { AccountView } from '@/views/mastodon/AccountView.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']);
@ -29,8 +26,7 @@ const relaySchema = z.object({
type RelayEntity = z.infer<typeof relaySchema>; type RelayEntity = z.infer<typeof relaySchema>;
export const adminRelaysController: AppController = async (c) => { export const adminRelaysController: AppController = async (c) => {
const { conf } = c.var; const { conf, store } = c.var;
const store = await Storages.db();
const [event] = await store.query([ const [event] = await store.query([
{ kinds: [10002], authors: [conf.pubkey], limit: 1 }, { kinds: [10002], authors: [conf.pubkey], limit: 1 },
@ -44,10 +40,10 @@ export const adminRelaysController: AppController = async (c) => {
}; };
export const adminSetRelaysController: AppController = async (c) => { export const adminSetRelaysController: AppController = async (c) => {
const store = await Storages.db(); const { conf, store } = c.var;
const relays = relaySchema.array().parse(await c.req.json()); const relays = relaySchema.array().parse(await c.req.json());
const event = await new AdminSigner().signEvent({ const event = await new AdminSigner(conf).signEvent({
kind: 10002, kind: 10002,
tags: relays.map(({ url, marker }) => marker ? ['r', url, marker] : ['r', url]), tags: relays.map(({ url, marker }) => marker ? ['r', url, marker] : ['r', url]),
content: '', content: '',
@ -79,19 +75,18 @@ const nameRequestSchema = z.object({
}); });
export const nameRequestController: AppController = async (c) => { export const nameRequestController: AppController = async (c) => {
const store = await Storages.db(); const { conf, store, user } = c.var;
const signer = c.get('signer')!;
const pubkey = await signer.getPublicKey();
const { conf } = c.var;
const { name, reason } = nameRequestSchema.parse(await c.req.json()); const { name, reason } = nameRequestSchema.parse(await c.req.json());
const pubkey = await user!.signer.getPublicKey();
const [existing] = await store.query([{ kinds: [3036], authors: [pubkey], '#r': [name], limit: 1 }]); const [existing] = await store.query([{ kinds: [3036], authors: [pubkey], '#r': [name], limit: 1 }]);
if (existing) { if (existing) {
return c.json({ error: 'Name request already exists' }, 400); return c.json({ error: 'Name request already exists' }, 400);
} }
const event = await createEvent({ const event = await createEvent(c.var, {
kind: 3036, kind: 3036,
content: reason, content: reason,
tags: [ tags: [
@ -100,9 +95,9 @@ export const nameRequestController: AppController = async (c) => {
['l', name.split('@')[1], 'nip05.domain'], ['l', name.split('@')[1], 'nip05.domain'],
['p', conf.pubkey], ['p', conf.pubkey],
], ],
}, c); });
await hydrateEvents({ events: [event], store: await Storages.db() }); await hydrateEvents(c.var, [event]);
const nameRequest = await renderNameRequest(event); const nameRequest = await renderNameRequest(event);
return c.json(nameRequest); return c.json(nameRequest);
@ -114,10 +109,9 @@ const nameRequestsSchema = z.object({
}); });
export const nameRequestsController: AppController = async (c) => { export const nameRequestsController: AppController = async (c) => {
const { conf } = c.var; const { conf, store, user } = c.var;
const store = await Storages.db();
const signer = c.get('signer')!; const pubkey = await user!.signer.getPublicKey();
const pubkey = await signer.getPublicKey();
const params = c.get('pagination'); const params = c.get('pagination');
const { approved, rejected } = nameRequestsSchema.parse(c.req.query()); const { approved, rejected } = nameRequestsSchema.parse(c.req.query());
@ -151,8 +145,9 @@ export const nameRequestsController: AppController = async (c) => {
return c.json([]); return c.json([]);
} }
const events = await store.query([{ kinds: [3036], ids: [...ids], authors: [pubkey] }]) const events = await store
.then((events) => hydrateEvents({ store, events: events, signal: c.req.raw.signal })); .query([{ kinds: [3036], ids: [...ids], authors: [pubkey] }])
.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)),
@ -170,10 +165,10 @@ const zapSplitSchema = z.record(
); );
export const updateZapSplitsController: AppController = async (c) => { export const updateZapSplitsController: AppController = async (c) => {
const { conf } = c.var; const { conf, store } = c.var;
const body = await parseBody(c.req.raw); const body = await parseBody(c.req.raw);
const result = zapSplitSchema.safeParse(body); const result = zapSplitSchema.safeParse(body);
const store = c.get('store');
if (!result.success) { if (!result.success) {
return c.json({ error: result.error }, 400); return c.json({ error: result.error }, 400);
@ -192,15 +187,15 @@ export const updateZapSplitsController: AppController = async (c) => {
} }
await updateListAdminEvent( await updateListAdminEvent(
c.var,
{ kinds: [30078], authors: [conf.pubkey], '#d': ['pub.ditto.zapSplits'], limit: 1 }, { kinds: [30078], authors: [conf.pubkey], '#d': ['pub.ditto.zapSplits'], limit: 1 },
(tags) => (tags) =>
pubkeys.reduce((accumulator, pubkey) => { pubkeys.reduce((accumulator, pubkey) => {
return addTag(accumulator, ['p', pubkey, data[pubkey].weight.toString(), data[pubkey].message]); return addTag(accumulator, ['p', pubkey, data[pubkey].weight.toString(), data[pubkey].message]);
}, tags), }, tags),
c,
); );
return c.json(200); return c.newResponse(null, 200);
}; };
const deleteZapSplitSchema = z.array(n.id()).min(1); const deleteZapSplitSchema = z.array(n.id()).min(1);
@ -216,6 +211,7 @@ export const deleteZapSplitsController: AppController = async (c) => {
} }
const dittoZapSplit = await getZapSplits(store, conf.pubkey); const dittoZapSplit = await getZapSplits(store, conf.pubkey);
if (!dittoZapSplit) { if (!dittoZapSplit) {
return c.json({ error: 'Zap split not activated, restart the server.' }, 404); return c.json({ error: 'Zap split not activated, restart the server.' }, 404);
} }
@ -223,32 +219,32 @@ export const deleteZapSplitsController: AppController = async (c) => {
const { data } = result; const { data } = result;
await updateListAdminEvent( await updateListAdminEvent(
c.var,
{ kinds: [30078], authors: [conf.pubkey], '#d': ['pub.ditto.zapSplits'], limit: 1 }, { kinds: [30078], authors: [conf.pubkey], '#d': ['pub.ditto.zapSplits'], limit: 1 },
(tags) => (tags) =>
data.reduce((accumulator, currentValue) => { data.reduce((accumulator, currentValue) => {
return deleteTag(accumulator, ['p', currentValue]); return deleteTag(accumulator, ['p', currentValue]);
}, tags), }, tags),
c,
); );
return c.json(200); return c.newResponse(null, 204);
}; };
export const getZapSplitsController: AppController = async (c) => { export const getZapSplitsController: AppController = async (c) => {
const { conf } = c.var; const { conf, store } = c.var;
const store = c.get('store');
const dittoZapSplit: DittoZapSplits | undefined = await getZapSplits(store, conf.pubkey) ?? {}; const dittoZapSplit: DittoZapSplits | undefined = await getZapSplits(store, conf.pubkey) ?? {};
if (!dittoZapSplit) { if (!dittoZapSplit) {
return c.json({ error: 'Zap split not activated, restart the server.' }, 404); return c.json({ error: 'Zap split not activated, restart the server.' }, 404);
} }
const pubkeys = Object.keys(dittoZapSplit); const pubkeys = Object.keys(dittoZapSplit);
const view = new AccountView(c.var);
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(c.var, pubkey);
const account = view.render(author, pubkey);
const account = author ? renderAccount(author) : accountFromPubkey(pubkey);
return { return {
account, account,
@ -261,11 +257,12 @@ export const getZapSplitsController: AppController = async (c) => {
}; };
export const statusZapSplitsController: AppController = async (c) => { export const statusZapSplitsController: AppController = async (c) => {
const store = c.get('store'); const { store, signal } = c.var;
const id = c.req.param('id'); const id = c.req.param('id');
const { signal } = c.req.raw;
const [event] = await store.query([{ kinds: [1, 20], ids: [id], limit: 1 }], { signal }); const [event] = await store.query([{ kinds: [1, 20], ids: [id], limit: 1 }], { signal });
if (!event) { if (!event) {
return c.json({ error: 'Event not found' }, 404); return c.json({ error: 'Event not found' }, 404);
} }
@ -275,14 +272,15 @@ export const statusZapSplitsController: AppController = async (c) => {
const pubkeys = zapsTag.map((name) => name[1]); const pubkeys = zapsTag.map((name) => name[1]);
const users = await store.query([{ authors: pubkeys, kinds: [0], limit: pubkeys.length }], { signal }); const users = await store.query([{ authors: pubkeys, kinds: [0], limit: pubkeys.length }], { signal });
await hydrateEvents({ events: users, store, signal }); await hydrateEvents(c.var, users);
const zapSplits = (await Promise.all(pubkeys.map((pubkey) => { const view = new AccountView(c.var);
const author = (users.find((event) => event.pubkey === pubkey) as DittoEvent | undefined)?.author;
const account = author ? renderAccount(author) : accountFromPubkey(pubkey); const zapSplits = pubkeys.map((pubkey) => {
const author = users.find((event) => event.pubkey === pubkey)?.author;
const account = view.render(author, pubkey);
const weight = percentageSchema.catch(0).parse(zapsTag.find((name) => name[1] === pubkey)![3]) ?? 0; const weight = percentageSchema.catch(0).parse(zapsTag.find((name) => name[1] === pubkey)![3]) ?? 0;
const message = zapsTag.find((name) => name[1] === pubkey)![4] ?? ''; const message = zapsTag.find((name) => name[1] === pubkey)![4] ?? '';
return { return {
@ -290,7 +288,7 @@ export const statusZapSplitsController: AppController = async (c) => {
message, message,
weight, weight,
}; };
}))).filter((zapSplit) => zapSplit.weight > 0); }).filter((zapSplit) => zapSplit.weight > 0);
return c.json(zapSplits, 200); return c.json(zapSplits, 200);
}; };
@ -317,9 +315,10 @@ 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(c.var);
await updateAdminEvent( await updateAdminEvent(
c.var,
{ kinds: [0], authors: [pubkey], limit: 1 }, { kinds: [0], authors: [pubkey], limit: 1 },
(_) => { (_) => {
const { const {
@ -343,8 +342,7 @@ export const updateInstanceController: AppController = async (c) => {
tags: [], tags: [],
}; };
}, },
c,
); );
return c.json(204); return c.newResponse(null, 204);
}; };

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})`;
@ -18,7 +17,7 @@ const features = [
const instanceV1Controller: AppController = async (c) => { const instanceV1Controller: AppController = async (c) => {
const { conf } = c.var; const { conf } = 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(c.var);
/** 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:';
@ -78,7 +77,7 @@ const instanceV1Controller: AppController = async (c) => {
const instanceV2Controller: AppController = async (c) => { const instanceV2Controller: AppController = async (c) => {
const { conf } = c.var; const { conf } = 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(c.var);
/** 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,7 @@ 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 meta = await getInstanceMetadata(c.var);
return c.json({ return c.json({
content: meta.about, content: meta.about,

View file

@ -1,13 +1,11 @@
import { type AppController } from '@/app.ts'; import { type AppController } from '@/app.ts';
import { Storages } from '@/storages.ts';
import { getTagSet } from '@/utils/tags.ts'; import { getTagSet } from '@/utils/tags.ts';
import { renderAccounts } from '@/views.ts'; import { renderAccounts } from '@/views.ts';
/** https://docs.joinmastodon.org/methods/mutes/#get */ /** https://docs.joinmastodon.org/methods/mutes/#get */
const mutesController: AppController = async (c) => { const mutesController: AppController = async (c) => {
const store = await Storages.db(); const { store, user, signal } = c.var;
const pubkey = await c.get('signer')?.getPublicKey()!; const pubkey = await user!.signer.getPublicKey();
const { signal } = c.req.raw;
const [event10000] = await store.query( const [event10000] = await store.query(
[{ kinds: [10000], authors: [pubkey], limit: 1 }], [{ kinds: [10000], authors: [pubkey], limit: 1 }],

View file

@ -1,14 +1,16 @@
import { NConnectSigner, NSchema as n, NSecSigner } from '@nostrify/nostrify'; import { DittoConf } from '@ditto/conf';
import { DittoTables } from '@ditto/db';
import { NConnectSigner, NRelay, NSchema as n, NSecSigner } from '@nostrify/nostrify';
import { escape } from 'entities'; 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 { 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';
import { generateToken, getTokenHash } from '@/utils/auth.ts'; import { generateToken, getTokenHash } from '@/utils/auth.ts';
import { Kysely } from 'kysely';
const passwordGrantSchema = z.object({ const passwordGrantSchema = z.object({
grant_type: z.literal('password'), grant_type: z.literal('password'),
@ -39,7 +41,6 @@ const createTokenSchema = z.discriminatedUnion('grant_type', [
]); ]);
const createTokenController: AppController = async (c) => { const createTokenController: AppController = async (c) => {
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 +51,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.var, result.data),
token_type: 'Bearer', token_type: 'Bearer',
scope: 'read write follow push', scope: 'read write follow push',
created_at: nostrNow(), created_at: nostrNow(),
@ -90,6 +91,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 { kysely } = 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,7 +102,6 @@ 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 kysely
@ -110,11 +112,17 @@ const revokeTokenController: AppController = async (c) => {
return c.json({}); return c.json({});
}; };
interface GetTokenOpts {
conf: DittoConf;
kysely: Kysely<DittoTables>;
pubsub: NRelay;
}
async function getToken( async function getToken(
opts: GetTokenOpts,
{ pubkey: bunkerPubkey, secret, relays = [] }: { pubkey: string; secret?: string; relays?: string[] }, { pubkey: bunkerPubkey, secret, relays = [] }: { pubkey: string; secret?: string; relays?: string[] },
dittoSeckey: Uint8Array,
): Promise<`token1${string}`> { ): Promise<`token1${string}`> {
const kysely = await Storages.kysely(); const { conf, kysely, pubsub } = opts;
const { token, hash } = await generateToken(); const { token, hash } = await generateToken();
const nip46Seckey = generateSecretKey(); const nip46Seckey = generateSecretKey();
@ -123,7 +131,7 @@ async function getToken(
encryption: 'nip44', encryption: 'nip44',
pubkey: bunkerPubkey, pubkey: bunkerPubkey,
signer: new NSecSigner(nip46Seckey), signer: new NSecSigner(nip46Seckey),
relay: await Storages.pubsub(), // TODO: Use the relays from the request. relay: pubsub, // TODO: Use the relays from the request.
timeout: 60_000, timeout: 60_000,
}); });
@ -134,7 +142,7 @@ async function getToken(
token_hash: hash, token_hash: hash,
pubkey: userPubkey, pubkey: userPubkey,
bunker_pubkey: bunkerPubkey, bunker_pubkey: bunkerPubkey,
nip46_sk_enc: await aesEncrypt(dittoSeckey, nip46Seckey), nip46_sk_enc: await aesEncrypt(conf.seckey, nip46Seckey),
nip46_relays: relays, nip46_relays: relays,
created_at: new Date(), created_at: new Date(),
}).execute(); }).execute();
@ -222,8 +230,6 @@ const oauthAuthorizeSchema = z.object({
/** Controller the OAuth form is POSTed to. */ /** Controller the OAuth form is POSTed to. */
const oauthAuthorizeController: AppController = async (c) => { const oauthAuthorizeController: AppController = async (c) => {
const { conf } = c.var;
/** FormData results in JSON. */ /** FormData results in JSON. */
const result = oauthAuthorizeSchema.safeParse(await parseBody(c.req.raw)); const result = oauthAuthorizeSchema.safeParse(await parseBody(c.req.raw));
@ -236,11 +242,11 @@ 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.var, {
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'),
}, conf.seckey); });
if (redirectUri === 'urn:ietf:wg:oauth:2.0:oob') { if (redirectUri === 'urn:ietf:wg:oauth:2.0:oob') {
return c.text(token); return c.text(token);

View file

@ -3,14 +3,12 @@ import { z } from 'zod';
import { type AppController } from '@/app.ts'; import { type AppController } from '@/app.ts';
import { configSchema, elixirTupleSchema } from '@/schemas/pleroma-api.ts'; import { configSchema, elixirTupleSchema } from '@/schemas/pleroma-api.ts';
import { AdminSigner } from '@/signers/AdminSigner.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts';
import { Storages } from '@/storages.ts';
import { createAdminEvent, updateAdminEvent, updateUser } from '@/utils/api.ts'; import { createAdminEvent, updateAdminEvent, updateUser } from '@/utils/api.ts';
import { lookupPubkey } from '@/utils/lookup.ts'; import { lookupPubkey } from '@/utils/lookup.ts';
import { getPleromaConfigs } from '@/utils/pleroma.ts'; import { getPleromaConfigs } from '@/utils/pleroma.ts';
const frontendConfigController: AppController = async (c) => { const frontendConfigController: AppController = async (c) => {
const store = await Storages.db(); const configDB = await getPleromaConfigs(c.var);
const configDB = await getPleromaConfigs(store, c.req.raw.signal);
const frontendConfig = configDB.get(':pleroma', ':frontend_configurations'); const frontendConfig = configDB.get(':pleroma', ':frontend_configurations');
if (frontendConfig) { if (frontendConfig) {
@ -26,8 +24,7 @@ const frontendConfigController: AppController = async (c) => {
}; };
const configController: AppController = async (c) => { const configController: AppController = async (c) => {
const store = await Storages.db(); const configs = await getPleromaConfigs(c.var);
const configs = await getPleromaConfigs(store, c.req.raw.signal);
return c.json({ configs, need_reboot: false }); return c.json({ configs, need_reboot: false });
}; };
@ -36,29 +33,28 @@ const updateConfigController: AppController = async (c) => {
const { conf } = c.var; const { conf } = c.var;
const { pubkey } = conf; const { pubkey } = conf;
const store = await Storages.db(); const configs = await getPleromaConfigs(c.var);
const configs = await getPleromaConfigs(store, c.req.raw.signal);
const { configs: newConfigs } = z.object({ configs: z.array(configSchema) }).parse(await c.req.json()); const { configs: newConfigs } = z.object({ configs: z.array(configSchema) }).parse(await c.req.json());
configs.merge(newConfigs); configs.merge(newConfigs);
await createAdminEvent({ await createAdminEvent(c.var, {
kind: 30078, kind: 30078,
content: await new AdminSigner().nip44.encrypt(pubkey, JSON.stringify(configs)), content: await new AdminSigner(conf).nip44.encrypt(pubkey, JSON.stringify(configs)),
tags: [ tags: [
['d', 'pub.ditto.pleroma.config'], ['d', 'pub.ditto.pleroma.config'],
['encrypted', 'nip44'], ['encrypted', 'nip44'],
], ],
}, c); });
return c.json({ configs: newConfigs, need_reboot: false }); return c.json({ configs: newConfigs, need_reboot: false });
}; };
const pleromaAdminDeleteStatusController: AppController = async (c) => { const pleromaAdminDeleteStatusController: AppController = async (c) => {
await createAdminEvent({ await createAdminEvent(c.var, {
kind: 5, kind: 5,
tags: [['e', c.req.param('id')]], tags: [['e', c.req.param('id')]],
}, c); });
return c.json({}); return c.json({});
}; };
@ -73,10 +69,11 @@ 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(c.var, nickname);
if (!pubkey) continue; if (!pubkey) continue;
await updateAdminEvent( await updateAdminEvent(
c,
{ kinds: [30382], authors: [conf.pubkey], '#d': [pubkey], limit: 1 }, { kinds: [30382], authors: [conf.pubkey], '#d': [pubkey], limit: 1 },
(prev) => { (prev) => {
const tags = prev?.tags ?? [['d', pubkey]]; const tags = prev?.tags ?? [['d', pubkey]];
@ -94,11 +91,10 @@ const pleromaAdminTagController: AppController = async (c) => {
tags, tags,
}; };
}, },
c,
); );
} }
return new Response(null, { status: 204 }); return c.newResponse(null, { status: 204 });
}; };
const pleromaAdminUntagController: AppController = async (c) => { const pleromaAdminUntagController: AppController = async (c) => {
@ -106,10 +102,11 @@ 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(c.var, nickname);
if (!pubkey) continue; if (!pubkey) continue;
await updateAdminEvent( await updateAdminEvent(
c,
{ kinds: [30382], authors: [conf.pubkey], '#d': [pubkey], limit: 1 }, { kinds: [30382], authors: [conf.pubkey], '#d': [pubkey], limit: 1 },
(prev) => ({ (prev) => ({
kind: 30382, kind: 30382,
@ -117,11 +114,10 @@ const pleromaAdminUntagController: AppController = async (c) => {
tags: (prev?.tags ?? [['d', pubkey]]) tags: (prev?.tags ?? [['d', pubkey]])
.filter(([name, value]) => !(name === 't' && params.tags.includes(value))), .filter(([name, value]) => !(name === 't' && params.tags.includes(value))),
}), }),
c,
); );
} }
return new Response(null, { status: 204 }); return c.newResponse(null, { status: 204 });
}; };
const pleromaAdminSuggestSchema = z.object({ const pleromaAdminSuggestSchema = z.object({
@ -132,24 +128,26 @@ 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(c.var, nickname);
if (!pubkey) continue; if (pubkey) {
await updateUser(pubkey, { suggested: true }, c); await updateUser(c.var, pubkey, { suggested: true });
}
} }
return new Response(null, { status: 204 }); return c.newResponse(null, { status: 204 });
}; };
const pleromaAdminUnsuggestController: AppController = async (c) => { 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(c.var, nickname);
if (!pubkey) continue; if (pubkey) {
await updateUser(pubkey, { suggested: false }, c); await updateUser(c.var, pubkey, { suggested: false });
}
} }
return new Response(null, { status: 204 }); return c.newResponse(null, { status: 204 });
}; };
export { export {

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,8 @@ const pushSubscribeSchema = z.object({
}); });
export const pushSubscribeController: AppController = async (c) => { export const pushSubscribeController: AppController = async (c) => {
const { conf } = c.var; const { conf, kysely, user } = c.var;
const vapidPublicKey = await conf.vapidPublicKey; const vapidPublicKey = await conf.vapidPublicKey;
if (!vapidPublicKey) { if (!vapidPublicKey) {
@ -50,10 +50,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 = c.get('signer')!;
const result = pushSubscribeSchema.safeParse(await parseBody(c.req.raw)); const result = pushSubscribeSchema.safeParse(await parseBody(c.req.raw));
if (!result.success) { if (!result.success) {
@ -62,7 +58,7 @@ export const pushSubscribeController: AppController = async (c) => {
const { subscription, data } = result.data; const { subscription, data } = result.data;
const pubkey = await signer.getPublicKey(); const pubkey = await user!.signer.getPublicKey();
const tokenHash = await getTokenHash(accessToken); const tokenHash = await getTokenHash(accessToken);
const { id } = await kysely.transaction().execute(async (trx) => { const { id } = await kysely.transaction().execute(async (trx) => {
@ -97,7 +93,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, kysely } = c.var;
const vapidPublicKey = await conf.vapidPublicKey; const vapidPublicKey = await conf.vapidPublicKey;
if (!vapidPublicKey) { if (!vapidPublicKey) {
@ -106,7 +102,6 @@ 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 kysely

View file

@ -1,10 +1,9 @@
import { AppController } from '@/app.ts'; import { AppController } from '@/app.ts';
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 { Storages } from '@/storages.ts';
import { createEvent } from '@/utils/api.ts'; import { createEvent } from '@/utils/api.ts';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { AccountView } from '@/views/mastodon/AccountView.ts';
import { renderStatus } from '@/views/mastodon/statuses.ts'; import { StatusView } from '@/views/mastodon/StatusView.ts';
/** /**
* React to a status. * React to a status.
@ -13,29 +12,30 @@ import { renderStatus } from '@/views/mastodon/statuses.ts';
const reactionController: AppController = async (c) => { const reactionController: AppController = async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const emoji = c.req.param('emoji'); const emoji = c.req.param('emoji');
const signer = c.get('signer')!;
const { store } = c.var;
if (!/^\p{RGI_Emoji}$/v.test(emoji)) { if (!/^\p{RGI_Emoji}$/v.test(emoji)) {
return c.json({ error: 'Invalid emoji' }, 400); return c.json({ error: 'Invalid emoji' }, 400);
} }
const store = await Storages.db();
const [event] = await store.query([{ kinds: [1, 20], ids: [id], limit: 1 }]); const [event] = await store.query([{ kinds: [1, 20], ids: [id], limit: 1 }]);
if (!event) { if (!event) {
return c.json({ error: 'Status not found' }, 404); return c.json({ error: 'Status not found' }, 404);
} }
await createEvent({ await createEvent(c.var, {
kind: 7, kind: 7,
content: emoji, content: emoji,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags: [['e', id], ['p', event.pubkey]], tags: [['e', id], ['p', event.pubkey]],
}, c); });
await hydrateEvents({ events: [event], store }); await hydrateEvents(c.var, [event]);
const status = await renderStatus(event, { viewerPubkey: await signer.getPublicKey() }); const view = new StatusView(c.var);
const status = await view.render(event);
return c.json(status); return c.json(status);
}; };
@ -47,9 +47,10 @@ const reactionController: AppController = async (c) => {
const deleteReactionController: AppController = async (c) => { const deleteReactionController: AppController = async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const emoji = c.req.param('emoji'); const emoji = c.req.param('emoji');
const signer = c.get('signer')!;
const pubkey = await signer.getPublicKey(); const { store, user } = c.var;
const store = await Storages.db();
const pubkey = await user!.signer.getPublicKey();
if (!/^\p{RGI_Emoji}$/v.test(emoji)) { if (!/^\p{RGI_Emoji}$/v.test(emoji)) {
return c.json({ error: 'Invalid emoji' }, 400); return c.json({ error: 'Invalid emoji' }, 400);
@ -71,14 +72,15 @@ const deleteReactionController: AppController = async (c) => {
.filter((event) => event.content === emoji) .filter((event) => event.content === emoji)
.map((event) => ['e', event.id]); .map((event) => ['e', event.id]);
await createEvent({ await createEvent(c.var, {
kind: 5, kind: 5,
content: '', content: '',
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags, tags,
}, c); });
const status = renderStatus(event, { viewerPubkey: pubkey }); const view = new StatusView(c.var);
const status = await view.render(event);
return c.json(status); return c.json(status);
}; };
@ -89,10 +91,12 @@ const deleteReactionController: AppController = async (c) => {
*/ */
const reactionsController: AppController = async (c) => { const reactionsController: AppController = async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const store = await Storages.db();
const pubkey = await c.get('signer')?.getPublicKey();
const emoji = c.req.param('emoji') as string | undefined; const emoji = c.req.param('emoji') as string | undefined;
const { store, user } = c.var;
const pubkey = await user?.signer.getPublicKey();
if (typeof emoji === 'string' && !/^\p{RGI_Emoji}$/v.test(emoji)) { if (typeof emoji === 'string' && !/^\p{RGI_Emoji}$/v.test(emoji)) {
return c.json({ error: 'Invalid emoji' }, 400); return c.json({ error: 'Invalid emoji' }, 400);
} }
@ -100,7 +104,7 @@ const reactionsController: AppController = async (c) => {
const events = await store.query([{ kinds: [7], '#e': [id], limit: 100 }]) const events = await store.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, store })); .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) => {
@ -110,18 +114,16 @@ const reactionsController: AppController = async (c) => {
return acc; return acc;
}, {} as Record<string, DittoEvent[]>); }, {} as Record<string, DittoEvent[]>);
const results = await Promise.all( const view = new AccountView(c.var);
Object.entries(byEmoji).map(async ([name, events]) => {
const results = Object.entries(byEmoji).map(([name, events]) => {
return { return {
name, name,
count: events.length, count: events.length,
me: pubkey && events.some((event) => event.pubkey === pubkey), me: pubkey && events.some((event) => event.pubkey === pubkey),
accounts: await Promise.all( accounts: events.map((event) => view.render(event.author, event.pubkey)),
events.map((event) => event.author ? renderAccount(event.author) : accountFromPubkey(event.pubkey)),
),
}; };
}), });
);
return c.json(results); return c.json(results);
}; };

View file

@ -1,15 +1,17 @@
import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify'; import { DittoConf } from '@ditto/conf';
import { DittoTables } from '@ditto/db';
import { NostrEvent, NostrFilter, NSchema as n, NStore } from '@nostrify/nostrify';
import { Kysely } from 'kysely';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { z } from 'zod'; import { z } from 'zod';
import { AppController } from '@/app.ts'; import { 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 { resolveNip05 } from '@/utils/nip05.ts';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { AccountView } from '@/views/mastodon/AccountView.ts';
import { renderStatus } from '@/views/mastodon/statuses.ts'; import { StatusView } from '@/views/mastodon/StatusView.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'; import { paginated, paginatedList } from '@/utils/api.ts';
@ -26,23 +28,26 @@ 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 { pagination } = c.var;
const result = searchQuerySchema.safeParse(c.req.query()); const result = searchQuerySchema.safeParse(c.req.query());
const params = c.get('pagination');
const { signal } = c.req.raw;
const viewerPubkey = await c.get('signer')?.getPublicKey();
if (!result.success) { if (!result.success) {
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, ...params }, signal); const event = await lookupEvent(c.var, { ...result.data, ...pagination });
const lookup = extractIdentifier(result.data.q); const lookup = extractIdentifier(result.data.q);
const accountView = new AccountView(c.var);
const statusView = new StatusView(c.var);
// Render account from pubkey. // Render account from pubkey.
if (!event && lookup) { if (!event && lookup) {
const pubkey = await lookupPubkey(lookup); const pubkey = await lookupPubkey(c.var, lookup);
return c.json({ return c.json({
accounts: pubkey ? [accountFromPubkey(pubkey)] : [], accounts: pubkey ? [accountView.render(undefined, pubkey)] : [],
statuses: [], statuses: [],
hashtags: [], hashtags: [],
}); });
@ -54,19 +59,19 @@ const searchController: AppController = async (c) => {
events = [event]; events = [event];
} }
events.push(...(await searchEvents({ ...result.data, ...params, viewerPubkey }, signal))); events.push(...(await searchEvents(c.var, { ...result.data, ...pagination })));
const [accounts, statuses] = await Promise.all([ const [accounts, statuses] = await Promise.all([
Promise.all( Promise.all(
events events
.filter((event) => event.kind === 0) .filter((event) => event.kind === 0)
.map((event) => renderAccount(event)) .map((event) => accountView.render(event))
.filter(Boolean), .filter(Boolean),
), ),
Promise.all( Promise.all(
events events
.filter((event) => event.kind === 1) .filter((event) => event.kind === 1)
.map((event) => renderStatus(event, { viewerPubkey })) .map((event) => statusView.render(event))
.filter(Boolean), .filter(Boolean),
), ),
]); ]);
@ -78,24 +83,31 @@ const searchController: AppController = async (c) => {
}; };
if (result.data.type === 'accounts') { if (result.data.type === 'accounts') {
return paginatedList(c, { ...result.data, ...params }, body); return paginatedList(c, { ...result.data, ...pagination }, body);
} else { } else {
return paginated(c, events, body); return paginated(c, events, body);
} }
}; };
interface SearchEventsOpts {
conf: DittoConf;
store: NStore;
kysely: Kysely<DittoTables>;
signal?: AbortSignal;
}
/** Get events for the search params. */ /** Get events for the search params. */
async function searchEvents( async function searchEvents(
opts: SearchEventsOpts,
{ 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,
): Promise<NostrEvent[]> { ): Promise<NostrEvent[]> {
const { store, kysely, signal } = opts;
// Hashtag search is not supported. // Hashtag search is not supported.
if (type === 'hashtags') { if (type === 'hashtags') {
return Promise.resolve([]); return Promise.resolve([]);
} }
const store = await Storages.search();
const filter: NostrFilter = { const filter: NostrFilter = {
kinds: typeToKinds(type), kinds: typeToKinds(type),
search: q, search: q,
@ -104,11 +116,9 @@ 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(store, viewerPubkey) : new Set<string>();
const searchPubkeys = await getPubkeysBySearch(kysely, { q, limit, offset, following }); const searchPubkeys = await getPubkeysBySearch(kysely, { q, limit, offset, following });
filter.authors = [...searchPubkeys]; filter.authors = [...searchPubkeys];
@ -123,7 +133,7 @@ async function searchEvents(
// Query the events. // Query the events.
let events = await store let events = await store
.query([filter], { signal }) .query([filter], { signal })
.then((events) => hydrateEvents({ events, store, signal })); .then((events) => hydrateEvents(opts, 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) {
@ -147,18 +157,27 @@ function typeToKinds(type: SearchQuery['type']): number[] {
} }
} }
/** Resolve a searched value into an event, if applicable. */ interface LookupEventOpts {
async function lookupEvent(query: SearchQuery, signal: AbortSignal): Promise<NostrEvent | undefined> { conf: DittoConf;
const filters = await getLookupFilters(query, signal); store: NStore;
const store = await Storages.search(); kysely: Kysely<DittoTables>;
signal?: AbortSignal;
}
return store.query(filters, { limit: 1, signal }) /** Resolve a searched value into an event, if applicable. */
.then((events) => hydrateEvents({ events, store, signal })) async function lookupEvent(opts: LookupEventOpts, query: SearchQuery): Promise<NostrEvent | undefined> {
const { store, signal } = opts;
const filters = await getLookupFilters(opts, query);
const _opts = { limit: 1, signal };
return store.query(filters, _opts)
.then((events) => hydrateEvents(opts, 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(opts: LookupEventOpts, { 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';
@ -168,8 +187,8 @@ async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: Abort
if (n.id().safeParse(q).success) { if (n.id().safeParse(q).success) {
const filters: NostrFilter[] = []; const filters: NostrFilter[] = [];
if (accounts) filters.push({ kinds: [0], authors: [q] }); if (accounts) filters.push({ kinds: [0], authors: [q], limit: 1 });
if (statuses) filters.push({ kinds: [1, 20], ids: [q] }); if (statuses) filters.push({ kinds: [1, 20], ids: [q], limit: 1 });
return filters; return filters;
} }
@ -181,16 +200,16 @@ async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: Abort
const filters: NostrFilter[] = []; const filters: NostrFilter[] = [];
switch (result.type) { switch (result.type) {
case 'npub': case 'npub':
if (accounts) filters.push({ kinds: [0], authors: [result.data] }); if (accounts) filters.push({ kinds: [0], authors: [result.data], limit: 1 });
break; break;
case 'nprofile': case 'nprofile':
if (accounts) filters.push({ kinds: [0], authors: [result.data.pubkey] }); if (accounts) filters.push({ kinds: [0], authors: [result.data.pubkey], limit: 1 });
break; break;
case 'note': case 'note':
if (statuses) filters.push({ kinds: [1, 20], ids: [result.data] }); if (statuses) filters.push({ kinds: [1, 20], ids: [result.data], limit: 1 });
break; break;
case 'nevent': case 'nevent':
if (statuses) filters.push({ kinds: [1, 20], ids: [result.data.id] }); if (statuses) filters.push({ kinds: [1, 20], ids: [result.data.id], limit: 1 });
break; break;
} }
return filters; return filters;
@ -199,9 +218,9 @@ async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: Abort
} }
try { try {
const { pubkey } = await nip05Cache.fetch(lookup, { signal }); const { pubkey } = await resolveNip05(opts, lookup);
if (pubkey) { if (pubkey) {
return [{ kinds: [0], authors: [pubkey] }]; return [{ kinds: [0], authors: [pubkey], limit: 1 }];
} }
} catch { } catch {
// fall through // fall through

View file

@ -13,15 +13,14 @@ import { addTag, deleteTag } from '@/utils/tags.ts';
import { asyncReplaceAll } from '@/utils/text.ts'; 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 { Storages } from '@/storages.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, paginated, paginatedList, 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';
import { renderEventAccounts } from '@/views.ts'; import { renderEventAccounts } from '@/views.ts';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { AccountView } from '@/views/mastodon/AccountView.ts';
import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts'; import { StatusView } from '@/views/mastodon/StatusView.ts';
const createStatusSchema = z.object({ const createStatusSchema = z.object({
in_reply_to_id: n.id().nullish(), in_reply_to_id: n.id().nullish(),
@ -47,17 +46,15 @@ const createStatusSchema = z.object({
const statusController: AppController = async (c) => { const statusController: AppController = async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const signal = AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(1500)]); const event = await getEvent(c.var, id);
const event = await getEvent(id, { signal });
if (event?.author) { if (event?.author) {
assertAuthenticated(c, event.author); assertAuthenticated(c, event.author);
} }
if (event) { if (event) {
const viewerPubkey = await c.get('signer')?.getPublicKey(); const view = new StatusView(c.var);
const status = await renderStatus(event, { viewerPubkey }); const status = await view.render(event);
return c.json(status); return c.json(status);
} }
@ -65,7 +62,7 @@ const statusController: AppController = async (c) => {
}; };
const createStatusController: AppController = async (c) => { const createStatusController: AppController = async (c) => {
const { conf } = c.var; const { conf, 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);
const store = c.get('store'); const store = c.get('store');
@ -190,8 +187,8 @@ const createStatusController: AppController = async (c) => {
} }
} }
const pubkey = await c.get('signer')?.getPublicKey()!; const pubkey = await user!.signer.getPublicKey();
const author = pubkey ? await getAuthor(pubkey) : undefined; const author = await getAuthor(c.var, pubkey);
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);
@ -247,39 +244,38 @@ const createStatusController: AppController = async (c) => {
content += mediaUrls.join('\n'); content += mediaUrls.join('\n');
} }
const event = await createEvent({ const event = await createEvent(c.var, {
kind: 1, kind: 1,
content, content,
tags, tags,
}, c); });
if (data.quote_id) { if (data.quote_id) {
await hydrateEvents({ await hydrateEvents(c.var, [event]);
events: [event],
store: await Storages.db(),
signal: c.req.raw.signal,
});
} }
return c.json(await renderStatus({ ...event, author }, { viewerPubkey: author?.pubkey })); const view = new StatusView(c.var);
return c.json(await view.render({ ...event, author }));
}; };
const deleteStatusController: AppController = async (c) => { const deleteStatusController: AppController = async (c) => {
const { conf } = c.var; const { conf, user } = c.var;
const id = c.req.param('id'); const id = c.req.param('id');
const pubkey = await c.get('signer')?.getPublicKey(); const pubkey = await user!.signer.getPublicKey();
const event = await getEvent(id, { signal: c.req.raw.signal }); const event = await getEvent(c.var, id);
if (event) { if (event) {
if (event.pubkey === pubkey) { if (event.pubkey === pubkey) {
await createEvent({ await createEvent(c.var, {
kind: 5, kind: 5,
tags: [['e', id, conf.relay, '', pubkey]], tags: [['e', id, conf.relay, '', pubkey]],
}, c); });
const author = await getAuthor(event.pubkey); const author = await getAuthor(c.var, event.pubkey);
return c.json(await renderStatus({ ...event, author }, { viewerPubkey: pubkey })); const view = new StatusView(c.var);
return c.json(await view.render({ ...event, author }));
} else { } else {
return c.json({ error: 'Unauthorized' }, 403); return c.json({ error: 'Unauthorized' }, 403);
} }
@ -289,14 +285,16 @@ const deleteStatusController: AppController = async (c) => {
}; };
const contextController: AppController = async (c) => { const contextController: AppController = async (c) => {
const { store } = c.var;
const id = c.req.param('id'); const id = c.req.param('id');
const store = c.get('store');
const [event] = await store.query([{ kinds: [1, 20], ids: [id] }]); const [event] = await store.query([{ kinds: [1, 20], ids: [id] }]);
const viewerPubkey = await c.get('signer')?.getPublicKey();
const view = new StatusView(c.var);
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) => view.render(event)),
); );
return statuses.filter(Boolean); return statuses.filter(Boolean);
} }
@ -307,11 +305,7 @@ const contextController: AppController = async (c) => {
getDescendants(store, event), getDescendants(store, event),
]); ]);
await hydrateEvents({ await hydrateEvents(c.var, [...ancestorEvents, ...descendantEvents]);
events: [...ancestorEvents, ...descendantEvents],
signal: c.req.raw.signal,
store,
});
const [ancestors, descendants] = await Promise.all([ const [ancestors, descendants] = await Promise.all([
renderStatuses(ancestorEvents), renderStatuses(ancestorEvents),
@ -325,24 +319,25 @@ const contextController: AppController = async (c) => {
}; };
const favouriteController: AppController = async (c) => { const favouriteController: AppController = async (c) => {
const { conf } = c.var; const { conf, store } = c.var;
const id = c.req.param('id'); const id = c.req.param('id');
const store = await Storages.db();
const [target] = await store.query([{ ids: [id], kinds: [1, 20] }]); const [target] = await store.query([{ ids: [id], kinds: [1, 20] }], c.var);
if (target) { if (target) {
await createEvent({ await createEvent(c.var, {
kind: 7, kind: 7,
content: '+', content: '+',
tags: [ tags: [
['e', target.id, conf.relay, '', target.pubkey], ['e', target.id, conf.relay, '', target.pubkey],
['p', target.pubkey, conf.relay], ['p', target.pubkey, conf.relay],
], ],
}, c); });
await hydrateEvents({ events: [target], store }); await hydrateEvents(c.var, [target]);
const status = await renderStatus(target, { viewerPubkey: await c.get('signer')?.getPublicKey() }); const view = new StatusView(c.var);
const status = await view.render(target);
if (status) { if (status) {
status.favourited = true; status.favourited = true;
@ -368,43 +363,38 @@ const favouritedByController: AppController = (c) => {
const reblogStatusController: AppController = async (c) => { const reblogStatusController: AppController = async (c) => {
const { conf } = c.var; const { conf } = c.var;
const eventId = c.req.param('id'); const eventId = c.req.param('id');
const { signal } = c.req.raw;
const event = await getEvent(eventId, { const event = await getEvent(c.var, eventId);
kind: 1,
});
if (!event) { if (!event) {
return c.json({ error: 'Event not found.' }, 404); return c.json({ error: 'Event not found.' }, 404);
} }
const reblogEvent = await createEvent({ const reblogEvent = await createEvent(c.var, {
kind: 6, kind: 6,
tags: [ tags: [
['e', event.id, conf.relay, '', event.pubkey], ['e', event.id, conf.relay, '', event.pubkey],
['p', event.pubkey, conf.relay], ['p', event.pubkey, conf.relay],
], ],
}, c);
await hydrateEvents({
events: [reblogEvent],
store: await Storages.db(),
signal: signal,
}); });
const status = await renderReblog(reblogEvent, { viewerPubkey: await c.get('signer')?.getPublicKey() }); await hydrateEvents(c.var, [reblogEvent]);
const view = new StatusView(c.var);
const status = await view.renderReblog(reblogEvent);
return c.json(status); return c.json(status);
}; };
/** https://docs.joinmastodon.org/methods/statuses/#unreblog */ /** https://docs.joinmastodon.org/methods/statuses/#unreblog */
const unreblogStatusController: AppController = async (c) => { const unreblogStatusController: AppController = async (c) => {
const { conf } = c.var; const { conf, store, user } = c.var;
const eventId = c.req.param('id'); const eventId = c.req.param('id');
const pubkey = await c.get('signer')?.getPublicKey()!; const pubkey = await user!.signer.getPublicKey();
const store = await Storages.db();
const [event] = await store.query([{ ids: [eventId], kinds: [1, 20] }]); const [event] = await store.query([{ ids: [eventId], kinds: [1, 20] }]);
if (!event) { if (!event) {
return c.json({ error: 'Record not found' }, 404); return c.json({ error: 'Record not found' }, 404);
} }
@ -417,38 +407,41 @@ const unreblogStatusController: AppController = async (c) => {
return c.json({ error: 'Record not found' }, 404); return c.json({ error: 'Record not found' }, 404);
} }
await createEvent({ await createEvent(c.var, {
kind: 5, kind: 5,
tags: [['e', repostEvent.id, conf.relay, '', repostEvent.pubkey]], tags: [['e', repostEvent.id, conf.relay, '', repostEvent.pubkey]],
}, c); });
return c.json(await renderStatus(event, { viewerPubkey: pubkey })); const view = new StatusView(c.var);
const status = await view.render(event);
return c.json(status);
}; };
const rebloggedByController: AppController = (c) => { const rebloggedByController: AppController = (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const params = c.get('pagination'); const { pagination } = c.var;
return renderEventAccounts(c, [{ kinds: [6], '#e': [id], ...params }]); return renderEventAccounts(c, [{ kinds: [6], '#e': [id], ...pagination }]);
}; };
const quotesController: AppController = async (c) => { const quotesController: AppController = async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const params = c.get('pagination'); const { store, pagination } = c.var;
const store = await Storages.db();
const [event] = await store.query([{ ids: [id], kinds: [1, 20] }]); const [event] = await store.query([{ ids: [id], kinds: [1, 20] }]);
if (!event) { if (!event) {
return c.json({ error: 'Event not found.' }, 404); return c.json({ error: 'Event not found.' }, 404);
} }
const quotes = await store const quotes = await store
.query([{ kinds: [1, 20], '#q': [event.id], ...params }]) .query([{ kinds: [1, 20], '#q': [event.id], ...pagination }])
.then((events) => hydrateEvents({ events, store })); .then((events) => hydrateEvents(c.var, events));
const viewerPubkey = await c.get('signer')?.getPublicKey(); const view = new StatusView(c.var);
const statuses = await Promise.all( const statuses = await Promise.all(
quotes.map((event) => renderStatus(event, { viewerPubkey })), quotes.map((event) => view.render(event)),
); );
if (!statuses.length) { if (!statuses.length) {
@ -460,23 +453,22 @@ 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 } = c.var; const { conf, user } = c.var;
const pubkey = await c.get('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(c.var, eventId);
kind: 1,
relations: ['author', 'event_stats', 'author_stats'],
});
if (event) { if (event) {
await updateListEvent( await updateListEvent(
c.var,
{ kinds: [10003], authors: [pubkey], limit: 1 }, { kinds: [10003], authors: [pubkey], limit: 1 },
(tags) => addTag(tags, ['e', event.id, conf.relay, '', event.pubkey]), (tags) => addTag(tags, ['e', event.id, conf.relay, '', event.pubkey]),
c,
); );
const status = await renderStatus(event, { viewerPubkey: pubkey }); const view = new StatusView(c.var);
const status = await view.render(event);
if (status) { if (status) {
status.bookmarked = true; status.bookmarked = true;
} }
@ -488,23 +480,22 @@ 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 } = c.var; const { conf, user } = c.var;
const pubkey = await c.get('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(c.var, eventId);
kind: 1,
relations: ['author', 'event_stats', 'author_stats'],
});
if (event) { if (event) {
await updateListEvent( await updateListEvent(
c.var,
{ kinds: [10003], authors: [pubkey], limit: 1 }, { kinds: [10003], authors: [pubkey], limit: 1 },
(tags) => deleteTag(tags, ['e', event.id, conf.relay, '', event.pubkey]), (tags) => deleteTag(tags, ['e', event.id, conf.relay, '', event.pubkey]),
c,
); );
const status = await renderStatus(event, { viewerPubkey: pubkey }); const view = new StatusView(c.var);
const status = await view.render(event);
if (status) { if (status) {
status.bookmarked = false; status.bookmarked = false;
} }
@ -516,23 +507,22 @@ 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 } = c.var; const { conf, user } = c.var;
const pubkey = await c.get('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(c.var, eventId);
kind: 1,
relations: ['author', 'event_stats', 'author_stats'],
});
if (event) { if (event) {
await updateListEvent( await updateListEvent(
c.var,
{ kinds: [10001], authors: [pubkey], limit: 1 }, { kinds: [10001], authors: [pubkey], limit: 1 },
(tags) => addTag(tags, ['e', event.id, conf.relay, '', event.pubkey]), (tags) => addTag(tags, ['e', event.id, conf.relay, '', event.pubkey]),
c,
); );
const status = await renderStatus(event, { viewerPubkey: pubkey }); const view = new StatusView(c.var);
const status = await view.render(event);
if (status) { if (status) {
status.pinned = true; status.pinned = true;
} }
@ -544,25 +534,22 @@ 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 } = c.var; const { conf, user } = c.var;
const pubkey = await c.get('signer')?.getPublicKey()!; const pubkey = await user!.signer.getPublicKey();
const eventId = c.req.param('id'); const eventId = c.req.param('id');
const { signal } = c.req.raw;
const event = await getEvent(eventId, { const event = await getEvent(c.var, eventId);
kind: 1,
relations: ['author', 'event_stats', 'author_stats'],
signal,
});
if (event) { if (event) {
await updateListEvent( await updateListEvent(
c.var,
{ kinds: [10001], authors: [pubkey], limit: 1 }, { kinds: [10001], authors: [pubkey], limit: 1 },
(tags) => deleteTag(tags, ['e', event.id, conf.relay, '', event.pubkey]), (tags) => deleteTag(tags, ['e', event.id, conf.relay, '', event.pubkey]),
c,
); );
const status = await renderStatus(event, { viewerPubkey: pubkey }); const view = new StatusView(c.var);
const status = await view.render(event);
if (status) { if (status) {
status.pinned = false; status.pinned = false;
} }
@ -597,7 +584,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, relations: ['author'], signal }); target = await getEvent(c.var, status_id);
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);
@ -625,11 +612,11 @@ const zapController: AppController = async (c) => {
} }
if (target && lnurl) { if (target && lnurl) {
const nostr = await createEvent({ const nostr = await createEvent(c.var, {
kind: 9734, kind: 9734,
content: comment ?? '', content: comment ?? '',
tags, tags,
}, c); });
return c.json({ invoice: await getInvoice({ amount, nostr: purifyEvent(nostr), lnurl }, signal) }); return c.json({ invoice: await getInvoice({ amount, nostr: purifyEvent(nostr), lnurl }, signal) });
} else { } else {
@ -640,8 +627,8 @@ const zapController: AppController = async (c) => {
const zappedByController: AppController = async (c) => { const zappedByController: AppController = async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const params = c.get('listPagination'); const params = c.get('listPagination');
const store = await Storages.db();
const kysely = await Storages.kysely(); const { kysely, store } = c.var;
const zaps = await kysely.selectFrom('event_zaps') const zaps = await kysely.selectFrom('event_zaps')
.selectAll() .selectAll()
@ -651,22 +638,21 @@ const zappedByController: AppController = async (c) => {
.offset(params.offset).execute(); .offset(params.offset).execute();
const authors = await store.query([{ kinds: [0], authors: zaps.map((zap) => zap.sender_pubkey) }]); const authors = await store.query([{ kinds: [0], authors: zaps.map((zap) => zap.sender_pubkey) }]);
const view = new AccountView(c.var);
const results = (await Promise.all( const results = zaps.map((zap) => {
zaps.map(async (zap) => {
const amount = zap.amount_millisats; const amount = zap.amount_millisats;
const comment = zap.comment; const comment = zap.comment;
const sender = authors.find((author) => author.pubkey === zap.sender_pubkey); const sender = authors.find((author) => author.pubkey === zap.sender_pubkey);
const account = sender ? await renderAccount(sender) : await accountFromPubkey(zap.sender_pubkey); const account = view.render(sender, zap.sender_pubkey);
return { return {
comment, comment,
amount, amount,
account, account,
}; };
}), }).filter(Boolean);
)).filter(Boolean);
return paginatedList(c, params, results); return paginatedList(c, params, results);
}; };

View file

@ -1,23 +1,25 @@
import { DittoTables } from '@ditto/db';
import { import {
streamingClientMessagesCounter, streamingClientMessagesCounter,
streamingConnectionsGauge, streamingConnectionsGauge,
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 { Kysely } from 'kysely';
import { z } from 'zod'; import { z } from 'zod';
import { type AppController } from '@/app.ts'; import { type AppController } from '@/app.ts';
import { MuteListPolicy } from '@/policies/MuteListPolicy.ts'; import { MuteListPolicy } from '@/policies/MuteListPolicy.ts';
import { getFeedPubkeys } from '@/queries.ts'; import { getFeedPubkeys } from '@/queries.ts';
import { AdminStore } from '@/storages/AdminStore.ts';
import { hydrateEvents } from '@/storages/hydrate.ts'; import { hydrateEvents } from '@/storages/hydrate.ts';
import { Storages } from '@/storages.ts';
import { getTokenHash } from '@/utils/auth.ts'; import { getTokenHash } from '@/utils/auth.ts';
import { errorJson } from '@/utils/log.ts'; import { errorJson } from '@/utils/log.ts';
import { bech32ToPubkey, Time } from '@/utils.ts'; import { bech32ToPubkey, Time } from '@/utils.ts';
import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts'; import { StatusView } from '@/views/mastodon/StatusView.ts';
import { renderNotification } from '@/views/mastodon/notifications.ts'; import { NotificationView } from '@/views/mastodon/NotificationView.ts';
import { HTTPException } from '@hono/hono/http-exception'; import { HTTPException } from '@hono/hono/http-exception';
/** /**
@ -68,7 +70,8 @@ const limiter = new TTLCache<string, number>();
const connections = new Set<WebSocket>(); const connections = new Set<WebSocket>();
const streamingController: AppController = async (c) => { const streamingController: AppController = async (c) => {
const { conf } = c.var; const { conf, kysely, store, pubsub } = c.var;
const upgrade = c.req.header('upgrade'); const upgrade = c.req.header('upgrade');
const token = c.req.header('sec-websocket-protocol'); const token = c.req.header('sec-websocket-protocol');
const stream = streamSchema.optional().catch(undefined).parse(c.req.query('stream')); const stream = streamSchema.optional().catch(undefined).parse(c.req.query('stream'));
@ -78,7 +81,7 @@ const streamingController: AppController = async (c) => {
return c.text('Please use websocket protocol', 400); return c.text('Please use websocket protocol', 400);
} }
const pubkey = token ? await getTokenPubkey(token) : undefined; const pubkey = token ? await getTokenPubkey(kysely, token) : undefined;
if (token && !pubkey) { if (token && !pubkey) {
return c.json({ error: 'Invalid access token' }, 401); return c.json({ error: 'Invalid access token' }, 401);
} }
@ -93,10 +96,10 @@ const streamingController: AppController = async (c) => {
const { socket, response } = Deno.upgradeWebSocket(c.req.raw, { protocol: token, idleTimeout: 30 }); const { socket, response } = Deno.upgradeWebSocket(c.req.raw, { protocol: token, idleTimeout: 30 });
const store = await Storages.db(); const statusView = new StatusView(c.var);
const pubsub = await Storages.pubsub(); const adminStore = new AdminStore(conf, store);
const notificationView = new NotificationView(c.var);
const policy = pubkey ? new MuteListPolicy(pubkey, await Storages.admin()) : undefined; const policy = pubkey ? new MuteListPolicy(pubkey, adminStore) : undefined;
function send(e: StreamingEvent) { function send(e: StreamingEvent) {
if (socket.readyState === WebSocket.OPEN) { if (socket.readyState === WebSocket.OPEN) {
@ -118,7 +121,7 @@ const streamingController: AppController = async (c) => {
} }
} }
await hydrateEvents({ events: [event], store, signal: AbortSignal.timeout(1000) }); await hydrateEvents({ conf, kysely, store, signal: AbortSignal.timeout(1000) }, [event]);
const result = await render(event); const result = await render(event);
@ -137,17 +140,14 @@ 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(store, 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 ([1, 6, 20, 1111].includes(event.kind)) {
payload = await renderStatus(event, { viewerPubkey: pubkey }); payload = await statusView.render(event);
}
if (event.kind === 6) {
payload = await renderReblog(event, { viewerPubkey: pubkey });
} }
if (payload) { if (payload) {
@ -163,7 +163,7 @@ const streamingController: AppController = async (c) => {
if (['user', 'user:notification'].includes(stream) && pubkey) { if (['user', 'user:notification'].includes(stream) && pubkey) {
sub([{ '#p': [pubkey] }], async (event) => { sub([{ '#p': [pubkey] }], 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 notificationView.render(event);
if (payload) { if (payload) {
return { return {
event: 'notification', event: 'notification',
@ -205,6 +205,7 @@ const streamingController: AppController = async (c) => {
}; };
async function topicToFilter( async function topicToFilter(
store: NStore,
topic: Stream, topic: Stream,
query: Record<string, string>, query: Record<string, string>,
pubkey: string | undefined, pubkey: string | undefined,
@ -225,19 +226,19 @@ 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)] } : undefined; return pubkey ? { kinds: [1, 6, 20], authors: [...await getFeedPubkeys(store, pubkey)] } : undefined;
} }
} }
async function getTokenPubkey(token: string): Promise<string | undefined> { async function getTokenPubkey(kysely: Kysely<DittoTables>, token: string): Promise<string | undefined> {
if (token.startsWith('token1')) { if (token.startsWith('token1')) {
const kysely = await Storages.kysely();
const tokenHash = await getTokenHash(token as `token1${string}`); const tokenHash = await getTokenHash(token as `token1${string}`);
const row = await kysely const row = await kysely
.selectFrom('auth_tokens') .selectFrom('auth_tokens')
.select('pubkey') .select('pubkey')
.where('token_hash', '=', tokenHash) .where('token_hash', '=', tokenHash)
.limit(1)
.executeTakeFirst(); .executeTakeFirst();
if (!row) { if (!row) {

View file

@ -8,16 +8,18 @@ import { MastodonTranslation } from '@/entities/MastodonTranslation.ts';
import { getEvent } from '@/queries.ts'; import { getEvent } from '@/queries.ts';
import { localeSchema } from '@/schema.ts'; import { localeSchema } from '@/schema.ts';
import { parseBody } from '@/utils/api.ts'; import { parseBody } from '@/utils/api.ts';
import { renderStatus } from '@/views/mastodon/statuses.ts'; import { StatusView } from '@/views/mastodon/StatusView.ts';
const translateSchema = z.object({ const translateSchema = z.object({
lang: localeSchema, lang: localeSchema,
}); });
const translateController: AppController = async (c) => { const translateController: AppController = async (c) => {
const result = translateSchema.safeParse(await parseBody(c.req.raw)); const { store, user } = c.var;
const { signal } = c.req.raw; const { signal } = c.req.raw;
const result = translateSchema.safeParse(await parseBody(c.req.raw));
if (!result.success) { if (!result.success) {
return c.json({ error: 'Bad request.', schema: result.error }, 422); return c.json({ error: 'Bad request.', schema: result.error }, 422);
} }
@ -31,18 +33,20 @@ 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(store, id, { signal });
if (!event) { if (!event) {
return c.json({ error: 'Record not found' }, 400); return c.json({ error: 'Record not found' }, 400);
} }
const viewerPubkey = await c.get('signer')?.getPublicKey(); const viewerPubkey = await user?.signer.getPublicKey();
if (lang.toLowerCase() === event?.language?.toLowerCase()) { if (lang.toLowerCase() === event?.language?.toLowerCase()) {
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 view = new StatusView(c.var);
const status = await view.render(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

@ -4,17 +4,29 @@ 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 { PreviewCard } from '@/entities/PreviewCard.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 { unfurlCardCached } from '@/utils/unfurl.ts';
import { paginated } from '@/utils/api.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 { StatusView } from '@/views/mastodon/StatusView.ts';
let trendingHashtagsCache = getTrendingHashtags(Conf).catch((e: unknown) => { interface MastodonTrendingHashtag {
name: string;
url: string;
history: {
day: string;
accounts: string;
uses: string;
}[];
}
let trendingHashtagsCache: Promise<MastodonTrendingHashtag[]> | undefined;
function updateTrendingHashtagsCache(conf: DittoConf, store: NStore) {
trendingHashtagsCache = getTrendingHashtags(conf, store).catch((e: unknown) => {
logi({ logi({
level: 'error', level: 'error',
ns: 'ditto.trends.api', ns: 'ditto.trends.api',
@ -24,6 +36,7 @@ let trendingHashtagsCache = getTrendingHashtags(Conf).catch((e: unknown) => {
}); });
return Promise.resolve([]); return Promise.resolve([]);
}); });
}
Deno.cron('update trending hashtags cache', '35 * * * *', async () => { Deno.cron('update trending hashtags cache', '35 * * * *', async () => {
try { try {
@ -51,8 +64,7 @@ const trendingTagsController: AppController = async (c) => {
return c.json(trends.slice(offset, offset + limit)); return c.json(trends.slice(offset, offset + limit));
}; };
async function getTrendingHashtags(conf: DittoConf) { async function getTrendingHashtags(conf: DittoConf, store: NStore): Promise<MastodonTrendingHashtag[]> {
const store = await Storages.db();
const trends = await getTrendingTags(store, 't', conf.pubkey); const trends = await getTrendingTags(store, 't', conf.pubkey);
return trends.map((trend) => { return trends.map((trend) => {
@ -104,13 +116,20 @@ const trendingLinksController: AppController = async (c) => {
return c.json(trends.slice(offset, offset + limit)); return c.json(trends.slice(offset, offset + limit));
}; };
async function getTrendingLinks(conf: DittoConf) { interface MastodonTrendingLink extends PreviewCard {
const store = await Storages.db(); history: {
day: string;
accounts: string;
uses: string;
}[];
}
async function getTrendingLinks(conf: DittoConf, store: NStore): Promise<MastodonTrendingLink[]> {
const trends = await getTrendingTags(store, 'r', conf.pubkey); const trends = await getTrendingTags(store, 'r', conf.pubkey);
return Promise.all(trends.map(async (trend) => { return Promise.all(trends.map(async (trend) => {
const link = trend.value; const link = trend.value;
const card = await unfurlCardCached(link); const card = await unfurlCardCached(conf, link);
const history = trend.history.map(({ day, authors, uses }) => ({ const history = trend.history.map(({ day, authors, uses }) => ({
day: String(day), day: String(day),
@ -140,8 +159,7 @@ async function getTrendingLinks(conf: DittoConf) {
} }
const trendingStatusesController: AppController = async (c) => { const trendingStatusesController: AppController = async (c) => {
const { conf } = c.var; const { conf, store } = c.var;
const store = await Storages.db();
const { limit, offset, until } = paginationSchema.parse(c.req.query()); const { limit, offset, until } = paginationSchema.parse(c.req.query());
const [label] = await store.query([{ const [label] = await store.query([{
@ -163,15 +181,17 @@ const trendingStatusesController: AppController = async (c) => {
} }
const results = await store.query([{ kinds: [1, 20], ids }]) const results = await store.query([{ kinds: [1, 20], ids }])
.then((events) => hydrateEvents({ events, store })); .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
.map((id) => results.find((event) => event.id === id)) .map((id) => results.find((event) => event.id === id))
.filter((event): event is NostrEvent => !!event); .filter((event): event is NostrEvent => !!event);
const view = new StatusView(c.var);
const statuses = await Promise.all( const statuses = await Promise.all(
events.map((event) => renderStatus(event, {})), events.map((event) => view.render(event)),
); );
return paginated(c, results, statuses); return paginated(c, results, statuses);

View file

@ -1,20 +1,25 @@
import { DittoConf } from '@ditto/conf';
import { DittoTables } from '@ditto/db';
import { logi } from '@soapbox/logi'; import { logi } from '@soapbox/logi';
import { Kysely } from 'kysely';
import { AppMiddleware } from '@/app.ts'; import { AppMiddleware } from '@/app.ts';
import { Storages } from '@/storages.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';
import { lookupPubkey } from '@/utils/lookup.ts'; import { lookupPubkey } from '@/utils/lookup.ts';
import { renderMetadata } from '@/views/meta.ts'; 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 { StatusView } from '@/views/mastodon/StatusView.ts';
import { renderAccount } from '@/views/mastodon/accounts.ts'; import { AccountView } from '@/views/mastodon/AccountView.ts';
import { NStore } from '@nostrify/types';
/** 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 { conf } = 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 {
@ -23,8 +28,8 @@ 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(params ?? {}); const entities = await getEntities(c.var, params ?? {});
const meta = renderMetadata(c.req.url, entities); const meta = renderMetadata(conf, c.req.raw, entities);
return c.html(content.replace(META_PLACEHOLDER, meta)); return c.html(content.replace(META_PLACEHOLDER, meta));
} catch (e) { } catch (e) {
logi({ level: 'error', ns: 'ditto.frontend', msg: 'Error building meta tags', error: errorJson(e) }); logi({ level: 'error', ns: 'ditto.frontend', msg: 'Error building meta tags', error: errorJson(e) });
@ -37,27 +42,39 @@ export const frontendController: AppMiddleware = async (c) => {
} }
}; };
async function getEntities(params: { acct?: string; statusId?: string }): Promise<MetadataEntities> { interface GetEntitiesOpts {
const store = await Storages.db(); conf: DittoConf;
store: NStore;
kysely: Kysely<DittoTables>;
signal?: AbortSignal;
}
async function getEntities(
opts: GetEntitiesOpts,
params: { acct?: string; statusId?: string },
): Promise<MetadataEntities> {
const entities: MetadataEntities = { const entities: MetadataEntities = {
instance: await getInstanceMetadata(store), instance: await getInstanceMetadata(opts),
}; };
if (params.statusId) { if (params.statusId) {
const event = await getEvent(params.statusId, { kind: 1 }); const event = await getEvent(opts, params.statusId);
if (event) { if (event) {
entities.status = await renderStatus(event, {}); const view = new StatusView(opts);
entities.status = await view.render(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(opts, params.acct.replace(/^@/, ''));
const event = pubkey ? await getAuthor(pubkey) : undefined; const event = pubkey ? await getAuthor(opts, pubkey) : undefined;
if (event) { if (event) {
entities.account = await renderAccount(event); const view = new AccountView(opts);
entities.account = view.render(event);
} }
} }

View file

@ -1,10 +1,9 @@
import { AppController } from '@/app.ts'; import { AppController } from '@/app.ts';
import { Storages } from '@/storages.ts';
import { WebManifestCombined } from '@/types/webmanifest.ts'; import { WebManifestCombined } from '@/types/webmanifest.ts';
import { getInstanceMetadata } from '@/utils/instance.ts'; import { getInstanceMetadata } from '@/utils/instance.ts';
export const manifestController: AppController = async (c) => { export const manifestController: AppController = async (c) => {
const meta = await getInstanceMetadata(await Storages.db(), c.req.raw.signal); const meta = await getInstanceMetadata(c.var);
const manifest: WebManifestCombined = { const manifest: WebManifestCombined = {
description: meta.about, description: meta.about,

View file

@ -7,12 +7,10 @@ import {
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, pool } = 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);

View file

@ -1,13 +1,12 @@
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 relayInfoController: AppController = async (c) => { const relayInfoController: AppController = async (c) => {
const { conf } = c.var; const { conf } = c.var;
const store = await Storages.db();
const meta = await getInstanceMetadata(store, c.req.raw.signal); const meta = await getInstanceMetadata(c.var);
c.res.headers.set('access-control-allow-origin', '*'); c.res.headers.set('access-control-allow-origin', '*');

View file

@ -10,20 +10,21 @@ import {
NostrClientMsg, NostrClientMsg,
NostrClientREQ, NostrClientREQ,
NostrRelayMsg, NostrRelayMsg,
NRelay,
NSchema as n, NSchema as n,
} from '@nostrify/nostrify'; } from '@nostrify/nostrify';
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 { Storages } from '@/storages.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 { MemoryRateLimiter } from '@/utils/ratelimiter/MemoryRateLimiter.ts'; import { MemoryRateLimiter } from '@/utils/ratelimiter/MemoryRateLimiter.ts';
import { MultiRateLimiter } from '@/utils/ratelimiter/MultiRateLimiter.ts'; import { MultiRateLimiter } from '@/utils/ratelimiter/MultiRateLimiter.ts';
import { RateLimiter } from '@/utils/ratelimiter/types.ts'; import { RateLimiter } from '@/utils/ratelimiter/types.ts';
import { Time } from '@/utils/time.ts'; import { Time } from '@/utils/time.ts';
import { DittoPipeline } from '@/DittoPipeline.ts';
import { EventsDB } from '@/storages/EventsDB.ts';
/** Limit of initial events returned for a subscription. */ /** Limit of initial events returned for a subscription. */
const FILTER_LIMIT = 100; const FILTER_LIMIT = 100;
@ -46,8 +47,17 @@ const limiters = {
/** Connections for metrics purposes. */ /** Connections for metrics purposes. */
const connections = new Set<WebSocket>(); const connections = new Set<WebSocket>();
interface ConnectStreamOpts {
conf: DittoConf;
store: EventsDB;
pubsub: NRelay;
pipeline: DittoPipeline;
}
/** Set up the Websocket connection. */ /** Set up the Websocket connection. */
function connectStream(socket: WebSocket, ip: string | undefined, conf: DittoConf) { function connectStream(opts: ConnectStreamOpts, socket: WebSocket, ip: string | undefined) {
const { conf, store, pubsub, pipeline } = opts;
const controllers = new Map<string, AbortController>(); const controllers = new Map<string, AbortController>();
socket.onopen = () => { socket.onopen = () => {
@ -127,9 +137,6 @@ function connectStream(socket: WebSocket, ip: string | undefined, conf: DittoCon
controllers.get(subId)?.abort(); controllers.get(subId)?.abort();
controllers.set(subId, controller); controllers.set(subId, controller);
const store = await Storages.db();
const pubsub = await Storages.pubsub();
try { try {
for (const event of await store.query(filters, { limit: FILTER_LIMIT, timeout: conf.db.timeouts.relay })) { for (const event of await store.query(filters, { limit: FILTER_LIMIT, timeout: conf.db.timeouts.relay })) {
send(['EVENT', subId, purifyEvent(event)]); send(['EVENT', subId, purifyEvent(event)]);
@ -168,7 +175,7 @@ function connectStream(socket: WebSocket, ip: string | undefined, conf: DittoCon
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 pipeline.event(purifyEvent(event), { source: 'relay', 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) {
@ -192,7 +199,6 @@ function connectStream(socket: WebSocket, ip: string | undefined, conf: DittoCon
/** Handle COUNT. Return the number of events matching the filters. */ /** Handle COUNT. Return the number of events matching the filters. */
async function handleCount([_, subId, ...filters]: NostrClientCOUNT): Promise<void> { async function handleCount([_, subId, ...filters]: NostrClientCOUNT): Promise<void> {
if (rateLimited(limiters.req)) return; if (rateLimited(limiters.req)) return;
const store = await Storages.db();
const { count } = await store.count(filters, { timeout: conf.db.timeouts.relay }); const { count } = await store.count(filters, { timeout: conf.db.timeouts.relay });
send(['COUNT', subId, { count, approximate: false }]); send(['COUNT', subId, { count, approximate: false }]);
} }
@ -235,7 +241,7 @@ const relayController: AppController = (c, next) => {
} }
const { socket, response } = Deno.upgradeWebSocket(c.req.raw, { idleTimeout: 30 }); const { socket, response } = Deno.upgradeWebSocket(c.req.raw, { idleTimeout: 30 });
connectStream(socket, ip, conf); connectStream(c.var, socket, ip);
return response; return response;
}; };

View file

@ -18,11 +18,9 @@ const nostrController: AppController = async (c) => {
return c.json(emptyResult); return c.json(emptyResult);
} }
const store = c.get('store');
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(store, name) : undefined; const pointer = name ? await localNip05Lookup(c.var, name) : undefined;
if (!name || !pointer) { if (!name || !pointer) {
// Not found, cache for 5 minutes. // Not found, cache for 5 minutes.

View file

@ -1,24 +1,28 @@
import { sql } from 'kysely'; import { DittoConf } from '@ditto/conf';
import { DittoTables } from '@ditto/db';
import { NStore } from '@nostrify/nostrify';
import { Kysely, sql } from 'kysely';
import { Storages } from '@/storages.ts'; import { DittoTrends } from '@/trends.ts';
import {
updateTrendingEvents, interface CronOpts {
updateTrendingHashtags, conf: DittoConf;
updateTrendingLinks, kysely: Kysely<DittoTables>;
updateTrendingPubkeys, store: NStore;
updateTrendingZappedEvents, }
} from '@/trends.ts';
/** Start cron jobs for the application. */ /** Start cron jobs for the application. */
export function cron() { export function cron(opts: CronOpts) {
Deno.cron('update trending pubkeys', '0 * * * *', updateTrendingPubkeys); const trends = new DittoTrends(opts);
Deno.cron('update trending zapped events', '7 * * * *', updateTrendingZappedEvents);
Deno.cron('update trending events', '15 * * * *', updateTrendingEvents); Deno.cron('update trending pubkeys', '0 * * * *', () => trends.updateTrendingPubkeys());
Deno.cron('update trending hashtags', '30 * * * *', updateTrendingHashtags); Deno.cron('update trending zapped events', '7 * * * *', () => trends.updateTrendingZappedEvents());
Deno.cron('update trending links', '45 * * * *', updateTrendingLinks); Deno.cron('update trending events', '15 * * * *', () => trends.updateTrendingEvents());
Deno.cron('update trending hashtags', '30 * * * *', () => trends.updateTrendingHashtags());
Deno.cron('update trending links', '45 * * * *', () => trends.updateTrendingLinks());
Deno.cron('refresh top authors', '20 * * * *', async () => { Deno.cron('refresh top authors', '20 * * * *', async () => {
const kysely = await Storages.kysely(); const { kysely } = opts;
await sql`refresh materialized view top_authors`.execute(kysely); await sql`refresh materialized view top_authors`.execute(kysely);
}); });
} }

View file

@ -1,32 +1,39 @@
import { firehoseEventsCounter } from '@ditto/metrics'; import { firehoseEventsCounter } from '@ditto/metrics';
import { Semaphore } from '@core/asyncutil'; import { Semaphore } from '@core/asyncutil';
import { NostrEvent, NRelay } 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 StartFirehoseOpts {
store: Pick<NRelay, 'req'>;
const sem = new Semaphore(Conf.firehoseConcurrency); kinds?: number[];
concurrency: 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(
const store = await Storages.client(); opts: StartFirehoseOpts,
onEvent: (event: NostrEvent) => Promise<void> | void,
): Promise<void> {
const { store, kinds, concurrency } = 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 store.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 onEvent(event);
} catch { } catch {
// Ignore // Ignore
} }

View file

@ -1,5 +0,0 @@
import { NostrEvent } from '@nostrify/nostrify';
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
/** Additional properties that may be added by Ditto to events. */
export type DittoRelation = Exclude<keyof DittoEvent, keyof NostrEvent>;

View file

@ -2,41 +2,15 @@ import { HTTPException } from '@hono/hono/http-exception';
import { NostrEvent } from '@nostrify/nostrify'; import { NostrEvent } from '@nostrify/nostrify';
import { type AppContext, type AppMiddleware } from '@/app.ts'; import { type AppContext, type AppMiddleware } from '@/app.ts';
import { ReadOnlySigner } from '@/signers/ReadOnlySigner.ts';
import { Storages } from '@/storages.ts';
import { localRequest } from '@/utils/api.ts'; import { localRequest } from '@/utils/api.ts';
import { import { buildAuthEventTemplate, type ParseAuthRequestOpts, validateAuthEvent } from '@/utils/nip98.ts';
buildAuthEventTemplate,
parseAuthRequest,
type ParseAuthRequestOpts,
validateAuthEvent,
} from '@/utils/nip98.ts';
/**
* NIP-98 auth.
* https://github.com/nostr-protocol/nips/blob/master/98.md
*/
function auth98Middleware(opts: ParseAuthRequestOpts = {}): AppMiddleware {
return async (c, next) => {
const req = localRequest(c);
const result = await parseAuthRequest(req, opts);
if (result.success) {
c.set('signer', new ReadOnlySigner(result.data.pubkey));
c.set('proof', result.data);
}
await next();
};
}
type UserRole = 'user' | 'admin'; type UserRole = 'user' | 'admin';
/** Require the user to prove their role before invoking the controller. */ /** Require the user to prove their role before invoking the controller. */
function requireRole(role: UserRole, opts?: ParseAuthRequestOpts): AppMiddleware { function requireRole(role: UserRole, opts?: ParseAuthRequestOpts): AppMiddleware {
return withProof(async (c, proof, next) => { return withProof(async (c, proof, next) => {
const { conf } = c.var; const { conf, store } = c.var;
const store = await Storages.db();
const [user] = await store.query([{ const [user] = await store.query([{
kinds: [30382], kinds: [30382],
@ -71,22 +45,8 @@ function withProof(
opts?: ParseAuthRequestOpts, opts?: ParseAuthRequestOpts,
): AppMiddleware { ): AppMiddleware {
return async (c, next) => { return async (c, next) => {
const signer = c.get('signer'); const proof = await obtainProof(c, opts);
const pubkey = await signer?.getPublicKey();
const proof = c.get('proof') || await obtainProof(c, opts);
// Prevent people from accidentally using the wrong account. This has no other security implications.
if (proof && pubkey && pubkey !== proof.pubkey) {
throw new HTTPException(401, { message: 'Pubkey mismatch' });
}
if (proof) { if (proof) {
c.set('proof', proof);
if (!signer) {
c.set('signer', new ReadOnlySigner(proof.pubkey));
}
await handler(c, proof, next); await handler(c, proof, next);
} else { } else {
throw new HTTPException(401, { message: 'No proof' }); throw new HTTPException(401, { message: 'No proof' });
@ -96,8 +56,9 @@ function withProof(
/** Get the proof over Nostr Connect. */ /** Get the proof over Nostr Connect. */
async function obtainProof(c: AppContext, opts?: ParseAuthRequestOpts) { async function obtainProof(c: AppContext, opts?: ParseAuthRequestOpts) {
const signer = c.get('signer'); const { user } = c.var;
if (!signer) {
if (!user) {
throw new HTTPException(401, { throw new HTTPException(401, {
res: c.json({ error: 'No way to sign Nostr event' }, 401), res: c.json({ error: 'No way to sign Nostr event' }, 401),
}); });
@ -105,7 +66,7 @@ async function obtainProof(c: AppContext, opts?: ParseAuthRequestOpts) {
const req = localRequest(c); const req = localRequest(c);
const reqEvent = await buildAuthEventTemplate(req, opts); const reqEvent = await buildAuthEventTemplate(req, opts);
const resEvent = await signer.signEvent(reqEvent); const resEvent = await user.signer.signEvent(reqEvent);
const result = await validateAuthEvent(req, resEvent, opts); const result = await validateAuthEvent(req, resEvent, opts);
if (result.success) { if (result.success) {
@ -113,4 +74,4 @@ async function obtainProof(c: AppContext, opts?: ParseAuthRequestOpts) {
} }
} }
export { auth98Middleware, requireProof, requireRole }; export { requireProof, requireRole };

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; let configDBCache: Promise<PleromaConfigDB> | undefined;
export const cspMiddleware = (): AppMiddleware => { export const cspMiddleware = (): AppMiddleware => {
return async (c, next) => { return async (c, next) => {
const { conf } = c.var; const { conf, store } = c.var;
const store = await Storages.db();
if (!configDBCache) { if (!configDBCache) {
configDBCache = getPleromaConfigs(store); configDBCache = getPleromaConfigs(conf, store);
} }
const { host, protocol, origin } = conf.url; const { host, protocol, origin } = conf.url;

View file

@ -1,9 +1,10 @@
import { AppMiddleware } from '@/app.ts'; import { AppMiddleware } from '@/app.ts';
import { paginationSchema } from '@/schemas/pagination.ts'; import { paginationSchema } from '@/schemas/pagination.ts';
import { Storages } from '@/storages.ts';
/** Fixes compatibility with Mastodon apps by that don't use `Link` headers. */ /** Fixes compatibility with Mastodon apps by that don't use `Link` headers. */
export const paginationMiddleware: AppMiddleware = async (c, next) => { export const paginationMiddleware: AppMiddleware = async (c, next) => {
const { store } = c.var;
const pagination = paginationSchema.parse(c.req.query()); const pagination = paginationSchema.parse(c.req.query());
const { const {
@ -20,8 +21,6 @@ export const paginationMiddleware: AppMiddleware = async (c, next) => {
if (minId) ids.push(minId); if (minId) ids.push(minId);
if (ids.length) { if (ids.length) {
const store = await Storages.db();
const events = await store.query( const events = await store.query(
[{ ids, limit: ids.length }], [{ ids, limit: ids.length }],
{ signal: c.req.raw.signal }, { signal: c.req.raw.signal },

View file

@ -4,8 +4,8 @@ import { NostrSigner } from '@nostrify/nostrify';
import { SetRequired } from 'type-fest'; import { SetRequired } from 'type-fest';
/** Throw a 401 if a signer isn't set. */ /** Throw a 401 if a signer isn't set. */
export const requireSigner: MiddlewareHandler<{ Variables: { signer: NostrSigner } }> = async (c, next) => { export const requireSigner: MiddlewareHandler<{ Variables: { user: { signer: NostrSigner } } }> = async (c, next) => {
if (!c.get('signer')) { if (!c.var.user) {
throw new HTTPException(401, { message: 'No pubkey provided' }); throw new HTTPException(401, { message: 'No pubkey provided' });
} }
@ -13,15 +13,16 @@ export const requireSigner: MiddlewareHandler<{ Variables: { signer: NostrSigner
}; };
/** Throw a 401 if a NIP-44 signer isn't set. */ /** Throw a 401 if a NIP-44 signer isn't set. */
export const requireNip44Signer: MiddlewareHandler<{ Variables: { signer: SetRequired<NostrSigner, 'nip44'> } }> = export const requireNip44Signer: MiddlewareHandler<
async (c, next) => { { Variables: { user: { signer: SetRequired<NostrSigner, 'nip44'> } } }
const signer = c.get('signer'); > = async (c, next) => {
const { user } = c.var;
if (!signer) { if (!user) {
throw new HTTPException(401, { message: 'No pubkey provided' }); throw new HTTPException(401, { message: 'No pubkey provided' });
} }
if (!signer.nip44) { if (!user.signer.nip44) {
throw new HTTPException(401, { message: 'No NIP-44 signer provided' }); throw new HTTPException(401, { message: 'No NIP-44 signer provided' });
} }

View file

@ -1,12 +1,13 @@
import { type DittoConf } from '@ditto/conf'; import { type DittoConf } from '@ditto/conf';
import { DittoTables } from '@ditto/db';
import { MiddlewareHandler } from '@hono/hono'; import { MiddlewareHandler } from '@hono/hono';
import { HTTPException } from '@hono/hono/http-exception'; import { HTTPException } from '@hono/hono/http-exception';
import { NostrSigner, NSecSigner } from '@nostrify/nostrify'; import { NostrSigner, NRelay, NSecSigner } from '@nostrify/nostrify';
import { Kysely } from 'kysely';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { ConnectSigner } from '@/signers/ConnectSigner.ts'; import { ConnectSigner } from '@/signers/ConnectSigner.ts';
import { ReadOnlySigner } from '@/signers/ReadOnlySigner.ts'; import { ReadOnlySigner } from '@/signers/ReadOnlySigner.ts';
import { Storages } from '@/storages.ts';
import { aesDecrypt } from '@/utils/aes.ts'; import { aesDecrypt } from '@/utils/aes.ts';
import { getTokenHash } from '@/utils/auth.ts'; import { getTokenHash } from '@/utils/auth.ts';
@ -14,11 +15,13 @@ import { getTokenHash } from '@/utils/auth.ts';
const BEARER_REGEX = new RegExp(`^Bearer (${nip19.BECH32_REGEX.source})$`); const BEARER_REGEX = new RegExp(`^Bearer (${nip19.BECH32_REGEX.source})$`);
/** Make a `signer` object available to all controllers, or unset if the user isn't logged in. */ /** Make a `signer` object available to all controllers, or unset if the user isn't logged in. */
export const signerMiddleware: MiddlewareHandler<{ Variables: { signer: NostrSigner; conf: DittoConf } }> = async ( export const signerMiddleware: MiddlewareHandler<
{ Variables: { signer: NostrSigner; conf: DittoConf; kysely: Kysely<DittoTables>; pubsub: NRelay } }
> = async (
c, c,
next, next,
) => { ) => {
const { conf } = c.var; const { conf, kysely } = c.var;
const header = c.req.header('authorization'); const header = c.req.header('authorization');
const match = header?.match(BEARER_REGEX); const match = header?.match(BEARER_REGEX);
@ -27,7 +30,6 @@ export const signerMiddleware: MiddlewareHandler<{ Variables: { signer: NostrSig
if (bech32.startsWith('token1')) { if (bech32.startsWith('token1')) {
try { try {
const kysely = await Storages.kysely();
const tokenHash = await getTokenHash(bech32 as `token1${string}`); const tokenHash = await getTokenHash(bech32 as `token1${string}`);
const { pubkey: userPubkey, bunker_pubkey: bunkerPubkey, nip46_sk_enc, nip46_relays } = await kysely const { pubkey: userPubkey, bunker_pubkey: bunkerPubkey, nip46_sk_enc, nip46_relays } = await kysely
@ -45,6 +47,7 @@ export const signerMiddleware: MiddlewareHandler<{ Variables: { signer: NostrSig
userPubkey, userPubkey,
signer: new NSecSigner(nep46Seckey), signer: new NSecSigner(nep46Seckey),
relays: nip46_relays, relays: nip46_relays,
relay: c.var.pubsub,
}), }),
); );
} catch { } catch {

View file

@ -1,28 +0,0 @@
import { MiddlewareHandler } from '@hono/hono';
import { NostrSigner, NStore } from '@nostrify/nostrify';
import { UserStore } from '@/storages/UserStore.ts';
import { Storages } from '@/storages.ts';
export const requireStore: MiddlewareHandler<{ Variables: { store: NStore } }> = async (c, next) => {
if (!c.get('store')) {
throw new Error('Store is required');
}
await next();
};
/** Store middleware. */
export const storeMiddleware: MiddlewareHandler<{ Variables: { signer?: NostrSigner; store: NStore } }> = async (
c,
next,
) => {
const pubkey = await c.get('signer')?.getPublicKey();
if (pubkey) {
const store = new UserStore(pubkey, await Storages.admin());
c.set('store', store);
} else {
c.set('store', await Storages.admin());
}
await next();
};

View file

@ -1,10 +1,9 @@
import { AppEnv } from '@/app.ts';
import { CashuMint, CashuWallet, getEncodedToken, type Proof } from '@cashu/cashu-ts'; import { CashuMint, CashuWallet, getEncodedToken, type Proof } from '@cashu/cashu-ts';
import { type DittoConf } from '@ditto/conf';
import { MiddlewareHandler } from '@hono/hono'; import { MiddlewareHandler } from '@hono/hono';
import { HTTPException } from '@hono/hono/http-exception'; import { HTTPException } from '@hono/hono/http-exception';
import { getPublicKey } from 'nostr-tools'; import { getPublicKey } from 'nostr-tools';
import { NostrFilter, NostrSigner, NSchema as n, NStore } from '@nostrify/nostrify'; import { NostrFilter, NSchema as n } from '@nostrify/nostrify';
import { SetRequired } from 'type-fest';
import { stringToBytes } from '@scure/base'; import { stringToBytes } from '@scure/base';
import { logi } from '@soapbox/logi'; import { logi } from '@soapbox/logi';
@ -17,33 +16,24 @@ import { z } from 'zod';
* Swap nutzaps into wallet (create new events) if the user has a wallet, otheriwse, just fallthrough. * Swap nutzaps into wallet (create new events) if the user has a wallet, otheriwse, just fallthrough.
* Errors are only thrown if 'signer' and 'store' middlewares are not set. * Errors are only thrown if 'signer' and 'store' middlewares are not set.
*/ */
export const swapNutzapsMiddleware: MiddlewareHandler< export const swapNutzapsMiddleware: MiddlewareHandler<AppEnv> = async (c, next) => {
{ Variables: { signer: SetRequired<NostrSigner, 'nip44'>; store: NStore; conf: DittoConf } } const { conf, store, user, signal } = c.var;
> = async (c, next) => {
const { conf } = c.var;
const signer = c.get('signer');
const store = c.get('store');
if (!signer) { if (!user) {
throw new HTTPException(401, { message: 'No pubkey provided' }); throw new HTTPException(401, { message: 'No pubkey provided' });
} }
if (!signer.nip44) { if (!user.signer.nip44) {
throw new HTTPException(401, { message: 'No NIP-44 signer provided' }); throw new HTTPException(401, { message: 'No NIP-44 signer provided' });
} }
if (!store) { const pubkey = await user.signer.getPublicKey();
throw new HTTPException(401, { message: 'No store provided' });
}
const { signal } = c.req.raw;
const pubkey = await signer.getPublicKey();
const [wallet] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal }); const [wallet] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal });
if (wallet) { if (wallet) {
let decryptedContent: string; let decryptedContent: string;
try { try {
decryptedContent = await signer.nip44.decrypt(pubkey, wallet.content); decryptedContent = await user.signer.nip44.decrypt(pubkey, wallet.content);
} catch (e) { } catch (e) {
logi({ logi({
level: 'error', level: 'error',
@ -152,24 +142,24 @@ export const swapNutzapsMiddleware: MiddlewareHandler<
const cashuWallet = new CashuWallet(new CashuMint(mint)); const cashuWallet = new CashuWallet(new CashuMint(mint));
const receiveProofs = await cashuWallet.receive(token, { privkey }); const receiveProofs = await cashuWallet.receive(token, { privkey });
const unspentProofs = await createEvent({ const unspentProofs = await createEvent({ ...c.var, user }, {
kind: 7375, kind: 7375,
content: await signer.nip44.encrypt( content: await user.signer.nip44.encrypt(
pubkey, pubkey,
JSON.stringify({ JSON.stringify({
mint, mint,
proofs: receiveProofs, proofs: receiveProofs,
}), }),
), ),
}, c); });
const amount = receiveProofs.reduce((accumulator, current) => { const amount = receiveProofs.reduce((accumulator, current) => {
return accumulator + current.amount; return accumulator + current.amount;
}, 0); }, 0);
await createEvent({ await createEvent({ ...c.var, user }, {
kind: 7376, kind: 7376,
content: await signer.nip44.encrypt( content: await user.signer.nip44.encrypt(
pubkey, pubkey,
JSON.stringify([ JSON.stringify([
['direction', 'in'], ['direction', 'in'],
@ -178,7 +168,7 @@ export const swapNutzapsMiddleware: MiddlewareHandler<
]), ]),
), ),
tags: mintsToProofs[mint].redeemed, tags: mintsToProofs[mint].redeemed,
}, c); });
} catch (e) { } catch (e) {
logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: errorJson(e) }); logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: errorJson(e) });
} }

View file

@ -8,7 +8,7 @@ import { S3Uploader } from '@/uploaders/S3Uploader.ts';
/** Set an uploader for the user. */ /** Set an uploader for the user. */
export const uploaderMiddleware: AppMiddleware = async (c, next) => { export const uploaderMiddleware: AppMiddleware = async (c, next) => {
const { signer, conf } = c.var; const { conf, user } = c.var;
switch (conf.uploader) { switch (conf.uploader) {
case 's3': case 's3':
@ -35,11 +35,14 @@ export const uploaderMiddleware: AppMiddleware = async (c, next) => {
c.set('uploader', new DenoUploader({ baseUrl: conf.mediaDomain, dir: conf.uploadsDir })); c.set('uploader', new DenoUploader({ baseUrl: conf.mediaDomain, dir: conf.uploadsDir }));
break; break;
case 'nostrbuild': case 'nostrbuild':
c.set('uploader', new NostrBuildUploader({ endpoint: conf.nostrbuildEndpoint, signer, fetch: safeFetch })); c.set(
'uploader',
new NostrBuildUploader({ endpoint: conf.nostrbuildEndpoint, signer: user?.signer, fetch: safeFetch }),
);
break; break;
case 'blossom': case 'blossom':
if (signer) { if (user) {
c.set('uploader', new BlossomUploader({ servers: conf.blossomServers, signer, fetch: safeFetch })); c.set('uploader', new BlossomUploader({ servers: conf.blossomServers, signer: user.signer, fetch: safeFetch }));
} }
break; break;
} }

View file

@ -1,19 +1,26 @@
import { Semaphore } from '@core/asyncutil'; import { Semaphore } from '@core/asyncutil';
import { DittoDatabase } from '@ditto/db';
import { pipelineEncounters } from '@/caches/pipelineEncounters.ts'; import { NStore } from '@nostrify/nostrify';
import { Conf } from '@/config.ts';
import * as pipeline from '@/pipeline.ts';
import { Storages } from '@/storages.ts';
import { logi } from '@soapbox/logi'; import { logi } from '@soapbox/logi';
const sem = new Semaphore(1); import type { DittoConf } from '@ditto/conf';
import type { DittoPipeline } from '@/DittoPipeline.ts';
export async function startNotify(): Promise<void> { interface StartNotifyOpts {
const { listen } = await Storages.database(); db: DittoDatabase;
const store = await Storages.db(); store: NStore;
conf: DittoConf;
pipeline: DittoPipeline;
concurrency?: number;
}
listen('nostr_event', (id) => { export function startNotify(opts: StartNotifyOpts): void {
if (pipelineEncounters.has(id)) { const { conf, db, store, pipeline, concurrency = 1 } = opts;
const sem = new Semaphore(concurrency);
db.listen('nostr_event', (id) => {
if (pipeline.encounters.has(id)) {
logi({ level: 'debug', ns: 'ditto.notify', id, skipped: true }); logi({ level: 'debug', ns: 'ditto.notify', id, skipped: true });
return; return;
} }
@ -22,13 +29,13 @@ export async function startNotify(): Promise<void> {
sem.lock(async () => { sem.lock(async () => {
try { try {
const signal = AbortSignal.timeout(Conf.db.timeouts.default); const signal = AbortSignal.timeout(conf.db.timeouts.default);
const [event] = await store.query([{ ids: [id], limit: 1 }], { signal }); const [event] = await store.query([{ ids: [id], limit: 1 }], { signal });
if (event) { if (event) {
logi({ level: 'debug', ns: 'ditto.event', source: 'notify', id: event.id, kind: event.kind }); logi({ level: 'debug', ns: 'ditto.event', source: 'notify', id: event.id, kind: event.kind });
await pipeline.handleEvent(event, { source: 'notify', signal }); await pipeline.event(event, { source: 'notify', signal });
} }
} catch { } catch {
// Ignore // Ignore

View file

@ -1,401 +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 { AdminSigner } from '@/signers/AdminSigner.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) {
await streamOut(event);
return;
}
// Ensure the event doesn't violate the policy.
if (event.pubkey !== Conf.pubkey) {
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');
}
// Ephemeral events must throw if they are not streamed out.
if (NKinds.ephemeral(event.kind)) {
await Promise.all([
streamOut(event),
webPush(event),
]);
return;
}
// Events received through notify are thought to already be in the database, so they only need to be streamed.
if (opts.source === 'notify') {
await Promise.all([
streamOut(event),
webPush(event),
]);
return;
}
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(() =>
Promise.allSettled([
streamOut(event),
webPush(event),
])
);
}
}
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], store: await Storages.db(), signal });
}
/** Maybe store the event, if eligible. */
async function storeEvent(event: NostrEvent, signal?: AbortSignal): Promise<undefined> {
if (NKinds.ephemeral(event.kind)) return;
const store = await Storages.db();
try {
await store.transaction(async (store, kysely) => {
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.toLowerCase(), { 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);
}
/** Distribute the event through active subscriptions. */
async function streamOut(event: NostrEvent): Promise<void> {
if (!isFresh(event)) {
throw new RelayError('invalid', 'event too old');
}
const pubsub = await Storages.pubsub();
await pubsub.event(event);
}
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 tagsAdmin = event.tags.some(([name, value]) => ['p', 'P'].includes(name) && value === Conf.pubkey);
if (event.kind === 1984 && tagsAdmin) {
const signer = new AdminSigner();
const rel = await signer.signEvent({
kind: 30383,
content: '',
tags: [
['d', event.id],
['p', event.pubkey],
['k', '1984'],
['n', 'open'],
...[...getTagSet(event.tags, 'p')].map((pubkey) => ['P', pubkey]),
...[...getTagSet(event.tags, 'e')].map((pubkey) => ['e', pubkey]),
],
created_at: Math.floor(Date.now() / 1000),
});
await handleEvent(rel, { source: 'pipeline', signal: AbortSignal.timeout(1000) });
}
if (event.kind === 3036 && tagsAdmin) {
const signer = new AdminSigner();
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,22 +0,0 @@
import { Conf } from '@/config.ts';
/** Ensure the media URL is not on the same host as the local domain. */
function checkMediaHost() {
const { url, mediaDomain } = Conf;
const mediaUrl = new URL(mediaDomain);
if (url.host === mediaUrl.host) {
throw new PrecheckError('For security reasons, MEDIA_DOMAIN cannot be on the same host as LOCAL_DOMAIN.');
}
}
/** Error class for precheck errors. */
class PrecheckError extends Error {
constructor(message: string) {
super(`${message}\nTo disable this check, set DITTO_PRECHECK="false"`);
}
}
if (Deno.env.get('DITTO_PRECHECK') !== 'false') {
checkMediaHost();
}

View file

@ -1,76 +1,60 @@
import { DittoConf } from '@ditto/conf';
import { DittoTables } from '@ditto/db';
import { NostrEvent, NostrFilter, NStore } from '@nostrify/nostrify'; import { NostrEvent, NostrFilter, NStore } from '@nostrify/nostrify';
import { Kysely } from 'kysely';
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 { type DittoRelation } from '@/interfaces/DittoFilter.ts';
import { hydrateEvents } from '@/storages/hydrate.ts'; import { hydrateEvents } from '@/storages/hydrate.ts';
import { fallbackAuthor } from '@/utils.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. */ conf: DittoConf;
store: NStore;
kysely: Kysely<DittoTables>;
signal?: AbortSignal; signal?: AbortSignal;
/** Event kind. */
kind?: number;
/** @deprecated Relations to include on the event. */
relations?: DittoRelation[];
} }
/** /** Get a Nostr event by its ID. */
* Get a Nostr event by its ID. async function getEvent(opts: GetEventOpts, id: string): Promise<DittoEvent | undefined> {
* @deprecated Use `store.query` directly. const { store, signal } = opts;
*/
const getEvent = async (
id: string,
opts: GetEventOpts = {},
): Promise<DittoEvent | undefined> => {
const store = 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) {
filter.kinds = [kind]; return await store.query([{ ...filter, limit: 1 }], { signal })
.then((events) => hydrateEvents(opts, events))
.then(([event]) => event);
} }
return await store.query([filter], { limit: 1, signal }) /** Get a Nostr `set_medatadata` event for a user's pubkey. */
.then((events) => hydrateEvents({ events, store, signal })) async function getAuthor(opts: GetEventOpts, pubkey: string): Promise<NostrEvent | undefined> {
.then(([event]) => event); const { store, signal } = opts;
};
/** const events = await store.query([{ authors: [pubkey], kinds: [0], limit: 1 }], { signal });
* Get a Nostr `set_medatadata` event for a user's pubkey.
* @deprecated Use `store.query` directly.
*/
async function getAuthor(pubkey: string, opts: GetEventOpts = {}): Promise<NostrEvent | undefined> {
const store = await Storages.db();
const { signal = AbortSignal.timeout(1000) } = opts;
const events = await store.query([{ authors: [pubkey], kinds: [0], limit: 1 }], { limit: 1, signal });
const event = events[0] ?? fallbackAuthor(pubkey); const event = events[0] ?? fallbackAuthor(pubkey);
await hydrateEvents({ events: [event], store, signal }); await hydrateEvents(opts, [event]);
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> => { async function getFollows(opts: GetEventOpts, pubkey: string): Promise<NostrEvent | undefined> {
const store = await Storages.db(); const { store, signal } = opts;
const [event] = await store.query([{ authors: [pubkey], kinds: [3], limit: 1 }], { limit: 1, signal }); const [event] = await store.query([{ authors: [pubkey], kinds: [3], 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(opts: GetEventOpts, pubkey: string): Promise<Set<string>> {
const event = await getFollows(pubkey, signal); const event = await getFollows(opts, pubkey);
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(opts: GetEventOpts, pubkey: string): Promise<Set<string>> {
const authors = await getFollowedPubkeys(pubkey); const authors = await getFollowedPubkeys(opts, pubkey);
return authors.add(pubkey); return authors.add(pubkey);
} }
@ -103,14 +87,11 @@ async function getDescendants(
} }
/** Returns whether the pubkey is followed by a local user. */ /** Returns whether the pubkey is followed by a local user. */
async function isLocallyFollowed(pubkey: string): Promise<boolean> { async function isLocallyFollowed(conf: DittoConf, store: NStore, pubkey: string): Promise<boolean> {
const { host } = Conf.url; const { host } = conf.url;
const store = await Storages.db();
const [event] = await store.query( const [event] = await store.query(
[{ kinds: [3], '#p': [pubkey], search: `domain:${host}`, limit: 1 }], [{ kinds: [3], '#p': [pubkey], search: `domain:${host}`, limit: 1 }],
{ limit: 1 },
); );
return Boolean(event); return Boolean(event);

View file

@ -1,15 +1,15 @@
import { DittoConf } from '@ditto/conf';
import * as Sentry from '@sentry/deno'; import * as Sentry from '@sentry/deno';
import { logi } from '@soapbox/logi'; import { logi } from '@soapbox/logi';
import { Conf } from '@/config.ts'; export function startSentry(conf: DittoConf): void {
if (conf.sentryDsn) {
// Sentry
if (Conf.sentryDsn) {
logi({ level: 'info', ns: 'ditto.sentry', msg: 'Sentry enabled.', enabled: true }); logi({ level: 'info', ns: 'ditto.sentry', msg: 'Sentry enabled.', enabled: true });
Sentry.init({ Sentry.init({
dsn: Conf.sentryDsn, dsn: conf.sentryDsn,
tracesSampleRate: 1.0, tracesSampleRate: 1.0,
}); });
} else { } else {
logi({ level: 'info', ns: 'ditto.sentry', msg: 'Sentry not configured. Skipping.', enabled: false }); logi({ level: 'info', ns: 'ditto.sentry', msg: 'Sentry not configured. Skipping.', enabled: false });
} }
}

View file

@ -1,13 +1,28 @@
import { DittoConf } from '@ditto/conf';
import { logi } from '@soapbox/logi'; import { logi } from '@soapbox/logi';
import '@/precheck.ts'; import { startSentry } from '@/sentry.ts';
import '@/sentry.ts';
import '@/nostr-wasm.ts'; import '@/nostr-wasm.ts';
import app from '@/app.ts'; import app from '@/app.ts';
import { Conf } from '@/config.ts';
const conf = new DittoConf(Deno.env);
startSentry(conf);
// Ensure the media URL is not on the same host as the local domain.
if (conf.precheck) {
const { url, mediaDomain } = conf;
const mediaUrl = new URL(mediaDomain);
if (url.host === mediaUrl.host) {
throw new Error(
'For security reasons, MEDIA_DOMAIN cannot be on the same host as LOCAL_DOMAIN. To disable this check, set DITTO_PRECHECK="false"',
);
}
}
Deno.serve({ Deno.serve({
port: Conf.port, port: conf.port,
onListen({ hostname, port }): void { onListen({ hostname, port }): void {
logi({ level: 'info', ns: 'ditto.server', msg: `Listening on http://${hostname}:${port}`, hostname, port }); logi({ level: 'info', ns: 'ditto.server', msg: `Listening on http://${hostname}:${port}`, hostname, port });
}, },

View file

@ -1,9 +1,10 @@
import { NSecSigner } from '@nostrify/nostrify'; import { NSecSigner } from '@nostrify/nostrify';
import { Conf } from '@/config.ts';
import type { DittoConf } from '@ditto/conf';
/** Sign events as the Ditto server. */ /** Sign events as the Ditto server. */
export class AdminSigner extends NSecSigner { export class AdminSigner extends NSecSigner {
constructor() { constructor(conf: DittoConf) {
super(Conf.seckey); super(conf.seckey);
} }
} }

View file

@ -1,10 +1,9 @@
// 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 {
relay: NRelay;
bunkerPubkey: string; bunkerPubkey: string;
userPubkey: string; userPubkey: string;
signer: NostrSigner; signer: NostrSigner;
@ -27,8 +26,7 @@ export class ConnectSigner implements NostrSigner {
return 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: this.opts.relay,
relay: await Storages.pubsub(),
signer, signer,
timeout: 60_000, timeout: 60_000,
}); });

View file

@ -1,17 +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';
import { startNotify } from '@/notify.ts';
if (Conf.firehoseEnabled) {
startFirehose();
}
if (Conf.notifyEnabled) {
startNotify();
}
if (Conf.cronEnabled) {
cron();
}

View file

@ -1,24 +1,25 @@
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 { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { getTagSet } from '@/utils/tags.ts'; import { getTagSet } from '@/utils/tags.ts';
/** A store that prevents banned users from being displayed. */ /** A store that prevents banned users from being displayed. */
export class AdminStore implements NStore { export class AdminStore implements NStore {
constructor(private store: NStore) {} constructor(private conf: DittoConf, private store: NStore) {}
async event(event: NostrEvent, opts?: { signal?: AbortSignal }): Promise<void> { async event(event: NostrEvent, opts?: { signal?: AbortSignal }): Promise<void> {
return await this.store.event(event, opts); return await this.store.event(event, opts);
} }
async query(filters: NostrFilter[], opts: { signal?: AbortSignal; limit?: number } = {}): Promise<DittoEvent[]> { async query(filters: NostrFilter[], opts: { signal?: AbortSignal; limit?: number } = {}): Promise<DittoEvent[]> {
const { conf } = this;
const events = await this.store.query(filters, opts); const events = await this.store.query(filters, opts);
const pubkeys = new Set(events.map((event) => event.pubkey)); const pubkeys = new Set(events.map((event) => event.pubkey));
const users = await this.store.query([{ const users = await this.store.query([{
kinds: [30382], kinds: [30382],
authors: [Conf.pubkey], authors: [conf.pubkey],
'#d': [...pubkeys], '#d': [...pubkeys],
limit: pubkeys.size, limit: pubkeys.size,
}]); }]);
@ -26,7 +27,7 @@ export class AdminStore implements NStore {
return events.filter((event) => { return events.filter((event) => {
const user = users.find( const user = users.find(
({ kind, pubkey, tags }) => ({ kind, pubkey, tags }) =>
kind === 30382 && pubkey === Conf.pubkey && tags.find(([name]) => name === 'd')?.[1] === event.pubkey, kind === 30382 && pubkey === conf.pubkey && tags.find(([name]) => name === 'd')?.[1] === event.pubkey,
); );
const n = getTagSet(user?.tags ?? [], 'n'); const n = getTagSet(user?.tags ?? [], 'n');

View file

@ -1,9 +1,9 @@
import { DittoConf } from '@ditto/conf';
import { assertEquals, assertRejects } from '@std/assert'; import { assertEquals, assertRejects } from '@std/assert';
import { generateSecretKey } from 'nostr-tools'; import { generateSecretKey, nip19 } from 'nostr-tools';
import { RelayError } from '@/RelayError.ts'; import { RelayError } from '@/RelayError.ts';
import { eventFixture, genEvent } from '@/test.ts'; import { eventFixture, genEvent } from '@/test.ts';
import { Conf } from '@/config.ts';
import { EventsDB } from '@/storages/EventsDB.ts'; import { EventsDB } from '@/storages/EventsDB.ts';
import { createTestDB } from '@/test.ts'; import { createTestDB } from '@/test.ts';
@ -122,7 +122,10 @@ Deno.test("user cannot delete another user's event", async () => {
}); });
Deno.test('admin can delete any event', async () => { Deno.test('admin can delete any event', async () => {
await using db = await createTestDB({ pure: true }); const admin = generateSecretKey();
const conf = new DittoConf(new Map([['DITTO_NSEC', nip19.nsecEncode(admin)]]));
await using db = await createTestDB({ conf, pure: true });
const { store } = db; const { store } = db;
const sk = generateSecretKey(); const sk = generateSecretKey();
@ -139,20 +142,23 @@ Deno.test('admin can delete any event', async () => {
assertEquals(await store.query([{ kinds: [1] }]), [two, one]); assertEquals(await store.query([{ kinds: [1] }]), [two, one]);
await store.event( await store.event(
genEvent({ kind: 5, tags: [['e', one.id]] }, Conf.seckey), // admin sk genEvent({ kind: 5, tags: [['e', one.id]] }, conf.seckey), // admin sk
); );
assertEquals(await store.query([{ kinds: [1] }]), [two]); assertEquals(await store.query([{ kinds: [1] }]), [two]);
}); });
Deno.test('throws a RelayError when inserting an event deleted by the admin', async () => { Deno.test('throws a RelayError when inserting an event deleted by the admin', async () => {
await using db = await createTestDB({ pure: true }); const admin = generateSecretKey();
const conf = new DittoConf(new Map([['DITTO_NSEC', nip19.nsecEncode(admin)]]));
await using db = await createTestDB({ conf, pure: true });
const { store } = db; const { store } = db;
const event = genEvent(); const event = genEvent();
await store.event(event); await store.event(event);
const deletion = genEvent({ kind: 5, tags: [['e', event.id]] }, Conf.seckey); const deletion = genEvent({ kind: 5, tags: [['e', event.id]] }, conf.seckey);
await store.event(deletion); await store.event(deletion);
await assertRejects( await assertRejects(

View file

@ -1,6 +1,10 @@
import { DittoConf } from '@ditto/conf';
import { assembleEvents } from '@/storages/hydrate.ts'; import { assembleEvents } from '@/storages/hydrate.ts';
import { jsonlEvents } from '@/test.ts'; import { jsonlEvents } from '@/test.ts';
const conf = new DittoConf(new Map());
const testEvents = await jsonlEvents('fixtures/hydrated.jsonl'); const testEvents = await jsonlEvents('fixtures/hydrated.jsonl');
const testStats = JSON.parse(await Deno.readTextFile('fixtures/stats.json')); const testStats = JSON.parse(await Deno.readTextFile('fixtures/stats.json'));
@ -9,5 +13,5 @@ const testStats = JSON.parse(await Deno.readTextFile('fixtures/stats.json'));
const events = testEvents.slice(0, 20); const events = testEvents.slice(0, 20);
Deno.bench('assembleEvents with home feed', () => { Deno.bench('assembleEvents with home feed', () => {
assembleEvents(events, testEvents, testStats); assembleEvents(conf, events, testEvents, testStats);
}); });

View file

@ -1,4 +1,3 @@
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';
@ -6,29 +5,25 @@ import { hydrateEvents } from '@/storages/hydrate.ts';
import { createTestDB, eventFixture } from '@/test.ts'; import { createTestDB, eventFixture } from '@/test.ts';
Deno.test('hydrateEvents(): author --- WITHOUT stats', async () => { Deno.test('hydrateEvents(): author --- WITHOUT stats', async () => {
const relay = new MockRelay();
await using db = await createTestDB(); await using db = await createTestDB();
const { store } = db;
const event0 = await eventFixture('event-0'); const event0 = await eventFixture('event-0');
const event1 = await eventFixture('event-1'); const event1 = await eventFixture('event-1');
// Save events to database // Save events to database
await relay.event(event0); await store.event(event0);
await relay.event(event1); await store.event(event1);
await hydrateEvents({ await hydrateEvents(db, [event1]);
events: [event1],
store: 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();
await using db = await createTestDB(); await using db = await createTestDB();
const { store } = db;
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');
@ -36,16 +31,12 @@ Deno.test('hydrateEvents(): repost --- WITHOUT stats', async () => {
const event6 = await eventFixture('event-6'); const event6 = await eventFixture('event-6');
// Save events to database // Save events to database
await relay.event(event0madePost); await store.event(event0madePost);
await relay.event(event0madeRepost); await store.event(event0madeRepost);
await relay.event(event1reposted); await store.event(event1reposted);
await relay.event(event6); await store.event(event6);
await hydrateEvents({ await hydrateEvents(db, [event6]);
events: [event6],
store: relay,
kysely: db.kysely,
});
const expectedEvent6 = { const expectedEvent6 = {
...event6, ...event6,
@ -56,8 +47,8 @@ Deno.test('hydrateEvents(): repost --- WITHOUT stats', async () => {
}); });
Deno.test('hydrateEvents(): quote repost --- WITHOUT stats', async () => { Deno.test('hydrateEvents(): quote repost --- WITHOUT stats', async () => {
const relay = new MockRelay();
await using db = await createTestDB(); await using db = await createTestDB();
const { store } = db;
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');
@ -65,16 +56,12 @@ Deno.test('hydrateEvents(): quote repost --- WITHOUT stats', async () => {
const event1willBeQuoteReposted = await eventFixture('event-1-that-will-be-quote-reposted'); const event1willBeQuoteReposted = await eventFixture('event-1-that-will-be-quote-reposted');
// Save events to database // Save events to database
await relay.event(event0madeQuoteRepost); await store.event(event0madeQuoteRepost);
await relay.event(event0); await store.event(event0);
await relay.event(event1quoteRepost); await store.event(event1quoteRepost);
await relay.event(event1willBeQuoteReposted); await store.event(event1willBeQuoteReposted);
await hydrateEvents({ await hydrateEvents(db, [event1quoteRepost]);
events: [event1quoteRepost],
store: relay,
kysely: db.kysely,
});
const expectedEvent1quoteRepost = { const expectedEvent1quoteRepost = {
...event1quoteRepost, ...event1quoteRepost,
@ -86,8 +73,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();
await using db = await createTestDB(); await using db = await createTestDB();
const { store } = db;
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');
@ -95,16 +82,12 @@ Deno.test('hydrateEvents(): repost of quote repost --- WITHOUT stats', async ()
const event1quote = await eventFixture('event-1-quote-repost-will-be-reposted'); const event1quote = await eventFixture('event-1-quote-repost-will-be-reposted');
// Save events to database // Save events to database
await relay.event(author); await store.event(author);
await relay.event(event1); await store.event(event1);
await relay.event(event1quote); await store.event(event1quote);
await relay.event(event6); await store.event(event6);
await hydrateEvents({ await hydrateEvents(db, [event6]);
events: [event6],
store: relay,
kysely: db.kysely,
});
const expectedEvent6 = { const expectedEvent6 = {
...event6, ...event6,
@ -115,8 +98,8 @@ Deno.test('hydrateEvents(): repost of quote repost --- WITHOUT stats', async ()
}); });
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();
await using db = await createTestDB(); await using db = await createTestDB();
const { store } = db;
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');
@ -124,16 +107,12 @@ Deno.test('hydrateEvents(): report pubkey and post // kind 1984 --- WITHOUT stat
const event1 = await eventFixture('kind-1-author-george-orwell'); const event1 = await eventFixture('kind-1-author-george-orwell');
// Save events to database // Save events to database
await relay.event(authorDictator); await store.event(authorDictator);
await relay.event(authorVictim); await store.event(authorVictim);
await relay.event(reportEvent); await store.event(reportEvent);
await relay.event(event1); await store.event(event1);
await hydrateEvents({ await hydrateEvents(db, [reportEvent]);
events: [reportEvent],
store: relay,
kysely: db.kysely,
});
const expectedEvent: DittoEvent = { const expectedEvent: DittoEvent = {
...reportEvent, ...reportEvent,
@ -145,8 +124,8 @@ Deno.test('hydrateEvents(): report pubkey and post // kind 1984 --- WITHOUT stat
}); });
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();
await using db = await createTestDB(); await using db = await createTestDB();
const { store } = db;
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');
@ -154,16 +133,12 @@ Deno.test('hydrateEvents(): zap sender, zap amount, zapped post // kind 9735 ---
const zapReceiver = await eventFixture('kind-0-patrick'); const zapReceiver = await eventFixture('kind-0-patrick');
// Save events to database // Save events to database
await relay.event(zapSender); await store.event(zapSender);
await relay.event(zapReceipt); await store.event(zapReceipt);
await relay.event(zappedPost); await store.event(zappedPost);
await relay.event(zapReceiver); await store.event(zapReceiver);
await hydrateEvents({ await hydrateEvents(db, [zapReceipt]);
events: [zapReceipt],
store: relay,
kysely: db.kysely,
});
const expectedEvent: DittoEvent = { const expectedEvent: DittoEvent = {
...zapReceipt, ...zapReceipt,

View file

@ -1,3 +1,4 @@
import { DittoConf } from '@ditto/conf';
import { DittoTables } from '@ditto/db'; import { DittoTables } from '@ditto/db';
import { NStore } from '@nostrify/nostrify'; import { NStore } from '@nostrify/nostrify';
import { Kysely } from 'kysely'; import { Kysely } from 'kysely';
@ -5,24 +6,22 @@ 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[]; conf: DittoConf;
store: NStore; store: NStore;
kysely: Kysely<DittoTables>;
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, events: DittoEvent[]): Promise<DittoEvent[]> {
const { events, store, signal, kysely = await Storages.kysely() } = opts; const { conf, kysely } = opts;
if (!events.length) { if (!events.length) {
return events; return events;
@ -30,23 +29,23 @@ async function hydrateEvents(opts: HydrateOpts): Promise<DittoEvent[]> {
const cache = [...events]; const cache = [...events];
for (const event of await gatherRelatedEvents({ events: cache, store, signal })) { for (const event of await gatherRelatedEvents(opts, cache)) {
cache.push(event); cache.push(event);
} }
for (const event of await gatherQuotes({ events: cache, store, signal })) { for (const event of await gatherQuotes(opts, cache)) {
cache.push(event); cache.push(event);
} }
for (const event of await gatherProfiles({ events: cache, store, signal })) { for (const event of await gatherProfiles(opts, cache)) {
cache.push(event); cache.push(event);
} }
for (const event of await gatherUsers({ events: cache, store, signal })) { for (const event of await gatherUsers(opts, cache)) {
cache.push(event); cache.push(event);
} }
for (const event of await gatherInfo({ events: cache, store, signal })) { for (const event of await gatherInfo(opts, cache)) {
cache.push(event); cache.push(event);
} }
@ -80,14 +79,15 @@ async function hydrateEvents(opts: HydrateOpts): Promise<DittoEvent[]> {
const results = [...new Map(cache.map((event) => [event.id, event])).values()]; const results = [...new Map(cache.map((event) => [event.id, event])).values()];
// 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(results, results, stats); assembleEvents(conf, results, results, stats);
assembleEvents(events, results, stats); assembleEvents(conf, events, results, stats);
return events; return events;
} }
/** Connect the events in list `b` to the DittoEvent fields in list `a`. */ /** Connect the events in list `b` to the DittoEvent fields in list `a`. */
export function assembleEvents( export function assembleEvents(
conf: DittoConf,
a: DittoEvent[], a: DittoEvent[],
b: DittoEvent[], b: DittoEvent[],
stats: { stats: {
@ -96,7 +96,7 @@ export function assembleEvents(
favicons: Record<string, string>; favicons: Record<string, string>;
}, },
): DittoEvent[] { ): DittoEvent[] {
const admin = Conf.pubkey; const admin = conf.pubkey;
const authorStats = stats.authors.reduce((result, { pubkey, ...stat }) => { const authorStats = stats.authors.reduce((result, { pubkey, ...stat }) => {
result[pubkey] = { result[pubkey] = {
@ -198,7 +198,7 @@ export function assembleEvents(
} }
/** Collect event targets (eg reposts, quote posts, reacted posts, etc.) */ /** Collect event targets (eg reposts, quote posts, reacted posts, etc.) */
function gatherRelatedEvents({ events, store, signal }: HydrateOpts): Promise<DittoEvent[]> { function gatherRelatedEvents({ store, signal }: HydrateOpts, events: DittoEvent[]): Promise<DittoEvent[]> {
const ids = new Set<string>(); const ids = new Set<string>();
for (const event of events) { for (const event of events) {
@ -240,7 +240,7 @@ function gatherRelatedEvents({ events, store, signal }: HydrateOpts): Promise<Di
} }
/** Collect quotes from the events. */ /** Collect quotes from the events. */
function gatherQuotes({ events, store, signal }: HydrateOpts): Promise<DittoEvent[]> { function gatherQuotes({ store, signal }: HydrateOpts, events: DittoEvent[]): Promise<DittoEvent[]> {
const ids = new Set<string>(); const ids = new Set<string>();
for (const event of events) { for (const event of events) {
@ -259,7 +259,7 @@ function gatherQuotes({ events, store, signal }: HydrateOpts): Promise<DittoEven
} }
/** Collect profiles from the events. */ /** Collect profiles from the events. */
async function gatherProfiles({ events, store, signal }: HydrateOpts): Promise<DittoEvent[]> { async function gatherProfiles({ store, signal }: HydrateOpts, events: DittoEvent[]): Promise<DittoEvent[]> {
const pubkeys = new Set<string>(); const pubkeys = new Set<string>();
for (const event of events) { for (const event of events) {
@ -316,7 +316,7 @@ async function gatherProfiles({ events, store, signal }: HydrateOpts): Promise<D
} }
/** Collect users from the events. */ /** Collect users from the events. */
function gatherUsers({ events, store, signal }: HydrateOpts): Promise<DittoEvent[]> { function gatherUsers({ conf, store, signal }: HydrateOpts, events: DittoEvent[]): 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) {
@ -324,13 +324,13 @@ function gatherUsers({ events, store, signal }: HydrateOpts): Promise<DittoEvent
} }
return store.query( return store.query(
[{ kinds: [30382], authors: [Conf.pubkey], '#d': [...pubkeys], limit: pubkeys.size }], [{ kinds: [30382], authors: [conf.pubkey], '#d': [...pubkeys], limit: pubkeys.size }],
{ signal }, { signal },
); );
} }
/** Collect info events from the events. */ /** Collect info events from the events. */
function gatherInfo({ events, store, signal }: HydrateOpts): Promise<DittoEvent[]> { function gatherInfo({ conf, store, signal }: HydrateOpts, events: DittoEvent[]): Promise<DittoEvent[]> {
const ids = new Set<string>(); const ids = new Set<string>();
for (const event of events) { for (const event of events) {
@ -344,7 +344,7 @@ function gatherInfo({ events, store, signal }: HydrateOpts): Promise<DittoEvent[
} }
return store.query( return store.query(
[{ kinds: [30383], authors: [Conf.pubkey], '#d': [...ids], limit: ids.size }], [{ kinds: [30383], authors: [conf.pubkey], '#d': [...ids], limit: ids.size }],
{ signal }, { signal },
); );
} }

View file

@ -1,60 +0,0 @@
import { NostrEvent, NostrFilter, NRelay1, NStore } from '@nostrify/nostrify';
import { logi } from '@soapbox/logi';
import { JsonValue } from '@std/json';
import { normalizeFilters } from '@/filter.ts';
import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
import { hydrateEvents } from '@/storages/hydrate.ts';
import { abortError } from '@/utils/abort.ts';
interface SearchStoreOpts {
relay: string | undefined;
fallback: NStore;
hydrator?: NStore;
}
class SearchStore implements NStore {
#fallback: NStore;
#hydrator: NStore;
#relay: NRelay1 | undefined;
constructor(opts: SearchStoreOpts) {
this.#fallback = opts.fallback;
this.#hydrator = opts.hydrator ?? this;
if (opts.relay) {
this.#relay = new NRelay1(opts.relay);
}
}
event(_event: NostrEvent, _opts?: { signal?: AbortSignal }): Promise<void> {
return Promise.reject(new Error('EVENT not implemented.'));
}
async query(filters: NostrFilter[], opts?: { signal?: AbortSignal; limit?: number }): Promise<DittoEvent[]> {
filters = normalizeFilters(filters);
if (opts?.signal?.aborted) return Promise.reject(abortError());
if (!filters.length) return Promise.resolve([]);
logi({ level: 'debug', ns: 'ditto.req', source: 'search', filters: filters as JsonValue });
const query = filters[0]?.search;
if (this.#relay && this.#relay.socket.readyState === WebSocket.OPEN) {
logi({ level: 'debug', ns: 'ditto.search', query, source: 'relay', relay: this.#relay.socket.url });
const events = await this.#relay.query(filters, opts);
return hydrateEvents({
events,
store: this.#hydrator,
signal: opts?.signal,
});
} else {
logi({ level: 'debug', ns: 'ditto.search', query, source: 'db' });
return this.#fallback.query(filters, opts);
}
}
}
export { SearchStore };

View file

@ -1,10 +1,10 @@
import { DittoConf } from '@ditto/conf';
import { DittoDB } from '@ditto/db'; import { DittoDB } from '@ditto/db';
import ISO6391, { LanguageCode } from 'iso-639-1'; import ISO6391, { LanguageCode } from 'iso-639-1';
import lande from 'lande'; import lande from 'lande';
import { NostrEvent } from '@nostrify/nostrify'; import { NostrEvent } from '@nostrify/nostrify';
import { finalizeEvent, generateSecretKey } from 'nostr-tools'; import { finalizeEvent, generateSecretKey, nip19 } from 'nostr-tools';
import { Conf } from '@/config.ts';
import { EventsDB } from '@/storages/EventsDB.ts'; import { EventsDB } from '@/storages/EventsDB.ts';
import { purifyEvent } from '@/utils/purify.ts'; import { purifyEvent } from '@/utils/purify.ts';
import { sql } from 'kysely'; import { sql } from 'kysely';
@ -35,19 +35,21 @@ export function genEvent(t: Partial<NostrEvent> = {}, sk: Uint8Array = generateS
} }
/** 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?: { conf?: DittoConf; pure?: boolean }) {
const { kysely } = DittoDB.create(Conf.databaseUrl, { poolSize: 1 }); const conf = opts?.conf ?? testConf();
const { kysely } = DittoDB.create(conf.databaseUrl, { poolSize: 1 });
await DittoDB.migrate(kysely); await DittoDB.migrate(kysely);
const store = new EventsDB({ const store = new EventsDB({
kysely, kysely,
timeout: Conf.db.timeouts.default, timeout: conf.db.timeouts.default,
pubkey: Conf.pubkey, pubkey: conf.pubkey,
pure: opts?.pure ?? false, pure: opts?.pure ?? false,
}); });
return { return {
conf,
store, store,
kysely, kysely,
[Symbol.asyncDispose]: async () => { [Symbol.asyncDispose]: async () => {
@ -65,6 +67,15 @@ export async function createTestDB(opts?: { pure?: boolean }) {
}; };
} }
export function testConf(): DittoConf {
const env = new Map<string, string>();
env.set('DITTO_NSEC', nip19.nsecEncode(generateSecretKey()));
env.set('LOCAL_DOMAIN', 'https://ditto.test');
return new DittoConf(env);
}
export function sleep(ms: number): Promise<void> { export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }

View file

@ -1,6 +1,6 @@
import { DittoConf } from '@ditto/conf';
import { assert, assertEquals } from '@std/assert'; import { assert, assertEquals } from '@std/assert';
import { Conf } from '@/config.ts';
import { DeepLTranslator } from '@/translators/DeepLTranslator.ts'; import { DeepLTranslator } from '@/translators/DeepLTranslator.ts';
import { getLanguage } from '@/test.ts'; import { getLanguage } from '@/test.ts';
@ -8,7 +8,7 @@ const {
deeplBaseUrl: baseUrl, deeplBaseUrl: baseUrl,
deeplApiKey: apiKey, deeplApiKey: apiKey,
translationProvider, translationProvider,
} = Conf; } = new DittoConf(Deno.env);
const deepl = 'deepl'; const deepl = 'deepl';

View file

@ -1,6 +1,6 @@
import { DittoConf } from '@ditto/conf';
import { assertEquals } from '@std/assert'; import { assertEquals } from '@std/assert';
import { Conf } from '@/config.ts';
import { LibreTranslateTranslator } from '@/translators/LibreTranslateTranslator.ts'; import { LibreTranslateTranslator } from '@/translators/LibreTranslateTranslator.ts';
import { getLanguage } from '@/test.ts'; import { getLanguage } from '@/test.ts';
@ -8,7 +8,7 @@ const {
libretranslateBaseUrl: baseUrl, libretranslateBaseUrl: baseUrl,
libretranslateApiKey: apiKey, libretranslateApiKey: apiKey,
translationProvider, translationProvider,
} = Conf; } = new DittoConf(Deno.env);
const libretranslate = 'libretranslate'; const libretranslate = 'libretranslate';

View file

@ -1,12 +1,17 @@
import { DittoConf } from '@ditto/conf';
import { assertEquals } from '@std/assert'; import { assertEquals } from '@std/assert';
import { generateSecretKey, NostrEvent } from 'nostr-tools'; import { generateSecretKey, NostrEvent } from 'nostr-tools';
import { getTrendingTagValues } from '@/trends.ts'; import { DittoTrends } from '@/trends.ts';
import { createTestDB, genEvent } from '@/test.ts'; import { createTestDB, genEvent } from '@/test.ts';
Deno.test("getTrendingTagValues(): 'e' tag and WITHOUT language parameter", async () => { const conf = new DittoConf(new Map());
Deno.test("Trends.getTrendingTagValues(): 'e' tag and WITHOUT language parameter", async () => {
await using db = await createTestDB(); await using db = await createTestDB();
const trends = new DittoTrends({ ...db, conf });
const events: NostrEvent[] = []; const events: NostrEvent[] = [];
let sk = generateSecretKey(); let sk = generateSecretKey();
@ -43,7 +48,7 @@ Deno.test("getTrendingTagValues(): 'e' tag and WITHOUT language parameter", asyn
await db.store.event(event); await db.store.event(event);
} }
const trends = await getTrendingTagValues(db.kysely, ['e'], { kinds: [1, 7] }); const results = await trends.getTrendingTagValues(['e'], { kinds: [1, 7] });
const expected = [{ value: post1.id, authors: numberOfAuthorsWhoLikedPost1, uses: post1uses }, { const expected = [{ value: post1.id, authors: numberOfAuthorsWhoLikedPost1, uses: post1uses }, {
value: post2.id, value: post2.id,
@ -51,12 +56,13 @@ Deno.test("getTrendingTagValues(): 'e' tag and WITHOUT language parameter", asyn
uses: post2uses, uses: post2uses,
}]; }];
assertEquals(trends, expected); assertEquals(results, expected);
}); });
Deno.test("getTrendingTagValues(): 'e' tag and WITH language parameter", async () => { Deno.test("Trends.getTrendingTagValues(): 'e' tag and WITH language parameter", async () => {
await using db = await createTestDB(); await using db = await createTestDB();
const trends = new DittoTrends({ ...db, conf });
const events: NostrEvent[] = []; const events: NostrEvent[] = [];
let sk = generateSecretKey(); let sk = generateSecretKey();
@ -104,10 +110,10 @@ Deno.test("getTrendingTagValues(): 'e' tag and WITH language parameter", async (
const languagesIds = (await db.store.query([{ search: 'language:pt' }])).map((event) => event.id); const languagesIds = (await db.store.query([{ search: 'language:pt' }])).map((event) => event.id);
const trends = await getTrendingTagValues(db.kysely, ['e'], { kinds: [1, 7] }, languagesIds); const results = await trends.getTrendingTagValues(['e'], { kinds: [1, 7] }, languagesIds);
// portuguese post // portuguese post
const expected = [{ value: post1.id, authors: numberOfAuthorsWhoLikedPost1, uses: post1uses }]; const expected = [{ value: post1.id, authors: numberOfAuthorsWhoLikedPost1, uses: post1uses }];
assertEquals(trends, expected); assertEquals(results, expected);
}); });

View file

@ -1,19 +1,24 @@
import { DittoConf } from '@ditto/conf';
import { DittoTables } from '@ditto/db'; import { DittoTables } from '@ditto/db';
import { NostrFilter } from '@nostrify/nostrify'; 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 { AdminSigner } from '@/signers/AdminSigner.ts'; import { AdminSigner } from '@/signers/AdminSigner.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';
interface DittoTrendsOpts {
conf: DittoConf;
kysely: Kysely<DittoTables>;
store: NStore;
}
export class DittoTrends {
constructor(private opts: DittoTrendsOpts) {}
/** Get trending tag values for a given tag in the given time frame. */ /** Get trending tag values for a given tag in the given time frame. */
export async function getTrendingTagValues( async getTrendingTagValues(
/** Kysely instance to execute queries on. */
kysely: Kysely<DittoTables>,
/** Tag name to filter by, eg `t` or `r`. */ /** Tag name to filter by, eg `t` or `r`. */
tagNames: string[], tagNames: string[],
/** Filter of eligible events. */ /** Filter of eligible events. */
@ -21,6 +26,8 @@ export async function getTrendingTagValues(
/** If present, only tag values in this list are permitted to trend. */ /** If present, only tag values in this list are permitted to trend. */
values?: string[], values?: string[],
): Promise<{ value: string; authors: number; uses: number }[]> { ): Promise<{ value: string; authors: number; uses: number }[]> {
const { kysely } = this.opts;
let query = kysely let query = kysely
.selectFrom([ .selectFrom([
'nostr_events', 'nostr_events',
@ -65,7 +72,7 @@ export async function getTrendingTagValues(
} }
/** Get trending tags and publish an event with them. */ /** Get trending tags and publish an event with them. */
export async function updateTrendingTags( async updateTrendingTags(
l: string, l: string,
tagName: string, tagName: string,
kinds: number[], kinds: number[],
@ -74,10 +81,11 @@ export async function updateTrendingTags(
aliases?: string[], aliases?: string[],
values?: string[], values?: string[],
) { ) {
const { conf, store } = this.opts;
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);
@ -86,7 +94,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 this.getTrendingTagValues(tagNames, {
kinds, kinds,
since: yesterday, since: yesterday,
until: now, until: now,
@ -100,7 +108,7 @@ export async function updateTrendingTags(
return; return;
} }
const signer = new AdminSigner(); const signer = new AdminSigner(conf);
const label = await signer.signEvent({ const label = await signer.signEvent({
kind: 1985, kind: 1985,
@ -113,7 +121,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 store.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) });
@ -121,24 +129,26 @@ export async function updateTrendingTags(
} }
/** Update trending pubkeys. */ /** Update trending pubkeys. */
export function updateTrendingPubkeys(): Promise<void> { updateTrendingPubkeys(): Promise<void> {
return updateTrendingTags('#p', 'p', [1, 3, 6, 7, 9735], 40, Conf.relay); const { conf } = this.opts;
return this.updateTrendingTags('#p', 'p', [1, 3, 6, 7, 9735], 40, conf.relay);
} }
/** Update trending zapped events. */ /** Update trending zapped events. */
export function updateTrendingZappedEvents(): Promise<void> { updateTrendingZappedEvents(): Promise<void> {
return updateTrendingTags('zapped', 'e', [9735], 40, Conf.relay, ['q']); const { conf } = this.opts;
return this.updateTrendingTags('zapped', 'e', [9735], 40, conf.relay, ['q']);
} }
/** Update trending events. */ /** Update trending events. */
export async function updateTrendingEvents(): Promise<void> { async updateTrendingEvents(): Promise<void> {
const { conf, kysely } = this.opts;
const results: Promise<void>[] = [ const results: Promise<void>[] = [
updateTrendingTags('#e', 'e', [1, 6, 7, 9735], 40, Conf.relay, ['q']), this.updateTrendingTags('#e', 'e', [1, 6, 7, 9735], 40, 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);
@ -152,18 +162,19 @@ 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(this.updateTrendingTags(`#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> { updateTrendingHashtags(): Promise<void> {
return updateTrendingTags('#t', 't', [1], 20); return this.updateTrendingTags('#t', 't', [1], 20);
} }
/** Update trending links. */ /** Update trending links. */
export function updateTrendingLinks(): Promise<void> { updateTrendingLinks(): Promise<void> {
return updateTrendingTags('#r', 'r', [1], 20); return this.updateTrendingTags('#r', 'r', [1], 20);
}
} }

View file

@ -1,16 +1,14 @@
import { DittoConf } from '@ditto/conf';
import { type Context } from '@hono/hono'; import { type Context } from '@hono/hono';
import { HTTPException } from '@hono/hono/http-exception'; import { HTTPException } from '@hono/hono/http-exception';
import { NostrEvent, NostrFilter } from '@nostrify/nostrify'; import { NostrEvent, NostrFilter, NostrSigner, NStore } from '@nostrify/nostrify';
import { logi } from '@soapbox/logi'; 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 { RelayError } from '@/RelayError.ts';
import { AdminSigner } from '@/signers/AdminSigner.ts'; import { AdminSigner } from '@/signers/AdminSigner.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 { purifyEvent } from '@/utils/purify.ts'; import { purifyEvent } from '@/utils/purify.ts';
@ -18,24 +16,31 @@ 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. */ interface CreateEventOpts {
async function createEvent(t: EventStub, c: Context): Promise<NostrEvent> { conf: DittoConf;
const signer = c.get('signer'); store: NStore;
pool: NStore;
if (!signer) { user: {
throw new HTTPException(401, { signer: NostrSigner;
res: c.json({ error: 'No way to sign Nostr event' }, 401), };
}); signal?: AbortSignal;
} }
const event = await signer.signEvent({ /** Publish an event through the pipeline. */
async function createEvent(
opts: CreateEventOpts,
t: EventStub,
): Promise<NostrEvent> {
const { user } = opts;
const event = await user.signer.signEvent({
content: '', content: '',
created_at: nostrNow(), created_at: nostrNow(),
tags: [], tags: [],
...t, ...t,
}); });
return publishEvent(event, c); return publishEvent(opts, event);
} }
/** Filter for fetching an existing event to update. */ /** Filter for fetching an existing event to update. */
@ -46,19 +51,17 @@ interface UpdateEventFilter extends NostrFilter {
/** Update a replaceable event, or throw if no event exists yet. */ /** Update a replaceable event, or throw if no event exists yet. */
async function updateEvent<E extends EventStub>( async function updateEvent<E extends EventStub>(
opts: CreateEventOpts,
filter: UpdateEventFilter, filter: UpdateEventFilter,
fn: (prev: NostrEvent) => E | Promise<E>, fn: (prev: NostrEvent) => E | Promise<E>,
c: AppContext, signal?: AbortSignal,
): Promise<NostrEvent> { ): Promise<NostrEvent> {
const store = await Storages.db(); const { store } = opts;
const [prev] = await store.query( const [prev] = await store.query([filter], { signal });
[filter],
{ signal: c.req.raw.signal },
);
if (prev) { if (prev) {
return createEvent(await fn(prev), c); return createEvent(opts, await fn(prev));
} else { } else {
throw new HTTPException(422, { throw new HTTPException(422, {
message: 'No event to update', message: 'No event to update',
@ -68,20 +71,22 @@ async function updateEvent<E extends EventStub>(
/** Update a replaceable list event, or throw if no event exists yet. */ /** Update a replaceable list event, or throw if no event exists yet. */
function updateListEvent( function updateListEvent(
opts: CreateEventOpts,
filter: UpdateEventFilter, filter: UpdateEventFilter,
fn: (tags: string[][]) => string[][], fn: (tags: string[][]) => string[][],
c: AppContext,
): Promise<NostrEvent> { ): Promise<NostrEvent> {
return updateEvent(filter, ({ content, tags }) => ({ return updateEvent(opts, filter, ({ content, tags }) => ({
kind: filter.kinds[0], kind: filter.kinds[0],
content, content,
tags: fn(tags), tags: fn(tags),
}), c); }));
} }
/** 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(opts: CreateEventOpts, t: EventStub): Promise<NostrEvent> {
const signer = new AdminSigner(); const { conf } = opts;
const signer = new AdminSigner(conf);
const event = await signer.signEvent({ const event = await signer.signEvent({
content: '', content: '',
@ -90,46 +95,59 @@ async function createAdminEvent(t: EventStub, c: AppContext): Promise<NostrEvent
...t, ...t,
}); });
return publishEvent(event, c); return publishEvent(opts, 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. */
function updateListAdminEvent( function updateListAdminEvent(
opts: CreateEventOpts,
filter: UpdateEventFilter, filter: UpdateEventFilter,
fn: (tags: string[][]) => string[][], fn: (tags: string[][]) => string[][],
c: AppContext,
): Promise<NostrEvent> { ): Promise<NostrEvent> {
return updateAdminEvent(filter, (prev) => ({ return updateAdminEvent(opts, filter, (prev) => ({
kind: filter.kinds[0], kind: filter.kinds[0],
content: prev?.content ?? '', content: prev?.content ?? '',
tags: fn(prev?.tags ?? []), tags: fn(prev?.tags ?? []),
}), c); }));
} }
/** Fetch existing event, update it, then publish the new admin event. */ /** Fetch existing event, update it, then publish the new admin event. */
async function updateAdminEvent<E extends EventStub>( async function updateAdminEvent<E extends EventStub>(
opts: CreateEventOpts,
filter: UpdateEventFilter, filter: UpdateEventFilter,
fn: (prev: NostrEvent | undefined) => E, fn: (prev: NostrEvent | undefined) => E,
c: AppContext,
): Promise<NostrEvent> { ): Promise<NostrEvent> {
const store = await Storages.db(); const { store, signal } = opts;
const [prev] = await store.query([filter], { limit: 1, signal: c.req.raw.signal });
return createAdminEvent(fn(prev), c); const [prev] = await store.query(
[{ ...filter, limit: 1 }],
{ signal },
);
return createAdminEvent(opts, fn(prev));
} }
function updateUser(pubkey: string, n: Record<string, boolean>, c: AppContext): Promise<NostrEvent> { function updateUser(opts: CreateEventOpts, pubkey: string, n: Record<string, boolean>): Promise<NostrEvent> {
return updateNames(30382, pubkey, n, c); return updateNames(opts, 30382, pubkey, n);
} }
function updateEventInfo(id: string, n: Record<string, boolean>, c: AppContext): Promise<NostrEvent> { function updateEventInfo(opts: CreateEventOpts, id: string, n: Record<string, boolean>): Promise<NostrEvent> {
return updateNames(30383, id, n, c); return updateNames(opts, 30383, id, n);
} }
async function updateNames(k: number, d: string, n: Record<string, boolean>, c: AppContext): Promise<NostrEvent> { async function updateNames(
const signer = new AdminSigner(); opts: CreateEventOpts,
k: number,
d: string,
n: Record<string, boolean>,
): Promise<NostrEvent> {
const { conf } = opts;
const signer = new AdminSigner(conf);
const admin = await signer.getPublicKey(); const admin = await signer.getPublicKey();
return updateAdminEvent( return updateAdminEvent(
opts,
{ kinds: [k], authors: [admin], '#d': [d], limit: 1 }, { kinds: [k], authors: [admin], '#d': [d], limit: 1 },
(prev) => { (prev) => {
const prevNames = prev?.tags.reduce((acc, [name, value]) => { const prevNames = prev?.tags.reduce((acc, [name, value]) => {
@ -151,22 +169,25 @@ async function updateNames(k: number, d: string, n: Record<string, boolean>, c:
], ],
}; };
}, },
c,
); );
} }
/** Push the event through the pipeline, rethrowing any RelayError. */ /** Push the event through the pipeline, rethrowing any RelayError. */
async function publishEvent(event: NostrEvent, c: AppContext): Promise<NostrEvent> { async function publishEvent(
opts: CreateEventOpts,
event: NostrEvent,
): Promise<NostrEvent> {
const { store, pool, signal } = opts;
logi({ level: 'info', ns: 'ditto.event', source: 'api', id: event.id, kind: event.kind }); logi({ level: 'info', ns: 'ditto.event', source: 'api', id: event.id, kind: event.kind });
try { try {
await pipeline.handleEvent(event, { source: 'api', signal: c.req.raw.signal }); event = purifyEvent(event);
const client = await Storages.client(); await store.event(event, { signal });
await client.event(purifyEvent(event)); await pool.event(event, { signal });
} catch (e) { } catch (e) {
if (e instanceof RelayError) { if (e instanceof RelayError) {
throw new HTTPException(422, { throw new HTTPException(422, e);
res: c.json({ error: e.message }, 422),
});
} else { } else {
throw e; throw e;
} }
@ -191,12 +212,11 @@ async function parseBody(req: Request): Promise<unknown> {
} }
/** Build HTTP Link header for Mastodon API pagination. */ /** Build HTTP Link header for Mastodon API pagination. */
function buildLinkHeader(url: string, events: NostrEvent[]): string | undefined { function buildLinkHeader(origin: string, url: string, events: NostrEvent[]): string | undefined {
if (events.length <= 1) return; if (events.length <= 1) return;
const firstEvent = events[0]; const firstEvent = events[0];
const lastEvent = events[events.length - 1]; const lastEvent = events[events.length - 1];
const { origin } = Conf.url;
const { pathname, search } = new URL(url); const { pathname, search } = new URL(url);
const next = new URL(pathname + search, origin); const next = new URL(pathname + search, origin);
const prev = new URL(pathname + search, origin); const prev = new URL(pathname + search, origin);
@ -211,7 +231,10 @@ type HeaderRecord = Record<string, string | string[]>;
/** Return results with pagination headers. Assumes chronological sorting of events. */ /** Return results with pagination headers. Assumes chronological sorting of events. */
function paginated(c: AppContext, events: NostrEvent[], body: object | unknown[], headers: HeaderRecord = {}) { function paginated(c: AppContext, events: NostrEvent[], body: object | unknown[], headers: HeaderRecord = {}) {
const link = buildLinkHeader(c.req.url, events); const { conf } = c.var;
const { origin } = conf.url;
const link = buildLinkHeader(origin, c.req.url, events);
if (link) { if (link) {
headers.link = link; headers.link = link;
@ -223,8 +246,11 @@ function paginated(c: AppContext, events: NostrEvent[], body: object | unknown[]
} }
/** Build HTTP Link header for paginating Nostr lists. */ /** Build HTTP Link header for paginating Nostr lists. */
function buildListLinkHeader(url: string, params: { offset: number; limit: number }): string | undefined { function buildListLinkHeader(
const { origin } = Conf.url; origin: string,
url: string,
params: { offset: number; limit: number },
): string | undefined {
const { pathname, search } = new URL(url); const { pathname, search } = new URL(url);
const { offset, limit } = params; const { offset, limit } = params;
const next = new URL(pathname + search, origin); const next = new URL(pathname + search, origin);
@ -246,7 +272,10 @@ function paginatedList(
body: object | unknown[], body: object | unknown[],
headers: HeaderRecord = {}, headers: HeaderRecord = {},
) { ) {
const link = buildListLinkHeader(c.req.url, params); const { conf } = c.var;
const { origin } = conf.url;
const link = buildListLinkHeader(origin, c.req.url, params);
const hasMore = Array.isArray(body) ? body.length > 0 : true; const hasMore = Array.isArray(body) ? body.length > 0 : true;
if (link) { if (link) {
@ -260,15 +289,17 @@ function paginatedList(
/** Rewrite the URL of the request object to use the local domain. */ /** Rewrite the URL of the request object to use the local domain. */
function localRequest(c: Context): Request { function localRequest(c: Context): Request {
const { conf } = c.var;
return Object.create(c.req.raw, { return Object.create(c.req.raw, {
url: { value: Conf.local(c.req.url) }, url: { value: conf.local(c.req.url) },
}); });
} }
/** 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 (
!c.get('signer') && author.tags.some(([name, value, ns]) => !c.var.user && author.tags.some(([name, value, ns]) =>
name === 'l' && name === 'l' &&
value === '!no-unauthenticated' && value === '!no-unauthenticated' &&
ns === 'com.atproto.label.defs#selfLabel' ns === 'com.atproto.label.defs#selfLabel'

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 = Conf.pubkey;
uri.searchParams.set('relay', Conf.relay);
uri.searchParams.set('metadata', JSON.stringify(metadata));
return uri.toString();
}

View file

@ -1,4 +1,5 @@
import { DOMParser } from '@b-fuze/deno-dom'; import { DOMParser } from '@b-fuze/deno-dom';
import { DittoConf } from '@ditto/conf';
import { DittoTables } from '@ditto/db'; import { DittoTables } from '@ditto/db';
import { cachedFaviconsSizeGauge } from '@ditto/metrics'; import { cachedFaviconsSizeGauge } from '@ditto/metrics';
import { logi } from '@soapbox/logi'; import { logi } from '@soapbox/logi';
@ -6,18 +7,26 @@ 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'; import { SimpleLRU } from '@/utils/SimpleLRU.ts';
export const faviconCache = new SimpleLRU<string, URL>( let faviconCache: SimpleLRU<string, URL> | undefined;
async (domain, { signal }) => {
const kysely = await Storages.kysely();
interface ResolveFaviconOpts {
conf: DittoConf;
kysely: Kysely<DittoTables>;
signal?: AbortSignal;
}
export function resolveFavicon(opts: ResolveFaviconOpts, domain: string): Promise<URL> {
const { conf, kysely } = opts;
if (!faviconCache) {
faviconCache = new SimpleLRU<string, URL>(
async (domain, { signal }) => {
const row = await queryFavicon(kysely, domain); const row = await queryFavicon(kysely, domain);
if (row && (nostrNow() - row.last_updated_at) < (Conf.caches.favicon.ttl / 1000)) { if (row && (nostrNow() - row.last_updated_at) < (conf.caches.favicon.ttl / 1000)) {
return new URL(row.favicon); return new URL(row.favicon);
} }
@ -27,8 +36,12 @@ export const faviconCache = new SimpleLRU<string, URL>(
return url; return url;
}, },
{ ...Conf.caches.favicon, gauge: cachedFaviconsSizeGauge }, { ...conf.caches.favicon, gauge: cachedFaviconsSizeGauge },
); );
}
return faviconCache.fetch(domain, opts);
}
async function queryFavicon( async function queryFavicon(
kysely: Kysely<DittoTables>, kysely: Kysely<DittoTables>,
@ -38,6 +51,7 @@ async function queryFavicon(
.selectFrom('domain_favicons') .selectFrom('domain_favicons')
.selectAll() .selectAll()
.where('domain', '=', domain) .where('domain', '=', domain)
.limit(1)
.executeTakeFirst(); .executeTakeFirst();
} }

View file

@ -1,7 +1,7 @@
import { type DittoConf } from '@ditto/conf';
import { NostrEvent, NostrMetadata, NSchema as n, NStore } from '@nostrify/nostrify'; import { NostrEvent, NostrMetadata, NSchema as n, NStore } from '@nostrify/nostrify';
import { z } from 'zod'; import { z } from 'zod';
import { Conf } from '@/config.ts';
import { screenshotsSchema, serverMetaSchema } from '@/schemas/nostr.ts'; import { screenshotsSchema, serverMetaSchema } from '@/schemas/nostr.ts';
/** Like NostrMetadata, but some fields are required and also contains some extra fields. */ /** Like NostrMetadata, but some fields are required and also contains some extra fields. */
@ -16,9 +16,13 @@ export interface InstanceMetadata extends NostrMetadata {
} }
/** Get and parse instance metadata from the kind 0 of the admin user. */ /** Get and parse instance metadata from the kind 0 of the admin user. */
export async function getInstanceMetadata(store: NStore, signal?: AbortSignal): Promise<InstanceMetadata> { export async function getInstanceMetadata(
opts: { conf: DittoConf; store: NStore; signal?: AbortSignal },
): Promise<InstanceMetadata> {
const { conf, store, signal } = opts;
const [event] = await store.query( const [event] = await store.query(
[{ kinds: [0], authors: [Conf.pubkey], limit: 1 }], [{ kinds: [0], authors: [conf.pubkey], limit: 1 }],
{ signal }, { signal },
); );
@ -33,8 +37,8 @@ export async function getInstanceMetadata(store: NStore, signal?: AbortSignal):
name: meta.name ?? 'Ditto', name: meta.name ?? 'Ditto',
about: meta.about ?? 'Nostr community server', about: meta.about ?? 'Nostr community server',
tagline: meta.tagline ?? meta.about ?? 'Nostr community server', tagline: meta.tagline ?? meta.about ?? 'Nostr community server',
email: meta.email ?? `postmaster@${Conf.url.host}`, email: meta.email ?? `postmaster@${conf.url.host}`,
picture: meta.picture ?? Conf.local('/images/thumbnail.png'), picture: meta.picture ?? conf.local('/images/thumbnail.png'),
event, event,
screenshots: meta.screenshots ?? [], screenshots: meta.screenshots ?? [],
}; };

View file

@ -1,32 +1,49 @@
import { NostrEvent, NSchema as n } from '@nostrify/nostrify'; import { DittoConf } from '@ditto/conf';
import { DittoTables } from '@ditto/db';
import { NostrEvent, NSchema as n, NStore } from '@nostrify/nostrify';
import { Kysely } from 'kysely';
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 { resolveNip05 } from '@/utils/nip05.ts';
interface LookupAccountOpts {
conf: DittoConf;
store: NStore;
kysely: Kysely<DittoTables>;
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(
opts: LookupAccountOpts,
value: string, value: string,
signal = AbortSignal.timeout(3000),
): Promise<NostrEvent | undefined> { ): Promise<NostrEvent | undefined> {
const pubkey = await lookupPubkey(value, signal); const pubkey = await lookupPubkey(opts, value);
if (pubkey) { if (pubkey) {
return getAuthor(pubkey); return getAuthor(opts, pubkey);
} }
} }
interface LookupPubkeyOpts {
conf: DittoConf;
store: NStore;
kysely: Kysely<DittoTables>;
signal?: AbortSignal;
}
/** 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(opts: LookupPubkeyOpts, value: string): 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 resolveNip05(opts, value);
return pubkey; return pubkey;
} catch { } catch {
return; return;

View file

@ -5,24 +5,38 @@ 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'; import { SimpleLRU } from '@/utils/SimpleLRU.ts';
import { DittoConf } from '../../conf/mod.ts';
export const nip05Cache = new SimpleLRU<string, nip19.ProfilePointer>( let nip05Cache: SimpleLRU<string, nip19.ProfilePointer> | undefined;
interface ResolveNip05Opts {
conf: DittoConf;
store: NStore;
signal?: AbortSignal;
}
export function resolveNip05(opts: ResolveNip05Opts, nip05: string): Promise<nip19.ProfilePointer> {
const { conf } = opts;
if (!nip05Cache) {
nip05Cache = new SimpleLRU<string, nip19.ProfilePointer>(
async (nip05, { signal }) => { async (nip05, { signal }) => {
const store = await Storages.db(); return await getNip05({ ...opts, signal }, nip05);
return getNip05(store, nip05, signal);
}, },
{ ...Conf.caches.nip05, gauge: cachedNip05sSizeGauge }, { ...conf.caches.nip05, gauge: cachedNip05sSizeGauge },
); );
}
return nip05Cache.fetch(nip05, opts);
}
async function getNip05( async function getNip05(
store: NStore, opts: ResolveNip05Opts,
nip05: string, nip05: string,
signal?: AbortSignal,
): Promise<nip19.ProfilePointer> { ): Promise<nip19.ProfilePointer> {
const { conf, signal } = opts;
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 +48,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(opts, name);
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,17 +67,22 @@ async function getNip05(
} }
} }
export async function localNip05Lookup(store: NStore, localpart: string): Promise<nip19.ProfilePointer | undefined> { export async function localNip05Lookup(
opts: ResolveNip05Opts,
localpart: string,
): Promise<nip19.ProfilePointer | undefined> {
const { conf, store } = opts;
const [grant] = await store.query([{ const [grant] = await store.query([{
kinds: [30360], kinds: [30360],
'#d': [`${localpart}@${Conf.url.host}`], '#d': [`${localpart}@${conf.url.host}`],
authors: [Conf.pubkey], authors: [conf.pubkey],
limit: 1, limit: 1,
}]); }]);
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,27 +1,32 @@
import { assertEquals } from '@std/assert'; import { assertEquals } from '@std/assert';
import { eventFixture } from '@/test.ts'; import { eventFixture, testConf } from '@/test.ts';
import { getMediaLinks, parseNoteContent, stripimeta } from '@/utils/note.ts'; import { getMediaLinks, parseNoteContent, stripimeta } from '@/utils/note.ts';
Deno.test('parseNoteContent', () => { Deno.test('parseNoteContent', () => {
const { html, links, firstUrl } = parseNoteContent('Hello, world!', []); const conf = testConf();
const { html, links, firstUrl } = parseNoteContent(conf, 'Hello, world!', []);
assertEquals(html, 'Hello, world!'); assertEquals(html, 'Hello, world!');
assertEquals(links, []); assertEquals(links, []);
assertEquals(firstUrl, undefined); assertEquals(firstUrl, undefined);
}); });
Deno.test('parseNoteContent parses URLs', () => { Deno.test('parseNoteContent parses URLs', () => {
const { html } = parseNoteContent('check out my website: https://alexgleason.me', []); const conf = testConf();
const { html } = parseNoteContent(conf, 'check out my website: https://alexgleason.me', []);
assertEquals(html, 'check out my website: <a href="https://alexgleason.me">https://alexgleason.me</a>'); assertEquals(html, 'check out my website: <a href="https://alexgleason.me">https://alexgleason.me</a>');
}); });
Deno.test('parseNoteContent parses bare URLs', () => { Deno.test('parseNoteContent parses bare URLs', () => {
const { html } = parseNoteContent('have you seen ditto.pub?', []); const conf = testConf();
const { html } = parseNoteContent(conf, 'have you seen ditto.pub?', []);
assertEquals(html, 'have you seen <a href="http://ditto.pub">ditto.pub</a>?'); assertEquals(html, 'have you seen <a href="http://ditto.pub">ditto.pub</a>?');
}); });
Deno.test('parseNoteContent parses mentions with apostrophes', () => { Deno.test('parseNoteContent parses mentions with apostrophes', () => {
const conf = testConf();
const { html } = parseNoteContent( const { html } = parseNoteContent(
conf,
`did you see nostr:nprofile1qqsqgc0uhmxycvm5gwvn944c7yfxnnxm0nyh8tt62zhrvtd3xkj8fhgprdmhxue69uhkwmr9v9ek7mnpw3hhytnyv4mz7un9d3shjqgcwaehxw309ahx7umywf5hvefwv9c8qtmjv4kxz7gpzemhxue69uhhyetvv9ujumt0wd68ytnsw43z7s3al0v's speech?`, `did you see nostr:nprofile1qqsqgc0uhmxycvm5gwvn944c7yfxnnxm0nyh8tt62zhrvtd3xkj8fhgprdmhxue69uhkwmr9v9ek7mnpw3hhytnyv4mz7un9d3shjqgcwaehxw309ahx7umywf5hvefwv9c8qtmjv4kxz7gpzemhxue69uhhyetvv9ujumt0wd68ytnsw43z7s3al0v's speech?`,
[{ [{
id: '0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd', id: '0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd',
@ -37,7 +42,9 @@ Deno.test('parseNoteContent parses mentions with apostrophes', () => {
}); });
Deno.test('parseNoteContent parses mentions with commas', () => { Deno.test('parseNoteContent parses mentions with commas', () => {
const conf = testConf();
const { html } = parseNoteContent( const { html } = parseNoteContent(
conf,
`Sim. Hi nostr:npub1q3sle0kvfsehgsuexttt3ugjd8xdklxfwwkh559wxckmzddywnws6cd26p and nostr:npub1gujeqakgt7fyp6zjggxhyy7ft623qtcaay5lkc8n8gkry4cvnrzqd3f67z, any chance to have Cobrafuma as PWA?`, `Sim. Hi nostr:npub1q3sle0kvfsehgsuexttt3ugjd8xdklxfwwkh559wxckmzddywnws6cd26p and nostr:npub1gujeqakgt7fyp6zjggxhyy7ft623qtcaay5lkc8n8gkry4cvnrzqd3f67z, any chance to have Cobrafuma as PWA?`,
[{ [{
id: '0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd', id: '0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd',
@ -58,12 +65,15 @@ Deno.test('parseNoteContent parses mentions with commas', () => {
}); });
Deno.test("parseNoteContent doesn't parse invalid nostr URIs", () => { Deno.test("parseNoteContent doesn't parse invalid nostr URIs", () => {
const { html } = parseNoteContent('nip19 has URIs like nostr:npub and nostr:nevent, etc.', []); const conf = testConf();
const { html } = parseNoteContent(conf, 'nip19 has URIs like nostr:npub and nostr:nevent, etc.', []);
assertEquals(html, 'nip19 has URIs like nostr:npub and nostr:nevent, etc.'); assertEquals(html, 'nip19 has URIs like nostr:npub and nostr:nevent, etc.');
}); });
Deno.test('parseNoteContent renders empty for non-profile nostr URIs', () => { Deno.test('parseNoteContent renders empty for non-profile nostr URIs', () => {
const conf = testConf();
const { html } = parseNoteContent( const { html } = parseNoteContent(
conf,
'nostr:nevent1qgsr9cvzwc652r4m83d86ykplrnm9dg5gwdvzzn8ameanlvut35wy3gpz3mhxue69uhhztnnwashymtnw3ezucm0d5qzqru8mkz2q4gzsxg99q7pdneyx7n8p5u0afe3ntapj4sryxxmg4gpcdvgce', 'nostr:nevent1qgsr9cvzwc652r4m83d86ykplrnm9dg5gwdvzzn8ameanlvut35wy3gpz3mhxue69uhhztnnwashymtnw3ezucm0d5qzqru8mkz2q4gzsxg99q7pdneyx7n8p5u0afe3ntapj4sryxxmg4gpcdvgce',
[], [],
); );
@ -71,7 +81,9 @@ Deno.test('parseNoteContent renders empty for non-profile nostr URIs', () => {
}); });
Deno.test("parseNoteContent doesn't fuck up links to my own post", () => { Deno.test("parseNoteContent doesn't fuck up links to my own post", () => {
const conf = testConf();
const { html } = parseNoteContent( const { html } = parseNoteContent(
conf,
'Check this post: https://gleasonator.dev/@alex@gleasonator.dev/posts/a8badb480d88f9e7b6a090342279ef47ed0e0a3989ed85f898dfedc6be94225f', 'Check this post: https://gleasonator.dev/@alex@gleasonator.dev/posts/a8badb480d88f9e7b6a090342279ef47ed0e0a3989ed85f898dfedc6be94225f',
[{ [{
id: '0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd', id: '0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd',

View file

@ -1,9 +1,9 @@
import { DittoConf } from '@ditto/conf';
import 'linkify-plugin-hashtag'; import 'linkify-plugin-hashtag';
import linkifyStr from 'linkify-string'; import linkifyStr from 'linkify-string';
import linkify from 'linkifyjs'; import linkify from 'linkifyjs';
import { nip19, nip27 } from 'nostr-tools'; import { nip19, nip27 } from 'nostr-tools';
import { Conf } from '@/config.ts';
import { MastodonMention } from '@/entities/MastodonMention.ts'; import { MastodonMention } from '@/entities/MastodonMention.ts';
import { html } from '@/utils/html.ts'; import { html } from '@/utils/html.ts';
import { getUrlMediaType, isPermittedMediaType } from '@/utils/media.ts'; import { getUrlMediaType, isPermittedMediaType } from '@/utils/media.ts';
@ -21,7 +21,7 @@ interface ParsedNoteContent {
} }
/** Convert Nostr content to Mastodon API HTML. Also return parsed data. */ /** Convert Nostr content to Mastodon API HTML. Also return parsed data. */
function parseNoteContent(content: string, mentions: MastodonMention[]): ParsedNoteContent { function parseNoteContent(conf: DittoConf, content: string, mentions: MastodonMention[]): ParsedNoteContent {
const links = linkify.find(content).filter(({ type }) => type === 'url'); const links = linkify.find(content).filter(({ type }) => type === 'url');
const firstUrl = links.find(isNonMediaLink)?.href; const firstUrl = links.find(isNonMediaLink)?.href;
@ -29,7 +29,7 @@ function parseNoteContent(content: string, mentions: MastodonMention[]): ParsedN
render: { render: {
hashtag: ({ content }) => { hashtag: ({ content }) => {
const tag = content.replace(/^#/, ''); const tag = content.replace(/^#/, '');
const href = Conf.local(`/tags/${tag}`); const href = conf.local(`/tags/${tag}`);
return html`<a class="mention hashtag" href="${href}" rel="tag"><span>#</span>${tag}</a>`; return html`<a class="mention hashtag" href="${href}" rel="tag"><span>#</span>${tag}</a>`;
}, },
url: ({ attributes, content }) => { url: ({ attributes, content }) => {
@ -48,7 +48,7 @@ function parseNoteContent(content: string, mentions: MastodonMention[]): ParsedN
const npub = nip19.npubEncode(pubkey); const npub = nip19.npubEncode(pubkey);
const acct = mention?.acct ?? npub; const acct = mention?.acct ?? npub;
const name = mention?.acct ?? npub.substring(0, 8); const name = mention?.acct ?? npub.substring(0, 8);
const href = mention?.url ?? Conf.local(`/@${acct}`); const href = mention?.url ?? conf.local(`/@${acct}`);
return html`<span class="h-card"><a class="u-url mention" href="${href}" rel="ugc">@<span>${name}</span></a></span>${extra}`; return html`<span class="h-card"><a class="u-url mention" href="${href}" rel="ugc">@<span>${name}</span></a></span>${extra}`;
} else { } else {
return ''; return '';

View file

@ -1,29 +1,30 @@
import { MockRelay } from '@nostrify/nostrify/test'; import { createTestDB, eventFixture } from '@/test.ts';
import { eventFixture } from '@/test.ts';
import { getRelays } from '@/utils/outbox.ts'; import { getRelays } from '@/utils/outbox.ts';
import { assertEquals } from '@std/assert'; import { assertEquals } from '@std/assert';
Deno.test('Get write relays - kind 10002', async () => { Deno.test('Get write relays - kind 10002', async () => {
const db = new MockRelay(); await using db = await createTestDB();
const { conf, store } = db;
const relayListMetadata = await eventFixture('kind-10002-alex'); const relayListMetadata = await eventFixture('kind-10002-alex');
await db.event(relayListMetadata); await store.event(relayListMetadata);
const relays = await getRelays(db, relayListMetadata.pubkey); const relays = await getRelays(conf, store, relayListMetadata.pubkey);
assertEquals(relays.size, 6); assertEquals(relays.size, 6);
}); });
Deno.test('Get write relays with invalid URL - kind 10002', async () => { Deno.test('Get write relays with invalid URL - kind 10002', async () => {
const db = new MockRelay(); await using db = await createTestDB();
const { conf, store } = db;
const relayListMetadata = await eventFixture('kind-10002-alex'); const relayListMetadata = await eventFixture('kind-10002-alex');
relayListMetadata.tags[0] = ['r', 'yolo']; relayListMetadata.tags[0] = ['r', 'yolo'];
await db.event(relayListMetadata); await store.event(relayListMetadata);
const relays = await getRelays(db, relayListMetadata.pubkey); const relays = await getRelays(conf, store, relayListMetadata.pubkey);
assertEquals(relays.size, 5); assertEquals(relays.size, 5);
}); });

View file

@ -1,12 +1,11 @@
import { DittoConf } from '@ditto/conf';
import { NStore } from '@nostrify/nostrify'; import { NStore } from '@nostrify/nostrify';
import { Conf } from '@/config.ts'; export async function getRelays(conf: DittoConf, store: NStore, pubkey: string): Promise<Set<string>> {
export async function getRelays(store: NStore, pubkey: string): Promise<Set<string>> {
const relays = new Set<`wss://${string}`>(); const relays = new Set<`wss://${string}`>();
const events = await store.query([ const events = await store.query([
{ kinds: [10002], authors: [pubkey, Conf.pubkey], limit: 2 }, { kinds: [10002], authors: [pubkey, conf.pubkey], limit: 2 },
]); ]);
for (const event of events) { for (const event of events) {

View file

@ -1,12 +1,19 @@
import { DittoConf } from '@ditto/conf';
import { NSchema as n, NStore } from '@nostrify/nostrify'; import { NSchema as n, NStore } from '@nostrify/nostrify';
import { Conf } from '@/config.ts';
import { configSchema } from '@/schemas/pleroma-api.ts'; import { configSchema } from '@/schemas/pleroma-api.ts';
import { AdminSigner } from '@/signers/AdminSigner.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts';
import { PleromaConfigDB } from '@/utils/PleromaConfigDB.ts'; import { PleromaConfigDB } from '@/utils/PleromaConfigDB.ts';
export async function getPleromaConfigs(store: NStore, signal?: AbortSignal): Promise<PleromaConfigDB> { interface GetPleromaConfigOpts {
const { pubkey } = Conf; conf: DittoConf;
store: NStore;
signal?: AbortSignal;
}
export async function getPleromaConfigs(opts: GetPleromaConfigOpts): Promise<PleromaConfigDB> {
const { conf, store, signal } = opts;
const { pubkey } = conf;
const [event] = await store.query([{ const [event] = await store.query([{
kinds: [30078], kinds: [30078],
@ -20,7 +27,7 @@ export async function getPleromaConfigs(store: NStore, signal?: AbortSignal): Pr
} }
try { try {
const decrypted = await new AdminSigner().nip44.decrypt(Conf.pubkey, event.content); const decrypted = await new AdminSigner(conf).nip44.decrypt(conf.pubkey, event.content);
const configs = n.json().pipe(configSchema.array()).catch([]).parse(decrypted); const configs = n.json().pipe(configSchema.array()).catch([]).parse(decrypted);
return new PleromaConfigDB(configs); return new PleromaConfigDB(configs);
} catch (_e) { } catch (_e) {

View file

@ -10,7 +10,7 @@ Deno.test('updateStats with kind 1 increments notes count', async () => {
const sk = generateSecretKey(); const sk = generateSecretKey();
const pubkey = getPublicKey(sk); const pubkey = getPublicKey(sk);
await updateStats({ ...db, event: genEvent({ kind: 1 }, sk) }); await updateStats(db, genEvent({ kind: 1 }, sk));
const stats = await getAuthorStats(db.kysely, pubkey); const stats = await getAuthorStats(db.kysely, pubkey);
@ -23,11 +23,11 @@ Deno.test('updateStats with kind 1 increments replies count', async () => {
const sk = generateSecretKey(); const sk = generateSecretKey();
const note = genEvent({ kind: 1 }, sk); const note = genEvent({ kind: 1 }, sk);
await updateStats({ ...db, event: note }); await updateStats(db, note);
await db.store.event(note); await db.store.event(note);
const reply = genEvent({ kind: 1, tags: [['e', note.id]] }, sk); const reply = genEvent({ kind: 1, tags: [['e', note.id]] }, sk);
await updateStats({ ...db, event: reply }); await updateStats(db, reply);
await db.store.event(reply); await db.store.event(reply);
const stats = await getEventStats(db.kysely, note.id); const stats = await getEventStats(db.kysely, note.id);
@ -44,11 +44,11 @@ Deno.test('updateStats with kind 5 decrements notes count', async () => {
const create = genEvent({ kind: 1 }, sk); const create = genEvent({ kind: 1 }, sk);
const remove = genEvent({ kind: 5, tags: [['e', create.id]] }, sk); const remove = genEvent({ kind: 5, tags: [['e', create.id]] }, sk);
await updateStats({ ...db, event: create }); await updateStats(db, create);
assertEquals((await getAuthorStats(db.kysely, pubkey))!.notes_count, 1); assertEquals((await getAuthorStats(db.kysely, pubkey))!.notes_count, 1);
await db.store.event(create); await db.store.event(create);
await updateStats({ ...db, event: remove }); await updateStats(db, remove);
assertEquals((await getAuthorStats(db.kysely, pubkey))!.notes_count, 0); assertEquals((await getAuthorStats(db.kysely, pubkey))!.notes_count, 0);
await db.store.event(remove); await db.store.event(remove);
}); });
@ -56,9 +56,9 @@ Deno.test('updateStats with kind 5 decrements notes count', async () => {
Deno.test('updateStats with kind 3 increments followers count', async () => { Deno.test('updateStats with kind 3 increments followers count', async () => {
await using db = await createTestDB(); await using db = await createTestDB();
await updateStats({ ...db, event: genEvent({ kind: 3, tags: [['p', 'alex']] }) }); await updateStats(db, genEvent({ kind: 3, tags: [['p', 'alex']] }));
await updateStats({ ...db, event: genEvent({ kind: 3, tags: [['p', 'alex']] }) }); await updateStats(db, genEvent({ kind: 3, tags: [['p', 'alex']] }));
await updateStats({ ...db, event: genEvent({ kind: 3, tags: [['p', 'alex']] }) }); await updateStats(db, genEvent({ kind: 3, tags: [['p', 'alex']] }));
const stats = await getAuthorStats(db.kysely, 'alex'); const stats = await getAuthorStats(db.kysely, 'alex');
@ -72,11 +72,11 @@ Deno.test('updateStats with kind 3 decrements followers count', async () => {
const follow = genEvent({ kind: 3, tags: [['p', 'alex']], created_at: 0 }, sk); const follow = genEvent({ kind: 3, tags: [['p', 'alex']], created_at: 0 }, sk);
const remove = genEvent({ kind: 3, tags: [], created_at: 1 }, sk); const remove = genEvent({ kind: 3, tags: [], created_at: 1 }, sk);
await updateStats({ ...db, event: follow }); await updateStats(db, follow);
assertEquals((await getAuthorStats(db.kysely, 'alex'))!.followers_count, 1); assertEquals((await getAuthorStats(db.kysely, 'alex'))!.followers_count, 1);
await db.store.event(follow); await db.store.event(follow);
await updateStats({ ...db, event: remove }); await updateStats(db, remove);
assertEquals((await getAuthorStats(db.kysely, 'alex'))!.followers_count, 0); assertEquals((await getAuthorStats(db.kysely, 'alex'))!.followers_count, 0);
await db.store.event(remove); await db.store.event(remove);
}); });
@ -95,11 +95,11 @@ Deno.test('updateStats with kind 6 increments reposts count', async () => {
await using db = await createTestDB(); await using db = await createTestDB();
const note = genEvent({ kind: 1 }); const note = genEvent({ kind: 1 });
await updateStats({ ...db, event: note }); await updateStats(db, note);
await db.store.event(note); await db.store.event(note);
const repost = genEvent({ kind: 6, tags: [['e', note.id]] }); const repost = genEvent({ kind: 6, tags: [['e', note.id]] });
await updateStats({ ...db, event: repost }); await updateStats(db, repost);
await db.store.event(repost); await db.store.event(repost);
const stats = await getEventStats(db.kysely, note.id); const stats = await getEventStats(db.kysely, note.id);
@ -111,15 +111,15 @@ Deno.test('updateStats with kind 5 decrements reposts count', async () => {
await using db = await createTestDB(); await using db = await createTestDB();
const note = genEvent({ kind: 1 }); const note = genEvent({ kind: 1 });
await updateStats({ ...db, event: note }); await updateStats(db, note);
await db.store.event(note); await db.store.event(note);
const sk = generateSecretKey(); const sk = generateSecretKey();
const repost = genEvent({ kind: 6, tags: [['e', note.id]] }, sk); const repost = genEvent({ kind: 6, tags: [['e', note.id]] }, sk);
await updateStats({ ...db, event: repost }); await updateStats(db, repost);
await db.store.event(repost); await db.store.event(repost);
await updateStats({ ...db, event: genEvent({ kind: 5, tags: [['e', repost.id]] }, sk) }); await updateStats(db, genEvent({ kind: 5, tags: [['e', repost.id]] }, sk));
const stats = await getEventStats(db.kysely, note.id); const stats = await getEventStats(db.kysely, note.id);
@ -130,11 +130,11 @@ Deno.test('updateStats with kind 7 increments reactions count', async () => {
await using db = await createTestDB(); await using db = await createTestDB();
const note = genEvent({ kind: 1 }); const note = genEvent({ kind: 1 });
await updateStats({ ...db, event: note }); await updateStats(db, note);
await db.store.event(note); await db.store.event(note);
await updateStats({ ...db, event: genEvent({ kind: 7, content: '+', tags: [['e', note.id]] }) }); await updateStats(db, genEvent({ kind: 7, content: '+', tags: [['e', note.id]] }));
await updateStats({ ...db, event: genEvent({ kind: 7, content: '😂', tags: [['e', note.id]] }) }); await updateStats(db, genEvent({ kind: 7, content: '😂', tags: [['e', note.id]] }));
const stats = await getEventStats(db.kysely, note.id); const stats = await getEventStats(db.kysely, note.id);
@ -146,15 +146,15 @@ Deno.test('updateStats with kind 5 decrements reactions count', async () => {
await using db = await createTestDB(); await using db = await createTestDB();
const note = genEvent({ kind: 1 }); const note = genEvent({ kind: 1 });
await updateStats({ ...db, event: note }); await updateStats(db, note);
await db.store.event(note); await db.store.event(note);
const sk = generateSecretKey(); const sk = generateSecretKey();
const reaction = genEvent({ kind: 7, content: '+', tags: [['e', note.id]] }, sk); const reaction = genEvent({ kind: 7, content: '+', tags: [['e', note.id]] }, sk);
await updateStats({ ...db, event: reaction }); await updateStats(db, reaction);
await db.store.event(reaction); await db.store.event(reaction);
await updateStats({ ...db, event: genEvent({ kind: 5, tags: [['e', reaction.id]] }, sk) }); await updateStats(db, genEvent({ kind: 5, tags: [['e', reaction.id]] }, sk));
const stats = await getEventStats(db.kysely, note.id); const stats = await getEventStats(db.kysely, note.id);

View file

@ -1,43 +1,44 @@
import { DittoConf } from '@ditto/conf';
import { DittoTables } from '@ditto/db'; import { DittoTables } from '@ditto/db';
import { NostrEvent, NSchema as n, NStore } from '@nostrify/nostrify'; import { NostrEvent, NSchema as n, NStore } from '@nostrify/nostrify';
import { Insertable, Kysely, UpdateObject } from 'kysely'; import { Insertable, Kysely, UpdateObject } from 'kysely';
import { SetRequired } from 'type-fest'; import { SetRequired } from 'type-fest';
import { z } from 'zod'; import { z } from 'zod';
import { Conf } from '@/config.ts';
import { findQuoteTag, findReplyTag, getTagSet } from '@/utils/tags.ts'; import { findQuoteTag, findReplyTag, getTagSet } from '@/utils/tags.ts';
interface UpdateStatsOpts { interface UpdateStatsOpts {
conf: DittoConf;
kysely: Kysely<DittoTables>; kysely: Kysely<DittoTables>;
store: NStore; store: NStore;
event: NostrEvent;
x?: 1 | -1;
} }
/** Handle one event at a time and update relevant stats for it. */ /** Handle one event at a time and update relevant stats for it. */
// deno-lint-ignore require-await // deno-lint-ignore require-await
export async function updateStats({ event, kysely, store, x = 1 }: UpdateStatsOpts): Promise<void> { export async function updateStats(opts: UpdateStatsOpts, event: NostrEvent, x = 1): Promise<void> {
switch (event.kind) { switch (event.kind) {
case 1: case 1:
case 20: case 20:
case 1111: case 1111:
case 30023: case 30023:
return handleEvent1(kysely, event, x); return handleEvent1(opts, event, x);
case 3: case 3:
return handleEvent3(kysely, event, x, store); return handleEvent3(opts, event, x);
case 5: case 5:
return handleEvent5(kysely, event, -1, store); return handleEvent5(opts, event, -1);
case 6: case 6:
return handleEvent6(kysely, event, x); return handleEvent6(opts, event, x);
case 7: case 7:
return handleEvent7(kysely, event, x); return handleEvent7(opts, event, x);
case 9735: case 9735:
return handleEvent9735(kysely, event); return handleEvent9735(opts, event);
} }
} }
/** Update stats for kind 1 event. */ /** Update stats for kind 1 event. */
async function handleEvent1(kysely: Kysely<DittoTables>, event: NostrEvent, x: number): Promise<void> { async function handleEvent1(opts: UpdateStatsOpts, event: NostrEvent, x: number): Promise<void> {
const { conf, kysely } = opts;
await updateAuthorStats(kysely, event.pubkey, (prev) => { await updateAuthorStats(kysely, event.pubkey, (prev) => {
const now = event.created_at; const now = event.created_at;
@ -47,7 +48,7 @@ async function handleEvent1(kysely: Kysely<DittoTables>, event: NostrEvent, x: n
if (start && end) { // Streak exists. if (start && end) { // Streak exists.
if (now <= end) { if (now <= end) {
// Streak cannot go backwards in time. Skip it. // Streak cannot go backwards in time. Skip it.
} else if (now - end > Conf.streakWindow) { } else if (now - end > conf.streakWindow) {
// Streak is broken. Start a new streak. // Streak is broken. Start a new streak.
start = now; start = now;
end = now; end = now;
@ -88,7 +89,9 @@ async function handleEvent1(kysely: Kysely<DittoTables>, event: NostrEvent, x: n
} }
/** Update stats for kind 3 event. */ /** Update stats for kind 3 event. */
async function handleEvent3(kysely: Kysely<DittoTables>, event: NostrEvent, x: number, store: NStore): Promise<void> { async function handleEvent3(opts: UpdateStatsOpts, event: NostrEvent, x: number): Promise<void> {
const { kysely, store } = opts;
const following = getTagSet(event.tags, 'p'); const following = getTagSet(event.tags, 'p');
await updateAuthorStats(kysely, event.pubkey, () => ({ following_count: following.size })); await updateAuthorStats(kysely, event.pubkey, () => ({ following_count: following.size }));
@ -117,26 +120,34 @@ async function handleEvent3(kysely: Kysely<DittoTables>, event: NostrEvent, x: n
} }
/** Update stats for kind 5 event. */ /** Update stats for kind 5 event. */
async function handleEvent5(kysely: Kysely<DittoTables>, event: NostrEvent, x: -1, store: NStore): Promise<void> { async function handleEvent5(opts: UpdateStatsOpts, event: NostrEvent, x: number): Promise<void> {
const { store } = opts;
const id = event.tags.find(([name]) => name === 'e')?.[1]; const id = event.tags.find(([name]) => name === 'e')?.[1];
if (id) { if (id) {
const [target] = await store.query([{ ids: [id], authors: [event.pubkey], limit: 1 }]); const [target] = await store.query([{ ids: [id], authors: [event.pubkey], limit: 1 }]);
if (target) { if (target) {
await updateStats({ event: target, kysely, store, x }); await updateStats(opts, event, x);
} }
} }
} }
/** Update stats for kind 6 event. */ /** Update stats for kind 6 event. */
async function handleEvent6(kysely: Kysely<DittoTables>, event: NostrEvent, x: number): Promise<void> { async function handleEvent6(opts: UpdateStatsOpts, event: NostrEvent, x: number): Promise<void> {
const { kysely } = opts;
const id = event.tags.find(([name]) => name === 'e')?.[1]; const id = event.tags.find(([name]) => name === 'e')?.[1];
if (id) { if (id) {
await updateEventStats(kysely, id, ({ reposts_count }) => ({ reposts_count: Math.max(0, reposts_count + x) })); await updateEventStats(kysely, id, ({ reposts_count }) => ({ reposts_count: Math.max(0, reposts_count + x) }));
} }
} }
/** Update stats for kind 7 event. */ /** Update stats for kind 7 event. */
async function handleEvent7(kysely: Kysely<DittoTables>, event: NostrEvent, x: number): Promise<void> { async function handleEvent7(opts: UpdateStatsOpts, event: NostrEvent, x: number): Promise<void> {
const { kysely } = opts;
const id = event.tags.findLast(([name]) => name === 'e')?.[1]; const id = event.tags.findLast(([name]) => name === 'e')?.[1];
const emoji = event.content; const emoji = event.content;
@ -166,7 +177,9 @@ async function handleEvent7(kysely: Kysely<DittoTables>, event: NostrEvent, x: n
} }
/** Update stats for kind 9735 event. */ /** Update stats for kind 9735 event. */
async function handleEvent9735(kysely: Kysely<DittoTables>, event: NostrEvent): Promise<void> { async function handleEvent9735(opts: UpdateStatsOpts, event: NostrEvent): Promise<void> {
const { kysely } = opts;
// https://github.com/nostr-protocol/nips/blob/master/57.md#appendix-f-validating-zap-receipts // https://github.com/nostr-protocol/nips/blob/master/57.md#appendix-f-validating-zap-receipts
const id = event.tags.find(([name]) => name === 'e')?.[1]; const id = event.tags.find(([name]) => name === 'e')?.[1];
if (!id) return; if (!id) return;

View file

@ -1,3 +1,4 @@
import { DittoConf } from '@ditto/conf';
import { cachedLinkPreviewSizeGauge } from '@ditto/metrics'; import { cachedLinkPreviewSizeGauge } from '@ditto/metrics';
import TTLCache from '@isaacs/ttlcache'; import TTLCache from '@isaacs/ttlcache';
import { logi } from '@soapbox/logi'; import { logi } from '@soapbox/logi';
@ -5,18 +6,17 @@ import { safeFetch } from '@soapbox/safe-fetch';
import DOMPurify from 'isomorphic-dompurify'; import DOMPurify from 'isomorphic-dompurify';
import { unfurl } from 'unfurl.js'; import { unfurl } from 'unfurl.js';
import { Conf } from '@/config.ts';
import { PreviewCard } from '@/entities/PreviewCard.ts'; import { PreviewCard } from '@/entities/PreviewCard.ts';
import { errorJson } from '@/utils/log.ts'; import { errorJson } from '@/utils/log.ts';
async function unfurlCard(url: string, signal: AbortSignal): Promise<PreviewCard | null> { async function unfurlCard(conf: DittoConf, url: string, signal: AbortSignal): Promise<PreviewCard | null> {
try { try {
const result = await unfurl(url, { const result = await unfurl(url, {
fetch: (url) => fetch: (url) =>
safeFetch(url, { safeFetch(url, {
headers: { headers: {
'Accept': 'text/html, application/xhtml+xml', 'Accept': 'text/html, application/xhtml+xml',
'User-Agent': Conf.fetchUserAgent, 'User-Agent': conf.fetchUserAgent,
}, },
signal, signal,
}), }),
@ -55,15 +55,22 @@ async function unfurlCard(url: string, signal: AbortSignal): Promise<PreviewCard
} }
/** TTL cache for preview cards. */ /** TTL cache for preview cards. */
const previewCardCache = new TTLCache<string, Promise<PreviewCard | null>>(Conf.caches.linkPreview); let previewCardCache: TTLCache<string, Promise<PreviewCard | null>> | undefined;
/** Unfurl card from cache if available, otherwise fetch it. */ /** Unfurl card from cache if available, otherwise fetch it. */
function unfurlCardCached(url: string, signal = AbortSignal.timeout(1000)): Promise<PreviewCard | null> { function unfurlCardCached(
conf: DittoConf,
url: string,
signal = AbortSignal.timeout(1000),
): Promise<PreviewCard | null> {
if (!previewCardCache) {
previewCardCache = new TTLCache<string, Promise<PreviewCard | null>>(conf.caches.linkPreview);
}
const cached = previewCardCache.get(url); const cached = previewCardCache.get(url);
if (cached !== undefined) { if (cached !== undefined) {
return cached; return cached;
} else { } else {
const card = unfurlCard(url, signal); const card = unfurlCard(conf, url, signal);
previewCardCache.set(url, card); previewCardCache.set(url, card);
cachedLinkPreviewSizeGauge.set(previewCardCache.size); cachedLinkPreviewSizeGauge.set(previewCardCache.size);
return card; return card;

View file

@ -6,7 +6,6 @@ import { encode } from 'blurhash';
import sharp from 'sharp'; import sharp from 'sharp';
import { AppContext } from '@/app.ts'; import { AppContext } from '@/app.ts';
import { Conf } from '@/config.ts';
import { DittoUpload, dittoUploads } from '@/DittoUploads.ts'; import { DittoUpload, dittoUploads } from '@/DittoUploads.ts';
import { errorJson } from '@/utils/log.ts'; import { errorJson } from '@/utils/log.ts';
@ -22,7 +21,8 @@ export async function uploadFile(
meta: FileMeta, meta: FileMeta,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<DittoUpload> { ): Promise<DittoUpload> {
const uploader = c.get('uploader'); const { conf, uploader } = c.var;
if (!uploader) { if (!uploader) {
throw new HTTPException(500, { throw new HTTPException(500, {
res: c.json({ error: 'No uploader configured.' }), res: c.json({ error: 'No uploader configured.' }),
@ -31,7 +31,7 @@ export async function uploadFile(
const { pubkey, description } = meta; const { pubkey, description } = meta;
if (file.size > Conf.maxUploadSize) { if (file.size > conf.maxUploadSize) {
throw new Error('File size is too large.'); throw new Error('File size is too large.');
} }
@ -63,7 +63,7 @@ export async function uploadFile(
// If the uploader didn't already, try to get a blurhash and media dimensions. // If the uploader didn't already, try to get a blurhash and media dimensions.
// This requires `MEDIA_ANALYZE=true` to be configured because it comes with security tradeoffs. // This requires `MEDIA_ANALYZE=true` to be configured because it comes with security tradeoffs.
if (Conf.mediaAnalyze && (!blurhash || !dim)) { if (conf.mediaAnalyze && (!blurhash || !dim)) {
try { try {
const bytes = await new Response(file.stream()).bytes(); const bytes = await new Response(file.stream()).bytes();
const img = sharp(bytes); const img = sharp(bytes);

View file

@ -1,5 +1,6 @@
import { DittoConf } from '@ditto/conf';
import { AdminSigner } from '@/signers/AdminSigner.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts';
import { Conf } from '@/config.ts';
import { NSchema as n, NStore } from '@nostrify/nostrify'; import { NSchema as n, NStore } from '@nostrify/nostrify';
import { nostrNow } from '@/utils.ts'; import { nostrNow } from '@/utils.ts';
import { percentageSchema } from '@/schema.ts'; import { percentageSchema } from '@/schema.ts';
@ -37,14 +38,14 @@ export async function getZapSplits(store: NStore, pubkey: string): Promise<Ditto
return zapSplits; return zapSplits;
} }
export async function seedZapSplits(store: NStore) { export async function seedZapSplits(conf: DittoConf, store: NStore) {
const zapSplit: DittoZapSplits | undefined = await getZapSplits(store, Conf.pubkey); const zapSplit: DittoZapSplits | undefined = await getZapSplits(store, conf.pubkey);
if (!zapSplit) { if (!zapSplit) {
const dittoPubkey = '781a1527055f74c1f70230f10384609b34548f8ab6a0a6caa74025827f9fdae5'; const dittoPubkey = '781a1527055f74c1f70230f10384609b34548f8ab6a0a6caa74025827f9fdae5';
const dittoMsg = 'Official Ditto Account'; const dittoMsg = 'Official Ditto Account';
const signer = new AdminSigner(); const signer = new AdminSigner(conf);
const event = await signer.signEvent({ const event = await signer.signEvent({
content: '', content: '',
created_at: nostrNow(), created_at: nostrNow(),

View file

@ -1,12 +1,10 @@
import { NostrEvent, NostrFilter } from '@nostrify/nostrify'; import { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { AppContext } from '@/app.ts'; import { AppContext } from '@/app.ts';
import { Storages } from '@/storages.ts';
import { renderAccount } from '@/views/mastodon/accounts.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 { paginated, paginatedList } from '@/utils/api.ts';
import { AccountView } from '@/views/mastodon/AccountView.ts';
import { StatusView } from '@/views/mastodon/StatusView.ts';
interface RenderEventAccountsOpts { interface RenderEventAccountsOpts {
signal?: AbortSignal; signal?: AbortSignal;
@ -19,25 +17,17 @@ async function renderEventAccounts(c: AppContext, filters: NostrFilter[], opts?:
return c.json([]); return c.json([]);
} }
const { store } = c.var;
const { signal = AbortSignal.timeout(1000), filterFn } = opts ?? {}; const { signal = AbortSignal.timeout(1000), filterFn } = opts ?? {};
const store = await Storages.db();
const events = await store.query(filters, { signal }) const events = await store.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, store, signal })) .then((events) => hydrateEvents(c.var, events))
.then((events) => filterFn ? events.filter(filterFn) : events); .then((events) => filterFn ? events.filter(filterFn) : events);
const accounts = await Promise.all( const view = new AccountView(c.var);
events.map(({ author, pubkey }) => { const accounts = events.map(({ author, pubkey }) => view.render(author, pubkey));
if (author) {
return renderAccount(author);
} else {
return accountFromPubkey(pubkey);
}
}),
);
return paginated(c, events, accounts); return paginated(c, events, accounts);
} }
@ -46,22 +36,19 @@ async function renderAccounts(c: AppContext, pubkeys: string[]) {
const { offset, limit } = c.get('listPagination'); const { offset, limit } = c.get('listPagination');
const authors = pubkeys.reverse().slice(offset, offset + limit); const authors = pubkeys.reverse().slice(offset, offset + limit);
const store = await Storages.db(); const { store } = c.var;
const signal = c.req.raw.signal; const signal = c.req.raw.signal;
const events = await store.query([{ kinds: [0], authors }], { signal }) const events = await store
.then((events) => hydrateEvents({ events, store, signal })); .query([{ kinds: [0], authors }], { signal })
.then((events) => hydrateEvents(c.var, events));
const accounts = await Promise.all( const view = new AccountView(c.var);
authors.map((pubkey) => {
const accounts = authors.map((pubkey) => {
const event = events.find((event) => event.pubkey === pubkey); const event = events.find((event) => event.pubkey === pubkey);
if (event) { return view.render(event, pubkey);
return renderAccount(event); });
} else {
return accountFromPubkey(pubkey);
}
}),
);
return paginatedList(c, { offset, limit }, accounts); return paginatedList(c, { offset, limit }, accounts);
} }
@ -72,11 +59,12 @@ async function renderStatuses(c: AppContext, ids: string[], signal = AbortSignal
return c.json([]); return c.json([]);
} }
const store = await Storages.db(); const { store, pagination } = c.var;
const { limit } = c.get('pagination'); const { limit } = pagination;
const events = await store.query([{ kinds: [1, 20], ids, limit }], { signal }) const events = await store
.then((events) => hydrateEvents({ events, store, signal })); .query([{ kinds: [1, 20], ids, limit }], { signal })
.then((events) => hydrateEvents(c.var, events));
if (!events.length) { if (!events.length) {
return c.json([]); return c.json([]);
@ -84,10 +72,10 @@ async function renderStatuses(c: AppContext, ids: string[], signal = AbortSignal
const sortedEvents = [...events].sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id)); const sortedEvents = [...events].sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id));
const viewerPubkey = await c.get('signer')?.getPublicKey(); const view = new StatusView(c.var);
const statuses = await Promise.all( const statuses = await Promise.all(
sortedEvents.map((event) => renderStatus(event, { viewerPubkey })), sortedEvents.map((event) => view.render(event)),
); );
// 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

@ -0,0 +1,170 @@
import { DittoConf } from '@ditto/conf';
import { NSchema as n } from '@nostrify/nostrify';
import { nip19, UnsignedEvent } from 'nostr-tools';
import { MastodonAccount } from '@/entities/MastodonAccount.ts';
import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
import { metadataSchema } from '@/schemas/nostr.ts';
import { getLnurl } from '@/utils/lnurl.ts';
import { parseNoteContent } from '@/utils/note.ts';
import { getTagSet } from '@/utils/tags.ts';
import { nostrDate, nostrNow, parseNip05 } from '@/utils.ts';
import { renderEmojis } from '@/views/mastodon/emojis.ts';
type ToAccountOpts = {
withSource: true;
settingsStore: Record<string, unknown> | undefined;
} | {
withSource?: false;
};
interface AccountViewOpts {
conf: DittoConf;
}
export class AccountView {
constructor(private opts: AccountViewOpts) {}
render(event: Omit<DittoEvent, 'id' | 'sig'>, pubkey?: string, opts?: ToAccountOpts): MastodonAccount;
render(event: Omit<DittoEvent, 'id' | 'sig'> | undefined, pubkey: string, opts?: ToAccountOpts): MastodonAccount;
render(event: Omit<DittoEvent, 'id' | 'sig'> | undefined, pubkey: string, opts: ToAccountOpts = {}): MastodonAccount {
const { conf } = this.opts;
if (!event) {
return this.accountFromPubkey(pubkey, opts);
}
const stats = event.author_stats;
const names = getTagSet(event.user?.tags ?? [], 'n');
if (names.has('disabled')) {
const account = this.accountFromPubkey(pubkey, opts);
account.pleroma.deactivated = true;
return account;
}
const {
name,
nip05,
picture = conf.local('/images/avi.png'),
banner = conf.local('/images/banner.png'),
about,
lud06,
lud16,
website,
fields: _fields,
} = n.json().pipe(metadataSchema).catch({}).parse(event.content);
const npub = nip19.npubEncode(pubkey);
const nprofile = nip19.nprofileEncode({ pubkey, relays: [conf.relay] });
const parsed05 = stats?.nip05 ? parseNip05(stats.nip05) : undefined;
const acct = parsed05?.handle || npub;
const { html } = parseNoteContent(conf, about || '', []);
const fields = _fields
?.slice(0, conf.profileFields.maxFields)
.map(([name, value]) => ({
name: name.slice(0, conf.profileFields.nameLength),
value: value.slice(0, conf.profileFields.valueLength),
verified_at: null,
})) ?? [];
let streakDays = 0;
let streakStart = stats?.streak_start ?? null;
let streakEnd = stats?.streak_end ?? null;
const { streakWindow } = conf;
if (streakStart && streakEnd) {
const broken = nostrNow() - streakEnd > streakWindow;
if (broken) {
streakStart = null;
streakEnd = null;
} else {
const delta = streakEnd - streakStart;
streakDays = Math.max(Math.ceil(delta / 86400), 1);
}
}
return {
id: pubkey,
acct,
avatar: picture,
avatar_static: picture,
bot: false,
created_at: nostrDate(event.user?.created_at ?? event.created_at).toISOString(),
discoverable: true,
display_name: name ?? '',
emojis: renderEmojis(event),
fields: fields.map((field) => ({ ...field, value: parseNoteContent(conf, field.value, []).html })),
follow_requests_count: 0,
followers_count: stats?.followers_count ?? 0,
following_count: stats?.following_count ?? 0,
fqn: parsed05?.handle || npub,
header: banner,
header_static: banner,
last_status_at: null,
locked: false,
note: html,
roles: [],
source: opts.withSource
? {
fields,
language: '',
note: about || '',
privacy: 'public',
sensitive: false,
follow_requests_count: 0,
nostr: {
nip05,
},
ditto: {
captcha_solved: names.has('captcha_solved'),
},
}
: undefined,
statuses_count: stats?.notes_count ?? 0,
uri: conf.local(`/users/${acct}`),
url: conf.local(`/@${acct}`),
username: parsed05?.nickname || npub.substring(0, 8),
ditto: {
accepts_zaps: Boolean(getLnurl({ lud06, lud16 })),
external_url: conf.external(nprofile),
streak: {
days: streakDays,
start: streakStart ? nostrDate(streakStart).toISOString() : null,
end: streakEnd ? nostrDate(streakEnd).toISOString() : null,
expires: streakEnd ? nostrDate(streakEnd + streakWindow).toISOString() : null,
},
},
domain: parsed05?.domain,
pleroma: {
deactivated: names.has('disabled'),
is_admin: names.has('admin'),
is_moderator: names.has('admin') || names.has('moderator'),
is_suggested: names.has('suggested'),
is_local: parsed05?.domain === conf.url.host,
settings_store: opts.withSource ? opts.settingsStore : undefined,
tags: [...getTagSet(event.user?.tags ?? [], 't')],
favicon: stats?.favicon,
},
nostr: {
pubkey,
lud16,
},
website: website && /^https?:\/\//.test(website) ? website : undefined,
};
}
private accountFromPubkey(pubkey: string, opts: ToAccountOpts = {}): MastodonAccount {
const event: UnsignedEvent = {
kind: 0,
pubkey,
content: '',
tags: [],
created_at: nostrNow(),
};
return this.render(event, pubkey, opts);
}
}

View file

@ -0,0 +1,80 @@
import { DittoConf } from '@ditto/conf';
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { getTagSet } from '@/utils/tags.ts';
import { AccountView } from '@/views/mastodon/AccountView.ts';
interface AdminAccountViewOpts {
conf: DittoConf;
}
export class AdminAccountView {
private accountView: AccountView;
constructor(opts: AdminAccountViewOpts) {
this.accountView = new AccountView(opts);
}
/** Expects a kind 0 fully hydrated */
render(event: DittoEvent | undefined, pubkey: string) {
if (!event) {
return this.renderAdminAccountFromPubkey(pubkey);
}
const account = this.accountView.render(event, pubkey);
const names = getTagSet(event.user?.tags ?? [], 'n');
let role = 'user';
if (names.has('admin')) {
role = 'admin';
}
if (names.has('moderator')) {
role = 'moderator';
}
return {
id: account.id,
username: account.username,
domain: account.acct.split('@')[1] || null,
created_at: account.created_at,
email: '',
ip: null,
ips: [],
locale: '',
invite_request: null,
role,
confirmed: true,
approved: true,
disabled: names.has('disabled'),
silenced: names.has('silenced'),
suspended: names.has('suspended'),
sensitized: names.has('sensitized'),
account,
};
}
/** Expects a target pubkey */
private renderAdminAccountFromPubkey(pubkey: string) {
const account = this.accountView.render(undefined, pubkey);
return {
id: account.id,
username: account.username,
domain: account.acct.split('@')[1] || null,
created_at: account.created_at,
email: '',
ip: null,
ips: [],
locale: '',
invite_request: null,
role: 'user',
confirmed: true,
approved: true,
disabled: false,
silenced: false,
suspended: false,
account,
};
}
}

View file

@ -0,0 +1,165 @@
import { DittoConf } from '@ditto/conf';
import { NostrEvent, NostrSigner, NStore } from '@nostrify/nostrify';
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { nostrDate } from '@/utils.ts';
import { AccountView } from '@/views/mastodon/AccountView.ts';
import { StatusView } from '@/views/mastodon/StatusView.ts';
interface NotificationViewOpts {
conf: DittoConf;
store: NStore;
user?: {
signer: NostrSigner;
};
}
export class NotificationView {
private accountView: AccountView;
private statusView: StatusView;
constructor(private opts: NotificationViewOpts) {
this.accountView = new AccountView(opts);
this.statusView = new StatusView(opts);
}
async render(event: DittoEvent) {
const { conf, user } = this.opts;
const viewerPubkey = await user?.signer.getPublicKey();
const mentioned = !!event.tags.find(([name, value]) => name === 'p' && value === viewerPubkey);
if (event.kind === 1 && mentioned) {
return this.renderMention(event);
}
if (event.kind === 6) {
return this.renderReblog(event);
}
if (event.kind === 7 && event.content === '+') {
return this.renderFavourite(event);
}
if (event.kind === 7) {
return this.renderReaction(event);
}
if (event.kind === 30360 && event.pubkey === conf.pubkey) {
return this.renderNameGrant(event);
}
if (event.kind === 9735) {
return this.renderZap(event);
}
}
async renderMention(event: DittoEvent) {
const status = await this.statusView.render(event);
if (!status) return;
return {
id: this.notificationId(event),
type: 'mention' as const,
created_at: nostrDate(event.created_at).toISOString(),
account: status.account,
status: status,
};
}
async renderReblog(event: DittoEvent) {
if (event.repost?.kind !== 1) return;
const status = await this.statusView.render(event.repost);
if (!status) return;
const account = this.accountView.render(event.author, event.pubkey);
return {
id: this.notificationId(event),
type: 'reblog' as const,
created_at: nostrDate(event.created_at).toISOString(),
account,
status,
};
}
async renderFavourite(event: DittoEvent) {
if (event.reacted?.kind !== 1) return;
const status = await this.statusView.render(event.reacted);
if (!status) return;
const account = this.accountView.render(event.author, event.pubkey);
return {
id: this.notificationId(event),
type: 'favourite' as const,
created_at: nostrDate(event.created_at).toISOString(),
account,
status,
};
}
async renderReaction(event: DittoEvent) {
if (event.reacted?.kind !== 1) return;
const status = await this.statusView.render(event.reacted);
if (!status) return;
const account = this.accountView.render(event.author, event.pubkey);
return {
id: this.notificationId(event),
type: 'pleroma:emoji_reaction' as const,
emoji: event.content,
emoji_url: event.tags.find(([name, value]) => name === 'emoji' && `:${value}:` === event.content)?.[2],
created_at: nostrDate(event.created_at).toISOString(),
account,
status,
};
}
renderNameGrant(event: DittoEvent) {
const d = event.tags.find(([name]) => name === 'd')?.[1];
const account = this.accountView.render(event.author, event.pubkey);
if (!d) return;
return {
id: this.notificationId(event),
type: 'ditto:name_grant' as const,
name: d,
created_at: nostrDate(event.created_at).toISOString(),
account,
};
}
async renderZap(event: DittoEvent) {
if (!event.zap_sender) return;
const { zap_amount = 0, zap_message = '' } = event;
if (zap_amount < 1) return;
let zapSender: NostrEvent | undefined;
let zapSenderPubkey: string;
if (typeof event.zap_sender === 'string') {
zapSenderPubkey = event.zap_sender;
} else {
zapSender = event.zap_sender;
zapSenderPubkey = zapSender.pubkey;
}
const account = this.accountView.render(zapSender, zapSenderPubkey);
return {
id: this.notificationId(event),
type: 'ditto:zap' as const,
amount: zap_amount,
message: zap_message,
created_at: nostrDate(event.created_at).toISOString(),
account,
...(event.zapped ? { status: await this.statusView.render(event.zapped) } : {}),
};
}
/** This helps notifications be sorted in the correct order. */
notificationId({ id, created_at }: NostrEvent): string {
return `${created_at}-${id}`;
}
}

View file

@ -0,0 +1,200 @@
import { DittoConf } from '@ditto/conf';
import { NostrEvent, NostrSigner, NStore } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools';
import { MastodonAttachment } from '@/entities/MastodonAttachment.ts';
import { MastodonMention } from '@/entities/MastodonMention.ts';
import { MastodonStatus } from '@/entities/MastodonStatus.ts';
import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
import { nostrDate } from '@/utils.ts';
import { getMediaLinks, parseNoteContent, stripimeta } from '@/utils/note.ts';
import { findReplyTag } from '@/utils/tags.ts';
import { unfurlCardCached } from '@/utils/unfurl.ts';
import { AccountView } from '@/views/mastodon/AccountView.ts';
import { renderAttachment } from '@/views/mastodon/attachments.ts';
import { renderEmojis } from '@/views/mastodon/emojis.ts';
interface RenderStatusOpts {
depth?: number;
}
interface StatusViewOpts {
conf: DittoConf;
store: NStore;
user?: {
signer: NostrSigner;
};
}
export class StatusView {
private accountView: AccountView;
constructor(private opts: StatusViewOpts) {
this.accountView = new AccountView(opts);
}
async render(event: DittoEvent, opts?: RenderStatusOpts): Promise<MastodonStatus | undefined> {
if (event.kind === 6) {
return await this.renderReblog(event, opts);
}
return await this.renderStatus(event, opts);
}
async renderStatus(event: DittoEvent, opts?: RenderStatusOpts): Promise<MastodonStatus | undefined> {
const { conf, store, user } = this.opts;
const { depth = 1 } = opts ?? {};
if (depth > 2 || depth < 0) return;
const nevent = nip19.neventEncode({
id: event.id,
author: event.pubkey,
kind: event.kind,
relays: [conf.relay],
});
const account = this.accountView.render(event.author, event.pubkey);
const viewerPubkey = await user?.signer.getPublicKey();
const replyId = findReplyTag(event.tags)?.[1];
const mentions = event.mentions?.map((event) => this.renderMention(event)) ?? [];
const { html, links, firstUrl } = parseNoteContent(conf, stripimeta(event.content, event.tags), mentions);
const [card, relatedEvents] = await Promise
.all([
firstUrl ? unfurlCardCached(conf, firstUrl, AbortSignal.timeout(500)) : null,
viewerPubkey
? await store.query([
{ kinds: [6], '#e': [event.id], authors: [viewerPubkey], limit: 1 },
{ kinds: [7], '#e': [event.id], authors: [viewerPubkey], limit: 1 },
{ kinds: [9734], '#e': [event.id], authors: [viewerPubkey], limit: 1 },
{ kinds: [10001], '#e': [event.id], authors: [viewerPubkey], limit: 1 },
{ kinds: [10003], '#e': [event.id], authors: [viewerPubkey], limit: 1 },
])
: [],
]);
const reactionEvent = relatedEvents.find((event) => event.kind === 7);
const repostEvent = relatedEvents.find((event) => event.kind === 6);
const pinEvent = relatedEvents.find((event) => event.kind === 10001);
const bookmarkEvent = relatedEvents.find((event) => event.kind === 10003);
const zapEvent = relatedEvents.find((event) => event.kind === 9734);
const compatMentions = this.buildInlineRecipients(mentions.filter((m) => {
if (m.id === account.id) return false;
if (html.includes(m.url)) return false;
return true;
}));
const cw = event.tags.find(([name]) => name === 'content-warning');
const subject = event.tags.find(([name]) => name === 'subject');
const imeta: string[][][] = event.tags
.filter(([name]) => name === 'imeta')
.map(([_, ...entries]) =>
entries.map((entry) => {
const split = entry.split(' ');
return [split[0], split.splice(1).join(' ')];
})
);
const media = imeta.length ? imeta : getMediaLinks(links);
/** Pleroma emoji reactions object. */
const reactions = Object.entries(event.event_stats?.reactions ?? {}).reduce((acc, [emoji, count]) => {
if (['+', '-'].includes(emoji)) return acc;
acc.push({ name: emoji, count, me: reactionEvent?.content === emoji });
return acc;
}, [] as { name: string; count: number; me: boolean }[]);
const expiresAt = new Date(Number(event.tags.find(([name]) => name === 'expiration')?.[1]) * 1000);
return {
id: event.id,
account,
card,
content: compatMentions + html,
created_at: nostrDate(event.created_at).toISOString(),
in_reply_to_id: replyId ?? null,
in_reply_to_account_id: null,
sensitive: !!cw,
spoiler_text: (cw ? cw[1] : subject?.[1]) || '',
visibility: 'public',
language: event.language ?? null,
replies_count: event.event_stats?.replies_count ?? 0,
reblogs_count: event.event_stats?.reposts_count ?? 0,
favourites_count: event.event_stats?.reactions['+'] ?? 0,
zaps_amount: event.event_stats?.zaps_amount ?? 0,
favourited: reactionEvent?.content === '+',
reblogged: Boolean(repostEvent),
muted: false,
bookmarked: Boolean(bookmarkEvent),
pinned: Boolean(pinEvent),
reblog: null,
application: null,
media_attachments: media
.map((m) => renderAttachment({ tags: m }))
.filter((m): m is MastodonAttachment => Boolean(m)),
mentions,
tags: [],
emojis: renderEmojis(event),
poll: null,
quote: !event.quote ? null : await this.renderStatus(event.quote, { depth: depth + 1 }),
quote_id: event.quote?.id ?? null,
uri: conf.local(`/users/${account.acct}/statuses/${event.id}`),
url: conf.local(`/@${account.acct}/${event.id}`),
zapped: Boolean(zapEvent),
ditto: {
external_url: conf.external(nevent),
},
pleroma: {
emoji_reactions: reactions,
expires_at: !isNaN(expiresAt.getTime()) ? expiresAt.toISOString() : undefined,
quotes_count: event.event_stats?.quotes_count ?? 0,
},
};
}
async renderReblog(event: DittoEvent, opts?: RenderStatusOpts): Promise<MastodonStatus | undefined> {
if (!event.repost) return;
const status = await this.renderStatus(event, opts);
if (!status) return;
const reblog = await this.renderStatus(event.repost, opts) ?? null;
return {
...status,
in_reply_to_id: null,
in_reply_to_account_id: null,
reblog,
};
}
renderMention(event: NostrEvent): MastodonMention {
const account = this.accountView.render(event, event.pubkey);
return {
id: account.id,
acct: account.acct,
username: account.username,
url: account.url,
};
}
buildInlineRecipients(mentions: MastodonMention[]): string {
if (!mentions.length) return '';
const elements = mentions.reduce<string[]>((acc, { url, username }) => {
const name = nip19.BECH32_REGEX.test(username) ? username.substring(0, 8) : username;
acc.push(
`<span class="h-card"><a class="u-url mention" href="${url}" rel="ugc">@<span>${name}</span></a></span>`,
);
return acc;
}, []);
return `<span class="recipients-inline">${elements.join(' ')} </span>`;
}
}

View file

@ -1,158 +0,0 @@
import { NSchema as n } from '@nostrify/nostrify';
import { nip19, UnsignedEvent } from 'nostr-tools';
import { Conf } from '@/config.ts';
import { MastodonAccount } from '@/entities/MastodonAccount.ts';
import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
import { metadataSchema } from '@/schemas/nostr.ts';
import { getLnurl } from '@/utils/lnurl.ts';
import { parseNoteContent } from '@/utils/note.ts';
import { getTagSet } from '@/utils/tags.ts';
import { nostrDate, nostrNow, parseNip05 } from '@/utils.ts';
import { renderEmojis } from '@/views/mastodon/emojis.ts';
type ToAccountOpts = {
withSource: true;
settingsStore: Record<string, unknown> | undefined;
} | {
withSource?: false;
};
function renderAccount(event: Omit<DittoEvent, 'id' | 'sig'>, opts: ToAccountOpts = {}): MastodonAccount {
const { pubkey } = event;
const stats = event.author_stats;
const names = getTagSet(event.user?.tags ?? [], 'n');
if (names.has('disabled')) {
const account = accountFromPubkey(pubkey, opts);
account.pleroma.deactivated = true;
return account;
}
const {
name,
nip05,
picture = Conf.local('/images/avi.png'),
banner = Conf.local('/images/banner.png'),
about,
lud06,
lud16,
website,
fields: _fields,
} = n.json().pipe(metadataSchema).catch({}).parse(event.content);
const npub = nip19.npubEncode(pubkey);
const nprofile = nip19.nprofileEncode({ pubkey, relays: [Conf.relay] });
const parsed05 = stats?.nip05 ? parseNip05(stats.nip05) : undefined;
const acct = parsed05?.handle || npub;
const { html } = parseNoteContent(about || '', []);
const fields = _fields
?.slice(0, Conf.profileFields.maxFields)
.map(([name, value]) => ({
name: name.slice(0, Conf.profileFields.nameLength),
value: value.slice(0, Conf.profileFields.valueLength),
verified_at: null,
})) ?? [];
let streakDays = 0;
let streakStart = stats?.streak_start ?? null;
let streakEnd = stats?.streak_end ?? null;
const { streakWindow } = Conf;
if (streakStart && streakEnd) {
const broken = nostrNow() - streakEnd > streakWindow;
if (broken) {
streakStart = null;
streakEnd = null;
} else {
const delta = streakEnd - streakStart;
streakDays = Math.max(Math.ceil(delta / 86400), 1);
}
}
return {
id: pubkey,
acct,
avatar: picture,
avatar_static: picture,
bot: false,
created_at: nostrDate(event.user?.created_at ?? event.created_at).toISOString(),
discoverable: true,
display_name: name ?? '',
emojis: renderEmojis(event),
fields: fields.map((field) => ({ ...field, value: parseNoteContent(field.value, []).html })),
follow_requests_count: 0,
followers_count: stats?.followers_count ?? 0,
following_count: stats?.following_count ?? 0,
fqn: parsed05?.handle || npub,
header: banner,
header_static: banner,
last_status_at: null,
locked: false,
note: html,
roles: [],
source: opts.withSource
? {
fields,
language: '',
note: about || '',
privacy: 'public',
sensitive: false,
follow_requests_count: 0,
nostr: {
nip05,
},
ditto: {
captcha_solved: names.has('captcha_solved'),
},
}
: undefined,
statuses_count: stats?.notes_count ?? 0,
uri: Conf.local(`/users/${acct}`),
url: Conf.local(`/@${acct}`),
username: parsed05?.nickname || npub.substring(0, 8),
ditto: {
accepts_zaps: Boolean(getLnurl({ lud06, lud16 })),
external_url: Conf.external(nprofile),
streak: {
days: streakDays,
start: streakStart ? nostrDate(streakStart).toISOString() : null,
end: streakEnd ? nostrDate(streakEnd).toISOString() : null,
expires: streakEnd ? nostrDate(streakEnd + streakWindow).toISOString() : null,
},
},
domain: parsed05?.domain,
pleroma: {
deactivated: names.has('disabled'),
is_admin: names.has('admin'),
is_moderator: names.has('admin') || names.has('moderator'),
is_suggested: names.has('suggested'),
is_local: parsed05?.domain === Conf.url.host,
settings_store: opts.withSource ? opts.settingsStore : undefined,
tags: [...getTagSet(event.user?.tags ?? [], 't')],
favicon: stats?.favicon,
},
nostr: {
pubkey,
lud16,
},
website: website && /^https?:\/\//.test(website) ? website : undefined,
};
}
function accountFromPubkey(pubkey: string, opts: ToAccountOpts = {}): MastodonAccount {
const event: UnsignedEvent = {
kind: 0,
pubkey,
content: '',
tags: [],
created_at: nostrNow(),
};
return renderAccount(event, opts);
}
export { accountFromPubkey, renderAccount };

View file

@ -1,64 +0,0 @@
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { getTagSet } from '@/utils/tags.ts';
/** Expects a kind 0 fully hydrated */
async function renderAdminAccount(event: DittoEvent) {
const account = await renderAccount(event);
const names = getTagSet(event.user?.tags ?? [], 'n');
let role = 'user';
if (names.has('admin')) {
role = 'admin';
}
if (names.has('moderator')) {
role = 'moderator';
}
return {
id: account.id,
username: account.username,
domain: account.acct.split('@')[1] || null,
created_at: account.created_at,
email: '',
ip: null,
ips: [],
locale: '',
invite_request: null,
role,
confirmed: true,
approved: true,
disabled: names.has('disabled'),
silenced: names.has('silenced'),
suspended: names.has('suspended'),
sensitized: names.has('sensitized'),
account,
};
}
/** Expects a target pubkey */
async function renderAdminAccountFromPubkey(pubkey: string) {
const account = await accountFromPubkey(pubkey);
return {
id: account.id,
username: account.username,
domain: account.acct.split('@')[1] || null,
created_at: account.created_at,
email: '',
ip: null,
ips: [],
locale: '',
invite_request: null,
role: 'user',
confirmed: true,
approved: true,
disabled: false,
silenced: false,
suspended: false,
account,
};
}
export { renderAdminAccount, renderAdminAccountFromPubkey };

View file

@ -1,142 +0,0 @@
import { NostrEvent } from '@nostrify/nostrify';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
import { Conf } from '@/config.ts';
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { nostrDate } from '@/utils.ts';
import { renderStatus } from '@/views/mastodon/statuses.ts';
interface RenderNotificationOpts {
viewerPubkey: string;
}
function renderNotification(event: DittoEvent, opts: RenderNotificationOpts) {
const mentioned = !!event.tags.find(([name, value]) => name === 'p' && value === opts.viewerPubkey);
if (event.kind === 1 && mentioned) {
return renderMention(event, opts);
}
if (event.kind === 6) {
return renderReblog(event, opts);
}
if (event.kind === 7 && event.content === '+') {
return renderFavourite(event, opts);
}
if (event.kind === 7) {
return renderReaction(event, opts);
}
if (event.kind === 30360 && event.pubkey === Conf.pubkey) {
return renderNameGrant(event);
}
if (event.kind === 9735) {
return renderZap(event, opts);
}
}
async function renderMention(event: DittoEvent, opts: RenderNotificationOpts) {
const status = await renderStatus(event, opts);
if (!status) return;
return {
id: notificationId(event),
type: 'mention' as const,
created_at: nostrDate(event.created_at).toISOString(),
account: status.account,
status: status,
};
}
async function renderReblog(event: DittoEvent, opts: RenderNotificationOpts) {
if (event.repost?.kind !== 1) return;
const status = await renderStatus(event.repost, opts);
if (!status) return;
const account = event.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey);
return {
id: notificationId(event),
type: 'reblog' as const,
created_at: nostrDate(event.created_at).toISOString(),
account,
status,
};
}
async function renderFavourite(event: DittoEvent, opts: RenderNotificationOpts) {
if (event.reacted?.kind !== 1) return;
const status = await renderStatus(event.reacted, opts);
if (!status) return;
const account = event.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey);
return {
id: notificationId(event),
type: 'favourite' as const,
created_at: nostrDate(event.created_at).toISOString(),
account,
status,
};
}
async function renderReaction(event: DittoEvent, opts: RenderNotificationOpts) {
if (event.reacted?.kind !== 1) return;
const status = await renderStatus(event.reacted, opts);
if (!status) return;
const account = event.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey);
return {
id: notificationId(event),
type: 'pleroma:emoji_reaction' as const,
emoji: event.content,
emoji_url: event.tags.find(([name, value]) => name === 'emoji' && `:${value}:` === event.content)?.[2],
created_at: nostrDate(event.created_at).toISOString(),
account,
status,
};
}
async function renderNameGrant(event: DittoEvent) {
const d = event.tags.find(([name]) => name === 'd')?.[1];
const account = event.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey);
if (!d) return;
return {
id: notificationId(event),
type: 'ditto:name_grant' as const,
name: d,
created_at: nostrDate(event.created_at).toISOString(),
account,
};
}
async function renderZap(event: DittoEvent, opts: RenderNotificationOpts) {
if (!event.zap_sender) return;
const { zap_amount = 0, zap_message = '' } = event;
if (zap_amount < 1) return;
const account = typeof event.zap_sender !== 'string'
? await renderAccount(event.zap_sender)
: await accountFromPubkey(event.zap_sender);
return {
id: notificationId(event),
type: 'ditto:zap' as const,
amount: zap_amount,
message: zap_message,
created_at: nostrDate(event.created_at).toISOString(),
account,
...(event.zapped ? { status: await renderStatus(event.zapped, opts) } : {}),
};
}
/** This helps notifications be sorted in the correct order. */
function notificationId({ id, created_at }: NostrEvent): string {
return `${created_at}-${id}`;
}
export { renderNotification };

View file

@ -1,3 +1,4 @@
import { AppContext } from '@/app.ts';
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';
@ -36,7 +37,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(c: AppContext, 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 +46,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(c, status, { viewerPubkey }));
} }
} }

View file

@ -1,181 +0,0 @@
import { NostrEvent } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools';
import { Conf } from '@/config.ts';
import { MastodonAttachment } from '@/entities/MastodonAttachment.ts';
import { MastodonMention } from '@/entities/MastodonMention.ts';
import { MastodonStatus } from '@/entities/MastodonStatus.ts';
import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
import { Storages } from '@/storages.ts';
import { nostrDate } from '@/utils.ts';
import { getMediaLinks, parseNoteContent, stripimeta } from '@/utils/note.ts';
import { findReplyTag } from '@/utils/tags.ts';
import { unfurlCardCached } from '@/utils/unfurl.ts';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
import { renderAttachment } from '@/views/mastodon/attachments.ts';
import { renderEmojis } from '@/views/mastodon/emojis.ts';
interface RenderStatusOpts {
viewerPubkey?: string;
depth?: number;
}
async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise<MastodonStatus | undefined> {
const { viewerPubkey, depth = 1 } = opts;
if (depth > 2 || depth < 0) return;
const nevent = nip19.neventEncode({
id: event.id,
author: event.pubkey,
kind: event.kind,
relays: [Conf.relay],
});
const account = event.author
? renderAccount({ ...event.author, author_stats: event.author_stats })
: accountFromPubkey(event.pubkey);
const replyId = findReplyTag(event.tags)?.[1];
const store = await Storages.db();
const mentions = event.mentions?.map((event) => renderMention(event)) ?? [];
const { html, links, firstUrl } = parseNoteContent(stripimeta(event.content, event.tags), mentions);
const [card, relatedEvents] = await Promise
.all([
firstUrl ? unfurlCardCached(firstUrl, AbortSignal.timeout(500)) : null,
viewerPubkey
? await store.query([
{ kinds: [6], '#e': [event.id], authors: [viewerPubkey], limit: 1 },
{ kinds: [7], '#e': [event.id], authors: [viewerPubkey], limit: 1 },
{ kinds: [9734], '#e': [event.id], authors: [viewerPubkey], limit: 1 },
{ kinds: [10001], '#e': [event.id], authors: [viewerPubkey], limit: 1 },
{ kinds: [10003], '#e': [event.id], authors: [viewerPubkey], limit: 1 },
])
: [],
]);
const reactionEvent = relatedEvents.find((event) => event.kind === 7);
const repostEvent = relatedEvents.find((event) => event.kind === 6);
const pinEvent = relatedEvents.find((event) => event.kind === 10001);
const bookmarkEvent = relatedEvents.find((event) => event.kind === 10003);
const zapEvent = relatedEvents.find((event) => event.kind === 9734);
const compatMentions = buildInlineRecipients(mentions.filter((m) => {
if (m.id === account.id) return false;
if (html.includes(m.url)) return false;
return true;
}));
const cw = event.tags.find(([name]) => name === 'content-warning');
const subject = event.tags.find(([name]) => name === 'subject');
const imeta: string[][][] = event.tags
.filter(([name]) => name === 'imeta')
.map(([_, ...entries]) =>
entries.map((entry) => {
const split = entry.split(' ');
return [split[0], split.splice(1).join(' ')];
})
);
const media = imeta.length ? imeta : getMediaLinks(links);
/** Pleroma emoji reactions object. */
const reactions = Object.entries(event.event_stats?.reactions ?? {}).reduce((acc, [emoji, count]) => {
if (['+', '-'].includes(emoji)) return acc;
acc.push({ name: emoji, count, me: reactionEvent?.content === emoji });
return acc;
}, [] as { name: string; count: number; me: boolean }[]);
const expiresAt = new Date(Number(event.tags.find(([name]) => name === 'expiration')?.[1]) * 1000);
return {
id: event.id,
account,
card,
content: compatMentions + html,
created_at: nostrDate(event.created_at).toISOString(),
in_reply_to_id: replyId ?? null,
in_reply_to_account_id: null,
sensitive: !!cw,
spoiler_text: (cw ? cw[1] : subject?.[1]) || '',
visibility: 'public',
language: event.language ?? null,
replies_count: event.event_stats?.replies_count ?? 0,
reblogs_count: event.event_stats?.reposts_count ?? 0,
favourites_count: event.event_stats?.reactions['+'] ?? 0,
zaps_amount: event.event_stats?.zaps_amount ?? 0,
favourited: reactionEvent?.content === '+',
reblogged: Boolean(repostEvent),
muted: false,
bookmarked: Boolean(bookmarkEvent),
pinned: Boolean(pinEvent),
reblog: null,
application: null,
media_attachments: media
.map((m) => renderAttachment({ tags: m }))
.filter((m): m is MastodonAttachment => Boolean(m)),
mentions,
tags: [],
emojis: renderEmojis(event),
poll: null,
quote: !event.quote ? null : await renderStatus(event.quote, { depth: depth + 1 }),
quote_id: event.quote?.id ?? null,
uri: Conf.local(`/users/${account.acct}/statuses/${event.id}`),
url: Conf.local(`/@${account.acct}/${event.id}`),
zapped: Boolean(zapEvent),
ditto: {
external_url: Conf.external(nevent),
},
pleroma: {
emoji_reactions: reactions,
expires_at: !isNaN(expiresAt.getTime()) ? expiresAt.toISOString() : undefined,
quotes_count: event.event_stats?.quotes_count ?? 0,
},
};
}
async function renderReblog(event: DittoEvent, opts: RenderStatusOpts): Promise<MastodonStatus | undefined> {
const { viewerPubkey } = opts;
if (!event.repost) return;
const status = await renderStatus(event, {}); // omit viewerPubkey intentionally
if (!status) return;
const reblog = await renderStatus(event.repost, { viewerPubkey }) ?? null;
return {
...status,
in_reply_to_id: null,
in_reply_to_account_id: null,
reblog,
};
}
function renderMention(event: NostrEvent): MastodonMention {
const account = renderAccount(event);
return {
id: account.id,
acct: account.acct,
username: account.username,
url: account.url,
};
}
function buildInlineRecipients(mentions: MastodonMention[]): string {
if (!mentions.length) return '';
const elements = mentions.reduce<string[]>((acc, { url, username }) => {
const name = nip19.BECH32_REGEX.test(username) ? username.substring(0, 8) : username;
acc.push(`<span class="h-card"><a class="u-url mention" href="${url}" rel="ugc">@<span>${name}</span></a></span>`);
return acc;
}, []);
return `<span class="recipients-inline">${elements.join(' ')} </span>`;
}
export { renderReblog, renderStatus };

View file

@ -1,6 +1,6 @@
import { DittoConf } from '@ditto/conf';
import DOMPurify from 'isomorphic-dompurify'; import DOMPurify from 'isomorphic-dompurify';
import { Conf } from '@/config.ts';
import { html } from '@/utils/html.ts'; import { html } from '@/utils/html.ts';
import { MetadataEntities } from '@/utils/og-metadata.ts'; import { MetadataEntities } from '@/utils/og-metadata.ts';
@ -9,13 +9,13 @@ import { MetadataEntities } from '@/utils/og-metadata.ts';
* @param opts the metadata to use to fill the template. * @param opts the metadata to use to fill the template.
* @returns the built OpenGraph metadata. * @returns the built OpenGraph metadata.
*/ */
export function renderMetadata(url: string, { account, status, instance }: MetadataEntities): string { export function renderMetadata(conf: DittoConf, req: Request, { account, status, instance }: MetadataEntities): string {
const tags: string[] = []; const tags: string[] = [];
const title = account ? `${account.display_name} (@${account.acct})` : instance.name; const title = account ? `${account.display_name} (@${account.acct})` : instance.name;
const attachment = status?.media_attachments?.find((a) => a.type === 'image'); const attachment = status?.media_attachments?.find((a) => a.type === 'image');
const description = DOMPurify.sanitize(status?.content || account?.note || instance.tagline, { ALLOWED_TAGS: [] }); const description = DOMPurify.sanitize(status?.content || account?.note || instance.tagline, { ALLOWED_TAGS: [] });
const image = attachment?.preview_url || account?.avatar_static || instance.picture || Conf.local('/favicon.ico'); const image = attachment?.preview_url || account?.avatar_static || instance.picture || conf.local('/favicon.ico');
const siteName = instance?.name; const siteName = instance?.name;
const width = attachment?.meta?.original?.width; const width = attachment?.meta?.original?.width;
const height = attachment?.meta?.original?.height; const height = attachment?.meta?.original?.height;
@ -48,7 +48,7 @@ export function renderMetadata(url: string, { account, status, instance }: Metad
// Extra tags (always present if other tags exist). // Extra tags (always present if other tags exist).
if (tags.length > 0) { if (tags.length > 0) {
tags.push(html`<meta property="og:url" content="${url}">`); tags.push(html`<meta property="og:url" content="${conf.local(req.url)}">`);
tags.push('<meta property="og:type" content="website">'); tags.push('<meta property="og:type" content="website">');
tags.push('<meta name="twitter:card" content="summary">'); tags.push('<meta name="twitter:card" content="summary">');
} }

View file

@ -1,16 +1,16 @@
import { DittoConf } from '@ditto/conf';
import { NostrEvent, NostrRelayOK, NPolicy } from '@nostrify/nostrify'; import { NostrEvent, NostrRelayOK, NPolicy } from '@nostrify/nostrify';
import { logi } from '@soapbox/logi'; import { logi } from '@soapbox/logi';
import * as Comlink from 'comlink'; import * as Comlink from 'comlink';
import { Conf } from '@/config.ts';
import type { CustomPolicy } from '@/workers/policy.worker.ts'; import type { CustomPolicy } from '@/workers/policy.worker.ts';
class PolicyWorker implements NPolicy { export class PolicyWorker implements NPolicy {
private worker: Comlink.Remote<CustomPolicy>; private worker: Comlink.Remote<CustomPolicy>;
private ready: Promise<void>; private ready: Promise<void>;
private enabled = true; private enabled = true;
constructor() { constructor(private conf: DittoConf) {
this.worker = Comlink.wrap<CustomPolicy>( this.worker = Comlink.wrap<CustomPolicy>(
new Worker( new Worker(
new URL('./policy.worker.ts', import.meta.url), new URL('./policy.worker.ts', import.meta.url),
@ -19,8 +19,8 @@ class PolicyWorker implements NPolicy {
name: 'PolicyWorker', name: 'PolicyWorker',
deno: { deno: {
permissions: { permissions: {
read: [Conf.denoDir, Conf.policy, Conf.dataDir], read: [conf.denoDir, conf.policy, conf.dataDir],
write: [Conf.dataDir], write: [conf.dataDir],
net: 'inherit', net: 'inherit',
env: false, env: false,
import: true, import: true,
@ -46,16 +46,16 @@ class PolicyWorker implements NPolicy {
private async init(): Promise<void> { private async init(): Promise<void> {
try { try {
await this.worker.init({ await this.worker.init({
path: Conf.policy, path: this.conf.policy,
databaseUrl: Conf.databaseUrl, databaseUrl: this.conf.databaseUrl,
pubkey: Conf.pubkey, pubkey: this.conf.pubkey,
}); });
logi({ logi({
level: 'info', level: 'info',
ns: 'ditto.system.policy', ns: 'ditto.system.policy',
msg: 'Using custom policy', msg: 'Using custom policy',
path: Conf.policy, path: this.conf.policy,
enabled: true, enabled: true,
}); });
} catch (e) { } catch (e) {
@ -76,16 +76,14 @@ class PolicyWorker implements NPolicy {
level: 'warn', level: 'warn',
ns: 'ditto.system.policy', ns: 'ditto.system.policy',
msg: 'Custom policies are not supported with PGlite. The policy is disabled.', msg: 'Custom policies are not supported with PGlite. The policy is disabled.',
path: Conf.policy, path: this.conf.policy,
enabled: false, enabled: false,
}); });
this.enabled = false; this.enabled = false;
return; return;
} }
throw new Error(`DITTO_POLICY (error importing policy): ${Conf.policy}`); throw new Error(`DITTO_POLICY (error importing policy): ${this.conf.policy}`);
} }
} }
} }
export const policyWorker = new PolicyWorker();

View file

@ -1,9 +1,14 @@
import { DittoConf } from '@ditto/conf';
import { NostrEvent } from '@nostrify/nostrify'; import { NostrEvent } from '@nostrify/nostrify';
import * as Comlink from 'comlink'; import * as Comlink from 'comlink';
import { VerifiedEvent, verifyEvent } from 'nostr-tools'; import { VerifiedEvent, verifyEvent } from 'nostr-tools';
import '@/nostr-wasm.ts'; import '@/nostr-wasm.ts';
import '@/sentry.ts'; import { startSentry } from '@/sentry.ts';
const conf = new DittoConf(Deno.env);
startSentry(conf);
export const VerifyWorker = { export const VerifyWorker = {
verifyEvent(event: NostrEvent): event is VerifiedEvent { verifyEvent(event: NostrEvent): event is VerifiedEvent {

View file

@ -1,13 +1,16 @@
import { DittoConf } from '@ditto/conf';
import { JsonParseStream } from '@std/json/json-parse-stream'; import { JsonParseStream } from '@std/json/json-parse-stream';
import { TextLineStream } from '@std/streams/text-line-stream'; import { TextLineStream } from '@std/streams/text-line-stream';
import { AdminSigner } from '../packages/ditto/signers/AdminSigner.ts'; import { AdminSigner } from '../packages/ditto/signers/AdminSigner.ts';
import { Storages } from '../packages/ditto/storages.ts'; import { DittoStorages } from '../packages/ditto/DittoStorages.ts';
import { type EventStub } from '../packages/ditto/utils/api.ts'; import { type EventStub } from '../packages/ditto/utils/api.ts';
import { nostrNow } from '../packages/ditto/utils.ts'; import { nostrNow } from '../packages/ditto/utils.ts';
const signer = new AdminSigner(); const conf = new DittoConf(Deno.env);
const store = await Storages.db(); const signer = new AdminSigner(conf);
const storages = new DittoStorages(conf);
const store = await storages.db();
const readable = Deno.stdin.readable const readable = Deno.stdin.readable
.pipeThrough(new TextDecoderStream()) .pipeThrough(new TextDecoderStream())

View file

@ -1,11 +1,14 @@
import { DittoConf } from '@ditto/conf';
import { NSchema } from '@nostrify/nostrify'; import { NSchema } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { AdminSigner } from '../packages/ditto/signers/AdminSigner.ts'; import { AdminSigner } from '../packages/ditto/signers/AdminSigner.ts';
import { Storages } from '../packages/ditto/storages.ts'; import { DittoStorages } from '../packages/ditto/DittoStorages.ts';
import { nostrNow } from '../packages/ditto/utils.ts'; import { nostrNow } from '../packages/ditto/utils.ts';
const store = await Storages.db(); const conf = new DittoConf(Deno.env);
const storages = new DittoStorages(conf);
const store = await storages.db();
const [pubkeyOrNpub, role] = Deno.args; const [pubkeyOrNpub, role] = Deno.args;
const pubkey = pubkeyOrNpub.startsWith('npub1') ? nip19.decode(pubkeyOrNpub as `npub1${string}`).data : pubkeyOrNpub; const pubkey = pubkeyOrNpub.startsWith('npub1') ? nip19.decode(pubkeyOrNpub as `npub1${string}`).data : pubkeyOrNpub;
@ -20,7 +23,7 @@ if (!['admin', 'user'].includes(role)) {
Deno.exit(1); Deno.exit(1);
} }
const signer = new AdminSigner(); const signer = new AdminSigner(conf);
const admin = await signer.getPublicKey(); const admin = await signer.getPublicKey();
const [existing] = await store.query([{ const [existing] = await store.query([{

View file

@ -1,7 +1,11 @@
import { DittoConf } from '@ditto/conf';
import { NostrFilter } from '@nostrify/nostrify'; import { NostrFilter } from '@nostrify/nostrify';
import { Command, InvalidOptionArgumentError } from 'commander'; import { Command, InvalidOptionArgumentError } from 'commander';
import { Storages } from '../packages/ditto/storages.ts'; import { DittoStorages } from '../packages/ditto/DittoStorages.ts';
const conf = new DittoConf(Deno.env);
const storages = new DittoStorages(conf);
interface ExportFilter { interface ExportFilter {
authors?: string[]; authors?: string[];
@ -98,7 +102,7 @@ export function buildFilter(args: ExportFilter) {
} }
async function exportEvents(args: ExportFilter) { async function exportEvents(args: ExportFilter) {
const store = await Storages.db(); const store = await storages.db();
let filter: NostrFilter = {}; let filter: NostrFilter = {};
try { try {

View file

@ -1,13 +1,15 @@
import { Semaphore } from '@core/asyncutil'; import { Semaphore } from '@core/asyncutil';
import { DittoConf } from '@ditto/conf';
import { NostrEvent } from '@nostrify/nostrify'; import { NostrEvent } from '@nostrify/nostrify';
import { JsonParseStream } from '@std/json/json-parse-stream'; import { JsonParseStream } from '@std/json/json-parse-stream';
import { TextLineStream } from '@std/streams/text-line-stream'; import { TextLineStream } from '@std/streams/text-line-stream';
import { Conf } from '../packages/ditto/config.ts'; import { DittoStorages } from '../packages/ditto/DittoStorages.ts';
import { Storages } from '../packages/ditto/storages.ts';
const store = await Storages.db(); const conf = new DittoConf(Deno.env);
const sem = new Semaphore(Conf.pg.poolSize); const storages = new DittoStorages(conf);
const store = await storages.db();
const sem = new Semaphore(conf.pg.poolSize);
console.warn('Importing events...'); console.warn('Importing events...');

View file

@ -1,7 +1,12 @@
import { Storages } from '../packages/ditto/storages.ts'; import { DittoConf } from '@ditto/conf';
import { DittoStorages } from '../packages/ditto/DittoStorages.ts';
const conf = new DittoConf(Deno.env);
const storages = new DittoStorages(conf);
// This migrates kysely internally. // This migrates kysely internally.
const kysely = await Storages.kysely(); const kysely = await storages.kysely();
// Close the connection before exiting. // Close the connection before exiting.
await kysely.destroy(); await kysely.destroy();

Some files were not shown because too many files have changed in this diff Show more