diff --git a/composer.json b/composer.json
index 3195f732b..ffe87edb1 100644
--- a/composer.json
+++ b/composer.json
@@ -9,6 +9,7 @@
],
"require": {
"php": "^8.2",
+ "ext-dom": "*",
"ext-json": "*",
"ext-openssl": "*",
"ext-redis": "*",
diff --git a/src/Bundle/ChillAsideActivityBundle/src/Tests/Chill/DocStoreBundle/Tests/Security/Guard/DavTokenAuthenticationEventSubscriberTest.php b/src/Bundle/ChillAsideActivityBundle/src/Tests/Chill/DocStoreBundle/Tests/Security/Guard/DavTokenAuthenticationEventSubscriberTest.php
new file mode 100644
index 000000000..875e40a65
--- /dev/null
+++ b/src/Bundle/ChillAsideActivityBundle/src/Tests/Chill/DocStoreBundle/Tests/Security/Guard/DavTokenAuthenticationEventSubscriberTest.php
@@ -0,0 +1,66 @@
+ 1,
+ 'so' => '1234',
+ 'e' => 1,
+ ], $token);
+
+ $eventSubscriber->onJWTAuthenticated($event);
+
+ self::assertTrue($token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT));
+ self::assertTrue($token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS));
+ self::assertEquals('1234', $token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT));
+ self::assertEquals(StoredObjectRoleEnum::EDIT, $token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS));
+ }
+
+ public function testOnJWTAuthenticatedWithDavNoDataInPayload(): void
+ {
+ $eventSubscriber = new DavTokenAuthenticationEventSubscriber();
+ $token = new class () extends AbstractToken {
+ public function getCredentials()
+ {
+ return null;
+ }
+ };
+ $event = new JWTAuthenticatedEvent([], $token);
+
+ $eventSubscriber->onJWTAuthenticated($event);
+
+ self::assertFalse($token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT));
+ self::assertFalse($token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS));
+ }
+}
diff --git a/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php b/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php
new file mode 100644
index 000000000..70aecbe1e
--- /dev/null
+++ b/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php
@@ -0,0 +1,252 @@
+requestAnalyzer = new PropfindRequestAnalyzer();
+ }
+
+ /**
+ * @Route("/dav/{access_token}/get/{uuid}/", methods={"GET", "HEAD"}, name="chill_docstore_dav_directory_get")
+ */
+ public function getDirectory(StoredObject $storedObject, string $access_token): Response
+ {
+ if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
+ throw new AccessDeniedHttpException();
+ }
+
+ return new DavResponse(
+ $this->engine->render('@ChillDocStore/Webdav/directory.html.twig', [
+ 'stored_object' => $storedObject,
+ 'access_token' => $access_token,
+ ])
+ );
+ }
+
+ /**
+ * @Route("/dav/{access_token}/get/{uuid}/", methods={"OPTIONS"})
+ */
+ public function optionsDirectory(StoredObject $storedObject): Response
+ {
+ if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
+ throw new AccessDeniedHttpException();
+ }
+
+ $response = (new DavResponse(''))
+ ->setEtag($this->storedObjectManager->etag($storedObject))
+ ;
+
+ // $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']);
+
+ return $response;
+ }
+
+ /**
+ * @Route("/dav/{access_token}/get/{uuid}/", methods={"PROPFIND"})
+ */
+ public function propfindDirectory(StoredObject $storedObject, string $access_token, Request $request): Response
+ {
+ if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
+ throw new AccessDeniedHttpException();
+ }
+
+ $depth = $request->headers->get('depth');
+
+ if ('0' !== $depth && '1' !== $depth) {
+ throw new BadRequestHttpException('only 1 and 0 are accepted for Depth header');
+ }
+
+ [$properties, $lastModified, $etag, $length] = $this->parseDavRequest($request->getContent(), $storedObject);
+
+ $response = new DavResponse(
+ $this->engine->render('@ChillDocStore/Webdav/directory_propfind.xml.twig', [
+ 'stored_object' => $storedObject,
+ 'properties' => $properties,
+ 'last_modified' => $lastModified,
+ 'etag' => $etag,
+ 'content_length' => $length,
+ 'depth' => (int) $depth,
+ 'access_token' => $access_token,
+ ]),
+ 207
+ );
+
+ $response->headers->add([
+ 'Content-Type' => 'text/xml',
+ ]);
+
+ return $response;
+ }
+
+ /**
+ * @Route("/dav/{access_token}/get/{uuid}/d", name="chill_docstore_dav_document_get", methods={"GET"})
+ */
+ public function getDocument(StoredObject $storedObject): Response
+ {
+ if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
+ throw new AccessDeniedHttpException();
+ }
+
+ return (new DavResponse($this->storedObjectManager->read($storedObject)))
+ ->setEtag($this->storedObjectManager->etag($storedObject));
+ }
+
+ /**
+ * @Route("/dav/{access_token}/get/{uuid}/d", methods={"HEAD"})
+ */
+ public function headDocument(StoredObject $storedObject): Response
+ {
+ if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
+ throw new AccessDeniedHttpException();
+ }
+
+ $response = new DavResponse('');
+
+ $response->headers->add(
+ [
+ 'Content-Length' => $this->storedObjectManager->getContentLength($storedObject),
+ 'Content-Type' => $storedObject->getType(),
+ 'Etag' => $this->storedObjectManager->etag($storedObject),
+ ]
+ );
+
+ return $response;
+ }
+
+ /**
+ * @Route("/dav/{access_token}/get/{uuid}/d", methods={"OPTIONS"})
+ */
+ public function optionsDocument(StoredObject $storedObject): Response
+ {
+ if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
+ throw new AccessDeniedHttpException();
+ }
+
+ $response = (new DavResponse(''))
+ ->setEtag($this->storedObjectManager->etag($storedObject))
+ ;
+
+ $response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT']);
+
+ return $response;
+ }
+
+ /**
+ * @Route("/dav/{access_token}/get/{uuid}/d", methods={"PROPFIND"})
+ */
+ public function propfindDocument(StoredObject $storedObject, string $access_token, Request $request): Response
+ {
+ if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
+ throw new AccessDeniedHttpException();
+ }
+
+ [$properties, $lastModified, $etag, $length] = $this->parseDavRequest($request->getContent(), $storedObject);
+
+ $response = new DavResponse(
+ $this->engine->render(
+ '@ChillDocStore/Webdav/doc_props.xml.twig',
+ [
+ 'stored_object' => $storedObject,
+ 'properties' => $properties,
+ 'etag' => $etag,
+ 'last_modified' => $lastModified,
+ 'content_length' => $length,
+ 'access_token' => $access_token,
+ ]
+ ),
+ 207
+ );
+
+ $response
+ ->headers->add([
+ 'Content-Type' => 'text/xml',
+ ]);
+
+ return $response;
+ }
+
+ /**
+ * @Route("/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());
+
+ return new DavResponse('', Response::HTTP_NO_CONTENT);
+ }
+
+ /**
+ * @return array{0: array, 1: \DateTimeInterface, 2: string, 3: int} properties, lastModified, etag, length
+ */
+ private function parseDavRequest(string $content, StoredObject $storedObject): array
+ {
+ $xml = new \DOMDocument();
+ $xml->loadXML($content);
+
+ $properties = $this->requestAnalyzer->getRequestedProperties($xml);
+ $requested = array_keys(array_filter($properties, fn ($item) => true === $item));
+
+ if (
+ in_array('lastModified', $requested, true)
+ || in_array('etag', $requested, true)
+ ) {
+ $lastModified = $this->storedObjectManager->getLastModified($storedObject);
+ $etag = $this->storedObjectManager->etag($storedObject);
+ }
+ if (in_array('contentLength', $requested, true)) {
+ $length = $this->storedObjectManager->getContentLength($storedObject);
+ }
+
+ return [
+ $properties,
+ $lastModified ?? null,
+ $etag ?? null,
+ $length ?? null,
+ ];
+ }
+}
diff --git a/src/Bundle/ChillDocStoreBundle/Dav/Exception/ParseRequestException.php b/src/Bundle/ChillDocStoreBundle/Dav/Exception/ParseRequestException.php
new file mode 100644
index 000000000..70fff1866
--- /dev/null
+++ b/src/Bundle/ChillDocStoreBundle/Dav/Exception/ParseRequestException.php
@@ -0,0 +1,16 @@
+}
+ */
+class PropfindRequestAnalyzer
+{
+ private const KNOWN_PROPS = [
+ 'resourceType',
+ 'contentType',
+ 'lastModified',
+ 'creationDate',
+ 'contentLength',
+ 'etag',
+ 'supportedLock',
+ ];
+
+ /**
+ * @return davProperties
+ */
+ public function getRequestedProperties(\DOMDocument $request): array
+ {
+ $propfinds = $request->getElementsByTagNameNS('DAV:', 'propfind');
+
+ if (0 === $propfinds->count()) {
+ throw new ParseRequestException('any propfind element found');
+ }
+
+ if (1 < $propfinds->count()) {
+ throw new ParseRequestException('too much propfind element found');
+ }
+
+ $propfind = $propfinds->item(0);
+
+ if (0 === $propfind->childNodes->count()) {
+ throw new ParseRequestException('no element under propfind');
+ }
+
+ $unknows = [];
+ $props = [];
+
+ foreach ($propfind->childNodes->getIterator() as $prop) {
+ /** @var \DOMNode $prop */
+ if (XML_ELEMENT_NODE !== $prop->nodeType) {
+ continue;
+ }
+
+ if ('propname' === $prop->nodeName) {
+ return $this->baseProps(true);
+ }
+
+ foreach ($prop->childNodes->getIterator() as $getProp) {
+ if (XML_ELEMENT_NODE !== $getProp->nodeType) {
+ continue;
+ }
+
+ if ('DAV:' !== $getProp->lookupNamespaceURI(null)) {
+ $unknows[] = ['xmlns' => $getProp->lookupNamespaceURI(null), 'prop' => $getProp->nodeName];
+ continue;
+ }
+
+ $props[] = match ($getProp->nodeName) {
+ 'resourcetype' => 'resourceType',
+ 'getcontenttype' => 'contentType',
+ 'getlastmodified' => 'lastModified',
+ default => '',
+ };
+ }
+ }
+
+ $props = array_filter(array_values($props), fn (string $item) => '' !== $item);
+
+ return [...$this->baseProps(false), ...array_combine($props, array_fill(0, count($props), true)), 'unknowns' => $unknows];
+ }
+
+ /**
+ * @return davProperties
+ */
+ private function baseProps(bool $default = false): array
+ {
+ return
+ [
+ ...array_combine(
+ self::KNOWN_PROPS,
+ array_fill(0, count(self::KNOWN_PROPS), $default)
+ ),
+ 'unknowns' => [],
+ ];
+ }
+}
diff --git a/src/Bundle/ChillDocStoreBundle/Dav/Response/DavResponse.php b/src/Bundle/ChillDocStoreBundle/Dav/Response/DavResponse.php
new file mode 100644
index 000000000..32332d20a
--- /dev/null
+++ b/src/Bundle/ChillDocStoreBundle/Dav/Response/DavResponse.php
@@ -0,0 +1,24 @@
+headers->add(['DAV' => '1']);
+ }
+}
diff --git a/src/Bundle/ChillDocStoreBundle/Resources/public/module/document_action_buttons_group/index.ts b/src/Bundle/ChillDocStoreBundle/Resources/public/module/document_action_buttons_group/index.ts
index 4180808dd..77eb8c2c9 100644
--- a/src/Bundle/ChillDocStoreBundle/Resources/public/module/document_action_buttons_group/index.ts
+++ b/src/Bundle/ChillDocStoreBundle/Resources/public/module/document_action_buttons_group/index.ts
@@ -17,18 +17,22 @@ window.addEventListener('DOMContentLoaded', function (e) {
canEdit: string,
storedObject: string,
buttonSmall: string,
+ davLink: string,
+ davLinkExpiration: string,
};
const
storedObject = JSON.parse(datasets.storedObject) as StoredObject,
filename = datasets.filename,
canEdit = datasets.canEdit === '1',
- small = datasets.buttonSmall === '1'
+ small = datasets.buttonSmall === '1',
+ davLink = 'davLink' in datasets && datasets.davLink !== '' ? datasets.davLink : null,
+ davLinkExpiration = 'davLinkExpiration' in datasets ? Number.parseInt(datasets.davLinkExpiration) : null
;
- return { storedObject, filename, canEdit, small };
+ return { storedObject, filename, canEdit, small, davLink, davLinkExpiration };
},
- template: '
Veuillez enregistrer vos modifications avant le
+{{ editionUntilFormatted }}
+ +Ouvrir le document pour édition
+ +Le document peut être édité uniquement en utilisant Libre Office.
+ +En cas d'échec lors de l'enregistrement, sauver le document sur le poste de travail avant de le déposer à nouveau ici.
+ +Vous pouvez naviguez sur d'autres pages pendant l'édition.
+document uuid: {{ stored_object.uuid }}
+{{ absolute_url(path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token': access_token })) }}
+Open document +{% endblock %} diff --git a/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectRoleEnum.php b/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectRoleEnum.php new file mode 100644 index 000000000..af2813240 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectRoleEnum.php @@ -0,0 +1,22 @@ +hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT) + || $subject->getUuid()->toString() !== $token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT) + ) { + return false; + } + + if (!$token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)) { + return false; + } + + $askedRole = StoredObjectRoleEnum::from($attribute); + $tokenRoleAuthorization = + $token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS); + + return match ($askedRole) { + StoredObjectRoleEnum::SEE => StoredObjectRoleEnum::EDIT === $tokenRoleAuthorization || StoredObjectRoleEnum::SEE === $tokenRoleAuthorization, + StoredObjectRoleEnum::EDIT => StoredObjectRoleEnum::EDIT === $tokenRoleAuthorization + }; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Security/Guard/DavOnUrlTokenExtractor.php b/src/Bundle/ChillDocStoreBundle/Security/Guard/DavOnUrlTokenExtractor.php new file mode 100644 index 000000000..543996f57 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Security/Guard/DavOnUrlTokenExtractor.php @@ -0,0 +1,58 @@ +getRequestUri(); + + $segments = array_values( + array_filter( + explode('/', $uri), + fn ($item) => '' !== trim($item) + ) + ); + + if (2 > count($segments)) { + $this->logger->info('not enough segment for parsing URL'); + + return false; + } + + if ('dav' !== $segments[0]) { + $this->logger->info('the first segment of the url must be DAV'); + + return false; + } + + return $segments[1]; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Security/Guard/DavTokenAuthenticationEventSubscriber.php b/src/Bundle/ChillDocStoreBundle/Security/Guard/DavTokenAuthenticationEventSubscriber.php new file mode 100644 index 000000000..7b33c0eec --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Security/Guard/DavTokenAuthenticationEventSubscriber.php @@ -0,0 +1,51 @@ + ['onJWTAuthenticated', 0], + ]; + } + + public function onJWTAuthenticated(JWTAuthenticatedEvent $event): void + { + $payload = $event->getPayload(); + + if (!(array_key_exists('dav', $payload) && 1 === $payload['dav'])) { + return; + } + + $token = $event->getToken(); + $token->setAttribute(self::ACTIONS, match ($payload['e']) { + 0 => StoredObjectRoleEnum::SEE, + 1 => StoredObjectRoleEnum::EDIT, + default => throw new \UnexpectedValueException('unsupported value for e parameter') + }); + + $token->setAttribute(self::STORED_OBJECT, $payload['so']); + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProvider.php b/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProvider.php new file mode 100644 index 000000000..24e89a3ba --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProvider.php @@ -0,0 +1,48 @@ +JWTTokenManager->createFromPayload($this->security->getUser(), [ + 'dav' => 1, + 'e' => match ($roleEnum) { + StoredObjectRoleEnum::SEE => 0, + StoredObjectRoleEnum::EDIT => 1, + }, + 'so' => $storedObject->getUuid(), + ]); + } + + public function getTokenExpiration(string $tokenString): \DateTimeImmutable + { + $jwt = $this->JWTTokenManager->parse($tokenString); + + return \DateTimeImmutable::createFromFormat('U', (string) $jwt['exp']); + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProviderInterface.php b/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProviderInterface.php new file mode 100644 index 000000000..95c62c86e --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProviderInterface.php @@ -0,0 +1,25 @@ +davOnUrlTokenExtractor; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManager.php b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManager.php index b6ab798b8..bbbf615af 100644 --- a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManager.php +++ b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManager.php @@ -57,6 +57,62 @@ final class StoredObjectManager implements StoredObjectManagerInterface return $this->extractLastModifiedFromResponse($response); } + public function getContentLength(StoredObject $document): int + { + if ([] === $document->getKeyInfos()) { + if ($this->hasCache($document)) { + $response = $this->getResponseFromCache($document); + } else { + try { + $response = $this + ->client + ->request( + Request::METHOD_HEAD, + $this + ->tempUrlGenerator + ->generate( + Request::METHOD_HEAD, + $document->getFilename() + ) + ->url + ); + } catch (TransportExceptionInterface $exception) { + throw StoredObjectManagerException::errorDuringHttpRequest($exception); + } + } + + return $this->extractContentLengthFromResponse($response); + } + + return strlen($this->read($document)); + } + + public function etag(StoredObject $document): string + { + if ($this->hasCache($document)) { + $response = $this->getResponseFromCache($document); + } else { + try { + $response = $this + ->client + ->request( + Request::METHOD_HEAD, + $this + ->tempUrlGenerator + ->generate( + Request::METHOD_HEAD, + $document->getFilename() + ) + ->url + ); + } catch (TransportExceptionInterface $exception) { + throw StoredObjectManagerException::errorDuringHttpRequest($exception); + } + } + + return $this->extractEtagFromResponse($response, $document); + } + public function read(StoredObject $document): string { $response = $this->getResponseFromCache($document); @@ -158,6 +214,22 @@ final class StoredObjectManager implements StoredObjectManagerInterface return $date; } + private function extractContentLengthFromResponse(ResponseInterface $response): int + { + return (int) ($response->getHeaders()['content-length'] ?? ['0'])[0]; + } + + private function extractEtagFromResponse(ResponseInterface $response, StoredObject $storedObject): ?string + { + $etag = ($response->getHeaders()['etag'] ?? [''])[0]; + + if ('' === $etag) { + return null; + } + + return $etag; + } + private function fillCache(StoredObject $document): void { try { diff --git a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManagerInterface.php b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManagerInterface.php index d55f68023..19ff974f0 100644 --- a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManagerInterface.php +++ b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManagerInterface.php @@ -18,6 +18,8 @@ interface StoredObjectManagerInterface { public function getLastModified(StoredObject $document): \DateTimeInterface; + public function getContentLength(StoredObject $document): int; + /** * Get the content of a StoredObject. * @@ -39,5 +41,7 @@ interface StoredObjectManagerInterface */ public function write(StoredObject $document, string $clearContent): void; + public function etag(StoredObject $document): string; + public function clearCache(): void; } diff --git a/src/Bundle/ChillDocStoreBundle/Templating/WopiEditTwigExtensionRuntime.php b/src/Bundle/ChillDocStoreBundle/Templating/WopiEditTwigExtensionRuntime.php index f833200c8..3b6c32148 100644 --- a/src/Bundle/ChillDocStoreBundle/Templating/WopiEditTwigExtensionRuntime.php +++ b/src/Bundle/ChillDocStoreBundle/Templating/WopiEditTwigExtensionRuntime.php @@ -13,6 +13,9 @@ namespace Chill\DocStoreBundle\Templating; use ChampsLibres\WopiLib\Contract\Service\Discovery\DiscoveryInterface; use Chill\DocStoreBundle\Entity\StoredObject; +use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; +use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Twig\Environment; @@ -120,8 +123,12 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt private const TEMPLATE_BUTTON_GROUP = '@ChillDocStore/Button/button_group.html.twig'; - public function __construct(private DiscoveryInterface $discovery, private NormalizerInterface $normalizer) - { + public function __construct( + private DiscoveryInterface $discovery, + private NormalizerInterface $normalizer, + private JWTDavTokenProviderInterface $davTokenProvider, + private UrlGeneratorInterface $urlGenerator, + ) { } /** @@ -132,7 +139,7 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt */ public function isEditable(StoredObject $document): bool { - return \in_array($document->getType(), self::SUPPORTED_MIMES, true); + return in_array($document->getType(), self::SUPPORTED_MIMES, true); } /** @@ -144,12 +151,26 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt */ public function renderButtonGroup(Environment $environment, StoredObject $document, ?string $title = null, bool $canEdit = true, array $options = []): string { + $accessToken = $this->davTokenProvider->createToken( + $document, + $canEdit ? StoredObjectRoleEnum::EDIT : StoredObjectRoleEnum::SEE + ); + return $environment->render(self::TEMPLATE_BUTTON_GROUP, [ 'document' => $document, 'document_json' => $this->normalizer->normalize($document, 'json', [AbstractNormalizer::GROUPS => ['read']]), 'title' => $title, 'can_edit' => $canEdit, 'options' => [...self::DEFAULT_OPTIONS_TEMPLATE_BUTTON_GROUP, ...$options], + 'dav_link' => $this->urlGenerator->generate( + 'chill_docstore_dav_document_get', + [ + 'uuid' => $document->getUuid(), + 'access_token' => $accessToken, + ], + UrlGeneratorInterface::ABSOLUTE_URL, + ), + 'dav_link_expiration' => $this->davTokenProvider->getTokenExpiration($accessToken)->format('U'), ]); } diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Controller/WebdavControllerTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Controller/WebdavControllerTest.php new file mode 100644 index 000000000..9254efc2d --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Tests/Controller/WebdavControllerTest.php @@ -0,0 +1,410 @@ +engine = self::$container->get(\Twig\Environment::class); + } + + private function buildController(): WebdavController + { + $storedObjectManager = new MockedStoredObjectManager(); + $security = $this->prophesize(Security::class); + $security->isGranted(Argument::in(['EDIT', 'SEE']), Argument::type(StoredObject::class)) + ->willReturn(true); + + return new WebdavController($this->engine, $storedObjectManager, $security->reveal()); + } + + private function buildDocument(): StoredObject + { + $object = (new StoredObject()) + ->setType('application/vnd.oasis.opendocument.text'); + + $reflectionObject = new \ReflectionClass($object); + $reflectionObjectUuid = $reflectionObject->getProperty('uuid'); + + $reflectionObjectUuid->setValue($object, Uuid::fromString('716e6688-4579-4938-acf3-c4ab5856803b')); + + return $object; + } + + public function testGet(): void + { + $controller = $this->buildController(); + + $response = $controller->getDocument($this->buildDocument()); + + self::assertEquals(200, $response->getStatusCode()); + self::assertEquals('abcde', $response->getContent()); + self::assertContains('etag', $response->headers->keys()); + self::assertStringContainsString('ab56b4', $response->headers->get('etag')); + } + + public function testOptionsOnDocument(): void + { + $controller = $this->buildController(); + + $response = $controller->optionsDocument($this->buildDocument()); + + self::assertEquals(200, $response->getStatusCode()); + self::assertContains('allow', $response->headers->keys()); + + foreach (explode(',', 'OPTIONS,GET,HEAD,PROPFIND') as $method) { + self::assertStringContainsString($method, $response->headers->get('allow')); + } + + self::assertContains('dav', $response->headers->keys()); + self::assertStringContainsString('1', $response->headers->get('dav')); + } + + public function testOptionsOnDirectory(): void + { + $controller = $this->buildController(); + + $response = $controller->optionsDirectory($this->buildDocument()); + + self::assertEquals(200, $response->getStatusCode()); + self::assertContains('allow', $response->headers->keys()); + + foreach (explode(',', 'OPTIONS,GET,HEAD,PROPFIND') as $method) { + self::assertStringContainsString($method, $response->headers->get('allow')); + } + + self::assertContains('dav', $response->headers->keys()); + self::assertStringContainsString('1', $response->headers->get('dav')); + } + + /** + * @dataProvider generateDataPropfindDocument + */ + public function testPropfindDocument(string $requestContent, int $expectedStatusCode, string $expectedXmlResponse, string $message): void + { + $controller = $this->buildController(); + + $request = new Request([], [], [], [], [], [], $requestContent); + $request->setMethod('PROPFIND'); + $response = $controller->propfindDocument($this->buildDocument(), '1234', $request); + + self::assertEquals($expectedStatusCode, $response->getStatusCode()); + self::assertContains('content-type', $response->headers->keys()); + self::assertStringContainsString('text/xml', $response->headers->get('content-type')); + self::assertTrue((new \DOMDocument())->loadXML($response->getContent()), $message.' test that the xml response is a valid xml'); + self::assertXmlStringEqualsXmlString($expectedXmlResponse, $response->getContent(), $message); + } + + /** + * @dataProvider generateDataPropfindDirectory + */ + public function testPropfindDirectory(string $requestContent, int $expectedStatusCode, string $expectedXmlResponse, string $message): void + { + $controller = $this->buildController(); + + $request = new Request([], [], [], [], [], [], $requestContent); + $request->setMethod('PROPFIND'); + $request->headers->add(['Depth' => '0']); + $response = $controller->propfindDirectory($this->buildDocument(), '1234', $request); + + self::assertEquals($expectedStatusCode, $response->getStatusCode()); + self::assertContains('content-type', $response->headers->keys()); + self::assertStringContainsString('text/xml', $response->headers->get('content-type')); + self::assertTrue((new \DOMDocument())->loadXML($response->getContent()), $message.' test that the xml response is a valid xml'); + self::assertXmlStringEqualsXmlString($expectedXmlResponse, $response->getContent(), $message); + } + + public function testHeadDocument(): void + { + $controller = $this->buildController(); + $response = $controller->headDocument($this->buildDocument()); + + self::assertEquals(200, $response->getStatusCode()); + self::assertContains('content-length', $response->headers->keys()); + self::assertContains('content-type', $response->headers->keys()); + self::assertContains('etag', $response->headers->keys()); + self::assertEquals('ab56b4d92b40713acc5af89985d4b786', $response->headers->get('etag')); + self::assertEquals('application/vnd.oasis.opendocument.text', $response->headers->get('content-type')); + self::assertEquals(5, $response->headers->get('content-length')); + } + + public static function generateDataPropfindDocument(): iterable + { + $content = + <<<'XML' + +