Merge remote-tracking branch 'origin/145-permettre-de-visualiser-les-documents-dans-libreoffice-en-utilisant-webdav' into testing-2024-03

This commit is contained in:
Julien Fastré 2024-03-26 21:34:15 +01:00
commit 88bac5b5d8
Signed by: julienfastre
GPG Key ID: BDE2190974723FCB
31 changed files with 1854 additions and 7 deletions

View File

@ -9,6 +9,7 @@
],
"require": {
"php": "^8.2",
"ext-dom": "*",
"ext-json": "*",
"ext-openssl": "*",
"ext-redis": "*",

View File

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\Security\Guard;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Guard\DavTokenAuthenticationEventSubscriber;
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTAuthenticatedEvent;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Authentication\Token\AbstractToken;
/**
* @internal
*
* @coversNothing
*/
class DavTokenAuthenticationEventSubscriberTest extends TestCase
{
public function testOnJWTAuthenticatedWithDavDataInPayload(): void
{
$eventSubscriber = new DavTokenAuthenticationEventSubscriber();
$token = new class () extends AbstractToken {
public function getCredentials()
{
return null;
}
};
$event = new JWTAuthenticatedEvent([
'dav' => 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));
}
}

View File

@ -0,0 +1,252 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Controller;
use Chill\DocStoreBundle\Dav\Request\PropfindRequestAnalyzer;
use Chill\DocStoreBundle\Dav\Response\DavResponse;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
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;
/**
* 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;
public function __construct(
private \Twig\Environment $engine,
private StoredObjectManagerInterface $storedObjectManager,
private Security $security,
) {
$this->requestAnalyzer = new PropfindRequestAnalyzer();
}
/**
* @Route("/dav/{access_token}/get/{uuid}/", methods={"GET", "HEAD"}, name="chill_docstore_dav_directory_get")
*/
public function getDirectory(StoredObject $storedObject, string $access_token): Response
{
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
throw new AccessDeniedHttpException();
}
return new DavResponse(
$this->engine->render('@ChillDocStore/Webdav/directory.html.twig', [
'stored_object' => $storedObject,
'access_token' => $access_token,
])
);
}
/**
* @Route("/dav/{access_token}/get/{uuid}/", methods={"OPTIONS"})
*/
public function optionsDirectory(StoredObject $storedObject): Response
{
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
throw new AccessDeniedHttpException();
}
$response = (new DavResponse(''))
->setEtag($this->storedObjectManager->etag($storedObject))
;
// $response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT,PROPPATCH,COPY,MOVE,REPORT,PATCH,POST,TRACE']);
$response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT']);
return $response;
}
/**
* @Route("/dav/{access_token}/get/{uuid}/", methods={"PROPFIND"})
*/
public function propfindDirectory(StoredObject $storedObject, string $access_token, Request $request): Response
{
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
throw new AccessDeniedHttpException();
}
$depth = $request->headers->get('depth');
if ('0' !== $depth && '1' !== $depth) {
throw new BadRequestHttpException('only 1 and 0 are accepted for Depth header');
}
[$properties, $lastModified, $etag, $length] = $this->parseDavRequest($request->getContent(), $storedObject);
$response = new DavResponse(
$this->engine->render('@ChillDocStore/Webdav/directory_propfind.xml.twig', [
'stored_object' => $storedObject,
'properties' => $properties,
'last_modified' => $lastModified,
'etag' => $etag,
'content_length' => $length,
'depth' => (int) $depth,
'access_token' => $access_token,
]),
207
);
$response->headers->add([
'Content-Type' => 'text/xml',
]);
return $response;
}
/**
* @Route("/dav/{access_token}/get/{uuid}/d", name="chill_docstore_dav_document_get", methods={"GET"})
*/
public function getDocument(StoredObject $storedObject): Response
{
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
throw new AccessDeniedHttpException();
}
return (new DavResponse($this->storedObjectManager->read($storedObject)))
->setEtag($this->storedObjectManager->etag($storedObject));
}
/**
* @Route("/dav/{access_token}/get/{uuid}/d", methods={"HEAD"})
*/
public function headDocument(StoredObject $storedObject): Response
{
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
throw new AccessDeniedHttpException();
}
$response = new DavResponse('');
$response->headers->add(
[
'Content-Length' => $this->storedObjectManager->getContentLength($storedObject),
'Content-Type' => $storedObject->getType(),
'Etag' => $this->storedObjectManager->etag($storedObject),
]
);
return $response;
}
/**
* @Route("/dav/{access_token}/get/{uuid}/d", methods={"OPTIONS"})
*/
public function optionsDocument(StoredObject $storedObject): Response
{
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
throw new AccessDeniedHttpException();
}
$response = (new DavResponse(''))
->setEtag($this->storedObjectManager->etag($storedObject))
;
$response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT']);
return $response;
}
/**
* @Route("/dav/{access_token}/get/{uuid}/d", methods={"PROPFIND"})
*/
public function propfindDocument(StoredObject $storedObject, string $access_token, Request $request): Response
{
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
throw new AccessDeniedHttpException();
}
[$properties, $lastModified, $etag, $length] = $this->parseDavRequest($request->getContent(), $storedObject);
$response = new DavResponse(
$this->engine->render(
'@ChillDocStore/Webdav/doc_props.xml.twig',
[
'stored_object' => $storedObject,
'properties' => $properties,
'etag' => $etag,
'last_modified' => $lastModified,
'content_length' => $length,
'access_token' => $access_token,
]
),
207
);
$response
->headers->add([
'Content-Type' => 'text/xml',
]);
return $response;
}
/**
* @Route("/dav/{access_token}/get/{uuid}/d", methods={"PUT"})
*/
public function putDocument(StoredObject $storedObject, Request $request): Response
{
if (!$this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $storedObject)) {
throw new AccessDeniedHttpException();
}
$this->storedObjectManager->write($storedObject, $request->getContent());
return new DavResponse('', Response::HTTP_NO_CONTENT);
}
/**
* @return array{0: array, 1: \DateTimeInterface, 2: string, 3: int} properties, lastModified, etag, length
*/
private function parseDavRequest(string $content, StoredObject $storedObject): array
{
$xml = new \DOMDocument();
$xml->loadXML($content);
$properties = $this->requestAnalyzer->getRequestedProperties($xml);
$requested = array_keys(array_filter($properties, fn ($item) => true === $item));
if (
in_array('lastModified', $requested, true)
|| in_array('etag', $requested, true)
) {
$lastModified = $this->storedObjectManager->getLastModified($storedObject);
$etag = $this->storedObjectManager->etag($storedObject);
}
if (in_array('contentLength', $requested, true)) {
$length = $this->storedObjectManager->getContentLength($storedObject);
}
return [
$properties,
$lastModified ?? null,
$etag ?? null,
$length ?? null,
];
}
}

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Dav\Exception;
class ParseRequestException extends \UnexpectedValueException
{
}

