aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorElizabeth Hunt <me@liz.coffee>2025-12-15 00:58:43 -0800
committerElizabeth Hunt <me@liz.coffee>2025-12-15 00:58:43 -0800
commit2e41f030f02a336c2e9866d3d56b0494da5a622e (patch)
tree226d7fe0f2e0d5af847c31d97c1ab9664d1b7e33 /src
parent9c9f35734e795e3c2cea21384349b655d7ffa164 (diff)
downloadposthook-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.ts123
-rw-r--r--src/index.ts6
-rw-r--r--src/server/index.ts11
-rw-r--r--src/storage/index.ts95
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,