Merge remote-tracking branch 'origin/master' into upgrade-sf5

This commit is contained in:
Julien Fastré 2024-05-28 14:16:01 +02:00
commit 84f515d451
Signed by: julienfastre
GPG Key ID: BDE2190974723FCB
95 changed files with 3191 additions and 539 deletions

3
.changes/v2.18.2.md Normal file
View File

@ -0,0 +1,3 @@
## v2.18.2 - 2024-04-12
### Fixed
* ([#250](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/250)) Postal codes import : fix the source URL and the keys to handle each record

20
.changes/v2.19.0.md Normal file
View File

@ -0,0 +1,20 @@
## v2.19.0 - 2024-05-14
### Feature
* ([#197](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/197)) Make the script which subscribe to microsoft calendars changes more tolerant to errors or missing configuration on the microsoft side
* ([#276](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/276)) Take closing date into account when computing the geographical unit on accompanying period. When a person moved after an accompanying period is closed, the date of closing accompanying period is took into account if it is earlier than the date given by the user.
### Fixed
* ([#270](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/270)) Fix broken link in homepage when a evaluation from a closed acc period was present in the homepage widget
* ([#275](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/275)) Allow the filter "filter accompanying period by geographical unit" to take period's location on address into account
### UX
* Form for document generation moved to the top of document list page
* ([#266](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/266)) Event bundle: adjust certain graphical issues for better user experience
### Traduction francophone des principaux changements
- script de synchronisation des agendas de microsoft Outlook: le script est plus tolérant aux erreurs de configuration côté serveur (manque de droit d'accès);
- dans les statistiques sur les parcours d'accompagnements, regroupement et filtre par unité géographique: lorsque la date de prise en compte de l'adresse est postérieure à la fermeture du parcours, c'est la date de fermeture du parcours qui est prise en compte (cela permet de tenir compte de la localisation de l'usager au moment de la fermeture dans le cas où celui-ci aurait déménagé par la suite);
- sur la page d'accueil, il n'y a plus de rappel pour les évaluations pour les parcours cloturés;
- correction du filtre "filtrer par zone géographique"
- répétition du bouton pour générer un document en haut de la page "liste des documents", quand il y a plus de cinq documents;
- module événement: améliorerations graphiques

View File

@ -6,6 +6,31 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
and is generated by [Changie](https://github.com/miniscruff/changie). and is generated by [Changie](https://github.com/miniscruff/changie).
## v2.19.0 - 2024-05-14
### Feature
* ([#197](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/197)) Make the script which subscribe to microsoft calendars changes more tolerant to errors or missing configuration on the microsoft side
* ([#276](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/276)) Take closing date into account when computing the geographical unit on accompanying period. When a person moved after an accompanying period is closed, the date of closing accompanying period is took into account if it is earlier than the date given by the user.
### Fixed
* ([#270](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/270)) Fix broken link in homepage when a evaluation from a closed acc period was present in the homepage widget
* ([#275](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/275)) Allow the filter "filter accompanying period by geographical unit" to take period's location on address into account
### UX
* Form for document generation moved to the top of document list page
* ([#266](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/266)) Event bundle: adjust certain graphical issues for better user experience
### Traduction francophone des principaux changements
- script de synchronisation des agendas de microsoft Outlook: le script est plus tolérant aux erreurs de configuration côté serveur (manque de droit d'accès);
- dans les statistiques sur les parcours d'accompagnements, regroupement et filtre par unité géographique: lorsque la date de prise en compte de l'adresse est postérieure à la fermeture du parcours, c'est la date de fermeture du parcours qui est prise en compte (cela permet de tenir compte de la localisation de l'usager au moment de la fermeture dans le cas où celui-ci aurait déménagé par la suite);
- sur la page d'accueil, il n'y a plus de rappel pour les évaluations pour les parcours cloturés;
- correction du filtre "filtrer par zone géographique"
- répétition du bouton pour générer un document en haut de la page "liste des documents", quand il y a plus de cinq documents;
- module événement: améliorerations graphiques
## v2.18.2 - 2024-04-12
### Fixed
* ([#250](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/250)) Postal codes import : fix the source URL and the keys to handle each record
## v2.18.1 - 2024-03-26 ## v2.18.1 - 2024-03-26
### Fixed ### Fixed
* Fix layout issue in document generation for admin (minor) * Fix layout issue in document generation for admin (minor)

View File

@ -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": "*",
@ -92,7 +93,7 @@
"phpstan/phpstan-deprecation-rules": "^1.1", "phpstan/phpstan-deprecation-rules": "^1.1",
"phpstan/phpstan-strict-rules": "^1.0", "phpstan/phpstan-strict-rules": "^1.0",
"phpunit/phpunit": ">= 7.5", "phpunit/phpunit": ">= 7.5",
"rector/rector": "^1.0.0", "rector/rector": "^1.1.0",
"symfony/debug-bundle": "^5.4", "symfony/debug-bundle": "^5.4",
"symfony/dotenv": "^5.4", "symfony/dotenv": "^5.4",
"symfony/maker-bundle": "^1.20", "symfony/maker-bundle": "^1.20",

View File

@ -8,6 +8,16 @@ Chill can store a list of geolocated address references, which are used to sugge
Those addresses may be load from a dedicated source. Those addresses may be load from a dedicated source.
Countries
=========
In order to load addresses into the chill application we first have to make sure that a list of countries is present.
To import the countries run the following command.
.. code-block:: bash
bin/console chill:main:countries:populate
In France In France
========= =========

View 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

View File

@ -33,3 +33,5 @@ includes:
- phpstan-baseline-level-5.neon - phpstan-baseline-level-5.neon
- phpstan-deprecations-sf54.neon - phpstan-deprecations-sf54.neon
- phpstan-baseline-deprecations-doctrine-orm.neon - phpstan-baseline-deprecations-doctrine-orm.neon
- phpstan-baseline-2024-05.neon

View File

@ -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],
]); ]);
} }

View File

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

View File

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

View File

@ -51,8 +51,6 @@ final class MapAndSubscribeUserCalendarCommand extends Command
$limit = 50; $limit = 50;
$offset = 0; $offset = 0;
/** @var \DateInterval $interval the interval before the end of the expiration */
$interval = new \DateInterval('P1D');
$expiration = (new \DateTimeImmutable('now'))->add(new \DateInterval($input->getOption('subscription-duration'))); $expiration = (new \DateTimeImmutable('now'))->add(new \DateInterval($input->getOption('subscription-duration')));
$users = $this->userRepository->findAllAsArray('fr'); $users = $this->userRepository->findAllAsArray('fr');
$created = 0; $created = 0;
@ -95,7 +93,6 @@ final class MapAndSubscribeUserCalendarCommand extends Command
} catch (UserAbsenceSyncException $e) { } catch (UserAbsenceSyncException $e) {
$this->logger->error('could not sync user absence', ['userId' => $user->getId(), 'email' => $user->getEmail(), 'exception' => $e->getTraceAsString(), 'message' => $e->getMessage()]); $this->logger->error('could not sync user absence', ['userId' => $user->getId(), 'email' => $user->getEmail(), 'exception' => $e->getTraceAsString(), 'message' => $e->getMessage()]);
$output->writeln(sprintf('Could not sync user absence: id: %s and email: %s', $user->getId(), $user->getEmail())); $output->writeln(sprintf('Could not sync user absence: id: %s and email: %s', $user->getId(), $user->getEmail()));
throw $e;
} }
// we first try to renew an existing subscription, if any. // we first try to renew an existing subscription, if any.

View File

@ -49,15 +49,12 @@ 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(mixed $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

View File

@ -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)
{ {

View File

@ -0,0 +1,252 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Controller;
use Chill\DocStoreBundle\Dav\Request\PropfindRequestAnalyzer;
use Chill\DocStoreBundle\Dav\Response\DavResponse;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
/**
* Provide endpoint for editing a document on the desktop using dav.
*
* This controller implements the minimal required methods to edit a document on a desktop software (i.e. LibreOffice)
* and save the document online.
*
* To avoid to ask for a password, the endpoints are protected using a JWT access token, which is inside the
* URL. This avoid the DAV Client (LibreOffice) to keep an access token in query parameter or in some header (which
* they are not able to understand). The JWT Guard is adapted with a dedicated token extractor which is going to read
* the segments (separation of "/"): the first segment must be the string "dav", and the second one must be the JWT.
*/
final readonly class WebdavController
{
private PropfindRequestAnalyzer $requestAnalyzer;
public function __construct(
private \Twig\Environment $engine,
private StoredObjectManagerInterface $storedObjectManager,
private Security $security,
) {
$this->requestAnalyzer = new PropfindRequestAnalyzer();
}
/**
* @Route("/dav/{access_token}/get/{uuid}/", methods={"GET", "HEAD"}, name="chill_docstore_dav_directory_get")
*/
public function getDirectory(StoredObject $storedObject, string $access_token): Response
{
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
throw new AccessDeniedHttpException();
}
return new DavResponse(
$this->engine->render('@ChillDocStore/Webdav/directory.html.twig', [
'stored_object' => $storedObject,
'access_token' => $access_token,
])
);
}
/**
* @Route("/dav/{access_token}/get/{uuid}/", methods={"OPTIONS"})
*/
public function optionsDirectory(StoredObject $storedObject): Response
{
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
throw new AccessDeniedHttpException();
}
$response = (new DavResponse(''))
->setEtag($this->storedObjectManager->etag($storedObject))
;
// $response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT,PROPPATCH,COPY,MOVE,REPORT,PATCH,POST,TRACE']);
$response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT']);
return $response;
}
/**
* @Route("/dav/{access_token}/get/{uuid}/", methods={"PROPFIND"})
*/
public function propfindDirectory(StoredObject $storedObject, string $access_token, Request $request): Response
{
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
throw new AccessDeniedHttpException();
}
$depth = $request->headers->get('depth');
if ('0' !== $depth && '1' !== $depth) {
throw new BadRequestHttpException('only 1 and 0 are accepted for Depth header');
}
[$properties, $lastModified, $etag, $length] = $this->parseDavRequest($request->getContent(), $storedObject);
$response = new DavResponse(
$this->engine->render('@ChillDocStore/Webdav/directory_propfind.xml.twig', [
'stored_object' => $storedObject,
'properties' => $properties,
'last_modified' => $lastModified,
'etag' => $etag,
'content_length' => $length,
'depth' => (int) $depth,
'access_token' => $access_token,
]),
207
);
$response->headers->add([
'Content-Type' => 'text/xml',
]);
return $response;
}
/**
* @Route("/dav/{access_token}/get/{uuid}/d", name="chill_docstore_dav_document_get", methods={"GET"})
*/
public function getDocument(StoredObject $storedObject): Response
{
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
throw new AccessDeniedHttpException();
}
return (new DavResponse($this->storedObjectManager->read($storedObject)))
->setEtag($this->storedObjectManager->etag($storedObject));
}
/**
* @Route("/dav/{access_token}/get/{uuid}/d", methods={"HEAD"})
*/
public function headDocument(StoredObject $storedObject): Response
{
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
throw new AccessDeniedHttpException();
}
$response = new DavResponse('');
$response->headers->add(
[
'Content-Length' => $this->storedObjectManager->getContentLength($storedObject),
'Content-Type' => $storedObject->getType(),
'Etag' => $this->storedObjectManager->etag($storedObject),
]
);
return $response;
}
/**
* @Route("/dav/{access_token}/get/{uuid}/d", methods={"OPTIONS"})
*/
public function optionsDocument(StoredObject $storedObject): Response
{
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
throw new AccessDeniedHttpException();
}
$response = (new DavResponse(''))
->setEtag($this->storedObjectManager->etag($storedObject))
;
$response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT']);
return $response;
}
/**
* @Route("/dav/{access_token}/get/{uuid}/d", methods={"PROPFIND"})
*/
public function propfindDocument(StoredObject $storedObject, string $access_token, Request $request): Response
{
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
throw new AccessDeniedHttpException();
}
[$properties, $lastModified, $etag, $length] = $this->parseDavRequest($request->getContent(), $storedObject);
$response = new DavResponse(
$this->engine->render(
'@ChillDocStore/Webdav/doc_props.xml.twig',
[
'stored_object' => $storedObject,
'properties' => $properties,
'etag' => $etag,
'last_modified' => $lastModified,
'content_length' => $length,
'access_token' => $access_token,
]
),
207
);
$response
->headers->add([
'Content-Type' => 'text/xml',
]);
return $response;
}
/**
* @Route("/dav/{access_token}/get/{uuid}/d", methods={"PUT"})
*/
public function putDocument(StoredObject $storedObject, Request $request): Response
{
if (!$this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $storedObject)) {
throw new AccessDeniedHttpException();
}
$this->storedObjectManager->write($storedObject, $request->getContent());
return new DavResponse('', Response::HTTP_NO_CONTENT);
}
/**
* @return array{0: array, 1: \DateTimeInterface, 2: string, 3: int} properties, lastModified, etag, length
*/
private function parseDavRequest(string $content, StoredObject $storedObject): array
{
$xml = new \DOMDocument();
$xml->loadXML($content);
$properties = $this->requestAnalyzer->getRequestedProperties($xml);
$requested = array_keys(array_filter($properties, fn ($item) => true === $item));
if (
in_array('lastModified', $requested, true)
|| in_array('etag', $requested, true)
) {
$lastModified = $this->storedObjectManager->getLastModified($storedObject);
$etag = $this->storedObjectManager->etag($storedObject);
}
if (in_array('contentLength', $requested, true)) {
$length = $this->storedObjectManager->getContentLength($storedObject);
}
return [
$properties,
$lastModified ?? null,
$etag ?? null,
$length ?? null,
];
}
}

View File

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

View File

@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Dav\Request;
use Chill\DocStoreBundle\Dav\Exception\ParseRequestException;
/**
* @phpstan-type davProperties array{resourceType: bool, contentType: bool, lastModified: bool, creationDate: bool, contentLength: bool, etag: bool, supportedLock: bool, unknowns: list<array{xmlns: string, prop: string}>}
*/
class PropfindRequestAnalyzer
{
private const KNOWN_PROPS = [
'resourceType',
'contentType',
'lastModified',
'creationDate',
'contentLength',
'etag',
'supportedLock',
];
/**
* @return davProperties
*/
public function getRequestedProperties(\DOMDocument $request): array
{
$propfinds = $request->getElementsByTagNameNS('DAV:', 'propfind');
if (0 === $propfinds->count()) {
throw new ParseRequestException('any propfind element found');
}
if (1 < $propfinds->count()) {
throw new ParseRequestException('too much propfind element found');
}
$propfind = $propfinds->item(0);
if (0 === $propfind->childNodes->count()) {
throw new ParseRequestException('no element under propfind');
}
$unknows = [];
$props = [];
foreach ($propfind->childNodes->getIterator() as $prop) {
/** @var \DOMNode $prop */
if (XML_ELEMENT_NODE !== $prop->nodeType) {
continue;
}
if ('propname' === $prop->nodeName) {
return $this->baseProps(true);
}
foreach ($prop->childNodes->getIterator() as $getProp) {
if (XML_ELEMENT_NODE !== $getProp->nodeType) {
continue;
}
if ('DAV:' !== $getProp->lookupNamespaceURI(null)) {
$unknows[] = ['xmlns' => $getProp->lookupNamespaceURI(null), 'prop' => $getProp->nodeName];
continue;
}
$props[] = match ($getProp->nodeName) {
'resourcetype' => 'resourceType',
'getcontenttype' => 'contentType',
'getlastmodified' => 'lastModified',
default => '',
};
}
}
$props = array_filter(array_values($props), fn (string $item) => '' !== $item);
return [...$this->baseProps(false), ...array_combine($props, array_fill(0, count($props), true)), 'unknowns' => $unknows];
}
/**
* @return davProperties
*/
private function baseProps(bool $default = false): array
{
return
[
...array_combine(
self::KNOWN_PROPS,
array_fill(0, count(self::KNOWN_PROPS), $default)
),
'unknowns' => [],
];
}
}

View File

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

View File

@ -39,15 +39,15 @@ class StoredObject implements Document, TrackCreationInterface
final public const STATUS_PENDING = 'pending'; final public const STATUS_PENDING = 'pending';
final public const STATUS_FAILURE = 'failure'; final public const STATUS_FAILURE = 'failure';
#[Serializer\Groups(['read', 'write'])] #[Serializer\Groups(['write'])]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'datas')] #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'datas')]
private array $datas = []; private array $datas = [];
#[Serializer\Groups(['read', 'write'])] #[Serializer\Groups(['write'])]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT)] #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT)]
private string $filename = ''; private string $filename = '';
#[Serializer\Groups(['read', 'write'])] #[Serializer\Groups(['write'])]
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)] #[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)]
@ -56,23 +56,23 @@ class StoredObject implements Document, TrackCreationInterface
/** /**
* @var int[] * @var int[]
*/ */
#[Serializer\Groups(['read', 'write'])] #[Serializer\Groups(['write'])]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'iv')] #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'iv')]
private array $iv = []; private array $iv = [];
#[Serializer\Groups(['read', 'write'])] #[Serializer\Groups(['write'])]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'key')] #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'key')]
private array $keyInfos = []; private array $keyInfos = [];
#[Serializer\Groups(['read', 'write'])] #[Serializer\Groups(['write'])]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, name: 'title')] #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, name: 'title')]
private string $title = ''; private string $title = '';
#[Serializer\Groups(['read', 'write'])] #[Serializer\Groups(['write'])]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, name: 'type', options: ['default' => ''])] #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, name: 'type', options: ['default' => ''])]
private string $type = ''; private string $type = '';
#[Serializer\Groups(['read', 'write'])] #[Serializer\Groups(['write'])]
#[ORM\Column(type: 'uuid', unique: true)] #[ORM\Column(type: 'uuid', unique: true)]
private UuidInterface $uuid; private UuidInterface $uuid;
@ -98,7 +98,7 @@ class StoredObject implements Document, TrackCreationInterface
* @param StoredObject::STATUS_* $status * @param StoredObject::STATUS_* $status
*/ */
public function __construct( public function __construct(
#[Serializer\Groups(['read'])] #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, options: ['default' => 'ready'])] #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, options: ['default' => 'ready'])]
private string $status = 'ready' private string $status = 'ready'
) { ) {
$this->uuid = Uuid::uuid4(); $this->uuid = Uuid::uuid4();
@ -114,7 +114,7 @@ class StoredObject implements Document, TrackCreationInterface
/** /**
* @deprecated * @deprecated
*/ */
#[Serializer\Groups(['read', 'write'])] #[Serializer\Groups(['write'])]
public function getCreationDate(): \DateTime public function getCreationDate(): \DateTime
{ {
if (null === $this->createdAt) { if (null === $this->createdAt) {

View File

@ -24,7 +24,7 @@ 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
{ {
public function __construct( public function __construct(
private readonly TranslatableStringHelperInterface $translatableStringHelper private readonly TranslatableStringHelperInterface $translatableStringHelper

View File

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

View File

@ -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());
}
}
}

View File

@ -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);
}
}

View File

@ -11,11 +11,10 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Form; namespace Chill\DocStoreBundle\Form;
use Chill\DocStoreBundle\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,9 +23,13 @@ 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(private readonly EntityManagerInterface $em) {} public function __construct(
private readonly StoredObjectDataTransformer $storedObjectDataTransformer,
private readonly StoredObjectDataMapper $storedObjectDataMapper,
) {
}
public function buildForm(FormBuilderInterface $builder, array $options) public function buildForm(FormBuilderInterface $builder, array $options)
{ {
@ -37,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)
@ -72,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;
}
} }

View File

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

View File

@ -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;

View File

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

View File

@ -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);

View File

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

View File

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

View File

@ -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,
}; };

View File

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

View File

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

View File

@ -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,

View File

@ -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]);
};

View File

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

View File

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

View File

@ -1,54 +1,62 @@
{% extends "@ChillPerson/AccompanyingCourse/layout.html.twig" %} {% extends "@ChillPerson/AccompanyingCourse/layout.html.twig" %} {% set
activeRouteKey = '' %} {% block title %}
{{ "Documents" }}
{% endblock %} {% block js %}
{{ parent() }}
{{ encore_entry_script_tags("mod_docgen_picktemplate") }}
{{ encore_entry_script_tags("mod_entity_workflow_pick") }}
{{ encore_entry_script_tags("mod_document_action_buttons_group") }}
{% endblock %} {% block css %}
{{ parent() }}
{{ encore_entry_script_tags("mod_docgen_picktemplate") }}
{{ encore_entry_link_tags("mod_entity_workflow_pick") }}
{{ encore_entry_link_tags("mod_document_action_buttons_group") }}
{% endblock %} {% block content %}
<div class="document-list">
<h1>{{ "Documents" }}</h1>
{% set activeRouteKey = '' %} {{ filter | chill_render_filter_order_helper }}
{% block title %}
{{ 'Documents' }}
{% endblock %}
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_docgen_picktemplate') }}
{{ encore_entry_script_tags('mod_entity_workflow_pick') }}
{{ encore_entry_script_tags('mod_document_action_buttons_group') }}
{% endblock %}
{% block css %}
{{ parent() }}
{{ encore_entry_script_tags('mod_docgen_picktemplate') }}
{{ encore_entry_link_tags('mod_entity_workflow_pick') }}
{{ encore_entry_link_tags('mod_document_action_buttons_group') }}
{% endblock %}
{% block content %}
<div class="document-list">
<h1>{{ 'Documents' }}</h1>
{{ filter|chill_render_filter_order_helper }}
{% if documents|length == 0 %}
<p class="chill-no-data-statement">{{ 'No documents'|trans }}</p>
{% else %}
<div class="flex-table chill-task-list">
{% for document in documents %}
{{ document|chill_generic_doc_render }}
{% endfor %}
</div>
{% endif %}
{{ chill_pagination(pagination) }}
<div data-docgen-template-picker="data-docgen-template-picker" data-entity-class="Chill\PersonBundle\Entity\AccompanyingPeriod" data-entity-id="{{ accompanyingCourse.id }}"></div>
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_CREATE', accompanyingCourse) %}
<ul class="record_actions sticky-form-buttons">
<li class="create">
<a href="{{ path('accompanying_course_document_new', {'course': accompanyingCourse.id}) }}" class="btn btn-create">
{{ 'Create'|trans }}
</a>
</li>
</ul>
{% endif %}
{% if documents|length > 5 %}
<div
data-docgen-template-picker="data-docgen-template-picker"
data-entity-class="Chill\PersonBundle\Entity\AccompanyingPeriod"
data-entity-id="{{ accompanyingCourse.id }}"
></div>
{% endif %} {% if documents|length == 0 %}
<p class="chill-no-data-statement">{{ "No documents" | trans }}</p>
{% else %}
<div class="flex-table chill-task-list">
{% for document in documents %}
{{ document | chill_generic_doc_render }}
{% endfor %}
</div> </div>
{% endif %}
{{ chill_pagination(pagination) }}
<div
data-docgen-template-picker="data-docgen-template-picker"
data-entity-class="Chill\PersonBundle\Entity\AccompanyingPeriod"
data-entity-id="{{ accompanyingCourse.id }}"
></div>
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_CREATE',
accompanyingCourse) %}
<ul class="record_actions sticky-form-buttons">
<li class="create">
<a
href="{{
path('accompanying_course_document_new', {
course: accompanyingCourse.id
})
}}"
class="btn btn-create"
>
{{ "Create" | trans }}
</a>
</li>
</ul>
{% endif %}
</div>
{% endblock %} {% endblock %}

