Compare commits

...

29 Commits

Author SHA1 Message Date
58509ae4da Rector fixes 2026-04-15 18:17:25 +02:00
60b31a0b19 Fix eslint 2026-04-15 18:13:12 +02:00
d24842fc17 Extend document replacement logic to include storedObject and update components
- Updated `replaceDocument` function to handle `storedObject` and ensure proper version assignment.
- Added `@on-stored-object-refresh` event handling in `DocumentsList.vue` for dynamic updates.
- Introduced `refresh-stored-object-auto` prop in various components to control auto-refresh behavior.
2026-04-15 18:10:18 +02:00
fbb04eb783 Add automatic refresh of stored objects with interval support
- Introduced `refreshStoredObject` logic to periodically update stored objects using `get_stored_object` API.
- Updated `DocumentActionButtonsGroup.vue` and `IsCurrentlyEdited.vue` to emit refresh events and handle object updates.
- Added new props to configure auto-refresh behavior, including interval and maximum execution limits.
- Extended `helpers.ts` with new `get_stored_object` function for retrieving objects by UUID.
2026-04-15 18:10:07 +02:00
d17d211429 Add getStoredObject endpoint to StoredObjectApiController with tests
- Introduced the `/1.0/doc-store/stored-object/{uuid}` endpoint to retrieve stored objects by UUID.
- Added access control to ensure users have appropriate permissions to view stored objects.
- Extended the OpenAPI specification with new endpoint definitions, request parameters, and response schemas.
- Developed unit tests to validate the endpoint's behavior, covering access denial and successful retrieval
2026-04-15 18:09:30 +02:00
06146f7909 Add lock removal functionality to IsCurrentlyEdited component
- Added a button to forcefully remove WebDAV locks with confirmation and toast notifications.
- Introduced a `remove_lock` helper function and updated corresponding TypeScript imports.
- Enhanced French translations with new keys for lock removal messages and better wording.
- Adjusted display logic to show short lock status messages in the dropdown.
2026-04-14 16:25:51 +02:00
7b00006943 Add StoredObjectLockApiController and tests for lock removal functionality
- Implemented `StoredObjectLockApiController` with endpoint to remove locks from stored objects.
- Added access control checks and validation for lock existence before removal.
- Wrote `StoredObjectLockApiControllerTest` to cover access denial, lock absence, and successful lock removal scenarios.
- Utilized `MockClock` in test cases to accurately simulate time-based behaviors.
2026-04-14 16:25:05 +02:00
a27173ee4a Add IsCurrentlyEdited and CurrentlyEditingIcon components with lock display logic
- Introduced `IsCurrentlyEdited.vue` and `CurrentlyEditingIcon.vue` to display lock status and editing users with modal support.
- Refactored button components (`WopiEditButton.vue`, `DesktopEditButton.vue`) to dynamically compute classes based on lock and editor states.
- Updated `DocumentActionButtonsGroup.vue` to include lock-related components for enhanced editing status visibility.
- Added new localization keys for lock-related messages and implemented `localizeList` helper for list formatting.
- Amended `package.json` with `tsc-check` script for stricter TypeScript checks.
2026-04-14 15:24:34 +02:00
b171afba2f Add _editor and lock to StoredObjecNormalizer. Refactor StoredObjectNormalizerTest to use prophecy and add tests for lock and editor states
- Replaced PHPUnit mocks with prophecy for improved flexibility in `StoredObjectNormalizerTest`.
- Added test cases to cover lock handling and editor state evaluation for WebDAV and WOPI methods.
- Updated `StoredObjectNormalizer` to include lock normalization and editor state computation logic.
- Extended TypeScript types to reflect lock and editor states in the front-end model.
2026-04-13 14:26:37 +02:00
bb9da5dada Merge branch 'refs/heads/master' into 508-lock-stored-object 2026-04-13 12:59:40 +02:00
012dc3c27d Add StoredObjectEditorDecisionManager with tests and interface for editability decisions
- Implemented `StoredObjectEditorDecisionManager` to assess editability of stored objects based on lock status and methods.
- Created `StoredObjectEditorDecisionManagerInterface` to define the contract for decision logic.
- Added comprehensive test suite `StoredObjectEditorDecisionManagerTest` to validate scenarios including lock absence, conflicting lock methods, and compatible WOPI locks.
2026-04-13 12:34:47 +02:00
4c86dcb9ff Add createdAt field normalization in StoredObjectLockNormalizer and update related test
- Modified `StoredObjectLockNormalizer` to include the `createdAt` field in the normalized output.
- Updated `StoredObjectLockNormalizerTest` to reflect the new `createdAt` field, including test assertions and normalization logic adjustments.
2026-04-10 12:33:14 +02:00
3905b7c9a7 Add StoredObjectLockNormalizer with corresponding test suite
- Implemented `StoredObjectLockNormalizer` to handle JSON normalization for `StoredObjectLock` entities.
- Added `StoredObjectLockNormalizerTest` to verify normalization logic, format support, and edge cases.
2026-04-08 22:36:27 +02:00
003cccfdc4 Update foreign key constraint to include ON DELETE CASCADE for storedobjectlock_uuid
- Modified migration `Version20260331122339` to apply `ON DELETE CASCADE` to the foreign key constraint on `storedobjectlock_uuid` in `stored_object_lock_user` table.
- Ensures dependent records are removed automatically when the parent `stored_object_lock` is deleted.
2026-04-08 22:26:53 +02:00
c60383b636 Add CleanOldLockCronJob service and corresponding test suite
- Introduced `CleanOldLockCronJob` to handle scheduled cleaning of old locks.
- Implemented tests in `CleanOldLockCronJobTest` to verify behavior, including conditions for execution based on elapsed time.
- Utilized `MockClock` for precise time-based testing scenarios.
2026-04-08 22:26:43 +02:00
678ec844e2 Add service to clean expired locks and related tests
- Introduced `CleanOldLock` service to remove expired locks older than 24 hours.
- Added `removeExpiredBefore` method in `StoredObjectLockRepository` for efficient deletion of expired locks.
- Created `CleanOldLockTest` to verify the cleaning service functionality using `MockClock`.
2026-04-08 22:26:27 +02:00
4afdc9a7cc Add WebDAV lock and renew functionality with comprehensive tests
- Implemented `WebdavLockController::lockDocument` to handle LOCK requests for creating and renewing locks.
- Introduced access control checks using `StoredObjectRoleEnum::EDIT` to restrict lock operations to authorized users.
- Added test suite `WebdavLockControllerTest` to validate locking and renewing scenarios, including edge cases.
- Refactored lock logic into `lockOrRenewToken` for streamlined processing.
2026-04-08 21:33:00 +02:00
25962e0e39 Update setLock to reuse existing lock without generating new lock ID
- Modified `StoredObjectLockManager` logic to prevent unnecessary lock ID regeneration when updating existing locks.
- Added `testSetLockExistingUpdatesLockWithoutNewLockId` to verify behavior.
- Ensured `persist` is not called again for existing locks.
2026-04-08 21:32:45 +02:00
4a224054e2 Extract WebDAV PUT logic into a dedicated WebdavPutController and add comprehensive tests
- Moved `putDocument` method from `WebdavController` to the new `WebdavPutController`.
- Introduced stricter locking and permission checks during PUT operations.
- Added new test suite `WebdavPutControllerTest` to cover different scenarios, including lock validation and access control.
- Updated existing logic in `WebdavControllerTest` to reflect these changes.
2026-04-03 18:01:23 +02:00
a1d72cefff Add LockTokenParser::parseIfCondition method and corresponding tests
- Implemented the `parseIfCondition` method in `LockTokenParser` to extract lock tokens from `if` headers.
- Added `LockTokenParserTest` with multiple test cases using data providers to validate parsing logic, including scenarios with no headers, resource URIs, and "not" conditions.
2026-04-03 18:01:04 +02:00
ff9e4f2709 Implements controllers for locking and unlocking with the webdav protocol (wip) 2026-04-02 14:36:13 +02:00
a3b857253a Add LockTokenCheckResultEnum for lock token validation results
- Introduced `LockTokenCheckResultEnum` with cases to handle various lock token validation outcomes:
  - `NO_LOCK_FOUND`
  - `LOCK_TOKEN_DO_NOT_MATCH`
  - `LOCK_TOKEN_DO_NOT_BELONG_TO_USER`.
2026-04-02 14:35:26 +02:00
c9a632f3a9 Add LockTimeoutAnalyzer utility and corresponding tests
- Implemented `LockTimeoutAnalyzer` to parse timeout values from RFC-compliant strings and return `DateInterval` objects.
- Added `LockTimeoutAnalyzerTest` with data providers to validate handling of various timeout cases, including "Second" and "Infinite".
2026-04-02 14:35:20 +02:00
ba2288de55 Add support for Lock-Token header in DavResponse
- Updated `DAV` header to include version 2.
- Introduced `setLockToken` method to add a `Lock-Token` header.
2026-04-02 14:35:08 +02:00
9f8e349a85 Refactor ChillDocumentLockManager to delegate lock operations to StoredObjectLockManager
- Replaced inline lock management logic with `StoredObjectLockManager` to handle lock operations (create, delete, retrieve, existence check).
- Updated constructor to require `StoredObjectLockManager` and removed unused dependencies.
- Simplified methods (`setLock`, `deleteLock`, `hasLock`, `getLock`) by leveraging `StoredObjectLockManager`.
- Removed `onKernelTerminate` event listener and deferred flush logic.
- Updated existing tests in `ChillDocumentLockManagerTest` to reflect changes and ensure compatibility.
2026-04-02 14:34:56 +02:00
76d3612d33 Refactor ChillDocumentLockManager to use database locks and add related tests
- Replaced Redis-based locking mechanism with database-based `StoredObjectLock` management using `EntityManagerInterface`.
- Integrated `MockClock` and `Security` components for lock timing and user association.
- Updated test cases to include database persistence and user assignment during lock operations.
- Implemented `onKernelTerminate` event listener to handle deferred database flush for lock updates.
2026-03-31 21:04:05 +02:00
277e4fa490 Add RandomUserTrait to retrieve random user for testing
- Implemented `getRandomUser` method to fetch a random user using `EntityManagerInterface`.
- Added support for random user retrieval by querying `User` entity repository.
2026-03-31 17:32:36 +02:00
fe11780ad5 Add method to check lock activity and corresponding tests
- Added `isActiveAt` method in `StoredObjectLock` to check if a lock is active at a specific time.
- Implemented `isLockedAt` method in `StoredObject` to determine if an object is locked at a given time.
- Included unit tests for `isActiveAt` method in `StoredObjectLockTest` to validate various scenarios.
2026-03-31 17:32:24 +02:00
c1e5346ef9 Add locking mechanism for stored objects
- Created `StoredObjectLock` entity to manage locks on stored objects.
- Introduced `StoredObjectLockMethodEnum` to define locking methods.
- Added relationships between `StoredObject` and `StoredObjectLock` with appropriate methods for management.
- Added database migrations to create necessary tables and constraints for lock handling.
2026-03-31 14:50:35 +02:00
60 changed files with 3088 additions and 144 deletions

View File

@@ -87,7 +87,8 @@
"specs-create-dir": "mkdir -p templates/api",
"specs": "yarn run specs-create-dir && yarn run specs-build && yarn run specs-validate",
"version": "node --version",
"eslint": "eslint-baseline --fix \"src/**/*.{js,ts,vue}\""
"eslint": "eslint-baseline --fix \"src/**/*.{js,ts,vue}\"",
"tsc-check": "vue-tsc --noEmit"
},
"private": true
}

View File

@@ -12,16 +12,17 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Controller;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\MainBundle\CRUD\Controller\ApiController;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
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
{
public function __construct(
private readonly Security $security,
@@ -53,4 +54,17 @@ class StoredObjectApiController extends ApiController
json: true
);
}
#[Route('/api/1.0/doc-store/stored-object/{uuid}', methods: ['GET', 'HEAD'])]
public function getStoredObject(StoredObject $storedObject, Request $request): JsonResponse
{
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
throw new AccessDeniedHttpException('No permission to see the stored object');
}
return new JsonResponse(
$this->serializer->serialize($storedObject, 'json', [AbstractNormalizer::GROUPS => ['read']]),
json: true
);
}
}

View File

@@ -0,0 +1,47 @@
<?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\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Service\Lock\StoredObjectLockManager;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\PreconditionFailedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
final readonly class StoredObjectLockApiController
{
public function __construct(
private Security $security,
private StoredObjectLockManager $storedObjectLockManager,
private ClockInterface $clock,
) {}
#[Route('/api/1.0/doc-store/stored-object/{uuid}/lock', name: 'chill_docstore_storedobjectlock_removelock', methods: ['DELETE'])]
public function removeLock(StoredObject $storedObject): Response
{
if (!$this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $storedObject)) {
throw new AccessDeniedHttpException();
}
if (!$this->storedObjectLockManager->hasLock($storedObject)) {
throw new PreconditionFailedHttpException('No lock found for this stored object');
}
$this->storedObjectLockManager->deleteLock($storedObject, $this->clock->now());
return new Response(null, Response::HTTP_NO_CONTENT);
}
}

View File

