mirror of
				https://gitlab.com/Chill-Projet/chill-bundles.git
				synced 2025-10-31 17:28:23 +00:00 
			
		
		
		
	Compare commits
	
		
			242 Commits
		
	
	
		
			v3.1.1
			...
			295-cancel
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| fad7bdf235 | |||
| 8521cea46c | |||
| 4ead7ba761 | |||
| 9721b166eb | |||
| 1b21cd6c33 | |||
| 97860a9487 | |||
| 2fb46c65c2 | |||
| f4356ac249 | |||
| d152efe084 | |||
| ee9530d03f | |||
| b97eabf0d2 | |||
| 2e69d2df90 | |||
| cb446edd18 | |||
| 5d84e997c1 | |||
| 35199b6993 | |||
| dab68fb409 | |||
| 6001bb6447 | |||
| 29fec50515 | |||
| 34edb02cd0 | |||
| 860ae5cedf | |||
| bf056046ab | |||
| 4d73f9b81a | |||
| dd159f4379 | |||
| 49ad25b4c8 | |||
| ad94310981 | |||
| e8f09b507f | |||
| e29e1db6ed | |||
| 8c4f342ca1 | |||
| 745a29f742 | |||
| 41ffc470a0 | |||
| 46b31ae1ea | |||
| 8c5e94e295 | |||
| 9c8a84cdbd | |||
| a82b99aecc | |||
| deb4bda16e | |||
| c1e5f4a57e | |||
| 6fc5a10dc4 | |||
| 18abc84e68 | |||
| e85c31826f | |||
| d119ba49f7 | |||
| c21de777fd | |||
| 15eaf648df | |||
| 42471269db | |||
| 9475a708c3 | |||
| bf1af1aaad | |||
| 8ea87053f0 | |||
| 3c9ee41b3b | |||
| d0031e82e8 | |||
| 20f2bc6c35 | |||
| 71d3aa3969 | |||
| ce781a5b58 | |||
| 2dd275a074 | |||
| 5f5d4b8f06 | |||
| cc8214d52c | |||
| 0c797c2997 | |||
| ee6edba206 | |||
| 3e6d764b9b | |||
| 3e5a558cdf | |||
| 0e6b7d76a4 | |||
| b2042bd1e4 | |||
| 6e9f111fd9 | |||
| 313fb9ffdf | |||
| 063bc2857f | |||
| 615629d1b4 | |||
| 667e144681 | |||
| e17203ca3a | |||
| c6a6d76790 | |||
| 3d49c959e0 | |||
|  | 86896a12e6 | ||
|  | 3a959b7044 | ||
|  | f8d95384ea | ||
| b6edbb3eed | |||
| 00cc3b7806 | |||
| 7ab52ff09e | |||
| 2d82c1e105 | |||
| e477a49c92 | |||
| 0db2652f08 | |||
| c38f7c1179 | |||
| 67d24cb951 | |||
| cb90261309 | |||
| 2feea24c41 | |||
| 1b16d4fe3b | |||
| ce5659219a | |||
| 5fefe09a39 | |||
| e21db73b84 | |||
| 3978ea9a47 | |||
| 4fbb7811ac | |||
| 2b7ea4178b | |||
| 8a374864fa | |||
| bb848746d5 | |||
| 564813ef3d | |||
| 5fed42a623 | |||
| b19dd4fc11 | |||
| 44226d6f7f | |||
| d75607a1d2 | |||
| bf66af0f25 | |||
| 15f3e474a0 | |||
| 5623cf946e | |||
| 0a6f3a99da | |||
| 50bd9f32c3 | |||
| 1396304af5 | |||
| c33e4adeec | |||
| 7351a35c42 | |||
| 72e3325626 | |||
| 0a46b5304d | |||
|  | e57d52d00e | ||
| 64e527672d | |||
| 123168a5ee | |||
| 3836d0dc9b | |||
| 51ab4bef38 | |||
|  | 567ca8a26f | ||
|  | 111305d09c | ||
|  | 67395f52b5 | ||
|  | 421226c0dc | ||
|  | 77da2c1ac6 | ||
|  | 39d3ba2f40 | ||
|  | fb62e54d63 | ||
|  | c968d6c541 | ||
|  | c428e6665f | ||
|  | 5b7e3f0336 | ||
|  | 0c8ef37860 | ||
|  | 794c479b9e | ||
|  | 1bee3114ac | ||
|  | 1344b65dd4 | ||
|  | 68dcf4dd28 | ||
|  | b0a8fd54a8 | ||
|  | 0f589ec57e | ||
|  | 2d4fc45a0c | ||
|  | c80f23f0db | ||
|  | c950400fe2 | ||
|  | 21c1e77d36 | ||
|  | bbfd0caf10 | ||
|  | 9192883217 | ||
| 3836622d27 | |||
| cc2c4be1b0 | |||
| 873940786f | |||
| db73dcffc7 | |||
| 8aec69f0f9 | |||
| 9f88eef249 | |||
| d689ce9aef | |||
| d5e4991982 | |||
| ca68b58246 | |||
| 747a1de321 | |||
| 9e92ede16f | |||
| 31f842471a | |||
| 7d0f9175be | |||
| e83307ca6d | |||
| 215eba41b7 | |||
| 52a3d1be1b | |||
| 8d543be5cc | |||
| 0474b25859 | |||
| db94af0958 | |||
| 3e8805bdda | |||
| a887602f4f | |||
| c1cf27c42d | |||
| fe6b4848e6 | |||
| b5af9f7b63 | |||
| 7f3de62b2c | |||
| cfa51cd659 | |||
| facc4affed | |||
| f9122341d1 | |||
| 7dd5f542a6 | |||
| 3b80d9a93b | |||
| 790576863f | |||
| 25e89571f7 | |||
| 435836c7d1 | |||
| af4db22184 | |||
| 2adc8b3bf6 | |||
| 21b79c1981 | |||
| 428494ca1f | |||
| 5d57ec8a3b | |||
| 719fabc878 | |||
| e9a9a3430f | |||
| c648a560cc | |||
| 3d7c8596ee | |||
| 345f379650 | |||
| 3262a1dd02 | |||
| a9f4f8c973 | |||
| c19c597ba0 | |||
| 03800029c9 | |||
| 064dfc5a56 | |||
| ba95687f46 | |||
| a309cc0774 | |||
| 5b0babb9b0 | |||
| ac2f314395 | |||
| 8c92d11722 | |||
| 3db4fff80d | |||
| fb743b522d | |||
| d1653a074b | |||
| 254122d125 | |||
| c9d2e37cee | |||
| c9d54a5fea | |||
| 86c862e69d | |||
| 9bc6fe6aff | |||
| 18a03fd740 | |||
| e9d4b9e2ab | |||
| efaad1981d | |||
| 742f2540f6 | |||
| bab6528ed6 | |||
| a25f2c7539 | |||
| c06e76a0ee | |||
| 4607c36b57 | |||
| 7c03a25f1a | |||
| cce04ee490 | |||
| e54633d14d | |||
| d9892f6822 | |||
| f75c7a0232 | |||
| 062afd6695 | |||
| 830dace1ba | |||
| 2ce9810243 | |||
| 26b3d84d62 | |||
| 30078db841 | |||
| aaac80be84 | |||
| a0fead48e1 | |||
| 2d09efb2e0 | |||
| 3a87513a11 | |||
| d3956319ca | |||
| bd36735cb1 | |||
| 1310d53589 | |||
| 610239930b | |||
| b65e2c62c4 | |||
| 89f5231649 | |||
| 73797b98f6 | |||
| 3d40db7493 | |||
| 760d65b972 | |||
| d26fa6bde6 | |||
| 427f232ab8 | |||
| 99818c211d | |||
| a9f0059743 | |||
| 5bc542a567 | |||
| 482f279dc5 | |||
| e0828b1f0f | |||
| e015f71bb0 | |||
| 04a48f22ad | |||
| ad4fe80240 | |||
| 4b82e67952 | |||
| c8ccce83fd | |||
| e9a9262fae | |||
| d9e37d0958 | |||
| 65c41e6fa9 | |||
| 7923b5a1ef | |||
| 4a229ebf6b | 
							
								
								
									
										8
									
								
								.changes/unreleased/Feature-20240614-153236.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								.changes/unreleased/Feature-20240614-153236.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| kind: Feature | ||||
| body: |- | ||||
|   Electronic signature | ||||
|  | ||||
|   Implementation of the electronic signature for documents within chill. | ||||
| time: 2024-06-14T15:32:36.875891692+02:00 | ||||
| custom: | ||||
|   Issue: "" | ||||
							
								
								
									
										7
									
								
								.changes/unreleased/Feature-20240614-153537.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								.changes/unreleased/Feature-20240614-153537.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| kind: Feature | ||||
| body: The behavoir of the voters for stored objects is adjusted so as to limit edit | ||||
|   and delete possibilities to users related to the activity, social action or workflow | ||||
|   entity. | ||||
| time: 2024-06-14T15:35:37.582159301+02:00 | ||||
| custom: | ||||
|   Issue: "286" | ||||
							
								
								
									
										5
									
								
								.changes/unreleased/Feature-20240718-151233.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								.changes/unreleased/Feature-20240718-151233.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| kind: Feature | ||||
