From 482c494034acffe6403368e1e539324ce6c2daa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Tue, 12 Sep 2023 11:24:50 +0200 Subject: [PATCH 1/7] Webdav: fully implements the controller and response The controller is tested from real request scraped from apache mod_dav implementation. The requests were scraped using a wireshark-like tool. Those requests have been adapted to suit to our xml. --- composer.json | 1 + .../Controller/WebdavController.php | 232 ++++++++++ .../Dav/Exception/ParseRequestException.php | 14 + .../Dav/Request/PropfindRequestAnalyzer.php | 104 +++++ .../Dav/Response/DavResponse.php | 24 ++ .../views/Webdav/directory.html.twig | 12 + .../views/Webdav/directory_propfind.xml.twig | 81 ++++ .../Resources/views/Webdav/doc_props.xml.twig | 53 +++ .../views/Webdav/open_in_browser.html.twig | 7 + .../Service/StoredObjectManager.php | 72 ++++ .../Service/StoredObjectManagerInterface.php | 4 + .../Tests/Controller/WebdavControllerTest.php | 408 ++++++++++++++++++ .../Request/PropfindRequestAnalyzerTest.php | 134 ++++++ utils/http/docstore/dav.http | 16 + utils/http/docstore/http-client.env.json | 6 + 15 files changed, 1168 insertions(+) create mode 100644 src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php create mode 100644 src/Bundle/ChillDocStoreBundle/Dav/Exception/ParseRequestException.php create mode 100644 src/Bundle/ChillDocStoreBundle/Dav/Request/PropfindRequestAnalyzer.php create mode 100644 src/Bundle/ChillDocStoreBundle/Dav/Response/DavResponse.php create mode 100644 src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/directory.html.twig create mode 100644 src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/directory_propfind.xml.twig create mode 100644 src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/doc_props.xml.twig create mode 100644 src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/open_in_browser.html.twig create mode 100644 src/Bundle/ChillDocStoreBundle/Tests/Controller/WebdavControllerTest.php create mode 100644 src/Bundle/ChillDocStoreBundle/Tests/Dav/Request/PropfindRequestAnalyzerTest.php create mode 100644 utils/http/docstore/dav.http create mode 100644 utils/http/docstore/http-client.env.json 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/ChillDocStoreBundle/Controller/WebdavController.php b/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php new file mode 100644 index 000000000..30a7e4eb2 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php @@ -0,0 +1,232 @@ +requestAnalyzer = new PropfindRequestAnalyzer(); + } + + /** + * @Route("/dav/open/{uuid}") + */ + public function open(StoredObject $storedObject): Response + { + /*$accessToken = $this->JWTTokenManager->createFromPayload($this->security->getUser(), [ + 'UserCanWrite' => true, + 'UserCanAttend' => true, + 'UserCanPresent' => true, + 'fileId' => $storedObject->getUuid(), + ]);*/ + + return new DavResponse($this->engine->render('@ChillDocStore/Webdav/open_in_browser.html.twig', [ + 'stored_object' => $storedObject, 'access_token' => '', + ])); + } + + /** + * @Route("/dav/get/{uuid}/", methods={"GET", "HEAD"}, name="chill_docstore_dav_directory_get") + */ + public function getDirectory(StoredObject $storedObject): Response + { + return new DavResponse( + $this->engine->render('@ChillDocStore/Webdav/directory.html.twig', [ + 'stored_object' => $storedObject + ]) + ); + } + + /** + * @Route("/dav/get/{uuid}/", methods={"OPTIONS"}) + */ + public function optionsDirectory(StoredObject $storedObject): Response + { + $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']); + + return $response; + } + + /** + * @Route("/dav/get/{uuid}/", methods={"PROPFIND"}) + */ + public function propfindDirectory(StoredObject $storedObject, Request $request): Response + { + $depth = $request->headers->get('depth'); + + if ("0" !== $depth && "1" !== $depth) { + throw new BadRequestHttpException("only 1 and 0 are accepted for Depth header"); + } + + $content = $request->getContent(); + $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); + } + + $response = new DavResponse( + $this->engine->render('@ChillDocStore/Webdav/directory_propfind.xml.twig', [ + 'stored_object' => $storedObject, + 'properties' => $properties, + 'last_modified' => $lastModified ?? null, + 'etag' => $etag ?? null, + 'content_length' => $length ?? null, + 'depth' => (int) $depth + ]), + 207 + ); + + $response->headers->add([ + 'Content-Type' => 'text/xml' + ]); + + return $response; + } + + /** + * @Route("/dav/get/{uuid}/d", name="chill_docstore_dav_document_get", methods={"GET"}) + */ + public function getDocument(StoredObject $storedObject): Response + { + return (new DavResponse($this->storedObjectManager->read($storedObject))) + ->setEtag($this->storedObjectManager->etag($storedObject)); + } + + /** + * @Route("/dav/get/{uuid}/d", methods={"HEAD"}) + */ + public function headDocument(StoredObject $storedObject): Response + { + $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/get/{uuid}/d", methods={"OPTIONS"}) + */ + public function optionsDocument(StoredObject $storedObject): Response + { + $response = (new DavResponse("")) + ->setEtag($this->storedObjectManager->etag($storedObject)) + ; + + $response->headers->add(['Allow' => /*sprintf( + '%s, %s, %s, %s, %s', + Request::METHOD_OPTIONS, + Request::METHOD_GET, + Request::METHOD_HEAD, + Request::METHOD_PUT, + 'PROPFIND' + ) */ 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT,PROPPATCH,COPY,MOVE,REPORT,PATCH,POST,TRACE' + ]); + + return $response; + } + + /** + * @Route("/dav/get/{uuid}/d", methods={"PROPFIND"}) + */ + public function propfindDocument(StoredObject $storedObject, Request $request): Response + { + $content = $request->getContent(); + $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); + } + + $response = new DavResponse( + $this->engine->render( + '@ChillDocStore/Webdav/doc_props.xml.twig', + [ + 'stored_object' => $storedObject, + 'properties' => $properties, + 'etag' => $etag ?? null, + 'last_modified' => $lastModified ?? null, + 'content_length' => $length ?? null, + ] + ), + 207 + ); + + $response + ->headers->add([ + 'Content-Type' => 'text/xml' + ]); + + return $response; + } + + /** + * @Route("/dav/get/{uuid}/d", methods={"PUT"}) + */ + public function putDocument(StoredObject $storedObject, Request $request): Response + { + $this->storedObjectManager->write($storedObject, $request->getContent()); + + return (new DavResponse("", Response::HTTP_NO_CONTENT)); + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Dav/Exception/ParseRequestException.php b/src/Bundle/ChillDocStoreBundle/Dav/Exception/ParseRequestException.php new file mode 100644 index 000000000..0c740be17 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Dav/Exception/ParseRequestException.php @@ -0,0 +1,14 @@ +} + */ +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/views/Webdav/directory.html.twig b/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/directory.html.twig new file mode 100644 index 000000000..5a95e894a --- /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..6edbb76e0 --- /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 } ) }} + {% 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}) }} + {% 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..3d320295b --- /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}) }} + {% 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..37d137047 --- /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 })) }}

