378 lines
14 KiB
TypeScript
378 lines
14 KiB
TypeScript
/**
|
|
* Submission API for the embeddable form widget (Phase 05 Task #58, layer 4).
|
|
*
|
|
* POST /api/embed/forms/:formKey/submit
|
|
*
|
|
* Body: `application/x-www-form-urlencoded` with a single `__payload` field
|
|
* carrying a JSON string `{ values, turnstileToken? }`. The form sends data
|
|
* this way so the standard `<fetcher.Form>` POST works without CORS preflights.
|
|
*
|
|
* Pipeline:
|
|
* 1. Resolve the form by public key (cuid). 404 when missing / disabled.
|
|
* 2. Allow-list the Origin header against `form.allowedOrigins` (wildcards
|
|
* via {@link matchAllowedOrigin}). Reject with 403 on mismatch.
|
|
* 3. Honeypot — silent 204 when the bot-marker field is filled.
|
|
* 4. Sliding-window rate-limit (10/min/IP, 100/min/origin) via the shared
|
|
* Redis store (factory in `app/shared-store.server.ts`).
|
|
* 5. Optional Cloudflare Turnstile verification, gated behind the
|
|
* `form-widget-turnstile` feature flag.
|
|
* 6. Validate payload against the stored form definition.
|
|
* 7. Persist a {@link FormSubmission} row (status=received) AND publish a
|
|
* `form.submission.process` outbox event so the worker can route the
|
|
* payload to CRM-contact / entity-row / webhook targets.
|
|
*
|
|
* Response shape: `{ ok: true }` on success, `{ ok: false, error }` for the
|
|
* client renderer. Honeypot-triggered + origin-blocked requests do NOT echo
|
|
* the body (silent 204 / generic 403) so bots get minimal signal.
|
|
*/
|
|
|
|
import { createHash } from "node:crypto";
|
|
import {
|
|
FormSubmissionPayloadSchema,
|
|
FormDefinitionSchema,
|
|
isHoneypotTriggered,
|
|
matchAllowedOrigin,
|
|
resolveSubmissionTarget,
|
|
type FormDefinition,
|
|
} from "@toolctrl/form-widget";
|
|
import { publishOutboxEvent, type OutboxPublisherTransaction } from "@toolctrl/db/outbox";
|
|
import { createSlidingWindowRateLimiter } from "@toolctrl/shared-store";
|
|
import { db } from "~/utils/db.server";
|
|
import { getTranslations } from "~/locale/i18next.server";
|
|
import { getSharedStore } from "~/shared-store.server";
|
|
import { createScopedLogger } from "~/utils/logger.server";
|
|
import { evaluateFlagForContext } from "~/utils/services/.server/featureFlagEvaluator.server";
|
|
import { resolveCaptchaConfig, verifyCaptchaToken } from "@toolctrl/captcha";
|
|
|
|
const log = createScopedLogger("form-widget.submit");
|
|
|
|
const RATE_LIMIT_WINDOW_SECONDS = 60;
|
|
const RATE_LIMIT_PER_IP = 10;
|
|
const RATE_LIMIT_PER_ORIGIN = 100;
|
|
|
|
// On a rate-limit store (Redis) outage, production fails closed so an attacker
|
|
// cannot flood this unauthenticated endpoint by knocking the store offline.
|
|
// Non-production fails open so a local Redis blip doesn't drop submissions.
|
|
function shouldFailClosedOnRateLimitInfraFailure(): boolean {
|
|
return process.env.NODE_ENV === "production";
|
|
}
|
|
const PAYLOAD_BYTE_LIMIT = 256 * 1024; // 256 KiB hard cap on the form-encoded body
|
|
|
|
type EmbedSubmitErrorCode =
|
|
| "METHOD_NOT_ALLOWED"
|
|
| "FORM_NOT_FOUND"
|
|
| "PAYLOAD_TOO_LARGE"
|
|
| "ORIGIN_NOT_ALLOWED"
|
|
| "INVALID_PAYLOAD"
|
|
| "INVALID_PAYLOAD_SHAPE"
|
|
| "RATE_LIMIT_IP"
|
|
| "RATE_LIMIT_ORIGIN"
|
|
| "FORM_MISCONFIGURED"
|
|
| "FIELD_REQUIRED"
|
|
| "FIELD_TOO_LONG"
|
|
| "FIELD_TOO_SHORT"
|
|
| "FIELD_INVALID_EMAIL"
|
|
| "TURNSTILE_FAILED";
|
|
|
|
type EmbedSubmitError = {
|
|
ok: false;
|
|
error: string;
|
|
errorCode: EmbedSubmitErrorCode;
|
|
errorKey: string;
|
|
errorParams?: Record<string, string | number | boolean>;
|
|
};
|
|
|
|
const ERROR_KEYS: Record<EmbedSubmitErrorCode, string> = {
|
|
METHOD_NOT_ALLOWED: "embed.forms.submit.errors.methodNotAllowed",
|
|
FORM_NOT_FOUND: "embed.forms.submit.errors.formNotFound",
|
|
PAYLOAD_TOO_LARGE: "embed.forms.submit.errors.payloadTooLarge",
|
|
ORIGIN_NOT_ALLOWED: "embed.forms.submit.errors.originNotAllowed",
|
|
INVALID_PAYLOAD: "embed.forms.submit.errors.invalidPayload",
|
|
INVALID_PAYLOAD_SHAPE: "embed.forms.submit.errors.invalidPayloadShape",
|
|
RATE_LIMIT_IP: "embed.forms.submit.errors.rateLimitIp",
|
|
RATE_LIMIT_ORIGIN: "embed.forms.submit.errors.rateLimitOrigin",
|
|
FORM_MISCONFIGURED: "embed.forms.submit.errors.formMisconfigured",
|
|
FIELD_REQUIRED: "embed.forms.submit.errors.fieldRequired",
|
|
FIELD_TOO_LONG: "embed.forms.submit.errors.fieldTooLong",
|
|
FIELD_TOO_SHORT: "embed.forms.submit.errors.fieldTooShort",
|
|
FIELD_INVALID_EMAIL: "embed.forms.submit.errors.fieldInvalidEmail",
|
|
TURNSTILE_FAILED: "embed.forms.submit.errors.turnstileFailed",
|
|
};
|
|
|
|
function fallbackErrorMessage(code: EmbedSubmitErrorCode, params: Record<string, string | number | boolean> = {}): string {
|
|
const label = typeof params.fieldLabel === "string" ? params.fieldLabel : "This field";
|
|
switch (code) {
|
|
case "METHOD_NOT_ALLOWED":
|
|
return "Method not allowed";
|
|
case "FORM_NOT_FOUND":
|
|
return "Form not found";
|
|
case "PAYLOAD_TOO_LARGE":
|
|
return "Payload too large";
|
|
case "ORIGIN_NOT_ALLOWED":
|
|
return "Origin not allowed";
|
|
case "INVALID_PAYLOAD":
|
|
return "Invalid payload";
|
|
case "INVALID_PAYLOAD_SHAPE":
|
|
return "Invalid payload shape";
|
|
case "RATE_LIMIT_IP":
|
|
return "Too many submissions. Try again shortly.";
|
|
case "RATE_LIMIT_ORIGIN":
|
|
return "Too many submissions for this site.";
|
|
case "FORM_MISCONFIGURED":
|
|
return "Form is misconfigured";
|
|
case "FIELD_REQUIRED":
|
|
return `${label} is required.`;
|
|
case "FIELD_TOO_LONG":
|
|
return `${label} is too long.`;
|
|
case "FIELD_TOO_SHORT":
|
|
return `${label} is too short.`;
|
|
case "FIELD_INVALID_EMAIL":
|
|
return `${label} must be a valid email.`;
|
|
case "TURNSTILE_FAILED":
|
|
return "Anti-spam check failed. Please retry.";
|
|
}
|
|
}
|
|
|
|
function errorResponse(
|
|
t: Awaited<ReturnType<typeof getTranslations>>["t"],
|
|
code: EmbedSubmitErrorCode,
|
|
status: number,
|
|
params?: Record<string, string | number | boolean>,
|
|
init?: ResponseInit,
|
|
): Response {
|
|
const errorKey = ERROR_KEYS[code];
|
|
const payload: EmbedSubmitError = {
|
|
ok: false,
|
|
error: t(errorKey, fallbackErrorMessage(code, params), params),
|
|
errorCode: code,
|
|
errorKey,
|
|
...(params ? { errorParams: params } : {}),
|
|
};
|
|
return Response.json(payload, { ...init, status });
|
|
}
|
|
|
|
function hashIp(ip: string | null): string | null {
|
|
if (!ip) return null;
|
|
return createHash("sha256").update(ip).digest("hex").slice(0, 32);
|
|
}
|
|
|
|
function clientIp(request: Request): string | null {
|
|
const fwd = request.headers.get("x-forwarded-for");
|
|
if (fwd) return fwd.split(",")[0]?.trim() || null;
|
|
return request.headers.get("x-real-ip");
|
|
}
|
|
|
|
function validateAgainstDefinition(values: Record<string, unknown>, def: FormDefinition): { code: EmbedSubmitErrorCode; params: Record<string, string | number | boolean> } | null {
|
|
for (const field of def.fields) {
|
|
const raw = values[field.key];
|
|
if (field.type === "honeypot") continue;
|
|
const params = { fieldKey: field.key, fieldLabel: field.label };
|
|
const filled = raw !== undefined && raw !== null && raw !== "" && raw !== false;
|
|
if (field.required && !filled) {
|
|
return { code: "FIELD_REQUIRED", params };
|
|
}
|
|
if (typeof raw === "string") {
|
|
if (field.maxLength !== undefined && raw.length > field.maxLength) {
|
|
return { code: "FIELD_TOO_LONG", params: { ...params, maxLength: field.maxLength } };
|
|
}
|
|
if (field.minLength !== undefined && raw.length < field.minLength && filled) {
|
|
return { code: "FIELD_TOO_SHORT", params: { ...params, minLength: field.minLength } };
|
|
}
|
|
if (field.type === "email" && filled && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(raw)) {
|
|
return { code: "FIELD_INVALID_EMAIL", params };
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export async function action({ params, request }: { params: { formKey?: string }; request: Request }): Promise<Response> {
|
|
const { t } = await getTranslations(request);
|
|
if (request.method !== "POST") {
|
|
return errorResponse(t, "METHOD_NOT_ALLOWED", 405, undefined, { headers: { Allow: "POST" } });
|
|
}
|
|
const formKey = params.formKey;
|
|
if (!formKey) return errorResponse(t, "FORM_NOT_FOUND", 404);
|
|
|
|
const contentLength = Number(request.headers.get("content-length") ?? "0");
|
|
if (Number.isFinite(contentLength) && contentLength > PAYLOAD_BYTE_LIMIT) {
|
|
return errorResponse(t, "PAYLOAD_TOO_LARGE", 413);
|
|
}
|
|
|
|
const form = await db.form.findUnique({
|
|
where: { key: formKey },
|
|
select: {
|
|
id: true,
|
|
tenantId: true,
|
|
isEnabled: true,
|
|
definition: true,
|
|
allowedOrigins: true,
|
|
targetType: true,
|
|
targetEntityId: true,
|
|
fieldMapping: true,
|
|
config: true,
|
|
webhookUrl: true,
|
|
notifyEmails: true,
|
|
},
|
|
});
|
|
if (!form || !form.isEnabled) {
|
|
return errorResponse(t, "FORM_NOT_FOUND", 404);
|
|
}
|
|
|
|
// 2. Origin allowlist
|
|
const origin = request.headers.get("origin");
|
|
if (!matchAllowedOrigin(origin, form.allowedOrigins)) {
|
|
log.info({ op: "blocked.origin", formId: form.id, origin }, "Submission origin not allowlisted");
|
|
return errorResponse(t, "ORIGIN_NOT_ALLOWED", 403);
|
|
}
|
|
|
|
// 1. Parse body
|
|
let payloadJson: unknown;
|
|
try {
|
|
const formBody = await request.formData();
|
|
const raw = formBody.get("__payload");
|
|
if (typeof raw !== "string") throw new Error("payload missing");
|
|
if (raw.length > PAYLOAD_BYTE_LIMIT) throw new Error("payload too large");
|
|
payloadJson = JSON.parse(raw);
|
|
} catch (err) {
|
|
return errorResponse(t, "INVALID_PAYLOAD", 400);
|
|
}
|
|
|
|
const parsed = FormSubmissionPayloadSchema.safeParse(payloadJson);
|
|
if (!parsed.success) {
|
|
return errorResponse(t, "INVALID_PAYLOAD_SHAPE", 400);
|
|
}
|
|
const { values, turnstileToken } = parsed.data;
|
|
|
|
// 3. Honeypot — silent 204 to starve bot signal
|
|
if (isHoneypotTriggered(values)) {
|
|
log.info({ op: "blocked.honeypot", formId: form.id }, "Honeypot triggered — silent drop");
|
|
return new Response(null, { status: 204 });
|
|
}
|
|
|
|
// 4. Rate-limit (per-IP and per-origin)
|
|
const ip = clientIp(request);
|
|
try {
|
|
const store = getSharedStore();
|
|
const limiter = createSlidingWindowRateLimiter({
|
|
store,
|
|
max: RATE_LIMIT_PER_IP,
|
|
windowSeconds: RATE_LIMIT_WINDOW_SECONDS,
|
|
prefix: "form-widget:rl",
|
|
});
|
|
if (ip) {
|
|
const decision = await limiter.consume(`ip:${ip}`);
|
|
if (!decision.allowed) {
|
|
return errorResponse(t, "RATE_LIMIT_IP", 429);
|
|
}
|
|
}
|
|
if (origin) {
|
|
const originLimiter = createSlidingWindowRateLimiter({
|
|
store,
|
|
max: RATE_LIMIT_PER_ORIGIN,
|
|
windowSeconds: RATE_LIMIT_WINDOW_SECONDS,
|
|
prefix: "form-widget:rl-origin",
|
|
});
|
|
const decision = await originLimiter.consume(`origin:${origin}`);
|
|
if (!decision.allowed) {
|
|
return errorResponse(t, "RATE_LIMIT_ORIGIN", 429);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
log.warn({ op: "rate-limit.error", err: err instanceof Error ? err.message : String(err) }, "Rate-limit check failed");
|
|
if (shouldFailClosedOnRateLimitInfraFailure()) {
|
|
// Production: refuse rather than allow unbounded submissions on this
|
|
// unauthenticated endpoint while the rate-limit store is unavailable.
|
|
return errorResponse(t, "RATE_LIMIT_IP", 503);
|
|
}
|
|
// Non-production: fail open so a local Redis blip doesn't drop submissions.
|
|
}
|
|
|
|
// 5. Validate against form definition
|
|
const def = FormDefinitionSchema.safeParse(form.definition);
|
|
if (!def.success) {
|
|
log.error({ op: "form.misconfigured", formId: form.id }, "Stored definition failed schema");
|
|
return errorResponse(t, "FORM_MISCONFIGURED", 500);
|
|
}
|
|
const valueError = validateAgainstDefinition(values, def.data);
|
|
if (valueError) {
|
|
return errorResponse(t, valueError.code, 400, valueError.params);
|
|
}
|
|
|
|
// 6. Optional captcha gate (mCaptcha)
|
|
const flag = await evaluateFlagForContext("form-widget-turnstile", {
|
|
flagName: "form-widget-turnstile",
|
|
tenantId: form.tenantId,
|
|
userId: null,
|
|
});
|
|
const cfg = (form.config ?? {}) as { turnstileEnabled?: boolean };
|
|
if (flag.enabled && cfg.turnstileEnabled === true) {
|
|
const captcha = resolveCaptchaConfig(process.env);
|
|
const ok = await verifyCaptchaToken({
|
|
token: turnstileToken,
|
|
sitekey: captcha.sitekeys.forms,
|
|
secret: captcha.accountSecret,
|
|
instanceUrl: captcha.instanceUrl,
|
|
failOpen: captcha.failOpen,
|
|
logger: log,
|
|
});
|
|
if (!ok) {
|
|
return errorResponse(t, "TURNSTILE_FAILED", 403);
|
|
}
|
|
}
|
|
|
|
// 7. Persist submission + publish outbox event atomically.
|
|
//
|
|
// The outbox row is committed in the same transaction as the FormSubmission
|
|
// insert, so the worker (apps/worker/src/jobs/form-submission-route.ts) is
|
|
// guaranteed to see a routable row when it claims the event. For
|
|
// `targetType=generic` the worker no-ops; for `crm-contact` / `entity-row`
|
|
// it materializes the actual entity write via the existing CRM / Rows APIs.
|
|
const target = resolveSubmissionTarget({
|
|
targetType: form.targetType,
|
|
targetEntityId: form.targetEntityId,
|
|
fieldMapping: (form.fieldMapping ?? {}) as Record<string, string>,
|
|
});
|
|
|
|
const submission = await db.$transaction(async (tx) => {
|
|
const created = await tx.formSubmission.create({
|
|
data: {
|
|
formId: form.id,
|
|
tenantId: form.tenantId,
|
|
payload: { values } as object,
|
|
sourceOrigin: origin ?? null,
|
|
ipHash: hashIp(ip),
|
|
userAgent: request.headers.get("user-agent")?.slice(0, 500) ?? null,
|
|
status: "received",
|
|
result: { target: target.type } as object,
|
|
},
|
|
select: { id: true },
|
|
});
|
|
|
|
const outboxPayload = {
|
|
submissionId: created.id,
|
|
formId: form.id,
|
|
tenantId: form.tenantId,
|
|
targetType: target.type,
|
|
targetEntityId: target.entityId,
|
|
fieldMapping: { ...target.fieldMapping },
|
|
webhookUrl: form.webhookUrl ?? null,
|
|
notifyEmails: form.notifyEmails,
|
|
values,
|
|
} as unknown as Parameters<typeof publishOutboxEvent>[1]["payload"];
|
|
|
|
await publishOutboxEvent(tx as unknown as OutboxPublisherTransaction, {
|
|
tenantId: form.tenantId,
|
|
type: "form.submission.process",
|
|
payload: outboxPayload,
|
|
dispatchKey: `form-submission:${created.id}`,
|
|
});
|
|
|
|
return created;
|
|
});
|
|
|
|
log.info({ op: "submission.received", formId: form.id, submissionId: submission.id, target: target.type }, "Submission accepted");
|
|
|
|
return Response.json({ ok: true });
|
|
}
|