From 45433998fde26a1f19a82be4e5b47832ea32a06d Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 31 Jan 2025 15:54:46 -0600 Subject: [PATCH] Add a DittoController --- src/DittoController.ts | 121 ++++++++++++++++++++++++++++++++ src/app.ts | 2 +- src/controllers/api/instance.ts | 7 +- 3 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 src/DittoController.ts diff --git a/src/DittoController.ts b/src/DittoController.ts new file mode 100644 index 00000000..1e62ce7a --- /dev/null +++ b/src/DittoController.ts @@ -0,0 +1,121 @@ +import { Context, Handler, MiddlewareHandler } from '@hono/hono'; +import { every } from '@hono/hono/combine'; +import { cors } from '@hono/hono/cors'; +import { HandlerResponse } from '@hono/hono/types'; +import { NostrEvent, NostrSigner, NStore, NUploader } from '@nostrify/nostrify'; +import { Kysely } from 'kysely'; + +import { DittoTables } from '@/db/DittoTables.ts'; +import { DittoTranslator } from '@/interfaces/DittoTranslator.ts'; +import { auth98Middleware, requireProof, requireRole } from '@/middleware/auth98Middleware.ts'; +import { cspMiddleware } from '@/middleware/cspMiddleware.ts'; +import { paginationMiddleware } from '@/middleware/paginationMiddleware.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'; + +interface DittoEnv { + /** 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; + /** Normalized pagination params. */ + pagination: { since?: number; until?: number; limit: number }; + /** Normalized list pagination params. */ + listPagination: { offset: number; limit: number }; + /** Translation service. */ + translator?: DittoTranslator; + /** Signal to abort the request. */ + signal: AbortSignal; +} + +interface DittoControllerOpts { + path?: string; + requireSigner?: boolean; + requireProof?: boolean; + requireRole?: 'admin'; + timeout?: number; +} + +// deno-lint-ignore ban-types +type RequireSigner = O['requireSigner'] extends true ? { signer: NostrSigner } : {}; +// deno-lint-ignore ban-types +type RequireProof = O['requireProof'] extends true ? { proof: NostrEvent } : {}; + +type DittoContext = + & DittoEnv + & Pick, 'json' | 'req'> + & RequireSigner + & RequireProof; +type DittoHandler = (c: DittoContext) => HandlerResponse; + +export class DittoController< + O extends DittoControllerOpts, + P extends string = O['path'] extends string ? O['path'] : any, +> { + private readonly _handler: DittoHandler; + private readonly opts: O; + + constructor(handler: DittoHandler); + constructor(opts: O, handler: DittoHandler); + constructor(arg1: O | DittoHandler, arg2?: DittoHandler) { + if (typeof arg1 === 'function') { + this._handler = arg1; + this.opts = {} as O; + } else { + this._handler = arg2!; + this.opts = arg1; + } + } + + get handler(): Handler { + const middleware: MiddlewareHandler[] = [ + cspMiddleware(), + cors({ origin: '*', exposeHeaders: ['link'] }), + signerMiddleware, + uploaderMiddleware, + auth98Middleware(), + storeMiddleware, + paginationMiddleware, + ]; + + if (this.opts.requireSigner) { + middleware.push(requireSigner); + } + + if (this.opts.requireProof) { + middleware.push(requireProof()); + } + + if (typeof this.opts.requireRole === 'string') { + middleware.push(requireRole(this.opts.requireRole)); + } + + const handler: Handler = (c) => { + return this._handler({ + signer: c.get('signer'), + proof: c.get('proof'), + kysely: c.get('kysely'), + store: c.get('store'), + pagination: c.get('pagination'), + listPagination: c.get('listPagination'), + translator: c.get('translator'), + uploader: c.get('uploader'), + req: c.req, + signal: this.opts.timeout + ? AbortSignal.any([AbortSignal.timeout(this.opts.timeout), c.req.raw.signal]) + : c.req.raw.signal, + json: (data) => c.json(data), + }); + }; + + return every(...middleware, handler); + } +} diff --git a/src/app.ts b/src/app.ts index 6929757f..aca335bd 100644 --- a/src/app.ts +++ b/src/app.ts @@ -227,7 +227,7 @@ app.get( app.get( '/api/v1/instance', cacheControlMiddleware({ maxAge: 5, staleWhileRevalidate: 5, staleIfError: 21600, public: true }), - instanceV1Controller, + instanceV1Controller.handler, ); app.get( '/api/v2/instance', diff --git a/src/controllers/api/instance.ts b/src/controllers/api/instance.ts index 986537bb..ec5e3777 100644 --- a/src/controllers/api/instance.ts +++ b/src/controllers/api/instance.ts @@ -2,6 +2,7 @@ import denoJson from 'deno.json' with { type: 'json' }; import { AppController } from '@/app.ts'; import { Conf } from '@/config.ts'; +import { DittoController } from '@/DittoController.ts'; import { Storages } from '@/storages.ts'; import { getInstanceMetadata } from '@/utils/instance.ts'; @@ -16,9 +17,9 @@ const features = [ 'v2_suggestions', ]; -const instanceV1Controller: AppController = async (c) => { +const instanceV1Controller = new DittoController({ requireSigner: true }, async (c) => { const { host, protocol } = Conf.url; - const meta = await getInstanceMetadata(await Storages.db(), c.req.raw.signal); + const meta = await getInstanceMetadata(c.store, c.signal); /** Protocol to use for WebSocket URLs, depending on the protocol of the `LOCAL_DOMAIN`. */ const wsProtocol = protocol === 'http:' ? 'ws:' : 'wss:'; @@ -73,7 +74,7 @@ const instanceV1Controller: AppController = async (c) => { }, rules: [], }); -}; +}); const instanceV2Controller: AppController = async (c) => { const { host, protocol } = Conf.url;