From 5c0a35077642554e348a5ca16d9734a236b67e63 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 20 Feb 2025 11:19:50 -0600 Subject: [PATCH 01/23] Add @ditto/router package --- deno.json | 1 + packages/router/DittoApp.test.ts | 23 +++++++++++++ packages/router/DittoApp.ts | 21 ++++++++++++ packages/router/DittoEnv.ts | 20 +++++++++++ packages/router/DittoMiddleware.ts | 5 +++ packages/router/DittoRoute.test.ts | 12 +++++++ packages/router/DittoRoute.ts | 53 ++++++++++++++++++++++++++++++ packages/router/deno.json | 7 ++++ packages/router/mod.ts | 4 +++ 9 files changed, 146 insertions(+) create mode 100644 packages/router/DittoApp.test.ts create mode 100644 packages/router/DittoApp.ts create mode 100644 packages/router/DittoEnv.ts create mode 100644 packages/router/DittoMiddleware.ts create mode 100644 packages/router/DittoRoute.test.ts create mode 100644 packages/router/DittoRoute.ts create mode 100644 packages/router/deno.json create mode 100644 packages/router/mod.ts diff --git a/deno.json b/deno.json index a3f06bd5..4a34db67 100644 --- a/deno.json +++ b/deno.json @@ -9,6 +9,7 @@ "./packages/metrics", "./packages/policies", "./packages/ratelimiter", + "./packages/router", "./packages/translators", "./packages/uploaders" ], diff --git a/packages/router/DittoApp.test.ts b/packages/router/DittoApp.test.ts new file mode 100644 index 00000000..83da5bca --- /dev/null +++ b/packages/router/DittoApp.test.ts @@ -0,0 +1,23 @@ +import { DittoConf } from '@ditto/conf'; +import { DittoDB } 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 = DittoDB.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); +}); diff --git a/packages/router/DittoApp.ts b/packages/router/DittoApp.ts new file mode 100644 index 00000000..3309f65d --- /dev/null +++ b/packages/router/DittoApp.ts @@ -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 { + // @ts-ignore Require a DittoRoute for type safety. + declare route: (path: string, app: Hono) => Hono; + + constructor(vars: Omit, opts: HonoOptions = {}) { + 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(); + }); + } +} diff --git a/packages/router/DittoEnv.ts b/packages/router/DittoEnv.ts new file mode 100644 index 00000000..761bc3f8 --- /dev/null +++ b/packages/router/DittoEnv.ts @@ -0,0 +1,20 @@ +import type { DittoConf } from '@ditto/conf'; +import type { DittoDatabase } 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: DittoDatabase; + /** Abort signal for the request. */ + signal: AbortSignal; + }; +} diff --git a/packages/router/DittoMiddleware.ts b/packages/router/DittoMiddleware.ts new file mode 100644 index 00000000..1483ca90 --- /dev/null +++ b/packages/router/DittoMiddleware.ts @@ -0,0 +1,5 @@ +import type { MiddlewareHandler } from '@hono/hono'; +import type { DittoEnv } from './DittoEnv.ts'; + +// deno-lint-ignore ban-types +export type DittoMiddleware = MiddlewareHandler; diff --git a/packages/router/DittoRoute.test.ts b/packages/router/DittoRoute.test.ts new file mode 100644 index 00000000..737019c4 --- /dev/null +++ b/packages/router/DittoRoute.test.ts @@ -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' }); +}); diff --git a/packages/router/DittoRoute.ts b/packages/router/DittoRoute.ts new file mode 100644 index 00000000..369fb858 --- /dev/null +++ b/packages/router/DittoRoute.ts @@ -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 { + constructor(opts: HonoOptions = {}) { + super(opts); + + this.use((c, next) => { + this.assertVars(c.var); + return next(); + }); + + this.onError(this._errorHandler); + } + + private assertVars(vars: Partial): 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); + }; +} diff --git a/packages/router/deno.json b/packages/router/deno.json new file mode 100644 index 00000000..8321baaf --- /dev/null +++ b/packages/router/deno.json @@ -0,0 +1,7 @@ +{ + "name": "@ditto/router", + "version": "1.1.0", + "exports": { + ".": "./mod.ts" + } +} diff --git a/packages/router/mod.ts b/packages/router/mod.ts new file mode 100644 index 00000000..8e9d1d46 --- /dev/null +++ b/packages/router/mod.ts @@ -0,0 +1,4 @@ +export { DittoApp } from './DittoApp.ts'; +export { DittoRoute } from './DittoRoute.ts'; + +export type { DittoEnv } from './DittoEnv.ts'; From 67aec57990f52d121a240e6ca144205208265bfc Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 20 Feb 2025 14:29:22 -0600 Subject: [PATCH 02/23] Rename @ditto/api to @ditto/mastoapi, start using the new router and middleware in app --- deno.json | 2 +- packages/api/middleware/confMw.test.ts | 19 --- packages/api/middleware/confMw.ts | 15 -- .../api/middleware/confRequiredMw.test.ts | 22 --- packages/api/middleware/confRequiredMw.ts | 15 -- packages/api/middleware/mod.ts | 2 - packages/ditto/app.ts | 55 +++--- packages/ditto/controllers/api/accounts.ts | 104 ++++++------ packages/ditto/controllers/api/admin.ts | 51 +++--- packages/ditto/controllers/api/bookmarks.ts | 9 +- packages/ditto/controllers/api/captcha.ts | 4 +- packages/ditto/controllers/api/cashu.test.ts | 8 +- packages/ditto/controllers/api/cashu.ts | 135 ++++++++------- packages/ditto/controllers/api/ditto.ts | 59 +++---- packages/ditto/controllers/api/markers.ts | 8 +- packages/ditto/controllers/api/media.ts | 5 +- packages/ditto/controllers/api/mutes.ts | 9 +- .../ditto/controllers/api/notifications.ts | 25 +-- packages/ditto/controllers/api/pleroma.ts | 16 +- packages/ditto/controllers/api/push.ts | 4 +- packages/ditto/controllers/api/reactions.ts | 29 ++-- packages/ditto/controllers/api/reports.ts | 56 +++--- packages/ditto/controllers/api/search.ts | 24 +-- packages/ditto/controllers/api/statuses.ts | 159 ++++++++---------- packages/ditto/controllers/api/streaming.ts | 7 +- packages/ditto/controllers/api/suggestions.ts | 44 +++-- packages/ditto/controllers/api/timelines.ts | 25 ++- packages/ditto/controllers/api/translate.ts | 5 +- packages/ditto/controllers/api/trends.ts | 17 +- packages/ditto/controllers/frontend.ts | 12 +- packages/ditto/controllers/manifest.ts | 5 +- .../ditto/controllers/nostr/relay-info.ts | 7 +- packages/ditto/controllers/nostr/relay.ts | 15 +- .../ditto/controllers/well-known/nostr.ts | 6 +- packages/ditto/middleware/auth98Middleware.ts | 21 ++- .../ditto/middleware/paginationMiddleware.ts | 49 ------ packages/ditto/middleware/requireSigner.ts | 29 ---- packages/ditto/middleware/signerMiddleware.ts | 75 --------- packages/ditto/middleware/storeMiddleware.ts | 28 --- .../ditto/middleware/uploaderMiddleware.ts | 3 +- packages/ditto/pipeline.ts | 2 +- packages/ditto/queries.ts | 16 +- packages/ditto/storages/hydrate.test.ts | 12 +- packages/ditto/storages/hydrate.ts | 34 ++-- packages/ditto/utils/api.ts | 2 +- packages/ditto/views.ts | 28 ++- packages/mastoapi/auth/aes.bench.ts | 18 ++ packages/mastoapi/auth/aes.test.ts | 15 ++ packages/mastoapi/auth/aes.ts | 17 ++ packages/mastoapi/auth/token.bench.ts | 11 ++ packages/mastoapi/auth/token.test.ts | 18 ++ packages/mastoapi/auth/token.ts | 30 ++++ packages/{api => mastoapi}/deno.json | 2 +- packages/mastoapi/middleware/mod.ts | 2 + .../middleware/paginationMiddleware.ts | 81 +++++++++ .../mastoapi/middleware/userMiddleware.ts | 128 ++++++++++++++ .../mastoapi/pagination/link-header.test.ts | 34 ++++ packages/mastoapi/pagination/link-header.ts | 39 +++++ packages/mastoapi/pagination/paginate.test.ts | 0 packages/mastoapi/pagination/paginate.ts | 43 +++++ packages/mastoapi/pagination/schema.test.ts | 23 +++ packages/mastoapi/pagination/schema.ts | 14 ++ packages/mastoapi/signers/ConnectSigner.ts | 124 ++++++++++++++ packages/mastoapi/signers/ReadOnlySigner.ts | 18 ++ packages/router/DittoApp.test.ts | 4 +- packages/router/DittoEnv.ts | 4 +- packages/router/mod.ts | 1 + 67 files changed, 1134 insertions(+), 769 deletions(-) delete mode 100644 packages/api/middleware/confMw.test.ts delete mode 100644 packages/api/middleware/confMw.ts delete mode 100644 packages/api/middleware/confRequiredMw.test.ts delete mode 100644 packages/api/middleware/confRequiredMw.ts delete mode 100644 packages/api/middleware/mod.ts delete mode 100644 packages/ditto/middleware/paginationMiddleware.ts delete mode 100644 packages/ditto/middleware/requireSigner.ts delete mode 100644 packages/ditto/middleware/signerMiddleware.ts delete mode 100644 packages/ditto/middleware/storeMiddleware.ts create mode 100644 packages/mastoapi/auth/aes.bench.ts create mode 100644 packages/mastoapi/auth/aes.test.ts create mode 100644 packages/mastoapi/auth/aes.ts create mode 100644 packages/mastoapi/auth/token.bench.ts create mode 100644 packages/mastoapi/auth/token.test.ts create mode 100644 packages/mastoapi/auth/token.ts rename packages/{api => mastoapi}/deno.json (75%) create mode 100644 packages/mastoapi/middleware/mod.ts create mode 100644 packages/mastoapi/middleware/paginationMiddleware.ts create mode 100644 packages/mastoapi/middleware/userMiddleware.ts create mode 100644 packages/mastoapi/pagination/link-header.test.ts create mode 100644 packages/mastoapi/pagination/link-header.ts create mode 100644 packages/mastoapi/pagination/paginate.test.ts create mode 100644 packages/mastoapi/pagination/paginate.ts create mode 100644 packages/mastoapi/pagination/schema.test.ts create mode 100644 packages/mastoapi/pagination/schema.ts create mode 100644 packages/mastoapi/signers/ConnectSigner.ts create mode 100644 packages/mastoapi/signers/ReadOnlySigner.ts diff --git a/deno.json b/deno.json index 4a34db67..4466b7b3 100644 --- a/deno.json +++ b/deno.json @@ -1,11 +1,11 @@ { "version": "1.1.0", "workspace": [ - "./packages/api", "./packages/conf", "./packages/db", "./packages/ditto", "./packages/lang", + "./packages/mastoapi", "./packages/metrics", "./packages/policies", "./packages/ratelimiter", diff --git a/packages/api/middleware/confMw.test.ts b/packages/api/middleware/confMw.test.ts deleted file mode 100644 index 350a585f..00000000 --- a/packages/api/middleware/confMw.test.ts +++ /dev/null @@ -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'); -}); diff --git a/packages/api/middleware/confMw.ts b/packages/api/middleware/confMw.ts deleted file mode 100644 index ebfdfe4b..00000000 --- a/packages/api/middleware/confMw.ts +++ /dev/null @@ -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(); - }; -} diff --git a/packages/api/middleware/confRequiredMw.test.ts b/packages/api/middleware/confRequiredMw.test.ts deleted file mode 100644 index 9dfcc096..00000000 --- a/packages/api/middleware/confRequiredMw.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/api/middleware/confRequiredMw.ts b/packages/api/middleware/confRequiredMw.ts deleted file mode 100644 index dc4d661d..00000000 --- a/packages/api/middleware/confRequiredMw.ts +++ /dev/null @@ -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(); -}; diff --git a/packages/api/middleware/mod.ts b/packages/api/middleware/mod.ts deleted file mode 100644 index 54a1b35c..00000000 --- a/packages/api/middleware/mod.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { confMw } from './confMw.ts'; -export { confRequiredMw } from './confRequiredMw.ts'; diff --git a/packages/ditto/app.ts b/packages/ditto/app.ts index 88bfa7f9..9944426c 100644 --- a/packages/ditto/app.ts +++ b/packages/ditto/app.ts @@ -1,16 +1,18 @@ -import { confMw } from '@ditto/api/middleware'; -import { type DittoConf } from '@ditto/conf'; -import { DittoTables } from '@ditto/db'; +import { DittoConf } from '@ditto/conf'; +import { DittoDB } from '@ditto/db'; +import { paginationMiddleware, userMiddleware } from '@ditto/mastoapi/middleware'; +import { DittoApp, type DittoEnv } from '@ditto/router'; 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 { cors } from '@hono/hono/cors'; import { serveStatic } from '@hono/hono/deno'; -import { NostrEvent, NostrSigner, NStore, NUploader } from '@nostrify/nostrify'; -import { Kysely } from 'kysely'; +import { NostrEvent, NostrSigner, NRelay, NUploader } from '@nostrify/nostrify'; import '@/startup.ts'; +import { Conf } from '@/config.ts'; +import { Storages } from '@/storages.ts'; import { Time } from '@/utils/time.ts'; import { @@ -140,34 +142,33 @@ import { cacheControlMiddleware } from '@/middleware/cacheControlMiddleware.ts'; import { cspMiddleware } from '@/middleware/cspMiddleware.ts'; import { metricsMiddleware } from '@/middleware/metricsMiddleware.ts'; import { notActivitypubMiddleware } from '@/middleware/notActivitypubMiddleware.ts'; -import { paginationMiddleware } from '@/middleware/paginationMiddleware.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 { translatorMiddleware } from '@/middleware/translatorMiddleware.ts'; import { logiMiddleware } from '@/middleware/logiMiddleware.ts'; -export interface AppEnv extends HonoEnv { +export interface AppEnv extends DittoEnv { Variables: { 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?: NUploader; /** NIP-98 signed event proving the pubkey is owned by the user. */ proof?: NostrEvent; /** Kysely instance for the database. */ - kysely: Kysely; - /** Storage for the user, might filter out unwanted content. */ - store: NStore; + db: DittoDB; + /** Base database store. No content filtering. */ + relay: NRelay; /** Normalized pagination params. */ pagination: { since?: number; until?: number; limit: number }; - /** Normalized list pagination params. */ - listPagination: { offset: number; limit: number }; /** Translation service. */ 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,21 +177,29 @@ type AppMiddleware = MiddlewareHandler; // deno-lint-ignore no-explicit-any type AppController

= Handler>; -const app = new Hono({ 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. */ const publicFiles = serveStatic({ root: './public/' }); /** Static files provided by the Ditto repo, checked into git. */ 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( rateLimitMiddleware(30, Time.seconds(5), false), rateLimitMiddleware(300, Time.minutes(5), false), ); -app.use('/api/*', metricsMiddleware, ratelimit, paginationMiddleware, logiMiddleware); +const requireSigner = userMiddleware({ privileged: false, required: true }); + +app.use('/api/*', metricsMiddleware, ratelimit, paginationMiddleware(), logiMiddleware); app.use('/.well-known/*', metricsMiddleware, ratelimit, logiMiddleware); app.use('/nodeinfo/*', metricsMiddleware, ratelimit, logiMiddleware); app.use('/oauth/*', metricsMiddleware, ratelimit, logiMiddleware); @@ -201,10 +210,8 @@ app.get('/relay', metricsMiddleware, ratelimit, relayController); app.use( cspMiddleware(), cors({ origin: '*', exposeHeaders: ['link'] }), - signerMiddleware, uploaderMiddleware, auth98Middleware(), - storeMiddleware, ); app.get('/metrics', metricsController); @@ -251,7 +258,7 @@ app.post('/oauth/revoke', revokeTokenController); app.post('/oauth/authorize', oauthAuthorizeController); app.get('/oauth/authorize', oauthController); -app.post('/api/v1/accounts', requireProof({ pow: 20 }), createAccountController); +app.post('/api/v1/accounts', requireProof(), createAccountController); app.get('/api/v1/accounts/verify_credentials', requireSigner, verifyCredentialsController); app.patch('/api/v1/accounts/update_credentials', requireSigner, updateCredentialsController); app.get('/api/v1/accounts/search', accountSearchController); diff --git a/packages/ditto/controllers/api/accounts.ts b/packages/ditto/controllers/api/accounts.ts index 27710063..24f7d5af 100644 --- a/packages/ditto/controllers/api/accounts.ts +++ b/packages/ditto/controllers/api/accounts.ts @@ -26,7 +26,9 @@ const createAccountSchema = z.object({ }); 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()); if (!result.success) { @@ -46,15 +48,15 @@ const createAccountController: AppController = async (c) => { }; const verifyCredentialsController: AppController = async (c) => { - const signer = c.get('signer')!; - const pubkey = await signer.getPublicKey(); + const { relay, user } = c.var; - const store = await Storages.db(); + const signer = user!.signer; + const pubkey = await signer.getPublicKey(); const [author, [settingsEvent]] = await Promise.all([ getAuthor(pubkey, { signal: AbortSignal.timeout(5000) }), - store.query([{ + relay.query([{ kinds: [30078], authors: [pubkey], '#d': ['pub.ditto.pleroma_settings_store'], @@ -115,12 +117,10 @@ const accountSearchQuerySchema = z.object({ }); const accountSearchController: AppController = async (c) => { - const { store } = c.var; - const { signal } = c.req.raw; - const { limit } = c.get('pagination'); + const { db, relay, user, pagination, signal } = c.var; + const { limit } = pagination; - const kysely = await Storages.kysely(); - const viewerPubkey = await c.get('signer')?.getPublicKey(); + const viewerPubkey = await user?.signer.getPublicKey(); const result = accountSearchQuerySchema.safeParse(c.req.query()); @@ -144,8 +144,8 @@ const accountSearchController: AppController = async (c) => { events.push(event); } else { const following = viewerPubkey ? await getFollowedPubkeys(viewerPubkey) : new Set(); - const authors = [...await getPubkeysBySearch(kysely, { q: query, limit, offset: 0, following })]; - const profiles = await store.query([{ kinds: [0], authors, limit }], { signal }); + const authors = [...await getPubkeysBySearch(db.kysely, { q: query, limit, offset: 0, following })]; + const profiles = await relay.query([{ kinds: [0], authors, limit }], { signal }); for (const pubkey of authors) { 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))); return c.json(accounts); }; 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[]')); if (!ids.success) { @@ -201,17 +203,17 @@ const accountStatusesQuerySchema = z.object({ }); const accountStatusesController: AppController = async (c) => { + const { conf, user, signal } = c.var; + const pubkey = c.req.param('pubkey'); - const { conf } = c.var; const { since, until } = c.var.pagination; const { pinned, limit, exclude_replies, tagged, only_media } = accountStatusesQuerySchema.parse(c.req.query()); - const { signal } = c.req.raw; - const store = await Storages.db(); + const { relay } = c.var; - const [[author], [user]] = await Promise.all([ - store.query([{ kinds: [0], authors: [pubkey], limit: 1 }], { signal }), - store.query([{ kinds: [30382], authors: [await conf.signer.getPublicKey()], '#d': [pubkey], limit: 1 }], { + const [[author], [userEvent]] = await Promise.all([ + relay.query([{ kinds: [0], authors: [pubkey], limit: 1 }], { signal }), + relay.query([{ kinds: [30382], authors: [await conf.signer.getPublicKey()], '#d': [pubkey], limit: 1 }], { signal, }), ]); @@ -220,14 +222,14 @@ const accountStatusesController: AppController = async (c) => { assertAuthenticated(c, author); } - const names = getTagSet(user?.tags ?? [], 'n'); + const names = getTagSet(userEvent?.tags ?? [], 'n'); if (names.has('disabled')) { return c.json([]); } 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) { const pinnedEventIds = getTagSet(pinEvent.tags, 'e'); return renderStatuses(c, [...pinnedEventIds].reverse()); @@ -264,8 +266,8 @@ const accountStatusesController: AppController = async (c) => { const opts = { signal, limit, timeout: conf.db.timeouts.timelines }; - const events = await store.query([filter], opts) - .then((events) => hydrateEvents({ events, store, signal })) + const events = await relay.query([filter], opts) + .then((events) => hydrateEvents({ events, relay, signal })) .then((events) => { if (exclude_replies) { return events.filter((event) => { @@ -276,7 +278,7 @@ const accountStatusesController: AppController = async (c) => { return events; }); - const viewerPubkey = await c.get('signer')?.getPublicKey(); + const viewerPubkey = await user?.signer.getPublicKey(); const statuses = await Promise.all( events.map((event) => { @@ -303,12 +305,11 @@ const updateCredentialsSchema = z.object({ }); const updateCredentialsController: AppController = async (c) => { - const signer = c.get('signer')!; - const pubkey = await signer.getPublicKey(); + const { relay, user, signal } = c.var; + + const pubkey = await user!.signer.getPublicKey(); const body = await parseBody(c.req.raw); const result = updateCredentialsSchema.safeParse(body); - const store = await Storages.db(); - const signal = c.req.raw.signal; if (!result.success) { return c.json(result.error, 422); @@ -318,7 +319,7 @@ const updateCredentialsController: AppController = async (c) => { let event: NostrEvent | undefined; 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 { event = await updateEvent( { kinds: [0], authors: [pubkey], limit: 1 }, @@ -374,7 +375,7 @@ const updateCredentialsController: AppController = async (c) => { let account: MastodonAccount; if (event) { - await hydrateEvents({ events: [event], store, signal }); + await hydrateEvents({ events: [event], relay, signal }); account = await renderAccount(event, { withSource: true, settingsStore }); } else { account = await accountFromPubkey(pubkey, { withSource: true, settingsStore }); @@ -393,7 +394,9 @@ const updateCredentialsController: AppController = async (c) => { /** https://docs.joinmastodon.org/methods/accounts/#follow */ 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'); await updateListEvent( @@ -410,7 +413,9 @@ const followController: AppController = async (c) => { /** https://docs.joinmastodon.org/methods/accounts/#unfollow */ 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'); await updateListEvent( @@ -447,7 +452,9 @@ const unblockController: AppController = (c) => { /** https://docs.joinmastodon.org/methods/accounts/#mute */ 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'); await updateListEvent( @@ -462,7 +469,9 @@ const muteController: AppController = async (c) => { /** https://docs.joinmastodon.org/methods/accounts/#unmute */ 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'); await updateListEvent( @@ -476,14 +485,12 @@ const unmuteController: AppController = async (c) => { }; const favouritesController: AppController = async (c) => { - const pubkey = await c.get('signer')?.getPublicKey()!; - const params = c.get('pagination'); - const { signal } = c.req.raw; + const { relay, user, pagination, signal } = c.var; - const store = await Storages.db(); + const pubkey = await user!.signer.getPublicKey(); - const events7 = await store.query( - [{ kinds: [7], authors: [pubkey], ...params }], + const events7 = await relay.query( + [{ kinds: [7], authors: [pubkey], ...pagination }], { signal }, ); @@ -491,10 +498,10 @@ const favouritesController: AppController = async (c) => { .map((event) => event.tags.find((tag) => tag[0] === 'e')?.[1]) .filter((id): id is string => !!id); - const events1 = await store.query([{ kinds: [1, 20], ids }], { signal }) - .then((events) => hydrateEvents({ events, store, signal })); + const events1 = await relay.query([{ kinds: [1, 20], ids }], { 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( events1.map((event) => renderStatus(event, { viewerPubkey })), @@ -503,16 +510,15 @@ const favouritesController: AppController = async (c) => { }; const familiarFollowersController: AppController = async (c) => { - const store = await Storages.db(); - const signer = c.get('signer')!; - const pubkey = await signer.getPublicKey(); + const { relay, user } = c.var; + const pubkey = await user!.signer.getPublicKey(); const ids = z.array(z.string()).parse(c.req.queries('id[]')); const follows = await getFollowedPubkeys(pubkey); const results = await Promise.all(ids.map(async (id) => { - const followLists = await store.query([{ kinds: [3], authors: [...follows], '#p': [id] }]) - .then((events) => hydrateEvents({ events, store })); + const followLists = await relay.query([{ kinds: [3], authors: [...follows], '#p': [id] }]) + .then((events) => hydrateEvents({ events, relay })); const accounts = await Promise.all( followLists.map((event) => event.author ? renderAccount(event.author) : accountFromPubkey(event.pubkey)), diff --git a/packages/ditto/controllers/api/admin.ts b/packages/ditto/controllers/api/admin.ts index 9e9ba5d0..0568cd57 100644 --- a/packages/ditto/controllers/api/admin.ts +++ b/packages/ditto/controllers/api/admin.ts @@ -4,7 +4,6 @@ import { z } from 'zod'; import { type AppController } from '@/app.ts'; import { booleanParamSchema } from '@/schema.ts'; -import { Storages } from '@/storages.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; import { createAdminEvent, paginated, parseBody, updateEventInfo, updateUser } from '@/utils/api.ts'; import { renderNameRequest } from '@/views/ditto.ts'; @@ -29,10 +28,8 @@ const adminAccountQuerySchema = z.object({ }); const adminAccountsController: AppController = async (c) => { - const { conf } = c.var; - const store = await Storages.db(); - const params = c.get('pagination'); - const { signal } = c.req.raw; + const { conf, relay, signal, pagination } = c.var; + const { local, pending, @@ -50,8 +47,8 @@ const adminAccountsController: AppController = async (c) => { return c.json([]); } - const orig = await store.query( - [{ kinds: [30383], authors: [adminPubkey], '#k': ['3036'], '#n': ['pending'], ...params }], + const orig = await relay.query( + [{ kinds: [30383], authors: [adminPubkey], '#k': ['3036'], '#n': ['pending'], ...pagination }], { signal }, ); @@ -61,8 +58,8 @@ const adminAccountsController: AppController = async (c) => { .filter((id): id is string => !!id), ); - const events = await store.query([{ kinds: [3036], ids: [...ids] }]) - .then((events) => hydrateEvents({ store, events, signal })); + const events = await relay.query([{ kinds: [3036], ids: [...ids] }]) + .then((events) => hydrateEvents({ relay, events, signal })); const nameRequests = await Promise.all(events.map(renderNameRequest)); return paginated(c, orig, nameRequests); @@ -88,8 +85,8 @@ const adminAccountsController: AppController = async (c) => { n.push('moderator'); } - const events = await store.query( - [{ kinds: [30382], authors: [adminPubkey], '#n': n, ...params }], + const events = await relay.query( + [{ kinds: [30382], authors: [adminPubkey], '#n': n, ...pagination }], { signal }, ); @@ -99,8 +96,8 @@ const adminAccountsController: AppController = async (c) => { .filter((pubkey): pubkey is string => !!pubkey), ); - const authors = await store.query([{ kinds: [0], authors: [...pubkeys] }]) - .then((events) => hydrateEvents({ store, events, signal })); + const authors = await relay.query([{ kinds: [0], authors: [...pubkeys] }]) + .then((events) => hydrateEvents({ relay, events, signal })); const accounts = await Promise.all( [...pubkeys].map((pubkey) => { @@ -112,14 +109,14 @@ const adminAccountsController: AppController = async (c) => { return paginated(c, events, accounts); } - const filter: NostrFilter = { kinds: [0], ...params }; + const filter: NostrFilter = { kinds: [0], ...pagination }; if (local) { filter.search = `domain:${conf.url.host}`; } - const events = await store.query([filter], { signal }) - .then((events) => hydrateEvents({ store, events, signal })); + const events = await relay.query([filter], { signal }) + .then((events) => hydrateEvents({ relay, events, signal })); const accounts = await Promise.all(events.map(renderAdminAccount)); return paginated(c, events, accounts); @@ -130,9 +127,9 @@ const adminAccountActionSchema = z.object({ }); const adminActionController: AppController = async (c) => { - const { conf } = c.var; + const { conf, relay } = c.var; + const body = await parseBody(c.req.raw); - const store = await Storages.db(); const result = adminAccountActionSchema.safeParse(body); const authorId = c.req.param('id'); @@ -156,13 +153,13 @@ const adminActionController: AppController = async (c) => { if (data.type === 'suspend') { n.disabled = 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) }); }); } if (data.type === 'revoke_name') { 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) => { 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 { conf } = c.var; 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) { 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); } - const [existing] = await store.query([ + const [existing] = await relay.query([ { kinds: [30360], authors: [await conf.signer.getPublicKey()], '#d': [r], limit: 1 }, ]); @@ -212,7 +209,7 @@ const adminApproveController: AppController = async (c) => { }, 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); return c.json(nameRequest); @@ -220,15 +217,15 @@ const adminApproveController: AppController = async (c) => { const adminRejectController: AppController = async (c) => { 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) { return c.json({ error: 'Event not found' }, 404); } 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); return c.json(nameRequest); diff --git a/packages/ditto/controllers/api/bookmarks.ts b/packages/ditto/controllers/api/bookmarks.ts index 6d80b500..e5253986 100644 --- a/packages/ditto/controllers/api/bookmarks.ts +++ b/packages/ditto/controllers/api/bookmarks.ts @@ -1,15 +1,14 @@ import { type AppController } from '@/app.ts'; -import { Storages } from '@/storages.ts'; import { getTagSet } from '@/utils/tags.ts'; import { renderStatuses } from '@/views.ts'; /** https://docs.joinmastodon.org/methods/bookmarks/#get */ const bookmarksController: AppController = async (c) => { - const store = await Storages.db(); - const pubkey = await c.get('signer')?.getPublicKey()!; - const { signal } = c.req.raw; + const { relay, user, signal } = c.var; - const [event10003] = await store.query( + const pubkey = await user!.signer.getPublicKey(); + + const [event10003] = await relay.query( [{ kinds: [10003], authors: [pubkey], limit: 1 }], { signal }, ); diff --git a/packages/ditto/controllers/api/captcha.ts b/packages/ditto/controllers/api/captcha.ts index 7b310e53..790913af 100644 --- a/packages/ditto/controllers/api/captcha.ts +++ b/packages/ditto/controllers/api/captcha.ts @@ -152,9 +152,11 @@ const pointSchema = z.object({ /** Verify the captcha solution and sign an event in the database. */ export const captchaVerifyController: AppController = async (c) => { + const { user } = c.var; + const id = c.req.param('id'); const result = pointSchema.safeParse(await c.req.json()); - const pubkey = await c.get('signer')!.getPublicKey(); + const pubkey = await user!.signer.getPublicKey(); if (!result.success) { return c.json({ error: 'Invalid input' }, { status: 422 }); diff --git a/packages/ditto/controllers/api/cashu.test.ts b/packages/ditto/controllers/api/cashu.test.ts index 57be895d..d82e205e 100644 --- a/packages/ditto/controllers/api/cashu.test.ts +++ b/packages/ditto/controllers/api/cashu.test.ts @@ -1,11 +1,10 @@ -import { confMw } from '@ditto/api/middleware'; import { Env as HonoEnv, Hono } from '@hono/hono'; import { NostrSigner, NSecSigner, NStore } from '@nostrify/nostrify'; import { genEvent } from '@nostrify/nostrify/test'; -import { generateSecretKey, getPublicKey } from 'nostr-tools'; import { bytesToString, stringToBytes } from '@scure/base'; import { stub } from '@std/testing/mock'; import { assertEquals, assertExists, assertObjectMatch } from '@std/assert'; +import { generateSecretKey, getPublicKey } from 'nostr-tools'; import { createTestDB } from '@/test.ts'; @@ -44,7 +43,6 @@ Deno.test('PUT /wallet must be successful', { }, ); - app.use(confMw(new Map())); app.route('/', cashuApp); const response = await app.request('/wallet', { @@ -123,7 +121,6 @@ Deno.test('PUT /wallet must NOT be successful: wrong request body/schema', async }, ); - app.use(confMw(new Map())); app.route('/', cashuApp); const response = await app.request('/wallet', { @@ -162,7 +159,6 @@ Deno.test('PUT /wallet must NOT be successful: wallet already exists', { }, ); - app.use(confMw(new Map())); app.route('/', cashuApp); await db.store.event(genEvent({ kind: 17375 }, sk)); @@ -206,7 +202,6 @@ Deno.test('GET /wallet must be successful', { }, ); - app.use(confMw(new Map())); app.route('/', cashuApp); // Wallet @@ -312,7 +307,6 @@ Deno.test('GET /mints must be successful', async () => { }, ); - app.use(confMw(new Map())); app.route('/', cashuApp); const response = await app.request('/mints', { diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index dd753884..60832ac4 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -1,22 +1,22 @@ import { Proof } from '@cashu/cashu-ts'; -import { confRequiredMw } from '@ditto/api/middleware'; -import { Hono } from '@hono/hono'; +import { userMiddleware } from '@ditto/mastoapi/middleware'; +import { DittoMiddleware, DittoRoute } from '@ditto/router'; import { generateSecretKey, getPublicKey } from 'nostr-tools'; import { bytesToString, stringToBytes } from '@scure/base'; import { z } from 'zod'; 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 { swapNutzapsMiddleware } from '@/middleware/swapNutzapsMiddleware.ts'; import { isNostrId } from '@/utils.ts'; import { logi } from '@soapbox/logi'; import { errorJson } from '@/utils/log.ts'; +import { SetRequired } from 'type-fest'; +import { NostrSigner } from '@nostrify/nostrify'; type Wallet = z.infer; -const app = new Hono().use('*', confRequiredMw, requireStore); +const app = new DittoRoute(); // app.delete('/wallet') -> 204 @@ -33,6 +33,19 @@ interface Nutzap { recipient_pubkey: string; } +const requireNip44Signer: DittoMiddleware<{ user: { signer: SetRequired } }> = async ( + c, + next, +) => { + const { user } = c.var; + + if (!user?.signer.nip44) { + return c.json({ error: 'User does not have a NIP-44 signer' }, 400); + } + + await next(); +}; + const createCashuWalletAndNutzapInfoSchema = z.object({ mints: z.array(z.string().url()).nonempty().transform((val) => { return [...new Set(val)]; @@ -44,12 +57,11 @@ const createCashuWalletAndNutzapInfoSchema = z.object({ * https://github.com/nostr-protocol/nips/blob/master/60.md * https://github.com/nostr-protocol/nips/blob/master/61.md#nutzap-informational-event */ -app.put('/wallet', requireNip44Signer, async (c) => { - const { conf, signer } = c.var; - const store = c.get('store'); - const pubkey = await signer.getPublicKey(); +app.put('/wallet', userMiddleware({ privileged: false, required: true }), requireNip44Signer, async (c) => { + const { conf, user, relay, signal } = c.var; + + const pubkey = await user.signer.getPublicKey(); const body = await parseBody(c.req.raw); - const { signal } = c.req.raw; const result = createCashuWalletAndNutzapInfoSchema.safeParse(body); if (!result.success) { @@ -58,7 +70,7 @@ app.put('/wallet', requireNip44Signer, async (c) => { 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) { return c.json({ error: 'You already have a wallet 😏' }, 400); } @@ -75,7 +87,7 @@ app.put('/wallet', requireNip44Signer, async (c) => { 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 await createEvent({ @@ -105,58 +117,63 @@ app.put('/wallet', requireNip44Signer, async (c) => { }); /** Gets a wallet, if it exists. */ -app.get('/wallet', requireNip44Signer, swapNutzapsMiddleware, async (c) => { - const { conf, signer } = c.var; - const store = c.get('store'); - const pubkey = await signer.getPublicKey(); - const { signal } = c.req.raw; +app.get( + '/wallet', + userMiddleware({ privileged: false, required: true }), + requireNip44Signer, + swapNutzapsMiddleware, + async (c) => { + const { conf, relay, user, signal } = c.var; - const [event] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal }); - if (!event) { - return c.json({ error: 'Wallet not found' }, 404); - } + const pubkey = await user.signer.getPublicKey(); - const decryptedContent: string[][] = JSON.parse(await signer.nip44.decrypt(pubkey, event.content)); - - const privkey = decryptedContent.find(([value]) => value === 'privkey')?.[1]; - if (!privkey || !isNostrId(privkey)) { - return c.json({ error: 'Wallet does not contain privkey or privkey is not a valid nostr id.' }, 422); - } - - const p2pk = getPublicKey(stringToBytes('hex', privkey)); - - let balance = 0; - const mints: string[] = []; - - const tokens = await store.query([{ authors: [pubkey], kinds: [7375] }], { signal }); - for (const token of tokens) { - try { - const decryptedContent: { mint: string; proofs: Proof[] } = JSON.parse( - await signer.nip44.decrypt(pubkey, token.content), - ); - - if (!mints.includes(decryptedContent.mint)) { - mints.push(decryptedContent.mint); - } - - balance += decryptedContent.proofs.reduce((accumulator, current) => { - return accumulator + current.amount; - }, 0); - } catch (e) { - logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: errorJson(e) }); + const [event] = await relay.query([{ authors: [pubkey], kinds: [17375] }], { signal }); + if (!event) { + return c.json({ error: 'Wallet not found' }, 404); } - } - // TODO: maybe change the 'Wallet' type data structure so each mint is a key and the value are the tokens associated with a given mint - const walletEntity: Wallet = { - pubkey_p2pk: p2pk, - mints, - relays: [conf.relay], - balance, - }; + const decryptedContent: string[][] = JSON.parse(await user.signer.nip44.decrypt(pubkey, event.content)); - return c.json(walletEntity, 200); -}); + const privkey = decryptedContent.find(([value]) => value === 'privkey')?.[1]; + if (!privkey || !isNostrId(privkey)) { + return c.json({ error: 'Wallet does not contain privkey or privkey is not a valid nostr id.' }, 422); + } + + const p2pk = getPublicKey(stringToBytes('hex', privkey)); + + let balance = 0; + const mints: string[] = []; + + const tokens = await relay.query([{ authors: [pubkey], kinds: [7375] }], { signal }); + for (const token of tokens) { + try { + const decryptedContent: { mint: string; proofs: Proof[] } = JSON.parse( + await user.signer.nip44.decrypt(pubkey, token.content), + ); + + if (!mints.includes(decryptedContent.mint)) { + mints.push(decryptedContent.mint); + } + + balance += decryptedContent.proofs.reduce((accumulator, current) => { + return accumulator + current.amount; + }, 0); + } catch (e) { + logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: errorJson(e) }); + } + } + + // TODO: maybe change the 'Wallet' type data structure so each mint is a key and the value are the tokens associated with a given mint + const walletEntity: Wallet = { + pubkey_p2pk: p2pk, + mints, + relays: [conf.relay], + balance, + }; + + return c.json(walletEntity, 200); + }, +); /** Get mints set by the CASHU_MINTS environment variable. */ app.get('/mints', (c) => { diff --git a/packages/ditto/controllers/api/ditto.ts b/packages/ditto/controllers/api/ditto.ts index 752124dc..f67fed32 100644 --- a/packages/ditto/controllers/api/ditto.ts +++ b/packages/ditto/controllers/api/ditto.ts @@ -28,10 +28,9 @@ const relaySchema = z.object({ type RelayEntity = z.infer; export const adminRelaysController: AppController = async (c) => { - const { conf } = c.var; - const store = await Storages.db(); + const { conf, relay } = c.var; - const [event] = await store.query([ + const [event] = await relay.query([ { 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) => { - const { conf } = c.var; - const store = await Storages.db(); + const { conf, relay } = c.var; const relays = relaySchema.array().parse(await c.req.json()); const event = await conf.signer.signEvent({ @@ -54,7 +52,7 @@ export const adminSetRelaysController: AppController = async (c) => { created_at: Math.floor(Date.now() / 1000), }); - await store.event(event); + await relay.event(event); return c.json(renderRelays(event)); }; @@ -79,14 +77,12 @@ const nameRequestSchema = z.object({ }); export const nameRequestController: AppController = async (c) => { - const store = await Storages.db(); - const signer = c.get('signer')!; - const pubkey = await signer.getPublicKey(); - const { conf } = c.var; + const { conf, relay, user } = c.var; + const pubkey = await user!.signer.getPublicKey(); const { name, reason } = nameRequestSchema.parse(await c.req.json()); - const [existing] = await store.query([{ kinds: [3036], authors: [pubkey], '#r': [name], limit: 1 }]); + const [existing] = await relay.query([{ kinds: [3036], authors: [pubkey], '#r': [name], limit: 1 }]); if (existing) { return c.json({ error: 'Name request already exists' }, 400); } @@ -102,7 +98,7 @@ export const nameRequestController: AppController = async (c) => { ], }, c); - await hydrateEvents({ events: [event], store: await Storages.db() }); + await hydrateEvents({ events: [event], relay }); const nameRequest = await renderNameRequest(event); return c.json(nameRequest); @@ -114,10 +110,8 @@ const nameRequestsSchema = z.object({ }); export const nameRequestsController: AppController = async (c) => { - const { conf } = c.var; - const store = await Storages.db(); - const signer = c.get('signer')!; - const pubkey = await signer.getPublicKey(); + const { conf, relay, user, signal } = c.var; + const pubkey = await user!.signer.getPublicKey(); const params = c.get('pagination'); const { approved, rejected } = nameRequestsSchema.parse(c.req.query()); @@ -137,7 +131,7 @@ export const nameRequestsController: AppController = async (c) => { filter['#n'] = ['rejected']; } - const orig = await store.query([filter]); + const orig = await relay.query([filter]); const ids = new Set(); for (const event of orig) { @@ -151,8 +145,8 @@ export const nameRequestsController: AppController = async (c) => { return c.json([]); } - const events = await store.query([{ kinds: [3036], ids: [...ids], authors: [pubkey] }]) - .then((events) => hydrateEvents({ store, events: events, signal: c.req.raw.signal })); + const events = await relay.query([{ kinds: [3036], ids: [...ids], authors: [pubkey] }]) + .then((events) => hydrateEvents({ relay, events: events, signal })); const nameRequests = await Promise.all( events.map((event) => renderNameRequest(event)), @@ -170,10 +164,9 @@ const zapSplitSchema = z.record( ); export const updateZapSplitsController: AppController = async (c) => { - const { conf } = c.var; + const { conf, relay } = c.var; const body = await parseBody(c.req.raw); const result = zapSplitSchema.safeParse(body); - const store = c.get('store'); if (!result.success) { return c.json({ error: result.error }, 400); @@ -181,7 +174,7 @@ export const updateZapSplitsController: AppController = async (c) => { const adminPubkey = await conf.signer.getPublicKey(); - const dittoZapSplit = await getZapSplits(store, adminPubkey); + const dittoZapSplit = await getZapSplits(relay, adminPubkey); if (!dittoZapSplit) { return c.json({ error: 'Zap split not activated, restart the server.' }, 404); } @@ -208,10 +201,9 @@ export const updateZapSplitsController: AppController = async (c) => { const deleteZapSplitSchema = z.array(n.id()).min(1); export const deleteZapSplitsController: AppController = async (c) => { - const { conf } = c.var; + const { conf, relay } = c.var; const body = await parseBody(c.req.raw); const result = deleteZapSplitSchema.safeParse(body); - const store = c.get('store'); if (!result.success) { return c.json({ error: result.error }, 400); @@ -219,7 +211,7 @@ export const deleteZapSplitsController: AppController = async (c) => { const adminPubkey = await conf.signer.getPublicKey(); - const dittoZapSplit = await getZapSplits(store, adminPubkey); + const dittoZapSplit = await getZapSplits(relay, adminPubkey); if (!dittoZapSplit) { return c.json({ error: 'Zap split not activated, restart the server.' }, 404); } @@ -239,10 +231,9 @@ export const deleteZapSplitsController: AppController = async (c) => { }; export const getZapSplitsController: AppController = async (c) => { - const { conf } = c.var; - const store = c.get('store'); + const { conf, relay } = c.var; - const dittoZapSplit: DittoZapSplits | undefined = await getZapSplits(store, await conf.signer.getPublicKey()) ?? {}; + const dittoZapSplit: DittoZapSplits | undefined = await getZapSplits(relay, await conf.signer.getPublicKey()) ?? {}; if (!dittoZapSplit) { return c.json({ error: 'Zap split not activated, restart the server.' }, 404); } @@ -265,11 +256,11 @@ export const getZapSplitsController: AppController = async (c) => { }; export const statusZapSplitsController: AppController = async (c) => { - const store = c.get('store'); - const id = c.req.param('id'); - const { signal } = c.req.raw; + const { relay, signal } = c.var; - 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) { return c.json({ error: 'Event not found' }, 404); } @@ -278,8 +269,8 @@ export const statusZapSplitsController: AppController = async (c) => { const pubkeys = zapsTag.map((name) => name[1]); - const users = await store.query([{ authors: pubkeys, kinds: [0], limit: pubkeys.length }], { signal }); - await hydrateEvents({ events: users, store, signal }); + const users = await relay.query([{ authors: pubkeys, kinds: [0], limit: pubkeys.length }], { signal }); + await hydrateEvents({ events: users, relay, signal }); const zapSplits = (await Promise.all(pubkeys.map((pubkey) => { const author = (users.find((event) => event.pubkey === pubkey) as DittoEvent | undefined)?.author; diff --git a/packages/ditto/controllers/api/markers.ts b/packages/ditto/controllers/api/markers.ts index 005ebbe5..7e7cb8dd 100644 --- a/packages/ditto/controllers/api/markers.ts +++ b/packages/ditto/controllers/api/markers.ts @@ -14,7 +14,9 @@ interface Marker { } 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 results = await kv.getMany( @@ -37,7 +39,9 @@ const markerDataSchema = z.object({ }); 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 timelines = Object.keys(record) as Timeline[]; diff --git a/packages/ditto/controllers/api/media.ts b/packages/ditto/controllers/api/media.ts index fc309cdf..c6c6b062 100644 --- a/packages/ditto/controllers/api/media.ts +++ b/packages/ditto/controllers/api/media.ts @@ -21,9 +21,10 @@ const mediaUpdateSchema = z.object({ }); 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 { signal } = c.req.raw; if (!result.success) { return c.json({ error: 'Bad request.', schema: result.error }, 422); diff --git a/packages/ditto/controllers/api/mutes.ts b/packages/ditto/controllers/api/mutes.ts index 90b5f545..9ce9c5e9 100644 --- a/packages/ditto/controllers/api/mutes.ts +++ b/packages/ditto/controllers/api/mutes.ts @@ -1,15 +1,14 @@ import { type AppController } from '@/app.ts'; -import { Storages } from '@/storages.ts'; import { getTagSet } from '@/utils/tags.ts'; import { renderAccounts } from '@/views.ts'; /** https://docs.joinmastodon.org/methods/mutes/#get */ const mutesController: AppController = async (c) => { - const store = await Storages.db(); - const pubkey = await c.get('signer')?.getPublicKey()!; - const { signal } = c.req.raw; + const { relay, user, signal } = c.var; - const [event10000] = await store.query( + const pubkey = await user!.signer.getPublicKey(); + + const [event10000] = await relay.query( [{ kinds: [10000], authors: [pubkey], limit: 1 }], { signal }, ); diff --git a/packages/ditto/controllers/api/notifications.ts b/packages/ditto/controllers/api/notifications.ts index dfd4a03c..f180cf9e 100644 --- a/packages/ditto/controllers/api/notifications.ts +++ b/packages/ditto/controllers/api/notifications.ts @@ -30,8 +30,9 @@ const notificationsSchema = z.object({ }); const notificationsController: AppController = async (c) => { - const { conf } = c.var; - const pubkey = await c.get('signer')?.getPublicKey()!; + const { conf, user } = c.var; + + const pubkey = await user!.signer.getPublicKey(); const params = c.get('pagination'); const types = notificationTypes @@ -75,20 +76,21 @@ const notificationsController: AppController = async (c) => { }; const notificationController: AppController = async (c) => { + const { relay, user } = c.var; + const id = c.req.param('id'); - const pubkey = await c.get('signer')?.getPublicKey()!; - const store = c.get('store'); + const pubkey = await user!.signer.getPublicKey(); // Remove the timestamp from the ID. const eventId = id.replace(/^\d+-/, ''); - const [event] = await store.query([{ ids: [eventId] }]); + const [event] = await relay.query([{ ids: [eventId] }]); if (!event) { 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 }); @@ -105,16 +107,15 @@ async function renderNotifications( params: DittoPagination, c: AppContext, ) { - const { conf } = c.var; - const store = c.get('store'); - const pubkey = await c.get('signer')?.getPublicKey()!; - const { signal } = c.req.raw; + const { conf, relay, user, signal } = c.var; + + const pubkey = await user!.signer.getPublicKey(); const opts = { signal, limit: params.limit, timeout: conf.db.timeouts.timelines }; - const events = await store + const events = await relay .query(filters, opts) .then((events) => events.filter((event) => event.pubkey !== pubkey)) - .then((events) => hydrateEvents({ events, store, signal })); + .then((events) => hydrateEvents({ events, relay, signal })); if (!events.length) { return c.json([]); diff --git a/packages/ditto/controllers/api/pleroma.ts b/packages/ditto/controllers/api/pleroma.ts index 721347f3..dc4b0c68 100644 --- a/packages/ditto/controllers/api/pleroma.ts +++ b/packages/ditto/controllers/api/pleroma.ts @@ -2,14 +2,14 @@ import { z } from 'zod'; import { type AppController } from '@/app.ts'; import { configSchema, elixirTupleSchema } from '@/schemas/pleroma-api.ts'; -import { Storages } from '@/storages.ts'; import { createAdminEvent, updateAdminEvent, updateUser } from '@/utils/api.ts'; import { lookupPubkey } from '@/utils/lookup.ts'; import { getPleromaConfigs } from '@/utils/pleroma.ts'; const frontendConfigController: AppController = async (c) => { - const store = await Storages.db(); - const configDB = await getPleromaConfigs(store, c.req.raw.signal); + const { relay, signal } = c.var; + + const configDB = await getPleromaConfigs(relay, signal); const frontendConfig = configDB.get(':pleroma', ':frontend_configurations'); if (frontendConfig) { @@ -25,17 +25,17 @@ const frontendConfigController: AppController = async (c) => { }; const configController: AppController = async (c) => { - const store = await Storages.db(); - const configs = await getPleromaConfigs(store, c.req.raw.signal); + const { relay, signal } = c.var; + + const configs = await getPleromaConfigs(relay, signal); return c.json({ configs, need_reboot: false }); }; /** Pleroma admin config controller. */ const updateConfigController: AppController = async (c) => { - const { conf } = c.var; + const { conf, relay, signal } = c.var; - const store = await Storages.db(); - const configs = await getPleromaConfigs(store, c.req.raw.signal); + const configs = await getPleromaConfigs(relay, signal); const { configs: newConfigs } = z.object({ configs: z.array(configSchema) }).parse(await c.req.json()); configs.merge(newConfigs); diff --git a/packages/ditto/controllers/api/push.ts b/packages/ditto/controllers/api/push.ts index 79063622..e613c5f8 100644 --- a/packages/ditto/controllers/api/push.ts +++ b/packages/ditto/controllers/api/push.ts @@ -42,7 +42,7 @@ const pushSubscribeSchema = z.object({ }); export const pushSubscribeController: AppController = async (c) => { - const { conf } = c.var; + const { conf, user } = c.var; const vapidPublicKey = await conf.vapidPublicKey; if (!vapidPublicKey) { @@ -52,7 +52,7 @@ export const pushSubscribeController: AppController = async (c) => { const accessToken = getAccessToken(c.req.raw); const kysely = await Storages.kysely(); - const signer = c.get('signer')!; + const signer = user!.signer; const result = pushSubscribeSchema.safeParse(await parseBody(c.req.raw)); diff --git a/packages/ditto/controllers/api/reactions.ts b/packages/ditto/controllers/api/reactions.ts index 0beb985d..a69ba363 100644 --- a/packages/ditto/controllers/api/reactions.ts +++ b/packages/ditto/controllers/api/reactions.ts @@ -1,7 +1,6 @@ import { AppController } from '@/app.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; -import { Storages } from '@/storages.ts'; import { createEvent } from '@/utils/api.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.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 */ const reactionController: AppController = async (c) => { + const { relay, user } = c.var; const id = c.req.param('id'); const emoji = c.req.param('emoji'); - const signer = c.get('signer')!; if (!/^\p{RGI_Emoji}$/v.test(emoji)) { return c.json({ error: 'Invalid emoji' }, 400); } - const store = await Storages.db(); - const [event] = await store.query([{ kinds: [1, 20], ids: [id], limit: 1 }]); + const [event] = await relay.query([{ kinds: [1, 20], ids: [id], limit: 1 }]); if (!event) { return c.json({ error: 'Status not found' }, 404); @@ -33,9 +31,9 @@ const reactionController: AppController = async (c) => { tags: [['e', id], ['p', event.pubkey]], }, 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); }; @@ -45,17 +43,17 @@ const reactionController: AppController = async (c) => { * https://docs.pleroma.social/backend/development/API/pleroma_api/#delete-apiv1pleromastatusesidreactionsemoji */ const deleteReactionController: AppController = async (c) => { + const { relay, user } = c.var; + const id = c.req.param('id'); const emoji = c.req.param('emoji'); - const signer = c.get('signer')!; - const pubkey = await signer.getPublicKey(); - const store = await Storages.db(); + const pubkey = await user!.signer.getPublicKey(); if (!/^\p{RGI_Emoji}$/v.test(emoji)) { return c.json({ error: 'Invalid emoji' }, 400); } - const [event] = await store.query([ + const [event] = await relay.query([ { kinds: [1, 20], ids: [id], limit: 1 }, ]); @@ -63,7 +61,7 @@ const deleteReactionController: AppController = async (c) => { return c.json({ error: 'Status not found' }, 404); } - const events = await store.query([ + const events = await relay.query([ { 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 */ const reactionsController: AppController = async (c) => { + const { relay, user } = c.var; + const id = c.req.param('id'); - const store = await Storages.db(); - const pubkey = await c.get('signer')?.getPublicKey(); + const pubkey = await user?.signer.getPublicKey(); const emoji = c.req.param('emoji') as string | undefined; if (typeof emoji === 'string' && !/^\p{RGI_Emoji}$/v.test(emoji)) { 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((event) => !emoji || event.content === emoji)) - .then((events) => hydrateEvents({ events, store })); + .then((events) => hydrateEvents({ events, relay })); /** Events grouped by emoji. */ const byEmoji = events.reduce((acc, event) => { diff --git a/packages/ditto/controllers/api/reports.ts b/packages/ditto/controllers/api/reports.ts index 11285825..7c98ce4e 100644 --- a/packages/ditto/controllers/api/reports.ts +++ b/packages/ditto/controllers/api/reports.ts @@ -18,8 +18,8 @@ const reportSchema = z.object({ /** https://docs.joinmastodon.org/methods/reports/#post */ const reportController: AppController = async (c) => { - const { conf } = c.var; - const store = c.get('store'); + const { conf, relay } = c.var; + const body = await parseBody(c.req.raw); const result = reportSchema.safeParse(body); @@ -49,7 +49,7 @@ const reportController: AppController = async (c) => { tags, }, c); - await hydrateEvents({ events: [event], store }); + await hydrateEvents({ events: [event], relay }); return c.json(await renderReport(event)); }; @@ -61,18 +61,16 @@ const adminReportsSchema = z.object({ /** https://docs.joinmastodon.org/methods/admin/reports/#get */ const adminReportsController: AppController = async (c) => { - const { conf } = c.var; - const store = c.get('store'); - const viewerPubkey = await c.get('signer')?.getPublicKey(); + const { conf, relay, user, pagination } = c.var; - const params = c.get('pagination'); + const viewerPubkey = await user?.signer.getPublicKey(); const { resolved, account_id, target_account_id } = adminReportsSchema.parse(c.req.query()); const filter: NostrFilter = { kinds: [30383], authors: [await conf.signer.getPublicKey()], '#k': ['1984'], - ...params, + ...pagination, }; if (typeof resolved === 'boolean') { @@ -85,7 +83,7 @@ const adminReportsController: AppController = async (c) => { filter['#P'] = [target_account_id]; } - const orig = await store.query([filter]); + const orig = await relay.query([filter]); const ids = new Set(); for (const event of orig) { @@ -95,8 +93,8 @@ const adminReportsController: AppController = async (c) => { } } - const events = await store.query([{ kinds: [1984], ids: [...ids] }]) - .then((events) => hydrateEvents({ store, events: events, signal: c.req.raw.signal })); + const events = await relay.query([{ kinds: [1984], ids: [...ids] }]) + .then((events) => hydrateEvents({ relay, events: events, signal: c.req.raw.signal })); const reports = await Promise.all( events.map((event) => renderAdminReport(event, { viewerPubkey })), @@ -107,12 +105,12 @@ const adminReportsController: AppController = async (c) => { /** https://docs.joinmastodon.org/methods/admin/reports/#get-one */ const adminReportController: AppController = async (c) => { - const eventId = c.req.param('id'); - const { signal } = c.req.raw; - const store = c.get('store'); - const pubkey = await c.get('signer')?.getPublicKey(); + const { relay, user, signal } = c.var; - const [event] = await store.query([{ + const eventId = c.req.param('id'); + const pubkey = await user?.signer.getPublicKey(); + + const [event] = await relay.query([{ kinds: [1984], ids: [eventId], limit: 1, @@ -122,7 +120,7 @@ const adminReportController: AppController = async (c) => { 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 }); return c.json(report); @@ -130,12 +128,12 @@ const adminReportController: AppController = async (c) => { /** https://docs.joinmastodon.org/methods/admin/reports/#resolve */ const adminReportResolveController: AppController = async (c) => { - const eventId = c.req.param('id'); - const { signal } = c.req.raw; - const store = c.get('store'); - const pubkey = await c.get('signer')?.getPublicKey(); + const { relay, user, signal } = c.var; - const [event] = await store.query([{ + const eventId = c.req.param('id'); + const pubkey = await user?.signer.getPublicKey(); + + const [event] = await relay.query([{ kinds: [1984], ids: [eventId], limit: 1, @@ -146,19 +144,19 @@ const adminReportResolveController: AppController = async (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 }); return c.json(report); }; const adminReportReopenController: AppController = async (c) => { - const eventId = c.req.param('id'); - const { signal } = c.req.raw; - const store = c.get('store'); - const pubkey = await c.get('signer')?.getPublicKey(); + const { relay, user, signal } = c.var; - const [event] = await store.query([{ + const eventId = c.req.param('id'); + const pubkey = await user?.signer.getPublicKey(); + + const [event] = await relay.query([{ kinds: [1984], ids: [eventId], limit: 1, @@ -169,7 +167,7 @@ const adminReportReopenController: AppController = async (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 }); return c.json(report); diff --git a/packages/ditto/controllers/api/search.ts b/packages/ditto/controllers/api/search.ts index e890f166..3ce9e0ac 100644 --- a/packages/ditto/controllers/api/search.ts +++ b/packages/ditto/controllers/api/search.ts @@ -26,16 +26,16 @@ const searchQuerySchema = z.object({ type SearchQuery = z.infer & { since?: number; until?: number; limit: number }; const searchController: AppController = async (c) => { + const { user, pagination, signal } = c.var; + const result = searchQuerySchema.safeParse(c.req.query()); - const params = c.get('pagination'); - const { signal } = c.req.raw; - const viewerPubkey = await c.get('signer')?.getPublicKey(); + const viewerPubkey = await user?.signer.getPublicKey(); if (!result.success) { 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); // Render account from pubkey. @@ -54,7 +54,7 @@ const searchController: AppController = async (c) => { 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([ Promise.all( @@ -78,7 +78,7 @@ const searchController: AppController = async (c) => { }; if (result.data.type === 'accounts') { - return paginatedList(c, { ...result.data, ...params }, body); + return paginatedList(c, { ...result.data, ...pagination }, body); } else { return paginated(c, events, body); } @@ -94,7 +94,7 @@ async function searchEvents( return Promise.resolve([]); } - const store = await Storages.db(); + const relay = await Storages.db(); const filter: NostrFilter = { kinds: typeToKinds(type), @@ -121,9 +121,9 @@ async function searchEvents( } // Query the events. - let events = await store + let events = await relay .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. if (filter.authors) { @@ -150,10 +150,10 @@ function typeToKinds(type: SearchQuery['type']): number[] { /** Resolve a searched value into an event, if applicable. */ async function lookupEvent(query: SearchQuery, signal: AbortSignal): Promise { const filters = await getLookupFilters(query, signal); - const store = await Storages.db(); + const relay = await Storages.db(); - return store.query(filters, { limit: 1, signal }) - .then((events) => hydrateEvents({ events, store, signal })) + return relay.query(filters, { limit: 1, signal }) + .then((events) => hydrateEvents({ events, relay, signal })) .then(([event]) => event); } diff --git a/packages/ditto/controllers/api/statuses.ts b/packages/ditto/controllers/api/statuses.ts index 6aa53308..5b73be9f 100644 --- a/packages/ditto/controllers/api/statuses.ts +++ b/packages/ditto/controllers/api/statuses.ts @@ -9,11 +9,11 @@ import { type AppController } from '@/app.ts'; import { DittoUpload, dittoUploads } from '@/DittoUploads.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { getAncestors, getAuthor, getDescendants, getEvent } from '@/queries.ts'; +import { paginationSchema } from '@/schemas/pagination.ts'; import { addTag, deleteTag } from '@/utils/tags.ts'; import { asyncReplaceAll } from '@/utils/text.ts'; import { lookupPubkey } from '@/utils/lookup.ts'; import { languageSchema } from '@/schema.ts'; -import { Storages } from '@/storages.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; import { assertAuthenticated, createEvent, paginated, paginatedList, parseBody, updateListEvent } from '@/utils/api.ts'; import { getInvoice, getLnurl } from '@/utils/lnurl.ts'; @@ -46,9 +46,9 @@ const createStatusSchema = z.object({ ); const statusController: AppController = async (c) => { - const id = c.req.param('id'); - const signal = AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(1500)]); + const { user, signal } = c.var; + const id = c.req.param('id'); const event = await getEvent(id, { signal }); if (event?.author) { @@ -56,7 +56,7 @@ const statusController: AppController = async (c) => { } if (event) { - const viewerPubkey = await c.get('signer')?.getPublicKey(); + const viewerPubkey = await user?.signer.getPublicKey(); const status = await renderStatus(event, { viewerPubkey }); return c.json(status); } @@ -65,10 +65,10 @@ const statusController: 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 result = createStatusSchema.safeParse(body); - const store = c.get('store'); if (!result.success) { return c.json({ error: 'Bad request', schema: result.error }, 400); @@ -87,14 +87,14 @@ const createStatusController: AppController = async (c) => { const tags: string[][] = []; 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) { 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 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) { tags.push(['e', root.id, conf.relay, 'root', root.pubkey]); @@ -108,7 +108,7 @@ const createStatusController: AppController = async (c) => { let quoted: DittoEvent | undefined; if (data.quote_id) { - [quoted] = await store.query([{ ids: [data.quote_id] }]); + [quoted] = await relay.query([{ ids: [data.quote_id] }]); if (!quoted) { 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; if (conf.zapSplitsEnabled) { const meta = n.json().pipe(n.metadata()).catch({}).parse(author?.content); 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) { const totalSplit = Object.values(dittoZapSplit).reduce((total, { weight }) => total + weight, 0); for (const zapPubkey in dittoZapSplit) { @@ -256,8 +256,8 @@ const createStatusController: AppController = async (c) => { if (data.quote_id) { await hydrateEvents({ events: [event], - store: await Storages.db(), - signal: c.req.raw.signal, + relay, + signal, }); } @@ -265,11 +265,11 @@ const createStatusController: AppController = async (c) => { }; const deleteStatusController: AppController = async (c) => { - const { conf } = c.var; - const id = c.req.param('id'); - const pubkey = await c.get('signer')?.getPublicKey(); + const { conf, user, signal } = c.var; - 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.pubkey === pubkey) { @@ -289,10 +289,11 @@ const deleteStatusController: AppController = async (c) => { }; const contextController: AppController = async (c) => { + const { relay, user } = c.var; + const id = c.req.param('id'); - const store = c.get('store'); - const [event] = await store.query([{ kinds: [1, 20], ids: [id] }]); - const viewerPubkey = await c.get('signer')?.getPublicKey(); + const [event] = await relay.query([{ kinds: [1, 20], ids: [id] }]); + const viewerPubkey = await user?.signer.getPublicKey(); async function renderStatuses(events: NostrEvent[]) { const statuses = await Promise.all( @@ -303,14 +304,14 @@ const contextController: AppController = async (c) => { if (event) { const [ancestorEvents, descendantEvents] = await Promise.all([ - getAncestors(store, event), - getDescendants(store, event), + getAncestors(relay, event), + getDescendants(relay, event), ]); await hydrateEvents({ events: [...ancestorEvents, ...descendantEvents], signal: c.req.raw.signal, - store, + relay, }); const [ancestors, descendants] = await Promise.all([ @@ -325,10 +326,10 @@ const contextController: 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 store = await Storages.db(); - const [target] = await store.query([{ ids: [id], kinds: [1, 20] }]); + const [target] = await relay.query([{ ids: [id], kinds: [1, 20] }]); if (target) { await createEvent({ @@ -340,9 +341,9 @@ const favouriteController: AppController = async (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) { status.favourited = true; @@ -366,13 +367,10 @@ const favouritedByController: AppController = (c) => { /** https://docs.joinmastodon.org/methods/statuses/#boost */ const reblogStatusController: AppController = async (c) => { - const { conf } = c.var; - const eventId = c.req.param('id'); - const { signal } = c.req.raw; + const { conf, relay, user, signal } = c.var; - const event = await getEvent(eventId, { - kind: 1, - }); + const eventId = c.req.param('id'); + const event = await getEvent(eventId); if (!event) { return c.json({ error: 'Event not found.' }, 404); @@ -388,28 +386,28 @@ const reblogStatusController: AppController = async (c) => { await hydrateEvents({ events: [reblogEvent], - store: await Storages.db(), + relay, 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); }; /** https://docs.joinmastodon.org/methods/statuses/#unreblog */ const unreblogStatusController: AppController = async (c) => { - const { conf } = c.var; - const eventId = c.req.param('id'); - const pubkey = await c.get('signer')?.getPublicKey()!; - const store = await Storages.db(); + const { conf, relay, user } = c.var; - 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) { 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 }], ); @@ -432,20 +430,20 @@ const rebloggedByController: AppController = (c) => { }; const quotesController: AppController = async (c) => { - const id = c.req.param('id'); - const params = c.get('pagination'); - const store = await Storages.db(); + const { relay, user, pagination } = c.var; - 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) { return c.json({ error: 'Event not found.' }, 404); } - const quotes = await store - .query([{ kinds: [1, 20], '#q': [event.id], ...params }]) - .then((events) => hydrateEvents({ events, store })); + const quotes = await relay + .query([{ kinds: [1, 20], '#q': [event.id], ...pagination }]) + .then((events) => hydrateEvents({ events, relay })); - const viewerPubkey = await c.get('signer')?.getPublicKey(); + const viewerPubkey = await user?.signer.getPublicKey(); const statuses = await Promise.all( quotes.map((event) => renderStatus(event, { viewerPubkey })), @@ -460,14 +458,11 @@ const quotesController: AppController = async (c) => { /** https://docs.joinmastodon.org/methods/statuses/#bookmark */ const bookmarkController: AppController = async (c) => { - const { conf } = c.var; - const pubkey = await c.get('signer')?.getPublicKey()!; + const { conf, user } = c.var; + const pubkey = await user!.signer.getPublicKey(); const eventId = c.req.param('id'); - const event = await getEvent(eventId, { - kind: 1, - relations: ['author', 'event_stats', 'author_stats'], - }); + const event = await getEvent(eventId); if (event) { await updateListEvent( @@ -488,14 +483,12 @@ const bookmarkController: AppController = async (c) => { /** https://docs.joinmastodon.org/methods/statuses/#unbookmark */ const unbookmarkController: AppController = async (c) => { - const { conf } = c.var; - const pubkey = await c.get('signer')?.getPublicKey()!; + const { conf, user } = c.var; + + const pubkey = await user!.signer.getPublicKey(); const eventId = c.req.param('id'); - const event = await getEvent(eventId, { - kind: 1, - relations: ['author', 'event_stats', 'author_stats'], - }); + const event = await getEvent(eventId); if (event) { await updateListEvent( @@ -516,14 +509,12 @@ const unbookmarkController: AppController = async (c) => { /** https://docs.joinmastodon.org/methods/statuses/#pin */ const pinController: AppController = async (c) => { - const { conf } = c.var; - const pubkey = await c.get('signer')?.getPublicKey()!; + const { conf, user } = c.var; + + const pubkey = await user!.signer.getPublicKey(); const eventId = c.req.param('id'); - const event = await getEvent(eventId, { - kind: 1, - relations: ['author', 'event_stats', 'author_stats'], - }); + const event = await getEvent(eventId); if (event) { await updateListEvent( @@ -544,14 +535,13 @@ const pinController: AppController = async (c) => { /** https://docs.joinmastodon.org/methods/statuses/#unpin */ const unpinController: AppController = async (c) => { - const { conf } = c.var; - const pubkey = await c.get('signer')?.getPublicKey()!; + const { conf, user, signal } = c.var; + + const pubkey = await user!.signer.getPublicKey(); const eventId = c.req.param('id'); - const { signal } = c.req.raw; const event = await getEvent(eventId, { kind: 1, - relations: ['author', 'event_stats', 'author_stats'], signal, }); @@ -580,11 +570,10 @@ const zapSchema = z.object({ }); const zapController: AppController = async (c) => { - const { conf } = c.var; + const { conf, relay, signal } = c.var; + const body = await parseBody(c.req.raw); const result = zapSchema.safeParse(body); - const { signal } = c.req.raw; - const store = c.get('store'); if (!result.success) { return c.json({ error: 'Bad request', schema: result.error }, 400); @@ -611,7 +600,7 @@ const zapController: AppController = async (c) => { ); } } 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); lnurl = getLnurl(meta); if (target && lnurl) { @@ -638,19 +627,19 @@ const zapController: AppController = async (c) => { }; const zappedByController: AppController = async (c) => { - const id = c.req.param('id'); - const params = c.get('listPagination'); - const store = await Storages.db(); - const kysely = await Storages.kysely(); + const { db, relay } = c.var; - 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() .where('target_event_id', '=', id) .orderBy('amount_millisats', 'desc') - .limit(params.limit) - .offset(params.offset).execute(); + .limit(limit) + .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( zaps.map(async (zap) => { @@ -668,7 +657,7 @@ const zappedByController: AppController = async (c) => { }), )).filter(Boolean); - return paginatedList(c, params, results); + return paginatedList(c, { limit, offset }, results); }; export { diff --git a/packages/ditto/controllers/api/streaming.ts b/packages/ditto/controllers/api/streaming.ts index 4171e1be..b39f1db5 100644 --- a/packages/ditto/controllers/api/streaming.ts +++ b/packages/ditto/controllers/api/streaming.ts @@ -68,7 +68,7 @@ const limiter = new TTLCache(); const connections = new Set(); const streamingController: AppController = async (c) => { - const { conf } = c.var; + const { conf, relay } = c.var; const upgrade = c.req.header('upgrade'); const token = c.req.header('sec-websocket-protocol'); const stream = streamSchema.optional().catch(undefined).parse(c.req.query('stream')); @@ -93,7 +93,6 @@ const streamingController: AppController = async (c) => { const { socket, response } = Deno.upgradeWebSocket(c.req.raw, { protocol: token, idleTimeout: 30 }); - const store = await Storages.db(); const policy = pubkey ? new MuteListPolicy(pubkey, await Storages.admin()) : undefined; function send(e: StreamingEvent) { @@ -108,7 +107,7 @@ const streamingController: AppController = async (c) => { render: (event: NostrEvent) => Promise, ) { 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') { const event = msg[2]; @@ -119,7 +118,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); diff --git a/packages/ditto/controllers/api/suggestions.ts b/packages/ditto/controllers/api/suggestions.ts index 5dbf0d14..3af4f678 100644 --- a/packages/ditto/controllers/api/suggestions.ts +++ b/packages/ditto/controllers/api/suggestions.ts @@ -2,33 +2,32 @@ import { NostrFilter } from '@nostrify/nostrify'; import { matchFilter } from 'nostr-tools'; import { AppContext, AppController } from '@/app.ts'; +import { paginationSchema } from '@/schemas/pagination.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; import { paginated, paginatedList } from '@/utils/api.ts'; import { getTagSet } from '@/utils/tags.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; export const suggestionsV1Controller: AppController = async (c) => { - const signal = c.req.raw.signal; - const params = c.get('listPagination'); - const suggestions = await renderV2Suggestions(c, params, signal); + const { signal } = c.var; + const { offset, limit } = paginationSchema.parse(c.req.query()); + const suggestions = await renderV2Suggestions(c, { offset, limit }, signal); const accounts = suggestions.map(({ account }) => account); - return paginatedList(c, params, accounts); + return paginatedList(c, { offset, limit }, accounts); }; export const suggestionsV2Controller: AppController = async (c) => { - const signal = c.req.raw.signal; - const params = c.get('listPagination'); - const suggestions = await renderV2Suggestions(c, params, signal); - return paginatedList(c, params, suggestions); + const { signal } = c.var; + const { offset, limit } = paginationSchema.parse(c.req.query()); + const suggestions = await renderV2Suggestions(c, { offset, limit }, signal); + return paginatedList(c, { offset, limit }, suggestions); }; 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 store = c.get('store'); - const signer = c.get('signer'); - const pubkey = await signer?.getPublicKey(); + const pubkey = await user?.signer.getPublicKey(); const filters: NostrFilter[] = [ { 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 }); } - const events = await store.query(filters, { signal }); + const events = await relay.query(filters, { signal }); const adminPubkey = await conf.signer.getPublicKey(); 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 profiles = await store.query( + const profiles = await relay.query( [{ kinds: [0], authors, limit: authors.length }], { signal }, ) - .then((events) => hydrateEvents({ events, store, signal })); + .then((events) => hydrateEvents({ events, relay, signal })); return Promise.all(authors.map(async (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) => { - const { conf } = c.var; - const signal = c.req.raw.signal; - const params = c.get('pagination'); - const store = c.get('store'); + const { conf, relay, pagination, signal } = c.var; - const grants = await store.query( - [{ kinds: [30360], authors: [await conf.signer.getPublicKey()], ...params }], + const grants = await relay.query( + [{ kinds: [30360], authors: [await conf.signer.getPublicKey()], ...pagination }], { signal }, ); @@ -115,11 +111,11 @@ export const localSuggestionsController: AppController = async (c) => { } } - const profiles = await store.query( - [{ kinds: [0], authors: [...pubkeys], search: `domain:${conf.url.host}`, ...params }], + const profiles = await relay.query( + [{ kinds: [0], authors: [...pubkeys], search: `domain:${conf.url.host}`, ...pagination }], { signal }, ) - .then((events) => hydrateEvents({ store, events, signal })); + .then((events) => hydrateEvents({ relay, events, signal })); const suggestions = [...pubkeys].map((pubkey) => { const profile = profiles.find((event) => event.pubkey === pubkey); diff --git a/packages/ditto/controllers/api/timelines.ts b/packages/ditto/controllers/api/timelines.ts index a6f872b9..b8c74f41 100644 --- a/packages/ditto/controllers/api/timelines.ts +++ b/packages/ditto/controllers/api/timelines.ts @@ -15,8 +15,8 @@ const homeQuerySchema = z.object({ }); const homeTimelineController: AppController = async (c) => { - const params = c.get('pagination'); - const pubkey = await c.get('signer')?.getPublicKey()!; + const { user, pagination } = c.var; + const pubkey = await user?.signer.getPublicKey()!; const result = homeQuerySchema.safeParse(c.req.query()); if (!result.success) { @@ -26,7 +26,7 @@ const homeTimelineController: AppController = async (c) => { const { exclude_replies, only_media } = result.data; 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[] = []; @@ -90,35 +90,32 @@ const hashtagTimelineController: AppController = (c) => { }; const suggestedTimelineController: AppController = async (c) => { - const { conf } = c.var; - const store = c.get('store'); - const params = c.get('pagination'); + const { conf, relay, pagination } = c.var; - const [follows] = await store.query( + const [follows] = await relay.query( [{ kinds: [3], authors: [await conf.signer.getPublicKey()], limit: 1 }], ); 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. */ async function renderStatuses(c: AppContext, filters: NostrFilter[]) { - const { conf } = c.var; - const { signal } = c.req.raw; - const store = c.get('store'); + const { conf, relay, user, signal } = c.var; + const opts = { signal, timeout: conf.db.timeouts.timelines }; - const events = await store + const events = await relay .query(filters, opts) - .then((events) => hydrateEvents({ events, store, signal })); + .then((events) => hydrateEvents({ events, relay, signal })); if (!events.length) { return c.json([]); } - const viewerPubkey = await c.get('signer')?.getPublicKey(); + const viewerPubkey = await user?.signer.getPublicKey(); const statuses = (await Promise.all(events.map((event) => { if (event.kind === 6) { diff --git a/packages/ditto/controllers/api/translate.ts b/packages/ditto/controllers/api/translate.ts index de183e23..f9ff4dcd 100644 --- a/packages/ditto/controllers/api/translate.ts +++ b/packages/ditto/controllers/api/translate.ts @@ -17,8 +17,9 @@ const translateSchema = z.object({ }); const translateController: AppController = async (c) => { + const { user, signal } = c.var; + const result = translateSchema.safeParse(await parseBody(c.req.raw)); - const { signal } = c.req.raw; if (!result.success) { 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); } - const viewerPubkey = await c.get('signer')?.getPublicKey(); + const viewerPubkey = await user?.signer.getPublicKey(); if (lang.toLowerCase() === event?.language?.toLowerCase()) { return c.json({ error: 'Source and target languages are the same. No translation needed.' }, 400); diff --git a/packages/ditto/controllers/api/trends.ts b/packages/ditto/controllers/api/trends.ts index f14cf0b7..5af88557 100644 --- a/packages/ditto/controllers/api/trends.ts +++ b/packages/ditto/controllers/api/trends.ts @@ -52,8 +52,8 @@ const trendingTagsController: AppController = async (c) => { }; async function getTrendingHashtags(conf: DittoConf) { - const store = await Storages.db(); - const trends = await getTrendingTags(store, 't', await conf.signer.getPublicKey()); + const relay = await Storages.db(); + const trends = await getTrendingTags(relay, 't', await conf.signer.getPublicKey()); return trends.map((trend) => { const hashtag = trend.value; @@ -105,8 +105,8 @@ const trendingLinksController: AppController = async (c) => { }; async function getTrendingLinks(conf: DittoConf) { - const store = await Storages.db(); - const trends = await getTrendingTags(store, 'r', await conf.signer.getPublicKey()); + const relay = await Storages.db(); + const trends = await getTrendingTags(relay, 'r', await conf.signer.getPublicKey()); return Promise.all(trends.map(async (trend) => { const link = trend.value; @@ -140,11 +140,10 @@ async function getTrendingLinks(conf: DittoConf) { } const trendingStatusesController: AppController = async (c) => { - const { conf } = c.var; - const store = await Storages.db(); + const { conf, relay } = c.var; const { limit, offset, until } = paginationSchema.parse(c.req.query()); - const [label] = await store.query([{ + const [label] = await relay.query([{ kinds: [1985], '#L': ['pub.ditto.trends'], '#l': ['#e'], @@ -162,8 +161,8 @@ const trendingStatusesController: AppController = async (c) => { return c.json([]); } - const results = await store.query([{ kinds: [1, 20], ids }]) - .then((events) => hydrateEvents({ events, store })); + const results = await relay.query([{ kinds: [1, 20], ids }]) + .then((events) => hydrateEvents({ events, relay })); // Sort events in the order they appear in the label. const events = ids diff --git a/packages/ditto/controllers/frontend.ts b/packages/ditto/controllers/frontend.ts index ec9f11a5..d19a20cb 100644 --- a/packages/ditto/controllers/frontend.ts +++ b/packages/ditto/controllers/frontend.ts @@ -1,7 +1,6 @@ import { logi } from '@soapbox/logi'; import { AppMiddleware } from '@/app.ts'; -import { Storages } from '@/storages.ts'; import { getPathParams, MetadataEntities } from '@/utils/og-metadata.ts'; import { getInstanceMetadata } from '@/utils/instance.ts'; import { errorJson } from '@/utils/log.ts'; @@ -10,11 +9,14 @@ import { renderMetadata } from '@/views/meta.ts'; import { getAuthor, getEvent } from '@/queries.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts'; import { renderAccount } from '@/views/mastodon/accounts.ts'; +import { NStore } from '@nostrify/nostrify'; /** Placeholder to find & replace with metadata. */ const META_PLACEHOLDER = '' as const; 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'); try { @@ -23,7 +25,7 @@ export const frontendController: AppMiddleware = async (c) => { if (content.includes(META_PLACEHOLDER)) { const params = getPathParams(c.req.path); try { - const entities = await getEntities(params ?? {}); + const entities = await getEntities(relay, params ?? {}); const meta = renderMetadata(c.req.url, entities); return c.html(content.replace(META_PLACEHOLDER, meta)); } catch (e) { @@ -37,11 +39,9 @@ export const frontendController: AppMiddleware = async (c) => { } }; -async function getEntities(params: { acct?: string; statusId?: string }): Promise { - const store = await Storages.db(); - +async function getEntities(relay: NStore, params: { acct?: string; statusId?: string }): Promise { const entities: MetadataEntities = { - instance: await getInstanceMetadata(store), + instance: await getInstanceMetadata(relay), }; if (params.statusId) { diff --git a/packages/ditto/controllers/manifest.ts b/packages/ditto/controllers/manifest.ts index 2e75de04..70d42dea 100644 --- a/packages/ditto/controllers/manifest.ts +++ b/packages/ditto/controllers/manifest.ts @@ -1,10 +1,11 @@ import { AppController } from '@/app.ts'; -import { Storages } from '@/storages.ts'; import { WebManifestCombined } from '@/types/webmanifest.ts'; import { getInstanceMetadata } from '@/utils/instance.ts'; 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 = { description: meta.about, diff --git a/packages/ditto/controllers/nostr/relay-info.ts b/packages/ditto/controllers/nostr/relay-info.ts index d4721cdc..50702c23 100644 --- a/packages/ditto/controllers/nostr/relay-info.ts +++ b/packages/ditto/controllers/nostr/relay-info.ts @@ -1,13 +1,12 @@ import denoJson from 'deno.json' with { type: 'json' }; import { AppController } from '@/app.ts'; -import { Storages } from '@/storages.ts'; import { getInstanceMetadata } from '@/utils/instance.ts'; const relayInfoController: AppController = async (c) => { - const { conf } = c.var; - const store = await Storages.db(); - const meta = await getInstanceMetadata(store, c.req.raw.signal); + const { conf, relay, signal } = c.var; + + const meta = await getInstanceMetadata(relay, signal); c.res.headers.set('access-control-allow-origin', '*'); diff --git a/packages/ditto/controllers/nostr/relay.ts b/packages/ditto/controllers/nostr/relay.ts index 0284ce64..191aed36 100644 --- a/packages/ditto/controllers/nostr/relay.ts +++ b/packages/ditto/controllers/nostr/relay.ts @@ -18,7 +18,7 @@ import { AppController } from '@/app.ts'; import { relayInfoController } from '@/controllers/nostr/relay-info.ts'; import * as pipeline from '@/pipeline.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 { purifyEvent } from '@/utils/purify.ts'; import { Time } from '@/utils/time.ts'; @@ -42,7 +42,7 @@ const limiters = { const connections = new Set(); /** 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(); if (ip) { @@ -133,10 +133,8 @@ function connectStream(socket: WebSocket, ip: string | undefined, conf: DittoCon controllers.get(subId)?.abort(); controllers.set(subId, controller); - const store = await Storages.db(); - 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); } } 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. */ async function handleCount([_, subId, ...filters]: NostrClientCOUNT): Promise { if (rateLimited(limiters.req)) return; - const store = await Storages.db(); - const { count } = await store.count(filters, { timeout: conf.db.timeouts.relay }); + const { count } = await relay.count(filters, { timeout: conf.db.timeouts.relay }); 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 { conf } = c.var; + const { conf, relay } = c.var; const upgrade = c.req.header('upgrade'); // 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 }); - connectStream(socket, ip, conf); + connectStream(conf, relay as DittoPgStore, socket, ip); return response; }; diff --git a/packages/ditto/controllers/well-known/nostr.ts b/packages/ditto/controllers/well-known/nostr.ts index 4fd366e7..ee442788 100644 --- a/packages/ditto/controllers/well-known/nostr.ts +++ b/packages/ditto/controllers/well-known/nostr.ts @@ -12,17 +12,17 @@ const emptyResult: NostrJson = { names: {}, relays: {} }; * https://github.com/nostr-protocol/nips/blob/master/05.md */ const nostrController: AppController = async (c) => { + const { relay } = c.var; + // If there are no query parameters, this will always return an empty result. if (!Object.entries(c.req.queries()).length) { c.header('Cache-Control', 'max-age=31536000, public, immutable, stale-while-revalidate=86400'); return c.json(emptyResult); } - const store = c.get('store'); - const result = nameSchema.safeParse(c.req.query('name')); 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) { // Not found, cache for 5 minutes. diff --git a/packages/ditto/middleware/auth98Middleware.ts b/packages/ditto/middleware/auth98Middleware.ts index 573853f0..18fce5fd 100644 --- a/packages/ditto/middleware/auth98Middleware.ts +++ b/packages/ditto/middleware/auth98Middleware.ts @@ -22,8 +22,13 @@ function auth98Middleware(opts: ParseAuthRequestOpts = {}): AppMiddleware { const result = await parseAuthRequest(req, opts); if (result.success) { - c.set('signer', new ReadOnlySigner(result.data.pubkey)); - c.set('proof', result.data); + const user = { + relay: c.var.relay, + signer: new ReadOnlySigner(result.data.pubkey), + ...c.var.user, + }; + + c.set('user', user); } await next(); @@ -71,7 +76,7 @@ function withProof( opts?: ParseAuthRequestOpts, ): AppMiddleware { return async (c, next) => { - const signer = c.get('signer'); + const signer = c.var.user?.signer; const pubkey = await signer?.getPublicKey(); const proof = c.get('proof') || await obtainProof(c, opts); @@ -84,7 +89,13 @@ function withProof( c.set('proof', proof); if (!signer) { - c.set('signer', new ReadOnlySigner(proof.pubkey)); + const user = { + relay: c.var.relay, + signer: new ReadOnlySigner(proof.pubkey), + ...c.var.user, + }; + + c.set('user', user); } await handler(c, proof, next); @@ -96,7 +107,7 @@ function withProof( /** Get the proof over Nostr Connect. */ async function obtainProof(c: AppContext, opts?: ParseAuthRequestOpts) { - const signer = c.get('signer'); + const signer = c.var.user?.signer; if (!signer) { throw new HTTPException(401, { res: c.json({ error: 'No way to sign Nostr event' }, 401), diff --git a/packages/ditto/middleware/paginationMiddleware.ts b/packages/ditto/middleware/paginationMiddleware.ts deleted file mode 100644 index b1f1e2f3..00000000 --- a/packages/ditto/middleware/paginationMiddleware.ts +++ /dev/null @@ -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(); -}; diff --git a/packages/ditto/middleware/requireSigner.ts b/packages/ditto/middleware/requireSigner.ts deleted file mode 100644 index 7733b26f..00000000 --- a/packages/ditto/middleware/requireSigner.ts +++ /dev/null @@ -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 } }> = - 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(); - }; diff --git a/packages/ditto/middleware/signerMiddleware.ts b/packages/ditto/middleware/signerMiddleware.ts deleted file mode 100644 index deea86b3..00000000 --- a/packages/ditto/middleware/signerMiddleware.ts +++ /dev/null @@ -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(); -}; diff --git a/packages/ditto/middleware/storeMiddleware.ts b/packages/ditto/middleware/storeMiddleware.ts deleted file mode 100644 index f69712a3..00000000 --- a/packages/ditto/middleware/storeMiddleware.ts +++ /dev/null @@ -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(); -}; diff --git a/packages/ditto/middleware/uploaderMiddleware.ts b/packages/ditto/middleware/uploaderMiddleware.ts index 10cd3d2b..2a3cffd3 100644 --- a/packages/ditto/middleware/uploaderMiddleware.ts +++ b/packages/ditto/middleware/uploaderMiddleware.ts @@ -6,7 +6,8 @@ import { AppMiddleware } from '@/app.ts'; /** Set an uploader for the user. */ export const uploaderMiddleware: AppMiddleware = async (c, next) => { - const { signer, conf } = c.var; + const { user, conf } = c.var; + const signer = user?.signer; switch (conf.uploader) { case 's3': diff --git a/packages/ditto/pipeline.ts b/packages/ditto/pipeline.ts index 602d0e2b..815229a0 100644 --- a/packages/ditto/pipeline.ts +++ b/packages/ditto/pipeline.ts @@ -137,7 +137,7 @@ function isProtectedEvent(event: NostrEvent): boolean { /** Hydrate the event with the user, if applicable. */ async function hydrateEvent(event: DittoEvent, signal: AbortSignal): Promise { - await hydrateEvents({ events: [event], store: await Storages.db(), signal }); + await hydrateEvents({ events: [event], relay: await Storages.db(), signal }); } /** Maybe store the event, if eligible. */ diff --git a/packages/ditto/queries.ts b/packages/ditto/queries.ts index f60d3daa..d4f0cb11 100644 --- a/packages/ditto/queries.ts +++ b/packages/ditto/queries.ts @@ -19,13 +19,13 @@ interface GetEventOpts { /** * Get a Nostr event by its ID. - * @deprecated Use `store.query` directly. + * @deprecated Use `relay.query` directly. */ const getEvent = async ( id: string, opts: GetEventOpts = {}, ): Promise => { - const store = await Storages.db(); + const relay = await Storages.db(); const { kind, signal = AbortSignal.timeout(1000) } = opts; const filter: NostrFilter = { ids: [id], limit: 1 }; @@ -33,23 +33,23 @@ const getEvent = async ( filter.kinds = [kind]; } - return await store.query([filter], { limit: 1, signal }) - .then((events) => hydrateEvents({ events, store, signal })) + return await relay.query([filter], { limit: 1, signal }) + .then((events) => hydrateEvents({ events, relay, signal })) .then(([event]) => event); }; /** * Get a Nostr `set_medatadata` event for a user's pubkey. - * @deprecated Use `store.query` directly. + * @deprecated Use `relay.query` directly. */ async function getAuthor(pubkey: string, opts: GetEventOpts = {}): Promise { - const store = await Storages.db(); + const relay = await Storages.db(); 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); - await hydrateEvents({ events: [event], store, signal }); + await hydrateEvents({ events: [event], relay, signal }); return event; } diff --git a/packages/ditto/storages/hydrate.test.ts b/packages/ditto/storages/hydrate.test.ts index 1527f321..ebafa6af 100644 --- a/packages/ditto/storages/hydrate.test.ts +++ b/packages/ditto/storages/hydrate.test.ts @@ -18,7 +18,7 @@ Deno.test('hydrateEvents(): author --- WITHOUT stats', async () => { await hydrateEvents({ events: [event1], - store: relay, + relay, kysely: db.kysely, }); @@ -43,7 +43,7 @@ Deno.test('hydrateEvents(): repost --- WITHOUT stats', async () => { await hydrateEvents({ events: [event6], - store: relay, + relay, kysely: db.kysely, }); @@ -72,7 +72,7 @@ Deno.test('hydrateEvents(): quote repost --- WITHOUT stats', async () => { await hydrateEvents({ events: [event1quoteRepost], - store: relay, + relay, kysely: db.kysely, }); @@ -102,7 +102,7 @@ Deno.test('hydrateEvents(): repost of quote repost --- WITHOUT stats', async () await hydrateEvents({ events: [event6], - store: relay, + relay, kysely: db.kysely, }); @@ -131,7 +131,7 @@ Deno.test('hydrateEvents(): report pubkey and post // kind 1984 --- WITHOUT stat await hydrateEvents({ events: [reportEvent], - store: relay, + relay, kysely: db.kysely, }); @@ -161,7 +161,7 @@ Deno.test('hydrateEvents(): zap sender, zap amount, zapped post // kind 9735 --- await hydrateEvents({ events: [zapReceipt], - store: relay, + relay, kysely: db.kysely, }); diff --git a/packages/ditto/storages/hydrate.ts b/packages/ditto/storages/hydrate.ts index 96341a1f..5bf51f96 100644 --- a/packages/ditto/storages/hydrate.ts +++ b/packages/ditto/storages/hydrate.ts @@ -15,14 +15,14 @@ import { Storages } from '@/storages.ts'; interface HydrateOpts { events: DittoEvent[]; - store: NStore; + relay: NStore; signal?: AbortSignal; kysely?: Kysely; } /** Hydrate events using the provided storage. */ async function hydrateEvents(opts: HydrateOpts): Promise { - const { events, store, signal, kysely = await Storages.kysely() } = opts; + const { events, relay, signal, kysely = await Storages.kysely() } = opts; if (!events.length) { return events; @@ -30,23 +30,23 @@ async function hydrateEvents(opts: HydrateOpts): Promise { 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); } - for (const event of await gatherQuotes({ events: cache, store, signal })) { + for (const event of await gatherQuotes({ events: cache, relay, signal })) { 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); } - for (const event of await gatherUsers({ events: cache, store, signal })) { + for (const event of await gatherUsers({ events: cache, relay, signal })) { 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); } @@ -199,7 +199,7 @@ export function assembleEvents( } /** Collect event targets (eg reposts, quote posts, reacted posts, etc.) */ -function gatherRelatedEvents({ events, store, signal }: HydrateOpts): Promise { +function gatherRelatedEvents({ events, relay, signal }: HydrateOpts): Promise { const ids = new Set(); for (const event of events) { @@ -234,14 +234,14 @@ function gatherRelatedEvents({ events, store, signal }: HydrateOpts): Promise { +function gatherQuotes({ events, relay, signal }: HydrateOpts): Promise { const ids = new Set(); for (const event of events) { @@ -253,14 +253,14 @@ function gatherQuotes({ events, store, signal }: HydrateOpts): Promise { +async function gatherProfiles({ events, relay, signal }: HydrateOpts): Promise { const pubkeys = new Set(); for (const event of events) { @@ -300,7 +300,7 @@ async function gatherProfiles({ events, store, signal }: HydrateOpts): Promise { +async function gatherUsers({ events, relay, signal }: HydrateOpts): Promise { const pubkeys = new Set(events.map((event) => event.pubkey)); if (!pubkeys.size) { return Promise.resolve([]); } - return store.query( + return relay.query( [{ kinds: [30382], authors: [await Conf.signer.getPublicKey()], '#d': [...pubkeys], limit: pubkeys.size }], { signal }, ); } /** Collect info events from the events. */ -async function gatherInfo({ events, store, signal }: HydrateOpts): Promise { +async function gatherInfo({ events, relay, signal }: HydrateOpts): Promise { const ids = new Set(); for (const event of events) { @@ -344,7 +344,7 @@ async function gatherInfo({ events, store, signal }: HydrateOpts): Promise + !c.var.user && author.tags.some(([name, value, ns]) => name === 'l' && value === '!no-unauthenticated' && ns === 'com.atproto.label.defs#selfLabel' diff --git a/packages/ditto/views.ts b/packages/ditto/views.ts index 562043db..879c3196 100644 --- a/packages/ditto/views.ts +++ b/packages/ditto/views.ts @@ -1,7 +1,7 @@ import { NostrEvent, NostrFilter } from '@nostrify/nostrify'; import { AppContext } from '@/app.ts'; -import { Storages } from '@/storages.ts'; +import { paginationSchema } from '@/schemas/pagination.ts'; import { renderAccount } from '@/views/mastodon/accounts.ts'; import { renderStatus } from '@/views/mastodon/statuses.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 { relay } = c.var; - const store = await Storages.db(); - - const events = await store.query(filters, { signal }) + const events = await relay.query(filters, { signal }) // Deduplicate by author. .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); const accounts = await Promise.all( @@ -43,14 +42,13 @@ async function renderEventAccounts(c: AppContext, filters: NostrFilter[], opts?: } 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 store = await Storages.db(); - const signal = c.req.raw.signal; + const { relay, signal } = c.var; - const events = await store.query([{ kinds: [0], authors }], { signal }) - .then((events) => hydrateEvents({ events, store, signal })); + const events = await relay.query([{ kinds: [0], authors }], { signal }) + .then((events) => hydrateEvents({ events, relay, signal })); const accounts = await Promise.all( authors.map((pubkey) => { @@ -72,11 +70,11 @@ async function renderStatuses(c: AppContext, ids: string[], signal = AbortSignal return c.json([]); } - const store = await Storages.db(); - const { limit } = c.get('pagination'); + const { user, relay, pagination } = c.var; + const { limit } = pagination; - const events = await store.query([{ kinds: [1, 20], ids, limit }], { signal }) - .then((events) => hydrateEvents({ events, store, signal })); + const events = await relay.query([{ kinds: [1, 20], ids, limit }], { signal }) + .then((events) => hydrateEvents({ events, relay, signal })); if (!events.length) { 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 viewerPubkey = await c.get('signer')?.getPublicKey(); + const viewerPubkey = await user?.signer.getPublicKey(); const statuses = await Promise.all( sortedEvents.map((event) => renderStatus(event, { viewerPubkey })), diff --git a/packages/mastoapi/auth/aes.bench.ts b/packages/mastoapi/auth/aes.bench.ts new file mode 100644 index 00000000..3b46f436 --- /dev/null +++ b/packages/mastoapi/auth/aes.bench.ts @@ -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); +}); diff --git a/packages/mastoapi/auth/aes.test.ts b/packages/mastoapi/auth/aes.test.ts new file mode 100644 index 00000000..ee735731 --- /dev/null +++ b/packages/mastoapi/auth/aes.test.ts @@ -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)); +}); diff --git a/packages/mastoapi/auth/aes.ts b/packages/mastoapi/auth/aes.ts new file mode 100644 index 00000000..983fc39c --- /dev/null +++ b/packages/mastoapi/auth/aes.ts @@ -0,0 +1,17 @@ +/** Encrypt data with AES-GCM and a secret key. */ +export async function aesEncrypt(sk: Uint8Array, plaintext: Uint8Array): Promise { + 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 { + 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); +} diff --git a/packages/mastoapi/auth/token.bench.ts b/packages/mastoapi/auth/token.bench.ts new file mode 100644 index 00000000..5df41d0f --- /dev/null +++ b/packages/mastoapi/auth/token.bench.ts @@ -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); +}); diff --git a/packages/mastoapi/auth/token.test.ts b/packages/mastoapi/auth/token.test.ts new file mode 100644 index 00000000..6f002267 --- /dev/null +++ b/packages/mastoapi/auth/token.test.ts @@ -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'); +}); diff --git a/packages/mastoapi/auth/token.ts b/packages/mastoapi/auth/token.ts new file mode 100644 index 00000000..8d71ed6f --- /dev/null +++ b/packages/mastoapi/auth/token.ts @@ -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 { + const { bytes: sk } = bech32.decodeToBytes(token); + const buffer = await crypto.subtle.digest('SHA-256', sk); + + return new Uint8Array(buffer); +} diff --git a/packages/api/deno.json b/packages/mastoapi/deno.json similarity index 75% rename from packages/api/deno.json rename to packages/mastoapi/deno.json index a8bbb3f5..f9abac55 100644 --- a/packages/api/deno.json +++ b/packages/mastoapi/deno.json @@ -1,5 +1,5 @@ { - "name": "@ditto/api", + "name": "@ditto/mastoapi", "version": "1.1.0", "exports": { "./middleware": "./middleware/mod.ts" diff --git a/packages/mastoapi/middleware/mod.ts b/packages/mastoapi/middleware/mod.ts new file mode 100644 index 00000000..7cdd6748 --- /dev/null +++ b/packages/mastoapi/middleware/mod.ts @@ -0,0 +1,2 @@ +export { paginationMiddleware } from './paginationMiddleware.ts'; +export { userMiddleware } from './userMiddleware.ts'; diff --git a/packages/mastoapi/middleware/paginationMiddleware.ts b/packages/mastoapi/middleware/paginationMiddleware.ts new file mode 100644 index 00000000..cca64229 --- /dev/null +++ b/packages/mastoapi/middleware/paginationMiddleware.ts @@ -0,0 +1,81 @@ +import { paginated, paginatedList } from '../pagination/paginate.ts'; +import { paginationSchema } from '../pagination/schema.ts'; + +import type { DittoMiddleware } from '@ditto/router'; +import type { NostrEvent } from '@nostrify/nostrify'; + +interface Pagination { + since?: number; + until?: number; + limit: number; +} + +interface ListPagination { + limit: number; + offset: number; +} + +type HeaderRecord = Record; +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(); + }; +} diff --git a/packages/mastoapi/middleware/userMiddleware.ts b/packages/mastoapi/middleware/userMiddleware.ts new file mode 100644 index 00000000..29a7b6f3 --- /dev/null +++ b/packages/mastoapi/middleware/userMiddleware.ts @@ -0,0 +1,128 @@ +import { HTTPException } from '@hono/hono/http-exception'; +import { type NostrSigner, type NRelay, 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 type { DittoConf } from '@ditto/conf'; +import type { DittoDB } from '@ditto/db'; +import type { DittoMiddleware } from '@ditto/router'; + +interface User { + signer: NostrSigner; + relay: NRelay; +} + +/** We only accept "Bearer" type. */ +const BEARER_REGEX = new RegExp(`^Bearer (${nip19.BECH32_REGEX.source})$`); + +export function userMiddleware(opts: { privileged: true; required: false }): never; +// @ts-ignore The types are right. +export function userMiddleware(opts: { privileged: false; required: true }): DittoMiddleware<{ user: User }>; +export function userMiddleware(opts: { privileged: true; required?: boolean }): DittoMiddleware<{ user: User }>; +export function userMiddleware(opts: { privileged: false; required?: boolean }): DittoMiddleware<{ user?: User }>; +export function userMiddleware(opts: { privileged: boolean; required?: boolean }): DittoMiddleware<{ user?: User }> { + const { privileged, required = privileged } = opts; + + if (privileged && !required) { + throw new Error('Privileged middleware requires authorization.'); + } + + return async (c, next) => { + const header = c.req.header('authorization'); + + if (!header && required) { + throw new HTTPException(403, { message: 'Authorization required.' }); + } + + if (header) { + const user: User = { + signer: await getSigner(header, c.var), + relay: c.var.relay, // TODO: set user's relay + }; + + c.set('user', user); + } + + if (privileged) { + // TODO: add back nip98 auth + throw new HTTPException(500); + } + + await next(); + }; +} + +interface GetSignerOpts { + db: DittoDB; + conf: DittoConf; + relay: NRelay; +} + +function getSigner(header: string, opts: GetSignerOpts): NostrSigner | Promise { + const match = header.match(BEARER_REGEX); + + if (!match) { + throw new HTTPException(400, { message: 'Invalid Authorization header.' }); + } + + const [_, bech32] = match; + + if (isToken(bech32)) { + return getSignerFromToken(bech32, opts); + } else { + return getSignerFromNip19(bech32); + } +} + +function isToken(value: string): value is `token1${string}` { + return value.startsWith('token1'); +} + +async function getSignerFromToken(token: `token1${string}`, opts: GetSignerOpts): Promise { + const { conf, db, relay } = opts; + + 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.' }); +} diff --git a/packages/mastoapi/pagination/link-header.test.ts b/packages/mastoapi/pagination/link-header.test.ts new file mode 100644 index 00000000..db41eaa0 --- /dev/null +++ b/packages/mastoapi/pagination/link-header.test.ts @@ -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(), + '; rel="next", ; 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(), + '; rel="next", ; rel="prev"', + ); +}); diff --git a/packages/mastoapi/pagination/link-header.ts b/packages/mastoapi/pagination/link-header.ts new file mode 100644 index 00000000..648b4aab --- /dev/null +++ b/packages/mastoapi/pagination/link-header.ts @@ -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"`; +} diff --git a/packages/mastoapi/pagination/paginate.test.ts b/packages/mastoapi/pagination/paginate.test.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/mastoapi/pagination/paginate.ts b/packages/mastoapi/pagination/paginate.ts new file mode 100644 index 00000000..2da2e478 --- /dev/null +++ b/packages/mastoapi/pagination/paginate.ts @@ -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; + +/** 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); +} diff --git a/packages/mastoapi/pagination/schema.test.ts b/packages/mastoapi/pagination/schema.test.ts new file mode 100644 index 00000000..94be9091 --- /dev/null +++ b/packages/mastoapi/pagination/schema.test.ts @@ -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, + }); +}); diff --git a/packages/mastoapi/pagination/schema.ts b/packages/mastoapi/pagination/schema.ts new file mode 100644 index 00000000..89e3c5f6 --- /dev/null +++ b/packages/mastoapi/pagination/schema.ts @@ -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), +}); diff --git a/packages/mastoapi/signers/ConnectSigner.ts b/packages/mastoapi/signers/ConnectSigner.ts new file mode 100644 index 00000000..e3671413 --- /dev/null +++ b/packages/mastoapi/signers/ConnectSigner.ts @@ -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; + + constructor(private opts: ConnectSignerOpts) { + this.signer = this.init(opts.signer); + } + + async init(signer: NostrSigner): Promise { + return new NConnectSigner({ + encryption: 'nip44', + pubkey: this.opts.bunkerPubkey, + relay: this.opts.relay, + signer, + timeout: 60_000, + }); + } + + async signEvent(event: Omit): Promise { + 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 => { + 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 => { + 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 => { + 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 => { + 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 { + return this.opts.userPubkey; + } + + /** Get the user's relays if they passed in an `nprofile` auth token. */ + async getRelays(): Promise> { + return this.opts.relays?.reduce>((acc, relay) => { + acc[relay] = { read: true, write: true }; + return acc; + }, {}) ?? {}; + } +} diff --git a/packages/mastoapi/signers/ReadOnlySigner.ts b/packages/mastoapi/signers/ReadOnlySigner.ts new file mode 100644 index 00000000..74740b03 --- /dev/null +++ b/packages/mastoapi/signers/ReadOnlySigner.ts @@ -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 { + throw new HTTPException(401, { + message: 'Log in with Nostr Connect to sign events', + }); + } + + async getPublicKey(): Promise { + return this.pubkey; + } +} diff --git a/packages/router/DittoApp.test.ts b/packages/router/DittoApp.test.ts index 83da5bca..329b9dbc 100644 --- a/packages/router/DittoApp.test.ts +++ b/packages/router/DittoApp.test.ts @@ -1,5 +1,5 @@ import { DittoConf } from '@ditto/conf'; -import { DittoDB } from '@ditto/db'; +import { DittoPolyPg } from '@ditto/db'; import { Hono } from '@hono/hono'; import { MockRelay } from '@nostrify/nostrify/test'; @@ -7,7 +7,7 @@ import { DittoApp } from './DittoApp.ts'; import { DittoRoute } from './DittoRoute.ts'; Deno.test('DittoApp', async () => { - await using db = DittoDB.create('memory://'); + await using db = DittoPolyPg.create('memory://'); const conf = new DittoConf(new Map()); const relay = new MockRelay(); diff --git a/packages/router/DittoEnv.ts b/packages/router/DittoEnv.ts index 761bc3f8..7f399e62 100644 --- a/packages/router/DittoEnv.ts +++ b/packages/router/DittoEnv.ts @@ -1,5 +1,5 @@ import type { DittoConf } from '@ditto/conf'; -import type { DittoDatabase } from '@ditto/db'; +import type { DittoDB } from '@ditto/db'; import type { Env } from '@hono/hono'; import type { NRelay } from '@nostrify/nostrify'; @@ -13,7 +13,7 @@ export interface DittoEnv extends Env { * Database object. * @deprecated Store data as Nostr events instead. */ - db: DittoDatabase; + db: DittoDB; /** Abort signal for the request. */ signal: AbortSignal; }; diff --git a/packages/router/mod.ts b/packages/router/mod.ts index 8e9d1d46..a4361da6 100644 --- a/packages/router/mod.ts +++ b/packages/router/mod.ts @@ -2,3 +2,4 @@ export { DittoApp } from './DittoApp.ts'; export { DittoRoute } from './DittoRoute.ts'; export type { DittoEnv } from './DittoEnv.ts'; +export type { DittoMiddleware } from './DittoMiddleware.ts'; From e1bf86eb21b3674047c55c329996419d4f594ad3 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 20 Feb 2025 14:45:44 -0600 Subject: [PATCH 03/23] Make auth middleware work again (in a hacky way for now) --- packages/ditto/app.ts | 56 ++++++++++--------- packages/ditto/controllers/api/cashu.ts | 2 + packages/ditto/middleware/auth98Middleware.ts | 7 +-- .../ditto/middleware/swapNutzapsMiddleware.ts | 36 +++++------- packages/ditto/utils/api.ts | 8 +-- 5 files changed, 54 insertions(+), 55 deletions(-) diff --git a/packages/ditto/app.ts b/packages/ditto/app.ts index 9944426c..8b291a66 100644 --- a/packages/ditto/app.ts +++ b/packages/ditto/app.ts @@ -7,6 +7,7 @@ import { type Context, Handler, Input as HonoInput, MiddlewareHandler } from '@h import { every } from '@hono/hono/combine'; import { cors } from '@hono/hono/cors'; import { serveStatic } from '@hono/hono/deno'; +import { createFactory } from '@hono/hono/factory'; import { NostrEvent, NostrSigner, NRelay, NUploader } from '@nostrify/nostrify'; import '@/startup.ts'; @@ -137,7 +138,7 @@ import { metricsController } from '@/controllers/metrics.ts'; import { manifestController } from '@/controllers/manifest.ts'; import { nodeInfoController, nodeInfoSchemaController } from '@/controllers/well-known/nodeinfo.ts'; import { nostrController } from '@/controllers/well-known/nostr.ts'; -import { auth98Middleware, requireProof, requireRole } from '@/middleware/auth98Middleware.ts'; +import { auth98Middleware, requireProof as _requireProof, requireRole } from '@/middleware/auth98Middleware.ts'; import { cacheControlMiddleware } from '@/middleware/cacheControlMiddleware.ts'; import { cspMiddleware } from '@/middleware/cspMiddleware.ts'; import { metricsMiddleware } from '@/middleware/metricsMiddleware.ts'; @@ -197,7 +198,10 @@ const ratelimit = every( rateLimitMiddleware(300, Time.minutes(5), false), ); +const factory = createFactory(); const requireSigner = userMiddleware({ privileged: false, required: true }); +const requireAdmin = factory.createHandlers(requireSigner, requireRole('admin')); +const requireProof = factory.createHandlers(requireSigner, _requireProof()); app.use('/api/*', metricsMiddleware, ratelimit, paginationMiddleware(), logiMiddleware); app.use('/.well-known/*', metricsMiddleware, ratelimit, logiMiddleware); @@ -258,7 +262,7 @@ app.post('/oauth/revoke', revokeTokenController); app.post('/oauth/authorize', oauthAuthorizeController); app.get('/oauth/authorize', oauthController); -app.post('/api/v1/accounts', requireProof(), createAccountController); +app.post('/api/v1/accounts', ...requireProof, createAccountController); app.get('/api/v1/accounts/verify_credentials', requireSigner, verifyCredentialsController); app.patch('/api/v1/accounts/update_credentials', requireSigner, updateCredentialsController); app.get('/api/v1/accounts/search', accountSearchController); @@ -372,25 +376,25 @@ app.get('/api/v1/bookmarks', requireSigner, bookmarksController); app.get('/api/v1/blocks', requireSigner, blocksController); app.get('/api/v1/mutes', requireSigner, mutesController); -app.get('/api/v1/markers', requireProof(), markersController); -app.post('/api/v1/markers', requireProof(), updateMarkersController); +app.get('/api/v1/markers', ...requireProof, markersController); +app.post('/api/v1/markers', ...requireProof, updateMarkersController); app.get('/api/v1/push/subscription', requireSigner, getSubscriptionController); -app.post('/api/v1/push/subscription', requireProof(), pushSubscribeController); +app.post('/api/v1/push/subscription', ...requireProof, 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/:emoji', reactionsController); app.put('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', requireSigner, reactionController); app.delete('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', requireSigner, deleteReactionController); -app.get('/api/v1/pleroma/admin/config', requireRole('admin'), configController); -app.post('/api/v1/pleroma/admin/config', requireRole('admin'), updateConfigController); -app.delete('/api/v1/pleroma/admin/statuses/:id', requireRole('admin'), pleromaAdminDeleteStatusController); +app.get('/api/v1/pleroma/admin/config', ...requireAdmin, configController); +app.post('/api/v1/pleroma/admin/config', ...requireAdmin, updateConfigController); +app.delete('/api/v1/pleroma/admin/statuses/:id', ...requireAdmin, pleromaAdminDeleteStatusController); -app.get('/api/v1/admin/ditto/relays', requireRole('admin'), adminRelaysController); -app.put('/api/v1/admin/ditto/relays', requireRole('admin'), adminSetRelaysController); +app.get('/api/v1/admin/ditto/relays', ...requireAdmin, adminRelaysController); +app.put('/api/v1/admin/ditto/relays', ...requireAdmin, adminSetRelaysController); -app.put('/api/v1/admin/ditto/instance', requireRole('admin'), updateInstanceController); +app.put('/api/v1/admin/ditto/instance', ...requireAdmin, updateInstanceController); app.post('/api/v1/ditto/names', requireSigner, nameRequestController); app.get('/api/v1/ditto/names', requireSigner, nameRequestsController); @@ -399,7 +403,7 @@ app.get('/api/v1/ditto/captcha', rateLimitMiddleware(3, Time.minutes(1)), captch app.post( '/api/v1/ditto/captcha/:id/verify', rateLimitMiddleware(8, Time.minutes(1)), - requireProof(), + ...requireProof, captchaVerifyController, ); @@ -410,8 +414,8 @@ app.get( ); 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.delete('/api/v1/admin/ditto/zap_splits', requireRole('admin'), deleteZapSplitsController); +app.put('/api/v1/admin/ditto/zap_splits', ...requireAdmin, updateZapSplitsController); +app.delete('/api/v1/admin/ditto/zap_splits', ...requireAdmin, deleteZapSplitsController); app.post('/api/v1/ditto/zap', requireSigner, zapController); app.get('/api/v1/ditto/statuses/:id{[0-9a-f]{64}}/zapped_by', zappedByController); @@ -419,35 +423,35 @@ app.get('/api/v1/ditto/statuses/:id{[0-9a-f]{64}}/zapped_by', zappedByController app.route('/api/v1/ditto/cashu', cashuApp); app.post('/api/v1/reports', requireSigner, reportController); -app.get('/api/v1/admin/reports', requireSigner, requireRole('admin'), adminReportsController); -app.get('/api/v1/admin/reports/:id{[0-9a-f]{64}}', requireSigner, requireRole('admin'), adminReportController); +app.get('/api/v1/admin/reports', requireSigner, ...requireAdmin, adminReportsController); +app.get('/api/v1/admin/reports/:id{[0-9a-f]{64}}', requireSigner, ...requireAdmin, adminReportController); app.post( '/api/v1/admin/reports/:id{[0-9a-f]{64}}/resolve', requireSigner, - requireRole('admin'), + ...requireAdmin, adminReportResolveController, ); app.post( '/api/v1/admin/reports/:id{[0-9a-f]{64}}/reopen', requireSigner, - requireRole('admin'), + ...requireAdmin, adminReportReopenController, ); -app.get('/api/v1/admin/accounts', requireRole('admin'), adminAccountsController); -app.post('/api/v1/admin/accounts/:id{[0-9a-f]{64}}/action', requireSigner, requireRole('admin'), adminActionController); +app.get('/api/v1/admin/accounts', ...requireAdmin, adminAccountsController); +app.post('/api/v1/admin/accounts/:id{[0-9a-f]{64}}/action', requireSigner, ...requireAdmin, adminActionController); app.post( '/api/v1/admin/accounts/:id{[0-9a-f]{64}}/approve', requireSigner, - requireRole('admin'), + ...requireAdmin, 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', requireSigner, ...requireAdmin, adminRejectController); -app.put('/api/v1/pleroma/admin/users/tag', requireRole('admin'), pleromaAdminTagController); -app.delete('/api/v1/pleroma/admin/users/tag', requireRole('admin'), pleromaAdminUntagController); -app.patch('/api/v1/pleroma/admin/users/suggest', requireRole('admin'), pleromaAdminSuggestController); -app.patch('/api/v1/pleroma/admin/users/unsuggest', requireRole('admin'), pleromaAdminUnsuggestController); +app.put('/api/v1/pleroma/admin/users/tag', ...requireAdmin, pleromaAdminTagController); +app.delete('/api/v1/pleroma/admin/users/tag', ...requireAdmin, pleromaAdminUntagController); +app.patch('/api/v1/pleroma/admin/users/suggest', ...requireAdmin, pleromaAdminSuggestController); +app.patch('/api/v1/pleroma/admin/users/unsuggest', ...requireAdmin, pleromaAdminUnsuggestController); // Not (yet) implemented. app.get('/api/v1/custom_emojis', emptyArrayController); diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index 60832ac4..16a28b09 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -93,6 +93,7 @@ app.put('/wallet', userMiddleware({ privileged: false, required: true }), requir await createEvent({ kind: 17375, content: encryptedWalletContentTags, + // @ts-ignore kill me }, c); // Nutzap information @@ -103,6 +104,7 @@ app.put('/wallet', userMiddleware({ privileged: false, required: true }), requir ['relay', conf.relay], // TODO: add more relays once things get more stable ['pubkey', p2pk], ], + // @ts-ignore kill me }, c); // TODO: hydrate wallet and add a 'balance' field when a 'renderWallet' view function is created diff --git a/packages/ditto/middleware/auth98Middleware.ts b/packages/ditto/middleware/auth98Middleware.ts index 18fce5fd..48ac5875 100644 --- a/packages/ditto/middleware/auth98Middleware.ts +++ b/packages/ditto/middleware/auth98Middleware.ts @@ -3,7 +3,6 @@ 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, @@ -40,10 +39,9 @@ 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 { conf, relay } = c.var; - const [user] = await store.query([{ + const [user] = await relay.query([{ kinds: [30382], authors: [await conf.signer.getPublicKey()], '#d': [proof.pubkey], @@ -108,6 +106,7 @@ function withProof( /** Get the proof over Nostr Connect. */ async function obtainProof(c: AppContext, opts?: ParseAuthRequestOpts) { const signer = c.var.user?.signer; + if (!signer) { throw new HTTPException(401, { res: c.json({ error: 'No way to sign Nostr event' }, 401), diff --git a/packages/ditto/middleware/swapNutzapsMiddleware.ts b/packages/ditto/middleware/swapNutzapsMiddleware.ts index aa68c1c1..79bdf01e 100644 --- a/packages/ditto/middleware/swapNutzapsMiddleware.ts +++ b/packages/ditto/middleware/swapNutzapsMiddleware.ts @@ -1,13 +1,12 @@ import { CashuMint, CashuWallet, getEncodedToken, type Proof } from '@cashu/cashu-ts'; -import { type DittoConf } from '@ditto/conf'; import { MiddlewareHandler } from '@hono/hono'; import { HTTPException } from '@hono/hono/http-exception'; import { getPublicKey } from 'nostr-tools'; -import { NostrFilter, NostrSigner, NSchema as n, NStore } from '@nostrify/nostrify'; -import { SetRequired } from 'type-fest'; +import { NostrFilter, NSchema as n } from '@nostrify/nostrify'; import { stringToBytes } from '@scure/base'; import { logi } from '@soapbox/logi'; +import { AppEnv } from '@/app.ts'; import { isNostrId } from '@/utils.ts'; import { errorJson } from '@/utils/log.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. * Errors are only thrown if 'signer' and 'store' middlewares are not set. */ -export const swapNutzapsMiddleware: MiddlewareHandler< - { Variables: { signer: SetRequired; store: NStore; conf: DittoConf } } -> = async (c, next) => { - const { conf } = c.var; - const signer = c.get('signer'); - const store = c.get('store'); +export const swapNutzapsMiddleware: MiddlewareHandler = async (c, next) => { + const { conf, relay, user, signal } = c.var; - if (!signer) { + if (!user) { 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' }); } - if (!store) { + if (!relay) { throw new HTTPException(401, { message: 'No store provided' }); } - const { signal } = c.req.raw; - const pubkey = await signer.getPublicKey(); - const [wallet] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal }); + const pubkey = await user.signer.getPublicKey(); + const [wallet] = await relay.query([{ authors: [pubkey], kinds: [17375] }], { signal }); if (wallet) { let decryptedContent: string; try { - decryptedContent = await signer.nip44.decrypt(pubkey, wallet.content); + decryptedContent = await user.signer.nip44.decrypt(pubkey, wallet.content); } catch (e) { logi({ level: 'error', @@ -68,7 +62,7 @@ export const swapNutzapsMiddleware: MiddlewareHandler< } 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) { 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 [nutzapHistory] = await store.query([{ kinds: [7376], authors: [pubkey] }], { signal }); + const [nutzapHistory] = await relay.query([{ kinds: [7376], authors: [pubkey] }], { signal }); if (nutzapHistory) { nutzapsFilter.since = nutzapHistory.created_at; } 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) { try { @@ -154,7 +148,7 @@ export const swapNutzapsMiddleware: MiddlewareHandler< const unspentProofs = await createEvent({ kind: 7375, - content: await signer.nip44.encrypt( + content: await user.signer.nip44.encrypt( pubkey, JSON.stringify({ mint, @@ -169,7 +163,7 @@ export const swapNutzapsMiddleware: MiddlewareHandler< await createEvent({ kind: 7376, - content: await signer.nip44.encrypt( + content: await user.signer.nip44.encrypt( pubkey, JSON.stringify([ ['direction', 'in'], diff --git a/packages/ditto/utils/api.ts b/packages/ditto/utils/api.ts index 0ac80a73..37a38d6a 100644 --- a/packages/ditto/utils/api.ts +++ b/packages/ditto/utils/api.ts @@ -18,16 +18,16 @@ import { purifyEvent } from '@/utils/purify.ts'; type EventStub = TypeFest.SetOptional; /** Publish an event through the pipeline. */ -async function createEvent(t: EventStub, c: Context): Promise { - const signer = c.get('signer'); +async function createEvent(t: EventStub, c: AppContext): Promise { + const { user } = c.var; - if (!signer) { + if (!user) { throw new HTTPException(401, { res: c.json({ error: 'No way to sign Nostr event' }, 401), }); } - const event = await signer.signEvent({ + const event = await user.signer.signEvent({ content: '', created_at: nostrNow(), tags: [], From 33786d2e5db07acebd11d25b3c9e82ae43ac7e3e Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 20 Feb 2025 18:48:44 -0600 Subject: [PATCH 04/23] Fix cashu tests, sorta --- packages/ditto/controllers/api/cashu.test.ts | 103 ++++++------------ .../mastoapi/middleware/userMiddleware.ts | 6 +- 2 files changed, 33 insertions(+), 76 deletions(-) diff --git a/packages/ditto/controllers/api/cashu.test.ts b/packages/ditto/controllers/api/cashu.test.ts index d82e205e..f8d37ca0 100644 --- a/packages/ditto/controllers/api/cashu.test.ts +++ b/packages/ditto/controllers/api/cashu.test.ts @@ -1,53 +1,39 @@ -import { Env as HonoEnv, Hono } from '@hono/hono'; -import { NostrSigner, NSecSigner, NStore } from '@nostrify/nostrify'; +import { DittoConf } from '@ditto/conf'; +import { DittoApp } from '@ditto/router'; +import { NSecSigner } from '@nostrify/nostrify'; import { genEvent } from '@nostrify/nostrify/test'; import { bytesToString, stringToBytes } from '@scure/base'; import { stub } from '@std/testing/mock'; import { assertEquals, assertExists, assertObjectMatch } from '@std/assert'; -import { generateSecretKey, getPublicKey } from 'nostr-tools'; +import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools'; import { createTestDB } from '@/test.ts'; import cashuApp from '@/controllers/api/cashu.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', { sanitizeOps: false, sanitizeResources: false, }, async () => { using _mock = mockFetch(); await using db = await createTestDB(); - const store = db.store; + const relay = db.store; const sk = generateSecretKey(); const signer = new NSecSigner(sk); const nostrPrivateKey = bytesToString('hex', sk); - const app = new Hono().use( - async (c, next) => { - c.set('signer', signer); - await next(); - }, - async (c, next) => { - c.set('store', store); - await next(); - }, - ); + const app = new DittoApp({ db, relay, conf: new DittoConf(new Map()) }); app.route('/', cashuApp); const response = await app.request('/wallet', { method: 'PUT', - headers: [['content-type', 'application/json']], + headers: { + 'authorization': `Bearer ${nip19.nsecEncode(sk)}`, + 'content-type': 'application/json', + }, body: JSON.stringify({ mints: [ 'https://houston.mint.com', @@ -61,7 +47,7 @@ Deno.test('PUT /wallet must be successful', { 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); assertEquals(wallet.kind, 17375); @@ -88,7 +74,7 @@ Deno.test('PUT /wallet must be successful', { ]); 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); assertEquals(nutzap_info.kind, 10019); @@ -105,27 +91,19 @@ Deno.test('PUT /wallet must be successful', { Deno.test('PUT /wallet must NOT be successful: wrong request body/schema', async () => { using _mock = mockFetch(); await using db = await createTestDB(); - const store = db.store; - + const relay = db.store; const sk = generateSecretKey(); - const signer = new NSecSigner(sk); - const app = new Hono().use( - async (c, next) => { - c.set('signer', signer); - await next(); - }, - async (c, next) => { - c.set('store', store); - await next(); - }, - ); + const app = new DittoApp({ db, relay, conf: new DittoConf(new Map()) }); app.route('/', cashuApp); const response = await app.request('/wallet', { method: 'PUT', - headers: [['content-type', 'application/json']], + headers: { + 'authorization': `Bearer ${nip19.nsecEncode(sk)}`, + 'content-type': 'application/json', + }, body: JSON.stringify({ mints: [], // no mints should throw an error }), @@ -143,21 +121,10 @@ Deno.test('PUT /wallet must NOT be successful: wallet already exists', { }, async () => { using _mock = mockFetch(); await using db = await createTestDB(); - const store = db.store; - + const relay = db.store; const sk = generateSecretKey(); - const signer = new NSecSigner(sk); - const app = new Hono().use( - async (c, next) => { - c.set('signer', signer); - await next(); - }, - async (c, next) => { - c.set('store', store); - await next(); - }, - ); + const app = new DittoApp({ db, relay, conf: new DittoConf(new Map()) }); app.route('/', cashuApp); @@ -165,7 +132,10 @@ Deno.test('PUT /wallet must NOT be successful: wallet already exists', { const response = await app.request('/wallet', { method: 'PUT', - headers: [['content-type', 'application/json']], + headers: { + 'authorization': `Bearer ${nip19.nsecEncode(sk)}`, + 'content-type': 'application/json', + }, body: JSON.stringify({ mints: ['https://mint.heart.com'], }), @@ -183,7 +153,7 @@ Deno.test('GET /wallet must be successful', { }, async () => { using _mock = mockFetch(); await using db = await createTestDB(); - const store = db.store; + const relay = db.store; const sk = generateSecretKey(); const signer = new NSecSigner(sk); @@ -191,16 +161,7 @@ Deno.test('GET /wallet must be successful', { const privkey = bytesToString('hex', sk); const p2pk = getPublicKey(stringToBytes('hex', privkey)); - const app = new Hono().use( - async (c, next) => { - c.set('signer', signer); - await next(); - }, - async (c, next) => { - c.set('store', store); - await next(); - }, - ); + const app = new DittoApp({ db, relay, conf: new DittoConf(new Map()) }); app.route('/', cashuApp); @@ -282,6 +243,9 @@ Deno.test('GET /wallet must be successful', { const response = await app.request('/wallet', { method: 'GET', + headers: { + 'authorization': `Bearer ${nip19.nsecEncode(sk)}`, + }, }); const body = await response.json(); @@ -298,14 +262,9 @@ Deno.test('GET /wallet must be successful', { Deno.test('GET /mints must be successful', async () => { using _mock = mockFetch(); await using db = await createTestDB(); - const store = db.store; + const relay = db.store; - const app = new Hono().use( - async (c, next) => { - c.set('store', store); - await next(); - }, - ); + const app = new DittoApp({ db, relay, conf: new DittoConf(new Map()) }); app.route('/', cashuApp); diff --git a/packages/mastoapi/middleware/userMiddleware.ts b/packages/mastoapi/middleware/userMiddleware.ts index 29a7b6f3..4a88e325 100644 --- a/packages/mastoapi/middleware/userMiddleware.ts +++ b/packages/mastoapi/middleware/userMiddleware.ts @@ -34,10 +34,6 @@ export function userMiddleware(opts: { privileged: boolean; required?: boolean } return async (c, next) => { const header = c.req.header('authorization'); - if (!header && required) { - throw new HTTPException(403, { message: 'Authorization required.' }); - } - if (header) { const user: User = { signer: await getSigner(header, c.var), @@ -45,6 +41,8 @@ export function userMiddleware(opts: { privileged: boolean; required?: boolean } }; c.set('user', user); + } else if (required) { + throw new HTTPException(403, { message: 'Authorization required.' }); } if (privileged) { From 8a978b088bdc0e516d23f5427bd80b355f43d551 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 20 Feb 2025 18:57:55 -0600 Subject: [PATCH 05/23] Use the user's store in a few places where it matters --- packages/ditto/controllers/api/notifications.ts | 3 ++- packages/ditto/controllers/api/timelines.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ditto/controllers/api/notifications.ts b/packages/ditto/controllers/api/notifications.ts index f180cf9e..f0435bc4 100644 --- a/packages/ditto/controllers/api/notifications.ts +++ b/packages/ditto/controllers/api/notifications.ts @@ -107,8 +107,9 @@ async function renderNotifications( params: DittoPagination, c: AppContext, ) { - const { conf, relay, user, signal } = c.var; + const { conf, user, signal } = c.var; + const relay = user!.relay; const pubkey = await user!.signer.getPublicKey(); const opts = { signal, limit: params.limit, timeout: conf.db.timeouts.timelines }; diff --git a/packages/ditto/controllers/api/timelines.ts b/packages/ditto/controllers/api/timelines.ts index b8c74f41..5ef83856 100644 --- a/packages/ditto/controllers/api/timelines.ts +++ b/packages/ditto/controllers/api/timelines.ts @@ -103,8 +103,9 @@ const suggestedTimelineController: AppController = async (c) => { /** Render statuses for timelines. */ async function renderStatuses(c: AppContext, filters: NostrFilter[]) { - const { conf, relay, user, signal } = c.var; + const { conf, user, signal } = c.var; + const relay = user?.relay ?? c.var.relay; const opts = { signal, timeout: conf.db.timeouts.timelines }; const events = await relay From 8f49b99935c2ce001980c3d8b295c9056be49d94 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 20 Feb 2025 20:03:31 -0600 Subject: [PATCH 06/23] Consolidate AdminStore and UserStore --- packages/ditto/storages/AdminStore.ts | 43 ----------- packages/ditto/storages/UserStore.test.ts | 10 +-- packages/ditto/storages/UserStore.ts | 73 ++++++++++++------- .../mastoapi/middleware/userMiddleware.ts | 10 ++- 4 files changed, 60 insertions(+), 76 deletions(-) delete mode 100644 packages/ditto/storages/AdminStore.ts diff --git a/packages/ditto/storages/AdminStore.ts b/packages/ditto/storages/AdminStore.ts deleted file mode 100644 index ae03c59d..00000000 --- a/packages/ditto/storages/AdminStore.ts +++ /dev/null @@ -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 { - return await this.store.event(event, opts); - } - - async query(filters: NostrFilter[], opts: { signal?: AbortSignal; limit?: number } = {}): Promise { - 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; - }); - } -} diff --git a/packages/ditto/storages/UserStore.test.ts b/packages/ditto/storages/UserStore.test.ts index d04ece07..56ec1254 100644 --- a/packages/ditto/storages/UserStore.test.ts +++ b/packages/ditto/storages/UserStore.test.ts @@ -14,9 +14,8 @@ Deno.test('query events of users that are not muted', async () => { const blockEventCopy = structuredClone(blockEvent); const event1authorUserMeCopy = structuredClone(event1authorUserMe); - const db = new MockRelay(); - - const store = new UserStore(userBlackCopy.pubkey, db); + const relay = new MockRelay(); + const store = new UserStore({ relay, userPubkey: userBlackCopy.pubkey }); await store.event(blockEventCopy); await store.event(userBlackCopy); @@ -30,9 +29,8 @@ Deno.test('user never muted anyone', async () => { const userBlackCopy = structuredClone(userBlack); const userMeCopy = structuredClone(userMe); - const db = new MockRelay(); - - const store = new UserStore(userBlackCopy.pubkey, db); + const relay = new MockRelay(); + const store = new UserStore({ relay, userPubkey: userBlackCopy.pubkey }); await store.event(userBlackCopy); await store.event(userMeCopy); diff --git a/packages/ditto/storages/UserStore.ts b/packages/ditto/storages/UserStore.ts index 2449d8c1..0533917c 100644 --- a/packages/ditto/storages/UserStore.ts +++ b/packages/ditto/storages/UserStore.ts @@ -1,43 +1,66 @@ -import { NostrEvent, NostrFilter, NStore } from '@nostrify/nostrify'; +import { NostrEvent, NostrFilter, NostrRelayCLOSED, NostrRelayEOSE, NostrRelayEVENT, NRelay } from '@nostrify/nostrify'; -import { DittoEvent } from '@/interfaces/DittoEvent.ts'; -import { getTagSet } from '@/utils/tags.ts'; +interface UserStoreOpts { + relay: NRelay; + userPubkey: string; + adminPubkey?: string; +} -export class UserStore implements NStore { - private promise: Promise | undefined; +export class UserStore implements NRelay { + constructor(private opts: UserStoreOpts) {} - constructor(private pubkey: string, private store: NStore) {} + req( + filters: NostrFilter[], + opts?: { signal?: AbortSignal }, + ): AsyncIterable { + // TODO: support req maybe? It would be inefficient. + return this.opts.relay.req(filters, opts); + } async event(event: NostrEvent, opts?: { signal?: AbortSignal }): Promise { - return await this.store.event(event, opts); + 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 { - const events = await this.store.query(filters, opts); - const pubkeys = await this.getMutedPubkeys(); + async query(filters: NostrFilter[], opts: { signal?: AbortSignal; limit?: number } = {}): Promise { + const { relay, userPubkey, adminPubkey } = this.opts; + + const mutes = new Set(); + 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) => { - return event.kind === 0 || !pubkeys.has(event.pubkey); + 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); }); } - private async getMuteList(): Promise { - 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> { - const mutedPubkeysEvent = await this.getMuteList(); - if (!mutedPubkeysEvent) { - return new Set(); - } - return getTagSet(mutedPubkeysEvent.tags, 'p'); + close(): Promise { + return this.opts.relay.close(); } } diff --git a/packages/mastoapi/middleware/userMiddleware.ts b/packages/mastoapi/middleware/userMiddleware.ts index 4a88e325..a86fecc5 100644 --- a/packages/mastoapi/middleware/userMiddleware.ts +++ b/packages/mastoapi/middleware/userMiddleware.ts @@ -35,9 +35,15 @@ export function userMiddleware(opts: { privileged: boolean; required?: boolean } const header = c.req.header('authorization'); if (header) { + const { relay, conf } = c.var; + + const signer = await getSigner(header, c.var); + const userPubkey = await signer.getPublicKey(); + const adminPubkey = await conf.signer.getPublicKey(); + const user: User = { - signer: await getSigner(header, c.var), - relay: c.var.relay, // TODO: set user's relay + signer, + relay: new UserStore({ relay, userPubkey, adminPubkey }), }; c.set('user', user); From f83925331af56f284adccbfcef3554aa3cef112a Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 20 Feb 2025 20:04:57 -0600 Subject: [PATCH 07/23] Apply the UserStore to the userMiddleware --- packages/mastoapi/middleware/userMiddleware.ts | 1 + packages/{ditto => mastoapi}/storages/UserStore.test.ts | 4 ++-- packages/{ditto => mastoapi}/storages/UserStore.ts | 9 ++++++++- 3 files changed, 11 insertions(+), 3 deletions(-) rename packages/{ditto => mastoapi}/storages/UserStore.test.ts (96%) rename packages/{ditto => mastoapi}/storages/UserStore.ts (92%) diff --git a/packages/mastoapi/middleware/userMiddleware.ts b/packages/mastoapi/middleware/userMiddleware.ts index a86fecc5..71a375eb 100644 --- a/packages/mastoapi/middleware/userMiddleware.ts +++ b/packages/mastoapi/middleware/userMiddleware.ts @@ -6,6 +6,7 @@ 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 { DittoConf } from '@ditto/conf'; import type { DittoDB } from '@ditto/db'; diff --git a/packages/ditto/storages/UserStore.test.ts b/packages/mastoapi/storages/UserStore.test.ts similarity index 96% rename from packages/ditto/storages/UserStore.test.ts rename to packages/mastoapi/storages/UserStore.test.ts index 56ec1254..c9aa3329 100644 --- a/packages/ditto/storages/UserStore.test.ts +++ b/packages/mastoapi/storages/UserStore.test.ts @@ -1,7 +1,7 @@ import { MockRelay } from '@nostrify/nostrify/test'; - 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 userMe from '~/fixtures/events/event-0-makes-repost-with-quote-repost.json' with { type: 'json' }; diff --git a/packages/ditto/storages/UserStore.ts b/packages/mastoapi/storages/UserStore.ts similarity index 92% rename from packages/ditto/storages/UserStore.ts rename to packages/mastoapi/storages/UserStore.ts index 0533917c..dec77916 100644 --- a/packages/ditto/storages/UserStore.ts +++ b/packages/mastoapi/storages/UserStore.ts @@ -1,4 +1,11 @@ -import { NostrEvent, NostrFilter, NostrRelayCLOSED, NostrRelayEOSE, NostrRelayEVENT, NRelay } from '@nostrify/nostrify'; +import type { + NostrEvent, + NostrFilter, + NostrRelayCLOSED, + NostrRelayEOSE, + NostrRelayEVENT, + NRelay, +} from '@nostrify/nostrify'; interface UserStoreOpts { relay: NRelay; From 5ad7f1d5d7c3c1b1319fbb3b67139d8a977c7312 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 21 Feb 2025 13:27:19 -0600 Subject: [PATCH 08/23] userMiddleware -> tokenMiddleware --- packages/mastoapi/middleware/mod.ts | 2 +- .../{userMiddleware.ts => tokenMiddleware.ts} | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) rename packages/mastoapi/middleware/{userMiddleware.ts => tokenMiddleware.ts} (86%) diff --git a/packages/mastoapi/middleware/mod.ts b/packages/mastoapi/middleware/mod.ts index 7cdd6748..e4c346e1 100644 --- a/packages/mastoapi/middleware/mod.ts +++ b/packages/mastoapi/middleware/mod.ts @@ -1,2 +1,2 @@ export { paginationMiddleware } from './paginationMiddleware.ts'; -export { userMiddleware } from './userMiddleware.ts'; +export { tokenMiddleware } from './tokenMiddleware.ts'; diff --git a/packages/mastoapi/middleware/userMiddleware.ts b/packages/mastoapi/middleware/tokenMiddleware.ts similarity index 86% rename from packages/mastoapi/middleware/userMiddleware.ts rename to packages/mastoapi/middleware/tokenMiddleware.ts index 71a375eb..9b666ed1 100644 --- a/packages/mastoapi/middleware/userMiddleware.ts +++ b/packages/mastoapi/middleware/tokenMiddleware.ts @@ -20,12 +20,12 @@ interface User { /** We only accept "Bearer" type. */ const BEARER_REGEX = new RegExp(`^Bearer (${nip19.BECH32_REGEX.source})$`); -export function userMiddleware(opts: { privileged: true; required: false }): never; +export function tokenMiddleware(opts: { privileged: true; required: false }): never; // @ts-ignore The types are right. -export function userMiddleware(opts: { privileged: false; required: true }): DittoMiddleware<{ user: User }>; -export function userMiddleware(opts: { privileged: true; required?: boolean }): DittoMiddleware<{ user: User }>; -export function userMiddleware(opts: { privileged: false; required?: boolean }): DittoMiddleware<{ user?: User }>; -export function userMiddleware(opts: { privileged: boolean; required?: boolean }): DittoMiddleware<{ user?: User }> { +export function tokenMiddleware(opts: { privileged: false; required: true }): DittoMiddleware<{ user: User }>; +export function tokenMiddleware(opts: { privileged: true; required?: boolean }): DittoMiddleware<{ user: User }>; +export function tokenMiddleware(opts: { privileged: false; required?: boolean }): DittoMiddleware<{ user?: User }>; +export function tokenMiddleware(opts: { privileged: boolean; required?: boolean }): DittoMiddleware<{ user?: User }> { const { privileged, required = privileged } = opts; if (privileged && !required) { From 438ab0921697da3af7eae72a225f9db26b6cd655 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 21 Feb 2025 14:52:17 -0600 Subject: [PATCH 09/23] Split userMiddleware into tokenMiddleware and a new userMiddleware --- packages/ditto/app.ts | 5 +- packages/ditto/controllers/api/cashu.test.ts | 21 +++- packages/ditto/controllers/api/cashu.ts | 103 +++++++----------- packages/mastoapi/middleware/User.ts | 6 + packages/mastoapi/middleware/mod.ts | 3 + .../mastoapi/middleware/tokenMiddleware.ts | 26 +---- .../mastoapi/middleware/userMiddleware.ts | 27 +++++ 7 files changed, 97 insertions(+), 94 deletions(-) create mode 100644 packages/mastoapi/middleware/User.ts create mode 100644 packages/mastoapi/middleware/userMiddleware.ts diff --git a/packages/ditto/app.ts b/packages/ditto/app.ts index 8b291a66..459baf6b 100644 --- a/packages/ditto/app.ts +++ b/packages/ditto/app.ts @@ -1,6 +1,6 @@ import { DittoConf } from '@ditto/conf'; import { DittoDB } from '@ditto/db'; -import { paginationMiddleware, userMiddleware } from '@ditto/mastoapi/middleware'; +import { paginationMiddleware, tokenMiddleware, userMiddleware } from '@ditto/mastoapi/middleware'; import { DittoApp, type DittoEnv } from '@ditto/router'; import { type DittoTranslator } from '@ditto/translators'; import { type Context, Handler, Input as HonoInput, MiddlewareHandler } from '@hono/hono'; @@ -199,7 +199,7 @@ const ratelimit = every( ); const factory = createFactory(); -const requireSigner = userMiddleware({ privileged: false, required: true }); +const requireSigner = userMiddleware(); const requireAdmin = factory.createHandlers(requireSigner, requireRole('admin')); const requireProof = factory.createHandlers(requireSigner, _requireProof()); @@ -214,6 +214,7 @@ app.get('/relay', metricsMiddleware, ratelimit, relayController); app.use( cspMiddleware(), cors({ origin: '*', exposeHeaders: ['link'] }), + tokenMiddleware(), uploaderMiddleware, auth98Middleware(), ); diff --git a/packages/ditto/controllers/api/cashu.test.ts b/packages/ditto/controllers/api/cashu.test.ts index f8d37ca0..67530b6a 100644 --- a/packages/ditto/controllers/api/cashu.test.ts +++ b/packages/ditto/controllers/api/cashu.test.ts @@ -1,5 +1,6 @@ import { DittoConf } from '@ditto/conf'; -import { DittoApp } from '@ditto/router'; +import { type User } from '@ditto/mastoapi/middleware'; +import { DittoApp, DittoMiddleware } from '@ditto/router'; import { NSecSigner } from '@nostrify/nostrify'; import { genEvent } from '@nostrify/nostrify/test'; import { bytesToString, stringToBytes } from '@scure/base'; @@ -12,6 +13,13 @@ import { createTestDB } from '@/test.ts'; import cashuApp from '@/controllers/api/cashu.ts'; import { walletSchema } from '@/schema.ts'; +function testUserMiddleware(user: User): DittoMiddleware<{ user: User }> { + return async (c, next) => { + c.set('user', user); + await next(); + }; +} + Deno.test('PUT /wallet must be successful', { sanitizeOps: false, sanitizeResources: false, @@ -26,12 +34,12 @@ Deno.test('PUT /wallet must be successful', { const app = new DittoApp({ db, relay, conf: new DittoConf(new Map()) }); + app.use(testUserMiddleware({ signer, relay })); app.route('/', cashuApp); const response = await app.request('/wallet', { method: 'PUT', headers: { - 'authorization': `Bearer ${nip19.nsecEncode(sk)}`, 'content-type': 'application/json', }, body: JSON.stringify({ @@ -93,15 +101,16 @@ Deno.test('PUT /wallet must NOT be successful: wrong request body/schema', async await using db = await createTestDB(); const relay = db.store; const sk = generateSecretKey(); + const signer = new NSecSigner(sk); const app = new DittoApp({ db, relay, conf: new DittoConf(new Map()) }); + app.use(testUserMiddleware({ signer, relay })); app.route('/', cashuApp); const response = await app.request('/wallet', { method: 'PUT', headers: { - 'authorization': `Bearer ${nip19.nsecEncode(sk)}`, 'content-type': 'application/json', }, body: JSON.stringify({ @@ -123,9 +132,11 @@ Deno.test('PUT /wallet must NOT be successful: wallet already exists', { await using db = await createTestDB(); const relay = db.store; const sk = generateSecretKey(); + const signer = new NSecSigner(sk); const app = new DittoApp({ db, relay, conf: new DittoConf(new Map()) }); + app.use(testUserMiddleware({ signer, relay })); app.route('/', cashuApp); await db.store.event(genEvent({ kind: 17375 }, sk)); @@ -163,6 +174,7 @@ Deno.test('GET /wallet must be successful', { const app = new DittoApp({ db, relay, conf: new DittoConf(new Map()) }); + app.use(testUserMiddleware({ signer, relay })); app.route('/', cashuApp); // Wallet @@ -243,9 +255,6 @@ Deno.test('GET /wallet must be successful', { const response = await app.request('/wallet', { method: 'GET', - headers: { - 'authorization': `Bearer ${nip19.nsecEncode(sk)}`, - }, }); const body = await response.json(); diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index 16a28b09..9c7dcab0 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -1,6 +1,6 @@ import { Proof } from '@cashu/cashu-ts'; import { userMiddleware } from '@ditto/mastoapi/middleware'; -import { DittoMiddleware, DittoRoute } from '@ditto/router'; +import { DittoRoute } from '@ditto/router'; import { generateSecretKey, getPublicKey } from 'nostr-tools'; import { bytesToString, stringToBytes } from '@scure/base'; import { z } from 'zod'; @@ -11,8 +11,6 @@ import { swapNutzapsMiddleware } from '@/middleware/swapNutzapsMiddleware.ts'; import { isNostrId } from '@/utils.ts'; import { logi } from '@soapbox/logi'; import { errorJson } from '@/utils/log.ts'; -import { SetRequired } from 'type-fest'; -import { NostrSigner } from '@nostrify/nostrify'; type Wallet = z.infer; @@ -33,19 +31,6 @@ interface Nutzap { recipient_pubkey: string; } -const requireNip44Signer: DittoMiddleware<{ user: { signer: SetRequired } }> = async ( - c, - next, -) => { - const { user } = c.var; - - if (!user?.signer.nip44) { - return c.json({ error: 'User does not have a NIP-44 signer' }, 400); - } - - await next(); -}; - const createCashuWalletAndNutzapInfoSchema = z.object({ mints: z.array(z.string().url()).nonempty().transform((val) => { return [...new Set(val)]; @@ -57,7 +42,7 @@ const createCashuWalletAndNutzapInfoSchema = z.object({ * https://github.com/nostr-protocol/nips/blob/master/60.md * https://github.com/nostr-protocol/nips/blob/master/61.md#nutzap-informational-event */ -app.put('/wallet', userMiddleware({ privileged: false, required: true }), requireNip44Signer, async (c) => { +app.put('/wallet', userMiddleware('nip44'), async (c) => { const { conf, user, relay, signal } = c.var; const pubkey = await user.signer.getPublicKey(); @@ -119,63 +104,57 @@ app.put('/wallet', userMiddleware({ privileged: false, required: true }), requir }); /** Gets a wallet, if it exists. */ -app.get( - '/wallet', - userMiddleware({ privileged: false, required: true }), - requireNip44Signer, - swapNutzapsMiddleware, - async (c) => { - const { conf, relay, user, signal } = c.var; +app.get('/wallet', userMiddleware('nip44'), swapNutzapsMiddleware, async (c) => { + const { conf, relay, user, signal } = c.var; - const pubkey = await user.signer.getPublicKey(); + const pubkey = await user.signer.getPublicKey(); - const [event] = await relay.query([{ authors: [pubkey], kinds: [17375] }], { signal }); - if (!event) { - return c.json({ error: 'Wallet not found' }, 404); - } + const [event] = await relay.query([{ authors: [pubkey], kinds: [17375] }], { signal }); + if (!event) { + return c.json({ error: 'Wallet not found' }, 404); + } - const decryptedContent: string[][] = JSON.parse(await user.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]; - if (!privkey || !isNostrId(privkey)) { - return c.json({ error: 'Wallet does not contain privkey or privkey is not a valid nostr id.' }, 422); - } + const privkey = decryptedContent.find(([value]) => value === 'privkey')?.[1]; + if (!privkey || !isNostrId(privkey)) { + return c.json({ error: 'Wallet does not contain privkey or privkey is not a valid nostr id.' }, 422); + } - const p2pk = getPublicKey(stringToBytes('hex', privkey)); + const p2pk = getPublicKey(stringToBytes('hex', privkey)); - let balance = 0; - const mints: string[] = []; + let balance = 0; + const mints: string[] = []; - const tokens = await relay.query([{ authors: [pubkey], kinds: [7375] }], { signal }); - for (const token of tokens) { - try { - const decryptedContent: { mint: string; proofs: Proof[] } = JSON.parse( - await user.signer.nip44.decrypt(pubkey, token.content), - ); + const tokens = await relay.query([{ authors: [pubkey], kinds: [7375] }], { signal }); + for (const token of tokens) { + try { + const decryptedContent: { mint: string; proofs: Proof[] } = JSON.parse( + await user.signer.nip44.decrypt(pubkey, token.content), + ); - if (!mints.includes(decryptedContent.mint)) { - mints.push(decryptedContent.mint); - } - - balance += decryptedContent.proofs.reduce((accumulator, current) => { - return accumulator + current.amount; - }, 0); - } catch (e) { - logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: errorJson(e) }); + if (!mints.includes(decryptedContent.mint)) { + mints.push(decryptedContent.mint); } + + balance += decryptedContent.proofs.reduce((accumulator, current) => { + return accumulator + current.amount; + }, 0); + } catch (e) { + logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: errorJson(e) }); } + } - // TODO: maybe change the 'Wallet' type data structure so each mint is a key and the value are the tokens associated with a given mint - const walletEntity: Wallet = { - pubkey_p2pk: p2pk, - mints, - relays: [conf.relay], - balance, - }; + // TODO: maybe change the 'Wallet' type data structure so each mint is a key and the value are the tokens associated with a given mint + const walletEntity: Wallet = { + pubkey_p2pk: p2pk, + mints, + relays: [conf.relay], + balance, + }; - return c.json(walletEntity, 200); - }, -); + return c.json(walletEntity, 200); +}); /** Get mints set by the CASHU_MINTS environment variable. */ app.get('/mints', (c) => { diff --git a/packages/mastoapi/middleware/User.ts b/packages/mastoapi/middleware/User.ts new file mode 100644 index 00000000..ac38b8de --- /dev/null +++ b/packages/mastoapi/middleware/User.ts @@ -0,0 +1,6 @@ +import type { NostrSigner, NRelay } from '@nostrify/nostrify'; + +export interface User { + signer: S; + relay: R; +} diff --git a/packages/mastoapi/middleware/mod.ts b/packages/mastoapi/middleware/mod.ts index e4c346e1..fb6ffb59 100644 --- a/packages/mastoapi/middleware/mod.ts +++ b/packages/mastoapi/middleware/mod.ts @@ -1,2 +1,5 @@ export { paginationMiddleware } from './paginationMiddleware.ts'; export { tokenMiddleware } from './tokenMiddleware.ts'; +export { userMiddleware } from './userMiddleware.ts'; + +export type { User } from './User.ts'; diff --git a/packages/mastoapi/middleware/tokenMiddleware.ts b/packages/mastoapi/middleware/tokenMiddleware.ts index 9b666ed1..407548ed 100644 --- a/packages/mastoapi/middleware/tokenMiddleware.ts +++ b/packages/mastoapi/middleware/tokenMiddleware.ts @@ -11,27 +11,12 @@ import { UserStore } from '../storages/UserStore.ts'; import type { DittoConf } from '@ditto/conf'; import type { DittoDB } from '@ditto/db'; import type { DittoMiddleware } from '@ditto/router'; - -interface User { - signer: NostrSigner; - relay: NRelay; -} +import type { User } from './User.ts'; /** We only accept "Bearer" type. */ const BEARER_REGEX = new RegExp(`^Bearer (${nip19.BECH32_REGEX.source})$`); -export function tokenMiddleware(opts: { privileged: true; required: false }): never; -// @ts-ignore The types are right. -export function tokenMiddleware(opts: { privileged: false; required: true }): DittoMiddleware<{ user: User }>; -export function tokenMiddleware(opts: { privileged: true; required?: boolean }): DittoMiddleware<{ user: User }>; -export function tokenMiddleware(opts: { privileged: false; required?: boolean }): DittoMiddleware<{ user?: User }>; -export function tokenMiddleware(opts: { privileged: boolean; required?: boolean }): DittoMiddleware<{ user?: User }> { - const { privileged, required = privileged } = opts; - - if (privileged && !required) { - throw new Error('Privileged middleware requires authorization.'); - } - +export function tokenMiddleware(): DittoMiddleware<{ user?: User }> { return async (c, next) => { const header = c.req.header('authorization'); @@ -48,13 +33,6 @@ export function tokenMiddleware(opts: { privileged: boolean; required?: boolean }; c.set('user', user); - } else if (required) { - throw new HTTPException(403, { message: 'Authorization required.' }); - } - - if (privileged) { - // TODO: add back nip98 auth - throw new HTTPException(500); } await next(); diff --git a/packages/mastoapi/middleware/userMiddleware.ts b/packages/mastoapi/middleware/userMiddleware.ts new file mode 100644 index 00000000..5b18e718 --- /dev/null +++ b/packages/mastoapi/middleware/userMiddleware.ts @@ -0,0 +1,27 @@ +import { HTTPException } from '@hono/hono/http-exception'; + +import type { DittoMiddleware } from '@ditto/router'; +import type { NostrSigner } from '@nostrify/nostrify'; +import type { SetRequired } from 'type-fest'; +import type { User } from './User.ts'; + +type Nip44Signer = SetRequired; + +export function userMiddleware(): DittoMiddleware<{ user: User }>; +// @ts-ignore Types are right. +export function userMiddleware(enc: 'nip44'): DittoMiddleware<{ user: User }>; +export function userMiddleware(enc?: 'nip04' | 'nip44'): DittoMiddleware<{ user: User }> { + return async (c, next) => { + const { user } = c.var; + + if (!user) { + throw new HTTPException(403, { message: 'Authorization required.' }); + } + + if (enc && !user.signer[enc]) { + throw new HTTPException(403, { message: `User does not have a ${enc} signer` }); + } + + await next(); + }; +} From d0c7cc7a45a29cc31350f05a038f1d41623a699a Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 21 Feb 2025 15:05:54 -0600 Subject: [PATCH 10/23] Improve cashu test --- packages/ditto/controllers/api/cashu.test.ts | 107 ++++++++----------- 1 file changed, 46 insertions(+), 61 deletions(-) diff --git a/packages/ditto/controllers/api/cashu.test.ts b/packages/ditto/controllers/api/cashu.test.ts index 67530b6a..bb80128f 100644 --- a/packages/ditto/controllers/api/cashu.test.ts +++ b/packages/ditto/controllers/api/cashu.test.ts @@ -13,30 +13,15 @@ import { createTestDB } from '@/test.ts'; import cashuApp from '@/controllers/api/cashu.ts'; import { walletSchema } from '@/schema.ts'; -function testUserMiddleware(user: User): DittoMiddleware<{ user: User }> { - return async (c, next) => { - c.set('user', user); - await next(); - }; -} - Deno.test('PUT /wallet must be successful', { sanitizeOps: false, sanitizeResources: false, }, async () => { - using _mock = mockFetch(); - await using db = await createTestDB(); - const relay = db.store; + await using test = await createTestApp(); - const sk = generateSecretKey(); - const signer = new NSecSigner(sk); + const { app, signer, sk, relay } = test; const nostrPrivateKey = bytesToString('hex', sk); - const app = new DittoApp({ db, relay, conf: new DittoConf(new Map()) }); - - app.use(testUserMiddleware({ signer, relay })); - app.route('/', cashuApp); - const response = await app.request('/wallet', { method: 'PUT', headers: { @@ -97,16 +82,8 @@ Deno.test('PUT /wallet must be successful', { }); Deno.test('PUT /wallet must NOT be successful: wrong request body/schema', async () => { - using _mock = mockFetch(); - await using db = await createTestDB(); - const relay = db.store; - const sk = generateSecretKey(); - const signer = new NSecSigner(sk); - - const app = new DittoApp({ db, relay, conf: new DittoConf(new Map()) }); - - app.use(testUserMiddleware({ signer, relay })); - app.route('/', cashuApp); + await using test = await createTestApp(); + const { app } = test; const response = await app.request('/wallet', { method: 'PUT', @@ -128,18 +105,10 @@ Deno.test('PUT /wallet must NOT be successful: wallet already exists', { sanitizeOps: false, sanitizeResources: false, }, async () => { - using _mock = mockFetch(); - await using db = await createTestDB(); - const relay = db.store; - const sk = generateSecretKey(); - const signer = new NSecSigner(sk); + await using test = await createTestApp(); + const { app, sk, relay } = test; - const app = new DittoApp({ db, relay, conf: new DittoConf(new Map()) }); - - app.use(testUserMiddleware({ signer, relay })); - app.route('/', cashuApp); - - await db.store.event(genEvent({ kind: 17375 }, sk)); + await relay.event(genEvent({ kind: 17375 }, sk)); const response = await app.request('/wallet', { method: 'PUT', @@ -162,23 +131,15 @@ Deno.test('GET /wallet must be successful', { sanitizeOps: false, sanitizeResources: false, }, async () => { - using _mock = mockFetch(); - await using db = await createTestDB(); - const relay = db.store; + await using test = await createTestApp(); + const { app, sk, relay, signer } = test; - const sk = generateSecretKey(); - const signer = new NSecSigner(sk); const pubkey = await signer.getPublicKey(); const privkey = bytesToString('hex', sk); const p2pk = getPublicKey(stringToBytes('hex', privkey)); - const app = new DittoApp({ db, relay, conf: new DittoConf(new Map()) }); - - app.use(testUserMiddleware({ signer, relay })); - app.route('/', cashuApp); - // Wallet - await db.store.event(genEvent({ + await relay.event(genEvent({ kind: 17375, content: await signer.nip44.encrypt( pubkey, @@ -190,7 +151,7 @@ Deno.test('GET /wallet must be successful', { }, sk)); // Nutzap information - await db.store.event(genEvent({ + await relay.event(genEvent({ kind: 10019, tags: [ ['pubkey', p2pk], @@ -199,7 +160,7 @@ Deno.test('GET /wallet must be successful', { }, sk)); // Unspent proofs - await db.store.event(genEvent({ + await relay.event(genEvent({ kind: 7375, content: await signer.nip44.encrypt( pubkey, @@ -240,7 +201,7 @@ Deno.test('GET /wallet must be successful', { // Nutzap const senderSk = generateSecretKey(); - await db.store.event(genEvent({ + await relay.event(genEvent({ kind: 9321, content: 'Nice post!', tags: [ @@ -269,13 +230,8 @@ Deno.test('GET /wallet must be successful', { }); Deno.test('GET /mints must be successful', async () => { - using _mock = mockFetch(); - await using db = await createTestDB(); - const relay = db.store; - - const app = new DittoApp({ db, relay, conf: new DittoConf(new Map()) }); - - app.route('/', cashuApp); + await using test = await createTestApp(); + const { app } = test; const response = await app.request('/mints', { method: 'GET', @@ -287,13 +243,42 @@ Deno.test('GET /mints must be successful', async () => { assertEquals(body, { mints: [] }); }); -function mockFetch() { +async function createTestApp() { + const conf = new DittoConf(new Map()); + + const db = await createTestDB(); + const relay = db.store; + + const sk = generateSecretKey(); + const signer = new NSecSigner(sk); + + const app = new DittoApp({ db, relay, conf }); + + app.use(testUserMiddleware({ signer, relay })); + app.route('/', cashuApp); + const mock = stub(globalThis, 'fetch', () => { return Promise.resolve(new Response()); }); + return { - [Symbol.dispose]: () => { + app, + db, + conf, + sk, + signer, + relay, + [Symbol.asyncDispose]: async () => { mock.restore(); + await db[Symbol.asyncDispose](); + await relay[Symbol.asyncDispose](); }, }; } + +function testUserMiddleware(user: User): DittoMiddleware<{ user: User }> { + return async (c, next) => { + c.set('user', user); + await next(); + }; +} From e5657d67c0dc9ec5408ee0d5b761c646b8911f82 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 21 Feb 2025 15:08:37 -0600 Subject: [PATCH 11/23] app -> route --- packages/ditto/controllers/api/cashu.test.ts | 42 ++++++++++---------- packages/ditto/controllers/api/cashu.ts | 10 ++--- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/ditto/controllers/api/cashu.test.ts b/packages/ditto/controllers/api/cashu.test.ts index bb80128f..22e9a38f 100644 --- a/packages/ditto/controllers/api/cashu.test.ts +++ b/packages/ditto/controllers/api/cashu.test.ts @@ -10,19 +10,19 @@ import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools'; import { createTestDB } from '@/test.ts'; -import cashuApp from '@/controllers/api/cashu.ts'; +import cashuRoute from './cashu.ts'; import { walletSchema } from '@/schema.ts'; Deno.test('PUT /wallet must be successful', { sanitizeOps: false, sanitizeResources: false, }, async () => { - await using test = await createTestApp(); + await using test = await createTestRoute(); - const { app, signer, sk, relay } = test; + const { route, signer, sk, relay } = test; const nostrPrivateKey = bytesToString('hex', sk); - const response = await app.request('/wallet', { + const response = await route.request('/wallet', { method: 'PUT', headers: { 'content-type': 'application/json', @@ -82,10 +82,10 @@ Deno.test('PUT /wallet must be successful', { }); Deno.test('PUT /wallet must NOT be successful: wrong request body/schema', async () => { - await using test = await createTestApp(); - const { app } = test; + await using test = await createTestRoute(); + const { route } = test; - const response = await app.request('/wallet', { + const response = await route.request('/wallet', { method: 'PUT', headers: { 'content-type': 'application/json', @@ -105,12 +105,12 @@ Deno.test('PUT /wallet must NOT be successful: wallet already exists', { sanitizeOps: false, sanitizeResources: false, }, async () => { - await using test = await createTestApp(); - const { app, sk, relay } = test; + await using test = await createTestRoute(); + const { route, sk, relay } = test; await relay.event(genEvent({ kind: 17375 }, sk)); - const response = await app.request('/wallet', { + const response = await route.request('/wallet', { method: 'PUT', headers: { 'authorization': `Bearer ${nip19.nsecEncode(sk)}`, @@ -131,8 +131,8 @@ Deno.test('GET /wallet must be successful', { sanitizeOps: false, sanitizeResources: false, }, async () => { - await using test = await createTestApp(); - const { app, sk, relay, signer } = test; + await using test = await createTestRoute(); + const { route, sk, relay, signer } = test; const pubkey = await signer.getPublicKey(); const privkey = bytesToString('hex', sk); @@ -214,7 +214,7 @@ Deno.test('GET /wallet must be successful', { ], }, senderSk)); - const response = await app.request('/wallet', { + const response = await route.request('/wallet', { method: 'GET', }); @@ -230,10 +230,10 @@ Deno.test('GET /wallet must be successful', { }); Deno.test('GET /mints must be successful', async () => { - await using test = await createTestApp(); - const { app } = test; + await using test = await createTestRoute(); + const { route } = test; - const response = await app.request('/mints', { + const response = await route.request('/mints', { method: 'GET', }); @@ -243,7 +243,7 @@ Deno.test('GET /mints must be successful', async () => { assertEquals(body, { mints: [] }); }); -async function createTestApp() { +async function createTestRoute() { const conf = new DittoConf(new Map()); const db = await createTestDB(); @@ -252,17 +252,17 @@ async function createTestApp() { const sk = generateSecretKey(); const signer = new NSecSigner(sk); - const app = new DittoApp({ db, relay, conf }); + const route = new DittoApp({ db, relay, conf }); - app.use(testUserMiddleware({ signer, relay })); - app.route('/', cashuApp); + route.use(testUserMiddleware({ signer, relay })); + route.route('/', cashuRoute); const mock = stub(globalThis, 'fetch', () => { return Promise.resolve(new Response()); }); return { - app, + route, db, conf, sk, diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index 9c7dcab0..2d3a1519 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -14,7 +14,7 @@ import { errorJson } from '@/utils/log.ts'; type Wallet = z.infer; -const app = new DittoRoute(); +const route = new DittoRoute(); // app.delete('/wallet') -> 204 @@ -42,7 +42,7 @@ const createCashuWalletAndNutzapInfoSchema = z.object({ * https://github.com/nostr-protocol/nips/blob/master/60.md * https://github.com/nostr-protocol/nips/blob/master/61.md#nutzap-informational-event */ -app.put('/wallet', userMiddleware('nip44'), async (c) => { +route.put('/wallet', userMiddleware('nip44'), async (c) => { const { conf, user, relay, signal } = c.var; const pubkey = await user.signer.getPublicKey(); @@ -104,7 +104,7 @@ app.put('/wallet', userMiddleware('nip44'), async (c) => { }); /** Gets a wallet, if it exists. */ -app.get('/wallet', userMiddleware('nip44'), swapNutzapsMiddleware, async (c) => { +route.get('/wallet', userMiddleware('nip44'), swapNutzapsMiddleware, async (c) => { const { conf, relay, user, signal } = c.var; const pubkey = await user.signer.getPublicKey(); @@ -157,7 +157,7 @@ app.get('/wallet', userMiddleware('nip44'), swapNutzapsMiddleware, async (c) => }); /** Get mints set by the CASHU_MINTS environment variable. */ -app.get('/mints', (c) => { +route.get('/mints', (c) => { const { conf } = c.var; // TODO: Return full Mint information: https://github.com/cashubtc/nuts/blob/main/06.md @@ -166,4 +166,4 @@ app.get('/mints', (c) => { return c.json({ mints }, 200); }); -export default app; +export default route; From 72851bc5365d967c97c249287379e44a6ffe66d8 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 21 Feb 2025 15:08:58 -0600 Subject: [PATCH 12/23] Remove AdminStore from storages --- packages/ditto/storages.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/packages/ditto/storages.ts b/packages/ditto/storages.ts index 7b77a037..d5d0f029 100644 --- a/packages/ditto/storages.ts +++ b/packages/ditto/storages.ts @@ -5,7 +5,6 @@ import { logi } from '@soapbox/logi'; import { Conf } from '@/config.ts'; import { wsUrlSchema } from '@/schema.ts'; -import { AdminStore } from '@/storages/AdminStore.ts'; import { DittoPgStore } from '@/storages/DittoPgStore.ts'; import { getRelays } from '@/utils/outbox.ts'; import { seedZapSplits } from '@/utils/zap-split.ts'; @@ -13,7 +12,6 @@ import { seedZapSplits } from '@/utils/zap-split.ts'; export class Storages { private static _db: Promise | undefined; private static _database: Promise | undefined; - private static _admin: Promise | undefined; private static _client: Promise> | undefined; public static async database(): Promise { @@ -53,14 +51,6 @@ export class Storages { return this._db; } - /** Admin user storage. */ - public static async admin(): Promise { - if (!this._admin) { - this._admin = Promise.resolve(new AdminStore(await this.db())); - } - return this._admin; - } - /** Relay pool storage. */ public static async client(): Promise> { if (!this._client) { From f0add87c6db2f0c573ecec07201bf223d97295a1 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 21 Feb 2025 15:35:03 -0600 Subject: [PATCH 13/23] Create @ditto/nip98 package --- deno.json | 1 + packages/ditto/middleware/auth98Middleware.ts | 7 +---- packages/ditto/schema.ts | 13 ---------- packages/ditto/schemas/nostr.ts | 16 +----------- packages/nip98/deno.json | 7 +++++ packages/{ditto/utils => nip98}/nip98.ts | 26 ++++++++++++++----- packages/nip98/schema.ts | 20 ++++++++++++++ 7 files changed, 49 insertions(+), 41 deletions(-) create mode 100644 packages/nip98/deno.json rename packages/{ditto/utils => nip98}/nip98.ts (79%) create mode 100644 packages/nip98/schema.ts diff --git a/deno.json b/deno.json index 4466b7b3..20d87204 100644 --- a/deno.json +++ b/deno.json @@ -7,6 +7,7 @@ "./packages/lang", "./packages/mastoapi", "./packages/metrics", + "./packages/nip98", "./packages/policies", "./packages/ratelimiter", "./packages/router", diff --git a/packages/ditto/middleware/auth98Middleware.ts b/packages/ditto/middleware/auth98Middleware.ts index 48ac5875..6cab6566 100644 --- a/packages/ditto/middleware/auth98Middleware.ts +++ b/packages/ditto/middleware/auth98Middleware.ts @@ -1,15 +1,10 @@ +import { buildAuthEventTemplate, parseAuthRequest, type ParseAuthRequestOpts, validateAuthEvent } from '@ditto/nip98'; 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 { localRequest } from '@/utils/api.ts'; -import { - buildAuthEventTemplate, - parseAuthRequest, - type ParseAuthRequestOpts, - validateAuthEvent, -} from '@/utils/nip98.ts'; /** * NIP-98 auth. diff --git a/packages/ditto/schema.ts b/packages/ditto/schema.ts index 30b4520a..56c9b998 100644 --- a/packages/ditto/schema.ts +++ b/packages/ditto/schema.ts @@ -13,18 +13,6 @@ function filteredArray(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`. */ const hashtagSchema = z.string().regex(/^\w{1,30}$/); @@ -96,7 +84,6 @@ const walletSchema = z.object({ export { booleanParamSchema, - decode64Schema, fileSchema, filteredArray, hashtagSchema, diff --git a/packages/ditto/schemas/nostr.ts b/packages/ditto/schemas/nostr.ts index 05cd0f31..558e6c13 100644 --- a/packages/ditto/schemas/nostr.ts +++ b/packages/ditto/schemas/nostr.ts @@ -1,14 +1,8 @@ import { NSchema as n } from '@nostrify/nostrify'; -import { getEventHash, verifyEvent } from 'nostr-tools'; import { z } from 'zod'; 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. */ const metadataSchema = n.metadata().and(z.object({ 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. */ type EmojiTag = z.infer; -export { - type EmojiTag, - emojiTagSchema, - metadataSchema, - relayInfoDocSchema, - screenshotsSchema, - serverMetaSchema, - signedEventSchema, -}; +export { type EmojiTag, emojiTagSchema, metadataSchema, relayInfoDocSchema, screenshotsSchema, serverMetaSchema }; diff --git a/packages/nip98/deno.json b/packages/nip98/deno.json new file mode 100644 index 00000000..108e1bb8 --- /dev/null +++ b/packages/nip98/deno.json @@ -0,0 +1,7 @@ +{ + "name": "@ditto/nip98", + "version": "1.0.0", + "exports": { + ".": "./nip98.ts" + } +} diff --git a/packages/ditto/utils/nip98.ts b/packages/nip98/nip98.ts similarity index 79% rename from packages/ditto/utils/nip98.ts rename to packages/nip98/nip98.ts index f83fcddb..e8574c86 100644 --- a/packages/ditto/utils/nip98.ts +++ b/packages/nip98/nip98.ts @@ -1,11 +1,8 @@ -import { NostrEvent, NSchema as n } from '@nostrify/nostrify'; +import { type NostrEvent, NSchema as n } from '@nostrify/nostrify'; 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 { signedEventSchema } from '@/schemas/nostr.ts'; -import { eventAge, findTag, nostrNow } from '@/utils.ts'; -import { Time } from '@/utils/time.ts'; +import { decode64Schema, signedEventSchema } from './schema.ts'; /** Decode a Nostr event from a base64 encoded string. */ const decode64EventSchema = decode64Schema.pipe(n.json()).pipe(signedEventSchema); @@ -32,7 +29,7 @@ async function parseAuthRequest(req: Request, opts: ParseAuthRequestOpts = {}) { /** Compare the auth event with the request, returning a zod SafeParse type. */ function validateAuthEvent(req: Request, event: NostrEvent, opts: ParseAuthRequestOpts = {}) { - const { maxAge = Time.minutes(1), validatePayload = true, pow = 0 } = opts; + const { maxAge = 60_000, validatePayload = true, pow = 0 } = opts; const schema = signedEventSchema .refine((event) => event.kind === 27235, 'Event must be kind 27235') @@ -87,4 +84,19 @@ function tagValue(event: NostrEvent, tagName: string): string | undefined { 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 }; diff --git a/packages/nip98/schema.ts b/packages/nip98/schema.ts new file mode 100644 index 00000000..a0cf627c --- /dev/null +++ b/packages/nip98/schema.ts @@ -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'); From adeff1cae519b552a62ba9fac518c91cfab05cfd Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 21 Feb 2025 15:53:29 -0600 Subject: [PATCH 14/23] tokenMiddleware: support nip98 auth --- packages/ditto/app.ts | 3 +- packages/mastoapi/middleware/User.ts | 1 + .../mastoapi/middleware/tokenMiddleware.ts | 90 ++++++++++++------- 3 files changed, 59 insertions(+), 35 deletions(-) diff --git a/packages/ditto/app.ts b/packages/ditto/app.ts index 459baf6b..3333b9eb 100644 --- a/packages/ditto/app.ts +++ b/packages/ditto/app.ts @@ -138,7 +138,7 @@ import { metricsController } from '@/controllers/metrics.ts'; import { manifestController } from '@/controllers/manifest.ts'; import { nodeInfoController, nodeInfoSchemaController } from '@/controllers/well-known/nodeinfo.ts'; import { nostrController } from '@/controllers/well-known/nostr.ts'; -import { auth98Middleware, requireProof as _requireProof, requireRole } from '@/middleware/auth98Middleware.ts'; +import { requireProof as _requireProof, requireRole } from '@/middleware/auth98Middleware.ts'; import { cacheControlMiddleware } from '@/middleware/cacheControlMiddleware.ts'; import { cspMiddleware } from '@/middleware/cspMiddleware.ts'; import { metricsMiddleware } from '@/middleware/metricsMiddleware.ts'; @@ -216,7 +216,6 @@ app.use( cors({ origin: '*', exposeHeaders: ['link'] }), tokenMiddleware(), uploaderMiddleware, - auth98Middleware(), ); app.get('/metrics', metricsController); diff --git a/packages/mastoapi/middleware/User.ts b/packages/mastoapi/middleware/User.ts index ac38b8de..8fd61f96 100644 --- a/packages/mastoapi/middleware/User.ts +++ b/packages/mastoapi/middleware/User.ts @@ -3,4 +3,5 @@ import type { NostrSigner, NRelay } from '@nostrify/nostrify'; export interface User { signer: S; relay: R; + verified?: boolean; } diff --git a/packages/mastoapi/middleware/tokenMiddleware.ts b/packages/mastoapi/middleware/tokenMiddleware.ts index 407548ed..4796b5d4 100644 --- a/packages/mastoapi/middleware/tokenMiddleware.ts +++ b/packages/mastoapi/middleware/tokenMiddleware.ts @@ -1,5 +1,6 @@ +import { parseAuthRequest } from '@ditto/nip98'; import { HTTPException } from '@hono/hono/http-exception'; -import { type NostrSigner, type NRelay, NSecSigner } from '@nostrify/nostrify'; +import { type NostrSigner, NSecSigner } from '@nostrify/nostrify'; import { nip19 } from 'nostr-tools'; import { aesDecrypt } from '../auth/aes.ts'; @@ -8,14 +9,10 @@ import { ConnectSigner } from '../signers/ConnectSigner.ts'; import { ReadOnlySigner } from '../signers/ReadOnlySigner.ts'; import { UserStore } from '../storages/UserStore.ts'; -import type { DittoConf } from '@ditto/conf'; -import type { DittoDB } from '@ditto/db'; -import type { DittoMiddleware } from '@ditto/router'; +import type { DittoEnv, DittoMiddleware } from '@ditto/router'; +import type { Context } from '@hono/hono'; import type { User } from './User.ts'; -/** We only accept "Bearer" type. */ -const BEARER_REGEX = new RegExp(`^Bearer (${nip19.BECH32_REGEX.source})$`); - export function tokenMiddleware(): DittoMiddleware<{ user?: User }> { return async (c, next) => { const header = c.req.header('authorization'); @@ -23,13 +20,15 @@ export function tokenMiddleware(): DittoMiddleware<{ user?: User }> { if (header) { const { relay, conf } = c.var; - const signer = await getSigner(header, 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 }), + verified: auth.realm === 'Nostr', }; c.set('user', user); @@ -39,34 +38,26 @@ export function tokenMiddleware(): DittoMiddleware<{ user?: User }> { }; } -interface GetSignerOpts { - db: DittoDB; - conf: DittoConf; - relay: NRelay; -} - -function getSigner(header: string, opts: GetSignerOpts): NostrSigner | Promise { - const match = header.match(BEARER_REGEX); - - if (!match) { - throw new HTTPException(400, { message: 'Invalid Authorization header.' }); - } - - const [_, bech32] = match; - - if (isToken(bech32)) { - return getSignerFromToken(bech32, opts); - } else { - return getSignerFromNip19(bech32); +function getSigner(c: Context, auth: Authorization): NostrSigner | Promise { + 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.' }); + } } } -function isToken(value: string): value is `token1${string}` { - return value.startsWith('token1'); -} - -async function getSignerFromToken(token: `token1${string}`, opts: GetSignerOpts): Promise { - const { conf, db, relay } = opts; +async function getSignerFromToken(c: Context, token: `token1${string}`): Promise { + const { conf, db, relay } = c.var; try { const tokenHash = await getTokenHash(token); @@ -109,3 +100,36 @@ function getSignerFromNip19(bech32: string): NostrSigner { throw new HTTPException(401, { message: 'Invalid NIP-19 identifier in Authorization header.' }); } + +async function getSignerFromNip98(c: Context): Promise { + 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'); +} From 806bfc1b45c090ed09883f22bfe51a16035808dd Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 21 Feb 2025 16:54:38 -0600 Subject: [PATCH 15/23] Delete auth98Middleware, replace with userMiddleware --- packages/ditto/app.ts | 146 +++++++++--------- packages/ditto/controllers/api/cashu.ts | 4 +- packages/ditto/middleware/auth98Middleware.ts | 121 --------------- packages/ditto/utils/api.ts | 9 -- packages/mastoapi/middleware/User.ts | 1 - .../mastoapi/middleware/tokenMiddleware.ts | 1 - .../mastoapi/middleware/userMiddleware.ts | 58 ++++++- 7 files changed, 133 insertions(+), 207 deletions(-) delete mode 100644 packages/ditto/middleware/auth98Middleware.ts diff --git a/packages/ditto/app.ts b/packages/ditto/app.ts index 3333b9eb..3b1c3f48 100644 --- a/packages/ditto/app.ts +++ b/packages/ditto/app.ts @@ -7,7 +7,6 @@ import { type Context, Handler, Input as HonoInput, MiddlewareHandler } from '@h import { every } from '@hono/hono/combine'; import { cors } from '@hono/hono/cors'; import { serveStatic } from '@hono/hono/deno'; -import { createFactory } from '@hono/hono/factory'; import { NostrEvent, NostrSigner, NRelay, NUploader } from '@nostrify/nostrify'; import '@/startup.ts'; @@ -138,7 +137,6 @@ import { metricsController } from '@/controllers/metrics.ts'; import { manifestController } from '@/controllers/manifest.ts'; import { nodeInfoController, nodeInfoSchemaController } from '@/controllers/well-known/nodeinfo.ts'; import { nostrController } from '@/controllers/well-known/nostr.ts'; -import { requireProof as _requireProof, requireRole } from '@/middleware/auth98Middleware.ts'; import { cacheControlMiddleware } from '@/middleware/cacheControlMiddleware.ts'; import { cspMiddleware } from '@/middleware/cspMiddleware.ts'; import { metricsMiddleware } from '@/middleware/metricsMiddleware.ts'; @@ -198,11 +196,6 @@ const ratelimit = every( rateLimitMiddleware(300, Time.minutes(5), false), ); -const factory = createFactory(); -const requireSigner = userMiddleware(); -const requireAdmin = factory.createHandlers(requireSigner, requireRole('admin')); -const requireProof = factory.createHandlers(requireSigner, _requireProof()); - app.use('/api/*', metricsMiddleware, ratelimit, paginationMiddleware(), logiMiddleware); app.use('/.well-known/*', metricsMiddleware, ratelimit, logiMiddleware); app.use('/nodeinfo/*', metricsMiddleware, ratelimit, logiMiddleware); @@ -262,27 +255,27 @@ app.post('/oauth/revoke', revokeTokenController); app.post('/oauth/authorize', oauthAuthorizeController); app.get('/oauth/authorize', oauthController); -app.post('/api/v1/accounts', ...requireProof, createAccountController); -app.get('/api/v1/accounts/verify_credentials', requireSigner, verifyCredentialsController); -app.patch('/api/v1/accounts/update_credentials', requireSigner, updateCredentialsController); +app.post('/api/v1/accounts', userMiddleware({ verify: true }), createAccountController); +app.get('/api/v1/accounts/verify_credentials', userMiddleware(), verifyCredentialsController); +app.patch('/api/v1/accounts/update_credentials', userMiddleware(), updateCredentialsController); app.get('/api/v1/accounts/search', accountSearchController); app.get('/api/v1/accounts/lookup', accountLookupController); -app.get('/api/v1/accounts/relationships', requireSigner, relationshipsController); -app.get('/api/v1/accounts/familiar_followers', requireSigner, familiarFollowersController); -app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/block', requireSigner, blockController); -app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/unblock', requireSigner, unblockController); -app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/mute', requireSigner, muteController); -app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/unmute', requireSigner, unmuteController); +app.get('/api/v1/accounts/relationships', userMiddleware(), relationshipsController); +app.get('/api/v1/accounts/familiar_followers', userMiddleware(), familiarFollowersController); +app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/block', userMiddleware(), blockController); +app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/unblock', userMiddleware(), unblockController); +app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/mute', userMiddleware(), muteController); +app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/unmute', userMiddleware(), unmuteController); app.post( '/api/v1/accounts/:pubkey{[0-9a-f]{64}}/follow', rateLimitMiddleware(2, Time.seconds(1)), - requireSigner, + userMiddleware(), followController, ); app.post( '/api/v1/accounts/:pubkey{[0-9a-f]{64}}/unfollow', rateLimitMiddleware(2, Time.seconds(1)), - requireSigner, + userMiddleware(), unfollowController, ); app.get( @@ -306,22 +299,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}}/context', contextController); 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}}/bookmark', requireSigner, bookmarkController); -app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unbookmark', requireSigner, unbookmarkController); -app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/pin', requireSigner, pinController); -app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unpin', requireSigner, unpinController); +app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/favourite', userMiddleware(), favouriteController); +app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/bookmark', userMiddleware(), bookmarkController); +app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unbookmark', userMiddleware(), unbookmarkController); +app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/pin', userMiddleware(), pinController); +app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unpin', userMiddleware(), unpinController); app.post( '/api/v1/statuses/:id{[0-9a-f]{64}}/translate', - requireSigner, + userMiddleware(), rateLimitMiddleware(15, Time.minutes(1)), translatorMiddleware, translateController, ); -app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/reblog', requireSigner, reblogStatusController); -app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unreblog', requireSigner, unreblogStatusController); -app.post('/api/v1/statuses', requireSigner, createStatusController); -app.delete('/api/v1/statuses/:id{[0-9a-f]{64}}', requireSigner, deleteStatusController); +app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/reblog', userMiddleware(), reblogStatusController); +app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unreblog', userMiddleware(), unreblogStatusController); +app.post('/api/v1/statuses', userMiddleware(), createStatusController); +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); @@ -332,7 +325,7 @@ app.put( ); 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/tag/:hashtag', rateLimitMiddleware(8, Time.seconds(30)), hashtagTimelineController); app.get('/api/v1/timelines/suggested', rateLimitMiddleware(8, Time.seconds(30)), suggestedTimelineController); @@ -368,42 +361,42 @@ app.get('/api/v1/suggestions', suggestionsV1Controller); app.get('/api/v2/suggestions', suggestionsV2Controller); 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/:id', requireSigner, notificationController); +app.get('/api/v1/notifications', rateLimitMiddleware(8, Time.seconds(30)), userMiddleware(), notificationsController); +app.get('/api/v1/notifications/:id', userMiddleware(), notificationController); -app.get('/api/v1/favourites', requireSigner, favouritesController); -app.get('/api/v1/bookmarks', requireSigner, bookmarksController); -app.get('/api/v1/blocks', requireSigner, blocksController); -app.get('/api/v1/mutes', requireSigner, mutesController); +app.get('/api/v1/favourites', userMiddleware(), favouritesController); +app.get('/api/v1/bookmarks', userMiddleware(), bookmarksController); +app.get('/api/v1/blocks', userMiddleware(), blocksController); +app.get('/api/v1/mutes', userMiddleware(), mutesController); -app.get('/api/v1/markers', ...requireProof, markersController); -app.post('/api/v1/markers', ...requireProof, updateMarkersController); +app.get('/api/v1/markers', userMiddleware({ verify: true }), markersController); +app.post('/api/v1/markers', userMiddleware({ verify: true }), updateMarkersController); -app.get('/api/v1/push/subscription', requireSigner, getSubscriptionController); -app.post('/api/v1/push/subscription', ...requireProof, pushSubscribeController); +app.get('/api/v1/push/subscription', userMiddleware(), getSubscriptionController); +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/:emoji', reactionsController); -app.put('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', requireSigner, reactionController); -app.delete('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', requireSigner, deleteReactionController); +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', userMiddleware(), deleteReactionController); -app.get('/api/v1/pleroma/admin/config', ...requireAdmin, configController); -app.post('/api/v1/pleroma/admin/config', ...requireAdmin, updateConfigController); -app.delete('/api/v1/pleroma/admin/statuses/:id', ...requireAdmin, pleromaAdminDeleteStatusController); +app.get('/api/v1/pleroma/admin/config', userMiddleware({ role: 'admin' }), configController); +app.post('/api/v1/pleroma/admin/config', userMiddleware({ role: 'admin' }), updateConfigController); +app.delete('/api/v1/pleroma/admin/statuses/:id', userMiddleware({ role: 'admin' }), pleromaAdminDeleteStatusController); -app.get('/api/v1/admin/ditto/relays', ...requireAdmin, adminRelaysController); -app.put('/api/v1/admin/ditto/relays', ...requireAdmin, adminSetRelaysController); +app.get('/api/v1/admin/ditto/relays', userMiddleware({ role: 'admin' }), adminRelaysController); +app.put('/api/v1/admin/ditto/relays', userMiddleware({ role: 'admin' }), adminSetRelaysController); -app.put('/api/v1/admin/ditto/instance', ...requireAdmin, updateInstanceController); +app.put('/api/v1/admin/ditto/instance', userMiddleware({ role: 'admin' }), updateInstanceController); -app.post('/api/v1/ditto/names', requireSigner, nameRequestController); -app.get('/api/v1/ditto/names', requireSigner, nameRequestsController); +app.post('/api/v1/ditto/names', userMiddleware(), nameRequestController); +app.get('/api/v1/ditto/names', userMiddleware(), nameRequestsController); app.get('/api/v1/ditto/captcha', rateLimitMiddleware(3, Time.minutes(1)), captchaController); app.post( '/api/v1/ditto/captcha/:id/verify', rateLimitMiddleware(8, Time.minutes(1)), - ...requireProof, + userMiddleware({ verify: true }), captchaVerifyController, ); @@ -414,44 +407,59 @@ app.get( ); app.get('/api/v1/ditto/:id{[0-9a-f]{64}}/zap_splits', statusZapSplitsController); -app.put('/api/v1/admin/ditto/zap_splits', ...requireAdmin, updateZapSplitsController); -app.delete('/api/v1/admin/ditto/zap_splits', ...requireAdmin, deleteZapSplitsController); +app.put('/api/v1/admin/ditto/zap_splits', userMiddleware({ role: 'admin' }), updateZapSplitsController); +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.route('/api/v1/ditto/cashu', cashuApp); -app.post('/api/v1/reports', requireSigner, reportController); -app.get('/api/v1/admin/reports', requireSigner, ...requireAdmin, adminReportsController); -app.get('/api/v1/admin/reports/:id{[0-9a-f]{64}}', requireSigner, ...requireAdmin, adminReportController); +app.post('/api/v1/reports', userMiddleware(), reportController); +app.get('/api/v1/admin/reports', userMiddleware(), userMiddleware({ role: 'admin' }), adminReportsController); +app.get( + '/api/v1/admin/reports/:id{[0-9a-f]{64}}', + userMiddleware(), + userMiddleware({ role: 'admin' }), + adminReportController, +); app.post( '/api/v1/admin/reports/:id{[0-9a-f]{64}}/resolve', - requireSigner, - ...requireAdmin, + userMiddleware(), + userMiddleware({ role: 'admin' }), adminReportResolveController, ); app.post( '/api/v1/admin/reports/:id{[0-9a-f]{64}}/reopen', - requireSigner, - ...requireAdmin, + userMiddleware(), + userMiddleware({ role: 'admin' }), adminReportReopenController, ); -app.get('/api/v1/admin/accounts', ...requireAdmin, adminAccountsController); -app.post('/api/v1/admin/accounts/:id{[0-9a-f]{64}}/action', requireSigner, ...requireAdmin, adminActionController); +app.get('/api/v1/admin/accounts', userMiddleware({ role: 'admin' }), adminAccountsController); +app.post( + '/api/v1/admin/accounts/:id{[0-9a-f]{64}}/action', + userMiddleware(), + userMiddleware({ role: 'admin' }), + adminActionController, +); app.post( '/api/v1/admin/accounts/:id{[0-9a-f]{64}}/approve', - requireSigner, - ...requireAdmin, + userMiddleware(), + userMiddleware({ role: 'admin' }), adminApproveController, ); -app.post('/api/v1/admin/accounts/:id{[0-9a-f]{64}}/reject', requireSigner, ...requireAdmin, 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', ...requireAdmin, pleromaAdminTagController); -app.delete('/api/v1/pleroma/admin/users/tag', ...requireAdmin, pleromaAdminUntagController); -app.patch('/api/v1/pleroma/admin/users/suggest', ...requireAdmin, pleromaAdminSuggestController); -app.patch('/api/v1/pleroma/admin/users/unsuggest', ...requireAdmin, pleromaAdminUnsuggestController); +app.put('/api/v1/pleroma/admin/users/tag', userMiddleware({ role: 'admin' }), pleromaAdminTagController); +app.delete('/api/v1/pleroma/admin/users/tag', userMiddleware({ role: 'admin' }), pleromaAdminUntagController); +app.patch('/api/v1/pleroma/admin/users/suggest', userMiddleware({ role: 'admin' }), pleromaAdminSuggestController); +app.patch('/api/v1/pleroma/admin/users/unsuggest', userMiddleware({ role: 'admin' }), pleromaAdminUnsuggestController); // Not (yet) implemented. app.get('/api/v1/custom_emojis', emptyArrayController); diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index 2d3a1519..bb39397c 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -42,7 +42,7 @@ const createCashuWalletAndNutzapInfoSchema = z.object({ * https://github.com/nostr-protocol/nips/blob/master/60.md * https://github.com/nostr-protocol/nips/blob/master/61.md#nutzap-informational-event */ -route.put('/wallet', userMiddleware('nip44'), async (c) => { +route.put('/wallet', userMiddleware({ enc: 'nip44' }), async (c) => { const { conf, user, relay, signal } = c.var; const pubkey = await user.signer.getPublicKey(); @@ -104,7 +104,7 @@ route.put('/wallet', userMiddleware('nip44'), async (c) => { }); /** Gets a wallet, if it exists. */ -route.get('/wallet', userMiddleware('nip44'), swapNutzapsMiddleware, async (c) => { +route.get('/wallet', userMiddleware({ enc: 'nip44' }), swapNutzapsMiddleware, async (c) => { const { conf, relay, user, signal } = c.var; const pubkey = await user.signer.getPublicKey(); diff --git a/packages/ditto/middleware/auth98Middleware.ts b/packages/ditto/middleware/auth98Middleware.ts deleted file mode 100644 index 6cab6566..00000000 --- a/packages/ditto/middleware/auth98Middleware.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { buildAuthEventTemplate, parseAuthRequest, type ParseAuthRequestOpts, validateAuthEvent } from '@ditto/nip98'; -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 { localRequest } from '@/utils/api.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) { - const user = { - relay: c.var.relay, - signer: new ReadOnlySigner(result.data.pubkey), - ...c.var.user, - }; - - c.set('user', user); - } - - 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, relay } = c.var; - - const [user] = await relay.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) => Promise, - opts?: ParseAuthRequestOpts, -): AppMiddleware { - return async (c, next) => { - const signer = c.var.user?.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) { - const user = { - relay: c.var.relay, - signer: new ReadOnlySigner(proof.pubkey), - ...c.var.user, - }; - - c.set('user', user); - } - - 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.var.user?.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 }; diff --git a/packages/ditto/utils/api.ts b/packages/ditto/utils/api.ts index 37a38d6a..591c1852 100644 --- a/packages/ditto/utils/api.ts +++ b/packages/ditto/utils/api.ts @@ -1,4 +1,3 @@ -import { type Context } from '@hono/hono'; import { HTTPException } from '@hono/hono/http-exception'; import { NostrEvent, NostrFilter } from '@nostrify/nostrify'; import { logi } from '@soapbox/logi'; @@ -257,13 +256,6 @@ function paginatedList( 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. */ function assertAuthenticated(c: AppContext, author: NostrEvent): void { if ( @@ -282,7 +274,6 @@ export { createAdminEvent, createEvent, type EventStub, - localRequest, paginated, paginatedList, parseBody, diff --git a/packages/mastoapi/middleware/User.ts b/packages/mastoapi/middleware/User.ts index 8fd61f96..ac38b8de 100644 --- a/packages/mastoapi/middleware/User.ts +++ b/packages/mastoapi/middleware/User.ts @@ -3,5 +3,4 @@ import type { NostrSigner, NRelay } from '@nostrify/nostrify'; export interface User { signer: S; relay: R; - verified?: boolean; } diff --git a/packages/mastoapi/middleware/tokenMiddleware.ts b/packages/mastoapi/middleware/tokenMiddleware.ts index 4796b5d4..ad174c72 100644 --- a/packages/mastoapi/middleware/tokenMiddleware.ts +++ b/packages/mastoapi/middleware/tokenMiddleware.ts @@ -28,7 +28,6 @@ export function tokenMiddleware(): DittoMiddleware<{ user?: User }> { const user: User = { signer, relay: new UserStore({ relay, userPubkey, adminPubkey }), - verified: auth.realm === 'Nostr', }; c.set('user', user); diff --git a/packages/mastoapi/middleware/userMiddleware.ts b/packages/mastoapi/middleware/userMiddleware.ts index 5b18e718..1afef59a 100644 --- a/packages/mastoapi/middleware/userMiddleware.ts +++ b/packages/mastoapi/middleware/userMiddleware.ts @@ -1,18 +1,29 @@ +import { buildAuthEventTemplate, validateAuthEvent } from '@ditto/nip98'; import { HTTPException } from '@hono/hono/http-exception'; import type { DittoMiddleware } from '@ditto/router'; -import type { NostrSigner } from '@nostrify/nostrify'; +import type { NostrEvent, NostrSigner } from '@nostrify/nostrify'; import type { SetRequired } from 'type-fest'; import type { User } from './User.ts'; type Nip44Signer = SetRequired; +interface UserMiddlewareOpts { + enc?: 'nip04' | 'nip44'; + role?: string; + verify?: boolean; +} + export function userMiddleware(): DittoMiddleware<{ user: User }>; // @ts-ignore Types are right. -export function userMiddleware(enc: 'nip44'): DittoMiddleware<{ user: User }>; -export function userMiddleware(enc?: 'nip04' | 'nip44'): DittoMiddleware<{ user: User }> { +export function userMiddleware( + opts: UserMiddlewareOpts & { enc: 'nip44' }, +): DittoMiddleware<{ user: User }>; +export function userMiddleware(opts: UserMiddlewareOpts): DittoMiddleware<{ user: User }>; +export function userMiddleware(opts: UserMiddlewareOpts = {}): DittoMiddleware<{ user: User }> { return async (c, next) => { - const { user } = c.var; + const { conf, user, relay } = c.var; + const { enc, role, verify } = opts; if (!user) { throw new HTTPException(403, { message: 'Authorization required.' }); @@ -22,6 +33,45 @@ export function userMiddleware(enc?: 'nip04' | 'nip44'): DittoMiddleware<{ user: throw new HTTPException(403, { 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(403, { 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); +} From 26e87b396205aaf3b28e3bf000ca05e2c28c0a06 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 21 Feb 2025 17:44:56 -0600 Subject: [PATCH 16/23] tokenMiddleware: pass token to streaming API --- packages/ditto/app.ts | 9 ++++- packages/ditto/controllers/api/streaming.ts | 36 +++---------------- .../mastoapi/middleware/tokenMiddleware.ts | 6 ++-- packages/policies/MuteListPolicy.ts | 2 +- 4 files changed, 17 insertions(+), 36 deletions(-) diff --git a/packages/ditto/app.ts b/packages/ditto/app.ts index 3b1c3f48..2b90f132 100644 --- a/packages/ditto/app.ts +++ b/packages/ditto/app.ts @@ -196,12 +196,19 @@ const ratelimit = every( rateLimitMiddleware(300, Time.minutes(5), false), ); +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('/nodeinfo/*', 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.use( diff --git a/packages/ditto/controllers/api/streaming.ts b/packages/ditto/controllers/api/streaming.ts index b39f1db5..cdd8dae3 100644 --- a/packages/ditto/controllers/api/streaming.ts +++ b/packages/ditto/controllers/api/streaming.ts @@ -12,13 +12,10 @@ import { z } from 'zod'; import { type AppController } from '@/app.ts'; import { getFeedPubkeys } from '@/queries.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 { bech32ToPubkey, Time } from '@/utils.ts'; +import { Time } from '@/utils.ts'; import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts'; import { renderNotification } from '@/views/mastodon/notifications.ts'; -import { HTTPException } from '@hono/hono/http-exception'; /** * Streaming timelines/categories. @@ -68,7 +65,7 @@ const limiter = new TTLCache(); const connections = new Set(); const streamingController: AppController = async (c) => { - const { conf, relay } = c.var; + const { conf, relay, user } = c.var; const upgrade = c.req.header('upgrade'); const token = c.req.header('sec-websocket-protocol'); 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); } - 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'); if (ip) { const count = limiter.get(ip) ?? 0; @@ -93,7 +85,8 @@ const streamingController: AppController = async (c) => { const { socket, response } = Deno.upgradeWebSocket(c.req.raw, { protocol: token, idleTimeout: 30 }); - const policy = pubkey ? new MuteListPolicy(pubkey, await Storages.admin()) : undefined; + const pubkey = await user?.signer.getPublicKey(); + const policy = pubkey ? new MuteListPolicy(pubkey, relay) : undefined; function send(e: StreamingEvent) { if (socket.readyState === WebSocket.OPEN) { @@ -229,25 +222,4 @@ async function topicToFilter( } } -async function getTokenPubkey(token: string): Promise { - 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 }; diff --git a/packages/mastoapi/middleware/tokenMiddleware.ts b/packages/mastoapi/middleware/tokenMiddleware.ts index ad174c72..d4f8a05b 100644 --- a/packages/mastoapi/middleware/tokenMiddleware.ts +++ b/packages/mastoapi/middleware/tokenMiddleware.ts @@ -13,9 +13,11 @@ import type { DittoEnv, DittoMiddleware } from '@ditto/router'; import type { Context } from '@hono/hono'; import type { User } from './User.ts'; -export function tokenMiddleware(): DittoMiddleware<{ user?: User }> { +type CredentialsFn = (c: Context) => string | undefined; + +export function tokenMiddleware(fn?: CredentialsFn): DittoMiddleware<{ user?: User }> { return async (c, next) => { - const header = c.req.header('authorization'); + const header = fn ? fn(c) : c.req.header('authorization'); if (header) { const { relay, conf } = c.var; diff --git a/packages/policies/MuteListPolicy.ts b/packages/policies/MuteListPolicy.ts index d880c57d..1025e75b 100644 --- a/packages/policies/MuteListPolicy.ts +++ b/packages/policies/MuteListPolicy.ts @@ -15,7 +15,7 @@ export class MuteListPolicy implements NPolicy { } 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, '']; From 6b1aadc24c75d939033be5aef94509220100839d Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 21 Feb 2025 18:46:43 -0600 Subject: [PATCH 17/23] nip98: add explicit types to exported functions --- packages/nip98/nip98.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/nip98/nip98.ts b/packages/nip98/nip98.ts index e8574c86..b0815f91 100644 --- a/packages/nip98/nip98.ts +++ b/packages/nip98/nip98.ts @@ -4,6 +4,8 @@ import { type EventTemplate, nip13 } from 'nostr-tools'; import { decode64Schema, signedEventSchema } from './schema.ts'; +import type { z } from 'zod'; + /** Decode a Nostr event from a base64 encoded string. */ const decode64EventSchema = decode64Schema.pipe(n.json()).pipe(signedEventSchema); @@ -18,7 +20,10 @@ interface ParseAuthRequestOpts { /** Parse the auth event from a Request, returning a zod SafeParse type. */ // deno-lint-ignore require-await -async function parseAuthRequest(req: Request, opts: ParseAuthRequestOpts = {}) { +async function parseAuthRequest( + req: Request, + opts: ParseAuthRequestOpts = {}, +): Promise | z.SafeParseError> { const header = req.headers.get('authorization'); const base64 = header?.match(/^Nostr (.+)$/)?.[1]; const result = decode64EventSchema.safeParse(base64); @@ -28,7 +33,11 @@ async function parseAuthRequest(req: Request, opts: ParseAuthRequestOpts = {}) { } /** Compare the auth event with the request, returning a zod SafeParse type. */ -function validateAuthEvent(req: Request, event: NostrEvent, opts: ParseAuthRequestOpts = {}) { +function validateAuthEvent( + req: Request, + event: NostrEvent, + opts: ParseAuthRequestOpts = {}, +): Promise> { const { maxAge = 60_000, validatePayload = true, pow = 0 } = opts; const schema = signedEventSchema From d2abb1f1e48725d71895523a5b48e177ad3f7b01 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 21 Feb 2025 18:59:14 -0600 Subject: [PATCH 18/23] Fix MuteListPolicy test --- packages/policies/MuteListPolicy.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/policies/MuteListPolicy.test.ts b/packages/policies/MuteListPolicy.test.ts index d07c4472..21c29cbc 100644 --- a/packages/policies/MuteListPolicy.test.ts +++ b/packages/policies/MuteListPolicy.test.ts @@ -25,7 +25,7 @@ Deno.test('block event: muted user cannot post', async () => { 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 () => { From d4fc10fe3e6f58ebaa6d83bca256adacccd221b6 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 21 Feb 2025 21:09:22 -0600 Subject: [PATCH 19/23] Add userMiddleware tests --- packages/db/adapters/DummyDB.test.ts | 9 ++ packages/db/adapters/DummyDB.ts | 29 ++++++ packages/db/mod.ts | 3 + .../middleware/userMiddleware.test.ts | 99 +++++++++++++++++++ .../mastoapi/middleware/userMiddleware.ts | 8 +- 5 files changed, 144 insertions(+), 4 deletions(-) create mode 100644 packages/db/adapters/DummyDB.test.ts create mode 100644 packages/db/adapters/DummyDB.ts create mode 100644 packages/mastoapi/middleware/userMiddleware.test.ts diff --git a/packages/db/adapters/DummyDB.test.ts b/packages/db/adapters/DummyDB.test.ts new file mode 100644 index 00000000..c725ab51 --- /dev/null +++ b/packages/db/adapters/DummyDB.test.ts @@ -0,0 +1,9 @@ +import { assertEquals } from '@std/assert'; +import { DummyDB } from './DummyDB.ts'; + +Deno.test('DummyDB', async () => { + const db = DummyDB.create(); + const rows = await db.kysely.selectFrom('nostr_events').selectAll().execute(); + + assertEquals(rows, []); +}); diff --git a/packages/db/adapters/DummyDB.ts b/packages/db/adapters/DummyDB.ts new file mode 100644 index 00000000..51c29b10 --- /dev/null +++ b/packages/db/adapters/DummyDB.ts @@ -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; + readonly poolSize = 0; + readonly availableConnections = 0; + + constructor() { + this.kysely = new Kysely({ + dialect: { + createAdapter: () => new PostgresAdapter(), + createDriver: () => new DummyDriver(), + createIntrospector: (db) => new PostgresIntrospector(db), + createQueryCompiler: () => new PostgresQueryCompiler(), + }, + }); + } + + listen(): void { + // noop + } + + [Symbol.asyncDispose](): Promise { + return Promise.resolve(); + } +} diff --git a/packages/db/mod.ts b/packages/db/mod.ts index 49100cd6..2766e524 100644 --- a/packages/db/mod.ts +++ b/packages/db/mod.ts @@ -1,4 +1,7 @@ +export { DittoPglite } from './adapters/DittoPglite.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 { DittoTables } from './DittoTables.ts'; diff --git a/packages/mastoapi/middleware/userMiddleware.test.ts b/packages/mastoapi/middleware/userMiddleware.test.ts new file mode 100644 index 00000000..a72a5677 --- /dev/null +++ b/packages/mastoapi/middleware/userMiddleware.test.ts @@ -0,0 +1,99 @@ +import { DittoConf } from '@ditto/conf'; +import { DummyDB } from '@ditto/db'; +import { DittoApp, type DittoMiddleware } from '@ditto/router'; +import { type NostrSigner, NSecSigner } from '@nostrify/nostrify'; +import { MockRelay } from '@nostrify/nostrify/test'; +import { assertEquals } from '@std/assert'; +import { generateSecretKey, nip19 } from 'nostr-tools'; + +import { userMiddleware } from './userMiddleware.ts'; +import { ReadOnlySigner } from '../signers/ReadOnlySigner.ts'; + +import type { User } from './User.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); +}); + +function testApp() { + const relay = new MockRelay(); + const signer = new NSecSigner(generateSecretKey()); + const conf = new DittoConf(new Map([['DITTO_NSEC', nip19.nsecEncode(generateSecretKey())]])); + const db = new DummyDB(); + const app = new DittoApp({ conf, relay, db }); + const user = { signer, relay }; + + return { app, relay, conf, db, user }; +} + +function setUser(user: User): DittoMiddleware<{ user: User }> { + return async (c, next) => { + c.set('user', user); + await next(); + }; +} diff --git a/packages/mastoapi/middleware/userMiddleware.ts b/packages/mastoapi/middleware/userMiddleware.ts index 1afef59a..8308172d 100644 --- a/packages/mastoapi/middleware/userMiddleware.ts +++ b/packages/mastoapi/middleware/userMiddleware.ts @@ -26,11 +26,11 @@ export function userMiddleware(opts: UserMiddlewareOpts = {}): DittoMiddleware<{ const { enc, role, verify } = opts; if (!user) { - throw new HTTPException(403, { message: 'Authorization required.' }); + throw new HTTPException(401, { message: 'Authorization required' }); } if (enc && !user.signer[enc]) { - throw new HTTPException(403, { message: `User does not have a ${enc} signer` }); + throw new HTTPException(400, { message: `User does not have a ${enc} signer` }); } if (role || verify) { @@ -40,7 +40,7 @@ export function userMiddleware(opts: UserMiddlewareOpts = {}): DittoMiddleware<{ const result = await validateAuthEvent(req, resEvent); if (!result.success) { - throw new HTTPException(403, { message: 'Verification failed.' }); + throw new HTTPException(401, { message: 'Verification failed' }); } // Prevent people from accidentally using the wrong account. This has no other security implications. @@ -57,7 +57,7 @@ export function userMiddleware(opts: UserMiddlewareOpts = {}): DittoMiddleware<{ }]); if (!user || !matchesRole(user, role)) { - throw new HTTPException(403, { message: `Must have ${role} role.` }); + throw new HTTPException(403, { message: `Must have ${role} role` }); } } } From 9c97cc387f68176678935b652b82962ec6fa0744 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 21 Feb 2025 21:15:57 -0600 Subject: [PATCH 20/23] mastoapi: add a test module --- packages/mastoapi/deno.json | 3 +- .../middleware/userMiddleware.test.ts | 27 +--------------- packages/mastoapi/test.ts | 32 +++++++++++++++++++ 3 files changed, 35 insertions(+), 27 deletions(-) create mode 100644 packages/mastoapi/test.ts diff --git a/packages/mastoapi/deno.json b/packages/mastoapi/deno.json index f9abac55..ddeb175f 100644 --- a/packages/mastoapi/deno.json +++ b/packages/mastoapi/deno.json @@ -2,6 +2,7 @@ "name": "@ditto/mastoapi", "version": "1.1.0", "exports": { - "./middleware": "./middleware/mod.ts" + "./middleware": "./middleware/mod.ts", + "./test": "./test.ts" } } diff --git a/packages/mastoapi/middleware/userMiddleware.test.ts b/packages/mastoapi/middleware/userMiddleware.test.ts index a72a5677..2d30b0dc 100644 --- a/packages/mastoapi/middleware/userMiddleware.test.ts +++ b/packages/mastoapi/middleware/userMiddleware.test.ts @@ -1,16 +1,9 @@ -import { DittoConf } from '@ditto/conf'; -import { DummyDB } from '@ditto/db'; -import { DittoApp, type DittoMiddleware } from '@ditto/router'; -import { type NostrSigner, NSecSigner } from '@nostrify/nostrify'; -import { MockRelay } from '@nostrify/nostrify/test'; +import { setUser, testApp } from '@ditto/mastoapi/test'; import { assertEquals } from '@std/assert'; -import { generateSecretKey, nip19 } from 'nostr-tools'; import { userMiddleware } from './userMiddleware.ts'; import { ReadOnlySigner } from '../signers/ReadOnlySigner.ts'; -import type { User } from './User.ts'; - Deno.test('no user 401', async () => { const { app } = testApp(); const response = await app.use(userMiddleware()).request('/'); @@ -79,21 +72,3 @@ Deno.test('admin role 200', async () => { assertEquals(response.status, 200); }); - -function testApp() { - const relay = new MockRelay(); - const signer = new NSecSigner(generateSecretKey()); - const conf = new DittoConf(new Map([['DITTO_NSEC', nip19.nsecEncode(generateSecretKey())]])); - const db = new DummyDB(); - const app = new DittoApp({ conf, relay, db }); - const user = { signer, relay }; - - return { app, relay, conf, db, user }; -} - -function setUser(user: User): DittoMiddleware<{ user: User }> { - return async (c, next) => { - c.set('user', user); - await next(); - }; -} diff --git a/packages/mastoapi/test.ts b/packages/mastoapi/test.ts new file mode 100644 index 00000000..70a5e1af --- /dev/null +++ b/packages/mastoapi/test.ts @@ -0,0 +1,32 @@ +import { DittoConf } from '@ditto/conf'; +import { DummyDB } from '@ditto/db'; +import { DittoApp, type DittoMiddleware } from '@ditto/router'; +import { type NostrSigner, 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() { + 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(user: User): DittoMiddleware<{ user: User }> { + return async (c, next) => { + c.set('user', user); + await next(); + }; +} From 07b68b71d2dc5ad138e95e879ebdbbb6cad0e488 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 21 Feb 2025 21:31:42 -0600 Subject: [PATCH 21/23] Add missing types to testApp --- packages/mastoapi/test.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/mastoapi/test.ts b/packages/mastoapi/test.ts index 70a5e1af..78753511 100644 --- a/packages/mastoapi/test.ts +++ b/packages/mastoapi/test.ts @@ -1,13 +1,22 @@ import { DittoConf } from '@ditto/conf'; -import { DummyDB } from '@ditto/db'; +import { type DittoDB, DummyDB } from '@ditto/db'; import { DittoApp, type DittoMiddleware } from '@ditto/router'; -import { type NostrSigner, NSecSigner } from '@nostrify/nostrify'; +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() { +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()); From 084c6aa94443422f40a91537ae09cde7c354da00 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 21 Feb 2025 21:55:00 -0600 Subject: [PATCH 22/23] Fix DummyDB test --- packages/db/adapters/DummyDB.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/db/adapters/DummyDB.test.ts b/packages/db/adapters/DummyDB.test.ts index c725ab51..9945be45 100644 --- a/packages/db/adapters/DummyDB.test.ts +++ b/packages/db/adapters/DummyDB.test.ts @@ -2,7 +2,7 @@ import { assertEquals } from '@std/assert'; import { DummyDB } from './DummyDB.ts'; Deno.test('DummyDB', async () => { - const db = DummyDB.create(); + const db = new DummyDB(); const rows = await db.kysely.selectFrom('nostr_events').selectAll().execute(); assertEquals(rows, []); From 4ed064076698f19314c0d07797c7fe89a0b851d0 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 21 Feb 2025 23:32:15 -0600 Subject: [PATCH 23/23] @ditto/router -> @ditto/mastoapi/router --- deno.json | 1 - packages/ditto/app.ts | 2 +- packages/ditto/controllers/api/cashu.test.ts | 2 +- packages/ditto/controllers/api/cashu.ts | 2 +- packages/mastoapi/deno.json | 1 + packages/mastoapi/middleware/paginationMiddleware.ts | 2 +- packages/mastoapi/middleware/tokenMiddleware.ts | 2 +- packages/mastoapi/middleware/userMiddleware.ts | 2 +- packages/{ => mastoapi}/router/DittoApp.test.ts | 0 packages/{ => mastoapi}/router/DittoApp.ts | 0 packages/{ => mastoapi}/router/DittoEnv.ts | 0 packages/{ => mastoapi}/router/DittoMiddleware.ts | 0 packages/{ => mastoapi}/router/DittoRoute.test.ts | 0 packages/{ => mastoapi}/router/DittoRoute.ts | 0 packages/{ => mastoapi}/router/mod.ts | 0 packages/mastoapi/test.ts | 2 +- packages/router/deno.json | 7 ------- 17 files changed, 8 insertions(+), 15 deletions(-) rename packages/{ => mastoapi}/router/DittoApp.test.ts (100%) rename packages/{ => mastoapi}/router/DittoApp.ts (100%) rename packages/{ => mastoapi}/router/DittoEnv.ts (100%) rename packages/{ => mastoapi}/router/DittoMiddleware.ts (100%) rename packages/{ => mastoapi}/router/DittoRoute.test.ts (100%) rename packages/{ => mastoapi}/router/DittoRoute.ts (100%) rename packages/{ => mastoapi}/router/mod.ts (100%) delete mode 100644 packages/router/deno.json diff --git a/deno.json b/deno.json index 20d87204..05ecb34a 100644 --- a/deno.json +++ b/deno.json @@ -10,7 +10,6 @@ "./packages/nip98", "./packages/policies", "./packages/ratelimiter", - "./packages/router", "./packages/translators", "./packages/uploaders" ], diff --git a/packages/ditto/app.ts b/packages/ditto/app.ts index 2b90f132..eab81b47 100644 --- a/packages/ditto/app.ts +++ b/packages/ditto/app.ts @@ -1,7 +1,7 @@ import { DittoConf } from '@ditto/conf'; import { DittoDB } from '@ditto/db'; import { paginationMiddleware, tokenMiddleware, userMiddleware } from '@ditto/mastoapi/middleware'; -import { DittoApp, type DittoEnv } from '@ditto/router'; +import { DittoApp, type DittoEnv } from '@ditto/mastoapi/router'; import { type DittoTranslator } from '@ditto/translators'; import { type Context, Handler, Input as HonoInput, MiddlewareHandler } from '@hono/hono'; import { every } from '@hono/hono/combine'; diff --git a/packages/ditto/controllers/api/cashu.test.ts b/packages/ditto/controllers/api/cashu.test.ts index 22e9a38f..1b28d099 100644 --- a/packages/ditto/controllers/api/cashu.test.ts +++ b/packages/ditto/controllers/api/cashu.test.ts @@ -1,6 +1,6 @@ import { DittoConf } from '@ditto/conf'; import { type User } from '@ditto/mastoapi/middleware'; -import { DittoApp, DittoMiddleware } from '@ditto/router'; +import { DittoApp, DittoMiddleware } from '@ditto/mastoapi/router'; import { NSecSigner } from '@nostrify/nostrify'; import { genEvent } from '@nostrify/nostrify/test'; import { bytesToString, stringToBytes } from '@scure/base'; diff --git a/packages/ditto/controllers/api/cashu.ts b/packages/ditto/controllers/api/cashu.ts index bb39397c..a98a0309 100644 --- a/packages/ditto/controllers/api/cashu.ts +++ b/packages/ditto/controllers/api/cashu.ts @@ -1,6 +1,6 @@ import { Proof } from '@cashu/cashu-ts'; import { userMiddleware } from '@ditto/mastoapi/middleware'; -import { DittoRoute } from '@ditto/router'; +import { DittoRoute } from '@ditto/mastoapi/router'; import { generateSecretKey, getPublicKey } from 'nostr-tools'; import { bytesToString, stringToBytes } from '@scure/base'; import { z } from 'zod'; diff --git a/packages/mastoapi/deno.json b/packages/mastoapi/deno.json index ddeb175f..d98dbc91 100644 --- a/packages/mastoapi/deno.json +++ b/packages/mastoapi/deno.json @@ -3,6 +3,7 @@ "version": "1.1.0", "exports": { "./middleware": "./middleware/mod.ts", + "./router": "./router/mod.ts", "./test": "./test.ts" } } diff --git a/packages/mastoapi/middleware/paginationMiddleware.ts b/packages/mastoapi/middleware/paginationMiddleware.ts index cca64229..28a7f1a1 100644 --- a/packages/mastoapi/middleware/paginationMiddleware.ts +++ b/packages/mastoapi/middleware/paginationMiddleware.ts @@ -1,7 +1,7 @@ import { paginated, paginatedList } from '../pagination/paginate.ts'; import { paginationSchema } from '../pagination/schema.ts'; -import type { DittoMiddleware } from '@ditto/router'; +import type { DittoMiddleware } from '@ditto/mastoapi/router'; import type { NostrEvent } from '@nostrify/nostrify'; interface Pagination { diff --git a/packages/mastoapi/middleware/tokenMiddleware.ts b/packages/mastoapi/middleware/tokenMiddleware.ts index d4f8a05b..a2241c19 100644 --- a/packages/mastoapi/middleware/tokenMiddleware.ts +++ b/packages/mastoapi/middleware/tokenMiddleware.ts @@ -9,7 +9,7 @@ import { ConnectSigner } from '../signers/ConnectSigner.ts'; import { ReadOnlySigner } from '../signers/ReadOnlySigner.ts'; import { UserStore } from '../storages/UserStore.ts'; -import type { DittoEnv, DittoMiddleware } from '@ditto/router'; +import type { DittoEnv, DittoMiddleware } from '@ditto/mastoapi/router'; import type { Context } from '@hono/hono'; import type { User } from './User.ts'; diff --git a/packages/mastoapi/middleware/userMiddleware.ts b/packages/mastoapi/middleware/userMiddleware.ts index 8308172d..2b964362 100644 --- a/packages/mastoapi/middleware/userMiddleware.ts +++ b/packages/mastoapi/middleware/userMiddleware.ts @@ -1,7 +1,7 @@ import { buildAuthEventTemplate, validateAuthEvent } from '@ditto/nip98'; import { HTTPException } from '@hono/hono/http-exception'; -import type { DittoMiddleware } from '@ditto/router'; +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'; diff --git a/packages/router/DittoApp.test.ts b/packages/mastoapi/router/DittoApp.test.ts similarity index 100% rename from packages/router/DittoApp.test.ts rename to packages/mastoapi/router/DittoApp.test.ts diff --git a/packages/router/DittoApp.ts b/packages/mastoapi/router/DittoApp.ts similarity index 100% rename from packages/router/DittoApp.ts rename to packages/mastoapi/router/DittoApp.ts diff --git a/packages/router/DittoEnv.ts b/packages/mastoapi/router/DittoEnv.ts similarity index 100% rename from packages/router/DittoEnv.ts rename to packages/mastoapi/router/DittoEnv.ts diff --git a/packages/router/DittoMiddleware.ts b/packages/mastoapi/router/DittoMiddleware.ts similarity index 100% rename from packages/router/DittoMiddleware.ts rename to packages/mastoapi/router/DittoMiddleware.ts diff --git a/packages/router/DittoRoute.test.ts b/packages/mastoapi/router/DittoRoute.test.ts similarity index 100% rename from packages/router/DittoRoute.test.ts rename to packages/mastoapi/router/DittoRoute.test.ts diff --git a/packages/router/DittoRoute.ts b/packages/mastoapi/router/DittoRoute.ts similarity index 100% rename from packages/router/DittoRoute.ts rename to packages/mastoapi/router/DittoRoute.ts diff --git a/packages/router/mod.ts b/packages/mastoapi/router/mod.ts similarity index 100% rename from packages/router/mod.ts rename to packages/mastoapi/router/mod.ts diff --git a/packages/mastoapi/test.ts b/packages/mastoapi/test.ts index 78753511..41e35c2c 100644 --- a/packages/mastoapi/test.ts +++ b/packages/mastoapi/test.ts @@ -1,6 +1,6 @@ import { DittoConf } from '@ditto/conf'; import { type DittoDB, DummyDB } from '@ditto/db'; -import { DittoApp, type DittoMiddleware } from '@ditto/router'; +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'; diff --git a/packages/router/deno.json b/packages/router/deno.json deleted file mode 100644 index 8321baaf..00000000 --- a/packages/router/deno.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "@ditto/router", - "version": "1.1.0", - "exports": { - ".": "./mod.ts" - } -}