mirror of
				https://gitlab.com/Chill-Projet/chill-bundles.git
				synced 2025-11-04 11:18:25 +00:00 
			
		
		
		
	Compare commits
	
		
			297 Commits
		
	
	
		
			v3.2.1
			...
			signature-
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						
						
							
						
						611a968162
	
				 | 
					
					
						|||
| 
						
						
							
						
						ce80207d98
	
				 | 
					
					
						|||
| 
						
						
							
						
						5fc5369db6
	
				 | 
					
					
						|||
| 
						
						
							
						
						20e8b03588
	
				 | 
					
					
						|||
| 
						
						
							
						
						4b65ec9b54
	
				 | 
					
					
						|||
| 
						
						
							
						
						a8c5d1f660
	
				 | 
					
					
						|||
| 
						
						
							
						
						5f67a7aadc
	
				 | 
					
					
						|||
| 
						
						
							
						
						77d06d756a
	
				 | 
					
					
						|||
| c4c5c860f0 | |||
| 
						
						
							
						
						47f575de92
	
				 | 
					
					
						|||
| 
						
						
							
						
						5906171041
	
				 | 
					
					
						|||
| 
						
						
							
						
						b0e2e65885
	
				 | 
					
					
						|||
| 
						
						
							
						
						dd3f6fb0ab
	
				 | 
					
					
						|||
| 
						
						
							
						
						5fa5a2349e
	
				 | 
					
					
						|||
| 
						
						
							
						
						48f727dcfd
	
				 | 
					
					
						|||
| 
						
						
							
						
						6a0e26ec31
	
				 | 
					
					
						|||
| 
						
						
							
						
						943a42cd38
	
				 | 
					
					
						|||
| 
						
						
							
						
						d9b36533a2
	
				 | 
					
					
						|||
| 3697aee584 | |||
| 
						
						
							
						
						33cc308e1e
	
				 | 
					
					
						|||
| 
						
						
							
						
						4d8de46ac9
	
				 | 
					
					
						|||
| 
						
						
							
						
						4696332a46
	
				 | 
					
					
						|||
| 
						
						
							
						
						0d54637d35
	
				 | 
					
					
						|||
| 
						
						
							
						
						7a7d1d5b16
	
				 | 
					
					
						|||
| 
						
						
							
						
						e5737b0c49
	
				 | 
					
					
						|||
| 
						
						
							
						
						45323e9136
	
				 | 
					
					
						|||
| 
						
						
							
						
						9f1afb8423
	
				 | 
					
					
						|||
| 1494c7ecd7 | |||
| 
						
						
							
						
						911dfc2878
	
				 | 
					
					
						|||
| 
						
						
							
						
						8e984f2006
	
				 | 
					
					
						|||
| 
						
						
							
						
						f0e8df38af
	
				 | 
					
					
						|||
| 
						 | 
					59c34dabd7 | ||
| 
						 | 
					119668e415 | ||
| 
						 | 
					2b516629f6 | ||
| 
						 | 
					092b5c4f90 | ||
| 
						 | 
					ae1459cf77 | ||
| 
						 | 
					57d2929ecd | ||
| 
						 | 
					3c987e0b8d | ||
| 
						
						
							
						
						f8a986d59b
	
				 | 
					
					
						|||
| 
						
						
							
						
						09563979a2
	
				 | 
					
					
						|||
| 
						 | 
					0ee91800ab | ||
| 
						 | 
					
						
						
							
						
						d08212df46
	
				 | 
					
					
						||
| 
						 | 
					
						
						
							
						
						4933238f3f
	
				 | 
					
					
						||
| 
						 | 
					
						
						
							
						
						c23568032c
	
				 | 
					
					
						||
| 
						
						
							
						
						18af2ca70b
	
				 | 
					
					
						|||
| 
						
						
							
						
						f1505a9d15
	
				 | 
					
					
						|||
| 
						
						
							
						
						4e588ed0e0
	
				 | 
					
					
						|||
| 
						
						
							
						
						70671dadac
	
				 | 
					
					
						|||
| f0d581b7f8 | |||
| 
						 | 
					
						
						
							
						
						1197a46f5f
	
				 | 
					
					
						||
| 00e878892e | |||
| 
						
						
							
						
						941444b7d5
	
				 | 
					
					
						|||
| 
						
						
							
						
						a60ea0e066
	
				 | 
					
					
						|||
| 
						
						
							
						
						1ddd283f26
	
				 | 
					
					
						|||
| 
						
						
							
						
						669b967899
	
				 | 
					
					
						|||
| 
						
						
							
						
						d33da6519a
	
				 | 
					
					
						|||
| 
						
						
							
						
						f5ba5d574b
	
				 | 
					
					
						|||
| ccc11b1c1d | |||
| 
						
						
							
						
						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
	
				 | 
					
					
						|||
| 
						 | 
					479a02bbc7 | ||
| 
						 | 
					0d62d8d1c6 | ||
| 
						 | 
					5b90632231 | ||
| 
						
						
							
						
						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"
 | 
			
		||||
							
								
								
									
										2
									
								
								.env
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								.env
									
									
									
									
									
								
							@@ -23,7 +23,7 @@ TRUSTED_HOSTS='^(localhost|example\.com|nginx)$'
 | 
			
		||||
###< symfony/framework-bundle ###
 | 
			
		||||
 | 
			
		||||
## Wopi server for editing documents online
 | 
			
		||||
WOPI_SERVER=http://collabora:9980
 | 
			
		||||
EDITOR_SERVER=http://collabora:9980
 | 
			
		||||
 | 
			
		||||
# must be manually set in .env.local
 | 
			
		||||
# ADMIN_PASSWORD=
 | 
			
		||||
 
 | 
			
		||||
@@ -41,3 +41,5 @@ DATABASE_URL="postgresql://postgres:postgres@db:5432/test?serverVersion=14&chars
 | 
			
		||||
ASYNC_UPLOAD_TEMP_URL_KEY=
 | 
			
		||||
ASYNC_UPLOAD_TEMP_URL_BASE_PATH=
 | 
			
		||||
ASYNC_UPLOAD_TEMP_URL_CONTAINER=
 | 
			
		||||
 | 
			
		||||
EDITOR_SERVER=https://localhost:9980
 | 
			
		||||
 
 | 
			
		||||
@@ -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:
 | 
			
		||||
 
 | 
			
		||||
@@ -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",
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										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,9 +53,9 @@
 | 
			
		||||
    "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": "^3.5.6",
 | 
			
		||||
    "vue-i18n": "^9.1.6",
 | 
			
		||||
    "vue-multiselect": "3.0.0-alpha.2",
 | 
			
		||||
    "vue-toast-notification": "^3.1.2",
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -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']]),
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,55 @@
 | 
			
		||||
<?php
 | 
			
		||||
 | 
			
		||||
declare(strict_types=1);
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
 * Chill is a software for social workers
 | 
			
		||||
 *
 | 
			
		||||
 * For the full copyright and license information, please view
 | 
			
		||||
 * the LICENSE file that was distributed with this source code.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
namespace Chill\DocStoreBundle\Controller;
 | 
			
		||||
 | 
			
		||||
use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument;
 | 
			
		||||
use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter;
 | 
			
		||||
use Chill\DocStoreBundle\Workflow\AccompanyingCourseDocumentDuplicator;
 | 
			
		||||
use Doctrine\ORM\EntityManagerInterface;
 | 
			
		||||
use Symfony\Component\HttpFoundation\RedirectResponse;
 | 
			
		||||
use Symfony\Component\HttpFoundation\Request;
 | 
			
		||||
use Symfony\Component\HttpFoundation\Response;
 | 
			
		||||
use Symfony\Component\HttpFoundation\Session\Session;
 | 
			
		||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
 | 
			
		||||
use Symfony\Component\Routing\Annotation\Route;
 | 
			
		||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
 | 
			
		||||
use Symfony\Component\Security\Core\Security;
 | 
			
		||||
 | 
			
		||||
