diff --git a/.changes/unreleased/Feature-20240614-153236.yaml b/.changes/unreleased/Feature-20240614-153236.yaml new file mode 100644 index 000000000..a29000cc1 --- /dev/null +++ b/.changes/unreleased/Feature-20240614-153236.yaml @@ -0,0 +1,8 @@ +kind: Feature +body: |- + Electronic signature + + Implementation of the electronic signature for documents within chill. +time: 2024-06-14T15:32:36.875891692+02:00 +custom: + Issue: "" diff --git a/.changes/unreleased/Feature-20240614-153537.yaml b/.changes/unreleased/Feature-20240614-153537.yaml new file mode 100644 index 000000000..c16d49b2d --- /dev/null +++ b/.changes/unreleased/Feature-20240614-153537.yaml @@ -0,0 +1,7 @@ +kind: Feature +body: The behavoir of the voters for stored objects is adjusted so as to limit edit + and delete possibilities to users related to the activity, social action or workflow + entity. +time: 2024-06-14T15:35:37.582159301+02:00 +custom: + Issue: "286" diff --git a/.changes/unreleased/Feature-20240718-151233.yaml b/.changes/unreleased/Feature-20240718-151233.yaml new file mode 100644 index 000000000..6e2666be8 --- /dev/null +++ b/.changes/unreleased/Feature-20240718-151233.yaml @@ -0,0 +1,5 @@ +kind: Feature +body: Metadata form added for person signatures +time: 2024-07-18T15:12:33.8134266+02:00 +custom: + Issue: "288" diff --git a/.changes/unreleased/Feature-20241112-212340.yaml b/.changes/unreleased/Feature-20241112-212340.yaml new file mode 100644 index 000000000..cd4da511c --- /dev/null +++ b/.changes/unreleased/Feature-20241112-212340.yaml @@ -0,0 +1,5 @@ +kind: Feature +body: Add a signature step in workflow, which allow to apply an electronic signature on documents +time: 2024-11-12T21:23:40.271035497+01:00 +custom: + Issue: "" diff --git a/.changes/unreleased/Feature-20241112-212417.yaml b/.changes/unreleased/Feature-20241112-212417.yaml new file mode 100644 index 000000000..de0ca49c8 --- /dev/null +++ b/.changes/unreleased/Feature-20241112-212417.yaml @@ -0,0 +1,5 @@ +kind: Feature +body: Keep an history of each version of a stored object. +time: 2024-11-12T21:24:17.491708762+01:00 +custom: + Issue: "" diff --git a/.changes/unreleased/Feature-20241112-212503.yaml b/.changes/unreleased/Feature-20241112-212503.yaml new file mode 100644 index 000000000..935d452bb --- /dev/null +++ b/.changes/unreleased/Feature-20241112-212503.yaml @@ -0,0 +1,5 @@ +kind: Feature +body: Add a "send external" step in workflow, which allow to send stored objects and other elements to remote people, by sending them a public url +time: 2024-11-12T21:25:03.599543577+01:00 +custom: + Issue: "" diff --git a/.env b/.env index 1714966d4..b2eecb78f 100644 --- a/.env +++ b/.env @@ -23,7 +23,7 @@ TRUSTED_HOSTS='^(localhost|example\.com|nginx)$' ###< symfony/framework-bundle ### ## Wopi server for editing documents online -WOPI_SERVER=http://collabora:9980 +EDITOR_SERVER=http://collabora:9980 # must be manually set in .env.local # ADMIN_PASSWORD= diff --git a/.env.test b/.env.test index f84920e54..c78a1bc63 100644 --- a/.env.test +++ b/.env.test @@ -41,3 +41,5 @@ DATABASE_URL="postgresql://postgres:postgres@db:5432/test?serverVersion=14&chars ASYNC_UPLOAD_TEMP_URL_KEY= ASYNC_UPLOAD_TEMP_URL_BASE_PATH= ASYNC_UPLOAD_TEMP_URL_CONTAINER= + +EDITOR_SERVER=https://localhost:9980 diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index bb5c8dd5d..c1fdebf43 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -122,7 +122,7 @@ unit_tests: - php tests/console chill:db:sync-views --env=test - php -d memory_limit=2G tests/console cache:clear --env=test - php -d memory_limit=3G tests/console doctrine:fixtures:load -n --env=test - - php -d memory_limit=4G bin/phpunit --colors=never --exclude-group dbIntensive + - php -d memory_limit=4G bin/phpunit --colors=never --exclude-group dbIntensive,openstack-integration,collabora-integration artifacts: expire_in: 1 day paths: diff --git a/composer.json b/composer.json index 9ec23e857..b33314789 100644 --- a/composer.json +++ b/composer.json @@ -31,6 +31,7 @@ "phpoffice/phpspreadsheet": "^1.16", "ramsey/uuid-doctrine": "^1.7", "sensio/framework-extra-bundle": "^5.5", + "smalot/pdfparser": "^2.10", "spomky-labs/base64url": "^2.0", "symfony/asset": "^5.4", "symfony/browser-kit": "^5.4", diff --git a/docs/source/installation/enable-collabora-for-dev.rst b/docs/source/installation/enable-collabora-for-dev.rst new file mode 100644 index 000000000..17a9ae1cc --- /dev/null +++ b/docs/source/installation/enable-collabora-for-dev.rst @@ -0,0 +1,125 @@ + +Enable CODE for development +=========================== + +For editing a document, there must be a way to communicate between the collabora server and the symfony server, in +both direction. The domain name should also be the same for collabora server and for the browser which access to the +online editor. + +Using ngrok (or other http tunnel) +---------------------------------- + +One can configure a tunnel server to expose your local install to the web, and access to your local server using the +tunnel url. + +Start ngrok +^^^^^^^^^^^ + +This can be achieve using `ngrok `_. + +.. note:: + + The configuration of ngrok is outside of the scope of this document. Refers to the ngrok's documentation. + +.. code-block:: bash + + # ensuring that your server is running through http and port 8000 + ngrok http 8000 + # then open the link given by the ngrok utility and you should reach your app + +At this step, ensure that you can reach your local app using the ngrok url. + +Configure Collabora +^^^^^^^^^^^^^^^^^^^ + +The collabora server must be executed online and configure to access to your ngrok installation. Ensure that the aliasgroup +exists for your ngrok application (`See the CODE documentation: `_). + +Configure your app +^^^^^^^^^^^^^^^^^^ + +Set the :code:`EDITOR_SERVER` variable to point to your collabora server, this should be done in your :code:`.env.local` file. + +At this point, everything must be fine. In case of errors, watch the log from your collabora server, use the `profiler `_ +to debug the requests. + +.. note:: + + In case of error while validating proof (you'll see those message in the collabora's logs), you can temporarily disable + the proof validation adding this code snippet in `config/services.yaml`: + + .. code-block:: yaml + + when@dev: + # add only in dev environment, to avoid security problems + services: + ChampsLibres\WopiLib\Contract\Service\ProofValidatorInterface: + # this class will always validate proof + alias: Chill\WopiBundle\Service\Wopi\NullProofValidator + +With a local CODE image +----------------------- + +.. warning:: + + This configuration is not sure, and must be refined. The documentation does not seems to be entirely valid. + +Use a local domain name and https for your app +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Use the proxy feature from embedded symfony server to run your app. `See the dedicated doc ` + +Configure also the `https certificate `_ + +In this example, your local domain name will be :code:`my-domain` and the url will be :code:`https://my-domain.wip`. + +Ensure that the proxy is running. + +Create a certificate database for collabora +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Collabora must validate your certificate generated by symfony console. For that, you need `to create a NSS database ` +and configure collabora to use it. + +At first, export the certificate for symfony development. Use the graphical interface from your browser to get the +certificate as a PEM file. + +.. code-block:: bash + + # create your database in a custom directory + mkdir /path/to/your/directory + certutil -N -d /path/to/your/directory + cat /path/to/your/ca.crt | certutil -d . -A symfony -t -t C,P,C,u,w -a + +Launch CODE properly configured + +.. code-block:: yaml + + collabora: + image: collabora/code:latest + environment: + - SLEEPFORDEBUGGER=0 + - DONT_GEN_SSL_CERT="True" + # add path to the database + - extra_params=--o:ssl.enable=false --o:ssl.termination=false --o:logging.level=7 -o:certificates.database_path=/etc/custom-certificates/nss-database + - username=admin + - password=admin + - dictionaries=en_US + - aliasgroup1=https://my-domain.wip + ports: + - "127.0.0.1:9980:9980" + volumes: + - "/path/to/your/directory/nss-database:/etc/custom-certificates/nss-database" + extra_hosts: + - "my-domain.wip:host-gateway" + +Configure your app +^^^^^^^^^^^^^^^^^^ + +Into your :code:`.env.local` file: + +.. code-block:: env + + EDITOR_SERVER=http://${COLLABORA_HOST}:${COLLABORA_PORT} + +At this step, you should be able to edit a document through collabora. diff --git a/package.json b/package.json index 8f741d3e9..ae6d5437e 100644 --- a/package.json +++ b/package.json @@ -53,9 +53,9 @@ "marked": "^12.0.2", "masonry-layout": "^4.2.2", "mime": "^4.0.0", - "swagger-ui": "^4.15.5", + "pdfjs-dist": "^4.3.136", "vis-network": "^9.1.0", - "vue": "^3.2.37", + "vue": "^3.5.6", "vue-i18n": "^9.1.6", "vue-multiselect": "3.0.0-alpha.2", "vue-toast-notification": "^3.1.2", diff --git a/src/Bundle/ChillActivityBundle/Repository/ActivityRepository.php b/src/Bundle/ChillActivityBundle/Repository/ActivityRepository.php index a7025d4a9..ff4904f9e 100644 --- a/src/Bundle/ChillActivityBundle/Repository/ActivityRepository.php +++ b/src/Bundle/ChillActivityBundle/Repository/ActivityRepository.php @@ -12,6 +12,8 @@ declare(strict_types=1); namespace Chill\ActivityBundle\Repository; use Chill\ActivityBundle\Entity\Activity; +use Chill\DocStoreBundle\Entity\StoredObject; +use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface; use Chill\PersonBundle\Entity\AccompanyingPeriod; use Chill\PersonBundle\Entity\Person; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; @@ -23,7 +25,7 @@ use Doctrine\Persistence\ManagerRegistry; * @method Activity[] findAll() * @method Activity[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) */ -class ActivityRepository extends ServiceEntityRepository +class ActivityRepository extends ServiceEntityRepository implements AssociatedEntityToStoredObjectInterface { public function __construct(ManagerRegistry $registry) { @@ -97,4 +99,16 @@ class ActivityRepository extends ServiceEntityRepository return $qb->getQuery()->getResult(); } + + public function findAssociatedEntityToStoredObject(StoredObject $storedObject): ?Activity + { + $qb = $this->createQueryBuilder('a'); + $query = $qb + ->leftJoin('a.documents', 'ad') + ->where('ad.id = :storedObjectId') + ->setParameter('storedObjectId', $storedObject->getId()) + ->getQuery(); + + return $query->getOneOrNullResult(); + } } diff --git a/src/Bundle/ChillActivityBundle/Security/Authorization/ActivityStoredObjectVoter.php b/src/Bundle/ChillActivityBundle/Security/Authorization/ActivityStoredObjectVoter.php new file mode 100644 index 000000000..78def7318 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Security/Authorization/ActivityStoredObjectVoter.php @@ -0,0 +1,54 @@ +repository; + } + + protected function getClass(): string + { + return Activity::class; + } + + protected function attributeToRole(StoredObjectRoleEnum $attribute): string + { + return match ($attribute) { + StoredObjectRoleEnum::EDIT => ActivityVoter::UPDATE, + StoredObjectRoleEnum::SEE => ActivityVoter::SEE_DETAILS, + }; + } + + protected function canBeAssociatedWithWorkflow(): bool + { + return false; + } +} diff --git a/src/Bundle/ChillDocGeneratorBundle/DataFixtures/ORM/LoadDocGeneratorTemplate.php b/src/Bundle/ChillDocGeneratorBundle/DataFixtures/ORM/LoadDocGeneratorTemplate.php index 97d436c5e..f38102a10 100644 --- a/src/Bundle/ChillDocGeneratorBundle/DataFixtures/ORM/LoadDocGeneratorTemplate.php +++ b/src/Bundle/ChillDocGeneratorBundle/DataFixtures/ORM/LoadDocGeneratorTemplate.php @@ -54,12 +54,15 @@ class LoadDocGeneratorTemplate extends AbstractFixture ]; foreach ($templates as $template) { - $newStoredObj = (new StoredObject()) - ->setFilename($template['file']['filename']) - ->setKeyInfos(json_decode($template['file']['key'], true)) - ->setIv(json_decode($template['file']['iv'], true)) + $newStoredObj = (new StoredObject()); + + $newStoredObj ->setCreatedAt(new \DateTime('today')) - ->setType($template['file']['type']); + ->registerVersion( + json_decode($template['file']['key'], true), + json_decode($template['file']['iv'], true), + $template['file']['type'], + ); $manager->persist($newStoredObj); diff --git a/src/Bundle/ChillDocGeneratorBundle/Service/Generator/Generator.php b/src/Bundle/ChillDocGeneratorBundle/Service/Generator/Generator.php index 90dbb33a4..3e4d8fe3b 100644 --- a/src/Bundle/ChillDocGeneratorBundle/Service/Generator/Generator.php +++ b/src/Bundle/ChillDocGeneratorBundle/Service/Generator/Generator.php @@ -134,13 +134,11 @@ class Generator implements GeneratorInterface $content = Yaml::dump($data, 6); /* @var StoredObject $destinationStoredObject */ $destinationStoredObject - ->setType('application/yaml') - ->setFilename(sprintf('%s_yaml', uniqid('doc_', true))) ->setStatus(StoredObject::STATUS_READY) ; try { - $this->storedObjectManager->write($destinationStoredObject, $content); + $this->storedObjectManager->write($destinationStoredObject, $content, 'application/yaml'); } catch (StoredObjectManagerException $e) { $destinationStoredObject->addGenerationErrors($e->getMessage()); @@ -174,13 +172,11 @@ class Generator implements GeneratorInterface /* @var StoredObject $destinationStoredObject */ $destinationStoredObject - ->setType($template->getFile()->getType()) - ->setFilename(sprintf('%s_odt', uniqid('doc_', true))) ->setStatus(StoredObject::STATUS_READY) ; try { - $this->storedObjectManager->write($destinationStoredObject, $generatedResource); + $this->storedObjectManager->write($destinationStoredObject, $generatedResource, $template->getFile()->getType()); } catch (StoredObjectManagerException $e) { $destinationStoredObject->addGenerationErrors($e->getMessage()); diff --git a/src/Bundle/ChillDocGeneratorBundle/tests/Service/Context/Generator/GeneratorTest.php b/src/Bundle/ChillDocGeneratorBundle/tests/Service/Context/Generator/GeneratorTest.php index 0bb274228..ea21f09e5 100644 --- a/src/Bundle/ChillDocGeneratorBundle/tests/Service/Context/Generator/GeneratorTest.php +++ b/src/Bundle/ChillDocGeneratorBundle/tests/Service/Context/Generator/GeneratorTest.php @@ -19,6 +19,7 @@ use Chill\DocGeneratorBundle\Service\Generator\Generator; use Chill\DocGeneratorBundle\Service\Generator\ObjectReadyException; use Chill\DocGeneratorBundle\Service\Generator\RelatedEntityNotFoundException; use Chill\DocStoreBundle\Entity\StoredObject; +use Chill\DocStoreBundle\Entity\StoredObjectVersion; use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; use Chill\MainBundle\Entity\User; use Doctrine\ORM\EntityManagerInterface; @@ -39,11 +40,11 @@ class GeneratorTest extends TestCase public function testSuccessfulGeneration(): void { - $template = (new DocGeneratorTemplate())->setFile($templateStoredObject = (new StoredObject()) - ->setType('application/test')); + $templateStoredObject = new StoredObject(); + $templateStoredObject->registerVersion(type: 'application/test'); + $template = (new DocGeneratorTemplate())->setFile($templateStoredObject); $destinationStoredObject = (new StoredObject())->setStatus(StoredObject::STATUS_PENDING); $reflection = new \ReflectionClass($destinationStoredObject); - $reflection->getProperty('id')->setAccessible(true); $reflection->getProperty('id')->setValue($destinationStoredObject, 1); $entity = new class () {}; $data = []; @@ -76,7 +77,14 @@ class GeneratorTest extends TestCase $storedObjectManager = $this->prophesize(StoredObjectManagerInterface::class); $storedObjectManager->read($templateStoredObject)->willReturn('template'); - $storedObjectManager->write($destinationStoredObject, 'generated')->shouldBeCalled(); + $storedObjectManager->write($destinationStoredObject, 'generated', 'application/test') + ->will(function ($args): StoredObjectVersion { + /** @var StoredObject $storedObject */ + $storedObject = $args[0]; + + return $storedObject->registerVersion(type: $args[2]); + }) + ->shouldBeCalled(); $generator = new Generator( $contextManagerInterface->reveal(), @@ -107,8 +115,9 @@ class GeneratorTest extends TestCase $this->prophesize(StoredObjectManagerInterface::class)->reveal() ); - $template = (new DocGeneratorTemplate())->setFile($templateStoredObject = (new StoredObject()) - ->setType('application/test')); + $templateStoredObject = new StoredObject(); + $templateStoredObject->registerVersion(type: 'application/test'); + $template = (new DocGeneratorTemplate())->setFile($templateStoredObject); $destinationStoredObject = (new StoredObject())->setStatus(StoredObject::STATUS_READY); $generator->generateDocFromTemplate( @@ -124,11 +133,11 @@ class GeneratorTest extends TestCase { $this->expectException(RelatedEntityNotFoundException::class); - $template = (new DocGeneratorTemplate())->setFile($templateStoredObject = (new StoredObject()) - ->setType('application/test')); + $templateStoredObject = new StoredObject(); + $templateStoredObject->registerVersion(type: 'application/test'); + $template = (new DocGeneratorTemplate())->setFile($templateStoredObject); $destinationStoredObject = (new StoredObject())->setStatus(StoredObject::STATUS_PENDING); $reflection = new \ReflectionClass($destinationStoredObject); - $reflection->getProperty('id')->setAccessible(true); $reflection->getProperty('id')->setValue($destinationStoredObject, 1); $context = $this->prophesize(DocGeneratorContextInterface::class); diff --git a/src/Bundle/ChillDocStoreBundle/AsyncUpload/Driver/OpenstackObjectStore/TempUrlOpenstackGenerator.php b/src/Bundle/ChillDocStoreBundle/AsyncUpload/Driver/OpenstackObjectStore/TempUrlOpenstackGenerator.php index 6b221a107..19086b1c9 100644 --- a/src/Bundle/ChillDocStoreBundle/AsyncUpload/Driver/OpenstackObjectStore/TempUrlOpenstackGenerator.php +++ b/src/Bundle/ChillDocStoreBundle/AsyncUpload/Driver/OpenstackObjectStore/TempUrlOpenstackGenerator.php @@ -58,6 +58,7 @@ final readonly class TempUrlOpenstackGenerator implements TempUrlGeneratorInterf ?int $expire_delay = null, ?int $submit_delay = null, int $max_file_count = 1, + ?string $object_name = null, ): SignedUrlPost { $delay = $expire_delay ?? $this->max_expire_delay; $submit_delay ??= $this->max_submit_delay; @@ -84,11 +85,14 @@ final readonly class TempUrlOpenstackGenerator implements TempUrlGeneratorInterf $expires = $this->clock->now()->add(new \DateInterval('PT'.(string) $delay.'S')); - $object_name = $this->generateObjectName(); + if (null === $object_name) { + $object_name = $this->generateObjectName(); + } $g = new SignedUrlPost( $url = $this->generateUrl($object_name), $expires, + $object_name, $this->max_post_file_size, $max_file_count, $submit_delay, @@ -127,7 +131,7 @@ final readonly class TempUrlOpenstackGenerator implements TempUrlGeneratorInterf ]; $url = $url.'?'.\http_build_query($args); - $signature = new SignedUrl(strtoupper($method), $url, $expires); + $signature = new SignedUrl(strtoupper($method), $url, $expires, $object_name); $this->event_dispatcher->dispatch( new TempUrlGenerateEvent($signature) @@ -178,21 +182,19 @@ final readonly class TempUrlOpenstackGenerator implements TempUrlGeneratorInterf return \hash_hmac('sha512', $body, $this->key, false); } - private function generateSignature($method, $url, \DateTimeImmutable $expires) + private function generateSignature(string $method, $url, \DateTimeImmutable $expires) { if ('POST' === $method) { return $this->generateSignaturePost($url, $expires); } $path = \parse_url((string) $url, PHP_URL_PATH); - $body = sprintf( "%s\n%s\n%s", - $method, + strtoupper($method), $expires->format('U'), $path - ) - ; + ); $this->logger->debug( 'generate signature GET', diff --git a/src/Bundle/ChillDocStoreBundle/AsyncUpload/SignedUrl.php b/src/Bundle/ChillDocStoreBundle/AsyncUpload/SignedUrl.php index aba289652..ec0debca9 100644 --- a/src/Bundle/ChillDocStoreBundle/AsyncUpload/SignedUrl.php +++ b/src/Bundle/ChillDocStoreBundle/AsyncUpload/SignedUrl.php @@ -21,6 +21,8 @@ readonly class SignedUrl #[Serializer\Groups(['read'])] public string $url, public \DateTimeImmutable $expires, + #[Serializer\Groups(['read'])] + public string $object_name, ) {} #[Serializer\Groups(['read'])] diff --git a/src/Bundle/ChillDocStoreBundle/AsyncUpload/SignedUrlPost.php b/src/Bundle/ChillDocStoreBundle/AsyncUpload/SignedUrlPost.php index 9d37771d1..e9aecf6b2 100644 --- a/src/Bundle/ChillDocStoreBundle/AsyncUpload/SignedUrlPost.php +++ b/src/Bundle/ChillDocStoreBundle/AsyncUpload/SignedUrlPost.php @@ -18,6 +18,7 @@ readonly class SignedUrlPost extends SignedUrl public function __construct( string $url, \DateTimeImmutable $expires, + string $object_name, #[Serializer\Groups(['read'])] public int $max_file_size, #[Serializer\Groups(['read'])] @@ -31,6 +32,6 @@ readonly class SignedUrlPost extends SignedUrl #[Serializer\Groups(['read'])] public string $signature, ) { - parent::__construct('POST', $url, $expires); + parent::__construct('POST', $url, $expires, $object_name); } } diff --git a/src/Bundle/ChillDocStoreBundle/AsyncUpload/TempUrlGeneratorInterface.php b/src/Bundle/ChillDocStoreBundle/AsyncUpload/TempUrlGeneratorInterface.php index d81de6f1f..e6589b3c7 100644 --- a/src/Bundle/ChillDocStoreBundle/AsyncUpload/TempUrlGeneratorInterface.php +++ b/src/Bundle/ChillDocStoreBundle/AsyncUpload/TempUrlGeneratorInterface.php @@ -17,6 +17,7 @@ interface TempUrlGeneratorInterface ?int $expire_delay = null, ?int $submit_delay = null, int $max_file_count = 1, + ?string $object_name = null, ): SignedUrlPost; public function generate(string $method, string $object_name, ?int $expire_delay = null): SignedUrl; diff --git a/src/Bundle/ChillDocStoreBundle/Controller/AsyncUploadController.php b/src/Bundle/ChillDocStoreBundle/Controller/AsyncUploadController.php index 75be20d34..cdbb94d29 100644 --- a/src/Bundle/ChillDocStoreBundle/Controller/AsyncUploadController.php +++ b/src/Bundle/ChillDocStoreBundle/Controller/AsyncUploadController.php @@ -11,9 +11,11 @@ declare(strict_types=1); namespace Chill\DocStoreBundle\Controller; -use Chill\DocStoreBundle\AsyncUpload\Exception\TempUrlGeneratorException; use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface; -use Chill\DocStoreBundle\Security\Authorization\AsyncUploadVoter; +use Chill\DocStoreBundle\Entity\StoredObject; +use Chill\DocStoreBundle\Entity\StoredObjectVersion; +use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; +use Chill\MainBundle\Entity\User; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -30,62 +32,84 @@ final readonly class AsyncUploadController private TempUrlGeneratorInterface $tempUrlGenerator, private SerializerInterface $serializer, private Security $security, - private LoggerInterface $logger, + private LoggerInterface $chillLogger, ) {} - #[Route(path: '/asyncupload/temp_url/generate/{method}', name: 'async_upload.generate_url')] - public function getSignedUrl(string $method, Request $request): JsonResponse + #[Route(path: '/api/1.0/doc-store/async-upload/temp_url/{uuid}/generate/post', name: 'chill_docstore_asyncupload_getsignedurlpost')] + public function getSignedUrlPost(Request $request, StoredObject $storedObject): JsonResponse { - try { - switch (strtolower($method)) { - case 'post': - $p = $this->tempUrlGenerator - ->generatePost( - $request->query->has('expires_delay') ? $request->query->getInt('expires_delay') : null, - $request->query->has('submit_delay') ? $request->query->getInt('submit_delay') : null - ) - ; - break; - case 'get': - case 'head': - $object_name = $request->query->get('object_name', null); + if (!$this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $storedObject)) { + throw new AccessDeniedHttpException('not able to edit the given stored object'); + } - if (null === $object_name) { - return (new JsonResponse((object) [ - 'message' => 'the object_name is null', - ])) - ->setStatusCode(JsonResponse::HTTP_BAD_REQUEST); - } - $p = $this->tempUrlGenerator->generate( - $method, - $object_name, - $request->query->has('expires_delay') ? $request->query->getInt('expires_delay') : null - ); - break; - default: - return (new JsonResponse((object) ['message' => 'the method ' - ."{$method} is not valid"])) - ->setStatusCode(JsonResponse::HTTP_BAD_REQUEST); + // we create a dummy version, to generate a filename + $version = $storedObject->registerVersion(); + + $p = $this->tempUrlGenerator + ->generatePost( + $request->query->has('expires_delay') ? $request->query->getInt('expires_delay') : null, + $request->query->has('submit_delay') ? $request->query->getInt('submit_delay') : null, + object_name: $version->getFilename() + ); + + $this->chillLogger->notice('[Privacy Event] a request to upload a document has been generated', [ + 'doc_uuid' => $storedObject->getUuid(), + ]); + + return new JsonResponse( + $this->serializer->serialize($p, 'json', [AbstractNormalizer::GROUPS => ['read']]), + Response::HTTP_OK, + [], + true + ); + } + + #[Route(path: '/api/1.0/doc-store/async-upload/temp_url/{uuid}/generate/{method}', name: 'chill_docstore_asyncupload_getsignedurlget', requirements: ['method' => 'get|head'])] + public function getSignedUrlGet(Request $request, StoredObject $storedObject, string $method): JsonResponse + { + if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) { + throw new AccessDeniedHttpException('not able to read the given stored object'); + } + + // we really want to be sure that there are no other method than get or head: + if (!in_array($method, ['get', 'head'], true)) { + throw new AccessDeniedHttpException('Only methods get and head are allowed'); + } + + if ($request->query->has('version')) { + $filename = $request->query->get('version'); + + $storedObjectVersion = $storedObject->getVersions()->findFirst(fn (int $index, StoredObjectVersion $version): bool => $version->getFilename() === $filename); + + if (null === $storedObjectVersion) { + // we are here in the case where the version is not stored into the database + // as the version is prefixed by the stored object prefix, we just have to check that this prefix + // is the same. It means that the user had previously the permission to "SEE_AND_EDIT" this stored + // object with same prefix that we checked before + if (!str_starts_with($filename, $storedObject->getPrefix())) { + throw new AccessDeniedHttpException('not able to match the version with the same filename'); + } } - } catch (TempUrlGeneratorException $e) { - $this->logger->warning('The client requested a temp url' - .' which sparkle an error.', [ - 'message' => $e->getMessage(), - 'expire_delay' => $request->query->getInt('expire_delay', 0), - 'file_count' => $request->query->getInt('file_count', 1), - 'method' => $method, - ]); - - $p = new \stdClass(); - $p->message = $e->getMessage(); - $p->status = JsonResponse::HTTP_BAD_REQUEST; - - return new JsonResponse($p, JsonResponse::HTTP_BAD_REQUEST); + } else { + $filename = $storedObject->getCurrentVersion()->getFilename(); } - if (!$this->security->isGranted(AsyncUploadVoter::GENERATE_SIGNATURE, $p)) { - throw new AccessDeniedHttpException('not allowed to generate this signature'); - } + $p = $this->tempUrlGenerator->generate( + $method, + $filename, + $request->query->has('expires_delay') ? $request->query->getInt('expires_delay') : null + ); + + $user = $this->security->getUser(); + $userId = match ($user instanceof User) { + true => $user->getId(), + false => $user->getUserIdentifier(), + }; + + $this->chillLogger->notice('[Privacy Event] a request to see a document has been granted', [ + 'doc_uuid' => $storedObject->getUuid()->toString(), + 'user_id' => $userId, + ]); return new JsonResponse( $this->serializer->serialize($p, 'json', [AbstractNormalizer::GROUPS => ['read']]), diff --git a/src/Bundle/ChillDocStoreBundle/Controller/DocumentAccompanyingCourseDuplicateController.php b/src/Bundle/ChillDocStoreBundle/Controller/DocumentAccompanyingCourseDuplicateController.php new file mode 100644 index 000000000..129109634 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Controller/DocumentAccompanyingCourseDuplicateController.php @@ -0,0 +1,55 @@ +security->isGranted(AccompanyingCourseDocumentVoter::SEE, $document)) { + throw new AccessDeniedHttpException('not allowed to see this document'); + } + + if (!$this->security->isGranted(AccompanyingCourseDocumentVoter::CREATE, $document->getCourse())) { + throw new AccessDeniedHttpException('not allowed to create this document'); + } + + $duplicated = $this->documentWorkflowDuplicator->duplicate($document); + $this->entityManager->persist($duplicated); + $this->entityManager->flush(); + + return new RedirectResponse( + $this->urlGenerator->generate('accompanying_course_document_edit', ['id' => $duplicated->getId(), 'course' => $duplicated->getCourse()->getId()]) + ); + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Controller/DocumentPersonController.php b/src/Bundle/ChillDocStoreBundle/Controller/DocumentPersonController.php index 417c8c57e..2fd231aec 100644 --- a/src/Bundle/ChillDocStoreBundle/Controller/DocumentPersonController.php +++ b/src/Bundle/ChillDocStoreBundle/Controller/DocumentPersonController.php @@ -26,6 +26,8 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; use Symfony\Contracts\Translation\TranslatorInterface; +use Chill\DocStoreBundle\Service\Signature\PDFSignatureZoneParser; +use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; /** * Class DocumentPersonController. @@ -40,6 +42,8 @@ class DocumentPersonController extends AbstractController protected TranslatorInterface $translator, protected EventDispatcherInterface $eventDispatcher, protected AuthorizationHelper $authorizationHelper, + protected PDFSignatureZoneParser $PDFSignatureZoneParser, + protected StoredObjectManagerInterface $storedObjectManagerInterface, private readonly \Doctrine\Persistence\ManagerRegistry $managerRegistry, ) {} diff --git a/src/Bundle/ChillDocStoreBundle/Controller/SignatureRequestController.php b/src/Bundle/ChillDocStoreBundle/Controller/SignatureRequestController.php new file mode 100644 index 000000000..c6c564be1 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Controller/SignatureRequestController.php @@ -0,0 +1,102 @@ +security->isGranted(EntityWorkflowStepSignatureVoter::SIGN, $signature)) { + throw new AccessDeniedHttpException('not authorized to sign this step'); + } + + $entityWorkflow = $signature->getStep()->getEntityWorkflow(); + + if (EntityWorkflowSignatureStateEnum::PENDING !== $signature->getState()) { + return new JsonResponse([], status: Response::HTTP_CONFLICT); + } + + $storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($entityWorkflow); + $content = $this->storedObjectManager->read($storedObject); + + $data = \json_decode((string) $request->getContent(), true, 512, JSON_THROW_ON_ERROR); // TODO parse payload: json_decode ou, mieux, dataTransfertObject + $zone = new PDFSignatureZone( + $data['zone']['index'], + $data['zone']['x'], + $data['zone']['y'], + $data['zone']['height'], + $data['zone']['width'], + new PDFPage($data['zone']['PDFPage']['index'], $data['zone']['PDFPage']['width'], $data['zone']['PDFPage']['height']) + ); + + $this->messageBus->dispatch(new RequestPdfSignMessage( + $signature->getId(), + $zone, + $data['zone']['index'], + 'Signed by IP: '.(string) $request->getClientIp().', authenticated user: '.$this->entityRender->renderString($this->security->getUser(), []), + $this->entityRender->renderString($signature->getSigner(), [ + // options for user render + 'absence' => false, + 'main_scope' => false, + // options for person render + 'addAge' => false, + ]), + $content + )); + + return new JsonResponse(null, JsonResponse::HTTP_OK, []); + } + + #[Route('/api/1.0/document/workflow/{id}/check-signature', name: 'chill_docstore_check_signature')] + public function checkSignature(EntityWorkflowStepSignature $signature): JsonResponse + { + $entityWorkflow = $signature->getStep()->getEntityWorkflow(); + $storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($entityWorkflow); + + return new JsonResponse( + [ + 'state' => $signature->getState(), + 'storedObject' => $this->normalizer->normalize($storedObject, 'json'), + ], + JsonResponse::HTTP_OK, + [] + ); + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Controller/StoredObjectApiController.php b/src/Bundle/ChillDocStoreBundle/Controller/StoredObjectApiController.php index 600b6e52d..b67298f4c 100644 --- a/src/Bundle/ChillDocStoreBundle/Controller/StoredObjectApiController.php +++ b/src/Bundle/ChillDocStoreBundle/Controller/StoredObjectApiController.php @@ -11,6 +11,46 @@ declare(strict_types=1); namespace Chill\DocStoreBundle\Controller; +use Chill\DocStoreBundle\Entity\StoredObject; use Chill\MainBundle\CRUD\Controller\ApiController; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; +use Symfony\Component\Serializer\SerializerInterface; -class StoredObjectApiController extends ApiController {} +class StoredObjectApiController extends ApiController +{ + public function __construct( + private readonly Security $security, + private readonly SerializerInterface $serializer, + private readonly EntityManagerInterface $entityManager, + ) {} + + /** + * Creates a new stored object. + * + * @return JsonResponse the response containing the serialized object in JSON format + * + * @throws AccessDeniedHttpException if the user does not have the necessary role to create a stored object + */ + #[Route('/api/1.0/doc-store/stored-object/create', methods: ['POST'])] + public function createStoredObject(): JsonResponse + { + if (!($this->security->isGranted('ROLE_ADMIN') || $this->security->isGranted('ROLE_USER'))) { + throw new AccessDeniedHttpException('Must be user or admin to create a stored object'); + } + + $object = new StoredObject(); + + $this->entityManager->persist($object); + $this->entityManager->flush(); + + return new JsonResponse( + $this->serializer->serialize($object, 'json', [AbstractNormalizer::GROUPS => ['read']]), + json: true + ); + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Controller/StoredObjectRestoreVersionApiController.php b/src/Bundle/ChillDocStoreBundle/Controller/StoredObjectRestoreVersionApiController.php new file mode 100644 index 000000000..e3b4702f5 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Controller/StoredObjectRestoreVersionApiController.php @@ -0,0 +1,47 @@ +security->isGranted(StoredObjectRoleEnum::EDIT->value, $storedObjectVersion->getStoredObject())) { + throw new AccessDeniedHttpException('not allowed to edit the stored object'); + } + + $newVersion = $this->storedObjectRestore->restore($storedObjectVersion); + + $this->entityManager->persist($newVersion); + $this->entityManager->flush(); + + return new JsonResponse( + $this->serializer->serialize($newVersion, 'json', [AbstractNormalizer::GROUPS => ['read', StoredObjectVersionNormalizer::WITH_POINT_IN_TIMES_CONTEXT, StoredObjectVersionNormalizer::WITH_RESTORED_CONTEXT]]), + json: true + ); + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Controller/StoredObjectVersionApiController.php b/src/Bundle/ChillDocStoreBundle/Controller/StoredObjectVersionApiController.php new file mode 100644 index 000000000..819eb9f84 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Controller/StoredObjectVersionApiController.php @@ -0,0 +1,69 @@ +security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) { + throw new AccessDeniedHttpException('not allowed to see this stored object'); + } + + $total = $storedObject->getVersions()->count(); + $paginator = $this->paginatorFactory->create($total); + + $criteria = Criteria::create(); + $criteria->orderBy(['id' => Order::Ascending]); + $criteria->setMaxResults($paginator->getItemsPerPage())->setFirstResult($paginator->getCurrentPageFirstItemNumber()); + $items = $storedObject->getVersions()->matching($criteria); + + return new JsonResponse( + $this->serializer->serialize( + new Collection($items, $paginator), + 'json', + [AbstractNormalizer::GROUPS => ['read', StoredObjectVersionNormalizer::WITH_POINT_IN_TIMES_CONTEXT, StoredObjectVersionNormalizer::WITH_RESTORED_CONTEXT]] + ), + json: true + ); + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php b/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php index 6e105f9ee..022d544cb 100644 --- a/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php +++ b/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php @@ -16,6 +16,7 @@ use Chill\DocStoreBundle\Dav\Response\DavResponse; use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; +use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; @@ -42,6 +43,7 @@ final readonly class WebdavController private \Twig\Environment $engine, private StoredObjectManagerInterface $storedObjectManager, private Security $security, + private EntityManagerInterface $entityManager, ) { $this->requestAnalyzer = new PropfindRequestAnalyzer(); } @@ -201,6 +203,8 @@ final readonly class WebdavController $this->storedObjectManager->write($storedObject, $request->getContent()); + $this->entityManager->flush(); + return new DavResponse('', Response::HTTP_NO_CONTENT); } diff --git a/src/Bundle/ChillDocStoreBundle/DependencyInjection/ChillDocStoreExtension.php b/src/Bundle/ChillDocStoreBundle/DependencyInjection/ChillDocStoreExtension.php index fe9aeecfa..efeb6362d 100644 --- a/src/Bundle/ChillDocStoreBundle/DependencyInjection/ChillDocStoreExtension.php +++ b/src/Bundle/ChillDocStoreBundle/DependencyInjection/ChillDocStoreExtension.php @@ -11,14 +11,13 @@ declare(strict_types=1); namespace Chill\DocStoreBundle\DependencyInjection; -use Chill\DocStoreBundle\Controller\StoredObjectApiController; use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter; use Chill\DocStoreBundle\Security\Authorization\PersonDocumentVoter; +use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoterInterface; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; use Symfony\Component\DependencyInjection\Loader; -use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\DependencyInjection\Extension; /** @@ -35,6 +34,8 @@ class ChillDocStoreExtension extends Extension implements PrependExtensionInterf $container->setParameter('chill_doc_store', $config); + $container->registerForAutoconfiguration(StoredObjectVoterInterface::class)->addTag('stored_object_voter'); + $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../config')); $loader->load('services.yaml'); $loader->load('services/controller.yaml'); @@ -42,6 +43,7 @@ class ChillDocStoreExtension extends Extension implements PrependExtensionInterf $loader->load('services/fixtures.yaml'); $loader->load('services/form.yaml'); $loader->load('services/templating.yaml'); + $loader->load('services/security.yaml'); } public function prepend(ContainerBuilder $container) @@ -49,29 +51,6 @@ class ChillDocStoreExtension extends Extension implements PrependExtensionInterf $this->prependRoute($container); $this->prependAuthorization($container); $this->prependTwig($container); - $this->prependApis($container); - } - - protected function prependApis(ContainerBuilder $container) - { - $container->prependExtensionConfig('chill_main', [ - 'apis' => [ - [ - 'class' => \Chill\DocStoreBundle\Entity\StoredObject::class, - 'controller' => StoredObjectApiController::class, - 'name' => 'stored_object', - 'base_path' => '/api/1.0/docstore/stored-object', - 'base_role' => 'ROLE_USER', - 'actions' => [ - '_entity' => [ - 'methods' => [ - Request::METHOD_POST => true, - ], - ], - ], - ], - ], - ]); } protected function prependAuthorization(ContainerBuilder $container) diff --git a/src/Bundle/ChillDocStoreBundle/Entity/StoredObject.php b/src/Bundle/ChillDocStoreBundle/Entity/StoredObject.php index 7e23c8b10..06c2e5bd3 100644 --- a/src/Bundle/ChillDocStoreBundle/Entity/StoredObject.php +++ b/src/Bundle/ChillDocStoreBundle/Entity/StoredObject.php @@ -16,10 +16,17 @@ use ChampsLibres\WopiLib\Contract\Entity\Document; use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate; use Chill\MainBundle\Doctrine\Model\TrackCreationInterface; use Chill\MainBundle\Doctrine\Model\TrackCreationTrait; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\Common\Collections\Order; +use Doctrine\Common\Collections\ReadableCollection; +use Doctrine\Common\Collections\Selectable; use Doctrine\ORM\Mapping as ORM; use Ramsey\Uuid\Uuid; use Ramsey\Uuid\UuidInterface; +use Random\RandomException; use Symfony\Component\Serializer\Annotation as Serializer; +use Symfony\Component\Validator\Constraints as Assert; /** * Represent a document stored in an object store. @@ -28,13 +35,16 @@ use Symfony\Component\Serializer\Annotation as Serializer; * * The property `$deleteAt` allow a deletion of the document after the given date. But this property should * be set before the document is actually written by the StoredObjectManager. + * + * Each version is stored within a @see{StoredObjectVersion}, associated with this current's object. The creation + * of each new version should be done using the method @see{self::registerVersion}. */ #[ORM\Entity] -#[ORM\Table('chill_doc.stored_object')] -#[AsyncFileExists(message: 'The file is not stored properly')] +#[ORM\Table('stored_object', schema: 'chill_doc')] class StoredObject implements Document, TrackCreationInterface { use TrackCreationTrait; + final public const STATUS_EMPTY = 'empty'; final public const STATUS_READY = 'ready'; final public const STATUS_PENDING = 'pending'; final public const STATUS_FAILURE = 'failure'; @@ -43,9 +53,11 @@ class StoredObject implements Document, TrackCreationInterface #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'datas')] private array $datas = []; - #[Serializer\Groups(['write'])] - #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT)] - private string $filename = ''; + /** + * the prefix of each version. + */ + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])] + private string $prefix = ''; #[Serializer\Groups(['write'])] #[ORM\Id] @@ -53,25 +65,10 @@ class StoredObject implements Document, TrackCreationInterface #[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)] private ?int $id = null; - /** - * @var int[] - */ #[Serializer\Groups(['write'])] - #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'iv')] - private array $iv = []; - - #[Serializer\Groups(['write'])] - #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'key')] - private array $keyInfos = []; - - #[Serializer\Groups(['write'])] - #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, name: 'title')] + #[ORM\Column(name: 'title', type: \Doctrine\DBAL\Types\Types::TEXT, options: ['default' => ''])] private string $title = ''; - #[Serializer\Groups(['write'])] - #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, name: 'type', options: ['default' => ''])] - private string $type = ''; - #[Serializer\Groups(['write'])] #[ORM\Column(type: 'uuid', unique: true)] private UuidInterface $uuid; @@ -94,14 +91,22 @@ class StoredObject implements Document, TrackCreationInterface #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])] private string $generationErrors = ''; + /** + * @var Collection&Selectable + */ + #[ORM\OneToMany(mappedBy: 'storedObject', targetEntity: StoredObjectVersion::class, cascade: ['persist'], orphanRemoval: true)] + private Collection&Selectable $versions; + /** * @param StoredObject::STATUS_* $status */ public function __construct( #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, options: ['default' => 'ready'])] - private string $status = 'ready', + private string $status = 'empty', ) { $this->uuid = Uuid::uuid4(); + $this->versions = new ArrayCollection(); + $this->prefix = self::generatePrefix(); } public function addGenerationTrial(): self @@ -125,14 +130,34 @@ class StoredObject implements Document, TrackCreationInterface return \DateTime::createFromImmutable($this->createdAt); } + #[AsyncFileExists(message: 'The file is not stored properly')] + #[Assert\NotNull(message: 'The store object version must be present')] + public function getCurrentVersion(): ?StoredObjectVersion + { + $maxVersion = null; + + foreach ($this->versions as $v) { + if ($v->getVersion() > ($maxVersion?->getVersion() ?? -1)) { + $maxVersion = $v; + } + } + + return $maxVersion; + } + public function getDatas(): array { return $this->datas; } + public function getPrefix(): string + { + return $this->prefix; + } + public function getFilename(): string { - return $this->filename; + return $this->getCurrentVersion()?->getFilename() ?? ''; } public function getGenerationTrialsCounter(): int @@ -145,14 +170,17 @@ class StoredObject implements Document, TrackCreationInterface return $this->id; } + /** + * @return list + */ public function getIv(): array { - return $this->iv; + return $this->getCurrentVersion()?->getIv() ?? []; } public function getKeyInfos(): array { - return $this->keyInfos; + return $this->getCurrentVersion()?->getKeyInfos() ?? []; } /** @@ -171,14 +199,14 @@ class StoredObject implements Document, TrackCreationInterface return $this->status; } - public function getTitle() + public function getTitle(): string { return $this->title; } - public function getType() + public function getType(): string { - return $this->type; + return $this->getCurrentVersion()?->getType() ?? ''; } public function getUuid(): UuidInterface @@ -209,27 +237,6 @@ class StoredObject implements Document, TrackCreationInterface return $this; } - public function setFilename(?string $filename): self - { - $this->filename = (string) $filename; - - return $this; - } - - public function setIv(?array $iv): self - { - $this->iv = (array) $iv; - - return $this; - } - - public function setKeyInfos(?array $keyInfos): self - { - $this->keyInfos = (array) $keyInfos; - - return $this; - } - /** * @param StoredObject::STATUS_* $status */ @@ -247,23 +254,89 @@ class StoredObject implements Document, TrackCreationInterface return $this; } - public function setType(?string $type): self - { - $this->type = (string) $type; - - return $this; - } - public function getTemplate(): ?DocGeneratorTemplate { return $this->template; } + /** + * @return Selectable&Collection + */ + public function getVersions(): Collection&Selectable + { + return $this->versions; + } + + /** + * Retrieves versions sorted by a given order. + * + * @param 'ASC'|'DESC' $order the sorting order, default is Order::Ascending + * + * @return readableCollection&Selectable The ordered collection of versions + */ + public function getVersionsOrdered(string $order = 'ASC'): ReadableCollection&Selectable + { + $versions = $this->getVersions()->toArray(); + + match ($order) { + 'ASC' => usort($versions, static fn (StoredObjectVersion $a, StoredObjectVersion $b) => $a->getVersion() <=> $b->getVersion()), + 'DESC' => usort($versions, static fn (StoredObjectVersion $a, StoredObjectVersion $b) => $b->getVersion() <=> $a->getVersion()), + }; + + return new ArrayCollection($versions); + } + + public function hasCurrentVersion(): bool + { + return null !== $this->getCurrentVersion(); + } + public function hasTemplate(): bool { return null !== $this->template; } + /** + * Checks if there is a version kept before conversion. + * + * @return bool true if a version is kept before conversion, false otherwise + */ + public function hasKeptBeforeConversionVersion(): bool + { + foreach ($this->getVersions() as $version) { + foreach ($version->getPointInTimes() as $pointInTime) { + if (StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION === $pointInTime->getReason()) { + return true; + } + } + } + + return false; + } + + /** + * Retrieves the last version of the stored object that was kept before conversion. + * + * This method iterates through the ordered versions and their respective points + * in time to find the most recent version that has a point in time with the reason + * 'KEEP_BEFORE_CONVERSION'. + * + * @return StoredObjectVersion|null the version that was kept before conversion, + * or null if not found + */ + public function getLastKeptBeforeConversionVersion(): ?StoredObjectVersion + { + foreach ($this->getVersionsOrdered('DESC') as $version) { + foreach ($version->getPointInTimes() as $pointInTime) { + if (StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION === $pointInTime->getReason()) { + return $version; + } + } + } + + return null; + } + public function setTemplate(?DocGeneratorTemplate $template): StoredObject { $this->template = $template; @@ -314,18 +387,65 @@ class StoredObject implements Document, TrackCreationInterface return $this; } - public function saveHistory(): void - { - if ('' === $this->getFilename()) { - return; + public function registerVersion( + array $iv = [], + array $keyInfos = [], + string $type = '', + ?string $filename = null, + ): StoredObjectVersion { + $version = new StoredObjectVersion( + $this, + null === $this->getCurrentVersion() ? 0 : $this->getCurrentVersion()->getVersion() + 1, + $iv, + $keyInfos, + $type, + $filename + ); + + $this->versions->add($version); + + if ('empty' === $this->status) { + $this->status = self::STATUS_READY; } - $this->datas['history'][] = [ - 'filename' => $this->getFilename(), - 'iv' => $this->getIv(), - 'key_infos' => $this->getKeyInfos(), - 'type' => $this->getType(), - 'before' => (new \DateTimeImmutable('now'))->getTimestamp(), - ]; + return $version; + } + + public function removeVersion(StoredObjectVersion $storedObjectVersion): void + { + if (!$this->versions->contains($storedObjectVersion)) { + throw new \UnexpectedValueException('This stored object does not contains this version'); + } + $this->versions->removeElement($storedObjectVersion); + } + + /** + * @deprecated + */ + public function saveHistory(): void {} + + public static function generatePrefix(): string + { + try { + return base_convert(bin2hex(random_bytes(32)), 16, 36); + } catch (RandomException) { + return uniqid(more_entropy: true); + } + } + + /** + * Checks if a stored object can be deleted. + * + * Currently, return true if the deletedAt date is below the current date, and the object + * does not contains any version (which must be removed first). + * + * @param \DateTimeImmutable $now the current date and time + * @param StoredObject $storedObject the stored object to check + * + * @return bool returns true if the stored object can be deleted, false otherwise + */ + public static function canBeDeleted(\DateTimeImmutable $now, StoredObject $storedObject): bool + { + return $storedObject->getDeleteAt() < $now && $storedObject->getVersions()->isEmpty(); } } diff --git a/src/Bundle/ChillDocStoreBundle/Entity/StoredObjectPointInTime.php b/src/Bundle/ChillDocStoreBundle/Entity/StoredObjectPointInTime.php new file mode 100644 index 000000000..bff5c60c1 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Entity/StoredObjectPointInTime.php @@ -0,0 +1,67 @@ +objectVersion->addPointInTime($this); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getByUser(): ?User + { + return $this->byUser; + } + + public function getObjectVersion(): StoredObjectVersion + { + return $this->objectVersion; + } + + public function getReason(): StoredObjectPointInTimeReasonEnum + { + return $this->reason; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Entity/StoredObjectPointInTimeReasonEnum.php b/src/Bundle/ChillDocStoreBundle/Entity/StoredObjectPointInTimeReasonEnum.php new file mode 100644 index 000000000..9f03c7279 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Entity/StoredObjectPointInTimeReasonEnum.php @@ -0,0 +1,18 @@ + ''])] + private string $filename = ''; + + /** + * @var Collection&Selectable + */ + #[ORM\OneToMany(mappedBy: 'objectVersion', targetEntity: StoredObjectPointInTime::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + private Collection&Selectable $pointInTimes; + + /** + * Previous storedObjectVersion, from which the current stored object version is created. + * + * If null, the current stored object version is generated by other means. + * + * Those version may be associated with the same storedObject, or not. In this last case, that means that + * the stored object's current version is created from another stored object version. + */ + #[ORM\ManyToOne(targetEntity: StoredObjectVersion::class)] + private ?StoredObjectVersion $createdFrom = null; + + /** + * List of stored object versions created from the current version. + * + * @var Collection + */ + #[ORM\OneToMany(mappedBy: 'createdFrom', targetEntity: StoredObjectVersion::class)] + private Collection $children; + + public function __construct( + /** + * The stored object associated with this version. + */ + #[ORM\ManyToOne(targetEntity: StoredObject::class, inversedBy: 'versions')] + #[ORM\JoinColumn(name: 'stored_object_id', nullable: false)] + private StoredObject $storedObject, + + /** + * The incremental version. + */ + #[ORM\Column(name: 'version', type: \Doctrine\DBAL\Types\Types::INTEGER, options: ['default' => 0])] + private int $version = 0, + + /** + * vector for encryption. + * + * @var int[] + */ + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'iv')] + private array $iv = [], + + /** + * Key infos for document encryption. + * + * @var array + */ + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'key')] + private array $keyInfos = [], + + /** + * type of the document. + */ + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, name: 'type', options: ['default' => ''])] + private string $type = '', + ?string $filename = null, + ) { + $this->filename = $filename ?? self::generateFilename($this); + $this->pointInTimes = new ArrayCollection(); + $this->children = new ArrayCollection(); + } + + public static function generateFilename(StoredObjectVersion $storedObjectVersion): string + { + try { + $suffix = base_convert(bin2hex(random_bytes(8)), 16, 36); + } catch (RandomException) { + $suffix = uniqid(more_entropy: true); + } + + return $storedObjectVersion->getStoredObject()->getPrefix().'/'.$suffix; + } + + public function getFilename(): string + { + return $this->filename; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getIv(): array + { + return $this->iv; + } + + public function getKeyInfos(): array + { + return $this->keyInfos; + } + + public function getStoredObject(): StoredObject + { + return $this->storedObject; + } + + public function getType(): string + { + return $this->type; + } + + public function getVersion(): int + { + return $this->version; + } + + /** + * @return Collection&Selectable + */ + public function getPointInTimes(): Selectable&Collection + { + return $this->pointInTimes; + } + + public function hasPointInTimes(): bool + { + return $this->pointInTimes->count() > 0; + } + + /** + * @internal use @see{StoredObjectPointInTime} constructor instead + */ + public function addPointInTime(StoredObjectPointInTime $storedObjectPointInTime): self + { + if (!$this->pointInTimes->contains($storedObjectPointInTime)) { + $this->pointInTimes->add($storedObjectPointInTime); + } + + return $this; + } + + public function removePointInTime(StoredObjectPointInTime $storedObjectPointInTime): self + { + if ($this->pointInTimes->contains($storedObjectPointInTime)) { + $this->pointInTimes->removeElement($storedObjectPointInTime); + } + + return $this; + } + + public function getCreatedFrom(): ?StoredObjectVersion + { + return $this->createdFrom; + } + + public function setCreatedFrom(?StoredObjectVersion $createdFrom): StoredObjectVersion + { + if (null === $createdFrom && null !== $this->createdFrom) { + $this->createdFrom->removeChild($this); + } + + $createdFrom?->addChild($this); + + $this->createdFrom = $createdFrom; + + return $this; + } + + public function addChild(StoredObjectVersion $child): self + { + if (!$this->children->contains($child)) { + $this->children->add($child); + } + + return $this; + } + + public function removeChild(StoredObjectVersion $child): self + { + $result = $this->children->removeElement($child); + + if (false === $result) { + throw new \UnexpectedValueException('the child is not associated with the current stored object version'); + } + + return $this; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Form/DataMapper/StoredObjectDataMapper.php b/src/Bundle/ChillDocStoreBundle/Form/DataMapper/StoredObjectDataMapper.php index 170b1ab0a..264f52365 100644 --- a/src/Bundle/ChillDocStoreBundle/Form/DataMapper/StoredObjectDataMapper.php +++ b/src/Bundle/ChillDocStoreBundle/Form/DataMapper/StoredObjectDataMapper.php @@ -55,16 +55,8 @@ class StoredObjectDataMapper implements DataMapperInterface return; } - /** @var StoredObject $viewData */ - if ($viewData->getFilename() !== $forms['stored_object']->getData()['filename']) { - // we want to keep the previous history - $viewData->saveHistory(); - } - - $viewData->setFilename($forms['stored_object']->getData()['filename']); - $viewData->setIv($forms['stored_object']->getData()['iv']); - $viewData->setKeyInfos($forms['stored_object']->getData()['keyInfos']); - $viewData->setType($forms['stored_object']->getData()['type']); + /* @var StoredObject $viewData */ + $viewData = $forms['stored_object']->getData(); if (array_key_exists('title', $forms)) { $viewData->setTitle($forms['title']->getData()); diff --git a/src/Bundle/ChillDocStoreBundle/Form/DataTransformer/StoredObjectDataTransformer.php b/src/Bundle/ChillDocStoreBundle/Form/DataTransformer/StoredObjectDataTransformer.php index 38c469c88..8df3e3eb9 100644 --- a/src/Bundle/ChillDocStoreBundle/Form/DataTransformer/StoredObjectDataTransformer.php +++ b/src/Bundle/ChillDocStoreBundle/Form/DataTransformer/StoredObjectDataTransformer.php @@ -12,7 +12,6 @@ declare(strict_types=1); namespace Chill\DocStoreBundle\Form\DataTransformer; use Chill\DocStoreBundle\Entity\StoredObject; -use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectNormalizer; use Symfony\Component\Form\DataTransformerInterface; use Symfony\Component\Form\Exception\UnexpectedTypeException; use Symfony\Component\Serializer\SerializerInterface; @@ -30,11 +29,7 @@ class StoredObjectDataTransformer implements DataTransformerInterface } if ($value instanceof StoredObject) { - return $this->serializer->serialize($value, 'json', [ - 'groups' => [ - StoredObjectNormalizer::ADD_DAV_EDIT_LINK_CONTEXT, - ], - ]); + return $this->serializer->serialize($value, 'json'); } throw new UnexpectedTypeException($value, StoredObject::class); @@ -46,6 +41,6 @@ class StoredObjectDataTransformer implements DataTransformerInterface return null; } - return json_decode((string) $value, true, 10, JSON_THROW_ON_ERROR); + return $this->serializer->deserialize($value, StoredObject::class, 'json'); } } diff --git a/src/Bundle/ChillDocStoreBundle/Repository/AccompanyingCourseDocumentRepository.php b/src/Bundle/ChillDocStoreBundle/Repository/AccompanyingCourseDocumentRepository.php index 2679993c4..3590aae7f 100644 --- a/src/Bundle/ChillDocStoreBundle/Repository/AccompanyingCourseDocumentRepository.php +++ b/src/Bundle/ChillDocStoreBundle/Repository/AccompanyingCourseDocumentRepository.php @@ -12,17 +12,18 @@ declare(strict_types=1); namespace Chill\DocStoreBundle\Repository; use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument; +use Chill\DocStoreBundle\Entity\StoredObject; use Chill\PersonBundle\Entity\AccompanyingPeriod; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ObjectRepository; -class AccompanyingCourseDocumentRepository implements ObjectRepository +class AccompanyingCourseDocumentRepository implements ObjectRepository, AssociatedEntityToStoredObjectInterface { private readonly EntityRepository $repository; - public function __construct(private readonly EntityManagerInterface $em) + public function __construct(EntityManagerInterface $em) { $this->repository = $em->getRepository(AccompanyingCourseDocument::class); } @@ -45,6 +46,16 @@ class AccompanyingCourseDocumentRepository implements ObjectRepository return $qb->getQuery()->getSingleScalarResult(); } + public function findAssociatedEntityToStoredObject(StoredObject $storedObject): ?object + { + $qb = $this->repository->createQueryBuilder('d'); + $query = $qb->where('d.object = :storedObject') + ->setParameter('storedObject', $storedObject) + ->getQuery(); + + return $query->getOneOrNullResult(); + } + public function find($id): ?AccompanyingCourseDocument { return $this->repository->find($id); @@ -55,7 +66,7 @@ class AccompanyingCourseDocumentRepository implements ObjectRepository return $this->repository->findAll(); } - public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null) + public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null): array { return $this->repository->findBy($criteria, $orderBy, $limit, $offset); } @@ -65,7 +76,7 @@ class AccompanyingCourseDocumentRepository implements ObjectRepository return $this->findOneBy($criteria); } - public function getClassName() + public function getClassName(): string { return AccompanyingCourseDocument::class; } diff --git a/src/Bundle/ChillDocStoreBundle/Repository/AssociatedEntityToStoredObjectInterface.php b/src/Bundle/ChillDocStoreBundle/Repository/AssociatedEntityToStoredObjectInterface.php new file mode 100644 index 000000000..81d230e67 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Repository/AssociatedEntityToStoredObjectInterface.php @@ -0,0 +1,19 @@ + */ -readonly class PersonDocumentRepository implements ObjectRepository +readonly class PersonDocumentRepository implements ObjectRepository, AssociatedEntityToStoredObjectInterface { private EntityRepository $repository; @@ -53,4 +54,14 @@ readonly class PersonDocumentRepository implements ObjectRepository { return PersonDocument::class; } + + public function findAssociatedEntityToStoredObject(StoredObject $storedObject): ?object + { + $qb = $this->repository->createQueryBuilder('d'); + $query = $qb->where('d.object = :storedObject') + ->setParameter('storedObject', $storedObject) + ->getQuery(); + + return $query->getOneOrNullResult(); + } } diff --git a/src/Bundle/ChillDocStoreBundle/Repository/StoredObjectPointInTimeRepository.php b/src/Bundle/ChillDocStoreBundle/Repository/StoredObjectPointInTimeRepository.php new file mode 100644 index 000000000..c5c923ac9 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Repository/StoredObjectPointInTimeRepository.php @@ -0,0 +1,27 @@ + + */ +class StoredObjectPointInTimeRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, StoredObjectPointInTime::class); + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Repository/StoredObjectRepository.php b/src/Bundle/ChillDocStoreBundle/Repository/StoredObjectRepository.php index 84bc7d4cb..d48792bc9 100644 --- a/src/Bundle/ChillDocStoreBundle/Repository/StoredObjectRepository.php +++ b/src/Bundle/ChillDocStoreBundle/Repository/StoredObjectRepository.php @@ -14,6 +14,7 @@ namespace Chill\DocStoreBundle\Repository; use Chill\DocStoreBundle\Entity\StoredObject; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; +use Doctrine\ORM\Query; final readonly class StoredObjectRepository implements StoredObjectRepositoryInterface { @@ -53,6 +54,21 @@ final readonly class StoredObjectRepository implements StoredObjectRepositoryInt return $this->repository->findOneBy($criteria); } + public function findByExpired(\DateTimeImmutable $expiredAtDate): iterable + { + $qb = $this->repository->createQueryBuilder('stored_object'); + $qb + ->where('stored_object.deleteAt <= :expiredAt') + ->setParameter('expiredAt', $expiredAtDate); + + return $qb->getQuery()->toIterable(hydrationMode: Query::HYDRATE_OBJECT); + } + + public function findOneByUUID(string $uuid): ?StoredObject + { + return $this->repository->findOneBy(['uuid' => $uuid]); + } + public function getClassName(): string { return StoredObject::class; diff --git a/src/Bundle/ChillDocStoreBundle/Repository/StoredObjectRepositoryInterface.php b/src/Bundle/ChillDocStoreBundle/Repository/StoredObjectRepositoryInterface.php index c694f1e09..877847677 100644 --- a/src/Bundle/ChillDocStoreBundle/Repository/StoredObjectRepositoryInterface.php +++ b/src/Bundle/ChillDocStoreBundle/Repository/StoredObjectRepositoryInterface.php @@ -17,4 +17,12 @@ use Doctrine\Persistence\ObjectRepository; /** * @extends ObjectRepository */ -interface StoredObjectRepositoryInterface extends ObjectRepository {} +interface StoredObjectRepositoryInterface extends ObjectRepository +{ + /** + * @return iterable + */ + public function findByExpired(\DateTimeImmutable $expiredAtDate): iterable; + + public function findOneByUUID(string $uuid): ?StoredObject; +} diff --git a/src/Bundle/ChillDocStoreBundle/Repository/StoredObjectVersionRepository.php b/src/Bundle/ChillDocStoreBundle/Repository/StoredObjectVersionRepository.php new file mode 100644 index 000000000..60ea07420 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Repository/StoredObjectVersionRepository.php @@ -0,0 +1,94 @@ + + */ +class StoredObjectVersionRepository implements ObjectRepository +{ + private readonly EntityRepository $repository; + + private readonly Connection $connection; + + public function __construct(EntityManagerInterface $entityManager) + { + $this->repository = $entityManager->getRepository(StoredObjectVersion::class); + $this->connection = $entityManager->getConnection(); + } + + public function find($id): ?StoredObjectVersion + { + return $this->repository->find($id); + } + + public function findAll(): array + { + return $this->repository->findAll(); + } + + public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array + { + return $this->repository->findBy($criteria, $orderBy, $limit, $offset); + } + + public function findOneBy(array $criteria): ?StoredObjectVersion + { + return $this->repository->findOneBy($criteria); + } + + /** + * Finds the IDs of versions older than a given date and that are not the last version. + * + * Those version are good candidates for a deletion. + * + * @param \DateTimeImmutable $beforeDate the date to compare versions against + * + * @return iterable returns an iterable with the IDs of the versions + */ + public function findIdsByVersionsOlderThanDateAndNotLastVersionAndNotPointInTime(\DateTimeImmutable $beforeDate): iterable + { + $results = $this->connection->executeQuery( + self::QUERY_FIND_IDS_BY_VERSIONS_OLDER_THAN_DATE_AND_NOT_LAST_VERSION, + [$beforeDate], + [Types::DATETIME_IMMUTABLE] + ); + + foreach ($results->iterateAssociative() as $row) { + yield $row['sov_id']; + } + } + + private const QUERY_FIND_IDS_BY_VERSIONS_OLDER_THAN_DATE_AND_NOT_LAST_VERSION = <<<'SQL' + SELECT + sov.id AS sov_id + FROM chill_doc.stored_object_version sov + WHERE + sov.createdat < ?::timestamp + AND + sov.version < (SELECT MAX(sub_sov.version) FROM chill_doc.stored_object_version sub_sov WHERE sub_sov.stored_object_id = sov.stored_object_id) + AND + NOT EXISTS (SELECT 1 FROM chill_doc.stored_object_point_in_time sub_poi WHERE sub_poi.stored_object_version_id = sov.id) + SQL; + + public function getClassName(): string + { + return StoredObjectVersion::class; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/_components/helper.ts b/src/Bundle/ChillDocStoreBundle/Resources/public/js/async-upload/uploader.ts similarity index 75% rename from src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/_components/helper.ts rename to src/Bundle/ChillDocStoreBundle/Resources/public/js/async-upload/uploader.ts index 6d07e769d..592bd8111 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/_components/helper.ts +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/js/async-upload/uploader.ts @@ -1,5 +1,5 @@ import {makeFetch} from "../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods"; -import {PostStoreObjectSignature} from "../../types"; +import {PostStoreObjectSignature, StoredObject} from "../../types"; const algo = 'AES-CBC'; @@ -21,11 +21,22 @@ const createFilename = (): string => { return text; }; -export const uploadFile = async (uploadFile: ArrayBuffer): Promise => { +/** + * Fetches a new stored object from the server. + * + * @async + * @function fetchNewStoredObject + * @returns {Promise} A Promise that resolves to the newly created StoredObject. + */ +export const fetchNewStoredObject = async (): Promise => { + return makeFetch("POST", '/api/1.0/doc-store/stored-object/create', null); +} + +export const uploadVersion = async (uploadFile: ArrayBuffer, storedObject: StoredObject): Promise => { const params = new URLSearchParams(); params.append('expires_delay', "180"); params.append('submit_delay', "180"); - const asyncData: PostStoreObjectSignature = await makeFetch("GET", URL_POST + "?" + params.toString()); + const asyncData: PostStoreObjectSignature = await makeFetch("GET", `/api/1.0/doc-store/async-upload/temp_url/${storedObject.uuid}/generate/post` + "?" + params.toString()); const suffix = createFilename(); const filename = asyncData.prefix + suffix; const formData = new FormData(); @@ -50,7 +61,6 @@ export const uploadFile = async (uploadFile: ArrayBuffer): Promise => { } export const encryptFile = async (originalFile: ArrayBuffer): Promise<[ArrayBuffer, Uint8Array, JsonWebKey]> => { - console.log('encrypt', originalFile); const iv = crypto.getRandomValues(new Uint8Array(16)); const key = await window.crypto.subtle.generateKey(keyDefinition, true, [ "encrypt", "decrypt" ]); const exportedKey = await window.crypto.subtle.exportKey('jwk', key); diff --git a/src/Bundle/ChillDocStoreBundle/Resources/public/module/async_upload/index.ts b/src/Bundle/ChillDocStoreBundle/Resources/public/module/async_upload/index.ts index b7df11323..50849d635 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/public/module/async_upload/index.ts +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/module/async_upload/index.ts @@ -1,7 +1,7 @@ import {CollectionEventPayload} from "../../../../../ChillMainBundle/Resources/public/module/collection"; import {createApp} from "vue"; import DropFileWidget from "../../vuejs/DropFileWidget/DropFileWidget.vue" -import {StoredObject, StoredObjectCreated} from "../../types"; +import {StoredObject, StoredObjectVersion} from "../../types"; import {_createI18n} from "../../../../../ChillMainBundle/Resources/public/vuejs/_js/i18n"; const i18n = _createI18n({}); @@ -30,15 +30,17 @@ const startApp = (divElement: HTMLDivElement, collectionEntry: null|HTMLLIElemen DropFileWidget, }, methods: { - addDocument: function(object: StoredObjectCreated): void { - console.log('object added', object); - this.$data.existingDoc = object; - input_stored_object.value = JSON.stringify(object); + addDocument: function({stored_object, stored_object_version}: {stored_object: StoredObject, stored_object_version: StoredObjectVersion}): void { + console.log('object added', stored_object); + console.log('version added', stored_object_version); + this.$data.existingDoc = stored_object; + this.$data.existingDoc.currentVersion = stored_object_version; + input_stored_object.value = JSON.stringify(this.$data.existingDoc); }, removeDocument: function(object: StoredObject): void { console.log('catch remove document', object); input_stored_object.value = ""; - this.$data.existingDoc = null; + this.$data.existingDoc = undefined; console.log('collectionEntry', collectionEntry); if (null !== collectionEntry) { diff --git a/src/Bundle/ChillDocStoreBundle/Resources/public/module/button_download/index.ts b/src/Bundle/ChillDocStoreBundle/Resources/public/module/button_download/index.ts new file mode 100644 index 000000000..9ffe696c7 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/module/button_download/index.ts @@ -0,0 +1,27 @@ +import {_createI18n} from "../../../../../ChillMainBundle/Resources/public/vuejs/_js/i18n"; +import {createApp} from "vue"; +import DocumentActionButtonsGroup from "../../vuejs/DocumentActionButtonsGroup.vue"; +import {StoredObject, StoredObjectStatusChange} from "../../types"; +import {defineComponent} from "vue"; +import DownloadButton from "../../vuejs/StoredObjectButton/DownloadButton.vue"; +import ToastPlugin from "vue-toast-notification"; + + + +const i18n = _createI18n({}); + +window.addEventListener('DOMContentLoaded', function (e) { + document.querySelectorAll('div[data-download-button-single]').forEach((el) => { + const storedObject = JSON.parse(el.dataset.storedObject as string) as StoredObject; + const title = el.dataset.title as string; + const app = createApp({ + components: {DownloadButton}, + data() { + return {storedObject, title, classes: {btn: true, "btn-outline-primary": true}}; + }, + template: '', + }); + + app.use(i18n).use(ToastPlugin).mount(el); + }); +}); diff --git a/src/Bundle/ChillDocStoreBundle/Resources/public/module/document_action_buttons_group/index.ts b/src/Bundle/ChillDocStoreBundle/Resources/public/module/document_action_buttons_group/index.ts index 77eb8c2c9..f9cf13a97 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/public/module/document_action_buttons_group/index.ts +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/module/document_action_buttons_group/index.ts @@ -3,6 +3,7 @@ import DocumentActionButtonsGroup from "../../vuejs/DocumentActionButtonsGroup.v import {createApp} from "vue"; import {StoredObject, StoredObjectStatusChange} from "../../types"; import {is_object_ready} from "../../vuejs/StoredObjectButton/helpers"; +import ToastPlugin from "vue-toast-notification"; const i18n = _createI18n({}); @@ -48,6 +49,6 @@ window.addEventListener('DOMContentLoaded', function (e) { } }); - app.use(i18n).mount(el); + app.use(i18n).use(ToastPlugin).mount(el); }) }); diff --git a/src/Bundle/ChillDocStoreBundle/Resources/public/types.ts b/src/Bundle/ChillDocStoreBundle/Resources/public/types.ts index 25d956312..ba7369661 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/public/types.ts +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/types.ts @@ -1,64 +1,141 @@ -import {DateTime} from "../../../ChillMainBundle/Resources/public/types"; +import { + DateTime, + User, +} from "../../../ChillMainBundle/Resources/public/types"; +import {SignedUrlGet} from "./vuejs/StoredObjectButton/helpers"; -export type StoredObjectStatus = "ready"|"failure"|"pending"; +export type StoredObjectStatus = "empty" | "ready" | "failure" | "pending"; export interface StoredObject { - id: number, - - /** - * filename of the object in the object storage - */ - filename: string, - creationDate: DateTime, - datas: object, - iv: number[], - keyInfos: object, - title: string, - type: string, - uuid: string, - status: StoredObjectStatus, + id: number; + title: string | null; + uuid: string; + prefix: string; + status: StoredObjectStatus; + currentVersion: + | null + | StoredObjectVersionCreated + | StoredObjectVersionPersisted; + totalVersions: number; + datas: object; + /** @deprecated */ + creationDate: DateTime; + createdAt: DateTime | null; + createdBy: User | null; + _permissions: { + canEdit: boolean; + canSee: boolean; + }; _links?: { - dav_link?: { - href: string - expiration: number - }, - } + dav_link?: { + href: string; + expiration: number; + }; + downloadLink?: SignedUrlGet; + }; } -export interface StoredObjectCreated { - status: "stored_object_created", - filename: string, - iv: Uint8Array, - keyInfos: object, - type: string, +export interface StoredObjectVersion { + /** + * filename of the object in the object storage + */ + filename: string; + iv: number[]; + keyInfos: JsonWebKey; + type: string; +} + +export interface StoredObjectVersionCreated extends StoredObjectVersion { + persisted: false; +} + +export interface StoredObjectVersionPersisted + extends StoredObjectVersionCreated { + version: number; + id: number; + createdAt: DateTime | null; + createdBy: User | null; } export interface StoredObjectStatusChange { - id: number, - filename: string, - status: StoredObjectStatus, - type: string, + id: number; + filename: string; + status: StoredObjectStatus; + type: string; +} + +export interface StoredObjectVersionWithPointInTime extends StoredObjectVersionPersisted { + "point-in-times": StoredObjectPointInTime[]; + "from-restored": StoredObjectVersionPersisted|null; +} + +export interface StoredObjectPointInTime { + id: number; + byUser: User | null; + reason: 'keep-before-conversion'|'keep-by-user'; } /** * Function executed by the WopiEditButton component. */ export type WopiEditButtonExecutableBeforeLeaveFunction = { - (): Promise -} + (): Promise; +}; /** * Object containing information for performering a POST request to a swift object store */ export interface PostStoreObjectSignature { - method: "POST", - max_file_size: number, - max_file_count: 1, - expires: number, - submit_delay: 180, - redirect: string, - prefix: string, - url: string, - signature: string, + method: "POST"; + max_file_size: number; + max_file_count: 1; + expires: number; + submit_delay: 180; + redirect: string; + prefix: string; + url: string; + signature: string; } +export interface PDFPage { + index: number; + width: number; + height: number; +} +export interface SignatureZone { + index: number | null; + x: number; + y: number; + width: number; + height: number; + PDFPage: PDFPage; +} + +export interface Signature { + id: number; + storedObject: StoredObject; + zones: SignatureZone[]; +} + +export type SignedState = + | "pending" + | "signed" + | "rejected" + | "canceled" + | "error"; + +export interface CheckSignature { + state: SignedState; + storedObject: StoredObject; +} + +export type CanvasEvent = "select" | "add"; + +export interface ZoomLevel { + id: number; + zoom: number; + label: { + fr?: string, + nl?: string + }; +} \ No newline at end of file diff --git a/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/DocumentActionButtonsGroup.vue b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/DocumentActionButtonsGroup.vue index b4c53eacd..9c7dac095 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/DocumentActionButtonsGroup.vue +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/DocumentActionButtonsGroup.vue @@ -1,20 +1,23 @@ + {{ $t('on_hold') }}
@@ -73,7 +74,8 @@ const i18n = { you_subscribed_to_all_steps: "Vous recevrez une notification à chaque étape", you_subscribed_to_final_step: "Vous recevrez une notification à l'étape finale", by: "Par", - at: "Le" + at: "Le", + on_hold: "En attente" } } } @@ -100,11 +102,17 @@ export default { }, getPopContent(step) { if (step.transitionPrevious != null) { - return `
    -
  • ${i18n.messages.fr.by} : ${step.transitionPreviousBy.text}
  • -
  • ${i18n.messages.fr.at} : ${this.formatDate(step.transitionPreviousAt.datetime)}
  • -
` - ; + if (step.transitionPreviousBy !== null) { + return `
    +
  • ${i18n.messages.fr.by} : ${step.transitionPreviousBy.text}
  • +
  • ${i18n.messages.fr.at} : ${this.formatDate(step.transitionPreviousAt.datetime)}
  • +
` + ; + } else { + return `
    +
  • ${i18n.messages.fr.at} : ${this.formatDate(step.transitionPreviousAt.datetime)}
  • +
` + } } }, formatDate(datetime) { diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/EntityWorkflow/ListWorkflowModal.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/EntityWorkflow/ListWorkflowModal.vue index 151bc06c7..ab52b7452 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/EntityWorkflow/ListWorkflowModal.vue +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/EntityWorkflow/ListWorkflowModal.vue @@ -1,20 +1,13 @@ diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/EntityWorkflow/PickWorkflow.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/EntityWorkflow/PickWorkflow.vue index 9ed1ef6cc..3cd88369c 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/EntityWorkflow/PickWorkflow.vue +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/EntityWorkflow/PickWorkflow.vue @@ -1,63 +1,104 @@