@@ -16,7 +16,6 @@ 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;
@@ -43,7 +42,6 @@ final readonly class WebdavController
private \Twig\Environment $engine,
private StoredObjectManagerInterface $storedObjectManager,
private Security $security,
private EntityManagerInterface $entityManager,
) {
$this->requestAnalyzer = new PropfindRequestAnalyzer();
}
@@ -75,7 +73,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;
}
@@ -194,20 +192,6 @@ final readonly class WebdavController
return $response;
}
#[Route(path: '/dav/{access_token}/get/{uuid}/d', methods: ['PUT'])]
public function putDocument(StoredObject $storedObject, Request $request): Response
{
if (!$this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $storedObject)) {
throw new AccessDeniedHttpException();
}
$this->storedObjectManager->write($storedObject, $request->getContent());
$this->entityManager->flush();
return new DavResponse('', Response::HTTP_NO_CONTENT);
}
/**
* @return array{0: array, 1: \DateTimeInterface, 2: string, 3: int} properties, lastModified, etag, length
*/

View File

@@ -0,0 +1,120 @@
<?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\Security\Authorization\StoredObjectRoleEnum;
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\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Twig\Environment;
final readonly 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
{
$user = $this->security->getUser();
if (null !== $token = $this->lockTokenParser->parseIfCondition($request)) {
// this is a renew of a token. We first perform checks
if (true !== $error = $this->lockManager->checkLock($storedObject, $token, $user)) {
$e = match ($error) {
LockTokenCheckResultEnum::NO_LOCK_FOUND, LockTokenCheckResultEnum::LOCK_TOKEN_DO_NOT_MATCH => new PreconditionFailedHttpException(),
LockTokenCheckResultEnum::LOCK_TOKEN_DO_NOT_BELONG_TO_USER => new ConflictHttpException(),
};
throw $e;
}
} else {
if ($this->lockManager->hasLock($storedObject)) {
throw new ConflictHttpException();
}
}
return $this->lockOrRenewToken($storedObject, $request);
}
private function lockOrRenewToken(StoredObject $storedObject, Request $request): Response
{
if (!$this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $storedObject)) {
throw new UnauthorizedHttpException('not allowed to edit this document');
}
$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,71 @@
<?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\LockTokenParser;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Service\Lock\LockTokenCheckResultEnum;
use Chill\DocStoreBundle\Service\Lock\StoredObjectLockManager;
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;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\PreconditionFailedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
final readonly class WebdavPutController
{
public function __construct(
private StoredObjectManagerInterface $storedObjectManager,
private Security $security,
private EntityManagerInterface $entityManager,
private LockTokenParser $lockTokenParser,
private StoredObjectLockManager $lockManager,
) {}
#[Route(path: '/dav/{access_token}/get/{uuid}/d', methods: ['PUT'])]
public function putDocument(StoredObject $storedObject, Request $request): Response
{
$user = $this->security->getUser();
if (null !== $token = $this->lockTokenParser->parseIfCondition($request)) {
// this is a renew of a token. We first perform checks
if (true !== $error = $this->lockManager->checkLock($storedObject, $token, $user)) {
$e = match ($error) {
LockTokenCheckResultEnum::NO_LOCK_FOUND, LockTokenCheckResultEnum::LOCK_TOKEN_DO_NOT_MATCH => new PreconditionFailedHttpException(),
LockTokenCheckResultEnum::LOCK_TOKEN_DO_NOT_BELONG_TO_USER => new ConflictHttpException(),
};
throw $e;
}
} else {
if ($this->lockManager->hasLock($storedObject)) {
throw new ConflictHttpException();
}
}
if (!$this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $storedObject)) {
throw new AccessDeniedHttpException();
}
$this->storedObjectManager->write($storedObject, $request->getContent());
$this->entityManager->flush();
return new DavResponse('', Response::HTTP_NO_CONTENT);
}
}

View File

@@ -19,6 +19,13 @@ class DavResponse extends Response
{
parent::__construct($content, $status, $headers);
$this->headers->add(['DAV' => '1']);
$this->headers->add(['DAV' => '1,2']);
}
public function setLockToken(string $token): self
{
$this->headers->add(['Lock-Token' => $token]);
return $this;
}
}

View File

@@ -0,0 +1,45 @@
<?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 DateInterval;
class LockTimeoutAnalyzer
{
/**
* Analyzes the timeout value from the provided content string, RFC2068 string
* and return a DateInterval object representing the timeout duration.
*
* @param string $content The input string containing timeout information, as described by RFC2518, section 4.2
*
* @return \DateInterval the calculated timeout as a DateInterval object
*
* @throws \Exception if the DateInterval creation fails
*/
public function analyzeTimeout(string $content): \DateInterval
{
$types = explode(',', $content);
$firstType = trim(reset($types));
if (str_starts_with($firstType, 'Second-')) {
$seconds = (int) substr($firstType, 7);
return new \DateInterval(sprintf('PT%dS', $seconds));
}
if ('Infinite' === $firstType) {
return new \DateInterval('PT24H');
}
return new \DateInterval('PT3600S');
}
}

View File

@@ -0,0 +1,58 @@
<?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;
}
public function parseIfCondition(Request $request): ?string
{
$if = $request->headers->get('if');
if (null === $if) {
return null;
}
if (preg_match('/\((?:not\s+)?<([^>]+)>/i', $if, $matches)) {
return $matches[1];
}
return null;
}
}

View File

@@ -97,6 +97,12 @@ class StoredObject implements Document, TrackCreationInterface
#[ORM\OneToMany(mappedBy: 'storedObject', targetEntity: StoredObjectVersion::class, cascade: ['persist'], orphanRemoval: true, fetch: 'EAGER')]
private Collection&Selectable $versions;
/**
* @var Collection<int, StoredObjectLock>
*/
#[ORM\OneToMany(mappedBy: 'storedObject', targetEntity: StoredObjectLock::class, cascade: ['persist', 'remove', 'refresh', 'merge'])]
private Collection $locks;
/**
* @param StoredObject::STATUS_* $status
*/
@@ -107,6 +113,41 @@ class StoredObject implements Document, TrackCreationInterface
$this->uuid = Uuid::uuid4();
$this->versions = new ArrayCollection();
$this->prefix = self::generatePrefix();
$this->locks = new ArrayCollection();
}
/**
* @internal use @see{StoredObjectLock::__construct}
*/
public function addLock(StoredObjectLock $lock): void
{
if (!$this->locks->contains($lock)) {
$this->locks->add($lock);
}
}
public function removeLock(StoredObjectLock $lock): void
{
$this->locks->removeElement($lock);
}
public function isLockedAt(\DateTimeImmutable $at): bool
{
foreach ($this->locks as $lock) {
if ($lock->isActiveAt($at)) {
return true;
}
}
return false;
}
/**
* @return Collection<int, StoredObjectLock>
*/
public function getLocks(): Collection
{
return $this->locks;
}
public function addGenerationTrial(): self

View File

@@ -0,0 +1,142 @@
<?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\Entity;
use Chill\MainBundle\Entity\User;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
#[ORM\Entity]
#[ORM\Table(name: 'stored_object_lock', schema: 'chill_doc')]
class StoredObjectLock
{
#[ORM\Id]
#[ORM\Column(type: 'uuid', unique: true)]
private UuidInterface $uuid;
/**
* @var Collection<int, User>
*/
#[ORM\ManyToMany(targetEntity: User::class)]
#[ORM\JoinTable(name: 'stored_object_lock_user', schema: 'chill_doc')]
#[ORM\JoinColumn(referencedColumnName: 'uuid', nullable: false)]
private Collection $users;
/**
* @param list<User> $users
*/
public function __construct(
#[ORM\ManyToOne(targetEntity: StoredObject::class, inversedBy: 'locks')]
private StoredObject $storedObject,
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 10, nullable: false, enumType: StoredObjectLockMethodEnum::class)]
private StoredObjectLockMethodEnum $method,
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIMETZ_IMMUTABLE, nullable: false)]
private \DateTimeImmutable $createdAt,
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])]
private string $token = '',
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIMETZ_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $expireAt = null,
array $users = [],
) {
$this->uuid = Uuid::uuid7();
$this->users = new ArrayCollection();
$this->storedObject->addLock($this);
foreach ($users as $user) {
$this->addUser($user);
}
}
public function addUser(User $user): void
{
if (!$this->users->contains($user)) {
$this->users->add($user);
}
}
public function removeUser(User $user): void
{
$this->users->removeElement($user);
}
/**
* @return Collection<int, User>
*/
public function getUsers(): Collection
{
return $this->users;
}
public function setToken(string $token): void
{
$this->token = $token;
}
public function setExpireAt(?\DateTimeImmutable $expireAt): void
{
$this->expireAt = $expireAt;
}
public function getUuid(): UuidInterface
{
return $this->uuid;
}
public function getStoredObject(): StoredObject
{
return $this->storedObject;
}
public function getMethod(): StoredObjectLockMethodEnum
{
return $this->method;
}
public function getToken(): string
{
return $this->token;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
public function getExpireAt(): ?\DateTimeImmutable
{
return $this->expireAt;
}
/**
* Return true if the lock must be considered as active.
*
* A StoredObjectLock is active if there isn't any expiration date, or
* if the expiration date and time is before the given time.
*/
public function isActiveAt(\DateTimeImmutable $at): bool
{
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 @@
<?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\Entity;
enum StoredObjectLockMethodEnum: string
{
case WEBDAV = 'webdav';
case WOPI = 'wopi';
}

View File

@@ -0,0 +1,40 @@
<?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\Repository;
use Chill\DocStoreBundle\Entity\StoredObjectLock;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<StoredObjectLock>
*/
class StoredObjectLockRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $manager)
{
parent::__construct($manager, StoredObjectLock::class);
}
public function removeExpiredBefore(\DateTimeImmutable $dateTime): void
{
$qb = $this->createQueryBuilder('sol');
$qb->delete()
->where($qb->expr()->lt('sol.expireAt', ':at'))
->andWhere($qb->expr()->isNotNull('sol.expireAt'))
->setParameter('at', $dateTime, Types::DATETIMETZ_IMMUTABLE)
->getQuery()
->execute();
}
}

View File

@@ -46,7 +46,7 @@ window.addEventListener("DOMContentLoaded", function () {
};
},
template:
'<document-action-buttons-group :can-edit="canEdit" :filename="filename" :stored-object="storedObject" :small="small" :dav-link="davLink" :dav-link-expiration="davLinkExpiration" @on-stored-object-status-change="onStoredObjectStatusChange"></document-action-buttons-group>',
'<document-action-buttons-group :can-edit="canEdit" :filename="filename" :stored-object="storedObject" :small="small" :dav-link="davLink" :dav-link-expiration="davLinkExpiration" @on-stored-object-status-change="onStoredObjectStatusChange" @onStoredObjectRefresh="onStoredObjectRefresh"></document-action-buttons-group>',
methods: {
onStoredObjectStatusChange: function (
newStatus: StoredObjectStatusChange,
@@ -64,6 +64,9 @@ window.addEventListener("DOMContentLoaded", function () {
el.remove();
});
},
onStoredObjectRefresh: function (newStoredObject: StoredObject) {
this.$data.storedObject = newStoredObject;
},
},
});

View File

