Allow to upload files to attachments by passing a base64 encoded file

This commit is contained in:
Jan Böhmer 2024-03-03 18:40:35 +01:00
parent 0824e11570
commit 0c33059c4e
6 changed files with 261 additions and 32 deletions

View file

@ -26,6 +26,7 @@
"florianv/swap": "^4.0",
"florianv/swap-bundle": "dev-master",
"gregwar/captcha-bundle": "^2.1.0",
"hshn/base64-encoded-file": "^5.0",
"jbtronics/2fa-webauthn": "^v2.2.0",
"jbtronics/dompdf-font-loader-bundle": "^1.0.0",
"jfcherng/php-diff": "^6.14",

64
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "6c3d6e309f579d6683344fead9a86d50",
"content-hash": "d262b7af88fd38fff57c486ce7f61cbe",
"packages": [
{
"name": "api-platform/core",
@ -2904,6 +2904,68 @@
],
"time": "2023-12-03T20:05:35+00:00"
},
{
"name": "hshn/base64-encoded-file",
"version": "v5.0.1",
"source": {
"type": "git",
"url": "https://github.com/hshn/base64-encoded-file.git",
"reference": "54fa81461ba4fbf5b67ed71d22b43ea5cc8c8748"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/hshn/base64-encoded-file/zipball/54fa81461ba4fbf5b67ed71d22b43ea5cc8c8748",
"reference": "54fa81461ba4fbf5b67ed71d22b43ea5cc8c8748",
"shasum": ""
},
"require": {
"php": "^8.1.0",
"symfony/http-foundation": "^5.4 || ^6.0 || ^7.0",
"symfony/mime": "^5.4 || ^6.0 || ^7.0"
},
"require-dev": {
"phpunit/phpunit": "^9.0.0",
"symfony/config": "^5.4 || ^6.0 || ^7.0",
"symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0",
"symfony/form": "^5.4 || ^6.0 || ^7.0",
"symfony/http-kernel": "^5.4 || ^6.0 || ^7.0",
"symfony/serializer": "^5.4 || ^6.0 || ^7.0"
},
"suggest": {
"symfony/config": "to use the bundle in a Symfony project",
"symfony/dependency-injection": "to use the bundle in a Symfony project",
"symfony/form": "to use base64_encoded_file type",
"symfony/http-kernel": "to use the bundle in a Symfony project",
"symfony/serializer": "to convert a base64 string to a Base64EncodedFile object"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "5.x-dev"
}
},
"autoload": {
"psr-4": {
"Hshn\\Base64EncodedFile\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Shota Hoshino",
"email": "sht.hshn@gmail.com"
}
],
"description": "Provides handling base64 encoded files, and the integration of symfony/form",
"support": {
"issues": "https://github.com/hshn/base64-encoded-file/issues",
"source": "https://github.com/hshn/base64-encoded-file/tree/v5.0.1"
},
"time": "2023-12-24T07:23:07+00:00"
},
{
"name": "imagine/imagine",
"version": "1.3.5",

View file

@ -0,0 +1,69 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\ApiPlatform;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Attachments\Attachment;
use App\Services\Attachments\AttachmentSubmitHandler;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* This state processor handles the upload property set on the deserialized attachment entity and
* calls the upload handler service to handle the upload.
*/
final class HandleAttachmentsUploadsProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private readonly ProcessorInterface $removeProcessor,
private readonly AttachmentSubmitHandler $attachmentSubmitHandler
) {
}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
{
if ($operation instanceof DeleteOperationInterface) {
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
}
//Check if the attachment has any upload data we need to handle
//This have to happen before the persist processor is called, because the changes on the entity must be saved!
if ($data instanceof Attachment && $data->getUpload()) {
$upload = $data->getUpload();
//Reset the upload data
$data->setUpload(null);
$this->attachmentSubmitHandler->handleFormSubmit($data, $upload);
}
$result = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
return $result;
}
}

View file

