From 73d6578a3a0fba16a48d2848578d995b7d493cef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Mon, 29 Apr 2024 15:28:24 +0200 Subject: [PATCH 01/34] Fix workflow configuration --- src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflow.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflow.php b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflow.php index 6996eacea..be2676e35 100644 --- a/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflow.php +++ b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflow.php @@ -85,7 +85,7 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface */ #[Assert\Valid(traverse: true)] #[ORM\OneToMany(targetEntity: EntityWorkflowStep::class, mappedBy: 'entityWorkflow', orphanRemoval: true, cascade: ['persist'])] - #[ORM\OrderBy(['transitionAt' => Order::Ascending, 'id' => 'ASC'])] + #[ORM\OrderBy(['transitionAt' => 'ASC', 'id' => 'ASC'])] private Collection $steps; /** From 9a010f76cc8d382988fe2e9f79f271edca154251 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Mon, 29 Apr 2024 15:28:44 +0200 Subject: [PATCH 02/34] Fix some translations (already existing in v2) --- .../Resources/views/PersonResource/create.html.twig | 1 - src/Bundle/ChillPersonBundle/translations/messages.fr.yml | 8 ++++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/Bundle/ChillPersonBundle/Resources/views/PersonResource/create.html.twig b/src/Bundle/ChillPersonBundle/Resources/views/PersonResource/create.html.twig index 2c36ae141..3071fe6e2 100644 --- a/src/Bundle/ChillPersonBundle/Resources/views/PersonResource/create.html.twig +++ b/src/Bundle/ChillPersonBundle/Resources/views/PersonResource/create.html.twig @@ -19,7 +19,6 @@

{{ 'Add a person resource'|trans }}

-

{{ 'Add a person resource'|trans }}

