Implements controllers for locking and unlocking with the webdav protocol (wip)

This commit is contained in:
2026-04-02 14:36:13 +02:00
parent a3b857253a
commit ff9e4f2709
10 changed files with 543 additions and 1 deletions

View File

@@ -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;
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Controller;
use Chill\DocStoreBundle\Dav\Response\DavResponse;
use Chill\DocStoreBundle\Dav\Utils\LockTimeoutAnalyzer;
use Chill\DocStoreBundle\Dav\Utils\LockTokenParser;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectLockMethodEnum;
use Chill\DocStoreBundle\Service\Lock\LockTokenCheckResultEnum;
use Chill\DocStoreBundle\Service\Lock\StoredObjectLockManager;
use Chill\MainBundle\Entity\User;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\PreconditionFailedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Twig\Environment;
class WebdavLockController
{
public function __construct(
private StoredObjectLockManager $lockManager,
private Security $security,
private Environment $twig,
private LockTimeoutAnalyzer $lockTimeoutAnalyzer,
private ClockInterface $clock,
private LockTokenParser $lockTokenParser,
) {}
#[Route(path: '/dav/{access_token}/get/{uuid}/d', name: 'chill_docstore_dav_document_lock', methods: ['LOCK'])]
public function lockDocument(StoredObject $storedObject, Request $request): Response
{
$timeout = $request->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;
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Dav\Utils;
use Symfony\Component\HttpFoundation\Request;
final readonly class LockTokenParser
{
public function parseLockToken(Request $request): ?string
{
$token = $request->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;
}
}

View File

@@ -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();
}
}

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8" ?>
<D:prop xmlns:D="DAV:">
<D:lockdiscovery>
<D:activelock>
<D:lockscope><D:exclusive/></D:lockscope>
<D:locktype><D:write/></D:locktype>
<D:depth>infinity</D:depth>
{% set user = lock.users.first %}
{% if user is not same as null %}
<D:owner><D:href>{{ lock.users.first }}</D:href></D:owner>
{% endif %}
<D:timeout>{{ timeout }}</D:timeout>
<D:locktoken>
<D:href>{{ lock.token }}</D:href>
</D:locktoken>
</D:activelock>
</D:lockdiscovery>
</D:prop>

View File

@@ -5,6 +5,12 @@
{% if properties.lastModified or properties.contentLength or properties.resourceType or properties.etag or properties.contentType or properties.creationDate %}
<d:propstat>
<d:prop>
<d:supportedlock>
<d:lockentry>
<d:lockscope><d:exclusive/></d:lockscope>
<d:locktype><d:write/></d:locktype>
</d:lockentry>
</d:supportedlock>
{% if properties.resourceType %}
<d:resourcetype/>
{% endif %}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Service\Lock\Exception;
class NoLockFoundException extends \RuntimeException
{
public function __construct(?string $message = null, int $code = 0, ?\Throwable $previous = null)
{
parent::__construct($message ?? 'No lock found', $code, $previous);
}
}

View File

@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Service\Lock;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectLock;
use Chill\DocStoreBundle\Entity\StoredObjectLockMethodEnum;
use Chill\DocStoreBundle\Service\Lock\Exception\NoLockFoundException;
use Doctrine\ORM\EntityManagerInterface;
use Ramsey\Uuid\Uuid;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\TerminateEvent;
use Symfony\Component\Security\Core\User\UserInterface;
class StoredObjectLockManager implements EventSubscriberInterface
{
private bool $mustFlush = false;
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ClockInterface $clock,
) {}
public function deleteLock(StoredObject $document, \DateTimeImmutable $expiresAt): bool
{
foreach ($document->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();
}
}
}

View File

@@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\Service\Lock;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectLock;
use Chill\DocStoreBundle\Entity\StoredObjectLockMethodEnum;
use Chill\DocStoreBundle\Service\Lock\Exception\NoLockFoundException;
use Chill\DocStoreBundle\Service\Lock\StoredObjectLockManager;
use Chill\MainBundle\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\HttpKernel\Event\TerminateEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpFoundation\Request;
/**
* @internal
*
* @coversNothing
*/
class StoredObjectLockManagerTest extends TestCase
{
use ProphecyTrait;
private MockClock $clock;
/**
* @var ObjectProphecy<EntityManagerInterface>
*/
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]);
}
}

View File

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