@@ -3,6 +3,16 @@ import { SignedUrlGet } from "ChillDocStoreAssets/vuejs/StoredObjectButton/helpe
export type StoredObjectStatus = "empty" | "ready" | "failure" | "pending";
export type StoredObjectLockMethodEnum = "webdav" | "wopi";
export interface StoredObjectLock {
uuid: string;
method: StoredObjectLockMethodEnum;
createdAt: DateTime;
expiresAt: DateTime;
users: User[];
}
export interface StoredObject {
id: number;
title: string | null;
@@ -31,6 +41,11 @@ export interface StoredObject {
};
downloadLink?: SignedUrlGet;
};
lock: StoredObjectLock | null;
_editor?: {
webdav: boolean;
wopi: boolean;
};
}
export interface StoredObjectVersion {

View File

@@ -14,8 +14,22 @@
aria-expanded="false"
>
Actions
<currently-editing-icon
v-if="props.storedObject.lock !== null"
></currently-editing-icon>
</button>
<ul class="dropdown-menu">
<li v-if="null !== props.storedObject.lock">
<is-currently-edited
:stored-object="
props.storedObject as StoredObject & { lock: StoredObjectLock }
"
@require-refresh-stored-object="refreshStoredObject"
></is-currently-edited>
</li>
<li v-if="null !== props.storedObject.lock">
<hr class="dropdown-divider" />
</li>
<li v-if="isEditableOnline">
<wopi-edit-button
:stored-object="props.storedObject"
@@ -28,6 +42,7 @@
:classes="{ 'dropdown-item': true }"
:edit-link="props.davLink ?? ''"
:expiration-link="props.davLinkExpiration ?? 0"
:stored-object="props.storedObject"
></desktop-edit-button>
</li>
<li v-if="isConvertibleToPdf">
@@ -49,7 +64,7 @@
<li v-if="isHistoryViewable">
<history-button
:stored-object="props.storedObject"
:can-edit="canEdit && props.storedObject._permissions.canEdit"
:can-edit="props.storedObject._permissions.canEdit"
></history-button>
</li>
</ul>
@@ -68,17 +83,22 @@ import ConvertButton from "./StoredObjectButton/ConvertButton.vue";
import DownloadButton from "./StoredObjectButton/DownloadButton.vue";
import WopiEditButton from "./StoredObjectButton/WopiEditButton.vue";
import {
get_stored_object,
is_extension_editable,
is_extension_viewable,
is_object_ready,
} from "./StoredObjectButton/helpers";
import {
StoredObject,
StoredObjectLock,
StoredObjectStatusChange,
WopiEditButtonExecutableBeforeLeaveFunction,
} from "../types";
import DesktopEditButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DesktopEditButton.vue";
import HistoryButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/HistoryButton.vue";
import CurrentlyEditingIcon from "ChillDocStoreAssets/vuejs/StoredObjectButton/CurrentlyEditingIcon.vue";
import IsCurrentlyEdited from "ChillDocStoreAssets/vuejs/StoredObjectButton/IsCurrentlyEdited.vue";
import { useIntervalFn } from "@vueuse/core";
interface DocumentActionButtonsGroupConfig {
storedObject: StoredObject;
@@ -110,15 +130,27 @@ interface DocumentActionButtonsGroupConfig {
* the expiration date of the download, as a unix timestamp
*/
davLinkExpiration?: number;
/**
* This module will trigger a refresh of the stored object on a given interval, if true.
*/
refreshStoredObjectAuto?: boolean;
/**
* When enabled, the refresh interval, in milliseconds.
*/
refreshStoredObjectInterval?: number;
/**
* When enable, the refresh will stop after a given number of executions.
*/
refreshStoredObjectMaxTimes?: number;
}
const emit =
defineEmits<
(
e: "onStoredObjectStatusChange",
newStatus: StoredObjectStatusChange,
) => void
>();
const emit = defineEmits<{
(e: "onStoredObjectStatusChange", newStatus: StoredObjectStatusChange): void;
(e: "onStoredObjectRefresh", newStoredObject: StoredObject): void;
}>();
const props = withDefaults(defineProps<DocumentActionButtonsGroupConfig>(), {
small: false,
@@ -127,6 +159,9 @@ const props = withDefaults(defineProps<DocumentActionButtonsGroupConfig>(), {
canConvertPdf: true,
returnPath:
window.location.pathname + window.location.search + window.location.hash,
refreshStoredObjectAuto: true,
refreshStoredObjectInterval: 60000,
refreshStoredObjectMaxTimes: 20,
});
/**
@@ -221,8 +256,33 @@ const onObjectNewStatusCallback = async function (): Promise<void> {
return Promise.resolve();
};
let numberOfRefresh = 0;
const refreshStoredObject = async () => {
if (!props.refreshStoredObjectAuto) {
interval.pause();
return;
}
numberOfRefresh++;
const n = await get_stored_object(props.storedObject.uuid);
emit("onStoredObjectRefresh", n);
if (numberOfRefresh >= props.refreshStoredObjectMaxTimes) {
interval.pause();
}
};
const interval = useIntervalFn(
refreshStoredObject,
props.refreshStoredObjectInterval,
{ immediate: false, immediateCallback: false },
);
onMounted(() => {
checkForReady();
if (props.refreshStoredObjectAuto) {
interval.resume();
}
});
</script>

View File

@@ -13,6 +13,7 @@
:can-download="true"
:dav-link="dav_link_href ?? ''"
:dav-link-expiration="dav_link_expiration ?? 0"
:refresh-stored-object-auto="false"
/>
</li>
<li>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts"></script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
class="icon-pen-fill"
>
<defs>
<linearGradient
id="pen-grad"
gradientUnits="userSpaceOnUse"
x1="-16"
y1="0"
x2="0"
y2="0"
>
<stop offset="0%" stop-color="#334D5C" />
<stop offset="40%" stop-color="#334D5C" />
<stop offset="40%" stop-color="#43B29D" />
<stop offset="50%" stop-color="#43B29D" />
<stop offset="50%" stop-color="#328474" />
<stop offset="60%" stop-color="#328474" />
<stop offset="60%" stop-color="#EEC84A" />
<stop offset="70%" stop-color="#EEC84A" />
<stop offset="70%" stop-color="#E2793D" />
<stop offset="80%" stop-color="#E2793D" />
<stop offset="80%" stop-color="#DF4949" />
<stop offset="90%" stop-color="#DF4949" />
<stop offset="90%" stop-color="#cabb9f" />
<stop offset="100%" stop-color="#cabb9f" />
<stop offset="100%" stop-color="#334D5C" />
<animateTransform
attributeName="gradientTransform"
type="translate"
from="0 0"
to="32 32"
dur="1.5s"
repeatCount="indefinite"
/>
</linearGradient>
</defs>
<path
fill="url(#pen-grad)"
d="m13.498.795.149-.149a1.207 1.207 0 1 1 1.707 1.708l-.149.148a1.5 1.5 0 0 1-.059 2.059L4.854 14.854a.5.5 0 0 1-.233.131l-4 1a.5.5 0 0 1-.606-.606l1-4a.5.5 0 0 1 .131-.232l9.642-9.642a.5.5 0 0 0-.642.056L6.854 4.854a.5.5 0 1 1-.708-.708L9.44.854A1.5 1.5 0 0 1 11.5.796a1.5 1.5 0 0 1 1.998-.001"
/>
</svg>
</template>
<style scoped lang="scss"></style>

View File

@@ -37,7 +37,7 @@
</template>
</modal>
</teleport>
<a :class="props.classes" @click="state.modalOpened = true">
<a :class="classesComputed()" @click="state.modalOpened = true">
<i class="fa fa-desktop"></i>
Éditer sur le bureau
</a>
@@ -55,11 +55,13 @@ i.fa::before {
<script setup lang="ts">
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import { computed, reactive } from "vue";
import { StoredObject } from "ChillDocStoreAssets/types";
export interface DesktopEditButtonConfig {
editLink: string;
classes: Record<string, boolean>;
expirationLink: number | Date;
storedObject: StoredObject;
}
interface DesktopEditButtonState {
@@ -74,6 +76,19 @@ const buildCommand = computed<string>(
() => "vnd.libreoffice.command:ofe|u|" + props.editLink,
);
function classesComputed(): Record<string, boolean> {
const cl = props.classes;
cl["btn"] = true;
if (false === props.storedObject._editor?.webdav) {
cl["disabled"] = true;
} else {
cl["disabled"] = false;
}
return cl;
}
const editionUntilFormatted = computed<string>(() => {
let d;

View File

@@ -0,0 +1,149 @@
<script setup lang="ts">
import { StoredObject, StoredObjectLock } from "ChillDocStoreAssets/types";
import CurrentlyEditingIcon from "ChillDocStoreAssets/vuejs/StoredObjectButton/CurrentlyEditingIcon.vue";
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import { computed, reactive } from "vue";
import { localizeList } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
import { User } from "ChillMainAssets/types";
import {
STORED_OBJECT_LOCK_IS_CURRENTLY_EDITED,
STORED_OBJECT_LOCK_IS_CURRENTLY_EDITED_WITHOUT_USERS,
STORED_OBJECT_LOCK_IS_CURRENTLY_EDITED_SHORT,
STORED_OBJECT_LOCK_IS_CURRENTLY_EDITED_WITHOUT_USERS_SHORT,
STORED_OBJECT_LOCK_EDITING_SINCE,
STORED_OBJECT_LOCK_LIST_OF_USERS_MAY_BE_INCOMPLETE,
STORED_OBJECT_LOCK_FORCE_REMOVE_LOCK,
STORED_OBJECT_LOCK_FORCE_REMOVE_LOCK_POSSIBLE_EXPLAIN,
STORED_OBJECT_LOCK_FORCE_REMOVE_LOCK_WARNING,
STORED_OBJECT_LOCK_REMOVE_LOCK_FAILURE,
STORED_OBJECT_LOCK_REMOVE_LOCK_SUCCESS,
trans,
} from "translator";
import { ISOToDatetime } from "ChillMainAssets/chill/js/date";
import { remove_lock } from "ChillDocStoreAssets/vuejs/StoredObjectButton/helpers";
import { useToast } from "vue-toast-notification";
interface IsCurrentlyEditedConfig {
storedObject: StoredObject & { lock: StoredObjectLock };
}
const emit = defineEmits<(e: "requireRefreshStoredObject") => void>();
const props = defineProps<IsCurrentlyEditedConfig>();
const state = reactive({
modalOpened: false,
});
const toast = useToast();
const sinceDate = computed<Date | null>(() =>
ISOToDatetime(props.storedObject.lock.createdAt.datetime),
);
const onModalClose = function (): void {
state.modalOpened = false;
};
const openModal = function (): void {
state.modalOpened = true;
};
const removeLock = async function (): Promise<void> {
if (
window.confirm(
trans(STORED_OBJECT_LOCK_FORCE_REMOVE_LOCK_WARNING, {
nb: props.storedObject.lock.users.length,
}),
)
) {
try {
await remove_lock(props.storedObject);
} catch (e: unknown) {
toast.error(
trans(STORED_OBJECT_LOCK_REMOVE_LOCK_FAILURE, {
status: (e as Error).toString(),
}),
);
return Promise.reject(e);
}
emit("requireRefreshStoredObject");
toast.success(trans(STORED_OBJECT_LOCK_REMOVE_LOCK_SUCCESS));
return Promise.resolve();
} else {
console.log("User did not confirmed edit");
}
};
</script>
<template>
<teleport to="body">
<modal v-if="state.modalOpened" @close="onModalClose">
<template v-slot:body>
<div class="modal-currently-edit-content">
<p v-if="props.storedObject.lock.users.length > 0">
{{
trans(STORED_OBJECT_LOCK_IS_CURRENTLY_EDITED, {
byUsers: localizeList(
props.storedObject.lock.users.map((u: User) => u.label),
),
})
}}.<template v-if="props.storedObject.lock.method === 'wopi'">
{{
trans(STORED_OBJECT_LOCK_LIST_OF_USERS_MAY_BE_INCOMPLETE)
}}</template
>
</p>
<p v-else>
{{ trans(STORED_OBJECT_LOCK_IS_CURRENTLY_EDITED_WITHOUT_USERS) }}
</p>
<p v-if="sinceDate !== null">
{{ trans(STORED_OBJECT_LOCK_EDITING_SINCE, { since: sinceDate }) }}
</p>
<div
v-if="
props.storedObject._permissions.canEdit &&
props.storedObject.lock.method === 'webdav'
"
class="alert alert-danger"
>
<p>
<i class="bi bi-exclamation-lg"></i
>{{
trans(STORED_OBJECT_LOCK_FORCE_REMOVE_LOCK_POSSIBLE_EXPLAIN)
}}
</p>
<button class="btn btn-delete" @click="removeLock">
{{ trans(STORED_OBJECT_LOCK_FORCE_REMOVE_LOCK) }}
</button>
</div>
</div>
</template>
</modal>
</teleport>
<button class="dropdown-item" type="button" @click="openModal()">
<span class="currently-edited"
><currently-editing-icon></currently-editing-icon
></span>
<span v-if="props.storedObject.lock.users.length > 0"
>{{
trans(STORED_OBJECT_LOCK_IS_CURRENTLY_EDITED_SHORT, {
byUsers: localizeList([props.storedObject.lock.users[0].label]),
})
}}<span v-if="props.storedObject.lock.users.length > 1">
(+{{ props.storedObject.lock.users.length - 1 }})</span
></span
>
<span v-else>{{
trans(STORED_OBJECT_LOCK_IS_CURRENTLY_EDITED_WITHOUT_USERS_SHORT)
}}</span>
</button>
</template>
<style scoped lang="scss">
span.currently-edited {
margin-right: 0.15rem;
}
</style>

View File

@@ -1,8 +1,9 @@
<template>
<a
:class="Object.assign(props.classes, { btn: true })"
:class="classesComputed()"
@click="beforeLeave($event)"
:href="build_wopi_editor_link(props.storedObject.uuid, props.returnPath)"
:aria-disabled="!props.storedObject._editor?.wopi"
>
<i class="fa fa-paragraph"></i>
Editer en ligne
@@ -10,7 +11,6 @@
</template>
<script lang="ts" setup>
import WopiEditButton from "./WopiEditButton.vue";
import { build_wopi_editor_link } from "./helpers";
import {
StoredObject,
@@ -28,6 +28,19 @@ const props = defineProps<WopiEditButtonConfig>();
let executed = false;
function classesComputed(): Record<string, boolean> {
const cl = props.classes;
cl["btn"] = true;
if (false === props.storedObject._editor?.wopi) {
cl["disabled"] = true;
} else {
cl["disabled"] = false;
}
return cl;
}
async function beforeLeave(event: Event): Promise<true> {
if (props.executeBeforeLeave === undefined || executed === true) {
return Promise.resolve(true);

View File

@@ -264,6 +264,41 @@ async function is_object_ready(
return await new_status_response.json();
}
async function remove_lock(storedObject: StoredObject): Promise<void> {
const remove_lock_response = await window.fetch(
`/api/1.0/doc-store/stored-object/${storedObject.uuid}/lock`,
{ method: "DELETE" },
);
if (remove_lock_response.ok) {
return Promise.resolve();
} else if (remove_lock_response.status === 412) {
return Promise.resolve();
} else {
throw new Error(
"Could not remove lock: status code: " +
remove_lock_response.status +
" message: " +
remove_lock_response.statusText,
);
}
}
/**
* Retrieves a stored object by its unique identifier.
*
* @param {string} storedObjectUuid - The unique identifier of the stored object to retrieve.
* @return {Promise<StoredObject>} A promise that resolves to the retrieved stored object.
*/
async function get_stored_object(
storedObjectUuid: string,
): Promise<StoredObject> {
return await makeFetch<null, StoredObject>(
"GET",
`/api/1.0/doc-store/stored-object/${storedObjectUuid}`,
);
}
export {
build_convert_link,
build_wopi_editor_link,
@@ -274,4 +309,6 @@ export {
is_extension_editable,
is_extension_viewable,
is_object_ready,
remove_lock,
get_stored_object,
};

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,40 @@
<?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\Serializer\Normalizer;
use Chill\DocStoreBundle\Entity\StoredObjectLock;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
final class StoredObjectLockNormalizer implements NormalizerInterface, NormalizerAwareInterface
{
use NormalizerAwareTrait;
public function normalize($object, ?string $format = null, array $context = []): array
{
assert($object instanceof StoredObjectLock);
return [
'uuid' => $object->getUuid(),
'method' => $object->getMethod(),
'createdAt' => $this->normalizer->normalize($object->getCreatedAt(), $format, $context),
'expiresAt' => $this->normalizer->normalize($object->getExpireAt(), $format, $context),
'users' => $this->normalizer->normalize($object->getUsers()->getValues(), $format, $context),
];
}
public function supportsNormalization($data, ?string $format = null): bool
{
return 'json' === $format && $data instanceof StoredObjectLock;
}
}

View File

@@ -13,8 +13,11 @@ namespace Chill\DocStoreBundle\Serializer\Normalizer;
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectLockMethodEnum;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface;
use Chill\DocStoreBundle\Service\Lock\StoredObjectEditorDecisionManagerInterface;
use Chill\DocStoreBundle\Service\Lock\StoredObjectLockManager;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
@@ -42,6 +45,8 @@ final class StoredObjectNormalizer implements NormalizerInterface, NormalizerAwa
private readonly UrlGeneratorInterface $urlGenerator,
private readonly Security $security,
private readonly TempUrlGeneratorInterface $tempUrlGenerator,
private readonly StoredObjectEditorDecisionManagerInterface $storedObjectEditorDecisionManager,
private readonly StoredObjectLockManager $storedObjectLockManager,
) {}
public function normalize($object, ?string $format = null, array $context = [])
@@ -58,6 +63,7 @@ final class StoredObjectNormalizer implements NormalizerInterface, NormalizerAwa
'createdBy' => $this->normalizer->normalize($object->getCreatedBy(), $format, $context),
'currentVersion' => $this->normalizer->normalize($object->getCurrentVersion(), $format, [...$context, [AbstractNormalizer::GROUPS => 'read']]),
'totalVersions' => $object->getVersions()->count(),
'lock' => $this->storedObjectLockManager->hasLock($object) ? $this->normalizer->normalize($this->storedObjectLockManager->getLock($object), $format, $context) : null,
];
// deprecated property
@@ -110,6 +116,11 @@ final class StoredObjectNormalizer implements NormalizerInterface, NormalizerAwa
];
}
$datas['_editor'] = [
'webdav' => $canEdit && $this->storedObjectEditorDecisionManager->canEdit($object, StoredObjectLockMethodEnum::WEBDAV),
'wopi' => $canEdit && $this->storedObjectEditorDecisionManager->canEdit($object, StoredObjectLockMethodEnum::WOPI),
];
return $datas;
}

