first commit

This commit is contained in:
Matt Kane
2026-04-01 10:44:22 +01:00
commit 43fcb9a131
1789 changed files with 395041 additions and 0 deletions

View 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,
});
});
}
}

View 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);
}

View 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`,
};
}

View 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,
};
}