View File

@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Dav\Request;
use Chill\DocStoreBundle\Dav\Exception\ParseRequestException;
/**
* @phpstan-type davProperties array{resourceType: bool, contentType: bool, lastModified: bool, creationDate: bool, contentLength: bool, etag: bool, supportedLock: bool, unknowns: list<array{xmlns: string, prop: string}>}
*/
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' => [],
];
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Dav\Response;
use Symfony\Component\HttpFoundation\Response;
class DavResponse extends Response
{
public function __construct($content = '', int $status = 200, array $headers = [])
{
parent::__construct($content, $status, $headers);
$this->headers->add(['DAV' => '1']);
}
}

View File

@ -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: '<document-action-buttons-group :can-edit="canEdit" :filename="filename" :stored-object="storedObject" :small="small" @on-stored-object-status-change="onStoredObjectStatusChange"></document-action-buttons-group>',
template: '<document-action-buttons-group :can-edit="canEdit" :filename="filename" :stored-object="storedObject" :small="small" :dav-link="davLink" :dav-link-expiration="davLinkExpiration" @on-stored-object-status-change="onStoredObjectStatusChange"></document-action-buttons-group>',
methods: {
onStoredObjectStatusChange: function(newStatus: StoredObjectStatusChange): void {
this.$data.storedObject.status = newStatus.status;

View File

@ -7,6 +7,9 @@
<li v-if="props.canEdit && is_extension_editable(props.storedObject.type)">
<wopi-edit-button :stored-object="props.storedObject" :classes="{'dropdown-item': true}" :execute-before-leave="props.executeBeforeLeave"></wopi-edit-button>
</li>
<li v-if="props.canEdit && is_extension_editable(props.storedObject.type) && props.davLink !== undefined && props.davLinkExpiration !== undefined">
<desktop-edit-button :classes="{'dropdown-item': true}" :edit-link="props.davLink" :expiration-link="props.davLinkExpiration"></desktop-edit-button>
</li>
<li v-if="props.storedObject.type != 'application/pdf' && is_extension_viewable(props.storedObject.type) && props.canConvertPdf">
<convert-button :stored-object="props.storedObject" :filename="filename" :classes="{'dropdown-item': true}"></convert-button>
</li>
@ -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<DocumentActionButtonsGroupConfig>(), {
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
});
/**

View File

@ -0,0 +1,66 @@
<script setup lang="ts">
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import {computed, reactive} from "vue";
export interface DesktopEditButtonConfig {
editLink: null,
classes: { [k: string]: boolean },
expirationLink: number|Date,
}
interface DesktopEditButtonState {
modalOpened: boolean
};
const state: DesktopEditButtonState = reactive({modalOpened: false});
const props = defineProps<DesktopEditButtonConfig>();
const buildCommand = computed<string>(() => 'vnd.libreoffice.command:ofe|u|' + props.editLink);
const editionUntilFormatted = computed<string>(() => {
let d;
if (props.expirationLink instanceof Date) {
d = props.expirationLink;
} else {
d = new Date(props.expirationLink * 1000);
}
console.log(props.expirationLink);
return (new Intl.DateTimeFormat(undefined, {'dateStyle': 'long', 'timeStyle': 'medium'})).format(d);
});
</script>
<template>
<teleport to="body">
<modal v-if="state.modalOpened" @close="state.modalOpened=false">
<template v-slot:body>
<div class="desktop-edit">
<p class="center">Veuillez enregistrer vos modifications avant le</p>
<p><strong>{{ editionUntilFormatted }}</strong></p>
<p><a class="btn btn-primary" :href="buildCommand">Ouvrir le document pour édition</a></p>
<p><small>Le document peut être édité uniquement en utilisant Libre Office.</small></p>
<p><small>En cas d'échec lors de l'enregistrement, sauver le document sur le poste de travail avant de le déposer à nouveau ici.</small></p>
<p><small>Vous pouvez naviguez sur d'autres pages pendant l'édition.</small></p>
</div>
</template>
</modal>
</teleport>
<a :class="props.classes" @click="state.modalOpened = true">
<i class="fa fa-desktop"></i>
Éditer sur le bureau
</a>
</template>
<style scoped lang="scss">
.desktop-edit {
text-align: center;
}
</style>

View File

@ -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 %}></div>

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Directory for {{ stored_object.uuid }}</title>
</head>
<body>
<ul>
<li><a href="{{ absolute_url(path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token': access_token })) }}">d</a></li>
</ul>
</body>
</html>

View File

@ -0,0 +1,81 @@
<?xml version="1.0" encoding="UTF-8" ?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>{{ path('chill_docstore_dav_directory_get', { 'uuid': stored_object.uuid, 'access_token': access_token } ) }}</d:href>
{% if properties.resourceType or properties.contentType %}
<d:propstat>
<d:prop>
{% if properties.resourceType %}
<d:resourcetype><d:collection/></d:resourcetype>
{% endif %}
{% if properties.contentType %}
<d:getcontenttype>httpd/unix-directory</d:getcontenttype>
{% endif %}
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
{% endif %}
{% if properties.unknowns|length > 0 %}
<d:propstat>
{% for k,u in properties.unknowns %}
<d:prop {{ ('xmlns:ns' ~ k ~ '="' ~ u.xmlns|e('html_attr') ~ '"')|raw }}>
<{{ 'ns'~ k ~ ':' ~ u.prop }} />
</d:prop>
{% endfor %}
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
{% endif %}
</d:response>
{% if depth == 1 %}
<d:response>
<d:href>{{ path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token':access_token}) }}</d:href>
{% if properties.lastModified or properties.contentLength or properties.resourceType or properties.etag or properties.contentType or properties.creationDate %}
<d:propstat>
<d:prop>
{% if properties.resourceType %}
<d:resourcetype/>
{% endif %}
{% if properties.creationDate %}
<d:creationdate />
{% endif %}
{% if properties.lastModified %}
{% if last_modified is not same as null %}
<d:getlastmodified>{{ last_modified.format(constant('DATE_RSS')) }}</d:getlastmodified>
{% else %}
<d:getlastmodified />
{% endif %}
{% endif %}
{% if properties.contentLength %}
{% if content_length is not same as null %}
<d:getcontentlength>{{ content_length }}</d:getcontentlength>
{% else %}
<d:getcontentlength />
{% endif %}
{% endif %}
{% if properties.etag %}
{% if etag is not same as null %}
<d:getetag>"{{ etag }}"</d:getetag>
{% else %}
<d:getetag />
{% endif %}
{% endif %}
{% if properties.contentType %}
<d:getcontenttype>{{ stored_object.type }}</d:getcontenttype>
{% endif %}
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
{% endif %}
{% if properties.unknowns|length > 0 %}
<d:propstat>
{% for k,u in properties.unknowns %}
<d:prop {{ ('xmlns:ns' ~ k ~ '="' ~ u.xmlns|e('html_attr') ~ '"')|raw }}>
<{{ 'ns'~ k ~ ':' ~ u.prop }} />
</d:prop>
{% endfor %}
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
{% endif %}
</d:response>
{% endif %}
</d:multistatus>

View File

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8" ?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>{{ path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token': access_token}) }}</d:href>
{% if properties.lastModified or properties.contentLength or properties.resourceType or properties.etag or properties.contentType or properties.creationDate %}
<d:propstat>
<d:prop>
{% if properties.resourceType %}
<d:resourcetype/>
{% endif %}
{% if properties.creationDate %}
<d:creationdate />
{% endif %}
{% if properties.lastModified %}
{% if last_modified is not same as null %}
<d:getlastmodified>{{ last_modified.format(constant('DATE_RSS')) }}</d:getlastmodified>
{% else %}
<d:getlastmodified />
{% endif %}
{% endif %}
{% if properties.contentLength %}
{% if content_length is not same as null %}
<d:getcontentlength>{{ content_length }}</d:getcontentlength>
{% else %}
<d:getcontentlength />
{% endif %}
{% endif %}
{% if properties.etag %}
{% if etag is not same as null %}
<d:getetag>"{{ etag }}"</d:getetag>
{% else %}
<d:getetag />
{% endif %}
{% endif %}
{% if properties.contentType %}
<d:getcontenttype>{{ stored_object.type }}</d:getcontenttype>
{% endif %}
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
{% endif %}
{% if properties.unknowns|length > 0 %}
<d:propstat>
{% for k,u in properties.unknowns %}
<d:prop {{ ('xmlns:ns' ~ k ~ '="' ~ u.xmlns|e('html_attr') ~ '"')|raw }}>
<{{ 'ns'~ k ~ ':' ~ u.prop }} />
</d:prop>
{% endfor %}
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
{% endif %}
</d:response>
</d:multistatus>

View File

@ -0,0 +1,7 @@
{% extends '@ChillMain/layout.html.twig' %}
{% block content %}
<p>document uuid: {{ stored_object.uuid }}</p>
<p>{{ absolute_url(path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token': access_token })) }}</p>
<a href="vnd.libreoffice.command:ofe|u|{{ absolute_url(path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token': access_token })) }}">Open document</a>
{% endblock %}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Security\Authorization;
/**
* Role to edit or see the stored object content.
*/
enum StoredObjectRoleEnum: string
{
case SEE = 'SEE';
case EDIT = 'SEE_AND_EDIT';
}

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Security\Authorization;
use Chill\DocStoreBundle\Entity\StoredObject;
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
{
return StoredObjectRoleEnum::tryFrom($attribute) instanceof StoredObjectRoleEnum
&& $subject instanceof StoredObject;
}
protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
{
/** @var StoredObject $subject */
if (
!$token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)
|| $subject->getUuid()->toString() !== $token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)
) {
return false;
}
if (!$token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)) {
return false;
}
$askedRole = StoredObjectRoleEnum::from($attribute);
$tokenRoleAuthorization =
$token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS);
return match ($askedRole) {
StoredObjectRoleEnum::SEE => StoredObjectRoleEnum::EDIT === $tokenRoleAuthorization || StoredObjectRoleEnum::SEE === $tokenRoleAuthorization,
StoredObjectRoleEnum::EDIT => StoredObjectRoleEnum::EDIT === $tokenRoleAuthorization
};
}
}

