From 8de37a9ef3298dfeef10c9daa4c436c797889944 Mon Sep 17 00:00:00 2001 From: Julie Lenaerts Date: Thu, 11 Apr 2024 16:45:22 +0200 Subject: [PATCH 01/30] Fix import of postal codes. URL changed + names of keys --- .../Service/Import/PostalCodeFRFromOpenData.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Bundle/ChillMainBundle/Service/Import/PostalCodeFRFromOpenData.php b/src/Bundle/ChillMainBundle/Service/Import/PostalCodeFRFromOpenData.php index e5934af3b..c073e5bdb 100644 --- a/src/Bundle/ChillMainBundle/Service/Import/PostalCodeFRFromOpenData.php +++ b/src/Bundle/ChillMainBundle/Service/Import/PostalCodeFRFromOpenData.php @@ -23,7 +23,7 @@ use Symfony\Contracts\HttpClient\HttpClientInterface; */ 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) { @@ -50,7 +50,7 @@ class PostalCodeFRFromOpenData fseek($tmpfile, 0); $csv = Reader::createFromStream($tmpfile); - $csv->setDelimiter(';'); + $csv->setDelimiter(','); $csv->setHeaderOffset(0); foreach ($csv as $offset => $record) { @@ -65,23 +65,23 @@ class PostalCodeFRFromOpenData private function handleRecord(array $record): void { - if ('' !== trim($record['coordonnees_geographiques'] ?? $record['coordonnees_gps'])) { - [$lat, $lon] = array_map(static fn ($el) => (float) trim($el), explode(',', $record['coordonnees_geographiques'] ?? $record['coordonnees_gps'])); + if ('' !== trim($record['_geopoint'])) { + [$lat, $lon] = array_map(static fn ($el) => (float) trim($el), explode(',', $record['_geopoint'])); } else { $lat = $lon = 0.0; } - $ref = trim((string) $record['Code_commune_INSEE']); + $ref = trim((string) $record['code_commune_insee']); if (str_starts_with($ref, '987')) { // some differences in French Polynesia - $ref .= '.'.trim((string) $record['Libellé_d_acheminement']); + $ref .= '.'.trim((string) $record['libelle_d_acheminement']); } $this->baseImporter->importCode( 'FR', - trim((string) $record['Libellé_d_acheminement']), - trim((string) $record['Code_postal']), + trim((string) $record['libelle_d_acheminement']), + trim((string) $record['code_postal']), $ref, 'INSEE', $lat, From e8b95f8491252e64050e9630998930eef3b2b419 Mon Sep 17 00:00:00 2001 From: Julie Lenaerts Date: Thu, 11 Apr 2024 16:45:43 +0200 Subject: [PATCH 02/30] Add changie for fix import postal codes --- .changes/unreleased/Fixed-20240411-164104.yaml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changes/unreleased/Fixed-20240411-164104.yaml diff --git a/.changes/unreleased/Fixed-20240411-164104.yaml b/.changes/unreleased/Fixed-20240411-164104.yaml new file mode 100644 index 000000000..aa63f022a --- /dev/null +++ b/.changes/unreleased/Fixed-20240411-164104.yaml @@ -0,0 +1,5 @@ +kind: Fixed +body: 'Postal codes import : fix the source URL and the keys to handle each record' +time: 2024-04-11T16:41:04.786062252+02:00 +custom: + Issue: "250" From dbb9feb12980f474698dd42a05f6a88bd73c1993 Mon Sep 17 00:00:00 2001 From: Julie Lenaerts Date: Fri, 12 Apr 2024 12:35:52 +0200 Subject: [PATCH 03/30] rector correction: cast value to string --- .../Service/Import/PostalCodeFRFromOpenData.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Bundle/ChillMainBundle/Service/Import/PostalCodeFRFromOpenData.php b/src/Bundle/ChillMainBundle/Service/Import/PostalCodeFRFromOpenData.php index c073e5bdb..e40b4dff9 100644 --- a/src/Bundle/ChillMainBundle/Service/Import/PostalCodeFRFromOpenData.php +++ b/src/Bundle/ChillMainBundle/Service/Import/PostalCodeFRFromOpenData.php @@ -65,8 +65,8 @@ class PostalCodeFRFromOpenData private function handleRecord(array $record): void { - if ('' !== trim($record['_geopoint'])) { - [$lat, $lon] = array_map(static fn ($el) => (float) trim($el), explode(',', $record['_geopoint'])); + if ('' !== trim((string) $record['_geopoint'])) { + [$lat, $lon] = array_map(static fn ($el) => (float) trim($el), explode(',', (string) $record['_geopoint'])); } else { $lat = $lon = 0.0; } From 4091efc72ec308931fcb3d0d740c0895b9c6ff6e Mon Sep 17 00:00:00 2001 From: Julie Lenaerts Date: Fri, 12 Apr 2024 12:58:23 +0200 Subject: [PATCH 04/30] Update bundles version to 2.18.2 --- .changes/unreleased/Fixed-20240411-164104.yaml | 5 ----- .changes/v2.18.2.md | 3 +++ CHANGELOG.md | 4 ++++ 3 files changed, 7 insertions(+), 5 deletions(-) delete mode 100644 .changes/unreleased/Fixed-20240411-164104.yaml create mode 100644 .changes/v2.18.2.md diff --git a/.changes/unreleased/Fixed-20240411-164104.yaml b/.changes/unreleased/Fixed-20240411-164104.yaml deleted file mode 100644 index aa63f022a..000000000 --- a/.changes/unreleased/Fixed-20240411-164104.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: Fixed -body: 'Postal codes import : fix the source URL and the keys to handle each record' -time: 2024-04-11T16:41:04.786062252+02:00 -custom: - Issue: "250" diff --git a/.changes/v2.18.2.md b/.changes/v2.18.2.md new file mode 100644 index 000000000..42eff9e25 --- /dev/null +++ b/.changes/v2.18.2.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c0a6b008..ae9407c8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), and is generated by [Changie](https://github.com/miniscruff/changie). +## 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 ### Fixed * Fix layout issue in document generation for admin (minor) From 8516e89c9cac8b8be16a581ef9a55bafef03e9d7 Mon Sep 17 00:00:00 2001 From: Julie Lenaerts Date: Wed, 17 Apr 2024 16:16:56 +0200 Subject: [PATCH 05/30] update docs to include import of countries which is necessary to be able to import addresses --- docs/source/installation/load-addresses.rst | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 ========= From ab6feef371eda7885bd6ab80967389fb754303ef Mon Sep 17 00:00:00 2001 From: juminet Date: Thu, 18 Apr 2024 07:37:05 +0000 Subject: [PATCH 06/30] Do not display evaluation from closed accompanying period on homepage --- .changes/unreleased/Fixed-20240416-161817.yaml | 6 ++++++ .../AccompanyingPeriodWorkEvaluationRepository.php | 3 +++ 2 files changed, 9 insertions(+) create mode 100644 .changes/unreleased/Fixed-20240416-161817.yaml diff --git a/.changes/unreleased/Fixed-20240416-161817.yaml b/.changes/unreleased/Fixed-20240416-161817.yaml new file mode 100644 index 000000000..6884c11fe --- /dev/null +++ b/.changes/unreleased/Fixed-20240416-161817.yaml @@ -0,0 +1,6 @@ +kind: Fixed +body: Fix broken link in homepage when a evaluation from a closed acc period was present + in the homepage widget +time: 2024-04-16T16:18:17.888645172+02:00 +custom: + Issue: "270" diff --git a/src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriod/AccompanyingPeriodWorkEvaluationRepository.php b/src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriod/AccompanyingPeriodWorkEvaluationRepository.php index 9c6ed19fc..6110f8bb0 100644 --- a/src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriod/AccompanyingPeriodWorkEvaluationRepository.php +++ b/src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriod/AccompanyingPeriodWorkEvaluationRepository.php @@ -12,6 +12,7 @@ declare(strict_types=1); namespace Chill\PersonBundle\Repository\AccompanyingPeriod; use Chill\MainBundle\Entity\User; +use Chill\PersonBundle\Entity\AccompanyingPeriod; use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWork; use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWorkEvaluation; use Doctrine\ORM\EntityManagerInterface; @@ -88,6 +89,7 @@ class AccompanyingPeriodWorkEvaluationRepository implements ObjectRepository ->where( $qb->expr()->andX( $qb->expr()->isNull('e.endDate'), + $qb->expr()->neq('period.step', ':closed'), $qb->expr()->gte(':now', $qb->expr()->diff('e.maxDate', 'e.warningInterval')), $qb->expr()->orX( $qb->expr()->eq('period.user', ':user'), @@ -100,6 +102,7 @@ class AccompanyingPeriodWorkEvaluationRepository implements ObjectRepository ->setParameters([ 'user' => $user, 'now' => new \DateTimeImmutable('now'), + 'closed' => AccompanyingPeriod::STEP_CLOSED, ]); return $qb; From 3e2ff463bce58bd0603f3bb3859ad01227e3f141 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Fri, 26 Apr 2024 13:47:25 +0200 Subject: [PATCH 07/30] make the script which subscribe to user calendars on ms-graph more tolerant to errors The script does not fails and exit when some calendar settings are not reachable --- .changes/unreleased/Feature-20240426-134831.yaml | 6 ++++++ .../Command/MapAndSubscribeUserCalendarCommand.php | 3 --- 2 files changed, 6 insertions(+), 3 deletions(-) create mode 100644 .changes/unreleased/Feature-20240426-134831.yaml diff --git a/.changes/unreleased/Feature-20240426-134831.yaml b/.changes/unreleased/Feature-20240426-134831.yaml new file mode 100644 index 000000000..00952fe57 --- /dev/null +++ b/.changes/unreleased/Feature-20240426-134831.yaml @@ -0,0 +1,6 @@ +kind: Feature +body: Make the script which subscribe to microsoft calendars changes more tolerant + to errors or missing configuration on the microsoft side +time: 2024-04-26T13:48:31.476415017+02:00 +custom: + Issue: "197" 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. From c773f9c6db31542685f409475182168e9ba607ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Mon, 29 Apr 2024 11:39:46 +0200 Subject: [PATCH 08/30] Update geographical unit filter for period's location The geographical unit filter in the accompanying course filters now takes the period's location on the address into account. This enhancement was achieved by modifying the GeographicalUnitStatFilter class. As a result, the "filter accompanying period by geographical unit" option provides more accurate data. --- .changes/unreleased/Fixed-20240429-113804.yaml | 6 ++++++ .../GeographicalUnitStatFilter.php | 16 ++++++++++------ 2 files changed, 16 insertions(+), 6 deletions(-) create mode 100644 .changes/unreleased/Fixed-20240429-113804.yaml diff --git a/.changes/unreleased/Fixed-20240429-113804.yaml b/.changes/unreleased/Fixed-20240429-113804.yaml new file mode 100644 index 000000000..0fa696901 --- /dev/null +++ b/.changes/unreleased/Fixed-20240429-113804.yaml @@ -0,0 +1,6 @@ +kind: Fixed +body: Allow the filter "filter accompanying period by geographical unit" to take period's + location on address into account +time: 2024-04-29T11:38:04.966027861+02:00 +custom: + Issue: "275" diff --git a/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/GeographicalUnitStatFilter.php b/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/GeographicalUnitStatFilter.php index ce193e1a4..7d0980dad 100644 --- a/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/GeographicalUnitStatFilter.php +++ b/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/GeographicalUnitStatFilter.php @@ -33,8 +33,12 @@ use Symfony\Component\Form\FormBuilderInterface; */ 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 @@ -50,6 +54,10 @@ class GeographicalUnitStatFilter implements FilterInterface FROM '.AccompanyingPeriod\AccompanyingPeriodLocationHistory::class.' acp_geog_filter_location_history 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) + 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 + )) 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 LEFT JOIN acp_geog_filter_address.geographicalUnits acp_geog_filter_units @@ -57,10 +65,6 @@ class GeographicalUnitStatFilter implements FilterInterface (acp_geog_filter_location_history.startDate <= :acp_geog_filter_date AND ( acp_geog_filter_location_history.endDate IS NULL OR acp_geog_filter_location_history.endDate > :acp_geog_filter_date )) - 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_location_history.period = acp.id '; From 89c231de41a1ad13a04663599bb20bd21539446c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Mon, 29 Apr 2024 13:03:22 +0200 Subject: [PATCH 09/30] Update geographical unit computation for closed periods in exports The geographical unit computation in the ChillPersonBundle now considers the closing date of an accompanying period when a person changes location. This provides more accurate statistics, especially in situations where the individual moved after the period closed. The changes also include refinements for the validFrom and validTo data within the AccompanyingCourseFilters and AccompanyingCourseAggregators. --- .changes/unreleased/Feature-20240429-130102.yaml | 7 +++++++ .../GeographicalUnitStatAggregator.php | 15 +++++++++------ .../GeographicalUnitStatFilter.php | 9 +++++---- 3 files changed, 21 insertions(+), 10 deletions(-) create mode 100644 .changes/unreleased/Feature-20240429-130102.yaml diff --git a/.changes/unreleased/Feature-20240429-130102.yaml b/.changes/unreleased/Feature-20240429-130102.yaml new file mode 100644 index 000000000..e07dcb40e --- /dev/null +++ b/.changes/unreleased/Feature-20240429-130102.yaml @@ -0,0 +1,7 @@ +kind: Feature +body: '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.' +time: 2024-04-29T13:01:02.463796452+02:00 +custom: + Issue: "276" diff --git a/src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/GeographicalUnitStatAggregator.php b/src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/GeographicalUnitStatAggregator.php index 2d6dbeae4..0a861d64c 100644 --- a/src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/GeographicalUnitStatAggregator.php +++ b/src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/GeographicalUnitStatAggregator.php @@ -28,8 +28,11 @@ use Symfony\Component\Form\FormBuilderInterface; final readonly class GeographicalUnitStatAggregator implements AggregatorInterface { - public function __construct(private GeographicalUnitLayerRepositoryInterface $geographicalUnitLayerRepository, private TranslatableStringHelperInterface $translatableStringHelper, private RollingDateConverterInterface $rollingDateConverter) - { + public function __construct( + private GeographicalUnitLayerRepositoryInterface $geographicalUnitLayerRepository, + private TranslatableStringHelperInterface $translatableStringHelper, + private RollingDateConverterInterface $rollingDateConverter + ) { } public function addRole(): ?string @@ -43,10 +46,10 @@ final readonly class GeographicalUnitStatAggregator implements AggregatorInterfa $qb->andWhere( $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( '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)' ) ) ); @@ -58,9 +61,9 @@ final readonly class GeographicalUnitStatAggregator implements AggregatorInterfa Join::WITH, $qb->expr()->andX( '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( - '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') ) ) diff --git a/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/GeographicalUnitStatFilter.php b/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/GeographicalUnitStatFilter.php index 7d0980dad..d84745403 100644 --- a/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/GeographicalUnitStatFilter.php +++ b/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/GeographicalUnitStatFilter.php @@ -52,18 +52,19 @@ class GeographicalUnitStatFilter implements FilterInterface 'SELECT 1 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 WITH IDENTITY(acp_geog_filter_location_history.personLocation) = IDENTITY(acp_geog_filter_address_person_location.person) 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 + (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 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 WHERE - (acp_geog_filter_location_history.startDate <= :acp_geog_filter_date 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.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 > LEAST(:acp_geog_filter_date, acp_geog_filter_location_history_period.closingDate) )) AND acp_geog_filter_units IN (:acp_geog_filter_units) AND acp_geog_filter_location_history.period = acp.id From 536c2622c79a8e38e075f61b6c689b001fafe10c Mon Sep 17 00:00:00 2001 From: Ronchie Blondiau Date: Tue, 7 May 2024 14:30:16 +0000 Subject: [PATCH 10/30] 239 - doc generation form added to top of doc list page when more than 5 documents --- .changes/unreleased/UX-20240507-160217.yaml | 5 + .../accompanying_period_list.html.twig | 108 +++++++++------- .../views/GenericDoc/person_list.html.twig | 122 +++++++++--------- 3 files changed, 122 insertions(+), 113 deletions(-) create mode 100644 .changes/unreleased/UX-20240507-160217.yaml diff --git a/.changes/unreleased/UX-20240507-160217.yaml b/.changes/unreleased/UX-20240507-160217.yaml new file mode 100644 index 000000000..f74a2f2ef --- /dev/null +++ b/.changes/unreleased/UX-20240507-160217.yaml @@ -0,0 +1,5 @@ +kind: UX +body: Form for document generation moved to the top of document list page +time: 2024-05-07T16:02:17.11820977+02:00 +custom: + Issue: "" diff --git a/src/Bundle/ChillDocStoreBundle/Resources/views/GenericDoc/accompanying_period_list.html.twig b/src/Bundle/ChillDocStoreBundle/Resources/views/GenericDoc/accompanying_period_list.html.twig index b22c7d00f..46b08f37d 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/views/GenericDoc/accompanying_period_list.html.twig +++ b/src/Bundle/ChillDocStoreBundle/Resources/views/GenericDoc/accompanying_period_list.html.twig @@ -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 %} +
+

{{ "Documents" }}

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

{{ 'Documents' }}

- - {{ filter|chill_render_filter_order_helper }} - - {% if documents|length == 0 %} -

{{ 'No documents'|trans }}

- {% else %} -
- {% for document in documents %} - {{ document|chill_generic_doc_render }} - {% endfor %} -
- {% endif %} - - {{ chill_pagination(pagination) }} - -
- - {% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_CREATE', accompanyingCourse) %} - - {% endif %} + {{ filter | chill_render_filter_order_helper }} + {% if documents|length > 5 %} +
+ {% endif %} {% if documents|length == 0 %} +

{{ "No documents" | trans }}

+ {% else %} +
+ {% for document in documents %} + {{ document | chill_generic_doc_render }} + {% endfor %}
+ {% endif %} + + {{ chill_pagination(pagination) }} + +
+ + {% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_CREATE', + accompanyingCourse) %} + + {% endif %} +
{% endblock %} diff --git a/src/Bundle/ChillDocStoreBundle/Resources/views/GenericDoc/person_list.html.twig b/src/Bundle/ChillDocStoreBundle/Resources/views/GenericDoc/person_list.html.twig index a4aa4fbbc..5bc9f42d1 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/views/GenericDoc/person_list.html.twig +++ b/src/Bundle/ChillDocStoreBundle/Resources/views/GenericDoc/person_list.html.twig @@ -1,74 +1,70 @@ -{# - * Copyright (C) 2018, Champs Libres Cooperative SCRLFS, - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . -#} - -{% extends "@ChillPerson/Person/layout.html.twig" %} - -{% set activeRouteKey = '' %} - -{% import "@ChillDocStore/Macro/macro.html.twig" as m %} - -{% block title %} - {{ '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 %} +{# * Copyright (C) 2018, Champs Libres Cooperative SCRLFS, + * * This program is free software: you can +redistribute it and/or modify * it under the terms of the GNU Affero General +Public License as * published by the Free Software Foundation, either version 3 +of the * License, or (at your option) any later version. * * This program is +distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; +without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A +PARTICULAR PURPOSE. See the * GNU Affero General Public License for more +details. * * You should have received a copy of the GNU Affero General Public +License * along with this program. If not, see . +#} {% extends "@ChillPerson/Person/layout.html.twig" %} {% set activeRouteKey = +'' %} {% import "@ChillDocStore/Macro/macro.html.twig" as m %} {% block title %} +{{ '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 %}
-

{{ 'Documents for %name%'|trans({ '%name%': person|chill_entity_render_string } ) }}

+

+ {{ 'Documents for %name%'|trans({ '%name%': person|chill_entity_render_string } ) }} +

- {{ filter|chill_render_filter_order_helper }} + {{ filter | chill_render_filter_order_helper }} - {% if documents|length == 0 %} -

{{ 'No documents'|trans }}

+ {% if documents|length > 5 %} +
+ {% endif %} {% if documents|length == 0 %} +

{{ "No documents" | trans }}

{% else %} -
- {% for document in documents %} - {{ document|chill_generic_doc_render }} - {% endfor %} -
+
+ {% for document in documents %} + {{ document | chill_generic_doc_render }} + {% endfor %} +
{% endif %} - {{ chill_pagination(pagination) }} + {{ chill_pagination(pagination) }} -
- - {% if is_granted('CHILL_PERSON_DOCUMENT_CREATE', person) %} - - {% endif %} +
+ {% if is_granted('CHILL_PERSON_DOCUMENT_CREATE', person) %} + + {% endif %}
{% endblock %} From 46c647cbb77ebeef220ec5463a5183213a298a22 Mon Sep 17 00:00:00 2001 From: rblondiau Date: Thu, 25 Apr 2024 10:39:56 +0200 Subject: [PATCH 11/30] fixed events width --- .../Resources/views/Event/new.html.twig | 34 ++- .../Resources/views/Event/page_list.html.twig | 196 ++++++++++-------- 2 files changed, 131 insertions(+), 99 deletions(-) diff --git a/src/Bundle/ChillEventBundle/Resources/views/Event/new.html.twig b/src/Bundle/ChillEventBundle/Resources/views/Event/new.html.twig index 12ea8f246..6412c94a5 100644 --- a/src/Bundle/ChillEventBundle/Resources/views/Event/new.html.twig +++ b/src/Bundle/ChillEventBundle/Resources/views/Event/new.html.twig @@ -1,18 +1,11 @@ -{% extends '@ChillEvent/layout.html.twig' %} - -{% block js %} - {{ encore_entry_script_tags('mod_async_upload') }} -{% endblock %} - -{% block css %} - {{ encore_entry_link_tags('mod_async_upload') }} -{% endblock %} - -{% block title 'Event creation'|trans %} - -{% block event_content -%} +{% extends '@ChillEvent/layout.html.twig' %} {% block js %} +{{ encore_entry_script_tags("mod_async_upload") }} +{% endblock %} {% block css %} +{{ encore_entry_link_tags("mod_async_upload") }} +{% endblock %} {% block title 'Event creation'|trans %} {% block event_content +-%}
-

{{ 'Event creation'|trans }}

+

{{ "Event creation" | trans }}

{{ form_start(form) }} {{ form_errors(form) }} @@ -20,7 +13,7 @@ {{ form_row(form.name) }} {{ 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.location) }} {{ form_row(form.organizationCost) }} @@ -30,12 +23,17 @@ diff --git a/src/Bundle/ChillEventBundle/Resources/views/Event/page_list.html.twig b/src/Bundle/ChillEventBundle/Resources/views/Event/page_list.html.twig index f22b56c05..bb1ffa24e 100644 --- a/src/Bundle/ChillEventBundle/Resources/views/Event/page_list.html.twig +++ b/src/Bundle/ChillEventBundle/Resources/views/Event/page_list.html.twig @@ -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 %} +
+

{{ block("title") }}

-{% block title 'Events'|trans %} + {{ filter | chill_render_filter_order_helper }} -{% block js %} - {{ parent() }} - {{ encore_entry_script_tags('mod_pickentity_type') }} -{% endblock %} - -{% block css %} - {{ parent() }} - {{ encore_entry_link_tags('mod_pickentity_type') }} -{% endblock %} - -{% block content %} -

{{ block('title') }}

- - {{ filter|chill_render_filter_order_helper }} - -{# {% if is_granted('CHILL_EVENT_CREATE') %} #} - - {# {% endif %} #} - {% if events|length > 0 %} -
- {% for e in events %} -
-
-
-
- {{ e.name }} -
-

{{ e.type.name|localize_translatable_string }}

- {% if e.moderator is not null %} -

{{ 'Moderator'|trans }}: {{ e.moderator|chill_entity_render_box }}

- {% endif %} -
-
-
-

{{ e.date|format_datetime('medium', 'medium') }}

-

{{ 'count participations to this event'|trans({'count': e.participations|length}) }}

-
-
+ {# {% if is_granted('CHILL_EVENT_CREATE') %} #} + + {# {% endif %} #} {% if events|length > 0 %} +
+ {% for e in events %} +
+
+
+
+ {{ e.name }}
- {% if e.participations|length > 0 %} -
- {{ 'Participations'|trans }} : - {% 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 %} -
+

{{ e.type.name | localize_translatable_string }}

+ {% if e.moderator is not null %} +

+ {{ "Moderator" | trans }}: + {{ e.moderator | chill_entity_render_box }} +

{% endif %} -
-
- {{ form_start(eventForms[e.id]) }} - {{ form_widget(eventForms[e.id].person_id) }} - {{ form_end(eventForms[e.id]) }} -
-
-
-
-
-
-
    - {% if is_granted('CHILL_EVENT_UPDATE', e) %} -
  • - {% endif %} - {% if is_granted('CHILL_EVENT_UPDATE', e) %} -
  • - {% endif %} -
  • -
-
+
+
+
+

{{ e.date|format_datetime('medium', 'medium') }}

+

+ {{ 'count participations to this event'|trans({'count': e.participations|length}) }} +

- {% endfor %} +
+ {% if e.participations|length > 0 %} +
+ {{ "Participations" | trans }} : + {% 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 %} +
+ {% endif %} +
+
+ {{ form_start(eventForms[e.id]) }} + {{ form_widget(eventForms[e.id].person_id) }} + {{ form_end(eventForms[e.id]) }} +
+
+
+
+
+
    + {% if is_granted('CHILL_EVENT_UPDATE', e) %} +
  • + +
  • + {% endif %} {% if is_granted('CHILL_EVENT_UPDATE', e) %} +
  • + +
  • + {% endif %} +
  • + +
  • +
+
+
+ {% endfor %} +
{% endif %} +
- {{ chill_pagination(pagination) }} +{{ chill_pagination(pagination) }} {% endblock %} From d50d067bf79df4812a121c87647d3fe505108b3d Mon Sep 17 00:00:00 2001 From: rblondiau Date: Thu, 25 Apr 2024 12:51:20 +0200 Subject: [PATCH 12/30] added button for moderators and fixed participant styling --- .../ChillEventBundle/Form/EventType.php | 14 ++++------ .../Resources/views/Event/edit.html.twig | 28 +++++++++++++------ .../Resources/views/Event/new.html.twig | 6 ++-- 3 files changed, 28 insertions(+), 20 deletions(-) diff --git a/src/Bundle/ChillEventBundle/Form/EventType.php b/src/Bundle/ChillEventBundle/Form/EventType.php index fe64ef12b..3363079fa 100644 --- a/src/Bundle/ChillEventBundle/Form/EventType.php +++ b/src/Bundle/ChillEventBundle/Form/EventType.php @@ -18,6 +18,7 @@ use Chill\MainBundle\Entity\Center; use Chill\MainBundle\Form\Type\ChillCollectionType; use Chill\MainBundle\Form\Type\ChillDateTimeType; use Chill\MainBundle\Form\Type\CommentType; +use Chill\MainBundle\Form\Type\PickUserDynamicType; use Chill\MainBundle\Form\Type\PickUserLocationType; use Chill\MainBundle\Form\Type\ScopePickerType; use Chill\MainBundle\Form\Type\UserPickerType; @@ -45,14 +46,9 @@ class EventType extends AbstractType 'class' => '', ], ]) - ->add('moderator', UserPickerType::class, [ - 'center' => $options['center'], - 'role' => $options['role'], - 'placeholder' => 'Pick a moderator', - 'attr' => [ - 'class' => '', - ], - 'required' => false, + ->add('moderator', PickUserDynamicType::class, [ + 'label' => 'Pick a moderator', + ]) ->add('location', PickUserLocationType::class, [ 'label' => 'event.fields.location', @@ -68,7 +64,7 @@ class EventType extends AbstractType ], 'allow_add' => true, 'allow_delete' => true, - 'delete_empty' => fn (StoredObject $storedObject): bool => '' === $storedObject->getFilename(), + 'delete_empty' => fn(StoredObject $storedObject): bool => '' === $storedObject->getFilename(), 'button_remove_label' => 'event.form.remove_document', 'button_add_label' => 'event.form.add_document', ]) diff --git a/src/Bundle/ChillEventBundle/Resources/views/Event/edit.html.twig b/src/Bundle/ChillEventBundle/Resources/views/Event/edit.html.twig index fc4001c63..b6b11878b 100644 --- a/src/Bundle/ChillEventBundle/Resources/views/Event/edit.html.twig +++ b/src/Bundle/ChillEventBundle/Resources/views/Event/edit.html.twig @@ -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 -%}
-

{{ 'Event edit'|trans }}

+

{{ "Event edit" | trans }}

{{ form_start(edit_form) }} {{ form_errors(edit_form) }} @@ -12,7 +16,7 @@ {{ form_row(edit_form.name) }} {{ 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.location) }} {{ form_row(edit_form.organizationCost) }} @@ -22,16 +26,22 @@ {{ form_end(edit_form) }} -
{% endblock %} diff --git a/src/Bundle/ChillEventBundle/Resources/views/Event/new.html.twig b/src/Bundle/ChillEventBundle/Resources/views/Event/new.html.twig index 6412c94a5..0fb69a4ea 100644 --- a/src/Bundle/ChillEventBundle/Resources/views/Event/new.html.twig +++ b/src/Bundle/ChillEventBundle/Resources/views/Event/new.html.twig @@ -1,7 +1,11 @@ {% extends '@ChillEvent/layout.html.twig' %} {% block js %} {{ encore_entry_script_tags("mod_async_upload") }} +{{ encore_entry_script_tags("mod_pickentity_type") }} + {% endblock %} {% block css %} {{ encore_entry_link_tags("mod_async_upload") }} +{{ encore_entry_link_tags("mod_pickentity_type") }} + {% endblock %} {% block title 'Event creation'|trans %} {% block event_content -%}
@@ -12,12 +16,10 @@ {{ form_row(form.circle) }} {{ form_row(form.name) }} {{ form_row(form.date) }} - {{ form_row(form.type, { label: "Event type" }) }} {{ form_row(form.moderator) }} {{ form_row(form.location) }} {{ form_row(form.organizationCost) }} - {{ form_row(form.comment) }} {{ form_row(form.documents) }} From 651c455bdf7c52f4639473b0c5019192d632e68f Mon Sep 17 00:00:00 2001 From: rblondiau Date: Thu, 25 Apr 2024 15:05:11 +0200 Subject: [PATCH 13/30] added translation for No item --- src/Bundle/ChillDocStoreBundle/translations/messages.fr.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Bundle/ChillDocStoreBundle/translations/messages.fr.yml b/src/Bundle/ChillDocStoreBundle/translations/messages.fr.yml index ffabdaa59..0bfe2b64c 100644 --- a/src/Bundle/ChillDocStoreBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillDocStoreBundle/translations/messages.fr.yml @@ -13,13 +13,14 @@ Update document: Modifier le document Edit attributes: Modifier les propriétés du document Existing document: Document existant 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é The document is successfully registered: Le document est enregistré The document is successfully updated: Le document est mis à jour Any description: Aucune description Document from person %name%: Document de l'usager %name% See the document: Voir le document +No item: Aucun document document: Any title: Aucun titre @@ -36,7 +37,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 ? The document is successfully removed: Le document a été supprimé - # dropzone upload File too big: Fichier trop volumineux Drop your file or click here: Cliquez ici ou faites glissez votre nouveau fichier dans cette zone From f7f7e1cb1d905b4f5fa6d9bfbc8d72060a6a1f22 Mon Sep 17 00:00:00 2001 From: rblondiau Date: Thu, 25 Apr 2024 15:44:29 +0200 Subject: [PATCH 14/30] updated translation by changing translation in twig --- src/Bundle/ChillDocStoreBundle/translations/messages.fr.yml | 1 - .../ChillMainBundle/Resources/views/Form/fields.html.twig | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Bundle/ChillDocStoreBundle/translations/messages.fr.yml b/src/Bundle/ChillDocStoreBundle/translations/messages.fr.yml index 0bfe2b64c..c16c161bc 100644 --- a/src/Bundle/ChillDocStoreBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillDocStoreBundle/translations/messages.fr.yml @@ -20,7 +20,6 @@ The document is successfully updated: Le document est mis à jour Any description: Aucune description Document from person %name%: Document de l'usager %name% See the document: Voir le document -No item: Aucun document document: Any title: Aucun titre diff --git a/src/Bundle/ChillMainBundle/Resources/views/Form/fields.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Form/fields.html.twig index 9c60028ad..1d0764980 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Form/fields.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Form/fields.html.twig @@ -173,10 +173,10 @@ {{ form_widget(entry) }} {{ form_errors(entry) }}
- + {% else %}
  • - {{ form.vars.empty_collection_explain|default('No item')|trans }} + {{ form.vars.empty_collection_explain|default('No entities')|trans }}
  • {% endfor %} From 30955752c34ac3b2586bed9fef5a03eda19db509 Mon Sep 17 00:00:00 2001 From: Julie Lenaerts Date: Wed, 8 May 2024 10:28:14 +0200 Subject: [PATCH 15/30] fix pipeline and add changie --- .changes/unreleased/UX-20240508-102757.yaml | 5 +++++ src/Bundle/ChillEventBundle/Form/EventType.php | 4 +--- 2 files changed, 6 insertions(+), 3 deletions(-) create mode 100644 .changes/unreleased/UX-20240508-102757.yaml diff --git a/.changes/unreleased/UX-20240508-102757.yaml b/.changes/unreleased/UX-20240508-102757.yaml new file mode 100644 index 000000000..8ba540e2d --- /dev/null +++ b/.changes/unreleased/UX-20240508-102757.yaml @@ -0,0 +1,5 @@ +kind: UX +body: Adjust certain graphical issues for better user experience +time: 2024-05-08T10:27:57.220873296+02:00 +custom: + Issue: "266" diff --git a/src/Bundle/ChillEventBundle/Form/EventType.php b/src/Bundle/ChillEventBundle/Form/EventType.php index 3363079fa..98cb5ea5e 100644 --- a/src/Bundle/ChillEventBundle/Form/EventType.php +++ b/src/Bundle/ChillEventBundle/Form/EventType.php @@ -21,7 +21,6 @@ use Chill\MainBundle\Form\Type\CommentType; use Chill\MainBundle\Form\Type\PickUserDynamicType; use Chill\MainBundle\Form\Type\PickUserLocationType; use Chill\MainBundle\Form\Type\ScopePickerType; -use Chill\MainBundle\Form\Type\UserPickerType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\MoneyType; use Symfony\Component\Form\FormBuilderInterface; @@ -48,7 +47,6 @@ class EventType extends AbstractType ]) ->add('moderator', PickUserDynamicType::class, [ 'label' => 'Pick a moderator', - ]) ->add('location', PickUserLocationType::class, [ 'label' => 'event.fields.location', @@ -64,7 +62,7 @@ class EventType extends AbstractType ], 'allow_add' => true, 'allow_delete' => true, - 'delete_empty' => fn(StoredObject $storedObject): bool => '' === $storedObject->getFilename(), + 'delete_empty' => fn (StoredObject $storedObject): bool => '' === $storedObject->getFilename(), 'button_remove_label' => 'event.form.remove_document', 'button_add_label' => 'event.form.add_document', ]) From 20291026fb94de1ab535db8dd458cbc49d0be440 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Tue, 14 May 2024 16:05:23 +0200 Subject: [PATCH 16/30] release 2.19.0 --- .../unreleased/Feature-20240426-134831.yaml | 6 ------ .../unreleased/Feature-20240429-130102.yaml | 7 ------- .../unreleased/Fixed-20240416-161817.yaml | 6 ------ .../unreleased/Fixed-20240429-113804.yaml | 6 ------ .changes/unreleased/UX-20240507-160217.yaml | 5 ----- .changes/unreleased/UX-20240508-102757.yaml | 5 ----- .changes/v2.19.0.md | 20 ++++++++++++++++++ CHANGELOG.md | 21 +++++++++++++++++++ 8 files changed, 41 insertions(+), 35 deletions(-) delete mode 100644 .changes/unreleased/Feature-20240426-134831.yaml delete mode 100644 .changes/unreleased/Feature-20240429-130102.yaml delete mode 100644 .changes/unreleased/Fixed-20240416-161817.yaml delete mode 100644 .changes/unreleased/Fixed-20240429-113804.yaml delete mode 100644 .changes/unreleased/UX-20240507-160217.yaml delete mode 100644 .changes/unreleased/UX-20240508-102757.yaml create mode 100644 .changes/v2.19.0.md diff --git a/.changes/unreleased/Feature-20240426-134831.yaml b/.changes/unreleased/Feature-20240426-134831.yaml deleted file mode 100644 index 00952fe57..000000000 --- a/.changes/unreleased/Feature-20240426-134831.yaml +++ /dev/null @@ -1,6 +0,0 @@ -kind: Feature -body: Make the script which subscribe to microsoft calendars changes more tolerant - to errors or missing configuration on the microsoft side -time: 2024-04-26T13:48:31.476415017+02:00 -custom: - Issue: "197" diff --git a/.changes/unreleased/Feature-20240429-130102.yaml b/.changes/unreleased/Feature-20240429-130102.yaml deleted file mode 100644 index e07dcb40e..000000000 --- a/.changes/unreleased/Feature-20240429-130102.yaml +++ /dev/null @@ -1,7 +0,0 @@ -kind: Feature -body: '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.' -time: 2024-04-29T13:01:02.463796452+02:00 -custom: - Issue: "276" diff --git a/.changes/unreleased/Fixed-20240416-161817.yaml b/.changes/unreleased/Fixed-20240416-161817.yaml deleted file mode 100644 index 6884c11fe..000000000 --- a/.changes/unreleased/Fixed-20240416-161817.yaml +++ /dev/null @@ -1,6 +0,0 @@ -kind: Fixed -body: Fix broken link in homepage when a evaluation from a closed acc period was present - in the homepage widget -time: 2024-04-16T16:18:17.888645172+02:00 -custom: - Issue: "270" diff --git a/.changes/unreleased/Fixed-20240429-113804.yaml b/.changes/unreleased/Fixed-20240429-113804.yaml deleted file mode 100644 index 0fa696901..000000000 --- a/.changes/unreleased/Fixed-20240429-113804.yaml +++ /dev/null @@ -1,6 +0,0 @@ -kind: Fixed -body: Allow the filter "filter accompanying period by geographical unit" to take period's - location on address into account -time: 2024-04-29T11:38:04.966027861+02:00 -custom: - Issue: "275" diff --git a/.changes/unreleased/UX-20240507-160217.yaml b/.changes/unreleased/UX-20240507-160217.yaml deleted file mode 100644 index f74a2f2ef..000000000 --- a/.changes/unreleased/UX-20240507-160217.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: UX -body: Form for document generation moved to the top of document list page -time: 2024-05-07T16:02:17.11820977+02:00 -custom: - Issue: "" diff --git a/.changes/unreleased/UX-20240508-102757.yaml b/.changes/unreleased/UX-20240508-102757.yaml deleted file mode 100644 index 8ba540e2d..000000000 --- a/.changes/unreleased/UX-20240508-102757.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: UX -body: Adjust certain graphical issues for better user experience -time: 2024-05-08T10:27:57.220873296+02:00 -custom: - Issue: "266" 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/CHANGELOG.md b/CHANGELOG.md index ae9407c8a..9b2f5e2e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,27 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), 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 From 146e0090fbb95f8576454fa4f5f18df1376efa25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Tue, 12 Sep 2023 11:24:50 +0200 Subject: [PATCH 17/30] Webdav: fully implements the controller and response The controller is tested from real request scraped from apache mod_dav implementation. The requests were scraped using a wireshark-like tool. Those requests have been adapted to suit to our xml. --- composer.json | 1 + .../Controller/WebdavController.php | 232 ++++++++++ .../Dav/Exception/ParseRequestException.php | 14 + .../Dav/Request/PropfindRequestAnalyzer.php | 104 +++++ .../Dav/Response/DavResponse.php | 24 ++ .../views/Webdav/directory.html.twig | 12 + .../views/Webdav/directory_propfind.xml.twig | 81 ++++ .../Resources/views/Webdav/doc_props.xml.twig | 53 +++ .../views/Webdav/open_in_browser.html.twig | 7 + .../Service/StoredObjectManager.php | 72 ++++ .../Service/StoredObjectManagerInterface.php | 4 + .../Tests/Controller/WebdavControllerTest.php | 408 ++++++++++++++++++ .../Request/PropfindRequestAnalyzerTest.php | 134 ++++++ utils/http/docstore/dav.http | 16 + utils/http/docstore/http-client.env.json | 6 + 15 files changed, 1168 insertions(+) create mode 100644 src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php create mode 100644 src/Bundle/ChillDocStoreBundle/Dav/Exception/ParseRequestException.php create mode 100644 src/Bundle/ChillDocStoreBundle/Dav/Request/PropfindRequestAnalyzer.php create mode 100644 src/Bundle/ChillDocStoreBundle/Dav/Response/DavResponse.php create mode 100644 src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/directory.html.twig create mode 100644 src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/directory_propfind.xml.twig create mode 100644 src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/doc_props.xml.twig create mode 100644 src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/open_in_browser.html.twig create mode 100644 src/Bundle/ChillDocStoreBundle/Tests/Controller/WebdavControllerTest.php create mode 100644 src/Bundle/ChillDocStoreBundle/Tests/Dav/Request/PropfindRequestAnalyzerTest.php create mode 100644 utils/http/docstore/dav.http create mode 100644 utils/http/docstore/http-client.env.json diff --git a/composer.json b/composer.json index 3195f732b..ffe87edb1 100644 --- a/composer.json +++ b/composer.json @@ -9,6 +9,7 @@ ], "require": { "php": "^8.2", + "ext-dom": "*", "ext-json": "*", "ext-openssl": "*", "ext-redis": "*", diff --git a/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php b/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php new file mode 100644 index 000000000..30a7e4eb2 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php @@ -0,0 +1,232 @@ +requestAnalyzer = new PropfindRequestAnalyzer(); + } + + /** + * @Route("/dav/open/{uuid}") + */ + public function open(StoredObject $storedObject): Response + { + /*$accessToken = $this->JWTTokenManager->createFromPayload($this->security->getUser(), [ + 'UserCanWrite' => true, + 'UserCanAttend' => true, + 'UserCanPresent' => true, + 'fileId' => $storedObject->getUuid(), + ]);*/ + + return new DavResponse($this->engine->render('@ChillDocStore/Webdav/open_in_browser.html.twig', [ + 'stored_object' => $storedObject, 'access_token' => '', + ])); + } + + /** + * @Route("/dav/get/{uuid}/", methods={"GET", "HEAD"}, name="chill_docstore_dav_directory_get") + */ + public function getDirectory(StoredObject $storedObject): Response + { + return new DavResponse( + $this->engine->render('@ChillDocStore/Webdav/directory.html.twig', [ + 'stored_object' => $storedObject + ]) + ); + } + + /** + * @Route("/dav/get/{uuid}/", methods={"OPTIONS"}) + */ + public function optionsDirectory(StoredObject $storedObject): Response + { + $response = (new DavResponse("")) + ->setEtag($this->storedObjectManager->etag($storedObject)) + ; + + $response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT,PROPPATCH,COPY,MOVE,REPORT,PATCH,POST,TRACE']); + + return $response; + } + + /** + * @Route("/dav/get/{uuid}/", methods={"PROPFIND"}) + */ + public function propfindDirectory(StoredObject $storedObject, Request $request): Response + { + $depth = $request->headers->get('depth'); + + if ("0" !== $depth && "1" !== $depth) { + throw new BadRequestHttpException("only 1 and 0 are accepted for Depth header"); + } + + $content = $request->getContent(); + $xml = new \DOMDocument(); + $xml->loadXML($content); + + $properties = $this->requestAnalyzer->getRequestedProperties($xml); + $requested = array_keys(array_filter($properties, fn ($item) => true === $item)); + + if ( + in_array('lastModified', $requested, true) + || in_array('etag', $requested, true) + ) { + $lastModified = $this->storedObjectManager->getLastModified($storedObject); + $etag = $this->storedObjectManager->etag($storedObject); + + } + if (in_array('contentLength', $requested, true)) { + $length = $this->storedObjectManager->getContentLength($storedObject); + } + + $response = new DavResponse( + $this->engine->render('@ChillDocStore/Webdav/directory_propfind.xml.twig', [ + 'stored_object' => $storedObject, + 'properties' => $properties, + 'last_modified' => $lastModified ?? null, + 'etag' => $etag ?? null, + 'content_length' => $length ?? null, + 'depth' => (int) $depth + ]), + 207 + ); + + $response->headers->add([ + 'Content-Type' => 'text/xml' + ]); + + return $response; + } + + /** + * @Route("/dav/get/{uuid}/d", name="chill_docstore_dav_document_get", methods={"GET"}) + */ + public function getDocument(StoredObject $storedObject): Response + { + return (new DavResponse($this->storedObjectManager->read($storedObject))) + ->setEtag($this->storedObjectManager->etag($storedObject)); + } + + /** + * @Route("/dav/get/{uuid}/d", methods={"HEAD"}) + */ + public function headDocument(StoredObject $storedObject): Response + { + $response = new DavResponse(""); + + $response->headers->add( + [ + 'Content-Length' => $this->storedObjectManager->getContentLength($storedObject), + 'Content-Type' => $storedObject->getType(), + 'Etag' => $this->storedObjectManager->etag($storedObject) + ] + ); + + return $response; + } + + /** + * @Route("/dav/get/{uuid}/d", methods={"OPTIONS"}) + */ + public function optionsDocument(StoredObject $storedObject): Response + { + $response = (new DavResponse("")) + ->setEtag($this->storedObjectManager->etag($storedObject)) + ; + + $response->headers->add(['Allow' => /*sprintf( + '%s, %s, %s, %s, %s', + Request::METHOD_OPTIONS, + Request::METHOD_GET, + Request::METHOD_HEAD, + Request::METHOD_PUT, + 'PROPFIND' + ) */ 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT,PROPPATCH,COPY,MOVE,REPORT,PATCH,POST,TRACE' + ]); + + return $response; + } + + /** + * @Route("/dav/get/{uuid}/d", methods={"PROPFIND"}) + */ + public function propfindDocument(StoredObject $storedObject, Request $request): Response + { + $content = $request->getContent(); + $xml = new \DOMDocument(); + $xml->loadXML($content); + + $properties = $this->requestAnalyzer->getRequestedProperties($xml); + $requested = array_keys(array_filter($properties, fn ($item) => true === $item)); + + if ( + in_array('lastModified', $requested, true) + || in_array('etag', $requested, true) + ) { + $lastModified = $this->storedObjectManager->getLastModified($storedObject); + $etag = $this->storedObjectManager->etag($storedObject); + } + if (in_array('contentLength', $requested, true)) { + $length = $this->storedObjectManager->getContentLength($storedObject); + } + + $response = new DavResponse( + $this->engine->render( + '@ChillDocStore/Webdav/doc_props.xml.twig', + [ + 'stored_object' => $storedObject, + 'properties' => $properties, + 'etag' => $etag ?? null, + 'last_modified' => $lastModified ?? null, + 'content_length' => $length ?? null, + ] + ), + 207 + ); + + $response + ->headers->add([ + 'Content-Type' => 'text/xml' + ]); + + return $response; + } + + /** + * @Route("/dav/get/{uuid}/d", methods={"PUT"}) + */ + public function putDocument(StoredObject $storedObject, Request $request): Response + { + $this->storedObjectManager->write($storedObject, $request->getContent()); + + return (new DavResponse("", Response::HTTP_NO_CONTENT)); + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Dav/Exception/ParseRequestException.php b/src/Bundle/ChillDocStoreBundle/Dav/Exception/ParseRequestException.php new file mode 100644 index 000000000..0c740be17 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Dav/Exception/ParseRequestException.php @@ -0,0 +1,14 @@ +} + */ +class PropfindRequestAnalyzer +{ + private const KNOWN_PROPS = [ + 'resourceType', + 'contentType', + 'lastModified', + 'creationDate', + 'contentLength', + 'etag', + 'supportedLock', + ]; + + /** + * @return davProperties + */ + public function getRequestedProperties(\DOMDocument $request): array + { + $propfinds = $request->getElementsByTagNameNS('DAV:', 'propfind'); + + if (0 === $propfinds->count()) { + throw new ParseRequestException("any propfind element found"); + } + + if (1 < $propfinds->count()) { + throw new ParseRequestException("too much propfind element found"); + } + + $propfind = $propfinds->item(0); + + if (0 === $propfind->childNodes->count()) { + throw new ParseRequestException("no element under propfind"); + } + + $unknows = []; + $props = []; + + foreach ($propfind->childNodes->getIterator() as $prop) { + /** @var \DOMNode $prop */ + if (XML_ELEMENT_NODE !== $prop->nodeType) { + continue; + } + + if ('propname' === $prop->nodeName) { + return $this->baseProps(true); + } + + foreach ($prop->childNodes->getIterator() as $getProp) { + if (XML_ELEMENT_NODE !== $getProp->nodeType) { + continue; + } + + if ('DAV:' !== $getProp->lookupNamespaceURI(null)) { + $unknows[] = ['xmlns' => $getProp->lookupNamespaceURI(null), 'prop' => $getProp->nodeName]; + continue; + } + + $props[] = match ($getProp->nodeName) { + 'resourcetype' => 'resourceType', + 'getcontenttype' => 'contentType', + 'getlastmodified' => 'lastModified', + default => '', + }; + } + + } + + $props = array_filter(array_values($props), fn (string $item) => '' !== $item); + + return [...$this->baseProps(false), ...array_combine($props, array_fill(0, count($props), true)), 'unknowns' => $unknows]; + } + + /** + * @return davProperties + */ + private function baseProps(bool $default = false): array + { + return + [ + ...array_combine( + self::KNOWN_PROPS, + array_fill(0, count(self::KNOWN_PROPS), $default) + ), + 'unknowns' => [] + ]; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Dav/Response/DavResponse.php b/src/Bundle/ChillDocStoreBundle/Dav/Response/DavResponse.php new file mode 100644 index 000000000..32332d20a --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Dav/Response/DavResponse.php @@ -0,0 +1,24 @@ +headers->add(['DAV' => '1']); + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/directory.html.twig b/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/directory.html.twig new file mode 100644 index 000000000..5a95e894a --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/directory.html.twig @@ -0,0 +1,12 @@ + + + + + Directory for {{ stored_object.uuid }} + + +
      +
    • d
    • +
    + + diff --git a/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/directory_propfind.xml.twig b/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/directory_propfind.xml.twig new file mode 100644 index 000000000..6edbb76e0 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/directory_propfind.xml.twig @@ -0,0 +1,81 @@ + + + + {{ path('chill_docstore_dav_directory_get', { 'uuid': stored_object.uuid } ) }} + {% if properties.resourceType or properties.contentType %} + + + {% if properties.resourceType %} + + {% endif %} + {% if properties.contentType %} + httpd/unix-directory + {% endif %} + + HTTP/1.1 200 OK + + {% endif %} + {% if properties.unknowns|length > 0 %} + + {% for k,u in properties.unknowns %} + + <{{ 'ns'~ k ~ ':' ~ u.prop }} /> + + {% endfor %} + HTTP/1.1 404 Not Found + + {% endif %} + + {% if depth == 1 %} + + {{ path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid}) }} + {% if properties.lastModified or properties.contentLength or properties.resourceType or properties.etag or properties.contentType or properties.creationDate %} + + + {% if properties.resourceType %} + + {% endif %} + {% if properties.creationDate %} + + {% endif %} + {% if properties.lastModified %} + {% if last_modified is not same as null %} + {{ last_modified.format(constant('DATE_RSS')) }} + {% else %} + + {% endif %} + {% endif %} + {% if properties.contentLength %} + {% if content_length is not same as null %} + {{ content_length }} + {% else %} + + {% endif %} + {% endif %} + {% if properties.etag %} + {% if etag is not same as null %} + "{{ etag }}" + {% else %} + + {% endif %} + {% endif %} + {% if properties.contentType %} + {{ stored_object.type }} + {% endif %} + + HTTP/1.1 200 OK + + {% endif %} + {% if properties.unknowns|length > 0 %} + + {% for k,u in properties.unknowns %} + + <{{ 'ns'~ k ~ ':' ~ u.prop }} /> + + {% endfor %} + HTTP/1.1 404 Not Found + + {% endif %} + + {% endif %} + diff --git a/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/doc_props.xml.twig b/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/doc_props.xml.twig new file mode 100644 index 000000000..3d320295b --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/doc_props.xml.twig @@ -0,0 +1,53 @@ + + + + {{ path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid}) }} + {% if properties.lastModified or properties.contentLength or properties.resourceType or properties.etag or properties.contentType or properties.creationDate %} + + + {% if properties.resourceType %} + + {% endif %} + {% if properties.creationDate %} + + {% endif %} + {% if properties.lastModified %} + {% if last_modified is not same as null %} + {{ last_modified.format(constant('DATE_RSS')) }} + {% else %} + + {% endif %} + {% endif %} + {% if properties.contentLength %} + {% if content_length is not same as null %} + {{ content_length }} + {% else %} + + {% endif %} + {% endif %} + {% if properties.etag %} + {% if etag is not same as null %} + "{{ etag }}" + {% else %} + + {% endif %} + {% endif %} + {% if properties.contentType %} + {{ stored_object.type }} + {% endif %} + + HTTP/1.1 200 OK + + {% endif %} + {% if properties.unknowns|length > 0 %} + + {% for k,u in properties.unknowns %} + + <{{ 'ns'~ k ~ ':' ~ u.prop }} /> + + {% endfor %} + HTTP/1.1 404 Not Found + + {% endif %} + + diff --git a/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/open_in_browser.html.twig b/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/open_in_browser.html.twig new file mode 100644 index 000000000..37d137047 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/open_in_browser.html.twig @@ -0,0 +1,7 @@ +{% extends '@ChillMain/layout.html.twig' %} + +{% block content %} +

    document uuid: {{ stored_object.uuid }}

    +

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

    +Open document +{% endblock %} diff --git a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManager.php b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManager.php index b6ab798b8..bbbf615af 100644 --- a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManager.php +++ b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManager.php @@ -57,6 +57,62 @@ final class StoredObjectManager implements StoredObjectManagerInterface return $this->extractLastModifiedFromResponse($response); } + public function getContentLength(StoredObject $document): int + { + if ([] === $document->getKeyInfos()) { + if ($this->hasCache($document)) { + $response = $this->getResponseFromCache($document); + } else { + try { + $response = $this + ->client + ->request( + Request::METHOD_HEAD, + $this + ->tempUrlGenerator + ->generate( + Request::METHOD_HEAD, + $document->getFilename() + ) + ->url + ); + } catch (TransportExceptionInterface $exception) { + throw StoredObjectManagerException::errorDuringHttpRequest($exception); + } + } + + return $this->extractContentLengthFromResponse($response); + } + + return strlen($this->read($document)); + } + + public function etag(StoredObject $document): string + { + if ($this->hasCache($document)) { + $response = $this->getResponseFromCache($document); + } else { + try { + $response = $this + ->client + ->request( + Request::METHOD_HEAD, + $this + ->tempUrlGenerator + ->generate( + Request::METHOD_HEAD, + $document->getFilename() + ) + ->url + ); + } catch (TransportExceptionInterface $exception) { + throw StoredObjectManagerException::errorDuringHttpRequest($exception); + } + } + + return $this->extractEtagFromResponse($response, $document); + } + public function read(StoredObject $document): string { $response = $this->getResponseFromCache($document); @@ -158,6 +214,22 @@ final class StoredObjectManager implements StoredObjectManagerInterface return $date; } + private function extractContentLengthFromResponse(ResponseInterface $response): int + { + return (int) ($response->getHeaders()['content-length'] ?? ['0'])[0]; + } + + private function extractEtagFromResponse(ResponseInterface $response, StoredObject $storedObject): ?string + { + $etag = ($response->getHeaders()['etag'] ?? [''])[0]; + + if ('' === $etag) { + return null; + } + + return $etag; + } + private function fillCache(StoredObject $document): void { try { diff --git a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManagerInterface.php b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManagerInterface.php index d55f68023..19ff974f0 100644 --- a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManagerInterface.php +++ b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManagerInterface.php @@ -18,6 +18,8 @@ interface StoredObjectManagerInterface { public function getLastModified(StoredObject $document): \DateTimeInterface; + public function getContentLength(StoredObject $document): int; + /** * Get the content of a StoredObject. * @@ -39,5 +41,7 @@ interface StoredObjectManagerInterface */ public function write(StoredObject $document, string $clearContent): void; + public function etag(StoredObject $document): string; + public function clearCache(): void; } diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Controller/WebdavControllerTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Controller/WebdavControllerTest.php new file mode 100644 index 000000000..959e9ed71 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Tests/Controller/WebdavControllerTest.php @@ -0,0 +1,408 @@ +engine = self::$container->get(\Twig\Environment::class); + } + + private function buildController(): WebdavController + { + $storedObjectManager = new MockedStoredObjectManager(); + + return new WebdavController($this->engine, $storedObjectManager); + } + + private function buildDocument(): StoredObject + { + $object = (new StoredObject()) + ->setType('application/vnd.oasis.opendocument.text'); + + $reflectionObject = new \ReflectionClass($object); + $reflectionObjectUuid = $reflectionObject->getProperty('uuid'); + + $reflectionObjectUuid->setValue($object, Uuid::fromString('716e6688-4579-4938-acf3-c4ab5856803b')); + + return $object; + } + + public function testGet(): void + { + $controller = $this->buildController(); + + $response = $controller->getDocument($this->buildDocument()); + + self::assertEquals(200, $response->getStatusCode()); + self::assertEquals('abcde', $response->getContent()); + self::assertContains('etag', $response->headers->keys()); + self::assertStringContainsString('ab56b4', $response->headers->get('etag')); + } + + public function testOptionsOnDocument(): void + { + $controller = $this->buildController(); + + $response = $controller->optionsDocument($this->buildDocument()); + + self::assertEquals(200, $response->getStatusCode()); + self::assertContains('allow', $response->headers->keys()); + + foreach (explode(',', 'OPTIONS,GET,HEAD,POST,DELETE,TRACE,PROPFIND,PROPPATCH,COPY,MOVE,PUT') as $method) { + self::assertStringContainsString($method, $response->headers->get('allow')); + } + + self::assertContains('dav', $response->headers->keys()); + self::assertStringContainsString('1', $response->headers->get('dav')); + } + + public function testOptionsOnDirectory(): void + { + $controller = $this->buildController(); + + $response = $controller->optionsDirectory($this->buildDocument()); + + self::assertEquals(200, $response->getStatusCode()); + self::assertContains('allow', $response->headers->keys()); + + foreach (explode(',', 'OPTIONS,GET,HEAD,POST,DELETE,TRACE,PROPFIND,PROPPATCH,COPY,MOVE,PUT') as $method) { + self::assertStringContainsString($method, $response->headers->get('allow')); + } + + self::assertContains('dav', $response->headers->keys()); + self::assertStringContainsString('1', $response->headers->get('dav')); + } + + /** + * @dataProvider generateDataPropfindDocument + */ + public function testPropfindDocument(string $requestContent, int $expectedStatusCode, string $expectedXmlResponse, string $message): void + { + $controller = $this->buildController(); + + $request = new Request([], [], [], [], [], [], $requestContent); + $request->setMethod('PROPFIND'); + $response = $controller->propfindDocument($this->buildDocument(), $request); + + self::assertEquals($expectedStatusCode, $response->getStatusCode()); + self::assertContains('content-type', $response->headers->keys()); + self::assertStringContainsString('text/xml', $response->headers->get('content-type')); + self::assertTrue((new \DOMDocument())->loadXML($response->getContent()), $message . " test that the xml response is a valid xml"); + self::assertXmlStringEqualsXmlString($expectedXmlResponse, $response->getContent(), $message); + } + + /** + * @dataProvider generateDataPropfindDirectory + */ + public function testPropfindDirectory(string $requestContent, int $expectedStatusCode, string $expectedXmlResponse, string $message): void + { + $controller = $this->buildController(); + + $request = new Request([], [], [], [], [], [], $requestContent); + $request->setMethod('PROPFIND'); + $request->headers->add(["Depth" => "0"]); + $response = $controller->propfindDirectory($this->buildDocument(), $request); + + self::assertEquals($expectedStatusCode, $response->getStatusCode()); + self::assertContains('content-type', $response->headers->keys()); + self::assertStringContainsString('text/xml', $response->headers->get('content-type')); + self::assertTrue((new \DOMDocument())->loadXML($response->getContent()), $message . " test that the xml response is a valid xml"); + self::assertXmlStringEqualsXmlString($expectedXmlResponse, $response->getContent(), $message); + } + + public function testHeadDocument(): void + { + $controller = $this->buildController(); + $response = $controller->headDocument($this->buildDocument()); + + self::assertEquals(200, $response->getStatusCode()); + self::assertContains('content-length', $response->headers->keys()); + self::assertContains('content-type', $response->headers->keys()); + self::assertContains('etag', $response->headers->keys()); + self::assertEquals('ab56b4d92b40713acc5af89985d4b786', $response->headers->get('etag')); + self::assertEquals('application/vnd.oasis.opendocument.text', $response->headers->get('content-type')); + self::assertEquals(5, $response->headers->get('content-length')); + } + + public static function generateDataPropfindDocument(): iterable + { + $content = + <<<'XML' + + + XML; + + $response = + <<<'XML' + + + + /dav/get/716e6688-4579-4938-acf3-c4ab5856803b/d + + + + application/vnd.oasis.opendocument.text + + HTTP/1.1 200 OK + + + + + + HTTP/1.1 404 Not Found + + + + XML; + + yield [$content, 207, $response, "get IsReadOnly and contenttype from server"]; + + $content = + <<<'XML' + + + + + + + XML; + + $response = + <<<'XML' + + + + /dav/get/716e6688-4579-4938-acf3-c4ab5856803b/d + + + + + HTTP/1.1 404 Not Found + + + + XML; + + yield [$content, 207, $response, "get property IsReadOnly"]; + + yield [ + <<<'XML' + + + + + + + XML, + 207, + <<<'XML' + + + + /dav/get/716e6688-4579-4938-acf3-c4ab5856803b/d + + + + + HTTP/1.1 404 Not Found + + + + XML, + "Test requesting an unknow property" + ]; + + yield [ + <<<'XML' + + + + + + + XML, + 207, + <<<'XML' + + + + /dav/get/716e6688-4579-4938-acf3-c4ab5856803b/d + + + + Wed, 13 Sep 2023 14:15:00 +0200 + + HTTP/1.1 200 OK + + + + XML, + "test getting the last modified date" + ]; + + yield [ + <<<'XML' + + + + + XML, + 207, + <<<'XML' + + + + /dav/get/716e6688-4579-4938-acf3-c4ab5856803b/d + + + + + Wed, 13 Sep 2023 14:15:00 +0200 + + 5 + + "ab56b4d92b40713acc5af89985d4b786" + + + application/vnd.oasis.opendocument.text + + HTTP/1.1 200 OK + + + + XML, + "test finding all properties" + ]; + } + + public static function generateDataPropfindDirectory(): iterable + { + yield [ + <<<'XML' + + + XML, + 207, + <<<'XML' + + + + /dav/get/716e6688-4579-4938-acf3-c4ab5856803b/ + + + + httpd/unix-directory + + + HTTP/1.1 200 OK + + + + + + HTTP/1.1 404 Not Found + + + + XML, + "test resourceType and IsReadOnly " + ]; + + yield [ + <<<'XML' + + + XML, + 207, + <<<'XML' + + + + /dav/get/716e6688-4579-4938-acf3-c4ab5856803b/ + + + + + HTTP/1.1 404 Not Found + + + + XML, + "test creatableContentsInfo" + ]; + } + +} + +class MockedStoredObjectManager implements StoredObjectManagerInterface +{ + public function getLastModified(StoredObject $document): DateTimeInterface + { + return new \DateTimeImmutable('2023-09-13T14:15'); + } + + public function getContentLength(StoredObject $document): int + { + return 5; + } + + public function read(StoredObject $document): string + { + return 'abcde'; + } + + public function write(StoredObject $document, string $clearContent): void {} + + public function etag(StoredObject $document): string + { + return 'ab56b4d92b40713acc5af89985d4b786'; + } + +} diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Dav/Request/PropfindRequestAnalyzerTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Dav/Request/PropfindRequestAnalyzerTest.php new file mode 100644 index 000000000..3cc2f19fe --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Tests/Dav/Request/PropfindRequestAnalyzerTest.php @@ -0,0 +1,134 @@ +loadXML($xml); + $actual = $analyzer->getRequestedProperties($request); + + foreach ($expected as $key => $value) { + if ($key === 'unknowns') { + continue; + } + + self::assertArrayHasKey($key, $actual, "Check that key {$key} does exists in list of expected values"); + self::assertEquals($value, $actual[$key], "Does the value match expected for key {$key}"); + } + + if (array_key_exists('unknowns', $expected)) { + self::assertEquals(count($expected['unknowns']), count($actual['unknowns'])); + self::assertEqualsCanonicalizing($expected['unknowns'], $actual['unknowns']); + } + } + + public function provideRequestedProperties(): iterable + { + yield [ + <<<'XML' + + + + + + + XML, + [ + "resourceType" => false, + "contentType" => false, + "lastModified" => false, + "creationDate" => false, + "contentLength" => false, + "etag" => false, + "supportedLock" => false, + 'unknowns' => [ + ['xmlns' => 'http://ucb.openoffice.org/dav/props/', 'prop' => 'BaseURI'] + ] + ] + ]; + + yield [ + <<<'XML' + + + + + XML, + [ + "resourceType" => true, + "contentType" => true, + "lastModified" => true, + "creationDate" => true, + "contentLength" => true, + "etag" => true, + "supportedLock" => true, + "unknowns" => [], + ] + ]; + + yield [ + <<<'XML' + + + + + + + XML, + [ + "resourceType" => false, + "contentType" => false, + "lastModified" => true, + "creationDate" => false, + "contentLength" => false, + "etag" => false, + "supportedLock" => false, + 'unknowns' => [] + ] + ]; + + yield [ + <<<'XML' + + + XML, + [ + "resourceType" => true, + "contentType" => true, + "lastModified" => false, + "creationDate" => false, + "contentLength" => false, + "etag" => false, + "supportedLock" => false, + 'unknowns' => [ + ['xmlns' => 'http://ucb.openoffice.org/dav/props/', 'prop' => 'IsReadOnly'] + ] + ] + ]; + } +} diff --git a/utils/http/docstore/dav.http b/utils/http/docstore/dav.http new file mode 100644 index 000000000..c566358cd --- /dev/null +++ b/utils/http/docstore/dav.http @@ -0,0 +1,16 @@ + +### Get a document +GET http://{{ host }}/dav/get/{{ uuid }}/d + +### OPTIONS on a document +OPTIONS http://{{ host }}/dav/get/{{ uuid }}/d + +### HEAD ona document +HEAD http://{{ host }}/dav/get/{{ uuid }}/d + +### Get the directory of a document +GET http://{{ host }}/dav/get/{{ uuid }}/ + +### Option the directory of a document +OPTIONS http://{{ host }}/dav/get/{{ uuid }}/ + diff --git a/utils/http/docstore/http-client.env.json b/utils/http/docstore/http-client.env.json new file mode 100644 index 000000000..0b78e77ed --- /dev/null +++ b/utils/http/docstore/http-client.env.json @@ -0,0 +1,6 @@ +{ + "dev": { + "host": "localhost:8001", + "uuid": "0bf3b8e7-b25b-4227-aae9-a3263af0766f" + } +} From 6f6683f549f5ba172df60bf3dae96975504e17cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Thu, 14 Sep 2023 21:54:30 +0200 Subject: [PATCH 18/30] Dav: implements JWT extraction from the URL, and add the access_token in dav urls --- .../Controller/WebdavController.php | 52 ++++++++++-------- .../views/Webdav/directory.html.twig | 2 +- .../views/Webdav/directory_propfind.xml.twig | 4 +- .../Resources/views/Webdav/doc_props.xml.twig | 2 +- .../views/Webdav/open_in_browser.html.twig | 4 +- .../Security/Guard/DavOnUrlTokenExtractor.php | 49 +++++++++++++++++ .../Guard/JWTOnDavUrlAuthenticator.php | 38 +++++++++++++ .../Tests/Controller/WebdavControllerTest.php | 22 ++++---- .../Guard/DavOnUrlTokenExtractorTest.php | 54 +++++++++++++++++++ .../ChillDocStoreBundle/config/services.yaml | 5 ++ 10 files changed, 193 insertions(+), 39 deletions(-) create mode 100644 src/Bundle/ChillDocStoreBundle/Security/Guard/DavOnUrlTokenExtractor.php create mode 100644 src/Bundle/ChillDocStoreBundle/Security/Guard/JWTOnDavUrlAuthenticator.php create mode 100644 src/Bundle/ChillDocStoreBundle/Tests/Security/Guard/DavOnUrlTokenExtractorTest.php diff --git a/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php b/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php index 30a7e4eb2..dbd4e11ec 100644 --- a/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php +++ b/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php @@ -15,6 +15,7 @@ use Chill\DocStoreBundle\Dav\Request\PropfindRequestAnalyzer; use Chill\DocStoreBundle\Dav\Response\DavResponse; use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; +use DateTimeInterface; use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -30,41 +31,44 @@ final readonly class WebdavController public function __construct( private \Twig\Environment $engine, private StoredObjectManagerInterface $storedObjectManager, + private Security $security, + private ?JWTTokenManagerInterface $JWTTokenManager = null, ) { $this->requestAnalyzer = new PropfindRequestAnalyzer(); } /** - * @Route("/dav/open/{uuid}") + * @Route("/chdoc/open/{uuid}") */ public function open(StoredObject $storedObject): Response { - /*$accessToken = $this->JWTTokenManager->createFromPayload($this->security->getUser(), [ + $accessToken = $this->JWTTokenManager?->createFromPayload($this->security->getUser(), [ 'UserCanWrite' => true, 'UserCanAttend' => true, 'UserCanPresent' => true, 'fileId' => $storedObject->getUuid(), - ]);*/ + ]); return new DavResponse($this->engine->render('@ChillDocStore/Webdav/open_in_browser.html.twig', [ - 'stored_object' => $storedObject, 'access_token' => '', + 'stored_object' => $storedObject, 'access_token' => $accessToken, ])); } /** - * @Route("/dav/get/{uuid}/", methods={"GET", "HEAD"}, name="chill_docstore_dav_directory_get") + * @Route("/dav/{access_token}/get/{uuid}/", methods={"GET", "HEAD"}, name="chill_docstore_dav_directory_get") */ - public function getDirectory(StoredObject $storedObject): Response + public function getDirectory(StoredObject $storedObject, string $access_token): Response { return new DavResponse( $this->engine->render('@ChillDocStore/Webdav/directory.html.twig', [ - 'stored_object' => $storedObject + 'stored_object' => $storedObject, + 'access_token' => $access_token, ]) ); } /** - * @Route("/dav/get/{uuid}/", methods={"OPTIONS"}) + * @Route("/dav/{access_token}/get/{uuid}/", methods={"OPTIONS"}) */ public function optionsDirectory(StoredObject $storedObject): Response { @@ -78,9 +82,9 @@ final readonly class WebdavController } /** - * @Route("/dav/get/{uuid}/", methods={"PROPFIND"}) + * @Route("/dav/{access_token}/get/{uuid}/", methods={"PROPFIND"}) */ - public function propfindDirectory(StoredObject $storedObject, Request $request): Response + public function propfindDirectory(StoredObject $storedObject, string $access_token, Request $request): Response { $depth = $request->headers->get('depth'); @@ -111,10 +115,11 @@ final readonly class WebdavController $this->engine->render('@ChillDocStore/Webdav/directory_propfind.xml.twig', [ 'stored_object' => $storedObject, 'properties' => $properties, - 'last_modified' => $lastModified ?? null, - 'etag' => $etag ?? null, - 'content_length' => $length ?? null, - 'depth' => (int) $depth + 'last_modified' => $lastModified , + 'etag' => $etag, + 'content_length' => $length, + 'depth' => (int) $depth, + 'access_token' => $access_token, ]), 207 ); @@ -127,7 +132,7 @@ final readonly class WebdavController } /** - * @Route("/dav/get/{uuid}/d", name="chill_docstore_dav_document_get", methods={"GET"}) + * @Route("/dav/{access_token}/get/{uuid}/d", name="chill_docstore_dav_document_get", methods={"GET"}) */ public function getDocument(StoredObject $storedObject): Response { @@ -136,7 +141,7 @@ final readonly class WebdavController } /** - * @Route("/dav/get/{uuid}/d", methods={"HEAD"}) + * @Route("/dav/{access_token}/get/{uuid}/d", methods={"HEAD"}) */ public function headDocument(StoredObject $storedObject): Response { @@ -154,7 +159,7 @@ final readonly class WebdavController } /** - * @Route("/dav/get/{uuid}/d", methods={"OPTIONS"}) + * @Route("/dav/{access_token}/get/{uuid}/d", methods={"OPTIONS"}) */ public function optionsDocument(StoredObject $storedObject): Response { @@ -176,9 +181,9 @@ final readonly class WebdavController } /** - * @Route("/dav/get/{uuid}/d", methods={"PROPFIND"}) + * @Route("/dav/{access_token}/get/{uuid}/d", methods={"PROPFIND"}) */ - public function propfindDocument(StoredObject $storedObject, Request $request): Response + public function propfindDocument(StoredObject $storedObject, string $access_token, Request $request): Response { $content = $request->getContent(); $xml = new \DOMDocument(); @@ -204,9 +209,10 @@ final readonly class WebdavController [ 'stored_object' => $storedObject, 'properties' => $properties, - 'etag' => $etag ?? null, - 'last_modified' => $lastModified ?? null, - 'content_length' => $length ?? null, + 'etag' => $etag, + 'last_modified' => $lastModified, + 'content_length' => $length, + 'access_token' => $access_token, ] ), 207 @@ -221,7 +227,7 @@ final readonly class WebdavController } /** - * @Route("/dav/get/{uuid}/d", methods={"PUT"}) + * @Route("/dav/{access_token}/get/{uuid}/d", methods={"PUT"}) */ public function putDocument(StoredObject $storedObject, Request $request): Response { diff --git a/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/directory.html.twig b/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/directory.html.twig index 5a95e894a..90e19dd13 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/directory.html.twig +++ b/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/directory.html.twig @@ -6,7 +6,7 @@ diff --git a/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/directory_propfind.xml.twig b/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/directory_propfind.xml.twig index 6edbb76e0..23a8da064 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/directory_propfind.xml.twig +++ b/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/directory_propfind.xml.twig @@ -1,7 +1,7 @@ - {{ path('chill_docstore_dav_directory_get', { 'uuid': stored_object.uuid } ) }} + {{ path('chill_docstore_dav_directory_get', { 'uuid': stored_object.uuid, 'access_token': access_token } ) }} {% if properties.resourceType or properties.contentType %} @@ -28,7 +28,7 @@ {% if depth == 1 %} - {{ path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid}) }} + {{ path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token':access_token}) }} {% if properties.lastModified or properties.contentLength or properties.resourceType or properties.etag or properties.contentType or properties.creationDate %} diff --git a/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/doc_props.xml.twig b/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/doc_props.xml.twig index 3d320295b..7cde5a5de 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/doc_props.xml.twig +++ b/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/doc_props.xml.twig @@ -1,7 +1,7 @@ - {{ path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid}) }} + {{ path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token': access_token}) }} {% if properties.lastModified or properties.contentLength or properties.resourceType or properties.etag or properties.contentType or properties.creationDate %} diff --git a/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/open_in_browser.html.twig b/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/open_in_browser.html.twig index 37d137047..2a32681e7 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/open_in_browser.html.twig +++ b/src/Bundle/ChillDocStoreBundle/Resources/views/Webdav/open_in_browser.html.twig @@ -2,6 +2,6 @@ {% block content %}

    document uuid: {{ stored_object.uuid }}

    -

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

    -Open document +

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

    +Open document {% endblock %} diff --git a/src/Bundle/ChillDocStoreBundle/Security/Guard/DavOnUrlTokenExtractor.php b/src/Bundle/ChillDocStoreBundle/Security/Guard/DavOnUrlTokenExtractor.php new file mode 100644 index 000000000..eb030f799 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Security/Guard/DavOnUrlTokenExtractor.php @@ -0,0 +1,49 @@ +getRequestUri(); + + $segments = array_values( + array_filter( + explode('/', $uri), + fn ($item) => '' !== trim($item) + ) + ); + + if (2 > count($segments)) { + $this->logger->info("not enough segment for parsing URL"); + + return false; + } + + if ('dav' !== $segments[0]) { + $this->logger->info("the first segment of the url must be DAV"); + + return false; + } + + return $segments[1]; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTOnDavUrlAuthenticator.php b/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTOnDavUrlAuthenticator.php new file mode 100644 index 000000000..b4ae04afc --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTOnDavUrlAuthenticator.php @@ -0,0 +1,38 @@ +davOnUrlTokenExtractor; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Controller/WebdavControllerTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Controller/WebdavControllerTest.php index 959e9ed71..9064d8419 100644 --- a/src/Bundle/ChillDocStoreBundle/Tests/Controller/WebdavControllerTest.php +++ b/src/Bundle/ChillDocStoreBundle/Tests/Controller/WebdavControllerTest.php @@ -21,6 +21,7 @@ use Prophecy\PhpUnit\ProphecyTrait; use Ramsey\Uuid\Uuid; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Security\Core\Security; use Symfony\Component\Templating\EngineInterface; /** @@ -43,8 +44,9 @@ class WebdavControllerTest extends KernelTestCase private function buildController(): WebdavController { $storedObjectManager = new MockedStoredObjectManager(); + $security = $this->prophesize(Security::class); - return new WebdavController($this->engine, $storedObjectManager); + return new WebdavController($this->engine, $storedObjectManager, $security->reveal()); } private function buildDocument(): StoredObject @@ -115,7 +117,7 @@ class WebdavControllerTest extends KernelTestCase $request = new Request([], [], [], [], [], [], $requestContent); $request->setMethod('PROPFIND'); - $response = $controller->propfindDocument($this->buildDocument(), $request); + $response = $controller->propfindDocument($this->buildDocument(), '1234', $request); self::assertEquals($expectedStatusCode, $response->getStatusCode()); self::assertContains('content-type', $response->headers->keys()); @@ -134,7 +136,7 @@ class WebdavControllerTest extends KernelTestCase $request = new Request([], [], [], [], [], [], $requestContent); $request->setMethod('PROPFIND'); $request->headers->add(["Depth" => "0"]); - $response = $controller->propfindDirectory($this->buildDocument(), $request); + $response = $controller->propfindDirectory($this->buildDocument(), '1234', $request); self::assertEquals($expectedStatusCode, $response->getStatusCode()); self::assertContains('content-type', $response->headers->keys()); @@ -170,7 +172,7 @@ class WebdavControllerTest extends KernelTestCase - /dav/get/716e6688-4579-4938-acf3-c4ab5856803b/d + /dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d @@ -205,7 +207,7 @@ class WebdavControllerTest extends KernelTestCase - /dav/get/716e6688-4579-4938-acf3-c4ab5856803b/d + /dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d @@ -232,7 +234,7 @@ class WebdavControllerTest extends KernelTestCase - /dav/get/716e6688-4579-4938-acf3-c4ab5856803b/d + /dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d @@ -259,7 +261,7 @@ class WebdavControllerTest extends KernelTestCase - /dav/get/716e6688-4579-4938-acf3-c4ab5856803b/d + /dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d @@ -285,7 +287,7 @@ class WebdavControllerTest extends KernelTestCase - /dav/get/716e6688-4579-4938-acf3-c4ab5856803b/d + /dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d @@ -323,7 +325,7 @@ class WebdavControllerTest extends KernelTestCase - /dav/get/716e6688-4579-4938-acf3-c4ab5856803b/ + /dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/ @@ -365,7 +367,7 @@ class WebdavControllerTest extends KernelTestCase - /dav/get/716e6688-4579-4938-acf3-c4ab5856803b/ + /dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/ diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Security/Guard/DavOnUrlTokenExtractorTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Security/Guard/DavOnUrlTokenExtractorTest.php new file mode 100644 index 000000000..b9e046b28 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Tests/Security/Guard/DavOnUrlTokenExtractorTest.php @@ -0,0 +1,54 @@ +prophesize(Request::class); + $request->getRequestUri()->willReturn($uri); + + $extractor = new DavOnUrlTokenExtractor(new NullLogger()); + + $actual = $extractor->extract($request->reveal()); + + self::assertEquals($expected, $actual); + } + + /** + * @phpstan-pure + */ + public static function provideDataUri(): iterable + { + yield ['/dav/123456789/get/d07d2230-5326-11ee-8fd4-93696acf5ea1/d', '123456789']; + yield ['/dav/123456789', '123456789']; + yield ['/not-dav/123456978', false]; + yield ['/dav', false]; + yield ['/', false]; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/config/services.yaml b/src/Bundle/ChillDocStoreBundle/config/services.yaml index 04fc3ace3..390abbf25 100644 --- a/src/Bundle/ChillDocStoreBundle/config/services.yaml +++ b/src/Bundle/ChillDocStoreBundle/config/services.yaml @@ -34,6 +34,11 @@ services: autoconfigure: true autowire: true + Chill\DocStoreBundle\Security\: + resource: './../Security' + autoconfigure: true + autowire: true + Chill\DocStoreBundle\Serializer\Normalizer\: autowire: true resource: '../Serializer/Normalizer/' From 3fe870ba718303438b36000d16ebfcb994364bbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Fri, 15 Sep 2023 14:16:58 +0200 Subject: [PATCH 19/30] Dav: refactor WebdavController --- .../Controller/WebdavController.php | 67 +++++++++---------- 1 file changed, 32 insertions(+), 35 deletions(-) diff --git a/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php b/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php index dbd4e11ec..8ef4ee71a 100644 --- a/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php +++ b/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php @@ -92,24 +92,7 @@ final readonly class WebdavController throw new BadRequestHttpException("only 1 and 0 are accepted for Depth header"); } - $content = $request->getContent(); - $xml = new \DOMDocument(); - $xml->loadXML($content); - - $properties = $this->requestAnalyzer->getRequestedProperties($xml); - $requested = array_keys(array_filter($properties, fn ($item) => true === $item)); - - if ( - in_array('lastModified', $requested, true) - || in_array('etag', $requested, true) - ) { - $lastModified = $this->storedObjectManager->getLastModified($storedObject); - $etag = $this->storedObjectManager->etag($storedObject); - - } - if (in_array('contentLength', $requested, true)) { - $length = $this->storedObjectManager->getContentLength($storedObject); - } + [$properties, $lastModified, $etag, $length] = $this->parseDavRequest($request->getContent(), $storedObject); $response = new DavResponse( $this->engine->render('@ChillDocStore/Webdav/directory_propfind.xml.twig', [ @@ -185,23 +168,7 @@ final readonly class WebdavController */ public function propfindDocument(StoredObject $storedObject, string $access_token, Request $request): Response { - $content = $request->getContent(); - $xml = new \DOMDocument(); - $xml->loadXML($content); - - $properties = $this->requestAnalyzer->getRequestedProperties($xml); - $requested = array_keys(array_filter($properties, fn ($item) => true === $item)); - - if ( - in_array('lastModified', $requested, true) - || in_array('etag', $requested, true) - ) { - $lastModified = $this->storedObjectManager->getLastModified($storedObject); - $etag = $this->storedObjectManager->etag($storedObject); - } - if (in_array('contentLength', $requested, true)) { - $length = $this->storedObjectManager->getContentLength($storedObject); - } + [$properties, $lastModified, $etag, $length] = $this->parseDavRequest($request->getContent(), $storedObject); $response = new DavResponse( $this->engine->render( @@ -235,4 +202,34 @@ final readonly class WebdavController return (new DavResponse("", Response::HTTP_NO_CONTENT)); } + + /** + * @return array{0: array, 1: DateTimeInterface, 2: string, 3: int} properties, lastModified, etag, length + */ + private function parseDavRequest(string $content, StoredObject $storedObject): array + { + $xml = new \DOMDocument(); + $xml->loadXML($content); + + $properties = $this->requestAnalyzer->getRequestedProperties($xml); + $requested = array_keys(array_filter($properties, fn ($item) => true === $item)); + + if ( + in_array('lastModified', $requested, true) + || in_array('etag', $requested, true) + ) { + $lastModified = $this->storedObjectManager->getLastModified($storedObject); + $etag = $this->storedObjectManager->etag($storedObject); + } + if (in_array('contentLength', $requested, true)) { + $length = $this->storedObjectManager->getContentLength($storedObject); + } + + return [ + $properties, + $lastModified ?? null, + $etag ?? null, + $length ?? null + ]; + } } From a57e6c0cc9eec53af1ff7c0cef74d559885ebb3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Fri, 15 Sep 2023 22:21:56 +0200 Subject: [PATCH 20/30] Dav: Introduce access control inside de dav controller --- ...TokenAuthenticationEventSubscriberTest.php | 65 +++++++++ .../Controller/WebdavController.php | 61 ++++++--- .../Authorization/StoredObjectRoleEnum.php | 19 +++ .../Authorization/StoredObjectVoter.php | 52 ++++++++ .../DavTokenAuthenticationEventSubscriber.php | 50 +++++++ .../Security/Guard/JWTDavTokenProvider.php | 41 ++++++ .../Guard/JWTDavTokenProviderInterface.php | 23 ++++ .../Tests/Controller/WebdavControllerTest.php | 6 +- .../Authorization/StoredObjectVoterTest.php | 124 ++++++++++++++++++ 9 files changed, 419 insertions(+), 22 deletions(-) create mode 100644 src/Bundle/ChillAsideActivityBundle/src/Tests/Chill/DocStoreBundle/Tests/Security/Guard/DavTokenAuthenticationEventSubscriberTest.php create mode 100644 src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectRoleEnum.php create mode 100644 src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectVoter.php create mode 100644 src/Bundle/ChillDocStoreBundle/Security/Guard/DavTokenAuthenticationEventSubscriber.php create mode 100644 src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProvider.php create mode 100644 src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProviderInterface.php create mode 100644 src/Bundle/ChillDocStoreBundle/Tests/Security/Authorization/StoredObjectVoterTest.php diff --git a/src/Bundle/ChillAsideActivityBundle/src/Tests/Chill/DocStoreBundle/Tests/Security/Guard/DavTokenAuthenticationEventSubscriberTest.php b/src/Bundle/ChillAsideActivityBundle/src/Tests/Chill/DocStoreBundle/Tests/Security/Guard/DavTokenAuthenticationEventSubscriberTest.php new file mode 100644 index 000000000..03cea7d29 --- /dev/null +++ b/src/Bundle/ChillAsideActivityBundle/src/Tests/Chill/DocStoreBundle/Tests/Security/Guard/DavTokenAuthenticationEventSubscriberTest.php @@ -0,0 +1,65 @@ + 1, + 'so' => '1234', + 'e' => 1, + ], $token); + + $eventSubscriber->onJWTAuthenticated($event); + + self::assertTrue($token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)); + self::assertTrue($token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)); + self::assertEquals('1234', $token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)); + self::assertEquals(StoredObjectRoleEnum::EDIT, $token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)); + } + + public function testOnJWTAuthenticatedWithDavNoDataInPayload(): void + { + $eventSubscriber = new DavTokenAuthenticationEventSubscriber(); + $token = new class () extends AbstractToken { + public function getCredentials() + { + return null; + } + }; + $event = new JWTAuthenticatedEvent([], $token); + + $eventSubscriber->onJWTAuthenticated($event); + + self::assertFalse($token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)); + self::assertFalse($token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)); + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php b/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php index 8ef4ee71a..471aec99f 100644 --- a/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php +++ b/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php @@ -14,15 +14,16 @@ namespace Chill\DocStoreBundle\Controller; use Chill\DocStoreBundle\Dav\Request\PropfindRequestAnalyzer; use Chill\DocStoreBundle\Dav\Response\DavResponse; use Chill\DocStoreBundle\Entity\StoredObject; +use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; +use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface; use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; use DateTimeInterface; -use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Security\Core\Security; -use Symfony\Component\Templating\EngineInterface; final readonly class WebdavController { @@ -31,8 +32,8 @@ final readonly class WebdavController public function __construct( private \Twig\Environment $engine, private StoredObjectManagerInterface $storedObjectManager, - private Security $security, - private ?JWTTokenManagerInterface $JWTTokenManager = null, + private Security $security, + private ?JWTDavTokenProviderInterface $davTokenProvider = null, ) { $this->requestAnalyzer = new PropfindRequestAnalyzer(); } @@ -42,12 +43,7 @@ final readonly class WebdavController */ public function open(StoredObject $storedObject): Response { - $accessToken = $this->JWTTokenManager?->createFromPayload($this->security->getUser(), [ - 'UserCanWrite' => true, - 'UserCanAttend' => true, - 'UserCanPresent' => true, - 'fileId' => $storedObject->getUuid(), - ]); + $accessToken = $this->davTokenProvider?->createToken($storedObject, StoredObjectRoleEnum::EDIT); return new DavResponse($this->engine->render('@ChillDocStore/Webdav/open_in_browser.html.twig', [ 'stored_object' => $storedObject, 'access_token' => $accessToken, @@ -59,6 +55,10 @@ final readonly class WebdavController */ public function getDirectory(StoredObject $storedObject, string $access_token): Response { + if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) { + throw new AccessDeniedHttpException(); + } + return new DavResponse( $this->engine->render('@ChillDocStore/Webdav/directory.html.twig', [ 'stored_object' => $storedObject, @@ -72,11 +72,16 @@ final readonly class WebdavController */ public function optionsDirectory(StoredObject $storedObject): Response { + if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) { + throw new AccessDeniedHttpException(); + } + $response = (new DavResponse("")) ->setEtag($this->storedObjectManager->etag($storedObject)) ; - $response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT,PROPPATCH,COPY,MOVE,REPORT,PATCH,POST,TRACE']); + //$response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT,PROPPATCH,COPY,MOVE,REPORT,PATCH,POST,TRACE']); + $response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT']); return $response; } @@ -86,6 +91,10 @@ final readonly class WebdavController */ public function propfindDirectory(StoredObject $storedObject, string $access_token, Request $request): Response { + if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) { + throw new AccessDeniedHttpException(); + } + $depth = $request->headers->get('depth'); if ("0" !== $depth && "1" !== $depth) { @@ -119,6 +128,10 @@ final readonly class WebdavController */ public function getDocument(StoredObject $storedObject): Response { + if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) { + throw new AccessDeniedHttpException(); + } + return (new DavResponse($this->storedObjectManager->read($storedObject))) ->setEtag($this->storedObjectManager->etag($storedObject)); } @@ -128,6 +141,10 @@ final readonly class WebdavController */ public function headDocument(StoredObject $storedObject): Response { + if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) { + throw new AccessDeniedHttpException(); + } + $response = new DavResponse(""); $response->headers->add( @@ -146,19 +163,15 @@ final readonly class WebdavController */ public function optionsDocument(StoredObject $storedObject): Response { + if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) { + throw new AccessDeniedHttpException(); + } + $response = (new DavResponse("")) ->setEtag($this->storedObjectManager->etag($storedObject)) ; - $response->headers->add(['Allow' => /*sprintf( - '%s, %s, %s, %s, %s', - Request::METHOD_OPTIONS, - Request::METHOD_GET, - Request::METHOD_HEAD, - Request::METHOD_PUT, - 'PROPFIND' - ) */ 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT,PROPPATCH,COPY,MOVE,REPORT,PATCH,POST,TRACE' - ]); + $response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT']); return $response; } @@ -168,6 +181,10 @@ final readonly class WebdavController */ public function propfindDocument(StoredObject $storedObject, string $access_token, Request $request): Response { + if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) { + throw new AccessDeniedHttpException(); + } + [$properties, $lastModified, $etag, $length] = $this->parseDavRequest($request->getContent(), $storedObject); $response = new DavResponse( @@ -198,6 +215,10 @@ final readonly class WebdavController */ public function putDocument(StoredObject $storedObject, Request $request): Response { + if (!$this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $storedObject)) { + throw new AccessDeniedHttpException(); + } + $this->storedObjectManager->write($storedObject, $request->getContent()); return (new DavResponse("", Response::HTTP_NO_CONTENT)); diff --git a/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectRoleEnum.php b/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectRoleEnum.php new file mode 100644 index 000000000..6a5a22da0 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectRoleEnum.php @@ -0,0 +1,19 @@ +hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT) + || + $subject->getUuid()->toString() !== $token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT) + ) { + return false; + } + + if (!$token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)) { + return false; + } + + $askedRole = StoredObjectRoleEnum::from($attribute); + $tokenRoleAuthorization = + $token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS); + + return match ($askedRole) { + StoredObjectRoleEnum::SEE => + $tokenRoleAuthorization === StoredObjectRoleEnum::EDIT || $tokenRoleAuthorization === StoredObjectRoleEnum::SEE, + StoredObjectRoleEnum::EDIT => $tokenRoleAuthorization === StoredObjectRoleEnum::EDIT + }; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Security/Guard/DavTokenAuthenticationEventSubscriber.php b/src/Bundle/ChillDocStoreBundle/Security/Guard/DavTokenAuthenticationEventSubscriber.php new file mode 100644 index 000000000..3c1b3e2a9 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Security/Guard/DavTokenAuthenticationEventSubscriber.php @@ -0,0 +1,50 @@ + ['onJWTAuthenticated', 0], + ]; + } + + public function onJWTAuthenticated(JWTAuthenticatedEvent $event): void + { + $payload = $event->getPayload(); + + if (!(array_key_exists('dav', $payload) && 1 === $payload['dav'])) { + return; + } + + $token = $event->getToken(); + $token->setAttribute(self::ACTIONS, match ($payload['e']) { + 0 => StoredObjectRoleEnum::SEE, + 1 => StoredObjectRoleEnum::EDIT, + default => throw new \UnexpectedValueException("unsupported value for e parameter") + }); + + $token->setAttribute(self::STORED_OBJECT, $payload['so']); + } + + +} diff --git a/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProvider.php b/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProvider.php new file mode 100644 index 000000000..30b25dd0b --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProvider.php @@ -0,0 +1,41 @@ +JWTTokenManager->createFromPayload($this->security->getUser(), [ + 'dav' => 1, + 'e' => match ($roleEnum) { + StoredObjectRoleEnum::SEE => 0, + StoredObjectRoleEnum::EDIT => 1, + }, + 'so' => $storedObject->getUuid(), + ]); + } + +} diff --git a/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProviderInterface.php b/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProviderInterface.php new file mode 100644 index 000000000..6be91a39c --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProviderInterface.php @@ -0,0 +1,23 @@ +prophesize(Security::class); + $security->isGranted(Argument::in(['EDIT', 'SEE']), Argument::type(StoredObject::class)) + ->willReturn(true); return new WebdavController($this->engine, $storedObjectManager, $security->reveal()); } @@ -83,7 +85,7 @@ class WebdavControllerTest extends KernelTestCase self::assertEquals(200, $response->getStatusCode()); self::assertContains('allow', $response->headers->keys()); - foreach (explode(',', 'OPTIONS,GET,HEAD,POST,DELETE,TRACE,PROPFIND,PROPPATCH,COPY,MOVE,PUT') as $method) { + foreach (explode(',', 'OPTIONS,GET,HEAD,PROPFIND') as $method) { self::assertStringContainsString($method, $response->headers->get('allow')); } @@ -100,7 +102,7 @@ class WebdavControllerTest extends KernelTestCase self::assertEquals(200, $response->getStatusCode()); self::assertContains('allow', $response->headers->keys()); - foreach (explode(',', 'OPTIONS,GET,HEAD,POST,DELETE,TRACE,PROPFIND,PROPPATCH,COPY,MOVE,PUT') as $method) { + foreach (explode(',', 'OPTIONS,GET,HEAD,PROPFIND') as $method) { self::assertStringContainsString($method, $response->headers->get('allow')); } diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Security/Authorization/StoredObjectVoterTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Security/Authorization/StoredObjectVoterTest.php new file mode 100644 index 000000000..c4518586f --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Tests/Security/Authorization/StoredObjectVoterTest.php @@ -0,0 +1,124 @@ +vote($token, $subject, [$attribute])); + } + + public function provideDataVote(): iterable + { + yield [ + $this->buildToken(StoredObjectRoleEnum::EDIT, new StoredObject()), + new \stdClass(), + 'SOMETHING', + VoterInterface::ACCESS_ABSTAIN + ]; + + yield [ + $this->buildToken(StoredObjectRoleEnum::EDIT, $so = new StoredObject()), + $so, + 'SOMETHING', + VoterInterface::ACCESS_ABSTAIN + ]; + + yield [ + $this->buildToken(StoredObjectRoleEnum::EDIT, $so = new StoredObject()), + $so, + StoredObjectRoleEnum::SEE->value, + VoterInterface::ACCESS_GRANTED + ]; + + yield [ + $this->buildToken(StoredObjectRoleEnum::EDIT, $so = new StoredObject()), + $so, + StoredObjectRoleEnum::EDIT->value, + VoterInterface::ACCESS_GRANTED + ]; + + yield [ + $this->buildToken(StoredObjectRoleEnum::SEE, $so = new StoredObject()), + $so, + StoredObjectRoleEnum::EDIT->value, + VoterInterface::ACCESS_DENIED, + ]; + + yield [ + $this->buildToken(StoredObjectRoleEnum::SEE, $so = new StoredObject()), + $so, + StoredObjectRoleEnum::SEE->value, + VoterInterface::ACCESS_GRANTED + ]; + + yield [ + $this->buildToken(null, null), + new StoredObject(), + StoredObjectRoleEnum::SEE->value, + VoterInterface::ACCESS_DENIED, + ]; + + yield [ + $this->buildToken(null, null), + new StoredObject(), + StoredObjectRoleEnum::SEE->value, + VoterInterface::ACCESS_DENIED, + ]; + } + + + private function buildToken(?StoredObjectRoleEnum $storedObjectRoleEnum = null, ?StoredObject $storedObject = null): TokenInterface + { + $token = $this->prophesize(TokenInterface::class); + + if (null !== $storedObjectRoleEnum) { + $token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willReturn(true); + $token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willReturn($storedObjectRoleEnum); + } else { + $token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willReturn(false); + $token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willThrow(new \InvalidArgumentException()); + } + + + if (null !== $storedObject) { + $token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willReturn(true); + $token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willReturn($storedObject->getUuid()->toString()); + } else { + $token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willReturn(false); + $token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willThrow(new \InvalidArgumentException()); + } + + return $token->reveal(); + } +} From 8d44bb2c323da9d2f23d53976d1f4a234ebdc343 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Fri, 15 Sep 2023 22:32:25 +0200 Subject: [PATCH 21/30] Dav: add some documentation on classes --- .../Controller/WebdavController.php | 24 +++++++++---------- .../Authorization/StoredObjectRoleEnum.php | 3 +++ .../Authorization/StoredObjectVoter.php | 5 ++++ .../Security/Guard/DavOnUrlTokenExtractor.php | 8 +++++++ .../DavTokenAuthenticationEventSubscriber.php | 3 +++ .../Guard/JWTOnDavUrlAuthenticator.php | 3 +++ 6 files changed, 33 insertions(+), 13 deletions(-) diff --git a/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php b/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php index 471aec99f..ff22e6046 100644 --- a/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php +++ b/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php @@ -25,6 +25,17 @@ use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Security\Core\Security; +/** + * Provide endpoint for editing a document on the desktop using dav. + * + * This controller implements the minimal required methods to edit a document on a desktop software (i.e. LibreOffice) + * and save the document online. + * + * To avoid to ask for a password, the endpoints are protected using a JWT access token, which is inside the + * URL. This avoid the DAV Client (LibreOffice) to keep an access token in query parameter or in some header (which + * they are not able to understand). The JWT Guard is adapted with a dedicated token extractor which is going to read + * the segments (separation of "/"): the first segment must be the string "dav", and the second one must be the JWT. + */ final readonly class WebdavController { private PropfindRequestAnalyzer $requestAnalyzer; @@ -33,23 +44,10 @@ final readonly class WebdavController private \Twig\Environment $engine, private StoredObjectManagerInterface $storedObjectManager, private Security $security, - private ?JWTDavTokenProviderInterface $davTokenProvider = null, ) { $this->requestAnalyzer = new PropfindRequestAnalyzer(); } - /** - * @Route("/chdoc/open/{uuid}") - */ - public function open(StoredObject $storedObject): Response - { - $accessToken = $this->davTokenProvider?->createToken($storedObject, StoredObjectRoleEnum::EDIT); - - return new DavResponse($this->engine->render('@ChillDocStore/Webdav/open_in_browser.html.twig', [ - 'stored_object' => $storedObject, 'access_token' => $accessToken, - ])); - } - /** * @Route("/dav/{access_token}/get/{uuid}/", methods={"GET", "HEAD"}, name="chill_docstore_dav_directory_get") */ diff --git a/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectRoleEnum.php b/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectRoleEnum.php index 6a5a22da0..af2813240 100644 --- a/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectRoleEnum.php +++ b/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectRoleEnum.php @@ -11,6 +11,9 @@ declare(strict_types=1); namespace Chill\DocStoreBundle\Security\Authorization; +/** + * Role to edit or see the stored object content. + */ enum StoredObjectRoleEnum: string { case SEE = 'SEE'; diff --git a/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectVoter.php b/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectVoter.php index 55c9b3ca1..570a2be13 100644 --- a/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectVoter.php +++ b/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectVoter.php @@ -16,6 +16,11 @@ use Chill\DocStoreBundle\Security\Guard\DavTokenAuthenticationEventSubscriber; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\Voter\Voter; +/** + * Voter for the content of a stored object. + * + * This is in use to allow or disallow the edition of the stored object's content. + */ class StoredObjectVoter extends Voter { protected function supports($attribute, $subject): bool diff --git a/src/Bundle/ChillDocStoreBundle/Security/Guard/DavOnUrlTokenExtractor.php b/src/Bundle/ChillDocStoreBundle/Security/Guard/DavOnUrlTokenExtractor.php index eb030f799..0b2cc6a0a 100644 --- a/src/Bundle/ChillDocStoreBundle/Security/Guard/DavOnUrlTokenExtractor.php +++ b/src/Bundle/ChillDocStoreBundle/Security/Guard/DavOnUrlTokenExtractor.php @@ -15,6 +15,14 @@ use Lexik\Bundle\JWTAuthenticationBundle\TokenExtractor\TokenExtractorInterface; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; +/** + * Extract the JWT Token from the segment of the dav endpoints. + * + * A segment is a separation inside the string, using the character "/". + * + * For recognizing the JWT, the first segment must be "dav", and the second one must be + * the JWT endpoint. + */ final readonly class DavOnUrlTokenExtractor implements TokenExtractorInterface { public function __construct( diff --git a/src/Bundle/ChillDocStoreBundle/Security/Guard/DavTokenAuthenticationEventSubscriber.php b/src/Bundle/ChillDocStoreBundle/Security/Guard/DavTokenAuthenticationEventSubscriber.php index 3c1b3e2a9..c3f527e71 100644 --- a/src/Bundle/ChillDocStoreBundle/Security/Guard/DavTokenAuthenticationEventSubscriber.php +++ b/src/Bundle/ChillDocStoreBundle/Security/Guard/DavTokenAuthenticationEventSubscriber.php @@ -16,6 +16,9 @@ use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTAuthenticatedEvent; use Lexik\Bundle\JWTAuthenticationBundle\Events; use Symfony\Component\EventDispatcher\EventSubscriberInterface; +/** + * Store some data from the JWT's payload inside the token's attributes. + */ class DavTokenAuthenticationEventSubscriber implements EventSubscriberInterface { final public const STORED_OBJECT = 'stored_object'; diff --git a/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTOnDavUrlAuthenticator.php b/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTOnDavUrlAuthenticator.php index b4ae04afc..ceb44949a 100644 --- a/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTOnDavUrlAuthenticator.php +++ b/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTOnDavUrlAuthenticator.php @@ -18,6 +18,9 @@ use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInt use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Symfony\Contracts\Translation\TranslatorInterface; +/** + * Alter the base JWTTokenAuthenticator to add the special extractor for dav url endpoints. + */ class JWTOnDavUrlAuthenticator extends JWTTokenAuthenticator { public function __construct( From fca929f56fe89103fd42412f13e294e08d9ac1b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Sun, 17 Sep 2023 15:44:57 +0200 Subject: [PATCH 22/30] Dav: add UI to edit document --- .../Controller/WebdavController.php | 1 - .../document_action_buttons_group/index.ts | 10 ++- .../vuejs/DocumentActionButtonsGroup.vue | 16 ++++- .../StoredObjectButton/DesktopEditButton.vue | 66 +++++++++++++++++++ .../views/Button/button_group.html.twig | 2 + .../Security/Guard/JWTDavTokenProvider.php | 7 ++ .../Guard/JWTDavTokenProviderInterface.php | 2 + .../WopiEditTwigExtensionRuntime.php | 29 ++++++-- 8 files changed, 124 insertions(+), 9 deletions(-) create mode 100644 src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/DesktopEditButton.vue diff --git a/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php b/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php index ff22e6046..b4624d463 100644 --- a/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php +++ b/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php @@ -15,7 +15,6 @@ use Chill\DocStoreBundle\Dav\Request\PropfindRequestAnalyzer; use Chill\DocStoreBundle\Dav\Response\DavResponse; use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; -use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface; use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; use DateTimeInterface; use Symfony\Component\HttpFoundation\Request; diff --git a/src/Bundle/ChillDocStoreBundle/Resources/public/module/document_action_buttons_group/index.ts b/src/Bundle/ChillDocStoreBundle/Resources/public/module/document_action_buttons_group/index.ts index 4180808dd..77eb8c2c9 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/public/module/document_action_buttons_group/index.ts +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/module/document_action_buttons_group/index.ts @@ -17,18 +17,22 @@ window.addEventListener('DOMContentLoaded', function (e) { canEdit: string, storedObject: string, buttonSmall: string, + davLink: string, + davLinkExpiration: string, }; const storedObject = JSON.parse(datasets.storedObject) as StoredObject, filename = datasets.filename, canEdit = datasets.canEdit === '1', - small = datasets.buttonSmall === '1' + small = datasets.buttonSmall === '1', + davLink = 'davLink' in datasets && datasets.davLink !== '' ? datasets.davLink : null, + davLinkExpiration = 'davLinkExpiration' in datasets ? Number.parseInt(datasets.davLinkExpiration) : null ; - return { storedObject, filename, canEdit, small }; + return { storedObject, filename, canEdit, small, davLink, davLinkExpiration }; }, - template: '', + template: '', methods: { onStoredObjectStatusChange: function(newStatus: StoredObjectStatusChange): void { this.$data.storedObject.status = newStatus.status; diff --git a/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/DocumentActionButtonsGroup.vue b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/DocumentActionButtonsGroup.vue index 88587e90f..284ae0f1f 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/DocumentActionButtonsGroup.vue +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/DocumentActionButtonsGroup.vue @@ -7,6 +7,9 @@
  • +
  • + +
  • @@ -36,6 +39,7 @@ import { StoredObjectStatusChange, WopiEditButtonExecutableBeforeLeaveFunction } from "../types"; +import DesktopEditButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DesktopEditButton.vue"; interface DocumentActionButtonsGroupConfig { storedObject: StoredObject, @@ -57,6 +61,16 @@ interface DocumentActionButtonsGroupConfig { * If set, will execute this function before leaving to the editor */ executeBeforeLeave?: WopiEditButtonExecutableBeforeLeaveFunction, + + /** + * a link to download and edit file using webdav + */ + davLink?: string, + + /** + * the expiration date of the download, as a unix timestamp + */ + davLinkExpiration?: number, } const emit = defineEmits<{ @@ -68,7 +82,7 @@ const props = withDefaults(defineProps(), { canEdit: true, canDownload: true, canConvertPdf: true, - returnPath: window.location.pathname + window.location.search + window.location.hash, + returnPath: window.location.pathname + window.location.search + window.location.hash }); /** diff --git a/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/DesktopEditButton.vue b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/DesktopEditButton.vue new file mode 100644 index 000000000..ef0d0d376 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/DesktopEditButton.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/src/Bundle/ChillDocStoreBundle/Resources/views/Button/button_group.html.twig b/src/Bundle/ChillDocStoreBundle/Resources/views/Button/button_group.html.twig index 2babe1ad9..6248cbd20 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/views/Button/button_group.html.twig +++ b/src/Bundle/ChillDocStoreBundle/Resources/views/Button/button_group.html.twig @@ -3,5 +3,7 @@ data-download-buttons data-stored-object="{{ document_json|json_encode|escape('html_attr') }}" data-can-edit="{{ can_edit ? '1' : '0' }}" + data-dav-link="{{ dav_link|escape('html_attr') }}" + data-dav-link-expiration="{{ dav_link_expiration|escape('html_attr') }}" {% if options['small'] is defined %}data-button-small="{{ options['small'] ? '1' : '0' }}"{% endif %} {% if title|default(document.title)|default(null) is not null %}data-filename="{{ title|default(document.title)|escape('html_attr') }}"{% endif %}>
    diff --git a/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProvider.php b/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProvider.php index 30b25dd0b..a297ed613 100644 --- a/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProvider.php +++ b/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProvider.php @@ -38,4 +38,11 @@ final readonly class JWTDavTokenProvider implements JWTDavTokenProviderInterface ]); } + public function getTokenExpiration(string $tokenString): \DateTimeImmutable + { + $jwt = $this->JWTTokenManager->parse($tokenString); + + return \DateTimeImmutable::createFromFormat('U', (string) $jwt['exp']); + } + } diff --git a/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProviderInterface.php b/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProviderInterface.php index 6be91a39c..b93b658b0 100644 --- a/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProviderInterface.php +++ b/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProviderInterface.php @@ -20,4 +20,6 @@ use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; interface JWTDavTokenProviderInterface { public function createToken(StoredObject $storedObject, StoredObjectRoleEnum $roleEnum): string; + + public function getTokenExpiration(string $tokenString): \DateTimeImmutable; } diff --git a/src/Bundle/ChillDocStoreBundle/Templating/WopiEditTwigExtensionRuntime.php b/src/Bundle/ChillDocStoreBundle/Templating/WopiEditTwigExtensionRuntime.php index f833200c8..be4afd68d 100644 --- a/src/Bundle/ChillDocStoreBundle/Templating/WopiEditTwigExtensionRuntime.php +++ b/src/Bundle/ChillDocStoreBundle/Templating/WopiEditTwigExtensionRuntime.php @@ -13,6 +13,10 @@ namespace Chill\DocStoreBundle\Templating; use ChampsLibres\WopiLib\Contract\Service\Discovery\DiscoveryInterface; use Chill\DocStoreBundle\Entity\StoredObject; +use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; +use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface; +use Namshi\JOSE\JWS; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Twig\Environment; @@ -120,9 +124,12 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt private const TEMPLATE_BUTTON_GROUP = '@ChillDocStore/Button/button_group.html.twig'; - public function __construct(private DiscoveryInterface $discovery, private NormalizerInterface $normalizer) - { - } + public function __construct( + private DiscoveryInterface $discovery, + private NormalizerInterface $normalizer, + private JWTDavTokenProviderInterface $davTokenProvider, + private UrlGeneratorInterface $urlGenerator, + ) {} /** * return true if the document is editable. @@ -132,7 +139,7 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt */ public function isEditable(StoredObject $document): bool { - return \in_array($document->getType(), self::SUPPORTED_MIMES, true); + return in_array($document->getType(), self::SUPPORTED_MIMES, true); } /** @@ -144,12 +151,26 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt */ public function renderButtonGroup(Environment $environment, StoredObject $document, ?string $title = null, bool $canEdit = true, array $options = []): string { + $accessToken = $this->davTokenProvider->createToken( + $document, + $canEdit ? StoredObjectRoleEnum::EDIT : StoredObjectRoleEnum::SEE + ); + return $environment->render(self::TEMPLATE_BUTTON_GROUP, [ 'document' => $document, 'document_json' => $this->normalizer->normalize($document, 'json', [AbstractNormalizer::GROUPS => ['read']]), 'title' => $title, 'can_edit' => $canEdit, 'options' => [...self::DEFAULT_OPTIONS_TEMPLATE_BUTTON_GROUP, ...$options], + 'dav_link' => $this->urlGenerator->generate( + 'chill_docstore_dav_document_get', + [ + 'uuid' => $document->getUuid(), + 'access_token' => $accessToken, + ], + UrlGeneratorInterface::ABSOLUTE_URL, + ), + 'dav_link_expiration' => $this->davTokenProvider->getTokenExpiration($accessToken)->format('U'), ]); } From 4cff7063067feb0e7f21d4d6eb1f264b0db48c97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Mon, 15 Jan 2024 20:38:03 +0100 Subject: [PATCH 23/30] Apply new CS rules on the webdav feature --- ...TokenAuthenticationEventSubscriberTest.php | 1 + .../Controller/WebdavController.php | 29 ++++--- .../Dav/Exception/ParseRequestException.php | 4 +- .../Dav/Request/PropfindRequestAnalyzer.php | 9 +-- .../Authorization/StoredObjectVoter.php | 8 +- .../Security/Guard/DavOnUrlTokenExtractor.php | 9 ++- .../DavTokenAuthenticationEventSubscriber.php | 4 +- .../Security/Guard/JWTDavTokenProvider.php | 6 +- .../Guard/JWTDavTokenProviderInterface.php | 2 +- .../WopiEditTwigExtensionRuntime.php | 8 +- .../Tests/Controller/WebdavControllerTest.php | 32 ++++---- .../Request/PropfindRequestAnalyzerTest.php | 80 +++++++++---------- .../Authorization/StoredObjectVoterTest.php | 17 ++-- .../Guard/DavOnUrlTokenExtractorTest.php | 3 +- 14 files changed, 104 insertions(+), 108 deletions(-) diff --git a/src/Bundle/ChillAsideActivityBundle/src/Tests/Chill/DocStoreBundle/Tests/Security/Guard/DavTokenAuthenticationEventSubscriberTest.php b/src/Bundle/ChillAsideActivityBundle/src/Tests/Chill/DocStoreBundle/Tests/Security/Guard/DavTokenAuthenticationEventSubscriberTest.php index 03cea7d29..875e40a65 100644 --- a/src/Bundle/ChillAsideActivityBundle/src/Tests/Chill/DocStoreBundle/Tests/Security/Guard/DavTokenAuthenticationEventSubscriberTest.php +++ b/src/Bundle/ChillAsideActivityBundle/src/Tests/Chill/DocStoreBundle/Tests/Security/Guard/DavTokenAuthenticationEventSubscriberTest.php @@ -19,6 +19,7 @@ use Symfony\Component\Security\Core\Authentication\Token\AbstractToken; /** * @internal + * * @coversNothing */ class DavTokenAuthenticationEventSubscriberTest extends TestCase diff --git a/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php b/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php index b4624d463..70aecbe1e 100644 --- a/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php +++ b/src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php @@ -16,7 +16,6 @@ use Chill\DocStoreBundle\Dav\Response\DavResponse; use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; -use DateTimeInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; @@ -42,7 +41,7 @@ final readonly class WebdavController public function __construct( private \Twig\Environment $engine, private StoredObjectManagerInterface $storedObjectManager, - private Security $security, + private Security $security, ) { $this->requestAnalyzer = new PropfindRequestAnalyzer(); } @@ -73,11 +72,11 @@ final readonly class WebdavController throw new AccessDeniedHttpException(); } - $response = (new DavResponse("")) + $response = (new DavResponse('')) ->setEtag($this->storedObjectManager->etag($storedObject)) ; - //$response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT,PROPPATCH,COPY,MOVE,REPORT,PATCH,POST,TRACE']); + // $response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT,PROPPATCH,COPY,MOVE,REPORT,PATCH,POST,TRACE']); $response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT']); return $response; @@ -94,8 +93,8 @@ final readonly class WebdavController $depth = $request->headers->get('depth'); - if ("0" !== $depth && "1" !== $depth) { - throw new BadRequestHttpException("only 1 and 0 are accepted for Depth header"); + if ('0' !== $depth && '1' !== $depth) { + throw new BadRequestHttpException('only 1 and 0 are accepted for Depth header'); } [$properties, $lastModified, $etag, $length] = $this->parseDavRequest($request->getContent(), $storedObject); @@ -104,7 +103,7 @@ final readonly class WebdavController $this->engine->render('@ChillDocStore/Webdav/directory_propfind.xml.twig', [ 'stored_object' => $storedObject, 'properties' => $properties, - 'last_modified' => $lastModified , + 'last_modified' => $lastModified, 'etag' => $etag, 'content_length' => $length, 'depth' => (int) $depth, @@ -114,7 +113,7 @@ final readonly class WebdavController ); $response->headers->add([ - 'Content-Type' => 'text/xml' + 'Content-Type' => 'text/xml', ]); return $response; @@ -142,13 +141,13 @@ final readonly class WebdavController throw new AccessDeniedHttpException(); } - $response = new DavResponse(""); + $response = new DavResponse(''); $response->headers->add( [ 'Content-Length' => $this->storedObjectManager->getContentLength($storedObject), 'Content-Type' => $storedObject->getType(), - 'Etag' => $this->storedObjectManager->etag($storedObject) + 'Etag' => $this->storedObjectManager->etag($storedObject), ] ); @@ -164,7 +163,7 @@ final readonly class WebdavController throw new AccessDeniedHttpException(); } - $response = (new DavResponse("")) + $response = (new DavResponse('')) ->setEtag($this->storedObjectManager->etag($storedObject)) ; @@ -201,7 +200,7 @@ final readonly class WebdavController $response ->headers->add([ - 'Content-Type' => 'text/xml' + 'Content-Type' => 'text/xml', ]); return $response; @@ -218,11 +217,11 @@ final readonly class WebdavController $this->storedObjectManager->write($storedObject, $request->getContent()); - return (new DavResponse("", Response::HTTP_NO_CONTENT)); + return new DavResponse('', Response::HTTP_NO_CONTENT); } /** - * @return array{0: array, 1: DateTimeInterface, 2: string, 3: int} properties, lastModified, etag, length + * @return array{0: array, 1: \DateTimeInterface, 2: string, 3: int} properties, lastModified, etag, length */ private function parseDavRequest(string $content, StoredObject $storedObject): array { @@ -247,7 +246,7 @@ final readonly class WebdavController $properties, $lastModified ?? null, $etag ?? null, - $length ?? null + $length ?? null, ]; } } diff --git a/src/Bundle/ChillDocStoreBundle/Dav/Exception/ParseRequestException.php b/src/Bundle/ChillDocStoreBundle/Dav/Exception/ParseRequestException.php index 0c740be17..70fff1866 100644 --- a/src/Bundle/ChillDocStoreBundle/Dav/Exception/ParseRequestException.php +++ b/src/Bundle/ChillDocStoreBundle/Dav/Exception/ParseRequestException.php @@ -11,4 +11,6 @@ declare(strict_types=1); namespace Chill\DocStoreBundle\Dav\Exception; -class ParseRequestException extends \UnexpectedValueException {} +class ParseRequestException extends \UnexpectedValueException +{ +} diff --git a/src/Bundle/ChillDocStoreBundle/Dav/Request/PropfindRequestAnalyzer.php b/src/Bundle/ChillDocStoreBundle/Dav/Request/PropfindRequestAnalyzer.php index 3abf43c62..e6c52a193 100644 --- a/src/Bundle/ChillDocStoreBundle/Dav/Request/PropfindRequestAnalyzer.php +++ b/src/Bundle/ChillDocStoreBundle/Dav/Request/PropfindRequestAnalyzer.php @@ -36,17 +36,17 @@ class PropfindRequestAnalyzer $propfinds = $request->getElementsByTagNameNS('DAV:', 'propfind'); if (0 === $propfinds->count()) { - throw new ParseRequestException("any propfind element found"); + throw new ParseRequestException('any propfind element found'); } if (1 < $propfinds->count()) { - throw new ParseRequestException("too much propfind element found"); + throw new ParseRequestException('too much propfind element found'); } $propfind = $propfinds->item(0); if (0 === $propfind->childNodes->count()) { - throw new ParseRequestException("no element under propfind"); + throw new ParseRequestException('no element under propfind'); } $unknows = []; @@ -79,7 +79,6 @@ class PropfindRequestAnalyzer default => '', }; } - } $props = array_filter(array_values($props), fn (string $item) => '' !== $item); @@ -98,7 +97,7 @@ class PropfindRequestAnalyzer self::KNOWN_PROPS, array_fill(0, count(self::KNOWN_PROPS), $default) ), - 'unknowns' => [] + 'unknowns' => [], ]; } } diff --git a/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectVoter.php b/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectVoter.php index 570a2be13..2e253cf3c 100644 --- a/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectVoter.php +++ b/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectVoter.php @@ -34,8 +34,7 @@ class StoredObjectVoter extends Voter /** @var StoredObject $subject */ if ( !$token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT) - || - $subject->getUuid()->toString() !== $token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT) + || $subject->getUuid()->toString() !== $token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT) ) { return false; } @@ -49,9 +48,8 @@ class StoredObjectVoter extends Voter $token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS); return match ($askedRole) { - StoredObjectRoleEnum::SEE => - $tokenRoleAuthorization === StoredObjectRoleEnum::EDIT || $tokenRoleAuthorization === StoredObjectRoleEnum::SEE, - StoredObjectRoleEnum::EDIT => $tokenRoleAuthorization === StoredObjectRoleEnum::EDIT + StoredObjectRoleEnum::SEE => StoredObjectRoleEnum::EDIT === $tokenRoleAuthorization || StoredObjectRoleEnum::SEE === $tokenRoleAuthorization, + StoredObjectRoleEnum::EDIT => StoredObjectRoleEnum::EDIT === $tokenRoleAuthorization }; } } diff --git a/src/Bundle/ChillDocStoreBundle/Security/Guard/DavOnUrlTokenExtractor.php b/src/Bundle/ChillDocStoreBundle/Security/Guard/DavOnUrlTokenExtractor.php index 0b2cc6a0a..543996f57 100644 --- a/src/Bundle/ChillDocStoreBundle/Security/Guard/DavOnUrlTokenExtractor.php +++ b/src/Bundle/ChillDocStoreBundle/Security/Guard/DavOnUrlTokenExtractor.php @@ -27,9 +27,10 @@ final readonly class DavOnUrlTokenExtractor implements TokenExtractorInterface { public function __construct( private LoggerInterface $logger, - ) {} + ) { + } - public function extract(Request $request): string|false + public function extract(Request $request): false|string { $uri = $request->getRequestUri(); @@ -41,13 +42,13 @@ final readonly class DavOnUrlTokenExtractor implements TokenExtractorInterface ); if (2 > count($segments)) { - $this->logger->info("not enough segment for parsing URL"); + $this->logger->info('not enough segment for parsing URL'); return false; } if ('dav' !== $segments[0]) { - $this->logger->info("the first segment of the url must be DAV"); + $this->logger->info('the first segment of the url must be DAV'); return false; } diff --git a/src/Bundle/ChillDocStoreBundle/Security/Guard/DavTokenAuthenticationEventSubscriber.php b/src/Bundle/ChillDocStoreBundle/Security/Guard/DavTokenAuthenticationEventSubscriber.php index c3f527e71..7b33c0eec 100644 --- a/src/Bundle/ChillDocStoreBundle/Security/Guard/DavTokenAuthenticationEventSubscriber.php +++ b/src/Bundle/ChillDocStoreBundle/Security/Guard/DavTokenAuthenticationEventSubscriber.php @@ -43,11 +43,9 @@ class DavTokenAuthenticationEventSubscriber implements EventSubscriberInterface $token->setAttribute(self::ACTIONS, match ($payload['e']) { 0 => StoredObjectRoleEnum::SEE, 1 => StoredObjectRoleEnum::EDIT, - default => throw new \UnexpectedValueException("unsupported value for e parameter") + default => throw new \UnexpectedValueException('unsupported value for e parameter') }); $token->setAttribute(self::STORED_OBJECT, $payload['so']); } - - } diff --git a/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProvider.php b/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProvider.php index a297ed613..24e89a3ba 100644 --- a/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProvider.php +++ b/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProvider.php @@ -17,14 +17,15 @@ use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface; use Symfony\Component\Security\Core\Security; /** - * Provide a JWT Token which will be valid for viewing or editing a document + * Provide a JWT Token which will be valid for viewing or editing a document. */ final readonly class JWTDavTokenProvider implements JWTDavTokenProviderInterface { public function __construct( private JWTTokenManagerInterface $JWTTokenManager, private Security $security, - ) {} + ) { + } public function createToken(StoredObject $storedObject, StoredObjectRoleEnum $roleEnum): string { @@ -44,5 +45,4 @@ final readonly class JWTDavTokenProvider implements JWTDavTokenProviderInterface return \DateTimeImmutable::createFromFormat('U', (string) $jwt['exp']); } - } diff --git a/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProviderInterface.php b/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProviderInterface.php index b93b658b0..95c62c86e 100644 --- a/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProviderInterface.php +++ b/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTDavTokenProviderInterface.php @@ -15,7 +15,7 @@ use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; /** - * Provide a JWT Token which will be valid for viewing or editing a document + * Provide a JWT Token which will be valid for viewing or editing a document. */ interface JWTDavTokenProviderInterface { diff --git a/src/Bundle/ChillDocStoreBundle/Templating/WopiEditTwigExtensionRuntime.php b/src/Bundle/ChillDocStoreBundle/Templating/WopiEditTwigExtensionRuntime.php index be4afd68d..e394e0a11 100644 --- a/src/Bundle/ChillDocStoreBundle/Templating/WopiEditTwigExtensionRuntime.php +++ b/src/Bundle/ChillDocStoreBundle/Templating/WopiEditTwigExtensionRuntime.php @@ -15,7 +15,6 @@ use ChampsLibres\WopiLib\Contract\Service\Discovery\DiscoveryInterface; use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface; -use Namshi\JOSE\JWS; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; @@ -129,7 +128,8 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt private NormalizerInterface $normalizer, private JWTDavTokenProviderInterface $davTokenProvider, private UrlGeneratorInterface $urlGenerator, - ) {} + ) { + } /** * return true if the document is editable. @@ -149,7 +149,7 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt * @throws \Twig\Error\RuntimeError * @throws \Twig\Error\SyntaxError */ - public function renderButtonGroup(Environment $environment, StoredObject $document, ?string $title = null, bool $canEdit = true, array $options = []): string + public function renderButtonGroup(Environment $environment, StoredObject $document, string $title = null, bool $canEdit = true, array $options = []): string { $accessToken = $this->davTokenProvider->createToken( $document, @@ -174,7 +174,7 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt ]); } - public function renderEditButton(Environment $environment, StoredObject $document, ?array $options = null): string + public function renderEditButton(Environment $environment, StoredObject $document, array $options = null): string { return $environment->render(self::TEMPLATE, [ 'document' => $document, diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Controller/WebdavControllerTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Controller/WebdavControllerTest.php index 93d4d85e8..9254efc2d 100644 --- a/src/Bundle/ChillDocStoreBundle/Tests/Controller/WebdavControllerTest.php +++ b/src/Bundle/ChillDocStoreBundle/Tests/Controller/WebdavControllerTest.php @@ -14,18 +14,16 @@ namespace Chill\DocStoreBundle\Tests\Controller; use Chill\DocStoreBundle\Controller\WebdavController; use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; -use DateTimeInterface; -use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Ramsey\Uuid\Uuid; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Security\Core\Security; -use Symfony\Component\Templating\EngineInterface; /** * @internal + * * @coversNothing */ class WebdavControllerTest extends KernelTestCase @@ -124,7 +122,7 @@ class WebdavControllerTest extends KernelTestCase self::assertEquals($expectedStatusCode, $response->getStatusCode()); self::assertContains('content-type', $response->headers->keys()); self::assertStringContainsString('text/xml', $response->headers->get('content-type')); - self::assertTrue((new \DOMDocument())->loadXML($response->getContent()), $message . " test that the xml response is a valid xml"); + self::assertTrue((new \DOMDocument())->loadXML($response->getContent()), $message.' test that the xml response is a valid xml'); self::assertXmlStringEqualsXmlString($expectedXmlResponse, $response->getContent(), $message); } @@ -137,13 +135,13 @@ class WebdavControllerTest extends KernelTestCase $request = new Request([], [], [], [], [], [], $requestContent); $request->setMethod('PROPFIND'); - $request->headers->add(["Depth" => "0"]); + $request->headers->add(['Depth' => '0']); $response = $controller->propfindDirectory($this->buildDocument(), '1234', $request); self::assertEquals($expectedStatusCode, $response->getStatusCode()); self::assertContains('content-type', $response->headers->keys()); self::assertStringContainsString('text/xml', $response->headers->get('content-type')); - self::assertTrue((new \DOMDocument())->loadXML($response->getContent()), $message . " test that the xml response is a valid xml"); + self::assertTrue((new \DOMDocument())->loadXML($response->getContent()), $message.' test that the xml response is a valid xml'); self::assertXmlStringEqualsXmlString($expectedXmlResponse, $response->getContent(), $message); } @@ -192,7 +190,7 @@ class WebdavControllerTest extends KernelTestCase XML; - yield [$content, 207, $response, "get IsReadOnly and contenttype from server"]; + yield [$content, 207, $response, 'get IsReadOnly and contenttype from server']; $content = <<<'XML' @@ -220,7 +218,7 @@ class WebdavControllerTest extends KernelTestCase XML; - yield [$content, 207, $response, "get property IsReadOnly"]; + yield [$content, 207, $response, 'get property IsReadOnly']; yield [ <<<'XML' @@ -246,7 +244,7 @@ class WebdavControllerTest extends KernelTestCase XML, - "Test requesting an unknow property" + 'Test requesting an unknow property', ]; yield [ @@ -274,7 +272,7 @@ class WebdavControllerTest extends KernelTestCase XML, - "test getting the last modified date" + 'test getting the last modified date', ]; yield [ @@ -311,7 +309,7 @@ class WebdavControllerTest extends KernelTestCase XML, - "test finding all properties" + 'test finding all properties', ]; } @@ -356,7 +354,7 @@ class WebdavControllerTest extends KernelTestCase XML, - "test resourceType and IsReadOnly " + 'test resourceType and IsReadOnly ', ]; yield [ @@ -379,15 +377,14 @@ class WebdavControllerTest extends KernelTestCase XML, - "test creatableContentsInfo" + 'test creatableContentsInfo', ]; } - } class MockedStoredObjectManager implements StoredObjectManagerInterface { - public function getLastModified(StoredObject $document): DateTimeInterface + public function getLastModified(StoredObject $document): \DateTimeInterface { return new \DateTimeImmutable('2023-09-13T14:15'); } @@ -402,11 +399,12 @@ class MockedStoredObjectManager implements StoredObjectManagerInterface return 'abcde'; } - public function write(StoredObject $document, string $clearContent): void {} + public function write(StoredObject $document, string $clearContent): void + { + } public function etag(StoredObject $document): string { return 'ab56b4d92b40713acc5af89985d4b786'; } - } diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Dav/Request/PropfindRequestAnalyzerTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Dav/Request/PropfindRequestAnalyzerTest.php index 3cc2f19fe..babe9932a 100644 --- a/src/Bundle/ChillDocStoreBundle/Tests/Dav/Request/PropfindRequestAnalyzerTest.php +++ b/src/Bundle/ChillDocStoreBundle/Tests/Dav/Request/PropfindRequestAnalyzerTest.php @@ -12,11 +12,11 @@ declare(strict_types=1); namespace Chill\DocStoreBundle\Tests\Dav\Request; use Chill\DocStoreBundle\Dav\Request\PropfindRequestAnalyzer; -use phpseclib3\Crypt\DSA\Formats\Keys\XML; use PHPUnit\Framework\TestCase; /** * @internal + * * @coversNothing */ class PropfindRequestAnalyzerTest extends TestCase @@ -33,7 +33,7 @@ class PropfindRequestAnalyzerTest extends TestCase $actual = $analyzer->getRequestedProperties($request); foreach ($expected as $key => $value) { - if ($key === 'unknowns') { + if ('unknowns' === $key) { continue; } @@ -59,17 +59,17 @@ class PropfindRequestAnalyzerTest extends TestCase XML, [ - "resourceType" => false, - "contentType" => false, - "lastModified" => false, - "creationDate" => false, - "contentLength" => false, - "etag" => false, - "supportedLock" => false, + 'resourceType' => false, + 'contentType' => false, + 'lastModified' => false, + 'creationDate' => false, + 'contentLength' => false, + 'etag' => false, + 'supportedLock' => false, 'unknowns' => [ - ['xmlns' => 'http://ucb.openoffice.org/dav/props/', 'prop' => 'BaseURI'] - ] - ] + ['xmlns' => 'http://ucb.openoffice.org/dav/props/', 'prop' => 'BaseURI'], + ], + ], ]; yield [ @@ -80,15 +80,15 @@ class PropfindRequestAnalyzerTest extends TestCase XML, [ - "resourceType" => true, - "contentType" => true, - "lastModified" => true, - "creationDate" => true, - "contentLength" => true, - "etag" => true, - "supportedLock" => true, - "unknowns" => [], - ] + 'resourceType' => true, + 'contentType' => true, + 'lastModified' => true, + 'creationDate' => true, + 'contentLength' => true, + 'etag' => true, + 'supportedLock' => true, + 'unknowns' => [], + ], ]; yield [ @@ -101,15 +101,15 @@ class PropfindRequestAnalyzerTest extends TestCase XML, [ - "resourceType" => false, - "contentType" => false, - "lastModified" => true, - "creationDate" => false, - "contentLength" => false, - "etag" => false, - "supportedLock" => false, - 'unknowns' => [] - ] + 'resourceType' => false, + 'contentType' => false, + 'lastModified' => true, + 'creationDate' => false, + 'contentLength' => false, + 'etag' => false, + 'supportedLock' => false, + 'unknowns' => [], + ], ]; yield [ @@ -118,17 +118,17 @@ class PropfindRequestAnalyzerTest extends TestCase XML, [ - "resourceType" => true, - "contentType" => true, - "lastModified" => false, - "creationDate" => false, - "contentLength" => false, - "etag" => false, - "supportedLock" => false, + 'resourceType' => true, + 'contentType' => true, + 'lastModified' => false, + 'creationDate' => false, + 'contentLength' => false, + 'etag' => false, + 'supportedLock' => false, 'unknowns' => [ - ['xmlns' => 'http://ucb.openoffice.org/dav/props/', 'prop' => 'IsReadOnly'] - ] - ] + ['xmlns' => 'http://ucb.openoffice.org/dav/props/', 'prop' => 'IsReadOnly'], + ], + ], ]; } } diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Security/Authorization/StoredObjectVoterTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Security/Authorization/StoredObjectVoterTest.php index c4518586f..477427078 100644 --- a/src/Bundle/ChillDocStoreBundle/Tests/Security/Authorization/StoredObjectVoterTest.php +++ b/src/Bundle/ChillDocStoreBundle/Tests/Security/Authorization/StoredObjectVoterTest.php @@ -22,6 +22,7 @@ use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; /** * @internal + * * @coversNothing */ class StoredObjectVoterTest extends TestCase @@ -31,7 +32,7 @@ class StoredObjectVoterTest extends TestCase /** * @dataProvider provideDataVote */ - public function testVote(TokenInterface $token, object|null $subject, string $attribute, mixed $expected): void + public function testVote(TokenInterface $token, null|object $subject, string $attribute, mixed $expected): void { $voter = new StoredObjectVoter(); @@ -44,28 +45,28 @@ class StoredObjectVoterTest extends TestCase $this->buildToken(StoredObjectRoleEnum::EDIT, new StoredObject()), new \stdClass(), 'SOMETHING', - VoterInterface::ACCESS_ABSTAIN + VoterInterface::ACCESS_ABSTAIN, ]; yield [ $this->buildToken(StoredObjectRoleEnum::EDIT, $so = new StoredObject()), $so, 'SOMETHING', - VoterInterface::ACCESS_ABSTAIN + VoterInterface::ACCESS_ABSTAIN, ]; yield [ $this->buildToken(StoredObjectRoleEnum::EDIT, $so = new StoredObject()), $so, StoredObjectRoleEnum::SEE->value, - VoterInterface::ACCESS_GRANTED + VoterInterface::ACCESS_GRANTED, ]; yield [ $this->buildToken(StoredObjectRoleEnum::EDIT, $so = new StoredObject()), $so, StoredObjectRoleEnum::EDIT->value, - VoterInterface::ACCESS_GRANTED + VoterInterface::ACCESS_GRANTED, ]; yield [ @@ -79,7 +80,7 @@ class StoredObjectVoterTest extends TestCase $this->buildToken(StoredObjectRoleEnum::SEE, $so = new StoredObject()), $so, StoredObjectRoleEnum::SEE->value, - VoterInterface::ACCESS_GRANTED + VoterInterface::ACCESS_GRANTED, ]; yield [ @@ -97,8 +98,7 @@ class StoredObjectVoterTest extends TestCase ]; } - - private function buildToken(?StoredObjectRoleEnum $storedObjectRoleEnum = null, ?StoredObject $storedObject = null): TokenInterface + private function buildToken(StoredObjectRoleEnum $storedObjectRoleEnum = null, StoredObject $storedObject = null): TokenInterface { $token = $this->prophesize(TokenInterface::class); @@ -110,7 +110,6 @@ class StoredObjectVoterTest extends TestCase $token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willThrow(new \InvalidArgumentException()); } - if (null !== $storedObject) { $token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willReturn(true); $token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willReturn($storedObject->getUuid()->toString()); diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Security/Guard/DavOnUrlTokenExtractorTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Security/Guard/DavOnUrlTokenExtractorTest.php index b9e046b28..e1e0a3b36 100644 --- a/src/Bundle/ChillDocStoreBundle/Tests/Security/Guard/DavOnUrlTokenExtractorTest.php +++ b/src/Bundle/ChillDocStoreBundle/Tests/Security/Guard/DavOnUrlTokenExtractorTest.php @@ -19,6 +19,7 @@ use Symfony\Component\HttpFoundation\Request; /** * @internal + * * @coversNothing */ class DavOnUrlTokenExtractorTest extends TestCase @@ -28,7 +29,7 @@ class DavOnUrlTokenExtractorTest extends TestCase /** * @dataProvider provideDataUri */ - public function testExtract(string $uri, string|false $expected): void + public function testExtract(string $uri, false|string $expected): void { $request = $this->prophesize(Request::class); $request->getRequestUri()->willReturn($uri); From 0dd58cebec46f7faf0f688f2cda798c2dbe8249e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Mon, 15 Jan 2024 21:18:51 +0100 Subject: [PATCH 24/30] optional parameter after the required one --- .../Security/Guard/JWTOnDavUrlAuthenticator.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTOnDavUrlAuthenticator.php b/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTOnDavUrlAuthenticator.php index ceb44949a..7695fb635 100644 --- a/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTOnDavUrlAuthenticator.php +++ b/src/Bundle/ChillDocStoreBundle/Security/Guard/JWTOnDavUrlAuthenticator.php @@ -27,9 +27,9 @@ class JWTOnDavUrlAuthenticator extends JWTTokenAuthenticator JWTTokenManagerInterface $jwtManager, EventDispatcherInterface $dispatcher, TokenExtractorInterface $tokenExtractor, + private readonly DavOnUrlTokenExtractor $davOnUrlTokenExtractor, TokenStorageInterface $preAuthenticationTokenStorage, TranslatorInterface $translator = null, - private readonly DavOnUrlTokenExtractor $davOnUrlTokenExtractor, ) { parent::__construct($jwtManager, $dispatcher, $tokenExtractor, $preAuthenticationTokenStorage, $translator); } From 47a928a6cd8d9ad8436c8d70c7700d77eefd42e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Thu, 23 May 2024 18:25:20 +0200 Subject: [PATCH 25/30] Add DAV edit link to StoredObject serialization Enabled the adding of access link, specifically DAV edit link to the JSON serialization of the StoredObject entity. The patch also adjusted the serializer groups of various attributes of StoredObject from "read, write" to "write". Lastly, these changes were reflected in the accompanying CourseWork Controller and the FormEvaluation Vue component. --- .../Entity/StoredObject.php | 18 ++-- .../Normalizer/StoredObjectNormalizer.php | 90 +++++++++++++++++++ .../AccompanyingCourseWorkController.php | 3 +- .../components/FormEvaluation.vue | 2 + 4 files changed, 102 insertions(+), 11 deletions(-) create mode 100644 src/Bundle/ChillDocStoreBundle/Serializer/Normalizer/StoredObjectNormalizer.php diff --git a/src/Bundle/ChillDocStoreBundle/Entity/StoredObject.php b/src/Bundle/ChillDocStoreBundle/Entity/StoredObject.php index 4a16d33f4..aae003e09 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' ) { diff --git a/src/Bundle/ChillDocStoreBundle/Serializer/Normalizer/StoredObjectNormalizer.php b/src/Bundle/ChillDocStoreBundle/Serializer/Normalizer/StoredObjectNormalizer.php new file mode 100644 index 000000000..e74ab5547 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Serializer/Normalizer/StoredObjectNormalizer.php @@ -0,0 +1,90 @@ + $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'] ?? []); + $canDavEdit = in_array(self::ADD_DAV_EDIT_LINK_CONTEXT, $context['groups'] ?? []); + + 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; + } +} diff --git a/src/Bundle/ChillPersonBundle/Controller/AccompanyingCourseWorkController.php b/src/Bundle/ChillPersonBundle/Controller/AccompanyingCourseWorkController.php index 5ae2db0e3..43ebae9cd 100644 --- a/src/Bundle/ChillPersonBundle/Controller/AccompanyingCourseWorkController.php +++ b/src/Bundle/ChillPersonBundle/Controller/AccompanyingCourseWorkController.php @@ -11,6 +11,7 @@ declare(strict_types=1); namespace Chill\PersonBundle\Controller; +use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectNormalizer; use Chill\MainBundle\Pagination\PaginatorFactory; use Chill\MainBundle\Templating\Listing\FilterOrderHelper; use Chill\MainBundle\Templating\Listing\FilterOrderHelperFactoryInterface; @@ -134,7 +135,7 @@ final class AccompanyingCourseWorkController extends AbstractController { $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', [ 'accompanyingCourse' => $work->getAccompanyingPeriod(), diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/FormEvaluation.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/FormEvaluation.vue index 4ea00f73a..5aa270a37 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/FormEvaluation.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/FormEvaluation.vue @@ -135,6 +135,8 @@ :filename="d.title" :can-edit="true" :execute-before-leave="submitBeforeLeaveToEditor" + :davLink="d.storedObject._links.dav_link.href" + :davLinkExpiration="d.storedObject._links.dav_link.expiration" @on-stored-object-status-change="onStatusDocumentChanged" > From 775535e68384e210980fa45e3991aa60fd43442b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Mon, 27 May 2024 22:32:03 +0200 Subject: [PATCH 26/30] refactor file drop widget --- .../ChillActivityBundle/Form/ActivityType.php | 12 +- .../Resources/views/Activity/edit.html.twig | 6 +- .../Form/AccompanyingCourseDocumentType.php | 32 +--- .../Form/CollectionStoredObjectType.php | 37 +++++ .../DataMapper/StoredObjectDataMapper.php | 82 +++++++++ .../StoredObjectDataTransformer.php | 52 ++++++ .../Form/StoredObjectType.php | 85 ++-------- .../async_upload/{index.js => index-old.js} | 0 .../public/module/async_upload/index.ts | 86 ++++++++++ .../Resources/public/types.ts | 29 ++++ .../vuejs/DocumentActionButtonsGroup.vue | 16 +- .../public/vuejs/DropFileWidget/DropFile.vue | 155 ++++++++++++++++++ .../vuejs/DropFileWidget/DropFileWidget.vue | 83 ++++++++++ .../StoredObjectButton/ConvertButton.vue | 4 +- .../StoredObjectButton/DownloadButton.vue | 4 +- .../StoredObjectButton/WopiEditButton.vue | 4 +- .../public/vuejs/_components/helper.ts | 60 +++++++ .../Resources/views/Form/fields.html.twig | 20 +-- .../chill.webpack.config.js | 2 +- .../config/services/form.yaml | 27 +-- .../translations/messages.fr.yml | 3 + .../Form/Type/ChillCollectionType.php | 3 + .../Resources/public/chill/index.js | 2 - .../Resources/public/lib/collection/index.js | 120 -------------- .../collection/collection.scss | 0 .../public/module/collection/index.ts | 128 +++++++++++++++ .../Resources/views/Form/fields.html.twig | 3 +- .../Resources/views/layout.html.twig | 2 + .../ChillMainBundle/chill.webpack.config.js | 1 + .../components/FormEvaluation.vue | 4 +- 30 files changed, 780 insertions(+), 282 deletions(-) create mode 100644 src/Bundle/ChillDocStoreBundle/Form/CollectionStoredObjectType.php create mode 100644 src/Bundle/ChillDocStoreBundle/Form/DataMapper/StoredObjectDataMapper.php create mode 100644 src/Bundle/ChillDocStoreBundle/Form/DataTransformer/StoredObjectDataTransformer.php rename src/Bundle/ChillDocStoreBundle/Resources/public/module/async_upload/{index.js => index-old.js} (100%) create mode 100644 src/Bundle/ChillDocStoreBundle/Resources/public/module/async_upload/index.ts create mode 100644 src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/DropFileWidget/DropFile.vue create mode 100644 src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/DropFileWidget/DropFileWidget.vue create mode 100644 src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/_components/helper.ts delete mode 100644 src/Bundle/ChillMainBundle/Resources/public/lib/collection/index.js rename src/Bundle/ChillMainBundle/Resources/public/{lib => module}/collection/collection.scss (100%) create mode 100644 src/Bundle/ChillMainBundle/Resources/public/module/collection/index.ts 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/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..2869522a0 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Form/DataMapper/StoredObjectDataMapper.php @@ -0,0 +1,82 @@ +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); + } + + dump($forms['stored_object']->getData(), $viewData); + + 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()); + } + + dump($viewData); + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Form/DataTransformer/StoredObjectDataTransformer.php b/src/Bundle/ChillDocStoreBundle/Form/DataTransformer/StoredObjectDataTransformer.php new file mode 100644 index 000000000..dfa1b6d4e --- /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($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/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 284ae0f1f..192cdd271 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/DocumentActionButtonsGroup.vue +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/DocumentActionButtonsGroup.vue @@ -1,5 +1,5 @@