├── .gitignore
├── LICENSE
├── Makefile
├── README.md
├── composer.json
├── config
├── config.yaml
└── services.yaml
├── couscous.yml
├── docs
├── captcha.md
├── coding_standards.md
├── creating_new_forms.md
├── handlers
│ ├── index.md
│ ├── redirect.md
│ ├── save_to_contenttype.md
│ └── save_to_database.md
├── index.md
├── overriding_form_configuration.md
├── search.md
├── setting_up_contact.md
└── tips.md
├── ecs.php
├── phpstan.neon
├── src
├── BoltFormsConfig.php
├── CaptchaException.php
├── Event
│ ├── BoltFormsEvent.php
│ ├── BoltFormsEvents.php
│ ├── PostSubmitEvent.php
│ └── PostSubmitEventDispatcher.php
├── EventSubscriber
│ ├── AbstractPersistSubscriber.php
│ ├── ContentTypePersister.php
│ ├── DbTablePersister.php
│ ├── FileUploadHandler.php
│ ├── HoneypotSubscriber.php
│ ├── Logger.php
│ ├── Mailer.php
│ ├── Redirect.php
│ └── SymfonyFormProxySubscriber.php
├── Extension.php
├── Factory
│ ├── EmailFactory.php
│ ├── FieldConstraints.php
│ ├── FieldOptions.php
│ └── FieldType.php
├── Form
│ ├── CaptchaType.php
│ └── ContenttypeType.php
├── FormBuilder.php
├── FormHelper.php
├── FormRuntime.php
├── Honeypot.php
├── Services
│ ├── HcaptchaService.php
│ └── RecaptchaService.php
├── TwigExtension.php
└── Validator
│ └── Constraints
│ ├── Hcaptcha.php
│ ├── HcaptchaValidator.php
│ ├── Recaptcha.php
│ └── RecaptchaValidator.php
└── templates
├── assets
└── boltforms.css
├── captcha.html.twig
├── email.html.twig
├── email_blocks.html.twig
├── email_blocks_table.html.twig
├── email_table.html.twig
├── form.html.twig
├── honeypotstyle.html.twig
├── page.html.twig
└── widget.html.twig
/.gitignore:
--------------------------------------------------------------------------------
1 | ### Platfom-specific files
2 | .DS_Store
3 | thumbs.db
4 | Vagrantfile
5 | .vagrant*
6 | .idea
7 | .vscode/*
8 | appveyor.yml
9 |
10 | vendor/
11 | composer.lock
12 | var/
13 |
14 | .couscous
15 | couscous
16 | couscous.phar
17 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 YourNameHere, Acme
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | csfix:
2 | vendor/bin/ecs check src --fix
3 | make stancheck
4 |
5 |
6 | stancheck:
7 | vendor/bin/phpstan --memory-limit=1G analyse -c phpstan.neon src
8 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Forms
2 |
3 | Authors: Bob den Otter, Ivo Valchev
4 |
5 | This Bolt extension can be used to handle forms in your Bolt 4/5 project.
6 |
7 | Installation:
8 |
9 | ```bash
10 | composer require bolt/forms
11 | ```
12 |
13 | The full documentation for this extension can be found here:
14 | [BoltForms documentation](https://bolt.github.io/forms/).
15 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "bolt/forms",
3 | "description": "📦 This Bolt extension can be used to handle forms in your Bolt 5 project.",
4 | "type": "bolt-extension",
5 | "license": "MIT",
6 | "authors": [
7 | {
8 | "name": "Bob den Otter",
9 | "email": "bobdenotter@gmail.com"
10 | }
11 | ],
12 | "require": {
13 | "php": ">=7.2.9",
14 | "twig/twig": "^2.12 || ^3.0",
15 | "ext-json": "*",
16 | "gregwar/captcha-bundle": "^2.2",
17 | "pixelopen/cloudflare-turnstile-bundle": "^0.1.3"
18 | },
19 | "require-dev": {
20 | "phpstan/phpstan": "^0.12",
21 | "phpstan/phpstan-doctrine": "^0.12",
22 | "phpstan/phpstan-symfony": "^0.12",
23 | "bolt/core": "^5.1",
24 | "symplify/easy-coding-standard": "^10.2"
25 | },
26 | "autoload": {
27 | "psr-4": {
28 | "Bolt\\BoltForms\\": "src/"
29 | }
30 | },
31 | "minimum-stability": "dev",
32 | "prefer-stable": true,
33 | "extra": {
34 | "entrypoint": "Bolt\\BoltForms\\Extension"
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/config/config.yaml:
--------------------------------------------------------------------------------
1 | # BoltForms
2 |
3 | ## Debugging
4 | #
5 | # Global debugging on/off switch
6 | #
7 | # If enabled, ALL forms will go into debug mode.
8 | debug:
9 | enabled: true
10 | address: noreply@example.com
11 |
12 | ## Various SPAM protection measures
13 | #
14 | # Enable CSRF protection for forms. You should leave this set to 'true', unless
15 | # you know what you're doing.
16 | csrf: true
17 | honeypot: true
18 | spam-action: mark-as-spam # Either 'block', 'none' or 'mark-as-spam'
19 |
20 | # Global templates used for rendering forms and emails.
21 | #
22 | # You can find assorted templates to copy and modify in the templates/
23 | # directory. Simply copy them to a directory of your choosing in your theme
24 | # and set the template you want to override.
25 | #
26 | # More information on available template overrides can be found in the
27 | # Templates section of docs.
28 | #
29 | # NOTE: All paths are relative of your running theme.
30 | #
31 | # e.g. You created your templates in the `extensions/boltforms/` directory
32 | # inside your theme, you would use similar to:
33 | #
34 | # You can replace twig namespace @boltforms with @theme which will point to your active theme folder
35 | # The namespace is configured in config/packages/twig.yaml
36 | templates:
37 | form: '@boltforms/form.html.twig'
38 | email: '@boltforms/email_table.html.twig' # Replace with @boltforms/email.html.twig to send simple list-based emails.
39 | subject: '@boltforms/subject.html.twig'
40 | files: '@boltforms/file_browser.twig'
41 |
42 | # If the default templates are used, customise the layout, or see all options here:
43 | # https://symfony.com/doc/current/form/form_themes.html#symfony-builtin-forms
44 | # Note that when using 'form_div_layout.html.twig', checkboxes and radio-buttons
45 | # do not show labels by default.
46 | layout:
47 | form: 'bootstrap_5_layout.html.twig'
48 | bootstrap: true # if set to `true`, Bootstrap's CSS will automatically be included from CDN
49 |
50 | ## reCAPTCHa set up
51 | #
52 | # NOTE: You can get your keys from https://www.google.com/recaptcha/admin
53 | # * `public_key` key will be labeled "Site key"
54 | # * `private_key` key will be labeled "Secret key"
55 | #
56 | #recaptcha:
57 | # enabled: false
58 | # public_key: ''
59 | # private_key: ''
60 | # theme: light|dark
61 | # recaptcha_v3_threshold: 0.0 #Allows all forms to pass threshold.
62 | # recaptcha_v3_fail_message: We've been unable to verify whether you're human! Please, try resubmitting the form or get in touch via an alternative contact method.
63 |
64 | ## hCaptcha set up
65 | #
66 | # NOTE: You can get your keys from https://dashboard.hcaptcha.com/sites
67 | # * `public_key` key will be labeled "Site key" within the Site configuration
68 | # * `private_key` key will be labeled "Secret key" within the Settings tab
69 | #
70 | #hcaptcha:
71 | # enabled: false
72 | # public_key: ''
73 | # private_key: ''
74 | # theme: light|dark
75 |
76 | ## File Uploads
77 | #
78 | # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ SECURITY WARNING ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
79 | #
80 | # Handling file uploads is a very common attack vector used to compromise (hack)
81 | # a server.
82 | #
83 | # BoltForms does a few things to help increase slightly the security of handling
84 | # file uploads.
85 | #
86 | # Firstly, the directory that you specify for "base_directory" below should NOT
87 | # be an route accessible to the outside world. We provide a special route should
88 | # you wish to make the files browsable after upload.
89 | #
90 | # Secondly, is the "filename_handling" parameter. If an attacker knows the
91 | # uploaded file name, this can make their job a bit easier. So we provide three
92 | # options, e.g. uploading the file kitten.jpg:
93 | #
94 | # -------------------------------------
95 | # | Setting | Resulting file name |
96 | # |-----------------------------------|
97 | # | prefix | kitten.Ze1d352rrI3p.jpg |
98 | # | suffix | kitten.jpg.Ze1d352rrI3p |
99 | # | keep | kitten.jpg |
100 | # -------------------------------------
101 | #
102 | # We recommend "suffix" as this is the most secure, alternatively "prefix" will
103 | # aid in file browsing. However "keep" should always be used with caution!
104 | #
105 | # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ SECURITY WARNING ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
106 | #
107 | #uploads:
108 | # enabled: false # The global on/off switch for upload handling
109 | # base_directory: /full/path/for/uploaded/files/ # Outside web root and writable by the web server's user
110 | # filename_handling: suffix # Can be either "prefix", "suffix", or "keep"
111 | # management_controller: false # Enable a controller to handle browsing and downloading of uploaded files
112 |
113 | ## Example 1
114 | #
115 | # Contact Form
116 | contact:
117 | notification:
118 | enabled: true
119 | debug: false
120 | debug_address: name@example.com # Email address used when debug mode is enabled
121 | debug_smtp: true
122 | subject: Your message was submitted
123 | subject_prefix: '[Boltforms]'
124 | replyto_name: '{NAME}' # Email addresses and names can be either the
125 | replyto_email: '{EMAIL}' # name of a field below or valid text.
126 | to_name: 'WebsiteName'
127 | to_email: 'youremail@example.org'
128 | from_name: 'WebsiteName'
129 | from_email: 'youremail@example.org'
130 | feedback:
131 | success: Message submission successful
132 | error: There are errors in the form, please fix before trying to resubmit
133 | fields:
134 | name:
135 | type: text
136 | options:
137 | required: true
138 | label: Name
139 | attr:
140 | placeholder: Your name...
141 | constraints: [ NotBlank, { Length: { 'min': 3, 'max': 128 } } ]
142 | email:
143 | type: email
144 | options:
145 | required: true
146 | label: Email address
147 | attr:
148 | placeholder: Your email...
149 | constraints: [ NotBlank, Email ]
150 | message:
151 | type: textarea
152 | options:
153 | required: true
154 | label: Your message
155 | attr:
156 | placeholder: Your message...
157 | class: myclass
158 | needreply:
159 | type: choice
160 | options:
161 | required: false
162 | label: Do you want us to contact you back?
163 | choices: { 'Yes': 'yes', 'No': 'no' }
164 | multiple: false
165 | submit:
166 | type: submit
167 | options:
168 | label: Submit my message »
169 | attr:
170 | class: button primary
171 |
172 | ## Example 2
173 | #
174 | # Example Showcase Form
175 | #showcase:
176 | # submission:
177 | # ajax: false # Use AJAX for form submission and handling
178 | # notification:
179 | # enabled: true
180 | # debug: false
181 | # debug_address: name@example.com # Email address used when debug mode is enabled
182 | # debug_smtp: true
183 | # subject: "[TESTING] A showcase form was just submitted"
184 | # from_name: name # A field name, specified in the fields: section below
185 | # replyto_email: email # A field name, specified in the fields: section below
186 | # replyto_name: name # A field name, specified in the fields: section below
187 | # from_email: email # A field name, specified in the fields: section below
188 | # to_name: My Site # Must be valid text
189 | # to_email: noreply@example.com # Must be valid email address
190 | # cc_name: Info Person
191 | # cc_email: info@example.com
192 | # bcc_name: Manager Person
193 | # bcc_email: manager@example.com
194 | # templates: # Override the global Twig templates for this form
195 | # form: @boltforms/form.twig
196 | # email: @boltforms/email.twig
197 | # subject: @boltforms/subject.twig
198 | # files: @boltforms/file_browser.twig
199 | # feedback:
200 | # success: Form submission sucessful
201 | # error: There are errors in the form, please fix before trying to resubmit
202 | # redirect:
203 | # target: page/another-page # A page path, or URL
204 | # query: [ name, email ] # Optional keys for the GET parameters
205 | # database:
206 | # contenttype:
207 | # name: mycontenttype # ContentType record to create
208 | # field_map:
209 | # email: ~ # Do not try to save this field to the ContentType
210 | # message: 'sent_message' # Form field "message" will be saved to the ContentType field "sent_message"
211 | # table: bolt_secret_table # Specific database table to write to
212 | # uploads:
213 | # subdirectory: showcase_files # The (optional) subdirectory for uploaded files
214 | # recaptcha: false # This setting is optional to use the overall default, false is the only valid value to disable for this form only.
215 | # fields:
216 | # subject:
217 | # type: text
218 | # options:
219 | # required: true
220 | # label: Subject of your message
221 | # attr:
222 | # placeholder: Just a quick message...
223 | # prefix: '
A small HTML prefix
'
224 | # postfix: '
A small HTML postfix
'
225 | # constraints: [ NotBlank, {Length: {'min': 3, 'max': 30}} ]
226 | # name:
227 | # type: text
228 | # options:
229 | # required: true
230 | # label: Name
231 | # attr:
232 | # placeholder: Your name...
233 | # constraints: [ NotBlank, {Length: {'min': 3}} ]
234 | # email:
235 | # type: email
236 | # options:
237 | # required: true
238 | # label: Email address
239 | # attr:
240 | # placeholder: Your email...
241 | # class: myclass-email
242 | # constraints: [ NotBlank, Email ]
243 | # message:
244 | # type: textarea
245 | # options:
246 | # required: true
247 | # label: Your message
248 | # attr:
249 | # placeholder: Well, the other day I was thinking that maybe you ought to improve your website by adding...
250 | # class: myclass-message
251 | # pet_ownership:
252 | # type: choice
253 | # options:
254 | # required: false
255 | # label: Do you own a pet?
256 | # choices: [ Yes, No ]
257 | # multiple: false
258 | # cutest_pet:
259 | # type: choice
260 | # options:
261 | # required: false
262 | # label: Which do you think are cutest?
263 | # choices: { 'Fluffy Kittens': 'kittens', 'Cute Puppies': 'puppies' }
264 | # contenttype_choice:
265 | # type: choice
266 | # options:
267 | # required: false
268 | # label: ContentType selection
269 | # choices: content
270 | # params:
271 | # contenttype: pages
272 | # label: title # Field name to get the lable value from
273 | # value: slug # Field name to get the underlying choice value from
274 | # limit: 3 # Limit on the number of results returned
275 | # sort: title # Field name to sort on
276 | # order: ASC # Sorting direction, either "ASC" or "DESC"
277 | # where:
278 | # and: { 'koala': 'bear' } # Field name "koala" with the value of "bear"
279 | # or: { 'koala': 'dangerous' } # Field name "koala" with the value of "dangerous"
280 | # event:
281 | # type: choice
282 | # options:
283 | # required: false
284 | # label: What event would you like to attend?
285 | # choices: event # Will be dispatching on \Bolt\Extension\Bolt\BoltForms\Event\BoltFormsEvents::DATA_CHOICE_EVENT
286 | # multiple: false
287 | # event_custom:
288 | # type: choice
289 | # options:
290 | # required: false
291 | # label: What type of animal would you like to bring?
292 | # choices: event::your.custom.event.name # Will be dispatching on "your.custom.event.name"
293 | # multiple: false
294 | # choice_simple:
295 | # type: choice
296 | # options:
297 | # label: A very simple choice
298 | # choices: { 'Item One': 'item_1', 'Item Two': 'item_2' }
299 | # choice_traversable_choices_class:
300 | # type: choice
301 | # options:
302 | # required: false
303 | # label: Traversable choices class with "group_b" passed to the constructor
304 | # choices: Example\TraversableChoice::group_b
305 | # choice_label: Example\StaticChoice::choiceLabel
306 | # choice_static_choices_class:
307 | # type: choice
308 | # options:
309 | # required: false
310 | # label: Choices from the calls to a static class::function
311 | # choices: Example\StaticChoice::choices
312 | # choice_label: Example\StaticChoice::choiceLabel
313 | # choice_with_attrib:
314 | # type: choice
315 | # options:
316 | # label: HTML attributes added to each choice
317 | # choices: Example\StaticChoice::choices
318 | # choice_value: Example\StaticChoice::choiceValue
319 | # choice_label: Example\StaticChoice::choiceLabel
320 | # choice_attr: Example\StaticChoice::choiceAttr
321 | # group_simple:
322 | # type: choice
323 | # options:
324 | # label: Grouping Simple
325 | # choices:
326 | # 'Group Aye': { 'Item One': 'item_1', 'Item Two': 'item_2', }
327 | # 'Group Bee': { 'Item Eleven': 'item_11', 'Item Twelve': 'item_12' }
328 | # choice_group_callouts:
329 | # type: choice
330 | # options:
331 | # required: false
332 | # label: Grouping callouts
333 | # choices: Example\StaticChoice::choices
334 | # choice_label: Example\StaticChoice::choiceLabel
335 | # group_by: Example\StaticChoice::groupBy
336 | # choice_group_callouts_preferred_choices:
337 | # type: choice
338 | # options:
339 | # required: false
340 | # label: Grouping callouts with preferred choices
341 | # choices: Example\StaticChoice::choices
342 | # choice_label: Example\StaticChoice::choiceLabel
343 | # preferred_choices: Example\StaticChoice::preferredChoices
344 | # multiple: false
345 | # upload:
346 | # type: file
347 | # attach: true # attach your file if you need to send it or save it
348 | # options:
349 | # required: false
350 | # label: Picture of your pet that you want us to add to our site
351 | # hcaptcha:
352 | # type: captcha
353 | # options:
354 | # captcha_type: hcaptcha
355 | # recaptcha_v3:
356 | # type: captcha
357 | # options:
358 | # captcha_type: recaptcha_v3
359 | # recaptcha_v2:
360 | # type: captcha
361 | # options:
362 | # captcha_type: recaptcha_v2
363 | # # To not show a label at all, use "label: false"
364 | # label: Please complete this CAPTCHA
365 | # recaptcha_v2_invisible:
366 | # type: captcha
367 | # options:
368 | # captcha_type: recaptcha_v2
369 | # captcha_invisible: true
370 | # submit:
371 | # type: submit
372 | # options:
373 | # label: Submit my message »
374 | # attr:
375 | # class: button primary
376 |
--------------------------------------------------------------------------------
/config/services.yaml:
--------------------------------------------------------------------------------
1 | services:
2 | _defaults:
3 | autowire: true # Automatically injects dependencies in your services.
4 | autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
5 | Bolt\BoltForms\EventSubscriber\FileUploadHandler:
6 | arguments:
7 | $projectDir: '%kernel.project_dir%'
8 | Bolt\BoltForms\Event\PostSubmitEvent:
9 | autowire: false
10 | autoconfigure: false
11 |
--------------------------------------------------------------------------------
/couscous.yml:
--------------------------------------------------------------------------------
1 | # This is the Couscous configuration file, used to generate the documentation
2 | # See couscous.io for details.
3 |
4 | template:
5 | # The following assumes you have a checkout of the template folder in
6 | # ../Extension-docs-template
7 | directory: ../Extension-docs-template/public
8 | # Alternatively, use it directly from the repository, without checking it
9 | # out on your local dev environment
10 | # url: https://github.com/bolt/Extension-docs-template
11 | index: index.md
12 |
13 | include:
14 | - docs
15 |
16 | branch: gh-pages
17 | baseUrl: https://bolt.github.io/forms
18 |
19 | title: BoltForms
20 | subTitle: A Bolt extension to make forms.
21 | github:
22 | user: bolt
23 | repo: forms
24 | branch: master
25 | # Editlink is not required, if user, repo and branch are given
26 | # editlink: https://github.com/bolt/forms/edit/master/docs/
27 |
28 | menu:
29 | index.md:
30 | label: Getting Started
31 | setting_up_contact.md:
32 | label: Setting up a contact form
33 | creating_new_forms.md:
34 | label: Creating new forms
35 | handlers/index.md:
36 | label: Handlers and Events
37 | section: Handlers / Events
38 | handlers/save_to_contenttype.md:
39 | label: Save to ContentType
40 | class: sub
41 | handlers/save_to_database.md:
42 | label: Save to Database
43 | class: sub
44 | handlers/redirect.md:
45 | label: Redirect the browser
46 | class: sub
47 | captcha.md:
48 | label: CAPTCHA challenges
49 | tips.md:
50 | label: Tips
51 | coding_standards.md:
52 | label: Coding Standards
53 |
--------------------------------------------------------------------------------
/docs/captcha.md:
--------------------------------------------------------------------------------
1 | Add CAPTCHA challenges to protect against bots
2 | ==============================================
3 |
4 | Bolt forms support the following CAPTCHA platforms:
5 |
6 | * [Google reCAPTCHA](https://www.google.com/recaptcha/about/) (v3, v2 checkbox, v2 invisible)
7 | * [hCaptcha](https://www.hcaptcha.com/)
8 | * [gregwarCaptcha](https://github.com/Gregwar/CaptchaBundle) (Captcha without 3rd party dependencies)
9 |
10 | You will need to obtain a site key and secret key from either of the above platforms.
11 |
12 | **Please note it is not possible to use both. hCaptcha has been designed to easily replace reCAPTCHA, so it takes over.**
13 |
14 | Uncomment either the `hcaptcha` or `recaptcha` nodes in your config, and populate the public_key (site key) and
15 | private_key (secret key) settings. Set the `enabled` node to true.
16 |
17 | For reCAPTCHA v3, you can set a threshold for the score that's returned from Google. If the score is not met, the string set in `recaptcha_v3_threshold` is returned to the form.
18 |
19 | Please note: `theme` can either be `light` or `dark` - it only applies to hCaptcha and reCAPTCHA v2 checkbox.)
20 |
21 | ## hCaptcha
22 |
23 | ```yaml
24 | hcaptcha:
25 | enabled: true
26 | public_key: "..."
27 | private_key: "..."
28 | theme: dark
29 | ```
30 |
31 | ## reCAPTCHA
32 |
33 | ```yaml
34 | recaptcha:
35 | enabled: true
36 | public_key: '...'
37 | private_key: '...'
38 | theme: light
39 | recaptcha_v3_threshold: 0.0 # A threshold of 0.0 allows all scores returned from Google to be submitted.
40 | recaptcha_v3_fail_message: We've been unable to verify whether you're human! Please, try resubmitting the form or get in touch via an alternative contact method.
41 | ```
42 |
43 | Finally, insert a captcha field in your form where you would like the CAPTCHA challenge to appear. If an invisible
44 | CAPTCHA is being used (reCAPTCHA v3 or v2 invisible), this is where any validation errors will be rendered.
45 |
46 | This field must be before the submit button for reCAPTCHA v3 and v2 invisible. The field name can
47 | be anything as long as it is unique within your form.
48 |
49 | ```yaml
50 | contact:
51 | fields:
52 | # hCaptcha:
53 | my_hcaptcha_field:
54 | type: captcha
55 | options:
56 | captcha_type: hcaptcha
57 |
58 | # reCAPTCHA v3:
59 | my_recaptcha_v3_field:
60 | type: captcha
61 | options:
62 | captcha_type: recaptcha_v3
63 |
64 | # reCAPTCHA v2 checkbox:
65 | my_recaptcha_v2_checkbox_field:
66 | type: captcha
67 | options:
68 | captcha_type: recaptcha_v2
69 | # Use "label: false" to hide the label
70 | label: Please complete this CAPTCHA
71 |
72 | # reCAPTCHA v2 invisible:
73 | my_recaptcha_v2_invisible_field:
74 | type: captcha
75 | options:
76 | captcha_type: recaptcha_v2
77 | captcha_invisible: true
78 |
79 | # Gregwar Captcha:
80 | my_gregwar_captcha:
81 | type: gregwarCaptcha
82 |
83 | # submit button must come after the CAPTCHA
84 | ```
--------------------------------------------------------------------------------
/docs/coding_standards.md:
--------------------------------------------------------------------------------
1 | Running PHPStan and Easy Codings Standard
2 | =========================================
3 |
4 | If you're working on improving this extension (as opposed to working _with_
5 | this extension), you might want to run the built-in configurations for ECS and
6 | PHPStan.
7 |
8 | First, make sure dependencies are installed:
9 |
10 | ```bash
11 | composer install
12 | ```
13 |
14 | And then run ECS to find potential issues:
15 |
16 | ```bash
17 | vendor/bin/ecs check src
18 | ```
19 |
20 | Or to fix them automatically:
21 |
22 | ```bash
23 | vendor/bin/ecs check src --fix
24 | ```
25 |
26 | Run PHPStan:
27 |
28 | ```bash
29 | vendor/bin/phpstan
30 | ```
31 |
32 |
--------------------------------------------------------------------------------
/docs/creating_new_forms.md:
--------------------------------------------------------------------------------
1 | Creating new forms
2 | ==================
3 |
4 | All the configuration required to customise the contact form, or build
5 | your very own Bolt form is located in `config/extensions/bolt-boltforms.yaml`.
6 |
7 | The first time you open it, you'll see a few options are already defined for you.
8 | The default configuration file includes a lot of comments to help you get started.
9 |
10 | If you have a few forms and managing the `config/extensions/bolt-boltforms.yaml`
11 | file becomes a chore, you can split each form into its own file in `config/extensions/bolt-boltforms/`.
12 |
13 | For example, `config/extensions/bolt-boltforms/contact.yaml` will create a form called `contact`.
14 | Note, you should **only** put the configuration for this form, for example:
15 |
16 | ```yaml
17 | notification:
18 | enabled: true
19 | debug: false
20 | debug_address: name@example.com # Email address used when debug mode is enabled
21 | debug_smtp: true
22 | subject: Your message was submitted
23 | subject_prefix: '[Boltforms]'
24 | replyto_name: '{NAME}' # Email addresses and names can be either the
25 | replyto_email: '{EMAIL}' # name of a field below or valid text.
26 | to_name: 'WebsiteName'
27 | to_email: 'youremail@example.org'
28 | from_name: 'WebsiteName'
29 | from_email: 'youremail@example.org'
30 | feedback:
31 | success: Message submission successful
32 | error: There are errors in the form, please fix before trying to resubmit
33 | fields:
34 | name:
35 | type: text
36 | options:
37 | required: true
38 | label: Name
39 | attr:
40 | placeholder: Your name...
41 | constraints: [ NotBlank, { Length: { 'min': 3, 'max': 128 } } ]
42 | email:
43 | type: email
44 | options:
45 | required: true
46 | label: Email address
47 | attr:
48 | placeholder: Your email...
49 | constraints: [ NotBlank, Email ]
50 | message:
51 | type: textarea
52 | options:
53 | required: true
54 | label: Your message
55 | attr:
56 | placeholder: Your message...
57 | class: myclass
58 | needreply:
59 | type: choice
60 | options:
61 | required: false
62 | label: Do you want us to contact you back?
63 | choices: { 'Yes': 'yes', 'No': 'no' }
64 | multiple: false
65 | upload:
66 | type: file
67 | attach: true # Not only upload the file, but also attach it to the mail
68 | options:
69 | required: false
70 | label: Upload your file
71 | attr:
72 | class: upload
73 | submit:
74 | type: submit
75 | options:
76 | label: Submit my message »
77 | attr:
78 | class: button primary
79 | ```
80 |
81 |
--------------------------------------------------------------------------------
/docs/handlers/index.md:
--------------------------------------------------------------------------------
1 | Handlers and Events
2 | ===================
3 |
4 | All actions that are taken after (or sometimes before) a form is submitted, are
5 | handled by Symfony Events. These take care of things like logginf, sending out
6 | emails and determining if a submission might be spam.
7 |
8 |
--------------------------------------------------------------------------------
/docs/handlers/redirect.md:
--------------------------------------------------------------------------------
1 | Redirect after a succesful submit
2 | =================================
3 |
4 | After the form has been successfully submitted, the visitor will normally get
5 | redirected to the originating page. You can use the Redirect handler to send
6 | them elsewhere, instead.
7 |
8 | You can do this, by setting `redirect:` in the `feedback:` section of the
9 | form's configuration.
10 |
11 |
12 | ```yaml
13 | feedback:
14 | success: …
15 | error: …
16 | redirect:
17 | target: page/another-page
18 | query: [name, email]
19 | ```
20 |
21 | The `target:` specifies where the visitor will be sent. Note that you can add
22 | optional get parameters in this URI, that will get sent as-is. For example:
23 |
24 | ```yaml
25 | target: page/another-page?foo=bar&qux=boo
26 | ```
27 |
28 | You can use 'self' as a special case: This will redirect the browser (using a
29 | GET request) to the originating page. This is useful for when you want the user
30 | to stay on the same page, without them being able to accidentally re-submit the
31 | form by refreshing the page.
32 |
33 | The optional `query:` lets you set additional parameters that will contain the
34 | corresponding values from the form. For example, the configuration above will redirect the visitor to a URL like this, after a correct form has been posted:
35 |
36 | ```
37 | /page/another-page?foo=bar&qux=boo&name=Bob&email=bob%40twokings.nl
38 | ```
39 |
40 | Please note that these will be sent as `GET` parameters, so do not use these to
41 | pass around sensitive information.
--------------------------------------------------------------------------------
/docs/handlers/save_to_contenttype.md:
--------------------------------------------------------------------------------
1 | Create a form and save it to a ContentType
2 | ==========================================
3 |
4 | To create a new form and store submissions in a Bolt ContentType, first
5 | create a new form. For example, create a file `quotation.yaml` in the folder
6 | `config/extensions/bolt-boltforms`:
7 |
8 | ```yaml
9 | # config/extensions/bolt-boltforms/quotation.yaml
10 |
11 | templates: # Override the global Twig templates if you want
12 | # form: @theme/form.twig
13 | # email: @theme/email.twig
14 | # subject: @theme/subject.twig
15 | # files: @theme/file_browser.twig
16 | feedback:
17 | success: Quotation request has been received. We'll be in touch soon.
18 | error: There are errors in the form. Please fix them, before trying to resubmit
19 | database:
20 | contenttype:
21 | name: quotations # save all form submissions to the quotations contenttype
22 | ignore_missing: true # ignore fields in the form that aren't defined in the ContentType
23 | status: draft # save entry with publication status: published|held|draft
24 | fields:
25 | name:
26 | type: text
27 | options:
28 | required: true
29 | label: Name
30 | attr:
31 | placeholder: Your name...
32 | constraints: [ NotBlank, { Length: { 'min': 3, 'max': 128 } } ]
33 | email:
34 | type: email
35 | options:
36 | required: true
37 | label: Email address
38 | attr:
39 | placeholder: Your email...
40 | constraints: [ NotBlank, Email ]
41 | needhelp:
42 | type: choice
43 | options:
44 | required: true
45 | label: What project do you need help with?
46 | choices: { 'Web development': 'web-development', 'Mobile development': 'mobile-development', 'Marketing': 'marketing' }
47 | details:
48 | type: textarea
49 | options:
50 | required: true
51 | label: Description
52 | attr:
53 | placeholder: Please describe your desired website or app…
54 | submit:
55 | type: submit
56 | options:
57 | label: Request quotation »
58 | attr:
59 | class: button primary
60 | ```
61 |
62 | To store the data, you'd need a ContentType `quotations`, as referenced in the
63 | form above. For example:
64 |
65 | ```yaml
66 | # config/bolt/contenttypes.yaml
67 |
68 | quotations:
69 | name: Quotations
70 | singular_name: Quotation
71 | title_format: "Quotation request from: {name}"
72 | fields:
73 | name:
74 | type: text
75 | variant: inline
76 | email:
77 | type: text
78 | variant: inline
79 | needhelp:
80 | type: text
81 | variant: inline
82 | details:
83 | type: textarea
84 | timestamp:
85 | type: text
86 | variant: inline
87 | url:
88 | type: text
89 | variant: inline
90 | path:
91 | type: text
92 | variant: inline
93 | ip:
94 | type: text
95 | variant: inline
96 | ```
97 |
98 | As a result, the submissions of the form will be stored as proper Records in
99 | your Bolt backend. See this screenshot as an example:
100 |
101 | 
102 |
103 | If the name of the fields in your ContentType and the form do not match exactly,
104 | use the `field_map` option to override the default mapping:
105 |
106 | ```yaml
107 | database:
108 | contenttype:
109 | name: quotations # save all form submissions to the quotations ContentType
110 | field_map:
111 | name: customer_name # name is the form field. customer_name is the ContentType field.
112 | ```
113 |
114 | In addition to all form fields, you can also keep the following information:
115 |
116 | * `timestamp` - the timestamp when the form is submitted
117 | * `url` - the url where the form is submitted
118 | * `path` - the absolute path where the form is submitted
119 | * `ip` - the ip address of the user submitting the form
120 | * `attachments` - an array of attached files from file fields
121 |
122 | To store that information in a ContentType, simply create a field for each
123 | option that you want to keep, and remove the override from the form config:
124 |
125 | `ip: ~ # do not save the ip <- REMOVE THIS LINE`
126 |
127 | By default, Boltforms silently ignores fields from the form that are missing from the ContentType. Often this is what you want, because you might not need an `attachments` field, if the form has no file uploads. However, if you do want to have this strict behaviour, set `ignore_missing: false`, like so:
128 |
129 | ```yaml
130 | database:
131 | contenttype:
132 | name: quotations # save all form submissions to the quotations ContentType
133 | field_map:
134 | timestamp: ~ # do not save the timestamp
135 | url: ~ # do not save the url
136 | path: ~ # do not save the path
137 | ip: ~ # do not save the ip
138 | ignore_missing: false
139 | ```
140 |
--------------------------------------------------------------------------------
/docs/handlers/save_to_database.md:
--------------------------------------------------------------------------------
1 | Create a form and save it to a custom database table
2 | ====================================================
3 |
4 | Instead of saving the data to a Bolt ContentType, you can also save it to a custom
5 | table of your own making. To do so, just update the `database` option in the form
6 | configuration:
7 |
8 | ```yaml
9 | database:
10 | table:
11 | name: custom_quotations # save all form submissions to the custom_quotations table in the database
12 | field_map: # optional
13 | timestamp: ~ # do not save the timestamp
14 | url: ~ # do not save the url
15 | path: ~ # do not save the path
16 | ip: ~ # do not save the ip
17 | attachments: ~ # do not save attached files
18 | ```
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | Getting Started
2 | ===============
3 |
4 | This Bolt extension can be used to handle forms in your Bolt 5 project.
5 |
6 | Installation:
7 |
8 | ```bash
9 | composer require bolt/forms
10 | ```
11 |
--------------------------------------------------------------------------------
/docs/overriding_form_configuration.md:
--------------------------------------------------------------------------------
1 | Overriding form configuration
2 | =========
3 | ### Configuration overrides in the tag
4 |
5 | Configuration parameters can be overridden at runtime by passing them in using
6 | the `data` named parameter.
7 |
8 | #### Overriding the default value of a text field
9 |
10 | In this example the text field `field_name` will render with the value `default_field_value`.
11 |
12 | ```twig
13 | {{ boltforms('form_name',
14 | data = {
15 | 'field_name': 'default_field_value'
16 | }
17 | })
18 | }}
19 | ```
20 |
21 | #### Overriding the default options of a field
22 |
23 | In this example the `label` of the `field_name` field is being overridden and rendering with the value `new_label`.
24 |
25 | ```twig
26 | {{ boltforms('contact_subsites',
27 | data = {
28 | 'field_name': {
29 | options: {
30 | label: 'new_label'
31 | }
32 | }
33 | })
34 | }}
35 | ```
36 |
37 | **NOTE:** Where the override array key matches a field name, the field name is
38 | overridden, if it then matches a field configuration parameter, that will be
39 | the affected value.
--------------------------------------------------------------------------------
/docs/search.md:
--------------------------------------------------------------------------------
1 | Search
2 | ======
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/docs/setting_up_contact.md:
--------------------------------------------------------------------------------
1 | Setting up a contact form
2 | =========================
3 |
4 | After installing the `bolt/forms` extension, you can directly use the default
5 | contact form in your twig template like this:
6 |
7 | ```twig
8 | {{ boltform('contact') }}
9 | ```
10 |
11 | Which will display something that looks like this:
12 |
13 | 
14 |
--------------------------------------------------------------------------------
/docs/tips.md:
--------------------------------------------------------------------------------
1 | 💡 Tips
2 | ======
3 |
4 | To configure a `date`, `datetime` or `dateinterval` field to use HTML5's standard date picker widget, set the options as this example:
5 |
6 | ```yaml
7 | date_of_birth:
8 | type: date
9 | options:
10 | widget: single_text
11 | ```
12 |
13 | To configure a `choice` field to show checkboxes, set the options as this example:
14 |
15 | ```yaml
16 | favourite_food:
17 | type: choice
18 | options:
19 | choices:
20 | "bananas": "Bananas"
21 | "chocolate": "Chocolate"
22 | "oranges": "Oranges"
23 | expanded: true
24 | multiple: true
25 | ```
26 |
27 | A *single* checkbox can be added like this:
28 |
29 | ```yaml
30 | newsletter:
31 | type: checkbox
32 | options:
33 | label: "Yes, I'd like to subscribe to your newsletter"
34 | required: false
35 | ```
36 |
37 | Note that when using `form_div_layout.html.twig` as layout in `bolt-boltforms.yaml`, checkboxes and radio-buttons do not show labels by default.
38 | If you want to use this layout, it's advised to copy it to your theme folder, change its name, modify it, and configure it like this:
39 |
40 | ```yaml
41 | layout:
42 | form: 'my_form_layout.html.twig'
43 | bootstrap: false
44 | ```
45 |
46 | More options regarding the `choice` field are available on the [official Symfony Forms documentation](https://symfony.com/doc/current/reference/forms/types/choice.html#select-tag-checkboxes-or-radio-buttons) page.
47 |
48 | ## Using Content as Choices
49 |
50 | With `type: contenttype`, you can use content as choices. This makes additional `params` option is available.
51 | These are the same options as what you'd use for regular `setcontent` queries.
52 |
53 | ```yaml
54 | favourite_food:
55 | type: contenttype
56 | options:
57 | expanded: true
58 | multiple: true
59 | params:
60 | contenttype: pages
61 | label: title
62 | value: slug
63 | limit: 10
64 | sort: title
65 | criteria:
66 | status: 'draft || published' # by default only published items are queried
67 | ```
68 |
69 | Options for `choice` are available here; `choices` will be overwritten by the queried values.
70 | The following options are available for `params`:
71 |
72 | * `contenttype`: the contenttype to query.
73 | * `label`: the name of the field to use as labels.
74 | * `value`: the name of the field to use as values.
75 | * `limit`: the number of records to return.
76 | * `sort`: the field value to sort on; prefix with `-` for descending order.
77 | * `criteria`: the `where` clause.
78 |
--------------------------------------------------------------------------------
/ecs.php:
--------------------------------------------------------------------------------
1 | 7.3 only.
52 | // See: https://github.com/bolt/core/issues/2519
53 | error_reporting(error_reporting() & ~E_NOTICE);
54 |
55 | return static function (ECSConfig $ecsConfig): void {
56 | $parameters = $ecsConfig->parameters();
57 |
58 | $parameters->set('sets', ['clean-code', 'common', 'php70', 'php71', 'psr12', 'symfony', 'symfony-risky']);
59 |
60 | $parameters->set('paths', [
61 | __DIR__ . '/src',
62 | __DIR__ . '/ecs.php',
63 | ]);
64 |
65 | $parameters->set('cache_directory', 'var/cache/ecs');
66 |
67 | $parameters->set('skip', [
68 | OrderedClassElementsFixer::class => null,
69 | YodaStyleFixer::class => null,
70 | IncrementStyleFixer::class => null,
71 | PhpdocAnnotationWithoutDotFixer::class => null,
72 | PhpdocSummaryFixer::class => null,
73 | PhpdocAlignFixer::class => null,
74 | NativeConstantInvocationFixer::class => null,
75 | NativeFunctionInvocationFixer::class => null,
76 | UnaryOperatorSpacesFixer::class => null,
77 | ArrayOpenerAndCloserNewlineFixer::class => null,
78 | ArrayListItemNewlineFixer::class => null,
79 | ]);
80 |
81 | $services = $ecsConfig->services();
82 |
83 | $services->set(StandaloneLineInMultilineArrayFixer::class);
84 |
85 | $services->set(BlankLineAfterStrictTypesFixer::class);
86 |
87 | $services->set(ConcatSpaceFixer::class)
88 | ->call('configure', [['spacing' => 'one']]);
89 |
90 | $services->set(RemoveSuperfluousDocBlockWhitespaceFixer::class);
91 |
92 | $services->set(PhpUnitMethodCasingFixer::class);
93 |
94 | $services->set(FinalInternalClassFixer::class);
95 |
96 | $services->set(MbStrFunctionsFixer::class);
97 |
98 | $services->set(Psr0Fixer::class);
99 |
100 | $services->set(Psr4Fixer::class);
101 |
102 | $services->set(LowercaseCastFixer::class);
103 |
104 | $services->set(ShortScalarCastFixer::class);
105 |
106 | $services->set(BlankLineAfterOpeningTagFixer::class);
107 |
108 | $services->set(NoLeadingImportSlashFixer::class);
109 |
110 | $services->set(OrderedImportsFixer::class)
111 | ->call('configure', [[
112 | 'imports_order' => ['class', 'const', 'function'],
113 | ]]);
114 |
115 | $services->set(DeclareEqualNormalizeFixer::class)
116 | ->call('configure', [['space' => 'none']]);
117 |
118 | $services->set(NewWithBracesFixer::class);
119 |
120 | $services->set(BracesFixer::class)
121 | ->call('configure', [[
122 | 'allow_single_line_closure' => false,
123 | 'position_after_functions_and_oop_constructs' => 'next',
124 | 'position_after_control_structures' => 'same',
125 | 'position_after_anonymous_constructs' => 'same',
126 | ]]);
127 |
128 | $services->set(NoBlankLinesAfterClassOpeningFixer::class);
129 |
130 | $services->set(VisibilityRequiredFixer::class)
131 | ->call('configure', [[
132 | 'elements' => ['const', 'method', 'property'],
133 | ]]);
134 |
135 | $services->set(TernaryOperatorSpacesFixer::class);
136 |
137 | $services->set(ReturnTypeDeclarationFixer::class);
138 |
139 | $services->set(NoTrailingWhitespaceFixer::class);
140 |
141 | $services->set(NoSinglelineWhitespaceBeforeSemicolonsFixer::class);
142 |
143 | $services->set(NoWhitespaceBeforeCommaInArrayFixer::class);
144 |
145 | $services->set(WhitespaceAfterCommaInArrayFixer::class);
146 |
147 | $services->set(PhpdocToReturnTypeFixer::class);
148 |
149 | $services->set(FullyQualifiedStrictTypesFixer::class);
150 |
151 | $services->set(NoSuperfluousPhpdocTagsFixer::class);
152 |
153 | $services->set(PhpdocLineSpanFixer::class)
154 | ->call('configure', [['property' => 'single']]);
155 |
156 | $services->set(DisallowYodaComparisonSniff::class);
157 | };
158 |
--------------------------------------------------------------------------------
/phpstan.neon:
--------------------------------------------------------------------------------
1 | parameters:
2 | level: 5
3 |
4 | paths:
5 | - src
6 |
7 | scanDirectories:
8 | # In order to 'recognize' Bolt, Twig and Carbon functions in global scope
9 | - %currentWorkingDirectory%/vendor/bolt/core/src/
10 | - %currentWorkingDirectory%/vendor/twig/twig/src/Extension
11 | - %currentWorkingDirectory%/vendor/nesbot/carbon/src/Carbon
12 |
13 | ignoreErrors:
14 | # false positive: `TranslationInterface does not know about FieldTranslation::getValue().` Skip this error.
15 | -
16 | message: '#Call to an undefined method Bolt\\Extension\\ExtensionInterface#'
17 | path: %currentWorkingDirectory%/src/*
18 |
19 | includes:
20 | - vendor/phpstan/phpstan-symfony/extension.neon
21 | - vendor/phpstan/phpstan-doctrine/extension.neon
22 |
23 | services:
24 | -
25 | class: Symplify\CodingStandard\Rules\ForbiddenFuncCallRule
26 | tags: [phpstan.rules.rule]
27 | arguments:
28 | forbiddenFunctions: ['d', 'dd', 'dump', 'var_dump', 'extract']
29 |
30 | - Symplify\PackageBuilder\Matcher\ArrayStringAndFnMatcher
--------------------------------------------------------------------------------
/src/BoltFormsConfig.php:
--------------------------------------------------------------------------------
1 | registry = $registry;
31 | $this->boltConfig = $boltConfig;
32 | }
33 |
34 | public function getConfig(): Collection
35 | {
36 | if ($this->config === null) {
37 | // We get the defaults as baseline, and merge (override) with all the
38 | // configured Settings
39 |
40 | /** @var Extension $extension */
41 | $extension = $this->getExtension();
42 | $this->config = $this->getDefaults()->replaceRecursive($this->getAdditionalFormConfigs())->replaceRecursive($extension->getConfig());
43 | }
44 |
45 | return $this->config;
46 | }
47 |
48 | public function getBoltConfig(): Config
49 | {
50 | return $this->boltConfig;
51 | }
52 |
53 | public function getExtension(): ?ExtensionInterface
54 | {
55 | if ($this->extension === null) {
56 | $this->extension = $this->registry->getExtension(Extension::class);
57 | }
58 |
59 | return $this->extension;
60 | }
61 |
62 | private function getDefaults(): Collection
63 | {
64 | return new Collection([
65 | 'csrf' => false,
66 | 'foo' => 'bar',
67 | ]);
68 | }
69 |
70 | private function getAdditionalFormConfigs(): array
71 | {
72 | $configPath = explode('.yaml', $this->getExtension()->getConfigFilenames()['main'])[0] . DIRECTORY_SEPARATOR;
73 |
74 | $finder = new Finder();
75 |
76 | $finder->files()->in($configPath)->name('*.yaml');
77 |
78 | if (! $finder->hasResults()) {
79 | return [];
80 | }
81 |
82 | $result = [];
83 |
84 | foreach ($finder as $file) {
85 | $formName = basename($file->getBasename(), '.yaml');
86 |
87 | $result[$formName] = Yaml::parseFile($file->getRealPath());
88 | }
89 |
90 | return $result;
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/src/CaptchaException.php:
--------------------------------------------------------------------------------
1 | .
28 | *
29 | * @author Gawain Lynch
30 | * @copyright Copyright (c) 2014-2016, Gawain Lynch
31 | * @license http://opensource.org/licenses/GPL-3.0 GNU Public License 3.0
32 | * @license http://opensource.org/licenses/LGPL-3.0 GNU Lesser General Public License 3.0
33 | */
34 | class BoltFormsEvent extends FormEvent
35 | {
36 | /** @var FormEvent */
37 | protected $event;
38 |
39 | /** @var array */
40 | protected $data = [];
41 |
42 | /** @var FormInterface */
43 | protected $form;
44 |
45 | /** @var string */
46 | protected $formsEventName;
47 |
48 | public function __construct(FormEvent $event, string $formsEventName)
49 | {
50 | parent::__construct($event->getForm(), $event->getData());
51 |
52 | $this->event = $event;
53 | $this->formsEventName = $formsEventName;
54 | }
55 |
56 | public function getEvent(): FormEvent
57 | {
58 | return $this->event;
59 | }
60 |
61 | public function getData(): array
62 | {
63 | return $this->data;
64 | }
65 |
66 | public function setData($data): void
67 | {
68 | if (in_array($this->formsEventName, [FormEvents::PRE_SUBMIT, BoltFormsEvents::PRE_SUBMIT], true)) {
69 | $this->event->setData($data);
70 | } else {
71 | throw new \RuntimeException(self::class . '::' . __FUNCTION__ . ' can only be called in BoltFormsEvents::PRE_SUBMIT or FormEvents::PRE_SUBMIT');
72 | }
73 | }
74 |
75 | public function getForm(): FormInterface
76 | {
77 | return $this->form;
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/Event/BoltFormsEvents.php:
--------------------------------------------------------------------------------
1 | .
24 | *
25 | * @author Gawain Lynch
26 | * @copyright Copyright (c) 2014-2016, Gawain Lynch
27 | * @license http://opensource.org/licenses/GPL-3.0 GNU Public License 3.0
28 | * @license http://opensource.org/licenses/LGPL-3.0 GNU Lesser General Public License 3.0
29 | */
30 | final class BoltFormsEvents
31 | {
32 | /*
33 | * Symfony Forms events
34 | */
35 | public const PRE_SUBMIT = 'boltforms.pre_submit';
36 | public const SUBMIT = 'boltforms.submit';
37 | public const PRE_SET_DATA = 'boltforms.pre_set_data';
38 | public const POST_SET_DATA = 'boltforms.post_set_data';
39 |
40 | private function __construct()
41 | {
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/Event/PostSubmitEvent.php:
--------------------------------------------------------------------------------
1 | form = $form;
39 | $this->config = $config;
40 | $this->formName = $formName;
41 | $this->request = $request;
42 | $this->attachments = collect([]);
43 | }
44 |
45 | public function getFormName(): string
46 | {
47 | return $this->formName;
48 | }
49 |
50 | public function getForm(): Form
51 | {
52 | return $this->form;
53 | }
54 |
55 | public function getExtension()
56 | {
57 | return $this->config->getExtension();
58 | }
59 |
60 | public function getConfig(): Collection
61 | {
62 | return $this->config->getConfig();
63 | }
64 |
65 | public function getFormConfig(): Collection
66 | {
67 | return new Collection($this->getConfig()->get($this->formName));
68 | }
69 |
70 | public function getMeta(): array
71 | {
72 | return [
73 | 'ip' => $this->request->getClientIp(),
74 | 'timestamp' => Carbon::now(),
75 | 'path' => $this->request->getRequestUri(),
76 | 'url' => $this->request->getUri(),
77 | 'attachments' => $this->getAttachments(),
78 | ];
79 | }
80 |
81 | public function markAsSpam($spam): void
82 | {
83 | $this->spam = $spam;
84 | }
85 |
86 | public function isSpam(): bool
87 | {
88 | return $this->spam;
89 | }
90 |
91 | public function addAttachments(array $attachments): void
92 | {
93 | $this->attachments = $this->attachments->merge($attachments);
94 | }
95 |
96 | public function getAttachments(): array
97 | {
98 | return $this->attachments->toArray();
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/src/Event/PostSubmitEventDispatcher.php:
--------------------------------------------------------------------------------
1 | config = $config;
25 | $this->dispatchedForms = collect([]);
26 | $this->dispatcher = $dispatcher;
27 | }
28 |
29 | public function handle(string $formName, Form $form, Request $request): void
30 | {
31 | if (! $this->shouldDispatch($formName)) {
32 | return;
33 | }
34 |
35 | $this->dispatch($formName, $form, $request);
36 | }
37 |
38 | private function shouldDispatch(string $formName): bool
39 | {
40 | return ! $this->dispatchedForms->contains($formName);
41 | }
42 |
43 | private function dispatch(string $formName, Form $form, Request $request): void
44 | {
45 | $event = new PostSubmitEvent($form, $this->config, $formName, $request);
46 | $this->dispatcher->dispatch($event, PostSubmitEvent::NAME);
47 | $this->config->getExtension()->dump(sprintf('Form "%s" has been submitted', $formName));
48 |
49 | $this->dispatchedForms->add($formName);
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/EventSubscriber/AbstractPersistSubscriber.php:
--------------------------------------------------------------------------------
1 | getForm();
17 |
18 | // Don't save anything if the form isn't valid
19 | if (! $form->isValid()) {
20 | return;
21 | }
22 |
23 | $config = collect($event->getFormConfig()->get('database', false));
24 |
25 | // If contenttype is not configured, bail out.
26 | if (! $config) {
27 | return;
28 | }
29 |
30 | $this->save($event, $form, $config);
31 | }
32 |
33 | abstract public function save(PostSubmitEvent $event, Form $form, Collection $config): void;
34 |
35 | public static function getSubscribedEvents()
36 | {
37 | return [
38 | 'boltforms.post_submit' => ['handleSave', 10],
39 | ];
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/EventSubscriber/ContentTypePersister.php:
--------------------------------------------------------------------------------
1 | boltConfig = $boltConfig;
35 | $this->userRepository = $userRepository;
36 | $this->em = $em;
37 | $this->projectDir = $projectDir;
38 | }
39 |
40 | public function save(PostSubmitEvent $event, Form $form, Collection $config): void
41 | {
42 | $config = collect($config->get('contenttype', []));
43 |
44 | if (! $config) {
45 | return;
46 | }
47 |
48 | if (! $this->boltConfig->getContentType($config->get('name', ''))) {
49 | // todo: handle this error message better
50 | return;
51 | }
52 |
53 | $content = new Content();
54 | $this->setContentData($content, $config);
55 | $this->setContentFields($content, $form, $event, $config);
56 | $this->saveContent($content);
57 | }
58 |
59 | private function setContentData(Content $content, Collection $config): void
60 | {
61 | $content->setStatus($config->get('status', Statuses::PUBLISHED));
62 | $contentType = $this->boltConfig->getContentType($config->get('name'));
63 | $content->setContentType($contentType->get('slug'));
64 | $content->setDefinition($contentType);
65 |
66 | if ($config->get('author', false)) {
67 | $user = $this->userRepository->findOneByUsername($config->get('author'));
68 | $content->setAuthor($user);
69 | }
70 | }
71 |
72 | private function setContentFields(Content $content, Form $form, PostSubmitEvent $event, Collection $config): void
73 | {
74 | $mapping = collect($config->get('field_map'));
75 |
76 | $data = array_merge(
77 | $event->getMeta(),
78 | $form->getData()
79 | );
80 |
81 | // Map given data to fields, using the mapping
82 | foreach ($data as $field => $value) {
83 | $name = $mapping->get($field, $field);
84 |
85 | if ($name === null) {
86 | continue;
87 | }
88 |
89 | if (is_array($value)) {
90 | $value = implode(', ', array_map(function ($entry) {
91 | // check if $entry is an array and not empty
92 | return (is_array($entry) && count($entry) > 0) ? $entry[0] : '';
93 | }, $value));
94 | }
95 |
96 | if ($value instanceof \DateTimeInterface) {
97 | $value = Carbon::instance($value);
98 | }
99 |
100 | $value = (string) $value;
101 |
102 | if (in_array($name, array_keys($data['attachments'] ?? null), true)) {
103 | // Don't save the file. Rather, save the filename that's in attachments.
104 | $value = $data['attachments'][$name];
105 |
106 | // Don't save the full path. Only the path without the project dir.
107 | $newValue = [];
108 | foreach ($value as $path) {
109 | $newValue[] = str_replace($this->projectDir, '', $path);
110 | }
111 |
112 | $value = $newValue;
113 | }
114 |
115 | // We forcibly set it, if the field is defined OR (`ignore_missing` is set AND it is `false`)
116 | if ($content->hasFieldDefined($name) || (isset($config['ignore_missing']) && ! $config['ignore_missing'])) {
117 | $content->setFieldValue($name, $value);
118 | }
119 | }
120 |
121 | // And the reverse: Map the mapping to fields from the data
122 | foreach ($mapping as $mappingName => $fieldName) {
123 | if ($content->hasFieldDefined($mappingName) && array_key_exists($fieldName, $data)) {
124 | $content->setFieldValue($mappingName, $data[$fieldName]);
125 | }
126 | }
127 | }
128 |
129 | private function saveContent(Content $content): void
130 | {
131 | $this->em->persist($content);
132 | $this->em->flush();
133 | }
134 | }
135 |
--------------------------------------------------------------------------------
/src/EventSubscriber/DbTablePersister.php:
--------------------------------------------------------------------------------
1 | query = $connection->createQueryBuilder();
27 | $this->log = $log;
28 | }
29 |
30 | public function save(PostSubmitEvent $event, Form $form, Collection $config): void
31 | {
32 | $config = collect($config->get('table', []));
33 |
34 | if (! $config) {
35 | return;
36 | }
37 |
38 | if (! $config->get('name', '')) {
39 | // todo: handle this error message better
40 | return;
41 | }
42 |
43 | $table = $config->get('name', '');
44 | $fields = $this->parseForm($form, $event, $config);
45 | $this->saveToTable($table, $fields);
46 | }
47 |
48 | private function parseForm(Form $form, PostSubmitEvent $event, Collection $config): array
49 | {
50 | $mapping = collect($config->get('field_map'));
51 |
52 | $data = array_merge(
53 | $event->getMeta(),
54 | $form->getData()
55 | );
56 |
57 | foreach (array_keys($data) as $field) {
58 | $name = $mapping->get($field, $field);
59 |
60 | if ($name === null) {
61 | unset($data[$field]);
62 | }
63 | }
64 |
65 | return $data;
66 | }
67 |
68 | private function saveToTable(string $table, array $fields): void
69 | {
70 | $columns = [];
71 | $parameters = [];
72 |
73 | foreach (array_keys($fields) as $name) {
74 | $columns[$name] = '?';
75 | }
76 |
77 | foreach(array_values($fields) as $value) {
78 | if ($value instanceof \DateTimeInterface) {
79 | $parameters[] = Carbon::instance($value);
80 | } else {
81 | $parameters[] = $value;
82 | }
83 | }
84 |
85 | $this->query
86 | ->insert($table)
87 | ->values($columns)
88 | ->setParameters($parameters);
89 |
90 | try {
91 | $this->query->execute();
92 | } catch (\Throwable $exception) {
93 | $this->log->error($exception->getMessage());
94 | }
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/src/EventSubscriber/FileUploadHandler.php:
--------------------------------------------------------------------------------
1 | helper = $helper;
30 | $this->projectDir = $projectDir;
31 | }
32 |
33 | public function handleEvent(PostSubmitEvent $event): void
34 | {
35 | $form = $event->getForm();
36 | $formConfig = $event->getFormConfig();
37 |
38 | $fields = $form->all();
39 | foreach ($fields as $field) {
40 | $fieldConfig = $formConfig->get('fields')[$field->getName()] ?? null;
41 | if ($fieldConfig && $fieldConfig['type'] === 'file') {
42 | $this->processFileField($field, collect($fieldConfig), $event);
43 | }
44 | }
45 | }
46 |
47 | private function processFileField(Form $field, Collection $fieldConfig, PostSubmitEvent $event): void
48 | {
49 | $file = $field->getData();
50 | $form = $event->getForm();
51 | $formConfig = $event->getFormConfig();
52 |
53 | $filename = $this->getFilename($field->getName(), $form, $formConfig);
54 | $path = $fieldConfig['directory'] ?? '/uploads/';
55 | Str::ensureStartsWith($path, \DIRECTORY_SEPARATOR);
56 | $files = $this->uploadFiles($filename, $file, $path);
57 |
58 | if (isset($fieldConfig['attach']) && $fieldConfig['attach']) {
59 | $event->addAttachments([$field->getName() => $files]);
60 | }
61 | }
62 |
63 | private function getFilename(string $fieldname, Form $form, Collection $formConfig): string
64 | {
65 | $filenameFormat = $formConfig->get('fields')[$fieldname]['file_format'] ?? 'Uploaded file' . uniqid();
66 | $filename = $this->helper->get($filenameFormat, $form);
67 |
68 | if (! $filename) {
69 | $filename = uniqid();
70 | }
71 |
72 | return $filename;
73 | }
74 |
75 | /**
76 | * @param UploadedFile|array $file
77 | */
78 | private function uploadFiles(string $filename, $file, string $path = ''): array
79 | {
80 | $uploadPath = $this->projectDir . $path;
81 | $uploadHandler = new Handler($uploadPath, [
82 | Handler::OPTION_AUTOCONFIRM => true,
83 | Handler::OPTION_OVERWRITE => false,
84 | ]);
85 |
86 | $uploadHandler->setPrefix(mb_substr(md5((string) time()), 0, 8) . '_' . $filename);
87 |
88 | $uploadHandler->setSanitizerCallback(function ($name) {
89 | return $this->sanitiseFilename($name);
90 | });
91 |
92 | /** @var File $processed */
93 | $processed = $uploadHandler->process($file);
94 |
95 | $result = [];
96 | if ($processed->isValid()) {
97 | $processed->confirm();
98 |
99 | if (is_iterable($processed)) {
100 | foreach ($processed as $file) {
101 | $result[] = $uploadPath . $file->__get('name');
102 | }
103 | } else {
104 | $result[] = $uploadPath . $processed->__get('name');
105 | }
106 | }
107 |
108 | // Very ugly. But it works when later someone uses Request::createFromGlobals();
109 | $_FILES = [];
110 |
111 | return $result;
112 | }
113 |
114 | private function sanitiseFilename(string $filename): string
115 | {
116 | $extensionSlug = new Slugify([
117 | 'regexp' => '/([^a-z0-9]|-)+/',
118 | ]);
119 | $filenameSlug = new Slugify([
120 | 'lowercase' => false,
121 | ]);
122 |
123 | $extension = $extensionSlug->slugify(Path::getExtension($filename));
124 | $filename = $filenameSlug->slugify(Path::getFilenameWithoutExtension($filename));
125 |
126 | return $filename . '.' . $extension;
127 | }
128 |
129 | public static function getSubscribedEvents()
130 | {
131 | return [
132 | 'boltforms.post_submit' => ['handleEvent', 40],
133 | ];
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/src/EventSubscriber/HoneypotSubscriber.php:
--------------------------------------------------------------------------------
1 | event = $event;
24 |
25 | $config = $this->event->getConfig();
26 |
27 | if (! $config->get('honeypot', false)) {
28 | return $event;
29 | }
30 |
31 | $honeypot = new Honeypot($event->getFormName());
32 | $fieldName = $honeypot->generateFieldName();
33 |
34 | $data = $event->getForm()->get($fieldName)->getData();
35 |
36 | if (! empty($data)) {
37 | $action = $config->get('spam-action', 'nothing');
38 | if ($action === 'mark-as-spam') {
39 | $event->markAsSpam(true);
40 | }
41 |
42 | if ($action === 'block') {
43 | $event->getForm()->addError(new FormError('An extra special error occured.'));
44 | }
45 | }
46 |
47 | return $event;
48 | }
49 |
50 | public static function getSubscribedEvents()
51 | {
52 | return [
53 | 'boltforms.post_submit' => ['handleEvent', 50],
54 | ];
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/EventSubscriber/Logger.php:
--------------------------------------------------------------------------------
1 | event = $event;
30 | $this->notification = new Collection($this->event->getFormConfig()->get('notification'));
31 |
32 | if (! $this->notification->get('log')) {
33 | $this->log();
34 | }
35 | }
36 |
37 | public function log(): void
38 | {
39 | // Don't log anything, if the form isn't valid
40 | if (! $this->event->getForm()->isValid()) {
41 | return;
42 | }
43 |
44 | $data = $this->event->getForm()->getData();
45 |
46 | // We cannot serialize Uploaded file. See https://github.com/symfony/symfony/issues/19572.
47 | // So instead, let's get the filename. ¯\_(ツ)_/¯
48 | // todo: Can we fix this?
49 | foreach ($data as $key => $value) {
50 | if ($value instanceof UploadedFile) {
51 | $data[$key] = $value->getClientOriginalName();
52 | } elseif (is_iterable($value)) {
53 | // Multiple files
54 | foreach ($value as $k => $v) {
55 | if ($v instanceof UploadedFile) {
56 | $data[$key][$k] = $v->getClientOriginalName();
57 | }
58 | }
59 | }
60 | }
61 |
62 | $data['formname'] = $this->event->getFormName();
63 |
64 | $this->logger->info('[Boltforms] Form {formname} - submitted Form data (see \'Context\')', $data);
65 |
66 | $this->event->getExtension()->dump('Submitted form data was logged in the System log.');
67 | }
68 |
69 | public static function getSubscribedEvents()
70 | {
71 | return [
72 | 'boltforms.post_submit' => ['handleEvent', 10],
73 | ];
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/EventSubscriber/Mailer.php:
--------------------------------------------------------------------------------
1 | mailer = $mailer;
32 | }
33 |
34 | public function handleEvent(PostSubmitEvent $event): void
35 | {
36 | $this->event = $event;
37 | $this->notification = new Collection($this->event->getFormConfig()->get('notification'));
38 |
39 | // Don't send mails, if the form isn't valid
40 | if (! $this->event->getForm()->isValid()) {
41 | return;
42 | }
43 |
44 | if ($this->notification->get('enabled') || $this->notification->get('email')) {
45 | $this->mail();
46 | }
47 | }
48 |
49 | public function mail(): void
50 | {
51 | $email = $this->buildEmail();
52 | // @todo Returns `null`, whilst it _should_ return some info on whether it was successful.
53 | $this->mailer->send($email);
54 |
55 | $this->logger->info(
56 | '[Boltforms] Form {formname} sent email to {recipient}',
57 | [
58 | 'formname' => $this->event->getFormName(),
59 | // is this casting right?
60 | 'recipient' => (string) $email->getTo()[0]->getName(),
61 | ]
62 | );
63 | }
64 |
65 | public function buildEmail(): TemplatedEmail
66 | {
67 | $meta = $this->event->getMeta();
68 |
69 | $email = (new EmailFactory())->create($this->event->getFormConfig(), $this->event->getConfig(), $this->event->getForm(), $meta);
70 |
71 | if ($this->event->isSpam()) {
72 | $subject = Str::ensureStartsWith($email->getSubject(), '[SPAM] ');
73 | $email->subject($subject);
74 | }
75 |
76 | return $email;
77 | }
78 |
79 | public static function getSubscribedEvents()
80 | {
81 | return [
82 | 'boltforms.post_submit' => ['handleEvent', 30],
83 | ];
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/src/EventSubscriber/Redirect.php:
--------------------------------------------------------------------------------
1 | event = $event;
28 |
29 | // Don't redirect, if the form isn't valid
30 | if (! $this->event->getForm()->isValid()) {
31 | return;
32 | }
33 |
34 | $this->formConfig = $this->event->getFormConfig();
35 | $this->feedback = new Collection($this->formConfig->get('feedback'));
36 | if ($this->feedback->get('redirect')) {
37 | $this->redirect();
38 | }
39 | }
40 |
41 | public function redirect(): void
42 | {
43 | if (isset($this->formConfig->get('submission')['ajax']) && $this->formConfig->get('submission')['ajax']) {
44 | return;
45 | }
46 |
47 | if (isset($this->feedback->get('redirect')['target']) && ! empty($this->feedback->get('redirect')['target'])) {
48 | $response = $this->getRedirectResponse($this->feedback->get('redirect'));
49 |
50 | $response->send();
51 | return;
52 | }
53 |
54 | throw new HttpException(Response::HTTP_FOUND, '', null, []);
55 | }
56 |
57 | protected function getRedirectResponse(array $redirect): ?RedirectResponse
58 | {
59 | $url = $this->makeUrl($redirect);
60 |
61 | return new RedirectResponse($url);
62 | }
63 |
64 | private function makeUrl($redirect): string
65 | {
66 | $parsedUrl = parse_url($redirect['target']);
67 |
68 | // Special case, if redirecting to 'self', get the current URL and return it
69 | if ($redirect['target'] == 'self') {
70 | return $this->event->getMeta()['path'];
71 | }
72 |
73 | // parse_str returns result in `$query` ¯\_(ツ)_/¯
74 | parse_str($parsedUrl['query'] ?? '', $query);
75 |
76 | if (isset($this->formConfig['feedback']['redirect']['query'])) {
77 | foreach ($this->formConfig['feedback']['redirect']['query'] as $key) {
78 | if ($this->event->getForm()->has($key)) {
79 | $query[$key] = $this->event->getForm()->get($key)->getNormData();
80 | }
81 | }
82 | }
83 |
84 | $url = Str::splitFirst($redirect['target'], '?') . '?' . http_build_query($query);
85 |
86 | if ((mb_strpos($url, 'http') === 0) || (mb_strpos($url, '#') === 0)) {
87 | return $url;
88 | }
89 |
90 | return '/' . ltrim($url, '/');
91 | }
92 |
93 | public static function getSubscribedEvents()
94 | {
95 | return [
96 | 'boltforms.post_submit' => ['handleEvent', 5],
97 | ];
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/src/EventSubscriber/SymfonyFormProxySubscriber.php:
--------------------------------------------------------------------------------
1 | .
32 | *
33 | * @author Gawain Lynch
34 | * @copyright Copyright (c) 2014-2016, Gawain Lynch
35 | * @license http://opensource.org/licenses/GPL-3.0 GNU Public License 3.0
36 | * @license http://opensource.org/licenses/LGPL-3.0 GNU Lesser General Public License 3.0
37 | */
38 | class SymfonyFormProxySubscriber implements EventSubscriberInterface
39 | {
40 | /** @var EventDispatcher */
41 | private $boltFormsDispatcher;
42 |
43 | /**
44 | * SymfonyFormProxySubscriber constructor.
45 | */
46 | public function __construct(EventDispatcherInterface $boltFormsDispatcher)
47 | {
48 | $this->boltFormsDispatcher = $boltFormsDispatcher;
49 | }
50 |
51 | /**
52 | * Events that BoltFormsSubscriber subscribes to
53 | */
54 | public static function getSubscribedEvents(): array
55 | {
56 | return [
57 | FormEvents::PRE_SET_DATA => 'preSetData',
58 | FormEvents::POST_SET_DATA => 'postSetData',
59 | FormEvents::PRE_SUBMIT => 'preSubmit',
60 | FormEvents::SUBMIT => 'submit',
61 | ];
62 | }
63 |
64 | /**
65 | * Event triggered on FormEvents::PRE_SET_DATA
66 | */
67 | public function preSetData(FormEvent $event, string $eventName, EventDispatcher $dispatcher): void
68 | {
69 | $this->dispatch(BoltFormsEvents::PRE_SET_DATA, $event, $eventName, $dispatcher);
70 | }
71 |
72 | /**
73 | * Event triggered on FormEvents::POST_SET_DATA
74 | */
75 | public function postSetData(FormEvent $event, string $eventName, EventDispatcher $dispatcher): void
76 | {
77 | $this->dispatch(BoltFormsEvents::POST_SET_DATA, $event, $eventName, $dispatcher);
78 | }
79 |
80 | /**
81 | * Form pre submission event
82 | *
83 | * Event triggered on FormEvents::PRE_SUBMIT
84 | *
85 | * To modify data on the fly, this is the point to do it using:
86 | * $data = $event->getData();
87 | * $event->setData($data);
88 | */
89 | public function preSubmit(FormEvent $event, string $eventName, EventDispatcher $dispatcher): void
90 | {
91 | $this->dispatch(BoltFormsEvents::PRE_SUBMIT, $event, $eventName, $dispatcher);
92 | }
93 |
94 | /**
95 | * Event triggered on FormEvents::SUBMIT
96 | */
97 | public function submit(FormEvent $event, string $eventName, EventDispatcher $dispatcher): void
98 | {
99 | $this->dispatch(BoltFormsEvents::SUBMIT, $event, $eventName, $dispatcher);
100 | }
101 |
102 | /**
103 | * Dispatch event.
104 | */
105 | protected function dispatch(string $eventName, FormEvent $event, string $formsEventName, EventDispatcher $dispatcher): void
106 | {
107 | $event = new BoltFormsEvent($event, $eventName);
108 | $this->boltFormsDispatcher->dispatch($event, $eventName);
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/src/Extension.php:
--------------------------------------------------------------------------------
1 | addTwigNamespace('boltforms');
20 | }
21 |
22 | public function dump(...$moreVars): void
23 | {
24 | if (! $this->getService('kernel')->isDebug() || ! $this->getConfig()->get('debug')['enabled']) {
25 | return;
26 | }
27 |
28 | dump(...$moreVars);
29 | }
30 |
31 | public function install(): void
32 | {
33 | $projectDir = $this->getContainer()->getParameter('kernel.project_dir');
34 |
35 | $filesystem = new Filesystem();
36 | $filesystem->mkdir($projectDir . '/config/extensions/bolt-boltforms/');
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/Factory/EmailFactory.php:
--------------------------------------------------------------------------------
1 | config = $config;
37 | $this->notification = collect($formConfig->get('notification', []));
38 | $this->form = $form;
39 | $this->formConfig = $formConfig;
40 |
41 | $debug = (bool) $this->config->get('debug')['enabled'];
42 |
43 | $attachments = $meta['attachments'] ?? [];
44 | unset($meta['attachments']);
45 |
46 | $email = (new TemplatedEmail())
47 | ->from($this->getFrom())
48 | ->to($this->getTo())
49 | ->subject($this->getSubject())
50 | ->htmlTemplate($this->getEmailTemplate())
51 | ->context([
52 | 'data' => $form->getData(),
53 | 'formname' => $form->getName(),
54 | 'meta' => $meta,
55 | 'config' => $formConfig,
56 | ]);
57 |
58 | if (self::hasCc()) {
59 | $email->cc($this->getCc());
60 | }
61 |
62 | if ($this->hasBcc()) {
63 | $email->bcc($this->getBcc());
64 | }
65 |
66 | if ($this->hasReplyTo()) {
67 | $email->replyTo($this->getReplyTo());
68 | }
69 |
70 | foreach ($attachments as $name => $attachment) {
71 | /** @var File $attachment */
72 | foreach ($attachment as $file) {
73 | $email->attachFromPath($file, $name . '.' . pathinfo($file, PATHINFO_EXTENSION));
74 | }
75 | }
76 |
77 | // Override the "to"
78 | if ($debug) {
79 | $email->to($this->config->get('debug')['address']);
80 | }
81 |
82 | return $email;
83 | }
84 |
85 | protected function getSubject(): string
86 | {
87 | $subject = $this->notification->get('subject', 'Untitled email');
88 | $subject = Str::ensureStartsWith($subject, $this->notification->get('subject_prefix', '[Boltforms] ') . ' ');
89 |
90 | return $this->parsePartial($subject);
91 | }
92 |
93 | protected function getFrom(): Address
94 | {
95 | return $this->getAddress('from_email', 'from_name');
96 | }
97 |
98 | protected function getTo(): Address
99 | {
100 | return $this->getAddress('to_email', 'to_name');
101 | }
102 |
103 | protected function hasCc(): bool
104 | {
105 | return $this->notification->has('cc_email');
106 | }
107 |
108 | protected function getCc(): Address
109 | {
110 | return $this->getAddress('cc_email', 'cc_name');
111 | }
112 |
113 | protected function hasBcc(): bool
114 | {
115 | return $this->notification->has('bcc_email');
116 | }
117 |
118 | protected function getBcc(): Address
119 | {
120 | return $this->getAddress('bcc_email', 'bcc_name');
121 | }
122 |
123 | protected function hasReplyTo(): bool
124 | {
125 | return $this->notification->has('replyto_email');
126 | }
127 |
128 | protected function getReplyTo(): Address
129 | {
130 | return $this->getAddress('replyto_email', 'replyto_name');
131 | }
132 |
133 | private function getAddress(string $email, string $name): Address
134 | {
135 | $email = $this->parsePartial($this->notification->get($email));
136 | $name = $this->parsePartial($this->notification->get($name));
137 |
138 | return new Address($email, $name);
139 | }
140 |
141 | protected function getEmailTemplate(): string
142 | {
143 | $templates = $this->config->get('templates', []);
144 |
145 | if (! \array_key_exists('email', $templates)) {
146 | return '@boltforms/email.html.twig';
147 | }
148 |
149 | if ($this->formConfig->has('templates') && isset($this->formConfig->get('templates')['email'])) {
150 | $template = $this->formConfig->get('templates')['email'];
151 | } else {
152 | $template = $templates['email'];
153 | }
154 |
155 | return $template;
156 | }
157 |
158 | private function parsePartial(string $partial): string
159 | {
160 | $fields = $this->form->getData();
161 |
162 | return Str::placeholders($partial, $fields);
163 | }
164 | }
165 |
--------------------------------------------------------------------------------
/src/Factory/FieldConstraints.php:
--------------------------------------------------------------------------------
1 | has('hcaptcha')) {
28 | $options['hcaptcha_public_key'] = $config['hcaptcha']['public_key'];
29 |
30 | if (isset($config['hcaptcha']['theme'])) {
31 | $options['hcaptcha_theme'] = $config['hcaptcha']['theme'];
32 | }
33 | }
34 |
35 | if ($config->has('recaptcha')) {
36 | $options['recaptcha_public_key'] = $config['recaptcha']['public_key'];
37 |
38 | if (isset($config['recaptcha']['theme'])) {
39 | $options['recaptcha_theme'] = $config['recaptcha']['theme'];
40 | }
41 | }
42 |
43 | unset($options['constraints']);
44 |
45 | if (isset($options['captcha_type'])) {
46 | switch ($options['captcha_type']) {
47 | case 'hcaptcha':
48 | $options['constraints'] = [
49 | new Hcaptcha($config['hcaptcha']['public_key'], $config['hcaptcha']['private_key']),
50 | ];
51 | break;
52 |
53 | case 'recaptcha_v3':
54 | $options['constraints'] = [
55 | new Recaptcha($config['recaptcha']['public_key'], $config['recaptcha']['private_key'], $options['captcha_type'], $config['recaptcha']['recaptcha_v3_threshold'], $config['recaptcha']['recaptcha_v3_fail_message']),
56 | ];
57 | break;
58 | case 'recaptcha_v2':
59 | $options['constraints'] = [
60 | new Recaptcha($config['recaptcha']['public_key'], $config['recaptcha']['private_key'], $options['captcha_type']),
61 | ];
62 | break;
63 | }
64 | }
65 | } elseif ($field['type'] === 'turnstileCaptcha') {
66 | $options['constraints'] = [
67 | new CloudflareTurnstile(),
68 | ];
69 | }
70 |
71 | return $options;
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/src/Factory/FieldType.php:
--------------------------------------------------------------------------------
1 | vars['captcha_type'] = $options['captcha_type'];
17 | $view->vars['captcha_invisible'] = $options['captcha_invisible'];
18 | $view->vars['hcaptcha_public_key'] = $options['hcaptcha_public_key'];
19 | $view->vars['hcaptcha_theme'] = $options['hcaptcha_theme'];
20 | $view->vars['recaptcha_public_key'] = $options['recaptcha_public_key'];
21 | $view->vars['recaptcha_theme'] = $options['recaptcha_theme'];
22 | }
23 |
24 | public function configureOptions(OptionsResolver $resolver): void
25 | {
26 | $resolver->setDefaults([
27 | 'compound' => false,
28 | 'captcha_type' => '',
29 | 'captcha_invisible' => false,
30 | 'hcaptcha_theme' => 'light',
31 | 'recaptcha_theme' => 'light',
32 | 'hcaptcha_public_key' => '',
33 | 'recaptcha_public_key' => '',
34 | ]);
35 | }
36 |
37 | public function getBlockPrefix()
38 | {
39 | return 'boltFormsCaptcha';
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/Form/ContenttypeType.php:
--------------------------------------------------------------------------------
1 | query = $query;
25 | }
26 |
27 | public function configureOptions(OptionsResolver $resolver): void
28 | {
29 | $resolver->setDefaults([
30 | 'choices' => [],
31 | 'params' => $this->getDefaultParams(),
32 | ]);
33 | }
34 |
35 | private function getDefaultParams(): array
36 | {
37 | return [
38 | 'contenttype' => 'pages',
39 | 'label' => 'title',
40 | 'value' => 'slug',
41 | 'limit' => 4,
42 | 'sort' => 'title',
43 | 'criteria' => [],
44 | ];
45 | }
46 |
47 | public function buildForm(FormBuilderInterface $builder, array $options): void
48 | {
49 | $builder
50 | ->addEventListener(FormEvents::PRE_SET_DATA, function(FormEvent $event) use ($options) {
51 | $form = $event->getForm();
52 | $params = array_merge($this->getDefaultParams(), $options['params']);
53 |
54 | $criteria = [
55 | 'status' => Statuses::PUBLISHED,
56 | 'limit' => $params['limit'],
57 | 'order' => $params['sort'],
58 | ];
59 |
60 | $criteria = array_merge($criteria, $params['criteria']);
61 |
62 | $entries = $this->query->getContent($params['contenttype'], $criteria);
63 |
64 | $choices = [];
65 | foreach ($entries->getCurrentPageResults() as $entry) {
66 | $value = $entry->getFieldValue($params['value']);
67 | $label = $entry->getFieldValue($params['label']);
68 | $choices[$label] = $value;
69 | }
70 |
71 | $options['choices'] = $choices;
72 | unset($options['params']);
73 |
74 | $parent = $form->getParent();
75 | $name = $form->getConfig()->getName();
76 | $parent->add($name, ChoiceType::class, $options);
77 | })
78 | ;
79 | }
80 |
81 | public function getParent(): string
82 | {
83 | return ChoiceType::class;
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/src/FormBuilder.php:
--------------------------------------------------------------------------------
1 | formFactory = $formFactory;
31 | }
32 |
33 | public function build(string $formName, array $data, Collection $config, EventDispatcherInterface $eventDispatcher): Form
34 | {
35 | /** @var SymfonyFormBuilder $formBuilder */
36 | $formBuilder = $this->formFactory->createNamedBuilder($formName, FormType::class, [], [
37 | 'attr' => [
38 | 'class' => 'boltforms',
39 | ],
40 | ]);
41 | $formBuilder->addEventSubscriber(new SymfonyFormProxySubscriber($eventDispatcher));
42 |
43 | foreach ($config->get($formName)['fields'] as $name => $field) {
44 | // If we passed in a default value, set it as the Field's `data`-value
45 | if (array_key_exists($name, $data)) {
46 | if(is_iterable($data[$name])){
47 | $field['options'] = array_merge($field['options'], $data[$name]['options']);
48 | } else {
49 | $field['options']['data'] = $data[$name];
50 | }
51 | }
52 |
53 | if ($field['type'] === 'captcha') {
54 | $this->addCaptchaField($formBuilder, $name, $field, $config);
55 | } else {
56 | $this->addField($formBuilder, $name, $field, $config, $formName);
57 | }
58 | }
59 |
60 | $this->addHoneypot($formName, $formBuilder, $config);
61 |
62 | return $formBuilder->getForm();
63 | }
64 |
65 | private function addCaptchaField(SymfonyFormBuilder $formBuilder, string $name, array $field, Collection $config): void
66 | {
67 | // Can't do anything if we don't know what type of CAPTCHA is required
68 | if (! isset($field['options']['captcha_type'])) {
69 | throw new \Exception(sprintf('The CAPTCHA field \'%s\' does not have a captcha_type option defined.', $name));
70 | }
71 |
72 | // If we're using reCaptcha V3 or V2 invisible, we need to add some attributes to the submit button
73 | // As we're adding each field, if it's a reCaptcha V3, flag that we're using it
74 |
75 | switch ($field['options']['captcha_type']) {
76 | case 'recaptcha_v3':
77 | if (! $config->has('recaptcha') || ! (bool) ($config->get('recaptcha')['enabled'])) {
78 | // Allow users to simply disable CAPTCHA protection by flipping the flag
79 | return;
80 | }
81 |
82 | $this->hasRecaptchaV3 = true;
83 |
84 | if (! isset($config['recaptcha']['public_key'])) {
85 | throw new \Exception('You must specify your site key using the public_key option under the recaptcha node in your forms config.');
86 | }
87 |
88 | if (! isset($config['recaptcha']['private_key'])) {
89 | throw new \Exception('You must specify your secret key using the private_key option under the recaptcha node in your forms config.');
90 | }
91 | break;
92 |
93 | case 'recaptcha_v2':
94 | if (! $config->has('recaptcha') || ! (bool) ($config->get('recaptcha')['enabled'])) {
95 | // Allow users to simply disable CAPTCHA protection by flipping the flag
96 | return;
97 | }
98 |
99 | $this->hasRecaptchaV2Invisible = isset($field['options']['captcha_invisible']) && (bool) ($field['options']['captcha_invisible']);
100 |
101 | if (! isset($config['recaptcha']['public_key'])) {
102 | throw new \Exception('You must specify your site key using the public_key option under the recaptcha node in your forms config.');
103 | }
104 |
105 | if (! isset($config['recaptcha']['private_key'])) {
106 | throw new \Exception('You must specify your secret key using the private_key option under the recaptcha node in your forms config.');
107 | }
108 | break;
109 |
110 | case 'hcaptcha':
111 | if (! $config->has('hcaptcha') || ! (bool) ($config->get('hcaptcha')['enabled'])) {
112 | // Allow users to simply disable CAPTCHA protection by flipping the flag
113 | return;
114 | }
115 |
116 | if (! isset($config['hcaptcha']['public_key'])) {
117 | throw new \Exception('You must specify your site key using the public_key option under the hcaptcha node in your forms config.');
118 | }
119 |
120 | if (! isset($config['hcaptcha']['private_key'])) {
121 | throw new \Exception('You must specify your secret key using the private_key option under the hcaptcha node in your forms config.');
122 | }
123 | break;
124 |
125 | default:
126 | throw new \Exception(sprintf('The captcha_type value \'%s\' is not supported on the \'%s\' field.', $field['options']['captcha_type'], $name));
127 | }
128 |
129 | $type = FieldType::get($field);
130 | $options = FieldOptions::get($name, $field, $config);
131 |
132 | $formBuilder->add($name, $type, $options);
133 | }
134 |
135 | private function addField(SymfonyFormBuilder $formBuilder, string $name, array $field, Collection $config, $formName): void
136 | {
137 | // If we're using reCaptcha V3 or V2 invisible, we need to add some attributes to the submit button
138 | // If we're adding a submit button, attach the attributes if we're using reCaptcha v3 or v2 invisible
139 | if ($field['type'] === 'submit' && ($this->hasRecaptchaV3 || $this->hasRecaptchaV2Invisible)) {
140 | $attr = [];
141 |
142 | if ($this->hasRecaptchaV3) {
143 | //Fix for allowing multiple recaptcha v3 forms on a single page
144 |
145 | //Used to converted snake case into camel case
146 | $splitFormName = explode("_", $formName);
147 | if (count($splitFormName) > 1) {
148 | foreach ($splitFormName as $item) {
149 | $item = ucfirst($item);
150 | }
151 | $formNameJs = join("", $splitFormName);
152 | } else {
153 | $formNameJs = ucfirst($formName);
154 | }
155 | $attr = [
156 | 'class' => 'g-recaptcha',
157 | 'data-sitekey' => $config['recaptcha']['public_key'],
158 | 'data-callback' => 'onRecaptchaSubmitted' . $formNameJs,
159 | // pass the name of the form as the Action for reCAPTCHA v3
160 | 'data-action' => $formName,
161 | ];
162 | } elseif ($this->hasRecaptchaV2Invisible) {
163 | $attr = [
164 | 'class' => 'g-recaptcha',
165 | 'data-sitekey' => $config['recaptcha']['public_key'],
166 | 'data-callback' => 'onRecaptchaSubmitted',
167 | 'data-size' => 'invisible',
168 | ];
169 | }
170 |
171 | // Merge our attributes with any existing ones defined in the config
172 | // If a CSS class is already defined, append our new class instead of merging so it doesn't end up as an
173 | // array instead of a string
174 | if (isset($field['options']['attr']['class'])) {
175 | $attr['class'] = sprintf('%s %s', $field['options']['attr']['class'], $attr['class']);
176 | unset($field['options']['attr']['class']);
177 | }
178 |
179 | $field['options'] = array_merge_recursive($field['options'], [
180 | 'attr' => $attr,
181 | ]);
182 | }
183 |
184 | $type = FieldType::get($field);
185 | $options = FieldOptions::get($name, $field, $config);
186 |
187 | // This part is needed for the `repeated` type
188 | if (isset($options['type'])) {
189 | $options['type'] = FieldType::get($options);
190 | }
191 |
192 | $formBuilder->add($name, $type, $options);
193 | }
194 |
195 | private function addHoneypot(string $formName, SymfonyFormBuilder $formBuilder, Collection $config): void
196 | {
197 | if ($config->get('honeypot', false)) {
198 | $honeypot = new Honeypot($formName, $formBuilder);
199 | $honeypot->addField();
200 | }
201 | }
202 | }
203 |
--------------------------------------------------------------------------------
/src/FormHelper.php:
--------------------------------------------------------------------------------
1 | isSubmitted()) {
14 | return null;
15 | }
16 |
17 | return preg_replace_callback(
18 | '/{([\w]+)}/i',
19 | function ($match) use ($form, $values) {
20 | if (\array_key_exists($match[1], $form->all())) {
21 | return (string) $form->get($match[1])->getData();
22 | }
23 |
24 | if (\array_key_exists($match[1], $values)) {
25 | return (string) $values[$match[1]];
26 | }
27 |
28 | return '(unknown)';
29 | },
30 | $format
31 | );
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/FormRuntime.php:
--------------------------------------------------------------------------------
1 | notifications = $notifications;
50 | $this->twig = $twig;
51 | $this->builder = $builder;
52 | $this->request = $requestStack->getCurrentRequest();
53 | $this->dispatcher = $dispatcher;
54 | $this->config = $boltFormsConfig;
55 | $this->postSubmitEventDispatcher = $postSubmitEventDispatcher;
56 | }
57 |
58 | public function run(string $formName = '', array $data = [], bool $warn = true)
59 | {
60 | $config = $this->config->getConfig();
61 | $extension = $this->config->getExtension();
62 |
63 | if (! $config->has($formName)) {
64 | return $warn ? $this->notifications->warning(
65 | '[Boltforms] Incorrect usage of form',
66 | 'The form "' . $formName . '" is not defined. '
67 | ) : '';
68 | }
69 |
70 | $formConfig = collect($config->get($formName));
71 | $form = $this->builder->build($formName, $data, $config, $this->dispatcher);
72 |
73 | $form->handleRequest($this->request);
74 |
75 | if ($form->isSubmitted()) {
76 | $this->postSubmitEventDispatcher->handle($formName, $form, $this->request);
77 | }
78 |
79 | $extension->dump($formConfig);
80 | $extension->dump($form);
81 |
82 | if ($config->get('honeypot')) {
83 | $honeypot = new Honeypot($formName);
84 | $honeypotName = $honeypot->generateFieldName(true);
85 | } else {
86 | $honeypotName = false;
87 | }
88 |
89 | if ($formConfig->has('templates') && isset($formConfig->get('templates')['form'])) {
90 | $template = $formConfig->get('templates')['form'];
91 | } else {
92 | $template = $config->get('templates')['form'];
93 | }
94 |
95 | return $this->twig->render($template, [
96 | 'boltforms_config' => $config,
97 | 'form_config' => $formConfig,
98 | 'debug' => $config->get('debug'),
99 | 'honeypot_name' => $honeypotName,
100 | 'form' => $form->createView(),
101 | 'submitted' => $form->isSubmitted(),
102 | 'valid' => $form->isSubmitted() && $form->isValid(),
103 | 'data' => $form->getData(),
104 | // Deprecated
105 | 'formconfig' => $formConfig,
106 | // Deprecated
107 | 'honeypotname' => $honeypotName,
108 | ]);
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/src/Honeypot.php:
--------------------------------------------------------------------------------
1 | formName = $formName;
21 | $this->formBuilder = $formBuilder;
22 | }
23 |
24 | public function addField(): void
25 | {
26 | $fieldname = $this->generateFieldName();
27 |
28 | $options = [
29 | 'required' => false,
30 | 'attr' => [
31 | 'tabindex' => '-1',
32 | 'autocomplete' => 'off',
33 | ],
34 | ];
35 |
36 | $this->formBuilder->add($fieldname, TextType::class, $options);
37 | }
38 |
39 | public function generateFieldName($withFormName = false): string
40 | {
41 | $seed = preg_replace('/[^0-9]/', '', md5($_SERVER['APP_SECRET'] . $_SERVER['REMOTE_ADDR']));
42 | mt_srand($seed % PHP_INT_MAX);
43 |
44 | $values = ['field', 'name', 'object', 'string', 'value', 'input', 'required', 'optional', 'first', 'last', 'phone', 'telephone', 'fax', 'twitter', 'contact', 'approve', 'city', 'state', 'province', 'company', 'card', 'number', 'recipient', 'processor', 'transaction', 'domain', 'date', 'type'];
45 |
46 | if ($withFormName) {
47 | $parts = [$this->formName];
48 | } else {
49 | $parts = [];
50 | }
51 |
52 | // Note: we're using mt_rand here, because we explicitly want
53 | // pseudo-random results, to make sure it's reproducible.
54 | for ($i = 0; $i <= mt_rand(2, 3); $i++) {
55 | $parts[] = $values[mt_rand(0, \count($values) - 1)];
56 | }
57 |
58 | return implode('_', $parts);
59 | }
60 |
61 | public function isenabled(): bool
62 | {
63 | return $this->config->get('honeypot', false);
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/src/Services/HcaptchaService.php:
--------------------------------------------------------------------------------
1 | registry = $extensionRegistry;
28 | }
29 |
30 | public function setKeys(string $siteKey, string $secretKey): void
31 | {
32 | $this->siteKey = $siteKey;
33 | $this->secretKey = $secretKey;
34 | }
35 |
36 | public function validateTokenFromRequest(Request $request)
37 | {
38 | $extension = $this->registry->getExtension(Extension::class);
39 |
40 | $validationData = [
41 | 'secret' => $this->secretKey,
42 | 'response' => $request->get(self::POST_FIELD_NAME),
43 | 'remoteip' => $request->getClientIp(),
44 | 'sitekey' => $this->siteKey,
45 | ];
46 | $extension->dump($validationData);
47 |
48 | $postData = http_build_query($validationData);
49 | $extension->dump($postData);
50 |
51 | $ch = curl_init('https://hcaptcha.com/siteverify');
52 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
53 | curl_setopt($ch, CURLOPT_POST, true);
54 | curl_setopt($ch, CURLOPT_POSTFIELDS, $postData);
55 | curl_setopt($ch, CURLOPT_HTTPHEADER, [
56 | 'Content-Type: application/x-www-form-urlencoded',
57 | ]);
58 |
59 | $response = curl_exec($ch);
60 | $extension->dump($response);
61 |
62 | $jsonResponse = json_decode($response);
63 |
64 | if ($jsonResponse === false) {
65 | throw new CaptchaException(sprintf('Unexpected response: %s', $response));
66 | }
67 |
68 | if ($jsonResponse->success) {
69 | return true;
70 | }
71 |
72 | return implode(',', $jsonResponse->{'error-codes'});
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/src/Services/RecaptchaService.php:
--------------------------------------------------------------------------------
1 | registry = $extensionRegistry;
33 | }
34 |
35 | public function setKeys(?string $siteKey = null, string $secretKey): void
36 | {
37 | // Note: $siteKey is not used, but here to stay in sync with HcaptchaService.php
38 | $this->secretKey = $secretKey;
39 | }
40 |
41 | /**
42 | */
43 | public function setRecaptchaVersion(string $recaptchaVersion): void
44 | {
45 | $this->recaptchaVersion = $recaptchaVersion;
46 | }
47 |
48 | public function setV3Threshold(float $v3Threshold): void
49 | {
50 | $v3Threshold = round($v3Threshold, 1);
51 |
52 | if ($v3Threshold >= 0.0 && $v3Threshold <= 1.0) {
53 | $this->v3Threshold = $v3Threshold;
54 | } else {
55 | throw new CaptchaException('Score must be between 0.0 and 1.0, you provided: ' . $v3Threshold);
56 | }
57 | }
58 |
59 | public function validateTokenFromRequest(Request $request)
60 | {
61 | $extension = $this->registry->getExtension(Extension::class);
62 |
63 | $validationData = [
64 | 'secret' => $this->secretKey,
65 | 'response' => $request->get(self::POST_FIELD_NAME),
66 | 'remoteip' => $request->getClientIp(),
67 | ];
68 | $extension->dump($validationData);
69 |
70 | $postData = http_build_query($validationData);
71 | $extension->dump($postData);
72 |
73 | $ch = curl_init('https://www.google.com/recaptcha/api/siteverify');
74 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
75 | curl_setopt($ch, CURLOPT_POST, true);
76 | curl_setopt($ch, CURLOPT_POSTFIELDS, $postData);
77 | curl_setopt($ch, CURLOPT_HTTPHEADER, [
78 | 'Content-Type: application/x-www-form-urlencoded',
79 | ]);
80 |
81 | $response = curl_exec($ch);
82 | $extension->dump($response);
83 |
84 | $jsonResponse = json_decode($response);
85 |
86 | if ($jsonResponse === false) {
87 | throw new CaptchaException(sprintf('Unexpected response: %s', $response));
88 | }
89 |
90 | if ($jsonResponse->success) {
91 | if ($this->recaptchaVersion === self::RECAPTCHA_VERSION_3 && $jsonResponse->score < $this->v3Threshold) {
92 | return false;
93 | }
94 |
95 | return true;
96 | }
97 |
98 | return implode(',', $jsonResponse->{'error-codes'});
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/src/TwigExtension.php:
--------------------------------------------------------------------------------
1 | ['html'],
19 | ];
20 |
21 | return [
22 | new TwigFunction('boltform', [FormRuntime::class, 'run'], $safe),
23 | new TwigFunction('boltforms', [FormRuntime::class, 'run'], $safe),
24 | ];
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/Validator/Constraints/Hcaptcha.php:
--------------------------------------------------------------------------------
1 | siteKey = $siteKey;
31 | $this->secretKey = $secretKey;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/Validator/Constraints/HcaptchaValidator.php:
--------------------------------------------------------------------------------
1 | service = $service;
25 | $this->request = $requestStack->getCurrentRequest();
26 | }
27 |
28 | public function validate($value, Constraint $constraint): void
29 | {
30 | if (! $constraint instanceof Hcaptcha) {
31 | throw new UnexpectedTypeException($constraint, Hcaptcha::class);
32 | }
33 |
34 | if (empty($this->request->get(HcaptchaService::POST_FIELD_NAME))) {
35 | $this->context->buildViolation($constraint->incompleteMessage)
36 | ->addViolation();
37 |
38 | return;
39 | }
40 |
41 | $this->service->setKeys($constraint->siteKey, $constraint->secretKey);
42 |
43 | $result = $this->service->validateTokenFromRequest($this->request);
44 |
45 | if ($result !== true) {
46 | $this->context->buildViolation($constraint->message)
47 | ->setParameter('{{ error }}', $result)
48 | ->addViolation();
49 | }
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/Validator/Constraints/Recaptcha.php:
--------------------------------------------------------------------------------
1 | siteKey = $siteKey;
36 | $this->secretKey = $secretKey;
37 | $this->recaptchaVersion = $recaptchaVersion;
38 | $this->v3Threshold = $v3Threshold;
39 | $this->v3ThresholdFailedMessage = $v3ThresholdFailedMessage;
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/Validator/Constraints/RecaptchaValidator.php:
--------------------------------------------------------------------------------
1 | service = $service;
25 | $this->request = $requestStack->getCurrentRequest();
26 | }
27 |
28 | public function validate($value, Constraint $constraint): void
29 | {
30 | if (! $constraint instanceof Recaptcha) {
31 | throw new UnexpectedTypeException($constraint, Recaptcha::class);
32 | }
33 |
34 | if (empty($this->request->get(RecaptchaService::POST_FIELD_NAME))) {
35 | $this->context->buildViolation($constraint->incompleteMessage)
36 | ->addViolation();
37 |
38 | return;
39 | }
40 |
41 | $this->service->setKeys($constraint->siteKey, $constraint->secretKey);
42 | $this->service->setRecaptchaVersion($constraint->recaptchaVersion);
43 | if (isset($constraint->v3Threshold)) {
44 | $this->service->setV3Threshold($constraint->v3Threshold);
45 | }
46 |
47 | $result = $this->service->validateTokenFromRequest($this->request);
48 |
49 | if ($result !== true) {
50 | if ($result === false) {
51 | $this->context->buildViolation($constraint->v3ThresholdFailedMessage)
52 | ->addViolation();
53 | } else {
54 | $this->context->buildViolation($constraint->message)
55 | ->setParameter('{{ error }}', $result)
56 | ->addViolation();
57 | }
58 | }
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/templates/assets/boltforms.css:
--------------------------------------------------------------------------------
1 | form.boltforms label.required:after {
2 | content: " *";
3 | color: #F00;
4 | font-weight: normal;
5 | }
6 |
7 | form.boltforms .form-group {
8 | margin: 0.75em 0.1em;
9 | }
10 | .boltforms-feedback {
11 | padding: 8px 35px 8px 14px;
12 | margin-bottom: 20px;
13 | text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
14 | -webkit-border-radius: 4px;
15 | -moz-border-radius: 4px;
16 | border-radius: 4px;
17 | }
18 |
19 | .boltforms-feedback.success {
20 | border: 1px solid #D6E9C6;
21 | color: #468847;
22 | background-color: #DFF0D8;
23 | }
24 |
25 | .boltforms-feedback.debug {
26 | border: 1px solid #FFEEBA;
27 | color: #856404;
28 | background-color: #FFF3CD;
29 | }
30 |
31 | .boltforms-feedback.error {
32 | border: 1px solid #EED3D7;
33 | color: #B94A48;
34 | background-color: #F2DEDE;
35 | }
36 |
37 | ul.boltform-error {
38 | margin-left: 0;
39 | padding-left: 0;
40 | }
41 |
42 | li.boltform-error {
43 | list-style: none;
44 | border: 1px solid #EED3D7;
45 | color: #B94A48;
46 | background-color: #F2DEDE;
47 | }
48 |
49 | .boltforms-preview-image {
50 | display: inline-block;
51 | }
52 |
53 | div.grecaptcha-badge, div.grecaptcha-logo {
54 | padding: 0;
55 | }
56 |
--------------------------------------------------------------------------------
/templates/captcha.html.twig:
--------------------------------------------------------------------------------
1 | {% block boltFormsCaptcha_label %}
2 | {% if captcha_type != 'recaptcha_v3' and (captcha_type != 'recaptcha_v2' or not captcha_invisible) and label !== false %}
3 | {{ block('form_label') }}
4 | {% endif %}
5 | {% endblock %}
6 |
7 | {% block boltFormsCaptcha_widget %}
8 | {% if captcha_type == 'hcaptcha' %}
9 |
10 | {% elseif captcha_type == 'recaptcha_v3' %}
11 |
12 | {% elseif captcha_type == 'recaptcha_v2' %}
13 | {% if captcha_invisible %}
14 |
15 | {% else %}
16 |
17 | {% endif %}
18 | {% endif %}
19 | {# If we don't have a label, errors won't be rendered, so show them here instead #}
20 | {% if label === false or captcha_type == 'recaptcha_v3' or (captcha_type == 'recaptcha_v2' and captcha_invisible) %}
21 | {{- form_errors(form) -}}
22 | {% endif %}
23 | {% endblock %}
24 |
--------------------------------------------------------------------------------
/templates/email.html.twig:
--------------------------------------------------------------------------------
1 | {#
2 | # Passed in variables:
3 | # email — The mail's WrappedTemplatedEmail instance
4 | # data — POSTed data
5 | # formname — Field data from the form configuration
6 | # config — Current Form's configuration data
7 | # meta — Meta data (IP, URL, Timestamp)
8 | #}
9 |
Dear {{ email.toName }},
10 |
11 |
Somebody used the form {{ formname }} on {{ meta.url }} to send you a message.
12 |
13 |
The posted data is as follows:
14 |
15 |
16 | {{ block('submission_summary', '@boltforms/email_blocks.html.twig') }}
17 |
18 |
19 |
20 |
21 | Sent by the BoltForms extension for Bolt.
--------------------------------------------------------------------------------
/templates/email_blocks.html.twig:
--------------------------------------------------------------------------------
1 | {% block submission_summary %}
2 |
3 | {%- for fieldname, values in data|filter((values, fieldname) => attribute(config.fields, fieldname) is defined) %}
4 | {%- set field = attribute(config.fields, fieldname) %}
5 | {%- set label = field.options.label|default(fieldname) %}
6 |
7 | {% if not (field.type == 'file' and field.attach|default) %}
8 | {%- if values is iterable %}
9 |
3 | {%- for fieldname, value in data|filter((value, fieldname) => attribute(config.fields, fieldname) is defined) %}
4 | {%- set definition = attribute(config.fields, fieldname) %}
5 | {%- set label = definition.options.label|default(fieldname)|replace({'_':' '})|capitalize %}
6 |
7 | {{ block('print_field') }}
8 | {%- endfor %}
9 |
10 | {%- for label, value in meta %}
11 | {% set label = label|capitalize %}
12 | {{ block('print_field') }}
13 | {%- endfor %}
14 |
15 |
16 |
22 | {% endblock %}
23 |
24 | {# Print the label and value for a field. #}
25 | {% block print_field %}
26 |
27 |
28 | {{ block('print_label') }}
29 |
30 |
31 | {{ block('print_value') }}
32 |
33 |
34 | {% endblock %}
35 |
36 | {# Print the label of a field.
37 | One of three blocks is used, in order of priority:
38 | 1. A block based on the field label, e.g. label_first_name
39 | 2. A block based on the field type, e.g. label_text
40 | 3. Otherwise, use the label_generic block.
41 | #}
42 | {% block print_label %}
43 | {% if block('label_'~label) is defined %}
44 | {{ block('label_'~label) }}
45 | {% elseif block('label_'~definition.type|default) is defined %}
46 | {{ block('label_'~definition.type) }}
47 | {% else %}
48 | {{ block('label_generic') }}
49 | {% endif %}
50 | {% endblock %}
51 |
52 | {# Use {{ label }} variable. #}
53 | {% block label_generic %}
54 | {{ label }}
55 | {% endblock %}
56 |
57 | {# Don't show any file field labels.#}
58 | {% block label_file %}
59 | {% endblock %}
60 |
61 | {# Print the value of a field. #}
62 | {# Available variables: value, definition, label. #}
63 | {# One of three blocks is used, in order of priority#}
64 | {# 1. A block based on the field label, e.g. value_first_name#}
65 | {# 2. A block based on the field type, e.g. value_text#}
66 | {# 3. Otherwise, use the value_generic block.#}
67 | {% block print_value %}
68 | {% if block('value_'~label) is defined %}
69 | {{ block('value_'~label) }}
70 | {% elseif block('value_'~definition.type|default) is defined %}
71 | {{ block('value_'~definition.type) }}
72 | {% else %}
73 | {{ block('value_generic') }}
74 | {% endif %}
75 | {% endblock %}
76 |
77 | {# Use the {{ value }} variable. #}
78 | {% block value_generic %}
79 | {% if value is iterable %}
80 |