View File

@ -1,74 +1,70 @@
{# {# * Copyright (C) 2018, Champs Libres Cooperative SCRLFS,
* Copyright (C) 2018, Champs Libres Cooperative SCRLFS, <http://www.champs-libres.coop> <http://www.champs-libres.coop> * * This program is free software: you can
* redistribute it and/or modify * it under the terms of the GNU Affero General
* This program is free software: you can redistribute it and/or modify Public License as * published by the Free Software Foundation, either version 3
* it under the terms of the GNU Affero General Public License as of the * License, or (at your option) any later version. * * This program is
* published by the Free Software Foundation, either version 3 of the distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY;
* License, or (at your option) any later version. without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the * GNU Affero General Public License for more
* This program is distributed in the hope that it will be useful, details. * * You should have received a copy of the GNU Affero General Public
* but WITHOUT ANY WARRANTY; without even the implied warranty of License * along with this program. If not, see <http://www.gnu.org/licenses/>.
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #} {% extends "@ChillPerson/Person/layout.html.twig" %} {% set activeRouteKey =
* GNU Affero General Public License for more details. '' %} {% import "@ChillDocStore/Macro/macro.html.twig" as m %} {% block title %}
* {{ 'Documents for %name%'|trans({ '%name%': person|chill_entity_render_string } ) }}
* You should have received a copy of the GNU Affero General Public License {% endblock %} {% block js %}
* along with this program. If not, see <http://www.gnu.org/licenses/>. {{ parent() }}
#} {{ encore_entry_script_tags("mod_docgen_picktemplate") }}
{{ encore_entry_script_tags("mod_entity_workflow_pick") }}
{% extends "@ChillPerson/Person/layout.html.twig" %} {{ encore_entry_script_tags("mod_document_action_buttons_group") }}
{% endblock %} {% block css %}
{% set activeRouteKey = '' %} {{ parent() }}
{{ encore_entry_link_tags("mod_docgen_picktemplate") }}
{% import "@ChillDocStore/Macro/macro.html.twig" as m %} {{ encore_entry_link_tags("mod_entity_workflow_pick") }}
{{ encore_entry_link_tags("mod_document_action_buttons_group") }}
{% block title %} {% endblock %} {% block content %}
{{ 'Documents for %name%'|trans({ '%name%': person|chill_entity_render_string } ) }}
{% endblock %}
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_docgen_picktemplate') }}
{{ encore_entry_script_tags('mod_entity_workflow_pick') }}
{{ encore_entry_script_tags('mod_document_action_buttons_group') }}
{% endblock %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_docgen_picktemplate') }}
{{ encore_entry_link_tags('mod_entity_workflow_pick') }}
{{ encore_entry_link_tags('mod_document_action_buttons_group') }}
{% endblock %}
{% block content %}
<div class="col-md-10 col-xxl"> <div class="col-md-10 col-xxl">
<h1>{{ 'Documents for %name%'|trans({ '%name%': person|chill_entity_render_string } ) }}</h1> <h1>
{{ 'Documents for %name%'|trans({ '%name%': person|chill_entity_render_string } ) }}
</h1>
{{ filter|chill_render_filter_order_helper }} {{ filter | chill_render_filter_order_helper }}
{% if documents|length == 0 %} {% if documents|length > 5 %}
<p class="chill-no-data-statement">{{ 'No documents'|trans }}</p> <div
data-docgen-template-picker="data-docgen-template-picker"
data-entity-class="Chill\PersonBundle\Entity\Person"
data-entity-id="{{ person.id }}"
></div>
{% endif %} {% if documents|length == 0 %}
<p class="chill-no-data-statement">{{ "No documents" | trans }}</p>
{% else %} {% else %}
<div class="flex-table chill-task-list"> <div class="flex-table chill-task-list">
{% for document in documents %} {% for document in documents %}
{{ document|chill_generic_doc_render }} {{ document | chill_generic_doc_render }}
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
{{ chill_pagination(pagination) }} {{ chill_pagination(pagination) }}
<div data-docgen-template-picker="data-docgen-template-picker" data-entity-class="Chill\PersonBundle\Entity\Person" data-entity-id="{{ person.id }}"></div> <div
data-docgen-template-picker="data-docgen-template-picker"
{% if is_granted('CHILL_PERSON_DOCUMENT_CREATE', person) %} data-entity-class="Chill\PersonBundle\Entity\Person"
<ul class="record_actions sticky-form-buttons"> data-entity-id="{{ person.id }}"
<li class="create"> ></div>
<a href="{{ path('person_document_new', {'person': person.id}) }}" class="btn btn-create">
{{ 'Create new document' | trans }}
</a>
</li>
</ul>
{% endif %}
{% if is_granted('CHILL_PERSON_DOCUMENT_CREATE', person) %}
<ul class="record_actions sticky-form-buttons">
<li class="create">
<a
href="{{ path('person_document_new', { person: person.id }) }}"
class="btn btn-create"
>
{{ "Create new document" | trans }}
</a>
</li>
</ul>
{% endif %}
</div> </div>
{% endblock %} {% endblock %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Security\Authorization;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Guard\DavTokenAuthenticationEventSubscriber;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* Voter for the content of a stored object.
*
* This is in use to allow or disallow the edition of the stored object's content.
*/
class StoredObjectVoter extends Voter
{
protected function supports($attribute, $subject): bool
{
return StoredObjectRoleEnum::tryFrom($attribute) instanceof StoredObjectRoleEnum
&& $subject instanceof StoredObject;
}
protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
{
/** @var StoredObject $subject */
if (
!$token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)
|| $subject->getUuid()->toString() !== $token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)
) {
return false;
}
if (!$token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)) {
return false;
}
$askedRole = StoredObjectRoleEnum::from($attribute);
$tokenRoleAuthorization =
$token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS);
return match ($askedRole) {
StoredObjectRoleEnum::SEE => StoredObjectRoleEnum::EDIT === $tokenRoleAuthorization || StoredObjectRoleEnum::SEE === $tokenRoleAuthorization,
StoredObjectRoleEnum::EDIT => StoredObjectRoleEnum::EDIT === $tokenRoleAuthorization
};
}
}

