├── .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 | ![Form saved as Bolt Record](https://user-images.githubusercontent.com/1833361/122095553-d34f0500-ce0d-11eb-827d-00077c00c53f.png) 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 | ![contact form](https://user-images.githubusercontent.com/7093518/94814151-3a03ce00-03f9-11eb-8946-96a03ba0a263.png) 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 |
  • 10 | {{ block('field_label') }} 11 | {{ block('value_array') }} 12 |
  • 13 | {%- else %} 14 | {%- set value = values %} 15 | {% apply spaceless %} 16 |
  • 17 | {{ block('field_label') }} 18 | {% if field.type in ['date', 'datetime', 'dateinterval'] %} 19 | {{ block('field_date') }} 20 | {% else %} 21 | {{ block('field_value') }} 22 | {% endif %} 23 |
  • 24 | {% endapply %} 25 | {%- endif %} 26 | {% endif %} 27 | {%- endfor %} 28 |
29 |
    30 | {%- for label, value in meta %} 31 |
  • {{ block('field_label') }}{{ block('field_value') }}
  • 32 | {%- endfor %} 33 |
34 | 35 | {% endblock %} 36 | 37 | {% block field_label %} 38 | {%- if label is not empty %}{{ label|striptags }}: {% endif %} 39 | {% endblock %} 40 | 41 | {% block field_value %} 42 | {{- value }} 43 | {% endblock %} 44 | 45 | {% block field_date %} 46 | {{- value|localdate() }} 47 | {% endblock %} 48 | 49 | {% block file_field_value %} 50 | {% if value == '' %} 51 | {{ name }} 52 | {% else %} 53 | {{ name }} 54 | {% endif %} 55 | {% endblock %} 56 | 57 | {% block value_array %} 58 |
    59 | {%- for name, value in values %} 60 | {%- if value is iterable %} 61 | {{ block('value_array') }} 62 | {%- else %} 63 | 64 |
  • 65 | {%- if field.type == 'file' %} 66 | {{ block('file_field_value') }} 67 | {%- else %} 68 | {{ block('field_value') }} 69 | {%- endif %} 70 |
  • 71 | 72 | {%- endif %} 73 | {%- endfor %} 74 | 75 |
76 | {% endblock %} 77 | -------------------------------------------------------------------------------- /templates/email_blocks_table.html.twig: -------------------------------------------------------------------------------- 1 | {% block submission_summary %} 2 | 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 |
    81 | {% for v in value %} 82 |
  • {{ v }}
  • 83 | {% endfor %} 84 |
85 | {% else %} 86 | {{ value }} 87 | {% endif %} 88 | {% endblock %} 89 | 90 | {% block value_date %} 91 | {{ value|date('F j, Y') }} 92 | {% endblock %} 93 | 94 | {% block value_datetime %} 95 | {{ value|localdate }} 96 | {% endblock %} 97 | 98 | {% block value_dateinterval %} 99 | {{ value|localdate }} 100 | {% endblock %} 101 | 102 | {% block value_time %} 103 | {{ value|date('H:i') }} 104 | {% endblock %} 105 | 106 | {# Don't show any file fields. #} 107 | {% block value_file %} 108 | {% endblock %} 109 | -------------------------------------------------------------------------------- /templates/email_table.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_table.html.twig') }} 17 |
18 | 19 |
20 | 21 | Sent by the BoltForms extension for Bolt. 22 | -------------------------------------------------------------------------------- /templates/form.html.twig: -------------------------------------------------------------------------------- 1 | {# this tag only applies to the forms defined in this template #} 2 | {% set layout = [boltforms_config.layout.form|default('form_div_layout.html.twig'), '@boltforms/captcha.html.twig'] %} 3 | {% form_theme form with layout %} 4 | 5 | {% if boltforms_config.layout.bootstrap|default() %} 6 | 7 | {% endif %} 8 | 9 | {% if boltforms_config.hcaptcha.enabled|default() %} 10 | 11 | {% endif %} 12 | 13 | {% if boltforms_config.recaptcha.enabled|default() %} 14 | 15 | {% set formSplit = form.vars.name|split("_") %} 16 | {% if formSplit|length > 1 %} 17 | {% for item in formSplit %} 18 | {% set item = item|capitalize %} 19 | {% endfor %} 20 | {% set formNameJs = formSplit|join("") %} 21 | {% else %} 22 | {% set formNameJs = form.vars.name|capitalize %} 23 | {% endif %} 24 | 25 | 26 | 61 | {% endif %} 62 | 63 | 66 | 67 | {% if debug.enabled %} 68 |
Debug is enabled. Check the Dump panel in the Symfony Toolbar for details.
69 | {% endif %} 70 | 71 | {% if valid %} 72 |
{{ formconfig.feedback.success|raw }}
73 | {% endif %} 74 | 75 | {% if submitted and not valid %} 76 |
{{ formconfig.feedback.error|raw }}
77 | {% endif %} 78 | 79 | {% if not valid %} 80 | {% include '@boltforms/honeypotstyle.html.twig' with {'honeypot_name': honeypot_name} %} 81 | {{ form(form, {'attr': { 'id': form.vars.id|default }}) }} 82 | {% endif %} 83 | -------------------------------------------------------------------------------- /templates/honeypotstyle.html.twig: -------------------------------------------------------------------------------- 1 | {% if honeypot_name %} 2 | 17 | {% endif %} 18 | -------------------------------------------------------------------------------- /templates/page.html.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ title }} 6 | 7 | 10 | 11 | 12 | 13 | 14 |

{{title}}

15 | 16 |

Hello, {{ name }}

17 | 18 | {{ dump() }} 19 | 20 | 21 | -------------------------------------------------------------------------------- /templates/widget.html.twig: -------------------------------------------------------------------------------- 1 |
2 |
3 | {{ extension.name }} 4 |
5 |
6 | 7 |

{{ extension.composerpackage.description }}

8 | 9 |

{{ extension.composerpackage.description|rot13 }}

10 | 11 |

12 | 13 | Button 14 | 15 | 16 | Dangerous button 17 | 18 |

19 |
20 |
--------------------------------------------------------------------------------