diff options
| author | Elizabeth Hunt <me@liz.coffee> | 2025-12-14 20:36:24 -0800 |
|---|---|---|
| committer | Elizabeth Hunt <me@liz.coffee> | 2025-12-14 20:36:24 -0800 |
| commit | 6bf57766feb8321f860baf300140563cd9539053 (patch) | |
| tree | d80ff78c2a7f4dbea79f9ee850542aee1b735ef4 /src/activity/index.ts | |
| download | posthook-6bf57766feb8321f860baf300140563cd9539053.tar.gz posthook-6bf57766feb8321f860baf300140563cd9539053.zip | |
Init
Diffstat (limited to 'src/activity/index.ts')
| -rw-r--r-- | src/activity/index.ts | 438 |
1 files changed, 438 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(); + }; + } +} |