View File

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Security\Guard;
use Lexik\Bundle\JWTAuthenticationBundle\TokenExtractor\TokenExtractorInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
/**
* Extract the JWT Token from the segment of the dav endpoints.
*
* A segment is a separation inside the string, using the character "/".
*
* For recognizing the JWT, the first segment must be "dav", and the second one must be
* the JWT endpoint.
*/
final readonly class DavOnUrlTokenExtractor implements TokenExtractorInterface
{
public function __construct(
private LoggerInterface $logger,
) {
}
public function extract(Request $request): false|string
{
$uri = $request->getRequestUri();
$segments = array_values(
array_filter(
explode('/', $uri),
fn ($item) => '' !== trim($item)
)
);
if (2 > count($segments)) {
$this->logger->info('not enough segment for parsing URL');
return false;
}
if ('dav' !== $segments[0]) {
$this->logger->info('the first segment of the url must be DAV');
return false;
}
return $segments[1];
}
}

View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Security\Guard;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTAuthenticatedEvent;
use Lexik\Bundle\JWTAuthenticationBundle\Events;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Store some data from the JWT's payload inside the token's attributes.
*/
class DavTokenAuthenticationEventSubscriber implements EventSubscriberInterface
{
final public const STORED_OBJECT = 'stored_object';
final public const ACTIONS = 'stored_objects_actions';
public static function getSubscribedEvents(): array
{
return [
Events::JWT_AUTHENTICATED => ['onJWTAuthenticated', 0],
];
}
public function onJWTAuthenticated(JWTAuthenticatedEvent $event): void
{
$payload = $event->getPayload();
if (!(array_key_exists('dav', $payload) && 1 === $payload['dav'])) {
return;
}
$token = $event->getToken();
$token->setAttribute(self::ACTIONS, match ($payload['e']) {
0 => StoredObjectRoleEnum::SEE,
1 => StoredObjectRoleEnum::EDIT,
default => throw new \UnexpectedValueException('unsupported value for e parameter')
});
$token->setAttribute(self::STORED_OBJECT, $payload['so']);
}
}

