aboutsummaryrefslogtreecommitdiff
path: root/src/activity
diff options
context:
space:
mode:
Diffstat (limited to 'src/activity')
-rw-r--r--src/activity/index.ts438
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();
+ };
+ }
+}