mirror of
https://gitlab.com/Chill-Projet/chill-bundles.git
synced 2025-06-07 18:44:08 +00:00
Merge branch '145-permettre-de-visualiser-les-documents-dans-libreoffice-en-utilisant-webdav' into 'master'
Webdav access point to edit documents using LibreOffice Closes #145 See merge request Chill-Projet/chill-bundles!592
This commit is contained in:
commit
27b0ec0ae7
@ -9,6 +9,7 @@
|
|||||||
],
|
],
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
|
"ext-dom": "*",
|
||||||
"ext-json": "*",
|
"ext-json": "*",
|
||||||
"ext-openssl": "*",
|
"ext-openssl": "*",
|
||||||
"ext-redis": "*",
|
"ext-redis": "*",
|
||||||
@ -75,7 +76,7 @@
|
|||||||
"phpunit/phpunit": ">= 7.5",
|
"phpunit/phpunit": ">= 7.5",
|
||||||
"psalm/plugin-phpunit": "^0.18.4",
|
"psalm/plugin-phpunit": "^0.18.4",
|
||||||
"psalm/plugin-symfony": "^4.0.2",
|
"psalm/plugin-symfony": "^4.0.2",
|
||||||
"rector/rector": "^0.17.7",
|
"rector/rector": "^1.1.0",
|
||||||
"symfony/debug-bundle": "^5.1",
|
"symfony/debug-bundle": "^5.1",
|
||||||
"symfony/dotenv": "^4.4",
|
"symfony/dotenv": "^4.4",
|
||||||
"symfony/maker-bundle": "^1.20",
|
"symfony/maker-bundle": "^1.20",
|
||||||
|
6
phpstan-baseline-2024-05.neon
Normal file
6
phpstan-baseline-2024-05.neon
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
parameters:
|
||||||
|
ignoreErrors:
|
||||||
|
-
|
||||||
|
message: "#^Parameter \\#1 \\$records of method League\\\\Csv\\\\Writer\\:\\:insertAll\\(\\) expects iterable\\<array\\<float\\|int\\|string\\|Stringable\\|null\\>\\>, iterable\\<array\\<string, bool\\|int\\|string\\>\\> given\\.$#"
|
||||||
|
count: 1
|
||||||
|
path: src/Bundle/ChillMainBundle/Controller/UserExportController.php
|
@ -31,4 +31,5 @@ includes:
|
|||||||
- phpstan-baseline-level-3.neon
|
- phpstan-baseline-level-3.neon
|
||||||
- phpstan-baseline-level-4.neon
|
- phpstan-baseline-level-4.neon
|
||||||
- phpstan-baseline-level-5.neon
|
- phpstan-baseline-level-5.neon
|
||||||
|
- phpstan-baseline-2024-05.neon
|
||||||
|
|
||||||
|
@ -45,9 +45,6 @@ return static function (RectorConfig $rectorConfig): void {
|
|||||||
|
|
||||||
// skip some path...
|
// skip some path...
|
||||||
$rectorConfig->skip([
|
$rectorConfig->skip([
|
||||||
// we need to discuss this: are we going to have FALSE in tests instead of an error ?
|
|
||||||
\Rector\Php71\Rector\FuncCall\CountOnNullRector::class,
|
|
||||||
|
|
||||||
// we must adapt service definition
|
// we must adapt service definition
|
||||||
\Rector\Symfony\Symfony28\Rector\MethodCall\GetToConstructorInjectionRector::class,
|
\Rector\Symfony\Symfony28\Rector\MethodCall\GetToConstructorInjectionRector::class,
|
||||||
\Rector\Symfony\Symfony34\Rector\Closure\ContainerGetNameToTypeInTestsRector::class,
|
\Rector\Symfony\Symfony34\Rector\Closure\ContainerGetNameToTypeInTestsRector::class,
|
||||||
|
@ -15,11 +15,10 @@ use Chill\ActivityBundle\Entity\Activity;
|
|||||||
use Chill\ActivityBundle\Entity\ActivityPresence;
|
use Chill\ActivityBundle\Entity\ActivityPresence;
|
||||||
use Chill\ActivityBundle\Form\Type\PickActivityReasonType;
|
use Chill\ActivityBundle\Form\Type\PickActivityReasonType;
|
||||||
use Chill\ActivityBundle\Security\Authorization\ActivityVoter;
|
use Chill\ActivityBundle\Security\Authorization\ActivityVoter;
|
||||||
use Chill\DocStoreBundle\Form\StoredObjectType;
|
use Chill\DocStoreBundle\Form\CollectionStoredObjectType;
|
||||||
use Chill\MainBundle\Entity\Center;
|
use Chill\MainBundle\Entity\Center;
|
||||||
use Chill\MainBundle\Entity\Location;
|
use Chill\MainBundle\Entity\Location;
|
||||||
use Chill\MainBundle\Entity\User;
|
use Chill\MainBundle\Entity\User;
|
||||||
use Chill\MainBundle\Form\Type\ChillCollectionType;
|
|
||||||
use Chill\MainBundle\Form\Type\ChillDateType;
|
use Chill\MainBundle\Form\Type\ChillDateType;
|
||||||
use Chill\MainBundle\Form\Type\CommentType;
|
use Chill\MainBundle\Form\Type\CommentType;
|
||||||
use Chill\MainBundle\Form\Type\PickUserDynamicType;
|
use Chill\MainBundle\Form\Type\PickUserDynamicType;
|
||||||
@ -276,16 +275,9 @@ class ActivityType extends AbstractType
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($activityType->isVisible('documents')) {
|
if ($activityType->isVisible('documents')) {
|
||||||
$builder->add('documents', ChillCollectionType::class, [
|
$builder->add('documents', CollectionStoredObjectType::class, [
|
||||||
'entry_type' => StoredObjectType::class,
|
|
||||||
'label' => $activityType->getLabel('documents'),
|
'label' => $activityType->getLabel('documents'),
|
||||||
'required' => $activityType->isRequired('documents'),
|
'required' => $activityType->isRequired('documents'),
|
||||||
'allow_add' => true,
|
|
||||||
'allow_delete' => true,
|
|
||||||
'button_add_label' => 'activity.Insert a document',
|
|
||||||
'button_remove_label' => 'activity.Remove a document',
|
|
||||||
'empty_collection_explain' => 'No documents',
|
|
||||||
'entry_options' => ['has_title' => true],
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -92,7 +92,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{%- if edit_form.documents is defined -%}
|
{%- if edit_form.documents is defined -%}
|
||||||
{{ form_row(edit_form.documents) }}
|
{{ form_label(edit_form.documents) }}
|
||||||
|
{{ form_errors(edit_form.documents) }}
|
||||||
|
{{ form_widget(edit_form.documents) }}
|
||||||
<div data-docgen-template-picker="data-docgen-template-picker" data-entity-class="Chill\ActivityBundle\Entity\Activity" data-entity-id="{{ entity.id }}"></div>
|
<div data-docgen-template-picker="data-docgen-template-picker" data-entity-class="Chill\ActivityBundle\Entity\Activity" data-entity-id="{{ entity.id }}"></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@ -127,4 +129,4 @@
|
|||||||
|
|
||||||
{% block css %}
|
{% block css %}
|
||||||
{{ encore_entry_link_tags('mod_pickentity_type') }}
|
{{ encore_entry_link_tags('mod_pickentity_type') }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
@ -49,20 +49,17 @@ interface CustomFieldInterface
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Return if the value can be considered as empty.
|
* Return if the value can be considered as empty.
|
||||||
*
|
|
||||||
* @param mixed $value the value passed throug the deserialize function
|
|
||||||
*/
|
*/
|
||||||
public function isEmptyValue($value, CustomField $customField);
|
public function isEmptyValue(mixed $value, CustomField $customField);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return a repsentation of the value of the CustomField.
|
* Return a repsentation of the value of the CustomField.
|
||||||
*
|
*
|
||||||
* @param mixed $value the raw value, **not deserialized** (= as stored in the db)
|
|
||||||
* @param \Chill\CustomFieldsBundle\CustomField\CustomField $customField
|
* @param \Chill\CustomFieldsBundle\CustomField\CustomField $customField
|
||||||
*
|
*
|
||||||
* @return string an html representation of the value
|
* @return string an html representation of the value
|
||||||
*/
|
*/
|
||||||
public function render($value, CustomField $customField, $documentType = 'html');
|
public function render(mixed $value, CustomField $customField, $documentType = 'html');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transform the value into a format that can be stored in DB.
|
* Transform the value into a format that can be stored in DB.
|
||||||
|
@ -399,8 +399,6 @@ final class CustomFieldsChoiceTest extends KernelTestCase
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @dataProvider emptyDataProvider
|
* @dataProvider emptyDataProvider
|
||||||
*
|
|
||||||
* @param mixed $data deserialized data
|
|
||||||
*/
|
*/
|
||||||
public function testIsEmptyValueEmpty(mixed $data)
|
public function testIsEmptyValueEmpty(mixed $data)
|
||||||
{
|
{
|
||||||
|
252
src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php
Normal file
252
src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
|
{
|
||||||
|
}
|
@ -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' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
24
src/Bundle/ChillDocStoreBundle/Dav/Response/DavResponse.php
Normal file
24
src/Bundle/ChillDocStoreBundle/Dav/Response/DavResponse.php
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
@ -48,14 +48,14 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
|
|||||||
/**
|
/**
|
||||||
* @ORM\Column(type="json", name="datas")
|
* @ORM\Column(type="json", name="datas")
|
||||||
*
|
*
|
||||||
* @Serializer\Groups({"read", "write"})
|
* @Serializer\Groups({"write"})
|
||||||
*/
|
*/
|
||||||
private array $datas = [];
|
private array $datas = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @ORM\Column(type="text")
|
* @ORM\Column(type="text")
|
||||||
*
|
*
|
||||||
* @Serializer\Groups({"read", "write"})
|
* @Serializer\Groups({"write"})
|
||||||
*/
|
*/
|
||||||
private string $filename = '';
|
private string $filename = '';
|
||||||
|
|
||||||
@ -66,7 +66,7 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
|
|||||||
*
|
*
|
||||||
* @ORM\Column(type="integer")
|
* @ORM\Column(type="integer")
|
||||||
*
|
*
|
||||||
* @Serializer\Groups({"read", "write"})
|
* @Serializer\Groups({"write"})
|
||||||
*/
|
*/
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
@ -75,35 +75,35 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
|
|||||||
*
|
*
|
||||||
* @ORM\Column(type="json", name="iv")
|
* @ORM\Column(type="json", name="iv")
|
||||||
*
|
*
|
||||||
* @Serializer\Groups({"read", "write"})
|
* @Serializer\Groups({"write"})
|
||||||
*/
|
*/
|
||||||
private array $iv = [];
|
private array $iv = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @ORM\Column(type="json", name="key")
|
* @ORM\Column(type="json", name="key")
|
||||||
*
|
*
|
||||||
* @Serializer\Groups({"read", "write"})
|
* @Serializer\Groups({"write"})
|
||||||
*/
|
*/
|
||||||
private array $keyInfos = [];
|
private array $keyInfos = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @ORM\Column(type="text", name="title")
|
* @ORM\Column(type="text", name="title")
|
||||||
*
|
*
|
||||||
* @Serializer\Groups({"read", "write"})
|
* @Serializer\Groups({"write"})
|
||||||
*/
|
*/
|
||||||
private string $title = '';
|
private string $title = '';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @ORM\Column(type="text", name="type", options={"default": ""})
|
* @ORM\Column(type="text", name="type", options={"default": ""})
|
||||||
*
|
*
|
||||||
* @Serializer\Groups({"read", "write"})
|
* @Serializer\Groups({"write"})
|
||||||
*/
|
*/
|
||||||
private string $type = '';
|
private string $type = '';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @ORM\Column(type="uuid", unique=true)
|
* @ORM\Column(type="uuid", unique=true)
|
||||||
*
|
*
|
||||||
* @Serializer\Groups({"read", "write"})
|
* @Serializer\Groups({"write"})
|
||||||
*/
|
*/
|
||||||
private UuidInterface $uuid;
|
private UuidInterface $uuid;
|
||||||
|
|
||||||
@ -137,8 +137,6 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
|
|||||||
*/
|
*/
|
||||||
public function __construct(/**
|
public function __construct(/**
|
||||||
* @ORM\Column(type="text", options={"default": "ready"})
|
* @ORM\Column(type="text", options={"default": "ready"})
|
||||||
*
|
|
||||||
* @Serializer\Groups({"read"})
|
|
||||||
*/
|
*/
|
||||||
private string $status = 'ready'
|
private string $status = 'ready'
|
||||||
) {
|
) {
|
||||||
|
@ -14,47 +14,21 @@ namespace Chill\DocStoreBundle\Form;
|
|||||||
use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument;
|
use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument;
|
||||||
use Chill\DocStoreBundle\Entity\Document;
|
use Chill\DocStoreBundle\Entity\Document;
|
||||||
use Chill\DocStoreBundle\Entity\DocumentCategory;
|
use Chill\DocStoreBundle\Entity\DocumentCategory;
|
||||||
use Chill\MainBundle\Entity\User;
|
|
||||||
use Chill\MainBundle\Form\Type\ChillDateType;
|
use Chill\MainBundle\Form\Type\ChillDateType;
|
||||||
use Chill\MainBundle\Form\Type\ChillTextareaType;
|
use Chill\MainBundle\Form\Type\ChillTextareaType;
|
||||||
use Chill\MainBundle\Security\Authorization\AuthorizationHelper;
|
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
|
||||||
use Chill\MainBundle\Templating\TranslatableStringHelper;
|
|
||||||
use Doctrine\ORM\EntityRepository;
|
use Doctrine\ORM\EntityRepository;
|
||||||
use Doctrine\Persistence\ObjectManager;
|
|
||||||
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
||||||
use Symfony\Component\Form\AbstractType;
|
use Symfony\Component\Form\AbstractType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
use Symfony\Component\Form\FormBuilderInterface;
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
class AccompanyingCourseDocumentType extends AbstractType
|
final class AccompanyingCourseDocumentType extends AbstractType
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* @var AuthorizationHelper
|
|
||||||
*/
|
|
||||||
protected $authorizationHelper;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var ObjectManager
|
|
||||||
*/
|
|
||||||
protected $om;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var TranslatableStringHelper
|
|
||||||
*/
|
|
||||||
protected $translatableStringHelper;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* the user running this form.
|
|
||||||
*
|
|
||||||
* @var User
|
|
||||||
*/
|
|
||||||
protected $user;
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
TranslatableStringHelper $translatableStringHelper
|
private readonly TranslatableStringHelperInterface $translatableStringHelper
|
||||||
) {
|
) {
|
||||||
$this->translatableStringHelper = $translatableStringHelper;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function buildForm(FormBuilderInterface $builder, array $options)
|
public function buildForm(FormBuilderInterface $builder, array $options)
|
||||||
|
@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Form;
|
||||||
|
|
||||||
|
use Chill\MainBundle\Form\Type\ChillCollectionType;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
|
class CollectionStoredObjectType extends AbstractType
|
||||||
|
{
|
||||||
|
public function configureOptions(OptionsResolver $resolver)
|
||||||
|
{
|
||||||
|
$resolver
|
||||||
|
->setDefault('entry_type', StoredObjectType::class)
|
||||||
|
->setDefault('allow_add', true)
|
||||||
|
->setDefault('allow_delete', true)
|
||||||
|
->setDefault('button_add_label', 'stored_object.Insert a document')
|
||||||
|
->setDefault('button_remove_label', 'stored_object.Remove a document')
|
||||||
|
->setDefault('empty_collection_explain', 'No documents')
|
||||||
|
->setDefault('entry_options', ['has_title' => true])
|
||||||
|
->setDefault('js_caller', 'data-collection-stored-object');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getParent()
|
||||||
|
{
|
||||||
|
return ChillCollectionType::class;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,75 @@
|
|||||||
|
<?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\Form\DataMapper;
|
||||||
|
|
||||||
|
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||||
|
use Symfony\Component\Form\DataMapperInterface;
|
||||||
|
use Symfony\Component\Form\Exception;
|
||||||
|
use Symfony\Component\Form\FormInterface;
|
||||||
|
|
||||||
|
class StoredObjectDataMapper implements DataMapperInterface
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param FormInterface[]|\Traversable $forms A list of {@link FormInterface} instances
|
||||||
|
*/
|
||||||
|
public function mapDataToForms($viewData, $forms)
|
||||||
|
{
|
||||||
|
if (null === $viewData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$viewData instanceof StoredObject) {
|
||||||
|
throw new Exception\UnexpectedTypeException($viewData, StoredObject::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
$forms = iterator_to_array($forms);
|
||||||
|
if (array_key_exists('title', $forms)) {
|
||||||
|
$forms['title']->setData($viewData->getTitle());
|
||||||
|
}
|
||||||
|
$forms['stored_object']->setData($viewData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param FormInterface[]|\Traversable $forms A list of {@link FormInterface} instances
|
||||||
|
*/
|
||||||
|
public function mapFormsToData($forms, &$viewData)
|
||||||
|
{
|
||||||
|
$forms = iterator_to_array($forms);
|
||||||
|
|
||||||
|
if (!(null === $viewData || $viewData instanceof StoredObject)) {
|
||||||
|
throw new Exception\UnexpectedTypeException($viewData, StoredObject::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $forms['stored_object']->getData()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var StoredObject $viewData */
|
||||||
|
if ($viewData->getFilename() !== $forms['stored_object']->getData()['filename']) {
|
||||||
|
// we do not want to erase the previous object
|
||||||
|
$viewData = new StoredObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
$viewData->setFilename($forms['stored_object']->getData()['filename']);
|
||||||
|
$viewData->setIv($forms['stored_object']->getData()['iv']);
|
||||||
|
$viewData->setKeyInfos($forms['stored_object']->getData()['keyInfos']);
|
||||||
|
$viewData->setType($forms['stored_object']->getData()['type']);
|
||||||
|
|
||||||
|
if (array_key_exists('title', $forms)) {
|
||||||
|
$viewData->setTitle($forms['title']->getData());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Form\DataTransformer;
|
||||||
|
|
||||||
|
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||||
|
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectNormalizer;
|
||||||
|
use Symfony\Component\Form\DataTransformerInterface;
|
||||||
|
use Symfony\Component\Form\Exception\UnexpectedTypeException;
|
||||||
|
use Symfony\Component\Serializer\SerializerInterface;
|
||||||
|
|
||||||
|
class StoredObjectDataTransformer implements DataTransformerInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly SerializerInterface $serializer
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function transform(mixed $value): mixed
|
||||||
|
{
|
||||||
|
if (null === $value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value instanceof StoredObject) {
|
||||||
|
return $this->serializer->serialize($value, 'json', [
|
||||||
|
'groups' => [
|
||||||
|
StoredObjectNormalizer::ADD_DAV_EDIT_LINK_CONTEXT,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new UnexpectedTypeException($value, StoredObject::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reverseTransform(mixed $value): mixed
|
||||||
|
{
|
||||||
|
if ('' === $value || null === $value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return json_decode((string) $value, true, 10, JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
}
|
@ -11,11 +11,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Chill\DocStoreBundle\Form;
|
namespace Chill\DocStoreBundle\Form;
|
||||||
|
|
||||||
use ChampsLibres\AsyncUploaderBundle\Form\Type\AsyncUploaderType;
|
|
||||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Chill\DocStoreBundle\Form\DataMapper\StoredObjectDataMapper;
|
||||||
|
use Chill\DocStoreBundle\Form\DataTransformer\StoredObjectDataTransformer;
|
||||||
use Symfony\Component\Form\AbstractType;
|
use Symfony\Component\Form\AbstractType;
|
||||||
use Symfony\Component\Form\CallbackTransformer;
|
|
||||||
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
|
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
use Symfony\Component\Form\FormBuilderInterface;
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
@ -24,16 +23,12 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
|||||||
/**
|
/**
|
||||||
* Form type which allow to join a document.
|
* Form type which allow to join a document.
|
||||||
*/
|
*/
|
||||||
class StoredObjectType extends AbstractType
|
final class StoredObjectType extends AbstractType
|
||||||
{
|
{
|
||||||
/**
|
public function __construct(
|
||||||
* @var EntityManagerInterface
|
private readonly StoredObjectDataTransformer $storedObjectDataTransformer,
|
||||||
*/
|
private readonly StoredObjectDataMapper $storedObjectDataMapper,
|
||||||
protected $em;
|
) {
|
||||||
|
|
||||||
public function __construct(EntityManagerInterface $em)
|
|
||||||
{
|
|
||||||
$this->em = $em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function buildForm(FormBuilderInterface $builder, array $options)
|
public function buildForm(FormBuilderInterface $builder, array $options)
|
||||||
@ -45,30 +40,9 @@ class StoredObjectType extends AbstractType
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$builder
|
$builder->add('stored_object', HiddenType::class);
|
||||||
->add('filename', AsyncUploaderType::class)
|
$builder->get('stored_object')->addModelTransformer($this->storedObjectDataTransformer);
|
||||||
->add('type', HiddenType::class)
|
$builder->setDataMapper($this->storedObjectDataMapper);
|
||||||
->add('keyInfos', HiddenType::class)
|
|
||||||
->add('iv', HiddenType::class);
|
|
||||||
|
|
||||||
$builder
|
|
||||||
->get('keyInfos')
|
|
||||||
->addModelTransformer(new CallbackTransformer(
|
|
||||||
$this->transform(...),
|
|
||||||
$this->reverseTransform(...)
|
|
||||||
));
|
|
||||||
$builder
|
|
||||||
->get('iv')
|
|
||||||
->addModelTransformer(new CallbackTransformer(
|
|
||||||
$this->transform(...),
|
|
||||||
$this->reverseTransform(...)
|
|
||||||
));
|
|
||||||
|
|
||||||
$builder
|
|
||||||
->addModelTransformer(new CallbackTransformer(
|
|
||||||
$this->transformObject(...),
|
|
||||||
$this->reverseTransformObject(...)
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function configureOptions(OptionsResolver $resolver)
|
public function configureOptions(OptionsResolver $resolver)
|
||||||
@ -80,43 +54,4 @@ class StoredObjectType extends AbstractType
|
|||||||
->setDefault('has_title', false)
|
->setDefault('has_title', false)
|
||||||
->setAllowedTypes('has_title', ['bool']);
|
->setAllowedTypes('has_title', ['bool']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function reverseTransform($value)
|
|
||||||
{
|
|
||||||
if (null === $value) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return \json_decode((string) $value, true, 512, JSON_THROW_ON_ERROR);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function reverseTransformObject($object)
|
|
||||||
{
|
|
||||||
if (null === $object) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (null === $object->getFilename()) {
|
|
||||||
// remove the original object
|
|
||||||
$this->em->remove($object);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $object;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function transform($object)
|
|
||||||
{
|
|
||||||
if (null === $object) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return \json_encode($object, JSON_THROW_ON_ERROR);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function transformObject($object = null)
|
|
||||||
{
|
|
||||||
return $object;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,86 @@
|
|||||||
|
import {CollectionEventPayload} from "../../../../../ChillMainBundle/Resources/public/module/collection";
|
||||||
|
import {createApp} from "vue";
|
||||||
|
import DropFileWidget from "../../vuejs/DropFileWidget/DropFileWidget.vue"
|
||||||
|
import {StoredObject, StoredObjectCreated} from "../../types";
|
||||||
|
import {_createI18n} from "../../../../../ChillMainBundle/Resources/public/vuejs/_js/i18n";
|
||||||
|
const i18n = _createI18n({});
|
||||||
|
|
||||||
|
const startApp = (divElement: HTMLDivElement, collectionEntry: null|HTMLLIElement): void => {
|
||||||
|
console.log('app started', divElement);
|
||||||
|
const input_stored_object: HTMLInputElement|null = divElement.querySelector("input[data-stored-object]");
|
||||||
|
if (null === input_stored_object) {
|
||||||
|
throw new Error('input to stored object not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
let existingDoc: StoredObject|null = null;
|
||||||
|
if (input_stored_object.value !== "") {
|
||||||
|
existingDoc = JSON.parse(input_stored_object.value);
|
||||||
|
}
|
||||||
|
const app_container = document.createElement("div");
|
||||||
|
divElement.appendChild(app_container);
|
||||||
|
|
||||||
|
const app = createApp({
|
||||||
|
template: '<drop-file-widget :existingDoc="this.$data.existingDoc" :allowRemove="true" @addDocument="this.addDocument" @removeDocument="removeDocument"></drop-file-widget>',
|
||||||
|
data(vm) {
|
||||||
|
return {
|
||||||
|
existingDoc: existingDoc,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
DropFileWidget,
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
addDocument: function(object: StoredObjectCreated): void {
|
||||||
|
console.log('object added', object);
|
||||||
|
this.$data.existingDoc = object;
|
||||||
|
input_stored_object.value = JSON.stringify(object);
|
||||||
|
},
|
||||||
|
removeDocument: function(object: StoredObject): void {
|
||||||
|
console.log('catch remove document', object);
|
||||||
|
input_stored_object.value = "";
|
||||||
|
this.$data.existingDoc = null;
|
||||||
|
console.log('collectionEntry', collectionEntry);
|
||||||
|
|
||||||
|
if (null !== collectionEntry) {
|
||||||
|
console.log('will remove collection');
|
||||||
|
collectionEntry.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(i18n).mount(app_container);
|
||||||
|
}
|
||||||
|
window.addEventListener('collection-add-entry', ((e: CustomEvent<CollectionEventPayload>) => {
|
||||||
|
const detail = e.detail;
|
||||||
|
const divElement: null|HTMLDivElement = detail.entry.querySelector('div[data-stored-object]');
|
||||||
|
|
||||||
|
if (null === divElement) {
|
||||||
|
throw new Error('div[data-stored-object] not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
startApp(divElement, detail.entry);
|
||||||
|
}) as EventListener);
|
||||||
|
|
||||||
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const upload_inputs: NodeListOf<HTMLDivElement> = document.querySelectorAll('div[data-stored-object]');
|
||||||
|
|
||||||
|
upload_inputs.forEach((input: HTMLDivElement): void => {
|
||||||
|
// test for a parent to check if this is a collection entry
|
||||||
|
let collectionEntry: null|HTMLLIElement = null;
|
||||||
|
let parent = input.parentElement;
|
||||||
|
console.log('parent', parent);
|
||||||
|
if (null !== parent) {
|
||||||
|
let grandParent = parent.parentElement;
|
||||||
|
console.log('grandParent', grandParent);
|
||||||
|
if (null !== grandParent) {
|
||||||
|
if (grandParent.tagName.toLowerCase() === 'li' && grandParent.classList.contains('entry')) {
|
||||||
|
collectionEntry = grandParent as HTMLLIElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
startApp(input, collectionEntry);
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
export {}
|
@ -17,18 +17,22 @@ window.addEventListener('DOMContentLoaded', function (e) {
|
|||||||
canEdit: string,
|
canEdit: string,
|
||||||
storedObject: string,
|
storedObject: string,
|
||||||
buttonSmall: string,
|
buttonSmall: string,
|
||||||
|
davLink: string,
|
||||||
|
davLinkExpiration: string,
|
||||||
};
|
};
|
||||||
|
|
||||||
const
|
const
|
||||||
storedObject = JSON.parse(datasets.storedObject) as StoredObject,
|
storedObject = JSON.parse(datasets.storedObject) as StoredObject,
|
||||||
filename = datasets.filename,
|
filename = datasets.filename,
|
||||||
canEdit = datasets.canEdit === '1',
|
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: {
|
methods: {
|
||||||
onStoredObjectStatusChange: function(newStatus: StoredObjectStatusChange): void {
|
onStoredObjectStatusChange: function(newStatus: StoredObjectStatusChange): void {
|
||||||
this.$data.storedObject.status = newStatus.status;
|
this.$data.storedObject.status = newStatus.status;
|
||||||
|
@ -17,6 +17,20 @@ export interface StoredObject {
|
|||||||
type: string,
|
type: string,
|
||||||
uuid: string,
|
uuid: string,
|
||||||
status: StoredObjectStatus,
|
status: StoredObjectStatus,
|
||||||
|
_links?: {
|
||||||
|
dav_link?: {
|
||||||
|
href: string
|
||||||
|
expiration: number
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoredObjectCreated {
|
||||||
|
status: "stored_object_created",
|
||||||
|
filename: string,
|
||||||
|
iv: Uint8Array,
|
||||||
|
keyInfos: object,
|
||||||
|
type: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StoredObjectStatusChange {
|
export interface StoredObjectStatusChange {
|
||||||
@ -33,3 +47,18 @@ export type WopiEditButtonExecutableBeforeLeaveFunction = {
|
|||||||
(): Promise<void>
|
(): Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Object containing information for performering a POST request to a swift object store
|
||||||
|
*/
|
||||||
|
export interface PostStoreObjectSignature {
|
||||||
|
method: "POST",
|
||||||
|
max_file_size: number,
|
||||||
|
max_file_count: 1,
|
||||||
|
expires: number,
|
||||||
|
submit_delay: 180,
|
||||||
|
redirect: string,
|
||||||
|
prefix: string,
|
||||||
|
url: string,
|
||||||
|
signature: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="'ready' === props.storedObject.status" class="btn-group">
|
<div v-if="'ready' === props.storedObject.status || 'stored_object_created' === props.storedObject.status" class="btn-group">
|
||||||
<button :class="Object.assign({'btn': true, 'btn-outline-primary': true, 'dropdown-toggle': true, 'btn-sm': props.small})" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
<button :class="Object.assign({'btn': true, 'btn-outline-primary': true, 'dropdown-toggle': true, 'btn-sm': props.small})" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
Actions
|
Actions
|
||||||
</button>
|
</button>
|
||||||
@ -7,6 +7,9 @@
|
|||||||
<li v-if="props.canEdit && is_extension_editable(props.storedObject.type)">
|
<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>
|
<wopi-edit-button :stored-object="props.storedObject" :classes="{'dropdown-item': true}" :execute-before-leave="props.executeBeforeLeave"></wopi-edit-button>
|
||||||
</li>
|
</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">
|
<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>
|
<convert-button :stored-object="props.storedObject" :filename="filename" :classes="{'dropdown-item': true}"></convert-button>
|
||||||
</li>
|
</li>
|
||||||
@ -32,13 +35,14 @@ import DownloadButton from "./StoredObjectButton/DownloadButton.vue";
|
|||||||
import WopiEditButton from "./StoredObjectButton/WopiEditButton.vue";
|
import WopiEditButton from "./StoredObjectButton/WopiEditButton.vue";
|
||||||
import {is_extension_editable, is_extension_viewable, is_object_ready} from "./StoredObjectButton/helpers";
|
import {is_extension_editable, is_extension_viewable, is_object_ready} from "./StoredObjectButton/helpers";
|
||||||
import {
|
import {
|
||||||
StoredObject,
|
StoredObject, StoredObjectCreated,
|
||||||
StoredObjectStatusChange,
|
StoredObjectStatusChange,
|
||||||
WopiEditButtonExecutableBeforeLeaveFunction
|
WopiEditButtonExecutableBeforeLeaveFunction
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
import DesktopEditButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DesktopEditButton.vue";
|
||||||
|
|
||||||
interface DocumentActionButtonsGroupConfig {
|
interface DocumentActionButtonsGroupConfig {
|
||||||
storedObject: StoredObject,
|
storedObject: StoredObject|StoredObjectCreated,
|
||||||
small?: boolean,
|
small?: boolean,
|
||||||
canEdit?: boolean,
|
canEdit?: boolean,
|
||||||
canDownload?: boolean,
|
canDownload?: boolean,
|
||||||
@ -57,6 +61,16 @@ interface DocumentActionButtonsGroupConfig {
|
|||||||
* If set, will execute this function before leaving to the editor
|
* If set, will execute this function before leaving to the editor
|
||||||
*/
|
*/
|
||||||
executeBeforeLeave?: WopiEditButtonExecutableBeforeLeaveFunction,
|
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<{
|
const emit = defineEmits<{
|
||||||
@ -68,7 +82,7 @@ const props = withDefaults(defineProps<DocumentActionButtonsGroupConfig>(), {
|
|||||||
canEdit: true,
|
canEdit: true,
|
||||||
canDownload: true,
|
canDownload: true,
|
||||||
canConvertPdf: true,
|
canConvertPdf: true,
|
||||||
returnPath: window.location.pathname + window.location.search + window.location.hash,
|
returnPath: window.location.pathname + window.location.search + window.location.hash
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -85,6 +99,7 @@ const checkForReady = function(): void {
|
|||||||
if (
|
if (
|
||||||
'ready' === props.storedObject.status
|
'ready' === props.storedObject.status
|
||||||
|| 'failure' === props.storedObject.status
|
|| 'failure' === props.storedObject.status
|
||||||
|
|| 'stored_object_created' === props.storedObject.status
|
||||||
// stop reloading if the page stays opened for a long time
|
// stop reloading if the page stays opened for a long time
|
||||||
|| tryiesForReady > maxTryiesForReady
|
|| tryiesForReady > maxTryiesForReady
|
||||||
) {
|
) {
|
||||||
@ -97,6 +112,11 @@ const checkForReady = function(): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onObjectNewStatusCallback = async function(): Promise<void> {
|
const onObjectNewStatusCallback = async function(): Promise<void> {
|
||||||
|
|
||||||
|
if (props.storedObject.status === 'stored_object_created') {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
const new_status = await is_object_ready(props.storedObject);
|
const new_status = await is_object_ready(props.storedObject);
|
||||||
if (props.storedObject.status !== new_status.status) {
|
if (props.storedObject.status !== new_status.status) {
|
||||||
emit('onStoredObjectStatusChange', new_status);
|
emit('onStoredObjectStatusChange', new_status);
|
||||||
|
@ -0,0 +1,155 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
import {StoredObject, StoredObjectCreated} from "../../types";
|
||||||
|
import {encryptFile, uploadFile} from "../_components/helper";
|
||||||
|
import {computed, ref, Ref} from "vue";
|
||||||
|
|
||||||
|
interface DropFileConfig {
|
||||||
|
existingDoc?: StoredObjectCreated|StoredObject,
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<DropFileConfig>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'addDocument', stored_object: StoredObjectCreated): void,
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const is_dragging: Ref<boolean> = ref(false);
|
||||||
|
const uploading: Ref<boolean> = ref(false);
|
||||||
|
|
||||||
|
const has_existing_doc = computed<boolean>(() => {
|
||||||
|
return props.existingDoc !== undefined && props.existingDoc !== null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const onDragOver = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
is_dragging.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDragLeave = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
is_dragging.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDrop = (e: DragEvent) => {
|
||||||
|
console.log('on drop', e);
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const files = e.dataTransfer?.files;
|
||||||
|
|
||||||
|
if (null === files || undefined === files) {
|
||||||
|
console.error("no files transferred", e.dataTransfer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (files.length === 0) {
|
||||||
|
console.error("no files given");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFile(files[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
const onZoneClick = (e: Event) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "file";
|
||||||
|
input.addEventListener("change", onFileChange);
|
||||||
|
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
const onFileChange = async (event: Event): Promise<void> => {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
console.log('event triggered', input);
|
||||||
|
|
||||||
|
if (input.files && input.files[0]) {
|
||||||
|
console.log('file added', input.files[0]);
|
||||||
|
const file = input.files[0];
|
||||||
|
await handleFile(file);
|
||||||
|
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw 'No file given';
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFile = async (file: File): Promise<void> => {
|
||||||
|
uploading.value = true;
|
||||||
|
const type = file.type;
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
const [encrypted, iv, jsonWebKey] = await encryptFile(buffer);
|
||||||
|
const filename = await uploadFile(encrypted);
|
||||||
|
|
||||||
|
console.log(iv, jsonWebKey);
|
||||||
|
|
||||||
|
const storedObject: StoredObjectCreated = {
|
||||||
|
filename: filename,
|
||||||
|
iv,
|
||||||
|
keyInfos: jsonWebKey,
|
||||||
|
type: type,
|
||||||
|
status: "stored_object_created",
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('addDocument', storedObject);
|
||||||
|
uploading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="drop-file">
|
||||||
|
<div v-if="!uploading" :class="{ area: true, dragging: is_dragging}" @click="onZoneClick" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
|
||||||
|
<p v-if="has_existing_doc">
|
||||||
|
<i class="fa fa-file-pdf-o" v-if="props.existingDoc?.type === 'application/pdf'"></i>
|
||||||
|
<i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/vnd.oasis.opendocument.text'"></i>
|
||||||
|
<i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'"></i>
|
||||||
|
<i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/msword'"></i>
|
||||||
|
<i class="fa fa-file-excel-o" v-else-if="props.existingDoc?.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'"></i>
|
||||||
|
<i class="fa fa-file-excel-o" v-else-if="props.existingDoc?.type === 'application/vnd.ms-excel'"></i>
|
||||||
|
<i class="fa fa-file-image-o" v-else-if="props.existingDoc?.type === 'image/jpeg'"></i>
|
||||||
|
<i class="fa fa-file-image-o" v-else-if="props.existingDoc?.type === 'image/png'"></i>
|
||||||
|
<i class="fa fa-file-archive-o" v-else-if="props.existingDoc?.type === 'application/x-zip-compressed'"></i>
|
||||||
|
<i class="fa fa-file-code-o" v-else ></i>
|
||||||
|
</p>
|
||||||
|
<!-- todo i18n -->
|
||||||
|
<p v-if="has_existing_doc">Déposez un document ou cliquez ici pour remplacer le document existant</p>
|
||||||
|
<p v-else>Déposez un document ou cliquez ici pour ouvrir le navigateur de fichier</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="waiting">
|
||||||
|
<i class="fa fa-cog fa-spin fa-3x fa-fw"></i>
|
||||||
|
<span class="sr-only">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.drop-file {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
& > .area, & > .waiting {
|
||||||
|
width: 100%;
|
||||||
|
height: 8rem;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > .area {
|
||||||
|
border: 4px dashed #ccc;
|
||||||
|
|
||||||
|
&.dragging {
|
||||||
|
border: 4px dashed blue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div.chill-collection ul.list-entry li.entry:nth-child(2n) {
|
||||||
|
|
||||||
|
}
|
||||||
|
</style>
|
@ -0,0 +1,83 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
import {StoredObject, StoredObjectCreated} from "../../types";
|
||||||
|
import {computed, ref, Ref} from "vue";
|
||||||
|
import DropFile from "ChillDocStoreAssets/vuejs/DropFileWidget/DropFile.vue";
|
||||||
|
import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue";
|
||||||
|
|
||||||
|
interface DropFileConfig {
|
||||||
|
allowRemove: boolean,
|
||||||
|
existingDoc?: StoredObjectCreated|StoredObject,
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<DropFileConfig>(), {
|
||||||
|
allowRemove: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'addDocument', stored_object: StoredObjectCreated): void,
|
||||||
|
(e: 'removeDocument', stored_object: null): void
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const has_existing_doc = computed<boolean>(() => {
|
||||||
|
return props.existingDoc !== undefined && props.existingDoc !== null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const dav_link_expiration = computed<number|undefined>(() => {
|
||||||
|
if (props.existingDoc === undefined || props.existingDoc === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (props.existingDoc.status !== 'ready') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.existingDoc._links?.dav_link?.expiration;
|
||||||
|
});
|
||||||
|
|
||||||
|
const dav_link_href = computed<string|undefined>(() => {
|
||||||
|
if (props.existingDoc === undefined || props.existingDoc === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (props.existingDoc.status !== 'ready') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.existingDoc._links?.dav_link?.href;
|
||||||
|
})
|
||||||
|
|
||||||
|
const onAddDocument = (s: StoredObjectCreated): void => {
|
||||||
|
emit('addDocument', s);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onRemoveDocument = (e: Event): void => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
emit('removeDocument', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<drop-file :existingDoc="props.existingDoc" @addDocument="onAddDocument"></drop-file>
|
||||||
|
|
||||||
|
<ul class="record_actions">
|
||||||
|
<li v-if="has_existing_doc">
|
||||||
|
<document-action-buttons-group
|
||||||
|
:stored-object="props.existingDoc"
|
||||||
|
:can-edit="props.existingDoc?.status === 'ready'"
|
||||||
|
:can-download="true"
|
||||||
|
:dav-link="dav_link_href"
|
||||||
|
:dav-link-expiration="dav_link_expiration"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button v-if="allowRemove" class="btn btn-delete" @click="onRemoveDocument($event)" ></button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
|
||||||
|
</style>
|
@ -10,10 +10,10 @@
|
|||||||
import {build_convert_link, download_and_decrypt_doc, download_doc} from "./helpers";
|
import {build_convert_link, download_and_decrypt_doc, download_doc} from "./helpers";
|
||||||
import mime from "mime";
|
import mime from "mime";
|
||||||
import {reactive} from "vue";
|
import {reactive} from "vue";
|
||||||
import {StoredObject} from "../../types";
|
import {StoredObject, StoredObjectCreated} from "../../types";
|
||||||
|
|
||||||
interface ConvertButtonConfig {
|
interface ConvertButtonConfig {
|
||||||
storedObject: StoredObject,
|
storedObject: StoredObject|StoredObjectCreated,
|
||||||
classes: { [key: string]: boolean},
|
classes: { [key: string]: boolean},
|
||||||
filename?: string,
|
filename?: string,
|
||||||
};
|
};
|
||||||
|
@ -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>
|
@ -13,10 +13,10 @@
|
|||||||
import {reactive, ref, nextTick, onMounted} from "vue";
|
import {reactive, ref, nextTick, onMounted} from "vue";
|
||||||
import {build_download_info_link, download_and_decrypt_doc} from "./helpers";
|
import {build_download_info_link, download_and_decrypt_doc} from "./helpers";
|
||||||
import mime from "mime";
|
import mime from "mime";
|
||||||
import {StoredObject} from "../../types";
|
import {StoredObject, StoredObjectCreated} from "../../types";
|
||||||
|
|
||||||
interface DownloadButtonConfig {
|
interface DownloadButtonConfig {
|
||||||
storedObject: StoredObject,
|
storedObject: StoredObject|StoredObjectCreated,
|
||||||
classes: { [k: string]: boolean },
|
classes: { [k: string]: boolean },
|
||||||
filename?: string,
|
filename?: string,
|
||||||
}
|
}
|
||||||
|
@ -8,10 +8,10 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import WopiEditButton from "./WopiEditButton.vue";
|
import WopiEditButton from "./WopiEditButton.vue";
|
||||||
import {build_wopi_editor_link} from "./helpers";
|
import {build_wopi_editor_link} from "./helpers";
|
||||||
import {StoredObject, WopiEditButtonExecutableBeforeLeaveFunction} from "../../types";
|
import {StoredObject, StoredObjectCreated, WopiEditButtonExecutableBeforeLeaveFunction} from "../../types";
|
||||||
|
|
||||||
interface WopiEditButtonConfig {
|
interface WopiEditButtonConfig {
|
||||||
storedObject: StoredObject,
|
storedObject: StoredObject|StoredObjectCreated,
|
||||||
returnPath?: string,
|
returnPath?: string,
|
||||||
classes: {[k: string] : boolean},
|
classes: {[k: string] : boolean},
|
||||||
executeBeforeLeave?: WopiEditButtonExecutableBeforeLeaveFunction,
|
executeBeforeLeave?: WopiEditButtonExecutableBeforeLeaveFunction,
|
||||||
|
@ -0,0 +1,60 @@
|
|||||||
|
import {makeFetch} from "../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods";
|
||||||
|
import {PostStoreObjectSignature} from "../../types";
|
||||||
|
|
||||||
|
const algo = 'AES-CBC';
|
||||||
|
|
||||||
|
const URL_POST = '/asyncupload/temp_url/generate/post';
|
||||||
|
|
||||||
|
const keyDefinition = {
|
||||||
|
name: algo,
|
||||||
|
length: 256
|
||||||
|
};
|
||||||
|
|
||||||
|
const createFilename = (): string => {
|
||||||
|
var text = "";
|
||||||
|
var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
text += possible.charAt(Math.floor(Math.random() * possible.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const uploadFile = async (uploadFile: ArrayBuffer): Promise<string> => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append('expires_delay', "180");
|
||||||
|
params.append('submit_delay', "180");
|
||||||
|
const asyncData: PostStoreObjectSignature = await makeFetch("GET", URL_POST + "?" + params.toString());
|
||||||
|
const suffix = createFilename();
|
||||||
|
const filename = asyncData.prefix + suffix;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("redirect", asyncData.redirect);
|
||||||
|
formData.append("max_file_size", asyncData.max_file_size.toString());
|
||||||
|
formData.append("max_file_count", asyncData.max_file_count.toString());
|
||||||
|
formData.append("expires", asyncData.expires.toString());
|
||||||
|
formData.append("signature", asyncData.signature);
|
||||||
|
formData.append(filename, new Blob([uploadFile]), suffix);
|
||||||
|
|
||||||
|
const response = await window.fetch(asyncData.url, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error("Error while sending file to store", response);
|
||||||
|
throw new Error(response.statusText);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const encryptFile = async (originalFile: ArrayBuffer): Promise<[ArrayBuffer, Uint8Array, JsonWebKey]> => {
|
||||||
|
console.log('encrypt', originalFile);
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
const key = await window.crypto.subtle.generateKey(keyDefinition, true, [ "encrypt", "decrypt" ]);
|
||||||
|
const exportedKey = await window.crypto.subtle.exportKey('jwk', key);
|
||||||
|
const encrypted = await window.crypto.subtle.encrypt({ name: algo, iv: iv}, key, originalFile);
|
||||||
|
|
||||||
|
return Promise.resolve([encrypted, iv, exportedKey]);
|
||||||
|
};
|
@ -3,5 +3,7 @@
|
|||||||
data-download-buttons
|
data-download-buttons
|
||||||
data-stored-object="{{ document_json|json_encode|escape('html_attr') }}"
|
data-stored-object="{{ document_json|json_encode|escape('html_attr') }}"
|
||||||
data-can-edit="{{ can_edit ? '1' : '0' }}"
|
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 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>
|
{% if title|default(document.title)|default(null) is not null %}data-filename="{{ title|default(document.title)|escape('html_attr') }}"{% endif %}></div>
|
||||||
|
@ -1,23 +1,7 @@
|
|||||||
{% block stored_object_widget %}
|
{% block stored_object_widget %}
|
||||||
{% if form.title is defined %} {{ form_row(form.title) }} {% endif %}
|
{% if form.title is defined %} {{ form_row(form.title) }} {% endif %}
|
||||||
<div
|
<div
|
||||||
data-stored-object="data-stored-object"
|
data-stored-object="data-stored-object">
|
||||||
data-label-preparing="{{ ('Preparing'|trans ~ '...')|escape('html_attr') }}"
|
{{ form_widget(form.stored_object, { 'attr': { 'data-stored-object': 1 } }) }}
|
||||||
data-label-quiet-button="{{ 'Download existing file'|trans|escape('html_attr') }}"
|
|
||||||
data-label-ready="{{ 'Ready to show'|trans|escape('html_attr') }}"
|
|
||||||
data-dict-file-too-big="{{ 'File too big'|trans|escape('html_attr') }}"
|
|
||||||
data-dict-default-message="{{ "Drop your file or click here"|trans|escape('html_attr') }}"
|
|
||||||
data-dict-remove-file="{{ 'Remove file in order to upload a new one'|trans|escape('html_attr') }}"
|
|
||||||
data-dict-max-files-exceeded="{{ 'Max files exceeded. Remove previous files'|trans|escape('html_attr') }}"
|
|
||||||
data-dict-cancel-upload="{{ 'Cancel upload'|trans|escape('html_attr') }}"
|
|
||||||
data-dict-cancel-upload-confirm="{{ 'Are you sure you want to cancel this upload ?'|trans|escape('html_attr') }}"
|
|
||||||
data-dict-upload-canceled="{{ 'Upload canceled'|trans|escape('html_attr') }}"
|
|
||||||
data-dict-remove="{{ 'Remove existing file'|trans|escape('html_attr') }}"
|
|
||||||
data-allow-remove="{% if required %}false{% else %}true{% endif %}"
|
|
||||||
data-temp-url-generator="{{ path('async_upload.generate_url', { 'method': 'GET' })|escape('html_attr') }}">
|
|
||||||
{{ form_widget(form.filename) }}
|
|
||||||
{{ form_widget(form.keyInfos, { 'attr': { 'data-stored-object-key': 1 } }) }}
|
|
||||||
{{ form_widget(form.iv, { 'attr': { 'data-stored-object-iv': 1 } }) }}
|
|
||||||
{{ form_widget(form.type, { 'attr': { 'data-async-file-type': 1 } }) }}
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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 %}
|
@ -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';
|
||||||
|
}
|
@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -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];
|
||||||
|
}
|
||||||
|
}
|
@ -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']);
|
||||||
|
}
|
||||||
|
}
|
@ -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']);
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Serializer\Normalizer;
|
||||||
|
|
||||||
|
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||||
|
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||||
|
use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface;
|
||||||
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
|
||||||
|
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
|
||||||
|
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class StoredObjectNormalizer.
|
||||||
|
*
|
||||||
|
* Normalizes a StoredObject entity to an array of data.
|
||||||
|
*/
|
||||||
|
final class StoredObjectNormalizer implements NormalizerInterface, NormalizerAwareInterface
|
||||||
|
{
|
||||||
|
use NormalizerAwareTrait;
|
||||||
|
public const ADD_DAV_SEE_LINK_CONTEXT = 'dav-see-link-context';
|
||||||
|
public const ADD_DAV_EDIT_LINK_CONTEXT = 'dav-edit-link-context';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly JWTDavTokenProviderInterface $JWTDavTokenProvider,
|
||||||
|
private readonly UrlGeneratorInterface $urlGenerator
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function normalize($object, ?string $format = null, array $context = [])
|
||||||
|
{
|
||||||
|
/** @var StoredObject $object */
|
||||||
|
$datas = [
|
||||||
|
'datas' => $object->getDatas(),
|
||||||
|
'filename' => $object->getFilename(),
|
||||||
|
'id' => $object->getId(),
|
||||||
|
'iv' => $object->getIv(),
|
||||||
|
'keyInfos' => $object->getKeyInfos(),
|
||||||
|
'title' => $object->getTitle(),
|
||||||
|
'type' => $object->getType(),
|
||||||
|
'uuid' => $object->getUuid(),
|
||||||
|
'status' => $object->getStatus(),
|
||||||
|
'createdAt' => $this->normalizer->normalize($object->getCreatedAt(), $format, $context),
|
||||||
|
'createdBy' => $this->normalizer->normalize($object->getCreatedBy(), $format, $context),
|
||||||
|
];
|
||||||
|
|
||||||
|
// deprecated property
|
||||||
|
$datas['creationDate'] = $datas['createdAt'];
|
||||||
|
|
||||||
|
$canDavSee = in_array(self::ADD_DAV_SEE_LINK_CONTEXT, $context['groups'] ?? [], true);
|
||||||
|
$canDavEdit = in_array(self::ADD_DAV_EDIT_LINK_CONTEXT, $context['groups'] ?? [], true);
|
||||||
|
|
||||||
|
if ($canDavSee || $canDavEdit) {
|
||||||
|
$accessToken = $this->JWTDavTokenProvider->createToken(
|
||||||
|
$object,
|
||||||
|
$canDavEdit ? StoredObjectRoleEnum::EDIT : StoredObjectRoleEnum::SEE
|
||||||
|
);
|
||||||
|
|
||||||
|
$datas['_links'] = [
|
||||||
|
'dav_link' => [
|
||||||
|
'href' => $this->urlGenerator->generate(
|
||||||
|
'chill_docstore_dav_document_get',
|
||||||
|
[
|
||||||
|
'uuid' => $object->getUuid(),
|
||||||
|
'access_token' => $accessToken,
|
||||||
|
],
|
||||||
|
UrlGeneratorInterface::ABSOLUTE_URL,
|
||||||
|
),
|
||||||
|
'expiration' => $this->JWTDavTokenProvider->getTokenExpiration($accessToken)->format('U'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $datas;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function supportsNormalization($data, ?string $format = null)
|
||||||
|
{
|
||||||
|
return $data instanceof StoredObject && 'json' === $format;
|
||||||
|
}
|
||||||
|
}
|
@ -57,6 +57,62 @@ final class StoredObjectManager implements StoredObjectManagerInterface
|
|||||||
return $this->extractLastModifiedFromResponse($response);
|
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
|
public function read(StoredObject $document): string
|
||||||
{
|
{
|
||||||
$response = $this->getResponseFromCache($document);
|
$response = $this->getResponseFromCache($document);
|
||||||
@ -158,6 +214,22 @@ final class StoredObjectManager implements StoredObjectManagerInterface
|
|||||||
return $date;
|
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
|
private function fillCache(StoredObject $document): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
@ -18,6 +18,8 @@ interface StoredObjectManagerInterface
|
|||||||
{
|
{
|
||||||
public function getLastModified(StoredObject $document): \DateTimeInterface;
|
public function getLastModified(StoredObject $document): \DateTimeInterface;
|
||||||
|
|
||||||
|
public function getContentLength(StoredObject $document): int;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the content of a StoredObject.
|
* Get the content of a StoredObject.
|
||||||
*
|
*
|
||||||
@ -39,5 +41,7 @@ interface StoredObjectManagerInterface
|
|||||||
*/
|
*/
|
||||||
public function write(StoredObject $document, string $clearContent): void;
|
public function write(StoredObject $document, string $clearContent): void;
|
||||||
|
|
||||||
|
public function etag(StoredObject $document): string;
|
||||||
|
|
||||||
public function clearCache(): void;
|
public function clearCache(): void;
|
||||||
}
|
}
|
||||||
|
@ -13,6 +13,9 @@ namespace Chill\DocStoreBundle\Templating;
|
|||||||
|
|
||||||
use ChampsLibres\WopiLib\Contract\Service\Discovery\DiscoveryInterface;
|
use ChampsLibres\WopiLib\Contract\Service\Discovery\DiscoveryInterface;
|
||||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
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\AbstractNormalizer;
|
||||||
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||||
use Twig\Environment;
|
use Twig\Environment;
|
||||||
@ -120,8 +123,12 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt
|
|||||||
|
|
||||||
private const TEMPLATE_BUTTON_GROUP = '@ChillDocStore/Button/button_group.html.twig';
|
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
|
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
|
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, [
|
return $environment->render(self::TEMPLATE_BUTTON_GROUP, [
|
||||||
'document' => $document,
|
'document' => $document,
|
||||||
'document_json' => $this->normalizer->normalize($document, 'json', [AbstractNormalizer::GROUPS => ['read']]),
|
'document_json' => $this->normalizer->normalize($document, 'json', [AbstractNormalizer::GROUPS => ['read']]),
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'can_edit' => $canEdit,
|
'can_edit' => $canEdit,
|
||||||
'options' => [...self::DEFAULT_OPTIONS_TEMPLATE_BUTTON_GROUP, ...$options],
|
'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'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -0,0 +1,414 @@
|
|||||||
|
<?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';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearCache(): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
@ -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'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,105 @@
|
|||||||
|
<?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\Form;
|
||||||
|
|
||||||
|
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||||
|
use Chill\DocStoreBundle\Form\DataMapper\StoredObjectDataMapper;
|
||||||
|
use Chill\DocStoreBundle\Form\DataTransformer\StoredObjectDataTransformer;
|
||||||
|
use Chill\DocStoreBundle\Form\StoredObjectType;
|
||||||
|
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||||
|
use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface;
|
||||||
|
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectNormalizer;
|
||||||
|
use Prophecy\Argument;
|
||||||
|
use Prophecy\PhpUnit\ProphecyTrait;
|
||||||
|
use Symfony\Component\Form\PreloadedExtension;
|
||||||
|
use Symfony\Component\Form\Test\TypeTestCase;
|
||||||
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
use Symfony\Component\Serializer\Encoder\JsonEncoder;
|
||||||
|
use Symfony\Component\Serializer\Serializer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*
|
||||||
|
* @coversNothing
|
||||||
|
*/
|
||||||
|
class StoredObjectTypeTest extends TypeTestCase
|
||||||
|
{
|
||||||
|
use ProphecyTrait;
|
||||||
|
|
||||||
|
public function testChangeTitleValue(): void
|
||||||
|
{
|
||||||
|
$formData = ['title' => $newTitle = 'new title', 'stored_object' => <<<'JSON'
|
||||||
|
{"datas":[],"filename":"","id":null,"iv":[],"keyInfos":[],"title":"","type":"","uuid":"3c6a28fe-f913-40b9-a201-5eccc4f2d312","status":"ready","createdAt":null,"createdBy":null,"creationDate":null,"_links":{"dav_link":{"href":"http:\/\/url\/fake","expiration":"1716889578"}}}
|
||||||
|
JSON];
|
||||||
|
$model = new StoredObject();
|
||||||
|
$form = $this->factory->create(StoredObjectType::class, $model, ['has_title' => true]);
|
||||||
|
|
||||||
|
$form->submit($formData);
|
||||||
|
|
||||||
|
$this->assertTrue($form->isSynchronized());
|
||||||
|
|
||||||
|
$this->assertEquals($newTitle, $model->getTitle());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReplaceByAnotherObject(): void
|
||||||
|
{
|
||||||
|
$formData = ['title' => $newTitle = 'new title', 'stored_object' => <<<'JSON'
|
||||||
|
{"filename":"abcdef","iv":[10, 15, 20, 30],"keyInfos":[],"type":"text/html","status":"object_store_created"}
|
||||||
|
JSON];
|
||||||
|
$model = new StoredObject();
|
||||||
|
$originalObjectId = spl_object_id($model);
|
||||||
|
$form = $this->factory->create(StoredObjectType::class, $model, ['has_title' => true]);
|
||||||
|
|
||||||
|
$form->submit($formData);
|
||||||
|
|
||||||
|
$this->assertTrue($form->isSynchronized());
|
||||||
|
$model = $form->getData();
|
||||||
|
$this->assertNotEquals($originalObjectId, spl_object_hash($model));
|
||||||
|
$this->assertEquals('abcdef', $model->getFilename());
|
||||||
|
$this->assertEquals([10, 15, 20, 30], $model->getIv());
|
||||||
|
$this->assertEquals('text/html', $model->getType());
|
||||||
|
$this->assertEquals($newTitle, $model->getTitle());
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getExtensions()
|
||||||
|
{
|
||||||
|
$jwtTokenProvider = $this->prophesize(JWTDavTokenProviderInterface::class);
|
||||||
|
$jwtTokenProvider->createToken(Argument::type(StoredObject::class), Argument::type(StoredObjectRoleEnum::class))
|
||||||
|
->willReturn('token');
|
||||||
|
$jwtTokenProvider->getTokenExpiration('token')->willReturn(new \DateTimeImmutable());
|
||||||
|
$urlGenerator = $this->prophesize(UrlGeneratorInterface::class);
|
||||||
|
$urlGenerator->generate('chill_docstore_dav_document_get', Argument::type('array'), UrlGeneratorInterface::ABSOLUTE_URL)
|
||||||
|
->willReturn('http://url/fake');
|
||||||
|
|
||||||
|
$serializer = new Serializer(
|
||||||
|
[
|
||||||
|
new StoredObjectNormalizer(
|
||||||
|
$jwtTokenProvider->reveal(),
|
||||||
|
$urlGenerator->reveal(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
new JsonEncoder(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
$dataTransformer = new StoredObjectDataTransformer($serializer);
|
||||||
|
$dataMapper = new StoredObjectDataMapper();
|
||||||
|
$type = new StoredObjectType(
|
||||||
|
$dataTransformer,
|
||||||
|
$dataMapper,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
new PreloadedExtension([$type], []),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
@ -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, ?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();
|
||||||
|
}
|
||||||
|
}
|
@ -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];
|
||||||
|
}
|
||||||
|
}
|
@ -3,6 +3,6 @@ module.exports = function(encore)
|
|||||||
encore.addAliases({
|
encore.addAliases({
|
||||||
ChillDocStoreAssets: __dirname + '/Resources/public'
|
ChillDocStoreAssets: __dirname + '/Resources/public'
|
||||||
});
|
});
|
||||||
encore.addEntry('mod_async_upload', __dirname + '/Resources/public/module/async_upload/index.js');
|
encore.addEntry('mod_async_upload', __dirname + '/Resources/public/module/async_upload/index.ts');
|
||||||
encore.addEntry('mod_document_action_buttons_group', __dirname + '/Resources/public/module/document_action_buttons_group/index');
|
encore.addEntry('mod_document_action_buttons_group', __dirname + '/Resources/public/module/document_action_buttons_group/index');
|
||||||
};
|
};
|
||||||
|
@ -34,6 +34,11 @@ services:
|
|||||||
autoconfigure: true
|
autoconfigure: true
|
||||||
autowire: true
|
autowire: true
|
||||||
|
|
||||||
|
Chill\DocStoreBundle\Security\:
|
||||||
|
resource: './../Security'
|
||||||
|
autoconfigure: true
|
||||||
|
autowire: true
|
||||||
|
|
||||||
Chill\DocStoreBundle\Serializer\Normalizer\:
|
Chill\DocStoreBundle\Serializer\Normalizer\:
|
||||||
autowire: true
|
autowire: true
|
||||||
resource: '../Serializer/Normalizer/'
|
resource: '../Serializer/Normalizer/'
|
||||||
|
@ -1,13 +1,18 @@
|
|||||||
services:
|
services:
|
||||||
Chill\DocStoreBundle\Form\StoredObjectType:
|
_defaults:
|
||||||
arguments:
|
autowire: true
|
||||||
$em: '@Doctrine\ORM\EntityManagerInterface'
|
autoconfigure: true
|
||||||
tags:
|
|
||||||
- { name: form.type }
|
|
||||||
|
|
||||||
Chill\DocStoreBundle\Form\AccompanyingCourseDocumentType:
|
Chill\DocStoreBundle\Form\StoredObjectType:
|
||||||
class: Chill\DocStoreBundle\Form\AccompanyingCourseDocumentType
|
tags:
|
||||||
arguments:
|
- { name: form.type }
|
||||||
- "@chill.main.helper.translatable_string"
|
|
||||||
tags:
|
Chill\DocStoreBundle\Form\AccompanyingCourseDocumentType:
|
||||||
- { name: form.type, alias: chill_docstorebundle_form_document }
|
tags:
|
||||||
|
- { name: form.type, alias: chill_docstorebundle_form_document }
|
||||||
|
|
||||||
|
Chill\DocStoreBundle\Form\DataMapper\:
|
||||||
|
resource: '../../Form/DataMapper'
|
||||||
|
|
||||||
|
Chill\DocStoreBundle\Form\DataTransformer\:
|
||||||
|
resource: '../../Form/DataTransformer'
|
||||||
|
@ -46,6 +46,9 @@ Are you sure you want to cancel this upload ?: Êtes-vous sûrs de vouloir annul
|
|||||||
Upload canceled: Téléversement annulé
|
Upload canceled: Téléversement annulé
|
||||||
Remove existing file: Supprimer le document existant
|
Remove existing file: Supprimer le document existant
|
||||||
|
|
||||||
|
stored_object:
|
||||||
|
Insert a document: Ajouter un document
|
||||||
|
|
||||||
# ROLES
|
# ROLES
|
||||||
PersonDocument: Documents
|
PersonDocument: Documents
|
||||||
CHILL_PERSON_DOCUMENT_CREATE: Ajouter un document
|
CHILL_PERSON_DOCUMENT_CREATE: Ajouter un document
|
||||||
|
@ -206,8 +206,6 @@ class EventTypeController extends AbstractController
|
|||||||
/**
|
/**
|
||||||
* Creates a form to delete a EventType entity by id.
|
* Creates a form to delete a EventType entity by id.
|
||||||
*
|
*
|
||||||
* @param mixed $id The entity id
|
|
||||||
*
|
|
||||||
* @return \Symfony\Component\Form\Form The form
|
* @return \Symfony\Component\Form\Form The form
|
||||||
*/
|
*/
|
||||||
private function createDeleteForm(mixed $id)
|
private function createDeleteForm(mixed $id)
|
||||||
|
@ -206,8 +206,6 @@ class RoleController extends AbstractController
|
|||||||
/**
|
/**
|
||||||
* Creates a form to delete a Role entity by id.
|
* Creates a form to delete a Role entity by id.
|
||||||
*
|
*
|
||||||
* @param mixed $id The entity id
|
|
||||||
*
|
|
||||||
* @return \Symfony\Component\Form\Form The form
|
* @return \Symfony\Component\Form\Form The form
|
||||||
*/
|
*/
|
||||||
private function createDeleteForm(mixed $id)
|
private function createDeleteForm(mixed $id)
|
||||||
|
@ -206,8 +206,6 @@ class StatusController extends AbstractController
|
|||||||
/**
|
/**
|
||||||
* Creates a form to delete a Status entity by id.
|
* Creates a form to delete a Status entity by id.
|
||||||
*
|
*
|
||||||
* @param mixed $id The entity id
|
|
||||||
*
|
|
||||||
* @return \Symfony\Component\Form\Form The form
|
* @return \Symfony\Component\Form\Form The form
|
||||||
*/
|
*/
|
||||||
private function createDeleteForm(mixed $id)
|
private function createDeleteForm(mixed $id)
|
||||||
|
@ -700,7 +700,6 @@ class CRUDController extends AbstractController
|
|||||||
* and view.
|
* and view.
|
||||||
*
|
*
|
||||||
* @param string $action
|
* @param string $action
|
||||||
* @param mixed $entity the entity for the current request, or an array of entities
|
|
||||||
*
|
*
|
||||||
* @return string the path to the template
|
* @return string the path to the template
|
||||||
*
|
*
|
||||||
|
@ -317,8 +317,8 @@ final class PermissionsGroupController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
return strcmp(
|
return strcmp(
|
||||||
$translatableStringHelper->localize($a->getScope()->getName()),
|
(string) $translatableStringHelper->localize($a->getScope()->getName()),
|
||||||
$translatableStringHelper->localize($b->getScope()->getName())
|
(string) $translatableStringHelper->localize($b->getScope()->getName())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -451,8 +451,6 @@ final class PermissionsGroupController extends AbstractController
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a form to delete a link to roleScope.
|
* Creates a form to delete a link to roleScope.
|
||||||
*
|
|
||||||
* @param mixed $permissionsGroup The entity id
|
|
||||||
*/
|
*/
|
||||||
private function createDeleteRoleScopeForm(
|
private function createDeleteRoleScopeForm(
|
||||||
PermissionsGroup $permissionsGroup,
|
PermissionsGroup $permissionsGroup,
|
||||||
|
@ -73,6 +73,7 @@ final readonly class UserExportController
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
$csv->addFormatter(fn (array $row) => null !== ($row['absenceStart'] ?? null) ? array_merge($row, ['absenceStart' => $row['absenceStart']->format('Y-m-d')]) : $row);
|
$csv->addFormatter(fn (array $row) => null !== ($row['absenceStart'] ?? null) ? array_merge($row, ['absenceStart' => $row['absenceStart']->format('Y-m-d')]) : $row);
|
||||||
|
/* @phpstan-ignore-next-line as phpstan seem to ignore that we transform datetime into string */
|
||||||
$csv->insertAll($users);
|
$csv->insertAll($users);
|
||||||
|
|
||||||
return new StreamedResponse(
|
return new StreamedResponse(
|
||||||
|
@ -344,11 +344,11 @@ class LoadPostalCodes extends AbstractFixture implements OrderedFixtureInterface
|
|||||||
->findOneBy(['countryCode' => $countryCode]);
|
->findOneBy(['countryCode' => $countryCode]);
|
||||||
|
|
||||||
foreach ($lines as $line) {
|
foreach ($lines as $line) {
|
||||||
$code = str_getcsv($line);
|
$code = str_getcsv((string) $line);
|
||||||
$c = new PostalCode();
|
$c = new PostalCode();
|
||||||
$c->setCountry($country)
|
$c->setCountry($country)
|
||||||
->setCode($code[0])
|
->setCode($code[0])
|
||||||
->setName(\ucwords(\strtolower($code[1])));
|
->setName(\ucwords(\strtolower((string) $code[1])));
|
||||||
|
|
||||||
if (null !== ($code[3] ?? null)) {
|
if (null !== ($code[3] ?? null)) {
|
||||||
$c->setRefPostalCodeId($code[3]);
|
$c->setRefPostalCodeId($code[3]);
|
||||||
|
@ -73,7 +73,6 @@ interface AggregatorInterface extends ModifierInterface
|
|||||||
*
|
*
|
||||||
* @param string $key The column key, as added in the query
|
* @param string $key The column key, as added in the query
|
||||||
* @param mixed[] $values The values from the result. if there are duplicates, those might be given twice. Example: array('FR', 'BE', 'CZ', 'FR', 'BE', 'FR')
|
* @param mixed[] $values The values from the result. if there are duplicates, those might be given twice. Example: array('FR', 'BE', 'CZ', 'FR', 'BE', 'FR')
|
||||||
* @param mixed $data The data from the export's form (as defined in `buildForm`
|
|
||||||
*
|
*
|
||||||
* @return \Closure where the first argument is the value, and the function should return the label to show in the formatted file. Example : `function($countryCode) use ($countries) { return $countries[$countryCode]->getName(); }`
|
* @return \Closure where the first argument is the value, and the function should return the label to show in the formatted file. Example : `function($countryCode) use ($countries) { return $countries[$countryCode]->getName(); }`
|
||||||
*/
|
*/
|
||||||
|
@ -30,8 +30,6 @@ interface ExportElementValidatedInterface
|
|||||||
/**
|
/**
|
||||||
* validate the form's data and, if required, build a contraint
|
* validate the form's data and, if required, build a contraint
|
||||||
* violation on the data.
|
* violation on the data.
|
||||||
*
|
|
||||||
* @param mixed $data the data, as returned by the user
|
|
||||||
*/
|
*/
|
||||||
public function validateForm(mixed $data, ExecutionContextInterface $context);
|
public function validateForm(mixed $data, ExecutionContextInterface $context);
|
||||||
}
|
}
|
||||||
|
@ -96,7 +96,6 @@ interface ExportInterface extends ExportElementInterface
|
|||||||
*
|
*
|
||||||
* @param string $key The column key, as added in the query
|
* @param string $key The column key, as added in the query
|
||||||
* @param mixed[] $values The values from the result. if there are duplicates, those might be given twice. Example: array('FR', 'BE', 'CZ', 'FR', 'BE', 'FR')
|
* @param mixed[] $values The values from the result. if there are duplicates, those might be given twice. Example: array('FR', 'BE', 'CZ', 'FR', 'BE', 'FR')
|
||||||
* @param mixed $data The data from the export's form (as defined in `buildForm`)
|
|
||||||
*
|
*
|
||||||
* @return (callable(string|int|float|'_header'|null $value): string|int|\DateTimeInterface) where the first argument is the value, and the function should return the label to show in the formatted file. Example : `function($countryCode) use ($countries) { return $countries[$countryCode]->getName(); }`
|
* @return (callable(string|int|float|'_header'|null $value): string|int|\DateTimeInterface) where the first argument is the value, and the function should return the label to show in the formatted file. Example : `function($countryCode) use ($countries) { return $countries[$countryCode]->getName(); }`
|
||||||
*/
|
*/
|
||||||
|
@ -552,7 +552,6 @@ class ExportManager
|
|||||||
*
|
*
|
||||||
* This function check the acl.
|
* This function check the acl.
|
||||||
*
|
*
|
||||||
* @param mixed $data the data under the initial 'filters' data
|
|
||||||
* @param \Chill\MainBundle\Entity\Center[] $centers the picked centers
|
* @param \Chill\MainBundle\Entity\Center[] $centers the picked centers
|
||||||
*
|
*
|
||||||
* @throw UnauthorizedHttpException if the user is not authorized
|
* @throw UnauthorizedHttpException if the user is not authorized
|
||||||
@ -615,9 +614,6 @@ class ExportManager
|
|||||||
return $usedTypes;
|
return $usedTypes;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param mixed $data the data from the filter key of the ExportType
|
|
||||||
*/
|
|
||||||
private function retrieveUsedFilters(mixed $data): iterable
|
private function retrieveUsedFilters(mixed $data): iterable
|
||||||
{
|
{
|
||||||
if (null === $data) {
|
if (null === $data) {
|
||||||
@ -634,8 +630,6 @@ class ExportManager
|
|||||||
/**
|
/**
|
||||||
* Retrieve the filter used in this export.
|
* Retrieve the filter used in this export.
|
||||||
*
|
*
|
||||||
* @param mixed $data the data from the `filters` key of the ExportType
|
|
||||||
*
|
|
||||||
* @return array an array with types
|
* @return array an array with types
|
||||||
*/
|
*/
|
||||||
private function retrieveUsedFiltersType(mixed $data): iterable
|
private function retrieveUsedFiltersType(mixed $data): iterable
|
||||||
|
@ -35,6 +35,7 @@ class ChillCollectionType extends AbstractType
|
|||||||
$view->vars['allow_add'] = (int) $options['allow_add'];
|
$view->vars['allow_add'] = (int) $options['allow_add'];
|
||||||
$view->vars['identifier'] = $options['identifier'];
|
$view->vars['identifier'] = $options['identifier'];
|
||||||
$view->vars['empty_collection_explain'] = $options['empty_collection_explain'];
|
$view->vars['empty_collection_explain'] = $options['empty_collection_explain'];
|
||||||
|
$view->vars['js_caller'] = $options['js_caller'];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function configureOptions(OptionsResolver $resolver)
|
public function configureOptions(OptionsResolver $resolver)
|
||||||
@ -45,6 +46,8 @@ class ChillCollectionType extends AbstractType
|
|||||||
'button_remove_label' => 'Remove entry',
|
'button_remove_label' => 'Remove entry',
|
||||||
'identifier' => '',
|
'identifier' => '',
|
||||||
'empty_collection_explain' => '',
|
'empty_collection_explain' => '',
|
||||||
|
'js_caller' => 'data-collection-regular',
|
||||||
|
'delete_empty' => true,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -41,8 +41,6 @@ require('./img/logo-chill-outil-accompagnement_white.png');
|
|||||||
* Some libs are only used in a few pages, they are loaded on a case by case basis
|
* Some libs are only used in a few pages, they are loaded on a case by case basis
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require('../lib/collection/index.js');
|
|
||||||
|
|
||||||
require('../lib/breadcrumb/index.js');
|
require('../lib/breadcrumb/index.js');
|
||||||
require('../lib/download-report/index.js');
|
require('../lib/download-report/index.js');
|
||||||
require('../lib/select_interactive_loading/index.js');
|
require('../lib/select_interactive_loading/index.js');
|
||||||
|
@ -1,120 +0,0 @@
|
|||||||
/**
|
|
||||||
* Javascript file which handle ChillCollectionType
|
|
||||||
*
|
|
||||||
* Two events are emitted by this module, both on window and on collection / ul.
|
|
||||||
*
|
|
||||||
* Collection (an UL element) and entry (a li element) are associated with those
|
|
||||||
* events.
|
|
||||||
*
|
|
||||||
* ```
|
|
||||||
* window.addEventListener('collection-add-entry', function(e) {
|
|
||||||
* console.log(e.detail.collection);
|
|
||||||
* console.log(e.detail.entry);
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* window.addEventListener('collection-remove-entry', function(e) {
|
|
||||||
* console.log(e.detail.collection);
|
|
||||||
* console.log(e.detail.entry);
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* collection.addEventListener('collection-add-entry', function(e) {
|
|
||||||
* console.log(e.detail.collection);
|
|
||||||
* console.log(e.detail.entry);
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* collection.addEventListener('collection-remove-entry', function(e) {
|
|
||||||
* console.log(e.detail.collection);
|
|
||||||
* console.log(e.detail.entry);
|
|
||||||
* });
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
require('./collection.scss');
|
|
||||||
|
|
||||||
class CollectionEvent {
|
|
||||||
constructor(collection, entry) {
|
|
||||||
this.collection = collection;
|
|
||||||
this.entry = entry;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param {type} button
|
|
||||||
* @returns {handleAdd}
|
|
||||||
*/
|
|
||||||
var handleAdd = function(button) {
|
|
||||||
var
|
|
||||||
form_name = button.dataset.collectionAddTarget,
|
|
||||||
prototype = button.dataset.formPrototype,
|
|
||||||
collection = document.querySelector('ul[data-collection-name="'+form_name+'"]'),
|
|
||||||
empty_explain = collection.querySelector('li[data-collection-empty-explain]'),
|
|
||||||
entry = document.createElement('li'),
|
|
||||||
event = new CustomEvent('collection-add-entry', { detail: { collection: collection, entry: entry } }),
|
|
||||||
counter = collection.childNodes.length + parseInt(Math.random() * 1000000)
|
|
||||||
content
|
|
||||||
;
|
|
||||||
content = prototype.replace(new RegExp('__name__', 'g'), counter);
|
|
||||||
entry.innerHTML = content;
|
|
||||||
entry.classList.add('entry');
|
|
||||||
initializeRemove(collection, entry);
|
|
||||||
if (empty_explain !== null) {
|
|
||||||
empty_explain.remove();
|
|
||||||
}
|
|
||||||
collection.appendChild(entry);
|
|
||||||
|
|
||||||
collection.dispatchEvent(event);
|
|
||||||
window.dispatchEvent(event);
|
|
||||||
};
|
|
||||||
|
|
||||||
var initializeRemove = function(collection, entry) {
|
|
||||||
var
|
|
||||||
button = document.createElement('button'),
|
|
||||||
isPersisted = entry.dataset.collectionIsPersisted,
|
|
||||||
content = collection.dataset.collectionButtonRemoveLabel,
|
|
||||||
allowDelete = collection.dataset.collectionAllowDelete,
|
|
||||||
event = new CustomEvent('collection-remove-entry', { detail: { collection: collection, entry: entry } })
|
|
||||||
;
|
|
||||||
|
|
||||||
if (allowDelete === '0' && isPersisted === '1') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.classList.add('btn', 'btn-delete', 'remove-entry');
|
|
||||||
button.textContent = content;
|
|
||||||
|
|
||||||
button.addEventListener('click', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
entry.remove();
|
|
||||||
collection.dispatchEvent(event);
|
|
||||||
window.dispatchEvent(event);
|
|
||||||
});
|
|
||||||
|
|
||||||
entry.appendChild(button);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('load', function() {
|
|
||||||
var
|
|
||||||
addButtons = document.querySelectorAll("button[data-collection-add-target]"),
|
|
||||||
collections = document.querySelectorAll("ul[data-collection-name]")
|
|
||||||
;
|
|
||||||
|
|
||||||
for (let i = 0; i < addButtons.length; i ++) {
|
|
||||||
let addButton = addButtons[i];
|
|
||||||
addButton.addEventListener('click', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleAdd(e.target);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < collections.length; i ++) {
|
|
||||||
let entries = collections[i].querySelectorAll(':scope > li');
|
|
||||||
|
|
||||||
for (let j = 0; j < entries.length; j ++) {
|
|
||||||
console.log(entries[j].dataset);
|
|
||||||
if (entries[j].dataset.collectionEmptyExplain === "1") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
initializeRemove(collections[i], entries[j]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* Javascript file which handle ChillCollectionType
|
||||||
|
*
|
||||||
|
* Two events are emitted by this module, both on window and on collection / ul.
|
||||||
|
*
|
||||||
|
* Collection (an UL element) and entry (a li element) are associated with those
|
||||||
|
* events.
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* window.addEventListener('collection-add-entry', function(e) {
|
||||||
|
* console.log(e.detail.collection);
|
||||||
|
* console.log(e.detail.entry);
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* window.addEventListener('collection-remove-entry', function(e) {
|
||||||
|
* console.log(e.detail.collection);
|
||||||
|
* console.log(e.detail.entry);
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* collection.addEventListener('collection-add-entry', function(e) {
|
||||||
|
* console.log(e.detail.collection);
|
||||||
|
* console.log(e.detail.entry);
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* collection.addEventListener('collection-remove-entry', function(e) {
|
||||||
|
* console.log(e.detail.collection);
|
||||||
|
* console.log(e.detail.entry);
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
import './collection.scss';
|
||||||
|
|
||||||
|
export class CollectionEventPayload {
|
||||||
|
collection: HTMLUListElement;
|
||||||
|
entry: HTMLLIElement;
|
||||||
|
|
||||||
|
constructor(collection: HTMLUListElement, entry: HTMLLIElement) {
|
||||||
|
this.collection = collection;
|
||||||
|
this.entry = entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handleAdd = (button: any): void => {
|
||||||
|
let
|
||||||
|
form_name = button.dataset.collectionAddTarget,
|
||||||
|
prototype = button.dataset.formPrototype,
|
||||||
|
collection: HTMLUListElement | null = document.querySelector('ul[data-collection-name="' + form_name + '"]');
|
||||||
|
|
||||||
|
if (collection === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let
|
||||||
|
empty_explain: HTMLLIElement | null = collection.querySelector('li[data-collection-empty-explain]'),
|
||||||
|
entry = document.createElement('li'),
|
||||||
|
counter = collection.childNodes.length + 1,
|
||||||
|
content = prototype.replace(new RegExp('__name__', 'g'), counter.toString()),
|
||||||
|
event = new CustomEvent('collection-add-entry', {detail: new CollectionEventPayload(collection, entry)});
|
||||||
|
|
||||||
|
entry.innerHTML = content;
|
||||||
|
entry.classList.add('entry');
|
||||||
|
|
||||||
|
if ("dataCollectionRegular" in collection.dataset) {
|
||||||
|
initializeRemove(collection, entry);
|
||||||
|
if (empty_explain !== null) {
|
||||||
|
empty_explain.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
collection.appendChild(entry);
|
||||||
|
collection.dispatchEvent(event);
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const initializeRemove = (collection: HTMLUListElement, entry: HTMLLIElement): void => {
|
||||||
|
const button = buildRemoveButton(collection, entry);
|
||||||
|
if (null === button) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entry.appendChild(button);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildRemoveButton = (collection: HTMLUListElement, entry: HTMLLIElement): HTMLButtonElement|null => {
|
||||||
|
|
||||||
|
let
|
||||||
|
button = document.createElement('button'),
|
||||||
|
isPersisted = entry.dataset.collectionIsPersisted || '',
|
||||||
|
content = collection.dataset.collectionButtonRemoveLabel || '',
|
||||||
|
allowDelete = collection.dataset.collectionAllowDelete || '',
|
||||||
|
event = new CustomEvent('collection-remove-entry', {detail: new CollectionEventPayload(collection, entry)});
|
||||||
|
|
||||||
|
if (allowDelete === '0' && isPersisted === '1') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
button.classList.add('btn', 'btn-delete', 'remove-entry');
|
||||||
|
button.textContent = content;
|
||||||
|
button.addEventListener('click', (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
entry.remove();
|
||||||
|
collection.dispatchEvent(event);
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
let
|
||||||
|
addButtons: NodeListOf<HTMLButtonElement> = document.querySelectorAll("button[data-collection-add-target]"),
|
||||||
|
collections: NodeListOf<HTMLUListElement> = document.querySelectorAll("ul[data-collection-regular]");
|
||||||
|
|
||||||
|
for (let i = 0; i < addButtons.length; i++) {
|
||||||
|
let addButton = addButtons[i];
|
||||||
|
addButton.addEventListener('click', (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAdd(e.target);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (let i = 0; i < collections.length; i++) {
|
||||||
|
let entries: NodeListOf<HTMLLIElement> = collections[i].querySelectorAll(':scope > li');
|
||||||
|
for (let j = 0; j < entries.length; j++) {
|
||||||
|
if (entries[j].dataset.collectionEmptyExplain === "1") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
initializeRemove(collections[i], entries[j]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
@ -162,6 +162,7 @@
|
|||||||
{% block chill_collection_widget %}
|
{% block chill_collection_widget %}
|
||||||
<div class="chill-collection">
|
<div class="chill-collection">
|
||||||
<ul class="list-entry"
|
<ul class="list-entry"
|
||||||
|
{{ form.vars.js_caller }}="{{ form.vars.js_caller }}"
|
||||||
data-collection-name="{{ form.vars.name|escape('html_attr') }}"
|
data-collection-name="{{ form.vars.name|escape('html_attr') }}"
|
||||||
data-collection-identifier="{{ form.vars.identifier|escape('html_attr') }}"
|
data-collection-identifier="{{ form.vars.identifier|escape('html_attr') }}"
|
||||||
data-collection-button-remove-label="{{ form.vars.button_remove_label|trans|e }}"
|
data-collection-button-remove-label="{{ form.vars.button_remove_label|trans|e }}"
|
||||||
@ -173,7 +174,7 @@
|
|||||||
{{ form_widget(entry) }}
|
{{ form_widget(entry) }}
|
||||||
{{ form_errors(entry) }}
|
{{ form_errors(entry) }}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li data-collection-empty-explain="1">
|
<li data-collection-empty-explain="1">
|
||||||
<span class="chill-no-data-statement">{{ form.vars.empty_collection_explain|default('No entities')|trans }}</span>
|
<span class="chill-no-data-statement">{{ form.vars.empty_collection_explain|default('No entities')|trans }}</span>
|
||||||
|
@ -14,6 +14,7 @@
|
|||||||
window.addaddress = {{ add_address|json_encode|raw }};
|
window.addaddress = {{ add_address|json_encode|raw }};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{{ encore_entry_link_tags('mod_collection') }}
|
||||||
{{ encore_entry_link_tags('mod_bootstrap') }}
|
{{ encore_entry_link_tags('mod_bootstrap') }}
|
||||||
{{ encore_entry_link_tags('mod_forkawesome') }}
|
{{ encore_entry_link_tags('mod_forkawesome') }}
|
||||||
{{ encore_entry_link_tags('mod_ckeditor5') }}
|
{{ encore_entry_link_tags('mod_ckeditor5') }}
|
||||||
@ -107,6 +108,7 @@
|
|||||||
|
|
||||||
{{ include('@ChillMain/Layout/_footer.html.twig') }}
|
{{ include('@ChillMain/Layout/_footer.html.twig') }}
|
||||||
|
|
||||||
|
{{ encore_entry_script_tags('mod_collection') }}
|
||||||
{{ encore_entry_script_tags('mod_bootstrap') }}
|
{{ encore_entry_script_tags('mod_bootstrap') }}
|
||||||
{{ encore_entry_script_tags('mod_forkawesome') }}
|
{{ encore_entry_script_tags('mod_forkawesome') }}
|
||||||
{{ encore_entry_script_tags('mod_ckeditor5') }}
|
{{ encore_entry_script_tags('mod_ckeditor5') }}
|
||||||
|
@ -257,10 +257,10 @@ class SearchProvider
|
|||||||
$this->mustBeExtracted[] = $matches[0][$key];
|
$this->mustBeExtracted[] = $matches[0][$key];
|
||||||
// strip parenthesis
|
// strip parenthesis
|
||||||
if (
|
if (
|
||||||
'"' === mb_substr((string) $match, 0, 1)
|
'"' === mb_substr($match, 0, 1)
|
||||||
&& '"' === mb_substr((string) $match, mb_strlen((string) $match) - 1)
|
&& '"' === mb_substr($match, mb_strlen($match) - 1)
|
||||||
) {
|
) {
|
||||||
$match = trim(mb_substr((string) $match, 1, mb_strlen((string) $match) - 2));
|
$match = trim(mb_substr($match, 1, mb_strlen($match) - 2));
|
||||||
}
|
}
|
||||||
$terms[$matches[1][$key]] = $match;
|
$terms[$matches[1][$key]] = $match;
|
||||||
}
|
}
|
||||||
|
@ -199,8 +199,6 @@ class AuthorizationHelper implements AuthorizationHelperInterface
|
|||||||
* if the entity implements Chill\MainBundle\Entity\HasScopeInterface,
|
* if the entity implements Chill\MainBundle\Entity\HasScopeInterface,
|
||||||
* the scope is taken into account.
|
* the scope is taken into account.
|
||||||
*
|
*
|
||||||
* @param mixed $entity the entity may also implement HasScopeInterface
|
|
||||||
*
|
|
||||||
* @return bool true if the user has access
|
* @return bool true if the user has access
|
||||||
*/
|
*/
|
||||||
public function userHasAccess(User $user, mixed $entity, string $attribute)
|
public function userHasAccess(User $user, mixed $entity, string $attribute)
|
||||||
|
@ -62,6 +62,7 @@ module.exports = function(encore, entries)
|
|||||||
buildCKEditor(encore);
|
buildCKEditor(encore);
|
||||||
|
|
||||||
// Modules entrypoints
|
// Modules entrypoints
|
||||||
|
encore.addEntry('mod_collection', __dirname + '/Resources/public/module/collection/index.ts');
|
||||||
encore.addEntry('mod_forkawesome', __dirname + '/Resources/public/module/forkawesome/index.js');
|
encore.addEntry('mod_forkawesome', __dirname + '/Resources/public/module/forkawesome/index.js');
|
||||||
encore.addEntry('mod_bootstrap', __dirname + '/Resources/public/module/bootstrap/index.js');
|
encore.addEntry('mod_bootstrap', __dirname + '/Resources/public/module/bootstrap/index.js');
|
||||||
encore.addEntry('mod_ckeditor5', __dirname + '/Resources/public/module/ckeditor5/index.js');
|
encore.addEntry('mod_ckeditor5', __dirname + '/Resources/public/module/ckeditor5/index.js');
|
||||||
|
@ -11,6 +11,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Chill\PersonBundle\Controller;
|
namespace Chill\PersonBundle\Controller;
|
||||||
|
|
||||||
|
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectNormalizer;
|
||||||
use Chill\MainBundle\Pagination\PaginatorFactory;
|
use Chill\MainBundle\Pagination\PaginatorFactory;
|
||||||
use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
|
use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
|
||||||
use Chill\MainBundle\Templating\Listing\FilterOrderHelperFactoryInterface;
|
use Chill\MainBundle\Templating\Listing\FilterOrderHelperFactoryInterface;
|
||||||
@ -134,7 +135,7 @@ final class AccompanyingCourseWorkController extends AbstractController
|
|||||||
{
|
{
|
||||||
$this->denyAccessUnlessGranted(AccompanyingPeriodWorkVoter::UPDATE, $work);
|
$this->denyAccessUnlessGranted(AccompanyingPeriodWorkVoter::UPDATE, $work);
|
||||||
|
|
||||||
$json = $this->serializer->normalize($work, 'json', ['groups' => ['read']]);
|
$json = $this->serializer->normalize($work, 'json', ['groups' => ['read', StoredObjectNormalizer::ADD_DAV_EDIT_LINK_CONTEXT]]);
|
||||||
|
|
||||||
return $this->render('@ChillPerson/AccompanyingCourseWork/edit.html.twig', [
|
return $this->render('@ChillPerson/AccompanyingCourseWork/edit.html.twig', [
|
||||||
'accompanyingCourse' => $work->getAccompanyingPeriod(),
|
'accompanyingCourse' => $work->getAccompanyingPeriod(),
|
||||||
|
@ -788,7 +788,7 @@ class AccompanyingPeriod implements
|
|||||||
if (self::STEP_DRAFT === $this->getStep()) {
|
if (self::STEP_DRAFT === $this->getStep()) {
|
||||||
return [[self::STEP_DRAFT]];
|
return [[self::STEP_DRAFT]];
|
||||||
}
|
}
|
||||||
if (str_starts_with($this->getStep(), 'CONFIRM')) {
|
if (str_starts_with((string) $this->getStep(), 'CONFIRM')) {
|
||||||
return [[self::STEP_DRAFT, self::STEP_CONFIRMED]];
|
return [[self::STEP_DRAFT, self::STEP_CONFIRMED]];
|
||||||
}
|
}
|
||||||
if (self::STEP_CLOSED === $this->getStep()) {
|
if (self::STEP_CLOSED === $this->getStep()) {
|
||||||
|
@ -1787,10 +1787,7 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function setSpokenLanguages(mixed $spokenLanguages): self
|
||||||
* @param Collection $spokenLanguages
|
|
||||||
*/
|
|
||||||
public function setSpokenLanguages($spokenLanguages): self
|
|
||||||
{
|
{
|
||||||
$this->spokenLanguages = $spokenLanguages;
|
$this->spokenLanguages = $spokenLanguages;
|
||||||
|
|
||||||
|
@ -112,8 +112,8 @@ final readonly class PersonACLAwareRepository implements PersonACLAwareRepositor
|
|||||||
$andWhereSearchClause = [];
|
$andWhereSearchClause = [];
|
||||||
$andWhereSearchClauseArgs = [];
|
$andWhereSearchClauseArgs = [];
|
||||||
|
|
||||||
if ('' !== trim($default)) {
|
if ('' !== trim((string) $default)) {
|
||||||
foreach (\explode(' ', $default) as $str) {
|
foreach (\explode(' ', (string) $default) as $str) {
|
||||||
if ('' === trim($str)) {
|
if ('' === trim($str)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
@ -135,6 +135,8 @@
|
|||||||
:filename="d.title"
|
:filename="d.title"
|
||||||
:can-edit="true"
|
:can-edit="true"
|
||||||
:execute-before-leave="submitBeforeLeaveToEditor"
|
:execute-before-leave="submitBeforeLeaveToEditor"
|
||||||
|
:davLink="d.storedObject._links?.dav_link.href"
|
||||||
|
:davLinkExpiration="d.storedObject._links?.dav_link.expiration"
|
||||||
@on-stored-object-status-change="onStatusDocumentChanged"
|
@on-stored-object-status-change="onStatusDocumentChanged"
|
||||||
></document-action-buttons-group>
|
></document-action-buttons-group>
|
||||||
</li>
|
</li>
|
||||||
|
@ -686,20 +686,9 @@ class ThirdParty implements TrackCreationInterface, TrackUpdateInterface, \Strin
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function setCenters(Collection $centers): self
|
||||||
* @return $this
|
|
||||||
*/
|
|
||||||
public function setCenters(Collection $centers)
|
|
||||||
{
|
{
|
||||||
foreach ($centers as $center) {
|
$this->centers = $centers;
|
||||||
$this->addCenter($center);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->centers as $center) {
|
|
||||||
if (false === $centers->contains($center)) {
|
|
||||||
$this->removeCenter($center);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
16
utils/http/docstore/dav.http
Normal file
16
utils/http/docstore/dav.http
Normal 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 }}/
|
||||||
|
|
6
utils/http/docstore/http-client.env.json
Normal file
6
utils/http/docstore/http-client.env.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"dev": {
|
||||||
|
"host": "localhost:8001",
|
||||||
|
"uuid": "0bf3b8e7-b25b-4227-aae9-a3263af0766f"
|
||||||
|
}
|
||||||
|
}
|
@ -17,7 +17,7 @@ use Chill\MainBundle\Export\ExportInterface;
|
|||||||
use Chill\MainBundle\Export\FilterInterface;
|
use Chill\MainBundle\Export\FilterInterface;
|
||||||
use Chill\MainBundle\Export\ListInterface;
|
use Chill\MainBundle\Export\ListInterface;
|
||||||
use PhpParser\Node;
|
use PhpParser\Node;
|
||||||
use Rector\Core\Rector\AbstractRector;
|
use Rector\Rector\AbstractRector;
|
||||||
use Rector\Symfony\NodeAnalyzer\ClassAnalyzer;
|
use Rector\Symfony\NodeAnalyzer\ClassAnalyzer;
|
||||||
use Symplify\RuleDocGenerator\ValueObject\RuleDefinition;
|
use Symplify\RuleDocGenerator\ValueObject\RuleDefinition;
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user