{% include "@ChillPerson/PersonResource/form.html.twig" %}
{% endblock %} diff --git a/src/Bundle/ChillPersonBundle/translations/messages.fr.yml b/src/Bundle/ChillPersonBundle/translations/messages.fr.yml index 013298ba5..7724eadae 100644 --- a/src/Bundle/ChillPersonBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillPersonBundle/translations/messages.fr.yml @@ -250,9 +250,9 @@ Concerned scopes: Services concernés # person resource person_resources_menu: "Personnes ressources" Person resources: "Personnes ressources de l'usager" -Add a person resource: "Ajouter une person ressource" -edit resource: "Modifier la ressource" -Remove resource: "Supprimer la ressource" +Add a person resource: "Ajouter une personne ressource" +edit resource: "Modifier la personne ressource" +Remove resource: "Supprimer la personne ressource" Are you sure you want to remove the resource for "%name%" ?: Étes-vous sûr de vouloir supprimer cette ressource de %name%? The resource has been successfully removed.: "La ressource a été supprimée." List of resources: "Liste des personnes ressources" @@ -293,7 +293,7 @@ residential_address_new_address_explanation: Créer une nouvelle adresse. L'adre New residential address: Nouvelle adresse de résidence Host person: Choisir l'adresse d'un usager The new residential address was created successfully: La nouvelle adresse de résidence a été créée -Edit a residential address: Modifier l'addresse de résidence +Edit a residential address: Modifier l'adresse de résidence The residential address was updated successfully: L'adresse de résidence a été mise à jour Residential addresses: Adresses de résidence Address of: Adresse de From 0c75514ec48975a74a04c0f655bf56de210506ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Mon, 29 Apr 2024 15:53:08 +0200 Subject: [PATCH 03/34] Fix validation of household address history --- .../Entity/Household/Household.php | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/Bundle/ChillPersonBundle/Entity/Household/Household.php b/src/Bundle/ChillPersonBundle/Entity/Household/Household.php index fdea18901..543726eb2 100644 --- a/src/Bundle/ChillPersonBundle/Entity/Household/Household.php +++ b/src/Bundle/ChillPersonBundle/Entity/Household/Household.php @@ -40,7 +40,7 @@ class Household implements HasCentersInterface #[Serializer\Groups(['write'])] #[ORM\ManyToMany(targetEntity: Address::class, cascade: ['persist', 'remove', 'merge', 'detach'])] #[ORM\JoinTable(name: 'chill_person_household_to_addresses')] - #[ORM\OrderBy(['validFrom' => Criteria::DESC, 'id' => 'DESC'])] + #[ORM\OrderBy(['validFrom' => 'DESC', 'id' => 'DESC'])] private Collection $addresses; #[ORM\Embedded(class: CommentEmbeddable::class, columnPrefix: 'comment_members_')] @@ -592,14 +592,15 @@ class Household implements HasCentersInterface } #[Assert\Callback] - public function validate(ExecutionContextInterface $context, $payload) + public function validate(ExecutionContextInterface $context, $payload): void { - $addresses = $this->getAddresses(); - $cond = true; + $addresses = $this->getAddressesOrdered(); for ($i = 0; \count($addresses) - 1 > $i; ++$i) { - if ($addresses[$i]->getValidFrom() !== $addresses[$i + 1]->getValidTo()) { - $cond = false; + if ($i === 0) { + continue; + } + if ($addresses[$i - 1]->getValidTo() !== $addresses[$i]->getValidFrom()) { $context->buildViolation('The address are not sequentials. The validFrom date of one address should be equal to the validTo date of the previous address.') ->atPath('addresses') ->addViolation(); From e24c4dc2e81624790d07883ab5403d9e3df91628 Mon Sep 17 00:00:00 2001 From: Julie Lenaerts Date: Tue, 30 Apr 2024 09:25:09 +0200 Subject: [PATCH 04/34] Fix workflow hierarchy --- .../DependencyInjection/ChillPersonExtension.php | 1 - .../ChillTaskBundle/DependencyInjection/ChillTaskExtension.php | 1 - 2 files changed, 2 deletions(-) diff --git a/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php b/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php index e9e1b79b5..586e37eac 100644 --- a/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php +++ b/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php @@ -1004,7 +1004,6 @@ class ChillPersonExtension extends Extension implements PrependExtensionInterfac 'enabled' => true, ], 'marking_store' => [ - 'type' => 'method', 'property' => 'step', ], 'supports' => [ diff --git a/src/Bundle/ChillTaskBundle/DependencyInjection/ChillTaskExtension.php b/src/Bundle/ChillTaskBundle/DependencyInjection/ChillTaskExtension.php index 0122861d6..1b678cffa 100644 --- a/src/Bundle/ChillTaskBundle/DependencyInjection/ChillTaskExtension.php +++ b/src/Bundle/ChillTaskBundle/DependencyInjection/ChillTaskExtension.php @@ -80,7 +80,6 @@ class ChillTaskExtension extends Extension implements PrependExtensionInterface 'workflows' => [ 'task_default' => [ 'marking_store' => [ - 'type' => 'method', 'property' => 'currentStates', ], 'type' => 'workflow', From 394c752fc3ca7f442b2feedf98373cc586450b5b Mon Sep 17 00:00:00 2001 From: Julie Lenaerts Date: Tue, 30 Apr 2024 16:29:33 +0200 Subject: [PATCH 05/34] undo wrong fix of workflow configuration --- .../DependencyInjection/ChillPersonExtension.php | 1 + .../ChillTaskBundle/DependencyInjection/ChillTaskExtension.php | 1 + 2 files changed, 2 insertions(+) diff --git a/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php b/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php index 586e37eac..e9e1b79b5 100644 --- a/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php +++ b/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php @@ -1004,6 +1004,7 @@ class ChillPersonExtension extends Extension implements PrependExtensionInterfac 'enabled' => true, ], 'marking_store' => [ + 'type' => 'method', 'property' => 'step', ], 'supports' => [ diff --git a/src/Bundle/ChillTaskBundle/DependencyInjection/ChillTaskExtension.php b/src/Bundle/ChillTaskBundle/DependencyInjection/ChillTaskExtension.php index 1b678cffa..0122861d6 100644 --- a/src/Bundle/ChillTaskBundle/DependencyInjection/ChillTaskExtension.php +++ b/src/Bundle/ChillTaskBundle/DependencyInjection/ChillTaskExtension.php @@ -80,6 +80,7 @@ class ChillTaskExtension extends Extension implements PrependExtensionInterface 'workflows' => [ 'task_default' => [ 'marking_store' => [ + 'type' => 'method', 'property' => 'currentStates', ], 'type' => 'workflow', From 1230b7f4c058ce1ba9011708ec4aba5073b978b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Fri, 3 May 2024 13:16:51 +0200 Subject: [PATCH 06/34] documentation for installation process --- docs/source/installation/index.rst | 506 +++++++++++++---------------- 1 file changed, 234 insertions(+), 272 deletions(-) diff --git a/docs/source/installation/index.rst b/docs/source/installation/index.rst index 67a0b6ec6..6c5b07346 100644 --- a/docs/source/installation/index.rst +++ b/docs/source/installation/index.rst @@ -14,273 +14,305 @@ Installation & Usage #################### -.. toctree:: - :maxdepth: 2 - - prod.rst - load-addresses.rst - prod-calendar-sms-sending.rst - msgraph-configure.rst +You will learn here how to install a new symfony project with chill, and configure it. Requirements -************ +============ -- This project use `docker `_ to be run. As a developer, use `docker-compose `_ to bootstrap a dev environment in a glance. You do not need any other dependencies ; -- Make is used to automate scripts. +The installation is tested on Debian-like linux distribution. The installation on other operating systems is not documented. -Installation -************ +You have to install the following tools on your computer: -If you plan to run chill in production: +- `PHP `_, version 8.3+, with the following extensions: pdo_pgsql, intl, mbstring, zip, bcmath, exif, sockets, redis, ast, gd; +- `composer `_; +- `symfony cli `_; +- `node, we encourage you to use nvm to configure the correct version `_. The project contains an + :code:`.nvmrc` file which selects automatically the required version of node (if present). +- `yarn `_. We use the version 1.22+ for now. +- `docker and the plugin compose `_ to run the database -1. install it locally first, and check if everything is ok on your local machine; -2. once ready, build the image from your local machine, and deploy them. - -If you want to develop some bundles, the first step is sufficient (until you deploy on production). +Chill needs a redis server and a postgresql database, and some stuffes like a "relatorio service" which will +generate documents from templates. **All those stuffes are available trough docker with the plugin compose**. We do not provide +information of how to run it without it. -1. Get the code -=============== - -Clone or download the chill-skeleton project and `cd` into the main directory. +Install a new project +===================== +Initialize project and dependencies +*********************************** .. code-block:: bash - git clone https://gitea.champs-libres.be/Chill-project/chill-skeleton-basic.git - cd chill-skeleton-basic + symfony new --version=5.4 my_chill_project + cd my_chill_project - -As a developer, the code will stay on your computer and will be executed in docker container. To avoid permission problem, the code should be run with the same uid/gid from your current user. This is why we get your current user id with the command ``id -u`` in each following scripts. - -2. Prepare composer to download the sources -=========================================== - -As you are running in dev, you must configure an auth token for getting the source code. - -.. warning - - If you skip this part, the code will be downloaded from dist instead of source (with git repository). You will probably replace the source manually, but the next time you will run ```composer update```, your repository will be replaced and you might loose something. - -1. Create a personal access token from https://gitlab.com/-/profile/personal_access_tokens, with the `read_api` scope. -2. add a file called ```.composer/auth.json``` with this content: - - .. code-block:: json - - { - "gitlab-token": { - "gitlab.com": "glXXX-XXXXXXXXXXXXXXXXXXXX" - } - } - -2. Prepare your variables and environment -========================================= - -Copy ```docker-compose.override.dev.yml``` into ```docker-compose.override.yml``` +We strongly encourage you to initialize a git repository at this step, to track further changes. .. code-block:: bash - cp docker-compose.override.dev.template.yml docker-compose.override.yml + # add the flex endpoints required to custom recipes + cat <<< "$(jq '.extra.symfony += {"endpoint": ["flex://defaults", "https://gitlab.com/api/v4/projects/57371968/repository/files/index.json/raw?ref=main"]}' composer.json)" > composer.json + # install chill and some dependencies + # TODO fix the suffix "alpha1" and replace by ^3.0.0 when version 3.0.0 will be released + composer require chill-project/chill-bundles v3.0.0-alpha1 champs-libres/wopi-lib dev-master@dev champs-libres/wopi-bundle dev-master@dev -2. Prepare your variables and docker-compose -============================================ - -Have a look at the variable in ``.env`` and check if you need to adapt them. If they do not adapt with your need, or if some are missing: - -1. copy the file as ``.env.local``: ``cp .env .env.local`` -2. you may replace some variables inside ``.env`` - -Prepare also you docker-compose installation, and adapt it to your needs: - -1. If you plan to deploy on dev, copy the file ``docker-compose.override.dev.template.yml`` to ``docker-compose.override.yml``. -2. adapt to your needs. - - -**Note**: If you intend to use the bundle ``Chill-Doc-Store``, you will need to configure and install an openstack object storage container with temporary url middleware. You will have to configure `secret keys `_. - -3. Run the bootstrap script -=========================== - -This script can be run using `make` +We encourage you to accept to include the "Docker configuration from recipes": this is the documented way to run database. +You must also accept to configure recipes from the contrib repository, unless you want to configure by yourself the bundles). .. code-block:: bash - make init - -This script will : - -1. force docker-compose to, eventually, pull the base images and build the image used by this project ; -2. run an install script to download `composer `_ ; -3. install the php dependencies -4. build assets - -.. warning:: - - The script will work only if the binary ``docker-compose`` is located into your ``PATH``. If you use ``compose`` as a docker plugin, - you can simulate this binary by creating this file at (for instance), ``/usr/local/bin/docker-compose`` (and run ``chmod +x /usr/local/bin/docker-compose``): - - .. code-block:: bash - - #!/bin/bash - - /usr/bin/docker compose "$@" - + # fix some configuration + ./post-install-chill.sh + # install node dependencies + yarn install + # and compile assets + yarn run encore production .. note:: - In some cases it can happen that an old image (chill_base_php82 or chill_php82) stored in the docker cache will make the script fail. To solve this problem you have to delete the image and the container, before the make init : + If you encounter this error during assets compilation (:code:`yarn run encore production`) (repeated multiple times): - .. code-block:: bash + .. code-block:: txt - docker-compose images php - docker rmi -f chill_php82:prod - docker-compose rm php + [tsl] ERROR in /tmp/chill/v1/public/bundles/chillcalendar/types.ts(2,65) + TS2307: Cannot find module '../../../ChillMainBundle/Resources/public/types' or its corresponding type declarations. + run: -4. Start the project -==================== + .. code-block:: bash + + rm -rf public/bundles/* + + Then restart the compilation of assets (:code:```yarn run encore production```) + +Configure your project +********************** + +You should carefully read the configuration files in :code:`chill/config/packages`, especially if you have +custom developments. But most of the time, this should be fine. + +You have to configure some local variable, which are described in the :code:`.env` file. The secrets should not be stored +in this :code:`.env` file, but instead using the `secret management tool `_ +or in the :code:`.env.local` file, which should not be commited to git repository. + +You do not need to set variables for the smtp server, redis server and relatorio server, as they are generated automatically +by the symfony server, from the docker compose services. + +The only required variable in the :code:`ADMIN_PASSWORD`. You can generate an hashed and salted admin password using the command +:code:`symfony console security:hash-password 'Symfony\Component\Security\Core\User\User'`. Then, +you can either: + +- add this password to the :code:`.env.local` file, you must escape the character :code:`$`: if the generated password + is :code:`$2y$13$iyvJLuT4YEa6iWXyQV4/N.hNHpNG8kXlYDkkt5MkYy4FXcSwYAwmm`, your :code:`.env.local` file will be: + + .. code-block:: env + + ADMIN_PASSWORD=\$2y\$13\$iyvJLuT4YEa6iWXyQV4/N.hNHpNG8kXlYDkkt5MkYy4FXcSwYAwmm + +- add the generated password to the secret management (**note**: you must add the generated hashed password to the secrets env, + not the password in clear text). + +Prepare migrations and other tools +********************************** + +To continue the installation process, you will have to run migrations: .. code-block:: bash - docker-compose up + # start databases and other services + docker compose up -d + # the first start, it may last some seconds, you can check with docker compose ps + # run migrations + symfony console doctrine:migrations:migrate + # setup messenger + symfony console messenger:setup-transports + # prepare some views + symfony console chill:db:sync-views -**On the first run** (and after each upgrade), you must execute *post update commands* and run database migrations. With a container up and running, execute the following commands: +.. warning:: + + If you encounter error while running :code:`symfony console messenger:setup-transports`, you can set up the messenger + transport to redis, by adding this in the :code:`.env.local` or :code:`.env` file: + + .. code-block:: env + + MESSENGER_TRANSPORT_DSN=redis://${REDIS_HOST}:${REDIS_PORT}/messages + +Start your web server locally +***************************** + +At this step, Chill will be ready for serving locally, but does not contains any configuration. You can run the project +locally using the `local symfony server `_: .. code-block:: bash - # mount into to container - ./docker-php.sh - bin/console chill:db:sync-views - # and load fixtures - bin/console doctrine:migrations:migrate + # see the whole possibilities at https://symfony.com/doc/current/setup/symfony_server.html + symfony server:start -d -Chill will be available at ``http://localhost:8001.`` Currently, there isn't any user or data. To add fixtures, run +If you need to test the instance with accounts and some basic configuration, install fixtures (see below). + + +Add capabilities for dev +======================== + +If you need to add custom bundles, you can develop them in the `src/` directory, like any other symfony projects. You +can rely on the whole chill framework from there: there is no need to add them to the original `chill-bundles`. + +You will require some bundles to have development tools: + +- add fixtures +- add profiler and var-dumper to debug + +Install fixtures +**************** .. code-block:: bash - # mount into to container - ./docker-php.sh - # and load fixtures (do not this for production) - bin/console doctrine:fixtures:load --purge-with-truncate + # generate fixtures for chill + composer require --dev doctrine/fixtures-bundle nelmio/alice + # now, you can generate fixtures (this will reset your database) + symfony console doctrine:fixtures:load -There are several users available: +This will generate user accounts, centers, and some basic configuration. -- ``center a_social`` -- ``center b_social`` +The accounts created are: :code:`center a_social`, :code:`center b_social`, :code:`center a_direction`, ... The full list is +visibile in the "users" table: :code:`docker compose exec database psql -U app -c "SELECT username FROM users"`. -The password is always ``password``. +The password is always :code:`password`. -Now, read `Operations` below. For running in production, read `prod_`. +.. warning:: + The fixtures are not fully functional. See the `corresponding issue `_. + +Add web profiler and debugger +***************************** + +.. code-block:: bash + + composer require --dev symfony/web-profiler-bundle symfony/var-dumper + +Working on chill bundles +************************ + +If you plan to improve the chill-bundles repository, this is great! + +You must download chill-bundles as a git repository (and not as an archive, which is barely editable). + +In your :code:`composer.json` file, add those lines: + +.. code-block:: diff + + { + "config": { + + "preferred-install": { + + "chill-project/chill-bundles": "source", + + } + } + +Then, run :code:`composer update` to take changes into account. + +Code style, code quality and other tools +**************************************** + +For development, you will also have to install: + +- `php-cs-fixer `_ + +We also encourage you to use tools like `phpstan `_ and `rector `_. + +Commit and share your project +============================= + +If multiple developers works on the project, you can commit your symfony project and share it with other people. + +When another developers clone your project, they will have to: + +- run :code:`composer install` and :code:`yarn install` to install the same dependencies as the initial developer; +- run :code:`yarn run encore production` to compile assets; +- copy eventually variables from the :code:`.env.local` files; +- start the docker compose stack, using :code:`docker compose`, and run migrations, set up transports, and prepare chill db views + (see the corresponding command above) + +Update +====== + +In order to update your app, you must update dependencies: + +- for chill-bundles, you can `set the last version `_ manually + in the :code:`composer.json` file, or set to the version to `^3.0.0` and run :code:`composer update` regularly +- run :code:`composer update` and :code:`yarn update` to maintain your dependencies up-to-date. + +After each update, you must update your database schema: + +.. code-block:: bash + + symfony console doctrine:migrations:migrate + symfony console chill:db:sync-views Operations -********** +========== Build assets -============ +************ run those commands: .. code-block:: bash - make build-assets + # for production (or in dev, when you don't need to work on your assets and need some speed) + yarn run encore production + # in dev, when you wan't to reload the assets on each changes + yarn run encore dev --watch How to execute the console ? -============================ +**************************** .. code-block:: bash - # if a container is running - ./docker-php.sh - # if not - docker-compose run --user $(id -u) php bin/console + # start the console with all required variables + symfony console + # you can add your command after that: + symfony console list -How to create the database schema (= run migrations) ? -====================================================== +How to generate documents +************************* + +Documents are generated asynchronously by `"consuming messages" `_. + +You must generate them using a dedicated process: .. code-block:: bash - # if a container is running - ./docker-php.sh - bin/console doctrine:migrations:migrate - bin/console chill:db:sync-views - # if not - docker-compose run --user $(id -u) php bin/console doctrine:migrations:migrate - docker-compose run --user $(id -u) php bin/console chill:db:sync-views + symfony console messenger:consume async priority +To avoid memory issues, we encourage you to also use the :code:`--limit` parameter of the command. How to read the email sent by the program ? -=========================================== +******************************************* -Go at ``http://localhost:8005`` and you should have access to mailcatcher. - -In case of you should click on a link in the email, be aware that you should remove the "s" from https. - -How to load fixtures ? (development mode only) -============================================== - -.. code-block:: bash - - # if a container is running - ./docker-php.sh - bin/console doctrine:fixtures:load - # if not - docker-compose run --user $(id -u) php bin/console doctrine:fixtures:load - -How to open a terminal in the project -===================================== - -.. code-block:: bash - - # if a container is running - ./docker-php.sh - # if not - docker-compose run --user $(id -u) php /bin/bash +In development, there is a built-in "mail catcher". Open it with :code:`symfony open:local:webmail` How to run cron-jobs ? -====================== +********************** Some command must be executed in :ref:`cron jobs `. To execute them: .. code-block:: bash - # if a container is running - ./docker-php.sh - bin/console chill:cron-job:execute - # some of them are executed only during the night. So, we have to force the execution during the day: - bin/console chill:cron-job:execute 'name-of-the-cron' - # if not - docker-compose run --user $(id -u) php bin/console chill:cron-job:execute - # some of them are executed only during the night. So, we have to force the execution during the day: - docker-compose run --user $(id -u) php bin/console chill:cron-job:execute 'name-of-the-cron' + symfony console chill:cron-job:execute -How to run composer ? -===================== +What about materialized views ? +******************************* -.. code-block:: bash +There are some materialized views in chill, to speed up some complex computation on the database. - # if a container is running - ./docker-php.sh - composer - # if not - docker-compose run --user $(id -u) php composer +In order to refresh them, run cron job or refresh them manually in your database. -How to access to PGADMIN ? -========================== - -Pgadmin is installed with docker-compose, and is available **only if you uncomment the appropriate lines into ``docker-compose.override.yml``. - -You can access it at ``http://localhost:8002``. - -Credentials: - -- login: from the variable you set into ``docker-composer.override.yml`` -- password: same :-) - -How to run tests ? -================== +How to run tests for chill-bundles +********************************** Tests reside inside the installed bundles. You must `cd` into that directory, download the required packages, and execute them from this place. @@ -290,87 +322,29 @@ Exemple, for running unit test inside `main` bundle: .. code-block:: bash - # mount into the php image - ./docker-php.sh # cd into main directory cd vendor/chill-project/chill-bundles - # download deps - git submodule init - git submodule update composer install # run tests bin/phpunit src/Bundle/path/to/your/test Or for running tests to check code style and php conventions with csfixer and phpstan: -.. code-block:: bash - - # run code style fixer - bin/grumphp run --tasks=phpcsfixer - # run phpstan - bin/grumphp run --tasks=phpstan - - -.. note:: - - To avoid phpstan block your commits: - - .. code-block:: bash - - git commit -n ... - - To avoid phpstan block your commits permanently: - - .. code-block:: bash - - ./bin/grumphp git:deinit - -How to run webpack interactively -================================ - -Executing :code:`bash docker-node.sh` will open a terminal in a node container, with volumes mounted. - -How to switch the branch for chill-bundles, and get new dependencies -==================================================================== - -During development, you will switch to new branches for chill-bundles. As long as the dependencies are equals, this does not cause any problem. But sometimes, a new branch introduces a new dependency, and you must download it. - -.. warning:: - - Ensure that you have gitlab-token ready before updating your branches. See above. - -In order to do that without pain, use those steps: - -0. Ensuire you have a token, set -1. at the app's root, update the ``composer.json`` to your current branch: - -.. code-block:: json - - { - "require": { - "chill-bundles": "dev-@dev" - } - -2. mount into the php container (``./docker-php.sh``), and run ``composer update`` +Troubleshooting +=============== Error `An exception has been thrown during the rendering of a template ("Asset manifest file "/var/www/app/web/build/manifest.json" does not exist.").` on first run -==================================================================================================================================================================== +******************************************************************************************************************************************************************** Run :code:`make build-assets` Running in production -********************* +===================== Currently, to run this software in production, the *state of the art* is the following : 1. Run the software locally and tweak the configuration to your needs ; -2. Build the image and store them into a private container registry. This can be done using :code:`make build-and-push-image`. - - To be sure to target the correct container registry, you have to adapt the image names into your ``docker-compose.override.yml`` file. -3. Push the image on your registry, or upload them to the destination machine using ``docker image save`` and ``docker image load``. -3. Run the image on your production server, using docker-compose or eventually docker stack. You have to customize the variable set in docker-compose. - - See also the :ref:`running-production-tips-and-tricks` below. +2. Build the image and store them into a private container registry. .. warning:: @@ -382,25 +356,13 @@ Currently, to run this software in production, the *state of the art* is the fol There are security issues if you keep the same variable than for production. -.. _running-production-tips-and-tricks: +Going further +============= -Tips and tricks -=============== - -Operation on database (backups, running custom sql, replication) are easier to set when run outside of a container. If you run into a container, take care of the volume where data are stored. - -The PHP sessions are stored inside redis. This is useful if you distribute the traffic amongst different php server: they will share same sessions if a request goes into a different instance of the container. - -When the PHP servers are shared across multiple instances, take care that some data is stored into redis: the same redis server should be reachable by all instances. - -It is worth having an eye on the configuration of logstash container. - - -Design principles -***************** - -Why the DB URL is also set in environment, and not in .env file ? -================================================================= - -Because, at startup, a script does check the db is up and, if not, wait for a couple of seconds before running ``entrypoint.sh``. For avoiding double configuration, the configuration of the PHP app takes his configuration from environment also (and it will be standard in future releases, with symfony 4.0). +.. toctree:: + :maxdepth: 2 + prod.rst + load-addresses.rst + prod-calendar-sms-sending.rst + msgraph-configure.rst From 5334779b55e7d6bd20887c75d08152114d6b118d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Fri, 3 May 2024 13:20:02 +0200 Subject: [PATCH 07/34] fix issue in installation instructions --- docs/source/installation/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/installation/index.rst b/docs/source/installation/index.rst index 6c5b07346..806d434ff 100644 --- a/docs/source/installation/index.rst +++ b/docs/source/installation/index.rst @@ -336,7 +336,7 @@ Troubleshooting Error `An exception has been thrown during the rendering of a template ("Asset manifest file "/var/www/app/web/build/manifest.json" does not exist.").` on first run ******************************************************************************************************************************************************************** -Run :code:`make build-assets` +Build assets, see above. Running in production ===================== From d63e1a15bd467bf746b938aad5ed87766740a991 Mon Sep 17 00:00:00 2001 From: LenaertsJ Date: Tue, 7 May 2024 14:51:31 +0000 Subject: [PATCH 08/34] Language corrections --- docs/source/installation/index.rst | 70 +++++++++++++++--------------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/docs/source/installation/index.rst b/docs/source/installation/index.rst index 806d434ff..0cf16b0f2 100644 --- a/docs/source/installation/index.rst +++ b/docs/source/installation/index.rst @@ -20,7 +20,7 @@ You will learn here how to install a new symfony project with chill, and configu Requirements ============ -The installation is tested on Debian-like linux distribution. The installation on other operating systems is not documented. +The installation is tested on a Debian-like linux distribution. The installation on other operating systems is not documented. You have to install the following tools on your computer: @@ -32,9 +32,9 @@ You have to install the following tools on your computer: - `yarn `_. We use the version 1.22+ for now. - `docker and the plugin compose `_ to run the database -Chill needs a redis server and a postgresql database, and some stuffes like a "relatorio service" which will -generate documents from templates. **All those stuffes are available trough docker with the plugin compose**. We do not provide -information of how to run it without it. +Chill needs a redis server and a postgresql database, and a few other things like a "relatorio service" which will +generate documents from templates. **All these things are available through docker using the plugin compose**. We do not provide +information on how to run this without docker compose. Install a new project @@ -52,14 +52,14 @@ We strongly encourage you to initialize a git repository at this step, to track .. code-block:: bash - # add the flex endpoints required to custom recipes + # add the flex endpoints required for custom recipes cat <<< "$(jq '.extra.symfony += {"endpoint": ["flex://defaults", "https://gitlab.com/api/v4/projects/57371968/repository/files/index.json/raw?ref=main"]}' composer.json)" > composer.json # install chill and some dependencies # TODO fix the suffix "alpha1" and replace by ^3.0.0 when version 3.0.0 will be released composer require chill-project/chill-bundles v3.0.0-alpha1 champs-libres/wopi-lib dev-master@dev champs-libres/wopi-bundle dev-master@dev -We encourage you to accept to include the "Docker configuration from recipes": this is the documented way to run database. -You must also accept to configure recipes from the contrib repository, unless you want to configure by yourself the bundles). +We encourage you to accept the inclusion of the "Docker configuration from recipes": this is the documented way to run the database. +You must also accept to configure recipes from the contrib repository, unless you want to configure the bundles manually). .. code-block:: bash @@ -90,17 +90,17 @@ You must also accept to configure recipes from the contrib repository, unless yo Configure your project ********************** -You should carefully read the configuration files in :code:`chill/config/packages`, especially if you have +You should read the configuration files in :code:`chill/config/packages` carefully, especially if you have custom developments. But most of the time, this should be fine. -You have to configure some local variable, which are described in the :code:`.env` file. The secrets should not be stored -in this :code:`.env` file, but instead using the `secret management tool `_ -or in the :code:`.env.local` file, which should not be commited to git repository. +You have to configure some local variables, which are described in the :code:`.env` file. The secrets should not be stored +in this :code:`.env` file, but instead using the `secrets management tool `_ +or in the :code:`.env.local` file, which should not be commited to the git repository. You do not need to set variables for the smtp server, redis server and relatorio server, as they are generated automatically by the symfony server, from the docker compose services. -The only required variable in the :code:`ADMIN_PASSWORD`. You can generate an hashed and salted admin password using the command +The only required variable is the :code:`ADMIN_PASSWORD`. You can generate a hashed and salted admin password using the command :code:`symfony console security:hash-password 'Symfony\Component\Security\Core\User\User'`. Then, you can either: @@ -111,7 +111,7 @@ you can either: ADMIN_PASSWORD=\$2y\$13\$iyvJLuT4YEa6iWXyQV4/N.hNHpNG8kXlYDkkt5MkYy4FXcSwYAwmm -- add the generated password to the secret management (**note**: you must add the generated hashed password to the secrets env, +- add the generated password to the secrets manager (**note**: you must add the generated hashed password to the secrets env, not the password in clear text). Prepare migrations and other tools @@ -133,7 +133,7 @@ To continue the installation process, you will have to run migrations: .. warning:: - If you encounter error while running :code:`symfony console messenger:setup-transports`, you can set up the messenger + If you encounter an error while running :code:`symfony console messenger:setup-transports`, you can set up the messenger transport to redis, by adding this in the :code:`.env.local` or :code:`.env` file: .. code-block:: env @@ -143,7 +143,7 @@ To continue the installation process, you will have to run migrations: Start your web server locally ***************************** -At this step, Chill will be ready for serving locally, but does not contains any configuration. You can run the project +At this step, Chill will be ready to be served locally, but without any configuration. You can run the project locally using the `local symfony server `_: .. code-block:: bash @@ -152,16 +152,16 @@ locally using the `local symfony server `_ and `re Commit and share your project ============================= -If multiple developers works on the project, you can commit your symfony project and share it with other people. +If multiple developers work on a project, you can commit your symfony project and share it with other people. -When another developers clone your project, they will have to: +When another developer clones your project, they will have to: - run :code:`composer install` and :code:`yarn install` to install the same dependencies as the initial developer; - run :code:`yarn run encore production` to compile assets; -- copy eventually variables from the :code:`.env.local` files; +- copy any possible variables from the :code:`.env.local` files; - start the docker compose stack, using :code:`docker compose`, and run migrations, set up transports, and prepare chill db views (see the corresponding command above) @@ -242,7 +242,7 @@ Update In order to update your app, you must update dependencies: - for chill-bundles, you can `set the last version `_ manually - in the :code:`composer.json` file, or set to the version to `^3.0.0` and run :code:`composer update` regularly + in the :code:`composer.json` file, or set the version to `^3.0.0` and run :code:`composer update` regularly - run :code:`composer update` and :code:`yarn update` to maintain your dependencies up-to-date. After each update, you must update your database schema: @@ -290,7 +290,7 @@ You must generate them using a dedicated process: To avoid memory issues, we encourage you to also use the :code:`--limit` parameter of the command. -How to read the email sent by the program ? +How to read emails sent by the program ? ******************************************* In development, there is a built-in "mail catcher". Open it with :code:`symfony open:local:webmail` @@ -298,7 +298,7 @@ In development, there is a built-in "mail catcher". Open it with :code:`symfony How to run cron-jobs ? ********************** -Some command must be executed in :ref:`cron jobs `. To execute them: +Some commands must be executed in :ref:`cron jobs `. To execute them: .. code-block:: bash @@ -307,18 +307,18 @@ Some command must be executed in :ref:`cron jobs `. To execute them: What about materialized views ? ******************************* -There are some materialized views in chill, to speed up some complex computation on the database. +There are some materialized views in chill, to speed up some complex computations in the database. -In order to refresh them, run cron job or refresh them manually in your database. +In order to refresh them, run a cron job or refresh them manually in your database. How to run tests for chill-bundles ********************************** Tests reside inside the installed bundles. You must `cd` into that directory, download the required packages, and execute them from this place. -**Note**: some bundle require the fixture to be executed. See the dedicated _how-tos_. +**Note**: some bundles require the fixtures to be executed. See the dedicated _how-tos_. -Exemple, for running unit test inside `main` bundle: +Example, for running a unit test inside `main` bundle: .. code-block:: bash @@ -344,16 +344,16 @@ Running in production Currently, to run this software in production, the *state of the art* is the following : 1. Run the software locally and tweak the configuration to your needs ; -2. Build the image and store them into a private container registry. +2. Build the image and store it in a private container registry. .. warning:: - In production, you **must** set those variables: + In production, you **must** set these variables: * ``APP_ENV`` to ``prod`` * ``APP_DEBUG`` to ``false`` - There are security issues if you keep the same variable than for production. + There are security issues if you keep the same variables as for production. Going further From d0e27d51fe7bf26695deef1e8c3fa40b664a568a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Mon, 13 May 2024 13:20:08 +0000 Subject: [PATCH 09/34] fix path to doctrine-fixtures-bundles and prefix composer command by "symfony" --- docs/source/installation/index.rst | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/source/installation/index.rst b/docs/source/installation/index.rst index 0cf16b0f2..0328322e4 100644 --- a/docs/source/installation/index.rst +++ b/docs/source/installation/index.rst @@ -56,7 +56,7 @@ We strongly encourage you to initialize a git repository at this step, to track cat <<< "$(jq '.extra.symfony += {"endpoint": ["flex://defaults", "https://gitlab.com/api/v4/projects/57371968/repository/files/index.json/raw?ref=main"]}' composer.json)" > composer.json # install chill and some dependencies # TODO fix the suffix "alpha1" and replace by ^3.0.0 when version 3.0.0 will be released - composer require chill-project/chill-bundles v3.0.0-alpha1 champs-libres/wopi-lib dev-master@dev champs-libres/wopi-bundle dev-master@dev + symfony composer require chill-project/chill-bundles v3.0.0-alpha1 champs-libres/wopi-lib dev-master@dev champs-libres/wopi-bundle dev-master@dev We encourage you to accept the inclusion of the "Docker configuration from recipes": this is the documented way to run the database. You must also accept to configure recipes from the contrib repository, unless you want to configure the bundles manually). @@ -172,7 +172,7 @@ Install fixtures .. code-block:: bash # generate fixtures for chill - composer require --dev doctrine/fixtures-bundle nelmio/alice + symfony composer require --dev doctrine/doctrine-fixtures-bundle nelmio/alice # now, you can generate fixtures (this will reset your database) symfony console doctrine:fixtures:load @@ -192,7 +192,7 @@ Add web profiler and debugger .. code-block:: bash - composer require --dev symfony/web-profiler-bundle symfony/var-dumper + symfony composer require --dev symfony/web-profiler-bundle symfony/var-dumper Working on chill bundles ************************ @@ -207,12 +207,13 @@ In your :code:`composer.json` file, add these lines: { "config": { - + "preferred-install": { - + "chill-project/chill-bundles": "source", + + "preferred-install": { + + "chill-project/chill-bundles": "source", + "*": "dist" + } } -Then, run :code:`composer update` to take changes into account. +Then, run :code:`symfony composer reinstall chill-project/chill-bundles` to re-install the package from source. Code style, code quality and other tools **************************************** @@ -230,7 +231,7 @@ If multiple developers work on a project, you can commit your symfony project an When another developer clones your project, they will have to: -- run :code:`composer install` and :code:`yarn install` to install the same dependencies as the initial developer; +- run :code:`symfony composer install` and :code:`yarn install` to install the same dependencies as the initial developer; - run :code:`yarn run encore production` to compile assets; - copy any possible variables from the :code:`.env.local` files; - start the docker compose stack, using :code:`docker compose`, and run migrations, set up transports, and prepare chill db views @@ -242,7 +243,7 @@ Update In order to update your app, you must update dependencies: - for chill-bundles, you can `set the last version `_ manually - in the :code:`composer.json` file, or set the version to `^3.0.0` and run :code:`composer update` regularly + in the :code:`composer.json` file, or set the version to `^3.0.0` and run :code:`symfony composer update` regularly - run :code:`composer update` and :code:`yarn update` to maintain your dependencies up-to-date. After each update, you must update your database schema: 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 10/34] 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 11/34] 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 12/34] 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 13/34] 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 14/34] 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 15/34] 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 16/34] 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 17/34] 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 18/34] 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 19/34] 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 @@