first commit
This commit is contained in:
38
packages/plugins/forms/package.json
Normal file
38
packages/plugins/forms/package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@emdashcms/plugin-forms",
|
||||
"version": "0.0.1",
|
||||
"description": "Forms plugin for EmDash CMS - build forms, collect submissions, send notifications",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./admin": "./src/admin.tsx",
|
||||
"./astro": "./src/astro/index.ts",
|
||||
"./client": "./src/client/index.ts",
|
||||
"./styles": "./src/styles/forms.css"
|
||||
},
|
||||
"files": ["src"],
|
||||
"keywords": [
|
||||
"emdash",
|
||||
"cms",
|
||||
"plugin",
|
||||
"forms",
|
||||
"submissions",
|
||||
"contact-form"
|
||||
],
|
||||
"author": "Matt Kane",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"astro": ">=6.0.0-beta.0",
|
||||
"emdash": "workspace:*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@cloudflare/kumo": "^1.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"ulidx": "^2.4.1"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit"
|
||||
}
|
||||
}
|
||||
1288
packages/plugins/forms/src/admin.tsx
Normal file
1288
packages/plugins/forms/src/admin.tsx
Normal file
File diff suppressed because it is too large
Load Diff
26
packages/plugins/forms/src/astro/Form.astro
Normal file
26
packages/plugins/forms/src/astro/Form.astro
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
/**
|
||||
* Standalone form component.
|
||||
*
|
||||
* Use this outside Portable Text content to embed a form directly.
|
||||
*
|
||||
* @example
|
||||
* ```astro
|
||||
* ---
|
||||
* import { Form } from "@emdashcms/plugin-forms/ui";
|
||||
* ---
|
||||
*
|
||||
* <Form id="contact-form" />
|
||||
* ```
|
||||
*/
|
||||
import FormEmbed from "./FormEmbed.astro";
|
||||
|
||||
interface Props {
|
||||
/** Form ID or slug */
|
||||
id: string;
|
||||
}
|
||||
|
||||
const { id } = Astro.props;
|
||||
---
|
||||
|
||||
<FormEmbed node={{ formId: id }} />
|
||||
301
packages/plugins/forms/src/astro/FormEmbed.astro
Normal file
301
packages/plugins/forms/src/astro/FormEmbed.astro
Normal file
@@ -0,0 +1,301 @@
|
||||
---
|
||||
/**
|
||||
* Form embed component for Portable Text blocks.
|
||||
*
|
||||
* Server-renders the full form with all pages as <fieldset> elements.
|
||||
* Without JavaScript, all pages are visible as one long form.
|
||||
* The client-side script enhances with multi-page navigation, AJAX, etc.
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
node: { formId: string };
|
||||
}
|
||||
|
||||
interface FormField {
|
||||
id: string;
|
||||
type: string;
|
||||
label: string;
|
||||
name: string;
|
||||
placeholder?: string;
|
||||
helpText?: string;
|
||||
required: boolean;
|
||||
validation?: {
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
pattern?: string;
|
||||
patternMessage?: string;
|
||||
accept?: string;
|
||||
maxFileSize?: number;
|
||||
};
|
||||
options?: Array<{ label: string; value: string }>;
|
||||
defaultValue?: string;
|
||||
width: "full" | "half";
|
||||
condition?: { field: string; op: string; value?: string };
|
||||
}
|
||||
|
||||
interface FormPage {
|
||||
title?: string;
|
||||
fields: FormField[];
|
||||
}
|
||||
|
||||
interface FormDefinition {
|
||||
name: string;
|
||||
slug: string;
|
||||
pages: FormPage[];
|
||||
settings: {
|
||||
spamProtection: string;
|
||||
submitLabel: string;
|
||||
nextLabel?: string;
|
||||
prevLabel?: string;
|
||||
};
|
||||
status: string;
|
||||
_turnstileSiteKey?: string | null;
|
||||
}
|
||||
|
||||
const { node } = Astro.props;
|
||||
const formId = node.formId;
|
||||
|
||||
// Fetch form definition server-side
|
||||
const response = await fetch(
|
||||
new URL("/_emdash/api/plugins/emdash-forms/definition", Astro.url),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id: formId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return;
|
||||
|
||||
const form = (await response.json()) as FormDefinition;
|
||||
if (!form || form.status !== "active") return;
|
||||
|
||||
const submitUrl = `/_emdash/api/plugins/emdash-forms/submit`;
|
||||
const isMultiPage = form.pages.length > 1;
|
||||
const turnstileSiteKey = form._turnstileSiteKey;
|
||||
const hasFiles = form.pages.some((p: FormPage) =>
|
||||
p.fields.some((f: FormField) => f.type === "file")
|
||||
);
|
||||
|
||||
/** Generate an element ID for a field */
|
||||
function fieldId(name: string): string {
|
||||
return `${formId}-${name}`;
|
||||
}
|
||||
---
|
||||
|
||||
<form
|
||||
class="ec-form"
|
||||
method="POST"
|
||||
action={submitUrl}
|
||||
enctype={hasFiles ? "multipart/form-data" : undefined}
|
||||
data-form-id={formId}
|
||||
data-ec-form
|
||||
data-pages={isMultiPage ? form.pages.length : undefined}
|
||||
>
|
||||
{
|
||||
form.pages.map((page: FormPage, pageIndex: number) => (
|
||||
<fieldset
|
||||
class="ec-form-page"
|
||||
data-page={pageIndex}
|
||||
aria-label={page.title || `Page ${pageIndex + 1}`}
|
||||
>
|
||||
{isMultiPage && page.title && (
|
||||
<legend class="ec-form-page-title">{page.title}</legend>
|
||||
)}
|
||||
|
||||
{page.fields.map((field: FormField) => (
|
||||
<div
|
||||
class:list={[
|
||||
"ec-form-field",
|
||||
`ec-form-field--${field.type}`,
|
||||
field.width === "half" && "ec-form-field--half",
|
||||
]}
|
||||
data-condition={
|
||||
field.condition ? JSON.stringify(field.condition) : undefined
|
||||
}
|
||||
>
|
||||
{field.type !== "hidden" && field.type !== "checkbox" && (
|
||||
<label class="ec-form-label" for={fieldId(field.name)}>
|
||||
{field.label}
|
||||
{field.required && (
|
||||
<span class="ec-form-required" aria-label="required">
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
{[
|
||||
"text",
|
||||
"email",
|
||||
"tel",
|
||||
"url",
|
||||
"number",
|
||||
"date",
|
||||
"hidden",
|
||||
].includes(field.type) && (
|
||||
<input
|
||||
type={field.type as astroHTML.JSX.HTMLInputTypeAttribute}
|
||||
class={field.type !== "hidden" ? "ec-form-input" : undefined}
|
||||
id={fieldId(field.name)}
|
||||
name={field.name}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
minlength={field.validation?.minLength}
|
||||
maxlength={field.validation?.maxLength}
|
||||
min={field.validation?.min}
|
||||
max={field.validation?.max}
|
||||
pattern={field.validation?.pattern}
|
||||
value={field.defaultValue}
|
||||
/>
|
||||
)}
|
||||
{field.type === "file" && (
|
||||
<input
|
||||
type="file"
|
||||
class="ec-form-input"
|
||||
id={fieldId(field.name)}
|
||||
name={field.name}
|
||||
required={field.required}
|
||||
accept={field.validation?.accept}
|
||||
/>
|
||||
)}
|
||||
{field.type === "textarea" && (
|
||||
<textarea
|
||||
class="ec-form-input"
|
||||
id={fieldId(field.name)}
|
||||
name={field.name}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
minlength={field.validation?.minLength}
|
||||
maxlength={field.validation?.maxLength}
|
||||
>
|
||||
{field.defaultValue || ""}
|
||||
</textarea>
|
||||
)}
|
||||
{field.type === "select" && (
|
||||
<select
|
||||
class="ec-form-input"
|
||||
id={fieldId(field.name)}
|
||||
name={field.name}
|
||||
required={field.required}
|
||||
>
|
||||
{(field.options || []).map((o) => (
|
||||
<option
|
||||
value={o.value}
|
||||
selected={o.value === field.defaultValue}
|
||||
>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{field.type === "radio" && (
|
||||
<fieldset class="ec-form-radio-group" role="radiogroup">
|
||||
{(field.options || []).map((o) => (
|
||||
<label class="ec-form-radio-label">
|
||||
<input
|
||||
type="radio"
|
||||
name={field.name}
|
||||
value={o.value}
|
||||
checked={o.value === field.defaultValue}
|
||||
required={field.required}
|
||||
/>{" "}
|
||||
{o.label}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
)}
|
||||
{field.type === "checkbox" && (
|
||||
<label class="ec-form-checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="ec-form-input"
|
||||
id={fieldId(field.name)}
|
||||
name={field.name}
|
||||
value={field.defaultValue || "1"}
|
||||
required={field.required}
|
||||
/>{" "}
|
||||
{field.label}
|
||||
</label>
|
||||
)}
|
||||
{field.type === "checkbox-group" && (
|
||||
<fieldset class="ec-form-checkbox-group">
|
||||
{(field.options || []).map((o) => (
|
||||
<label class="ec-form-checkbox-label">
|
||||
<input type="checkbox" name={field.name} value={o.value} />{" "}
|
||||
{o.label}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
)}
|
||||
{field.helpText && (
|
||||
<span class="ec-form-help">{field.helpText}</span>
|
||||
)}
|
||||
<span
|
||||
class="ec-form-error"
|
||||
data-error-for={field.name}
|
||||
aria-live="polite"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</fieldset>
|
||||
))
|
||||
}
|
||||
|
||||
{
|
||||
form.settings.spamProtection === "honeypot" && (
|
||||
<div
|
||||
class="ec-form-field"
|
||||
style="position:absolute;left:-9999px;"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<label for={`${formId}-_hp`}>Leave blank</label>
|
||||
<input
|
||||
type="text"
|
||||
id={`${formId}-_hp`}
|
||||
name="_hp"
|
||||
tabindex="-1"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
form.settings.spamProtection === "turnstile" && turnstileSiteKey && (
|
||||
<div
|
||||
class="ec-form-turnstile"
|
||||
data-ec-turnstile
|
||||
data-sitekey={turnstileSiteKey}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
<input type="hidden" name="formId" value={formId} />
|
||||
|
||||
<div class="ec-form-nav">
|
||||
<button type="button" class="ec-form-prev" data-ec-prev hidden>
|
||||
{form.settings.prevLabel || "Previous"}
|
||||
</button>
|
||||
<button type="button" class="ec-form-next" data-ec-next hidden>
|
||||
{form.settings.nextLabel || "Next"}
|
||||
</button>
|
||||
<button type="submit" class="ec-form-submit">
|
||||
{form.settings.submitLabel || "Submit"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{
|
||||
isMultiPage && (
|
||||
<div class="ec-form-progress" data-ec-progress aria-live="polite" />
|
||||
)
|
||||
}
|
||||
|
||||
<div class="ec-form-status" data-form-status aria-live="polite"></div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
import { initForms } from "@emdashcms/plugin-forms/client";
|
||||
initForms();
|
||||
</script>
|
||||
11
packages/plugins/forms/src/astro/index.ts
Normal file
11
packages/plugins/forms/src/astro/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Astro component exports for the forms plugin.
|
||||
*
|
||||
* Auto-wired via the `virtual:emdash/block-components` virtual module.
|
||||
*/
|
||||
|
||||
import FormEmbed from "./FormEmbed.astro";
|
||||
|
||||
export const blockComponents = {
|
||||
"emdash-form": FormEmbed,
|
||||
};
|
||||
536
packages/plugins/forms/src/client/index.ts
Normal file
536
packages/plugins/forms/src/client/index.ts
Normal file
@@ -0,0 +1,536 @@
|
||||
/**
|
||||
* Client-side form enhancement.
|
||||
*
|
||||
* Following the same progressive enhancement pattern as Astro's <ClientRouter />,
|
||||
* this uses event delegation on `document` — a single set of listeners handles
|
||||
* all forms on the page, including forms added after initial load.
|
||||
*
|
||||
* Features:
|
||||
* - AJAX submission (no page reload)
|
||||
* - Client-side validation with inline errors
|
||||
* - Multi-page navigation with history integration
|
||||
* - Conditional field visibility
|
||||
* - Session persistence (survives page refreshes)
|
||||
* - Turnstile widget injection
|
||||
* - File upload with FormData
|
||||
*/
|
||||
|
||||
const STORAGE_PREFIX = "ec-form:";
|
||||
const DEBOUNCE_MS = 500;
|
||||
|
||||
let saveTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let listenersRegistered = false;
|
||||
|
||||
// ─── Initialization ──────────────────────────────────────────────
|
||||
|
||||
export function initForms() {
|
||||
const init = () => {
|
||||
document.querySelectorAll<HTMLFormElement>("[data-ec-form]").forEach((form) => {
|
||||
if (form.dataset.ecInitialized) return;
|
||||
form.dataset.ecInitialized = "1";
|
||||
restoreState(form);
|
||||
initMultiPage(form);
|
||||
initConditions(form);
|
||||
initTurnstile(form);
|
||||
});
|
||||
};
|
||||
|
||||
// Guard against duplicate listener registration
|
||||
if (!listenersRegistered) {
|
||||
listenersRegistered = true;
|
||||
|
||||
// Event delegation — handles all forms, current and future
|
||||
document.addEventListener("submit", handleSubmit);
|
||||
document.addEventListener("click", handleClick);
|
||||
document.addEventListener("input", handleInput);
|
||||
document.addEventListener("change", handleChange);
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
|
||||
// Astro ClientRouter fires astro:page-load on every navigation
|
||||
document.addEventListener("astro:page-load", init);
|
||||
|
||||
// Clean up pending save timers before view transitions swap the DOM
|
||||
document.addEventListener("astro:before-swap", () => {
|
||||
for (const timer of saveTimers.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
saveTimers.clear();
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback for sites without ClientRouter
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Submit Handler ──────────────────────────────────────────────
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
const form = (e.target as HTMLElement).closest<HTMLFormElement>("[data-ec-form]");
|
||||
if (!form) return;
|
||||
e.preventDefault();
|
||||
|
||||
// Validate current (or last) page
|
||||
if (!validateVisibleFields(form)) return;
|
||||
|
||||
const submitBtn = form.querySelector<HTMLButtonElement>(".ec-form-submit");
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = "Submitting...";
|
||||
}
|
||||
|
||||
clearStatus(form);
|
||||
|
||||
try {
|
||||
const hasFiles = form.querySelector<HTMLInputElement>('input[type="file"]');
|
||||
let body: BodyInit;
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (hasFiles) {
|
||||
body = new FormData(form);
|
||||
} else {
|
||||
headers["Content-Type"] = "application/json";
|
||||
const formData = new FormData(form);
|
||||
let formId = "";
|
||||
const data: Record<string, unknown> = {};
|
||||
// Track keys we've seen to detect multi-value fields (checkbox-group)
|
||||
const seen = new Set<string>();
|
||||
for (const [key, val] of formData) {
|
||||
if (typeof val !== "string") continue;
|
||||
if (key === "formId") {
|
||||
formId = val;
|
||||
} else if (key === "_hp" || key === "cf-turnstile-response") {
|
||||
// Include spam fields at top level for server-side checks
|
||||
data[key] = val;
|
||||
} else if (seen.has(key)) {
|
||||
// Multi-value field (checkbox-group) — collect into array
|
||||
const existing = data[key];
|
||||
if (Array.isArray(existing)) {
|
||||
existing.push(val);
|
||||
} else {
|
||||
data[key] = [existing, val];
|
||||
}
|
||||
} else {
|
||||
seen.add(key);
|
||||
data[key] = val;
|
||||
}
|
||||
}
|
||||
body = JSON.stringify({ formId, data });
|
||||
}
|
||||
|
||||
const res = await fetch(form.action, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
|
||||
const result = (await res.json()) as {
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
redirect?: string;
|
||||
errors?: Array<{ field: string; message: string }>;
|
||||
};
|
||||
|
||||
if (result.success) {
|
||||
clearSavedState(form);
|
||||
if (result.redirect) {
|
||||
window.location.href = result.redirect;
|
||||
} else {
|
||||
showStatus(form, result.message || "Submitted successfully.", "success");
|
||||
form.reset();
|
||||
}
|
||||
} else if (result.errors) {
|
||||
showErrors(form, result.errors);
|
||||
} else {
|
||||
showStatus(form, "Something went wrong. Please try again.", "error");
|
||||
}
|
||||
} catch {
|
||||
showStatus(form, "Network error. Please try again.", "error");
|
||||
} finally {
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = form.dataset.submitLabel || "Submit";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Click Handler (Prev/Next) ───────────────────────────────────
|
||||
|
||||
function handleClick(e: Event) {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
const nextBtn = target.closest("[data-ec-next]");
|
||||
if (nextBtn) {
|
||||
const form = nextBtn.closest<HTMLFormElement>("[data-ec-form]");
|
||||
if (form) {
|
||||
const current = getCurrentPage(form);
|
||||
if (validatePage(form, current)) {
|
||||
showPage(form, current + 1);
|
||||
saveState(form);
|
||||
history.pushState({ ecFormPage: current + 1, ecFormId: form.dataset.formId }, "");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const prevBtn = target.closest("[data-ec-prev]");
|
||||
if (prevBtn) {
|
||||
const form = prevBtn.closest<HTMLFormElement>("[data-ec-form]");
|
||||
if (form) {
|
||||
const current = getCurrentPage(form);
|
||||
if (current > 0) {
|
||||
showPage(form, current - 1);
|
||||
saveState(form);
|
||||
history.pushState({ ecFormPage: current - 1, ecFormId: form.dataset.formId }, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Input/Change Handlers ───────────────────────────────────────
|
||||
|
||||
function handleInput(e: Event) {
|
||||
const target = e.target as HTMLElement;
|
||||
const form = target.closest<HTMLFormElement>("[data-ec-form]");
|
||||
if (!form) return;
|
||||
|
||||
// Clear field error on input
|
||||
const name = (target as HTMLInputElement).name;
|
||||
if (name) {
|
||||
const errorEl = form.querySelector(`[data-error-for="${name}"]`);
|
||||
if (errorEl) errorEl.textContent = "";
|
||||
}
|
||||
|
||||
// Debounced save
|
||||
debouncedSave(form);
|
||||
}
|
||||
|
||||
function handleChange(e: Event) {
|
||||
const target = e.target as HTMLElement;
|
||||
const form = target.closest<HTMLFormElement>("[data-ec-form]");
|
||||
if (!form) return;
|
||||
|
||||
// Evaluate conditions
|
||||
evaluateConditions(form);
|
||||
}
|
||||
|
||||
// ─── Popstate Handler ────────────────────────────────────────────
|
||||
|
||||
function handlePopState(e: PopStateEvent) {
|
||||
if (e.state && typeof e.state.ecFormPage === "number" && typeof e.state.ecFormId === "string") {
|
||||
const form = document.querySelector<HTMLFormElement>(
|
||||
`[data-ec-form][data-form-id="${CSS.escape(e.state.ecFormId)}"]`,
|
||||
);
|
||||
if (form) {
|
||||
const pages = form.querySelectorAll("[data-page]");
|
||||
const page = Math.min(e.state.ecFormPage, pages.length - 1);
|
||||
showPage(form, Math.max(0, page));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Multi-Page ──────────────────────────────────────────────────
|
||||
|
||||
function initMultiPage(form: HTMLFormElement) {
|
||||
const pages = form.querySelectorAll<HTMLFieldSetElement>("[data-page]");
|
||||
if (pages.length <= 1) return;
|
||||
|
||||
// Hide all pages except first
|
||||
pages.forEach((page, i) => {
|
||||
if (i > 0) {
|
||||
page.hidden = true;
|
||||
// Remove required from hidden pages to prevent native validation
|
||||
page.querySelectorAll<HTMLElement>("[required]").forEach((el) => {
|
||||
el.removeAttribute("required");
|
||||
el.dataset.wasRequired = "1";
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Show next button, hide submit (unless single page)
|
||||
const nextBtn = form.querySelector<HTMLButtonElement>("[data-ec-next]");
|
||||
const submitBtn = form.querySelector<HTMLButtonElement>(".ec-form-submit");
|
||||
if (nextBtn) nextBtn.hidden = false;
|
||||
if (submitBtn) submitBtn.hidden = true;
|
||||
|
||||
updateProgress(form, 0, pages.length);
|
||||
}
|
||||
|
||||
function showPage(form: HTMLFormElement, pageIndex: number) {
|
||||
const pages = form.querySelectorAll<HTMLFieldSetElement>("[data-page]");
|
||||
const totalPages = pages.length;
|
||||
|
||||
pages.forEach((page, i) => {
|
||||
if (i === pageIndex) {
|
||||
page.hidden = false;
|
||||
// Restore required attributes
|
||||
page.querySelectorAll<HTMLElement>("[data-was-required]").forEach((el) => {
|
||||
el.setAttribute("required", "");
|
||||
delete el.dataset.wasRequired;
|
||||
});
|
||||
} else {
|
||||
page.hidden = true;
|
||||
// Strip required from hidden
|
||||
page.querySelectorAll<HTMLElement>("[required]").forEach((el) => {
|
||||
el.removeAttribute("required");
|
||||
el.dataset.wasRequired = "1";
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Update button visibility
|
||||
const prevBtn = form.querySelector<HTMLButtonElement>("[data-ec-prev]");
|
||||
const nextBtn = form.querySelector<HTMLButtonElement>("[data-ec-next]");
|
||||
const submitBtn = form.querySelector<HTMLButtonElement>(".ec-form-submit");
|
||||
|
||||
if (prevBtn) prevBtn.hidden = pageIndex === 0;
|
||||
if (nextBtn) nextBtn.hidden = pageIndex === totalPages - 1;
|
||||
if (submitBtn) submitBtn.hidden = pageIndex < totalPages - 1;
|
||||
|
||||
updateProgress(form, pageIndex, totalPages);
|
||||
}
|
||||
|
||||
function getCurrentPage(form: HTMLFormElement): number {
|
||||
const pages = form.querySelectorAll<HTMLFieldSetElement>("[data-page]");
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
if (!pages[i]!.hidden) return i;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function updateProgress(form: HTMLFormElement, current: number, total: number) {
|
||||
const progress = form.querySelector("[data-ec-progress]");
|
||||
if (progress) {
|
||||
progress.textContent = `Step ${current + 1} of ${total}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Validation ──────────────────────────────────────────────────
|
||||
|
||||
function validatePage(form: HTMLFormElement, pageIndex: number): boolean {
|
||||
const page = form.querySelector<HTMLFieldSetElement>(`[data-page="${pageIndex}"]`);
|
||||
if (!page) return true;
|
||||
|
||||
let valid = true;
|
||||
page
|
||||
.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>(
|
||||
"input, select, textarea",
|
||||
)
|
||||
.forEach((input) => {
|
||||
if (!input.checkValidity()) {
|
||||
valid = false;
|
||||
showFieldError(form, input.name, input.validationMessage);
|
||||
}
|
||||
});
|
||||
|
||||
return valid;
|
||||
}
|
||||
|
||||
function validateVisibleFields(form: HTMLFormElement): boolean {
|
||||
let valid = true;
|
||||
form.querySelectorAll<HTMLFieldSetElement>("[data-page]:not([hidden])").forEach((page) => {
|
||||
page
|
||||
.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>(
|
||||
"input, select, textarea",
|
||||
)
|
||||
.forEach((input) => {
|
||||
if (!input.checkValidity()) {
|
||||
valid = false;
|
||||
showFieldError(form, input.name, input.validationMessage);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return valid;
|
||||
}
|
||||
|
||||
function showFieldError(form: HTMLFormElement, fieldName: string, message: string) {
|
||||
const errorEl = form.querySelector(`[data-error-for="${fieldName}"]`);
|
||||
if (errorEl) errorEl.textContent = message;
|
||||
}
|
||||
|
||||
function showErrors(form: HTMLFormElement, errors: Array<{ field: string; message: string }>) {
|
||||
for (const err of errors) {
|
||||
showFieldError(form, err.field, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Status Messages ─────────────────────────────────────────────
|
||||
|
||||
function showStatus(form: HTMLFormElement, message: string, type: "success" | "error") {
|
||||
const status = form.querySelector("[data-form-status]");
|
||||
if (status) {
|
||||
status.textContent = message;
|
||||
status.className = `ec-form-status ec-form-status--${type}`;
|
||||
}
|
||||
}
|
||||
|
||||
function clearStatus(form: HTMLFormElement) {
|
||||
const status = form.querySelector("[data-form-status]");
|
||||
if (status) {
|
||||
status.textContent = "";
|
||||
status.className = "ec-form-status";
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Conditional Fields ──────────────────────────────────────────
|
||||
|
||||
function initConditions(form: HTMLFormElement) {
|
||||
evaluateConditions(form);
|
||||
}
|
||||
|
||||
function evaluateConditions(form: HTMLFormElement) {
|
||||
form.querySelectorAll<HTMLElement>("[data-condition]").forEach((wrapper) => {
|
||||
try {
|
||||
const condition = JSON.parse(wrapper.dataset.condition || "{}") as {
|
||||
field: string;
|
||||
op: string;
|
||||
value?: string;
|
||||
};
|
||||
const input = form.elements.namedItem(condition.field) as HTMLInputElement | null;
|
||||
if (!input) return;
|
||||
|
||||
const value = input.value;
|
||||
let visible = true;
|
||||
|
||||
switch (condition.op) {
|
||||
case "eq":
|
||||
visible = value === (condition.value ?? "");
|
||||
break;
|
||||
case "neq":
|
||||
visible = value !== (condition.value ?? "");
|
||||
break;
|
||||
case "filled":
|
||||
visible = value !== "";
|
||||
break;
|
||||
case "empty":
|
||||
visible = value === "";
|
||||
break;
|
||||
}
|
||||
|
||||
wrapper.hidden = !visible;
|
||||
// Disable inputs in hidden fields so they're excluded from FormData
|
||||
wrapper.querySelectorAll<HTMLInputElement>("input, select, textarea").forEach((el) => {
|
||||
el.disabled = !visible;
|
||||
});
|
||||
} catch {
|
||||
// Invalid condition JSON — show field
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Session Persistence ─────────────────────────────────────────
|
||||
|
||||
function saveState(form: HTMLFormElement) {
|
||||
const formId = form.dataset.formId;
|
||||
if (!formId) return;
|
||||
|
||||
const page = getCurrentPage(form);
|
||||
const values: Record<string, string> = {};
|
||||
for (const [key, val] of new FormData(form)) {
|
||||
if (typeof val === "string") values[key] = val;
|
||||
}
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
STORAGE_PREFIX + formId,
|
||||
JSON.stringify({ page, values, savedAt: Date.now() }),
|
||||
);
|
||||
} catch {
|
||||
// sessionStorage full or unavailable — ignore
|
||||
}
|
||||
}
|
||||
|
||||
function restoreState(form: HTMLFormElement) {
|
||||
const formId = form.dataset.formId;
|
||||
if (!formId) return;
|
||||
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_PREFIX + formId);
|
||||
if (!raw) return;
|
||||
|
||||
const state = JSON.parse(raw) as {
|
||||
page: number;
|
||||
values: Record<string, string>;
|
||||
};
|
||||
|
||||
// Restore field values
|
||||
for (const [name, value] of Object.entries(state.values)) {
|
||||
const input = form.elements.namedItem(name);
|
||||
if (input && "value" in input) {
|
||||
(input as unknown as HTMLInputElement).value = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to saved page (clamped to valid range)
|
||||
if (state.page > 0) {
|
||||
const pages = form.querySelectorAll("[data-page]");
|
||||
const page = Math.min(state.page, pages.length - 1);
|
||||
if (page > 0) showPage(form, page);
|
||||
}
|
||||
} catch {
|
||||
// Invalid saved state — ignore
|
||||
}
|
||||
}
|
||||
|
||||
function clearSavedState(form: HTMLFormElement) {
|
||||
const formId = form.dataset.formId;
|
||||
if (formId) {
|
||||
try {
|
||||
sessionStorage.removeItem(STORAGE_PREFIX + formId);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedSave(form: HTMLFormElement) {
|
||||
const formId = form.dataset.formId;
|
||||
if (!formId) return;
|
||||
|
||||
const existing = saveTimers.get(formId);
|
||||
if (existing) clearTimeout(existing);
|
||||
|
||||
saveTimers.set(
|
||||
formId,
|
||||
setTimeout(() => {
|
||||
saveState(form);
|
||||
saveTimers.delete(formId);
|
||||
}, DEBOUNCE_MS),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Turnstile ───────────────────────────────────────────────────
|
||||
|
||||
function initTurnstile(form: HTMLFormElement) {
|
||||
const container = form.querySelector<HTMLElement>("[data-ec-turnstile]");
|
||||
if (!container) return;
|
||||
|
||||
const siteKey = container.dataset.sitekey;
|
||||
if (!siteKey) return;
|
||||
|
||||
// Load Turnstile script if not already loaded
|
||||
if (!document.querySelector('script[src*="turnstile"]')) {
|
||||
const script = document.createElement("script");
|
||||
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
|
||||
script.async = true;
|
||||
script.onload = () => renderTurnstile(container, siteKey);
|
||||
document.head.appendChild(script);
|
||||
} else {
|
||||
renderTurnstile(container, siteKey);
|
||||
}
|
||||
}
|
||||
|
||||
function renderTurnstile(container: HTMLElement, siteKey: string) {
|
||||
const w = window as unknown as {
|
||||
turnstile?: {
|
||||
render: (el: HTMLElement, opts: Record<string, unknown>) => void;
|
||||
};
|
||||
};
|
||||
if (w.turnstile) {
|
||||
w.turnstile.render(container, { sitekey: siteKey });
|
||||
}
|
||||
}
|
||||
160
packages/plugins/forms/src/format.ts
Normal file
160
packages/plugins/forms/src/format.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Formatting utilities for email notifications and webhook payloads.
|
||||
*/
|
||||
|
||||
import type { FormDefinition, Submission, SubmissionFile } from "./types.js";
|
||||
import { getFormFields } from "./types.js";
|
||||
|
||||
const CSV_ESCAPE_RE = /[,"\n]/;
|
||||
const DOUBLE_QUOTE_RE = /"/g;
|
||||
const CSV_FORMULA_TRIGGERS = new Set(["=", "+", "-", "@", "\t", "\r"]);
|
||||
|
||||
/**
|
||||
* Format a submission as plain text for email notifications.
|
||||
*/
|
||||
export function formatSubmissionText(
|
||||
form: FormDefinition,
|
||||
data: Record<string, unknown>,
|
||||
files?: SubmissionFile[],
|
||||
): string {
|
||||
const fields = getFormFields(form);
|
||||
const lines: string[] = [`New submission for "${form.name}"`, ""];
|
||||
|
||||
for (const field of fields) {
|
||||
if (field.type === "hidden") continue;
|
||||
const value = data[field.name];
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
|
||||
const display = Array.isArray(value)
|
||||
? (value as string[]).join(", ")
|
||||
: String(value as string | number | boolean);
|
||||
lines.push(`${field.label}: ${display}`);
|
||||
}
|
||||
|
||||
if (files && files.length > 0) {
|
||||
lines.push("", "Attached files:");
|
||||
for (const file of files) {
|
||||
lines.push(` - ${file.filename} (${formatBytes(file.size)})`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("", `Submitted at: ${new Date().toISOString()}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a digest email summarizing submissions over a period.
|
||||
*/
|
||||
export function formatDigestText(
|
||||
form: FormDefinition,
|
||||
formId: string,
|
||||
submissions: Submission[],
|
||||
siteUrl: string,
|
||||
): string {
|
||||
const lines: string[] = [
|
||||
`Daily digest for "${form.name}"`,
|
||||
"",
|
||||
`${submissions.length} new submission${submissions.length === 1 ? "" : "s"} since last digest.`,
|
||||
"",
|
||||
];
|
||||
|
||||
for (const sub of submissions.slice(0, 10)) {
|
||||
const preview = getSubmissionPreview(form, sub);
|
||||
lines.push(` - ${sub.createdAt}: ${preview}`);
|
||||
}
|
||||
|
||||
if (submissions.length > 10) {
|
||||
lines.push(` ... and ${submissions.length - 10} more`);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
"",
|
||||
`View all submissions: ${siteUrl}/_emdash/admin/plugins/emdash-forms/submissions?formId=${encodeURIComponent(formId)}`,
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a webhook payload for a new submission.
|
||||
*/
|
||||
export function formatWebhookPayload(
|
||||
form: FormDefinition,
|
||||
submissionId: string,
|
||||
data: Record<string, unknown>,
|
||||
files?: SubmissionFile[],
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
event: "form.submission",
|
||||
formId: form.slug,
|
||||
formName: form.name,
|
||||
submissionId,
|
||||
data,
|
||||
files: files?.map((f) => ({
|
||||
fieldName: f.fieldName,
|
||||
filename: f.filename,
|
||||
contentType: f.contentType,
|
||||
size: f.size,
|
||||
mediaId: f.mediaId,
|
||||
})),
|
||||
submittedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format submissions as CSV.
|
||||
*/
|
||||
export function formatCsv(
|
||||
form: FormDefinition,
|
||||
items: Array<{ id: string; data: Submission }>,
|
||||
): string {
|
||||
const fields = getFormFields(form).filter((f) => f.type !== "hidden");
|
||||
const headers = ["ID", "Submitted At", "Status", ...fields.map((f) => f.label)];
|
||||
|
||||
const rows = items.map(({ id, data: sub }) => {
|
||||
const values = [id, sub.createdAt, sub.status];
|
||||
for (const field of fields) {
|
||||
const v = sub.data[field.name];
|
||||
if (field.type === "file") {
|
||||
const file = sub.files?.find((f) => f.fieldName === field.name);
|
||||
values.push(file ? file.filename : "");
|
||||
} else if (Array.isArray(v)) {
|
||||
values.push(v.join("; "));
|
||||
} else {
|
||||
values.push(v === undefined || v === null ? "" : String(v as string | number | boolean));
|
||||
}
|
||||
}
|
||||
return values;
|
||||
});
|
||||
|
||||
return [headers, ...rows].map((row) => row.map(escapeCsv).join(",")).join("\n");
|
||||
}
|
||||
|
||||
function escapeCsv(value: string): string {
|
||||
// Neutralize formula triggers to prevent CSV injection in spreadsheet apps
|
||||
if (value.length > 0 && CSV_FORMULA_TRIGGERS.has(value.charAt(0))) {
|
||||
value = "'" + value;
|
||||
}
|
||||
if (CSV_ESCAPE_RE.test(value)) {
|
||||
return `"${value.replace(DOUBLE_QUOTE_RE, '""')}"`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function getSubmissionPreview(form: FormDefinition, sub: Submission): string {
|
||||
const fields = getFormFields(form).filter((f) => f.type !== "hidden" && f.type !== "file");
|
||||
const previews: string[] = [];
|
||||
for (const field of fields.slice(0, 3)) {
|
||||
const v = sub.data[field.name];
|
||||
if (v !== undefined && v !== null && v !== "") {
|
||||
const str = String(v as string | number | boolean);
|
||||
previews.push(str.length > 50 ? `${str.slice(0, 47)}...` : str);
|
||||
}
|
||||
}
|
||||
return previews.join(" | ") || "(empty)";
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
151
packages/plugins/forms/src/handlers/cron.ts
Normal file
151
packages/plugins/forms/src/handlers/cron.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Cron task handlers.
|
||||
*
|
||||
* - cleanup: Delete submissions past their retention period
|
||||
* - digest: Send daily digest emails for forms with digest enabled
|
||||
*/
|
||||
|
||||
import type { PluginContext, StorageCollection } from "emdash";
|
||||
|
||||
import { formatDigestText } from "../format.js";
|
||||
import type { FormDefinition, Submission } from "../types.js";
|
||||
|
||||
/** Typed access to plugin storage collections */
|
||||
function forms(ctx: PluginContext): StorageCollection<FormDefinition> {
|
||||
return ctx.storage.forms as StorageCollection<FormDefinition>;
|
||||
}
|
||||
|
||||
function submissions(ctx: PluginContext): StorageCollection<Submission> {
|
||||
return ctx.storage.submissions as StorageCollection<Submission>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Weekly cleanup: delete submissions past retention period.
|
||||
*/
|
||||
export async function handleCleanup(ctx: PluginContext) {
|
||||
let formsCursor: string | undefined;
|
||||
|
||||
do {
|
||||
const formsBatch = await forms(ctx).query({ limit: 100, cursor: formsCursor });
|
||||
|
||||
for (const formItem of formsBatch.items) {
|
||||
const form = formItem.data;
|
||||
if (form.settings.retentionDays === 0) continue;
|
||||
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - form.settings.retentionDays);
|
||||
const cutoffStr = cutoff.toISOString();
|
||||
|
||||
let cursor: string | undefined;
|
||||
let deletedCount = 0;
|
||||
|
||||
do {
|
||||
const batch = await submissions(ctx).query({
|
||||
where: {
|
||||
formId: formItem.id,
|
||||
createdAt: { lt: cutoffStr },
|
||||
},
|
||||
limit: 100,
|
||||
cursor,
|
||||
});
|
||||
|
||||
// Delete media files
|
||||
if (ctx.media && "delete" in ctx.media) {
|
||||
const mediaWithDelete = ctx.media as { delete(id: string): Promise<boolean> };
|
||||
for (const item of batch.items) {
|
||||
if (item.data.files) {
|
||||
for (const file of item.data.files) {
|
||||
await mediaWithDelete.delete(file.mediaId).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ids = batch.items.map((item) => item.id);
|
||||
if (ids.length > 0) {
|
||||
await submissions(ctx).deleteMany(ids);
|
||||
deletedCount += ids.length;
|
||||
}
|
||||
|
||||
cursor = batch.cursor;
|
||||
} while (cursor);
|
||||
|
||||
// Update form counter
|
||||
if (deletedCount > 0) {
|
||||
const count = await submissions(ctx).count({ formId: formItem.id });
|
||||
await forms(ctx).put(formItem.id, {
|
||||
...form,
|
||||
submissionCount: count,
|
||||
});
|
||||
|
||||
ctx.log.info("Cleaned up expired submissions", {
|
||||
formId: formItem.id,
|
||||
formName: form.name,
|
||||
deleted: deletedCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
formsCursor = formsBatch.cursor;
|
||||
} while (formsCursor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily digest: send summary email for a specific form.
|
||||
*
|
||||
* The cron task name contains the form ID: "digest:{formId}"
|
||||
*/
|
||||
export async function handleDigest(formId: string, ctx: PluginContext) {
|
||||
const form = await forms(ctx).get(formId);
|
||||
if (!form) {
|
||||
ctx.log.warn("Digest: form not found, cancelling", { formId });
|
||||
if (ctx.cron) {
|
||||
await ctx.cron.cancel(`digest:${formId}`).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!form.settings.digestEnabled || form.settings.notifyEmails.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ctx.email) {
|
||||
ctx.log.warn("Digest: email not configured", { formId });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get submissions since last 24 hours
|
||||
const since = new Date();
|
||||
since.setDate(since.getDate() - 1);
|
||||
|
||||
const recent = await submissions(ctx).query({
|
||||
where: {
|
||||
formId,
|
||||
createdAt: { gte: since.toISOString() },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
if (recent.items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subs = recent.items.map((item) => item.data);
|
||||
const text = formatDigestText(form, formId, subs, ctx.site.url);
|
||||
|
||||
for (const email of form.settings.notifyEmails) {
|
||||
await ctx.email
|
||||
.send({
|
||||
to: email,
|
||||
subject: `Daily digest: ${form.name} (${subs.length} new)`,
|
||||
text,
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
ctx.log.error("Failed to send digest email", {
|
||||
error: String(err),
|
||||
to: email,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
269
packages/plugins/forms/src/handlers/forms.ts
Normal file
269
packages/plugins/forms/src/handlers/forms.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Form CRUD route handlers.
|
||||
*
|
||||
* Admin-only routes for managing form definitions.
|
||||
*/
|
||||
|
||||
import type { RouteContext, StorageCollection } from "emdash";
|
||||
import { PluginRouteError } from "emdash";
|
||||
import { ulid } from "ulidx";
|
||||
|
||||
import type {
|
||||
FormCreateInput,
|
||||
FormDeleteInput,
|
||||
FormDuplicateInput,
|
||||
FormUpdateInput,
|
||||
} from "../schemas.js";
|
||||
import type { FormDefinition } from "../types.js";
|
||||
|
||||
/** Typed access to plugin storage collections */
|
||||
function forms(ctx: RouteContext): StorageCollection<FormDefinition> {
|
||||
return ctx.storage.forms as StorageCollection<FormDefinition>;
|
||||
}
|
||||
|
||||
function submissions(ctx: RouteContext): StorageCollection {
|
||||
return ctx.storage.submissions as StorageCollection;
|
||||
}
|
||||
|
||||
// ─── List Forms ──────────────────────────────────────────────────
|
||||
|
||||
export async function formsListHandler(ctx: RouteContext) {
|
||||
const result = await forms(ctx).query({
|
||||
orderBy: { createdAt: "desc" },
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
return {
|
||||
items: result.items.map((item) => ({ id: item.id, ...item.data })),
|
||||
hasMore: result.hasMore,
|
||||
cursor: result.cursor,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Create Form ─────────────────────────────────────────────────
|
||||
|
||||
export async function formsCreateHandler(ctx: RouteContext<FormCreateInput>) {
|
||||
const input = ctx.input;
|
||||
|
||||
// Check slug uniqueness
|
||||
const existing = await forms(ctx).query({
|
||||
where: { slug: input.slug },
|
||||
limit: 1,
|
||||
});
|
||||
if (existing.items.length > 0) {
|
||||
throw PluginRouteError.conflict(`A form with slug "${input.slug}" already exists`);
|
||||
}
|
||||
|
||||
// Validate field names are unique across all pages
|
||||
validateFieldNames(input.pages);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const id = ulid();
|
||||
const form: FormDefinition = {
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
pages: input.pages,
|
||||
settings: {
|
||||
confirmationMessage: input.settings.confirmationMessage ?? "Thank you for your submission.",
|
||||
redirectUrl: input.settings.redirectUrl || undefined,
|
||||
notifyEmails: input.settings.notifyEmails ?? [],
|
||||
digestEnabled: input.settings.digestEnabled ?? false,
|
||||
digestHour: input.settings.digestHour ?? 9,
|
||||
autoresponder: input.settings.autoresponder,
|
||||
webhookUrl: input.settings.webhookUrl || undefined,
|
||||
retentionDays: input.settings.retentionDays ?? 0,
|
||||
spamProtection: input.settings.spamProtection ?? "honeypot",
|
||||
submitLabel: input.settings.submitLabel ?? "Submit",
|
||||
nextLabel: input.settings.nextLabel,
|
||||
prevLabel: input.settings.prevLabel,
|
||||
},
|
||||
status: "active",
|
||||
submissionCount: 0,
|
||||
lastSubmissionAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await forms(ctx).put(id, form);
|
||||
|
||||
// Schedule digest cron if enabled
|
||||
if (form.settings.digestEnabled && ctx.cron) {
|
||||
await ctx.cron.schedule(`digest:${id}`, {
|
||||
schedule: `0 ${form.settings.digestHour} * * *`,
|
||||
});
|
||||
}
|
||||
|
||||
return { id, ...form };
|
||||
}
|
||||
|
||||
// ─── Update Form ─────────────────────────────────────────────────
|
||||
|
||||
export async function formsUpdateHandler(ctx: RouteContext<FormUpdateInput>) {
|
||||
const input = ctx.input;
|
||||
|
||||
const existing = await forms(ctx).get(input.id);
|
||||
if (!existing) {
|
||||
throw PluginRouteError.notFound("Form not found");
|
||||
}
|
||||
|
||||
// Check slug uniqueness if changing
|
||||
if (input.slug && input.slug !== existing.slug) {
|
||||
const slugCheck = await forms(ctx).query({
|
||||
where: { slug: input.slug },
|
||||
limit: 1,
|
||||
});
|
||||
if (slugCheck.items.length > 0) {
|
||||
throw PluginRouteError.conflict(`A form with slug "${input.slug}" already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
if (input.pages) {
|
||||
validateFieldNames(input.pages);
|
||||
}
|
||||
|
||||
const updated: FormDefinition = {
|
||||
...existing,
|
||||
name: input.name ?? existing.name,
|
||||
slug: input.slug ?? existing.slug,
|
||||
pages: input.pages ?? existing.pages,
|
||||
settings: input.settings ? { ...existing.settings, ...input.settings } : existing.settings,
|
||||
status: input.status ?? existing.status,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Clean up empty strings
|
||||
if (updated.settings.redirectUrl === "") updated.settings.redirectUrl = undefined;
|
||||
if (updated.settings.webhookUrl === "") updated.settings.webhookUrl = undefined;
|
||||
|
||||
await forms(ctx).put(input.id, updated);
|
||||
|
||||
// Update digest cron if settings changed
|
||||
if (ctx.cron) {
|
||||
if (updated.settings.digestEnabled && !existing.settings.digestEnabled) {
|
||||
await ctx.cron.schedule(`digest:${input.id}`, {
|
||||
schedule: `0 ${updated.settings.digestHour} * * *`,
|
||||
});
|
||||
} else if (!updated.settings.digestEnabled && existing.settings.digestEnabled) {
|
||||
await ctx.cron.cancel(`digest:${input.id}`);
|
||||
} else if (
|
||||
updated.settings.digestEnabled &&
|
||||
updated.settings.digestHour !== existing.settings.digestHour
|
||||
) {
|
||||
await ctx.cron.schedule(`digest:${input.id}`, {
|
||||
schedule: `0 ${updated.settings.digestHour} * * *`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { id: input.id, ...updated };
|
||||
}
|
||||
|
||||
// ─── Delete Form ─────────────────────────────────────────────────
|
||||
|
||||
export async function formsDeleteHandler(ctx: RouteContext<FormDeleteInput>) {
|
||||
const input = ctx.input;
|
||||
|
||||
const existing = await forms(ctx).get(input.id);
|
||||
if (!existing) {
|
||||
throw PluginRouteError.notFound("Form not found");
|
||||
}
|
||||
|
||||
// Delete associated submissions if requested
|
||||
if (input.deleteSubmissions) {
|
||||
await deleteFormSubmissions(input.id, ctx);
|
||||
}
|
||||
|
||||
// Cancel digest cron
|
||||
if (ctx.cron) {
|
||||
await ctx.cron.cancel(`digest:${input.id}`).catch(() => {});
|
||||
}
|
||||
|
||||
await forms(ctx).delete(input.id);
|
||||
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
// ─── Duplicate Form ──────────────────────────────────────────────
|
||||
|
||||
export async function formsDuplicateHandler(ctx: RouteContext<FormDuplicateInput>) {
|
||||
const input = ctx.input;
|
||||
|
||||
const existing = await forms(ctx).get(input.id);
|
||||
if (!existing) {
|
||||
throw PluginRouteError.notFound("Form not found");
|
||||
}
|
||||
|
||||
const newSlug = input.slug ?? `${existing.slug}-copy`;
|
||||
const newName = input.name ?? `${existing.name} (Copy)`;
|
||||
|
||||
// Check slug uniqueness
|
||||
const slugCheck = await forms(ctx).query({
|
||||
where: { slug: newSlug },
|
||||
limit: 1,
|
||||
});
|
||||
if (slugCheck.items.length > 0) {
|
||||
throw PluginRouteError.conflict(`A form with slug "${newSlug}" already exists`);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const id = ulid();
|
||||
const duplicate: FormDefinition = {
|
||||
...existing,
|
||||
name: newName,
|
||||
slug: newSlug,
|
||||
submissionCount: 0,
|
||||
lastSubmissionAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await forms(ctx).put(id, duplicate);
|
||||
|
||||
return { id, ...duplicate };
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
function validateFieldNames(pages: Array<{ fields: Array<{ name: string }> }>) {
|
||||
const names = new Set<string>();
|
||||
for (const page of pages) {
|
||||
for (const field of page.fields) {
|
||||
if (names.has(field.name)) {
|
||||
throw PluginRouteError.badRequest(`Duplicate field name "${field.name}" across form pages`);
|
||||
}
|
||||
names.add(field.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete all submissions for a form, including media files */
|
||||
async function deleteFormSubmissions(formId: string, ctx: RouteContext) {
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const batch = await submissions(ctx).query({
|
||||
where: { formId },
|
||||
limit: 100,
|
||||
cursor,
|
||||
});
|
||||
|
||||
// Delete associated media files
|
||||
if (ctx.media && "delete" in ctx.media) {
|
||||
const mediaWithDelete = ctx.media as { delete(id: string): Promise<boolean> };
|
||||
for (const item of batch.items) {
|
||||
const sub = item.data as { files?: Array<{ mediaId: string }> };
|
||||
if (sub.files) {
|
||||
for (const file of sub.files) {
|
||||
await mediaWithDelete.delete(file.mediaId).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ids = batch.items.map((item) => item.id);
|
||||
if (ids.length > 0) {
|
||||
await submissions(ctx).deleteMany(ids);
|
||||
}
|
||||
|
||||
cursor = batch.cursor;
|
||||
} while (cursor);
|
||||
}
|
||||
191
packages/plugins/forms/src/handlers/submissions.ts
Normal file
191
packages/plugins/forms/src/handlers/submissions.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Submission management route handlers.
|
||||
*
|
||||
* Admin-only routes for viewing, updating, exporting, and deleting submissions.
|
||||
*/
|
||||
|
||||
import type { RouteContext, StorageCollection } from "emdash";
|
||||
import { PluginRouteError } from "emdash";
|
||||
|
||||
import { formatCsv } from "../format.js";
|
||||
import type {
|
||||
ExportInput,
|
||||
SubmissionDeleteInput,
|
||||
SubmissionGetInput,
|
||||
SubmissionsListInput,
|
||||
SubmissionUpdateInput,
|
||||
} from "../schemas.js";
|
||||
import type { FormDefinition, Submission } from "../types.js";
|
||||
|
||||
/** Typed access to plugin storage collections */
|
||||
function forms(ctx: RouteContext): StorageCollection<FormDefinition> {
|
||||
return ctx.storage.forms as StorageCollection<FormDefinition>;
|
||||
}
|
||||
|
||||
function submissions(ctx: RouteContext): StorageCollection<Submission> {
|
||||
return ctx.storage.submissions as StorageCollection<Submission>;
|
||||
}
|
||||
|
||||
// ─── List Submissions ────────────────────────────────────────────
|
||||
|
||||
export async function submissionsListHandler(ctx: RouteContext<SubmissionsListInput>) {
|
||||
const input = ctx.input;
|
||||
|
||||
const result = await submissions(ctx).query({
|
||||
where: {
|
||||
formId: input.formId,
|
||||
...(input.status ? { status: input.status } : {}),
|
||||
...(input.starred !== undefined ? { starred: input.starred } : {}),
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
limit: input.limit,
|
||||
cursor: input.cursor,
|
||||
});
|
||||
|
||||
return {
|
||||
items: result.items.map((item) => ({ id: item.id, ...item.data })),
|
||||
hasMore: result.hasMore,
|
||||
cursor: result.cursor,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Get Single Submission ───────────────────────────────────────
|
||||
|
||||
export async function submissionGetHandler(ctx: RouteContext<SubmissionGetInput>) {
|
||||
const sub = await submissions(ctx).get(ctx.input.id);
|
||||
if (!sub) {
|
||||
throw PluginRouteError.notFound("Submission not found");
|
||||
}
|
||||
|
||||
return { id: ctx.input.id, ...sub };
|
||||
}
|
||||
|
||||
// ─── Update Submission ───────────────────────────────────────────
|
||||
|
||||
export async function submissionUpdateHandler(ctx: RouteContext<SubmissionUpdateInput>) {
|
||||
const input = ctx.input;
|
||||
|
||||
const existing = await submissions(ctx).get(input.id);
|
||||
if (!existing) {
|
||||
throw PluginRouteError.notFound("Submission not found");
|
||||
}
|
||||
|
||||
const updated: Submission = {
|
||||
...existing,
|
||||
status: input.status ?? existing.status,
|
||||
starred: input.starred ?? existing.starred,
|
||||
notes: input.notes !== undefined ? input.notes : existing.notes,
|
||||
};
|
||||
|
||||
await submissions(ctx).put(input.id, updated);
|
||||
|
||||
return { id: input.id, ...updated };
|
||||
}
|
||||
|
||||
// ─── Delete Submission ───────────────────────────────────────────
|
||||
|
||||
export async function submissionDeleteHandler(ctx: RouteContext<SubmissionDeleteInput>) {
|
||||
const input = ctx.input;
|
||||
|
||||
const existing = await submissions(ctx).get(input.id);
|
||||
if (!existing) {
|
||||
throw PluginRouteError.notFound("Submission not found");
|
||||
}
|
||||
|
||||
// Delete associated media files
|
||||
if (existing.files && ctx.media && "delete" in ctx.media) {
|
||||
const mediaWithDelete = ctx.media as { delete(id: string): Promise<boolean> };
|
||||
for (const file of existing.files) {
|
||||
await mediaWithDelete.delete(file.mediaId).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
await submissions(ctx).delete(input.id);
|
||||
|
||||
// Update form counter using count() to avoid race conditions
|
||||
if (existing.formId) {
|
||||
const form = await forms(ctx).get(existing.formId);
|
||||
if (form) {
|
||||
const count = await submissions(ctx).count({ formId: existing.formId });
|
||||
await forms(ctx).put(existing.formId, {
|
||||
...form,
|
||||
submissionCount: count,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
// <20><>── Export Submissions ──────────────────────────────────────────
|
||||
|
||||
export async function exportHandler(ctx: RouteContext<ExportInput>) {
|
||||
const input = ctx.input;
|
||||
|
||||
// Load form definition
|
||||
let form: FormDefinition | null = null;
|
||||
const byId = await forms(ctx).get(input.formId);
|
||||
if (byId) {
|
||||
form = byId;
|
||||
} else {
|
||||
const bySlug = await forms(ctx).query({
|
||||
where: { slug: input.formId },
|
||||
limit: 1,
|
||||
});
|
||||
if (bySlug.items.length > 0) {
|
||||
form = bySlug.items[0]!.data;
|
||||
}
|
||||
}
|
||||
|
||||
if (!form) {
|
||||
throw PluginRouteError.notFound("Form not found");
|
||||
}
|
||||
|
||||
// Build where clause
|
||||
const where: Record<string, string | number | boolean | null | Record<string, string>> = {
|
||||
formId: input.formId,
|
||||
};
|
||||
if (input.status) where.status = input.status;
|
||||
if (input.from || input.to) {
|
||||
const range: Record<string, string> = {};
|
||||
if (input.from) range.gte = input.from;
|
||||
if (input.to) range.lte = input.to;
|
||||
where.createdAt = range;
|
||||
}
|
||||
|
||||
// Collect all submissions (paginate through)
|
||||
const allItems: Array<{ id: string; data: Submission }> = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
do {
|
||||
const batch = await submissions(ctx).query({
|
||||
where: where as Record<string, string | number | boolean | null>,
|
||||
orderBy: { createdAt: "desc" },
|
||||
limit: 100,
|
||||
cursor,
|
||||
});
|
||||
|
||||
for (const item of batch.items) {
|
||||
allItems.push(item);
|
||||
}
|
||||
|
||||
cursor = batch.cursor;
|
||||
} while (cursor);
|
||||
|
||||
if (input.format === "json") {
|
||||
return {
|
||||
data: allItems.map((item) => item.data),
|
||||
count: allItems.length,
|
||||
contentType: "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
// CSV
|
||||
const csv = formatCsv(form, allItems);
|
||||
return {
|
||||
data: csv,
|
||||
count: allItems.length,
|
||||
contentType: "text/csv",
|
||||
filename: `${form.slug}-submissions-${new Date().toISOString().split("T")[0]}.csv`,
|
||||
};
|
||||
}
|
||||
297
packages/plugins/forms/src/handlers/submit.ts
Normal file
297
packages/plugins/forms/src/handlers/submit.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* Public form submission handler.
|
||||
*
|
||||
* This is the main entry point for form submissions from anonymous visitors.
|
||||
* Handles spam protection, validation, file uploads, notifications, and webhooks.
|
||||
*/
|
||||
|
||||
import type { RouteContext, StorageCollection } from "emdash";
|
||||
import { PluginRouteError } from "emdash";
|
||||
import { ulid } from "ulidx";
|
||||
|
||||
import { formatSubmissionText, formatWebhookPayload } from "../format.js";
|
||||
import type { SubmitInput } from "../schemas.js";
|
||||
import { verifyTurnstile } from "../turnstile.js";
|
||||
import type { FormDefinition, Submission, SubmissionFile } from "../types.js";
|
||||
import { getFormFields } from "../types.js";
|
||||
import { validateSubmission } from "../validation.js";
|
||||
|
||||
/** Typed access to plugin storage collections */
|
||||
function forms(ctx: RouteContext): StorageCollection<FormDefinition> {
|
||||
return ctx.storage.forms as StorageCollection<FormDefinition>;
|
||||
}
|
||||
|
||||
function submissions(ctx: RouteContext): StorageCollection<Submission> {
|
||||
return ctx.storage.submissions as StorageCollection<Submission>;
|
||||
}
|
||||
|
||||
export async function submitHandler(ctx: RouteContext<SubmitInput>) {
|
||||
const input = ctx.input;
|
||||
|
||||
// 1. Load form definition (by ID first, then by slug)
|
||||
let formId = input.formId;
|
||||
let form = await forms(ctx).get(formId);
|
||||
if (!form) {
|
||||
const bySlug = await forms(ctx).query({
|
||||
where: { slug: input.formId },
|
||||
limit: 1,
|
||||
});
|
||||
if (bySlug.items.length > 0) {
|
||||
formId = bySlug.items[0]!.id;
|
||||
form = bySlug.items[0]!.data;
|
||||
}
|
||||
}
|
||||
if (!form) {
|
||||
throw PluginRouteError.notFound("Form not found");
|
||||
}
|
||||
|
||||
if (form.status === "paused") {
|
||||
throw new PluginRouteError(
|
||||
"FORM_PAUSED",
|
||||
"This form is not currently accepting submissions",
|
||||
410,
|
||||
);
|
||||
}
|
||||
|
||||
const settings = form.settings;
|
||||
|
||||
// 2. Spam protection
|
||||
if (settings.spamProtection === "turnstile") {
|
||||
const token = input.data["cf-turnstile-response"];
|
||||
if (typeof token !== "string" || !token) {
|
||||
throw PluginRouteError.forbidden("Spam verification required");
|
||||
}
|
||||
|
||||
const secretKey = await ctx.kv.get<string>("settings:turnstileSecretKey");
|
||||
if (!secretKey || !ctx.http) {
|
||||
throw PluginRouteError.internal("Turnstile is not configured");
|
||||
}
|
||||
|
||||
const result = await verifyTurnstile(
|
||||
token,
|
||||
secretKey,
|
||||
ctx.http.fetch.bind(ctx.http),
|
||||
ctx.requestMeta.ip,
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
ctx.log.warn("Turnstile verification failed", {
|
||||
errorCodes: result.errorCodes,
|
||||
});
|
||||
throw PluginRouteError.forbidden("Spam verification failed. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.spamProtection === "honeypot") {
|
||||
if (input.data._hp) {
|
||||
// Honeypot triggered — return success silently
|
||||
return {
|
||||
success: true,
|
||||
message: settings.confirmationMessage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Validate submission data
|
||||
const allFields = getFormFields(form);
|
||||
const result = validateSubmission(allFields, input.data);
|
||||
|
||||
if (!result.valid) {
|
||||
throw PluginRouteError.badRequest("Validation failed", { errors: result.errors });
|
||||
}
|
||||
|
||||
// 4. Upload files
|
||||
const files: SubmissionFile[] = [];
|
||||
if (input.files && ctx.media && "upload" in ctx.media) {
|
||||
const mediaWithWrite = ctx.media as {
|
||||
upload(
|
||||
filename: string,
|
||||
contentType: string,
|
||||
bytes: ArrayBuffer,
|
||||
): Promise<{ mediaId: string; storageKey: string; url: string }>;
|
||||
};
|
||||
|
||||
for (const field of allFields.filter((f) => f.type === "file")) {
|
||||
const fileData = input.files[field.name];
|
||||
if (!fileData) continue;
|
||||
|
||||
// Validate file type
|
||||
if (field.validation?.accept) {
|
||||
const allowed = field.validation.accept.split(",").map((s) => s.trim().toLowerCase());
|
||||
const ext = `.${fileData.filename.split(".").pop()?.toLowerCase()}`;
|
||||
const typeMatch = allowed.some(
|
||||
(a) =>
|
||||
a === ext ||
|
||||
a === fileData.contentType ||
|
||||
fileData.contentType.startsWith(a.replace("/*", "/")),
|
||||
);
|
||||
if (!typeMatch) {
|
||||
throw PluginRouteError.badRequest(`File type not allowed for ${field.label}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (
|
||||
field.validation?.maxFileSize &&
|
||||
fileData.bytes.byteLength > field.validation.maxFileSize
|
||||
) {
|
||||
throw PluginRouteError.badRequest(
|
||||
`File too large for ${field.label}. Maximum: ${Math.round(field.validation.maxFileSize / 1024)} KB`,
|
||||
);
|
||||
}
|
||||
|
||||
const uploaded = await mediaWithWrite.upload(
|
||||
fileData.filename,
|
||||
fileData.contentType,
|
||||
fileData.bytes,
|
||||
);
|
||||
|
||||
files.push({
|
||||
fieldName: field.name,
|
||||
filename: fileData.filename,
|
||||
contentType: fileData.contentType,
|
||||
size: fileData.bytes.byteLength,
|
||||
mediaId: uploaded.mediaId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Store submission
|
||||
const submissionId = ulid();
|
||||
const submission: Submission = {
|
||||
formId,
|
||||
data: result.data,
|
||||
files: files.length > 0 ? files : undefined,
|
||||
status: "new",
|
||||
starred: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
meta: {
|
||||
ip: ctx.requestMeta.ip,
|
||||
userAgent: ctx.requestMeta.userAgent,
|
||||
referer: ctx.requestMeta.referer,
|
||||
country: ctx.requestMeta.geo?.country ?? null,
|
||||
},
|
||||
};
|
||||
|
||||
await submissions(ctx).put(submissionId, submission);
|
||||
|
||||
// 6. Update form counters (use count() to avoid race conditions
|
||||
// from concurrent submissions doing read-modify-write)
|
||||
const submissionCount = await submissions(ctx).count({ formId });
|
||||
await forms(ctx).put(formId, {
|
||||
...form,
|
||||
submissionCount,
|
||||
lastSubmissionAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 7. Immediate email notifications (not digest)
|
||||
if (settings.notifyEmails.length > 0 && !settings.digestEnabled && ctx.email) {
|
||||
const text = formatSubmissionText(form, result.data, files);
|
||||
for (const email of settings.notifyEmails) {
|
||||
await ctx.email
|
||||
.send({
|
||||
to: email,
|
||||
subject: `New submission: ${form.name}`,
|
||||
text,
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
ctx.log.error("Failed to send notification email", {
|
||||
error: String(err),
|
||||
to: email,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Autoresponder
|
||||
if (settings.autoresponder && ctx.email) {
|
||||
const emailField = allFields.find((f) => f.type === "email");
|
||||
const submitterEmail = emailField ? result.data[emailField.name] : null;
|
||||
if (typeof submitterEmail === "string" && submitterEmail) {
|
||||
await ctx.email
|
||||
.send({
|
||||
to: submitterEmail,
|
||||
subject: settings.autoresponder.subject,
|
||||
text: settings.autoresponder.body,
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
ctx.log.error("Failed to send autoresponder", { error: String(err) });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Webhook (fire and forget)
|
||||
if (settings.webhookUrl && ctx.http) {
|
||||
const payload = formatWebhookPayload(form, submissionId, result.data, files);
|
||||
ctx.http
|
||||
.fetch(settings.webhookUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
ctx.log.error("Webhook failed", {
|
||||
error: String(err),
|
||||
url: settings.webhookUrl,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 10. Return success
|
||||
return {
|
||||
success: true,
|
||||
message: settings.confirmationMessage,
|
||||
redirect: settings.redirectUrl,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Public Form Definition Endpoint ─────────────────────────────
|
||||
|
||||
export async function definitionHandler(
|
||||
ctx: RouteContext<import("../schemas.js").DefinitionInput>,
|
||||
) {
|
||||
const { id } = ctx.input;
|
||||
|
||||
// Look up by ID first, then by slug
|
||||
let form = await forms(ctx).get(id);
|
||||
|
||||
if (!form) {
|
||||
const bySlug = await forms(ctx).query({
|
||||
where: { slug: id },
|
||||
limit: 1,
|
||||
});
|
||||
if (bySlug.items.length > 0) {
|
||||
form = bySlug.items[0]!.data;
|
||||
}
|
||||
}
|
||||
|
||||
if (!form) {
|
||||
throw PluginRouteError.notFound("Form not found");
|
||||
}
|
||||
|
||||
if (form.status !== "active") {
|
||||
throw new PluginRouteError("FORM_PAUSED", "This form is not currently available", 410);
|
||||
}
|
||||
|
||||
// Include Turnstile site key if configured
|
||||
const turnstileSiteKey =
|
||||
form.settings.spamProtection === "turnstile"
|
||||
? await ctx.kv.get<string>("settings:turnstileSiteKey")
|
||||
: null;
|
||||
|
||||
// Return only the settings needed for client rendering — never expose
|
||||
// admin emails, webhook URLs, or other internal configuration.
|
||||
return {
|
||||
name: form.name,
|
||||
slug: form.slug,
|
||||
pages: form.pages,
|
||||
settings: {
|
||||
spamProtection: form.settings.spamProtection,
|
||||
submitLabel: form.settings.submitLabel,
|
||||
nextLabel: form.settings.nextLabel,
|
||||
prevLabel: form.settings.prevLabel,
|
||||
},
|
||||
status: form.status,
|
||||
_turnstileSiteKey: turnstileSiteKey,
|
||||
};
|
||||
}
|
||||
230
packages/plugins/forms/src/index.ts
Normal file
230
packages/plugins/forms/src/index.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Forms Plugin for EmDash CMS
|
||||
*
|
||||
* Build forms in the admin, embed them in content via Portable Text,
|
||||
* accept submissions from anonymous visitors, send notifications, export data.
|
||||
*
|
||||
* This is a trusted plugin shipped as an npm package. It uses the standard
|
||||
* plugin APIs — nothing privileged.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // live.config.ts
|
||||
* import { formsPlugin } from "@emdashcms/plugin-forms";
|
||||
*
|
||||
* export default defineConfig({
|
||||
* plugins: [formsPlugin()],
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type { PluginDescriptor, ResolvedPlugin } from "emdash";
|
||||
import { definePlugin } from "emdash";
|
||||
|
||||
import { handleCleanup, handleDigest } from "./handlers/cron.js";
|
||||
import {
|
||||
formsCreateHandler,
|
||||
formsDeleteHandler,
|
||||
formsDuplicateHandler,
|
||||
formsListHandler,
|
||||
formsUpdateHandler,
|
||||
} from "./handlers/forms.js";
|
||||
import {
|
||||
exportHandler,
|
||||
submissionDeleteHandler,
|
||||
submissionGetHandler,
|
||||
submissionsListHandler,
|
||||
submissionUpdateHandler,
|
||||
} from "./handlers/submissions.js";
|
||||
import { definitionHandler, submitHandler } from "./handlers/submit.js";
|
||||
import {
|
||||
definitionSchema,
|
||||
exportSchema,
|
||||
formCreateSchema,
|
||||
formDeleteSchema,
|
||||
formDuplicateSchema,
|
||||
formUpdateSchema,
|
||||
submissionDeleteSchema,
|
||||
submissionGetSchema,
|
||||
submissionsListSchema,
|
||||
submitSchema,
|
||||
submissionUpdateSchema,
|
||||
} from "./schemas.js";
|
||||
import { FORMS_STORAGE_CONFIG } from "./storage.js";
|
||||
|
||||
// ─── Plugin Options ──────────────────────────────────────────────
|
||||
|
||||
export interface FormsPluginOptions {
|
||||
/** Default spam protection for new forms */
|
||||
defaultSpamProtection?: "none" | "honeypot" | "turnstile";
|
||||
}
|
||||
|
||||
// ─── Plugin Descriptor (for live.config.ts) ──────────────────────
|
||||
|
||||
export function formsPlugin(
|
||||
options: FormsPluginOptions = {},
|
||||
): PluginDescriptor<FormsPluginOptions> {
|
||||
return {
|
||||
id: "emdash-forms",
|
||||
version: "0.0.1",
|
||||
entrypoint: "@emdashcms/plugin-forms",
|
||||
adminEntry: "@emdashcms/plugin-forms/admin",
|
||||
componentsEntry: "@emdashcms/plugin-forms/astro",
|
||||
options,
|
||||
capabilities: ["email:send", "write:media", "network:fetch"],
|
||||
allowedHosts: ["*"],
|
||||
adminPages: [
|
||||
{ path: "/", label: "Forms", icon: "list" },
|
||||
{ path: "/submissions", label: "Submissions", icon: "inbox" },
|
||||
],
|
||||
adminWidgets: [{ id: "recent-submissions", title: "Recent Submissions", size: "half" }],
|
||||
// Descriptor uses flat indexes only; composite indexes are in definePlugin
|
||||
storage: {
|
||||
forms: { indexes: ["status", "createdAt"], uniqueIndexes: ["slug"] },
|
||||
submissions: { indexes: ["formId", "status", "starred", "createdAt"] },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Plugin Implementation ───────────────────────────────────────
|
||||
|
||||
export function createPlugin(_options: FormsPluginOptions = {}): ResolvedPlugin {
|
||||
return definePlugin({
|
||||
id: "emdash-forms",
|
||||
version: "0.0.1",
|
||||
capabilities: ["email:send", "write:media", "network:fetch"],
|
||||
allowedHosts: ["*"],
|
||||
|
||||
storage: FORMS_STORAGE_CONFIG,
|
||||
|
||||
hooks: {
|
||||
"plugin:activate": {
|
||||
handler: async (_event, ctx) => {
|
||||
// Schedule weekly cleanup for expired submissions
|
||||
if (ctx.cron) {
|
||||
await ctx.cron.schedule("cleanup", { schedule: "@weekly" });
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
cron: {
|
||||
handler: async (event, ctx) => {
|
||||
if (event.name === "cleanup") {
|
||||
await handleCleanup(ctx);
|
||||
} else if (event.name.startsWith("digest:")) {
|
||||
const formId = event.name.slice("digest:".length);
|
||||
await handleDigest(formId, ctx);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Route handlers are typed with specific input schemas but the route record
|
||||
// erases the generic to `unknown`. The cast is safe because the input schema
|
||||
// guarantees the runtime shape matches the handler's expected type.
|
||||
routes: {
|
||||
// --- Public routes ---
|
||||
|
||||
submit: {
|
||||
public: true,
|
||||
input: submitSchema,
|
||||
handler: submitHandler as never,
|
||||
},
|
||||
|
||||
definition: {
|
||||
public: true,
|
||||
input: definitionSchema,
|
||||
handler: definitionHandler as never,
|
||||
},
|
||||
|
||||
// --- Admin routes (require auth) ---
|
||||
|
||||
"forms/list": {
|
||||
handler: formsListHandler,
|
||||
},
|
||||
"forms/create": {
|
||||
input: formCreateSchema,
|
||||
handler: formsCreateHandler as never,
|
||||
},
|
||||
"forms/update": {
|
||||
input: formUpdateSchema,
|
||||
handler: formsUpdateHandler as never,
|
||||
},
|
||||
"forms/delete": {
|
||||
input: formDeleteSchema,
|
||||
handler: formsDeleteHandler as never,
|
||||
},
|
||||
"forms/duplicate": {
|
||||
input: formDuplicateSchema,
|
||||
handler: formsDuplicateHandler as never,
|
||||
},
|
||||
|
||||
"submissions/list": {
|
||||
input: submissionsListSchema,
|
||||
handler: submissionsListHandler as never,
|
||||
},
|
||||
"submissions/get": {
|
||||
input: submissionGetSchema,
|
||||
handler: submissionGetHandler as never,
|
||||
},
|
||||
"submissions/update": {
|
||||
input: submissionUpdateSchema,
|
||||
handler: submissionUpdateHandler as never,
|
||||
},
|
||||
"submissions/delete": {
|
||||
input: submissionDeleteSchema,
|
||||
handler: submissionDeleteHandler as never,
|
||||
},
|
||||
"submissions/export": {
|
||||
input: exportSchema,
|
||||
handler: exportHandler as never,
|
||||
},
|
||||
|
||||
"settings/turnstile-status": {
|
||||
handler: async (ctx) => {
|
||||
const siteKey = await ctx.kv.get<string>("settings:turnstileSiteKey");
|
||||
const secretKey = await ctx.kv.get<string>("settings:turnstileSecretKey");
|
||||
return {
|
||||
hasSiteKey: !!siteKey,
|
||||
hasSecretKey: !!secretKey,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
admin: {
|
||||
settingsSchema: {
|
||||
turnstileSiteKey: { type: "string", label: "Turnstile Site Key" },
|
||||
turnstileSecretKey: { type: "secret", label: "Turnstile Secret Key" },
|
||||
},
|
||||
pages: [
|
||||
{ path: "/", label: "Forms", icon: "list" },
|
||||
{ path: "/submissions", label: "Submissions", icon: "inbox" },
|
||||
],
|
||||
widgets: [{ id: "recent-submissions", title: "Recent Submissions", size: "half" }],
|
||||
portableTextBlocks: [
|
||||
{
|
||||
type: "emdash-form",
|
||||
label: "Form",
|
||||
icon: "form",
|
||||
description: "Embed a form",
|
||||
fields: [
|
||||
{
|
||||
type: "select",
|
||||
action_id: "formId",
|
||||
label: "Form",
|
||||
options: [],
|
||||
optionsRoute: "forms/list",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default createPlugin;
|
||||
|
||||
// Re-export types for consumers
|
||||
export type * from "./types.js";
|
||||
export type { FormsStorage } from "./storage.js";
|
||||
215
packages/plugins/forms/src/schemas.ts
Normal file
215
packages/plugins/forms/src/schemas.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Zod schemas for route input validation.
|
||||
*/
|
||||
|
||||
import { z } from "astro/zod";
|
||||
|
||||
/** Matches http(s) scheme at start of URL */
|
||||
const HTTP_SCHEME_RE = /^https?:\/\//i;
|
||||
|
||||
/** Validates that a URL string uses http or https scheme. Rejects javascript:/data: URI XSS vectors. */
|
||||
const httpUrl = z
|
||||
.string()
|
||||
.url()
|
||||
.refine((url) => HTTP_SCHEME_RE.test(url), "URL must use http or https");
|
||||
|
||||
// ─── Field Schemas ───────────────────────────────────────────────
|
||||
|
||||
const fieldOptionSchema = z.object({
|
||||
label: z.string().min(1),
|
||||
value: z.string().min(1),
|
||||
});
|
||||
|
||||
const fieldValidationSchema = z
|
||||
.object({
|
||||
minLength: z.number().int().min(0).optional(),
|
||||
maxLength: z.number().int().min(1).optional(),
|
||||
min: z.number().optional(),
|
||||
max: z.number().optional(),
|
||||
pattern: z.string().optional(),
|
||||
patternMessage: z.string().optional(),
|
||||
accept: z.string().optional(),
|
||||
maxFileSize: z.number().int().min(1).optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const fieldConditionSchema = z
|
||||
.object({
|
||||
field: z.string().min(1),
|
||||
op: z.enum(["eq", "neq", "filled", "empty"]),
|
||||
value: z.string().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const fieldTypeSchema = z.enum([
|
||||
"text",
|
||||
"email",
|
||||
"textarea",
|
||||
"number",
|
||||
"tel",
|
||||
"url",
|
||||
"date",
|
||||
"select",
|
||||
"radio",
|
||||
"checkbox",
|
||||
"checkbox-group",
|
||||
"file",
|
||||
"hidden",
|
||||
]);
|
||||
|
||||
const formFieldSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
type: fieldTypeSchema,
|
||||
label: z.string().min(1),
|
||||
name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(/^[a-zA-Z][a-zA-Z0-9_-]*$/, "Invalid field name"),
|
||||
placeholder: z.string().optional(),
|
||||
helpText: z.string().optional(),
|
||||
required: z.boolean(),
|
||||
validation: fieldValidationSchema,
|
||||
options: z.array(fieldOptionSchema).optional(),
|
||||
defaultValue: z.string().optional(),
|
||||
width: z.enum(["full", "half"]).default("full"),
|
||||
condition: fieldConditionSchema,
|
||||
});
|
||||
|
||||
const formPageSchema = z.object({
|
||||
title: z.string().optional(),
|
||||
fields: z.array(formFieldSchema).min(1, "Each page must have at least one field"),
|
||||
});
|
||||
|
||||
// ─── Settings Schema ─────────────────────────────────────────────
|
||||
|
||||
const autoresponderSchema = z
|
||||
.object({
|
||||
subject: z.string().min(1),
|
||||
body: z.string().min(1),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const formSettingsSchema = z.object({
|
||||
confirmationMessage: z.string().min(1).default("Thank you for your submission."),
|
||||
redirectUrl: httpUrl.optional().or(z.literal("")),
|
||||
notifyEmails: z.array(z.string().email()).default([]),
|
||||
digestEnabled: z.boolean().default(false),
|
||||
digestHour: z.number().int().min(0).max(23).default(9),
|
||||
autoresponder: autoresponderSchema,
|
||||
webhookUrl: httpUrl.optional().or(z.literal("")),
|
||||
retentionDays: z.number().int().min(0).default(0),
|
||||
spamProtection: z.enum(["none", "honeypot", "turnstile"]).default("honeypot"),
|
||||
submitLabel: z.string().min(1).default("Submit"),
|
||||
nextLabel: z.string().optional(),
|
||||
prevLabel: z.string().optional(),
|
||||
});
|
||||
|
||||
// ─── Form CRUD Schemas ──────────────────────────────────────────
|
||||
|
||||
export const formCreateSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
slug: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.regex(/^[a-z][a-z0-9-]*$/, "Slug must be lowercase alphanumeric with hyphens"),
|
||||
pages: z.array(formPageSchema).min(1),
|
||||
settings: formSettingsSchema,
|
||||
});
|
||||
|
||||
export const formUpdateSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
slug: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.regex(/^[a-z][a-z0-9-]*$/)
|
||||
.optional(),
|
||||
pages: z.array(formPageSchema).min(1).optional(),
|
||||
settings: formSettingsSchema.partial().optional(),
|
||||
status: z.enum(["active", "paused"]).optional(),
|
||||
});
|
||||
|
||||
export const formDeleteSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
deleteSubmissions: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export const formDuplicateSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
slug: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.regex(/^[a-z][a-z0-9-]*$/)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const definitionSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
});
|
||||
|
||||
export type DefinitionInput = z.infer<typeof definitionSchema>;
|
||||
|
||||
// ─── Submission Schemas ──────────────────────────────────────────
|
||||
|
||||
export const submitSchema = z.object({
|
||||
formId: z.string().min(1),
|
||||
data: z.record(z.string(), z.unknown()),
|
||||
files: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
filename: z.string(),
|
||||
contentType: z.string(),
|
||||
bytes: z.custom<ArrayBuffer>(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const submissionsListSchema = z.object({
|
||||
formId: z.string().min(1),
|
||||
status: z.enum(["new", "read", "archived"]).optional(),
|
||||
starred: z.boolean().optional(),
|
||||
cursor: z.string().optional(),
|
||||
limit: z.number().int().min(1).max(100).default(50),
|
||||
});
|
||||
|
||||
export const submissionGetSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
});
|
||||
|
||||
export const submissionUpdateSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
status: z.enum(["new", "read", "archived"]).optional(),
|
||||
starred: z.boolean().optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
export const submissionDeleteSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
});
|
||||
|
||||
export const exportSchema = z.object({
|
||||
formId: z.string().min(1),
|
||||
format: z.enum(["csv", "json"]).default("csv"),
|
||||
status: z.enum(["new", "read", "archived"]).optional(),
|
||||
from: z.string().datetime().optional(),
|
||||
to: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
// ─── Type Exports ────────────────────────────────────────────────
|
||||
|
||||
export type FormCreateInput = z.infer<typeof formCreateSchema>;
|
||||
export type FormUpdateInput = z.infer<typeof formUpdateSchema>;
|
||||
export type FormDeleteInput = z.infer<typeof formDeleteSchema>;
|
||||
export type FormDuplicateInput = z.infer<typeof formDuplicateSchema>;
|
||||
export type SubmitInput = z.infer<typeof submitSchema>;
|
||||
export type SubmissionsListInput = z.infer<typeof submissionsListSchema>;
|
||||
export type SubmissionGetInput = z.infer<typeof submissionGetSchema>;
|
||||
export type SubmissionUpdateInput = z.infer<typeof submissionUpdateSchema>;
|
||||
export type SubmissionDeleteInput = z.infer<typeof submissionDeleteSchema>;
|
||||
export type ExportInput = z.infer<typeof exportSchema>;
|
||||
41
packages/plugins/forms/src/storage.ts
Normal file
41
packages/plugins/forms/src/storage.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Storage type definition for the forms plugin.
|
||||
*
|
||||
* Declares the two storage collections and their indexes.
|
||||
*/
|
||||
|
||||
import type { PluginStorageConfig } from "emdash";
|
||||
|
||||
export type FormsStorage = PluginStorageConfig & {
|
||||
forms: {
|
||||
indexes: ["status", "createdAt"];
|
||||
uniqueIndexes: ["slug"];
|
||||
};
|
||||
submissions: {
|
||||
indexes: [
|
||||
"formId",
|
||||
"status",
|
||||
"starred",
|
||||
"createdAt",
|
||||
["formId", "createdAt"],
|
||||
["formId", "status"],
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
export const FORMS_STORAGE_CONFIG = {
|
||||
forms: {
|
||||
indexes: ["status", "createdAt"] as const,
|
||||
uniqueIndexes: ["slug"] as const,
|
||||
},
|
||||
submissions: {
|
||||
indexes: [
|
||||
"formId",
|
||||
"status",
|
||||
"starred",
|
||||
"createdAt",
|
||||
["formId", "createdAt"],
|
||||
["formId", "status"],
|
||||
] as const,
|
||||
},
|
||||
} satisfies PluginStorageConfig;
|
||||
200
packages/plugins/forms/src/styles/forms.css
Normal file
200
packages/plugins/forms/src/styles/forms.css
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Optional minimal styles for EmDash forms.
|
||||
*
|
||||
* Uses CSS custom properties for theming.
|
||||
* Import this stylesheet in your site to get basic form styling:
|
||||
*
|
||||
* import "@emdashcms/plugin-forms/styles";
|
||||
*/
|
||||
|
||||
.ec-form {
|
||||
--ec-form-gap: 1rem;
|
||||
--ec-form-field-border: 1px solid #d1d5db;
|
||||
--ec-form-field-radius: 6px;
|
||||
--ec-form-field-padding: 0.5rem 0.75rem;
|
||||
--ec-form-field-bg: #fff;
|
||||
--ec-form-error-color: #dc2626;
|
||||
--ec-form-required-color: #dc2626;
|
||||
--ec-form-help-color: #6b7280;
|
||||
--ec-form-submit-bg: #111827;
|
||||
--ec-form-submit-color: #fff;
|
||||
--ec-form-submit-radius: 6px;
|
||||
--ec-form-submit-padding: 0.625rem 1.25rem;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ec-form-gap);
|
||||
}
|
||||
|
||||
.ec-form-page {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--ec-form-gap);
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ec-form-page-title {
|
||||
width: 100%;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
padding: 0;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.ec-form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ec-form-field--half {
|
||||
width: calc(50% - var(--ec-form-gap) / 2);
|
||||
}
|
||||
|
||||
.ec-form-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ec-form-required {
|
||||
color: var(--ec-form-required-color);
|
||||
margin-left: 0.125rem;
|
||||
}
|
||||
|
||||
.ec-form-input,
|
||||
.ec-form select,
|
||||
.ec-form textarea {
|
||||
border: var(--ec-form-field-border);
|
||||
border-radius: var(--ec-form-field-radius);
|
||||
padding: var(--ec-form-field-padding);
|
||||
background: var(--ec-form-field-bg);
|
||||
font: inherit;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ec-form-input:focus,
|
||||
.ec-form select:focus,
|
||||
.ec-form textarea:focus {
|
||||
outline: 2px solid #2563eb;
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.ec-form textarea {
|
||||
min-height: 6rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.ec-form-help {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ec-form-help-color);
|
||||
}
|
||||
|
||||
.ec-form-error {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ec-form-error-color);
|
||||
min-height: 1em;
|
||||
}
|
||||
|
||||
.ec-form-error:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ec-form-radio-group,
|
||||
.ec-form-checkbox-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ec-form-radio-label,
|
||||
.ec-form-checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ec-form-nav {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ec-form-submit,
|
||||
.ec-form-next {
|
||||
background: var(--ec-form-submit-bg);
|
||||
color: var(--ec-form-submit-color);
|
||||
border: none;
|
||||
border-radius: var(--ec-form-submit-radius);
|
||||
padding: var(--ec-form-submit-padding);
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ec-form-submit:hover,
|
||||
.ec-form-next:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.ec-form-submit:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ec-form-prev {
|
||||
background: transparent;
|
||||
border: var(--ec-form-field-border);
|
||||
border-radius: var(--ec-form-submit-radius);
|
||||
padding: var(--ec-form-submit-padding);
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ec-form-progress {
|
||||
font-size: 0.875rem;
|
||||
color: var(--ec-form-help-color);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ec-form-status {
|
||||
padding: 0.75rem;
|
||||
border-radius: var(--ec-form-field-radius);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.ec-form-status:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ec-form-status--success {
|
||||
background: #f0fdf4;
|
||||
color: #166534;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
|
||||
.ec-form-status--error {
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.ec-form-turnstile {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Responsive: stack half-width fields on small screens */
|
||||
@media (max-width: 640px) {
|
||||
.ec-form-field--half {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
51
packages/plugins/forms/src/turnstile.ts
Normal file
51
packages/plugins/forms/src/turnstile.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Turnstile verification helper.
|
||||
*
|
||||
* Verifies a Turnstile token server-side via the Cloudflare API.
|
||||
*/
|
||||
|
||||
const VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
|
||||
|
||||
export interface TurnstileResult {
|
||||
success: boolean;
|
||||
errorCodes: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a Turnstile response token.
|
||||
*
|
||||
* @param token - The `cf-turnstile-response` token from the client
|
||||
* @param secretKey - The Turnstile secret key
|
||||
* @param httpFetch - The capability-gated fetch function from ctx.http
|
||||
* @param remoteIp - Optional client IP for additional verification
|
||||
*/
|
||||
export async function verifyTurnstile(
|
||||
token: string,
|
||||
secretKey: string,
|
||||
httpFetch: (url: string, init?: RequestInit) => Promise<Response>,
|
||||
remoteIp?: string | null,
|
||||
): Promise<TurnstileResult> {
|
||||
const body: Record<string, string> = {
|
||||
secret: secretKey,
|
||||
response: token,
|
||||
};
|
||||
if (remoteIp) {
|
||||
body.remoteip = remoteIp;
|
||||
}
|
||||
|
||||
const res = await httpFetch(VERIFY_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = (await res.json()) as {
|
||||
success: boolean;
|
||||
"error-codes"?: string[];
|
||||
};
|
||||
|
||||
return {
|
||||
success: data.success,
|
||||
errorCodes: data["error-codes"] ?? [],
|
||||
};
|
||||
}
|
||||
164
packages/plugins/forms/src/types.ts
Normal file
164
packages/plugins/forms/src/types.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Core types for the forms plugin.
|
||||
*
|
||||
* These define the data model stored in plugin storage.
|
||||
*/
|
||||
|
||||
// ─── Form Definitions ────────────────────────────────────────────
|
||||
|
||||
export interface FormDefinition {
|
||||
name: string;
|
||||
slug: string;
|
||||
pages: FormPage[];
|
||||
settings: FormSettings;
|
||||
status: "active" | "paused";
|
||||
submissionCount: number;
|
||||
lastSubmissionAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface FormPage {
|
||||
/** Page title shown in multi-page progress indicator. Optional for single-page forms. */
|
||||
title?: string;
|
||||
fields: FormField[];
|
||||
}
|
||||
|
||||
export interface FormSettings {
|
||||
/** Message shown after successful submission */
|
||||
confirmationMessage: string;
|
||||
/** Redirect URL after submission (overrides confirmation message) */
|
||||
redirectUrl?: string;
|
||||
/** Email addresses for submission notifications */
|
||||
notifyEmails: string[];
|
||||
/** Enable daily digest instead of per-submission notifications */
|
||||
digestEnabled: boolean;
|
||||
/** Hour (0-23) to send digest, in site timezone */
|
||||
digestHour: number;
|
||||
/** Autoresponder email sent to the submitter */
|
||||
autoresponder?: {
|
||||
subject: string;
|
||||
body: string;
|
||||
};
|
||||
/** Webhook URL for submission notifications */
|
||||
webhookUrl?: string;
|
||||
/** Days to retain submissions (0 = forever) */
|
||||
retentionDays: number;
|
||||
/** Spam protection strategy */
|
||||
spamProtection: "none" | "honeypot" | "turnstile";
|
||||
/** Submit button text */
|
||||
submitLabel: string;
|
||||
/** Label for Next button on multi-page forms */
|
||||
nextLabel?: string;
|
||||
/** Label for Previous button on multi-page forms */
|
||||
prevLabel?: string;
|
||||
}
|
||||
|
||||
// ─── Form Fields ─────────────────────────────────────────────────
|
||||
|
||||
export interface FormField {
|
||||
id: string;
|
||||
type: FieldType;
|
||||
label: string;
|
||||
/** HTML input name, unique per form */
|
||||
name: string;
|
||||
placeholder?: string;
|
||||
helpText?: string;
|
||||
required: boolean;
|
||||
validation?: FieldValidation;
|
||||
/** For select, radio, checkbox-group */
|
||||
options?: FieldOption[];
|
||||
defaultValue?: string;
|
||||
/** Layout hint */
|
||||
width: "full" | "half";
|
||||
/** Conditional visibility */
|
||||
condition?: FieldCondition;
|
||||
}
|
||||
|
||||
export type FieldType =
|
||||
| "text"
|
||||
| "email"
|
||||
| "textarea"
|
||||
| "number"
|
||||
| "tel"
|
||||
| "url"
|
||||
| "date"
|
||||
| "select"
|
||||
| "radio"
|
||||
| "checkbox"
|
||||
| "checkbox-group"
|
||||
| "file"
|
||||
| "hidden";
|
||||
|
||||
export interface FieldValidation {
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
/** Regex pattern */
|
||||
pattern?: string;
|
||||
/** Error message for pattern mismatch */
|
||||
patternMessage?: string;
|
||||
/** File types, e.g. ".pdf,.doc" */
|
||||
accept?: string;
|
||||
/** Max file size in bytes */
|
||||
maxFileSize?: number;
|
||||
}
|
||||
|
||||
export interface FieldOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface FieldCondition {
|
||||
/** Name of the controlling field */
|
||||
field: string;
|
||||
op: "eq" | "neq" | "filled" | "empty";
|
||||
value?: string;
|
||||
}
|
||||
|
||||
// ─── Submissions ─────────────────────────────────────────────────
|
||||
|
||||
export interface Submission {
|
||||
formId: string;
|
||||
data: Record<string, unknown>;
|
||||
files?: SubmissionFile[];
|
||||
status: "new" | "read" | "archived";
|
||||
starred: boolean;
|
||||
notes?: string;
|
||||
createdAt: string;
|
||||
meta: SubmissionMeta;
|
||||
}
|
||||
|
||||
export interface SubmissionFile {
|
||||
fieldName: string;
|
||||
filename: string;
|
||||
contentType: string;
|
||||
size: number;
|
||||
/** Reference to media library item */
|
||||
mediaId: string;
|
||||
}
|
||||
|
||||
export interface SubmissionMeta {
|
||||
ip: string | null;
|
||||
userAgent: string | null;
|
||||
referer: string | null;
|
||||
country: string | null;
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
/** Get all fields across all pages */
|
||||
export function getFormFields(form: FormDefinition): FormField[] {
|
||||
return form.pages.flatMap((p) => p.fields);
|
||||
}
|
||||
|
||||
/** Check if a form has multiple pages */
|
||||
export function isMultiPage(form: FormDefinition): boolean {
|
||||
return form.pages.length > 1;
|
||||
}
|
||||
|
||||
/** Check if a form has any file fields */
|
||||
export function hasFileFields(form: FormDefinition): boolean {
|
||||
return getFormFields(form).some((f) => f.type === "file");
|
||||
}
|
||||
205
packages/plugins/forms/src/validation.ts
Normal file
205
packages/plugins/forms/src/validation.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Server-side submission validation.
|
||||
*
|
||||
* Validates submitted data against the form's field definitions.
|
||||
* These rules mirror what the client-side script checks, but server
|
||||
* validation is authoritative — never trust the client.
|
||||
*/
|
||||
|
||||
import type { FieldType, FormField } from "./types.js";
|
||||
|
||||
export interface ValidationError {
|
||||
field: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors: ValidationError[];
|
||||
/** Sanitized/coerced values */
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const URL_RE = /^https?:\/\/.+/;
|
||||
const TEL_RE = /^[+\d][\d\s()-]*$/;
|
||||
|
||||
/**
|
||||
* Validate submission data against form field definitions.
|
||||
*
|
||||
* Returns sanitized data with proper type coercion and all validation
|
||||
* errors. Conditionally hidden fields are excluded from validation
|
||||
* if their condition is not met.
|
||||
*/
|
||||
export function validateSubmission(
|
||||
fields: FormField[],
|
||||
data: Record<string, unknown>,
|
||||
): ValidationResult {
|
||||
const errors: ValidationError[] = [];
|
||||
const validated: Record<string, unknown> = {};
|
||||
|
||||
for (const field of fields) {
|
||||
// Skip conditionally hidden fields
|
||||
if (field.condition && !evaluateCondition(field.condition, data)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const raw = data[field.name];
|
||||
const value = typeof raw === "string" ? raw.trim() : raw;
|
||||
const isEmpty = value === undefined || value === null || value === "";
|
||||
|
||||
// Required check
|
||||
if (field.required && isEmpty) {
|
||||
errors.push({ field: field.name, message: `${field.label} is required` });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip further validation if empty and not required
|
||||
if (isEmpty) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Type-specific validation
|
||||
const typeError = validateFieldType(field, value);
|
||||
if (typeError) {
|
||||
errors.push({ field: field.name, message: typeError });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validation rules
|
||||
const ruleErrors = validateFieldRules(field, value);
|
||||
for (const msg of ruleErrors) {
|
||||
errors.push({ field: field.name, message: msg });
|
||||
}
|
||||
|
||||
if (ruleErrors.length === 0) {
|
||||
validated[field.name] = coerceValue(field.type, value);
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors, data: validated };
|
||||
}
|
||||
|
||||
function validateFieldType(field: FormField, value: unknown): string | null {
|
||||
if (typeof value !== "string" && field.type !== "checkbox" && field.type !== "number") {
|
||||
return `${field.label} has an invalid value`;
|
||||
}
|
||||
|
||||
const strValue = String(value);
|
||||
|
||||
switch (field.type) {
|
||||
case "email":
|
||||
if (!EMAIL_RE.test(strValue)) return `${field.label} must be a valid email address`;
|
||||
break;
|
||||
case "url":
|
||||
if (!URL_RE.test(strValue)) return `${field.label} must be a valid URL`;
|
||||
break;
|
||||
case "tel":
|
||||
if (!TEL_RE.test(strValue)) return `${field.label} must be a valid phone number`;
|
||||
break;
|
||||
case "number": {
|
||||
const num = Number(value);
|
||||
if (Number.isNaN(num)) return `${field.label} must be a number`;
|
||||
break;
|
||||
}
|
||||
case "date":
|
||||
if (Number.isNaN(Date.parse(strValue))) return `${field.label} must be a valid date`;
|
||||
break;
|
||||
case "select":
|
||||
case "radio":
|
||||
if (field.options && !field.options.some((o) => o.value === strValue)) {
|
||||
return `${field.label} has an invalid selection`;
|
||||
}
|
||||
break;
|
||||
case "checkbox-group": {
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
if (field.options) {
|
||||
const validValues = new Set(field.options.map((o) => o.value));
|
||||
for (const v of values) {
|
||||
if (!validValues.has(String(v))) {
|
||||
return `${field.label} contains an invalid selection`;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateFieldRules(field: FormField, value: unknown): string[] {
|
||||
const errors: string[] = [];
|
||||
const v = field.validation;
|
||||
if (!v) return errors;
|
||||
|
||||
const strValue = String(value);
|
||||
|
||||
if (v.minLength !== undefined && strValue.length < v.minLength) {
|
||||
errors.push(`${field.label} must be at least ${v.minLength} characters`);
|
||||
}
|
||||
if (v.maxLength !== undefined && strValue.length > v.maxLength) {
|
||||
errors.push(`${field.label} must be at most ${v.maxLength} characters`);
|
||||
}
|
||||
|
||||
if (field.type === "number") {
|
||||
const num = Number(value);
|
||||
if (v.min !== undefined && num < v.min) {
|
||||
errors.push(`${field.label} must be at least ${v.min}`);
|
||||
}
|
||||
if (v.max !== undefined && num > v.max) {
|
||||
errors.push(`${field.label} must be at most ${v.max}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (v.pattern) {
|
||||
try {
|
||||
const re = new RegExp(v.pattern);
|
||||
if (!re.test(strValue)) {
|
||||
errors.push(v.patternMessage || `${field.label} has an invalid format`);
|
||||
}
|
||||
} catch {
|
||||
// Invalid regex in config — skip pattern check
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function coerceValue(type: FieldType, value: unknown): unknown {
|
||||
switch (type) {
|
||||
case "number":
|
||||
return Number(value);
|
||||
case "checkbox":
|
||||
return value === "on" || value === "true" || value === true;
|
||||
case "checkbox-group":
|
||||
return Array.isArray(value) ? value : [value];
|
||||
default:
|
||||
return typeof value === "string" ? value.trim() : value;
|
||||
}
|
||||
}
|
||||
|
||||
function evaluateCondition(
|
||||
condition: { field: string; op: string; value?: string },
|
||||
data: Record<string, unknown>,
|
||||
): boolean {
|
||||
const fieldValue = data[condition.field];
|
||||
const strValue =
|
||||
fieldValue === undefined || fieldValue === null
|
||||
? ""
|
||||
: String(fieldValue as string | number | boolean);
|
||||
const isFilled = strValue !== "";
|
||||
|
||||
switch (condition.op) {
|
||||
case "eq":
|
||||
return strValue === (condition.value ?? "");
|
||||
case "neq":
|
||||
return strValue !== (condition.value ?? "");
|
||||
case "filled":
|
||||
return isFilled;
|
||||
case "empty":
|
||||
return !isFilled;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
9
packages/plugins/forms/tsconfig.json
Normal file
9
packages/plugins/forms/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "src/astro", "src/admin.tsx"]
|
||||
}
|
||||
Reference in New Issue
Block a user