View File

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Security\Guard;
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(
private LoggerInterface $logger,
) {
}
public function extract(Request $request): false|string
{
$uri = $request->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];
}
}

View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Security\Guard;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
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';
final public const ACTIONS = 'stored_objects_actions';
public static function getSubscribedEvents(): array
{
return [
Events::JWT_AUTHENTICATED => ['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']);
}
}

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Security\Guard;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
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.
*/
final readonly class JWTDavTokenProvider implements JWTDavTokenProviderInterface
{
public function __construct(
private JWTTokenManagerInterface $JWTTokenManager,
private Security $security,
) {
}
public function createToken(StoredObject $storedObject, StoredObjectRoleEnum $roleEnum): string
{
return $this->JWTTokenManager->createFromPayload($this->security->getUser(), [
'dav' => 1,
'e' => match ($roleEnum) {
StoredObjectRoleEnum::SEE => 0,
StoredObjectRoleEnum::EDIT => 1,
},
'so' => $storedObject->getUuid(),
]);
}
public function getTokenExpiration(string $tokenString): \DateTimeImmutable
{
$jwt = $this->JWTTokenManager->parse($tokenString);
return \DateTimeImmutable::createFromFormat('U', (string) $jwt['exp']);
}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Security\Guard;
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.
*/
interface JWTDavTokenProviderInterface
{
public function createToken(StoredObject $storedObject, StoredObjectRoleEnum $roleEnum): string;
public function getTokenExpiration(string $tokenString): \DateTimeImmutable;
}

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Security\Guard;
use Lexik\Bundle\JWTAuthenticationBundle\Security\Guard\JWTTokenAuthenticator;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Lexik\Bundle\JWTAuthenticationBundle\TokenExtractor\TokenExtractorInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
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(
JWTTokenManagerInterface $jwtManager,
EventDispatcherInterface $dispatcher,
TokenExtractorInterface $tokenExtractor,
private readonly DavOnUrlTokenExtractor $davOnUrlTokenExtractor,
TokenStorageInterface $preAuthenticationTokenStorage,
TranslatorInterface $translator = null,
) {
parent::__construct($jwtManager, $dispatcher, $tokenExtractor, $preAuthenticationTokenStorage, $translator);
}
protected function getTokenExtractor()
{
return $this->davOnUrlTokenExtractor;
}
}

