@10x-media/form-builder 0.1.0-beta.0 → 0.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/README.md +43 -1402
- package/dist/actions/buildActionBlocks.js +19 -0
- package/dist/actions/buildActionBlocks.js.map +1 -0
- package/dist/actions/builtin/confirmation.js +49 -0
- package/dist/actions/builtin/confirmation.js.map +1 -0
- package/dist/actions/builtin/emailTeam.js +46 -0
- package/dist/actions/builtin/emailTeam.js.map +1 -0
- package/dist/actions/builtin/index.d.ts +7 -0
- package/dist/actions/builtin/index.js +13 -0
- package/dist/actions/builtin/index.js.map +1 -0
- package/dist/actions/builtin/signedWebhook.js +49 -0
- package/dist/actions/builtin/signedWebhook.js.map +1 -0
- package/dist/actions/defineAction.d.ts +36 -0
- package/dist/actions/defineAction.js +6 -0
- package/dist/actions/defineAction.js.map +1 -0
- package/dist/actions/dispatch.js +48 -0
- package/dist/actions/dispatch.js.map +1 -0
- package/dist/actions/registry.d.ts +16 -0
- package/dist/actions/registry.js +16 -0
- package/dist/actions/registry.js.map +1 -0
- package/dist/actions/runActions.d.ts +9 -0
- package/dist/actions/runActions.js +34 -0
- package/dist/actions/runActions.js.map +1 -0
- package/dist/actions/sign.d.ts +7 -0
- package/dist/actions/sign.js +9 -0
- package/dist/actions/sign.js.map +1 -0
- package/dist/actions/task.js +82 -0
- package/dist/actions/task.js.map +1 -0
- package/dist/aggregation/aggregateResponses.d.ts +31 -0
- package/dist/aggregation/aggregateResponses.js +86 -0
- package/dist/aggregation/aggregateResponses.js.map +1 -0
- package/dist/aggregation/aggregateRows.d.ts +10 -0
- package/dist/aggregation/aggregateRows.js +64 -0
- package/dist/aggregation/aggregateRows.js.map +1 -0
- package/dist/aggregation/resolveResultsRequest.d.ts +32 -0
- package/dist/aggregation/resolveResultsRequest.js +56 -0
- package/dist/aggregation/resolveResultsRequest.js.map +1 -0
- package/dist/aggregation/types.d.ts +39 -0
- package/dist/calc/computeCalcFields.d.ts +15 -0
- package/dist/calc/computeCalcFields.js +27 -0
- package/dist/calc/computeCalcFields.js.map +1 -0
- package/dist/calc/evaluate.d.ts +8 -0
- package/dist/calc/evaluate.js +59 -0
- package/dist/calc/evaluate.js.map +1 -0
- package/dist/calc/normalizeCalc.d.ts +8 -0
- package/dist/calc/normalizeCalc.js +100 -0
- package/dist/calc/normalizeCalc.js.map +1 -0
- package/dist/calc/types.d.ts +28 -0
- package/dist/client/ConditionBuilder.js +121 -0
- package/dist/client/ConditionBuilder.js.map +1 -0
- package/dist/client/ConditionRow.js +150 -0
- package/dist/client/ConditionRow.js.map +1 -0
- package/dist/client/FlowBuilder.d.ts +15 -0
- package/dist/client/FlowBuilder.js +408 -0
- package/dist/client/FlowBuilder.js.map +1 -0
- package/dist/client/FormConditionField.d.ts +21 -0
- package/dist/client/FormConditionField.js +33 -0
- package/dist/client/FormConditionField.js.map +1 -0
- package/dist/client/synthesizeClientField.js +46 -0
- package/dist/client/synthesizeClientField.js.map +1 -0
- package/dist/collections/formSubmissions.js +138 -0
- package/dist/collections/formSubmissions.js.map +1 -0
- package/dist/collections/forms.js +187 -0
- package/dist/collections/forms.js.map +1 -0
- package/dist/collections/uploads.d.ts +29 -0
- package/dist/collections/uploads.js +46 -0
- package/dist/collections/uploads.js.map +1 -0
- package/dist/conditions/conditionType.js +14 -0
- package/dist/conditions/conditionType.js.map +1 -0
- package/dist/conditions/evaluate.d.ts +17 -0
- package/dist/conditions/evaluate.js +94 -0
- package/dist/conditions/evaluate.js.map +1 -0
- package/dist/conditions/fieldTypes.d.ts +9 -0
- package/dist/{fieldTypes-CzhhJXjg.js → conditions/fieldTypes.js} +2 -2
- package/dist/conditions/fieldTypes.js.map +1 -0
- package/dist/conditions/normalizeConditions.js +47 -0
- package/dist/conditions/normalizeConditions.js.map +1 -0
- package/dist/conditions/types.d.ts +8 -0
- package/dist/consent/buildConsentSourceConfig.js +49 -0
- package/dist/consent/buildConsentSourceConfig.js.map +1 -0
- package/dist/consent/builtin/index.d.ts +7 -0
- package/dist/consent/builtin/index.js +11 -0
- package/dist/consent/builtin/index.js.map +1 -0
- package/dist/consent/builtin/pageReference.js +75 -0
- package/dist/consent/builtin/pageReference.js.map +1 -0
- package/dist/consent/builtin/static.js +44 -0
- package/dist/consent/builtin/static.js.map +1 -0
- package/dist/consent/captureConsent.d.ts +28 -0
- package/dist/consent/captureConsent.js +26 -0
- package/dist/consent/captureConsent.js.map +1 -0
- package/dist/consent/defineConsentSource.d.ts +35 -0
- package/dist/consent/defineConsentSource.js +6 -0
- package/dist/consent/defineConsentSource.js.map +1 -0
- package/dist/consent/registry.d.ts +16 -0
- package/dist/consent/registry.js +16 -0
- package/dist/consent/registry.js.map +1 -0
- package/dist/consent/resolveConsentLinks.d.ts +19 -0
- package/dist/consent/resolveConsentLinks.js +26 -0
- package/dist/consent/resolveConsentLinks.js.map +1 -0
- package/dist/consent/resolvePublishedVersionRef.d.ts +17 -0
- package/dist/consent/resolvePublishedVersionRef.js +27 -0
- package/dist/consent/resolvePublishedVersionRef.js.map +1 -0
- package/dist/events/noopSink.js +6 -0
- package/dist/events/noopSink.js.map +1 -0
- package/dist/events/resolveEventSink.js +8 -0
- package/dist/events/resolveEventSink.js.map +1 -0
- package/dist/events/types.d.ts +31 -0
- package/dist/exports/client.d.ts +3 -21
- package/dist/exports/client.js +3 -319
- package/dist/exports/i18n.d.ts +3 -138
- package/dist/exports/i18n.js +2 -2
- package/dist/exports/react.d.ts +45 -550
- package/dist/exports/react.js +39 -1563
- package/dist/exports/rsc.d.ts +2 -16
- package/dist/exports/rsc.js +1 -54
- package/dist/exports/types.d.ts +27 -5
- package/dist/fields/buildFieldBlocks.js +46 -0
- package/dist/fields/buildFieldBlocks.js.map +1 -0
- package/dist/fields/builtin/calculation.js +24 -0
- package/dist/fields/builtin/calculation.js.map +1 -0
- package/dist/fields/builtin/checkbox.js +13 -0
- package/dist/fields/builtin/checkbox.js.map +1 -0
- package/dist/fields/builtin/consent.js +32 -0
- package/dist/fields/builtin/consent.js.map +1 -0
- package/dist/fields/builtin/email.js +18 -0
- package/dist/fields/builtin/email.js.map +1 -0
- package/dist/fields/builtin/file.js +40 -0
- package/dist/fields/builtin/file.js.map +1 -0
- package/dist/fields/builtin/index.js +25 -0
- package/dist/fields/builtin/index.js.map +1 -0
- package/dist/fields/builtin/number.js +17 -0
- package/dist/fields/builtin/number.js.map +1 -0
- package/dist/fields/builtin/select.js +44 -0
- package/dist/fields/builtin/select.js.map +1 -0
- package/dist/fields/builtin/text.js +13 -0
- package/dist/fields/builtin/text.js.map +1 -0
- package/dist/fields/builtin/textarea.js +13 -0
- package/dist/fields/builtin/textarea.js.map +1 -0
- package/dist/fields/defineFormField.d.ts +13 -0
- package/dist/fields/defineFormField.js +12 -0
- package/dist/fields/defineFormField.js.map +1 -0
- package/dist/fields/registry.d.ts +10 -0
- package/dist/fields/registry.js +24 -0
- package/dist/fields/registry.js.map +1 -0
- package/dist/fields/sharedConfig.js +89 -0
- package/dist/fields/sharedConfig.js.map +1 -0
- package/dist/fields/types.d.ts +88 -0
- package/dist/flow/engine.d.ts +17 -0
- package/dist/flow/engine.js +24 -0
- package/dist/flow/engine.js.map +1 -0
- package/dist/flow/normalizeFlow.js +35 -0
- package/dist/flow/normalizeFlow.js.map +1 -0
- package/dist/{types-DsJ_6kGJ.d.ts → flow/types.d.ts} +2 -2
- package/dist/index.d.ts +74 -4
- package/dist/index.js +40 -1889
- package/dist/index.js.map +1 -1
- package/dist/plugin/access.js +12 -0
- package/dist/plugin/access.js.map +1 -0
- package/dist/plugin/registerCollections.js +40 -0
- package/dist/plugin/registerCollections.js.map +1 -0
- package/dist/plugin/registerTranslations.js +19 -0
- package/dist/plugin/registerTranslations.js.map +1 -0
- package/dist/prefill/valuesFromSearchParams.d.ts +18 -0
- package/dist/prefill/valuesFromSearchParams.js +45 -0
- package/dist/prefill/valuesFromSearchParams.js.map +1 -0
- package/dist/presentations/defaults.d.ts +33 -0
- package/dist/presentations/defaults.js +35 -0
- package/dist/presentations/defaults.js.map +1 -0
- package/dist/presentations/registry.d.ts +15 -0
- package/dist/presentations/registry.js +15 -0
- package/dist/presentations/registry.js.map +1 -0
- package/dist/presentations/types.d.ts +18 -0
- package/dist/react/Form.d.ts +81 -0
- package/dist/react/Form.js +457 -0
- package/dist/react/Form.js.map +1 -0
- package/dist/react/FormContext.d.ts +16 -0
- package/dist/react/FormContext.js +14 -0
- package/dist/react/FormContext.js.map +1 -0
- package/dist/react/FormLayout.d.ts +20 -0
- package/dist/react/FormLayout.js +14 -0
- package/dist/react/FormLayout.js.map +1 -0
- package/dist/react/FormResults.d.ts +25 -0
- package/dist/react/FormResults.js +85 -0
- package/dist/react/FormResults.js.map +1 -0
- package/dist/react/Honeypot.d.ts +22 -0
- package/dist/react/Honeypot.js +38 -0
- package/dist/react/Honeypot.js.map +1 -0
- package/dist/react/Poll.d.ts +32 -0
- package/dist/react/Poll.js +77 -0
- package/dist/react/Poll.js.map +1 -0
- package/dist/react/cn.d.ts +6 -0
- package/dist/react/cn.js +8 -0
- package/dist/react/cn.js.map +1 -0
- package/dist/react/contract.d.ts +32 -0
- package/dist/react/contract.js +7 -0
- package/dist/react/contract.js.map +1 -0
- package/dist/react/events.js +14 -0
- package/dist/react/events.js.map +1 -0
- package/dist/react/fetchResults.d.ts +25 -0
- package/dist/react/fetchResults.js +31 -0
- package/dist/react/fetchResults.js.map +1 -0
- package/dist/react/presentation/Backdrop.d.ts +18 -0
- package/dist/react/presentation/Backdrop.js +14 -0
- package/dist/react/presentation/Backdrop.js.map +1 -0
- package/dist/react/presentation/DialogSurface.d.ts +35 -0
- package/dist/react/presentation/DialogSurface.js +56 -0
- package/dist/react/presentation/DialogSurface.js.map +1 -0
- package/dist/react/presentation/presentations.d.ts +7 -0
- package/dist/react/presentation/presentations.js +32 -0
- package/dist/react/presentation/presentations.js.map +1 -0
- package/dist/react/presentation/registry.d.ts +11 -0
- package/dist/react/presentation/registry.js +12 -0
- package/dist/react/presentation/registry.js.map +1 -0
- package/dist/react/presentation/types.d.ts +19 -0
- package/dist/react/presentation/useDismiss.d.ts +19 -0
- package/dist/react/presentation/useDismiss.js +33 -0
- package/dist/react/presentation/useDismiss.js.map +1 -0
- package/dist/react/presentation/useFocusTrap.d.ts +8 -0
- package/dist/react/presentation/useFocusTrap.js +35 -0
- package/dist/react/presentation/useFocusTrap.js.map +1 -0
- package/dist/react/presentation/useScrollLock.d.ts +6 -0
- package/dist/react/presentation/useScrollLock.js +18 -0
- package/dist/react/presentation/useScrollLock.js.map +1 -0
- package/dist/react/primitives/Checkbox.d.ts +26 -0
- package/dist/react/primitives/Checkbox.js +20 -0
- package/dist/react/primitives/Checkbox.js.map +1 -0
- package/dist/react/primitives/FieldShell.d.ts +31 -0
- package/dist/react/primitives/FieldShell.js +51 -0
- package/dist/react/primitives/FieldShell.js.map +1 -0
- package/dist/react/primitives/Input.d.ts +30 -0
- package/dist/react/primitives/Input.js +21 -0
- package/dist/react/primitives/Input.js.map +1 -0
- package/dist/react/primitives/Select.d.ts +34 -0
- package/dist/react/primitives/Select.js +26 -0
- package/dist/react/primitives/Select.js.map +1 -0
- package/dist/react/primitives/Textarea.d.ts +28 -0
- package/dist/react/primitives/Textarea.js +20 -0
- package/dist/react/primitives/Textarea.js.map +1 -0
- package/dist/react/recall.js +20 -0
- package/dist/react/recall.js.map +1 -0
- package/dist/react/registry.d.ts +16 -0
- package/dist/react/registry.js +16 -0
- package/dist/react/registry.js.map +1 -0
- package/dist/react/renderers/calculation.js +30 -0
- package/dist/react/renderers/calculation.js.map +1 -0
- package/dist/react/renderers/checkbox.js +35 -0
- package/dist/react/renderers/checkbox.js.map +1 -0
- package/dist/react/renderers/consent.js +54 -0
- package/dist/react/renderers/consent.js.map +1 -0
- package/dist/react/renderers/email.js +37 -0
- package/dist/react/renderers/email.js.map +1 -0
- package/dist/react/renderers/file.js +92 -0
- package/dist/react/renderers/file.js.map +1 -0
- package/dist/react/renderers/index.d.ts +8 -0
- package/dist/react/renderers/index.js +26 -0
- package/dist/react/renderers/index.js.map +1 -0
- package/dist/react/renderers/number.js +40 -0
- package/dist/react/renderers/number.js.map +1 -0
- package/dist/react/renderers/select.js +38 -0
- package/dist/react/renderers/select.js.map +1 -0
- package/dist/react/renderers/text.js +37 -0
- package/dist/react/renderers/text.js.map +1 -0
- package/dist/react/renderers/textarea.js +36 -0
- package/dist/react/renderers/textarea.js.map +1 -0
- package/dist/react/resolveForm.js +16 -0
- package/dist/react/resolveForm.js.map +1 -0
- package/dist/react/state.d.ts +15 -0
- package/dist/react/state.js +70 -0
- package/dist/react/state.js.map +1 -0
- package/dist/react/submitForm.d.ts +31 -0
- package/dist/react/submitForm.js +58 -0
- package/dist/react/submitForm.js.map +1 -0
- package/dist/react/toFormDocument.d.ts +24 -0
- package/dist/react/toFormDocument.js +22 -0
- package/dist/react/toFormDocument.js.map +1 -0
- package/dist/react/uploadFile.d.ts +23 -0
- package/dist/react/uploadFile.js +39 -0
- package/dist/react/uploadFile.js.map +1 -0
- package/dist/react/useField.d.ts +14 -0
- package/dist/react/useField.js +49 -0
- package/dist/react/useField.js.map +1 -0
- package/dist/react/useFormState.d.ts +8 -0
- package/dist/react/useFormState.js +9 -0
- package/dist/react/useFormState.js.map +1 -0
- package/dist/react/useFormStep.d.ts +8 -0
- package/dist/react/useFormStep.js +9 -0
- package/dist/react/useFormStep.js.map +1 -0
- package/dist/react/validateField.js +30 -0
- package/dist/react/validateField.js.map +1 -0
- package/dist/recall/interpolate.d.ts +10 -0
- package/dist/recall/interpolate.js +16 -0
- package/dist/recall/interpolate.js.map +1 -0
- package/dist/recall/resolver.d.ts +22 -0
- package/dist/recall/resolver.js +36 -0
- package/dist/recall/resolver.js.map +1 -0
- package/dist/spam/captcha.d.ts +12 -0
- package/dist/spam/captcha.js +11 -0
- package/dist/spam/captcha.js.map +1 -0
- package/dist/spam/constants.d.ts +12 -0
- package/dist/spam/constants.js +15 -0
- package/dist/spam/constants.js.map +1 -0
- package/dist/spam/identify.d.ts +12 -0
- package/dist/spam/identify.js +20 -0
- package/dist/spam/identify.js.map +1 -0
- package/dist/spam/rateLimiter.d.ts +18 -0
- package/dist/spam/rateLimiter.js +41 -0
- package/dist/spam/rateLimiter.js.map +1 -0
- package/dist/spam/reserved.js +30 -0
- package/dist/spam/reserved.js.map +1 -0
- package/dist/spam/resolveSpam.d.ts +12 -0
- package/dist/spam/resolveSpam.js +42 -0
- package/dist/spam/resolveSpam.js.map +1 -0
- package/dist/spam/spamGuard.js +68 -0
- package/dist/spam/spamGuard.js.map +1 -0
- package/dist/spam/types.d.ts +73 -0
- package/dist/spam/uploadHooks.js +33 -0
- package/dist/spam/uploadHooks.js.map +1 -0
- package/dist/submissions/SubmissionAnswers.d.ts +16 -0
- package/dist/submissions/SubmissionAnswers.js +57 -0
- package/dist/submissions/SubmissionAnswers.js.map +1 -0
- package/dist/submissions/runSubmission.js +205 -0
- package/dist/submissions/runSubmission.js.map +1 -0
- package/dist/submissions/types.d.ts +33 -0
- package/dist/submissions/validateSubmission.js +59 -0
- package/dist/submissions/validateSubmission.js.map +1 -0
- package/dist/translations/en.d.ts +12 -0
- package/dist/{translations-edMqq_2e.js → translations/en.js} +3 -22
- package/dist/translations/en.js.map +1 -0
- package/dist/translations/index.d.ts +14 -0
- package/dist/translations/index.js +26 -0
- package/dist/translations/index.js.map +1 -0
- package/dist/translations/keys.d.ts +132 -0
- package/dist/{keys-N5xGiUsh.js → translations/keys.js} +2 -2
- package/dist/translations/keys.js.map +1 -0
- package/dist/translations/makeTranslate.d.ts +11 -0
- package/dist/translations/makeTranslate.js +11 -0
- package/dist/translations/makeTranslate.js.map +1 -0
- package/dist/translations/server.js +25 -0
- package/dist/translations/server.js.map +1 -0
- package/dist/translations/useTranslation.js +12 -0
- package/dist/translations/useTranslation.js.map +1 -0
- package/dist/uploads/captureFileRef.d.ts +27 -0
- package/dist/uploads/captureFileRef.js +34 -0
- package/dist/uploads/captureFileRef.js.map +1 -0
- package/dist/uploads/resolveFileRef.d.ts +26 -0
- package/dist/uploads/resolveFileRef.js +44 -0
- package/dist/uploads/resolveFileRef.js.map +1 -0
- package/dist/uploads/types.d.ts +20 -0
- package/dist/validation/buildRuleBlocks.js +46 -0
- package/dist/validation/buildRuleBlocks.js.map +1 -0
- package/dist/validation/builtin/email.js +19 -0
- package/dist/validation/builtin/email.js.map +1 -0
- package/dist/validation/builtin/index.js +27 -0
- package/dist/validation/builtin/index.js.map +1 -0
- package/dist/validation/builtin/matchesField.js +20 -0
- package/dist/validation/builtin/matchesField.js.map +1 -0
- package/dist/validation/builtin/max.js +21 -0
- package/dist/validation/builtin/max.js.map +1 -0
- package/dist/validation/builtin/maxLength.js +26 -0
- package/dist/validation/builtin/maxLength.js.map +1 -0
- package/dist/validation/builtin/min.js +21 -0
- package/dist/validation/builtin/min.js.map +1 -0
- package/dist/validation/builtin/minLength.js +26 -0
- package/dist/validation/builtin/minLength.js.map +1 -0
- package/dist/validation/builtin/notAlreadySubmitted.js +32 -0
- package/dist/validation/builtin/notAlreadySubmitted.js.map +1 -0
- package/dist/validation/builtin/oneOf.js +34 -0
- package/dist/validation/builtin/oneOf.js.map +1 -0
- package/dist/validation/builtin/pattern.js +41 -0
- package/dist/validation/builtin/pattern.js.map +1 -0
- package/dist/validation/builtin/url.js +22 -0
- package/dist/validation/builtin/url.js.map +1 -0
- package/dist/validation/defineValidationRule.d.ts +12 -0
- package/dist/validation/defineValidationRule.js +11 -0
- package/dist/validation/defineValidationRule.js.map +1 -0
- package/dist/validation/message.js +14 -0
- package/dist/validation/message.js.map +1 -0
- package/dist/validation/registry.d.ts +10 -0
- package/dist/validation/registry.js +20 -0
- package/dist/validation/registry.js.map +1 -0
- package/dist/validation/runValidation.js +111 -0
- package/dist/validation/runValidation.js.map +1 -0
- package/dist/validation/types.d.ts +76 -0
- package/package.json +8 -5
- package/styles/form-builder.css +201 -11
- package/dist/constants-B-BUfetP.d.ts +0 -288
- package/dist/exports/client.js.map +0 -1
- package/dist/exports/react.js.map +0 -1
- package/dist/exports/rsc.js.map +0 -1
- package/dist/fieldTypes-B8jkNRob.d.ts +0 -188
- package/dist/fieldTypes-CzhhJXjg.js.map +0 -1
- package/dist/index-DfFYGbVF.d.ts +0 -481
- package/dist/keys-N5xGiUsh.js.map +0 -1
- package/dist/registry-CoCyhtvB.js +0 -282
- package/dist/registry-CoCyhtvB.js.map +0 -1
- package/dist/resolver-OeQyVH2N.js +0 -665
- package/dist/resolver-OeQyVH2N.js.map +0 -1
- package/dist/translations-edMqq_2e.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,1891 +1,42 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const resolve = (name) => {
|
|
41
|
-
const found = values.find((v) => v.field === name);
|
|
42
|
-
return found == null ? "" : String(found.value ?? "");
|
|
43
|
-
};
|
|
44
|
-
const subject = interpolate(config.subject ?? "", resolve);
|
|
45
|
-
const html = interpolate(config.body ?? "", resolve);
|
|
46
|
-
await payload.sendEmail({
|
|
47
|
-
to,
|
|
48
|
-
subject,
|
|
49
|
-
html
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
|
-
});
|
|
53
|
-
//#endregion
|
|
54
|
-
//#region src/actions/builtin/emailTeam.ts
|
|
55
|
-
const emailTeam = defineAction({
|
|
56
|
-
type: "emailTeam",
|
|
57
|
-
label: keys.actionEmailTeam,
|
|
58
|
-
config: [
|
|
59
|
-
{
|
|
60
|
-
name: "to",
|
|
61
|
-
type: "text",
|
|
62
|
-
label: labelFor(keys.actionConfigTo)
|
|
63
|
-
},
|
|
64
|
-
{
|
|
65
|
-
name: "subject",
|
|
66
|
-
type: "text",
|
|
67
|
-
label: labelFor(keys.actionConfigSubject)
|
|
68
|
-
},
|
|
69
|
-
{
|
|
70
|
-
name: "body",
|
|
71
|
-
type: "textarea",
|
|
72
|
-
label: labelFor(keys.actionConfigBody)
|
|
73
|
-
}
|
|
74
|
-
],
|
|
75
|
-
run: async (args) => {
|
|
76
|
-
const { config, values, payload } = args;
|
|
77
|
-
if (!config.to) throw new Error("emailTeam: missing \"to\" address");
|
|
78
|
-
if (typeof payload.sendEmail !== "function") throw new Error("emailTeam: no email adapter configured");
|
|
79
|
-
const resolve = (name) => {
|
|
80
|
-
const entry = values.find((v) => v.field === name);
|
|
81
|
-
return entry == null ? "" : String(entry.value ?? "");
|
|
82
|
-
};
|
|
83
|
-
const subject = interpolate(config.subject ?? "", resolve);
|
|
84
|
-
const html = interpolate(config.body ?? "", resolve);
|
|
85
|
-
await payload.sendEmail({
|
|
86
|
-
to: config.to,
|
|
87
|
-
subject,
|
|
88
|
-
html
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
});
|
|
92
|
-
//#endregion
|
|
93
|
-
//#region src/actions/sign.ts
|
|
94
|
-
const SIGNATURE_HEADER = "X-Form-Signature";
|
|
95
|
-
/** HMAC-SHA256 over `body`, hex-encoded, wrapped in the versioned header value (`v1=<hex>`). */
|
|
96
|
-
const signPayload = (body, secret) => `v1=${createHmac("sha256", secret).update(body).digest("hex")}`;
|
|
97
|
-
//#endregion
|
|
98
|
-
//#region src/actions/builtin/index.ts
|
|
99
|
-
const defaultActionDefinitions = {
|
|
100
|
-
emailTeam,
|
|
101
|
-
confirmation,
|
|
102
|
-
signedWebhook: defineAction({
|
|
103
|
-
type: "signedWebhook",
|
|
104
|
-
label: keys.actionSignedWebhook,
|
|
105
|
-
config: [{
|
|
106
|
-
name: "url",
|
|
107
|
-
type: "text",
|
|
108
|
-
label: labelFor(keys.actionConfigUrl)
|
|
109
|
-
}, {
|
|
110
|
-
name: "secret",
|
|
111
|
-
type: "text",
|
|
112
|
-
label: labelFor(keys.actionConfigSecret)
|
|
113
|
-
}],
|
|
114
|
-
run: async (args) => {
|
|
115
|
-
const { config, form, submissionId, values } = args;
|
|
116
|
-
if (!config.url) throw new Error("signedWebhook: missing \"url\"");
|
|
117
|
-
if (!config.secret) throw new Error("signedWebhook: missing \"secret\"");
|
|
118
|
-
const body = JSON.stringify({
|
|
119
|
-
formId: form.id,
|
|
120
|
-
submissionId,
|
|
121
|
-
values
|
|
122
|
-
});
|
|
123
|
-
const controller = new AbortController();
|
|
124
|
-
const timer = setTimeout(() => controller.abort(), 1e4);
|
|
125
|
-
let response;
|
|
126
|
-
try {
|
|
127
|
-
response = await fetch(config.url, {
|
|
128
|
-
method: "POST",
|
|
129
|
-
headers: {
|
|
130
|
-
"Content-Type": "application/json",
|
|
131
|
-
[SIGNATURE_HEADER]: signPayload(body, config.secret)
|
|
132
|
-
},
|
|
133
|
-
body,
|
|
134
|
-
signal: controller.signal
|
|
135
|
-
});
|
|
136
|
-
} finally {
|
|
137
|
-
clearTimeout(timer);
|
|
138
|
-
}
|
|
139
|
-
if (!response.ok) throw new Error(`signedWebhook: server responded ${response.status}`);
|
|
140
|
-
}
|
|
141
|
-
})
|
|
142
|
-
};
|
|
143
|
-
//#endregion
|
|
144
|
-
//#region src/actions/registry.ts
|
|
145
|
-
/**
|
|
146
|
-
* Resolve the active action registry from built-in defaults and a consumer override map. `false`
|
|
147
|
-
* removes a type, `true` keeps the default (no-op when none exists), a definition adds or replaces.
|
|
148
|
-
* Mirrors the field-type and validation-rule registry convention.
|
|
149
|
-
*/
|
|
150
|
-
const resolveActions = (defaults, config = {}) => {
|
|
151
|
-
const registry = new Map(Object.entries(defaults));
|
|
152
|
-
for (const [type, option] of Object.entries(config)) if (option === false) registry.delete(type);
|
|
153
|
-
else if (option === true) {} else registry.set(type, option);
|
|
154
|
-
return registry;
|
|
155
|
-
};
|
|
156
|
-
//#endregion
|
|
157
|
-
//#region src/collections/uploads.ts
|
|
158
|
-
const FORM_UPLOADS_SLUG = "form-uploads";
|
|
159
|
-
/**
|
|
160
|
-
* The built-in upload collection backing the `file` field. Anonymous create (public forms upload here),
|
|
161
|
-
* authed read/update/delete (only admins read stored files; submitters cannot enumerate others' uploads).
|
|
162
|
-
* Per-field MIME/size is re-enforced at submit, so the collection stays permissive by default. No
|
|
163
|
-
* `imageSizes`, so it needs no `sharp`. Projects with their own upload collection set `uploads: false` and
|
|
164
|
-
* point the file field's `relationTo` at theirs.
|
|
165
|
-
*/
|
|
166
|
-
const buildUploadsCollection = (config = {}) => ({
|
|
167
|
-
slug: config.slug ?? "form-uploads",
|
|
168
|
-
access: config.access ?? {
|
|
169
|
-
create: () => true,
|
|
170
|
-
read: ({ req }) => Boolean(req.user),
|
|
171
|
-
update: ({ req }) => Boolean(req.user),
|
|
172
|
-
delete: ({ req }) => Boolean(req.user)
|
|
173
|
-
},
|
|
174
|
-
admin: { group: "Forms" },
|
|
175
|
-
upload: config.upload && config.upload !== true ? config.upload : true,
|
|
176
|
-
fields: [{
|
|
177
|
-
name: "owner",
|
|
178
|
-
type: "text",
|
|
179
|
-
admin: {
|
|
180
|
-
readOnly: true,
|
|
181
|
-
hidden: true
|
|
182
|
-
}
|
|
183
|
-
}, ...config.fields ?? []]
|
|
184
|
-
});
|
|
185
|
-
/** Resolve the `uploads` plugin option: `false` disables, `true`/object enables the built-in collection. */
|
|
186
|
-
const resolveUploads = (option) => {
|
|
187
|
-
if (option === false) return {
|
|
188
|
-
enabled: false,
|
|
189
|
-
slug: FORM_UPLOADS_SLUG
|
|
190
|
-
};
|
|
191
|
-
const collection = buildUploadsCollection(option && option !== true ? option : {});
|
|
192
|
-
return {
|
|
193
|
-
enabled: true,
|
|
194
|
-
slug: collection.slug,
|
|
195
|
-
collection
|
|
196
|
-
};
|
|
197
|
-
};
|
|
198
|
-
//#endregion
|
|
199
|
-
//#region src/consent/defineConsentSource.ts
|
|
200
|
-
const defineConsentSource = (source) => source;
|
|
201
|
-
//#endregion
|
|
202
|
-
//#region src/consent/resolvePublishedVersionRef.ts
|
|
203
|
-
/**
|
|
204
|
-
* Returns the latest published version of a doc, or null when versions/drafts are off or nothing
|
|
205
|
-
* is published. Never throws.
|
|
206
|
-
*/
|
|
207
|
-
const resolvePublishedVersionRef = async (payload, args) => {
|
|
208
|
-
try {
|
|
209
|
-
const doc = (await payload.findVersions({
|
|
210
|
-
collection: args.collection,
|
|
211
|
-
where: { and: [{ parent: { equals: args.id } }, { "version._status": { equals: "published" } }] },
|
|
212
|
-
sort: "-updatedAt",
|
|
213
|
-
limit: 1,
|
|
214
|
-
depth: 0
|
|
215
|
-
}))?.docs?.[0];
|
|
216
|
-
if (!doc) return null;
|
|
217
|
-
return {
|
|
218
|
-
versionId: String(doc.id),
|
|
219
|
-
updatedAt: String(doc.updatedAt)
|
|
220
|
-
};
|
|
221
|
-
} catch {
|
|
222
|
-
return null;
|
|
223
|
-
}
|
|
224
|
-
};
|
|
225
|
-
//#endregion
|
|
226
|
-
//#region src/consent/builtin/pageReference.ts
|
|
227
|
-
const pageReferenceSource = defineConsentSource({
|
|
228
|
-
type: "pageReference",
|
|
229
|
-
label: keys.consentSourcePageReference,
|
|
230
|
-
config: [
|
|
231
|
-
{
|
|
232
|
-
name: "relationTo",
|
|
233
|
-
type: "text",
|
|
234
|
-
label: labelFor(keys.consentConfigRelationTo)
|
|
235
|
-
},
|
|
236
|
-
{
|
|
237
|
-
name: "docId",
|
|
238
|
-
type: "text",
|
|
239
|
-
label: labelFor(keys.consentConfigDocId)
|
|
240
|
-
},
|
|
241
|
-
{
|
|
242
|
-
name: "urlField",
|
|
243
|
-
type: "text",
|
|
244
|
-
label: labelFor(keys.consentConfigUrlField),
|
|
245
|
-
defaultValue: "slug"
|
|
246
|
-
},
|
|
247
|
-
{
|
|
248
|
-
name: "captureVersion",
|
|
249
|
-
type: "checkbox",
|
|
250
|
-
label: labelFor(keys.consentConfigCaptureVersion)
|
|
251
|
-
}
|
|
252
|
-
],
|
|
253
|
-
resolve: async ({ config, payload, req, locale }) => {
|
|
254
|
-
const relationTo = String(config.relationTo ?? "");
|
|
255
|
-
const docId = String(config.docId ?? "");
|
|
256
|
-
const urlField = String(config.urlField ?? "slug");
|
|
257
|
-
const captureVersion = Boolean(config.captureVersion);
|
|
258
|
-
if (!relationTo || !docId) return { links: [] };
|
|
259
|
-
const doc = await payload.findByID({
|
|
260
|
-
collection: relationTo,
|
|
261
|
-
id: docId,
|
|
262
|
-
depth: 0,
|
|
263
|
-
locale,
|
|
264
|
-
req
|
|
265
|
-
}).catch(() => null);
|
|
266
|
-
if (!doc) return { links: [{
|
|
267
|
-
label: relationTo,
|
|
268
|
-
url: ""
|
|
269
|
-
}] };
|
|
270
|
-
const docRecord = doc;
|
|
271
|
-
const label = String(docRecord.title ?? docRecord[urlField] ?? relationTo);
|
|
272
|
-
const url = String(docRecord[urlField] ?? "");
|
|
273
|
-
if (!captureVersion) return { links: [{
|
|
274
|
-
label,
|
|
275
|
-
url
|
|
276
|
-
}] };
|
|
277
|
-
const v = await resolvePublishedVersionRef(payload, {
|
|
278
|
-
collection: relationTo,
|
|
279
|
-
id: docId
|
|
280
|
-
});
|
|
281
|
-
return {
|
|
282
|
-
links: [{
|
|
283
|
-
label,
|
|
284
|
-
url
|
|
285
|
-
}],
|
|
286
|
-
...v ? {
|
|
287
|
-
versionRef: v.versionId,
|
|
288
|
-
versionLabel: v.updatedAt
|
|
289
|
-
} : {}
|
|
290
|
-
};
|
|
291
|
-
}
|
|
292
|
-
});
|
|
293
|
-
//#endregion
|
|
294
|
-
//#region src/consent/builtin/index.ts
|
|
295
|
-
const defaultConsentSources = {
|
|
296
|
-
static: defineConsentSource({
|
|
297
|
-
type: "static",
|
|
298
|
-
label: keys.consentSourceStatic,
|
|
299
|
-
config: [
|
|
300
|
-
{
|
|
301
|
-
name: "label",
|
|
302
|
-
type: "text",
|
|
303
|
-
label: labelFor(keys.consentConfigLabel)
|
|
304
|
-
},
|
|
305
|
-
{
|
|
306
|
-
name: "url",
|
|
307
|
-
type: "text",
|
|
308
|
-
label: labelFor(keys.consentConfigUrl)
|
|
309
|
-
},
|
|
310
|
-
{
|
|
311
|
-
name: "version",
|
|
312
|
-
type: "text",
|
|
313
|
-
label: labelFor(keys.consentConfigVersion)
|
|
314
|
-
}
|
|
315
|
-
],
|
|
316
|
-
resolve({ config }) {
|
|
317
|
-
const label = String(config.label ?? "");
|
|
318
|
-
const url = String(config.url ?? "");
|
|
319
|
-
const version = config.version ? String(config.version) : void 0;
|
|
320
|
-
return {
|
|
321
|
-
links: [{
|
|
322
|
-
label,
|
|
323
|
-
url
|
|
324
|
-
}],
|
|
325
|
-
...version ? {
|
|
326
|
-
versionRef: version,
|
|
327
|
-
versionLabel: version
|
|
328
|
-
} : {}
|
|
329
|
-
};
|
|
330
|
-
}
|
|
331
|
-
}),
|
|
332
|
-
pageReference: pageReferenceSource
|
|
333
|
-
};
|
|
334
|
-
//#endregion
|
|
335
|
-
//#region src/consent/registry.ts
|
|
336
|
-
/**
|
|
337
|
-
* Resolve the active consent-source registry from built-in defaults and a consumer override map.
|
|
338
|
-
* `false` removes a type, `true` keeps the default (no-op when none exists), a definition adds or
|
|
339
|
-
* replaces. Mirrors the action and field-type registry convention.
|
|
340
|
-
*/
|
|
341
|
-
const resolveConsentSources = (defaults, config = {}) => {
|
|
342
|
-
const registry = new Map(Object.entries(defaults));
|
|
343
|
-
for (const [type, option] of Object.entries(config)) if (option === false) registry.delete(type);
|
|
344
|
-
else if (option === true) {} else registry.set(type, option);
|
|
345
|
-
return registry;
|
|
346
|
-
};
|
|
347
|
-
const canQueue = (payload) => typeof payload.jobs?.queue === "function";
|
|
348
|
-
const deadline = (ms) => new Promise((resolve) => {
|
|
349
|
-
setTimeout(resolve, ms).unref?.();
|
|
350
|
-
});
|
|
351
|
-
/**
|
|
352
|
-
* Dispatch a submission's post-submit actions without throwing and without blocking the response on slow
|
|
353
|
-
* action work. With no actions, returns immediately. When a job runner is present, enqueues the native
|
|
354
|
-
* `form-builder-actions` task and returns (action work happens out of band). Otherwise runs the actions
|
|
355
|
-
* inline but bounded by a deadline so a missing worker still delivers without hanging the request; any
|
|
356
|
-
* error is swallowed (logged via `payload.logger`). Never rejects.
|
|
357
|
-
*/
|
|
358
|
-
const dispatchActions = async (args) => {
|
|
359
|
-
const { payload, registry, req, formId, submissionId } = args;
|
|
360
|
-
if ((args.actions ?? []).length === 0) return;
|
|
361
|
-
if (args.hasRunner && canQueue(payload)) {
|
|
362
|
-
try {
|
|
363
|
-
const input = {
|
|
364
|
-
formId,
|
|
365
|
-
submissionId
|
|
366
|
-
};
|
|
367
|
-
await payload.jobs.queue({
|
|
368
|
-
task: ACTIONS_TASK_SLUG,
|
|
369
|
-
input,
|
|
370
|
-
req
|
|
371
|
-
});
|
|
372
|
-
} catch (error) {
|
|
373
|
-
payload.logger?.error(`@10x-media/form-builder: failed to enqueue actions for submission ${String(submissionId)}: ${error instanceof Error ? error.message : String(error)}`);
|
|
374
|
-
}
|
|
375
|
-
return;
|
|
376
|
-
}
|
|
377
|
-
const ms = args.deadlineMs ?? 5e3;
|
|
378
|
-
const work = runActionsForSubmission({
|
|
379
|
-
input: {
|
|
380
|
-
formId,
|
|
381
|
-
submissionId
|
|
382
|
-
},
|
|
383
|
-
registry,
|
|
384
|
-
payload,
|
|
385
|
-
req
|
|
386
|
-
}).catch((error) => {
|
|
387
|
-
payload.logger?.error(`@10x-media/form-builder: inline action dispatch for submission ${String(submissionId)} threw: ${error instanceof Error ? error.message : String(error)}`);
|
|
388
|
-
});
|
|
389
|
-
await Promise.race([work, deadline(ms)]);
|
|
390
|
-
};
|
|
391
|
-
//#endregion
|
|
392
|
-
//#region src/events/resolveEventSink.ts
|
|
393
|
-
/** Resolves the configured event sink, falling back to the no-op sink. Consumed by the submission pipeline in a later phase. */
|
|
394
|
-
const resolveEventSink = (sink) => sink ?? noopEventSink;
|
|
395
|
-
//#endregion
|
|
396
|
-
//#region src/spam/reserved.ts
|
|
397
|
-
/**
|
|
398
|
-
* Split reserved entries (honeypot decoy, captcha token) out of the submitted `values`. The honeypot
|
|
399
|
-
* rides a configurable, innocuous field name; the captcha token rides a fixed reserved key. Real field
|
|
400
|
-
* values pass through unchanged. `runSubmission` would already ignore unknown field names, but stripping
|
|
401
|
-
* here keeps them out of storage and out of the validation pass.
|
|
402
|
-
*/
|
|
403
|
-
const extractReservedValues = (values, honeypotField) => {
|
|
404
|
-
const cleaned = [];
|
|
405
|
-
const result = { cleaned };
|
|
406
|
-
for (const entry of values) {
|
|
407
|
-
if (entry.field === "__fb_captcha") {
|
|
408
|
-
if (typeof entry.value === "string") result.captchaToken = entry.value;
|
|
409
|
-
continue;
|
|
410
|
-
}
|
|
411
|
-
if (honeypotField !== null && entry.field === honeypotField) {
|
|
412
|
-
result.honeypot = entry.value;
|
|
413
|
-
continue;
|
|
414
|
-
}
|
|
415
|
-
cleaned.push(entry);
|
|
416
|
-
}
|
|
417
|
-
return result;
|
|
418
|
-
};
|
|
419
|
-
/** A honeypot is tripped when the decoy carries any non-empty value (a real user never fills it). */
|
|
420
|
-
const isHoneypotTripped = (value) => value != null && value !== "" && !(Array.isArray(value) && value.length === 0);
|
|
421
|
-
//#endregion
|
|
422
|
-
//#region src/spam/spamGuard.ts
|
|
423
|
-
const firstHop = (req, header) => {
|
|
424
|
-
const raw = req.headers?.get(header);
|
|
425
|
-
return raw ? raw.split(",")[0]?.trim() ?? void 0 : void 0;
|
|
426
|
-
};
|
|
427
|
-
/**
|
|
428
|
-
* Submissions spam guard, prepended before `validateSubmission` in `beforeValidate` so it rejects before
|
|
429
|
-
* the form load + field validation. On create it: resolves the request identity once (stashing it on
|
|
430
|
-
* `req.context` for upload-ownership verification downstream); rate-limits per form + identity (429,
|
|
431
|
-
* fail-open when identity is null); strips reserved `values` entries (honeypot, captcha token); rejects a
|
|
432
|
-
* filled honeypot with a generic error; verifies a configured captcha; and writes a server-authoritative
|
|
433
|
-
* `meta` (timestamp + spam signal, with opt-in ip/ua). App-level rate limiting is defense-in-depth that
|
|
434
|
-
* complements edge/CDN/WAF limiting, not a DoS replacement.
|
|
435
|
-
*/
|
|
436
|
-
const buildSpamGuard = (spam) => async ({ data, operation, req }) => {
|
|
437
|
-
if (operation !== "create" || !data) return data;
|
|
438
|
-
const t = asTranslate(req.i18n.t);
|
|
439
|
-
const authenticated = Boolean(req.user);
|
|
440
|
-
const identity = await spam.identify(req);
|
|
441
|
-
if (identity != null) req.context[IDENTITY_CONTEXT_KEY] = identity;
|
|
442
|
-
if (spam.rateLimit !== false && identity != null) {
|
|
443
|
-
const formKey = data.form != null ? String(data.form) : "unknown";
|
|
444
|
-
const { ok } = await spam.rateLimit.limiter.check({
|
|
445
|
-
key: `submissions:${formKey}:${identity}`,
|
|
446
|
-
max: spam.rateLimit.max,
|
|
447
|
-
window: spam.rateLimit.window,
|
|
448
|
-
req
|
|
449
|
-
});
|
|
450
|
-
if (!ok) throw new APIError(t(keys.spamRateLimited), 429);
|
|
451
|
-
}
|
|
452
|
-
const honeypotField = spam.honeypot === false ? null : spam.honeypot.fieldName;
|
|
453
|
-
const { cleaned, honeypot, captchaToken } = extractReservedValues(data.values ?? [], honeypotField);
|
|
454
|
-
data.values = cleaned;
|
|
455
|
-
if (!authenticated && honeypotField !== null && isHoneypotTripped(honeypot)) throw new APIError(t(keys.spamRejected), 400);
|
|
456
|
-
const captchaChecked = Boolean(spam.captcha) && !authenticated;
|
|
457
|
-
if (spam.captcha && !authenticated) {
|
|
458
|
-
if (!(typeof captchaToken === "string" && captchaToken.length > 0 ? await spam.captcha.verify({
|
|
459
|
-
token: captchaToken,
|
|
460
|
-
req
|
|
461
|
-
}).catch(() => false) : false)) throw new APIError(t(keys.spamCaptchaFailed), 400);
|
|
462
|
-
}
|
|
463
|
-
const serverMeta = {
|
|
464
|
-
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
465
|
-
spam: { captcha: captchaChecked ? "passed" : "skipped" }
|
|
466
|
-
};
|
|
467
|
-
if (spam.metadata.ip) {
|
|
468
|
-
const ip = firstHop(req, spam.ipHeader);
|
|
469
|
-
if (ip) serverMeta.ip = ip;
|
|
470
|
-
}
|
|
471
|
-
if (spam.metadata.ua) {
|
|
472
|
-
const ua = req.headers?.get("user-agent");
|
|
473
|
-
if (ua) serverMeta.ua = ua;
|
|
474
|
-
}
|
|
475
|
-
data.meta = {
|
|
476
|
-
...authenticated && data.meta != null && typeof data.meta === "object" ? data.meta : {},
|
|
477
|
-
...serverMeta
|
|
478
|
-
};
|
|
479
|
-
return data;
|
|
480
|
-
};
|
|
481
|
-
//#endregion
|
|
482
|
-
//#region src/actions/buildActionBlocks.ts
|
|
483
|
-
/** One authoring block per registered action type: the action's own config fields. */
|
|
484
|
-
const buildActionBlocks = (registry) => {
|
|
485
|
-
const blocks = [];
|
|
486
|
-
for (const definition of registry.values()) blocks.push({
|
|
487
|
-
slug: definition.type,
|
|
488
|
-
labels: {
|
|
489
|
-
singular: labelFor(definition.label),
|
|
490
|
-
plural: labelFor(definition.label)
|
|
491
|
-
},
|
|
492
|
-
fields: definition.config ?? []
|
|
493
|
-
});
|
|
494
|
-
return blocks;
|
|
495
|
-
};
|
|
496
|
-
//#endregion
|
|
497
|
-
//#region src/aggregation/aggregateRows.ts
|
|
498
|
-
const round1 = (n) => Math.round(n * 10) / 10;
|
|
499
|
-
/** The grouping keys a single answer contributes: [] for empty, one per element for arrays, else one. */
|
|
500
|
-
const valueKeys = (value) => {
|
|
501
|
-
if (value == null || value === "") return [];
|
|
502
|
-
if (Array.isArray(value)) return value.filter((entry) => entry != null && entry !== "").map((entry) => String(entry));
|
|
503
|
-
return [String(value)];
|
|
504
|
-
};
|
|
505
|
-
/** Merge every row's descriptor snapshot for one field into a value -> label map (last write wins). */
|
|
506
|
-
const snapshotLabels = (rows, field) => {
|
|
507
|
-
const labels = /* @__PURE__ */ new Map();
|
|
508
|
-
for (const row of rows) for (const descriptor of row.descriptors ?? []) if (descriptor.field === field && descriptor.optionLabels) for (const [value, label] of Object.entries(descriptor.optionLabels)) labels.set(value, label);
|
|
509
|
-
return labels;
|
|
510
|
-
};
|
|
511
|
-
/** Aggregate one field across rows: respondent-denominated counts + percentages, option-ordered. */
|
|
512
|
-
const aggregateRowForField = (rows, meta, truncated) => {
|
|
513
|
-
const counts = /* @__PURE__ */ new Map();
|
|
514
|
-
const order = [];
|
|
515
|
-
for (const option of meta.options ?? []) {
|
|
516
|
-
counts.set(option.value, 0);
|
|
517
|
-
order.push(option.value);
|
|
518
|
-
}
|
|
519
|
-
const snapshots = snapshotLabels(rows, meta.field);
|
|
520
|
-
let total = 0;
|
|
521
|
-
for (const row of rows) {
|
|
522
|
-
const keys = valueKeys((row.values ?? []).find((entry) => entry.field === meta.field)?.value);
|
|
523
|
-
if (keys.length === 0) continue;
|
|
524
|
-
total += 1;
|
|
525
|
-
for (const key of keys) {
|
|
526
|
-
if (!counts.has(key)) {
|
|
527
|
-
counts.set(key, 0);
|
|
528
|
-
order.push(key);
|
|
529
|
-
}
|
|
530
|
-
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
const optionValues = new Set((meta.options ?? []).map((option) => option.value));
|
|
534
|
-
const leftovers = order.filter((value) => !optionValues.has(value)).sort((a, b) => (counts.get(b) ?? 0) - (counts.get(a) ?? 0));
|
|
535
|
-
const ordered = [...(meta.options ?? []).map((option) => option.value), ...leftovers];
|
|
536
|
-
const optionLabelByValue = new Map((meta.options ?? []).map((option) => [option.value, option.label]));
|
|
537
|
-
const buckets = ordered.map((value) => {
|
|
538
|
-
const count = counts.get(value) ?? 0;
|
|
539
|
-
return {
|
|
540
|
-
value,
|
|
541
|
-
label: optionLabelByValue.get(value) ?? snapshots.get(value) ?? value,
|
|
542
|
-
count,
|
|
543
|
-
percentage: total > 0 ? round1(count / total * 100) : 0
|
|
544
|
-
};
|
|
545
|
-
});
|
|
546
|
-
return {
|
|
547
|
-
field: meta.field,
|
|
548
|
-
label: meta.label,
|
|
549
|
-
fieldType: meta.fieldType,
|
|
550
|
-
total,
|
|
551
|
-
buckets,
|
|
552
|
-
truncated
|
|
553
|
-
};
|
|
554
|
-
};
|
|
555
|
-
/** Aggregate several fields across the same set of rows. */
|
|
556
|
-
const aggregateRowsForFields = (rows, metas, truncated) => metas.map((meta) => aggregateRowForField(rows, meta, truncated));
|
|
557
|
-
//#endregion
|
|
558
|
-
//#region src/aggregation/aggregateResponses.ts
|
|
559
|
-
const optionsOf = (field) => {
|
|
560
|
-
if (!Array.isArray(field.options)) return;
|
|
561
|
-
const options = field.options.filter((option) => typeof option?.value === "string").map((option) => ({
|
|
562
|
-
value: String(option.value),
|
|
563
|
-
label: option.label ?? String(option.value)
|
|
564
|
-
}));
|
|
565
|
-
return options.length > 0 ? options : void 0;
|
|
566
|
-
};
|
|
567
|
-
/** True when a field declares non-empty options (a choice field safe to aggregate publicly). */
|
|
568
|
-
const fieldHasOptions = (field) => optionsOf(field) !== void 0;
|
|
569
|
-
const metaFor = (field) => ({
|
|
570
|
-
field: field.name,
|
|
571
|
-
label: field.label ?? field.name,
|
|
572
|
-
fieldType: field.blockType,
|
|
573
|
-
options: optionsOf(field)
|
|
574
|
-
});
|
|
575
|
-
/**
|
|
576
|
-
* Aggregate submission responses for a form. Loads the form for current labels + option order, then pages
|
|
577
|
-
* `payload.find` over its submissions (JSON `values` cannot be GROUP BY'd portably across Mongo + Postgres,
|
|
578
|
-
* so we scan + reduce in JS, cross-DB-identically). Bounded by `maxSubmissions`; beyond it the result is
|
|
579
|
-
* `truncated`. Returns one `FieldAggregation` per requested field (or per enumerable field by default).
|
|
580
|
-
*/
|
|
581
|
-
const aggregateFormResponses = async (args) => {
|
|
582
|
-
const { payload, formId, fields, status = "complete", req, maxSubmissions = 1e4, pageSize = 500 } = args;
|
|
583
|
-
const form = await payload.findByID({
|
|
584
|
-
collection: FORMS_SLUG,
|
|
585
|
-
id: formId,
|
|
586
|
-
depth: 0,
|
|
587
|
-
overrideAccess: true,
|
|
588
|
-
req
|
|
589
|
-
}).catch(() => null);
|
|
590
|
-
if (!form || !Array.isArray(form.fields)) return [];
|
|
591
|
-
const instances = form.fields;
|
|
592
|
-
const selected = fields ? instances.filter((field) => fields.includes(field.name)) : instances.filter((field) => optionsOf(field) !== void 0);
|
|
593
|
-
if (selected.length === 0) return [];
|
|
594
|
-
const metas = selected.map(metaFor);
|
|
595
|
-
const where = { and: [{ form: { equals: formId } }, ...status === "all" ? [] : [{ status: { equals: status } }]] };
|
|
596
|
-
const rows = [];
|
|
597
|
-
let page = 1;
|
|
598
|
-
let truncated = false;
|
|
599
|
-
for (;;) {
|
|
600
|
-
const result = await payload.find({
|
|
601
|
-
collection: FORM_SUBMISSIONS_SLUG,
|
|
602
|
-
where,
|
|
603
|
-
limit: pageSize,
|
|
604
|
-
page,
|
|
605
|
-
depth: 0,
|
|
606
|
-
overrideAccess: true,
|
|
607
|
-
req,
|
|
608
|
-
select: {
|
|
609
|
-
values: true,
|
|
610
|
-
descriptors: true
|
|
611
|
-
}
|
|
612
|
-
});
|
|
613
|
-
for (const doc of result.docs) {
|
|
614
|
-
if (rows.length >= maxSubmissions) {
|
|
615
|
-
truncated = true;
|
|
616
|
-
break;
|
|
617
|
-
}
|
|
618
|
-
rows.push({
|
|
619
|
-
values: doc.values,
|
|
620
|
-
descriptors: doc.descriptors
|
|
621
|
-
});
|
|
622
|
-
}
|
|
623
|
-
if (truncated || !result.hasNextPage) break;
|
|
624
|
-
page += 1;
|
|
625
|
-
}
|
|
626
|
-
return aggregateRowsForFields(rows, metas, truncated);
|
|
627
|
-
};
|
|
628
|
-
/** Single-field convenience over `aggregateFormResponses`. Returns the field's aggregation, or null if absent. */
|
|
629
|
-
const aggregateFieldResponses = async (args) => {
|
|
630
|
-
const { field, ...rest } = args;
|
|
631
|
-
const [result] = await aggregateFormResponses({
|
|
632
|
-
...rest,
|
|
633
|
-
fields: [field]
|
|
634
|
-
});
|
|
635
|
-
return result ?? null;
|
|
636
|
-
};
|
|
637
|
-
//#endregion
|
|
638
|
-
//#region src/aggregation/resolveResultsRequest.ts
|
|
639
|
-
const forbidden = {
|
|
640
|
-
status: 403,
|
|
641
|
-
body: { errors: [{ message: "Forbidden" }] }
|
|
642
|
-
};
|
|
643
|
-
/**
|
|
644
|
-
* Authorize and resolve a poll/survey results request. Authed callers may aggregate any field (or all
|
|
645
|
-
* enumerable fields). Anonymous callers are allowed only when the form opted in (`showResults`), and then
|
|
646
|
-
* only for the configured `resultsField`, and only if that field is enumerable (has options) so a
|
|
647
|
-
* misconfigured `resultsField` pointing at a free-text or PII field can never be dumped publicly. Returns
|
|
648
|
-
* only aggregate counts, never raw submissions.
|
|
649
|
-
*/
|
|
650
|
-
const resolveFormResultsRequest = async (args) => {
|
|
651
|
-
const { payload, formId, field, isAuthed, req } = args;
|
|
652
|
-
if (formId == null) return {
|
|
653
|
-
status: 400,
|
|
654
|
-
body: { errors: [{ message: "Missing form id" }] }
|
|
655
|
-
};
|
|
656
|
-
const form = await payload.findByID({
|
|
657
|
-
collection: FORMS_SLUG,
|
|
658
|
-
id: formId,
|
|
659
|
-
depth: 0,
|
|
660
|
-
overrideAccess: true,
|
|
661
|
-
req
|
|
662
|
-
}).catch(() => null);
|
|
663
|
-
if (!form) return {
|
|
664
|
-
status: 404,
|
|
665
|
-
body: { errors: [{ message: "Not found" }] }
|
|
666
|
-
};
|
|
667
|
-
let fields;
|
|
668
|
-
if (isAuthed) fields = field ? [field] : void 0;
|
|
669
|
-
else {
|
|
670
|
-
if (form.showResults !== true) return forbidden;
|
|
671
|
-
const publicField = typeof form.resultsField === "string" && form.resultsField.length > 0 ? form.resultsField : void 0;
|
|
672
|
-
if (!publicField) return forbidden;
|
|
673
|
-
if (field && field !== publicField) return forbidden;
|
|
674
|
-
const instance = (Array.isArray(form.fields) ? form.fields : []).find((entry) => entry.name === publicField);
|
|
675
|
-
if (!instance || !fieldHasOptions(instance)) return forbidden;
|
|
676
|
-
fields = [publicField];
|
|
677
|
-
}
|
|
678
|
-
return {
|
|
679
|
-
status: 200,
|
|
680
|
-
body: { results: await aggregateFormResponses({
|
|
681
|
-
payload,
|
|
682
|
-
formId,
|
|
683
|
-
fields,
|
|
684
|
-
req
|
|
685
|
-
}) }
|
|
686
|
-
};
|
|
687
|
-
};
|
|
688
|
-
//#endregion
|
|
689
|
-
//#region src/calc/normalizeCalc.ts
|
|
690
|
-
const MAX_DEPTH = 64;
|
|
691
|
-
const OP_SET = new Set([
|
|
692
|
-
"+",
|
|
693
|
-
"-",
|
|
694
|
-
"*",
|
|
695
|
-
"/",
|
|
696
|
-
"%"
|
|
697
|
-
]);
|
|
698
|
-
const FN_SET = new Set([
|
|
699
|
-
"min",
|
|
700
|
-
"max",
|
|
701
|
-
"round",
|
|
702
|
-
"abs",
|
|
703
|
-
"ceil",
|
|
704
|
-
"floor"
|
|
705
|
-
]);
|
|
706
|
-
const isRecord$1 = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
707
|
-
const normalizeNode = (value, depth) => {
|
|
708
|
-
if (depth > MAX_DEPTH) return void 0;
|
|
709
|
-
if (!isRecord$1(value)) return void 0;
|
|
710
|
-
switch (value.type) {
|
|
711
|
-
case "lit": {
|
|
712
|
-
const { value: v } = value;
|
|
713
|
-
if (typeof v !== "number" || !Number.isFinite(v)) return void 0;
|
|
714
|
-
return {
|
|
715
|
-
type: "lit",
|
|
716
|
-
value: v
|
|
717
|
-
};
|
|
718
|
-
}
|
|
719
|
-
case "ref": {
|
|
720
|
-
const { field } = value;
|
|
721
|
-
if (typeof field !== "string") return void 0;
|
|
722
|
-
return {
|
|
723
|
-
type: "ref",
|
|
724
|
-
field
|
|
725
|
-
};
|
|
726
|
-
}
|
|
727
|
-
case "op": {
|
|
728
|
-
const { op, left, right } = value;
|
|
729
|
-
if (typeof op !== "string" || !OP_SET.has(op)) return void 0;
|
|
730
|
-
const l = normalizeNode(left, depth + 1);
|
|
731
|
-
if (!l) return void 0;
|
|
732
|
-
const r = normalizeNode(right, depth + 1);
|
|
733
|
-
if (!r) return void 0;
|
|
734
|
-
return {
|
|
735
|
-
type: "op",
|
|
736
|
-
op,
|
|
737
|
-
left: l,
|
|
738
|
-
right: r
|
|
739
|
-
};
|
|
740
|
-
}
|
|
741
|
-
case "neg": {
|
|
742
|
-
const operand = normalizeNode(value.operand, depth + 1);
|
|
743
|
-
if (!operand) return void 0;
|
|
744
|
-
return {
|
|
745
|
-
type: "neg",
|
|
746
|
-
operand
|
|
747
|
-
};
|
|
748
|
-
}
|
|
749
|
-
case "fn": {
|
|
750
|
-
const { fn, args } = value;
|
|
751
|
-
if (typeof fn !== "string" || !FN_SET.has(fn)) return void 0;
|
|
752
|
-
if (!Array.isArray(args)) return void 0;
|
|
753
|
-
const normalizedArgs = [];
|
|
754
|
-
for (const arg of args) {
|
|
755
|
-
const a = normalizeNode(arg, depth + 1);
|
|
756
|
-
if (!a) return void 0;
|
|
757
|
-
normalizedArgs.push(a);
|
|
758
|
-
}
|
|
759
|
-
return {
|
|
760
|
-
type: "fn",
|
|
761
|
-
fn,
|
|
762
|
-
args: normalizedArgs
|
|
763
|
-
};
|
|
764
|
-
}
|
|
765
|
-
case "weight": {
|
|
766
|
-
const { field, weights } = value;
|
|
767
|
-
if (typeof field !== "string") return void 0;
|
|
768
|
-
if (!isRecord$1(weights)) return void 0;
|
|
769
|
-
const normalizedWeights = {};
|
|
770
|
-
for (const [k, v] of Object.entries(weights)) {
|
|
771
|
-
if (typeof v !== "number" || !Number.isFinite(v)) return void 0;
|
|
772
|
-
normalizedWeights[k] = v;
|
|
773
|
-
}
|
|
774
|
-
return {
|
|
775
|
-
type: "weight",
|
|
776
|
-
field,
|
|
777
|
-
weights: normalizedWeights
|
|
778
|
-
};
|
|
779
|
-
}
|
|
780
|
-
default: return;
|
|
781
|
-
}
|
|
782
|
-
};
|
|
783
|
-
/** Structural guard: returns a valid CalcExpression if `value` is structurally sound (depth-guarded at 64), otherwise undefined. Never throws. */
|
|
784
|
-
const normalizeCalc = (value) => normalizeNode(value, 0);
|
|
785
|
-
//#endregion
|
|
786
|
-
//#region src/conditions/conditionType.ts
|
|
787
|
-
/** The condition input a field type uses in the builder: its declared `conditionType` or the value-kind default. */
|
|
788
|
-
const conditionTypeForDefinition = (definition) => definition.conditionType ?? defaultConditionType(definition.value);
|
|
789
|
-
/** A serializable slug -> condition type map for every registered field type, handed to the client builder. */
|
|
790
|
-
const buildConditionTypeMap = (registry) => {
|
|
791
|
-
const map = {};
|
|
792
|
-
for (const definition of registry.values()) map[definition.type] = conditionTypeForDefinition(definition);
|
|
793
|
-
return map;
|
|
794
|
-
};
|
|
795
|
-
//#endregion
|
|
796
|
-
//#region src/conditions/normalizeConditions.ts
|
|
797
|
-
const isValidConstraint = (constraint, operandTypes) => {
|
|
798
|
-
if (constraint == null || typeof constraint !== "object") return false;
|
|
799
|
-
const field = Object.keys(constraint)[0];
|
|
800
|
-
if (!field) return false;
|
|
801
|
-
const type = operandTypes.get(field);
|
|
802
|
-
if (!type) return false;
|
|
803
|
-
const ops = constraint[field];
|
|
804
|
-
if (ops == null || typeof ops !== "object") return false;
|
|
805
|
-
const operator = Object.keys(ops)[0];
|
|
806
|
-
return Boolean(operator && conditionOperators[type].includes(operator));
|
|
807
|
-
};
|
|
808
|
-
const normalizeOne = (raw, operandTypes) => {
|
|
809
|
-
if (raw == null || typeof raw !== "object" || Object.keys(raw).length === 0) return;
|
|
810
|
-
const canonical = transformWhereQuery(raw);
|
|
811
|
-
const groups = (Array.isArray(canonical.or) ? canonical.or : Array.isArray(canonical.and) ? [{ and: canonical.and }] : []).map((group) => {
|
|
812
|
-
return (Array.isArray(group.and) ? group.and : []).filter((constraint) => isValidConstraint(constraint, operandTypes));
|
|
813
|
-
}).filter((group) => group.length > 0);
|
|
814
|
-
return groups.length > 0 ? { or: groups.map((and) => ({ and })) } : void 0;
|
|
815
|
-
};
|
|
816
|
-
/**
|
|
817
|
-
* Normalize every field's `visibleWhen`/`validateWhen` against the form's own field list. Canonicalizes
|
|
818
|
-
* to OR-of-ANDs and strips constraints whose operand field is missing or whose operator is invalid for
|
|
819
|
-
* that field's condition type, so stored conditions always match what `evaluateCondition` can run.
|
|
820
|
-
*/
|
|
821
|
-
const normalizeFormConditions = (fields, conditionTypes) => {
|
|
822
|
-
const operandTypes = /* @__PURE__ */ new Map();
|
|
823
|
-
for (const row of fields) {
|
|
824
|
-
const name = typeof row.name === "string" ? row.name.trim() : "";
|
|
825
|
-
if (name.length > 0) operandTypes.set(name, conditionTypes[row.blockType] ?? "text");
|
|
826
|
-
}
|
|
827
|
-
return fields.map((row) => {
|
|
828
|
-
const visibleWhen = normalizeOne(row.visibleWhen, operandTypes);
|
|
829
|
-
const validateWhen = normalizeOne(row.validateWhen, operandTypes);
|
|
830
|
-
return {
|
|
831
|
-
...row,
|
|
832
|
-
visibleWhen,
|
|
833
|
-
validateWhen
|
|
834
|
-
};
|
|
835
|
-
});
|
|
836
|
-
};
|
|
837
|
-
//#endregion
|
|
838
|
-
//#region src/validation/buildRuleBlocks.ts
|
|
839
|
-
/** Each rule block appends its own `message` and `severity` fields, so a rule param of either name would collide and be silently dropped by Payload. */
|
|
840
|
-
const RESERVED_PARAM_NAMES = new Set(["message", "severity"]);
|
|
841
|
-
/** One block per rule applicable to `fieldType` (gated by `appliesTo`): the rule params, then a message override and severity. */
|
|
842
|
-
const buildRuleBlocks = (registry, fieldType) => {
|
|
843
|
-
const blocks = [];
|
|
844
|
-
for (const rule of registry.values()) {
|
|
845
|
-
if (rule.appliesTo && !rule.appliesTo.includes(fieldType)) continue;
|
|
846
|
-
for (const param of rule.params ?? []) if ("name" in param && RESERVED_PARAM_NAMES.has(param.name)) throw new Error(`form-builder: validation rule "${rule.type}" declares a reserved param name "${param.name}". The names "message" and "severity" are reserved for the constraint-list UI.`);
|
|
847
|
-
blocks.push({
|
|
848
|
-
slug: rule.type,
|
|
849
|
-
labels: {
|
|
850
|
-
singular: labelFor(rule.label),
|
|
851
|
-
plural: labelFor(rule.label)
|
|
852
|
-
},
|
|
853
|
-
fields: [
|
|
854
|
-
...rule.params ?? [],
|
|
855
|
-
{
|
|
856
|
-
name: "message",
|
|
857
|
-
type: "text",
|
|
858
|
-
label: labelFor(keys.validationMessageLabel)
|
|
859
|
-
},
|
|
860
|
-
{
|
|
861
|
-
name: "severity",
|
|
862
|
-
type: "select",
|
|
863
|
-
defaultValue: "error",
|
|
864
|
-
label: labelFor(keys.validationSeverityLabel),
|
|
865
|
-
options: [{
|
|
866
|
-
label: labelFor(keys.validationSeverityError),
|
|
867
|
-
value: "error"
|
|
868
|
-
}, {
|
|
869
|
-
label: labelFor(keys.validationSeverityWarning),
|
|
870
|
-
value: "warning"
|
|
871
|
-
}]
|
|
872
|
-
}
|
|
873
|
-
]
|
|
874
|
-
});
|
|
875
|
-
}
|
|
876
|
-
return blocks;
|
|
877
|
-
};
|
|
878
|
-
//#endregion
|
|
879
|
-
//#region src/fields/sharedConfig.ts
|
|
880
|
-
const CONDITION_FIELD_REF = "@10x-media/form-builder/client#FormConditionField";
|
|
881
|
-
const conditionField = (name, labelKey, conditionTypes) => ({
|
|
882
|
-
name,
|
|
883
|
-
type: "json",
|
|
884
|
-
label: labelFor(labelKey),
|
|
885
|
-
admin: { components: { Field: {
|
|
886
|
-
path: CONDITION_FIELD_REF,
|
|
887
|
-
clientProps: { conditionTypes }
|
|
888
|
-
} } }
|
|
889
|
-
});
|
|
890
|
-
/**
|
|
891
|
-
* Config every field instance carries regardless of type. `name` is the machine key written into
|
|
892
|
-
* submissions; `width` is stored for the layout grid. `visibleWhen`/`validateWhen` store a
|
|
893
|
-
* canonical Payload `Where`, edited by the native condition builder. Field types add their own `config`
|
|
894
|
-
* after these.
|
|
895
|
-
*/
|
|
896
|
-
const sharedFieldConfig = (conditionTypes) => [
|
|
897
|
-
{
|
|
898
|
-
name: "name",
|
|
899
|
-
type: "text",
|
|
900
|
-
required: true,
|
|
901
|
-
label: labelFor(keys.configName)
|
|
902
|
-
},
|
|
903
|
-
{
|
|
904
|
-
name: "label",
|
|
905
|
-
type: "text",
|
|
906
|
-
label: labelFor(keys.configLabel)
|
|
907
|
-
},
|
|
908
|
-
{
|
|
909
|
-
name: "required",
|
|
910
|
-
type: "checkbox",
|
|
911
|
-
label: labelFor(keys.configRequired)
|
|
912
|
-
},
|
|
913
|
-
{
|
|
914
|
-
name: "width",
|
|
915
|
-
type: "select",
|
|
916
|
-
defaultValue: "full",
|
|
917
|
-
label: labelFor(keys.configWidth),
|
|
918
|
-
options: [
|
|
919
|
-
{
|
|
920
|
-
label: "Full",
|
|
921
|
-
value: "full"
|
|
922
|
-
},
|
|
923
|
-
{
|
|
924
|
-
label: "Half",
|
|
925
|
-
value: "half"
|
|
926
|
-
},
|
|
927
|
-
{
|
|
928
|
-
label: "Third",
|
|
929
|
-
value: "third"
|
|
930
|
-
},
|
|
931
|
-
{
|
|
932
|
-
label: "Two thirds",
|
|
933
|
-
value: "twoThirds"
|
|
934
|
-
}
|
|
935
|
-
]
|
|
936
|
-
},
|
|
937
|
-
{
|
|
938
|
-
name: "placeholder",
|
|
939
|
-
type: "text",
|
|
940
|
-
label: labelFor(keys.configPlaceholder)
|
|
941
|
-
},
|
|
942
|
-
{
|
|
943
|
-
name: "description",
|
|
944
|
-
type: "textarea",
|
|
945
|
-
label: labelFor(keys.configDescription)
|
|
946
|
-
},
|
|
947
|
-
{
|
|
948
|
-
type: "collapsible",
|
|
949
|
-
label: labelFor(keys.configAdvanced),
|
|
950
|
-
admin: { initCollapsed: true },
|
|
951
|
-
fields: [
|
|
952
|
-
conditionField("visibleWhen", keys.configVisibleWhen, conditionTypes),
|
|
953
|
-
conditionField("validateWhen", keys.configValidateWhen, conditionTypes),
|
|
954
|
-
{
|
|
955
|
-
name: "hidden",
|
|
956
|
-
type: "checkbox",
|
|
957
|
-
label: labelFor(keys.configHidden)
|
|
958
|
-
}
|
|
959
|
-
]
|
|
960
|
-
}
|
|
961
|
-
];
|
|
962
|
-
//#endregion
|
|
963
|
-
//#region src/fields/buildFieldBlocks.ts
|
|
964
|
-
/** One add-field block per registered type: shared config, the type's own config, then its validations. */
|
|
965
|
-
const buildFieldBlocks = (registry, ruleRegistry) => {
|
|
966
|
-
const conditionTypes = buildConditionTypeMap(registry);
|
|
967
|
-
const blocks = [];
|
|
968
|
-
for (const definition of registry.values()) blocks.push({
|
|
969
|
-
slug: definition.type,
|
|
970
|
-
labels: {
|
|
971
|
-
singular: labelFor(definition.label),
|
|
972
|
-
plural: labelFor(definition.label)
|
|
973
|
-
},
|
|
974
|
-
fields: [
|
|
975
|
-
...sharedFieldConfig(conditionTypes),
|
|
976
|
-
...definition.config ?? [],
|
|
977
|
-
{
|
|
978
|
-
name: "validations",
|
|
979
|
-
type: "blocks",
|
|
980
|
-
label: labelFor(keys.validationsLabel),
|
|
981
|
-
blocks: buildRuleBlocks(ruleRegistry, definition.type)
|
|
982
|
-
}
|
|
983
|
-
]
|
|
984
|
-
});
|
|
985
|
-
return blocks;
|
|
986
|
-
};
|
|
987
|
-
//#endregion
|
|
988
|
-
//#region src/flow/normalizeFlow.ts
|
|
989
|
-
const isRecord = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
990
|
-
/**
|
|
991
|
-
* Pure guard that normalizes a raw (possibly untrusted) flow value against the
|
|
992
|
-
* form's current field list. Returns `undefined` when the flow is absent, empty,
|
|
993
|
-
* or resolves to fewer than two steps (a single step is an ordinary form).
|
|
994
|
-
*/
|
|
995
|
-
const normalizeFlow = (raw, fieldNames) => {
|
|
996
|
-
if (!isRecord(raw) || !Array.isArray(raw.steps)) return;
|
|
997
|
-
const knownFields = new Set(fieldNames);
|
|
998
|
-
const rawSteps = raw.steps.filter((s) => isRecord(s) && typeof s.id === "string");
|
|
999
|
-
const knownIds = new Set(rawSteps.map((s) => s.id));
|
|
1000
|
-
if (knownIds.size < 2) return;
|
|
1001
|
-
return { steps: rawSteps.map((s) => {
|
|
1002
|
-
const id = s.id;
|
|
1003
|
-
const fields = Array.isArray(s.fields) ? s.fields.filter((f) => typeof f === "string" && knownFields.has(f)) : [];
|
|
1004
|
-
const transitions = (Array.isArray(s.transitions) ? s.transitions : []).filter((t) => isRecord(t) && typeof t.to === "string" && knownIds.has(t.to) && isRecord(t.when)).map((t) => ({
|
|
1005
|
-
when: t.when,
|
|
1006
|
-
to: t.to
|
|
1007
|
-
}));
|
|
1008
|
-
const next = typeof s.next === "string" && knownIds.has(s.next) ? s.next : void 0;
|
|
1009
|
-
const step = {
|
|
1010
|
-
id,
|
|
1011
|
-
fields
|
|
1012
|
-
};
|
|
1013
|
-
if (typeof s.title === "string") step.title = s.title;
|
|
1014
|
-
if (transitions.length > 0) step.transitions = transitions;
|
|
1015
|
-
if (next !== void 0) step.next = next;
|
|
1016
|
-
return step;
|
|
1017
|
-
}) };
|
|
1018
|
-
};
|
|
1019
|
-
//#endregion
|
|
1020
|
-
//#region src/collections/forms.ts
|
|
1021
|
-
const FORMS_SLUG = "forms";
|
|
1022
|
-
const buildFormsCollection = ({ registry, ruleRegistry, presentationRegistry = new Map(Object.entries(defaultPresentationDescriptors)), actionRegistry = /* @__PURE__ */ new Map() }) => {
|
|
1023
|
-
const conditionTypes = buildConditionTypeMap(registry);
|
|
1024
|
-
const beforeValidate = ({ data }) => {
|
|
1025
|
-
if (data && typeof data.defaultPresentation === "string" && !presentationRegistry.has(data.defaultPresentation)) data.defaultPresentation = DEFAULT_PRESENTATION_NAME;
|
|
1026
|
-
if (data && Array.isArray(data.fields)) {
|
|
1027
|
-
const normalized = normalizeFormConditions(data.fields, conditionTypes);
|
|
1028
|
-
for (const field of normalized) if ("expression" in field) field.expression = normalizeCalc(field.expression);
|
|
1029
|
-
data.fields = normalized;
|
|
1030
|
-
const fieldNames = normalized.map((field) => typeof field.name === "string" ? field.name : void 0).filter((name) => name !== void 0);
|
|
1031
|
-
data.flow = normalizeFlow(data.flow, fieldNames);
|
|
1032
|
-
}
|
|
1033
|
-
return data;
|
|
1034
|
-
};
|
|
1035
|
-
return {
|
|
1036
|
-
slug: FORMS_SLUG,
|
|
1037
|
-
labels: {
|
|
1038
|
-
singular: "Form",
|
|
1039
|
-
plural: "Forms"
|
|
1040
|
-
},
|
|
1041
|
-
admin: {
|
|
1042
|
-
group: "Forms",
|
|
1043
|
-
useAsTitle: "title"
|
|
1044
|
-
},
|
|
1045
|
-
access: { read: () => true },
|
|
1046
|
-
hooks: { beforeValidate: [beforeValidate] },
|
|
1047
|
-
fields: [
|
|
1048
|
-
{
|
|
1049
|
-
name: "title",
|
|
1050
|
-
type: "text",
|
|
1051
|
-
required: true,
|
|
1052
|
-
label: labelForKey(keys.fieldTitle)
|
|
1053
|
-
},
|
|
1054
|
-
{
|
|
1055
|
-
name: "fields",
|
|
1056
|
-
type: "blocks",
|
|
1057
|
-
blocks: buildFieldBlocks(registry, ruleRegistry)
|
|
1058
|
-
},
|
|
1059
|
-
{
|
|
1060
|
-
name: "flow",
|
|
1061
|
-
type: "json"
|
|
1062
|
-
},
|
|
1063
|
-
{
|
|
1064
|
-
name: "actions",
|
|
1065
|
-
type: "blocks",
|
|
1066
|
-
blocks: buildActionBlocks(actionRegistry),
|
|
1067
|
-
label: labelForKey(keys.configActions)
|
|
1068
|
-
},
|
|
1069
|
-
{
|
|
1070
|
-
name: "defaultPresentation",
|
|
1071
|
-
type: "select",
|
|
1072
|
-
defaultValue: DEFAULT_PRESENTATION_NAME,
|
|
1073
|
-
options: [...presentationRegistry.values()].map((descriptor) => ({
|
|
1074
|
-
label: labelFor(descriptor.label),
|
|
1075
|
-
value: descriptor.name
|
|
1076
|
-
})),
|
|
1077
|
-
label: labelForKey(keys.configDefaultPresentation),
|
|
1078
|
-
admin: { position: "sidebar" }
|
|
1079
|
-
},
|
|
1080
|
-
{
|
|
1081
|
-
name: "showResults",
|
|
1082
|
-
type: "checkbox",
|
|
1083
|
-
defaultValue: false,
|
|
1084
|
-
label: labelForKey(keys.configShowResults),
|
|
1085
|
-
admin: { position: "sidebar" }
|
|
1086
|
-
},
|
|
1087
|
-
{
|
|
1088
|
-
name: "resultsField",
|
|
1089
|
-
type: "text",
|
|
1090
|
-
label: labelForKey(keys.configResultsField),
|
|
1091
|
-
admin: {
|
|
1092
|
-
position: "sidebar",
|
|
1093
|
-
description: "Field whose aggregate results are public when \"Show results publicly\" is on. Use a choice field, never a free-text or PII field.",
|
|
1094
|
-
condition: (data) => Boolean(data?.showResults)
|
|
1095
|
-
}
|
|
1096
|
-
}
|
|
1097
|
-
],
|
|
1098
|
-
endpoints: [{
|
|
1099
|
-
path: "/:id/results",
|
|
1100
|
-
method: "get",
|
|
1101
|
-
handler: async (req) => {
|
|
1102
|
-
const field = typeof req.query?.field === "string" ? req.query.field : void 0;
|
|
1103
|
-
const { status, body } = await resolveFormResultsRequest({
|
|
1104
|
-
payload: req.payload,
|
|
1105
|
-
formId: req.routeParams?.id,
|
|
1106
|
-
field,
|
|
1107
|
-
isAuthed: Boolean(req.user),
|
|
1108
|
-
req
|
|
1109
|
-
});
|
|
1110
|
-
return Response.json(body, { status });
|
|
1111
|
-
}
|
|
1112
|
-
}]
|
|
1113
|
-
};
|
|
1114
|
-
};
|
|
1115
|
-
//#endregion
|
|
1116
|
-
//#region src/consent/resolveConsentLinks.ts
|
|
1117
|
-
const EMPTY = { links: [] };
|
|
1118
|
-
/**
|
|
1119
|
-
* Resolve a consent field's policy links via its configured source (for display).
|
|
1120
|
-
* Unknown/missing source returns empty links. Never throws.
|
|
1121
|
-
*/
|
|
1122
|
-
const resolveConsentLinks = async (field, ctx) => {
|
|
1123
|
-
const sourceType = typeof field.source === "string" ? field.source : "static";
|
|
1124
|
-
const source = ctx.registry.get(sourceType);
|
|
1125
|
-
if (!source) return EMPTY;
|
|
1126
|
-
const sourceConfig = field.sourceConfig && typeof field.sourceConfig === "object" ? field.sourceConfig : {};
|
|
1127
|
-
try {
|
|
1128
|
-
return await source.resolve({
|
|
1129
|
-
config: sourceConfig,
|
|
1130
|
-
payload: ctx.payload,
|
|
1131
|
-
req: ctx.req,
|
|
1132
|
-
locale: ctx.locale
|
|
1133
|
-
});
|
|
1134
|
-
} catch {
|
|
1135
|
-
return EMPTY;
|
|
1136
|
-
}
|
|
1137
|
-
};
|
|
1138
|
-
//#endregion
|
|
1139
|
-
//#region src/consent/captureConsent.ts
|
|
1140
|
-
/**
|
|
1141
|
-
* Build the authoritative consent proof at submit time: re-resolve the source server-side
|
|
1142
|
-
* (ignores any client ref), capturing a reference url -- never the policy text.
|
|
1143
|
-
* `now` is injected by the caller (`new Date().toISOString()`) for testability.
|
|
1144
|
-
*/
|
|
1145
|
-
const captureConsent = async (args) => {
|
|
1146
|
-
const resolved = await resolveConsentLinks(args.field, {
|
|
1147
|
-
registry: args.registry,
|
|
1148
|
-
payload: args.payload,
|
|
1149
|
-
req: args.req,
|
|
1150
|
-
locale: args.locale
|
|
1151
|
-
});
|
|
1152
|
-
const ref = resolved.links[0]?.url || void 0;
|
|
1153
|
-
return {
|
|
1154
|
-
agreed: args.agreed,
|
|
1155
|
-
...ref ? { ref } : {},
|
|
1156
|
-
...resolved.versionRef ? { versionRef: resolved.versionRef } : {},
|
|
1157
|
-
at: args.now
|
|
1158
|
-
};
|
|
1159
|
-
};
|
|
1160
|
-
//#endregion
|
|
1161
|
-
//#region src/uploads/resolveFileRef.ts
|
|
1162
|
-
const mimeAllowed = (mimeType, allow) => {
|
|
1163
|
-
if (!allow || allow.length === 0) return true;
|
|
1164
|
-
return allow.some((pattern) => {
|
|
1165
|
-
if (pattern.endsWith("/*")) return mimeType.startsWith(pattern.slice(0, -1));
|
|
1166
|
-
return mimeType === pattern;
|
|
1167
|
-
});
|
|
1168
|
-
};
|
|
1169
|
-
/**
|
|
1170
|
-
* Pure server trust-boundary check for an uploaded file: given the loaded upload doc (or null) and the
|
|
1171
|
-
* field's constraints, either return an authoritative `FileRef` snapshot or a rejection code. Never trusts
|
|
1172
|
-
* client metadata; the caller passes the doc it loaded from the upload collection.
|
|
1173
|
-
*/
|
|
1174
|
-
const resolveFileRef = (doc, config) => {
|
|
1175
|
-
if (doc == null || doc.id == null) return {
|
|
1176
|
-
ok: false,
|
|
1177
|
-
code: "missing"
|
|
1178
|
-
};
|
|
1179
|
-
const mimeType = typeof doc.mimeType === "string" ? doc.mimeType : "";
|
|
1180
|
-
const filesize = typeof doc.filesize === "number" ? doc.filesize : 0;
|
|
1181
|
-
if (!mimeAllowed(mimeType, config.mimeTypes)) return {
|
|
1182
|
-
ok: false,
|
|
1183
|
-
code: "mimeType"
|
|
1184
|
-
};
|
|
1185
|
-
if (typeof config.maxSize === "number" && filesize > config.maxSize) return {
|
|
1186
|
-
ok: false,
|
|
1187
|
-
code: "tooLarge"
|
|
1188
|
-
};
|
|
1189
|
-
const ref = {
|
|
1190
|
-
id: doc.id,
|
|
1191
|
-
filename: typeof doc.filename === "string" ? doc.filename : "",
|
|
1192
|
-
mimeType,
|
|
1193
|
-
filesize
|
|
1194
|
-
};
|
|
1195
|
-
if (typeof doc.url === "string" && doc.url.length > 0) ref.url = doc.url;
|
|
1196
|
-
return {
|
|
1197
|
-
ok: true,
|
|
1198
|
-
ref
|
|
1199
|
-
};
|
|
1200
|
-
};
|
|
1201
|
-
//#endregion
|
|
1202
|
-
//#region src/uploads/captureFileRef.ts
|
|
1203
|
-
/**
|
|
1204
|
-
* Load the referenced upload doc and run the pure trust-boundary check. The client sends only the id; the
|
|
1205
|
-
* filename/mimeType/filesize are read authoritatively from the stored doc, never from the client. When the
|
|
1206
|
-
* upload carries an `owner` stamp AND the submitter is identifiable, the two must match, so an anonymous
|
|
1207
|
-
* submitter cannot capture another identity's upload; a mismatch collapses to `missing`, indistinguishable
|
|
1208
|
-
* from a deleted upload. When the submitter cannot be identified (no `expectedOwner`), ownership is not
|
|
1209
|
-
* enforced (fail-open, consistent with rate-limiting): a proxy-configured deployment identifies every
|
|
1210
|
-
* request, so this only relaxes scoping where it could not be applied fairly anyway. Unstamped uploads
|
|
1211
|
-
* (no identity at upload time, or a BYO collection without the field) pass unchanged.
|
|
1212
|
-
*/
|
|
1213
|
-
const captureFileRef = async (args) => {
|
|
1214
|
-
const { payload, collectionSlug, uploadId, config, req, expectedOwner } = args;
|
|
1215
|
-
const doc = await payload.findByID({
|
|
1216
|
-
collection: collectionSlug,
|
|
1217
|
-
id: uploadId,
|
|
1218
|
-
depth: 0,
|
|
1219
|
-
overrideAccess: true,
|
|
1220
|
-
req
|
|
1221
|
-
}).catch(() => null);
|
|
1222
|
-
if (doc && expectedOwner != null) {
|
|
1223
|
-
const owner = doc.owner;
|
|
1224
|
-
if (typeof owner === "string" && owner.length > 0 && owner !== expectedOwner) return {
|
|
1225
|
-
ok: false,
|
|
1226
|
-
code: "missing"
|
|
1227
|
-
};
|
|
1228
|
-
}
|
|
1229
|
-
return resolveFileRef(doc, config);
|
|
1230
|
-
};
|
|
1231
|
-
//#endregion
|
|
1232
|
-
//#region src/submissions/runSubmission.ts
|
|
1233
|
-
const errorKeyFor = (code) => {
|
|
1234
|
-
if (code === "mimeType") return keys.validationFileMimeType;
|
|
1235
|
-
if (code === "tooLarge") return keys.validationFileTooLarge;
|
|
1236
|
-
return keys.validationFileMissing;
|
|
1237
|
-
};
|
|
1238
|
-
/** Normalize the authored `mimeTypes` (a `hasMany` text field) to a `string[]`. */
|
|
1239
|
-
const mimeTypesOf = (raw) => {
|
|
1240
|
-
if (!Array.isArray(raw)) return;
|
|
1241
|
-
const out = raw.filter((entry) => typeof entry === "string");
|
|
1242
|
-
return out.length > 0 ? out : void 0;
|
|
1243
|
-
};
|
|
1244
|
-
const fileFieldConfigOf = (instance) => ({
|
|
1245
|
-
relationTo: typeof instance.relationTo === "string" ? instance.relationTo : void 0,
|
|
1246
|
-
mimeTypes: mimeTypesOf(instance.mimeTypes),
|
|
1247
|
-
maxSize: typeof instance.maxSize === "number" ? instance.maxSize : void 0
|
|
1248
|
-
});
|
|
1249
|
-
const isEmpty = (value) => value == null || value === "" || Array.isArray(value) && value.length === 0;
|
|
1250
|
-
const coerce = (kind, value) => {
|
|
1251
|
-
if (value == null) return value;
|
|
1252
|
-
if (kind === "number") {
|
|
1253
|
-
const next = typeof value === "number" ? value : Number(value);
|
|
1254
|
-
return Number.isNaN(next) ? value : next;
|
|
1255
|
-
}
|
|
1256
|
-
if (kind === "boolean") {
|
|
1257
|
-
if (typeof value === "string") {
|
|
1258
|
-
const normalized = value.trim().toLowerCase();
|
|
1259
|
-
return !(normalized === "" || normalized === "false" || normalized === "0" || normalized === "off" || normalized === "no");
|
|
1260
|
-
}
|
|
1261
|
-
return Boolean(value);
|
|
1262
|
-
}
|
|
1263
|
-
if (kind === "text") return typeof value === "string" ? value : String(value);
|
|
1264
|
-
return value;
|
|
1265
|
-
};
|
|
1266
|
-
const optionLabelsFor$1 = (instance) => {
|
|
1267
|
-
const options = instance.options;
|
|
1268
|
-
if (!Array.isArray(options)) return;
|
|
1269
|
-
const map = {};
|
|
1270
|
-
for (const option of options) if (typeof option?.value === "string") map[option.value] = option.label ?? option.value;
|
|
1271
|
-
return Object.keys(map).length > 0 ? map : void 0;
|
|
1272
|
-
};
|
|
1273
|
-
/**
|
|
1274
|
-
* Pure submission core, two-pass: first coerce every answered field to its typed kind (so cross-field
|
|
1275
|
-
* rules see coerced siblings), then validate each field through `runValidation` (required, intrinsic
|
|
1276
|
-
* facet, declarative rules), snapshotting a localized descriptor per answered field. Calc fields are
|
|
1277
|
-
* the trust boundary: their client-sent values are never seeded, their values are derived from their
|
|
1278
|
-
* expressions over the coerced answers, and those derived values are authoritative everywhere downstream
|
|
1279
|
-
* (conditions, validation, storage). Conditions gate the second pass against these effective answers: a
|
|
1280
|
-
* field whose `visibleWhen` is false is skipped entirely (never validated, never stored, so a client-sent
|
|
1281
|
-
* value for it is ignored), and a visible field whose `validateWhen` is false stores its value but skips
|
|
1282
|
-
* validation. A visible calc field stores its derived value and is never validated. Only `error` severity
|
|
1283
|
-
* blocks; warnings are computed but not surfaced server-side (the renderer surfaces them).
|
|
1284
|
-
*/
|
|
1285
|
-
const runSubmission = async (input) => {
|
|
1286
|
-
const { fields, values, registry, ruleRegistry, consentRegistry, locale, t, operation, req, payload, formId, uploadSlug, expectedOwner } = input;
|
|
1287
|
-
const incoming = new Map(values.map((entry) => [entry.field, entry.value]));
|
|
1288
|
-
const coercedAnswers = {};
|
|
1289
|
-
const coercedByName = /* @__PURE__ */ new Map();
|
|
1290
|
-
for (const instance of fields) {
|
|
1291
|
-
const definition = registry.get(instance.blockType);
|
|
1292
|
-
const raw = incoming.get(instance.name);
|
|
1293
|
-
if (!definition || calcExpressionOf(instance)) continue;
|
|
1294
|
-
const effectiveRaw = instance.blockType === "consent" && isEmpty(raw) ? false : raw;
|
|
1295
|
-
if (isEmpty(effectiveRaw)) continue;
|
|
1296
|
-
const value = coerce(definition.value, effectiveRaw);
|
|
1297
|
-
coercedAnswers[instance.name] = value;
|
|
1298
|
-
coercedByName.set(instance.name, value);
|
|
1299
|
-
}
|
|
1300
|
-
const effective = computeCalcFields(fields, coercedAnswers);
|
|
1301
|
-
const errors = [];
|
|
1302
|
-
const outValues = [];
|
|
1303
|
-
const descriptors = [];
|
|
1304
|
-
const consentProofs = [];
|
|
1305
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1306
|
-
for (const instance of fields) {
|
|
1307
|
-
const definition = registry.get(instance.blockType);
|
|
1308
|
-
if (!definition) continue;
|
|
1309
|
-
const raw = incoming.get(instance.name);
|
|
1310
|
-
const value = coercedByName.has(instance.name) ? coercedByName.get(instance.name) : raw;
|
|
1311
|
-
if (!evaluateCondition(instance.visibleWhen, effective)) continue;
|
|
1312
|
-
if (calcExpressionOf(instance)) {
|
|
1313
|
-
outValues.push({
|
|
1314
|
-
field: instance.name,
|
|
1315
|
-
value: effective[instance.name]
|
|
1316
|
-
});
|
|
1317
|
-
descriptors.push({
|
|
1318
|
-
field: instance.name,
|
|
1319
|
-
label: instance.label ?? instance.name,
|
|
1320
|
-
fieldType: instance.blockType
|
|
1321
|
-
});
|
|
1322
|
-
continue;
|
|
1323
|
-
}
|
|
1324
|
-
if (evaluateCondition(instance.validateWhen, effective)) {
|
|
1325
|
-
const { errors: issues } = await runValidation({
|
|
1326
|
-
field: instance,
|
|
1327
|
-
fieldDefinition: definition,
|
|
1328
|
-
value,
|
|
1329
|
-
fieldType: instance.blockType,
|
|
1330
|
-
ruleRegistry,
|
|
1331
|
-
answers: effective,
|
|
1332
|
-
locale,
|
|
1333
|
-
t,
|
|
1334
|
-
operation,
|
|
1335
|
-
event: "submit",
|
|
1336
|
-
mode: "server",
|
|
1337
|
-
req,
|
|
1338
|
-
payload,
|
|
1339
|
-
formId
|
|
1340
|
-
});
|
|
1341
|
-
const blocking = issues.filter((issue) => issue.severity === "error");
|
|
1342
|
-
if (blocking.length > 0) {
|
|
1343
|
-
for (const issue of blocking) errors.push({
|
|
1344
|
-
path: instance.name,
|
|
1345
|
-
message: issue.message
|
|
1346
|
-
});
|
|
1347
|
-
continue;
|
|
1348
|
-
}
|
|
1349
|
-
}
|
|
1350
|
-
if (instance.blockType === "file") {
|
|
1351
|
-
if (isEmpty(value)) continue;
|
|
1352
|
-
if (payload) {
|
|
1353
|
-
const fileConfig = fileFieldConfigOf(instance);
|
|
1354
|
-
const captured = await captureFileRef({
|
|
1355
|
-
payload,
|
|
1356
|
-
collectionSlug: fileConfig.relationTo ?? uploadSlug ?? "form-uploads",
|
|
1357
|
-
uploadId: value,
|
|
1358
|
-
config: fileConfig,
|
|
1359
|
-
req,
|
|
1360
|
-
expectedOwner
|
|
1361
|
-
});
|
|
1362
|
-
if (!captured.ok) {
|
|
1363
|
-
errors.push({
|
|
1364
|
-
path: instance.name,
|
|
1365
|
-
message: t(errorKeyFor(captured.code))
|
|
1366
|
-
});
|
|
1367
|
-
continue;
|
|
1368
|
-
}
|
|
1369
|
-
outValues.push({
|
|
1370
|
-
field: instance.name,
|
|
1371
|
-
value: captured.ref
|
|
1372
|
-
});
|
|
1373
|
-
descriptors.push({
|
|
1374
|
-
field: instance.name,
|
|
1375
|
-
label: instance.label ?? instance.name,
|
|
1376
|
-
fieldType: instance.blockType
|
|
1377
|
-
});
|
|
1378
|
-
continue;
|
|
1379
|
-
}
|
|
1380
|
-
outValues.push({
|
|
1381
|
-
field: instance.name,
|
|
1382
|
-
value
|
|
1383
|
-
});
|
|
1384
|
-
descriptors.push({
|
|
1385
|
-
field: instance.name,
|
|
1386
|
-
label: instance.label ?? instance.name,
|
|
1387
|
-
fieldType: instance.blockType
|
|
1388
|
-
});
|
|
1389
|
-
continue;
|
|
1390
|
-
}
|
|
1391
|
-
if (instance.blockType === "consent" && payload) {
|
|
1392
|
-
const proof = await captureConsent({
|
|
1393
|
-
field: instance,
|
|
1394
|
-
agreed: value === true,
|
|
1395
|
-
registry: consentRegistry,
|
|
1396
|
-
payload,
|
|
1397
|
-
req,
|
|
1398
|
-
locale,
|
|
1399
|
-
now
|
|
1400
|
-
});
|
|
1401
|
-
consentProofs.push({
|
|
1402
|
-
field: instance.name,
|
|
1403
|
-
...proof
|
|
1404
|
-
});
|
|
1405
|
-
continue;
|
|
1406
|
-
}
|
|
1407
|
-
if (isEmpty(raw)) continue;
|
|
1408
|
-
outValues.push({
|
|
1409
|
-
field: instance.name,
|
|
1410
|
-
value
|
|
1411
|
-
});
|
|
1412
|
-
const optionLabels = optionLabelsFor$1(instance);
|
|
1413
|
-
descriptors.push({
|
|
1414
|
-
field: instance.name,
|
|
1415
|
-
label: instance.label ?? instance.name,
|
|
1416
|
-
fieldType: instance.blockType,
|
|
1417
|
-
...optionLabels ? { optionLabels } : {}
|
|
1418
|
-
});
|
|
1419
|
-
}
|
|
1420
|
-
return {
|
|
1421
|
-
errors,
|
|
1422
|
-
values: outValues,
|
|
1423
|
-
descriptors,
|
|
1424
|
-
consent: consentProofs
|
|
1425
|
-
};
|
|
1426
|
-
};
|
|
1427
|
-
//#endregion
|
|
1428
|
-
//#region src/submissions/validateSubmission.ts
|
|
1429
|
-
/**
|
|
1430
|
-
* Server-authoritative submission validation. On create it loads the referenced form, re-runs every
|
|
1431
|
-
* field's required check, intrinsic validator, and declarative rules through `runSubmission`, threading
|
|
1432
|
-
* `req`/`payload` so server-only async rules can hit the DB, and throws a Payload `ValidationError` with
|
|
1433
|
-
* per-field paths on any error-severity failure. The client is never trusted.
|
|
1434
|
-
* Consent fields are captured into `result.consent` (array of proofs, one per visible consent field).
|
|
1435
|
-
*/
|
|
1436
|
-
const validateSubmission = ({ registry, ruleRegistry, consentRegistry, uploadSlug }) => async ({ data, operation, req }) => {
|
|
1437
|
-
if (operation !== "create" || !data) return data;
|
|
1438
|
-
const formId = data.form;
|
|
1439
|
-
if (formId == null) return data;
|
|
1440
|
-
const fields = (await req.payload.findByID({
|
|
1441
|
-
collection: "forms",
|
|
1442
|
-
id: formId,
|
|
1443
|
-
depth: 0,
|
|
1444
|
-
locale: req.locale,
|
|
1445
|
-
req
|
|
1446
|
-
})).fields ?? [];
|
|
1447
|
-
const incoming = data.values ?? [];
|
|
1448
|
-
const locale = req.locale ?? "en";
|
|
1449
|
-
const t = asFieldTranslate(req.i18n.t);
|
|
1450
|
-
const expectedOwner = typeof req.context?.["formBuilderSpamIdentity"] === "string" ? req.context[IDENTITY_CONTEXT_KEY] : void 0;
|
|
1451
|
-
const result = await runSubmission({
|
|
1452
|
-
fields,
|
|
1453
|
-
values: incoming,
|
|
1454
|
-
registry,
|
|
1455
|
-
ruleRegistry,
|
|
1456
|
-
consentRegistry,
|
|
1457
|
-
locale,
|
|
1458
|
-
t,
|
|
1459
|
-
operation: "create",
|
|
1460
|
-
req,
|
|
1461
|
-
payload: req.payload,
|
|
1462
|
-
formId,
|
|
1463
|
-
uploadSlug,
|
|
1464
|
-
expectedOwner
|
|
1465
|
-
});
|
|
1466
|
-
if (result.errors.length > 0) throw new ValidationError({
|
|
1467
|
-
collection: FORM_SUBMISSIONS_SLUG,
|
|
1468
|
-
errors: result.errors
|
|
1469
|
-
}, req.t);
|
|
1470
|
-
data.values = result.values;
|
|
1471
|
-
data.descriptors = result.descriptors;
|
|
1472
|
-
data.consent = result.consent.length > 0 ? result.consent : void 0;
|
|
1473
|
-
data.locale = locale;
|
|
1474
|
-
return data;
|
|
1475
|
-
};
|
|
1476
|
-
//#endregion
|
|
1477
|
-
//#region src/collections/formSubmissions.ts
|
|
1478
|
-
const FORM_SUBMISSIONS_SLUG = "form-submissions";
|
|
1479
|
-
const formIdOf = (form) => {
|
|
1480
|
-
if (typeof form === "number" || typeof form === "string") return form;
|
|
1481
|
-
if (form && typeof form === "object" && "id" in form) {
|
|
1482
|
-
const id = form.id;
|
|
1483
|
-
if (typeof id === "number" || typeof id === "string") return id;
|
|
1484
|
-
}
|
|
1485
|
-
};
|
|
1486
|
-
/**
|
|
1487
|
-
* On a completed submission create, dispatch the form's post-submit actions and emit `submission.created`.
|
|
1488
|
-
* Both are side effects on an already-written row, so the whole body is wrapped: nothing it does can throw
|
|
1489
|
-
* past the hook (a failed action or sink must never fail the submission write). Dispatch is itself bounded
|
|
1490
|
-
* and non-throwing; the inline fallback is awaited so the bound caps the added latency, while the queued
|
|
1491
|
-
* path returns immediately. The form's `actions` are null-guarded for legacy rows created before the field
|
|
1492
|
-
* existed.
|
|
1493
|
-
*/
|
|
1494
|
-
const makeAfterChange = (args) => async ({ doc, operation, req }) => {
|
|
1495
|
-
if (operation !== "create" || doc.status != null && doc.status !== "complete") return doc;
|
|
1496
|
-
const { payload } = req;
|
|
1497
|
-
try {
|
|
1498
|
-
const formId = formIdOf(doc.form);
|
|
1499
|
-
if (formId == null) return doc;
|
|
1500
|
-
await dispatchActions({
|
|
1501
|
-
actions: (await payload.findByID({
|
|
1502
|
-
collection: "forms",
|
|
1503
|
-
id: formId,
|
|
1504
|
-
depth: 0,
|
|
1505
|
-
overrideAccess: true,
|
|
1506
|
-
req
|
|
1507
|
-
}).catch(() => null))?.actions ?? null,
|
|
1508
|
-
formId,
|
|
1509
|
-
submissionId: doc.id,
|
|
1510
|
-
registry: args.actionRegistry,
|
|
1511
|
-
payload,
|
|
1512
|
-
req,
|
|
1513
|
-
hasRunner: args.hasRunner
|
|
1514
|
-
});
|
|
1515
|
-
try {
|
|
1516
|
-
await resolveEventSink(args.events).emit({
|
|
1517
|
-
type: "submission.created",
|
|
1518
|
-
formId: String(formId),
|
|
1519
|
-
submissionId: String(doc.id),
|
|
1520
|
-
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1521
|
-
});
|
|
1522
|
-
} catch (error) {
|
|
1523
|
-
payload.logger?.error(`@10x-media/form-builder: submission.created sink threw: ${error instanceof Error ? error.message : String(error)}`);
|
|
1524
|
-
}
|
|
1525
|
-
} catch (error) {
|
|
1526
|
-
payload.logger?.error(`@10x-media/form-builder: afterChange dispatch failed for submission ${String(doc.id)}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1527
|
-
}
|
|
1528
|
-
return doc;
|
|
1529
|
-
};
|
|
1530
|
-
const buildSubmissionsCollection = ({ registry, ruleRegistry, consentRegistry, actionRegistry = /* @__PURE__ */ new Map(), events, hasRunner = false, uploadSlug, spam }) => ({
|
|
1531
|
-
slug: FORM_SUBMISSIONS_SLUG,
|
|
1532
|
-
labels: {
|
|
1533
|
-
singular: "Submission",
|
|
1534
|
-
plural: "Submissions"
|
|
1535
|
-
},
|
|
1536
|
-
admin: { group: "Forms" },
|
|
1537
|
-
access: {
|
|
1538
|
-
create: () => true,
|
|
1539
|
-
read: ({ req }) => Boolean(req.user),
|
|
1540
|
-
update: () => false
|
|
1541
|
-
},
|
|
1542
|
-
hooks: {
|
|
1543
|
-
beforeValidate: [...spam ? [buildSpamGuard(spam)] : [], validateSubmission({
|
|
1544
|
-
registry,
|
|
1545
|
-
ruleRegistry,
|
|
1546
|
-
consentRegistry,
|
|
1547
|
-
uploadSlug
|
|
1548
|
-
})],
|
|
1549
|
-
afterChange: [makeAfterChange({
|
|
1550
|
-
actionRegistry,
|
|
1551
|
-
events,
|
|
1552
|
-
hasRunner
|
|
1553
|
-
})]
|
|
1554
|
-
},
|
|
1555
|
-
fields: [
|
|
1556
|
-
{
|
|
1557
|
-
name: "form",
|
|
1558
|
-
type: "relationship",
|
|
1559
|
-
relationTo: FORMS_SLUG,
|
|
1560
|
-
required: true
|
|
1561
|
-
},
|
|
1562
|
-
{
|
|
1563
|
-
name: "status",
|
|
1564
|
-
type: "select",
|
|
1565
|
-
defaultValue: "complete",
|
|
1566
|
-
options: [{
|
|
1567
|
-
label: "Complete",
|
|
1568
|
-
value: "complete"
|
|
1569
|
-
}, {
|
|
1570
|
-
label: "Partial",
|
|
1571
|
-
value: "partial"
|
|
1572
|
-
}]
|
|
1573
|
-
},
|
|
1574
|
-
{
|
|
1575
|
-
name: "locale",
|
|
1576
|
-
type: "text"
|
|
1577
|
-
},
|
|
1578
|
-
{
|
|
1579
|
-
name: "values",
|
|
1580
|
-
type: "json"
|
|
1581
|
-
},
|
|
1582
|
-
{
|
|
1583
|
-
name: "descriptors",
|
|
1584
|
-
type: "json"
|
|
1585
|
-
},
|
|
1586
|
-
{
|
|
1587
|
-
name: "consent",
|
|
1588
|
-
type: "json"
|
|
1589
|
-
},
|
|
1590
|
-
{
|
|
1591
|
-
name: "meta",
|
|
1592
|
-
type: "json"
|
|
1593
|
-
},
|
|
1594
|
-
{
|
|
1595
|
-
name: "answers",
|
|
1596
|
-
type: "ui",
|
|
1597
|
-
admin: { components: { Field: "@10x-media/form-builder/rsc#SubmissionAnswers" } }
|
|
1598
|
-
}
|
|
1599
|
-
]
|
|
1600
|
-
});
|
|
1601
|
-
//#endregion
|
|
1602
|
-
//#region src/actions/runActions.ts
|
|
1603
|
-
/**
|
|
1604
|
-
* Run each configured action, isolating failures: a throwing action is captured as a failed result
|
|
1605
|
-
* and never breaks the others or the caller.
|
|
1606
|
-
*/
|
|
1607
|
-
const runActions = async (args) => {
|
|
1608
|
-
const { actions, registry, ...ctx } = args;
|
|
1609
|
-
const results = [];
|
|
1610
|
-
for (const instance of actions) {
|
|
1611
|
-
const definition = registry.get(instance.blockType);
|
|
1612
|
-
if (!definition) continue;
|
|
1613
|
-
try {
|
|
1614
|
-
await definition.run({
|
|
1615
|
-
...ctx,
|
|
1616
|
-
config: instance
|
|
1617
|
-
});
|
|
1618
|
-
results.push({
|
|
1619
|
-
type: instance.blockType,
|
|
1620
|
-
ok: true
|
|
1621
|
-
});
|
|
1622
|
-
} catch (error) {
|
|
1623
|
-
results.push({
|
|
1624
|
-
type: instance.blockType,
|
|
1625
|
-
ok: false,
|
|
1626
|
-
error: error instanceof Error ? error.message : String(error)
|
|
1627
|
-
});
|
|
1628
|
-
}
|
|
1629
|
-
}
|
|
1630
|
-
return results;
|
|
1631
|
-
};
|
|
1632
|
-
//#endregion
|
|
1633
|
-
//#region src/actions/task.ts
|
|
1634
|
-
const ACTIONS_TASK_SLUG = "form-builder-actions";
|
|
1635
|
-
const asActions = (value) => Array.isArray(value) ? value : [];
|
|
1636
|
-
const asValues = (value) => Array.isArray(value) ? value : [];
|
|
1637
|
-
const asDescriptors = (value) => Array.isArray(value) ? value : [];
|
|
1638
|
-
/**
|
|
1639
|
-
* Load the form and submission by id and run the form's actions through the shared, failure-isolating
|
|
1640
|
-
* `runActions`. Tolerates a missing form or submission (the row may have been deleted between enqueue and
|
|
1641
|
-
* run) by returning early. Used by both the queued task handler and the inline fallback.
|
|
1642
|
-
*/
|
|
1643
|
-
const runActionsForSubmission = async (args) => {
|
|
1644
|
-
const { input, registry, payload, req } = args;
|
|
1645
|
-
const form = await payload.findByID({
|
|
1646
|
-
collection: FORMS_SLUG,
|
|
1647
|
-
id: input.formId,
|
|
1648
|
-
depth: 0,
|
|
1649
|
-
overrideAccess: true,
|
|
1650
|
-
req
|
|
1651
|
-
}).catch(() => null);
|
|
1652
|
-
if (!form) return;
|
|
1653
|
-
const submission = await payload.findByID({
|
|
1654
|
-
collection: FORM_SUBMISSIONS_SLUG,
|
|
1655
|
-
id: input.submissionId,
|
|
1656
|
-
depth: 0,
|
|
1657
|
-
overrideAccess: true,
|
|
1658
|
-
req
|
|
1659
|
-
}).catch(() => null);
|
|
1660
|
-
if (!submission) return;
|
|
1661
|
-
const locale = typeof submission.locale === "string" ? submission.locale : req?.locale ?? "en";
|
|
1662
|
-
const t = asFieldTranslate(req?.i18n?.t ?? ((key) => key));
|
|
1663
|
-
await runActions({
|
|
1664
|
-
actions: asActions(form.actions),
|
|
1665
|
-
registry,
|
|
1666
|
-
form: {
|
|
1667
|
-
id: form.id,
|
|
1668
|
-
title: typeof form.title === "string" ? form.title : void 0
|
|
1669
|
-
},
|
|
1670
|
-
submissionId: submission.id,
|
|
1671
|
-
values: asValues(submission.values),
|
|
1672
|
-
descriptors: asDescriptors(submission.descriptors),
|
|
1673
|
-
payload,
|
|
1674
|
-
req,
|
|
1675
|
-
locale,
|
|
1676
|
-
t
|
|
1677
|
-
});
|
|
1678
|
-
};
|
|
1679
|
-
/** Native Payload jobs task that runs a submission's post-submit actions out of band. */
|
|
1680
|
-
const buildActionsTask = (registry) => ({
|
|
1681
|
-
slug: ACTIONS_TASK_SLUG,
|
|
1682
|
-
inputSchema: [{
|
|
1683
|
-
name: "formId",
|
|
1684
|
-
type: "text",
|
|
1685
|
-
required: true
|
|
1686
|
-
}, {
|
|
1687
|
-
name: "submissionId",
|
|
1688
|
-
type: "text",
|
|
1689
|
-
required: true
|
|
1690
|
-
}],
|
|
1691
|
-
handler: async ({ input, req }) => {
|
|
1692
|
-
await runActionsForSubmission({
|
|
1693
|
-
input,
|
|
1694
|
-
registry,
|
|
1695
|
-
payload: req.payload,
|
|
1696
|
-
req
|
|
1697
|
-
});
|
|
1698
|
-
return { output: {} };
|
|
1699
|
-
}
|
|
1700
|
-
});
|
|
1701
|
-
/** Register the actions task on `config.jobs.tasks`, creating the jobs config if absent. */
|
|
1702
|
-
const registerActionsTask = (config, registry) => {
|
|
1703
|
-
config.jobs ??= {};
|
|
1704
|
-
config.jobs.tasks ??= [];
|
|
1705
|
-
config.jobs.tasks.push(buildActionsTask(registry));
|
|
1706
|
-
};
|
|
1707
|
-
//#endregion
|
|
1708
|
-
//#region src/spam/uploadHooks.ts
|
|
1709
|
-
/**
|
|
1710
|
-
* Stamp the resolved request identity onto a new upload's `owner`. `captureFileRef` later requires the
|
|
1711
|
-
* referencing submission's identity to match, so an anonymous submitter cannot capture another identity's
|
|
1712
|
-
* upload. No identity (no user, no trusted IP header) means no stamp, so the upload stays unscoped (a
|
|
1713
|
-
* deliberate fail-open; identity granularity is IP-level, so same-network clients share scope -- documented).
|
|
1714
|
-
*/
|
|
1715
|
-
const buildUploadOwnerStamp = (spam) => async ({ data, operation, req }) => {
|
|
1716
|
-
if (operation !== "create" || !data) return data;
|
|
1717
|
-
const identity = await spam.identify(req);
|
|
1718
|
-
if (identity != null && data.owner == null) data.owner = identity;
|
|
1719
|
-
return data;
|
|
1720
|
-
};
|
|
1721
|
-
/** Reject upload floods per identity before the file is written to `staticDir`. Fail-open without identity. */
|
|
1722
|
-
const buildUploadRateLimit = (spam) => async ({ operation, req }) => {
|
|
1723
|
-
if (operation !== "create" || spam.uploadRateLimit === false) return;
|
|
1724
|
-
const identity = await spam.identify(req);
|
|
1725
|
-
if (identity == null) return;
|
|
1726
|
-
const { ok } = await spam.uploadRateLimit.limiter.check({
|
|
1727
|
-
key: `uploads:${identity}`,
|
|
1728
|
-
max: spam.uploadRateLimit.max,
|
|
1729
|
-
window: spam.uploadRateLimit.window,
|
|
1730
|
-
req
|
|
1731
|
-
});
|
|
1732
|
-
if (!ok) throw new APIError(asTranslate(req.i18n.t)(keys.spamRateLimited), 429);
|
|
1733
|
-
};
|
|
1734
|
-
//#endregion
|
|
1735
|
-
//#region src/plugin/registerCollections.ts
|
|
1736
|
-
const registerCollections = ({ config, registry, ruleRegistry, consentRegistry, presentationRegistry, actionRegistry, hasJobsPlugin, events, uploads, spam }) => {
|
|
1737
|
-
registerActionsTask(config, actionRegistry);
|
|
1738
|
-
const hasRunner = Boolean(config.jobs?.autoRun) || hasJobsPlugin;
|
|
1739
|
-
if (spam && uploads.enabled && uploads.collection) {
|
|
1740
|
-
const collection = uploads.collection;
|
|
1741
|
-
collection.hooks = collection.hooks ?? {};
|
|
1742
|
-
collection.hooks.beforeOperation = [...collection.hooks.beforeOperation ?? [], buildUploadRateLimit(spam)];
|
|
1743
|
-
collection.hooks.beforeValidate = [...collection.hooks.beforeValidate ?? [], buildUploadOwnerStamp(spam)];
|
|
1744
|
-
}
|
|
1745
|
-
config.collections = [
|
|
1746
|
-
...config.collections ?? [],
|
|
1747
|
-
...uploads.enabled && uploads.collection ? [uploads.collection] : [],
|
|
1748
|
-
buildFormsCollection({
|
|
1749
|
-
registry,
|
|
1750
|
-
ruleRegistry,
|
|
1751
|
-
presentationRegistry,
|
|
1752
|
-
actionRegistry
|
|
1753
|
-
}),
|
|
1754
|
-
buildSubmissionsCollection({
|
|
1755
|
-
registry,
|
|
1756
|
-
ruleRegistry,
|
|
1757
|
-
consentRegistry,
|
|
1758
|
-
actionRegistry,
|
|
1759
|
-
events,
|
|
1760
|
-
hasRunner,
|
|
1761
|
-
uploadSlug: uploads.slug,
|
|
1762
|
-
spam
|
|
1763
|
-
})
|
|
1764
|
-
];
|
|
1765
|
-
};
|
|
1766
|
-
//#endregion
|
|
1767
|
-
//#region src/plugin/registerTranslations.ts
|
|
1768
|
-
/**
|
|
1769
|
-
* Merge this plugin's translations into the host config. A host value wins on
|
|
1770
|
-
* conflict (`deepMergeSimple` lets the second argument override), so projects can
|
|
1771
|
-
* override any string.
|
|
1772
|
-
*/
|
|
1773
|
-
const registerTranslations = (config) => {
|
|
1774
|
-
config.i18n ??= {};
|
|
1775
|
-
config.i18n.translations = deepMergeSimple(translations, config.i18n.translations ?? {});
|
|
1776
|
-
};
|
|
1777
|
-
//#endregion
|
|
1778
|
-
//#region src/presentations/registry.ts
|
|
1779
|
-
/**
|
|
1780
|
-
* Resolve the active presentation descriptors from the defaults and a consumer override map.
|
|
1781
|
-
* Mirrors the field-type, validation-rule, and renderer registry convention.
|
|
1782
|
-
*/
|
|
1783
|
-
const resolvePresentationDescriptors = (defaults, config = {}) => {
|
|
1784
|
-
const registry = new Map(Object.entries(defaults));
|
|
1785
|
-
for (const [name, option] of Object.entries(config)) if (option === false) registry.delete(name);
|
|
1786
|
-
else if (option === true) {} else registry.set(name, option);
|
|
1787
|
-
return registry;
|
|
1788
|
-
};
|
|
1789
|
-
//#endregion
|
|
1790
|
-
//#region src/spam/identify.ts
|
|
1791
|
-
/**
|
|
1792
|
-
* Default identity resolution for rate-limiting + upload ownership. Prefers an authenticated user id
|
|
1793
|
-
* (trustworthy); else the first hop of the configured trusted IP header (best-effort, proxy-dependent);
|
|
1794
|
-
* else null, in which case the caller fails open (cannot fairly rate-limit an unidentifiable client).
|
|
1795
|
-
*/
|
|
1796
|
-
const defaultIdentify = (ipHeader) => (req) => {
|
|
1797
|
-
const userId = req.user?.id;
|
|
1798
|
-
if (userId != null) return `user:${String(userId)}`;
|
|
1799
|
-
const header = req.headers?.get(ipHeader);
|
|
1800
|
-
if (header) {
|
|
1801
|
-
const first = header.split(",")[0]?.trim();
|
|
1802
|
-
if (first) return `ip:${first}`;
|
|
1803
|
-
}
|
|
1804
|
-
return null;
|
|
1805
|
-
};
|
|
1806
|
-
//#endregion
|
|
1807
|
-
//#region src/spam/rateLimiter.ts
|
|
1808
|
-
/**
|
|
1809
|
-
* The default rate limiter: a read-modify-write window counter over Payload's `payload.kv`
|
|
1810
|
-
* (always present; default `DatabaseKVAdapter`, durable + cross-instance). Because the KV interface
|
|
1811
|
-
* has no atomic increment, the get-then-set is NON-ATOMIC, so a concurrent burst can slightly undercount.
|
|
1812
|
-
* This is a SOFT limit, acceptable for spam basics and complemented by edge/WAF limiting. The window
|
|
1813
|
-
* expires lazily on read; stale keys for one-time identities linger (minor; the count self-resets).
|
|
1814
|
-
* `now` is injectable for tests.
|
|
1815
|
-
*/
|
|
1816
|
-
const createKvRateLimiter = (options = {}) => {
|
|
1817
|
-
const now = options.now ?? (() => Date.now());
|
|
1818
|
-
const prefix = options.namespace ?? "fb:rl:";
|
|
1819
|
-
return { async check({ key, max, window, req }) {
|
|
1820
|
-
const kv = req.payload.kv;
|
|
1821
|
-
const storeKey = `${prefix}${key}`;
|
|
1822
|
-
const current = now();
|
|
1823
|
-
const existing = await kv.get(storeKey).catch(() => null);
|
|
1824
|
-
let count;
|
|
1825
|
-
let windowStart;
|
|
1826
|
-
if (!existing || current - existing.windowStart >= window) {
|
|
1827
|
-
count = 1;
|
|
1828
|
-
windowStart = current;
|
|
1829
|
-
} else {
|
|
1830
|
-
count = existing.count + 1;
|
|
1831
|
-
windowStart = existing.windowStart;
|
|
1832
|
-
}
|
|
1833
|
-
await kv.set(storeKey, {
|
|
1834
|
-
count,
|
|
1835
|
-
windowStart
|
|
1836
|
-
}).catch(() => void 0);
|
|
1837
|
-
return {
|
|
1838
|
-
ok: count <= max,
|
|
1839
|
-
remaining: Math.max(0, max - count),
|
|
1840
|
-
resetAt: windowStart + window
|
|
1841
|
-
};
|
|
1842
|
-
} };
|
|
1843
|
-
};
|
|
1844
|
-
//#endregion
|
|
1845
|
-
//#region src/spam/resolveSpam.ts
|
|
1846
|
-
const DEFAULT_WINDOW = 6e4;
|
|
1847
|
-
const DEFAULT_SUBMISSION_MAX = 5;
|
|
1848
|
-
const DEFAULT_UPLOAD_MAX = 20;
|
|
1849
|
-
const resolveRateLimit = (option, defaultMax) => {
|
|
1850
|
-
if (option === false) return false;
|
|
1851
|
-
const cfg = option ?? {};
|
|
1852
|
-
return {
|
|
1853
|
-
window: cfg.window ?? DEFAULT_WINDOW,
|
|
1854
|
-
max: cfg.max ?? defaultMax,
|
|
1855
|
-
limiter: cfg.limiter ?? createKvRateLimiter()
|
|
1856
|
-
};
|
|
1857
|
-
};
|
|
1858
|
-
/**
|
|
1859
|
-
* Resolve the `spam` plugin option into a concrete config. `false` disables the whole subsystem.
|
|
1860
|
-
* Otherwise honeypot + both rate limits default on, captcha + metadata capture default off, and the
|
|
1861
|
-
* identity seam defaults to user id / trusted IP header.
|
|
1862
|
-
*/
|
|
1863
|
-
const resolveSpamConfig = (option) => {
|
|
1864
|
-
if (option === false) return false;
|
|
1865
|
-
const cfg = option ?? {};
|
|
1866
|
-
const ipHeader = cfg.ipHeader ?? "x-forwarded-for";
|
|
1867
|
-
return {
|
|
1868
|
-
honeypot: cfg.honeypot === false ? false : { fieldName: cfg.honeypot?.fieldName ?? "confirm_email" },
|
|
1869
|
-
rateLimit: resolveRateLimit(cfg.rateLimit, DEFAULT_SUBMISSION_MAX),
|
|
1870
|
-
uploadRateLimit: resolveRateLimit(cfg.uploadRateLimit, DEFAULT_UPLOAD_MAX),
|
|
1871
|
-
captcha: cfg.captcha,
|
|
1872
|
-
identify: cfg.identify ?? defaultIdentify(ipHeader),
|
|
1873
|
-
ipHeader,
|
|
1874
|
-
metadata: {
|
|
1875
|
-
ip: cfg.metadata?.ip ?? false,
|
|
1876
|
-
ua: cfg.metadata?.ua ?? false
|
|
1877
|
-
}
|
|
1878
|
-
};
|
|
1879
|
-
};
|
|
1880
|
-
//#endregion
|
|
1881
|
-
//#region src/spam/captcha.ts
|
|
1882
|
-
/**
|
|
1883
|
-
* Identity helper for authoring a captcha provider (mirrors `defineAction`/`defineConsentSource`).
|
|
1884
|
-
* v1 ships no built-in provider; a project supplies one (Turnstile/reCAPTCHA/hCaptcha) and the submit
|
|
1885
|
-
* path verifies a token carried on the submission. Prebuilt providers are a v1.x addition.
|
|
1886
|
-
*/
|
|
1887
|
-
const defineCaptchaProvider = (provider) => provider;
|
|
1888
|
-
//#endregion
|
|
1
|
+
import { interpolate } from "./recall/interpolate.js";
|
|
2
|
+
import { defineAction } from "./actions/defineAction.js";
|
|
3
|
+
import { SIGNATURE_HEADER, signPayload } from "./actions/sign.js";
|
|
4
|
+
import { defaultActionDefinitions } from "./actions/builtin/index.js";
|
|
5
|
+
import { resolveActions } from "./actions/registry.js";
|
|
6
|
+
import { FORM_UPLOADS_SLUG, buildUploadsCollection, resolveUploads } from "./collections/uploads.js";
|
|
7
|
+
import { defineConsentSource } from "./consent/defineConsentSource.js";
|
|
8
|
+
import { resolvePublishedVersionRef } from "./consent/resolvePublishedVersionRef.js";
|
|
9
|
+
import { defaultConsentSources } from "./consent/builtin/index.js";
|
|
10
|
+
import { resolveConsentSources } from "./consent/registry.js";
|
|
11
|
+
import { defineFormField } from "./fields/defineFormField.js";
|
|
12
|
+
import { defaultFieldDefinitions } from "./fields/builtin/index.js";
|
|
13
|
+
import { resolveFieldTypes } from "./fields/registry.js";
|
|
14
|
+
import { CAPTCHA_TOKEN_KEY, DEFAULT_HONEYPOT_FIELD } from "./spam/constants.js";
|
|
15
|
+
import { aggregateRowForField, aggregateRowsForFields } from "./aggregation/aggregateRows.js";
|
|
16
|
+
import { aggregateFieldResponses, aggregateFormResponses, fieldHasOptions } from "./aggregation/aggregateResponses.js";
|
|
17
|
+
import { resolveFormResultsRequest } from "./aggregation/resolveResultsRequest.js";
|
|
18
|
+
import { normalizeCalc } from "./calc/normalizeCalc.js";
|
|
19
|
+
import { DEFAULT_PRESENTATION_NAME, defaultPresentationDescriptors } from "./presentations/defaults.js";
|
|
20
|
+
import { evaluateCalc } from "./calc/evaluate.js";
|
|
21
|
+
import { calcExpressionOf, computeCalcFields } from "./calc/computeCalcFields.js";
|
|
22
|
+
import { evaluateCondition } from "./conditions/evaluate.js";
|
|
23
|
+
import { resolveConsentLinks } from "./consent/resolveConsentLinks.js";
|
|
24
|
+
import { captureConsent } from "./consent/captureConsent.js";
|
|
25
|
+
import { resolveFileRef } from "./uploads/resolveFileRef.js";
|
|
26
|
+
import { captureFileRef } from "./uploads/captureFileRef.js";
|
|
27
|
+
import { registerCollections } from "./plugin/registerCollections.js";
|
|
28
|
+
import { registerTranslations } from "./plugin/registerTranslations.js";
|
|
29
|
+
import { resolvePresentationDescriptors } from "./presentations/registry.js";
|
|
30
|
+
import { defaultIdentify } from "./spam/identify.js";
|
|
31
|
+
import { createKvRateLimiter } from "./spam/rateLimiter.js";
|
|
32
|
+
import { resolveSpamConfig } from "./spam/resolveSpam.js";
|
|
33
|
+
import { defineValidationRule } from "./validation/defineValidationRule.js";
|
|
34
|
+
import { defaultValidationRules } from "./validation/builtin/index.js";
|
|
35
|
+
import { resolveValidationRules } from "./validation/registry.js";
|
|
36
|
+
import { valuesFromSearchParams } from "./prefill/valuesFromSearchParams.js";
|
|
37
|
+
import { buildRecallResolver, optionLabelsFor } from "./recall/resolver.js";
|
|
38
|
+
import { defineCaptchaProvider } from "./spam/captcha.js";
|
|
39
|
+
import { definePlugin } from "payload";
|
|
1889
40
|
//#region src/index.ts
|
|
1890
41
|
const formBuilder = definePlugin({
|
|
1891
42
|
slug: "@10x-media/form-builder",
|
|
@@ -1899,7 +50,7 @@ const formBuilder = definePlugin({
|
|
|
1899
50
|
const actionRegistry = resolveActions(defaultActionDefinitions, options.actions);
|
|
1900
51
|
const uploads = resolveUploads(options.uploads);
|
|
1901
52
|
const spam = resolveSpamConfig(options.spam);
|
|
1902
|
-
registerTranslations(config);
|
|
53
|
+
registerTranslations(config, options.translations);
|
|
1903
54
|
registerCollections({
|
|
1904
55
|
config,
|
|
1905
56
|
registry,
|