@nextlyhq/plugin-form-builder 0.0.2-alpha.73 → 0.0.2-alpha.75
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/README.md +2 -1
- package/dist/admin/index.d.ts +88 -65
- package/dist/admin/index.js +3512 -1458
- package/dist/admin/index.js.map +1 -1
- package/dist/{field-references-CtzGg51W.js → field-references-oCuFEwkr.js} +1256 -524
- package/dist/field-references-oCuFEwkr.js.map +1 -0
- package/dist/index.d.ts +11 -5
- package/dist/index.js +1900 -408
- package/dist/index.js.map +1 -1
- package/dist/{new-submissions-CzlhVGhH.d.ts → new-submissions-DGiIhdBF.d.ts} +96 -5
- package/package.json +10 -10
- package/dist/field-references-CtzGg51W.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { A as isKnownFormField, D as
|
|
1
|
+
import { $ as SCHEMA_ONLY_READ, A as applyFieldDefaults, B as downloadFile, C as isKnownFormField, D as normalizeFormSettings, E as fieldsHiddenByCondition, F as warnIfWebhooksConfigured, G as generateExportFilename, H as exportToCSV, J as getSupportedComparisonOperators, K as evaluateConditions, M as createFormConfig, N as pluralize, O as DEFAULT_FORM_SETTINGS, P as toTitleCase, Q as SCHEMA_ONLY_CONTEXT, R as formsEmailTemplates, S as isFormFieldType, T as isTextFormField, U as exportToJSON, V as exportAndDownload, W as formatExportValue, X as NEW_SUBMISSIONS_WIDGET_ID, Y as isValidComparisonOperator, Z as readNewSubmissions, _ as BUILT_IN_FORM_FIELD_TYPES, at as addressedFormPages, b as isFileFormField, d as generateZodSchema, et as documentReachability, f as getValidationErrors, h as validateFormData, j as applyFormDefaults, k as DEFAULT_NOTIFICATION_SETTINGS, m as transformFormData, nt as pickedDocumentField, p as getValidationIssues, q as evaluateSingleCondition, r as parseFieldRef, rt as FORM_EDITOR_VIEW, tt as parseRedirectReference, u as oneResponseFieldProblem, v as isCheckboxFormField, w as isSelectFormField, x as isFormField, y as isEmailFormField } from "./field-references-oCuFEwkr.js";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { MAX_WIDGET_ROWS, NextlyError, defineJob, definePlugin, defineWidget, isLocaleSelector, listEntries, setting } from "@nextlyhq/plugin-sdk";
|
|
5
|
+
import { NO_SUCH_FORM, NextlyError as NextlyError$1, RateLimiter, buildPaginatedResponse, checkbox as checkbox$1, clampLimit, date as date$1, formAvailability, group, isPluginFieldTypeOnSurface, json, number as number$1, relationship, resolveRateLimitStore, respondAction, respondDoc, respondList, select as select$1, stripHtmlTags, text as text$1, textarea as textarea$1, trustedClientIp } from "nextly";
|
|
5
6
|
import { previewUrlFromTemplate } from "nextly/config";
|
|
6
7
|
import { z } from "zod";
|
|
7
8
|
|
|
@@ -476,6 +477,20 @@ function redirectRefusal(message, field) {
|
|
|
476
477
|
message
|
|
477
478
|
}] });
|
|
478
479
|
}
|
|
480
|
+
/**
|
|
481
|
+
* A field read rule admitting the callers who can update forms of this
|
|
482
|
+
* collection, and no one else: what the people managing a form may see, and
|
|
483
|
+
* a visitor may not.
|
|
484
|
+
*
|
|
485
|
+
* A super-admin is admitted by role. They may save any form, since the
|
|
486
|
+
* collection gate admits them before any rule, but field rules still apply
|
|
487
|
+
* to them, and their role need not hold `update-<slug>` as a stored grant. A
|
|
488
|
+
* field they could save but not read would come back empty to the editor
|
|
489
|
+
* and be saved empty. So everyone who can save a form can read these fields.
|
|
490
|
+
*/
|
|
491
|
+
function readableByFormEditors(slug) {
|
|
492
|
+
return ({ permissions, roles }) => permissions.includes(`update-${slug}`) || roles.includes("super-admin");
|
|
493
|
+
}
|
|
479
494
|
function formsCollection(pluginConfig) {
|
|
480
495
|
const { slug, labels, fields: additionalFields, access: accessOverrides, hooks: hookOverrides, admin: adminOverrides, ...overrides } = pluginConfig.formOverrides;
|
|
481
496
|
const redirectCollections = Object.keys(pluginConfig.redirectRelationships);
|
|
@@ -545,8 +560,40 @@ function formsCollection(pluginConfig) {
|
|
|
545
560
|
settingsFields.push(checkbox$1({
|
|
546
561
|
name: "allowMultipleSubmissions",
|
|
547
562
|
label: "Allow Multiple Submissions",
|
|
548
|
-
|
|
549
|
-
|
|
563
|
+
admin: { hidden: true }
|
|
564
|
+
}), select$1({
|
|
565
|
+
name: "oneResponse",
|
|
566
|
+
label: "One response per person",
|
|
567
|
+
options: [
|
|
568
|
+
{
|
|
569
|
+
label: "Allow several responses",
|
|
570
|
+
value: "off"
|
|
571
|
+
},
|
|
572
|
+
{
|
|
573
|
+
label: "One per email address",
|
|
574
|
+
value: "email"
|
|
575
|
+
},
|
|
576
|
+
{
|
|
577
|
+
label: "One per browser",
|
|
578
|
+
value: "browser"
|
|
579
|
+
},
|
|
580
|
+
{
|
|
581
|
+
label: "One per network (IP address)",
|
|
582
|
+
value: "ip"
|
|
583
|
+
}
|
|
584
|
+
]
|
|
585
|
+
}), text$1({
|
|
586
|
+
name: "oneResponseField",
|
|
587
|
+
label: "Email field for one response per person"
|
|
588
|
+
}), number$1({
|
|
589
|
+
name: "oneResponseVersion",
|
|
590
|
+
label: "One-response version",
|
|
591
|
+
defaultValue: 0,
|
|
592
|
+
admin: { hidden: true }
|
|
593
|
+
}), number$1({
|
|
594
|
+
name: "oneResponseKeyedVersion",
|
|
595
|
+
label: "One-response keys made for version",
|
|
596
|
+
admin: { hidden: true }
|
|
550
597
|
}), checkbox$1({
|
|
551
598
|
name: "honeypotEnabled",
|
|
552
599
|
label: "Honeypot",
|
|
@@ -554,7 +601,47 @@ function formsCollection(pluginConfig) {
|
|
|
554
601
|
}), checkbox$1({
|
|
555
602
|
name: "captchaEnabled",
|
|
556
603
|
label: "Enable reCAPTCHA",
|
|
557
|
-
admin: {
|
|
604
|
+
admin: { hidden: true }
|
|
605
|
+
}), select$1({
|
|
606
|
+
name: "botCheck",
|
|
607
|
+
label: "Bot check",
|
|
608
|
+
options: [
|
|
609
|
+
{
|
|
610
|
+
label: "Site default",
|
|
611
|
+
value: "default"
|
|
612
|
+
},
|
|
613
|
+
{
|
|
614
|
+
label: "Off",
|
|
615
|
+
value: "off"
|
|
616
|
+
},
|
|
617
|
+
{
|
|
618
|
+
label: "reCAPTCHA",
|
|
619
|
+
value: "recaptcha"
|
|
620
|
+
},
|
|
621
|
+
{
|
|
622
|
+
label: "Turnstile",
|
|
623
|
+
value: "turnstile"
|
|
624
|
+
}
|
|
625
|
+
]
|
|
626
|
+
}), date$1({
|
|
627
|
+
name: "closesAt",
|
|
628
|
+
label: "Closes at"
|
|
629
|
+
}), number$1({
|
|
630
|
+
name: "submissionLimit",
|
|
631
|
+
label: "Submission limit",
|
|
632
|
+
min: 1
|
|
633
|
+
}), checkbox$1({
|
|
634
|
+
name: "storeIpAndBrowser",
|
|
635
|
+
label: "Store IP address and browser",
|
|
636
|
+
defaultValue: true
|
|
637
|
+
}), number$1({
|
|
638
|
+
name: "keepDays",
|
|
639
|
+
label: "Keep submissions for (days)",
|
|
640
|
+
min: 1
|
|
641
|
+
}), textarea$1({
|
|
642
|
+
name: "internalNote",
|
|
643
|
+
label: "Internal note",
|
|
644
|
+
access: { read: readableByFormEditors(slug) }
|
|
558
645
|
}), text$1({
|
|
559
646
|
name: "captchaSiteKey",
|
|
560
647
|
label: "reCAPTCHA Site Key",
|
|
@@ -601,7 +688,8 @@ function formsCollection(pluginConfig) {
|
|
|
601
688
|
name: "notifications",
|
|
602
689
|
label: "Notifications",
|
|
603
690
|
defaultValue: [],
|
|
604
|
-
admin: { description: "Email notification rules. Managed by the Form Builder UI." }
|
|
691
|
+
admin: { description: "Email notification rules. Managed by the Form Builder UI." },
|
|
692
|
+
access: { read: readableByFormEditors(slug) }
|
|
605
693
|
}),
|
|
606
694
|
select$1({
|
|
607
695
|
name: "status",
|
|
@@ -660,7 +748,7 @@ function formsCollection(pluginConfig) {
|
|
|
660
748
|
order: 50,
|
|
661
749
|
useAsTitle: "name",
|
|
662
750
|
description: "Create and manage your form templates using the visual form builder. Create and manage forms for collecting user submissions",
|
|
663
|
-
components: { views: { Edit: { Component:
|
|
751
|
+
components: { views: { Edit: { Component: FORM_EDITOR_VIEW } } },
|
|
664
752
|
...adminOverrides
|
|
665
753
|
},
|
|
666
754
|
access: accessWithDefaults(accessOverrides, { read: true }),
|
|
@@ -698,6 +786,15 @@ function formsCollection(pluginConfig) {
|
|
|
698
786
|
function trustedSubmittedAt(context) {
|
|
699
787
|
return context.req?.http === void 0 && Boolean(context.data?.submittedAt);
|
|
700
788
|
}
|
|
789
|
+
/** The unique index one response per person rests on. */
|
|
790
|
+
const RESPONSE_KEY_INDEX = {
|
|
791
|
+
fields: ["form", "responseKey"],
|
|
792
|
+
unique: true
|
|
793
|
+
};
|
|
794
|
+
/** Whether two index declarations name the same columns and uniqueness. */
|
|
795
|
+
function sameIndex(index, other) {
|
|
796
|
+
return index.fields.join("\0") === other.fields.join("\0") && (index.unique ?? false) === (other.unique ?? false);
|
|
797
|
+
}
|
|
701
798
|
/**
|
|
702
799
|
* Generate the Form Submissions collection configuration.
|
|
703
800
|
*
|
|
@@ -820,6 +917,11 @@ function submissionsCollection(pluginConfig) {
|
|
|
820
917
|
readOnly: true,
|
|
821
918
|
description: "ID of the admin who last edited the submitted data"
|
|
822
919
|
}
|
|
920
|
+
}),
|
|
921
|
+
text$1({
|
|
922
|
+
name: "responseKey",
|
|
923
|
+
label: "One-response key",
|
|
924
|
+
admin: { hidden: true }
|
|
823
925
|
})
|
|
824
926
|
];
|
|
825
927
|
let finalFields;
|
|
@@ -864,7 +966,8 @@ function submissionsCollection(pluginConfig) {
|
|
|
864
966
|
return data;
|
|
865
967
|
}]
|
|
866
968
|
},
|
|
867
|
-
...overrides
|
|
969
|
+
...overrides,
|
|
970
|
+
indexes: [RESPONSE_KEY_INDEX, ...(overrides.indexes ?? []).filter((index) => !sameIndex(index, RESPONSE_KEY_INDEX))]
|
|
868
971
|
};
|
|
869
972
|
}
|
|
870
973
|
|
|
@@ -938,8 +1041,14 @@ function newSubmissionsWidget(declared, submissionsPath) {
|
|
|
938
1041
|
signal
|
|
939
1042
|
}) > 0,
|
|
940
1043
|
skeleton: "list",
|
|
941
|
-
|
|
942
|
-
|
|
1044
|
+
settings: { limit: setting.number({
|
|
1045
|
+
label: "Number of entries",
|
|
1046
|
+
defaultValue: 5,
|
|
1047
|
+
min: 1,
|
|
1048
|
+
max: MAX_WIDGET_ROWS
|
|
1049
|
+
}) },
|
|
1050
|
+
load: async ({ services, settings, signal, scope }) => readNewSubmissions(services, declared, {
|
|
1051
|
+
limit: listEntries(settings.limit),
|
|
943
1052
|
signal,
|
|
944
1053
|
scope,
|
|
945
1054
|
submissionsPath: submissionsPath()
|
|
@@ -988,408 +1097,609 @@ function asFormDocument(row) {
|
|
|
988
1097
|
return row;
|
|
989
1098
|
}
|
|
990
1099
|
|
|
991
|
-
// src/
|
|
992
|
-
/**
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
*/
|
|
1013
|
-
function
|
|
1014
|
-
const
|
|
1100
|
+
// src/emails/answers.ts
|
|
1101
|
+
/** Whether a stored answer counts as given. */
|
|
1102
|
+
function isAnswered(value) {
|
|
1103
|
+
if (value === void 0 || value === null) return false;
|
|
1104
|
+
if (typeof value === "string") return value.trim() !== "";
|
|
1105
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
1106
|
+
return true;
|
|
1107
|
+
}
|
|
1108
|
+
/** A choice field's stored value(s) replaced by its option labels. */
|
|
1109
|
+
function choiceLabels(field, value) {
|
|
1110
|
+
const options = field.options;
|
|
1111
|
+
if (!Array.isArray(options)) return value;
|
|
1112
|
+
const label = (v) => options.find((option) => option.value === v)?.label ?? v;
|
|
1113
|
+
return Array.isArray(value) ? value.map(label) : label(value);
|
|
1114
|
+
}
|
|
1115
|
+
/** A file answer: how many, and whether they come with the email. */
|
|
1116
|
+
function fileAnswer(field, value) {
|
|
1117
|
+
const count = Array.isArray(value) ? value.length : 1;
|
|
1118
|
+
const files = count === 1 ? "1 file" : `${count} files`;
|
|
1119
|
+
return field.attachToEmail ? `${files}, attached` : `${files}, uploaded`;
|
|
1120
|
+
}
|
|
1121
|
+
/** Whether the form's conditions showed `field` for these answers. */
|
|
1122
|
+
function wasShown(field, data) {
|
|
1123
|
+
const logic = field.conditionalLogic;
|
|
1124
|
+
return evaluateConditions(logic, data);
|
|
1125
|
+
}
|
|
1126
|
+
/** The answers in a submission, for a notification's `_submission.answers`. */
|
|
1127
|
+
function submissionAnswers(fields, data) {
|
|
1128
|
+
const answers = [];
|
|
1015
1129
|
for (const field of fields) {
|
|
1016
|
-
if (
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1130
|
+
if (field.type === "hidden") continue;
|
|
1131
|
+
const value = data[field.name];
|
|
1132
|
+
if (!isAnswered(value) || !wasShown(field, data)) continue;
|
|
1133
|
+
if (field.type === "file") {
|
|
1134
|
+
answers.push({
|
|
1135
|
+
label: field.label || field.name,
|
|
1136
|
+
value: fileAnswer(field, value)
|
|
1137
|
+
});
|
|
1022
1138
|
continue;
|
|
1023
1139
|
}
|
|
1024
|
-
const
|
|
1025
|
-
|
|
1140
|
+
const shown = field.type === "select" || field.type === "radio" ? choiceLabels(field, value) : value;
|
|
1141
|
+
answers.push({
|
|
1142
|
+
label: field.label || field.name,
|
|
1143
|
+
value: formatExportValue(shown, field)
|
|
1144
|
+
});
|
|
1026
1145
|
}
|
|
1027
|
-
return
|
|
1146
|
+
return answers;
|
|
1028
1147
|
}
|
|
1148
|
+
|
|
1149
|
+
// src/handlers/bot-check-send.ts
|
|
1150
|
+
/** Where each provider verifies a token. */
|
|
1151
|
+
const VERIFY_URLS = {
|
|
1152
|
+
recaptcha: "https://www.google.com/recaptcha/api/siteverify",
|
|
1153
|
+
turnstile: "https://challenges.cloudflare.com/turnstile/v0/siteverify"
|
|
1154
|
+
};
|
|
1155
|
+
/** The hosts those requests go to, for the plugin's outbound allowlist. */
|
|
1156
|
+
const VERIFY_HOSTS = {
|
|
1157
|
+
recaptcha: "www.google.com",
|
|
1158
|
+
turnstile: "challenges.cloudflare.com"
|
|
1159
|
+
};
|
|
1029
1160
|
/**
|
|
1030
|
-
*
|
|
1161
|
+
* Ask `provider` whether `token` is good, as both providers take it: a form
|
|
1162
|
+
* post of the secret, the token and, where known, the visitor's address.
|
|
1163
|
+
* Answers the provider's JSON; throws when it could not be reached or did
|
|
1164
|
+
* not answer 200.
|
|
1031
1165
|
*/
|
|
1032
|
-
function
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
case "time": return generateTimeSchema(field);
|
|
1046
|
-
case "hidden": return generateHiddenSchema(field);
|
|
1047
|
-
default: return z.unknown();
|
|
1048
|
-
}
|
|
1166
|
+
async function sendVerification(fetch, provider, request) {
|
|
1167
|
+
const body = new URLSearchParams({
|
|
1168
|
+
secret: request.secret,
|
|
1169
|
+
response: request.token,
|
|
1170
|
+
...request.ip ? { remoteip: request.ip } : {}
|
|
1171
|
+
});
|
|
1172
|
+
const response = await fetch(VERIFY_URLS[provider], {
|
|
1173
|
+
method: "POST",
|
|
1174
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
1175
|
+
body: body.toString()
|
|
1176
|
+
});
|
|
1177
|
+
if (!response.ok) throw new Error(`${provider} verification answered ${response.status}`);
|
|
1178
|
+
return response.json();
|
|
1049
1179
|
}
|
|
1180
|
+
|
|
1181
|
+
// src/handlers/bot-check.ts
|
|
1182
|
+
/**
|
|
1183
|
+
* Bot checks: Google reCAPTCHA v3, which scores each visitor in the
|
|
1184
|
+
* background, and Cloudflare Turnstile, which checks them in the background
|
|
1185
|
+
* and rarely shows a puzzle. Either gives the visitor's browser a token,
|
|
1186
|
+
* which the site's form sends with the submission under the provider's own
|
|
1187
|
+
* field name, and which the server verifies with the provider before the
|
|
1188
|
+
* submission is accepted.
|
|
1189
|
+
*
|
|
1190
|
+
* A token that fails, or a submission that brings none, is stored flagged as
|
|
1191
|
+
* spam, as a honeypot hit is, so a false positive stays reviewable and
|
|
1192
|
+
* recoverable. A provider that cannot be reached leaves the submission
|
|
1193
|
+
* unchecked, and says so in the log: an outage must not send every real
|
|
1194
|
+
* submission to spam. A form whose check has no secret configured is
|
|
1195
|
+
* accepted unchecked, as before bot checks were verified, with a warning at
|
|
1196
|
+
* start and in Settings.
|
|
1197
|
+
*
|
|
1198
|
+
* @module handlers/bot-check
|
|
1199
|
+
*/
|
|
1200
|
+
/** The field each provider's widget puts its token in, as forms send it. */
|
|
1201
|
+
const TOKEN_FIELDS = {
|
|
1202
|
+
recaptcha: "g-recaptcha-response",
|
|
1203
|
+
turnstile: "cf-turnstile-response"
|
|
1204
|
+
};
|
|
1205
|
+
/** The score below which reCAPTCHA calls a visitor a bot, unless set. */
|
|
1206
|
+
const DEFAULT_SCORE_THRESHOLD = .5;
|
|
1050
1207
|
/**
|
|
1051
|
-
*
|
|
1208
|
+
* The provider a form's bot check names, its site default resolved: the
|
|
1209
|
+
* plugin's reCAPTCHA when enabled, else its Turnstile when enabled, else none.
|
|
1052
1210
|
*/
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
* `message` was the one rule that stayed outside this. It agreed with the table
|
|
1064
|
-
* only because every row happens to list it, which is agreement by coincidence:
|
|
1065
|
-
* drop `message` from a row and the control disappears while the wording went
|
|
1066
|
-
* on being applied.
|
|
1067
|
-
*/
|
|
1068
|
-
function customMessage(field) {
|
|
1069
|
-
if (!(ENFORCED_VALIDATION_RULES[field.type] ?? []).includes("message")) return void 0;
|
|
1070
|
-
return field.validation?.errorMessage;
|
|
1211
|
+
function botProviderOf(botCheck, spam) {
|
|
1212
|
+
if (botCheck === "off") return void 0;
|
|
1213
|
+
if (botCheck === "recaptcha" || botCheck === "turnstile") return botCheck;
|
|
1214
|
+
if (spam.recaptcha?.enabled === true) return "recaptcha";
|
|
1215
|
+
if (spam.turnstile?.enabled === true) return "turnstile";
|
|
1216
|
+
}
|
|
1217
|
+
/** The plugin's secret for `provider`, when one is set. */
|
|
1218
|
+
function secretOf(provider, spam) {
|
|
1219
|
+
const secret = spam[provider]?.secretKey;
|
|
1220
|
+
return secret ? secret : void 0;
|
|
1071
1221
|
}
|
|
1072
1222
|
/**
|
|
1073
|
-
*
|
|
1074
|
-
*
|
|
1075
|
-
*
|
|
1076
|
-
* validation object, and giving them one whose `errorMessage` has been through
|
|
1077
|
-
* {@link customMessage} makes the rule hold for every applier without each of
|
|
1078
|
-
* them having to remember it.
|
|
1223
|
+
* The site key a visitor's form renders `provider`'s widget with: the
|
|
1224
|
+
* plugin's, or, for reCAPTCHA, a key the form stored itself before keys
|
|
1225
|
+
* moved into the plugin's options.
|
|
1079
1226
|
*/
|
|
1080
|
-
function
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
};
|
|
1227
|
+
function siteKeyOf(provider, spam, settings) {
|
|
1228
|
+
const own = spam[provider]?.siteKey;
|
|
1229
|
+
if (own) return own;
|
|
1230
|
+
return provider === "recaptcha" ? settings.captchaSiteKey : void 0;
|
|
1085
1231
|
}
|
|
1086
1232
|
/**
|
|
1087
|
-
*
|
|
1088
|
-
*
|
|
1089
|
-
*
|
|
1090
|
-
*
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
const STRING_RULE_APPLIERS = {
|
|
1101
|
-
minLength: (schema, validation) => validation.minLength === void 0 ? schema : schema.min(validation.minLength, validation.errorMessage || `Minimum ${validation.minLength} characters required`),
|
|
1102
|
-
maxLength: (schema, validation) => validation.maxLength === void 0 ? schema : schema.max(validation.maxLength, validation.errorMessage || `Maximum ${validation.maxLength} characters allowed`),
|
|
1103
|
-
pattern: (schema, validation, fallback) => validation.pattern ? schema.regex(new RegExp(validation.pattern), validation.errorMessage || fallback) : schema
|
|
1104
|
-
};
|
|
1105
|
-
/** The same, for the bounds a number carries. */
|
|
1106
|
-
const NUMBER_RULE_APPLIERS = {
|
|
1107
|
-
min: (schema, validation) => validation.min === void 0 ? schema : schema.min(validation.min, validation.errorMessage || `Minimum value is ${validation.min}`),
|
|
1108
|
-
max: (schema, validation) => validation.max === void 0 ? schema : schema.max(validation.max, validation.errorMessage || `Maximum value is ${validation.max}`)
|
|
1109
|
-
};
|
|
1233
|
+
* `provider`'s keys for a form, when both are known: the secret to verify
|
|
1234
|
+
* with and a site key for the visitor's widget. A check missing either is
|
|
1235
|
+
* not run: without a site key no visitor could produce a token, and every
|
|
1236
|
+
* real submission would be flagged.
|
|
1237
|
+
*/
|
|
1238
|
+
function readyKeys(provider, spam, settings) {
|
|
1239
|
+
const secret = secretOf(provider, spam);
|
|
1240
|
+
const siteKey = siteKeyOf(provider, spam, settings);
|
|
1241
|
+
return secret && siteKey ? {
|
|
1242
|
+
secret,
|
|
1243
|
+
siteKey
|
|
1244
|
+
} : void 0;
|
|
1245
|
+
}
|
|
1110
1246
|
/**
|
|
1111
|
-
* Whether
|
|
1112
|
-
*
|
|
1113
|
-
* Two conditions, and both are load-bearing: a pattern must be stored, AND the
|
|
1114
|
-
* type's row must enforce `pattern`. A type that keeps its own shape check —
|
|
1115
|
-
* a phone, a URL — stands it down only for an override that will really be
|
|
1116
|
-
* applied, because standing down for one that will not leaves nothing behind.
|
|
1247
|
+
* Whether a form's bot check will be run: it names a provider and that
|
|
1248
|
+
* provider's keys are both known. The same answer `judgeBotCheck` acts on.
|
|
1117
1249
|
*/
|
|
1118
|
-
function
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
return Boolean(validation.pattern);
|
|
1250
|
+
function botCheckReady(settings, spam) {
|
|
1251
|
+
const provider = botProviderOf(settings.botCheck, spam);
|
|
1252
|
+
return provider !== void 0 && readyKeys(provider, spam, settings) !== void 0;
|
|
1122
1253
|
}
|
|
1254
|
+
const RECAPTCHA_ANSWER = z.object({
|
|
1255
|
+
success: z.boolean(),
|
|
1256
|
+
score: z.number().optional()
|
|
1257
|
+
});
|
|
1258
|
+
const TURNSTILE_ANSWER = z.object({ success: z.boolean() });
|
|
1123
1259
|
/**
|
|
1124
|
-
*
|
|
1125
|
-
*
|
|
1126
|
-
*
|
|
1127
|
-
* value for it, which is the point: a `pattern` stored on a textarea is a bound
|
|
1128
|
-
* the editor never offered, and honouring it here would enforce a restriction
|
|
1129
|
-
* its author could not see.
|
|
1130
|
-
*
|
|
1131
|
-
* `patternMessage` is the type's own wording for a failed pattern — "Invalid
|
|
1132
|
-
* URL format" reads differently from "Invalid format" — so the rule set decides
|
|
1133
|
-
* WHICH constraints apply while each type keeps its own voice.
|
|
1260
|
+
* Verify `token` with `provider`. A missing token fails: the form's widget
|
|
1261
|
+
* never ran. A provider that cannot be reached, or answers something that is
|
|
1262
|
+
* not its answer, leaves the submission unchecked.
|
|
1134
1263
|
*/
|
|
1135
|
-
function
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1264
|
+
async function judgeBotToken(input) {
|
|
1265
|
+
if (!input.token) return "fail";
|
|
1266
|
+
if (!input.fetch) return "unchecked";
|
|
1267
|
+
let answer;
|
|
1268
|
+
try {
|
|
1269
|
+
answer = await sendVerification(input.fetch, input.provider, {
|
|
1270
|
+
secret: input.secret,
|
|
1271
|
+
token: input.token,
|
|
1272
|
+
...input.ip ? { ip: input.ip } : {}
|
|
1273
|
+
});
|
|
1274
|
+
} catch {
|
|
1275
|
+
return "unchecked";
|
|
1141
1276
|
}
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1277
|
+
if (input.provider === "turnstile") {
|
|
1278
|
+
const parsed = TURNSTILE_ANSWER.safeParse(answer);
|
|
1279
|
+
if (!parsed.success) return "unchecked";
|
|
1280
|
+
return parsed.data.success ? "pass" : "fail";
|
|
1281
|
+
}
|
|
1282
|
+
const parsed = RECAPTCHA_ANSWER.safeParse(answer);
|
|
1283
|
+
if (!parsed.success) return "unchecked";
|
|
1284
|
+
const threshold = input.scoreThreshold ?? DEFAULT_SCORE_THRESHOLD;
|
|
1285
|
+
return parsed.data.success && (parsed.data.score ?? 0) >= threshold ? "pass" : "fail";
|
|
1146
1286
|
}
|
|
1287
|
+
const KEY = z.string().trim().min(1).optional();
|
|
1288
|
+
/** The bot-check options, as the plugin's options give them. */
|
|
1289
|
+
const BOT_CHECK_OPTIONS = z.object({
|
|
1290
|
+
recaptcha: z.object({
|
|
1291
|
+
enabled: z.boolean(),
|
|
1292
|
+
siteKey: KEY,
|
|
1293
|
+
secretKey: KEY,
|
|
1294
|
+
scoreThreshold: z.number().min(0).max(1).optional()
|
|
1295
|
+
}).optional(),
|
|
1296
|
+
turnstile: z.object({
|
|
1297
|
+
enabled: z.boolean(),
|
|
1298
|
+
siteKey: KEY,
|
|
1299
|
+
secretKey: KEY
|
|
1300
|
+
}).optional()
|
|
1301
|
+
});
|
|
1147
1302
|
/**
|
|
1148
|
-
*
|
|
1303
|
+
* The bot-check part of the plugin's options, parsed at start: a key that is
|
|
1304
|
+
* not a string, or a score threshold outside 0 to 1, fails the start with
|
|
1305
|
+
* the option named, rather than every submission later.
|
|
1149
1306
|
*/
|
|
1150
|
-
function
|
|
1151
|
-
|
|
1307
|
+
function botCheckOptions(spamProtection) {
|
|
1308
|
+
const source = spamProtection !== null && typeof spamProtection === "object" ? spamProtection : {};
|
|
1309
|
+
const parsed = BOT_CHECK_OPTIONS.safeParse({
|
|
1310
|
+
recaptcha: source.recaptcha,
|
|
1311
|
+
turnstile: source.turnstile
|
|
1312
|
+
});
|
|
1313
|
+
if (!parsed.success) {
|
|
1314
|
+
const issue = parsed.error.issues[0];
|
|
1315
|
+
throw new Error(`formBuilder: spamProtection.${issue?.path.join(".") ?? ""} is not valid: ${issue?.message ?? "invalid"}`);
|
|
1316
|
+
}
|
|
1317
|
+
return {
|
|
1318
|
+
recaptcha: parsed.data.recaptcha ?? { enabled: false },
|
|
1319
|
+
turnstile: parsed.data.turnstile ?? { enabled: false }
|
|
1320
|
+
};
|
|
1152
1321
|
}
|
|
1153
1322
|
/**
|
|
1154
|
-
*
|
|
1323
|
+
* The hosts the plugin's bot checks call, for its outbound allowlist: a
|
|
1324
|
+
* provider's only when its secret is set, so a site using neither declares
|
|
1325
|
+
* no network access at all.
|
|
1155
1326
|
*/
|
|
1156
|
-
function
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1327
|
+
function botCheckHosts(spam) {
|
|
1328
|
+
return ["recaptcha", "turnstile"].filter((provider) => secretOf(provider, spam) !== void 0).map((provider) => VERIFY_HOSTS[provider]);
|
|
1329
|
+
}
|
|
1330
|
+
/** The tokens in a submission's payload, before its own fields are kept. */
|
|
1331
|
+
function botTokensOf(payload) {
|
|
1332
|
+
const tokens = {};
|
|
1333
|
+
for (const provider of ["recaptcha", "turnstile"]) {
|
|
1334
|
+
const value = payload[TOKEN_FIELDS[provider]];
|
|
1335
|
+
if (typeof value === "string" && value.trim() !== "") tokens[provider] = value.trim();
|
|
1162
1336
|
}
|
|
1163
|
-
|
|
1164
|
-
return schema.optional().nullable();
|
|
1337
|
+
return tokens;
|
|
1165
1338
|
}
|
|
1166
1339
|
/**
|
|
1167
|
-
*
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1340
|
+
* A request-made submission's bot check: none when its form's check is off,
|
|
1341
|
+
* or names a provider missing its secret or site key (accepted unchecked, as
|
|
1342
|
+
* warned at start and in Settings); a spam verdict when the token fails or
|
|
1343
|
+
* is missing.
|
|
1344
|
+
*/
|
|
1345
|
+
async function judgeBotCheck(input) {
|
|
1346
|
+
const provider = botProviderOf(input.settings.botCheck, input.spam);
|
|
1347
|
+
if (provider === void 0) return void 0;
|
|
1348
|
+
const keys = readyKeys(provider, input.spam, input.settings);
|
|
1349
|
+
if (keys === void 0) return void 0;
|
|
1350
|
+
const verdict = await judgeBotToken({
|
|
1351
|
+
provider,
|
|
1352
|
+
secret: keys.secret,
|
|
1353
|
+
token: input.tokens[provider],
|
|
1354
|
+
...input.ip ? { ip: input.ip } : {},
|
|
1355
|
+
scoreThreshold: input.spam.recaptcha?.scoreThreshold,
|
|
1356
|
+
fetch: input.fetch
|
|
1357
|
+
});
|
|
1358
|
+
if (verdict === "unchecked") input.warn("Form Builder: the bot check could not be completed, so the submission was accepted unchecked", { provider });
|
|
1359
|
+
return verdict === "fail" ? {
|
|
1360
|
+
isSpam: true,
|
|
1361
|
+
reason: provider
|
|
1362
|
+
} : void 0;
|
|
1171
1363
|
}
|
|
1172
1364
|
/**
|
|
1173
|
-
*
|
|
1365
|
+
* Warn, once at start, for each bot check that is in use (the site default,
|
|
1366
|
+
* or named by a form) without both of its keys, naming the options: those
|
|
1367
|
+
* forms' submissions are accepted unchecked until it is. Reads at most the
|
|
1368
|
+
* first 500 forms; a failure to read them is not a reason to stop the start.
|
|
1174
1369
|
*/
|
|
1175
|
-
function
|
|
1176
|
-
|
|
1370
|
+
async function warnUncheckedBotChecks(input) {
|
|
1371
|
+
let forms = [];
|
|
1372
|
+
try {
|
|
1373
|
+
forms = await input.forms();
|
|
1374
|
+
} catch {
|
|
1375
|
+
forms = [];
|
|
1376
|
+
}
|
|
1377
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1378
|
+
for (const form of forms) {
|
|
1379
|
+
const settings = input.readSettings(form.settings);
|
|
1380
|
+
const provider = botProviderOf(settings.botCheck, input.spam);
|
|
1381
|
+
if (provider && readyKeys(provider, input.spam, settings) === void 0) counts.set(provider, (counts.get(provider) ?? 0) + 1);
|
|
1382
|
+
}
|
|
1383
|
+
const siteDefault = botProviderOf("default", input.spam);
|
|
1384
|
+
if (siteDefault && readyKeys(siteDefault, input.spam, {}) === void 0) counts.set(siteDefault, counts.get(siteDefault) ?? 0);
|
|
1385
|
+
for (const [provider, forms] of counts) input.warn(`Form Builder: the ${provider === "recaptcha" ? "reCAPTCHA" : "Turnstile"} bot check is in use without both of its keys, so its submissions are accepted unchecked. Set spamProtection.${provider}.secretKey and spamProtection.${provider}.siteKey in the plugin's options.`, {
|
|
1386
|
+
provider,
|
|
1387
|
+
forms
|
|
1388
|
+
});
|
|
1177
1389
|
}
|
|
1178
|
-
/**
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1390
|
+
/** {@link BotCheckDefaults} for the admin: whether keys are set, never the keys. */
|
|
1391
|
+
function botCheckDefaults(spam) {
|
|
1392
|
+
const keys = (provider) => ({
|
|
1393
|
+
secret: secretOf(provider, spam) !== void 0,
|
|
1394
|
+
siteKey: Boolean(spam[provider]?.siteKey)
|
|
1395
|
+
});
|
|
1396
|
+
return {
|
|
1397
|
+
botCheckDefault: botProviderOf("default", spam) ?? null,
|
|
1398
|
+
botChecks: {
|
|
1399
|
+
recaptcha: keys("recaptcha"),
|
|
1400
|
+
turnstile: keys("turnstile")
|
|
1401
|
+
},
|
|
1402
|
+
rateLimit: spam.rateLimit ?? null
|
|
1403
|
+
};
|
|
1183
1404
|
}
|
|
1184
1405
|
/**
|
|
1185
|
-
*
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
const
|
|
1195
|
-
|
|
1196
|
-
|
|
1406
|
+
* The bot check a visitor's form renders, as the public read of a form gives
|
|
1407
|
+
* it: the provider and its site key, or none when the form's check is off,
|
|
1408
|
+
* names a provider with no secret (not verified, so not shown), or has no
|
|
1409
|
+
* site key to render with.
|
|
1410
|
+
*/
|
|
1411
|
+
function publicBotCheck(storedSettings, spam) {
|
|
1412
|
+
const settings = normalizeFormSettings(storedSettings);
|
|
1413
|
+
const provider = botProviderOf(settings.botCheck, spam);
|
|
1414
|
+
if (provider === void 0) return void 0;
|
|
1415
|
+
const keys = readyKeys(provider, spam, settings);
|
|
1416
|
+
return keys ? {
|
|
1417
|
+
provider,
|
|
1418
|
+
siteKey: keys.siteKey,
|
|
1419
|
+
field: TOKEN_FIELDS[provider]
|
|
1420
|
+
} : void 0;
|
|
1197
1421
|
}
|
|
1422
|
+
|
|
1423
|
+
// src/utils/form-closing.ts
|
|
1198
1424
|
/**
|
|
1199
|
-
*
|
|
1425
|
+
* Whether a form is taking submissions now: its status, as core reads it for
|
|
1426
|
+
* every way a form is reached, then the form's closing time and its limit.
|
|
1427
|
+
*
|
|
1428
|
+
* Whatever closes a form (its author by hand, its closing time or its limit),
|
|
1429
|
+
* a visitor is told one thing: the form's closed message, or the generic
|
|
1430
|
+
* sentence when its author wrote none. That message is core's own answer for
|
|
1431
|
+
* a closed form, asked of this form, so the three causes cannot drift apart.
|
|
1432
|
+
*
|
|
1433
|
+
* @module utils/form-closing
|
|
1200
1434
|
*/
|
|
1201
|
-
function generateCheckboxSchema(field) {
|
|
1202
|
-
const schema = z.boolean();
|
|
1203
|
-
if (field.required) return schema.refine((val) => val === true, { message: customMessage(field) || "This field is required" });
|
|
1204
|
-
return schema.optional();
|
|
1205
|
-
}
|
|
1206
1435
|
/**
|
|
1207
|
-
*
|
|
1436
|
+
* The message a visitor is given when this form is closed: core's answer for
|
|
1437
|
+
* the form had its author closed it, whether or not they did.
|
|
1208
1438
|
*/
|
|
1209
|
-
function
|
|
1210
|
-
const
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1439
|
+
function closedMessageOf(form) {
|
|
1440
|
+
const asClosed = formAvailability({
|
|
1441
|
+
closedMessage: form.closedMessage,
|
|
1442
|
+
status: "closed",
|
|
1443
|
+
wentLiveAt: "live"
|
|
1444
|
+
});
|
|
1445
|
+
if (asClosed.kind !== "closed") throw new Error("a closed form that has been public explains itself");
|
|
1446
|
+
return asClosed.message;
|
|
1447
|
+
}
|
|
1448
|
+
/** Whether the form's closing time has passed at `now`. */
|
|
1449
|
+
function pastClosingTime(settings, now) {
|
|
1450
|
+
if (settings.closesAt === void 0) return false;
|
|
1451
|
+
const closesAt = Date.parse(settings.closesAt);
|
|
1452
|
+
return Number.isFinite(closesAt) && now.getTime() >= closesAt;
|
|
1453
|
+
}
|
|
1454
|
+
/** Whether `taken` non-spam submissions fill the form's limit. */
|
|
1455
|
+
function limitReached(settings, taken) {
|
|
1456
|
+
return settings.submissionLimit !== void 0 && taken >= settings.submissionLimit;
|
|
1214
1457
|
}
|
|
1215
1458
|
/**
|
|
1216
|
-
*
|
|
1217
|
-
*
|
|
1218
|
-
*
|
|
1219
|
-
*
|
|
1220
|
-
*/
|
|
1221
|
-
function
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1459
|
+
* The form's answer to a visitor now. Core decides by status first, so a
|
|
1460
|
+
* draft stays absent and a form closed by hand keeps its own answer; a
|
|
1461
|
+
* published one is then closed by its closing time, and by its limit when
|
|
1462
|
+
* `taken` (its non-spam submissions) is known.
|
|
1463
|
+
*/
|
|
1464
|
+
function formAvailabilityNow(form, settings, at) {
|
|
1465
|
+
const byStatus = formAvailability(form);
|
|
1466
|
+
if (byStatus.kind !== "open") return byStatus;
|
|
1467
|
+
const closedBy = closedByNow(settings, at);
|
|
1468
|
+
return closedBy === void 0 ? byStatus : {
|
|
1469
|
+
kind: "closed",
|
|
1470
|
+
message: closedMessageOf(form),
|
|
1471
|
+
closedBy
|
|
1472
|
+
};
|
|
1473
|
+
}
|
|
1474
|
+
/** What closes an open form now, if anything does. */
|
|
1475
|
+
function closedByNow(settings, at) {
|
|
1476
|
+
if (pastClosingTime(settings, at.now)) return "date";
|
|
1477
|
+
if (at.taken !== void 0 && limitReached(settings, at.taken)) return "limit";
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
// src/handlers/one-response.ts
|
|
1481
|
+
/** What a visitor sending a second response is told. */
|
|
1482
|
+
const ALREADY_RESPONDED = "You have already submitted this form.";
|
|
1483
|
+
/**
|
|
1484
|
+
* The answer a visitor's browser sends its token in, for a form that takes
|
|
1485
|
+
* one response per browser. The form's script keeps one random token per
|
|
1486
|
+
* browser and sends it with every submission; the row never stores it.
|
|
1487
|
+
*/
|
|
1488
|
+
const BROWSER_TOKEN_FIELD = "nextly-browser-token";
|
|
1489
|
+
/**
|
|
1490
|
+
* What every browser key starts with. The token is not stored, so a browser
|
|
1491
|
+
* key cannot be made again from the row the way an address's can; the prefix
|
|
1492
|
+
* is what lets the submission's hold tell a key made for one per browser from
|
|
1493
|
+
* one made for another mode, and refuse it once the form's mode has changed.
|
|
1494
|
+
* Digests are hex, so no address or network key can start with it.
|
|
1495
|
+
*/
|
|
1496
|
+
const BROWSER_KEY_PREFIX = "browser:";
|
|
1497
|
+
/**
|
|
1498
|
+
* What a key made under the form's one-response `version` starts with. The
|
|
1499
|
+
* version moves on each time the form changes how it tells people apart (its
|
|
1500
|
+
* mode, or the Email field it reads), so every key made before the change
|
|
1501
|
+
* stops matching a new one without a write to any earlier submission; the
|
|
1502
|
+
* keys of earlier submissions are then made again for the new version
|
|
1503
|
+
* (`one-response-rebuild`). Version 0 has no prefix, so keys stored before
|
|
1504
|
+
* versions were written keep matching.
|
|
1505
|
+
*/
|
|
1506
|
+
function keyPrefixOf(version) {
|
|
1507
|
+
return version !== void 0 && version > 0 ? `v${version}:` : "";
|
|
1508
|
+
}
|
|
1509
|
+
/** The one-response version a stored key was made under. */
|
|
1510
|
+
function keyVersionOf(key) {
|
|
1511
|
+
const match = /^v(\d+):/.exec(key);
|
|
1512
|
+
return match ? Number(match[1]) : 0;
|
|
1228
1513
|
}
|
|
1229
1514
|
/**
|
|
1230
|
-
*
|
|
1515
|
+
* Whether the form's earlier submissions are still being given keys for its
|
|
1516
|
+
* current version (`one-response-rebuild`). Unset reads as up to date: a form
|
|
1517
|
+
* saved before the version was recorded has keys for version 0.
|
|
1231
1518
|
*/
|
|
1232
|
-
function
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
if (!val) return true;
|
|
1236
|
-
const date = new Date(val);
|
|
1237
|
-
return !isNaN(date.getTime());
|
|
1238
|
-
}, { message: customMessage(field) || "Invalid date" });
|
|
1239
|
-
if (field.min || field.max) schema = schema.refine((val) => {
|
|
1240
|
-
if (!val) return true;
|
|
1241
|
-
const date = new Date(val);
|
|
1242
|
-
if (field.min && date < new Date(field.min)) return false;
|
|
1243
|
-
if (field.max && date > new Date(field.max)) return false;
|
|
1244
|
-
return true;
|
|
1245
|
-
}, { message: customMessage(field) || `Date must be between ${field.min || "any"} and ${field.max || "any"}` });
|
|
1246
|
-
if (field.required) return schema.refine((val) => val !== void 0 && val !== null && val !== "", { message: customMessage(field) || "This field is required" });
|
|
1247
|
-
return schema.optional().or(z.literal(""));
|
|
1519
|
+
function keysBehind(settings) {
|
|
1520
|
+
const keyed = settings.oneResponseKeyedVersion;
|
|
1521
|
+
return keyed !== void 0 && keyed < (settings.oneResponseVersion ?? 0);
|
|
1248
1522
|
}
|
|
1249
1523
|
/**
|
|
1250
|
-
*
|
|
1524
|
+
* What tells one person from another on a form: its mode, and for one per
|
|
1525
|
+
* email the field it reads. A change to it is a change of version.
|
|
1251
1526
|
*/
|
|
1252
|
-
function
|
|
1253
|
-
|
|
1527
|
+
function oneResponseIdentityOf(settings) {
|
|
1528
|
+
const mode = settings.oneResponse ?? "off";
|
|
1529
|
+
return mode === "email" ? `email:${settings.oneResponseField ?? ""}` : mode;
|
|
1254
1530
|
}
|
|
1255
1531
|
/**
|
|
1256
|
-
*
|
|
1532
|
+
* A browser token as the form's script makes one: a random identifier
|
|
1533
|
+
* (`crypto.randomUUID()` is one), letters, digits, `-` and `_`. Anything else
|
|
1534
|
+
* is treated as no token, so a malformed value can neither be stored as a
|
|
1535
|
+
* key nor fill the index with arbitrary text.
|
|
1257
1536
|
*/
|
|
1258
|
-
|
|
1259
|
-
|
|
1537
|
+
const BROWSER_TOKEN = z.string().trim().regex(/^[A-Za-z0-9_-]{16,128}$/);
|
|
1538
|
+
/** The browser token a submission's answers carry, if a well-formed one. */
|
|
1539
|
+
function browserTokenOf(payload) {
|
|
1540
|
+
const parsed = BROWSER_TOKEN.safeParse(answersOf(payload)?.[BROWSER_TOKEN_FIELD]);
|
|
1541
|
+
return parsed.success ? parsed.data : void 0;
|
|
1260
1542
|
}
|
|
1261
1543
|
/**
|
|
1262
|
-
*
|
|
1544
|
+
* What a visitor's form needs to take part in one response per browser: the
|
|
1545
|
+
* answer to send its token in. Other modes need nothing from the browser, and
|
|
1546
|
+
* a form that does not compare by browser says nothing.
|
|
1263
1547
|
*/
|
|
1264
|
-
function
|
|
1265
|
-
|
|
1266
|
-
|
|
1548
|
+
function publicOneResponse(settings) {
|
|
1549
|
+
return settings.oneResponse === "browser" ? {
|
|
1550
|
+
by: "browser",
|
|
1551
|
+
field: BROWSER_TOKEN_FIELD
|
|
1552
|
+
} : void 0;
|
|
1553
|
+
}
|
|
1554
|
+
/** Whether `key` was made for one response per browser, under `version`. */
|
|
1555
|
+
function isBrowserKey(key, version) {
|
|
1556
|
+
return typeof key === "string" && key.startsWith(`${keyPrefixOf(version)}${BROWSER_KEY_PREFIX}`);
|
|
1557
|
+
}
|
|
1558
|
+
/** A non-empty string, trimmed, or undefined. */
|
|
1559
|
+
function textOf(value) {
|
|
1560
|
+
if (typeof value !== "string") return void 0;
|
|
1561
|
+
const trimmed = value.trim();
|
|
1562
|
+
return trimmed === "" ? void 0 : trimmed;
|
|
1267
1563
|
}
|
|
1268
1564
|
/**
|
|
1269
|
-
*
|
|
1270
|
-
*
|
|
1271
|
-
* Handles type coercion and normalization:
|
|
1272
|
-
* - Converts string numbers to actual numbers
|
|
1273
|
-
* - Converts string booleans to actual booleans
|
|
1274
|
-
* - Trims string values
|
|
1275
|
-
* - Handles empty strings
|
|
1276
|
-
*
|
|
1277
|
-
* @param data - Raw form data
|
|
1278
|
-
* @param fields - Form field configurations
|
|
1279
|
-
* @returns Transformed data ready for validation
|
|
1565
|
+
* A submission's answers as an object: as written, or as a stored row holds
|
|
1566
|
+
* them before a read decodes them, a JSON string.
|
|
1280
1567
|
*/
|
|
1281
|
-
function
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1568
|
+
function answersOf(data) {
|
|
1569
|
+
let value = data;
|
|
1570
|
+
if (typeof value === "string") try {
|
|
1571
|
+
value = JSON.parse(value);
|
|
1572
|
+
} catch {
|
|
1573
|
+
return;
|
|
1287
1574
|
}
|
|
1288
|
-
return
|
|
1575
|
+
return value !== null && typeof value === "object" ? value : void 0;
|
|
1576
|
+
}
|
|
1577
|
+
/** The answer to `field` in a submission's data. */
|
|
1578
|
+
function answerOf(data, field) {
|
|
1579
|
+
return field === void 0 ? void 0 : answersOf(data)?.[field];
|
|
1289
1580
|
}
|
|
1290
1581
|
/**
|
|
1291
|
-
*
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
case "
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
case "select":
|
|
1314
|
-
if (field.allowMultiple) {
|
|
1315
|
-
if (!Array.isArray(value)) return value ? [value] : [];
|
|
1316
|
-
}
|
|
1317
|
-
return value;
|
|
1318
|
-
default: return value;
|
|
1582
|
+
* The submission's key under the form's one-response setting, or null when
|
|
1583
|
+
* it has none: the form allows several responses, the submission is spam,
|
|
1584
|
+
* or what the mode compares is missing (a submission with no address, or
|
|
1585
|
+
* none of the browser token, which only a create brings).
|
|
1586
|
+
*/
|
|
1587
|
+
async function responseKeyOf(digests, settings, submission) {
|
|
1588
|
+
if (submission.status === "spam") return null;
|
|
1589
|
+
const prefix = keyPrefixOf(settings.oneResponseVersion);
|
|
1590
|
+
switch (settings.oneResponse) {
|
|
1591
|
+
case "ip": {
|
|
1592
|
+
const address = textOf(submission.ipAddress);
|
|
1593
|
+
return address === void 0 ? null : `${prefix}${await digests().digest("ip", address)}`;
|
|
1594
|
+
}
|
|
1595
|
+
case "email": {
|
|
1596
|
+
const email = textOf(answerOf(submission.data, settings.oneResponseField));
|
|
1597
|
+
return email === void 0 ? null : `${prefix}${await digests().digest("email", email.toLowerCase())}`;
|
|
1598
|
+
}
|
|
1599
|
+
case "browser": {
|
|
1600
|
+
const token = submission.browserToken;
|
|
1601
|
+
return token === void 0 ? null : `${prefix}${BROWSER_KEY_PREFIX}${await digests().digest("browser", token)}`;
|
|
1602
|
+
}
|
|
1603
|
+
default: return null;
|
|
1319
1604
|
}
|
|
1320
1605
|
}
|
|
1321
1606
|
/**
|
|
1322
|
-
*
|
|
1323
|
-
*
|
|
1324
|
-
*
|
|
1325
|
-
*
|
|
1326
|
-
*
|
|
1327
|
-
*
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
* }
|
|
1339
|
-
* ```
|
|
1340
|
-
*/
|
|
1341
|
-
function validateFormData(data, fields) {
|
|
1342
|
-
const transformed = transformFormData(data, fields);
|
|
1343
|
-
return generateZodSchema(fields).safeParse(transformed);
|
|
1607
|
+
* Whether the key a new submission was given is the one `settings` give it.
|
|
1608
|
+
* An address's key is made again from the row and compared. A browser's
|
|
1609
|
+
* cannot be, since its token is not stored: under one per browser a browser
|
|
1610
|
+
* key, or none (a submission sent without a token), is the right one, and a
|
|
1611
|
+
* key made for another mode is not. Nor can a network's on a form that keeps
|
|
1612
|
+
* no addresses, where a key for the current version is the right one.
|
|
1613
|
+
*/
|
|
1614
|
+
async function holdsKeyFor(digests, settings, row) {
|
|
1615
|
+
const key = row.responseKey ?? null;
|
|
1616
|
+
if (settings.oneResponse === "browser" && row.status !== "spam") return key === null || isBrowserKey(key, settings.oneResponseVersion);
|
|
1617
|
+
if (settings.oneResponse === "ip" && textOf(row.ipAddress) === void 0 && typeof key === "string" && row.status !== "spam") return keyVersionOf(key) === (settings.oneResponseVersion ?? 0) && !key.includes(BROWSER_KEY_PREFIX);
|
|
1618
|
+
return key === await responseKeyOf(digests, settings, row);
|
|
1619
|
+
}
|
|
1620
|
+
/** Whether an update changes what a submission's key is made from. */
|
|
1621
|
+
function touchesResponseKey(patch) {
|
|
1622
|
+
return patch.status !== void 0 || patch.data !== void 0 || patch.ipAddress !== void 0;
|
|
1344
1623
|
}
|
|
1345
1624
|
/**
|
|
1346
|
-
*
|
|
1347
|
-
*
|
|
1348
|
-
*
|
|
1349
|
-
*
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1625
|
+
* A submission's key after an update, made from the stored row with the
|
|
1626
|
+
* update applied: none once it is spam; its own again when restored from
|
|
1627
|
+
* spam, unless another submission has since taken that value, when it keeps
|
|
1628
|
+
* none and the value stays with that one; a new one when its answer or
|
|
1629
|
+
* address changed, which the unique index refuses if another submission
|
|
1630
|
+
* holds it already, so an edit cannot give two submissions one value; and,
|
|
1631
|
+
* when the value is unchanged, what it held.
|
|
1632
|
+
*/
|
|
1633
|
+
async function responseKeyAfterUpdate(input) {
|
|
1634
|
+
const { patch, stored } = input;
|
|
1635
|
+
if (input.settings.oneResponse === "browser") return browserKeyAfterUpdate(patch, stored, input.settings.oneResponseVersion, input.moved);
|
|
1636
|
+
if (input.settings.oneResponse === "ip" && textOf(stored?.ipAddress) === void 0 && patch.ipAddress === void 0) return keptKeyAfterUpdate(patch, stored, input.settings.oneResponseVersion, input.moved);
|
|
1637
|
+
const after = (field) => patch[field] === void 0 ? stored?.[field] : patch[field];
|
|
1638
|
+
const key = await responseKeyOf(input.digests, input.settings, {
|
|
1639
|
+
status: after("status"),
|
|
1640
|
+
data: after("data"),
|
|
1641
|
+
ipAddress: after("ipAddress")
|
|
1642
|
+
});
|
|
1643
|
+
if (key === null) return null;
|
|
1644
|
+
const storedValue = await responseKeyOf(input.digests, input.settings, {
|
|
1645
|
+
data: stored?.data,
|
|
1646
|
+
ipAddress: stored?.ipAddress
|
|
1647
|
+
});
|
|
1648
|
+
if (input.moved || key !== storedValue) return key;
|
|
1649
|
+
if (stored?.status === "spam") return await input.heldElsewhere(key) ? null : key;
|
|
1650
|
+
return typeof stored?.responseKey === "string" ? stored.responseKey : null;
|
|
1353
1651
|
}
|
|
1354
1652
|
/**
|
|
1355
|
-
*
|
|
1356
|
-
*
|
|
1357
|
-
*
|
|
1358
|
-
*
|
|
1359
|
-
*
|
|
1360
|
-
*
|
|
1361
|
-
*
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1653
|
+
* A submission's browser key after an update. The token is not stored, so
|
|
1654
|
+
* the key cannot be made again: the submission keeps the browser key it
|
|
1655
|
+
* holds until it is marked spam, which gives the browser its place back, and
|
|
1656
|
+
* one restored from spam stays without one. Answers edited keep it, and a
|
|
1657
|
+
* submission moved to another form taking one per browser takes it there,
|
|
1658
|
+
* for that form's version, since it is the same browser's response; the
|
|
1659
|
+
* unique index refuses a move onto a form where that browser has responded.
|
|
1660
|
+
*/
|
|
1661
|
+
function browserKeyAfterUpdate(patch, stored, version, moved) {
|
|
1662
|
+
if ((patch.status === void 0 ? stored?.status : patch.status) === "spam") return null;
|
|
1663
|
+
const key = stored?.responseKey;
|
|
1664
|
+
if (moved) return browserKeyFor(key, version);
|
|
1665
|
+
return isBrowserKey(key, version) ? key : null;
|
|
1666
|
+
}
|
|
1667
|
+
/** A browser key of any version, spelled for `version`; none for any other key. */
|
|
1668
|
+
function browserKeyFor(key, version) {
|
|
1669
|
+
if (typeof key !== "string") return null;
|
|
1670
|
+
const match = /^(?:v\d+:)?browser:([0-9a-f]+)$/.exec(key);
|
|
1671
|
+
return match ? `${keyPrefixOf(version)}${BROWSER_KEY_PREFIX}${match[1]}` : null;
|
|
1672
|
+
}
|
|
1673
|
+
/**
|
|
1674
|
+
* A key the row holds but cannot be made again from it (a network's on a
|
|
1675
|
+
* form that keeps no addresses), after an update: none once the submission
|
|
1676
|
+
* is spam; spelled for the destination's version when it moves to another
|
|
1677
|
+
* form; else the key it holds, while that is for the form's current version.
|
|
1367
1678
|
*/
|
|
1368
|
-
function
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
const path = issue.path.join(".");
|
|
1374
|
-
if (seen.has(path)) continue;
|
|
1375
|
-
seen.add(path);
|
|
1376
|
-
issues.push({
|
|
1377
|
-
path,
|
|
1378
|
-
code: isBlank(submitted[path]) ? "REQUIRED" : "INVALID",
|
|
1379
|
-
message: issue.message
|
|
1380
|
-
});
|
|
1381
|
-
}
|
|
1382
|
-
return issues;
|
|
1679
|
+
function keptKeyAfterUpdate(patch, stored, version, moved) {
|
|
1680
|
+
const key = networkKeyHeld(patch, stored);
|
|
1681
|
+
if (key === null) return null;
|
|
1682
|
+
if (moved) return `${keyPrefixOf(version)}${key.replace(/^v\d+:/, "")}`;
|
|
1683
|
+
return keyVersionOf(key) === (version ?? 0) ? key : null;
|
|
1383
1684
|
}
|
|
1384
1685
|
/**
|
|
1385
|
-
* The
|
|
1386
|
-
*
|
|
1387
|
-
*
|
|
1388
|
-
*
|
|
1389
|
-
*
|
|
1686
|
+
* The key a stored submission should hold under `settings`: its value's,
|
|
1687
|
+
* made again from the row; or, for a network's on a row that kept no
|
|
1688
|
+
* address (a form that keeps none), the key it holds while that is for the
|
|
1689
|
+
* current version, since it cannot be made again. The rebuild asks this, so
|
|
1690
|
+
* a submission stored under a form that keeps no addresses keeps its key.
|
|
1390
1691
|
*/
|
|
1391
|
-
function
|
|
1392
|
-
|
|
1692
|
+
async function storedRowKeyOf(digests, settings, row) {
|
|
1693
|
+
if (settings.oneResponse !== "ip" || textOf(row.ipAddress) !== void 0) return responseKeyOf(digests, settings, row);
|
|
1694
|
+
const key = networkKeyHeld({}, row);
|
|
1695
|
+
return key !== null && keyVersionOf(key) === (settings.oneResponseVersion ?? 0) ? key : null;
|
|
1696
|
+
}
|
|
1697
|
+
/** The network key a submission holds, unless the update makes it spam. */
|
|
1698
|
+
function networkKeyHeld(patch, stored) {
|
|
1699
|
+
const status = patch.status === void 0 ? stored?.status : patch.status;
|
|
1700
|
+
const key = stored?.responseKey;
|
|
1701
|
+
if (status === "spam" || typeof key !== "string") return null;
|
|
1702
|
+
return key.includes(BROWSER_KEY_PREFIX) ? null : key;
|
|
1393
1703
|
}
|
|
1394
1704
|
|
|
1395
1705
|
// src/handlers/prepare-submission.ts
|
|
@@ -1489,17 +1799,17 @@ function submissionMarks(row) {
|
|
|
1489
1799
|
return row[PLUGIN_SUBMISSION];
|
|
1490
1800
|
}
|
|
1491
1801
|
/** A form value, compared the way a form value can be. */
|
|
1492
|
-
function sameValue(left, right) {
|
|
1802
|
+
function sameValue$1(left, right) {
|
|
1493
1803
|
if (Object.is(left, right)) return true;
|
|
1494
1804
|
if (left instanceof Date && right instanceof Date) return left.getTime() === right.getTime();
|
|
1495
|
-
if (Array.isArray(left) && Array.isArray(right)) return left.length === right.length && left.every((value, index) => sameValue(value, right[index]));
|
|
1805
|
+
if (Array.isArray(left) && Array.isArray(right)) return left.length === right.length && left.every((value, index) => sameValue$1(value, right[index]));
|
|
1496
1806
|
return false;
|
|
1497
1807
|
}
|
|
1498
1808
|
/** Whether two submitted payloads say the same thing. */
|
|
1499
1809
|
function sameSubmittedPayload(granted, incoming) {
|
|
1500
1810
|
const answers = Object.entries(granted);
|
|
1501
1811
|
if (answers.length !== Object.keys(incoming).length) return false;
|
|
1502
|
-
return answers.every(([field, answer]) => sameValue(answer, incoming[field]));
|
|
1812
|
+
return answers.every(([field, answer]) => sameValue$1(answer, incoming[field]));
|
|
1503
1813
|
}
|
|
1504
1814
|
/**
|
|
1505
1815
|
* Bring a submission to the shape the form declares.
|
|
@@ -1525,13 +1835,804 @@ function prepareSubmission({ data, fields, validate }) {
|
|
|
1525
1835
|
const transformed = transformFormData(data, fields);
|
|
1526
1836
|
sanitizeSubmissionData(transformed, fields);
|
|
1527
1837
|
if (!validate) return { data: transformed };
|
|
1528
|
-
const
|
|
1838
|
+
const hidden = fieldsHiddenByCondition(fields, transformed);
|
|
1839
|
+
const result = generateZodSchema(fields.filter((field) => !hidden.has(field.name))).safeParse(transformed);
|
|
1529
1840
|
if (!result.success) return {
|
|
1530
1841
|
data: transformed,
|
|
1531
1842
|
validationErrors: getValidationErrors(result),
|
|
1532
1843
|
validationIssues: getValidationIssues(result, data)
|
|
1533
1844
|
};
|
|
1534
|
-
return { data:
|
|
1845
|
+
return { data: {
|
|
1846
|
+
...transformed,
|
|
1847
|
+
...result.data
|
|
1848
|
+
} };
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
// src/handlers/form-closing-hold.ts
|
|
1852
|
+
/**
|
|
1853
|
+
* A form's closing time and limit, held where a submission is stored: inside
|
|
1854
|
+
* the submission's own write transaction, whatever wrote it (the public route,
|
|
1855
|
+
* `submitForm` called from another plugin, `nextly.forms.submit()` or the
|
|
1856
|
+
* admin). So no way of storing a submission can outlive the form's closing.
|
|
1857
|
+
*
|
|
1858
|
+
* Past the closing time, every new submission is refused, spam included.
|
|
1859
|
+
*
|
|
1860
|
+
* Two submissions for a form's last place must not both be stored. Checking a
|
|
1861
|
+
* count before the write lets both read "one place left", so the check runs
|
|
1862
|
+
* after the submission's row is inserted, in the same transaction, under the
|
|
1863
|
+
* form's row locked for update: a second submission to the same form waits
|
|
1864
|
+
* there until the first has committed or rolled back, then counts again, its
|
|
1865
|
+
* own row and the first's both included, and is refused if they are more
|
|
1866
|
+
* than the limit. The refusal rolls the whole write back, so nothing is kept.
|
|
1867
|
+
*
|
|
1868
|
+
* Per dialect:
|
|
1869
|
+
*
|
|
1870
|
+
* - PostgreSQL and MySQL. A write that opens its own transaction and runs
|
|
1871
|
+
* in-transaction handlers reads committed, so the count after the lock sees
|
|
1872
|
+
* what the submission before it committed. The count takes no lock of its
|
|
1873
|
+
* own: a locking read would wait on another submission's uncommitted row
|
|
1874
|
+
* while that submission waits for this one's lock. The submission's `form`
|
|
1875
|
+
* column is a plain column with no database foreign key, so its insert holds
|
|
1876
|
+
* no lock on the form's row, and two submissions waiting on each other's
|
|
1877
|
+
* insert cannot arise.
|
|
1878
|
+
* - SQLite. One writer at a time already, so the count sees every commit.
|
|
1879
|
+
*
|
|
1880
|
+
* One limit to this: a submission created inside a transaction its caller
|
|
1881
|
+
* opened (`withTransaction` with `createEntryInTransaction`) reads at that
|
|
1882
|
+
* transaction's isolation. On MySQL that is REPEATABLE READ, where the count
|
|
1883
|
+
* answers from the transaction's first read, so two such transactions racing
|
|
1884
|
+
* for the last place can both be stored. Every way of submitting this plugin
|
|
1885
|
+
* and core offer opens its own transaction.
|
|
1886
|
+
*
|
|
1887
|
+
* Spam does not count, and a submission stored as spam takes no place.
|
|
1888
|
+
* Marking a submission spam or not spam, deleting submissions and the
|
|
1889
|
+
* retention pass change the count by changing the rows it reads: there is no
|
|
1890
|
+
* counter to keep in step. Only a new submission is judged: marking a stored
|
|
1891
|
+
* one "not spam" restores a place it was entitled to when it arrived.
|
|
1892
|
+
*
|
|
1893
|
+
* @module handlers/form-closing-hold
|
|
1894
|
+
*/
|
|
1895
|
+
/** The mark on a refusal for a closed form, for the caller to recognise it. */
|
|
1896
|
+
const FORM_CLOSED = "form-builder:form-closed";
|
|
1897
|
+
/** The mark on a refusal for a setting changed while the write was in flight. */
|
|
1898
|
+
const SETTINGS_CHANGED_MARK = "form-builder:settings-changed";
|
|
1899
|
+
/** The mark on a refusal for a person who has responded already. */
|
|
1900
|
+
const RESPONDED_MARK = "form-builder:responded";
|
|
1901
|
+
/** Rows read at a time when earlier submissions are compared one by one. */
|
|
1902
|
+
const COMPARE_PAGE = 200;
|
|
1903
|
+
/** What a submission refused for a setting changed in flight is told. */
|
|
1904
|
+
const SETTINGS_CHANGED = "This form's settings changed while your response was being sent. Please send it again.";
|
|
1905
|
+
/** The id a submission's `form` names, as an id or a populated document. */
|
|
1906
|
+
function formIdOf(value) {
|
|
1907
|
+
if (typeof value === "string" && value !== "") return value;
|
|
1908
|
+
if (typeof value === "number") return String(value);
|
|
1909
|
+
if (value && typeof value === "object" && "id" in value) return formIdOf(value.id);
|
|
1910
|
+
}
|
|
1911
|
+
/** The form a new submission is made to, and whether it takes a place. */
|
|
1912
|
+
function newSubmission(write) {
|
|
1913
|
+
if (write.operation !== "create") return void 0;
|
|
1914
|
+
const data = write.data;
|
|
1915
|
+
const formId = formIdOf(data?.form);
|
|
1916
|
+
return formId === void 0 ? void 0 : {
|
|
1917
|
+
formId,
|
|
1918
|
+
takesAPlace: data?.status !== "spam"
|
|
1919
|
+
};
|
|
1920
|
+
}
|
|
1921
|
+
/** The refusal a submission to a closed form gets, and rolls back with. */
|
|
1922
|
+
function closedRefusal(form, logContext) {
|
|
1923
|
+
return NextlyError.conflict({
|
|
1924
|
+
reason: "state",
|
|
1925
|
+
message: closedMessageOf(form),
|
|
1926
|
+
logContext: {
|
|
1927
|
+
cause: FORM_CLOSED,
|
|
1928
|
+
...logContext
|
|
1929
|
+
}
|
|
1930
|
+
});
|
|
1931
|
+
}
|
|
1932
|
+
/** The form, as stored, with its settings read. */
|
|
1933
|
+
async function formOf(own, write, slugs, formId, lock) {
|
|
1934
|
+
const form = await own.findEntryByIdInTransaction(write.transaction, slugs.forms, formId, { lock });
|
|
1935
|
+
return {
|
|
1936
|
+
form,
|
|
1937
|
+
settings: normalizeFormSettings(form.settings)
|
|
1938
|
+
};
|
|
1939
|
+
}
|
|
1940
|
+
/**
|
|
1941
|
+
* Refuse a new submission to a form past its closing time, one that would
|
|
1942
|
+
* take it past its limit, or one whose one-response key is not the one the
|
|
1943
|
+
* form's committed settings give it.
|
|
1944
|
+
*/
|
|
1945
|
+
async function holdFormClosing(deps, write, slugs) {
|
|
1946
|
+
const submission = newSubmission(write);
|
|
1947
|
+
if (submission === void 0) return;
|
|
1948
|
+
const { formId } = submission;
|
|
1949
|
+
const locked = await formOf(deps.own, write, slugs, formId, "update");
|
|
1950
|
+
if (pastClosingTime(locked.settings, /* @__PURE__ */ new Date())) throw closedRefusal(locked.form, {
|
|
1951
|
+
formId,
|
|
1952
|
+
closedBy: "date"
|
|
1953
|
+
});
|
|
1954
|
+
refuseKeptAddress(write, formId, locked.settings);
|
|
1955
|
+
if (submission.takesAPlace) await holdLimit(deps.own, write, slugs, formId, locked);
|
|
1956
|
+
await holdResponseKey(deps, write, slugs, formId, locked.settings);
|
|
1957
|
+
}
|
|
1958
|
+
/**
|
|
1959
|
+
* Refuse a new submission that carries an IP address or browser to a form
|
|
1960
|
+
* that, as committed, keeps neither: the write seam read the setting before
|
|
1961
|
+
* it changed. The visitor sends it again, and it is stored without them.
|
|
1962
|
+
*/
|
|
1963
|
+
function refuseKeptAddress(write, formId, settings) {
|
|
1964
|
+
if (settings.storeIpAndBrowser !== false) return;
|
|
1965
|
+
const row = write.data;
|
|
1966
|
+
const kept = (value) => typeof value === "string" && value !== "";
|
|
1967
|
+
if (kept(row?.ipAddress) || kept(row?.userAgent)) throw changedRefusal(formId);
|
|
1968
|
+
}
|
|
1969
|
+
/** Refuse a submission that takes the form past its limit, under the lock. */
|
|
1970
|
+
async function holdLimit(own, write, slugs, formId, { form, settings }) {
|
|
1971
|
+
const limit = settings.submissionLimit;
|
|
1972
|
+
if (limit === void 0) return;
|
|
1973
|
+
const { pagination } = await own.listEntriesInTransaction(write.transaction, slugs.submissions, {
|
|
1974
|
+
where: {
|
|
1975
|
+
form: { equals: formId },
|
|
1976
|
+
status: { not_equals: "spam" }
|
|
1977
|
+
},
|
|
1978
|
+
pagination: { limit: 1 }
|
|
1979
|
+
}, { lock: "none" });
|
|
1980
|
+
if (pagination.total > limit) throw closedRefusal(form, {
|
|
1981
|
+
formId,
|
|
1982
|
+
closedBy: "limit",
|
|
1983
|
+
limit
|
|
1984
|
+
});
|
|
1985
|
+
}
|
|
1986
|
+
/**
|
|
1987
|
+
* Refuse a submission whose one-response key is not the one the form's
|
|
1988
|
+
* setting, as stored now, gives it. The key was made before the write, from
|
|
1989
|
+
* the settings read then; a change of mode or Email field committed since
|
|
1990
|
+
* would leave a key no longer compared by anything, or none where one is
|
|
1991
|
+
* owed. The visitor sends it again, under the setting that applies.
|
|
1992
|
+
*/
|
|
1993
|
+
async function holdResponseKey(deps, write, slugs, formId, settings) {
|
|
1994
|
+
const row = write.data;
|
|
1995
|
+
if (!await holdsKeyFor(() => deps.digests, settings, row)) throw changedRefusal(formId);
|
|
1996
|
+
if (row.responseKey === null || row.responseKey === void 0) return;
|
|
1997
|
+
await refuseEarlierResponse(deps, write, slugs, formId, settings);
|
|
1998
|
+
}
|
|
1999
|
+
/** The refusal for a key made under a setting the form has since left. */
|
|
2000
|
+
function changedRefusal(formId) {
|
|
2001
|
+
return NextlyError.conflict({
|
|
2002
|
+
reason: "state",
|
|
2003
|
+
message: SETTINGS_CHANGED,
|
|
2004
|
+
logContext: {
|
|
2005
|
+
cause: SETTINGS_CHANGED_MARK,
|
|
2006
|
+
formId
|
|
2007
|
+
}
|
|
2008
|
+
});
|
|
2009
|
+
}
|
|
2010
|
+
/**
|
|
2011
|
+
* Refuse a keyed submission when an earlier one holds its value without the
|
|
2012
|
+
* key that would show it.
|
|
2013
|
+
*
|
|
2014
|
+
* One per network compares the stored address itself, every time: a form
|
|
2015
|
+
* that took one response per network before keys were stored has earlier
|
|
2016
|
+
* submissions with an address and no key. One per email compares the earlier
|
|
2017
|
+
* answers while the form's keys are still being made for its current version
|
|
2018
|
+
* (`keysBehind`), after a change of mode or Email field. Both run under the
|
|
2019
|
+
* form's lock, so a submission stored meanwhile is counted.
|
|
2020
|
+
*/
|
|
2021
|
+
async function refuseEarlierResponse(deps, write, slugs, formId, settings) {
|
|
2022
|
+
const row = write.data;
|
|
2023
|
+
const earlier = {
|
|
2024
|
+
form: { equals: formId },
|
|
2025
|
+
status: { not_equals: "spam" },
|
|
2026
|
+
id: { not_equals: write.id }
|
|
2027
|
+
};
|
|
2028
|
+
const read = (where, limit, page = 1) => deps.own.listEntriesInTransaction(write.transaction, slugs.submissions, {
|
|
2029
|
+
where,
|
|
2030
|
+
pagination: {
|
|
2031
|
+
limit,
|
|
2032
|
+
page
|
|
2033
|
+
},
|
|
2034
|
+
depth: 0
|
|
2035
|
+
}, { lock: "none" });
|
|
2036
|
+
if (settings.oneResponse === "ip" && typeof row.ipAddress === "string") {
|
|
2037
|
+
const { pagination } = await read({
|
|
2038
|
+
...earlier,
|
|
2039
|
+
ipAddress: { equals: row.ipAddress }
|
|
2040
|
+
}, 1);
|
|
2041
|
+
if (pagination.total > 0) throw respondedRefusal(formId);
|
|
2042
|
+
return;
|
|
2043
|
+
}
|
|
2044
|
+
if (settings.oneResponse === "email" && keysBehind(settings)) await refuseEarlierEmail(deps, formId, settings, row.responseKey, (page) => read(earlier, COMPARE_PAGE, page));
|
|
2045
|
+
}
|
|
2046
|
+
/**
|
|
2047
|
+
* Compare a new submission's key with the key each earlier submission's
|
|
2048
|
+
* answer would have, a page at a time.
|
|
2049
|
+
*/
|
|
2050
|
+
async function refuseEarlierEmail(deps, formId, settings, key, page) {
|
|
2051
|
+
for (let n = 1;; n++) {
|
|
2052
|
+
const { data } = await page(n);
|
|
2053
|
+
for (const other of data) if (await responseKeyOf(() => deps.digests, settings, other) === key) throw respondedRefusal(formId);
|
|
2054
|
+
if (data.length < COMPARE_PAGE) return;
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
/** The refusal for a person who has responded to the form already. */
|
|
2058
|
+
function respondedRefusal(formId) {
|
|
2059
|
+
return NextlyError.conflict({
|
|
2060
|
+
reason: "state",
|
|
2061
|
+
message: ALREADY_RESPONDED,
|
|
2062
|
+
logContext: {
|
|
2063
|
+
cause: RESPONDED_MARK,
|
|
2064
|
+
formId
|
|
2065
|
+
}
|
|
2066
|
+
});
|
|
2067
|
+
}
|
|
2068
|
+
/**
|
|
2069
|
+
* Refuse a key written to a stored submission (an edit that moves its key,
|
|
2070
|
+
* or the plugin giving it one) when the key was made for a version the form
|
|
2071
|
+
* has left: the form's mode or Email field changed after it was made. The
|
|
2072
|
+
* form's lock orders it after the change, so the version it reads is the one
|
|
2073
|
+
* committed.
|
|
2074
|
+
*/
|
|
2075
|
+
async function holdKeyWrite(deps, write, slugs) {
|
|
2076
|
+
const changed = changedKeyOf(write);
|
|
2077
|
+
if (changed === void 0) return;
|
|
2078
|
+
const { key, formId } = changed;
|
|
2079
|
+
const { settings } = await formOf(deps.own, write, slugs, formId, "update");
|
|
2080
|
+
if (keyVersionOf(key) !== (settings.oneResponseVersion ?? 0)) throw changedRefusal(formId);
|
|
2081
|
+
if (sameValue(write)) return;
|
|
2082
|
+
await refuseEarlierResponse(deps, write, slugs, formId, settings);
|
|
2083
|
+
}
|
|
2084
|
+
/**
|
|
2085
|
+
* The key an update gave a stored submission, and its form, when the update
|
|
2086
|
+
* changed the key to one: undefined for a create, a key cleared or kept.
|
|
2087
|
+
*/
|
|
2088
|
+
function changedKeyOf(write) {
|
|
2089
|
+
if (write.operation !== "update") return void 0;
|
|
2090
|
+
const after = write.data;
|
|
2091
|
+
const before = write.originalData;
|
|
2092
|
+
const key = after?.responseKey;
|
|
2093
|
+
if (typeof key !== "string" || key === before?.responseKey) return void 0;
|
|
2094
|
+
const formId = formIdOf(after.form);
|
|
2095
|
+
return formId === void 0 ? void 0 : {
|
|
2096
|
+
key,
|
|
2097
|
+
formId
|
|
2098
|
+
};
|
|
2099
|
+
}
|
|
2100
|
+
/**
|
|
2101
|
+
* Whether an update left a submission's answers, address and form as they
|
|
2102
|
+
* were. A submission moved to another form joins that form's responses, so a
|
|
2103
|
+
* move is compared as an edit is.
|
|
2104
|
+
*/
|
|
2105
|
+
function sameValue(write) {
|
|
2106
|
+
const after = write.data;
|
|
2107
|
+
const before = write.originalData;
|
|
2108
|
+
const same = (field) => JSON.stringify(after?.[field] ?? null) === JSON.stringify(before?.[field] ?? null);
|
|
2109
|
+
return same("data") && same("ipAddress") && formIdOf(after?.form) === formIdOf(before?.form);
|
|
2110
|
+
}
|
|
2111
|
+
/**
|
|
2112
|
+
* When the submission holding a one-response key stops holding it (deleted,
|
|
2113
|
+
* marked spam, or edited or moved to another value), the earliest other
|
|
2114
|
+
* non-spam submission of the form with the same value takes the key, in the
|
|
2115
|
+
* same transaction. Without it, a keyless duplicate (one stored before the
|
|
2116
|
+
* form compared that value, or kept as evidence and restored) would leave
|
|
2117
|
+
* the value free, and a person who had responded could respond again.
|
|
2118
|
+
*
|
|
2119
|
+
* Under the form's lock, so a new submission with that value waits and then
|
|
2120
|
+
* meets the key. A key made for a version the form has left is not handed
|
|
2121
|
+
* on: nothing compares it. One per browser hands nothing on, since no
|
|
2122
|
+
* earlier submission kept its browser's token.
|
|
2123
|
+
*/
|
|
2124
|
+
async function handOffKey(deps, write, slugs) {
|
|
2125
|
+
const released = releasedKeyOf(write);
|
|
2126
|
+
if (released === void 0) return;
|
|
2127
|
+
const { key, formId, ipAddress } = released;
|
|
2128
|
+
const { settings } = await formOf(deps.own, write, slugs, formId, "update");
|
|
2129
|
+
if (keyVersionOf(key) !== (settings.oneResponseVersion ?? 0)) return;
|
|
2130
|
+
const next = await nextHolderOf(deps, write, slugs, {
|
|
2131
|
+
formId,
|
|
2132
|
+
settings,
|
|
2133
|
+
key,
|
|
2134
|
+
ipAddress
|
|
2135
|
+
});
|
|
2136
|
+
if (next === void 0) return;
|
|
2137
|
+
await deps.collections.updateEntryInTransaction(write.transaction, slugs.submissions, next, asPluginSubmission({
|
|
2138
|
+
responseKey: key,
|
|
2139
|
+
_overwrite: true
|
|
2140
|
+
}, {
|
|
2141
|
+
keepAsEvidence: false,
|
|
2142
|
+
setKey: key
|
|
2143
|
+
}));
|
|
2144
|
+
}
|
|
2145
|
+
/**
|
|
2146
|
+
* The key a write took from the submission that held it, with that
|
|
2147
|
+
* submission's form and address as they were: undefined when it still holds
|
|
2148
|
+
* it, or held none.
|
|
2149
|
+
*/
|
|
2150
|
+
function releasedKeyOf(write) {
|
|
2151
|
+
const before = heldBefore(write);
|
|
2152
|
+
const key = before?.responseKey;
|
|
2153
|
+
if (typeof key !== "string" || stillHeld(write, key, before)) return;
|
|
2154
|
+
const formId = formIdOf(before?.form);
|
|
2155
|
+
return formId === void 0 ? void 0 : {
|
|
2156
|
+
key,
|
|
2157
|
+
formId,
|
|
2158
|
+
ipAddress: before?.ipAddress
|
|
2159
|
+
};
|
|
2160
|
+
}
|
|
2161
|
+
/**
|
|
2162
|
+
* The submission as it was before a write: a delete describes it as it was,
|
|
2163
|
+
* an update by its original; a create had none.
|
|
2164
|
+
*/
|
|
2165
|
+
function heldBefore(write) {
|
|
2166
|
+
if (write.operation === "create") return void 0;
|
|
2167
|
+
return write.operation === "delete" ? write.data : write.originalData;
|
|
2168
|
+
}
|
|
2169
|
+
/** Whether an update left the submission holding `key` on the same form. */
|
|
2170
|
+
function stillHeld(write, key, before) {
|
|
2171
|
+
const after = write.data;
|
|
2172
|
+
return write.operation === "update" && after?.responseKey === key && formIdOf(after?.form) === formIdOf(before?.form);
|
|
2173
|
+
}
|
|
2174
|
+
/**
|
|
2175
|
+
* The earliest non-spam submission of the form, other than the one writing,
|
|
2176
|
+
* that holds no key and whose value gives `key`. One per network reads only
|
|
2177
|
+
* the submissions from the same address; one per email reads the keyless
|
|
2178
|
+
* ones a page at a time.
|
|
2179
|
+
*/
|
|
2180
|
+
async function nextHolderOf(deps, write, slugs, wanted) {
|
|
2181
|
+
const { settings } = wanted;
|
|
2182
|
+
if (settings.oneResponse !== "ip" && settings.oneResponse !== "email") return;
|
|
2183
|
+
const where = {
|
|
2184
|
+
form: { equals: wanted.formId },
|
|
2185
|
+
status: { not_equals: "spam" },
|
|
2186
|
+
id: { not_equals: write.id },
|
|
2187
|
+
responseKey: { exists: false }
|
|
2188
|
+
};
|
|
2189
|
+
if (settings.oneResponse === "ip") {
|
|
2190
|
+
if (typeof wanted.ipAddress !== "string") return void 0;
|
|
2191
|
+
where.ipAddress = { equals: wanted.ipAddress };
|
|
2192
|
+
}
|
|
2193
|
+
for (let page = 1;; page++) {
|
|
2194
|
+
const { data } = await deps.own.listEntriesInTransaction(write.transaction, slugs.submissions, {
|
|
2195
|
+
where,
|
|
2196
|
+
sort: {
|
|
2197
|
+
field: "createdAt",
|
|
2198
|
+
direction: "asc"
|
|
2199
|
+
},
|
|
2200
|
+
pagination: {
|
|
2201
|
+
limit: COMPARE_PAGE,
|
|
2202
|
+
page
|
|
2203
|
+
},
|
|
2204
|
+
depth: 0
|
|
2205
|
+
}, { lock: "none" });
|
|
2206
|
+
for (const row of data) if (await responseKeyOf(() => deps.digests, settings, row) === wanted.key) return String(row.id);
|
|
2207
|
+
if (data.length < COMPARE_PAGE) return void 0;
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2210
|
+
/** Hold every form's closing on the submissions this plugin stores. */
|
|
2211
|
+
function registerFormClosing(ctx, slugs) {
|
|
2212
|
+
const deps = {
|
|
2213
|
+
own: ctx.services.ownCollections,
|
|
2214
|
+
digests: ctx.security.digests,
|
|
2215
|
+
collections: ctx.services.collections
|
|
2216
|
+
};
|
|
2217
|
+
ctx.hooks.onInTransaction(slugs.submissions, async (write) => {
|
|
2218
|
+
await holdFormClosing(deps, write, slugs);
|
|
2219
|
+
await holdKeyWrite(deps, write, slugs);
|
|
2220
|
+
await handOffKey(deps, write, slugs);
|
|
2221
|
+
});
|
|
2222
|
+
}
|
|
2223
|
+
/**
|
|
2224
|
+
* Which of the hold's refusals `error` is, or one it was made from: a closed
|
|
2225
|
+
* form's, a setting changed while the submission was in flight, or a person
|
|
2226
|
+
* who has responded already.
|
|
2227
|
+
*/
|
|
2228
|
+
function heldRefusal(error) {
|
|
2229
|
+
let current = error;
|
|
2230
|
+
for (let depth = 0; depth < 10 && current; depth += 1) {
|
|
2231
|
+
if (current instanceof NextlyError) {
|
|
2232
|
+
const cause = current.logContext?.cause;
|
|
2233
|
+
if (cause === FORM_CLOSED) return "closed";
|
|
2234
|
+
if (cause === SETTINGS_CHANGED_MARK) return "changed";
|
|
2235
|
+
if (cause === RESPONDED_MARK) return "responded";
|
|
2236
|
+
}
|
|
2237
|
+
current = current.cause;
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
// src/handlers/one-response-field-check.ts
|
|
2242
|
+
/**
|
|
2243
|
+
* The save of a form refuses a one response per email it could not keep:
|
|
2244
|
+
* no Email field chosen, or one that is missing, optional or hidden by a
|
|
2245
|
+
* condition. The builder and Settings refuse the same, and this holds it for
|
|
2246
|
+
* every other way a form is written (the API, an import).
|
|
2247
|
+
*
|
|
2248
|
+
* @module handlers/one-response-field-check
|
|
2249
|
+
*/
|
|
2250
|
+
/** A stored value as written: itself, or a JSON string before a read decodes it. */
|
|
2251
|
+
function decoded(value) {
|
|
2252
|
+
if (typeof value !== "string") return value;
|
|
2253
|
+
try {
|
|
2254
|
+
return JSON.parse(value);
|
|
2255
|
+
} catch {
|
|
2256
|
+
return;
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
/**
|
|
2260
|
+
* Refuse a form save whose one response per email has no Email field it can
|
|
2261
|
+
* compare. What the save leaves out is read as stored, so a save of the
|
|
2262
|
+
* fields alone is judged against the stored settings, and the other way
|
|
2263
|
+
* round.
|
|
2264
|
+
*/
|
|
2265
|
+
function refuseOneResponseFieldProblem(context) {
|
|
2266
|
+
const ctx = context;
|
|
2267
|
+
const data = ctx.data;
|
|
2268
|
+
if (!data || data.settings === void 0 && data.fields === void 0) return;
|
|
2269
|
+
const settings = normalizeFormSettings(decoded(data.settings !== void 0 ? data.settings : ctx.originalData?.settings));
|
|
2270
|
+
const fields = decoded(data.fields !== void 0 ? data.fields : ctx.originalData?.fields);
|
|
2271
|
+
const problem = oneResponseFieldProblem(settings, Array.isArray(fields) ? fields : []);
|
|
2272
|
+
if (problem === void 0) return;
|
|
2273
|
+
throw NextlyError.validation({ errors: [{
|
|
2274
|
+
path: "settings.oneResponseField",
|
|
2275
|
+
code: "INVALID",
|
|
2276
|
+
message: problem
|
|
2277
|
+
}] });
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
// src/handlers/one-response-rebuild.ts
|
|
2281
|
+
/**
|
|
2282
|
+
* One response per person, after a form changes how it tells people apart.
|
|
2283
|
+
*
|
|
2284
|
+
* A submission's key is made under the form's one-response version
|
|
2285
|
+
* (`one-response`). Saving a form with another mode or another Email field
|
|
2286
|
+
* moves the version on (`stampOneResponseVersion`), so every earlier key stops
|
|
2287
|
+
* matching new ones at once, with no write to any earlier submission. The
|
|
2288
|
+
* earlier submissions are then given keys for the new version by a sweep
|
|
2289
|
+
* (`rebuildResponseKeys`): oldest first, each the key of its own answer or
|
|
2290
|
+
* address, or none where an earlier one holds that value already, so the
|
|
2291
|
+
* earliest of several with one value keeps it. The sweep runs right after the
|
|
2292
|
+
* save commits, and the job runner carries on with what it could not finish.
|
|
2293
|
+
*
|
|
2294
|
+
* Until it has finished, the form records the version its keys are made for
|
|
2295
|
+
* (`oneResponseKeyedVersion`) behind the current one, and the submission's
|
|
2296
|
+
* hold compares a new submission with the earlier ones' answers themselves
|
|
2297
|
+
* (`form-closing-hold`), so no person gets a second response in between.
|
|
2298
|
+
*
|
|
2299
|
+
* Each key written is checked again where it is stored: the hold refuses a
|
|
2300
|
+
* key made for a version the form has since left, so a sweep that started
|
|
2301
|
+
* before a second change writes nothing for the old version. Each write also
|
|
2302
|
+
* names the revision the sweep read, so a submission edited meanwhile keeps
|
|
2303
|
+
* the key its edit gave it.
|
|
2304
|
+
*
|
|
2305
|
+
* @module handlers/one-response-rebuild
|
|
2306
|
+
*/
|
|
2307
|
+
/** The sweep that gives earlier submissions their keys. */
|
|
2308
|
+
const REBUILD_KEYS_JOB = "form-builder:one-response-keys";
|
|
2309
|
+
/** How long a save waits for the sweep after it commits, at most. */
|
|
2310
|
+
const REBUILD_ON_SAVE_MS = 5e3;
|
|
2311
|
+
/**
|
|
2312
|
+
* How far past the start of a pass its cutoff lies. MySQL stores a row's
|
|
2313
|
+
* `createdAt` to the second, rounded, so a submission stored a moment before
|
|
2314
|
+
* the pass can read as up to a second after it; a cutoff at the start would
|
|
2315
|
+
* leave it out of every pass and without a key. Rows inside the margin are
|
|
2316
|
+
* ones stored after the change, keyed already, so reading them costs a look.
|
|
2317
|
+
*/
|
|
2318
|
+
const CUTOFF_MARGIN_MS = 2e3;
|
|
2319
|
+
function passCutoff() {
|
|
2320
|
+
return new Date(Date.now() + CUTOFF_MARGIN_MS);
|
|
2321
|
+
}
|
|
2322
|
+
/** Rows read at a time: forms, then one form's submissions. */
|
|
2323
|
+
const PAGE = 100;
|
|
2324
|
+
/**
|
|
2325
|
+
* The mark on the sweep's own write of a form, saying which version's keys
|
|
2326
|
+
* it finished. Only the plugin sets it; `stampOneResponseVersion` takes the
|
|
2327
|
+
* finished version from nowhere else.
|
|
2328
|
+
*/
|
|
2329
|
+
const FORM_KEYED = Symbol.for("nextly.plugin-form-builder.form-keyed");
|
|
2330
|
+
/** A form's settings as stored: an object, or a JSON string before a read decodes it. */
|
|
2331
|
+
function settingsObjectOf(value) {
|
|
2332
|
+
let parsed = value;
|
|
2333
|
+
if (typeof parsed === "string") try {
|
|
2334
|
+
parsed = JSON.parse(parsed);
|
|
2335
|
+
} catch {
|
|
2336
|
+
return;
|
|
2337
|
+
}
|
|
2338
|
+
return parsed !== null && typeof parsed === "object" ? parsed : void 0;
|
|
2339
|
+
}
|
|
2340
|
+
/**
|
|
2341
|
+
* The version a save leaves the form at, and the version its keys are made
|
|
2342
|
+
* for, both decided here from what is stored and never taken from the
|
|
2343
|
+
* payload: a caller that could set them could make every earlier response
|
|
2344
|
+
* count again, or none.
|
|
2345
|
+
*
|
|
2346
|
+
* A save that changes the mode or the Email field moves the version on by
|
|
2347
|
+
* one. Its keys are up to date at once when the new mode compares nothing
|
|
2348
|
+
* an earlier submission stored (several responses allowed, or one per
|
|
2349
|
+
* browser, whose earlier tokens were never kept), or when the form has few
|
|
2350
|
+
* enough submissions for the save to key them itself (`rebuildInSave`);
|
|
2351
|
+
* otherwise they are behind until the sweep has made them.
|
|
2352
|
+
*/
|
|
2353
|
+
async function stampOneResponseVersion(context, fitsInSave) {
|
|
2354
|
+
const ctx = context;
|
|
2355
|
+
const incoming = settingsObjectOf(ctx.data?.settings);
|
|
2356
|
+
if (!ctx.data || incoming === void 0) return;
|
|
2357
|
+
const next = { ...incoming };
|
|
2358
|
+
delete next.oneResponseKeyedVersion;
|
|
2359
|
+
if (ctx.operation === "create") {
|
|
2360
|
+
next.oneResponseVersion = 0;
|
|
2361
|
+
ctx.data.settings = next;
|
|
2362
|
+
return;
|
|
2363
|
+
}
|
|
2364
|
+
const stored = normalizeFormSettings(settingsObjectOf(ctx.originalData?.settings) ?? {});
|
|
2365
|
+
const changed = oneResponseIdentityOf(stored) !== oneResponseIdentityOf(normalizeFormSettings(incoming));
|
|
2366
|
+
const version = (stored.oneResponseVersion ?? 0) + (changed ? 1 : 0);
|
|
2367
|
+
next.oneResponseVersion = version;
|
|
2368
|
+
const storedId = ctx.originalData?.id;
|
|
2369
|
+
const formId = typeof storedId === "string" || typeof storedId === "number" ? String(storedId) : void 0;
|
|
2370
|
+
const keyed = keyedVersionAfterSave({
|
|
2371
|
+
stored,
|
|
2372
|
+
changed,
|
|
2373
|
+
version,
|
|
2374
|
+
mode: normalizeFormSettings(incoming).oneResponse,
|
|
2375
|
+
finished: ctx.data[FORM_KEYED]?.version,
|
|
2376
|
+
inSave: changed && formId !== void 0 && await fitsInSave(formId)
|
|
2377
|
+
});
|
|
2378
|
+
if (keyed !== void 0) next.oneResponseKeyedVersion = keyed;
|
|
2379
|
+
ctx.data.settings = next;
|
|
2380
|
+
}
|
|
2381
|
+
/** The version a form's keys are made for once a save has been stamped. */
|
|
2382
|
+
function keyedVersionAfterSave(input) {
|
|
2383
|
+
const { stored, changed, version } = input;
|
|
2384
|
+
if (changed) return (input.mode === "ip" || input.mode === "email") && !input.inSave ? stored.oneResponseKeyedVersion ?? stored.oneResponseVersion ?? 0 : version;
|
|
2385
|
+
if (input.finished === version) return version;
|
|
2386
|
+
return stored.oneResponseKeyedVersion;
|
|
2387
|
+
}
|
|
2388
|
+
/**
|
|
2389
|
+
* After a save leaves the form's keys behind (a form too large to key inside
|
|
2390
|
+
* the save), start the sweep once the save has committed. Not awaited: the
|
|
2391
|
+
* save answers with the form as it committed, and the sweep's own write of
|
|
2392
|
+
* the form, when it finishes, comes after. What one run leaves, the job
|
|
2393
|
+
* runner finishes.
|
|
2394
|
+
*/
|
|
2395
|
+
async function rebuildAfterSave(context, run, warn) {
|
|
2396
|
+
const ctx = context;
|
|
2397
|
+
const settings = normalizeFormSettings(settingsObjectOf(ctx.data?.settings) ?? {});
|
|
2398
|
+
if (!keysBehind(settings) || !ctx.afterCommit) return;
|
|
2399
|
+
await ctx.afterCommit(() => {
|
|
2400
|
+
run().catch((error) => warn("The one-response key sweep failed; the job runner retries it.", { error: error instanceof Error ? error.message : String(error) }));
|
|
2401
|
+
});
|
|
2402
|
+
}
|
|
2403
|
+
/** The plugin's one-response options, with their defaults. */
|
|
2404
|
+
function oneResponseOptions(options) {
|
|
2405
|
+
const keysInSave = options?.keysInSave;
|
|
2406
|
+
return { keysInSave: typeof keysInSave === "number" && keysInSave >= 0 ? Math.floor(keysInSave) : KEYS_IN_SAVE };
|
|
2407
|
+
}
|
|
2408
|
+
/** Earlier submissions a form may have for the save itself to key them. */
|
|
2409
|
+
const KEYS_IN_SAVE = 200;
|
|
2410
|
+
/**
|
|
2411
|
+
* Give a form's earlier submissions keys for its new version inside the
|
|
2412
|
+
* save that changed it, when the save decided they fit
|
|
2413
|
+
* (`stampOneResponseVersion` recorded the keys as made). Read whole first,
|
|
2414
|
+
* then keyed oldest first, the earliest of several with one value keeping it. The save holds the form's
|
|
2415
|
+
* row, so no submission to the form is stored meanwhile, and the save
|
|
2416
|
+
* answers with every key in place and no later write to the form.
|
|
2417
|
+
*/
|
|
2418
|
+
async function rebuildInSave(deps, write) {
|
|
2419
|
+
if (write.operation !== "update") return;
|
|
2420
|
+
const after = normalizeFormSettings(settingsObjectOf(write.data?.settings) ?? {});
|
|
2421
|
+
const before = normalizeFormSettings(settingsObjectOf(write.originalData?.settings) ?? {});
|
|
2422
|
+
const version = after.oneResponseVersion ?? 0;
|
|
2423
|
+
if (version === (before.oneResponseVersion ?? 0)) return;
|
|
2424
|
+
if (after.oneResponseKeyedVersion !== version) return;
|
|
2425
|
+
if (after.oneResponse !== "ip" && after.oneResponse !== "email") return;
|
|
2426
|
+
const rows = await snapshotOf(async (where) => (await deps.own.listEntriesInTransaction(write.transaction, deps.submissionsSlug, {
|
|
2427
|
+
where,
|
|
2428
|
+
sort: {
|
|
2429
|
+
field: "id",
|
|
2430
|
+
direction: "asc"
|
|
2431
|
+
},
|
|
2432
|
+
pagination: { limit: PAGE },
|
|
2433
|
+
depth: 0
|
|
2434
|
+
}, { lock: "none" })).data, {
|
|
2435
|
+
form: { equals: write.id },
|
|
2436
|
+
status: { not_equals: "spam" }
|
|
2437
|
+
});
|
|
2438
|
+
const held = /* @__PURE__ */ new Set();
|
|
2439
|
+
for (const row of rows) await keyInSave(deps, write, after, held, row);
|
|
2440
|
+
}
|
|
2441
|
+
/** One row keyed inside the save: its value's key unless held already. */
|
|
2442
|
+
async function keyInSave(deps, write, settings, held, row) {
|
|
2443
|
+
const own = await storedRowKeyOf(deps.digests, settings, row);
|
|
2444
|
+
const target = own === null || held.has(own) ? null : own;
|
|
2445
|
+
if (target !== null) held.add(target);
|
|
2446
|
+
if ((row.responseKey ?? null) === target) return;
|
|
2447
|
+
await deps.collections.updateEntryInTransaction(write.transaction, deps.submissionsSlug, String(row.id), asPluginSubmission({
|
|
2448
|
+
responseKey: target,
|
|
2449
|
+
_overwrite: true
|
|
2450
|
+
}, {
|
|
2451
|
+
keepAsEvidence: false,
|
|
2452
|
+
setKey: target
|
|
2453
|
+
}));
|
|
2454
|
+
}
|
|
2455
|
+
/** The sweep's job, run by `run` until the deadline the runner gives it. */
|
|
2456
|
+
function rebuildKeysJob(run) {
|
|
2457
|
+
return defineJob({
|
|
2458
|
+
slug: REBUILD_KEYS_JOB,
|
|
2459
|
+
sweep: true,
|
|
2460
|
+
handler: async (_input, context) => {
|
|
2461
|
+
await run(context.deadline);
|
|
2462
|
+
}
|
|
2463
|
+
});
|
|
2464
|
+
}
|
|
2465
|
+
/**
|
|
2466
|
+
* Give every form whose keys are behind its earlier submissions' keys, until
|
|
2467
|
+
* `deadline`. A form is marked up to date only after a pass over all its
|
|
2468
|
+
* submissions in which every one took its key, with none to look at again.
|
|
2469
|
+
*/
|
|
2470
|
+
async function rebuildResponseKeys(deps, deadline) {
|
|
2471
|
+
for (let page = 1;; page++) {
|
|
2472
|
+
const { data } = await deps.collections.listEntries(deps.formsSlug, {
|
|
2473
|
+
pagination: {
|
|
2474
|
+
limit: PAGE,
|
|
2475
|
+
page
|
|
2476
|
+
},
|
|
2477
|
+
depth: 0
|
|
2478
|
+
}, { as: "system" });
|
|
2479
|
+
const forms = data;
|
|
2480
|
+
for (const form of forms) {
|
|
2481
|
+
if (pastDeadline(deadline)) return;
|
|
2482
|
+
await bringUpToDate(deps, form, deadline);
|
|
2483
|
+
}
|
|
2484
|
+
if (forms.length < PAGE) return;
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2487
|
+
/** One form: its keys made for its current version, if they are behind. */
|
|
2488
|
+
async function bringUpToDate(deps, form, deadline) {
|
|
2489
|
+
const settings = normalizeFormSettings(settingsObjectOf(form.settings) ?? {});
|
|
2490
|
+
if (!keysBehind(settings)) return;
|
|
2491
|
+
if (await rebuildForm(deps, form, settings, deadline) === "done") await markKeyed(deps, form, settings.oneResponseVersion ?? 0);
|
|
2492
|
+
}
|
|
2493
|
+
function pastDeadline(deadline) {
|
|
2494
|
+
return Date.now() >= deadline.getTime();
|
|
2495
|
+
}
|
|
2496
|
+
/**
|
|
2497
|
+
* Passes over one form's submissions until one leaves nothing to look at
|
|
2498
|
+
* again: done, or `stop` when the deadline passed or the form's version moved
|
|
2499
|
+
* on, which leaves the rest to a later sweep. A sweep that resumes passes
|
|
2500
|
+
* quickly over the rows an earlier run keyed: each holds its own key, which
|
|
2501
|
+
* is recognised without a query.
|
|
2502
|
+
*/
|
|
2503
|
+
async function rebuildForm(deps, form, settings, deadline) {
|
|
2504
|
+
if (settings.oneResponse !== "ip" && settings.oneResponse !== "email") return "done";
|
|
2505
|
+
for (;;) {
|
|
2506
|
+
const pass = await rebuildPass(deps, String(form.id), settings, deadline);
|
|
2507
|
+
if (pass !== "again") return pass;
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
/**
|
|
2511
|
+
* One pass over a form's non-spam submissions, oldest first. The rows are
|
|
2512
|
+
* read first, whole (`snapshotOf`), then keyed in order: a submission deleted
|
|
2513
|
+
* or restored while the pass runs cannot move another out of a page it reads.
|
|
2514
|
+
* Rows stored after the pass began are left out (`passCutoff`): they were
|
|
2515
|
+
* keyed for the current version when they were stored. A row whose write was
|
|
2516
|
+
* refused, as edited since or as holding a value taken since, sends the
|
|
2517
|
+
* sweep round again.
|
|
2518
|
+
*/
|
|
2519
|
+
async function rebuildPass(deps, formId, settings, deadline) {
|
|
2520
|
+
const rows = await snapshotOf(async (where) => (await deps.collections.listEntries(deps.submissionsSlug, {
|
|
2521
|
+
where,
|
|
2522
|
+
sort: {
|
|
2523
|
+
field: "id",
|
|
2524
|
+
direction: "asc"
|
|
2525
|
+
},
|
|
2526
|
+
pagination: { limit: PAGE },
|
|
2527
|
+
depth: 0
|
|
2528
|
+
}, { as: "system" })).data, {
|
|
2529
|
+
form: { equals: formId },
|
|
2530
|
+
status: { not_equals: "spam" },
|
|
2531
|
+
createdAt: { less_than_equal: passCutoff() }
|
|
2532
|
+
});
|
|
2533
|
+
let retries = 0;
|
|
2534
|
+
for (const row of rows) {
|
|
2535
|
+
if (pastDeadline(deadline)) return "stop";
|
|
2536
|
+
const outcome = await keyRow(deps, formId, settings, row);
|
|
2537
|
+
if (outcome === "stale-form") return "stop";
|
|
2538
|
+
if (outcome === "retry") retries += 1;
|
|
2539
|
+
}
|
|
2540
|
+
return retries === 0 ? "done" : "again";
|
|
2541
|
+
}
|
|
2542
|
+
/**
|
|
2543
|
+
* Every row `where` matches, oldest first. Read a page at a time by id, each
|
|
2544
|
+
* page after the last id read: ids are unique and fixed, so a row deleted or
|
|
2545
|
+
* added meanwhile never moves one that is there throughout out of reach, as
|
|
2546
|
+
* a page by position would. Then put in the order they were stored.
|
|
2547
|
+
*/
|
|
2548
|
+
async function snapshotOf(page, where) {
|
|
2549
|
+
const rows = [];
|
|
2550
|
+
for (let after;;) {
|
|
2551
|
+
const read = await page(after === void 0 ? where : {
|
|
2552
|
+
...where,
|
|
2553
|
+
id: { greater_than: after }
|
|
2554
|
+
});
|
|
2555
|
+
rows.push(...read);
|
|
2556
|
+
if (read.length < PAGE) break;
|
|
2557
|
+
after = String(read[read.length - 1]?.id);
|
|
2558
|
+
}
|
|
2559
|
+
return rows.sort(storedFirst);
|
|
2560
|
+
}
|
|
2561
|
+
/** Older first, by when each was stored; ties by id, the same every time. */
|
|
2562
|
+
function storedFirst(a, b) {
|
|
2563
|
+
const at = (row) => new Date(row.createdAt).getTime();
|
|
2564
|
+
return at(a) - at(b) || (String(a.id) < String(b.id) ? -1 : 1);
|
|
2565
|
+
}
|
|
2566
|
+
/**
|
|
2567
|
+
* Give one submission the key of its answer or address, or none where
|
|
2568
|
+
* another submission of the form holds that value already.
|
|
2569
|
+
*/
|
|
2570
|
+
async function keyRow(deps, formId, settings, row) {
|
|
2571
|
+
const target = await targetKeyOf(deps, formId, settings, row);
|
|
2572
|
+
if (target === void 0) return "unchanged";
|
|
2573
|
+
if ((row.responseKey ?? null) === target) return "unchanged";
|
|
2574
|
+
return writeKey(deps, row, target);
|
|
2575
|
+
}
|
|
2576
|
+
/**
|
|
2577
|
+
* The key a row should hold: its own value's, or none (null) where another
|
|
2578
|
+
* submission holds that value; undefined when it holds its own already.
|
|
2579
|
+
*/
|
|
2580
|
+
async function targetKeyOf(deps, formId, settings, row) {
|
|
2581
|
+
const own = await storedRowKeyOf(deps.digests, settings, row);
|
|
2582
|
+
if (own === null) return null;
|
|
2583
|
+
if (row.responseKey === own) return void 0;
|
|
2584
|
+
return await heldByAnother(deps, formId, row, own) ? null : own;
|
|
2585
|
+
}
|
|
2586
|
+
/** Write `key` to a row as the plugin's own key write. */
|
|
2587
|
+
async function writeKey(deps, row, key) {
|
|
2588
|
+
try {
|
|
2589
|
+
await deps.collections.updateEntry(deps.submissionsSlug, String(row.id), asPluginSubmission({
|
|
2590
|
+
responseKey: key,
|
|
2591
|
+
_rev: row._rev
|
|
2592
|
+
}, {
|
|
2593
|
+
keepAsEvidence: false,
|
|
2594
|
+
setKey: key
|
|
2595
|
+
}), { as: "system" });
|
|
2596
|
+
} catch (error) {
|
|
2597
|
+
if (heldRefusal(error) === "changed") return "stale-form";
|
|
2598
|
+
const code = codeOf(error);
|
|
2599
|
+
if (code === "STALE_VERSION" || code === "DUPLICATE") return "retry";
|
|
2600
|
+
throw error;
|
|
2601
|
+
}
|
|
2602
|
+
return "written";
|
|
2603
|
+
}
|
|
2604
|
+
function codeOf(error) {
|
|
2605
|
+
return error instanceof NextlyError ? error.code : void 0;
|
|
2606
|
+
}
|
|
2607
|
+
/** Whether a submission of the form other than `row` holds `key`. */
|
|
2608
|
+
async function heldByAnother(deps, formId, row, key) {
|
|
2609
|
+
return await deps.collections.count(deps.submissionsSlug, { where: {
|
|
2610
|
+
form: { equals: formId },
|
|
2611
|
+
responseKey: { equals: key },
|
|
2612
|
+
id: { not_equals: String(row.id) }
|
|
2613
|
+
} }, { as: "system" }) > 0;
|
|
2614
|
+
}
|
|
2615
|
+
/**
|
|
2616
|
+
* Record that the form's keys are made for `version`, by a write that names
|
|
2617
|
+
* the revision the sweep read: a form saved meanwhile is left for the next
|
|
2618
|
+
* sweep to judge.
|
|
2619
|
+
*/
|
|
2620
|
+
async function markKeyed(deps, form, version) {
|
|
2621
|
+
const payload = {
|
|
2622
|
+
settings: settingsObjectOf(form.settings) ?? {},
|
|
2623
|
+
_rev: form._rev
|
|
2624
|
+
};
|
|
2625
|
+
Object.defineProperty(payload, FORM_KEYED, {
|
|
2626
|
+
value: { version },
|
|
2627
|
+
enumerable: true,
|
|
2628
|
+
configurable: true
|
|
2629
|
+
});
|
|
2630
|
+
try {
|
|
2631
|
+
await deps.collections.updateEntry(deps.formsSlug, String(form.id), payload, { as: "system" });
|
|
2632
|
+
} catch (error) {
|
|
2633
|
+
if (codeOf(error) === "STALE_VERSION") return;
|
|
2634
|
+
throw error;
|
|
2635
|
+
}
|
|
1535
2636
|
}
|
|
1536
2637
|
|
|
1537
2638
|
// src/handlers/spam-detection.ts
|
|
@@ -1558,7 +2659,9 @@ function prepareSubmission({ data, fields, validate }) {
|
|
|
1558
2659
|
* Performs multiple spam checks based on configuration:
|
|
1559
2660
|
* 1. Honeypot field detection
|
|
1560
2661
|
* 2. Rate limiting by IP/form
|
|
1561
|
-
*
|
|
2662
|
+
*
|
|
2663
|
+
* A form's bot check (reCAPTCHA or Turnstile) is verified with its provider
|
|
2664
|
+
* where the submission is stored (`handlers/bot-check`), not here.
|
|
1562
2665
|
*
|
|
1563
2666
|
* @param options - Spam check options
|
|
1564
2667
|
* @returns Spam check result
|
|
@@ -1664,6 +2767,98 @@ async function checkRateLimit(ipAddress, formSlug, config) {
|
|
|
1664
2767
|
* @since 0.1.0
|
|
1665
2768
|
*/
|
|
1666
2769
|
/**
|
|
2770
|
+
* A submission's spam verdict and its prepared data: the honeypot first,
|
|
2771
|
+
* BEFORE validation, so a bot that trips the trap while also failing
|
|
2772
|
+
* validation still receives the same fake success as any other bot; then the
|
|
2773
|
+
* form's bot check for one that would otherwise be refused as invalid.
|
|
2774
|
+
*/
|
|
2775
|
+
async function judgeAndPrepare(input) {
|
|
2776
|
+
const { data, form, settings, pluginConfig, pluginContext, formSlug } = input;
|
|
2777
|
+
const { logger } = pluginContext;
|
|
2778
|
+
const declaredFieldNames = new Set(form.fields.map((field) => field.name));
|
|
2779
|
+
const undeclaredData = Object.fromEntries(Object.entries(data).filter(([key]) => !declaredFieldNames.has(key)));
|
|
2780
|
+
let spamResult = await checkSpam({
|
|
2781
|
+
data: undeclaredData,
|
|
2782
|
+
formSlug,
|
|
2783
|
+
config: { honeypot: settings.honeypotEnabled ?? pluginConfig.spamProtection.honeypot }
|
|
2784
|
+
});
|
|
2785
|
+
const isContentSpam = spamResult.isSpam;
|
|
2786
|
+
if (isContentSpam) logger.info?.("Spam submission detected — storing flagged", {
|
|
2787
|
+
formSlug,
|
|
2788
|
+
reason: spamResult.reason,
|
|
2789
|
+
ipAddress: input.ipAddress
|
|
2790
|
+
});
|
|
2791
|
+
let prepared = prepareSubmission({
|
|
2792
|
+
data,
|
|
2793
|
+
fields: form.fields,
|
|
2794
|
+
validate: !isContentSpam
|
|
2795
|
+
});
|
|
2796
|
+
let early = { rateCounted: false };
|
|
2797
|
+
if (prepared.validationErrors && !isContentSpam) {
|
|
2798
|
+
early = await botCheckBeforeRefusal(input);
|
|
2799
|
+
if (early.spam) {
|
|
2800
|
+
spamResult = early.spam;
|
|
2801
|
+
prepared = prepareSubmission({
|
|
2802
|
+
data,
|
|
2803
|
+
fields: form.fields,
|
|
2804
|
+
validate: false
|
|
2805
|
+
});
|
|
2806
|
+
}
|
|
2807
|
+
}
|
|
2808
|
+
return {
|
|
2809
|
+
spamResult,
|
|
2810
|
+
prepared,
|
|
2811
|
+
throttled: early.throttled === true,
|
|
2812
|
+
rateCounted: early.rateCounted
|
|
2813
|
+
};
|
|
2814
|
+
}
|
|
2815
|
+
/**
|
|
2816
|
+
* An invalid submission meets its form's bot check here, before it is
|
|
2817
|
+
* refused, so it is judged as one written any other way: a failing or
|
|
2818
|
+
* missing token keeps it as spam evidence rather than answering the bot with
|
|
2819
|
+
* validation errors. Only for a request-made submission to a form whose
|
|
2820
|
+
* check is ready, as the write seam judges. The rate limit is asked first,
|
|
2821
|
+
* so a throttled network cannot make the provider be called, and is counted
|
|
2822
|
+
* once: the seam is told it has been.
|
|
2823
|
+
*/
|
|
2824
|
+
async function botCheckBeforeRefusal(input) {
|
|
2825
|
+
const spam = input.pluginConfig.spamProtection;
|
|
2826
|
+
if (!input.requested || !botCheckReady(input.settings, spam)) return { rateCounted: false };
|
|
2827
|
+
const rateCounted = Boolean(spam.rateLimit && input.ipAddress);
|
|
2828
|
+
if (rateCounted) {
|
|
2829
|
+
if ((await checkSpam({
|
|
2830
|
+
data: {},
|
|
2831
|
+
ipAddress: input.ipAddress,
|
|
2832
|
+
formSlug: input.form.id,
|
|
2833
|
+
config: { rateLimit: spam.rateLimit }
|
|
2834
|
+
})).isSpam) return {
|
|
2835
|
+
throttled: true,
|
|
2836
|
+
rateCounted
|
|
2837
|
+
};
|
|
2838
|
+
}
|
|
2839
|
+
const { logger } = input.pluginContext;
|
|
2840
|
+
const bot = await judgeBotCheck({
|
|
2841
|
+
settings: input.settings,
|
|
2842
|
+
spam,
|
|
2843
|
+
tokens: botTokensOf(input.data),
|
|
2844
|
+
ip: input.ipAddress,
|
|
2845
|
+
fetch: input.pluginContext.fetch,
|
|
2846
|
+
warn: (message, warnContext) => logger.warn?.(message, warnContext)
|
|
2847
|
+
});
|
|
2848
|
+
return bot ? {
|
|
2849
|
+
spam: bot,
|
|
2850
|
+
rateCounted
|
|
2851
|
+
} : { rateCounted };
|
|
2852
|
+
}
|
|
2853
|
+
/**
|
|
2854
|
+
* Whether a failed store was refused because the person has responded
|
|
2855
|
+
* already: the database refused a second one-response key, or the hold found
|
|
2856
|
+
* an earlier submission with the same value (`heldRefusal`).
|
|
2857
|
+
*/
|
|
2858
|
+
function isRepeatResponse(error, held) {
|
|
2859
|
+
return held === "responded" || error instanceof NextlyError$1 && error.code === "DUPLICATE";
|
|
2860
|
+
}
|
|
2861
|
+
/**
|
|
1667
2862
|
* Process a form submission.
|
|
1668
2863
|
*
|
|
1669
2864
|
* This is the main entry point for handling form submissions.
|
|
@@ -1726,7 +2921,17 @@ async function submitForm(options, context) {
|
|
|
1726
2921
|
};
|
|
1727
2922
|
}
|
|
1728
2923
|
const redirectFor = () => resolveRedirectUrl(form, pluginConfig, pluginContext, targetRead);
|
|
1729
|
-
const
|
|
2924
|
+
const throttledAnswer = async () => {
|
|
2925
|
+
logger.info?.("Form submission refused (rate limit)", { formSlug });
|
|
2926
|
+
return {
|
|
2927
|
+
success: true,
|
|
2928
|
+
outcome: "accepted",
|
|
2929
|
+
successMessage: settings.successMessage,
|
|
2930
|
+
redirect: await redirectFor()
|
|
2931
|
+
};
|
|
2932
|
+
};
|
|
2933
|
+
const settings = normalizeFormSettings(form.settings);
|
|
2934
|
+
const availability = formAvailabilityNow(form, settings, { now: /* @__PURE__ */ new Date() });
|
|
1730
2935
|
if (availability.kind !== "open") {
|
|
1731
2936
|
logger.info?.("Form submission rejected - form not accepting", {
|
|
1732
2937
|
formSlug,
|
|
@@ -1739,9 +2944,9 @@ async function submitForm(options, context) {
|
|
|
1739
2944
|
error: availability.kind === "closed" ? availability.message : NO_SUCH_FORM
|
|
1740
2945
|
};
|
|
1741
2946
|
}
|
|
1742
|
-
const
|
|
1743
|
-
if (
|
|
1744
|
-
if (
|
|
2947
|
+
const onePerAddress = settings.oneResponse === "ip";
|
|
2948
|
+
if (onePerAddress && !metadata?.ipAddress) logger.warn?.("Single-submission form received a submission without an IP — the restriction cannot apply", { formSlug });
|
|
2949
|
+
if (onePerAddress && metadata?.ipAddress) {
|
|
1745
2950
|
if (await collections.count(pluginConfig.formSubmissionOverrides.slug, { where: {
|
|
1746
2951
|
form: { equals: form.id },
|
|
1747
2952
|
ipAddress: { equals: metadata.ipAddress },
|
|
@@ -1754,28 +2959,22 @@ async function submitForm(options, context) {
|
|
|
1754
2959
|
return {
|
|
1755
2960
|
success: false,
|
|
1756
2961
|
outcome: "duplicate",
|
|
1757
|
-
error:
|
|
2962
|
+
error: ALREADY_RESPONDED
|
|
1758
2963
|
};
|
|
1759
2964
|
}
|
|
1760
2965
|
}
|
|
1761
|
-
const
|
|
1762
|
-
const undeclaredData = Object.fromEntries(Object.entries(data).filter(([key]) => !declaredFieldNames.has(key)));
|
|
1763
|
-
const spamResult = await checkSpam({
|
|
1764
|
-
data: undeclaredData,
|
|
1765
|
-
formSlug,
|
|
1766
|
-
config: { honeypot: settings.honeypotEnabled ?? pluginConfig.spamProtection.honeypot }
|
|
1767
|
-
});
|
|
1768
|
-
const isContentSpam = spamResult.isSpam;
|
|
1769
|
-
if (isContentSpam) logger.info?.("Spam submission detected — storing flagged", {
|
|
1770
|
-
formSlug,
|
|
1771
|
-
reason: spamResult.reason,
|
|
1772
|
-
ipAddress: metadata?.ipAddress
|
|
1773
|
-
});
|
|
1774
|
-
const prepared = prepareSubmission({
|
|
2966
|
+
const { spamResult, prepared, throttled, rateCounted } = await judgeAndPrepare({
|
|
1775
2967
|
data,
|
|
1776
|
-
|
|
1777
|
-
|
|
2968
|
+
form,
|
|
2969
|
+
settings,
|
|
2970
|
+
pluginConfig,
|
|
2971
|
+
pluginContext,
|
|
2972
|
+
ipAddress: metadata?.ipAddress,
|
|
2973
|
+
formSlug,
|
|
2974
|
+
requested: request !== void 0
|
|
1778
2975
|
});
|
|
2976
|
+
if (throttled) return throttledAnswer();
|
|
2977
|
+
let isContentSpam = spamResult.isSpam;
|
|
1779
2978
|
if (prepared.validationErrors) {
|
|
1780
2979
|
logger.debug?.("Form validation failed", {
|
|
1781
2980
|
formSlug,
|
|
@@ -1799,30 +2998,53 @@ async function submitForm(options, context) {
|
|
|
1799
2998
|
userAgent: metadata?.userAgent || null,
|
|
1800
2999
|
submittedAt: /* @__PURE__ */ new Date()
|
|
1801
3000
|
};
|
|
3001
|
+
const browserToken = browserTokenOf(data);
|
|
1802
3002
|
let submission;
|
|
1803
3003
|
try {
|
|
1804
3004
|
submission = await collections.createEntry(pluginConfig.formSubmissionOverrides.slug, asPluginSubmission(submissionData, {
|
|
1805
3005
|
keepAsEvidence: isContentSpam,
|
|
1806
3006
|
form: {
|
|
1807
3007
|
id: form.id,
|
|
1808
|
-
fields: form.fields
|
|
1809
|
-
|
|
3008
|
+
fields: form.fields,
|
|
3009
|
+
settings: form.settings
|
|
3010
|
+
},
|
|
3011
|
+
botTokens: botTokensOf(data),
|
|
3012
|
+
...browserToken === void 0 ? {} : { browserToken },
|
|
3013
|
+
...rateCounted ? { rateCounted } : {}
|
|
1810
3014
|
}), {
|
|
1811
3015
|
...access,
|
|
1812
3016
|
...request ? { request } : {}
|
|
1813
3017
|
});
|
|
1814
3018
|
} catch (error) {
|
|
1815
|
-
|
|
1816
|
-
|
|
3019
|
+
const held = heldRefusal(error);
|
|
3020
|
+
if (held === "closed") {
|
|
3021
|
+
logger.info?.("Form submission refused (form closed)", { formSlug });
|
|
3022
|
+
return {
|
|
3023
|
+
success: false,
|
|
3024
|
+
outcome: "closed",
|
|
3025
|
+
error: closedMessageOf(form)
|
|
3026
|
+
};
|
|
3027
|
+
}
|
|
3028
|
+
if (held === "changed") {
|
|
3029
|
+
logger.info?.("Form submission refused (settings changed)", { formSlug });
|
|
1817
3030
|
return {
|
|
1818
|
-
success:
|
|
1819
|
-
outcome: "
|
|
1820
|
-
|
|
1821
|
-
|
|
3031
|
+
success: false,
|
|
3032
|
+
outcome: "failed",
|
|
3033
|
+
error: SETTINGS_CHANGED
|
|
3034
|
+
};
|
|
3035
|
+
}
|
|
3036
|
+
if (isRepeatResponse(error, held)) {
|
|
3037
|
+
logger.info?.("Repeat submission rejected (one response per person)", { formSlug });
|
|
3038
|
+
return {
|
|
3039
|
+
success: false,
|
|
3040
|
+
outcome: "duplicate",
|
|
3041
|
+
error: ALREADY_RESPONDED
|
|
1822
3042
|
};
|
|
1823
3043
|
}
|
|
3044
|
+
if (NextlyError$1.isRateLimited(error)) return throttledAnswer();
|
|
1824
3045
|
throw error;
|
|
1825
3046
|
}
|
|
3047
|
+
if (submission.item.status === "spam") isContentSpam = true;
|
|
1826
3048
|
logger.info?.("Form submission created successfully", {
|
|
1827
3049
|
formSlug,
|
|
1828
3050
|
submissionId: submission.item.id,
|
|
@@ -2227,13 +3449,31 @@ async function listPublishedForms(req, ctx, config) {
|
|
|
2227
3449
|
}, PUBLIC);
|
|
2228
3450
|
return respondList(result.data ?? [], toPageMeta(result.pagination));
|
|
2229
3451
|
}
|
|
3452
|
+
/**
|
|
3453
|
+
* A found form's answer now: its status, then its closing time, then its
|
|
3454
|
+
* limit, so a visitor reaching a form that has closed by itself is given its
|
|
3455
|
+
* closed message rather than fields to fill in. The count here is a plain
|
|
3456
|
+
* read: what is stored at the submission decides exactly, under its lock.
|
|
3457
|
+
*/
|
|
3458
|
+
async function availabilityNow(form, ctx, slugs) {
|
|
3459
|
+
const settings = normalizeFormSettings(form.settings);
|
|
3460
|
+
const formId = typeof form.id === "string" ? form.id : void 0;
|
|
3461
|
+
const taken = settings.submissionLimit === void 0 || formId === void 0 ? void 0 : await ctx.services.ownCollections.count(slugs.submissions, { where: {
|
|
3462
|
+
form: { equals: formId },
|
|
3463
|
+
status: { not_equals: "spam" }
|
|
3464
|
+
} });
|
|
3465
|
+
return formAvailabilityNow(form, settings, {
|
|
3466
|
+
now: /* @__PURE__ */ new Date(),
|
|
3467
|
+
taken
|
|
3468
|
+
});
|
|
3469
|
+
}
|
|
2230
3470
|
async function readFormBySlug(slug, ctx, config) {
|
|
2231
3471
|
const slugs = installedSlugs(config, ctx.self);
|
|
2232
3472
|
const doc = (await ctx.services.collections.listEntries(slugs.forms, {
|
|
2233
3473
|
where: { slug: { equals: slug } },
|
|
2234
3474
|
pagination: { limit: 1 }
|
|
2235
3475
|
}, PUBLIC)).data?.[0];
|
|
2236
|
-
const availability = formAvailability(doc);
|
|
3476
|
+
const availability = doc ? await availabilityNow(doc, ctx, slugs) : formAvailability(doc);
|
|
2237
3477
|
if (availability.kind === "absent") throw NextlyError$1.notFound({
|
|
2238
3478
|
message: NO_SUCH_FORM,
|
|
2239
3479
|
logContext: {
|
|
@@ -2247,7 +3487,13 @@ async function readFormBySlug(slug, ctx, config) {
|
|
|
2247
3487
|
status: "closed",
|
|
2248
3488
|
closedMessage: availability.message
|
|
2249
3489
|
});
|
|
2250
|
-
|
|
3490
|
+
const botCheck = publicBotCheck(doc?.settings, config.spamProtection);
|
|
3491
|
+
const oneResponse = publicOneResponse(normalizeFormSettings(doc?.settings));
|
|
3492
|
+
return respondDoc({
|
|
3493
|
+
...doc,
|
|
3494
|
+
...botCheck ? { botCheck } : {},
|
|
3495
|
+
...oneResponse ? { oneResponse } : {}
|
|
3496
|
+
});
|
|
2251
3497
|
}
|
|
2252
3498
|
/**
|
|
2253
3499
|
* The submitted answers, or the refusal for a body that carries none.
|
|
@@ -2436,9 +3682,10 @@ function resolveConfig(options) {
|
|
|
2436
3682
|
defaultToEmail: options.notifications?.defaultToEmail,
|
|
2437
3683
|
enabled: options.notifications?.enabled ?? true
|
|
2438
3684
|
},
|
|
3685
|
+
oneResponse: oneResponseOptions(options.oneResponse),
|
|
2439
3686
|
spamProtection: {
|
|
2440
3687
|
honeypot: options.spamProtection?.honeypot ?? true,
|
|
2441
|
-
|
|
3688
|
+
...botCheckOptions(options.spamProtection),
|
|
2442
3689
|
rateLimit: {
|
|
2443
3690
|
maxSubmissions: options.spamProtection?.rateLimit?.maxSubmissions ?? 10,
|
|
2444
3691
|
windowMs: options.spamProtection?.rateLimit?.windowMs ?? 6e4
|
|
@@ -2496,11 +3743,14 @@ function formBuilder(options = {}) {
|
|
|
2496
3743
|
forms: resolvedConfig.formOverrides.slug,
|
|
2497
3744
|
submissions: resolvedConfig.formSubmissionOverrides.slug
|
|
2498
3745
|
}, () => installedSubmissionsSlug);
|
|
3746
|
+
const outbound = botCheckHosts(resolvedConfig.spamProtection);
|
|
3747
|
+
let rebuildDeps;
|
|
2499
3748
|
return {
|
|
2500
3749
|
plugin: definePlugin({
|
|
2501
3750
|
name: "@nextlyhq/plugin-form-builder",
|
|
2502
3751
|
version: PLUGIN_VERSION,
|
|
2503
3752
|
nextly: ">=0.0.2-alpha.21",
|
|
3753
|
+
...outbound.length > 0 ? { capabilities: { net: { outbound } } } : {},
|
|
2504
3754
|
author: "Nextly",
|
|
2505
3755
|
homepage: "https://nextlyhq.com",
|
|
2506
3756
|
repository: "https://github.com/nextlyhq/nextly",
|
|
@@ -2513,7 +3763,15 @@ function formBuilder(options = {}) {
|
|
|
2513
3763
|
],
|
|
2514
3764
|
contributes: {
|
|
2515
3765
|
collections: [formsCol, submissionsCol],
|
|
3766
|
+
jobs: [rebuildKeysJob(async (deadline) => {
|
|
3767
|
+
if (rebuildDeps) await rebuildResponseKeys(rebuildDeps, deadline);
|
|
3768
|
+
})],
|
|
2516
3769
|
dashboard: { widgets: [newSubmissions] },
|
|
3770
|
+
admin: { views: { [resolvedConfig.formOverrides.slug]: { documentViews: addressedFormPages().map((page) => ({
|
|
3771
|
+
...page,
|
|
3772
|
+
component: FORM_EDITOR_VIEW
|
|
3773
|
+
})) } } },
|
|
3774
|
+
emailTemplates: formsEmailTemplates(),
|
|
2517
3775
|
publicGrants: [{
|
|
2518
3776
|
collection: resolvedConfig.formSubmissionOverrides.slug,
|
|
2519
3777
|
action: "create"
|
|
@@ -2604,7 +3862,8 @@ function formBuilder(options = {}) {
|
|
|
2604
3862
|
},
|
|
2605
3863
|
spamProtection: {
|
|
2606
3864
|
honeypot: resolvedConfig.spamProtection.honeypot,
|
|
2607
|
-
recaptchaEnabled: resolvedConfig.spamProtection.recaptcha?.enabled ?? false
|
|
3865
|
+
recaptchaEnabled: resolvedConfig.spamProtection.recaptcha?.enabled ?? false,
|
|
3866
|
+
...botCheckDefaults(resolvedConfig.spamProtection)
|
|
2608
3867
|
},
|
|
2609
3868
|
redirectCollections: Object.keys(resolvedConfig.redirectRelationships),
|
|
2610
3869
|
slugs: {
|
|
@@ -2629,6 +3888,7 @@ function formBuilder(options = {}) {
|
|
|
2629
3888
|
const declaredSubmissionsSlug = resolvedConfig.formSubmissionOverrides.slug;
|
|
2630
3889
|
const submissionSlug = nextly.self.collections[declaredSubmissionsSlug] ?? declaredSubmissionsSlug;
|
|
2631
3890
|
installedSubmissionsSlug = submissionSlug;
|
|
3891
|
+
await nextly.security.digests.prepare();
|
|
2632
3892
|
nextly.logger.info("Form Builder plugin initialized", {
|
|
2633
3893
|
formsCollection: resolvedConfig.formOverrides.slug,
|
|
2634
3894
|
submissionsCollection: submissionSlug,
|
|
@@ -2664,6 +3924,37 @@ function formBuilder(options = {}) {
|
|
|
2664
3924
|
}
|
|
2665
3925
|
return ctx.data;
|
|
2666
3926
|
});
|
|
3927
|
+
warnUncheckedBotChecks({
|
|
3928
|
+
spam: resolvedConfig.spamProtection,
|
|
3929
|
+
forms: async () => (await nextly.services.ownCollections.listEntries(formsSlug, { pagination: { limit: 500 } })).data ?? [],
|
|
3930
|
+
readSettings: normalizeFormSettings,
|
|
3931
|
+
warn: (message, context) => nextly.logger.warn?.(message, context)
|
|
3932
|
+
});
|
|
3933
|
+
nextly.hooks.on("beforeChange", formsSlug, (context) => {
|
|
3934
|
+
refuseOneResponseFieldProblem(context);
|
|
3935
|
+
return context.data;
|
|
3936
|
+
});
|
|
3937
|
+
rebuildDeps = {
|
|
3938
|
+
collections: nextly.services.collections,
|
|
3939
|
+
digests: () => nextly.security.digests,
|
|
3940
|
+
formsSlug,
|
|
3941
|
+
submissionsSlug: submissionSlug
|
|
3942
|
+
};
|
|
3943
|
+
nextly.hooks.on("beforeChange", formsSlug, async (context) => {
|
|
3944
|
+
await stampOneResponseVersion(context, async (formId) => keysFitInSave(nextly, submissionSlug, formId, resolvedConfig.oneResponse.keysInSave));
|
|
3945
|
+
return context.data;
|
|
3946
|
+
});
|
|
3947
|
+
nextly.hooks.onInTransaction(formsSlug, (write) => rebuildInSave({
|
|
3948
|
+
own: nextly.services.ownCollections,
|
|
3949
|
+
collections: nextly.services.collections,
|
|
3950
|
+
digests: () => nextly.security.digests,
|
|
3951
|
+
submissionsSlug: submissionSlug
|
|
3952
|
+
}, write));
|
|
3953
|
+
nextly.hooks.on("afterUpdate", formsSlug, (context) => rebuildAfterSave(context, () => nextly.services.jobs.runSweepNow(REBUILD_KEYS_JOB, { maxDurationMs: REBUILD_ON_SAVE_MS }), (message, context) => nextly.logger.warn?.(message, context)));
|
|
3954
|
+
registerFormClosing(nextly, {
|
|
3955
|
+
forms: formsSlug,
|
|
3956
|
+
submissions: submissionSlug
|
|
3957
|
+
});
|
|
2667
3958
|
nextly.hooks.on("afterRead", formsSlug, (context) => injectSubmissionCount(context, submissionSlug, nextly));
|
|
2668
3959
|
}
|
|
2669
3960
|
}),
|
|
@@ -2745,6 +4036,7 @@ function normalizeAddressList(addresses) {
|
|
|
2745
4036
|
*/
|
|
2746
4037
|
function buildNotificationEmails(input) {
|
|
2747
4038
|
const { notifications, submittedData, formName, submissionId, defaultFrom } = input;
|
|
4039
|
+
const answers = submissionAnswers(input.fields ?? [], submittedData);
|
|
2748
4040
|
const seen = /* @__PURE__ */ new Set();
|
|
2749
4041
|
const emails = [];
|
|
2750
4042
|
const skipped = [];
|
|
@@ -2785,7 +4077,11 @@ function buildNotificationEmails(input) {
|
|
|
2785
4077
|
variables: {
|
|
2786
4078
|
...submittedData,
|
|
2787
4079
|
formName,
|
|
2788
|
-
submissionId
|
|
4080
|
+
submissionId,
|
|
4081
|
+
_submission: {
|
|
4082
|
+
id: submissionId,
|
|
4083
|
+
answers
|
|
4084
|
+
}
|
|
2789
4085
|
},
|
|
2790
4086
|
providerId: notification.providerId,
|
|
2791
4087
|
from,
|
|
@@ -2897,19 +4193,83 @@ function updateNeedsChecking(submission, stored) {
|
|
|
2897
4193
|
if (submission.form !== void 0) return true;
|
|
2898
4194
|
return stored.status === "spam" && submission.status !== void 0 && submission.status !== "spam";
|
|
2899
4195
|
}
|
|
4196
|
+
/**
|
|
4197
|
+
* Whether a form has few enough non-spam submissions for a change of its
|
|
4198
|
+
* one-response setting to give them keys inside the save.
|
|
4199
|
+
*/
|
|
4200
|
+
async function keysFitInSave(nextly, submissionsSlug, formId, limit) {
|
|
4201
|
+
return await nextly.services.collections.count(submissionsSlug, { where: {
|
|
4202
|
+
form: { equals: formId },
|
|
4203
|
+
status: { not_equals: "spam" }
|
|
4204
|
+
} }, { as: "system" }) <= limit;
|
|
4205
|
+
}
|
|
4206
|
+
/**
|
|
4207
|
+
* On a form that keeps no addresses, compare a keyed submission's address
|
|
4208
|
+
* with the earlier submissions' before it is dropped: the hold compares
|
|
4209
|
+
* stored addresses, and this one will have none. An earlier submission with
|
|
4210
|
+
* the address but without a key for the current version (one stored before
|
|
4211
|
+
* the form stopped keeping addresses, while its keys are made) would
|
|
4212
|
+
* otherwise let the network respond again. Read outside the form's lock,
|
|
4213
|
+
* which is enough: the earlier submissions it guards against are not being
|
|
4214
|
+
* written, and new ones race on their keys, which the unique index decides.
|
|
4215
|
+
*/
|
|
4216
|
+
async function refuseEarlierAddress(settings, ctx, formId, nextly) {
|
|
4217
|
+
const row = ctx.data;
|
|
4218
|
+
if (settings.storeIpAndBrowser !== false || settings.oneResponse !== "ip") return;
|
|
4219
|
+
if (typeof row?.ipAddress !== "string" || !row.responseKey) return;
|
|
4220
|
+
if (await nextly.services.collections.count(ctx.collection ?? "form-submissions", { where: {
|
|
4221
|
+
form: { equals: formId },
|
|
4222
|
+
ipAddress: { equals: row.ipAddress },
|
|
4223
|
+
status: { not_equals: "spam" }
|
|
4224
|
+
} }, { as: "system" }) > 0) throw respondedRefusal(formId);
|
|
4225
|
+
}
|
|
4226
|
+
/**
|
|
4227
|
+
* A form that keeps no IP address or browser (Settings › Privacy) stores
|
|
4228
|
+
* neither with a new submission. Cleared after the one-response key is made,
|
|
4229
|
+
* so one per network still compares the address, by its keyed digest only.
|
|
4230
|
+
*/
|
|
4231
|
+
function withholdAddress(settings, row) {
|
|
4232
|
+
if (settings.storeIpAndBrowser !== false) return;
|
|
4233
|
+
row.ipAddress = null;
|
|
4234
|
+
row.userAgent = null;
|
|
4235
|
+
}
|
|
4236
|
+
/**
|
|
4237
|
+
* A request's address as the server judged it, never one the caller posted:
|
|
4238
|
+
* the collection grants public create, and a posted address would choose the
|
|
4239
|
+
* network one response per person compares. A create no request produced
|
|
4240
|
+
* (an import) keeps the address it was given.
|
|
4241
|
+
*/
|
|
4242
|
+
function stampRequestAddress(ctx, row) {
|
|
4243
|
+
if (ctx.operation === "create" && ctx.req?.http) row.ipAddress = ctx.req.http.ip ?? null;
|
|
4244
|
+
}
|
|
4245
|
+
/**
|
|
4246
|
+
* The plugin's own write of a one-response key it made (`setKey`): the
|
|
4247
|
+
* key is written and nothing else is done to the patch. Whether `row` was one.
|
|
4248
|
+
*/
|
|
4249
|
+
function pluginKeyWrite(operation, row, stored) {
|
|
4250
|
+
const key = submissionMarks(row)?.setKey;
|
|
4251
|
+
if (operation !== "update" || key === void 0) return false;
|
|
4252
|
+
row.responseKey = stored?.status === "spam" ? null : key;
|
|
4253
|
+
return true;
|
|
4254
|
+
}
|
|
2900
4255
|
async function prepareSubmissionForWrite(context, formsSlug, nextly) {
|
|
2901
4256
|
const ctx = context;
|
|
2902
4257
|
const submission = ctx.data;
|
|
2903
4258
|
if (!submission || typeof submission !== "object") return ctx.data;
|
|
4259
|
+
if (pluginKeyWrite(ctx.operation, submission, ctx.originalData)) return submission;
|
|
4260
|
+
delete submission.responseKey;
|
|
2904
4261
|
const stored = ctx.originalData;
|
|
2905
4262
|
const formId = parentFormId(submission) ?? (stored ? parentFormId(stored) : null);
|
|
2906
4263
|
if (!formId) return ctx.data;
|
|
2907
|
-
if (ctx.operation !== "create" && !updateNeedsChecking(submission, stored))
|
|
4264
|
+
if (ctx.operation !== "create" && !updateNeedsChecking(submission, stored)) {
|
|
4265
|
+
await rekeyAfterUpdate(ctx, formsSlug, formId, nextly);
|
|
4266
|
+
return ctx.data;
|
|
4267
|
+
}
|
|
2908
4268
|
const incoming = submittedPayload(submission.data !== void 0 ? submission.data : stored?.data);
|
|
2909
4269
|
const marks = submissionMarks(submission);
|
|
2910
4270
|
const form = await formToCheckAgainst(marks, formsSlug, formId, nextly);
|
|
2911
4271
|
const fields = form.fields;
|
|
2912
|
-
const spam = ctx.operation === "create" && ctx.req?.http ? await judgeSubmission(incoming, form, ctx.req.http, nextly) : void 0;
|
|
4272
|
+
const spam = ctx.operation === "create" && ctx.req?.http ? await judgeSubmission(incoming, form, ctx.req.http, nextly, marks) : void 0;
|
|
2913
4273
|
if (spam?.reason === "rate_limit") throw NextlyError.rateLimited();
|
|
2914
4274
|
const prepared = prepareSubmission({
|
|
2915
4275
|
data: incoming,
|
|
@@ -2931,9 +4291,112 @@ async function prepareSubmissionForWrite(context, formsSlug, nextly) {
|
|
|
2931
4291
|
ctx.data.spamReason = spam.reason ?? null;
|
|
2932
4292
|
}
|
|
2933
4293
|
if (ctx.operation !== "create" && submission.data === void 0) stampDerivedEdit(ctx.data, ctx.user, incoming, prepared.data);
|
|
4294
|
+
stampRequestAddress(ctx, ctx.data);
|
|
4295
|
+
if (ctx.operation === "create") {
|
|
4296
|
+
const settings = normalizeFormSettings(form.settings);
|
|
4297
|
+
const browserToken = marks?.browserToken ?? browserTokenOf(incoming);
|
|
4298
|
+
ctx.data.responseKey = await responseKeyOf(() => nextly.security.digests, settings, {
|
|
4299
|
+
...ctx.data,
|
|
4300
|
+
browserToken
|
|
4301
|
+
});
|
|
4302
|
+
warnTokenless(settings, browserToken, ctx, formId, nextly);
|
|
4303
|
+
await refuseEarlierAddress(settings, ctx, formId, nextly);
|
|
4304
|
+
withholdAddress(settings, ctx.data);
|
|
4305
|
+
} else await rekeyAfterUpdate(ctx, formsSlug, formId, nextly, form);
|
|
2934
4306
|
return ctx.data;
|
|
2935
4307
|
}
|
|
2936
4308
|
/**
|
|
4309
|
+
* Say so when a visitor's submission reaches a form taking one response per
|
|
4310
|
+
* browser without the browser's token: it is accepted, as a browser that
|
|
4311
|
+
* cleared its data is, and nothing stops that visitor sending another. Left
|
|
4312
|
+
* silent, a site whose form script does not send the token would never learn
|
|
4313
|
+
* the setting has no effect.
|
|
4314
|
+
*/
|
|
4315
|
+
function warnTokenless(settings, browserToken, ctx, formId, nextly) {
|
|
4316
|
+
if (settings.oneResponse !== "browser" || browserToken !== void 0) return;
|
|
4317
|
+
if (!ctx.req?.http || ctx.data?.status === "spam") return;
|
|
4318
|
+
nextly.logger.warn?.("A submission to a form taking one response per browser came without the browser's token, so it is accepted without a check. The form's script sends it as the `nextly-browser-token` answer.", { formId });
|
|
4319
|
+
}
|
|
4320
|
+
/**
|
|
4321
|
+
* Move an updated submission's one-response key with what the update changes
|
|
4322
|
+
* (`responseKeyAfterUpdate`): spam gives it back, a restored or edited
|
|
4323
|
+
* submission takes the key of what it now holds. An update that touches none
|
|
4324
|
+
* of status, answers or address leaves it as stored.
|
|
4325
|
+
*/
|
|
4326
|
+
async function rekeyAfterUpdate(ctx, formsSlug, formId, nextly, form) {
|
|
4327
|
+
const row = ctx.data;
|
|
4328
|
+
if (!row || !touchesResponseKey(row)) return;
|
|
4329
|
+
const parent = form ?? await formToCheckAgainst(void 0, formsSlug, formId, nextly);
|
|
4330
|
+
const stored = ctx.originalData;
|
|
4331
|
+
const movedFrom = stored ? parentFormId(stored) : null;
|
|
4332
|
+
row.responseKey = await responseKeyAfterUpdate({
|
|
4333
|
+
moved: movedFrom !== null && movedFrom !== formId,
|
|
4334
|
+
digests: () => nextly.security.digests,
|
|
4335
|
+
settings: normalizeFormSettings(parent.settings),
|
|
4336
|
+
patch: row,
|
|
4337
|
+
stored,
|
|
4338
|
+
heldElsewhere: async (key) => await nextly.services.collections.count(ctx.collection ?? "form-submissions", { where: {
|
|
4339
|
+
form: { equals: formId },
|
|
4340
|
+
responseKey: { equals: key },
|
|
4341
|
+
...stored?.id ? { id: { not_equals: stored.id } } : {}
|
|
4342
|
+
} }, { as: "system" }) > 0
|
|
4343
|
+
});
|
|
4344
|
+
await refuseTakenAddress(ctx, formId, nextly, parent);
|
|
4345
|
+
}
|
|
4346
|
+
/**
|
|
4347
|
+
* Refuse an edit that moves a submission onto an email address another
|
|
4348
|
+
* submission of the form already holds, saying which one only to a person who
|
|
4349
|
+
* may read it. The holder is found as the system, since the address is taken
|
|
4350
|
+
* whoever may see it; whether to name it is the editor's own read, judged by
|
|
4351
|
+
* the access gate. Anyone else is told only that the address is used, so the
|
|
4352
|
+
* refusal never shows a submission they could not open. The unique index
|
|
4353
|
+
* still refuses a holder that commits after this look, without the name.
|
|
4354
|
+
*/
|
|
4355
|
+
async function refuseTakenAddress(ctx, formId, nextly, form) {
|
|
4356
|
+
const key = ctx.data?.responseKey;
|
|
4357
|
+
const stored = ctx.originalData;
|
|
4358
|
+
if (typeof key !== "string" || key === stored?.responseKey) return;
|
|
4359
|
+
if (normalizeFormSettings(form.settings).oneResponse !== "email") return;
|
|
4360
|
+
const collection = ctx.collection ?? "form-submissions";
|
|
4361
|
+
const { data: holders } = await nextly.services.collections.listEntries(collection, {
|
|
4362
|
+
where: {
|
|
4363
|
+
form: { equals: formId },
|
|
4364
|
+
responseKey: { equals: key },
|
|
4365
|
+
...stored?.id ? { id: { not_equals: stored.id } } : {}
|
|
4366
|
+
},
|
|
4367
|
+
pagination: { limit: 1 }
|
|
4368
|
+
}, { as: "system" });
|
|
4369
|
+
const id = (holders?.[0])?.id;
|
|
4370
|
+
if (typeof id !== "string" && typeof id !== "number") return;
|
|
4371
|
+
const holderId = String(id);
|
|
4372
|
+
const named = await mayRead(nextly, collection, holderId, ctx.user);
|
|
4373
|
+
throw NextlyError.duplicate({
|
|
4374
|
+
message: named ? `This email address is already used by submission ${holderId} of this form.` : "This email address is already used by another submission of this form.",
|
|
4375
|
+
logContext: {
|
|
4376
|
+
formId,
|
|
4377
|
+
holderId
|
|
4378
|
+
}
|
|
4379
|
+
});
|
|
4380
|
+
}
|
|
4381
|
+
/** Whether a hook's user is a signed-in person the access gate can judge. */
|
|
4382
|
+
function isAuthUser(user) {
|
|
4383
|
+
if (!user || typeof user !== "object") return false;
|
|
4384
|
+
const { id, email } = user;
|
|
4385
|
+
return typeof id === "string" && typeof email === "string";
|
|
4386
|
+
}
|
|
4387
|
+
/** Whether `user` may read the submission `id`, asked of the access gate. */
|
|
4388
|
+
async function mayRead(nextly, collection, id, user) {
|
|
4389
|
+
if (!isAuthUser(user)) return false;
|
|
4390
|
+
try {
|
|
4391
|
+
return await nextly.services.collections.count(collection, { where: { id: { equals: id } } }, {
|
|
4392
|
+
as: "user",
|
|
4393
|
+
user
|
|
4394
|
+
}) > 0;
|
|
4395
|
+
} catch {
|
|
4396
|
+
return false;
|
|
4397
|
+
}
|
|
4398
|
+
}
|
|
4399
|
+
/**
|
|
2937
4400
|
* The status a submission a request created starts with: `new`, whatever the
|
|
2938
4401
|
* request named, before the spam verdict may make it `spam`. The collection
|
|
2939
4402
|
* grants public create and nothing restricts `status`, so a visitor posting
|
|
@@ -2998,7 +4461,7 @@ function rateLimitKeyFor(form) {
|
|
|
2998
4461
|
* every visitor behind an untrusted proxy. The honeypot still applies, because
|
|
2999
4462
|
* it reads the payload rather than the caller.
|
|
3000
4463
|
*/
|
|
3001
|
-
async function judgeSubmission(payload, form, http, nextly) {
|
|
4464
|
+
async function judgeSubmission(payload, form, http, nextly, marks) {
|
|
3002
4465
|
const config = getFormBuilderConfig(nextly);
|
|
3003
4466
|
if (!config) return void 0;
|
|
3004
4467
|
const declared = new Set((Array.isArray(form.fields) ? form.fields : []).map((field) => field.name).filter((name) => typeof name === "string"));
|
|
@@ -3010,10 +4473,19 @@ async function judgeSubmission(payload, form, http, nextly) {
|
|
|
3010
4473
|
formSlug: rateLimitKeyFor(form),
|
|
3011
4474
|
config: {
|
|
3012
4475
|
honeypot: settings.honeypotEnabled ?? config.spamProtection.honeypot,
|
|
3013
|
-
rateLimit: config.spamProtection.rateLimit
|
|
4476
|
+
rateLimit: marks?.rateCounted === true ? void 0 : config.spamProtection.rateLimit
|
|
3014
4477
|
}
|
|
3015
4478
|
});
|
|
3016
|
-
|
|
4479
|
+
if (verdict.isSpam) return verdict;
|
|
4480
|
+
if (marks?.keepAsEvidence === true) return void 0;
|
|
4481
|
+
return judgeBotCheck({
|
|
4482
|
+
settings: normalizeFormSettings(form.settings),
|
|
4483
|
+
spam: config.spamProtection,
|
|
4484
|
+
tokens: marks?.botTokens ?? botTokensOf(payload),
|
|
4485
|
+
ip: http.ip ?? void 0,
|
|
4486
|
+
fetch: nextly.fetch,
|
|
4487
|
+
warn: (message, context) => nextly.logger.warn?.(message, context)
|
|
4488
|
+
});
|
|
3017
4489
|
}
|
|
3018
4490
|
/**
|
|
3019
4491
|
* Record a payload change this hook derived, since nothing else will.
|
|
@@ -3051,7 +4523,8 @@ async function handleSubmissionCreated(context, config, nextly) {
|
|
|
3051
4523
|
submittedData,
|
|
3052
4524
|
formName: form.name,
|
|
3053
4525
|
submissionId: submission.id,
|
|
3054
|
-
defaultFrom: config.notifications.defaultFrom
|
|
4526
|
+
defaultFrom: config.notifications.defaultFrom,
|
|
4527
|
+
fields: Array.isArray(form.fields) ? form.fields : []
|
|
3055
4528
|
});
|
|
3056
4529
|
for (const skip of skipped) if (skip.reason === "empty-recipient") nextly.logger.warn?.("Form Builder: empty recipient, skipping notification", {
|
|
3057
4530
|
notificationId: skip.notificationId,
|
|
@@ -3063,52 +4536,71 @@ async function handleSubmissionCreated(context, config, nextly) {
|
|
|
3063
4536
|
reason: skip.reason
|
|
3064
4537
|
});
|
|
3065
4538
|
if (emails.length === 0) return;
|
|
3066
|
-
await
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
4539
|
+
const finalEmails = await nextly.filters.apply("form-builder.beforeEmail", emails, {
|
|
4540
|
+
form,
|
|
4541
|
+
submission
|
|
4542
|
+
});
|
|
4543
|
+
const send = () => queueNotificationEmails({
|
|
4544
|
+
emails: finalEmails,
|
|
3071
4545
|
emailService,
|
|
3072
4546
|
logger: nextly.logger,
|
|
3073
4547
|
formSlug: form.slug,
|
|
4548
|
+
submissionId: typeof submission.id === "number" ? `${submission.id}` : typeof submission.id === "string" ? submission.id : "",
|
|
3074
4549
|
fileAttachments
|
|
3075
4550
|
});
|
|
4551
|
+
const { afterCommit } = context;
|
|
4552
|
+
await (afterCommit ? afterCommit(send) : send());
|
|
4553
|
+
}
|
|
4554
|
+
/**
|
|
4555
|
+
* The key a notification is queued under: its submission, its place among
|
|
4556
|
+
* the submission's emails, and a short digest of its notification's id. The
|
|
4557
|
+
* place keeps apart two emails of one notification (a `beforeEmail` hook
|
|
4558
|
+
* may add a copy of one), and the digest keeps the key short whatever the
|
|
4559
|
+
* id's length. The same submission's emails, built again, get the same keys.
|
|
4560
|
+
*/
|
|
4561
|
+
function notificationKey(submissionId, index, notificationId) {
|
|
4562
|
+
return `form-notification:${submissionId}:${index}:${createHash("sha256").update(notificationId ?? "").digest("hex").slice(0, 16)}`;
|
|
3076
4563
|
}
|
|
3077
4564
|
/**
|
|
3078
|
-
*
|
|
4565
|
+
* Queue each notification, and log where it stands.
|
|
3079
4566
|
*
|
|
3080
|
-
*
|
|
3081
|
-
*
|
|
3082
|
-
*
|
|
3083
|
-
*
|
|
3084
|
-
*
|
|
4567
|
+
* Queued through the email service's queued send rather than sent once: the
|
|
4568
|
+
* queue makes the first attempt at once, and a failure a retry can help is
|
|
4569
|
+
* tried again through the job queue, with backoff, until it is sent or fails
|
|
4570
|
+
* for good, never dropped. Each carries an idempotency key from its
|
|
4571
|
+
* submission and notification (`notificationKey`), so a provider that takes keys delivers it
|
|
4572
|
+
* once even if this hook runs again. No log line names a recipient: an
|
|
4573
|
+
* address in a log outlives the submission it came from, and the
|
|
4574
|
+
* notification's id and the queued email's id are enough to find it.
|
|
3085
4575
|
*/
|
|
3086
|
-
async function
|
|
4576
|
+
async function queueNotificationEmails(input) {
|
|
3087
4577
|
const { emails, emailService, logger, formSlug, fileAttachments } = input;
|
|
3088
|
-
for (const email of emails) {
|
|
4578
|
+
for (const [index, email] of emails.entries()) {
|
|
3089
4579
|
const identity = {
|
|
3090
4580
|
formSlug,
|
|
3091
4581
|
templateSlug: email.templateSlug,
|
|
3092
4582
|
notificationId: email.notificationId
|
|
3093
4583
|
};
|
|
3094
4584
|
try {
|
|
3095
|
-
const
|
|
4585
|
+
const queued = await emailService.queueWithTemplate(email.templateSlug, email.to, email.variables, {
|
|
3096
4586
|
providerId: email.providerId,
|
|
3097
4587
|
from: email.from,
|
|
3098
4588
|
replyTo: email.replyTo,
|
|
3099
4589
|
cc: email.cc,
|
|
3100
4590
|
bcc: email.bcc,
|
|
3101
|
-
attachments: fileAttachments.length > 0 ? fileAttachments : void 0
|
|
4591
|
+
attachments: fileAttachments.length > 0 ? fileAttachments : void 0,
|
|
4592
|
+
idempotencyKey: notificationKey(input.submissionId, index, email.notificationId)
|
|
3102
4593
|
});
|
|
3103
|
-
if (
|
|
4594
|
+
if (queued.state === "failed") logger.error?.("Form Builder: notification failed", {
|
|
3104
4595
|
...identity,
|
|
3105
|
-
|
|
4596
|
+
queuedId: queued.id,
|
|
4597
|
+
retryable: false
|
|
3106
4598
|
});
|
|
3107
|
-
else logger.
|
|
4599
|
+
else logger.info?.("Form Builder: notification queued", {
|
|
3108
4600
|
...identity,
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
4601
|
+
queuedId: queued.id,
|
|
4602
|
+
state: queued.state,
|
|
4603
|
+
attachmentCount: fileAttachments.length
|
|
3112
4604
|
});
|
|
3113
4605
|
} catch (err) {
|
|
3114
4606
|
logger.error?.("Form Builder: notification failed", {
|