diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/activity/index.ts | 438 | ||||
| -rw-r--r-- | src/index.ts | 64 | ||||
| -rw-r--r-- | src/integrations/hcaptcha.ts | 32 | ||||
| -rw-r--r-- | src/integrations/ntfy.ts | 40 | ||||
| -rw-r--r-- | src/server/index.ts | 84 | ||||
| -rw-r--r-- | src/storage/index.ts | 114 | ||||
| -rw-r--r-- | src/token/index.ts | 76 | ||||
| -rw-r--r-- | src/types/index.ts | 78 |
8 files changed, 926 insertions, 0 deletions
diff --git a/src/activity/index.ts b/src/activity/index.ts new file mode 100644 index 0000000..20d123c --- /dev/null +++ b/src/activity/index.ts @@ -0,0 +1,438 @@ +import { + Either, + ErrorSource, + type IActivity, + type IEither, + type ITraceable, + jsonModel, + JsonResponse, + LogLevel, + LogMetricTraceSupplier, + Metric, + PenguenoError, + PenguenoResponse, + type PenguenoRequest, + type ServerTrace, + 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 { 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; +} + +export class WebhookActivityImpl implements IWebhookActivity { + constructor( + private readonly storage: Storage, + private readonly signer: TokenSigner, + ) {} + + private trace(r: ITraceable<PenguenoRequest, ServerTrace>) { + return r.flatMap(TraceUtil.withClassTrace(this)).flatMap(TraceUtil.withMetricTrace(webhookRequestMetric)); + } + + private async parseBody( + req: PenguenoRequest, + contentType: ContentType, + ): Promise<IEither<PenguenoError, { body: unknown; redirect: string | undefined; token: string | undefined }>> { + try { + const rawBody = await req.req.text(); + + type ParsedBody = { body: unknown; redirect: string | undefined; token: string | undefined }; + + switch (contentType) { + case ContentType.JSON: + try { + return Either.right(<ParsedBody>{ + body: JSON.parse(rawBody), + redirect: undefined, + token: undefined, + }); + } catch { + return Either.left(new PenguenoError('Invalid JSON', 400)); + } + + case ContentType.FORM: + try { + const formData = new URLSearchParams(rawBody); + const obj: Record<string, string> = {}; + let redirect: string | undefined; + let token: string | undefined; + + for (const [key, value] of formData.entries()) { + if (key === '_redirect') { + redirect = value; + } else if (key === '_token') { + token = value; + } else { + obj[key] = value; + } + } + return Either.right(<ParsedBody>{ body: obj, redirect, token }); + } catch { + return Either.left(new PenguenoError('Invalid form data', 400)); + } + + case ContentType.TEXT: + return Either.right(<ParsedBody>{ body: rawBody, redirect: undefined, token: undefined }); + + case ContentType.RAW: + return Either.right(<ParsedBody>{ body: rawBody, redirect: undefined, token: undefined }); + + case ContentType.MULTIPART: + return Either.left(new PenguenoError('Multipart not yet implemented', 501)); + + default: + return Either.right(<ParsedBody>{ body: rawBody, redirect: undefined, token: undefined }); + } + } catch (err) { + return Either.left(new PenguenoError(err instanceof Error ? err.message : String(err), 500)); + } + } + + public processWebhook(routeName: string) { + return (r: ITraceable<PenguenoRequest, ServerTrace>) => { + type WebhookResult = { success: true; stored: string; redirect: string | undefined }; + + return this.trace(r) + .flatMapAsync(async (tReq) => { + const route = this.storage.getRoute(routeName); + if (!route) { + tReq.trace.traceScope(LogLevel.WARN).trace(`Route not found: ${routeName}`); + return tReq.move( + Either.left<PenguenoError, WebhookResult>(new PenguenoError('Route not found', 404)), + ); + } + + const req = tReq.get().req; + const headers = req.header(); + const query = req.query(); + + // Extract hCaptcha token if route is protected + if (route.hcaptchaProtected) { + const hCaptchaToken = headers['h-captcha-response'] ?? query['h-captcha-response']; + if (!hCaptchaToken) { + tReq.trace.traceScope(LogLevel.WARN).trace('Missing hCaptcha token'); + return tReq.move( + Either.left<PenguenoError, WebhookResult>( + new PenguenoError('Missing hCaptcha token', 400), + ), + ); + } + + if (!route.hcaptchaSecret) { + tReq.trace.traceScope(LogLevel.ERROR).trace('hCaptcha secret not configured'); + return tReq.move( + Either.left<PenguenoError, WebhookResult>( + new PenguenoError('Server misconfiguration', 500), + ), + ); + } + + const verifyResult = await verifyHCaptcha(hCaptchaToken, route.hcaptchaSecret); + const isValid = verifyResult.fold( + () => false, + (success) => success, + ); + + if (!isValid) { + tReq.trace.traceScope(LogLevel.WARN).trace('hCaptcha verification failed'); + return tReq.move( + Either.left<PenguenoError, WebhookResult>( + new PenguenoError('hCaptcha verification failed', 403), + ), + ); + } + } + + // Parse body based on content type + const bodyResult = await this.parseBody(tReq.get(), route.contentType); + if (bodyResult.left().present()) { + return tReq.move(Either.left<PenguenoError, WebhookResult>(bodyResult.left().get())); + } + + const { body, redirect, token: bodyToken } = bodyResult.right().get(); + + // Validate token if required + if (route.requireToken) { + const csrfToken = bodyToken ?? headers['x-csrf-token']; + if (!csrfToken) { + tReq.trace.traceScope(LogLevel.WARN).trace('Missing CSRF token'); + return tReq.move( + Either.left<PenguenoError, WebhookResult>(new PenguenoError('Missing CSRF token', 400)), + ); + } + + const validationResult = this.signer.validate(csrfToken, routeName); + if (validationResult.left().present()) { + const error = validationResult.left().get(); + tReq.trace.traceScope(LogLevel.WARN).trace(`Token validation failed: ${error.message}`); + return tReq.move( + Either.left<PenguenoError, WebhookResult>( + new PenguenoError('Invalid or expired token', 403), + ), + ); + } + } + + // Store the request + const storeResult = await this.storage.storeRequest(routeName, req.method, headers, body); + if (storeResult.left().present()) { + return tReq.move( + Either.left<PenguenoError, WebhookResult>( + new PenguenoError(storeResult.left().get().message, 500), + ), + ); + } + + const storedRequest = storeResult.right().get(); + + // Send ntfy notification if configured + if (route.ntfy?.enabled) { + const ntfyResult = await sendNtfyNotification(route.ntfy, storedRequest); + if (ntfyResult.left().present()) { + const err = ntfyResult.left().get(); + tReq.trace.traceScope(LogLevel.WARN).trace(`ntfy notification failed: ${err.message}`); + } else { + tReq.trace.trace('ntfy notification sent'); + } + } + + const filename = `${storedRequest.timestamp}_${storedRequest.uuid}.json`; + return tReq.move( + Either.right<PenguenoError, WebhookResult>({ + success: true, + stored: filename, + redirect, + }), + ); + }) + .flatMapAsync( + TraceUtil.promiseify((tEitherResult) => { + const errorSource = tEitherResult + .get() + .left() + .map(({ source }) => source) + .orSome(() => ErrorSource.SYSTEM) + .get(); + const shouldWarn = errorSource === ErrorSource.USER; + return TraceUtil.traceResultingEither<PenguenoError, WebhookResult, LogMetricTraceSupplier>( + webhookRequestMetric, + shouldWarn, + )(tEitherResult); + }), + ) + .peek( + TraceUtil.promiseify((tResult) => + tResult.get().mapRight(() => tResult.trace.trace('Webhook request processed successfully')), + ), + ) + .map( + TraceUtil.promiseify((tEitherResult) => { + const result = tEitherResult.get(); + + // Check if we should redirect + const shouldRedirect = result.fold( + () => false, + (data) => data.redirect !== undefined, + ); + + if (shouldRedirect) { + const redirectUrl = result.right().get().redirect!; + return new PenguenoResponse(r, '', { + status: 303, + statusText: 'See Other', + headers: { Location: redirectUrl }, + }); + } + + // Return JSON response for non-redirect cases + return new JsonResponse(r, result, { + status: result.fold( + ({ status }) => status, + () => 200, + ), + }); + }), + ) + .get(); + }; + } +} + +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; +} + +export class TokenGenerateActivityImpl implements ITokenGenerateActivity { + constructor( + private readonly storage: Storage, + private readonly signer: TokenSigner, + ) {} + + private trace(r: ITraceable<PenguenoRequest, ServerTrace>) { + return r.flatMap(TraceUtil.withClassTrace(this)).flatMap(TraceUtil.withMetricTrace(tokenGenerateMetric)); + } + + public generateToken(routeName: string) { + return (r: ITraceable<PenguenoRequest, ServerTrace>) => { + type TokenResult = { token: string; expiresAt: number }; + + return this.trace(r) + .map((tReq) => { + const route = this.storage.getRoute(routeName); + if (!route) { + tReq.trace.traceScope(LogLevel.WARN).trace(`Route not found: ${routeName}`); + return Either.left<PenguenoError, TokenResult>(new PenguenoError('Route not found', 404)); + } + + const token = this.signer.generate(routeName); + const expiresAt = Date.now() + 30 * 1000; // 30 seconds + + return Either.right<PenguenoError, TokenResult>({ token, expiresAt }); + }) + .peek( + TraceUtil.traceResultingEither<PenguenoError, TokenResult, LogMetricTraceSupplier>( + tokenGenerateMetric, + ), + ) + .map( + async (tEitherResult) => + new JsonResponse(r, tEitherResult.get(), { + status: tEitherResult.get().fold( + ({ status }) => status, + () => 200, + ), + }), + ) + .get(); + }; + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..cbc946a --- /dev/null +++ b/src/index.ts @@ -0,0 +1,64 @@ +#!/usr/bin/env node + +import { argv, Either, getEnv, type IEither, HonoProxy } from '@emprespresso/pengueno'; +import { PosthookServer } from './server/index.js'; +import { Storage } from './storage/index.js'; +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'], + { + '--port': { absent: 9000, present: (port) => parseInt(port) }, + '--host': { absent: '0.0.0.0', present: (host) => host }, + '--data-dir': { absent: './data', present: (dir) => dir }, + '--token-secret': { absent: undefined, present: (secret) => secret }, + }, + _argv, + ); + + return argsResult + .mapRight((args) => ({ + port: args['--port'], + host: args['--host'], + dataDir: args['--data-dir'], + tokenSecret: args['--token-secret'], + })) + .flatMapAsync(async (config) => { + // Initialize storage + const storage = new Storage(config.dataDir); + const initResult = await storage.init(); + + if (initResult.left().present()) { + return Either.left(initResult.left().get()); + } + + // Initialize token signer (use env var or command line arg or generate random) + const envSecret = getEnv('POSTHOOK_TOKEN_SECRET'); + const secret = config.tokenSecret ?? (envSecret.present() ? envSecret.get() : undefined); + const signer = new TokenSigner(secret); + + if (config.tokenSecret === undefined && !envSecret.present()) { + console.log('No token secret provided; generated a random one (will not persist across restarts).'); + console.log('Set POSTHOOK_TOKEN_SECRET or pass --token-secret to use a persistent secret.'); + } + + console.log(`Storage initialized at: ${config.dataDir}`); + console.log(`Starting server on ${config.host}:${config.port}`); + + // Create and start server + const server = new PosthookServer(storage, signer); + const hono = new HonoProxy(server); + + return hono.serve(config.port, config.host); + }); +}; + +if (process.argv[1] === import.meta.filename) { + await main().then((eitherDone) => + eitherDone.mapLeft((err) => { + console.error('error:', err); + process.exit(1); + }), + ); +} diff --git a/src/integrations/hcaptcha.ts b/src/integrations/hcaptcha.ts new file mode 100644 index 0000000..78ff356 --- /dev/null +++ b/src/integrations/hcaptcha.ts @@ -0,0 +1,32 @@ +import { Either, type IEither } from '@emprespresso/pengueno'; + +export interface HCaptchaResponse { + success: boolean; + challenge_ts?: string; + hostname?: string; + 'error-codes'?: string[]; +} + +export async function verifyHCaptcha(token: string, secret: string): Promise<IEither<Error, boolean>> { + try { + const response = await fetch('https://hcaptcha.com/siteverify', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + secret, + response: token, + }), + }); + + if (!response.ok) { + return Either.left(new Error(`hCaptcha verification failed: ${response.statusText}`)); + } + + const result = (await response.json()) as HCaptchaResponse; + return Either.right(result.success); + } catch (err) { + return Either.left(err instanceof Error ? err : new Error(String(err))); + } +} diff --git a/src/integrations/ntfy.ts b/src/integrations/ntfy.ts new file mode 100644 index 0000000..cf815ed --- /dev/null +++ b/src/integrations/ntfy.ts @@ -0,0 +1,40 @@ +import { Either, type IEither } from '@emprespresso/pengueno'; +import type { NtfyConfig, StoredRequest } from '../types/index.js'; + +export interface NtfyNotification { + topic: string; + title: string; + message: string; + tags?: string[]; + priority?: number; +} + +export async function sendNtfyNotification(config: NtfyConfig, request: StoredRequest): Promise<IEither<Error, void>> { + if (!config.enabled || !config.server || !config.topic) { + return Either.right(<void>undefined); + } + + try { + const url = `${config.server}/${config.topic}`; + const title = `Webhook received: ${request.routeName}`; + const message = `Method: ${request.method}\nTimestamp: ${new Date(request.timestamp).toISOString()}\nUUID: ${request.uuid}`; + + const response = await fetch(url, { + method: 'POST', + headers: { + Title: title, + Tags: 'webhook,posthook', + Priority: '3', + }, + body: message, + }); + + if (!response.ok) { + return Either.left(new Error(`ntfy notification failed: ${response.statusText}`)); + } + + return Either.right(<void>undefined); + } catch (err) { + return Either.left(err instanceof Error ? err : new Error(String(err))); + } +} diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..0747680 --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,84 @@ +import { + Either, + FourOhFourActivityImpl, + HealthCheckActivityImpl, + type HealthChecker, + HealthCheckOutput, + type IFourOhFourActivity, + type IHealthCheckActivity, + type ITraceable, + PenguenoRequest, + Server, + type ServerTrace, +} 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'; +import { TokenSigner } from '../token/index.js'; + +const defaultHealthCheck: HealthChecker = async (input) => { + void input.get(); + return Either.right(HealthCheckOutput.YAASSSLAYQUEEN); +}; + +export class PosthookServer implements Server { + constructor( + storage: Storage, + signer: TokenSigner, + 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(), + ) {} + + public serve(req: ITraceable<PenguenoRequest, ServerTrace>) { + const url = new URL(req.get().req.url); + const { pathname } = url; + + // === Public Routes (/) === + + // Health check endpoint + if (pathname === '/health') { + return this.healthCheckActivity.checkHealth(req); + } + + // Token generation endpoint - /hook/{routeName}/token + const tokenMatch = pathname.match(/^\/hook\/([^/]+)\/token$/); + if (tokenMatch && req.get().req.method === 'GET') { + const routeName = tokenMatch[1]; + return this.tokenGenerateActivity.generateToken(routeName)(req); + } + + // Dynamic webhook endpoints - /hook/{routeName} + const hookMatch = pathname.match(/^\/hook\/([^/]+)$/); + if (hookMatch && req.get().req.method === 'POST') { + const routeName = hookMatch[1]; + return this.webhookActivity.processWebhook(routeName)(req); + } + + // === Admin Routes (/admin) - Put behind OAuth proxy === + + // Admin endpoints for route management + if (pathname === '/admin/routes' && req.get().req.method === 'POST') { + return this.registerRouteActivity.registerRoute(req); + } + + if (pathname === '/admin/routes' && req.get().req.method === 'GET') { + return this.listRoutesActivity.listRoutes(req); + } + + // 404 for everything else + return this.fourOhFourActivity.fourOhFour(req); + } +} diff --git a/src/storage/index.ts b/src/storage/index.ts new file mode 100644 index 0000000..2c8ffb2 --- /dev/null +++ b/src/storage/index.ts @@ -0,0 +1,114 @@ +import { randomUUID } from 'crypto'; +import { mkdir, writeFile, readFile } from 'fs/promises'; +import { join } from 'path'; +import { isSafeRouteName, type RouteConfig, type StoredRequest } from '../types/index.js'; +import { Either, type IEither } from '@emprespresso/pengueno'; + +export class Storage { + private routes: Map<string, RouteConfig> = new Map(); + + constructor(private readonly dataDir: string = './data') {} + + async init(): Promise<IEither<Error, void>> { + try { + await mkdir(this.dataDir, { recursive: true }); + await this.loadRoutes(); + return Either.right(<void>undefined); + } catch (err) { + return Either.left(err instanceof Error ? err : new Error(String(err))); + } + } + + 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[]; + for (const route of routes) { + if (!isSafeRouteName(route.name)) { + continue; + } + this.routes.set(route.name, route); + } + } 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); + } catch (err) { + return Either.left(err instanceof Error ? err : new Error(String(err))); + } + } + + async registerRoute(config: RouteConfig): Promise<IEither<Error, void>> { + if (!isSafeRouteName(config.name)) { + return Either.left(new Error('Invalid route name')); + } + + 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(); + } + + getRoute(name: string): RouteConfig | undefined { + if (!isSafeRouteName(name)) return undefined; + return this.routes.get(name); + } + + listRoutes(): RouteConfig[] { + 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, + headers: Record<string, string>, + body: unknown, + files?: StoredRequest['files'], + ): Promise<IEither<Error, StoredRequest>> { + if (!isSafeRouteName(routeName)) { + return Either.left(new Error('Invalid route name')); + } + + const timestamp = Date.now(); + const uuid = randomUUID(); + const filename = `${timestamp}_${uuid}.json`; + + const stored: StoredRequest = { + timestamp, + uuid, + routeName, + method, + headers, + body, + files, + }; + + const filepath = join(this.dataDir, routeName, filename); + try { + await writeFile(filepath, JSON.stringify(stored, null, 2)); + return Either.right(stored); + } catch (err) { + return Either.left(err instanceof Error ? err : new Error(String(err))); + } + } +} diff --git a/src/token/index.ts b/src/token/index.ts new file mode 100644 index 0000000..7251714 --- /dev/null +++ b/src/token/index.ts @@ -0,0 +1,76 @@ +import { createHmac, randomBytes } from 'crypto'; +import { Either, type IEither } from '@emprespresso/pengueno'; + +export interface TokenPayload { + routeName: string; + timestamp: number; +} + +export class TokenSigner { + private readonly secret: string; + private readonly ttlSeconds: number; + + constructor(secret?: string, ttlSeconds: number = 30) { + this.secret = secret || randomBytes(32).toString('hex'); + this.ttlSeconds = ttlSeconds; + } + + generate(routeName: string): string { + const timestamp = Date.now(); + const payload = JSON.stringify({ routeName, timestamp }); + const signature = this.sign(payload); + const token = Buffer.from(`${payload}.${signature}`).toString('base64url'); + return token; + } + + validate(token: string, expectedRoute: string): IEither<Error, TokenPayload> { + try { + const decoded = Buffer.from(token, 'base64url').toString('utf-8'); + const lastDotIndex = decoded.lastIndexOf('.'); + + if (lastDotIndex === -1) { + return Either.left(new Error('Invalid token format')); + } + + const payload = decoded.substring(0, lastDotIndex); + const signature = decoded.substring(lastDotIndex + 1); + + // Verify signature + const expectedSignature = this.sign(payload); + if (signature !== expectedSignature) { + return Either.left(new Error('Invalid token signature')); + } + + // Parse payload + const parsed: TokenPayload = JSON.parse(payload); + + // Check route name + if (parsed.routeName !== expectedRoute) { + return Either.left(new Error('Token route mismatch')); + } + + // Check expiration + const now = Date.now(); + const age = (now - parsed.timestamp) / 1000; + if (age > this.ttlSeconds) { + return Either.left(new Error('Token expired')); + } + + if (age < 0) { + return Either.left(new Error('Token from future')); + } + + return Either.right(parsed); + } catch (err) { + return Either.left(err instanceof Error ? err : new Error(String(err))); + } + } + + private sign(payload: string): string { + return createHmac('sha256', this.secret).update(payload).digest('hex'); + } + + getSecret(): string { + return this.secret; + } +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..fbfc70d --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,78 @@ +export enum ContentType { + JSON = 'json', + FORM = 'form', + MULTIPART = 'multipart', + TEXT = 'text', + RAW = 'raw', +} + +export interface NtfyConfig { + enabled: boolean; + server?: string; + topic?: string; +} + +export interface RouteConfig { + name: string; + contentType: ContentType; + hcaptchaProtected: boolean; + hcaptchaSecret?: string; + ntfy?: NtfyConfig; + requireToken?: boolean; +} + +export interface StoredRequest { + timestamp: number; + uuid: string; + routeName: string; + method: string; + headers: Record<string, string>; + body: unknown; + files?: Array<{ + filename: string; + contentType: string; + size: number; + path: string; + }>; +} + +const ROUTE_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/i; + +export function isSafeRouteName(name: unknown): name is string { + if (typeof name !== 'string') return false; + if (name !== name.trim()) return false; + if (name === '.' || name === '..') return false; + if (name.includes('/') || name.includes('\\')) return false; + return ROUTE_NAME_PATTERN.test(name); +} + +export function isRouteConfig(obj: unknown): obj is RouteConfig { + if (typeof obj !== 'object' || obj === null) return false; + const r = obj as Record<string, unknown>; + + const validBasic = + isSafeRouteName(r.name) && + typeof r.contentType === 'string' && + Object.values(ContentType).includes(r.contentType as ContentType) && + typeof r.hcaptchaProtected === 'boolean' && + (r.hcaptchaProtected === false || typeof r.hcaptchaSecret === 'string'); + + if (!validBasic) return false; + + // Validate ntfy config if present + if (r.ntfy !== undefined) { + if (typeof r.ntfy !== 'object' || r.ntfy === null) return false; + const ntfy = r.ntfy as Record<string, unknown>; + if (typeof ntfy.enabled !== 'boolean') return false; + if (ntfy.enabled && (typeof ntfy.server !== 'string' || typeof ntfy.topic !== 'string')) { + return false; + } + } + + // Validate requireToken if present + if (r.requireToken !== undefined && typeof r.requireToken !== 'boolean') { + return false; + } + + return true; +} |
