@nextlyhq/plugin-form-builder 0.0.2-alpha.65 → 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 +1244 -935
- 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];
|
|
@@ -1671,7 +2401,7 @@ function submittedPayload(raw) {
|
|
|
1671
2401
|
} else if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
1672
2402
|
return raw;
|
|
1673
2403
|
}
|
|
1674
|
-
throw
|
|
2404
|
+
throw NextlyError4.validation({
|
|
1675
2405
|
errors: [
|
|
1676
2406
|
{
|
|
1677
2407
|
path: "data",
|
|
@@ -1701,30 +2431,39 @@ async function prepareSubmissionForWrite(context, formsSlug, nextly) {
|
|
|
1701
2431
|
submission.data !== void 0 ? submission.data : stored?.data
|
|
1702
2432
|
);
|
|
1703
2433
|
const marks = submissionMarks(submission);
|
|
1704
|
-
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
|
+
}
|
|
1705
2440
|
const prepared = prepareSubmission({
|
|
1706
2441
|
data: incoming,
|
|
1707
2442
|
fields,
|
|
1708
|
-
validate: marks?.keepAsEvidence !== true
|
|
2443
|
+
validate: marks?.keepAsEvidence !== true && spam === void 0
|
|
1709
2444
|
});
|
|
1710
2445
|
if (prepared.validationErrors) {
|
|
1711
|
-
throw
|
|
2446
|
+
throw NextlyError4.validation({
|
|
1712
2447
|
errors: Object.entries(prepared.validationErrors).map(
|
|
1713
2448
|
([path, message]) => ({ path, code: "INVALID", message })
|
|
1714
2449
|
)
|
|
1715
2450
|
});
|
|
1716
2451
|
}
|
|
1717
2452
|
ctx.data = { ...submission, data: prepared.data };
|
|
2453
|
+
if (spam) {
|
|
2454
|
+
ctx.data.status = "spam";
|
|
2455
|
+
ctx.data.spamReason = spam.reason ?? null;
|
|
2456
|
+
}
|
|
1718
2457
|
if (ctx.operation !== "create" && submission.data === void 0) {
|
|
1719
2458
|
stampDerivedEdit(ctx.data, ctx.user, incoming, prepared.data);
|
|
1720
2459
|
}
|
|
1721
2460
|
return ctx.data;
|
|
1722
2461
|
}
|
|
1723
|
-
async function
|
|
2462
|
+
async function formToCheckAgainst(marks, formsSlug, formId, nextly) {
|
|
1724
2463
|
const handedOver = marks?.form?.id === formId ? marks.form : null;
|
|
1725
2464
|
const form = handedOver ?? await fetchParentForm(formsSlug, formId, nextly);
|
|
1726
2465
|
if (!form || !Array.isArray(form.fields)) {
|
|
1727
|
-
throw
|
|
2466
|
+
throw NextlyError4.validation({
|
|
1728
2467
|
errors: [
|
|
1729
2468
|
{
|
|
1730
2469
|
path: "form",
|
|
@@ -1734,7 +2473,32 @@ async function fieldsToCheckAgainst(marks, formsSlug, formId, nextly) {
|
|
|
1734
2473
|
]
|
|
1735
2474
|
});
|
|
1736
2475
|
}
|
|
1737
|
-
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;
|
|
1738
2502
|
}
|
|
1739
2503
|
function stampDerivedEdit(row, user, before, after) {
|
|
1740
2504
|
if (sameSubmittedPayload(before, after)) return;
|
|
@@ -1852,7 +2616,7 @@ async function fetchParentForm(formsSlug, formId, nextly) {
|
|
|
1852
2616
|
);
|
|
1853
2617
|
return form;
|
|
1854
2618
|
} catch (err) {
|
|
1855
|
-
if (!
|
|
2619
|
+
if (!NextlyError4.isNotFound(err)) throw err;
|
|
1856
2620
|
nextly.logger.error?.("Form Builder: form not found", {
|
|
1857
2621
|
formId,
|
|
1858
2622
|
error: err instanceof Error ? err.message : String(err)
|
|
@@ -1916,834 +2680,383 @@ function validateSlug(slug, errors) {
|
|
|
1916
2680
|
code: "SLUG_TOO_LONG"
|
|
1917
2681
|
});
|
|
1918
2682
|
}
|
|
1919
|
-
if (!SLUG_PATTERN.test(slug)) {
|
|
1920
|
-
errors.push({
|
|
1921
|
-
path,
|
|
1922
|
-
message: "Form slug must start with a lowercase letter and contain only lowercase letters, numbers, hyphens, and underscores",
|
|
1923
|
-
code: "SLUG_INVALID_FORMAT"
|
|
1924
|
-
});
|
|
1925
|
-
}
|
|
1926
|
-
if (isReservedSlug(slug)) {
|
|
1927
|
-
errors.push({
|
|
1928
|
-
path,
|
|
1929
|
-
message: `Form slug '${slug}' is reserved and cannot be used`,
|
|
1930
|
-
code: "SLUG_RESERVED"
|
|
1931
|
-
});
|
|
1932
|
-
}
|
|
1933
|
-
}
|
|
1934
|
-
function validateFieldName(name, path, errors, seenNames) {
|
|
1935
|
-
if (!name) {
|
|
1936
|
-
errors.push({
|
|
1937
|
-
path: `${path}.name`,
|
|
1938
|
-
message: "Field name is required",
|
|
1939
|
-
code: "FIELD_NAME_REQUIRED"
|
|
1940
|
-
});
|
|
1941
|
-
return;
|
|
1942
|
-
}
|
|
1943
|
-
if (typeof name !== "string") {
|
|
1944
|
-
errors.push({
|
|
1945
|
-
path: `${path}.name`,
|
|
1946
|
-
message: "Field name must be a string",
|
|
1947
|
-
code: "FIELD_NAME_REQUIRED"
|
|
1948
|
-
});
|
|
1949
|
-
return;
|
|
1950
|
-
}
|
|
1951
|
-
if (!FIELD_NAME_PATTERN.test(name)) {
|
|
1952
|
-
errors.push({
|
|
1953
|
-
path: `${path}.name`,
|
|
1954
|
-
message: `Field name '${name}' must start with a letter and contain only letters, numbers, and underscores`,
|
|
1955
|
-
code: "FIELD_NAME_INVALID_FORMAT"
|
|
1956
|
-
});
|
|
1957
|
-
}
|
|
1958
|
-
const nameLower = name.toLowerCase();
|
|
1959
|
-
if (seenNames.has(nameLower)) {
|
|
1960
|
-
errors.push({
|
|
1961
|
-
path: `${path}.name`,
|
|
1962
|
-
message: `Duplicate field name '${name}'`,
|
|
1963
|
-
code: "FIELD_NAME_DUPLICATE"
|
|
1964
|
-
});
|
|
1965
|
-
} else {
|
|
1966
|
-
seenNames.add(nameLower);
|
|
1967
|
-
}
|
|
1968
|
-
}
|
|
1969
|
-
function validateFieldLabel(label, path, errors) {
|
|
1970
|
-
if (!label || typeof label === "string" && label.trim() === "") {
|
|
1971
|
-
errors.push({
|
|
1972
|
-
path: `${path}.label`,
|
|
1973
|
-
message: "Field label is required",
|
|
1974
|
-
code: "FIELD_LABEL_REQUIRED"
|
|
1975
|
-
});
|
|
1976
|
-
}
|
|
1977
|
-
}
|
|
1978
|
-
function validateOptions(field, path, errors) {
|
|
1979
|
-
const fieldWithOptions = field;
|
|
1980
|
-
const options = fieldWithOptions.options;
|
|
1981
|
-
if (!options) {
|
|
1982
|
-
errors.push({
|
|
1983
|
-
path: `${path}.options`,
|
|
1984
|
-
message: `${field.type} field must have an 'options' array`,
|
|
1985
|
-
code: "OPTIONS_REQUIRED"
|
|
1986
|
-
});
|
|
1987
|
-
return;
|
|
1988
|
-
}
|
|
1989
|
-
if (!Array.isArray(options)) {
|
|
1990
|
-
errors.push({
|
|
1991
|
-
path: `${path}.options`,
|
|
1992
|
-
message: `${field.type} field 'options' must be an array`,
|
|
1993
|
-
code: "OPTIONS_REQUIRED"
|
|
1994
|
-
});
|
|
1995
|
-
return;
|
|
1996
|
-
}
|
|
1997
|
-
if (options.length === 0) {
|
|
1998
|
-
errors.push({
|
|
1999
|
-
path: `${path}.options`,
|
|
2000
|
-
message: `${field.type} field must have at least one option`,
|
|
2001
|
-
code: "OPTIONS_EMPTY"
|
|
2002
|
-
});
|
|
2003
|
-
}
|
|
2004
|
-
}
|
|
2005
|
-
function validateConditionalLogic(field, path, errors) {
|
|
2006
|
-
const { conditionalLogic } = field;
|
|
2007
|
-
if (!conditionalLogic || !conditionalLogic.enabled) {
|
|
2008
|
-
return;
|
|
2009
|
-
}
|
|
2010
|
-
if (!conditionalLogic.conditions || conditionalLogic.conditions.length === 0) {
|
|
2011
|
-
errors.push({
|
|
2012
|
-
path: `${path}.conditionalLogic.conditions`,
|
|
2013
|
-
message: "Conditional logic must have at least one condition",
|
|
2014
|
-
code: "CONDITIONS_REQUIRED"
|
|
2015
|
-
});
|
|
2016
|
-
return;
|
|
2017
|
-
}
|
|
2018
|
-
conditionalLogic.conditions.forEach((condition, index) => {
|
|
2019
|
-
const conditionPath = `${path}.conditionalLogic.conditions[${index}]`;
|
|
2020
|
-
if (!condition.field) {
|
|
2021
|
-
errors.push({
|
|
2022
|
-
path: `${conditionPath}.field`,
|
|
2023
|
-
message: "Condition must specify a field name",
|
|
2024
|
-
code: "CONDITION_FIELD_REQUIRED"
|
|
2025
|
-
});
|
|
2026
|
-
}
|
|
2027
|
-
if (!condition.comparison) {
|
|
2028
|
-
errors.push({
|
|
2029
|
-
path: `${conditionPath}.comparison`,
|
|
2030
|
-
message: "Condition must specify a comparison operator",
|
|
2031
|
-
code: "CONDITION_COMPARISON_REQUIRED"
|
|
2032
|
-
});
|
|
2033
|
-
}
|
|
2034
|
-
});
|
|
2035
|
-
}
|
|
2036
|
-
function validateFieldByType(field, path, errors) {
|
|
2037
|
-
switch (field.type) {
|
|
2038
|
-
case "select":
|
|
2039
|
-
case "radio":
|
|
2040
|
-
validateOptions(field, path, errors);
|
|
2041
|
-
break;
|
|
2042
|
-
case "number": {
|
|
2043
|
-
const numberField = field;
|
|
2044
|
-
if (numberField.validation) {
|
|
2045
|
-
const { min, max } = numberField.validation;
|
|
2046
|
-
if (min !== void 0 && max !== void 0 && min > max) {
|
|
2047
|
-
errors.push({
|
|
2048
|
-
path: `${path}.validation`,
|
|
2049
|
-
message: "Min value cannot be greater than max value",
|
|
2050
|
-
code: "MIN_GREATER_THAN_MAX"
|
|
2051
|
-
});
|
|
2052
|
-
}
|
|
2053
|
-
}
|
|
2054
|
-
break;
|
|
2055
|
-
}
|
|
2056
|
-
case "text":
|
|
2057
|
-
case "textarea": {
|
|
2058
|
-
const textField = field;
|
|
2059
|
-
if (textField.validation) {
|
|
2060
|
-
const { minLength, maxLength } = textField.validation;
|
|
2061
|
-
if (minLength !== void 0 && maxLength !== void 0 && minLength > maxLength) {
|
|
2062
|
-
errors.push({
|
|
2063
|
-
path: `${path}.validation`,
|
|
2064
|
-
message: "Min length cannot be greater than max length",
|
|
2065
|
-
code: "MIN_LENGTH_GREATER_THAN_MAX"
|
|
2066
|
-
});
|
|
2067
|
-
}
|
|
2068
|
-
}
|
|
2069
|
-
break;
|
|
2070
|
-
}
|
|
2071
|
-
case "file": {
|
|
2072
|
-
const fileField = field;
|
|
2073
|
-
if (fileField.maxFileSize !== void 0 && fileField.maxFileSize <= 0) {
|
|
2074
|
-
errors.push({
|
|
2075
|
-
path: `${path}.maxFileSize`,
|
|
2076
|
-
message: "File maxFileSize must be a positive number",
|
|
2077
|
-
code: "FILE_MAX_SIZE_INVALID"
|
|
2078
|
-
});
|
|
2079
|
-
}
|
|
2080
|
-
break;
|
|
2081
|
-
}
|
|
2082
|
-
}
|
|
2083
|
-
}
|
|
2084
|
-
function validateField(field, path, errors, seenNames) {
|
|
2085
|
-
if (!field || typeof field !== "object") {
|
|
2086
|
-
return;
|
|
2087
|
-
}
|
|
2088
|
-
const f = field;
|
|
2089
|
-
const fieldType = f.type;
|
|
2090
|
-
if (!fieldType) {
|
|
2091
|
-
errors.push({
|
|
2092
|
-
path: `${path}.type`,
|
|
2093
|
-
message: "Field type is required",
|
|
2094
|
-
code: "FIELD_TYPE_REQUIRED"
|
|
2095
|
-
});
|
|
2096
|
-
return;
|
|
2097
|
-
}
|
|
2098
|
-
const isValidType = typeof fieldType === "string" && (VALID_FIELD_TYPES_SET.has(fieldType) || isPluginFieldTypeOnSurface(fieldType, "forms"));
|
|
2099
|
-
if (!isValidType) {
|
|
2100
|
-
let fieldTypeRepr;
|
|
2101
|
-
if (typeof fieldType === "object" && fieldType !== null) {
|
|
2102
|
-
fieldTypeRepr = JSON.stringify(fieldType);
|
|
2103
|
-
} else {
|
|
2104
|
-
fieldTypeRepr = String(fieldType);
|
|
2105
|
-
}
|
|
2106
|
-
errors.push({
|
|
2107
|
-
path: `${path}.type`,
|
|
2108
|
-
message: `Invalid field type '${fieldTypeRepr}'. Valid types: ${VALID_FIELD_TYPES.join(", ")}`,
|
|
2109
|
-
code: "FIELD_TYPE_INVALID"
|
|
2110
|
-
});
|
|
2111
|
-
return;
|
|
2112
|
-
}
|
|
2113
|
-
validateFieldName(f.name, path, errors, seenNames);
|
|
2114
|
-
if (fieldType !== "hidden") {
|
|
2115
|
-
validateFieldLabel(f.label, path, errors);
|
|
2116
|
-
}
|
|
2117
|
-
validateFieldByType(field, path, errors);
|
|
2118
|
-
if (f.conditionalLogic) {
|
|
2119
|
-
validateConditionalLogic(field, path, errors);
|
|
2120
|
-
}
|
|
2121
|
-
}
|
|
2122
|
-
function validateFieldsArray(fields, basePath, errors) {
|
|
2123
|
-
const seenNames = /* @__PURE__ */ new Set();
|
|
2124
|
-
fields.forEach((field, index) => {
|
|
2125
|
-
const fieldPath = `${basePath}[${index}]`;
|
|
2126
|
-
validateField(field, fieldPath, errors, seenNames);
|
|
2127
|
-
});
|
|
2128
|
-
}
|
|
2129
|
-
function validateFields(fields, errors) {
|
|
2130
|
-
const path = "fields";
|
|
2131
|
-
if (!fields) {
|
|
2132
|
-
errors.push({
|
|
2133
|
-
path,
|
|
2134
|
-
message: "Form must have at least one field",
|
|
2135
|
-
code: "FIELDS_REQUIRED"
|
|
2136
|
-
});
|
|
2137
|
-
return;
|
|
2138
|
-
}
|
|
2139
|
-
if (!Array.isArray(fields)) {
|
|
2683
|
+
if (!SLUG_PATTERN.test(slug)) {
|
|
2140
2684
|
errors.push({
|
|
2141
2685
|
path,
|
|
2142
|
-
message: "Form
|
|
2143
|
-
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"
|
|
2144
2688
|
});
|
|
2145
|
-
return;
|
|
2146
2689
|
}
|
|
2147
|
-
if (
|
|
2690
|
+
if (isReservedSlug(slug)) {
|
|
2148
2691
|
errors.push({
|
|
2149
2692
|
path,
|
|
2150
|
-
message:
|
|
2151
|
-
code: "
|
|
2693
|
+
message: `Form slug '${slug}' is reserved and cannot be used`,
|
|
2694
|
+
code: "SLUG_RESERVED"
|
|
2152
2695
|
});
|
|
2153
|
-
return;
|
|
2154
2696
|
}
|
|
2155
|
-
validateFieldsArray(fields, path, errors);
|
|
2156
2697
|
}
|
|
2157
|
-
function
|
|
2158
|
-
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
|
+
});
|
|
2159
2705
|
return;
|
|
2160
2706
|
}
|
|
2161
|
-
if (typeof
|
|
2707
|
+
if (typeof name !== "string") {
|
|
2162
2708
|
errors.push({
|
|
2163
|
-
path:
|
|
2164
|
-
message: "
|
|
2165
|
-
code: "
|
|
2709
|
+
path: `${path}.name`,
|
|
2710
|
+
message: "Field name must be a string",
|
|
2711
|
+
code: "FIELD_NAME_REQUIRED"
|
|
2166
2712
|
});
|
|
2167
2713
|
return;
|
|
2168
2714
|
}
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
code: "REDIRECT_URL_REQUIRED"
|
|
2176
|
-
});
|
|
2177
|
-
}
|
|
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
|
+
});
|
|
2178
2721
|
}
|
|
2179
|
-
|
|
2722
|
+
const nameLower = name.toLowerCase();
|
|
2723
|
+
if (seenNames.has(nameLower)) {
|
|
2180
2724
|
errors.push({
|
|
2181
|
-
path:
|
|
2182
|
-
message:
|
|
2183
|
-
code: "
|
|
2725
|
+
path: `${path}.name`,
|
|
2726
|
+
message: `Duplicate field name '${name}'`,
|
|
2727
|
+
code: "FIELD_NAME_DUPLICATE"
|
|
2184
2728
|
});
|
|
2729
|
+
} else {
|
|
2730
|
+
seenNames.add(nameLower);
|
|
2185
2731
|
}
|
|
2186
2732
|
}
|
|
2187
|
-
function
|
|
2188
|
-
if (typeof
|
|
2189
|
-
|
|
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
|
+
}
|
|
2190
2741
|
}
|
|
2191
|
-
function
|
|
2192
|
-
|
|
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
|
+
});
|
|
2193
2751
|
return;
|
|
2194
2752
|
}
|
|
2195
|
-
if (
|
|
2753
|
+
if (!Array.isArray(options)) {
|
|
2196
2754
|
errors.push({
|
|
2197
|
-
path:
|
|
2198
|
-
message:
|
|
2199
|
-
code: "
|
|
2755
|
+
path: `${path}.options`,
|
|
2756
|
+
message: `${field.type} field 'options' must be an array`,
|
|
2757
|
+
code: "OPTIONS_REQUIRED"
|
|
2200
2758
|
});
|
|
2201
2759
|
return;
|
|
2202
2760
|
}
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
errors.push({
|
|
2209
|
-
path: `access.${key}`,
|
|
2210
|
-
message: `Access control '${key}' must be a function`,
|
|
2211
|
-
code: "ACCESS_FUNCTION_INVALID"
|
|
2212
|
-
});
|
|
2213
|
-
}
|
|
2214
|
-
}
|
|
2215
|
-
}
|
|
2216
|
-
function validateFormConfig(config) {
|
|
2217
|
-
const errors = [];
|
|
2218
|
-
validateSlug(config.slug, errors);
|
|
2219
|
-
validateFields(config.fields, errors);
|
|
2220
|
-
validateSettings(config.settings, errors);
|
|
2221
|
-
validateAccess(config.access, errors);
|
|
2222
|
-
return {
|
|
2223
|
-
valid: errors.length === 0,
|
|
2224
|
-
errors
|
|
2225
|
-
};
|
|
2226
|
-
}
|
|
2227
|
-
function assertValidFormConfig(config) {
|
|
2228
|
-
const result = validateFormConfig(config);
|
|
2229
|
-
if (!result.valid) {
|
|
2230
|
-
const errorMessages = result.errors.map((err) => ` - [${err.code}] ${err.path}: ${err.message}`).join("\n");
|
|
2231
|
-
throw new Error(
|
|
2232
|
-
`Invalid form config for '${config.slug || "unknown"}':
|
|
2233
|
-
${errorMessages}`
|
|
2234
|
-
);
|
|
2235
|
-
}
|
|
2236
|
-
}
|
|
2237
|
-
|
|
2238
|
-
// src/config/field-helpers.ts
|
|
2239
|
-
var option = (label, value) => ({
|
|
2240
|
-
label,
|
|
2241
|
-
value: value ?? label.toLowerCase().replace(/\s+/g, "_")
|
|
2242
|
-
});
|
|
2243
|
-
var text3 = (config) => ({
|
|
2244
|
-
...config,
|
|
2245
|
-
type: "text"
|
|
2246
|
-
});
|
|
2247
|
-
var email = (config) => ({
|
|
2248
|
-
...config,
|
|
2249
|
-
type: "email"
|
|
2250
|
-
});
|
|
2251
|
-
var phone = (config) => ({
|
|
2252
|
-
...config,
|
|
2253
|
-
type: "phone"
|
|
2254
|
-
});
|
|
2255
|
-
var url = (config) => ({
|
|
2256
|
-
...config,
|
|
2257
|
-
type: "url"
|
|
2258
|
-
});
|
|
2259
|
-
var textarea3 = (config) => ({
|
|
2260
|
-
...config,
|
|
2261
|
-
type: "textarea"
|
|
2262
|
-
});
|
|
2263
|
-
var number = (config) => ({
|
|
2264
|
-
...config,
|
|
2265
|
-
type: "number"
|
|
2266
|
-
});
|
|
2267
|
-
var select3 = (config) => ({
|
|
2268
|
-
...config,
|
|
2269
|
-
type: "select"
|
|
2270
|
-
});
|
|
2271
|
-
var checkbox2 = (config) => ({
|
|
2272
|
-
...config,
|
|
2273
|
-
type: "checkbox"
|
|
2274
|
-
});
|
|
2275
|
-
var radio = (config) => ({
|
|
2276
|
-
...config,
|
|
2277
|
-
type: "radio"
|
|
2278
|
-
});
|
|
2279
|
-
var file = (config) => ({
|
|
2280
|
-
...config,
|
|
2281
|
-
type: "file"
|
|
2282
|
-
});
|
|
2283
|
-
var date3 = (config) => ({
|
|
2284
|
-
...config,
|
|
2285
|
-
type: "date"
|
|
2286
|
-
});
|
|
2287
|
-
var time = (config) => ({
|
|
2288
|
-
...config,
|
|
2289
|
-
type: "time"
|
|
2290
|
-
});
|
|
2291
|
-
var hidden = (config) => ({
|
|
2292
|
-
...config,
|
|
2293
|
-
type: "hidden"
|
|
2294
|
-
});
|
|
2295
|
-
|
|
2296
|
-
// src/handlers/submit-form.ts
|
|
2297
|
-
import { formAvailability, NO_SUCH_FORM } from "nextly";
|
|
2298
|
-
|
|
2299
|
-
// src/handlers/spam-detection.ts
|
|
2300
|
-
var rateLimitStore = /* @__PURE__ */ new Map();
|
|
2301
|
-
async function checkSpam(options) {
|
|
2302
|
-
const { data, ipAddress, formSlug, config } = options;
|
|
2303
|
-
if (config.honeypot) {
|
|
2304
|
-
const honeypotResult = checkHoneypot(data);
|
|
2305
|
-
if (honeypotResult.isSpam) {
|
|
2306
|
-
return honeypotResult;
|
|
2307
|
-
}
|
|
2308
|
-
}
|
|
2309
|
-
if (config.rateLimit && ipAddress) {
|
|
2310
|
-
const rateLimitResult = checkRateLimit(
|
|
2311
|
-
ipAddress,
|
|
2312
|
-
formSlug,
|
|
2313
|
-
config.rateLimit
|
|
2314
|
-
);
|
|
2315
|
-
if (rateLimitResult.isSpam) {
|
|
2316
|
-
return rateLimitResult;
|
|
2317
|
-
}
|
|
2318
|
-
}
|
|
2319
|
-
return { isSpam: false };
|
|
2320
|
-
}
|
|
2321
|
-
var HONEYPOT_FIELDS = [
|
|
2322
|
-
"__honeypot",
|
|
2323
|
-
"_honeypot",
|
|
2324
|
-
"honeypot",
|
|
2325
|
-
"__hp",
|
|
2326
|
-
"_hp",
|
|
2327
|
-
"website",
|
|
2328
|
-
// Common honeypot field name
|
|
2329
|
-
"url_field",
|
|
2330
|
-
// Another common honeypot
|
|
2331
|
-
"fax_number"
|
|
2332
|
-
// Rarely used by humans
|
|
2333
|
-
];
|
|
2334
|
-
function checkHoneypot(data) {
|
|
2335
|
-
for (const fieldName of HONEYPOT_FIELDS) {
|
|
2336
|
-
const value = data[fieldName];
|
|
2337
|
-
if (value !== void 0 && value !== null && value !== "") {
|
|
2338
|
-
let raw;
|
|
2339
|
-
if (typeof value === "object") {
|
|
2340
|
-
raw = JSON.stringify(value);
|
|
2341
|
-
} else {
|
|
2342
|
-
raw = String(value);
|
|
2343
|
-
}
|
|
2344
|
-
const stringValue = raw.trim();
|
|
2345
|
-
if (stringValue.length > 0) {
|
|
2346
|
-
return {
|
|
2347
|
-
isSpam: true,
|
|
2348
|
-
reason: "honeypot",
|
|
2349
|
-
details: `Honeypot field "${fieldName}" was filled`
|
|
2350
|
-
};
|
|
2351
|
-
}
|
|
2352
|
-
}
|
|
2353
|
-
}
|
|
2354
|
-
return { isSpam: false };
|
|
2355
|
-
}
|
|
2356
|
-
function checkRateLimit(ipAddress, formSlug, config) {
|
|
2357
|
-
const key = `${formSlug}:${ipAddress}`;
|
|
2358
|
-
const now = Date.now();
|
|
2359
|
-
const entry = rateLimitStore.get(key);
|
|
2360
|
-
if (entry) {
|
|
2361
|
-
if (now - entry.timestamp > config.windowMs) {
|
|
2362
|
-
rateLimitStore.set(key, { count: 1, timestamp: now });
|
|
2363
|
-
return { isSpam: false };
|
|
2364
|
-
}
|
|
2365
|
-
if (entry.count >= config.maxSubmissions) {
|
|
2366
|
-
return {
|
|
2367
|
-
isSpam: true,
|
|
2368
|
-
reason: "rate_limit",
|
|
2369
|
-
details: `Rate limit exceeded: ${entry.count}/${config.maxSubmissions} in ${config.windowMs}ms`
|
|
2370
|
-
};
|
|
2371
|
-
}
|
|
2372
|
-
rateLimitStore.set(key, {
|
|
2373
|
-
count: entry.count + 1,
|
|
2374
|
-
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"
|
|
2375
2766
|
});
|
|
2376
|
-
} else {
|
|
2377
|
-
rateLimitStore.set(key, { count: 1, timestamp: now });
|
|
2378
2767
|
}
|
|
2379
|
-
return { isSpam: false };
|
|
2380
|
-
}
|
|
2381
|
-
function cleanupRateLimitStore(maxAgeMs = 5 * 60 * 1e3) {
|
|
2382
|
-
const now = Date.now();
|
|
2383
|
-
let removed = 0;
|
|
2384
|
-
for (const [key, entry] of rateLimitStore.entries()) {
|
|
2385
|
-
if (now - entry.timestamp > maxAgeMs) {
|
|
2386
|
-
rateLimitStore.delete(key);
|
|
2387
|
-
removed++;
|
|
2388
|
-
}
|
|
2389
|
-
}
|
|
2390
|
-
return removed;
|
|
2391
2768
|
}
|
|
2392
|
-
function
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
rateLimitStore.clear();
|
|
2397
|
-
}
|
|
2398
|
-
function isRateLimited(ipAddress, formSlug, config) {
|
|
2399
|
-
const key = `${formSlug}:${ipAddress}`;
|
|
2400
|
-
const now = Date.now();
|
|
2401
|
-
const entry = rateLimitStore.get(key);
|
|
2402
|
-
if (!entry) {
|
|
2403
|
-
return false;
|
|
2769
|
+
function validateConditionalLogic(field, path, errors) {
|
|
2770
|
+
const { conditionalLogic } = field;
|
|
2771
|
+
if (!conditionalLogic || !conditionalLogic.enabled) {
|
|
2772
|
+
return;
|
|
2404
2773
|
}
|
|
2405
|
-
if (
|
|
2406
|
-
|
|
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;
|
|
2407
2781
|
}
|
|
2408
|
-
|
|
2409
|
-
}
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
const { collections } = pluginContext.services;
|
|
2416
|
-
const { logger } = pluginContext;
|
|
2417
|
-
try {
|
|
2418
|
-
const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);
|
|
2419
|
-
if (!form) {
|
|
2420
|
-
logger.warn?.("Form submission attempted for non-existent form", {
|
|
2421
|
-
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"
|
|
2422
2789
|
});
|
|
2423
|
-
return { success: false, error: NO_SUCH_FORM };
|
|
2424
2790
|
}
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
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"
|
|
2431
2796
|
});
|
|
2432
|
-
return {
|
|
2433
|
-
success: false,
|
|
2434
|
-
error: availability.kind === "closed" ? availability.message : NO_SUCH_FORM
|
|
2435
|
-
};
|
|
2436
|
-
}
|
|
2437
|
-
const settings = normalizeFormSettings(form.settings);
|
|
2438
|
-
if (settings.allowMultipleSubmissions === false && !metadata?.ipAddress) {
|
|
2439
|
-
logger.warn?.(
|
|
2440
|
-
"Single-submission form received a submission without an IP \u2014 the restriction cannot apply",
|
|
2441
|
-
{ formSlug }
|
|
2442
|
-
);
|
|
2443
2797
|
}
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
error: "You have already submitted this form."
|
|
2464
|
-
};
|
|
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
|
+
}
|
|
2465
2817
|
}
|
|
2818
|
+
break;
|
|
2466
2819
|
}
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
rateLimit: pluginConfig.spamProtection.rateLimit,
|
|
2479
|
-
recaptcha: {
|
|
2480
|
-
...pluginConfig.spamProtection.recaptcha,
|
|
2481
|
-
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
|
+
});
|
|
2482
2831
|
}
|
|
2483
2832
|
}
|
|
2484
|
-
|
|
2485
|
-
if (spamResult.isSpam && spamResult.reason === "rate_limit") {
|
|
2486
|
-
logger.info?.("Spam submission rejected (rate limit)", {
|
|
2487
|
-
formSlug,
|
|
2488
|
-
ipAddress: metadata?.ipAddress
|
|
2489
|
-
});
|
|
2490
|
-
return { success: true };
|
|
2491
|
-
}
|
|
2492
|
-
const isContentSpam = spamResult.isSpam;
|
|
2493
|
-
if (isContentSpam) {
|
|
2494
|
-
logger.info?.("Spam submission detected \u2014 storing flagged", {
|
|
2495
|
-
formSlug,
|
|
2496
|
-
reason: spamResult.reason,
|
|
2497
|
-
ipAddress: metadata?.ipAddress
|
|
2498
|
-
});
|
|
2833
|
+
break;
|
|
2499
2834
|
}
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
return {
|
|
2511
|
-
success: false,
|
|
2512
|
-
error: "Validation failed",
|
|
2513
|
-
validationErrors: prepared.validationErrors
|
|
2514
|
-
};
|
|
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;
|
|
2515
2845
|
}
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
keepAsEvidence: isContentSpam,
|
|
2530
|
-
form: { id: form.id, fields: form.fields }
|
|
2531
|
-
}),
|
|
2532
|
-
// Public form submission — create as system. No ambient user; an
|
|
2533
|
-
// empty context already resolves to system, but be explicit.
|
|
2534
|
-
{ as: "system" }
|
|
2535
|
-
);
|
|
2536
|
-
logger.info?.("Form submission created successfully", {
|
|
2537
|
-
formSlug,
|
|
2538
|
-
submissionId: submission.item.id,
|
|
2539
|
-
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"
|
|
2540
2859
|
});
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
pluginConfig,
|
|
2551
|
-
pluginContext
|
|
2552
|
-
);
|
|
2553
|
-
if (isContentSpam) {
|
|
2554
|
-
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);
|
|
2555
2869
|
}
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
// integration test reads `submission.id` off the result because nothing
|
|
2561
|
-
// at compile time will.
|
|
2562
|
-
submission: asSubmissionDocument(submission.item),
|
|
2563
|
-
redirect
|
|
2564
|
-
};
|
|
2565
|
-
} catch (error) {
|
|
2566
|
-
logger.error?.("Form submission error", {
|
|
2567
|
-
formSlug,
|
|
2568
|
-
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"
|
|
2569
2874
|
});
|
|
2570
|
-
return
|
|
2571
|
-
success: false,
|
|
2572
|
-
error: "An error occurred processing your submission. Please try again."
|
|
2573
|
-
};
|
|
2875
|
+
return;
|
|
2574
2876
|
}
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
const { collections } = pluginContext.services;
|
|
2579
|
-
const result = await collections.listEntries(
|
|
2580
|
-
pluginConfig.formOverrides.slug,
|
|
2581
|
-
{ where: { slug: { equals: slug } }, pagination: { limit: 1 } },
|
|
2582
|
-
{ as: "system" }
|
|
2583
|
-
);
|
|
2584
|
-
const form = result.data?.[0];
|
|
2585
|
-
return form ? asFormDocument(form) : null;
|
|
2586
|
-
} catch {
|
|
2587
|
-
return null;
|
|
2877
|
+
validateFieldName(f.name, path, errors, seenNames);
|
|
2878
|
+
if (fieldType !== "hidden") {
|
|
2879
|
+
validateFieldLabel(f.label, path, errors);
|
|
2588
2880
|
}
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
if (!settings) return void 0;
|
|
2593
|
-
if (settings.confirmationType === "redirect" && settings.redirectUrl) {
|
|
2594
|
-
return settings.redirectUrl;
|
|
2881
|
+
validateFieldByType(field, path, errors);
|
|
2882
|
+
if (f.conditionalLogic) {
|
|
2883
|
+
validateConditionalLogic(field, path, errors);
|
|
2595
2884
|
}
|
|
2596
|
-
const field = pickedDocumentField(settings);
|
|
2597
|
-
if (!field) return void 0;
|
|
2598
|
-
return urlForPickedDocument(
|
|
2599
|
-
settings[field],
|
|
2600
|
-
form,
|
|
2601
|
-
pluginConfig,
|
|
2602
|
-
pluginContext
|
|
2603
|
-
);
|
|
2604
2885
|
}
|
|
2605
|
-
|
|
2606
|
-
const
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
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"
|
|
2612
2900
|
});
|
|
2613
|
-
return
|
|
2901
|
+
return;
|
|
2614
2902
|
}
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
"Form
|
|
2619
|
-
|
|
2620
|
-
);
|
|
2621
|
-
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;
|
|
2622
2910
|
}
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
) === "unreachable") {
|
|
2629
|
-
logger.warn?.("Form redirects to a page that has never been published", {
|
|
2630
|
-
form: form.slug,
|
|
2631
|
-
collection: reference.collection,
|
|
2632
|
-
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"
|
|
2633
2916
|
});
|
|
2634
|
-
return
|
|
2917
|
+
return;
|
|
2635
2918
|
}
|
|
2636
|
-
|
|
2919
|
+
validateFieldsArray(fields, path, errors);
|
|
2637
2920
|
}
|
|
2638
|
-
function
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
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
|
+
});
|
|
2647
2941
|
}
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
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"
|
|
2654
2948
|
});
|
|
2655
|
-
return void 0;
|
|
2656
2949
|
}
|
|
2657
2950
|
}
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
);
|
|
2672
|
-
} catch (error) {
|
|
2673
|
-
logger.warn?.("Form redirect target could not be read", {
|
|
2674
|
-
...context,
|
|
2675
|
-
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"
|
|
2676
2964
|
});
|
|
2677
|
-
return
|
|
2965
|
+
return;
|
|
2678
2966
|
}
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
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
|
+
}
|
|
2682
2978
|
}
|
|
2683
|
-
return row;
|
|
2684
2979
|
}
|
|
2685
|
-
|
|
2686
|
-
const
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
const prepared = prepareSubmission({
|
|
2692
|
-
data,
|
|
2693
|
-
fields: form.fields,
|
|
2694
|
-
validate: true
|
|
2695
|
-
});
|
|
2696
|
-
if (!prepared.validationErrors) {
|
|
2697
|
-
return { valid: true };
|
|
2698
|
-
}
|
|
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);
|
|
2699
2986
|
return {
|
|
2700
|
-
valid:
|
|
2701
|
-
errors
|
|
2987
|
+
valid: errors.length === 0,
|
|
2988
|
+
errors
|
|
2702
2989
|
};
|
|
2703
2990
|
}
|
|
2704
|
-
|
|
2705
|
-
const
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
async function getFormSubmissionStats(formSlug, context) {
|
|
2713
|
-
const { pluginContext, pluginConfig } = context;
|
|
2714
|
-
const { collections } = pluginContext.services;
|
|
2715
|
-
const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);
|
|
2716
|
-
if (!form) {
|
|
2717
|
-
return null;
|
|
2718
|
-
}
|
|
2719
|
-
try {
|
|
2720
|
-
const submissionsSlug = pluginConfig.formSubmissionOverrides.slug;
|
|
2721
|
-
const sys = { as: "system" };
|
|
2722
|
-
const base = { form: { equals: form.id } };
|
|
2723
|
-
const [total, newCount, read, archived] = await Promise.all([
|
|
2724
|
-
collections.count(submissionsSlug, { where: base }, sys),
|
|
2725
|
-
collections.count(
|
|
2726
|
-
submissionsSlug,
|
|
2727
|
-
{ where: { ...base, status: { equals: "new" } } },
|
|
2728
|
-
sys
|
|
2729
|
-
),
|
|
2730
|
-
collections.count(
|
|
2731
|
-
submissionsSlug,
|
|
2732
|
-
{ where: { ...base, status: { equals: "read" } } },
|
|
2733
|
-
sys
|
|
2734
|
-
),
|
|
2735
|
-
collections.count(
|
|
2736
|
-
submissionsSlug,
|
|
2737
|
-
{ where: { ...base, status: { equals: "archived" } } },
|
|
2738
|
-
sys
|
|
2739
|
-
)
|
|
2740
|
-
]);
|
|
2741
|
-
return { total, new: newCount, read, archived };
|
|
2742
|
-
} catch {
|
|
2743
|
-
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
|
+
);
|
|
2744
2999
|
}
|
|
2745
3000
|
}
|
|
2746
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
|
+
|
|
2747
3060
|
// src/handlers/webhooks.ts
|
|
2748
3061
|
import { safeFetch, ExternalUrlError } from "nextly";
|
|
2749
3062
|
var WEBHOOK_TIMEOUT_MS = 1e4;
|
|
@@ -2934,8 +3247,6 @@ export {
|
|
|
2934
3247
|
assertValidFormConfig,
|
|
2935
3248
|
checkSpam,
|
|
2936
3249
|
checkbox2 as checkbox,
|
|
2937
|
-
cleanupRateLimitStore,
|
|
2938
|
-
clearRateLimitStore,
|
|
2939
3250
|
createFormConfig,
|
|
2940
3251
|
date3 as date,
|
|
2941
3252
|
downloadFile,
|
|
@@ -2954,7 +3265,6 @@ export {
|
|
|
2954
3265
|
generateZodSchema,
|
|
2955
3266
|
getFormBuilderConfig,
|
|
2956
3267
|
getFormSubmissionStats,
|
|
2957
|
-
getRateLimitStoreSize,
|
|
2958
3268
|
getSupportedComparisonOperators,
|
|
2959
3269
|
getSupportedWebhookEvents,
|
|
2960
3270
|
getValidationErrors,
|
|
@@ -2966,7 +3276,6 @@ export {
|
|
|
2966
3276
|
isFormField,
|
|
2967
3277
|
isFormFieldType,
|
|
2968
3278
|
isKnownFormField,
|
|
2969
|
-
isRateLimited,
|
|
2970
3279
|
isSelectFormField,
|
|
2971
3280
|
isTextFormField,
|
|
2972
3281
|
isValidComparisonOperator,
|