View File

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

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Security\Guard;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
/**
* Provide a JWT Token which will be valid for viewing or editing a document.
*/
interface JWTDavTokenProviderInterface
{
public function createToken(StoredObject $storedObject, StoredObjectRoleEnum $roleEnum): string;
public function getTokenExpiration(string $tokenString): \DateTimeImmutable;
}

View File

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

View File

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

View File

@ -58,6 +58,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);
@ -159,6 +215,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 {

View File

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

View File

@ -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,7 +123,13 @@ 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,
) {
}
/** /**
* return true if the document is editable. * return true if the document is editable.
@ -130,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);
} }
/** /**
@ -142,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'),
]); ]);
} }

View File

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

View File

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

View File

@ -0,0 +1,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], []),
];
}
}

View File

@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\Security\Authorization;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter;
use Chill\DocStoreBundle\Security\Guard\DavTokenAuthenticationEventSubscriber;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
/**
* @internal
*
* @coversNothing
*/
class StoredObjectVoterTest extends TestCase
{
use ProphecyTrait;
/**
* @dataProvider provideDataVote
*/
public function testVote(TokenInterface $token, ?object $subject, string $attribute, mixed $expected): void
{
$voter = new StoredObjectVoter();
self::assertEquals($expected, $voter->vote($token, $subject, [$attribute]));
}
public function provideDataVote(): iterable
{
yield [
$this->buildToken(StoredObjectRoleEnum::EDIT, new StoredObject()),
new \stdClass(),
'SOMETHING',
VoterInterface::ACCESS_ABSTAIN,
];
yield [
$this->buildToken(StoredObjectRoleEnum::EDIT, $so = new StoredObject()),
$so,
'SOMETHING',
VoterInterface::ACCESS_ABSTAIN,
];
yield [
$this->buildToken(StoredObjectRoleEnum::EDIT, $so = new StoredObject()),
$so,
StoredObjectRoleEnum::SEE->value,
VoterInterface::ACCESS_GRANTED,
];
yield [
$this->buildToken(StoredObjectRoleEnum::EDIT, $so = new StoredObject()),
$so,
StoredObjectRoleEnum::EDIT->value,
VoterInterface::ACCESS_GRANTED,
];
yield [
$this->buildToken(StoredObjectRoleEnum::SEE, $so = new StoredObject()),
$so,
StoredObjectRoleEnum::EDIT->value,
VoterInterface::ACCESS_DENIED,
];
yield [
$this->buildToken(StoredObjectRoleEnum::SEE, $so = new StoredObject()),
$so,
StoredObjectRoleEnum::SEE->value,
VoterInterface::ACCESS_GRANTED,
];
yield [
$this->buildToken(null, null),
new StoredObject(),
StoredObjectRoleEnum::SEE->value,
VoterInterface::ACCESS_DENIED,
];
yield [
$this->buildToken(null, null),
new StoredObject(),
StoredObjectRoleEnum::SEE->value,
VoterInterface::ACCESS_DENIED,
];
}
private function buildToken(?StoredObjectRoleEnum $storedObjectRoleEnum = null, ?StoredObject $storedObject = null): TokenInterface
{
$token = $this->prophesize(TokenInterface::class);
if (null !== $storedObjectRoleEnum) {
$token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willReturn(true);
$token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willReturn($storedObjectRoleEnum);
} else {
$token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willReturn(false);
$token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willThrow(new \InvalidArgumentException());
}
if (null !== $storedObject) {
$token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willReturn(true);
$token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willReturn($storedObject->getUuid()->toString());
} else {
$token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willReturn(false);
$token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willThrow(new \InvalidArgumentException());
}
return $token->reveal();
}
}

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\Security\Guard;
use Chill\DocStoreBundle\Security\Guard\DavOnUrlTokenExtractor;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\NullLogger;
use Symfony\Component\HttpFoundation\Request;
/**
* @internal
*
* @coversNothing
*/
class DavOnUrlTokenExtractorTest extends TestCase
{
use ProphecyTrait;
/**
* @dataProvider provideDataUri
*/
public function testExtract(string $uri, false|string $expected): void
{
$request = $this->prophesize(Request::class);
$request->getRequestUri()->willReturn($uri);
$extractor = new DavOnUrlTokenExtractor(new NullLogger());
$actual = $extractor->extract($request->reveal());
self::assertEquals($expected, $actual);
}
/**
* @phpstan-pure
*/
public static function provideDataUri(): iterable
{
yield ['/dav/123456789/get/d07d2230-5326-11ee-8fd4-93696acf5ea1/d', '123456789'];
yield ['/dav/123456789', '123456789'];
yield ['/not-dav/123456978', false];
yield ['/dav', false];
yield ['/', false];
}
}

