diff --git a/.changes/v2.19.0.md b/.changes/v2.19.0.md new file mode 100644 index 000000000..6b24baa6a --- /dev/null +++ b/.changes/v2.19.0.md @@ -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 diff --git a/.changes/v2.20.0.md b/.changes/v2.20.0.md new file mode 100644 index 000000000..7cfb4809c --- /dev/null +++ b/.changes/v2.20.0.md @@ -0,0 +1,21 @@ +## v2.20.0 - 2024-06-05 +### Fixed +* ([#170](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/170)) Display agents traitants instead of accompanying period referrer in export list social actions. +* Added translations for choices of durations (> 5 hours) +### Feature +* ([#145](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/145)) Allow to open documents in LibreOffice locally (need configuration within security); + + This endpoint should be added to make the endpoint works properly: + + ```yaml + security: + firewalls: + dav: + pattern: ^/dav + provider: chain_provider + stateless: true + guard: + authenticators: + - Chill\DocStoreBundle\Security\Guard\JWTOnDavUrlAuthenticator + + ``` diff --git a/.changes/v2.20.1.md b/.changes/v2.20.1.md new file mode 100644 index 000000000..5493c2c17 --- /dev/null +++ b/.changes/v2.20.1.md @@ -0,0 +1,3 @@ +## v2.20.1 - 2024-06-05 +### Fixed +* Do not allow StoredObjectCreated for edit and convert buttons diff --git a/CHANGELOG.md b/CHANGELOG.md index ae9407c8a..93c606250 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,53 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), and is generated by [Changie](https://github.com/miniscruff/changie). +## v2.20.1 - 2024-06-05 +### Fixed +* Do not allow StoredObjectCreated for edit and convert buttons + +## v2.20.0 - 2024-06-05 +### Fixed +* ([#170](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/170)) Display agents traitants instead of accompanying period referrer in export list social actions. +* Added translations for choices of durations (> 5 hours) +### Feature +* ([#145](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/145)) Allow to open documents in LibreOffice locally (need configuration within security); + + This endpoint should be added to make the endpoint works properly: + + ```yaml + security: + firewalls: + dav: + pattern: ^/dav + provider: chain_provider + stateless: true + guard: + authenticators: + - Chill\DocStoreBundle\Security\Guard\JWTOnDavUrlAuthenticator + + ``` + +## 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 diff --git a/composer.json b/composer.json index 3195f732b..37282788e 100644 --- a/composer.json +++ b/composer.json @@ -9,6 +9,7 @@ ], "require": { "php": "^8.2", + "ext-dom": "*", "ext-json": "*", "ext-openssl": "*", "ext-redis": "*", @@ -75,7 +76,7 @@ "phpunit/phpunit": ">= 7.5", "psalm/plugin-phpunit": "^0.18.4", "psalm/plugin-symfony": "^4.0.2", - "rector/rector": "^0.17.7", + "rector/rector": "^1.1.0", "symfony/debug-bundle": "^5.1", "symfony/dotenv": "^4.4", "symfony/maker-bundle": "^1.20", diff --git a/docs/source/installation/load-addresses.rst b/docs/source/installation/load-addresses.rst index 779032fd0..85c29d618 100644 --- a/docs/source/installation/load-addresses.rst +++ b/docs/source/installation/load-addresses.rst @@ -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. +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 ========= diff --git a/phpstan-baseline-2024-05.neon b/phpstan-baseline-2024-05.neon new file mode 100644 index 000000000..728ec4edc --- /dev/null +++ b/phpstan-baseline-2024-05.neon @@ -0,0 +1,6 @@ +parameters: + ignoreErrors: + - + message: "#^Parameter \\#1 \\$records of method League\\\\Csv\\\\Writer\\:\\:insertAll\\(\\) expects iterable\\\\>, iterable\\\\> given\\.$#" + count: 1 + path: src/Bundle/ChillMainBundle/Controller/UserExportController.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 62dbe0468..4e6745469 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -31,4 +31,5 @@ includes: - phpstan-baseline-level-3.neon - phpstan-baseline-level-4.neon - phpstan-baseline-level-5.neon + - phpstan-baseline-2024-05.neon diff --git a/rector.php b/rector.php index 0dd8e355c..8aa62bdd0 100644 --- a/rector.php +++ b/rector.php @@ -45,9 +45,6 @@ return static function (RectorConfig $rectorConfig): void { // skip some path... $rectorConfig->skip([ - // we need to discuss this: are we going to have FALSE in tests instead of an error ? - \Rector\Php71\Rector\FuncCall\CountOnNullRector::class, - // we must adapt service definition \Rector\Symfony\Symfony28\Rector\MethodCall\GetToConstructorInjectionRector::class, \Rector\Symfony\Symfony34\Rector\Closure\ContainerGetNameToTypeInTestsRector::class, diff --git a/src/Bundle/ChillActivityBundle/Form/ActivityType.php b/src/Bundle/ChillActivityBundle/Form/ActivityType.php index fbcf60bf0..9e2358e8b 100644 --- a/src/Bundle/ChillActivityBundle/Form/ActivityType.php +++ b/src/Bundle/ChillActivityBundle/Form/ActivityType.php @@ -15,11 +15,10 @@ use Chill\ActivityBundle\Entity\Activity; use Chill\ActivityBundle\Entity\ActivityPresence; use Chill\ActivityBundle\Form\Type\PickActivityReasonType; 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\Location; use Chill\MainBundle\Entity\User; -use Chill\MainBundle\Form\Type\ChillCollectionType; use Chill\MainBundle\Form\Type\ChillDateType; use Chill\MainBundle\Form\Type\CommentType; use Chill\MainBundle\Form\Type\PickUserDynamicType; @@ -276,16 +275,9 @@ class ActivityType extends AbstractType } if ($activityType->isVisible('documents')) { - $builder->add('documents', ChillCollectionType::class, [ - 'entry_type' => StoredObjectType::class, + $builder->add('documents', CollectionStoredObjectType::class, [ 'label' => $activityType->getLabel('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], ]); } diff --git a/src/Bundle/ChillActivityBundle/Resources/views/Activity/edit.html.twig b/src/Bundle/ChillActivityBundle/Resources/views/Activity/edit.html.twig index a000b0c7e..d986f9150 100644 --- a/src/Bundle/ChillActivityBundle/Resources/views/Activity/edit.html.twig +++ b/src/Bundle/ChillActivityBundle/Resources/views/Activity/edit.html.twig @@ -92,7 +92,9 @@ {% endif %} {%- 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) }}
{% endif %} @@ -127,4 +129,4 @@ {% block css %} {{ encore_entry_link_tags('mod_pickentity_type') }} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/src/Bundle/ChillActivityBundle/translations/messages.fr.yml b/src/Bundle/ChillActivityBundle/translations/messages.fr.yml index 441c23eeb..cb5ffb982 100644 --- a/src/Bundle/ChillActivityBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillActivityBundle/translations/messages.fr.yml @@ -77,6 +77,18 @@ Choose a type: Choisir un type 4 hours: 4 heures 4 hours 30: 4 heures 30 5 hours: 5 heures +5 hours 30: 5 heure 30 +6 hours: 6 heures +6 hours 30: 6 heure 30 +7 hours: 7 heures +7 hours 30: 7 heure 30 +8 hours: 8 heures +8 hours 30: 8 heure 30 +9 hours: 9 heures +9 hours 30: 9 heure 30 +10 hours: 10 heures +11 hours: 11 heures +12 hours: 12 heures Concerned groups: Parties concernées par l'échange Persons in accompanying course: Usagers du parcours Third persons: Tiers non-pro. diff --git a/src/Bundle/ChillAsideActivityBundle/src/Tests/Chill/DocStoreBundle/Tests/Security/Guard/DavTokenAuthenticationEventSubscriberTest.php b/src/Bundle/ChillAsideActivityBundle/src/Tests/Chill/DocStoreBundle/Tests/Security/Guard/DavTokenAuthenticationEventSubscriberTest.php new file mode 100644 index 000000000..875e40a65 --- /dev/null +++ b/src/Bundle/ChillAsideActivityBundle/src/Tests/Chill/DocStoreBundle/Tests/Security/Guard/DavTokenAuthenticationEventSubscriberTest.php @@ -0,0 +1,66 @@ + 1, + 'so' => '1234', + 'e' => 1, + ], $token); + + $eventSubscriber->onJWTAuthenticated($event); + + self::assertTrue($token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)); + self::assertTrue($token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)); + self::assertEquals('1234', $token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)); + self::assertEquals(StoredObjectRoleEnum::EDIT, $token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)); + } + + public function testOnJWTAuthenticatedWithDavNoDataInPayload(): void + { + $eventSubscriber = new DavTokenAuthenticationEventSubscriber(); + $token = new class () extends AbstractToken { + public function getCredentials() + { + return null; + } + }; + $event = new JWTAuthenticatedEvent([], $token); + + $eventSubscriber->onJWTAuthenticated($event); + + self::assertFalse($token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)); + self::assertFalse($token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)); + } +} diff --git a/src/Bundle/ChillAsideActivityBundle/src/translations/messages.fr.yml b/src/Bundle/ChillAsideActivityBundle/src/translations/messages.fr.yml index 0a324d2cd..7d3c7a20e 100644 --- a/src/Bundle/ChillAsideActivityBundle/src/translations/messages.fr.yml +++ b/src/Bundle/ChillAsideActivityBundle/src/translations/messages.fr.yml @@ -72,21 +72,21 @@ days: jours 1 hour 30: 1 heure 30 1 hour 45: 1 heure 45 2 hours: 2 heures -2 hours 30: 2 heure 30 +2 hours 30: 2 heures 30 3 hours: 3 heures -3 hours 30: 3 heure 30 +3 hours 30: 3 heures 30 4 hours: 4 heures -4 hours 30: 4 heure 30 +4 hours 30: 4 heures 30 5 hours: 5 heures -5 hours 30: 5 heure 30 +5 hours 30: 5 heures 30 6 hours: 6 heures -6 hours 30: 6 heure 30 +6 hours 30: 6 heures 30 7 hours: 7 heures -7 hours 30: 7 heure 30 +7 hours 30: 7 heures 30 8 hours: 8 heures -8 hours 30: 8 heure 30 +8 hours 30: 8 heures 30 9 hours: 9 heures -9 hours 30: 9 heure 30 +9 hours 30: 9 heures 30 10 hours: 10 heures 1/2 day: 1/2 jour 1 day: 1 jour diff --git a/src/Bundle/ChillCalendarBundle/Command/MapAndSubscribeUserCalendarCommand.php b/src/Bundle/ChillCalendarBundle/Command/MapAndSubscribeUserCalendarCommand.php index 4964f0064..177fd2375 100644 --- a/src/Bundle/ChillCalendarBundle/Command/MapAndSubscribeUserCalendarCommand.php +++ b/src/Bundle/ChillCalendarBundle/Command/MapAndSubscribeUserCalendarCommand.php @@ -49,8 +49,6 @@ final class MapAndSubscribeUserCalendarCommand extends Command $limit = 50; $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'))); $users = $this->userRepository->findAllAsArray('fr'); $created = 0; @@ -93,7 +91,6 @@ final class MapAndSubscribeUserCalendarCommand extends Command } catch (UserAbsenceSyncException $e) { $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())); - throw $e; } // we first try to renew an existing subscription, if any. diff --git a/src/Bundle/ChillCustomFieldsBundle/CustomFields/CustomFieldInterface.php b/src/Bundle/ChillCustomFieldsBundle/CustomFields/CustomFieldInterface.php index ff89c04d2..eb2d3b011 100644 --- a/src/Bundle/ChillCustomFieldsBundle/CustomFields/CustomFieldInterface.php +++ b/src/Bundle/ChillCustomFieldsBundle/CustomFields/CustomFieldInterface.php @@ -49,20 +49,17 @@ interface CustomFieldInterface /** * Return if the value can be considered as empty. - * - * @param mixed $value the value passed throug the deserialize function */ - public function isEmptyValue($value, CustomField $customField); + public function isEmptyValue(mixed $value, CustomField $customField); /** * Return a repsentation of the value of the CustomField. * - * @param mixed $value the raw value, **not deserialized** (= as stored in the db) * @param \Chill\CustomFieldsBundle\CustomField\CustomField $customField * * @return string an html representation of the value */ - public function render($value, CustomField $customField, $documentType = 'html'); + public function render(mixed $value, CustomField $customField, $documentType = 'html'); /** * Transform the value into a format that can be stored in DB. diff --git a/src/Bundle/ChillCustomFieldsBundle/Tests/CustomFields/CustomFieldsChoiceTest.php b/src/Bundle/ChillCustomFieldsBundle/Tests/CustomFields/CustomFieldsChoiceTest.php index 48d4847d6..b9fe45820 100644 --- a/src/Bundle/ChillCustomFieldsBundle/Tests/CustomFields/CustomFieldsChoiceTest.php +++ b/src/Bundle/ChillCustomFieldsBundle/Tests/CustomFields/CustomFieldsChoiceTest.php @@ -399,8 +399,6 @@ final class CustomFieldsChoiceTest extends KernelTestCase /** * @dataProvider emptyDataProvider - * - * @param mixed $data deserialized data */ public function testIsEmptyValueEmpty(mixed $data) { diff --git a/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php b/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php new file mode 100644 index 000000000..70aecbe1e --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php @@ -0,0 +1,252 @@ +requestAnalyzer = new PropfindRequestAnalyzer(); + } + + /** + * @Route("/dav/{access_token}/get/{uuid}/", methods={"GET", "HEAD"}, name="chill_docstore_dav_directory_get") + */ + public function getDirectory(StoredObject $storedObject, string $access_token): Response + { + if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) { + throw new AccessDeniedHttpException(); + } + + return new DavResponse( + $this->engine->render('@ChillDocStore/Webdav/directory.html.twig', [ + 'stored_object' => $storedObject, + 'access_token' => $access_token, + ]) + ); + } + + /** + * @Route("/dav/{access_token}/get/{uuid}/", methods={"OPTIONS"}) + */ + public function optionsDirectory(StoredObject $storedObject): Response + { + if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) { + throw new AccessDeniedHttpException(); + } + + $response = (new DavResponse('')) + ->setEtag($this->storedObjectManager->etag($storedObject)) + ; + + // $response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT,PROPPATCH,COPY,MOVE,REPORT,PATCH,POST,TRACE']); + $response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT']); + + return $response; + } + + /** + * @Route("/dav/{access_token}/get/{uuid}/", methods={"PROPFIND"}) + */ + public function propfindDirectory(StoredObject $storedObject, string $access_token, Request $request): Response + { + if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) { + throw new AccessDeniedHttpException(); + } + + $depth = $request->headers->get('depth'); + + if ('0' !== $depth && '1' !== $depth) { + throw new BadRequestHttpException('only 1 and 0 are accepted for Depth header'); + } + + [$properties, $lastModified, $etag, $length] = $this->parseDavRequest($request->getContent(), $storedObject); + + $response = new DavResponse( + $this->engine->render('@ChillDocStore/Webdav/directory_propfind.xml.twig', [ + 'stored_object' => $storedObject, + 'properties' => $properties, + 'last_modified' => $lastModified, + 'etag' => $etag, + 'content_length' => $length, + 'depth' => (int) $depth, + 'access_token' => $access_token, + ]), + 207 + ); + + $response->headers->add([ + 'Content-Type' => 'text/xml', + ]); + + return $response; + } + + /** + * @Route("/dav/{access_token}/get/{uuid}/d", name="chill_docstore_dav_document_get", methods={"GET"}) + */ + public function getDocument(StoredObject $storedObject): Response + { + if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) { + throw new AccessDeniedHttpException(); + } + + return (new DavResponse($this->storedObjectManager->read($storedObject))) + ->setEtag($this->storedObjectManager->etag($storedObject)); + } + + /** + * @Route("/dav/{access_token}/get/{uuid}/d", methods={"HEAD"}) + */ + public function headDocument(StoredObject $storedObject): Response + { + if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) { + throw new AccessDeniedHttpException(); + } + + $response = new DavResponse(''); + + $response->headers->add( + [ + 'Content-Length' => $this->storedObjectManager->getContentLength($storedObject), + 'Content-Type' => $storedObject->getType(), + 'Etag' => $this->storedObjectManager->etag($storedObject), + ] + ); + + return $response; + } + + /** + * @Route("/dav/{access_token}/get/{uuid}/d", methods={"OPTIONS"}) + */ + public function optionsDocument(StoredObject $storedObject): Response + { + if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) { + throw new AccessDeniedHttpException(); + } + + $response = (new DavResponse('')) + ->setEtag($this->storedObjectManager->etag($storedObject)) + ; + + $response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT']); + + return $response; + } + + /** + * @Route("/dav/{access_token}/get/{uuid}/d", methods={"PROPFIND"}) + */ + public function propfindDocument(StoredObject $storedObject, string $access_token, Request $request): Response + { + if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) { + throw new AccessDeniedHttpException(); + } + + [$properties, $lastModified, $etag, $length] = $this->parseDavRequest($request->getContent(), $storedObject); + + $response = new DavResponse( + $this->engine->render( + '@ChillDocStore/Webdav/doc_props.xml.twig', + [ + 'stored_object' => $storedObject, + 'properties' => $properties, + 'etag' => $etag, + 'last_modified' => $lastModified, + 'content_length' => $length, + 'access_token' => $access_token, + ] + ), + 207 + ); + + $response + ->headers->add([ + 'Content-Type' => 'text/xml', + ]); + + return $response; + } + + /** + * @Route("/dav/{access_token}/get/{uuid}/d", methods={"PUT"}) + */ + public function putDocument(StoredObject $storedObject, Request $request): Response + { + if (!$this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $storedObject)) { + throw new AccessDeniedHttpException(); + } + + $this->storedObjectManager->write($storedObject, $request->getContent()); + + return new DavResponse('', Response::HTTP_NO_CONTENT); + } + + /** + * @return array{0: array, 1: \DateTimeInterface, 2: string, 3: int} properties, lastModified, etag, length + */ + private function parseDavRequest(string $content, StoredObject $storedObject): array + { + $xml = new \DOMDocument(); + $xml->loadXML($content); + + $properties = $this->requestAnalyzer->getRequestedProperties($xml); + $requested = array_keys(array_filter($properties, fn ($item) => true === $item)); + + if ( + in_array('lastModified', $requested, true) + || in_array('etag', $requested, true) + ) { + $lastModified = $this->storedObjectManager->getLastModified($storedObject); + $etag = $this->storedObjectManager->etag($storedObject); + } + if (in_array('contentLength', $requested, true)) { + $length = $this->storedObjectManager->getContentLength($storedObject); + } + + return [ + $properties, + $lastModified ?? null, + $etag ?? null, + $length ?? null, + ]; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Dav/Exception/ParseRequestException.php b/src/Bundle/ChillDocStoreBundle/Dav/Exception/ParseRequestException.php new file mode 100644 index 000000000..70fff1866 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Dav/Exception/ParseRequestException.php @@ -0,0 +1,16 @@ +} + */ +class PropfindRequestAnalyzer +{ + private const KNOWN_PROPS = [ + 'resourceType', + 'contentType', + 'lastModified', + 'creationDate', + 'contentLength', + 'etag', + 'supportedLock', + ]; + + /** + * @return davProperties + */ + public function getRequestedProperties(\DOMDocument $request): array + { + $propfinds = $request->getElementsByTagNameNS('DAV:', 'propfind'); + + if (0 === $propfinds->count()) { + throw new ParseRequestException('any propfind element found'); + } + + if (1 < $propfinds->count()) { + throw new ParseRequestException('too much propfind element found'); + } + + $propfind = $propfinds->item(0); + + if (0 === $propfind->childNodes->count()) { + throw new ParseRequestException('no element under propfind'); + } + + $unknows = []; + $props = []; + + foreach ($propfind->childNodes->getIterator() as $prop) { + /** @var \DOMNode $prop */ + if (XML_ELEMENT_NODE !== $prop->nodeType) { + continue; + } + + if ('propname' === $prop->nodeName) { + return $this->baseProps(true); + } + + foreach ($prop->childNodes->getIterator() as $getProp) { + if (XML_ELEMENT_NODE !== $getProp->nodeType) { + continue; + } + + if ('DAV:' !== $getProp->lookupNamespaceURI(null)) { + $unknows[] = ['xmlns' => $getProp->lookupNamespaceURI(null), 'prop' => $getProp->nodeName]; + continue; + } + + $props[] = match ($getProp->nodeName) { + 'resourcetype' => 'resourceType', + 'getcontenttype' => 'contentType', + 'getlastmodified' => 'lastModified', + default => '', + }; + } + } + + $props = array_filter(array_values($props), fn (string $item) => '' !== $item); + + return [...$this->baseProps(false), ...array_combine($props, array_fill(0, count($props), true)), 'unknowns' => $unknows]; + } + + /** + * @return davProperties + */ + private function baseProps(bool $default = false): array + { + return + [ + ...array_combine( + self::KNOWN_PROPS, + array_fill(0, count(self::KNOWN_PROPS), $default) + ), + 'unknowns' => [], + ]; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Dav/Response/DavResponse.php b/src/Bundle/ChillDocStoreBundle/Dav/Response/DavResponse.php new file mode 100644 index 000000000..32332d20a --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Dav/Response/DavResponse.php @@ -0,0 +1,24 @@ +headers->add(['DAV' => '1']); + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Entity/StoredObject.php b/src/Bundle/ChillDocStoreBundle/Entity/StoredObject.php index 4a16d33f4..e2cb4fff2 100644 --- a/src/Bundle/ChillDocStoreBundle/Entity/StoredObject.php +++ b/src/Bundle/ChillDocStoreBundle/Entity/StoredObject.php @@ -48,14 +48,14 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa /** * @ORM\Column(type="json", name="datas") * - * @Serializer\Groups({"read", "write"}) + * @Serializer\Groups({"write"}) */ private array $datas = []; /** * @ORM\Column(type="text") * - * @Serializer\Groups({"read", "write"}) + * @Serializer\Groups({"write"}) */ private string $filename = ''; @@ -66,7 +66,7 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa * * @ORM\Column(type="integer") * - * @Serializer\Groups({"read", "write"}) + * @Serializer\Groups({"write"}) */ private ?int $id = null; @@ -75,35 +75,35 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa * * @ORM\Column(type="json", name="iv") * - * @Serializer\Groups({"read", "write"}) + * @Serializer\Groups({"write"}) */ private array $iv = []; /** * @ORM\Column(type="json", name="key") * - * @Serializer\Groups({"read", "write"}) + * @Serializer\Groups({"write"}) */ private array $keyInfos = []; /** * @ORM\Column(type="text", name="title") * - * @Serializer\Groups({"read", "write"}) + * @Serializer\Groups({"write"}) */ private string $title = ''; /** * @ORM\Column(type="text", name="type", options={"default": ""}) * - * @Serializer\Groups({"read", "write"}) + * @Serializer\Groups({"write"}) */ private string $type = ''; /** * @ORM\Column(type="uuid", unique=true) * - * @Serializer\Groups({"read", "write"}) + * @Serializer\Groups({"write"}) */ private UuidInterface $uuid; @@ -137,8 +137,6 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa */ public function __construct(/** * @ORM\Column(type="text", options={"default": "ready"}) - * - * @Serializer\Groups({"read"}) */ private string $status = 'ready' ) { @@ -356,4 +354,19 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa return $this; } + + public function saveHistory(): void + { + if ('' === $this->getFilename()) { + return; + } + + $this->datas['history'][] = [ + 'filename' => $this->getFilename(), + 'iv' => $this->getIv(), + 'key_infos' => $this->getKeyInfos(), + 'type' => $this->getType(), + 'before' => (new \DateTimeImmutable('now'))->getTimestamp(), + ]; + } } diff --git a/src/Bundle/ChillDocStoreBundle/Form/AccompanyingCourseDocumentType.php b/src/Bundle/ChillDocStoreBundle/Form/AccompanyingCourseDocumentType.php index 0fced39d3..331e3cb98 100644 --- a/src/Bundle/ChillDocStoreBundle/Form/AccompanyingCourseDocumentType.php +++ b/src/Bundle/ChillDocStoreBundle/Form/AccompanyingCourseDocumentType.php @@ -14,47 +14,21 @@ namespace Chill\DocStoreBundle\Form; use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument; use Chill\DocStoreBundle\Entity\Document; use Chill\DocStoreBundle\Entity\DocumentCategory; -use Chill\MainBundle\Entity\User; use Chill\MainBundle\Form\Type\ChillDateType; use Chill\MainBundle\Form\Type\ChillTextareaType; -use Chill\MainBundle\Security\Authorization\AuthorizationHelper; -use Chill\MainBundle\Templating\TranslatableStringHelper; +use Chill\MainBundle\Templating\TranslatableStringHelperInterface; use Doctrine\ORM\EntityRepository; -use Doctrine\Persistence\ObjectManager; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; -class AccompanyingCourseDocumentType extends AbstractType +final class AccompanyingCourseDocumentType extends AbstractType { - /** - * @var AuthorizationHelper - */ - protected $authorizationHelper; - - /** - * @var ObjectManager - */ - protected $om; - - /** - * @var TranslatableStringHelper - */ - protected $translatableStringHelper; - - /** - * the user running this form. - * - * @var User - */ - protected $user; - public function __construct( - TranslatableStringHelper $translatableStringHelper + private readonly TranslatableStringHelperInterface $translatableStringHelper ) { - $this->translatableStringHelper = $translatableStringHelper; } public function buildForm(FormBuilderInterface $builder, array $options) diff --git a/src/Bundle/ChillDocStoreBundle/Form/CollectionStoredObjectType.php b/src/Bundle/ChillDocStoreBundle/Form/CollectionStoredObjectType.php new file mode 100644 index 000000000..fd14897bf --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Form/CollectionStoredObjectType.php @@ -0,0 +1,37 @@ +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; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Form/DataMapper/StoredObjectDataMapper.php b/src/Bundle/ChillDocStoreBundle/Form/DataMapper/StoredObjectDataMapper.php new file mode 100644 index 000000000..4e1aa36d6 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Form/DataMapper/StoredObjectDataMapper.php @@ -0,0 +1,75 @@ +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 want to keep the previous history + $viewData->saveHistory(); + } + + $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()); + } + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Form/DataTransformer/StoredObjectDataTransformer.php b/src/Bundle/ChillDocStoreBundle/Form/DataTransformer/StoredObjectDataTransformer.php new file mode 100644 index 000000000..520b644b3 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Form/DataTransformer/StoredObjectDataTransformer.php @@ -0,0 +1,52 @@ +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); + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Form/StoredObjectType.php b/src/Bundle/ChillDocStoreBundle/Form/StoredObjectType.php index 77484208f..5badc458d 100644 --- a/src/Bundle/ChillDocStoreBundle/Form/StoredObjectType.php +++ b/src/Bundle/ChillDocStoreBundle/Form/StoredObjectType.php @@ -11,11 +11,10 @@ declare(strict_types=1); namespace Chill\DocStoreBundle\Form; -use ChampsLibres\AsyncUploaderBundle\Form\Type\AsyncUploaderType; 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\CallbackTransformer; use Symfony\Component\Form\Extension\Core\Type\HiddenType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; @@ -24,16 +23,12 @@ use Symfony\Component\OptionsResolver\OptionsResolver; /** * Form type which allow to join a document. */ -class StoredObjectType extends AbstractType +final class StoredObjectType extends AbstractType { - /** - * @var EntityManagerInterface - */ - protected $em; - - public function __construct(EntityManagerInterface $em) - { - $this->em = $em; + public function __construct( + private readonly StoredObjectDataTransformer $storedObjectDataTransformer, + private readonly StoredObjectDataMapper $storedObjectDataMapper, + ) { } public function buildForm(FormBuilderInterface $builder, array $options) @@ -45,30 +40,9 @@ class StoredObjectType extends AbstractType ]); } - $builder - ->add('filename', AsyncUploaderType::class) - ->add('type', HiddenType::class) - ->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(...) - )); + $builder->add('stored_object', HiddenType::class); + $builder->get('stored_object')->addModelTransformer($this->storedObjectDataTransformer); + $builder->setDataMapper($this->storedObjectDataMapper); } public function configureOptions(OptionsResolver $resolver) @@ -80,43 +54,4 @@ class StoredObjectType extends AbstractType ->setDefault('has_title', false) ->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; - } } diff --git a/src/Bundle/ChillDocStoreBundle/Resources/public/module/async_upload/index.js b/src/Bundle/ChillDocStoreBundle/Resources/public/module/async_upload/index-old.js similarity index 100% rename from src/Bundle/ChillDocStoreBundle/Resources/public/module/async_upload/index.js rename to src/Bundle/ChillDocStoreBundle/Resources/public/module/async_upload/index-old.js diff --git a/src/Bundle/ChillDocStoreBundle/Resources/public/module/async_upload/index.ts b/src/Bundle/ChillDocStoreBundle/Resources/public/module/async_upload/index.ts new file mode 100644 index 000000000..b7df11323 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/module/async_upload/index.ts @@ -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: '', + 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) => { + 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 = 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 {} diff --git a/src/Bundle/ChillDocStoreBundle/Resources/public/module/document_action_buttons_group/index.ts b/src/Bundle/ChillDocStoreBundle/Resources/public/module/document_action_buttons_group/index.ts index 4180808dd..77eb8c2c9 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/public/module/document_action_buttons_group/index.ts +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/module/document_action_buttons_group/index.ts @@ -17,18 +17,22 @@ window.addEventListener('DOMContentLoaded', function (e) { canEdit: string, storedObject: string, buttonSmall: string, + davLink: string, + davLinkExpiration: string, }; const storedObject = JSON.parse(datasets.storedObject) as StoredObject, filename = datasets.filename, canEdit = datasets.canEdit === '1', - small = datasets.buttonSmall === '1' + small = datasets.buttonSmall === '1', + davLink = 'davLink' in datasets && datasets.davLink !== '' ? datasets.davLink : null, + davLinkExpiration = 'davLinkExpiration' in datasets ? Number.parseInt(datasets.davLinkExpiration) : null ; - return { storedObject, filename, canEdit, small }; + return { storedObject, filename, canEdit, small, davLink, davLinkExpiration }; }, - template: '', + template: '', methods: { onStoredObjectStatusChange: function(newStatus: StoredObjectStatusChange): void { this.$data.storedObject.status = newStatus.status; diff --git a/src/Bundle/ChillDocStoreBundle/Resources/public/types.ts b/src/Bundle/ChillDocStoreBundle/Resources/public/types.ts index 825055973..25d956312 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/public/types.ts +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/types.ts @@ -17,6 +17,20 @@ export interface StoredObject { type: string, uuid: string, 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 { @@ -33,3 +47,18 @@ export type WopiEditButtonExecutableBeforeLeaveFunction = { (): Promise } +/** + * 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, +} + diff --git a/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/DocumentActionButtonsGroup.vue b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/DocumentActionButtonsGroup.vue index 88587e90f..b4c53eacd 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/DocumentActionButtonsGroup.vue +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/DocumentActionButtonsGroup.vue @@ -1,13 +1,16 @@