View File

@@ -0,0 +1,33 @@
<?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\Repository\StoredObjectLockRepository;
use Symfony\Component\Clock\ClockInterface;
class CleanOldLock
{
private readonly \DateInterval $cleanBefore;
public function __construct(
private readonly StoredObjectLockRepository $storedObjectLockRepository,
private readonly ClockInterface $clock,
) {
$this->cleanBefore = new \DateInterval('PT24H');
}
public function __invoke(): void
{
$before = $this->clock->now()->sub($this->cleanBefore);
$this->storedObjectLockRepository->removeExpiredBefore($before);
}
}

View File

@@ -0,0 +1,44 @@
<?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\MainBundle\Cron\CronJobInterface;
use Chill\MainBundle\Entity\CronJobExecution;
use Symfony\Component\Clock\ClockInterface;
final readonly class CleanOldLockCronJob implements CronJobInterface
{
private const KEY = 'clean-old-stored-object-lock';
public function __construct(private CleanOldLock $cleanOldLock, private ClockInterface $clock) {}
public function canRun(?CronJobExecution $cronJobExecution): bool
{
if (null === $cronJobExecution) {
return true;
}
return $this->clock->now() > $cronJobExecution->getLastStart()->add(new \DateInterval('PT12H'));
}
public function getKey(): string
{
return self::KEY;
}
public function run(array $lastExecutionData): ?array
{
($this->cleanOldLock)();
return [];
}
}

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,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;
enum LockTokenCheckResultEnum
{
case NO_LOCK_FOUND;
case LOCK_TOKEN_DO_NOT_MATCH;
case LOCK_TOKEN_DO_NOT_BELONG_TO_USER;
}

View File

@@ -0,0 +1,31 @@
<?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\StoredObjectLockMethodEnum;
final readonly class StoredObjectEditorDecisionManager implements StoredObjectEditorDecisionManagerInterface
{
public function __construct(private StoredObjectLockManager $lockManager) {}
public function canEdit(StoredObject $storedObject, StoredObjectLockMethodEnum $lockMethodEnum): bool
{
if (!$this->lockManager->hasLock($storedObject)) {
return true;
}
$lock = $this->lockManager->getLock($storedObject);
return StoredObjectLockMethodEnum::WOPI === $lockMethodEnum && StoredObjectLockMethodEnum::WOPI === $lock->getMethod();
}
}

View File

@@ -0,0 +1,37 @@
<?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\StoredObjectLockMethodEnum;
/**
* Responsible for managing decisions related to editing stored objects based on their lock status and lock methods.
*
* This class focuses solely on determining editability concerning existing locks on the stored object.
* It does not evaluate permissions.
*/
interface StoredObjectEditorDecisionManagerInterface
{
/**
* Determines if a stored object can be edited based on its lock status and the lock method.
*
* This method does not take into account the permissions to edit the stored object. Its purpose is to
* check that a future edition (and lock) will be allowed.
*
* @param StoredObject $storedObject the stored object to check for edit permissions
* @param StoredObjectLockMethodEnum $lockMethodEnum the lock method to verify against the stored object's lock
*
* @return bool returns true if the stored object can be edited, false otherwise
*/
public function canEdit(StoredObject $storedObject, StoredObjectLockMethodEnum $lockMethodEnum): bool;
}

View File

@@ -0,0 +1,146 @@
<?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 === $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())) {
if (null !== $lockId) {
$lock->setToken($lockId);
}
$lock->setExpireAt($expiresAt);
foreach ($users as $user) {
$lock->addUser($user);
}
$this->mustFlush = true;
return $lock;
}
}
}
if (null === $lockId) {
$lockId = 'opaquelocktoken:'.Uuid::uuid4();
}
// 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

@@ -13,16 +13,18 @@ namespace Chill\DocStoreBundle\Tests\Controller;
use Chill\DocStoreBundle\Controller\StoredObjectApiController;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\SerializerInterface;
/**
* @internal
*
* @coversNothing
* @covers \Chill\DocStoreBundle\Controller\StoredObjectApiController
*/
class StoredObjectApiControllerTest extends TestCase
{
@@ -52,4 +54,32 @@ class StoredObjectApiControllerTest extends TestCase
self::assertInstanceOf(JsonResponse::class, $actual);
self::assertEquals($r, $actual->getContent());
}
public function testGet(): void
{
$storedObject = new StoredObject();
$request = new Request();
$security = $this->createMock(Security::class);
$security->expects($this->once())->method('isGranted')
->with($this->identicalTo(StoredObjectRoleEnum::SEE->value), $this->identicalTo($storedObject))
->willReturn(true)
;
$entityManager = $this->createMock(EntityManagerInterface::class);
$serializer = $this->createMock(SerializerInterface::class);
$serializer->expects($this->once())->method('serialize')
->with($this->identicalTo($storedObject), 'json', $this->anything())
->willReturn($r = <<<'JSON'
{"type": "stored-object", "id": 1}
JSON);
$controller = new StoredObjectApiController($security, $serializer, $entityManager);
$actual = $controller->getStoredObject($storedObject, $request);
self::assertInstanceOf(JsonResponse::class, $actual);
self::assertEquals($r, $actual->getContent());
}
}

View File

@@ -0,0 +1,100 @@
<?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\Controller;
use Chill\DocStoreBundle\Controller\StoredObjectLockApiController;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Service\Lock\StoredObjectLockManager;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\PreconditionFailedHttpException;
use Symfony\Component\Security\Core\Security;
/**
* @internal
*
* @coversNothing
*/
class StoredObjectLockApiControllerTest extends TestCase
{
use ProphecyTrait;
public function testRemoveLockAccessDenied(): void
{
$security = $this->prophesize(Security::class);
$lockManager = $this->prophesize(StoredObjectLockManager::class);
$clock = new MockClock();
$storedObject = new StoredObject();
$security->isGranted(StoredObjectRoleEnum::EDIT, $storedObject)->willReturn(false);
$controller = new StoredObjectLockApiController(
$security->reveal(),
$lockManager->reveal(),
$clock
);
$this->expectException(AccessDeniedHttpException::class);
$controller->removeLock($storedObject);
}
public function testRemoveLockNoLockFound(): void
{
$security = $this->prophesize(Security::class);
$lockManager = $this->prophesize(StoredObjectLockManager::class);
$clock = new MockClock();
$storedObject = new StoredObject();
$security->isGranted(StoredObjectRoleEnum::EDIT, $storedObject)->willReturn(true);
$lockManager->hasLock($storedObject)->willReturn(false);
$controller = new StoredObjectLockApiController(
$security->reveal(),
$lockManager->reveal(),
$clock
);
$this->expectException(PreconditionFailedHttpException::class);
$this->expectExceptionMessage('No lock found for this stored object');
$controller->removeLock($storedObject);
}
public function testRemoveLockSuccess(): void
{
$security = $this->prophesize(Security::class);
$lockManager = $this->prophesize(StoredObjectLockManager::class);
$clock = new MockClock('2024-01-01 12:00:00');
$storedObject = new StoredObject();
$security->isGranted(StoredObjectRoleEnum::EDIT, $storedObject)->willReturn(true);
$lockManager->hasLock($storedObject)->willReturn(true);
$lockManager->deleteLock($storedObject, $clock->now())->shouldBeCalled();
$controller = new StoredObjectLockApiController(
$security->reveal(),
$lockManager->reveal(),
$clock
);
$response = $controller->removeLock($storedObject);
self::assertSame(Response::HTTP_ACCEPTED, $response->getStatusCode());
self::assertNull($response->getContent() ?: null);
}
}

View File

@@ -47,15 +47,11 @@ class WebdavControllerTest extends KernelTestCase
$storedObjectManager = new MockedStoredObjectManager();
}
if (null === $entityManager) {
$entityManager = $this->createMock(EntityManagerInterface::class);
}
$security = $this->prophesize(Security::class);
$security->isGranted(Argument::in(['SEE_AND_EDIT', 'SEE']), Argument::type(StoredObject::class))
->willReturn(true);
return new WebdavController($this->engine, $storedObjectManager, $security->reveal(), $entityManager);
return new WebdavController($this->engine, $storedObjectManager, $security->reveal());
}
private function buildDocument(): StoredObject
@@ -152,6 +148,16 @@ class WebdavControllerTest extends KernelTestCase
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href>
<d:propstat>
<d:prop>
<d:supportedlock>
<d:lockentry>
<d:lockscope>
<d:exclusive/>
</d:lockscope>
<d:locktype>
<d:write/>
</d:locktype>
</d:lockentry>
</d:supportedlock>
<d:resourcetype/>
<d:getcontenttype>application/vnd.oasis.opendocument.text</d:getcontenttype>
</d:prop>
@@ -241,6 +247,16 @@ class WebdavControllerTest extends KernelTestCase
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href>
<d:propstat>
<d:prop>
<d:supportedlock>
<d:lockentry>
<d:lockscope>
<d:exclusive/>
</d:lockscope>
<d:locktype>
<d:write/>
</d:locktype>
</d:lockentry>
</d:supportedlock>
<!-- the date scraped from a webserver is >Sun, 10 Sep 2023 14:10:23 GMT -->
<d:getlastmodified>Wed, 13 Sep 2023 14:15:00 +0200</d:getlastmodified>
</d:prop>
@@ -267,6 +283,16 @@ class WebdavControllerTest extends KernelTestCase
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href>
<d:propstat>
<d:prop>
<d:supportedlock>
<d:lockentry>
<d:lockscope>
<d:exclusive/>
</d:lockscope>
<d:locktype>
<d:write/>
</d:locktype>
</d:lockentry>
</d:supportedlock>
<d:resourcetype/>
<d:creationdate/>
<d:getlastmodified>Wed, 13 Sep 2023 14:15:00 +0200</d:getlastmodified>
@@ -390,30 +416,6 @@ class WebdavControllerTest extends KernelTestCase
self::assertEquals('application/vnd.oasis.opendocument.text', $response->headers->get('content-type'));
self::assertEquals(5, $response->headers->get('content-length'));
}
public function testPutDocument(): void
{
$document = $this->buildDocument();
$entityManager = $this->createMock(EntityManagerInterface::class);
$storedObjectManager = $this->createMock(StoredObjectManagerInterface::class);
// entity manager must be flushed
$entityManager->expects($this->once())
->method('flush');
// object must be written by StoredObjectManager
$storedObjectManager->expects($this->once())
->method('write')
->with($this->identicalTo($document), $this->identicalTo('1234'));
$controller = $this->buildController($entityManager, $storedObjectManager);
$request = new Request(content: '1234');
$response = $controller->putDocument($document, $request);
self::assertEquals(204, $response->getStatusCode());
self::assertEquals('', $response->getContent());
}
}
class MockedStoredObjectManager implements StoredObjectManagerInterface