View File

@ -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');
}; };

View File

@ -15,6 +15,11 @@ services:
Chill\DocStoreBundle\Workflow\: Chill\DocStoreBundle\Workflow\:
resource: './../Workflow/' resource: './../Workflow/'
Chill\DocStoreBundle\Security\:
resource: './../Security'
autoconfigure: true
autowire: true
Chill\DocStoreBundle\Serializer\Normalizer\: Chill\DocStoreBundle\Serializer\Normalizer\:
resource: '../Serializer/Normalizer/' resource: '../Serializer/Normalizer/'
tags: tags:

View File

@ -13,7 +13,7 @@ Update document: Modifier le document
Edit attributes: Modifier les propriétés du document Edit attributes: Modifier les propriétés du document
Existing document: Document existant Existing document: Document existant
No document to download: Aucun document à télécharger No document to download: Aucun document à télécharger
'Choose a document category': Choisissez une catégorie de document "Choose a document category": Choisissez une catégorie de document
No document found: Aucun document trouvé No document found: Aucun document trouvé
The document is successfully registered: Le document est enregistré The document is successfully registered: Le document est enregistré
The document is successfully updated: Le document est mis à jour The document is successfully updated: Le document est mis à jour
@ -36,7 +36,6 @@ Delete document ?: Supprimer le document ?
Are you sure you want to remove this document ?: Êtes-vous sûr·e de vouloir supprimer ce document ? Are you sure you want to remove this document ?: Êtes-vous sûr·e de vouloir supprimer ce document ?
The document is successfully removed: Le document a été supprimé The document is successfully removed: Le document a été supprimé
# dropzone upload # dropzone upload
File too big: Fichier trop volumineux File too big: Fichier trop volumineux
Drop your file or click here: Cliquez ici ou faites glissez votre nouveau fichier dans cette zone Drop your file or click here: Cliquez ici ou faites glissez votre nouveau fichier dans cette zone
@ -47,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

View File

@ -201,8 +201,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)

View File

@ -201,8 +201,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)

View File

@ -201,8 +201,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)

View File

