Merge branch 'router' into 'main'

Switch to @ditto/router

See merge request soapbox-pub/ditto!683
This commit is contained in:
Alex Gleason 2025-02-22 05:32:52 +00:00
commit 68a3db0c5e
92 changed files with 1770 additions and 1233 deletions

View file

@ -1,12 +1,13 @@
{ {
"version": "1.1.0", "version": "1.1.0",
"workspace": [ "workspace": [
"./packages/api",
"./packages/conf", "./packages/conf",
"./packages/db", "./packages/db",
"./packages/ditto", "./packages/ditto",
"./packages/lang", "./packages/lang",
"./packages/mastoapi",
"./packages/metrics", "./packages/metrics",
"./packages/nip98",
"./packages/policies", "./packages/policies",
"./packages/ratelimiter", "./packages/ratelimiter",
"./packages/translators", "./packages/translators",

View file

@ -1,7 +0,0 @@
{
"name": "@ditto/api",
"version": "1.1.0",
"exports": {
"./middleware": "./middleware/mod.ts"
}
}

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), async (c) => c.text(await c.var.conf.signer.getPublicKey()));
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

@ -0,0 +1,9 @@
import { assertEquals } from '@std/assert';
import { DummyDB } from './DummyDB.ts';
Deno.test('DummyDB', async () => {
const db = new DummyDB();
const rows = await db.kysely.selectFrom('nostr_events').selectAll().execute();
assertEquals(rows, []);
});

View file

@ -0,0 +1,29 @@
import { DummyDriver, Kysely, PostgresAdapter, PostgresIntrospector, PostgresQueryCompiler } from 'kysely';
import type { DittoDB } from '../DittoDB.ts';
import type { DittoTables } from '../DittoTables.ts';
export class DummyDB implements DittoDB {
readonly kysely: Kysely<DittoTables>;
readonly poolSize = 0;
readonly availableConnections = 0;
constructor() {
this.kysely = new Kysely<DittoTables>({
dialect: {
createAdapter: () => new PostgresAdapter(),
createDriver: () => new DummyDriver(),
createIntrospector: (db) => new PostgresIntrospector(db),
createQueryCompiler: () => new PostgresQueryCompiler(),
},
});
}
listen(): void {
// noop
}
[Symbol.asyncDispose](): Promise<void> {
return Promise.resolve();
}
}

View file

@ -1,4 +1,7 @@
export { DittoPglite } from './adapters/DittoPglite.ts';
export { DittoPolyPg } from './adapters/DittoPolyPg.ts'; export { DittoPolyPg } from './adapters/DittoPolyPg.ts';
export { DittoPostgres } from './adapters/DittoPostgres.ts';
export { DummyDB } from './adapters/DummyDB.ts';
export type { DittoDB } from './DittoDB.ts'; export type { DittoDB } from './DittoDB.ts';
export type { DittoTables } from './DittoTables.ts'; export type { DittoTables } from './DittoTables.ts';

View file

@ -1,16 +1,18 @@
import { confMw } from '@ditto/api/middleware'; import { DittoConf } from '@ditto/conf';
import { type DittoConf } from '@ditto/conf'; import { DittoDB } from '@ditto/db';
import { DittoTables } from '@ditto/db'; import { paginationMiddleware, tokenMiddleware, userMiddleware } from '@ditto/mastoapi/middleware';
import { DittoApp, type DittoEnv } from '@ditto/mastoapi/router';
import { type DittoTranslator } from '@ditto/translators'; import { type DittoTranslator } from '@ditto/translators';
import { type Context, Env as HonoEnv, Handler, Hono, Input as HonoInput, MiddlewareHandler } from '@hono/hono'; import { type Context, Handler, Input as HonoInput, MiddlewareHandler } from '@hono/hono';
import { every } from '@hono/hono/combine'; import { every } from '@hono/hono/combine';
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 { NostrEvent, NostrSigner, NRelay, NUploader } from '@nostrify/nostrify';
import { Kysely } from 'kysely';
import '@/startup.ts'; import '@/startup.ts';
import { Conf } from '@/config.ts';
import { Storages } from '@/storages.ts';
import { Time } from '@/utils/time.ts'; import { Time } from '@/utils/time.ts';
import { import {
@ -135,39 +137,37 @@ import { metricsController } from '@/controllers/metrics.ts';
import { manifestController } from '@/controllers/manifest.ts'; 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 { auth98Middleware, 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';
import { notActivitypubMiddleware } from '@/middleware/notActivitypubMiddleware.ts'; import { notActivitypubMiddleware } from '@/middleware/notActivitypubMiddleware.ts';
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 { 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';
export interface AppEnv extends HonoEnv { export interface AppEnv extends DittoEnv {
Variables: { Variables: {
conf: DittoConf; conf: DittoConf;
/** 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;
/** Uploader for the user to upload files. */ /** Uploader for the user to upload files. */
uploader?: NUploader; uploader?: NUploader;
/** NIP-98 signed event proving the pubkey is owned by the user. */ /** NIP-98 signed event proving the pubkey is owned by the user. */
proof?: NostrEvent; proof?: NostrEvent;
/** Kysely instance for the database. */ /** Kysely instance for the database. */
kysely: Kysely<DittoTables>; db: DittoDB;
/** Storage for the user, might filter out unwanted content. */ /** Base database store. No content filtering. */
store: NStore; relay: NRelay;
/** Normalized pagination params. */ /** Normalized pagination params. */
pagination: { since?: number; until?: number; limit: number }; pagination: { since?: number; until?: number; limit: number };
/** Normalized list pagination params. */
listPagination: { offset: number; limit: number };
/** Translation service. */ /** Translation service. */
translator?: DittoTranslator; translator?: DittoTranslator;
signal: AbortSignal;
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: NostrSigner;
/** User's relay. Might filter out unwanted content. */
relay: NRelay;
};
}; };
} }
@ -176,35 +176,46 @@ type AppMiddleware = MiddlewareHandler<AppEnv>;
// deno-lint-ignore no-explicit-any // deno-lint-ignore no-explicit-any
type AppController<P extends string = any> = Handler<AppEnv, P, HonoInput, Response | Promise<Response>>; type AppController<P extends string = any> = Handler<AppEnv, P, HonoInput, Response | Promise<Response>>;
const app = new Hono<AppEnv>({ strict: false }); const app = new DittoApp({
conf: Conf,
db: await Storages.database(),
relay: await Storages.db(),
}, {
strict: false,
});
/** User-provided files in the gitignored `public/` directory. */ /** User-provided files in the gitignored `public/` directory. */
const publicFiles = serveStatic({ root: './public/' }); const publicFiles = serveStatic({ root: './public/' });
/** Static files provided by the Ditto repo, checked into git. */ /** Static files provided by the Ditto repo, checked into git. */
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 })); app.use(cacheControlMiddleware({ noStore: true }));
const ratelimit = every( const ratelimit = every(
rateLimitMiddleware(30, Time.seconds(5), false), rateLimitMiddleware(30, Time.seconds(5), false),
rateLimitMiddleware(300, Time.minutes(5), false), rateLimitMiddleware(300, Time.minutes(5), false),
); );
app.use('/api/*', metricsMiddleware, ratelimit, paginationMiddleware, logiMiddleware); const socketTokenMiddleware = tokenMiddleware((c) => {
const token = c.req.header('sec-websocket-protocol');
if (token) {
return `Bearer ${token}`;
}
});
app.use('/api/*', metricsMiddleware, ratelimit, paginationMiddleware(), logiMiddleware);
app.use('/.well-known/*', metricsMiddleware, ratelimit, logiMiddleware); app.use('/.well-known/*', metricsMiddleware, ratelimit, logiMiddleware);
app.use('/nodeinfo/*', metricsMiddleware, ratelimit, logiMiddleware); app.use('/nodeinfo/*', metricsMiddleware, ratelimit, logiMiddleware);
app.use('/oauth/*', metricsMiddleware, ratelimit, logiMiddleware); app.use('/oauth/*', metricsMiddleware, ratelimit, logiMiddleware);
app.get('/api/v1/streaming', metricsMiddleware, ratelimit, streamingController); app.get('/api/v1/streaming', socketTokenMiddleware, metricsMiddleware, ratelimit, streamingController);
app.get('/relay', metricsMiddleware, ratelimit, relayController); app.get('/relay', metricsMiddleware, ratelimit, relayController);
app.use( app.use(
cspMiddleware(), cspMiddleware(),
cors({ origin: '*', exposeHeaders: ['link'] }), cors({ origin: '*', exposeHeaders: ['link'] }),
signerMiddleware, tokenMiddleware(),
uploaderMiddleware, uploaderMiddleware,
auth98Middleware(),
storeMiddleware,
); );
app.get('/metrics', metricsController); app.get('/metrics', metricsController);
@ -251,27 +262,27 @@ 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', userMiddleware({ verify: true }), createAccountController);
app.get('/api/v1/accounts/verify_credentials', requireSigner, verifyCredentialsController); app.get('/api/v1/accounts/verify_credentials', userMiddleware(), verifyCredentialsController);
app.patch('/api/v1/accounts/update_credentials', requireSigner, updateCredentialsController); app.patch('/api/v1/accounts/update_credentials', userMiddleware(), updateCredentialsController);
app.get('/api/v1/accounts/search', accountSearchController); app.get('/api/v1/accounts/search', accountSearchController);
app.get('/api/v1/accounts/lookup', accountLookupController); app.get('/api/v1/accounts/lookup', accountLookupController);
app.get('/api/v1/accounts/relationships', requireSigner, relationshipsController); app.get('/api/v1/accounts/relationships', userMiddleware(), relationshipsController);
app.get('/api/v1/accounts/familiar_followers', requireSigner, familiarFollowersController); app.get('/api/v1/accounts/familiar_followers', userMiddleware(), familiarFollowersController);
app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/block', requireSigner, blockController); app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/block', userMiddleware(), blockController);
app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/unblock', requireSigner, unblockController); app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/unblock', userMiddleware(), unblockController);
app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/mute', requireSigner, muteController); app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/mute', userMiddleware(), muteController);
app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/unmute', requireSigner, unmuteController); app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/unmute', userMiddleware(), unmuteController);
app.post( app.post(
'/api/v1/accounts/:pubkey{[0-9a-f]{64}}/follow', '/api/v1/accounts/:pubkey{[0-9a-f]{64}}/follow',
rateLimitMiddleware(2, Time.seconds(1)), rateLimitMiddleware(2, Time.seconds(1)),
requireSigner, userMiddleware(),
followController, followController,
); );
app.post( app.post(
'/api/v1/accounts/:pubkey{[0-9a-f]{64}}/unfollow', '/api/v1/accounts/:pubkey{[0-9a-f]{64}}/unfollow',
rateLimitMiddleware(2, Time.seconds(1)), rateLimitMiddleware(2, Time.seconds(1)),
requireSigner, userMiddleware(),
unfollowController, unfollowController,
); );
app.get( app.get(
@ -295,22 +306,22 @@ app.get('/api/v1/statuses/:id{[0-9a-f]{64}}/favourited_by', favouritedByControll
app.get('/api/v1/statuses/:id{[0-9a-f]{64}}/reblogged_by', rebloggedByController); app.get('/api/v1/statuses/:id{[0-9a-f]{64}}/reblogged_by', rebloggedByController);
app.get('/api/v1/statuses/:id{[0-9a-f]{64}}/context', contextController); app.get('/api/v1/statuses/:id{[0-9a-f]{64}}/context', contextController);
app.get('/api/v1/statuses/:id{[0-9a-f]{64}}', statusController); app.get('/api/v1/statuses/:id{[0-9a-f]{64}}', statusController);
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/favourite', requireSigner, favouriteController); app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/favourite', userMiddleware(), favouriteController);
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/bookmark', requireSigner, bookmarkController); app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/bookmark', userMiddleware(), bookmarkController);
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unbookmark', requireSigner, unbookmarkController); app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unbookmark', userMiddleware(), unbookmarkController);
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/pin', requireSigner, pinController); app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/pin', userMiddleware(), pinController);
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unpin', requireSigner, unpinController); app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unpin', userMiddleware(), unpinController);
app.post( app.post(
'/api/v1/statuses/:id{[0-9a-f]{64}}/translate', '/api/v1/statuses/:id{[0-9a-f]{64}}/translate',
requireSigner, userMiddleware(),
rateLimitMiddleware(15, Time.minutes(1)), rateLimitMiddleware(15, Time.minutes(1)),
translatorMiddleware, translatorMiddleware,
translateController, translateController,
); );
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/reblog', requireSigner, reblogStatusController); app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/reblog', userMiddleware(), reblogStatusController);
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unreblog', requireSigner, unreblogStatusController); app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unreblog', userMiddleware(), unreblogStatusController);
app.post('/api/v1/statuses', requireSigner, createStatusController); app.post('/api/v1/statuses', userMiddleware(), createStatusController);
app.delete('/api/v1/statuses/:id{[0-9a-f]{64}}', requireSigner, deleteStatusController); app.delete('/api/v1/statuses/:id{[0-9a-f]{64}}', userMiddleware(), deleteStatusController);
app.get('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/quotes', quotesController); app.get('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/quotes', quotesController);
@ -321,7 +332,7 @@ app.put(
); );
app.post('/api/v2/media', mediaController); app.post('/api/v2/media', mediaController);
app.get('/api/v1/timelines/home', rateLimitMiddleware(8, Time.seconds(30)), requireSigner, homeTimelineController); app.get('/api/v1/timelines/home', rateLimitMiddleware(8, Time.seconds(30)), userMiddleware(), homeTimelineController);
app.get('/api/v1/timelines/public', rateLimitMiddleware(8, Time.seconds(30)), publicTimelineController); app.get('/api/v1/timelines/public', rateLimitMiddleware(8, Time.seconds(30)), publicTimelineController);
app.get('/api/v1/timelines/tag/:hashtag', rateLimitMiddleware(8, Time.seconds(30)), hashtagTimelineController); app.get('/api/v1/timelines/tag/:hashtag', rateLimitMiddleware(8, Time.seconds(30)), hashtagTimelineController);
app.get('/api/v1/timelines/suggested', rateLimitMiddleware(8, Time.seconds(30)), suggestedTimelineController); app.get('/api/v1/timelines/suggested', rateLimitMiddleware(8, Time.seconds(30)), suggestedTimelineController);
@ -357,42 +368,42 @@ app.get('/api/v1/suggestions', suggestionsV1Controller);
app.get('/api/v2/suggestions', suggestionsV2Controller); app.get('/api/v2/suggestions', suggestionsV2Controller);
app.get('/api/v2/ditto/suggestions/local', localSuggestionsController); app.get('/api/v2/ditto/suggestions/local', localSuggestionsController);
app.get('/api/v1/notifications', rateLimitMiddleware(8, Time.seconds(30)), requireSigner, notificationsController); app.get('/api/v1/notifications', rateLimitMiddleware(8, Time.seconds(30)), userMiddleware(), notificationsController);
app.get('/api/v1/notifications/:id', requireSigner, notificationController); app.get('/api/v1/notifications/:id', userMiddleware(), notificationController);
app.get('/api/v1/favourites', requireSigner, favouritesController); app.get('/api/v1/favourites', userMiddleware(), favouritesController);
app.get('/api/v1/bookmarks', requireSigner, bookmarksController); app.get('/api/v1/bookmarks', userMiddleware(), bookmarksController);
app.get('/api/v1/blocks', requireSigner, blocksController); app.get('/api/v1/blocks', userMiddleware(), blocksController);
app.get('/api/v1/mutes', requireSigner, mutesController); app.get('/api/v1/mutes', userMiddleware(), mutesController);
app.get('/api/v1/markers', requireProof(), markersController); app.get('/api/v1/markers', userMiddleware({ verify: true }), markersController);
app.post('/api/v1/markers', requireProof(), updateMarkersController); app.post('/api/v1/markers', userMiddleware({ verify: true }), updateMarkersController);
app.get('/api/v1/push/subscription', requireSigner, getSubscriptionController); app.get('/api/v1/push/subscription', userMiddleware(), getSubscriptionController);
app.post('/api/v1/push/subscription', requireProof(), pushSubscribeController); app.post('/api/v1/push/subscription', userMiddleware({ verify: true }), pushSubscribeController);
app.get('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions', reactionsController); app.get('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions', reactionsController);
app.get('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', reactionsController); app.get('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', reactionsController);
app.put('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', requireSigner, reactionController); app.put('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', userMiddleware(), reactionController);
app.delete('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', requireSigner, deleteReactionController); app.delete('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', userMiddleware(), deleteReactionController);
app.get('/api/v1/pleroma/admin/config', requireRole('admin'), configController); app.get('/api/v1/pleroma/admin/config', userMiddleware({ role: 'admin' }), configController);
app.post('/api/v1/pleroma/admin/config', requireRole('admin'), updateConfigController); app.post('/api/v1/pleroma/admin/config', userMiddleware({ role: 'admin' }), updateConfigController);
app.delete('/api/v1/pleroma/admin/statuses/:id', requireRole('admin'), pleromaAdminDeleteStatusController); app.delete('/api/v1/pleroma/admin/statuses/:id', userMiddleware({ role: 'admin' }), pleromaAdminDeleteStatusController);
app.get('/api/v1/admin/ditto/relays', requireRole('admin'), adminRelaysController); app.get('/api/v1/admin/ditto/relays', userMiddleware({ role: 'admin' }), adminRelaysController);
app.put('/api/v1/admin/ditto/relays', requireRole('admin'), adminSetRelaysController); app.put('/api/v1/admin/ditto/relays', userMiddleware({ role: 'admin' }), adminSetRelaysController);
app.put('/api/v1/admin/ditto/instance', requireRole('admin'), updateInstanceController); app.put('/api/v1/admin/ditto/instance', userMiddleware({ role: 'admin' }), updateInstanceController);
app.post('/api/v1/ditto/names', requireSigner, nameRequestController); app.post('/api/v1/ditto/names', userMiddleware(), nameRequestController);
app.get('/api/v1/ditto/names', requireSigner, nameRequestsController); app.get('/api/v1/ditto/names', userMiddleware(), nameRequestsController);
app.get('/api/v1/ditto/captcha', rateLimitMiddleware(3, Time.minutes(1)), captchaController); app.get('/api/v1/ditto/captcha', rateLimitMiddleware(3, Time.minutes(1)), captchaController);
app.post( app.post(
'/api/v1/ditto/captcha/:id/verify', '/api/v1/ditto/captcha/:id/verify',
rateLimitMiddleware(8, Time.minutes(1)), rateLimitMiddleware(8, Time.minutes(1)),
requireProof(), userMiddleware({ verify: true }),
captchaVerifyController, captchaVerifyController,
); );
@ -403,44 +414,59 @@ app.get(
); );
app.get('/api/v1/ditto/:id{[0-9a-f]{64}}/zap_splits', statusZapSplitsController); app.get('/api/v1/ditto/:id{[0-9a-f]{64}}/zap_splits', statusZapSplitsController);
app.put('/api/v1/admin/ditto/zap_splits', requireRole('admin'), updateZapSplitsController); app.put('/api/v1/admin/ditto/zap_splits', userMiddleware({ role: 'admin' }), updateZapSplitsController);
app.delete('/api/v1/admin/ditto/zap_splits', requireRole('admin'), deleteZapSplitsController); app.delete('/api/v1/admin/ditto/zap_splits', userMiddleware({ role: 'admin' }), deleteZapSplitsController);
app.post('/api/v1/ditto/zap', requireSigner, zapController); app.post('/api/v1/ditto/zap', userMiddleware(), zapController);
app.get('/api/v1/ditto/statuses/:id{[0-9a-f]{64}}/zapped_by', zappedByController); app.get('/api/v1/ditto/statuses/:id{[0-9a-f]{64}}/zapped_by', zappedByController);
app.route('/api/v1/ditto/cashu', cashuApp); app.route('/api/v1/ditto/cashu', cashuApp);
app.post('/api/v1/reports', requireSigner, reportController); app.post('/api/v1/reports', userMiddleware(), reportController);
app.get('/api/v1/admin/reports', requireSigner, requireRole('admin'), adminReportsController); app.get('/api/v1/admin/reports', userMiddleware(), userMiddleware({ role: 'admin' }), adminReportsController);
app.get('/api/v1/admin/reports/:id{[0-9a-f]{64}}', requireSigner, requireRole('admin'), adminReportController); app.get(
'/api/v1/admin/reports/:id{[0-9a-f]{64}}',
userMiddleware(),
userMiddleware({ role: 'admin' }),
adminReportController,
);
app.post( app.post(
'/api/v1/admin/reports/:id{[0-9a-f]{64}}/resolve', '/api/v1/admin/reports/:id{[0-9a-f]{64}}/resolve',
requireSigner, userMiddleware(),
requireRole('admin'), userMiddleware({ role: 'admin' }),
adminReportResolveController, adminReportResolveController,
); );
app.post( app.post(
'/api/v1/admin/reports/:id{[0-9a-f]{64}}/reopen', '/api/v1/admin/reports/:id{[0-9a-f]{64}}/reopen',
requireSigner, userMiddleware(),
requireRole('admin'), userMiddleware({ role: 'admin' }),
adminReportReopenController, adminReportReopenController,
); );
app.get('/api/v1/admin/accounts', requireRole('admin'), adminAccountsController); app.get('/api/v1/admin/accounts', userMiddleware({ role: 'admin' }), adminAccountsController);
app.post('/api/v1/admin/accounts/:id{[0-9a-f]{64}}/action', requireSigner, requireRole('admin'), adminActionController); app.post(
'/api/v1/admin/accounts/:id{[0-9a-f]{64}}/action',
userMiddleware(),
userMiddleware({ role: 'admin' }),
adminActionController,
);
app.post( app.post(
'/api/v1/admin/accounts/:id{[0-9a-f]{64}}/approve', '/api/v1/admin/accounts/:id{[0-9a-f]{64}}/approve',
requireSigner, userMiddleware(),
requireRole('admin'), userMiddleware({ role: 'admin' }),
adminApproveController, adminApproveController,
); );
app.post('/api/v1/admin/accounts/:id{[0-9a-f]{64}}/reject', requireSigner, requireRole('admin'), adminRejectController); app.post(
'/api/v1/admin/accounts/:id{[0-9a-f]{64}}/reject',
userMiddleware(),
userMiddleware({ role: 'admin' }),
adminRejectController,
);
app.put('/api/v1/pleroma/admin/users/tag', requireRole('admin'), pleromaAdminTagController); app.put('/api/v1/pleroma/admin/users/tag', userMiddleware({ role: 'admin' }), pleromaAdminTagController);
app.delete('/api/v1/pleroma/admin/users/tag', requireRole('admin'), pleromaAdminUntagController); app.delete('/api/v1/pleroma/admin/users/tag', userMiddleware({ role: 'admin' }), pleromaAdminUntagController);
app.patch('/api/v1/pleroma/admin/users/suggest', requireRole('admin'), pleromaAdminSuggestController); app.patch('/api/v1/pleroma/admin/users/suggest', userMiddleware({ role: 'admin' }), pleromaAdminSuggestController);
app.patch('/api/v1/pleroma/admin/users/unsuggest', requireRole('admin'), pleromaAdminUnsuggestController); app.patch('/api/v1/pleroma/admin/users/unsuggest', userMiddleware({ role: 'admin' }), pleromaAdminUnsuggestController);
// Not (yet) implemented. // Not (yet) implemented.
app.get('/api/v1/custom_emojis', emptyArrayController); app.get('/api/v1/custom_emojis', emptyArrayController);

