├── .husky ├── php-cs-fixer-lint-staged └── pre-commit ├── .php-cs-fixer.dist.php ├── .prettierrc ├── .vscode └── settings.json ├── blueprints ├── fields │ ├── actions.php │ ├── email-template.php │ ├── error-message.yml │ ├── field.php │ ├── fields.php │ ├── form.php │ ├── key.yml │ ├── label.yml │ ├── options.yml │ ├── placeholder.yml │ ├── required.yml │ ├── static-dynamic-toggles.yml │ ├── success.yml │ ├── writer-with-fields.php │ └── writer.php ├── files │ └── dreamform-upload.yml ├── pages │ ├── form.php │ ├── forms.php │ └── submission.php └── tabs │ └── form-submissions.php ├── classes ├── Actions │ ├── AbortAction.php │ ├── Action.php │ ├── BrevoAction.php │ ├── ButtondownAction.php │ ├── ConditionalAction.php │ ├── DiscordWebhookAction.php │ ├── EmailAction.php │ ├── LoopsAction.php │ ├── MailchimpAction.php │ ├── PlausibleAction.php │ ├── RedirectAction.php │ └── WebhookAction.php ├── DreamForm.php ├── Exceptions │ ├── PerformerException.php │ └── SuccessException.php ├── Fields │ ├── ButtonField.php │ ├── CheckboxField.php │ ├── EmailField.php │ ├── Field.php │ ├── FileUploadField.php │ ├── HiddenField.php │ ├── NumberField.php │ ├── PagesField.php │ ├── RadioField.php │ ├── SelectField.php │ ├── TextField.php │ └── TextareaField.php ├── Guards │ ├── AkismetGuard.php │ ├── CsrfGuard.php │ ├── Guard.php │ ├── HCaptchaGuard.php │ ├── HoneypotGuard.php │ ├── LicenseGuard.php │ ├── RatelimitGuard.php │ └── TurnstileGuard.php ├── Models │ ├── BasePage.php │ ├── FormPage.php │ ├── FormsPage.php │ ├── Log │ │ ├── HasSubmissionLog.php │ │ ├── SubmissionLog.php │ │ └── SubmissionLogEntry.php │ ├── SubmissionHandling.php │ ├── SubmissionMetadata.php │ ├── SubmissionPage.php │ └── SubmissionSession.php ├── Performer.php ├── Permissions │ ├── FormPermissions.php │ └── SubmissionPermissions.php ├── Storage │ ├── SubmissionCacheStorage.php │ └── SubmissionSessionStorage.php └── Support │ ├── HasCache.php │ ├── Htmx.php │ ├── License.php │ └── Menu.php ├── composer.json ├── config ├── api.php ├── areas.php ├── blockMethods.php ├── fields.php ├── hooks.php ├── options.php └── sections.php ├── eslint.config.mjs ├── i18n.json ├── i18n.lock ├── index.css ├── index.js ├── index.php ├── jsconfig.json ├── package.json ├── pnpm-workspace.yaml ├── public.pem ├── readme.md ├── snippets ├── fields │ ├── button.php │ ├── checkbox.php │ ├── email.php │ ├── file-upload.php │ ├── hidden.php │ ├── number.php │ ├── pages.php │ ├── partials │ │ ├── error.php │ │ ├── label.php │ │ └── wrapper.php │ ├── radio.php │ ├── select.php │ ├── text.php │ └── textarea.php ├── form.php ├── guards.php ├── guards │ ├── csrf.php │ ├── hcaptcha.php │ ├── honeypot.php │ └── turnstile.php ├── inactive.php ├── session.php └── success.php ├── src ├── components │ ├── DynamicFieldPreview.vue │ ├── Editable.vue │ ├── FieldError.vue │ ├── FieldHeader.vue │ ├── FieldInput.vue │ ├── Options.vue │ └── log │ │ ├── EmailEntry.vue │ │ ├── EntryBase.vue │ │ ├── ErrorEntry.vue │ │ └── InfoEntry.vue ├── core │ ├── Layout.vue │ └── LayoutSelector.vue ├── fields │ ├── ApiObject.vue │ └── DynamicField.vue ├── index.css ├── index.js ├── previews │ ├── ButtonField.vue │ ├── ChoicesField.vue │ ├── FileUploadField.vue │ ├── HiddenField.vue │ ├── SelectField.vue │ └── TextField.vue ├── sections │ ├── License.vue │ └── Submission.vue └── utils │ ├── block.js │ └── date.js ├── templates └── emails │ ├── dreamform.html.php │ ├── dreamform.mjml │ └── dreamform.php ├── translations ├── cs.json ├── de.json ├── en.json ├── es.json ├── fr.json ├── it.json └── nl.json └── vendor ├── autoload.php └── composer ├── ClassLoader.php ├── InstalledVersions.php ├── autoload_classmap.php ├── autoload_namespaces.php ├── autoload_psr4.php ├── autoload_real.php ├── autoload_static.php ├── installed.php └── platform_check.php /.husky/php-cs-fixer-lint-staged: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | PHP_CS_FIXER_IGNORE_ENV=1 php-cs-fixer fix --config=.php-cs-fixer.dist.php "$@" -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpm exec lint-staged 2 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | exclude('vendor') 5 | ->in(__DIR__); 6 | 7 | $config = new PhpCsFixer\Config(); 8 | return $config 9 | ->setRules([ 10 | '@PSR12' => true, 11 | 'array_indentation' => true, 12 | ]) 13 | ->setRiskyAllowed(true) 14 | ->setIndent("\t") 15 | ->setFinder($finder); 16 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "trailingComma": "none", 4 | "useTabs": true, 5 | "tabWidth": 2 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "intelephense.environment.includePaths": [ 3 | "./../../.." // root of kirby site 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /blueprints/fields/actions.php: -------------------------------------------------------------------------------- 1 | $action) { 9 | if (!isset($fieldsets[$group = $action::group()])) { 10 | $fieldsets[$group] = [ 11 | 'label' => t("dreamform.actions.category.{$group}"), 12 | 'type' => 'group', 13 | 'fieldsets' => [] 14 | ]; 15 | } 16 | 17 | $fieldsets[$group]['fieldsets']["{$type}-action"] = $action::blueprint(); 18 | } 19 | 20 | return [ 21 | 'label' => t('dreamform.actions.label'), 22 | 'type' => 'blocks', 23 | 'empty' => t('dreamform.actions.empty'), 24 | 'fieldsets' => $fieldsets 25 | ]; 26 | }; 27 | -------------------------------------------------------------------------------- /blueprints/fields/email-template.php: -------------------------------------------------------------------------------- 1 | root('templates') . '/emails'); 9 | $templates = array_unique(A::map($templates, fn ($name) => Str::split($name, '.')[0])); 10 | 11 | return [ 12 | 'label' => t('dreamform.actions.email.templateType.kirby'), 13 | 'type' => 'select', 14 | 'default' => $templates[0] ?? null, 15 | 'options' => $templates, 16 | ]; 17 | }; 18 | -------------------------------------------------------------------------------- /blueprints/fields/error-message.yml: -------------------------------------------------------------------------------- 1 | label: dreamform.common.errorMessage.label 2 | type: text 3 | help: dreamform.common.errorMessage.help 4 | placeholder: dreamform.fields.error.required 5 | -------------------------------------------------------------------------------- /blueprints/fields/field.php: -------------------------------------------------------------------------------- 1 | t('dreamform.field'), 10 | 'type' => 'select', 11 | 'options' => $fields, 12 | ]; 13 | }; 14 | -------------------------------------------------------------------------------- /blueprints/fields/fields.php: -------------------------------------------------------------------------------- 1 | $field) { 11 | if (!isset($fieldsets[$group = $field::group()])) { 12 | $fieldsets[$group] = [ 13 | 'label' => t("dreamform.fields.category.{$group}"), 14 | 'type' => 'group', 15 | 'fieldsets' => [] 16 | ]; 17 | } 18 | 19 | $fieldsets[$group]['fieldsets']["{$type}-field"] = $field::blueprint(); 20 | } 21 | 22 | return [ 23 | 'label' => t('dreamform.fields'), 24 | 'type' => 'layout', 25 | 'layouts' => DreamForm::option('multiStep', true) ? ["dreamform-page", ...$layouts] : $layouts, 26 | 'fieldsets' => $fieldsets 27 | ]; 28 | }; 29 | -------------------------------------------------------------------------------- /blueprints/fields/form.php: -------------------------------------------------------------------------------- 1 | user()->role()->permissions()->for('tobimori.dreamform', 'accessForms')) { 8 | return [ 9 | 'type' => 'hidden', 10 | ]; 11 | } 12 | 13 | $page = DreamForm::option('page', 'page://forms'); 14 | 15 | return [ 16 | 'label' => t('dreamform.form'), 17 | 'type' => 'pages', 18 | 'query' => "page('{$page}').children.listed.filterBy('intendedTemplate', 'form')", 19 | 'empty' => t('dreamform.form.empty'), 20 | 'multiple' => false 21 | ]; 22 | }; 23 | -------------------------------------------------------------------------------- /blueprints/fields/key.yml: -------------------------------------------------------------------------------- 1 | label: dreamform.common.key.label 2 | type: slug 3 | required: true 4 | width: 1/2 5 | icon: false # none would show an empty icon which cuts of the input too early 6 | wizard: 7 | field: label 8 | text: "​" # TODO: find a better way to add an empty text 9 | allow: a-zA-Z0-9_ # we need to allow underscores for url params 10 | -------------------------------------------------------------------------------- /blueprints/fields/label.yml: -------------------------------------------------------------------------------- 1 | label: dreamform.common.label.label 2 | type: text 3 | required: true 4 | -------------------------------------------------------------------------------- /blueprints/fields/options.yml: -------------------------------------------------------------------------------- 1 | label: dreamform.common.options.label 2 | type: structure 3 | columns: 4 | label: 5 | width: 2/3 6 | value: 7 | width: 1/3 8 | fields: 9 | value: 10 | label: dreamform.common.options.value.label 11 | help: dreamform.common.options.value.help 12 | type: text 13 | required: true 14 | label: 15 | extends: dreamform/fields/writer 16 | label: dreamform.common.label.label 17 | help: dreamform.common.options.label.help 18 | required: true 19 | -------------------------------------------------------------------------------- /blueprints/fields/placeholder.yml: -------------------------------------------------------------------------------- 1 | label: dreamform.common.placeholder.label 2 | type: text 3 | -------------------------------------------------------------------------------- /blueprints/fields/required.yml: -------------------------------------------------------------------------------- 1 | label: dreamform.common.required.label 2 | type: toggle 3 | width: 1/2 4 | text: 5 | - dreamform.common.required.false 6 | - dreamform.common.required.true 7 | -------------------------------------------------------------------------------- /blueprints/fields/static-dynamic-toggles.yml: -------------------------------------------------------------------------------- 1 | type: toggles 2 | required: true 3 | width: 1/4 4 | default: static 5 | options: 6 | static: '{{ t("dreamform.static") }}' 7 | field: '{{ t("dreamform.fromField") }}' 8 | -------------------------------------------------------------------------------- /blueprints/fields/success.yml: -------------------------------------------------------------------------------- 1 | fields: 2 | successMessage: 3 | label: dreamform.form.successMessage.label 4 | placeholder: dreamform.form.successMessage.default 5 | type: writer 6 | extends: dreamform/fields/writer 7 | -------------------------------------------------------------------------------- /blueprints/fields/writer-with-fields.php: -------------------------------------------------------------------------------- 1 | 'writer', 11 | 'toolbar' => [ 12 | 'inline' => false 13 | ], 14 | 'marks' => DreamForm::option('marks'), 15 | ]; 16 | }; 17 | -------------------------------------------------------------------------------- /blueprints/fields/writer.php: -------------------------------------------------------------------------------- 1 | 'writer', 8 | 'marks' => DreamForm::option('marks'), 9 | 'inline' => true, 10 | 'toolbar' => [ 11 | 'inline' => false 12 | ] 13 | ]; 14 | }; 15 | -------------------------------------------------------------------------------- /blueprints/files/dreamform-upload.yml: -------------------------------------------------------------------------------- 1 | options: 2 | delete: false 3 | changeTemplate: false 4 | replace: false 5 | update: false 6 | 7 | fields: 8 | hidden: true 9 | -------------------------------------------------------------------------------- /blueprints/pages/form.php: -------------------------------------------------------------------------------- 1 | 'dreamform.form', 11 | 'image' => [ 12 | 'icon' => 'survey', 13 | 'query' => 'icon', 14 | 'back' => 'transparent' 15 | ], 16 | 'num' => 0, 17 | 'options' => [ 18 | 'preview' => false, 19 | 'move' => false 20 | ], 21 | 'create' => [ 22 | 'title' => [ 23 | 'label' => 'dreamform.form.formName.label' 24 | ] 25 | ], 26 | 'status' => [ 27 | 'draft' => [ 28 | 'label' => 'dreamform.form.status.draft.label', 29 | 'text' => 'dreamform.form.status.draft.text' 30 | ], 31 | 'unlisted' => false, 32 | 'listed' => [ 33 | 'label' => 'dreamform.form.status.listed.label', 34 | 'text' => 'dreamform.form.status.listed.text' 35 | ] 36 | ], 37 | 'tabs' => [ 38 | 'fields' => [ 39 | 'label' => 'dreamform.fields', 40 | 'icon' => 'input-cursor-move', 41 | 'fields' => [ 42 | 'fields' => 'dreamform/fields/fields' 43 | ] 44 | ], 45 | 'workflow' => [ 46 | 'label' => 'dreamform.workflow', 47 | 'icon' => 'folder-structure', 48 | 'fields' => [ 49 | 'actions' => 'dreamform/fields/actions' 50 | ] 51 | ], 52 | 'submissions' => App::instance()->user()?->role()->permissions()->for('tobimori.dreamform', 'accessSubmissions') ? 'dreamform/tabs/form-submissions' : false, 53 | 'settings' => [ 54 | 'label' => 'dreamform.settings', 55 | 'icon' => 'cog', 56 | 'columns' => A::merge( 57 | [ 58 | [ 59 | 'width' => '1/4', 60 | 'fields' => [ 61 | '_success' => [ 62 | 'label' => 'dreamform.form.successPage.label', 63 | 'type' => 'headline' 64 | ] 65 | ] 66 | ], 67 | [ 68 | 'width' => '3/4', 69 | 'fields' => [ 70 | 'success' => [ 71 | 'type' => 'group', 72 | 'extends' => 'dreamform/fields/success' 73 | ] 74 | ] 75 | ], 76 | [ 77 | 'width' => '1', 78 | 'fields' => [ 79 | '_line' => [ 80 | 'type' => 'line' 81 | ] 82 | ] 83 | ], 84 | [ 85 | 'width' => '1/4', 86 | 'fields' => [ 87 | '_workflow' => [ 88 | 'label' => 'dreamform.workflow', 89 | 'type' => 'headline' 90 | ] 91 | ] 92 | ], 93 | [ 94 | 'width' => '3/4', 95 | 'fields' => [ 96 | 'continueOnError' => [ 97 | 'label' => 'dreamform.form.continueOnError.label', 98 | 'type' => 'toggle', 99 | 'help' => 'dreamform.form.continueOnError.help', 100 | 'width' => '1/2' 101 | ] 102 | ] 103 | ], 104 | ], 105 | DreamForm::option('storeSubmissions') ? [ 106 | [ 107 | 'width' => '1', 108 | 'fields' => [ 109 | '_line2' => [ 110 | 'type' => 'line' 111 | ] 112 | ] 113 | ], 114 | [ 115 | 'width' => '1/4', 116 | 'fields' => [ 117 | '_submissions' => [ 118 | 'label' => 'dreamform.submissions', 119 | 'type' => 'headline' 120 | ] 121 | ] 122 | ], 123 | [ 124 | 'width' => '3/4', 125 | 'fields' => [ 126 | 'storeSubmissions' => [ 127 | 'label' => 'dreamform.form.storeSubmissions.label', 128 | 'type' => 'toggle', 129 | 'default' => true, 130 | 'help' => 'dreamform.form.storeSubmissions.help', 131 | 'width' => '1/2' 132 | ], 133 | 'partialSubmissions' => Htmx::isActive() && DreamForm::option('precognition') && DreamForm::option('partialSubmissions') ? [ 134 | 'label' => 'dreamform.form.partialSubmissions.label', 135 | 'type' => 'toggle', 136 | 'default' => false, 137 | 'help' => 'dreamform.form.partialSubmissions.help', 138 | 'width' => '1/2', 139 | 'when' => [ 140 | 'storeSubmissions' => true 141 | ] 142 | ] : false 143 | ] 144 | ], 145 | ] : [] 146 | ) 147 | ] 148 | ] 149 | ]; 150 | }; 151 | -------------------------------------------------------------------------------- /blueprints/pages/forms.php: -------------------------------------------------------------------------------- 1 | 'dreamform.forms', 8 | 'image' => [ 9 | 'icon' => 'survey', 10 | 'query' => 'icon', 11 | 'back' => 'transparent' 12 | ], 13 | 'options' => [ 14 | 'create' => false, 15 | 'preview' => false, 16 | 'delete' => false, 17 | 'changeSlug' => false, 18 | 'changeStatus' => false, 19 | 'duplicate' => false, 20 | 'changeTitle' => false, 21 | 'update' => false 22 | ], 23 | 'status' => [ 24 | 'draft' => false, 25 | 'unlisted' => true, 26 | 'listed' => false 27 | ], 28 | 'tabs' => [ 29 | 'forms' => [ 30 | 'label' => 'dreamform.forms', 31 | 'icon' => 'survey', 32 | 'sections' => [ 33 | 'license' => [ 34 | 'type' => 'dreamform-license' 35 | ], 36 | 'forms' => [ 37 | 'label' => 'dreamform.forms', 38 | 'type' => 'pages', 39 | 'empty' => 'dreamform.forms.empty', 40 | 'template' => 'form', 41 | 'image' => false 42 | ] 43 | ] 44 | ], 45 | 'submissions' => App::instance()->user()?->role()->permissions()->for('tobimori.dreamform', 'accessSubmissions') ? [ 46 | 'label' => 'dreamform.submissions.recent', 47 | 'icon' => 'archive', 48 | 'sections' => [ 49 | 'license' => [ 50 | 'type' => 'dreamform-license' 51 | ], 52 | 'submissions' => [ 53 | 'label' => 'dreamform.submissions.recent', 54 | 'type' => 'pages', 55 | 'empty' => 'dreamform.submissions.empty', 56 | 'template' => 'submission', 57 | 'layout' => 'table', 58 | 'create' => false, 59 | 'rawvalues' => true, 60 | 'text' => false, 61 | // TODO: cache the query as it seems to be slow on larger sites (> 1 sec) 62 | 'query' => "page.index.filterBy('intendedTemplate', 'submission').sortBy('sortDate', 'desc').limit(20)", 63 | 'columns' => [ 64 | 'date' => [ 65 | 'label' => 'dreamform.submission.submittedAt', 66 | 'type' => 'html', 67 | 'value' => '{{ page.title }}', 68 | 'mobile' => true 69 | ], 70 | 'form' => [ 71 | 'label' => 'dreamform.form', 72 | 'type' => 'html', 73 | 'value' => '{{ page.parent.title }}' 74 | ] 75 | ] 76 | ] 77 | ] 78 | ] : false, 79 | ] 80 | ]; 81 | }; 82 | -------------------------------------------------------------------------------- /blueprints/pages/submission.php: -------------------------------------------------------------------------------- 1 | intendedTemplate()?->name() === 'form') { 11 | $fields = $page->fields(); 12 | } elseif ($page?->intendedTemplate()?->name() === 'submission') { 13 | $fields = $page->form()->fields(); 14 | } 15 | 16 | foreach ($fields as $field) { 17 | $blueprint[$field->key()] = $field->submissionBlueprint() ?? false; 18 | } 19 | 20 | return [ 21 | 'title' => 'dreamform.submission', 22 | 'navigation' => [ 23 | 'status' => 'all', 24 | 'sortBy' => 'sortDate desc' 25 | ], 26 | 'image' => [ 27 | 'icon' => 'archive', 28 | 'back' => 'transparent', 29 | 'query' => 'page.gravatar()' 30 | ], 31 | 'options' => [ 32 | 'create' => false, 33 | 'preview' => false, 34 | 'changeSlug' => false, 35 | 'changeStatus' => false, 36 | 'duplicate' => false, 37 | 'changeTitle' => false, 38 | 'update' => false, 39 | 'move' => false 40 | ], 41 | 'status' => [ 42 | 'draft' => false, 43 | 'unlisted' => true, 44 | 'listed' => false, 45 | ], 46 | 'columns' => [ 47 | 'main' => [ 48 | 'width' => '2/3', 49 | 'fields' => $blueprint 50 | ], 51 | 'sidebar' => [ 52 | 'width' => '1/3', 53 | 'sections' => [ 54 | 'submission' => [ 55 | 'type' => 'dreamform-submission' 56 | ] 57 | ] 58 | ], 59 | ] 60 | ]; 61 | }; 62 | -------------------------------------------------------------------------------- /blueprints/tabs/form-submissions.php: -------------------------------------------------------------------------------- 1 | intendedTemplate()->name() !== 'form') { 13 | return null; 14 | } 15 | 16 | $columns = []; 17 | foreach ($page?->fields()->filterBy(fn ($field) => $field::hasValue())->limit(4) as $field) { 18 | $columns[$field->key()] = [ 19 | 'label' => $field->block()->label()->value(), 20 | ]; 21 | } 22 | 23 | return [ 24 | 'label' => t('dreamform.submissions'), 25 | 'icon' => 'archive', 26 | 'sections' => ['submissions' => [ 27 | 'label' => t('dreamform.submissions'), 28 | 'type' => 'pages', 29 | 'empty' => 'dreamform.submissions.empty', 30 | 'template' => 'submission', 31 | 'layout' => 'table', 32 | 'create' => false, 33 | 'image' => DreamForm::option('integrations.gravatar'), 34 | 'text' => false, 35 | 'search' => true, 36 | 'rawvalues' => true, 37 | 'sortBy' => 'sortDate desc', 38 | 'columns' => A::merge([ 39 | 'date' => [ 40 | 'label' => t('dreamform.submission.submittedAt'), 41 | 'type' => 'html', 42 | 'value' => '{{ page.title }}', 43 | 'mobile' => true 44 | ], 45 | ], $columns) 46 | ]] 47 | ]; 48 | }; 49 | -------------------------------------------------------------------------------- /classes/Actions/AbortAction.php: -------------------------------------------------------------------------------- 1 | t('dreamform.actions.abort.name'), 19 | 'preview' => 'fields', 20 | 'wysiwyg' => true, 21 | 'icon' => 'protected', 22 | 'tabs' => [ 23 | 'settings' => [ 24 | 'label' => t('dreamform.settings'), 25 | 'fields' => [ 26 | 'showError' => [ 27 | 'label' => t('dreamform.actions.abort.showError.label'), 28 | 'type' => 'toggle', 29 | 'default' => true, 30 | 'width' => '1/2', 31 | ], 32 | 'errorMessage' => [ 33 | 'extends' => 'dreamform/fields/error-message', 34 | 'help' => false, 35 | 'placeholder' => t('dreamform.submission.error.generic'), 36 | 'when' => [ 37 | 'showError' => true 38 | ] 39 | ], 40 | ] 41 | ] 42 | ] 43 | ]; 44 | } 45 | 46 | /** 47 | * Run the action 48 | */ 49 | public function run(): void 50 | { 51 | if ($this->block()->showError()->toBool()) { 52 | $this->cancel($this->block()->errorMessage()->or(t('dreamform.submission.error.generic')), public: true, log: false); 53 | } else { 54 | $this->success(); 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /classes/Actions/Action.php: -------------------------------------------------------------------------------- 1 | submission; 35 | } 36 | 37 | /** 38 | * Returns the form the performer is being run on 39 | */ 40 | public function form(): FormPage 41 | { 42 | return $this->submission()->form(); 43 | } 44 | 45 | /** 46 | * Returns the action configuration 47 | */ 48 | public function block(): Block 49 | { 50 | return $this->block; 51 | } 52 | 53 | /** 54 | * Returns true if the action is executed forcibly 55 | */ 56 | public function isForced(): bool 57 | { 58 | return $this->force; 59 | } 60 | 61 | /** 62 | * Returns the base log settings for the action 63 | */ 64 | protected function logSettings(): array|bool 65 | { 66 | return true; 67 | } 68 | 69 | /** 70 | * Create an action log entry 71 | */ 72 | protected function log(array $data, string|null $type = null, string|null $icon = null, string|null $title = null): SubmissionLogEntry 73 | { 74 | return $this->submission()->addLogEntry($data, $type, $icon, $title); 75 | } 76 | 77 | /** 78 | * Cancel the form submission 79 | * 80 | * The form will be shown as failed to the user and the error message will be displayed 81 | */ 82 | protected function cancel(string|null $message = null, bool $public = false, array|bool|null $log = null): void 83 | { 84 | throw new PerformerException( 85 | performer: $this, 86 | message: $message, 87 | public: $public, 88 | force: $this->isForced(), 89 | submission: $this->submission(), 90 | log: $log ?? $this->logSettings() 91 | ); 92 | } 93 | 94 | /** 95 | * Silently cancel the form submission 96 | * 97 | * The form will be shown as successful to the user, except if debug mode is enabled 98 | */ 99 | protected function silentCancel(string|null $message = null, array|bool|null $log = null): void 100 | { 101 | throw new PerformerException( 102 | performer: $this, 103 | message: $message, 104 | silent: true, 105 | force: $this->isForced(), 106 | submission: $this->submission(), 107 | log: $log ?? $this->logSettings() 108 | ); 109 | } 110 | 111 | /** 112 | * Finish the form submission early 113 | */ 114 | protected function success(): void 115 | { 116 | throw new SuccessException(); 117 | } 118 | 119 | /** 120 | * Returns the Blocks fieldset blueprint for the actions' settings 121 | */ 122 | abstract public static function blueprint(): array; 123 | 124 | /** 125 | * Returns the actions' blueprint group 126 | */ 127 | public static function group(): string 128 | { 129 | return 'common'; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /classes/Actions/ConditionalAction.php: -------------------------------------------------------------------------------- 1 | t('dreamform.actions.conditional.name'), 16 | 'preview' => 'fields', 17 | 'wysiwyg' => true, 18 | 'icon' => 'split', 19 | 'tabs' => [ 20 | 'conditions' => [ 21 | 'label' => t('dreamform.actions.conditional.conditions.label'), 22 | 'fields' => [ 23 | 'conditions' => [ 24 | 'label' => t('dreamform.actions.conditional.conditions.label'), 25 | 'type' => 'structure', 26 | 'fields' => [ 27 | 'field' => [ 28 | 'label' => 'dreamform.actions.conditional.if.label', 29 | 'extends' => 'dreamform/fields/field', 30 | 'required' => true, 31 | 'width' => '1/3' 32 | ], 33 | 'operator' => [ 34 | 'label' => 'dreamform.actions.conditional.operator.label', 35 | 'type' => 'select', 36 | 'options' => [ 37 | 'equals' => t('dreamform.actions.conditional.operator.equals'), 38 | 'not-equals' => t('dreamform.actions.conditional.operator.notEquals'), 39 | 'contains' => t('dreamform.actions.conditional.operator.contains'), 40 | 'not-contains' => t('dreamform.actions.conditional.operator.notContains'), 41 | 'starts-with' => t('dreamform.actions.conditional.operator.startsWith'), 42 | 'ends-with' => t('dreamform.actions.conditional.operator.endsWith'), 43 | ], 44 | 'required' => true, 45 | 'width' => '1/6' 46 | ], 47 | 'value' => [ 48 | 'label' => 'dreamform.actions.conditional.value.label', 49 | 'type' => 'text', 50 | 'width' => '3/6' 51 | ] 52 | ] 53 | ], 54 | 'thatActions' => [ 55 | 'label' => 'dreamform.actions.conditional.thatActions.label', 56 | 'extends' => 'dreamform/fields/actions', 57 | 'fieldsets' => [ 58 | static::group() => [ 59 | 'fieldsets' => [ 60 | 'conditional-action' => false // prevent infinite recursion 61 | ] 62 | ] 63 | ] 64 | ], 65 | 'elseActions' => [ 66 | 'label' => 'dreamform.actions.conditional.elseActions.label', 67 | 'extends' => 'dreamform/fields/actions', 68 | 'fieldsets' => [ 69 | static::group() => [ 70 | 'fieldsets' => [ 71 | 'conditional-action' => false // prevent infinite recursion 72 | ] 73 | ] 74 | ] 75 | ] 76 | ] 77 | ] 78 | ] 79 | ]; 80 | } 81 | 82 | public function conditionsMet(): bool 83 | { 84 | foreach ($this->block()->conditions()->toStructure() as $condition) { 85 | $submitted = $this->submission()->valueForId($condition->content()->get('field')->value())?->value(); 86 | $expected = $condition->value()->value(); 87 | 88 | switch ($condition->operator()->value()) { 89 | case 'equals': 90 | if ($submitted !== $expected) { 91 | return false; 92 | } 93 | break; 94 | case 'not-equals': 95 | if ($submitted === $expected) { 96 | return false; 97 | } 98 | break; 99 | case 'contains': 100 | if (strpos($submitted, $expected) === false) { 101 | return false; 102 | } 103 | break; 104 | case 'not-contains': 105 | if (strpos($submitted, $expected) !== false) { 106 | return false; 107 | } 108 | break; 109 | case 'starts-with': 110 | if (strpos($submitted, $expected) !== 0) { 111 | return false; 112 | } 113 | break; 114 | case 'ends-with': 115 | if (substr($submitted, -strlen($expected)) !== $expected) { 116 | return false; 117 | } 118 | break; 119 | } 120 | } 121 | 122 | return true; 123 | } 124 | 125 | public function run(): void 126 | { 127 | $collection = $this->conditionsMet() ? $this->block()->thatActions() : $this->block()->elseActions(); 128 | foreach ($this->submission()->createActions($collection->toBlocks()) as $action) { 129 | $action->run(); 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /classes/Actions/DiscordWebhookAction.php: -------------------------------------------------------------------------------- 1 | t('dreamform.actions.discord.name'), 25 | 'preview' => 'fields', 26 | 'wysiwyg' => true, 27 | 'icon' => 'discord', 28 | 'tabs' => [ 29 | 'settings' => [ 30 | 'label' => t('settings'), 31 | 'fields' => [ 32 | 'webhookUrl' => [ 33 | 'label' => 'dreamform.actions.webhook.url.label', 34 | 'type' => 'text', 35 | 'pattern' => 'https:\/\/discord\.com\/api\/webhooks\/.+\/.+', 36 | 'placeholder' => 'https://discord.com/api/webhooks/...', 37 | 'required' => !DreamForm::option('actions.discord.webhook') 38 | ], 39 | 'exposedFields' => [ 40 | 'label' => 'dreamform.actions.webhook.exposedFields.label', 41 | 'extends' => 'dreamform/fields/field', 42 | 'type' => 'multiselect', 43 | ] 44 | ] 45 | ] 46 | ] 47 | ]; 48 | } 49 | 50 | /** 51 | * Returns the webhook URL 52 | */ 53 | protected function webhookUrl(): string 54 | { 55 | return $this->block()->webhookUrl()->or(DreamForm::option('actions.discord.webhook')); 56 | } 57 | 58 | /** 59 | * Returns the content to be sent to Discord 60 | */ 61 | protected function content(): string 62 | { 63 | // get all fields that should be exposed, or use all fields if none are specified 64 | $exposed = $this->block()->exposedFields()->split(); 65 | if (empty($exposed)) { 66 | $exposed = $this->form()->fields()->keys(); 67 | } 68 | 69 | // get the values & keys of the exposed fields 70 | $content = ''; 71 | foreach ($exposed as $fieldId) { 72 | $field = $this->form()->fields()->find($fieldId); 73 | $value = $this->submission()->valueForId($fieldId); 74 | 75 | if ($field && $value?->isNotEmpty()) { 76 | // add the field key and the value to the webhook content 77 | $content .= "**{$field->label()}**\n{$value}\n\n"; 78 | } 79 | } 80 | 81 | return $content; 82 | } 83 | 84 | /** 85 | * Run the action 86 | */ 87 | public function run(): void 88 | { 89 | try { 90 | $request = Remote::post($this->webhookUrl(), [ 91 | 'headers' => [ 92 | 'User-Agent' => DreamForm::userAgent(), 93 | 'Content-Type' => 'application/json' 94 | ], 95 | 'data' => Json::encode([ 96 | 'content' => null, 97 | 'embeds' => [ 98 | [ 99 | 'title' => $this->form()->title()->value(), 100 | 'description' => $this->content(), 101 | "author" => [ 102 | "name" => "New submission" 103 | ], 104 | 'footer' => [ 105 | 'text' => App::instance()->site()->title()->value(), 106 | 'icon_url' => 'https://www.google.com/s2/favicons?domain=' . App::instance()->site()->url() . '&sz=32' 107 | ], 108 | 'timestamp' => date('c', $this->submission()->sortDate()) 109 | ] 110 | ], 111 | 'attachments' => [] 112 | ]) 113 | ]); 114 | } catch (Throwable $e) { 115 | $this->cancel($e->getMessage()); 116 | } 117 | 118 | if ($request->code() > 299) { 119 | $this->cancel('dreamform.actions.discord.log.error'); 120 | } 121 | 122 | $meta = Remote::get($this->webhookUrl(), [ 123 | 'headers' => [ 124 | 'User-Agent' => DreamForm::userAgent() 125 | ] 126 | ]); 127 | $this->log([ 128 | 'template' => [ 129 | 'name' => $meta->json()['name'], 130 | ] 131 | ], type: 'none', icon: 'discord', title: 'dreamform.actions.discord.log.success'); 132 | } 133 | 134 | /** 135 | * Returns the actions' blueprint group 136 | */ 137 | public static function group(): string 138 | { 139 | return 'notifications'; 140 | } 141 | 142 | /** 143 | * Returns the base log settings for the action 144 | */ 145 | protected function logSettings(): array|bool 146 | { 147 | return [ 148 | 'icon' => 'discord', 149 | 'title' => 'dreamform.actions.discord.name' 150 | ]; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /classes/Actions/PlausibleAction.php: -------------------------------------------------------------------------------- 1 | t('dreamform.actions.plausible.name'), 26 | 'preview' => 'fields', 27 | 'wysiwyg' => true, 28 | 'icon' => 'plausible', 29 | 'tabs' => [ 30 | 'settings' => [ 31 | 'label' => t('dreamform.settings'), 32 | 'fields' => [ 33 | 'event' => [ 34 | 'label' => t('dreamform.actions.plausible.event.label'), 35 | 'type' => 'text', 36 | 'help' => sprintf( 37 | '%s (link: %s text: %s target: _blank)', 38 | t('dreamform.actions.plausible.event.help'), 39 | Str::before(DreamForm::option('actions.plausible.apiUrl'), '/api') . '/' . DreamForm::option('actions.plausible.domain'), 40 | t('dreamform.actions.plausible.event.link') 41 | ), 42 | 'required' => true 43 | ] 44 | ] 45 | ] 46 | ] 47 | ]; 48 | } 49 | 50 | /** 51 | * Run the action 52 | */ 53 | public function run(): void 54 | { 55 | Remote::post(DreamForm::option('actions.plausible.apiUrl') . '/event', [ 56 | 'data' => Json::encode([ 57 | 'name' => $this->block()->event()->value(), 58 | 'domain' => DreamForm::option('actions.plausible.domain'), 59 | "url" => Url::toObject(App::instance()->url() . '/' . $this->submission()->referer())->toString(), 60 | ]), 61 | 'headers' => [ 62 | 'Content-Type' => 'application/json', 63 | 'X-Forwarded-For' => $this->submission()->metadata()->ip()?->value(), 64 | 'User-Agent' => $this->submission()->metadata()->userAgent()?->value(), 65 | ] 66 | ]); 67 | } 68 | 69 | /** 70 | * Allow the action when metadata collection is enabled 71 | */ 72 | public static function isAvailable(): bool 73 | { 74 | return 75 | DreamForm::option('actions.plausible.domain') !== null 76 | && count(array_intersect(DreamForm::option('metadata.collect'), ['ip', 'userAgent'])) === 2; 77 | } 78 | 79 | /** 80 | * Returns the actions' blueprint group 81 | */ 82 | public static function group(): string 83 | { 84 | return 'analytics'; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /classes/Actions/RedirectAction.php: -------------------------------------------------------------------------------- 1 | t('dreamform.actions.redirect.name'), 16 | 'preview' => 'fields', 17 | 'wysiwyg' => true, 18 | 'icon' => 'shuffle', 19 | 'tabs' => [ 20 | 'settings' => [ 21 | 'label' => t('dreamform.settings'), 22 | 'fields' => [ 23 | 'redirectTo' => [ 24 | 'label' => 'dreamform.actions.redirect.redirectTo.label', 25 | 'type' => 'link', 26 | 'options' => [ 27 | 'url', 28 | 'page', 29 | 'file' 30 | ], 31 | 'required' => true 32 | ] 33 | ] 34 | ] 35 | ] 36 | ]; 37 | } 38 | 39 | public function run(): void 40 | { 41 | $redirect = $this->block()->redirectTo()->toUrl(); 42 | if ($redirect) { 43 | $this->submission()->setRedirect($redirect); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /classes/Actions/WebhookAction.php: -------------------------------------------------------------------------------- 1 | t('dreamform.actions.webhook.name'), 21 | 'preview' => 'fields', 22 | 'wysiwyg' => true, 23 | 'icon' => 'webhook', 24 | 'tabs' => [ 25 | 'settings' => [ 26 | 'label' => t('dreamform.settings'), 27 | 'fields' => [ 28 | 'webhookUrl' => [ 29 | 'label' => 'dreamform.actions.webhook.url.label', 30 | 'type' => 'url', 31 | 'placeholder' => 'https://hooks.zapier.com/hooks/catch/...', 32 | 'required' => true 33 | ], 34 | 'exposedFields' => [ 35 | 'label' => 'dreamform.actions.webhook.exposedFields.label', 36 | 'extends' => 'dreamform/fields/field', 37 | 'type' => 'multiselect', 38 | 'options' => [ 39 | 'dreamform-referer' => t('dreamform.actions.webhook.exposedFields.referer'), 40 | ] 41 | ] 42 | ] 43 | ] 44 | ] 45 | ]; 46 | } 47 | 48 | public function run(): void 49 | { 50 | // get all fields that should be exposed, or use all fields if none are specified 51 | $exposed = $this->block()->exposedFields()->split(); 52 | if (empty($exposed)) { 53 | $exposed = $this->form()->fields()->keys(); 54 | } 55 | 56 | // get the values & keys of the exposed fields 57 | $content = []; 58 | foreach ($exposed as $fieldId) { 59 | if ($fieldId === 'dreamform-referer') { 60 | $content['referer'] = $this->submission()->referer(); 61 | continue; 62 | } 63 | 64 | $field = $this->form()->fields()->find($fieldId); 65 | $value = $this->submission()->valueForId($fieldId); 66 | 67 | if ($field && $value?->isNotEmpty()) { 68 | // add the field key and the value to the webhook content 69 | $content[$field->key()] = $value->value(); 70 | } 71 | } 72 | 73 | // send the webhook 74 | try { 75 | $request = Remote::post($this->block()->webhookUrl()->value(), [ 76 | 'headers' => [ 77 | 'User-Agent' => 'Kirby DreamForm', 78 | 'Content-Type' => 'application/json' 79 | ], 80 | 'data' => json_encode($content) 81 | ]); 82 | } catch (Throwable $e) { 83 | // (this will only be shown in the frontend if debug mode is enabled) 84 | $this->cancel($e->getMessage()); 85 | } 86 | 87 | if ($request->code() > 299) { 88 | $this->cancel( 89 | I18n::template('dreamform.actions.webhook.log.error', replace: [ 90 | 'url' => $this->block()->webhookUrl()->value() 91 | ]) 92 | ); 93 | } 94 | 95 | $this->log([ 96 | 'template' => [ 97 | 'url' => Url::toObject($request->url())->domain() 98 | ] 99 | ], type: 'none', icon: 'webhook', title: 'dreamform.actions.webhook.log.success'); 100 | } 101 | 102 | /** 103 | * Returns the base log settings for the action 104 | */ 105 | protected function logSettings(): array|bool 106 | { 107 | return [ 108 | 'icon' => 'webhook', 109 | 'title' => 'dreamform.actions.webhook.name' 110 | ]; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /classes/Exceptions/PerformerException.php: -------------------------------------------------------------------------------- 1 | submission() && $log !== false) { 31 | $this->submission()->addLogEntry( 32 | ...A::merge( 33 | [ 34 | 'data' => [ 35 | 'text' => $message ?? self::GENERIC_ERROR, 36 | 'template' => [ 37 | 'type' => $this->performer->type(), 38 | ] 39 | ], 40 | 'type' => 'error', 41 | 'icon' => 'alert', 42 | 'title' => "dreamform.submission.log.error", 43 | ], 44 | is_bool($log) ? [] : $log 45 | ) 46 | ); 47 | } 48 | 49 | parent::__construct($this->isPublic() ? $translated : t(self::GENERIC_ERROR)); 50 | } 51 | 52 | public function submission(): SubmissionPage|false 53 | { 54 | if (!$this->submission) { 55 | return false; 56 | } 57 | 58 | return $this->submission; 59 | } 60 | 61 | public function form(): FormPage 62 | { 63 | return $this->performer->form(); 64 | } 65 | 66 | public function shouldContinue(): bool 67 | { 68 | return $this->isForced() || $this->form()->continueOnError()->toBool(); 69 | } 70 | 71 | public function isSilent(): bool 72 | { 73 | return $this->silent && !DreamForm::debugMode(); 74 | } 75 | 76 | public function isPublic(): bool 77 | { 78 | return $this->public || DreamForm::debugMode(); 79 | } 80 | 81 | public function isForced(): bool 82 | { 83 | return $this->force; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /classes/Exceptions/SuccessException.php: -------------------------------------------------------------------------------- 1 | t('dreamform.fields.button.name'), 13 | 'icon' => 'ticket', 14 | 'preview' => 'button-field', 15 | 'wysiwyg' => true, 16 | 'tabs' => [ 17 | 'settings' => [ 18 | 'label' => t('dreamform.settings'), 19 | 'fields' => [ 20 | 'label' => [ 21 | 'extends' => 'dreamform/fields/label', 22 | 'width' => 1, 23 | 'required' => false, 24 | 'placeholder' => t('dreamform.fields.button.label.label') 25 | ], 26 | ] 27 | ] 28 | ] 29 | ]; 30 | } 31 | 32 | public static function hasValue(): bool 33 | { 34 | return false; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /classes/Fields/CheckboxField.php: -------------------------------------------------------------------------------- 1 | t('dreamform.fields.checkboxes.name'), 17 | 'preview' => 'choices-field', 18 | 'wysiwyg' => true, 19 | 'icon' => 'toggle-off', 20 | 'tabs' => [ 21 | 'field' => [ 22 | 'label' => t('dreamform.field'), 23 | 'fields' => [ 24 | 'key' => 'dreamform/fields/key', 25 | 'label' => [ 26 | 'extends' => 'dreamform/fields/label', 27 | 'required' => false 28 | ], 29 | 'options' => 'dreamform/fields/options', 30 | ] 31 | ], 32 | 'validation' => [ 33 | 'label' => t('dreamform.validation'), 34 | 'fields' => [ 35 | 'min' => [ 36 | 'label' => t('dreamform.fields.checkboxes.min.label'), 37 | 'type' => 'number', 38 | 'width' => '1/2' 39 | ], 40 | 'max' => [ 41 | 'label' => t('dreamform.fields.checkboxes.max.label'), 42 | 'type' => 'number', 43 | 'width' => '1/2' 44 | ], 45 | 'errorMessage' => [ 46 | 'extends' => 'dreamform/fields/error-message', 47 | 'width' => '1' 48 | ], 49 | ] 50 | ] 51 | ] 52 | ]; 53 | } 54 | 55 | public function submissionBlueprint(): array|null 56 | { 57 | $options = []; 58 | foreach ($this->block()->options()->toStructure() as $option) { 59 | $options[] = [ 60 | 'value' => $option->value()->value(), 61 | 'text' => $option->label()->or($option->value())->value() 62 | ]; 63 | } 64 | 65 | return [ 66 | 'label' => t('dreamform.fields.checkboxes.name') . ': ' . $this->key(), 67 | 'type' => 'checkboxes', 68 | 'options' => $options 69 | ]; 70 | } 71 | 72 | public function validate(): true|string 73 | { 74 | $value = $this->value()->split() ?? []; 75 | 76 | if ( 77 | $this->block()->max()->isNotEmpty() 78 | && !V::max(count($value), $this->block()->max()->toInt()) 79 | || $this->block()->min()->isNotEmpty() 80 | && !V::min(count($value), $this->block()->min()->toInt()) 81 | ) { 82 | return $this->errorMessage(); 83 | } 84 | 85 | return true; 86 | } 87 | 88 | protected function sanitize(ContentField $value): ContentField 89 | { 90 | return new ContentField($this->block()->parent(), $this->key(), A::join($value->value() ?? [], ',')); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /classes/Fields/EmailField.php: -------------------------------------------------------------------------------- 1 | t('dreamform.fields.email.name'), 19 | 'preview' => 'text-field', 20 | 'wysiwyg' => true, 21 | 'icon' => 'email', 22 | 'tabs' => [ 23 | 'field' => [ 24 | 'label' => t('dreamform.field'), 25 | 'fields' => [ 26 | 'key' => 'dreamform/fields/key', 27 | 'label' => 'dreamform/fields/label', 28 | 'placeholder' => 'dreamform/fields/placeholder', 29 | ] 30 | ], 31 | 'validation' => [ 32 | 'label' => t('dreamform.validation'), 33 | 'fields' => [ 34 | 'required' => 'dreamform/fields/required', 35 | 'errorMessage' => 'dreamform/fields/error-message', 36 | ] 37 | ] 38 | ] 39 | ]; 40 | } 41 | 42 | public function submissionBlueprint(): array|null 43 | { 44 | return [ 45 | 'label' => $this->block()->label()->value() ?? t('dreamform.fields.email.name'), 46 | 'icon' => 'email', 47 | 'type' => 'text' 48 | ]; 49 | } 50 | 51 | protected function hostname(): string 52 | { 53 | return Str::after($this->value()->value(), '@'); 54 | } 55 | 56 | /** 57 | * Check if the TLD associated with the email address has a valid MX record 58 | */ 59 | protected function hasMxRecord(): bool 60 | { 61 | if (DreamForm::option('fields.email.dnsLookup') === false) { 62 | return true; 63 | } 64 | 65 | return checkdnsrr($this->hostname(), 'MX'); 66 | } 67 | 68 | /** 69 | * Check if the email address is on a disposable providers black list 70 | */ 71 | protected function isDisposableEmail(): bool 72 | { 73 | if (DreamForm::option('fields.email.disposableEmails.disallow') === false) { 74 | return false; 75 | } 76 | 77 | $url = DreamForm::option('fields.email.disposableEmails.list'); 78 | $list = static::cache('disposable', function () use ($url) { 79 | $request = Remote::get($url); 80 | return $request->code() === 200 ? Str::split($request->content(), PHP_EOL) : []; 81 | }); 82 | 83 | return in_array($this->hostname(), $list); 84 | } 85 | 86 | /** 87 | * Validate the email field 88 | */ 89 | public function validate(): true|string 90 | { 91 | if ( 92 | $this->block()->required()->toBool() 93 | && $this->value()->isEmpty() 94 | || $this->value()->isNotEmpty() 95 | && (!V::email($this->value()->value()) 96 | || !$this->hasMxRecord() 97 | || $this->isDisposableEmail()) 98 | ) { 99 | return $this->errorMessage(); 100 | } 101 | 102 | return true; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /classes/Fields/Field.php: -------------------------------------------------------------------------------- 1 | id = $block->id(); 33 | } 34 | 35 | /** 36 | * Returns the fields' ID 37 | */ 38 | public function id(): string 39 | { 40 | return $this->id; 41 | } 42 | 43 | /** 44 | * Returns the fields' key or ID as fallback 45 | */ 46 | public function key(): string 47 | { 48 | return Str::replace($this->block()->key()->or($this->id())->value(), '-', '_'); 49 | } 50 | 51 | /** 52 | * Returns the fields' block, which stores configuration of the instance 53 | */ 54 | public function block(): Block 55 | { 56 | return $this->block; 57 | } 58 | 59 | /** 60 | * Returns the fields' error message from the block content 61 | */ 62 | public function errorMessage(string $key = 'errorMessage'): string 63 | { 64 | return $this->block()->{$key}()->isNotEmpty() ? $this->block()->{$key}() : t('dreamform.fields.error.required'); 65 | } 66 | 67 | /** 68 | * Returns the fields' value 69 | */ 70 | public function value(): ContentField 71 | { 72 | if (!$this->value) { 73 | return new ContentField($this->block()->parent(), $this->key(), null); 74 | } 75 | 76 | return $this->value; 77 | } 78 | 79 | /** 80 | * Returns the fields' label or key as fallback 81 | */ 82 | public function label(): string 83 | { 84 | return $this->block()->label()->value() ?? $this->key(); 85 | } 86 | 87 | /** 88 | * Validate the field value 89 | * Returns true or an error message for the user frontend 90 | */ 91 | public function validate(): true|string 92 | { 93 | return true; 94 | } 95 | 96 | /** 97 | * Run logic after the form submission 98 | * e.g. for storing an uploaded file 99 | */ 100 | public function afterSubmit(SubmissionPage $submission): void 101 | { 102 | } 103 | 104 | /** 105 | * Returns the sanitzed value of the field 106 | */ 107 | protected function sanitize(ContentField $value): ContentField 108 | { 109 | return $value; 110 | } 111 | 112 | /** 113 | * Set the fields' value 114 | */ 115 | public function setValue(ContentField $value): static 116 | { 117 | $this->value = $this->sanitize($value); 118 | return $this; 119 | } 120 | 121 | /** 122 | * Returns true if the field is able to have/store a value 123 | * Set it to false, if your component is a field without user input, 124 | * like a headline or a separator 125 | */ 126 | public static function hasValue(): bool 127 | { 128 | return true; 129 | } 130 | 131 | /** 132 | * Returns the values fieldset blueprint for the fields' settings 133 | */ 134 | abstract public static function blueprint(): array; 135 | 136 | /** 137 | * Returns the fields' submission blueprint 138 | */ 139 | public function submissionBlueprint(): array|null 140 | { 141 | return null; 142 | } 143 | 144 | public function htmxAttr(FormPage $form): array 145 | { 146 | if (!Htmx::isActive() || !DreamForm::option('precognition')) { 147 | return []; 148 | } 149 | 150 | $htmx = [ 151 | 'hx-post' => $form->url(precognition: true), 152 | 153 | // we want to show the error in the last moment (on blur) 154 | // but we want to remove the error as soon as possible (on change) 155 | 'hx-trigger' => $this->validate() === true ? "change" : "change, input changed delay:500ms", 156 | 157 | // for on change validation we need the morph extension (otherwise we lose focus) 158 | 'hx-swap' => 'morph:{ignoreActiveValue:true}', 159 | 'hx-ext' => 'morph' 160 | ]; 161 | 162 | return $htmx; 163 | } 164 | 165 | /** 166 | * Returns the fields' type 167 | */ 168 | public static function type(): string 169 | { 170 | return static::TYPE; 171 | } 172 | 173 | /** 174 | * Returns the fields' blueprint group 175 | */ 176 | public static function group(): string 177 | { 178 | return 'common'; 179 | } 180 | 181 | /** 182 | * Returns true if the field is available 183 | * 184 | * Use this to disable fields based on configuration or other factors 185 | */ 186 | public static function isAvailable(FormPage|null $form = null): bool 187 | { 188 | return true; 189 | } 190 | 191 | /** 192 | * Get the fields's cache instance 193 | */ 194 | private static function cacheInstance(): Cache 195 | { 196 | return App::instance()->cache('tobimori.dreamform.fields'); 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /classes/Fields/HiddenField.php: -------------------------------------------------------------------------------- 1 | t('dreamform.fields.hidden.name'), 13 | 'preview' => 'hidden-field', 14 | 'wysiwyg' => true, 15 | 'icon' => 'hidden', 16 | 'tabs' => [ 17 | 'settings' => [ 18 | 'label' => t('dreamform.settings'), 19 | 'fields' => [ 20 | 'key' => [ 21 | 'extends' => 'dreamform/fields/key', 22 | 'width' => 1, 23 | 'wizard' => false, 24 | 'placeholder' => t('dreamform.fields.hidden.placeholder') 25 | ], 26 | ] 27 | ] 28 | ] 29 | ]; 30 | } 31 | 32 | public function submissionBlueprint(): array|null 33 | { 34 | return [ 35 | 'label' => t('dreamform.fields.hidden.name') . ': ' . $this->key(), 36 | 'icon' => 'hidden', 37 | 'type' => 'text' 38 | ]; 39 | } 40 | 41 | public static function group(): string 42 | { 43 | return 'advanced-fields'; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /classes/Fields/NumberField.php: -------------------------------------------------------------------------------- 1 | t('dreamform.fields.number.name'), 15 | 'preview' => 'text-field', 16 | 'wysiwyg' => true, 17 | 'icon' => 'order-num-asc', 18 | 'tabs' => [ 19 | 'field' => [ 20 | 'label' => t('dreamform.field'), 21 | 'fields' => [ 22 | 'key' => 'dreamform/fields/key', 23 | 'label' => 'dreamform/fields/label', 24 | 'placeholder' => 'dreamform/fields/placeholder', 25 | 'step' => [ 26 | 'label' => t('dreamform.fields.number.step.label'), 27 | 'type' => 'number', 28 | 'default' => 1, 29 | 'required' => true, 30 | 'width' => '1/2', 31 | 'help' => t('dreamform.fields.number.step.help') 32 | ], 33 | ] 34 | ], 35 | 'validation' => [ 36 | 'label' => t('dreamform.validation'), 37 | 'fields' => [ 38 | 'min' => [ 39 | 'label' => t('dreamform.fields.number.min.label'), 40 | 'type' => 'number', 41 | 'width' => '1/2' 42 | ], 43 | 'max' => [ 44 | 'label' => t('dreamform.fields.number.max.label'), 45 | 'type' => 'number', 46 | 'width' => '1/2' 47 | ], 48 | 'required' => 'dreamform/fields/required', 49 | 'errorMessage' => 'dreamform/fields/error-message', 50 | ] 51 | ] 52 | ] 53 | ]; 54 | } 55 | 56 | public function submissionBlueprint(): array|null 57 | { 58 | return [ 59 | 'label' => $this->block()->label()->value() ?? t('dreamform.fields.number.name'), 60 | 'type' => 'number' 61 | ]; 62 | } 63 | 64 | public function validate(): true|string 65 | { 66 | $value = $this->value()->toFloat(); 67 | 68 | if ( 69 | // check for required field 70 | $this->block()->required()->toBool() 71 | && $this->value()->isEmpty() 72 | 73 | // check for max 74 | || $this->block()->max()->isNotEmpty() 75 | && !V::max($value, $this->block()->max()->toFloat()) 76 | 77 | // check for min 78 | || $this->block()->min()->isNotEmpty() 79 | && !V::min($value, $this->block()->min()->toFloat()) 80 | 81 | // check for step 82 | || fmod($value, $this->block()->step()->toFloat()) !== 0.0 83 | ) { 84 | return $this->errorMessage(); 85 | } 86 | 87 | return true; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /classes/Fields/PagesField.php: -------------------------------------------------------------------------------- 1 | t('dreamform.fields.pages.name'), 15 | 'preview' => 'text-field', 16 | 'wysiwyg' => true, 17 | 'icon' => 'document', 18 | 'tabs' => [ 19 | 'field' => [ 20 | 'label' => t('dreamform.field'), 21 | 'fields' => [ 22 | 'key' => 'dreamform/fields/key', 23 | 'label' => 'dreamform/fields/label', 24 | 'placeholder' => 'dreamform/fields/placeholder', 25 | 'pages' => [ 26 | 'label' => t('dreamform.common.options.label'), 27 | 'type' => 'pages', 28 | 'query' => DreamForm::option('fields.pages.query'), 29 | 'width' => '1/2', 30 | ], 31 | 'useChildren' => [ 32 | 'label' => t('dreamform.fields.pages.useChildren.label'), 33 | 'help' => t('dreamform.fields.pages.useChildren.help'), 34 | 'type' => 'toggle', 35 | 'default' => false, 36 | 'width' => '1/2', 37 | ], 38 | ] 39 | ], 40 | 'validation' => [ 41 | 'label' => t('dreamform.validation'), 42 | 'fields' => [ 43 | 'required' => 'dreamform/fields/required', 44 | 'errorMessage' => 'dreamform/fields/error-message', 45 | ] 46 | ] 47 | ] 48 | ]; 49 | } 50 | 51 | public function submissionBlueprint(): array|null 52 | { 53 | return [ 54 | 'label' => $this->block()->label()->value() ?? t('dreamform.fields.pages.name'), 55 | 'type' => 'pages' 56 | ]; 57 | } 58 | 59 | /** 60 | * Returns the available options as a key-value array. 61 | */ 62 | public function options(): array 63 | { 64 | $options = []; 65 | /** @var \Kirby\Cms\Pages $pages */ 66 | $pages = $this->block()->pages()->toPages(); 67 | 68 | if ($this->block()->useChildren()->toBool()) { 69 | $pages = $pages->children()->listed(); 70 | } 71 | 72 | foreach ($pages as $page) { 73 | $options[$page->uuid()->toString() ?? $page->id()] = $page->title()->value(); 74 | } 75 | 76 | return $options; 77 | } 78 | 79 | /** 80 | * Validate if the selected page exists and is allowed. 81 | */ 82 | public function validate(): true|string 83 | { 84 | if ( 85 | !array_key_exists($this->value()->toString(), $this->options()) || 86 | $this->block()->required()->toBool() 87 | && $this->value()->isEmpty() 88 | ) { 89 | return $this->errorMessage(); 90 | } 91 | 92 | return true; 93 | } 94 | 95 | public static function group(): string 96 | { 97 | return 'advanced-fields'; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /classes/Fields/RadioField.php: -------------------------------------------------------------------------------- 1 | t('dreamform.fields.radio.name'), 15 | 'preview' => 'choices-field', 16 | 'wysiwyg' => true, 17 | 'icon' => 'circle-nested', 18 | 'tabs' => [ 19 | 'field' => [ 20 | 'label' => t('dreamform.field'), 21 | 'fields' => [ 22 | 'key' => 'dreamform/fields/key', 23 | 'label' => [ 24 | 'extends' => 'dreamform/fields/label', 25 | 'required' => false 26 | ], 27 | 'options' => 'dreamform/fields/options', 28 | ] 29 | ], 30 | 'validation' => [ 31 | 'label' => t('dreamform.validation'), 32 | 'fields' => [ 33 | 'required' => 'dreamform/fields/required', 34 | 'errorMessage' => 'dreamform/fields/error-message', 35 | ] 36 | ] 37 | ] 38 | ]; 39 | } 40 | 41 | public function submissionBlueprint(): array|null 42 | { 43 | $options = []; 44 | foreach ($this->block()->options()->toStructure() as $option) { 45 | $options[] = [ 46 | 'value' => $option->value()->value(), 47 | 'text' => $option->label()->or($option->value())->value() 48 | ]; 49 | } 50 | 51 | return [ 52 | 'label' => t('dreamform.fields.radio.name') . ': ' . $this->key(), 53 | 'type' => 'radio', 54 | 'options' => $options 55 | ]; 56 | } 57 | 58 | public function validate(): true|string 59 | { 60 | if ($this->block()->required()->toBool() && $this->value()->isEmpty()) { 61 | return $this->errorMessage(); 62 | } 63 | 64 | return true; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /classes/Fields/SelectField.php: -------------------------------------------------------------------------------- 1 | t('dreamform.fields.select.name'), 15 | 'preview' => 'select-field', 16 | 'wysiwyg' => true, 17 | 'icon' => 'list-bullet', 18 | 'tabs' => [ 19 | 'field' => [ 20 | 'label' => t('dreamform.field'), 21 | 'fields' => [ 22 | 'key' => 'dreamform/fields/key', 23 | 'label' => 'dreamform/fields/label', 24 | 'placeholder' => 'dreamform/fields/placeholder', 25 | 'options' => [ 26 | 'extends' => 'dreamform/fields/options', 27 | 'width' => '1', 28 | 'fields' => [ 29 | 'label' => [ 30 | 'type' => 'text' 31 | ] 32 | ] 33 | ] 34 | ] 35 | ], 36 | 'validation' => [ 37 | 'label' => t('dreamform.validation'), 38 | 'fields' => [ 39 | 'required' => 'dreamform/fields/required', 40 | 'errorMessage' => 'dreamform/fields/error-message', 41 | ] 42 | ] 43 | ] 44 | ]; 45 | } 46 | 47 | /** 48 | * Returns the available options as a key-value array. 49 | */ 50 | public function options(): array 51 | { 52 | $options = []; 53 | foreach ($this->block()->options()->toStructure() as $option) { 54 | $options[$option->value()->value()] = $option->label()->or($option->value())->value(); 55 | } 56 | 57 | return $options; 58 | } 59 | 60 | public function submissionBlueprint(): array|null 61 | { 62 | return [ 63 | 'label' => $this->block()->label()->value() ?? t('dreamform.fields.select.name'), 64 | 'type' => 'select', 65 | 'placeholder' => $this->block()->placeholder()->value() ?? '', 66 | 'options' => A::reduce(array_keys($this->options()), fn ($prev, $key) => array_merge($prev, [ 67 | ['value' => $key, 'text' => $this->options()[$key]] 68 | ]), []), 69 | ]; 70 | } 71 | 72 | public function validate(): true|string 73 | { 74 | if ( 75 | $this->block()->required()->toBool() 76 | && $this->value()->isEmpty() 77 | ) { 78 | return $this->errorMessage(); 79 | } 80 | 81 | return true; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /classes/Fields/TextField.php: -------------------------------------------------------------------------------- 1 | t('dreamform.fields.text.name'), 13 | 'preview' => 'text-field', 14 | 'wysiwyg' => true, 15 | 'icon' => 'title', 16 | 'tabs' => [ 17 | 'field' => [ 18 | 'label' => t('dreamform.field'), 19 | 'fields' => [ 20 | 'key' => 'dreamform/fields/key', 21 | 'label' => 'dreamform/fields/label', 22 | 'placeholder' => 'dreamform/fields/placeholder', 23 | ] 24 | ], 25 | 'validation' => [ 26 | 'label' => t('dreamform.validation'), 27 | 'fields' => [ 28 | 'required' => 'dreamform/fields/required', 29 | 'errorMessage' => 'dreamform/fields/error-message', 30 | ] 31 | ] 32 | ] 33 | ]; 34 | } 35 | 36 | public function submissionBlueprint(): array|null 37 | { 38 | return [ 39 | 'label' => $this->block()->label()->value() ?? t('dreamform.fields.text.name'), 40 | 'icon' => 'text-left', 41 | 'type' => 'text' 42 | ]; 43 | } 44 | 45 | public function validate(): true|string 46 | { 47 | if ( 48 | $this->block()->required()->toBool() 49 | && $this->value()->isEmpty() 50 | ) { 51 | return $this->errorMessage(); 52 | } 53 | 54 | return true; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /classes/Fields/TextareaField.php: -------------------------------------------------------------------------------- 1 | t('dreamform.fields.textarea.name'), 13 | 'preview' => 'text-field', 14 | 'wysiwyg' => true, 15 | 'icon' => 'text-left', 16 | 'tabs' => [ 17 | 'field' => [ 18 | 'label' => t('dreamform.field'), 19 | 'fields' => [ 20 | 'key' => 'dreamform/fields/key', 21 | 'label' => 'dreamform/fields/label', 22 | 'placeholder' => 'dreamform/fields/placeholder', 23 | ] 24 | ], 25 | 'validation' => [ 26 | 'label' => t('dreamform.validation'), 27 | 'fields' => [ 28 | 'required' => 'dreamform/fields/required', 29 | 'errorMessage' => 'dreamform/fields/error-message', 30 | ] 31 | ] 32 | ] 33 | ]; 34 | } 35 | 36 | public function submissionBlueprint(): array|null 37 | { 38 | return [ 39 | 'label' => $this->block()->label()->value() ?? t('dreamform.fields.textarea.name'), 40 | 'type' => 'textarea', 41 | 'size' => 'medium', 42 | ]; 43 | } 44 | 45 | public function validate(): true|string 46 | { 47 | if ( 48 | $this->block()->required()->toBool() 49 | && $this->value()->isEmpty() 50 | ) { 51 | return $this->errorMessage(); 52 | } 53 | 54 | return true; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /classes/Guards/AkismetGuard.php: -------------------------------------------------------------------------------- 1 | $fields) { 22 | $content[$key] = A::reduce($fields, function ($prev, $field) use ($submission) { 23 | if ($prev !== null) { 24 | return $prev; 25 | } 26 | 27 | return $submission->valueFor($field)?->value(); 28 | }, null); 29 | } 30 | 31 | return $content; 32 | } 33 | 34 | /** 35 | * Run the Akismet validation 36 | */ 37 | public function postValidation(SubmissionPage $submission): void 38 | { 39 | $kirby = App::instance(); 40 | $visitor = $kirby->visitor(); 41 | $request = $kirby->request(); 42 | 43 | try { 44 | $request = static::post('/comment-check', A::merge([ 45 | // send metadata 46 | 'user_ip' => $visitor->ip(), 47 | 'user_agent' => A::has(DreamForm::option('metadata.collect'), 'userAgent') ? $visitor->userAgent() : null, 48 | 'referrer' => $request->header("Referer"), 49 | 50 | // send honeypot if used 51 | 'honeypot_field_name' => $honeypotField = A::find($this->form()->guards(), fn ($guard) => $guard instanceof HoneypotGuard)?->fieldName(), 52 | 'hidden_honeypot_field' => $honeypotField ? SubmissionPage::valueFromBody($honeypotField) : null, 53 | ], $this->contentForSubmission($submission))); 54 | 55 | if ($request->content() === 'true') { 56 | $submission->markAsSpam(true); 57 | } 58 | } catch (\Throwable $e) { 59 | // we don't want to block the submission if Akismet fails 60 | } 61 | } 62 | 63 | /** 64 | * Returns the content to be reported to Akismet 65 | */ 66 | protected function reportContentForSubmission(SubmissionPage $submission): array 67 | { 68 | return A::merge([ 69 | 'comment_author' => $submission->metadata()->name()?->value(), 70 | 'comment_author_email' => $submission->metadata()->email()?->value(), 71 | 'comment_author_url' => $submission->metadata()->website()?->value(), 72 | 'comment_content' => $submission->message()->value() 73 | ], $this->contentForSubmission($submission)); 74 | } 75 | 76 | /** 77 | * Reports the submission as spam to Akismet 78 | */ 79 | public function reportSubmissionAsSpam(SubmissionPage $submission): void 80 | { 81 | static::post('/submit-spam', $this->reportContentForSubmission($submission)); 82 | } 83 | 84 | /** 85 | * Reports the submission as ham to Akismet 86 | */ 87 | public function reportSubmissionAsHam(SubmissionPage $submission): void 88 | { 89 | static::post('/submit-ham', $this->reportContentForSubmission($submission)); 90 | } 91 | 92 | /** 93 | * Returns the Akismet API key 94 | */ 95 | protected static function apiKey(): string|null 96 | { 97 | return DreamForm::option('guards.akismet.apiKey'); 98 | } 99 | 100 | /** 101 | * Returns the Akismet API URL 102 | */ 103 | protected static function apiUrl(): string 104 | { 105 | return 'https://rest.akismet.com/1.1'; 106 | } 107 | 108 | /** 109 | * Make a request to the Akismet API 110 | */ 111 | protected static function post(string $url, array $data = []): Remote 112 | { 113 | $kirby = App::instance(); 114 | 115 | return Remote::post( 116 | static::apiUrl() . $url, 117 | [ 118 | 'data' => A::filter(A::merge([ 119 | 'api_key' => static::apiKey(), 120 | 'blog' => App::instance()->site()->url(), 121 | 'comment_type' => 'contact-form', 122 | 'blog_lang' => $kirby->multilang() ? $kirby->languages()->map(fn ($lang) => $lang->code())->join(', ') : null, 123 | 'blog_charset' => 'UTF-8' 124 | ], $data), fn ($value) => $value !== null) 125 | ] 126 | ); 127 | } 128 | 129 | /** 130 | * Mark guard as available if an API key is set 131 | */ 132 | public static function isAvailable(): bool 133 | { 134 | if ( 135 | !static::apiKey() || 136 | !A::has(DreamForm::option('metadata.collect'), 'ip') 137 | ) { 138 | return false; 139 | } 140 | 141 | return static::cache('verify-key', function () { 142 | $request = static::post('/verify-key'); 143 | return $request->code() === 200; 144 | }); 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /classes/Guards/CsrfGuard.php: -------------------------------------------------------------------------------- 1 | csrf(); 23 | } 24 | 25 | return self::$token; 26 | } 27 | 28 | /** 29 | * Validate the CSRF token 30 | */ 31 | public function precognitiveRun(): void 32 | { 33 | $token = $this->csrf(); 34 | $submitted = SubmissionPage::valueFromBody('dreamform-csrf'); 35 | 36 | if ($submitted !== $token) { 37 | $this->cancel('dreamform.submission.error.csrf', true); 38 | } 39 | } 40 | 41 | public static function hasSnippet(): bool 42 | { 43 | return true; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /classes/Guards/Guard.php: -------------------------------------------------------------------------------- 1 | precognitiveRun(); 32 | } 33 | 34 | /** 35 | * Precognitive run 36 | */ 37 | public function precognitiveRun(): void 38 | { 39 | } 40 | 41 | /** 42 | * Reports the submission as spam to a third-party service 43 | */ 44 | public function reportSubmissionAsSpam(SubmissionPage $submission): void 45 | { 46 | } 47 | 48 | /** 49 | * Reports the submission as ham to a third-party service 50 | */ 51 | public function reportSubmissionAsHam(SubmissionPage $submission): void 52 | { 53 | } 54 | 55 | /** 56 | * Returns the form the guard is being run on 57 | */ 58 | public function form(): FormPage 59 | { 60 | return $this->form; 61 | } 62 | 63 | public static function hasSnippet(): bool 64 | { 65 | return false; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /classes/Guards/HCaptchaGuard.php: -------------------------------------------------------------------------------- 1 | static::secretKey(), 27 | 'response' => SubmissionPage::valueFromBody('h-captcha-response') 28 | ]; 29 | 30 | // Only include remoteip if IP metadata collection is enabled 31 | if (in_array('ip', DreamForm::option('metadata.collect', []))) { 32 | // we can't access the metadata object yet 33 | $data['remoteip'] = App::instance()->visitor()->ip(); 34 | } 35 | 36 | $remote = Remote::post('https://api.hcaptcha.com/siteverify', [ 37 | 'data' => $data 38 | ]); 39 | 40 | $result = $remote->json(); 41 | 42 | if ( 43 | $remote->code() !== 200 || 44 | $result['success'] !== true 45 | ) { 46 | $this->cancel(t('dreamform.submission.error.captcha')); 47 | } 48 | } 49 | 50 | public static function hasSnippet(): bool 51 | { 52 | return true; 53 | } 54 | 55 | public static function isAvailable(): bool 56 | { 57 | return static::siteKey() !== null 58 | && static::secretKey() !== null; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /classes/Guards/HoneypotGuard.php: -------------------------------------------------------------------------------- 1 | form()->fields()->map(fn ($field) => $field->key()); 16 | 17 | foreach ($available as $field) { 18 | if (!in_array($field, $used->data())) { 19 | return $field; 20 | } 21 | } 22 | 23 | return 'dreamform-guard'; 24 | } 25 | 26 | public function precognitiveRun(): void 27 | { 28 | $value = SubmissionPage::valueFromBody($this->fieldName()); 29 | 30 | if ($value) { 31 | $this->silentCancel(t('dreamform.submission.error.honeypot')); 32 | } 33 | } 34 | 35 | public static function hasSnippet(): bool 36 | { 37 | return true; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /classes/Guards/LicenseGuard.php: -------------------------------------------------------------------------------- 1 | isValid() && !App::instance()->system()->isLocal() && !App::instance()->user()?->isAdmin()) { 16 | $this->cancel(t('dreamform.license.error.submission'), public: true); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /classes/Guards/RatelimitGuard.php: -------------------------------------------------------------------------------- 1 | visitor()->ip()); // hash the IP address to protect user privacy 15 | 16 | $count = static::cache( 17 | $ip, 18 | fn () => DreamForm::option('guards.ratelimit.limit'), // set the initial count 19 | DreamForm::option('guards.ratelimit.interval') // set the expiration time 20 | ); 21 | 22 | if ($count <= 0) { 23 | $this->cancel('dreamform.submission.error.ratelimit', public: true); 24 | } else { 25 | static::setCache($ip, $count - 1, DreamForm::option('guards.ratelimit.interval')); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /classes/Guards/TurnstileGuard.php: -------------------------------------------------------------------------------- 1 | [ 27 | 'secret' => static::secretKey(), 28 | 'response' => SubmissionPage::valueFromBody('cf-turnstile-response') 29 | ] 30 | ]); 31 | 32 | if ( 33 | $remote->code() !== 200 || 34 | $remote->json()['success'] !== true 35 | ) { 36 | $this->cancel(t('dreamform.submission.error.captcha')); 37 | } 38 | } 39 | 40 | public static function hasSnippet(): bool 41 | { 42 | return true; 43 | } 44 | 45 | public static function isAvailable(): bool 46 | { 47 | return static::siteKey() !== null 48 | && static::secretKey() !== null; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /classes/Models/BasePage.php: -------------------------------------------------------------------------------- 1 | false]; 22 | } 23 | 24 | /** 25 | * Render a 404 page to lock pages 26 | */ 27 | public function render( 28 | array $data = [], 29 | $contentType = 'html', 30 | VersionId|string|null $versionId = null 31 | ): string { 32 | // this being the same means we have a custom template (by the user) assigned to the form 33 | if ($this->template()->name() === $this->intendedTemplate()->name()) { 34 | return parent::render($data, $contentType, $versionId); 35 | } 36 | 37 | kirby()->response()->code(404); 38 | return $this->site()->errorPage()->render(); 39 | } 40 | 41 | /** 42 | * Override the page title to be static to the template name 43 | */ 44 | public function title(): Field 45 | { 46 | return new Field($this, 'title', t("dreamform.{$this->intendedTemplate()->name()}")); 47 | } 48 | 49 | /** 50 | * Basic permissions for all pages 51 | */ 52 | public function isAccessible(): bool 53 | { 54 | if (!App::instance()->user()->role()->permissions()->for('tobimori.dreamform', 'accessForms')) { 55 | return false; 56 | } 57 | 58 | return parent::isAccessible(); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /classes/Models/FormsPage.php: -------------------------------------------------------------------------------- 1 | content()->get('dreamform_log')->value()), 18 | [ 19 | 'parent' => $this, 20 | ] 21 | ); 22 | } 23 | 24 | public function addLogEntry(array $data, ?string $type = null, ?string $icon = null, ?string $title = null): SubmissionLogEntry 25 | { 26 | $item = new SubmissionLogEntry([ 27 | 'parent' => $this, 28 | 'siblings' => $items = $this->log(), 29 | 'data' => $data, 30 | 'type' => $type, 31 | 'icon' => $icon, 32 | 'title' => $title, 33 | ]); 34 | 35 | $items = $items->add($item); 36 | App::instance()->impersonate('kirby', fn () => $this->version(VersionId::LATEST)->update([ 37 | 'dreamform_log' => Yaml::encode($items->toArray()) 38 | ])); 39 | 40 | return $item; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /classes/Models/Log/SubmissionLog.php: -------------------------------------------------------------------------------- 1 | data = $params['data'] ?? []; 29 | $this->type = $params['type'] ?? (empty($params['data']) ? 'none' : 'info'); 30 | $this->icon = $params['icon'] ?? 'info'; 31 | $this->title = $params['title'] ?? ucfirst($this->type); 32 | $this->timestamp = $params['timestamp'] ?? time(); 33 | } 34 | 35 | /** 36 | * Returns the DateTime object for the log entry 37 | */ 38 | public function dateTime(): DateTime 39 | { 40 | return new DateTime($this->timestamp); 41 | } 42 | 43 | /** 44 | * Returns the timestamp for the log entry 45 | */ 46 | public function timestamp(): int 47 | { 48 | return $this->timestamp; 49 | } 50 | 51 | /** 52 | * Returns the type of the log entry 53 | */ 54 | public function type(): string 55 | { 56 | return $this->type; 57 | } 58 | 59 | /** 60 | * Returns the title of the log entry 61 | */ 62 | public function title(): string 63 | { 64 | return $this->title; 65 | } 66 | 67 | 68 | /** 69 | * Returns the icon for the log entry 70 | */ 71 | public function icon(): string 72 | { 73 | return $this->icon; 74 | } 75 | 76 | /** 77 | * Converts the item to an array 78 | */ 79 | public function toArray(): array 80 | { 81 | return [ 82 | 'data' => $this->data, 83 | 'id' => $this->id(), 84 | 'type' => $this->type(), 85 | 'icon' => $this->icon(), 86 | 'title' => $this->title(), 87 | 'timestamp' => $this->timestamp(), 88 | ]; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /classes/Models/SubmissionHandling.php: -------------------------------------------------------------------------------- 1 | apply( 29 | "dreamform.submit:{$type}", 30 | ['submission' => $this, 'form' => $this->form()], 31 | 'submission' 32 | ); 33 | } 34 | 35 | /** 36 | * Handles the form submission precognitive guards 37 | * @internal 38 | */ 39 | public function handlePrecognitiveGuards(): SubmissionPage 40 | { 41 | foreach ($this->form()->guards() as $guard) { 42 | $guard->precognitiveRun(); 43 | } 44 | 45 | return $this; 46 | } 47 | 48 | /** 49 | * Handles the form submission guards 50 | * @internal 51 | */ 52 | public function handleGuards(bool $postValidation = false): SubmissionPage 53 | { 54 | foreach ($this->form()->guards() as $guard) { 55 | $postValidation ? $guard->postValidation($this) : $guard->run(); 56 | } 57 | 58 | return $this; 59 | } 60 | 61 | /** 62 | * Validates the fields and collects values from the request 63 | * @internal 64 | */ 65 | public function handleFields() 66 | { 67 | $currentStep = App::instance()->request()->query()->get('dreamform-step', 1); 68 | foreach ($this->form()->fields($currentStep) as $field) { 69 | // skip "decorative" fields that don't have a value 70 | if (!$field::hasValue()) { 71 | continue; 72 | } 73 | 74 | // create a field instance & set the value from the request 75 | $field = $this->updateFieldFromRequest($field); 76 | 77 | // validate the field 78 | $validation = $field->validate(); 79 | 80 | $this->setField($field); 81 | if ($validation !== true) { 82 | // if the validation fails, set an error in the submission state 83 | $this->setError(field: $field->key(), message: $validation); 84 | } else { 85 | $this->removeError($field->key()); 86 | } 87 | } 88 | 89 | return $this; 90 | } 91 | 92 | /** 93 | * Run the actions for the submission 94 | * @internal 95 | */ 96 | public function handleActions(bool $force = false): SubmissionPage 97 | { 98 | if ( 99 | $force || 100 | ($this->isFinalStep() 101 | && $this->isSuccessful() 102 | && $this->isHam()) 103 | ) { 104 | $this->updateState(['actionsdidrun' => true]); 105 | foreach ($this->createActions(force: $force) as $action) { 106 | try { 107 | $action->run(); 108 | } catch (Exception $e) { 109 | // we only want to log "unknown" exceptions 110 | if ( 111 | $e instanceof PerformerException || $e instanceof SuccessException 112 | ) { 113 | if (!$e->shouldContinue()) { 114 | throw $e; 115 | } 116 | 117 | continue; 118 | } 119 | 120 | $this->addLogEntry([ 121 | 'text' => $e->getMessage(), 122 | 'template' => [ 123 | 'type' => $action->type(), 124 | ] 125 | ], type: 'error', icon: 'alert', title: "dreamform.submission.log.error"); 126 | } 127 | } 128 | } 129 | 130 | return $this; 131 | } 132 | 133 | /** 134 | * Finishes the form submission or advances to the next step 135 | * @internal 136 | */ 137 | public function finalize(): SubmissionPage 138 | { 139 | if (!$this->isSuccessful()) { 140 | return $this; 141 | } 142 | 143 | if ($this->isFinalStep()) { 144 | return $this->finish(); 145 | } 146 | 147 | return $this->advanceStep(); 148 | } 149 | 150 | /** 151 | * Handles the after-submit hooks for the fields 152 | * @internal 153 | */ 154 | public function handleAfterSubmitFields(): SubmissionPage 155 | { 156 | $currentStep = App::instance()->request()->query()->get('dreamform-step', 1); 157 | if ($this->isSuccessful()) { 158 | foreach ($this->form()->fields($currentStep) as $field) { 159 | $field->afterSubmit($this); 160 | } 161 | } 162 | 163 | return $this; 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /classes/Models/SubmissionMetadata.php: -------------------------------------------------------------------------------- 1 | content()->get('dreamform_sender')->toObject(); 20 | } 21 | 22 | /** 23 | * Updates the metadata for the submission 24 | */ 25 | public function updateMetadata(array $data): static 26 | { 27 | App::instance()->impersonate('kirby', fn () => $this->version(VersionId::LATEST)->update([ 28 | 'dreamform_sender' => array_merge($this->metadata()->toArray(), $data) 29 | ])); 30 | 31 | return $this; 32 | } 33 | 34 | /** 35 | * Collects metadata for the submission 36 | */ 37 | public function collectMetadata(): static 38 | { 39 | $datapoints = DreamForm::option('metadata.collect', []); 40 | 41 | foreach ($datapoints as $type) { 42 | if (method_exists($this, 'collect' . Str::camel($type))) { 43 | $this->{'collect' . Str::camel($type)}(); 44 | continue; 45 | } 46 | 47 | throw new Exception('[DreamForm] Unknown metadata type: ' . $type); 48 | } 49 | 50 | return $this; 51 | } 52 | 53 | protected function collectIp(): void 54 | { 55 | $this->updateMetadata(['ip' => App::instance()->visitor()->ip()]); 56 | } 57 | 58 | protected function collectUserAgent(): void 59 | { 60 | $this->updateMetadata(['userAgent' => App::instance()->visitor()->userAgent()]); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /classes/Models/SubmissionSession.php: -------------------------------------------------------------------------------- 1 | storage(); 25 | 26 | // If using a session-aware storage handler, let it handle the reference 27 | if ( 28 | $storage instanceof SubmissionSessionStorage || 29 | $storage instanceof SubmissionCacheStorage 30 | ) { 31 | $storage->storeReference(); 32 | } else { 33 | // For PlainTextStorage (already persisted), store the slug reference 34 | if ($mode === 'api' || (Htmx::isActive() && Htmx::isHtmxRequest())) { 35 | // In sessionless mode, the reference is passed via request body 36 | // Nothing to store server-side 37 | } else { 38 | // Store slug in PHP session for PRG mode 39 | $kirby->session()->set(DreamForm::SESSION_KEY, $this->slug()); 40 | } 41 | } 42 | 43 | return static::$session = $this; 44 | } 45 | 46 | /** 47 | * Reconstruct submission from data 48 | */ 49 | private static function reconstructSubmission(mixed $data): SubmissionPage|null 50 | { 51 | if (is_string($data)) { 52 | // It's a slug reference - submission exists on disk 53 | return DreamForm::findPageOrDraftRecursive($data); 54 | } 55 | 56 | if (is_array($data) && isset($data['type']) && $data['type'] === 'submission') { 57 | // It's submission metadata - reconstruct the submission 58 | $parent = DreamForm::findPageOrDraftRecursive($data['parent']); 59 | if ($parent) { 60 | return new SubmissionPage([ 61 | 'template' => $data['template'], 62 | 'slug' => $data['slug'], 63 | 'parent' => $parent, 64 | ]); 65 | } 66 | } 67 | 68 | return null; 69 | } 70 | 71 | /** 72 | * Clean up submission if appropriate 73 | */ 74 | private static function cleanupIfNeeded(SubmissionPage $submission): void 75 | { 76 | // Only clean up if submission is finished 77 | // Don't clean up on validation errors - they're expected during form filling 78 | if ($submission->isFinished()) { 79 | $kirby = App::instance(); 80 | $storage = $submission->storage(); 81 | 82 | if ($storage instanceof SubmissionSessionStorage) { 83 | $kirby->session()->remove(DreamForm::SESSION_KEY); 84 | $storage->cleanup(); 85 | } elseif ($storage instanceof SubmissionCacheStorage) { 86 | $storage->cleanup(); 87 | } 88 | } 89 | } 90 | 91 | /** 92 | * Pull submission from session 93 | */ 94 | public static function fromSession(): SubmissionPage|null 95 | { 96 | // Return cached instance if available 97 | if (static::$session) { 98 | return static::$session; 99 | } 100 | 101 | $kirby = App::instance(); 102 | $mode = DreamForm::option('mode', 'prg'); 103 | 104 | // Determine where to look for data 105 | if ($mode === 'api' || ($mode === 'htmx' && Htmx::isHtmxRequest())) { 106 | // Get from request body 107 | $raw = $kirby->request()->body()->get('dreamform:session'); 108 | if (!$raw || $raw === 'null') { 109 | return null; 110 | } 111 | 112 | $id = Htmx::decrypt($raw); 113 | if (Str::startsWith($id, 'page://')) { 114 | $data = $id; 115 | } else { 116 | // Get from cache 117 | $data = $kirby->cache('tobimori.dreamform.sessionless')->get($id); 118 | } 119 | } else { 120 | // Get from PHP session 121 | $data = $kirby->session()->get(DreamForm::SESSION_KEY); 122 | } 123 | 124 | if (!$data) { 125 | return null; 126 | } 127 | 128 | // Reconstruct submission 129 | $submission = static::reconstructSubmission($data); 130 | if (!($submission instanceof SubmissionPage)) { 131 | return null; 132 | } 133 | 134 | static::$session = $submission; 135 | static::cleanupIfNeeded($submission); 136 | 137 | return static::$session; 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /classes/Performer.php: -------------------------------------------------------------------------------- 1 | cache('tobimori.dreamform.performer'); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /classes/Permissions/FormPermissions.php: -------------------------------------------------------------------------------- 1 | role()->permissions()->for('tobimori.dreamform', 'accessForms'); 12 | } 13 | 14 | protected function canCreate(): bool 15 | { 16 | return static::user()->role()->permissions()->for('tobimori.dreamform', 'createForms'); 17 | } 18 | 19 | protected function canUpdate(): bool 20 | { 21 | return static::user()->role()->permissions()->for('tobimori.dreamform', 'updateForms'); 22 | } 23 | 24 | protected function canDelete(): bool 25 | { 26 | return static::user()->role()->permissions()->for('tobimori.dreamform', 'deleteForms'); 27 | } 28 | 29 | protected function canDuplicate(): bool 30 | { 31 | return static::user()->role()->permissions()->for('tobimori.dreamform', 'duplicateForms'); 32 | } 33 | 34 | protected function canChangeTitle(): bool 35 | { 36 | return static::user()->role()->permissions()->for('tobimori.dreamform', 'changeFormTitle'); 37 | } 38 | 39 | protected function canChangeStatus(): bool 40 | { 41 | return static::user()->role()->permissions()->for('tobimori.dreamform', 'changeFormStatus'); 42 | } 43 | 44 | protected function canChangeSlug(): bool 45 | { 46 | return static::user()->role()->permissions()->for('tobimori.dreamform', 'changeFormSlug'); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /classes/Permissions/SubmissionPermissions.php: -------------------------------------------------------------------------------- 1 | role()->permissions()->for('tobimori.dreamform', 'accessSubmissions'); 12 | } 13 | 14 | protected function canDelete(): bool 15 | { 16 | return static::user()->role()->permissions()->for('tobimori.dreamform', 'deleteSubmissions'); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /classes/Storage/SubmissionCacheStorage.php: -------------------------------------------------------------------------------- 1 | cache = App::instance()->cache('tobimori.dreamform.sessionless'); 30 | } 31 | 32 | /** 33 | * Returns the cache key for storing submission data 34 | */ 35 | protected function cacheKey(): string 36 | { 37 | return $this->model->id() . ':data'; 38 | } 39 | 40 | /** 41 | * Get submission data from cache 42 | */ 43 | protected function getCacheData(): array 44 | { 45 | return $this->cache->get($this->cacheKey()) ?? []; 46 | } 47 | 48 | /** 49 | * Set submission data in cache 50 | */ 51 | protected function setCacheData(array $data): void 52 | { 53 | $this->cache->set($this->cacheKey(), $data, 60 * 24); // 24 hours 54 | } 55 | 56 | /** 57 | * Deletes an existing version in an idempotent way 58 | */ 59 | public function delete(VersionId $versionId, Language $language): void 60 | { 61 | $data = $this->getCacheData(); 62 | unset($data[$versionId->value()][$language->code()]); 63 | $this->setCacheData($data); 64 | } 65 | 66 | /** 67 | * Checks if a version exists 68 | */ 69 | public function exists(VersionId $versionId, Language $language): bool 70 | { 71 | $data = $this->getCacheData(); 72 | return isset($data[$versionId->value()][$language->code()]); 73 | } 74 | 75 | /** 76 | * Returns the modification timestamp of a version if it exists 77 | */ 78 | public function modified(VersionId $versionId, Language $language): int|null 79 | { 80 | $data = $this->getCacheData(); 81 | return $data[$versionId->value()][$language->code()]['_modified'] ?? null; 82 | } 83 | 84 | /** 85 | * Returns the stored content fields 86 | * 87 | * @return array 88 | */ 89 | public function read(VersionId $versionId, Language $language): array 90 | { 91 | $data = $this->getCacheData(); 92 | $fields = $data[$versionId->value()][$language->code()] ?? []; 93 | unset($fields['_modified']); 94 | return $fields; 95 | } 96 | 97 | /** 98 | * Updates the modification timestamp of an existing version 99 | * 100 | * @throws \Kirby\Exception\NotFoundException If the version does not exist 101 | */ 102 | public function touch(VersionId $versionId, Language $language): void 103 | { 104 | if (!$this->exists($versionId, $language)) { 105 | throw new \Kirby\Exception\NotFoundException('Version does not exist'); 106 | } 107 | 108 | $data = $this->getCacheData(); 109 | $data[$versionId->value()][$language->code()]['_modified'] = time(); 110 | $this->setCacheData($data); 111 | } 112 | 113 | /** 114 | * Writes the content fields of an existing version 115 | * 116 | * @param array $fields Content fields 117 | */ 118 | protected function write(VersionId $versionId, Language $language, array $fields): void 119 | { 120 | $data = $this->getCacheData(); 121 | $fields['_modified'] = time(); 122 | $data[$versionId->value()][$language->code()] = $fields; 123 | $this->setCacheData($data); 124 | } 125 | 126 | /** 127 | * Store submission reference in cache 128 | */ 129 | public function storeReference(): void 130 | { 131 | if (!($this->model instanceof SubmissionPage)) { 132 | return; 133 | } 134 | 135 | /** @var SubmissionPage $submission */ 136 | $submission = $this->model; 137 | 138 | if (!$submission->exists()) { 139 | // Store metadata that can be used to reconstruct the submission 140 | $this->cache->set($submission->slug(), [ 141 | 'type' => 'submission', 142 | 'template' => $submission->intendedTemplate()->name(), 143 | 'slug' => $submission->slug(), 144 | 'parent' => $submission->parent()?->id(), 145 | // Content is already stored via the storage handler's write() method 146 | ], 60 * 24); // 24 hours 147 | } 148 | // If exists on disk, the reference will be passed via request body 149 | } 150 | 151 | /** 152 | * Clean up cache data for this submission 153 | */ 154 | public function cleanup(): void 155 | { 156 | $this->cache->remove($this->cacheKey()); 157 | 158 | if ($this->model instanceof SubmissionPage) { 159 | $this->cache->remove($this->model->slug()); 160 | } 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /classes/Storage/SubmissionSessionStorage.php: -------------------------------------------------------------------------------- 1 | model->id(); 23 | } 24 | 25 | /** 26 | * Get submission data from session 27 | */ 28 | protected function getSessionData(): array 29 | { 30 | return App::instance()->session()->get($this->sessionKey(), []); 31 | } 32 | 33 | /** 34 | * Set submission data in session 35 | */ 36 | protected function setSessionData(array $data): void 37 | { 38 | App::instance()->session()->set($this->sessionKey(), $data); 39 | } 40 | 41 | /** 42 | * Deletes an existing version in an idempotent way 43 | */ 44 | public function delete(VersionId $versionId, Language $language): void 45 | { 46 | $data = $this->getSessionData(); 47 | unset($data[$versionId->value()][$language->code()]); 48 | $this->setSessionData($data); 49 | } 50 | 51 | /** 52 | * Checks if a version exists 53 | */ 54 | public function exists(VersionId $versionId, Language $language): bool 55 | { 56 | $data = $this->getSessionData(); 57 | return isset($data[$versionId->value()][$language->code()]); 58 | } 59 | 60 | /** 61 | * Returns the modification timestamp of a version if it exists 62 | */ 63 | public function modified(VersionId $versionId, Language $language): int|null 64 | { 65 | $data = $this->getSessionData(); 66 | return $data[$versionId->value()][$language->code()]['_modified'] ?? null; 67 | } 68 | 69 | /** 70 | * Returns the stored content fields 71 | * 72 | * @return array 73 | */ 74 | public function read(VersionId $versionId, Language $language): array 75 | { 76 | $data = $this->getSessionData(); 77 | $fields = $data[$versionId->value()][$language->code()] ?? []; 78 | unset($fields['_modified']); 79 | return $fields; 80 | } 81 | 82 | /** 83 | * Updates the modification timestamp of an existing version 84 | * 85 | * @throws \Kirby\Exception\NotFoundException If the version does not exist 86 | */ 87 | public function touch(VersionId $versionId, Language $language): void 88 | { 89 | if (!$this->exists($versionId, $language)) { 90 | throw new \Kirby\Exception\NotFoundException('Version does not exist'); 91 | } 92 | 93 | $data = $this->getSessionData(); 94 | $data[$versionId->value()][$language->code()]['_modified'] = time(); 95 | $this->setSessionData($data); 96 | } 97 | 98 | /** 99 | * Writes the content fields of an existing version 100 | * 101 | * @param array $fields Content fields 102 | */ 103 | protected function write(VersionId $versionId, Language $language, array $fields): void 104 | { 105 | $data = $this->getSessionData(); 106 | $fields['_modified'] = time(); 107 | $data[$versionId->value()][$language->code()] = $fields; 108 | $this->setSessionData($data); 109 | } 110 | 111 | /** 112 | * Store submission reference in session 113 | */ 114 | public function storeReference(): void 115 | { 116 | if (!($this->model instanceof SubmissionPage)) { 117 | return; 118 | } 119 | 120 | /** @var SubmissionPage $submission */ 121 | $submission = $this->model; 122 | $session = App::instance()->session(); 123 | 124 | if ($submission->exists()) { 125 | // Page exists on disk - just store the slug 126 | $session->set(DreamForm::SESSION_KEY, $submission->slug()); 127 | } else { 128 | // Page doesn't exist - store a data array that can be used to reconstruct it 129 | $session->set(DreamForm::SESSION_KEY, [ 130 | 'type' => 'submission', 131 | 'template' => $submission->intendedTemplate()->name(), 132 | 'slug' => $submission->slug(), 133 | 'parent' => $submission->parent()?->id(), 134 | // Content is already stored via the storage handler's write() method 135 | ]); 136 | } 137 | } 138 | 139 | /** 140 | * Clean up session data for this submission 141 | */ 142 | public function cleanup(): void 143 | { 144 | App::instance()->session()->remove($this->sessionKey()); 145 | App::instance()->session()->remove(DreamForm::SESSION_KEY); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /classes/Support/HasCache.php: -------------------------------------------------------------------------------- 1 | get($key); 27 | if ($value === null) { 28 | try { 29 | $value = $callback(); 30 | $cache->set($key, $value, $minutes); 31 | } catch (\Throwable $e) { 32 | $cache->remove($key); 33 | return null; 34 | } 35 | } 36 | 37 | return $value; 38 | } 39 | 40 | /** 41 | * Set a value for the performer cache 42 | */ 43 | protected static function setCache(string $key, mixed $value, int $minutes = 10): bool 44 | { 45 | if (!($cache = static::cacheInstance())) { 46 | return false; 47 | } 48 | 49 | $key = static::type() . '.' . $key; 50 | return $cache->set($key, $value, $minutes); 51 | } 52 | 53 | /** 54 | * Get a value from the performer cache 55 | */ 56 | protected static function getCache(string $key): mixed 57 | { 58 | if (!($cache = static::cacheInstance())) { 59 | return null; 60 | } 61 | 62 | $key = static::type() . '.' . $key; 63 | return $cache->get($key); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /classes/Support/Htmx.php: -------------------------------------------------------------------------------- 1 | request()->header('Hx-Request') === 'true'; 26 | } 27 | 28 | /** 29 | * Returns the secret key for encrypting and decrypting values 30 | */ 31 | private static function secret(): string 32 | { 33 | $secret = DreamForm::option('secret'); 34 | 35 | if (empty($secret)) { 36 | throw new \Exception('[DreamForm] Secret not set'); 37 | } 38 | 39 | return $secret; 40 | } 41 | 42 | public const CIPHER = 'AES-128-CBC'; 43 | 44 | /** 45 | * Encrypt a string value for use in HTMX attributes 46 | * Based on example code from https://www.php.net/manual/en/function.openssl-encrypt.php 47 | */ 48 | public static function encrypt(string $value): string 49 | { 50 | $ivlen = openssl_cipher_iv_length(self::CIPHER); 51 | $iv = openssl_random_pseudo_bytes($ivlen); 52 | $encrypted = openssl_encrypt($value, self::CIPHER, static::secret(), OPENSSL_RAW_DATA, $iv); 53 | $hmac = hash_hmac('sha256', $encrypted, static::secret(), true); 54 | 55 | return base64_encode($iv . $hmac . $encrypted); 56 | } 57 | 58 | /** 59 | * Decrypt a string value from HTMX attributes 60 | */ 61 | public static function decrypt(string $value): string 62 | { 63 | $c = base64_decode($value); 64 | $ivlen = openssl_cipher_iv_length(static::CIPHER); 65 | $iv = substr($c, 0, $ivlen); 66 | $hmac = substr($c, $ivlen, $sha2len = 32); 67 | $encrypted = substr($c, $ivlen + $sha2len); 68 | 69 | if (empty($hmac) || !hash_equals($hmac, hash_hmac('sha256', $encrypted, static::secret(), true))) { 70 | throw new \Exception('Decryption failed'); 71 | } 72 | 73 | return openssl_decrypt($encrypted, static::CIPHER, static::secret(), OPENSSL_RAW_DATA, $iv); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /classes/Support/Menu.php: -------------------------------------------------------------------------------- 1 | request()->path()->toString(); 24 | } 25 | 26 | /** 27 | * Returns the path to the forms page 28 | */ 29 | public static function formPath() 30 | { 31 | $formsPage = App::instance()->site()->findPageOrDraft(DreamForm::option('page')); 32 | return $formsPage?->panel()->path() ?? "/pages/forms"; 33 | } 34 | 35 | /** 36 | * Returns the menu item for the forms page 37 | */ 38 | public static function forms() 39 | { 40 | if (App::instance()->user()?->role()->permissions()->for('tobimori.dreamform', 'accessForms') === false) { 41 | return null; 42 | } 43 | 44 | return [ 45 | 'label' => t('dreamform.forms'), 46 | 'link' => static::formPath(), 47 | 'icon' => 'survey', 48 | 'current' => fn () => 49 | str_contains(static::path(), static::formPath()) 50 | ]; 51 | } 52 | 53 | /** 54 | * Returns the menu item for the submissions page 55 | */ 56 | public static function site() 57 | { 58 | return [ 59 | 'current' => fn (string|null $id) => $id === 'site' && !str_contains(static::path(), static::formPath()) 60 | ]; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tobimori/kirby-dreamform", 3 | "description": "Kirby DreamForm is an opiniated form builder plugin for Kirby CMS 4+ that makes forms work like magic", 4 | "type": "kirby-plugin", 5 | "license": "proprietary", 6 | "homepage": "https://plugins.andkindness.com/dreamform", 7 | "version": "2.0.0-rc.3", 8 | "authors": [ 9 | { 10 | "name": "Tobias Möritz", 11 | "email": "tobias@moeritz.io" 12 | } 13 | ], 14 | "autoload": { 15 | "psr-4": { 16 | "tobimori\\DreamForm\\": "classes" 17 | } 18 | }, 19 | "minimum-stability": "RC", 20 | "require": { 21 | "php": ">=8.3.0", 22 | "getkirby/composer-installer": "^1.2.1" 23 | }, 24 | "scripts": { 25 | "dist": "composer install --no-dev --optimize-autoloader", 26 | "fix": "php-cs-fixer fix" 27 | }, 28 | "config": { 29 | "optimize-autoloader": true, 30 | "allow-plugins": { 31 | "getkirby/composer-installer": true 32 | } 33 | }, 34 | "extra": { 35 | "kirby-cms-path": false 36 | }, 37 | "require-dev": { 38 | "getkirby/cms": "^5" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /config/api.php: -------------------------------------------------------------------------------- 1 | fn () => [ 9 | [ 10 | 'pattern' => '/dreamform/object/mailchimp/pages/(:any)/(:any)', 11 | 'action' => fn ($path, $list) => MailchimpAction::fieldMapping(DreamForm::findPageOrDraftRecursive(Str::replace($path, '+', '/')), $list), 12 | ] 13 | ] 14 | ]; 15 | -------------------------------------------------------------------------------- /config/areas.php: -------------------------------------------------------------------------------- 1 | fn () => [ 12 | 'dialogs' => [ 13 | 'dreamform/activate' => [ 14 | 'load' => fn () => [ 15 | 'component' => 'k-form-dialog', 16 | 'props' => [ 17 | 'fields' => [ 18 | 'domain' => [ 19 | 'label' => t('dreamform.license.activate.label'), 20 | 'type' => 'info', 21 | 'theme' => ($isLocal = App::instance()->system()->isLocal()) ? 'warning' : 'info', 22 | 'text' => tt( 23 | 'dreamform.license.activate.' . ($isLocal ? 'local' : 'domain'), 24 | ['domain' => App::instance()->system()->indexUrl()] 25 | ), 26 | ], 27 | 'email' => Field::email(['required' => true]), 28 | 'license' => [ 29 | 'label' => t('dreamform.license.key.label'), 30 | 'type' => 'text', 31 | 'required' => true, 32 | 'counter' => false, 33 | 'placeholder' => 'DF-XXX-1234XXXXXXXXXXXXXXXXXXXX', 34 | 'help' => t('dreamform.license.key.help'), 35 | ], 36 | ], 37 | 'submitButton' => [ 38 | 'icon' => 'key', 39 | 'text' => t('dreamform.license.activate'), 40 | 'theme' => 'love', 41 | ] 42 | ] 43 | ], 44 | 'submit' => function () { 45 | $body = App::instance()->request()->body(); 46 | 47 | if (!V::email($body->get('email'))) { 48 | throw new Exception(t('dreamform.license.error.email')); 49 | } 50 | 51 | if (!Str::startsWith($body->get('license'), 'DF-STD-') && !Str::startsWith($body->get('license'), 'DF-ENT-')) { 52 | throw new Exception(t('dreamform.license.error.key')); 53 | } 54 | 55 | License::downloadLicense( 56 | email: $body->get('email'), 57 | license: $body->get('license') 58 | ); 59 | 60 | return [ 61 | 'message' => 'License activated successfully!', 62 | ]; 63 | } 64 | ], 65 | 'submission/(:any)/mark-as-spam' => [ 66 | 'load' => function (string $path) { 67 | return [ 68 | 'component' => 'k-text-dialog', 69 | 'props' => [ 70 | 'text' => t('dreamform.submission.reportAsSpam.confirm'), 71 | 'submitButton' => [ 72 | 'text' => t('dreamform.submission.reportAsSpam.button'), 73 | 'icon' => 'spam', 74 | 'theme' => 'negative' 75 | ], 76 | ] 77 | ]; 78 | }, 79 | 'submit' => function (string $path) { 80 | $submission = DreamForm::findPageOrDraftRecursive(Str::replace($path, '+', '/')); 81 | $submission = $submission->markAsSpam(); 82 | 83 | return [ 84 | 'message' => t('dreamform.submission.reportAsSpam.success'), 85 | ]; 86 | } 87 | ], 88 | 'submission/(:any)/mark-as-ham' => [ 89 | 'load' => function (string $path) { 90 | $submission = DreamForm::findPageOrDraftRecursive(Str::replace($path, '+', '/')); 91 | 92 | return [ 93 | 'component' => 'k-text-dialog', 94 | 'props' => [ 95 | 'text' => t($submission->actionsDidRun() ? 'dreamform.submission.reportAsHam.confirm.default' : 'dreamform.submission.reportAsHam.confirm.unprocessed'), 96 | 'submitButton' => [ 97 | 'text' => t('dreamform.submission.reportAsHam.button'), 98 | 'icon' => 'shield-check', 99 | 'theme' => 'positive' 100 | ], 101 | ] 102 | ]; 103 | }, 104 | 'submit' => function (string $path) { 105 | $submission = DreamForm::findPageOrDraftRecursive(Str::replace($path, '+', '/')); 106 | $submission = $submission->markAsHam(); 107 | 108 | if (!$submission->actionsDidRun()) { 109 | $submission->updateState(['actionsdidrun' => true]); 110 | $submission->handleActions(force: true); 111 | } 112 | 113 | return [ 114 | 'message' => t('dreamform.submission.reportAsHam.success'), 115 | ]; 116 | } 117 | ], 118 | 'submission/(:any)/run-actions' => [ 119 | 'load' => function () { 120 | return [ 121 | 'component' => 'k-text-dialog', 122 | 'props' => [ 123 | 'text' => t('dreamform.submission.runActions.confirm'), 124 | 'submitButton' => [ 125 | 'text' => t('dreamform.submission.runActions.button'), 126 | 'icon' => 'play', 127 | 'theme' => 'positive' 128 | ], 129 | ] 130 | ]; 131 | }, 132 | 'submit' => function (string $path) { 133 | $submission = DreamForm::findPageOrDraftRecursive(Str::replace($path, '+', '/')); 134 | $submission = $submission->handleActions(force: true); 135 | 136 | return [ 137 | 'message' => t('dreamform.submission.runActions.success'), 138 | ]; 139 | } 140 | ], 141 | ] 142 | ] 143 | ]; 144 | -------------------------------------------------------------------------------- /config/blockMethods.php: -------------------------------------------------------------------------------- 1 | function (Collection $fields) { 7 | return $fields->findByKey($this->id()); 8 | } 9 | ]; 10 | -------------------------------------------------------------------------------- /config/fields.php: -------------------------------------------------------------------------------- 1 | [ 11 | 'extends' => 'object', 12 | 'props' => [ 13 | // Unset inherited props 14 | 'fields' => null, 15 | 16 | // reload when the following field changes 17 | 'sync' => function (string|null $sync = null) { 18 | return $sync; 19 | }, 20 | 21 | // fetch field setup from the API 22 | 'api' => function (string|null $api = null) { 23 | return $api; 24 | } 25 | ] 26 | ], 27 | 'dreamform-dynamic-field' => [ 28 | 'props' => [ 29 | 'after' => null, 30 | 'before' => null, 31 | 'placeholder' => null, 32 | 'icon' => null, 33 | 34 | 'limitType' => function (string|array|null $limitType = null): array|null { 35 | if (!is_array($limitType)) { 36 | $limitType = [$limitType]; 37 | } 38 | 39 | $limitType = array_filter($limitType, fn ($type) => $type !== null); 40 | if (empty($limitType)) { 41 | return null; 42 | } 43 | 44 | return $limitType; 45 | } 46 | ], 47 | 'computed' => [ 48 | 'value' => function () { 49 | $data = Data::decode($this->value, 'yaml'); 50 | 51 | if (isset($data[0]) && is_string($data[0])) { 52 | if (!!Str::match($data[0], "/^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/")) { 53 | return [ 54 | 'type' => 'dynamic', 55 | 'field' => $data[0], 56 | 'value' => null 57 | ]; 58 | } 59 | 60 | return [ 61 | 'type' => 'static', 62 | 'field' => null, 63 | 'value' => $data[0] 64 | ]; 65 | } 66 | 67 | return $data; 68 | }, 69 | 'options' => function () { 70 | $page = $this->model; 71 | 72 | $limit = $this->limitType(); 73 | return DreamForm::requestCache($limit ? [$page->uuid()->id(), implode(';', $limit)] : $page->uuid()->id(), function () use ($page, $limit) { 74 | $fields = []; 75 | foreach ($page->fields() as $field) { 76 | if (!$field::hasValue()) { 77 | continue; 78 | } 79 | 80 | if ($limit !== null && !A::has($limit, Str::replace($field->block()->type(), '-field', ''))) { 81 | continue; 82 | } 83 | 84 | $blueprint = $field::blueprint(); 85 | 86 | $fields[] = [ 87 | 'id' => $field->id(), 88 | 'label' => $field->block()->label()->or($field->key())->value(), 89 | 'icon' => $blueprint['icon'] ?? 'input-cursor-move', 90 | 'type' => $blueprint['name'] ?? "", 91 | ]; 92 | } 93 | 94 | return $fields; 95 | }); 96 | } 97 | ], 98 | 'validations' => [ 99 | 'value' => function (array|null $value) { 100 | if (empty($value) === true && !$this->required()) { 101 | return true; 102 | } 103 | 104 | if ($value['type'] === 'dynamic' && $value['field']) { 105 | $limit = $this->limitType(); 106 | foreach ($this->model->fields() as $field) { 107 | if (!$field::hasValue()) { 108 | continue; 109 | } 110 | 111 | if ($limit !== null && !A::has($limit, Str::replace($field->block()->type(), '-field', ''))) { 112 | continue; 113 | } 114 | 115 | if ($field->id() === $value['field']) { 116 | return true; 117 | } 118 | } 119 | } elseif ($value['type'] === 'static' && $value['value']) { 120 | return true; 121 | } 122 | 123 | if ($this->required()) { 124 | throw new InvalidArgumentException(); 125 | } 126 | }, 127 | ] 128 | ] 129 | ]; 130 | -------------------------------------------------------------------------------- /config/hooks.php: -------------------------------------------------------------------------------- 1 | function () { 11 | DreamForm::install(); 12 | }, 13 | 14 | /** 15 | * Injects submission variables in the page rendering process 16 | */ 17 | 'page.render:before' => function (string $contentType, array $data, Kirby\Cms\Page $page) { 18 | return [ 19 | ...$data, 20 | 'submission' => SubmissionPage::fromSession() 21 | ]; 22 | }, 23 | 24 | /* 25 | * Deletes all files associated with a submission page with elevated permissions, 26 | * so we can disallow deleting single files from the panel 27 | */ 28 | 'page.delete:before' => function (Kirby\Cms\Page $page) { 29 | if ($page->intendedTemplate()->name() === 'submission') { 30 | $page->kirby()->impersonate('kirby'); 31 | foreach ($page->files() as $file) { 32 | $file->delete(); 33 | } 34 | $page->kirby()->impersonate(); 35 | } 36 | } 37 | ]; 38 | -------------------------------------------------------------------------------- /config/options.php: -------------------------------------------------------------------------------- 1 | [ 8 | 'sessionless' => [ 9 | 'active' => true, 10 | ], 11 | 'fields' => [ 12 | 'active' => true 13 | ], 14 | 'performer' => [ 15 | 'active' => true 16 | ] 17 | ], 18 | 'useDataAttributes' => false, // uses data-form-url instead of action 19 | 'mode' => 'prg', // prg / api / htmx 20 | 'multiStep' => true, // Enable multi-step forms 21 | 'storeSubmissions' => true, // Store submissions in the content folder 22 | 'partialSubmissions' => true, // Allow partial submissions toggle in panel (requires precognition) 23 | 'precognition' => false, // Enable precognition (HTMX mode only) - Requires "idiomorph" htmx extension 24 | 'debug' => fn () => App::instance()->option('debug'), 25 | 'layouts' => [ // https://getkirby.com/docs/reference/panel/fields/layout#defining-your-own-layouts 26 | '1/1', 27 | '1/2, 1/2' 28 | ], 29 | 'page' => 'page://forms', // Slug or URI to the page where the forms are located 30 | 'secret' => null, // Encryption secret for htmx attributes 31 | 'marks' => ['bold', 'italic', 'underline', 'strike', 'link', 'email'], // Marks to be used in writer fields 32 | 'metadata' => [ 33 | 'collect' => [] // 'ip' | 'userAgent' 34 | ], 35 | 'guards' => [ 36 | 'available' => ['honeypot', 'csrf'], 37 | 'honeypot' => [ 38 | 'fields' => ['website', 'email', 'name', 'url', 'birthdate', 'comment', 'summary', 'subject'] 39 | ], 40 | 'akismet' => [ 41 | 'apiKey' => null, 42 | 'fields' => [ 43 | 'comment_author' => ['name', 'first-name', 'last-name', 'username'], 44 | 'comment_author_email' => ['email', 'mail', 'e-mail', 'email-address', 'emailaddress'], 45 | 'comment_author_url' => ['website', 'url', 'homepage', 'website-url'], 46 | 'comment_content' => ['message', 'comment', 'content', 'body', 'text', 'description'] 47 | ] 48 | ], 49 | 'turnstile' => [ 50 | 'theme' => 'auto', 51 | 'siteKey' => null, 52 | 'secretKey' => null, 53 | 'injectScript' => true 54 | ], 55 | 'hcaptcha' => [ 56 | 'theme' => 'auto', // 'auto', 'light', 'dark', 'custom', or custom theme object 57 | 'size' => 'normal', // 'normal' or 'compact' 58 | 'siteKey' => null, 59 | 'secretKey' => null, 60 | 'injectScript' => true, 61 | 'customTheme' => null // Custom theme configuration (Pro/Enterprise only) 62 | ], 63 | 'ratelimit' => [ 64 | 'limit' => 10, 65 | 'interval' => 3 66 | ] 67 | ], 68 | 'fields' => [ 69 | 'available' => true, 70 | 'email' => [ 71 | 'dnsLookup' => true, 72 | 'disposableEmails' => [ 73 | 'disallow' => true, 74 | 'list' => 'https://raw.githubusercontent.com/disposable/disposable-email-domains/master/domains.txt' 75 | ] 76 | ], 77 | 'pages' => [ 78 | 'query' => 'site.childrenAndDrafts.filterBy("intendedTemplate", "!=", "forms")' // Page query for the pages field type 79 | ], 80 | 'fileUpload' => [ 81 | 'types' => [ 82 | // JPEG, PNG, GIF, AVIF, WEBP 83 | 'images' => ["image/jpeg", "image/png", "image/gif", "image/avif", "image/webp",], 84 | // MP3, OGG, OPUS, WAV, WEBM 85 | 'audio' => ["audio/mpeg", "audio/ogg", "audio/opus", "audio/aac", "audio/wav", "audio/webm"], 86 | // AVI, MP4, MPEG, OGG, WEBM 87 | 'video' => ["video/x-msvideo", "video/mp4", "video/mpeg", "video/ogg", "video/webm"], 88 | // PDF, DOC, XLS, PPT 89 | 'documents' => ["application/pdf", "application/msword", "application/vnd.ms-excel", "application/vnd.ms-powerpoint"], 90 | // ZIP, RAR, TAR, 7Z 91 | 'archives' => ["application/zip", "application/x-rar-compressed", "application/x-tar", "application/x-7z-compressed"] 92 | ] 93 | ] 94 | ], 95 | 'actions' => [ 96 | 'available' => true, 97 | 'discord' => [ 98 | 'webhook' => null // Default webhook URL 99 | ], 100 | 'mailchimp' => [ 101 | 'apiKey' => null // Mailchimp API key 102 | ], 103 | 'loops' => [ 104 | 'apiKey' => null // Loops API key 105 | ], 106 | 'brevo' => [ 107 | 'apiKey' => null // Brevo API key 108 | ], 109 | 'buttondown' => [ 110 | 'apiKey' => null, // Buttondown API key 111 | 'simpleMode' => false // Simple mode supports free plans, removes tags support 112 | ], 113 | 'email' => [ 114 | 'from' => [ 115 | 'email' => fn () => App::instance()->option('email.transport.username'), 116 | 'name' => fn () => App::instance()->site()->title() 117 | ] 118 | ], 119 | 'plausible' => [ 120 | 'domain' => null, 121 | 'apiUrl' => 'https://plausible.io/api' 122 | ] 123 | ], 124 | 'integrations' => [ 125 | 'gravatar' => true, // Get profile pictures for email fields from Gravatar 126 | ], 127 | 'refererPageResolver' => fn (string | null $referer) => DreamForm::findPageOrDraftRecursive($referer) // Callback to resolve custom page URLs to pages 128 | ]; 129 | -------------------------------------------------------------------------------- /config/sections.php: -------------------------------------------------------------------------------- 1 | [ 9 | 'computed' => [ 10 | 'page' => function () { 11 | if ($this->model()->intendedTemplate()->name() !== 'submission') { 12 | throw new Exception('[DreamForm] This section can only be used on submission pages'); 13 | } 14 | 15 | return $this->model(); 16 | }, 17 | 'isSpam' => function () { 18 | return $this->model()->isSpam(); 19 | }, 20 | 'isPartial' => function () { 21 | return !$this->model()->isFinished(); 22 | }, 23 | 'log' => function () { 24 | return $this->model()->log()->toArray(); 25 | } 26 | ] 27 | ], 28 | 'dreamform-license' => [ 29 | 'computed' => [ 30 | 'local' => function () { 31 | return App::instance()->system()->isLocal(); 32 | }, 33 | 'activated' => function () { 34 | return License::fromDisk()->isValid(); 35 | } 36 | ] 37 | ] 38 | ]; 39 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import prettier from "eslint-config-prettier"; 3 | import vue from "eslint-plugin-vue"; 4 | 5 | export default [ 6 | js.configs.recommended, 7 | ...vue.configs["flat/vue2-recommended"], 8 | prettier, 9 | { 10 | rules: { 11 | "vue/attributes-order": "error", 12 | "vue/component-definition-name-casing": "off", 13 | "vue/html-closing-bracket-newline": [ 14 | "error", 15 | { 16 | singleline: "never", 17 | multiline: "always", 18 | }, 19 | ], 20 | "vue/multi-word-component-names": "off", 21 | "vue/require-default-prop": "off", 22 | "vue/require-prop-types": "error", 23 | "no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^props$" }], 24 | }, 25 | languageOptions: { 26 | ecmaVersion: 2022, 27 | globals: { 28 | window: "readonly", 29 | document: "readonly", 30 | console: "readonly", 31 | panel: "readonly" 32 | } 33 | }, 34 | }, 35 | ]; 36 | -------------------------------------------------------------------------------- /i18n.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1.8, 3 | "locale": { 4 | "source": "en", 5 | "targets": ["de", "en", "fr", "nl", "cs", "es", "it"] 6 | }, 7 | "buckets": { 8 | "json": { 9 | "include": ["translations/[locale].json"] 10 | } 11 | }, 12 | "$schema": "https://lingo.dev/schema/i18n.json" 13 | } 14 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@/*": ["./src/*"] 6 | }, 7 | "lib": ["esnext", "dom"] 8 | }, 9 | "vueCompilerOptions": { 10 | "target": 2.7 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "mjml": "mjml templates/emails/dreamform.mjml -o templates/emails/dreamform.html.php", 5 | "dev": "kirbyup serve src/index.js", 6 | "build": "kirbyup src/index.js", 7 | "lint": "eslint \"src/**/*.{js,vue}\"", 8 | "lint:fix": "npm run lint -- --fix", 9 | "format": "prettier --write \"src/**/*.{js,vue}\"", 10 | "prepare": "husky" 11 | }, 12 | "devDependencies": { 13 | "@eslint/js": "^9.28.0", 14 | "eslint": "^9.28.0", 15 | "eslint-config-prettier": "^10.1.5", 16 | "eslint-plugin-vue": "^10.1.0", 17 | "husky": "^9.1.7", 18 | "kirbyup": "^3.3.0", 19 | "lint-staged": "^16.1.0", 20 | "mjml": "^4.15.3", 21 | "prettier": "^3.5.3" 22 | }, 23 | "dependencies": { 24 | "kirbyuse": "^0.12.1" 25 | }, 26 | "browserslist": [ 27 | "last 2 Android versions", 28 | "last 2 Chrome versions", 29 | "last 2 ChromeAndroid versions", 30 | "last 2 Edge versions", 31 | "last 2 Firefox versions", 32 | "last 2 FirefoxAndroid versions", 33 | "last 2 iOS versions", 34 | "last 2 KaiOS versions", 35 | "last 2 Safari versions", 36 | "last 2 Samsung versions", 37 | "last 2 Opera versions", 38 | "last 2 OperaMobile versions", 39 | "last 2 UCAndroid versions" 40 | ], 41 | "lint-staged": { 42 | "src/*.{js,vue}": [ 43 | "eslint --fix", 44 | "prettier --write" 45 | ], 46 | "*.php": [ 47 | ".husky/php-cs-fixer-lint-staged" 48 | ] 49 | }, 50 | "packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977" 51 | } 52 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | onlyBuiltDependencies: 2 | - '@parcel/watcher' 3 | - esbuild 4 | -------------------------------------------------------------------------------- /public.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt+adZ2ODZhDtkBriVnN8 3 | 9noQfD6v1CTJsekknHX+MdcolGb5ydXrfwNCxDXJ399RlY/JGqJTmnvy8rned6S9 4 | 5sIR6FowFWmJ+3lrzn0NbqRt1FRMDSNdg99priNUkKRkKajvBiOylRGXlniAQ540 5 | Eh6bm7BO8XMwyLMpuNAM7FOy55BmU0hzCovlVdOLjQM54HpYf8sV8+k37ysVS+5W 6 | u+TgFIg2HCj804raXo5mhZ3bMIyoK3YJ5oXVRa7j6zuE/YF2Gao8j684Br7sYefV 7 | qsyxtl3BRdwXaB98sSQIX4OQIbheqF1ftpDd+TaII6Q0qX+PlyGJb2MCDjAvyLxY 8 | lwIDAQAB 9 | -----END PUBLIC KEY----- 10 | -------------------------------------------------------------------------------- /snippets/fields/button.php: -------------------------------------------------------------------------------- 1 | fields( 19 | $submission?->form()->is($form) ? $submission?->currentStep() ?? 1 : 1 20 | )->filterBy('type', 'button')) 21 | && $buttonFields->last() === $field 22 | ) { 23 | snippet('dreamform/guards', compact('form', 'attr')); 24 | } 25 | 26 | snippet('dreamform/fields/partials/wrapper', compact('block', 'field', 'form', 'attr'), slots: true) ?> 27 | 28 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /snippets/fields/checkbox.php: -------------------------------------------------------------------------------- 1 | valueFor($block->key())?->value(), ',') ?? []; 19 | if (!is_array($previousValue)) { 20 | $previousValue = [$previousValue]; 21 | } 22 | 23 | $attr = A::merge($attr, $attr[$type] ?? []); 24 | snippet('dreamform/fields/partials/wrapper', $arguments = compact('block', 'field', 'form', 'attr'), slots: true); 25 | 26 | if ($block->label()->isNotEmpty()) { 27 | snippet('dreamform/fields/partials/label', $arguments); 28 | } ?> 29 | 30 | options()->toStructure() as $option) : ?> 31 |
> 32 | $type, 34 | 'id' => $form->elementId("{$block->id()}-{$option->indexOf()}"), 35 | 'name' => $block->key() . ($type === 'checkbox' ? '[]' : null), 36 | 'value' => $option->value(), 37 | 'checked' => A::has($previousValue, $option->value()), 38 | 'aria-invalid' => ($error = $submission?->errorFor($block->key(), $form)) ? true : null, 39 | 'aria-describedby' => $error ? $form->elementId("{$block->id()}/error") : null, 40 | ], $field->htmxAttr($form))) ?>> 41 | 42 |
43 | 44 | 45 | 49 | -------------------------------------------------------------------------------- /snippets/fields/email.php: -------------------------------------------------------------------------------- 1 | $block, 16 | 'form' => $form, 17 | 'field' => $field, 18 | 'attr' => A::merge($attr, $attr['input'] ?? []), 19 | 'type' => 'email' 20 | ]); 21 | -------------------------------------------------------------------------------- /snippets/fields/file-upload.php: -------------------------------------------------------------------------------- 1 | $block, 14 | 'form' => $form, 15 | 'field' => $field, 16 | 'attr' => $attr, 17 | 'type' => 'file', 18 | 'input' => [ 19 | 'name' => $block->key() . ($block->allowMultiple()->toBool() ? '[]' : ''), 20 | $block->allowMultiple()->toBool() ? 'multiple' : '', 21 | ] 22 | ]); 23 | -------------------------------------------------------------------------------- /snippets/fields/hidden.php: -------------------------------------------------------------------------------- 1 | 15 | 16 | 'hidden', 18 | 'id' => $form->elementId($block->id()), 19 | 'name' => $block->key(), 20 | 'value' => $form->valueFor($block->key()) 21 | ])) ?>> -------------------------------------------------------------------------------- /snippets/fields/number.php: -------------------------------------------------------------------------------- 1 | $block, 14 | 'field' => $field, 15 | 'form' => $form, 16 | 'attr' => $attr, 17 | 'type' => 'number', 18 | 'input' => [ 19 | 'step' => $block->step()->or(1)?->value(), 20 | 'min' => $block->min()->or(null)?->value(), 21 | 'max' => $block->max()->or(null)?->value(), 22 | ] 23 | ]); 24 | -------------------------------------------------------------------------------- /snippets/fields/pages.php: -------------------------------------------------------------------------------- 1 | $block, 14 | 'field' => $field, 15 | 'form' => $form, 16 | 'attr' => $attr 17 | ]); 18 | -------------------------------------------------------------------------------- /snippets/fields/partials/error.php: -------------------------------------------------------------------------------- 1 | errorFor($block->key(), $form) ?> 15 | 16 | $block->key(), 20 | 'id' => $form->elementId("{$block->id()}/error"), 21 | 'role' => 'alert', 22 | 'aria-atomic' => true 23 | ] 24 | )) ?>> -------------------------------------------------------------------------------- /snippets/fields/partials/label.php: -------------------------------------------------------------------------------- 1 | 15 | 16 | 22 | -------------------------------------------------------------------------------- /snippets/fields/partials/wrapper.php: -------------------------------------------------------------------------------- 1 | 17 | 18 |
Htmx::isActive() && DreamForm::option('precognition') ? 'this' : null, 20 | 'data-has-error' => !!$submission?->errorFor($block->key(), $form) 21 | ])) ?>> 22 | 23 |
-------------------------------------------------------------------------------- /snippets/fields/radio.php: -------------------------------------------------------------------------------- 1 | $block, 14 | 'field' => $field, 15 | 'form' => $form, 16 | 'attr' => $attr, 17 | 'type' => 'radio' 18 | ]); 19 | -------------------------------------------------------------------------------- /snippets/fields/select.php: -------------------------------------------------------------------------------- 1 | 18 | 19 | 45 | 46 | 48 | -------------------------------------------------------------------------------- /snippets/fields/text.php: -------------------------------------------------------------------------------- 1 | 22 | 23 | $type, 27 | 'id' => $form->elementId($block->id()), 28 | 'name' => $block->key(), 29 | 'placeholder' => $type !== 'file' ? $block->placeholder()->or(" ") : null, 30 | 'required' => $block->required()->toBool() ?? null, 31 | 'value' => $type !== 'file' ? $form->valueFor($block->key()) : null, 32 | 'aria-invalid' => ($error = $submission?->errorFor($block->key(), $form)) ? true : null, 33 | 'aria-describedby' => $error ? $form->elementId("{$block->id()}/error") : null, 34 | ], 35 | $type !== 'file' ? $field->htmxAttr($form) : [], 36 | $input ?? [] 37 | )) ?>> 38 | 39 | 41 | -------------------------------------------------------------------------------- /snippets/fields/textarea.php: -------------------------------------------------------------------------------- 1 | 17 | 18 | 30 | 31 | 33 | -------------------------------------------------------------------------------- /snippets/form.php: -------------------------------------------------------------------------------- 1 | [], 17 | 'row' => [], 18 | 'column' => [], 19 | 'field' => [], 20 | 'label' => [], 21 | 'error' => [], 22 | 'input' => [], 23 | 'button' => [], 24 | 'success' => [], 25 | 'inactive' => [], 26 | 27 | // field-specific attributes 28 | 'textarea' => [ 29 | 'field' => [], 30 | 'label' => [], 31 | 'error' => [], 32 | 'input' => [], 33 | ], 34 | 'text' => [ 35 | 'field' => [], 36 | 'label' => [], 37 | 'error' => [], 38 | 'input' => [], 39 | ], 40 | 'select' => [ 41 | 'field' => [], 42 | 'label' => [], 43 | 'error' => [], 44 | 'input' => [], 45 | ], 46 | 'number' => [ 47 | 'field' => [], 48 | 'label' => [], 49 | 'error' => [], 50 | 'input' => [], 51 | ], 52 | 'file' => [ 53 | 'field' => [], 54 | 'label' => [], 55 | 'error' => [], 56 | 'input' => [], 57 | ], 58 | 'email' => [ 59 | 'field' => [], 60 | 'label' => [], 61 | 'error' => [], 62 | 'input' => [], 63 | ], 64 | 'radio' => [ 65 | 'field' => [], 66 | 'label' => [], 67 | 'error' => [], 68 | 'input' => [], 69 | 'row' => [] 70 | ], 71 | 'checkbox' => [ 72 | 'field' => [], 73 | 'label' => [], 74 | 'error' => [], 75 | 'input' => [], 76 | 'row' => [] 77 | ], 78 | 'hidden' => [ 79 | 'input' => [] 80 | ], 81 | ], $attr ?? []); 82 | 83 | // don't show the form if it's a draft 84 | if (!$form || $form->status() === 'draft') { 85 | snippet('dreamform/inactive', ['form' => $form, 'attr' => $attr]); 86 | return; 87 | } 88 | 89 | if ($submission?->isFinished() && $submission->form()->is($form)) { 90 | snippet('dreamform/success', ['form' => $form, 'attr' => $attr]); 91 | return; 92 | } ?> 93 | 94 |
htmxAttr($page, $attr), 97 | $form->attr() 98 | )) ?>> 99 | $form, 'submission' => $submission]) ?> 100 | 101 |
true, 'role' => 'alert', 'aria-atomic' => true], $attr['error'])) ?>>errorFor(null, $form) ?>
102 | 103 | currentLayouts() as $layoutRow) : ?> 104 |
'display: grid; grid-template-columns: repeat(12, 1fr);', 106 | ])) ?>> 107 | columns() as $layoutColumn) : ?> 108 |
"grid-column-start: span {$layoutColumn->span(12)};", 110 | ])) ?>> 111 | blocks() as $block) { 112 | // get the field instance to access field methods 113 | $field = $block->toFormField($form->fields()); 114 | 115 | if ($field) { 116 | snippet( 117 | "dreamform/fields/{$field->type()}", 118 | [ 119 | 'block' => $block, 120 | 'field' => $field, 121 | 'form' => $form, 122 | 'attr' => $attr 123 | ] 124 | ); 125 | } 126 | } ?> 127 |
128 | 129 |
130 | 131 |
132 | -------------------------------------------------------------------------------- /snippets/guards.php: -------------------------------------------------------------------------------- 1 | guards() as $guard) { 12 | if ($guard::hasSnippet()) { 13 | snippet( 14 | "dreamform/guards/{$guard->type()}", 15 | compact('form', 'attr', 'guard') 16 | ); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /snippets/guards/csrf.php: -------------------------------------------------------------------------------- 1 | 7 | 8 | -------------------------------------------------------------------------------- /snippets/guards/hcaptcha.php: -------------------------------------------------------------------------------- 1 | 20 | 21 | 22 | 23 | 42 | 'h-captcha', 48 | 'data-sitekey' => $guard::siteKey(), 49 | 'data-size' => DreamForm::option('guards.hcaptcha.size', 'normal') 50 | ]; 51 | 52 | // Only add data-theme for non-custom themes 53 | if (!$isCustomTheme) { 54 | $attrs['data-theme'] = $theme; 55 | } ?> 56 | 57 |
> 58 |
59 | 60 | 61 | 73 | -------------------------------------------------------------------------------- /snippets/guards/honeypot.php: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /snippets/guards/turnstile.php: -------------------------------------------------------------------------------- 1 | 15 | 16 | 17 | 18 | 25 | 27 | 28 |
'cf-turnstile', 30 | 'data-theme' => DreamForm::option('guards.turnstile.theme', 'auto'), 31 | 'data-sitekey' => $guard::siteKey() 32 | ]) ?>> 33 |
-------------------------------------------------------------------------------- /snippets/inactive.php: -------------------------------------------------------------------------------- 1 | 11 | 12 |
> 13 | 14 |
15 | -------------------------------------------------------------------------------- /snippets/session.php: -------------------------------------------------------------------------------- 1 | 14 | 'hidden', 16 | 'id' => $id = $form->uuid()->id() . '-session', 17 | 'name' => 'dreamform:session', 18 | 'value' => $submission ? Htmx::encrypt(($submission->exists() ? "page://" : "") . $submission->slug()) : null, 19 | 'hx-swap-oob' => isset($swap) && $swap ? "outerHTML:#{$id}" : null 20 | ]) ?>> 21 | 22 | -------------------------------------------------------------------------------- /snippets/success.php: -------------------------------------------------------------------------------- 1 | 14 | 15 |
$form->elementId()])) ?>> 16 | successMessage()->or(t('dreamform.form.successMessage.default')) ?> 17 |
18 | -------------------------------------------------------------------------------- /src/components/DynamicFieldPreview.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 43 | 44 | 57 | -------------------------------------------------------------------------------- /src/components/Editable.vue: -------------------------------------------------------------------------------- 1 | 134 | 135 | 150 | 151 | 172 | -------------------------------------------------------------------------------- /src/components/FieldError.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 22 | 23 | 40 | -------------------------------------------------------------------------------- /src/components/FieldHeader.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 57 | 58 | 108 | -------------------------------------------------------------------------------- /src/components/FieldInput.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 25 | 26 | 61 | -------------------------------------------------------------------------------- /src/components/log/EmailEntry.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 84 | 85 | 181 | -------------------------------------------------------------------------------- /src/components/log/EntryBase.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 32 | 33 | 84 | -------------------------------------------------------------------------------- /src/components/log/ErrorEntry.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 20 | -------------------------------------------------------------------------------- /src/components/log/InfoEntry.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 17 | 18 | 29 | -------------------------------------------------------------------------------- /src/core/Layout.vue: -------------------------------------------------------------------------------- 1 | This is the layout component extended to accomodate multi-step form layouts. 2 | Keep in mind that this component is heavily affected by any Core changes if they 3 | happen and so it's important to keep an eye on the core changes and update this 4 | component accordingly. 5 | https://github.com/getkirby/kirby/blob/main/panel/src/components/Forms/Layouts/Layout.vue 6 | This also means that the plugin does not work if any other plugin modifies the 7 | layout component as well. We can solve this by creating an entire custom field 8 | similar to https://github.com/tobimori/kirby-icon-field. 9 | 10 | 62 | 63 | 78 | 79 | 127 | -------------------------------------------------------------------------------- /src/core/LayoutSelector.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 49 | 50 | 64 | -------------------------------------------------------------------------------- /src/fields/ApiObject.vue: -------------------------------------------------------------------------------- 1 | 71 | 72 | 79 | -------------------------------------------------------------------------------- /src/fields/DynamicField.vue: -------------------------------------------------------------------------------- 1 | 89 | 90 | 165 | 166 | 191 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | .df-field { 2 | padding: var(--spacing-3) var(--spacing-4); 3 | height: 100%; 4 | } 5 | 6 | .k-header-buttons { 7 | .k-panel[data-template='submission'] &, 8 | .k-panel[data-template='forms'] & { 9 | display: none; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/previews/ButtonField.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 21 | 22 | 32 | -------------------------------------------------------------------------------- /src/previews/ChoicesField.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 46 | 47 | 84 | -------------------------------------------------------------------------------- /src/previews/FileUploadField.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 40 | 41 | 57 | -------------------------------------------------------------------------------- /src/previews/HiddenField.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 28 | 29 | 54 | -------------------------------------------------------------------------------- /src/previews/SelectField.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 34 | 35 | 87 | -------------------------------------------------------------------------------- /src/previews/TextField.vue: -------------------------------------------------------------------------------- 1 | 53 | 54 | 66 | 67 | 80 | -------------------------------------------------------------------------------- /src/sections/License.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 69 | 70 | 113 | -------------------------------------------------------------------------------- /src/sections/Submission.vue: -------------------------------------------------------------------------------- 1 | 64 | 65 | 140 | 141 | 194 | -------------------------------------------------------------------------------- /src/utils/block.js: -------------------------------------------------------------------------------- 1 | import { disabled, id, section } from "kirbyuse/props" 2 | 3 | export const props = { 4 | fieldset: section.fieldset, 5 | ...disabled, 6 | ...id, 7 | endpoints: { 8 | default: () => ({}), 9 | type: [Array, Object] 10 | }, 11 | content: Object 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/date.js: -------------------------------------------------------------------------------- 1 | export function formatDate(timestamp) { 2 | const locale = window.panel.user.language 3 | const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }) 4 | 5 | const diff = (Date.now() - timestamp * 1000) / 1000 6 | const units = [ 7 | { unit: "year", seconds: 365 * 24 * 60 * 60 }, 8 | { unit: "month", seconds: 30 * 24 * 60 * 60 }, 9 | { unit: "day", seconds: 24 * 60 * 60 }, 10 | { unit: "hour", seconds: 60 * 60 }, 11 | { unit: "minute", seconds: 60 } 12 | ] 13 | 14 | for (const { unit, seconds } of units) { 15 | const value = Math.floor(diff / seconds) 16 | if (value > 0) { 17 | return rtf.format(0 - value, unit) 18 | } 19 | } 20 | 21 | return window.panel.$t("dreamform.justNow") 22 | } 23 | -------------------------------------------------------------------------------- /templates/emails/dreamform.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | .shadow { box-shadow: 0 1px 3px 0 #0000000d, 0 1px 2px 0 #00000006; } 9 | 10 | 11 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | site()->title() ?> 26 | 27 | 28 | 29 | "{$form->title()}"]) ?> 30 | 31 | 32 | 33 | 39 | 40 | 41 | fields()->filterBy(fn ($f) => $f::hasValue() && $f::type() !== 'file-upload') as $field) : 42 | $value = $submission->valueFor($field->key())?->escape(); 43 | if(str_starts_with($value ?? "", 'page://')) { 44 | $page = \Kirby\Cms\App::instance()->site()->find($value); 45 | if($page) { 46 | $value = $page->title(); 47 | } 48 | } 49 | ?> 50 | 51 | label() ?> 54 | 58 | last() !== $field) : ?> 59 | 64 | 65 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /templates/emails/dreamform.php: -------------------------------------------------------------------------------- 1 | $form->title()]) ?> 2 | 3 | 4 | ——— 5 | 6 | fields()->filterBy(fn ($f) => $f::hasValue() && $f::type() !== 'file-upload') as $field) : 7 | $value = $submission->valueFor($field->key())?->escape(); 8 | if (str_starts_with($value ?? "", 'page://')) { 9 | $page = \Kirby\Cms\App::instance()->site()->find($value); 10 | if ($page) { 11 | $value = $page->title(); 12 | } 13 | } 14 | ?> 15 | label() ?>: 16 | 17 | 18 | last() !== $field) : ?> 19 | 20 | ——— 21 | 22 | 24 | -------------------------------------------------------------------------------- /vendor/autoload.php: -------------------------------------------------------------------------------- 1 | array($baseDir . '/classes'), 10 | 'Kirby\\' => array($vendorDir . '/getkirby/composer-installer/src'), 11 | ); 12 | -------------------------------------------------------------------------------- /vendor/composer/autoload_real.php: -------------------------------------------------------------------------------- 1 | register(true); 35 | 36 | return $loader; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /vendor/composer/installed.php: -------------------------------------------------------------------------------- 1 | array( 5 | 'name' => 'tobimori/kirby-dreamform', 6 | 'pretty_version' => '2.0.0-rc.3', 7 | 'version' => '2.0.0.0-RC3', 8 | 'reference' => null, 9 | 'type' => 'kirby-plugin', 10 | 'install_path' => __DIR__ . '/../../', 11 | 'aliases' => array(), 12 | 'dev' => false, 13 | ), 14 | 'versions' => array( 15 | 'getkirby/composer-installer' => array( 16 | 'pretty_version' => '1.2.1', 17 | 'version' => '1.2.1.0', 18 | 'reference' => 'c98ece30bfba45be7ce457e1102d1b169d922f3d', 19 | 'type' => 'composer-plugin', 20 | 'install_path' => __DIR__ . '/../getkirby/composer-installer', 21 | 'aliases' => array(), 22 | 'dev_requirement' => false, 23 | ), 24 | 'tobimori/kirby-dreamform' => array( 25 | 'pretty_version' => '2.0.0-rc.3', 26 | 'version' => '2.0.0.0-RC3', 27 | 'reference' => null, 28 | 'type' => 'kirby-plugin', 29 | 'install_path' => __DIR__ . '/../../', 30 | 'aliases' => array(), 31 | 'dev_requirement' => false, 32 | ), 33 | ), 34 | ); 35 | -------------------------------------------------------------------------------- /vendor/composer/platform_check.php: -------------------------------------------------------------------------------- 1 | = 80300)) { 8 | $issues[] = 'Your Composer dependencies require a PHP version ">= 8.3.0". You are running ' . PHP_VERSION . '.'; 9 | } 10 | 11 | if ($issues) { 12 | if (!headers_sent()) { 13 | header('HTTP/1.1 500 Internal Server Error'); 14 | } 15 | if (!ini_get('display_errors')) { 16 | if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') { 17 | fwrite(STDERR, 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . implode(PHP_EOL, $issues) . PHP_EOL.PHP_EOL); 18 | } elseif (!headers_sent()) { 19 | echo 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . str_replace('You are running '.PHP_VERSION.'.', '', implode(PHP_EOL, $issues)) . PHP_EOL.PHP_EOL; 20 | } 21 | } 22 | trigger_error( 23 | 'Composer detected issues in your platform: ' . implode(' ', $issues), 24 | E_USER_ERROR 25 | ); 26 | } 27 | --------------------------------------------------------------------------------