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 acd66a42e..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,openstack-integration + - 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/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; + public function __construct( /** * The stored object associated with this version. @@ -77,6 +86,7 @@ class StoredObjectVersion implements TrackCreationInterface ?string $filename = null, ) { $this->filename = $filename ?? self::generateFilename($this); + $this->pointInTimes = new ArrayCollection(); } public static function generateFilename(StoredObjectVersion $storedObjectVersion): string @@ -124,4 +134,40 @@ class StoredObjectVersion implements TrackCreationInterface { return $this->version; } + + /** + * @return Collection&Selectable + */ + public function getPointInTimes(): Selectable&Collection + { + return $this->pointInTimes; + } + + public function hasPointInTimes(): bool + { + return $this->pointInTimes->count() > 0; + } + + /** + * @return $this + * + * @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; + } } 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/StoredObjectVersionRepository.php b/src/Bundle/ChillDocStoreBundle/Repository/StoredObjectVersionRepository.php index 1ab9b9edd..60ea07420 100644 --- a/src/Bundle/ChillDocStoreBundle/Repository/StoredObjectVersionRepository.php +++ b/src/Bundle/ChillDocStoreBundle/Repository/StoredObjectVersionRepository.php @@ -62,7 +62,7 @@ class StoredObjectVersionRepository implements ObjectRepository * * @return iterable returns an iterable with the IDs of the versions */ - public function findIdsByVersionsOlderThanDateAndNotLastVersion(\DateTimeImmutable $beforeDate): iterable + public function findIdsByVersionsOlderThanDateAndNotLastVersionAndNotPointInTime(\DateTimeImmutable $beforeDate): iterable { $results = $this->connection->executeQuery( self::QUERY_FIND_IDS_BY_VERSIONS_OLDER_THAN_DATE_AND_NOT_LAST_VERSION, @@ -83,6 +83,8 @@ class StoredObjectVersionRepository implements ObjectRepository 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 diff --git a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectCleaner/RemoveOldVersionCronJob.php b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectCleaner/RemoveOldVersionCronJob.php index d190a4e45..d2494266b 100644 --- a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectCleaner/RemoveOldVersionCronJob.php +++ b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectCleaner/RemoveOldVersionCronJob.php @@ -50,7 +50,7 @@ final readonly class RemoveOldVersionCronJob implements CronJobInterface $deleteBeforeDate = $this->clock->now()->sub(new \DateInterval(self::KEEP_INTERVAL)); $maxDeleted = $lastExecutionData[self::LAST_DELETED_KEY] ?? 0; - foreach ($this->storedObjectVersionRepository->findIdsByVersionsOlderThanDateAndNotLastVersion($deleteBeforeDate) as $id) { + foreach ($this->storedObjectVersionRepository->findIdsByVersionsOlderThanDateAndNotLastVersionAndNotPointInTime($deleteBeforeDate) as $id) { $this->messageBus->dispatch(new RemoveOldVersionMessage($id)); $maxDeleted = max($maxDeleted, $id); } diff --git a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectCleaner/RemoveOldVersionMessageHandler.php b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectCleaner/RemoveOldVersionMessageHandler.php index 69c9f283d..ea3e37be7 100644 --- a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectCleaner/RemoveOldVersionMessageHandler.php +++ b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectCleaner/RemoveOldVersionMessageHandler.php @@ -18,6 +18,7 @@ use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; use Symfony\Component\Clock\ClockInterface; +use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException; use Symfony\Component\Messenger\Handler\MessageHandlerInterface; /** @@ -49,13 +50,18 @@ final readonly class RemoveOldVersionMessageHandler implements MessageHandlerInt $this->logger->info(self::LOG_PREFIX.'Received one message', ['storedObjectVersionId' => $message->storedObjectVersionId]); $storedObjectVersion = $this->storedObjectVersionRepository->find($message->storedObjectVersionId); - $storedObject = $storedObjectVersion->getStoredObject(); if (null === $storedObjectVersion) { $this->logger->error(self::LOG_PREFIX.'StoredObjectVersion not found in database', ['storedObjectVersionId' => $message->storedObjectVersionId]); throw new \RuntimeException('StoredObjectVersion not found with id '.$message->storedObjectVersionId); } + if ($storedObjectVersion->hasPointInTimes()) { + throw new UnrecoverableMessageHandlingException('the stored object version is now associated with a point in time'); + } + + $storedObject = $storedObjectVersion->getStoredObject(); + $this->storedObjectManager->delete($storedObjectVersion); // to ensure an immediate deletion $this->entityManager->remove($storedObjectVersion); diff --git a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectToPdfConverter.php b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectToPdfConverter.php new file mode 100644 index 000000000..abfcca4cc --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectToPdfConverter.php @@ -0,0 +1,75 @@ +mimeTypes->getMimeTypes($convertTo)[0] ?? null; + + if (null === $newMimeType) { + throw new \UnexpectedValueException(sprintf('could not find a preferred mime type for conversion to %s', $convertTo)); + } + + $currentVersion = $storedObject->getCurrentVersion(); + + if ($currentVersion->getType() === $newMimeType) { + throw new \UnexpectedValueException('Already at the same mime type'); + } + + $content = $this->storedObjectManager->read($currentVersion); + + try { + $converted = $this->wopiConverter->convert($lang, $content, $newMimeType, $convertTo); + } catch (\RuntimeException $e) { + throw new \RuntimeException('could not store a new version for document', previous: $e); + } + + $pointInTime = new StoredObjectPointInTime($currentVersion, StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION); + $version = $this->storedObjectManager->write($storedObject, $converted, $newMimeType); + + return [$pointInTime, $version]; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Service/Repository/StoredObjectVersionRepositoryTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Service/Repository/StoredObjectVersionRepositoryTest.php index ace122bea..4b134a1fb 100644 --- a/src/Bundle/ChillDocStoreBundle/Tests/Service/Repository/StoredObjectVersionRepositoryTest.php +++ b/src/Bundle/ChillDocStoreBundle/Tests/Service/Repository/StoredObjectVersionRepositoryTest.php @@ -35,7 +35,7 @@ class StoredObjectVersionRepositoryTest extends KernelTestCase $repository = new StoredObjectVersionRepository($this->entityManager); // get old version, to get a chance to get one - $actual = $repository->findIdsByVersionsOlderThanDateAndNotLastVersion(new \DateTimeImmutable('1970-01-01')); + $actual = $repository->findIdsByVersionsOlderThanDateAndNotLastVersionAndNotPointInTime(new \DateTimeImmutable('1970-01-01')); self::assertIsIterable($actual); self::assertContainsOnly('int', $actual); diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectCleaner/RemoveOldVersionCronJobTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectCleaner/RemoveOldVersionCronJobTest.php index 937253c81..df75eea93 100644 --- a/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectCleaner/RemoveOldVersionCronJobTest.php +++ b/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectCleaner/RemoveOldVersionCronJobTest.php @@ -46,7 +46,7 @@ class RemoveOldVersionCronJobTest extends KernelTestCase $clock = new MockClock(new \DateTimeImmutable('2024-01-01 00:00:00', new \DateTimeZone('+00:00'))); $repository = $this->createMock(StoredObjectVersionRepository::class); $repository->expects($this->once()) - ->method('findIdsByVersionsOlderThanDateAndNotLastVersion') + ->method('findIdsByVersionsOlderThanDateAndNotLastVersionAndNotPointInTime') ->with(new \DateTime('2023-10-03 00:00:00', new \DateTimeZone('+00:00'))) ->willReturnCallback(function ($arg) { yield 1; diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectToPdfConverterTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectToPdfConverterTest.php new file mode 100644 index 000000000..da3ed9210 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectToPdfConverterTest.php @@ -0,0 +1,61 @@ +registerVersion(type: 'text/html'); + + $storedObjectManager = $this->prophesize(StoredObjectManagerInterface::class); + $storedObjectManager->read($currentVersion)->willReturn('1234'); + $storedObjectManager->write($storedObject, '5678', 'application/pdf')->shouldBeCalled() + ->will(function ($args) { + /** @var StoredObject $storedObject */ + $storedObject = $args[0]; + + return $storedObject->registerVersion(type: $args[2]); + }); + + $converter = $this->prophesize(WopiConverter::class); + $converter->convert('fr', '1234', 'application/pdf', 'pdf')->shouldBeCalled() + ->willReturn('5678'); + + $converter = new StoredObjectToPdfConverter($storedObjectManager->reveal(), $converter->reveal(), MimeTypes::getDefault()); + + $actual = $converter->addConvertedVersion($storedObject, 'fr'); + + self::assertIsArray($actual); + self::assertInstanceOf(StoredObjectPointInTime::class, $actual[0]); + self::assertSame($currentVersion, $actual[0]->getObjectVersion()); + self::assertInstanceOf(StoredObjectVersion::class, $actual[1]); + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Workflow/ConvertToPdfBeforeSignatureStepEventSubscriberTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Workflow/ConvertToPdfBeforeSignatureStepEventSubscriberTest.php new file mode 100644 index 000000000..572cb192c --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Tests/Workflow/ConvertToPdfBeforeSignatureStepEventSubscriberTest.php @@ -0,0 +1,208 @@ +registerVersion(); + + $converter = $this->prophesize(StoredObjectToPdfConverter::class); + $converter->addConvertedVersion($storedObject, 'fr', 'pdf') + ->shouldBeCalledOnce() + ->will(function ($args) { + /** @var StoredObject $storedObject */ + $storedObject = $args[0]; + + $pointInTime = new StoredObjectPointInTime($storedObject->getCurrentVersion(), StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION); + $newVersion = $storedObject->registerVersion(filename: 'next'); + + return [$pointInTime, $newVersion]; + }); + + $entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class); + $entityWorkflowManager->getAssociatedStoredObject($entityWorkflow)->willReturn($storedObject); + + $request = new Request(); + $request->setLocale('fr'); + $stack = new RequestStack(); + $stack->push($request); + + $eventSubscriber = new ConvertToPdfBeforeSignatureStepEventSubscriber($entityWorkflowManager->reveal(), $converter->reveal(), $stack); + + $registry = $this->buildRegistry($eventSubscriber); + $workflow = $registry->get($entityWorkflow, 'dummy'); + + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $workflow->apply($entityWorkflow, 'to_signature', ['context' => $dto, 'transition' => 'to_signature', 'transitionAt' => new \DateTimeImmutable('now')]); + + self::assertEquals('signature', $entityWorkflow->getStep()); + self::assertNotSame($previousVersion, $storedObject->getCurrentVersion()); + self::assertTrue($previousVersion->hasPointInTimes()); + self::assertCount(2, $storedObject->getVersions()); + self::assertEquals('next', $storedObject->getCurrentVersion()->getFilename()); + } + + public function testConvertToPdfBeforeSignatureStepEventSubscriberToNotASignatureStep(): void + { + $entityWorkflow = new EntityWorkflow(); + $storedObject = new StoredObject(); + $previousVersion = $storedObject->registerVersion(); + + $converter = $this->prophesize(StoredObjectToPdfConverter::class); + $converter->addConvertedVersion($storedObject, 'fr', 'pdf') + ->shouldNotBeCalled(); + + $entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class); + $entityWorkflowManager->getAssociatedStoredObject($entityWorkflow)->willReturn($storedObject); + + $request = new Request(); + $request->setLocale('fr'); + $stack = new RequestStack(); + $stack->push($request); + + $eventSubscriber = new ConvertToPdfBeforeSignatureStepEventSubscriber($entityWorkflowManager->reveal(), $converter->reveal(), $stack); + + $registry = $this->buildRegistry($eventSubscriber); + $workflow = $registry->get($entityWorkflow, 'dummy'); + + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $workflow->apply($entityWorkflow, 'to_something', ['context' => $dto, 'transition' => 'to_something', 'transitionAt' => new \DateTimeImmutable('now')]); + + self::assertEquals('something', $entityWorkflow->getStep()); + self::assertSame($previousVersion, $storedObject->getCurrentVersion()); + self::assertFalse($previousVersion->hasPointInTimes()); + self::assertCount(1, $storedObject->getVersions()); + } + + public function testConvertToPdfBeforeSignatureStepEventSubscriberToSignatureAlreadyAPdf(): void + { + $entityWorkflow = new EntityWorkflow(); + $storedObject = new StoredObject(); + $previousVersion = $storedObject->registerVersion(type: 'application/pdf'); + + $converter = $this->prophesize(StoredObjectToPdfConverter::class); + $converter->addConvertedVersion($storedObject, 'fr', 'pdf') + ->shouldNotBeCalled(); + + $entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class); + $entityWorkflowManager->getAssociatedStoredObject($entityWorkflow)->willReturn($storedObject); + + $request = new Request(); + $request->setLocale('fr'); + $stack = new RequestStack(); + $stack->push($request); + + $eventSubscriber = new ConvertToPdfBeforeSignatureStepEventSubscriber($entityWorkflowManager->reveal(), $converter->reveal(), $stack); + + $registry = $this->buildRegistry($eventSubscriber); + $workflow = $registry->get($entityWorkflow, 'dummy'); + + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $workflow->apply($entityWorkflow, 'to_signature', ['context' => $dto, 'transition' => 'to_signature', 'transitionAt' => new \DateTimeImmutable('now')]); + + self::assertEquals('signature', $entityWorkflow->getStep()); + self::assertSame($previousVersion, $storedObject->getCurrentVersion()); + self::assertFalse($previousVersion->hasPointInTimes()); + self::assertCount(1, $storedObject->getVersions()); + } + + public function testConvertToPdfBeforeSignatureStepEventSubscriberToSignatureWithNoStoredObject(): void + { + $entityWorkflow = new EntityWorkflow(); + + $converter = $this->prophesize(StoredObjectToPdfConverter::class); + $converter->addConvertedVersion(Argument::type(StoredObject::class), 'fr', 'pdf') + ->shouldNotBeCalled(); + + $entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class); + $entityWorkflowManager->getAssociatedStoredObject($entityWorkflow)->willReturn(null); + + $request = new Request(); + $request->setLocale('fr'); + $stack = new RequestStack(); + $stack->push($request); + + $eventSubscriber = new ConvertToPdfBeforeSignatureStepEventSubscriber($entityWorkflowManager->reveal(), $converter->reveal(), $stack); + + $registry = $this->buildRegistry($eventSubscriber); + $workflow = $registry->get($entityWorkflow, 'dummy'); + + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $workflow->apply($entityWorkflow, 'to_signature', ['context' => $dto, 'transition' => 'to_signature', 'transitionAt' => new \DateTimeImmutable('now')]); + + self::assertEquals('signature', $entityWorkflow->getStep()); + } + + private function buildRegistry(EventSubscriberInterface $eventSubscriber): Registry + { + $builder = new DefinitionBuilder(); + $builder + ->setInitialPlaces('initial') + ->addPlaces(['initial', 'signature', 'something']) + ->addTransition(new Transition('to_something', 'initial', 'something')) + ->addTransition(new Transition('to_signature', 'initial', 'signature')); + + $metadataStore = new InMemoryMetadataStore([], ['signature' => ['isSignature' => ['user']]]); + $builder->setMetadataStore($metadataStore); + + $eventDispatcher = new EventDispatcher(); + $eventDispatcher->addSubscriber($eventSubscriber); + + $workflow = new Workflow($builder->build(), new EntityWorkflowMarkingStore(), $eventDispatcher, 'dummy'); + + $supports = new class () implements WorkflowSupportStrategyInterface { + public function supports(WorkflowInterface $workflow, object $subject): bool + { + return true; + } + }; + + $registry = new Registry(); + $registry->addWorkflow($workflow, $supports); + + return $registry; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Workflow/ConvertToPdfBeforeSignatureStepEventSubscriber.php b/src/Bundle/ChillDocStoreBundle/Workflow/ConvertToPdfBeforeSignatureStepEventSubscriber.php new file mode 100644 index 000000000..0d5fe9323 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Workflow/ConvertToPdfBeforeSignatureStepEventSubscriber.php @@ -0,0 +1,75 @@ + 'convertToPdfBeforeSignatureStepEvent', + ]; + } + + public function convertToPdfBeforeSignatureStepEvent(CompletedEvent $event): void + { + $entityWorkflow = $event->getSubject(); + if (!$entityWorkflow instanceof EntityWorkflow) { + return; + } + + $tos = $event->getTransition()->getTos(); + $workflow = $event->getWorkflow(); + $metadataStore = $workflow->getMetadataStore(); + + foreach ($tos as $to) { + $metadata = $metadataStore->getPlaceMetadata($to); + if (array_key_exists('isSignature', $metadata) && 0 < count($metadata['isSignature'])) { + $this->convertToPdf($entityWorkflow); + + return; + } + } + } + + private function convertToPdf(EntityWorkflow $entityWorkflow): void + { + $storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($entityWorkflow); + + if (null === $storedObject) { + return; + } + + if ('application/pdf' === $storedObject->getCurrentVersion()->getType()) { + return; + } + + $this->storedObjectToPdfConverter->addConvertedVersion($storedObject, $this->requestStack->getCurrentRequest()->getLocale(), 'pdf'); + } +} diff --git a/src/Bundle/ChillDocStoreBundle/migrations/Version20240910093735.php b/src/Bundle/ChillDocStoreBundle/migrations/Version20240910093735.php new file mode 100644 index 000000000..21906b168 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/migrations/Version20240910093735.php @@ -0,0 +1,49 @@ +addSql('CREATE SEQUENCE chill_doc.stored_object_point_in_time_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE chill_doc.stored_object_point_in_time (id INT NOT NULL, stored_object_version_id INT NOT NULL, reason TEXT NOT NULL, createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, byUser_id INT DEFAULT NULL, createdBy_id INT DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_CC83C7B81D0AB8B9 ON chill_doc.stored_object_point_in_time (stored_object_version_id)'); + $this->addSql('CREATE INDEX IDX_CC83C7B8D23C0240 ON chill_doc.stored_object_point_in_time (byUser_id)'); + $this->addSql('CREATE INDEX IDX_CC83C7B83174800F ON chill_doc.stored_object_point_in_time (createdBy_id)'); + $this->addSql('COMMENT ON COLUMN chill_doc.stored_object_point_in_time.createdAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE chill_doc.stored_object_point_in_time ADD CONSTRAINT FK_CC83C7B81D0AB8B9 FOREIGN KEY (stored_object_version_id) REFERENCES chill_doc.stored_object_version (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_doc.stored_object_point_in_time ADD CONSTRAINT FK_CC83C7B8D23C0240 FOREIGN KEY (byUser_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_doc.stored_object_point_in_time ADD CONSTRAINT FK_CC83C7B83174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_doc.stored_object ALTER prefix SET DEFAULT \'\''); + $this->addSql('ALTER TABLE chill_doc.stored_object_version ALTER filename SET DEFAULT \'\''); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP SEQUENCE chill_doc.stored_object_point_in_time_id_seq CASCADE'); + $this->addSql('ALTER TABLE chill_doc.stored_object_point_in_time DROP CONSTRAINT FK_CC83C7B81D0AB8B9'); + $this->addSql('ALTER TABLE chill_doc.stored_object_point_in_time DROP CONSTRAINT FK_CC83C7B8D23C0240'); + $this->addSql('ALTER TABLE chill_doc.stored_object_point_in_time DROP CONSTRAINT FK_CC83C7B83174800F'); + $this->addSql('DROP TABLE chill_doc.stored_object_point_in_time'); + $this->addSql('ALTER TABLE chill_doc.stored_object ALTER prefix DROP DEFAULT'); + $this->addSql('ALTER TABLE chill_doc.stored_object_version ALTER filename DROP DEFAULT'); + } +} diff --git a/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStepSignature.php b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStepSignature.php index 527ede0ef..7f6c5cdda 100644 --- a/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStepSignature.php +++ b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStepSignature.php @@ -140,4 +140,16 @@ class EntityWorkflowStepSignature implements TrackCreationInterface, TrackUpdate { return EntityWorkflowSignatureStateEnum::SIGNED == $this->getState(); } + + /** + * @return 'person'|'user' + */ + public function getSignerKind(): string + { + if ($this->personSigner instanceof Person) { + return 'person'; + } + + return 'user'; + } } diff --git a/src/Bundle/ChillMainBundle/Resources/views/Workflow/_signature.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Workflow/_signature.html.twig index 68b6f4274..50172089c 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Workflow/_signature.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Workflow/_signature.html.twig @@ -4,12 +4,20 @@ {% for s in signatures %}
- {% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with { - action: 'show', displayBadge: true, - targetEntity: { name: 'person', id: s.signer.id }, - buttonText: s.signer|chill_entity_render_string, - isDead: s.signer.deathDate is not null - } %} + {% if s.signerKind == 'person' %} + {% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with { + action: 'show', displayBadge: true, + targetEntity: { name: 'person', id: s.signer.id }, + buttonText: s.signer|chill_entity_render_string, + isDead: s.signer.deathDate is not null + } %} + {% else %} + {% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with { + action: 'show', displayBadge: true, + targetEntity: { name: 'user', id: s.signer.id }, + buttonText: s.signer|chill_entity_render_string, + } %} + {% endif %}
{% if s.isSigned %} diff --git a/src/Bundle/ChillWopiBundle/src/Controller/ConvertController.php b/src/Bundle/ChillWopiBundle/src/Controller/ConvertController.php index 73207e13a..86a624e4b 100644 --- a/src/Bundle/ChillWopiBundle/src/Controller/ConvertController.php +++ b/src/Bundle/ChillWopiBundle/src/Controller/ConvertController.php @@ -14,84 +14,44 @@ namespace Chill\WopiBundle\Controller; use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Service\StoredObjectManager; use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; -use Chill\MainBundle\Entity\User; +use Chill\WopiBundle\Service\WopiConverter; use Psr\Log\LoggerInterface; -use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; -use Symfony\Component\HttpFoundation\JsonResponse; -use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; -use Symfony\Component\Mime\Part\DataPart; -use Symfony\Component\Mime\Part\Multipart\FormDataPart; use Symfony\Component\Security\Core\Security; -use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; -use Symfony\Contracts\HttpClient\HttpClientInterface; -use Symfony\Contracts\HttpClient\ResponseInterface; class ConvertController { private const LOG_PREFIX = '[convert] '; - private readonly string $collaboraDomain; - /** * @param StoredObjectManager $storedObjectManager */ public function __construct( - private readonly HttpClientInterface $httpClient, - private readonly RequestStack $requestStack, private readonly Security $security, private readonly StoredObjectManagerInterface $storedObjectManager, + private readonly WopiConverter $wopiConverter, private readonly LoggerInterface $logger, - ParameterBagInterface $parameters, - ) { - $this->collaboraDomain = $parameters->get('wopi')['server']; - } + ) {} - public function __invoke(StoredObject $storedObject): Response + public function __invoke(StoredObject $storedObject, Request $request): Response { - if (!$this->security->getUser() instanceof User) { + if (!($this->security->isGranted('ROLE_USER') || $this->security->isGranted('ROLE_ADMIN'))) { throw new AccessDeniedHttpException('User must be authenticated'); } $content = $this->storedObjectManager->read($storedObject); - $query = []; - if (null !== $request = $this->requestStack->getCurrentRequest()) { - $query['lang'] = $request->getLocale(); - } + $lang = $request->getLocale(); try { - $url = sprintf('%s/cool/convert-to/pdf', $this->collaboraDomain); - $form = new FormDataPart([ - 'data' => new DataPart($content, $storedObject->getUuid()->toString(), $storedObject->getType()), - ]); - $response = $this->httpClient->request('POST', $url, [ - 'headers' => $form->getPreparedHeaders()->toArray(), - 'query' => $query, - 'body' => $form->bodyToString(), - 'timeout' => 10, - ]); - - return new Response($response->getContent(), Response::HTTP_OK, [ + return new Response($this->wopiConverter->convert($lang, $content, $storedObject->getType()), Response::HTTP_OK, [ 'Content-Type' => 'application/pdf', ]); - } catch (ClientExceptionInterface|RedirectionExceptionInterface|ServerExceptionInterface|TransportExceptionInterface $exception) { - return $this->onConversionFailed($url, $exception->getResponse()); + } catch (\RuntimeException $exception) { + $this->logger->alert(self::LOG_PREFIX.'Could not convert document', ['message' => $exception->getMessage(), 'exception', $exception->getTraceAsString()]); + + return new Response('convert server not available', Response::HTTP_SERVICE_UNAVAILABLE); } } - - private function onConversionFailed(string $url, ResponseInterface $response): JsonResponse - { - $this->logger->error(self::LOG_PREFIX.' could not convert document', [ - 'response_status' => $response->getStatusCode(), - 'message' => $response->getContent(false), - 'server' => $this->collaboraDomain, - 'url' => $url, - ]); - - return new JsonResponse(['message' => 'conversion failed : '.$response->getContent(false)], Response::HTTP_SERVICE_UNAVAILABLE); - } } diff --git a/src/Bundle/ChillWopiBundle/src/Service/WopiConverter.php b/src/Bundle/ChillWopiBundle/src/Service/WopiConverter.php new file mode 100644 index 000000000..ded9b3188 --- /dev/null +++ b/src/Bundle/ChillWopiBundle/src/Service/WopiConverter.php @@ -0,0 +1,69 @@ +collaboraDomain = $parameters->get('wopi')['server']; + } + + public function convert(string $lang, string $content, string $contentType, $convertTo = 'pdf'): string + { + try { + $url = sprintf('%s/cool/convert-to/%s', $this->collaboraDomain, $convertTo); + + $form = new FormDataPart([ + 'data' => new DataPart($content, uniqid('temp-file-'), contentType: $contentType), + ]); + $response = $this->httpClient->request('POST', $url, [ + 'headers' => $form->getPreparedHeaders()->toArray(), + 'query' => ['lang' => $lang], + 'body' => $form->bodyToString(), + 'timeout' => 10, + ]); + + if (200 === $response->getStatusCode()) { + $this->logger->info(self::LOG_PREFIX.'document converted successfully', ['size' => strlen($content)]); + } + + return $response->getContent(); + } catch (ClientExceptionInterface $e) { + throw new \LogicException('no correct request to collabora online', previous: $e); + } catch (RedirectionExceptionInterface $e) { + throw new \RuntimeException('no redirection expected', previous: $e); + } catch (ServerExceptionInterface|TransportExceptionInterface $e) { + throw new \RuntimeException('error while converting document', previous: $e); + } + } +} diff --git a/src/Bundle/ChillWopiBundle/tests/Controller/ConvertControllerTest.php b/src/Bundle/ChillWopiBundle/tests/Controller/ConvertControllerTest.php index 0a928bcd9..9a529df6b 100644 --- a/src/Bundle/ChillWopiBundle/tests/Controller/ConvertControllerTest.php +++ b/src/Bundle/ChillWopiBundle/tests/Controller/ConvertControllerTest.php @@ -13,16 +13,12 @@ namespace Chill\WopiBundle\Tests\Controller; use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; -use Chill\MainBundle\Entity\User; use Chill\WopiBundle\Controller\ConvertController; +use Chill\WopiBundle\Service\WopiConverter; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; use Psr\Log\NullLogger; -use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; -use Symfony\Component\HttpClient\MockHttpClient; -use Symfony\Component\HttpClient\Response\MockResponse; use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Security\Core\Security; /** @@ -39,28 +35,27 @@ final class ConvertControllerTest extends TestCase $storedObject = new StoredObject(); $storedObject->registerVersion(type: 'application/vnd.oasis.opendocument.text'); - $httpClient = new MockHttpClient([ - new MockResponse('not authorized', ['http_code' => 401]), - ], 'http://collabora:9980'); - $security = $this->prophesize(Security::class); - $security->getUser()->willReturn(new User()); + $security->isGranted('ROLE_USER')->willReturn(true); $storeManager = $this->prophesize(StoredObjectManagerInterface::class); $storeManager->read($storedObject)->willReturn('content'); - $parameterBag = new ParameterBag(['wopi' => ['server' => 'http://collabora:9980']]); + $wopiConverter = $this->prophesize(WopiConverter::class); + $wopiConverter->convert('fr', 'content', 'application/vnd.oasis.opendocument.text') + ->willThrow(new \RuntimeException()); - $convert = new ConvertController( - $httpClient, - $this->makeRequestStack(), + $controller = new ConvertController( $security->reveal(), $storeManager->reveal(), + $wopiConverter->reveal(), new NullLogger(), - $parameterBag ); - $response = $convert($storedObject); + $request = new Request(); + $request->setLocale('fr'); + + $response = $controller($storedObject, $request); $this->assertNotEquals(200, $response->getStatusCode()); } @@ -70,38 +65,29 @@ final class ConvertControllerTest extends TestCase $storedObject = new StoredObject(); $storedObject->registerVersion(type: 'application/vnd.oasis.opendocument.text'); - $httpClient = new MockHttpClient([ - new MockResponse('1234', ['http_code' => 200]), - ], 'http://collabora:9980'); - $security = $this->prophesize(Security::class); - $security->getUser()->willReturn(new User()); + $security->isGranted('ROLE_USER')->willReturn(true); $storeManager = $this->prophesize(StoredObjectManagerInterface::class); $storeManager->read($storedObject)->willReturn('content'); - $parameterBag = new ParameterBag(['wopi' => ['server' => 'http://collabora:9980']]); + $wopiConverter = $this->prophesize(WopiConverter::class); + $wopiConverter->convert('fr', 'content', 'application/vnd.oasis.opendocument.text') + ->willReturn('1234'); - $convert = new ConvertController( - $httpClient, - $this->makeRequestStack(), + $controller = new ConvertController( $security->reveal(), $storeManager->reveal(), + $wopiConverter->reveal(), new NullLogger(), - $parameterBag ); - $response = $convert($storedObject); + $request = new Request(); + $request->setLocale('fr'); + + $response = $controller($storedObject, $request); $this->assertEquals(200, $response->getStatusCode()); $this->assertEquals('1234', $response->getContent()); } - - private function makeRequestStack(): RequestStack - { - $requestStack = new RequestStack(); - $requestStack->push(new Request()); - - return $requestStack; - } } diff --git a/src/Bundle/ChillWopiBundle/tests/Service/WopiConvertToPdfTest.php b/src/Bundle/ChillWopiBundle/tests/Service/WopiConvertToPdfTest.php new file mode 100644 index 000000000..317932ea5 --- /dev/null +++ b/src/Bundle/ChillWopiBundle/tests/Service/WopiConvertToPdfTest.php @@ -0,0 +1,63 @@ + ['server' => $_ENV['EDITOR_SERVER']], + ]); + + $converter = new WopiConverter($client, new NullLogger(), $parameters); + + $actual = $converter->convert('fr', $content, 'application/vnd.oasis.opendocument.text'); + + self::assertIsString($actual); + } + + public function testConvertToPdfWithMock(): void + { + $httpClient = new MockHttpClient([ + new MockResponse('1234', ['http_code' => 200]), + ], 'http://collabora:9980'); + $parameters = new ParameterBag([ + 'wopi' => ['server' => 'http://collabora:9980'], + ]); + + $converter = new WopiConverter($httpClient, new NullLogger(), $parameters); + + $actual = $converter->convert('fr', 'content-string', 'application/vnd.oasis.opendocument.text'); + + self::assertEquals('1234', $actual); + } +} diff --git a/src/Bundle/ChillWopiBundle/tests/Service/fixtures/test-document.odt b/src/Bundle/ChillWopiBundle/tests/Service/fixtures/test-document.odt new file mode 100644 index 000000000..b6f644408 Binary files /dev/null and b/src/Bundle/ChillWopiBundle/tests/Service/fixtures/test-document.odt differ diff --git a/tests/app/config/packages/wopi.yaml b/tests/app/config/packages/wopi.yaml new file mode 100644 index 000000000..e02694625 --- /dev/null +++ b/tests/app/config/packages/wopi.yaml @@ -0,0 +1,2 @@ +wopi: + server: "%env(resolve:EDITOR_SERVER)%"