View File

@@ -0,0 +1,174 @@
<?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\Controller;
use Chill\DocStoreBundle\Controller\WebdavLockController;
use Chill\DocStoreBundle\Dav\Utils\LockTimeoutAnalyzer;
use Chill\DocStoreBundle\Dav\Utils\LockTokenParser;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectLock;
use Chill\DocStoreBundle\Entity\StoredObjectLockMethodEnum;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Service\Lock\StoredObjectLockManager;
use Chill\MainBundle\Entity\User;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Ramsey\Uuid\Uuid;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Security;
use Twig\Environment;
/**
* @internal
*
* @coversNothing
*/
class WebdavLockControllerTest extends KernelTestCase
{
use ProphecyTrait;
private Environment $twig;
private MockClock $clock;
protected function setUp(): void
{
self::bootKernel();
$this->twig = self::getContainer()->get(Environment::class);
$this->clock = new MockClock();
}
private function buildController(
StoredObjectLockManager $lockManager,
Security $security,
LockTimeoutAnalyzer $lockTimeoutAnalyzer,
LockTokenParser $lockTokenParser,
): WebdavLockController {
return new WebdavLockController(
$lockManager,
$security,
$this->twig,
$lockTimeoutAnalyzer,
$this->clock,
$lockTokenParser
);
}
public function testLockAndRenewDocument(): void
{
$storedObject = new StoredObject();
// Set a fixed UUID for testing
$reflection = new \ReflectionClass($storedObject);
$uuidProp = $reflection->getProperty('uuid');
$uuidProp->setValue($storedObject, Uuid::fromString('716e6688-4579-4938-acf3-c4ab5856803b'));
$user = new User();
$lockManager = $this->prophesize(StoredObjectLockManager::class);
$security = $this->prophesize(Security::class);
$lockTimeoutAnalyzer = new LockTimeoutAnalyzer();
$lockTokenParser = new LockTokenParser();
$security->getUser()->willReturn($user);
$security->isGranted(StoredObjectRoleEnum::EDIT->value, $storedObject)->willReturn(true);
$lockToken = 'opaquelocktoken:'.Uuid::uuid4()->toString();
$lock = new StoredObjectLock(
$storedObject,
StoredObjectLockMethodEnum::WEBDAV,
$this->clock->now(),
$lockToken,
$this->clock->now()->add(new \DateInterval('PT180S')),
[$user]
);
// Initial LOCK request
$lockManager->hasLock($storedObject)->willReturn(false);
$lockManager->setLock(
$storedObject,
StoredObjectLockMethodEnum::WEBDAV,
null, // lockId
Argument::type(\DateTimeImmutable::class),
[$user]
)->willReturn($lock);
$controller = $this->buildController(
$lockManager->reveal(),
$security->reveal(),
$lockTimeoutAnalyzer,
$lockTokenParser
);
$initialBody = <<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<lockinfo xmlns="DAV:"><lockscope><exclusive/></lockscope><locktype><write/></locktype><owner>LibreOffice - Julien Fastré</owner></lockinfo>
XML;
$request = new Request([], [], [], [], [], [
'HTTP_TIMEOUT' => 'Second-180',
], $initialBody);
$request->setMethod('LOCK');
$response = $controller->lockDocument($storedObject, $request);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
$tokenFromHeader = $response->headers->get('Lock-Token');
self::assertNotNull($tokenFromHeader);
// Extract token from XML content
$content = $response->getContent();
self::assertStringContainsString($lockToken, $content);
$dom = new \DOMDocument();
$dom->loadXML($content);
$xpath = new \DOMXPath($dom);
$xpath->registerNamespace('D', 'DAV:');
$tokenFromXml = $xpath->query('//D:locktoken/D:href')->item(0)->nodeValue;
self::assertSame($lockToken, $tokenFromHeader);
self::assertSame($lockToken, $tokenFromXml);
// RENEW request
$lockManager->checkLock($storedObject, $lockToken, $user)->willReturn(true);
// During renew, setLock is called again to update expiration
$lockManager->setLock(
$storedObject,
StoredObjectLockMethodEnum::WEBDAV,
null, // lockId is null because in lockOrRenewToken it's not passed, and it uses default null
Argument::type(\DateTimeImmutable::class),
[$user]
)->willReturn($lock);
$renewRequest = new Request([], [], [], [], [], [
'HTTP_IF' => '(<'.$lockToken.'>)',
'HTTP_TIMEOUT' => 'Second-180',
], '');
$renewRequest->setMethod('LOCK');
$renewResponse = $controller->lockDocument($storedObject, $renewRequest);
self::assertSame(Response::HTTP_OK, $renewResponse->getStatusCode());
$renewTokenFromHeader = $renewResponse->headers->get('Lock-Token');
$renewContent = $renewResponse->getContent();
$domRenew = new \DOMDocument();
$domRenew->loadXML($renewContent);
$xpathRenew = new \DOMXPath($domRenew);
$xpathRenew->registerNamespace('D', 'DAV:');
$renewTokenFromXml = $xpathRenew->query('//D:locktoken/D:href')->item(0)->nodeValue;
self::assertSame($lockToken, $renewTokenFromHeader);
self::assertSame($lockToken, $renewTokenFromXml);
}
}

View File

@@ -0,0 +1,236 @@
<?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\Controller;
use Chill\DocStoreBundle\Controller\WebdavPutController;
use Chill\DocStoreBundle\Dav\Utils\LockTokenParser;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Service\Lock\StoredObjectLockManager;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\MainBundle\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Prophecy\PhpUnit\ProphecyTrait;
use Ramsey\Uuid\Uuid;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Security;
/**
* @internal
*
* @coversNothing
*/
class WebdavPutControllerTest extends KernelTestCase
{
use ProphecyTrait;
private function buildDocument(): StoredObject
{
$object = (new StoredObject())
->registerVersion(type: 'application/vnd.oasis.opendocument.text')
->getStoredObject();
$reflectionObject = new \ReflectionClass($object);
$reflectionObjectUuid = $reflectionObject->getProperty('uuid');
$reflectionObjectUuid->setValue($object, Uuid::fromString('716e6688-4579-4938-acf3-c4ab5856803b'));
return $object;
}
private function buildController(EntityManagerInterface $entityManager, StoredObjectManagerInterface $storedObjectManager, LockTokenParser $lockTokenParser, StoredObjectLockManager $storedObjectLockManager, Security $security): WebdavPutController
{
return new WebdavPutController($storedObjectManager, $security, $entityManager, $lockTokenParser, $storedObjectLockManager);
}
public function testPutDocumentHappyScenario(): void
{
$user = new User();
$document = $this->buildDocument();
$entityManager = $this->prophesize(EntityManagerInterface::class);
// entity manager must be flushed
$entityManager->flush()->shouldBeCalled();
// object must be written by StoredObjectManager
$storedObjectManager = $this->prophesize(StoredObjectManagerInterface::class);
$storedObjectManager->write($document, '1234')->shouldBeCalled();
$security = $this->prophesize(Security::class);
$security->getUser()->willReturn($user);
$security->isGranted(StoredObjectRoleEnum::EDIT->value, $document)
->willReturn(true)->shouldBeCalled();
$storedObjectLockManager = $this->prophesize(StoredObjectLockManager::class);
$storedObjectLockManager->checkLock($document, $token = 'opaquelocktoken:88f391da-2f72-11f1-bf99-67e47d3105b8', $user)
->willReturn(true)->shouldBeCalled();
$storedObjectLockManager->hasLock($document)->willReturn(true);
$controller = $this->buildController(
$entityManager->reveal(),
$storedObjectManager->reveal(),
new LockTokenParser(),
$storedObjectLockManager->reveal(),
$security->reveal()
);
$request = new Request(
content: '1234',
);
$request->headers->set('If', '"(<'.$token.'>)"');
$response = $controller->putDocument($document, $request);
self::assertEquals(204, $response->getStatusCode());
self::assertEquals('', $response->getContent());
}
public function testPutDocumentNoLockFound(): void
{
$user = new User();
$document = $this->buildDocument();
$entityManager = $this->prophesize(EntityManagerInterface::class);
$storedObjectManager = $this->prophesize(StoredObjectManagerInterface::class);
$security = $this->prophesize(Security::class);
$security->getUser()->willReturn($user);
$storedObjectLockManager = $this->prophesize(StoredObjectLockManager::class);
$storedObjectLockManager->checkLock($document, $token = 'token', $user)
->willReturn(\Chill\DocStoreBundle\Service\Lock\LockTokenCheckResultEnum::NO_LOCK_FOUND);
$controller = $this->buildController(
$entityManager->reveal(),
$storedObjectManager->reveal(),
new LockTokenParser(),
$storedObjectLockManager->reveal(),
$security->reveal()
);
$request = new Request();
$request->headers->set('If', '"(<'.$token.'>)"');
$this->expectException(\Symfony\Component\HttpKernel\Exception\PreconditionFailedHttpException::class);
$controller->putDocument($document, $request);
}
public function testPutDocumentLockTokenDoNotMatch(): void
{
$user = new User();
$document = $this->buildDocument();
$entityManager = $this->prophesize(EntityManagerInterface::class);
$storedObjectManager = $this->prophesize(StoredObjectManagerInterface::class);
$security = $this->prophesize(Security::class);
$security->getUser()->willReturn($user);
$storedObjectLockManager = $this->prophesize(StoredObjectLockManager::class);
$storedObjectLockManager->checkLock($document, $token = 'token', $user)
->willReturn(\Chill\DocStoreBundle\Service\Lock\LockTokenCheckResultEnum::LOCK_TOKEN_DO_NOT_MATCH);
$controller = $this->buildController(
$entityManager->reveal(),
$storedObjectManager->reveal(),
new LockTokenParser(),
$storedObjectLockManager->reveal(),
$security->reveal()
);
$request = new Request();
$request->headers->set('If', '"(<'.$token.'>)"');
$this->expectException(\Symfony\Component\HttpKernel\Exception\PreconditionFailedHttpException::class);
$controller->putDocument($document, $request);
}
public function testPutDocumentLockTokenDoNotBelongToUser(): void
{
$user = new User();
$document = $this->buildDocument();
$entityManager = $this->prophesize(EntityManagerInterface::class);
$storedObjectManager = $this->prophesize(StoredObjectManagerInterface::class);
$security = $this->prophesize(Security::class);
$security->getUser()->willReturn($user);
$storedObjectLockManager = $this->prophesize(StoredObjectLockManager::class);
$storedObjectLockManager->checkLock($document, $token = 'token', $user)
->willReturn(\Chill\DocStoreBundle\Service\Lock\LockTokenCheckResultEnum::LOCK_TOKEN_DO_NOT_BELONG_TO_USER);
$controller = $this->buildController(
$entityManager->reveal(),
$storedObjectManager->reveal(),
new LockTokenParser(),
$storedObjectLockManager->reveal(),
$security->reveal()
);
$request = new Request();
$request->headers->set('If', '"(<'.$token.'>)"');
$this->expectException(\Symfony\Component\HttpKernel\Exception\ConflictHttpException::class);
$controller->putDocument($document, $request);
}
public function testPutDocumentNoLockGivenButTokenRequired(): void
{
$user = new User();
$document = $this->buildDocument();
$entityManager = $this->prophesize(EntityManagerInterface::class);
$storedObjectManager = $this->prophesize(StoredObjectManagerInterface::class);
$security = $this->prophesize(Security::class);
$security->getUser()->willReturn($user);
$storedObjectLockManager = $this->prophesize(StoredObjectLockManager::class);
$storedObjectLockManager->hasLock($document)->willReturn(true);
$controller = $this->buildController(
$entityManager->reveal(),
$storedObjectManager->reveal(),
new LockTokenParser(),
$storedObjectLockManager->reveal(),
$security->reveal()
);
$request = new Request();
$this->expectException(\Symfony\Component\HttpKernel\Exception\ConflictHttpException::class);
$controller->putDocument($document, $request);
}
public function testPutDocumentNotGranted(): void
{
$user = new User();
$document = $this->buildDocument();
$entityManager = $this->prophesize(EntityManagerInterface::class);
$storedObjectManager = $this->prophesize(StoredObjectManagerInterface::class);
$security = $this->prophesize(Security::class);
$security->getUser()->willReturn($user);
$security->isGranted(StoredObjectRoleEnum::EDIT->value, $document)
->willReturn(false);
$storedObjectLockManager = $this->prophesize(StoredObjectLockManager::class);
$storedObjectLockManager->hasLock($document)->willReturn(false);
$controller = $this->buildController(
$entityManager->reveal(),
$storedObjectManager->reveal(),
new LockTokenParser(),
$storedObjectLockManager->reveal(),
$security->reveal()
);
$request = new Request();
$this->expectException(\Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException::class);
$controller->putDocument($document, $request);
}
}

