mirror of
https://gitlab.com/Chill-Projet/chill-bundles.git
synced 2025-06-12 21:34:25 +00:00
Merge branch 'upgrade-sf5' into ticket-app-master
This commit is contained in:
commit
1fa483598b
@ -1,6 +0,0 @@
|
||||
kind: Fixed
|
||||
body: Fix broken link in homepage when a evaluation from a closed acc period was present
|
||||
in the homepage widget
|
||||
time: 2024-04-16T16:18:17.888645172+02:00
|
||||
custom:
|
||||
Issue: "270"
|
20
.changes/v2.19.0.md
Normal file
20
.changes/v2.19.0.md
Normal file
@ -0,0 +1,20 @@
|
||||
## v2.19.0 - 2024-05-14
|
||||
### Feature
|
||||
* ([#197](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/197)) Make the script which subscribe to microsoft calendars changes more tolerant to errors or missing configuration on the microsoft side
|
||||
* ([#276](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/276)) Take closing date into account when computing the geographical unit on accompanying period. When a person moved after an accompanying period is closed, the date of closing accompanying period is took into account if it is earlier than the date given by the user.
|
||||
### Fixed
|
||||
* ([#270](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/270)) Fix broken link in homepage when a evaluation from a closed acc period was present in the homepage widget
|
||||
* ([#275](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/275)) Allow the filter "filter accompanying period by geographical unit" to take period's location on address into account
|
||||
### UX
|
||||
* Form for document generation moved to the top of document list page
|
||||
* ([#266](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/266)) Event bundle: adjust certain graphical issues for better user experience
|
||||
|
||||
|
||||
### Traduction francophone des principaux changements
|
||||
|
||||
- script de synchronisation des agendas de microsoft Outlook: le script est plus tolérant aux erreurs de configuration côté serveur (manque de droit d'accès);
|
||||
- dans les statistiques sur les parcours d'accompagnements, regroupement et filtre par unité géographique: lorsque la date de prise en compte de l'adresse est postérieure à la fermeture du parcours, c'est la date de fermeture du parcours qui est prise en compte (cela permet de tenir compte de la localisation de l'usager au moment de la fermeture dans le cas où celui-ci aurait déménagé par la suite);
|
||||
- sur la page d'accueil, il n'y a plus de rappel pour les évaluations pour les parcours cloturés;
|
||||
- correction du filtre "filtrer par zone géographique"
|
||||
- répétition du bouton pour générer un document en haut de la page "liste des documents", quand il y a plus de cinq documents;
|
||||
- module événement: améliorerations graphiques
|
@ -27,6 +27,7 @@ $config
|
||||
->setRiskyAllowed(true)
|
||||
->setCacheFile('.cache/php-cs-fixer.cache')
|
||||
->setUsingCache(true)
|
||||
->setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect())
|
||||
;
|
||||
|
||||
$rules = $config->getRules();
|
||||
|
21
CHANGELOG.md
21
CHANGELOG.md
@ -6,6 +6,27 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
|
||||
and is generated by [Changie](https://github.com/miniscruff/changie).
|
||||
|
||||
|
||||
## v2.19.0 - 2024-05-14
|
||||
### Feature
|
||||
* ([#197](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/197)) Make the script which subscribe to microsoft calendars changes more tolerant to errors or missing configuration on the microsoft side
|
||||
* ([#276](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/276)) Take closing date into account when computing the geographical unit on accompanying period. When a person moved after an accompanying period is closed, the date of closing accompanying period is took into account if it is earlier than the date given by the user.
|
||||
### Fixed
|
||||
* ([#270](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/270)) Fix broken link in homepage when a evaluation from a closed acc period was present in the homepage widget
|
||||
* ([#275](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/275)) Allow the filter "filter accompanying period by geographical unit" to take period's location on address into account
|
||||
### UX
|
||||
* Form for document generation moved to the top of document list page
|
||||
* ([#266](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/266)) Event bundle: adjust certain graphical issues for better user experience
|
||||
|
||||
|
||||
### Traduction francophone des principaux changements
|
||||
|
||||
- script de synchronisation des agendas de microsoft Outlook: le script est plus tolérant aux erreurs de configuration côté serveur (manque de droit d'accès);
|
||||
- dans les statistiques sur les parcours d'accompagnements, regroupement et filtre par unité géographique: lorsque la date de prise en compte de l'adresse est postérieure à la fermeture du parcours, c'est la date de fermeture du parcours qui est prise en compte (cela permet de tenir compte de la localisation de l'usager au moment de la fermeture dans le cas où celui-ci aurait déménagé par la suite);
|
||||
- sur la page d'accueil, il n'y a plus de rappel pour les évaluations pour les parcours cloturés;
|
||||
- correction du filtre "filtrer par zone géographique"
|
||||
- répétition du bouton pour générer un document en haut de la page "liste des documents", quand il y a plus de cinq documents;
|
||||
- module événement: améliorerations graphiques
|
||||
|
||||
## v2.18.2 - 2024-04-12
|
||||
### Fixed
|
||||
* ([#250](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/250)) Postal codes import : fix the source URL and the keys to handle each record
|
||||
|
@ -9,6 +9,7 @@
|
||||
],
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"ext-dom": "*",
|
||||
"ext-json": "*",
|
||||
"ext-openssl": "*",
|
||||
"ext-redis": "*",
|
||||
@ -92,7 +93,7 @@
|
||||
"phpstan/phpstan-deprecation-rules": "^1.1",
|
||||
"phpstan/phpstan-strict-rules": "^1.0",
|
||||
"phpunit/phpunit": ">= 7.5",
|
||||
"rector/rector": "^1.0.0",
|
||||
"rector/rector": "^1.1.0",
|
||||
"symfony/debug-bundle": "^5.4",
|
||||
"symfony/dotenv": "^5.4",
|
||||
"symfony/maker-bundle": "^1.20",
|
||||
|
@ -14,6 +14,352 @@
|
||||
Installation & Usage
|
||||
####################
|
||||
|
||||
|
||||
You will learn here how to install a new symfony project with chill, and configure it.
|
||||
|
||||
Requirements
|
||||
============
|
||||
|
||||
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:
|
||||
|
||||
- `PHP <https://www.php.net/>`_, version 8.3+, with the following extensions: pdo_pgsql, intl, mbstring, zip, bcmath, exif, sockets, redis, ast, gd;
|
||||
- `composer <https://getcomposer.org/>`_;
|
||||
- `symfony cli <https://symfony.com/download>`_;
|
||||
- `node, we encourage you to use nvm to configure the correct version <https://github.com/nvm-sh/nvm>`_. The project contains an
|
||||
:code:`.nvmrc` file which selects automatically the required version of node (if present).
|
||||
- `yarn <https://classic.yarnpkg.com/lang/en/docs/install/>`_. We use the version 1.22+ for now.
|
||||
- `docker and the plugin compose <https://docker.com>`_ to run the database
|
||||
|
||||
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
|
||||
=====================
|
||||
|
||||
Initialize project and dependencies
|
||||
***********************************
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
symfony new --version=5.4 my_chill_project
|
||||
cd my_chill_project
|
||||
|
||||
We strongly encourage you to initialize a git repository at this step, to track further changes.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
# 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
|
||||
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).
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
# fix some configuration
|
||||
./post-install-chill.sh
|
||||
# install node dependencies
|
||||
yarn install
|
||||
# and compile assets
|
||||
yarn run encore production
|
||||
|
||||
.. note::
|
||||
|
||||
If you encounter this error during assets compilation (:code:`yarn run encore production`) (repeated multiple times):
|
||||
|
||||
.. code-block:: txt
|
||||
|
||||
[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:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
rm -rf public/bundles/*
|
||||
|
||||
Then restart the compilation of assets (:code:```yarn run encore production```)
|
||||
|
||||
Configure your project
|
||||
**********************
|
||||
|
||||
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 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 <https://symfony.com/doc/current/configuration/secrets.html>`_
|
||||
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 is the :code:`ADMIN_PASSWORD`. You can generate a hashed and salted admin password using the command
|
||||
:code:`symfony console security:hash-password <your 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 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
|
||||
**********************************
|
||||
|
||||
To continue the installation process, you will have to run migrations:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
# 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
|
||||
|
||||
.. warning::
|
||||
|
||||
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
|
||||
|
||||
MESSENGER_TRANSPORT_DSN=redis://${REDIS_HOST}:${REDIS_PORT}/messages
|
||||
|
||||
Start your web server locally
|
||||
*****************************
|
||||
|
||||
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 <https://symfony.com/doc/current/setup/symfony_server.html>`_:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
# see the whole possibilities at https://symfony.com/doc/current/setup/symfony_server.html
|
||||
symfony server:start -d
|
||||
|
||||
|
||||
If you need to test the instance with accounts and some basic configuration, please install the fixtures (see below).
|
||||
|
||||
|
||||
Add capabilities for dev
|
||||
========================
|
||||
|
||||
If you need to add custom bundles, you can develop them in the `src/` directory, like for any other symfony project. You
|
||||
can rely on the whole chill framework, meaning there is no need to add them to the original `chill-bundles`.
|
||||
|
||||
You will require some bundles to have the following development tools:
|
||||
|
||||
- add fixtures
|
||||
- add profiler and var-dumper to debug
|
||||
|
||||
Install fixtures
|
||||
****************
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
# generate fixtures for chill
|
||||
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
|
||||
|
||||
This will generate user accounts, centers, and some basic configuration.
|
||||
|
||||
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 :code:`password`.
|
||||
|
||||
.. warning::
|
||||
|
||||
The fixtures are not fully functional. See the `corresponding issue <https://gitlab.com/Chill-Projet/chill-bundles/-/issues/280>`_.
|
||||
|
||||
Add web profiler and debugger
|
||||
*****************************
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
symfony composer require --dev symfony/web-profiler-bundle symfony/var-dumper
|
||||
|
||||
Working on chill bundles
|
||||
************************
|
||||
|
||||
If you plan to improve the chill-bundles repository, that's great!
|
||||
|
||||
You will have to download chill-bundles as a git repository (and not as an archive, which is barely editable).
|
||||
|
||||
In your :code:`composer.json` file, add these lines:
|
||||
|
||||
.. code-block:: diff
|
||||
|
||||
{
|
||||
"config": {
|
||||
+ "preferred-install": {
|
||||
+ "chill-project/chill-bundles": "source",
|
||||
"*": "dist"
|
||||
+ }
|
||||
}
|
||||
|
||||
Then, run :code:`symfony composer reinstall chill-project/chill-bundles` to re-install the package from source.
|
||||
|
||||
Code style, code quality and other tools
|
||||
****************************************
|
||||
|
||||
For development, you will also have to install:
|
||||
|
||||
- `php-cs-fixer <https://cs.symfony.com/>`_
|
||||
|
||||
We also encourage you to use tools like `phpstan <https://phpstan.org>`_ and `rector <https://getrector.com>`_.
|
||||
|
||||
Commit and share your project
|
||||
=============================
|
||||
|
||||
If multiple developers work on a project, you can commit your symfony project and share it with other people.
|
||||
|
||||
When another developer clones your project, they will have to:
|
||||
|
||||
- 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
|
||||
(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 <https://gitlab.com/Chill-Projet/chill-bundles/-/releases>`_ manually
|
||||
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:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
symfony console doctrine:migrations:migrate
|
||||
symfony console chill:db:sync-views
|
||||
|
||||
Operations
|
||||
==========
|
||||
|
||||
Build assets
|
||||
************
|
||||
|
||||
run those commands:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
# 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
|
||||
|
||||
# start the console with all required variables
|
||||
symfony console
|
||||
# you can add your command after that:
|
||||
symfony console list
|
||||
|
||||
How to generate documents
|
||||
*************************
|
||||
|
||||
Documents are generated asynchronously by `"consuming messages" <https://symfony.com/doc/current/messenger.html#consuming-messages-running-the-worker>`_.
|
||||
|
||||
You must generate them using a dedicated process:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
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 emails sent by the program ?
|
||||
*******************************************
|
||||
|
||||
In development, there is a built-in "mail catcher". Open it with :code:`symfony open:local:webmail`
|
||||
|
||||
How to run cron-jobs ?
|
||||
**********************
|
||||
|
||||
Some commands must be executed in :ref:`cron jobs <cronjob>`. To execute them:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
symfony console chill:cron-job:execute
|
||||
|
||||
What about materialized views ?
|
||||
*******************************
|
||||
|
||||
There are some materialized views in chill, to speed up some complex computations in the 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 bundles require the fixtures to be executed. See the dedicated _how-tos_.
|
||||
|
||||
Example, for running a unit test inside `main` bundle:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
# cd into main directory
|
||||
cd vendor/chill-project/chill-bundles
|
||||
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:
|
||||
|
||||
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
|
||||
********************************************************************************************************************************************************************
|
||||
|
||||
Build assets, see above.
|
||||
|
||||
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 it in a private container registry.
|
||||
|
||||
.. warning::
|
||||
|
||||
In production, you **must** set these variables:
|
||||
|
||||
* ``APP_ENV`` to ``prod``
|
||||
* ``APP_DEBUG`` to ``false``
|
||||
|
||||
There are security issues if you keep the same variables as for production.
|
||||
|
||||
|
||||
Going further
|
||||
=============
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
@ -21,386 +367,3 @@ Installation & Usage
|
||||
load-addresses.rst
|
||||
prod-calendar-sms-sending.rst
|
||||
msgraph-configure.rst
|
||||
|
||||
|
||||
Requirements
|
||||
************
|
||||
|
||||
- This project use `docker <https://docker.com>`_ to be run. As a developer, use `docker-compose <https://docs.docker.com/compose/overview/>`_ to bootstrap a dev environment in a glance. You do not need any other dependencies ;
|
||||
- Make is used to automate scripts.
|
||||
|
||||
Installation
|
||||
************
|
||||
|
||||
If you plan to run chill in production:
|
||||
|
||||
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).
|
||||
|
||||
|
||||
1. Get the code
|
||||
===============
|
||||
|
||||
Clone or download the chill-skeleton project and `cd` into the main directory.
|
||||
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
git clone https://gitea.champs-libres.be/Chill-project/chill-skeleton-basic.git
|
||||
cd chill-skeleton-basic
|
||||
|
||||
|
||||
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```
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
cp docker-compose.override.dev.template.yml docker-compose.override.yml
|
||||
|
||||
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 <https://docs.openstack.org/swift/latest/api/temporary_url_middleware.html#secret-keys>`_.
|
||||
|
||||
3. Run the bootstrap script
|
||||
===========================
|
||||
|
||||
This script can be run using `make`
|
||||
|
||||
.. 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 <https://getcomposer.org>`_ ;
|
||||
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 "$@"
|
||||
|
||||
|
||||
.. 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 :
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
docker-compose images php
|
||||
docker rmi -f chill_php82:prod
|
||||
docker-compose rm php
|
||||
|
||||
|
||||
4. Start the project
|
||||
====================
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
docker-compose up
|
||||
|
||||
**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:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
# mount into to container
|
||||
./docker-php.sh
|
||||
bin/console chill:db:sync-views
|
||||
# and load fixtures
|
||||
bin/console doctrine:migrations:migrate
|
||||
|
||||
|
||||
Chill will be available at ``http://localhost:8001.`` Currently, there isn't any user or data. To add fixtures, run
|
||||
|
||||
.. 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
|
||||
|
||||
There are several users available:
|
||||
|
||||
- ``center a_social``
|
||||
- ``center b_social``
|
||||
|
||||
The password is always ``password``.
|
||||
|
||||
Now, read `Operations` below. For running in production, read `prod_`.
|
||||
|
||||
|
||||
Operations
|
||||
**********
|
||||
|
||||
Build assets
|
||||
============
|
||||
|
||||
run those commands:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
make build-assets
|
||||
|
||||
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
|
||||
|
||||
How to create the database schema (= run migrations) ?
|
||||
======================================================
|
||||
|
||||
.. 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
|
||||
|
||||
|
||||
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
|
||||
|
||||
How to run cron-jobs ?
|
||||
======================
|
||||
|
||||
Some command must be executed in :ref:`cron jobs <cronjob>`. 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'
|
||||
|
||||
How to run composer ?
|
||||
=====================
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
# if a container is running
|
||||
./docker-php.sh
|
||||
composer
|
||||
# if not
|
||||
docker-compose run --user $(id -u) php composer
|
||||
|
||||
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 ?
|
||||
==================
|
||||
|
||||
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_.
|
||||
|
||||
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-<my-branch>@dev"
|
||||
}
|
||||
|
||||
2. mount into the php container (``./docker-php.sh``), and run ``composer update``
|
||||
|
||||
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.
|
||||
|
||||
.. warning::
|
||||
|
||||
In production, you **must** set those variables:
|
||||
|
||||
* ``APP_ENV`` to ``prod``
|
||||
* ``APP_DEBUG`` to ``false``
|
||||
|
||||
There are security issues if you keep the same variable than for production.
|
||||
|
||||
|
||||
.. _running-production-tips-and-tricks:
|
||||
|
||||
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).
|
||||
|
||||
|
14
phpstan-baseline-2024-05.neon
Normal file
14
phpstan-baseline-2024-05.neon
Normal file
@ -0,0 +1,14 @@
|
||||
parameters:
|
||||
ignoreErrors:
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$records of method League\\\\Csv\\\\Writer\\:\\:insertAll\\(\\) expects iterable\\<array\\<float\\|int\\|string\\|Stringable\\|null\\>\\>, iterable\\<array\\<string, bool\\|int\\|string\\>\\> given\\.$#"
|
||||
count: 1
|
||||
path: src/Bundle/ChillMainBundle/Controller/UserExportController.php
|
||||
|
||||
-
|
||||
message: """
|
||||
#^Fetching deprecated class constant ASC of class Doctrine\\\\Common\\\\Collections\\\\Criteria\\:
|
||||
use Order\\:\\:Ascending instead$#
|
||||
"""
|
||||
count: 1
|
||||
path: src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflow.php
|
@ -33,3 +33,5 @@ includes:
|
||||
- phpstan-baseline-level-5.neon
|
||||
- phpstan-deprecations-sf54.neon
|
||||
- phpstan-baseline-deprecations-doctrine-orm.neon
|
||||
- phpstan-baseline-2024-05.neon
|
||||
|
||||
|
@ -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],
|
||||
]);
|
||||
}
|
||||
|
||||
|
@ -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) }}
|
||||
<div data-docgen-template-picker="data-docgen-template-picker" data-entity-class="Chill\ActivityBundle\Entity\Activity" data-entity-id="{{ entity.id }}"></div>
|
||||
{% endif %}
|
||||
|
||||
@ -127,4 +129,4 @@
|
||||
|
||||
{% block css %}
|
||||
{{ encore_entry_link_tags('mod_pickentity_type') }}
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\DocStoreBundle\Tests\Security\Guard;
|
||||
|
||||
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||
use Chill\DocStoreBundle\Security\Guard\DavTokenAuthenticationEventSubscriber;
|
||||
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTAuthenticatedEvent;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\AbstractToken;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class DavTokenAuthenticationEventSubscriberTest extends TestCase
|
||||
{
|
||||
public function testOnJWTAuthenticatedWithDavDataInPayload(): void
|
||||
{
|
||||
$eventSubscriber = new DavTokenAuthenticationEventSubscriber();
|
||||
$token = new class () extends AbstractToken {
|
||||
public function getCredentials()
|
||||
{
|
||||
return null;
|
||||
}
|
||||
};
|
||||
$event = new JWTAuthenticatedEvent([
|
||||
'dav' => 1,
|
||||
'so' => '1234',
|
||||
'e' => 1,
|
||||
], $token);
|
||||
|
||||
$eventSubscriber->onJWTAuthenticated($event);
|
||||
|
||||
self::assertTrue($token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT));
|
||||
self::assertTrue($token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS));
|
||||
self::assertEquals('1234', $token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT));
|
||||
self::assertEquals(StoredObjectRoleEnum::EDIT, $token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS));
|
||||
}
|
||||
|
||||
public function testOnJWTAuthenticatedWithDavNoDataInPayload(): void
|
||||
{
|
||||
$eventSubscriber = new DavTokenAuthenticationEventSubscriber();
|
||||
$token = new class () extends AbstractToken {
|
||||
public function getCredentials()
|
||||
{
|
||||
return null;
|
||||
}
|
||||
};
|
||||
$event = new JWTAuthenticatedEvent([], $token);
|
||||
|
||||
$eventSubscriber->onJWTAuthenticated($event);
|
||||
|
||||
self::assertFalse($token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT));
|
||||
self::assertFalse($token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS));
|
||||
}
|
||||
}
|
@ -51,8 +51,6 @@ final class MapAndSubscribeUserCalendarCommand extends Command
|
||||
|
||||
$limit = 50;
|
||||
$offset = 0;
|
||||
/** @var \DateInterval $interval the interval before the end of the expiration */
|
||||
$interval = new \DateInterval('P1D');
|
||||
$expiration = (new \DateTimeImmutable('now'))->add(new \DateInterval($input->getOption('subscription-duration')));
|
||||
$users = $this->userRepository->findAllAsArray('fr');
|
||||
$created = 0;
|
||||
@ -95,7 +93,6 @@ final class MapAndSubscribeUserCalendarCommand extends Command
|
||||
} catch (UserAbsenceSyncException $e) {
|
||||
$this->logger->error('could not sync user absence', ['userId' => $user->getId(), 'email' => $user->getEmail(), 'exception' => $e->getTraceAsString(), 'message' => $e->getMessage()]);
|
||||
$output->writeln(sprintf('Could not sync user absence: id: %s and email: %s', $user->getId(), $user->getEmail()));
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// we first try to renew an existing subscription, if any.
|
||||
|
@ -49,15 +49,12 @@ interface CustomFieldInterface
|
||||
|
||||
/**
|
||||
* Return if the value can be considered as empty.
|
||||
*
|
||||
* @param mixed $value the value passed throug the deserialize function
|
||||
*/
|
||||
public function isEmptyValue(mixed $value, CustomField $customField);
|
||||
|
||||
/**
|
||||
* Return a repsentation of the value of the CustomField.
|
||||
*
|
||||
* @param mixed $value the raw value, **not deserialized** (= as stored in the db)
|
||||
* @param \Chill\CustomFieldsBundle\CustomField\CustomField $customField
|
||||
*
|
||||
* @return string an html representation of the value
|
||||
|
@ -399,8 +399,6 @@ final class CustomFieldsChoiceTest extends KernelTestCase
|
||||
|
||||
/**
|
||||
* @dataProvider emptyDataProvider
|
||||
*
|
||||
* @param mixed $data deserialized data
|
||||
*/
|
||||
public function testIsEmptyValueEmpty(mixed $data)
|
||||
{
|
||||
|
236
src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php
Normal file
236
src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php
Normal file
@ -0,0 +1,236 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\DocStoreBundle\Controller;
|
||||
|
||||
use Chill\DocStoreBundle\Dav\Request\PropfindRequestAnalyzer;
|
||||
use Chill\DocStoreBundle\Dav\Response\DavResponse;
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
|
||||
/**
|
||||
* Provide endpoint for editing a document on the desktop using dav.
|
||||
*
|
||||
* This controller implements the minimal required methods to edit a document on a desktop software (i.e. LibreOffice)
|
||||
* and save the document online.
|
||||
*
|
||||
* To avoid to ask for a password, the endpoints are protected using a JWT access token, which is inside the
|
||||
* URL. This avoid the DAV Client (LibreOffice) to keep an access token in query parameter or in some header (which
|
||||
* they are not able to understand). The JWT Guard is adapted with a dedicated token extractor which is going to read
|
||||
* the segments (separation of "/"): the first segment must be the string "dav", and the second one must be the JWT.
|
||||
*/
|
||||
final readonly class WebdavController
|
||||
{
|
||||
private PropfindRequestAnalyzer $requestAnalyzer;
|
||||
|
||||
public function __construct(
|
||||
private \Twig\Environment $engine,
|
||||
private StoredObjectManagerInterface $storedObjectManager,
|
||||
private Security $security,
|
||||
) {
|
||||
$this->requestAnalyzer = new PropfindRequestAnalyzer();
|
||||
}
|
||||
|
||||
#[Route(path: '/dav/{access_token}/get/{uuid}/', methods: ['GET', 'HEAD'], name: 'chill_docstore_dav_directory_get')]
|
||||
public function getDirectory(StoredObject $storedObject, string $access_token): Response
|
||||
{
|
||||
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
|
||||
throw new AccessDeniedHttpException();
|
||||
}
|
||||
|
||||
return new DavResponse(
|
||||
$this->engine->render('@ChillDocStore/Webdav/directory.html.twig', [
|
||||
'stored_object' => $storedObject,
|
||||
'access_token' => $access_token,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[Route(path: '/dav/{access_token}/get/{uuid}/', methods: ['OPTIONS'])]
|
||||
public function optionsDirectory(StoredObject $storedObject): Response
|
||||
{
|
||||
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
|
||||
throw new AccessDeniedHttpException();
|
||||
}
|
||||
|
||||
$response = (new DavResponse(''))
|
||||
->setEtag($this->storedObjectManager->etag($storedObject))
|
||||
;
|
||||
|
||||
// $response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT,PROPPATCH,COPY,MOVE,REPORT,PATCH,POST,TRACE']);
|
||||
$response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT']);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
#[Route(path: '/dav/{access_token}/get/{uuid}/', methods: ['PROPFIND'])]
|
||||
public function propfindDirectory(StoredObject $storedObject, string $access_token, Request $request): Response
|
||||
{
|
||||
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
|
||||
throw new AccessDeniedHttpException();
|
||||
}
|
||||
|
||||
$depth = $request->headers->get('depth');
|
||||
|
||||
if ('0' !== $depth && '1' !== $depth) {
|
||||
throw new BadRequestHttpException('only 1 and 0 are accepted for Depth header');
|
||||
}
|
||||
|
||||
[$properties, $lastModified, $etag, $length] = $this->parseDavRequest($request->getContent(), $storedObject);
|
||||
|
||||
$response = new DavResponse(
|
||||
$this->engine->render('@ChillDocStore/Webdav/directory_propfind.xml.twig', [
|
||||
'stored_object' => $storedObject,
|
||||
'properties' => $properties,
|
||||
'last_modified' => $lastModified,
|
||||
'etag' => $etag,
|
||||
'content_length' => $length,
|
||||
'depth' => (int) $depth,
|
||||
'access_token' => $access_token,
|
||||
]),
|
||||
207
|
||||
);
|
||||
|
||||
$response->headers->add([
|
||||
'Content-Type' => 'text/xml',
|
||||
]);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
#[Route(path: '/dav/{access_token}/get/{uuid}/d', name: 'chill_docstore_dav_document_get', methods: ['GET'])]
|
||||
public function getDocument(StoredObject $storedObject): Response
|
||||
{
|
||||
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
|
||||
throw new AccessDeniedHttpException();
|
||||
}
|
||||
|
||||
return (new DavResponse($this->storedObjectManager->read($storedObject)))
|
||||
->setEtag($this->storedObjectManager->etag($storedObject));
|
||||
}
|
||||
|
||||
#[Route(path: '/dav/{access_token}/get/{uuid}/d', methods: ['HEAD'])]
|
||||
public function headDocument(StoredObject $storedObject): Response
|
||||
{
|
||||
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
|
||||
throw new AccessDeniedHttpException();
|
||||
}
|
||||
|
||||
$response = new DavResponse('');
|
||||
|
||||
$response->headers->add(
|
||||
[
|
||||
'Content-Length' => $this->storedObjectManager->getContentLength($storedObject),
|
||||
'Content-Type' => $storedObject->getType(),
|
||||
'Etag' => $this->storedObjectManager->etag($storedObject),
|
||||
]
|
||||
);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
#[Route(path: '/dav/{access_token}/get/{uuid}/d', methods: ['OPTIONS'])]
|
||||
public function optionsDocument(StoredObject $storedObject): Response
|
||||
{
|
||||
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
|
||||
throw new AccessDeniedHttpException();
|
||||
}
|
||||
|
||||
$response = (new DavResponse(''))
|
||||
->setEtag($this->storedObjectManager->etag($storedObject))
|
||||
;
|
||||
|
||||
$response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT']);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
#[Route(path: '/dav/{access_token}/get/{uuid}/d', methods: ['PROPFIND'])]
|
||||
public function propfindDocument(StoredObject $storedObject, string $access_token, Request $request): Response
|
||||
{
|
||||
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
|
||||
throw new AccessDeniedHttpException();
|
||||
}
|
||||
|
||||
[$properties, $lastModified, $etag, $length] = $this->parseDavRequest($request->getContent(), $storedObject);
|
||||
|
||||
$response = new DavResponse(
|
||||
$this->engine->render(
|
||||
'@ChillDocStore/Webdav/doc_props.xml.twig',
|
||||
[
|
||||
'stored_object' => $storedObject,
|
||||
'properties' => $properties,
|
||||
'etag' => $etag,
|
||||
'last_modified' => $lastModified,
|
||||
'content_length' => $length,
|
||||
'access_token' => $access_token,
|
||||
]
|
||||
),
|
||||
207
|
||||
);
|
||||
|
||||
$response
|
||||
->headers->add([
|
||||
'Content-Type' => 'text/xml',
|
||||
]);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
#[Route(path: '/dav/{access_token}/get/{uuid}/d', methods: ['PUT'])]
|
||||
public function putDocument(StoredObject $storedObject, Request $request): Response
|
||||
{
|
||||
if (!$this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $storedObject)) {
|
||||
throw new AccessDeniedHttpException();
|
||||
}
|
||||
|
||||
$this->storedObjectManager->write($storedObject, $request->getContent());
|
||||
|
||||
return new DavResponse('', Response::HTTP_NO_CONTENT);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: array, 1: \DateTimeInterface, 2: string, 3: int} properties, lastModified, etag, length
|
||||
*/
|
||||
private function parseDavRequest(string $content, StoredObject $storedObject): array
|
||||
{
|
||||
$xml = new \DOMDocument();
|
||||
$xml->loadXML($content);
|
||||
|
||||
$properties = $this->requestAnalyzer->getRequestedProperties($xml);
|
||||
$requested = array_keys(array_filter($properties, fn ($item) => true === $item));
|
||||
|
||||
if (
|
||||
in_array('lastModified', $requested, true)
|
||||
|| in_array('etag', $requested, true)
|
||||
) {
|
||||
$lastModified = $this->storedObjectManager->getLastModified($storedObject);
|
||||
$etag = $this->storedObjectManager->etag($storedObject);
|
||||
}
|
||||
if (in_array('contentLength', $requested, true)) {
|
||||
$length = $this->storedObjectManager->getContentLength($storedObject);
|
||||
}
|
||||
|
||||
return [
|
||||
$properties,
|
||||
$lastModified ?? null,
|
||||
$etag ?? null,
|
||||
$length ?? null,
|
||||
];
|
||||
}
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\DocStoreBundle\Dav\Exception;
|
||||
|
||||
class ParseRequestException extends \UnexpectedValueException {}
|
@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\DocStoreBundle\Dav\Request;
|
||||
|
||||
use Chill\DocStoreBundle\Dav\Exception\ParseRequestException;
|
||||
|
||||
/**
|
||||
* @phpstan-type davProperties array{resourceType: bool, contentType: bool, lastModified: bool, creationDate: bool, contentLength: bool, etag: bool, supportedLock: bool, unknowns: list<array{xmlns: string, prop: string}>}
|
||||
*/
|
||||
class PropfindRequestAnalyzer
|
||||
{
|
||||
private const KNOWN_PROPS = [
|
||||
'resourceType',
|
||||
'contentType',
|
||||
'lastModified',
|
||||
'creationDate',
|
||||
'contentLength',
|
||||
'etag',
|
||||
'supportedLock',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return davProperties
|
||||
*/
|
||||
public function getRequestedProperties(\DOMDocument $request): array
|
||||
{
|
||||
$propfinds = $request->getElementsByTagNameNS('DAV:', 'propfind');
|
||||
|
||||
if (0 === $propfinds->count()) {
|
||||
throw new ParseRequestException('any propfind element found');
|
||||
}
|
||||
|
||||
if (1 < $propfinds->count()) {
|
||||
throw new ParseRequestException('too much propfind element found');
|
||||
}
|
||||
|
||||
$propfind = $propfinds->item(0);
|
||||
|
||||
if (0 === $propfind->childNodes->count()) {
|
||||
throw new ParseRequestException('no element under propfind');
|
||||
}
|
||||
|
||||
$unknows = [];
|
||||
$props = [];
|
||||
|
||||
foreach ($propfind->childNodes->getIterator() as $prop) {
|
||||
/** @var \DOMNode $prop */
|
||||
if (XML_ELEMENT_NODE !== $prop->nodeType) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ('propname' === $prop->nodeName) {
|
||||
return $this->baseProps(true);
|
||||
}
|
||||
|
||||
foreach ($prop->childNodes->getIterator() as $getProp) {
|
||||
if (XML_ELEMENT_NODE !== $getProp->nodeType) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ('DAV:' !== $getProp->lookupNamespaceURI(null)) {
|
||||
$unknows[] = ['xmlns' => $getProp->lookupNamespaceURI(null), 'prop' => $getProp->nodeName];
|
||||
continue;
|
||||
}
|
||||
|
||||
$props[] = match ($getProp->nodeName) {
|
||||
'resourcetype' => 'resourceType',
|
||||
'getcontenttype' => 'contentType',
|
||||
'getlastmodified' => 'lastModified',
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
$props = array_filter(array_values($props), fn (string $item) => '' !== $item);
|
||||
|
||||
return [...$this->baseProps(false), ...array_combine($props, array_fill(0, count($props), true)), 'unknowns' => $unknows];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return davProperties
|
||||
*/
|
||||
private function baseProps(bool $default = false): array
|
||||
{
|
||||
return
|
||||
[
|
||||
...array_combine(
|
||||
self::KNOWN_PROPS,
|
||||
array_fill(0, count(self::KNOWN_PROPS), $default)
|
||||
),
|
||||
'unknowns' => [],
|
||||
];
|
||||
}
|
||||
}
|
24
src/Bundle/ChillDocStoreBundle/Dav/Response/DavResponse.php
Normal file
24
src/Bundle/ChillDocStoreBundle/Dav/Response/DavResponse.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\DocStoreBundle\Dav\Response;
|
||||
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class DavResponse extends Response
|
||||
{
|
||||
public function __construct($content = '', int $status = 200, array $headers = [])
|
||||
{
|
||||
parent::__construct($content, $status, $headers);
|
||||
|
||||
$this->headers->add(['DAV' => '1']);
|
||||
}
|
||||
}
|
@ -39,15 +39,15 @@ class StoredObject implements Document, TrackCreationInterface
|
||||
final public const STATUS_PENDING = 'pending';
|
||||
final public const STATUS_FAILURE = 'failure';
|
||||
|
||||
#[Serializer\Groups(['read', 'write'])]
|
||||
#[Serializer\Groups(['write'])]
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'datas')]
|
||||
private array $datas = [];
|
||||
|
||||
#[Serializer\Groups(['read', 'write'])]
|
||||
#[Serializer\Groups(['write'])]
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT)]
|
||||
private string $filename = '';
|
||||
|
||||
#[Serializer\Groups(['read', 'write'])]
|
||||
#[Serializer\Groups(['write'])]
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)]
|
||||
@ -56,23 +56,23 @@ class StoredObject implements Document, TrackCreationInterface
|
||||
/**
|
||||
* @var int[]
|
||||
*/
|
||||
#[Serializer\Groups(['read', 'write'])]
|
||||
#[Serializer\Groups(['write'])]
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'iv')]
|
||||
private array $iv = [];
|
||||
|
||||
#[Serializer\Groups(['read', 'write'])]
|
||||
#[Serializer\Groups(['write'])]
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'key')]
|
||||
private array $keyInfos = [];
|
||||
|
||||
#[Serializer\Groups(['read', 'write'])]
|
||||
#[Serializer\Groups(['write'])]
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, name: 'title')]
|
||||
private string $title = '';
|
||||
|
||||
#[Serializer\Groups(['read', 'write'])]
|
||||
#[Serializer\Groups(['write'])]
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, name: 'type', options: ['default' => ''])]
|
||||
private string $type = '';
|
||||
|
||||
#[Serializer\Groups(['read', 'write'])]
|
||||
#[Serializer\Groups(['write'])]
|
||||
#[ORM\Column(type: 'uuid', unique: true)]
|
||||
private UuidInterface $uuid;
|
||||
|
||||
@ -98,7 +98,7 @@ class StoredObject implements Document, TrackCreationInterface
|
||||
* @param StoredObject::STATUS_* $status
|
||||
*/
|
||||
public function __construct(
|
||||
#[Serializer\Groups(['read'])] #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, options: ['default' => 'ready'])]
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, options: ['default' => 'ready'])]
|
||||
private string $status = 'ready'
|
||||
) {
|
||||
$this->uuid = Uuid::uuid4();
|
||||
@ -114,7 +114,7 @@ class StoredObject implements Document, TrackCreationInterface
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
#[Serializer\Groups(['read', 'write'])]
|
||||
#[Serializer\Groups(['write'])]
|
||||
public function getCreationDate(): \DateTime
|
||||
{
|
||||
if (null === $this->createdAt) {
|
||||
|
@ -24,7 +24,7 @@ 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
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TranslatableStringHelperInterface $translatableStringHelper
|
||||
|
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\DocStoreBundle\Form;
|
||||
|
||||
use Chill\MainBundle\Form\Type\ChillCollectionType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class CollectionStoredObjectType extends AbstractType
|
||||
{
|
||||
public function configureOptions(OptionsResolver $resolver)
|
||||
{
|
||||
$resolver
|
||||
->setDefault('entry_type', StoredObjectType::class)
|
||||
->setDefault('allow_add', true)
|
||||
->setDefault('allow_delete', true)
|
||||
->setDefault('button_add_label', 'stored_object.Insert a document')
|
||||
->setDefault('button_remove_label', 'stored_object.Remove a document')
|
||||
->setDefault('empty_collection_explain', 'No documents')
|
||||
->setDefault('entry_options', ['has_title' => true])
|
||||
->setDefault('js_caller', 'data-collection-stored-object');
|
||||
}
|
||||
|
||||
public function getParent()
|
||||
{
|
||||
return ChillCollectionType::class;
|
||||
}
|
||||
}
|
@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\DocStoreBundle\Form\DataMapper;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Symfony\Component\Form\DataMapperInterface;
|
||||
use Symfony\Component\Form\Exception;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
|
||||
class StoredObjectDataMapper implements DataMapperInterface
|
||||
{
|
||||
public function __construct() {}
|
||||
|
||||
/**
|
||||
* @param FormInterface[]|\Traversable $forms A list of {@link FormInterface} instances
|
||||
*/
|
||||
public function mapDataToForms($viewData, \Traversable $forms)
|
||||
{
|
||||
if (null === $viewData) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$viewData instanceof StoredObject) {
|
||||
throw new Exception\UnexpectedTypeException($viewData, StoredObject::class);
|
||||
}
|
||||
|
||||
$forms = iterator_to_array($forms);
|
||||
if (array_key_exists('title', $forms)) {
|
||||
$forms['title']->setData($viewData->getTitle());
|
||||
}
|
||||
$forms['stored_object']->setData($viewData);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param FormInterface[]|\Traversable $forms A list of {@link FormInterface} instances
|
||||
*/
|
||||
public function mapFormsToData(\Traversable $forms, &$viewData)
|
||||
{
|
||||
$forms = iterator_to_array($forms);
|
||||
|
||||
if (!(null === $viewData || $viewData instanceof StoredObject)) {
|
||||
throw new Exception\UnexpectedTypeException($viewData, StoredObject::class);
|
||||
}
|
||||
|
||||
if (null === $forms['stored_object']->getData()) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var StoredObject $viewData */
|
||||
if ($viewData->getFilename() !== $forms['stored_object']->getData()['filename']) {
|
||||
// we do not want to erase the previous object
|
||||
$viewData = new StoredObject();
|
||||
}
|
||||
|
||||
$viewData->setFilename($forms['stored_object']->getData()['filename']);
|
||||
$viewData->setIv($forms['stored_object']->getData()['iv']);
|
||||
$viewData->setKeyInfos($forms['stored_object']->getData()['keyInfos']);
|
||||
$viewData->setType($forms['stored_object']->getData()['type']);
|
||||
|
||||
if (array_key_exists('title', $forms)) {
|
||||
$viewData->setTitle($forms['title']->getData());
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\DocStoreBundle\Form\DataTransformer;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectNormalizer;
|
||||
use Symfony\Component\Form\DataTransformerInterface;
|
||||
use Symfony\Component\Form\Exception\UnexpectedTypeException;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
|
||||
class StoredObjectDataTransformer implements DataTransformerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SerializerInterface $serializer
|
||||
) {}
|
||||
|
||||
public function transform(mixed $value): mixed
|
||||
{
|
||||
if (null === $value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($value instanceof StoredObject) {
|
||||
return $this->serializer->serialize($value, 'json', [
|
||||
'groups' => [
|
||||
StoredObjectNormalizer::ADD_DAV_EDIT_LINK_CONTEXT,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
throw new UnexpectedTypeException($value, StoredObject::class);
|
||||
}
|
||||
|
||||
public function reverseTransform(mixed $value): mixed
|
||||
{
|
||||
if ('' === $value || null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return json_decode((string) $value, true, 10, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
@ -11,11 +11,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace Chill\DocStoreBundle\Form;
|
||||
|
||||
use Chill\DocStoreBundle\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,9 +23,12 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
/**
|
||||
* Form type which allow to join a document.
|
||||
*/
|
||||
class StoredObjectType extends AbstractType
|
||||
final class StoredObjectType extends AbstractType
|
||||
{
|
||||
public function __construct(private readonly EntityManagerInterface $em) {}
|
||||
public function __construct(
|
||||
private readonly StoredObjectDataTransformer $storedObjectDataTransformer,
|
||||
private readonly StoredObjectDataMapper $storedObjectDataMapper,
|
||||
) {}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options)
|
||||
{
|
||||
@ -37,30 +39,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)
|
||||
@ -72,43 +53,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;
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,86 @@
|
||||
import {CollectionEventPayload} from "../../../../../ChillMainBundle/Resources/public/module/collection";
|
||||
import {createApp} from "vue";
|
||||
import DropFileWidget from "../../vuejs/DropFileWidget/DropFileWidget.vue"
|
||||
import {StoredObject, StoredObjectCreated} from "../../types";
|
||||
import {_createI18n} from "../../../../../ChillMainBundle/Resources/public/vuejs/_js/i18n";
|
||||
const i18n = _createI18n({});
|
||||
|
||||
const startApp = (divElement: HTMLDivElement, collectionEntry: null|HTMLLIElement): void => {
|
||||
console.log('app started', divElement);
|
||||
const input_stored_object: HTMLInputElement|null = divElement.querySelector("input[data-stored-object]");
|
||||
if (null === input_stored_object) {
|
||||
throw new Error('input to stored object not found');
|
||||
}
|
||||
|
||||
let existingDoc: StoredObject|null = null;
|
||||
if (input_stored_object.value !== "") {
|
||||
existingDoc = JSON.parse(input_stored_object.value);
|
||||
}
|
||||
const app_container = document.createElement("div");
|
||||
divElement.appendChild(app_container);
|
||||
|
||||
const app = createApp({
|
||||
template: '<drop-file-widget :existingDoc="this.$data.existingDoc" :allowRemove="true" @addDocument="this.addDocument" @removeDocument="removeDocument"></drop-file-widget>',
|
||||
data(vm) {
|
||||
return {
|
||||
existingDoc: existingDoc,
|
||||
}
|
||||
},
|
||||
components: {
|
||||
DropFileWidget,
|
||||
},
|
||||
methods: {
|
||||
addDocument: function(object: StoredObjectCreated): void {
|
||||
console.log('object added', object);
|
||||
this.$data.existingDoc = object;
|
||||
input_stored_object.value = JSON.stringify(object);
|
||||
},
|
||||
removeDocument: function(object: StoredObject): void {
|
||||
console.log('catch remove document', object);
|
||||
input_stored_object.value = "";
|
||||
this.$data.existingDoc = null;
|
||||
console.log('collectionEntry', collectionEntry);
|
||||
|
||||
if (null !== collectionEntry) {
|
||||
console.log('will remove collection');
|
||||
collectionEntry.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.use(i18n).mount(app_container);
|
||||
}
|
||||
window.addEventListener('collection-add-entry', ((e: CustomEvent<CollectionEventPayload>) => {
|
||||
const detail = e.detail;
|
||||
const divElement: null|HTMLDivElement = detail.entry.querySelector('div[data-stored-object]');
|
||||
|
||||
if (null === divElement) {
|
||||
throw new Error('div[data-stored-object] not found');
|
||||
}
|
||||
|
||||
startApp(divElement, detail.entry);
|
||||
}) as EventListener);
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
const upload_inputs: NodeListOf<HTMLDivElement> = document.querySelectorAll('div[data-stored-object]');
|
||||
|
||||
upload_inputs.forEach((input: HTMLDivElement): void => {
|
||||
// test for a parent to check if this is a collection entry
|
||||
let collectionEntry: null|HTMLLIElement = null;
|
||||
let parent = input.parentElement;
|
||||
console.log('parent', parent);
|
||||
if (null !== parent) {
|
||||
let grandParent = parent.parentElement;
|
||||
console.log('grandParent', grandParent);
|
||||
if (null !== grandParent) {
|
||||
if (grandParent.tagName.toLowerCase() === 'li' && grandParent.classList.contains('entry')) {
|
||||
collectionEntry = grandParent as HTMLLIElement;
|
||||
}
|
||||
}
|
||||
}
|
||||
startApp(input, collectionEntry);
|
||||
})
|
||||
});
|
||||
|
||||
export {}
|
@ -17,18 +17,22 @@ window.addEventListener('DOMContentLoaded', function (e) {
|
||||
canEdit: string,
|
||||
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: '<document-action-buttons-group :can-edit="canEdit" :filename="filename" :stored-object="storedObject" :small="small" @on-stored-object-status-change="onStoredObjectStatusChange"></document-action-buttons-group>',
|
||||
template: '<document-action-buttons-group :can-edit="canEdit" :filename="filename" :stored-object="storedObject" :small="small" :dav-link="davLink" :dav-link-expiration="davLinkExpiration" @on-stored-object-status-change="onStoredObjectStatusChange"></document-action-buttons-group>',
|
||||
methods: {
|
||||
onStoredObjectStatusChange: function(newStatus: StoredObjectStatusChange): void {
|
||||
this.$data.storedObject.status = newStatus.status;
|
||||
|
@ -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<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Object containing information for performering a POST request to a swift object store
|
||||
*/
|
||||
export interface PostStoreObjectSignature {
|
||||
method: "POST",
|
||||
max_file_size: number,
|
||||
max_file_count: 1,
|
||||
expires: number,
|
||||
submit_delay: 180,
|
||||
redirect: string,
|
||||
prefix: string,
|
||||
url: string,
|
||||
signature: string,
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div v-if="'ready' === props.storedObject.status" class="btn-group">
|
||||
<div v-if="'ready' === props.storedObject.status || 'stored_object_created' === props.storedObject.status" class="btn-group">
|
||||
<button :class="Object.assign({'btn': true, 'btn-outline-primary': true, 'dropdown-toggle': true, 'btn-sm': props.small})" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Actions
|
||||
</button>
|
||||
@ -7,6 +7,9 @@
|
||||
<li v-if="props.canEdit && is_extension_editable(props.storedObject.type)">
|
||||
<wopi-edit-button :stored-object="props.storedObject" :classes="{'dropdown-item': true}" :execute-before-leave="props.executeBeforeLeave"></wopi-edit-button>
|
||||
</li>
|
||||
<li v-if="props.canEdit && is_extension_editable(props.storedObject.type) && props.davLink !== undefined && props.davLinkExpiration !== undefined">
|
||||
<desktop-edit-button :classes="{'dropdown-item': true}" :edit-link="props.davLink" :expiration-link="props.davLinkExpiration"></desktop-edit-button>
|
||||
</li>
|
||||
<li v-if="props.storedObject.type != 'application/pdf' && is_extension_viewable(props.storedObject.type) && props.canConvertPdf">
|
||||
<convert-button :stored-object="props.storedObject" :filename="filename" :classes="{'dropdown-item': true}"></convert-button>
|
||||
</li>
|
||||
@ -32,13 +35,14 @@ import DownloadButton from "./StoredObjectButton/DownloadButton.vue";
|
||||
import WopiEditButton from "./StoredObjectButton/WopiEditButton.vue";
|
||||
import {is_extension_editable, is_extension_viewable, is_object_ready} from "./StoredObjectButton/helpers";
|
||||
import {
|
||||
StoredObject,
|
||||
StoredObjectStatusChange,
|
||||
WopiEditButtonExecutableBeforeLeaveFunction
|
||||
StoredObject, StoredObjectCreated,
|
||||
StoredObjectStatusChange,
|
||||
WopiEditButtonExecutableBeforeLeaveFunction
|
||||
} from "../types";
|
||||
import DesktopEditButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DesktopEditButton.vue";
|
||||
|
||||
interface DocumentActionButtonsGroupConfig {
|
||||
storedObject: StoredObject,
|
||||
storedObject: StoredObject|StoredObjectCreated,
|
||||
small?: boolean,
|
||||
canEdit?: boolean,
|
||||
canDownload?: boolean,
|
||||
@ -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<DocumentActionButtonsGroupConfig>(), {
|
||||
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
|
||||
});
|
||||
|
||||
/**
|
||||
@ -85,6 +99,7 @@ const checkForReady = function(): void {
|
||||
if (
|
||||
'ready' === props.storedObject.status
|
||||
|| 'failure' === props.storedObject.status
|
||||
|| 'stored_object_created' === props.storedObject.status
|
||||
// stop reloading if the page stays opened for a long time
|
||||
|| tryiesForReady > maxTryiesForReady
|
||||
) {
|
||||
@ -97,6 +112,11 @@ const checkForReady = function(): void {
|
||||
};
|
||||
|
||||
const onObjectNewStatusCallback = async function(): Promise<void> {
|
||||
|
||||
if (props.storedObject.status === 'stored_object_created') {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const new_status = await is_object_ready(props.storedObject);
|
||||
if (props.storedObject.status !== new_status.status) {
|
||||
emit('onStoredObjectStatusChange', new_status);
|
||||
|
@ -0,0 +1,155 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import {StoredObject, StoredObjectCreated} from "../../types";
|
||||
import {encryptFile, uploadFile} from "../_components/helper";
|
||||
import {computed, ref, Ref} from "vue";
|
||||
|
||||
interface DropFileConfig {
|
||||
existingDoc?: StoredObjectCreated|StoredObject,
|
||||
}
|
||||
|
||||
const props = defineProps<DropFileConfig>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'addDocument', stored_object: StoredObjectCreated): void,
|
||||
}>();
|
||||
|
||||
const is_dragging: Ref<boolean> = ref(false);
|
||||
const uploading: Ref<boolean> = ref(false);
|
||||
|
||||
const has_existing_doc = computed<boolean>(() => {
|
||||
return props.existingDoc !== undefined && props.existingDoc !== null;
|
||||
});
|
||||
|
||||
const onDragOver = (e: Event) => {
|
||||
e.preventDefault();
|
||||
|
||||
is_dragging.value = true;
|
||||
}
|
||||
|
||||
const onDragLeave = (e: Event) => {
|
||||
e.preventDefault();
|
||||
|
||||
is_dragging.value = false;
|
||||
}
|
||||
|
||||
const onDrop = (e: DragEvent) => {
|
||||
console.log('on drop', e);
|
||||
e.preventDefault();
|
||||
|
||||
const files = e.dataTransfer?.files;
|
||||
|
||||
if (null === files || undefined === files) {
|
||||
console.error("no files transferred", e.dataTransfer);
|
||||
return;
|
||||
}
|
||||
if (files.length === 0) {
|
||||
console.error("no files given");
|
||||
return;
|
||||
}
|
||||
|
||||
handleFile(files[0])
|
||||
}
|
||||
|
||||
const onZoneClick = (e: Event) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.addEventListener("change", onFileChange);
|
||||
|
||||
input.click();
|
||||
}
|
||||
|
||||
const onFileChange = async (event: Event): Promise<void> => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
console.log('event triggered', input);
|
||||
|
||||
if (input.files && input.files[0]) {
|
||||
console.log('file added', input.files[0]);
|
||||
const file = input.files[0];
|
||||
await handleFile(file);
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
throw 'No file given';
|
||||
}
|
||||
|
||||
const handleFile = async (file: File): Promise<void> => {
|
||||
uploading.value = true;
|
||||
const type = file.type;
|
||||
const buffer = await file.arrayBuffer();
|
||||
const [encrypted, iv, jsonWebKey] = await encryptFile(buffer);
|
||||
const filename = await uploadFile(encrypted);
|
||||
|
||||
console.log(iv, jsonWebKey);
|
||||
|
||||
const storedObject: StoredObjectCreated = {
|
||||
filename: filename,
|
||||
iv,
|
||||
keyInfos: jsonWebKey,
|
||||
type: type,
|
||||
status: "stored_object_created",
|
||||
}
|
||||
|
||||
emit('addDocument', storedObject);
|
||||
uploading.value = false;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="drop-file">
|
||||
<div v-if="!uploading" :class="{ area: true, dragging: is_dragging}" @click="onZoneClick" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
|
||||
<p v-if="has_existing_doc">
|
||||
<i class="fa fa-file-pdf-o" v-if="props.existingDoc?.type === 'application/pdf'"></i>
|
||||
<i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/vnd.oasis.opendocument.text'"></i>
|
||||
<i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'"></i>
|
||||
<i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/msword'"></i>
|
||||
<i class="fa fa-file-excel-o" v-else-if="props.existingDoc?.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'"></i>
|
||||
<i class="fa fa-file-excel-o" v-else-if="props.existingDoc?.type === 'application/vnd.ms-excel'"></i>
|
||||
<i class="fa fa-file-image-o" v-else-if="props.existingDoc?.type === 'image/jpeg'"></i>
|
||||
<i class="fa fa-file-image-o" v-else-if="props.existingDoc?.type === 'image/png'"></i>
|
||||
<i class="fa fa-file-archive-o" v-else-if="props.existingDoc?.type === 'application/x-zip-compressed'"></i>
|
||||
<i class="fa fa-file-code-o" v-else ></i>
|
||||
</p>
|
||||
<!-- todo i18n -->
|
||||
<p v-if="has_existing_doc">Déposez un document ou cliquez ici pour remplacer le document existant</p>
|
||||
<p v-else>Déposez un document ou cliquez ici pour ouvrir le navigateur de fichier</p>
|
||||
</div>
|
||||
<div v-else class="waiting">
|
||||
<i class="fa fa-cog fa-spin fa-3x fa-fw"></i>
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.drop-file {
|
||||
width: 100%;
|
||||
|
||||
& > .area, & > .waiting {
|
||||
width: 100%;
|
||||
height: 8rem;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
& > .area {
|
||||
border: 4px dashed #ccc;
|
||||
|
||||
&.dragging {
|
||||
border: 4px dashed blue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div.chill-collection ul.list-entry li.entry:nth-child(2n) {
|
||||
|
||||
}
|
||||
</style>
|
@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import {StoredObject, StoredObjectCreated} from "../../types";
|
||||
import {computed, ref, Ref} from "vue";
|
||||
import DropFile from "ChillDocStoreAssets/vuejs/DropFileWidget/DropFile.vue";
|
||||
import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue";
|
||||
|
||||
interface DropFileConfig {
|
||||
allowRemove: boolean,
|
||||
existingDoc?: StoredObjectCreated|StoredObject,
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<DropFileConfig>(), {
|
||||
allowRemove: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'addDocument', stored_object: StoredObjectCreated): void,
|
||||
(e: 'removeDocument', stored_object: null): void
|
||||
}>();
|
||||
|
||||
const has_existing_doc = computed<boolean>(() => {
|
||||
return props.existingDoc !== undefined && props.existingDoc !== null;
|
||||
});
|
||||
|
||||
const dav_link_expiration = computed<number|undefined>(() => {
|
||||
if (props.existingDoc === undefined || props.existingDoc === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (props.existingDoc.status !== 'ready') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return props.existingDoc._links?.dav_link?.expiration;
|
||||
});
|
||||
|
||||
const dav_link_href = computed<string|undefined>(() => {
|
||||
if (props.existingDoc === undefined || props.existingDoc === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (props.existingDoc.status !== 'ready') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return props.existingDoc._links?.dav_link?.href;
|
||||
})
|
||||
|
||||
const onAddDocument = (s: StoredObjectCreated): void => {
|
||||
emit('addDocument', s);
|
||||
}
|
||||
|
||||
const onRemoveDocument = (e: Event): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
emit('removeDocument', null);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<drop-file :existingDoc="props.existingDoc" @addDocument="onAddDocument"></drop-file>
|
||||
|
||||
<ul class="record_actions">
|
||||
<li v-if="has_existing_doc">
|
||||
<document-action-buttons-group
|
||||
:stored-object="props.existingDoc"
|
||||
:can-edit="props.existingDoc?.status === 'ready'"
|
||||
:can-download="true"
|
||||
:dav-link="dav_link_href"
|
||||
:dav-link-expiration="dav_link_expiration"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<button v-if="allowRemove" class="btn btn-delete" @click="onRemoveDocument($event)" ></button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
@ -10,10 +10,10 @@
|
||||
import {build_convert_link, download_and_decrypt_doc, download_doc} from "./helpers";
|
||||
import mime from "mime";
|
||||
import {reactive} from "vue";
|
||||
import {StoredObject} from "../../types";
|
||||
import {StoredObject, StoredObjectCreated} from "../../types";
|
||||
|
||||
interface ConvertButtonConfig {
|
||||
storedObject: StoredObject,
|
||||
storedObject: StoredObject|StoredObjectCreated,
|
||||
classes: { [key: string]: boolean},
|
||||
filename?: string,
|
||||
};
|
||||
|
@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
|
||||
import {computed, reactive} from "vue";
|
||||
|
||||
export interface DesktopEditButtonConfig {
|
||||
editLink: null,
|
||||
classes: { [k: string]: boolean },
|
||||
expirationLink: number|Date,
|
||||
}
|
||||
|
||||
interface DesktopEditButtonState {
|
||||
modalOpened: boolean
|
||||
};
|
||||
|
||||
const state: DesktopEditButtonState = reactive({modalOpened: false});
|
||||
|
||||
const props = defineProps<DesktopEditButtonConfig>();
|
||||
|
||||
const buildCommand = computed<string>(() => 'vnd.libreoffice.command:ofe|u|' + props.editLink);
|
||||
|
||||
const editionUntilFormatted = computed<string>(() => {
|
||||
let d;
|
||||
|
||||
if (props.expirationLink instanceof Date) {
|
||||
d = props.expirationLink;
|
||||
} else {
|
||||
d = new Date(props.expirationLink * 1000);
|
||||
}
|
||||
console.log(props.expirationLink);
|
||||
|
||||
return (new Intl.DateTimeFormat(undefined, {'dateStyle': 'long', 'timeStyle': 'medium'})).format(d);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<modal v-if="state.modalOpened" @close="state.modalOpened=false">
|
||||
<template v-slot:body>
|
||||
<div class="desktop-edit">
|
||||
<p class="center">Veuillez enregistrer vos modifications avant le</p>
|
||||
<p><strong>{{ editionUntilFormatted }}</strong></p>
|
||||
|
||||
<p><a class="btn btn-primary" :href="buildCommand">Ouvrir le document pour édition</a></p>
|
||||
|
||||
<p><small>Le document peut être édité uniquement en utilisant Libre Office.</small></p>
|
||||
|
||||
<p><small>En cas d'échec lors de l'enregistrement, sauver le document sur le poste de travail avant de le déposer à nouveau ici.</small></p>
|
||||
|
||||
<p><small>Vous pouvez naviguez sur d'autres pages pendant l'édition.</small></p>
|
||||
</div>
|
||||
</template>
|
||||
</modal>
|
||||
</teleport>
|
||||
<a :class="props.classes" @click="state.modalOpened = true">
|
||||
<i class="fa fa-desktop"></i>
|
||||
Éditer sur le bureau
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.desktop-edit {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
@ -13,10 +13,10 @@
|
||||
import {reactive, ref, nextTick, onMounted} from "vue";
|
||||
import {build_download_info_link, download_and_decrypt_doc} from "./helpers";
|
||||
import mime from "mime";
|
||||
import {StoredObject} from "../../types";
|
||||
import {StoredObject, StoredObjectCreated} from "../../types";
|
||||
|
||||
interface DownloadButtonConfig {
|
||||
storedObject: StoredObject,
|
||||
storedObject: StoredObject|StoredObjectCreated,
|
||||
classes: { [k: string]: boolean },
|
||||
filename?: string,
|
||||
}
|
||||
|
@ -8,10 +8,10 @@
|
||||
<script lang="ts" setup>
|
||||
import WopiEditButton from "./WopiEditButton.vue";
|
||||
import {build_wopi_editor_link} from "./helpers";
|
||||
import {StoredObject, WopiEditButtonExecutableBeforeLeaveFunction} from "../../types";
|
||||
import {StoredObject, StoredObjectCreated, WopiEditButtonExecutableBeforeLeaveFunction} from "../../types";
|
||||
|
||||
interface WopiEditButtonConfig {
|
||||
storedObject: StoredObject,
|
||||
storedObject: StoredObject|StoredObjectCreated,
|
||||
returnPath?: string,
|
||||
classes: {[k: string] : boolean},
|
||||
executeBeforeLeave?: WopiEditButtonExecutableBeforeLeaveFunction,
|
||||
|
@ -0,0 +1,60 @@
|
||||
import {makeFetch} from "../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods";
|
||||
import {PostStoreObjectSignature} from "../../types";
|
||||
|
||||
const algo = 'AES-CBC';
|
||||
|
||||
const URL_POST = '/asyncupload/temp_url/generate/post';
|
||||
|
||||
const keyDefinition = {
|
||||
name: algo,
|
||||
length: 256
|
||||
};
|
||||
|
||||
const createFilename = (): string => {
|
||||
var text = "";
|
||||
var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
text += possible.charAt(Math.floor(Math.random() * possible.length));
|
||||
}
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
export const uploadFile = async (uploadFile: ArrayBuffer): Promise<string> => {
|
||||
const params = new URLSearchParams();
|
||||
params.append('expires_delay', "180");
|
||||
params.append('submit_delay', "180");
|
||||
const asyncData: PostStoreObjectSignature = await makeFetch("GET", URL_POST + "?" + params.toString());
|
||||
const suffix = createFilename();
|
||||
const filename = asyncData.prefix + suffix;
|
||||
const formData = new FormData();
|
||||
formData.append("redirect", asyncData.redirect);
|
||||
formData.append("max_file_size", asyncData.max_file_size.toString());
|
||||
formData.append("max_file_count", asyncData.max_file_count.toString());
|
||||
formData.append("expires", asyncData.expires.toString());
|
||||
formData.append("signature", asyncData.signature);
|
||||
formData.append(filename, new Blob([uploadFile]), suffix);
|
||||
|
||||
const response = await window.fetch(asyncData.url, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("Error while sending file to store", response);
|
||||
throw new Error(response.statusText);
|
||||
}
|
||||
|
||||
return Promise.resolve(filename);
|
||||
}
|
||||
|
||||
export const encryptFile = async (originalFile: ArrayBuffer): Promise<[ArrayBuffer, Uint8Array, JsonWebKey]> => {
|
||||
console.log('encrypt', originalFile);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(16));
|
||||
const key = await window.crypto.subtle.generateKey(keyDefinition, true, [ "encrypt", "decrypt" ]);
|
||||
const exportedKey = await window.crypto.subtle.exportKey('jwk', key);
|
||||
const encrypted = await window.crypto.subtle.encrypt({ name: algo, iv: iv}, key, originalFile);
|
||||
|
||||
return Promise.resolve([encrypted, iv, exportedKey]);
|
||||
};
|
@ -3,5 +3,7 @@
|
||||
data-download-buttons
|
||||
data-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 %}></div>
|
||||
|
@ -1,23 +1,7 @@
|
||||
{% block stored_object_widget %}
|
||||
{% if form.title is defined %} {{ form_row(form.title) }} {% endif %}
|
||||
<div
|
||||
data-stored-object="data-stored-object"
|
||||
data-label-preparing="{{ ('Preparing'|trans ~ '...')|escape('html_attr') }}"
|
||||
data-label-quiet-button="{{ 'Download existing file'|trans|escape('html_attr') }}"
|
||||
data-label-ready="{{ 'Ready to show'|trans|escape('html_attr') }}"
|
||||
data-dict-file-too-big="{{ 'File too big'|trans|escape('html_attr') }}"
|
||||
data-dict-default-message="{{ "Drop your file or click here"|trans|escape('html_attr') }}"
|
||||
data-dict-remove-file="{{ 'Remove file in order to upload a new one'|trans|escape('html_attr') }}"
|
||||
data-dict-max-files-exceeded="{{ 'Max files exceeded. Remove previous files'|trans|escape('html_attr') }}"
|
||||
data-dict-cancel-upload="{{ 'Cancel upload'|trans|escape('html_attr') }}"
|
||||
data-dict-cancel-upload-confirm="{{ 'Are you sure you want to cancel this upload ?'|trans|escape('html_attr') }}"
|
||||
data-dict-upload-canceled="{{ 'Upload canceled'|trans|escape('html_attr') }}"
|
||||
data-dict-remove="{{ 'Remove existing file'|trans|escape('html_attr') }}"
|
||||
data-allow-remove="{% if required %}false{% else %}true{% endif %}"
|
||||
data-temp-url-generator="{{ path('async_upload.generate_url', { 'method': 'GET' })|escape('html_attr') }}">
|
||||
{{ form_widget(form.filename) }}
|
||||
{{ form_widget(form.keyInfos, { 'attr': { 'data-stored-object-key': 1 } }) }}
|
||||
{{ form_widget(form.iv, { 'attr': { 'data-stored-object-iv': 1 } }) }}
|
||||
{{ form_widget(form.type, { 'attr': { 'data-async-file-type': 1 } }) }}
|
||||
data-stored-object="data-stored-object">
|
||||
{{ form_widget(form.stored_object, { 'attr': { 'data-stored-object': 1 } }) }}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -1,54 +1,62 @@
|
||||
{% extends "@ChillPerson/AccompanyingCourse/layout.html.twig" %}
|
||||
{% extends "@ChillPerson/AccompanyingCourse/layout.html.twig" %} {% set
|
||||
activeRouteKey = '' %} {% block title %}
|
||||
{{ "Documents" }}
|
||||
{% endblock %} {% block js %}
|
||||
{{ parent() }}
|
||||
{{ encore_entry_script_tags("mod_docgen_picktemplate") }}
|
||||
{{ encore_entry_script_tags("mod_entity_workflow_pick") }}
|
||||
{{ encore_entry_script_tags("mod_document_action_buttons_group") }}
|
||||
{% endblock %} {% block css %}
|
||||
{{ parent() }}
|
||||
{{ encore_entry_script_tags("mod_docgen_picktemplate") }}
|
||||
{{ encore_entry_link_tags("mod_entity_workflow_pick") }}
|
||||
{{ encore_entry_link_tags("mod_document_action_buttons_group") }}
|
||||
{% endblock %} {% block content %}
|
||||
<div class="document-list">
|
||||
<h1>{{ "Documents" }}</h1>
|
||||
|
||||
{% set activeRouteKey = '' %}
|
||||
|
||||
{% block title %}
|
||||
{{ 'Documents' }}
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
{{ parent() }}
|
||||
{{ encore_entry_script_tags('mod_docgen_picktemplate') }}
|
||||
{{ encore_entry_script_tags('mod_entity_workflow_pick') }}
|
||||
{{ encore_entry_script_tags('mod_document_action_buttons_group') }}
|
||||
{% endblock %}
|
||||
|
||||
{% block css %}
|
||||
{{ parent() }}
|
||||
{{ encore_entry_script_tags('mod_docgen_picktemplate') }}
|
||||
{{ encore_entry_link_tags('mod_entity_workflow_pick') }}
|
||||
{{ encore_entry_link_tags('mod_document_action_buttons_group') }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="document-list">
|
||||
<h1>{{ 'Documents' }}</h1>
|
||||
|
||||
{{ filter|chill_render_filter_order_helper }}
|
||||
|
||||
{% if documents|length == 0 %}
|
||||
<p class="chill-no-data-statement">{{ 'No documents'|trans }}</p>
|
||||
{% else %}
|
||||
<div class="flex-table chill-task-list">
|
||||
{% for document in documents %}
|
||||
{{ document|chill_generic_doc_render }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{{ chill_pagination(pagination) }}
|
||||
|
||||
<div data-docgen-template-picker="data-docgen-template-picker" data-entity-class="Chill\PersonBundle\Entity\AccompanyingPeriod" data-entity-id="{{ accompanyingCourse.id }}"></div>
|
||||
|
||||
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_CREATE', accompanyingCourse) %}
|
||||
<ul class="record_actions sticky-form-buttons">
|
||||
<li class="create">
|
||||
<a href="{{ path('accompanying_course_document_new', {'course': accompanyingCourse.id}) }}" class="btn btn-create">
|
||||
{{ 'Create'|trans }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
{{ filter | chill_render_filter_order_helper }}
|
||||
|
||||
{% if documents|length > 5 %}
|
||||
<div
|
||||
data-docgen-template-picker="data-docgen-template-picker"
|
||||
data-entity-class="Chill\PersonBundle\Entity\AccompanyingPeriod"
|
||||
data-entity-id="{{ accompanyingCourse.id }}"
|
||||
></div>
|
||||
{% endif %} {% if documents|length == 0 %}
|
||||
<p class="chill-no-data-statement">{{ "No documents" | trans }}</p>
|
||||
{% else %}
|
||||
<div class="flex-table chill-task-list">
|
||||
{% for document in documents %}
|
||||
{{ document | chill_generic_doc_render }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{{ chill_pagination(pagination) }}
|
||||
|
||||
<div
|
||||
data-docgen-template-picker="data-docgen-template-picker"
|
||||
data-entity-class="Chill\PersonBundle\Entity\AccompanyingPeriod"
|
||||
data-entity-id="{{ accompanyingCourse.id }}"
|
||||
></div>
|
||||
|
||||
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_CREATE',
|
||||
accompanyingCourse) %}
|
||||
<ul class="record_actions sticky-form-buttons">
|
||||
<li class="create">
|
||||
<a
|
||||
href="{{
|
||||
path('accompanying_course_document_new', {
|
||||
course: accompanyingCourse.id
|
||||
})
|
||||
}}"
|
||||
class="btn btn-create"
|
||||
>
|
||||
{{ "Create" | trans }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -1,74 +1,70 @@
|
||||
{#
|
||||
* Copyright (C) 2018, Champs Libres Cooperative SCRLFS, <http://www.champs-libres.coop>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#}
|
||||
|
||||
{% extends "@ChillPerson/Person/layout.html.twig" %}
|
||||
|
||||
{% set activeRouteKey = '' %}
|
||||
|
||||
{% import "@ChillDocStore/Macro/macro.html.twig" as m %}
|
||||
|
||||
{% block title %}
|
||||
{{ 'Documents for %name%'|trans({ '%name%': person|chill_entity_render_string } ) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
{{ parent() }}
|
||||
{{ encore_entry_script_tags('mod_docgen_picktemplate') }}
|
||||
{{ encore_entry_script_tags('mod_entity_workflow_pick') }}
|
||||
{{ encore_entry_script_tags('mod_document_action_buttons_group') }}
|
||||
{% endblock %}
|
||||
|
||||
{% block css %}
|
||||
{{ parent() }}
|
||||
{{ encore_entry_link_tags('mod_docgen_picktemplate') }}
|
||||
{{ encore_entry_link_tags('mod_entity_workflow_pick') }}
|
||||
{{ encore_entry_link_tags('mod_document_action_buttons_group') }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{# * Copyright (C) 2018, Champs Libres Cooperative SCRLFS,
|
||||
<http://www.champs-libres.coop> * * This program is free software: you can
|
||||
redistribute it and/or modify * it under the terms of the GNU Affero General
|
||||
Public License as * published by the Free Software Foundation, either version 3
|
||||
of the * License, or (at your option) any later version. * * This program is
|
||||
distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY;
|
||||
without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. See the * GNU Affero General Public License for more
|
||||
details. * * You should have received a copy of the GNU Affero General Public
|
||||
License * along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#} {% extends "@ChillPerson/Person/layout.html.twig" %} {% set activeRouteKey =
|
||||
'' %} {% import "@ChillDocStore/Macro/macro.html.twig" as m %} {% block title %}
|
||||
{{ 'Documents for %name%'|trans({ '%name%': person|chill_entity_render_string } ) }}
|
||||
{% endblock %} {% block js %}
|
||||
{{ parent() }}
|
||||
{{ encore_entry_script_tags("mod_docgen_picktemplate") }}
|
||||
{{ encore_entry_script_tags("mod_entity_workflow_pick") }}
|
||||
{{ encore_entry_script_tags("mod_document_action_buttons_group") }}
|
||||
{% endblock %} {% block css %}
|
||||
{{ parent() }}
|
||||
{{ encore_entry_link_tags("mod_docgen_picktemplate") }}
|
||||
{{ encore_entry_link_tags("mod_entity_workflow_pick") }}
|
||||
{{ encore_entry_link_tags("mod_document_action_buttons_group") }}
|
||||
{% endblock %} {% block content %}
|
||||
|
||||
<div class="col-md-10 col-xxl">
|
||||
<h1>{{ 'Documents for %name%'|trans({ '%name%': person|chill_entity_render_string } ) }}</h1>
|
||||
<h1>
|
||||
{{ 'Documents for %name%'|trans({ '%name%': person|chill_entity_render_string } ) }}
|
||||
</h1>
|
||||
|
||||
{{ filter|chill_render_filter_order_helper }}
|
||||
{{ filter | chill_render_filter_order_helper }}
|
||||
|
||||
{% if documents|length == 0 %}
|
||||
<p class="chill-no-data-statement">{{ 'No documents'|trans }}</p>
|
||||
{% if documents|length > 5 %}
|
||||
<div
|
||||
data-docgen-template-picker="data-docgen-template-picker"
|
||||
data-entity-class="Chill\PersonBundle\Entity\Person"
|
||||
data-entity-id="{{ person.id }}"
|
||||
></div>
|
||||
{% endif %} {% if documents|length == 0 %}
|
||||
<p class="chill-no-data-statement">{{ "No documents" | trans }}</p>
|
||||
{% else %}
|
||||
<div class="flex-table chill-task-list">
|
||||
{% for document in documents %}
|
||||
{{ document|chill_generic_doc_render }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="flex-table chill-task-list">
|
||||
{% for document in documents %}
|
||||
{{ document | chill_generic_doc_render }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{{ chill_pagination(pagination) }}
|
||||
{{ chill_pagination(pagination) }}
|
||||
|
||||
<div data-docgen-template-picker="data-docgen-template-picker" data-entity-class="Chill\PersonBundle\Entity\Person" data-entity-id="{{ person.id }}"></div>
|
||||
|
||||
{% if is_granted('CHILL_PERSON_DOCUMENT_CREATE', person) %}
|
||||
<ul class="record_actions sticky-form-buttons">
|
||||
<li class="create">
|
||||
<a href="{{ path('person_document_new', {'person': person.id}) }}" class="btn btn-create">
|
||||
{{ 'Create new document' | trans }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
<div
|
||||
data-docgen-template-picker="data-docgen-template-picker"
|
||||
data-entity-class="Chill\PersonBundle\Entity\Person"
|
||||
data-entity-id="{{ person.id }}"
|
||||
></div>
|
||||
|
||||
{% if is_granted('CHILL_PERSON_DOCUMENT_CREATE', person) %}
|
||||
<ul class="record_actions sticky-form-buttons">
|
||||
<li class="create">
|
||||
<a
|
||||
href="{{ path('person_document_new', { person: person.id }) }}"
|
||||
class="btn btn-create"
|
||||
>
|
||||
{{ "Create new document" | trans }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Directory for {{ stored_object.uuid }}</title>
|
||||
</head>
|
||||
<body>
|
||||
<ul>
|
||||
<li><a href="{{ absolute_url(path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token': access_token })) }}">d</a></li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1,81 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<d:multistatus xmlns:d="DAV:">
|
||||
<d:response>
|
||||
<d:href>{{ path('chill_docstore_dav_directory_get', { 'uuid': stored_object.uuid, 'access_token': access_token } ) }}</d:href>
|
||||
{% if properties.resourceType or properties.contentType %}
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
{% if properties.resourceType %}
|
||||
<d:resourcetype><d:collection/></d:resourcetype>
|
||||
{% endif %}
|
||||
{% if properties.contentType %}
|
||||
<d:getcontenttype>httpd/unix-directory</d:getcontenttype>
|
||||
{% endif %}
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
{% endif %}
|
||||
{% if properties.unknowns|length > 0 %}
|
||||
<d:propstat>
|
||||
{% for k,u in properties.unknowns %}
|
||||
<d:prop {{ ('xmlns:ns' ~ k ~ '="' ~ u.xmlns|e('html_attr') ~ '"')|raw }}>
|
||||
<{{ 'ns'~ k ~ ':' ~ u.prop }} />
|
||||
</d:prop>
|
||||
{% endfor %}
|
||||
<d:status>HTTP/1.1 404 Not Found</d:status>
|
||||
</d:propstat>
|
||||
{% endif %}
|
||||
</d:response>
|
||||
{% if depth == 1 %}
|
||||
<d:response>
|
||||
<d:href>{{ path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token':access_token}) }}</d:href>
|
||||
{% if properties.lastModified or properties.contentLength or properties.resourceType or properties.etag or properties.contentType or properties.creationDate %}
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
{% if properties.resourceType %}
|
||||
<d:resourcetype/>
|
||||
{% endif %}
|
||||
{% if properties.creationDate %}
|
||||
<d:creationdate />
|
||||
{% endif %}
|
||||
{% if properties.lastModified %}
|
||||
{% if last_modified is not same as null %}
|
||||
<d:getlastmodified>{{ last_modified.format(constant('DATE_RSS')) }}</d:getlastmodified>
|
||||
{% else %}
|
||||
<d:getlastmodified />
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if properties.contentLength %}
|
||||
{% if content_length is not same as null %}
|
||||
<d:getcontentlength>{{ content_length }}</d:getcontentlength>
|
||||
{% else %}
|
||||
<d:getcontentlength />
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if properties.etag %}
|
||||
{% if etag is not same as null %}
|
||||
<d:getetag>"{{ etag }}"</d:getetag>
|
||||
{% else %}
|
||||
<d:getetag />
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if properties.contentType %}
|
||||
<d:getcontenttype>{{ stored_object.type }}</d:getcontenttype>
|
||||
{% endif %}
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
{% endif %}
|
||||
{% if properties.unknowns|length > 0 %}
|
||||
<d:propstat>
|
||||
{% for k,u in properties.unknowns %}
|
||||
<d:prop {{ ('xmlns:ns' ~ k ~ '="' ~ u.xmlns|e('html_attr') ~ '"')|raw }}>
|
||||
<{{ 'ns'~ k ~ ':' ~ u.prop }} />
|
||||
</d:prop>
|
||||
{% endfor %}
|
||||
<d:status>HTTP/1.1 404 Not Found</d:status>
|
||||
</d:propstat>
|
||||
{% endif %}
|
||||
</d:response>
|
||||
{% endif %}
|
||||
</d:multistatus>
|
@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<d:multistatus xmlns:d="DAV:">
|
||||
<d:response>
|
||||
<d:href>{{ path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token': access_token}) }}</d:href>
|
||||
{% if properties.lastModified or properties.contentLength or properties.resourceType or properties.etag or properties.contentType or properties.creationDate %}
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
{% if properties.resourceType %}
|
||||
<d:resourcetype/>
|
||||
{% endif %}
|
||||
{% if properties.creationDate %}
|
||||
<d:creationdate />
|
||||
{% endif %}
|
||||
{% if properties.lastModified %}
|
||||
{% if last_modified is not same as null %}
|
||||
<d:getlastmodified>{{ last_modified.format(constant('DATE_RSS')) }}</d:getlastmodified>
|
||||
{% else %}
|
||||
<d:getlastmodified />
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if properties.contentLength %}
|
||||
{% if content_length is not same as null %}
|
||||
<d:getcontentlength>{{ content_length }}</d:getcontentlength>
|
||||
{% else %}
|
||||
<d:getcontentlength />
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if properties.etag %}
|
||||
{% if etag is not same as null %}
|
||||
<d:getetag>"{{ etag }}"</d:getetag>
|
||||
{% else %}
|
||||
<d:getetag />
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if properties.contentType %}
|
||||
<d:getcontenttype>{{ stored_object.type }}</d:getcontenttype>
|
||||
{% endif %}
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
{% endif %}
|
||||
{% if properties.unknowns|length > 0 %}
|
||||
<d:propstat>
|
||||
{% for k,u in properties.unknowns %}
|
||||
<d:prop {{ ('xmlns:ns' ~ k ~ '="' ~ u.xmlns|e('html_attr') ~ '"')|raw }}>
|
||||
<{{ 'ns'~ k ~ ':' ~ u.prop }} />
|
||||
</d:prop>
|
||||
{% endfor %}
|
||||
<d:status>HTTP/1.1 404 Not Found</d:status>
|
||||
</d:propstat>
|
||||
{% endif %}
|
||||
</d:response>
|
||||
</d:multistatus>
|
@ -0,0 +1,7 @@
|
||||
{% extends '@ChillMain/layout.html.twig' %}
|
||||
|
||||
{% block content %}
|
||||
<p>document uuid: {{ stored_object.uuid }}</p>
|
||||
<p>{{ absolute_url(path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token': access_token })) }}</p>
|
||||
<a href="vnd.libreoffice.command:ofe|u|{{ absolute_url(path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token': access_token })) }}">Open document</a>
|
||||
{% endblock %}
|
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\DocStoreBundle\Security\Authorization;
|
||||
|
||||
/**
|
||||
* Role to edit or see the stored object content.
|
||||
*/
|
||||
enum StoredObjectRoleEnum: string
|
||||
{
|
||||
case SEE = 'SEE';
|
||||
|
||||
case EDIT = 'SEE_AND_EDIT';
|
||||
}
|
@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\DocStoreBundle\Security\Authorization;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Security\Guard\DavTokenAuthenticationEventSubscriber;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
/**
|
||||
* Voter for the content of a stored object.
|
||||
*
|
||||
* This is in use to allow or disallow the edition of the stored object's content.
|
||||
*/
|
||||
class StoredObjectVoter extends Voter
|
||||
{
|
||||
protected function supports($attribute, $subject): bool
|
||||
{
|
||||
return StoredObjectRoleEnum::tryFrom($attribute) instanceof StoredObjectRoleEnum
|
||||
&& $subject instanceof StoredObject;
|
||||
}
|
||||
|
||||
protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
|
||||
{
|
||||
/** @var StoredObject $subject */
|
||||
if (
|
||||
!$token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)
|
||||
|| $subject->getUuid()->toString() !== $token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$askedRole = StoredObjectRoleEnum::from($attribute);
|
||||
$tokenRoleAuthorization =
|
||||
$token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS);
|
||||
|
||||
return match ($askedRole) {
|
||||
StoredObjectRoleEnum::SEE => StoredObjectRoleEnum::EDIT === $tokenRoleAuthorization || StoredObjectRoleEnum::SEE === $tokenRoleAuthorization,
|
||||
StoredObjectRoleEnum::EDIT => StoredObjectRoleEnum::EDIT === $tokenRoleAuthorization
|
||||
};
|
||||
}
|
||||
}
|
@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\DocStoreBundle\Security\Guard;
|
||||
|
||||
use Lexik\Bundle\JWTAuthenticationBundle\TokenExtractor\TokenExtractorInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* Extract the JWT Token from the segment of the dav endpoints.
|
||||
*
|
||||
* A segment is a separation inside the string, using the character "/".
|
||||
*
|
||||
* For recognizing the JWT, the first segment must be "dav", and the second one must be
|
||||
* the JWT endpoint.
|
||||
*/
|
||||
final readonly class DavOnUrlTokenExtractor implements TokenExtractorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
public function extract(Request $request): false|string
|
||||
{
|
||||
$uri = $request->getRequestUri();
|
||||
|
||||
$segments = array_values(
|
||||
array_filter(
|
||||
explode('/', $uri),
|
||||
fn ($item) => '' !== trim($item)
|
||||
)
|
||||
);
|
||||
|
||||
if (2 > count($segments)) {
|
||||
$this->logger->info('not enough segment for parsing URL');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if ('dav' !== $segments[0]) {
|
||||
$this->logger->info('the first segment of the url must be DAV');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return $segments[1];
|
||||
}
|
||||
}
|
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\DocStoreBundle\Security\Guard;
|
||||
|
||||
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTAuthenticatedEvent;
|
||||
use Lexik\Bundle\JWTAuthenticationBundle\Events;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
|
||||
/**
|
||||
* Store some data from the JWT's payload inside the token's attributes.
|
||||
*/
|
||||
class DavTokenAuthenticationEventSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
final public const STORED_OBJECT = 'stored_object';
|
||||
final public const ACTIONS = 'stored_objects_actions';
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
Events::JWT_AUTHENTICATED => ['onJWTAuthenticated', 0],
|
||||
];
|
||||
}
|
||||
|
||||
public function onJWTAuthenticated(JWTAuthenticatedEvent $event): void
|
||||
{
|
||||
$payload = $event->getPayload();
|
||||
|
||||
if (!(array_key_exists('dav', $payload) && 1 === $payload['dav'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$token = $event->getToken();
|
||||
$token->setAttribute(self::ACTIONS, match ($payload['e']) {
|
||||
0 => StoredObjectRoleEnum::SEE,
|
||||
1 => StoredObjectRoleEnum::EDIT,
|
||||
default => throw new \UnexpectedValueException('unsupported value for e parameter')
|
||||
});
|
||||
|
||||
$token->setAttribute(self::STORED_OBJECT, $payload['so']);
|
||||
}
|
||||
}
|
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\DocStoreBundle\Security\Guard;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
|
||||
/**
|
||||
* Provide a JWT Token which will be valid for viewing or editing a document.
|
||||
*/
|
||||
final readonly class JWTDavTokenProvider implements JWTDavTokenProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private JWTTokenManagerInterface $JWTTokenManager,
|
||||
private Security $security,
|
||||
) {}
|
||||
|
||||
public function createToken(StoredObject $storedObject, StoredObjectRoleEnum $roleEnum): string
|
||||
{
|
||||
return $this->JWTTokenManager->createFromPayload($this->security->getUser(), [
|
||||
'dav' => 1,
|
||||
'e' => match ($roleEnum) {
|
||||
StoredObjectRoleEnum::SEE => 0,
|
||||
StoredObjectRoleEnum::EDIT => 1,
|
||||
},
|
||||
'so' => $storedObject->getUuid(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function getTokenExpiration(string $tokenString): \DateTimeImmutable
|
||||
{
|
||||
$jwt = $this->JWTTokenManager->parse($tokenString);
|
||||
|
||||
return \DateTimeImmutable::createFromFormat('U', (string) $jwt['exp']);
|
||||
}
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\DocStoreBundle\Security\Guard;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||
|
||||
/**
|
||||
* Provide a JWT Token which will be valid for viewing or editing a document.
|
||||
*/
|
||||
interface JWTDavTokenProviderInterface
|
||||
{
|
||||
public function createToken(StoredObject $storedObject, StoredObjectRoleEnum $roleEnum): string;
|
||||
|
||||
public function getTokenExpiration(string $tokenString): \DateTimeImmutable;
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\DocStoreBundle\Security\Guard;
|
||||
|
||||
use Lexik\Bundle\JWTAuthenticationBundle\Security\Guard\JWTTokenAuthenticator;
|
||||
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
|
||||
use Lexik\Bundle\JWTAuthenticationBundle\TokenExtractor\TokenExtractorInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
* Alter the base JWTTokenAuthenticator to add the special extractor for dav url endpoints.
|
||||
*/
|
||||
class JWTOnDavUrlAuthenticator extends JWTTokenAuthenticator
|
||||
{
|
||||
public function __construct(
|
||||
JWTTokenManagerInterface $jwtManager,
|
||||
EventDispatcherInterface $dispatcher,
|
||||
TokenExtractorInterface $tokenExtractor,
|
||||
private readonly DavOnUrlTokenExtractor $davOnUrlTokenExtractor,
|
||||
TokenStorageInterface $preAuthenticationTokenStorage,
|
||||
?TranslatorInterface $translator = null,
|
||||
) {
|
||||
parent::__construct($jwtManager, $dispatcher, $tokenExtractor, $preAuthenticationTokenStorage, $translator);
|
||||
}
|
||||
|
||||
protected function getTokenExtractor()
|
||||
{
|
||||
return $this->davOnUrlTokenExtractor;
|
||||
}
|
||||
}
|
@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\DocStoreBundle\Serializer\Normalizer;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||
use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||
|
||||
/**
|
||||
* Class StoredObjectNormalizer.
|
||||
*
|
||||
* Normalizes a StoredObject entity to an array of data.
|
||||
*/
|
||||
final class StoredObjectNormalizer implements NormalizerInterface, NormalizerAwareInterface
|
||||
{
|
||||
use NormalizerAwareTrait;
|
||||
public const ADD_DAV_SEE_LINK_CONTEXT = 'dav-see-link-context';
|
||||
public const ADD_DAV_EDIT_LINK_CONTEXT = 'dav-edit-link-context';
|
||||
|
||||
public function __construct(
|
||||
private readonly JWTDavTokenProviderInterface $JWTDavTokenProvider,
|
||||
private readonly UrlGeneratorInterface $urlGenerator
|
||||
) {}
|
||||
|
||||
public function normalize($object, ?string $format = null, array $context = [])
|
||||
{
|
||||
/** @var StoredObject $object */
|
||||
$datas = [
|
||||
'datas' => $object->getDatas(),
|
||||
'filename' => $object->getFilename(),
|
||||
'id' => $object->getId(),
|
||||
'iv' => $object->getIv(),
|
||||
'keyInfos' => $object->getKeyInfos(),
|
||||
'title' => $object->getTitle(),
|
||||
'type' => $object->getType(),
|
||||
'uuid' => $object->getUuid(),
|
||||
'status' => $object->getStatus(),
|
||||
'createdAt' => $this->normalizer->normalize($object->getCreatedAt(), $format, $context),
|
||||
'createdBy' => $this->normalizer->normalize($object->getCreatedBy(), $format, $context),
|
||||
];
|
||||
|
||||
// deprecated property
|
||||
$datas['creationDate'] = $datas['createdAt'];
|
||||
|
||||
$canDavSee = in_array(self::ADD_DAV_SEE_LINK_CONTEXT, $context['groups'] ?? [], true);
|
||||
$canDavEdit = in_array(self::ADD_DAV_EDIT_LINK_CONTEXT, $context['groups'] ?? [], true);
|
||||
|
||||
if ($canDavSee || $canDavEdit) {
|
||||
$accessToken = $this->JWTDavTokenProvider->createToken(
|
||||
$object,
|
||||
$canDavEdit ? StoredObjectRoleEnum::EDIT : StoredObjectRoleEnum::SEE
|
||||
);
|
||||
|
||||
$datas['_links'] = [
|
||||
'dav_link' => [
|
||||
'href' => $this->urlGenerator->generate(
|
||||
'chill_docstore_dav_document_get',
|
||||
[
|
||||
'uuid' => $object->getUuid(),
|
||||
'access_token' => $accessToken,
|
||||
],
|
||||
UrlGeneratorInterface::ABSOLUTE_URL,
|
||||
),
|
||||
'expiration' => $this->JWTDavTokenProvider->getTokenExpiration($accessToken)->format('U'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $datas;
|
||||
}
|
||||
|
||||
public function supportsNormalization($data, ?string $format = null)
|
||||
{
|
||||
return $data instanceof StoredObject && 'json' === $format;
|
||||
}
|
||||
}
|
@ -58,6 +58,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);
|
||||
@ -159,6 +215,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 {
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -13,6 +13,9 @@ 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 Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||
use Twig\Environment;
|
||||
@ -120,7 +123,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.
|
||||
@ -130,7 +138,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);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -142,12 +150,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'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,410 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\DocStoreBundle\Tests\Controller;
|
||||
|
||||
use Chill\DocStoreBundle\Controller\WebdavController;
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class WebdavControllerTest extends KernelTestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private \Twig\Environment $engine;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
|
||||
$this->engine = self::getContainer()->get(\Twig\Environment::class);
|
||||
}
|
||||
|
||||
private function buildController(): WebdavController
|
||||
{
|
||||
$storedObjectManager = new MockedStoredObjectManager();
|
||||
$security = $this->prophesize(Security::class);
|
||||
$security->isGranted(Argument::in(['EDIT', 'SEE']), Argument::type(StoredObject::class))
|
||||
->willReturn(true);
|
||||
|
||||
return new WebdavController($this->engine, $storedObjectManager, $security->reveal());
|
||||
}
|
||||
|
||||
private function buildDocument(): StoredObject
|
||||
{
|
||||
$object = (new StoredObject())
|
||||
->setType('application/vnd.oasis.opendocument.text');
|
||||
|
||||
$reflectionObject = new \ReflectionClass($object);
|
||||
$reflectionObjectUuid = $reflectionObject->getProperty('uuid');
|
||||
|
||||
$reflectionObjectUuid->setValue($object, Uuid::fromString('716e6688-4579-4938-acf3-c4ab5856803b'));
|
||||
|
||||
return $object;
|
||||
}
|
||||
|
||||
public function testGet(): void
|
||||
{
|
||||
$controller = $this->buildController();
|
||||
|
||||
$response = $controller->getDocument($this->buildDocument());
|
||||
|
||||
self::assertEquals(200, $response->getStatusCode());
|
||||
self::assertEquals('abcde', $response->getContent());
|
||||
self::assertContains('etag', $response->headers->keys());
|
||||
self::assertStringContainsString('ab56b4', $response->headers->get('etag'));
|
||||
}
|
||||
|
||||
public function testOptionsOnDocument(): void
|
||||
{
|
||||
$controller = $this->buildController();
|
||||
|
||||
$response = $controller->optionsDocument($this->buildDocument());
|
||||
|
||||
self::assertEquals(200, $response->getStatusCode());
|
||||
self::assertContains('allow', $response->headers->keys());
|
||||
|
||||
foreach (explode(',', 'OPTIONS,GET,HEAD,PROPFIND') as $method) {
|
||||
self::assertStringContainsString($method, $response->headers->get('allow'));
|
||||
}
|
||||
|
||||
self::assertContains('dav', $response->headers->keys());
|
||||
self::assertStringContainsString('1', $response->headers->get('dav'));
|
||||
}
|
||||
|
||||
public function testOptionsOnDirectory(): void
|
||||
{
|
||||
$controller = $this->buildController();
|
||||
|
||||
$response = $controller->optionsDirectory($this->buildDocument());
|
||||
|
||||
self::assertEquals(200, $response->getStatusCode());
|
||||
self::assertContains('allow', $response->headers->keys());
|
||||
|
||||
foreach (explode(',', 'OPTIONS,GET,HEAD,PROPFIND') as $method) {
|
||||
self::assertStringContainsString($method, $response->headers->get('allow'));
|
||||
}
|
||||
|
||||
self::assertContains('dav', $response->headers->keys());
|
||||
self::assertStringContainsString('1', $response->headers->get('dav'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider generateDataPropfindDocument
|
||||
*/
|
||||
public function testPropfindDocument(string $requestContent, int $expectedStatusCode, string $expectedXmlResponse, string $message): void
|
||||
{
|
||||
$controller = $this->buildController();
|
||||
|
||||
$request = new Request([], [], [], [], [], [], $requestContent);
|
||||
$request->setMethod('PROPFIND');
|
||||
$response = $controller->propfindDocument($this->buildDocument(), '1234', $request);
|
||||
|
||||
self::assertEquals($expectedStatusCode, $response->getStatusCode());
|
||||
self::assertContains('content-type', $response->headers->keys());
|
||||
self::assertStringContainsString('text/xml', $response->headers->get('content-type'));
|
||||
self::assertTrue((new \DOMDocument())->loadXML($response->getContent()), $message.' test that the xml response is a valid xml');
|
||||
self::assertXmlStringEqualsXmlString($expectedXmlResponse, $response->getContent(), $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider generateDataPropfindDirectory
|
||||
*/
|
||||
public function testPropfindDirectory(string $requestContent, int $expectedStatusCode, string $expectedXmlResponse, string $message): void
|
||||
{
|
||||
$controller = $this->buildController();
|
||||
|
||||
$request = new Request([], [], [], [], [], [], $requestContent);
|
||||
$request->setMethod('PROPFIND');
|
||||
$request->headers->add(['Depth' => '0']);
|
||||
$response = $controller->propfindDirectory($this->buildDocument(), '1234', $request);
|
||||
|
||||
self::assertEquals($expectedStatusCode, $response->getStatusCode());
|
||||
self::assertContains('content-type', $response->headers->keys());
|
||||
self::assertStringContainsString('text/xml', $response->headers->get('content-type'));
|
||||
self::assertTrue((new \DOMDocument())->loadXML($response->getContent()), $message.' test that the xml response is a valid xml');
|
||||
self::assertXmlStringEqualsXmlString($expectedXmlResponse, $response->getContent(), $message);
|
||||
}
|
||||
|
||||
public function testHeadDocument(): void
|
||||
{
|
||||
$controller = $this->buildController();
|
||||
$response = $controller->headDocument($this->buildDocument());
|
||||
|
||||
self::assertEquals(200, $response->getStatusCode());
|
||||
self::assertContains('content-length', $response->headers->keys());
|
||||
self::assertContains('content-type', $response->headers->keys());
|
||||
self::assertContains('etag', $response->headers->keys());
|
||||
self::assertEquals('ab56b4d92b40713acc5af89985d4b786', $response->headers->get('etag'));
|
||||
self::assertEquals('application/vnd.oasis.opendocument.text', $response->headers->get('content-type'));
|
||||
self::assertEquals(5, $response->headers->get('content-length'));
|
||||
}
|
||||
|
||||
public static function generateDataPropfindDocument(): iterable
|
||||
{
|
||||
$content =
|
||||
<<<'XML'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<propfind xmlns="DAV:"><prop><resourcetype xmlns="DAV:"/><IsReadOnly xmlns="http://ucb.openoffice.org/dav/props/"/><getcontenttype xmlns="DAV:"/><supportedlock xmlns="DAV:"/></prop></propfind>
|
||||
XML;
|
||||
|
||||
$response =
|
||||
<<<'XML'
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:multistatus xmlns:d="DAV:" >
|
||||
<d:response>
|
||||
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:resourcetype/>
|
||||
<d:getcontenttype>application/vnd.oasis.opendocument.text</d:getcontenttype>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
<d:propstat>
|
||||
<d:prop xmlns:ns0="http://ucb.openoffice.org/dav/props/">
|
||||
<ns0:IsReadOnly/>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 404 Not Found</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>
|
||||
XML;
|
||||
|
||||
yield [$content, 207, $response, 'get IsReadOnly and contenttype from server'];
|
||||
|
||||
$content =
|
||||
<<<'XML'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<propfind xmlns="DAV:">
|
||||
<prop>
|
||||
<IsReadOnly xmlns="http://ucb.openoffice.org/dav/props/"/>
|
||||
</prop>
|
||||
</propfind>
|
||||
XML;
|
||||
|
||||
$response =
|
||||
<<<'XML'
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:multistatus xmlns:d="DAV:">
|
||||
<d:response>
|
||||
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href>
|
||||
<d:propstat>
|
||||
<d:prop xmlns:ns0="http://ucb.openoffice.org/dav/props/">
|
||||
<ns0:IsReadOnly/>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 404 Not Found</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>
|
||||
XML;
|
||||
|
||||
yield [$content, 207, $response, 'get property IsReadOnly'];
|
||||
|
||||
yield [
|
||||
<<<'XML'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<propfind xmlns="DAV:">
|
||||
<prop>
|
||||
<BaseURI xmlns="http://ucb.openoffice.org/dav/props/"/>
|
||||
</prop>
|
||||
</propfind>
|
||||
XML,
|
||||
207,
|
||||
<<<'XML'
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:multistatus xmlns:d="DAV:">
|
||||
<d:response>
|
||||
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href>
|
||||
<d:propstat>
|
||||
<d:prop xmlns:ns0="http://ucb.openoffice.org/dav/props/">
|
||||
<ns0:BaseURI/>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 404 Not Found</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>
|
||||
XML,
|
||||
'Test requesting an unknow property',
|
||||
];
|
||||
|
||||
yield [
|
||||
<<<'XML'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<propfind xmlns="DAV:">
|
||||
<prop>
|
||||
<getlastmodified xmlns="DAV:"/>
|
||||
</prop>
|
||||
</propfind>
|
||||
XML,
|
||||
207,
|
||||
<<<'XML'
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:multistatus xmlns:d="DAV:">
|
||||
<d:response>
|
||||
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<!-- the date scraped from a webserver is >Sun, 10 Sep 2023 14:10:23 GMT -->
|
||||
<d:getlastmodified>Wed, 13 Sep 2023 14:15:00 +0200</d:getlastmodified>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>
|
||||
XML,
|
||||
'test getting the last modified date',
|
||||
];
|
||||
|
||||
yield [
|
||||
<<<'XML'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<propfind xmlns="DAV:">
|
||||
<propname/>
|
||||
</propfind>
|
||||
XML,
|
||||
207,
|
||||
<<<'XML'
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:multistatus xmlns:d="DAV:">
|
||||
<d:response>
|
||||
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:resourcetype/>
|
||||
<d:creationdate/>
|
||||
<d:getlastmodified>Wed, 13 Sep 2023 14:15:00 +0200</d:getlastmodified>
|
||||
<!-- <d:getcontentlength/> -->
|
||||
<d:getcontentlength>5</d:getcontentlength>
|
||||
<!-- <d:getlastmodified/> -->
|
||||
<d:getetag>"ab56b4d92b40713acc5af89985d4b786"</d:getetag>
|
||||
<!--
|
||||
<d:supportedlock/>
|
||||
<d:lockdiscovery/>
|
||||
-->
|
||||
<!-- <d:getcontenttype/> -->
|
||||
<d:getcontenttype>application/vnd.oasis.opendocument.text</d:getcontenttype>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>
|
||||
XML,
|
||||
'test finding all properties',
|
||||
];
|
||||
}
|
||||
|
||||
public static function generateDataPropfindDirectory(): iterable
|
||||
{
|
||||
yield [
|
||||
<<<'XML'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<propfind xmlns="DAV:"><prop><resourcetype xmlns="DAV:"/><IsReadOnly xmlns="http://ucb.openoffice.org/dav/props/"/><getcontenttype xmlns="DAV:"/><supportedlock xmlns="DAV:"/></prop></propfind>
|
||||
XML,
|
||||
207,
|
||||
<<<'XML'
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:multistatus xmlns:d="DAV:">
|
||||
<d:response>
|
||||
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:resourcetype><d:collection/></d:resourcetype>
|
||||
<d:getcontenttype>httpd/unix-directory</d:getcontenttype>
|
||||
<!--
|
||||
<d:supportedlock>
|
||||
<d:lockentry>
|
||||
<d:lockscope><d:exclusive/></d:lockscope>
|
||||
<d:locktype><d:write/></d:locktype>
|
||||
</d:lockentry>
|
||||
<d:lockentry>
|
||||
<d:lockscope><d:shared/></d:lockscope>
|
||||
<d:locktype><d:write/></d:locktype>
|
||||
</d:lockentry>
|
||||
</d:supportedlock>
|
||||
-->
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
<d:propstat>
|
||||
<d:prop xmlns:ns0="http://ucb.openoffice.org/dav/props/">
|
||||
<ns0:IsReadOnly/>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 404 Not Found</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>
|
||||
XML,
|
||||
'test resourceType and IsReadOnly ',
|
||||
];
|
||||
|
||||
yield [
|
||||
<<<'XML'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<propfind xmlns="DAV:"><prop><CreatableContentsInfo xmlns="http://ucb.openoffice.org/dav/props/"/></prop></propfind>
|
||||
XML,
|
||||
207,
|
||||
<<<'XML'
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:multistatus xmlns:d="DAV:">
|
||||
<d:response>
|
||||
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/</d:href>
|
||||
<d:propstat>
|
||||
<d:prop xmlns:ns0="http://ucb.openoffice.org/dav/props/" >
|
||||
<ns0:CreatableContentsInfo/>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 404 Not Found</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>
|
||||
XML,
|
||||
'test creatableContentsInfo',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class MockedStoredObjectManager implements StoredObjectManagerInterface
|
||||
{
|
||||
public function getLastModified(StoredObject $document): \DateTimeInterface
|
||||
{
|
||||
return new \DateTimeImmutable('2023-09-13T14:15');
|
||||
}
|
||||
|
||||
public function getContentLength(StoredObject $document): int
|
||||
{
|
||||
return 5;
|
||||
}
|
||||
|
||||
public function read(StoredObject $document): string
|
||||
{
|
||||
return 'abcde';
|
||||
}
|
||||
|
||||
public function write(StoredObject $document, string $clearContent): void {}
|
||||
|
||||
public function etag(StoredObject $document): string
|
||||
{
|
||||
return 'ab56b4d92b40713acc5af89985d4b786';
|
||||
}
|
||||
|
||||
public function clearCache(): void {}
|
||||
}
|
@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\DocStoreBundle\Tests\Dav\Request;
|
||||
|
||||
use Chill\DocStoreBundle\Dav\Request\PropfindRequestAnalyzer;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class PropfindRequestAnalyzerTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @dataProvider provideRequestedProperties
|
||||
*/
|
||||
public function testGetRequestedProperties(string $xml, array $expected): void
|
||||
{
|
||||
$analyzer = new PropfindRequestAnalyzer();
|
||||
|
||||
$request = new \DOMDocument();
|
||||
$request->loadXML($xml);
|
||||
$actual = $analyzer->getRequestedProperties($request);
|
||||
|
||||
foreach ($expected as $key => $value) {
|
||||
if ('unknowns' === $key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
self::assertArrayHasKey($key, $actual, "Check that key {$key} does exists in list of expected values");
|
||||
self::assertEquals($value, $actual[$key], "Does the value match expected for key {$key}");
|
||||
}
|
||||
|
||||
if (array_key_exists('unknowns', $expected)) {
|
||||
self::assertEquals(count($expected['unknowns']), count($actual['unknowns']));
|
||||
self::assertEqualsCanonicalizing($expected['unknowns'], $actual['unknowns']);
|
||||
}
|
||||
}
|
||||
|
||||
public static function provideRequestedProperties(): iterable
|
||||
{
|
||||
yield [
|
||||
<<<'XML'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<propfind xmlns="DAV:">
|
||||
<prop>
|
||||
<BaseURI xmlns="http://ucb.openoffice.org/dav/props/"/>
|
||||
</prop>
|
||||
</propfind>
|
||||
XML,
|
||||
[
|
||||
'resourceType' => false,
|
||||
'contentType' => false,
|
||||
'lastModified' => false,
|
||||
'creationDate' => false,
|
||||
'contentLength' => false,
|
||||
'etag' => false,
|
||||
'supportedLock' => false,
|
||||
'unknowns' => [
|
||||
['xmlns' => 'http://ucb.openoffice.org/dav/props/', 'prop' => 'BaseURI'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
yield [
|
||||
<<<'XML'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<propfind xmlns="DAV:">
|
||||
<propname/>
|
||||
</propfind>
|
||||
XML,
|
||||
[
|
||||
'resourceType' => true,
|
||||
'contentType' => true,
|
||||
'lastModified' => true,
|
||||
'creationDate' => true,
|
||||
'contentLength' => true,
|
||||
'etag' => true,
|
||||
'supportedLock' => true,
|
||||
'unknowns' => [],
|
||||
],
|
||||
];
|
||||
|
||||
yield [
|
||||
<<<'XML'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<propfind xmlns="DAV:">
|
||||
<prop>
|
||||
<getlastmodified xmlns="DAV:"/>
|
||||
</prop>
|
||||
</propfind>
|
||||
XML,
|
||||
[
|
||||
'resourceType' => false,
|
||||
'contentType' => false,
|
||||
'lastModified' => true,
|
||||
'creationDate' => false,
|
||||
'contentLength' => false,
|
||||
'etag' => false,
|
||||
'supportedLock' => false,
|
||||
'unknowns' => [],
|
||||
],
|
||||
];
|
||||
|
||||
yield [
|
||||
<<<'XML'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<propfind xmlns="DAV:"><prop><resourcetype xmlns="DAV:"/><IsReadOnly xmlns="http://ucb.openoffice.org/dav/props/"/><getcontenttype xmlns="DAV:"/><supportedlock xmlns="DAV:"/></prop></propfind>
|
||||
XML,
|
||||
[
|
||||
'resourceType' => true,
|
||||
'contentType' => true,
|
||||
'lastModified' => false,
|
||||
'creationDate' => false,
|
||||
'contentLength' => false,
|
||||
'etag' => false,
|
||||
'supportedLock' => false,
|
||||
'unknowns' => [
|
||||
['xmlns' => 'http://ucb.openoffice.org/dav/props/', 'prop' => 'IsReadOnly'],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\DocStoreBundle\Tests\Form;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Form\DataMapper\StoredObjectDataMapper;
|
||||
use Chill\DocStoreBundle\Form\DataTransformer\StoredObjectDataTransformer;
|
||||
use Chill\DocStoreBundle\Form\StoredObjectType;
|
||||
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||
use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface;
|
||||
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectNormalizer;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Symfony\Component\Form\PreloadedExtension;
|
||||
use Symfony\Component\Form\Test\TypeTestCase;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Serializer\Encoder\JsonEncoder;
|
||||
use Symfony\Component\Serializer\Serializer;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class StoredObjectTypeTest extends TypeTestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
public function testChangeTitleValue(): void
|
||||
{
|
||||
$formData = ['title' => $newTitle = 'new title', 'stored_object' => <<<'JSON'
|
||||
{"datas":[],"filename":"","id":null,"iv":[],"keyInfos":[],"title":"","type":"","uuid":"3c6a28fe-f913-40b9-a201-5eccc4f2d312","status":"ready","createdAt":null,"createdBy":null,"creationDate":null,"_links":{"dav_link":{"href":"http:\/\/url\/fake","expiration":"1716889578"}}}
|
||||
JSON];
|
||||
$model = new StoredObject();
|
||||
$form = $this->factory->create(StoredObjectType::class, $model, ['has_title' => true]);
|
||||
|
||||
$form->submit($formData);
|
||||
|
||||
$this->assertTrue($form->isSynchronized());
|
||||
|
||||
$this->assertEquals($newTitle, $model->getTitle());
|
||||
}
|
||||
|
||||
public function testReplaceByAnotherObject(): void
|
||||
{
|
||||
$formData = ['title' => $newTitle = 'new title', 'stored_object' => <<<'JSON'
|
||||
{"filename":"abcdef","iv":[10, 15, 20, 30],"keyInfos":[],"type":"text/html","status":"object_store_created"}
|
||||
JSON];
|
||||
$model = new StoredObject();
|
||||
$originalObjectId = spl_object_id($model);
|
||||
$form = $this->factory->create(StoredObjectType::class, $model, ['has_title' => true]);
|
||||
|
||||
$form->submit($formData);
|
||||
|
||||
$this->assertTrue($form->isSynchronized());
|
||||
$model = $form->getData();
|
||||
$this->assertNotEquals($originalObjectId, spl_object_hash($model));
|
||||
$this->assertEquals('abcdef', $model->getFilename());
|
||||
$this->assertEquals([10, 15, 20, 30], $model->getIv());
|
||||
$this->assertEquals('text/html', $model->getType());
|
||||
$this->assertEquals($newTitle, $model->getTitle());
|
||||
}
|
||||
|
||||
protected function getExtensions()
|
||||
{
|
||||
$jwtTokenProvider = $this->prophesize(JWTDavTokenProviderInterface::class);
|
||||
$jwtTokenProvider->createToken(Argument::type(StoredObject::class), Argument::type(StoredObjectRoleEnum::class))
|
||||
->willReturn('token');
|
||||
$jwtTokenProvider->getTokenExpiration('token')->willReturn(new \DateTimeImmutable());
|
||||
$urlGenerator = $this->prophesize(UrlGeneratorInterface::class);
|
||||
$urlGenerator->generate('chill_docstore_dav_document_get', Argument::type('array'), UrlGeneratorInterface::ABSOLUTE_URL)
|
||||
->willReturn('http://url/fake');
|
||||
|
||||
$serializer = new Serializer(
|
||||
[
|
||||
new StoredObjectNormalizer(
|
||||
$jwtTokenProvider->reveal(),
|
||||
$urlGenerator->reveal(),
|
||||
),
|
||||
],
|
||||
[
|
||||
new JsonEncoder(),
|
||||
]
|
||||
);
|
||||
$dataTransformer = new StoredObjectDataTransformer($serializer);
|
||||
$dataMapper = new StoredObjectDataMapper();
|
||||
$type = new StoredObjectType(
|
||||
$dataTransformer,
|
||||
$dataMapper,
|
||||
);
|
||||
|
||||
return [
|
||||
new PreloadedExtension([$type], []),
|
||||
];
|
||||
}
|
||||
}
|
@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\DocStoreBundle\Tests\Security\Authorization;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter;
|
||||
use Chill\DocStoreBundle\Security\Guard\DavTokenAuthenticationEventSubscriber;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class StoredObjectVoterTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
/**
|
||||
* @dataProvider provideDataVote
|
||||
*/
|
||||
public function testVote(TokenInterface $token, ?object $subject, string $attribute, mixed $expected): void
|
||||
{
|
||||
$voter = new StoredObjectVoter();
|
||||
|
||||
self::assertEquals($expected, $voter->vote($token, $subject, [$attribute]));
|
||||
}
|
||||
|
||||
public function provideDataVote(): iterable
|
||||
{
|
||||
yield [
|
||||
$this->buildToken(StoredObjectRoleEnum::EDIT, new StoredObject()),
|
||||
new \stdClass(),
|
||||
'SOMETHING',
|
||||
VoterInterface::ACCESS_ABSTAIN,
|
||||
];
|
||||
|
||||
yield [
|
||||
$this->buildToken(StoredObjectRoleEnum::EDIT, $so = new StoredObject()),
|
||||
$so,
|
||||
'SOMETHING',
|
||||
VoterInterface::ACCESS_ABSTAIN,
|
||||
];
|
||||
|
||||
yield [
|
||||
$this->buildToken(StoredObjectRoleEnum::EDIT, $so = new StoredObject()),
|
||||
$so,
|
||||
StoredObjectRoleEnum::SEE->value,
|
||||
VoterInterface::ACCESS_GRANTED,
|
||||
];
|
||||
|
||||
yield [
|
||||
$this->buildToken(StoredObjectRoleEnum::EDIT, $so = new StoredObject()),
|
||||
$so,
|
||||
StoredObjectRoleEnum::EDIT->value,
|
||||
VoterInterface::ACCESS_GRANTED,
|
||||
];
|
||||
|
||||
yield [
|
||||
$this->buildToken(StoredObjectRoleEnum::SEE, $so = new StoredObject()),
|
||||
$so,
|
||||
StoredObjectRoleEnum::EDIT->value,
|
||||
VoterInterface::ACCESS_DENIED,
|
||||
];
|
||||
|
||||
yield [
|
||||
$this->buildToken(StoredObjectRoleEnum::SEE, $so = new StoredObject()),
|
||||
$so,
|
||||
StoredObjectRoleEnum::SEE->value,
|
||||
VoterInterface::ACCESS_GRANTED,
|
||||
];
|
||||
|
||||
yield [
|
||||
$this->buildToken(null, null),
|
||||
new StoredObject(),
|
||||
StoredObjectRoleEnum::SEE->value,
|
||||
VoterInterface::ACCESS_DENIED,
|
||||
];
|
||||
|
||||
yield [
|
||||
$this->buildToken(null, null),
|
||||
new StoredObject(),
|
||||
StoredObjectRoleEnum::SEE->value,
|
||||
VoterInterface::ACCESS_DENIED,
|
||||
];
|
||||
}
|
||||
|
||||
private function buildToken(?StoredObjectRoleEnum $storedObjectRoleEnum = null, ?StoredObject $storedObject = null): TokenInterface
|
||||
{
|
||||
$token = $this->prophesize(TokenInterface::class);
|
||||
|
||||
if (null !== $storedObjectRoleEnum) {
|
||||
$token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willReturn(true);
|
||||
$token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willReturn($storedObjectRoleEnum);
|
||||
} else {
|
||||
$token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willReturn(false);
|
||||
$token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willThrow(new \InvalidArgumentException());
|
||||
}
|
||||
|
||||
if (null !== $storedObject) {
|
||||
$token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willReturn(true);
|
||||
$token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willReturn($storedObject->getUuid()->toString());
|
||||
} else {
|
||||
$token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willReturn(false);
|
||||
$token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willThrow(new \InvalidArgumentException());
|
||||
}
|
||||
|
||||
return $token->reveal();
|
||||
}
|
||||
}
|
@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\DocStoreBundle\Tests\Security\Guard;
|
||||
|
||||
use Chill\DocStoreBundle\Security\Guard\DavOnUrlTokenExtractor;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class DavOnUrlTokenExtractorTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
/**
|
||||
* @dataProvider provideDataUri
|
||||
*/
|
||||
public function testExtract(string $uri, false|string $expected): void
|
||||
{
|
||||
$request = $this->prophesize(Request::class);
|
||||
$request->getRequestUri()->willReturn($uri);
|
||||
|
||||
$extractor = new DavOnUrlTokenExtractor(new NullLogger());
|
||||
|
||||
$actual = $extractor->extract($request->reveal());
|
||||
|
||||
self::assertEquals($expected, $actual);
|
||||
}
|
||||
|
||||
/**
|
||||
* @phpstan-pure
|
||||
*/
|
||||
public static function provideDataUri(): iterable
|
||||
{
|
||||
yield ['/dav/123456789/get/d07d2230-5326-11ee-8fd4-93696acf5ea1/d', '123456789'];
|
||||
yield ['/dav/123456789', '123456789'];
|
||||
yield ['/not-dav/123456978', false];
|
||||
yield ['/dav', false];
|
||||
yield ['/', false];
|
||||
}
|
||||
}
|
@ -3,6 +3,6 @@ module.exports = function(encore)
|
||||
encore.addAliases({
|
||||
ChillDocStoreAssets: __dirname + '/Resources/public'
|
||||
});
|
||||
encore.addEntry('mod_async_upload', __dirname + '/Resources/public/module/async_upload/index.js');
|
||||
encore.addEntry('mod_async_upload', __dirname + '/Resources/public/module/async_upload/index.ts');
|
||||
encore.addEntry('mod_document_action_buttons_group', __dirname + '/Resources/public/module/document_action_buttons_group/index');
|
||||
};
|
||||
|
@ -15,6 +15,11 @@ services:
|
||||
Chill\DocStoreBundle\Workflow\:
|
||||
resource: './../Workflow/'
|
||||
|
||||
Chill\DocStoreBundle\Security\:
|
||||
resource: './../Security'
|
||||
autoconfigure: true
|
||||
autowire: true
|
||||
|
||||
Chill\DocStoreBundle\Serializer\Normalizer\:
|
||||
resource: '../Serializer/Normalizer/'
|
||||
tags:
|
||||
|
@ -13,7 +13,7 @@ Update document: Modifier le document
|
||||
Edit attributes: Modifier les propriétés du document
|
||||
Existing document: Document existant
|
||||
No document to download: Aucun document à télécharger
|
||||
'Choose a document category': Choisissez une catégorie de document
|
||||
"Choose a document category": Choisissez une catégorie de document
|
||||
No document found: Aucun document trouvé
|
||||
The document is successfully registered: Le document est enregistré
|
||||
The document is successfully updated: Le document est mis à jour
|
||||
@ -36,7 +36,6 @@ Delete document ?: Supprimer le document ?
|
||||
Are you sure you want to remove this document ?: Êtes-vous sûr·e de vouloir supprimer ce document ?
|
||||
The document is successfully removed: Le document a été supprimé
|
||||
|
||||
|
||||
# dropzone upload
|
||||
File too big: Fichier trop volumineux
|
||||
Drop your file or click here: Cliquez ici ou faites glissez votre nouveau fichier dans cette zone
|
||||
@ -47,6 +46,9 @@ Are you sure you want to cancel this upload ?: Êtes-vous sûrs de vouloir annul
|
||||
Upload canceled: Téléversement annulé
|
||||
Remove existing file: Supprimer le document existant
|
||||
|
||||
stored_object:
|
||||
Insert a document: Ajouter un document
|
||||
|
||||
# ROLES
|
||||
PersonDocument: Documents
|
||||
CHILL_PERSON_DOCUMENT_CREATE: Ajouter un document
|
||||
|
@ -201,8 +201,6 @@ class EventTypeController extends AbstractController
|
||||
/**
|
||||
* Creates a form to delete a EventType entity by id.
|
||||
*
|
||||
* @param mixed $id The entity id
|
||||
*
|
||||
* @return \Symfony\Component\Form\Form The form
|
||||
*/
|
||||
private function createDeleteForm(mixed $id)
|
||||
|
@ -201,8 +201,6 @@ class RoleController extends AbstractController
|
||||
/**
|
||||
* Creates a form to delete a Role entity by id.
|
||||
*
|
||||
* @param mixed $id The entity id
|
||||
*
|
||||
* @return \Symfony\Component\Form\Form The form
|
||||
*/
|
||||
private function createDeleteForm(mixed $id)
|
||||
|
@ -201,8 +201,6 @@ class StatusController extends AbstractController
|
||||
/**
|
||||
* Creates a form to delete a Status entity by id.
|
||||
*
|
||||
* @param mixed $id The entity id
|
||||
*
|
||||
* @return \Symfony\Component\Form\Form The form
|
||||
*/
|
||||
private function createDeleteForm(mixed $id)
|
||||
|
@ -18,9 +18,9 @@ use Chill\MainBundle\Entity\Center;
|
||||
use Chill\MainBundle\Form\Type\ChillCollectionType;
|
||||
use Chill\MainBundle\Form\Type\ChillDateTimeType;
|
||||
use Chill\MainBundle\Form\Type\CommentType;
|
||||
use Chill\MainBundle\Form\Type\PickUserDynamicType;
|
||||
use Chill\MainBundle\Form\Type\PickUserLocationType;
|
||||
use Chill\MainBundle\Form\Type\ScopePickerType;
|
||||
use Chill\MainBundle\Form\Type\UserPickerType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
@ -45,14 +45,8 @@ class EventType extends AbstractType
|
||||
'class' => '',
|
||||
],
|
||||
])
|
||||
->add('moderator', UserPickerType::class, [
|
||||
'center' => $options['center'],
|
||||
'role' => $options['role'],
|
||||
'placeholder' => 'Pick a moderator',
|
||||
'attr' => [
|
||||
'class' => '',
|
||||
],
|
||||
'required' => false,
|
||||
->add('moderator', PickUserDynamicType::class, [
|
||||
'label' => 'Pick a moderator',
|
||||
])
|
||||
->add('location', PickUserLocationType::class, [
|
||||
'label' => 'event.fields.location',
|
||||
|
@ -1,10 +1,14 @@
|
||||
{% extends '@ChillEvent/layout.html.twig' %}
|
||||
{% extends '@ChillEvent/layout.html.twig' %} {% block js %}
|
||||
{{ encore_entry_script_tags("mod_async_upload") }}
|
||||
{{ encore_entry_script_tags("mod_pickentity_type") }}
|
||||
|
||||
{% block title 'Event edit'|trans %}
|
||||
{% endblock %} {% block css %}
|
||||
{{ encore_entry_link_tags("mod_async_upload") }}
|
||||
{{ encore_entry_link_tags("mod_pickentity_type") }}
|
||||
|
||||
{% block event_content -%}
|
||||
{% endblock %} {% block title 'Event edit'|trans %} {% block event_content -%}
|
||||
<div class="col-10">
|
||||
<h1>{{ 'Event edit'|trans }}</h1>
|
||||
<h1>{{ "Event edit" | trans }}</h1>
|
||||
|
||||
{{ form_start(edit_form) }}
|
||||
{{ form_errors(edit_form) }}
|
||||
@ -12,7 +16,7 @@
|
||||
{{ form_row(edit_form.name) }}
|
||||
{{ form_row(edit_form.date) }}
|
||||
|
||||
{{ form_row(edit_form.type, { 'label': 'Event type' }) }}
|
||||
{{ form_row(edit_form.type, { label: "Event type" }) }}
|
||||
{{ form_row(edit_form.moderator) }}
|
||||
{{ form_row(edit_form.location) }}
|
||||
{{ form_row(edit_form.organizationCost) }}
|
||||
@ -22,16 +26,22 @@
|
||||
|
||||
<ul class="record_actions sticky-form-buttons">
|
||||
<li class="cancel">
|
||||
<a href="{{ chill_return_path_or('chill_event_event_list') }}" class="btn btn-cancel">
|
||||
{{ 'List of events'|trans|chill_return_path_label }}
|
||||
<a
|
||||
href="{{ chill_return_path_or('chill_event_event_list') }}"
|
||||
class="btn btn-cancel"
|
||||
>
|
||||
{{ "List of events" | trans | chill_return_path_label }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
{{ form_widget(edit_form.submit, { 'attr' : { 'class' : 'btn btn-update' } }) }}
|
||||
{{
|
||||
form_widget(edit_form.submit, {
|
||||
attr: { class: "btn btn-update" }
|
||||
})
|
||||
}}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{{ form_end(edit_form) }}
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -1,41 +1,41 @@
|
||||
{% extends '@ChillEvent/layout.html.twig' %}
|
||||
{% extends '@ChillEvent/layout.html.twig' %} {% block js %}
|
||||
{{ encore_entry_script_tags("mod_async_upload") }}
|
||||
{{ encore_entry_script_tags("mod_pickentity_type") }}
|
||||
|
||||
{% block js %}
|
||||
{{ encore_entry_script_tags('mod_async_upload') }}
|
||||
{% endblock %}
|
||||
{% endblock %} {% block css %}
|
||||
{{ encore_entry_link_tags("mod_async_upload") }}
|
||||
{{ encore_entry_link_tags("mod_pickentity_type") }}
|
||||
|
||||
{% block css %}
|
||||
{{ encore_entry_link_tags('mod_async_upload') }}
|
||||
{% endblock %}
|
||||
|
||||
{% block title 'Event creation'|trans %}
|
||||
|
||||
{% block event_content -%}
|
||||
{% endblock %} {% block title 'Event creation'|trans %} {% block event_content
|
||||
-%}
|
||||
<div class="col-10">
|
||||
<h1>{{ 'Event creation'|trans }}</h1>
|
||||
<h1>{{ "Event creation" | trans }}</h1>
|
||||
|
||||
{{ form_start(form) }}
|
||||
{{ form_errors(form) }}
|
||||
{{ form_row(form.circle) }}
|
||||
{{ form_row(form.name) }}
|
||||
{{ form_row(form.date) }}
|
||||
|
||||
{{ form_row(form.type, { 'label': 'Event type' }) }}
|
||||
{{ form_row(form.type, { label: "Event type" }) }}
|
||||
{{ form_row(form.moderator) }}
|
||||
{{ form_row(form.location) }}
|
||||
{{ form_row(form.organizationCost) }}
|
||||
|
||||
{{ form_row(form.comment) }}
|
||||
{{ form_row(form.documents) }}
|
||||
|
||||
<ul class="record_actions sticky-form-buttons">
|
||||
<li class="cancel">
|
||||
<a href="{{ path('chill_event_list_most_recent') }}" class="btn btn-cancel">
|
||||
{{ 'Back to the most recent events'|trans }}
|
||||
<a
|
||||
href="{{ path('chill_event_list_most_recent') }}"
|
||||
class="btn btn-cancel"
|
||||
>
|
||||
{{ "Back to the most recent events" | trans }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
{{ form_widget(form.submit, { 'attr' : { 'class' : 'btn btn-create' } }) }}
|
||||
{{
|
||||
form_widget(form.submit, { attr: { class: "btn btn-create" } })
|
||||
}}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
|
@ -1,92 +1,126 @@
|
||||
{% extends '@ChillEvent/layout.html.twig' %}
|
||||
{% extends '@ChillEvent/layout.html.twig' %} {% block title 'Events'|trans %} {%
|
||||
block js %}
|
||||
{{ parent() }}
|
||||
{{ encore_entry_script_tags("mod_pickentity_type") }}
|
||||
{% endblock %} {% block css %}
|
||||
{{ parent() }}
|
||||
{{ encore_entry_link_tags("mod_pickentity_type") }}
|
||||
{% endblock %} {% block content %}
|
||||
<div class="col-10">
|
||||
<h1>{{ block("title") }}</h1>
|
||||
|
||||
{% block title 'Events'|trans %}
|
||||
{{ filter | chill_render_filter_order_helper }}
|
||||
|
||||
{% block js %}
|
||||
{{ parent() }}
|
||||
{{ encore_entry_script_tags('mod_pickentity_type') }}
|
||||
{% endblock %}
|
||||
|
||||
{% block css %}
|
||||
{{ parent() }}
|
||||
{{ encore_entry_link_tags('mod_pickentity_type') }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ block('title') }}</h1>
|
||||
|
||||
{{ filter|chill_render_filter_order_helper }}
|
||||
|
||||
{# {% if is_granted('CHILL_EVENT_CREATE') %} #}
|
||||
<ul class="record_actions">
|
||||
<li><a class="btn btn-create" href="{{ chill_path_add_return_path('chill_event__event_new_pickcenter') }}">{{ 'Add an event'|trans }}</a></li>
|
||||
</ul>
|
||||
{# {% endif %} #}
|
||||
{% if events|length > 0 %}
|
||||
<div class="flex-table">
|
||||
{% for e in events %}
|
||||
<div class="item-bloc">
|
||||
<div class="item-row">
|
||||
<div class="item-col">
|
||||
<div class="denomination h2">
|
||||
{{ e.name }}
|
||||
</div>
|
||||
<p>{{ e.type.name|localize_translatable_string }}</p>
|
||||
{% if e.moderator is not null %}
|
||||
<p>{{ 'Moderator'|trans }}: {{ e.moderator|chill_entity_render_box }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="item-col">
|
||||
<div class="container" style="text-align: right;">
|
||||
<p>{{ e.date|format_datetime('medium', 'medium') }}</p>
|
||||
<p>{{ 'count participations to this event'|trans({'count': e.participations|length}) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{# {% if is_granted('CHILL_EVENT_CREATE') %} #}
|
||||
<ul class="record_actions">
|
||||
<li>
|
||||
<a
|
||||
class="btn btn-create"
|
||||
href="{{
|
||||
chill_path_add_return_path(
|
||||
'chill_event__event_new_pickcenter'
|
||||
)
|
||||
}}"
|
||||
>{{ "Add an event" | trans }}</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
{# {% endif %} #} {% if events|length > 0 %}
|
||||
<div class="flex-table">
|
||||
{% for e in events %}
|
||||
<div class="item-bloc">
|
||||
<div class="item-row">
|
||||
<div class="item-col">
|
||||
<div class="denomination h2">
|
||||
{{ e.name }}
|
||||
</div>
|
||||
{% if e.participations|length > 0 %}
|
||||
<div class="item-row separator">
|
||||
<strong>{{ 'Participations'|trans }} : </strong>
|
||||
{% for part in e.participations|slice(0, 20) %}
|
||||
{% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with {
|
||||
targetEntity: { name: 'person', id: part.person.id },
|
||||
action: 'show',
|
||||
displayBadge: true,
|
||||
buttonText: part.person|chill_entity_render_string,
|
||||
isDead: part.person.deathdate is not null
|
||||
} %}
|
||||
{% endfor %}
|
||||
{% if e.participations|length > 20 %}
|
||||
{{ 'events.and_other_count_participants'|trans({'count': e.participations|length - 20}) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<p>{{ e.type.name | localize_translatable_string }}</p>
|
||||
{% if e.moderator is not null %}
|
||||
<p>
|
||||
{{ "Moderator" | trans }}:
|
||||
{{ e.moderator | chill_entity_render_box }}
|
||||
</p>
|
||||
{% endif %}
|
||||
<div class="item-row">
|
||||
<div class="item-col">
|
||||
{{ form_start(eventForms[e.id]) }}
|
||||
{{ form_widget(eventForms[e.id].person_id) }}
|
||||
{{ form_end(eventForms[e.id]) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-row separator">
|
||||
<div class="item-col item-meta">
|
||||
</div>
|
||||
<div class="item-col">
|
||||
<ul class="record_actions">
|
||||
{% if is_granted('CHILL_EVENT_UPDATE', e) %}
|
||||
<li><a href="{{ chill_path_add_return_path('chill_event__event_delete', {'event_id': e.id}) }}" class="btn btn-delete"></a></li>
|
||||
{% endif %}
|
||||
{% if is_granted('CHILL_EVENT_UPDATE', e) %}
|
||||
<li><a href="{{ chill_path_add_return_path('chill_event__event_edit', {'event_id': e.id}) }}" class="btn btn-edit"></a></li>
|
||||
{% endif %}
|
||||
<li><a href="{{ chill_path_add_return_path('chill_event__event_show', {'event_id': e.id}) }}" class="btn btn-show"></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-col">
|
||||
<div class="container" style="text-align: right">
|
||||
<p>{{ e.date|format_datetime('medium', 'medium') }}</p>
|
||||
<p>
|
||||
{{ 'count participations to this event'|trans({'count': e.participations|length}) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if e.participations|length > 0 %}
|
||||
<div class="item-row separator">
|
||||
<strong>{{ "Participations" | trans }} : </strong>
|
||||
{% for part in e.participations|slice(0, 20) %} {% include
|
||||
'@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with {
|
||||
targetEntity: { name: 'person', id: part.person.id }, action:
|
||||
'show', displayBadge: true, buttonText:
|
||||
part.person|chill_entity_render_string, isDead:
|
||||
part.person.deathdate is not null } %} {% endfor %} {% if
|
||||
e.participations|length > 20 %}
|
||||
{{ 'events.and_other_count_participants'|trans({'count': e.participations|length - 20}) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="item-row">
|
||||
<div class="item-col">
|
||||
{{ form_start(eventForms[e.id]) }}
|
||||
{{ form_widget(eventForms[e.id].person_id) }}
|
||||
{{ form_end(eventForms[e.id]) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-row separator">
|
||||
<div class="item-col item-meta"></div>
|
||||
<div class="item-col">
|
||||
<ul class="record_actions">
|
||||
{% if is_granted('CHILL_EVENT_UPDATE', e) %}
|
||||
<li>
|
||||
<a
|
||||
href="{{
|
||||
chill_path_add_return_path(
|
||||
'chill_event__event_delete',
|
||||
{ event_id: e.id }
|
||||
)
|
||||
}}"
|
||||
class="btn btn-delete"
|
||||
></a>
|
||||
</li>
|
||||
{% endif %} {% if is_granted('CHILL_EVENT_UPDATE', e) %}
|
||||
<li>
|
||||
<a
|
||||
href="{{
|
||||
chill_path_add_return_path(
|
||||
'chill_event__event_edit',
|
||||
{ event_id: e.id }
|
||||
)
|
||||
}}"
|
||||
class="btn btn-edit"
|
||||
></a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li>
|
||||
<a
|
||||
href="{{
|
||||
chill_path_add_return_path(
|
||||
'chill_event__event_show',
|
||||
{ event_id: e.id }
|
||||
)
|
||||
}}"
|
||||
class="btn btn-show"
|
||||
></a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{{ chill_pagination(pagination) }}
|
||||
{{ chill_pagination(pagination) }}
|
||||
|
||||
{% endblock %}
|
||||
|
@ -725,7 +725,6 @@ class CRUDController extends AbstractController
|
||||
* and view.
|
||||
*
|
||||
* @param string $action
|
||||
* @param mixed $entity the entity for the current request, or an array of entities
|
||||
*
|
||||
* @return string the path to the template
|
||||
*
|
||||
|
@ -442,8 +442,6 @@ final class PermissionsGroupController extends AbstractController
|
||||
|
||||
/**
|
||||
* Creates a form to delete a link to roleScope.
|
||||
*
|
||||
* @param mixed $permissionsGroup The entity id
|
||||
*/
|
||||
private function createDeleteRoleScopeForm(
|
||||
PermissionsGroup $permissionsGroup,
|
||||
|
@ -71,6 +71,7 @@ final readonly class UserExportController
|
||||
)
|
||||
);
|
||||
$csv->addFormatter(fn (array $row) => null !== ($row['absenceStart'] ?? null) ? array_merge($row, ['absenceStart' => $row['absenceStart']->format('Y-m-d')]) : $row);
|
||||
/* @phpstan-ignore-next-line as phpstan seem to ignore that we transform datetime into string */
|
||||
$csv->insertAll($users);
|
||||
|
||||
return new StreamedResponse(
|
||||
|
@ -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' => \Doctrine\Common\Collections\Criteria::ASC, 'id' => 'ASC'])]
|
||||
private Collection $steps;
|
||||
|
||||
/**
|
||||
|
@ -73,7 +73,6 @@ interface AggregatorInterface extends ModifierInterface
|
||||
*
|
||||
* @param string $key The column key, as added in the query
|
||||
* @param mixed[] $values The values from the result. if there are duplicates, those might be given twice. Example: array('FR', 'BE', 'CZ', 'FR', 'BE', 'FR')
|
||||
* @param mixed $data The data from the export's form (as defined in `buildForm`
|
||||
*
|
||||
* @return \Closure where the first argument is the value, and the function should return the label to show in the formatted file. Example : `function($countryCode) use ($countries) { return $countries[$countryCode]->getName(); }`
|
||||
*/
|
||||
|
@ -30,8 +30,6 @@ interface ExportElementValidatedInterface
|
||||
/**
|
||||
* validate the form's data and, if required, build a contraint
|
||||
* violation on the data.
|
||||
*
|
||||
* @param mixed $data the data, as returned by the user
|
||||
*/
|
||||
public function validateForm(mixed $data, ExecutionContextInterface $context);
|
||||
}
|
||||
|
@ -96,7 +96,6 @@ interface ExportInterface extends ExportElementInterface
|
||||
*
|
||||
* @param string $key The column key, as added in the query
|
||||
* @param mixed[] $values The values from the result. if there are duplicates, those might be given twice. Example: array('FR', 'BE', 'CZ', 'FR', 'BE', 'FR')
|
||||
* @param mixed $data The data from the export's form (as defined in `buildForm`)
|
||||
*
|
||||
* @return (callable(string|int|float|'_header'|null $value): string|int|\DateTimeInterface) where the first argument is the value, and the function should return the label to show in the formatted file. Example : `function($countryCode) use ($countries) { return $countries[$countryCode]->getName(); }`
|
||||
*/
|
||||
|
@ -552,7 +552,6 @@ class ExportManager
|
||||
*
|
||||
* This function check the acl.
|
||||
*
|
||||
* @param mixed $data the data under the initial 'filters' data
|
||||
* @param \Chill\MainBundle\Entity\Center[] $centers the picked centers
|
||||
*
|
||||
* @throw UnauthorizedHttpException if the user is not authorized
|
||||
@ -615,9 +614,6 @@ class ExportManager
|
||||
return $usedTypes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $data the data from the filter key of the ExportType
|
||||
*/
|
||||
private function retrieveUsedFilters(mixed $data): iterable
|
||||
{
|
||||
if (null === $data) {
|
||||
@ -634,8 +630,6 @@ class ExportManager
|
||||
/**
|
||||
* Retrieve the filter used in this export.
|
||||
*
|
||||
* @param mixed $data the data from the `filters` key of the ExportType
|
||||
*
|
||||
* @return array an array with types
|
||||
*/
|
||||
private function retrieveUsedFiltersType(mixed $data): iterable
|
||||
|
@ -35,6 +35,7 @@ class ChillCollectionType extends AbstractType
|
||||
$view->vars['allow_add'] = (int) $options['allow_add'];
|
||||
$view->vars['identifier'] = $options['identifier'];
|
||||
$view->vars['empty_collection_explain'] = $options['empty_collection_explain'];
|
||||
$view->vars['js_caller'] = $options['js_caller'];
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver)
|
||||
@ -45,6 +46,8 @@ class ChillCollectionType extends AbstractType
|
||||
'button_remove_label' => 'Remove entry',
|
||||
'identifier' => '',
|
||||
'empty_collection_explain' => '',
|
||||
'js_caller' => 'data-collection-regular',
|
||||
'delete_empty' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
|
@ -41,8 +41,6 @@ require('./img/logo-chill-outil-accompagnement_white.png');
|
||||
* Some libs are only used in a few pages, they are loaded on a case by case basis
|
||||
*/
|
||||
|
||||
require('../lib/collection/index.js');
|
||||
|
||||
require('../lib/breadcrumb/index.js');
|
||||
require('../lib/download-report/index.js');
|
||||
require('../lib/select_interactive_loading/index.js');
|
||||
|
@ -1,120 +0,0 @@
|
||||
/**
|
||||
* Javascript file which handle ChillCollectionType
|
||||
*
|
||||
* Two events are emitted by this module, both on window and on collection / ul.
|
||||
*
|
||||
* Collection (an UL element) and entry (a li element) are associated with those
|
||||
* events.
|
||||
*
|
||||
* ```
|
||||
* window.addEventListener('collection-add-entry', function(e) {
|
||||
* console.log(e.detail.collection);
|
||||
* console.log(e.detail.entry);
|
||||
* });
|
||||
*
|
||||
* window.addEventListener('collection-remove-entry', function(e) {
|
||||
* console.log(e.detail.collection);
|
||||
* console.log(e.detail.entry);
|
||||
* });
|
||||
*
|
||||
* collection.addEventListener('collection-add-entry', function(e) {
|
||||
* console.log(e.detail.collection);
|
||||
* console.log(e.detail.entry);
|
||||
* });
|
||||
*
|
||||
* collection.addEventListener('collection-remove-entry', function(e) {
|
||||
* console.log(e.detail.collection);
|
||||
* console.log(e.detail.entry);
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
require('./collection.scss');
|
||||
|
||||
class CollectionEvent {
|
||||
constructor(collection, entry) {
|
||||
this.collection = collection;
|
||||
this.entry = entry;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {type} button
|
||||
* @returns {handleAdd}
|
||||
*/
|
||||
var handleAdd = function(button) {
|
||||
var
|
||||
form_name = button.dataset.collectionAddTarget,
|
||||
prototype = button.dataset.formPrototype,
|
||||
collection = document.querySelector('ul[data-collection-name="'+form_name+'"]'),
|
||||
empty_explain = collection.querySelector('li[data-collection-empty-explain]'),
|
||||
entry = document.createElement('li'),
|
||||
event = new CustomEvent('collection-add-entry', { detail: { collection: collection, entry: entry } }),
|
||||
counter = collection.childNodes.length + parseInt(Math.random() * 1000000)
|
||||
content
|
||||
;
|
||||
content = prototype.replace(new RegExp('__name__', 'g'), counter);
|
||||
entry.innerHTML = content;
|
||||
entry.classList.add('entry');
|
||||
initializeRemove(collection, entry);
|
||||
if (empty_explain !== null) {
|
||||
empty_explain.remove();
|
||||
}
|
||||
collection.appendChild(entry);
|
||||
|
||||
collection.dispatchEvent(event);
|
||||
window.dispatchEvent(event);
|
||||
};
|
||||
|
||||
var initializeRemove = function(collection, entry) {
|
||||
var
|
||||
button = document.createElement('button'),
|
||||
isPersisted = entry.dataset.collectionIsPersisted,
|
||||
content = collection.dataset.collectionButtonRemoveLabel,
|
||||
allowDelete = collection.dataset.collectionAllowDelete,
|
||||
event = new CustomEvent('collection-remove-entry', { detail: { collection: collection, entry: entry } })
|
||||
;
|
||||
|
||||
if (allowDelete === '0' && isPersisted === '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
button.classList.add('btn', 'btn-delete', 'remove-entry');
|
||||
button.textContent = content;
|
||||
|
||||
button.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
entry.remove();
|
||||
collection.dispatchEvent(event);
|
||||
window.dispatchEvent(event);
|
||||
});
|
||||
|
||||
entry.appendChild(button);
|
||||
};
|
||||
|
||||
window.addEventListener('load', function() {
|
||||
var
|
||||
addButtons = document.querySelectorAll("button[data-collection-add-target]"),
|
||||
collections = document.querySelectorAll("ul[data-collection-name]")
|
||||
;
|
||||
|
||||
for (let i = 0; i < addButtons.length; i ++) {
|
||||
let addButton = addButtons[i];
|
||||
addButton.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
handleAdd(e.target);
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < collections.length; i ++) {
|
||||
let entries = collections[i].querySelectorAll(':scope > li');
|
||||
|
||||
for (let j = 0; j < entries.length; j ++) {
|
||||
console.log(entries[j].dataset);
|
||||
if (entries[j].dataset.collectionEmptyExplain === "1") {
|
||||
continue;
|
||||
}
|
||||
initializeRemove(collections[i], entries[j]);
|
||||
}
|
||||
}
|
||||
});
|
@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Javascript file which handle ChillCollectionType
|
||||
*
|
||||
* Two events are emitted by this module, both on window and on collection / ul.
|
||||
*
|
||||
* Collection (an UL element) and entry (a li element) are associated with those
|
||||
* events.
|
||||
*
|
||||
* ```
|
||||
* window.addEventListener('collection-add-entry', function(e) {
|
||||
* console.log(e.detail.collection);
|
||||
* console.log(e.detail.entry);
|
||||
* });
|
||||
*
|
||||
* window.addEventListener('collection-remove-entry', function(e) {
|
||||
* console.log(e.detail.collection);
|
||||
* console.log(e.detail.entry);
|
||||
* });
|
||||
*
|
||||
* collection.addEventListener('collection-add-entry', function(e) {
|
||||
* console.log(e.detail.collection);
|
||||
* console.log(e.detail.entry);
|
||||
* });
|
||||
*
|
||||
* collection.addEventListener('collection-remove-entry', function(e) {
|
||||
* console.log(e.detail.collection);
|
||||
* console.log(e.detail.entry);
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
import './collection.scss';
|
||||
|
||||
export class CollectionEventPayload {
|
||||
collection: HTMLUListElement;
|
||||
entry: HTMLLIElement;
|
||||
|
||||
constructor(collection: HTMLUListElement, entry: HTMLLIElement) {
|
||||
this.collection = collection;
|
||||
this.entry = entry;
|
||||
}
|
||||
}
|
||||
|
||||
export const handleAdd = (button: any): void => {
|
||||
let
|
||||
form_name = button.dataset.collectionAddTarget,
|
||||
prototype = button.dataset.formPrototype,
|
||||
collection: HTMLUListElement | null = document.querySelector('ul[data-collection-name="' + form_name + '"]');
|
||||
|
||||
if (collection === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let
|
||||
empty_explain: HTMLLIElement | null = collection.querySelector('li[data-collection-empty-explain]'),
|
||||
entry = document.createElement('li'),
|
||||
counter = collection.childNodes.length + 1,
|
||||
content = prototype.replace(new RegExp('__name__', 'g'), counter.toString()),
|
||||
event = new CustomEvent('collection-add-entry', {detail: new CollectionEventPayload(collection, entry)});
|
||||
|
||||
entry.innerHTML = content;
|
||||
entry.classList.add('entry');
|
||||
|
||||
if ("dataCollectionRegular" in collection.dataset) {
|
||||
initializeRemove(collection, entry);
|
||||
if (empty_explain !== null) {
|
||||
empty_explain.remove();
|
||||
}
|
||||
}
|
||||
|
||||
collection.appendChild(entry);
|
||||
collection.dispatchEvent(event);
|
||||
window.dispatchEvent(event);
|
||||
};
|
||||
|
||||
const initializeRemove = (collection: HTMLUListElement, entry: HTMLLIElement): void => {
|
||||
const button = buildRemoveButton(collection, entry);
|
||||
if (null === button) {
|
||||
return;
|
||||
}
|
||||
entry.appendChild(button);
|
||||
};
|
||||
|
||||
export const buildRemoveButton = (collection: HTMLUListElement, entry: HTMLLIElement): HTMLButtonElement|null => {
|
||||
|
||||
let
|
||||
button = document.createElement('button'),
|
||||
isPersisted = entry.dataset.collectionIsPersisted || '',
|
||||
content = collection.dataset.collectionButtonRemoveLabel || '',
|
||||
allowDelete = collection.dataset.collectionAllowDelete || '',
|
||||
event = new CustomEvent('collection-remove-entry', {detail: new CollectionEventPayload(collection, entry)});
|
||||
|
||||
if (allowDelete === '0' && isPersisted === '1') {
|
||||
return null;
|
||||
}
|
||||
button.classList.add('btn', 'btn-delete', 'remove-entry');
|
||||
button.textContent = content;
|
||||
button.addEventListener('click', (e: Event) => {
|
||||
e.preventDefault();
|
||||
entry.remove();
|
||||
collection.dispatchEvent(event);
|
||||
window.dispatchEvent(event);
|
||||
});
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
let
|
||||
addButtons: NodeListOf<HTMLButtonElement> = document.querySelectorAll("button[data-collection-add-target]"),
|
||||
collections: NodeListOf<HTMLUListElement> = document.querySelectorAll("ul[data-collection-regular]");
|
||||
|
||||
for (let i = 0; i < addButtons.length; i++) {
|
||||
let addButton = addButtons[i];
|
||||
addButton.addEventListener('click', (e: Event) => {
|
||||
e.preventDefault();
|
||||
handleAdd(e.target);
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < collections.length; i++) {
|
||||
let entries: NodeListOf<HTMLLIElement> = collections[i].querySelectorAll(':scope > li');
|
||||
for (let j = 0; j < entries.length; j++) {
|
||||
if (entries[j].dataset.collectionEmptyExplain === "1") {
|
||||
continue;
|
||||
}
|
||||
initializeRemove(collections[i], entries[j]);
|
||||
}
|
||||
}
|
||||
});
|
@ -162,6 +162,7 @@
|
||||
{% block chill_collection_widget %}
|
||||
<div class="chill-collection">
|
||||
<ul class="list-entry"
|
||||
{{ form.vars.js_caller }}="{{ form.vars.js_caller }}"
|
||||
data-collection-name="{{ form.vars.name|escape('html_attr') }}"
|
||||
data-collection-identifier="{{ form.vars.identifier|escape('html_attr') }}"
|
||||
data-collection-button-remove-label="{{ form.vars.button_remove_label|trans|e }}"
|
||||
@ -176,7 +177,7 @@
|
||||
</li>
|
||||
{% else %}
|
||||
<li data-collection-empty-explain="1">
|
||||
<span class="chill-no-data-statement">{{ form.vars.empty_collection_explain|default('No item')|trans }}</span>
|
||||
<span class="chill-no-data-statement">{{ form.vars.empty_collection_explain|default('No entities')|trans }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
@ -14,6 +14,7 @@
|
||||
window.addaddress = {{ add_address|json_encode|raw }};
|
||||
</script>
|
||||
|
||||
{{ encore_entry_link_tags('mod_collection') }}
|
||||
{{ encore_entry_link_tags('mod_bootstrap') }}
|
||||
{{ encore_entry_link_tags('mod_forkawesome') }}
|
||||
{{ encore_entry_link_tags('mod_ckeditor5') }}
|
||||
@ -109,6 +110,7 @@
|
||||
|
||||
{{ include('@ChillMain/Layout/_footer.html.twig') }}
|
||||
|
||||
{{ encore_entry_script_tags('mod_collection') }}
|
||||
{{ encore_entry_script_tags('mod_bootstrap') }}
|
||||
{{ encore_entry_script_tags('mod_forkawesome') }}
|
||||
{{ encore_entry_script_tags('mod_ckeditor5') }}
|
||||
|
@ -257,10 +257,10 @@ class SearchProvider
|
||||
$this->mustBeExtracted[] = $matches[0][$key];
|
||||
// strip parenthesis
|
||||
if (
|
||||
'"' === mb_substr((string) $match, 0, 1)
|
||||
&& '"' === mb_substr((string) $match, mb_strlen((string) $match) - 1)
|
||||
'"' === mb_substr($match, 0, 1)
|
||||
&& '"' === mb_substr($match, mb_strlen($match) - 1)
|
||||
) {
|
||||
$match = trim(mb_substr((string) $match, 1, mb_strlen((string) $match) - 2));
|
||||
$match = trim(mb_substr($match, 1, mb_strlen($match) - 2));
|
||||
}
|
||||
$terms[$matches[1][$key]] = $match;
|
||||
}
|
||||
|
@ -198,8 +198,6 @@ class AuthorizationHelper implements AuthorizationHelperInterface
|
||||
* if the entity implements Chill\MainBundle\Entity\HasScopeInterface,
|
||||
* the scope is taken into account.
|
||||
*
|
||||
* @param mixed $entity the entity may also implement HasScopeInterface
|
||||
*
|
||||
* @return bool true if the user has access
|
||||
*/
|
||||
public function userHasAccess(User $user, mixed $entity, string $attribute)
|
||||
|
@ -62,6 +62,7 @@ module.exports = function(encore, entries)
|
||||
buildCKEditor(encore);
|
||||
|
||||
// Modules entrypoints
|
||||
encore.addEntry('mod_collection', __dirname + '/Resources/public/module/collection/index.ts');
|
||||
encore.addEntry('mod_forkawesome', __dirname + '/Resources/public/module/forkawesome/index.js');
|
||||
encore.addEntry('mod_bootstrap', __dirname + '/Resources/public/module/bootstrap/index.js');
|
||||
encore.addEntry('mod_ckeditor5', __dirname + '/Resources/public/module/ckeditor5/index.js');
|
||||
|
@ -11,6 +11,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Chill\PersonBundle\Controller;
|
||||
|
||||
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectNormalizer;
|
||||
use Chill\MainBundle\Pagination\PaginatorFactory;
|
||||
use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
|
||||
use Chill\MainBundle\Templating\Listing\FilterOrderHelperFactoryInterface;
|
||||
@ -115,7 +116,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(),
|
||||
|
@ -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 (0 === $i) {
|
||||
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();
|
||||
|
@ -41,10 +41,10 @@ final readonly class GeographicalUnitStatAggregator implements AggregatorInterfa
|
||||
|
||||
$qb->andWhere(
|
||||
$qb->expr()->andX(
|
||||
'acp_geog_agg_location_history.startDate <= :acp_geog_aggregator_date',
|
||||
'acp_geog_agg_location_history.startDate <= LEAST(:acp_geog_aggregator_date, acp.closingDate)',
|
||||
$qb->expr()->orX(
|
||||
'acp_geog_agg_location_history.endDate IS NULL',
|
||||
'acp_geog_agg_location_history.endDate > :acp_geog_aggregator_date'
|
||||
'acp_geog_agg_location_history.endDate > LEAST(:acp_geog_aggregator_date, acp.closingDate)'
|
||||
)
|
||||
)
|
||||
);
|
||||
@ -56,9 +56,9 @@ final readonly class GeographicalUnitStatAggregator implements AggregatorInterfa
|
||||
Join::WITH,
|
||||
$qb->expr()->andX(
|
||||
'IDENTITY(acp_geog_agg_address_person_location.person) = IDENTITY(acp_geog_agg_location_history.personLocation)',
|
||||
'acp_geog_agg_address_person_location.validFrom <= :acp_geog_aggregator_date',
|
||||
'acp_geog_agg_address_person_location.validFrom <= LEAST(:acp_geog_aggregator_date, acp.closingDate)',
|
||||
$qb->expr()->orX(
|
||||
'acp_geog_agg_address_person_location.validTo > :acp_geog_aggregator_date',
|
||||
'acp_geog_agg_address_person_location.validTo > LEAST(:acp_geog_aggregator_date, acp.closingDate)',
|
||||
$qb->expr()->isNull('acp_geog_agg_address_person_location.validTo')
|
||||
)
|
||||
)
|
||||
|
@ -33,7 +33,12 @@ use Symfony\Component\Form\FormBuilderInterface;
|
||||
*/
|
||||
class GeographicalUnitStatFilter implements FilterInterface
|
||||
{
|
||||
public function __construct(private readonly GeographicalUnitRepositoryInterface $geographicalUnitRepository, private readonly GeographicalUnitLayerRepositoryInterface $geographicalUnitLayerRepository, private readonly TranslatableStringHelperInterface $translatableStringHelper, private readonly RollingDateConverterInterface $rollingDateConverter) {}
|
||||
public function __construct(
|
||||
private readonly GeographicalUnitRepositoryInterface $geographicalUnitRepository,
|
||||
private readonly GeographicalUnitLayerRepositoryInterface $geographicalUnitLayerRepository,
|
||||
private readonly TranslatableStringHelperInterface $translatableStringHelper,
|
||||
private readonly RollingDateConverterInterface $rollingDateConverter
|
||||
) {}
|
||||
|
||||
public function addRole(): ?string
|
||||
{
|
||||
@ -46,18 +51,19 @@ class GeographicalUnitStatFilter implements FilterInterface
|
||||
'SELECT
|
||||
1
|
||||
FROM '.AccompanyingPeriod\AccompanyingPeriodLocationHistory::class.' acp_geog_filter_location_history
|
||||
JOIN acp_geog_filter_location_history.period acp_geog_filter_location_history_period
|
||||
LEFT JOIN '.PersonHouseholdAddress::class.' acp_geog_filter_address_person_location
|
||||
WITH IDENTITY(acp_geog_filter_location_history.personLocation) = IDENTITY(acp_geog_filter_address_person_location.person)
|
||||
AND
|
||||
(acp_geog_filter_address_person_location.validFrom < LEAST(:acp_geog_filter_date, acp_geog_filter_location_history_period.closingDate) AND (
|
||||
acp_geog_filter_address_person_location.validTo IS NULL OR acp_geog_filter_address_person_location.validTo > LEAST(:acp_geog_filter_date, acp_geog_filter_location_history_period.closingDate)
|
||||
))
|
||||
LEFT JOIN '.Address::class.' acp_geog_filter_address
|
||||
WITH COALESCE(IDENTITY(acp_geog_filter_address_person_location.address), IDENTITY(acp_geog_filter_location_history.addressLocation)) = acp_geog_filter_address.id
|
||||
LEFT JOIN acp_geog_filter_address.geographicalUnits acp_geog_filter_units
|
||||
WHERE
|
||||
(acp_geog_filter_location_history.startDate <= :acp_geog_filter_date AND (
|
||||
acp_geog_filter_location_history.endDate IS NULL OR acp_geog_filter_location_history.endDate > :acp_geog_filter_date
|
||||
))
|
||||
AND
|
||||
(acp_geog_filter_address_person_location.validFrom < :acp_geog_filter_date AND (
|
||||
acp_geog_filter_address_person_location.validTo IS NULL OR acp_geog_filter_address_person_location.validTo > :acp_geog_filter_date
|
||||
(acp_geog_filter_location_history.startDate <= LEAST(:acp_geog_filter_date, acp_geog_filter_location_history_period.closingDate) AND (
|
||||
acp_geog_filter_location_history.endDate IS NULL OR acp_geog_filter_location_history.endDate > LEAST(:acp_geog_filter_date, acp_geog_filter_location_history_period.closingDate)
|
||||
))
|
||||
AND acp_geog_filter_units IN (:acp_geog_filter_units)
|
||||
AND acp_geog_filter_location_history.period = acp.id
|
||||
|
@ -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"
|
||||
></document-action-buttons-group>
|
||||
</li>
|
||||
|
@ -19,7 +19,6 @@
|
||||
<h1 style="margin-bottom: 2rem;">{{ 'Add a person resource'|trans }}</h1>
|
||||
|
||||
<div class="col-md col-xxl">
|
||||
<h3 style="margin-bottom: 2rem;">{{ 'Add a person resource'|trans }}</h3>
|
||||
{% include "@ChillPerson/PersonResource/form.html.twig" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -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
|
||||
|
@ -598,10 +598,7 @@ class ThirdParty implements TrackCreationInterface, TrackUpdateInterface, \Strin
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function setCenters(Collection $centers)
|
||||
public function setCenters(Collection $centers): self
|
||||
{
|
||||
$this->centers = $centers;
|
||||
|
||||
|
16
utils/http/docstore/dav.http
Normal file
16
utils/http/docstore/dav.http
Normal file
@ -0,0 +1,16 @@
|
||||
|
||||
### Get a document
|
||||
GET http://{{ host }}/dav/get/{{ uuid }}/d
|
||||
|
||||
### OPTIONS on a document
|
||||
OPTIONS http://{{ host }}/dav/get/{{ uuid }}/d
|
||||
|
||||
### HEAD ona document
|
||||
HEAD http://{{ host }}/dav/get/{{ uuid }}/d
|
||||
|
||||
### Get the directory of a document
|
||||
GET http://{{ host }}/dav/get/{{ uuid }}/
|
||||
|
||||
### Option the directory of a document
|
||||
OPTIONS http://{{ host }}/dav/get/{{ uuid }}/
|
||||
|
6
utils/http/docstore/http-client.env.json
Normal file
6
utils/http/docstore/http-client.env.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"dev": {
|
||||
"host": "localhost:8001",
|
||||
"uuid": "0bf3b8e7-b25b-4227-aae9-a3263af0766f"
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user