@ -18,9 +18,9 @@ use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Form\Type\ChillCollectionType; use Chill\MainBundle\Form\Type\ChillCollectionType;
use Chill\MainBundle\Form\Type\ChillDateTimeType; use Chill\MainBundle\Form\Type\ChillDateTimeType;
use Chill\MainBundle\Form\Type\CommentType; use Chill\MainBundle\Form\Type\CommentType;
use Chill\MainBundle\Form\Type\PickUserDynamicType;
use Chill\MainBundle\Form\Type\PickUserLocationType; use Chill\MainBundle\Form\Type\PickUserLocationType;
use Chill\MainBundle\Form\Type\ScopePickerType; use Chill\MainBundle\Form\Type\ScopePickerType;
use Chill\MainBundle\Form\Type\UserPickerType;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType; use Symfony\Component\Form\Extension\Core\Type\MoneyType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
@ -45,14 +45,8 @@ class EventType extends AbstractType
'class' => '', 'class' => '',
], ],
]) ])
->add('moderator', UserPickerType::class, [ ->add('moderator', PickUserDynamicType::class, [
'center' => $options['center'], 'label' => 'Pick a moderator',
'role' => $options['role'],
'placeholder' => 'Pick a moderator',
'attr' => [
'class' => '',
],
'required' => false,
]) ])
->add('location', PickUserLocationType::class, [ ->add('location', PickUserLocationType::class, [
'label' => 'event.fields.location', 'label' => 'event.fields.location',

View File

@ -1,10 +1,14 @@
{% extends '@ChillEvent/layout.html.twig' %} {% extends '@ChillEvent/layout.html.twig' %} {% block js %}
{{ encore_entry_script_tags("mod_async_upload") }}
{{ encore_entry_script_tags("mod_pickentity_type") }}
{% block title 'Event edit'|trans %} {% endblock %} {% block css %}
{{ encore_entry_link_tags("mod_async_upload") }}
{{ encore_entry_link_tags("mod_pickentity_type") }}
{% block event_content -%} {% endblock %} {% block title 'Event edit'|trans %} {% block event_content -%}
<div class="col-10"> <div class="col-10">
<h1>{{ 'Event edit'|trans }}</h1> <h1>{{ "Event edit" | trans }}</h1>
{{ form_start(edit_form) }} {{ form_start(edit_form) }}
{{ form_errors(edit_form) }} {{ form_errors(edit_form) }}
@ -12,7 +16,7 @@
{{ form_row(edit_form.name) }} {{ form_row(edit_form.name) }}
{{ form_row(edit_form.date) }} {{ form_row(edit_form.date) }}
{{ form_row(edit_form.type, { 'label': 'Event type' }) }} {{ form_row(edit_form.type, { label: "Event type" }) }}
{{ form_row(edit_form.moderator) }} {{ form_row(edit_form.moderator) }}
{{ form_row(edit_form.location) }} {{ form_row(edit_form.location) }}
{{ form_row(edit_form.organizationCost) }} {{ form_row(edit_form.organizationCost) }}
@ -22,16 +26,22 @@
<ul class="record_actions sticky-form-buttons"> <ul class="record_actions sticky-form-buttons">
<li class="cancel"> <li class="cancel">
<a href="{{ chill_return_path_or('chill_event_event_list') }}" class="btn btn-cancel"> <a
{{ 'List of events'|trans|chill_return_path_label }} href="{{ chill_return_path_or('chill_event_event_list') }}"
class="btn btn-cancel"
>
{{ "List of events" | trans | chill_return_path_label }}
</a> </a>
</li> </li>
<li> <li>
{{ form_widget(edit_form.submit, { 'attr' : { 'class' : 'btn btn-update' } }) }} {{
form_widget(edit_form.submit, {
attr: { class: "btn btn-update" }
})
}}
</li> </li>
</ul> </ul>
{{ form_end(edit_form) }} {{ form_end(edit_form) }}
</div> </div>
{% endblock %} {% endblock %}

View File

@ -1,41 +1,41 @@
{% extends '@ChillEvent/layout.html.twig' %} {% extends '@ChillEvent/layout.html.twig' %} {% block js %}
{{ encore_entry_script_tags("mod_async_upload") }}
{{ encore_entry_script_tags("mod_pickentity_type") }}
{% block js %} {% endblock %} {% block css %}
{{ encore_entry_script_tags('mod_async_upload') }} {{ encore_entry_link_tags("mod_async_upload") }}
{% endblock %} {{ encore_entry_link_tags("mod_pickentity_type") }}
{% block css %} {% endblock %} {% block title 'Event creation'|trans %} {% block event_content
{{ encore_entry_link_tags('mod_async_upload') }} -%}
{% endblock %}
{% block title 'Event creation'|trans %}
{% block event_content -%}
<div class="col-10"> <div class="col-10">
<h1>{{ 'Event creation'|trans }}</h1> <h1>{{ "Event creation" | trans }}</h1>
{{ form_start(form) }} {{ form_start(form) }}
{{ form_errors(form) }} {{ form_errors(form) }}
{{ form_row(form.circle) }} {{ form_row(form.circle) }}
{{ form_row(form.name) }} {{ form_row(form.name) }}
{{ form_row(form.date) }} {{ form_row(form.date) }}
{{ form_row(form.type, { label: "Event type" }) }}
{{ form_row(form.type, { 'label': 'Event type' }) }}
{{ form_row(form.moderator) }} {{ form_row(form.moderator) }}
{{ form_row(form.location) }} {{ form_row(form.location) }}
{{ form_row(form.organizationCost) }} {{ form_row(form.organizationCost) }}
{{ form_row(form.comment) }} {{ form_row(form.comment) }}
{{ form_row(form.documents) }} {{ form_row(form.documents) }}
<ul class="record_actions sticky-form-buttons"> <ul class="record_actions sticky-form-buttons">
<li class="cancel"> <li class="cancel">
<a href="{{ path('chill_event_list_most_recent') }}" class="btn btn-cancel"> <a
{{ 'Back to the most recent events'|trans }} href="{{ path('chill_event_list_most_recent') }}"
class="btn btn-cancel"
>
{{ "Back to the most recent events" | trans }}
</a> </a>
</li> </li>
<li> <li>
{{ form_widget(form.submit, { 'attr' : { 'class' : 'btn btn-create' } }) }} {{
form_widget(form.submit, { attr: { class: "btn btn-create" } })
}}
</li> </li>
</ul> </ul>

View File

@ -1,92 +1,126 @@
{% extends '@ChillEvent/layout.html.twig' %} {% extends '@ChillEvent/layout.html.twig' %} {% block title 'Events'|trans %} {%
block js %}
{{ parent() }}
{{ encore_entry_script_tags("mod_pickentity_type") }}
{% endblock %} {% block css %}
{{ parent() }}
{{ encore_entry_link_tags("mod_pickentity_type") }}
{% endblock %} {% block content %}
<div class="col-10">
<h1>{{ block("title") }}</h1>
{% block title 'Events'|trans %} {{ filter | chill_render_filter_order_helper }}
{% block js %} {# {% if is_granted('CHILL_EVENT_CREATE') %} #}
{{ parent() }} <ul class="record_actions">
{{ encore_entry_script_tags('mod_pickentity_type') }} <li>
{% endblock %} <a
class="btn btn-create"
{% block css %} href="{{
{{ parent() }} chill_path_add_return_path(
{{ encore_entry_link_tags('mod_pickentity_type') }} 'chill_event__event_new_pickcenter'
{% endblock %} )
}}"
{% block content %} >{{ "Add an event" | trans }}</a
<h1>{{ block('title') }}</h1> >
</li>
{{ filter|chill_render_filter_order_helper }} </ul>
{# {% endif %} #} {% if events|length > 0 %}
{# {% if is_granted('CHILL_EVENT_CREATE') %} #} <div class="flex-table">
<ul class="record_actions"> {% for e in events %}
<li><a class="btn btn-create" href="{{ chill_path_add_return_path('chill_event__event_new_pickcenter') }}">{{ 'Add an event'|trans }}</a></li> <div class="item-bloc">
</ul> <div class="item-row">
{# {% endif %} #} <div class="item-col">
{% if events|length > 0 %} <div class="denomination h2">
<div class="flex-table"> {{ e.name }}
{% for e in events %}
<div class="item-bloc">
<div class="item-row">
<div class="item-col">
<div class="denomination h2">
{{ e.name }}
</div>
<p>{{ e.type.name|localize_translatable_string }}</p>
{% if e.moderator is not null %}
<p>{{ 'Moderator'|trans }}: {{ e.moderator|chill_entity_render_box }}</p>
{% endif %}
</div>
<div class="item-col">
<div class="container" style="text-align: right;">
<p>{{ e.date|format_datetime('medium', 'medium') }}</p>
<p>{{ 'count participations to this event'|trans({'count': e.participations|length}) }}</p>
</div>
</div>
</div> </div>
{% if e.participations|length > 0 %} <p>{{ e.type.name | localize_translatable_string }}</p>
<div class="item-row separator"> {% if e.moderator is not null %}
<strong>{{ 'Participations'|trans }}&nbsp;: </strong> <p>
{% for part in e.participations|slice(0, 20) %} {{ "Moderator" | trans }}:
{% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with { {{ e.moderator | chill_entity_render_box }}
targetEntity: { name: 'person', id: part.person.id }, </p>
action: 'show',
displayBadge: true,
buttonText: part.person|chill_entity_render_string,
isDead: part.person.deathdate is not null
} %}
{% endfor %}
{% if e.participations|length > 20 %}
{{ 'events.and_other_count_participants'|trans({'count': e.participations|length - 20}) }}
{% endif %}
</div>
{% endif %} {% endif %}
<div class="item-row"> </div>
<div class="item-col"> <div class="item-col">
{{ form_start(eventForms[e.id]) }} <div class="container" style="text-align: right">
{{ form_widget(eventForms[e.id].person_id) }} <p>{{ e.date|format_datetime('medium', 'medium') }}</p>
{{ form_end(eventForms[e.id]) }} <p>
</div> {{ 'count participations to this event'|trans({'count': e.participations|length}) }}
</div> </p>
<div class="item-row separator">
<div class="item-col item-meta">
</div>
<div class="item-col">
<ul class="record_actions">
{% if is_granted('CHILL_EVENT_UPDATE', e) %}
<li><a href="{{ chill_path_add_return_path('chill_event__event_delete', {'event_id': e.id}) }}" class="btn btn-delete"></a></li>
{% endif %}
{% if is_granted('CHILL_EVENT_UPDATE', e) %}
<li><a href="{{ chill_path_add_return_path('chill_event__event_edit', {'event_id': e.id}) }}" class="btn btn-edit"></a></li>
{% endif %}
<li><a href="{{ chill_path_add_return_path('chill_event__event_show', {'event_id': e.id}) }}" class="btn btn-show"></a></li>
</ul>
</div>
</div> </div>
</div> </div>
{% endfor %} </div>
{% if e.participations|length > 0 %}
<div class="item-row separator">
<strong>{{ "Participations" | trans }}&nbsp;: </strong>
{% for part in e.participations|slice(0, 20) %} {% include
'@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with {
targetEntity: { name: 'person', id: part.person.id }, action:
'show', displayBadge: true, buttonText:
part.person|chill_entity_render_string, isDead:
part.person.deathdate is not null } %} {% endfor %} {% if
e.participations|length > 20 %}
{{ 'events.and_other_count_participants'|trans({'count': e.participations|length - 20}) }}
{% endif %}
</div>
{% endif %}
<div class="item-row">
<div class="item-col">
{{ form_start(eventForms[e.id]) }}
{{ form_widget(eventForms[e.id].person_id) }}
{{ form_end(eventForms[e.id]) }}
</div>
</div>
<div class="item-row separator">
<div class="item-col item-meta"></div>
<div class="item-col">
<ul class="record_actions">
{% if is_granted('CHILL_EVENT_UPDATE', e) %}
<li>
<a
href="{{
chill_path_add_return_path(
'chill_event__event_delete',
{ event_id: e.id }
)
}}"
class="btn btn-delete"
></a>
</li>
{% endif %} {% if is_granted('CHILL_EVENT_UPDATE', e) %}
<li>
<a
href="{{
chill_path_add_return_path(
'chill_event__event_edit',
{ event_id: e.id }
)
}}"
class="btn btn-edit"
></a>
</li>
{% endif %}
<li>
<a
href="{{
chill_path_add_return_path(
'chill_event__event_show',
{ event_id: e.id }
)
}}"
class="btn btn-show"
></a>
</li>
</ul>
</div>
</div>
</div> </div>
{% endfor %}
</div>
{% endif %} {% endif %}
</div>
{{ chill_pagination(pagination) }} {{ chill_pagination(pagination) }}
{% endblock %} {% endblock %}