View File

@@ -0,0 +1,62 @@
<?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\Dav\Utils;
use Chill\DocStoreBundle\Dav\Utils\LockTimeoutAnalyzer;
use PHPUnit\Framework\TestCase;
/**
* @internal
*
* @coversNothing
*/
class LockTimeoutAnalyzerTest extends TestCase
{
/**
* @dataProvider provideTimeoutData
*/
public function testAnalyzeTimeout(string $content, \DateInterval $expected): void
{
$analyzer = new LockTimeoutAnalyzer();
$result = $analyzer->analyzeTimeout($content);
self::assertEquals($expected, $result);
}
public static function provideTimeoutData(): iterable
{
yield 'Second-1800' => [
'Second-1800',
new \DateInterval('PT1800S'),
];
yield 'Second-3600' => [
'Second-3600',
new \DateInterval('PT3600S'),
];
yield 'Infinite' => [
'Infinite',
new \DateInterval('PT24H'), // Typically "Infinite" is represented by a long duration or special handling; RFC says it should be long. Let's assume a long one for now or check common practices.
];
yield 'Multiple types, first is Second' => [
'Second-1800, Infinite',
new \DateInterval('PT1800S'),
];
yield 'Multiple types, first is Infinite' => [
'Infinite, Second-1800',
new \DateInterval('PT24H'),
];
}
}

View File

@@ -0,0 +1,70 @@
<?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\Dav\Utils;
use Chill\DocStoreBundle\Dav\Utils\LockTokenParser;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
/**
* @internal
*
* @coversNothing
*/
class LockTokenParserTest extends TestCase
{
private LockTokenParser $parser;
protected function setUp(): void
{
$this->parser = new LockTokenParser();
}
/**
* @dataProvider provideIfConditions
*/
public function testParseIfCondition(string $ifHeader, ?string $expectedToken): void
{
$request = new Request();
$request->headers->set('if', $ifHeader);
$this->assertSame($expectedToken, $this->parser->parseIfCondition($request));
}
public static function provideIfConditions(): array
{
return [
'standard lock token' => [
'(<opaquelocktoken:f81d4fae-7dec-11d0-a765-00a0c91e6bf6>)',
'opaquelocktoken:f81d4fae-7dec-11d0-a765-00a0c91e6bf6',
],
'with resource uri' => [
'<http://www.ics.uci.edu/users/f/fielding/index.html> (<opaquelocktoken:f81d4fae-7dec-11d0-a765-00a0c91e6bf6>)',
'opaquelocktoken:f81d4fae-7dec-11d0-a765-00a0c91e6bf6',
],
'no match' => [
'some other value',
null,
],
'with NOT' => [
'(Not <opaquelocktoken:f81d4fae-7dec-11d0-a765-00a0c91e6bf6>)',
'opaquelocktoken:f81d4fae-7dec-11d0-a765-00a0c91e6bf6',
],
];
}
public function testParseIfConditionWithNoHeader(): void
{
$request = new Request();
$this->assertNull($this->parser->parseIfCondition($request));
}
}

View File

@@ -0,0 +1,76 @@
<?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\Entity;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectLock;
use Chill\DocStoreBundle\Entity\StoredObjectLockMethodEnum;
use PHPUnit\Framework\TestCase;
/**
* @internal
*
* @covers \Chill\DocStoreBundle\Entity\StoredObjectLock
*/
class StoredObjectLockTest extends TestCase
{
public function testIsActiveAtWithNoExpiration(): void
{
$storedObject = new StoredObject();
$now = new \DateTimeImmutable();
$lock = new StoredObjectLock(
$storedObject,
StoredObjectLockMethodEnum::WOPI,
$now,
'token',
null
);
self::assertTrue($lock->isActiveAt($now));
self::assertTrue($lock->isActiveAt($now->modify('+1 year')));
}
public function testIsActiveAtWithFutureExpiration(): void
{
$storedObject = new StoredObject();
$now = new \DateTimeImmutable();
$expireAt = $now->modify('+1 hour');
$lock = new StoredObjectLock(
$storedObject,
StoredObjectLockMethodEnum::WOPI,
$now,
'token',
$expireAt
);
self::assertTrue($lock->isActiveAt($now));
self::assertTrue($lock->isActiveAt($now->modify('+30 minutes')));
}
public function testIsActiveAtWithPastExpiration(): void
{
$storedObject = new StoredObject();
$now = new \DateTimeImmutable();
$expireAt = $now->modify('-1 hour');
$lock = new StoredObjectLock(
$storedObject,
StoredObjectLockMethodEnum::WOPI,
$now->modify('-2 hours'),
'token',
$expireAt
);
self::assertFalse($lock->isActiveAt($now));
self::assertFalse($lock->isActiveAt($expireAt));
self::assertFalse($lock->isActiveAt($expireAt->modify('+1 second')));
}
}

View File

@@ -0,0 +1,96 @@
<?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\Serializer\Normalizer;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectLock;
use Chill\DocStoreBundle\Entity\StoredObjectLockMethodEnum;
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectLockNormalizer;
use Chill\MainBundle\Entity\User;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
/**
* @internal
*
* @covers \Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectLockNormalizer
*/
class StoredObjectLockNormalizerTest extends TestCase
{
private StoredObjectLockNormalizer $normalizer;
private NormalizerInterface $childNormalizer;
protected function setUp(): void
{
$this->normalizer = new StoredObjectLockNormalizer();
$this->childNormalizer = $this->createMock(NormalizerInterface::class);
$this->normalizer->setNormalizer($this->childNormalizer);
}
public function testSupportsNormalization(): void
{
$storedObject = new StoredObject('pending');
$lock = new StoredObjectLock(
$storedObject,
StoredObjectLockMethodEnum::WEBDAV,
new \DateTimeImmutable()
);
$this->assertTrue($this->normalizer->supportsNormalization($lock, 'json'));
$this->assertFalse($this->normalizer->supportsNormalization($lock, 'xml'));
$this->assertFalse($this->normalizer->supportsNormalization($storedObject, 'json'));
}
public function testNormalize(): void
{
$storedObject = new StoredObject('pending');
$user = $this->createMock(User::class);
$expireAt = new \DateTimeImmutable('2026-04-08 23:00:00');
$createdAt = new \DateTimeImmutable('2026-04-08 22:00:00');
$lock = new StoredObjectLock(
$storedObject,
StoredObjectLockMethodEnum::WEBDAV,
$createdAt,
'some-token',
$expireAt,
[$user]
);
$this->childNormalizer->expects($this->exactly(3))
->method('normalize')
->withAnyParameters()
->willReturnCallback(function ($data, $format, $context) use ($createdAt, $expireAt, $user) {
if ($data === $createdAt) {
return '2026-04-08T22:00:00+00:00';
}
if ($data === $expireAt) {
return '2026-04-08T23:00:00+00:00';
}
if ($data === [$user]) {
return [['username' => 'testuser']];
}
return null;
});
$result = $this->normalizer->normalize($lock, 'json');
$this->assertEquals([
'uuid' => $lock->getUuid(),
'method' => StoredObjectLockMethodEnum::WEBDAV,
'createdAt' => '2026-04-08T22:00:00+00:00',
'expiresAt' => '2026-04-08T23:00:00+00:00',
'users' => [['username' => 'testuser']],
], $result);
}
}

View File

