aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md161
-rw-r--r--package-lock.json15
-rw-r--r--package.json3
-rw-r--r--routes.toml.example47
-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
-rw-r--r--test/storage.test.ts67
9 files changed, 231 insertions, 297 deletions
diff --git a/README.md b/README.md
index 4b741e1..8ba2c16 100644
--- a/README.md
+++ b/README.md
@@ -4,8 +4,8 @@ A simple API service for receiving and storing webhook requests with dynamic rou
## Features
-- Dynamic route/topic registration
-- Multiple content type support (JSON, form, text, raw)
+- File-based route configuration with hot-reload
+- Multiple content type support (JSON, form, text, raw, multipart)
- CSRF token protection with HMAC-signed stateless tokens
- hCaptcha protection per route
- ntfy notification alerts when webhooks are received
@@ -16,83 +16,70 @@ A simple API service for receiving and storing webhook requests with dynamic rou
- Comprehensive metrics and tracing
- Single Dockerfile deployment
-## API Endpoints
-
-### Route Prefixes
-
-- **`/`** - Public routes (webhooks, token generation, health)
-- **`/admin`** - Admin routes (route management) - Put behind OAuth proxy
-
-### Admin Endpoints
-
-**⚠️ Recommendation**: Put `/admin/*` behind an OAuth proxy (e.g., OAuth2 Proxy, Pomerium) for authentication.
-
-#### Register a Route
-
-```bash
-POST /admin/routes
-Content-Type: application/json
-
-{
- "name": "my-webhook",
- "contentType": "json",
- "hcaptchaProtected": false
-}
-```
-
-With hCaptcha protection:
-
-```bash
-POST /admin/routes
-Content-Type: application/json
-
-{
- "name": "protected-webhook",
- "contentType": "json",
- "hcaptchaProtected": true,
- "hcaptchaSecret": "your-hcaptcha-secret"
-}
-```
-
-With ntfy notifications:
-
-```bash
-POST /admin/routes
-Content-Type: application/json
+## Configuration
-{
- "name": "notified-webhook",
- "contentType": "json",
- "hcaptchaProtected": false,
- "ntfy": {
- "enabled": true,
- "server": "https://ntfy.sh",
- "topic": "my-webhook-alerts"
- }
-}
+Routes are configured via a `routes.toml` file that Posthook watches for changes. When you edit the file, Posthook automatically reloads the configuration.
+
+### Example Configuration
+
+Create a `routes.toml` file:
+
+```toml
+# Simple JSON webhook
+[[route]]
+name = "github-webhook"
+contentType = "json"
+hcaptchaProtected = false
+requireToken = false
+
+# Form with hCaptcha protection
+[[route]]
+name = "contact-form"
+contentType = "form"
+hcaptchaProtected = true
+hcaptchaSecret = "0x0000000000000000000000000000000000000000"
+requireToken = false
+
+# JSON webhook with ntfy notifications
+[[route]]
+name = "alerts"
+contentType = "json"
+hcaptchaProtected = false
+requireToken = false
+
+[route.ntfy]
+enabled = true
+server = "https://ntfy.sh"
+topic = "my-alerts"
+
+# Multipart file upload with token protection
+[[route]]
+name = "file-upload"
+contentType = "multipart"
+hcaptchaProtected = false
+requireToken = true
```
-With CSRF token protection:
+### Configuration Fields
-```bash
-POST /admin/routes
-Content-Type: application/json
-
-{
- "name": "secure-form",
- "contentType": "form",
- "hcaptchaProtected": false,
- "requireToken": true
-}
-```
+- `name` - Route identifier (alphanumeric, dash, underscore only)
+- `contentType` - One of: `json`, `form`, `multipart`, `text`, `raw`
+- `hcaptchaProtected` - Enable hCaptcha verification (default: false)
+- `hcaptchaSecret` - hCaptcha secret key (required if hcaptchaProtected is true)
+- `requireToken` - Enable CSRF token protection (default: false)
+- `ntfy` - Optional ntfy notification configuration:
+ - `enabled` - Enable notifications (required)
+ - `server` - ntfy server URL (required if enabled)
+ - `topic` - ntfy topic name (required if enabled)
-#### List Routes
+### Hot Reload
-```bash
-GET /admin/routes
-```
+Posthook watches `routes.toml` for changes. When you save the file:
+- Valid changes are applied immediately
+- Invalid configuration causes the process to exit (fail-fast)
+- No need to restart the server for route updates
-### Public Webhook Endpoints
+## API Endpoints
#### Get CSRF Token (for routes with requireToken: true)
@@ -347,37 +334,23 @@ docker run -p 9000:9000 \
posthook
```
-### With OAuth Proxy (Recommended for Production)
-
-Protect admin routes with an OAuth proxy:
+### With Custom Config Location
```bash
-# Run posthook
-docker run -p 9000:9000 posthook
-
-# Run OAuth2 Proxy in front of /admin routes
-docker run -p 4180:4180 \
- quay.io/oauth2-proxy/oauth2-proxy:latest \
- --upstream=http://localhost:9000/admin \
- --http-address=0.0.0.0:4180 \
- --provider=google \
- --client-id=your-client-id \
- --client-secret=your-client-secret \
- --cookie-secret=random-secret-here \
- --email-domain=yourdomain.com
+docker run -p 9000:9000 \
+ -v $(pwd)/my-routes.toml:/app/routes.toml \
+ -v $(pwd)/data:/app/data \
+ -e POSTHOOK_TOKEN_SECRET=your-secret-here \
+ posthook -- --config /app/routes.toml
```
-Then configure your reverse proxy:
-
-- `/admin/*` → OAuth2 Proxy on port 4180
-- `/` → Posthook on port 9000
-
-## Configuration
+## Runtime Configuration
### Command Line Arguments:
- `--port` - Server port (default: 9000)
- `--host` - Server host (default: 0.0.0.0)
+- `--config` - Path to routes.toml configuration file (default: ./routes.toml)
- `--data-dir` - Data storage directory (default: ./data)
- `--token-secret` - HMAC secret for token signing (optional, generates random if not provided)
- `--cors-origins` - Allowed CORS origins (default: `*`). Supports `*`, exact origins (`https://a.com`), and host patterns (`liz.coffee`, `*.liz.coffee`). When not `*`, only `https` origins are allowed. Responses only set `Access-Control-Allow-Origin` (preflight uses the standard allow-\* headers).
diff --git a/package-lock.json b/package-lock.json
index f5fa199..c4b7c94 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,7 +10,8 @@
"dependencies": {
"@emprespresso/pengueno": "^0.0.18",
"@hono/node-server": "^1.14.0",
- "hono": "^4.8.9"
+ "hono": "^4.8.9",
+ "smol-toml": "^1.5.2"
},
"devDependencies": {
"@types/node": "^24.0.3",
@@ -3267,6 +3268,18 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/smol-toml": {
+ "version": "1.5.2",
+ "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.5.2.tgz",
+ "integrity": "sha512-QlaZEqcAH3/RtNyet1IPIYPsEWAaYyXXv1Krsi+1L/QHppjX4Ifm8MQsBISz9vE8cHicIq3clogsheili5vhaQ==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/cyyynthia"
+ }
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
diff --git a/package.json b/package.json
index 7265fe2..6b09911 100644
--- a/package.json
+++ b/package.json
@@ -20,7 +20,8 @@
"dependencies": {
"@emprespresso/pengueno": "^0.0.18",
"@hono/node-server": "^1.14.0",
- "hono": "^4.8.9"
+ "hono": "^4.8.9",
+ "smol-toml": "^1.5.2"
},
"devDependencies": {
"@types/node": "^24.0.3",
diff --git a/routes.toml.example b/routes.toml.example
new file mode 100644
index 0000000..ac9a493
--- /dev/null
+++ b/routes.toml.example
@@ -0,0 +1,47 @@
+# Posthook Routes Configuration
+#
+# This file defines webhook routes that Posthook will handle.
+# Edit this file and Posthook will automatically reload the configuration.
+#
+# Each [[route]] section defines a webhook endpoint available at /hook/{name}
+
+# Example: Simple JSON webhook
+[[route]]
+name = "github-webhook"
+contentType = "json"
+hcaptchaProtected = false
+requireToken = false
+
+# Example: Form-encoded webhook with hCaptcha protection
+[[route]]
+name = "contact-form"
+contentType = "form"
+hcaptchaProtected = true
+hcaptchaSecret = "0x0000000000000000000000000000000000000000"
+requireToken = false
+
+# Example: JSON webhook with ntfy notifications
+[[route]]
+name = "alerts"
+contentType = "json"
+hcaptchaProtected = false
+requireToken = false
+
+[route.ntfy]
+enabled = true
+server = "https://ntfy.sh"
+topic = "my-alerts"
+
+# Example: Multipart file upload with token protection
+[[route]]
+name = "file-upload"
+contentType = "multipart"
+hcaptchaProtected = false
+requireToken = true
+
+# Content Types:
+# - json: application/json
+# - form: application/x-www-form-urlencoded
+# - multipart: multipart/form-data
+# - text: text/plain
+# - raw: any content type (stored as-is)
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,
diff --git a/test/storage.test.ts b/test/storage.test.ts
index 200c81a..985ba48 100644
--- a/test/storage.test.ts
+++ b/test/storage.test.ts
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { mkdtemp, readFile, rm } from 'fs/promises';
+import { mkdtemp, readFile, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
@@ -9,56 +9,69 @@ import { ContentType, type RouteConfig } from '../src/types/index.js';
describe('Storage', () => {
let dataDir: string;
+ let configPath: string;
beforeEach(async () => {
dataDir = await mkdtemp(join(tmpdir(), 'posthook-test-'));
+ configPath = join(dataDir, 'routes.toml');
});
afterEach(async () => {
await rm(dataDir, { recursive: true, force: true });
});
- it('persists routes to routes.json and loads them on init', async () => {
+ it('loads routes from routes.toml on init', async () => {
const route: RouteConfig = {
name: 'route1',
contentType: ContentType.JSON,
hcaptchaProtected: false,
};
- const storage1 = new Storage(dataDir);
- expect((await storage1.init()).left().present()).toBe(false);
- expect((await storage1.registerRoute(route)).left().present()).toBe(false);
+ // Create a TOML config file
+ const toml = `
+[[route]]
+name = "route1"
+contentType = "json"
+hcaptchaProtected = false
+`;
+ await writeFile(configPath, toml);
- const storage2 = new Storage(dataDir);
- expect((await storage2.init()).left().present()).toBe(false);
- expect(storage2.getRoute('route1')).toEqual(route);
- expect(storage2.listRoutes()).toEqual([route]);
+ const storage = new Storage(dataDir, configPath);
+ expect((await storage.init()).left().present()).toBe(false);
+ expect(storage.getRoute('route1')).toEqual(route);
+ expect(storage.listRoutes()).toEqual([route]);
});
- it('rejects unsafe route names', async () => {
- const storage = new Storage(dataDir);
- expect((await storage.init()).left().present()).toBe(false);
+ it('returns undefined for unsafe route names', async () => {
+ const toml = `
+[[route]]
+name = "safe-route"
+contentType = "json"
+hcaptchaProtected = false
+`;
+ await writeFile(configPath, toml);
- const result = await storage.registerRoute({
- name: '../bad',
- contentType: ContentType.JSON,
- hcaptchaProtected: false,
- });
+ const storage = new Storage(dataDir, configPath);
+ expect((await storage.init()).left().present()).toBe(false);
- expect(result.left().present()).toBe(true);
- expect(result.left().get().message).toBe('Invalid route name');
+ // getRoute should reject unsafe names
+ expect(storage.getRoute('../bad')).toBeUndefined();
+ expect(storage.getRoute('.')).toBeUndefined();
+ expect(storage.getRoute('..')).toBeUndefined();
+ expect(storage.getRoute('path/with/slash')).toBeUndefined();
});
it('stores a request and sanitizes uploaded filenames', async () => {
- const route: RouteConfig = {
- name: 'route1',
- contentType: ContentType.JSON,
- hcaptchaProtected: false,
- };
-
- const storage = new Storage(dataDir);
+ const toml = `
+[[route]]
+name = "route1"
+contentType = "json"
+hcaptchaProtected = false
+`;
+ await writeFile(configPath, toml);
+
+ const storage = new Storage(dataDir, configPath);
expect((await storage.init()).left().present()).toBe(false);
- expect((await storage.registerRoute(route)).left().present()).toBe(false);
const upload = {
fieldName: 'file',