@ -35,6 +35,8 @@ use ApiPlatform\Metadata\Post;
use App\ApiPlatform\DocumentedAPIProperty;
use App\ApiPlatform\Filter\EntityFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\ApiPlatform\HandleAttachmentsUploadsProcessor;
use App\EntityListeners\AttachmentUploadListener;
use App\Repository\AttachmentRepository;
use App\EntityListeners\AttachmentDeleteListener;
use Doctrine\DBAL\Types\Types;
@ -68,12 +70,13 @@ use LogicException;
operations: [
new Get(security: 'is_granted("read", object)'),
new GetCollection(security: 'is_granted("@attachments.list_attachments")'),
new Post(securityPostDenormalize: 'is_granted("create", object)'),
new Post(securityPostDenormalize: 'is_granted("create", object)', ),
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
],
normalizationContext: ['groups' => ['attachment:read', 'attachment:read:standalone', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['attachment:write', 'attachment:write:standalone', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
processor: HandleAttachmentsUploadsProcessor::class,
)]
#[DocumentedAPIProperty(schemaName: 'Attachment-Read', property: 'media_url', type: 'string', nullable: true,
description: 'The URL to the file, where the attachment file can be downloaded. This can be an internal or external URL.',
@ -132,6 +135,14 @@ abstract class Attachment extends AbstractNamedDBElement
*/
protected const ALLOWED_ELEMENT_CLASS = AttachmentContainingDBElement::class;
/**
* @var AttachmentUpload|null The options used for uploading a file to this attachment or modify it.
* This value is not persisted in the database, but is just used to pass options to the upload manager.
* If it is null, no upload process is started.
*/
#[Groups(['attachment:write'])]
protected ?AttachmentUpload $upload = null;
/**
* @var string|null the original filename the file had, when the user uploaded it
*/
@ -192,6 +203,31 @@ abstract class Attachment extends AbstractNamedDBElement
}
}
/**
* Gets the upload currently associated with this attachment.
* This is only temporary and not persisted directly in the database.
* @internal This function should only be used by the Attachment Submit handler service
* @return AttachmentUpload|null
*/
public function getUpload(): ?AttachmentUpload
{
return $this->upload;
}
/**
* Sets the current upload for this attachment.
* It will be processed as the attachment is persisted/flushed.
* @param AttachmentUpload|null $upload
* @return $this
*/
public function setUpload(?AttachmentUpload $upload): Attachment
{
$this->upload = $upload;
return $this;
}
/***********************************************************
* Various function
***********************************************************/

View file

@ -0,0 +1,57 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Entity\Attachments;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* This is a DTO representing a file upload for an attachment and which is used to pass data to the Attachment
* submit handler service.
*/
class AttachmentUpload
{
public function __construct(
/** @var UploadedFile|null The file which was uploaded, or null if the file should not be changed */
public readonly ?UploadedFile $file,
/** @var string|null The base64 encoded data of the file which should be uploaded. */
#[Groups(['attachment:write'])]
public readonly ?string $data = null,
/** @vaar string|null The original filename of the file passed in data. */
#[Groups(['attachment:write'])]
public readonly ?string $filename = null,
/** @var bool True, if the URL in the attachment should be downloaded by Part-DB */
#[Groups(['attachment:write'])]
public readonly bool $downloadUrl = false,
/** @var bool If true the file will be moved to private attachment storage,
* if false it will be moved to public attachment storage. On null file is not moved
*/
#[Groups(['attachment:write'])]
public readonly ?bool $private = null,
/** @var bool If true and no preview image was set yet, the new uploaded file will become the preview image */
#[Groups(['attachment:write'])]
public readonly ?bool $becomePreviewIfEmpty = true,
) {
}
}

View file

