diff options
| author | Elizabeth Hunt <me@liz.coffee> | 2025-12-15 00:58:43 -0800 |
|---|---|---|
| committer | Elizabeth Hunt <me@liz.coffee> | 2025-12-15 00:58:43 -0800 |
| commit | 2e41f030f02a336c2e9866d3d56b0494da5a622e (patch) | |
| tree | 226d7fe0f2e0d5af847c31d97c1ab9664d1b7e33 /src | |
| parent | 9c9f35734e795e3c2cea21384349b655d7ffa164 (diff) | |
| download | posthook-2e41f030f02a336c2e9866d3d56b0494da5a622e.tar.gz posthook-2e41f030f02a336c2e9866d3d56b0494da5a622e.zip | |
Remove admin route in favor of a simpler toml format
Diffstat (limited to 'src')
| -rw-r--r-- | src/activity/index.ts | 123 | ||||
| -rw-r--r-- | src/index.ts | 6 | ||||
| -rw-r--r-- | src/server/index.ts | 11 | ||||
| -rw-r--r-- | src/storage/index.ts | 95 |
4 files changed, 61 insertions, 174 deletions
diff --git a/src/activity/index.ts b/src/activity/index.ts index e14507d..d3537b4 100644 --- a/src/activity/index.ts +++ b/src/activity/index.ts @@ -4,7 +4,6 @@ import { type IActivity, type IEither, type ITraceable, - jsonModel, JsonResponse, LogLevel, LogMetricTraceSupplier, @@ -16,83 +15,14 @@ import { TraceUtil, } from '@emprespresso/pengueno'; import type { Storage } from '../storage/index.js'; -import type { RouteConfig } from '../types/index.js'; -import { isRouteConfig, ContentType } from '../types/index.js'; +import { ContentType } from '../types/index.js'; import { verifyHCaptcha } from '../integrations/hcaptcha.js'; import { sendNtfyNotification } from '../integrations/ntfy.js'; import { TokenSigner } from '../token/index.js'; -const routeConfigMetric = Metric.fromName('Route.Config').asResult(); const webhookRequestMetric = Metric.fromName('Webhook.Process').asResult(); -const listRoutesMetric = Metric.fromName('Routes.List').asResult(); const tokenGenerateMetric = Metric.fromName('Token.Generate').asResult(); -export interface IRegisterRouteActivity { - registerRoute: IActivity; -} - -export class RegisterRouteActivityImpl implements IRegisterRouteActivity { - constructor(private readonly storage: Storage) {} - - private trace(r: ITraceable<PenguenoRequest, ServerTrace>) { - return r.flatMap(TraceUtil.withClassTrace(this)).flatMap(TraceUtil.withMetricTrace(routeConfigMetric)); - } - - public registerRoute(r: ITraceable<PenguenoRequest, ServerTrace>) { - const routeConfigTransformer = (j: ITraceable<unknown, ServerTrace>): IEither<PenguenoError, RouteConfig> => { - const config = j.get(); - if (!isRouteConfig(config)) { - const err = 'Invalid route configuration'; - j.trace.traceScope(LogLevel.WARN).trace(err); - return Either.left(new PenguenoError(err, 400)); - } - return Either.right(config); - }; - - return this.trace(r) - .map(jsonModel(routeConfigTransformer)) - .map(async (tEitherConfig) => { - const eitherConfig = await tEitherConfig.get(); - return eitherConfig.flatMapAsync(async (config) => { - const eitherStored = await this.storage.registerRoute(config); - return eitherStored.mapLeft((e) => new PenguenoError(e.message, 500)); - }); - }) - .flatMapAsync( - TraceUtil.promiseify((tEitherStored) => { - const errorSource = tEitherStored - .get() - .left() - .map(({ source }) => source) - .orSome(() => ErrorSource.SYSTEM) - .get(); - const shouldWarn = errorSource === ErrorSource.USER; - return TraceUtil.traceResultingEither<PenguenoError, void, LogMetricTraceSupplier>( - routeConfigMetric, - shouldWarn, - )(tEitherStored); - }), - ) - .peek( - TraceUtil.promiseify((tResult) => - tResult.get().mapRight(() => tResult.trace.trace('Route registered successfully')), - ), - ) - .map( - TraceUtil.promiseify((tEitherResult) => { - const result = tEitherResult.get().mapRight(() => ({ success: true })); - return new JsonResponse(r, result, { - status: result.fold( - ({ status }) => status, - () => 200, - ), - }); - }), - ) - .get(); - } -} - export interface IWebhookActivity { processWebhook: (routeName: string) => IActivity; } @@ -443,57 +373,6 @@ export class WebhookActivityImpl implements IWebhookActivity { } } -export interface IListRoutesActivity { - listRoutes: IActivity; -} - -export class ListRoutesActivityImpl implements IListRoutesActivity { - constructor(private readonly storage: Storage) {} - - private trace(r: ITraceable<PenguenoRequest, ServerTrace>) { - return r.flatMap(TraceUtil.withClassTrace(this)).flatMap(TraceUtil.withMetricTrace(listRoutesMetric)); - } - - public listRoutes(r: ITraceable<PenguenoRequest, ServerTrace>) { - type ListRoutesResult = { - routes: Array<{ - name: string; - contentType: ContentType; - hcaptchaProtected: boolean; - ntfyEnabled: boolean; - requireToken: boolean; - }>; - }; - - return this.trace(r) - .map((tReq) => { - void tReq.get(); - - const routes = this.storage.listRoutes(); - const sanitized = routes.map(({ name, contentType, hcaptchaProtected, ntfy, requireToken }) => ({ - name, - contentType, - hcaptchaProtected, - ntfyEnabled: ntfy?.enabled || false, - requireToken: requireToken || false, - })); - return Either.right<PenguenoError, ListRoutesResult>({ routes: sanitized }); - }) - .peek( - TraceUtil.traceResultingEither<PenguenoError, ListRoutesResult, LogMetricTraceSupplier>( - listRoutesMetric, - ), - ) - .map( - async (tEitherResult) => - new JsonResponse(r, tEitherResult.get(), { - status: 200, - }), - ) - .get(); - } -} - export interface ITokenGenerateActivity { generateToken: (routeName: string) => IActivity; } diff --git a/src/index.ts b/src/index.ts index 0ece985..2985d36 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,11 +7,12 @@ import { TokenSigner } from './token/index.js'; const main = async (_argv = process.argv.slice(2)): Promise<IEither<Error, void>> => { const argsResult = argv( - ['--port', '--host', '--data-dir', '--token-secret', '--cors-origins'], + ['--port', '--host', '--data-dir', '--config', '--token-secret', '--cors-origins'], { '--port': { absent: 9000, present: (port) => parseInt(port) }, '--host': { absent: '0.0.0.0', present: (host) => host }, '--data-dir': { absent: './data', present: (dir) => dir }, + '--config': { absent: './routes.toml', present: (path) => path }, '--token-secret': { absent: undefined, present: (secret) => secret }, '--cors-origins': { absent: '*', present: (origins) => origins }, }, @@ -23,12 +24,13 @@ const main = async (_argv = process.argv.slice(2)): Promise<IEither<Error, void> port: args['--port'], host: args['--host'], dataDir: args['--data-dir'], + configPath: args['--config'], tokenSecret: args['--token-secret'], corsOrigins: args['--cors-origins'], })) .flatMapAsync(async (config) => { // Initialize storage - const storage = new Storage(config.dataDir); + const storage = new Storage(config.dataDir, config.configPath); const initResult = await storage.init(); if (initResult.left().present()) { diff --git a/src/server/index.ts b/src/server/index.ts index a38b47a..95358d4 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -14,12 +14,8 @@ import { } from '@emprespresso/pengueno'; import { Storage } from '../storage/index.js'; import { - ListRoutesActivityImpl, - RegisterRouteActivityImpl, TokenGenerateActivityImpl, WebhookActivityImpl, - type IListRoutesActivity, - type IRegisterRouteActivity, type ITokenGenerateActivity, type IWebhookActivity, } from '../activity/index.js'; @@ -89,10 +85,8 @@ export class PosthookServer implements Server { corsOriginsRaw: string = '*', healthCheck: HealthChecker = defaultHealthCheck, private readonly healthCheckActivity: IHealthCheckActivity = new HealthCheckActivityImpl(healthCheck), - private readonly registerRouteActivity: IRegisterRouteActivity = new RegisterRouteActivityImpl(storage), private readonly webhookActivity: IWebhookActivity = new WebhookActivityImpl(storage, signer), private readonly tokenGenerateActivity: ITokenGenerateActivity = new TokenGenerateActivityImpl(storage, signer), - private readonly listRoutesActivity: IListRoutesActivity = new ListRoutesActivityImpl(storage), private readonly fourOhFourActivity: IFourOhFourActivity = new FourOhFourActivityImpl(), private readonly corsOrigins: AllowedCorsOrigins = parseAllowedCorsOrigins(corsOriginsRaw), ) {} @@ -211,11 +205,6 @@ export class PosthookServer implements Server { if (hookMatch && method === 'POST') { const routeName = hookMatch[1]; result = this.webhookActivity.processWebhook(routeName)(req); - } else if (pathname === '/admin/routes' && method === 'POST') { - // === Admin Routes (/admin) - Put behind OAuth proxy === - result = this.registerRouteActivity.registerRoute(req); - } else if (pathname === '/admin/routes' && method === 'GET') { - result = this.listRoutesActivity.listRoutes(req); } else { // 404 for everything else result = this.fourOhFourActivity.fourOhFour(req); diff --git a/src/storage/index.ts b/src/storage/index.ts index c3c97d8..8d7debd 100644 --- a/src/storage/index.ts +++ b/src/storage/index.ts @@ -1,7 +1,9 @@ import { randomUUID } from 'crypto'; import { mkdir, writeFile, readFile } from 'fs/promises'; +import { watch } from 'fs'; import { basename, join } from 'path'; -import { isSafeRouteName, type RouteConfig, type StoredRequest } from '../types/index.js'; +import { parse as parseToml } from 'smol-toml'; +import { isSafeRouteName, isRouteConfig, type RouteConfig, type StoredRequest } from '../types/index.js'; import { Either, type IEither } from '@emprespresso/pengueno'; type IncomingUpload = { @@ -20,13 +22,20 @@ function sanitizeFilename(filename: string): string { export class Storage { private routes: Map<string, RouteConfig> = new Map(); + private configPath: string; - constructor(private readonly dataDir: string = './data') {} + constructor( + private readonly dataDir: string = './data', + configPath: string = './routes.toml', + ) { + this.configPath = configPath; + } async init(): Promise<IEither<Error, void>> { try { await mkdir(this.dataDir, { recursive: true }); await this.loadRoutes(); + this.watchConfig(); return Either.right(<void>undefined); } catch (err) { return Either.left(err instanceof Error ? err : new Error(String(err))); @@ -35,44 +44,60 @@ export class Storage { private async loadRoutes(): Promise<void> { try { - const routesPath = join(this.dataDir, 'routes.json'); - const data = await readFile(routesPath, 'utf-8'); - const routes = JSON.parse(data) as RouteConfig[]; + const data = await readFile(this.configPath, 'utf-8'); + const parsed = parseToml(data); + + if (!parsed || typeof parsed !== 'object' || !('route' in parsed)) { + console.error('Invalid routes.toml: missing [[route]] sections'); + process.exit(1); + } + + const routes = parsed.route; + if (!Array.isArray(routes)) { + console.error('Invalid routes.toml: "route" must be an array of tables'); + process.exit(1); + } + + const newRoutes = new Map<string, RouteConfig>(); for (const route of routes) { - if (!isSafeRouteName(route.name)) { - continue; + if (!isRouteConfig(route)) { + console.error('Invalid route configuration:', route); + process.exit(1); + } + if (newRoutes.has(route.name)) { + console.error(`Duplicate route name: ${route.name}`); + process.exit(1); } - this.routes.set(route.name, route); + newRoutes.set(route.name, route); + + // Ensure route directory exists + const routeDir = join(this.dataDir, route.name); + await mkdir(routeDir, { recursive: true }); } - } catch { - // routes file doesn't exist yet, that's ok - } - } - private async saveRoutes(): Promise<IEither<Error, void>> { - try { - const routesPath = join(this.dataDir, 'routes.json'); - const routes = Array.from(this.routes.values()); - await writeFile(routesPath, JSON.stringify(routes, null, 2)); - return Either.right(<void>undefined); + this.routes = newRoutes; + console.log(`Loaded ${this.routes.size} route(s) from ${this.configPath}`); } catch (err) { - return Either.left(err instanceof Error ? err : new Error(String(err))); + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + console.log(`No ${this.configPath} found, starting with empty routes`); + return; + } + console.error(`Failed to load routes from ${this.configPath}:`, err); + process.exit(1); } } - async registerRoute(config: RouteConfig): Promise<IEither<Error, void>> { - if (!isSafeRouteName(config.name)) { - return Either.left(new Error('Invalid route name')); - } + private watchConfig(): void { + const watcher = watch(this.configPath, async (eventType) => { + if (eventType === 'change') { + console.log(`${this.configPath} changed, reloading...`); + await this.loadRoutes(); + } + }); - this.routes.set(config.name, config); - const routeDir = join(this.dataDir, config.name); - try { - await mkdir(routeDir, { recursive: true }); - } catch (err) { - return Either.left(err instanceof Error ? err : new Error(String(err))); - } - return this.saveRoutes(); + watcher.on('error', (err) => { + console.error(`Error watching ${this.configPath}:`, err); + }); } getRoute(name: string): RouteConfig | undefined { @@ -84,14 +109,6 @@ export class Storage { return Array.from(this.routes.values()); } - async deleteRoute(name: string): Promise<IEither<Error, void>> { - if (!isSafeRouteName(name)) { - return Either.left(new Error('Invalid route name')); - } - this.routes.delete(name); - return this.saveRoutes(); - } - async storeRequest( routeName: string, method: string, |
