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) { return r.flatMap(TraceUtil.withClassTrace(this)).flatMap(TraceUtil.withMetricTrace(routeConfigMetric)); } public registerRoute(r: ITraceable) { const routeConfigTransformer = (j: ITraceable): IEither => { 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( 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) { 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; uploads: | Array<{ fieldName: string; filename: string; contentType: string; size: number; data: Uint8Array; }> | undefined; } > > { try { type ParsedBody = { body: unknown; redirect: string | undefined; token: string | undefined; uploads: | Array<{ fieldName: string; filename: string; contentType: string; size: number; data: Uint8Array; }> | undefined; }; switch (contentType) { case ContentType.JSON: { const rawBody = await req.req.text(); try { return Either.right({ body: JSON.parse(rawBody), redirect: undefined, token: undefined, uploads: undefined, }); } catch { return Either.left(new PenguenoError('Invalid JSON', 400)); } } case ContentType.FORM: { const rawBody = await req.req.text(); try { const formData = new URLSearchParams(rawBody); const obj: Record = {}; 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({ body: obj, redirect, token, uploads: undefined }); } catch { return Either.left(new PenguenoError('Invalid form data', 400)); } } case ContentType.MULTIPART: { try { const formData = await req.req.formData(); const obj: Record = {}; const uploads: ParsedBody['uploads'] = []; let redirect: string | undefined; let token: string | undefined; for (const [key, value] of formData.entries()) { if (typeof value === 'string') { if (key === '_redirect') { redirect = value; } else if (key === '_token') { token = value; } else { const existing = obj[key]; if (existing === undefined) { obj[key] = value; } else if (Array.isArray(existing)) { existing.push(value); } else { obj[key] = [existing, value]; } } continue; } // Avoid DOM typings; treat as a File-like object. const maybeFile = value as unknown as { name?: unknown; type?: unknown; size?: unknown; arrayBuffer?: unknown; }; const filename = typeof maybeFile.name === 'string' ? maybeFile.name : 'upload.bin'; const contentType = typeof maybeFile.type === 'string' ? maybeFile.type : 'application/octet-stream'; const size = typeof maybeFile.size === 'number' ? maybeFile.size : 0; if (typeof maybeFile.arrayBuffer !== 'function') { return Either.left(new PenguenoError('Invalid multipart file upload', 400)); } const buf = new Uint8Array(await (maybeFile.arrayBuffer as () => Promise)()); uploads.push({ fieldName: key, filename, contentType, size, data: buf }); } return Either.right({ body: obj, redirect, token, uploads }); } catch { return Either.left(new PenguenoError('Invalid multipart form data', 400)); } } case ContentType.TEXT: { const rawBody = await req.req.text(); return Either.right({ body: rawBody, redirect: undefined, token: undefined, uploads: undefined, }); } case ContentType.RAW: { const rawBody = await req.req.text(); return Either.right({ body: rawBody, redirect: undefined, token: undefined, uploads: undefined, }); } default: { const rawBody = await req.req.text(); return Either.right({ body: rawBody, redirect: undefined, token: undefined, uploads: undefined, }); } } } catch (err) { return Either.left(new PenguenoError(err instanceof Error ? err.message : String(err), 500)); } } public processWebhook(routeName: string) { return (r: ITraceable) => { 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(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( new PenguenoError('Missing hCaptcha token', 400), ), ); } if (!route.hcaptchaSecret) { tReq.trace.traceScope(LogLevel.ERROR).trace('hCaptcha secret not configured'); return tReq.move( Either.left( 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( 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(bodyResult.left().get())); } const { body, redirect, token: bodyToken, uploads } = 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(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( new PenguenoError('Invalid or expired token', 403), ), ); } } // Store the request const storeResult = await this.storage.storeRequest(routeName, req.method, headers, body, uploads); if (storeResult.left().present()) { return tReq.move( Either.left( 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 baseName = `${storedRequest.timestamp}_${storedRequest.uuid}`; const storedPath = `${baseName}/request.json`; return tReq.move( Either.right({ success: true, stored: storedPath, 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( 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) { return r.flatMap(TraceUtil.withClassTrace(this)).flatMap(TraceUtil.withMetricTrace(listRoutesMetric)); } public listRoutes(r: ITraceable) { 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({ routes: sanitized }); }) .peek( TraceUtil.traceResultingEither( 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) { return r.flatMap(TraceUtil.withClassTrace(this)).flatMap(TraceUtil.withMetricTrace(tokenGenerateMetric)); } public generateToken(routeName: string) { return (r: ITraceable) => { 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(new PenguenoError('Route not found', 404)); } const token = this.signer.generate(routeName); const expiresAt = Date.now() + 30 * 1000; // 30 seconds return Either.right({ token, expiresAt }); }) .peek( TraceUtil.traceResultingEither( tokenGenerateMetric, ), ) .map( async (tEitherResult) => new JsonResponse(r, tEitherResult.get(), { status: tEitherResult.get().fold( ({ status }) => status, () => 200, ), }), ) .get(); }; } }