Merge branch 'ditto-controller' into 'main'

Draft: Add a DittoController

See merge request soapbox-pub/ditto!632
This commit is contained in:
Alex Gleason 2025-02-06 16:55:48 +00:00
commit 1e53eba5cf
3 changed files with 126 additions and 4 deletions

121
src/DittoController.ts Normal file
View file

@ -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<DittoTables>;
/** 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 extends DittoControllerOpts> = O['requireSigner'] extends true ? { signer: NostrSigner } : {};
// deno-lint-ignore ban-types
type RequireProof<O extends DittoControllerOpts> = O['requireProof'] extends true ? { proof: NostrEvent } : {};
type DittoContext<O extends DittoControllerOpts, P extends string> =
& DittoEnv
& Pick<Context<any, P>, 'json' | 'req'>
& RequireSigner<O>
& RequireProof<O>;
type DittoHandler<O extends DittoControllerOpts, P extends string> = (c: DittoContext<O, P>) => HandlerResponse<any>;
export class DittoController<
O extends DittoControllerOpts,
P extends string = O['path'] extends string ? O['path'] : any,
> {
private readonly _handler: DittoHandler<O, P>;
private readonly opts: O;
constructor(handler: DittoHandler<O, P>);
constructor(opts: O, handler: DittoHandler<O, P>);
constructor(arg1: O | DittoHandler<O, P>, arg2?: DittoHandler<O, P>) {
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);
}
}

View file

@ -227,7 +227,7 @@ app.get(
app.get( app.get(
'/api/v1/instance', '/api/v1/instance',
cacheControlMiddleware({ maxAge: 5, staleWhileRevalidate: 5, staleIfError: 21600, public: true }), cacheControlMiddleware({ maxAge: 5, staleWhileRevalidate: 5, staleIfError: 21600, public: true }),
instanceV1Controller, instanceV1Controller.handler,
); );
app.get( app.get(
'/api/v2/instance', '/api/v2/instance',

View file

@ -2,6 +2,7 @@ import denoJson from 'deno.json' with { type: 'json' };
import { AppController } from '@/app.ts'; import { AppController } from '@/app.ts';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { DittoController } from '@/DittoController.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import { getInstanceMetadata } from '@/utils/instance.ts'; import { getInstanceMetadata } from '@/utils/instance.ts';
@ -16,9 +17,9 @@ const features = [
'v2_suggestions', 'v2_suggestions',
]; ];
const instanceV1Controller: AppController = async (c) => { const instanceV1Controller = new DittoController({ requireSigner: true }, async (c) => {
const { host, protocol } = Conf.url; const { host, protocol } = Conf.url;
const meta = await getInstanceMetadata(await Storages.db(), c.req.raw.signal); const meta = await getInstanceMetadata(c.store, c.signal);
/** Protocol to use for WebSocket URLs, depending on the protocol of the `LOCAL_DOMAIN`. */ /** Protocol to use for WebSocket URLs, depending on the protocol of the `LOCAL_DOMAIN`. */
const wsProtocol = protocol === 'http:' ? 'ws:' : 'wss:'; const wsProtocol = protocol === 'http:' ? 'ws:' : 'wss:';
@ -73,7 +74,7 @@ const instanceV1Controller: AppController = async (c) => {
}, },
rules: [], rules: [],
}); });
}; });
const instanceV2Controller: AppController = async (c) => { const instanceV2Controller: AppController = async (c) => {
const { host, protocol } = Conf.url; const { host, protocol } = Conf.url;