@ -26,6 +26,7 @@ use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentContainingDBElement;
use App\Entity\Attachments\AttachmentType;
use App\Entity\Attachments\AttachmentTypeAttachment;
use App\Entity\Attachments\AttachmentUpload;
use App\Entity\Attachments\CategoryAttachment;
use App\Entity\Attachments\CurrencyAttachment;
use App\Entity\Attachments\LabelAttachment;
@ -39,6 +40,8 @@ use App\Entity\Attachments\StorageLocationAttachment;
use App\Entity\Attachments\SupplierAttachment;
use App\Entity\Attachments\UserAttachment;
use App\Exceptions\AttachmentDownloadException;
use Hshn\Base64EncodedFile\HttpFoundation\File\Base64EncodedFile;
use Hshn\Base64EncodedFile\HttpFoundation\File\UploadedBase64EncodedFile;
use const DIRECTORY_SEPARATOR;
use function get_class;
use InvalidArgumentException;
@ -179,27 +182,39 @@ class AttachmentSubmitHandler
* This function will move the uploaded file or download the URL file to server, if needed.
*
* @param Attachment $attachment the attachment that should be used for handling
* @param UploadedFile|null $file If given, that file will be moved to the right location
* @param array $options The options to use with the upload. Here you can specify that a URL should be downloaded,
* or an file should be moved to a secure location.
* @param AttachmentUpload|null $upload The upload options DTO. If it is null, it will be tried to get from the attachment option
*
* @return Attachment The attachment with the new filename (same instance as passed $attachment)
*/
public function handleFormSubmit(Attachment $attachment, ?UploadedFile $file, array $options = []): Attachment
public function handleFormSubmit(Attachment $attachment, ?AttachmentUpload $upload): Attachment
{
$resolver = new OptionsResolver();
$this->configureOptions($resolver);
$options = $resolver->resolve($options);
if ($upload === null) {
$upload = $attachment->getUpload();
if ($upload === null) {
throw new InvalidArgumentException('No upload options given and no upload options set in attachment!');
}
}
$file = $upload->file;
//If no file was uploaded, but we have base64 encoded data, create a file from it
if (!$file && $upload->data !== null) {
$file = new UploadedBase64EncodedFile(new Base64EncodedFile($upload->data), $upload->filename ?? 'base64');
}
//By default we assume a public upload
$secure_attachment = $upload->private ?? false;
//When a file is given then upload it, otherwise check if we need to download the URL
if ($file instanceof UploadedFile) {
$this->upload($attachment, $file, $options);
} elseif ($options['download_url'] && $attachment->isExternal()) {
$this->downloadURL($attachment, $options);
$this->upload($attachment, $file, $secure_attachment);
} elseif ($upload->downloadUrl && $attachment->isExternal()) {
$this->downloadURL($attachment, $secure_attachment);
}
//Move the attachment files to secure location (and back) if needed
$this->moveFile($attachment, $options['secure_attachment']);
$this->moveFile($attachment, $secure_attachment);
//Rename blacklisted (unsecure) files to a better extension
$this->renameBlacklistedExtensions($attachment);
@ -208,7 +223,7 @@ class AttachmentSubmitHandler
$element = $attachment->getElement();
if ($element instanceof AttachmentContainingDBElement) {
//Make this attachment the master picture if needed and this was requested
if ($options['become_preview_if_empty']
if ($upload->becomePreviewIfEmpty
&& $element->getMasterPictureAttachment() === null //Element must not have an preview image yet
&& null === $attachment->getID() //Attachment must be null
&& $attachment->isPicture() //Attachment must be a picture
@ -261,17 +276,6 @@ class AttachmentSubmitHandler
return $attachment;
}
protected function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
//If no preview image was set yet, the new uploaded file will become the preview image
'become_preview_if_empty' => true,
//When a URL is given download the URL
'download_url' => false,
'secure_attachment' => false,
]);
}
/**
* Move the given attachment to secure location (or back to public folder) if needed.
*
@ -325,11 +329,11 @@ class AttachmentSubmitHandler
/**
* Download the URL set in the attachment and save it on the server.
*
* @param array $options The options from the handleFormSubmit function
* @param bool $secureAttachment True if the file should be moved to the secure attachment storage
*
* @return Attachment The attachment with the new filepath
*/
protected function downloadURL(Attachment $attachment, array $options): Attachment
protected function downloadURL(Attachment $attachment, array $secureAttachment): Attachment
{
//Check if we are allowed to download files
if (!$this->allow_attachments_downloads) {
@ -339,7 +343,7 @@ class AttachmentSubmitHandler
$url = $attachment->getURL();
$fs = new Filesystem();
$attachment_folder = $this->generateAttachmentPath($attachment, $options['secure_attachment']);
$attachment_folder = $this->generateAttachmentPath($attachment, $secureAttachment);
$tmp_path = $attachment_folder.DIRECTORY_SEPARATOR.$this->generateAttachmentFilename($attachment, 'tmp');
try {
@ -408,15 +412,15 @@ class AttachmentSubmitHandler
*
* @param Attachment $attachment The attachment in which the file should be saved
* @param UploadedFile $file The file which was uploaded
* @param array $options The options from the handleFormSubmit function
* @param bool $secureAttachment True if the file should be moved to the secure attachment storage
*
* @return Attachment The attachment with the new filepath
*/
protected function upload(Attachment $attachment, UploadedFile $file, array $options): Attachment
protected function upload(Attachment $attachment, UploadedFile $file, bool $secureAttachment): Attachment
{
//Move our temporay attachment to its final location
//Move our temporary attachment to its final location
$file_path = $file->move(
$this->generateAttachmentPath($attachment, $options['secure_attachment']),
$this->generateAttachmentPath($attachment, $secureAttachment),
$this->generateAttachmentFilename($attachment, $file->getClientOriginalExtension())
)->getRealPath();