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/*'