From ff9e4f270981b029fb7101ffedbc243ce9bbf072 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Thu, 2 Apr 2026 14:36:13 +0200 Subject: [PATCH] Implements controllers for locking and unlocking with the webdav protocol (wip) --- .../Controller/WebdavController.php | 2 +- .../Controller/WebdavLockController.php | 92 ++++++++ .../Dav/Utils/LockTokenParser.php | 43 ++++ .../Entity/StoredObjectLock.php | 10 + .../Resources/views/Webdav/doc_lock.xml.twig | 18 ++ .../Resources/views/Webdav/doc_props.xml.twig | 6 + .../Lock/Exception/NoLockFoundException.php | 20 ++ .../Service/Lock/StoredObjectLockManager.php | 144 +++++++++++++ .../Lock/StoredObjectLockManagerTest.php | 200 ++++++++++++++++++ .../ChillDocStoreBundle/config/services.yaml | 9 + 10 files changed, 543 insertions(+), 1 deletion(-) create mode 100644 src/Bundle/ChillDocStoreBundle/Controller/WebdavLockController.php create mode 100644 src/Bundle/ChillDocStoreBundle/Dav/Utils/LockTokenParser.php create mode 100644 src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/doc_lock.xml.twig create mode 100644 src/Bundle/ChillDocStoreBundle/Service/Lock/Exception/NoLockFoundException.php create mode 100644 src/Bundle/ChillDocStoreBundle/Service/Lock/StoredObjectLockManager.php create mode 100644 src/Bundle/ChillDocStoreBundle/Tests/Service/Lock/StoredObjectLockManagerTest.php diff --git a/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php b/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php index 022d544cb..d289a0131 100644 --- a/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php +++ b/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php @@ -75,7 +75,7 @@ final readonly class WebdavController ; // $response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT,PROPPATCH,COPY,MOVE,REPORT,PATCH,POST,TRACE']); - $response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT']); + $response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT,LOCK,UNLOCK']); return $response; } diff --git a/src/Bundle/ChillDocStoreBundle/Controller/WebdavLockController.php b/src/Bundle/ChillDocStoreBundle/Controller/WebdavLockController.php new file mode 100644 index 000000000..cc1e53cf0 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Controller/WebdavLockController.php @@ -0,0 +1,92 @@ +headers->get('Timeout', 'Second-3600'); + $timeoutInterval = $this->lockTimeoutAnalyzer->analyzeTimeout($timeout); + + $user = $this->security->getUser(); + + $users = $user instanceof User ? [$user] : []; + + $lock = $this->lockManager->setLock( + $storedObject, + StoredObjectLockMethodEnum::WEBDAV, + expiresAt: $this->clock->now()->add($timeoutInterval), + users: $users + ); + + $content = $this->twig->render('@ChillDocStore/Webdav/doc_lock.xml.twig', [ + 'lock' => $lock, + 'timeout' => $timeout, + ]); + + return (new DavResponse($content))->setLockToken($lock->getToken()); + } + + #[Route(path: '/dav/{access_token}/get/{uuid}/d', name: 'chill_docstore_dav_document_unlock', methods: ['UNLOCK'])] + public function unlockDocument(StoredObject $storedObject, Request $request): Response + { + $lockToken = $this->lockTokenParser->parseLockToken($request); + + if (null === $lockToken) { + throw new BadRequestHttpException('LockToken not found'); + } + + $check = $this->lockManager->checkLock($storedObject, $lockToken, $this->security->getUser()); + + if (true === $check) { + $this->lockManager->deleteLock($storedObject, $this->clock->now()->add(new \DateInterval('PT3S'))); + + return new DavResponse(null, status: Response::HTTP_NO_CONTENT); + } + $e = match ($check) { + LockTokenCheckResultEnum::LOCK_TOKEN_DO_NOT_MATCH, LockTokenCheckResultEnum::LOCK_TOKEN_DO_NOT_BELONG_TO_USER => new ConflictHttpException(), + LockTokenCheckResultEnum::NO_LOCK_FOUND => new PreconditionFailedHttpException(), + }; + + throw $e; + + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Dav/Utils/LockTokenParser.php b/src/Bundle/ChillDocStoreBundle/Dav/Utils/LockTokenParser.php new file mode 100644 index 000000000..a254eff9a --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Dav/Utils/LockTokenParser.php @@ -0,0 +1,43 @@ +headers->get('lock-token'); + + if (null === $token) { + return null; + } + if (str_starts_with($token, '"')) { + $token = substr($token, 1, -1); + } + + if (str_starts_with($token, '<')) { + $token = substr($token, 1); + } + + if (str_ends_with($token, '>')) { + $token = substr($token, 0, -1); + } + + if (str_ends_with($token, '"')) { + $token = substr($token, 1, -1); + } + + return $token; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Entity/StoredObjectLock.php b/src/Bundle/ChillDocStoreBundle/Entity/StoredObjectLock.php index 2f7a3fcf7..d65ed2cad 100644 --- a/src/Bundle/ChillDocStoreBundle/Entity/StoredObjectLock.php +++ b/src/Bundle/ChillDocStoreBundle/Entity/StoredObjectLock.php @@ -129,4 +129,14 @@ class StoredObjectLock { return null === $this->getExpireAt() || $at < $this->getExpireAt(); } + + /** + * Return true if the lock token is exclusive. + * + * Currently, this is linked to the webdav method. + */ + public function isExclusive(): bool + { + return StoredObjectLockMethodEnum::WEBDAV === $this->getMethod(); + } } diff --git a/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/doc_lock.xml.twig b/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/doc_lock.xml.twig new file mode 100644 index 000000000..dd4a84b0e --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/doc_lock.xml.twig @@ -0,0 +1,18 @@ + + + + + + + infinity + {% set user = lock.users.first %} + {% if user is not same as null %} + {{ lock.users.first }} + {% endif %} + {{ timeout }} + + {{ lock.token }} + + + + diff --git a/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/doc_props.xml.twig b/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/doc_props.xml.twig index 7cde5a5de..2535b28b9 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/doc_props.xml.twig +++ b/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/doc_props.xml.twig @@ -5,6 +5,12 @@ {% if properties.lastModified or properties.contentLength or properties.resourceType or properties.etag or properties.contentType or properties.creationDate %} + + + + + + {% if properties.resourceType %} {% endif %} diff --git a/src/Bundle/ChillDocStoreBundle/Service/Lock/Exception/NoLockFoundException.php b/src/Bundle/ChillDocStoreBundle/Service/Lock/Exception/NoLockFoundException.php new file mode 100644 index 000000000..3fd746e17 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Service/Lock/Exception/NoLockFoundException.php @@ -0,0 +1,20 @@ +getLocks() as $lock) { + if ($lock->isActiveAt($this->clock->now())) { + $lock->setExpireAt($expiresAt); + $this->mustFlush = true; + } + } + + return true; + } + + public function getLock(StoredObject $document): StoredObjectLock + { + foreach ($document->getLocks() as $lock) { + if ($lock->isActiveAt($this->clock->now())) { + return $lock; + } + } + + throw new NoLockFoundException(); + } + + public function hasLock(StoredObject $document): bool + { + foreach ($document->getLocks() as $lock) { + if ($lock->isActiveAt($this->clock->now())) { + return true; + } + } + + return false; + } + + public function setLock( + StoredObject $document, + StoredObjectLockMethodEnum $method, + ?string $lockId = null, + ?\DateTimeImmutable $expiresAt = null, + array $users = [], + ): StoredObjectLock { + if (null === $lockId) { + $lockId = 'opaquelocktoken:'.Uuid::uuid4(); + } + + if (null === $expiresAt) { + $expiresAt = $this->clock->now()->add(new \DateInterval('PT60M')); + } + + if ($document->isLockedAt($this->clock->now())) { + foreach ($document->getLocks() as $lock) { + if ($lock->isActiveAt($this->clock->now())) { + $lock->setToken($lockId); + $lock->setExpireAt($expiresAt); + foreach ($users as $user) { + $lock->addUser($user); + } + + $this->mustFlush = true; + + return $lock; + } + } + } + + // there is no lock yet, we must create one + $lock = new StoredObjectLock( + $document, + method: $method, + createdAt: $this->clock->now(), + token: $lockId, + expireAt: $expiresAt, + ); + + foreach ($users as $user) { + $lock->addUser($user); + } + + $this->entityManager->persist($lock); + $this->mustFlush = true; + + return $lock; + } + + public function checkLock(StoredObject $storedObject, string $lockId, ?UserInterface $byUser = null): true|LockTokenCheckResultEnum + { + if (!$this->hasLock($storedObject)) { + return LockTokenCheckResultEnum::NO_LOCK_FOUND; + } + + $lock = $this->getLock($storedObject); + + if ($lockId !== $lock->getToken()) { + return LockTokenCheckResultEnum::LOCK_TOKEN_DO_NOT_MATCH; + } + + return true; + } + + public static function getSubscribedEvents(): array + { + return [TerminateEvent::class => 'onKernelTerminate']; + } + + public function onKernelTerminate(TerminateEvent $event): void + { + if ($this->mustFlush) { + $this->entityManager->flush(); + } + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Service/Lock/StoredObjectLockManagerTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Service/Lock/StoredObjectLockManagerTest.php new file mode 100644 index 000000000..4dc7ce13a --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Tests/Service/Lock/StoredObjectLockManagerTest.php @@ -0,0 +1,200 @@ + + */ + private ObjectProphecy $entityManager; + private StoredObjectLockManager $manager; + + protected function setUp(): void + { + $this->clock = new MockClock(); + $this->entityManager = $this->prophesize(EntityManagerInterface::class); + $this->manager = new StoredObjectLockManager( + $this->entityManager->reveal(), + $this->clock + ); + } + + public function testHasLockNoLock(): void + { + $document = new StoredObject(); + $this->assertFalse($this->manager->hasLock($document)); + } + + public function testSetLockNew(): void + { + $document = new StoredObject(); + $method = StoredObjectLockMethodEnum::WEBDAV; + $lockId = 'test-lock-id'; + $expiresAt = $this->clock->now()->add(new \DateInterval('PT30M')); + $user = new User(); + + $this->entityManager->persist(Argument::type(StoredObjectLock::class))->shouldBeCalled(); + + $result = $this->manager->setLock($document, $method, $lockId, $expiresAt, [$user]); + + $this->assertInstanceOf(StoredObjectLock::class, $result); + $this->assertTrue($this->manager->hasLock($document)); + + $lock = $this->manager->getLock($document); + $this->assertSame($document, $lock->getStoredObject()); + $this->assertSame($method, $lock->getMethod()); + $this->assertSame($lockId, $lock->getToken()); + $this->assertSame($expiresAt, $lock->getExpireAt()); + $this->assertContains($user, $lock->getUsers()); + } + + public function testSetLockDefaultValues(): void + { + $document = new StoredObject(); + $method = StoredObjectLockMethodEnum::WOPI; + + $this->entityManager->persist(Argument::type(StoredObjectLock::class))->shouldBeCalled(); + + $result = $this->manager->setLock($document, $method); + + $this->assertInstanceOf(StoredObjectLock::class, $result); + $lock = $this->manager->getLock($document); + $this->assertNotEmpty($lock->getToken()); + $this->assertEquals($this->clock->now()->add(new \DateInterval('PT60M')), $lock->getExpireAt()); + } + + public function testHasLockActive(): void + { + $document = new StoredObject(); + new StoredObjectLock($document, StoredObjectLockMethodEnum::WEBDAV, $this->clock->now(), 'token', $this->clock->now()->add(new \DateInterval('PT1M'))); + + $this->assertTrue($this->manager->hasLock($document)); + } + + public function testHasLockExpired(): void + { + $document = new StoredObject(); + new StoredObjectLock($document, StoredObjectLockMethodEnum::WEBDAV, $this->clock->now()->sub(new \DateInterval('PT2M')), 'token', $this->clock->now()->sub(new \DateInterval('PT1M'))); + + $this->assertFalse($this->manager->hasLock($document)); + } + + public function testGetLockThrowsExceptionWhenNoLock(): void + { + $document = new StoredObject(); + $this->expectException(NoLockFoundException::class); + $this->manager->getLock($document); + } + + public function testSetLockExistingUpdatesLock(): void + { + $document = new StoredObject(); + $initialExpire = $this->clock->now()->add(new \DateInterval('PT10M')); + $lock = new StoredObjectLock($document, StoredObjectLockMethodEnum::WEBDAV, $this->clock->now(), 'initial-token', $initialExpire); + + $newLockId = 'new-token'; + $newExpire = $this->clock->now()->add(new \DateInterval('PT20M')); + $user = new User(); + + // Should NOT call persist again + $this->entityManager->persist(Argument::any())->shouldNotBeCalled(); + + $result = $this->manager->setLock($document, StoredObjectLockMethodEnum::WOPI, $newLockId, $newExpire, [$user]); + + $this->assertInstanceOf(StoredObjectLock::class, $result); + $this->assertCount(1, $document->getLocks()); + $this->assertSame($lock, $document->getLocks()->first()); + $this->assertSame($newLockId, $lock->getToken()); + $this->assertSame($newExpire, $lock->getExpireAt()); + $this->assertContains($user, $lock->getUsers()); + } + + public function testDeleteLock(): void + { + $document = new StoredObject(); + $expiresAt = $this->clock->now()->add(new \DateInterval('PT10M')); + $lock = new StoredObjectLock($document, StoredObjectLockMethodEnum::WEBDAV, $this->clock->now(), 'token', $expiresAt); + + $this->assertTrue($this->manager->hasLock($document)); + + $newExpire = $this->clock->now(); + $result = $this->manager->deleteLock($document, $newExpire); + + $this->assertTrue($result); + $this->assertSame($newExpire, $lock->getExpireAt()); + // Since isActiveAt uses $at < $expireAt, and we passed $this->clock->now(), it should be inactive + $this->assertFalse($this->manager->hasLock($document)); + } + + public function testOnKernelTerminateFlushesWhenMustFlushIsTrue(): void + { + $document = new StoredObject(); + $this->entityManager->persist(Argument::any())->shouldBeCalled(); + $this->manager->setLock($document, StoredObjectLockMethodEnum::WEBDAV); + + $this->entityManager->flush()->shouldBeCalledOnce(); + + $event = new TerminateEvent( + $this->prophesize(HttpKernelInterface::class)->reveal(), + new Request(), + new \Symfony\Component\HttpFoundation\Response() + ); + + $this->manager->onKernelTerminate($event); + } + + public function testOnKernelTerminateDoesNotFlushWhenMustFlushIsFalse(): void + { + $this->entityManager->flush()->shouldNotBeCalled(); + + $event = new TerminateEvent( + $this->prophesize(HttpKernelInterface::class)->reveal(), + new Request(), + new \Symfony\Component\HttpFoundation\Response() + ); + + $this->manager->onKernelTerminate($event); + } + + public function testGetSubscribedEvents(): void + { + $events = StoredObjectLockManager::getSubscribedEvents(); + $this->assertArrayHasKey(TerminateEvent::class, $events); + $this->assertSame('onKernelTerminate', $events[TerminateEvent::class]); + } +} diff --git a/src/Bundle/ChillDocStoreBundle/config/services.yaml b/src/Bundle/ChillDocStoreBundle/config/services.yaml index caecc216d..fdca6545a 100644 --- a/src/Bundle/ChillDocStoreBundle/config/services.yaml +++ b/src/Bundle/ChillDocStoreBundle/config/services.yaml @@ -26,6 +26,8 @@ services: Chill\DocStoreBundle\Service\: resource: '../Service/' + exclude: + '../Service/Lock/Exception/*' Chill\DocStoreBundle\GenericDoc\Manager: arguments: @@ -63,3 +65,10 @@ services: Chill\DocStoreBundle\AsyncUpload\Templating\: resource: '../AsyncUpload/Templating/' + + Chill\DocStoreBundle\Dav\: + resource: '../Dav/' + exclude: + - '../Dav/Exception/*' + - '../Dav/Request/*' + - '../Dav/Response/*'