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: '', + template: '', methods: { onStoredObjectStatusChange: function(newStatus: StoredObjectStatusChange): void { this.$data.storedObject.status = newStatus.status; diff --git a/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/DocumentActionButtonsGroup.vue b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/DocumentActionButtonsGroup.vue index 88587e90f..284ae0f1f 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/DocumentActionButtonsGroup.vue +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/DocumentActionButtonsGroup.vue @@ -7,6 +7,9 @@
  • +
  • + +
  • @@ -36,6 +39,7 @@ import { StoredObjectStatusChange, WopiEditButtonExecutableBeforeLeaveFunction } from "../types"; +import DesktopEditButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DesktopEditButton.vue"; interface DocumentActionButtonsGroupConfig { storedObject: StoredObject, @@ -57,6 +61,16 @@ interface DocumentActionButtonsGroupConfig { * If set, will execute this function before leaving to the editor */ executeBeforeLeave?: WopiEditButtonExecutableBeforeLeaveFunction, + + /** + * a link to download and edit file using webdav + */ + davLink?: string, + + /** + * the expiration date of the download, as a unix timestamp + */ + davLinkExpiration?: number, } const emit = defineEmits<{ @@ -68,7 +82,7 @@ const props = withDefaults(defineProps(), { canEdit: true, canDownload: true, canConvertPdf: true, - returnPath: window.location.pathname + window.location.search + window.location.hash, + returnPath: window.location.pathname + window.location.search + window.location.hash }); /** diff --git a/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/DesktopEditButton.vue b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/DesktopEditButton.vue new file mode 100644 index 000000000..ef0d0d376 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/DesktopEditButton.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/src/Bundle/ChillDocStoreBundle/Resources/views/Button/button_group.html.twig b/src/Bundle/ChillDocStoreBundle/Resources/views/Button/button_group.html.twig index 2babe1ad9..6248cbd20 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/views/Button/button_group.html.twig +++ b/src/Bundle/ChillDocStoreBundle/Resources/views/Button/button_group.html.twig @@ -3,5 +3,7 @@ data-download-buttons data-stored-object="{{ document_json|json_encode|escape('html_attr') }}" data-can-edit="{{ can_edit ? '1' : '0' }}" + data-dav-link="{{ dav_link|escape('html_attr') }}" + data-dav-link-expiration="{{ dav_link_expiration|escape('html_attr') }}" {% if options['small'] is defined %}data-button-small="{{ options['small'] ? '1' : '0' }}"{% endif %} {% if title|default(document.title)|default(null) is not null %}data-filename="{{ title|default(document.title)|escape('html_attr') }}"{% endif %}> diff --git a/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/directory.html.twig b/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/directory.html.twig new file mode 100644 index 000000000..90e19dd13 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/directory.html.twig @@ -0,0 +1,12 @@ + + + + + Directory for {{ stored_object.uuid }} + + + + + diff --git a/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/directory_propfind.xml.twig b/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/directory_propfind.xml.twig new file mode 100644 index 000000000..23a8da064 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/directory_propfind.xml.twig @@ -0,0 +1,81 @@ + + + + {{ path('chill_docstore_dav_directory_get', { 'uuid': stored_object.uuid, 'access_token': access_token } ) }} + {% if properties.resourceType or properties.contentType %} + + + {% if properties.resourceType %} + + {% endif %} + {% if properties.contentType %} + httpd/unix-directory + {% endif %} + + HTTP/1.1 200 OK + + {% endif %} + {% if properties.unknowns|length > 0 %} + + {% for k,u in properties.unknowns %} + + <{{ 'ns'~ k ~ ':' ~ u.prop }} /> + + {% endfor %} + HTTP/1.1 404 Not Found + + {% endif %} + + {% if depth == 1 %} + + {{ path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token':access_token}) }} + {% if properties.lastModified or properties.contentLength or properties.resourceType or properties.etag or properties.contentType or properties.creationDate %} + + + {% if properties.resourceType %} + + {% endif %} + {% if properties.creationDate %} + + {% endif %} + {% if properties.lastModified %} + {% if last_modified is not same as null %} + {{ last_modified.format(constant('DATE_RSS')) }} + {% else %} + + {% endif %} + {% endif %} + {% if properties.contentLength %} + {% if content_length is not same as null %} + {{ content_length }} + {% else %} + + {% endif %} + {% endif %} + {% if properties.etag %} + {% if etag is not same as null %} + "{{ etag }}" + {% else %} + + {% endif %} + {% endif %} + {% if properties.contentType %} + {{ stored_object.type }} + {% endif %} + + HTTP/1.1 200 OK + + {% endif %} + {% if properties.unknowns|length > 0 %} + + {% for k,u in properties.unknowns %} + + <{{ 'ns'~ k ~ ':' ~ u.prop }} /> + + {% endfor %} + HTTP/1.1 404 Not Found + + {% endif %} + + {% endif %} + diff --git a/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/doc_props.xml.twig b/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/doc_props.xml.twig new file mode 100644 index 000000000..7cde5a5de --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/doc_props.xml.twig @@ -0,0 +1,53 @@ + + + + {{ path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token': access_token}) }} + {% if properties.lastModified or properties.contentLength or properties.resourceType or properties.etag or properties.contentType or properties.creationDate %} + + + {% if properties.resourceType %} + + {% endif %} + {% if properties.creationDate %} + + {% endif %} + {% if properties.lastModified %} + {% if last_modified is not same as null %} + {{ last_modified.format(constant('DATE_RSS')) }} + {% else %} + + {% endif %} + {% endif %} + {% if properties.contentLength %} + {% if content_length is not same as null %} + {{ content_length }} + {% else %} + + {% endif %} + {% endif %} + {% if properties.etag %} + {% if etag is not same as null %} + "{{ etag }}" + {% else %} + + {% endif %} + {% endif %} + {% if properties.contentType %} + {{ stored_object.type }} + {% endif %} + + HTTP/1.1 200 OK + + {% endif %} + {% if properties.unknowns|length > 0 %} + + {% for k,u in properties.unknowns %} + + <{{ 'ns'~ k ~ ':' ~ u.prop }} /> + + {% endfor %} + HTTP/1.1 404 Not Found + + {% endif %} + + diff --git a/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/open_in_browser.html.twig b/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/open_in_browser.html.twig new file mode 100644 index 000000000..2a32681e7 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/open_in_browser.html.twig @@ -0,0 +1,7 @@ +{% extends '@ChillMain/layout.html.twig' %} + +{% block content %} +

    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' + + + XML; + + $response = + <<<'XML' + + + + /dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d + + + + application/vnd.oasis.opendocument.text + + HTTP/1.1 200 OK + + + + + + HTTP/1.1 404 Not Found + + + + XML; + + yield [$content, 207, $response, 'get IsReadOnly and contenttype from server']; + + $content = + <<<'XML' + + + + + + + XML; + + $response = + <<<'XML' + + + + /dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d + + + + + HTTP/1.1 404 Not Found + + + + XML; + + yield [$content, 207, $response, 'get property IsReadOnly']; + + yield [ + <<<'XML' + + + + + + + XML, + 207, + <<<'XML' + + + + /dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d + + + + + HTTP/1.1 404 Not Found + + + + XML, + 'Test requesting an unknow property', + ]; + + yield [ + <<<'XML' + + + + + + + XML, + 207, + <<<'XML' + + + + /dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d + + + + Wed, 13 Sep 2023 14:15:00 +0200 + + HTTP/1.1 200 OK + + + + XML, + 'test getting the last modified date', + ]; + + yield [ + <<<'XML' + + + + + XML, + 207, + <<<'XML' + + + + /dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d + + + + + Wed, 13 Sep 2023 14:15:00 +0200 + + 5 + + "ab56b4d92b40713acc5af89985d4b786" + + + application/vnd.oasis.opendocument.text + + HTTP/1.1 200 OK + + + + XML, + 'test finding all properties', + ]; + } + + public static function generateDataPropfindDirectory(): iterable + { + yield [ + <<<'XML' + + + XML, + 207, + <<<'XML' + + + + /dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/ + + + + httpd/unix-directory + + + HTTP/1.1 200 OK + + + + + + HTTP/1.1 404 Not Found + + + + XML, + 'test resourceType and IsReadOnly ', + ]; + + yield [ + <<<'XML' + + + XML, + 207, + <<<'XML' + + + + /dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/ + + + + + HTTP/1.1 404 Not Found + + + + XML, + 'test creatableContentsInfo', + ]; + } +} + +class MockedStoredObjectManager implements StoredObjectManagerInterface +{ + public function getLastModified(StoredObject $document): \DateTimeInterface + { + return new \DateTimeImmutable('2023-09-13T14:15'); + } + + public function getContentLength(StoredObject $document): int + { + return 5; + } + + public function read(StoredObject $document): string + { + return 'abcde'; + } + + public function write(StoredObject $document, string $clearContent): void + { + } + + public function etag(StoredObject $document): string + { + return 'ab56b4d92b40713acc5af89985d4b786'; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Dav/Request/PropfindRequestAnalyzerTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Dav/Request/PropfindRequestAnalyzerTest.php new file mode 100644 index 000000000..babe9932a --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Tests/Dav/Request/PropfindRequestAnalyzerTest.php @@ -0,0 +1,134 @@ +loadXML($xml); + $actual = $analyzer->getRequestedProperties($request); + + foreach ($expected as $key => $value) { + if ('unknowns' === $key) { + continue; + } + + self::assertArrayHasKey($key, $actual, "Check that key {$key} does exists in list of expected values"); + self::assertEquals($value, $actual[$key], "Does the value match expected for key {$key}"); + } + + if (array_key_exists('unknowns', $expected)) { + self::assertEquals(count($expected['unknowns']), count($actual['unknowns'])); + self::assertEqualsCanonicalizing($expected['unknowns'], $actual['unknowns']); + } + } + + public function provideRequestedProperties(): iterable + { + yield [ + <<<'XML' + + + + + + + XML, + [ + 'resourceType' => false, + 'contentType' => false, + 'lastModified' => false, + 'creationDate' => false, + 'contentLength' => false, + 'etag' => false, + 'supportedLock' => false, + 'unknowns' => [ + ['xmlns' => 'http://ucb.openoffice.org/dav/props/', 'prop' => 'BaseURI'], + ], + ], + ]; + + yield [ + <<<'XML' + + + + + XML, + [ + 'resourceType' => true, + 'contentType' => true, + 'lastModified' => true, + 'creationDate' => true, + 'contentLength' => true, + 'etag' => true, + 'supportedLock' => true, + 'unknowns' => [], + ], + ]; + + yield [ + <<<'XML' + + + + + + + XML, + [ + 'resourceType' => false, + 'contentType' => false, + 'lastModified' => true, + 'creationDate' => false, + 'contentLength' => false, + 'etag' => false, + 'supportedLock' => false, + 'unknowns' => [], + ], + ]; + + yield [ + <<<'XML' + + + XML, + [ + 'resourceType' => true, + 'contentType' => true, + 'lastModified' => false, + 'creationDate' => false, + 'contentLength' => false, + 'etag' => false, + 'supportedLock' => false, + 'unknowns' => [ + ['xmlns' => 'http://ucb.openoffice.org/dav/props/', 'prop' => 'IsReadOnly'], + ], + ], + ]; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Security/Authorization/StoredObjectVoterTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Security/Authorization/StoredObjectVoterTest.php new file mode 100644 index 000000000..477427078 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Tests/Security/Authorization/StoredObjectVoterTest.php @@ -0,0 +1,123 @@ +vote($token, $subject, [$attribute])); + } + + public function provideDataVote(): iterable + { + yield [ + $this->buildToken(StoredObjectRoleEnum::EDIT, new StoredObject()), + new \stdClass(), + 'SOMETHING', + VoterInterface::ACCESS_ABSTAIN, + ]; + + yield [ + $this->buildToken(StoredObjectRoleEnum::EDIT, $so = new StoredObject()), + $so, + 'SOMETHING', + VoterInterface::ACCESS_ABSTAIN, + ]; + + yield [ + $this->buildToken(StoredObjectRoleEnum::EDIT, $so = new StoredObject()), + $so, + StoredObjectRoleEnum::SEE->value, + VoterInterface::ACCESS_GRANTED, + ]; + + yield [ + $this->buildToken(StoredObjectRoleEnum::EDIT, $so = new StoredObject()), + $so, + StoredObjectRoleEnum::EDIT->value, + VoterInterface::ACCESS_GRANTED, + ]; + + yield [ + $this->buildToken(StoredObjectRoleEnum::SEE, $so = new StoredObject()), + $so, + StoredObjectRoleEnum::EDIT->value, + VoterInterface::ACCESS_DENIED, + ]; + + yield [ + $this->buildToken(StoredObjectRoleEnum::SEE, $so = new StoredObject()), + $so, + StoredObjectRoleEnum::SEE->value, + VoterInterface::ACCESS_GRANTED, + ]; + + yield [ + $this->buildToken(null, null), + new StoredObject(), + StoredObjectRoleEnum::SEE->value, + VoterInterface::ACCESS_DENIED, + ]; + + yield [ + $this->buildToken(null, null), + new StoredObject(), + StoredObjectRoleEnum::SEE->value, + VoterInterface::ACCESS_DENIED, + ]; + } + + private function buildToken(StoredObjectRoleEnum $storedObjectRoleEnum = null, StoredObject $storedObject = null): TokenInterface + { + $token = $this->prophesize(TokenInterface::class); + + if (null !== $storedObjectRoleEnum) { + $token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willReturn(true); + $token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willReturn($storedObjectRoleEnum); + } else { + $token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willReturn(false); + $token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willThrow(new \InvalidArgumentException()); + } + + if (null !== $storedObject) { + $token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willReturn(true); + $token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willReturn($storedObject->getUuid()->toString()); + } else { + $token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willReturn(false); + $token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willThrow(new \InvalidArgumentException()); + } + + return $token->reveal(); + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Security/Guard/DavOnUrlTokenExtractorTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Security/Guard/DavOnUrlTokenExtractorTest.php new file mode 100644 index 000000000..e1e0a3b36 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Tests/Security/Guard/DavOnUrlTokenExtractorTest.php @@ -0,0 +1,55 @@ +prophesize(Request::class); + $request->getRequestUri()->willReturn($uri); + + $extractor = new DavOnUrlTokenExtractor(new NullLogger()); + + $actual = $extractor->extract($request->reveal()); + + self::assertEquals($expected, $actual); + } + + /** + * @phpstan-pure + */ + public static function provideDataUri(): iterable + { + yield ['/dav/123456789/get/d07d2230-5326-11ee-8fd4-93696acf5ea1/d', '123456789']; + yield ['/dav/123456789', '123456789']; + yield ['/not-dav/123456978', false]; + yield ['/dav', false]; + yield ['/', false]; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/config/services.yaml b/src/Bundle/ChillDocStoreBundle/config/services.yaml index 04fc3ace3..390abbf25 100644 --- a/src/Bundle/ChillDocStoreBundle/config/services.yaml +++ b/src/Bundle/ChillDocStoreBundle/config/services.yaml @@ -34,6 +34,11 @@ services: autoconfigure: true autowire: true + Chill\DocStoreBundle\Security\: + resource: './../Security' + autoconfigure: true + autowire: true + Chill\DocStoreBundle\Serializer\Normalizer\: autowire: true resource: '../Serializer/Normalizer/' diff --git a/utils/http/docstore/dav.http b/utils/http/docstore/dav.http new file mode 100644 index 000000000..c566358cd --- /dev/null +++ b/utils/http/docstore/dav.http @@ -0,0 +1,16 @@ + +### Get a document +GET http://{{ host }}/dav/get/{{ uuid }}/d + +### OPTIONS on a document +OPTIONS http://{{ host }}/dav/get/{{ uuid }}/d + +### HEAD ona document +HEAD http://{{ host }}/dav/get/{{ uuid }}/d + +### Get the directory of a document +GET http://{{ host }}/dav/get/{{ uuid }}/ + +### Option the directory of a document +OPTIONS http://{{ host }}/dav/get/{{ uuid }}/ + diff --git a/utils/http/docstore/http-client.env.json b/utils/http/docstore/http-client.env.json new file mode 100644 index 000000000..0b78e77ed --- /dev/null +++ b/utils/http/docstore/http-client.env.json @@ -0,0 +1,6 @@ +{ + "dev": { + "host": "localhost:8001", + "uuid": "0bf3b8e7-b25b-4227-aae9-a3263af0766f" + } +}