+Open document +{% endblock %} diff --git a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManager.php b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManager.php index b55074627..57d8cb3c5 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); @@ -146,6 +202,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 cee6586ea..d0865e733 100644 --- a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManagerInterface.php +++ b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManagerInterface.php @@ -17,6 +17,8 @@ interface StoredObjectManagerInterface { public function getLastModified(StoredObject $document): \DateTimeInterface; + public function getContentLength(StoredObject $document): int; + /** * Get the content of a StoredObject. * @@ -33,4 +35,6 @@ interface StoredObjectManagerInterface * @param $clearContent The content to store in clear */ public function write(StoredObject $document, string $clearContent): void; + + public function etag(StoredObject $document): string; } diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Controller/WebdavControllerTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Controller/WebdavControllerTest.php new file mode 100644 index 000000000..959e9ed71 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Tests/Controller/WebdavControllerTest.php @@ -0,0 +1,408 @@ +engine = self::$container->get(\Twig\Environment::class); + } + + private function buildController(): WebdavController + { + $storedObjectManager = new MockedStoredObjectManager(); + + return new WebdavController($this->engine, $storedObjectManager); + } + + 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,POST,DELETE,TRACE,PROPFIND,PROPPATCH,COPY,MOVE,PUT') 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,POST,DELETE,TRACE,PROPFIND,PROPPATCH,COPY,MOVE,PUT') 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(), $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(), $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/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/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/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/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/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/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/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..3cc2f19fe --- /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 ($key === 'unknowns') { + 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/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" + } +} From ff05f9f48aca9e760be3ef9ac92e5a7403576923 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Thu, 14 Sep 2023 21:54:30 +0200 Subject: [PATCH 2/7] Dav: implements JWT extraction from the URL, and add the access_token in dav urls --- .../Controller/WebdavController.php | 52 ++++++++++-------- .../views/Webdav/directory.html.twig | 2 +- .../views/Webdav/directory_propfind.xml.twig | 4 +- .../Resources/views/Webdav/doc_props.xml.twig | 2 +- .../views/Webdav/open_in_browser.html.twig | 4 +- .../Security/Guard/DavOnUrlTokenExtractor.php | 49 +++++++++++++++++ .../Guard/JWTOnDavUrlAuthenticator.php | 38 +++++++++++++ .../Tests/Controller/WebdavControllerTest.php | 22 ++++---- .../Guard/DavOnUrlTokenExtractorTest.php | 54 +++++++++++++++++++ .../ChillDocStoreBundle/config/services.yaml | 5 ++ 10 files changed, 193 insertions(+), 39 deletions(-) create mode 100644 src/Bundle/ChillDocStoreBundle/Security/Guard/DavOnUrlTokenExtractor.php create mode 100644 src/Bundle/ChillDocStoreBundle/Security/Guard/JWTOnDavUrlAuthenticator.php create mode 100644 src/Bundle/ChillDocStoreBundle/Tests/Security/Guard/DavOnUrlTokenExtractorTest.php diff --git a/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php b/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php index 30a7e4eb2..dbd4e11ec 100644 --- a/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php +++ b/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php @@ -15,6 +15,7 @@ use Chill\DocStoreBundle\Dav\Request\PropfindRequestAnalyzer; use Chill\DocStoreBundle\Dav\Response\DavResponse; use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; +use DateTimeInterface; use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -30,41 +31,44 @@ final readonly class WebdavController public function __construct( private \Twig\Environment $engine, private StoredObjectManagerInterface $storedObjectManager, + private Security $security, + private ?JWTTokenManagerInterface $JWTTokenManager = null, ) { $this->requestAnalyzer = new PropfindRequestAnalyzer(); } /** - * @Route("/dav/open/{uuid}") + * @Route("/chdoc/open/{uuid}") */ public function open(StoredObject $storedObject): Response { - /*$accessToken = $this->JWTTokenManager->createFromPayload($this->security->getUser(), [ + $accessToken = $this->JWTTokenManager?->createFromPayload($this->security->getUser(), [ 'UserCanWrite' => true, 'UserCanAttend' => true, 'UserCanPresent' => true, 'fileId' => $storedObject->getUuid(), - ]);*/ + ]); return new DavResponse($this->engine->render('@ChillDocStore/Webdav/open_in_browser.html.twig', [ - 'stored_object' => $storedObject, 'access_token' => '', + 'stored_object' => $storedObject, 'access_token' => $accessToken, ])); } /** - * @Route("/dav/get/{uuid}/", methods={"GET", "HEAD"}, name="chill_docstore_dav_directory_get") + * @Route("/dav/{access_token}/get/{uuid}/", methods={"GET", "HEAD"}, name="chill_docstore_dav_directory_get") */ - public function getDirectory(StoredObject $storedObject): Response + public function getDirectory(StoredObject $storedObject, string $access_token): Response { return new DavResponse( $this->engine->render('@ChillDocStore/Webdav/directory.html.twig', [ - 'stored_object' => $storedObject + 'stored_object' => $storedObject, + 'access_token' => $access_token, ]) ); } /** - * @Route("/dav/get/{uuid}/", methods={"OPTIONS"}) + * @Route("/dav/{access_token}/get/{uuid}/", methods={"OPTIONS"}) */ public function optionsDirectory(StoredObject $storedObject): Response { @@ -78,9 +82,9 @@ final readonly class WebdavController } /** - * @Route("/dav/get/{uuid}/", methods={"PROPFIND"}) + * @Route("/dav/{access_token}/get/{uuid}/", methods={"PROPFIND"}) */ - public function propfindDirectory(StoredObject $storedObject, Request $request): Response + public function propfindDirectory(StoredObject $storedObject, string $access_token, Request $request): Response { $depth = $request->headers->get('depth'); @@ -111,10 +115,11 @@ final readonly class WebdavController $this->engine->render('@ChillDocStore/Webdav/directory_propfind.xml.twig', [ 'stored_object' => $storedObject, 'properties' => $properties, - 'last_modified' => $lastModified ?? null, - 'etag' => $etag ?? null, - 'content_length' => $length ?? null, - 'depth' => (int) $depth + 'last_modified' => $lastModified , + 'etag' => $etag, + 'content_length' => $length, + 'depth' => (int) $depth, + 'access_token' => $access_token, ]), 207 ); @@ -127,7 +132,7 @@ final readonly class WebdavController } /** - * @Route("/dav/get/{uuid}/d", name="chill_docstore_dav_document_get", methods={"GET"}) + * @Route("/dav/{access_token}/get/{uuid}/d", name="chill_docstore_dav_document_get", methods={"GET"}) */ public function getDocument(StoredObject $storedObject): Response { @@ -136,7 +141,7 @@ final readonly class WebdavController } /** - * @Route("/dav/get/{uuid}/d", methods={"HEAD"}) + * @Route("/dav/{access_token}/get/{uuid}/d", methods={"HEAD"}) */ public function headDocument(StoredObject $storedObject): Response { @@ -154,7 +159,7 @@ final readonly class WebdavController } /** - * @Route("/dav/get/{uuid}/d", methods={"OPTIONS"}) + * @Route("/dav/{access_token}/get/{uuid}/d", methods={"OPTIONS"}) */ public function optionsDocument(StoredObject $storedObject): Response { @@ -176,9 +181,9 @@ final readonly class WebdavController } /** - * @Route("/dav/get/{uuid}/d", methods={"PROPFIND"}) + * @Route("/dav/{access_token}/get/{uuid}/d", methods={"PROPFIND"}) */ - public function propfindDocument(StoredObject $storedObject, Request $request): Response + public function propfindDocument(StoredObject $storedObject, string $access_token, Request $request): Response { $content = $request->getContent(); $xml = new \DOMDocument(); @@ -204,9 +209,10 @@ final readonly class WebdavController [ 'stored_object' => $storedObject, 'properties' => $properties, - 'etag' => $etag ?? null, - 'last_modified' => $lastModified ?? null, - 'content_length' => $length ?? null, + 'etag' => $etag, + 'last_modified' => $lastModified, + 'content_length' => $length, + 'access_token' => $access_token, ] ), 207 @@ -221,7 +227,7 @@ final readonly class WebdavController } /** - * @Route("/dav/get/{uuid}/d", methods={"PUT"}) + * @Route("/dav/{access_token}/get/{uuid}/d", methods={"PUT"}) */ public function putDocument(StoredObject $storedObject, Request $request): Response { diff --git a/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/directory.html.twig b/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/directory.html.twig index 5a95e894a..90e19dd13 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/directory.html.twig +++ b/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/directory.html.twig @@ -6,7 +6,7 @@ diff --git a/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/directory_propfind.xml.twig b/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/directory_propfind.xml.twig index 6edbb76e0..23a8da064 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/directory_propfind.xml.twig +++ b/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/directory_propfind.xml.twig @@ -1,7 +1,7 @@ - {{ path('chill_docstore_dav_directory_get', { 'uuid': stored_object.uuid } ) }} + {{ path('chill_docstore_dav_directory_get', { 'uuid': stored_object.uuid, 'access_token': access_token } ) }} {% if properties.resourceType or properties.contentType %} @@ -28,7 +28,7 @@ {% if depth == 1 %} - {{ path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid}) }} + {{ 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 %} diff --git a/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/doc_props.xml.twig b/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/doc_props.xml.twig index 3d320295b..7cde5a5de 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/doc_props.xml.twig +++ b/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/doc_props.xml.twig @@ -1,7 +1,7 @@ - {{ path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid}) }} + {{ 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 %} 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 index 37d137047..2a32681e7 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/open_in_browser.html.twig +++ b/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/open_in_browser.html.twig @@ -2,6 +2,6 @@ {% block content %}

document uuid: {{ stored_object.uuid }}

-

{{ absolute_url(path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid })) }}

