@nextlyhq/plugin-form-builder 0.0.2-alpha.64 → 0.0.2-alpha.66
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/dist/admin/index.js +1 -1
- package/dist/{chunk-W6NLZFHH.js → chunk-VIGKSRSH.js} +117 -117
- package/dist/chunk-VIGKSRSH.js.map +1 -0
- package/dist/index.d.ts +130 -48
- package/dist/index.js +1276 -960
- package/dist/index.js.map +1 -1
- package/package.json +12 -12
- package/dist/chunk-W6NLZFHH.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -31,13 +31,13 @@ import {
|
|
|
31
31
|
pickedDocumentField,
|
|
32
32
|
pluralize,
|
|
33
33
|
toTitleCase
|
|
34
|
-
} from "./chunk-
|
|
34
|
+
} from "./chunk-VIGKSRSH.js";
|
|
35
35
|
|
|
36
36
|
// src/plugin.ts
|
|
37
37
|
import { createRequire } from "module";
|
|
38
38
|
import {
|
|
39
39
|
definePlugin,
|
|
40
|
-
NextlyError as
|
|
40
|
+
NextlyError as NextlyError4
|
|
41
41
|
} from "@nextlyhq/plugin-sdk";
|
|
42
42
|
|
|
43
43
|
// src/collections/forms.ts
|
|
@@ -1134,18 +1134,32 @@ function validateFormData(data, fields) {
|
|
|
1134
1134
|
const schema = generateZodSchema(fields);
|
|
1135
1135
|
return schema.safeParse(transformed);
|
|
1136
1136
|
}
|
|
1137
|
-
function
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1137
|
+
function isBlank(value) {
|
|
1138
|
+
return value === void 0 || value === null || value === "" || Array.isArray(value) && value.length === 0;
|
|
1139
|
+
}
|
|
1140
|
+
function getValidationIssues(result, submitted = {}) {
|
|
1141
|
+
if (result.success) return [];
|
|
1142
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1143
|
+
const issues = [];
|
|
1144
|
+
for (const issue of result.error.issues) {
|
|
1145
|
+
const path = issue.path.join(".");
|
|
1146
|
+
if (seen.has(path)) continue;
|
|
1147
|
+
seen.add(path);
|
|
1148
|
+
issues.push({
|
|
1149
|
+
path,
|
|
1150
|
+
// Read at the top level, which is the only depth a form field has: a
|
|
1151
|
+
// declared field is one key, so a dotted path can only come from inside a
|
|
1152
|
+
// value the visitor did supply.
|
|
1153
|
+
code: isBlank(submitted[path]) ? "REQUIRED" : "INVALID",
|
|
1154
|
+
message: issue.message
|
|
1155
|
+
});
|
|
1147
1156
|
}
|
|
1148
|
-
return
|
|
1157
|
+
return issues;
|
|
1158
|
+
}
|
|
1159
|
+
function getValidationErrors(result) {
|
|
1160
|
+
return Object.fromEntries(
|
|
1161
|
+
getValidationIssues(result).map((issue) => [issue.path, issue.message])
|
|
1162
|
+
);
|
|
1149
1163
|
}
|
|
1150
1164
|
|
|
1151
1165
|
// src/handlers/prepare-submission.ts
|
|
@@ -1203,167 +1217,883 @@ function prepareSubmission({
|
|
|
1203
1217
|
}
|
|
1204
1218
|
const result = generateZodSchema(fields).safeParse(transformed);
|
|
1205
1219
|
if (!result.success) {
|
|
1206
|
-
return {
|
|
1220
|
+
return {
|
|
1221
|
+
data: transformed,
|
|
1222
|
+
validationErrors: getValidationErrors(result),
|
|
1223
|
+
// The RAW submission, not the transformed payload. Transformation
|
|
1224
|
+
// normalises a value before the schema sees it, so a required
|
|
1225
|
+
// multi-select sent a scalar `0` arrives here as `[]` and would read as
|
|
1226
|
+
// blank: the visitor answered, and would be told they had not.
|
|
1227
|
+
validationIssues: getValidationIssues(result, data)
|
|
1228
|
+
};
|
|
1207
1229
|
}
|
|
1208
1230
|
return { data: result.data };
|
|
1209
1231
|
}
|
|
1210
1232
|
|
|
1211
|
-
// src/
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
+
// src/handlers/spam-detection.ts
|
|
1234
|
+
import { RateLimiter, resolveRateLimitStore } from "nextly";
|
|
1235
|
+
async function checkSpam(options) {
|
|
1236
|
+
const { data, ipAddress, formSlug, config } = options;
|
|
1237
|
+
if (config.honeypot) {
|
|
1238
|
+
const honeypotResult = checkHoneypot(data);
|
|
1239
|
+
if (honeypotResult.isSpam) {
|
|
1240
|
+
return honeypotResult;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
if (config.rateLimit && ipAddress) {
|
|
1244
|
+
const rateLimitResult = await checkRateLimit(
|
|
1245
|
+
ipAddress,
|
|
1246
|
+
formSlug,
|
|
1247
|
+
config.rateLimit
|
|
1248
|
+
);
|
|
1249
|
+
if (rateLimitResult.isSpam) {
|
|
1250
|
+
return rateLimitResult;
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
return { isSpam: false };
|
|
1254
|
+
}
|
|
1255
|
+
var HONEYPOT_FIELDS = [
|
|
1256
|
+
"__honeypot",
|
|
1257
|
+
"_honeypot",
|
|
1258
|
+
"honeypot",
|
|
1259
|
+
"__hp",
|
|
1260
|
+
"_hp",
|
|
1261
|
+
"website",
|
|
1262
|
+
// Common honeypot field name
|
|
1263
|
+
"url_field",
|
|
1264
|
+
// Another common honeypot
|
|
1265
|
+
"fax_number"
|
|
1266
|
+
// Rarely used by humans
|
|
1267
|
+
];
|
|
1268
|
+
function checkHoneypot(data) {
|
|
1269
|
+
for (const fieldName of HONEYPOT_FIELDS) {
|
|
1270
|
+
const value = data[fieldName];
|
|
1271
|
+
if (value !== void 0 && value !== null && value !== "") {
|
|
1272
|
+
let raw;
|
|
1273
|
+
if (typeof value === "object") {
|
|
1274
|
+
raw = JSON.stringify(value);
|
|
1275
|
+
} else {
|
|
1276
|
+
raw = String(value);
|
|
1233
1277
|
}
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
textarea: options.fields?.textarea ?? true,
|
|
1242
|
-
select: options.fields?.select ?? true,
|
|
1243
|
-
checkbox: options.fields?.checkbox ?? true,
|
|
1244
|
-
radio: options.fields?.radio ?? true,
|
|
1245
|
-
file: options.fields?.file ?? true,
|
|
1246
|
-
date: options.fields?.date ?? true,
|
|
1247
|
-
time: options.fields?.time ?? true,
|
|
1248
|
-
hidden: options.fields?.hidden ?? true
|
|
1249
|
-
},
|
|
1250
|
-
redirectRelationships: normalizeRedirectRelationships(
|
|
1251
|
-
options.redirectRelationships
|
|
1252
|
-
),
|
|
1253
|
-
// Filled at `init` from each collection's own metadata. Empty until then,
|
|
1254
|
-
// and a slug that stays absent reads as unknown rather than as not
|
|
1255
|
-
// localized — see `ResolvedFormBuilderConfig`.
|
|
1256
|
-
redirectTargetLocalization: {},
|
|
1257
|
-
beforeEmail: options.beforeEmail,
|
|
1258
|
-
notifications: {
|
|
1259
|
-
defaultFrom: options.notifications?.defaultFrom,
|
|
1260
|
-
defaultToEmail: options.notifications?.defaultToEmail,
|
|
1261
|
-
enabled: options.notifications?.enabled ?? true
|
|
1262
|
-
},
|
|
1263
|
-
spamProtection: {
|
|
1264
|
-
honeypot: options.spamProtection?.honeypot ?? true,
|
|
1265
|
-
recaptcha: options.spamProtection?.recaptcha ?? { enabled: false },
|
|
1266
|
-
rateLimit: {
|
|
1267
|
-
maxSubmissions: options.spamProtection?.rateLimit?.maxSubmissions ?? 10,
|
|
1268
|
-
windowMs: options.spamProtection?.rateLimit?.windowMs ?? 6e4
|
|
1278
|
+
const stringValue = raw.trim();
|
|
1279
|
+
if (stringValue.length > 0) {
|
|
1280
|
+
return {
|
|
1281
|
+
isSpam: true,
|
|
1282
|
+
reason: "honeypot",
|
|
1283
|
+
details: `Honeypot field "${fieldName}" was filled`
|
|
1284
|
+
};
|
|
1269
1285
|
}
|
|
1270
|
-
},
|
|
1271
|
-
uploads: {
|
|
1272
|
-
maxFileSize: options.uploads?.maxFileSize ?? 10485760,
|
|
1273
|
-
// 10 MB
|
|
1274
|
-
allowedMimeTypes: options.uploads?.allowedMimeTypes ?? [
|
|
1275
|
-
"image/*",
|
|
1276
|
-
"application/pdf",
|
|
1277
|
-
"text/*"
|
|
1278
|
-
],
|
|
1279
|
-
uploadCollection: options.uploads?.uploadCollection ?? "media"
|
|
1280
|
-
},
|
|
1281
|
-
features: {
|
|
1282
|
-
builder: options.features?.builder ?? true,
|
|
1283
|
-
conditionalLogic: options.features?.conditionalLogic ?? true,
|
|
1284
|
-
fileUploads: options.features?.fileUploads ?? true
|
|
1285
1286
|
}
|
|
1287
|
+
}
|
|
1288
|
+
return { isSpam: false };
|
|
1289
|
+
}
|
|
1290
|
+
async function checkRateLimit(ipAddress, formSlug, config) {
|
|
1291
|
+
const limiter = new RateLimiter(resolveRateLimitStore());
|
|
1292
|
+
const result = await limiter.check(
|
|
1293
|
+
`form-submit:${formSlug}:${ipAddress}`,
|
|
1294
|
+
config.maxSubmissions,
|
|
1295
|
+
config.windowMs
|
|
1296
|
+
);
|
|
1297
|
+
if (result.allowed) return { isSpam: false };
|
|
1298
|
+
return {
|
|
1299
|
+
isSpam: true,
|
|
1300
|
+
reason: "rate_limit",
|
|
1301
|
+
details: `Rate limit exceeded: ${config.maxSubmissions} per ${config.windowMs}ms`
|
|
1286
1302
|
};
|
|
1287
1303
|
}
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1304
|
+
|
|
1305
|
+
// src/routes/public-forms.ts
|
|
1306
|
+
import {
|
|
1307
|
+
buildPaginatedResponse,
|
|
1308
|
+
clampLimit,
|
|
1309
|
+
formAvailability as formAvailability2,
|
|
1310
|
+
NextlyError as NextlyError3,
|
|
1311
|
+
NO_SUCH_FORM as NO_SUCH_FORM2,
|
|
1312
|
+
respondAction,
|
|
1313
|
+
respondDoc,
|
|
1314
|
+
respondList,
|
|
1315
|
+
trustedClientIp
|
|
1316
|
+
} from "nextly";
|
|
1317
|
+
|
|
1318
|
+
// src/handlers/submit-form.ts
|
|
1319
|
+
import { isLocaleSelector } from "@nextlyhq/plugin-sdk";
|
|
1320
|
+
import { formAvailability, NextlyError as NextlyError2, NO_SUCH_FORM } from "nextly";
|
|
1321
|
+
async function submitForm(options, context) {
|
|
1322
|
+
const { formSlug, data, metadata, request } = options;
|
|
1323
|
+
const access = options.access ?? { as: "system" };
|
|
1324
|
+
const locale = visitorLocale(options.locale);
|
|
1325
|
+
const targetRead = { access, locale, request };
|
|
1326
|
+
const { pluginContext, pluginConfig } = context;
|
|
1327
|
+
const { collections } = pluginContext.services;
|
|
1328
|
+
const { logger } = pluginContext;
|
|
1329
|
+
try {
|
|
1330
|
+
const form = await fetchFormBySlug(
|
|
1331
|
+
formSlug,
|
|
1332
|
+
pluginConfig,
|
|
1333
|
+
pluginContext,
|
|
1334
|
+
access
|
|
1335
|
+
);
|
|
1336
|
+
if (!form) {
|
|
1337
|
+
logger.warn?.("Form submission attempted for non-existent form", {
|
|
1338
|
+
formSlug
|
|
1339
|
+
});
|
|
1340
|
+
return { success: false, outcome: "no-such-form", error: NO_SUCH_FORM };
|
|
1341
|
+
}
|
|
1342
|
+
const redirectFor = () => resolveRedirectUrl(form, pluginConfig, pluginContext, targetRead);
|
|
1343
|
+
const availability = formAvailability(form);
|
|
1344
|
+
if (availability.kind !== "open") {
|
|
1345
|
+
logger.info?.("Form submission rejected - form not accepting", {
|
|
1346
|
+
formSlug,
|
|
1347
|
+
status: form.status,
|
|
1348
|
+
availability: availability.kind
|
|
1349
|
+
});
|
|
1350
|
+
return {
|
|
1351
|
+
success: false,
|
|
1352
|
+
outcome: availability.kind === "closed" ? "closed" : "no-such-form",
|
|
1353
|
+
error: availability.kind === "closed" ? availability.message : NO_SUCH_FORM
|
|
1354
|
+
};
|
|
1355
|
+
}
|
|
1356
|
+
const settings = normalizeFormSettings(form.settings);
|
|
1357
|
+
if (settings.allowMultipleSubmissions === false && !metadata?.ipAddress) {
|
|
1358
|
+
logger.warn?.(
|
|
1359
|
+
"Single-submission form received a submission without an IP \u2014 the restriction cannot apply",
|
|
1360
|
+
{ formSlug }
|
|
1361
|
+
);
|
|
1362
|
+
}
|
|
1363
|
+
if (settings.allowMultipleSubmissions === false && metadata?.ipAddress) {
|
|
1364
|
+
const existing = await collections.count(
|
|
1365
|
+
pluginConfig.formSubmissionOverrides.slug,
|
|
1333
1366
|
{
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
+
where: {
|
|
1368
|
+
form: { equals: form.id },
|
|
1369
|
+
ipAddress: { equals: metadata.ipAddress },
|
|
1370
|
+
status: { not_equals: "spam" }
|
|
1371
|
+
}
|
|
1372
|
+
},
|
|
1373
|
+
// Deliberately elevated, unlike the write below. This counts rows the
|
|
1374
|
+
// visitor is not entitled to see in order to refuse their second
|
|
1375
|
+
// submission; judged as the public it would count zero on any install
|
|
1376
|
+
// that hides submissions and the restriction would never apply.
|
|
1377
|
+
{ as: "system" }
|
|
1378
|
+
);
|
|
1379
|
+
if (existing > 0) {
|
|
1380
|
+
logger.info?.("Repeat submission rejected (single-submission form)", {
|
|
1381
|
+
formSlug,
|
|
1382
|
+
ipAddress: metadata.ipAddress
|
|
1383
|
+
});
|
|
1384
|
+
return {
|
|
1385
|
+
success: false,
|
|
1386
|
+
outcome: "duplicate",
|
|
1387
|
+
error: "You have already submitted this form."
|
|
1388
|
+
};
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
const declaredFieldNames = new Set(form.fields.map((field) => field.name));
|
|
1392
|
+
const undeclaredData = Object.fromEntries(
|
|
1393
|
+
Object.entries(data).filter(([key]) => !declaredFieldNames.has(key))
|
|
1394
|
+
);
|
|
1395
|
+
const spamResult = await checkSpam({
|
|
1396
|
+
data: undeclaredData,
|
|
1397
|
+
formSlug,
|
|
1398
|
+
config: {
|
|
1399
|
+
// Per-form overrides win where set; blank inherits the plugin config.
|
|
1400
|
+
honeypot: settings.honeypotEnabled ?? pluginConfig.spamProtection.honeypot
|
|
1401
|
+
}
|
|
1402
|
+
});
|
|
1403
|
+
const isContentSpam = spamResult.isSpam;
|
|
1404
|
+
if (isContentSpam) {
|
|
1405
|
+
logger.info?.("Spam submission detected \u2014 storing flagged", {
|
|
1406
|
+
formSlug,
|
|
1407
|
+
reason: spamResult.reason,
|
|
1408
|
+
ipAddress: metadata?.ipAddress
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
const prepared = prepareSubmission({
|
|
1412
|
+
data,
|
|
1413
|
+
fields: form.fields,
|
|
1414
|
+
validate: !isContentSpam
|
|
1415
|
+
});
|
|
1416
|
+
if (prepared.validationErrors) {
|
|
1417
|
+
logger.debug?.("Form validation failed", {
|
|
1418
|
+
formSlug,
|
|
1419
|
+
errors: prepared.validationErrors
|
|
1420
|
+
});
|
|
1421
|
+
return {
|
|
1422
|
+
success: false,
|
|
1423
|
+
outcome: "invalid",
|
|
1424
|
+
error: "Validation failed",
|
|
1425
|
+
validationErrors: prepared.validationErrors,
|
|
1426
|
+
validationIssues: prepared.validationIssues
|
|
1427
|
+
};
|
|
1428
|
+
}
|
|
1429
|
+
const storedData = prepared.data;
|
|
1430
|
+
const submissionData = {
|
|
1431
|
+
form: form.id,
|
|
1432
|
+
data: storedData,
|
|
1433
|
+
status: isContentSpam ? "spam" : "new",
|
|
1434
|
+
spamReason: isContentSpam ? spamResult.reason ?? null : null,
|
|
1435
|
+
ipAddress: metadata?.ipAddress || null,
|
|
1436
|
+
userAgent: metadata?.userAgent || null,
|
|
1437
|
+
submittedAt: /* @__PURE__ */ new Date()
|
|
1438
|
+
};
|
|
1439
|
+
let submission;
|
|
1440
|
+
try {
|
|
1441
|
+
submission = await collections.createEntry(
|
|
1442
|
+
pluginConfig.formSubmissionOverrides.slug,
|
|
1443
|
+
asPluginSubmission(submissionData, {
|
|
1444
|
+
keepAsEvidence: isContentSpam,
|
|
1445
|
+
form: { id: form.id, fields: form.fields }
|
|
1446
|
+
}),
|
|
1447
|
+
// Public form submission — create as system. No ambient user; an
|
|
1448
|
+
// empty context already resolves to system, but be explicit.
|
|
1449
|
+
//
|
|
1450
|
+
// The request travels with it so the seam judges this submission on the
|
|
1451
|
+
// same facts as one arriving through a Nextly route. Omitted by a caller
|
|
1452
|
+
// that has none, and then the seam correctly reads the write as
|
|
1453
|
+
// server-side work.
|
|
1454
|
+
//
|
|
1455
|
+
// `access` rather than a fixed `system`, so a route serving an anonymous
|
|
1456
|
+
// visitor is still judged against the collection's own create rule. A
|
|
1457
|
+
// host that closed public submissions configured that on the
|
|
1458
|
+
// collection, and a write elevated past it reports a 201 for a
|
|
1459
|
+
// submission they had refused.
|
|
1460
|
+
{ ...access, ...request ? { request } : {} }
|
|
1461
|
+
);
|
|
1462
|
+
} catch (error) {
|
|
1463
|
+
if (NextlyError2.isRateLimited(error)) {
|
|
1464
|
+
logger.info?.("Form submission refused (rate limit)", { formSlug });
|
|
1465
|
+
return {
|
|
1466
|
+
success: true,
|
|
1467
|
+
outcome: "accepted",
|
|
1468
|
+
successMessage: settings.successMessage,
|
|
1469
|
+
// The same destination an accepted submission gets. Returning early
|
|
1470
|
+
// without it left a throttled visitor on the form instead of the
|
|
1471
|
+
// page its author sends people to, and made the one response this
|
|
1472
|
+
// whole branch exists to disguise the only accepted one carrying no
|
|
1473
|
+
// redirect.
|
|
1474
|
+
redirect: await redirectFor()
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
1477
|
+
throw error;
|
|
1478
|
+
}
|
|
1479
|
+
logger.info?.("Form submission created successfully", {
|
|
1480
|
+
formSlug,
|
|
1481
|
+
submissionId: submission.item.id,
|
|
1482
|
+
flaggedAsSpam: isContentSpam
|
|
1483
|
+
});
|
|
1484
|
+
if (submission.warnings) {
|
|
1485
|
+
logger.warn?.("Form submission side effects failed", {
|
|
1486
|
+
formSlug,
|
|
1487
|
+
submissionId: submission.item.id,
|
|
1488
|
+
warnings: submission.warnings
|
|
1489
|
+
});
|
|
1490
|
+
}
|
|
1491
|
+
const redirect = await redirectFor();
|
|
1492
|
+
if (isContentSpam) {
|
|
1493
|
+
return {
|
|
1494
|
+
success: true,
|
|
1495
|
+
outcome: "accepted",
|
|
1496
|
+
successMessage: settings.successMessage,
|
|
1497
|
+
redirect
|
|
1498
|
+
};
|
|
1499
|
+
}
|
|
1500
|
+
return {
|
|
1501
|
+
success: true,
|
|
1502
|
+
outcome: "accepted",
|
|
1503
|
+
successMessage: settings.successMessage,
|
|
1504
|
+
// The created ROW, not the envelope around it. Through the shared
|
|
1505
|
+
// conversion, which is where the unchecked step lives and says so; the
|
|
1506
|
+
// integration test reads `submission.id` off the result because nothing
|
|
1507
|
+
// at compile time will.
|
|
1508
|
+
submission: asSubmissionDocument(submission.item),
|
|
1509
|
+
redirect
|
|
1510
|
+
};
|
|
1511
|
+
} catch (error) {
|
|
1512
|
+
logger.error?.("Form submission error", {
|
|
1513
|
+
formSlug,
|
|
1514
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1515
|
+
});
|
|
1516
|
+
return {
|
|
1517
|
+
success: false,
|
|
1518
|
+
outcome: "failed",
|
|
1519
|
+
error: "An error occurred processing your submission. Please try again.",
|
|
1520
|
+
cause: error
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
async function fetchFormBySlug(slug, pluginConfig, pluginContext, access = { as: "system" }) {
|
|
1525
|
+
const { collections } = pluginContext.services;
|
|
1526
|
+
const result = await collections.listEntries(
|
|
1527
|
+
pluginConfig.formOverrides.slug,
|
|
1528
|
+
{ where: { slug: { equals: slug } }, pagination: { limit: 1 } },
|
|
1529
|
+
access
|
|
1530
|
+
);
|
|
1531
|
+
const form = result.data?.[0];
|
|
1532
|
+
return form ? asFormDocument(form) : null;
|
|
1533
|
+
}
|
|
1534
|
+
async function resolveRedirectUrl(form, pluginConfig, pluginContext, read) {
|
|
1535
|
+
const settings = form.settings;
|
|
1536
|
+
if (!settings) return void 0;
|
|
1537
|
+
if (settings.confirmationType === "redirect" && settings.redirectUrl) {
|
|
1538
|
+
return settings.redirectUrl;
|
|
1539
|
+
}
|
|
1540
|
+
const field = pickedDocumentField(settings);
|
|
1541
|
+
if (!field) return void 0;
|
|
1542
|
+
return urlForPickedDocument(
|
|
1543
|
+
settings[field],
|
|
1544
|
+
form,
|
|
1545
|
+
pluginConfig,
|
|
1546
|
+
pluginContext,
|
|
1547
|
+
read
|
|
1548
|
+
);
|
|
1549
|
+
}
|
|
1550
|
+
async function urlForPickedDocument(stored, form, pluginConfig, pluginContext, read) {
|
|
1551
|
+
const { logger } = pluginContext;
|
|
1552
|
+
const patterns = pluginConfig.redirectRelationships;
|
|
1553
|
+
const reference = parseRedirectReference(stored, Object.keys(patterns));
|
|
1554
|
+
if (!reference) {
|
|
1555
|
+
logger.warn?.("Form redirects to a page, but names no readable document", {
|
|
1556
|
+
form: form.slug
|
|
1557
|
+
});
|
|
1558
|
+
return void 0;
|
|
1559
|
+
}
|
|
1560
|
+
const pattern = patterns[reference.collection];
|
|
1561
|
+
if (!pattern) {
|
|
1562
|
+
logger.warn?.(
|
|
1563
|
+
"Form redirects to a collection that is not in redirectRelationships",
|
|
1564
|
+
{ form: form.slug, collection: reference.collection }
|
|
1565
|
+
);
|
|
1566
|
+
return void 0;
|
|
1567
|
+
}
|
|
1568
|
+
const target = await readTarget(reference, form, pluginContext, read);
|
|
1569
|
+
if (!target) return void 0;
|
|
1570
|
+
if (documentReachability(
|
|
1571
|
+
target,
|
|
1572
|
+
pluginConfig.redirectTargetLocalization[reference.collection]
|
|
1573
|
+
) === "unreachable") {
|
|
1574
|
+
logger.warn?.("Form redirects to a page that has never been published", {
|
|
1575
|
+
form: form.slug,
|
|
1576
|
+
collection: reference.collection,
|
|
1577
|
+
target: reference.id
|
|
1578
|
+
});
|
|
1579
|
+
return void 0;
|
|
1580
|
+
}
|
|
1581
|
+
return buildUrl(pattern, target, reference.collection, form, pluginContext);
|
|
1582
|
+
}
|
|
1583
|
+
function buildUrl(pattern, target, collection, form, pluginContext) {
|
|
1584
|
+
const { logger } = pluginContext;
|
|
1585
|
+
try {
|
|
1586
|
+
const url2 = applyRedirectPattern(pattern, target);
|
|
1587
|
+
if (!url2) {
|
|
1588
|
+
logger.warn?.(
|
|
1589
|
+
"Form redirect pattern could not be filled from the target document",
|
|
1590
|
+
{ form: form.slug, collection }
|
|
1591
|
+
);
|
|
1592
|
+
}
|
|
1593
|
+
return url2;
|
|
1594
|
+
} catch (error) {
|
|
1595
|
+
logger.warn?.("Form redirect pattern threw while building a URL", {
|
|
1596
|
+
form: form.slug,
|
|
1597
|
+
collection,
|
|
1598
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1599
|
+
});
|
|
1600
|
+
return void 0;
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
function visitorLocale(locale) {
|
|
1604
|
+
if (locale === void 0 || isLocaleSelector(locale)) return void 0;
|
|
1605
|
+
return locale;
|
|
1606
|
+
}
|
|
1607
|
+
async function readTarget(reference, form, pluginContext, read) {
|
|
1608
|
+
const { logger } = pluginContext;
|
|
1609
|
+
const context = {
|
|
1610
|
+
form: form.slug,
|
|
1611
|
+
collection: reference.collection,
|
|
1612
|
+
id: reference.id
|
|
1613
|
+
};
|
|
1614
|
+
let row;
|
|
1615
|
+
try {
|
|
1616
|
+
row = await pluginContext.services.collections.findEntryById(
|
|
1617
|
+
reference.collection,
|
|
1618
|
+
reference.id,
|
|
1619
|
+
{ ...read.access, locale: read.locale, request: read.request }
|
|
1620
|
+
);
|
|
1621
|
+
} catch (error) {
|
|
1622
|
+
logger.warn?.("Form redirect target could not be read", {
|
|
1623
|
+
...context,
|
|
1624
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1625
|
+
});
|
|
1626
|
+
return void 0;
|
|
1627
|
+
}
|
|
1628
|
+
if (!row || typeof row !== "object") {
|
|
1629
|
+
logger.warn?.("Form redirect target no longer exists", context);
|
|
1630
|
+
return void 0;
|
|
1631
|
+
}
|
|
1632
|
+
return row;
|
|
1633
|
+
}
|
|
1634
|
+
async function validateSubmission(formSlug, data, context) {
|
|
1635
|
+
const { pluginContext, pluginConfig } = context;
|
|
1636
|
+
const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);
|
|
1637
|
+
if (!form) {
|
|
1638
|
+
return { valid: false, errors: { _form: "Form not found" } };
|
|
1639
|
+
}
|
|
1640
|
+
const prepared = prepareSubmission({
|
|
1641
|
+
data,
|
|
1642
|
+
fields: form.fields,
|
|
1643
|
+
validate: true
|
|
1644
|
+
});
|
|
1645
|
+
if (!prepared.validationErrors) {
|
|
1646
|
+
return { valid: true };
|
|
1647
|
+
}
|
|
1648
|
+
return {
|
|
1649
|
+
valid: false,
|
|
1650
|
+
errors: prepared.validationErrors
|
|
1651
|
+
};
|
|
1652
|
+
}
|
|
1653
|
+
async function isFormAcceptingSubmissions(formSlug, context) {
|
|
1654
|
+
const { pluginContext, pluginConfig } = context;
|
|
1655
|
+
const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);
|
|
1656
|
+
if (!form) {
|
|
1657
|
+
return false;
|
|
1658
|
+
}
|
|
1659
|
+
return form.status === "published";
|
|
1660
|
+
}
|
|
1661
|
+
async function getFormSubmissionStats(formSlug, context) {
|
|
1662
|
+
const { pluginContext, pluginConfig } = context;
|
|
1663
|
+
const { collections } = pluginContext.services;
|
|
1664
|
+
const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);
|
|
1665
|
+
if (!form) {
|
|
1666
|
+
return null;
|
|
1667
|
+
}
|
|
1668
|
+
try {
|
|
1669
|
+
const submissionsSlug = pluginConfig.formSubmissionOverrides.slug;
|
|
1670
|
+
const sys = { as: "system" };
|
|
1671
|
+
const base = { form: { equals: form.id } };
|
|
1672
|
+
const [total, newCount, read, archived] = await Promise.all([
|
|
1673
|
+
collections.count(submissionsSlug, { where: base }, sys),
|
|
1674
|
+
collections.count(
|
|
1675
|
+
submissionsSlug,
|
|
1676
|
+
{ where: { ...base, status: { equals: "new" } } },
|
|
1677
|
+
sys
|
|
1678
|
+
),
|
|
1679
|
+
collections.count(
|
|
1680
|
+
submissionsSlug,
|
|
1681
|
+
{ where: { ...base, status: { equals: "read" } } },
|
|
1682
|
+
sys
|
|
1683
|
+
),
|
|
1684
|
+
collections.count(
|
|
1685
|
+
submissionsSlug,
|
|
1686
|
+
{ where: { ...base, status: { equals: "archived" } } },
|
|
1687
|
+
sys
|
|
1688
|
+
)
|
|
1689
|
+
]);
|
|
1690
|
+
return { total, new: newCount, read, archived };
|
|
1691
|
+
} catch {
|
|
1692
|
+
return null;
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
// src/routes/public-forms.ts
|
|
1697
|
+
var PUBLIC = { as: "public" };
|
|
1698
|
+
var DEFAULT_LIST_LIMIT = 100;
|
|
1699
|
+
var DEFAULT_SUCCESS_MESSAGE = "Thank you for your submission!";
|
|
1700
|
+
function installedSlugs(config, self) {
|
|
1701
|
+
const declaredForms = config.formOverrides.slug;
|
|
1702
|
+
const declaredSubmissions = config.formSubmissionOverrides.slug;
|
|
1703
|
+
return {
|
|
1704
|
+
forms: self.collections[declaredForms] ?? declaredForms,
|
|
1705
|
+
submissions: self.collections[declaredSubmissions] ?? declaredSubmissions
|
|
1706
|
+
};
|
|
1707
|
+
}
|
|
1708
|
+
function configForInstall(config, self) {
|
|
1709
|
+
const slugs = installedSlugs(config, self);
|
|
1710
|
+
return {
|
|
1711
|
+
...config,
|
|
1712
|
+
formOverrides: { ...config.formOverrides, slug: slugs.forms },
|
|
1713
|
+
formSubmissionOverrides: {
|
|
1714
|
+
...config.formSubmissionOverrides,
|
|
1715
|
+
slug: slugs.submissions
|
|
1716
|
+
}
|
|
1717
|
+
};
|
|
1718
|
+
}
|
|
1719
|
+
function positiveInt(raw) {
|
|
1720
|
+
if (raw === null) return void 0;
|
|
1721
|
+
const value = Number(raw);
|
|
1722
|
+
return Number.isInteger(value) && value > 0 ? value : void 0;
|
|
1723
|
+
}
|
|
1724
|
+
function toPageMeta(pagination) {
|
|
1725
|
+
const limit = pagination.limit > 0 ? pagination.limit : DEFAULT_LIST_LIMIT;
|
|
1726
|
+
const built = buildPaginatedResponse([], {
|
|
1727
|
+
total: pagination.total,
|
|
1728
|
+
page: Math.floor(pagination.offset / limit) + 1,
|
|
1729
|
+
limit
|
|
1730
|
+
});
|
|
1731
|
+
return {
|
|
1732
|
+
total: built.totalDocs,
|
|
1733
|
+
page: built.page,
|
|
1734
|
+
limit: built.limit,
|
|
1735
|
+
totalPages: built.totalPages,
|
|
1736
|
+
hasNext: built.hasNextPage,
|
|
1737
|
+
hasPrev: built.hasPrevPage
|
|
1738
|
+
};
|
|
1739
|
+
}
|
|
1740
|
+
async function listPublishedForms(req, ctx, config) {
|
|
1741
|
+
const url2 = new URL(req.url);
|
|
1742
|
+
const slugs = installedSlugs(config, ctx.self);
|
|
1743
|
+
const result = await ctx.services.collections.listEntries(
|
|
1744
|
+
slugs.forms,
|
|
1745
|
+
{
|
|
1746
|
+
where: { status: { equals: "published" } },
|
|
1747
|
+
pagination: {
|
|
1748
|
+
// Clamped HERE, with the canonical bound, so the limit that computes
|
|
1749
|
+
// the offset is the limit that comes back. The query service clamps to
|
|
1750
|
+
// 500 internally and reports the clamped value, while the offset is
|
|
1751
|
+
// built from what was asked for, so `?limit=1000&page=2` returned the
|
|
1752
|
+
// second 500-row slice and dividing the two reported page 3.
|
|
1753
|
+
limit: clampLimit(
|
|
1754
|
+
positiveInt(url2.searchParams.get("limit")) ?? DEFAULT_LIST_LIMIT
|
|
1755
|
+
),
|
|
1756
|
+
page: positiveInt(url2.searchParams.get("page")) ?? 1
|
|
1757
|
+
}
|
|
1758
|
+
},
|
|
1759
|
+
PUBLIC
|
|
1760
|
+
);
|
|
1761
|
+
return respondList(result.data ?? [], toPageMeta(result.pagination));
|
|
1762
|
+
}
|
|
1763
|
+
async function readFormBySlug(slug, ctx, config) {
|
|
1764
|
+
const slugs = installedSlugs(config, ctx.self);
|
|
1765
|
+
const result = await ctx.services.collections.listEntries(
|
|
1766
|
+
slugs.forms,
|
|
1767
|
+
{ where: { slug: { equals: slug } }, pagination: { limit: 1 } },
|
|
1768
|
+
PUBLIC
|
|
1769
|
+
);
|
|
1770
|
+
const doc = result.data?.[0];
|
|
1771
|
+
const availability = formAvailability2(doc);
|
|
1772
|
+
if (availability.kind === "absent") {
|
|
1773
|
+
throw NextlyError3.notFound({
|
|
1774
|
+
message: NO_SUCH_FORM2,
|
|
1775
|
+
logContext: { entity: "form", slug, reason: availability.reason }
|
|
1776
|
+
});
|
|
1777
|
+
}
|
|
1778
|
+
if (availability.kind === "closed") {
|
|
1779
|
+
return respondDoc({
|
|
1780
|
+
slug,
|
|
1781
|
+
status: "closed",
|
|
1782
|
+
closedMessage: availability.message
|
|
1783
|
+
});
|
|
1784
|
+
}
|
|
1785
|
+
return respondDoc(doc);
|
|
1786
|
+
}
|
|
1787
|
+
async function readSubmittedData(req, slug) {
|
|
1788
|
+
let parsed;
|
|
1789
|
+
try {
|
|
1790
|
+
parsed = await req.json();
|
|
1791
|
+
} catch {
|
|
1792
|
+
parsed = void 0;
|
|
1793
|
+
}
|
|
1794
|
+
const body = parsed;
|
|
1795
|
+
if (!body?.data || typeof body.data !== "object") {
|
|
1796
|
+
throw NextlyError3.validation({
|
|
1797
|
+
errors: [
|
|
1798
|
+
{
|
|
1799
|
+
path: "data",
|
|
1800
|
+
code: "MISSING_FIELD",
|
|
1801
|
+
message: "Request body must contain a 'data' object."
|
|
1802
|
+
}
|
|
1803
|
+
],
|
|
1804
|
+
logContext: { slug }
|
|
1805
|
+
});
|
|
1806
|
+
}
|
|
1807
|
+
return body.data;
|
|
1808
|
+
}
|
|
1809
|
+
function refusalFor(result, slug) {
|
|
1810
|
+
const entity = { entity: "form", slug };
|
|
1811
|
+
switch (result.outcome) {
|
|
1812
|
+
case "no-such-form":
|
|
1813
|
+
return NextlyError3.notFound({
|
|
1814
|
+
message: NO_SUCH_FORM2,
|
|
1815
|
+
logContext: entity
|
|
1816
|
+
});
|
|
1817
|
+
// A state conflict, not a validation failure. `validation` fixes the
|
|
1818
|
+
// canonical `error.message` to "Validation failed." and nests the real one
|
|
1819
|
+
// under `error.data.errors[0]`, so a client reading the documented envelope
|
|
1820
|
+
// would be handed the author's explanation and never show it.
|
|
1821
|
+
case "closed":
|
|
1822
|
+
return NextlyError3.conflict({
|
|
1823
|
+
reason: "state",
|
|
1824
|
+
message: result.error ?? NO_SUCH_FORM2,
|
|
1825
|
+
logContext: { ...entity, reason: "closed" }
|
|
1826
|
+
});
|
|
1827
|
+
case "duplicate":
|
|
1828
|
+
return NextlyError3.conflict({
|
|
1829
|
+
reason: "state",
|
|
1830
|
+
message: result.error ?? "You have already submitted this form.",
|
|
1831
|
+
logContext: { ...entity, reason: "duplicate" }
|
|
1832
|
+
});
|
|
1833
|
+
// The codes come from the issue list, not from the message map beside it.
|
|
1834
|
+
// A client tells "you left this blank" from "this is not an email" by the
|
|
1835
|
+
// code, and reporting every failure as `INVALID` stops that working while
|
|
1836
|
+
// looking correct in a status-only test.
|
|
1837
|
+
case "invalid":
|
|
1838
|
+
return NextlyError3.validation({
|
|
1839
|
+
errors: (result.validationIssues ?? []).map((issue) => ({
|
|
1840
|
+
path: issue.path,
|
|
1841
|
+
code: issue.code,
|
|
1842
|
+
message: issue.message
|
|
1843
|
+
})),
|
|
1844
|
+
logContext: { slug }
|
|
1845
|
+
});
|
|
1846
|
+
// A typed refusal is re-thrown as itself. The collection may refuse a write
|
|
1847
|
+
// for a reason its host chose, and those carry their own status and field
|
|
1848
|
+
// detail; answering all of them with one internal error tells a visitor the
|
|
1849
|
+
// server broke when their submission was in fact answered deliberately.
|
|
1850
|
+
// Anything untyped stays a 500, which is what an unexpected failure is.
|
|
1851
|
+
default:
|
|
1852
|
+
if (result.cause instanceof NextlyError3) return result.cause;
|
|
1853
|
+
return NextlyError3.internal({
|
|
1854
|
+
logContext: { ...entity, reason: "submission-failed" }
|
|
1855
|
+
});
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
async function acceptSubmission(req, ctx, config, slug) {
|
|
1859
|
+
const data = await readSubmittedData(req, slug);
|
|
1860
|
+
const result = await submitForm(
|
|
1861
|
+
{
|
|
1862
|
+
formSlug: slug,
|
|
1863
|
+
data,
|
|
1864
|
+
// The visitor's language, from `?locale=` on the submit URL — the same
|
|
1865
|
+
// place core's own routes read a locale on the wire — so a picked
|
|
1866
|
+
// redirect page answers with its URL in that language. A form on a
|
|
1867
|
+
// French page posts to `/api/forms/contact/submit?locale=fr` and is
|
|
1868
|
+
// sent to `/merci`; one that names no locale is answered in the
|
|
1869
|
+
// default, as before. An unconfigured code reads as the default rather
|
|
1870
|
+
// than refusing the submission: the visitor filled the form in, and a
|
|
1871
|
+
// wrong query string is not a reason to lose what they wrote.
|
|
1872
|
+
locale: new URL(req.url).searchParams.get("locale") || void 0,
|
|
1873
|
+
// The request itself, so the write seam resolves the client under this
|
|
1874
|
+
// deployment's proxy-trust settings and judges this submission on the
|
|
1875
|
+
// same facts as one arriving through any other door.
|
|
1876
|
+
request: req,
|
|
1877
|
+
// The address for the audit column and the single-submission check,
|
|
1878
|
+
// resolved by the core rather than read off a header here, which would
|
|
1879
|
+
// record whatever the sender chose to claim and let one visitor pass as
|
|
1880
|
+
// many.
|
|
1881
|
+
metadata: {
|
|
1882
|
+
ipAddress: trustedClientIp(req) ?? void 0,
|
|
1883
|
+
userAgent: req.headers.get("user-agent") ?? void 0
|
|
1884
|
+
},
|
|
1885
|
+
access: PUBLIC
|
|
1886
|
+
},
|
|
1887
|
+
{
|
|
1888
|
+
pluginContext: ctx,
|
|
1889
|
+
pluginConfig: configForInstall(config, ctx.self)
|
|
1890
|
+
}
|
|
1891
|
+
);
|
|
1892
|
+
if (result.outcome !== "accepted") throw refusalFor(result, slug);
|
|
1893
|
+
return respondAction(
|
|
1894
|
+
result.successMessage ?? DEFAULT_SUCCESS_MESSAGE,
|
|
1895
|
+
{
|
|
1896
|
+
...result.submission ? { submissionId: result.submission.id } : {},
|
|
1897
|
+
// Forwarded because this route is the only thing that can act on it. A
|
|
1898
|
+
// form configured to redirect on success resolves one here, and core's
|
|
1899
|
+
// endpoint dropped it, so the setting did nothing for any client posting
|
|
1900
|
+
// over HTTP. Additive, and present on a flagged submission too, so it
|
|
1901
|
+
// cannot be diffed against an accepted one.
|
|
1902
|
+
...result.redirect ? { redirect: result.redirect } : {}
|
|
1903
|
+
},
|
|
1904
|
+
{ status: 201 }
|
|
1905
|
+
);
|
|
1906
|
+
}
|
|
1907
|
+
function publicFormRoutes(config) {
|
|
1908
|
+
return [
|
|
1909
|
+
{
|
|
1910
|
+
method: "GET",
|
|
1911
|
+
path: "/forms",
|
|
1912
|
+
mount: "root",
|
|
1913
|
+
public: true,
|
|
1914
|
+
// Collection documents, so their stored timestamps are presented in the
|
|
1915
|
+
// installation's timezone exactly as the built-in read did.
|
|
1916
|
+
formatTimestamps: true,
|
|
1917
|
+
handler: (req, ctx) => listPublishedForms(req, ctx, config)
|
|
1918
|
+
},
|
|
1919
|
+
{
|
|
1920
|
+
method: "GET",
|
|
1921
|
+
path: "/forms/:slug",
|
|
1922
|
+
mount: "root",
|
|
1923
|
+
public: true,
|
|
1924
|
+
formatTimestamps: true,
|
|
1925
|
+
handler: (_req, ctx) => readFormBySlug(ctx.params.slug, ctx, config)
|
|
1926
|
+
},
|
|
1927
|
+
{
|
|
1928
|
+
method: "POST",
|
|
1929
|
+
path: "/forms/:slug/submit",
|
|
1930
|
+
mount: "root",
|
|
1931
|
+
public: true,
|
|
1932
|
+
handler: (req, ctx) => acceptSubmission(req, ctx, config, ctx.params.slug)
|
|
1933
|
+
}
|
|
1934
|
+
];
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
// src/plugin.ts
|
|
1938
|
+
var { version: PLUGIN_VERSION } = createRequire(import.meta.url)(
|
|
1939
|
+
"../package.json"
|
|
1940
|
+
);
|
|
1941
|
+
function resolveConfig(options) {
|
|
1942
|
+
const formOverrides = options.formOverrides || {};
|
|
1943
|
+
const submissionOverrides = options.formSubmissionOverrides || {};
|
|
1944
|
+
return {
|
|
1945
|
+
formOverrides: {
|
|
1946
|
+
...formOverrides,
|
|
1947
|
+
slug: formOverrides.slug || "forms",
|
|
1948
|
+
labels: {
|
|
1949
|
+
singular: formOverrides.labels?.singular || "Form",
|
|
1950
|
+
plural: formOverrides.labels?.plural || "Forms"
|
|
1951
|
+
}
|
|
1952
|
+
},
|
|
1953
|
+
formSubmissionOverrides: {
|
|
1954
|
+
...submissionOverrides,
|
|
1955
|
+
slug: submissionOverrides.slug || "form-submissions",
|
|
1956
|
+
labels: {
|
|
1957
|
+
singular: submissionOverrides.labels?.singular || "Submission",
|
|
1958
|
+
plural: submissionOverrides.labels?.plural || "Submissions"
|
|
1959
|
+
}
|
|
1960
|
+
},
|
|
1961
|
+
fields: {
|
|
1962
|
+
text: options.fields?.text ?? true,
|
|
1963
|
+
email: options.fields?.email ?? true,
|
|
1964
|
+
number: options.fields?.number ?? true,
|
|
1965
|
+
phone: options.fields?.phone ?? true,
|
|
1966
|
+
url: options.fields?.url ?? true,
|
|
1967
|
+
textarea: options.fields?.textarea ?? true,
|
|
1968
|
+
select: options.fields?.select ?? true,
|
|
1969
|
+
checkbox: options.fields?.checkbox ?? true,
|
|
1970
|
+
radio: options.fields?.radio ?? true,
|
|
1971
|
+
file: options.fields?.file ?? true,
|
|
1972
|
+
date: options.fields?.date ?? true,
|
|
1973
|
+
time: options.fields?.time ?? true,
|
|
1974
|
+
hidden: options.fields?.hidden ?? true
|
|
1975
|
+
},
|
|
1976
|
+
redirectRelationships: normalizeRedirectRelationships(
|
|
1977
|
+
options.redirectRelationships
|
|
1978
|
+
),
|
|
1979
|
+
// Filled at `init` from each collection's own metadata. Empty until then,
|
|
1980
|
+
// and a slug that stays absent reads as unknown rather than as not
|
|
1981
|
+
// localized — see `ResolvedFormBuilderConfig`.
|
|
1982
|
+
redirectTargetLocalization: {},
|
|
1983
|
+
beforeEmail: options.beforeEmail,
|
|
1984
|
+
notifications: {
|
|
1985
|
+
defaultFrom: options.notifications?.defaultFrom,
|
|
1986
|
+
defaultToEmail: options.notifications?.defaultToEmail,
|
|
1987
|
+
enabled: options.notifications?.enabled ?? true
|
|
1988
|
+
},
|
|
1989
|
+
spamProtection: {
|
|
1990
|
+
honeypot: options.spamProtection?.honeypot ?? true,
|
|
1991
|
+
recaptcha: options.spamProtection?.recaptcha ?? { enabled: false },
|
|
1992
|
+
rateLimit: {
|
|
1993
|
+
maxSubmissions: options.spamProtection?.rateLimit?.maxSubmissions ?? 10,
|
|
1994
|
+
windowMs: options.spamProtection?.rateLimit?.windowMs ?? 6e4
|
|
1995
|
+
}
|
|
1996
|
+
},
|
|
1997
|
+
uploads: {
|
|
1998
|
+
maxFileSize: options.uploads?.maxFileSize ?? 10485760,
|
|
1999
|
+
// 10 MB
|
|
2000
|
+
allowedMimeTypes: options.uploads?.allowedMimeTypes ?? [
|
|
2001
|
+
"image/*",
|
|
2002
|
+
"application/pdf",
|
|
2003
|
+
"text/*"
|
|
2004
|
+
],
|
|
2005
|
+
uploadCollection: options.uploads?.uploadCollection ?? "media"
|
|
2006
|
+
},
|
|
2007
|
+
features: {
|
|
2008
|
+
builder: options.features?.builder ?? true,
|
|
2009
|
+
conditionalLogic: options.features?.conditionalLogic ?? true,
|
|
2010
|
+
fileUploads: options.features?.fileUploads ?? true
|
|
2011
|
+
}
|
|
2012
|
+
};
|
|
2013
|
+
}
|
|
2014
|
+
function formBuilder(options = {}) {
|
|
2015
|
+
const resolvedConfig = resolveConfig(options);
|
|
2016
|
+
const formsCol = formsCollection(resolvedConfig);
|
|
2017
|
+
const submissionsCol = submissionsCollection(resolvedConfig);
|
|
2018
|
+
const plugin = definePlugin({
|
|
2019
|
+
name: "@nextlyhq/plugin-form-builder",
|
|
2020
|
+
version: PLUGIN_VERSION,
|
|
2021
|
+
nextly: ">=0.0.2-alpha.21",
|
|
2022
|
+
// Identity metadata for the admin plugins page, mirroring package.json.
|
|
2023
|
+
author: "Nextly",
|
|
2024
|
+
homepage: "https://nextlyhq.com",
|
|
2025
|
+
repository: "https://github.com/nextlyhq/nextly",
|
|
2026
|
+
license: "MIT",
|
|
2027
|
+
category: "forms",
|
|
2028
|
+
tags: ["forms", "submissions", "email-notifications"],
|
|
2029
|
+
// Declarative schema: the merged pipeline folds these into the
|
|
2030
|
+
// app schema — no manual `setup()` append needed. Just register the plugin.
|
|
2031
|
+
contributes: {
|
|
2032
|
+
collections: [formsCol, submissionsCol],
|
|
2033
|
+
// Custom permission — gates submission export beyond CRUD. The
|
|
2034
|
+
// canonical example third-party plugin authors copy.
|
|
2035
|
+
permissions: [
|
|
2036
|
+
{
|
|
2037
|
+
action: "export",
|
|
2038
|
+
resource: "submissions",
|
|
2039
|
+
label: "Export Submissions",
|
|
2040
|
+
description: "Export form submissions as CSV/JSON",
|
|
2041
|
+
// No `group`: the admin already files this under the plugin that
|
|
2042
|
+
// declared it, so naming the plugin again would nest it inside
|
|
2043
|
+
// itself. `group` is for a plugin with enough permissions to sort
|
|
2044
|
+
// its own into headings, which one is not.
|
|
2045
|
+
//
|
|
2046
|
+
// `danger` because the point of the permission is to take
|
|
2047
|
+
// submissions — names, emails, whatever a form asked for — out of
|
|
2048
|
+
// the site in a file.
|
|
2049
|
+
danger: true
|
|
2050
|
+
}
|
|
2051
|
+
],
|
|
2052
|
+
// HTTP route — exported at
|
|
2053
|
+
// /api/plugins/@nextlyhq/plugin-form-builder/submissions/export. Secure by
|
|
2054
|
+
// default: gated by the custom `export-submissions` permission. Reads
|
|
2055
|
+
// via the secure-by-default service path as the authed user and
|
|
2056
|
+
// resolves its OWN slug through `ctx.self`. The canonical
|
|
2057
|
+
// contributes.routes example for third-party authors.
|
|
2058
|
+
routes: [
|
|
2059
|
+
// The public form endpoints, at the addresses core used to serve
|
|
2060
|
+
// before this plugin took them over. Root-mounted, so no caller's URL
|
|
2061
|
+
// changes; see `routes/public-forms`.
|
|
2062
|
+
...publicFormRoutes(resolvedConfig),
|
|
2063
|
+
{
|
|
2064
|
+
method: "GET",
|
|
2065
|
+
path: "/submissions/export",
|
|
2066
|
+
requiredPermission: "export-submissions",
|
|
2067
|
+
handler: async (req, ctx) => {
|
|
2068
|
+
const declaredSlug = resolvedConfig.formSubmissionOverrides.slug;
|
|
2069
|
+
const slug = ctx.self.collections[declaredSlug] ?? declaredSlug;
|
|
2070
|
+
const url2 = new URL(req.url);
|
|
2071
|
+
const format = url2.searchParams.get("format") ?? "json";
|
|
2072
|
+
const formId = url2.searchParams.get("form");
|
|
2073
|
+
const status = url2.searchParams.get("status");
|
|
2074
|
+
const where = {};
|
|
2075
|
+
if (formId) where.form = { equals: formId };
|
|
2076
|
+
if (status) where.status = { equals: status };
|
|
2077
|
+
else where.status = { not_equals: "spam" };
|
|
2078
|
+
const opts = { as: "user", user: ctx.user ?? void 0 };
|
|
2079
|
+
let form;
|
|
2080
|
+
if (format === "csv") {
|
|
2081
|
+
if (!formId) {
|
|
2082
|
+
return Response.json(
|
|
2083
|
+
{
|
|
2084
|
+
error: {
|
|
2085
|
+
code: "VALIDATION_ERROR",
|
|
2086
|
+
message: "CSV export requires a form parameter."
|
|
2087
|
+
}
|
|
2088
|
+
},
|
|
2089
|
+
{ status: 400 }
|
|
2090
|
+
);
|
|
2091
|
+
}
|
|
2092
|
+
const formsSlugDeclared = resolvedConfig.formOverrides.slug;
|
|
2093
|
+
const formsSlug = ctx.self.collections[formsSlugDeclared] ?? formsSlugDeclared;
|
|
2094
|
+
const formResult = await ctx.services.collections.listEntries(
|
|
2095
|
+
formsSlug,
|
|
2096
|
+
{ where: { id: { equals: formId } } },
|
|
1367
2097
|
opts
|
|
1368
2098
|
);
|
|
1369
2099
|
form = formResult.data[0];
|
|
@@ -1541,30 +2271,11 @@ function formBuilder(options = {}) {
|
|
|
1541
2271
|
}
|
|
1542
2272
|
return ctx.data;
|
|
1543
2273
|
});
|
|
1544
|
-
nextly.hooks.on(
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
const form = record;
|
|
1550
|
-
if (typeof form.id !== "string") return;
|
|
1551
|
-
try {
|
|
1552
|
-
form.submissionCount = await nextly.services.collections.count(
|
|
1553
|
-
submissionSlug,
|
|
1554
|
-
{
|
|
1555
|
-
where: {
|
|
1556
|
-
form: { equals: form.id },
|
|
1557
|
-
status: { not_equals: "spam" }
|
|
1558
|
-
}
|
|
1559
|
-
},
|
|
1560
|
-
{ as: "system" }
|
|
1561
|
-
);
|
|
1562
|
-
} catch {
|
|
1563
|
-
form.submissionCount = 0;
|
|
1564
|
-
}
|
|
1565
|
-
})
|
|
1566
|
-
);
|
|
1567
|
-
});
|
|
2274
|
+
nextly.hooks.on(
|
|
2275
|
+
"afterRead",
|
|
2276
|
+
formsSlug,
|
|
2277
|
+
(context) => injectSubmissionCount(context, submissionSlug, nextly)
|
|
2278
|
+
);
|
|
1568
2279
|
}
|
|
1569
2280
|
});
|
|
1570
2281
|
return {
|
|
@@ -1690,7 +2401,7 @@ function submittedPayload(raw) {
|
|
|
1690
2401
|
} else if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
1691
2402
|
return raw;
|
|
1692
2403
|
}
|
|
1693
|
-
throw
|
|
2404
|
+
throw NextlyError4.validation({
|
|
1694
2405
|
errors: [
|
|
1695
2406
|
{
|
|
1696
2407
|
path: "data",
|
|
@@ -1720,30 +2431,39 @@ async function prepareSubmissionForWrite(context, formsSlug, nextly) {
|
|
|
1720
2431
|
submission.data !== void 0 ? submission.data : stored?.data
|
|
1721
2432
|
);
|
|
1722
2433
|
const marks = submissionMarks(submission);
|
|
1723
|
-
const
|
|
2434
|
+
const form = await formToCheckAgainst(marks, formsSlug, formId, nextly);
|
|
2435
|
+
const fields = form.fields;
|
|
2436
|
+
const spam = ctx.operation === "create" && ctx.req?.http ? await judgeSubmission(incoming, form, ctx.req.http, nextly) : void 0;
|
|
2437
|
+
if (spam?.reason === "rate_limit") {
|
|
2438
|
+
throw NextlyError4.rateLimited();
|
|
2439
|
+
}
|
|
1724
2440
|
const prepared = prepareSubmission({
|
|
1725
2441
|
data: incoming,
|
|
1726
2442
|
fields,
|
|
1727
|
-
validate: marks?.keepAsEvidence !== true
|
|
2443
|
+
validate: marks?.keepAsEvidence !== true && spam === void 0
|
|
1728
2444
|
});
|
|
1729
2445
|
if (prepared.validationErrors) {
|
|
1730
|
-
throw
|
|
2446
|
+
throw NextlyError4.validation({
|
|
1731
2447
|
errors: Object.entries(prepared.validationErrors).map(
|
|
1732
2448
|
([path, message]) => ({ path, code: "INVALID", message })
|
|
1733
2449
|
)
|
|
1734
2450
|
});
|
|
1735
2451
|
}
|
|
1736
2452
|
ctx.data = { ...submission, data: prepared.data };
|
|
2453
|
+
if (spam) {
|
|
2454
|
+
ctx.data.status = "spam";
|
|
2455
|
+
ctx.data.spamReason = spam.reason ?? null;
|
|
2456
|
+
}
|
|
1737
2457
|
if (ctx.operation !== "create" && submission.data === void 0) {
|
|
1738
2458
|
stampDerivedEdit(ctx.data, ctx.user, incoming, prepared.data);
|
|
1739
2459
|
}
|
|
1740
2460
|
return ctx.data;
|
|
1741
2461
|
}
|
|
1742
|
-
async function
|
|
2462
|
+
async function formToCheckAgainst(marks, formsSlug, formId, nextly) {
|
|
1743
2463
|
const handedOver = marks?.form?.id === formId ? marks.form : null;
|
|
1744
2464
|
const form = handedOver ?? await fetchParentForm(formsSlug, formId, nextly);
|
|
1745
2465
|
if (!form || !Array.isArray(form.fields)) {
|
|
1746
|
-
throw
|
|
2466
|
+
throw NextlyError4.validation({
|
|
1747
2467
|
errors: [
|
|
1748
2468
|
{
|
|
1749
2469
|
path: "form",
|
|
@@ -1753,7 +2473,32 @@ async function fieldsToCheckAgainst(marks, formsSlug, formId, nextly) {
|
|
|
1753
2473
|
]
|
|
1754
2474
|
});
|
|
1755
2475
|
}
|
|
1756
|
-
return form
|
|
2476
|
+
return form;
|
|
2477
|
+
}
|
|
2478
|
+
function rateLimitKeyFor(form) {
|
|
2479
|
+
const { id } = form;
|
|
2480
|
+
return typeof id === "string" && id.length > 0 ? id : "unidentified-form";
|
|
2481
|
+
}
|
|
2482
|
+
async function judgeSubmission(payload, form, http, nextly) {
|
|
2483
|
+
const config = getFormBuilderConfig(nextly);
|
|
2484
|
+
if (!config) return void 0;
|
|
2485
|
+
const declared = new Set(
|
|
2486
|
+
(Array.isArray(form.fields) ? form.fields : []).map((field) => field.name).filter((name) => typeof name === "string")
|
|
2487
|
+
);
|
|
2488
|
+
const undeclared = Object.fromEntries(
|
|
2489
|
+
Object.entries(payload).filter(([key]) => !declared.has(key))
|
|
2490
|
+
);
|
|
2491
|
+
const settings = form.settings ?? {};
|
|
2492
|
+
const verdict = await checkSpam({
|
|
2493
|
+
data: undeclared,
|
|
2494
|
+
ipAddress: http.ip ?? void 0,
|
|
2495
|
+
formSlug: rateLimitKeyFor(form),
|
|
2496
|
+
config: {
|
|
2497
|
+
honeypot: settings.honeypotEnabled ?? config.spamProtection.honeypot,
|
|
2498
|
+
rateLimit: config.spamProtection.rateLimit
|
|
2499
|
+
}
|
|
2500
|
+
});
|
|
2501
|
+
return verdict.isSpam ? verdict : void 0;
|
|
1757
2502
|
}
|
|
1758
2503
|
function stampDerivedEdit(row, user, before, after) {
|
|
1759
2504
|
if (sameSubmittedPayload(before, after)) return;
|
|
@@ -1836,16 +2581,42 @@ async function handleSubmissionCreated(context, config, nextly) {
|
|
|
1836
2581
|
}
|
|
1837
2582
|
}
|
|
1838
2583
|
}
|
|
2584
|
+
async function injectSubmissionCount(context, submissionSlug, nextly) {
|
|
2585
|
+
const hook = context;
|
|
2586
|
+
if (hook.context?.[SCHEMA_ONLY_READ] === true) return;
|
|
2587
|
+
const records = Array.isArray(hook.data) ? hook.data : hook.data ? [hook.data] : [];
|
|
2588
|
+
await Promise.all(
|
|
2589
|
+
records.map(async (record) => {
|
|
2590
|
+
const form = record;
|
|
2591
|
+
if (typeof form.id !== "string") return;
|
|
2592
|
+
try {
|
|
2593
|
+
form.submissionCount = await nextly.services.collections.count(
|
|
2594
|
+
submissionSlug,
|
|
2595
|
+
{
|
|
2596
|
+
where: {
|
|
2597
|
+
form: { equals: form.id },
|
|
2598
|
+
status: { not_equals: "spam" }
|
|
2599
|
+
}
|
|
2600
|
+
},
|
|
2601
|
+
{ as: "system" }
|
|
2602
|
+
);
|
|
2603
|
+
} catch {
|
|
2604
|
+
form.submissionCount = 0;
|
|
2605
|
+
}
|
|
2606
|
+
})
|
|
2607
|
+
);
|
|
2608
|
+
}
|
|
2609
|
+
var SCHEMA_ONLY_READ = "formBuilder.schemaOnlyRead";
|
|
1839
2610
|
async function fetchParentForm(formsSlug, formId, nextly) {
|
|
1840
2611
|
try {
|
|
1841
2612
|
const form = await nextly.services.collections.findEntryById(
|
|
1842
2613
|
formsSlug,
|
|
1843
2614
|
formId,
|
|
1844
|
-
{ as: "system" }
|
|
2615
|
+
{ as: "system", context: { [SCHEMA_ONLY_READ]: true } }
|
|
1845
2616
|
);
|
|
1846
2617
|
return form;
|
|
1847
2618
|
} catch (err) {
|
|
1848
|
-
if (!
|
|
2619
|
+
if (!NextlyError4.isNotFound(err)) throw err;
|
|
1849
2620
|
nextly.logger.error?.("Form Builder: form not found", {
|
|
1850
2621
|
formId,
|
|
1851
2622
|
error: err instanceof Error ? err.message : String(err)
|
|
@@ -1909,834 +2680,383 @@ function validateSlug(slug, errors) {
|
|
|
1909
2680
|
code: "SLUG_TOO_LONG"
|
|
1910
2681
|
});
|
|
1911
2682
|
}
|
|
1912
|
-
if (!SLUG_PATTERN.test(slug)) {
|
|
1913
|
-
errors.push({
|
|
1914
|
-
path,
|
|
1915
|
-
message: "Form slug must start with a lowercase letter and contain only lowercase letters, numbers, hyphens, and underscores",
|
|
1916
|
-
code: "SLUG_INVALID_FORMAT"
|
|
1917
|
-
});
|
|
1918
|
-
}
|
|
1919
|
-
if (isReservedSlug(slug)) {
|
|
1920
|
-
errors.push({
|
|
1921
|
-
path,
|
|
1922
|
-
message: `Form slug '${slug}' is reserved and cannot be used`,
|
|
1923
|
-
code: "SLUG_RESERVED"
|
|
1924
|
-
});
|
|
1925
|
-
}
|
|
1926
|
-
}
|
|
1927
|
-
function validateFieldName(name, path, errors, seenNames) {
|
|
1928
|
-
if (!name) {
|
|
1929
|
-
errors.push({
|
|
1930
|
-
path: `${path}.name`,
|
|
1931
|
-
message: "Field name is required",
|
|
1932
|
-
code: "FIELD_NAME_REQUIRED"
|
|
1933
|
-
});
|
|
1934
|
-
return;
|
|
1935
|
-
}
|
|
1936
|
-
if (typeof name !== "string") {
|
|
1937
|
-
errors.push({
|
|
1938
|
-
path: `${path}.name`,
|
|
1939
|
-
message: "Field name must be a string",
|
|
1940
|
-
code: "FIELD_NAME_REQUIRED"
|
|
1941
|
-
});
|
|
1942
|
-
return;
|
|
1943
|
-
}
|
|
1944
|
-
if (!FIELD_NAME_PATTERN.test(name)) {
|
|
1945
|
-
errors.push({
|
|
1946
|
-
path: `${path}.name`,
|
|
1947
|
-
message: `Field name '${name}' must start with a letter and contain only letters, numbers, and underscores`,
|
|
1948
|
-
code: "FIELD_NAME_INVALID_FORMAT"
|
|
1949
|
-
});
|
|
1950
|
-
}
|
|
1951
|
-
const nameLower = name.toLowerCase();
|
|
1952
|
-
if (seenNames.has(nameLower)) {
|
|
1953
|
-
errors.push({
|
|
1954
|
-
path: `${path}.name`,
|
|
1955
|
-
message: `Duplicate field name '${name}'`,
|
|
1956
|
-
code: "FIELD_NAME_DUPLICATE"
|
|
1957
|
-
});
|
|
1958
|
-
} else {
|
|
1959
|
-
seenNames.add(nameLower);
|
|
1960
|
-
}
|
|
1961
|
-
}
|
|
1962
|
-
function validateFieldLabel(label, path, errors) {
|
|
1963
|
-
if (!label || typeof label === "string" && label.trim() === "") {
|
|
1964
|
-
errors.push({
|
|
1965
|
-
path: `${path}.label`,
|
|
1966
|
-
message: "Field label is required",
|
|
1967
|
-
code: "FIELD_LABEL_REQUIRED"
|
|
1968
|
-
});
|
|
1969
|
-
}
|
|
1970
|
-
}
|
|
1971
|
-
function validateOptions(field, path, errors) {
|
|
1972
|
-
const fieldWithOptions = field;
|
|
1973
|
-
const options = fieldWithOptions.options;
|
|
1974
|
-
if (!options) {
|
|
1975
|
-
errors.push({
|
|
1976
|
-
path: `${path}.options`,
|
|
1977
|
-
message: `${field.type} field must have an 'options' array`,
|
|
1978
|
-
code: "OPTIONS_REQUIRED"
|
|
1979
|
-
});
|
|
1980
|
-
return;
|
|
1981
|
-
}
|
|
1982
|
-
if (!Array.isArray(options)) {
|
|
1983
|
-
errors.push({
|
|
1984
|
-
path: `${path}.options`,
|
|
1985
|
-
message: `${field.type} field 'options' must be an array`,
|
|
1986
|
-
code: "OPTIONS_REQUIRED"
|
|
1987
|
-
});
|
|
1988
|
-
return;
|
|
1989
|
-
}
|
|
1990
|
-
if (options.length === 0) {
|
|
1991
|
-
errors.push({
|
|
1992
|
-
path: `${path}.options`,
|
|
1993
|
-
message: `${field.type} field must have at least one option`,
|
|
1994
|
-
code: "OPTIONS_EMPTY"
|
|
1995
|
-
});
|
|
1996
|
-
}
|
|
1997
|
-
}
|
|
1998
|
-
function validateConditionalLogic(field, path, errors) {
|
|
1999
|
-
const { conditionalLogic } = field;
|
|
2000
|
-
if (!conditionalLogic || !conditionalLogic.enabled) {
|
|
2001
|
-
return;
|
|
2002
|
-
}
|
|
2003
|
-
if (!conditionalLogic.conditions || conditionalLogic.conditions.length === 0) {
|
|
2004
|
-
errors.push({
|
|
2005
|
-
path: `${path}.conditionalLogic.conditions`,
|
|
2006
|
-
message: "Conditional logic must have at least one condition",
|
|
2007
|
-
code: "CONDITIONS_REQUIRED"
|
|
2008
|
-
});
|
|
2009
|
-
return;
|
|
2010
|
-
}
|
|
2011
|
-
conditionalLogic.conditions.forEach((condition, index) => {
|
|
2012
|
-
const conditionPath = `${path}.conditionalLogic.conditions[${index}]`;
|
|
2013
|
-
if (!condition.field) {
|
|
2014
|
-
errors.push({
|
|
2015
|
-
path: `${conditionPath}.field`,
|
|
2016
|
-
message: "Condition must specify a field name",
|
|
2017
|
-
code: "CONDITION_FIELD_REQUIRED"
|
|
2018
|
-
});
|
|
2019
|
-
}
|
|
2020
|
-
if (!condition.comparison) {
|
|
2021
|
-
errors.push({
|
|
2022
|
-
path: `${conditionPath}.comparison`,
|
|
2023
|
-
message: "Condition must specify a comparison operator",
|
|
2024
|
-
code: "CONDITION_COMPARISON_REQUIRED"
|
|
2025
|
-
});
|
|
2026
|
-
}
|
|
2027
|
-
});
|
|
2028
|
-
}
|
|
2029
|
-
function validateFieldByType(field, path, errors) {
|
|
2030
|
-
switch (field.type) {
|
|
2031
|
-
case "select":
|
|
2032
|
-
case "radio":
|
|
2033
|
-
validateOptions(field, path, errors);
|
|
2034
|
-
break;
|
|
2035
|
-
case "number": {
|
|
2036
|
-
const numberField = field;
|
|
2037
|
-
if (numberField.validation) {
|
|
2038
|
-
const { min, max } = numberField.validation;
|
|
2039
|
-
if (min !== void 0 && max !== void 0 && min > max) {
|
|
2040
|
-
errors.push({
|
|
2041
|
-
path: `${path}.validation`,
|
|
2042
|
-
message: "Min value cannot be greater than max value",
|
|
2043
|
-
code: "MIN_GREATER_THAN_MAX"
|
|
2044
|
-
});
|
|
2045
|
-
}
|
|
2046
|
-
}
|
|
2047
|
-
break;
|
|
2048
|
-
}
|
|
2049
|
-
case "text":
|
|
2050
|
-
case "textarea": {
|
|
2051
|
-
const textField = field;
|
|
2052
|
-
if (textField.validation) {
|
|
2053
|
-
const { minLength, maxLength } = textField.validation;
|
|
2054
|
-
if (minLength !== void 0 && maxLength !== void 0 && minLength > maxLength) {
|
|
2055
|
-
errors.push({
|
|
2056
|
-
path: `${path}.validation`,
|
|
2057
|
-
message: "Min length cannot be greater than max length",
|
|
2058
|
-
code: "MIN_LENGTH_GREATER_THAN_MAX"
|
|
2059
|
-
});
|
|
2060
|
-
}
|
|
2061
|
-
}
|
|
2062
|
-
break;
|
|
2063
|
-
}
|
|
2064
|
-
case "file": {
|
|
2065
|
-
const fileField = field;
|
|
2066
|
-
if (fileField.maxFileSize !== void 0 && fileField.maxFileSize <= 0) {
|
|
2067
|
-
errors.push({
|
|
2068
|
-
path: `${path}.maxFileSize`,
|
|
2069
|
-
message: "File maxFileSize must be a positive number",
|
|
2070
|
-
code: "FILE_MAX_SIZE_INVALID"
|
|
2071
|
-
});
|
|
2072
|
-
}
|
|
2073
|
-
break;
|
|
2074
|
-
}
|
|
2075
|
-
}
|
|
2076
|
-
}
|
|
2077
|
-
function validateField(field, path, errors, seenNames) {
|
|
2078
|
-
if (!field || typeof field !== "object") {
|
|
2079
|
-
return;
|
|
2080
|
-
}
|
|
2081
|
-
const f = field;
|
|
2082
|
-
const fieldType = f.type;
|
|
2083
|
-
if (!fieldType) {
|
|
2084
|
-
errors.push({
|
|
2085
|
-
path: `${path}.type`,
|
|
2086
|
-
message: "Field type is required",
|
|
2087
|
-
code: "FIELD_TYPE_REQUIRED"
|
|
2088
|
-
});
|
|
2089
|
-
return;
|
|
2090
|
-
}
|
|
2091
|
-
const isValidType = typeof fieldType === "string" && (VALID_FIELD_TYPES_SET.has(fieldType) || isPluginFieldTypeOnSurface(fieldType, "forms"));
|
|
2092
|
-
if (!isValidType) {
|
|
2093
|
-
let fieldTypeRepr;
|
|
2094
|
-
if (typeof fieldType === "object" && fieldType !== null) {
|
|
2095
|
-
fieldTypeRepr = JSON.stringify(fieldType);
|
|
2096
|
-
} else {
|
|
2097
|
-
fieldTypeRepr = String(fieldType);
|
|
2098
|
-
}
|
|
2099
|
-
errors.push({
|
|
2100
|
-
path: `${path}.type`,
|
|
2101
|
-
message: `Invalid field type '${fieldTypeRepr}'. Valid types: ${VALID_FIELD_TYPES.join(", ")}`,
|
|
2102
|
-
code: "FIELD_TYPE_INVALID"
|
|
2103
|
-
});
|
|
2104
|
-
return;
|
|
2105
|
-
}
|
|
2106
|
-
validateFieldName(f.name, path, errors, seenNames);
|
|
2107
|
-
if (fieldType !== "hidden") {
|
|
2108
|
-
validateFieldLabel(f.label, path, errors);
|
|
2109
|
-
}
|
|
2110
|
-
validateFieldByType(field, path, errors);
|
|
2111
|
-
if (f.conditionalLogic) {
|
|
2112
|
-
validateConditionalLogic(field, path, errors);
|
|
2113
|
-
}
|
|
2114
|
-
}
|
|
2115
|
-
function validateFieldsArray(fields, basePath, errors) {
|
|
2116
|
-
const seenNames = /* @__PURE__ */ new Set();
|
|
2117
|
-
fields.forEach((field, index) => {
|
|
2118
|
-
const fieldPath = `${basePath}[${index}]`;
|
|
2119
|
-
validateField(field, fieldPath, errors, seenNames);
|
|
2120
|
-
});
|
|
2121
|
-
}
|
|
2122
|
-
function validateFields(fields, errors) {
|
|
2123
|
-
const path = "fields";
|
|
2124
|
-
if (!fields) {
|
|
2125
|
-
errors.push({
|
|
2126
|
-
path,
|
|
2127
|
-
message: "Form must have at least one field",
|
|
2128
|
-
code: "FIELDS_REQUIRED"
|
|
2129
|
-
});
|
|
2130
|
-
return;
|
|
2131
|
-
}
|
|
2132
|
-
if (!Array.isArray(fields)) {
|
|
2683
|
+
if (!SLUG_PATTERN.test(slug)) {
|
|
2133
2684
|
errors.push({
|
|
2134
2685
|
path,
|
|
2135
|
-
message: "Form
|
|
2136
|
-
code: "
|
|
2686
|
+
message: "Form slug must start with a lowercase letter and contain only lowercase letters, numbers, hyphens, and underscores",
|
|
2687
|
+
code: "SLUG_INVALID_FORMAT"
|
|
2137
2688
|
});
|
|
2138
|
-
return;
|
|
2139
2689
|
}
|
|
2140
|
-
if (
|
|
2690
|
+
if (isReservedSlug(slug)) {
|
|
2141
2691
|
errors.push({
|
|
2142
2692
|
path,
|
|
2143
|
-
message:
|
|
2144
|
-
code: "
|
|
2693
|
+
message: `Form slug '${slug}' is reserved and cannot be used`,
|
|
2694
|
+
code: "SLUG_RESERVED"
|
|
2145
2695
|
});
|
|
2146
|
-
return;
|
|
2147
2696
|
}
|
|
2148
|
-
validateFieldsArray(fields, path, errors);
|
|
2149
2697
|
}
|
|
2150
|
-
function
|
|
2151
|
-
if (!
|
|
2698
|
+
function validateFieldName(name, path, errors, seenNames) {
|
|
2699
|
+
if (!name) {
|
|
2700
|
+
errors.push({
|
|
2701
|
+
path: `${path}.name`,
|
|
2702
|
+
message: "Field name is required",
|
|
2703
|
+
code: "FIELD_NAME_REQUIRED"
|
|
2704
|
+
});
|
|
2152
2705
|
return;
|
|
2153
2706
|
}
|
|
2154
|
-
if (typeof
|
|
2707
|
+
if (typeof name !== "string") {
|
|
2155
2708
|
errors.push({
|
|
2156
|
-
path:
|
|
2157
|
-
message: "
|
|
2158
|
-
code: "
|
|
2709
|
+
path: `${path}.name`,
|
|
2710
|
+
message: "Field name must be a string",
|
|
2711
|
+
code: "FIELD_NAME_REQUIRED"
|
|
2159
2712
|
});
|
|
2160
2713
|
return;
|
|
2161
2714
|
}
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
code: "REDIRECT_URL_REQUIRED"
|
|
2169
|
-
});
|
|
2170
|
-
}
|
|
2715
|
+
if (!FIELD_NAME_PATTERN.test(name)) {
|
|
2716
|
+
errors.push({
|
|
2717
|
+
path: `${path}.name`,
|
|
2718
|
+
message: `Field name '${name}' must start with a letter and contain only letters, numbers, and underscores`,
|
|
2719
|
+
code: "FIELD_NAME_INVALID_FORMAT"
|
|
2720
|
+
});
|
|
2171
2721
|
}
|
|
2172
|
-
|
|
2722
|
+
const nameLower = name.toLowerCase();
|
|
2723
|
+
if (seenNames.has(nameLower)) {
|
|
2173
2724
|
errors.push({
|
|
2174
|
-
path:
|
|
2175
|
-
message:
|
|
2176
|
-
code: "
|
|
2725
|
+
path: `${path}.name`,
|
|
2726
|
+
message: `Duplicate field name '${name}'`,
|
|
2727
|
+
code: "FIELD_NAME_DUPLICATE"
|
|
2177
2728
|
});
|
|
2729
|
+
} else {
|
|
2730
|
+
seenNames.add(nameLower);
|
|
2178
2731
|
}
|
|
2179
2732
|
}
|
|
2180
|
-
function
|
|
2181
|
-
if (typeof
|
|
2182
|
-
|
|
2733
|
+
function validateFieldLabel(label, path, errors) {
|
|
2734
|
+
if (!label || typeof label === "string" && label.trim() === "") {
|
|
2735
|
+
errors.push({
|
|
2736
|
+
path: `${path}.label`,
|
|
2737
|
+
message: "Field label is required",
|
|
2738
|
+
code: "FIELD_LABEL_REQUIRED"
|
|
2739
|
+
});
|
|
2740
|
+
}
|
|
2183
2741
|
}
|
|
2184
|
-
function
|
|
2185
|
-
|
|
2742
|
+
function validateOptions(field, path, errors) {
|
|
2743
|
+
const fieldWithOptions = field;
|
|
2744
|
+
const options = fieldWithOptions.options;
|
|
2745
|
+
if (!options) {
|
|
2746
|
+
errors.push({
|
|
2747
|
+
path: `${path}.options`,
|
|
2748
|
+
message: `${field.type} field must have an 'options' array`,
|
|
2749
|
+
code: "OPTIONS_REQUIRED"
|
|
2750
|
+
});
|
|
2186
2751
|
return;
|
|
2187
2752
|
}
|
|
2188
|
-
if (
|
|
2753
|
+
if (!Array.isArray(options)) {
|
|
2189
2754
|
errors.push({
|
|
2190
|
-
path:
|
|
2191
|
-
message:
|
|
2192
|
-
code: "
|
|
2755
|
+
path: `${path}.options`,
|
|
2756
|
+
message: `${field.type} field 'options' must be an array`,
|
|
2757
|
+
code: "OPTIONS_REQUIRED"
|
|
2193
2758
|
});
|
|
2194
2759
|
return;
|
|
2195
2760
|
}
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
errors.push({
|
|
2202
|
-
path: `access.${key}`,
|
|
2203
|
-
message: `Access control '${key}' must be a function`,
|
|
2204
|
-
code: "ACCESS_FUNCTION_INVALID"
|
|
2205
|
-
});
|
|
2206
|
-
}
|
|
2207
|
-
}
|
|
2208
|
-
}
|
|
2209
|
-
function validateFormConfig(config) {
|
|
2210
|
-
const errors = [];
|
|
2211
|
-
validateSlug(config.slug, errors);
|
|
2212
|
-
validateFields(config.fields, errors);
|
|
2213
|
-
validateSettings(config.settings, errors);
|
|
2214
|
-
validateAccess(config.access, errors);
|
|
2215
|
-
return {
|
|
2216
|
-
valid: errors.length === 0,
|
|
2217
|
-
errors
|
|
2218
|
-
};
|
|
2219
|
-
}
|
|
2220
|
-
function assertValidFormConfig(config) {
|
|
2221
|
-
const result = validateFormConfig(config);
|
|
2222
|
-
if (!result.valid) {
|
|
2223
|
-
const errorMessages = result.errors.map((err) => ` - [${err.code}] ${err.path}: ${err.message}`).join("\n");
|
|
2224
|
-
throw new Error(
|
|
2225
|
-
`Invalid form config for '${config.slug || "unknown"}':
|
|
2226
|
-
${errorMessages}`
|
|
2227
|
-
);
|
|
2228
|
-
}
|
|
2229
|
-
}
|
|
2230
|
-
|
|
2231
|
-
// src/config/field-helpers.ts
|
|
2232
|
-
var option = (label, value) => ({
|
|
2233
|
-
label,
|
|
2234
|
-
value: value ?? label.toLowerCase().replace(/\s+/g, "_")
|
|
2235
|
-
});
|
|
2236
|
-
var text3 = (config) => ({
|
|
2237
|
-
...config,
|
|
2238
|
-
type: "text"
|
|
2239
|
-
});
|
|
2240
|
-
var email = (config) => ({
|
|
2241
|
-
...config,
|
|
2242
|
-
type: "email"
|
|
2243
|
-
});
|
|
2244
|
-
var phone = (config) => ({
|
|
2245
|
-
...config,
|
|
2246
|
-
type: "phone"
|
|
2247
|
-
});
|
|
2248
|
-
var url = (config) => ({
|
|
2249
|
-
...config,
|
|
2250
|
-
type: "url"
|
|
2251
|
-
});
|
|
2252
|
-
var textarea3 = (config) => ({
|
|
2253
|
-
...config,
|
|
2254
|
-
type: "textarea"
|
|
2255
|
-
});
|
|
2256
|
-
var number = (config) => ({
|
|
2257
|
-
...config,
|
|
2258
|
-
type: "number"
|
|
2259
|
-
});
|
|
2260
|
-
var select3 = (config) => ({
|
|
2261
|
-
...config,
|
|
2262
|
-
type: "select"
|
|
2263
|
-
});
|
|
2264
|
-
var checkbox2 = (config) => ({
|
|
2265
|
-
...config,
|
|
2266
|
-
type: "checkbox"
|
|
2267
|
-
});
|
|
2268
|
-
var radio = (config) => ({
|
|
2269
|
-
...config,
|
|
2270
|
-
type: "radio"
|
|
2271
|
-
});
|
|
2272
|
-
var file = (config) => ({
|
|
2273
|
-
...config,
|
|
2274
|
-
type: "file"
|
|
2275
|
-
});
|
|
2276
|
-
var date3 = (config) => ({
|
|
2277
|
-
...config,
|
|
2278
|
-
type: "date"
|
|
2279
|
-
});
|
|
2280
|
-
var time = (config) => ({
|
|
2281
|
-
...config,
|
|
2282
|
-
type: "time"
|
|
2283
|
-
});
|
|
2284
|
-
var hidden = (config) => ({
|
|
2285
|
-
...config,
|
|
2286
|
-
type: "hidden"
|
|
2287
|
-
});
|
|
2288
|
-
|
|
2289
|
-
// src/handlers/submit-form.ts
|
|
2290
|
-
import { formAvailability, NO_SUCH_FORM } from "nextly";
|
|
2291
|
-
|
|
2292
|
-
// src/handlers/spam-detection.ts
|
|
2293
|
-
var rateLimitStore = /* @__PURE__ */ new Map();
|
|
2294
|
-
async function checkSpam(options) {
|
|
2295
|
-
const { data, ipAddress, formSlug, config } = options;
|
|
2296
|
-
if (config.honeypot) {
|
|
2297
|
-
const honeypotResult = checkHoneypot(data);
|
|
2298
|
-
if (honeypotResult.isSpam) {
|
|
2299
|
-
return honeypotResult;
|
|
2300
|
-
}
|
|
2301
|
-
}
|
|
2302
|
-
if (config.rateLimit && ipAddress) {
|
|
2303
|
-
const rateLimitResult = checkRateLimit(
|
|
2304
|
-
ipAddress,
|
|
2305
|
-
formSlug,
|
|
2306
|
-
config.rateLimit
|
|
2307
|
-
);
|
|
2308
|
-
if (rateLimitResult.isSpam) {
|
|
2309
|
-
return rateLimitResult;
|
|
2310
|
-
}
|
|
2311
|
-
}
|
|
2312
|
-
return { isSpam: false };
|
|
2313
|
-
}
|
|
2314
|
-
var HONEYPOT_FIELDS = [
|
|
2315
|
-
"__honeypot",
|
|
2316
|
-
"_honeypot",
|
|
2317
|
-
"honeypot",
|
|
2318
|
-
"__hp",
|
|
2319
|
-
"_hp",
|
|
2320
|
-
"website",
|
|
2321
|
-
// Common honeypot field name
|
|
2322
|
-
"url_field",
|
|
2323
|
-
// Another common honeypot
|
|
2324
|
-
"fax_number"
|
|
2325
|
-
// Rarely used by humans
|
|
2326
|
-
];
|
|
2327
|
-
function checkHoneypot(data) {
|
|
2328
|
-
for (const fieldName of HONEYPOT_FIELDS) {
|
|
2329
|
-
const value = data[fieldName];
|
|
2330
|
-
if (value !== void 0 && value !== null && value !== "") {
|
|
2331
|
-
let raw;
|
|
2332
|
-
if (typeof value === "object") {
|
|
2333
|
-
raw = JSON.stringify(value);
|
|
2334
|
-
} else {
|
|
2335
|
-
raw = String(value);
|
|
2336
|
-
}
|
|
2337
|
-
const stringValue = raw.trim();
|
|
2338
|
-
if (stringValue.length > 0) {
|
|
2339
|
-
return {
|
|
2340
|
-
isSpam: true,
|
|
2341
|
-
reason: "honeypot",
|
|
2342
|
-
details: `Honeypot field "${fieldName}" was filled`
|
|
2343
|
-
};
|
|
2344
|
-
}
|
|
2345
|
-
}
|
|
2346
|
-
}
|
|
2347
|
-
return { isSpam: false };
|
|
2348
|
-
}
|
|
2349
|
-
function checkRateLimit(ipAddress, formSlug, config) {
|
|
2350
|
-
const key = `${formSlug}:${ipAddress}`;
|
|
2351
|
-
const now = Date.now();
|
|
2352
|
-
const entry = rateLimitStore.get(key);
|
|
2353
|
-
if (entry) {
|
|
2354
|
-
if (now - entry.timestamp > config.windowMs) {
|
|
2355
|
-
rateLimitStore.set(key, { count: 1, timestamp: now });
|
|
2356
|
-
return { isSpam: false };
|
|
2357
|
-
}
|
|
2358
|
-
if (entry.count >= config.maxSubmissions) {
|
|
2359
|
-
return {
|
|
2360
|
-
isSpam: true,
|
|
2361
|
-
reason: "rate_limit",
|
|
2362
|
-
details: `Rate limit exceeded: ${entry.count}/${config.maxSubmissions} in ${config.windowMs}ms`
|
|
2363
|
-
};
|
|
2364
|
-
}
|
|
2365
|
-
rateLimitStore.set(key, {
|
|
2366
|
-
count: entry.count + 1,
|
|
2367
|
-
timestamp: entry.timestamp
|
|
2761
|
+
if (options.length === 0) {
|
|
2762
|
+
errors.push({
|
|
2763
|
+
path: `${path}.options`,
|
|
2764
|
+
message: `${field.type} field must have at least one option`,
|
|
2765
|
+
code: "OPTIONS_EMPTY"
|
|
2368
2766
|
});
|
|
2369
|
-
} else {
|
|
2370
|
-
rateLimitStore.set(key, { count: 1, timestamp: now });
|
|
2371
2767
|
}
|
|
2372
|
-
return { isSpam: false };
|
|
2373
|
-
}
|
|
2374
|
-
function cleanupRateLimitStore(maxAgeMs = 5 * 60 * 1e3) {
|
|
2375
|
-
const now = Date.now();
|
|
2376
|
-
let removed = 0;
|
|
2377
|
-
for (const [key, entry] of rateLimitStore.entries()) {
|
|
2378
|
-
if (now - entry.timestamp > maxAgeMs) {
|
|
2379
|
-
rateLimitStore.delete(key);
|
|
2380
|
-
removed++;
|
|
2381
|
-
}
|
|
2382
|
-
}
|
|
2383
|
-
return removed;
|
|
2384
2768
|
}
|
|
2385
|
-
function
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
rateLimitStore.clear();
|
|
2390
|
-
}
|
|
2391
|
-
function isRateLimited(ipAddress, formSlug, config) {
|
|
2392
|
-
const key = `${formSlug}:${ipAddress}`;
|
|
2393
|
-
const now = Date.now();
|
|
2394
|
-
const entry = rateLimitStore.get(key);
|
|
2395
|
-
if (!entry) {
|
|
2396
|
-
return false;
|
|
2769
|
+
function validateConditionalLogic(field, path, errors) {
|
|
2770
|
+
const { conditionalLogic } = field;
|
|
2771
|
+
if (!conditionalLogic || !conditionalLogic.enabled) {
|
|
2772
|
+
return;
|
|
2397
2773
|
}
|
|
2398
|
-
if (
|
|
2399
|
-
|
|
2774
|
+
if (!conditionalLogic.conditions || conditionalLogic.conditions.length === 0) {
|
|
2775
|
+
errors.push({
|
|
2776
|
+
path: `${path}.conditionalLogic.conditions`,
|
|
2777
|
+
message: "Conditional logic must have at least one condition",
|
|
2778
|
+
code: "CONDITIONS_REQUIRED"
|
|
2779
|
+
});
|
|
2780
|
+
return;
|
|
2400
2781
|
}
|
|
2401
|
-
|
|
2402
|
-
}
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
const { collections } = pluginContext.services;
|
|
2409
|
-
const { logger } = pluginContext;
|
|
2410
|
-
try {
|
|
2411
|
-
const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);
|
|
2412
|
-
if (!form) {
|
|
2413
|
-
logger.warn?.("Form submission attempted for non-existent form", {
|
|
2414
|
-
formSlug
|
|
2782
|
+
conditionalLogic.conditions.forEach((condition, index) => {
|
|
2783
|
+
const conditionPath = `${path}.conditionalLogic.conditions[${index}]`;
|
|
2784
|
+
if (!condition.field) {
|
|
2785
|
+
errors.push({
|
|
2786
|
+
path: `${conditionPath}.field`,
|
|
2787
|
+
message: "Condition must specify a field name",
|
|
2788
|
+
code: "CONDITION_FIELD_REQUIRED"
|
|
2415
2789
|
});
|
|
2416
|
-
return { success: false, error: NO_SUCH_FORM };
|
|
2417
2790
|
}
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
availability: availability.kind
|
|
2791
|
+
if (!condition.comparison) {
|
|
2792
|
+
errors.push({
|
|
2793
|
+
path: `${conditionPath}.comparison`,
|
|
2794
|
+
message: "Condition must specify a comparison operator",
|
|
2795
|
+
code: "CONDITION_COMPARISON_REQUIRED"
|
|
2424
2796
|
});
|
|
2425
|
-
return {
|
|
2426
|
-
success: false,
|
|
2427
|
-
error: availability.kind === "closed" ? availability.message : NO_SUCH_FORM
|
|
2428
|
-
};
|
|
2429
|
-
}
|
|
2430
|
-
const settings = normalizeFormSettings(form.settings);
|
|
2431
|
-
if (settings.allowMultipleSubmissions === false && !metadata?.ipAddress) {
|
|
2432
|
-
logger.warn?.(
|
|
2433
|
-
"Single-submission form received a submission without an IP \u2014 the restriction cannot apply",
|
|
2434
|
-
{ formSlug }
|
|
2435
|
-
);
|
|
2436
2797
|
}
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
error: "You have already submitted this form."
|
|
2457
|
-
};
|
|
2798
|
+
});
|
|
2799
|
+
}
|
|
2800
|
+
function validateFieldByType(field, path, errors) {
|
|
2801
|
+
switch (field.type) {
|
|
2802
|
+
case "select":
|
|
2803
|
+
case "radio":
|
|
2804
|
+
validateOptions(field, path, errors);
|
|
2805
|
+
break;
|
|
2806
|
+
case "number": {
|
|
2807
|
+
const numberField = field;
|
|
2808
|
+
if (numberField.validation) {
|
|
2809
|
+
const { min, max } = numberField.validation;
|
|
2810
|
+
if (min !== void 0 && max !== void 0 && min > max) {
|
|
2811
|
+
errors.push({
|
|
2812
|
+
path: `${path}.validation`,
|
|
2813
|
+
message: "Min value cannot be greater than max value",
|
|
2814
|
+
code: "MIN_GREATER_THAN_MAX"
|
|
2815
|
+
});
|
|
2816
|
+
}
|
|
2458
2817
|
}
|
|
2818
|
+
break;
|
|
2459
2819
|
}
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
rateLimit: pluginConfig.spamProtection.rateLimit,
|
|
2472
|
-
recaptcha: {
|
|
2473
|
-
...pluginConfig.spamProtection.recaptcha,
|
|
2474
|
-
enabled: settings.captchaEnabled ?? pluginConfig.spamProtection.recaptcha?.enabled ?? false
|
|
2820
|
+
case "text":
|
|
2821
|
+
case "textarea": {
|
|
2822
|
+
const textField = field;
|
|
2823
|
+
if (textField.validation) {
|
|
2824
|
+
const { minLength, maxLength } = textField.validation;
|
|
2825
|
+
if (minLength !== void 0 && maxLength !== void 0 && minLength > maxLength) {
|
|
2826
|
+
errors.push({
|
|
2827
|
+
path: `${path}.validation`,
|
|
2828
|
+
message: "Min length cannot be greater than max length",
|
|
2829
|
+
code: "MIN_LENGTH_GREATER_THAN_MAX"
|
|
2830
|
+
});
|
|
2475
2831
|
}
|
|
2476
2832
|
}
|
|
2477
|
-
|
|
2478
|
-
if (spamResult.isSpam && spamResult.reason === "rate_limit") {
|
|
2479
|
-
logger.info?.("Spam submission rejected (rate limit)", {
|
|
2480
|
-
formSlug,
|
|
2481
|
-
ipAddress: metadata?.ipAddress
|
|
2482
|
-
});
|
|
2483
|
-
return { success: true };
|
|
2484
|
-
}
|
|
2485
|
-
const isContentSpam = spamResult.isSpam;
|
|
2486
|
-
if (isContentSpam) {
|
|
2487
|
-
logger.info?.("Spam submission detected \u2014 storing flagged", {
|
|
2488
|
-
formSlug,
|
|
2489
|
-
reason: spamResult.reason,
|
|
2490
|
-
ipAddress: metadata?.ipAddress
|
|
2491
|
-
});
|
|
2833
|
+
break;
|
|
2492
2834
|
}
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
return {
|
|
2504
|
-
success: false,
|
|
2505
|
-
error: "Validation failed",
|
|
2506
|
-
validationErrors: prepared.validationErrors
|
|
2507
|
-
};
|
|
2835
|
+
case "file": {
|
|
2836
|
+
const fileField = field;
|
|
2837
|
+
if (fileField.maxFileSize !== void 0 && fileField.maxFileSize <= 0) {
|
|
2838
|
+
errors.push({
|
|
2839
|
+
path: `${path}.maxFileSize`,
|
|
2840
|
+
message: "File maxFileSize must be a positive number",
|
|
2841
|
+
code: "FILE_MAX_SIZE_INVALID"
|
|
2842
|
+
});
|
|
2843
|
+
}
|
|
2844
|
+
break;
|
|
2508
2845
|
}
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
keepAsEvidence: isContentSpam,
|
|
2523
|
-
form: { id: form.id, fields: form.fields }
|
|
2524
|
-
}),
|
|
2525
|
-
// Public form submission — create as system. No ambient user; an
|
|
2526
|
-
// empty context already resolves to system, but be explicit.
|
|
2527
|
-
{ as: "system" }
|
|
2528
|
-
);
|
|
2529
|
-
logger.info?.("Form submission created successfully", {
|
|
2530
|
-
formSlug,
|
|
2531
|
-
submissionId: submission.item.id,
|
|
2532
|
-
flaggedAsSpam: isContentSpam
|
|
2846
|
+
}
|
|
2847
|
+
}
|
|
2848
|
+
function validateField(field, path, errors, seenNames) {
|
|
2849
|
+
if (!field || typeof field !== "object") {
|
|
2850
|
+
return;
|
|
2851
|
+
}
|
|
2852
|
+
const f = field;
|
|
2853
|
+
const fieldType = f.type;
|
|
2854
|
+
if (!fieldType) {
|
|
2855
|
+
errors.push({
|
|
2856
|
+
path: `${path}.type`,
|
|
2857
|
+
message: "Field type is required",
|
|
2858
|
+
code: "FIELD_TYPE_REQUIRED"
|
|
2533
2859
|
});
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
pluginConfig,
|
|
2544
|
-
pluginContext
|
|
2545
|
-
);
|
|
2546
|
-
if (isContentSpam) {
|
|
2547
|
-
return { success: true, redirect };
|
|
2860
|
+
return;
|
|
2861
|
+
}
|
|
2862
|
+
const isValidType = typeof fieldType === "string" && (VALID_FIELD_TYPES_SET.has(fieldType) || isPluginFieldTypeOnSurface(fieldType, "forms"));
|
|
2863
|
+
if (!isValidType) {
|
|
2864
|
+
let fieldTypeRepr;
|
|
2865
|
+
if (typeof fieldType === "object" && fieldType !== null) {
|
|
2866
|
+
fieldTypeRepr = JSON.stringify(fieldType);
|
|
2867
|
+
} else {
|
|
2868
|
+
fieldTypeRepr = String(fieldType);
|
|
2548
2869
|
}
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
// integration test reads `submission.id` off the result because nothing
|
|
2554
|
-
// at compile time will.
|
|
2555
|
-
submission: asSubmissionDocument(submission.item),
|
|
2556
|
-
redirect
|
|
2557
|
-
};
|
|
2558
|
-
} catch (error) {
|
|
2559
|
-
logger.error?.("Form submission error", {
|
|
2560
|
-
formSlug,
|
|
2561
|
-
error: error instanceof Error ? error.message : String(error)
|
|
2870
|
+
errors.push({
|
|
2871
|
+
path: `${path}.type`,
|
|
2872
|
+
message: `Invalid field type '${fieldTypeRepr}'. Valid types: ${VALID_FIELD_TYPES.join(", ")}`,
|
|
2873
|
+
code: "FIELD_TYPE_INVALID"
|
|
2562
2874
|
});
|
|
2563
|
-
return
|
|
2564
|
-
success: false,
|
|
2565
|
-
error: "An error occurred processing your submission. Please try again."
|
|
2566
|
-
};
|
|
2875
|
+
return;
|
|
2567
2876
|
}
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
const { collections } = pluginContext.services;
|
|
2572
|
-
const result = await collections.listEntries(
|
|
2573
|
-
pluginConfig.formOverrides.slug,
|
|
2574
|
-
{ where: { slug: { equals: slug } }, pagination: { limit: 1 } },
|
|
2575
|
-
{ as: "system" }
|
|
2576
|
-
);
|
|
2577
|
-
const form = result.data?.[0];
|
|
2578
|
-
return form ? asFormDocument(form) : null;
|
|
2579
|
-
} catch {
|
|
2580
|
-
return null;
|
|
2877
|
+
validateFieldName(f.name, path, errors, seenNames);
|
|
2878
|
+
if (fieldType !== "hidden") {
|
|
2879
|
+
validateFieldLabel(f.label, path, errors);
|
|
2581
2880
|
}
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
if (!settings) return void 0;
|
|
2586
|
-
if (settings.confirmationType === "redirect" && settings.redirectUrl) {
|
|
2587
|
-
return settings.redirectUrl;
|
|
2881
|
+
validateFieldByType(field, path, errors);
|
|
2882
|
+
if (f.conditionalLogic) {
|
|
2883
|
+
validateConditionalLogic(field, path, errors);
|
|
2588
2884
|
}
|
|
2589
|
-
const field = pickedDocumentField(settings);
|
|
2590
|
-
if (!field) return void 0;
|
|
2591
|
-
return urlForPickedDocument(
|
|
2592
|
-
settings[field],
|
|
2593
|
-
form,
|
|
2594
|
-
pluginConfig,
|
|
2595
|
-
pluginContext
|
|
2596
|
-
);
|
|
2597
2885
|
}
|
|
2598
|
-
|
|
2599
|
-
const
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2886
|
+
function validateFieldsArray(fields, basePath, errors) {
|
|
2887
|
+
const seenNames = /* @__PURE__ */ new Set();
|
|
2888
|
+
fields.forEach((field, index) => {
|
|
2889
|
+
const fieldPath = `${basePath}[${index}]`;
|
|
2890
|
+
validateField(field, fieldPath, errors, seenNames);
|
|
2891
|
+
});
|
|
2892
|
+
}
|
|
2893
|
+
function validateFields(fields, errors) {
|
|
2894
|
+
const path = "fields";
|
|
2895
|
+
if (!fields) {
|
|
2896
|
+
errors.push({
|
|
2897
|
+
path,
|
|
2898
|
+
message: "Form must have at least one field",
|
|
2899
|
+
code: "FIELDS_REQUIRED"
|
|
2605
2900
|
});
|
|
2606
|
-
return
|
|
2901
|
+
return;
|
|
2607
2902
|
}
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
"Form
|
|
2612
|
-
|
|
2613
|
-
);
|
|
2614
|
-
return
|
|
2903
|
+
if (!Array.isArray(fields)) {
|
|
2904
|
+
errors.push({
|
|
2905
|
+
path,
|
|
2906
|
+
message: "Form fields must be an array",
|
|
2907
|
+
code: "FIELDS_INVALID_TYPE"
|
|
2908
|
+
});
|
|
2909
|
+
return;
|
|
2615
2910
|
}
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
) === "unreachable") {
|
|
2622
|
-
logger.warn?.("Form redirects to a page that has never been published", {
|
|
2623
|
-
form: form.slug,
|
|
2624
|
-
collection: reference.collection,
|
|
2625
|
-
target: reference.id
|
|
2911
|
+
if (fields.length === 0) {
|
|
2912
|
+
errors.push({
|
|
2913
|
+
path,
|
|
2914
|
+
message: "Form must have at least one field",
|
|
2915
|
+
code: "FIELDS_EMPTY"
|
|
2626
2916
|
});
|
|
2627
|
-
return
|
|
2917
|
+
return;
|
|
2628
2918
|
}
|
|
2629
|
-
|
|
2919
|
+
validateFieldsArray(fields, path, errors);
|
|
2630
2920
|
}
|
|
2631
|
-
function
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2921
|
+
function validateSettings(settings, errors) {
|
|
2922
|
+
if (!settings) {
|
|
2923
|
+
return;
|
|
2924
|
+
}
|
|
2925
|
+
if (typeof settings !== "object" || Array.isArray(settings)) {
|
|
2926
|
+
errors.push({
|
|
2927
|
+
path: "settings",
|
|
2928
|
+
message: "Form settings must be an object",
|
|
2929
|
+
code: "SETTINGS_INVALID_TYPE"
|
|
2930
|
+
});
|
|
2931
|
+
return;
|
|
2932
|
+
}
|
|
2933
|
+
const s = settings;
|
|
2934
|
+
if (s.confirmationType === "redirect") {
|
|
2935
|
+
if (!s.redirectUrl && !s.redirectRelation) {
|
|
2936
|
+
errors.push({
|
|
2937
|
+
path: "settings.redirectUrl",
|
|
2938
|
+
message: "Redirect URL or redirect relation is required when confirmation type is 'redirect'",
|
|
2939
|
+
code: "REDIRECT_URL_REQUIRED"
|
|
2940
|
+
});
|
|
2640
2941
|
}
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2942
|
+
}
|
|
2943
|
+
if (s.confirmationType === "relationship" && !namesADocument(s.redirectPage)) {
|
|
2944
|
+
errors.push({
|
|
2945
|
+
path: "settings.redirectPage",
|
|
2946
|
+
message: "A page is required when confirmation type is 'relationship'",
|
|
2947
|
+
code: "REDIRECT_PAGE_REQUIRED"
|
|
2647
2948
|
});
|
|
2648
|
-
return void 0;
|
|
2649
2949
|
}
|
|
2650
2950
|
}
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
);
|
|
2665
|
-
} catch (error) {
|
|
2666
|
-
logger.warn?.("Form redirect target could not be read", {
|
|
2667
|
-
...context,
|
|
2668
|
-
error: error instanceof Error ? error.message : String(error)
|
|
2951
|
+
function namesADocument(value) {
|
|
2952
|
+
if (typeof value === "string") return value.length > 0;
|
|
2953
|
+
return parseRedirectReference(value, []) !== null;
|
|
2954
|
+
}
|
|
2955
|
+
function validateAccess(access, errors) {
|
|
2956
|
+
if (!access) {
|
|
2957
|
+
return;
|
|
2958
|
+
}
|
|
2959
|
+
if (typeof access !== "object" || Array.isArray(access)) {
|
|
2960
|
+
errors.push({
|
|
2961
|
+
path: "access",
|
|
2962
|
+
message: "Access control must be an object",
|
|
2963
|
+
code: "ACCESS_INVALID_TYPE"
|
|
2669
2964
|
});
|
|
2670
|
-
return
|
|
2965
|
+
return;
|
|
2671
2966
|
}
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2967
|
+
const accessObj = access;
|
|
2968
|
+
const validAccessKeys = ["submit", "read"];
|
|
2969
|
+
for (const key of validAccessKeys) {
|
|
2970
|
+
const fn = accessObj[key];
|
|
2971
|
+
if (fn !== void 0 && typeof fn !== "function") {
|
|
2972
|
+
errors.push({
|
|
2973
|
+
path: `access.${key}`,
|
|
2974
|
+
message: `Access control '${key}' must be a function`,
|
|
2975
|
+
code: "ACCESS_FUNCTION_INVALID"
|
|
2976
|
+
});
|
|
2977
|
+
}
|
|
2675
2978
|
}
|
|
2676
|
-
return row;
|
|
2677
2979
|
}
|
|
2678
|
-
|
|
2679
|
-
const
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
const prepared = prepareSubmission({
|
|
2685
|
-
data,
|
|
2686
|
-
fields: form.fields,
|
|
2687
|
-
validate: true
|
|
2688
|
-
});
|
|
2689
|
-
if (!prepared.validationErrors) {
|
|
2690
|
-
return { valid: true };
|
|
2691
|
-
}
|
|
2980
|
+
function validateFormConfig(config) {
|
|
2981
|
+
const errors = [];
|
|
2982
|
+
validateSlug(config.slug, errors);
|
|
2983
|
+
validateFields(config.fields, errors);
|
|
2984
|
+
validateSettings(config.settings, errors);
|
|
2985
|
+
validateAccess(config.access, errors);
|
|
2692
2986
|
return {
|
|
2693
|
-
valid:
|
|
2694
|
-
errors
|
|
2987
|
+
valid: errors.length === 0,
|
|
2988
|
+
errors
|
|
2695
2989
|
};
|
|
2696
2990
|
}
|
|
2697
|
-
|
|
2698
|
-
const
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
async function getFormSubmissionStats(formSlug, context) {
|
|
2706
|
-
const { pluginContext, pluginConfig } = context;
|
|
2707
|
-
const { collections } = pluginContext.services;
|
|
2708
|
-
const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);
|
|
2709
|
-
if (!form) {
|
|
2710
|
-
return null;
|
|
2711
|
-
}
|
|
2712
|
-
try {
|
|
2713
|
-
const submissionsSlug = pluginConfig.formSubmissionOverrides.slug;
|
|
2714
|
-
const sys = { as: "system" };
|
|
2715
|
-
const base = { form: { equals: form.id } };
|
|
2716
|
-
const [total, newCount, read, archived] = await Promise.all([
|
|
2717
|
-
collections.count(submissionsSlug, { where: base }, sys),
|
|
2718
|
-
collections.count(
|
|
2719
|
-
submissionsSlug,
|
|
2720
|
-
{ where: { ...base, status: { equals: "new" } } },
|
|
2721
|
-
sys
|
|
2722
|
-
),
|
|
2723
|
-
collections.count(
|
|
2724
|
-
submissionsSlug,
|
|
2725
|
-
{ where: { ...base, status: { equals: "read" } } },
|
|
2726
|
-
sys
|
|
2727
|
-
),
|
|
2728
|
-
collections.count(
|
|
2729
|
-
submissionsSlug,
|
|
2730
|
-
{ where: { ...base, status: { equals: "archived" } } },
|
|
2731
|
-
sys
|
|
2732
|
-
)
|
|
2733
|
-
]);
|
|
2734
|
-
return { total, new: newCount, read, archived };
|
|
2735
|
-
} catch {
|
|
2736
|
-
return null;
|
|
2991
|
+
function assertValidFormConfig(config) {
|
|
2992
|
+
const result = validateFormConfig(config);
|
|
2993
|
+
if (!result.valid) {
|
|
2994
|
+
const errorMessages = result.errors.map((err) => ` - [${err.code}] ${err.path}: ${err.message}`).join("\n");
|
|
2995
|
+
throw new Error(
|
|
2996
|
+
`Invalid form config for '${config.slug || "unknown"}':
|
|
2997
|
+
${errorMessages}`
|
|
2998
|
+
);
|
|
2737
2999
|
}
|
|
2738
3000
|
}
|
|
2739
3001
|
|
|
3002
|
+
// src/config/field-helpers.ts
|
|
3003
|
+
var option = (label, value) => ({
|
|
3004
|
+
label,
|
|
3005
|
+
value: value ?? label.toLowerCase().replace(/\s+/g, "_")
|
|
3006
|
+
});
|
|
3007
|
+
var text3 = (config) => ({
|
|
3008
|
+
...config,
|
|
3009
|
+
type: "text"
|
|
3010
|
+
});
|
|
3011
|
+
var email = (config) => ({
|
|
3012
|
+
...config,
|
|
3013
|
+
type: "email"
|
|
3014
|
+
});
|
|
3015
|
+
var phone = (config) => ({
|
|
3016
|
+
...config,
|
|
3017
|
+
type: "phone"
|
|
3018
|
+
});
|
|
3019
|
+
var url = (config) => ({
|
|
3020
|
+
...config,
|
|
3021
|
+
type: "url"
|
|
3022
|
+
});
|
|
3023
|
+
var textarea3 = (config) => ({
|
|
3024
|
+
...config,
|
|
3025
|
+
type: "textarea"
|
|
3026
|
+
});
|
|
3027
|
+
var number = (config) => ({
|
|
3028
|
+
...config,
|
|
3029
|
+
type: "number"
|
|
3030
|
+
});
|
|
3031
|
+
var select3 = (config) => ({
|
|
3032
|
+
...config,
|
|
3033
|
+
type: "select"
|
|
3034
|
+
});
|
|
3035
|
+
var checkbox2 = (config) => ({
|
|
3036
|
+
...config,
|
|
3037
|
+
type: "checkbox"
|
|
3038
|
+
});
|
|
3039
|
+
var radio = (config) => ({
|
|
3040
|
+
...config,
|
|
3041
|
+
type: "radio"
|
|
3042
|
+
});
|
|
3043
|
+
var file = (config) => ({
|
|
3044
|
+
...config,
|
|
3045
|
+
type: "file"
|
|
3046
|
+
});
|
|
3047
|
+
var date3 = (config) => ({
|
|
3048
|
+
...config,
|
|
3049
|
+
type: "date"
|
|
3050
|
+
});
|
|
3051
|
+
var time = (config) => ({
|
|
3052
|
+
...config,
|
|
3053
|
+
type: "time"
|
|
3054
|
+
});
|
|
3055
|
+
var hidden = (config) => ({
|
|
3056
|
+
...config,
|
|
3057
|
+
type: "hidden"
|
|
3058
|
+
});
|
|
3059
|
+
|
|
2740
3060
|
// src/handlers/webhooks.ts
|
|
2741
3061
|
import { safeFetch, ExternalUrlError } from "nextly";
|
|
2742
3062
|
var WEBHOOK_TIMEOUT_MS = 1e4;
|
|
@@ -2927,8 +3247,6 @@ export {
|
|
|
2927
3247
|
assertValidFormConfig,
|
|
2928
3248
|
checkSpam,
|
|
2929
3249
|
checkbox2 as checkbox,
|
|
2930
|
-
cleanupRateLimitStore,
|
|
2931
|
-
clearRateLimitStore,
|
|
2932
3250
|
createFormConfig,
|
|
2933
3251
|
date3 as date,
|
|
2934
3252
|
downloadFile,
|
|
@@ -2947,7 +3265,6 @@ export {
|
|
|
2947
3265
|
generateZodSchema,
|
|
2948
3266
|
getFormBuilderConfig,
|
|
2949
3267
|
getFormSubmissionStats,
|
|
2950
|
-
getRateLimitStoreSize,
|
|
2951
3268
|
getSupportedComparisonOperators,
|
|
2952
3269
|
getSupportedWebhookEvents,
|
|
2953
3270
|
getValidationErrors,
|
|
@@ -2959,7 +3276,6 @@ export {
|
|
|
2959
3276
|
isFormField,
|
|
2960
3277
|
isFormFieldType,
|
|
2961
3278
|
isKnownFormField,
|
|
2962
|
-
isRateLimited,
|
|
2963
3279
|
isSelectFormField,
|
|
2964
3280
|
isTextFormField,
|
|
2965
3281
|
isValidComparisonOperator,
|