View File

@ -725,7 +725,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
* *

View File

@ -442,8 +442,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,

View File

@ -71,6 +71,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(

View File

@ -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(); }`
*/ */

View File

@ -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);
} }

View File

@ -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(); }`
*/ */

View File

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

View File

@ -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,
]); ]);
} }

View File

@ -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');

View File

@ -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]);
}
}
});

View File

@ -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]);
}
}
});

View File

@ -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 }}"
@ -176,7 +177,7 @@
</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 item')|trans }}</span> <span class="chill-no-data-statement">{{ form.vars.empty_collection_explain|default('No entities')|trans }}</span>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>

View File

@ -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') }}

View File

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

View File

@ -198,8 +198,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)

View File

@ -23,7 +23,7 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
*/ */
class PostalCodeFRFromOpenData class PostalCodeFRFromOpenData
{ {
private const CSV = 'https://datanova.laposte.fr/data-fair/api/v1/datasets/laposte-hexasmal/data-files/019HexaSmal.csv'; private const CSV = 'https://datanova.laposte.fr/data-fair/api/v1/datasets/laposte-hexasmal/metadata-attachments/base-officielle-codes-postaux.csv';
public function __construct(private readonly PostalCodeBaseImporter $baseImporter, private readonly HttpClientInterface $client, private readonly LoggerInterface $logger) {} public function __construct(private readonly PostalCodeBaseImporter $baseImporter, private readonly HttpClientInterface $client, private readonly LoggerInterface $logger) {}
@ -48,7 +48,7 @@ class PostalCodeFRFromOpenData
fseek($tmpfile, 0); fseek($tmpfile, 0);
$csv = Reader::createFromStream($tmpfile); $csv = Reader::createFromStream($tmpfile);
$csv->setDelimiter(';'); $csv->setDelimiter(',');
$csv->setHeaderOffset(0); $csv->setHeaderOffset(0);
foreach ($csv as $offset => $record) { foreach ($csv as $offset => $record) {
@ -63,23 +63,23 @@ class PostalCodeFRFromOpenData
private function handleRecord(array $record): void private function handleRecord(array $record): void
{ {
if ('' !== trim($record['coordonnees_geographiques'] ?? $record['coordonnees_gps'])) { if ('' !== trim((string) $record['_geopoint'])) {
[$lat, $lon] = array_map(static fn ($el) => (float) trim($el), explode(',', $record['coordonnees_geographiques'] ?? $record['coordonnees_gps'])); [$lat, $lon] = array_map(static fn ($el) => (float) trim($el), explode(',', (string) $record['_geopoint']));
} else { } else {
$lat = $lon = 0.0; $lat = $lon = 0.0;
} }
$ref = trim((string) $record['Code_commune_INSEE']); $ref = trim((string) $record['code_commune_insee']);
if (str_starts_with($ref, '987')) { if (str_starts_with($ref, '987')) {
// some differences in French Polynesia // some differences in French Polynesia
$ref .= '.'.trim((string) $record['Libellé_d_acheminement']); $ref .= '.'.trim((string) $record['libelle_d_acheminement']);
} }
$this->baseImporter->importCode( $this->baseImporter->importCode(
'FR', 'FR',
trim((string) $record['Libellé_d_acheminement']), trim((string) $record['libelle_d_acheminement']),
trim((string) $record['Code_postal']), trim((string) $record['code_postal']),
$ref, $ref,
'INSEE', 'INSEE',
$lat, $lat,

View File

@ -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');

View File

@ -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;
@ -115,7 +116,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(),

View File

@ -41,10 +41,10 @@ final readonly class GeographicalUnitStatAggregator implements AggregatorInterfa
$qb->andWhere( $qb->andWhere(
$qb->expr()->andX( $qb->expr()->andX(
'acp_geog_agg_location_history.startDate <= :acp_geog_aggregator_date', 'acp_geog_agg_location_history.startDate <= LEAST(:acp_geog_aggregator_date, acp.closingDate)',
$qb->expr()->orX( $qb->expr()->orX(
'acp_geog_agg_location_history.endDate IS NULL', 'acp_geog_agg_location_history.endDate IS NULL',
'acp_geog_agg_location_history.endDate > :acp_geog_aggregator_date' 'acp_geog_agg_location_history.endDate > LEAST(:acp_geog_aggregator_date, acp.closingDate)'
) )
) )
); );
@ -56,9 +56,9 @@ final readonly class GeographicalUnitStatAggregator implements AggregatorInterfa
Join::WITH, Join::WITH,
$qb->expr()->andX( $qb->expr()->andX(
'IDENTITY(acp_geog_agg_address_person_location.person) = IDENTITY(acp_geog_agg_location_history.personLocation)', 'IDENTITY(acp_geog_agg_address_person_location.person) = IDENTITY(acp_geog_agg_location_history.personLocation)',
'acp_geog_agg_address_person_location.validFrom <= :acp_geog_aggregator_date', 'acp_geog_agg_address_person_location.validFrom <= LEAST(:acp_geog_aggregator_date, acp.closingDate)',
$qb->expr()->orX( $qb->expr()->orX(
'acp_geog_agg_address_person_location.validTo > :acp_geog_aggregator_date', 'acp_geog_agg_address_person_location.validTo > LEAST(:acp_geog_aggregator_date, acp.closingDate)',
$qb->expr()->isNull('acp_geog_agg_address_person_location.validTo') $qb->expr()->isNull('acp_geog_agg_address_person_location.validTo')
) )
) )

View File

@ -33,7 +33,8 @@ use Symfony\Component\Form\FormBuilderInterface;
*/ */
class GeographicalUnitStatFilter implements FilterInterface class GeographicalUnitStatFilter implements FilterInterface
{ {
public function __construct(private readonly GeographicalUnitRepositoryInterface $geographicalUnitRepository, private readonly GeographicalUnitLayerRepositoryInterface $geographicalUnitLayerRepository, private readonly TranslatableStringHelperInterface $translatableStringHelper, private readonly RollingDateConverterInterface $rollingDateConverter) {} public function __construct(
private readonly GeographicalUnitRepositoryInterface $geographicalUnitRepository, private readonly GeographicalUnitLayerRepositoryInterface $geographicalUnitLayerRepository, private readonly TranslatableStringHelperInterface $translatableStringHelper, private readonly RollingDateConverterInterface $rollingDateConverter) {}
public function addRole(): ?string public function addRole(): ?string
{ {
@ -46,18 +47,19 @@ class GeographicalUnitStatFilter implements FilterInterface
'SELECT 'SELECT
1 1
FROM '.AccompanyingPeriod\AccompanyingPeriodLocationHistory::class.' acp_geog_filter_location_history FROM '.AccompanyingPeriod\AccompanyingPeriodLocationHistory::class.' acp_geog_filter_location_history
JOIN acp_geog_filter_location_history.period acp_geog_filter_location_history_period
LEFT JOIN '.PersonHouseholdAddress::class.' acp_geog_filter_address_person_location LEFT JOIN '.PersonHouseholdAddress::class.' acp_geog_filter_address_person_location
WITH IDENTITY(acp_geog_filter_location_history.personLocation) = IDENTITY(acp_geog_filter_address_person_location.person) WITH IDENTITY(acp_geog_filter_location_history.personLocation) = IDENTITY(acp_geog_filter_address_person_location.person)
AND
(acp_geog_filter_address_person_location.validFrom < LEAST(:acp_geog_filter_date, acp_geog_filter_location_history_period.closingDate) AND (
acp_geog_filter_address_person_location.validTo IS NULL OR acp_geog_filter_address_person_location.validTo > LEAST(:acp_geog_filter_date, acp_geog_filter_location_history_period.closingDate)
))
LEFT JOIN '.Address::class.' acp_geog_filter_address LEFT JOIN '.Address::class.' acp_geog_filter_address
WITH COALESCE(IDENTITY(acp_geog_filter_address_person_location.address), IDENTITY(acp_geog_filter_location_history.addressLocation)) = acp_geog_filter_address.id WITH COALESCE(IDENTITY(acp_geog_filter_address_person_location.address), IDENTITY(acp_geog_filter_location_history.addressLocation)) = acp_geog_filter_address.id
LEFT JOIN acp_geog_filter_address.geographicalUnits acp_geog_filter_units LEFT JOIN acp_geog_filter_address.geographicalUnits acp_geog_filter_units
WHERE WHERE
(acp_geog_filter_location_history.startDate <= :acp_geog_filter_date AND ( (acp_geog_filter_location_history.startDate <= LEAST(:acp_geog_filter_date, acp_geog_filter_location_history_period.closingDate) AND (
acp_geog_filter_location_history.endDate IS NULL OR acp_geog_filter_location_history.endDate > :acp_geog_filter_date acp_geog_filter_location_history.endDate IS NULL OR acp_geog_filter_location_history.endDate > LEAST(:acp_geog_filter_date, acp_geog_filter_location_history_period.closingDate)
))
AND
(acp_geog_filter_address_person_location.validFrom < :acp_geog_filter_date AND (
acp_geog_filter_address_person_location.validTo IS NULL OR acp_geog_filter_address_person_location.validTo > :acp_geog_filter_date
)) ))
AND acp_geog_filter_units IN (:acp_geog_filter_units) AND acp_geog_filter_units IN (:acp_geog_filter_units)
AND acp_geog_filter_location_history.period = acp.id AND acp_geog_filter_location_history.period = acp.id

View File

@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Repository\AccompanyingPeriod; namespace Chill\PersonBundle\Repository\AccompanyingPeriod;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWork; use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWork;
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWorkEvaluation; use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWorkEvaluation;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@ -88,6 +89,7 @@ class AccompanyingPeriodWorkEvaluationRepository implements ObjectRepository
->where( ->where(
$qb->expr()->andX( $qb->expr()->andX(
$qb->expr()->isNull('e.endDate'), $qb->expr()->isNull('e.endDate'),
$qb->expr()->neq('period.step', ':closed'),
$qb->expr()->gte(':now', $qb->expr()->diff('e.maxDate', 'e.warningInterval')), $qb->expr()->gte(':now', $qb->expr()->diff('e.maxDate', 'e.warningInterval')),
$qb->expr()->orX( $qb->expr()->orX(
$qb->expr()->eq('period.user', ':user'), $qb->expr()->eq('period.user', ':user'),
@ -100,6 +102,7 @@ class AccompanyingPeriodWorkEvaluationRepository implements ObjectRepository
->setParameters([ ->setParameters([
'user' => $user, 'user' => $user,
'now' => new \DateTimeImmutable('now'), 'now' => new \DateTimeImmutable('now'),
'closed' => AccompanyingPeriod::STEP_CLOSED,
]); ]);
return $qb; return $qb;

View File

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

View File

@ -598,10 +598,7 @@ class ThirdParty implements TrackCreationInterface, TrackUpdateInterface, \Strin
return $this; return $this;
} }
/** public function setCenters(Collection $centers): self
* @return $this
*/
public function setCenters(Collection $centers)
{ {
$this->centers = $centers; $this->centers = $centers;

View File

@ -0,0 +1,16 @@
### Get a document
GET http://{{ host }}/dav/get/{{ uuid }}/d
### OPTIONS on a document
OPTIONS http://{{ host }}/dav/get/{{ uuid }}/d
### HEAD ona document
HEAD http://{{ host }}/dav/get/{{ uuid }}/d
### Get the directory of a document
GET http://{{ host }}/dav/get/{{ uuid }}/
### Option the directory of a document
OPTIONS http://{{ host }}/dav/get/{{ uuid }}/

View File

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