@emdash-cms/plugin-forms 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +40 -0
- package/src/admin.tsx +1288 -0
- package/src/astro/Form.astro +26 -0
- package/src/astro/FormEmbed.astro +301 -0
- package/src/astro/index.ts +11 -0
- package/src/client/index.ts +536 -0
- package/src/format.ts +160 -0
- package/src/handlers/cron.ts +151 -0
- package/src/handlers/forms.ts +269 -0
- package/src/handlers/submissions.ts +191 -0
- package/src/handlers/submit.ts +297 -0
- package/src/index.ts +230 -0
- package/src/schemas.ts +215 -0
- package/src/storage.ts +41 -0
- package/src/styles/forms.css +200 -0
- package/src/turnstile.ts +51 -0
- package/src/types.ts +164 -0
- package/src/validation.ts +205 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public form submission handler.
|
|
3
|
+
*
|
|
4
|
+
* This is the main entry point for form submissions from anonymous visitors.
|
|
5
|
+
* Handles spam protection, validation, file uploads, notifications, and webhooks.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { RouteContext, StorageCollection } from "emdash";
|
|
9
|
+
import { PluginRouteError } from "emdash";
|
|
10
|
+
import { ulid } from "ulidx";
|
|
11
|
+
|
|
12
|
+
import { formatSubmissionText, formatWebhookPayload } from "../format.js";
|
|
13
|
+
import type { SubmitInput } from "../schemas.js";
|
|
14
|
+
import { verifyTurnstile } from "../turnstile.js";
|
|
15
|
+
import type { FormDefinition, Submission, SubmissionFile } from "../types.js";
|
|
16
|
+
import { getFormFields } from "../types.js";
|
|
17
|
+
import { validateSubmission } from "../validation.js";
|
|
18
|
+
|
|
19
|
+
/** Typed access to plugin storage collections */
|
|
20
|
+
function forms(ctx: RouteContext): StorageCollection<FormDefinition> {
|
|
21
|
+
return ctx.storage.forms as StorageCollection<FormDefinition>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function submissions(ctx: RouteContext): StorageCollection<Submission> {
|
|
25
|
+
return ctx.storage.submissions as StorageCollection<Submission>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function submitHandler(ctx: RouteContext<SubmitInput>) {
|
|
29
|
+
const input = ctx.input;
|
|
30
|
+
|
|
31
|
+
// 1. Load form definition (by ID first, then by slug)
|
|
32
|
+
let formId = input.formId;
|
|
33
|
+
let form = await forms(ctx).get(formId);
|
|
34
|
+
if (!form) {
|
|
35
|
+
const bySlug = await forms(ctx).query({
|
|
36
|
+
where: { slug: input.formId },
|
|
37
|
+
limit: 1,
|
|
38
|
+
});
|
|
39
|
+
if (bySlug.items.length > 0) {
|
|
40
|
+
formId = bySlug.items[0]!.id;
|
|
41
|
+
form = bySlug.items[0]!.data;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (!form) {
|
|
45
|
+
throw PluginRouteError.notFound("Form not found");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (form.status === "paused") {
|
|
49
|
+
throw new PluginRouteError(
|
|
50
|
+
"FORM_PAUSED",
|
|
51
|
+
"This form is not currently accepting submissions",
|
|
52
|
+
410,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const settings = form.settings;
|
|
57
|
+
|
|
58
|
+
// 2. Spam protection
|
|
59
|
+
if (settings.spamProtection === "turnstile") {
|
|
60
|
+
const token = input.data["cf-turnstile-response"];
|
|
61
|
+
if (typeof token !== "string" || !token) {
|
|
62
|
+
throw PluginRouteError.forbidden("Spam verification required");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const secretKey = await ctx.kv.get<string>("settings:turnstileSecretKey");
|
|
66
|
+
if (!secretKey || !ctx.http) {
|
|
67
|
+
throw PluginRouteError.internal("Turnstile is not configured");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const result = await verifyTurnstile(
|
|
71
|
+
token,
|
|
72
|
+
secretKey,
|
|
73
|
+
ctx.http.fetch.bind(ctx.http),
|
|
74
|
+
ctx.requestMeta.ip,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
if (!result.success) {
|
|
78
|
+
ctx.log.warn("Turnstile verification failed", {
|
|
79
|
+
errorCodes: result.errorCodes,
|
|
80
|
+
});
|
|
81
|
+
throw PluginRouteError.forbidden("Spam verification failed. Please try again.");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (settings.spamProtection === "honeypot") {
|
|
86
|
+
if (input.data._hp) {
|
|
87
|
+
// Honeypot triggered — return success silently
|
|
88
|
+
return {
|
|
89
|
+
success: true,
|
|
90
|
+
message: settings.confirmationMessage,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 3. Validate submission data
|
|
96
|
+
const allFields = getFormFields(form);
|
|
97
|
+
const result = validateSubmission(allFields, input.data);
|
|
98
|
+
|
|
99
|
+
if (!result.valid) {
|
|
100
|
+
throw PluginRouteError.badRequest("Validation failed", { errors: result.errors });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 4. Upload files
|
|
104
|
+
const files: SubmissionFile[] = [];
|
|
105
|
+
if (input.files && ctx.media && "upload" in ctx.media) {
|
|
106
|
+
const mediaWithWrite = ctx.media as {
|
|
107
|
+
upload(
|
|
108
|
+
filename: string,
|
|
109
|
+
contentType: string,
|
|
110
|
+
bytes: ArrayBuffer,
|
|
111
|
+
): Promise<{ mediaId: string; storageKey: string; url: string }>;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
for (const field of allFields.filter((f) => f.type === "file")) {
|
|
115
|
+
const fileData = input.files[field.name];
|
|
116
|
+
if (!fileData) continue;
|
|
117
|
+
|
|
118
|
+
// Validate file type
|
|
119
|
+
if (field.validation?.accept) {
|
|
120
|
+
const allowed = field.validation.accept.split(",").map((s) => s.trim().toLowerCase());
|
|
121
|
+
const ext = `.${fileData.filename.split(".").pop()?.toLowerCase()}`;
|
|
122
|
+
const typeMatch = allowed.some(
|
|
123
|
+
(a) =>
|
|
124
|
+
a === ext ||
|
|
125
|
+
a === fileData.contentType ||
|
|
126
|
+
fileData.contentType.startsWith(a.replace("/*", "/")),
|
|
127
|
+
);
|
|
128
|
+
if (!typeMatch) {
|
|
129
|
+
throw PluginRouteError.badRequest(`File type not allowed for ${field.label}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Validate file size
|
|
134
|
+
if (
|
|
135
|
+
field.validation?.maxFileSize &&
|
|
136
|
+
fileData.bytes.byteLength > field.validation.maxFileSize
|
|
137
|
+
) {
|
|
138
|
+
throw PluginRouteError.badRequest(
|
|
139
|
+
`File too large for ${field.label}. Maximum: ${Math.round(field.validation.maxFileSize / 1024)} KB`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const uploaded = await mediaWithWrite.upload(
|
|
144
|
+
fileData.filename,
|
|
145
|
+
fileData.contentType,
|
|
146
|
+
fileData.bytes,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
files.push({
|
|
150
|
+
fieldName: field.name,
|
|
151
|
+
filename: fileData.filename,
|
|
152
|
+
contentType: fileData.contentType,
|
|
153
|
+
size: fileData.bytes.byteLength,
|
|
154
|
+
mediaId: uploaded.mediaId,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 5. Store submission
|
|
160
|
+
const submissionId = ulid();
|
|
161
|
+
const submission: Submission = {
|
|
162
|
+
formId,
|
|
163
|
+
data: result.data,
|
|
164
|
+
files: files.length > 0 ? files : undefined,
|
|
165
|
+
status: "new",
|
|
166
|
+
starred: false,
|
|
167
|
+
createdAt: new Date().toISOString(),
|
|
168
|
+
meta: {
|
|
169
|
+
ip: ctx.requestMeta.ip,
|
|
170
|
+
userAgent: ctx.requestMeta.userAgent,
|
|
171
|
+
referer: ctx.requestMeta.referer,
|
|
172
|
+
country: ctx.requestMeta.geo?.country ?? null,
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
await submissions(ctx).put(submissionId, submission);
|
|
177
|
+
|
|
178
|
+
// 6. Update form counters (use count() to avoid race conditions
|
|
179
|
+
// from concurrent submissions doing read-modify-write)
|
|
180
|
+
const submissionCount = await submissions(ctx).count({ formId });
|
|
181
|
+
await forms(ctx).put(formId, {
|
|
182
|
+
...form,
|
|
183
|
+
submissionCount,
|
|
184
|
+
lastSubmissionAt: new Date().toISOString(),
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// 7. Immediate email notifications (not digest)
|
|
188
|
+
if (settings.notifyEmails.length > 0 && !settings.digestEnabled && ctx.email) {
|
|
189
|
+
const text = formatSubmissionText(form, result.data, files);
|
|
190
|
+
for (const email of settings.notifyEmails) {
|
|
191
|
+
await ctx.email
|
|
192
|
+
.send({
|
|
193
|
+
to: email,
|
|
194
|
+
subject: `New submission: ${form.name}`,
|
|
195
|
+
text,
|
|
196
|
+
})
|
|
197
|
+
.catch((err: unknown) => {
|
|
198
|
+
ctx.log.error("Failed to send notification email", {
|
|
199
|
+
error: String(err),
|
|
200
|
+
to: email,
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 8. Autoresponder
|
|
207
|
+
if (settings.autoresponder && ctx.email) {
|
|
208
|
+
const emailField = allFields.find((f) => f.type === "email");
|
|
209
|
+
const submitterEmail = emailField ? result.data[emailField.name] : null;
|
|
210
|
+
if (typeof submitterEmail === "string" && submitterEmail) {
|
|
211
|
+
await ctx.email
|
|
212
|
+
.send({
|
|
213
|
+
to: submitterEmail,
|
|
214
|
+
subject: settings.autoresponder.subject,
|
|
215
|
+
text: settings.autoresponder.body,
|
|
216
|
+
})
|
|
217
|
+
.catch((err: unknown) => {
|
|
218
|
+
ctx.log.error("Failed to send autoresponder", { error: String(err) });
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// 9. Webhook (fire and forget)
|
|
224
|
+
if (settings.webhookUrl && ctx.http) {
|
|
225
|
+
const payload = formatWebhookPayload(form, submissionId, result.data, files);
|
|
226
|
+
ctx.http
|
|
227
|
+
.fetch(settings.webhookUrl, {
|
|
228
|
+
method: "POST",
|
|
229
|
+
headers: { "Content-Type": "application/json" },
|
|
230
|
+
body: JSON.stringify(payload),
|
|
231
|
+
})
|
|
232
|
+
.catch((err: unknown) => {
|
|
233
|
+
ctx.log.error("Webhook failed", {
|
|
234
|
+
error: String(err),
|
|
235
|
+
url: settings.webhookUrl,
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 10. Return success
|
|
241
|
+
return {
|
|
242
|
+
success: true,
|
|
243
|
+
message: settings.confirmationMessage,
|
|
244
|
+
redirect: settings.redirectUrl,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ─── Public Form Definition Endpoint ─────────────────────────────
|
|
249
|
+
|
|
250
|
+
export async function definitionHandler(
|
|
251
|
+
ctx: RouteContext<import("../schemas.js").DefinitionInput>,
|
|
252
|
+
) {
|
|
253
|
+
const { id } = ctx.input;
|
|
254
|
+
|
|
255
|
+
// Look up by ID first, then by slug
|
|
256
|
+
let form = await forms(ctx).get(id);
|
|
257
|
+
|
|
258
|
+
if (!form) {
|
|
259
|
+
const bySlug = await forms(ctx).query({
|
|
260
|
+
where: { slug: id },
|
|
261
|
+
limit: 1,
|
|
262
|
+
});
|
|
263
|
+
if (bySlug.items.length > 0) {
|
|
264
|
+
form = bySlug.items[0]!.data;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (!form) {
|
|
269
|
+
throw PluginRouteError.notFound("Form not found");
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (form.status !== "active") {
|
|
273
|
+
throw new PluginRouteError("FORM_PAUSED", "This form is not currently available", 410);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Include Turnstile site key if configured
|
|
277
|
+
const turnstileSiteKey =
|
|
278
|
+
form.settings.spamProtection === "turnstile"
|
|
279
|
+
? await ctx.kv.get<string>("settings:turnstileSiteKey")
|
|
280
|
+
: null;
|
|
281
|
+
|
|
282
|
+
// Return only the settings needed for client rendering — never expose
|
|
283
|
+
// admin emails, webhook URLs, or other internal configuration.
|
|
284
|
+
return {
|
|
285
|
+
name: form.name,
|
|
286
|
+
slug: form.slug,
|
|
287
|
+
pages: form.pages,
|
|
288
|
+
settings: {
|
|
289
|
+
spamProtection: form.settings.spamProtection,
|
|
290
|
+
submitLabel: form.settings.submitLabel,
|
|
291
|
+
nextLabel: form.settings.nextLabel,
|
|
292
|
+
prevLabel: form.settings.prevLabel,
|
|
293
|
+
},
|
|
294
|
+
status: form.status,
|
|
295
|
+
_turnstileSiteKey: turnstileSiteKey,
|
|
296
|
+
};
|
|
297
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forms Plugin for EmDash CMS
|
|
3
|
+
*
|
|
4
|
+
* Build forms in the admin, embed them in content via Portable Text,
|
|
5
|
+
* accept submissions from anonymous visitors, send notifications, export data.
|
|
6
|
+
*
|
|
7
|
+
* This is a trusted plugin shipped as an npm package. It uses the standard
|
|
8
|
+
* plugin APIs — nothing privileged.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```typescript
|
|
12
|
+
* // live.config.ts
|
|
13
|
+
* import { formsPlugin } from "@emdash-cms/plugin-forms";
|
|
14
|
+
*
|
|
15
|
+
* export default defineConfig({
|
|
16
|
+
* plugins: [formsPlugin()],
|
|
17
|
+
* });
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { PluginDescriptor, ResolvedPlugin } from "emdash";
|
|
22
|
+
import { definePlugin } from "emdash";
|
|
23
|
+
|
|
24
|
+
import { handleCleanup, handleDigest } from "./handlers/cron.js";
|
|
25
|
+
import {
|
|
26
|
+
formsCreateHandler,
|
|
27
|
+
formsDeleteHandler,
|
|
28
|
+
formsDuplicateHandler,
|
|
29
|
+
formsListHandler,
|
|
30
|
+
formsUpdateHandler,
|
|
31
|
+
} from "./handlers/forms.js";
|
|
32
|
+
import {
|
|
33
|
+
exportHandler,
|
|
34
|
+
submissionDeleteHandler,
|
|
35
|
+
submissionGetHandler,
|
|
36
|
+
submissionsListHandler,
|
|
37
|
+
submissionUpdateHandler,
|
|
38
|
+
} from "./handlers/submissions.js";
|
|
39
|
+
import { definitionHandler, submitHandler } from "./handlers/submit.js";
|
|
40
|
+
import {
|
|
41
|
+
definitionSchema,
|
|
42
|
+
exportSchema,
|
|
43
|
+
formCreateSchema,
|
|
44
|
+
formDeleteSchema,
|
|
45
|
+
formDuplicateSchema,
|
|
46
|
+
formUpdateSchema,
|
|
47
|
+
submissionDeleteSchema,
|
|
48
|
+
submissionGetSchema,
|
|
49
|
+
submissionsListSchema,
|
|
50
|
+
submitSchema,
|
|
51
|
+
submissionUpdateSchema,
|
|
52
|
+
} from "./schemas.js";
|
|
53
|
+
import { FORMS_STORAGE_CONFIG } from "./storage.js";
|
|
54
|
+
|
|
55
|
+
// ─── Plugin Options ──────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
export interface FormsPluginOptions {
|
|
58
|
+
/** Default spam protection for new forms */
|
|
59
|
+
defaultSpamProtection?: "none" | "honeypot" | "turnstile";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── Plugin Descriptor (for live.config.ts) ──────────────────────
|
|
63
|
+
|
|
64
|
+
export function formsPlugin(
|
|
65
|
+
options: FormsPluginOptions = {},
|
|
66
|
+
): PluginDescriptor<FormsPluginOptions> {
|
|
67
|
+
return {
|
|
68
|
+
id: "emdash-forms",
|
|
69
|
+
version: "0.0.1",
|
|
70
|
+
entrypoint: "@emdash-cms/plugin-forms",
|
|
71
|
+
adminEntry: "@emdash-cms/plugin-forms/admin",
|
|
72
|
+
componentsEntry: "@emdash-cms/plugin-forms/astro",
|
|
73
|
+
options,
|
|
74
|
+
capabilities: ["email:send", "write:media", "network:fetch"],
|
|
75
|
+
allowedHosts: ["*"],
|
|
76
|
+
adminPages: [
|
|
77
|
+
{ path: "/", label: "Forms", icon: "list" },
|
|
78
|
+
{ path: "/submissions", label: "Submissions", icon: "inbox" },
|
|
79
|
+
],
|
|
80
|
+
adminWidgets: [{ id: "recent-submissions", title: "Recent Submissions", size: "half" }],
|
|
81
|
+
// Descriptor uses flat indexes only; composite indexes are in definePlugin
|
|
82
|
+
storage: {
|
|
83
|
+
forms: { indexes: ["status", "createdAt"], uniqueIndexes: ["slug"] },
|
|
84
|
+
submissions: { indexes: ["formId", "status", "starred", "createdAt"] },
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ─── Plugin Implementation ───────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
export function createPlugin(_options: FormsPluginOptions = {}): ResolvedPlugin {
|
|
92
|
+
return definePlugin({
|
|
93
|
+
id: "emdash-forms",
|
|
94
|
+
version: "0.0.1",
|
|
95
|
+
capabilities: ["email:send", "write:media", "network:fetch"],
|
|
96
|
+
allowedHosts: ["*"],
|
|
97
|
+
|
|
98
|
+
storage: FORMS_STORAGE_CONFIG,
|
|
99
|
+
|
|
100
|
+
hooks: {
|
|
101
|
+
"plugin:activate": {
|
|
102
|
+
handler: async (_event, ctx) => {
|
|
103
|
+
// Schedule weekly cleanup for expired submissions
|
|
104
|
+
if (ctx.cron) {
|
|
105
|
+
await ctx.cron.schedule("cleanup", { schedule: "@weekly" });
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
cron: {
|
|
111
|
+
handler: async (event, ctx) => {
|
|
112
|
+
if (event.name === "cleanup") {
|
|
113
|
+
await handleCleanup(ctx);
|
|
114
|
+
} else if (event.name.startsWith("digest:")) {
|
|
115
|
+
const formId = event.name.slice("digest:".length);
|
|
116
|
+
await handleDigest(formId, ctx);
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
// Route handlers are typed with specific input schemas but the route record
|
|
123
|
+
// erases the generic to `unknown`. The cast is safe because the input schema
|
|
124
|
+
// guarantees the runtime shape matches the handler's expected type.
|
|
125
|
+
routes: {
|
|
126
|
+
// --- Public routes ---
|
|
127
|
+
|
|
128
|
+
submit: {
|
|
129
|
+
public: true,
|
|
130
|
+
input: submitSchema,
|
|
131
|
+
handler: submitHandler as never,
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
definition: {
|
|
135
|
+
public: true,
|
|
136
|
+
input: definitionSchema,
|
|
137
|
+
handler: definitionHandler as never,
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
// --- Admin routes (require auth) ---
|
|
141
|
+
|
|
142
|
+
"forms/list": {
|
|
143
|
+
handler: formsListHandler,
|
|
144
|
+
},
|
|
145
|
+
"forms/create": {
|
|
146
|
+
input: formCreateSchema,
|
|
147
|
+
handler: formsCreateHandler as never,
|
|
148
|
+
},
|
|
149
|
+
"forms/update": {
|
|
150
|
+
input: formUpdateSchema,
|
|
151
|
+
handler: formsUpdateHandler as never,
|
|
152
|
+
},
|
|
153
|
+
"forms/delete": {
|
|
154
|
+
input: formDeleteSchema,
|
|
155
|
+
handler: formsDeleteHandler as never,
|
|
156
|
+
},
|
|
157
|
+
"forms/duplicate": {
|
|
158
|
+
input: formDuplicateSchema,
|
|
159
|
+
handler: formsDuplicateHandler as never,
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
"submissions/list": {
|
|
163
|
+
input: submissionsListSchema,
|
|
164
|
+
handler: submissionsListHandler as never,
|
|
165
|
+
},
|
|
166
|
+
"submissions/get": {
|
|
167
|
+
input: submissionGetSchema,
|
|
168
|
+
handler: submissionGetHandler as never,
|
|
169
|
+
},
|
|
170
|
+
"submissions/update": {
|
|
171
|
+
input: submissionUpdateSchema,
|
|
172
|
+
handler: submissionUpdateHandler as never,
|
|
173
|
+
},
|
|
174
|
+
"submissions/delete": {
|
|
175
|
+
input: submissionDeleteSchema,
|
|
176
|
+
handler: submissionDeleteHandler as never,
|
|
177
|
+
},
|
|
178
|
+
"submissions/export": {
|
|
179
|
+
input: exportSchema,
|
|
180
|
+
handler: exportHandler as never,
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
"settings/turnstile-status": {
|
|
184
|
+
handler: async (ctx) => {
|
|
185
|
+
const siteKey = await ctx.kv.get<string>("settings:turnstileSiteKey");
|
|
186
|
+
const secretKey = await ctx.kv.get<string>("settings:turnstileSecretKey");
|
|
187
|
+
return {
|
|
188
|
+
hasSiteKey: !!siteKey,
|
|
189
|
+
hasSecretKey: !!secretKey,
|
|
190
|
+
};
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
admin: {
|
|
196
|
+
settingsSchema: {
|
|
197
|
+
turnstileSiteKey: { type: "string", label: "Turnstile Site Key" },
|
|
198
|
+
turnstileSecretKey: { type: "secret", label: "Turnstile Secret Key" },
|
|
199
|
+
},
|
|
200
|
+
pages: [
|
|
201
|
+
{ path: "/", label: "Forms", icon: "list" },
|
|
202
|
+
{ path: "/submissions", label: "Submissions", icon: "inbox" },
|
|
203
|
+
],
|
|
204
|
+
widgets: [{ id: "recent-submissions", title: "Recent Submissions", size: "half" }],
|
|
205
|
+
portableTextBlocks: [
|
|
206
|
+
{
|
|
207
|
+
type: "emdash-form",
|
|
208
|
+
label: "Form",
|
|
209
|
+
icon: "form",
|
|
210
|
+
description: "Embed a form",
|
|
211
|
+
fields: [
|
|
212
|
+
{
|
|
213
|
+
type: "select",
|
|
214
|
+
action_id: "formId",
|
|
215
|
+
label: "Form",
|
|
216
|
+
options: [],
|
|
217
|
+
optionsRoute: "forms/list",
|
|
218
|
+
},
|
|
219
|
+
],
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export default createPlugin;
|
|
227
|
+
|
|
228
|
+
// Re-export types for consumers
|
|
229
|
+
export type * from "./types.js";
|
|
230
|
+
export type { FormsStorage } from "./storage.js";
|