mirror of
https://gitlab.com/Chill-Projet/chill-bundles.git
synced 2025-09-14 02:34:58 +00:00
Compare commits
381 Commits
v3.2.1
...
signature-
Author | SHA1 | Date | |
---|---|---|---|
fd69568842
|
|||
71aaf01687 | |||
a256307b82
|
|||
a6480191e5 | |||
19eb6f7ebb
|
|||
261bc88b5e
|
|||
4f18b1d2b2
|
|||
968835a262
|
|||
85dc9bdb2f
|
|||
c877076429
|
|||
418794e586 | |||
fd66dbf26e
|
|||
fde74b190d
|
|||
527cf23d4f
|
|||
1d708a481d
|
|||
ff5640e193
|
|||
d45de5405b
|
|||
7b322d7bab
|
|||
daef18408a
|
|||
91a4b45607
|
|||
29fa086fde
|
|||
508c4cd674
|
|||
9fe20b5e81
|
|||
d8ded80582
|
|||
d283d62049
|
|||
6cd336922f
|
|||
13dbbb6741
|
|||
1313b6f138
|
|||
3d53e7da65
|
|||
8589bada3f
|
|||
292034d64d
|
|||
3f7c5d23dc
|
|||
78445f0d65
|
|||
c329a1f1f8
|
|||
9d722110a6
|
|||
82e2b9a0f6
|
|||
40b8fae8ba
|
|||
b99ea3b17a
|
|||
3f80d62ca2
|
|||
118ae291e2
|
|||
5c0f3cb317
|
|||
a0b5c208eb
|
|||
7913a377c8
|
|||
7cd638c5fc
|
|||
071c5e3c55
|
|||
da6589ba87
|
|||
a563ba644e
|
|||
2213f6f429
|
|||
9a9d14eb5a
|
|||
4cc001a070
|
|||
6c52ff84a8 | |||
818d800384
|
|||
cef641ee24
|
|||
c4c7280b52
|
|||
d8ad8c3605
|
|||
803332ba5f
|
|||
479651b31e
|
|||
7bedf1b5b8
|
|||
6b764114e4
|
|||
03a150aa16
|
|||
81706a61ef
|
|||
debca1f474
|
|||
2e71808be1
|
|||
0c1d9ee4be
|
|||
87599425df
|
|||
86ec6f82da
|
|||
17f4c85fa5
|
|||
82cd77678b
|
|||
9e69c97250
|
|||
b4fa478177
|
|||
42438d5bb5 | |||
5287824dbe
|
|||
cfce531754
|
|||
83121c2a83
|
|||
5a467ae38d
|
|||
75005b4ed6 | |||
90c5b0341a
|
|||
5a5d259d18
|
|||
758a14366e
|
|||
e91fce524e
|
|||
7b06c80c2a | |||
cf2fe1bba7
|
|||
27df3b2c9b
|
|||
b350c0cfe8 | |||
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"
|
6
.changes/unreleased/Fixed-20240916-151843.yaml
Normal file
6
.changes/unreleased/Fixed-20240916-151843.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
kind: Fixed
|
||||||
|
body: Show only the current referrer in the page "show" for an accompanying period
|
||||||
|
workf
|
||||||
|
time: 2024-09-16T15:18:43.017401122+02:00
|
||||||
|
custom:
|
||||||
|
Issue: "308"
|
@@ -1,6 +0,0 @@
|
|||||||
## v3.1.1 - 2024-10-01
|
|
||||||
### Fixed
|
|
||||||
* ([#308](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/308)) Show only the current referrer in the page "show" for an accompanying period workf
|
|
||||||
* ([#309](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/309)) Correctly compute the grouping by referrer aggregator
|
|
||||||
|
|
||||||
* Fixed typing of custom field long choice and custom field group
|
|
@@ -1,3 +0,0 @@
|
|||||||
## v3.2.0 - 2024-10-30
|
|
||||||
### Feature
|
|
||||||
* Introduce a gender entity
|
|
@@ -1,4 +0,0 @@
|
|||||||
## v3.2.1 - 2024-10-31
|
|
||||||
### Fixed
|
|
||||||
* Add the possibility of unknown to the gender entity
|
|
||||||
* Fix the fusion of person doubles by excluding accompanyingPeriod work entities to be deleted. They are moved instead.
|
|
2
.env
2
.env
@@ -23,7 +23,7 @@ TRUSTED_HOSTS='^(localhost|example\.com|nginx)$'
|
|||||||
###< symfony/framework-bundle ###
|
###< symfony/framework-bundle ###
|
||||||
|
|
||||||
## Wopi server for editing documents online
|
## Wopi server for editing documents online
|
||||||
WOPI_SERVER=http://collabora:9980
|
EDITOR_SERVER=http://collabora:9980
|
||||||
|
|
||||||
# must be manually set in .env.local
|
# must be manually set in .env.local
|
||||||
# ADMIN_PASSWORD=
|
# 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_KEY=
|
||||||
ASYNC_UPLOAD_TEMP_URL_BASE_PATH=
|
ASYNC_UPLOAD_TEMP_URL_BASE_PATH=
|
||||||
ASYNC_UPLOAD_TEMP_URL_CONTAINER=
|
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 tests/console chill:db:sync-views --env=test
|
||||||
- php -d memory_limit=2G tests/console cache:clear --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=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:
|
artifacts:
|
||||||
expire_in: 1 day
|
expire_in: 1 day
|
||||||
paths:
|
paths:
|
||||||
|
36
CHANGELOG.md
36
CHANGELOG.md
@@ -6,22 +6,6 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
|
|||||||
and is generated by [Changie](https://github.com/miniscruff/changie).
|
and is generated by [Changie](https://github.com/miniscruff/changie).
|
||||||
|
|
||||||
|
|
||||||
## v3.2.1 - 2024-10-31
|
|
||||||
### Fixed
|
|
||||||
* Add the possibility of unknown to the gender entity
|
|
||||||
* Fix the fusion of person doubles by excluding accompanyingPeriod work entities to be deleted. They are moved instead.
|
|
||||||
|
|
||||||
## v3.2.0 - 2024-10-30
|
|
||||||
### Feature
|
|
||||||
* Introduce a gender entity
|
|
||||||
|
|
||||||
## v3.1.1 - 2024-10-01
|
|
||||||
### Fixed
|
|
||||||
* ([#308](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/308)) Show only the current referrer in the page "show" for an accompanying period workf
|
|
||||||
* ([#309](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/309)) Correctly compute the grouping by referrer aggregator
|
|
||||||
|
|
||||||
* Fixed typing of custom field long choice and custom field group
|
|
||||||
|
|
||||||
## v3.1.0 - 2024-08-30
|
## v3.1.0 - 2024-08-30
|
||||||
### Feature
|
### Feature
|
||||||
* Add export aggregator to aggregate activities by household + filter persons that are not part of an accompanyingperiod during a certain timeframe.
|
* Add export aggregator to aggregate activities by household + filter persons that are not part of an accompanyingperiod during a certain timeframe.
|
||||||
@@ -36,14 +20,8 @@ and is generated by [Changie](https://github.com/miniscruff/changie).
|
|||||||
### Feature
|
### Feature
|
||||||
* ([#306](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/306)) When a document is converted or downloaded in the browser, this document is removed from the browser memory after 45s. Future click on the button re-download the document.
|
* ([#306](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/306)) When a document is converted or downloaded in the browser, this document is removed from the browser memory after 45s. Future click on the button re-download the document.
|
||||||
|
|
||||||
## v2.23.0 - 2024-07-23 & 2024-07-19
|
## v2.23.0 - 2024-07-19 & 2024-07-23
|
||||||
### Feature
|
### Feature
|
||||||
* ([#221](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/221)) [DX] move async-upload-bundle features into chill-bundles
|
|
||||||
* Add job bundle (module emploi)
|
|
||||||
* Upgrade import of address list to the last version of compiled addresses of belgian-best-address
|
|
||||||
|
|
||||||
* Upgrade CKEditor and refactor configuration with use of typescript
|
|
||||||
|
|
||||||
* ([#123](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/123)) Add a button to duplicate calendar ranges from a week to another one
|
* ([#123](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/123)) Add a button to duplicate calendar ranges from a week to another one
|
||||||
* [admin] filter users by active / inactive in the admin user's list
|
* [admin] filter users by active / inactive in the admin user's list
|
||||||
* ([#273](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/273)) Add the possibility to mark all notifications as read
|
* ([#273](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/273)) Add the possibility to mark all notifications as read
|
||||||
@@ -53,8 +31,6 @@ and is generated by [Changie](https://github.com/miniscruff/changie).
|
|||||||
* Do not update the "createdAt" column when importing postal code which does not change
|
* Do not update the "createdAt" column when importing postal code which does not change
|
||||||
* Display filename on file upload within the UI interface
|
* Display filename on file upload within the UI interface
|
||||||
### Fixed
|
### Fixed
|
||||||
* Fix resolving of centers for an household, which will fix in turn the access control
|
|
||||||
* Resolved type hinting error in activity list export
|
|
||||||
* ([#271](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/271)) Take into account the acp closing date in the acp works date filter
|
* ([#271](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/271)) Take into account the acp closing date in the acp works date filter
|
||||||
|
|
||||||
### Traduction française des principaux changements
|
### Traduction française des principaux changements
|
||||||
@@ -67,6 +43,16 @@ and is generated by [Changie](https://github.com/miniscruff/changie).
|
|||||||
- Agrandit l'icône du type de fichier dans l'interface de dépôt de fichier;
|
- Agrandit l'icône du type de fichier dans l'interface de dépôt de fichier;
|
||||||
- correction: tient compte de la date de fermeture du parcours dans les filtres sur les actions d'accompagnement.
|
- correction: tient compte de la date de fermeture du parcours dans les filtres sur les actions d'accompagnement.
|
||||||
|
|
||||||
|
* ([#221](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/221)) [DX] move async-upload-bundle features into chill-bundles
|
||||||
|
* Add job bundle (module emploi)
|
||||||
|
* Upgrade import of address list to the last version of compiled addresses of belgian-best-address
|
||||||
|
|
||||||
|
* Upgrade CKEditor and refactor configuration with use of typescript
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* Fix resolving of centers for an household, which will fix in turn the access control
|
||||||
|
* Resolved type hinting error in activity list export
|
||||||
|
|
||||||
## v2.22.2 - 2024-07-03
|
## v2.22.2 - 2024-07-03
|
||||||
### Fixed
|
### Fixed
|
||||||
* Remove scope required for event participation stats
|
* Remove scope required for event participation stats
|
||||||
|
@@ -31,6 +31,7 @@
|
|||||||
"phpoffice/phpspreadsheet": "^1.16",
|
"phpoffice/phpspreadsheet": "^1.16",
|
||||||
"ramsey/uuid-doctrine": "^1.7",
|
"ramsey/uuid-doctrine": "^1.7",
|
||||||
"sensio/framework-extra-bundle": "^5.5",
|
"sensio/framework-extra-bundle": "^5.5",
|
||||||
|
"smalot/pdfparser": "^2.10",
|
||||||
"spomky-labs/base64url": "^2.0",
|
"spomky-labs/base64url": "^2.0",
|
||||||
"symfony/asset": "^5.4",
|
"symfony/asset": "^5.4",
|
||||||
"symfony/browser-kit": "^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,14 +53,13 @@
|
|||||||
"marked": "^12.0.2",
|
"marked": "^12.0.2",
|
||||||
"masonry-layout": "^4.2.2",
|
"masonry-layout": "^4.2.2",
|
||||||
"mime": "^4.0.0",
|
"mime": "^4.0.0",
|
||||||
"swagger-ui": "^4.15.5",
|
"pdfjs-dist": "^4.3.136",
|
||||||
"vis-network": "^9.1.0",
|
"vis-network": "^9.1.0",
|
||||||
"vue": "^3.2.37",
|
"vue": "^3.5.6",
|
||||||
"vue-i18n": "^9.1.6",
|
"vue-i18n": "^9.1.6",
|
||||||
"vue-multiselect": "3.0.0-alpha.2",
|
"vue-multiselect": "3.0.0-alpha.2",
|
||||||
"vue-toast-notification": "^3.1.2",
|
"vue-toast-notification": "^3.1.2",
|
||||||
"vuex": "^4.0.0",
|
"vuex": "^4.0.0"
|
||||||
"bootstrap-icons": "^1.11.3"
|
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
"Firefox ESR"
|
"Firefox ESR"
|
||||||
|
@@ -12,6 +12,8 @@ declare(strict_types=1);
|
|||||||
namespace Chill\ActivityBundle\Repository;
|
namespace Chill\ActivityBundle\Repository;
|
||||||
|
|
||||||
use Chill\ActivityBundle\Entity\Activity;
|
use Chill\ActivityBundle\Entity\Activity;
|
||||||
|
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||||
|
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
|
||||||
use Chill\PersonBundle\Entity\AccompanyingPeriod;
|
use Chill\PersonBundle\Entity\AccompanyingPeriod;
|
||||||
use Chill\PersonBundle\Entity\Person;
|
use Chill\PersonBundle\Entity\Person;
|
||||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
@@ -23,7 +25,7 @@ use Doctrine\Persistence\ManagerRegistry;
|
|||||||
* @method Activity[] findAll()
|
* @method Activity[] findAll()
|
||||||
* @method Activity[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
|
* @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)
|
public function __construct(ManagerRegistry $registry)
|
||||||
{
|
{
|
||||||
@@ -97,4 +99,16 @@ class ActivityRepository extends ServiceEntityRepository
|
|||||||
|
|
||||||
return $qb->getQuery()->getResult();
|
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;
|
||||||
|
}
|
||||||
|
}
|
@@ -42,8 +42,8 @@ class CustomFieldLongChoice extends AbstractCustomField
|
|||||||
$translatableStringHelper = $this->translatableStringHelper;
|
$translatableStringHelper = $this->translatableStringHelper;
|
||||||
$builder->add($customField->getSlug(), Select2ChoiceType::class, [
|
$builder->add($customField->getSlug(), Select2ChoiceType::class, [
|
||||||
'choices' => $entries,
|
'choices' => $entries,
|
||||||
'choice_label' => static fn (?Option $option) => $translatableStringHelper->localize($option->getText()),
|
'choice_label' => static fn (Option $option) => $translatableStringHelper->localize($option->getText()),
|
||||||
'choice_value' => static fn (?Option $key): ?int => $key?->getId(),
|
'choice_value' => static fn (Option $key): ?int => null === $key ? null : $key->getId(),
|
||||||
'multiple' => false,
|
'multiple' => false,
|
||||||
'expanded' => false,
|
'expanded' => false,
|
||||||
'required' => $customField->isRequired(),
|
'required' => $customField->isRequired(),
|
||||||
|
@@ -46,8 +46,11 @@ class CustomFieldsGroup
|
|||||||
#[ORM\GeneratedValue(strategy: 'AUTO')]
|
#[ORM\GeneratedValue(strategy: 'AUTO')]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON)]
|
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON)]
|
||||||
private array|string $name;
|
private $name;
|
||||||
|
|
||||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON)]
|
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON)]
|
||||||
private array $options = [];
|
private array $options = [];
|
||||||
@@ -178,7 +181,7 @@ class CustomFieldsGroup
|
|||||||
*
|
*
|
||||||
* @return CustomFieldsGroup
|
* @return CustomFieldsGroup
|
||||||
*/
|
*/
|
||||||
public function setName(array|string $name)
|
public function setName($name)
|
||||||
{
|
{
|
||||||
$this->name = $name;
|
$this->name = $name;
|
||||||
|
|
||||||
|
@@ -54,12 +54,15 @@ class LoadDocGeneratorTemplate extends AbstractFixture
|
|||||||
];
|
];
|
||||||
|
|
||||||
foreach ($templates as $template) {
|
foreach ($templates as $template) {
|
||||||
$newStoredObj = (new StoredObject())
|
$newStoredObj = (new StoredObject());
|
||||||
->setFilename($template['file']['filename'])
|
|
||||||
->setKeyInfos(json_decode($template['file']['key'], true))
|
$newStoredObj
|
||||||
->setIv(json_decode($template['file']['iv'], true))
|
|
||||||
->setCreatedAt(new \DateTime('today'))
|
->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);
|
$manager->persist($newStoredObj);
|
||||||
|
|
||||||
|
@@ -134,13 +134,11 @@ class Generator implements GeneratorInterface
|
|||||||
$content = Yaml::dump($data, 6);
|
$content = Yaml::dump($data, 6);
|
||||||
/* @var StoredObject $destinationStoredObject */
|
/* @var StoredObject $destinationStoredObject */
|
||||||
$destinationStoredObject
|
$destinationStoredObject
|
||||||
->setType('application/yaml')
|
|
||||||
->setFilename(sprintf('%s_yaml', uniqid('doc_', true)))
|
|
||||||
->setStatus(StoredObject::STATUS_READY)
|
->setStatus(StoredObject::STATUS_READY)
|
||||||
;
|
;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->storedObjectManager->write($destinationStoredObject, $content);
|
$this->storedObjectManager->write($destinationStoredObject, $content, 'application/yaml');
|
||||||
} catch (StoredObjectManagerException $e) {
|
} catch (StoredObjectManagerException $e) {
|
||||||
$destinationStoredObject->addGenerationErrors($e->getMessage());
|
$destinationStoredObject->addGenerationErrors($e->getMessage());
|
||||||
|
|
||||||
@@ -174,13 +172,11 @@ class Generator implements GeneratorInterface
|
|||||||
|
|
||||||
/* @var StoredObject $destinationStoredObject */
|
/* @var StoredObject $destinationStoredObject */
|
||||||
$destinationStoredObject
|
$destinationStoredObject
|
||||||
->setType($template->getFile()->getType())
|
|
||||||
->setFilename(sprintf('%s_odt', uniqid('doc_', true)))
|
|
||||||
->setStatus(StoredObject::STATUS_READY)
|
->setStatus(StoredObject::STATUS_READY)
|
||||||
;
|
;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->storedObjectManager->write($destinationStoredObject, $generatedResource);
|
$this->storedObjectManager->write($destinationStoredObject, $generatedResource, $template->getFile()->getType());
|
||||||
} catch (StoredObjectManagerException $e) {
|
} catch (StoredObjectManagerException $e) {
|
||||||
$destinationStoredObject->addGenerationErrors($e->getMessage());
|
$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\ObjectReadyException;
|
||||||
use Chill\DocGeneratorBundle\Service\Generator\RelatedEntityNotFoundException;
|
use Chill\DocGeneratorBundle\Service\Generator\RelatedEntityNotFoundException;
|
||||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||||
|
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
|
||||||
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
|
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
|
||||||
use Chill\MainBundle\Entity\User;
|
use Chill\MainBundle\Entity\User;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
@@ -39,11 +40,11 @@ class GeneratorTest extends TestCase
|
|||||||
|
|
||||||
public function testSuccessfulGeneration(): void
|
public function testSuccessfulGeneration(): void
|
||||||
{
|
{
|
||||||
$template = (new DocGeneratorTemplate())->setFile($templateStoredObject = (new StoredObject())
|
$templateStoredObject = new StoredObject();
|
||||||
->setType('application/test'));
|
$templateStoredObject->registerVersion(type: 'application/test');
|
||||||
|
$template = (new DocGeneratorTemplate())->setFile($templateStoredObject);
|
||||||
$destinationStoredObject = (new StoredObject())->setStatus(StoredObject::STATUS_PENDING);
|
$destinationStoredObject = (new StoredObject())->setStatus(StoredObject::STATUS_PENDING);
|
||||||
$reflection = new \ReflectionClass($destinationStoredObject);
|
$reflection = new \ReflectionClass($destinationStoredObject);
|
||||||
$reflection->getProperty('id')->setAccessible(true);
|
|
||||||
$reflection->getProperty('id')->setValue($destinationStoredObject, 1);
|
$reflection->getProperty('id')->setValue($destinationStoredObject, 1);
|
||||||
$entity = new class () {};
|
$entity = new class () {};
|
||||||
$data = [];
|
$data = [];
|
||||||
@@ -76,7 +77,14 @@ class GeneratorTest extends TestCase
|
|||||||
|
|
||||||
$storedObjectManager = $this->prophesize(StoredObjectManagerInterface::class);
|
$storedObjectManager = $this->prophesize(StoredObjectManagerInterface::class);
|
||||||
$storedObjectManager->read($templateStoredObject)->willReturn('template');
|
$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(
|
$generator = new Generator(
|
||||||
$contextManagerInterface->reveal(),
|
$contextManagerInterface->reveal(),
|
||||||
@@ -107,8 +115,9 @@ class GeneratorTest extends TestCase
|
|||||||
$this->prophesize(StoredObjectManagerInterface::class)->reveal()
|
$this->prophesize(StoredObjectManagerInterface::class)->reveal()
|
||||||
);
|
);
|
||||||
|
|
||||||
$template = (new DocGeneratorTemplate())->setFile($templateStoredObject = (new StoredObject())
|
$templateStoredObject = new StoredObject();
|
||||||
->setType('application/test'));
|
$templateStoredObject->registerVersion(type: 'application/test');
|
||||||
|
$template = (new DocGeneratorTemplate())->setFile($templateStoredObject);
|
||||||
$destinationStoredObject = (new StoredObject())->setStatus(StoredObject::STATUS_READY);
|
$destinationStoredObject = (new StoredObject())->setStatus(StoredObject::STATUS_READY);
|
||||||
|
|
||||||
$generator->generateDocFromTemplate(
|
$generator->generateDocFromTemplate(
|
||||||
@@ -124,11 +133,11 @@ class GeneratorTest extends TestCase
|
|||||||
{
|
{
|
||||||
$this->expectException(RelatedEntityNotFoundException::class);
|
$this->expectException(RelatedEntityNotFoundException::class);
|
||||||
|
|
||||||
$template = (new DocGeneratorTemplate())->setFile($templateStoredObject = (new StoredObject())
|
$templateStoredObject = new StoredObject();
|
||||||
->setType('application/test'));
|
$templateStoredObject->registerVersion(type: 'application/test');
|
||||||
|
$template = (new DocGeneratorTemplate())->setFile($templateStoredObject);
|
||||||
$destinationStoredObject = (new StoredObject())->setStatus(StoredObject::STATUS_PENDING);
|
$destinationStoredObject = (new StoredObject())->setStatus(StoredObject::STATUS_PENDING);
|
||||||
$reflection = new \ReflectionClass($destinationStoredObject);
|
$reflection = new \ReflectionClass($destinationStoredObject);
|
||||||
$reflection->getProperty('id')->setAccessible(true);
|
|
||||||
$reflection->getProperty('id')->setValue($destinationStoredObject, 1);
|
$reflection->getProperty('id')->setValue($destinationStoredObject, 1);
|
||||||
|
|
||||||
$context = $this->prophesize(DocGeneratorContextInterface::class);
|
$context = $this->prophesize(DocGeneratorContextInterface::class);
|
||||||
|
@@ -58,6 +58,7 @@ final readonly class TempUrlOpenstackGenerator implements TempUrlGeneratorInterf
|
|||||||
?int $expire_delay = null,
|
?int $expire_delay = null,
|
||||||
?int $submit_delay = null,
|
?int $submit_delay = null,
|
||||||
int $max_file_count = 1,
|
int $max_file_count = 1,
|
||||||
|
?string $object_name = null,
|
||||||
): SignedUrlPost {
|
): SignedUrlPost {
|
||||||
$delay = $expire_delay ?? $this->max_expire_delay;
|
$delay = $expire_delay ?? $this->max_expire_delay;
|
||||||
$submit_delay ??= $this->max_submit_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'));
|
$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(
|
$g = new SignedUrlPost(
|
||||||
$url = $this->generateUrl($object_name),
|
$url = $this->generateUrl($object_name),
|
||||||
$expires,
|
$expires,
|
||||||
|
$object_name,
|
||||||
$this->max_post_file_size,
|
$this->max_post_file_size,
|
||||||
$max_file_count,
|
$max_file_count,
|
||||||
$submit_delay,
|
$submit_delay,
|
||||||
@@ -127,7 +131,7 @@ final readonly class TempUrlOpenstackGenerator implements TempUrlGeneratorInterf
|
|||||||
];
|
];
|
||||||
$url = $url.'?'.\http_build_query($args);
|
$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(
|
$this->event_dispatcher->dispatch(
|
||||||
new TempUrlGenerateEvent($signature)
|
new TempUrlGenerateEvent($signature)
|
||||||
@@ -178,21 +182,19 @@ final readonly class TempUrlOpenstackGenerator implements TempUrlGeneratorInterf
|
|||||||
return \hash_hmac('sha512', $body, $this->key, false);
|
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) {
|
if ('POST' === $method) {
|
||||||
return $this->generateSignaturePost($url, $expires);
|
return $this->generateSignaturePost($url, $expires);
|
||||||
}
|
}
|
||||||
|
|
||||||
$path = \parse_url((string) $url, PHP_URL_PATH);
|
$path = \parse_url((string) $url, PHP_URL_PATH);
|
||||||
|
|
||||||
$body = sprintf(
|
$body = sprintf(
|
||||||
"%s\n%s\n%s",
|
"%s\n%s\n%s",
|
||||||
$method,
|
strtoupper($method),
|
||||||
$expires->format('U'),
|
$expires->format('U'),
|
||||||
$path
|
$path
|
||||||
)
|
);
|
||||||
;
|
|
||||||
|
|
||||||
$this->logger->debug(
|
$this->logger->debug(
|
||||||
'generate signature GET',
|
'generate signature GET',
|
||||||
|
@@ -21,6 +21,8 @@ readonly class SignedUrl
|
|||||||
#[Serializer\Groups(['read'])]
|
#[Serializer\Groups(['read'])]
|
||||||
public string $url,
|
public string $url,
|
||||||
public \DateTimeImmutable $expires,
|
public \DateTimeImmutable $expires,
|
||||||
|
#[Serializer\Groups(['read'])]
|
||||||
|
public string $object_name,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
#[Serializer\Groups(['read'])]
|
#[Serializer\Groups(['read'])]
|
||||||
|
@@ -18,6 +18,7 @@ readonly class SignedUrlPost extends SignedUrl
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
string $url,
|
string $url,
|
||||||
\DateTimeImmutable $expires,
|
\DateTimeImmutable $expires,
|
||||||
|
string $object_name,
|
||||||
#[Serializer\Groups(['read'])]
|
#[Serializer\Groups(['read'])]
|
||||||
public int $max_file_size,
|
public int $max_file_size,
|
||||||
#[Serializer\Groups(['read'])]
|
#[Serializer\Groups(['read'])]
|
||||||
@@ -31,6 +32,6 @@ readonly class SignedUrlPost extends SignedUrl
|
|||||||
#[Serializer\Groups(['read'])]
|
#[Serializer\Groups(['read'])]
|
||||||
public string $signature,
|
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 $expire_delay = null,
|
||||||
?int $submit_delay = null,
|
?int $submit_delay = null,
|
||||||
int $max_file_count = 1,
|
int $max_file_count = 1,
|
||||||
|
?string $object_name = null,
|
||||||
): SignedUrlPost;
|
): SignedUrlPost;
|
||||||
|
|
||||||
public function generate(string $method, string $object_name, ?int $expire_delay = null): SignedUrl;
|
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;
|
namespace Chill\DocStoreBundle\Controller;
|
||||||
|
|
||||||
use Chill\DocStoreBundle\AsyncUpload\Exception\TempUrlGeneratorException;
|
|
||||||
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
|
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 Psr\Log\LoggerInterface;
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
@@ -30,62 +32,84 @@ final readonly class AsyncUploadController
|
|||||||
private TempUrlGeneratorInterface $tempUrlGenerator,
|
private TempUrlGeneratorInterface $tempUrlGenerator,
|
||||||
private SerializerInterface $serializer,
|
private SerializerInterface $serializer,
|
||||||
private Security $security,
|
private Security $security,
|
||||||
private LoggerInterface $logger,
|
private LoggerInterface $chillLogger,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
#[Route(path: '/asyncupload/temp_url/generate/{method}', name: 'async_upload.generate_url')]
|
#[Route(path: '/api/1.0/doc-store/async-upload/temp_url/{uuid}/generate/post', name: 'chill_docstore_asyncupload_getsignedurlpost')]
|
||||||
public function getSignedUrl(string $method, Request $request): JsonResponse
|
public function getSignedUrlPost(Request $request, StoredObject $storedObject): JsonResponse
|
||||||
{
|
{
|
||||||
try {
|
if (!$this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $storedObject)) {
|
||||||
switch (strtolower($method)) {
|
throw new AccessDeniedHttpException('not able to edit the given stored object');
|
||||||
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 (null === $object_name) {
|
// we create a dummy version, to generate a filename
|
||||||
return (new JsonResponse((object) [
|
$version = $storedObject->registerVersion();
|
||||||
'message' => 'the object_name is null',
|
|
||||||
]))
|
$p = $this->tempUrlGenerator
|
||||||
->setStatusCode(JsonResponse::HTTP_BAD_REQUEST);
|
->generatePost(
|
||||||
}
|
$request->query->has('expires_delay') ? $request->query->getInt('expires_delay') : null,
|
||||||
$p = $this->tempUrlGenerator->generate(
|
$request->query->has('submit_delay') ? $request->query->getInt('submit_delay') : null,
|
||||||
$method,
|
object_name: $version->getFilename()
|
||||||
$object_name,
|
);
|
||||||
$request->query->has('expires_delay') ? $request->query->getInt('expires_delay') : null
|
|
||||||
);
|
$this->chillLogger->notice('[Privacy Event] a request to upload a document has been generated', [
|
||||||
break;
|
'doc_uuid' => $storedObject->getUuid(),
|
||||||
default:
|
]);
|
||||||
return (new JsonResponse((object) ['message' => 'the method '
|
|
||||||
."{$method} is not valid"]))
|
return new JsonResponse(
|
||||||
->setStatusCode(JsonResponse::HTTP_BAD_REQUEST);
|
$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) {
|
} else {
|
||||||
$this->logger->warning('The client requested a temp url'
|
$filename = $storedObject->getCurrentVersion()->getFilename();
|
||||||
.' 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$this->security->isGranted(AsyncUploadVoter::GENERATE_SIGNATURE, $p)) {
|
$p = $this->tempUrlGenerator->generate(
|
||||||
throw new AccessDeniedHttpException('not allowed to generate this signature');
|
$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(
|
return new JsonResponse(
|
||||||
$this->serializer->serialize($p, 'json', [AbstractNormalizer::GROUPS => ['read']]),
|
$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\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Annotation\Route;
|
use Symfony\Component\Routing\Annotation\Route;
|
||||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||||
|
use Chill\DocStoreBundle\Service\Signature\PDFSignatureZoneParser;
|
||||||
|
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class DocumentPersonController.
|
* Class DocumentPersonController.
|
||||||
@@ -40,6 +42,8 @@ class DocumentPersonController extends AbstractController
|
|||||||
protected TranslatorInterface $translator,
|
protected TranslatorInterface $translator,
|
||||||
protected EventDispatcherInterface $eventDispatcher,
|
protected EventDispatcherInterface $eventDispatcher,
|
||||||
protected AuthorizationHelper $authorizationHelper,
|
protected AuthorizationHelper $authorizationHelper,
|
||||||
|
protected PDFSignatureZoneParser $PDFSignatureZoneParser,
|
||||||
|
protected StoredObjectManagerInterface $storedObjectManagerInterface,
|
||||||
private readonly \Doctrine\Persistence\ManagerRegistry $managerRegistry,
|
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;
|
namespace Chill\DocStoreBundle\Controller;
|
||||||
|
|
||||||
|
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||||
use Chill\MainBundle\CRUD\Controller\ApiController;
|
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\Entity\StoredObject;
|
||||||
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||||
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
|
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
@@ -42,6 +43,7 @@ final readonly class WebdavController
|
|||||||
private \Twig\Environment $engine,
|
private \Twig\Environment $engine,
|
||||||
private StoredObjectManagerInterface $storedObjectManager,
|
private StoredObjectManagerInterface $storedObjectManager,
|
||||||
private Security $security,
|
private Security $security,
|
||||||
|
private EntityManagerInterface $entityManager,
|
||||||
) {
|
) {
|
||||||
$this->requestAnalyzer = new PropfindRequestAnalyzer();
|
$this->requestAnalyzer = new PropfindRequestAnalyzer();
|
||||||
}
|
}
|
||||||
@@ -201,6 +203,8 @@ final readonly class WebdavController
|
|||||||
|
|
||||||
$this->storedObjectManager->write($storedObject, $request->getContent());
|
$this->storedObjectManager->write($storedObject, $request->getContent());
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
return new DavResponse('', Response::HTTP_NO_CONTENT);
|
return new DavResponse('', Response::HTTP_NO_CONTENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -11,14 +11,13 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Chill\DocStoreBundle\DependencyInjection;
|
namespace Chill\DocStoreBundle\DependencyInjection;
|
||||||
|
|
||||||
use Chill\DocStoreBundle\Controller\StoredObjectApiController;
|
|
||||||
use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter;
|
use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter;
|
||||||
use Chill\DocStoreBundle\Security\Authorization\PersonDocumentVoter;
|
use Chill\DocStoreBundle\Security\Authorization\PersonDocumentVoter;
|
||||||
|
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoterInterface;
|
||||||
use Symfony\Component\Config\FileLocator;
|
use Symfony\Component\Config\FileLocator;
|
||||||
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
||||||
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
|
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
|
||||||
use Symfony\Component\DependencyInjection\Loader;
|
use Symfony\Component\DependencyInjection\Loader;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
|
||||||
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
|
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,6 +34,8 @@ class ChillDocStoreExtension extends Extension implements PrependExtensionInterf
|
|||||||
|
|
||||||
$container->setParameter('chill_doc_store', $config);
|
$container->setParameter('chill_doc_store', $config);
|
||||||
|
|
||||||
|
$container->registerForAutoconfiguration(StoredObjectVoterInterface::class)->addTag('stored_object_voter');
|
||||||
|
|
||||||
$loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../config'));
|
$loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../config'));
|
||||||
$loader->load('services.yaml');
|
$loader->load('services.yaml');
|
||||||
$loader->load('services/controller.yaml');
|
$loader->load('services/controller.yaml');
|
||||||
@@ -42,6 +43,7 @@ class ChillDocStoreExtension extends Extension implements PrependExtensionInterf
|
|||||||
$loader->load('services/fixtures.yaml');
|
$loader->load('services/fixtures.yaml');
|
||||||
$loader->load('services/form.yaml');
|
$loader->load('services/form.yaml');
|
||||||
$loader->load('services/templating.yaml');
|
$loader->load('services/templating.yaml');
|
||||||
|
$loader->load('services/security.yaml');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function prepend(ContainerBuilder $container)
|
public function prepend(ContainerBuilder $container)
|
||||||
@@ -49,29 +51,6 @@ class ChillDocStoreExtension extends Extension implements PrependExtensionInterf
|
|||||||
$this->prependRoute($container);
|
$this->prependRoute($container);
|
||||||
$this->prependAuthorization($container);
|
$this->prependAuthorization($container);
|
||||||
$this->prependTwig($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)
|
protected function prependAuthorization(ContainerBuilder $container)
|
||||||
|
@@ -16,10 +16,17 @@ use ChampsLibres\WopiLib\Contract\Entity\Document;
|
|||||||
use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate;
|
use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate;
|
||||||
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
|
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
|
||||||
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
|
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
|
||||||
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
|
use Doctrine\Common\Collections\Order;
|
||||||
|
use Doctrine\Common\Collections\ReadableCollection;
|
||||||
|
use Doctrine\Common\Collections\Selectable;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Ramsey\Uuid\Uuid;
|
use Ramsey\Uuid\Uuid;
|
||||||
use Ramsey\Uuid\UuidInterface;
|
use Ramsey\Uuid\UuidInterface;
|
||||||
|
use Random\RandomException;
|
||||||
use Symfony\Component\Serializer\Annotation as Serializer;
|
use Symfony\Component\Serializer\Annotation as Serializer;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represent a document stored in an object store.
|
* Represent a document stored in an object store.
|
||||||
@@ -28,13 +35,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
|
* 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.
|
* 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\Entity]
|
||||||
#[ORM\Table('chill_doc.stored_object')]
|
#[ORM\Table('stored_object', schema: 'chill_doc')]
|
||||||
#[AsyncFileExists(message: 'The file is not stored properly')]
|
|
||||||
class StoredObject implements Document, TrackCreationInterface
|
class StoredObject implements Document, TrackCreationInterface
|
||||||
{
|
{
|
||||||
use TrackCreationTrait;
|
use TrackCreationTrait;
|
||||||
|
final public const STATUS_EMPTY = 'empty';
|
||||||
final public const STATUS_READY = 'ready';
|
final public const STATUS_READY = 'ready';
|
||||||
final public const STATUS_PENDING = 'pending';
|
final public const STATUS_PENDING = 'pending';
|
||||||
final public const STATUS_FAILURE = 'failure';
|
final public const STATUS_FAILURE = 'failure';
|
||||||
@@ -43,9 +53,11 @@ class StoredObject implements Document, TrackCreationInterface
|
|||||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'datas')]
|
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'datas')]
|
||||||
private array $datas = [];
|
private array $datas = [];
|
||||||
|
|
||||||
#[Serializer\Groups(['write'])]
|
/**
|
||||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT)]
|
* the prefix of each version.
|
||||||
private string $filename = '';
|
*/
|
||||||
|
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])]
|
||||||
|
private string $prefix = '';
|
||||||
|
|
||||||
#[Serializer\Groups(['write'])]
|
#[Serializer\Groups(['write'])]
|
||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
@@ -53,25 +65,10 @@ class StoredObject implements Document, TrackCreationInterface
|
|||||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)]
|
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
/**
|
|
||||||
* @var int[]
|
|
||||||
*/
|
|
||||||
#[Serializer\Groups(['write'])]
|
#[Serializer\Groups(['write'])]
|
||||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'iv')]
|
#[ORM\Column(name: 'title', type: \Doctrine\DBAL\Types\Types::TEXT, options: ['default' => ''])]
|
||||||
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')]
|
|
||||||
private string $title = '';
|
private string $title = '';
|
||||||
|
|
||||||
#[Serializer\Groups(['write'])]
|
|
||||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, name: 'type', options: ['default' => ''])]
|
|
||||||
private string $type = '';
|
|
||||||
|
|
||||||
#[Serializer\Groups(['write'])]
|
#[Serializer\Groups(['write'])]
|
||||||
#[ORM\Column(type: 'uuid', unique: true)]
|
#[ORM\Column(type: 'uuid', unique: true)]
|
||||||
private UuidInterface $uuid;
|
private UuidInterface $uuid;
|
||||||
@@ -94,14 +91,22 @@ class StoredObject implements Document, TrackCreationInterface
|
|||||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])]
|
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])]
|
||||||
private string $generationErrors = '';
|
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
|
* @param StoredObject::STATUS_* $status
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, options: ['default' => 'ready'])]
|
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, options: ['default' => 'ready'])]
|
||||||
private string $status = 'ready',
|
private string $status = 'empty',
|
||||||
) {
|
) {
|
||||||
$this->uuid = Uuid::uuid4();
|
$this->uuid = Uuid::uuid4();
|
||||||
|
$this->versions = new ArrayCollection();
|
||||||
|
$this->prefix = self::generatePrefix();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addGenerationTrial(): self
|
public function addGenerationTrial(): self
|
||||||
@@ -125,14 +130,34 @@ class StoredObject implements Document, TrackCreationInterface
|
|||||||
return \DateTime::createFromImmutable($this->createdAt);
|
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
|
public function getDatas(): array
|
||||||
{
|
{
|
||||||
return $this->datas;
|
return $this->datas;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getPrefix(): string
|
||||||
|
{
|
||||||
|
return $this->prefix;
|
||||||
|
}
|
||||||
|
|
||||||
public function getFilename(): string
|
public function getFilename(): string
|
||||||
{
|
{
|
||||||
return $this->filename;
|
return $this->getCurrentVersion()?->getFilename() ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getGenerationTrialsCounter(): int
|
public function getGenerationTrialsCounter(): int
|
||||||
@@ -145,14 +170,17 @@ class StoredObject implements Document, TrackCreationInterface
|
|||||||
return $this->id;
|
return $this->id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
public function getIv(): array
|
public function getIv(): array
|
||||||
{
|
{
|
||||||
return $this->iv;
|
return $this->getCurrentVersion()?->getIv() ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getKeyInfos(): array
|
public function getKeyInfos(): array
|
||||||
{
|
{
|
||||||
return $this->keyInfos;
|
return $this->getCurrentVersion()?->getKeyInfos() ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -171,14 +199,14 @@ class StoredObject implements Document, TrackCreationInterface
|
|||||||
return $this->status;
|
return $this->status;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getTitle()
|
public function getTitle(): string
|
||||||
{
|
{
|
||||||
return $this->title;
|
return $this->title;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getType()
|
public function getType(): string
|
||||||
{
|
{
|
||||||
return $this->type;
|
return $this->getCurrentVersion()?->getType() ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getUuid(): UuidInterface
|
public function getUuid(): UuidInterface
|
||||||
@@ -209,27 +237,6 @@ class StoredObject implements Document, TrackCreationInterface
|
|||||||
return $this;
|
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
|
* @param StoredObject::STATUS_* $status
|
||||||
*/
|
*/
|
||||||
@@ -247,23 +254,89 @@ class StoredObject implements Document, TrackCreationInterface
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setType(?string $type): self
|
|
||||||
{
|
|
||||||
$this->type = (string) $type;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getTemplate(): ?DocGeneratorTemplate
|
public function getTemplate(): ?DocGeneratorTemplate
|
||||||
{
|
{
|
||||||
return $this->template;
|
return $this->template;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Selectable<int, StoredObjectVersion>&Collection<int, StoredObjectVersion>
|
||||||
|
*/
|
||||||
|
public function getVersions(): Collection&Selectable
|
||||||
|
{
|
||||||
|
return $this->versions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves versions sorted by a given order.
|
||||||
|
*
|
||||||
|
* @param 'ASC'|'DESC' $order the sorting order, default is Order::Ascending
|
||||||
|
*
|
||||||
|
* @return readableCollection&Selectable The ordered collection of versions
|
||||||
|
*/
|
||||||
|
public function getVersionsOrdered(string $order = 'ASC'): ReadableCollection&Selectable
|
||||||
|
{
|
||||||
|
$versions = $this->getVersions()->toArray();
|
||||||
|
|
||||||
|
match ($order) {
|
||||||
|
'ASC' => usort($versions, static fn (StoredObjectVersion $a, StoredObjectVersion $b) => $a->getVersion() <=> $b->getVersion()),
|
||||||
|
'DESC' => usort($versions, static fn (StoredObjectVersion $a, StoredObjectVersion $b) => $b->getVersion() <=> $a->getVersion()),
|
||||||
|
};
|
||||||
|
|
||||||
|
return new ArrayCollection($versions);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasCurrentVersion(): bool
|
||||||
|
{
|
||||||
|
return null !== $this->getCurrentVersion();
|
||||||
|
}
|
||||||
|
|
||||||
public function hasTemplate(): bool
|
public function hasTemplate(): bool
|
||||||
{
|
{
|
||||||
return null !== $this->template;
|
return null !== $this->template;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if there is a version kept before conversion.
|
||||||
|
*
|
||||||
|
* @return bool true if a version is kept before conversion, false otherwise
|
||||||
|
*/
|
||||||
|
public function hasKeptBeforeConversionVersion(): bool
|
||||||
|
{
|
||||||
|
foreach ($this->getVersions() as $version) {
|
||||||
|
foreach ($version->getPointInTimes() as $pointInTime) {
|
||||||
|
if (StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION === $pointInTime->getReason()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the last version of the stored object that was kept before conversion.
|
||||||
|
*
|
||||||
|
* This method iterates through the ordered versions and their respective points
|
||||||
|
* in time to find the most recent version that has a point in time with the reason
|
||||||
|
* 'KEEP_BEFORE_CONVERSION'.
|
||||||
|
*
|
||||||
|
* @return StoredObjectVersion|null the version that was kept before conversion,
|
||||||
|
* or null if not found
|
||||||
|
*/
|
||||||
|
public function getLastKeptBeforeConversionVersion(): ?StoredObjectVersion
|
||||||
|
{
|
||||||
|
foreach ($this->getVersionsOrdered('DESC') as $version) {
|
||||||
|
foreach ($version->getPointInTimes() as $pointInTime) {
|
||||||
|
if (StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION === $pointInTime->getReason()) {
|
||||||
|
return $version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
public function setTemplate(?DocGeneratorTemplate $template): StoredObject
|
public function setTemplate(?DocGeneratorTemplate $template): StoredObject
|
||||||
{
|
{
|
||||||
$this->template = $template;
|
$this->template = $template;
|
||||||
@@ -314,18 +387,65 @@ class StoredObject implements Document, TrackCreationInterface
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function saveHistory(): void
|
public function registerVersion(
|
||||||
{
|
array $iv = [],
|
||||||
if ('' === $this->getFilename()) {
|
array $keyInfos = [],
|
||||||
return;
|
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'][] = [
|
return $version;
|
||||||
'filename' => $this->getFilename(),
|
}
|
||||||
'iv' => $this->getIv(),
|
|
||||||
'key_infos' => $this->getKeyInfos(),
|
public function removeVersion(StoredObjectVersion $storedObjectVersion): void
|
||||||
'type' => $this->getType(),
|
{
|
||||||
'before' => (new \DateTimeImmutable('now'))->getTimestamp(),
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var StoredObject $viewData */
|
/* @var StoredObject $viewData */
|
||||||
if ($viewData->getFilename() !== $forms['stored_object']->getData()['filename']) {
|
$viewData = $forms['stored_object']->getData();
|
||||||
// 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']);
|
|
||||||
|
|
||||||
if (array_key_exists('title', $forms)) {
|
if (array_key_exists('title', $forms)) {
|
||||||
$viewData->setTitle($forms['title']->getData());
|
$viewData->setTitle($forms['title']->getData());
|
||||||
|
@@ -12,7 +12,6 @@ declare(strict_types=1);
|
|||||||
namespace Chill\DocStoreBundle\Form\DataTransformer;
|
namespace Chill\DocStoreBundle\Form\DataTransformer;
|
||||||
|
|
||||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||||
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectNormalizer;
|
|
||||||
use Symfony\Component\Form\DataTransformerInterface;
|
use Symfony\Component\Form\DataTransformerInterface;
|
||||||
use Symfony\Component\Form\Exception\UnexpectedTypeException;
|
use Symfony\Component\Form\Exception\UnexpectedTypeException;
|
||||||
use Symfony\Component\Serializer\SerializerInterface;
|
use Symfony\Component\Serializer\SerializerInterface;
|
||||||
@@ -30,11 +29,7 @@ class StoredObjectDataTransformer implements DataTransformerInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($value instanceof StoredObject) {
|
if ($value instanceof StoredObject) {
|
||||||
return $this->serializer->serialize($value, 'json', [
|
return $this->serializer->serialize($value, 'json');
|
||||||
'groups' => [
|
|
||||||
StoredObjectNormalizer::ADD_DAV_EDIT_LINK_CONTEXT,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new UnexpectedTypeException($value, StoredObject::class);
|
throw new UnexpectedTypeException($value, StoredObject::class);
|
||||||
@@ -46,6 +41,6 @@ class StoredObjectDataTransformer implements DataTransformerInterface
|
|||||||
return null;
|
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;
|
namespace Chill\DocStoreBundle\Repository;
|
||||||
|
|
||||||
use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument;
|
use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument;
|
||||||
|
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||||
use Chill\PersonBundle\Entity\AccompanyingPeriod;
|
use Chill\PersonBundle\Entity\AccompanyingPeriod;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Doctrine\ORM\EntityRepository;
|
use Doctrine\ORM\EntityRepository;
|
||||||
use Doctrine\ORM\QueryBuilder;
|
use Doctrine\ORM\QueryBuilder;
|
||||||
use Doctrine\Persistence\ObjectRepository;
|
use Doctrine\Persistence\ObjectRepository;
|
||||||
|
|
||||||
class AccompanyingCourseDocumentRepository implements ObjectRepository
|
class AccompanyingCourseDocumentRepository implements ObjectRepository, AssociatedEntityToStoredObjectInterface
|
||||||
{
|
{
|
||||||
private readonly EntityRepository $repository;
|
private readonly EntityRepository $repository;
|
||||||
|
|
||||||
public function __construct(private readonly EntityManagerInterface $em)
|
public function __construct(EntityManagerInterface $em)
|
||||||
{
|
{
|
||||||
$this->repository = $em->getRepository(AccompanyingCourseDocument::class);
|
$this->repository = $em->getRepository(AccompanyingCourseDocument::class);
|
||||||
}
|
}
|
||||||
@@ -45,6 +46,16 @@ class AccompanyingCourseDocumentRepository implements ObjectRepository
|
|||||||
return $qb->getQuery()->getSingleScalarResult();
|
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
|
public function find($id): ?AccompanyingCourseDocument
|
||||||
{
|
{
|
||||||
return $this->repository->find($id);
|
return $this->repository->find($id);
|
||||||
@@ -55,7 +66,7 @@ class AccompanyingCourseDocumentRepository implements ObjectRepository
|
|||||||
return $this->repository->findAll();
|
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);
|
return $this->repository->findBy($criteria, $orderBy, $limit, $offset);
|
||||||
}
|
}
|
||||||
@@ -65,7 +76,7 @@ class AccompanyingCourseDocumentRepository implements ObjectRepository
|
|||||||
return $this->findOneBy($criteria);
|
return $this->findOneBy($criteria);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getClassName()
|
public function getClassName(): string
|
||||||
{
|
{
|
||||||
return AccompanyingCourseDocument::class;
|
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;
|
namespace Chill\DocStoreBundle\Repository;
|
||||||
|
|
||||||
use Chill\DocStoreBundle\Entity\PersonDocument;
|
use Chill\DocStoreBundle\Entity\PersonDocument;
|
||||||
|
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Doctrine\ORM\EntityRepository;
|
use Doctrine\ORM\EntityRepository;
|
||||||
use Doctrine\Persistence\ObjectRepository;
|
use Doctrine\Persistence\ObjectRepository;
|
||||||
@@ -19,7 +20,7 @@ use Doctrine\Persistence\ObjectRepository;
|
|||||||
/**
|
/**
|
||||||
* @template ObjectRepository<PersonDocument::class>
|
* @template ObjectRepository<PersonDocument::class>
|
||||||
*/
|
*/
|
||||||
readonly class PersonDocumentRepository implements ObjectRepository
|
readonly class PersonDocumentRepository implements ObjectRepository, AssociatedEntityToStoredObjectInterface
|
||||||
{
|
{
|
||||||
private EntityRepository $repository;
|
private EntityRepository $repository;
|
||||||
|
|
||||||
@@ -53,4 +54,14 @@ readonly class PersonDocumentRepository implements ObjectRepository
|
|||||||
{
|
{
|
||||||
return PersonDocument::class;
|
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 Chill\DocStoreBundle\Entity\StoredObject;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Doctrine\ORM\EntityRepository;
|
use Doctrine\ORM\EntityRepository;
|
||||||
|
use Doctrine\ORM\Query;
|
||||||
|
|
||||||
final readonly class StoredObjectRepository implements StoredObjectRepositoryInterface
|
final readonly class StoredObjectRepository implements StoredObjectRepositoryInterface
|
||||||
{
|
{
|
||||||
@@ -53,6 +54,21 @@ final readonly class StoredObjectRepository implements StoredObjectRepositoryInt
|
|||||||
return $this->repository->findOneBy($criteria);
|
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
|
public function getClassName(): string
|
||||||
{
|
{
|
||||||
return StoredObject::class;
|
return StoredObject::class;
|
||||||
|
@@ -17,4 +17,12 @@ use Doctrine\Persistence\ObjectRepository;
|
|||||||
/**
|
/**
|
||||||
* @extends ObjectRepository<StoredObject>
|
* @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 {makeFetch} from "../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods";
|
||||||
import {PostStoreObjectSignature} from "../../types";
|
import {PostStoreObjectSignature, StoredObject} from "../../types";
|
||||||
|
|
||||||
const algo = 'AES-CBC';
|
const algo = 'AES-CBC';
|
||||||
|
|
||||||
@@ -21,11 +21,22 @@ const createFilename = (): string => {
|
|||||||
return text;
|
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();
|
const params = new URLSearchParams();
|
||||||
params.append('expires_delay', "180");
|
params.append('expires_delay', "180");
|
||||||
params.append('submit_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 suffix = createFilename();
|
||||||
const filename = asyncData.prefix + suffix;
|
const filename = asyncData.prefix + suffix;
|
||||||
const formData = new FormData();
|
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]> => {
|
export const encryptFile = async (originalFile: ArrayBuffer): Promise<[ArrayBuffer, Uint8Array, JsonWebKey]> => {
|
||||||
console.log('encrypt', originalFile);
|
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(16));
|
const iv = crypto.getRandomValues(new Uint8Array(16));
|
||||||
const key = await window.crypto.subtle.generateKey(keyDefinition, true, [ "encrypt", "decrypt" ]);
|
const key = await window.crypto.subtle.generateKey(keyDefinition, true, [ "encrypt", "decrypt" ]);
|
||||||
const exportedKey = await window.crypto.subtle.exportKey('jwk', key);
|
const exportedKey = await window.crypto.subtle.exportKey('jwk', key);
|
@@ -1,7 +1,7 @@
|
|||||||
import {CollectionEventPayload} from "../../../../../ChillMainBundle/Resources/public/module/collection";
|
import {CollectionEventPayload} from "../../../../../ChillMainBundle/Resources/public/module/collection";
|
||||||
import {createApp} from "vue";
|
import {createApp} from "vue";
|
||||||
import DropFileWidget from "../../vuejs/DropFileWidget/DropFileWidget.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";
|
import {_createI18n} from "../../../../../ChillMainBundle/Resources/public/vuejs/_js/i18n";
|
||||||
const i18n = _createI18n({});
|
const i18n = _createI18n({});
|
||||||
|
|
||||||
@@ -30,15 +30,17 @@ const startApp = (divElement: HTMLDivElement, collectionEntry: null|HTMLLIElemen
|
|||||||
DropFileWidget,
|
DropFileWidget,
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
addDocument: function(object: StoredObjectCreated): void {
|
addDocument: function({stored_object, stored_object_version}: {stored_object: StoredObject, stored_object_version: StoredObjectVersion}): void {
|
||||||
console.log('object added', object);
|
console.log('object added', stored_object);
|
||||||
this.$data.existingDoc = object;
|
console.log('version added', stored_object_version);
|
||||||
input_stored_object.value = JSON.stringify(object);
|
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 {
|
removeDocument: function(object: StoredObject): void {
|
||||||
console.log('catch remove document', object);
|
console.log('catch remove document', object);
|
||||||
input_stored_object.value = "";
|
input_stored_object.value = "";
|
||||||
this.$data.existingDoc = null;
|
this.$data.existingDoc = undefined;
|
||||||
console.log('collectionEntry', collectionEntry);
|
console.log('collectionEntry', collectionEntry);
|
||||||
|
|
||||||
if (null !== collectionEntry) {
|
if (null !== collectionEntry) {
|
||||||
|
@@ -0,0 +1,27 @@
|
|||||||
|
import {_createI18n} from "../../../../../ChillMainBundle/Resources/public/vuejs/_js/i18n";
|
||||||
|
import {createApp} from "vue";
|
||||||
|
import DocumentActionButtonsGroup from "../../vuejs/DocumentActionButtonsGroup.vue";
|
||||||
|
import {StoredObject, StoredObjectStatusChange} from "../../types";
|
||||||
|
import {defineComponent} from "vue";
|
||||||
|
import DownloadButton from "../../vuejs/StoredObjectButton/DownloadButton.vue";
|
||||||
|
import ToastPlugin from "vue-toast-notification";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const i18n = _createI18n({});
|
||||||
|
|
||||||
|
window.addEventListener('DOMContentLoaded', function (e) {
|
||||||
|
document.querySelectorAll<HTMLDivElement>('div[data-download-button-single]').forEach((el) => {
|
||||||
|
const storedObject = JSON.parse(el.dataset.storedObject as string) as StoredObject;
|
||||||
|
const title = el.dataset.title as string;
|
||||||
|
const app = createApp({
|
||||||
|
components: {DownloadButton},
|
||||||
|
data() {
|
||||||
|
return {storedObject, title, classes: {btn: true, "btn-outline-primary": true}};
|
||||||
|
},
|
||||||
|
template: '<download-button :stored-object="storedObject" :at-version="storedObject.currentVersion" :classes="classes" :filename="title" :direct-download="true"></download-button>',
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(i18n).use(ToastPlugin).mount(el);
|
||||||
|
});
|
||||||
|
});
|
@@ -3,6 +3,7 @@ import DocumentActionButtonsGroup from "../../vuejs/DocumentActionButtonsGroup.v
|
|||||||
import {createApp} from "vue";
|
import {createApp} from "vue";
|
||||||
import {StoredObject, StoredObjectStatusChange} from "../../types";
|
import {StoredObject, StoredObjectStatusChange} from "../../types";
|
||||||
import {is_object_ready} from "../../vuejs/StoredObjectButton/helpers";
|
import {is_object_ready} from "../../vuejs/StoredObjectButton/helpers";
|
||||||
|
import ToastPlugin from "vue-toast-notification";
|
||||||
|
|
||||||
const i18n = _createI18n({});
|
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,132 @@
|
|||||||
import {DateTime} from "../../../ChillMainBundle/Resources/public/types";
|
import {
|
||||||
|
DateTime,
|
||||||
|
User,
|
||||||
|
} from "../../../ChillMainBundle/Resources/public/types";
|
||||||
|
import {SignedUrlGet} from "./vuejs/StoredObjectButton/helpers";
|
||||||
|
|
||||||
export type StoredObjectStatus = "ready"|"failure"|"pending";
|
export type StoredObjectStatus = "empty" | "ready" | "failure" | "pending";
|
||||||
|
|
||||||
export interface StoredObject {
|
export interface StoredObject {
|
||||||
id: number,
|
id: number;
|
||||||
|
title: string | null;
|
||||||
/**
|
uuid: string;
|
||||||
* filename of the object in the object storage
|
prefix: string;
|
||||||
*/
|
status: StoredObjectStatus;
|
||||||
filename: string,
|
currentVersion:
|
||||||
creationDate: DateTime,
|
| null
|
||||||
datas: object,
|
| StoredObjectVersionCreated
|
||||||
iv: number[],
|
| StoredObjectVersionPersisted;
|
||||||
keyInfos: object,
|
totalVersions: number;
|
||||||
title: string,
|
datas: object;
|
||||||
type: string,
|
/** @deprecated */
|
||||||
uuid: string,
|
creationDate: DateTime;
|
||||||
status: StoredObjectStatus,
|
createdAt: DateTime | null;
|
||||||
|
createdBy: User | null;
|
||||||
|
_permissions: {
|
||||||
|
canEdit: boolean;
|
||||||
|
canSee: boolean;
|
||||||
|
};
|
||||||
_links?: {
|
_links?: {
|
||||||
dav_link?: {
|
dav_link?: {
|
||||||
href: string
|
href: string;
|
||||||
expiration: number
|
expiration: number;
|
||||||
},
|
};
|
||||||
}
|
downloadLink?: SignedUrlGet;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StoredObjectCreated {
|
export interface StoredObjectVersion {
|
||||||
status: "stored_object_created",
|
/**
|
||||||
filename: string,
|
* filename of the object in the object storage
|
||||||
iv: Uint8Array,
|
*/
|
||||||
keyInfos: object,
|
filename: string;
|
||||||
type: 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 {
|
export interface StoredObjectStatusChange {
|
||||||
id: number,
|
id: number;
|
||||||
filename: string,
|
filename: string;
|
||||||
status: StoredObjectStatus,
|
status: StoredObjectStatus;
|
||||||
type: string,
|
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.
|
* Function executed by the WopiEditButton component.
|
||||||
*/
|
*/
|
||||||
export type WopiEditButtonExecutableBeforeLeaveFunction = {
|
export type WopiEditButtonExecutableBeforeLeaveFunction = {
|
||||||
(): Promise<void>
|
(): Promise<void>;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Object containing information for performering a POST request to a swift object store
|
* Object containing information for performering a POST request to a swift object store
|
||||||
*/
|
*/
|
||||||
export interface PostStoreObjectSignature {
|
export interface PostStoreObjectSignature {
|
||||||
method: "POST",
|
method: "POST";
|
||||||
max_file_size: number,
|
max_file_size: number;
|
||||||
max_file_count: 1,
|
max_file_count: 1;
|
||||||
expires: number,
|
expires: number;
|
||||||
submit_delay: 180,
|
submit_delay: 180;
|
||||||
redirect: string,
|
redirect: string;
|
||||||
prefix: string,
|
prefix: string;
|
||||||
url: string,
|
url: string;
|
||||||
signature: 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>
|
<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">
|
<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
|
Actions
|
||||||
</button>
|
</button>
|
||||||
<ul class="dropdown-menu">
|
<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>
|
<wopi-edit-button :stored-object="props.storedObject" :classes="{'dropdown-item': true}" :execute-before-leave="props.executeBeforeLeave"></wopi-edit-button>
|
||||||
</li>
|
</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>
|
<desktop-edit-button :classes="{'dropdown-item': true}" :edit-link="props.davLink" :expiration-link="props.davLinkExpiration"></desktop-edit-button>
|
||||||
</li>
|
</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>
|
<convert-button :stored-object="props.storedObject" :filename="filename" :classes="{'dropdown-item': true}"></convert-button>
|
||||||
</li>
|
</li>
|
||||||
<li v-if="props.canDownload">
|
<li v-if="isDownloadable">
|
||||||
<download-button :stored-object="props.storedObject" :filename="filename" :classes="{'dropdown-item': true}"></download-button>
|
<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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -29,20 +32,21 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
|
||||||
import {onMounted} from "vue";
|
import {computed, onMounted} from "vue";
|
||||||
import ConvertButton from "./StoredObjectButton/ConvertButton.vue";
|
import ConvertButton from "./StoredObjectButton/ConvertButton.vue";
|
||||||
import DownloadButton from "./StoredObjectButton/DownloadButton.vue";
|
import DownloadButton from "./StoredObjectButton/DownloadButton.vue";
|
||||||
import WopiEditButton from "./StoredObjectButton/WopiEditButton.vue";
|
import WopiEditButton from "./StoredObjectButton/WopiEditButton.vue";
|
||||||
import {is_extension_editable, is_extension_viewable, is_object_ready} from "./StoredObjectButton/helpers";
|
import {is_extension_editable, is_extension_viewable, is_object_ready} from "./StoredObjectButton/helpers";
|
||||||
import {
|
import {
|
||||||
StoredObject, StoredObjectCreated,
|
StoredObject,
|
||||||
StoredObjectStatusChange,
|
StoredObjectStatusChange, StoredObjectVersion,
|
||||||
WopiEditButtonExecutableBeforeLeaveFunction
|
WopiEditButtonExecutableBeforeLeaveFunction
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import DesktopEditButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DesktopEditButton.vue";
|
import DesktopEditButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DesktopEditButton.vue";
|
||||||
|
import HistoryButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/HistoryButton.vue";
|
||||||
|
|
||||||
interface DocumentActionButtonsGroupConfig {
|
interface DocumentActionButtonsGroupConfig {
|
||||||
storedObject: StoredObject|StoredObjectCreated,
|
storedObject: StoredObject,
|
||||||
small?: boolean,
|
small?: boolean,
|
||||||
canEdit?: boolean,
|
canEdit?: boolean,
|
||||||
canDownload?: boolean,
|
canDownload?: boolean,
|
||||||
@@ -95,11 +99,48 @@ let tryiesForReady = 0;
|
|||||||
*/
|
*/
|
||||||
const maxTryiesForReady = 120;
|
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 {
|
const checkForReady = function(): void {
|
||||||
if (
|
if (
|
||||||
'ready' === props.storedObject.status
|
'ready' === props.storedObject.status
|
||||||
|
|| 'empty' === props.storedObject.status
|
||||||
|| 'failure' === props.storedObject.status
|
|| 'failure' === props.storedObject.status
|
||||||
|| 'stored_object_created' === props.storedObject.status
|
|
||||||
// stop reloading if the page stays opened for a long time
|
// stop reloading if the page stays opened for a long time
|
||||||
|| tryiesForReady > maxTryiesForReady
|
|| 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">
|
<script setup lang="ts">
|
||||||
|
|
||||||
import {StoredObject, StoredObjectCreated} from "../../types";
|
import {StoredObject, StoredObjectVersionCreated} from "../../types";
|
||||||
import {encryptFile, uploadFile} from "../_components/helper";
|
import {encryptFile, fetchNewStoredObject, uploadVersion} from "../../js/async-upload/uploader";
|
||||||
import {computed, ref, Ref} from "vue";
|
import {computed, ref, Ref} from "vue";
|
||||||
|
import FileIcon from "ChillDocStoreAssets/vuejs/FileIcon.vue";
|
||||||
|
|
||||||
interface DropFileConfig {
|
interface DropFileConfig {
|
||||||
existingDoc?: StoredObjectCreated|StoredObject,
|
existingDoc?: StoredObject,
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<DropFileConfig>();
|
const props = withDefaults(defineProps<DropFileConfig>(), {existingDoc: null});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'addDocument', stored_object: StoredObjectCreated): void,
|
(e: 'addDocument', {stored_object_version: StoredObjectVersionCreated, stored_object: StoredObject}): void,
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const is_dragging: Ref<boolean> = ref(false);
|
const is_dragging: Ref<boolean> = ref(false);
|
||||||
@@ -35,7 +36,6 @@ const onDragLeave = (e: Event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onDrop = (e: DragEvent) => {
|
const onDrop = (e: DragEvent) => {
|
||||||
console.log('on drop', e);
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const files = e.dataTransfer?.files;
|
const files = e.dataTransfer?.files;
|
||||||
@@ -65,7 +65,6 @@ const onZoneClick = (e: Event) => {
|
|||||||
|
|
||||||
const onFileChange = async (event: Event): Promise<void> => {
|
const onFileChange = async (event: Event): Promise<void> => {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
console.log('event triggered', input);
|
|
||||||
|
|
||||||
if (input.files && input.files[0]) {
|
if (input.files && input.files[0]) {
|
||||||
console.log('file added', input.files[0]);
|
console.log('file added', input.files[0]);
|
||||||
@@ -82,21 +81,28 @@ const handleFile = async (file: File): Promise<void> => {
|
|||||||
uploading.value = true;
|
uploading.value = true;
|
||||||
display_filename.value = file.name;
|
display_filename.value = file.name;
|
||||||
const type = file.type;
|
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);
|
// create a stored_object if not exists
|
||||||
|
let stored_object;
|
||||||
const storedObject: StoredObjectCreated = {
|
if (null === props.existingDoc) {
|
||||||
filename: filename,
|
stored_object = await fetchNewStoredObject();
|
||||||
iv,
|
} else {
|
||||||
keyInfos: jsonWebKey,
|
stored_object = props.existingDoc;
|
||||||
type: type,
|
|
||||||
status: "stored_object_created",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
uploading.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,16 +112,7 @@ const handleFile = async (file: File): Promise<void> => {
|
|||||||
<div class="drop-file">
|
<div class="drop-file">
|
||||||
<div v-if="!uploading" :class="{ area: true, dragging: is_dragging}" @click="onZoneClick" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
|
<div v-if="!uploading" :class="{ area: true, dragging: is_dragging}" @click="onZoneClick" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
|
||||||
<p v-if="has_existing_doc" class="file-icon">
|
<p v-if="has_existing_doc" class="file-icon">
|
||||||
<i class="fa fa-file-pdf-o" v-if="props.existingDoc?.type === 'application/pdf'"></i>
|
<file-icon :type="props.existingDoc?.type"></file-icon>
|
||||||
<i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/vnd.oasis.opendocument.text'"></i>
|
|
||||||
<i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'"></i>
|
|
||||||
<i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/msword'"></i>
|
|
||||||
<i class="fa fa-file-excel-o" v-else-if="props.existingDoc?.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'"></i>
|
|
||||||
<i class="fa fa-file-excel-o" v-else-if="props.existingDoc?.type === 'application/vnd.ms-excel'"></i>
|
|
||||||
<i class="fa fa-file-image-o" v-else-if="props.existingDoc?.type === 'image/jpeg'"></i>
|
|
||||||
<i class="fa fa-file-image-o" v-else-if="props.existingDoc?.type === 'image/png'"></i>
|
|
||||||
<i class="fa fa-file-archive-o" v-else-if="props.existingDoc?.type === 'application/x-zip-compressed'"></i>
|
|
||||||
<i class="fa fa-file-code-o" v-else ></i>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p v-if="display_filename !== null" class="display-filename">{{ display_filename }}</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;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
|
p {
|
||||||
|
// require for display in DropFileModal
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
& > .area {
|
& > .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">
|
<script setup lang="ts">
|
||||||
|
|
||||||
import {StoredObject, StoredObjectCreated} from "../../types";
|
import {StoredObject, StoredObjectVersion} from "../../types";
|
||||||
import {computed, ref, Ref} from "vue";
|
import {computed, ref, Ref} from "vue";
|
||||||
import DropFile from "ChillDocStoreAssets/vuejs/DropFileWidget/DropFile.vue";
|
import DropFile from "ChillDocStoreAssets/vuejs/DropFileWidget/DropFile.vue";
|
||||||
import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue";
|
import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue";
|
||||||
|
|
||||||
interface DropFileConfig {
|
interface DropFileConfig {
|
||||||
allowRemove: boolean,
|
allowRemove: boolean,
|
||||||
existingDoc?: StoredObjectCreated|StoredObject,
|
existingDoc?: StoredObject,
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<DropFileConfig>(), {
|
const props = withDefaults(defineProps<DropFileConfig>(), {
|
||||||
@@ -15,8 +15,8 @@ const props = withDefaults(defineProps<DropFileConfig>(), {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'addDocument', stored_object: StoredObjectCreated): void,
|
(e: 'addDocument', {stored_object: StoredObject, stored_object_version: StoredObjectVersion}): void,
|
||||||
(e: 'removeDocument', stored_object: null): void
|
(e: 'removeDocument'): void
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const has_existing_doc = computed<boolean>(() => {
|
const has_existing_doc = computed<boolean>(() => {
|
||||||
@@ -45,14 +45,14 @@ const dav_link_href = computed<string|undefined>(() => {
|
|||||||
return props.existingDoc._links?.dav_link?.href;
|
return props.existingDoc._links?.dav_link?.href;
|
||||||
})
|
})
|
||||||
|
|
||||||
const onAddDocument = (s: StoredObjectCreated): void => {
|
const onAddDocument = ({stored_object, stored_object_version}: {stored_object: StoredObject, stored_object_version: StoredObjectVersion}): void => {
|
||||||
emit('addDocument', s);
|
emit('addDocument', {stored_object, stored_object_version});
|
||||||
}
|
}
|
||||||
|
|
||||||
const onRemoveDocument = (e: Event): void => {
|
const onRemoveDocument = (e: Event): void => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
emit('removeDocument', null);
|
emit('removeDocument');
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</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 {build_convert_link, download_and_decrypt_doc, download_doc} from "./helpers";
|
||||||
import mime from "mime";
|
import mime from "mime";
|
||||||
import {reactive, ref} from "vue";
|
import {reactive, ref} from "vue";
|
||||||
import {StoredObject, StoredObjectCreated} from "../../types";
|
import {StoredObject} from "../../types";
|
||||||
|
|
||||||
interface ConvertButtonConfig {
|
interface ConvertButtonConfig {
|
||||||
storedObject: StoredObject,
|
storedObject: StoredObject,
|
||||||
@@ -54,7 +54,7 @@ function reset_state(): void {
|
|||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="sass">
|
<style scoped lang="scss">
|
||||||
i.fa::before {
|
i.fa::before {
|
||||||
color: var(--bs-dropdown-link-hover-color);
|
color: var(--bs-dropdown-link-hover-color);
|
||||||
}
|
}
|
||||||
|
@@ -63,4 +63,7 @@ const editionUntilFormatted = computed<string>(() => {
|
|||||||
.desktop-edit {
|
.desktop-edit {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
i.fa::before {
|
||||||
|
color: var(--bs-dropdown-link-hover-color);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@@ -1,24 +1,34 @@
|
|||||||
<template>
|
<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()" title="Télécharger">
|
||||||
<i class="fa fa-download"></i>
|
<i class="fa fa-download"></i>
|
||||||
Télécharger
|
<template v-if="displayActionStringInButton">Télécharger</template>
|
||||||
</a>
|
</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>
|
<i class="fa fa-external-link"></i>
|
||||||
Ouvrir
|
<template v-if="displayActionStringInButton">Ouvrir</template>
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import {reactive, ref, nextTick, onMounted} from "vue";
|
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 mime from "mime";
|
||||||
import {StoredObject, StoredObjectCreated} from "../../types";
|
import {StoredObject, StoredObjectVersion} from "../../types";
|
||||||
|
|
||||||
interface DownloadButtonConfig {
|
interface DownloadButtonConfig {
|
||||||
storedObject: StoredObject|StoredObjectCreated,
|
storedObject: StoredObject,
|
||||||
|
atVersion: StoredObjectVersion,
|
||||||
classes: { [k: string]: boolean },
|
classes: { [k: string]: boolean },
|
||||||
filename?: string,
|
filename?: string,
|
||||||
|
/**
|
||||||
|
* if true, display the action string into the button. If false, displays only
|
||||||
|
* the icon
|
||||||
|
*/
|
||||||
|
displayActionStringInButton?: boolean,
|
||||||
|
/**
|
||||||
|
* if true, will download directly the file on load
|
||||||
|
*/
|
||||||
|
directDownload?: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DownloadButtonState {
|
interface DownloadButtonState {
|
||||||
@@ -27,14 +37,19 @@ interface DownloadButtonState {
|
|||||||
href_url: string,
|
href_url: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<DownloadButtonConfig>();
|
const props = withDefaults(defineProps<DownloadButtonConfig>(), {displayActionStringInButton: true, directDownload: false});
|
||||||
const state: DownloadButtonState = reactive({is_ready: false, is_running: false, href_url: "#"});
|
const state: DownloadButtonState = reactive({is_ready: false, is_running: false, href_url: "#"});
|
||||||
|
|
||||||
const open_button = ref<HTMLAnchorElement | null>(null);
|
const open_button = ref<HTMLAnchorElement | null>(null);
|
||||||
|
|
||||||
function buildDocumentName(): string {
|
function buildDocumentName(): string {
|
||||||
const document_name = props.filename || 'document';
|
let document_name = props.filename ?? props.storedObject.title;
|
||||||
const ext = mime.getExtension(props.storedObject.type);
|
|
||||||
|
if ('' === document_name) {
|
||||||
|
document_name = 'document';
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = mime.getExtension(props.atVersion.type);
|
||||||
|
|
||||||
if (null !== ext) {
|
if (null !== ext) {
|
||||||
return document_name + '.' + ext;
|
return document_name + '.' + ext;
|
||||||
@@ -43,9 +58,7 @@ function buildDocumentName(): string {
|
|||||||
return document_name;
|
return document_name;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function download_and_open(event: Event): Promise<void> {
|
async function download_and_open(): Promise<void> {
|
||||||
const button = event.target as HTMLAnchorElement;
|
|
||||||
|
|
||||||
if (state.is_running) {
|
if (state.is_running) {
|
||||||
console.log('state is running, aborting');
|
console.log('state is running, aborting');
|
||||||
return;
|
return;
|
||||||
@@ -58,36 +71,27 @@ async function download_and_open(event: Event): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const urlInfo = build_download_info_link(props.storedObject.filename);
|
|
||||||
let raw;
|
let raw;
|
||||||
|
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
console.error("error while downloading and decrypting document");
|
console.error("error while downloading and decrypting document");
|
||||||
console.error(e);
|
console.error(e);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('document downloading (and decrypting) successfully');
|
|
||||||
|
|
||||||
console.log('creating the url')
|
|
||||||
state.href_url = window.URL.createObjectURL(raw);
|
state.href_url = window.URL.createObjectURL(raw);
|
||||||
console.log('url created', state.href_url);
|
|
||||||
state.is_running = false;
|
state.is_running = false;
|
||||||
state.is_ready = true;
|
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);
|
if (!props.directDownload) {
|
||||||
|
await nextTick();
|
||||||
|
open_button.value?.click();
|
||||||
|
|
||||||
await nextTick();
|
console.log('open button should have been clicked');
|
||||||
console.log('next tick actions');
|
setTimeout(reset_state, 45000);
|
||||||
console.log('openbutton after next tick', open_button.value);
|
}
|
||||||
open_button.value?.click();
|
|
||||||
console.log('open button should have been clicked');
|
|
||||||
|
|
||||||
const timer = setTimeout(reset_state, 45000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset_state(): void {
|
function reset_state(): void {
|
||||||
@@ -95,10 +99,19 @@ function reset_state(): void {
|
|||||||
state.is_ready = false;
|
state.is_ready = false;
|
||||||
state.is_running = false;
|
state.is_running = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.directDownload) {
|
||||||
|
download_and_open();
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="sass">
|
<style scoped lang="scss">
|
||||||
i.fa::before {
|
i.fa::before {
|
||||||
color: var(--bs-dropdown-link-hover-color);
|
color: var(--bs-dropdown-link-hover-color);
|
||||||
}
|
}
|
||||||
|
i.fa {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@@ -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,67 @@
|
|||||||
|
<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"
|
||||||
|
: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,115 @@
|
|||||||
|
<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>(() => props.version.version > 0 && null !== props.version["from-restored"]);
|
||||||
|
|
||||||
|
const isDuplicated = computed<boolean>(() => props.version.version === 0 && 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 || isDuplicated">
|
||||||
|
<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 + 1 }}</span>
|
||||||
|
<span class="badge bg-info" v-if="isDuplicated">Dupliqué depuis un autre document</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<file-icon :type="version.type"></file-icon> <span><strong>#{{ version.version + 1 }}</strong></span> <template v-if="version.createdBy !== null && version.createdAt !== null"><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') }}</template><template v-if="version.createdBy === null && version.createdAt !== null"><strong v-if="version.version == 0">Créé le</strong><strong v-else>modifié le</strong> {{ $d(ISOToDatetime(version.createdAt.datetime8601), 'long') }}</template>
|
||||||
|
</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,47 @@
|
|||||||
|
<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 = () => {
|
||||||
|
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>
|
<script lang="ts" setup>
|
||||||
import WopiEditButton from "./WopiEditButton.vue";
|
import WopiEditButton from "./WopiEditButton.vue";
|
||||||
import {build_wopi_editor_link} from "./helpers";
|
import {build_wopi_editor_link} from "./helpers";
|
||||||
import {StoredObject, StoredObjectCreated, WopiEditButtonExecutableBeforeLeaveFunction} from "../../types";
|
import {StoredObject, WopiEditButtonExecutableBeforeLeaveFunction} from "../../types";
|
||||||
|
|
||||||
interface WopiEditButtonConfig {
|
interface WopiEditButtonConfig {
|
||||||
storedObject: StoredObject,
|
storedObject: StoredObject,
|
||||||
@@ -22,7 +22,6 @@ const props = defineProps<WopiEditButtonConfig>();
|
|||||||
let executed = false;
|
let executed = false;
|
||||||
|
|
||||||
async function beforeLeave(event: Event): Promise<true> {
|
async function beforeLeave(event: Event): Promise<true> {
|
||||||
console.log(executed);
|
|
||||||
if (props.executeBeforeLeave === undefined || executed === true) {
|
if (props.executeBeforeLeave === undefined || executed === true) {
|
||||||
return Promise.resolve(true);
|
return Promise.resolve(true);
|
||||||
}
|
}
|
||||||
@@ -39,7 +38,7 @@ async function beforeLeave(event: Event): Promise<true> {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="sass">
|
<style scoped lang="scss">
|
||||||
i.fa::before {
|
i.fa::before {
|
||||||
color: var(--bs-dropdown-link-hover-color);
|
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([
|
const MIMES_EDIT = new Set([
|
||||||
'application/vnd.ms-powerpoint',
|
'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 {
|
function is_extension_editable(mimeType: string): boolean {
|
||||||
return MIMES_EDIT.has(mimeType);
|
return MIMES_EDIT.has(mimeType);
|
||||||
}
|
}
|
||||||
@@ -109,8 +117,20 @@ function build_convert_link(uuid: string) {
|
|||||||
return `/chill/wopi/convert/${uuid}`;
|
return `/chill/wopi/convert/${uuid}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function build_download_info_link(object_name: string) {
|
function build_download_info_link(storedObject: StoredObject, atVersion: null|StoredObjectVersion): string {
|
||||||
return `/asyncupload/temp_url/generate/GET?object_name=${object_name}`;
|
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) {
|
function build_wopi_editor_link(uuid: string, returnPath?: string) {
|
||||||
@@ -131,43 +151,46 @@ 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';
|
const algo = 'AES-CBC';
|
||||||
// get an url to download the object
|
|
||||||
const downloadInfoResponse = await window.fetch(urlGenerator);
|
|
||||||
|
|
||||||
if (!downloadInfoResponse.ok) {
|
const atVersionToDownload = atVersion ?? storedObject.currentVersion;
|
||||||
throw new Error("error while downloading url " + downloadInfoResponse.status + " " + downloadInfoResponse.statusText);
|
|
||||||
|
if (null === atVersionToDownload) {
|
||||||
|
throw new Error("no version associated to stored object");
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadInfo = await downloadInfoResponse.json() as {url: string};
|
// sometimes, the downloadInfo may be embedded into the storedObject
|
||||||
|
console.log('storedObject', storedObject);
|
||||||
|
let downloadInfo;
|
||||||
|
if (typeof storedObject._links !== 'undefined' && typeof storedObject._links.downloadLink !== 'undefined') {
|
||||||
|
downloadInfo = storedObject._links.downloadLink;
|
||||||
|
} else {
|
||||||
|
downloadInfo = await download_info_link(storedObject, atVersionToDownload);
|
||||||
|
}
|
||||||
|
|
||||||
const rawResponse = await window.fetch(downloadInfo.url);
|
const rawResponse = await window.fetch(downloadInfo.url);
|
||||||
|
|
||||||
if (!rawResponse.ok) {
|
if (!rawResponse.ok) {
|
||||||
throw new Error("error while downloading raw file " + rawResponse.status + " " + rawResponse.statusText);
|
throw new Error("error while downloading raw file " + rawResponse.status + " " + rawResponse.statusText);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (iv.length === 0) {
|
if (atVersionToDownload.iv.length === 0) {
|
||||||
console.log('returning document immediatly');
|
|
||||||
return rawResponse.blob();
|
return rawResponse.blob();
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('start decrypting doc');
|
|
||||||
|
|
||||||
const rawBuffer = await rawResponse.arrayBuffer();
|
const rawBuffer = await rawResponse.arrayBuffer();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const key = await window.crypto.subtle
|
const key = await window.crypto.subtle
|
||||||
.importKey('jwk', keyData, { name: algo }, false, ['decrypt']);
|
.importKey('jwk', atVersionToDownload.keyInfos, { name: algo }, false, ['decrypt']);
|
||||||
console.log('key created');
|
const iv = Uint8Array.from(atVersionToDownload.iv);
|
||||||
const decrypted = await window.crypto.subtle
|
const decrypted = await window.crypto.subtle
|
||||||
.decrypt({ name: algo, iv: iv }, key, rawBuffer);
|
.decrypt({ name: algo, iv: iv }, key, rawBuffer);
|
||||||
console.log('doc decrypted');
|
|
||||||
|
|
||||||
return Promise.resolve(new Blob([decrypted]));
|
return Promise.resolve(new Blob([decrypted]));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('get error while keys and decrypt operations');
|
console.error('encounter error while keys and decrypt operations');
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
|
||||||
throw e;
|
throw e;
|
||||||
@@ -188,7 +211,6 @@ async function is_object_ready(storedObject: StoredObject): Promise<StoredObject
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
build_convert_link,
|
build_convert_link,
|
||||||
build_download_info_link,
|
|
||||||
build_wopi_editor_link,
|
build_wopi_editor_link,
|
||||||
download_and_decrypt_doc,
|
download_and_decrypt_doc,
|
||||||
download_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 %}
|
{% if display_action is defined and display_action == true %}
|
||||||
<ul class="record_actions">
|
<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) %}
|
{% if document.course != null and is_granted('CHILL_PERSON_ACCOMPANYING_PERIOD_SEE', document.course) %}
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ path('chill_person_accompanying_course_index', {'accompanying_period_id': document.course.id}) }}" class="btn btn-show change-icon">
|
<a href="{{ path('chill_person_accompanying_course_index', {'accompanying_period_id': document.course.id}) }}" class="btn btn-show change-icon">
|
||||||
|
@@ -0,0 +1 @@
|
|||||||
|
<div data-download-button-single="data-download-button-single" data-stored-object="{{ document_json|json_encode|escape('html_attr') }}" data-title="{{ title|escape('html_attr') }}"></div>
|
@@ -71,15 +71,7 @@
|
|||||||
</li>
|
</li>
|
||||||
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_SEE_DETAILS', document) %}
|
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_SEE_DETAILS', document) %}
|
||||||
<li>
|
<li>
|
||||||
{{ document.object|chill_document_button_group(document.title, is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_UPDATE', document)) }}
|
{{ document.object|chill_document_button_group(document.title) }}
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="{{ chill_path_add_return_path('accompanying_course_document_show', {'course': accompanyingCourse.id, 'id': document.id}) }}" class="btn btn-show"></a>
|
|
||||||
</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>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_DELETE', document) %}
|
{% 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>
|
<a href="{{ chill_return_path_or('chill_docstore_accompanying_course_document_delete', {'course': accompanyingCourse.id, 'id': document.id}) }}" class="btn btn-delete"></a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% 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 %}
|
{% else %}
|
||||||
{% if is_granted('CHILL_PERSON_DOCUMENT_SEE_DETAILS', document) %}
|
{% if is_granted('CHILL_PERSON_DOCUMENT_SEE_DETAILS', document) %}
|
||||||
<li>
|
<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>
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ path('person_document_show', {'person': person.id, 'id': document.id}) }}" class="btn btn-show"></a>
|
<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>
|
@@ -0,0 +1,43 @@
|
|||||||
|
{% extends '@ChillMain/Workflow/workflow_view_send_public_layout.html.twig' %}
|
||||||
|
|
||||||
|
{% block css %}
|
||||||
|
{{ parent() }}
|
||||||
|
{{ encore_entry_link_tags('mod_document_download_button') }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block js %}
|
||||||
|
{{ parent() }}
|
||||||
|
{{ encore_entry_script_tags('mod_document_download_button') }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block title %}{{ 'workflow.public_link.title'|trans }} - {{ title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block public_content %}
|
||||||
|
<h1>{{ 'workflow.public_link.shared_doc'|trans }}</h1>
|
||||||
|
|
||||||
|
{% set previous = send.entityWorkflowStepChained.previous %}
|
||||||
|
{% if previous is not null %}
|
||||||
|
{% if previous.transitionBy is not null %}
|
||||||
|
<p>{{ 'workflow.public_link.doc_shared_by_at_explanation'|trans({'byUser': previous.transitionBy|chill_entity_render_string( { 'at_date': previous.transitionAt } ), 'at': previous.transitionAt }) }}</p>
|
||||||
|
{% else %}
|
||||||
|
<p>{{ 'workflow.public_link.doc_shared_automatically_at_explanation'|trans({'at': previous.transitionAt}) }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-xs-12 col-sm-6 col-md-4">
|
||||||
|
<div class="card"">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">{{ title }}</h2>
|
||||||
|
<h3>{{ 'workflow.public_link.main_document'|trans }}</h3>
|
||||||
|
|
||||||
|
<ul class="record_actions slim small">
|
||||||
|
<li>
|
||||||
|
{{ storedObject|chill_document_download_only_button(storedObject.title(), false) }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
@@ -70,12 +70,12 @@ class AccompanyingCourseDocumentVoter extends AbstractChillVoter implements Prov
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function supports($attribute, $subject): bool
|
public function supports($attribute, $subject): bool
|
||||||
{
|
{
|
||||||
return $this->voterHelper->supports($attribute, $subject);
|
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) {
|
if (!$token->getUser() instanceof User) {
|
||||||
return false;
|
return false;
|
||||||
|
@@ -12,6 +12,7 @@ declare(strict_types=1);
|
|||||||
namespace Chill\DocStoreBundle\Security\Authorization;
|
namespace Chill\DocStoreBundle\Security\Authorization;
|
||||||
|
|
||||||
use Chill\DocStoreBundle\AsyncUpload\SignedUrl;
|
use Chill\DocStoreBundle\AsyncUpload\SignedUrl;
|
||||||
|
use Chill\DocStoreBundle\Repository\StoredObjectRepository;
|
||||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||||
use Symfony\Component\Security\Core\Security;
|
use Symfony\Component\Security\Core\Security;
|
||||||
@@ -22,6 +23,7 @@ final class AsyncUploadVoter extends Voter
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly Security $security,
|
private readonly Security $security,
|
||||||
|
private readonly StoredObjectRepository $storedObjectRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
protected function supports($attribute, $subject): bool
|
protected function supports($attribute, $subject): bool
|
||||||
@@ -32,10 +34,16 @@ final class AsyncUploadVoter extends Voter
|
|||||||
protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
|
protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
|
||||||
{
|
{
|
||||||
/** @var SignedUrl $subject */
|
/** @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 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;
|
namespace Chill\DocStoreBundle\Security\Authorization;
|
||||||
|
|
||||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
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\Authentication\Token\TokenInterface;
|
||||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||||
|
use Symfony\Component\Security\Core\Security;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Voter for the content of a stored object.
|
* Voter for the content of a stored object.
|
||||||
@@ -23,6 +24,10 @@ use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
|||||||
*/
|
*/
|
||||||
class StoredObjectVoter extends 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
|
protected function supports($attribute, $subject): bool
|
||||||
{
|
{
|
||||||
return StoredObjectRoleEnum::tryFrom($attribute) instanceof StoredObjectRoleEnum
|
return StoredObjectRoleEnum::tryFrom($attribute) instanceof StoredObjectRoleEnum
|
||||||
@@ -32,24 +37,28 @@ class StoredObjectVoter extends Voter
|
|||||||
protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
|
protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
|
||||||
{
|
{
|
||||||
/** @var StoredObject $subject */
|
/** @var StoredObject $subject */
|
||||||
if (
|
$attributeAsEnum = StoredObjectRoleEnum::from($attribute);
|
||||||
!$token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)
|
|
||||||
|| $subject->getUuid()->toString() !== $token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)
|
// Loop through context-specific voters
|
||||||
) {
|
foreach ($this->storedObjectVoters as $storedObjectVoter) {
|
||||||
return false;
|
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)) {
|
// User role-based fallback
|
||||||
return false;
|
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);
|
return false;
|
||||||
$tokenRoleAuthorization =
|
|
||||||
$token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS);
|
|
||||||
|
|
||||||
return match ($askedRole) {
|
|
||||||
StoredObjectRoleEnum::SEE => StoredObjectRoleEnum::EDIT === $tokenRoleAuthorization || StoredObjectRoleEnum::SEE === $tokenRoleAuthorization,
|
|
||||||
StoredObjectRoleEnum::EDIT => StoredObjectRoleEnum::EDIT === $tokenRoleAuthorization,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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;
|
namespace Chill\DocStoreBundle\Serializer\Normalizer;
|
||||||
|
|
||||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
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\DenormalizerInterface;
|
||||||
use Symfony\Component\Serializer\Normalizer\ObjectToPopulateTrait;
|
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
|
class StoredObjectDenormalizer implements DenormalizerInterface
|
||||||
{
|
{
|
||||||
use ObjectToPopulateTrait;
|
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) {
|
if (null === $storedObject) {
|
||||||
return $object;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (false === \array_key_exists('id', $data)) {
|
if (false === is_array($data)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return StoredObject::class === $type;
|
if (array_key_exists('id', $data) || array_key_exists('uuid', $data)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -11,10 +11,13 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Chill\DocStoreBundle\Serializer\Normalizer;
|
namespace Chill\DocStoreBundle\Serializer\Normalizer;
|
||||||
|
|
||||||
|
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
|
||||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||||
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||||
use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface;
|
use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface;
|
||||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
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\NormalizerAwareInterface;
|
||||||
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
|
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
|
||||||
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||||
@@ -27,41 +30,69 @@ use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
|||||||
final class StoredObjectNormalizer implements NormalizerInterface, NormalizerAwareInterface
|
final class StoredObjectNormalizer implements NormalizerInterface, NormalizerAwareInterface
|
||||||
{
|
{
|
||||||
use NormalizerAwareTrait;
|
use NormalizerAwareTrait;
|
||||||
public const ADD_DAV_SEE_LINK_CONTEXT = 'dav-see-link-context';
|
|
||||||
public const ADD_DAV_EDIT_LINK_CONTEXT = 'dav-edit-link-context';
|
/**
|
||||||
|
* when added to the groups, a download link is included in the normalization,
|
||||||
|
* and no webdav links are generated.
|
||||||
|
*/
|
||||||
|
public const DOWNLOAD_LINK_ONLY = 'read:download-link-only';
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly JWTDavTokenProviderInterface $JWTDavTokenProvider,
|
private readonly JWTDavTokenProviderInterface $JWTDavTokenProvider,
|
||||||
private readonly UrlGeneratorInterface $urlGenerator,
|
private readonly UrlGeneratorInterface $urlGenerator,
|
||||||
|
private readonly Security $security,
|
||||||
|
private readonly TempUrlGeneratorInterface $tempUrlGenerator,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function normalize($object, ?string $format = null, array $context = [])
|
public function normalize($object, ?string $format = null, array $context = [])
|
||||||
{
|
{
|
||||||
/** @var StoredObject $object */
|
/** @var StoredObject $object */
|
||||||
$datas = [
|
$datas = [
|
||||||
'datas' => $object->getDatas(),
|
|
||||||
'filename' => $object->getFilename(),
|
|
||||||
'id' => $object->getId(),
|
'id' => $object->getId(),
|
||||||
'iv' => $object->getIv(),
|
'datas' => $object->getDatas(),
|
||||||
'keyInfos' => $object->getKeyInfos(),
|
'prefix' => $object->getPrefix(),
|
||||||
'title' => $object->getTitle(),
|
'title' => $object->getTitle(),
|
||||||
'type' => $object->getType(),
|
'uuid' => $object->getUuid()->toString(),
|
||||||
'uuid' => $object->getUuid(),
|
|
||||||
'status' => $object->getStatus(),
|
'status' => $object->getStatus(),
|
||||||
'createdAt' => $this->normalizer->normalize($object->getCreatedAt(), $format, $context),
|
'createdAt' => $this->normalizer->normalize($object->getCreatedAt(), $format, $context),
|
||||||
'createdBy' => $this->normalizer->normalize($object->getCreatedBy(), $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
|
// deprecated property
|
||||||
$datas['creationDate'] = $datas['createdAt'];
|
$datas['creationDate'] = $datas['createdAt'];
|
||||||
|
|
||||||
$canDavSee = in_array(self::ADD_DAV_SEE_LINK_CONTEXT, $context['groups'] ?? [], true);
|
if (array_key_exists(AbstractNormalizer::GROUPS, $context)) {
|
||||||
$canDavEdit = in_array(self::ADD_DAV_EDIT_LINK_CONTEXT, $context['groups'] ?? [], true);
|
$groupsNormalized = is_array($context[AbstractNormalizer::GROUPS]) ? $context[AbstractNormalizer::GROUPS] : [$context[AbstractNormalizer::GROUPS]];
|
||||||
|
} else {
|
||||||
|
$groupsNormalized = [];
|
||||||
|
}
|
||||||
|
|
||||||
if ($canDavSee || $canDavEdit) {
|
if (in_array(self::DOWNLOAD_LINK_ONLY, $groupsNormalized, true)) {
|
||||||
|
$datas['_permissions'] = [
|
||||||
|
'canSee' => true,
|
||||||
|
'canEdit' => false,
|
||||||
|
];
|
||||||
|
$datas['_links'] = [
|
||||||
|
'downloadLink' => $this->normalizer->normalize($this->tempUrlGenerator->generate('GET', $object->getCurrentVersion()->getFilename(), 180), $format, [AbstractNormalizer::GROUPS => ['read']]),
|
||||||
|
];
|
||||||
|
|
||||||
|
return $datas;
|
||||||
|
}
|
||||||
|
|
||||||
|
$canSee = $this->security->isGranted(StoredObjectRoleEnum::SEE->value, $object);
|
||||||
|
$canEdit = $this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $object);
|
||||||
|
|
||||||
|
$datas['_permissions'] = [
|
||||||
|
'canEdit' => $canEdit,
|
||||||
|
'canSee' => $canSee,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($canSee || $canEdit) {
|
||||||
$accessToken = $this->JWTDavTokenProvider->createToken(
|
$accessToken = $this->JWTDavTokenProvider->createToken(
|
||||||
$object,
|
$object,
|
||||||
$canDavEdit ? StoredObjectRoleEnum::EDIT : StoredObjectRoleEnum::SEE
|
$canEdit ? StoredObjectRoleEnum::EDIT : StoredObjectRoleEnum::SEE
|
||||||
);
|
);
|
||||||
|
|
||||||
$datas['_links'] = [
|
$datas['_links'] = [
|
||||||
@@ -74,7 +105,7 @@ final class StoredObjectNormalizer implements NormalizerInterface, NormalizerAwa
|
|||||||
],
|
],
|
||||||
UrlGeneratorInterface::ABSOLUTE_URL,
|
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,
|
||||||
|
) {}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user