| body: Metadata form added for person signatures | ||||
| time: 2024-07-18T15:12:33.8134266+02:00 | ||||
| custom: | ||||
|   Issue: "288" | ||||
| @@ -1,30 +1,11 @@ | ||||
| ## v2.23.0 - 2024-07-23 & 2024-07-19 | ||||
| ## v2.23.0 - 2024-07-23 | ||||
| ### Feature | ||||
| * ([#221](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/221)) [DX] move async-upload-bundle features into chill-bundles | ||||
| * Add job bundle (module emploi) | ||||
| * ([#221](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/221)) [DX] move async-upload-bundle features into chill-bundles  | ||||
| * Add job bundle (module emploi)  | ||||
| * Upgrade import of address list to the last version of compiled addresses of belgian-best-address | ||||
|  | ||||
|   | ||||
| * Upgrade CKEditor and refactor configuration with use of typescript | ||||
|  | ||||
| * ([#123](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/123)) Add a button to duplicate calendar ranges from a week to another one | ||||
| * [admin] filter users by active / inactive in the admin user's list | ||||
| * ([#273](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/273)) Add the possibility to mark all notifications as read | ||||
|  | ||||
|  | ||||
| * Handle duplicate reference id in the import of reference addresses | ||||
| * Do not update the "createdAt" column when importing postal code which does not change | ||||
| * Display filename on file upload within the UI interface | ||||
|   | ||||
| ### Fixed | ||||
| * Fix resolving of centers for an household, which will fix in turn the access control | ||||
| * Resolved type hinting error in activity list export | ||||
| * ([#271](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/271)) Take into account the acp closing date in the acp works date filter | ||||
|  | ||||
| ### Traduction française des principaux changements | ||||
| - Ajout d'un bouton pour dupliquer les périodes de disponibilités d'une semaine à une autre; | ||||
| - dans l'interface d'administration, filtre sur les utilisateurs actifs. Par défaut, seul les utilisateurs | ||||
|   actifs sont affichés; | ||||
| - Nouveau bouton pour indiquer toutes les notifications comme lues; | ||||
| - Améliorations sur l'import des adresses et des codes postaux; | ||||
| - Affiche le nom du fichier déposé quand on téléverse un fichier depuis le poste de travail local; | ||||
| - Agrandit l'icône du type de fichier dans l'interface de dépôt de fichier; | ||||
| - correction: tient compte de la date de fermeture du parcours dans les filtres sur les actions d'accompagnement. | ||||
| * Fix resolving of centers for an household, which will fix in turn the access control  | ||||
| * Resolved type hinting error in activity list export   | ||||
|   | ||||
| @@ -1,3 +0,0 @@ | ||||
| ## v2.24.0 - 2024-09-11 | ||||
| ### Feature | ||||
| * ([#306](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/306)) When a document is converted or downloaded in the browser, this document is removed from the browser memory after 45s. Future click on the button re-download the document.  | ||||
| @@ -1,3 +0,0 @@ | ||||
| ## v3.1.0 - 2024-08-30 | ||||
| ### Feature | ||||
| * Add export aggregator to aggregate activities by household + filter persons that are not part of an accompanyingperiod during a certain timeframe.  | ||||
| @@ -1,6 +0,0 @@ | ||||
| ## v3.1.1 - 2024-10-01 | ||||
| ### Fixed | ||||
| * ([#308](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/308)) Show only the current referrer in the page "show" for an accompanying period workf  | ||||
| * ([#309](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/309)) Correctly compute the grouping by referrer aggregator | ||||
|   | ||||
| * Fixed typing of custom field long choice and custom field group  | ||||
| @@ -122,7 +122,7 @@ unit_tests: | ||||
|         - php tests/console chill:db:sync-views --env=test | ||||
|         - php -d memory_limit=2G tests/console cache:clear --env=test | ||||
|         - php -d memory_limit=3G tests/console doctrine:fixtures:load -n --env=test | ||||
|         - php -d memory_limit=4G bin/phpunit --colors=never --exclude-group dbIntensive | ||||
|         - php -d memory_limit=4G bin/phpunit --colors=never --exclude-group dbIntensive,openstack-integration,collabora-integration | ||||
|     artifacts: | ||||
|         expire_in: 1 day | ||||
|         paths: | ||||
|   | ||||
							
								
								
									
										48
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						
									
										48
									
								
								CHANGELOG.md
									
									
									
									
									
								
							| @@ -6,57 +6,23 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), | ||||
| and is generated by [Changie](https://github.com/miniscruff/changie). | ||||
|  | ||||
|  | ||||
| ## v3.1.1 - 2024-10-01 | ||||
| ### Fixed | ||||
| * ([#308](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/308)) Show only the current referrer in the page "show" for an accompanying period workf  | ||||
| * ([#309](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/309)) Correctly compute the grouping by referrer aggregator | ||||
|   | ||||
| * Fixed typing of custom field long choice and custom field group  | ||||
|  | ||||
| ## v3.1.0 - 2024-08-30 | ||||
| ### Feature | ||||
| * Add export aggregator to aggregate activities by household + filter persons that are not part of an accompanyingperiod during a certain timeframe.  | ||||
|  | ||||
| ## v3.0.0 - 2024-08-26 | ||||
| ### Fixed | ||||
| * Fix delete action for accompanying periods in draft state  | ||||
| * Fix connection to azure when making an calendar event in chill  | ||||
| * CollectionType js fixes for remove button and adding multiple entries  | ||||
|  | ||||
| ## v2.24.0 - 2024-09-11 | ||||
| ## v2.23.0 - 2024-07-23 | ||||
| ### Feature | ||||
| * ([#306](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/306)) When a document is converted or downloaded in the browser, this document is removed from the browser memory after 45s. Future click on the button re-download the document.  | ||||
|  | ||||
| ## v2.23.0 - 2024-07-23 & 2024-07-19 | ||||
| ### Feature | ||||
| * ([#221](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/221)) [DX] move async-upload-bundle features into chill-bundles | ||||
| * Add job bundle (module emploi) | ||||
| * ([#221](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/221)) [DX] move async-upload-bundle features into chill-bundles  | ||||
| * Add job bundle (module emploi)  | ||||
| * Upgrade import of address list to the last version of compiled addresses of belgian-best-address | ||||
|  | ||||
|   | ||||
| * Upgrade CKEditor and refactor configuration with use of typescript | ||||
|  | ||||
| * ([#123](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/123)) Add a button to duplicate calendar ranges from a week to another one | ||||
| * [admin] filter users by active / inactive in the admin user's list | ||||
| * ([#273](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/273)) Add the possibility to mark all notifications as read | ||||
|  | ||||
|  | ||||
| * Handle duplicate reference id in the import of reference addresses | ||||
| * Do not update the "createdAt" column when importing postal code which does not change | ||||
| * Display filename on file upload within the UI interface | ||||
|   | ||||
| ### Fixed | ||||
| * Fix resolving of centers for an household, which will fix in turn the access control | ||||
| * Resolved type hinting error in activity list export | ||||
| * ([#271](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/271)) Take into account the acp closing date in the acp works date filter | ||||
|  | ||||
| ### Traduction française des principaux changements | ||||
| - Ajout d'un bouton pour dupliquer les périodes de disponibilités d'une semaine à une autre; | ||||
| - dans l'interface d'administration, filtre sur les utilisateurs actifs. Par défaut, seul les utilisateurs | ||||
|   actifs sont affichés; | ||||
| - Nouveau bouton pour indiquer toutes les notifications comme lues; | ||||
| - Améliorations sur l'import des adresses et des codes postaux; | ||||
| - Affiche le nom du fichier déposé quand on téléverse un fichier depuis le poste de travail local; | ||||
| - Agrandit l'icône du type de fichier dans l'interface de dépôt de fichier; | ||||
| - correction: tient compte de la date de fermeture du parcours dans les filtres sur les actions d'accompagnement. | ||||
| * Fix resolving of centers for an household, which will fix in turn the access control  | ||||
| * Resolved type hinting error in activity list export   | ||||
|  | ||||
| ## v2.22.2 - 2024-07-03 | ||||
| ### Fixed | ||||
|   | ||||
| @@ -31,6 +31,7 @@ | ||||
|         "phpoffice/phpspreadsheet": "^1.16", | ||||
|         "ramsey/uuid-doctrine": "^1.7", | ||||
|         "sensio/framework-extra-bundle": "^5.5", | ||||
|         "smalot/pdfparser": "^2.10", | ||||
|         "spomky-labs/base64url": "^2.0", | ||||
|         "symfony/asset": "^5.4", | ||||
|         "symfony/browser-kit": "^5.4", | ||||
| @@ -42,7 +43,6 @@ | ||||
|         "symfony/dom-crawler": "^5.4", | ||||
|         "symfony/error-handler": "^5.4", | ||||
|         "symfony/event-dispatcher": "^5.4", | ||||
|         "symfony/event-dispatcher-contracts": "^2.4", | ||||
|         "symfony/expression-language": "^5.4", | ||||
|         "symfony/filesystem": "^5.4", | ||||
|         "symfony/finder": "^5.4", | ||||
|   | ||||
| @@ -39,12 +39,9 @@ Implements a :code:`Chill\MainBundle\Cron\CronJobInterface`. Here is an example: | ||||
|     use Chill\MainBundle\Entity\CronJobExecution; | ||||
|     use DateInterval; | ||||
|     use DateTimeImmutable; | ||||
|     use Symfony\Component\Clock\ClockInterface; | ||||
|  | ||||
|     class MyCronJob implements CronJobInterface | ||||
|     { | ||||
|         function __construct(private ClockInterface $clock) {} | ||||
|  | ||||
|         public function canRun(?CronJobExecution $cronJobExecution): bool | ||||
|         { | ||||
|             // the parameter $cronJobExecution contains data about the last execution of the cronjob | ||||
| @@ -59,7 +56,7 @@ Implements a :code:`Chill\MainBundle\Cron\CronJobInterface`. Here is an example: | ||||
|  | ||||
|             // this cron job should be executed if the last execution is greater than one day, but only during the night | ||||
|  | ||||
|             $now = $clock->now(); | ||||
|             $now = new DateTimeImmutable('now'); | ||||
|  | ||||
|             return $cronJobExecution->getLastStart() < $now->sub(new DateInterval('P1D')) | ||||
|                 && in_array($now->format('H'), self::ACCEPTED_HOURS, true) | ||||
| @@ -72,15 +69,10 @@ Implements a :code:`Chill\MainBundle\Cron\CronJobInterface`. Here is an example: | ||||
|             return 'arbitrary-and-unique-key'; | ||||
|         } | ||||
|  | ||||
|         public function run(array $lastExecutionData): void | ||||
|         public function run(): void | ||||
|         { | ||||
|             // here, we execute the command | ||||
|  | ||||
|             // we return execution data, which will be served for next execution | ||||
|             // this data should be easily serializable in a json column: it should contains | ||||
|             // only int, string, etc. Avoid storing object | ||||
|             return ['last-execution-id' => 0]; | ||||
|        } | ||||
|         } | ||||
|     } | ||||
|  | ||||
| How are cron job scheduled ? | ||||
|   | ||||
							
								
								
									
										125
									
								
								docs/source/installation/enable-collabora-for-dev.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										125
									
								
								docs/source/installation/enable-collabora-for-dev.rst
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,125 @@ | ||||
|  | ||||
| Enable CODE for development | ||||
| =========================== | ||||
|  | ||||
| For editing a document, there must be a way to communicate between the collabora server and the symfony server, in | ||||
| both direction. The domain name should also be the same for collabora server and for the browser which access to the | ||||
| online editor. | ||||
|  | ||||
| Using ngrok (or other http tunnel) | ||||
| ---------------------------------- | ||||
|  | ||||
| One can configure a tunnel server to expose your local install to the web, and access to your local server using the | ||||
| tunnel url. | ||||
|  | ||||
| Start ngrok | ||||
| ^^^^^^^^^^^ | ||||
|  | ||||
| This can be achieve using `ngrok <https://ngrok.com/>`_. | ||||
|  | ||||
| .. note:: | ||||
|  | ||||
|    The configuration of ngrok is outside of the scope of this document. Refers to the ngrok's documentation. | ||||
|  | ||||
| .. code-block:: bash | ||||
|  | ||||
|    # ensuring that your server is running through http and port 8000 | ||||
|    ngrok http 8000 | ||||
|    # then open the link given by the ngrok utility and you should reach your app | ||||
|  | ||||
| At this step, ensure that you can reach your local app using the ngrok url. | ||||
|  | ||||
| Configure Collabora | ||||
| ^^^^^^^^^^^^^^^^^^^ | ||||
|  | ||||
| The collabora server must be executed online and configure to access to your ngrok installation. Ensure that the aliasgroup | ||||
| exists for your ngrok application (`See the CODE documentation: <https://sdk.collaboraonline.com/docs/installation/Configuration.html#multihost-configuration>`_). | ||||
|  | ||||
| Configure your app | ||||
| ^^^^^^^^^^^^^^^^^^ | ||||
|  | ||||
| Set the :code:`EDITOR_SERVER` variable to point to your collabora server, this should be done in your :code:`.env.local` file. | ||||
|  | ||||
| At this point, everything must be fine. In case of errors, watch the log from your collabora server, use the `profiler <https://symfony.com/doc/current/profiler.html>`_ | ||||
| to debug the requests. | ||||
|  | ||||
| .. note:: | ||||
|  | ||||
|    In case of error while validating proof (you'll see those message in the collabora's logs), you can temporarily disable | ||||
|    the proof validation adding this code snippet in `config/services.yaml`: | ||||
|  | ||||
|    .. code-block:: yaml | ||||
|  | ||||
|       when@dev: | ||||
|           # add only in dev environment, to avoid security problems | ||||
|           services: | ||||
|               ChampsLibres\WopiLib\Contract\Service\ProofValidatorInterface: | ||||
|                   # this class will always validate proof | ||||
|                   alias: Chill\WopiBundle\Service\Wopi\NullProofValidator | ||||
|  | ||||
| With a local CODE image | ||||
| ----------------------- | ||||
|  | ||||
| .. warning:: | ||||
|  | ||||
|    This configuration is not sure, and must be refined. The documentation does not seems to be entirely valid. | ||||
|  | ||||
| Use a local domain name and https for your app | ||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | ||||
|  | ||||
| Use the proxy feature from embedded symfony server to run your app. `See the dedicated doc <https://symfony.com/doc/current/setup/symfony_server.html#local-domain-names>` | ||||
|  | ||||
| Configure also the `https certificate <https://symfony.com/doc/current/setup/symfony_server.html#enabling-tls>`_ | ||||
|  | ||||
| In this example, your local domain name will be :code:`my-domain` and the url will be :code:`https://my-domain.wip`. | ||||
|  | ||||
| Ensure that the proxy is running. | ||||
|  | ||||
| Create a certificate database for collabora | ||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | ||||
|  | ||||
| Collabora must validate your certificate generated by symfony console. For that, you need `to create a NSS database <https://sdk.collaboraonline.com/docs/installation/Configuration.html#validating-digital-signatures>` | ||||
| and configure collabora to use it. | ||||
|  | ||||
| At first, export the certificate for symfony development. Use the graphical interface from your browser to get the | ||||
| certificate as a PEM file. | ||||
|  | ||||
| .. code-block:: bash | ||||
|  | ||||
|    # create your database in a custom directory | ||||
|    mkdir /path/to/your/directory | ||||
|    certutil -N -d /path/to/your/directory | ||||
|    cat /path/to/your/ca.crt | certutil -d . -A symfony -t -t C,P,C,u,w -a | ||||
|  | ||||
| Launch CODE properly configured | ||||
|  | ||||
| .. code-block:: yaml | ||||
|  | ||||
|      collabora: | ||||
|          image: collabora/code:latest | ||||
|          environment: | ||||
|              - SLEEPFORDEBUGGER=0 | ||||
|              - DONT_GEN_SSL_CERT="True" | ||||
|              # add path to the database | ||||
|              - extra_params=--o:ssl.enable=false --o:ssl.termination=false --o:logging.level=7 -o:certificates.database_path=/etc/custom-certificates/nss-database | ||||
|              - username=admin | ||||
|              - password=admin | ||||
|              - dictionaries=en_US | ||||
|              - aliasgroup1=https://my-domain.wip | ||||
|          ports: | ||||
|              - "127.0.0.1:9980:9980" | ||||
|          volumes: | ||||
|              - "/path/to/your/directory/nss-database:/etc/custom-certificates/nss-database" | ||||
|          extra_hosts: | ||||
|              - "my-domain.wip:host-gateway" | ||||
|  | ||||
| Configure your app | ||||
| ^^^^^^^^^^^^^^^^^^ | ||||
|  | ||||
| Into your :code:`.env.local` file: | ||||
|  | ||||
| .. code-block:: env | ||||
|  | ||||
|    EDITOR_SERVER=http://${COLLABORA_HOST}:${COLLABORA_PORT} | ||||
|  | ||||
| At this step, you should be able to edit a document through collabora. | ||||
| @@ -53,7 +53,7 @@ | ||||
|     "marked": "^12.0.2", | ||||
|     "masonry-layout": "^4.2.2", | ||||
|     "mime": "^4.0.0", | ||||
|     "swagger-ui": "^4.15.5", | ||||
|     "pdfjs-dist": "^4.3.136", | ||||
|     "vis-network": "^9.1.0", | ||||
|     "vue": "^3.2.37", | ||||
|     "vue-i18n": "^9.1.6", | ||||
|   | ||||
| @@ -1,99 +0,0 @@ | ||||
| <?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\ActivityBundle\Export\Aggregator\PersonAggregators; | ||||
|  | ||||
| use Chill\ActivityBundle\Export\Declarations; | ||||
| use Chill\MainBundle\Export\AggregatorInterface; | ||||
| use Chill\PersonBundle\Entity\Household\Household; | ||||
| use Chill\PersonBundle\Entity\Household\HouseholdMember; | ||||
| use Chill\PersonBundle\Repository\Household\HouseholdRepository; | ||||
| use Doctrine\ORM\Query\Expr\Join; | ||||
| use Doctrine\ORM\QueryBuilder; | ||||
| use Symfony\Component\Form\FormBuilderInterface; | ||||
|  | ||||
| final readonly class HouseholdAggregator implements AggregatorInterface | ||||
| { | ||||
|     public function __construct(private HouseholdRepository $householdRepository) {} | ||||
|  | ||||
|     public function buildForm(FormBuilderInterface $builder) | ||||
|     { | ||||
|         // nothing to add here | ||||
|     } | ||||
|  | ||||
|     public function getFormDefaultData(): array | ||||
|     { | ||||
|         return []; | ||||
|     } | ||||
|  | ||||
|     public function getLabels($key, array $values, mixed $data) | ||||
|     { | ||||
|         return function (int|string|null $value): string|int { | ||||
|             if ('_header' === $value) { | ||||
|                 return 'export.aggregator.person.by_household.household'; | ||||
|             } | ||||
|  | ||||
|             if ('' === $value || null === $value || null === $household = $this->householdRepository->find($value)) { | ||||
|                 return ''; | ||||
|             } | ||||
|  | ||||
|             return $household->getId(); | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     public function getQueryKeys($data) | ||||
|     { | ||||
|         return ['activity_household_agg']; | ||||
|     } | ||||
|  | ||||
|     public function getTitle() | ||||
|     { | ||||
|         return 'export.aggregator.person.by_household.title'; | ||||
|     } | ||||
|  | ||||
|     public function addRole(): ?string | ||||
|     { | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     public function alterQuery(QueryBuilder $qb, $data) | ||||
|     { | ||||
|         $qb->join( | ||||
|             HouseholdMember::class, | ||||
|             'activity_household_agg_household_member', | ||||
|             Join::WITH, | ||||
|             $qb->expr()->andX( | ||||
|                 $qb->expr()->eq('activity_household_agg_household_member.person', 'activity.person'), | ||||
|                 $qb->expr()->lte('activity_household_agg_household_member.startDate', 'activity.date'), | ||||
|                 $qb->expr()->orX( | ||||
|                     $qb->expr()->gte('activity_household_agg_household_member.endDate', 'activity.date'), | ||||
|                     $qb->expr()->isNull('activity_household_agg_household_member.endDate') | ||||
|                 ) | ||||
|             ) | ||||
|         ); | ||||
|  | ||||
|         $qb->join( | ||||
|             Household::class, | ||||
|             'activity_household_agg_household', | ||||
|             Join::WITH, | ||||
|             $qb->expr()->eq('activity_household_agg_household_member.household', 'activity_household_agg_household') | ||||
|         ); | ||||
|  | ||||
|         $qb | ||||
|             ->addSelect('activity_household_agg_household.id AS activity_household_agg') | ||||
|             ->addGroupBy('activity_household_agg'); | ||||
|     } | ||||
|  | ||||
|     public function applyOn() | ||||
|     { | ||||
|         return Declarations::ACTIVITY_PERSON; | ||||
|     } | ||||
| } | ||||
| @@ -19,7 +19,6 @@ use Chill\MainBundle\Export\FormatterInterface; | ||||
| use Chill\MainBundle\Export\GroupedExportInterface; | ||||
| use Chill\MainBundle\Export\ListInterface; | ||||
| use Chill\MainBundle\Templating\TranslatableStringHelperInterface; | ||||
| use Chill\PersonBundle\Entity\Household\HouseholdMember; | ||||
| use Chill\PersonBundle\Export\Declarations as PersonDeclarations; | ||||
| use Doctrine\DBAL\Exception\InvalidArgumentException; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| @@ -45,7 +44,6 @@ class ListActivity implements ListInterface, GroupedExportInterface | ||||
|         'person_firstname', | ||||
|         'person_lastname', | ||||
|         'person_id', | ||||
|         'household_id', | ||||
|     ]; | ||||
|     private readonly bool $filterStatsByCenters; | ||||
|  | ||||
| @@ -191,26 +189,19 @@ class ListActivity implements ListInterface, GroupedExportInterface | ||||
|     { | ||||
|         $centers = array_map(static fn ($el) => $el['center'], $acl); | ||||
|  | ||||
|         // throw an error if no fields are present | ||||
|         // throw an error if any fields are present | ||||
|         if (!\array_key_exists('fields', $data)) { | ||||
|             throw new InvalidArgumentException('No fields have been checked.'); | ||||
|             throw new InvalidArgumentException('Any fields have been checked.'); | ||||
|         } | ||||
|  | ||||
|         $qb = $this->entityManager->createQueryBuilder(); | ||||
|  | ||||
|         $qb | ||||
|             ->from('ChillActivityBundle:Activity', 'activity') | ||||
|             ->join('activity.person', 'person') | ||||
|             ->join( | ||||
|                 HouseholdMember::class, | ||||
|                 'householdmember', | ||||
|                 Query\Expr\Join::WITH, | ||||
|                 'person = householdmember.person AND householdmember.startDate <= activity.date AND (householdmember.endDate IS NULL OR householdmember.endDate > activity.date)' | ||||
|             ) | ||||
|             ->join('householdmember.household', 'household'); | ||||
|             ->join('activity.person', 'actperson'); | ||||
|  | ||||
|         if ($this->filterStatsByCenters) { | ||||
|             $qb->join('person.centerHistory', 'centerHistory'); | ||||
|             $qb->join('actperson.centerHistory', 'centerHistory'); | ||||
|             $qb->where( | ||||
|                 $qb->expr()->andX( | ||||
|                     $qb->expr()->lte('centerHistory.startDate', 'activity.date'), | ||||
| @@ -233,22 +224,17 @@ class ListActivity implements ListInterface, GroupedExportInterface | ||||
|                         break; | ||||
|  | ||||
|                     case 'person_firstname': | ||||
|                         $qb->addSelect('person.firstName AS person_firstname'); | ||||
|                         $qb->addSelect('actperson.firstName AS person_firstname'); | ||||
|  | ||||
|                         break; | ||||
|  | ||||
|                     case 'person_lastname': | ||||
|                         $qb->addSelect('person.lastName AS person_lastname'); | ||||
|                         $qb->addSelect('actperson.lastName AS person_lastname'); | ||||
|  | ||||
|                         break; | ||||
|  | ||||
|                     case 'person_id': | ||||
|                         $qb->addSelect('person.id AS person_id'); | ||||
|  | ||||
|                         break; | ||||
|  | ||||
|                     case 'household_id': | ||||
|                         $qb->addSelect('household.id AS household_id'); | ||||
|                         $qb->addSelect('actperson.id AS person_id'); | ||||
|  | ||||
|                         break; | ||||
|  | ||||
| @@ -298,7 +284,7 @@ class ListActivity implements ListInterface, GroupedExportInterface | ||||
|         return ActivityStatsVoter::LISTS; | ||||
|     } | ||||
|  | ||||
|     public function supportsModifiers(): array | ||||
|     public function supportsModifiers() | ||||
|     { | ||||
|         return [ | ||||
|             Declarations::ACTIVITY, | ||||
|   | ||||
| @@ -73,7 +73,7 @@ final readonly class PeriodHavingActivityBetweenDatesFilter implements FilterInt | ||||
|  | ||||
|         $qb->andWhere( | ||||
|             $qb->expr()->exists( | ||||
|                 'SELECT 1 FROM '.Activity::class." {$alias} WHERE {$alias}.date >= :{$from} AND {$alias}.date < :{$to} AND {$alias}.accompanyingPeriod = acp" | ||||
|                 'SELECT 1 FROM '.Activity::class." {$alias} WHERE {$alias}.date >= :{$from} AND {$alias}.date < :{$to} AND {$alias}.accompanyingPeriod = activity.accompanyingPeriod" | ||||
|             ) | ||||
|         ); | ||||
|  | ||||
|   | ||||
| @@ -39,7 +39,7 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     public function alterQuery(QueryBuilder $qb, $data): void | ||||
|     public function alterQuery(QueryBuilder $qb, $data) | ||||
|     { | ||||
|         // create a subquery for activity | ||||
|         $sqb = $qb->getEntityManager()->createQueryBuilder(); | ||||
| @@ -121,7 +121,7 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|     public function describeAction($data, $format = 'string'): array | ||||
|     public function describeAction($data, $format = 'string') | ||||
|     { | ||||
|         return [ | ||||
|             [] === $data['reasons'] ? | ||||
| @@ -141,7 +141,7 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|     public function getTitle(): string | ||||
|     public function getTitle() | ||||
|     { | ||||
|         return 'export.filter.activity.person_between_dates.title'; | ||||
|     } | ||||
|   | ||||
| @@ -12,6 +12,8 @@ declare(strict_types=1); | ||||
| namespace Chill\ActivityBundle\Repository; | ||||
|  | ||||
| use Chill\ActivityBundle\Entity\Activity; | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface; | ||||
| use Chill\PersonBundle\Entity\AccompanyingPeriod; | ||||
| use Chill\PersonBundle\Entity\Person; | ||||
| use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; | ||||
| @@ -23,7 +25,7 @@ use Doctrine\Persistence\ManagerRegistry; | ||||
|  * @method Activity[]    findAll() | ||||
|  * @method Activity[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) | ||||
|  */ | ||||
| class ActivityRepository extends ServiceEntityRepository | ||||
| class ActivityRepository extends ServiceEntityRepository implements AssociatedEntityToStoredObjectInterface | ||||
| { | ||||
|     public function __construct(ManagerRegistry $registry) | ||||
|     { | ||||
| @@ -97,4 +99,16 @@ class ActivityRepository extends ServiceEntityRepository | ||||
|  | ||||
|         return $qb->getQuery()->getResult(); | ||||
|     } | ||||
|  | ||||
|     public function findAssociatedEntityToStoredObject(StoredObject $storedObject): ?Activity | ||||
|     { | ||||
|         $qb = $this->createQueryBuilder('a'); | ||||
|         $query = $qb | ||||
|             ->leftJoin('a.documents', 'ad') | ||||
|             ->where('ad.id = :storedObjectId') | ||||
|             ->setParameter('storedObjectId', $storedObject->getId()) | ||||
|             ->getQuery(); | ||||
|  | ||||
|         return $query->getOneOrNullResult(); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,54 @@ | ||||
| <?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\ActivityBundle\Security\Authorization; | ||||
|  | ||||
| use Chill\ActivityBundle\Entity\Activity; | ||||
| use Chill\ActivityBundle\Repository\ActivityRepository; | ||||
| use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface; | ||||
| use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; | ||||
| use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter\AbstractStoredObjectVoter; | ||||
| use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
|  | ||||
| class ActivityStoredObjectVoter extends AbstractStoredObjectVoter | ||||
| { | ||||
|     public function __construct( | ||||
|         private readonly ActivityRepository $repository, | ||||
|         Security $security, | ||||
|         WorkflowStoredObjectPermissionHelper $workflowDocumentService, | ||||
|     ) { | ||||
|         parent::__construct($security, $workflowDocumentService); | ||||
|     } | ||||
|  | ||||
|     protected function getRepository(): AssociatedEntityToStoredObjectInterface | ||||
|     { | ||||
|         return $this->repository; | ||||
|     } | ||||
|  | ||||
|     protected function getClass(): string | ||||
|     { | ||||
|         return Activity::class; | ||||
|     } | ||||
|  | ||||
|     protected function attributeToRole(StoredObjectRoleEnum $attribute): string | ||||
|     { | ||||
|         return match ($attribute) { | ||||
|             StoredObjectRoleEnum::EDIT => ActivityVoter::UPDATE, | ||||
|             StoredObjectRoleEnum::SEE => ActivityVoter::SEE_DETAILS, | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     protected function canBeAssociatedWithWorkflow(): bool | ||||
|     { | ||||
|         return false; | ||||
|     } | ||||
| } | ||||
| @@ -243,7 +243,3 @@ services: | ||||
|     Chill\ActivityBundle\Export\Aggregator\PersonAggregators\PersonAggregator: | ||||
|         tags: | ||||
|             - { name: chill.export_aggregator, alias: activity_person_agg } | ||||
|  | ||||
|     Chill\ActivityBundle\Export\Aggregator\PersonAggregators\HouseholdAggregator: | ||||
|         tags: | ||||
|             - { name: chill.export_aggregator, alias: activity_household_agg } | ||||
|   | ||||
| @@ -428,9 +428,6 @@ export: | ||||
|             by_person: | ||||
|                 title: Grouper les échanges par usager (dossier d'usager dans lequel l'échange est enregistré) | ||||
|                 person: Usager | ||||
|             by_household: | ||||
|                 title: Grouper les échanges par ménage | ||||
|                 household: Identifiant ménage | ||||
|         acp: | ||||
|             by_activity_type: | ||||
|                 title: Grouper les parcours par type d'échange | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| <template> | ||||
|   <div class="row"> | ||||
|     <div class="col-sm"> | ||||
|       <label class="form-label">{{ $t("created_availabilities") }}</label> | ||||
|       <label class="form-label">{{ $t('created_availabilities') }}</label> | ||||
|       <vue-multiselect | ||||
|         v-model="pickedLocation" | ||||
|         :options="locations" | ||||
| @@ -14,15 +14,10 @@ | ||||
|       ></vue-multiselect> | ||||
|     </div> | ||||
|   </div> | ||||
|   <div | ||||
|     class="display-options row justify-content-between" | ||||
|     style="margin-top: 1rem" | ||||
|   > | ||||
|   <div class="display-options row justify-content-between" style="margin-top: 1rem;"> | ||||
|     <div class="col-sm-9 col-xs-12"> | ||||
|       <div class="input-group mb-3"> | ||||
|         <label class="input-group-text" for="slotDuration" | ||||
|           >Durée des créneaux</label | ||||
|         > | ||||
|         <label class="input-group-text" for="slotDuration">Durée des créneaux</label> | ||||
|         <select v-model="slotDuration" id="slotDuration" class="form-select"> | ||||
|           <option value="00:05:00">5 minutes</option> | ||||
|           <option value="00:10:00">10 minutes</option> | ||||
| @@ -63,20 +58,13 @@ | ||||
|         </select> | ||||
|       </div> | ||||
|     </div> | ||||
|     <div class="col-xs-12 col-sm-3"> | ||||
|     <div class="col-sm-3 col-xs-12"> | ||||
|       <div class="float-end"> | ||||
|         <div class="form-check input-group"> | ||||
|           <span class="input-group-text"> | ||||
|             <input | ||||
|               id="showHideWE" | ||||
|               class="mt-0" | ||||
|               type="checkbox" | ||||
|               v-model="showWeekends" | ||||
|             /> | ||||
|             <input id="showHideWE" class="mt-0" type="checkbox" v-model="showWeekends"> | ||||
|           </span> | ||||
|           <label for="showHideWE" class="form-check-label input-group-text" | ||||
|             >Week-ends</label | ||||
|           > | ||||
|           <label for="showHideWE" class="form-check-label input-group-text">Week-ends</label> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
| @@ -84,86 +72,39 @@ | ||||
|   <FullCalendar :options="calendarOptions" ref="calendarRef"> | ||||
|     <template v-slot:eventContent="arg: EventApi"> | ||||
|       <span :class="eventClasses(arg.event)"> | ||||
|         <b v-if="arg.event.extendedProps.is === 'remote'">{{ | ||||
|           arg.event.title | ||||
|         }}</b> | ||||
|         <b v-else-if="arg.event.extendedProps.is === 'range'" | ||||
|           >{{ arg.timeText }} - {{ arg.event.extendedProps.locationName }}</b | ||||
|         > | ||||
|         <b v-else-if="arg.event.extendedProps.is === 'local'">{{ | ||||
|           arg.event.title | ||||
|         }}</b> | ||||
|         <b v-else>no 'is'</b> | ||||
|         <a | ||||
|           v-if="arg.event.extendedProps.is === 'range'" | ||||
|           class="fa fa-fw fa-times delete" | ||||
|           @click.prevent="onClickDelete(arg.event)" | ||||
|         > | ||||
|         </a> | ||||
|       </span> | ||||
|           <b v-if="arg.event.extendedProps.is === 'remote'">{{ arg.event.title}}</b> | ||||
|           <b v-else-if="arg.event.extendedProps.is === 'range'">{{ arg.timeText }} - {{ arg.event.extendedProps.locationName }}</b> | ||||
|           <b v-else-if="arg.event.extendedProps.is === 'local'">{{ arg.event.title}}</b> | ||||
|           <b v-else >no 'is'</b> | ||||
|           <a v-if="arg.event.extendedProps.is === 'range'" class="fa fa-fw fa-times delete" | ||||
|              @click.prevent="onClickDelete(arg.event)"> | ||||
|           </a> | ||||
|         </span> | ||||
|     </template> | ||||
|   </FullCalendar> | ||||
|  | ||||
|   <div id="copy-widget"> | ||||
|     <div class="container mt-2 mb-2"> | ||||
|  | ||||
|         <div class="row justify-content-between align-items-center mb-4"> | ||||
|           <div class="col-xs-12 col-sm-3 col-md-2"> | ||||
|             <h6 class="chill-red">{{ $t("copy_range_from_to") }}</h6> | ||||
|           </div> | ||||
|           <div class="col-xs-12 col-sm-9 col-md-2"> | ||||
|             <select v-model="dayOrWeek" id="dayOrWeek" class="form-select"> | ||||
|               <option value="day">{{ $t("from_day_to_day") }}</option> | ||||
|               <option value="week">{{ $t("from_week_to_week") }}</option> | ||||
|             </select> | ||||
|           </div> | ||||
|           <template v-if="dayOrWeek === 'day'"> | ||||
|             <div class="col-xs-12 col-sm-3 col-md-3"> | ||||
|               <input class="form-control" type="date" v-model="copyFrom" /> | ||||
|             </div> | ||||
|             <div class="col-xs-12 col-sm-1 col-md-1 copy-chevron"> | ||||
|               <i class="fa fa-angle-double-right"></i> | ||||
|             </div> | ||||
|             <div class="col-xs-12 col-sm-3 col-md-3"> | ||||
|               <input class="form-control" type="date" v-model="copyTo" /> | ||||
|             </div> | ||||
|             <div class="col-xs-12 col-sm-5 col-md-1"> | ||||
|               <button class="btn btn-action float-end" @click="copyDay"> | ||||
|                 {{ $t("copy_range") }} | ||||
|               </button> | ||||
|             </div> | ||||
|           </template> | ||||
|           <template v-else> | ||||
|             <div class="col-xs-12 col-sm-3 col-md-3"> | ||||
|               <select | ||||
|                 v-model="copyFromWeek" | ||||
|                 id="copyFromWeek" | ||||
|                 class="form-select" | ||||
|               > | ||||
|                 <option v-for="w in lastWeeks" :value="w.value" :key="w.value"> | ||||
|                   {{ w.text }} | ||||
|                 </option> | ||||
|               </select> | ||||
|             </div> | ||||
|             <div class="col-xs-12 col-sm-1 col-md-1 copy-chevron"> | ||||
|               <i class="fa fa-angle-double-right"></i> | ||||
|             </div> | ||||
|             <div class="col-xs-12 col-sm-3 col-md-3"> | ||||
|               <select v-model="copyToWeek" id="copyToWeek" class="form-select"> | ||||
|                 <option v-for="w in nextWeeks" :value="w.value" :key="w.value"> | ||||
|                   {{ w.text }} | ||||
|                 </option> | ||||
|               </select> | ||||
|             </div> | ||||
|             <div class="col-xs-12 col-sm-5 col-md-1"> | ||||
|               <button class="btn btn-action float-end" @click="copyWeek"> | ||||
|                 {{ $t("copy_range") }} | ||||
|               </button> | ||||
|             </div> | ||||
|           </template> | ||||
|     <div class="container"> | ||||
|       <div class="row align-items-center"> | ||||
|         <div class="col-sm-4 col-xs-12"> | ||||
|           <h6 class="chill-red">{{ $t('copy_range_from_to') }}</h6> | ||||
|         </div> | ||||
|         <div class="col-sm-3 col-xs-12"> | ||||
|           <input class="form-control" type="date" v-model="copyFrom" /> | ||||
|         </div> | ||||
|         <div class="col-sm-1 col-xs-12" style="text-align: center; font-size: x-large;"> | ||||
|           <i class="fa fa-angle-double-right"></i> | ||||
|         </div> | ||||
|         <div class="col-sm-3 col-xs-12" > | ||||
|           <input class="form-control" type="date" v-model="copyTo" /> | ||||
|         </div> | ||||
|         <div class="col-sm-1"> | ||||
|           <button class="btn btn-action" @click="copyDay"> | ||||
|             {{ $t('copy_range') }} | ||||
|           </button> | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|     </div> | ||||
|   </div> | ||||
|  | ||||
|   <!-- not directly seen, but include in a modal --> | ||||
| @@ -171,95 +112,42 @@ | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|  | ||||
| import type { | ||||
|   CalendarOptions, | ||||
|   DatesSetArg, | ||||
|   EventInput, | ||||
| } from "@fullcalendar/core"; | ||||
| import { reactive, computed, ref, onMounted } from "vue"; | ||||
| import { useStore } from "vuex"; | ||||
| import { key } from "./store"; | ||||
| import FullCalendar from "@fullcalendar/vue3"; | ||||
| import frLocale from "@fullcalendar/core/locales/fr"; | ||||
| import interactionPlugin, { | ||||
|   DropArg, | ||||
|   EventResizeDoneArg, | ||||
| } from "@fullcalendar/interaction"; | ||||
|   EventInput | ||||
| } from '@fullcalendar/core'; | ||||
| import {reactive, computed, ref} from "vue"; | ||||
| import {useStore} from "vuex"; | ||||
| import {key} from './store'; | ||||
| import FullCalendar from '@fullcalendar/vue3'; | ||||
| import frLocale from '@fullcalendar/core/locales/fr'; | ||||
| import interactionPlugin, {DropArg, EventResizeDoneArg} from "@fullcalendar/interaction"; | ||||
| import timeGridPlugin from "@fullcalendar/timegrid"; | ||||
| import { | ||||
|   EventApi, | ||||
|   DateSelectArg, | ||||
|   EventDropArg, | ||||
|   EventClickArg, | ||||
| } from "@fullcalendar/core"; | ||||
| import { | ||||
|   dateToISO, | ||||
|   ISOToDate, | ||||
| } from "../../../../../ChillMainBundle/Resources/public/chill/js/date"; | ||||
| import {EventApi, DateSelectArg, EventDropArg, EventClickArg} from "@fullcalendar/core"; | ||||
| import {ISOToDate} from "../../../../../ChillMainBundle/Resources/public/chill/js/date"; | ||||
| import VueMultiselect from "vue-multiselect"; | ||||
| import { Location } from "../../../../../ChillMainBundle/Resources/public/types"; | ||||
| import {Location} from "../../../../../ChillMainBundle/Resources/public/types"; | ||||
| import EditLocation from "./Components/EditLocation.vue"; | ||||
| import { useI18n } from "vue-i18n"; | ||||
| import {useI18n} from "vue-i18n"; | ||||
|  | ||||
| const store = useStore(key); | ||||
|  | ||||
| const { t } = useI18n(); | ||||
| const {t} = useI18n(); | ||||
|  | ||||
| const showWeekends = ref(false); | ||||
| const slotDuration = ref("00:15:00"); | ||||
| const slotMinTime = ref("09:00:00"); | ||||
| const slotMaxTime = ref("18:00:00"); | ||||
| const slotDuration = ref('00:05:00'); | ||||
| const slotMinTime = ref('09:00:00'); | ||||
| const slotMaxTime = ref('18:00:00'); | ||||
| const copyFrom = ref<string | null>(null); | ||||
| const copyTo = ref<string | null>(null); | ||||
| const editLocation = ref<InstanceType<typeof EditLocation> | null>(null); | ||||
| const dayOrWeek = ref("day"); | ||||
| const copyFromWeek = ref<string | null>(null); | ||||
| const copyToWeek = ref<string | null>(null); | ||||
|  | ||||
| interface Weeks { | ||||
|   value: string | null; | ||||
|   text: string; | ||||
| } | ||||
|  | ||||
| const getMonday = (week: number): Date => { | ||||
|   const lastMonday = new Date(); | ||||
|   lastMonday.setDate( | ||||
|     lastMonday.getDate() - ((lastMonday.getDay() + 6) % 7) + week * 7 | ||||
|   ); | ||||
|   return lastMonday; | ||||
| }; | ||||
|  | ||||
| const dateOptions: Intl.DateTimeFormatOptions = { | ||||
|   weekday: "long", | ||||
|   year: "numeric", | ||||
|   month: "long", | ||||
|   day: "numeric", | ||||
| }; | ||||
|  | ||||
| const lastWeeks = computed((): Weeks[] => | ||||
|   Array.from(Array(30).keys()).map((w) => { | ||||
|     const lastMonday = getMonday(15-w); | ||||
|     return { | ||||
|       value: dateToISO(lastMonday), | ||||
|       text: `Semaine du ${lastMonday.toLocaleDateString("fr-FR", dateOptions)}`, | ||||
|     }; | ||||
|   }) | ||||
| ); | ||||
|  | ||||
| const nextWeeks = computed((): Weeks[] => | ||||
|   Array.from(Array(52).keys()).map((w) => { | ||||
|     const nextMonday = getMonday(w + 1); | ||||
|     return { | ||||
|       value: dateToISO(nextMonday), | ||||
|       text: `Semaine du ${nextMonday.toLocaleDateString("fr-FR", dateOptions)}`, | ||||
|     }; | ||||
|   }) | ||||
| ); | ||||
| const editLocation = ref<InstanceType<typeof EditLocation> | null>(null) | ||||
|  | ||||
| const baseOptions = ref<CalendarOptions>({ | ||||
|   locale: frLocale, | ||||
|   plugins: [interactionPlugin, timeGridPlugin], | ||||
|   initialView: "timeGridWeek", | ||||
|   initialView: 'timeGridWeek', | ||||
|   initialDate: new Date(), | ||||
|   scrollTimeReset: false, | ||||
|   selectable: true, | ||||
| @@ -276,9 +164,9 @@ const baseOptions = ref<CalendarOptions>({ | ||||
|   selectMirror: false, | ||||
|   editable: true, | ||||
|   headerToolbar: { | ||||
|     left: "prev,next today", | ||||
|     center: "title", | ||||
|     right: "timeGridWeek,timeGridDay", | ||||
|     left: 'prev,next today', | ||||
|     center: 'title', | ||||
|     right: 'timeGridWeek,timeGridDay' | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| @@ -292,23 +180,20 @@ const locations = computed<Location[]>(() => { | ||||
|  | ||||
| const pickedLocation = computed<Location | null>({ | ||||
|   get(): Location | null { | ||||
|     return ( | ||||
|       store.state.locations.locationPicked || | ||||
|       store.state.locations.currentLocation | ||||
|     ); | ||||
|     return store.state.locations.locationPicked || store.state.locations.currentLocation; | ||||
|   }, | ||||
|   set(newLocation: Location | null): void { | ||||
|     store.commit("locations/setLocationPicked", newLocation, { root: true }); | ||||
|   }, | ||||
| }); | ||||
|     store.commit('locations/setLocationPicked', newLocation, {root: true}); | ||||
|   } | ||||
| }) | ||||
|  | ||||
| /** | ||||
|  * return the show classes for the event | ||||
|  * @param arg | ||||
|  */ | ||||
| const eventClasses = function (arg: EventApi): object { | ||||
|   return { calendarRangeItems: true }; | ||||
| }; | ||||
| const eventClasses = function(arg: EventApi): object { | ||||
|   return {'calendarRangeItems': true}; | ||||
| } | ||||
|  | ||||
| /* | ||||
| // currently, all events are stored into calendarRanges, due to reactivity bug | ||||
| @@ -345,60 +230,51 @@ const calendarOptions = computed((): CalendarOptions => { | ||||
|  * launched when the calendar range date change | ||||
|  */ | ||||
| function onDatesSet(event: DatesSetArg): void { | ||||
|   store.dispatch("fullCalendar/setCurrentDatesView", { | ||||
|     start: event.start, | ||||
|     end: event.end, | ||||
|   }); | ||||
|   store.dispatch('fullCalendar/setCurrentDatesView', {start: event.start, end: event.end}); | ||||
| } | ||||
|  | ||||
| function onDateSelect(event: DateSelectArg): void { | ||||
|  | ||||
|   if (null === pickedLocation.value) { | ||||
|     window.alert( | ||||
|       "Indiquez une localisation avant de créer une période de disponibilité." | ||||
|     ); | ||||
|     window.alert("Indiquez une localisation avant de créer une période de disponibilité."); | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   store.dispatch("calendarRanges/createRange", { | ||||
|     start: event.start, | ||||
|     end: event.end, | ||||
|     location: pickedLocation.value, | ||||
|   }); | ||||
|   store.dispatch('calendarRanges/createRange', {start: event.start, end: event.end, location: pickedLocation.value}); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * When a calendar range is deleted | ||||
|  */ | ||||
| function onClickDelete(event: EventApi): void { | ||||
|   if (event.extendedProps.is !== "range") { | ||||
|   console.log('onClickDelete', event); | ||||
|  | ||||
|   if (event.extendedProps.is !== 'range') { | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   store.dispatch( | ||||
|     "calendarRanges/deleteRange", | ||||
|     event.extendedProps.calendarRangeId | ||||
|   ); | ||||
|   store.dispatch('calendarRanges/deleteRange', event.extendedProps.calendarRangeId); | ||||
| } | ||||
|  | ||||
| function onEventDropOrResize(payload: EventDropArg | EventResizeDoneArg) { | ||||
|   if (payload.event.extendedProps.is !== "range") { | ||||
|   if (payload.event.extendedProps.is !== 'range') { | ||||
|     return; | ||||
|   } | ||||
|   const changedEvent = payload.event; | ||||
|  | ||||
|   store.dispatch("calendarRanges/patchRangeTime", { | ||||
|   store.dispatch('calendarRanges/patchRangeTime', { | ||||
|     calendarRangeId: payload.event.extendedProps.calendarRangeId, | ||||
|     start: payload.event.start, | ||||
|     end: payload.event.end, | ||||
|   }); | ||||
| } | ||||
| }; | ||||
|  | ||||
| function onEventClick(payload: EventClickArg): void { | ||||
|   // @ts-ignore TS does not recognize the target. But it does exists. | ||||
|   if (payload.jsEvent.target.classList.contains("delete")) { | ||||
|   if (payload.jsEvent.target.classList.contains('delete')) { | ||||
|     return; | ||||
|   } | ||||
|   if (payload.event.extendedProps.is !== "range") { | ||||
|   if (payload.event.extendedProps.is !== 'range') { | ||||
|     return; | ||||
|   } | ||||
|  | ||||
| @@ -409,26 +285,10 @@ function copyDay() { | ||||
|   if (null === copyFrom.value || null === copyTo.value) { | ||||
|     return; | ||||
|   } | ||||
|   store.dispatch("calendarRanges/copyFromDayToAnotherDay", { | ||||
|     from: ISOToDate(copyFrom.value), | ||||
|     to: ISOToDate(copyTo.value), | ||||
|   }); | ||||
|  | ||||
|   store.dispatch('calendarRanges/copyFromDayToAnotherDay', {from: ISOToDate(copyFrom.value), to: ISOToDate(copyTo.value)}) | ||||
| } | ||||
|  | ||||
| function copyWeek() { | ||||
|   if (null === copyFromWeek.value || null === copyToWeek.value) { | ||||
|     return; | ||||
|   } | ||||
|   store.dispatch("calendarRanges/copyFromWeekToAnotherWeek", { | ||||
|     fromMonday: ISOToDate(copyFromWeek.value), | ||||
|     toMonday: ISOToDate(copyToWeek.value), | ||||
|   }); | ||||
| } | ||||
|  | ||||
| onMounted(() => { | ||||
|   copyFromWeek.value = dateToISO(getMonday(0)); | ||||
|   copyToWeek.value = dateToISO(getMonday(1)); | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| @@ -439,9 +299,4 @@ onMounted(() => { | ||||
|   z-index: 9999999999; | ||||
|   padding: 0.25rem 0 0.25rem; | ||||
| } | ||||
| div.copy-chevron { | ||||
|   text-align: center; | ||||
|   font-size: x-large; | ||||
|   width: 2rem; | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -5,9 +5,11 @@ const appMessages = { | ||||
|       show_my_calendar: "Afficher mon calendrier", | ||||
|       show_weekends: "Afficher les week-ends", | ||||
|       copy_range: "Copier", | ||||
|       copy_range_from_to: "Copier les plages", | ||||
|       from_day_to_day: "d'un jour à l'autre", | ||||
|       from_week_to_week: "d'une semaine à l'autre", | ||||
|       copy_range_from_to: "Copier les plages d'un jour à l'autre", | ||||
|       copy_range_to_next_day: "Copier les plages du jour au jour suivant", | ||||
|       copy_range_from_day: "Copier les plages du ", | ||||
|       to_the_next_day: " au jour suivant", | ||||
|       copy_range_to_next_week: "Copier les plages de la semaine à la semaine suivante", | ||||
|       copy_range_how_to: "Créez les plages de disponibilités durant une journée et copiez-les facilement au jour suivant avec ce bouton. Si les week-ends sont cachés, le jour suivant un vendredi sera le lundi.", | ||||
|       new_range_to_save: "Nouvelles plages à enregistrer", | ||||
|       update_range_to_save: "Plages à modifier", | ||||
|   | ||||
| @@ -52,23 +52,6 @@ export default <Module<CalendarRangesState, State>>{ | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       return founds; | ||||
|     }, | ||||
|     getRangesOnWeek: (state: CalendarRangesState) => (mondayDate: Date): EventInputCalendarRange[] => { | ||||
|       const founds = []; | ||||
|       for (let d of Array.from(Array(7).keys())) { | ||||
|         const dateOfWeek = new Date(mondayDate); | ||||
|         dateOfWeek.setDate(mondayDate.getDate() + d); | ||||
|         const dateStr = <string>dateToISO(dateOfWeek); | ||||
|         for (let range of state.ranges) { | ||||
|           if (isEventInputCalendarRange(range) | ||||
|             && range.start.startsWith(dateStr) | ||||
|           ) { | ||||
|             founds.push(range); | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       return founds; | ||||
|     }, | ||||
|   }, | ||||
| @@ -255,7 +238,7 @@ export default <Module<CalendarRangesState, State>>{ | ||||
|  | ||||
|       for (let r of rangesToCopy) { | ||||
|         let start = new Date(<Date>ISOToDatetime(r.start)); | ||||
|         start.setFullYear(to.getFullYear(), to.getMonth(), to.getDate()); | ||||
|         start.setFullYear(to.getFullYear(), to.getMonth(), to.getDate()) | ||||
|         let end = new Date(<Date>ISOToDatetime(r.end)); | ||||
|         end.setFullYear(to.getFullYear(), to.getMonth(), to.getDate()); | ||||
|         let location = ctx.rootGetters['locations/getLocationById'](r.locationId); | ||||
| @@ -263,23 +246,6 @@ export default <Module<CalendarRangesState, State>>{ | ||||
|         promises.push(ctx.dispatch('createRange', {start, end, location})); | ||||
|       } | ||||
|  | ||||
|       return Promise.all(promises).then(_ => Promise.resolve(null)); | ||||
|     }, | ||||
|     copyFromWeekToAnotherWeek(ctx, {fromMonday, toMonday}: {fromMonday: Date, toMonday: Date}): Promise<null> { | ||||
|  | ||||
|       const rangesToCopy: EventInputCalendarRange[] = ctx.getters['getRangesOnWeek'](fromMonday); | ||||
|       const promises = []; | ||||
|       const diffTime = toMonday.getTime() - fromMonday.getTime(); | ||||
|       for (let r of rangesToCopy) { | ||||
|         let start = new Date(<Date>ISOToDatetime(r.start)); | ||||
|         let end = new Date(<Date>ISOToDatetime(r.end)); | ||||
|         start.setTime(start.getTime() + diffTime); | ||||
|         end.setTime(end.getTime() + diffTime); | ||||
|         let location = ctx.rootGetters['locations/getLocationById'](r.locationId); | ||||
|  | ||||
|         promises.push(ctx.dispatch('createRange', {start, end, location})); | ||||
|       } | ||||
|  | ||||
|       return Promise.all(promises).then(_ => Promise.resolve(null)); | ||||
|     } | ||||
|   } | ||||
|   | ||||
| @@ -42,8 +42,8 @@ class CustomFieldLongChoice extends AbstractCustomField | ||||
|         $translatableStringHelper = $this->translatableStringHelper; | ||||
|         $builder->add($customField->getSlug(), Select2ChoiceType::class, [ | ||||
|             'choices' => $entries, | ||||
|             'choice_label' => static fn (?Option $option) => $translatableStringHelper->localize($option->getText()), | ||||
|             'choice_value' => static fn (?Option $key): ?int => $key?->getId(), | ||||
|             'choice_label' => static fn (Option $option) => $translatableStringHelper->localize($option->getText()), | ||||
|             'choice_value' => static fn (Option $key): ?int => null === $key ? null : $key->getId(), | ||||
|             'multiple' => false, | ||||
|             'expanded' => false, | ||||
|             'required' => $customField->isRequired(), | ||||
|   | ||||
| @@ -46,8 +46,11 @@ class CustomFieldsGroup | ||||
|     #[ORM\GeneratedValue(strategy: 'AUTO')] | ||||
|     private ?int $id = null; | ||||
|  | ||||
|     /** | ||||
|      * @var array | ||||
|      */ | ||||
|     #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON)] | ||||
|     private array|string $name; | ||||
|     private $name; | ||||
|  | ||||
|     #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON)] | ||||
|     private array $options = []; | ||||
|   | ||||
| @@ -54,12 +54,15 @@ class LoadDocGeneratorTemplate extends AbstractFixture | ||||
|         ]; | ||||
|  | ||||
|         foreach ($templates as $template) { | ||||
|             $newStoredObj = (new StoredObject()) | ||||
|                 ->setFilename($template['file']['filename']) | ||||
|                 ->setKeyInfos(json_decode($template['file']['key'], true)) | ||||
|                 ->setIv(json_decode($template['file']['iv'], true)) | ||||
|             $newStoredObj = (new StoredObject()); | ||||
|  | ||||
|             $newStoredObj | ||||
|                 ->setCreatedAt(new \DateTime('today')) | ||||
|                 ->setType($template['file']['type']); | ||||
|                 ->registerVersion( | ||||
|                     json_decode($template['file']['key'], true), | ||||
|                     json_decode($template['file']['iv'], true), | ||||
|                     $template['file']['type'], | ||||
|                 ); | ||||
|  | ||||
|             $manager->persist($newStoredObj); | ||||
|  | ||||
|   | ||||
| @@ -134,13 +134,11 @@ class Generator implements GeneratorInterface | ||||
|             $content = Yaml::dump($data, 6); | ||||
|             /* @var StoredObject $destinationStoredObject */ | ||||
|             $destinationStoredObject | ||||
|                 ->setType('application/yaml') | ||||
|                 ->setFilename(sprintf('%s_yaml', uniqid('doc_', true))) | ||||
|                 ->setStatus(StoredObject::STATUS_READY) | ||||
|             ; | ||||
|  | ||||
|             try { | ||||
|                 $this->storedObjectManager->write($destinationStoredObject, $content); | ||||
|                 $this->storedObjectManager->write($destinationStoredObject, $content, 'application/yaml'); | ||||
|             } catch (StoredObjectManagerException $e) { | ||||
|                 $destinationStoredObject->addGenerationErrors($e->getMessage()); | ||||
|  | ||||
| @@ -174,13 +172,11 @@ class Generator implements GeneratorInterface | ||||
|  | ||||
|         /* @var StoredObject $destinationStoredObject */ | ||||
|         $destinationStoredObject | ||||
|             ->setType($template->getFile()->getType()) | ||||
|             ->setFilename(sprintf('%s_odt', uniqid('doc_', true))) | ||||
|             ->setStatus(StoredObject::STATUS_READY) | ||||
|         ; | ||||
|  | ||||
|         try { | ||||
|             $this->storedObjectManager->write($destinationStoredObject, $generatedResource); | ||||
|             $this->storedObjectManager->write($destinationStoredObject, $generatedResource, $template->getFile()->getType()); | ||||
|         } catch (StoredObjectManagerException $e) { | ||||
|             $destinationStoredObject->addGenerationErrors($e->getMessage()); | ||||
|  | ||||
|   | ||||
| @@ -19,6 +19,7 @@ use Chill\DocGeneratorBundle\Service\Generator\Generator; | ||||
| use Chill\DocGeneratorBundle\Service\Generator\ObjectReadyException; | ||||
| use Chill\DocGeneratorBundle\Service\Generator\RelatedEntityNotFoundException; | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Entity\StoredObjectVersion; | ||||
| use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| @@ -39,11 +40,11 @@ class GeneratorTest extends TestCase | ||||
|  | ||||
|     public function testSuccessfulGeneration(): void | ||||
|     { | ||||
|         $template = (new DocGeneratorTemplate())->setFile($templateStoredObject = (new StoredObject()) | ||||
|             ->setType('application/test')); | ||||
|         $templateStoredObject = new StoredObject(); | ||||
|         $templateStoredObject->registerVersion(type: 'application/test'); | ||||
|         $template = (new DocGeneratorTemplate())->setFile($templateStoredObject); | ||||
|         $destinationStoredObject = (new StoredObject())->setStatus(StoredObject::STATUS_PENDING); | ||||
|         $reflection = new \ReflectionClass($destinationStoredObject); | ||||
|         $reflection->getProperty('id')->setAccessible(true); | ||||
|         $reflection->getProperty('id')->setValue($destinationStoredObject, 1); | ||||
|         $entity = new class () {}; | ||||
|         $data = []; | ||||
| @@ -76,7 +77,14 @@ class GeneratorTest extends TestCase | ||||
|  | ||||
|         $storedObjectManager = $this->prophesize(StoredObjectManagerInterface::class); | ||||
|         $storedObjectManager->read($templateStoredObject)->willReturn('template'); | ||||
|         $storedObjectManager->write($destinationStoredObject, 'generated')->shouldBeCalled(); | ||||
|         $storedObjectManager->write($destinationStoredObject, 'generated', 'application/test') | ||||
|             ->will(function ($args): StoredObjectVersion { | ||||
|                 /** @var StoredObject $storedObject */ | ||||
|                 $storedObject = $args[0]; | ||||
|  | ||||
|                 return $storedObject->registerVersion(type: $args[2]); | ||||
|             }) | ||||
|             ->shouldBeCalled(); | ||||
|  | ||||
|         $generator = new Generator( | ||||
|             $contextManagerInterface->reveal(), | ||||
| @@ -107,8 +115,9 @@ class GeneratorTest extends TestCase | ||||
|             $this->prophesize(StoredObjectManagerInterface::class)->reveal() | ||||
|         ); | ||||
|  | ||||
|         $template = (new DocGeneratorTemplate())->setFile($templateStoredObject = (new StoredObject()) | ||||
|             ->setType('application/test')); | ||||
|         $templateStoredObject = new StoredObject(); | ||||
|         $templateStoredObject->registerVersion(type: 'application/test'); | ||||
|         $template = (new DocGeneratorTemplate())->setFile($templateStoredObject); | ||||
|         $destinationStoredObject = (new StoredObject())->setStatus(StoredObject::STATUS_READY); | ||||
|  | ||||
|         $generator->generateDocFromTemplate( | ||||
| @@ -124,11 +133,11 @@ class GeneratorTest extends TestCase | ||||
|     { | ||||
|         $this->expectException(RelatedEntityNotFoundException::class); | ||||
|  | ||||
|         $template = (new DocGeneratorTemplate())->setFile($templateStoredObject = (new StoredObject()) | ||||
|             ->setType('application/test')); | ||||
|         $templateStoredObject = new StoredObject(); | ||||
|         $templateStoredObject->registerVersion(type: 'application/test'); | ||||
|         $template = (new DocGeneratorTemplate())->setFile($templateStoredObject); | ||||
|         $destinationStoredObject = (new StoredObject())->setStatus(StoredObject::STATUS_PENDING); | ||||
|         $reflection = new \ReflectionClass($destinationStoredObject); | ||||
|         $reflection->getProperty('id')->setAccessible(true); | ||||
|         $reflection->getProperty('id')->setValue($destinationStoredObject, 1); | ||||
|  | ||||
|         $context = $this->prophesize(DocGeneratorContextInterface::class); | ||||
|   | ||||
| @@ -58,6 +58,7 @@ final readonly class TempUrlOpenstackGenerator implements TempUrlGeneratorInterf | ||||
|         ?int $expire_delay = null, | ||||
|         ?int $submit_delay = null, | ||||
|         int $max_file_count = 1, | ||||
|         ?string $object_name = null, | ||||
|     ): SignedUrlPost { | ||||
|         $delay = $expire_delay ?? $this->max_expire_delay; | ||||
|         $submit_delay ??= $this->max_submit_delay; | ||||
| @@ -84,11 +85,14 @@ final readonly class TempUrlOpenstackGenerator implements TempUrlGeneratorInterf | ||||
|  | ||||
|         $expires = $this->clock->now()->add(new \DateInterval('PT'.(string) $delay.'S')); | ||||
|  | ||||
|         $object_name = $this->generateObjectName(); | ||||
|         if (null === $object_name) { | ||||
|             $object_name = $this->generateObjectName(); | ||||
|         } | ||||
|  | ||||
|         $g = new SignedUrlPost( | ||||
|             $url = $this->generateUrl($object_name), | ||||
|             $expires, | ||||
|             $object_name, | ||||
|             $this->max_post_file_size, | ||||
|             $max_file_count, | ||||
|             $submit_delay, | ||||
| @@ -127,7 +131,7 @@ final readonly class TempUrlOpenstackGenerator implements TempUrlGeneratorInterf | ||||
|         ]; | ||||
|         $url = $url.'?'.\http_build_query($args); | ||||
|  | ||||
|         $signature = new SignedUrl(strtoupper($method), $url, $expires); | ||||
|         $signature = new SignedUrl(strtoupper($method), $url, $expires, $object_name); | ||||
|  | ||||
|         $this->event_dispatcher->dispatch( | ||||
|             new TempUrlGenerateEvent($signature) | ||||
| @@ -178,21 +182,19 @@ final readonly class TempUrlOpenstackGenerator implements TempUrlGeneratorInterf | ||||
|         return \hash_hmac('sha512', $body, $this->key, false); | ||||
|     } | ||||
|  | ||||
|     private function generateSignature($method, $url, \DateTimeImmutable $expires) | ||||
|     private function generateSignature(string $method, $url, \DateTimeImmutable $expires) | ||||
|     { | ||||
|         if ('POST' === $method) { | ||||
|             return $this->generateSignaturePost($url, $expires); | ||||
|         } | ||||
|  | ||||
|         $path = \parse_url((string) $url, PHP_URL_PATH); | ||||
|  | ||||
|         $body = sprintf( | ||||
|             "%s\n%s\n%s", | ||||
|             $method, | ||||
|             strtoupper($method), | ||||
|             $expires->format('U'), | ||||
|             $path | ||||
|         ) | ||||
|         ; | ||||
|         ); | ||||
|  | ||||
|         $this->logger->debug( | ||||
|             'generate signature GET', | ||||
|   | ||||
| @@ -21,6 +21,8 @@ readonly class SignedUrl | ||||
|         #[Serializer\Groups(['read'])] | ||||
|         public string $url, | ||||
|         public \DateTimeImmutable $expires, | ||||
|         #[Serializer\Groups(['read'])] | ||||
|         public string $object_name, | ||||
|     ) {} | ||||
|  | ||||
|     #[Serializer\Groups(['read'])] | ||||
|   | ||||
| @@ -18,6 +18,7 @@ readonly class SignedUrlPost extends SignedUrl | ||||
|     public function __construct( | ||||
|         string $url, | ||||
|         \DateTimeImmutable $expires, | ||||
|         string $object_name, | ||||
|         #[Serializer\Groups(['read'])] | ||||
|         public int $max_file_size, | ||||
|         #[Serializer\Groups(['read'])] | ||||
| @@ -31,6 +32,6 @@ readonly class SignedUrlPost extends SignedUrl | ||||
|         #[Serializer\Groups(['read'])] | ||||
|         public string $signature, | ||||
|     ) { | ||||
|         parent::__construct('POST', $url, $expires); | ||||
|         parent::__construct('POST', $url, $expires, $object_name); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -17,6 +17,7 @@ interface TempUrlGeneratorInterface | ||||
|         ?int $expire_delay = null, | ||||
|         ?int $submit_delay = null, | ||||
|         int $max_file_count = 1, | ||||
|         ?string $object_name = null, | ||||
|     ): SignedUrlPost; | ||||
|  | ||||
|     public function generate(string $method, string $object_name, ?int $expire_delay = null): SignedUrl; | ||||
|   | ||||
| @@ -11,9 +11,11 @@ declare(strict_types=1); | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Controller; | ||||
|  | ||||
| use Chill\DocStoreBundle\AsyncUpload\Exception\TempUrlGeneratorException; | ||||
| use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface; | ||||
| use Chill\DocStoreBundle\Security\Authorization\AsyncUploadVoter; | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Entity\StoredObjectVersion; | ||||
| use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Psr\Log\LoggerInterface; | ||||
| use Symfony\Component\HttpFoundation\JsonResponse; | ||||
| use Symfony\Component\HttpFoundation\Request; | ||||
| @@ -30,62 +32,84 @@ final readonly class AsyncUploadController | ||||
|         private TempUrlGeneratorInterface $tempUrlGenerator, | ||||
|         private SerializerInterface $serializer, | ||||
|         private Security $security, | ||||
|         private LoggerInterface $logger, | ||||
|         private LoggerInterface $chillLogger, | ||||
|     ) {} | ||||
|  | ||||
|     #[Route(path: '/asyncupload/temp_url/generate/{method}', name: 'async_upload.generate_url')] | ||||
|     public function getSignedUrl(string $method, Request $request): JsonResponse | ||||
|     #[Route(path: '/api/1.0/doc-store/async-upload/temp_url/{uuid}/generate/post', name: 'chill_docstore_asyncupload_getsignedurlpost')] | ||||
|     public function getSignedUrlPost(Request $request, StoredObject $storedObject): JsonResponse | ||||
|     { | ||||
|         try { | ||||
|             switch (strtolower($method)) { | ||||
|                 case 'post': | ||||
|                     $p = $this->tempUrlGenerator | ||||
|                         ->generatePost( | ||||
|                             $request->query->has('expires_delay') ? $request->query->getInt('expires_delay') : null, | ||||
|                             $request->query->has('submit_delay') ? $request->query->getInt('submit_delay') : null | ||||
|                         ) | ||||
|                     ; | ||||
|                     break; | ||||
|                 case 'get': | ||||
|                 case 'head': | ||||
|                     $object_name = $request->query->get('object_name', null); | ||||
|         if (!$this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $storedObject)) { | ||||
|             throw new AccessDeniedHttpException('not able to edit the given stored object'); | ||||
|         } | ||||
|  | ||||
|                     if (null === $object_name) { | ||||
|                         return (new JsonResponse((object) [ | ||||
|                             'message' => 'the object_name is null', | ||||
|                         ])) | ||||
|                             ->setStatusCode(JsonResponse::HTTP_BAD_REQUEST); | ||||
|                     } | ||||
|                     $p = $this->tempUrlGenerator->generate( | ||||
|                         $method, | ||||
|                         $object_name, | ||||
|                         $request->query->has('expires_delay') ? $request->query->getInt('expires_delay') : null | ||||
|                     ); | ||||
|                     break; | ||||
|                 default: | ||||
|                     return (new JsonResponse((object) ['message' => 'the method ' | ||||
|                         ."{$method} is not valid"])) | ||||
|                         ->setStatusCode(JsonResponse::HTTP_BAD_REQUEST); | ||||
|         // we create a dummy version, to generate a filename | ||||
|         $version = $storedObject->registerVersion(); | ||||
|  | ||||
|         $p = $this->tempUrlGenerator | ||||
|             ->generatePost( | ||||
|                 $request->query->has('expires_delay') ? $request->query->getInt('expires_delay') : null, | ||||
|                 $request->query->has('submit_delay') ? $request->query->getInt('submit_delay') : null, | ||||
|                 object_name: $version->getFilename() | ||||
|             ); | ||||
|  | ||||
|         $this->chillLogger->notice('[Privacy Event] a request to upload a document has been generated', [ | ||||
|             'doc_uuid' => $storedObject->getUuid(), | ||||
|         ]); | ||||
|  | ||||
|         return new JsonResponse( | ||||
|             $this->serializer->serialize($p, 'json', [AbstractNormalizer::GROUPS => ['read']]), | ||||
|             Response::HTTP_OK, | ||||
|             [], | ||||
|             true | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     #[Route(path: '/api/1.0/doc-store/async-upload/temp_url/{uuid}/generate/{method}', name: 'chill_docstore_asyncupload_getsignedurlget', requirements: ['method' => 'get|head'])] | ||||
|     public function getSignedUrlGet(Request $request, StoredObject $storedObject, string $method): JsonResponse | ||||
|     { | ||||
|         if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) { | ||||
|             throw new AccessDeniedHttpException('not able to read the given stored object'); | ||||
|         } | ||||
|  | ||||
|         // we really want to be sure that there are no other method than get or head: | ||||
|         if (!in_array($method, ['get', 'head'], true)) { | ||||
|             throw new AccessDeniedHttpException('Only methods get and head are allowed'); | ||||
|         } | ||||
|  | ||||
|         if ($request->query->has('version')) { | ||||
|             $filename = $request->query->get('version'); | ||||
|  | ||||
|             $storedObjectVersion = $storedObject->getVersions()->findFirst(fn (int $index, StoredObjectVersion $version): bool => $version->getFilename() === $filename); | ||||
|  | ||||
|             if (null === $storedObjectVersion) { | ||||
|                 // we are here in the case where the version is not stored into the database | ||||
|                 // as the version is prefixed by the stored object prefix, we just have to check that this prefix | ||||
|                 // is the same. It means that the user had previously the permission to "SEE_AND_EDIT" this stored | ||||
|                 // object with same prefix that we checked before | ||||
|                 if (!str_starts_with($filename, $storedObject->getPrefix())) { | ||||
|                     throw new AccessDeniedHttpException('not able to match the version with the same filename'); | ||||
|                 } | ||||
|             } | ||||
|         } catch (TempUrlGeneratorException $e) { | ||||
|             $this->logger->warning('The client requested a temp url' | ||||
|                 .' which sparkle an error.', [ | ||||
|                     'message' => $e->getMessage(), | ||||
|                     'expire_delay' => $request->query->getInt('expire_delay', 0), | ||||
|                     'file_count' => $request->query->getInt('file_count', 1), | ||||
|                     'method' => $method, | ||||
|                 ]); | ||||
|  | ||||
|             $p = new \stdClass(); | ||||
|             $p->message = $e->getMessage(); | ||||
|             $p->status = JsonResponse::HTTP_BAD_REQUEST; | ||||
|  | ||||
|             return new JsonResponse($p, JsonResponse::HTTP_BAD_REQUEST); | ||||
|         } else { | ||||
|             $filename = $storedObject->getCurrentVersion()->getFilename(); | ||||
|         } | ||||
|  | ||||
|         if (!$this->security->isGranted(AsyncUploadVoter::GENERATE_SIGNATURE, $p)) { | ||||
|             throw new AccessDeniedHttpException('not allowed to generate this signature'); | ||||
|         } | ||||
|         $p = $this->tempUrlGenerator->generate( | ||||
|             $method, | ||||
|             $filename, | ||||
|             $request->query->has('expires_delay') ? $request->query->getInt('expires_delay') : null | ||||
|         ); | ||||
|  | ||||
|         $user = $this->security->getUser(); | ||||
|         $userId = match ($user instanceof User) { | ||||
|             true => $user->getId(), | ||||
|             false => $user->getUserIdentifier(), | ||||
|         }; | ||||
|  | ||||
|         $this->chillLogger->notice('[Privacy Event] a request to see a document has been granted', [ | ||||
|             'doc_uuid' => $storedObject->getUuid()->toString(), | ||||
|             'user_id' => $userId, | ||||
|         ]); | ||||
|  | ||||
|         return new JsonResponse( | ||||
|             $this->serializer->serialize($p, 'json', [AbstractNormalizer::GROUPS => ['read']]), | ||||
|   | ||||
| @@ -26,6 +26,8 @@ use Symfony\Component\HttpFoundation\Request; | ||||
| use Symfony\Component\HttpFoundation\Response; | ||||
| use Symfony\Component\Routing\Annotation\Route; | ||||
| use Symfony\Contracts\Translation\TranslatorInterface; | ||||
| use Chill\DocStoreBundle\Service\Signature\PDFSignatureZoneParser; | ||||
| use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; | ||||
|  | ||||
| /** | ||||
|  * Class DocumentPersonController. | ||||
| @@ -40,6 +42,8 @@ class DocumentPersonController extends AbstractController | ||||
|         protected TranslatorInterface $translator, | ||||
|         protected EventDispatcherInterface $eventDispatcher, | ||||
|         protected AuthorizationHelper $authorizationHelper, | ||||
|         protected PDFSignatureZoneParser $PDFSignatureZoneParser, | ||||
|         protected StoredObjectManagerInterface $storedObjectManagerInterface, | ||||
|         private readonly \Doctrine\Persistence\ManagerRegistry $managerRegistry, | ||||
|     ) {} | ||||
|  | ||||
| @@ -197,4 +201,36 @@ class DocumentPersonController extends AbstractController | ||||
|             ['document' => $document, 'person' => $person] | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     #[Route(path: '/{id}/signature', name: 'person_document_signature', methods: 'GET')] | ||||
|     public function signature(Person $person, PersonDocument $document): Response | ||||
|     { | ||||
|         $this->denyAccessUnlessGranted('CHILL_PERSON_SEE', $person); | ||||
|         $this->denyAccessUnlessGranted('CHILL_PERSON_DOCUMENT_SEE', $document); | ||||
|  | ||||
|         $event = new PrivacyEvent($person, [ | ||||
|             'element_class' => PersonDocument::class, | ||||
|             'element_id' => $document->getId(), | ||||
|             'action' => 'show', | ||||
|         ]); | ||||
|         $this->eventDispatcher->dispatch($event, PrivacyEvent::PERSON_PRIVACY_EVENT); | ||||
|  | ||||
|         $storedObject = $document->getObject(); | ||||
|         $content = $this->storedObjectManagerInterface->read($storedObject); | ||||
|         $zones = $this->PDFSignatureZoneParser->findSignatureZones($content); | ||||
|  | ||||
|         $signature = []; | ||||
|         $signature['id'] = 1; | ||||
|         $signature['storedObject'] = [ // TEMP | ||||
|             'filename' => $storedObject->getFilename(), | ||||
|             'iv' => $storedObject->getIv(), | ||||
|             'keyInfos' => $storedObject->getKeyInfos(), | ||||
|         ]; | ||||
|         $signature['zones'] = $zones; | ||||
|  | ||||
|         return $this->render( | ||||
|             '@ChillDocStore/PersonDocument/signature.html.twig', | ||||
|             ['document' => $document, 'person' => $person, 'signature' => $signature] | ||||
|         ); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,67 @@ | ||||
| <?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\Service\Signature\Driver\BaseSigner\RequestPdfSignMessage; | ||||
| use Chill\DocStoreBundle\Service\Signature\PDFPage; | ||||
| use Chill\DocStoreBundle\Service\Signature\PDFSignatureZone; | ||||
| use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature; | ||||
| use Chill\MainBundle\Workflow\EntityWorkflowManager; | ||||
| use Symfony\Component\HttpFoundation\JsonResponse; | ||||
| use Symfony\Component\HttpFoundation\Request; | ||||
| use Symfony\Component\Messenger\MessageBusInterface; | ||||
| use Symfony\Component\Routing\Annotation\Route; | ||||
|  | ||||
| class SignatureRequestController | ||||
| { | ||||
|     public function __construct( | ||||
|         private readonly MessageBusInterface $messageBus, | ||||
|         private readonly StoredObjectManagerInterface $storedObjectManager, | ||||
|         private readonly EntityWorkflowManager $entityWorkflowManager, | ||||
|     ) {} | ||||
|  | ||||
|     #[Route('/api/1.0/document/workflow/{id}/signature-request', name: 'chill_docstore_signature_request')] | ||||
|     public function processSignature(EntityWorkflowStepSignature $signature, Request $request): JsonResponse | ||||
|     { | ||||
|         $entityWorkflow = $signature->getStep()->getEntityWorkflow(); | ||||
|         $storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($entityWorkflow); | ||||
|         $content = $this->storedObjectManager->read($storedObject); | ||||
|  | ||||
|         $data = \json_decode((string) $request->getContent(), true, 512, JSON_THROW_ON_ERROR); // TODO parse payload: json_decode ou, mieux, dataTransfertObject | ||||
|         $zone = new PDFSignatureZone( | ||||
|             $data['zone']['index'], | ||||
|             $data['zone']['x'], | ||||
|             $data['zone']['y'], | ||||
|             $data['zone']['height'], | ||||
|             $data['zone']['width'], | ||||
|             new PDFPage($data['zone']['PDFPage']['index'], $data['zone']['PDFPage']['width'], $data['zone']['PDFPage']['height']) | ||||
|         ); | ||||
|  | ||||
|         $this->messageBus->dispatch(new RequestPdfSignMessage( | ||||
|             $signature->getId(), | ||||
|             $zone, | ||||
|             $data['zone']['index'], | ||||
|             'test signature', // reason (string) | ||||
|             'Mme Caroline Diallo', // signerText (string) | ||||
|             $content | ||||
|         )); | ||||
|  | ||||
|         return new JsonResponse(null, JsonResponse::HTTP_OK, []); | ||||
|     } | ||||
|  | ||||
|     #[Route('/api/1.0/document/workflow/{id}/check-signature', name: 'chill_docstore_check_signature')] | ||||
|     public function checkSignature(EntityWorkflowStepSignature $signature): JsonResponse | ||||
|     { | ||||
|         return new JsonResponse($signature->getState(), JsonResponse::HTTP_OK, []); | ||||
|     } | ||||
| } | ||||
| @@ -11,6 +11,46 @@ declare(strict_types=1); | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Controller; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\MainBundle\CRUD\Controller\ApiController; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Symfony\Component\HttpFoundation\JsonResponse; | ||||
| use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; | ||||
| use Symfony\Component\Routing\Annotation\Route; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
| use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; | ||||
| use Symfony\Component\Serializer\SerializerInterface; | ||||
|  | ||||
| class StoredObjectApiController extends ApiController {} | ||||
| class StoredObjectApiController extends ApiController | ||||
| { | ||||
|     public function __construct( | ||||
|         private readonly Security $security, | ||||
|         private readonly SerializerInterface $serializer, | ||||
|         private readonly EntityManagerInterface $entityManager, | ||||
|     ) {} | ||||
|  | ||||
|     /** | ||||
|      * Creates a new stored object. | ||||
|      * | ||||
|      * @return JsonResponse the response containing the serialized object in JSON format | ||||
|      * | ||||
|      * @throws AccessDeniedHttpException if the user does not have the necessary role to create a stored object | ||||
|      */ | ||||
|     #[Route('/api/1.0/doc-store/stored-object/create', methods: ['POST'])] | ||||
|     public function createStoredObject(): JsonResponse | ||||
|     { | ||||
|         if (!($this->security->isGranted('ROLE_ADMIN') || $this->security->isGranted('ROLE_USER'))) { | ||||
|             throw new AccessDeniedHttpException('Must be user or admin to create a stored object'); | ||||
|         } | ||||
|  | ||||
|         $object = new StoredObject(); | ||||
|  | ||||
|         $this->entityManager->persist($object); | ||||
|         $this->entityManager->flush(); | ||||
|  | ||||
|         return new JsonResponse( | ||||
|             $this->serializer->serialize($object, 'json', [AbstractNormalizer::GROUPS => ['read']]), | ||||
|             json: true | ||||
|         ); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -16,6 +16,7 @@ use Chill\DocStoreBundle\Dav\Response\DavResponse; | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; | ||||
| use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Symfony\Component\HttpFoundation\Request; | ||||
| use Symfony\Component\HttpFoundation\Response; | ||||
| use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; | ||||
| @@ -42,6 +43,7 @@ final readonly class WebdavController | ||||
|         private \Twig\Environment $engine, | ||||
|         private StoredObjectManagerInterface $storedObjectManager, | ||||
|         private Security $security, | ||||
|         private EntityManagerInterface $entityManager, | ||||
|     ) { | ||||
|         $this->requestAnalyzer = new PropfindRequestAnalyzer(); | ||||
|     } | ||||
| @@ -201,6 +203,8 @@ final readonly class WebdavController | ||||
|  | ||||
|         $this->storedObjectManager->write($storedObject, $request->getContent()); | ||||
|  | ||||
|         $this->entityManager->flush(); | ||||
|  | ||||
|         return new DavResponse('', Response::HTTP_NO_CONTENT); | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -11,14 +11,13 @@ declare(strict_types=1); | ||||
|  | ||||
| namespace Chill\DocStoreBundle\DependencyInjection; | ||||
|  | ||||
| use Chill\DocStoreBundle\Controller\StoredObjectApiController; | ||||
| use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter; | ||||
| use Chill\DocStoreBundle\Security\Authorization\PersonDocumentVoter; | ||||
| use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoterInterface; | ||||
| use Symfony\Component\Config\FileLocator; | ||||
| use Symfony\Component\DependencyInjection\ContainerBuilder; | ||||
| use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; | ||||
| use Symfony\Component\DependencyInjection\Loader; | ||||
| use Symfony\Component\HttpFoundation\Request; | ||||
| use Symfony\Component\HttpKernel\DependencyInjection\Extension; | ||||
|  | ||||
| /** | ||||
| @@ -35,6 +34,8 @@ class ChillDocStoreExtension extends Extension implements PrependExtensionInterf | ||||
|  | ||||
|         $container->setParameter('chill_doc_store', $config); | ||||
|  | ||||
|         $container->registerForAutoconfiguration(StoredObjectVoterInterface::class)->addTag('stored_object_voter'); | ||||
|  | ||||
|         $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../config')); | ||||
|         $loader->load('services.yaml'); | ||||
|         $loader->load('services/controller.yaml'); | ||||
| @@ -42,6 +43,7 @@ class ChillDocStoreExtension extends Extension implements PrependExtensionInterf | ||||
|         $loader->load('services/fixtures.yaml'); | ||||
|         $loader->load('services/form.yaml'); | ||||
|         $loader->load('services/templating.yaml'); | ||||
|         $loader->load('services/security.yaml'); | ||||
|     } | ||||
|  | ||||
|     public function prepend(ContainerBuilder $container) | ||||
| @@ -49,29 +51,6 @@ class ChillDocStoreExtension extends Extension implements PrependExtensionInterf | ||||
|         $this->prependRoute($container); | ||||
|         $this->prependAuthorization($container); | ||||
|         $this->prependTwig($container); | ||||
|         $this->prependApis($container); | ||||
|     } | ||||
|  | ||||
|     protected function prependApis(ContainerBuilder $container) | ||||
|     { | ||||
|         $container->prependExtensionConfig('chill_main', [ | ||||
|             'apis' => [ | ||||
|                 [ | ||||
|                     'class' => \Chill\DocStoreBundle\Entity\StoredObject::class, | ||||
|                     'controller' => StoredObjectApiController::class, | ||||
|                     'name' => 'stored_object', | ||||
|                     'base_path' => '/api/1.0/docstore/stored-object', | ||||
|                     'base_role' => 'ROLE_USER', | ||||
|                     'actions' => [ | ||||
|                         '_entity' => [ | ||||
|                             'methods' => [ | ||||
|                                 Request::METHOD_POST => true, | ||||
|                             ], | ||||
|                         ], | ||||
|                     ], | ||||
|                 ], | ||||
|             ], | ||||
|         ]); | ||||
|     } | ||||
|  | ||||
|     protected function prependAuthorization(ContainerBuilder $container) | ||||
|   | ||||
| @@ -16,10 +16,14 @@ use ChampsLibres\WopiLib\Contract\Entity\Document; | ||||
| use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate; | ||||
| use Chill\MainBundle\Doctrine\Model\TrackCreationInterface; | ||||
| use Chill\MainBundle\Doctrine\Model\TrackCreationTrait; | ||||
| use Doctrine\Common\Collections\ArrayCollection; | ||||
| use Doctrine\Common\Collections\Collection; | ||||
| use Doctrine\ORM\Mapping as ORM; | ||||
| use Ramsey\Uuid\Uuid; | ||||
| use Ramsey\Uuid\UuidInterface; | ||||
| use Random\RandomException; | ||||
| use Symfony\Component\Serializer\Annotation as Serializer; | ||||
| use Symfony\Component\Validator\Constraints as Assert; | ||||
|  | ||||
| /** | ||||
|  * Represent a document stored in an object store. | ||||
| @@ -28,13 +32,16 @@ use Symfony\Component\Serializer\Annotation as Serializer; | ||||
|  * | ||||
|  * The property `$deleteAt` allow a deletion of the document after the given date. But this property should | ||||
|  * be set before the document is actually written by the StoredObjectManager. | ||||
|  * | ||||
|  * Each version is stored within a @see{StoredObjectVersion}, associated with this current's object. The creation | ||||
|  * of each new version should be done using the method @see{self::registerVersion}. | ||||
|  */ | ||||
| #[ORM\Entity] | ||||
| #[ORM\Table('chill_doc.stored_object')] | ||||
| #[AsyncFileExists(message: 'The file is not stored properly')] | ||||
| #[ORM\Table('stored_object', schema: 'chill_doc')] | ||||
| class StoredObject implements Document, TrackCreationInterface | ||||
| { | ||||
|     use TrackCreationTrait; | ||||
|     final public const STATUS_EMPTY = 'empty'; | ||||
|     final public const STATUS_READY = 'ready'; | ||||
|     final public const STATUS_PENDING = 'pending'; | ||||
|     final public const STATUS_FAILURE = 'failure'; | ||||
| @@ -43,9 +50,11 @@ class StoredObject implements Document, TrackCreationInterface | ||||
|     #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'datas')] | ||||
|     private array $datas = []; | ||||
|  | ||||
|     #[Serializer\Groups(['write'])] | ||||
|     #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT)] | ||||
|     private string $filename = ''; | ||||
|     /** | ||||
|      * the prefix of each version. | ||||
|      */ | ||||
|     #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])] | ||||
|     private string $prefix = ''; | ||||
|  | ||||
|     #[Serializer\Groups(['write'])] | ||||
|     #[ORM\Id] | ||||
| @@ -53,25 +62,10 @@ class StoredObject implements Document, TrackCreationInterface | ||||
|     #[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)] | ||||
|     private ?int $id = null; | ||||
|  | ||||
|     /** | ||||
|      * @var int[] | ||||
|      */ | ||||
|     #[Serializer\Groups(['write'])] | ||||
|     #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'iv')] | ||||
|     private array $iv = []; | ||||
|  | ||||
|     #[Serializer\Groups(['write'])] | ||||
|     #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'key')] | ||||
|     private array $keyInfos = []; | ||||
|  | ||||
|     #[Serializer\Groups(['write'])] | ||||
|     #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, name: 'title')] | ||||
|     #[ORM\Column(name: 'title', type: \Doctrine\DBAL\Types\Types::TEXT, options: ['default' => ''])] | ||||
|     private string $title = ''; | ||||
|  | ||||
|     #[Serializer\Groups(['write'])] | ||||
|     #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, name: 'type', options: ['default' => ''])] | ||||
|     private string $type = ''; | ||||
|  | ||||
|     #[Serializer\Groups(['write'])] | ||||
|     #[ORM\Column(type: 'uuid', unique: true)] | ||||
|     private UuidInterface $uuid; | ||||
| @@ -94,14 +88,22 @@ class StoredObject implements Document, TrackCreationInterface | ||||
|     #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])] | ||||
|     private string $generationErrors = ''; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection<int, StoredObjectVersion> | ||||
|      */ | ||||
|     #[ORM\OneToMany(mappedBy: 'storedObject', targetEntity: StoredObjectVersion::class, cascade: ['persist'], orphanRemoval: true)] | ||||
|     private Collection $versions; | ||||
|  | ||||
|     /** | ||||
|      * @param StoredObject::STATUS_* $status | ||||
|      */ | ||||
|     public function __construct( | ||||
|         #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, options: ['default' => 'ready'])] | ||||
|         private string $status = 'ready', | ||||
|         private string $status = 'empty', | ||||
|     ) { | ||||
|         $this->uuid = Uuid::uuid4(); | ||||
|         $this->versions = new ArrayCollection(); | ||||
|         $this->prefix = self::generatePrefix(); | ||||
|     } | ||||
|  | ||||
|     public function addGenerationTrial(): self | ||||
| @@ -125,14 +127,34 @@ class StoredObject implements Document, TrackCreationInterface | ||||
|         return \DateTime::createFromImmutable($this->createdAt); | ||||
|     } | ||||
|  | ||||
|     #[AsyncFileExists(message: 'The file is not stored properly')] | ||||
|     #[Assert\NotNull(message: 'The store object version must be present')] | ||||
|     public function getCurrentVersion(): ?StoredObjectVersion | ||||
|     { | ||||
|         $maxVersion = null; | ||||
|  | ||||
|         foreach ($this->versions as $v) { | ||||
|             if ($v->getVersion() > ($maxVersion?->getVersion() ?? -1)) { | ||||
|                 $maxVersion = $v; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return $maxVersion; | ||||
|     } | ||||
|  | ||||
|     public function getDatas(): array | ||||
|     { | ||||
|         return $this->datas; | ||||
|     } | ||||
|  | ||||
|     public function getPrefix(): string | ||||
|     { | ||||
|         return $this->prefix; | ||||
|     } | ||||
|  | ||||
|     public function getFilename(): string | ||||
|     { | ||||
|         return $this->filename; | ||||
|         return $this->getCurrentVersion()?->getFilename() ?? ''; | ||||
|     } | ||||
|  | ||||
|     public function getGenerationTrialsCounter(): int | ||||
| @@ -145,14 +167,17 @@ class StoredObject implements Document, TrackCreationInterface | ||||
|         return $this->id; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @return list<int> | ||||
|      */ | ||||
|     public function getIv(): array | ||||
|     { | ||||
|         return $this->iv; | ||||
|         return $this->getCurrentVersion()?->getIv() ?? []; | ||||
|     } | ||||
|  | ||||
|     public function getKeyInfos(): array | ||||
|     { | ||||
|         return $this->keyInfos; | ||||
|         return $this->getCurrentVersion()?->getKeyInfos() ?? []; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -171,14 +196,14 @@ class StoredObject implements Document, TrackCreationInterface | ||||
|         return $this->status; | ||||
|     } | ||||
|  | ||||
|     public function getTitle() | ||||
|     public function getTitle(): string | ||||
|     { | ||||
|         return $this->title; | ||||
|     } | ||||
|  | ||||
|     public function getType() | ||||
|     public function getType(): string | ||||
|     { | ||||
|         return $this->type; | ||||
|         return $this->getCurrentVersion()?->getType() ?? ''; | ||||
|     } | ||||
|  | ||||
|     public function getUuid(): UuidInterface | ||||
| @@ -209,27 +234,6 @@ class StoredObject implements Document, TrackCreationInterface | ||||
|         return $this; | ||||
|     } | ||||
|  | ||||
|     public function setFilename(?string $filename): self | ||||
|     { | ||||
|         $this->filename = (string) $filename; | ||||
|  | ||||
|         return $this; | ||||
|     } | ||||
|  | ||||
|     public function setIv(?array $iv): self | ||||
|     { | ||||
|         $this->iv = (array) $iv; | ||||
|  | ||||
|         return $this; | ||||
|     } | ||||
|  | ||||
|     public function setKeyInfos(?array $keyInfos): self | ||||
|     { | ||||
|         $this->keyInfos = (array) $keyInfos; | ||||
|  | ||||
|         return $this; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @param StoredObject::STATUS_* $status | ||||
|      */ | ||||
| @@ -247,18 +251,21 @@ class StoredObject implements Document, TrackCreationInterface | ||||
|         return $this; | ||||
|     } | ||||
|  | ||||
|     public function setType(?string $type): self | ||||
|     { | ||||
|         $this->type = (string) $type; | ||||
|  | ||||
|         return $this; | ||||
|     } | ||||
|  | ||||
|     public function getTemplate(): ?DocGeneratorTemplate | ||||
|     { | ||||
|         return $this->template; | ||||
|     } | ||||
|  | ||||
|     public function getVersions(): Collection | ||||
|     { | ||||
|         return $this->versions; | ||||
|     } | ||||
|  | ||||
|     public function hasCurrentVersion(): bool | ||||
|     { | ||||
|         return null !== $this->getCurrentVersion(); | ||||
|     } | ||||
|  | ||||
|     public function hasTemplate(): bool | ||||
|     { | ||||
|         return null !== $this->template; | ||||
| @@ -314,18 +321,65 @@ class StoredObject implements Document, TrackCreationInterface | ||||
|         return $this; | ||||
|     } | ||||
|  | ||||
|     public function saveHistory(): void | ||||
|     { | ||||
|         if ('' === $this->getFilename()) { | ||||
|             return; | ||||
|     public function registerVersion( | ||||
|         array $iv = [], | ||||
|         array $keyInfos = [], | ||||
|         string $type = '', | ||||
|         ?string $filename = null, | ||||
|     ): StoredObjectVersion { | ||||
|         $version = new StoredObjectVersion( | ||||
|             $this, | ||||
|             null === $this->getCurrentVersion() ? 0 : $this->getCurrentVersion()->getVersion() + 1, | ||||
|             $iv, | ||||
|             $keyInfos, | ||||
|             $type, | ||||
|             $filename | ||||
|         ); | ||||
|  | ||||
|         $this->versions->add($version); | ||||
|  | ||||
|         if ('empty' === $this->status) { | ||||
|             $this->status = self::STATUS_READY; | ||||
|         } | ||||
|  | ||||
|         $this->datas['history'][] = [ | ||||
|             'filename' => $this->getFilename(), | ||||
|             'iv' => $this->getIv(), | ||||
|             'key_infos' => $this->getKeyInfos(), | ||||
|             'type' => $this->getType(), | ||||
|             'before' => (new \DateTimeImmutable('now'))->getTimestamp(), | ||||
|         ]; | ||||
|         return $version; | ||||
|     } | ||||
|  | ||||
|     public function removeVersion(StoredObjectVersion $storedObjectVersion): void | ||||
|     { | ||||
|         if (!$this->versions->contains($storedObjectVersion)) { | ||||
|             throw new \UnexpectedValueException('This stored object does not contains this version'); | ||||
|         } | ||||
|         $this->versions->removeElement($storedObjectVersion); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @deprecated | ||||
|      */ | ||||
|     public function saveHistory(): void {} | ||||
|  | ||||
|     public static function generatePrefix(): string | ||||
|     { | ||||
|         try { | ||||
|             return base_convert(bin2hex(random_bytes(32)), 16, 36); | ||||
|         } catch (RandomException) { | ||||
|             return uniqid(more_entropy: true); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Checks if a stored object can be deleted. | ||||
|      * | ||||
|      * Currently, return true if the deletedAt date is below the current date, and the object | ||||
|      * does not contains any version (which must be removed first). | ||||
|      * | ||||
|      * @param \DateTimeImmutable $now          the current date and time | ||||
|      * @param StoredObject       $storedObject the stored object to check | ||||
|      * | ||||
|      * @return bool returns true if the stored object can be deleted, false otherwise | ||||
|      */ | ||||
|     public static function canBeDeleted(\DateTimeImmutable $now, StoredObject $storedObject): bool | ||||
|     { | ||||
|         return $storedObject->getDeleteAt() < $now && $storedObject->getVersions()->isEmpty(); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,67 @@ | ||||
| <?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\Entity; | ||||
|  | ||||
| use Chill\MainBundle\Doctrine\Model\TrackCreationInterface; | ||||
| use Chill\MainBundle\Doctrine\Model\TrackCreationTrait; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Doctrine\ORM\Mapping as ORM; | ||||
|  | ||||
| /** | ||||
|  * Represents a snapshot of a stored object at a specific point in time. | ||||
|  * | ||||
|  * This entity tracks versions of stored objects, reasons for the snapshot, | ||||
|  * and the user who initiated the action. | ||||
|  */ | ||||
| #[ORM\Entity] | ||||
| #[ORM\Table(name: 'stored_object_point_in_time', schema: 'chill_doc')] | ||||
| class StoredObjectPointInTime implements TrackCreationInterface | ||||
| { | ||||
|     use TrackCreationTrait; | ||||
|  | ||||
|     #[ORM\Id] | ||||
|     #[ORM\GeneratedValue] | ||||
|     #[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)] | ||||
|     private ?int $id = null; | ||||
|  | ||||
|     public function __construct( | ||||
|         #[ORM\ManyToOne(targetEntity: StoredObjectVersion::class, inversedBy: 'pointInTimes')] | ||||
|         #[ORM\JoinColumn(name: 'stored_object_version_id', nullable: false)] | ||||
|         private StoredObjectVersion $objectVersion, | ||||
|         #[ORM\Column(name: 'reason', type: 'text', nullable: false, enumType: StoredObjectPointInTimeReasonEnum::class)] | ||||
|         private StoredObjectPointInTimeReasonEnum $reason, | ||||
|         #[ORM\ManyToOne(targetEntity: User::class)] | ||||
|         private ?User $byUser = null, | ||||
|     ) { | ||||
|         $this->objectVersion->addPointInTime($this); | ||||
|     } | ||||
|  | ||||
|     public function getId(): ?int | ||||
|     { | ||||
|         return $this->id; | ||||
|     } | ||||
|  | ||||
|     public function getByUser(): ?User | ||||
|     { | ||||
|         return $this->byUser; | ||||
|     } | ||||
|  | ||||
|     public function getObjectVersion(): StoredObjectVersion | ||||
|     { | ||||
|         return $this->objectVersion; | ||||
|     } | ||||
|  | ||||
|     public function getReason(): StoredObjectPointInTimeReasonEnum | ||||
|     { | ||||
|         return $this->reason; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,18 @@ | ||||
| <?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\Entity; | ||||
|  | ||||
| enum StoredObjectPointInTimeReasonEnum: string | ||||
| { | ||||
|     case KEEP_BEFORE_CONVERSION = 'keep-before-conversion'; | ||||
|     case KEEP_BY_USER = 'keep-by-user'; | ||||
| } | ||||
							
								
								
									
										173
									
								
								src/Bundle/ChillDocStoreBundle/Entity/StoredObjectVersion.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										173
									
								
								src/Bundle/ChillDocStoreBundle/Entity/StoredObjectVersion.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,173 @@ | ||||
| <?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\Entity; | ||||
|  | ||||
| use Chill\MainBundle\Doctrine\Model\TrackCreationInterface; | ||||
| use Chill\MainBundle\Doctrine\Model\TrackCreationTrait; | ||||
| use Doctrine\Common\Collections\ArrayCollection; | ||||
| use Doctrine\Common\Collections\Collection; | ||||
| use Doctrine\Common\Collections\Selectable; | ||||
| use Doctrine\ORM\Mapping as ORM; | ||||
| use Random\RandomException; | ||||
|  | ||||
| /** | ||||
|  * Store each version of StoredObject's. | ||||
|  * | ||||
|  * A version should not be created manually: use the method @see{StoredObject::registerVersion} instead. | ||||
|  */ | ||||
| #[ORM\Entity] | ||||
| #[ORM\Table('chill_doc.stored_object_version')] | ||||
| #[ORM\UniqueConstraint(name: 'chill_doc_stored_object_version_unique_by_object', columns: ['stored_object_id', 'version'])] | ||||
| class StoredObjectVersion implements TrackCreationInterface | ||||
| { | ||||
|     use TrackCreationTrait; | ||||
|  | ||||
|     #[ORM\Id] | ||||
|     #[ORM\GeneratedValue] | ||||
|     #[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)] | ||||
|     private ?int $id = null; | ||||
|  | ||||
|     /** | ||||
|      * filename of the version in the stored object. | ||||
|      */ | ||||
|     #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])] | ||||
|     private string $filename = ''; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection<int, StoredObjectPointInTime>&Selectable<int, StoredObjectPointInTime> | ||||
|      */ | ||||
|     #[ORM\OneToMany(mappedBy: 'objectVersion', targetEntity: StoredObjectPointInTime::class, cascade: ['persist', 'remove'], orphanRemoval: true)] | ||||
|     private Collection&Selectable $pointInTimes; | ||||
|  | ||||
|     public function __construct( | ||||
|         /** | ||||
|          * The stored object associated with this version. | ||||
|          */ | ||||
|         #[ORM\ManyToOne(targetEntity: StoredObject::class, inversedBy: 'versions')] | ||||
|         #[ORM\JoinColumn(name: 'stored_object_id', nullable: false)] | ||||
|         private StoredObject $storedObject, | ||||
|  | ||||
|         /** | ||||
|          * The incremental version. | ||||
|          */ | ||||
|         #[ORM\Column(name: 'version', type: \Doctrine\DBAL\Types\Types::INTEGER, options: ['default' => 0])] | ||||
|         private int $version = 0, | ||||
|  | ||||
|         /** | ||||
|          * vector for encryption. | ||||
|          * | ||||
|          * @var int[] | ||||
|          */ | ||||
|         #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'iv')] | ||||
|         private array $iv = [], | ||||
|  | ||||
|         /** | ||||
|          * Key infos for document encryption. | ||||
|          * | ||||
|          * @var array | ||||
|          */ | ||||
|         #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'key')] | ||||
|         private array $keyInfos = [], | ||||
|  | ||||
|         /** | ||||
|          * type of the document. | ||||
|          */ | ||||
|         #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, name: 'type', options: ['default' => ''])] | ||||
|         private string $type = '', | ||||
|         ?string $filename = null, | ||||
|     ) { | ||||
|         $this->filename = $filename ?? self::generateFilename($this); | ||||
|         $this->pointInTimes = new ArrayCollection(); | ||||
|     } | ||||
|  | ||||
|     public static function generateFilename(StoredObjectVersion $storedObjectVersion): string | ||||
|     { | ||||
|         try { | ||||
|             $suffix = base_convert(bin2hex(random_bytes(8)), 16, 36); | ||||
|         } catch (RandomException) { | ||||
|             $suffix = uniqid(more_entropy: true); | ||||
|         } | ||||
|  | ||||
|         return $storedObjectVersion->getStoredObject()->getPrefix().'/'.$suffix; | ||||
|     } | ||||
|  | ||||
|     public function getFilename(): string | ||||
|     { | ||||
|         return $this->filename; | ||||
|     } | ||||
|  | ||||
|     public function getId(): ?int | ||||
|     { | ||||
|         return $this->id; | ||||
|     } | ||||
|  | ||||
|     public function getIv(): array | ||||
|     { | ||||
|         return $this->iv; | ||||
|     } | ||||
|  | ||||
|     public function getKeyInfos(): array | ||||
|     { | ||||
|         return $this->keyInfos; | ||||
|     } | ||||
|  | ||||
|     public function getStoredObject(): StoredObject | ||||
|     { | ||||
|         return $this->storedObject; | ||||
|     } | ||||
|  | ||||
|     public function getType(): string | ||||
|     { | ||||
|         return $this->type; | ||||
|     } | ||||
|  | ||||
|     public function getVersion(): int | ||||
|     { | ||||
|         return $this->version; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @return Collection<int, StoredObjectPointInTime>&Selectable<int, StoredObjectPointInTime> | ||||
|      */ | ||||
|     public function getPointInTimes(): Selectable&Collection | ||||
|     { | ||||
|         return $this->pointInTimes; | ||||
|     } | ||||
|  | ||||
|     public function hasPointInTimes(): bool | ||||
|     { | ||||
|         return $this->pointInTimes->count() > 0; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @return $this | ||||
|      * | ||||
|      * @internal use @see{StoredObjectPointInTime} constructor instead | ||||
|      */ | ||||
|     public function addPointInTime(StoredObjectPointInTime $storedObjectPointInTime): self | ||||
|     { | ||||
|         if (!$this->pointInTimes->contains($storedObjectPointInTime)) { | ||||
|             $this->pointInTimes->add($storedObjectPointInTime); | ||||
|         } | ||||
|  | ||||
|         return $this; | ||||
|     } | ||||
|  | ||||
|     public function removePointInTime(StoredObjectPointInTime $storedObjectPointInTime): self | ||||
|     { | ||||
|         if ($this->pointInTimes->contains($storedObjectPointInTime)) { | ||||
|             $this->pointInTimes->removeElement($storedObjectPointInTime); | ||||
|         } | ||||
|  | ||||
|         return $this; | ||||
|     } | ||||
| } | ||||
| @@ -55,16 +55,8 @@ class StoredObjectDataMapper implements DataMapperInterface | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         /** @var StoredObject $viewData */ | ||||
|         if ($viewData->getFilename() !== $forms['stored_object']->getData()['filename']) { | ||||
|             // we want to keep the previous history | ||||
|             $viewData->saveHistory(); | ||||
|         } | ||||
|  | ||||
|         $viewData->setFilename($forms['stored_object']->getData()['filename']); | ||||
|         $viewData->setIv($forms['stored_object']->getData()['iv']); | ||||
|         $viewData->setKeyInfos($forms['stored_object']->getData()['keyInfos']); | ||||
|         $viewData->setType($forms['stored_object']->getData()['type']); | ||||
|         /* @var StoredObject $viewData */ | ||||
|         $viewData = $forms['stored_object']->getData(); | ||||
|  | ||||
|         if (array_key_exists('title', $forms)) { | ||||
|             $viewData->setTitle($forms['title']->getData()); | ||||
|   | ||||
| @@ -12,7 +12,6 @@ declare(strict_types=1); | ||||
| 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; | ||||
| @@ -30,11 +29,7 @@ class StoredObjectDataTransformer implements DataTransformerInterface | ||||
|         } | ||||
|  | ||||
|         if ($value instanceof StoredObject) { | ||||
|             return $this->serializer->serialize($value, 'json', [ | ||||
|                 'groups' => [ | ||||
|                     StoredObjectNormalizer::ADD_DAV_EDIT_LINK_CONTEXT, | ||||
|                 ], | ||||
|             ]); | ||||
|             return $this->serializer->serialize($value, 'json'); | ||||
|         } | ||||
|  | ||||
|         throw new UnexpectedTypeException($value, StoredObject::class); | ||||
| @@ -46,6 +41,6 @@ class StoredObjectDataTransformer implements DataTransformerInterface | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         return json_decode((string) $value, true, 10, JSON_THROW_ON_ERROR); | ||||
|         return $this->serializer->deserialize($value, StoredObject::class, 'json'); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -12,13 +12,14 @@ declare(strict_types=1); | ||||
| namespace Chill\DocStoreBundle\Repository; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument; | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\PersonBundle\Entity\AccompanyingPeriod; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Doctrine\ORM\EntityRepository; | ||||
| use Doctrine\ORM\QueryBuilder; | ||||
| use Doctrine\Persistence\ObjectRepository; | ||||
|  | ||||
| class AccompanyingCourseDocumentRepository implements ObjectRepository | ||||
| class AccompanyingCourseDocumentRepository implements ObjectRepository, AssociatedEntityToStoredObjectInterface | ||||
| { | ||||
|     private readonly EntityRepository $repository; | ||||
|  | ||||
| @@ -45,6 +46,16 @@ class AccompanyingCourseDocumentRepository implements ObjectRepository | ||||
|         return $qb->getQuery()->getSingleScalarResult(); | ||||
|     } | ||||
|  | ||||
|     public function findAssociatedEntityToStoredObject(StoredObject $storedObject): ?object | ||||
|     { | ||||
|         $qb = $this->repository->createQueryBuilder('d'); | ||||
|         $query = $qb->where('d.object = :storedObject') | ||||
|             ->setParameter('storedObject', $storedObject) | ||||
|             ->getQuery(); | ||||
|  | ||||
|         return $query->getOneOrNullResult(); | ||||
|     } | ||||
|  | ||||
|     public function find($id): ?AccompanyingCourseDocument | ||||
|     { | ||||
|         return $this->repository->find($id); | ||||
| @@ -55,7 +66,7 @@ class AccompanyingCourseDocumentRepository implements ObjectRepository | ||||
|         return $this->repository->findAll(); | ||||
|     } | ||||
|  | ||||
|     public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null) | ||||
|     public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null): array | ||||
|     { | ||||
|         return $this->repository->findBy($criteria, $orderBy, $limit, $offset); | ||||
|     } | ||||
| @@ -65,7 +76,7 @@ class AccompanyingCourseDocumentRepository implements ObjectRepository | ||||
|         return $this->findOneBy($criteria); | ||||
|     } | ||||
|  | ||||
|     public function getClassName() | ||||
|     public function getClassName(): string | ||||
|     { | ||||
|         return AccompanyingCourseDocument::class; | ||||
|     } | ||||
|   | ||||
| @@ -0,0 +1,19 @@ | ||||
| <?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\Repository; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
|  | ||||
| interface AssociatedEntityToStoredObjectInterface | ||||
| { | ||||
|     public function findAssociatedEntityToStoredObject(StoredObject $storedObject): ?object; | ||||
| } | ||||
| @@ -12,6 +12,7 @@ declare(strict_types=1); | ||||
| namespace Chill\DocStoreBundle\Repository; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\PersonDocument; | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Doctrine\ORM\EntityRepository; | ||||
| use Doctrine\Persistence\ObjectRepository; | ||||
| @@ -19,7 +20,7 @@ use Doctrine\Persistence\ObjectRepository; | ||||
| /** | ||||
|  * @template ObjectRepository<PersonDocument::class> | ||||
|  */ | ||||
| readonly class PersonDocumentRepository implements ObjectRepository | ||||
| readonly class PersonDocumentRepository implements ObjectRepository, AssociatedEntityToStoredObjectInterface | ||||
| { | ||||
|     private EntityRepository $repository; | ||||
|  | ||||
| @@ -53,4 +54,14 @@ readonly class PersonDocumentRepository implements ObjectRepository | ||||
|     { | ||||
|         return PersonDocument::class; | ||||
|     } | ||||
|  | ||||
|     public function findAssociatedEntityToStoredObject(StoredObject $storedObject): ?object | ||||
|     { | ||||
|         $qb = $this->repository->createQueryBuilder('d'); | ||||
|         $query = $qb->where('d.object = :storedObject') | ||||
|             ->setParameter('storedObject', $storedObject) | ||||
|             ->getQuery(); | ||||
|  | ||||
|         return $query->getOneOrNullResult(); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,27 @@ | ||||
| <?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\Repository; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\StoredObjectPointInTime; | ||||
| use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; | ||||
| use Doctrine\Persistence\ManagerRegistry; | ||||
|  | ||||
| /** | ||||
|  * @template-extends ServiceEntityRepository<StoredObjectPointInTime> | ||||
|  */ | ||||
| class StoredObjectPointInTimeRepository extends ServiceEntityRepository | ||||
| { | ||||
|     public function __construct(ManagerRegistry $registry) | ||||
|     { | ||||
|         parent::__construct($registry, StoredObjectPointInTime::class); | ||||
|     } | ||||
| } | ||||
| @@ -14,6 +14,7 @@ namespace Chill\DocStoreBundle\Repository; | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Doctrine\ORM\EntityRepository; | ||||
| use Doctrine\ORM\Query; | ||||
|  | ||||
| final readonly class StoredObjectRepository implements StoredObjectRepositoryInterface | ||||
| { | ||||
| @@ -53,6 +54,21 @@ final readonly class StoredObjectRepository implements StoredObjectRepositoryInt | ||||
|         return $this->repository->findOneBy($criteria); | ||||
|     } | ||||
|  | ||||
|     public function findByExpired(\DateTimeImmutable $expiredAtDate): iterable | ||||
|     { | ||||
|         $qb = $this->repository->createQueryBuilder('stored_object'); | ||||
|         $qb | ||||
|             ->where('stored_object.deleteAt <= :expiredAt') | ||||
|             ->setParameter('expiredAt', $expiredAtDate); | ||||
|  | ||||
|         return $qb->getQuery()->toIterable(hydrationMode: Query::HYDRATE_OBJECT); | ||||
|     } | ||||
|  | ||||
|     public function findOneByUUID(string $uuid): ?StoredObject | ||||
|     { | ||||
|         return $this->repository->findOneBy(['uuid' => $uuid]); | ||||
|     } | ||||
|  | ||||
|     public function getClassName(): string | ||||
|     { | ||||
|         return StoredObject::class; | ||||
|   | ||||
| @@ -17,4 +17,12 @@ use Doctrine\Persistence\ObjectRepository; | ||||
| /** | ||||
|  * @extends ObjectRepository<StoredObject> | ||||
|  */ | ||||
| interface StoredObjectRepositoryInterface extends ObjectRepository {} | ||||
| interface StoredObjectRepositoryInterface extends ObjectRepository | ||||
| { | ||||
|     /** | ||||
|      * @return iterable<StoredObject> | ||||
|      */ | ||||
|     public function findByExpired(\DateTimeImmutable $expiredAtDate): iterable; | ||||
|  | ||||
|     public function findOneByUUID(string $uuid): ?StoredObject; | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,94 @@ | ||||
| <?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\Repository; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\StoredObjectVersion; | ||||
| use Doctrine\DBAL\Connection; | ||||
| use Doctrine\DBAL\Types\Types; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Doctrine\ORM\EntityRepository; | ||||
| use Doctrine\Persistence\ObjectRepository; | ||||
|  | ||||
| /** | ||||
|  * @implements ObjectRepository<StoredObjectVersion> | ||||
|  */ | ||||
| class StoredObjectVersionRepository implements ObjectRepository | ||||
| { | ||||
|     private readonly EntityRepository $repository; | ||||
|  | ||||
|     private readonly Connection $connection; | ||||
|  | ||||
|     public function __construct(EntityManagerInterface $entityManager) | ||||
|     { | ||||
|         $this->repository = $entityManager->getRepository(StoredObjectVersion::class); | ||||
|         $this->connection = $entityManager->getConnection(); | ||||
|     } | ||||
|  | ||||
|     public function find($id): ?StoredObjectVersion | ||||
|     { | ||||
|         return $this->repository->find($id); | ||||
|     } | ||||
|  | ||||
|     public function findAll(): array | ||||
|     { | ||||
|         return $this->repository->findAll(); | ||||
|     } | ||||
|  | ||||
|     public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array | ||||
|     { | ||||
|         return $this->repository->findBy($criteria, $orderBy, $limit, $offset); | ||||
|     } | ||||
|  | ||||
|     public function findOneBy(array $criteria): ?StoredObjectVersion | ||||
|     { | ||||
|         return $this->repository->findOneBy($criteria); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Finds the IDs of versions older than a given date and that are not the last version. | ||||
|      * | ||||
|      * Those version are good candidates for a deletion. | ||||
|      * | ||||
|      * @param \DateTimeImmutable $beforeDate the date to compare versions against | ||||
|      * | ||||
|      * @return iterable returns an iterable with the IDs of the versions | ||||
|      */ | ||||
|     public function findIdsByVersionsOlderThanDateAndNotLastVersionAndNotPointInTime(\DateTimeImmutable $beforeDate): iterable | ||||
|     { | ||||
|         $results = $this->connection->executeQuery( | ||||
|             self::QUERY_FIND_IDS_BY_VERSIONS_OLDER_THAN_DATE_AND_NOT_LAST_VERSION, | ||||
|             [$beforeDate], | ||||
|             [Types::DATETIME_IMMUTABLE] | ||||
|         ); | ||||
|  | ||||
|         foreach ($results->iterateAssociative() as $row) { | ||||
|             yield $row['sov_id']; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private const QUERY_FIND_IDS_BY_VERSIONS_OLDER_THAN_DATE_AND_NOT_LAST_VERSION = <<<'SQL' | ||||
|         SELECT | ||||
|             sov.id AS sov_id | ||||
|         FROM chill_doc.stored_object_version sov | ||||
|         WHERE | ||||
|             sov.createdat < ?::timestamp | ||||
|             AND | ||||
|             sov.version < (SELECT MAX(sub_sov.version) FROM chill_doc.stored_object_version sub_sov WHERE sub_sov.stored_object_id = sov.stored_object_id) | ||||
|             AND | ||||
|             NOT EXISTS (SELECT 1 FROM chill_doc.stored_object_point_in_time sub_poi WHERE sub_poi.stored_object_version_id = sov.id) | ||||
|         SQL; | ||||
|  | ||||
|     public function getClassName(): string | ||||
|     { | ||||
|         return StoredObjectVersion::class; | ||||
|     } | ||||
| } | ||||
| @@ -1,5 +1,5 @@ | ||||
| import {makeFetch} from "../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods"; | ||||
| import {PostStoreObjectSignature} from "../../types"; | ||||
| import {PostStoreObjectSignature, StoredObject} from "../../types"; | ||||
| 
 | ||||
| const algo = 'AES-CBC'; | ||||
| 
 | ||||
| @@ -21,11 +21,22 @@ const createFilename = (): string => { | ||||
|     return text; | ||||
| }; | ||||
| 
 | ||||
| export const uploadFile = async (uploadFile: ArrayBuffer): Promise<string> => { | ||||
| /** | ||||
|  * Fetches a new stored object from the server. | ||||
|  * | ||||
|  * @async | ||||
|  * @function fetchNewStoredObject | ||||
|  * @returns {Promise<StoredObject>} A Promise that resolves to the newly created StoredObject. | ||||
|  */ | ||||
| export const fetchNewStoredObject = async (): Promise<StoredObject> => { | ||||
|     return makeFetch("POST", '/api/1.0/doc-store/stored-object/create', null); | ||||
| } | ||||
| 
 | ||||
| export const uploadVersion = async (uploadFile: ArrayBuffer, storedObject: StoredObject): 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 asyncData: PostStoreObjectSignature = await makeFetch("GET", `/api/1.0/doc-store/async-upload/temp_url/${storedObject.uuid}/generate/post` + "?" + params.toString()); | ||||
|     const suffix = createFilename(); | ||||
|     const filename = asyncData.prefix + suffix; | ||||
|     const formData = new FormData(); | ||||
| @@ -50,7 +61,6 @@ export const uploadFile = async (uploadFile: ArrayBuffer): Promise<string> => { | ||||
| } | ||||
| 
 | ||||
| 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); | ||||
| @@ -1,7 +1,7 @@ | ||||
| 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 {StoredObject, StoredObjectVersion} from "../../types"; | ||||
| import {_createI18n} from "../../../../../ChillMainBundle/Resources/public/vuejs/_js/i18n"; | ||||
| const i18n = _createI18n({}); | ||||
|  | ||||
| @@ -30,15 +30,17 @@ const startApp = (divElement: HTMLDivElement, collectionEntry: null|HTMLLIElemen | ||||
|             DropFileWidget, | ||||
|         }, | ||||
|         methods: { | ||||
|             addDocument: function(object: StoredObjectCreated): void { | ||||
|                 console.log('object added', object); | ||||
|                 this.$data.existingDoc = object; | ||||
|                 input_stored_object.value = JSON.stringify(object); | ||||
|             addDocument: function({stored_object, stored_object_version}: {stored_object: StoredObject, stored_object_version: StoredObjectVersion}): void { | ||||
|                 console.log('object added', stored_object); | ||||
|                 console.log('version added', stored_object_version); | ||||
|                 this.$data.existingDoc = stored_object; | ||||
|                 this.$data.existingDoc.currentVersion = stored_object_version; | ||||
|                 input_stored_object.value = JSON.stringify(this.$data.existingDoc); | ||||
|             }, | ||||
|             removeDocument: function(object: StoredObject): void { | ||||
|                 console.log('catch remove document', object); | ||||
|                 input_stored_object.value = ""; | ||||
|                 this.$data.existingDoc = null; | ||||
|                 this.$data.existingDoc = undefined; | ||||
|                 console.log('collectionEntry', collectionEntry); | ||||
|  | ||||
|                 if (null !== collectionEntry) { | ||||
|   | ||||
| @@ -1,38 +1,53 @@ | ||||
| import {DateTime} from "../../../ChillMainBundle/Resources/public/types"; | ||||
| import {DateTime, User} from "../../../ChillMainBundle/Resources/public/types"; | ||||
|  | ||||
| export type StoredObjectStatus = "ready"|"failure"|"pending"; | ||||
| export type StoredObjectStatus = "empty"|"ready"|"failure"|"pending"; | ||||
|  | ||||
| export interface StoredObject { | ||||
|   id: number, | ||||
|  | ||||
|   /** | ||||
|    * filename of the object in the object storage | ||||
|    */ | ||||
|   filename: string, | ||||
|   creationDate: DateTime, | ||||
|   datas: object, | ||||
|   iv: number[], | ||||
|   keyInfos: object, | ||||
|   title: string, | ||||
|   type: string, | ||||
|   uuid: string, | ||||
|   status: StoredObjectStatus, | ||||
|     id: number, | ||||
|     title: string|null, | ||||
|     uuid: string, | ||||
|     prefix: string, | ||||
|     status: StoredObjectStatus, | ||||
|     currentVersion: null|StoredObjectVersionCreated|StoredObjectVersionPersisted, | ||||
|     totalVersions: number, | ||||
|     datas: object, | ||||
|     /** @deprecated */ | ||||
|     creationDate: DateTime, | ||||
|     createdAt: DateTime|null, | ||||
|     createdBy: User|null, | ||||
|     _permissions: { | ||||
|         canEdit: boolean, | ||||
|         canSee: boolean, | ||||
|     }, | ||||
|     _links?: { | ||||
|       dav_link?: { | ||||
|           href: string | ||||
|           expiration: number | ||||
|       }, | ||||
|     } | ||||
|         dav_link?: { | ||||
|             href: string | ||||
|             expiration: number | ||||
|         }, | ||||
|     }, | ||||
| } | ||||
|  | ||||
| export interface StoredObjectCreated { | ||||
|     status: "stored_object_created", | ||||
| export interface StoredObjectVersion { | ||||
|     /** | ||||
|      * filename of the object in the object storage | ||||
|      */ | ||||
|     filename: string, | ||||
|     iv: Uint8Array, | ||||
|     keyInfos: object, | ||||
|     iv: number[], | ||||
|     keyInfos: JsonWebKey, | ||||
|     type: string, | ||||
| } | ||||
|  | ||||
| export interface StoredObjectVersionCreated extends StoredObjectVersion { | ||||
|     persisted: false, | ||||
| } | ||||
|  | ||||
| export interface StoredObjectVersionPersisted extends StoredObjectVersionCreated { | ||||
|     version: number, | ||||
|     id: number, | ||||
|     createdAt: DateTime|null, | ||||
|     createdBy: User|null, | ||||
| } | ||||
|  | ||||
| export interface StoredObjectStatusChange { | ||||
|   id: number, | ||||
|   filename: string, | ||||
| @@ -51,14 +66,35 @@ export type WopiEditButtonExecutableBeforeLeaveFunction = { | ||||
|  * 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, | ||||
|   method: "POST", | ||||
|   max_file_size: number, | ||||
|   max_file_count: 1, | ||||
|   expires: number, | ||||
|   submit_delay: 180, | ||||
|   redirect: string, | ||||
|   prefix: string, | ||||
|   url: string, | ||||
|   signature: string, | ||||
| } | ||||
|  | ||||
| export interface PDFPage { | ||||
|   index: number, | ||||
|   width: number, | ||||
|   height: number, | ||||
| } | ||||
| export interface SignatureZone { | ||||
|   index: number, | ||||
|   x: number, | ||||
|   y: number, | ||||
|   width: number, | ||||
|   height: number, | ||||
|   PDFPage: PDFPage, | ||||
| } | ||||
|  | ||||
| export interface Signature { | ||||
|   id: number, | ||||
|   storedObject: StoredObject, | ||||
|   zones: SignatureZone[], | ||||
| } | ||||
|  | ||||
| export type SignedState = 'pending' | 'signed' | 'rejected' | 'canceled' | 'error'; | ||||
|   | ||||
| @@ -1,20 +1,20 @@ | ||||
| <template> | ||||
|   <div v-if="'ready' === props.storedObject.status || 'stored_object_created' === props.storedObject.status" class="btn-group"> | ||||
|   <div v-if="isButtonGroupDisplayable" 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> | ||||
|     <ul class="dropdown-menu"> | ||||
|       <li v-if="props.canEdit && is_extension_editable(props.storedObject.type) && props.storedObject.status !== 'stored_object_created'"> | ||||
|       <li v-if="isEditableOnline"> | ||||
|         <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"> | ||||
|       <li v-if="isEditableOnDesktop"> | ||||
|         <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 && props.storedObject.status !== 'stored_object_created'"> | ||||
|       <li v-if="isConvertibleToPdf"> | ||||
|         <convert-button :stored-object="props.storedObject" :filename="filename" :classes="{'dropdown-item': true}"></convert-button> | ||||
|       </li> | ||||
|       <li v-if="props.canDownload"> | ||||
|         <download-button :stored-object="props.storedObject" :filename="filename" :classes="{'dropdown-item': true}"></download-button> | ||||
|       <li v-if="isDownloadable"> | ||||
|         <download-button :stored-object="props.storedObject" :at-version="props.storedObject.currentVersion" :filename="filename" :classes="{'dropdown-item': true}"></download-button> | ||||
|       </li> | ||||
|     </ul> | ||||
|   </div> | ||||
| @@ -29,20 +29,20 @@ | ||||
|  | ||||
| <script lang="ts" setup> | ||||
|  | ||||
| import {onMounted} from "vue"; | ||||
| import {computed, onMounted} from "vue"; | ||||
| import ConvertButton from "./StoredObjectButton/ConvertButton.vue"; | ||||
| 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, StoredObjectCreated, | ||||
|     StoredObjectStatusChange, | ||||
|     StoredObject, | ||||
|     StoredObjectStatusChange, StoredObjectVersion, | ||||
|     WopiEditButtonExecutableBeforeLeaveFunction | ||||
| } from "../types"; | ||||
| import DesktopEditButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DesktopEditButton.vue"; | ||||
|  | ||||
| interface DocumentActionButtonsGroupConfig { | ||||
|   storedObject: StoredObject|StoredObjectCreated, | ||||
|   storedObject: StoredObject, | ||||
|   small?: boolean, | ||||
|   canEdit?: boolean, | ||||
|   canDownload?: boolean, | ||||
| @@ -95,11 +95,44 @@ let tryiesForReady = 0; | ||||
|  */ | ||||
| const maxTryiesForReady = 120; | ||||
|  | ||||
| const isButtonGroupDisplayable = computed<boolean>(() => { | ||||
|     return isDownloadable.value || isEditableOnline.value || isEditableOnDesktop.value || isConvertibleToPdf.value; | ||||
| }); | ||||
|  | ||||
| const isDownloadable = computed<boolean>(() => { | ||||
|     return props.storedObject.status === 'ready' | ||||
|         // happens when the stored object version is just added, but not persisted | ||||
|         || (props.storedObject.currentVersion !== null && props.storedObject.status === 'empty') | ||||
| }); | ||||
|  | ||||
| const isEditableOnline = computed<boolean>(() => { | ||||
|     return props.storedObject.status === 'ready' | ||||
|         && props.storedObject._permissions.canEdit | ||||
|         && props.canEdit | ||||
|         && props.storedObject.currentVersion !== null | ||||
|         && is_extension_editable(props.storedObject.currentVersion.type) | ||||
|         && props.storedObject.currentVersion.persisted !== false; | ||||
| }); | ||||
|  | ||||
| const isEditableOnDesktop = computed<boolean>(() => { | ||||
|     return isEditableOnline.value; | ||||
| }); | ||||
|  | ||||
| const isConvertibleToPdf = computed<boolean>(() => { | ||||
|     return props.storedObject.status === 'ready' | ||||
|         && props.storedObject._permissions.canSee | ||||
|         && props.canConvertPdf | ||||
|         && props.storedObject.currentVersion !== null | ||||
|         && is_extension_viewable(props.storedObject.currentVersion.type) | ||||
|         && props.storedObject.currentVersion.type !== 'application/pdf' | ||||
|         && props.storedObject.currentVersion.persisted !== false; | ||||
| }) | ||||
|  | ||||
| const checkForReady = function(): void { | ||||
|   if ( | ||||
|     'ready' === props.storedObject.status | ||||
|     || 'empty' === 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 | ||||
|   ) { | ||||
|   | ||||
| @@ -0,0 +1,450 @@ | ||||
| <template> | ||||
|   <teleport to="body"> | ||||
|     <modal v-if="modalOpen" @close="modalOpen = false"> | ||||
|       <template v-slot:header> | ||||
|         <h2>{{ $t("signature_confirmation") }}</h2> | ||||
|       </template> | ||||
|       <template v-slot:body> | ||||
|         <div class="signature-modal-body text-center" v-if="loading"> | ||||
|           <p>{{ $t("electronic_signature_in_progress") }}</p> | ||||
|           <div class="loading"> | ||||
|             <i | ||||
|               class="fa fa-circle-o-notch fa-spin fa-3x" | ||||
|               :title="$t('loading')" | ||||
|             ></i> | ||||
|           </div> | ||||
|         </div> | ||||
|         <div class="signature-modal-body text-center" v-else> | ||||
|           <p>{{ $t("you_are_going_to_sign") }}</p> | ||||
|           <p>{{ $t("are_you_sure") }}</p> | ||||
|         </div> | ||||
|       </template> | ||||
|       <template v-slot:footer> | ||||
|         <button class="btn btn-action" @click.prevent="confirmSign"> | ||||
|           {{ $t("yes") }} | ||||
|         </button> | ||||
|       </template> | ||||
|     </modal> | ||||
|   </teleport> | ||||
|   <div class="col-12"> | ||||
|     <div | ||||
|       class="row justify-content-center mb-2" | ||||
|       v-if="signature.zones.length > 1" | ||||
|     > | ||||
|       <div class="col-4 gap-2 d-grid"> | ||||
|         <button | ||||
|           :disabled="userSignatureZone === null || userSignatureZone?.index < 1" | ||||
|           class="btn btn-light btn-sm" | ||||
|           @click="turnSignature(-1)" | ||||
|         > | ||||
|           {{ $t("last_sign_zone") }} | ||||
|         </button> | ||||
|       </div> | ||||
|       <div class="col-4 gap-2 d-grid"> | ||||
|         <button | ||||
|           :disabled="userSignatureZone?.index >= signature.zones.length - 1" | ||||
|           class="btn btn-light btn-sm" | ||||
|           @click="turnSignature(1)" | ||||
|         > | ||||
|           {{ $t("next_sign_zone") }} | ||||
|         </button> | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
|     <div | ||||
|       id="turn-page" | ||||
|       class="row justify-content-center mb-2" | ||||
|       v-if="pageCount > 1" | ||||
|     > | ||||
|       <div class="col-6-sm col-3-md text-center"> | ||||
|         <button | ||||
|           class="btn btn-light btn-sm" | ||||
|           :disabled="page <= 1" | ||||
|           @click="turnPage(-1)" | ||||
|         > | ||||
|           ❮ | ||||
|         </button> | ||||
|         <span>page {{ page }} / {{ pageCount }}</span> | ||||
|         <button | ||||
|           class="btn btn-light btn-sm" | ||||
|           :disabled="page >= pageCount" | ||||
|           @click="turnPage(1)" | ||||
|         > | ||||
|           ❯ | ||||
|         </button> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
|   <div class="col-12 text-center"> | ||||
|     <canvas class="m-auto" id="canvas"></canvas> | ||||
|   </div> | ||||
|  | ||||
|   <div class="col-12 p-4" id="action-buttons" v-if="signedState !== 'signed'"> | ||||
|     <div class="row"> | ||||
|       <div class="col-6"> | ||||
|         <button | ||||
|           class="btn btn-action me-2" | ||||
|           :disabled="!userSignatureZone" | ||||
|           @click="sign" | ||||
|         > | ||||
|           {{ $t("sign") }} | ||||
|         </button> | ||||
|       </div> | ||||
|       <div class="col-6 d-flex justify-content-end"> | ||||
|         <button | ||||
|           class="btn btn-misc me-2" | ||||
|           :hidden="!userSignatureZone" | ||||
|           @click="undoSign" | ||||
|           v-if="signature.zones.length > 1" | ||||
|         > | ||||
|           {{ $t("choose_another_signature") }} | ||||
|         </button> | ||||
|         <button | ||||
|           class="btn btn-misc me-2" | ||||
|           :hidden="!userSignatureZone" | ||||
|           @click="undoSign" | ||||
|           v-else | ||||
|         > | ||||
|           {{ $t("cancel") }} | ||||
|         </button> | ||||
|         <button class="btn btn-delete" @click="undoSign"> | ||||
|           {{ $t("cancel_signing") }} | ||||
|         </button> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { ref, Ref, reactive } from "vue"; | ||||
| import { useToast } from "vue-toast-notification"; | ||||
| import "vue-toast-notification/dist/theme-sugar.css"; | ||||
| import { Signature, SignatureZone, SignedState } from "../../types"; | ||||
| import { makeFetch } from "../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods"; | ||||
| import * as pdfjsLib from "pdfjs-dist"; | ||||
| import { | ||||
|   PDFDocumentProxy, | ||||
|   PDFPageProxy, | ||||
| } from "pdfjs-dist/types/src/display/api"; | ||||
|  | ||||
| // @ts-ignore | ||||
| import * as PdfWorker from "pdfjs-dist/build/pdf.worker.mjs"; | ||||
| console.log(PdfWorker); // incredible but this is needed | ||||
|  | ||||
| // import { PdfWorker } from 'pdfjs-dist/build/pdf.worker.mjs' | ||||
| // pdfjsLib.GlobalWorkerOptions.workerSrc = PdfWorker; | ||||
|  | ||||
| import Modal from "ChillMainAssets/vuejs/_components/Modal.vue"; | ||||
| import { | ||||
|   download_and_decrypt_doc, | ||||
| } from "../StoredObjectButton/helpers"; | ||||
|  | ||||
| pdfjsLib.GlobalWorkerOptions.workerSrc = "pdfjs-dist/build/pdf.worker.mjs"; | ||||
|  | ||||
| const modalOpen: Ref<boolean> = ref(false); | ||||
| const loading: Ref<boolean> = ref(false); | ||||
| const signedState: Ref<SignedState> = ref("pending"); | ||||
| const page: Ref<number> = ref(1); | ||||
| const pageCount: Ref<number> = ref(0); | ||||
| let userSignatureZone: Ref<null | SignatureZone> = ref(null); | ||||
| let pdfSource: Ref<string> = ref(""); | ||||
| let pdf = {} as PDFDocumentProxy; | ||||
|  | ||||
| declare global { | ||||
|   interface Window { | ||||
|     signature: Signature; | ||||
|   } | ||||
| } | ||||
|  | ||||
| const $toast = useToast(); | ||||
|  | ||||
| const signature = window.signature; | ||||
|  | ||||
| const mountPdf = async (url: string) => { | ||||
|   const loadingTask = pdfjsLib.getDocument(url); | ||||
|   pdf = await loadingTask.promise; | ||||
|   pageCount.value = pdf.numPages; | ||||
|   await setPage(1); | ||||
| }; | ||||
|  | ||||
| const getRenderContext = (pdfPage: PDFPageProxy) => { | ||||
|   const scale = 1; | ||||
|   const viewport = pdfPage.getViewport({ scale }); | ||||
|   const canvas = document.querySelectorAll("canvas")[0] as HTMLCanvasElement; | ||||
|   const context = canvas.getContext("2d") as CanvasRenderingContext2D; | ||||
|   canvas.height = viewport.height; | ||||
|   canvas.width = viewport.width; | ||||
|  | ||||
|   return { | ||||
|     canvasContext: context, | ||||
|     viewport: viewport, | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| const setPage = async (page: number) => { | ||||
|   const pdfPage = await pdf.getPage(page); | ||||
|   const renderContext = getRenderContext(pdfPage); | ||||
|   await pdfPage.render(renderContext); | ||||
| }; | ||||
|  | ||||
| async function downloadAndOpen(): Promise<Blob> { | ||||
|   let raw; | ||||
|   try { | ||||
|     raw = await download_and_decrypt_doc(signature.storedObject, signature.storedObject.currentVersion); | ||||
|   } catch (e) { | ||||
|     console.error("error while downloading and decrypting document", e); | ||||
|     throw e; | ||||
|   } | ||||
|   await mountPdf(URL.createObjectURL(raw)); | ||||
|   initPdf(); | ||||
|   return raw; | ||||
| } | ||||
|  | ||||
| const initPdf = () => { | ||||
|   const canvas = document.querySelectorAll("canvas")[0] as HTMLCanvasElement; | ||||
|   canvas.addEventListener( | ||||
|     "pointerup", | ||||
|     (e: PointerEvent) => canvasClick(e, canvas), | ||||
|     false | ||||
|   ); | ||||
|   setTimeout(() => addZones(page.value), 800); | ||||
| }; | ||||
|  | ||||
| const hitSignature = ( | ||||
|   zone: SignatureZone, | ||||
|   xy: number[], | ||||
|   canvasWidth: number, | ||||
|   canvasHeight: number | ||||
| ) => { | ||||
|   const scaleXToCanvas = (x: number) => | ||||
|     Math.round((x * canvasWidth) / zone.PDFPage.width); | ||||
|   const scaleHeightToCanvas = (h: number) => | ||||
|     Math.round((h * canvasHeight) / zone.PDFPage.height); | ||||
|   const scaleYToCanvas = (y: number) => | ||||
|     Math.round(zone.PDFPage.height - scaleHeightToCanvas(y)); | ||||
|   return ( | ||||
|     scaleXToCanvas(zone.x) < xy[0] && | ||||
|     xy[0] < scaleXToCanvas(zone.x + zone.width) && | ||||
|     scaleYToCanvas(zone.y) < xy[1] && | ||||
|     xy[1] < scaleYToCanvas(zone.y) + scaleHeightToCanvas(zone.height) | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| const selectZone = (z: SignatureZone, canvas: HTMLCanvasElement) => { | ||||
|   userSignatureZone.value = z; | ||||
|   const ctx = canvas.getContext("2d"); | ||||
|   if (ctx) { | ||||
|     setPage(page.value); | ||||
|     setTimeout(() => drawZone(z, ctx, canvas.width, canvas.height), 200); | ||||
|   } | ||||
| }; | ||||
|  | ||||
| const canvasClick = (e: PointerEvent, canvas: HTMLCanvasElement) => | ||||
|   signature.zones | ||||
|     .filter((z) => z.PDFPage.index + 1 === page.value) | ||||
|     .map((z) => { | ||||
|       if ( | ||||
|         hitSignature(z, [e.offsetX, e.offsetY], canvas.width, canvas.height) | ||||
|       ) { | ||||
|         if (userSignatureZone.value === null) { | ||||
|           selectZone(z, canvas); | ||||
|         } else { | ||||
|           if (userSignatureZone.value.index === z.index) { | ||||
|             sign(); | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     }); | ||||
|  | ||||
| const turnPage = async (upOrDown: number) => { | ||||
|   userSignatureZone.value = null; | ||||
|   page.value = page.value + upOrDown; | ||||
|   await setPage(page.value); | ||||
|   setTimeout(() => addZones(page.value), 200); | ||||
| }; | ||||
|  | ||||
| const turnSignature = async (upOrDown: number) => { | ||||
|   let zoneIndex = userSignatureZone.value?.index ?? -1; | ||||
|   if (zoneIndex < -1) { | ||||
|     zoneIndex = -1; | ||||
|   } | ||||
|   if (zoneIndex < signature.zones.length) { | ||||
|     zoneIndex = zoneIndex + upOrDown; | ||||
|   } else { | ||||
|     zoneIndex = 0; | ||||
|   } | ||||
|   let currentZone = signature.zones[zoneIndex]; | ||||
|   if (currentZone) { | ||||
|     page.value = currentZone.PDFPage.index + 1; | ||||
|     userSignatureZone.value = currentZone; | ||||
|     const canvas = document.querySelectorAll("canvas")[0]; | ||||
|     selectZone(currentZone, canvas); | ||||
|   } | ||||
| }; | ||||
|  | ||||
| const drawZone = ( | ||||
|   zone: SignatureZone, | ||||
|   ctx: CanvasRenderingContext2D, | ||||
|   canvasWidth: number, | ||||
|   canvasHeight: number | ||||
| ) => { | ||||
|   const unselectedBlue = "#007bff"; | ||||
|   const selectedBlue = "#034286"; | ||||
|   const scaleXToCanvas = (x: number) => | ||||
|     Math.round((x * canvasWidth) / zone.PDFPage.width); | ||||
|   const scaleHeightToCanvas = (h: number) => | ||||
|     Math.round((h * canvasHeight) / zone.PDFPage.height); | ||||
|   const scaleYToCanvas = (y: number) => | ||||
|     Math.round(zone.PDFPage.height - scaleHeightToCanvas(y)); | ||||
|   ctx.strokeStyle = | ||||
|     userSignatureZone.value?.index === zone.index | ||||
|       ? selectedBlue | ||||
|       : unselectedBlue; | ||||
|   ctx.lineWidth = 2; | ||||
|   ctx.lineJoin = "bevel"; | ||||
|   ctx.strokeRect( | ||||
|     scaleXToCanvas(zone.x), | ||||
|     scaleYToCanvas(zone.y), | ||||
|     scaleXToCanvas(zone.width), | ||||
|     scaleHeightToCanvas(zone.height) | ||||
|   ); | ||||
|   ctx.font = "bold 16px serif"; | ||||
|   ctx.textAlign = "center"; | ||||
|   ctx.fillStyle = "black"; | ||||
|   const xText = scaleXToCanvas(zone.x) + scaleXToCanvas(zone.width) / 2; | ||||
|   const yText = scaleYToCanvas(zone.y) + scaleHeightToCanvas(zone.height) / 2; | ||||
|   if (userSignatureZone.value?.index === zone.index) { | ||||
|     ctx.fillStyle = selectedBlue; | ||||
|     ctx.fillText("Signer ici", xText, yText); | ||||
|   } else { | ||||
|     ctx.fillStyle = unselectedBlue; | ||||
|     ctx.fillText("Choisir cette", xText, yText - 12); | ||||
|     ctx.fillText("zone de signature", xText, yText + 12); | ||||
|     // ctx.strokeStyle = "#c6c6c6"; // halo | ||||
|     // ctx.strokeText("Choisir cette", xText, yText - 12); | ||||
|     // ctx.strokeText("zone de signature", xText, yText + 12); | ||||
|   } | ||||
| }; | ||||
|  | ||||
| const addZones = (page: number) => { | ||||
|   const canvas = document.querySelectorAll("canvas")[0]; | ||||
|   const ctx = canvas.getContext("2d"); | ||||
|   if (ctx) { | ||||
|     signature.zones | ||||
|       .filter((z) => z.PDFPage.index + 1 === page) | ||||
|       .map((z) => drawZone(z, ctx, canvas.width, canvas.height)); | ||||
|   } | ||||
| }; | ||||
|  | ||||
| const checkSignature = () => { | ||||
|   const url = `/api/1.0/document/workflow/${signature.id}/check-signature`; | ||||
|   return makeFetch("GET", url) | ||||
|     .then((r) => { | ||||
|       signedState.value = r as SignedState; | ||||
|       checkForReady(); | ||||
|     }) | ||||
|     .catch((error) => { | ||||
|       signedState.value = "error"; | ||||
|       console.log("Error while checking the signature", error); | ||||
|       $toast.error( | ||||
|         `Erreur lors de la vérification de la signature: ${error.txt}` | ||||
|       ); | ||||
|     }); | ||||
| }; | ||||
|  | ||||
| const maxTryForReady = 60; //2 minutes for trying to sign | ||||
| let tryForReady = 0; | ||||
|  | ||||
| const stopTrySigning = () => { | ||||
|   loading.value = false; | ||||
|   modalOpen.value = false; | ||||
| }; | ||||
|  | ||||
| const checkForReady = () => { | ||||
|   if (tryForReady > maxTryForReady) { | ||||
|     stopTrySigning(); | ||||
|     tryForReady = 0; | ||||
|     console.log("Reached the maximum number of tentative to try signing"); | ||||
|     $toast.error( | ||||
|       "Le nombre maximum de tentatives pour essayer de signer est atteint" | ||||
|     ); | ||||
|   } | ||||
|   if (signedState.value === "rejected") { | ||||
|     stopTrySigning(); | ||||
|     console.log("Signature rejected by the server"); | ||||
|     $toast.error("Signature rejetée par le serveur"); | ||||
|   } | ||||
|   if (signedState.value === "canceled") { | ||||
|     stopTrySigning(); | ||||
|     console.log("Signature canceled"); | ||||
|     $toast.error("Signature annulée"); | ||||
|   } | ||||
|   if (signedState.value === "pending") { | ||||
|     tryForReady = tryForReady + 1; | ||||
|     setTimeout(() => checkSignature(), 2000); | ||||
|   } else { | ||||
|     stopTrySigning(); | ||||
|     if (signedState.value === "signed") { | ||||
|       userSignatureZone.value = null; | ||||
|       downloadAndOpen(); | ||||
|     } | ||||
|   } | ||||
| }; | ||||
|  | ||||
| const sign = () => (modalOpen.value = true); | ||||
|  | ||||
| const confirmSign = () => { | ||||
|   loading.value = true; | ||||
|   const url = `/api/1.0/document/workflow/${signature.id}/signature-request`; | ||||
|   const body = { | ||||
|     storedObject: signature.storedObject, | ||||
|     zone: userSignatureZone.value, | ||||
|   }; | ||||
|   makeFetch("POST", url, body) | ||||
|     .then((r) => { | ||||
|       checkForReady(); | ||||
|     }) | ||||
|     .catch((error) => { | ||||
|       console.log("Error while posting the signature", error); | ||||
|       stopTrySigning(); | ||||
|       $toast.error( | ||||
|         `Erreur lors de la soumission de la signature: ${error.txt}` | ||||
|       ); | ||||
|     }); | ||||
| }; | ||||
|  | ||||
| const undoSign = async () => { | ||||
|   // const canvas = document.querySelectorAll("canvas")[0]; | ||||
|   // const ctx = canvas.getContext("2d"); | ||||
|   // if (ctx && userSignatureZone.value) { | ||||
|   //   //drawZone(userSignatureZone.value, ctx, canvas.width, canvas.height); | ||||
|   // } | ||||
|   await setPage(page.value); | ||||
|   setTimeout(() => addZones(page.value), 200); | ||||
|   userSignatureZone.value = null; | ||||
| }; | ||||
|  | ||||
| downloadAndOpen(); | ||||
| </script> | ||||
|  | ||||
| <style scoped lang="scss"> | ||||
| #canvas { | ||||
|   box-shadow: 0 20px 20px rgba(0, 0, 0, 0.1); | ||||
| } | ||||
| div#action-buttons { | ||||
|   position: sticky; | ||||
|   bottom: 0px; | ||||
|   background-color: white; | ||||
|   z-index: 100; | ||||
| } | ||||
| div#turn-page { | ||||
|   span { | ||||
|     font-size: 0.8rem; | ||||
|     margin: 0 0.4rem; | ||||
|   } | ||||
| } | ||||
| div.signature-modal-body { | ||||
|   height: 8rem; | ||||
| } | ||||
| </style> | ||||
|  | ||||
| @@ -0,0 +1,30 @@ | ||||
| import { createApp } from "vue"; | ||||
| // @ts-ignore | ||||
| import { _createI18n } from "ChillMainAssets/vuejs/_js/i18n"; | ||||
| import App from "./App.vue"; | ||||
|  | ||||
| const appMessages = { | ||||
|     fr: { | ||||
|         yes: 'Oui', | ||||
|         are_you_sure: 'Êtes-vous sûr·e?', | ||||
|         you_are_going_to_sign: 'Vous allez signer le document', | ||||
|         signature_confirmation: 'Confirmation de la signature', | ||||
|         sign: 'Signer', | ||||
|         choose_another_signature: 'Choisir une autre zone de signature', | ||||
|         cancel: 'Annuler', | ||||
|         cancel_signing: 'Refuser de signer', | ||||
|         last_sign_zone: 'Zone de signature précédente', | ||||
|         next_sign_zone: 'Zone de signature suivante', | ||||
|         electronic_signature_in_progress: 'Signature électronique en cours...', | ||||
|         loading: 'Chargement...' | ||||
|     } | ||||
| } | ||||
|  | ||||
| const i18n = _createI18n(appMessages); | ||||
|  | ||||
| const app = createApp({ | ||||
|     template: `<app></app>`, | ||||
| }) | ||||
|     .use(i18n) | ||||
|     .component("app", App) | ||||
|     .mount("#document-signature"); | ||||
| @@ -1,22 +1,21 @@ | ||||
| <script setup lang="ts"> | ||||
|  | ||||
| import {StoredObject, StoredObjectCreated} from "../../types"; | ||||
| import {encryptFile, uploadFile} from "../_components/helper"; | ||||
| import {StoredObject, StoredObjectVersionCreated} from "../../types"; | ||||
| import {encryptFile, fetchNewStoredObject, uploadVersion} from "../../js/async-upload/uploader"; | ||||
| import {computed, ref, Ref} from "vue"; | ||||
|  | ||||
| interface DropFileConfig { | ||||
|     existingDoc?: StoredObjectCreated|StoredObject, | ||||
|     existingDoc?: StoredObject, | ||||
| } | ||||
|  | ||||
| const props = defineProps<DropFileConfig>(); | ||||
| const props = withDefaults(defineProps<DropFileConfig>(), {existingDoc: null}); | ||||
|  | ||||
| const emit = defineEmits<{ | ||||
|     (e: 'addDocument', stored_object: StoredObjectCreated): void, | ||||
|     (e: 'addDocument', {stored_object_version: StoredObjectVersionCreated, stored_object: StoredObject}): void, | ||||
| }>(); | ||||
|  | ||||
| const is_dragging: Ref<boolean> = ref(false); | ||||
| const uploading: Ref<boolean> = ref(false); | ||||
| const display_filename: Ref<string|null> = ref(null); | ||||
|  | ||||
| const has_existing_doc = computed<boolean>(() => { | ||||
|     return props.existingDoc !== undefined && props.existingDoc !== null; | ||||
| @@ -35,7 +34,6 @@ const onDragLeave = (e: Event) => { | ||||
| } | ||||
|  | ||||
| const onDrop = (e: DragEvent) => { | ||||
|     console.log('on drop', e); | ||||
|     e.preventDefault(); | ||||
|  | ||||
|     const files = e.dataTransfer?.files; | ||||
| @@ -65,7 +63,6 @@ const onZoneClick = (e: Event) => { | ||||
|  | ||||
| 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]); | ||||
| @@ -80,23 +77,29 @@ const onFileChange = async (event: Event): Promise<void> => { | ||||
|  | ||||
| const handleFile = async (file: File): Promise<void> => { | ||||
|     uploading.value = true; | ||||
|     display_filename.value = file.name; | ||||
|     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", | ||||
|     // create a stored_object if not exists | ||||
|     let stored_object; | ||||
|     if (null === props.existingDoc) { | ||||
|         stored_object = await fetchNewStoredObject(); | ||||
|     } else { | ||||
|         stored_object = props.existingDoc; | ||||
|     } | ||||
|  | ||||
|     emit('addDocument', storedObject); | ||||
|     const buffer = await file.arrayBuffer(); | ||||
|     const [encrypted, iv, jsonWebKey] = await encryptFile(buffer); | ||||
|     const filename = await uploadVersion(encrypted, stored_object); | ||||
|  | ||||
|     const stored_object_version: StoredObjectVersionCreated = { | ||||
|         filename: filename, | ||||
|         iv: Array.from(iv), | ||||
|         keyInfos: jsonWebKey, | ||||
|         type: type, | ||||
|         persisted: false, | ||||
|     } | ||||
|  | ||||
|     emit('addDocument', {stored_object, stored_object_version}); | ||||
|     uploading.value = false; | ||||
| } | ||||
|  | ||||
| @@ -105,7 +108,7 @@ const handleFile = async (file: File): Promise<void> => { | ||||
| <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" class="file-icon"> | ||||
|             <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> | ||||
| @@ -117,8 +120,6 @@ const handleFile = async (file: File): Promise<void> => { | ||||
|                 <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> | ||||
|  | ||||
|             <p v-if="display_filename !== null" class="display-filename">{{ display_filename }}</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> | ||||
| @@ -134,23 +135,19 @@ const handleFile = async (file: File): Promise<void> => { | ||||
| .drop-file { | ||||
|     width: 100%; | ||||
|  | ||||
|     .file-icon { | ||||
|         font-size: xx-large; | ||||
|     } | ||||
|  | ||||
|     .display-filename { | ||||
|         font-variant: small-caps; | ||||
|         font-weight: 200; | ||||
|     } | ||||
|  | ||||
|     & > .area, & > .waiting { | ||||
|         width: 100%; | ||||
|         height: 10rem; | ||||
|         height: 8rem; | ||||
|  | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         justify-content: center; | ||||
|         align-items: center; | ||||
|  | ||||
|         p { | ||||
|             // require for display in DropFileModal | ||||
|             text-align: center; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     & > .area { | ||||
| @@ -161,5 +158,4 @@ const handleFile = async (file: File): Promise<void> => { | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| </style> | ||||
|   | ||||
| @@ -0,0 +1,69 @@ | ||||
| <script setup lang="ts"> | ||||
|  | ||||
| import Modal from "ChillMainAssets/vuejs/_components/Modal.vue"; | ||||
| import {StoredObject, StoredObjectVersion} from "../../types"; | ||||
| import DropFileWidget from "ChillDocStoreAssets/vuejs/DropFileWidget/DropFileWidget.vue"; | ||||
| import {computed, reactive} from "vue"; | ||||
| import {useToast} from 'vue-toast-notification'; | ||||
|  | ||||
| interface DropFileConfig { | ||||
|     allowRemove: boolean, | ||||
|     existingDoc?: StoredObject, | ||||
| } | ||||
|  | ||||
| const props = withDefaults(defineProps<DropFileConfig>(), { | ||||
|     allowRemove: false, | ||||
| }); | ||||
|  | ||||
| const emit = defineEmits<{ | ||||
|     (e: 'addDocument', {stored_object: StoredObject, stored_object_version: StoredObjectVersion}): void, | ||||
|     (e: 'removeDocument'): void | ||||
| }>(); | ||||
|  | ||||
| const $toast = useToast(); | ||||
|  | ||||
| const state = reactive({showModal: false}); | ||||
|  | ||||
| const modalClasses = {"modal-dialog-centered": true, "modal-md": true}; | ||||
|  | ||||
| const buttonState = computed<'add'|'replace'>(() => { | ||||
|     if (props.existingDoc === undefined || props.existingDoc === null) { | ||||
|         return 'add'; | ||||
|     } | ||||
|  | ||||
|     return 'replace'; | ||||
| }) | ||||
|  | ||||
| function onAddDocument({stored_object, stored_object_version}: {stored_object: StoredObject, stored_object_version: StoredObjectVersion}): void { | ||||
|     const message = buttonState.value === 'add' ? "Document ajouté" : "Document remplacé"; | ||||
|     $toast.success(message); | ||||
|     emit('addDocument', {stored_object_version, stored_object}); | ||||
|     state.showModal = false; | ||||
| } | ||||
|  | ||||
| function onRemoveDocument(): void { | ||||
|    emit('removeDocument'); | ||||
| } | ||||
|  | ||||
| function openModal(): void { | ||||
|     state.showModal = true; | ||||
| } | ||||
|  | ||||
| function closeModal(): void { | ||||
|     state.showModal = false; | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|     <button v-if="buttonState === 'add'" @click="openModal" class="btn btn-create">Ajouter un document</button> | ||||
|     <button v-else @click="openModal" class="btn btn-edit">Remplacer le document</button> | ||||
|     <modal v-if="state.showModal" :modal-dialog-class="modalClasses" @close="closeModal"> | ||||
|         <template v-slot:body> | ||||
|             <drop-file-widget :existing-doc="existingDoc" :allow-remove="allowRemove" @add-document="onAddDocument" @remove-document="onRemoveDocument" ></drop-file-widget> | ||||
|         </template> | ||||
|     </modal> | ||||
| </template> | ||||
|  | ||||
| <style scoped lang="scss"> | ||||
|  | ||||
| </style> | ||||
| @@ -1,13 +1,13 @@ | ||||
| <script setup lang="ts"> | ||||
|  | ||||
| import {StoredObject, StoredObjectCreated} from "../../types"; | ||||
| import {StoredObject, StoredObjectVersion} 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, | ||||
|     existingDoc?: StoredObject, | ||||
| } | ||||
|  | ||||
| const props = withDefaults(defineProps<DropFileConfig>(), { | ||||
| @@ -15,8 +15,8 @@ const props = withDefaults(defineProps<DropFileConfig>(), { | ||||
| }); | ||||
|  | ||||
| const emit = defineEmits<{ | ||||
|     (e: 'addDocument', stored_object: StoredObjectCreated): void, | ||||
|     (e: 'removeDocument', stored_object: null): void | ||||
|     (e: 'addDocument', {stored_object: StoredObject, stored_object_version: StoredObjectVersion}): void, | ||||
|     (e: 'removeDocument'): void | ||||
| }>(); | ||||
|  | ||||
| const has_existing_doc = computed<boolean>(() => { | ||||
| @@ -45,14 +45,14 @@ const dav_link_href = computed<string|undefined>(() => { | ||||
|     return props.existingDoc._links?.dav_link?.href; | ||||
| }) | ||||
|  | ||||
| const onAddDocument = (s: StoredObjectCreated): void => { | ||||
|     emit('addDocument', s); | ||||
| const onAddDocument = ({stored_object, stored_object_version}: {stored_object: StoredObject, stored_object_version: StoredObjectVersion}): void => { | ||||
|     emit('addDocument', {stored_object, stored_object_version}); | ||||
| } | ||||
|  | ||||
| const onRemoveDocument = (e: Event): void => { | ||||
|     e.stopPropagation(); | ||||
|     e.preventDefault(); | ||||
|     emit('removeDocument', null); | ||||
|     emit('removeDocument'); | ||||
| } | ||||
|  | ||||
| </script> | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| <template> | ||||
|   <a :class="props.classes" @click="download_and_open($event)" ref="btn"> | ||||
|   <a :class="props.classes" @click="download_and_open($event)"> | ||||
|     <i class="fa fa-file-pdf-o"></i> | ||||
|     Télécharger en pdf | ||||
|   </a> | ||||
| @@ -9,8 +9,8 @@ | ||||
|  | ||||
| import {build_convert_link, download_and_decrypt_doc, download_doc} from "./helpers"; | ||||
| import mime from "mime"; | ||||
| import {reactive, ref} from "vue"; | ||||
| import {StoredObject, StoredObjectCreated} from "../../types"; | ||||
| import {reactive} from "vue"; | ||||
| import {StoredObject} from "../../types"; | ||||
|  | ||||
| interface ConvertButtonConfig { | ||||
|   storedObject: StoredObject, | ||||
| @@ -24,7 +24,6 @@ interface DownloadButtonState { | ||||
|  | ||||
| const props = defineProps<ConvertButtonConfig>(); | ||||
| const state: DownloadButtonState = reactive({content: null}); | ||||
| const btn = ref<HTMLAnchorElement | null>(null); | ||||
|  | ||||
| async function download_and_open(event: Event): Promise<void> { | ||||
|   const button = event.target as HTMLAnchorElement; | ||||
| @@ -42,19 +41,11 @@ async function download_and_open(event: Event): Promise<void> { | ||||
|   } | ||||
|  | ||||
|   button.click(); | ||||
|   const reset_pending = setTimeout(reset_state, 45000); | ||||
| } | ||||
|  | ||||
| function reset_state(): void { | ||||
|     state.content = null; | ||||
|     btn.value?.removeAttribute('download'); | ||||
|     btn.value?.removeAttribute('href'); | ||||
|     btn.value?.removeAttribute('type'); | ||||
| } | ||||
|  | ||||
| </script> | ||||
|  | ||||
| <style scoped lang="sass"> | ||||
| <style scoped lang="scss"> | ||||
| i.fa::before { | ||||
|    color: var(--bs-dropdown-link-hover-color); | ||||
| } | ||||
|   | ||||
| @@ -63,4 +63,7 @@ const editionUntilFormatted = computed<string>(() => { | ||||
| .desktop-edit { | ||||
|     text-align: center; | ||||
| } | ||||
| i.fa::before { | ||||
|     color: var(--bs-dropdown-link-hover-color); | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -11,12 +11,13 @@ | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import {reactive, ref, nextTick, onMounted} from "vue"; | ||||
| import {build_download_info_link, download_and_decrypt_doc} from "./helpers"; | ||||
| import {download_and_decrypt_doc} from "./helpers"; | ||||
| import mime from "mime"; | ||||
| import {StoredObject, StoredObjectCreated} from "../../types"; | ||||
| import {StoredObject, StoredObjectVersion} from "../../types"; | ||||
|  | ||||
| interface DownloadButtonConfig { | ||||
|     storedObject: StoredObject|StoredObjectCreated, | ||||
|     storedObject: StoredObject, | ||||
|     atVersion: StoredObjectVersion, | ||||
|     classes: { [k: string]: boolean }, | ||||
|     filename?: string, | ||||
| } | ||||
| @@ -33,8 +34,9 @@ const state: DownloadButtonState = reactive({is_ready: false, is_running: false, | ||||
| const open_button = ref<HTMLAnchorElement | null>(null); | ||||
|  | ||||
| function buildDocumentName(): string { | ||||
|     const document_name = props.filename || 'document'; | ||||
|     const ext = mime.getExtension(props.storedObject.type); | ||||
|     const document_name = props.filename ?? props.storedObject.title ?? 'document'; | ||||
|  | ||||
|     const ext = mime.getExtension(props.atVersion.type); | ||||
|  | ||||
|     if (null !== ext) { | ||||
|         return document_name + '.' + ext; | ||||
| @@ -58,46 +60,26 @@ async function download_and_open(event: Event): Promise<void> { | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     const urlInfo = build_download_info_link(props.storedObject.filename); | ||||
|     let raw; | ||||
|  | ||||
|     try { | ||||
|         raw = await download_and_decrypt_doc(urlInfo, props.storedObject.keyInfos, new Uint8Array(props.storedObject.iv)); | ||||
|         raw = await download_and_decrypt_doc(props.storedObject, props.atVersion); | ||||
|     } catch (e) { | ||||
|         console.error("error while downloading and decrypting document"); | ||||
|         console.error(e); | ||||
|         throw e; | ||||
|     } | ||||
|  | ||||
|     console.log('document downloading (and decrypting) successfully'); | ||||
|  | ||||
|     console.log('creating the url') | ||||
|     state.href_url = window.URL.createObjectURL(raw); | ||||
|     console.log('url created', state.href_url); | ||||
|     state.is_running = false; | ||||
|     state.is_ready = true; | ||||
|     console.log('new button marked as ready'); | ||||
|     console.log('will click on button'); | ||||
|  | ||||
|     console.log('openbutton is now', open_button.value); | ||||
|  | ||||
|     await nextTick(); | ||||
|     console.log('next tick actions'); | ||||
|     console.log('openbutton after next tick', open_button.value); | ||||
|     open_button.value?.click(); | ||||
|     console.log('open button should have been clicked'); | ||||
|  | ||||
|     const timer = setTimeout(reset_state, 45000); | ||||
| } | ||||
|  | ||||
| function reset_state(): void { | ||||
|     state.href_url = '#'; | ||||
|     state.is_ready = false; | ||||
|     state.is_running = false; | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style scoped lang="sass"> | ||||
| <style scoped lang="scss"> | ||||
| i.fa::before { | ||||
|    color: var(--bs-dropdown-link-hover-color); | ||||
| } | ||||
|   | ||||
| @@ -8,7 +8,7 @@ | ||||
| <script lang="ts" setup> | ||||
| import WopiEditButton from "./WopiEditButton.vue"; | ||||
| import {build_wopi_editor_link} from "./helpers"; | ||||
| import {StoredObject, StoredObjectCreated, WopiEditButtonExecutableBeforeLeaveFunction} from "../../types"; | ||||
| import {StoredObject, WopiEditButtonExecutableBeforeLeaveFunction} from "../../types"; | ||||
|  | ||||
| interface WopiEditButtonConfig { | ||||
|   storedObject: StoredObject, | ||||
| @@ -22,7 +22,6 @@ const props = defineProps<WopiEditButtonConfig>(); | ||||
| let executed = false; | ||||
|  | ||||
| async function beforeLeave(event: Event): Promise<true> { | ||||
|   console.log(executed); | ||||
|   if (props.executeBeforeLeave === undefined || executed === true) { | ||||
|     return Promise.resolve(true); | ||||
|   } | ||||
| @@ -39,7 +38,7 @@ async function beforeLeave(event: Event): Promise<true> { | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style scoped lang="sass"> | ||||
| <style scoped lang="scss"> | ||||
| i.fa::before { | ||||
|    color: var(--bs-dropdown-link-hover-color); | ||||
| } | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| import {StoredObject, StoredObjectStatus, StoredObjectStatusChange} from "../../types"; | ||||
| import {StoredObject, StoredObjectStatus, StoredObjectStatusChange, StoredObjectVersion} from "../../types"; | ||||
| import {makeFetch} from "../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods"; | ||||
|  | ||||
| const MIMES_EDIT = new Set([ | ||||
|   'application/vnd.ms-powerpoint', | ||||
| @@ -97,6 +98,13 @@ const MIMES_VIEW = new Set([ | ||||
|   ] | ||||
| ]) | ||||
|  | ||||
| export interface SignedUrlGet { | ||||
|     method: 'GET'|'HEAD', | ||||
|     url: string, | ||||
|     expires: number, | ||||
|     object_name: string, | ||||
| } | ||||
|  | ||||
| function is_extension_editable(mimeType: string): boolean { | ||||
|   return MIMES_EDIT.has(mimeType); | ||||
| } | ||||
| @@ -109,8 +117,20 @@ function build_convert_link(uuid: string) { | ||||
|   return `/chill/wopi/convert/${uuid}`; | ||||
| } | ||||
|  | ||||
| function build_download_info_link(object_name: string) { | ||||
|   return `/asyncupload/temp_url/generate/GET?object_name=${object_name}`; | ||||
| function build_download_info_link(storedObject: StoredObject, atVersion: null|StoredObjectVersion): string { | ||||
|     const url = `/api/1.0/doc-store/async-upload/temp_url/${storedObject.uuid}/generate/get`; | ||||
|  | ||||
|     if (null !== atVersion) { | ||||
|         const params = new URLSearchParams({version: atVersion.filename}); | ||||
|  | ||||
|         return url + '?' + params.toString(); | ||||
|     } | ||||
|  | ||||
|     return url; | ||||
| } | ||||
|  | ||||
| async function download_info_link(storedObject: StoredObject, atVersion: null|StoredObjectVersion): Promise<SignedUrlGet> { | ||||
|     return makeFetch('GET', build_download_info_link(storedObject, atVersion)); | ||||
| } | ||||
|  | ||||
| function build_wopi_editor_link(uuid: string, returnPath?: string) { | ||||
| @@ -131,43 +151,39 @@ function download_doc(url: string): Promise<Blob> { | ||||
|   }); | ||||
| } | ||||
|  | ||||
| async function download_and_decrypt_doc(urlGenerator: string, keyData: JsonWebKey, iv: Uint8Array): Promise<Blob> | ||||
| async function download_and_decrypt_doc(storedObject: StoredObject, atVersion: null|StoredObjectVersion): Promise<Blob> | ||||
| { | ||||
|    const algo = 'AES-CBC'; | ||||
|    // get an url to download the object | ||||
|    const downloadInfoResponse = await window.fetch(urlGenerator); | ||||
|  | ||||
|    if (!downloadInfoResponse.ok) { | ||||
|      throw new Error("error while downloading url " + downloadInfoResponse.status + " " + downloadInfoResponse.statusText); | ||||
|    const atVersionToDownload = atVersion ?? storedObject.currentVersion; | ||||
|  | ||||
|    if (null === atVersionToDownload) { | ||||
|        throw new Error("no version associated to stored object"); | ||||
|    } | ||||
|  | ||||
|    const downloadInfo = await downloadInfoResponse.json() as {url: string}; | ||||
|    const downloadInfo= await download_info_link(storedObject, atVersionToDownload); | ||||
|  | ||||
|    const rawResponse = await window.fetch(downloadInfo.url); | ||||
|  | ||||
|    if (!rawResponse.ok) { | ||||
|      throw new Error("error while downloading raw file " + rawResponse.status + " " + rawResponse.statusText); | ||||
|    } | ||||
|  | ||||
|    if (iv.length === 0) { | ||||
|      console.log('returning document immediatly'); | ||||
|    if (atVersionToDownload.iv.length === 0) { | ||||
|      return rawResponse.blob(); | ||||
|    } | ||||
|  | ||||
|    console.log('start decrypting doc'); | ||||
|  | ||||
|    const rawBuffer = await rawResponse.arrayBuffer(); | ||||
|  | ||||
|    try { | ||||
|      const key = await window.crypto.subtle | ||||
|        .importKey('jwk', keyData, { name: algo }, false, ['decrypt']); | ||||
|      console.log('key created'); | ||||
|        .importKey('jwk', atVersionToDownload.keyInfos, { name: algo }, false, ['decrypt']); | ||||
|      const iv = Uint8Array.from(atVersionToDownload.iv); | ||||
|      const decrypted = await window.crypto.subtle | ||||
|        .decrypt({ name: algo, iv: iv }, key, rawBuffer); | ||||
|      console.log('doc decrypted'); | ||||
|  | ||||
|      return Promise.resolve(new Blob([decrypted])); | ||||
|    } catch (e) { | ||||
|      console.error('get error while keys and decrypt operations'); | ||||
|      console.error('encounter error while keys and decrypt operations'); | ||||
|      console.error(e); | ||||
|  | ||||
|      throw e; | ||||
| @@ -188,7 +204,6 @@ async function is_object_ready(storedObject: StoredObject): Promise<StoredObject | ||||
|  | ||||
| export { | ||||
|   build_convert_link, | ||||
|   build_download_info_link, | ||||
|   build_wopi_editor_link, | ||||
|   download_and_decrypt_doc, | ||||
|   download_doc, | ||||
|   | ||||
| @@ -1,174 +0,0 @@ | ||||
| <template> | ||||
|    <a :class="btnClasses" :title="$t(buttonTitle)" @click="openModal"> | ||||
|       <span>{{ $t(buttonTitle) }}</span> | ||||
|    </a> | ||||
|    <teleport to="body"> | ||||
|       <div> | ||||
|          <modal v-if="modal.showModal" | ||||
|             :modalDialogClass="modal.modalDialogClass" | ||||
|             @close="modal.showModal = false"> | ||||
|  | ||||
|             <template v-slot:header> | ||||
|                {{ $t('upload_a_document') }} | ||||
|             </template> | ||||
|  | ||||
|             <template v-slot:body> | ||||
|                 <div id="dropZoneWrapper" ref="dropZoneWrapper"> | ||||
|                     <div | ||||
|                         data-stored-object="data-stored-object" | ||||
|                         :data-label-preparing="$t('data_label_preparing')" | ||||
|                         :data-label-quiet-button="$t('data_label_quiet_button')" | ||||
|                         :data-label-ready="$t('data_label_ready')" | ||||
|                         :data-dict-file-too-big="$t('data_dict_file_too_big')" | ||||
|                         :data-dict-default-message="$t('data_dict_default_message')" | ||||
|                         :data-dict-remove-file="$t('data_dict_remove_file')" | ||||
|                         :data-dict-max-files-exceeded="$t('data_dict_max_files_exceeded')" | ||||
|                         :data-dict-cancel-upload="$t('data_dict_cancel_upload')" | ||||
|                         :data-dict-cancel-upload-confirm="$t('data_dict_cancel_upload_confirm')" | ||||
|                         :data-dict-upload-canceled="$t('data_dict_upload_canceled')" | ||||
|                         :data-dict-remove="$t('data_dict_remove')" | ||||
|                         :data-allow-remove="!options.required" | ||||
|                         data-temp-url-generator="/asyncupload/temp_url/generate/GET"> | ||||
|                         <input | ||||
|                            type="hidden" | ||||
|                            data-async-file-upload="data-async-file-upload" | ||||
|                            data-generate-temp-url-post="/asyncupload/temp_url/generate/post?expires_delay=180&submit_delay=3600" | ||||
|                            data-temp-url-get="/asyncupload/temp_url/generate/GET" | ||||
|                            :data-max-files="options.maxFiles" | ||||
|                            :data-max-post-size="options.maxPostSize" | ||||
|                            :v-model="dataAsyncFileUpload" | ||||
|                         > | ||||
|                         <input | ||||
|                            type="hidden" | ||||
|                            data-stored-object-key="1" | ||||
|                         > | ||||
|                         <input | ||||
|                            type="hidden" | ||||
|                            data-stored-object-iv="1" | ||||
|                         > | ||||
|                         <input | ||||
|                            type="hidden" | ||||
|                            data-async-file-type="1" | ||||
|                         > | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </template> | ||||
|  | ||||
|             <template v-slot:footer> | ||||
|                <button class="btn btn-create" | ||||
|                   @click.prevent="saveDocument"> | ||||
|                   {{ $t('action.add')}} | ||||
|             </button> | ||||
|             </template> | ||||
|  | ||||
|          </modal> | ||||
|       </div> | ||||
|    </teleport> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import Modal from 'ChillMainAssets/vuejs/_components/Modal'; | ||||
| import { searchForZones } from '../../module/async_upload/uploader'; | ||||
| import { makeFetch } from "ChillMainAssets/lib/api/apiMethods"; | ||||
|  | ||||
| const i18n = { | ||||
|    messages: { | ||||
|       fr: { | ||||
|          upload_a_document: "Téléversez un document", | ||||
|          data_label_preparing: "Chargement...", | ||||
|          data_label_quiet_button: "Téléchargez le fichier existant", | ||||
|          data_label_ready: "Prêt à montrer", | ||||
|          data_dict_file_too_big: "Fichier trop volumineux", | ||||
|          data_dict_default_message: "Glissez votre fichier ou cliquez ici", | ||||
|          data_dict_remove_file: "Enlevez votre fichier pour en téléversez un autre", | ||||
|          data_dict_max_files_exceeded: "Nombre maximum de fichiers atteint. Enlevez les fichiers précédents", | ||||
|          data_dict_cancel_upload: "Annulez le téléversement", | ||||
|          data_dict_cancel_upload_confirm: "Êtes-vous sûr·e de vouloir annuler ce téléversement?", | ||||
|          data_dict_upload_canceled: "Téléversement annulé", | ||||
|          data_dict_remove: "Enlevez le fichier existant", | ||||
|       } | ||||
|    } | ||||
| }; | ||||
|  | ||||
| export default { | ||||
|    name: "AddAsyncUpload", | ||||
|    components: { | ||||
|       Modal | ||||
|    }, | ||||
|    i18n, | ||||
|     props: { | ||||
|        buttonTitle: { | ||||
|            type: String, | ||||
|            default: 'Ajouter un document', | ||||
|        }, | ||||
|         options: { | ||||
|             type: Object, | ||||
|             default: { | ||||
|                 maxFiles: 1, | ||||
|                 maxPostSize: 262144000, // 250MB | ||||
|                 required: false, | ||||
|             } | ||||
|         }, | ||||
|         btnClasses: { | ||||
|            type: Object, | ||||
|             default: { | ||||
|                 btn: true, | ||||
|                 'btn-create': true | ||||
|             } | ||||
|         } | ||||
|     }, | ||||
|    emits: ['addDocument'], | ||||
|    data() { | ||||
|       return { | ||||
|          modal: { | ||||
|             showModal: false, | ||||
|             modalDialogClass: "modal-dialog-centered modal-md" | ||||
|          }, | ||||
|       } | ||||
|    }, | ||||
|    updated() { | ||||
|      if (this.modal.showModal){ | ||||
|          searchForZones(this.$refs.dropZoneWrapper); | ||||
|      } | ||||
|    }, | ||||
|    methods: { | ||||
|       openModal() { | ||||
|          this.modal.showModal = true; | ||||
|       }, | ||||
|       saveDocument() { | ||||
|          const dropzone = this.$refs.dropZoneWrapper; | ||||
|          if (dropzone) { | ||||
|             const inputKey = dropzone.querySelector('input[data-stored-object-key]'); | ||||
|             const inputIv  = dropzone.querySelector('input[data-stored-object-iv]'); | ||||
|             const inputObject = dropzone.querySelector('input[data-async-file-upload]'); | ||||
|             const inputType = dropzone.querySelector('input[data-async-file-type]'); | ||||
|  | ||||
|             const url = '/api/1.0/docstore/stored-object.json'; | ||||
|             const body = { | ||||
|                filename: inputObject.value, | ||||
|                keyInfos: JSON.parse(inputKey.value), | ||||
|                iv: JSON.parse(inputIv.value), | ||||
|                type: inputType.value, | ||||
|             }; | ||||
|             makeFetch('POST', url, body) | ||||
|                .then(r => { | ||||
|                   this.$emit("addDocument", r); | ||||
|                   this.modal.showModal = false; | ||||
|                }) | ||||
|                .catch((error) => { | ||||
|                   if (error.name === 'ValidationException') { | ||||
|                      for (let v of error.violations) { | ||||
|                         this.$toast.open({message: v }); | ||||
|                      } | ||||
|                   } else { | ||||
|                      console.error(error); | ||||
|                      this.$toast.open({message: 'An error occurred'}); | ||||
|                   } | ||||
|                }); | ||||
|          } else { | ||||
|             this.$toast.open({message: 'An error occurred - drop zone not found'}); | ||||
|          } | ||||
|       } | ||||
|    } | ||||
| } | ||||
| </script> | ||||
| @@ -1,45 +0,0 @@ | ||||
| <template> | ||||
|   <a | ||||
|     class="btn btn-download" | ||||
|     :title="$t(buttonTitle)" | ||||
|     :data-key=JSON.stringify(storedObject.keyInfos) | ||||
|     :data-iv=JSON.stringify(storedObject.iv) | ||||
|     :data-mime-type=storedObject.type | ||||
|     :data-label-preparing="$t('dataLabelPreparing')" | ||||
|     :data-label-ready="$t('dataLabelReady')" | ||||
|     :data-temp-url-get-generator="url" | ||||
|     @click.once="downloadDocument"> | ||||
|   </a> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import { download } from '../../module/async_upload/downloader'; | ||||
|  | ||||
| const i18n = { | ||||
|   messages: { | ||||
|     fr: { | ||||
|       dataLabelPreparing: "Chargement...", | ||||
|       dataLabelReady: "", | ||||
|     } | ||||
|   } | ||||
| }; | ||||
|  | ||||
| export default { | ||||
|   name: "AddAsyncUploadDownloader", | ||||
|   i18n, | ||||
|   props: [ | ||||
|    'buttonTitle', | ||||
|    'storedObject' | ||||
|   ], | ||||
|   computed: { | ||||
|     url() { | ||||
|       return `/asyncupload/temp_url/generate/GET?object_name=${this.storedObject.filename}`; | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     downloadDocument(e) { | ||||
|       download(e.target); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| @@ -38,6 +38,11 @@ | ||||
|  | ||||
|     {% if display_action is defined and display_action == true %} | ||||
|         <ul class="record_actions"> | ||||
|             {% for dam in display_action_more|default([]) %} | ||||
|                 <li> | ||||
|                     {{ dam|raw }} | ||||
|                 </li> | ||||
|             {% endfor %} | ||||
|             {% if document.course != null and is_granted('CHILL_PERSON_ACCOMPANYING_PERIOD_SEE', document.course) %} | ||||
|                 <li> | ||||
|                     <a href="{{ path('chill_person_accompanying_course_index', {'accompanying_period_id': document.course.id}) }}" class="btn btn-show change-icon"> | ||||
|   | ||||
| @@ -71,7 +71,7 @@ | ||||
|                 </li> | ||||
|                 {% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_SEE_DETAILS', document) %} | ||||
|                     <li> | ||||
|                         {{ document.object|chill_document_button_group(document.title, is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_UPDATE', document)) }} | ||||
|                         {{ document.object|chill_document_button_group(document.title) }} | ||||
|                     </li> | ||||
|                     <li> | ||||
|                         <a href="{{ chill_path_add_return_path('accompanying_course_document_show', {'course': accompanyingCourse.id, 'id': document.id}) }}" class="btn btn-show"></a> | ||||
| @@ -90,7 +90,7 @@ | ||||
|             {% else %} | ||||
|                 {% if is_granted('CHILL_PERSON_DOCUMENT_SEE_DETAILS', document) %} | ||||
|                     <li> | ||||
|                         {{ document.object|chill_document_button_group(document.title, is_granted('CHILL_PERSON_DOCUMENT_UPDATE', document)) }} | ||||
|                         {{ document.object|chill_document_button_group(document.title) }} | ||||
|                     </li> | ||||
|                     <li> | ||||
|                         <a href="{{ path('person_document_show', {'person': person.id, 'id': document.id}) }}" class="btn btn-show"></a> | ||||
|   | ||||
| @@ -0,0 +1,38 @@ | ||||
| <!DOCTYPE html> | ||||
| <html lang="fr"> | ||||
| <head> | ||||
|     <meta charset="UTF-8" /> | ||||
|     <meta http-equiv="x-ua-compatible" content="ie=edge"> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||||
|     <link rel="shortcut icon" href="{{ asset('build/images/favicon.ico') }}" type="image/x-icon"> | ||||
|     <title>Signature</title> | ||||
|  | ||||
|     {{ encore_entry_link_tags('mod_bootstrap') }} | ||||
|     {{ encore_entry_link_tags('mod_forkawesome') }} | ||||
|     {{ encore_entry_link_tags('chill') }} | ||||
|     {{ encore_entry_link_tags('vue_document_signature') }} | ||||
|  | ||||
| </head> | ||||
|  | ||||
| <body> | ||||
|  | ||||
|     {% block js %} | ||||
|         {{ encore_entry_script_tags('mod_document_action_buttons_group') }} | ||||
|         <script type="text/javascript"> | ||||
|             window.signature = {{ signature|json_encode|raw }}; | ||||
|         </script> | ||||
|         {{ encore_entry_script_tags('vue_document_signature') }} | ||||
|     {% endblock %} | ||||
|  | ||||
|     <div class="content" id="content"> | ||||
|         <div class="container-xxl"> | ||||
|             <div class="row"> | ||||
|                <div class="col-xs-12 col-md-12 col-lg-9 my-5 m-auto"> | ||||
|                    <h4>{{ 'Document %title%' | trans({ '%title%': document.title }) }}</h4> | ||||
|                    <div class="row" id="document-signature"></div> | ||||
|                </div> | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
| </body> | ||||
| </html> | ||||
| @@ -70,12 +70,12 @@ class AccompanyingCourseDocumentVoter extends AbstractChillVoter implements Prov | ||||
|         return []; | ||||
|     } | ||||
|  | ||||
|     protected function supports($attribute, $subject): bool | ||||
|     public function supports($attribute, $subject): bool | ||||
|     { | ||||
|         return $this->voterHelper->supports($attribute, $subject); | ||||
|     } | ||||
|  | ||||
|     protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool | ||||
|     public function voteOnAttribute($attribute, $subject, TokenInterface $token): bool | ||||
|     { | ||||
|         if (!$token->getUser() instanceof User) { | ||||
|             return false; | ||||
|   | ||||
| @@ -12,6 +12,7 @@ declare(strict_types=1); | ||||
| namespace Chill\DocStoreBundle\Security\Authorization; | ||||
|  | ||||
| use Chill\DocStoreBundle\AsyncUpload\SignedUrl; | ||||
| use Chill\DocStoreBundle\Repository\StoredObjectRepository; | ||||
| use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; | ||||
| use Symfony\Component\Security\Core\Authorization\Voter\Voter; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
| @@ -22,6 +23,7 @@ final class AsyncUploadVoter extends Voter | ||||
|  | ||||
|     public function __construct( | ||||
|         private readonly Security $security, | ||||
|         private readonly StoredObjectRepository $storedObjectRepository, | ||||
|     ) {} | ||||
|  | ||||
|     protected function supports($attribute, $subject): bool | ||||
| @@ -32,10 +34,16 @@ final class AsyncUploadVoter extends Voter | ||||
|     protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool | ||||
|     { | ||||
|         /** @var SignedUrl $subject */ | ||||
|         if (!in_array($subject->method, ['POST', 'GET', 'HEAD'], true)) { | ||||
|         if (!in_array($subject->method, ['POST', 'GET', 'HEAD', 'PUT'], true)) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         return $this->security->isGranted('ROLE_USER') || $this->security->isGranted('ROLE_ADMIN'); | ||||
|         $storedObject = $this->storedObjectRepository->findOneBy(['filename' => $subject->object_name]); | ||||
|  | ||||
|         return match ($subject->method) { | ||||
|             'GET', 'HEAD' => $this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject), | ||||
|             'PUT' => $this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $storedObject), | ||||
|             'POST' => $this->security->isGranted('ROLE_USER') || $this->security->isGranted('ROLE_ADMIN'), | ||||
|         }; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -12,9 +12,10 @@ declare(strict_types=1); | ||||
| namespace Chill\DocStoreBundle\Security\Authorization; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Security\Guard\DavTokenAuthenticationEventSubscriber; | ||||
| use Psr\Log\LoggerInterface; | ||||
| use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; | ||||
| use Symfony\Component\Security\Core\Authorization\Voter\Voter; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
|  | ||||
| /** | ||||
|  * Voter for the content of a stored object. | ||||
| @@ -23,6 +24,10 @@ use Symfony\Component\Security\Core\Authorization\Voter\Voter; | ||||
|  */ | ||||
| class StoredObjectVoter extends Voter | ||||
| { | ||||
|     public const LOG_PREFIX = '[stored object voter] '; | ||||
|  | ||||
|     public function __construct(private readonly Security $security, private readonly iterable $storedObjectVoters, private readonly LoggerInterface $logger) {} | ||||
|  | ||||
|     protected function supports($attribute, $subject): bool | ||||
|     { | ||||
|         return StoredObjectRoleEnum::tryFrom($attribute) instanceof StoredObjectRoleEnum | ||||
| @@ -32,24 +37,28 @@ class StoredObjectVoter extends Voter | ||||
|     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; | ||||
|         $attributeAsEnum = StoredObjectRoleEnum::from($attribute); | ||||
|  | ||||
|         // Loop through context-specific voters | ||||
|         foreach ($this->storedObjectVoters as $storedObjectVoter) { | ||||
|             if ($storedObjectVoter->supports($attributeAsEnum, $subject)) { | ||||
|                 $grant = $storedObjectVoter->voteOnAttribute($attributeAsEnum, $subject, $token); | ||||
|  | ||||
|                 if (false === $grant) { | ||||
|                     $this->logger->debug(self::LOG_PREFIX.'deny access by storedObjectVoter', ['stored_object_voter' => $storedObjectVoter::class]); | ||||
|                 } | ||||
|  | ||||
|                 return $grant; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (!$token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)) { | ||||
|             return false; | ||||
|         // User role-based fallback | ||||
|         if ($this->security->isGranted('ROLE_USER') || $this->security->isGranted('ROLE_ADMIN')) { | ||||
|             // TODO: this maybe considered as a security issue, as all authenticated users can reach a stored object which | ||||
|             // is potentially detached from an existing entity. | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         $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, | ||||
|         }; | ||||
|         return false; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,69 @@ | ||||
| <?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\StoredObjectVoter; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface; | ||||
| use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; | ||||
| use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoterInterface; | ||||
| use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper; | ||||
| use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
|  | ||||
| abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface | ||||
| { | ||||
|     abstract protected function getRepository(): AssociatedEntityToStoredObjectInterface; | ||||
|  | ||||
|     /** | ||||
|      * @return class-string | ||||
|      */ | ||||
|     abstract protected function getClass(): string; | ||||
|  | ||||
|     abstract protected function attributeToRole(StoredObjectRoleEnum $attribute): string; | ||||
|  | ||||
|     abstract protected function canBeAssociatedWithWorkflow(): bool; | ||||
|  | ||||
|     public function __construct( | ||||
|         private readonly Security $security, | ||||
|         private readonly ?WorkflowStoredObjectPermissionHelper $workflowDocumentService = null, | ||||
|     ) {} | ||||
|  | ||||
|     public function supports(StoredObjectRoleEnum $attribute, StoredObject $subject): bool | ||||
|     { | ||||
|         $class = $this->getClass(); | ||||
|  | ||||
|         return $this->getRepository()->findAssociatedEntityToStoredObject($subject) instanceof $class; | ||||
|     } | ||||
|  | ||||
|     public function voteOnAttribute(StoredObjectRoleEnum $attribute, StoredObject $subject, TokenInterface $token): bool | ||||
|     { | ||||
|         // Retrieve the related accompanying course document | ||||
|         $entity = $this->getRepository()->findAssociatedEntityToStoredObject($subject); | ||||
|  | ||||
|         // Determine the attribute to pass to AccompanyingCourseDocumentVoter | ||||
|         $voterAttribute = $this->attributeToRole($attribute); | ||||
|  | ||||
|         if (false === $this->security->isGranted($voterAttribute, $entity)) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (StoredObjectRoleEnum::SEE !== $attribute && $this->canBeAssociatedWithWorkflow()) { | ||||
|             if (null === $this->workflowDocumentService) { | ||||
|                 throw new \LogicException('Provide a workflow document service'); | ||||
|             } | ||||
|  | ||||
|             return $this->workflowDocumentService->notBlockedByWorkflow($entity); | ||||
|         } | ||||
|  | ||||
|         return true; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,54 @@ | ||||
| <?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\StoredObjectVoter; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument; | ||||
| use Chill\DocStoreBundle\Repository\AccompanyingCourseDocumentRepository; | ||||
| use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface; | ||||
| use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter; | ||||
| use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; | ||||
| use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
|  | ||||
| final class AccompanyingCourseDocumentStoredObjectVoter extends AbstractStoredObjectVoter | ||||
| { | ||||
|     public function __construct( | ||||
|         private readonly AccompanyingCourseDocumentRepository $repository, | ||||
|         Security $security, | ||||
|         WorkflowStoredObjectPermissionHelper $workflowDocumentService, | ||||
|     ) { | ||||
|         parent::__construct($security, $workflowDocumentService); | ||||
|     } | ||||
|  | ||||
|     protected function getRepository(): AssociatedEntityToStoredObjectInterface | ||||
|     { | ||||
|         return $this->repository; | ||||
|     } | ||||
|  | ||||
|     protected function attributeToRole(StoredObjectRoleEnum $attribute): string | ||||
|     { | ||||
|         return match ($attribute) { | ||||
|             StoredObjectRoleEnum::EDIT => AccompanyingCourseDocumentVoter::UPDATE, | ||||
|             StoredObjectRoleEnum::SEE => AccompanyingCourseDocumentVoter::SEE_DETAILS, | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     protected function getClass(): string | ||||
|     { | ||||
|         return AccompanyingCourseDocument::class; | ||||
|     } | ||||
|  | ||||
|     protected function canBeAssociatedWithWorkflow(): bool | ||||
|     { | ||||
|         return true; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,54 @@ | ||||
| <?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\StoredObjectVoter; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\PersonDocument; | ||||
| use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface; | ||||
| use Chill\DocStoreBundle\Repository\PersonDocumentRepository; | ||||
| use Chill\DocStoreBundle\Security\Authorization\PersonDocumentVoter; | ||||
| use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; | ||||
| use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
|  | ||||
| class PersonDocumentStoredObjectVoter extends AbstractStoredObjectVoter | ||||
| { | ||||
|     public function __construct( | ||||
|         private readonly PersonDocumentRepository $repository, | ||||
|         Security $security, | ||||
|         WorkflowStoredObjectPermissionHelper $workflowDocumentService, | ||||
|     ) { | ||||
|         parent::__construct($security, $workflowDocumentService); | ||||
|     } | ||||
|  | ||||
|     protected function getRepository(): AssociatedEntityToStoredObjectInterface | ||||
|     { | ||||
|         return $this->repository; | ||||
|     } | ||||
|  | ||||
|     protected function getClass(): string | ||||
|     { | ||||
|         return PersonDocument::class; | ||||
|     } | ||||
|  | ||||
|     protected function attributeToRole(StoredObjectRoleEnum $attribute): string | ||||
|     { | ||||
|         return match ($attribute) { | ||||
|             StoredObjectRoleEnum::EDIT => PersonDocumentVoter::UPDATE, | ||||
|             StoredObjectRoleEnum::SEE => PersonDocumentVoter::SEE_DETAILS, | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     protected function canBeAssociatedWithWorkflow(): bool | ||||
|     { | ||||
|         return true; | ||||
|     } | ||||
| } | ||||
| @@ -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; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; | ||||
|  | ||||
| interface StoredObjectVoterInterface | ||||
| { | ||||
|     public function supports(StoredObjectRoleEnum $attribute, StoredObject $subject): bool; | ||||
|  | ||||
|     public function voteOnAttribute(StoredObjectRoleEnum $attribute, StoredObject $subject, TokenInterface $token): bool; | ||||
| } | ||||
| @@ -12,37 +12,75 @@ declare(strict_types=1); | ||||
| namespace Chill\DocStoreBundle\Serializer\Normalizer; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Repository\StoredObjectRepository; | ||||
| use Chill\DocStoreBundle\Repository\StoredObjectRepositoryInterface; | ||||
| use Symfony\Component\Form\Exception\TransformationFailedException; | ||||
| use Symfony\Component\Serializer\Exception\LogicException; | ||||
| use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; | ||||
| use Symfony\Component\Serializer\Normalizer\ObjectToPopulateTrait; | ||||
|  | ||||
| /** | ||||
|  * Implements the DenormalizerInterface and is responsible for denormalizing data into StoredObject objects. | ||||
|  * | ||||
|  * If a new StoredObjectVersion has been added to the StoredObject, the version is created here and registered | ||||
|  * to the StoredObject. | ||||
|  */ | ||||
| class StoredObjectDenormalizer implements DenormalizerInterface | ||||
| { | ||||
|     use ObjectToPopulateTrait; | ||||
|  | ||||
|     public function __construct(private readonly StoredObjectRepository $storedObjectRepository) {} | ||||
|     public function __construct(private readonly StoredObjectRepositoryInterface $storedObjectRepository) {} | ||||
|  | ||||
|     public function denormalize($data, $type, $format = null, array $context = []) | ||||
|     public function denormalize($data, $type, $format = null, array $context = []): ?StoredObject | ||||
|     { | ||||
|         $object = $this->extractObjectToPopulate(StoredObject::class, $context); | ||||
|         $storedObject = $this->extractObjectToPopulate(StoredObject::class, $context); | ||||
|  | ||||
|         if (null !== $object) { | ||||
|             return $object; | ||||
|         if (null === $storedObject) { | ||||
|             if (array_key_exists('uuid', $data)) { | ||||
|                 $storedObject = $this->storedObjectRepository->findOneByUUID($data['uuid']); | ||||
|             } else { | ||||
|                 $storedObject = $this->storedObjectRepository->find($data['id']); | ||||
|             } | ||||
|  | ||||
|             if (null === $storedObject) { | ||||
|                 throw new LogicException('Object not found'); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return $this->storedObjectRepository->find($data['id']); | ||||
|         $storedObject->setTitle($data['title'] ?? $storedObject->getTitle()); | ||||
|  | ||||
|         if (true === ($data['currentVersion']['persisted'] ?? true)) { | ||||
|             // nothing has change, stop here | ||||
|             return $storedObject; | ||||
|         } | ||||
|  | ||||
|         if ([] !== $diff = array_diff(['filename', 'iv', 'keyInfos', 'type'], array_keys($data['currentVersion']))) { | ||||
|             throw new TransformationFailedException(sprintf('missing some keys in currentVersion: %s', implode(', ', $diff))); | ||||
|         } | ||||
|  | ||||
|         $storedObject->registerVersion( | ||||
|             $data['currentVersion']['iv'], | ||||
|             $data['currentVersion']['keyInfos'], | ||||
|             $data['currentVersion']['type'], | ||||
|             $data['currentVersion']['filename'] | ||||
|         ); | ||||
|  | ||||
|         return $storedObject; | ||||
|     } | ||||
|  | ||||
|     public function supportsDenormalization($data, $type, $format = null) | ||||
|     public function supportsDenormalization($data, $type, $format = null): bool | ||||
|     { | ||||
|         if (false === \is_array($data)) { | ||||
|         if (StoredObject::class !== $type) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (false === \array_key_exists('id', $data)) { | ||||
|         if (false === is_array($data)) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         return StoredObject::class === $type; | ||||
|         if (array_key_exists('id', $data) || array_key_exists('uuid', $data)) { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         return false; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -15,6 +15,8 @@ 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\Security\Core\Security; | ||||
| use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; | ||||
| use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; | ||||
| use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; | ||||
| use Symfony\Component\Serializer\Normalizer\NormalizerInterface; | ||||
| @@ -27,41 +29,44 @@ use Symfony\Component\Serializer\Normalizer\NormalizerInterface; | ||||
| 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, | ||||
|         private readonly Security $security, | ||||
|     ) {} | ||||
|  | ||||
|     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(), | ||||
|             'datas' => $object->getDatas(), | ||||
|             'prefix' => $object->getPrefix(), | ||||
|             'title' => $object->getTitle(), | ||||
|             'type' => $object->getType(), | ||||
|             'uuid' => $object->getUuid(), | ||||
|             'uuid' => $object->getUuid()->toString(), | ||||
|             'status' => $object->getStatus(), | ||||
|             'createdAt' => $this->normalizer->normalize($object->getCreatedAt(), $format, $context), | ||||
|             'createdBy' => $this->normalizer->normalize($object->getCreatedBy(), $format, $context), | ||||
|             'currentVersion' => $this->normalizer->normalize($object->getCurrentVersion(), $format, [...$context, [AbstractNormalizer::GROUPS => 'read']]), | ||||
|             'totalVersions' => $object->getVersions()->count(), | ||||
|         ]; | ||||
|  | ||||
|         // 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); | ||||
|         $canSee = $this->security->isGranted(StoredObjectRoleEnum::SEE->value, $object); | ||||
|         $canEdit = $this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $object); | ||||
|  | ||||
|         if ($canDavSee || $canDavEdit) { | ||||
|         $datas['_permissions'] = [ | ||||
|             'canEdit' => $canEdit, | ||||
|             'canSee' => $canSee, | ||||
|         ]; | ||||
|  | ||||
|         if ($canSee || $canEdit) { | ||||
|             $accessToken = $this->JWTDavTokenProvider->createToken( | ||||
|                 $object, | ||||
|                 $canDavEdit ? StoredObjectRoleEnum::EDIT : StoredObjectRoleEnum::SEE | ||||
|                 $canEdit ? StoredObjectRoleEnum::EDIT : StoredObjectRoleEnum::SEE | ||||
|             ); | ||||
|  | ||||
|             $datas['_links'] = [ | ||||
| @@ -74,7 +79,7 @@ final class StoredObjectNormalizer implements NormalizerInterface, NormalizerAwa | ||||
|                         ], | ||||
|                         UrlGeneratorInterface::ABSOLUTE_URL, | ||||
|                     ), | ||||
|                     'expiration' => $this->JWTDavTokenProvider->getTokenExpiration($accessToken)->format('U'), | ||||
|                     'expiration' => $this->JWTDavTokenProvider->getTokenExpiration($accessToken)->getTimestamp(), | ||||
|                 ], | ||||
|             ]; | ||||
|         } | ||||
|   | ||||
| @@ -0,0 +1,45 @@ | ||||
| <?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\StoredObjectVersion; | ||||
| use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; | ||||
| use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; | ||||
| use Symfony\Component\Serializer\Normalizer\NormalizerInterface; | ||||
|  | ||||
| class StoredObjectVersionNormalizer implements NormalizerInterface, NormalizerAwareInterface | ||||
| { | ||||
|     use NormalizerAwareTrait; | ||||
|  | ||||
|     public function normalize($object, ?string $format = null, array $context = []) | ||||
|     { | ||||
|         if (!$object instanceof StoredObjectVersion) { | ||||
|             throw new \InvalidArgumentException('The object must be an instance of '.StoredObjectVersion::class); | ||||
|         } | ||||
|  | ||||
|         return [ | ||||
|             'id' => $object->getId(), | ||||
|             'filename' => $object->getFilename(), | ||||
|             'version' => $object->getVersion(), | ||||
|             'iv' => array_values($object->getIv()), | ||||
|             'keyInfos' => $object->getKeyInfos(), | ||||
|             'type' => $object->getType(), | ||||
|             'createdAt' => $this->normalizer->normalize($object->getCreatedAt(), $format, $context), | ||||
|             'createdBy' => $this->normalizer->normalize($object->getCreatedBy(), $format, $context), | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|     public function supportsNormalization($data, ?string $format = null, array $context = []) | ||||
|     { | ||||
|         return $data instanceof StoredObjectVersion; | ||||
|     } | ||||
| } | ||||
| @@ -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\Service\Signature\Driver\BaseSigner; | ||||
|  | ||||
| /** | ||||
|  * Message which is received when a pdf is signed. | ||||
|  */ | ||||
| final readonly class PdfSignedMessage | ||||
| { | ||||
|     public function __construct( | ||||
|         public readonly int $signatureId, | ||||
|         public readonly int $signatureZoneIndex, | ||||
|         public readonly string $content, | ||||
|     ) {} | ||||
| } | ||||
| @@ -0,0 +1,62 @@ | ||||
| <?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\Service\Signature\Driver\BaseSigner; | ||||
|  | ||||
| use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum; | ||||
| use Chill\MainBundle\Repository\Workflow\EntityWorkflowStepSignatureRepository; | ||||
| use Chill\MainBundle\Workflow\EntityWorkflowManager; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Psr\Log\LoggerInterface; | ||||
| use Symfony\Component\Clock\ClockInterface; | ||||
| use Symfony\Component\Messenger\Handler\MessageHandlerInterface; | ||||
|  | ||||
| final readonly class PdfSignedMessageHandler implements MessageHandlerInterface | ||||
| { | ||||
|     /** | ||||
|      * log prefix. | ||||
|      */ | ||||
|     private const P = '[pdf signed message] '; | ||||
|  | ||||
|     public function __construct( | ||||
|         private LoggerInterface $logger, | ||||
|         private EntityWorkflowManager $entityWorkflowManager, | ||||
|         private StoredObjectManagerInterface $storedObjectManager, | ||||
|         private EntityWorkflowStepSignatureRepository $entityWorkflowStepSignatureRepository, | ||||
|         private EntityManagerInterface $entityManager, | ||||
|         private ClockInterface $clock, | ||||
|     ) {} | ||||
|  | ||||
|     public function __invoke(PdfSignedMessage $message): void | ||||
|     { | ||||
|         $this->logger->info(self::P.'a message is received', ['signaturedId' => $message->signatureId]); | ||||
|  | ||||
|         $signature = $this->entityWorkflowStepSignatureRepository->find($message->signatureId); | ||||
|  | ||||
|         if (null === $signature) { | ||||
|             throw new \RuntimeException('no signature found'); | ||||
|         } | ||||
|  | ||||
|         $storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($signature->getStep()->getEntityWorkflow()); | ||||
|  | ||||
|         if (null === $storedObject) { | ||||
|             throw new \RuntimeException('no stored object found'); | ||||
|         } | ||||
|  | ||||
|         $this->storedObjectManager->write($storedObject, $message->content); | ||||
|  | ||||
|         $signature->setState(EntityWorkflowSignatureStateEnum::SIGNED)->setStateDate($this->clock->now()); | ||||
|         $signature->setZoneSignatureIndex($message->signatureZoneIndex); | ||||
|         $this->entityManager->flush(); | ||||
|         $this->entityManager->clear(); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,67 @@ | ||||
| <?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\Service\Signature\Driver\BaseSigner; | ||||
|  | ||||
| use Symfony\Component\Messenger\Envelope; | ||||
| use Symfony\Component\Messenger\Exception\MessageDecodingFailedException; | ||||
| use Symfony\Component\Messenger\Transport\Serialization\SerializerInterface; | ||||
|  | ||||
| /** | ||||
|  * Decode (and requeue) @see{PdfSignedMessage}, which comes from an external producer. | ||||
|  */ | ||||
| final readonly class PdfSignedMessageSerializer implements SerializerInterface | ||||
| { | ||||
|     public function decode(array $encodedEnvelope): Envelope | ||||
|     { | ||||
|         $body = $encodedEnvelope['body']; | ||||
|  | ||||
|         try { | ||||
|             $decoded = json_decode((string) $body, true, 512, JSON_THROW_ON_ERROR); | ||||
|         } catch (\JsonException $e) { | ||||
|             throw new MessageDecodingFailedException('Could not deserialize message', previous: $e); | ||||
|         } | ||||
|  | ||||
|         if (!array_key_exists('signatureId', $decoded) || !array_key_exists('content', $decoded)) { | ||||
|             throw new MessageDecodingFailedException('Could not find expected keys: signatureId or content'); | ||||
|         } | ||||
|  | ||||
|         $content = base64_decode((string) $decoded['content'], true); | ||||
|  | ||||
|         if (false === $content) { | ||||
|             throw new MessageDecodingFailedException('Invalid character found in the base64 encoded content'); | ||||
|         } | ||||
|  | ||||
|         $message = new PdfSignedMessage($decoded['signatureId'], $decoded['signatureZoneIndex'], $content); | ||||
|  | ||||
|         return new Envelope($message); | ||||
|     } | ||||
|  | ||||
|     public function encode(Envelope $envelope): array | ||||
|     { | ||||
|         $message = $envelope->getMessage(); | ||||
|  | ||||
|         if (!$message instanceof PdfSignedMessage) { | ||||
|             throw new MessageDecodingFailedException('Expected a PdfSignedMessage'); | ||||
|         } | ||||
|  | ||||
|         $data = [ | ||||
|             'signatureId' => $message->signatureId, | ||||
|             'signatureZoneIndex' => $message->signatureZoneIndex, | ||||
|             'content' => base64_encode($message->content), | ||||
|         ]; | ||||
|  | ||||
|         return [ | ||||
|             'body' => json_encode($data, JSON_THROW_ON_ERROR), | ||||
|             'headers' => [], | ||||
|         ]; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,29 @@ | ||||
| <?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\Service\Signature\Driver\BaseSigner; | ||||
|  | ||||
| use Chill\DocStoreBundle\Service\Signature\PDFSignatureZone; | ||||
|  | ||||
| /** | ||||
|  * Message which is sent when we request a signature on a pdf. | ||||
|  */ | ||||
| final readonly class RequestPdfSignMessage | ||||
| { | ||||
|     public function __construct( | ||||
|         public int $signatureId, | ||||
|         public PDFSignatureZone $PDFSignatureZone, | ||||
|         public int $signatureZoneIndex, | ||||
|         public string $reason, | ||||
|         public string $signerText, | ||||
|         public string $content, | ||||
|     ) {} | ||||
| } | ||||
| @@ -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\Service\Signature\Driver\BaseSigner; | ||||
|  | ||||
| use Chill\DocStoreBundle\Service\Signature\PDFSignatureZone; | ||||
| use Symfony\Component\Messenger\Envelope; | ||||
| use Symfony\Component\Messenger\Exception\MessageDecodingFailedException; | ||||
| use Symfony\Component\Messenger\Stamp\NonSendableStampInterface; | ||||
| use Symfony\Component\Messenger\Transport\Serialization\SerializerInterface; | ||||
| use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; | ||||
| use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; | ||||
| use Symfony\Component\Serializer\Normalizer\NormalizerInterface; | ||||
|  | ||||
| /** | ||||
|  * Serialize a RequestPdfSignMessage, for external consumer. | ||||
|  */ | ||||
| final readonly class RequestPdfSignMessageSerializer implements SerializerInterface | ||||
| { | ||||
|     public function __construct( | ||||
|         private NormalizerInterface $normalizer, | ||||
|         private DenormalizerInterface $denormalizer, | ||||
|     ) {} | ||||
|  | ||||
|     public function decode(array $encodedEnvelope): Envelope | ||||
|     { | ||||
|         $body = $encodedEnvelope['body']; | ||||
|         $headers = $encodedEnvelope['headers']; | ||||
|  | ||||
|         if (RequestPdfSignMessage::class !== ($headers['Message'] ?? null)) { | ||||
|             throw new MessageDecodingFailedException('serializer does not support this message'); | ||||
|         } | ||||
|  | ||||
|         $data = json_decode((string) $body, true); | ||||
|  | ||||
|         $zoneSignature = $this->denormalizer->denormalize($data['signatureZone'], PDFSignatureZone::class, 'json', [ | ||||
|             AbstractNormalizer::GROUPS => ['write'], | ||||
|         ]); | ||||
|  | ||||
|         $content = base64_decode((string) $data['content'], true); | ||||
|  | ||||
|         if (false === $content) { | ||||
|             throw new MessageDecodingFailedException('the content could not be converted from base64 encoding'); | ||||
|         } | ||||
|  | ||||
|         $message = new RequestPdfSignMessage( | ||||
|             $data['signatureId'], | ||||
|             $zoneSignature, | ||||
|             $data['signatureZoneIndex'], | ||||
|             $data['reason'], | ||||
|             $data['signerText'], | ||||
|             $content, | ||||
|         ); | ||||
|  | ||||
|         // in case of redelivery, unserialize any stamps | ||||
|         $stamps = []; | ||||
|         if (isset($headers['stamps'])) { | ||||
|             $stamps = unserialize($headers['stamps']); | ||||
|         } | ||||
|  | ||||
|         return new Envelope($message, $stamps); | ||||
|     } | ||||
|  | ||||
|     public function encode(Envelope $envelope): array | ||||
|     { | ||||
|         $message = $envelope->getMessage(); | ||||
|  | ||||
|         if (!$message instanceof RequestPdfSignMessage) { | ||||
|             throw new MessageDecodingFailedException('Message is not a RequestPdfSignMessage'); | ||||
|         } | ||||
|  | ||||
|         $data = [ | ||||
|             'signatureId' => $message->signatureId, | ||||
|             'signatureZoneIndex' => $message->signatureZoneIndex, | ||||
|             'signatureZone' => $this->normalizer->normalize($message->PDFSignatureZone, 'json', [AbstractNormalizer::GROUPS => ['read']]), | ||||
|             'reason' => $message->reason, | ||||
|             'signerText' => $message->signerText, | ||||
|             'content' => base64_encode($message->content), | ||||
|         ]; | ||||
|  | ||||
|         $allStamps = []; | ||||
|         foreach ($envelope->all() as $stamp) { | ||||
|             if ($stamp instanceof NonSendableStampInterface) { | ||||
|                 continue; | ||||
|             } | ||||
|             $allStamps = [...$allStamps, ...$stamp]; | ||||
|         } | ||||
|  | ||||
|         return [ | ||||
|             'body' => json_encode($data, JSON_THROW_ON_ERROR, 512), | ||||
|             'headers' => [ | ||||
|                 'stamps' => serialize($allStamps), | ||||
|                 'Message' => RequestPdfSignMessage::class, | ||||
|             ], | ||||
|         ]; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										33
									
								
								src/Bundle/ChillDocStoreBundle/Service/Signature/PDFPage.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								src/Bundle/ChillDocStoreBundle/Service/Signature/PDFPage.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | ||||
| <?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\Service\Signature; | ||||
|  | ||||
| use Symfony\Component\Serializer\Annotation\Groups; | ||||
|  | ||||
| final readonly class PDFPage | ||||
| { | ||||
|     public function __construct( | ||||
|         #[Groups(['read'])] | ||||
|         public int $index, | ||||
|         #[Groups(['read'])] | ||||
|         public float $width, | ||||
|         #[Groups(['read'])] | ||||
|         public float $height, | ||||
|     ) {} | ||||
|  | ||||
|     public function equals(self $page): bool | ||||
|     { | ||||
|         return $page->index === $this->index | ||||
|             && round($page->width, 2) === round($this->width, 2) | ||||
|             && round($page->height, 2) === round($this->height, 2); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,43 @@ | ||||
| <?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\Service\Signature; | ||||
|  | ||||
| use Symfony\Component\Serializer\Annotation\Groups; | ||||
|  | ||||
| final readonly class PDFSignatureZone | ||||
| { | ||||
|     public function __construct( | ||||
|         #[Groups(['read'])] | ||||
|         public int $index, | ||||
|         #[Groups(['read'])] | ||||
|         public float $x, | ||||
|         #[Groups(['read'])] | ||||
|         public float $y, | ||||
|         #[Groups(['read'])] | ||||
|         public float $height, | ||||
|         #[Groups(['read'])] | ||||
|         public float $width, | ||||
|         #[Groups(['read'])] | ||||
|         public PDFPage $PDFPage, | ||||
|     ) {} | ||||
|  | ||||
|     public function equals(self $other): bool | ||||
|     { | ||||
|         return | ||||
|             $this->index == $other->index | ||||
|             && $this->x == $other->x | ||||
|             && $this->y == $other->y | ||||
|             && $this->height == $other->height | ||||
|             && $this->width == $other->width | ||||
|             && $this->PDFPage->equals($other->PDFPage); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,69 @@ | ||||
| <?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\Service\Signature; | ||||
|  | ||||
| use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflow; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature; | ||||
| use Chill\MainBundle\Workflow\EntityWorkflowManager; | ||||
|  | ||||
| class PDFSignatureZoneAvailable | ||||
| { | ||||
|     public function __construct( | ||||
|         private readonly EntityWorkflowManager $entityWorkflowManager, | ||||
|         private readonly PDFSignatureZoneParser $pdfSignatureZoneParser, | ||||
|         private readonly StoredObjectManagerInterface $storedObjectManager, | ||||
|     ) {} | ||||
|  | ||||
|     /** | ||||
|      * @return list<PDFSignatureZone> | ||||
|      */ | ||||
|     public function getAvailableSignatureZones(EntityWorkflow $entityWorkflow): array | ||||
|     { | ||||
|         $storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($entityWorkflow); | ||||
|  | ||||
|         if (null === $storedObject) { | ||||
|             throw new \RuntimeException('No stored object found'); | ||||
|         } | ||||
|  | ||||
|         if ('application/pdf' !== $storedObject->getType()) { | ||||
|             throw new \RuntimeException('Only PDF documents are supported'); | ||||
|         } | ||||
|  | ||||
|         $zones = $this->pdfSignatureZoneParser->findSignatureZones($this->storedObjectManager->read($storedObject)); | ||||
|         $signatureZonesIndexes = array_map( | ||||
|             fn (EntityWorkflowStepSignature $step) => $step->getZoneSignatureIndex(), | ||||
|             $this->collectSignaturesInUse($entityWorkflow) | ||||
|         ); | ||||
|  | ||||
|         return array_values(array_filter($zones, fn (PDFSignatureZone $zone) => !in_array($zone->index, $signatureZonesIndexes, true))); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @return list<EntityWorkflowStepSignature> | ||||
|      */ | ||||
|     private function collectSignaturesInUse(EntityWorkflow $entityWorkflow): array | ||||
|     { | ||||
|         return array_reduce($entityWorkflow->getSteps()->toArray(), function (array $result, EntityWorkflowStep $step) { | ||||
|             $current = [...$result]; | ||||
|             foreach ($step->getSignatures() as $signature) { | ||||
|                 if (EntityWorkflowSignatureStateEnum::SIGNED === $signature->getState()) { | ||||
|                     $current[] = $signature; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             return $current; | ||||
|         }, []); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,60 @@ | ||||
| <?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\Service\Signature; | ||||
|  | ||||
| use Smalot\PdfParser\Parser; | ||||
|  | ||||
| class PDFSignatureZoneParser | ||||
| { | ||||
|     public const ZONE_SIGNATURE_START = 'signature_zone'; | ||||
|  | ||||
|     private readonly Parser $parser; | ||||
|  | ||||
|     public function __construct( | ||||
|         public float $defaultHeight = 90.0, | ||||
|         public float $defaultWidth = 180.0, | ||||
|     ) { | ||||
|         $this->parser = new Parser(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @return list<PDFSignatureZone> | ||||
|      */ | ||||
|     public function findSignatureZones(string $fileContent): array | ||||
|     { | ||||
|         $pdf = $this->parser->parseContent($fileContent); | ||||
|         $zones = []; | ||||
|  | ||||
|         $defaults = $pdf->getObjectsByType('Pages'); | ||||
|         $defaultPage = reset($defaults); | ||||
|         $defaultPageDetails = $defaultPage->getDetails(); | ||||
|         $zoneIndex = 0; | ||||
|  | ||||
|         foreach ($pdf->getPages() as $index => $page) { | ||||
|             $details = $page->getDetails(); | ||||
|             $pdfPage = new PDFPage( | ||||
|                 $index, | ||||
|                 (float) ($details['MediaBox'][2] ?? $defaultPageDetails['MediaBox'][2]), | ||||
|                 (float) ($details['MediaBox'][3] ?? $defaultPageDetails['MediaBox'][3]), | ||||
|             ); | ||||
|  | ||||
|             foreach ($page->getDataTm() as $dataTm) { | ||||
|                 if (str_starts_with((string) $dataTm[1], self::ZONE_SIGNATURE_START)) { | ||||
|                     $zones[] = new PDFSignatureZone($zoneIndex, (float) $dataTm[0][4], (float) $dataTm[0][5], $this->defaultHeight, $this->defaultWidth, $pdfPage); | ||||
|                     ++$zoneIndex; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return $zones; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,65 @@ | ||||
| <?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\Service\StoredObjectCleaner; | ||||
|  | ||||
| use Chill\DocStoreBundle\Repository\StoredObjectRepositoryInterface; | ||||
| use Chill\MainBundle\Cron\CronJobInterface; | ||||
| use Chill\MainBundle\Entity\CronJobExecution; | ||||
| use Symfony\Component\Clock\ClockInterface; | ||||
| use Symfony\Component\Messenger\MessageBusInterface; | ||||
|  | ||||
| /** | ||||
|  * Represents a cron job that removes expired stored objects. | ||||
|  * | ||||
|  * This cronjob is executed every 7days, to remove expired stored object. For every | ||||
|  * expired stored object, every version is sent to message bus for async deletion. | ||||
|  */ | ||||
| final readonly class RemoveExpiredStoredObjectCronJob implements CronJobInterface | ||||
| { | ||||
|     public const KEY = 'remove-expired-stored-object'; | ||||
|  | ||||
|     private const LAST_DELETED_KEY = 'last-deleted-stored-object-id'; | ||||
|  | ||||
|     public function __construct( | ||||
|         private ClockInterface $clock, | ||||
|         private MessageBusInterface $messageBus, | ||||
|         private StoredObjectRepositoryInterface $storedObjectRepository, | ||||
|     ) {} | ||||
|  | ||||
|     public function canRun(?CronJobExecution $cronJobExecution): bool | ||||
|     { | ||||
|         if (null === $cronJobExecution) { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         return $this->clock->now() >= $cronJobExecution->getLastEnd()->add(new \DateInterval('P7D')); | ||||
|     } | ||||
|  | ||||
|     public function getKey(): string | ||||
|     { | ||||
|         return self::KEY; | ||||
|     } | ||||
|  | ||||
|     public function run(array $lastExecutionData): ?array | ||||
|     { | ||||
|         $lastDeleted = $lastExecutionData[self::LAST_DELETED_KEY] ?? 0; | ||||
|  | ||||
|         foreach ($this->storedObjectRepository->findByExpired($this->clock->now()) as $storedObject) { | ||||
|             foreach ($storedObject->getVersions() as $version) { | ||||
|                 $this->messageBus->dispatch(new RemoveOldVersionMessage($version->getId())); | ||||
|             } | ||||
|             $lastDeleted = max($lastDeleted, $storedObject->getId()); | ||||
|         } | ||||
|  | ||||
|         return [self::LAST_DELETED_KEY => $lastDeleted]; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,60 @@ | ||||
| <?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\Service\StoredObjectCleaner; | ||||
|  | ||||
| use Chill\DocStoreBundle\Repository\StoredObjectVersionRepository; | ||||
| use Chill\MainBundle\Cron\CronJobInterface; | ||||
| use Chill\MainBundle\Entity\CronJobExecution; | ||||
| use Symfony\Component\Clock\ClockInterface; | ||||
| use Symfony\Component\Messenger\MessageBusInterface; | ||||
|  | ||||
| final readonly class RemoveOldVersionCronJob implements CronJobInterface | ||||
| { | ||||
|     public const KEY = 'remove-old-stored-object-version'; | ||||
|  | ||||
|     private const LAST_DELETED_KEY = 'last-deleted-stored-object-version-id'; | ||||
|  | ||||
|     public const KEEP_INTERVAL = 'P90D'; | ||||
|  | ||||
|     public function __construct( | ||||
|         private ClockInterface $clock, | ||||
|         private MessageBusInterface $messageBus, | ||||
|         private StoredObjectVersionRepository $storedObjectVersionRepository, | ||||
|     ) {} | ||||
|  | ||||
|     public function canRun(?CronJobExecution $cronJobExecution): bool | ||||
|     { | ||||
|         if (null === $cronJobExecution) { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         return $this->clock->now() >= $cronJobExecution->getLastEnd()->add(new \DateInterval('P1D')); | ||||
|     } | ||||
|  | ||||
|     public function getKey(): string | ||||
|     { | ||||
|         return self::KEY; | ||||
|     } | ||||
|  | ||||
|     public function run(array $lastExecutionData): ?array | ||||
|     { | ||||
|         $deleteBeforeDate = $this->clock->now()->sub(new \DateInterval(self::KEEP_INTERVAL)); | ||||
|         $maxDeleted = $lastExecutionData[self::LAST_DELETED_KEY] ?? 0; | ||||
|  | ||||
|         foreach ($this->storedObjectVersionRepository->findIdsByVersionsOlderThanDateAndNotLastVersionAndNotPointInTime($deleteBeforeDate) as $id) { | ||||
|             $this->messageBus->dispatch(new RemoveOldVersionMessage($id)); | ||||
|             $maxDeleted = max($maxDeleted, $id); | ||||
|         } | ||||
|  | ||||
|         return [self::LAST_DELETED_KEY => $maxDeleted]; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,19 @@ | ||||
| <?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\Service\StoredObjectCleaner; | ||||
|  | ||||
| final readonly class RemoveOldVersionMessage | ||||
| { | ||||
|     public function __construct( | ||||
|         public int $storedObjectVersionId, | ||||
|     ) {} | ||||
| } | ||||
| @@ -0,0 +1,78 @@ | ||||
| <?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\Service\StoredObjectCleaner; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Exception\StoredObjectManagerException; | ||||
| use Chill\DocStoreBundle\Repository\StoredObjectVersionRepository; | ||||
| use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Psr\Log\LoggerInterface; | ||||
| use Symfony\Component\Clock\ClockInterface; | ||||
| use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException; | ||||
| use Symfony\Component\Messenger\Handler\MessageHandlerInterface; | ||||
|  | ||||
| /** | ||||
|  * Class RemoveOldVersionMessageHandler. | ||||
|  * | ||||
|  * This class is responsible for handling the RemoveOldVersionMessage. It implements the MessageHandlerInterface. | ||||
|  * It removes old versions of stored objects based on certain conditions. | ||||
|  * | ||||
|  * If a StoredObject is a candidate for deletion (is expired and no more version stored), it is also removed from the | ||||
|  * database. | ||||
|  */ | ||||
| final readonly class RemoveOldVersionMessageHandler implements MessageHandlerInterface | ||||
| { | ||||
|     private const LOG_PREFIX = '[RemoveOldVersionMessageHandler] '; | ||||
|  | ||||
|     public function __construct( | ||||
|         private StoredObjectVersionRepository $storedObjectVersionRepository, | ||||
|         private LoggerInterface $logger, | ||||
|         private EntityManagerInterface $entityManager, | ||||
|         private StoredObjectManagerInterface $storedObjectManager, | ||||
|         private ClockInterface $clock, | ||||
|     ) {} | ||||
|  | ||||
|     /** | ||||
|      * @throws StoredObjectManagerException | ||||
|      */ | ||||
|     public function __invoke(RemoveOldVersionMessage $message): void | ||||
|     { | ||||
|         $this->logger->info(self::LOG_PREFIX.'Received one message', ['storedObjectVersionId' => $message->storedObjectVersionId]); | ||||
|  | ||||
|         $storedObjectVersion = $this->storedObjectVersionRepository->find($message->storedObjectVersionId); | ||||
|  | ||||
|         if (null === $storedObjectVersion) { | ||||
|             $this->logger->error(self::LOG_PREFIX.'StoredObjectVersion not found in database', ['storedObjectVersionId' => $message->storedObjectVersionId]); | ||||
|             throw new \RuntimeException('StoredObjectVersion not found with id '.$message->storedObjectVersionId); | ||||
|         } | ||||
|  | ||||
|         if ($storedObjectVersion->hasPointInTimes()) { | ||||
|             throw new UnrecoverableMessageHandlingException('the stored object version is now associated with a point in time'); | ||||
|         } | ||||
|  | ||||
|         $storedObject = $storedObjectVersion->getStoredObject(); | ||||
|  | ||||
|         $this->storedObjectManager->delete($storedObjectVersion); | ||||
|         // to ensure an immediate deletion | ||||
|         $this->entityManager->remove($storedObjectVersion); | ||||
|  | ||||
|         if (StoredObject::canBeDeleted($this->clock->now(), $storedObject)) { | ||||
|             $this->entityManager->remove($storedObject); | ||||
|         } | ||||
|  | ||||
|         $this->entityManager->flush(); | ||||
|  | ||||
|         // clear the entity manager for future usage | ||||
|         $this->entityManager->clear(); | ||||
|     } | ||||
| } | ||||
| @@ -14,6 +14,7 @@ namespace Chill\DocStoreBundle\Service; | ||||
| use Base64Url\Base64Url; | ||||
| use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface; | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Entity\StoredObjectVersion; | ||||
| use Chill\DocStoreBundle\Exception\StoredObjectManagerException; | ||||
| use Symfony\Component\HttpFoundation\Request; | ||||
| use Symfony\Component\HttpFoundation\Response; | ||||
| @@ -32,10 +33,20 @@ final class StoredObjectManager implements StoredObjectManagerInterface | ||||
|         private readonly TempUrlGeneratorInterface $tempUrlGenerator, | ||||
|     ) {} | ||||
|  | ||||
|     public function getLastModified(StoredObject $document): \DateTimeInterface | ||||
|     public function getLastModified(StoredObject|StoredObjectVersion $document): \DateTimeInterface | ||||
|     { | ||||
|         if ($this->hasCache($document)) { | ||||
|             $response = $this->getResponseFromCache($document); | ||||
|         $version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document; | ||||
|  | ||||
|         if (null !== $createdAt = $version->getCreatedAt()) { | ||||
|             // as a createdAt datetime is set, return the date and time from database | ||||
|             return $createdAt; | ||||
|         } | ||||
|  | ||||
|         // if no createdAt version exists in the database, we fetch the date and time from the | ||||
|         // file. This situation happens for files created before July 2024. | ||||
|  | ||||
|         if ($this->hasCache($version)) { | ||||
|             $response = $this->getResponseFromCache($version); | ||||
|         } else { | ||||
|             try { | ||||
|                 $response = $this | ||||
| @@ -46,7 +57,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface | ||||
|                             ->tempUrlGenerator | ||||
|                             ->generate( | ||||
|                                 Request::METHOD_HEAD, | ||||
|                                 $document->getFilename() | ||||
|                                 $version->getFilename() | ||||
|                             ) | ||||
|                             ->url | ||||
|                     ); | ||||
| @@ -58,11 +69,13 @@ final class StoredObjectManager implements StoredObjectManagerInterface | ||||
|         return $this->extractLastModifiedFromResponse($response); | ||||
|     } | ||||
|  | ||||
|     public function getContentLength(StoredObject $document): int | ||||
|     public function getContentLength(StoredObject|StoredObjectVersion $document): int | ||||
|     { | ||||
|         if ([] === $document->getKeyInfos()) { | ||||
|             if ($this->hasCache($document)) { | ||||
|                 $response = $this->getResponseFromCache($document); | ||||
|         $version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document; | ||||
|  | ||||
|         if (!$this->isVersionEncrypted($version)) { | ||||
|             if ($this->hasCache($version)) { | ||||
|                 $response = $this->getResponseFromCache($version); | ||||
|             } else { | ||||
|                 try { | ||||
|                     $response = $this | ||||
| @@ -73,7 +86,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface | ||||
|                                 ->tempUrlGenerator | ||||
|                                 ->generate( | ||||
|                                     Request::METHOD_HEAD, | ||||
|                                     $document->getFilename() | ||||
|                                     $version->getFilename() | ||||
|                                 ) | ||||
|                                 ->url | ||||
|                         ); | ||||
| @@ -88,10 +101,43 @@ final class StoredObjectManager implements StoredObjectManagerInterface | ||||
|         return strlen($this->read($document)); | ||||
|     } | ||||
|  | ||||
|     public function etag(StoredObject $document): string | ||||
|     /** | ||||
|      * @throws TransportExceptionInterface | ||||
|      * @throws StoredObjectManagerException | ||||
|      */ | ||||
|     public function exists(StoredObject|StoredObjectVersion $document): bool | ||||
|     { | ||||
|         if ($this->hasCache($document)) { | ||||
|             $response = $this->getResponseFromCache($document); | ||||
|         $version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document; | ||||
|  | ||||
|         if ($this->hasCache($version)) { | ||||
|             return true; | ||||
|         } | ||||
|         try { | ||||
|             $response = $this | ||||
|                 ->client | ||||
|                 ->request( | ||||
|                     Request::METHOD_HEAD, | ||||
|                     $this | ||||
|                         ->tempUrlGenerator | ||||
|                         ->generate( | ||||
|                             Request::METHOD_HEAD, | ||||
|                             $version->getFilename() | ||||
|                         ) | ||||
|                         ->url | ||||
|                 ); | ||||
|  | ||||
|             return 200 === $response->getStatusCode(); | ||||
|         } catch (TransportExceptionInterface $exception) { | ||||
|             throw StoredObjectManagerException::errorDuringHttpRequest($exception); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public function etag(StoredObject|StoredObjectVersion $document): string | ||||
|     { | ||||
|         $version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document; | ||||
|  | ||||
|         if ($this->hasCache($version)) { | ||||
|             $response = $this->getResponseFromCache($version); | ||||
|         } else { | ||||
|             try { | ||||
|                 $response = $this | ||||
| @@ -102,7 +148,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface | ||||
|                             ->tempUrlGenerator | ||||
|                             ->generate( | ||||
|                                 Request::METHOD_HEAD, | ||||
|                                 $document->getFilename() | ||||
|                                 $version->getFilename() | ||||
|                             ) | ||||
|                             ->url | ||||
|                     ); | ||||
| @@ -111,12 +157,14 @@ final class StoredObjectManager implements StoredObjectManagerInterface | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return $this->extractEtagFromResponse($response, $document); | ||||
|         return $this->extractEtagFromResponse($response); | ||||
|     } | ||||
|  | ||||
|     public function read(StoredObject $document): string | ||||
|     public function read(StoredObject|StoredObjectVersion $document, ?int $version = null): string | ||||
|     { | ||||
|         $response = $this->getResponseFromCache($document); | ||||
|         $version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document; | ||||
|  | ||||
|         $response = $this->getResponseFromCache($version); | ||||
|  | ||||
|         try { | ||||
|             $data = $response->getContent(); | ||||
| @@ -124,7 +172,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface | ||||
|             throw StoredObjectManagerException::unableToGetResponseContent($e); | ||||
|         } | ||||
|  | ||||
|         if (false === $this->hasKeysAndIv($document)) { | ||||
|         if (!$this->isVersionEncrypted($version)) { | ||||
|             return $data; | ||||
|         } | ||||
|  | ||||
| @@ -132,9 +180,9 @@ final class StoredObjectManager implements StoredObjectManagerInterface | ||||
|             $data, | ||||
|             self::ALGORITHM, | ||||
|             // TODO: Why using this library and not use base64_decode() ? | ||||
|             Base64Url::decode($document->getKeyInfos()['k']), | ||||
|             Base64Url::decode($version->getKeyInfos()['k']), | ||||
|             \OPENSSL_RAW_DATA, | ||||
|             pack('C*', ...$document->getIv()) | ||||
|             pack('C*', ...$version->getIv()) | ||||
|         ); | ||||
|  | ||||
|         if (false === $clearData) { | ||||
| @@ -144,20 +192,25 @@ final class StoredObjectManager implements StoredObjectManagerInterface | ||||
|         return $clearData; | ||||
|     } | ||||
|  | ||||
|     public function write(StoredObject $document, string $clearContent): void | ||||
|     public function write(StoredObject $document, string $clearContent, ?string $contentType = null): StoredObjectVersion | ||||
|     { | ||||
|         if ($this->hasCache($document)) { | ||||
|             unset($this->inMemory[$document->getUuid()->toString()]); | ||||
|         } | ||||
|         $newIv = $document->getIv(); | ||||
|         $newKey = $document->getKeyInfos(); | ||||
|         $newType = $contentType ?? $document->getType(); | ||||
|         $version = $document->registerVersion( | ||||
|             $newIv, | ||||
|             $newKey, | ||||
|             $newType | ||||
|         ); | ||||
|  | ||||
|         $encryptedContent = $this->hasKeysAndIv($document) | ||||
|         $encryptedContent = $this->isVersionEncrypted($version) | ||||
|             ? openssl_encrypt( | ||||
|                 $clearContent, | ||||
|                 self::ALGORITHM, | ||||
|                 // TODO: Why using this library and not use base64_decode() ? | ||||
|                 Base64Url::decode($document->getKeyInfos()['k']), | ||||
|                 Base64Url::decode($version->getKeyInfos()['k']), | ||||
|                 \OPENSSL_RAW_DATA, | ||||
|                 pack('C*', ...$document->getIv()) | ||||
|                 pack('C*', ...$version->getIv()) | ||||
|             ) | ||||
|             : $clearContent; | ||||
|  | ||||
| @@ -176,7 +229,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface | ||||
|                         ->tempUrlGenerator | ||||
|                         ->generate( | ||||
|                             Request::METHOD_PUT, | ||||
|                             $document->getFilename() | ||||
|                             $version->getFilename() | ||||
|                         ) | ||||
|                         ->url, | ||||
|                     [ | ||||
| @@ -191,6 +244,29 @@ final class StoredObjectManager implements StoredObjectManagerInterface | ||||
|         if (Response::HTTP_CREATED !== $response->getStatusCode()) { | ||||
|             throw StoredObjectManagerException::invalidStatusCode($response->getStatusCode()); | ||||
|         } | ||||
|  | ||||
|         $this->clearCache(); | ||||
|  | ||||
|         return $version; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @throws StoredObjectManagerException | ||||
|      */ | ||||
|     public function delete(StoredObjectVersion $storedObjectVersion): void | ||||
|     { | ||||
|         $signedUrl = $this->tempUrlGenerator->generate('DELETE', $storedObjectVersion->getFilename()); | ||||
|  | ||||
|         try { | ||||
|             $response = $this->client->request('DELETE', $signedUrl->url); | ||||
|             if (! (Response::HTTP_NO_CONTENT === $response->getStatusCode() || Response::HTTP_NOT_FOUND === $response->getStatusCode())) { | ||||
|                 throw StoredObjectManagerException::invalidStatusCode($response->getStatusCode()); | ||||
|             } | ||||
|  | ||||
|             $storedObjectVersion->getStoredObject()->removeVersion($storedObjectVersion); | ||||
|         } catch (TransportExceptionInterface $exception) { | ||||
|             throw StoredObjectManagerException::errorDuringHttpRequest($exception); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public function clearCache(): void | ||||
| @@ -215,12 +291,19 @@ final class StoredObjectManager implements StoredObjectManagerInterface | ||||
|         return $date; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Extracts the content length from a ResponseInterface object. | ||||
|      * | ||||
|      * Does work only if the object is not encrypted. | ||||
|      * | ||||
|      * @return int the extracted content length as an integer | ||||
|      */ | ||||
|     private function extractContentLengthFromResponse(ResponseInterface $response): int | ||||
|     { | ||||
|         return (int) ($response->getHeaders()['content-length'] ?? ['0'])[0]; | ||||
|     } | ||||
|  | ||||
|     private function extractEtagFromResponse(ResponseInterface $response, StoredObject $storedObject): ?string | ||||
|     private function extractEtagFromResponse(ResponseInterface $response): ?string | ||||
|     { | ||||
|         $etag = ($response->getHeaders()['etag'] ?? [''])[0]; | ||||
|  | ||||
| @@ -231,7 +314,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface | ||||
|         return $etag; | ||||
|     } | ||||
|  | ||||
|     private function fillCache(StoredObject $document): void | ||||
|     private function fillCache(StoredObjectVersion $document): void | ||||
|     { | ||||
|         try { | ||||
|             $response = $this | ||||
| @@ -254,25 +337,30 @@ final class StoredObjectManager implements StoredObjectManagerInterface | ||||
|             throw StoredObjectManagerException::invalidStatusCode($response->getStatusCode()); | ||||
|         } | ||||
|  | ||||
|         $this->inMemory[$document->getUuid()->toString()] = $response; | ||||
|         $this->inMemory[$this->buildCacheKey($document)] = $response; | ||||
|     } | ||||
|  | ||||
|     private function getResponseFromCache(StoredObject $document): ResponseInterface | ||||
|     private function buildCacheKey(StoredObjectVersion $storedObjectVersion): string | ||||
|     { | ||||
|         return $storedObjectVersion->getStoredObject()->getUuid()->toString().$storedObjectVersion->getId(); | ||||
|     } | ||||
|  | ||||
|     private function getResponseFromCache(StoredObjectVersion $document): ResponseInterface | ||||
|     { | ||||
|         if (!$this->hasCache($document)) { | ||||
|             $this->fillCache($document); | ||||
|         } | ||||
|  | ||||
|         return $this->inMemory[$document->getUuid()->toString()]; | ||||
|         return $this->inMemory[$this->buildCacheKey($document)]; | ||||
|     } | ||||
|  | ||||
|     private function hasCache(StoredObject $document): bool | ||||
|     private function hasCache(StoredObjectVersion $document): bool | ||||
|     { | ||||
|         return \array_key_exists($document->getUuid()->toString(), $this->inMemory); | ||||
|         return \array_key_exists($this->buildCacheKey($document), $this->inMemory); | ||||
|     } | ||||
|  | ||||
|     private function hasKeysAndIv(StoredObject $storedObject): bool | ||||
|     private function isVersionEncrypted(StoredObjectVersion $storedObjectVersion): bool | ||||
|     { | ||||
|         return ([] !== $storedObject->getKeyInfos()) && ([] !== $storedObject->getIv()); | ||||
|         return ([] !== $storedObjectVersion->getKeyInfos()) && ([] !== $storedObjectVersion->getIv()); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -12,36 +12,74 @@ declare(strict_types=1); | ||||
| namespace Chill\DocStoreBundle\Service; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Entity\StoredObjectVersion; | ||||
| use Chill\DocStoreBundle\Exception\StoredObjectManagerException; | ||||
| use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; | ||||
|  | ||||
| interface StoredObjectManagerInterface | ||||
| { | ||||
|     public function getLastModified(StoredObject $document): \DateTimeInterface; | ||||
|     /** | ||||
|      * @param StoredObject|StoredObjectVersion $document if a StoredObject is given, the last version will be used | ||||
|      */ | ||||
|     public function getLastModified(StoredObject|StoredObjectVersion $document): \DateTimeInterface; | ||||
|  | ||||
|     public function getContentLength(StoredObject $document): int; | ||||
|     /** | ||||
|      * @param StoredObject|StoredObjectVersion $document if a StoredObject is given, the last version will be used | ||||
|      */ | ||||
|     public function getContentLength(StoredObject|StoredObjectVersion $document): int; | ||||
|  | ||||
|     /** | ||||
|      * @throws TransportExceptionInterface | ||||
|      */ | ||||
|     public function exists(StoredObject|StoredObjectVersion $document): bool; | ||||
|  | ||||
|     /** | ||||
|      * Get the content of a StoredObject. | ||||
|      * | ||||
|      * @param StoredObject $document the document | ||||
|      * @param StoredObject|StoredObjectVersion $document if a StoredObject is given, the last version will be used | ||||
|      * | ||||
|      * @return string the retrieved content in clear | ||||
|      * | ||||
|      * @throws StoredObjectManagerException if unable to read or decrypt the content | ||||
|      */ | ||||
|     public function read(StoredObject $document): string; | ||||
|     public function read(StoredObject|StoredObjectVersion $document): string; | ||||
|  | ||||
|     /** | ||||
|      * Set the content of a StoredObject. | ||||
|      * Register the content of a new version for the StoredObject. | ||||
|      * | ||||
|      * The manager is also responsible for registering a version in the StoredObject, and return this version. | ||||
|      * | ||||
|      * @param StoredObject $document     the document | ||||
|      * @param              $clearContent The content to store in clear | ||||
|      * @param string       $clearContent The content to store in clear | ||||
|      * @param string|null  $contentType  The new content type. If set to null, the content-type is supposed not to change. If there is no content type, an empty string will be used. | ||||
|      * | ||||
|      * @return StoredObjectVersion the newly created @see{StoredObjectVersion} for the given @see{StoredObject} | ||||
|      * | ||||
|      * @throws StoredObjectManagerException | ||||
|      */ | ||||
|     public function write(StoredObject $document, string $clearContent): void; | ||||
|     public function write(StoredObject $document, string $clearContent, ?string $contentType = null): StoredObjectVersion; | ||||
|  | ||||
|     public function etag(StoredObject $document): string; | ||||
|     /** | ||||
|      * Remove a version from the storage. | ||||
|      * | ||||
|      * This method is also responsible for removing the version from the StoredObject (using @see{StoredObject::removeVersion}) | ||||
|      * in case of success. | ||||
|      * | ||||
|      * @throws StoredObjectManagerException | ||||
|      */ | ||||
|     public function delete(StoredObjectVersion $storedObjectVersion): void; | ||||
|  | ||||
|     /** | ||||
|      * return or compute the etag for the document. | ||||
|      * | ||||
|      * @param StoredObject|StoredObjectVersion $document if a StoredObject is given, the last version will be used | ||||
|      * | ||||
|      * @return string the etag of this document | ||||
|      */ | ||||
|     public function etag(StoredObject|StoredObjectVersion $document): string; | ||||
|  | ||||
|     /** | ||||
|      * Clears the cache for the stored object. | ||||
|      */ | ||||
|     public function clearCache(): void; | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,75 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Service; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Entity\StoredObjectPointInTime; | ||||
| use Chill\DocStoreBundle\Entity\StoredObjectPointInTimeReasonEnum; | ||||
| use Chill\DocStoreBundle\Entity\StoredObjectVersion; | ||||
| use Chill\DocStoreBundle\Exception\StoredObjectManagerException; | ||||
| use Chill\WopiBundle\Service\WopiConverter; | ||||
| use Symfony\Component\Mime\MimeTypesInterface; | ||||
|  | ||||
| /** | ||||
|  * Class StoredObjectToPdfConverter. | ||||
|  * | ||||
|  * Converts stored objects to PDF or other specified formats using WopiConverter. | ||||
|  */ | ||||
| class StoredObjectToPdfConverter | ||||
| { | ||||
|     public function __construct( | ||||
|         private readonly StoredObjectManagerInterface $storedObjectManager, | ||||
|         private readonly WopiConverter $wopiConverter, | ||||
|         private readonly MimeTypesInterface $mimeTypes, | ||||
|     ) {} | ||||
|  | ||||
|     /** | ||||
|      * Converts the given stored object to a specified format and stores the new version. | ||||
|      * | ||||
|      * @param StoredObject $storedObject the stored object to be converted | ||||
|      * @param string       $lang         the language for the conversion context | ||||
|      * @param string       $convertTo    The target format for the conversion. Default is 'pdf'. | ||||
|      * | ||||
|      * @return array{0: StoredObjectPointInTime, 1: StoredObjectVersion} contains the point in time before conversion and the new version of the stored object | ||||
|      * | ||||
|      * @throws \UnexpectedValueException    if the preferred mime type for the conversion is not found | ||||
|      * @throws \RuntimeException            if the conversion or storage of the new version fails | ||||
|      * @throws StoredObjectManagerException | ||||
|      */ | ||||
|     public function addConvertedVersion(StoredObject $storedObject, string $lang, $convertTo = 'pdf'): array | ||||
|     { | ||||
|         $newMimeType = $this->mimeTypes->getMimeTypes($convertTo)[0] ?? null; | ||||
|  | ||||
|         if (null === $newMimeType) { | ||||
|             throw new \UnexpectedValueException(sprintf('could not find a preferred mime type for conversion to %s', $convertTo)); | ||||
|         } | ||||
|  | ||||
|         $currentVersion = $storedObject->getCurrentVersion(); | ||||
|  | ||||
|         if ($currentVersion->getType() === $newMimeType) { | ||||
|             throw new \UnexpectedValueException('Already at the same mime type'); | ||||
|         } | ||||
|  | ||||
|         $content = $this->storedObjectManager->read($currentVersion); | ||||
|  | ||||
|         try { | ||||
|             $converted = $this->wopiConverter->convert($lang, $content, $newMimeType, $convertTo); | ||||
|         } catch (\RuntimeException $e) { | ||||
|             throw new \RuntimeException('could not store a new version for document', previous: $e); | ||||
|         } | ||||
|  | ||||
|         $pointInTime = new StoredObjectPointInTime($currentVersion, StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION); | ||||
|         $version = $this->storedObjectManager->write($storedObject, $converted, $newMimeType); | ||||
|  | ||||
|         return [$pointInTime, $version]; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,38 @@ | ||||
| <?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\Service; | ||||
|  | ||||
| use Chill\MainBundle\Workflow\EntityWorkflowManager; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
|  | ||||
| class WorkflowStoredObjectPermissionHelper | ||||
| { | ||||
|     public function __construct(private readonly Security $security, private readonly EntityWorkflowManager $entityWorkflowManager) {} | ||||
|  | ||||
|     public function notBlockedByWorkflow(object $entity): bool | ||||
|     { | ||||
|         $workflows = $this->entityWorkflowManager->findByRelatedEntity($entity); | ||||
|         $currentUser = $this->security->getUser(); | ||||
|  | ||||
|         foreach ($workflows as $workflow) { | ||||
|             if ($workflow->isFinal()) { | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             if (!$workflow->getCurrentStep()->getAllDestUser()->contains($currentUser)) { | ||||
|                 return false; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return true; | ||||
|     } | ||||
| } | ||||
| @@ -16,6 +16,7 @@ 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\Security\Core\Security; | ||||
| use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; | ||||
| use Symfony\Component\Serializer\Normalizer\NormalizerInterface; | ||||
| use Twig\Environment; | ||||
| @@ -128,6 +129,7 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt | ||||
|         private NormalizerInterface $normalizer, | ||||
|         private JWTDavTokenProviderInterface $davTokenProvider, | ||||
|         private UrlGeneratorInterface $urlGenerator, | ||||
|         private Security $security, | ||||
|     ) {} | ||||
|  | ||||
|     /** | ||||
| @@ -148,8 +150,10 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt | ||||
|      * @throws \Twig\Error\RuntimeError | ||||
|      * @throws \Twig\Error\SyntaxError | ||||
|      */ | ||||
|     public function renderButtonGroup(Environment $environment, StoredObject $document, ?string $title = null, bool $canEdit = true, array $options = []): string | ||||
|     public function renderButtonGroup(Environment $environment, StoredObject $document, ?string $title = null, bool $showEditButtons = true, array $options = []): string | ||||
|     { | ||||
|         $canEdit = $this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $document) && $showEditButtons; | ||||
|  | ||||
|         $accessToken = $this->davTokenProvider->createToken( | ||||
|             $document, | ||||
|             $canEdit ? StoredObjectRoleEnum::EDIT : StoredObjectRoleEnum::SEE | ||||
|   | ||||
| @@ -14,19 +14,37 @@ namespace AsyncUpload\Driver\OpenstackObjectStore; | ||||
| use Chill\DocStoreBundle\AsyncUpload\Driver\OpenstackObjectStore\TempUrlOpenstackGenerator; | ||||
| use Chill\DocStoreBundle\AsyncUpload\SignedUrl; | ||||
| use Chill\DocStoreBundle\AsyncUpload\SignedUrlPost; | ||||
| use PHPUnit\Framework\TestCase; | ||||
| use Psr\Log\NullLogger; | ||||
| use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; | ||||
| use Symfony\Component\Clock\MockClock; | ||||
| use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; | ||||
| use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; | ||||
| use Symfony\Component\EventDispatcher\EventDispatcher; | ||||
| use Symfony\Component\Mime\Part\DataPart; | ||||
| use Symfony\Component\Mime\Part\Multipart\FormDataPart; | ||||
| use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface; | ||||
| use Symfony\Contracts\HttpClient\HttpClientInterface; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
|  * | ||||
|  * @coversNothing | ||||
|  */ | ||||
| class TempUrlOpenstackGeneratorTest extends TestCase | ||||
| class TempUrlOpenstackGeneratorTest extends KernelTestCase | ||||
| { | ||||
|     private ParameterBagInterface $parameterBag; | ||||
|     private HttpClientInterface $client; | ||||
|  | ||||
|     private const TESTING_OBJECT_NAME_PREFIX = 'test-prefix-o0o008wk404gcos40k8s4s4c44cgwwos4k4o8k/'; | ||||
|     private const TESTING_OBJECT_NAME = 'object-name-4fI0iAtq'; | ||||
|  | ||||
|     private function setUpIntegration(): void | ||||
|     { | ||||
|         self::bootKernel(); | ||||
|         $this->parameterBag = self::getContainer()->get(ParameterBagInterface::class); | ||||
|         $this->client = self::getContainer()->get(HttpClientInterface::class); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @dataProvider dataProviderGenerate | ||||
|      */ | ||||
| @@ -122,7 +140,8 @@ class TempUrlOpenstackGeneratorTest extends TestCase | ||||
|         $signedUrl = new SignedUrl( | ||||
|             'GET', | ||||
|             'https://objectstore.example/v1/my_account/container/object?temp_url_sig=0aeef353a5f6e22d125c76c6ad8c644a59b222ba1b13eaeb56bf3d04e28b081d11dfcb36601ab3aa7b623d79e1ef03017071bbc842fb7b34afec2baff895bf80&temp_url_expires=1702043543', | ||||
|             \DateTimeImmutable::createFromFormat('U', '1702043543') | ||||
|             \DateTimeImmutable::createFromFormat('U', '1702043543'), | ||||
|             $objectName | ||||
|         ); | ||||
|  | ||||
|         foreach ($baseUrls as $baseUrl) { | ||||
| @@ -153,6 +172,7 @@ class TempUrlOpenstackGeneratorTest extends TestCase | ||||
|         $signedUrl = new SignedUrlPost( | ||||
|             'https://objectstore.example/v1/my_account/container/object?temp_url_sig=0aeef353a5f6e22d125c76c6ad8c644a59b222ba1b13eaeb56bf3d04e28b081d11dfcb36601ab3aa7b623d79e1ef03017071bbc842fb7b34afec2baff895bf80&temp_url_expires=1702043543', | ||||
|             \DateTimeImmutable::createFromFormat('U', '1702043543'), | ||||
|             $objectName, | ||||
|             150, | ||||
|             1, | ||||
|             1800, | ||||
| @@ -173,4 +193,62 @@ class TempUrlOpenstackGeneratorTest extends TestCase | ||||
|             ]; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @group openstack-integration | ||||
|      */ | ||||
|     public function testGeneratePostIntegration(): void | ||||
|     { | ||||
|         $this->setUpIntegration(); | ||||
|         $generator = new TempUrlOpenstackGenerator(new NullLogger(), new EventDispatcher(), new MockClock(), $this->parameterBag); | ||||
|  | ||||
|         $signedUrl = $generator->generatePost(object_name: self::TESTING_OBJECT_NAME_PREFIX); | ||||
|         $formData = new FormDataPart([ | ||||
|             'redirect', $signedUrl->redirect, | ||||
|             'max_file_size' => (string) $signedUrl->max_file_size, | ||||
|             'max_file_count' => (string) $signedUrl->max_file_count, | ||||
|             'expires' => (string) $signedUrl->expires->getTimestamp(), | ||||
|             'signature' => $signedUrl->signature, | ||||
|             self::TESTING_OBJECT_NAME => DataPart::fromPath( | ||||
|                 __DIR__.'/file-to-upload.txt', | ||||
|                 self::TESTING_OBJECT_NAME | ||||
|             ), | ||||
|         ]); | ||||
|  | ||||
|         $response = $this->client | ||||
|             ->request( | ||||
|                 'POST', | ||||
|                 $signedUrl->url, | ||||
|                 [ | ||||
|                     'body' => $formData->bodyToString(), | ||||
|                     'headers' => $formData->getPreparedHeaders()->toArray(), | ||||
|                 ] | ||||
|             ); | ||||
|  | ||||
|         self::assertEquals(201, $response->getStatusCode()); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @group openstack-integration | ||||
|      * | ||||
|      * @depends testGeneratePostIntegration | ||||
|      */ | ||||
|     public function testGenerateGetIntegration(): void | ||||
|     { | ||||
|         $this->setUpIntegration(); | ||||
|         $generator = new TempUrlOpenstackGenerator(new NullLogger(), new EventDispatcher(), new MockClock(), $this->parameterBag); | ||||
|  | ||||
|         $signedUrl = $generator->generate('GET', self::TESTING_OBJECT_NAME_PREFIX.self::TESTING_OBJECT_NAME); | ||||
|  | ||||
|         $response = $this->client->request('GET', $signedUrl->url); | ||||
|  | ||||
|         self::assertEquals(200, $response->getStatusCode()); | ||||
|  | ||||
|         try { | ||||
|             $content = $response->getContent(); | ||||
|             self::assertEquals(file_get_contents(__DIR__.'/file-to-upload.txt'), $content); | ||||
|         } catch (HttpExceptionInterface $exception) { | ||||
|             $this->fail('could not retrieve file content: '.$exception->getMessage()); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user