@@ -14,10 +14,15 @@ namespace Chill\DocStoreBundle\Tests\Serializer\Normalizer;
use Chill\DocStoreBundle\AsyncUpload\SignedUrl;
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Entity\StoredObjectLock;
use Chill\DocStoreBundle\Entity\StoredObjectLockMethodEnum;
use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface;
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectNormalizer;
use Chill\DocStoreBundle\Service\Lock\StoredObjectEditorDecisionManagerInterface;
use Chill\DocStoreBundle\Service\Lock\StoredObjectLockManager;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
@@ -29,6 +34,8 @@ use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
*/
class StoredObjectNormalizerTest extends TestCase
{
use ProphecyTrait;
public function testNormalize(): void
{
$storedObject = new StoredObject();
@@ -37,45 +44,51 @@ class StoredObjectNormalizerTest extends TestCase
$idProperty = $reflection->getProperty('id');
$idProperty->setValue($storedObject, 1);
$jwtProvider = $this->createMock(JWTDavTokenProviderInterface::class);
$jwtProvider->expects($this->once())->method('createToken')->withAnyParameters()->willReturn('token');
$jwtProvider->expects($this->once())->method('getTokenExpiration')->with('token')->willReturn($d = new \DateTimeImmutable());
$jwtProvider = $this->prophesize(JWTDavTokenProviderInterface::class);
$jwtProvider->createToken(Argument::cetera())->willReturn('token');
$jwtProvider->getTokenExpiration('token')->willReturn($d = new \DateTimeImmutable());
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator->expects($this->once())->method('generate')
->with(
'chill_docstore_dav_document_get',
[
'uuid' => $storedObject->getUuid(),
'access_token' => 'token',
],
UrlGeneratorInterface::ABSOLUTE_URL,
)
->willReturn($davLink = 'http://localhost/dav/token');
$urlGenerator = $this->prophesize(UrlGeneratorInterface::class);
$urlGenerator->generate(
'chill_docstore_dav_document_get',
[
'uuid' => $storedObject->getUuid(),
'access_token' => 'token',
],
UrlGeneratorInterface::ABSOLUTE_URL,
)->willReturn($davLink = 'http://localhost/dav/token');
$security = $this->createMock(Security::class);
$security->expects($this->exactly(2))->method('isGranted')
->with(
$this->logicalOr(StoredObjectRoleEnum::EDIT->value, StoredObjectRoleEnum::SEE->value),
$storedObject
)
->willReturn(true);
$security = $this->prophesize(Security::class);
$security->isGranted(Argument::type('string'), $storedObject)->willReturn(true);
$globalNormalizer = $this->createMock(NormalizerInterface::class);
$globalNormalizer->expects($this->exactly(3))->method('normalize')
->withAnyParameters()
->willReturnCallback(function (?object $object, string $format, array $context) {
if (null === $object) {
$globalNormalizer = $this->prophesize(NormalizerInterface::class);
$globalNormalizer->normalize(Argument::cetera())
->will(function ($args) {
if (null === $args[0]) {
return null;
}
return ['sub' => 'sub'];
});
$tempUrlGenerator = $this->createMock(TempUrlGeneratorInterface::class);
$tempUrlGenerator = $this->prophesize(TempUrlGeneratorInterface::class);
$normalizer = new StoredObjectNormalizer($jwtProvider, $urlGenerator, $security, $tempUrlGenerator);
$normalizer->setNormalizer($globalNormalizer);
$storedObjectEditorDecisionManager = $this->prophesize(StoredObjectEditorDecisionManagerInterface::class);
$storedObjectEditorDecisionManager->canEdit($storedObject, StoredObjectLockMethodEnum::WEBDAV)->willReturn(true);
$storedObjectEditorDecisionManager->canEdit($storedObject, StoredObjectLockMethodEnum::WOPI)->willReturn(true);
$storedObjectLockManager = $this->prophesize(StoredObjectLockManager::class);
$storedObjectLockManager->hasLock($storedObject)->willReturn(false);
$normalizer = new StoredObjectNormalizer(
$jwtProvider->reveal(),
$urlGenerator->reveal(),
$security->reveal(),
$tempUrlGenerator->reveal(),
$storedObjectEditorDecisionManager->reveal(),
$storedObjectLockManager->reveal()
);
$normalizer->setNormalizer($globalNormalizer->reveal());
$actual = $normalizer->normalize($storedObject, 'json');
@@ -93,11 +106,67 @@ class StoredObjectNormalizerTest extends TestCase
self::assertArrayHasKey('datas', $actual);
self::assertArrayHasKey('createdAt', $actual);
self::assertArrayHasKey('createdBy', $actual);
self::assertArrayHasKey('lock', $actual);
self::assertNull($actual['lock']);
self::assertArrayHasKey('_permissions', $actual);
self::assertEqualsCanonicalizing(['canEdit' => true, 'canSee' => true], $actual['_permissions']);
self::assertArrayHaskey('_links', $actual);
self::assertArrayHasKey('dav_link', $actual['_links']);
self::assertEqualsCanonicalizing(['href' => $davLink, 'expiration' => $d->getTimestamp()], $actual['_links']['dav_link']);
self::assertArrayHasKey('_editor', $actual);
self::assertArrayHasKey('webdav', $actual['_editor']);
self::assertArrayHasKey('wopi', $actual['_editor']);
self::assertTrue($actual['_editor']['webdav']);
self::assertTrue($actual['_editor']['wopi']);
}
public function testNormalizeWithLock(): void
{
$storedObject = new StoredObject();
$storedObject->setTitle('test');
$reflection = new \ReflectionClass(StoredObject::class);
$idProperty = $reflection->getProperty('id');
$idProperty->setValue($storedObject, 1);
$jwtProvider = $this->prophesize(JWTDavTokenProviderInterface::class);
$jwtProvider->createToken(Argument::cetera())->willReturn('token');
$jwtProvider->getTokenExpiration('token')->willReturn(new \DateTimeImmutable());
$urlGenerator = $this->prophesize(UrlGeneratorInterface::class);
$urlGenerator->generate(Argument::cetera())->willReturn('http://localhost');
$security = $this->prophesize(Security::class);
$security->isGranted(Argument::cetera())->willReturn(true);
$lock = $this->prophesize(StoredObjectLock::class);
$globalNormalizer = $this->prophesize(NormalizerInterface::class);
$globalNormalizer->normalize(Argument::cetera())->willReturn(['sub' => 'sub']);
$tempUrlGenerator = $this->prophesize(TempUrlGeneratorInterface::class);
$storedObjectEditorDecisionManager = $this->prophesize(StoredObjectEditorDecisionManagerInterface::class);
$storedObjectEditorDecisionManager->canEdit(Argument::cetera())->willReturn(false);
$storedObjectLockManager = $this->prophesize(StoredObjectLockManager::class);
$storedObjectLockManager->hasLock($storedObject)->willReturn(true);
$storedObjectLockManager->getLock($storedObject)->willReturn($lock->reveal());
$normalizer = new StoredObjectNormalizer(
$jwtProvider->reveal(),
$urlGenerator->reveal(),
$security->reveal(),
$tempUrlGenerator->reveal(),
$storedObjectEditorDecisionManager->reveal(),
$storedObjectLockManager->reveal()
);
$normalizer->setNormalizer($globalNormalizer->reveal());
$actual = $normalizer->normalize($storedObject, 'json');
self::assertArrayHasKey('lock', $actual);
self::assertEquals(['sub' => 'sub'], $actual['lock']);
self::assertArrayHasKey('_editor', $actual);
self::assertFalse($actual['_editor']['webdav']);
self::assertFalse($actual['_editor']['wopi']);
}
public function testWithDownloadLinkOnly(): void
@@ -109,32 +178,42 @@ class StoredObjectNormalizerTest extends TestCase
$idProperty = $reflection->getProperty('id');
$idProperty->setValue($storedObject, 1);
$jwtProvider = $this->createMock(JWTDavTokenProviderInterface::class);
$jwtProvider->expects($this->never())->method('createToken')->withAnyParameters();
$jwtProvider = $this->prophesize(JWTDavTokenProviderInterface::class);
$jwtProvider->createToken(Argument::cetera())->shouldNotBeCalled();
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator->expects($this->never())->method('generate');
$urlGenerator = $this->prophesize(UrlGeneratorInterface::class);
$urlGenerator->generate(Argument::cetera())->shouldNotBeCalled();
$security = $this->createMock(Security::class);
$security->expects($this->never())->method('isGranted');
$security = $this->prophesize(Security::class);
$security->isGranted(Argument::cetera())->shouldNotBeCalled();
$globalNormalizer = $this->createMock(NormalizerInterface::class);
$globalNormalizer->expects($this->exactly(4))->method('normalize')
->withAnyParameters()
->willReturnCallback(function (?object $object, string $format, array $context) {
if (null === $object) {
$globalNormalizer = $this->prophesize(NormalizerInterface::class);
$globalNormalizer->normalize(Argument::cetera())
->will(function ($args) {
if (null === $args[0]) {
return null;
}
return ['sub' => 'sub'];
});
$tempUrlGenerator = $this->createMock(TempUrlGeneratorInterface::class);
$tempUrlGenerator->expects($this->once())->method('generate')->with('GET', $storedObject->getCurrentVersion()->getFilename(), $this->isType('int'))
$tempUrlGenerator = $this->prophesize(TempUrlGeneratorInterface::class);
$tempUrlGenerator->generate('GET', $storedObject->getCurrentVersion()->getFilename(), Argument::type('int'))
->willReturn(new SignedUrl('GET', 'https://some-link/test', new \DateTimeImmutable('300 seconds'), $storedObject->getCurrentVersion()->getFilename()));
$normalizer = new StoredObjectNormalizer($jwtProvider, $urlGenerator, $security, $tempUrlGenerator);
$normalizer->setNormalizer($globalNormalizer);
$storedObjectEditorDecisionManager = $this->prophesize(StoredObjectEditorDecisionManagerInterface::class);
$storedObjectLockManager = $this->prophesize(StoredObjectLockManager::class);
$storedObjectLockManager->hasLock($storedObject)->willReturn(false);
$normalizer = new StoredObjectNormalizer(
$jwtProvider->reveal(),
$urlGenerator->reveal(),
$security->reveal(),
$tempUrlGenerator->reveal(),
$storedObjectEditorDecisionManager->reveal(),
$storedObjectLockManager->reveal()
);
$normalizer->setNormalizer($globalNormalizer->reveal());
$actual = $normalizer->normalize($storedObject, 'json', ['groups' => ['read', 'read:download-link-only']]);

View File

@@ -0,0 +1,89 @@
<?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\Service\Lock\CleanOldLock;
use Chill\DocStoreBundle\Service\Lock\CleanOldLockCronJob;
use Chill\MainBundle\Entity\CronJobExecution;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Symfony\Component\Clock\MockClock;
/**
* @covers \Chill\DocStoreBundle\Service\Lock\CleanOldLockCronJob
*
* @internal
*/
class CleanOldLockCronJobTest extends TestCase
{
use ProphecyTrait;
private MockClock $clock;
/**
* @var ObjectProphecy<CleanOldLock>
*/
private ObjectProphecy $cleanOldLock;
private CleanOldLockCronJob $cronJob;
protected function setUp(): void
{
$this->clock = new MockClock('2024-01-02 12:00:00');
$this->cleanOldLock = $this->prophesize(CleanOldLock::class);
$this->cronJob = new CleanOldLockCronJob(
$this->cleanOldLock->reveal(),
$this->clock
);
}
public function testGetKey(): void
{
self::assertSame('clean-old-stored-object-lock', $this->cronJob->getKey());
}
public function testRun(): void
{
$this->cleanOldLock->__invoke()->shouldBeCalled();
$result = $this->cronJob->run([]);
self::assertSame([], $result);
}
public function testCanRunWhenNullExecution(): void
{
self::assertTrue($this->cronJob->canRun(null));
}
public function testCanRunWhenLastStartIsWithin12Hours(): void
{
$execution = new CronJobExecution('key');
// lastStart is "now" (2024-01-02 12:00:00)
// clock is "now"
// now > lastStart + 12h is FALSE
self::assertFalse($this->cronJob->canRun($execution));
}
public function testCanRunWhenLastStartIsOlderThan12Hours(): void
{
$execution = new CronJobExecution('key');
$execution->setLastStart(new \DateTimeImmutable('2024-01-01 12:00:00'));
// clock is 2024-01-02 12:00:00
// lastStart + 12h is 2024-01-02 00:00:00
// 2024-01-02 12:00:00 > 2024-01-02 00:00:00 is TRUE
self::assertTrue($this->cronJob->canRun($execution));
}
}

View File

@@ -0,0 +1,57 @@
<?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\Repository\StoredObjectLockRepository;
use Chill\DocStoreBundle\Service\Lock\CleanOldLock;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Symfony\Component\Clock\MockClock;
/**
* @covers \Chill\DocStoreBundle\Service\Lock\CleanOldLock
*
* @internal
*/
class CleanOldLockTest extends TestCase
{
use ProphecyTrait;
private MockClock $clock;
/**
* @var ObjectProphecy<StoredObjectLockRepository>
*/
private ObjectProphecy $repository;
private CleanOldLock $service;
protected function setUp(): void
{
$this->clock = new MockClock('2024-01-02 12:00:00');
$this->repository = $this->prophesize(StoredObjectLockRepository::class);
$this->service = new CleanOldLock(
$this->repository->reveal(),
$this->clock
);
}
public function testInvoke(): void
{
$expectedBefore = $this->clock->now()->sub(new \DateInterval('PT24H'));
$this->repository->removeExpiredBefore($expectedBefore)->shouldBeCalled();
($this->service)();
}
}

View File

@@ -0,0 +1,87 @@
<?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\StoredObjectEditorDecisionManager;
use Chill\DocStoreBundle\Service\Lock\StoredObjectLockManager;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
/**
* @internal
*
* @coversNothing
*/
class StoredObjectEditorDecisionManagerTest extends TestCase
{
use ProphecyTrait;
private ObjectProphecy|StoredObjectLockManager $lockManager;
private StoredObjectEditorDecisionManager $decisionManager;
protected function setUp(): void
{
$this->lockManager = $this->prophesize(StoredObjectLockManager::class);
$this->decisionManager = new StoredObjectEditorDecisionManager($this->lockManager->reveal());
}
public function testCanEditReturnsTrueIfNoLock(): void
{
$storedObject = $this->prophesize(StoredObject::class)->reveal();
$this->lockManager->hasLock($storedObject)->willReturn(false);
$this->assertTrue($this->decisionManager->canEdit($storedObject, StoredObjectLockMethodEnum::WEBDAV));
$this->assertTrue($this->decisionManager->canEdit($storedObject, StoredObjectLockMethodEnum::WOPI));
}
public function testCanEditReturnsFalseIfExistingLockIsWebdav(): void
{
$storedObject = $this->prophesize(StoredObject::class)->reveal();
$lock = $this->prophesize(StoredObjectLock::class);
$lock->getMethod()->willReturn(StoredObjectLockMethodEnum::WEBDAV);
$this->lockManager->hasLock($storedObject)->willReturn(true);
$this->lockManager->getLock($storedObject)->willReturn($lock->reveal());
// Always false if existing lock is Webdav
$this->assertFalse($this->decisionManager->canEdit($storedObject, StoredObjectLockMethodEnum::WEBDAV));
$this->assertFalse($this->decisionManager->canEdit($storedObject, StoredObjectLockMethodEnum::WOPI));
}
public function testCanEditReturnsTrueIfBothAreWopi(): void
{
$storedObject = $this->prophesize(StoredObject::class)->reveal();
$lock = $this->prophesize(StoredObjectLock::class);
$lock->getMethod()->willReturn(StoredObjectLockMethodEnum::WOPI);
$this->lockManager->hasLock($storedObject)->willReturn(true);
$this->lockManager->getLock($storedObject)->willReturn($lock->reveal());
$this->assertTrue($this->decisionManager->canEdit($storedObject, StoredObjectLockMethodEnum::WOPI));
}
public function testCanEditReturnsFalseIfExistingLockIsWopiButRequestedIsWebdav(): void
{
$storedObject = $this->prophesize(StoredObject::class)->reveal();
$lock = $this->prophesize(StoredObjectLock::class);
$lock->getMethod()->willReturn(StoredObjectLockMethodEnum::WOPI);
$this->lockManager->hasLock($storedObject)->willReturn(true);
$this->lockManager->getLock($storedObject)->willReturn($lock->reveal());
$this->assertFalse($this->decisionManager->canEdit($storedObject, StoredObjectLockMethodEnum::WEBDAV));
}
}

View File

@@ -0,0 +1,222 @@
<?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 testSetLockExistingUpdatesLockWithoutNewLockId(): void
{
$document = new StoredObject();
$initialExpire = $this->clock->now()->add(new \DateInterval('PT10M'));
$lock = new StoredObjectLock($document, StoredObjectLockMethodEnum::WEBDAV, $this->clock->now(), 'initial-token', $initialExpire);
$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, null, $newExpire, [$user]);
$this->assertInstanceOf(StoredObjectLock::class, $result);
$this->assertCount(1, $document->getLocks());
$this->assertSame($lock, $document->getLocks()->first());
$this->assertSame('initial-token', $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

@@ -121,6 +121,32 @@ paths:
404:
description: "Not found"
/1.0/doc-store/stored-object/{uuid}:
get:
tags:
- storedobject
summary: Get a stored object
parameters:
- in: path
name: uuid
required: true
allowEmptyValue: false
description: The UUID of the storedObject
schema:
type: string
format: uuid
responses:
200:
description: "OK"
content:
application/json:
schema:
$ref: "#/components/schemas/StoredObject"
403:
description: "Unauthorized"
404:
description: "Not found"
/1.0/doc-store/stored-object/{uuid}/versions:
get:
tags:
@@ -196,3 +222,26 @@ paths:
$ref: '#/components/schemas/GenericDoc'
type: object
/1.0/doc-store/stored-object/{uuid}/lock:
delete:
tags:
- storedobject
summary: Force removing a lock on a stored object
parameters:
- in: path
name: uuid
required: true
allowEmptyValue: false
description: The UUID of the storedObjeect
schema:
type: string
format: uuid
responses:
204:
description: "No content"
403:
description: "Forbidden"
404:
description: "Not found"
412:
description: "No lock found for this stored object"

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

View File

@@ -0,0 +1,52 @@
<?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\Migrations\DocStore;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260331122339 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add locks for stored objects';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE chill_doc.stored_object_lock (
uuid UUID NOT NULL, method VARCHAR(10) NOT NULL,
token TEXT DEFAULT \'\' NOT NULL, createdAt TIMESTAMP(0) WITH TIME ZONE NOT NULL,
expireAt TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL, storedObject_id INT DEFAULT NULL,
PRIMARY KEY(uuid))');
$this->addSql('CREATE INDEX IDX_E66CB6516C99C13A ON chill_doc.stored_object_lock (storedObject_id)');
$this->addSql('COMMENT ON COLUMN chill_doc.stored_object_lock.uuid IS \'(DC2Type:uuid)\'');
$this->addSql('COMMENT ON COLUMN chill_doc.stored_object_lock.createdAt IS \'(DC2Type:datetimetz_immutable)\'');
$this->addSql('COMMENT ON COLUMN chill_doc.stored_object_lock.expireAt IS \'(DC2Type:datetimetz_immutable)\'');
$this->addSql('ALTER TABLE chill_doc.stored_object_lock ADD CONSTRAINT FK_E66CB6516C99C13A FOREIGN KEY (storedObject_id) REFERENCES chill_doc.stored_object (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE TABLE chill_doc.stored_object_lock_user (storedobjectlock_uuid UUID NOT NULL, user_id INT NOT NULL, PRIMARY KEY(storedobjectlock_uuid, user_id))');
$this->addSql('CREATE INDEX IDX_A4353741F52905D0 ON chill_doc.stored_object_lock_user (storedobjectlock_uuid)');
$this->addSql('CREATE INDEX IDX_A4353741A76ED395 ON chill_doc.stored_object_lock_user (user_id)');
$this->addSql('COMMENT ON COLUMN chill_doc.stored_object_lock_user.storedobjectlock_uuid IS \'(DC2Type:uuid)\'');
$this->addSql('ALTER TABLE chill_doc.stored_object_lock_user ADD CONSTRAINT FK_A4353741F52905D0 FOREIGN KEY (storedobjectlock_uuid) REFERENCES chill_doc.stored_object_lock (uuid) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_doc.stored_object_lock_user ADD CONSTRAINT FK_A4353741A76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_doc.stored_object_lock_user DROP CONSTRAINT FK_A4353741F52905D0');
$this->addSql('ALTER TABLE chill_doc.stored_object_lock_user DROP CONSTRAINT FK_A4353741A76ED395');
$this->addSql('DROP TABLE chill_doc.stored_object_lock_user');
$this->addSql('ALTER TABLE chill_doc.stored_object_lock DROP CONSTRAINT FK_E66CB6516C99C13A');
$this->addSql('DROP TABLE chill_doc.stored_object_lock');
}
}

View File

@@ -9,3 +9,23 @@ workflow:
doc_shared_automatically_at_explanation: >-
Le document a été partagé avec vous le {at, date, long} à {at, time, short}
stored_object:
lock:
is_currently_edited: Le document est en cours d'édition par {byUsers}, un verrou est placé sur le document.
is_currently_edited_short: Le document est en cours d'édition par {byUsers}
is_currently_edited_without_users: Le document est en cours d'édition, un verrou est placé sur le document.
is_currently_edited_without_users_short: Le document est en cours d'édition
list_of_users_may_be_incomplete: La liste des utilisateurs mentionné peut être incomplète.
editing_since: >
L'édition a débuté le {since, date, long} à {since, time, short}
force_remove_lock_possible_explain: Vous pouvez supprimer le verrou à l'édition. Les modifications apportées par l'utilisateur pourront être perdues. Veuillez le contacter au préalable.
force_remove_lock: Supprimer le verrou à l'édition
force_remove_lock_warning: >-
J'ai pris contact avec {nb, plural,
=0 {l'utilisateur}
one {l'utilisateur}
other {les utilisateurs}
}, et je confirme vouloir supprimer le verrou à l'édition.
remove_lock_failure: 'Le verrou n''a pas pu être supprimé: {status}'
remove_lock_success: Le verrou a été supprimé

View File

@@ -102,4 +102,17 @@ export function localizeDateTimeFormat(
);
}
/**
* Format a list into the locale, using the Intl.ListFormat.
*
* This method is a stub while PHP supports the formatting of list using ICU.
*/
export function localizeList(
list: Iterable<string>,
options?: Intl.ListFormatOptions,
): string {
const formatter = new Intl.ListFormat(getLocale(), options);
return formatter.format(list);
}
export default datetimeFormats;

View File

@@ -27,6 +27,7 @@
<document-action-buttons-group
:stored-object="storedObject"
:filename="filename"
:refresh-stored-object-auto="false"
></document-action-buttons-group>
</p>
</template>

View File

@@ -53,6 +53,7 @@ const canRemove = computed<boolean>((): boolean => {
<li>
<document-action-buttons-group
:stored-object="a.genericDoc.storedObject"
:refresh-stored-object-auto="false"
></document-action-buttons-group>
</li>
<li v-if="canRemove">

View File

@@ -38,6 +38,7 @@ const clickOnAddButton = () => {
<li v-if="props.genericDoc.storedObject !== null">
<document-action-buttons-group
:stored-object="props.genericDoc.storedObject"
:refresh-stored-object-auto="false"
></document-action-buttons-group>
</li>
<li>

View File

@@ -0,0 +1,30 @@
<?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\MainBundle\Test;
use Chill\MainBundle\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
trait RandomUserTrait
{
public function getRandomUser(EntityManagerInterface $em): User
{
$userRepository = $em->getRepository(User::class);
$count = $userRepository->count([]);
$random = mt_rand(0, $count - 1);
return $em->createQuery('SELECT u FROM '.User::class.' u ')
->setMaxResults(1)
->setFirstResult($random)
->getSingleResult();
}
}

View File

@@ -106,6 +106,14 @@
@on-stored-object-status-change="
$emit('statusDocumentChanged', $event)
"
@on-stored-object-refresh="
(newStoredObject) =>
replaceDocument(
d,
newStoredObject,
newStoredObject.currentVersion,
)
"
></document-action-buttons-group>
</li>
<!--replace document-->
@@ -318,6 +326,7 @@ async function goToGenerateWorkflowEvaluationDocument({
* @return {void}
*/
async function replaceDocument(oldDocument, storedObject, storedObjectVersion) {
console.log("replaceDocument", storedObject, storedObjectVersion);
let document = {
type: "accompanying_period_work_evaluation_document",
storedObject: storedObject,
@@ -328,6 +337,7 @@ async function replaceDocument(oldDocument, storedObject, storedObjectVersion) {
key: props.evaluation.key,
document,
oldDocument: oldDocument,
storedObject: storedObject,
stored_object_version: storedObjectVersion,
});
}

View File

@@ -344,7 +344,7 @@ const store = createStore({
* Replaces a document in the state with a new document.
*
* @param {object} state - The current state of the application.
* @param {{key: number, oldDocument: {key: number}, stored_object_version: StoredObjectVersion}} payload - The object containing the information about the document to be replaced.
* @param {{key: number, oldDocument: {key: number}, stored_object_version: StoredObjectVersion, storedObject: StoredObject}} payload - The object containing the information about the document to be replaced.
* @return {void} - returns nothing.
*/
replaceDocument(state, payload) {
@@ -364,6 +364,7 @@ const store = createStore({
return;
}
doc.storedObject = payload.storedObject;
doc.storedObject.currentVersion = payload.stored_object_version;
return;
let newDocument = Object.assign(payload.document, {

View File

@@ -13,10 +13,16 @@ namespace Chill\WopiBundle\Service\Wopi;
use ChampsLibres\WopiLib\Contract\Entity\Document;
use ChampsLibres\WopiLib\Contract\Service\DocumentLockManagerInterface;
use Chill\MainBundle\Redis\ChillRedis;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectLockMethodEnum;
use Chill\DocStoreBundle\Service\Lock\StoredObjectLockManager;
use Chill\MainBundle\Entity\User;
use Psr\Http\Message\RequestInterface;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Security\Core\Security;
use Webmozart\Assert\Assert;
class ChillDocumentLockManager implements DocumentLockManagerInterface
final readonly class ChillDocumentLockManager implements DocumentLockManagerInterface
{
private const LOCK_DURATION = 60 * 30;
@@ -26,54 +32,49 @@ class ChillDocumentLockManager implements DocumentLockManagerInterface
private const LOCK_GRACEFUL_DURATION_TIME = 3;
public function __construct(
private readonly ChillRedis $redis,
private readonly int $ttlAfterDeleteSeconds = self::LOCK_GRACEFUL_DURATION_TIME,
private Security $security,
private ClockInterface $clock,
private StoredObjectLockManager $storedObjectLockManager,
private int $ttlAfterDeleteSeconds = self::LOCK_GRACEFUL_DURATION_TIME,
) {}
public function deleteLock(Document $document, RequestInterface $request): bool
{
if (0 === $this->redis->exists($this->getCacheId($document))) {
return true;
}
Assert::isInstanceOf($document, StoredObject::class);
// some queries (ex.: putFile) may be executed on the same time than the unlock, so
// we add a delay before unlocking the file, instead of deleting it immediatly
return $this->redis->expire($this->getCacheId($document), $this->ttlAfterDeleteSeconds);
return $this->storedObjectLockManager->deleteLock(
$document,
$this->clock->now()->add(new \DateInterval('PT'.$this->ttlAfterDeleteSeconds.'S'))
);
}
public function getLock(Document $document, RequestInterface $request): string
{
if (false !== $value = $this->redis->get($this->getCacheId($document))) {
return $value;
}
Assert::isInstanceOf($document, StoredObject::class);
throw new \RuntimeException('wopi key does not exists');
return $this->storedObjectLockManager->getLock($document)->getToken();
}
public function hasLock(Document $document, RequestInterface $request): bool
{
$r = $this->redis->exists($this->getCacheId($document));
Assert::isInstanceOf($document, StoredObject::class);
if (is_bool($r)) {
return $r;
}
if (is_int($r)) {
return $r > 0;
}
throw new \RuntimeException('data type not supported');
return $this->storedObjectLockManager->hasLock($document);
}
public function setLock(Document $document, string $lockId, RequestInterface $request): bool
{
$key = $this->getCacheId($document);
$this->redis->setex($key, self::LOCK_DURATION, $lockId);
Assert::isInstanceOf($document, StoredObject::class);
$user = $this->security->getUser();
$this->storedObjectLockManager->setLock(
$document,
StoredObjectLockMethodEnum::WOPI,
$lockId,
$this->clock->now()->add(new \DateInterval('PT'.self::LOCK_DURATION.'S')),
$user instanceof User ? [$user] : []
);
return true;
}
private function getCacheId(Document $document): string
{
return sprintf('wopi_lib_lock_%s', $document->getWopiDocId());
}
}

View File

@@ -12,11 +12,16 @@ declare(strict_types=1);
namespace Chill\WopiBundle\Tests\Service\Wopi;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\MainBundle\Redis\ChillRedis;
use Chill\DocStoreBundle\Service\Lock\StoredObjectLockManager;
use Chill\MainBundle\Test\RandomUserTrait;
use Chill\WopiBundle\Service\Wopi\ChillDocumentLockManager;
use Doctrine\ORM\EntityManagerInterface;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Http\Message\RequestInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\Security\Core\Security;
/**
* @internal
@@ -27,14 +32,31 @@ final class ChillDocumentLockManagerTest extends KernelTestCase
{
use ProphecyTrait;
use RandomUserTrait;
private MockClock $clock;
/**
* @var ObjectProphecy<Security>
*/
private ObjectProphecy $security;
private EntityManagerInterface $em;
protected function setUp(): void
{
self::bootKernel();
$this->em = self::getContainer()->get('doctrine.orm.entity_manager');
$this->security = $this->prophesize(Security::class);
$this->clock = new MockClock();
}
public function testRelock()
{
$user = $this->getRandomUser($this->em);
$this->security->getUser()->willReturn($user);
$manager = $this->makeManager(1);
$document = new StoredObject();
$request = $this->prophesize(RequestInterface::class);
@@ -50,15 +72,22 @@ final class ChillDocumentLockManagerTest extends KernelTestCase
$this->assertTrue($manager->deleteLock($document, $request->reveal()));
sleep(3); // wait for redis to remove the key
$this->clock->sleep(10);
$this->assertFalse($manager->hasLock($document, $request->reveal()));
$this->em->remove($document);
$this->em->flush();
}
public function testSingleLock()
{
$user = $this->getRandomUser($this->em);
$this->security->getUser()->willReturn($user);
$manager = $this->makeManager(1);
$document = new StoredObject();
$this->em->persist($document);
$this->em->flush();
$request = $this->prophesize(RequestInterface::class);
$this->assertFalse($manager->hasLock($document, $request->reveal()));
@@ -69,15 +98,22 @@ final class ChillDocumentLockManagerTest extends KernelTestCase
$this->assertTrue($manager->deleteLock($document, $request->reveal()));
sleep(3); // wait for redis to remove the key
$this->clock->sleep(10);
$this->assertFalse($manager->hasLock($document, $request->reveal()));
$this->em->remove($document);
$this->em->flush();
}
private function makeManager(int $ttlAfterDeleteSeconds = -1): ChillDocumentLockManager
{
$redis = self::getContainer()->get(ChillRedis::class);
$storedObjectLockManager = new StoredObjectLockManager($this->em, $this->clock);
return new ChillDocumentLockManager($redis, $ttlAfterDeleteSeconds);
if (-1 !== $ttlAfterDeleteSeconds) {
return new ChillDocumentLockManager($this->security->reveal(), $this->clock, $storedObjectLockManager, $ttlAfterDeleteSeconds);
}
return new ChillDocumentLockManager($this->security->reveal(), $this->clock, $storedObjectLockManager);
}
}