diff --git a/.env.test b/.env.test index 34932110..e5bb728c 100644 --- a/.env.test +++ b/.env.test @@ -1,4 +1,5 @@ LDAP_ENABLED=true +APP_UPLOADS_DIRECTORY=/tmp/bileto/uploads KERNEL_CLASS='App\Kernel' APP_SECRET='$ecretf0rt3st' diff --git a/.gitignore b/.gitignore index d5e75e60..53f81c42 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ /public/bundles/ /public/dev_assets/* /public/manifest.dev.json +/uploads/ /var/ /vendor/ /node_modules/ diff --git a/assets/javascripts/controllers/tinymce_controller.js b/assets/javascripts/controllers/tinymce_controller.js index 603d1441..7b08c639 100644 --- a/assets/javascripts/controllers/tinymce_controller.js +++ b/assets/javascripts/controllers/tinymce_controller.js @@ -5,6 +5,13 @@ import { Controller } from '@hotwired/stimulus'; export default class extends Controller { + static get values () { + return { + uploadUrl: String, + uploadCsrf: String, + }; + } + connect () { const colorScheme = this.colorScheme; @@ -34,6 +41,9 @@ export default class extends Controller { link_assume_external_targets: true, link_target_list: false, auto_focus: autofocus, + images_upload_handler: this.imagesUploader.bind(this), + relative_urls: false, + remove_script_host: false, }; window.tinymce.init(configuration); @@ -55,4 +65,72 @@ export default class extends Controller { } return colorScheme; } + + imagesUploader (blobInfo, progress) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.withCredentials = false; + xhr.open('POST', this.uploadUrlValue); + + xhr.upload.onprogress = (e) => { + progress(e.loaded / e.total * 100); + }; + + xhr.onload = () => { + if (xhr.status === 401) { + // eslint-disable-next-line prefer-promise-reject-errors + reject({ message: 'You are not authorized to upload files.', remove: true }); + return; + } + + let json; + try { + json = JSON.parse(xhr.responseText); + } catch (e) { + console.error('Bad JSON from server: ' + xhr.responseText); + + // eslint-disable-next-line prefer-promise-reject-errors + reject({ message: 'Bad response from the server.', remove: true }); + return; + } + + if ( + json == null || + (typeof json !== 'object') || + (json.error == null && json.urlShow == null) + ) { + console.error('Bad JSON from server: ' + xhr.responseText); + + // eslint-disable-next-line prefer-promise-reject-errors + reject({ message: 'Bad response from the server.', remove: true }); + return; + } + + if (json.error) { + if (json.description) { + console.error('Unexpected error from server: ' + json.description); + } + + // eslint-disable-next-line prefer-promise-reject-errors + reject({ message: json.error, remove: true }); + return; + } + + resolve(json.urlShow); + }; + + xhr.onerror = () => { + console.error('Unexpected error from server: error code ' + xhr.status); + + // eslint-disable-next-line prefer-promise-reject-errors + reject({ message: 'Bad response from the server.', remove: true }); + }; + + const formData = new FormData(); + formData.append('document', blobInfo.blob(), blobInfo.filename()); + formData.append('_csrf_token', this.uploadCsrfValue); + + xhr.send(formData); + }); + } } diff --git a/assets/stylesheets/components/images.css b/assets/stylesheets/components/images.css index 0bac0f9f..8e45de61 100644 --- a/assets/stylesheets/components/images.css +++ b/assets/stylesheets/components/images.css @@ -4,4 +4,5 @@ img { max-width: 100%; + height: auto; } diff --git a/config/services.yaml b/config/services.yaml index a81f4b4f..a6f02fcf 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -8,6 +8,8 @@ # Put parameters here that don't need to change on each machine where the app is deployed # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration parameters: + app.default_uploads_directory: '%kernel.project_dir%/uploads' + app.uploads_directory: '%env(default:app.default_uploads_directory:APP_UPLOADS_DIRECTORY)%' services: # default configuration for services in *this* file diff --git a/docs/developers/README.md b/docs/developers/README.md index 0f0537aa..d582fcb3 100644 --- a/docs/developers/README.md +++ b/docs/developers/README.md @@ -26,6 +26,7 @@ Dedicated to the maintainers: Architecture: +- [Document upload](/docs/developers/document-upload.md) - [Email collector](/docs/developers/email-collector.md) - [GreenMail](/docs/developers/greenmail.md) - [Roles & permissions](/docs/developers/roles.md) diff --git a/docs/developers/document-upload.md b/docs/developers/document-upload.md new file mode 100644 index 00000000..82649865 --- /dev/null +++ b/docs/developers/document-upload.md @@ -0,0 +1,44 @@ +# Document upload + +Bileto allows users to upload documents. +At the moment, documents can only be uploaded with messages. +The decision was made to name all the objects (i.e. controller, service and entity) to reflect this fact. + +## The `MessageDocuments` controller + +The controller dedicated to the upload of documents is [`MessageDocumentsController`](/src/Controller/MessageDocumentsController.php). + +It provides two endpoints: + +- `create` allows to upload the document to the server; +- `show` allows to download the document from the server. + +The user can upload a document if he's allowed to create messages in any organization. + +The document can be downloaded if: + +- the user has uploaded the file himself; +- or if he has access to the message related to the document. + +## The `MessageDocumentStorage` service + +The [`MessageDocumentStorage` service](/src/Service/MessageDocumentStorage.php) establishes the relationship between the file system (where the files are stored) and the database (to associate the file with a message). + +It allows to: + +- store a file in its correct location and its associated [`MessageDocument`](/src/Entity/MessageDocument.php) to be returned; +- get the size and read the file related to a given `MessageDocument`. + +## Documents on the filesystem + +Finally, the documents are stored somewhere on the file system. +The location is determined by the `APP_UPLOADS_DIRECTORY` environment variable (see [`env.sample`](/env.sample)). + +Then, the sha256 hash of the file is used to get the file name (the extension is the one corresponding to the mime type of the file). +The file is placed in two levels of subdirectories. +The subdirectories are named after the first four characters of the file hash. + +For example, considering the JPEG file hash `bd62115afd16249cff5bd9418b3c4fab3a9a254ebcf0e695cb3c14b92d7827f1`, the corresponding file will be saved as `$APP_UPLOADS_DIRECTORY/bd/62/bd62115afd16249cff5bd9418b3c4fab3a9a254ebcf0e695cb3c14b92d7827f1.jpg`. + +Using the hash as the file name avoids duplicating the same file more than once. +Storing the files in subdirectories allows a large number of files to be stored while maintaining good performance. diff --git a/env.sample b/env.sample index 51ded0e8..cc16c585 100644 --- a/env.sample +++ b/env.sample @@ -12,6 +12,10 @@ APP_ENV=prod # Generate a token with the command: php bin/console app:secret APP_SECRET=change-me +# The path to the upload directory. +# Default is uploads/ in the root directory. +# APP_UPLOADS_DIRECTORY=/path/to/uploads + ################################# # Configuration of the database # ################################# diff --git a/migrations/Version20230817141440CreateMessageDocument.php b/migrations/Version20230817141440CreateMessageDocument.php new file mode 100644 index 00000000..3f5fd3b0 --- /dev/null +++ b/migrations/Version20230817141440CreateMessageDocument.php @@ -0,0 +1,129 @@ +connection->getDatabasePlatform(); + if ($dbPlatform instanceof PostgreSQLPlatform) { + $this->addSql('CREATE SEQUENCE message_document_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql(<<addSql('CREATE UNIQUE INDEX UNIQ_D14F4E67539B0606 ON message_document (uid)'); + $this->addSql('CREATE INDEX IDX_D14F4E67B03A8386 ON message_document (created_by_id)'); + $this->addSql('CREATE INDEX IDX_D14F4E67896DBBDE ON message_document (updated_by_id)'); + $this->addSql('CREATE INDEX IDX_D14F4E67537A1329 ON message_document (message_id)'); + $this->addSql('COMMENT ON COLUMN message_document.created_at IS \'(DC2Type:datetimetz_immutable)\''); + $this->addSql('COMMENT ON COLUMN message_document.updated_at IS \'(DC2Type:datetimetz_immutable)\''); + $this->addSql(<<addSql(<<addSql(<<addSql(<<addSql(<<addSql(<<addSql(<<connection->getDatabasePlatform(); + if ($dbPlatform instanceof PostgreSQLPlatform) { + $this->addSql('DROP SEQUENCE message_document_id_seq CASCADE'); + $this->addSql('ALTER TABLE message_document DROP CONSTRAINT FK_D14F4E67B03A8386'); + $this->addSql('ALTER TABLE message_document DROP CONSTRAINT FK_D14F4E67896DBBDE'); + $this->addSql('ALTER TABLE message_document DROP CONSTRAINT FK_D14F4E67537A1329'); + $this->addSql('DROP TABLE message_document'); + } elseif ($dbPlatform instanceof MariaDBPlatform) { + $this->addSql('ALTER TABLE message_document DROP FOREIGN KEY FK_D14F4E67B03A8386'); + $this->addSql('ALTER TABLE message_document DROP FOREIGN KEY FK_D14F4E67896DBBDE'); + $this->addSql('ALTER TABLE message_document DROP FOREIGN KEY FK_D14F4E67537A1329'); + $this->addSql('DROP TABLE message_document'); + } + } +} diff --git a/src/Controller/MessageDocumentsController.php b/src/Controller/MessageDocumentsController.php new file mode 100644 index 00000000..67170c97 --- /dev/null +++ b/src/Controller/MessageDocumentsController.php @@ -0,0 +1,151 @@ +denyAccessUnlessGranted('orga:create:tickets:messages', 'any'); + + $file = $request->files->get('document'); + + /** @var string */ + $csrfToken = $request->request->get('_csrf_token', ''); + + if (!$this->isCsrfTokenValid('create message document', $csrfToken)) { + return new JsonResponse([ + 'error' => $translator->trans('csrf.invalid', [], 'errors'), + ], 400); + } + + if (!($file instanceof UploadedFile)) { + return new JsonResponse([ + 'error' => $translator->trans('message_document.required', [], 'errors'), + ], 400); + } + + try { + $messageDocument = $messageDocumentStorage->store($file, $file->getClientOriginalName()); + } catch (MessageDocumentStorageError $e) { + if ($e->getCode() === MessageDocumentStorageError::REJECTED_MIMETYPE) { + return new JsonResponse([ + 'error' => $translator->trans('message_document.mimetype.rejected', [], 'errors'), + 'description' => $e->getMessage(), + ], 400); + } else { + return new JsonResponse([ + 'error' => $translator->trans('message_document.server_error', [], 'errors'), + 'description' => $e->getMessage(), + ], 500); + } + } + + $messageDocumentRepository->save($messageDocument, true); + + $urlShow = $this->generateUrl( + 'message document', + [ + 'uid' => $messageDocument->getUid(), + 'extension' => $messageDocument->getExtension(), + ], + UrlGeneratorInterface::ABSOLUTE_URL, + ); + + return new JsonResponse([ + 'uid' => $messageDocument->getUid(), + 'name' => $messageDocument->getName(), + 'urlShow' => $urlShow, + ]); + } + + #[Route('/messages/documents/{uid}.{extension}', name: 'message document', methods: ['GET', 'HEAD'])] + public function show( + MessageDocument $messageDocument, + string $extension, + MessageDocumentStorage $messageDocumentStorage, + ): Response { + /** @var \App\Entity\User $user */ + $user = $this->getUser(); + + $message = $messageDocument->getMessage(); + + if (!$messageDocument->isCreatedBy($user)) { + if ($message === null) { + // The message of the document is not posted yet, only its author + // can see it. + throw $this->createAccessDeniedException(); + } else { + // The message of the document is posted, check that the user has + // the permissions to see the message. + $ticket = $message->getTicket(); + $organization = $ticket->getOrganization(); + + if (!$ticket->hasActor($user)) { + $this->denyAccessUnlessGranted('orga:see:tickets:all', $organization); + } + + if ($message->isConfidential()) { + $this->denyAccessUnlessGranted('orga:see:tickets:messages:confidential', $organization); + } + } + } + + // The extension parameter is only decorative, but at least check that + // it corresponds to the real extension! + if ($extension !== $messageDocument->getExtension()) { + throw $this->createNotFoundException('The file does not exist.'); + } + + try { + $content = $messageDocumentStorage->read($messageDocument); + $contentLength = $messageDocumentStorage->size($messageDocument); + } catch (MessageDocumentStorageError $e) { + throw $this->createNotFoundException('The file does not exist.'); + } + + $name = rawurlencode($messageDocument->getName()); + $mimetype = $messageDocument->getMimetype(); + if (str_starts_with($mimetype, 'image/') && $mimetype !== 'image/svg+xml') { + $contentDisposition = "inline; filename=\"{$name}\""; + } else { + $contentDisposition = "attachment; filename=\"{$name}\""; + } + + return new Response( + $content, + Response::HTTP_OK, + [ + 'Content-Disposition' => $contentDisposition, + 'Content-Length' => $contentLength, + 'Content-Type' => $mimetype, + ] + ); + } +} diff --git a/src/Entity/Message.php b/src/Entity/Message.php index 5e96a5ac..d6f84b92 100644 --- a/src/Entity/Message.php +++ b/src/Entity/Message.php @@ -8,6 +8,8 @@ use App\EntityListener\EntitySetMetaListener; use App\Repository\MessageRepository; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Translation\TranslatableMessage; @@ -63,6 +65,15 @@ class Message implements MetaEntityInterface, ActivityRecordableInterface #[ORM\Column(length: 1000, nullable: true)] private ?string $emailId = null; + /** @var Collection */ + #[ORM\OneToMany(mappedBy: 'message', targetEntity: MessageDocument::class)] + private Collection $messageDocuments; + + public function __construct() + { + $this->messageDocuments = new ArrayCollection(); + } + public function getId(): ?int { return $this->id; @@ -132,4 +143,12 @@ public function setEmailId(?string $emailId): static return $this; } + + /** + * @return Collection + */ + public function getMessageDocuments(): Collection + { + return $this->messageDocuments; + } } diff --git a/src/Entity/MessageDocument.php b/src/Entity/MessageDocument.php new file mode 100644 index 00000000..033000ee --- /dev/null +++ b/src/Entity/MessageDocument.php @@ -0,0 +1,214 @@ + subtypes arrays) that we + * accept in Bileto. The wildcard (*) subtype means that any mimetype with + * the related type is accepted. + */ + public const ACCEPTED_MIMETYPES = [ + 'application' => [ + 'gzip', + 'msword', + 'pdf', + 'vnd.ms-excel', + 'vnd.oasis.opendocument.spreadsheet', + 'vnd.oasis.opendocument.text', + 'vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'vnd.openxmlformats-officedocument.wordprocessingml.document', + 'x-7z-compressed', + 'x-bzip', + 'x-bzip2', + 'x-rar-compressed', + 'x-tar', + 'x-xz', + 'zip', + ], + 'image' => [ + 'bmp', + 'gif', + 'jpeg', + 'png', + 'webp', + ], + 'text' => ['*'], + ]; + + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + private ?int $id = null; + + #[ORM\Column(length: 20, unique: true)] + private ?string $uid = null; + + #[ORM\Column(type: Types::DATETIMETZ_IMMUTABLE)] + private ?\DateTimeImmutable $createdAt = null; + + #[ORM\ManyToOne] + #[ORM\JoinColumn(nullable: false)] + private ?User $createdBy = null; + + #[ORM\Column(type: Types::DATETIMETZ_IMMUTABLE)] + private ?\DateTimeImmutable $updatedAt = null; + + #[ORM\ManyToOne] + private ?User $updatedBy = null; + + #[ORM\Column(length: 255)] + private ?string $name = null; + + #[ORM\Column(length: 255)] + private ?string $filename = null; + + #[ORM\Column(length: 100)] + private ?string $mimetype = null; + + #[ORM\Column(length: 255)] + private ?string $hash = null; + + #[ORM\ManyToOne(inversedBy: 'messageDocuments')] + private ?Message $message = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function getFilename(): ?string + { + return $this->filename; + } + + public function setFilename(string $filename): static + { + $this->filename = $filename; + + return $this; + } + + /** + * Return the filepath of the MessageDocument file. + * + * The filepath is calculated from its filename. The first four characters + * of the filename are grouped in 2 groups of 2 characters which represents + * the 2 subdirectories levels. + * + * For instance, for the MessageDocument with the filename + * `bd62115afd16249cff5bd9418b3c4fab3a9a254ebcf0e695cb3c14b92d7827f1.jpg`, + * this method returns `bd/62`. + */ + public function getFilepath(): string + { + $folder1 = substr($this->filename, 0, 2); + $folder2 = substr($this->filename, 2, 2); + return "{$folder1}/{$folder2}"; + } + + public function getPathname(): string + { + return "{$this->getFilepath()}/{$this->filename}"; + } + + public function getExtension(): string + { + return pathinfo($this->filename, PATHINFO_EXTENSION); + } + + public function getMimetype(): ?string + { + return $this->mimetype; + } + + public function setMimetype(string $mimetype): static + { + $this->mimetype = $mimetype; + + return $this; + } + + /** + * Return wether the given mimetype is accepted. + * + * @see self::ACCEPTED_MIMETYPES + * + * The mimetype is accepted if it's contained in the ACCEPTED_MIMETYPES + * array constant, or if the related type declares a wildcard (*) subtype. + */ + public static function isMimetypeAccepted(string $mimetype): bool + { + if (!str_contains($mimetype, '/')) { + return false; + } + + list($type, $subtype) = explode('/', $mimetype, 2); + + if (!isset(self::ACCEPTED_MIMETYPES[$type])) { + return false; + } + + $validSubtypes = self::ACCEPTED_MIMETYPES[$type]; + + return ( + in_array('*', $validSubtypes) || + in_array($subtype, $validSubtypes) + ); + } + + public function getHash(): ?string + { + return $this->hash; + } + + public function setHash(string $algo, string $hash): static + { + $this->hash = "{$algo}:{$hash}"; + + return $this; + } + + public function getMessage(): ?Message + { + return $this->message; + } + + public function setMessage(?Message $message): static + { + $this->message = $message; + + return $this; + } +} diff --git a/src/Repository/MessageDocumentRepository.php b/src/Repository/MessageDocumentRepository.php new file mode 100644 index 00000000..b159a33d --- /dev/null +++ b/src/Repository/MessageDocumentRepository.php @@ -0,0 +1,47 @@ + + * + * @method MessageDocument|null find($id, $lockMode = null, $lockVersion = null) + * @method MessageDocument|null findOneBy(array $criteria, array $orderBy = null) + * @method MessageDocument[] findAll() + * @method MessageDocument[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class MessageDocumentRepository extends ServiceEntityRepository implements UidGeneratorInterface +{ + use UidGeneratorTrait; + + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, MessageDocument::class); + } + + public function save(MessageDocument $entity, bool $flush = false): void + { + $this->getEntityManager()->persist($entity); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } + + public function remove(MessageDocument $entity, bool $flush = false): void + { + $this->getEntityManager()->remove($entity); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } +} diff --git a/src/Service/MessageDocumentStorage.php b/src/Service/MessageDocumentStorage.php new file mode 100644 index 00000000..f29e1b22 --- /dev/null +++ b/src/Service/MessageDocumentStorage.php @@ -0,0 +1,113 @@ +getPathname(); + $mimetype = $file->getMimeType(); + + if (!MessageDocument::isMimetypeAccepted($mimetype)) { + throw MessageDocumentStorageError::rejectedMimetype($mimetype); + } + + $hash = @hash_file(self::HASH_ALGO, $pathname); + if ($hash === false) { + throw MessageDocumentStorageError::unreadableFile($pathname); + } + + // Calculate a filename based on the hash of its content so we can + // easily avoid to store the same file twice. + $filename = "{$hash}.{$file->guessExtension()}"; + + $messageDocument = new MessageDocument(); + $messageDocument->setName($name); + $messageDocument->setFilename($filename); + $messageDocument->setMimetype($mimetype); + $messageDocument->setHash(self::HASH_ALGO, $hash); + + $pathname = "{$this->uploadsDirectory}/{$messageDocument->getPathname()}"; + if (!file_exists($pathname)) { + $directory = "{$this->uploadsDirectory}/{$messageDocument->getFilepath()}"; + + try { + $file->move($directory, $messageDocument->getFilename()); + } catch (FileException $e) { + throw MessageDocumentStorageError::immovableFile($pathname, $directory, $e->getMessage()); + } + } + + return $messageDocument; + } + + /** + * Return the size of the file related to the given MessageDocument. + * + * @throws MessageDocumentStorageError + * If the file doesn't exist. + */ + public function size(MessageDocument $messageDocument): int + { + $pathname = "{$this->uploadsDirectory}/{$messageDocument->getPathname()}"; + $filesize = @filesize($pathname); + + if ($filesize === false) { + throw MessageDocumentStorageError::unreadableFile($pathname); + } + + return $filesize; + } + + /** + * Return the content of the file related to the given MessageDocument. + * + * @throws MessageDocumentStorageError + * If the file doesn't exist. + */ + public function read(MessageDocument $messageDocument): string + { + $pathname = "{$this->uploadsDirectory}/{$messageDocument->getPathname()}"; + $content = @file_get_contents($pathname); + + if ($content === false) { + throw MessageDocumentStorageError::unreadableFile($pathname); + } + + return $content; + } +} diff --git a/src/Service/MessageDocumentStorageError.php b/src/Service/MessageDocumentStorageError.php new file mode 100644 index 00000000..9dfa5cf1 --- /dev/null +++ b/src/Service/MessageDocumentStorageError.php @@ -0,0 +1,38 @@ +get('router'); + $user = UserFactory::createOne(); + $client->loginUser($user->object()); + $this->grantOrga($user->object(), ['orga:create:tickets:messages']); + $filepath = sys_get_temp_dir() . '/document.txt'; + $content = 'Hello World!'; + $hash = hash('sha256', $content); + file_put_contents($filepath, $content); + $document = new UploadedFile($filepath, 'My document'); + + $this->assertSame(0, MessageDocumentFactory::count()); + + $client->request('POST', '/messages/documents/new', [ + '_csrf_token' => $this->generateCsrfToken($client, 'create message document'), + ], [ + 'document' => $document, + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame(1, MessageDocumentFactory::count()); + $messageDocument = MessageDocumentFactory::first(); + $this->assertSame('My document', $messageDocument->getName()); + $this->assertSame($hash . '.txt', $messageDocument->getFilename()); + $this->assertSame('text/plain', $messageDocument->getMimetype()); + $this->assertSame('sha256:' . $hash, $messageDocument->getHash()); + $this->assertNull($messageDocument->getMessage()); + + $response = $client->getResponse(); + /** @var string */ + $content = $response->getContent(); + $responseData = json_decode($content, true); + $expectedUrlShow = $router->generate( + 'message document', + [ + 'uid' => $messageDocument->getUid(), + 'extension' => $messageDocument->getExtension(), + ], + UrlGeneratorInterface::ABSOLUTE_URL, + ); + $this->assertSame($messageDocument->getUid(), $responseData['uid']); + $this->assertSame($messageDocument->getName(), $responseData['name']); + $this->assertSame($expectedUrlShow, $responseData['urlShow']); + } + + public function testPostCreateFailsIfCsrfTokenIsInvalid(): void + { + $client = static::createClient(); + $user = UserFactory::createOne(); + $client->loginUser($user->object()); + $this->grantOrga($user->object(), ['orga:create:tickets:messages']); + $filepath = sys_get_temp_dir() . '/document.txt'; + $content = 'Hello World!'; + $hash = hash('sha256', $content); + file_put_contents($filepath, $content); + $document = new UploadedFile($filepath, 'My document'); + + $this->assertSame(0, MessageDocumentFactory::count()); + + $client->request('POST', '/messages/documents/new', [ + '_csrf_token' => 'not the token', + ], [ + 'document' => $document, + ]); + + $this->assertSame(0, MessageDocumentFactory::count()); + $response = $client->getResponse(); + /** @var string */ + $content = $response->getContent(); + $responseData = json_decode($content, true); + $this->assertSame('The security token is invalid, please try again.', $responseData['error']); + } + + public function testPostCreateFailsIfMimetypeIsInvalid(): void + { + $client = static::createClient(); + $user = UserFactory::createOne(); + $client->loginUser($user->object()); + $this->grantOrga($user->object(), ['orga:create:tickets:messages']); + $filepath = sys_get_temp_dir() . '/document.mp3'; + touch($filepath); + $document = new UploadedFile($filepath, 'My audio file'); + + $this->assertSame(0, MessageDocumentFactory::count()); + + $client->request('POST', '/messages/documents/new', [ + '_csrf_token' => $this->generateCsrfToken($client, 'create message document'), + ], [ + 'document' => $document, + ]); + + $this->assertSame(0, MessageDocumentFactory::count()); + $response = $client->getResponse(); + /** @var string */ + $content = $response->getContent(); + $responseData = json_decode($content, true); + $this->assertSame('You cannot upload this type of file, choose another file.', $responseData['error']); + } + + public function testPostCreateFailsIfDocumentIsMissing(): void + { + $client = static::createClient(); + $user = UserFactory::createOne(); + $client->loginUser($user->object()); + $this->grantOrga($user->object(), ['orga:create:tickets:messages']); + + $client->request('POST', '/messages/documents/new', [ + '_csrf_token' => $this->generateCsrfToken($client, 'create message document'), + ]); + + $this->assertSame(0, MessageDocumentFactory::count()); + $response = $client->getResponse(); + /** @var string */ + $content = $response->getContent(); + $responseData = json_decode($content, true); + $this->assertSame('Select a file.', $responseData['error']); + } + + public function testPostCreateFailsIfAccessIsForbidden(): void + { + $this->expectException(AccessDeniedException::class); + + $client = static::createClient(); + $user = UserFactory::createOne(); + $client->loginUser($user->object()); + $filepath = sys_get_temp_dir() . '/document.txt'; + $content = 'Hello World!'; + $hash = hash('sha256', $content); + file_put_contents($filepath, $content); + $document = new UploadedFile($filepath, 'My document'); + + $client->catchExceptions(false); + $client->request('POST', '/messages/documents/new', [ + '_csrf_token' => $this->generateCsrfToken($client, 'create message document'), + ], [ + 'document' => $document, + ]); + } + + public function testGetShowServesTheFile(): void + { + $client = static::createClient(); + /** @var MessageDocumentStorage */ + $messageDocumentStorage = static::getContainer()->get(MessageDocumentStorage::class); + /** @var MessageDocumentRepository */ + $messageDocumentRepository = static::getContainer()->get(MessageDocumentRepository::class); + $user = UserFactory::createOne(); + $client->loginUser($user->object()); + $filepath = sys_get_temp_dir() . '/document.txt'; + $expectedContent = 'Hello World!'; + $hash = hash('sha256', $expectedContent); + file_put_contents($filepath, $expectedContent); + $document = new File($filepath); + $messageDocument = $messageDocumentStorage->store($document, 'My document'); + $messageDocumentRepository->save($messageDocument, true); + + $this->assertNull($messageDocument->getMessage()); + + $client->request('GET', "/messages/documents/{$messageDocument->getUid()}.txt"); + + $response = $client->getResponse(); + $content = $response->getContent(); + $this->assertSame($expectedContent, $content); + $this->assertSame('attachment; filename="My%20document"', $response->headers->get('Content-Disposition')); + $this->assertSame('12', $response->headers->get('Content-Length')); + $this->assertSame('text/plain; charset=UTF-8', $response->headers->get('Content-Type')); + } + + public function testGetShowFailsIfCurrentUserIsNotAuthor(): void + { + $this->expectException(AccessDeniedException::class); + + $client = static::createClient(); + /** @var MessageDocumentStorage */ + $messageDocumentStorage = static::getContainer()->get(MessageDocumentStorage::class); + /** @var MessageDocumentRepository */ + $messageDocumentRepository = static::getContainer()->get(MessageDocumentRepository::class); + $user = UserFactory::createOne(); + $otherUser = UserFactory::createOne(); + $client->loginUser($user->object()); + $filepath = sys_get_temp_dir() . '/document.txt'; + $expectedContent = 'Hello World!'; + $hash = hash('sha256', $expectedContent); + file_put_contents($filepath, $expectedContent); + $document = new File($filepath); + $messageDocument = $messageDocumentStorage->store($document, 'My document'); + $messageDocumentRepository->save($messageDocument, true); + $client->loginUser($otherUser->object()); + + $this->assertNull($messageDocument->getMessage()); + + $client->catchExceptions(false); + $client->request('GET', "/messages/documents/{$messageDocument->getUid()}.txt"); + } + + public function testGetShowFailsIfMessageIsSetAndCurrentUserIsNotActorOfTheAssociatedTicket(): void + { + $this->expectException(AccessDeniedException::class); + + $client = static::createClient(); + /** @var MessageDocumentStorage */ + $messageDocumentStorage = static::getContainer()->get(MessageDocumentStorage::class); + /** @var MessageDocumentRepository */ + $messageDocumentRepository = static::getContainer()->get(MessageDocumentRepository::class); + $user = UserFactory::createOne(); + $otherUser = UserFactory::createOne(); + $client->loginUser($user->object()); + $filepath = sys_get_temp_dir() . '/document.txt'; + $expectedContent = 'Hello World!'; + $hash = hash('sha256', $expectedContent); + file_put_contents($filepath, $expectedContent); + $document = new File($filepath); + $messageDocument = $messageDocumentStorage->store($document, 'My document'); + + $message = MessageFactory::createOne(); + $messageDocument->setMessage($message->object()); + $messageDocumentRepository->save($messageDocument, true); + + $client->loginUser($otherUser->object()); + + $client->catchExceptions(false); + $client->request('GET', "/messages/documents/{$messageDocument->getUid()}.txt"); + } + + public function testGetShowFailsIfMessageIsSetAndCurrentUserCannotReadConfidentialMessage(): void + { + $this->expectException(AccessDeniedException::class); + + $client = static::createClient(); + /** @var MessageDocumentStorage */ + $messageDocumentStorage = static::getContainer()->get(MessageDocumentStorage::class); + /** @var MessageDocumentRepository */ + $messageDocumentRepository = static::getContainer()->get(MessageDocumentRepository::class); + $user = UserFactory::createOne(); + $otherUser = UserFactory::createOne(); + $this->grantOrga($otherUser->object(), ['orga:see:tickets:all']); + $client->loginUser($user->object()); + $filepath = sys_get_temp_dir() . '/document.txt'; + $expectedContent = 'Hello World!'; + $hash = hash('sha256', $expectedContent); + file_put_contents($filepath, $expectedContent); + $document = new File($filepath); + $messageDocument = $messageDocumentStorage->store($document, 'My document'); + + $message = MessageFactory::createOne([ + 'isConfidential' => true, + ]); + $messageDocument->setMessage($message->object()); + $messageDocumentRepository->save($messageDocument, true); + + $client->loginUser($otherUser->object()); + + $client->catchExceptions(false); + $client->request('GET', "/messages/documents/{$messageDocument->getUid()}.txt"); + } + + public function testGetShowFailsIfExtensionDoesNotMatch(): void + { + $this->expectException(NotFoundHttpException::class); + + $client = static::createClient(); + /** @var MessageDocumentStorage */ + $messageDocumentStorage = static::getContainer()->get(MessageDocumentStorage::class); + /** @var MessageDocumentRepository */ + $messageDocumentRepository = static::getContainer()->get(MessageDocumentRepository::class); + $user = UserFactory::createOne(); + $client->loginUser($user->object()); + $filepath = sys_get_temp_dir() . '/document.txt'; + $expectedContent = 'Hello World!'; + $hash = hash('sha256', $expectedContent); + file_put_contents($filepath, $expectedContent); + $document = new File($filepath); + $messageDocument = $messageDocumentStorage->store($document, 'My document'); + $messageDocumentRepository->save($messageDocument, true); + + $client->catchExceptions(false); + $client->request('GET', "/messages/documents/{$messageDocument->getUid()}.pdf"); + } +} diff --git a/tests/Entity/MessageDocumentTest.php b/tests/Entity/MessageDocumentTest.php new file mode 100644 index 00000000..02c177d0 --- /dev/null +++ b/tests/Entity/MessageDocumentTest.php @@ -0,0 +1,85 @@ +assertTrue($result); + } + + public function testIsMimetypeAcceptedAcceptsImages(): void + { + $mimetype = 'image/png'; + + $result = MessageDocument::isMimetypeAccepted($mimetype); + + $this->assertTrue($result); + } + + public function testIsMimetypeAcceptedAcceptsText(): void + { + $mimetype = 'text/plain'; + + $result = MessageDocument::isMimetypeAccepted($mimetype); + + $this->assertTrue($result); + } + + public function testIsMimetypeAcceptedRefusesExe(): void + { + $mimetype = 'application/vnd.microsoft.portable-executable'; + + $result = MessageDocument::isMimetypeAccepted($mimetype); + + $this->assertFalse($result); + } + + public function testIsMimetypeAcceptedRefusesOctetStream(): void + { + $mimetype = 'application/octet-stream'; + + $result = MessageDocument::isMimetypeAccepted($mimetype); + + $this->assertFalse($result); + } + + public function testIsMimetypeAcceptedRefusesQuicktimeVideo(): void + { + $mimetype = 'video/quicktime'; + + $result = MessageDocument::isMimetypeAccepted($mimetype); + + $this->assertFalse($result); + } + + public function testIsMimetypeAcceptedRefusesMimeWithoutSubtype(): void + { + $mimetype = 'text'; + + $result = MessageDocument::isMimetypeAccepted($mimetype); + + $this->assertFalse($result); + } + + public function testIsMimetypeAcceptedRefusesEmptyString(): void + { + $mimetype = ''; + + $result = MessageDocument::isMimetypeAccepted($mimetype); + + $this->assertFalse($result); + } +} diff --git a/tests/Factory/MessageDocumentFactory.php b/tests/Factory/MessageDocumentFactory.php new file mode 100644 index 00000000..aa1c0566 --- /dev/null +++ b/tests/Factory/MessageDocumentFactory.php @@ -0,0 +1,77 @@ + + * + * @method static MessageDocument|Proxy createOne(array $attributes = []) + * @method static MessageDocument[]|Proxy[] createMany(int $number, array|callable $attributes = []) + * @method static MessageDocument[]|Proxy[] createSequence(array|callable $sequence) + * @method static MessageDocument|Proxy find(object|array|mixed $criteria) + * @method static MessageDocument|Proxy findOrCreate(array $attributes) + * @method static MessageDocument|Proxy first(string $sortedField = 'id') + * @method static MessageDocument|Proxy last(string $sortedField = 'id') + * @method static MessageDocument|Proxy random(array $attributes = []) + * @method static MessageDocument|Proxy randomOrCreate(array $attributes = []) + * @method static MessageDocument[]|Proxy[] all() + * @method static MessageDocument[]|Proxy[] findBy(array $attributes) + * @method static MessageDocument[]|Proxy[] randomSet(int $number, array $attributes = []) + * @method static MessageDocument[]|Proxy[] randomRange(int $min, int $max, array $attributes = []) + * @method static MessageDocumentRepository|RepositoryProxy repository() + * @method MessageDocument|Proxy create(array|callable $attributes = []) + * + * @phpstan-method static MessageDocument&Proxy createOne(array $attributes = []) + * @phpstan-method static MessageDocument[]&Proxy[] createMany(int $number, array|callable $attributes = []) + * @phpstan-method static MessageDocument[]&Proxy[] createSequence(array|callable $sequence) + * @phpstan-method static MessageDocument&Proxy find(object|array|mixed $criteria) + * @phpstan-method static MessageDocument&Proxy findOrCreate(array $attributes) + * @phpstan-method static MessageDocument&Proxy first(string $sortedField = 'id') + * @phpstan-method static MessageDocument&Proxy last(string $sortedField = 'id') + * @phpstan-method static MessageDocument&Proxy random(array $attributes = []) + * @phpstan-method static MessageDocument&Proxy randomOrCreate(array $attributes = []) + * @phpstan-method static MessageDocument[]&Proxy[] all() + * @phpstan-method static MessageDocument[]&Proxy[] findBy(array $attributes) + * @phpstan-method static MessageDocument[]&Proxy[] randomSet(int $number, array $attributes = []) + * @phpstan-method static MessageDocument[]&Proxy[] randomRange(int $min, int $max, array $attributes = []) + * @phpstan-method MessageDocument&Proxy create(array|callable $attributes = []) + */ +final class MessageDocumentFactory extends ModelFactory +{ + /** + * @return mixed[] + */ + protected function getDefaults(): array + { + $hash = self::faker()->sha256(); + $mimetype = self::faker()->randomElement(MessageDocument::ACCEPTED_MIMETYPES); + $mimesubtype = self::faker()->randomElement(MessageDocument::ACCEPTED_MIMETYPES[$mimetype]); + $mimetype = "{$mimetype}/{$mimesubtype}"; + $extension = MimeTypes::getDefault()->getExtensions($mimetype)[0]; + return [ + 'uid' => Random::hex(20), + 'name' => self::faker()->words(3, true), + 'filename' => $hash . '.' . $extension, + 'mimetype' => $mimetype, + 'hash' => 'sha256:' . $hash, + ]; + } + + protected static function getClass(): string + { + return MessageDocument::class; + } +} diff --git a/translations/errors+intl-icu.en_GB.yaml b/translations/errors+intl-icu.en_GB.yaml index 08c0b684..73b85d48 100644 --- a/translations/errors+intl-icu.en_GB.yaml +++ b/translations/errors+intl-icu.en_GB.yaml @@ -13,6 +13,9 @@ mailbox.port.invalid: 'Enter a port between {min} and {max}.' mailbox.username.required: 'Enter a username.' message.cannot_confidential: 'You are not authorized to answer confidentially.' message.content.required: 'Enter a message.' +message_document.mimetype.rejected: 'You cannot upload this type of file, choose another file.' +message_document.required: 'Select a file.' +message_document.server_error: 'The file cannot be uploaded, try again later.' organization.name.max_chars: 'Enter a name of less than {limit} characters.' organization.name.required: 'Enter a name.' organization.sub.invalid: 'Select an organization from the list.' diff --git a/translations/errors+intl-icu.fr_FR.yaml b/translations/errors+intl-icu.fr_FR.yaml index 5cae72b6..355bcb3b 100644 --- a/translations/errors+intl-icu.fr_FR.yaml +++ b/translations/errors+intl-icu.fr_FR.yaml @@ -13,6 +13,9 @@ mailbox.port.invalid: 'Saisissez un port entre {min} et {max}.' mailbox.username.required: 'Saisissez un nom d’utilisateur.' message.cannot_confidential: 'Vous n’êtes pas autorisé à répondre confidentiellement.' message.content.required: 'Saisissez un message.' +message_document.mimetype.rejected: 'Vous ne pouvez pas téléverser ce type de fichier, choisissez un fichier différent.' +message_document.required: 'Sélectionnez un fichier.' +message_document.server_error: 'Le fichier ne peut pas être téléversé, essayez à nouveau plus tard.' organization.name.max_chars: 'Saisissez un nom de moins de {limit} caractères.' organization.name.required: 'Saisissez un nom.' organization.sub.invalid: 'Sélectionnez une organisation de la liste.'