View File

@ -57,6 +57,62 @@ final class StoredObjectManager implements StoredObjectManagerInterface
return $this->extractLastModifiedFromResponse($response);
}
public function getContentLength(StoredObject $document): int
{
if ([] === $document->getKeyInfos()) {
if ($this->hasCache($document)) {
$response = $this->getResponseFromCache($document);
} else {
try {
$response = $this
->client
->request(
Request::METHOD_HEAD,
$this
->tempUrlGenerator
->generate(
Request::METHOD_HEAD,
$document->getFilename()
)
->url
);
} catch (TransportExceptionInterface $exception) {
throw StoredObjectManagerException::errorDuringHttpRequest($exception);
}
}
return $this->extractContentLengthFromResponse($response);
}
return strlen($this->read($document));
}
public function etag(StoredObject $document): string
{
if ($this->hasCache($document)) {
$response = $this->getResponseFromCache($document);
} else {
try {
$response = $this
->client
->request(
Request::METHOD_HEAD,
$this
->tempUrlGenerator
->generate(
Request::METHOD_HEAD,
$document->getFilename()
)
->url
);
} catch (TransportExceptionInterface $exception) {
throw StoredObjectManagerException::errorDuringHttpRequest($exception);
}
}
return $this->extractEtagFromResponse($response, $document);
}
public function read(StoredObject $document): string
{
$response = $this->getResponseFromCache($document);
@ -158,6 +214,22 @@ final class StoredObjectManager implements StoredObjectManagerInterface
return $date;
}
private function extractContentLengthFromResponse(ResponseInterface $response): int
{
return (int) ($response->getHeaders()['content-length'] ?? ['0'])[0];
}
private function extractEtagFromResponse(ResponseInterface $response, StoredObject $storedObject): ?string
{
$etag = ($response->getHeaders()['etag'] ?? [''])[0];
if ('' === $etag) {
return null;
}
return $etag;
}
private function fillCache(StoredObject $document): void
{
try {

View File

@ -18,6 +18,8 @@ interface StoredObjectManagerInterface
{
public function getLastModified(StoredObject $document): \DateTimeInterface;
public function getContentLength(StoredObject $document): int;
/**
* Get the content of a StoredObject.
*
@ -39,5 +41,7 @@ interface StoredObjectManagerInterface
*/
public function write(StoredObject $document, string $clearContent): void;
public function etag(StoredObject $document): string;
public function clearCache(): void;
}

View File

@ -13,6 +13,9 @@ namespace Chill\DocStoreBundle\Templating;
use ChampsLibres\WopiLib\Contract\Service\Discovery\DiscoveryInterface;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Twig\Environment;
@ -120,8 +123,12 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt
private const TEMPLATE_BUTTON_GROUP = '@ChillDocStore/Button/button_group.html.twig';
public function __construct(private DiscoveryInterface $discovery, private NormalizerInterface $normalizer)
{
public function __construct(
private DiscoveryInterface $discovery,
private NormalizerInterface $normalizer,
private JWTDavTokenProviderInterface $davTokenProvider,
private UrlGeneratorInterface $urlGenerator,
) {
}
/**
@ -132,7 +139,7 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt
*/
public function isEditable(StoredObject $document): bool
{
return \in_array($document->getType(), self::SUPPORTED_MIMES, true);
return in_array($document->getType(), self::SUPPORTED_MIMES, true);
}
/**
@ -144,12 +151,26 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt
*/
public function renderButtonGroup(Environment $environment, StoredObject $document, ?string $title = null, bool $canEdit = true, array $options = []): string
{
$accessToken = $this->davTokenProvider->createToken(
$document,
$canEdit ? StoredObjectRoleEnum::EDIT : StoredObjectRoleEnum::SEE
);
return $environment->render(self::TEMPLATE_BUTTON_GROUP, [
'document' => $document,
'document_json' => $this->normalizer->normalize($document, 'json', [AbstractNormalizer::GROUPS => ['read']]),
'title' => $title,
'can_edit' => $canEdit,
'options' => [...self::DEFAULT_OPTIONS_TEMPLATE_BUTTON_GROUP, ...$options],
'dav_link' => $this->urlGenerator->generate(
'chill_docstore_dav_document_get',
[
'uuid' => $document->getUuid(),
'access_token' => $accessToken,
],
UrlGeneratorInterface::ABSOLUTE_URL,
),
'dav_link_expiration' => $this->davTokenProvider->getTokenExpiration($accessToken)->format('U'),
]);
}

View File

@ -0,0 +1,410 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\Controller;
use Chill\DocStoreBundle\Controller\WebdavController;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
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;
/**
* @internal
*
* @coversNothing
*/
class WebdavControllerTest extends KernelTestCase
{
use ProphecyTrait;
private \Twig\Environment $engine;
protected function setUp(): void
{
self::bootKernel();
$this->engine = self::$container->get(\Twig\Environment::class);
}
private function buildController(): WebdavController
{
$storedObjectManager = new MockedStoredObjectManager();
$security = $this->prophesize(Security::class);
$security->isGranted(Argument::in(['EDIT', 'SEE']), Argument::type(StoredObject::class))
->willReturn(true);
return new WebdavController($this->engine, $storedObjectManager, $security->reveal());
}
private function buildDocument(): StoredObject
{
$object = (new StoredObject())
->setType('application/vnd.oasis.opendocument.text');
$reflectionObject = new \ReflectionClass($object);
$reflectionObjectUuid = $reflectionObject->getProperty('uuid');
$reflectionObjectUuid->setValue($object, Uuid::fromString('716e6688-4579-4938-acf3-c4ab5856803b'));
return $object;
}
public function testGet(): void
{
$controller = $this->buildController();
$response = $controller->getDocument($this->buildDocument());
self::assertEquals(200, $response->getStatusCode());
self::assertEquals('abcde', $response->getContent());
self::assertContains('etag', $response->headers->keys());
self::assertStringContainsString('ab56b4', $response->headers->get('etag'));
}
public function testOptionsOnDocument(): void
{
$controller = $this->buildController();
$response = $controller->optionsDocument($this->buildDocument());
self::assertEquals(200, $response->getStatusCode());
self::assertContains('allow', $response->headers->keys());
foreach (explode(',', 'OPTIONS,GET,HEAD,PROPFIND') as $method) {
self::assertStringContainsString($method, $response->headers->get('allow'));
}
self::assertContains('dav', $response->headers->keys());
self::assertStringContainsString('1', $response->headers->get('dav'));
}
public function testOptionsOnDirectory(): void
{
$controller = $this->buildController();
$response = $controller->optionsDirectory($this->buildDocument());
self::assertEquals(200, $response->getStatusCode());
self::assertContains('allow', $response->headers->keys());
foreach (explode(',', 'OPTIONS,GET,HEAD,PROPFIND') as $method) {
self::assertStringContainsString($method, $response->headers->get('allow'));
}
self::assertContains('dav', $response->headers->keys());
self::assertStringContainsString('1', $response->headers->get('dav'));
}
/**
* @dataProvider generateDataPropfindDocument
*/
public function testPropfindDocument(string $requestContent, int $expectedStatusCode, string $expectedXmlResponse, string $message): void
{
$controller = $this->buildController();
$request = new Request([], [], [], [], [], [], $requestContent);
$request->setMethod('PROPFIND');
$response = $controller->propfindDocument($this->buildDocument(), '1234', $request);
self::assertEquals($expectedStatusCode, $response->getStatusCode());
self::assertContains('content-type', $response->headers->keys());
self::assertStringContainsString('text/xml', $response->headers->get('content-type'));
self::assertTrue((new \DOMDocument())->loadXML($response->getContent()), $message.' test that the xml response is a valid xml');
self::assertXmlStringEqualsXmlString($expectedXmlResponse, $response->getContent(), $message);
}
/**
* @dataProvider generateDataPropfindDirectory
*/
public function testPropfindDirectory(string $requestContent, int $expectedStatusCode, string $expectedXmlResponse, string $message): void
{
$controller = $this->buildController();
$request = new Request([], [], [], [], [], [], $requestContent);
$request->setMethod('PROPFIND');
$request->headers->add(['Depth' => '0']);
$response = $controller->propfindDirectory($this->buildDocument(), '1234', $request);
self::assertEquals($expectedStatusCode, $response->getStatusCode());
self::assertContains('content-type', $response->headers->keys());
self::assertStringContainsString('text/xml', $response->headers->get('content-type'));
self::assertTrue((new \DOMDocument())->loadXML($response->getContent()), $message.' test that the xml response is a valid xml');
self::assertXmlStringEqualsXmlString($expectedXmlResponse, $response->getContent(), $message);
}
public function testHeadDocument(): void
{
$controller = $this->buildController();
$response = $controller->headDocument($this->buildDocument());
self::assertEquals(200, $response->getStatusCode());
self::assertContains('content-length', $response->headers->keys());
self::assertContains('content-type', $response->headers->keys());
self::assertContains('etag', $response->headers->keys());
self::assertEquals('ab56b4d92b40713acc5af89985d4b786', $response->headers->get('etag'));
self::assertEquals('application/vnd.oasis.opendocument.text', $response->headers->get('content-type'));
self::assertEquals(5, $response->headers->get('content-length'));
}
public static function generateDataPropfindDocument(): iterable
{
$content =
<<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<propfind xmlns="DAV:"><prop><resourcetype xmlns="DAV:"/><IsReadOnly xmlns="http://ucb.openoffice.org/dav/props/"/><getcontenttype xmlns="DAV:"/><supportedlock xmlns="DAV:"/></prop></propfind>
XML;
$response =
<<<'XML'
<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:" >
<d:response>
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href>
<d:propstat>
<d:prop>
<d:resourcetype/>
<d:getcontenttype>application/vnd.oasis.opendocument.text</d:getcontenttype>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
<d:propstat>
<d:prop xmlns:ns0="http://ucb.openoffice.org/dav/props/">
<ns0:IsReadOnly/>
</d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
</d:response>
</d:multistatus>
XML;
yield [$content, 207, $response, 'get IsReadOnly and contenttype from server'];
$content =
<<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<propfind xmlns="DAV:">
<prop>
<IsReadOnly xmlns="http://ucb.openoffice.org/dav/props/"/>
</prop>
</propfind>
XML;
$response =
<<<'XML'
<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href>
<d:propstat>
<d:prop xmlns:ns0="http://ucb.openoffice.org/dav/props/">
<ns0:IsReadOnly/>
</d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
</d:response>
</d:multistatus>
XML;
yield [$content, 207, $response, 'get property IsReadOnly'];
yield [
<<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<propfind xmlns="DAV:">
<prop>
<BaseURI xmlns="http://ucb.openoffice.org/dav/props/"/>
</prop>
</propfind>
XML,
207,
<<<'XML'
<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href>
<d:propstat>
<d:prop xmlns:ns0="http://ucb.openoffice.org/dav/props/">
<ns0:BaseURI/>
</d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
</d:response>
</d:multistatus>
XML,
'Test requesting an unknow property',
];
yield [
<<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<propfind xmlns="DAV:">
<prop>
<getlastmodified xmlns="DAV:"/>
</prop>
</propfind>
XML,
207,
<<<'XML'
<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href>
<d:propstat>
<d:prop>
<!-- the date scraped from a webserver is >Sun, 10 Sep 2023 14:10:23 GMT -->
<d:getlastmodified>Wed, 13 Sep 2023 14:15:00 +0200</d:getlastmodified>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>
XML,
'test getting the last modified date',
];
yield [
<<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<propfind xmlns="DAV:">
<propname/>
</propfind>
XML,
207,
<<<'XML'
<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href>
<d:propstat>
<d:prop>
<d:resourcetype/>
<d:creationdate/>
<d:getlastmodified>Wed, 13 Sep 2023 14:15:00 +0200</d:getlastmodified>
<!-- <d:getcontentlength/> -->
<d:getcontentlength>5</d:getcontentlength>
<!-- <d:getlastmodified/> -->
<d:getetag>"ab56b4d92b40713acc5af89985d4b786"</d:getetag>
<!--
<d:supportedlock/>
<d:lockdiscovery/>
-->
<!-- <d:getcontenttype/> -->
<d:getcontenttype>application/vnd.oasis.opendocument.text</d:getcontenttype>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>
XML,
'test finding all properties',
];
}
public static function generateDataPropfindDirectory(): iterable
{
yield [
<<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<propfind xmlns="DAV:"><prop><resourcetype xmlns="DAV:"/><IsReadOnly xmlns="http://ucb.openoffice.org/dav/props/"/><getcontenttype xmlns="DAV:"/><supportedlock xmlns="DAV:"/></prop></propfind>
XML,
207,
<<<'XML'
<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/</d:href>
<d:propstat>
<d:prop>
<d:resourcetype><d:collection/></d:resourcetype>
<d:getcontenttype>httpd/unix-directory</d:getcontenttype>
<!--
<d:supportedlock>
<d:lockentry>
<d:lockscope><d:exclusive/></d:lockscope>
<d:locktype><d:write/></d:locktype>
</d:lockentry>
<d:lockentry>
<d:lockscope><d:shared/></d:lockscope>
<d:locktype><d:write/></d:locktype>
</d:lockentry>
</d:supportedlock>
-->
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
<d:propstat>
<d:prop xmlns:ns0="http://ucb.openoffice.org/dav/props/">
<ns0:IsReadOnly/>
</d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
</d:response>
</d:multistatus>
XML,
'test resourceType and IsReadOnly ',
];
yield [
<<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<propfind xmlns="DAV:"><prop><CreatableContentsInfo xmlns="http://ucb.openoffice.org/dav/props/"/></prop></propfind>
XML,
207,
<<<'XML'
<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/</d:href>
<d:propstat>
<d:prop xmlns:ns0="http://ucb.openoffice.org/dav/props/" >
<ns0:CreatableContentsInfo/>
</d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
</d:response>
</d:multistatus>
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';
}
}

View File

@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\Dav\Request;
use Chill\DocStoreBundle\Dav\Request\PropfindRequestAnalyzer;
use PHPUnit\Framework\TestCase;
/**
* @internal
*
* @coversNothing
*/
class PropfindRequestAnalyzerTest extends TestCase
{
/**
* @dataProvider provideRequestedProperties
*/
public function testGetRequestedProperties(string $xml, array $expected): void
{
$analyzer = new PropfindRequestAnalyzer();
$request = new \DOMDocument();
$request->loadXML($xml);
$actual = $analyzer->getRequestedProperties($request);
foreach ($expected as $key => $value) {
if ('unknowns' === $key) {
continue;
}
self::assertArrayHasKey($key, $actual, "Check that key {$key} does exists in list of expected values");
self::assertEquals($value, $actual[$key], "Does the value match expected for key {$key}");
}
if (array_key_exists('unknowns', $expected)) {
self::assertEquals(count($expected['unknowns']), count($actual['unknowns']));
self::assertEqualsCanonicalizing($expected['unknowns'], $actual['unknowns']);
}
}
public function provideRequestedProperties(): iterable
{
yield [
<<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<propfind xmlns="DAV:">
<prop>
<BaseURI xmlns="http://ucb.openoffice.org/dav/props/"/>
</prop>
</propfind>
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 version="1.0" encoding="UTF-8"?>
<propfind xmlns="DAV:">
<propname/>
</propfind>
XML,
[
'resourceType' => true,
'contentType' => true,
'lastModified' => true,
'creationDate' => true,
'contentLength' => true,
'etag' => true,
'supportedLock' => true,
'unknowns' => [],
],
];
yield [
<<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<propfind xmlns="DAV:">
<prop>
<getlastmodified xmlns="DAV:"/>
</prop>
</propfind>
XML,
[
'resourceType' => false,
'contentType' => false,
'lastModified' => true,
'creationDate' => false,
'contentLength' => false,
'etag' => false,
'supportedLock' => false,
'unknowns' => [],
],
];
yield [
<<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<propfind xmlns="DAV:"><prop><resourcetype xmlns="DAV:"/><IsReadOnly xmlns="http://ucb.openoffice.org/dav/props/"/><getcontenttype xmlns="DAV:"/><supportedlock xmlns="DAV:"/></prop></propfind>
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'],
],
],
];
}
}

View File

@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\Security\Authorization;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter;
use Chill\DocStoreBundle\Security\Guard\DavTokenAuthenticationEventSubscriber;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
/**
* @internal
*
* @coversNothing
*/
class StoredObjectVoterTest extends TestCase
{
use ProphecyTrait;
/**
* @dataProvider provideDataVote
*/
public function testVote(TokenInterface $token, null|object $subject, string $attribute, mixed $expected): void
{
$voter = new StoredObjectVoter();
self::assertEquals($expected, $voter->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();
}
}

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\Security\Guard;
use Chill\DocStoreBundle\Security\Guard\DavOnUrlTokenExtractor;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\NullLogger;
use Symfony\Component\HttpFoundation\Request;
/**
* @internal
*
* @coversNothing
*/
class DavOnUrlTokenExtractorTest extends TestCase
{
use ProphecyTrait;
/**
* @dataProvider provideDataUri
*/
public function testExtract(string $uri, false|string $expected): void
{
$request = $this->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];
}
}

View File

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

View File

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

View File

@ -0,0 +1,6 @@
{
"dev": {
"host": "localhost:8001",
"uuid": "0bf3b8e7-b25b-4227-aae9-a3263af0766f"
}
}