-Open document +

{{ 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/Guard/DavOnUrlTokenExtractor.php b/src/Bundle/ChillDocStoreBundle/Security/Guard/DavOnUrlTokenExtractor.php new file mode 100644 index 000000000..eb030f799 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Security/Guard/DavOnUrlTokenExtractor.php @@ -0,0 +1,49 @@ +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/JWTOnDavUrlAuthenticator.php b/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTOnDavUrlAuthenticator.php new file mode 100644 index 000000000..b4ae04afc --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTOnDavUrlAuthenticator.php @@ -0,0 +1,38 @@ +davOnUrlTokenExtractor; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Controller/WebdavControllerTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Controller/WebdavControllerTest.php index 959e9ed71..9064d8419 100644 --- a/src/Bundle/ChillDocStoreBundle/Tests/Controller/WebdavControllerTest.php +++ b/src/Bundle/ChillDocStoreBundle/Tests/Controller/WebdavControllerTest.php @@ -21,6 +21,7 @@ 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; use Symfony\Component\Templating\EngineInterface; /** @@ -43,8 +44,9 @@ class WebdavControllerTest extends KernelTestCase private function buildController(): WebdavController { $storedObjectManager = new MockedStoredObjectManager(); + $security = $this->prophesize(Security::class); - return new WebdavController($this->engine, $storedObjectManager); + return new WebdavController($this->engine, $storedObjectManager, $security->reveal()); } private function buildDocument(): StoredObject @@ -115,7 +117,7 @@ class WebdavControllerTest extends KernelTestCase $request = new Request([], [], [], [], [], [], $requestContent); $request->setMethod('PROPFIND'); - $response = $controller->propfindDocument($this->buildDocument(), $request); + $response = $controller->propfindDocument($this->buildDocument(), '1234', $request); self::assertEquals($expectedStatusCode, $response->getStatusCode()); self::assertContains('content-type', $response->headers->keys()); @@ -134,7 +136,7 @@ class WebdavControllerTest extends KernelTestCase $request = new Request([], [], [], [], [], [], $requestContent); $request->setMethod('PROPFIND'); $request->headers->add(["Depth" => "0"]); - $response = $controller->propfindDirectory($this->buildDocument(), $request); + $response = $controller->propfindDirectory($this->buildDocument(), '1234', $request); self::assertEquals($expectedStatusCode, $response->getStatusCode()); self::assertContains('content-type', $response->headers->keys()); @@ -170,7 +172,7 @@ class WebdavControllerTest extends KernelTestCase - /dav/get/716e6688-4579-4938-acf3-c4ab5856803b/d + /dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d @@ -205,7 +207,7 @@ class WebdavControllerTest extends KernelTestCase - /dav/get/716e6688-4579-4938-acf3-c4ab5856803b/d + /dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d @@ -232,7 +234,7 @@ class WebdavControllerTest extends KernelTestCase - /dav/get/716e6688-4579-4938-acf3-c4ab5856803b/d + /dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d @@ -259,7 +261,7 @@ class WebdavControllerTest extends KernelTestCase - /dav/get/716e6688-4579-4938-acf3-c4ab5856803b/d + /dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d @@ -285,7 +287,7 @@ class WebdavControllerTest extends KernelTestCase - /dav/get/716e6688-4579-4938-acf3-c4ab5856803b/d + /dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d @@ -323,7 +325,7 @@ class WebdavControllerTest extends KernelTestCase - /dav/get/716e6688-4579-4938-acf3-c4ab5856803b/ + /dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/ @@ -365,7 +367,7 @@ class WebdavControllerTest extends KernelTestCase - /dav/get/716e6688-4579-4938-acf3-c4ab5856803b/ + /dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/ 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..b9e046b28 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Tests/Security/Guard/DavOnUrlTokenExtractorTest.php @@ -0,0 +1,54 @@ +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/' From a35f7656cb5b841b98bd460004fa417136153848 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Fri, 15 Sep 2023 14:16:58 +0200 Subject: [PATCH 3/7] Dav: refactor WebdavController --- .../Controller/WebdavController.php | 67 +++++++++---------- 1 file changed, 32 insertions(+), 35 deletions(-) diff --git a/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php b/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php index dbd4e11ec..8ef4ee71a 100644 --- a/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php +++ b/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php @@ -92,24 +92,7 @@ final readonly class WebdavController throw new BadRequestHttpException("only 1 and 0 are accepted for Depth header"); } - $content = $request->getContent(); - $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); - } + [$properties, $lastModified, $etag, $length] = $this->parseDavRequest($request->getContent(), $storedObject); $response = new DavResponse( $this->engine->render('@ChillDocStore/Webdav/directory_propfind.xml.twig', [ @@ -185,23 +168,7 @@ final readonly class WebdavController */ public function propfindDocument(StoredObject $storedObject, string $access_token, Request $request): Response { - $content = $request->getContent(); - $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); - } + [$properties, $lastModified, $etag, $length] = $this->parseDavRequest($request->getContent(), $storedObject); $response = new DavResponse( $this->engine->render( @@ -235,4 +202,34 @@ final readonly class WebdavController 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 + ]; + } } From 18fd1dbc4a6efd8856a78e17ecaa9bb2f4ff8c20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Fri, 15 Sep 2023 22:21:56 +0200 Subject: [PATCH 4/7] Dav: Introduce access control inside de dav controller --- ...TokenAuthenticationEventSubscriberTest.php | 65 +++++++++ .../Controller/WebdavController.php | 61 ++++++--- .../Authorization/StoredObjectRoleEnum.php | 19 +++ .../Authorization/StoredObjectVoter.php | 52 ++++++++ .../DavTokenAuthenticationEventSubscriber.php | 50 +++++++ .../Security/Guard/JWTDavTokenProvider.php | 41 ++++++ .../Guard/JWTDavTokenProviderInterface.php | 23 ++++ .../Tests/Controller/WebdavControllerTest.php | 6 +- .../Authorization/StoredObjectVoterTest.php | 124 ++++++++++++++++++ 9 files changed, 419 insertions(+), 22 deletions(-) create mode 100644 src/Bundle/ChillAsideActivityBundle/src/Tests/Chill/DocStoreBundle/Tests/Security/Guard/DavTokenAuthenticationEventSubscriberTest.php create mode 100644 src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectRoleEnum.php create mode 100644 src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectVoter.php create mode 100644 src/Bundle/ChillDocStoreBundle/Security/Guard/DavTokenAuthenticationEventSubscriber.php create mode 100644 src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProvider.php create mode 100644 src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProviderInterface.php create mode 100644 src/Bundle/ChillDocStoreBundle/Tests/Security/Authorization/StoredObjectVoterTest.php 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..03cea7d29 --- /dev/null +++ b/src/Bundle/ChillAsideActivityBundle/src/Tests/Chill/DocStoreBundle/Tests/Security/Guard/DavTokenAuthenticationEventSubscriberTest.php @@ -0,0 +1,65 @@ + 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 index 8ef4ee71a..471aec99f 100644 --- a/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php +++ b/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php @@ -14,15 +14,16 @@ namespace Chill\DocStoreBundle\Controller; use Chill\DocStoreBundle\Dav\Request\PropfindRequestAnalyzer; use Chill\DocStoreBundle\Dav\Response\DavResponse; use Chill\DocStoreBundle\Entity\StoredObject; +use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; +use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface; use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; use DateTimeInterface; -use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Security\Core\Security; -use Symfony\Component\Templating\EngineInterface; final readonly class WebdavController { @@ -31,8 +32,8 @@ final readonly class WebdavController public function __construct( private \Twig\Environment $engine, private StoredObjectManagerInterface $storedObjectManager, - private Security $security, - private ?JWTTokenManagerInterface $JWTTokenManager = null, + private Security $security, + private ?JWTDavTokenProviderInterface $davTokenProvider = null, ) { $this->requestAnalyzer = new PropfindRequestAnalyzer(); } @@ -42,12 +43,7 @@ final readonly class WebdavController */ public function open(StoredObject $storedObject): Response { - $accessToken = $this->JWTTokenManager?->createFromPayload($this->security->getUser(), [ - 'UserCanWrite' => true, - 'UserCanAttend' => true, - 'UserCanPresent' => true, - 'fileId' => $storedObject->getUuid(), - ]); + $accessToken = $this->davTokenProvider?->createToken($storedObject, StoredObjectRoleEnum::EDIT); return new DavResponse($this->engine->render('@ChillDocStore/Webdav/open_in_browser.html.twig', [ 'stored_object' => $storedObject, 'access_token' => $accessToken, @@ -59,6 +55,10 @@ final readonly class WebdavController */ 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, @@ -72,11 +72,16 @@ final readonly class WebdavController */ 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,PROPPATCH,COPY,MOVE,REPORT,PATCH,POST,TRACE']); + $response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT']); return $response; } @@ -86,6 +91,10 @@ final readonly class WebdavController */ 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) { @@ -119,6 +128,10 @@ final readonly class WebdavController */ 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)); } @@ -128,6 +141,10 @@ final readonly class WebdavController */ public function headDocument(StoredObject $storedObject): Response { + if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) { + throw new AccessDeniedHttpException(); + } + $response = new DavResponse(""); $response->headers->add( @@ -146,19 +163,15 @@ final readonly class WebdavController */ 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' => /*sprintf( - '%s, %s, %s, %s, %s', - Request::METHOD_OPTIONS, - Request::METHOD_GET, - Request::METHOD_HEAD, - Request::METHOD_PUT, - 'PROPFIND' - ) */ '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; } @@ -168,6 +181,10 @@ final readonly class WebdavController */ 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( @@ -198,6 +215,10 @@ final readonly class WebdavController */ 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)); diff --git a/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectRoleEnum.php b/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectRoleEnum.php new file mode 100644 index 000000000..6a5a22da0 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectRoleEnum.php @@ -0,0 +1,19 @@ +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 => + $tokenRoleAuthorization === StoredObjectRoleEnum::EDIT || $tokenRoleAuthorization === StoredObjectRoleEnum::SEE, + StoredObjectRoleEnum::EDIT => $tokenRoleAuthorization === StoredObjectRoleEnum::EDIT + }; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Security/Guard/DavTokenAuthenticationEventSubscriber.php b/src/Bundle/ChillDocStoreBundle/Security/Guard/DavTokenAuthenticationEventSubscriber.php new file mode 100644 index 000000000..3c1b3e2a9 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Security/Guard/DavTokenAuthenticationEventSubscriber.php @@ -0,0 +1,50 @@ + ['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..30b25dd0b --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProvider.php @@ -0,0 +1,41 @@ +JWTTokenManager->createFromPayload($this->security->getUser(), [ + 'dav' => 1, + 'e' => match ($roleEnum) { + StoredObjectRoleEnum::SEE => 0, + StoredObjectRoleEnum::EDIT => 1, + }, + 'so' => $storedObject->getUuid(), + ]); + } + +} diff --git a/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProviderInterface.php b/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProviderInterface.php new file mode 100644 index 000000000..6be91a39c --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProviderInterface.php @@ -0,0 +1,23 @@ +prophesize(Security::class); + $security->isGranted(Argument::in(['EDIT', 'SEE']), Argument::type(StoredObject::class)) + ->willReturn(true); return new WebdavController($this->engine, $storedObjectManager, $security->reveal()); } @@ -83,7 +85,7 @@ class WebdavControllerTest extends KernelTestCase self::assertEquals(200, $response->getStatusCode()); self::assertContains('allow', $response->headers->keys()); - foreach (explode(',', 'OPTIONS,GET,HEAD,POST,DELETE,TRACE,PROPFIND,PROPPATCH,COPY,MOVE,PUT') as $method) { + foreach (explode(',', 'OPTIONS,GET,HEAD,PROPFIND') as $method) { self::assertStringContainsString($method, $response->headers->get('allow')); } @@ -100,7 +102,7 @@ class WebdavControllerTest extends KernelTestCase self::assertEquals(200, $response->getStatusCode()); self::assertContains('allow', $response->headers->keys()); - foreach (explode(',', 'OPTIONS,GET,HEAD,POST,DELETE,TRACE,PROPFIND,PROPPATCH,COPY,MOVE,PUT') as $method) { + foreach (explode(',', 'OPTIONS,GET,HEAD,PROPFIND') as $method) { self::assertStringContainsString($method, $response->headers->get('allow')); } 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..c4518586f --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Tests/Security/Authorization/StoredObjectVoterTest.php @@ -0,0 +1,124 @@ +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(); + } +} From ab95bb157eb940bb7fd25bcc615de8835817e7c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Fri, 15 Sep 2023 22:32:25 +0200 Subject: [PATCH 5/7] Dav: add some documentation on classes --- .../Controller/WebdavController.php | 24 +++++++++---------- .../Authorization/StoredObjectRoleEnum.php | 3 +++ .../Authorization/StoredObjectVoter.php | 5 ++++ .../Security/Guard/DavOnUrlTokenExtractor.php | 8 +++++++ .../DavTokenAuthenticationEventSubscriber.php | 3 +++ .../Guard/JWTOnDavUrlAuthenticator.php | 3 +++ 6 files changed, 33 insertions(+), 13 deletions(-) diff --git a/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php b/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php index 471aec99f..ff22e6046 100644 --- a/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php +++ b/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php @@ -25,6 +25,17 @@ use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Security\Core\Security; +/** + * Provide endpoint for editing a document on the desktop using dav. + * + * This controller implements the minimal required methods to edit a document on a desktop software (i.e. LibreOffice) + * and save the document online. + * + * To avoid to ask for a password, the endpoints are protected using a JWT access token, which is inside the + * URL. This avoid the DAV Client (LibreOffice) to keep an access token in query parameter or in some header (which + * they are not able to understand). The JWT Guard is adapted with a dedicated token extractor which is going to read + * the segments (separation of "/"): the first segment must be the string "dav", and the second one must be the JWT. + */ final readonly class WebdavController { private PropfindRequestAnalyzer $requestAnalyzer; @@ -33,23 +44,10 @@ final readonly class WebdavController private \Twig\Environment $engine, private StoredObjectManagerInterface $storedObjectManager, private Security $security, - private ?JWTDavTokenProviderInterface $davTokenProvider = null, ) { $this->requestAnalyzer = new PropfindRequestAnalyzer(); } - /** - * @Route("/chdoc/open/{uuid}") - */ - public function open(StoredObject $storedObject): Response - { - $accessToken = $this->davTokenProvider?->createToken($storedObject, StoredObjectRoleEnum::EDIT); - - return new DavResponse($this->engine->render('@ChillDocStore/Webdav/open_in_browser.html.twig', [ - 'stored_object' => $storedObject, 'access_token' => $accessToken, - ])); - } - /** * @Route("/dav/{access_token}/get/{uuid}/", methods={"GET", "HEAD"}, name="chill_docstore_dav_directory_get") */ diff --git a/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectRoleEnum.php b/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectRoleEnum.php index 6a5a22da0..af2813240 100644 --- a/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectRoleEnum.php +++ b/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectRoleEnum.php @@ -11,6 +11,9 @@ declare(strict_types=1); namespace Chill\DocStoreBundle\Security\Authorization; +/** + * Role to edit or see the stored object content. + */ enum StoredObjectRoleEnum: string { case SEE = 'SEE'; diff --git a/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectVoter.php b/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectVoter.php index 55c9b3ca1..570a2be13 100644 --- a/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectVoter.php +++ b/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectVoter.php @@ -16,6 +16,11 @@ use Chill\DocStoreBundle\Security\Guard\DavTokenAuthenticationEventSubscriber; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\Voter\Voter; +/** + * Voter for the content of a stored object. + * + * This is in use to allow or disallow the edition of the stored object's content. + */ class StoredObjectVoter extends Voter { protected function supports($attribute, $subject): bool diff --git a/src/Bundle/ChillDocStoreBundle/Security/Guard/DavOnUrlTokenExtractor.php b/src/Bundle/ChillDocStoreBundle/Security/Guard/DavOnUrlTokenExtractor.php index eb030f799..0b2cc6a0a 100644 --- a/src/Bundle/ChillDocStoreBundle/Security/Guard/DavOnUrlTokenExtractor.php +++ b/src/Bundle/ChillDocStoreBundle/Security/Guard/DavOnUrlTokenExtractor.php @@ -15,6 +15,14 @@ use Lexik\Bundle\JWTAuthenticationBundle\TokenExtractor\TokenExtractorInterface; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; +/** + * Extract the JWT Token from the segment of the dav endpoints. + * + * A segment is a separation inside the string, using the character "/". + * + * For recognizing the JWT, the first segment must be "dav", and the second one must be + * the JWT endpoint. + */ final readonly class DavOnUrlTokenExtractor implements TokenExtractorInterface { public function __construct( diff --git a/src/Bundle/ChillDocStoreBundle/Security/Guard/DavTokenAuthenticationEventSubscriber.php b/src/Bundle/ChillDocStoreBundle/Security/Guard/DavTokenAuthenticationEventSubscriber.php index 3c1b3e2a9..c3f527e71 100644 --- a/src/Bundle/ChillDocStoreBundle/Security/Guard/DavTokenAuthenticationEventSubscriber.php +++ b/src/Bundle/ChillDocStoreBundle/Security/Guard/DavTokenAuthenticationEventSubscriber.php @@ -16,6 +16,9 @@ use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTAuthenticatedEvent; use Lexik\Bundle\JWTAuthenticationBundle\Events; use Symfony\Component\EventDispatcher\EventSubscriberInterface; +/** + * Store some data from the JWT's payload inside the token's attributes. + */ class DavTokenAuthenticationEventSubscriber implements EventSubscriberInterface { final public const STORED_OBJECT = 'stored_object'; diff --git a/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTOnDavUrlAuthenticator.php b/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTOnDavUrlAuthenticator.php index b4ae04afc..ceb44949a 100644 --- a/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTOnDavUrlAuthenticator.php +++ b/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTOnDavUrlAuthenticator.php @@ -18,6 +18,9 @@ use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInt use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Symfony\Contracts\Translation\TranslatorInterface; +/** + * Alter the base JWTTokenAuthenticator to add the special extractor for dav url endpoints. + */ class JWTOnDavUrlAuthenticator extends JWTTokenAuthenticator { public function __construct( From 813a80d6f9b46286ba68315cb2ee5a382e3cadce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Sun, 17 Sep 2023 15:44:57 +0200 Subject: [PATCH 6/7] Dav: add UI to edit document --- .../Controller/WebdavController.php | 1 - .../document_action_buttons_group/index.ts | 10 ++- .../vuejs/DocumentActionButtonsGroup.vue | 16 ++++- .../StoredObjectButton/DesktopEditButton.vue | 66 +++++++++++++++++++ .../views/Button/button_group.html.twig | 2 + .../Security/Guard/JWTDavTokenProvider.php | 7 ++ .../Guard/JWTDavTokenProviderInterface.php | 2 + .../WopiEditTwigExtensionRuntime.php | 33 ++++++++-- 8 files changed, 126 insertions(+), 11 deletions(-) create mode 100644 src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/DesktopEditButton.vue diff --git a/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php b/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php index ff22e6046..b4624d463 100644 --- a/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php +++ b/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php @@ -15,7 +15,6 @@ use Chill\DocStoreBundle\Dav\Request\PropfindRequestAnalyzer; use Chill\DocStoreBundle\Dav\Response\DavResponse; use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; -use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface; use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; use DateTimeInterface; use Symfony\Component\HttpFoundation\Request; 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/Security/Guard/JWTDavTokenProvider.php b/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProvider.php index 30b25dd0b..a297ed613 100644 --- a/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProvider.php +++ b/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProvider.php @@ -38,4 +38,11 @@ final readonly class JWTDavTokenProvider implements JWTDavTokenProviderInterface ]); } + 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 index 6be91a39c..b93b658b0 100644 --- a/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProviderInterface.php +++ b/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProviderInterface.php @@ -20,4 +20,6 @@ use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; interface JWTDavTokenProviderInterface { public function createToken(StoredObject $storedObject, StoredObjectRoleEnum $roleEnum): string; + + public function getTokenExpiration(string $tokenString): \DateTimeImmutable; } diff --git a/src/Bundle/ChillDocStoreBundle/Templating/WopiEditTwigExtensionRuntime.php b/src/Bundle/ChillDocStoreBundle/Templating/WopiEditTwigExtensionRuntime.php index 969e4f95e..c26e7d3fc 100644 --- a/src/Bundle/ChillDocStoreBundle/Templating/WopiEditTwigExtensionRuntime.php +++ b/src/Bundle/ChillDocStoreBundle/Templating/WopiEditTwigExtensionRuntime.php @@ -13,6 +13,10 @@ 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 Namshi\JOSE\JWS; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Twig\Environment; @@ -120,9 +124,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, + ) {} /** * return true if the document is editable. @@ -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); } /** @@ -142,18 +149,32 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt * @throws \Twig\Error\RuntimeError * @throws \Twig\Error\SyntaxError */ - public function renderButtonGroup(Environment $environment, StoredObject $document, string $title = null, bool $canEdit = true, array $options = []): string + 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'), ]); } - public function renderEditButton(Environment $environment, StoredObject $document, array $options = null): string + public function renderEditButton(Environment $environment, StoredObject $document, ?array $options = null): string { return $environment->render(self::TEMPLATE, [ 'document' => $document, From a0328b9d68093cad82072c1c0475006e4f6538e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Mon, 15 Jan 2024 20:38:03 +0100 Subject: [PATCH 7/7] Apply new CS rules on the webdav feature --- ...TokenAuthenticationEventSubscriberTest.php | 1 + .../Controller/WebdavController.php | 29 ++++--- .../Dav/Exception/ParseRequestException.php | 4 +- .../Dav/Request/PropfindRequestAnalyzer.php | 9 +-- .../Authorization/StoredObjectVoter.php | 8 +- .../Security/Guard/DavOnUrlTokenExtractor.php | 9 ++- .../DavTokenAuthenticationEventSubscriber.php | 4 +- .../Security/Guard/JWTDavTokenProvider.php | 6 +- .../Guard/JWTDavTokenProviderInterface.php | 2 +- .../WopiEditTwigExtensionRuntime.php | 8 +- .../Tests/Controller/WebdavControllerTest.php | 32 ++++---- .../Request/PropfindRequestAnalyzerTest.php | 80 +++++++++---------- .../Authorization/StoredObjectVoterTest.php | 17 ++-- .../Guard/DavOnUrlTokenExtractorTest.php | 3 +- 14 files changed, 104 insertions(+), 108 deletions(-) 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 index 03cea7d29..875e40a65 100644 --- 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 @@ -19,6 +19,7 @@ use Symfony\Component\Security\Core\Authentication\Token\AbstractToken; /** * @internal + * * @coversNothing */ class DavTokenAuthenticationEventSubscriberTest extends TestCase diff --git a/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php b/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php index b4624d463..70aecbe1e 100644 --- a/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php +++ b/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php @@ -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 DateTimeInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; @@ -42,7 +41,7 @@ final readonly class WebdavController public function __construct( private \Twig\Environment $engine, private StoredObjectManagerInterface $storedObjectManager, - private Security $security, + private Security $security, ) { $this->requestAnalyzer = new PropfindRequestAnalyzer(); } @@ -73,11 +72,11 @@ final readonly class WebdavController throw new AccessDeniedHttpException(); } - $response = (new DavResponse("")) + $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,PROPPATCH,COPY,MOVE,REPORT,PATCH,POST,TRACE']); $response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT']); return $response; @@ -94,8 +93,8 @@ final readonly class WebdavController $depth = $request->headers->get('depth'); - if ("0" !== $depth && "1" !== $depth) { - throw new BadRequestHttpException("only 1 and 0 are accepted for Depth header"); + 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); @@ -104,7 +103,7 @@ final readonly class WebdavController $this->engine->render('@ChillDocStore/Webdav/directory_propfind.xml.twig', [ 'stored_object' => $storedObject, 'properties' => $properties, - 'last_modified' => $lastModified , + 'last_modified' => $lastModified, 'etag' => $etag, 'content_length' => $length, 'depth' => (int) $depth, @@ -114,7 +113,7 @@ final readonly class WebdavController ); $response->headers->add([ - 'Content-Type' => 'text/xml' + 'Content-Type' => 'text/xml', ]); return $response; @@ -142,13 +141,13 @@ final readonly class WebdavController throw new AccessDeniedHttpException(); } - $response = new DavResponse(""); + $response = new DavResponse(''); $response->headers->add( [ 'Content-Length' => $this->storedObjectManager->getContentLength($storedObject), 'Content-Type' => $storedObject->getType(), - 'Etag' => $this->storedObjectManager->etag($storedObject) + 'Etag' => $this->storedObjectManager->etag($storedObject), ] ); @@ -164,7 +163,7 @@ final readonly class WebdavController throw new AccessDeniedHttpException(); } - $response = (new DavResponse("")) + $response = (new DavResponse('')) ->setEtag($this->storedObjectManager->etag($storedObject)) ; @@ -201,7 +200,7 @@ final readonly class WebdavController $response ->headers->add([ - 'Content-Type' => 'text/xml' + 'Content-Type' => 'text/xml', ]); return $response; @@ -218,11 +217,11 @@ final readonly class WebdavController $this->storedObjectManager->write($storedObject, $request->getContent()); - return (new DavResponse("", Response::HTTP_NO_CONTENT)); + return new DavResponse('', Response::HTTP_NO_CONTENT); } /** - * @return array{0: array, 1: DateTimeInterface, 2: string, 3: int} properties, lastModified, etag, length + * @return array{0: array, 1: \DateTimeInterface, 2: string, 3: int} properties, lastModified, etag, length */ private function parseDavRequest(string $content, StoredObject $storedObject): array { @@ -247,7 +246,7 @@ final readonly class WebdavController $properties, $lastModified ?? null, $etag ?? null, - $length ?? null + $length ?? null, ]; } } diff --git a/src/Bundle/ChillDocStoreBundle/Dav/Exception/ParseRequestException.php b/src/Bundle/ChillDocStoreBundle/Dav/Exception/ParseRequestException.php index 0c740be17..70fff1866 100644 --- a/src/Bundle/ChillDocStoreBundle/Dav/Exception/ParseRequestException.php +++ b/src/Bundle/ChillDocStoreBundle/Dav/Exception/ParseRequestException.php @@ -11,4 +11,6 @@ declare(strict_types=1); namespace Chill\DocStoreBundle\Dav\Exception; -class ParseRequestException extends \UnexpectedValueException {} +class ParseRequestException extends \UnexpectedValueException +{ +} diff --git a/src/Bundle/ChillDocStoreBundle/Dav/Request/PropfindRequestAnalyzer.php b/src/Bundle/ChillDocStoreBundle/Dav/Request/PropfindRequestAnalyzer.php index 3abf43c62..e6c52a193 100644 --- a/src/Bundle/ChillDocStoreBundle/Dav/Request/PropfindRequestAnalyzer.php +++ b/src/Bundle/ChillDocStoreBundle/Dav/Request/PropfindRequestAnalyzer.php @@ -36,17 +36,17 @@ class PropfindRequestAnalyzer $propfinds = $request->getElementsByTagNameNS('DAV:', 'propfind'); if (0 === $propfinds->count()) { - throw new ParseRequestException("any propfind element found"); + throw new ParseRequestException('any propfind element found'); } if (1 < $propfinds->count()) { - throw new ParseRequestException("too much propfind element found"); + throw new ParseRequestException('too much propfind element found'); } $propfind = $propfinds->item(0); if (0 === $propfind->childNodes->count()) { - throw new ParseRequestException("no element under propfind"); + throw new ParseRequestException('no element under propfind'); } $unknows = []; @@ -79,7 +79,6 @@ class PropfindRequestAnalyzer default => '', }; } - } $props = array_filter(array_values($props), fn (string $item) => '' !== $item); @@ -98,7 +97,7 @@ class PropfindRequestAnalyzer self::KNOWN_PROPS, array_fill(0, count(self::KNOWN_PROPS), $default) ), - 'unknowns' => [] + 'unknowns' => [], ]; } } diff --git a/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectVoter.php b/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectVoter.php index 570a2be13..2e253cf3c 100644 --- a/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectVoter.php +++ b/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectVoter.php @@ -34,8 +34,7 @@ class StoredObjectVoter extends Voter /** @var StoredObject $subject */ if ( !$token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT) - || - $subject->getUuid()->toString() !== $token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT) + || $subject->getUuid()->toString() !== $token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT) ) { return false; } @@ -49,9 +48,8 @@ class StoredObjectVoter extends Voter $token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS); return match ($askedRole) { - StoredObjectRoleEnum::SEE => - $tokenRoleAuthorization === StoredObjectRoleEnum::EDIT || $tokenRoleAuthorization === StoredObjectRoleEnum::SEE, - StoredObjectRoleEnum::EDIT => $tokenRoleAuthorization === StoredObjectRoleEnum::EDIT + 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 index 0b2cc6a0a..543996f57 100644 --- a/src/Bundle/ChillDocStoreBundle/Security/Guard/DavOnUrlTokenExtractor.php +++ b/src/Bundle/ChillDocStoreBundle/Security/Guard/DavOnUrlTokenExtractor.php @@ -27,9 +27,10 @@ final readonly class DavOnUrlTokenExtractor implements TokenExtractorInterface { public function __construct( private LoggerInterface $logger, - ) {} + ) { + } - public function extract(Request $request): string|false + public function extract(Request $request): false|string { $uri = $request->getRequestUri(); @@ -41,13 +42,13 @@ final readonly class DavOnUrlTokenExtractor implements TokenExtractorInterface ); if (2 > count($segments)) { - $this->logger->info("not enough segment for parsing URL"); + $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"); + $this->logger->info('the first segment of the url must be DAV'); return false; } diff --git a/src/Bundle/ChillDocStoreBundle/Security/Guard/DavTokenAuthenticationEventSubscriber.php b/src/Bundle/ChillDocStoreBundle/Security/Guard/DavTokenAuthenticationEventSubscriber.php index c3f527e71..7b33c0eec 100644 --- a/src/Bundle/ChillDocStoreBundle/Security/Guard/DavTokenAuthenticationEventSubscriber.php +++ b/src/Bundle/ChillDocStoreBundle/Security/Guard/DavTokenAuthenticationEventSubscriber.php @@ -43,11 +43,9 @@ class DavTokenAuthenticationEventSubscriber implements EventSubscriberInterface $token->setAttribute(self::ACTIONS, match ($payload['e']) { 0 => StoredObjectRoleEnum::SEE, 1 => StoredObjectRoleEnum::EDIT, - default => throw new \UnexpectedValueException("unsupported value for e parameter") + 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 index a297ed613..24e89a3ba 100644 --- a/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProvider.php +++ b/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProvider.php @@ -17,14 +17,15 @@ use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface; use Symfony\Component\Security\Core\Security; /** - * Provide a JWT Token which will be valid for viewing or editing a document + * Provide a JWT Token which will be valid for viewing or editing a document. */ final readonly class JWTDavTokenProvider implements JWTDavTokenProviderInterface { public function __construct( private JWTTokenManagerInterface $JWTTokenManager, private Security $security, - ) {} + ) { + } public function createToken(StoredObject $storedObject, StoredObjectRoleEnum $roleEnum): string { @@ -44,5 +45,4 @@ final readonly class JWTDavTokenProvider implements JWTDavTokenProviderInterface 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 index b93b658b0..95c62c86e 100644 --- a/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProviderInterface.php +++ b/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProviderInterface.php @@ -15,7 +15,7 @@ use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; /** - * Provide a JWT Token which will be valid for viewing or editing a document + * Provide a JWT Token which will be valid for viewing or editing a document. */ interface JWTDavTokenProviderInterface { diff --git a/src/Bundle/ChillDocStoreBundle/Templating/WopiEditTwigExtensionRuntime.php b/src/Bundle/ChillDocStoreBundle/Templating/WopiEditTwigExtensionRuntime.php index c26e7d3fc..fd6c718b9 100644 --- a/src/Bundle/ChillDocStoreBundle/Templating/WopiEditTwigExtensionRuntime.php +++ b/src/Bundle/ChillDocStoreBundle/Templating/WopiEditTwigExtensionRuntime.php @@ -15,7 +15,6 @@ 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 Namshi\JOSE\JWS; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; @@ -129,7 +128,8 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt private NormalizerInterface $normalizer, private JWTDavTokenProviderInterface $davTokenProvider, private UrlGeneratorInterface $urlGenerator, - ) {} + ) { + } /** * return true if the document is editable. @@ -149,7 +149,7 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt * @throws \Twig\Error\RuntimeError * @throws \Twig\Error\SyntaxError */ - public function renderButtonGroup(Environment $environment, StoredObject $document, ?string $title = null, bool $canEdit = true, array $options = []): string + public function renderButtonGroup(Environment $environment, StoredObject $document, string $title = null, bool $canEdit = true, array $options = []): string { $accessToken = $this->davTokenProvider->createToken( $document, @@ -174,7 +174,7 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt ]); } - public function renderEditButton(Environment $environment, StoredObject $document, ?array $options = null): string + public function renderEditButton(Environment $environment, StoredObject $document, array $options = null): string { return $environment->render(self::TEMPLATE, [ 'document' => $document, diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Controller/WebdavControllerTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Controller/WebdavControllerTest.php index 93d4d85e8..9254efc2d 100644 --- a/src/Bundle/ChillDocStoreBundle/Tests/Controller/WebdavControllerTest.php +++ b/src/Bundle/ChillDocStoreBundle/Tests/Controller/WebdavControllerTest.php @@ -14,18 +14,16 @@ namespace Chill\DocStoreBundle\Tests\Controller; use Chill\DocStoreBundle\Controller\WebdavController; use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; -use DateTimeInterface; -use PHPUnit\Framework\TestCase; use Prophecy\Argument; 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; -use Symfony\Component\Templating\EngineInterface; /** * @internal + * * @coversNothing */ class WebdavControllerTest extends KernelTestCase @@ -124,7 +122,7 @@ class WebdavControllerTest extends KernelTestCase 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::assertTrue((new \DOMDocument())->loadXML($response->getContent()), $message.' test that the xml response is a valid xml'); self::assertXmlStringEqualsXmlString($expectedXmlResponse, $response->getContent(), $message); } @@ -137,13 +135,13 @@ class WebdavControllerTest extends KernelTestCase $request = new Request([], [], [], [], [], [], $requestContent); $request->setMethod('PROPFIND'); - $request->headers->add(["Depth" => "0"]); + $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::assertTrue((new \DOMDocument())->loadXML($response->getContent()), $message.' test that the xml response is a valid xml'); self::assertXmlStringEqualsXmlString($expectedXmlResponse, $response->getContent(), $message); } @@ -192,7 +190,7 @@ class WebdavControllerTest extends KernelTestCase
    XML; - yield [$content, 207, $response, "get IsReadOnly and contenttype from server"]; + yield [$content, 207, $response, 'get IsReadOnly and contenttype from server']; $content = <<<'XML' @@ -220,7 +218,7 @@ class WebdavControllerTest extends KernelTestCase
    XML; - yield [$content, 207, $response, "get property IsReadOnly"]; + yield [$content, 207, $response, 'get property IsReadOnly']; yield [ <<<'XML' @@ -246,7 +244,7 @@ class WebdavControllerTest extends KernelTestCase
    XML, - "Test requesting an unknow property" + 'Test requesting an unknow property', ]; yield [ @@ -274,7 +272,7 @@ class WebdavControllerTest extends KernelTestCase
    XML, - "test getting the last modified date" + 'test getting the last modified date', ]; yield [ @@ -311,7 +309,7 @@ class WebdavControllerTest extends KernelTestCase
    XML, - "test finding all properties" + 'test finding all properties', ]; } @@ -356,7 +354,7 @@ class WebdavControllerTest extends KernelTestCase
    XML, - "test resourceType and IsReadOnly " + 'test resourceType and IsReadOnly ', ]; yield [ @@ -379,15 +377,14 @@ class WebdavControllerTest extends KernelTestCase
    XML, - "test creatableContentsInfo" + 'test creatableContentsInfo', ]; } - } class MockedStoredObjectManager implements StoredObjectManagerInterface { - public function getLastModified(StoredObject $document): DateTimeInterface + public function getLastModified(StoredObject $document): \DateTimeInterface { return new \DateTimeImmutable('2023-09-13T14:15'); } @@ -402,11 +399,12 @@ class MockedStoredObjectManager implements StoredObjectManagerInterface return 'abcde'; } - public function write(StoredObject $document, string $clearContent): void {} + 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 index 3cc2f19fe..babe9932a 100644 --- a/src/Bundle/ChillDocStoreBundle/Tests/Dav/Request/PropfindRequestAnalyzerTest.php +++ b/src/Bundle/ChillDocStoreBundle/Tests/Dav/Request/PropfindRequestAnalyzerTest.php @@ -12,11 +12,11 @@ declare(strict_types=1); namespace Chill\DocStoreBundle\Tests\Dav\Request; use Chill\DocStoreBundle\Dav\Request\PropfindRequestAnalyzer; -use phpseclib3\Crypt\DSA\Formats\Keys\XML; use PHPUnit\Framework\TestCase; /** * @internal + * * @coversNothing */ class PropfindRequestAnalyzerTest extends TestCase @@ -33,7 +33,7 @@ class PropfindRequestAnalyzerTest extends TestCase $actual = $analyzer->getRequestedProperties($request); foreach ($expected as $key => $value) { - if ($key === 'unknowns') { + if ('unknowns' === $key) { continue; } @@ -59,17 +59,17 @@ class PropfindRequestAnalyzerTest extends TestCase XML, [ - "resourceType" => false, - "contentType" => false, - "lastModified" => false, - "creationDate" => false, - "contentLength" => false, - "etag" => false, - "supportedLock" => false, + 'resourceType' => false, + 'contentType' => false, + 'lastModified' => false, + 'creationDate' => false, + 'contentLength' => false, + 'etag' => false, + 'supportedLock' => false, 'unknowns' => [ - ['xmlns' => 'http://ucb.openoffice.org/dav/props/', 'prop' => 'BaseURI'] - ] - ] + ['xmlns' => 'http://ucb.openoffice.org/dav/props/', 'prop' => 'BaseURI'], + ], + ], ]; yield [ @@ -80,15 +80,15 @@ class PropfindRequestAnalyzerTest extends TestCase XML, [ - "resourceType" => true, - "contentType" => true, - "lastModified" => true, - "creationDate" => true, - "contentLength" => true, - "etag" => true, - "supportedLock" => true, - "unknowns" => [], - ] + 'resourceType' => true, + 'contentType' => true, + 'lastModified' => true, + 'creationDate' => true, + 'contentLength' => true, + 'etag' => true, + 'supportedLock' => true, + 'unknowns' => [], + ], ]; yield [ @@ -101,15 +101,15 @@ class PropfindRequestAnalyzerTest extends TestCase XML, [ - "resourceType" => false, - "contentType" => false, - "lastModified" => true, - "creationDate" => false, - "contentLength" => false, - "etag" => false, - "supportedLock" => false, - 'unknowns' => [] - ] + 'resourceType' => false, + 'contentType' => false, + 'lastModified' => true, + 'creationDate' => false, + 'contentLength' => false, + 'etag' => false, + 'supportedLock' => false, + 'unknowns' => [], + ], ]; yield [ @@ -118,17 +118,17 @@ class PropfindRequestAnalyzerTest extends TestCase XML, [ - "resourceType" => true, - "contentType" => true, - "lastModified" => false, - "creationDate" => false, - "contentLength" => false, - "etag" => false, - "supportedLock" => false, + 'resourceType' => true, + 'contentType' => true, + 'lastModified' => false, + 'creationDate' => false, + 'contentLength' => false, + 'etag' => false, + 'supportedLock' => false, 'unknowns' => [ - ['xmlns' => 'http://ucb.openoffice.org/dav/props/', 'prop' => 'IsReadOnly'] - ] - ] + ['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 index c4518586f..477427078 100644 --- a/src/Bundle/ChillDocStoreBundle/Tests/Security/Authorization/StoredObjectVoterTest.php +++ b/src/Bundle/ChillDocStoreBundle/Tests/Security/Authorization/StoredObjectVoterTest.php @@ -22,6 +22,7 @@ use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; /** * @internal + * * @coversNothing */ class StoredObjectVoterTest extends TestCase @@ -31,7 +32,7 @@ class StoredObjectVoterTest extends TestCase /** * @dataProvider provideDataVote */ - public function testVote(TokenInterface $token, object|null $subject, string $attribute, mixed $expected): void + public function testVote(TokenInterface $token, null|object $subject, string $attribute, mixed $expected): void { $voter = new StoredObjectVoter(); @@ -44,28 +45,28 @@ class StoredObjectVoterTest extends TestCase $this->buildToken(StoredObjectRoleEnum::EDIT, new StoredObject()), new \stdClass(), 'SOMETHING', - VoterInterface::ACCESS_ABSTAIN + VoterInterface::ACCESS_ABSTAIN, ]; yield [ $this->buildToken(StoredObjectRoleEnum::EDIT, $so = new StoredObject()), $so, 'SOMETHING', - VoterInterface::ACCESS_ABSTAIN + VoterInterface::ACCESS_ABSTAIN, ]; yield [ $this->buildToken(StoredObjectRoleEnum::EDIT, $so = new StoredObject()), $so, StoredObjectRoleEnum::SEE->value, - VoterInterface::ACCESS_GRANTED + VoterInterface::ACCESS_GRANTED, ]; yield [ $this->buildToken(StoredObjectRoleEnum::EDIT, $so = new StoredObject()), $so, StoredObjectRoleEnum::EDIT->value, - VoterInterface::ACCESS_GRANTED + VoterInterface::ACCESS_GRANTED, ]; yield [ @@ -79,7 +80,7 @@ class StoredObjectVoterTest extends TestCase $this->buildToken(StoredObjectRoleEnum::SEE, $so = new StoredObject()), $so, StoredObjectRoleEnum::SEE->value, - VoterInterface::ACCESS_GRANTED + VoterInterface::ACCESS_GRANTED, ]; yield [ @@ -97,8 +98,7 @@ class StoredObjectVoterTest extends TestCase ]; } - - private function buildToken(?StoredObjectRoleEnum $storedObjectRoleEnum = null, ?StoredObject $storedObject = null): TokenInterface + private function buildToken(StoredObjectRoleEnum $storedObjectRoleEnum = null, StoredObject $storedObject = null): TokenInterface { $token = $this->prophesize(TokenInterface::class); @@ -110,7 +110,6 @@ class StoredObjectVoterTest extends TestCase $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()); diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Security/Guard/DavOnUrlTokenExtractorTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Security/Guard/DavOnUrlTokenExtractorTest.php index b9e046b28..e1e0a3b36 100644 --- a/src/Bundle/ChillDocStoreBundle/Tests/Security/Guard/DavOnUrlTokenExtractorTest.php +++ b/src/Bundle/ChillDocStoreBundle/Tests/Security/Guard/DavOnUrlTokenExtractorTest.php @@ -19,6 +19,7 @@ use Symfony\Component\HttpFoundation\Request; /** * @internal + * * @coversNothing */ class DavOnUrlTokenExtractorTest extends TestCase @@ -28,7 +29,7 @@ class DavOnUrlTokenExtractorTest extends TestCase /** * @dataProvider provideDataUri */ - public function testExtract(string $uri, string|false $expected): void + public function testExtract(string $uri, false|string $expected): void { $request = $this->prophesize(Request::class); $request->getRequestUri()->willReturn($uri);