Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions UPGRADE.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Upgrade Notes

## 5.3.4
- **[SECURITY]** Introduce upload field reference and server-side MIME type validation. Read more about upload security [here](./docs/80_FileUpload.md#security)

## 5.3.3
- **[BUGFIX]** Sanitize form field values by removing template tags during output transformation

Expand Down
2 changes: 1 addition & 1 deletion config/install/translations/admin.csv
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@
"form_builder_type_field.service_name","Service Name","Service Name"
"form_builder_type_field.placeholder_desc","This option determines whether or not a special 'empty' option (e.g. 'Choose an option') will appear at the top of a select field. This option only applies if the multiple option is set to false. Leave empty for no default option.","Diese Option legt fest, ob eine spezielle 'leere' Option (z. B. 'Wählen Sie eine Option') am oberen Rand eines Auswahlfeldes erscheinen soll. Diese Option gilt nur, wenn die Option 'Mehrfach' nicht aktiv ist. Leer lassen für keine Standardoption."
"form_builder_type_field.allowed_extensions","Allowed Extensions","Erlaubte Dateitypen"
"form_builder_type_field.allowed_extensions_desc","Add some extensions and confirm with enter.","Fügen Sie erlaubte Dateierweiterungen hinzu und bestätigen Sie mit der Enter-Taste."
"form_builder_type_field.allowed_extensions_desc","Add some extensions and confirm with Enter. If server-side MIME type validation is enabled, you can also specify full MIME types (e.g. application/pdf).","Fügen Sie erlaubte Dateierweiterungen hinzu und bestätigen Sie mit der Enter-Taste. Wenn die serverseitige MIME-Type-Validierung aktiviert ist, können Sie auch vollständige MIME-Typen angeben (z. B. application/pdf)."
"form_builder_type_field.item_limit","Item Limit","Datei-Limit"
"form_builder_type_field.item_limit_desc","The maximum number of files that can be uploaded. 0 = unlimited.","Die maximale Anzahl an Dateien welche hochgeladen werden darf. 0 = Kein Limit."
"form_builder_type_field.max_file_size","Max File Size","Max. Dateigröße"
Expand Down
60 changes: 51 additions & 9 deletions docs/80_FileUpload.md
Original file line number Diff line number Diff line change
@@ -1,29 +1,29 @@
# Dynamic Multi File

FormBuilder comes with a smart multi file upload type.
FormBuilder comes with a smart multi-file upload type.
It allows you to use different adapters/libraries like FineUploader or DropZoneJs.

## Highlights
- File Upload per file type (yes, it's possible to place multiple upload fields per form)
- Large File Support: Process chunked files to allow large file uploads
- Different adapters: Choose between different upload handler or create a custom one!
- Stateless: no session is required to handle file uploads
- Different storage principals: Store data as pimcore assets (`/formdata` asset folder) and add download-link to mail **or** add them as native mail attachments
- Different storage principals: Store data as pimcore assets (`/formdata` asset folder) and add a download-link to mail **or** add them as native mail attachments
- Stay clean: unsubmitted data / chunk data will be swiped via maintenance
- Prebuild Extensions: Use included jQuery extensions to set up your form in front end in no time!
- Prebuild Extensions: Use included jQuery extensions to set up your form in the front end in no time!

## Field Configuration
There are some options in the (backend) field configuration:

| Name | Description
|------|------------|
| `Max File Size` | Max file size will be calculated in MB. Empty or zero means no limit |
| `Allowed Extensions` | Define allowed extensions, for example: `pdf, zip` (Format depends on active adapter) |
| `Item limit` | The maximum number of files that can be uploaded. Empty or zero means no limit |
| Name | Description |
|----------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `Max File Size` | Max file size will be calculated in MB. Empty or zero means no limit |
| `Allowed Extensions` | Define allowed extensions, for example: `pdf, zip` (Format depends on active adapter) |
| `Item limit` | The maximum number of files that can be uploaded. Empty or zero means no limit |
| `Send Files as Attachment` | All Files will be stored in your pimcore asset structure (/formdata) by default. If you check this option, the files will be attached to the mail instead of adding a download link |

## Setup
Per default, FineUploader will be used. If you want to change the dmf adapter, you need to define it:
Per default, DropZone will be used. If you want to change the dmf adapter, you need to define it:

```yaml
form_builder:
Expand All @@ -34,6 +34,48 @@ form_builder:
By default, you don't need to implement more than the standard initialization, described in [FormBuilder Javascript Core Extension](./91_Javascript.md).
The core extension will try to fetch the handler path, defined by `dynamicMultiFileHandlerOptions.defaultHandlerPath`.

## Security

### Field Reference

```yaml
form_builder:
security:
enable_upload_field_reference: true
```

The Field Reference feature ensures that every uploaded file is associated with an existing form field.
When enabled, the client must send a reference to the corresponding form field along with the file.
The server validates that the field exists and that the upload complies with the field’s configuration (e.g., allowed file types, maximum upload size).

> [!CAUTION]
> This option is disabled by default to avoid breaking existing uploads.
> Enable it when you want to enforce field-level validation.

### Server MIME Type Validation

```yaml
form_builder:
security:
enable_upload_server_mime_type_validation: true
```

Server MIME Type Validation enforces that uploaded files match the allowed MIME types defined on the server.
When enabled, the server checks the actual file content rather than relying solely on file extensions provided by the client.
This ensures that only valid file types are accepted, preventing spoofed files or uploads that do not match the allowed formats.

> [!CAUTION]
>Important: When MIME type validation is active, you must list the allowed MIME types in your form field configuration.
> Users can no longer just specify file extensions (like `pdf` or `jpg`); the server will validate the real MIME type of the uploaded file (for example, `application/pdf` or `image/jpeg`).
> If the MIME type of the file is not included in the server configuration, the upload will be rejected, even if the file extension appears valid.
> Always ensure the MIME types in your configuration match the types you expect to allow for uploads.

> [!CAUTION]
> Note: This option is disabled by default to maintain backward compatibility.
> Enable it when you want the server to strictly validate MIME types for uploaded files.

***

## Available Adapter
- [DropZoneJs](./DynamicMultiFile/01_DropZoneJs.md)
- [FineUploader](./DynamicMultiFile/02_FineUploader.md)
Expand Down
16 changes: 16 additions & 0 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ public function getConfigTreeBuilder()
$rootNode->append($this->createPersistenceNode());
$rootNode->append($this->buildFlagsNode());
$rootNode->append($this->buildSpamProductionNode());
$rootNode->append($this->buildSecurityNode());
$rootNode->append($this->buildAreaNode());
$rootNode->append($this->buildFormConfigurationNode());
$rootNode->append($this->buildAdminConfigurationNode());
Expand Down Expand Up @@ -608,6 +609,21 @@ private function buildAreaNode(): NodeDefinition
return $rootNode;
}

private function buildSecurityNode(): NodeDefinition
{
$builder = new TreeBuilder('security');

$rootNode = $builder->getRootNode();
$rootNode
->addDefaultsIfNotSet()
->children()
->booleanNode('enable_upload_field_reference')->defaultFalse()->end()
->booleanNode('enable_upload_server_mime_type_validation')->defaultFalse()->end()
->end();

return $rootNode;
}

private function buildSpamProductionNode(): NodeDefinition
{
$builder = new TreeBuilder('spam_protection');
Expand Down
39 changes: 30 additions & 9 deletions src/DynamicMultiFile/Adapter/DropZoneAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,15 @@ public function getJsHandler(): string
public function onUpload(Request $request): Response
{
$result = $this->fileStream->handleUpload([
'binary' => 'dmfData',
'uuid' => 'uuid',
'chunkIndex' => 'dzchunkindex',
'totalChunkCount' => 'dztotalchunkcount',
'totalFileSize' => 'dztotalfilesize',
'binary' => 'dmfData',
'uuid' => 'uuid',
'fieldReferenceKey' => 'fieldReference',
'chunkIndex' => 'dzchunkindex',
'totalChunkCount' => 'dztotalchunkcount',
'totalFileSize' => 'dztotalfilesize',
]);

return new JsonResponse($result);
return new JsonResponse($result, $result['statusCode'] ?? 200);
}

public function onDone(Request $request): Response
Expand All @@ -62,10 +63,30 @@ public function onDone(Request $request): Response
public function onDelete(Request $request): Response
{
$identifier = $request->attributes->get('identifier');
$checkChunkFolder = $request->request->get('uploadStatus') === 'canceled';

$result = $this->fileStream->handleDelete($identifier, $checkChunkFolder);
$uploadStatus = null;
$checkChunkFolder = false;
$fieldReference = null;

return new JsonResponse($result);
if ($request->request->has('uploadStatus')) {
$uploadStatus = $request->request->get('uploadStatus');
$checkChunkFolder = $uploadStatus === 'canceled';
}

if ($request->request->has('fieldReference')) {
$fieldReference = $request->request->get('fieldReference');
}

try {
$body = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
$checkChunkFolder = ($body['uploadStatus'] ?? $uploadStatus) === 'canceled';
$fieldReference = $body['fieldReference'] ?? $fieldReference;
} catch (\Throwable) {
// fail silently
}

$result = $this->fileStream->handleDelete($identifier, $checkChunkFolder, $fieldReference);

return new JsonResponse($result, $result['statusCode'] ?? 200);
}
}
32 changes: 18 additions & 14 deletions src/DynamicMultiFile/Adapter/FineUploadAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,15 @@ public function onUpload(Request $request): Response

if ($method === 'POST') {
$result = $this->fileStream->handleUpload([
'binary' => 'qqfile',
'uuid' => 'qquuid',
'chunkIndex' => 'qqpartindex',
'totalChunkCount' => 'qqtotalparts',
'totalFileSize' => 'qqtotalfilesize',
'binary' => 'qqfile',
'uuid' => 'qquuid',
'fieldReferenceKey' => 'fieldReference',
'chunkIndex' => 'qqpartindex',
'totalChunkCount' => 'qqtotalparts',
'totalFileSize' => 'qqtotalfilesize',
], false);

return new JsonResponse($result);
return new JsonResponse($result, $result['statusCode'] ?? 200);
}

if ($method === 'DELETE') {
Expand All @@ -64,14 +65,15 @@ public function onUpload(Request $request): Response
public function onDone(Request $request): Response
{
$result = $this->fileStream->combineChunks([
'fileName' => $request->request->get('qqfilename'),
'uuid' => 'qquuid',
'chunkIndex' => 'qqpartindex',
'totalChunkCount' => 'qqtotalparts',
'totalFileSize' => 'qqtotalfilesize',
'fileName' => $request->request->get('qqfilename'),
'uuid' => 'qquuid',
'fieldReferenceKey' => 'fieldReference',
'chunkIndex' => 'qqpartindex',
'totalChunkCount' => 'qqtotalparts',
'totalFileSize' => 'qqtotalfilesize',
]);

return new JsonResponse($result, $result['statusCode']);
return new JsonResponse($result, $result['statusCode'] ?? 200);
}

public function onDelete(Request $request): Response
Expand All @@ -80,8 +82,10 @@ public function onDelete(Request $request): Response
? $request->attributes->get('identifier')
: $request->request->get('uuid');

$result = $this->fileStream->handleDelete($identifier);
$fieldReference = $request->query->get('fieldReference');

return new JsonResponse($result);
$result = $this->fileStream->handleDelete($identifier, false, $fieldReference);

return new JsonResponse($result, $result['statusCode'] ?? 200);
}
}
18 changes: 18 additions & 0 deletions src/Exception/UploadErrorException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

/*
* This source file is available under two different licenses:
* - GNU General Public License version 3 (GPLv3)
* - DACHCOM Commercial License (DCL)
* Full copyright and license information is available in
* LICENSE.md which is distributed with this source code.
*
* @copyright Copyright (c) DACHCOM.DIGITAL AG (https://www.dachcom-digital.com)
* @license GPLv3 and DCL
*/

namespace FormBuilderBundle\Exception;

class UploadErrorException extends \Exception
{
}
26 changes: 23 additions & 3 deletions src/Form/Type/DynamicMultiFileType.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,15 @@

use FormBuilderBundle\Configuration\Configuration;
use FormBuilderBundle\Event\Form\FormTypeOptionsEvent;
use FormBuilderBundle\Form\Data\FormData;
use FormBuilderBundle\FormBuilderEvents;
use FormBuilderBundle\Registry\DynamicMultiFileAdapterRegistry;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
Expand Down Expand Up @@ -49,15 +52,32 @@ public function configureOptions(OptionsResolver $resolver): void

public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addEventListener(FormEvents::POST_SET_DATA, [$this, 'handleDynamicMultiFileForm']);
}

public function handleDynamicMultiFileForm(FormEvent $event): void
{
$adapterFormFieldName = 'adapter';

$dmfAdapterName = $this->configuration->getConfig('dynamic_multi_file_adapter');
$dmfAdapter = $this->dynamicMultiFileAdapterRegistry->get($dmfAdapterName);

$options = $event->getForm()->getConfig()->getOptions();

$options['compound'] = true;
$options['auto_initialize'] = false;
$options['label'] = empty($options['label']) ? false : $options['label'];
$options['attr']['data-dynamic-multi-file-instance'] = 'true';
$options['attr']['data-js-handler'] = $dmfAdapter->getJsHandler();

$adapterFormFieldName = 'adapter';
$rootForm = $event->getForm()->getRoot();
if ($rootForm->getData() instanceof FormData) {
$options['attr']['data-field-reference'] = sprintf(
'%s:%s',
$rootForm->getData()->getFormDefinition()->getId(),
$event->getForm()->getName()
);
}

$dmfForm = $this->formFactory->createNamedBuilder(
$adapterFormFieldName,
Expand All @@ -66,7 +86,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void
$this->dispatchFormTypeOptionsEvent($adapterFormFieldName, $dmfAdapter->getForm(), $options)
);

$dmfForm->add('data', HiddenType::class, []);
$dmfForm->add('data', HiddenType::class);
$dmfForm->get('data')->addModelTransformer(new CallbackTransformer(
function ($identifier) {
return $identifier === null ? null : json_encode($identifier, JSON_THROW_ON_ERROR);
Expand All @@ -76,7 +96,7 @@ function ($identifier) {
}
));

$builder->add($dmfForm);
$event->getForm()->add($dmfForm->getForm());
}

private function dispatchFormTypeOptionsEvent(string $name, string $type, array $options): array
Expand Down
Loading