final readonly class DocumentAccompanyingCourseDuplicateController
 | 
			
		||||
{
 | 
			
		||||
    public function __construct(
 | 
			
		||||
        private Security $security,
 | 
			
		||||
        private AccompanyingCourseDocumentDuplicator $documentWorkflowDuplicator,
 | 
			
		||||
        private EntityManagerInterface $entityManager,
 | 
			
		||||
        private UrlGeneratorInterface $urlGenerator,
 | 
			
		||||
    ) {}
 | 
			
		||||
 | 
			
		||||
    #[Route('/{_locale}/doc-store/accompanying-course-document/{id}/duplicate', name: 'chill_doc_store_accompanying_course_document_duplicate')]
 | 
			
		||||
    public function __invoke(AccompanyingCourseDocument $document, Request $request, Session $session): Response
 | 
			
		||||
    {
 | 
			
		||||
        if (!$this->security->isGranted(AccompanyingCourseDocumentVoter::SEE, $document)) {
 | 
			
		||||
            throw new AccessDeniedHttpException('not allowed to see this document');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!$this->security->isGranted(AccompanyingCourseDocumentVoter::CREATE, $document->getCourse())) {
 | 
			
		||||
            throw new AccessDeniedHttpException('not allowed to create this document');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $duplicated = $this->documentWorkflowDuplicator->duplicate($document);
 | 
			
		||||
        $this->entityManager->persist($duplicated);
 | 
			
		||||
        $this->entityManager->flush();
 | 
			
		||||
 | 
			
		||||
        return new RedirectResponse(
 | 
			
		||||
            $this->urlGenerator->generate('accompanying_course_document_edit', ['id' => $duplicated->getId(), 'course' => $duplicated->getCourse()->getId()])
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -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,
 | 
			
		||||
    ) {}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,102 @@
 | 
			
		||||
<?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\EntityWorkflowSignatureStateEnum;
 | 
			
		||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature;
 | 
			
		||||
use Chill\MainBundle\Templating\Entity\ChillEntityRenderManagerInterface;
 | 
			
		||||
use Chill\MainBundle\Security\Authorization\EntityWorkflowStepSignatureVoter;
 | 
			
		||||
use Chill\MainBundle\Workflow\EntityWorkflowManager;
 | 
			
		||||
use Symfony\Component\HttpFoundation\JsonResponse;
 | 
			
		||||
use Symfony\Component\HttpFoundation\Request;
 | 
			
		||||
use Symfony\Component\HttpFoundation\Response;
 | 
			
		||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
 | 
			
		||||
use Symfony\Component\Messenger\MessageBusInterface;
 | 
			
		||||
use Symfony\Component\Routing\Annotation\Route;
 | 
			
		||||
use Symfony\Component\Security\Core\Security;
 | 
			
		||||
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
 | 
			
		||||
 | 
			
		||||
class SignatureRequestController
 | 
			
		||||
{
 | 
			
		||||
    public function __construct(
 | 
			
		||||
        private readonly MessageBusInterface $messageBus,
 | 
			
		||||
        private readonly StoredObjectManagerInterface $storedObjectManager,
 | 
			
		||||
        private readonly EntityWorkflowManager $entityWorkflowManager,
 | 
			
		||||
        private readonly ChillEntityRenderManagerInterface $entityRender,
 | 
			
		||||
        private readonly NormalizerInterface $normalizer,
 | 
			
		||||
        private readonly Security $security,
 | 
			
		||||
    ) {}
 | 
			
		||||
 | 
			
		||||
    #[Route('/api/1.0/document/workflow/{id}/signature-request', name: 'chill_docstore_signature_request')]
 | 
			
		||||
    public function processSignature(EntityWorkflowStepSignature $signature, Request $request): JsonResponse
 | 
			
		||||
    {
 | 
			
		||||
        if (!$this->security->isGranted(EntityWorkflowStepSignatureVoter::SIGN, $signature)) {
 | 
			
		||||
            throw new AccessDeniedHttpException('not authorized to sign this step');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $entityWorkflow = $signature->getStep()->getEntityWorkflow();
 | 
			
		||||
 | 
			
		||||
        if (EntityWorkflowSignatureStateEnum::PENDING !== $signature->getState()) {
 | 
			
		||||
            return new JsonResponse([], status: Response::HTTP_CONFLICT);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $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'],
 | 
			
		||||
            'Signed by IP: '.(string) $request->getClientIp().', authenticated user: '.$this->entityRender->renderString($this->security->getUser(), []),
 | 
			
		||||
            $this->entityRender->renderString($signature->getSigner(), [
 | 
			
		||||
                // options for user render
 | 
			
		||||
                'absence' => false,
 | 
			
		||||
                'main_scope' => false,
 | 
			
		||||
                // options for person render
 | 
			
		||||
                'addAge' => false,
 | 
			
		||||
            ]),
 | 
			
		||||
            $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
 | 
			
		||||
    {
 | 
			
		||||
        $entityWorkflow = $signature->getStep()->getEntityWorkflow();
 | 
			
		||||
        $storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($entityWorkflow);
 | 
			
		||||
 | 
			
		||||
        return new JsonResponse(
 | 
			
		||||
            [
 | 
			
		||||
                'state' => $signature->getState(),
 | 
			
		||||
                'storedObject' => $this->normalizer->normalize($storedObject, 'json'),
 | 
			
		||||
            ],
 | 
			
		||||
            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
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,47 @@
 | 
			
		||||
<?php
 | 
			
		||||
 | 
			
		||||
declare(strict_types=1);
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
 * Chill is a software for social workers
 | 
			
		||||
 *
 | 
			
		||||
 * For the full copyright and license information, please view
 | 
			
		||||
 * the LICENSE file that was distributed with this source code.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
namespace Chill\DocStoreBundle\Controller;
 | 
			
		||||
 | 
			
		||||
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
 | 
			
		||||
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
 | 
			
		||||
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectVersionNormalizer;
 | 
			
		||||
use Chill\DocStoreBundle\Service\StoredObjectRestoreInterface;
 | 
			
		||||
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;
 | 
			
		||||
 | 
			
		||||
final readonly class StoredObjectRestoreVersionApiController
 | 
			
		||||
{
 | 
			
		||||
    public function __construct(private Security $security, private StoredObjectRestoreInterface $storedObjectRestore, private EntityManagerInterface $entityManager, private SerializerInterface $serializer) {}
 | 
			
		||||
 | 
			
		||||
    #[Route('/api/1.0/doc-store/stored-object/restore-from-version/{id}', methods: ['POST'])]
 | 
			
		||||
    public function restoreStoredObjectVersion(StoredObjectVersion $storedObjectVersion): JsonResponse
 | 
			
		||||
    {
 | 
			
		||||
        if (!$this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $storedObjectVersion->getStoredObject())) {
 | 
			
		||||
            throw new AccessDeniedHttpException('not allowed to edit the stored object');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $newVersion = $this->storedObjectRestore->restore($storedObjectVersion);
 | 
			
		||||
 | 
			
		||||
        $this->entityManager->persist($newVersion);
 | 
			
		||||
        $this->entityManager->flush();
 | 
			
		||||
 | 
			
		||||
        return new JsonResponse(
 | 
			
		||||
            $this->serializer->serialize($newVersion, 'json', [AbstractNormalizer::GROUPS => ['read', StoredObjectVersionNormalizer::WITH_POINT_IN_TIMES_CONTEXT, StoredObjectVersionNormalizer::WITH_RESTORED_CONTEXT]]),
 | 
			
		||||
            json: true
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -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\Controller;
 | 
			
		||||
 | 
			
		||||
use Chill\DocStoreBundle\Entity\StoredObject;
 | 
			
		||||
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
 | 
			
		||||
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectVersionNormalizer;
 | 
			
		||||
use Chill\MainBundle\Pagination\PaginatorFactoryInterface;
 | 
			
		||||
use Chill\MainBundle\Serializer\Model\Collection;
 | 
			
		||||
use Doctrine\Common\Collections\Criteria;
 | 
			
		||||
use Doctrine\Common\Collections\Order;
 | 
			
		||||
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;
 | 
			
		||||
 | 
			
		||||
final readonly class StoredObjectVersionApiController
 | 
			
		||||
{
 | 
			
		||||
    public function __construct(
 | 
			
		||||
        private PaginatorFactoryInterface $paginatorFactory,
 | 
			
		||||
        private SerializerInterface $serializer,
 | 
			
		||||
        private Security $security,
 | 
			
		||||
    ) {}
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Lists the versions of the specified stored object.
 | 
			
		||||
     *
 | 
			
		||||
     * @param StoredObject $storedObject the stored object whose versions are to be listed
 | 
			
		||||
     *
 | 
			
		||||
     * @return JsonResponse a JSON response containing the serialized versions of the stored object, encapsulated in a collection
 | 
			
		||||
     *
 | 
			
		||||
     * @throws AccessDeniedHttpException if the user is not allowed to see the stored object
 | 
			
		||||
     */
 | 
			
		||||
    #[Route('/api/1.0/doc-store/stored-object/{uuid}/versions', name: 'chill_doc_store_stored_object_versions_list')]
 | 
			
		||||
    public function listVersions(StoredObject $storedObject): JsonResponse
 | 
			
		||||
    {
 | 
			
		||||
        if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
 | 
			
		||||
            throw new AccessDeniedHttpException('not allowed to see this stored object');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $total = $storedObject->getVersions()->count();
 | 
			
		||||
        $paginator = $this->paginatorFactory->create($total);
 | 
			
		||||
 | 
			
		||||
        $criteria = Criteria::create();
 | 
			
		||||
        $criteria->orderBy(['id' => Order::Ascending]);
 | 
			
		||||
        $criteria->setMaxResults($paginator->getItemsPerPage())->setFirstResult($paginator->getCurrentPageFirstItemNumber());
 | 
			
		||||
        $items = $storedObject->getVersions()->matching($criteria);
 | 
			
		||||
 | 
			
		||||
        return new JsonResponse(
 | 
			
		||||
            $this->serializer->serialize(
 | 
			
		||||
                new Collection($items, $paginator),
 | 
			
		||||
                'json',
 | 
			
		||||
                [AbstractNormalizer::GROUPS => ['read', StoredObjectVersionNormalizer::WITH_POINT_IN_TIMES_CONTEXT, StoredObjectVersionNormalizer::WITH_RESTORED_CONTEXT]]
 | 
			
		||||
            ),
 | 
			
		||||
            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,15 @@ 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\Common\Collections\Selectable;
 | 
			
		||||
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 +33,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 +51,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 +63,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 +89,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>&Selectable<int, StoredObjectVersion>
 | 
			
		||||
     */
 | 
			
		||||
    #[ORM\OneToMany(mappedBy: 'storedObject', targetEntity: StoredObjectVersion::class, cascade: ['persist'], orphanRemoval: true)]
 | 
			
		||||
    private Collection&Selectable $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 +128,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 +168,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 +197,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 +235,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 +252,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&Selectable
 | 
			
		||||
    {
 | 
			
		||||
        return $this->versions;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function hasCurrentVersion(): bool
 | 
			
		||||
    {
 | 
			
		||||
        return null !== $this->getCurrentVersion();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function hasTemplate(): bool
 | 
			
		||||
    {
 | 
			
		||||
        return null !== $this->template;
 | 
			
		||||
@@ -314,18 +322,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: \Doctrine\DBAL\Types\Types::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';
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										229
									
								
								src/Bundle/ChillDocStoreBundle/Entity/StoredObjectVersion.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										229
									
								
								src/Bundle/ChillDocStoreBundle/Entity/StoredObjectVersion.php
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,229 @@
 | 
			
		||||
<?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;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Previous storedObjectVersion, from which the current stored object version is created.
 | 
			
		||||
     *
 | 
			
		||||
     * If null, the current stored object version is generated by other means.
 | 
			
		||||
     *
 | 
			
		||||
     * Those version may be associated with the same storedObject, or not. In this last case, that means that
 | 
			
		||||
     * the stored object's current version is created from another stored object version.
 | 
			
		||||
     */
 | 
			
		||||
    #[ORM\ManyToOne(targetEntity: StoredObjectVersion::class)]
 | 
			
		||||
    private ?StoredObjectVersion $createdFrom = null;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * List of stored object versions created from the current version.
 | 
			
		||||
     *
 | 
			
		||||
     * @var Collection<int, StoredObjectVersion>
 | 
			
		||||
     */
 | 
			
		||||
    #[ORM\OneToMany(mappedBy: 'createdFrom', targetEntity: StoredObjectVersion::class)]
 | 
			
		||||
    private Collection $children;
 | 
			
		||||
 | 
			
		||||
    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();
 | 
			
		||||
        $this->children = 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;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @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;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function getCreatedFrom(): ?StoredObjectVersion
 | 
			
		||||
    {
 | 
			
		||||
        return $this->createdFrom;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function setCreatedFrom(?StoredObjectVersion $createdFrom): StoredObjectVersion
 | 
			
		||||
    {
 | 
			
		||||
        if (null === $createdFrom && null !== $this->createdFrom) {
 | 
			
		||||
            $this->createdFrom->removeChild($this);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $createdFrom?->addChild($this);
 | 
			
		||||
 | 
			
		||||
        $this->createdFrom = $createdFrom;
 | 
			
		||||
 | 
			
		||||
        return $this;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function addChild(StoredObjectVersion $child): self
 | 
			
		||||
    {
 | 
			
		||||
        if (!$this->children->contains($child)) {
 | 
			
		||||
            $this->children->add($child);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $this;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function removeChild(StoredObjectVersion $child): self
 | 
			
		||||
    {
 | 
			
		||||
        $result = $this->children->removeElement($child);
 | 
			
		||||
 | 
			
		||||
        if (false === $result) {
 | 
			
		||||
            throw new \UnexpectedValueException('the child is not associated with the current stored object version');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        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,17 +12,18 @@ 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;
 | 
			
		||||
 | 
			
		||||
    public function __construct(private readonly EntityManagerInterface $em)
 | 
			
		||||
    public function __construct(EntityManagerInterface $em)
 | 
			
		||||
    {
 | 
			
		||||
        $this->repository = $em->getRepository(AccompanyingCourseDocument::class);
 | 
			
		||||
    }
 | 
			
		||||
@@ -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) {
 | 
			
		||||
 
 | 
			
		||||
@@ -3,6 +3,7 @@ import DocumentActionButtonsGroup from "../../vuejs/DocumentActionButtonsGroup.v
 | 
			
		||||
import {createApp} from "vue";
 | 
			
		||||
import {StoredObject, StoredObjectStatusChange} from "../../types";
 | 
			
		||||
import {is_object_ready} from "../../vuejs/StoredObjectButton/helpers";
 | 
			
		||||
import ToastPlugin from "vue-toast-notification";
 | 
			
		||||
 | 
			
		||||
const i18n = _createI18n({});
 | 
			
		||||
 | 
			
		||||
@@ -48,6 +49,6 @@ window.addEventListener('DOMContentLoaded', function (e) {
 | 
			
		||||
       }
 | 
			
		||||
     });
 | 
			
		||||
 | 
			
		||||
     app.use(i18n).mount(el);
 | 
			
		||||
     app.use(i18n).use(ToastPlugin).mount(el);
 | 
			
		||||
  })
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -1,64 +1,130 @@
 | 
			
		||||
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",
 | 
			
		||||
    filename: string,
 | 
			
		||||
    iv: Uint8Array,
 | 
			
		||||
    keyInfos: object,
 | 
			
		||||
    type: string,
 | 
			
		||||
export interface StoredObjectVersion {
 | 
			
		||||
    /**
 | 
			
		||||
     * filename of the object in the object storage
 | 
			
		||||
     */
 | 
			
		||||
    filename: string;
 | 
			
		||||
    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,
 | 
			
		||||
  status: StoredObjectStatus,
 | 
			
		||||
  type: string,
 | 
			
		||||
    id: number;
 | 
			
		||||
    filename: string;
 | 
			
		||||
    status: StoredObjectStatus;
 | 
			
		||||
    type: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface StoredObjectVersionWithPointInTime extends StoredObjectVersionPersisted {
 | 
			
		||||
    "point-in-times": StoredObjectPointInTime[];
 | 
			
		||||
    "from-restored": StoredObjectVersionPersisted|null;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface StoredObjectPointInTime {
 | 
			
		||||
    id: number;
 | 
			
		||||
    byUser: User | null;
 | 
			
		||||
    reason: 'keep-before-conversion'|'keep-by-user';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Function executed by the WopiEditButton component.
 | 
			
		||||
 */
 | 
			
		||||
export type WopiEditButtonExecutableBeforeLeaveFunction = {
 | 
			
		||||
  (): Promise<void>
 | 
			
		||||
}
 | 
			
		||||
    (): Promise<void>;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Object containing information for performering a POST request to a swift object store
 | 
			
		||||
 */
 | 
			
		||||
export interface PostStoreObjectSignature {
 | 
			
		||||
    method: "POST",
 | 
			
		||||
    max_file_size: number,
 | 
			
		||||
    max_file_count: 1,
 | 
			
		||||
    expires: number,
 | 
			
		||||
    submit_delay: 180,
 | 
			
		||||
    redirect: string,
 | 
			
		||||
    prefix: string,
 | 
			
		||||
    url: string,
 | 
			
		||||
    signature: string,
 | 
			
		||||
    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 | null;
 | 
			
		||||
    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";
 | 
			
		||||
 | 
			
		||||
export interface CheckSignature {
 | 
			
		||||
    state: SignedState;
 | 
			
		||||
    storedObject: StoredObject;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type CanvasEvent = "select" | "add";
 | 
			
		||||
 
 | 
			
		||||
@@ -1,20 +1,23 @@
 | 
			
		||||
<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}" :display-action-string-in-button="true"></download-button>
 | 
			
		||||
      </li>
 | 
			
		||||
      <li v-if="isHistoryViewable">
 | 
			
		||||
        <history-button :stored-object="props.storedObject" :can-edit="canEdit && props.storedObject._permissions.canEdit"></history-button>
 | 
			
		||||
      </li>
 | 
			
		||||
    </ul>
 | 
			
		||||
  </div>
 | 
			
		||||
@@ -29,20 +32,21 @@
 | 
			
		||||
 | 
			
		||||
<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";
 | 
			
		||||
import HistoryButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/HistoryButton.vue";
 | 
			
		||||
 | 
			
		||||
interface DocumentActionButtonsGroupConfig {
 | 
			
		||||
  storedObject: StoredObject|StoredObjectCreated,
 | 
			
		||||
  storedObject: StoredObject,
 | 
			
		||||
  small?: boolean,
 | 
			
		||||
  canEdit?: boolean,
 | 
			
		||||
  canDownload?: boolean,
 | 
			
		||||
@@ -95,11 +99,48 @@ 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 isHistoryViewable = computed<boolean>(() => {
 | 
			
		||||
    return props.storedObject.status === 'ready';
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
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,634 @@
 | 
			
		||||
<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 m-auto">
 | 
			
		||||
    <div class="row justify-content-center border-bottom pdf-tools d-md-none">
 | 
			
		||||
      <div v-if="pageCount > 1" class="col text-center turn-page">
 | 
			
		||||
        <button
 | 
			
		||||
          class="btn btn-light btn-sm"
 | 
			
		||||
          :disabled="page <= 1"
 | 
			
		||||
          @click="turnPage(-1)"
 | 
			
		||||
        >
 | 
			
		||||
          ❮
 | 
			
		||||
        </button>
 | 
			
		||||
        <span>{{ page }}/{{ pageCount }}</span>
 | 
			
		||||
        <button
 | 
			
		||||
          class="btn btn-light btn-sm"
 | 
			
		||||
          :disabled="page >= pageCount"
 | 
			
		||||
          @click="turnPage(1)"
 | 
			
		||||
        >
 | 
			
		||||
          ❯
 | 
			
		||||
        </button>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div v-if="signature.zones.length > 1" class="col-3 p-0">
 | 
			
		||||
        <button
 | 
			
		||||
          :disabled="userSignatureZone === null || userSignatureZone?.index < 1"
 | 
			
		||||
          class="btn btn-light btn-sm"
 | 
			
		||||
          @click="turnSignature(-1)"
 | 
			
		||||
        >
 | 
			
		||||
          {{ $t("last_zone") }}
 | 
			
		||||
        </button>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div v-if="signature.zones.length > 1" class="col-3 p-0">
 | 
			
		||||
        <button
 | 
			
		||||
          :disabled="userSignatureZone?.index >= signature.zones.length - 1"
 | 
			
		||||
          class="btn btn-light btn-sm"
 | 
			
		||||
          @click="turnSignature(1)"
 | 
			
		||||
        >
 | 
			
		||||
          {{ $t("next_zone") }}
 | 
			
		||||
        </button>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="col text-end p-0">
 | 
			
		||||
        <button
 | 
			
		||||
          class="btn btn-misc btn-sm"
 | 
			
		||||
          :hidden="!userSignatureZone"
 | 
			
		||||
          @click="undoSign"
 | 
			
		||||
          v-if="signature.zones.length > 1"
 | 
			
		||||
          :title="$t('choose_another_signature')"
 | 
			
		||||
        >
 | 
			
		||||
          {{ $t("another_zone") }}
 | 
			
		||||
        </button>
 | 
			
		||||
        <button
 | 
			
		||||
          class="btn btn-misc btn-sm"
 | 
			
		||||
          :hidden="!userSignatureZone"
 | 
			
		||||
          @click="undoSign"
 | 
			
		||||
          v-else
 | 
			
		||||
        >
 | 
			
		||||
          {{ $t("cancel") }}
 | 
			
		||||
        </button>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="col-1" v-if="signedState !== 'signed'">
 | 
			
		||||
        <button
 | 
			
		||||
          class="btn btn-create btn-sm"
 | 
			
		||||
          :class="{ active: canvasEvent === 'add' }"
 | 
			
		||||
          @click="toggleAddZone()"
 | 
			
		||||
          :title="$t('add_sign_zone')"
 | 
			
		||||
        ></button>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div
 | 
			
		||||
      class="row justify-content-center border-bottom pdf-tools d-none d-md-flex"
 | 
			
		||||
    >
 | 
			
		||||
      <div v-if="pageCount > 1" class="col-2 text-center turn-page p-0">
 | 
			
		||||
        <button
 | 
			
		||||
          class="btn btn-light btn-sm"
 | 
			
		||||
          :disabled="page <= 1"
 | 
			
		||||
          @click="turnPage(-1)"
 | 
			
		||||
        >
 | 
			
		||||
          ❮
 | 
			
		||||
        </button>
 | 
			
		||||
        <span>{{ page }} / {{ pageCount }}</span>
 | 
			
		||||
        <button
 | 
			
		||||
          class="btn btn-light btn-sm"
 | 
			
		||||
          :disabled="page >= pageCount"
 | 
			
		||||
          @click="turnPage(1)"
 | 
			
		||||
        >
 | 
			
		||||
          ❯
 | 
			
		||||
        </button>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div
 | 
			
		||||
        v-if="signature.zones.length > 1 && signedState !== 'signed'"
 | 
			
		||||
        class="col text-end d-xl-none"
 | 
			
		||||
      >
 | 
			
		||||
        <button
 | 
			
		||||
          :disabled="userSignatureZone === null || userSignatureZone?.index < 1"
 | 
			
		||||
          class="btn btn-light btn-sm"
 | 
			
		||||
          @click="turnSignature(-1)"
 | 
			
		||||
        >
 | 
			
		||||
          {{ $t("last_zone") }}
 | 
			
		||||
        </button>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div
 | 
			
		||||
        v-if="signature.zones.length > 1 && signedState !== 'signed'"
 | 
			
		||||
        class="col text-start d-xl-none"
 | 
			
		||||
      >
 | 
			
		||||
        <button
 | 
			
		||||
          :disabled="userSignatureZone?.index >= signature.zones.length - 1"
 | 
			
		||||
          class="btn btn-light btn-sm"
 | 
			
		||||
          @click="turnSignature(1)"
 | 
			
		||||
        >
 | 
			
		||||
          {{ $t("next_zone") }}
 | 
			
		||||
        </button>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div
 | 
			
		||||
        v-if="signature.zones.length > 1 && signedState !== 'signed'"
 | 
			
		||||
        class="col text-end d-none d-xl-flex p-0"
 | 
			
		||||
      >
 | 
			
		||||
        <button
 | 
			
		||||
          :disabled="userSignatureZone === null || userSignatureZone?.index < 1"
 | 
			
		||||
          class="btn btn-light btn-sm"
 | 
			
		||||
          @click="turnSignature(-1)"
 | 
			
		||||
        >
 | 
			
		||||
          {{ $t("last_sign_zone") }}
 | 
			
		||||
        </button>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div
 | 
			
		||||
        v-if="signature.zones.length > 1 && signedState !== 'signed'"
 | 
			
		||||
        class="col text-start d-none d-xl-flex p-0"
 | 
			
		||||
      >
 | 
			
		||||
        <button
 | 
			
		||||
          :disabled="userSignatureZone?.index >= signature.zones.length - 1"
 | 
			
		||||
          class="btn btn-light btn-sm"
 | 
			
		||||
          @click="turnSignature(1)"
 | 
			
		||||
        >
 | 
			
		||||
          {{ $t("next_sign_zone") }}
 | 
			
		||||
        </button>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="col text-end p-0" v-if="signedState !== 'signed'">
 | 
			
		||||
        <button
 | 
			
		||||
          class="btn btn-misc btn-sm"
 | 
			
		||||
          :hidden="!userSignatureZone"
 | 
			
		||||
          @click="undoSign"
 | 
			
		||||
          v-if="signature.zones.length > 1"
 | 
			
		||||
        >
 | 
			
		||||
          {{ $t("choose_another_signature") }}
 | 
			
		||||
        </button>
 | 
			
		||||
        <button
 | 
			
		||||
          class="btn btn-misc btn-sm"
 | 
			
		||||
          :hidden="!userSignatureZone"
 | 
			
		||||
          @click="undoSign"
 | 
			
		||||
          v-else
 | 
			
		||||
        >
 | 
			
		||||
          {{ $t("cancel") }}
 | 
			
		||||
        </button>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div
 | 
			
		||||
        class="col text-end p-0 pe-2 pe-xxl-4"
 | 
			
		||||
        v-if="signedState !== 'signed'"
 | 
			
		||||
      >
 | 
			
		||||
        <button
 | 
			
		||||
          class="btn btn-create btn-sm"
 | 
			
		||||
          :class="{ active: canvasEvent === 'add' }"
 | 
			
		||||
          @click="toggleAddZone()"
 | 
			
		||||
          :title="$t('add_sign_zone')"
 | 
			
		||||
        >
 | 
			
		||||
          {{ $t("add_zone") }}
 | 
			
		||||
        </button>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
  <div class="col-xs-12 col-md-12 col-lg-9 m-auto my-5 text-center">
 | 
			
		||||
    <canvas class="m-auto" id="canvas"></canvas>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <div class="col-xs-12 col-md-12 col-lg-9 m-auto p-4" id="action-buttons">
 | 
			
		||||
    <div class="row">
 | 
			
		||||
      <div class="col-4" v-if="signedState !== 'signed'">
 | 
			
		||||
        <button
 | 
			
		||||
          class="btn btn-action me-2"
 | 
			
		||||
          :disabled="!userSignatureZone"
 | 
			
		||||
          @click="sign"
 | 
			
		||||
        >
 | 
			
		||||
          {{ $t("sign") }}
 | 
			
		||||
        </button>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="col-4" v-else></div>
 | 
			
		||||
      <div class="col-8 d-flex justify-content-end">
 | 
			
		||||
        <a
 | 
			
		||||
          class="btn btn-delete"
 | 
			
		||||
          v-if="signedState !== 'signed'"
 | 
			
		||||
          :href="getReturnPath()"
 | 
			
		||||
        >
 | 
			
		||||
          {{ $t("cancel_signing") }}
 | 
			
		||||
        </a>
 | 
			
		||||
        <a class="btn btn-misc" v-else :href="getReturnPath()">
 | 
			
		||||
          {{ $t("return") }}
 | 
			
		||||
        </a>
 | 
			
		||||
      </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 {
 | 
			
		||||
  CanvasEvent,
 | 
			
		||||
  CheckSignature,
 | 
			
		||||
  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 adding: Ref<boolean> = ref(false);
 | 
			
		||||
const canvasEvent: Ref<CanvasEvent> = ref("select");
 | 
			
		||||
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 pdf = {} as PDFDocumentProxy;
 | 
			
		||||
 | 
			
		||||
declare global {
 | 
			
		||||
  interface Window {
 | 
			
		||||
    signature: Signature;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const $toast = useToast();
 | 
			
		||||
 | 
			
		||||
const signature = window.signature;
 | 
			
		||||
 | 
			
		||||
console.log(signature);
 | 
			
		||||
 | 
			
		||||
const mountPdf = async (url: string) => {
 | 
			
		||||
  const loadingTask = pdfjsLib.getDocument(url);
 | 
			
		||||
  pdf = await loadingTask.promise;
 | 
			
		||||
  pageCount.value = pdf.numPages;
 | 
			
		||||
  await setPage(page.value);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
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);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const init = () => downloadAndOpen().then(initPdf);
 | 
			
		||||
 | 
			
		||||
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));
 | 
			
		||||
  return raw;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const initPdf = () => {
 | 
			
		||||
  const canvas = document.querySelectorAll("canvas")[0] as HTMLCanvasElement;
 | 
			
		||||
  canvas.addEventListener("pointerup", canvasClick, false);
 | 
			
		||||
  setTimeout(() => drawAllZones(page.value), 800);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const scaleXToCanvas = (x: number, canvasWidth: number, PDFWidth: number) =>
 | 
			
		||||
  Math.round((x * canvasWidth) / PDFWidth);
 | 
			
		||||
 | 
			
		||||
const scaleYToCanvas = (h: number, canvasHeight: number, PDFHeight: number) =>
 | 
			
		||||
  Math.round((h * canvasHeight) / PDFHeight);
 | 
			
		||||
 | 
			
		||||
const hitSignature = (
 | 
			
		||||
  zone: SignatureZone,
 | 
			
		||||
  xy: number[],
 | 
			
		||||
  canvasWidth: number,
 | 
			
		||||
  canvasHeight: number
 | 
			
		||||
) =>
 | 
			
		||||
  scaleXToCanvas(zone.x, canvasWidth, zone.PDFPage.width) < xy[0] &&
 | 
			
		||||
  xy[0] <
 | 
			
		||||
    scaleXToCanvas(zone.x + zone.width, canvasWidth, zone.PDFPage.width) &&
 | 
			
		||||
  zone.PDFPage.height -
 | 
			
		||||
    scaleYToCanvas(zone.y, canvasHeight, zone.PDFPage.height) <
 | 
			
		||||
    xy[1] &&
 | 
			
		||||
  xy[1] <
 | 
			
		||||
    scaleYToCanvas(zone.height - zone.y, canvasHeight, zone.PDFPage.height) +
 | 
			
		||||
      zone.PDFPage.height;
 | 
			
		||||
 | 
			
		||||
const selectZone = (z: SignatureZone, canvas: HTMLCanvasElement) => {
 | 
			
		||||
  userSignatureZone.value = z;
 | 
			
		||||
  const ctx = canvas.getContext("2d");
 | 
			
		||||
  if (ctx) {
 | 
			
		||||
    setPage(page.value);
 | 
			
		||||
    setTimeout(() => drawAllZones(page.value), 200);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const selectZoneEvent = (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 canvasClick = (e: PointerEvent) => {
 | 
			
		||||
  const canvas = document.querySelectorAll("canvas")[0] as HTMLCanvasElement;
 | 
			
		||||
  canvasEvent.value === "select"
 | 
			
		||||
    ? selectZoneEvent(e, canvas)
 | 
			
		||||
    : addZoneEvent(e, canvas);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const turnPage = async (upOrDown: number) => {
 | 
			
		||||
  //userSignatureZone.value = null; // desactivate the reset of the zone when turning page
 | 
			
		||||
  page.value = page.value + upOrDown;
 | 
			
		||||
  await setPage(page.value);
 | 
			
		||||
  setTimeout(() => drawAllZones(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";
 | 
			
		||||
  ctx.strokeStyle =
 | 
			
		||||
    userSignatureZone.value?.index === zone.index
 | 
			
		||||
      ? selectedBlue
 | 
			
		||||
      : unselectedBlue;
 | 
			
		||||
  ctx.lineWidth = 2;
 | 
			
		||||
  ctx.lineJoin = "bevel";
 | 
			
		||||
  ctx.strokeRect(
 | 
			
		||||
    scaleXToCanvas(zone.x, canvasWidth, zone.PDFPage.width),
 | 
			
		||||
    zone.PDFPage.height -
 | 
			
		||||
      scaleYToCanvas(zone.y, canvasHeight, zone.PDFPage.height),
 | 
			
		||||
    scaleXToCanvas(zone.width, canvasWidth, zone.PDFPage.width),
 | 
			
		||||
    scaleYToCanvas(zone.height, canvasHeight, zone.PDFPage.height)
 | 
			
		||||
  );
 | 
			
		||||
  ctx.font = "bold 16px serif";
 | 
			
		||||
  ctx.textAlign = "center";
 | 
			
		||||
  ctx.fillStyle = "black";
 | 
			
		||||
  const xText =
 | 
			
		||||
    scaleXToCanvas(zone.x, canvasWidth, zone.PDFPage.width) +
 | 
			
		||||
    scaleXToCanvas(zone.width, canvasWidth, zone.PDFPage.width) / 2;
 | 
			
		||||
  const yText =
 | 
			
		||||
    zone.PDFPage.height -
 | 
			
		||||
    scaleYToCanvas(zone.y, canvasHeight, zone.PDFPage.height) +
 | 
			
		||||
    scaleYToCanvas(zone.height, canvasHeight, zone.PDFPage.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);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const drawAllZones = (page: number) => {
 | 
			
		||||
  const canvas = document.querySelectorAll("canvas")[0];
 | 
			
		||||
  const ctx = canvas.getContext("2d");
 | 
			
		||||
  if (ctx && signedState.value !== "signed") {
 | 
			
		||||
    signature.zones
 | 
			
		||||
      .filter((z) => z.PDFPage.index + 1 === page)
 | 
			
		||||
      .map((z) => {
 | 
			
		||||
        if (userSignatureZone.value) {
 | 
			
		||||
          if (userSignatureZone.value?.index === z.index) {
 | 
			
		||||
            drawZone(z, ctx, canvas.width, canvas.height);
 | 
			
		||||
          }
 | 
			
		||||
        } else {
 | 
			
		||||
          drawZone(z, ctx, canvas.width, canvas.height);
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const checkSignature = () => {
 | 
			
		||||
  const url = `/api/1.0/document/workflow/${signature.id}/check-signature`;
 | 
			
		||||
  return makeFetch<null, CheckSignature>("GET", url)
 | 
			
		||||
    .then((r) => {
 | 
			
		||||
      signedState.value = r.state;
 | 
			
		||||
      signature.storedObject = r.storedObject;
 | 
			
		||||
      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 () => {
 | 
			
		||||
  signature.zones = signature.zones.filter((z) => z.index !== null);
 | 
			
		||||
  await setPage(page.value);
 | 
			
		||||
  setTimeout(() => drawAllZones(page.value), 200);
 | 
			
		||||
  userSignatureZone.value = null;
 | 
			
		||||
  adding.value = false;
 | 
			
		||||
  canvasEvent.value = "select";
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const toggleAddZone = () => {
 | 
			
		||||
  canvasEvent.value === "select"
 | 
			
		||||
    ? (canvasEvent.value = "add")
 | 
			
		||||
    : (canvasEvent.value = "select");
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const addZoneEvent = async (e: PointerEvent, canvas: HTMLCanvasElement) => {
 | 
			
		||||
  const BOX_WIDTH = 180;
 | 
			
		||||
  const BOX_HEIGHT = 90;
 | 
			
		||||
  const PDFPageHeight = canvas.height;
 | 
			
		||||
  const PDFPageWidth = canvas.width;
 | 
			
		||||
 | 
			
		||||
  const x = e.offsetX;
 | 
			
		||||
  const y = e.offsetY;
 | 
			
		||||
  const newZone: SignatureZone = {
 | 
			
		||||
    index: null,
 | 
			
		||||
    x:
 | 
			
		||||
      scaleXToCanvas(x, canvas.width, PDFPageWidth) -
 | 
			
		||||
      scaleXToCanvas(BOX_WIDTH / 2, canvas.width, PDFPageWidth),
 | 
			
		||||
    y:
 | 
			
		||||
      PDFPageHeight -
 | 
			
		||||
      scaleYToCanvas(y, canvas.height, PDFPageHeight) +
 | 
			
		||||
      scaleYToCanvas(BOX_HEIGHT / 2, canvas.height, PDFPageHeight),
 | 
			
		||||
    width: BOX_WIDTH,
 | 
			
		||||
    height: BOX_HEIGHT,
 | 
			
		||||
    PDFPage: {
 | 
			
		||||
      index: page.value - 1,
 | 
			
		||||
      width: PDFPageWidth,
 | 
			
		||||
      height: PDFPageHeight,
 | 
			
		||||
    },
 | 
			
		||||
  };
 | 
			
		||||
  signature.zones.push(newZone);
 | 
			
		||||
  userSignatureZone.value = newZone;
 | 
			
		||||
 | 
			
		||||
  await setPage(page.value);
 | 
			
		||||
  setTimeout(() => drawAllZones(page.value), 200);
 | 
			
		||||
  canvasEvent.value = "select";
 | 
			
		||||
  adding.value = true;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const getReturnPath = () =>
 | 
			
		||||
  window.location.search
 | 
			
		||||
    ? window.location.search.split("?returnPath=")[1] ??
 | 
			
		||||
      window.location.pathname
 | 
			
		||||
    : window.location.pathname;
 | 
			
		||||
 | 
			
		||||
init();
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style scoped lang="scss">
 | 
			
		||||
#canvas {
 | 
			
		||||
  box-shadow: 0 20px 20px rgba(0, 0, 0, 0.2);
 | 
			
		||||
}
 | 
			
		||||
div#action-buttons {
 | 
			
		||||
  position: sticky;
 | 
			
		||||
  bottom: 0px;
 | 
			
		||||
  background-color: white;
 | 
			
		||||
  z-index: 100;
 | 
			
		||||
}
 | 
			
		||||
div.pdf-tools {
 | 
			
		||||
  background-color: #f3f3f3;
 | 
			
		||||
  font-size: 0.8rem;
 | 
			
		||||
  @media (min-width: 1400px) {
 | 
			
		||||
    // background: none;
 | 
			
		||||
    // border: none !important;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
div.turn-page {
 | 
			
		||||
  span {
 | 
			
		||||
    font-size: 0.8rem;
 | 
			
		||||
    margin: 0 0.4rem;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
div.signature-modal-body {
 | 
			
		||||
  height: 8rem;
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
 | 
			
		||||
@@ -0,0 +1,37 @@
 | 
			
		||||
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',
 | 
			
		||||
        cancel: 'Annuler',
 | 
			
		||||
        cancel_signing: 'Refuser de signer',
 | 
			
		||||
        last_sign_zone: 'Zone de signature précédente',
 | 
			
		||||
        next_sign_zone: 'Zone de signature suivante',
 | 
			
		||||
        add_sign_zone: 'Ajouter une zone de signature',
 | 
			
		||||
        last_zone: 'Zone précédente',
 | 
			
		||||
        next_zone: 'Zone suivante',
 | 
			
		||||
        add_zone: 'Ajouter une zone',
 | 
			
		||||
        another_zone: 'Autre zone',
 | 
			
		||||
        electronic_signature_in_progress: 'Signature électronique en cours...',
 | 
			
		||||
        loading: 'Chargement...',
 | 
			
		||||
        remove_sign_zone: 'Enlever la zone',
 | 
			
		||||
        return: 'Retour',
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const i18n = _createI18n(appMessages);
 | 
			
		||||
 | 
			
		||||
const app = createApp({
 | 
			
		||||
    template: `<app></app>`,
 | 
			
		||||
})
 | 
			
		||||
    .use(i18n)
 | 
			
		||||
    .component("app", App)
 | 
			
		||||
    .mount("#document-signature");
 | 
			
		||||
@@ -1,17 +1,18 @@
 | 
			
		||||
<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";
 | 
			
		||||
import FileIcon from "ChillDocStoreAssets/vuejs/FileIcon.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);
 | 
			
		||||
@@ -35,7 +36,6 @@ const onDragLeave = (e: Event) => {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const onDrop = (e: DragEvent) => {
 | 
			
		||||
    console.log('on drop', e);
 | 
			
		||||
    e.preventDefault();
 | 
			
		||||
 | 
			
		||||
    const files = e.dataTransfer?.files;
 | 
			
		||||
@@ -65,7 +65,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]);
 | 
			
		||||
@@ -82,21 +81,28 @@ 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;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -106,16 +112,7 @@ const handleFile = async (file: File): Promise<void> => {
 | 
			
		||||
    <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">
 | 
			
		||||
                <i class="fa fa-file-pdf-o" v-if="props.existingDoc?.type === 'application/pdf'"></i>
 | 
			
		||||
                <i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/vnd.oasis.opendocument.text'"></i>
 | 
			
		||||
                <i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'"></i>
 | 
			
		||||
                <i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/msword'"></i>
 | 
			
		||||
                <i class="fa fa-file-excel-o" v-else-if="props.existingDoc?.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'"></i>
 | 
			
		||||
                <i class="fa fa-file-excel-o" v-else-if="props.existingDoc?.type === 'application/vnd.ms-excel'"></i>
 | 
			
		||||
                <i class="fa fa-file-image-o" v-else-if="props.existingDoc?.type === 'image/jpeg'"></i>
 | 
			
		||||
                <i class="fa fa-file-image-o" v-else-if="props.existingDoc?.type === 'image/png'"></i>
 | 
			
		||||
                <i class="fa fa-file-archive-o" v-else-if="props.existingDoc?.type === 'application/x-zip-compressed'"></i>
 | 
			
		||||
                <i class="fa fa-file-code-o" v-else ></i>
 | 
			
		||||
                <file-icon :type="props.existingDoc?.type"></file-icon>
 | 
			
		||||
            </p>
 | 
			
		||||
 | 
			
		||||
            <p v-if="display_filename !== null" class="display-filename">{{ display_filename }}</p>
 | 
			
		||||
@@ -151,6 +148,11 @@ const handleFile = async (file: File): Promise<void> => {
 | 
			
		||||
        flex-direction: column;
 | 
			
		||||
        justify-content: center;
 | 
			
		||||
        align-items: center;
 | 
			
		||||
 | 
			
		||||
        p {
 | 
			
		||||
            // require for display in DropFileModal
 | 
			
		||||
            text-align: center;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    & > .area {
 | 
			
		||||
 
 | 
			
		||||
@@ -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>
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,25 @@
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
interface FileIconConfig {
 | 
			
		||||
    type: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const props = defineProps<FileIconConfig>();
 | 
			
		||||
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
    <i class="fa fa-file-pdf-o" v-if="props.type === 'application/pdf'"></i>
 | 
			
		||||
    <i class="fa fa-file-word-o" v-else-if="props.type === 'application/vnd.oasis.opendocument.text'"></i>
 | 
			
		||||
    <i class="fa fa-file-word-o" v-else-if="props.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'"></i>
 | 
			
		||||
    <i class="fa fa-file-word-o" v-else-if="props.type === 'application/msword'"></i>
 | 
			
		||||
    <i class="fa fa-file-excel-o" v-else-if="props.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'"></i>
 | 
			
		||||
    <i class="fa fa-file-excel-o" v-else-if="props.type === 'application/vnd.ms-excel'"></i>
 | 
			
		||||
    <i class="fa fa-file-image-o" v-else-if="props.type === 'image/jpeg'"></i>
 | 
			
		||||
    <i class="fa fa-file-image-o" v-else-if="props.type === 'image/png'"></i>
 | 
			
		||||
    <i class="fa fa-file-archive-o" v-else-if="props.type === 'application/x-zip-compressed'"></i>
 | 
			
		||||
    <i class="fa fa-file-code-o" v-else ></i>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<style scoped lang="scss">
 | 
			
		||||
 | 
			
		||||
</style>
 | 
			
		||||
@@ -10,7 +10,7 @@
 | 
			
		||||
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 {StoredObject} from "../../types";
 | 
			
		||||
 | 
			
		||||
interface ConvertButtonConfig {
 | 
			
		||||
  storedObject: StoredObject,
 | 
			
		||||
@@ -54,7 +54,7 @@ function reset_state(): void {
 | 
			
		||||
 | 
			
		||||
</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>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,24 +1,26 @@
 | 
			
		||||
<template>
 | 
			
		||||
    <a v-if="!state.is_ready" :class="props.classes" @click="download_and_open($event)">
 | 
			
		||||
    <a v-if="!state.is_ready" :class="props.classes" @click="download_and_open($event)" title="Télécharger">
 | 
			
		||||
        <i class="fa fa-download"></i>
 | 
			
		||||
        Télécharger
 | 
			
		||||
        <template v-if="displayActionStringInButton">Télécharger</template>
 | 
			
		||||
    </a>
 | 
			
		||||
    <a v-else :class="props.classes" target="_blank" :type="props.storedObject.type" :download="buildDocumentName()" :href="state.href_url" ref="open_button">
 | 
			
		||||
    <a v-else :class="props.classes" target="_blank" :type="props.storedObject.type" :download="buildDocumentName()" :href="state.href_url" ref="open_button" title="Ouvrir">
 | 
			
		||||
        <i class="fa fa-external-link"></i>
 | 
			
		||||
        Ouvrir
 | 
			
		||||
        <template v-if="displayActionStringInButton">Ouvrir</template>
 | 
			
		||||
    </a>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<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,
 | 
			
		||||
    displayActionStringInButton: boolean,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface DownloadButtonState {
 | 
			
		||||
@@ -27,14 +29,15 @@ interface DownloadButtonState {
 | 
			
		||||
    href_url: string,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const props = defineProps<DownloadButtonConfig>();
 | 
			
		||||
const props = withDefaults(defineProps<DownloadButtonConfig>(), {displayActionStringInButton: true});
 | 
			
		||||
const state: DownloadButtonState = reactive({is_ready: false, is_running: false, href_url: "#"});
 | 
			
		||||
 | 
			
		||||
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,32 +61,21 @@ 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');
 | 
			
		||||
 | 
			
		||||
@@ -97,7 +89,7 @@ function reset_state(): void {
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style scoped lang="sass">
 | 
			
		||||
<style scoped lang="scss">
 | 
			
		||||
i.fa::before {
 | 
			
		||||
   color: var(--bs-dropdown-link-hover-color);
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,57 @@
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
 | 
			
		||||
import HistoryButtonModal from "ChillDocStoreAssets/vuejs/StoredObjectButton/HistoryButton/HistoryButtonModal.vue";
 | 
			
		||||
import {StoredObject, StoredObjectVersionWithPointInTime} from "./../../types";
 | 
			
		||||
import {computed, reactive, ref, useTemplateRef} from "vue";
 | 
			
		||||
import {get_versions} from "./HistoryButton/api";
 | 
			
		||||
 | 
			
		||||
interface HistoryButtonConfig {
 | 
			
		||||
    storedObject: StoredObject;
 | 
			
		||||
    canEdit: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface HistoryButtonState {
 | 
			
		||||
    versions: StoredObjectVersionWithPointInTime[];
 | 
			
		||||
    loaded: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const props = defineProps<HistoryButtonConfig>();
 | 
			
		||||
const state = reactive<HistoryButtonState>({versions: [], loaded: false});
 | 
			
		||||
const modal = useTemplateRef<typeof HistoryButtonModal>('modal');
 | 
			
		||||
 | 
			
		||||
const download_version_and_open_modal = async function (): Promise<void> {
 | 
			
		||||
    if (null !== modal.value) {
 | 
			
		||||
        modal.value.open();
 | 
			
		||||
    } else {
 | 
			
		||||
        console.log("modal is null");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!state.loaded) {
 | 
			
		||||
        const versions = await get_versions(props.storedObject);
 | 
			
		||||
 | 
			
		||||
        for (const version of versions) {
 | 
			
		||||
            state.versions.push(version);
 | 
			
		||||
        }
 | 
			
		||||
        state.loaded = true;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const onRestoreVersion = ({newVersion}: {newVersion: StoredObjectVersionWithPointInTime}) => {
 | 
			
		||||
    state.versions.unshift(newVersion);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
<a @click="download_version_and_open_modal" class="dropdown-item">
 | 
			
		||||
    <history-button-modal ref="modal" :versions="state.versions" :stored-object="storedObject" :can-edit="canEdit" @restore-version="onRestoreVersion"></history-button-modal>
 | 
			
		||||
    <i class="fa fa-history"></i>
 | 
			
		||||
    Historique
 | 
			
		||||
</a>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<style scoped lang="scss">
 | 
			
		||||
i.fa::before {
 | 
			
		||||
    color: var(--bs-dropdown-link-hover-color);
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
@@ -0,0 +1,68 @@
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import {StoredObject, StoredObjectVersionWithPointInTime} from "./../../../types";
 | 
			
		||||
import HistoryButtonListItem from "ChillDocStoreAssets/vuejs/StoredObjectButton/HistoryButton/HistoryButtonListItem.vue";
 | 
			
		||||
import {computed, reactive} from "vue";
 | 
			
		||||
 | 
			
		||||
interface HistoryButtonListConfig {
 | 
			
		||||
    versions: StoredObjectVersionWithPointInTime[];
 | 
			
		||||
    storedObject: StoredObject;
 | 
			
		||||
    canEdit: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const emit = defineEmits<{
 | 
			
		||||
    restoreVersion: [newVersion: StoredObjectVersionWithPointInTime]
 | 
			
		||||
}>()
 | 
			
		||||
 | 
			
		||||
interface HistoryButtonListState {
 | 
			
		||||
    /**
 | 
			
		||||
     * Contains the number of the newly created version when a version is restored.
 | 
			
		||||
     */
 | 
			
		||||
    restored: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const props = defineProps<HistoryButtonListConfig>();
 | 
			
		||||
 | 
			
		||||
const state = reactive<HistoryButtonListState>({restored: -1})
 | 
			
		||||
 | 
			
		||||
const higher_version = computed<number>(() => props.versions.reduce(
 | 
			
		||||
    (accumulator: number, version: StoredObjectVersionWithPointInTime) => Math.max(accumulator, version.version),
 | 
			
		||||
    -1
 | 
			
		||||
    )
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Executed when a version in child component is restored.
 | 
			
		||||
 *
 | 
			
		||||
 * internally, keep track of the newly restored version
 | 
			
		||||
 */
 | 
			
		||||
const onRestored = ({newVersion}: {newVersion: StoredObjectVersionWithPointInTime}) => {
 | 
			
		||||
    state.restored = newVersion.version;
 | 
			
		||||
    emit('restoreVersion', {newVersion});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
    <template v-if="props.versions.length > 0">
 | 
			
		||||
        <div class="container">
 | 
			
		||||
            <template v-for="v in props.versions">
 | 
			
		||||
                <history-button-list-item
 | 
			
		||||
                    :version="v"
 | 
			
		||||
                    :can-edit="canEdit"
 | 
			
		||||
                    :is-current="higher_version === v.version"
 | 
			
		||||
                    :is-restored="v.version === state.restored"
 | 
			
		||||
                    :stored-object="storedObject"
 | 
			
		||||
                    @restore-version="onRestored"
 | 
			
		||||
                ></history-button-list-item>
 | 
			
		||||
            </template>
 | 
			
		||||
        </div>
 | 
			
		||||
    </template>
 | 
			
		||||
    <template v-else>
 | 
			
		||||
        <p>Chargement des versions</p>
 | 
			
		||||
    </template>
 | 
			
		||||
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<style scoped lang="scss">
 | 
			
		||||
 | 
			
		||||
</style>
 | 
			
		||||
@@ -0,0 +1,113 @@
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import {StoredObject, StoredObjectPointInTime, StoredObjectVersionWithPointInTime} from "./../../../types";
 | 
			
		||||
import UserRenderBoxBadge from "ChillMainAssets/vuejs/_components/Entity/UserRenderBoxBadge.vue";
 | 
			
		||||
import {ISOToDatetime} from "./../../../../../../ChillMainBundle/Resources/public/chill/js/date";
 | 
			
		||||
import FileIcon from "ChillDocStoreAssets/vuejs/FileIcon.vue";
 | 
			
		||||
import RestoreVersionButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/HistoryButton/RestoreVersionButton.vue";
 | 
			
		||||
import DownloadButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DownloadButton.vue";
 | 
			
		||||
import {computed} from "vue";
 | 
			
		||||
 | 
			
		||||
interface HistoryButtonListItemConfig {
 | 
			
		||||
    version: StoredObjectVersionWithPointInTime;
 | 
			
		||||
    storedObject: StoredObject;
 | 
			
		||||
    canEdit: boolean;
 | 
			
		||||
    isCurrent: boolean;
 | 
			
		||||
    isRestored: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const emit = defineEmits<{
 | 
			
		||||
    restoreVersion: [newVersion: StoredObjectVersionWithPointInTime]
 | 
			
		||||
}>()
 | 
			
		||||
 | 
			
		||||
const props = defineProps<HistoryButtonListItemConfig>();
 | 
			
		||||
 | 
			
		||||
const onRestore = ({newVersion}: {newVersion: StoredObjectVersionWithPointInTime}) => {
 | 
			
		||||
    emit('restoreVersion', {newVersion});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const isKeptBeforeConversion = computed<boolean>(() => props.version["point-in-times"].reduce(
 | 
			
		||||
    (accumulator: boolean, pit: StoredObjectPointInTime) => accumulator || "keep-before-conversion" === pit.reason,
 | 
			
		||||
    false
 | 
			
		||||
    ),
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const isRestored = computed<boolean>(() => null !== props.version["from-restored"]);
 | 
			
		||||
 | 
			
		||||
const classes = computed<{row: true, 'row-hover': true, 'blinking-1': boolean, 'blinking-2': boolean}>(() => ({row: true, 'row-hover': true, 'blinking-1': props.isRestored && 0 === props.version.version % 2, 'blinking-2': props.isRestored && 1 === props.version.version % 2}));
 | 
			
		||||
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
<div :class="classes">
 | 
			
		||||
    <div class="col-12 tags" v-if="isCurrent || isKeptBeforeConversion || isRestored">
 | 
			
		||||
        <span class="badge bg-success" v-if="isCurrent">Version actuelle</span>
 | 
			
		||||
        <span class="badge bg-info" v-if="isKeptBeforeConversion">Conservée avant conversion dans un autre format</span>
 | 
			
		||||
        <span class="badge bg-info" v-if="isRestored">Restaurée depuis la version {{ version["from-restored"]?.version }}</span>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="col-12">
 | 
			
		||||
        <file-icon :type="version.type"></file-icon> <span><strong>#{{ version.version + 1 }}</strong></span> <strong v-if="version.version == 0">Créé par</strong><strong v-else>modifié par</strong> <span class="badge-user"><UserRenderBoxBadge :user="version.createdBy"></UserRenderBoxBadge></span> <strong>à</strong> {{ $d(ISOToDatetime(version.createdAt.datetime8601), 'long') }}
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="col-12">
 | 
			
		||||
        <ul class="record_actions small slim on-version-actions">
 | 
			
		||||
            <li v-if="canEdit && !isCurrent">
 | 
			
		||||
                <restore-version-button :stored-object-version="props.version" @restore-version="onRestore"></restore-version-button>
 | 
			
		||||
            </li>
 | 
			
		||||
            <li>
 | 
			
		||||
                <download-button :stored-object="storedObject" :at-version="version" :classes="{btn: true, 'btn-outline-primary': true}" :display-action-string-in-button="false"></download-button>
 | 
			
		||||
            </li>
 | 
			
		||||
        </ul>
 | 
			
		||||
    </div>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<style scoped lang="scss">
 | 
			
		||||
div.tags {
 | 
			
		||||
    span.badge:not(:last-child) {
 | 
			
		||||
        margin-right: 0.5rem;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
// to make the animation restart, we have the same animation twice,
 | 
			
		||||
// and alternate between both
 | 
			
		||||
.blinking-1 {
 | 
			
		||||
    animation-name: backgroundColorPalette-1;
 | 
			
		||||
    animation-duration: 8s;
 | 
			
		||||
    animation-iteration-count: 1;
 | 
			
		||||
    animation-direction: normal;
 | 
			
		||||
    animation-timing-function: linear;
 | 
			
		||||
}
 | 
			
		||||
@keyframes backgroundColorPalette-1 {
 | 
			
		||||
    0% {
 | 
			
		||||
        background: var(--bs-chill-green-dark);
 | 
			
		||||
    }
 | 
			
		||||
    25% {
 | 
			
		||||
        background: var(--bs-chill-green);
 | 
			
		||||
    }
 | 
			
		||||
    65% {
 | 
			
		||||
        background: var(--bs-chill-beige);
 | 
			
		||||
    }
 | 
			
		||||
    100% {
 | 
			
		||||
        background: unset;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
.blinking-2 {
 | 
			
		||||
    animation-name: backgroundColorPalette-2;
 | 
			
		||||
    animation-duration: 8s;
 | 
			
		||||
    animation-iteration-count: 1;
 | 
			
		||||
    animation-direction: normal;
 | 
			
		||||
    animation-timing-function: linear;
 | 
			
		||||
}
 | 
			
		||||
@keyframes backgroundColorPalette-2 {
 | 
			
		||||
    0% {
 | 
			
		||||
        background: var(--bs-chill-green-dark);
 | 
			
		||||
    }
 | 
			
		||||
    25% {
 | 
			
		||||
        background: var(--bs-chill-green);
 | 
			
		||||
    }
 | 
			
		||||
    65% {
 | 
			
		||||
        background: var(--bs-chill-beige);
 | 
			
		||||
    }
 | 
			
		||||
    100% {
 | 
			
		||||
        background: unset;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
@@ -0,0 +1,48 @@
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
 | 
			
		||||
import {reactive} from "vue";
 | 
			
		||||
import HistoryButtonList from "ChillDocStoreAssets/vuejs/StoredObjectButton/HistoryButton/HistoryButtonList.vue";
 | 
			
		||||
import {StoredObject, StoredObjectVersionWithPointInTime} from "./../../../types";
 | 
			
		||||
 | 
			
		||||
interface HistoryButtonListConfig {
 | 
			
		||||
    versions: StoredObjectVersionWithPointInTime[];
 | 
			
		||||
    storedObject: StoredObject;
 | 
			
		||||
    canEdit: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const emit = defineEmits<{
 | 
			
		||||
    restoreVersion: [newVersion: StoredObjectVersionWithPointInTime]
 | 
			
		||||
}>()
 | 
			
		||||
 | 
			
		||||
interface HistoryButtonModalState {
 | 
			
		||||
    opened: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const props = defineProps<HistoryButtonListConfig>();
 | 
			
		||||
const state = reactive<HistoryButtonModalState>({opened: false});
 | 
			
		||||
 | 
			
		||||
const open = () => {
 | 
			
		||||
    console.log('open');
 | 
			
		||||
    state.opened = true;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
defineExpose({open});
 | 
			
		||||
 | 
			
		||||
</script>
 | 
			
		||||
<template>
 | 
			
		||||
    <Teleport to="body">
 | 
			
		||||
        <modal v-if="state.opened" @close="state.opened = false">
 | 
			
		||||
            <template v-slot:header>
 | 
			
		||||
                <h3>Historique des versions du document</h3>
 | 
			
		||||
            </template>
 | 
			
		||||
            <template v-slot:body>
 | 
			
		||||
                <p>Les versions sont conservées pendant 90 jours.</p>
 | 
			
		||||
                <history-button-list :versions="props.versions" :can-edit="canEdit" :stored-object="storedObject" @restore-version="(payload) => emit('restoreVersion', payload)"></history-button-list>
 | 
			
		||||
            </template>
 | 
			
		||||
        </modal>
 | 
			
		||||
    </Teleport>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<style scoped lang="scss">
 | 
			
		||||
 | 
			
		||||
</style>
 | 
			
		||||
@@ -0,0 +1,32 @@
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import {StoredObjectVersionPersisted, StoredObjectVersionWithPointInTime} from "../../../types";
 | 
			
		||||
import {useToast} from "vue-toast-notification";
 | 
			
		||||
import {restore_version} from "./api";
 | 
			
		||||
 | 
			
		||||
interface RestoreVersionButtonProps {
 | 
			
		||||
    storedObjectVersion: StoredObjectVersionPersisted,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const emit = defineEmits<{
 | 
			
		||||
    restoreVersion: [newVersion: StoredObjectVersionWithPointInTime]
 | 
			
		||||
}>()
 | 
			
		||||
 | 
			
		||||
const props = defineProps<RestoreVersionButtonProps>()
 | 
			
		||||
 | 
			
		||||
const $toast = useToast();
 | 
			
		||||
 | 
			
		||||
const restore_version_fn = async () => {
 | 
			
		||||
    const newVersion = await restore_version(props.storedObjectVersion);
 | 
			
		||||
 | 
			
		||||
    $toast.success("Version restaurée");
 | 
			
		||||
    emit('restoreVersion', {newVersion});
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
<button class="btn btn-outline-action" @click="restore_version_fn" title="Restaurer"><i class="fa fa-rotate-left"></i> Restaurer</button>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<style scoped lang="scss">
 | 
			
		||||
 | 
			
		||||
</style>
 | 
			
		||||
@@ -0,0 +1,12 @@
 | 
			
		||||
import {StoredObject, StoredObjectVersionPersisted, StoredObjectVersionWithPointInTime} from "../../../types";
 | 
			
		||||
import {fetchResults, makeFetch} from "../../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods";
 | 
			
		||||
 | 
			
		||||
export const get_versions = async (storedObject: StoredObject): Promise<StoredObjectVersionWithPointInTime[]> => {
 | 
			
		||||
    const versions = await fetchResults<StoredObjectVersionWithPointInTime>(`/api/1.0/doc-store/stored-object/${storedObject.uuid}/versions`);
 | 
			
		||||
 | 
			
		||||
    return versions.sort((a: StoredObjectVersionWithPointInTime, b: StoredObjectVersionWithPointInTime) => b.version - a.version);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const restore_version = async (version: StoredObjectVersionPersisted): Promise<StoredObjectVersionWithPointInTime> => {
 | 
			
		||||
   return await makeFetch<null, StoredObjectVersionWithPointInTime>("POST", `/api/1.0/doc-store/stored-object/restore-from-version/${version.id}`);
 | 
			
		||||
}
 | 
			
		||||
@@ -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,15 +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)) }}
 | 
			
		||||
                    </li>
 | 
			
		||||
                    <li>
 | 
			
		||||
                        <a href="{{ chill_path_add_return_path('accompanying_course_document_show', {'course': accompanyingCourse.id, 'id': document.id}) }}" class="btn btn-show"></a>
 | 
			
		||||
                    </li>
 | 
			
		||||
                {% endif %}
 | 
			
		||||
                {% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_UPDATE', document) %}
 | 
			
		||||
                    <li>
 | 
			
		||||
                        <a href="{{ path('accompanying_course_document_edit', {'course': accompanyingCourse.id, 'id': document.id }) }}" class="btn btn-update"></a>
 | 
			
		||||
                        {{ document.object|chill_document_button_group(document.title) }}
 | 
			
		||||
                    </li>
 | 
			
		||||
                {% endif %}
 | 
			
		||||
                {% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_DELETE', document) %}
 | 
			
		||||
@@ -87,10 +79,25 @@
 | 
			
		||||
                        <a href="{{ chill_return_path_or('chill_docstore_accompanying_course_document_delete', {'course': accompanyingCourse.id, 'id': document.id}) }}" class="btn btn-delete"></a>
 | 
			
		||||
                    </li>
 | 
			
		||||
                {% endif %}
 | 
			
		||||
                {% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_CREATE', document.course) %}
 | 
			
		||||
                    <li>
 | 
			
		||||
                        <a href="{{ chill_path_add_return_path('chill_doc_store_accompanying_course_document_duplicate', {'id': document.id}) }}" class="btn btn-duplicate" title="{{ 'Duplicate'|trans|e('html_attr') }}"></a>
 | 
			
		||||
                    </li>
 | 
			
		||||
                {% endif %}
 | 
			
		||||
                {% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_UPDATE', document) %}
 | 
			
		||||
                    <li>
 | 
			
		||||
                        <a href="{{ path('accompanying_course_document_edit', {'course': accompanyingCourse.id, 'id': document.id }) }}" class="btn btn-update"></a>
 | 
			
		||||
                    </li>
 | 
			
		||||
                {% endif %}
 | 
			
		||||
                {% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_SEE_DETAILS', document) %}
 | 
			
		||||
                    <li>
 | 
			
		||||
                        <a href="{{ chill_path_add_return_path('accompanying_course_document_show', {'course': accompanyingCourse.id, 'id': document.id}) }}" class="btn btn-show"></a>
 | 
			
		||||
                    </li>
 | 
			
		||||
                {% endif %}
 | 
			
		||||
            {% 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,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\Serializer\Normalizer;
 | 
			
		||||
 | 
			
		||||
use Chill\DocStoreBundle\Entity\StoredObjectPointInTime;
 | 
			
		||||
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
 | 
			
		||||
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
 | 
			
		||||
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
 | 
			
		||||
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
 | 
			
		||||
 | 
			
		||||
class StoredObjectPointInTimeNormalizer implements NormalizerInterface, NormalizerAwareInterface
 | 
			
		||||
{
 | 
			
		||||
    use NormalizerAwareTrait;
 | 
			
		||||
 | 
			
		||||
    public function normalize($object, ?string $format = null, array $context = [])
 | 
			
		||||
    {
 | 
			
		||||
        /* @var StoredObjectPointInTime $object */
 | 
			
		||||
        return [
 | 
			
		||||
            'id' => $object->getId(),
 | 
			
		||||
            'reason' => $object->getReason()->value,
 | 
			
		||||
            'byUser' => $this->normalizer->normalize($object->getByUser(), $format, [AbstractNormalizer::GROUPS => 'read']),
 | 
			
		||||
        ];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function supportsNormalization($data, ?string $format = null)
 | 
			
		||||
    {
 | 
			
		||||
        return $data instanceof StoredObjectPointInTime;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,61 @@
 | 
			
		||||
<?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 Chill\MainBundle\Serializer\Normalizer\UserNormalizer;
 | 
			
		||||
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
 | 
			
		||||
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;
 | 
			
		||||
 | 
			
		||||
    final public const WITH_POINT_IN_TIMES_CONTEXT = 'with-point-in-times';
 | 
			
		||||
 | 
			
		||||
    final public const WITH_RESTORED_CONTEXT = 'with-restored';
 | 
			
		||||
 | 
			
		||||
    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);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $data = [
 | 
			
		||||
            '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, UserNormalizer::AT_DATE => $object->getCreatedAt()]),
 | 
			
		||||
        ];
 | 
			
		||||
 | 
			
		||||
        if (in_array(self::WITH_POINT_IN_TIMES_CONTEXT, $context[AbstractNormalizer::GROUPS] ?? [], true)) {
 | 
			
		||||
            $data['point-in-times'] = $this->normalizer->normalize($object->getPointInTimes(), $format, $context);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (in_array(self::WITH_RESTORED_CONTEXT, $context[AbstractNormalizer::GROUPS] ?? [], true)) {
 | 
			
		||||
            $data['from-restored'] = $this->normalizer->normalize($object->getCreatedFrom(), $format, [AbstractNormalizer::GROUPS => ['read']]);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $data;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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,61 @@
 | 
			
		||||
<?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\Repository\Workflow\EntityWorkflowStepSignatureRepository;
 | 
			
		||||
use Chill\MainBundle\Workflow\EntityWorkflowManager;
 | 
			
		||||
use Chill\MainBundle\Workflow\SignatureStepStateChanger;
 | 
			
		||||
use Doctrine\ORM\EntityManagerInterface;
 | 
			
		||||
use Psr\Log\LoggerInterface;
 | 
			
		||||
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 SignatureStepStateChanger $signatureStepStateChanger,
 | 
			
		||||
    ) {}
 | 
			
		||||
 | 
			
		||||
    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);
 | 
			
		||||
 | 
			
		||||
        $this->signatureStepStateChanger->markSignatureAsSigned($signature, $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();
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,47 @@
 | 
			
		||||
<?php
 | 
			
		||||
 | 
			
		||||
declare(strict_types=1);
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
 * Chill is a software for social workers
 | 
			
		||||
 *
 | 
			
		||||
 * For the full copyright and license information, please view
 | 
			
		||||
 * the LICENSE file that was distributed with this source code.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
namespace Chill\DocStoreBundle\Service;
 | 
			
		||||
 | 
			
		||||
use Chill\DocStoreBundle\Entity\StoredObject;
 | 
			
		||||
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
 | 
			
		||||
use Psr\Log\LoggerInterface;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Class which duplicate a stored object into a new one, recreating a stored object.
 | 
			
		||||
 */
 | 
			
		||||
class StoredObjectDuplicate
 | 
			
		||||
{
 | 
			
		||||
    public function __construct(private readonly StoredObjectManagerInterface $storedObjectManager, private readonly LoggerInterface $logger) {}
 | 
			
		||||
 | 
			
		||||
    public function duplicate(StoredObject|StoredObjectVersion $from): StoredObject
 | 
			
		||||
    {
 | 
			
		||||
        $fromVersion = $from instanceof StoredObjectVersion ? $from : $from->getCurrentVersion();
 | 
			
		||||
 | 
			
		||||
        $oldContent = $this->storedObjectManager->read($fromVersion);
 | 
			
		||||
 | 
			
		||||
        $storedObject = new StoredObject();
 | 
			
		||||
 | 
			
		||||
        $newVersion = $this->storedObjectManager->write($storedObject, $oldContent, $fromVersion->getType());
 | 
			
		||||
 | 
			
		||||
        $newVersion->setCreatedFrom($fromVersion);
 | 
			
		||||
 | 
			
		||||
        $this->logger->info('[StoredObjectDuplicate] Duplicated stored object from a version of a previous stored object', [
 | 
			
		||||
            'from_stored_object_uuid' => $fromVersion->getStoredObject()->getUuid(),
 | 
			
		||||
            'to_stored_object_uuid' => $storedObject->getUuid(),
 | 
			
		||||
            'old_version_id' => $fromVersion->getId(),
 | 
			
		||||
            'old_version_version' => $fromVersion->getVersion(),
 | 
			
		||||
            'new_version_id' => $newVersion->getVersion(),
 | 
			
		||||
        ]);
 | 
			
		||||
 | 
			
		||||
        return $storedObject;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -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,41 @@
 | 
			
		||||
<?php
 | 
			
		||||
 | 
			
		||||
declare(strict_types=1);
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
 * Chill is a software for social workers
 | 
			
		||||
 *
 | 
			
		||||
 * For the full copyright and license information, please view
 | 
			
		||||
 * the LICENSE file that was distributed with this source code.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
namespace Chill\DocStoreBundle\Service;
 | 
			
		||||
 | 
			
		||||
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
 | 
			
		||||
use Psr\Log\LoggerInterface;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Class responsible for restoring stored object versions into the same stored object.
 | 
			
		||||
 */
 | 
			
		||||
final readonly class StoredObjectRestore implements StoredObjectRestoreInterface
 | 
			
		||||
{
 | 
			
		||||
    public function __construct(private readonly StoredObjectManagerInterface $storedObjectManager, private readonly LoggerInterface $logger) {}
 | 
			
		||||
 | 
			
		||||
    public function restore(StoredObjectVersion $storedObjectVersion): StoredObjectVersion
 | 
			
		||||
    {
 | 
			
		||||
        $oldContent = $this->storedObjectManager->read($storedObjectVersion);
 | 
			
		||||
 | 
			
		||||
        $newVersion = $this->storedObjectManager->write($storedObjectVersion->getStoredObject(), $oldContent, $storedObjectVersion->getType());
 | 
			
		||||
 | 
			
		||||
        $newVersion->setCreatedFrom($storedObjectVersion);
 | 
			
		||||
 | 
			
		||||
        $this->logger->info('[StoredObjectRestore] Restore stored object version', [
 | 
			
		||||
            'stored_object_uuid' => $storedObjectVersion->getStoredObject()->getUuid(),
 | 
			
		||||
            'old_version_id' => $storedObjectVersion->getId(),
 | 
			
		||||
            'old_version_version' => $storedObjectVersion->getVersion(),
 | 
			
		||||
            'new_version_id' => $newVersion->getVersion(),
 | 
			
		||||
        ]);
 | 
			
		||||
 | 
			
		||||
        return $newVersion;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -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\Service;
 | 
			
		||||
 | 
			
		||||
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Restore an old version of the stored object as the current one.
 | 
			
		||||
 */
 | 
			
		||||
interface StoredObjectRestoreInterface
 | 
			
		||||
{
 | 
			
		||||
    public function restore(StoredObjectVersion $storedObjectVersion): StoredObjectVersion;
 | 
			
		||||
}
 | 
			
		||||
@@ -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,49 @@
 | 
			
		||||
<?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\Entity\Workflow\EntityWorkflowSignatureStateEnum;
 | 
			
		||||
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;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // as soon as there is one signatured applyied, we are not able to
 | 
			
		||||
            // edit the document any more
 | 
			
		||||
            foreach ($workflow->getSteps() as $step) {
 | 
			
		||||
                foreach ($step->getSignatures() as $signature) {
 | 
			
		||||
                    if (EntityWorkflowSignatureStateEnum::SIGNED === $signature->getState()) {
 | 
			
		||||
                        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