View file

@ -26,7 +26,9 @@ const createAccountSchema = z.object({
}); });
const createAccountController: AppController = async (c) => { const createAccountController: AppController = async (c) => {
const pubkey = await c.get('signer')?.getPublicKey()!; const { 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) {
@ -46,15 +48,15 @@ const createAccountController: AppController = async (c) => {
}; };
const verifyCredentialsController: AppController = async (c) => { const verifyCredentialsController: AppController = async (c) => {
const signer = c.get('signer')!; const { relay, user } = c.var;
const pubkey = await signer.getPublicKey();
const store = await Storages.db(); const signer = user!.signer;
const pubkey = await signer.getPublicKey();
const [author, [settingsEvent]] = await Promise.all([ const [author, [settingsEvent]] = await Promise.all([
getAuthor(pubkey, { signal: AbortSignal.timeout(5000) }), getAuthor(pubkey, { signal: AbortSignal.timeout(5000) }),
store.query([{ relay.query([{
kinds: [30078], kinds: [30078],
authors: [pubkey], authors: [pubkey],
'#d': ['pub.ditto.pleroma_settings_store'], '#d': ['pub.ditto.pleroma_settings_store'],
@ -115,12 +117,10 @@ const accountSearchQuerySchema = z.object({
}); });
const accountSearchController: AppController = async (c) => { const accountSearchController: AppController = async (c) => {
const { store } = c.var; const { db, relay, user, pagination, signal } = c.var;
const { signal } = c.req.raw; const { limit } = pagination;
const { limit } = c.get('pagination');
const kysely = await Storages.kysely(); const viewerPubkey = await user?.signer.getPublicKey();
const viewerPubkey = await c.get('signer')?.getPublicKey();
const result = accountSearchQuerySchema.safeParse(c.req.query()); const result = accountSearchQuerySchema.safeParse(c.req.query());
@ -144,8 +144,8 @@ const accountSearchController: AppController = async (c) => {
events.push(event); events.push(event);
} else { } else {
const following = viewerPubkey ? await getFollowedPubkeys(viewerPubkey) : new Set<string>(); const following = viewerPubkey ? await getFollowedPubkeys(viewerPubkey) : new Set<string>();
const authors = [...await getPubkeysBySearch(kysely, { q: query, limit, offset: 0, following })]; const authors = [...await getPubkeysBySearch(db.kysely, { q: query, limit, offset: 0, following })];
const profiles = await store.query([{ kinds: [0], authors, limit }], { signal }); const profiles = await relay.query([{ kinds: [0], authors, limit }], { signal });
for (const pubkey of authors) { for (const pubkey of authors) {
const profile = profiles.find((event) => event.pubkey === pubkey); const profile = profiles.find((event) => event.pubkey === pubkey);
@ -155,14 +155,16 @@ const accountSearchController: AppController = async (c) => {
} }
} }
const accounts = await hydrateEvents({ events, store, signal }) const accounts = await hydrateEvents({ events, relay, signal })
.then((events) => events.map((event) => renderAccount(event))); .then((events) => events.map((event) => renderAccount(event)));
return c.json(accounts); return c.json(accounts);
}; };
const relationshipsController: AppController = async (c) => { const relationshipsController: AppController = async (c) => {
const pubkey = await c.get('signer')?.getPublicKey()!; const { 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) {
@ -201,17 +203,17 @@ const accountStatusesQuerySchema = z.object({
}); });
const accountStatusesController: AppController = async (c) => { const accountStatusesController: AppController = async (c) => {
const { conf, user, signal } = c.var;
const pubkey = c.req.param('pubkey'); const pubkey = c.req.param('pubkey');
const { conf } = c.var;
const { since, until } = c.var.pagination; const { since, until } = c.var.pagination;
const { pinned, limit, exclude_replies, tagged, only_media } = accountStatusesQuerySchema.parse(c.req.query()); const { pinned, limit, exclude_replies, tagged, only_media } = accountStatusesQuerySchema.parse(c.req.query());
const { signal } = c.req.raw;
const store = await Storages.db(); const { relay } = c.var;
const [[author], [user]] = await Promise.all([ const [[author], [userEvent]] = await Promise.all([
store.query([{ kinds: [0], authors: [pubkey], limit: 1 }], { signal }), relay.query([{ kinds: [0], authors: [pubkey], limit: 1 }], { signal }),
store.query([{ kinds: [30382], authors: [await conf.signer.getPublicKey()], '#d': [pubkey], limit: 1 }], { relay.query([{ kinds: [30382], authors: [await conf.signer.getPublicKey()], '#d': [pubkey], limit: 1 }], {
signal, signal,
}), }),
]); ]);
@ -220,14 +222,14 @@ const accountStatusesController: AppController = async (c) => {
assertAuthenticated(c, author); assertAuthenticated(c, author);
} }
const names = getTagSet(user?.tags ?? [], 'n'); const names = getTagSet(userEvent?.tags ?? [], 'n');
if (names.has('disabled')) { if (names.has('disabled')) {
return c.json([]); return c.json([]);
} }
if (pinned) { if (pinned) {
const [pinEvent] = await store.query([{ kinds: [10001], authors: [pubkey], limit: 1 }], { signal }); const [pinEvent] = await relay.query([{ kinds: [10001], authors: [pubkey], limit: 1 }], { signal });
if (pinEvent) { if (pinEvent) {
const pinnedEventIds = getTagSet(pinEvent.tags, 'e'); const pinnedEventIds = getTagSet(pinEvent.tags, 'e');
return renderStatuses(c, [...pinnedEventIds].reverse()); return renderStatuses(c, [...pinnedEventIds].reverse());
@ -264,8 +266,8 @@ const accountStatusesController: AppController = async (c) => {
const opts = { signal, limit, timeout: conf.db.timeouts.timelines }; const opts = { signal, limit, timeout: conf.db.timeouts.timelines };
const events = await store.query([filter], opts) const events = await relay.query([filter], opts)
.then((events) => hydrateEvents({ events, store, signal })) .then((events) => hydrateEvents({ events, relay, signal }))
.then((events) => { .then((events) => {
if (exclude_replies) { if (exclude_replies) {
return events.filter((event) => { return events.filter((event) => {
@ -276,7 +278,7 @@ const accountStatusesController: AppController = async (c) => {
return events; return events;
}); });
const viewerPubkey = await c.get('signer')?.getPublicKey(); const viewerPubkey = await user?.signer.getPublicKey();
const statuses = await Promise.all( const statuses = await Promise.all(
events.map((event) => { events.map((event) => {
@ -303,12 +305,11 @@ const updateCredentialsSchema = z.object({
}); });
const updateCredentialsController: AppController = async (c) => { const updateCredentialsController: AppController = async (c) => {
const signer = c.get('signer')!; const { relay, user, signal } = 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);
@ -318,7 +319,7 @@ const updateCredentialsController: AppController = async (c) => {
let event: NostrEvent | undefined; let event: NostrEvent | undefined;
if (keys.length === 1 && keys[0] === 'pleroma_settings_store') { if (keys.length === 1 && keys[0] === 'pleroma_settings_store') {
event = (await store.query([{ kinds: [0], authors: [pubkey] }]))[0]; event = (await relay.query([{ kinds: [0], authors: [pubkey] }]))[0];
} else { } else {
event = await updateEvent( event = await updateEvent(
{ kinds: [0], authors: [pubkey], limit: 1 }, { kinds: [0], authors: [pubkey], limit: 1 },
@ -374,7 +375,7 @@ const updateCredentialsController: AppController = async (c) => {
let account: MastodonAccount; let account: MastodonAccount;
if (event) { if (event) {
await hydrateEvents({ events: [event], store, signal }); await hydrateEvents({ events: [event], relay, signal });
account = await renderAccount(event, { withSource: true, settingsStore }); account = await renderAccount(event, { withSource: true, settingsStore });
} else { } else {
account = await accountFromPubkey(pubkey, { withSource: true, settingsStore }); account = await accountFromPubkey(pubkey, { withSource: true, settingsStore });
@ -393,7 +394,9 @@ 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 { 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(
@ -410,7 +413,9 @@ 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 { 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(
@ -447,7 +452,9 @@ 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 { 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(
@ -462,7 +469,9 @@ const muteController: AppController = async (c) => {
/** 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 { 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(
@ -476,14 +485,12 @@ const unmuteController: AppController = async (c) => {
}; };
const favouritesController: AppController = async (c) => { const favouritesController: AppController = async (c) => {
const pubkey = await c.get('signer')?.getPublicKey()!; const { relay, 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 relay.query(
[{ kinds: [7], authors: [pubkey], ...params }], [{ kinds: [7], authors: [pubkey], ...pagination }],
{ signal }, { signal },
); );
@ -491,10 +498,10 @@ 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 events1 = await relay.query([{ kinds: [1, 20], ids }], { signal })
.then((events) => hydrateEvents({ events, store, signal })); .then((events) => hydrateEvents({ events, relay, signal }));
const viewerPubkey = await c.get('signer')?.getPublicKey(); const viewerPubkey = await user?.signer.getPublicKey();
const statuses = await Promise.all( const statuses = await Promise.all(
events1.map((event) => renderStatus(event, { viewerPubkey })), events1.map((event) => renderStatus(event, { viewerPubkey })),
@ -503,16 +510,15 @@ const favouritesController: AppController = async (c) => {
}; };
const familiarFollowersController: AppController = async (c) => { const familiarFollowersController: AppController = async (c) => {
const store = await Storages.db(); const { relay, user } = c.var;
const signer = c.get('signer')!;
const pubkey = await signer.getPublicKey();
const pubkey = await user!.signer.getPublicKey();
const ids = z.array(z.string()).parse(c.req.queries('id[]')); const ids = z.array(z.string()).parse(c.req.queries('id[]'));
const follows = await getFollowedPubkeys(pubkey); const follows = await getFollowedPubkeys(pubkey);
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 relay.query([{ kinds: [3], authors: [...follows], '#p': [id] }])
.then((events) => hydrateEvents({ events, store })); .then((events) => hydrateEvents({ events, relay }));
const accounts = await Promise.all( const accounts = await Promise.all(
followLists.map((event) => event.author ? renderAccount(event.author) : accountFromPubkey(event.pubkey)), followLists.map((event) => event.author ? renderAccount(event.author) : accountFromPubkey(event.pubkey)),

View file

@ -4,7 +4,6 @@ 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';
@ -29,10 +28,8 @@ const adminAccountQuerySchema = z.object({
}); });
const adminAccountsController: AppController = async (c) => { const adminAccountsController: AppController = async (c) => {
const { conf } = c.var; const { conf, relay, signal, pagination } = c.var;
const store = await Storages.db();
const params = c.get('pagination');
const { signal } = c.req.raw;
const { const {
local, local,
pending, pending,
@ -50,8 +47,8 @@ const adminAccountsController: AppController = async (c) => {
return c.json([]); return c.json([]);
} }
const orig = await store.query( const orig = await relay.query(
[{ kinds: [30383], authors: [adminPubkey], '#k': ['3036'], '#n': ['pending'], ...params }], [{ kinds: [30383], authors: [adminPubkey], '#k': ['3036'], '#n': ['pending'], ...pagination }],
{ signal }, { signal },
); );
@ -61,8 +58,8 @@ 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 relay.query([{ kinds: [3036], ids: [...ids] }])
.then((events) => hydrateEvents({ store, events, signal })); .then((events) => hydrateEvents({ relay, events, signal }));
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);
@ -88,8 +85,8 @@ const adminAccountsController: AppController = async (c) => {
n.push('moderator'); n.push('moderator');
} }
const events = await store.query( const events = await relay.query(
[{ kinds: [30382], authors: [adminPubkey], '#n': n, ...params }], [{ kinds: [30382], authors: [adminPubkey], '#n': n, ...pagination }],
{ signal }, { signal },
); );
@ -99,8 +96,8 @@ 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 relay.query([{ kinds: [0], authors: [...pubkeys] }])
.then((events) => hydrateEvents({ store, events, signal })); .then((events) => hydrateEvents({ relay, events, signal }));
const accounts = await Promise.all( const accounts = await Promise.all(
[...pubkeys].map((pubkey) => { [...pubkeys].map((pubkey) => {
@ -112,14 +109,14 @@ const adminAccountsController: AppController = async (c) => {
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 relay.query([filter], { signal })
.then((events) => hydrateEvents({ store, events, signal })); .then((events) => hydrateEvents({ relay, events, signal }));
const accounts = await Promise.all(events.map(renderAdminAccount)); const accounts = await Promise.all(events.map(renderAdminAccount));
return paginated(c, events, accounts); return paginated(c, events, accounts);
@ -130,9 +127,9 @@ const adminAccountActionSchema = z.object({
}); });
const adminActionController: AppController = async (c) => { const adminActionController: AppController = async (c) => {
const { conf } = c.var; const { conf, relay } = c.var;
const body = await parseBody(c.req.raw); const body = await parseBody(c.req.raw);
const store = await Storages.db();
const result = adminAccountActionSchema.safeParse(body); const result = adminAccountActionSchema.safeParse(body);
const authorId = c.req.param('id'); const authorId = c.req.param('id');
@ -156,13 +153,13 @@ 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) => { relay.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: [await conf.signer.getPublicKey()], '#p': [authorId] }]).catch( relay.remove!([{ kinds: [30360], authors: [await conf.signer.getPublicKey()], '#p': [authorId] }]).catch(
(e: unknown) => { (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) });
}, },
@ -177,9 +174,9 @@ const adminActionController: AppController = async (c) => {
const adminApproveController: AppController = async (c) => { const adminApproveController: AppController = async (c) => {
const { conf } = c.var; const { conf } = c.var;
const eventId = c.req.param('id'); const eventId = c.req.param('id');
const store = await Storages.db(); const { relay } = c.var;
const [event] = await store.query([{ kinds: [3036], ids: [eventId] }]); const [event] = await relay.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);
} }
@ -192,7 +189,7 @@ const adminApproveController: AppController = async (c) => {
return c.json({ error: 'Invalid NIP-05' }, 400); return c.json({ error: 'Invalid NIP-05' }, 400);
} }
const [existing] = await store.query([ const [existing] = await relay.query([
{ kinds: [30360], authors: [await conf.signer.getPublicKey()], '#d': [r.toLowerCase()], limit: 1 }, { kinds: [30360], authors: [await conf.signer.getPublicKey()], '#d': [r.toLowerCase()], limit: 1 },
]); ]);
@ -213,7 +210,7 @@ const adminApproveController: AppController = async (c) => {
}, c); }, c);
await updateEventInfo(eventId, { pending: false, approved: true, rejected: false }, c); await updateEventInfo(eventId, { pending: false, approved: true, rejected: false }, c);
await hydrateEvents({ events: [event], store }); await hydrateEvents({ events: [event], relay });
const nameRequest = await renderNameRequest(event); const nameRequest = await renderNameRequest(event);
return c.json(nameRequest); return c.json(nameRequest);
@ -221,15 +218,15 @@ 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 { relay } = c.var;
const [event] = await store.query([{ kinds: [3036], ids: [eventId] }]); const [event] = await relay.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(eventId, { pending: false, approved: false, rejected: true }, c);
await hydrateEvents({ events: [event], store }); await hydrateEvents({ events: [event], relay });
const nameRequest = await renderNameRequest(event); const nameRequest = await renderNameRequest(event);
return c.json(nameRequest); return c.json(nameRequest);

View file

@ -1,15 +1,14 @@
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 { relay, user, signal } = c.var;
const pubkey = await c.get('signer')?.getPublicKey()!;
const { signal } = c.req.raw;
const [event10003] = await store.query( const pubkey = await user!.signer.getPublicKey();
const [event10003] = await relay.query(
[{ kinds: [10003], authors: [pubkey], limit: 1 }], [{ kinds: [10003], authors: [pubkey], limit: 1 }],
{ signal }, { signal },
); );

View file

@ -152,9 +152,11 @@ const pointSchema = z.object({
/** Verify the captcha solution and sign an event in the database. */ /** Verify the captcha solution and sign an event in the database. */
export const captchaVerifyController: AppController = async (c) => { export const captchaVerifyController: AppController = async (c) => {
const { user } = c.var;
const id = c.req.param('id'); const id = c.req.param('id');
const result = pointSchema.safeParse(await c.req.json()); const result = pointSchema.safeParse(await c.req.json());
const pubkey = await c.get('signer')!.getPublicKey(); const pubkey = await user!.signer.getPublicKey();
if (!result.success) { if (!result.success) {
return c.json({ error: 'Invalid input' }, { status: 422 }); return c.json({ error: 'Invalid input' }, { status: 422 });

View file

@ -1,55 +1,32 @@
import { confMw } from '@ditto/api/middleware'; import { DittoConf } from '@ditto/conf';
import { Env as HonoEnv, Hono } from '@hono/hono'; import { type User } from '@ditto/mastoapi/middleware';
import { NostrSigner, NSecSigner, NStore } from '@nostrify/nostrify'; import { DittoApp, DittoMiddleware } from '@ditto/mastoapi/router';
import { NSecSigner } from '@nostrify/nostrify';
import { genEvent } from '@nostrify/nostrify/test'; import { genEvent } from '@nostrify/nostrify/test';
import { generateSecretKey, getPublicKey } from 'nostr-tools';
import { bytesToString, stringToBytes } from '@scure/base'; import { bytesToString, stringToBytes } from '@scure/base';
import { stub } from '@std/testing/mock'; import { stub } from '@std/testing/mock';
import { assertEquals, assertExists, assertObjectMatch } from '@std/assert'; import { assertEquals, assertExists, assertObjectMatch } from '@std/assert';
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
import { createTestDB } from '@/test.ts'; import { createTestDB } from '@/test.ts';
import cashuApp from '@/controllers/api/cashu.ts'; import cashuRoute from './cashu.ts';
import { walletSchema } from '@/schema.ts'; import { walletSchema } from '@/schema.ts';
interface AppEnv extends HonoEnv {
Variables: {
/** Signer to get the logged-in user's pubkey, relays, and to sign events. */
signer: NostrSigner;
/** Storage for the user, might filter out unwanted content. */
store: NStore;
};
}
Deno.test('PUT /wallet must be successful', { Deno.test('PUT /wallet must be successful', {
sanitizeOps: false, sanitizeOps: false,
sanitizeResources: false, sanitizeResources: false,
}, async () => { }, async () => {
using _mock = mockFetch(); await using test = await createTestRoute();
await using db = await createTestDB();
const store = db.store;
const sk = generateSecretKey(); const { route, signer, sk, relay } = test;
const signer = new NSecSigner(sk);
const nostrPrivateKey = bytesToString('hex', sk); const nostrPrivateKey = bytesToString('hex', sk);
const app = new Hono<AppEnv>().use( const response = await route.request('/wallet', {
async (c, next) => {
c.set('signer', signer);
await next();
},
async (c, next) => {
c.set('store', store);
await next();
},
);
app.use(confMw(new Map()));
app.route('/', cashuApp);
const response = await app.request('/wallet', {
method: 'PUT', method: 'PUT',
headers: [['content-type', 'application/json']], headers: {
'content-type': 'application/json',
},
body: JSON.stringify({ body: JSON.stringify({
mints: [ mints: [
'https://houston.mint.com', 'https://houston.mint.com',
@ -63,7 +40,7 @@ Deno.test('PUT /wallet must be successful', {
const pubkey = await signer.getPublicKey(); const pubkey = await signer.getPublicKey();
const [wallet] = await store.query([{ authors: [pubkey], kinds: [17375] }]); const [wallet] = await relay.query([{ authors: [pubkey], kinds: [17375] }]);
assertExists(wallet); assertExists(wallet);
assertEquals(wallet.kind, 17375); assertEquals(wallet.kind, 17375);
@ -90,7 +67,7 @@ Deno.test('PUT /wallet must be successful', {
]); ]);
assertEquals(data.balance, 0); assertEquals(data.balance, 0);
const [nutzap_info] = await store.query([{ authors: [pubkey], kinds: [10019] }]); const [nutzap_info] = await relay.query([{ authors: [pubkey], kinds: [10019] }]);
assertExists(nutzap_info); assertExists(nutzap_info);
assertEquals(nutzap_info.kind, 10019); assertEquals(nutzap_info.kind, 10019);
@ -105,30 +82,14 @@ Deno.test('PUT /wallet must be successful', {
}); });
Deno.test('PUT /wallet must NOT be successful: wrong request body/schema', async () => { Deno.test('PUT /wallet must NOT be successful: wrong request body/schema', async () => {
using _mock = mockFetch(); await using test = await createTestRoute();
await using db = await createTestDB(); const { route } = test;
const store = db.store;
const sk = generateSecretKey(); const response = await route.request('/wallet', {
const signer = new NSecSigner(sk);
const app = new Hono<AppEnv>().use(
async (c, next) => {
c.set('signer', signer);
await next();
},
async (c, next) => {
c.set('store', store);
await next();
},
);
app.use(confMw(new Map()));
app.route('/', cashuApp);
const response = await app.request('/wallet', {
method: 'PUT', method: 'PUT',
headers: [['content-type', 'application/json']], headers: {
'content-type': 'application/json',
},
body: JSON.stringify({ body: JSON.stringify({
mints: [], // no mints should throw an error mints: [], // no mints should throw an error
}), }),
@ -144,32 +105,17 @@ Deno.test('PUT /wallet must NOT be successful: wallet already exists', {
sanitizeOps: false, sanitizeOps: false,
sanitizeResources: false, sanitizeResources: false,
}, async () => { }, async () => {
using _mock = mockFetch(); await using test = await createTestRoute();
await using db = await createTestDB(); const { route, sk, relay } = test;
const store = db.store;
const sk = generateSecretKey(); await relay.event(genEvent({ kind: 17375 }, sk));
const signer = new NSecSigner(sk);
const app = new Hono<AppEnv>().use( const response = await route.request('/wallet', {
async (c, next) => {
c.set('signer', signer);
await next();
},
async (c, next) => {
c.set('store', store);
await next();
},
);
app.use(confMw(new Map()));
app.route('/', cashuApp);
await db.store.event(genEvent({ kind: 17375 }, sk));
const response = await app.request('/wallet', {
method: 'PUT', method: 'PUT',
headers: [['content-type', 'application/json']], headers: {
'authorization': `Bearer ${nip19.nsecEncode(sk)}`,
'content-type': 'application/json',
},
body: JSON.stringify({ body: JSON.stringify({
mints: ['https://mint.heart.com'], mints: ['https://mint.heart.com'],
}), }),
@ -185,32 +131,15 @@ Deno.test('GET /wallet must be successful', {
sanitizeOps: false, sanitizeOps: false,
sanitizeResources: false, sanitizeResources: false,
}, async () => { }, async () => {
using _mock = mockFetch(); await using test = await createTestRoute();
await using db = await createTestDB(); const { route, sk, relay, signer } = test;
const store = db.store;
const sk = generateSecretKey();
const signer = new NSecSigner(sk);
const pubkey = await signer.getPublicKey(); const pubkey = await signer.getPublicKey();
const privkey = bytesToString('hex', sk); const privkey = bytesToString('hex', sk);
const p2pk = getPublicKey(stringToBytes('hex', privkey)); const p2pk = getPublicKey(stringToBytes('hex', privkey));
const app = new Hono<AppEnv>().use(
async (c, next) => {
c.set('signer', signer);
await next();
},
async (c, next) => {
c.set('store', store);
await next();
},
);
app.use(confMw(new Map()));
app.route('/', cashuApp);
// Wallet // Wallet
await db.store.event(genEvent({ await relay.event(genEvent({
kind: 17375, kind: 17375,
content: await signer.nip44.encrypt( content: await signer.nip44.encrypt(
pubkey, pubkey,
@ -222,7 +151,7 @@ Deno.test('GET /wallet must be successful', {
}, sk)); }, sk));
// Nutzap information // Nutzap information
await db.store.event(genEvent({ await relay.event(genEvent({
kind: 10019, kind: 10019,
tags: [ tags: [
['pubkey', p2pk], ['pubkey', p2pk],
@ -231,7 +160,7 @@ Deno.test('GET /wallet must be successful', {
}, sk)); }, sk));
// Unspent proofs // Unspent proofs
await db.store.event(genEvent({ await relay.event(genEvent({
kind: 7375, kind: 7375,
content: await signer.nip44.encrypt( content: await signer.nip44.encrypt(
pubkey, pubkey,
@ -272,7 +201,7 @@ Deno.test('GET /wallet must be successful', {
// Nutzap // Nutzap
const senderSk = generateSecretKey(); const senderSk = generateSecretKey();
await db.store.event(genEvent({ await relay.event(genEvent({
kind: 9321, kind: 9321,
content: 'Nice post!', content: 'Nice post!',
tags: [ tags: [
@ -285,7 +214,7 @@ Deno.test('GET /wallet must be successful', {
], ],
}, senderSk)); }, senderSk));
const response = await app.request('/wallet', { const response = await route.request('/wallet', {
method: 'GET', method: 'GET',
}); });
@ -301,21 +230,10 @@ Deno.test('GET /wallet must be successful', {
}); });
Deno.test('GET /mints must be successful', async () => { Deno.test('GET /mints must be successful', async () => {
using _mock = mockFetch(); await using test = await createTestRoute();
await using db = await createTestDB(); const { route } = test;
const store = db.store;
const app = new Hono<AppEnv>().use( const response = await route.request('/mints', {
async (c, next) => {
c.set('store', store);
await next();
},
);
app.use(confMw(new Map()));
app.route('/', cashuApp);
const response = await app.request('/mints', {
method: 'GET', method: 'GET',
}); });
@ -325,13 +243,42 @@ Deno.test('GET /mints must be successful', async () => {
assertEquals(body, { mints: [] }); assertEquals(body, { mints: [] });
}); });
function mockFetch() { async function createTestRoute() {
const conf = new DittoConf(new Map());
const db = await createTestDB();
const relay = db.store;
const sk = generateSecretKey();
const signer = new NSecSigner(sk);
const route = new DittoApp({ db, relay, conf });
route.use(testUserMiddleware({ signer, relay }));
route.route('/', cashuRoute);
const mock = stub(globalThis, 'fetch', () => { const mock = stub(globalThis, 'fetch', () => {
return Promise.resolve(new Response()); return Promise.resolve(new Response());
}); });
return { return {
[Symbol.dispose]: () => { route,
db,
conf,
sk,
signer,
relay,
[Symbol.asyncDispose]: async () => {
mock.restore(); mock.restore();
await db[Symbol.asyncDispose]();
await relay[Symbol.asyncDispose]();
}, },
}; };
} }
function testUserMiddleware(user: User<NSecSigner>): DittoMiddleware<{ user: User<NSecSigner> }> {
return async (c, next) => {
c.set('user', user);
await next();
};
}

View file

@ -1,13 +1,11 @@
import { Proof } from '@cashu/cashu-ts'; import { Proof } from '@cashu/cashu-ts';
import { confRequiredMw } from '@ditto/api/middleware'; import { userMiddleware } from '@ditto/mastoapi/middleware';
import { Hono } from '@hono/hono'; import { DittoRoute } from '@ditto/mastoapi/router';
import { generateSecretKey, getPublicKey } from 'nostr-tools'; import { generateSecretKey, getPublicKey } from 'nostr-tools';
import { bytesToString, stringToBytes } from '@scure/base'; import { bytesToString, stringToBytes } from '@scure/base';
import { z } from 'zod'; 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 { 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';
@ -16,7 +14,7 @@ import { errorJson } from '@/utils/log.ts';
type Wallet = z.infer<typeof walletSchema>; type Wallet = z.infer<typeof walletSchema>;
const app = new Hono().use('*', confRequiredMw, requireStore); const route = new DittoRoute();
// app.delete('/wallet') -> 204 // app.delete('/wallet') -> 204
@ -44,12 +42,11 @@ const createCashuWalletAndNutzapInfoSchema = z.object({
* https://github.com/nostr-protocol/nips/blob/master/60.md * https://github.com/nostr-protocol/nips/blob/master/60.md
* https://github.com/nostr-protocol/nips/blob/master/61.md#nutzap-informational-event * https://github.com/nostr-protocol/nips/blob/master/61.md#nutzap-informational-event
*/ */
app.put('/wallet', requireNip44Signer, async (c) => { route.put('/wallet', userMiddleware({ enc: 'nip44' }), async (c) => {
const { conf, signer } = c.var; const { conf, user, relay, signal } = c.var;
const store = c.get('store');
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 { signal } = c.req.raw;
const result = createCashuWalletAndNutzapInfoSchema.safeParse(body); const result = createCashuWalletAndNutzapInfoSchema.safeParse(body);
if (!result.success) { if (!result.success) {
@ -58,7 +55,7 @@ app.put('/wallet', requireNip44Signer, async (c) => {
const { mints } = result.data; const { mints } = result.data;
const [event] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal }); const [event] = await relay.query([{ authors: [pubkey], kinds: [17375] }], { signal });
if (event) { if (event) {
return c.json({ error: 'You already have a wallet 😏' }, 400); return c.json({ error: 'You already have a wallet 😏' }, 400);
} }
@ -75,12 +72,13 @@ app.put('/wallet', requireNip44Signer, async (c) => {
walletContentTags.push(['mint', mint]); walletContentTags.push(['mint', mint]);
} }
const encryptedWalletContentTags = await signer.nip44.encrypt(pubkey, JSON.stringify(walletContentTags)); const encryptedWalletContentTags = await user.signer.nip44.encrypt(pubkey, JSON.stringify(walletContentTags));
// Wallet // Wallet
await createEvent({ await createEvent({
kind: 17375, kind: 17375,
content: encryptedWalletContentTags, content: encryptedWalletContentTags,
// @ts-ignore kill me
}, c); }, c);
// Nutzap information // Nutzap information
@ -91,6 +89,7 @@ app.put('/wallet', requireNip44Signer, async (c) => {
['relay', conf.relay], // TODO: add more relays once things get more stable ['relay', conf.relay], // TODO: add more relays once things get more stable
['pubkey', p2pk], ['pubkey', p2pk],
], ],
// @ts-ignore kill me
}, c); }, c);
// TODO: hydrate wallet and add a 'balance' field when a 'renderWallet' view function is created // TODO: hydrate wallet and add a 'balance' field when a 'renderWallet' view function is created
@ -105,18 +104,17 @@ app.put('/wallet', requireNip44Signer, async (c) => {
}); });
/** Gets a wallet, if it exists. */ /** Gets a wallet, if it exists. */
app.get('/wallet', requireNip44Signer, swapNutzapsMiddleware, async (c) => { route.get('/wallet', userMiddleware({ enc: 'nip44' }), swapNutzapsMiddleware, async (c) => {
const { conf, signer } = c.var; const { conf, relay, user, signal } = c.var;
const store = c.get('store');
const pubkey = await signer.getPublicKey();
const { signal } = c.req.raw;
const [event] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal }); const pubkey = await user.signer.getPublicKey();
const [event] = await relay.query([{ authors: [pubkey], kinds: [17375] }], { signal });
if (!event) { if (!event) {
return c.json({ error: 'Wallet not found' }, 404); return c.json({ error: 'Wallet not found' }, 404);
} }
const decryptedContent: string[][] = JSON.parse(await signer.nip44.decrypt(pubkey, event.content)); const decryptedContent: string[][] = JSON.parse(await user.signer.nip44.decrypt(pubkey, event.content));
const privkey = decryptedContent.find(([value]) => value === 'privkey')?.[1]; const privkey = decryptedContent.find(([value]) => value === 'privkey')?.[1];
if (!privkey || !isNostrId(privkey)) { if (!privkey || !isNostrId(privkey)) {
@ -128,11 +126,11 @@ app.get('/wallet', requireNip44Signer, swapNutzapsMiddleware, async (c) => {
let balance = 0; let balance = 0;
const mints: string[] = []; const mints: string[] = [];
const tokens = await store.query([{ authors: [pubkey], kinds: [7375] }], { signal }); const tokens = await relay.query([{ authors: [pubkey], kinds: [7375] }], { signal });
for (const token of tokens) { for (const token of tokens) {
try { try {
const decryptedContent: { mint: string; proofs: Proof[] } = JSON.parse( const decryptedContent: { mint: string; proofs: Proof[] } = JSON.parse(
await signer.nip44.decrypt(pubkey, token.content), await user.signer.nip44.decrypt(pubkey, token.content),
); );
if (!mints.includes(decryptedContent.mint)) { if (!mints.includes(decryptedContent.mint)) {
@ -159,7 +157,7 @@ app.get('/wallet', requireNip44Signer, swapNutzapsMiddleware, async (c) => {
}); });
/** Get mints set by the CASHU_MINTS environment variable. */ /** Get mints set by the CASHU_MINTS environment variable. */
app.get('/mints', (c) => { route.get('/mints', (c) => {
const { conf } = c.var; const { conf } = c.var;
// TODO: Return full Mint information: https://github.com/cashubtc/nuts/blob/main/06.md // TODO: Return full Mint information: https://github.com/cashubtc/nuts/blob/main/06.md
@ -168,4 +166,4 @@ app.get('/mints', (c) => {
return c.json({ mints }, 200); return c.json({ mints }, 200);
}); });
export default app; export default route;

View file

@ -28,10 +28,9 @@ 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, relay } = c.var;
const store = await Storages.db();
const [event] = await store.query([ const [event] = await relay.query([
{ kinds: [10002], authors: [await conf.signer.getPublicKey()], limit: 1 }, { kinds: [10002], authors: [await conf.signer.getPublicKey()], limit: 1 },
]); ]);
@ -43,8 +42,7 @@ export const adminRelaysController: AppController = async (c) => {
}; };
export const adminSetRelaysController: AppController = async (c) => { export const adminSetRelaysController: AppController = async (c) => {
const { conf } = c.var; const { conf, relay } = c.var;
const store = await Storages.db();
const relays = relaySchema.array().parse(await c.req.json()); const relays = relaySchema.array().parse(await c.req.json());
const event = await conf.signer.signEvent({ const event = await conf.signer.signEvent({
@ -54,7 +52,7 @@ export const adminSetRelaysController: AppController = async (c) => {
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
}); });
await store.event(event); await relay.event(event);
return c.json(renderRelays(event)); return c.json(renderRelays(event));
}; };
@ -79,11 +77,9 @@ const nameRequestSchema = z.object({
}); });
export const nameRequestController: AppController = async (c) => { export const nameRequestController: AppController = async (c) => {
const store = await Storages.db(); const { conf, relay, user } = c.var;
const signer = c.get('signer')!;
const pubkey = await signer.getPublicKey();
const { conf } = c.var;
const pubkey = await user!.signer.getPublicKey();
const result = nameRequestSchema.safeParse(await c.req.json()); const result = nameRequestSchema.safeParse(await c.req.json());
if (!result.success) { if (!result.success) {
@ -92,7 +88,7 @@ export const nameRequestController: AppController = async (c) => {
const { name, reason } = result.data; const { name, reason } = result.data;
const [existing] = await store.query([{ kinds: [3036], authors: [pubkey], '#r': [name.toLowerCase()], limit: 1 }]); const [existing] = await relay.query([{ kinds: [3036], authors: [pubkey], '#r': [name.toLowerCase()], limit: 1 }]);
if (existing) { if (existing) {
return c.json({ error: 'Name request already exists' }, 400); return c.json({ error: 'Name request already exists' }, 400);
} }
@ -114,7 +110,7 @@ export const nameRequestController: AppController = async (c) => {
], ],
}, c); }, c);
await hydrateEvents({ events: [event], store: await Storages.db() }); await hydrateEvents({ events: [event], relay });
const nameRequest = await renderNameRequest(event); const nameRequest = await renderNameRequest(event);
return c.json(nameRequest); return c.json(nameRequest);
@ -126,10 +122,8 @@ const nameRequestsSchema = z.object({
}); });
export const nameRequestsController: AppController = async (c) => { export const nameRequestsController: AppController = async (c) => {
const { conf } = c.var; const { conf, relay, user, signal } = c.var;
const store = await Storages.db(); const pubkey = await user!.signer.getPublicKey();
const signer = c.get('signer')!;
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());
@ -149,7 +143,7 @@ export const nameRequestsController: AppController = async (c) => {
filter['#n'] = ['rejected']; filter['#n'] = ['rejected'];
} }
const orig = await store.query([filter]); const orig = await relay.query([filter]);
const ids = new Set<string>(); const ids = new Set<string>();
for (const event of orig) { for (const event of orig) {
@ -163,8 +157,8 @@ 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 relay.query([{ kinds: [3036], ids: [...ids], authors: [pubkey] }])
.then((events) => hydrateEvents({ store, events: events, signal: c.req.raw.signal })); .then((events) => hydrateEvents({ relay, events: events, signal }));
const nameRequests = await Promise.all( const nameRequests = await Promise.all(
events.map((event) => renderNameRequest(event)), events.map((event) => renderNameRequest(event)),
@ -182,10 +176,9 @@ const zapSplitSchema = z.record(
); );
export const updateZapSplitsController: AppController = async (c) => { export const updateZapSplitsController: AppController = async (c) => {
const { conf } = c.var; const { conf, relay } = 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);
@ -193,7 +186,7 @@ export const updateZapSplitsController: AppController = async (c) => {
const adminPubkey = await conf.signer.getPublicKey(); const adminPubkey = await conf.signer.getPublicKey();
const dittoZapSplit = await getZapSplits(store, adminPubkey); const dittoZapSplit = await getZapSplits(relay, adminPubkey);
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);
} }
@ -220,10 +213,9 @@ export const updateZapSplitsController: AppController = async (c) => {
const deleteZapSplitSchema = z.array(n.id()).min(1); const deleteZapSplitSchema = z.array(n.id()).min(1);
export const deleteZapSplitsController: AppController = async (c) => { export const deleteZapSplitsController: AppController = async (c) => {
const { conf } = c.var; const { conf, relay } = c.var;
const body = await parseBody(c.req.raw); const body = await parseBody(c.req.raw);
const result = deleteZapSplitSchema.safeParse(body); const result = deleteZapSplitSchema.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);
@ -231,7 +223,7 @@ export const deleteZapSplitsController: AppController = async (c) => {
const adminPubkey = await conf.signer.getPublicKey(); const adminPubkey = await conf.signer.getPublicKey();
const dittoZapSplit = await getZapSplits(store, adminPubkey); const dittoZapSplit = await getZapSplits(relay, adminPubkey);
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);
} }
@ -251,10 +243,9 @@ export const deleteZapSplitsController: AppController = async (c) => {
}; };
export const getZapSplitsController: AppController = async (c) => { export const getZapSplitsController: AppController = async (c) => {
const { conf } = c.var; const { conf, relay } = c.var;
const store = c.get('store');
const dittoZapSplit: DittoZapSplits | undefined = await getZapSplits(store, await conf.signer.getPublicKey()) ?? {}; const dittoZapSplit: DittoZapSplits | undefined = await getZapSplits(relay, await conf.signer.getPublicKey()) ?? {};
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);
} }
@ -277,11 +268,11 @@ export const getZapSplitsController: AppController = async (c) => {
}; };
export const statusZapSplitsController: AppController = async (c) => { export const statusZapSplitsController: AppController = async (c) => {
const store = c.get('store'); const { relay, signal } = c.var;
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 id = c.req.param('id');
const [event] = await relay.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);
} }
@ -290,8 +281,8 @@ 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 relay.query([{ authors: pubkeys, kinds: [0], limit: pubkeys.length }], { signal });
await hydrateEvents({ events: users, store, signal }); await hydrateEvents({ events: users, relay, signal });
const zapSplits = (await Promise.all(pubkeys.map((pubkey) => { const zapSplits = (await Promise.all(pubkeys.map((pubkey) => {
const author = (users.find((event) => event.pubkey === pubkey) as DittoEvent | undefined)?.author; const author = (users.find((event) => event.pubkey === pubkey) as DittoEvent | undefined)?.author;

View file

@ -14,7 +14,9 @@ interface Marker {
} }
export const markersController: AppController = async (c) => { export const markersController: AppController = async (c) => {
const pubkey = await c.get('signer')?.getPublicKey()!; const { user } = c.var;
const pubkey = await user!.signer.getPublicKey();
const timelines = c.req.queries('timeline[]') ?? []; const timelines = c.req.queries('timeline[]') ?? [];
const results = await kv.getMany<Marker[]>( const results = await kv.getMany<Marker[]>(
@ -37,7 +39,9 @@ const markerDataSchema = z.object({
}); });
export const updateMarkersController: AppController = async (c) => { export const updateMarkersController: AppController = async (c) => {
const pubkey = await c.get('signer')?.getPublicKey()!; const { user } = c.var;
const pubkey = await user!.signer.getPublicKey();
const record = z.record(z.enum(['home', 'notifications']), markerDataSchema).parse(await parseBody(c.req.raw)); const record = z.record(z.enum(['home', 'notifications']), markerDataSchema).parse(await parseBody(c.req.raw));
const timelines = Object.keys(record) as Timeline[]; const timelines = Object.keys(record) as Timeline[];

View file

@ -21,9 +21,10 @@ const mediaUpdateSchema = z.object({
}); });
const mediaController: AppController = async (c) => { const mediaController: AppController = async (c) => {
const pubkey = await c.get('signer')?.getPublicKey()!; const { user, signal } = c.var;
const pubkey = await user!.signer.getPublicKey();
const result = mediaBodySchema.safeParse(await parseBody(c.req.raw)); const result = mediaBodySchema.safeParse(await parseBody(c.req.raw));
const { signal } = 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);

View file

@ -1,15 +1,14 @@
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 { relay, user, signal } = c.var;
const pubkey = await c.get('signer')?.getPublicKey()!;
const { signal } = c.req.raw;
const [event10000] = await store.query( const pubkey = await user!.signer.getPublicKey();
const [event10000] = await relay.query(
[{ kinds: [10000], authors: [pubkey], limit: 1 }], [{ kinds: [10000], authors: [pubkey], limit: 1 }],
{ signal }, { signal },
); );

View file

@ -30,8 +30,9 @@ const notificationsSchema = z.object({
}); });
const notificationsController: AppController = async (c) => { const notificationsController: 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 params = c.get('pagination'); const params = c.get('pagination');
const types = notificationTypes const types = notificationTypes
@ -75,20 +76,21 @@ const notificationsController: AppController = async (c) => {
}; };
const notificationController: AppController = async (c) => { const notificationController: AppController = async (c) => {
const { relay, 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 store = c.get('store');
// Remove the timestamp from the ID. // Remove the timestamp from the ID.
const eventId = id.replace(/^\d+-/, ''); const eventId = id.replace(/^\d+-/, '');
const [event] = await store.query([{ ids: [eventId] }]); const [event] = await relay.query([{ ids: [eventId] }]);
if (!event) { if (!event) {
return c.json({ error: 'Event not found' }, { status: 404 }); return c.json({ error: 'Event not found' }, { status: 404 });
} }
await hydrateEvents({ events: [event], store }); await hydrateEvents({ events: [event], relay });
const notification = await renderNotification(event, { viewerPubkey: pubkey }); const notification = await renderNotification(event, { viewerPubkey: pubkey });
@ -105,16 +107,16 @@ async function renderNotifications(
params: DittoPagination, params: DittoPagination,
c: AppContext, c: AppContext,
) { ) {
const { conf } = c.var; const { conf, user, signal } = c.var;
const store = c.get('store');
const pubkey = await c.get('signer')?.getPublicKey()!; const relay = user!.relay;
const { signal } = c.req.raw; const pubkey = await user!.signer.getPublicKey();
const opts = { signal, limit: params.limit, timeout: conf.db.timeouts.timelines }; const opts = { signal, limit: params.limit, timeout: conf.db.timeouts.timelines };
const events = await store const events = await relay
.query(filters, opts) .query(filters, opts)
.then((events) => events.filter((event) => event.pubkey !== pubkey)) .then((events) => events.filter((event) => event.pubkey !== pubkey))
.then((events) => hydrateEvents({ events, store, signal })); .then((events) => hydrateEvents({ events, relay, signal }));
if (!events.length) { if (!events.length) {
return c.json([]); return c.json([]);

View file

@ -2,14 +2,14 @@ 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 { 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 { relay, signal } = c.var;
const configDB = await getPleromaConfigs(store, c.req.raw.signal);
const configDB = await getPleromaConfigs(relay, signal);
const frontendConfig = configDB.get(':pleroma', ':frontend_configurations'); const frontendConfig = configDB.get(':pleroma', ':frontend_configurations');
if (frontendConfig) { if (frontendConfig) {
@ -25,17 +25,17 @@ const frontendConfigController: AppController = async (c) => {
}; };
const configController: AppController = async (c) => { const configController: AppController = async (c) => {
const store = await Storages.db(); const { relay, signal } = c.var;
const configs = await getPleromaConfigs(store, c.req.raw.signal);
const configs = await getPleromaConfigs(relay, signal);
return c.json({ configs, need_reboot: false }); return c.json({ configs, need_reboot: false });
}; };
/** Pleroma admin config controller. */ /** Pleroma admin config controller. */
const updateConfigController: AppController = async (c) => { const updateConfigController: AppController = async (c) => {
const { conf } = c.var; const { conf, relay, signal } = c.var;
const store = await Storages.db(); const configs = await getPleromaConfigs(relay, signal);
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);

View file

@ -42,7 +42,7 @@ const pushSubscribeSchema = z.object({
}); });
export const pushSubscribeController: AppController = async (c) => { export const pushSubscribeController: AppController = async (c) => {
const { conf } = c.var; const { conf, user } = c.var;
const vapidPublicKey = await conf.vapidPublicKey; const vapidPublicKey = await conf.vapidPublicKey;
if (!vapidPublicKey) { if (!vapidPublicKey) {
@ -52,7 +52,7 @@ export const pushSubscribeController: AppController = async (c) => {
const accessToken = getAccessToken(c.req.raw); const accessToken = getAccessToken(c.req.raw);
const kysely = await Storages.kysely(); const kysely = await Storages.kysely();
const signer = c.get('signer')!; const signer = user!.signer;
const result = pushSubscribeSchema.safeParse(await parseBody(c.req.raw)); const result = pushSubscribeSchema.safeParse(await parseBody(c.req.raw));

View file

@ -1,7 +1,6 @@
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 { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
import { renderStatus } from '@/views/mastodon/statuses.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts';
@ -11,16 +10,15 @@ import { renderStatus } from '@/views/mastodon/statuses.ts';
* https://docs.pleroma.social/backend/development/API/pleroma_api/#put-apiv1pleromastatusesidreactionsemoji * https://docs.pleroma.social/backend/development/API/pleroma_api/#put-apiv1pleromastatusesidreactionsemoji
*/ */
const reactionController: AppController = async (c) => { const reactionController: AppController = async (c) => {
const { relay, user } = c.var;
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')!;
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 relay.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);
@ -33,9 +31,9 @@ const reactionController: AppController = async (c) => {
tags: [['e', id], ['p', event.pubkey]], tags: [['e', id], ['p', event.pubkey]],
}, c); }, c);
await hydrateEvents({ events: [event], store }); await hydrateEvents({ events: [event], relay });
const status = await renderStatus(event, { viewerPubkey: await signer.getPublicKey() }); const status = await renderStatus(event, { viewerPubkey: await user!.signer.getPublicKey() });
return c.json(status); return c.json(status);
}; };
@ -45,17 +43,17 @@ const reactionController: AppController = async (c) => {
* https://docs.pleroma.social/backend/development/API/pleroma_api/#delete-apiv1pleromastatusesidreactionsemoji * https://docs.pleroma.social/backend/development/API/pleroma_api/#delete-apiv1pleromastatusesidreactionsemoji
*/ */
const deleteReactionController: AppController = async (c) => { const deleteReactionController: AppController = async (c) => {
const { relay, user } = c.var;
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 user!.signer.getPublicKey();
const pubkey = await signer.getPublicKey();
const store = await Storages.db();
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 [event] = await store.query([ const [event] = await relay.query([
{ kinds: [1, 20], ids: [id], limit: 1 }, { kinds: [1, 20], ids: [id], limit: 1 },
]); ]);
@ -63,7 +61,7 @@ const deleteReactionController: AppController = async (c) => {
return c.json({ error: 'Status not found' }, 404); return c.json({ error: 'Status not found' }, 404);
} }
const events = await store.query([ const events = await relay.query([
{ kinds: [7], authors: [pubkey], '#e': [id] }, { kinds: [7], authors: [pubkey], '#e': [id] },
]); ]);
@ -88,19 +86,20 @@ const deleteReactionController: AppController = async (c) => {
* https://docs.pleroma.social/backend/development/API/pleroma_api/#get-apiv1pleromastatusesidreactions * https://docs.pleroma.social/backend/development/API/pleroma_api/#get-apiv1pleromastatusesidreactions
*/ */
const reactionsController: AppController = async (c) => { const reactionsController: AppController = async (c) => {
const { relay, user } = c.var;
const id = c.req.param('id'); const id = c.req.param('id');
const store = await Storages.db(); const pubkey = await user?.signer.getPublicKey();
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;
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);
} }
const events = await store.query([{ kinds: [7], '#e': [id], limit: 100 }]) const events = await relay.query([{ kinds: [7], '#e': [id], limit: 100 }])
.then((events) => events.filter(({ content }) => /^\p{RGI_Emoji}$/v.test(content))) .then((events) => events.filter(({ content }) => /^\p{RGI_Emoji}$/v.test(content)))
.then((events) => events.filter((event) => !emoji || event.content === emoji)) .then((events) => events.filter((event) => !emoji || event.content === emoji))
.then((events) => hydrateEvents({ events, store })); .then((events) => hydrateEvents({ events, relay }));
/** Events grouped by emoji. */ /** Events grouped by emoji. */
const byEmoji = events.reduce((acc, event) => { const byEmoji = events.reduce((acc, event) => {

View file

@ -18,8 +18,8 @@ const reportSchema = z.object({
/** https://docs.joinmastodon.org/methods/reports/#post */ /** https://docs.joinmastodon.org/methods/reports/#post */
const reportController: AppController = async (c) => { const reportController: AppController = async (c) => {
const { conf } = c.var; const { conf, relay } = c.var;
const store = c.get('store');
const body = await parseBody(c.req.raw); const body = await parseBody(c.req.raw);
const result = reportSchema.safeParse(body); const result = reportSchema.safeParse(body);
@ -49,7 +49,7 @@ const reportController: AppController = async (c) => {
tags, tags,
}, c); }, c);
await hydrateEvents({ events: [event], store }); await hydrateEvents({ events: [event], relay });
return c.json(await renderReport(event)); return c.json(await renderReport(event));
}; };
@ -61,18 +61,16 @@ const adminReportsSchema = z.object({
/** https://docs.joinmastodon.org/methods/admin/reports/#get */ /** https://docs.joinmastodon.org/methods/admin/reports/#get */
const adminReportsController: AppController = async (c) => { const adminReportsController: AppController = async (c) => {
const { conf } = c.var; const { conf, relay, user, pagination } = c.var;
const store = c.get('store');
const viewerPubkey = await c.get('signer')?.getPublicKey();
const params = c.get('pagination'); const viewerPubkey = await user?.signer.getPublicKey();
const { resolved, account_id, target_account_id } = adminReportsSchema.parse(c.req.query()); const { resolved, account_id, target_account_id } = adminReportsSchema.parse(c.req.query());
const filter: NostrFilter = { const filter: NostrFilter = {
kinds: [30383], kinds: [30383],
authors: [await conf.signer.getPublicKey()], authors: [await conf.signer.getPublicKey()],
'#k': ['1984'], '#k': ['1984'],
...params, ...pagination,
}; };
if (typeof resolved === 'boolean') { if (typeof resolved === 'boolean') {
@ -85,7 +83,7 @@ const adminReportsController: AppController = async (c) => {
filter['#P'] = [target_account_id]; filter['#P'] = [target_account_id];
} }
const orig = await store.query([filter]); const orig = await relay.query([filter]);
const ids = new Set<string>(); const ids = new Set<string>();
for (const event of orig) { for (const event of orig) {
@ -95,8 +93,8 @@ const adminReportsController: AppController = async (c) => {
} }
} }
const events = await store.query([{ kinds: [1984], ids: [...ids] }]) const events = await relay.query([{ kinds: [1984], ids: [...ids] }])
.then((events) => hydrateEvents({ store, events: events, signal: c.req.raw.signal })); .then((events) => hydrateEvents({ relay, events: events, signal: c.req.raw.signal }));
const reports = await Promise.all( const reports = await Promise.all(
events.map((event) => renderAdminReport(event, { viewerPubkey })), events.map((event) => renderAdminReport(event, { viewerPubkey })),
@ -107,12 +105,12 @@ const adminReportsController: AppController = async (c) => {
/** https://docs.joinmastodon.org/methods/admin/reports/#get-one */ /** https://docs.joinmastodon.org/methods/admin/reports/#get-one */
const adminReportController: AppController = async (c) => { const adminReportController: AppController = async (c) => {
const eventId = c.req.param('id'); const { relay, user, signal } = c.var;
const { signal } = c.req.raw;
const store = c.get('store');
const pubkey = await c.get('signer')?.getPublicKey();
const [event] = await store.query([{ const eventId = c.req.param('id');
const pubkey = await user?.signer.getPublicKey();
const [event] = await relay.query([{
kinds: [1984], kinds: [1984],
ids: [eventId], ids: [eventId],
limit: 1, limit: 1,
@ -122,7 +120,7 @@ const adminReportController: AppController = async (c) => {
return c.json({ error: 'Not found' }, 404); return c.json({ error: 'Not found' }, 404);
} }
await hydrateEvents({ events: [event], store, signal }); await hydrateEvents({ events: [event], relay, signal });
const report = await renderAdminReport(event, { viewerPubkey: pubkey }); const report = await renderAdminReport(event, { viewerPubkey: pubkey });
return c.json(report); return c.json(report);
@ -130,12 +128,12 @@ const adminReportController: AppController = async (c) => {
/** https://docs.joinmastodon.org/methods/admin/reports/#resolve */ /** https://docs.joinmastodon.org/methods/admin/reports/#resolve */
const adminReportResolveController: AppController = async (c) => { const adminReportResolveController: AppController = async (c) => {
const eventId = c.req.param('id'); const { relay, user, signal } = c.var;
const { signal } = c.req.raw;
const store = c.get('store');
const pubkey = await c.get('signer')?.getPublicKey();
const [event] = await store.query([{ const eventId = c.req.param('id');
const pubkey = await user?.signer.getPublicKey();
const [event] = await relay.query([{
kinds: [1984], kinds: [1984],
ids: [eventId], ids: [eventId],
limit: 1, limit: 1,
@ -146,19 +144,19 @@ const adminReportResolveController: AppController = async (c) => {
} }
await updateEventInfo(eventId, { open: false, closed: true }, c); await updateEventInfo(eventId, { open: false, closed: true }, c);
await hydrateEvents({ events: [event], store, signal }); await hydrateEvents({ events: [event], relay, signal });
const report = await renderAdminReport(event, { viewerPubkey: pubkey }); const report = await renderAdminReport(event, { viewerPubkey: pubkey });
return c.json(report); return c.json(report);
}; };
const adminReportReopenController: AppController = async (c) => { const adminReportReopenController: AppController = async (c) => {
const eventId = c.req.param('id'); const { relay, user, signal } = c.var;
const { signal } = c.req.raw;
const store = c.get('store');
const pubkey = await c.get('signer')?.getPublicKey();
const [event] = await store.query([{ const eventId = c.req.param('id');
const pubkey = await user?.signer.getPublicKey();
const [event] = await relay.query([{
kinds: [1984], kinds: [1984],
ids: [eventId], ids: [eventId],
limit: 1, limit: 1,
@ -169,7 +167,7 @@ const adminReportReopenController: AppController = async (c) => {
} }
await updateEventInfo(eventId, { open: true, closed: false }, c); await updateEventInfo(eventId, { open: true, closed: false }, c);
await hydrateEvents({ events: [event], store, signal }); await hydrateEvents({ events: [event], relay, signal });
const report = await renderAdminReport(event, { viewerPubkey: pubkey }); const report = await renderAdminReport(event, { viewerPubkey: pubkey });
return c.json(report); return c.json(report);

View file

@ -26,16 +26,16 @@ const searchQuerySchema = z.object({
type SearchQuery = z.infer<typeof searchQuerySchema> & { since?: number; until?: number; limit: number }; type SearchQuery = z.infer<typeof searchQuerySchema> & { since?: number; until?: number; limit: number };
const searchController: AppController = async (c) => { const searchController: AppController = async (c) => {
const { user, pagination, signal } = c.var;
const result = searchQuerySchema.safeParse(c.req.query()); const result = searchQuerySchema.safeParse(c.req.query());
const params = c.get('pagination'); const viewerPubkey = await user?.signer.getPublicKey();
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({ ...result.data, ...pagination }, signal);
const lookup = extractIdentifier(result.data.q); const lookup = extractIdentifier(result.data.q);
// Render account from pubkey. // Render account from pubkey.
@ -54,7 +54,7 @@ const searchController: AppController = async (c) => {
events = [event]; events = [event];
} }
events.push(...(await searchEvents({ ...result.data, ...params, viewerPubkey }, signal))); events.push(...(await searchEvents({ ...result.data, ...pagination, viewerPubkey }, signal)));
const [accounts, statuses] = await Promise.all([ const [accounts, statuses] = await Promise.all([
Promise.all( Promise.all(
@ -78,7 +78,7 @@ 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);
} }
@ -94,7 +94,7 @@ async function searchEvents(
return Promise.resolve([]); return Promise.resolve([]);
} }
const store = await Storages.db(); const relay = await Storages.db();
const filter: NostrFilter = { const filter: NostrFilter = {
kinds: typeToKinds(type), kinds: typeToKinds(type),
@ -121,9 +121,9 @@ async function searchEvents(
} }
// Query the events. // Query the events.
let events = await store let events = await relay
.query([filter], { signal }) .query([filter], { signal })
.then((events) => hydrateEvents({ events, store, signal })); .then((events) => hydrateEvents({ events, relay, signal }));
// 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) {
@ -150,10 +150,10 @@ function typeToKinds(type: SearchQuery['type']): number[] {
/** Resolve a searched value into an event, if applicable. */ /** Resolve a searched value into an event, if applicable. */
async function lookupEvent(query: SearchQuery, signal: AbortSignal): Promise<NostrEvent | undefined> { async function lookupEvent(query: SearchQuery, signal: AbortSignal): Promise<NostrEvent | undefined> {
const filters = await getLookupFilters(query, signal); const filters = await getLookupFilters(query, signal);
const store = await Storages.db(); const relay = await Storages.db();
return store.query(filters, { limit: 1, signal }) return relay.query(filters, { limit: 1, signal })
.then((events) => hydrateEvents({ events, store, signal })) .then((events) => hydrateEvents({ events, relay, signal }))
.then(([event]) => event); .then(([event]) => event);
} }

View file

@ -9,11 +9,11 @@ import { type AppController } from '@/app.ts';
import { DittoUpload, dittoUploads } from '@/DittoUploads.ts'; import { DittoUpload, dittoUploads } from '@/DittoUploads.ts';
import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { getAncestors, getAuthor, getDescendants, getEvent } from '@/queries.ts'; import { getAncestors, getAuthor, getDescendants, getEvent } from '@/queries.ts';
import { paginationSchema } from '@/schemas/pagination.ts';
import { addTag, deleteTag } from '@/utils/tags.ts'; 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';
@ -46,9 +46,9 @@ const createStatusSchema = z.object({
); );
const statusController: AppController = async (c) => { const statusController: AppController = async (c) => {
const id = c.req.param('id'); const { user, signal } = c.var;
const signal = AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(1500)]);
const id = c.req.param('id');
const event = await getEvent(id, { signal }); const event = await getEvent(id, { signal });
if (event?.author) { if (event?.author) {
@ -56,7 +56,7 @@ const statusController: AppController = async (c) => {
} }
if (event) { if (event) {
const viewerPubkey = await c.get('signer')?.getPublicKey(); const viewerPubkey = await user?.signer.getPublicKey();
const status = await renderStatus(event, { viewerPubkey }); const status = await renderStatus(event, { viewerPubkey });
return c.json(status); return c.json(status);
} }
@ -65,10 +65,10 @@ const statusController: AppController = async (c) => {
}; };
const createStatusController: AppController = async (c) => { const createStatusController: AppController = async (c) => {
const { conf } = c.var; const { conf, relay, user, signal } = 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');
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);
@ -87,14 +87,14 @@ const createStatusController: AppController = async (c) => {
const tags: string[][] = []; const tags: string[][] = [];
if (data.in_reply_to_id) { if (data.in_reply_to_id) {
const [ancestor] = await store.query([{ ids: [data.in_reply_to_id] }]); const [ancestor] = await relay.query([{ ids: [data.in_reply_to_id] }]);
if (!ancestor) { if (!ancestor) {
return c.json({ error: 'Original post not found.' }, 404); return c.json({ error: 'Original post not found.' }, 404);
} }
const rootId = ancestor.tags.find((tag) => tag[0] === 'e' && tag[3] === 'root')?.[1] ?? ancestor.id; const rootId = ancestor.tags.find((tag) => tag[0] === 'e' && tag[3] === 'root')?.[1] ?? ancestor.id;
const root = rootId === ancestor.id ? ancestor : await store.query([{ ids: [rootId] }]).then(([event]) => event); const root = rootId === ancestor.id ? ancestor : await relay.query([{ ids: [rootId] }]).then(([event]) => event);
if (root) { if (root) {
tags.push(['e', root.id, conf.relay, 'root', root.pubkey]); tags.push(['e', root.id, conf.relay, 'root', root.pubkey]);
@ -108,7 +108,7 @@ const createStatusController: AppController = async (c) => {
let quoted: DittoEvent | undefined; let quoted: DittoEvent | undefined;
if (data.quote_id) { if (data.quote_id) {
[quoted] = await store.query([{ ids: [data.quote_id] }]); [quoted] = await relay.query([{ ids: [data.quote_id] }]);
if (!quoted) { if (!quoted) {
return c.json({ error: 'Quoted post not found.' }, 404); return c.json({ error: 'Quoted post not found.' }, 404);
@ -190,13 +190,13 @@ 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 = pubkey ? await getAuthor(pubkey) : undefined;
if (conf.zapSplitsEnabled) { if (conf.zapSplitsEnabled) {
const meta = n.json().pipe(n.metadata()).catch({}).parse(author?.content); const meta = n.json().pipe(n.metadata()).catch({}).parse(author?.content);
const lnurl = getLnurl(meta); const lnurl = getLnurl(meta);
const dittoZapSplit = await getZapSplits(store, await conf.signer.getPublicKey()); const dittoZapSplit = await getZapSplits(relay, await conf.signer.getPublicKey());
if (lnurl && dittoZapSplit) { if (lnurl && dittoZapSplit) {
const totalSplit = Object.values(dittoZapSplit).reduce((total, { weight }) => total + weight, 0); const totalSplit = Object.values(dittoZapSplit).reduce((total, { weight }) => total + weight, 0);
for (const zapPubkey in dittoZapSplit) { for (const zapPubkey in dittoZapSplit) {
@ -256,8 +256,8 @@ const createStatusController: AppController = async (c) => {
if (data.quote_id) { if (data.quote_id) {
await hydrateEvents({ await hydrateEvents({
events: [event], events: [event],
store: await Storages.db(), relay,
signal: c.req.raw.signal, signal,
}); });
} }
@ -265,11 +265,11 @@ const createStatusController: AppController = async (c) => {
}; };
const deleteStatusController: AppController = async (c) => { const deleteStatusController: AppController = async (c) => {
const { conf } = c.var; const { conf, user, signal } = c.var;
const id = c.req.param('id');
const pubkey = await c.get('signer')?.getPublicKey();
const event = await getEvent(id, { signal: c.req.raw.signal }); const id = c.req.param('id');
const pubkey = await user?.signer.getPublicKey();
const event = await getEvent(id, { signal });
if (event) { if (event) {
if (event.pubkey === pubkey) { if (event.pubkey === pubkey) {
@ -289,10 +289,11 @@ const deleteStatusController: AppController = async (c) => {
}; };
const contextController: AppController = async (c) => { const contextController: AppController = async (c) => {
const { relay, user } = c.var;
const id = c.req.param('id'); const id = c.req.param('id');
const store = c.get('store'); const [event] = await relay.query([{ kinds: [1, 20], ids: [id] }]);
const [event] = await store.query([{ kinds: [1, 20], ids: [id] }]); const viewerPubkey = await user?.signer.getPublicKey();
const viewerPubkey = await c.get('signer')?.getPublicKey();
async function renderStatuses(events: NostrEvent[]) { async function renderStatuses(events: NostrEvent[]) {
const statuses = await Promise.all( const statuses = await Promise.all(
@ -303,14 +304,14 @@ const contextController: AppController = async (c) => {
if (event) { if (event) {
const [ancestorEvents, descendantEvents] = await Promise.all([ const [ancestorEvents, descendantEvents] = await Promise.all([
getAncestors(store, event), getAncestors(relay, event),
getDescendants(store, event), getDescendants(relay, event),
]); ]);
await hydrateEvents({ await hydrateEvents({
events: [...ancestorEvents, ...descendantEvents], events: [...ancestorEvents, ...descendantEvents],
signal: c.req.raw.signal, signal: c.req.raw.signal,
store, relay,
}); });
const [ancestors, descendants] = await Promise.all([ const [ancestors, descendants] = await Promise.all([
@ -325,10 +326,10 @@ const contextController: AppController = async (c) => {
}; };
const favouriteController: AppController = async (c) => { const favouriteController: AppController = async (c) => {
const { conf } = c.var; const { conf, relay, user } = c.var;
const id = c.req.param('id'); const id = c.req.param('id');
const store = await Storages.db(); const [target] = await relay.query([{ ids: [id], kinds: [1, 20] }]);
const [target] = await store.query([{ ids: [id], kinds: [1, 20] }]);
if (target) { if (target) {
await createEvent({ await createEvent({
@ -340,9 +341,9 @@ const favouriteController: AppController = async (c) => {
], ],
}, c); }, c);
await hydrateEvents({ events: [target], store }); await hydrateEvents({ events: [target], relay });
const status = await renderStatus(target, { viewerPubkey: await c.get('signer')?.getPublicKey() }); const status = await renderStatus(target, { viewerPubkey: await user?.signer.getPublicKey() });
if (status) { if (status) {
status.favourited = true; status.favourited = true;
@ -366,13 +367,10 @@ const favouritedByController: AppController = (c) => {
/** https://docs.joinmastodon.org/methods/statuses/#boost */ /** https://docs.joinmastodon.org/methods/statuses/#boost */
const reblogStatusController: AppController = async (c) => { const reblogStatusController: AppController = async (c) => {
const { conf } = c.var; const { conf, relay, user, signal } = c.var;
const eventId = c.req.param('id');
const { signal } = c.req.raw;
const event = await getEvent(eventId, { const eventId = c.req.param('id');
kind: 1, const event = await getEvent(eventId);
});
if (!event) { if (!event) {
return c.json({ error: 'Event not found.' }, 404); return c.json({ error: 'Event not found.' }, 404);
@ -388,28 +386,28 @@ const reblogStatusController: AppController = async (c) => {
await hydrateEvents({ await hydrateEvents({
events: [reblogEvent], events: [reblogEvent],
store: await Storages.db(), relay,
signal: signal, signal: signal,
}); });
const status = await renderReblog(reblogEvent, { viewerPubkey: await c.get('signer')?.getPublicKey() }); const status = await renderReblog(reblogEvent, { viewerPubkey: await user?.signer.getPublicKey() });
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, relay, user } = c.var;
const eventId = c.req.param('id');
const pubkey = await c.get('signer')?.getPublicKey()!;
const store = await Storages.db();
const [event] = await store.query([{ ids: [eventId], kinds: [1, 20] }]); const eventId = c.req.param('id');
const pubkey = await user!.signer.getPublicKey();
const [event] = await relay.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);
} }
const [repostEvent] = await store.query( const [repostEvent] = await relay.query(
[{ kinds: [6], authors: [pubkey], '#e': [event.id], limit: 1 }], [{ kinds: [6], authors: [pubkey], '#e': [event.id], limit: 1 }],
); );
@ -432,20 +430,20 @@ const rebloggedByController: AppController = (c) => {
}; };
const quotesController: AppController = async (c) => { const quotesController: AppController = async (c) => {
const id = c.req.param('id'); const { relay, user, pagination } = c.var;
const params = c.get('pagination');
const store = await Storages.db();
const [event] = await store.query([{ ids: [id], kinds: [1, 20] }]); const id = c.req.param('id');
const [event] = await relay.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 relay
.query([{ kinds: [1, 20], '#q': [event.id], ...params }]) .query([{ kinds: [1, 20], '#q': [event.id], ...pagination }])
.then((events) => hydrateEvents({ events, store })); .then((events) => hydrateEvents({ events, relay }));
const viewerPubkey = await c.get('signer')?.getPublicKey(); const viewerPubkey = await user?.signer.getPublicKey();
const statuses = await Promise.all( const statuses = await Promise.all(
quotes.map((event) => renderStatus(event, { viewerPubkey })), quotes.map((event) => renderStatus(event, { viewerPubkey })),
@ -460,14 +458,11 @@ const quotesController: AppController = async (c) => {
/** https://docs.joinmastodon.org/methods/statuses/#bookmark */ /** https://docs.joinmastodon.org/methods/statuses/#bookmark */
const bookmarkController: AppController = async (c) => { const bookmarkController: AppController = async (c) => {
const { conf } = 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(eventId);
kind: 1,
relations: ['author', 'event_stats', 'author_stats'],
});
if (event) { if (event) {
await updateListEvent( await updateListEvent(
@ -488,14 +483,12 @@ const bookmarkController: AppController = async (c) => {
/** https://docs.joinmastodon.org/methods/statuses/#unbookmark */ /** https://docs.joinmastodon.org/methods/statuses/#unbookmark */
const unbookmarkController: AppController = async (c) => { const unbookmarkController: AppController = async (c) => {
const { conf } = 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(eventId);
kind: 1,
relations: ['author', 'event_stats', 'author_stats'],
});
if (event) { if (event) {
await updateListEvent( await updateListEvent(
@ -516,14 +509,12 @@ const unbookmarkController: AppController = async (c) => {
/** https://docs.joinmastodon.org/methods/statuses/#pin */ /** https://docs.joinmastodon.org/methods/statuses/#pin */
const pinController: AppController = async (c) => { const pinController: AppController = async (c) => {
const { conf } = 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(eventId);
kind: 1,
relations: ['author', 'event_stats', 'author_stats'],
});
if (event) { if (event) {
await updateListEvent( await updateListEvent(
@ -544,14 +535,13 @@ 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, signal } = 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(eventId, {
kind: 1, kind: 1,
relations: ['author', 'event_stats', 'author_stats'],
signal, signal,
}); });
@ -580,11 +570,10 @@ const zapSchema = z.object({
}); });
const zapController: AppController = async (c) => { const zapController: AppController = async (c) => {
const { conf } = c.var; const { conf, relay, signal } = c.var;
const body = await parseBody(c.req.raw); const body = await parseBody(c.req.raw);
const result = zapSchema.safeParse(body); const result = zapSchema.safeParse(body);
const { signal } = c.req.raw;
const store = c.get('store');
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);
@ -611,7 +600,7 @@ const zapController: AppController = async (c) => {
); );
} }
} else { } else {
[target] = await store.query([{ authors: [account_id], kinds: [0], limit: 1 }]); [target] = await relay.query([{ authors: [account_id], kinds: [0], limit: 1 }]);
const meta = n.json().pipe(n.metadata()).catch({}).parse(target?.content); const meta = n.json().pipe(n.metadata()).catch({}).parse(target?.content);
lnurl = getLnurl(meta); lnurl = getLnurl(meta);
if (target && lnurl) { if (target && lnurl) {
@ -638,19 +627,19 @@ const zapController: AppController = async (c) => {
}; };
const zappedByController: AppController = async (c) => { const zappedByController: AppController = async (c) => {
const id = c.req.param('id'); const { db, relay } = c.var;
const params = c.get('listPagination');
const store = await Storages.db();
const kysely = await Storages.kysely();
const zaps = await kysely.selectFrom('event_zaps') const id = c.req.param('id');
const { offset, limit } = paginationSchema.parse(c.req.query());
const zaps = await db.kysely.selectFrom('event_zaps')
.selectAll() .selectAll()
.where('target_event_id', '=', id) .where('target_event_id', '=', id)
.orderBy('amount_millisats', 'desc') .orderBy('amount_millisats', 'desc')
.limit(params.limit) .limit(limit)
.offset(params.offset).execute(); .offset(offset).execute();
const authors = await store.query([{ kinds: [0], authors: zaps.map((zap) => zap.sender_pubkey) }]); const authors = await relay.query([{ kinds: [0], authors: zaps.map((zap) => zap.sender_pubkey) }]);
const results = (await Promise.all( const results = (await Promise.all(
zaps.map(async (zap) => { zaps.map(async (zap) => {
@ -668,7 +657,7 @@ const zappedByController: AppController = async (c) => {
}), }),
)).filter(Boolean); )).filter(Boolean);
return paginatedList(c, params, results); return paginatedList(c, { limit, offset }, results);
}; };
export { export {

View file

@ -12,13 +12,10 @@ import { z } from 'zod';
import { type AppController } from '@/app.ts'; import { type AppController } from '@/app.ts';
import { getFeedPubkeys } from '@/queries.ts'; import { getFeedPubkeys } from '@/queries.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 { errorJson } from '@/utils/log.ts'; import { errorJson } from '@/utils/log.ts';
import { bech32ToPubkey, Time } from '@/utils.ts'; import { Time } from '@/utils.ts';
import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts'; import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts';
import { renderNotification } from '@/views/mastodon/notifications.ts'; import { renderNotification } from '@/views/mastodon/notifications.ts';
import { HTTPException } from '@hono/hono/http-exception';
/** /**
* Streaming timelines/categories. * Streaming timelines/categories.
@ -68,7 +65,7 @@ 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, relay, user } = 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,11 +75,6 @@ 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;
if (token && !pubkey) {
return c.json({ error: 'Invalid access token' }, 401);
}
const ip = c.req.header('x-real-ip'); const ip = c.req.header('x-real-ip');
if (ip) { if (ip) {
const count = limiter.get(ip) ?? 0; const count = limiter.get(ip) ?? 0;
@ -93,8 +85,8 @@ 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 pubkey = await user?.signer.getPublicKey();
const policy = pubkey ? new MuteListPolicy(pubkey, await Storages.admin()) : undefined; const policy = pubkey ? new MuteListPolicy(pubkey, relay) : undefined;
function send(e: StreamingEvent) { function send(e: StreamingEvent) {
if (socket.readyState === WebSocket.OPEN) { if (socket.readyState === WebSocket.OPEN) {
@ -108,7 +100,7 @@ const streamingController: AppController = async (c) => {
render: (event: NostrEvent) => Promise<StreamingEvent | undefined>, render: (event: NostrEvent) => Promise<StreamingEvent | undefined>,
) { ) {
try { try {
for await (const msg of store.req([filter], { signal: controller.signal })) { for await (const msg of relay.req([filter], { signal: controller.signal })) {
if (msg[0] === 'EVENT') { if (msg[0] === 'EVENT') {
const event = msg[2]; const event = msg[2];
@ -119,7 +111,7 @@ const streamingController: AppController = async (c) => {
} }
} }
await hydrateEvents({ events: [event], store, signal: AbortSignal.timeout(1000) }); await hydrateEvents({ events: [event], relay, signal: AbortSignal.timeout(1000) });
const result = await render(event); const result = await render(event);
@ -230,25 +222,4 @@ async function topicToFilter(
} }
} }
async function getTokenPubkey(token: string): Promise<string | undefined> {
if (token.startsWith('token1')) {
const kysely = await Storages.kysely();
const tokenHash = await getTokenHash(token as `token1${string}`);
const row = await kysely
.selectFrom('auth_tokens')
.select('pubkey')
.where('token_hash', '=', tokenHash)
.executeTakeFirst();
if (!row) {
throw new HTTPException(401, { message: 'Invalid access token' });
}
return row.pubkey;
} else {
return bech32ToPubkey(token);
}
}
export { streamingController }; export { streamingController };

View file

@ -2,33 +2,32 @@ import { NostrFilter } from '@nostrify/nostrify';
import { matchFilter } from 'nostr-tools'; import { matchFilter } from 'nostr-tools';
import { AppContext, AppController } from '@/app.ts'; import { AppContext, AppController } from '@/app.ts';
import { paginationSchema } from '@/schemas/pagination.ts';
import { hydrateEvents } from '@/storages/hydrate.ts'; import { hydrateEvents } from '@/storages/hydrate.ts';
import { paginated, paginatedList } from '@/utils/api.ts'; import { paginated, paginatedList } from '@/utils/api.ts';
import { getTagSet } from '@/utils/tags.ts'; import { getTagSet } from '@/utils/tags.ts';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
export const suggestionsV1Controller: AppController = async (c) => { export const suggestionsV1Controller: AppController = async (c) => {
const signal = c.req.raw.signal; const { signal } = c.var;
const params = c.get('listPagination'); const { offset, limit } = paginationSchema.parse(c.req.query());
const suggestions = await renderV2Suggestions(c, params, signal); const suggestions = await renderV2Suggestions(c, { offset, limit }, signal);
const accounts = suggestions.map(({ account }) => account); const accounts = suggestions.map(({ account }) => account);
return paginatedList(c, params, accounts); return paginatedList(c, { offset, limit }, accounts);
}; };
export const suggestionsV2Controller: AppController = async (c) => { export const suggestionsV2Controller: AppController = async (c) => {
const signal = c.req.raw.signal; const { signal } = c.var;
const params = c.get('listPagination'); const { offset, limit } = paginationSchema.parse(c.req.query());
const suggestions = await renderV2Suggestions(c, params, signal); const suggestions = await renderV2Suggestions(c, { offset, limit }, signal);
return paginatedList(c, params, suggestions); return paginatedList(c, { offset, limit }, suggestions);
}; };
async function renderV2Suggestions(c: AppContext, params: { offset: number; limit: number }, signal?: AbortSignal) { async function renderV2Suggestions(c: AppContext, params: { offset: number; limit: number }, signal?: AbortSignal) {
const { conf } = c.var; const { conf, relay, user } = c.var;
const { offset, limit } = params; const { offset, limit } = params;
const store = c.get('store'); const pubkey = await user?.signer.getPublicKey();
const signer = c.get('signer');
const pubkey = await signer?.getPublicKey();
const filters: NostrFilter[] = [ const filters: NostrFilter[] = [
{ kinds: [30382], authors: [await conf.signer.getPublicKey()], '#n': ['suggested'], limit }, { kinds: [30382], authors: [await conf.signer.getPublicKey()], '#n': ['suggested'], limit },
@ -40,7 +39,7 @@ async function renderV2Suggestions(c: AppContext, params: { offset: number; limi
filters.push({ kinds: [10000], authors: [pubkey], limit: 1 }); filters.push({ kinds: [10000], authors: [pubkey], limit: 1 });
} }
const events = await store.query(filters, { signal }); const events = await relay.query(filters, { signal });
const adminPubkey = await conf.signer.getPublicKey(); const adminPubkey = await conf.signer.getPublicKey();
const [userEvents, followsEvent, mutesEvent, trendingEvent] = [ const [userEvents, followsEvent, mutesEvent, trendingEvent] = [
@ -79,11 +78,11 @@ async function renderV2Suggestions(c: AppContext, params: { offset: number; limi
const authors = [...pubkeys].slice(offset, offset + limit); const authors = [...pubkeys].slice(offset, offset + limit);
const profiles = await store.query( const profiles = await relay.query(
[{ kinds: [0], authors, limit: authors.length }], [{ kinds: [0], authors, limit: authors.length }],
{ signal }, { signal },
) )
.then((events) => hydrateEvents({ events, store, signal })); .then((events) => hydrateEvents({ events, relay, signal }));
return Promise.all(authors.map(async (pubkey) => { return Promise.all(authors.map(async (pubkey) => {
const profile = profiles.find((event) => event.pubkey === pubkey); const profile = profiles.find((event) => event.pubkey === pubkey);
@ -96,13 +95,10 @@ async function renderV2Suggestions(c: AppContext, params: { offset: number; limi
} }
export const localSuggestionsController: AppController = async (c) => { export const localSuggestionsController: AppController = async (c) => {
const { conf } = c.var; const { conf, relay, pagination, signal } = c.var;
const signal = c.req.raw.signal;
const params = c.get('pagination');
const store = c.get('store');
const grants = await store.query( const grants = await relay.query(
[{ kinds: [30360], authors: [await conf.signer.getPublicKey()], ...params }], [{ kinds: [30360], authors: [await conf.signer.getPublicKey()], ...pagination }],
{ signal }, { signal },
); );
@ -115,11 +111,11 @@ export const localSuggestionsController: AppController = async (c) => {
} }
} }
const profiles = await store.query( const profiles = await relay.query(
[{ kinds: [0], authors: [...pubkeys], search: `domain:${conf.url.host}`, ...params }], [{ kinds: [0], authors: [...pubkeys], search: `domain:${conf.url.host}`, ...pagination }],
{ signal }, { signal },
) )
.then((events) => hydrateEvents({ store, events, signal })); .then((events) => hydrateEvents({ relay, events, signal }));
const suggestions = [...pubkeys].map((pubkey) => { const suggestions = [...pubkeys].map((pubkey) => {
const profile = profiles.find((event) => event.pubkey === pubkey); const profile = profiles.find((event) => event.pubkey === pubkey);

View file

@ -15,8 +15,8 @@ const homeQuerySchema = z.object({
}); });
const homeTimelineController: AppController = async (c) => { const homeTimelineController: AppController = async (c) => {
const params = c.get('pagination'); const { user, pagination } = c.var;
const pubkey = await c.get('signer')?.getPublicKey()!; const pubkey = await user?.signer.getPublicKey()!;
const result = homeQuerySchema.safeParse(c.req.query()); const result = homeQuerySchema.safeParse(c.req.query());
if (!result.success) { if (!result.success) {
@ -26,7 +26,7 @@ const homeTimelineController: AppController = async (c) => {
const { exclude_replies, only_media } = result.data; const { exclude_replies, only_media } = result.data;
const authors = [...await getFeedPubkeys(pubkey)]; const authors = [...await getFeedPubkeys(pubkey)];
const filter: NostrFilter = { authors, kinds: [1, 6, 20], ...params }; const filter: NostrFilter = { authors, kinds: [1, 6, 20], ...pagination };
const search: string[] = []; const search: string[] = [];
@ -90,35 +90,33 @@ const hashtagTimelineController: AppController = (c) => {
}; };
const suggestedTimelineController: AppController = async (c) => { const suggestedTimelineController: AppController = async (c) => {
const { conf } = c.var; const { conf, relay, pagination } = c.var;
const store = c.get('store');
const params = c.get('pagination');
const [follows] = await store.query( const [follows] = await relay.query(
[{ kinds: [3], authors: [await conf.signer.getPublicKey()], limit: 1 }], [{ kinds: [3], authors: [await conf.signer.getPublicKey()], limit: 1 }],
); );
const authors = [...getTagSet(follows?.tags ?? [], 'p')]; const authors = [...getTagSet(follows?.tags ?? [], 'p')];
return renderStatuses(c, [{ authors, kinds: [1, 20], ...params }]); return renderStatuses(c, [{ authors, kinds: [1, 20], ...pagination }]);
}; };
/** Render statuses for timelines. */ /** Render statuses for timelines. */
async function renderStatuses(c: AppContext, filters: NostrFilter[]) { async function renderStatuses(c: AppContext, filters: NostrFilter[]) {
const { conf } = c.var; const { conf, user, signal } = c.var;
const { signal } = c.req.raw;
const store = c.get('store'); const relay = user?.relay ?? c.var.relay;
const opts = { signal, timeout: conf.db.timeouts.timelines }; const opts = { signal, timeout: conf.db.timeouts.timelines };
const events = await store const events = await relay
.query(filters, opts) .query(filters, opts)
.then((events) => hydrateEvents({ events, store, signal })); .then((events) => hydrateEvents({ events, relay, signal }));
if (!events.length) { if (!events.length) {
return c.json([]); return c.json([]);
} }
const viewerPubkey = await c.get('signer')?.getPublicKey(); const viewerPubkey = await user?.signer.getPublicKey();
const statuses = (await Promise.all(events.map((event) => { const statuses = (await Promise.all(events.map((event) => {
if (event.kind === 6) { if (event.kind === 6) {

View file

@ -17,8 +17,9 @@ const translateSchema = z.object({
}); });
const translateController: AppController = async (c) => { const translateController: AppController = async (c) => {
const { user, signal } = c.var;
const result = translateSchema.safeParse(await parseBody(c.req.raw)); const result = translateSchema.safeParse(await parseBody(c.req.raw));
const { signal } = 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);
@ -38,7 +39,7 @@ const translateController: AppController = async (c) => {
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);

View file

@ -52,8 +52,8 @@ const trendingTagsController: AppController = async (c) => {
}; };
async function getTrendingHashtags(conf: DittoConf) { async function getTrendingHashtags(conf: DittoConf) {
const store = await Storages.db(); const relay = await Storages.db();
const trends = await getTrendingTags(store, 't', await conf.signer.getPublicKey()); const trends = await getTrendingTags(relay, 't', await conf.signer.getPublicKey());
return trends.map((trend) => { return trends.map((trend) => {
const hashtag = trend.value; const hashtag = trend.value;
@ -105,8 +105,8 @@ const trendingLinksController: AppController = async (c) => {
}; };
async function getTrendingLinks(conf: DittoConf) { async function getTrendingLinks(conf: DittoConf) {
const store = await Storages.db(); const relay = await Storages.db();
const trends = await getTrendingTags(store, 'r', await conf.signer.getPublicKey()); const trends = await getTrendingTags(relay, 'r', await conf.signer.getPublicKey());
return Promise.all(trends.map(async (trend) => { return Promise.all(trends.map(async (trend) => {
const link = trend.value; const link = trend.value;
@ -140,11 +140,10 @@ async function getTrendingLinks(conf: DittoConf) {
} }
const trendingStatusesController: AppController = async (c) => { const trendingStatusesController: AppController = async (c) => {
const { conf } = c.var; const { conf, relay } = 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 relay.query([{
kinds: [1985], kinds: [1985],
'#L': ['pub.ditto.trends'], '#L': ['pub.ditto.trends'],
'#l': ['#e'], '#l': ['#e'],
@ -162,8 +161,8 @@ const trendingStatusesController: AppController = async (c) => {
return c.json([]); return c.json([]);
} }
const results = await store.query([{ kinds: [1, 20], ids }]) const results = await relay.query([{ kinds: [1, 20], ids }])
.then((events) => hydrateEvents({ events, store })); .then((events) => hydrateEvents({ events, relay }));
// 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

View file

@ -1,7 +1,6 @@
import { logi } from '@soapbox/logi'; import { logi } from '@soapbox/logi';
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';
@ -10,11 +9,14 @@ import { renderMetadata } from '@/views/meta.ts';
import { getAuthor, getEvent } from '@/queries.ts'; import { getAuthor, getEvent } from '@/queries.ts';
import { renderStatus } from '@/views/mastodon/statuses.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts';
import { renderAccount } from '@/views/mastodon/accounts.ts'; import { renderAccount } from '@/views/mastodon/accounts.ts';
import { NStore } from '@nostrify/nostrify';
/** Placeholder to find & replace with metadata. */ /** Placeholder to find & replace with metadata. */
const META_PLACEHOLDER = '<!--server-generated-meta-->' as const; const META_PLACEHOLDER = '<!--server-generated-meta-->' as const;
export const frontendController: AppMiddleware = async (c) => { export const frontendController: AppMiddleware = async (c) => {
const { relay } = c.var;
c.header('Cache-Control', 'max-age=86400, s-maxage=30, public, stale-if-error=604800'); c.header('Cache-Control', 'max-age=86400, s-maxage=30, public, stale-if-error=604800');
try { try {
@ -23,7 +25,7 @@ export const frontendController: AppMiddleware = async (c) => {
if (content.includes(META_PLACEHOLDER)) { if (content.includes(META_PLACEHOLDER)) {
const params = getPathParams(c.req.path); const params = getPathParams(c.req.path);
try { try {
const entities = await getEntities(params ?? {}); const entities = await getEntities(relay, params ?? {});
const meta = renderMetadata(c.req.url, entities); const meta = renderMetadata(c.req.url, entities);
return c.html(content.replace(META_PLACEHOLDER, meta)); return c.html(content.replace(META_PLACEHOLDER, meta));
} catch (e) { } catch (e) {
@ -37,11 +39,9 @@ export const frontendController: AppMiddleware = async (c) => {
} }
}; };
async function getEntities(params: { acct?: string; statusId?: string }): Promise<MetadataEntities> { async function getEntities(relay: NStore, params: { acct?: string; statusId?: string }): Promise<MetadataEntities> {
const store = await Storages.db();
const entities: MetadataEntities = { const entities: MetadataEntities = {
instance: await getInstanceMetadata(store), instance: await getInstanceMetadata(relay),
}; };
if (params.statusId) { if (params.statusId) {

View file

@ -1,10 +1,11 @@
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 { relay, signal } = c.var;
const meta = await getInstanceMetadata(relay, signal);
const manifest: WebManifestCombined = { const manifest: WebManifestCombined = {
description: meta.about, description: meta.about,

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, relay, signal } = c.var;
const store = await Storages.db();
const meta = await getInstanceMetadata(store, c.req.raw.signal); const meta = await getInstanceMetadata(relay, signal);
c.res.headers.set('access-control-allow-origin', '*'); c.res.headers.set('access-control-allow-origin', '*');

View file

@ -18,7 +18,7 @@ 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 * as pipeline from '@/pipeline.ts';
import { RelayError } from '@/RelayError.ts'; import { RelayError } from '@/RelayError.ts';
import { Storages } from '@/storages.ts'; import { type DittoPgStore } from '@/storages/DittoPgStore.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 { Time } from '@/utils/time.ts'; import { Time } from '@/utils/time.ts';
@ -42,7 +42,7 @@ const limiters = {
const connections = new Set<WebSocket>(); const connections = new Set<WebSocket>();
/** Set up the Websocket connection. */ /** Set up the Websocket connection. */
function connectStream(socket: WebSocket, ip: string | undefined, conf: DittoConf) { function connectStream(conf: DittoConf, relay: DittoPgStore, socket: WebSocket, ip: string | undefined) {
const controllers = new Map<string, AbortController>(); const controllers = new Map<string, AbortController>();
if (ip) { if (ip) {
@ -133,10 +133,8 @@ 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();
try { try {
for await (const [verb, , ...rest] of store.req(filters, { limit: 100, timeout: conf.db.timeouts.relay })) { for await (const [verb, , ...rest] of relay.req(filters, { limit: 100, timeout: conf.db.timeouts.relay })) {
send([verb, subId, ...rest] as NostrRelayMsg); send([verb, subId, ...rest] as NostrRelayMsg);
} }
} catch (e) { } catch (e) {
@ -185,8 +183,7 @@ 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 relay.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 }]);
} }
@ -199,7 +196,7 @@ function connectStream(socket: WebSocket, ip: string | undefined, conf: DittoCon
} }
const relayController: AppController = (c, next) => { const relayController: AppController = (c, next) => {
const { conf } = c.var; const { conf, relay } = c.var;
const upgrade = c.req.header('upgrade'); const upgrade = c.req.header('upgrade');
// NIP-11: https://github.com/nostr-protocol/nips/blob/master/11.md // NIP-11: https://github.com/nostr-protocol/nips/blob/master/11.md
@ -218,7 +215,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(conf, relay as DittoPgStore, socket, ip);
return response; return response;
}; };

View file

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

View file

@ -1,116 +0,0 @@
import { HTTPException } from '@hono/hono/http-exception';
import { NostrEvent } from '@nostrify/nostrify';
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 {
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';
/** Require the user to prove their role before invoking the controller. */
function requireRole(role: UserRole, opts?: ParseAuthRequestOpts): AppMiddleware {
return withProof(async (c, proof, next) => {
const { conf } = c.var;
const store = await Storages.db();
const [user] = await store.query([{
kinds: [30382],
authors: [await conf.signer.getPublicKey()],
'#d': [proof.pubkey],
limit: 1,
}]);
if (user && matchesRole(user, role)) {
await next();
} else {
throw new HTTPException(401);
}
}, opts);
}
/** Require the user to demonstrate they own the pubkey by signing an event. */
function requireProof(opts?: ParseAuthRequestOpts): AppMiddleware {
return withProof(async (_c, _proof, next) => {
await next();
}, opts);
}
/** Check whether the user fulfills the role. */
function matchesRole(user: NostrEvent, role: UserRole): boolean {
return user.tags.some(([tag, value]) => tag === 'n' && value === role);
}
/** HOC to obtain proof in middleware. */
function withProof(
handler: (c: AppContext, proof: NostrEvent, next: () => Promise<void>) => Promise<void>,
opts?: ParseAuthRequestOpts,
): AppMiddleware {
return async (c, next) => {
const signer = c.get('signer');
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) {
c.set('proof', proof);
if (!signer) {
c.set('signer', new ReadOnlySigner(proof.pubkey));
}
await handler(c, proof, next);
} else {
throw new HTTPException(401, { message: 'No proof' });
}
};
}
/** Get the proof over Nostr Connect. */
async function obtainProof(c: AppContext, opts?: ParseAuthRequestOpts) {
const signer = c.get('signer');
if (!signer) {
throw new HTTPException(401, {
res: c.json({ error: 'No way to sign Nostr event' }, 401),
});
}
const req = localRequest(c);
const reqEvent = await buildAuthEventTemplate(req, opts);
const resEvent = await signer.signEvent(reqEvent);
const result = await validateAuthEvent(req, resEvent, opts);
if (result.success) {
return result.data;
}
}
export { auth98Middleware, requireProof, requireRole };

View file

@ -1,49 +0,0 @@
import { AppMiddleware } from '@/app.ts';
import { paginationSchema } from '@/schemas/pagination.ts';
import { Storages } from '@/storages.ts';
/** Fixes compatibility with Mastodon apps by that don't use `Link` headers. */
export const paginationMiddleware: AppMiddleware = async (c, next) => {
const pagination = paginationSchema.parse(c.req.query());
const {
max_id: maxId,
min_id: minId,
since,
until,
} = pagination;
if ((maxId && !until) || (minId && !since)) {
const ids: string[] = [];
if (maxId) ids.push(maxId);
if (minId) ids.push(minId);
if (ids.length) {
const store = await Storages.db();
const events = await store.query(
[{ ids, limit: ids.length }],
{ signal: c.req.raw.signal },
);
for (const event of events) {
if (!until && maxId === event.id) pagination.until = event.created_at;
if (!since && minId === event.id) pagination.since = event.created_at;
}
}
}
c.set('pagination', {
since: pagination.since,
until: pagination.until,
limit: pagination.limit,
});
c.set('listPagination', {
limit: pagination.limit,
offset: pagination.offset,
});
await next();
};

View file

@ -1,29 +0,0 @@
import { MiddlewareHandler } from '@hono/hono';
import { HTTPException } from '@hono/hono/http-exception';
import { NostrSigner } from '@nostrify/nostrify';
import { SetRequired } from 'type-fest';
/** Throw a 401 if a signer isn't set. */
export const requireSigner: MiddlewareHandler<{ Variables: { signer: NostrSigner } }> = async (c, next) => {
if (!c.get('signer')) {
throw new HTTPException(401, { message: 'No pubkey provided' });
}
await next();
};
/** Throw a 401 if a NIP-44 signer isn't set. */
export const requireNip44Signer: MiddlewareHandler<{ Variables: { signer: SetRequired<NostrSigner, 'nip44'> } }> =
async (c, next) => {
const signer = c.get('signer');
if (!signer) {
throw new HTTPException(401, { message: 'No pubkey provided' });
}
if (!signer.nip44) {
throw new HTTPException(401, { message: 'No NIP-44 signer provided' });
}
await next();
};

View file

@ -1,75 +0,0 @@
import { type DittoConf } from '@ditto/conf';
import { MiddlewareHandler } from '@hono/hono';
import { HTTPException } from '@hono/hono/http-exception';
import { NostrSigner, NSecSigner } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools';
import { ConnectSigner } from '@/signers/ConnectSigner.ts';
import { ReadOnlySigner } from '@/signers/ReadOnlySigner.ts';
import { Storages } from '@/storages.ts';
import { aesDecrypt } from '@/utils/aes.ts';
import { getTokenHash } from '@/utils/auth.ts';
/** We only accept "Bearer" type. */
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. */
export const signerMiddleware: MiddlewareHandler<{ Variables: { signer: NostrSigner; conf: DittoConf } }> = async (
c,
next,
) => {
const { conf } = c.var;
const header = c.req.header('authorization');
const match = header?.match(BEARER_REGEX);
if (match) {
const [_, bech32] = match;
if (bech32.startsWith('token1')) {
try {
const kysely = await Storages.kysely();
const tokenHash = await getTokenHash(bech32 as `token1${string}`);
const { pubkey: userPubkey, bunker_pubkey: bunkerPubkey, nip46_sk_enc, nip46_relays } = await kysely
.selectFrom('auth_tokens')
.select(['pubkey', 'bunker_pubkey', 'nip46_sk_enc', 'nip46_relays'])
.where('token_hash', '=', tokenHash)
.executeTakeFirstOrThrow();
const nep46Seckey = await aesDecrypt(conf.seckey, nip46_sk_enc);
c.set(
'signer',
new ConnectSigner({
bunkerPubkey,
userPubkey,
signer: new NSecSigner(nep46Seckey),
relays: nip46_relays,
}),
);
} catch {
throw new HTTPException(401);
}
} else {
try {
const decoded = nip19.decode(bech32!);
switch (decoded.type) {
case 'npub':
c.set('signer', new ReadOnlySigner(decoded.data));
break;
case 'nprofile':
c.set('signer', new ReadOnlySigner(decoded.data.pubkey));
break;
case 'nsec':
c.set('signer', new NSecSigner(decoded.data));
break;
}
} catch {
throw new HTTPException(401);
}
}
}
await next();
};

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,13 +1,12 @@
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';
import { AppEnv } from '@/app.ts';
import { isNostrId } from '@/utils.ts'; import { isNostrId } from '@/utils.ts';
import { errorJson } from '@/utils/log.ts'; import { errorJson } from '@/utils/log.ts';
import { createEvent } from '@/utils/api.ts'; import { createEvent } from '@/utils/api.ts';
@ -17,33 +16,28 @@ 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, relay, 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) { if (!relay) {
throw new HTTPException(401, { message: 'No store provided' }); throw new HTTPException(401, { message: 'No store provided' });
} }
const { signal } = c.req.raw; const pubkey = await user.signer.getPublicKey();
const pubkey = await signer.getPublicKey(); const [wallet] = await relay.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',
@ -68,7 +62,7 @@ export const swapNutzapsMiddleware: MiddlewareHandler<
} }
const p2pk = getPublicKey(stringToBytes('hex', privkey)); const p2pk = getPublicKey(stringToBytes('hex', privkey));
const [nutzapInformation] = await store.query([{ authors: [pubkey], kinds: [10019] }], { signal }); const [nutzapInformation] = await relay.query([{ authors: [pubkey], kinds: [10019] }], { signal });
if (!nutzapInformation) { if (!nutzapInformation) {
return c.json({ error: 'You need to have a nutzap information event so we can get the mints.' }, 400); return c.json({ error: 'You need to have a nutzap information event so we can get the mints.' }, 400);
} }
@ -88,14 +82,14 @@ export const swapNutzapsMiddleware: MiddlewareHandler<
const nutzapsFilter: NostrFilter = { kinds: [9321], '#p': [pubkey], '#u': mints }; const nutzapsFilter: NostrFilter = { kinds: [9321], '#p': [pubkey], '#u': mints };
const [nutzapHistory] = await store.query([{ kinds: [7376], authors: [pubkey] }], { signal }); const [nutzapHistory] = await relay.query([{ kinds: [7376], authors: [pubkey] }], { signal });
if (nutzapHistory) { if (nutzapHistory) {
nutzapsFilter.since = nutzapHistory.created_at; nutzapsFilter.since = nutzapHistory.created_at;
} }
const mintsToProofs: { [key: string]: { proofs: Proof[]; redeemed: string[][] } } = {}; const mintsToProofs: { [key: string]: { proofs: Proof[]; redeemed: string[][] } } = {};
const nutzaps = await store.query([nutzapsFilter], { signal }); const nutzaps = await relay.query([nutzapsFilter], { signal });
for (const event of nutzaps) { for (const event of nutzaps) {
try { try {
@ -154,7 +148,7 @@ export const swapNutzapsMiddleware: MiddlewareHandler<
const unspentProofs = await createEvent({ const unspentProofs = await createEvent({
kind: 7375, kind: 7375,
content: await signer.nip44.encrypt( content: await user.signer.nip44.encrypt(
pubkey, pubkey,
JSON.stringify({ JSON.stringify({
mint, mint,
@ -169,7 +163,7 @@ export const swapNutzapsMiddleware: MiddlewareHandler<
await createEvent({ await createEvent({
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'],

View file

@ -6,7 +6,8 @@ import { AppMiddleware } from '@/app.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 { user, conf } = c.var;
const signer = user?.signer;
switch (conf.uploader) { switch (conf.uploader) {
case 's3': case 's3':

View file

@ -137,7 +137,7 @@ function isProtectedEvent(event: NostrEvent): boolean {
/** Hydrate the event with the user, if applicable. */ /** Hydrate the event with the user, if applicable. */
async function hydrateEvent(event: DittoEvent, signal: AbortSignal): Promise<void> { async function hydrateEvent(event: DittoEvent, signal: AbortSignal): Promise<void> {
await hydrateEvents({ events: [event], store: await Storages.db(), signal }); await hydrateEvents({ events: [event], relay: await Storages.db(), signal });
} }
/** Maybe store the event, if eligible. */ /** Maybe store the event, if eligible. */

View file

@ -19,13 +19,13 @@ interface GetEventOpts {
/** /**
* Get a Nostr event by its ID. * Get a Nostr event by its ID.
* @deprecated Use `store.query` directly. * @deprecated Use `relay.query` directly.
*/ */
const getEvent = async ( const getEvent = async (
id: string, id: string,
opts: GetEventOpts = {}, opts: GetEventOpts = {},
): Promise<DittoEvent | undefined> => { ): Promise<DittoEvent | undefined> => {
const store = await Storages.db(); const relay = await Storages.db();
const { kind, signal = AbortSignal.timeout(1000) } = opts; const { kind, signal = AbortSignal.timeout(1000) } = opts;
const filter: NostrFilter = { ids: [id], limit: 1 }; const filter: NostrFilter = { ids: [id], limit: 1 };
@ -33,23 +33,23 @@ const getEvent = async (
filter.kinds = [kind]; filter.kinds = [kind];
} }
return await store.query([filter], { limit: 1, signal }) return await relay.query([filter], { limit: 1, signal })
.then((events) => hydrateEvents({ events, store, signal })) .then((events) => hydrateEvents({ events, relay, signal }))
.then(([event]) => event); .then(([event]) => event);
}; };
/** /**
* Get a Nostr `set_medatadata` event for a user's pubkey. * Get a Nostr `set_medatadata` event for a user's pubkey.
* @deprecated Use `store.query` directly. * @deprecated Use `relay.query` directly.
*/ */
async function getAuthor(pubkey: string, opts: GetEventOpts = {}): Promise<NostrEvent | undefined> { async function getAuthor(pubkey: string, opts: GetEventOpts = {}): Promise<NostrEvent | undefined> {
const store = await Storages.db(); const relay = await Storages.db();
const { signal = AbortSignal.timeout(1000) } = opts; const { signal = AbortSignal.timeout(1000) } = opts;
const events = await store.query([{ authors: [pubkey], kinds: [0], limit: 1 }], { limit: 1, signal }); const events = await relay.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({ events: [event], relay, signal });
return event; return event;
} }

View file

@ -13,18 +13,6 @@ function filteredArray<T extends z.ZodTypeAny>(schema: T) {
)); ));
} }
/** https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem */
const decode64Schema = z.string().transform((value, ctx) => {
try {
const binString = atob(value);
const bytes = Uint8Array.from(binString, (m) => m.codePointAt(0)!);
return new TextDecoder().decode(bytes);
} catch (_e) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid base64', fatal: true });
return z.NEVER;
}
});
/** Parses a hashtag, eg `#yolo`. */ /** Parses a hashtag, eg `#yolo`. */
const hashtagSchema = z.string().regex(/^\w{1,30}$/); const hashtagSchema = z.string().regex(/^\w{1,30}$/);
@ -96,7 +84,6 @@ const walletSchema = z.object({
export { export {
booleanParamSchema, booleanParamSchema,
decode64Schema,
fileSchema, fileSchema,
filteredArray, filteredArray,
hashtagSchema, hashtagSchema,

View file

@ -1,14 +1,8 @@
import { NSchema as n } from '@nostrify/nostrify'; import { NSchema as n } from '@nostrify/nostrify';
import { getEventHash, verifyEvent } from 'nostr-tools';
import { z } from 'zod'; import { z } from 'zod';
import { safeUrlSchema, sizesSchema } from '@/schema.ts'; import { safeUrlSchema, sizesSchema } from '@/schema.ts';
/** Nostr event schema that also verifies the event's signature. */
const signedEventSchema = n.event()
.refine((event) => event.id === getEventHash(event), 'Event ID does not match hash')
.refine(verifyEvent, 'Event signature is invalid');
/** Kind 0 standardized fields extended with Ditto custom fields. */ /** Kind 0 standardized fields extended with Ditto custom fields. */
const metadataSchema = n.metadata().and(z.object({ const metadataSchema = n.metadata().and(z.object({
fields: z.tuple([z.string(), z.string()]).array().optional().catch(undefined), fields: z.tuple([z.string(), z.string()]).array().optional().catch(undefined),
@ -68,12 +62,4 @@ const emojiTagSchema = z.tuple([z.literal('emoji'), z.string(), z.string().url()
/** NIP-30 custom emoji tag. */ /** NIP-30 custom emoji tag. */
type EmojiTag = z.infer<typeof emojiTagSchema>; type EmojiTag = z.infer<typeof emojiTagSchema>;
export { export { type EmojiTag, emojiTagSchema, metadataSchema, relayInfoDocSchema, screenshotsSchema, serverMetaSchema };
type EmojiTag,
emojiTagSchema,
metadataSchema,
relayInfoDocSchema,
screenshotsSchema,
serverMetaSchema,
signedEventSchema,
};

View file

@ -5,7 +5,6 @@ import { logi } from '@soapbox/logi';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { wsUrlSchema } from '@/schema.ts'; import { wsUrlSchema } from '@/schema.ts';
import { AdminStore } from '@/storages/AdminStore.ts';
import { DittoPgStore } from '@/storages/DittoPgStore.ts'; import { DittoPgStore } from '@/storages/DittoPgStore.ts';
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';
@ -13,7 +12,6 @@ import { seedZapSplits } from '@/utils/zap-split.ts';
export class Storages { export class Storages {
private static _db: Promise<DittoPgStore> | undefined; private static _db: Promise<DittoPgStore> | undefined;
private static _database: Promise<DittoDB> | undefined; private static _database: Promise<DittoDB> | undefined;
private static _admin: Promise<AdminStore> | undefined;
private static _client: Promise<NPool<NRelay1>> | undefined; private static _client: Promise<NPool<NRelay1>> | undefined;
public static async database(): Promise<DittoDB> { public static async database(): Promise<DittoDB> {
@ -53,14 +51,6 @@ export class Storages {
return this._db; return this._db;
} }
/** Admin user storage. */
public static async admin(): Promise<AdminStore> {
if (!this._admin) {
this._admin = Promise.resolve(new AdminStore(await this.db()));
}
return this._admin;
}
/** Relay pool storage. */ /** Relay pool storage. */
public static async client(): Promise<NPool<NRelay1>> { public static async client(): Promise<NPool<NRelay1>> {
if (!this._client) { if (!this._client) {

View file

@ -1,43 +0,0 @@
import { NostrEvent, NostrFilter, NStore } from '@nostrify/nostrify';
import { Conf } from '@/config.ts';
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { getTagSet } from '@/utils/tags.ts';
/** A store that prevents banned users from being displayed. */
export class AdminStore implements NStore {
constructor(private store: NStore) {}
async event(event: NostrEvent, opts?: { signal?: AbortSignal }): Promise<void> {
return await this.store.event(event, opts);
}
async query(filters: NostrFilter[], opts: { signal?: AbortSignal; limit?: number } = {}): Promise<DittoEvent[]> {
const events = await this.store.query(filters, opts);
const pubkeys = new Set(events.map((event) => event.pubkey));
const users = await this.store.query([{
kinds: [30382],
authors: [await Conf.signer.getPublicKey()],
'#d': [...pubkeys],
limit: pubkeys.size,
}]);
const adminPubkey = await Conf.signer.getPublicKey();
return events.filter((event) => {
const user = users.find(
({ kind, pubkey, tags }) =>
kind === 30382 && pubkey === adminPubkey && tags.find(([name]) => name === 'd')?.[1] === event.pubkey,
);
const n = getTagSet(user?.tags ?? [], 'n');
if (n.has('disabled')) {
return false;
}
return true;
});
}
}

View file

@ -1,43 +0,0 @@
import { NostrEvent, NostrFilter, NStore } from '@nostrify/nostrify';
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { getTagSet } from '@/utils/tags.ts';
export class UserStore implements NStore {
private promise: Promise<DittoEvent[]> | undefined;
constructor(private pubkey: string, private store: NStore) {}
async event(event: NostrEvent, opts?: { signal?: AbortSignal }): Promise<void> {
return await this.store.event(event, opts);
}
/**
* Query events that `pubkey` did not mute
* https://github.com/nostr-protocol/nips/blob/master/51.md#standard-lists
*/
async query(filters: NostrFilter[], opts: { signal?: AbortSignal; limit?: number } = {}): Promise<DittoEvent[]> {
const events = await this.store.query(filters, opts);
const pubkeys = await this.getMutedPubkeys();
return events.filter((event) => {
return event.kind === 0 || !pubkeys.has(event.pubkey);
});
}
private async getMuteList(): Promise<DittoEvent | undefined> {
if (!this.promise) {
this.promise = this.store.query([{ authors: [this.pubkey], kinds: [10000], limit: 1 }]);
}
const [muteList] = await this.promise;
return muteList;
}
private async getMutedPubkeys(): Promise<Set<string>> {
const mutedPubkeysEvent = await this.getMuteList();
if (!mutedPubkeysEvent) {
return new Set();
}
return getTagSet(mutedPubkeysEvent.tags, 'p');
}
}

View file

@ -18,7 +18,7 @@ Deno.test('hydrateEvents(): author --- WITHOUT stats', async () => {
await hydrateEvents({ await hydrateEvents({
events: [event1], events: [event1],
store: relay, relay,
kysely: db.kysely, kysely: db.kysely,
}); });
@ -43,7 +43,7 @@ Deno.test('hydrateEvents(): repost --- WITHOUT stats', async () => {
await hydrateEvents({ await hydrateEvents({
events: [event6], events: [event6],
store: relay, relay,
kysely: db.kysely, kysely: db.kysely,
}); });
@ -72,7 +72,7 @@ Deno.test('hydrateEvents(): quote repost --- WITHOUT stats', async () => {
await hydrateEvents({ await hydrateEvents({
events: [event1quoteRepost], events: [event1quoteRepost],
store: relay, relay,
kysely: db.kysely, kysely: db.kysely,
}); });
@ -102,7 +102,7 @@ Deno.test('hydrateEvents(): repost of quote repost --- WITHOUT stats', async ()
await hydrateEvents({ await hydrateEvents({
events: [event6], events: [event6],
store: relay, relay,
kysely: db.kysely, kysely: db.kysely,
}); });
@ -131,7 +131,7 @@ Deno.test('hydrateEvents(): report pubkey and post // kind 1984 --- WITHOUT stat
await hydrateEvents({ await hydrateEvents({
events: [reportEvent], events: [reportEvent],
store: relay, relay,
kysely: db.kysely, kysely: db.kysely,
}); });
@ -161,7 +161,7 @@ Deno.test('hydrateEvents(): zap sender, zap amount, zapped post // kind 9735 ---
await hydrateEvents({ await hydrateEvents({
events: [zapReceipt], events: [zapReceipt],
store: relay, relay,
kysely: db.kysely, kysely: db.kysely,
}); });

View file

@ -15,14 +15,14 @@ import { Storages } from '@/storages.ts';
interface HydrateOpts { interface HydrateOpts {
events: DittoEvent[]; events: DittoEvent[];
store: NStore; relay: NStore;
signal?: AbortSignal; signal?: AbortSignal;
kysely?: Kysely<DittoTables>; kysely?: Kysely<DittoTables>;
} }
/** Hydrate events using the provided storage. */ /** Hydrate events using the provided storage. */
async function hydrateEvents(opts: HydrateOpts): Promise<DittoEvent[]> { async function hydrateEvents(opts: HydrateOpts): Promise<DittoEvent[]> {
const { events, store, signal, kysely = await Storages.kysely() } = opts; const { events, relay, signal, kysely = await Storages.kysely() } = opts;
if (!events.length) { if (!events.length) {
return events; return events;
@ -30,23 +30,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({ events: cache, relay, signal })) {
cache.push(event); cache.push(event);
} }
for (const event of await gatherQuotes({ events: cache, store, signal })) { for (const event of await gatherQuotes({ events: cache, relay, signal })) {
cache.push(event); cache.push(event);
} }
for (const event of await gatherProfiles({ events: cache, store, signal })) { for (const event of await gatherProfiles({ events: cache, relay, signal })) {
cache.push(event); cache.push(event);
} }
for (const event of await gatherUsers({ events: cache, store, signal })) { for (const event of await gatherUsers({ events: cache, relay, signal })) {
cache.push(event); cache.push(event);
} }
for (const event of await gatherInfo({ events: cache, store, signal })) { for (const event of await gatherInfo({ events: cache, relay, signal })) {
cache.push(event); cache.push(event);
} }
@ -199,7 +199,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({ events, relay, signal }: HydrateOpts): Promise<DittoEvent[]> {
const ids = new Set<string>(); const ids = new Set<string>();
for (const event of events) { for (const event of events) {
@ -234,14 +234,14 @@ function gatherRelatedEvents({ events, store, signal }: HydrateOpts): Promise<Di
} }
} }
return store.query( return relay.query(
[{ ids: [...ids], limit: ids.size }], [{ ids: [...ids], limit: ids.size }],
{ signal }, { signal },
); );
} }
/** Collect quotes from the events. */ /** Collect quotes from the events. */
function gatherQuotes({ events, store, signal }: HydrateOpts): Promise<DittoEvent[]> { function gatherQuotes({ events, relay, signal }: HydrateOpts): Promise<DittoEvent[]> {
const ids = new Set<string>(); const ids = new Set<string>();
for (const event of events) { for (const event of events) {
@ -253,14 +253,14 @@ function gatherQuotes({ events, store, signal }: HydrateOpts): Promise<DittoEven
} }
} }
return store.query( return relay.query(
[{ ids: [...ids], limit: ids.size }], [{ ids: [...ids], limit: ids.size }],
{ signal }, { signal },
); );
} }
/** Collect profiles from the events. */ /** Collect profiles from the events. */
async function gatherProfiles({ events, store, signal }: HydrateOpts): Promise<DittoEvent[]> { async function gatherProfiles({ events, relay, signal }: HydrateOpts): Promise<DittoEvent[]> {
const pubkeys = new Set<string>(); const pubkeys = new Set<string>();
for (const event of events) { for (const event of events) {
@ -300,7 +300,7 @@ async function gatherProfiles({ events, store, signal }: HydrateOpts): Promise<D
} }
} }
const authors = await store.query( const authors = await relay.query(
[{ kinds: [0], authors: [...pubkeys], limit: pubkeys.size }], [{ kinds: [0], authors: [...pubkeys], limit: pubkeys.size }],
{ signal }, { signal },
); );
@ -317,21 +317,21 @@ async function gatherProfiles({ events, store, signal }: HydrateOpts): Promise<D
} }
/** Collect users from the events. */ /** Collect users from the events. */
async function gatherUsers({ events, store, signal }: HydrateOpts): Promise<DittoEvent[]> { async function gatherUsers({ events, relay, signal }: HydrateOpts): Promise<DittoEvent[]> {
const pubkeys = new Set(events.map((event) => event.pubkey)); const pubkeys = new Set(events.map((event) => event.pubkey));
if (!pubkeys.size) { if (!pubkeys.size) {
return Promise.resolve([]); return Promise.resolve([]);
} }
return store.query( return relay.query(
[{ kinds: [30382], authors: [await Conf.signer.getPublicKey()], '#d': [...pubkeys], limit: pubkeys.size }], [{ kinds: [30382], authors: [await Conf.signer.getPublicKey()], '#d': [...pubkeys], limit: pubkeys.size }],
{ signal }, { signal },
); );
} }
/** Collect info events from the events. */ /** Collect info events from the events. */
async function gatherInfo({ events, store, signal }: HydrateOpts): Promise<DittoEvent[]> { async function gatherInfo({ events, relay, signal }: HydrateOpts): Promise<DittoEvent[]> {
const ids = new Set<string>(); const ids = new Set<string>();
for (const event of events) { for (const event of events) {
@ -344,7 +344,7 @@ async function gatherInfo({ events, store, signal }: HydrateOpts): Promise<Ditto
return Promise.resolve([]); return Promise.resolve([]);
} }
return store.query( return relay.query(
[{ kinds: [30383], authors: [await Conf.signer.getPublicKey()], '#d': [...ids], limit: ids.size }], [{ kinds: [30383], authors: [await Conf.signer.getPublicKey()], '#d': [...ids], limit: ids.size }],
{ signal }, { signal },
); );

View file

@ -1,4 +1,3 @@
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 } from '@nostrify/nostrify';
import { logi } from '@soapbox/logi'; import { logi } from '@soapbox/logi';
@ -19,16 +18,16 @@ import { purifyEvent } from '@/utils/purify.ts';
type EventStub = TypeFest.SetOptional<EventTemplate, 'content' | 'created_at' | 'tags'>; type EventStub = TypeFest.SetOptional<EventTemplate, 'content' | 'created_at' | 'tags'>;
/** Publish an event through the pipeline. */ /** Publish an event through the pipeline. */
async function createEvent(t: EventStub, c: Context): Promise<NostrEvent> { async function createEvent(t: EventStub, c: AppContext): Promise<NostrEvent> {
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),
}); });
} }
const event = await signer.signEvent({ const event = await user.signer.signEvent({
content: '', content: '',
created_at: nostrNow(), created_at: nostrNow(),
tags: [], tags: [],
@ -265,17 +264,10 @@ function paginatedList(
return c.json(results, 200, headers); return c.json(results, 200, headers);
} }
/** Rewrite the URL of the request object to use the local domain. */
function localRequest(c: Context): Request {
return Object.create(c.req.raw, {
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'
@ -290,7 +282,6 @@ export {
createAdminEvent, createAdminEvent,
createEvent, createEvent,
type EventStub, type EventStub,
localRequest,
paginated, paginated,
paginatedList, paginatedList,
parseBody, parseBody,

View file

@ -1,7 +1,7 @@
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 { paginationSchema } from '@/schemas/pagination.ts';
import { renderAccount } from '@/views/mastodon/accounts.ts'; import { renderAccount } from '@/views/mastodon/accounts.ts';
import { renderStatus } from '@/views/mastodon/statuses.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts';
import { paginated, paginatedList } from '@/utils/api.ts'; import { paginated, paginatedList } from '@/utils/api.ts';
@ -20,13 +20,12 @@ async function renderEventAccounts(c: AppContext, filters: NostrFilter[], opts?:
} }
const { signal = AbortSignal.timeout(1000), filterFn } = opts ?? {}; const { signal = AbortSignal.timeout(1000), filterFn } = opts ?? {};
const { relay } = c.var;
const store = await Storages.db(); const events = await relay.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({ events, relay, signal }))
.then((events) => filterFn ? events.filter(filterFn) : events); .then((events) => filterFn ? events.filter(filterFn) : events);
const accounts = await Promise.all( const accounts = await Promise.all(
@ -43,14 +42,13 @@ async function renderEventAccounts(c: AppContext, filters: NostrFilter[], opts?:
} }
async function renderAccounts(c: AppContext, pubkeys: string[]) { async function renderAccounts(c: AppContext, pubkeys: string[]) {
const { offset, limit } = c.get('listPagination'); const { offset, limit } = paginationSchema.parse(c.req.query());
const authors = pubkeys.reverse().slice(offset, offset + limit); const authors = pubkeys.reverse().slice(offset, offset + limit);
const store = await Storages.db(); const { relay, signal } = c.var;
const signal = c.req.raw.signal;
const events = await store.query([{ kinds: [0], authors }], { signal }) const events = await relay.query([{ kinds: [0], authors }], { signal })
.then((events) => hydrateEvents({ events, store, signal })); .then((events) => hydrateEvents({ events, relay, signal }));
const accounts = await Promise.all( const accounts = await Promise.all(
authors.map((pubkey) => { authors.map((pubkey) => {
@ -72,11 +70,11 @@ async function renderStatuses(c: AppContext, ids: string[], signal = AbortSignal
return c.json([]); return c.json([]);
} }
const store = await Storages.db(); const { user, relay, 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 relay.query([{ kinds: [1, 20], ids, limit }], { signal })
.then((events) => hydrateEvents({ events, store, signal })); .then((events) => hydrateEvents({ events, relay, signal }));
if (!events.length) { if (!events.length) {
return c.json([]); return c.json([]);
@ -84,7 +82,7 @@ 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 viewerPubkey = await user?.signer.getPublicKey();
const statuses = await Promise.all( const statuses = await Promise.all(
sortedEvents.map((event) => renderStatus(event, { viewerPubkey })), sortedEvents.map((event) => renderStatus(event, { viewerPubkey })),

View file

@ -0,0 +1,18 @@
import { generateSecretKey } from 'nostr-tools';
import { aesDecrypt, aesEncrypt } from './aes.ts';
Deno.bench('aesEncrypt', async (b) => {
const sk = generateSecretKey();
const decrypted = generateSecretKey();
b.start();
await aesEncrypt(sk, decrypted);
});
Deno.bench('aesDecrypt', async (b) => {
const sk = generateSecretKey();
const decrypted = generateSecretKey();
const encrypted = await aesEncrypt(sk, decrypted);
b.start();
await aesDecrypt(sk, encrypted);
});

View file

@ -0,0 +1,15 @@
import { assertEquals } from '@std/assert';
import { encodeHex } from '@std/encoding/hex';
import { generateSecretKey } from 'nostr-tools';
import { aesDecrypt, aesEncrypt } from './aes.ts';
Deno.test('aesDecrypt & aesEncrypt', async () => {
const sk = generateSecretKey();
const data = generateSecretKey();
const encrypted = await aesEncrypt(sk, data);
const decrypted = await aesDecrypt(sk, encrypted);
assertEquals(encodeHex(decrypted), encodeHex(data));
});

View file

@ -0,0 +1,17 @@
/** Encrypt data with AES-GCM and a secret key. */
export async function aesEncrypt(sk: Uint8Array, plaintext: Uint8Array): Promise<Uint8Array> {
const secretKey = await crypto.subtle.importKey('raw', sk, { name: 'AES-GCM' }, false, ['encrypt']);
const iv = crypto.getRandomValues(new Uint8Array(12));
const buffer = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, secretKey, plaintext);
return new Uint8Array([...iv, ...new Uint8Array(buffer)]);
}
/** Decrypt data with AES-GCM and a secret key. */
export async function aesDecrypt(sk: Uint8Array, ciphertext: Uint8Array): Promise<Uint8Array> {
const secretKey = await crypto.subtle.importKey('raw', sk, { name: 'AES-GCM' }, false, ['decrypt']);
const iv = ciphertext.slice(0, 12);
const buffer = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, secretKey, ciphertext.slice(12));
return new Uint8Array(buffer);
}

View file

@ -0,0 +1,11 @@
import { generateToken, getTokenHash } from './token.ts';
Deno.bench('generateToken', async () => {
await generateToken();
});
Deno.bench('getTokenHash', async (b) => {
const { token } = await generateToken();
b.start();
await getTokenHash(token);
});

View file

@ -0,0 +1,18 @@
import { assertEquals } from '@std/assert';
import { decodeHex, encodeHex } from '@std/encoding/hex';
import { generateToken, getTokenHash } from './token.ts';
Deno.test('generateToken', async () => {
const sk = decodeHex('a0968751df8fd42f362213f08751911672f2a037113b392403bbb7dd31b71c95');
const { token, hash } = await generateToken(sk);
assertEquals(token, 'token15ztgw5wl3l2z7d3zz0cgw5v3zee09gphzyanjfqrhwma6vdhrj2sauwknd');
assertEquals(encodeHex(hash), 'ab4c4ead4d1c72a38fffd45b999937b7e3f25f867b19aaf252df858e77b66a8a');
});
Deno.test('getTokenHash', async () => {
const hash = await getTokenHash('token15ztgw5wl3l2z7d3zz0cgw5v3zee09gphzyanjfqrhwma6vdhrj2sauwknd');
assertEquals(encodeHex(hash), 'ab4c4ead4d1c72a38fffd45b999937b7e3f25f867b19aaf252df858e77b66a8a');
});

View file

@ -0,0 +1,30 @@
import { bech32 } from '@scure/base';
import { generateSecretKey } from 'nostr-tools';
/**
* Generate an auth token for the API.
*
* Returns a bech32 encoded API token and the SHA-256 hash of the bytes.
* The token should be presented to the user, but only the hash should be stored in the database.
*/
export async function generateToken(sk = generateSecretKey()): Promise<{ token: `token1${string}`; hash: Uint8Array }> {
const words = bech32.toWords(sk);
const token = bech32.encode('token', words);
const buffer = await crypto.subtle.digest('SHA-256', sk);
const hash = new Uint8Array(buffer);
return { token, hash };
}
/**
* Get the SHA-256 hash of an API token.
* First decodes from bech32 then hashes the bytes.
* Used to identify the user in the database by the hash of their token.
*/
export async function getTokenHash(token: `token1${string}`): Promise<Uint8Array> {
const { bytes: sk } = bech32.decodeToBytes(token);
const buffer = await crypto.subtle.digest('SHA-256', sk);
return new Uint8Array(buffer);
}

View file

@ -0,0 +1,9 @@
{
"name": "@ditto/mastoapi",
"version": "1.1.0",
"exports": {
"./middleware": "./middleware/mod.ts",
"./router": "./router/mod.ts",
"./test": "./test.ts"
}
}

View file

@ -0,0 +1,6 @@
import type { NostrSigner, NRelay } from '@nostrify/nostrify';
export interface User<S extends NostrSigner = NostrSigner, R extends NRelay = NRelay> {
signer: S;
relay: R;
}

View file

@ -0,0 +1,5 @@
export { paginationMiddleware } from './paginationMiddleware.ts';
export { tokenMiddleware } from './tokenMiddleware.ts';
export { userMiddleware } from './userMiddleware.ts';
export type { User } from './User.ts';

View file

@ -0,0 +1,81 @@
import { paginated, paginatedList } from '../pagination/paginate.ts';
import { paginationSchema } from '../pagination/schema.ts';
import type { DittoMiddleware } from '@ditto/mastoapi/router';
import type { NostrEvent } from '@nostrify/nostrify';
interface Pagination {
since?: number;
until?: number;
limit: number;
}
interface ListPagination {
limit: number;
offset: number;
}
type HeaderRecord = Record<string, string | string[]>;
type PaginateFn = (events: NostrEvent[], body: object | unknown[], headers?: HeaderRecord) => Response;
type ListPaginateFn = (params: ListPagination, body: object | unknown[], headers?: HeaderRecord) => Response;
/** Fixes compatibility with Mastodon apps by that don't use `Link` headers. */
// @ts-ignore Types are right.
export function paginationMiddleware(): DittoMiddleware<{ pagination: Pagination; paginate: PaginateFn }>;
export function paginationMiddleware(
type: 'list',
): DittoMiddleware<{ pagination: ListPagination; paginate: ListPaginateFn }>;
export function paginationMiddleware(
type?: string,
): DittoMiddleware<{ pagination?: Pagination | ListPagination; paginate: PaginateFn | ListPaginateFn }> {
return async (c, next) => {
const { relay } = c.var;
const pagination = paginationSchema.parse(c.req.query());
const {
max_id: maxId,
min_id: minId,
since,
until,
} = pagination;
if ((maxId && !until) || (minId && !since)) {
const ids: string[] = [];
if (maxId) ids.push(maxId);
if (minId) ids.push(minId);
if (ids.length) {
const events = await relay.query(
[{ ids, limit: ids.length }],
{ signal: c.req.raw.signal },
);
for (const event of events) {
if (!until && maxId === event.id) pagination.until = event.created_at;
if (!since && minId === event.id) pagination.since = event.created_at;
}
}
}
if (type === 'list') {
c.set('pagination', {
limit: pagination.limit,
offset: pagination.offset,
});
const fn: ListPaginateFn = (params, body, headers) => paginatedList(c, params, body, headers);
c.set('paginate', fn);
} else {
c.set('pagination', {
since: pagination.since,
until: pagination.until,
limit: pagination.limit,
});
const fn: PaginateFn = (events, body, headers) => paginated(c, events, body, headers);
c.set('paginate', fn);
}
await next();
};
}

View file

@ -0,0 +1,136 @@
import { parseAuthRequest } from '@ditto/nip98';
import { HTTPException } from '@hono/hono/http-exception';
import { type NostrSigner, NSecSigner } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools';
import { aesDecrypt } from '../auth/aes.ts';
import { getTokenHash } from '../auth/token.ts';
import { ConnectSigner } from '../signers/ConnectSigner.ts';
import { ReadOnlySigner } from '../signers/ReadOnlySigner.ts';
import { UserStore } from '../storages/UserStore.ts';
import type { DittoEnv, DittoMiddleware } from '@ditto/mastoapi/router';
import type { Context } from '@hono/hono';
import type { User } from './User.ts';
type CredentialsFn = (c: Context) => string | undefined;
export function tokenMiddleware(fn?: CredentialsFn): DittoMiddleware<{ user?: User }> {
return async (c, next) => {
const header = fn ? fn(c) : c.req.header('authorization');
if (header) {
const { relay, conf } = c.var;
const auth = parseAuthorization(header);
const signer = await getSigner(c, auth);
const userPubkey = await signer.getPublicKey();
const adminPubkey = await conf.signer.getPublicKey();
const user: User = {
signer,
relay: new UserStore({ relay, userPubkey, adminPubkey }),
};
c.set('user', user);
}
await next();
};
}
function getSigner(c: Context<DittoEnv>, auth: Authorization): NostrSigner | Promise<NostrSigner> {
switch (auth.realm) {
case 'Bearer': {
if (isToken(auth.token)) {
return getSignerFromToken(c, auth.token);
} else {
return getSignerFromNip19(auth.token);
}
}
case 'Nostr': {
return getSignerFromNip98(c);
}
default: {
throw new HTTPException(400, { message: 'Unsupported Authorization realm.' });
}
}
}
async function getSignerFromToken(c: Context<DittoEnv>, token: `token1${string}`): Promise<NostrSigner> {
const { conf, db, relay } = c.var;
try {
const tokenHash = await getTokenHash(token);
const row = await db.kysely
.selectFrom('auth_tokens')
.select(['pubkey', 'bunker_pubkey', 'nip46_sk_enc', 'nip46_relays'])
.where('token_hash', '=', tokenHash)
.executeTakeFirstOrThrow();
const nep46Seckey = await aesDecrypt(conf.seckey, row.nip46_sk_enc);
return new ConnectSigner({
bunkerPubkey: row.bunker_pubkey,
userPubkey: row.pubkey,
signer: new NSecSigner(nep46Seckey),
relays: row.nip46_relays,
relay,
});
} catch {
throw new HTTPException(401, { message: 'Token is wrong or expired.' });
}
}
function getSignerFromNip19(bech32: string): NostrSigner {
try {
const decoded = nip19.decode(bech32);
switch (decoded.type) {
case 'npub':
return new ReadOnlySigner(decoded.data);
case 'nprofile':
return new ReadOnlySigner(decoded.data.pubkey);
case 'nsec':
return new NSecSigner(decoded.data);
}
} catch {
// fallthrough
}
throw new HTTPException(401, { message: 'Invalid NIP-19 identifier in Authorization header.' });
}
async function getSignerFromNip98(c: Context<DittoEnv>): Promise<NostrSigner> {
const { conf } = c.var;
const req = Object.create(c.req.raw, {
url: { value: conf.local(c.req.url) },
});
const result = await parseAuthRequest(req);
if (result.success) {
return new ReadOnlySigner(result.data.pubkey);
} else {
throw new HTTPException(401, { message: 'Invalid NIP-98 event in Authorization header.' });
}
}
interface Authorization {
realm: string;
token: string;
}
function parseAuthorization(header: string): Authorization {
const [realm, ...parts] = header.split(' ');
return {
realm,
token: parts.join(' '),
};
}
function isToken(value: string): value is `token1${string}` {
return value.startsWith('token1');
}

View file

@ -0,0 +1,74 @@
import { setUser, testApp } from '@ditto/mastoapi/test';
import { assertEquals } from '@std/assert';
import { userMiddleware } from './userMiddleware.ts';
import { ReadOnlySigner } from '../signers/ReadOnlySigner.ts';
Deno.test('no user 401', async () => {
const { app } = testApp();
const response = await app.use(userMiddleware()).request('/');
assertEquals(response.status, 401);
});
Deno.test('unsupported signer 400', async () => {
const { app, relay } = testApp();
const signer = new ReadOnlySigner('0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd');
const response = await app
.use(setUser({ signer, relay }))
.use(userMiddleware({ enc: 'nip44' }))
.use((c, next) => {
c.var.user.signer.nip44.encrypt; // test that the type is set
return next();
})
.request('/');
assertEquals(response.status, 400);
});
Deno.test('with user 200', async () => {
const { app, user } = testApp();
const response = await app
.use(setUser(user))
.use(userMiddleware())
.get('/', (c) => c.text('ok'))
.request('/');
assertEquals(response.status, 200);
});
Deno.test('user and role 403', async () => {
const { app, user } = testApp();
const response = await app
.use(setUser(user))
.use(userMiddleware({ role: 'admin' }))
.request('/');
assertEquals(response.status, 403);
});
Deno.test('admin role 200', async () => {
const { conf, app, user, relay } = testApp();
const event = await conf.signer.signEvent({
kind: 30382,
tags: [
['d', await user.signer.getPublicKey()],
['n', 'admin'],
],
content: '',
created_at: Math.floor(Date.now() / 1000),
});
await relay.event(event);
const response = await app
.use(setUser(user))
.use(userMiddleware({ role: 'admin' }))
.get('/', (c) => c.text('ok'))
.request('/');
assertEquals(response.status, 200);
});

View file

@ -0,0 +1,77 @@
import { buildAuthEventTemplate, validateAuthEvent } from '@ditto/nip98';
import { HTTPException } from '@hono/hono/http-exception';
import type { DittoMiddleware } from '@ditto/mastoapi/router';
import type { NostrEvent, NostrSigner } from '@nostrify/nostrify';
import type { SetRequired } from 'type-fest';
import type { User } from './User.ts';
type Nip44Signer = SetRequired<NostrSigner, 'nip44'>;
interface UserMiddlewareOpts {
enc?: 'nip04' | 'nip44';
role?: string;
verify?: boolean;
}
export function userMiddleware(): DittoMiddleware<{ user: User }>;
// @ts-ignore Types are right.
export function userMiddleware(
opts: UserMiddlewareOpts & { enc: 'nip44' },
): DittoMiddleware<{ user: User<Nip44Signer> }>;
export function userMiddleware(opts: UserMiddlewareOpts): DittoMiddleware<{ user: User }>;
export function userMiddleware(opts: UserMiddlewareOpts = {}): DittoMiddleware<{ user: User }> {
return async (c, next) => {
const { conf, user, relay } = c.var;
const { enc, role, verify } = opts;
if (!user) {
throw new HTTPException(401, { message: 'Authorization required' });
}
if (enc && !user.signer[enc]) {
throw new HTTPException(400, { message: `User does not have a ${enc} signer` });
}
if (role || verify) {
const req = setRequestUrl(c.req.raw, conf.local(c.req.url));
const reqEvent = await buildAuthEventTemplate(req);
const resEvent = await user.signer.signEvent(reqEvent);
const result = await validateAuthEvent(req, resEvent);
if (!result.success) {
throw new HTTPException(401, { message: 'Verification failed' });
}
// Prevent people from accidentally using the wrong account. This has no other security implications.
if (result.data.pubkey !== await user.signer.getPublicKey()) {
throw new HTTPException(401, { message: 'Pubkey mismatch' });
}
if (role) {
const [user] = await relay.query([{
kinds: [30382],
authors: [await conf.signer.getPublicKey()],
'#d': [result.data.pubkey],
limit: 1,
}]);
if (!user || !matchesRole(user, role)) {
throw new HTTPException(403, { message: `Must have ${role} role` });
}
}
}
await next();
};
}
/** Rewrite the URL of the request object. */
function setRequestUrl(req: Request, url: string): Request {
return Object.create(req, { url: { value: url } });
}
/** Check whether the user fulfills the role. */
function matchesRole(user: NostrEvent, role: string): boolean {
return user.tags.some(([tag, value]) => tag === 'n' && value === role);
}

View file

@ -0,0 +1,34 @@
import { genEvent } from '@nostrify/nostrify/test';
import { assertEquals } from '@std/assert';
import { buildLinkHeader, buildListLinkHeader } from './link-header.ts';
Deno.test('buildLinkHeader', () => {
const url = 'https://ditto.test/api/v1/events';
const events = [
genEvent({ created_at: 1 }),
genEvent({ created_at: 2 }),
genEvent({ created_at: 3 }),
];
const link = buildLinkHeader(url, events);
assertEquals(
link?.toString(),
'<https://ditto.test/api/v1/events?until=3>; rel="next", <https://ditto.test/api/v1/events?since=1>; rel="prev"',
);
});
Deno.test('buildListLinkHeader', () => {
const url = 'https://ditto.test/api/v1/tags';
const params = { offset: 0, limit: 3 };
const link = buildListLinkHeader(url, params);
assertEquals(
link?.toString(),
'<https://ditto.test/api/v1/tags?offset=3&limit=3>; rel="next", <https://ditto.test/api/v1/tags?offset=0&limit=3>; rel="prev"',
);
});

View file

@ -0,0 +1,39 @@
import type { NostrEvent } from '@nostrify/nostrify';
/** Build HTTP Link header for Mastodon API pagination. */
export function buildLinkHeader(url: string, events: NostrEvent[]): string | undefined {
if (events.length <= 1) return;
const firstEvent = events[0];
const lastEvent = events[events.length - 1];
const { pathname, search } = new URL(url);
const next = new URL(pathname + search, url);
const prev = new URL(pathname + search, url);
next.searchParams.set('until', String(lastEvent.created_at));
prev.searchParams.set('since', String(firstEvent.created_at));
return `<${next}>; rel="next", <${prev}>; rel="prev"`;
}
/** Build HTTP Link header for paginating Nostr lists. */
export function buildListLinkHeader(
url: string,
params: { offset: number; limit: number },
): string | undefined {
const { pathname, search } = new URL(url);
const { offset, limit } = params;
const next = new URL(pathname + search, url);
const prev = new URL(pathname + search, url);
next.searchParams.set('offset', String(offset + limit));
prev.searchParams.set('offset', String(Math.max(offset - limit, 0)));
next.searchParams.set('limit', String(limit));
prev.searchParams.set('limit', String(limit));
return `<${next}>; rel="next", <${prev}>; rel="prev"`;
}

View file

@ -0,0 +1,43 @@
import { buildLinkHeader, buildListLinkHeader } from './link-header.ts';
import type { Context } from '@hono/hono';
import type { NostrEvent } from '@nostrify/nostrify';
type HeaderRecord = Record<string, string | string[]>;
/** Return results with pagination headers. Assumes chronological sorting of events. */
export function paginated(
c: Context,
events: NostrEvent[],
body: object | unknown[],
headers: HeaderRecord = {},
): Response {
const link = buildLinkHeader(c.req.url, events);
if (link) {
headers.link = link;
}
// Filter out undefined entities.
const results = Array.isArray(body) ? body.filter(Boolean) : body;
return c.json(results, 200, headers);
}
/** paginate a list of tags. */
export function paginatedList(
c: Context,
params: { offset: number; limit: number },
body: object | unknown[],
headers: HeaderRecord = {},
): Response {
const link = buildListLinkHeader(c.req.url, params);
const hasMore = Array.isArray(body) ? body.length > 0 : true;
if (link) {
headers.link = hasMore ? link : link.split(', ').find((link) => link.endsWith('; rel="prev"'))!;
}
// Filter out undefined entities.
const results = Array.isArray(body) ? body.filter(Boolean) : body;
return c.json(results, 200, headers);
}

View file

@ -0,0 +1,23 @@
import { assertEquals } from '@std/assert';
import { paginationSchema } from './schema.ts';
Deno.test('paginationSchema', () => {
const pagination = paginationSchema.parse({
limit: '10',
offset: '20',
max_id: '1',
min_id: '2',
since: '3',
until: '4',
});
assertEquals(pagination, {
limit: 10,
offset: 20,
max_id: '1',
min_id: '2',
since: 3,
until: 4,
});
});

View file

@ -0,0 +1,14 @@
import { z } from 'zod';
/** Schema to parse pagination query params. */
export const paginationSchema = z.object({
max_id: z.string().transform((val) => {
if (!val.includes('-')) return val;
return val.split('-')[1];
}).optional().catch(undefined),
min_id: z.string().optional().catch(undefined),
since: z.coerce.number().nonnegative().optional().catch(undefined),
until: z.coerce.number().nonnegative().optional().catch(undefined),
limit: z.coerce.number().catch(20).transform((value) => Math.min(Math.max(value, 0), 40)),
offset: z.coerce.number().nonnegative().catch(0),
});

View file

@ -0,0 +1,23 @@
import { DittoConf } from '@ditto/conf';
import { DittoPolyPg } from '@ditto/db';
import { Hono } from '@hono/hono';
import { MockRelay } from '@nostrify/nostrify/test';
import { DittoApp } from './DittoApp.ts';
import { DittoRoute } from './DittoRoute.ts';
Deno.test('DittoApp', async () => {
await using db = DittoPolyPg.create('memory://');
const conf = new DittoConf(new Map());
const relay = new MockRelay();
const app = new DittoApp({ conf, db, relay });
const hono = new Hono();
const route = new DittoRoute();
app.route('/', route);
// @ts-expect-error Passing a non-DittoRoute to route.
app.route('/', hono);
});

View file

@ -0,0 +1,21 @@
import { Hono } from '@hono/hono';
import type { HonoOptions } from '@hono/hono/hono-base';
import type { DittoEnv } from './DittoEnv.ts';
export class DittoApp extends Hono<DittoEnv> {
// @ts-ignore Require a DittoRoute for type safety.
declare route: (path: string, app: Hono<DittoEnv>) => Hono<DittoEnv>;
constructor(vars: Omit<DittoEnv['Variables'], 'signal'>, opts: HonoOptions<DittoEnv> = {}) {
super(opts);
this.use((c, next) => {
c.set('db', vars.db);
c.set('conf', vars.conf);
c.set('relay', vars.relay);
c.set('signal', c.req.raw.signal);
return next();
});
}
}

View file

@ -0,0 +1,20 @@
import type { DittoConf } from '@ditto/conf';
import type { DittoDB } from '@ditto/db';
import type { Env } from '@hono/hono';
import type { NRelay } from '@nostrify/nostrify';
export interface DittoEnv extends Env {
Variables: {
/** Ditto site configuration. */
conf: DittoConf;
/** Relay store. */
relay: NRelay;
/**
* Database object.
* @deprecated Store data as Nostr events instead.
*/
db: DittoDB;
/** Abort signal for the request. */
signal: AbortSignal;
};
}

View file

@ -0,0 +1,5 @@
import type { MiddlewareHandler } from '@hono/hono';
import type { DittoEnv } from './DittoEnv.ts';
// deno-lint-ignore ban-types
export type DittoMiddleware<T extends {}> = MiddlewareHandler<DittoEnv & { Variables: T }>;

View file

@ -0,0 +1,12 @@
import { assertEquals } from '@std/assert';
import { DittoRoute } from './DittoRoute.ts';
Deno.test('DittoRoute', async () => {
const route = new DittoRoute();
const response = await route.request('/');
const body = await response.json();
assertEquals(response.status, 500);
assertEquals(body, { error: 'Missing required variable: db' });
});

View file

@ -0,0 +1,53 @@
import { type ErrorHandler, Hono } from '@hono/hono';
import { HTTPException } from '@hono/hono/http-exception';
import type { HonoOptions } from '@hono/hono/hono-base';
import type { DittoEnv } from './DittoEnv.ts';
/**
* Ditto base route class.
* Ensures that required variables are set for type safety.
*/
export class DittoRoute extends Hono<DittoEnv> {
constructor(opts: HonoOptions<DittoEnv> = {}) {
super(opts);
this.use((c, next) => {
this.assertVars(c.var);
return next();
});
this.onError(this._errorHandler);
}
private assertVars(vars: Partial<DittoEnv['Variables']>): DittoEnv['Variables'] {
if (!vars.db) this.throwMissingVar('db');
if (!vars.conf) this.throwMissingVar('conf');
if (!vars.relay) this.throwMissingVar('relay');
if (!vars.signal) this.throwMissingVar('signal');
return {
...vars,
db: vars.db,
conf: vars.conf,
relay: vars.relay,
signal: vars.signal,
};
}
private throwMissingVar(name: string): never {
throw new HTTPException(500, { message: `Missing required variable: ${name}` });
}
private _errorHandler: ErrorHandler = (error, c) => {
if (error instanceof HTTPException) {
if (error.res) {
return error.res;
} else {
return c.json({ error: error.message }, error.status);
}
}
return c.json({ error: 'Something went wrong' }, 500);
};
}

View file

@ -0,0 +1,5 @@
export { DittoApp } from './DittoApp.ts';
export { DittoRoute } from './DittoRoute.ts';
export type { DittoEnv } from './DittoEnv.ts';
export type { DittoMiddleware } from './DittoMiddleware.ts';

View file

@ -0,0 +1,124 @@
// deno-lint-ignore-file require-await
import { HTTPException } from '@hono/hono/http-exception';
import { NConnectSigner, type NostrEvent, type NostrSigner, type NRelay } from '@nostrify/nostrify';
interface ConnectSignerOpts {
relay: NRelay;
bunkerPubkey: string;
userPubkey: string;
signer: NostrSigner;
relays?: string[];
}
/**
* NIP-46 signer.
*
* Simple extension of nostrify's `NConnectSigner`, with our options to keep it DRY.
*/
export class ConnectSigner implements NostrSigner {
private signer: Promise<NConnectSigner>;
constructor(private opts: ConnectSignerOpts) {
this.signer = this.init(opts.signer);
}
async init(signer: NostrSigner): Promise<NConnectSigner> {
return new NConnectSigner({
encryption: 'nip44',
pubkey: this.opts.bunkerPubkey,
relay: this.opts.relay,
signer,
timeout: 60_000,
});
}
async signEvent(event: Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>): Promise<NostrEvent> {
const signer = await this.signer;
try {
return await signer.signEvent(event);
} catch (e) {
if (e instanceof Error && e.name === 'AbortError') {
throw new HTTPException(408, { message: 'The event was not signed quickly enough' });
} else {
throw e;
}
}
}
readonly nip04 = {
encrypt: async (pubkey: string, plaintext: string): Promise<string> => {
const signer = await this.signer;
try {
return await signer.nip04.encrypt(pubkey, plaintext);
} catch (e) {
if (e instanceof Error && e.name === 'AbortError') {
throw new HTTPException(408, {
message: 'Text was not encrypted quickly enough',
});
} else {
throw e;
}
}
},
decrypt: async (pubkey: string, ciphertext: string): Promise<string> => {
const signer = await this.signer;
try {
return await signer.nip04.decrypt(pubkey, ciphertext);
} catch (e) {
if (e instanceof Error && e.name === 'AbortError') {
throw new HTTPException(408, {
message: 'Text was not decrypted quickly enough',
});
} else {
throw e;
}
}
},
};
readonly nip44 = {
encrypt: async (pubkey: string, plaintext: string): Promise<string> => {
const signer = await this.signer;
try {
return await signer.nip44.encrypt(pubkey, plaintext);
} catch (e) {
if (e instanceof Error && e.name === 'AbortError') {
throw new HTTPException(408, {
message: 'Text was not encrypted quickly enough',
});
} else {
throw e;
}
}
},
decrypt: async (pubkey: string, ciphertext: string): Promise<string> => {
const signer = await this.signer;
try {
return await signer.nip44.decrypt(pubkey, ciphertext);
} catch (e) {
if (e instanceof Error && e.name === 'AbortError') {
throw new HTTPException(408, {
message: 'Text was not decrypted quickly enough',
});
} else {
throw e;
}
}
},
};
// Prevent unnecessary NIP-46 round-trips.
async getPublicKey(): Promise<string> {
return this.opts.userPubkey;
}
/** Get the user's relays if they passed in an `nprofile` auth token. */
async getRelays(): Promise<Record<string, { read: boolean; write: boolean }>> {
return this.opts.relays?.reduce<Record<string, { read: boolean; write: boolean }>>((acc, relay) => {
acc[relay] = { read: true, write: true };
return acc;
}, {}) ?? {};
}
}

View file

@ -0,0 +1,18 @@
// deno-lint-ignore-file require-await
import { HTTPException } from '@hono/hono/http-exception';
import type { NostrEvent, NostrSigner } from '@nostrify/nostrify';
export class ReadOnlySigner implements NostrSigner {
constructor(private pubkey: string) {}
async signEvent(): Promise<NostrEvent> {
throw new HTTPException(401, {
message: 'Log in with Nostr Connect to sign events',
});
}
async getPublicKey(): Promise<string> {
return this.pubkey;
}
}

View file

@ -1,7 +1,7 @@
import { MockRelay } from '@nostrify/nostrify/test'; import { MockRelay } from '@nostrify/nostrify/test';
import { assertEquals } from '@std/assert'; import { assertEquals } from '@std/assert';
import { UserStore } from '@/storages/UserStore.ts';
import { UserStore } from './UserStore.ts';
import userBlack from '~/fixtures/events/kind-0-black.json' with { type: 'json' }; import userBlack from '~/fixtures/events/kind-0-black.json' with { type: 'json' };
import userMe from '~/fixtures/events/event-0-makes-repost-with-quote-repost.json' with { type: 'json' }; import userMe from '~/fixtures/events/event-0-makes-repost-with-quote-repost.json' with { type: 'json' };
@ -14,9 +14,8 @@ Deno.test('query events of users that are not muted', async () => {
const blockEventCopy = structuredClone(blockEvent); const blockEventCopy = structuredClone(blockEvent);
const event1authorUserMeCopy = structuredClone(event1authorUserMe); const event1authorUserMeCopy = structuredClone(event1authorUserMe);
const db = new MockRelay(); const relay = new MockRelay();
const store = new UserStore({ relay, userPubkey: userBlackCopy.pubkey });
const store = new UserStore(userBlackCopy.pubkey, db);
await store.event(blockEventCopy); await store.event(blockEventCopy);
await store.event(userBlackCopy); await store.event(userBlackCopy);
@ -30,9 +29,8 @@ Deno.test('user never muted anyone', async () => {
const userBlackCopy = structuredClone(userBlack); const userBlackCopy = structuredClone(userBlack);
const userMeCopy = structuredClone(userMe); const userMeCopy = structuredClone(userMe);
const db = new MockRelay(); const relay = new MockRelay();
const store = new UserStore({ relay, userPubkey: userBlackCopy.pubkey });
const store = new UserStore(userBlackCopy.pubkey, db);
await store.event(userBlackCopy); await store.event(userBlackCopy);
await store.event(userMeCopy); await store.event(userMeCopy);

View file

@ -0,0 +1,73 @@
import type {
NostrEvent,
NostrFilter,
NostrRelayCLOSED,
NostrRelayEOSE,
NostrRelayEVENT,
NRelay,
} from '@nostrify/nostrify';
interface UserStoreOpts {
relay: NRelay;
userPubkey: string;
adminPubkey?: string;
}
export class UserStore implements NRelay {
constructor(private opts: UserStoreOpts) {}
req(
filters: NostrFilter[],
opts?: { signal?: AbortSignal },
): AsyncIterable<NostrRelayEVENT | NostrRelayEOSE | NostrRelayCLOSED> {
// TODO: support req maybe? It would be inefficient.
return this.opts.relay.req(filters, opts);
}
async event(event: NostrEvent, opts?: { signal?: AbortSignal }): Promise<void> {
return await this.opts.relay.event(event, opts);
}
/**
* Query events that `pubkey` did not mute
* https://github.com/nostr-protocol/nips/blob/master/51.md#standard-lists
*/
async query(filters: NostrFilter[], opts: { signal?: AbortSignal; limit?: number } = {}): Promise<NostrEvent[]> {
const { relay, userPubkey, adminPubkey } = this.opts;
const mutes = new Set<string>();
const [muteList] = await this.opts.relay.query([{ authors: [userPubkey], kinds: [10000], limit: 1 }]);
for (const [name, value] of muteList?.tags ?? []) {
if (name === 'p') {
mutes.add(value);
}
}
const events = await relay.query(filters, opts);
const users = adminPubkey
? await relay.query([{
kinds: [30382],
authors: [adminPubkey],
'#d': [...events.map(({ pubkey }) => pubkey)],
}])
: [];
return events.filter((event) => {
const user = users.find((user) => user.tags.find(([name]) => name === 'd')?.[1] === event.pubkey);
for (const [name, value] of user?.tags ?? []) {
if (name === 'n' && value === 'disabled') {
return false;
}
}
return event.kind === 0 || !mutes.has(event.pubkey);
});
}
close(): Promise<void> {
return this.opts.relay.close();
}
}

41
packages/mastoapi/test.ts Normal file
View file

@ -0,0 +1,41 @@
import { DittoConf } from '@ditto/conf';
import { type DittoDB, DummyDB } from '@ditto/db';
import { DittoApp, type DittoMiddleware } from '@ditto/mastoapi/router';
import { type NostrSigner, type NRelay, NSecSigner } from '@nostrify/nostrify';
import { MockRelay } from '@nostrify/nostrify/test';
import { generateSecretKey, nip19 } from 'nostr-tools';
import type { User } from '@ditto/mastoapi/middleware';
export function testApp(): {
app: DittoApp;
relay: NRelay;
conf: DittoConf;
db: DittoDB;
user: {
signer: NostrSigner;
relay: NRelay;
};
} {
const db = new DummyDB();
const nsec = nip19.nsecEncode(generateSecretKey());
const conf = new DittoConf(new Map([['DITTO_NSEC', nsec]]));
const relay = new MockRelay();
const app = new DittoApp({ conf, relay, db });
const user = {
signer: new NSecSigner(generateSecretKey()),
relay,
};
return { app, relay, conf, db, user };
}
export function setUser<S extends NostrSigner>(user: User<S>): DittoMiddleware<{ user: User<S> }> {
return async (c, next) => {
c.set('user', user);
await next();
};
}

7
packages/nip98/deno.json Normal file
View file

@ -0,0 +1,7 @@
{
"name": "@ditto/nip98",
"version": "1.0.0",
"exports": {
".": "./nip98.ts"
}
}

View file

@ -1,11 +1,10 @@
import { NostrEvent, NSchema as n } from '@nostrify/nostrify'; import { type NostrEvent, NSchema as n } from '@nostrify/nostrify';
import { encodeHex } from '@std/encoding/hex'; import { encodeHex } from '@std/encoding/hex';
import { EventTemplate, nip13 } from 'nostr-tools'; import { type EventTemplate, nip13 } from 'nostr-tools';
import { decode64Schema } from '@/schema.ts'; import { decode64Schema, signedEventSchema } from './schema.ts';
import { signedEventSchema } from '@/schemas/nostr.ts';
import { eventAge, findTag, nostrNow } from '@/utils.ts'; import type { z } from 'zod';
import { Time } from '@/utils/time.ts';
/** Decode a Nostr event from a base64 encoded string. */ /** Decode a Nostr event from a base64 encoded string. */
const decode64EventSchema = decode64Schema.pipe(n.json()).pipe(signedEventSchema); const decode64EventSchema = decode64Schema.pipe(n.json()).pipe(signedEventSchema);
@ -21,7 +20,10 @@ interface ParseAuthRequestOpts {
/** Parse the auth event from a Request, returning a zod SafeParse type. */ /** Parse the auth event from a Request, returning a zod SafeParse type. */
// deno-lint-ignore require-await // deno-lint-ignore require-await
async function parseAuthRequest(req: Request, opts: ParseAuthRequestOpts = {}) { async function parseAuthRequest(
req: Request,
opts: ParseAuthRequestOpts = {},
): Promise<z.SafeParseReturnType<NostrEvent, NostrEvent> | z.SafeParseError<string>> {
const header = req.headers.get('authorization'); const header = req.headers.get('authorization');
const base64 = header?.match(/^Nostr (.+)$/)?.[1]; const base64 = header?.match(/^Nostr (.+)$/)?.[1];
const result = decode64EventSchema.safeParse(base64); const result = decode64EventSchema.safeParse(base64);
@ -31,8 +33,12 @@ async function parseAuthRequest(req: Request, opts: ParseAuthRequestOpts = {}) {
} }
/** Compare the auth event with the request, returning a zod SafeParse type. */ /** Compare the auth event with the request, returning a zod SafeParse type. */
function validateAuthEvent(req: Request, event: NostrEvent, opts: ParseAuthRequestOpts = {}) { function validateAuthEvent(
const { maxAge = Time.minutes(1), validatePayload = true, pow = 0 } = opts; req: Request,
event: NostrEvent,
opts: ParseAuthRequestOpts = {},
): Promise<z.SafeParseReturnType<NostrEvent, NostrEvent>> {
const { maxAge = 60_000, validatePayload = true, pow = 0 } = opts;
const schema = signedEventSchema const schema = signedEventSchema
.refine((event) => event.kind === 27235, 'Event must be kind 27235') .refine((event) => event.kind === 27235, 'Event must be kind 27235')
@ -87,4 +93,19 @@ function tagValue(event: NostrEvent, tagName: string): string | undefined {
return findTag(event.tags, tagName)?.[1]; return findTag(event.tags, tagName)?.[1];
} }
/** Get the current time in Nostr format. */
const nostrNow = (): number => Math.floor(Date.now() / 1000);
/** Convenience function to convert Nostr dates into native Date objects. */
const nostrDate = (seconds: number): Date => new Date(seconds * 1000);
/** Return the event's age in milliseconds. */
function eventAge(event: NostrEvent): number {
return Date.now() - nostrDate(event.created_at).getTime();
}
function findTag(tags: string[][], name: string): string[] | undefined {
return tags.find((tag) => tag[0] === name);
}
export { buildAuthEventTemplate, parseAuthRequest, type ParseAuthRequestOpts, validateAuthEvent }; export { buildAuthEventTemplate, parseAuthRequest, type ParseAuthRequestOpts, validateAuthEvent };

20
packages/nip98/schema.ts Normal file
View file

@ -0,0 +1,20 @@
import { NSchema as n } from '@nostrify/nostrify';
import { getEventHash, verifyEvent } from 'nostr-tools';
import z from 'zod';
/** https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem */
export const decode64Schema = z.string().transform((value, ctx) => {
try {
const binString = atob(value);
const bytes = Uint8Array.from(binString, (m) => m.codePointAt(0)!);
return new TextDecoder().decode(bytes);
} catch (_e) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid base64', fatal: true });
return z.NEVER;
}
});
/** Nostr event schema that also verifies the event's signature. */
export const signedEventSchema = n.event()
.refine((event) => event.id === getEventHash(event), 'Event ID does not match hash')
.refine(verifyEvent, 'Event signature is invalid');

View file

@ -25,7 +25,7 @@ Deno.test('block event: muted user cannot post', async () => {
const ok = await policy.call(event1authorUserMeCopy); const ok = await policy.call(event1authorUserMeCopy);
assertEquals(ok, ['OK', event1authorUserMeCopy.id, false, 'blocked: Your account has been deactivated.']); assertEquals(ok, ['OK', event1authorUserMeCopy.id, false, 'blocked: account blocked']);
}); });
Deno.test('allow event: user is NOT muted because there is no muted event', async () => { Deno.test('allow event: user is NOT muted because there is no muted event', async () => {

View file

@ -15,7 +15,7 @@ export class MuteListPolicy implements NPolicy {
} }
if (pubkeys.has(event.pubkey)) { if (pubkeys.has(event.pubkey)) {
return ['OK', event.id, false, 'blocked: Your account has been deactivated.']; return ['OK', event.id, false, 'blocked: account blocked'];
} }
return ['OK', event.id, true, '']; return ['OK', event.id, true, ''];