mirror of
https://gitlab.com/Chill-Projet/chill-bundles.git
synced 2025-09-25 08:05:00 +00:00
Compare commits
127 Commits
Author | SHA1 | Date | |
---|---|---|---|
47f4cfddbb
|
|||
e95f9e9846 | |||
1f4bef754d
|
|||
19e34d5dc0
|
|||
fab00f679c
|
|||
791b3776c5
|
|||
6bd38f1a58
|
|||
68d21c9267
|
|||
e7ca89e0c1
|
|||
fc8bc33ba9
|
|||
cbd9489810
|
|||
90b615c5b2
|
|||
5ca222b501 | |||
3e4495dd6e
|
|||
bca0d04201
|
|||
f66ac50571 | |||
b454774836
|
|||
008f344e49
|
|||
90bfd87ec6
|
|||
cc0030c1cd
|
|||
d60ba3ecb2
|
|||
cd5001ac74 | |||
98f47ac512
|
|||
31b541d12f
|
|||
72045ce082
|
|||
0bfb3de465
|
|||
9ec4c77fb7
|
|||
77c53972c8
|
|||
350d991a85
|
|||
0ce9cdd07a
|
|||
1993fac1c4
|
|||
83883567a2
|
|||
29d57934a1
|
|||
f43d79c940
|
|||
be730679c8
|
|||
f62f1891d8
|
|||
ebb856fe85
|
|||
61877e0157
|
|||
4c3f082163
|
|||
35109133f6
|
|||
a220dad83b
|
|||
9eb571549b
|
|||
db8257d230 | |||
bce93efe83 | |||
06401af801 | |||
ea1d4c48f2
|
|||
|
33cba27dd4 | ||
27b0ec0ae7 | |||
9f141468c7
|
|||
56d173046d
|
|||
059e4a0acd
|
|||
111a21fcec
|
|||
775535e683
|
|||
47a928a6cd
|
|||
0dd58cebec
|
|||
4cff706306
|
|||
fca929f56f
|
|||
8d44bb2c32
|
|||
a57e6c0cc9
|
|||
3fe870ba71
|
|||
6f6683f549
|
|||
146e0090fb
|
|||
a7ec7c9f37 | |||
c9e13be736 | |||
b9b342fe44 | |||
31f29f0bc5 | |||
0bc9fff825 | |||
25f93e8a89 | |||
4e0d8e4def | |||
1ecc825945 | |||
addc623add | |||
1b96deb4ee | |||
f510acd170 | |||
835409cb94 | |||
2121b3ef28 | |||
6c9101c167 | |||
b46883fe36 | |||
8d58805abd | |||
c3a799cb7d | |||
bc683b28d6 | |||
d91b1a70bf | |||
853014d8d2 | |||
ad6154a1e4 | |||
50c04382ef | |||
d62e9ce269 | |||
2149ef1cb4 | |||
d15fbadd27 | |||
fbbf421d8b | |||
fe695f1a14 | |||
d0ec6f9819 | |||
0b739fda34 | |||
9b8e143855 | |||
a533ab77ed | |||
087032881b | |||
82667a1c0f | |||
db6408926b | |||
f5c7ab6ef0 | |||
a13ada2937 | |||
3be8a39a1a | |||
d7eb1e01da | |||
bd62202d22 | |||
0e3de2ec8a | |||
aa2a398f9e | |||
33187448a0 | |||
a4482ad28b | |||
8ed5a023e8 | |||
653ac1d62b | |||
499009ac43 | |||
192b161e78 | |||
1b1f355123 | |||
39a863448c | |||
0c1a4a5f59 | |||
6f358ee1a9 | |||
0f36b9349b | |||
d18cc29acf | |||
4220d1a2d3 | |||
1ae27152c2 | |||
b946f8c10a | |||
62d6106801 | |||
89fb87f71f | |||
1337360690 | |||
9324c33caf | |||
c2dd9ef676 | |||
a42d7231d9 | |||
38deaf6f36 | |||
04fc5b6614 | |||
384b2be577 |
21
.changes/v2.20.0.md
Normal file
21
.changes/v2.20.0.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
## v2.20.0 - 2024-06-05
|
||||||
|
### Fixed
|
||||||
|
* ([#170](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/170)) Display agents traitants instead of accompanying period referrer in export list social actions.
|
||||||
|
* Added translations for choices of durations (> 5 hours)
|
||||||
|
### Feature
|
||||||
|
* ([#145](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/145)) Allow to open documents in LibreOffice locally (need configuration within security);
|
||||||
|
|
||||||
|
This endpoint should be added to make the endpoint works properly:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
security:
|
||||||
|
firewalls:
|
||||||
|
dav:
|
||||||
|
pattern: ^/dav
|
||||||
|
provider: chain_provider
|
||||||
|
stateless: true
|
||||||
|
guard:
|
||||||
|
authenticators:
|
||||||
|
- Chill\DocStoreBundle\Security\Guard\JWTOnDavUrlAuthenticator
|
||||||
|
|
||||||
|
```
|
3
.changes/v2.20.1.md
Normal file
3
.changes/v2.20.1.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
## v2.20.1 - 2024-06-05
|
||||||
|
### Fixed
|
||||||
|
* Do not allow StoredObjectCreated for edit and convert buttons
|
31
.changes/v2.21.0.md
Normal file
31
.changes/v2.21.0.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
## v2.21.0 - 2024-06-18
|
||||||
|
### Feature
|
||||||
|
* Add flash menu buttons in search results, to open directly a new calendar, or a new activity in an accompanying period
|
||||||
|
* ([#122](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/122)) Improve the list of calendar in the search results: make all calendar clicable, and display a list of calendars
|
||||||
|
* ([#282](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/282)) [export] add start date and end date on filters "filter course by referrer job" and "filter course by referrer scope"
|
||||||
|
* ([#282](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/282)) [export] the aggregator "Group by referrer" now accept a date range.
|
||||||
|
* ([#282](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/282)) [export] add date range on "group course by referrer's scope"
|
||||||
|
* ([#282](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/282)) [export] add date range on "group course by referrer's jobs"
|
||||||
|
* ([#168](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/168) In the UX, display user job and service at the time when he performs an action:
|
||||||
|
now, the job and service is shown:
|
||||||
|
* at the activity's date,
|
||||||
|
* at the appointment's date,
|
||||||
|
* when the user is marked as referrer for an accompanying period work,
|
||||||
|
* when the user apply a transition in a workflow,
|
||||||
|
* when the user updates or creates "something" ("created/updated by ... at ..."),
|
||||||
|
* or when he wrote a comment,
|
||||||
|
* …
|
||||||
|
|
||||||
|
### Traduction francophone
|
||||||
|
* Ajout d'un menu "flash" dans les résultats de recherche, pour créer un rendez-vous ou un échange dans un parcours depuis les résultats de recherche;
|
||||||
|
* Améliore la liste des rendez-vous dans les résultats de recherche: les rendez-vous sont cliquables;
|
||||||
|
* [exports] Ajout d'intervalles de dates pour des filtres et regroupements des parcours par référent, métier du référent, service du référent;
|
||||||
|
* Affiche le métier et le service des utilisateurs à la date à laquelle il a exécuté une action. Le métier et le service est affiché:
|
||||||
|
* à la date d'un échange,
|
||||||
|
* au jour d'un rendez-vous,
|
||||||
|
* quand l'utilisateur est devenu référent d'un parcours d'accompagnement,
|
||||||
|
* quand il a appliqué une transition sur un workflow,
|
||||||
|
* quand il a mise à jour ou créé une fiche, dans les mentions "créé / mise à jour par ..., le ...",
|
||||||
|
* quand il a mis à jour un commentaire,
|
||||||
|
* …
|
||||||
|
|
58
CHANGELOG.md
58
CHANGELOG.md
@@ -6,6 +6,64 @@ 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).
|
||||||
|
|
||||||
|
|
||||||
|
## v2.21.0 - 2024-06-18
|
||||||
|
### Feature
|
||||||
|
* Add flash menu buttons in search results, to open directly a new calendar, or a new activity in an accompanying period
|
||||||
|
* ([#122](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/122)) Improve the list of calendar in the search results: make all calendar clicable, and display a list of calendars
|
||||||
|
* ([#282](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/282)) [export] add start date and end date on filters "filter course by referrer job" and "filter course by referrer scope"
|
||||||
|
* ([#282](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/282)) [export] the aggregator "Group by referrer" now accept a date range.
|
||||||
|
* ([#282](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/282)) [export] add date range on "group course by referrer's scope"
|
||||||
|
* ([#282](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/282)) [export] add date range on "group course by referrer's jobs"
|
||||||
|
* ([#168](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/168) In the UX, display user job and service at the time when he performs an action:
|
||||||
|
now, the job and service is shown:
|
||||||
|
* at the activity's date,
|
||||||
|
* at the appointment's date,
|
||||||
|
* when the user is marked as referrer for an accompanying period work,
|
||||||
|
* when the user apply a transition in a workflow,
|
||||||
|
* when the user updates or creates "something" ("created/updated by ... at ..."),
|
||||||
|
* or when he wrote a comment,
|
||||||
|
* …
|
||||||
|
|
||||||
|
### Traduction francophone
|
||||||
|
* Ajout d'un menu "flash" dans les résultats de recherche, pour créer un rendez-vous ou un échange dans un parcours depuis les résultats de recherche;
|
||||||
|
* Améliore la liste des rendez-vous dans les résultats de recherche: les rendez-vous sont cliquables;
|
||||||
|
* [exports] Ajout d'intervalles de dates pour des filtres et regroupements des parcours par référent, métier du référent, service du référent;
|
||||||
|
* Affiche le métier et le service des utilisateurs à la date à laquelle il a exécuté une action. Le métier et le service est affiché:
|
||||||
|
* à la date d'un échange,
|
||||||
|
* au jour d'un rendez-vous,
|
||||||
|
* quand l'utilisateur est devenu référent d'un parcours d'accompagnement,
|
||||||
|
* quand il a appliqué une transition sur un workflow,
|
||||||
|
* quand il a mise à jour ou créé une fiche, dans les mentions "créé / mise à jour par ..., le ...",
|
||||||
|
* quand il a mis à jour un commentaire,
|
||||||
|
* …
|
||||||
|
|
||||||
|
|
||||||
|
## v2.20.1 - 2024-06-05
|
||||||
|
### Fixed
|
||||||
|
* Do not allow StoredObjectCreated for edit and convert buttons
|
||||||
|
|
||||||
|
## v2.20.0 - 2024-06-05
|
||||||
|
### Fixed
|
||||||
|
* ([#170](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/170)) Display agents traitants instead of accompanying period referrer in export list social actions.
|
||||||
|
* Added translations for choices of durations (> 5 hours)
|
||||||
|
### Feature
|
||||||
|
* ([#145](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/145)) Allow to open documents in LibreOffice locally (need configuration within security);
|
||||||
|
|
||||||
|
This endpoint should be added to make the endpoint works properly:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
security:
|
||||||
|
firewalls:
|
||||||
|
dav:
|
||||||
|
pattern: ^/dav
|
||||||
|
provider: chain_provider
|
||||||
|
stateless: true
|
||||||
|
guard:
|
||||||
|
authenticators:
|
||||||
|
- Chill\DocStoreBundle\Security\Guard\JWTOnDavUrlAuthenticator
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
## v2.19.0 - 2024-05-14
|
## v2.19.0 - 2024-05-14
|
||||||
### Feature
|
### Feature
|
||||||
* ([#197](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/197)) Make the script which subscribe to microsoft calendars changes more tolerant to errors or missing configuration on the microsoft side
|
* ([#197](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/197)) Make the script which subscribe to microsoft calendars changes more tolerant to errors or missing configuration on the microsoft side
|
||||||
|
@@ -9,6 +9,7 @@
|
|||||||
],
|
],
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
|
"ext-dom": "*",
|
||||||
"ext-json": "*",
|
"ext-json": "*",
|
||||||
"ext-openssl": "*",
|
"ext-openssl": "*",
|
||||||
"ext-redis": "*",
|
"ext-redis": "*",
|
||||||
@@ -75,7 +76,7 @@
|
|||||||
"phpunit/phpunit": ">= 7.5",
|
"phpunit/phpunit": ">= 7.5",
|
||||||
"psalm/plugin-phpunit": "^0.18.4",
|
"psalm/plugin-phpunit": "^0.18.4",
|
||||||
"psalm/plugin-symfony": "^4.0.2",
|
"psalm/plugin-symfony": "^4.0.2",
|
||||||
"rector/rector": "^0.17.7",
|
"rector/rector": "^1.1.0",
|
||||||
"symfony/debug-bundle": "^5.1",
|
"symfony/debug-bundle": "^5.1",
|
||||||
"symfony/dotenv": "^4.4",
|
"symfony/dotenv": "^4.4",
|
||||||
"symfony/maker-bundle": "^1.20",
|
"symfony/maker-bundle": "^1.20",
|
||||||
|
@@ -42,11 +42,13 @@
|
|||||||
"@fullcalendar/vue3": "^6.1.4",
|
"@fullcalendar/vue3": "^6.1.4",
|
||||||
"@popperjs/core": "^2.9.2",
|
"@popperjs/core": "^2.9.2",
|
||||||
"@types/leaflet": "^1.9.3",
|
"@types/leaflet": "^1.9.3",
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
"dropzone": "^5.7.6",
|
"dropzone": "^5.7.6",
|
||||||
"es6-promise": "^4.2.8",
|
"es6-promise": "^4.2.8",
|
||||||
"leaflet": "^1.7.1",
|
"leaflet": "^1.7.1",
|
||||||
|
"marked": "^12.0.2",
|
||||||
"masonry-layout": "^4.2.2",
|
"masonry-layout": "^4.2.2",
|
||||||
"mime": "^3.0.0",
|
"mime": "^4.0.0",
|
||||||
"swagger-ui": "^4.15.5",
|
"swagger-ui": "^4.15.5",
|
||||||
"vis-network": "^9.1.0",
|
"vis-network": "^9.1.0",
|
||||||
"vue": "^3.2.37",
|
"vue": "^3.2.37",
|
||||||
|
6
phpstan-baseline-2024-05.neon
Normal file
6
phpstan-baseline-2024-05.neon
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
parameters:
|
||||||
|
ignoreErrors:
|
||||||
|
-
|
||||||
|
message: "#^Parameter \\#1 \\$records of method League\\\\Csv\\\\Writer\\:\\:insertAll\\(\\) expects iterable\\<array\\<float\\|int\\|string\\|Stringable\\|null\\>\\>, iterable\\<array\\<string, bool\\|int\\|string\\>\\> given\\.$#"
|
||||||
|
count: 1
|
||||||
|
path: src/Bundle/ChillMainBundle/Controller/UserExportController.php
|
@@ -31,4 +31,5 @@ includes:
|
|||||||
- phpstan-baseline-level-3.neon
|
- phpstan-baseline-level-3.neon
|
||||||
- phpstan-baseline-level-4.neon
|
- phpstan-baseline-level-4.neon
|
||||||
- phpstan-baseline-level-5.neon
|
- phpstan-baseline-level-5.neon
|
||||||
|
- phpstan-baseline-2024-05.neon
|
||||||
|
|
||||||
|
@@ -45,9 +45,6 @@ return static function (RectorConfig $rectorConfig): void {
|
|||||||
|
|
||||||
// skip some path...
|
// skip some path...
|
||||||
$rectorConfig->skip([
|
$rectorConfig->skip([
|
||||||
// we need to discuss this: are we going to have FALSE in tests instead of an error ?
|
|
||||||
\Rector\Php71\Rector\FuncCall\CountOnNullRector::class,
|
|
||||||
|
|
||||||
// we must adapt service definition
|
// we must adapt service definition
|
||||||
\Rector\Symfony\Symfony28\Rector\MethodCall\GetToConstructorInjectionRector::class,
|
\Rector\Symfony\Symfony28\Rector\MethodCall\GetToConstructorInjectionRector::class,
|
||||||
\Rector\Symfony\Symfony34\Rector\Closure\ContainerGetNameToTypeInTestsRector::class,
|
\Rector\Symfony\Symfony34\Rector\Closure\ContainerGetNameToTypeInTestsRector::class,
|
||||||
|
@@ -15,11 +15,10 @@ use Chill\ActivityBundle\Entity\Activity;
|
|||||||
use Chill\ActivityBundle\Entity\ActivityPresence;
|
use Chill\ActivityBundle\Entity\ActivityPresence;
|
||||||
use Chill\ActivityBundle\Form\Type\PickActivityReasonType;
|
use Chill\ActivityBundle\Form\Type\PickActivityReasonType;
|
||||||
use Chill\ActivityBundle\Security\Authorization\ActivityVoter;
|
use Chill\ActivityBundle\Security\Authorization\ActivityVoter;
|
||||||
use Chill\DocStoreBundle\Form\StoredObjectType;
|
use Chill\DocStoreBundle\Form\CollectionStoredObjectType;
|
||||||
use Chill\MainBundle\Entity\Center;
|
use Chill\MainBundle\Entity\Center;
|
||||||
use Chill\MainBundle\Entity\Location;
|
use Chill\MainBundle\Entity\Location;
|
||||||
use Chill\MainBundle\Entity\User;
|
use Chill\MainBundle\Entity\User;
|
||||||
use Chill\MainBundle\Form\Type\ChillCollectionType;
|
|
||||||
use Chill\MainBundle\Form\Type\ChillDateType;
|
use Chill\MainBundle\Form\Type\ChillDateType;
|
||||||
use Chill\MainBundle\Form\Type\CommentType;
|
use Chill\MainBundle\Form\Type\CommentType;
|
||||||
use Chill\MainBundle\Form\Type\PickUserDynamicType;
|
use Chill\MainBundle\Form\Type\PickUserDynamicType;
|
||||||
@@ -276,16 +275,9 @@ class ActivityType extends AbstractType
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($activityType->isVisible('documents')) {
|
if ($activityType->isVisible('documents')) {
|
||||||
$builder->add('documents', ChillCollectionType::class, [
|
$builder->add('documents', CollectionStoredObjectType::class, [
|
||||||
'entry_type' => StoredObjectType::class,
|
|
||||||
'label' => $activityType->getLabel('documents'),
|
'label' => $activityType->getLabel('documents'),
|
||||||
'required' => $activityType->isRequired('documents'),
|
'required' => $activityType->isRequired('documents'),
|
||||||
'allow_add' => true,
|
|
||||||
'allow_delete' => true,
|
|
||||||
'button_add_label' => 'activity.Insert a document',
|
|
||||||
'button_remove_label' => 'activity.Remove a document',
|
|
||||||
'empty_collection_explain' => 'No documents',
|
|
||||||
'entry_options' => ['has_title' => true],
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\ActivityBundle\Menu;
|
||||||
|
|
||||||
|
use Chill\ActivityBundle\Security\Authorization\ActivityVoter;
|
||||||
|
use Chill\MainBundle\Routing\LocalMenuBuilderInterface;
|
||||||
|
use Knp\Menu\MenuItem;
|
||||||
|
use Symfony\Component\Security\Core\Security;
|
||||||
|
|
||||||
|
final readonly class AccompanyingCourseQuickMenuBuilder implements LocalMenuBuilderInterface
|
||||||
|
{
|
||||||
|
public function __construct(private Security $security)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getMenuIds(): array
|
||||||
|
{
|
||||||
|
return ['accompanying_course_quick_menu'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function buildMenu($menuId, MenuItem $menu, array $parameters)
|
||||||
|
{
|
||||||
|
/** @var \Chill\PersonBundle\Entity\AccompanyingPeriod $accompanyingCourse */
|
||||||
|
$accompanyingCourse = $parameters['accompanying-course'];
|
||||||
|
|
||||||
|
if ($this->security->isGranted(ActivityVoter::CREATE, $accompanyingCourse)) {
|
||||||
|
$menu
|
||||||
|
->addChild('Create a new activity in accompanying course', [
|
||||||
|
'route' => 'chill_activity_activity_new',
|
||||||
|
'routeParameters' => [
|
||||||
|
// 'activityType_id' => '',
|
||||||
|
'accompanying_period_id' => $accompanyingCourse->getId(),
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->setExtras([
|
||||||
|
'order' => 10,
|
||||||
|
'icon' => 'plus',
|
||||||
|
])
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -68,7 +68,7 @@
|
|||||||
<div class="wl-col title"><h3>{{ 'Referrer'|trans }}</h3></div>
|
<div class="wl-col title"><h3>{{ 'Referrer'|trans }}</h3></div>
|
||||||
<div class="wl-col list">
|
<div class="wl-col list">
|
||||||
<p class="wl-item">
|
<p class="wl-item">
|
||||||
<span class="badge-user">{{ activity.user|chill_entity_render_box }}</span>
|
<span class="badge-user">{{ activity.user|chill_entity_render_box({'at_date': activity.date}) }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -87,7 +87,8 @@
|
|||||||
<li>
|
<li>
|
||||||
{% if bloc.type == 'user' %}
|
{% if bloc.type == 'user' %}
|
||||||
<span class="badge-user">
|
<span class="badge-user">
|
||||||
{{ item|chill_entity_render_box({'render': 'raw', 'addAltNames': false }) }}
|
hello
|
||||||
|
{{ item|chill_entity_render_box({'render': 'raw', 'addAltNames': false, 'at_date': entity.date }) }}
|
||||||
</span>
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ _self.insert_onthefly(bloc.type, item) }}
|
{{ _self.insert_onthefly(bloc.type, item) }}
|
||||||
@@ -114,7 +115,7 @@
|
|||||||
<li>
|
<li>
|
||||||
{% if bloc.type == 'user' %}
|
{% if bloc.type == 'user' %}
|
||||||
<span class="badge-user">
|
<span class="badge-user">
|
||||||
{{ item|chill_entity_render_box({'render': 'raw', 'addAltNames': false }) }}
|
{{ item|chill_entity_render_box({'render': 'raw', 'addAltNames': false, 'at_date': entity.date }) }}
|
||||||
</span>
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ _self.insert_onthefly(bloc.type, item) }}
|
{{ _self.insert_onthefly(bloc.type, item) }}
|
||||||
@@ -142,7 +143,7 @@
|
|||||||
<span class="wl-item">
|
<span class="wl-item">
|
||||||
{% if bloc.type == 'user' %}
|
{% if bloc.type == 'user' %}
|
||||||
<span class="badge-user">
|
<span class="badge-user">
|
||||||
{{ item|chill_entity_render_box({'render': 'raw', 'addAltNames': false }) }}
|
{{ item|chill_entity_render_box({'render': 'raw', 'addAltNames': false, 'at_date': entity.date }) }}
|
||||||
{%- if context == 'calendar_accompanyingCourse' or context == 'calendar_person' %}
|
{%- if context == 'calendar_accompanyingCourse' or context == 'calendar_person' %}
|
||||||
{% set invite = entity.inviteForUser(item) %}
|
{% set invite = entity.inviteForUser(item) %}
|
||||||
{% if invite is not null %}
|
{% if invite is not null %}
|
||||||
|
@@ -92,7 +92,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{%- if edit_form.documents is defined -%}
|
{%- if edit_form.documents is defined -%}
|
||||||
{{ form_row(edit_form.documents) }}
|
{{ form_label(edit_form.documents) }}
|
||||||
|
{{ form_errors(edit_form.documents) }}
|
||||||
|
{{ form_widget(edit_form.documents) }}
|
||||||
<div data-docgen-template-picker="data-docgen-template-picker" data-entity-class="Chill\ActivityBundle\Entity\Activity" data-entity-id="{{ entity.id }}"></div>
|
<div data-docgen-template-picker="data-docgen-template-picker" data-entity-class="Chill\ActivityBundle\Entity\Activity" data-entity-id="{{ entity.id }}"></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -127,4 +129,4 @@
|
|||||||
|
|
||||||
{% block css %}
|
{% block css %}
|
||||||
{{ encore_entry_link_tags('mod_pickentity_type') }}
|
{{ encore_entry_link_tags('mod_pickentity_type') }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@@ -41,7 +41,7 @@
|
|||||||
{% if activity.user and t.userVisible %}
|
{% if activity.user and t.userVisible %}
|
||||||
<li>
|
<li>
|
||||||
<span class="item-key">{{ 'Referrer'|trans ~ ': ' }}</span>
|
<span class="item-key">{{ 'Referrer'|trans ~ ': ' }}</span>
|
||||||
<span class="badge-user">{{ activity.user|chill_entity_render_box }}</span>
|
<span class="badge-user">{{ activity.user|chill_entity_render_box({'at_date': activity.date}) }}</span>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
@@ -37,7 +37,7 @@
|
|||||||
{%- if entity.user is not null %}
|
{%- if entity.user is not null %}
|
||||||
<dt class="inline">{{ 'Referrer'|trans|capitalize }}</dt>
|
<dt class="inline">{{ 'Referrer'|trans|capitalize }}</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<span class="badge-user">{{ entity.user|chill_entity_render_box }}</span>
|
<span class="badge-user">{{ entity.user|chill_entity_render_box({'at_date': entity.date}) }}</span>
|
||||||
</dd>
|
</dd>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
@@ -145,7 +145,7 @@ class ActivityVoter extends AbstractChillVoter implements ProvideRoleHierarchyIn
|
|||||||
throw new \RuntimeException('Could not determine context of activity.');
|
throw new \RuntimeException('Could not determine context of activity.');
|
||||||
}
|
}
|
||||||
} elseif ($subject instanceof AccompanyingPeriod) {
|
} elseif ($subject instanceof AccompanyingPeriod) {
|
||||||
if (AccompanyingPeriod::STEP_CLOSED === $subject->getStep()) {
|
if (AccompanyingPeriod::STEP_CLOSED === $subject->getStep() || AccompanyingPeriod::STEP_DRAFT === $subject->getStep()) {
|
||||||
if (\in_array($attribute, [self::UPDATE, self::CREATE, self::DELETE], true)) {
|
if (\in_array($attribute, [self::UPDATE, self::CREATE, self::DELETE], true)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@@ -60,7 +60,7 @@ final class TranslatableActivityTypeTest extends KernelTestCase
|
|||||||
$this->assertInstanceOf(
|
$this->assertInstanceOf(
|
||||||
ActivityType::class,
|
ActivityType::class,
|
||||||
$form->getData()['type'],
|
$form->getData()['type'],
|
||||||
'The data is an instance of Chill\\ActivityBundle\\Entity\\ActivityType'
|
'The data is an instance of Chill\ActivityBundle\Entity\ActivityType'
|
||||||
);
|
);
|
||||||
$this->assertEquals($type->getId(), $form->getData()['type']->getId());
|
$this->assertEquals($type->getId(), $form->getData()['type']->getId());
|
||||||
|
|
||||||
|
@@ -77,6 +77,18 @@ Choose a type: Choisir un type
|
|||||||
4 hours: 4 heures
|
4 hours: 4 heures
|
||||||
4 hours 30: 4 heures 30
|
4 hours 30: 4 heures 30
|
||||||
5 hours: 5 heures
|
5 hours: 5 heures
|
||||||
|
5 hours 30: 5 heure 30
|
||||||
|
6 hours: 6 heures
|
||||||
|
6 hours 30: 6 heure 30
|
||||||
|
7 hours: 7 heures
|
||||||
|
7 hours 30: 7 heure 30
|
||||||
|
8 hours: 8 heures
|
||||||
|
8 hours 30: 8 heure 30
|
||||||
|
9 hours: 9 heures
|
||||||
|
9 hours 30: 9 heure 30
|
||||||
|
10 hours: 10 heures
|
||||||
|
11 hours: 11 heures
|
||||||
|
12 hours: 12 heures
|
||||||
Concerned groups: Parties concernées par l'échange
|
Concerned groups: Parties concernées par l'échange
|
||||||
Persons in accompanying course: Usagers du parcours
|
Persons in accompanying course: Usagers du parcours
|
||||||
Third persons: Tiers non-pro.
|
Third persons: Tiers non-pro.
|
||||||
@@ -210,6 +222,7 @@ Documents label: Libellé du champ Documents
|
|||||||
# activity type category admin
|
# activity type category admin
|
||||||
ActivityTypeCategory list: Liste des catégories des types d'échange
|
ActivityTypeCategory list: Liste des catégories des types d'échange
|
||||||
Create a new activity type category: Créer une nouvelle catégorie de type d'échange
|
Create a new activity type category: Créer une nouvelle catégorie de type d'échange
|
||||||
|
Create a new activity in accompanying course: Créer un échange dans le parcours
|
||||||
|
|
||||||
# activity delete
|
# activity delete
|
||||||
Remove activity: Supprimer un échange
|
Remove activity: Supprimer un échange
|
||||||
|
@@ -49,13 +49,13 @@
|
|||||||
<li>
|
<li>
|
||||||
<span>
|
<span>
|
||||||
<abbr class="referrer" title={{ 'Created by'|trans }}>{{ 'By'|trans }}:</abbr>
|
<abbr class="referrer" title={{ 'Created by'|trans }}>{{ 'By'|trans }}:</abbr>
|
||||||
<b>{{ entity.createdBy|chill_entity_render_box }}</b>
|
<b>{{ entity.createdBy|chill_entity_render_box({'at_date': entity.date}) }}</b>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span>
|
<span>
|
||||||
<abbr class="referrer" title={{ 'Created for'|trans }}>{{ 'For'|trans }}:</abbr>
|
<abbr class="referrer" title={{ 'Created for'|trans }}>{{ 'For'|trans }}:</abbr>
|
||||||
<b>{{ entity.agent|chill_entity_render_box }}</b>
|
<b>{{ entity.agent|chill_entity_render_box({'at_date': entity.date}) }}</b>
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
|
@@ -18,11 +18,11 @@
|
|||||||
<dd>{{ entity.type|chill_entity_render_box }}</dd>
|
<dd>{{ entity.type|chill_entity_render_box }}</dd>
|
||||||
|
|
||||||
<dt class="inline">{{ 'Created by'|trans }}</dt>
|
<dt class="inline">{{ 'Created by'|trans }}</dt>
|
||||||
<dd>{{ entity.createdBy }}</dd>
|
<dd>{{ entity.createdBy|chill_entity_render_box({'at_date': entity.date}) }}</dd>
|
||||||
|
|
||||||
<dt class="inline">{{ 'Created for'|trans }}</dt>
|
<dt class="inline">{{ 'Created for'|trans }}</dt>
|
||||||
<dd>{{ entity.agent }}</dd>
|
<dd>{{ entity.agent|chill_entity_render_box({'at_date': entity.date}) }}</dd>
|
||||||
|
|
||||||
<dt class="inline">{{ 'Asideactivity location'|trans }}</dt>
|
<dt class="inline">{{ 'Asideactivity location'|trans }}</dt>
|
||||||
{%- if entity.location.name is defined -%}
|
{%- if entity.location.name is defined -%}
|
||||||
<dd>{{ entity.location.name }}</dd>
|
<dd>{{ entity.location.name }}</dd>
|
||||||
|
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Tests\Security\Guard;
|
||||||
|
|
||||||
|
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||||
|
use Chill\DocStoreBundle\Security\Guard\DavTokenAuthenticationEventSubscriber;
|
||||||
|
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTAuthenticatedEvent;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Security\Core\Authentication\Token\AbstractToken;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*
|
||||||
|
* @coversNothing
|
||||||
|
*/
|
||||||
|
class DavTokenAuthenticationEventSubscriberTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testOnJWTAuthenticatedWithDavDataInPayload(): void
|
||||||
|
{
|
||||||
|
$eventSubscriber = new DavTokenAuthenticationEventSubscriber();
|
||||||
|
$token = new class () extends AbstractToken {
|
||||||
|
public function getCredentials()
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$event = new JWTAuthenticatedEvent([
|
||||||
|
'dav' => 1,
|
||||||
|
'so' => '1234',
|
||||||
|
'e' => 1,
|
||||||
|
], $token);
|
||||||
|
|
||||||
|
$eventSubscriber->onJWTAuthenticated($event);
|
||||||
|
|
||||||
|
self::assertTrue($token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT));
|
||||||
|
self::assertTrue($token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS));
|
||||||
|
self::assertEquals('1234', $token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT));
|
||||||
|
self::assertEquals(StoredObjectRoleEnum::EDIT, $token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOnJWTAuthenticatedWithDavNoDataInPayload(): void
|
||||||
|
{
|
||||||
|
$eventSubscriber = new DavTokenAuthenticationEventSubscriber();
|
||||||
|
$token = new class () extends AbstractToken {
|
||||||
|
public function getCredentials()
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$event = new JWTAuthenticatedEvent([], $token);
|
||||||
|
|
||||||
|
$eventSubscriber->onJWTAuthenticated($event);
|
||||||
|
|
||||||
|
self::assertFalse($token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT));
|
||||||
|
self::assertFalse($token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS));
|
||||||
|
}
|
||||||
|
}
|
@@ -72,21 +72,21 @@ days: jours
|
|||||||
1 hour 30: 1 heure 30
|
1 hour 30: 1 heure 30
|
||||||
1 hour 45: 1 heure 45
|
1 hour 45: 1 heure 45
|
||||||
2 hours: 2 heures
|
2 hours: 2 heures
|
||||||
2 hours 30: 2 heure 30
|
2 hours 30: 2 heures 30
|
||||||
3 hours: 3 heures
|
3 hours: 3 heures
|
||||||
3 hours 30: 3 heure 30
|
3 hours 30: 3 heures 30
|
||||||
4 hours: 4 heures
|
4 hours: 4 heures
|
||||||
4 hours 30: 4 heure 30
|
4 hours 30: 4 heures 30
|
||||||
5 hours: 5 heures
|
5 hours: 5 heures
|
||||||
5 hours 30: 5 heure 30
|
5 hours 30: 5 heures 30
|
||||||
6 hours: 6 heures
|
6 hours: 6 heures
|
||||||
6 hours 30: 6 heure 30
|
6 hours 30: 6 heures 30
|
||||||
7 hours: 7 heures
|
7 hours: 7 heures
|
||||||
7 hours 30: 7 heure 30
|
7 hours 30: 7 heures 30
|
||||||
8 hours: 8 heures
|
8 hours: 8 heures
|
||||||
8 hours 30: 8 heure 30
|
8 hours 30: 8 heures 30
|
||||||
9 hours: 9 heures
|
9 hours: 9 heures
|
||||||
9 hours 30: 9 heure 30
|
9 hours 30: 9 heures 30
|
||||||
10 hours: 10 heures
|
10 hours: 10 heures
|
||||||
1/2 day: 1/2 jour
|
1/2 day: 1/2 jour
|
||||||
1 day: 1 jour
|
1 day: 1 jour
|
||||||
|
@@ -524,6 +524,16 @@ class Calendar implements TrackCreationInterface, TrackUpdateInterface, HasCente
|
|||||||
return $this->startDate;
|
return $this->startDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get the date of the calendar.
|
||||||
|
*
|
||||||
|
* Useful for showing the date of the calendar event, required by twig in some places.
|
||||||
|
*/
|
||||||
|
public function getDate(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->getStartDate();
|
||||||
|
}
|
||||||
|
|
||||||
public function getStatus(): ?string
|
public function getStatus(): ?string
|
||||||
{
|
{
|
||||||
return $this->status;
|
return $this->status;
|
||||||
|
@@ -0,0 +1,50 @@
|
|||||||
|
<?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\CalendarBundle\Menu;
|
||||||
|
|
||||||
|
use Chill\CalendarBundle\Security\Voter\CalendarVoter;
|
||||||
|
use Chill\MainBundle\Routing\LocalMenuBuilderInterface;
|
||||||
|
use Knp\Menu\MenuItem;
|
||||||
|
use Symfony\Component\Security\Core\Security;
|
||||||
|
|
||||||
|
final readonly class AccompanyingCourseQuickMenuBuilder implements LocalMenuBuilderInterface
|
||||||
|
{
|
||||||
|
public function __construct(private Security $security)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getMenuIds(): array
|
||||||
|
{
|
||||||
|
return ['accompanying_course_quick_menu'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function buildMenu($menuId, MenuItem $menu, array $parameters)
|
||||||
|
{
|
||||||
|
/** @var \Chill\PersonBundle\Entity\AccompanyingPeriod $accompanyingCourse */
|
||||||
|
$accompanyingCourse = $parameters['accompanying-course'];
|
||||||
|
|
||||||
|
if ($this->security->isGranted(CalendarVoter::CREATE, $accompanyingCourse)) {
|
||||||
|
$menu
|
||||||
|
->addChild('Create a new calendar in accompanying course', [
|
||||||
|
'route' => 'chill_calendar_calendar_new',
|
||||||
|
'routeParameters' => [
|
||||||
|
'accompanying_period_id' => $accompanyingCourse->getId(),
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->setExtras([
|
||||||
|
'order' => 20,
|
||||||
|
'icon' => 'plus',
|
||||||
|
])
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -37,12 +37,12 @@ class RemoteEventConverter
|
|||||||
* valid when the remote string contains also a timezone, like in
|
* valid when the remote string contains also a timezone, like in
|
||||||
* lastModifiedDate.
|
* lastModifiedDate.
|
||||||
*/
|
*/
|
||||||
final public const REMOTE_DATETIMEZONE_FORMAT = 'Y-m-d\\TH:i:s.u?P';
|
final public const REMOTE_DATETIMEZONE_FORMAT = 'Y-m-d\TH:i:s.u?P';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Same as above, but sometimes the date is expressed with only 6 milliseconds.
|
* Same as above, but sometimes the date is expressed with only 6 milliseconds.
|
||||||
*/
|
*/
|
||||||
final public const REMOTE_DATETIMEZONE_FORMAT_ALT = 'Y-m-d\\TH:i:s.uP';
|
final public const REMOTE_DATETIMEZONE_FORMAT_ALT = 'Y-m-d\TH:i:s.uP';
|
||||||
|
|
||||||
private const REMOTE_DATE_FORMAT = 'Y-m-d\TH:i:s.u0';
|
private const REMOTE_DATE_FORMAT = 'Y-m-d\TH:i:s.u0';
|
||||||
|
|
||||||
|
@@ -1 +1,2 @@
|
|||||||
import './scss/badge.scss';
|
import './scss/badge.scss';
|
||||||
|
import './scss/calendar-list.scss';
|
||||||
|
@@ -0,0 +1,26 @@
|
|||||||
|
ul.calendar-list {
|
||||||
|
list-style-type: none;
|
||||||
|
padding: 0;
|
||||||
|
& > li {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
& > li:nth-child(n+2) {
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div.calendar-list {
|
||||||
|
|
||||||
|
ul.calendar-list {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > a.calendar-list__global {
|
||||||
|
display: inline-block;;
|
||||||
|
padding: 0.2rem;
|
||||||
|
min-width: 2rem;
|
||||||
|
border: 1px solid var(--bs-chill-blue);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
@@ -16,6 +16,7 @@
|
|||||||
:removableIfSet="false"
|
:removableIfSet="false"
|
||||||
:displayPicked="false"
|
:displayPicked="false"
|
||||||
:suggested="this.suggestedUsers"
|
:suggested="this.suggestedUsers"
|
||||||
|
:label="'Utilisateur principal'"
|
||||||
@addNewEntity="setMainUser"
|
@addNewEntity="setMainUser"
|
||||||
></pick-entity>
|
></pick-entity>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -55,7 +55,7 @@
|
|||||||
<div class="item-col">
|
<div class="item-col">
|
||||||
<ul class="list-content">
|
<ul class="list-content">
|
||||||
{% if calendar.mainUser is not empty %}
|
{% if calendar.mainUser is not empty %}
|
||||||
<span class="badge-user">{{ calendar.mainUser|chill_entity_render_box }}</span>
|
<span class="badge-user">{{ calendar.mainUser|chill_entity_render_box({'at_date': calendar.startDate}) }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -132,7 +132,7 @@
|
|||||||
<li class="cancel">
|
<li class="cancel">
|
||||||
<span class="createdBy">
|
<span class="createdBy">
|
||||||
{{ 'Created by'|trans }}
|
{{ 'Created by'|trans }}
|
||||||
<b>{{ calendar.activity.createdBy|chill_entity_render_string }}</b>, {{ 'on'|trans }} {{ calendar.activity.createdAt|format_datetime('short', 'short') }}
|
<b>{{ calendar.activity.createdBy|chill_entity_render_string({'at_date': calendar.activity.createdAt}) }}</b>, {{ 'on'|trans }} {{ calendar.activity.createdAt|format_datetime('short', 'short') }}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
{% if is_granted('CHILL_ACTIVITY_SEE', calendar.activity) %}
|
{% if is_granted('CHILL_ACTIVITY_SEE', calendar.activity) %}
|
||||||
|
@@ -89,7 +89,7 @@ class CalendarVoter extends AbstractChillVoter implements ProvideRoleHierarchyIn
|
|||||||
switch ($attribute) {
|
switch ($attribute) {
|
||||||
case self::SEE:
|
case self::SEE:
|
||||||
case self::CREATE:
|
case self::CREATE:
|
||||||
if (AccompanyingPeriod::STEP_DRAFT === $subject->getStep()) {
|
if (AccompanyingPeriod::STEP_DRAFT === $subject->getStep() || AccompanyingPeriod::STEP_CLOSED === $subject->getStep()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -26,6 +26,7 @@ The calendar item has been successfully removed.: Le rendez-vous a été supprim
|
|||||||
From the day: Du
|
From the day: Du
|
||||||
to the day: au
|
to the day: au
|
||||||
Transform to activity: Transformer en échange
|
Transform to activity: Transformer en échange
|
||||||
|
Create a new calendar in accompanying course: Créer un rendez-vous dans le parcours
|
||||||
Will send SMS: Un SMS de rappel sera envoyé
|
Will send SMS: Un SMS de rappel sera envoyé
|
||||||
Will not send SMS: Aucun SMS de rappel ne sera envoyé
|
Will not send SMS: Aucun SMS de rappel ne sera envoyé
|
||||||
SMS already sent: Un SMS a été envoyé
|
SMS already sent: Un SMS a été envoyé
|
||||||
|
@@ -49,20 +49,17 @@ interface CustomFieldInterface
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Return if the value can be considered as empty.
|
* Return if the value can be considered as empty.
|
||||||
*
|
|
||||||
* @param mixed $value the value passed throug the deserialize function
|
|
||||||
*/
|
*/
|
||||||
public function isEmptyValue($value, CustomField $customField);
|
public function isEmptyValue(mixed $value, CustomField $customField);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return a repsentation of the value of the CustomField.
|
* Return a repsentation of the value of the CustomField.
|
||||||
*
|
*
|
||||||
* @param mixed $value the raw value, **not deserialized** (= as stored in the db)
|
|
||||||
* @param \Chill\CustomFieldsBundle\CustomField\CustomField $customField
|
* @param \Chill\CustomFieldsBundle\CustomField\CustomField $customField
|
||||||
*
|
*
|
||||||
* @return string an html representation of the value
|
* @return string an html representation of the value
|
||||||
*/
|
*/
|
||||||
public function render($value, CustomField $customField, $documentType = 'html');
|
public function render(mixed $value, CustomField $customField, $documentType = 'html');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transform the value into a format that can be stored in DB.
|
* Transform the value into a format that can be stored in DB.
|
||||||
|
@@ -399,8 +399,6 @@ final class CustomFieldsChoiceTest extends KernelTestCase
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @dataProvider emptyDataProvider
|
* @dataProvider emptyDataProvider
|
||||||
*
|
|
||||||
* @param mixed $data deserialized data
|
|
||||||
*/
|
*/
|
||||||
public function testIsEmptyValueEmpty(mixed $data)
|
public function testIsEmptyValueEmpty(mixed $data)
|
||||||
{
|
{
|
||||||
|
@@ -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\DocGeneratorBundle\Test;
|
||||||
|
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
|
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
|
||||||
|
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T of object
|
||||||
|
*/
|
||||||
|
abstract class DocGenNormalizerTestAbstract extends KernelTestCase
|
||||||
|
{
|
||||||
|
public function testNullValueHasSameKeysAsNull(): void
|
||||||
|
{
|
||||||
|
$normalizedObject = $this->getNormalizer()->normalize($this->provideNotNullObject(), 'docgen', [
|
||||||
|
AbstractNormalizer::GROUPS => ['docgen:read'], 'docgen:expects' => $this->provideDocGenExpectClass(),
|
||||||
|
]);
|
||||||
|
$nullNormalizedObject = $this->getNormalizer()->normalize(null, 'docgen', [
|
||||||
|
AbstractNormalizer::GROUPS => ['docgen:read'], 'docgen:expects' => $this->provideDocGenExpectClass(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertEqualsCanonicalizing(array_keys($normalizedObject), array_keys($nullNormalizedObject));
|
||||||
|
self::assertArrayHasKey('isNull', $nullNormalizedObject, 'each object must have an "isNull" key');
|
||||||
|
self::assertTrue($nullNormalizedObject['isNull'], 'isNull key must be true for null objects');
|
||||||
|
self::assertFalse($normalizedObject['isNull'], 'isNull key must be false for null objects');
|
||||||
|
|
||||||
|
foreach ($normalizedObject as $key => $value) {
|
||||||
|
if (in_array($key, ['isNull', 'type'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($value)) {
|
||||||
|
if (array_is_list($value)) {
|
||||||
|
self::assertEquals([], $nullNormalizedObject[$key], "list must be serialized as an empty array, in {$key}");
|
||||||
|
} else {
|
||||||
|
self::assertEqualsCanonicalizing(array_keys($value), array_keys($nullNormalizedObject[$key]), "sub-object must have the same keys, in {$key}");
|
||||||
|
}
|
||||||
|
} elseif (is_string($value)) {
|
||||||
|
self::assertEquals('', $nullNormalizedObject[$key], 'strings must be ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return T
|
||||||
|
*/
|
||||||
|
abstract public function provideNotNullObject(): object;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return class-string<T>
|
||||||
|
*/
|
||||||
|
abstract public function provideDocGenExpectClass(): string;
|
||||||
|
|
||||||
|
abstract public function getNormalizer(): NormalizerInterface;
|
||||||
|
}
|
252
src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php
Normal file
252
src/Bundle/ChillDocStoreBundle/Controller/WebdavController.php
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Controller;
|
||||||
|
|
||||||
|
use Chill\DocStoreBundle\Dav\Request\PropfindRequestAnalyzer;
|
||||||
|
use Chill\DocStoreBundle\Dav\Response\DavResponse;
|
||||||
|
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||||
|
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||||
|
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
|
use Symfony\Component\Routing\Annotation\Route;
|
||||||
|
use Symfony\Component\Security\Core\Security;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provide endpoint for editing a document on the desktop using dav.
|
||||||
|
*
|
||||||
|
* This controller implements the minimal required methods to edit a document on a desktop software (i.e. LibreOffice)
|
||||||
|
* and save the document online.
|
||||||
|
*
|
||||||
|
* To avoid to ask for a password, the endpoints are protected using a JWT access token, which is inside the
|
||||||
|
* URL. This avoid the DAV Client (LibreOffice) to keep an access token in query parameter or in some header (which
|
||||||
|
* they are not able to understand). The JWT Guard is adapted with a dedicated token extractor which is going to read
|
||||||
|
* the segments (separation of "/"): the first segment must be the string "dav", and the second one must be the JWT.
|
||||||
|
*/
|
||||||
|
final readonly class WebdavController
|
||||||
|
{
|
||||||
|
private PropfindRequestAnalyzer $requestAnalyzer;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private \Twig\Environment $engine,
|
||||||
|
private StoredObjectManagerInterface $storedObjectManager,
|
||||||
|
private Security $security,
|
||||||
|
) {
|
||||||
|
$this->requestAnalyzer = new PropfindRequestAnalyzer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Route("/dav/{access_token}/get/{uuid}/", methods={"GET", "HEAD"}, name="chill_docstore_dav_directory_get")
|
||||||
|
*/
|
||||||
|
public function getDirectory(StoredObject $storedObject, string $access_token): Response
|
||||||
|
{
|
||||||
|
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
|
||||||
|
throw new AccessDeniedHttpException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DavResponse(
|
||||||
|
$this->engine->render('@ChillDocStore/Webdav/directory.html.twig', [
|
||||||
|
'stored_object' => $storedObject,
|
||||||
|
'access_token' => $access_token,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Route("/dav/{access_token}/get/{uuid}/", methods={"OPTIONS"})
|
||||||
|
*/
|
||||||
|
public function optionsDirectory(StoredObject $storedObject): Response
|
||||||
|
{
|
||||||
|
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
|
||||||
|
throw new AccessDeniedHttpException();
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = (new DavResponse(''))
|
||||||
|
->setEtag($this->storedObjectManager->etag($storedObject))
|
||||||
|
;
|
||||||
|
|
||||||
|
// $response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT,PROPPATCH,COPY,MOVE,REPORT,PATCH,POST,TRACE']);
|
||||||
|
$response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT']);
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Route("/dav/{access_token}/get/{uuid}/", methods={"PROPFIND"})
|
||||||
|
*/
|
||||||
|
public function propfindDirectory(StoredObject $storedObject, string $access_token, Request $request): Response
|
||||||
|
{
|
||||||
|
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
|
||||||
|
throw new AccessDeniedHttpException();
|
||||||
|
}
|
||||||
|
|
||||||
|
$depth = $request->headers->get('depth');
|
||||||
|
|
||||||
|
if ('0' !== $depth && '1' !== $depth) {
|
||||||
|
throw new BadRequestHttpException('only 1 and 0 are accepted for Depth header');
|
||||||
|
}
|
||||||
|
|
||||||
|
[$properties, $lastModified, $etag, $length] = $this->parseDavRequest($request->getContent(), $storedObject);
|
||||||
|
|
||||||
|
$response = new DavResponse(
|
||||||
|
$this->engine->render('@ChillDocStore/Webdav/directory_propfind.xml.twig', [
|
||||||
|
'stored_object' => $storedObject,
|
||||||
|
'properties' => $properties,
|
||||||
|
'last_modified' => $lastModified,
|
||||||
|
'etag' => $etag,
|
||||||
|
'content_length' => $length,
|
||||||
|
'depth' => (int) $depth,
|
||||||
|
'access_token' => $access_token,
|
||||||
|
]),
|
||||||
|
207
|
||||||
|
);
|
||||||
|
|
||||||
|
$response->headers->add([
|
||||||
|
'Content-Type' => 'text/xml',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Route("/dav/{access_token}/get/{uuid}/d", name="chill_docstore_dav_document_get", methods={"GET"})
|
||||||
|
*/
|
||||||
|
public function getDocument(StoredObject $storedObject): Response
|
||||||
|
{
|
||||||
|
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
|
||||||
|
throw new AccessDeniedHttpException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (new DavResponse($this->storedObjectManager->read($storedObject)))
|
||||||
|
->setEtag($this->storedObjectManager->etag($storedObject));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Route("/dav/{access_token}/get/{uuid}/d", methods={"HEAD"})
|
||||||
|
*/
|
||||||
|
public function headDocument(StoredObject $storedObject): Response
|
||||||
|
{
|
||||||
|
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
|
||||||
|
throw new AccessDeniedHttpException();
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = new DavResponse('');
|
||||||
|
|
||||||
|
$response->headers->add(
|
||||||
|
[
|
||||||
|
'Content-Length' => $this->storedObjectManager->getContentLength($storedObject),
|
||||||
|
'Content-Type' => $storedObject->getType(),
|
||||||
|
'Etag' => $this->storedObjectManager->etag($storedObject),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Route("/dav/{access_token}/get/{uuid}/d", methods={"OPTIONS"})
|
||||||
|
*/
|
||||||
|
public function optionsDocument(StoredObject $storedObject): Response
|
||||||
|
{
|
||||||
|
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
|
||||||
|
throw new AccessDeniedHttpException();
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = (new DavResponse(''))
|
||||||
|
->setEtag($this->storedObjectManager->etag($storedObject))
|
||||||
|
;
|
||||||
|
|
||||||
|
$response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT']);
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Route("/dav/{access_token}/get/{uuid}/d", methods={"PROPFIND"})
|
||||||
|
*/
|
||||||
|
public function propfindDocument(StoredObject $storedObject, string $access_token, Request $request): Response
|
||||||
|
{
|
||||||
|
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
|
||||||
|
throw new AccessDeniedHttpException();
|
||||||
|
}
|
||||||
|
|
||||||
|
[$properties, $lastModified, $etag, $length] = $this->parseDavRequest($request->getContent(), $storedObject);
|
||||||
|
|
||||||
|
$response = new DavResponse(
|
||||||
|
$this->engine->render(
|
||||||
|
'@ChillDocStore/Webdav/doc_props.xml.twig',
|
||||||
|
[
|
||||||
|
'stored_object' => $storedObject,
|
||||||
|
'properties' => $properties,
|
||||||
|
'etag' => $etag,
|
||||||
|
'last_modified' => $lastModified,
|
||||||
|
'content_length' => $length,
|
||||||
|
'access_token' => $access_token,
|
||||||
|
]
|
||||||
|
),
|
||||||
|
207
|
||||||
|
);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->headers->add([
|
||||||
|
'Content-Type' => 'text/xml',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Route("/dav/{access_token}/get/{uuid}/d", methods={"PUT"})
|
||||||
|
*/
|
||||||
|
public function putDocument(StoredObject $storedObject, Request $request): Response
|
||||||
|
{
|
||||||
|
if (!$this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $storedObject)) {
|
||||||
|
throw new AccessDeniedHttpException();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->storedObjectManager->write($storedObject, $request->getContent());
|
||||||
|
|
||||||
|
return new DavResponse('', Response::HTTP_NO_CONTENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: array, 1: \DateTimeInterface, 2: string, 3: int} properties, lastModified, etag, length
|
||||||
|
*/
|
||||||
|
private function parseDavRequest(string $content, StoredObject $storedObject): array
|
||||||
|
{
|
||||||
|
$xml = new \DOMDocument();
|
||||||
|
$xml->loadXML($content);
|
||||||
|
|
||||||
|
$properties = $this->requestAnalyzer->getRequestedProperties($xml);
|
||||||
|
$requested = array_keys(array_filter($properties, fn ($item) => true === $item));
|
||||||
|
|
||||||
|
if (
|
||||||
|
in_array('lastModified', $requested, true)
|
||||||
|
|| in_array('etag', $requested, true)
|
||||||
|
) {
|
||||||
|
$lastModified = $this->storedObjectManager->getLastModified($storedObject);
|
||||||
|
$etag = $this->storedObjectManager->etag($storedObject);
|
||||||
|
}
|
||||||
|
if (in_array('contentLength', $requested, true)) {
|
||||||
|
$length = $this->storedObjectManager->getContentLength($storedObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
$properties,
|
||||||
|
$lastModified ?? null,
|
||||||
|
$etag ?? null,
|
||||||
|
$length ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Dav\Exception;
|
||||||
|
|
||||||
|
class ParseRequestException extends \UnexpectedValueException
|
||||||
|
{
|
||||||
|
}
|
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Dav\Request;
|
||||||
|
|
||||||
|
use Chill\DocStoreBundle\Dav\Exception\ParseRequestException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @phpstan-type davProperties array{resourceType: bool, contentType: bool, lastModified: bool, creationDate: bool, contentLength: bool, etag: bool, supportedLock: bool, unknowns: list<array{xmlns: string, prop: string}>}
|
||||||
|
*/
|
||||||
|
class PropfindRequestAnalyzer
|
||||||
|
{
|
||||||
|
private const KNOWN_PROPS = [
|
||||||
|
'resourceType',
|
||||||
|
'contentType',
|
||||||
|
'lastModified',
|
||||||
|
'creationDate',
|
||||||
|
'contentLength',
|
||||||
|
'etag',
|
||||||
|
'supportedLock',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return davProperties
|
||||||
|
*/
|
||||||
|
public function getRequestedProperties(\DOMDocument $request): array
|
||||||
|
{
|
||||||
|
$propfinds = $request->getElementsByTagNameNS('DAV:', 'propfind');
|
||||||
|
|
||||||
|
if (0 === $propfinds->count()) {
|
||||||
|
throw new ParseRequestException('any propfind element found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (1 < $propfinds->count()) {
|
||||||
|
throw new ParseRequestException('too much propfind element found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$propfind = $propfinds->item(0);
|
||||||
|
|
||||||
|
if (0 === $propfind->childNodes->count()) {
|
||||||
|
throw new ParseRequestException('no element under propfind');
|
||||||
|
}
|
||||||
|
|
||||||
|
$unknows = [];
|
||||||
|
$props = [];
|
||||||
|
|
||||||
|
foreach ($propfind->childNodes->getIterator() as $prop) {
|
||||||
|
/** @var \DOMNode $prop */
|
||||||
|
if (XML_ELEMENT_NODE !== $prop->nodeType) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('propname' === $prop->nodeName) {
|
||||||
|
return $this->baseProps(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($prop->childNodes->getIterator() as $getProp) {
|
||||||
|
if (XML_ELEMENT_NODE !== $getProp->nodeType) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('DAV:' !== $getProp->lookupNamespaceURI(null)) {
|
||||||
|
$unknows[] = ['xmlns' => $getProp->lookupNamespaceURI(null), 'prop' => $getProp->nodeName];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$props[] = match ($getProp->nodeName) {
|
||||||
|
'resourcetype' => 'resourceType',
|
||||||
|
'getcontenttype' => 'contentType',
|
||||||
|
'getlastmodified' => 'lastModified',
|
||||||
|
default => '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$props = array_filter(array_values($props), fn (string $item) => '' !== $item);
|
||||||
|
|
||||||
|
return [...$this->baseProps(false), ...array_combine($props, array_fill(0, count($props), true)), 'unknowns' => $unknows];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return davProperties
|
||||||
|
*/
|
||||||
|
private function baseProps(bool $default = false): array
|
||||||
|
{
|
||||||
|
return
|
||||||
|
[
|
||||||
|
...array_combine(
|
||||||
|
self::KNOWN_PROPS,
|
||||||
|
array_fill(0, count(self::KNOWN_PROPS), $default)
|
||||||
|
),
|
||||||
|
'unknowns' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
24
src/Bundle/ChillDocStoreBundle/Dav/Response/DavResponse.php
Normal file
24
src/Bundle/ChillDocStoreBundle/Dav/Response/DavResponse.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Dav\Response;
|
||||||
|
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class DavResponse extends Response
|
||||||
|
{
|
||||||
|
public function __construct($content = '', int $status = 200, array $headers = [])
|
||||||
|
{
|
||||||
|
parent::__construct($content, $status, $headers);
|
||||||
|
|
||||||
|
$this->headers->add(['DAV' => '1']);
|
||||||
|
}
|
||||||
|
}
|
@@ -48,14 +48,14 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
|
|||||||
/**
|
/**
|
||||||
* @ORM\Column(type="json", name="datas")
|
* @ORM\Column(type="json", name="datas")
|
||||||
*
|
*
|
||||||
* @Serializer\Groups({"read", "write"})
|
* @Serializer\Groups({"write"})
|
||||||
*/
|
*/
|
||||||
private array $datas = [];
|
private array $datas = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @ORM\Column(type="text")
|
* @ORM\Column(type="text")
|
||||||
*
|
*
|
||||||
* @Serializer\Groups({"read", "write"})
|
* @Serializer\Groups({"write"})
|
||||||
*/
|
*/
|
||||||
private string $filename = '';
|
private string $filename = '';
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
|
|||||||
*
|
*
|
||||||
* @ORM\Column(type="integer")
|
* @ORM\Column(type="integer")
|
||||||
*
|
*
|
||||||
* @Serializer\Groups({"read", "write"})
|
* @Serializer\Groups({"write"})
|
||||||
*/
|
*/
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
@@ -75,35 +75,35 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
|
|||||||
*
|
*
|
||||||
* @ORM\Column(type="json", name="iv")
|
* @ORM\Column(type="json", name="iv")
|
||||||
*
|
*
|
||||||
* @Serializer\Groups({"read", "write"})
|
* @Serializer\Groups({"write"})
|
||||||
*/
|
*/
|
||||||
private array $iv = [];
|
private array $iv = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @ORM\Column(type="json", name="key")
|
* @ORM\Column(type="json", name="key")
|
||||||
*
|
*
|
||||||
* @Serializer\Groups({"read", "write"})
|
* @Serializer\Groups({"write"})
|
||||||
*/
|
*/
|
||||||
private array $keyInfos = [];
|
private array $keyInfos = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @ORM\Column(type="text", name="title")
|
* @ORM\Column(type="text", name="title")
|
||||||
*
|
*
|
||||||
* @Serializer\Groups({"read", "write"})
|
* @Serializer\Groups({"write"})
|
||||||
*/
|
*/
|
||||||
private string $title = '';
|
private string $title = '';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @ORM\Column(type="text", name="type", options={"default": ""})
|
* @ORM\Column(type="text", name="type", options={"default": ""})
|
||||||
*
|
*
|
||||||
* @Serializer\Groups({"read", "write"})
|
* @Serializer\Groups({"write"})
|
||||||
*/
|
*/
|
||||||
private string $type = '';
|
private string $type = '';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @ORM\Column(type="uuid", unique=true)
|
* @ORM\Column(type="uuid", unique=true)
|
||||||
*
|
*
|
||||||
* @Serializer\Groups({"read", "write"})
|
* @Serializer\Groups({"write"})
|
||||||
*/
|
*/
|
||||||
private UuidInterface $uuid;
|
private UuidInterface $uuid;
|
||||||
|
|
||||||
@@ -137,8 +137,6 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
|
|||||||
*/
|
*/
|
||||||
public function __construct(/**
|
public function __construct(/**
|
||||||
* @ORM\Column(type="text", options={"default": "ready"})
|
* @ORM\Column(type="text", options={"default": "ready"})
|
||||||
*
|
|
||||||
* @Serializer\Groups({"read"})
|
|
||||||
*/
|
*/
|
||||||
private string $status = 'ready'
|
private string $status = 'ready'
|
||||||
) {
|
) {
|
||||||
@@ -356,4 +354,19 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
|
|||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function saveHistory(): void
|
||||||
|
{
|
||||||
|
if ('' === $this->getFilename()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->datas['history'][] = [
|
||||||
|
'filename' => $this->getFilename(),
|
||||||
|
'iv' => $this->getIv(),
|
||||||
|
'key_infos' => $this->getKeyInfos(),
|
||||||
|
'type' => $this->getType(),
|
||||||
|
'before' => (new \DateTimeImmutable('now'))->getTimestamp(),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -14,47 +14,21 @@ namespace Chill\DocStoreBundle\Form;
|
|||||||
use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument;
|
use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument;
|
||||||
use Chill\DocStoreBundle\Entity\Document;
|
use Chill\DocStoreBundle\Entity\Document;
|
||||||
use Chill\DocStoreBundle\Entity\DocumentCategory;
|
use Chill\DocStoreBundle\Entity\DocumentCategory;
|
||||||
use Chill\MainBundle\Entity\User;
|
|
||||||
use Chill\MainBundle\Form\Type\ChillDateType;
|
use Chill\MainBundle\Form\Type\ChillDateType;
|
||||||
use Chill\MainBundle\Form\Type\ChillTextareaType;
|
use Chill\MainBundle\Form\Type\ChillTextareaType;
|
||||||
use Chill\MainBundle\Security\Authorization\AuthorizationHelper;
|
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
|
||||||
use Chill\MainBundle\Templating\TranslatableStringHelper;
|
|
||||||
use Doctrine\ORM\EntityRepository;
|
use Doctrine\ORM\EntityRepository;
|
||||||
use Doctrine\Persistence\ObjectManager;
|
|
||||||
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
||||||
use Symfony\Component\Form\AbstractType;
|
use Symfony\Component\Form\AbstractType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
use Symfony\Component\Form\FormBuilderInterface;
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
class AccompanyingCourseDocumentType extends AbstractType
|
final class AccompanyingCourseDocumentType extends AbstractType
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* @var AuthorizationHelper
|
|
||||||
*/
|
|
||||||
protected $authorizationHelper;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var ObjectManager
|
|
||||||
*/
|
|
||||||
protected $om;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var TranslatableStringHelper
|
|
||||||
*/
|
|
||||||
protected $translatableStringHelper;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* the user running this form.
|
|
||||||
*
|
|
||||||
* @var User
|
|
||||||
*/
|
|
||||||
protected $user;
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
TranslatableStringHelper $translatableStringHelper
|
private readonly TranslatableStringHelperInterface $translatableStringHelper
|
||||||
) {
|
) {
|
||||||
$this->translatableStringHelper = $translatableStringHelper;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function buildForm(FormBuilderInterface $builder, array $options)
|
public function buildForm(FormBuilderInterface $builder, array $options)
|
||||||
|
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Form;
|
||||||
|
|
||||||
|
use Chill\MainBundle\Form\Type\ChillCollectionType;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
|
class CollectionStoredObjectType extends AbstractType
|
||||||
|
{
|
||||||
|
public function configureOptions(OptionsResolver $resolver)
|
||||||
|
{
|
||||||
|
$resolver
|
||||||
|
->setDefault('entry_type', StoredObjectType::class)
|
||||||
|
->setDefault('allow_add', true)
|
||||||
|
->setDefault('allow_delete', true)
|
||||||
|
->setDefault('button_add_label', 'stored_object.Insert a document')
|
||||||
|
->setDefault('button_remove_label', 'stored_object.Remove a document')
|
||||||
|
->setDefault('empty_collection_explain', 'No documents')
|
||||||
|
->setDefault('entry_options', ['has_title' => true])
|
||||||
|
->setDefault('js_caller', 'data-collection-stored-object');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getParent()
|
||||||
|
{
|
||||||
|
return ChillCollectionType::class;
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Form\DataMapper;
|
||||||
|
|
||||||
|
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||||
|
use Symfony\Component\Form\DataMapperInterface;
|
||||||
|
use Symfony\Component\Form\Exception;
|
||||||
|
use Symfony\Component\Form\FormInterface;
|
||||||
|
|
||||||
|
class StoredObjectDataMapper implements DataMapperInterface
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param FormInterface[]|\Traversable $forms A list of {@link FormInterface} instances
|
||||||
|
*/
|
||||||
|
public function mapDataToForms($viewData, $forms)
|
||||||
|
{
|
||||||
|
if (null === $viewData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$viewData instanceof StoredObject) {
|
||||||
|
throw new Exception\UnexpectedTypeException($viewData, StoredObject::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
$forms = iterator_to_array($forms);
|
||||||
|
if (array_key_exists('title', $forms)) {
|
||||||
|
$forms['title']->setData($viewData->getTitle());
|
||||||
|
}
|
||||||
|
$forms['stored_object']->setData($viewData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param FormInterface[]|\Traversable $forms A list of {@link FormInterface} instances
|
||||||
|
*/
|
||||||
|
public function mapFormsToData($forms, &$viewData)
|
||||||
|
{
|
||||||
|
$forms = iterator_to_array($forms);
|
||||||
|
|
||||||
|
if (!(null === $viewData || $viewData instanceof StoredObject)) {
|
||||||
|
throw new Exception\UnexpectedTypeException($viewData, StoredObject::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $forms['stored_object']->getData()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var StoredObject $viewData */
|
||||||
|
if ($viewData->getFilename() !== $forms['stored_object']->getData()['filename']) {
|
||||||
|
// we 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)) {
|
||||||
|
$viewData->setTitle($forms['title']->getData());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Form\DataTransformer;
|
||||||
|
|
||||||
|
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||||
|
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectNormalizer;
|
||||||
|
use Symfony\Component\Form\DataTransformerInterface;
|
||||||
|
use Symfony\Component\Form\Exception\UnexpectedTypeException;
|
||||||
|
use Symfony\Component\Serializer\SerializerInterface;
|
||||||
|
|
||||||
|
class StoredObjectDataTransformer implements DataTransformerInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly SerializerInterface $serializer
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function transform(mixed $value): mixed
|
||||||
|
{
|
||||||
|
if (null === $value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value instanceof StoredObject) {
|
||||||
|
return $this->serializer->serialize($value, 'json', [
|
||||||
|
'groups' => [
|
||||||
|
StoredObjectNormalizer::ADD_DAV_EDIT_LINK_CONTEXT,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new UnexpectedTypeException($value, StoredObject::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reverseTransform(mixed $value): mixed
|
||||||
|
{
|
||||||
|
if ('' === $value || null === $value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return json_decode((string) $value, true, 10, JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
}
|
@@ -11,11 +11,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Chill\DocStoreBundle\Form;
|
namespace Chill\DocStoreBundle\Form;
|
||||||
|
|
||||||
use ChampsLibres\AsyncUploaderBundle\Form\Type\AsyncUploaderType;
|
|
||||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Chill\DocStoreBundle\Form\DataMapper\StoredObjectDataMapper;
|
||||||
|
use Chill\DocStoreBundle\Form\DataTransformer\StoredObjectDataTransformer;
|
||||||
use Symfony\Component\Form\AbstractType;
|
use Symfony\Component\Form\AbstractType;
|
||||||
use Symfony\Component\Form\CallbackTransformer;
|
|
||||||
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
|
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
use Symfony\Component\Form\FormBuilderInterface;
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
@@ -24,16 +23,12 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
|||||||
/**
|
/**
|
||||||
* Form type which allow to join a document.
|
* Form type which allow to join a document.
|
||||||
*/
|
*/
|
||||||
class StoredObjectType extends AbstractType
|
final class StoredObjectType extends AbstractType
|
||||||
{
|
{
|
||||||
/**
|
public function __construct(
|
||||||
* @var EntityManagerInterface
|
private readonly StoredObjectDataTransformer $storedObjectDataTransformer,
|
||||||
*/
|
private readonly StoredObjectDataMapper $storedObjectDataMapper,
|
||||||
protected $em;
|
) {
|
||||||
|
|
||||||
public function __construct(EntityManagerInterface $em)
|
|
||||||
{
|
|
||||||
$this->em = $em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function buildForm(FormBuilderInterface $builder, array $options)
|
public function buildForm(FormBuilderInterface $builder, array $options)
|
||||||
@@ -45,30 +40,9 @@ class StoredObjectType extends AbstractType
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$builder
|
$builder->add('stored_object', HiddenType::class);
|
||||||
->add('filename', AsyncUploaderType::class)
|
$builder->get('stored_object')->addModelTransformer($this->storedObjectDataTransformer);
|
||||||
->add('type', HiddenType::class)
|
$builder->setDataMapper($this->storedObjectDataMapper);
|
||||||
->add('keyInfos', HiddenType::class)
|
|
||||||
->add('iv', HiddenType::class);
|
|
||||||
|
|
||||||
$builder
|
|
||||||
->get('keyInfos')
|
|
||||||
->addModelTransformer(new CallbackTransformer(
|
|
||||||
$this->transform(...),
|
|
||||||
$this->reverseTransform(...)
|
|
||||||
));
|
|
||||||
$builder
|
|
||||||
->get('iv')
|
|
||||||
->addModelTransformer(new CallbackTransformer(
|
|
||||||
$this->transform(...),
|
|
||||||
$this->reverseTransform(...)
|
|
||||||
));
|
|
||||||
|
|
||||||
$builder
|
|
||||||
->addModelTransformer(new CallbackTransformer(
|
|
||||||
$this->transformObject(...),
|
|
||||||
$this->reverseTransformObject(...)
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function configureOptions(OptionsResolver $resolver)
|
public function configureOptions(OptionsResolver $resolver)
|
||||||
@@ -80,43 +54,4 @@ class StoredObjectType extends AbstractType
|
|||||||
->setDefault('has_title', false)
|
->setDefault('has_title', false)
|
||||||
->setAllowedTypes('has_title', ['bool']);
|
->setAllowedTypes('has_title', ['bool']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function reverseTransform($value)
|
|
||||||
{
|
|
||||||
if (null === $value) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return \json_decode((string) $value, true, 512, JSON_THROW_ON_ERROR);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function reverseTransformObject($object)
|
|
||||||
{
|
|
||||||
if (null === $object) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (null === $object->getFilename()) {
|
|
||||||
// remove the original object
|
|
||||||
$this->em->remove($object);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $object;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function transform($object)
|
|
||||||
{
|
|
||||||
if (null === $object) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return \json_encode($object, JSON_THROW_ON_ERROR);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function transformObject($object = null)
|
|
||||||
{
|
|
||||||
return $object;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -0,0 +1,86 @@
|
|||||||
|
import {CollectionEventPayload} from "../../../../../ChillMainBundle/Resources/public/module/collection";
|
||||||
|
import {createApp} from "vue";
|
||||||
|
import DropFileWidget from "../../vuejs/DropFileWidget/DropFileWidget.vue"
|
||||||
|
import {StoredObject, StoredObjectCreated} from "../../types";
|
||||||
|
import {_createI18n} from "../../../../../ChillMainBundle/Resources/public/vuejs/_js/i18n";
|
||||||
|
const i18n = _createI18n({});
|
||||||
|
|
||||||
|
const startApp = (divElement: HTMLDivElement, collectionEntry: null|HTMLLIElement): void => {
|
||||||
|
console.log('app started', divElement);
|
||||||
|
const input_stored_object: HTMLInputElement|null = divElement.querySelector("input[data-stored-object]");
|
||||||
|
if (null === input_stored_object) {
|
||||||
|
throw new Error('input to stored object not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
let existingDoc: StoredObject|null = null;
|
||||||
|
if (input_stored_object.value !== "") {
|
||||||
|
existingDoc = JSON.parse(input_stored_object.value);
|
||||||
|
}
|
||||||
|
const app_container = document.createElement("div");
|
||||||
|
divElement.appendChild(app_container);
|
||||||
|
|
||||||
|
const app = createApp({
|
||||||
|
template: '<drop-file-widget :existingDoc="this.$data.existingDoc" :allowRemove="true" @addDocument="this.addDocument" @removeDocument="removeDocument"></drop-file-widget>',
|
||||||
|
data(vm) {
|
||||||
|
return {
|
||||||
|
existingDoc: existingDoc,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
DropFileWidget,
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
addDocument: function(object: StoredObjectCreated): void {
|
||||||
|
console.log('object added', object);
|
||||||
|
this.$data.existingDoc = object;
|
||||||
|
input_stored_object.value = JSON.stringify(object);
|
||||||
|
},
|
||||||
|
removeDocument: function(object: StoredObject): void {
|
||||||
|
console.log('catch remove document', object);
|
||||||
|
input_stored_object.value = "";
|
||||||
|
this.$data.existingDoc = null;
|
||||||
|
console.log('collectionEntry', collectionEntry);
|
||||||
|
|
||||||
|
if (null !== collectionEntry) {
|
||||||
|
console.log('will remove collection');
|
||||||
|
collectionEntry.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(i18n).mount(app_container);
|
||||||
|
}
|
||||||
|
window.addEventListener('collection-add-entry', ((e: CustomEvent<CollectionEventPayload>) => {
|
||||||
|
const detail = e.detail;
|
||||||
|
const divElement: null|HTMLDivElement = detail.entry.querySelector('div[data-stored-object]');
|
||||||
|
|
||||||
|
if (null === divElement) {
|
||||||
|
throw new Error('div[data-stored-object] not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
startApp(divElement, detail.entry);
|
||||||
|
}) as EventListener);
|
||||||
|
|
||||||
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const upload_inputs: NodeListOf<HTMLDivElement> = document.querySelectorAll('div[data-stored-object]');
|
||||||
|
|
||||||
|
upload_inputs.forEach((input: HTMLDivElement): void => {
|
||||||
|
// test for a parent to check if this is a collection entry
|
||||||
|
let collectionEntry: null|HTMLLIElement = null;
|
||||||
|
let parent = input.parentElement;
|
||||||
|
console.log('parent', parent);
|
||||||
|
if (null !== parent) {
|
||||||
|
let grandParent = parent.parentElement;
|
||||||
|
console.log('grandParent', grandParent);
|
||||||
|
if (null !== grandParent) {
|
||||||
|
if (grandParent.tagName.toLowerCase() === 'li' && grandParent.classList.contains('entry')) {
|
||||||
|
collectionEntry = grandParent as HTMLLIElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
startApp(input, collectionEntry);
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
export {}
|
@@ -17,18 +17,22 @@ window.addEventListener('DOMContentLoaded', function (e) {
|
|||||||
canEdit: string,
|
canEdit: string,
|
||||||
storedObject: string,
|
storedObject: string,
|
||||||
buttonSmall: string,
|
buttonSmall: string,
|
||||||
|
davLink: string,
|
||||||
|
davLinkExpiration: string,
|
||||||
};
|
};
|
||||||
|
|
||||||
const
|
const
|
||||||
storedObject = JSON.parse(datasets.storedObject) as StoredObject,
|
storedObject = JSON.parse(datasets.storedObject) as StoredObject,
|
||||||
filename = datasets.filename,
|
filename = datasets.filename,
|
||||||
canEdit = datasets.canEdit === '1',
|
canEdit = datasets.canEdit === '1',
|
||||||
small = datasets.buttonSmall === '1'
|
small = datasets.buttonSmall === '1',
|
||||||
|
davLink = 'davLink' in datasets && datasets.davLink !== '' ? datasets.davLink : null,
|
||||||
|
davLinkExpiration = 'davLinkExpiration' in datasets ? Number.parseInt(datasets.davLinkExpiration) : null
|
||||||
;
|
;
|
||||||
|
|
||||||
return { storedObject, filename, canEdit, small };
|
return { storedObject, filename, canEdit, small, davLink, davLinkExpiration };
|
||||||
},
|
},
|
||||||
template: '<document-action-buttons-group :can-edit="canEdit" :filename="filename" :stored-object="storedObject" :small="small" @on-stored-object-status-change="onStoredObjectStatusChange"></document-action-buttons-group>',
|
template: '<document-action-buttons-group :can-edit="canEdit" :filename="filename" :stored-object="storedObject" :small="small" :dav-link="davLink" :dav-link-expiration="davLinkExpiration" @on-stored-object-status-change="onStoredObjectStatusChange"></document-action-buttons-group>',
|
||||||
methods: {
|
methods: {
|
||||||
onStoredObjectStatusChange: function(newStatus: StoredObjectStatusChange): void {
|
onStoredObjectStatusChange: function(newStatus: StoredObjectStatusChange): void {
|
||||||
this.$data.storedObject.status = newStatus.status;
|
this.$data.storedObject.status = newStatus.status;
|
||||||
|
@@ -17,6 +17,20 @@ export interface StoredObject {
|
|||||||
type: string,
|
type: string,
|
||||||
uuid: string,
|
uuid: string,
|
||||||
status: StoredObjectStatus,
|
status: StoredObjectStatus,
|
||||||
|
_links?: {
|
||||||
|
dav_link?: {
|
||||||
|
href: string
|
||||||
|
expiration: number
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoredObjectCreated {
|
||||||
|
status: "stored_object_created",
|
||||||
|
filename: string,
|
||||||
|
iv: Uint8Array,
|
||||||
|
keyInfos: object,
|
||||||
|
type: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StoredObjectStatusChange {
|
export interface StoredObjectStatusChange {
|
||||||
@@ -33,3 +47,18 @@ export type WopiEditButtonExecutableBeforeLeaveFunction = {
|
|||||||
(): Promise<void>
|
(): Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Object containing information for performering a POST request to a swift object store
|
||||||
|
*/
|
||||||
|
export interface PostStoreObjectSignature {
|
||||||
|
method: "POST",
|
||||||
|
max_file_size: number,
|
||||||
|
max_file_count: 1,
|
||||||
|
expires: number,
|
||||||
|
submit_delay: 180,
|
||||||
|
redirect: string,
|
||||||
|
prefix: string,
|
||||||
|
url: string,
|
||||||
|
signature: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
@@ -1,13 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="'ready' === props.storedObject.status" class="btn-group">
|
<div v-if="'ready' === props.storedObject.status || 'stored_object_created' === props.storedObject.status" class="btn-group">
|
||||||
<button :class="Object.assign({'btn': true, 'btn-outline-primary': true, 'dropdown-toggle': true, 'btn-sm': props.small})" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
<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)">
|
<li v-if="props.canEdit && is_extension_editable(props.storedObject.type) && props.storedObject.status !== 'stored_object_created'">
|
||||||
<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.storedObject.type != 'application/pdf' && is_extension_viewable(props.storedObject.type) && props.canConvertPdf">
|
<li v-if="props.canEdit && is_extension_editable(props.storedObject.type) && props.davLink !== undefined && props.davLinkExpiration !== undefined">
|
||||||
|
<desktop-edit-button :classes="{'dropdown-item': true}" :edit-link="props.davLink" :expiration-link="props.davLinkExpiration"></desktop-edit-button>
|
||||||
|
</li>
|
||||||
|
<li v-if="props.storedObject.type != 'application/pdf' && is_extension_viewable(props.storedObject.type) && props.canConvertPdf && props.storedObject.status !== 'stored_object_created'">
|
||||||
<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="props.canDownload">
|
||||||
@@ -32,13 +35,14 @@ 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,
|
StoredObject, StoredObjectCreated,
|
||||||
StoredObjectStatusChange,
|
StoredObjectStatusChange,
|
||||||
WopiEditButtonExecutableBeforeLeaveFunction
|
WopiEditButtonExecutableBeforeLeaveFunction
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
import DesktopEditButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DesktopEditButton.vue";
|
||||||
|
|
||||||
interface DocumentActionButtonsGroupConfig {
|
interface DocumentActionButtonsGroupConfig {
|
||||||
storedObject: StoredObject,
|
storedObject: StoredObject|StoredObjectCreated,
|
||||||
small?: boolean,
|
small?: boolean,
|
||||||
canEdit?: boolean,
|
canEdit?: boolean,
|
||||||
canDownload?: boolean,
|
canDownload?: boolean,
|
||||||
@@ -57,6 +61,16 @@ interface DocumentActionButtonsGroupConfig {
|
|||||||
* If set, will execute this function before leaving to the editor
|
* If set, will execute this function before leaving to the editor
|
||||||
*/
|
*/
|
||||||
executeBeforeLeave?: WopiEditButtonExecutableBeforeLeaveFunction,
|
executeBeforeLeave?: WopiEditButtonExecutableBeforeLeaveFunction,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* a link to download and edit file using webdav
|
||||||
|
*/
|
||||||
|
davLink?: string,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* the expiration date of the download, as a unix timestamp
|
||||||
|
*/
|
||||||
|
davLinkExpiration?: number,
|
||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -68,7 +82,7 @@ const props = withDefaults(defineProps<DocumentActionButtonsGroupConfig>(), {
|
|||||||
canEdit: true,
|
canEdit: true,
|
||||||
canDownload: true,
|
canDownload: true,
|
||||||
canConvertPdf: true,
|
canConvertPdf: true,
|
||||||
returnPath: window.location.pathname + window.location.search + window.location.hash,
|
returnPath: window.location.pathname + window.location.search + window.location.hash
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -85,6 +99,7 @@ const checkForReady = function(): void {
|
|||||||
if (
|
if (
|
||||||
'ready' === props.storedObject.status
|
'ready' === 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
|
||||||
) {
|
) {
|
||||||
@@ -97,6 +112,11 @@ const checkForReady = function(): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onObjectNewStatusCallback = async function(): Promise<void> {
|
const onObjectNewStatusCallback = async function(): Promise<void> {
|
||||||
|
|
||||||
|
if (props.storedObject.status === 'stored_object_created') {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
const new_status = await is_object_ready(props.storedObject);
|
const new_status = await is_object_ready(props.storedObject);
|
||||||
if (props.storedObject.status !== new_status.status) {
|
if (props.storedObject.status !== new_status.status) {
|
||||||
emit('onStoredObjectStatusChange', new_status);
|
emit('onStoredObjectStatusChange', new_status);
|
||||||
|
@@ -0,0 +1,155 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
import {StoredObject, StoredObjectCreated} from "../../types";
|
||||||
|
import {encryptFile, uploadFile} from "../_components/helper";
|
||||||
|
import {computed, ref, Ref} from "vue";
|
||||||
|
|
||||||
|
interface DropFileConfig {
|
||||||
|
existingDoc?: StoredObjectCreated|StoredObject,
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<DropFileConfig>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'addDocument', stored_object: StoredObjectCreated): void,
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const is_dragging: Ref<boolean> = ref(false);
|
||||||
|
const uploading: Ref<boolean> = ref(false);
|
||||||
|
|
||||||
|
const has_existing_doc = computed<boolean>(() => {
|
||||||
|
return props.existingDoc !== undefined && props.existingDoc !== null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const onDragOver = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
is_dragging.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDragLeave = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
is_dragging.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDrop = (e: DragEvent) => {
|
||||||
|
console.log('on drop', e);
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const files = e.dataTransfer?.files;
|
||||||
|
|
||||||
|
if (null === files || undefined === files) {
|
||||||
|
console.error("no files transferred", e.dataTransfer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (files.length === 0) {
|
||||||
|
console.error("no files given");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFile(files[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
const onZoneClick = (e: Event) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "file";
|
||||||
|
input.addEventListener("change", onFileChange);
|
||||||
|
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
const onFileChange = async (event: Event): Promise<void> => {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
console.log('event triggered', input);
|
||||||
|
|
||||||
|
if (input.files && input.files[0]) {
|
||||||
|
console.log('file added', input.files[0]);
|
||||||
|
const file = input.files[0];
|
||||||
|
await handleFile(file);
|
||||||
|
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw 'No file given';
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFile = async (file: File): Promise<void> => {
|
||||||
|
uploading.value = true;
|
||||||
|
const type = file.type;
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
const [encrypted, iv, jsonWebKey] = await encryptFile(buffer);
|
||||||
|
const filename = await uploadFile(encrypted);
|
||||||
|
|
||||||
|
console.log(iv, jsonWebKey);
|
||||||
|
|
||||||
|
const storedObject: StoredObjectCreated = {
|
||||||
|
filename: filename,
|
||||||
|
iv,
|
||||||
|
keyInfos: jsonWebKey,
|
||||||
|
type: type,
|
||||||
|
status: "stored_object_created",
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('addDocument', storedObject);
|
||||||
|
uploading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="drop-file">
|
||||||
|
<div v-if="!uploading" :class="{ area: true, dragging: is_dragging}" @click="onZoneClick" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
|
||||||
|
<p v-if="has_existing_doc">
|
||||||
|
<i class="fa fa-file-pdf-o" v-if="props.existingDoc?.type === 'application/pdf'"></i>
|
||||||
|
<i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/vnd.oasis.opendocument.text'"></i>
|
||||||
|
<i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'"></i>
|
||||||
|
<i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/msword'"></i>
|
||||||
|
<i class="fa fa-file-excel-o" v-else-if="props.existingDoc?.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'"></i>
|
||||||
|
<i class="fa fa-file-excel-o" v-else-if="props.existingDoc?.type === 'application/vnd.ms-excel'"></i>
|
||||||
|
<i class="fa fa-file-image-o" v-else-if="props.existingDoc?.type === 'image/jpeg'"></i>
|
||||||
|
<i class="fa fa-file-image-o" v-else-if="props.existingDoc?.type === 'image/png'"></i>
|
||||||
|
<i class="fa fa-file-archive-o" v-else-if="props.existingDoc?.type === 'application/x-zip-compressed'"></i>
|
||||||
|
<i class="fa fa-file-code-o" v-else ></i>
|
||||||
|
</p>
|
||||||
|
<!-- todo i18n -->
|
||||||
|
<p v-if="has_existing_doc">Déposez un document ou cliquez ici pour remplacer le document existant</p>
|
||||||
|
<p v-else>Déposez un document ou cliquez ici pour ouvrir le navigateur de fichier</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="waiting">
|
||||||
|
<i class="fa fa-cog fa-spin fa-3x fa-fw"></i>
|
||||||
|
<span class="sr-only">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.drop-file {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
& > .area, & > .waiting {
|
||||||
|
width: 100%;
|
||||||
|
height: 8rem;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > .area {
|
||||||
|
border: 4px dashed #ccc;
|
||||||
|
|
||||||
|
&.dragging {
|
||||||
|
border: 4px dashed blue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div.chill-collection ul.list-entry li.entry:nth-child(2n) {
|
||||||
|
|
||||||
|
}
|
||||||
|
</style>
|
@@ -0,0 +1,83 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
import {StoredObject, StoredObjectCreated} from "../../types";
|
||||||
|
import {computed, ref, Ref} from "vue";
|
||||||
|
import DropFile from "ChillDocStoreAssets/vuejs/DropFileWidget/DropFile.vue";
|
||||||
|
import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue";
|
||||||
|
|
||||||
|
interface DropFileConfig {
|
||||||
|
allowRemove: boolean,
|
||||||
|
existingDoc?: StoredObjectCreated|StoredObject,
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<DropFileConfig>(), {
|
||||||
|
allowRemove: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'addDocument', stored_object: StoredObjectCreated): void,
|
||||||
|
(e: 'removeDocument', stored_object: null): void
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const has_existing_doc = computed<boolean>(() => {
|
||||||
|
return props.existingDoc !== undefined && props.existingDoc !== null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const dav_link_expiration = computed<number|undefined>(() => {
|
||||||
|
if (props.existingDoc === undefined || props.existingDoc === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (props.existingDoc.status !== 'ready') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.existingDoc._links?.dav_link?.expiration;
|
||||||
|
});
|
||||||
|
|
||||||
|
const dav_link_href = computed<string|undefined>(() => {
|
||||||
|
if (props.existingDoc === undefined || props.existingDoc === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (props.existingDoc.status !== 'ready') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.existingDoc._links?.dav_link?.href;
|
||||||
|
})
|
||||||
|
|
||||||
|
const onAddDocument = (s: StoredObjectCreated): void => {
|
||||||
|
emit('addDocument', s);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onRemoveDocument = (e: Event): void => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
emit('removeDocument', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<drop-file :existingDoc="props.existingDoc" @addDocument="onAddDocument"></drop-file>
|
||||||
|
|
||||||
|
<ul class="record_actions">
|
||||||
|
<li v-if="has_existing_doc">
|
||||||
|
<document-action-buttons-group
|
||||||
|
:stored-object="props.existingDoc"
|
||||||
|
:can-edit="props.existingDoc?.status === 'ready'"
|
||||||
|
:can-download="true"
|
||||||
|
:dav-link="dav_link_href"
|
||||||
|
:dav-link-expiration="dav_link_expiration"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button v-if="allowRemove" class="btn btn-delete" @click="onRemoveDocument($event)" ></button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
|
||||||
|
</style>
|
@@ -10,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} from "vue";
|
import {reactive} from "vue";
|
||||||
import {StoredObject} from "../../types";
|
import {StoredObject, StoredObjectCreated} from "../../types";
|
||||||
|
|
||||||
interface ConvertButtonConfig {
|
interface ConvertButtonConfig {
|
||||||
storedObject: StoredObject,
|
storedObject: StoredObject,
|
||||||
|
@@ -0,0 +1,66 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
|
||||||
|
import {computed, reactive} from "vue";
|
||||||
|
|
||||||
|
export interface DesktopEditButtonConfig {
|
||||||
|
editLink: null,
|
||||||
|
classes: { [k: string]: boolean },
|
||||||
|
expirationLink: number|Date,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DesktopEditButtonState {
|
||||||
|
modalOpened: boolean
|
||||||
|
};
|
||||||
|
|
||||||
|
const state: DesktopEditButtonState = reactive({modalOpened: false});
|
||||||
|
|
||||||
|
const props = defineProps<DesktopEditButtonConfig>();
|
||||||
|
|
||||||
|
const buildCommand = computed<string>(() => 'vnd.libreoffice.command:ofe|u|' + props.editLink);
|
||||||
|
|
||||||
|
const editionUntilFormatted = computed<string>(() => {
|
||||||
|
let d;
|
||||||
|
|
||||||
|
if (props.expirationLink instanceof Date) {
|
||||||
|
d = props.expirationLink;
|
||||||
|
} else {
|
||||||
|
d = new Date(props.expirationLink * 1000);
|
||||||
|
}
|
||||||
|
console.log(props.expirationLink);
|
||||||
|
|
||||||
|
return (new Intl.DateTimeFormat(undefined, {'dateStyle': 'long', 'timeStyle': 'medium'})).format(d);
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<teleport to="body">
|
||||||
|
<modal v-if="state.modalOpened" @close="state.modalOpened=false">
|
||||||
|
<template v-slot:body>
|
||||||
|
<div class="desktop-edit">
|
||||||
|
<p class="center">Veuillez enregistrer vos modifications avant le</p>
|
||||||
|
<p><strong>{{ editionUntilFormatted }}</strong></p>
|
||||||
|
|
||||||
|
<p><a class="btn btn-primary" :href="buildCommand">Ouvrir le document pour édition</a></p>
|
||||||
|
|
||||||
|
<p><small>Le document peut être édité uniquement en utilisant Libre Office.</small></p>
|
||||||
|
|
||||||
|
<p><small>En cas d'échec lors de l'enregistrement, sauver le document sur le poste de travail avant de le déposer à nouveau ici.</small></p>
|
||||||
|
|
||||||
|
<p><small>Vous pouvez naviguez sur d'autres pages pendant l'édition.</small></p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</modal>
|
||||||
|
</teleport>
|
||||||
|
<a :class="props.classes" @click="state.modalOpened = true">
|
||||||
|
<i class="fa fa-desktop"></i>
|
||||||
|
Éditer sur le bureau
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.desktop-edit {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
@@ -13,10 +13,10 @@
|
|||||||
import {reactive, ref, nextTick, onMounted} from "vue";
|
import {reactive, ref, nextTick, onMounted} from "vue";
|
||||||
import {build_download_info_link, download_and_decrypt_doc} from "./helpers";
|
import {build_download_info_link, download_and_decrypt_doc} from "./helpers";
|
||||||
import mime from "mime";
|
import mime from "mime";
|
||||||
import {StoredObject} from "../../types";
|
import {StoredObject, StoredObjectCreated} from "../../types";
|
||||||
|
|
||||||
interface DownloadButtonConfig {
|
interface DownloadButtonConfig {
|
||||||
storedObject: StoredObject,
|
storedObject: StoredObject|StoredObjectCreated,
|
||||||
classes: { [k: string]: boolean },
|
classes: { [k: string]: boolean },
|
||||||
filename?: string,
|
filename?: string,
|
||||||
}
|
}
|
||||||
|
@@ -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, WopiEditButtonExecutableBeforeLeaveFunction} from "../../types";
|
import {StoredObject, StoredObjectCreated, WopiEditButtonExecutableBeforeLeaveFunction} from "../../types";
|
||||||
|
|
||||||
interface WopiEditButtonConfig {
|
interface WopiEditButtonConfig {
|
||||||
storedObject: StoredObject,
|
storedObject: StoredObject,
|
||||||
|
@@ -0,0 +1,60 @@
|
|||||||
|
import {makeFetch} from "../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods";
|
||||||
|
import {PostStoreObjectSignature} from "../../types";
|
||||||
|
|
||||||
|
const algo = 'AES-CBC';
|
||||||
|
|
||||||
|
const URL_POST = '/asyncupload/temp_url/generate/post';
|
||||||
|
|
||||||
|
const keyDefinition = {
|
||||||
|
name: algo,
|
||||||
|
length: 256
|
||||||
|
};
|
||||||
|
|
||||||
|
const createFilename = (): string => {
|
||||||
|
var text = "";
|
||||||
|
var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
text += possible.charAt(Math.floor(Math.random() * possible.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const uploadFile = async (uploadFile: ArrayBuffer): Promise<string> => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append('expires_delay', "180");
|
||||||
|
params.append('submit_delay', "180");
|
||||||
|
const asyncData: PostStoreObjectSignature = await makeFetch("GET", URL_POST + "?" + params.toString());
|
||||||
|
const suffix = createFilename();
|
||||||
|
const filename = asyncData.prefix + suffix;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("redirect", asyncData.redirect);
|
||||||
|
formData.append("max_file_size", asyncData.max_file_size.toString());
|
||||||
|
formData.append("max_file_count", asyncData.max_file_count.toString());
|
||||||
|
formData.append("expires", asyncData.expires.toString());
|
||||||
|
formData.append("signature", asyncData.signature);
|
||||||
|
formData.append(filename, new Blob([uploadFile]), suffix);
|
||||||
|
|
||||||
|
const response = await window.fetch(asyncData.url, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error("Error while sending file to store", response);
|
||||||
|
throw new Error(response.statusText);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const encryptFile = async (originalFile: ArrayBuffer): Promise<[ArrayBuffer, Uint8Array, JsonWebKey]> => {
|
||||||
|
console.log('encrypt', originalFile);
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
const key = await window.crypto.subtle.generateKey(keyDefinition, true, [ "encrypt", "decrypt" ]);
|
||||||
|
const exportedKey = await window.crypto.subtle.exportKey('jwk', key);
|
||||||
|
const encrypted = await window.crypto.subtle.encrypt({ name: algo, iv: iv}, key, originalFile);
|
||||||
|
|
||||||
|
return Promise.resolve([encrypted, iv, exportedKey]);
|
||||||
|
};
|
@@ -3,5 +3,7 @@
|
|||||||
data-download-buttons
|
data-download-buttons
|
||||||
data-stored-object="{{ document_json|json_encode|escape('html_attr') }}"
|
data-stored-object="{{ document_json|json_encode|escape('html_attr') }}"
|
||||||
data-can-edit="{{ can_edit ? '1' : '0' }}"
|
data-can-edit="{{ can_edit ? '1' : '0' }}"
|
||||||
|
data-dav-link="{{ dav_link|escape('html_attr') }}"
|
||||||
|
data-dav-link-expiration="{{ dav_link_expiration|escape('html_attr') }}"
|
||||||
{% if options['small'] is defined %}data-button-small="{{ options['small'] ? '1' : '0' }}"{% endif %}
|
{% if options['small'] is defined %}data-button-small="{{ options['small'] ? '1' : '0' }}"{% endif %}
|
||||||
{% if title|default(document.title)|default(null) is not null %}data-filename="{{ title|default(document.title)|escape('html_attr') }}"{% endif %}></div>
|
{% if title|default(document.title)|default(null) is not null %}data-filename="{{ title|default(document.title)|escape('html_attr') }}"{% endif %}></div>
|
||||||
|
@@ -1,23 +1,7 @@
|
|||||||
{% block stored_object_widget %}
|
{% block stored_object_widget %}
|
||||||
{% if form.title is defined %} {{ form_row(form.title) }} {% endif %}
|
{% if form.title is defined %} {{ form_row(form.title) }} {% endif %}
|
||||||
<div
|
<div
|
||||||
data-stored-object="data-stored-object"
|
data-stored-object="data-stored-object">
|
||||||
data-label-preparing="{{ ('Preparing'|trans ~ '...')|escape('html_attr') }}"
|
{{ form_widget(form.stored_object, { 'attr': { 'data-stored-object': 1 } }) }}
|
||||||
data-label-quiet-button="{{ 'Download existing file'|trans|escape('html_attr') }}"
|
|
||||||
data-label-ready="{{ 'Ready to show'|trans|escape('html_attr') }}"
|
|
||||||
data-dict-file-too-big="{{ 'File too big'|trans|escape('html_attr') }}"
|
|
||||||
data-dict-default-message="{{ "Drop your file or click here"|trans|escape('html_attr') }}"
|
|
||||||
data-dict-remove-file="{{ 'Remove file in order to upload a new one'|trans|escape('html_attr') }}"
|
|
||||||
data-dict-max-files-exceeded="{{ 'Max files exceeded. Remove previous files'|trans|escape('html_attr') }}"
|
|
||||||
data-dict-cancel-upload="{{ 'Cancel upload'|trans|escape('html_attr') }}"
|
|
||||||
data-dict-cancel-upload-confirm="{{ 'Are you sure you want to cancel this upload ?'|trans|escape('html_attr') }}"
|
|
||||||
data-dict-upload-canceled="{{ 'Upload canceled'|trans|escape('html_attr') }}"
|
|
||||||
data-dict-remove="{{ 'Remove existing file'|trans|escape('html_attr') }}"
|
|
||||||
data-allow-remove="{% if required %}false{% else %}true{% endif %}"
|
|
||||||
data-temp-url-generator="{{ path('async_upload.generate_url', { 'method': 'GET' })|escape('html_attr') }}">
|
|
||||||
{{ form_widget(form.filename) }}
|
|
||||||
{{ form_widget(form.keyInfos, { 'attr': { 'data-stored-object-key': 1 } }) }}
|
|
||||||
{{ form_widget(form.iv, { 'attr': { 'data-stored-object-iv': 1 } }) }}
|
|
||||||
{{ form_widget(form.type, { 'attr': { 'data-async-file-type': 1 } }) }}
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Directory for {{ stored_object.uuid }}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<ul>
|
||||||
|
<li><a href="{{ absolute_url(path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token': access_token })) }}">d</a></li>
|
||||||
|
</ul>
|
||||||
|
</body>
|
||||||
|
</html>
|
@@ -0,0 +1,81 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<d:multistatus xmlns:d="DAV:">
|
||||||
|
<d:response>
|
||||||
|
<d:href>{{ path('chill_docstore_dav_directory_get', { 'uuid': stored_object.uuid, 'access_token': access_token } ) }}</d:href>
|
||||||
|
{% if properties.resourceType or properties.contentType %}
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
{% if properties.resourceType %}
|
||||||
|
<d:resourcetype><d:collection/></d:resourcetype>
|
||||||
|
{% endif %}
|
||||||
|
{% if properties.contentType %}
|
||||||
|
<d:getcontenttype>httpd/unix-directory</d:getcontenttype>
|
||||||
|
{% endif %}
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
{% endif %}
|
||||||
|
{% if properties.unknowns|length > 0 %}
|
||||||
|
<d:propstat>
|
||||||
|
{% for k,u in properties.unknowns %}
|
||||||
|
<d:prop {{ ('xmlns:ns' ~ k ~ '="' ~ u.xmlns|e('html_attr') ~ '"')|raw }}>
|
||||||
|
<{{ 'ns'~ k ~ ':' ~ u.prop }} />
|
||||||
|
</d:prop>
|
||||||
|
{% endfor %}
|
||||||
|
<d:status>HTTP/1.1 404 Not Found</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
{% endif %}
|
||||||
|
</d:response>
|
||||||
|
{% if depth == 1 %}
|
||||||
|
<d:response>
|
||||||
|
<d:href>{{ path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token':access_token}) }}</d:href>
|
||||||
|
{% if properties.lastModified or properties.contentLength or properties.resourceType or properties.etag or properties.contentType or properties.creationDate %}
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
{% if properties.resourceType %}
|
||||||
|
<d:resourcetype/>
|
||||||
|
{% endif %}
|
||||||
|
{% if properties.creationDate %}
|
||||||
|
<d:creationdate />
|
||||||
|
{% endif %}
|
||||||
|
{% if properties.lastModified %}
|
||||||
|
{% if last_modified is not same as null %}
|
||||||
|
<d:getlastmodified>{{ last_modified.format(constant('DATE_RSS')) }}</d:getlastmodified>
|
||||||
|
{% else %}
|
||||||
|
<d:getlastmodified />
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% if properties.contentLength %}
|
||||||
|
{% if content_length is not same as null %}
|
||||||
|
<d:getcontentlength>{{ content_length }}</d:getcontentlength>
|
||||||
|
{% else %}
|
||||||
|
<d:getcontentlength />
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% if properties.etag %}
|
||||||
|
{% if etag is not same as null %}
|
||||||
|
<d:getetag>"{{ etag }}"</d:getetag>
|
||||||
|
{% else %}
|
||||||
|
<d:getetag />
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% if properties.contentType %}
|
||||||
|
<d:getcontenttype>{{ stored_object.type }}</d:getcontenttype>
|
||||||
|
{% endif %}
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
{% endif %}
|
||||||
|
{% if properties.unknowns|length > 0 %}
|
||||||
|
<d:propstat>
|
||||||
|
{% for k,u in properties.unknowns %}
|
||||||
|
<d:prop {{ ('xmlns:ns' ~ k ~ '="' ~ u.xmlns|e('html_attr') ~ '"')|raw }}>
|
||||||
|
<{{ 'ns'~ k ~ ':' ~ u.prop }} />
|
||||||
|
</d:prop>
|
||||||
|
{% endfor %}
|
||||||
|
<d:status>HTTP/1.1 404 Not Found</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
{% endif %}
|
||||||
|
</d:response>
|
||||||
|
{% endif %}
|
||||||
|
</d:multistatus>
|
@@ -0,0 +1,53 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<d:multistatus xmlns:d="DAV:">
|
||||||
|
<d:response>
|
||||||
|
<d:href>{{ path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token': access_token}) }}</d:href>
|
||||||
|
{% if properties.lastModified or properties.contentLength or properties.resourceType or properties.etag or properties.contentType or properties.creationDate %}
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
{% if properties.resourceType %}
|
||||||
|
<d:resourcetype/>
|
||||||
|
{% endif %}
|
||||||
|
{% if properties.creationDate %}
|
||||||
|
<d:creationdate />
|
||||||
|
{% endif %}
|
||||||
|
{% if properties.lastModified %}
|
||||||
|
{% if last_modified is not same as null %}
|
||||||
|
<d:getlastmodified>{{ last_modified.format(constant('DATE_RSS')) }}</d:getlastmodified>
|
||||||
|
{% else %}
|
||||||
|
<d:getlastmodified />
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% if properties.contentLength %}
|
||||||
|
{% if content_length is not same as null %}
|
||||||
|
<d:getcontentlength>{{ content_length }}</d:getcontentlength>
|
||||||
|
{% else %}
|
||||||
|
<d:getcontentlength />
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% if properties.etag %}
|
||||||
|
{% if etag is not same as null %}
|
||||||
|
<d:getetag>"{{ etag }}"</d:getetag>
|
||||||
|
{% else %}
|
||||||
|
<d:getetag />
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% if properties.contentType %}
|
||||||
|
<d:getcontenttype>{{ stored_object.type }}</d:getcontenttype>
|
||||||
|
{% endif %}
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
{% endif %}
|
||||||
|
{% if properties.unknowns|length > 0 %}
|
||||||
|
<d:propstat>
|
||||||
|
{% for k,u in properties.unknowns %}
|
||||||
|
<d:prop {{ ('xmlns:ns' ~ k ~ '="' ~ u.xmlns|e('html_attr') ~ '"')|raw }}>
|
||||||
|
<{{ 'ns'~ k ~ ':' ~ u.prop }} />
|
||||||
|
</d:prop>
|
||||||
|
{% endfor %}
|
||||||
|
<d:status>HTTP/1.1 404 Not Found</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
{% endif %}
|
||||||
|
</d:response>
|
||||||
|
</d:multistatus>
|
@@ -0,0 +1,7 @@
|
|||||||
|
{% extends '@ChillMain/layout.html.twig' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<p>document uuid: {{ stored_object.uuid }}</p>
|
||||||
|
<p>{{ absolute_url(path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token': access_token })) }}</p>
|
||||||
|
<a href="vnd.libreoffice.command:ofe|u|{{ absolute_url(path('chill_docstore_dav_document_get', {'uuid': stored_object.uuid, 'access_token': access_token })) }}">Open document</a>
|
||||||
|
{% endblock %}
|
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Security\Authorization;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role to edit or see the stored object content.
|
||||||
|
*/
|
||||||
|
enum StoredObjectRoleEnum: string
|
||||||
|
{
|
||||||
|
case SEE = 'SEE';
|
||||||
|
|
||||||
|
case EDIT = 'SEE_AND_EDIT';
|
||||||
|
}
|
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Security\Authorization;
|
||||||
|
|
||||||
|
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||||
|
use Chill\DocStoreBundle\Security\Guard\DavTokenAuthenticationEventSubscriber;
|
||||||
|
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||||
|
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Voter for the content of a stored object.
|
||||||
|
*
|
||||||
|
* This is in use to allow or disallow the edition of the stored object's content.
|
||||||
|
*/
|
||||||
|
class StoredObjectVoter extends Voter
|
||||||
|
{
|
||||||
|
protected function supports($attribute, $subject): bool
|
||||||
|
{
|
||||||
|
return StoredObjectRoleEnum::tryFrom($attribute) instanceof StoredObjectRoleEnum
|
||||||
|
&& $subject instanceof StoredObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
|
||||||
|
{
|
||||||
|
/** @var StoredObject $subject */
|
||||||
|
if (
|
||||||
|
!$token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)
|
||||||
|
|| $subject->getUuid()->toString() !== $token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$askedRole = StoredObjectRoleEnum::from($attribute);
|
||||||
|
$tokenRoleAuthorization =
|
||||||
|
$token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS);
|
||||||
|
|
||||||
|
return match ($askedRole) {
|
||||||
|
StoredObjectRoleEnum::SEE => StoredObjectRoleEnum::EDIT === $tokenRoleAuthorization || StoredObjectRoleEnum::SEE === $tokenRoleAuthorization,
|
||||||
|
StoredObjectRoleEnum::EDIT => StoredObjectRoleEnum::EDIT === $tokenRoleAuthorization
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Security\Guard;
|
||||||
|
|
||||||
|
use Lexik\Bundle\JWTAuthenticationBundle\TokenExtractor\TokenExtractorInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the JWT Token from the segment of the dav endpoints.
|
||||||
|
*
|
||||||
|
* A segment is a separation inside the string, using the character "/".
|
||||||
|
*
|
||||||
|
* For recognizing the JWT, the first segment must be "dav", and the second one must be
|
||||||
|
* the JWT endpoint.
|
||||||
|
*/
|
||||||
|
final readonly class DavOnUrlTokenExtractor implements TokenExtractorInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function extract(Request $request): false|string
|
||||||
|
{
|
||||||
|
$uri = $request->getRequestUri();
|
||||||
|
|
||||||
|
$segments = array_values(
|
||||||
|
array_filter(
|
||||||
|
explode('/', $uri),
|
||||||
|
fn ($item) => '' !== trim($item)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (2 > count($segments)) {
|
||||||
|
$this->logger->info('not enough segment for parsing URL');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('dav' !== $segments[0]) {
|
||||||
|
$this->logger->info('the first segment of the url must be DAV');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $segments[1];
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Security\Guard;
|
||||||
|
|
||||||
|
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||||
|
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTAuthenticatedEvent;
|
||||||
|
use Lexik\Bundle\JWTAuthenticationBundle\Events;
|
||||||
|
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store some data from the JWT's payload inside the token's attributes.
|
||||||
|
*/
|
||||||
|
class DavTokenAuthenticationEventSubscriber implements EventSubscriberInterface
|
||||||
|
{
|
||||||
|
final public const STORED_OBJECT = 'stored_object';
|
||||||
|
final public const ACTIONS = 'stored_objects_actions';
|
||||||
|
|
||||||
|
public static function getSubscribedEvents(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Events::JWT_AUTHENTICATED => ['onJWTAuthenticated', 0],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onJWTAuthenticated(JWTAuthenticatedEvent $event): void
|
||||||
|
{
|
||||||
|
$payload = $event->getPayload();
|
||||||
|
|
||||||
|
if (!(array_key_exists('dav', $payload) && 1 === $payload['dav'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = $event->getToken();
|
||||||
|
$token->setAttribute(self::ACTIONS, match ($payload['e']) {
|
||||||
|
0 => StoredObjectRoleEnum::SEE,
|
||||||
|
1 => StoredObjectRoleEnum::EDIT,
|
||||||
|
default => throw new \UnexpectedValueException('unsupported value for e parameter')
|
||||||
|
});
|
||||||
|
|
||||||
|
$token->setAttribute(self::STORED_OBJECT, $payload['so']);
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Security\Guard;
|
||||||
|
|
||||||
|
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||||
|
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||||
|
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
|
||||||
|
use Symfony\Component\Security\Core\Security;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provide a JWT Token which will be valid for viewing or editing a document.
|
||||||
|
*/
|
||||||
|
final readonly class JWTDavTokenProvider implements JWTDavTokenProviderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private JWTTokenManagerInterface $JWTTokenManager,
|
||||||
|
private Security $security,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createToken(StoredObject $storedObject, StoredObjectRoleEnum $roleEnum): string
|
||||||
|
{
|
||||||
|
return $this->JWTTokenManager->createFromPayload($this->security->getUser(), [
|
||||||
|
'dav' => 1,
|
||||||
|
'e' => match ($roleEnum) {
|
||||||
|
StoredObjectRoleEnum::SEE => 0,
|
||||||
|
StoredObjectRoleEnum::EDIT => 1,
|
||||||
|
},
|
||||||
|
'so' => $storedObject->getUuid(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTokenExpiration(string $tokenString): \DateTimeImmutable
|
||||||
|
{
|
||||||
|
$jwt = $this->JWTTokenManager->parse($tokenString);
|
||||||
|
|
||||||
|
return \DateTimeImmutable::createFromFormat('U', (string) $jwt['exp']);
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Security\Guard;
|
||||||
|
|
||||||
|
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||||
|
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provide a JWT Token which will be valid for viewing or editing a document.
|
||||||
|
*/
|
||||||
|
interface JWTDavTokenProviderInterface
|
||||||
|
{
|
||||||
|
public function createToken(StoredObject $storedObject, StoredObjectRoleEnum $roleEnum): string;
|
||||||
|
|
||||||
|
public function getTokenExpiration(string $tokenString): \DateTimeImmutable;
|
||||||
|
}
|
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Security\Guard;
|
||||||
|
|
||||||
|
use Lexik\Bundle\JWTAuthenticationBundle\Security\Guard\JWTTokenAuthenticator;
|
||||||
|
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
|
||||||
|
use Lexik\Bundle\JWTAuthenticationBundle\TokenExtractor\TokenExtractorInterface;
|
||||||
|
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||||
|
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||||
|
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alter the base JWTTokenAuthenticator to add the special extractor for dav url endpoints.
|
||||||
|
*/
|
||||||
|
class JWTOnDavUrlAuthenticator extends JWTTokenAuthenticator
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
JWTTokenManagerInterface $jwtManager,
|
||||||
|
EventDispatcherInterface $dispatcher,
|
||||||
|
TokenExtractorInterface $tokenExtractor,
|
||||||
|
private readonly DavOnUrlTokenExtractor $davOnUrlTokenExtractor,
|
||||||
|
TokenStorageInterface $preAuthenticationTokenStorage,
|
||||||
|
?TranslatorInterface $translator = null,
|
||||||
|
) {
|
||||||
|
parent::__construct($jwtManager, $dispatcher, $tokenExtractor, $preAuthenticationTokenStorage, $translator);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getTokenExtractor()
|
||||||
|
{
|
||||||
|
return $this->davOnUrlTokenExtractor;
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Serializer\Normalizer;
|
||||||
|
|
||||||
|
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||||
|
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||||
|
use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface;
|
||||||
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
|
||||||
|
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
|
||||||
|
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class StoredObjectNormalizer.
|
||||||
|
*
|
||||||
|
* Normalizes a StoredObject entity to an array of data.
|
||||||
|
*/
|
||||||
|
final class StoredObjectNormalizer implements NormalizerInterface, NormalizerAwareInterface
|
||||||
|
{
|
||||||
|
use NormalizerAwareTrait;
|
||||||
|
public const ADD_DAV_SEE_LINK_CONTEXT = 'dav-see-link-context';
|
||||||
|
public const ADD_DAV_EDIT_LINK_CONTEXT = 'dav-edit-link-context';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly JWTDavTokenProviderInterface $JWTDavTokenProvider,
|
||||||
|
private readonly UrlGeneratorInterface $urlGenerator
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function normalize($object, ?string $format = null, array $context = [])
|
||||||
|
{
|
||||||
|
/** @var StoredObject $object */
|
||||||
|
$datas = [
|
||||||
|
'datas' => $object->getDatas(),
|
||||||
|
'filename' => $object->getFilename(),
|
||||||
|
'id' => $object->getId(),
|
||||||
|
'iv' => $object->getIv(),
|
||||||
|
'keyInfos' => $object->getKeyInfos(),
|
||||||
|
'title' => $object->getTitle(),
|
||||||
|
'type' => $object->getType(),
|
||||||
|
'uuid' => $object->getUuid(),
|
||||||
|
'status' => $object->getStatus(),
|
||||||
|
'createdAt' => $this->normalizer->normalize($object->getCreatedAt(), $format, $context),
|
||||||
|
'createdBy' => $this->normalizer->normalize($object->getCreatedBy(), $format, $context),
|
||||||
|
];
|
||||||
|
|
||||||
|
// deprecated property
|
||||||
|
$datas['creationDate'] = $datas['createdAt'];
|
||||||
|
|
||||||
|
$canDavSee = in_array(self::ADD_DAV_SEE_LINK_CONTEXT, $context['groups'] ?? [], true);
|
||||||
|
$canDavEdit = in_array(self::ADD_DAV_EDIT_LINK_CONTEXT, $context['groups'] ?? [], true);
|
||||||
|
|
||||||
|
if ($canDavSee || $canDavEdit) {
|
||||||
|
$accessToken = $this->JWTDavTokenProvider->createToken(
|
||||||
|
$object,
|
||||||
|
$canDavEdit ? StoredObjectRoleEnum::EDIT : StoredObjectRoleEnum::SEE
|
||||||
|
);
|
||||||
|
|
||||||
|
$datas['_links'] = [
|
||||||
|
'dav_link' => [
|
||||||
|
'href' => $this->urlGenerator->generate(
|
||||||
|
'chill_docstore_dav_document_get',
|
||||||
|
[
|
||||||
|
'uuid' => $object->getUuid(),
|
||||||
|
'access_token' => $accessToken,
|
||||||
|
],
|
||||||
|
UrlGeneratorInterface::ABSOLUTE_URL,
|
||||||
|
),
|
||||||
|
'expiration' => $this->JWTDavTokenProvider->getTokenExpiration($accessToken)->format('U'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $datas;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function supportsNormalization($data, ?string $format = null)
|
||||||
|
{
|
||||||
|
return $data instanceof StoredObject && 'json' === $format;
|
||||||
|
}
|
||||||
|
}
|
@@ -57,6 +57,62 @@ final class StoredObjectManager implements StoredObjectManagerInterface
|
|||||||
return $this->extractLastModifiedFromResponse($response);
|
return $this->extractLastModifiedFromResponse($response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getContentLength(StoredObject $document): int
|
||||||
|
{
|
||||||
|
if ([] === $document->getKeyInfos()) {
|
||||||
|
if ($this->hasCache($document)) {
|
||||||
|
$response = $this->getResponseFromCache($document);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
$response = $this
|
||||||
|
->client
|
||||||
|
->request(
|
||||||
|
Request::METHOD_HEAD,
|
||||||
|
$this
|
||||||
|
->tempUrlGenerator
|
||||||
|
->generate(
|
||||||
|
Request::METHOD_HEAD,
|
||||||
|
$document->getFilename()
|
||||||
|
)
|
||||||
|
->url
|
||||||
|
);
|
||||||
|
} catch (TransportExceptionInterface $exception) {
|
||||||
|
throw StoredObjectManagerException::errorDuringHttpRequest($exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->extractContentLengthFromResponse($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
return strlen($this->read($document));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function etag(StoredObject $document): string
|
||||||
|
{
|
||||||
|
if ($this->hasCache($document)) {
|
||||||
|
$response = $this->getResponseFromCache($document);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
$response = $this
|
||||||
|
->client
|
||||||
|
->request(
|
||||||
|
Request::METHOD_HEAD,
|
||||||
|
$this
|
||||||
|
->tempUrlGenerator
|
||||||
|
->generate(
|
||||||
|
Request::METHOD_HEAD,
|
||||||
|
$document->getFilename()
|
||||||
|
)
|
||||||
|
->url
|
||||||
|
);
|
||||||
|
} catch (TransportExceptionInterface $exception) {
|
||||||
|
throw StoredObjectManagerException::errorDuringHttpRequest($exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->extractEtagFromResponse($response, $document);
|
||||||
|
}
|
||||||
|
|
||||||
public function read(StoredObject $document): string
|
public function read(StoredObject $document): string
|
||||||
{
|
{
|
||||||
$response = $this->getResponseFromCache($document);
|
$response = $this->getResponseFromCache($document);
|
||||||
@@ -158,6 +214,22 @@ final class StoredObjectManager implements StoredObjectManagerInterface
|
|||||||
return $date;
|
return $date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function extractContentLengthFromResponse(ResponseInterface $response): int
|
||||||
|
{
|
||||||
|
return (int) ($response->getHeaders()['content-length'] ?? ['0'])[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractEtagFromResponse(ResponseInterface $response, StoredObject $storedObject): ?string
|
||||||
|
{
|
||||||
|
$etag = ($response->getHeaders()['etag'] ?? [''])[0];
|
||||||
|
|
||||||
|
if ('' === $etag) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $etag;
|
||||||
|
}
|
||||||
|
|
||||||
private function fillCache(StoredObject $document): void
|
private function fillCache(StoredObject $document): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
@@ -18,6 +18,8 @@ interface StoredObjectManagerInterface
|
|||||||
{
|
{
|
||||||
public function getLastModified(StoredObject $document): \DateTimeInterface;
|
public function getLastModified(StoredObject $document): \DateTimeInterface;
|
||||||
|
|
||||||
|
public function getContentLength(StoredObject $document): int;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the content of a StoredObject.
|
* Get the content of a StoredObject.
|
||||||
*
|
*
|
||||||
@@ -39,5 +41,7 @@ interface StoredObjectManagerInterface
|
|||||||
*/
|
*/
|
||||||
public function write(StoredObject $document, string $clearContent): void;
|
public function write(StoredObject $document, string $clearContent): void;
|
||||||
|
|
||||||
|
public function etag(StoredObject $document): string;
|
||||||
|
|
||||||
public function clearCache(): void;
|
public function clearCache(): void;
|
||||||
}
|
}
|
||||||
|
@@ -13,6 +13,9 @@ namespace Chill\DocStoreBundle\Templating;
|
|||||||
|
|
||||||
use ChampsLibres\WopiLib\Contract\Service\Discovery\DiscoveryInterface;
|
use ChampsLibres\WopiLib\Contract\Service\Discovery\DiscoveryInterface;
|
||||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||||
|
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||||
|
use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface;
|
||||||
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
|
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
|
||||||
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||||
use Twig\Environment;
|
use Twig\Environment;
|
||||||
@@ -120,8 +123,12 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt
|
|||||||
|
|
||||||
private const TEMPLATE_BUTTON_GROUP = '@ChillDocStore/Button/button_group.html.twig';
|
private const TEMPLATE_BUTTON_GROUP = '@ChillDocStore/Button/button_group.html.twig';
|
||||||
|
|
||||||
public function __construct(private DiscoveryInterface $discovery, private NormalizerInterface $normalizer)
|
public function __construct(
|
||||||
{
|
private DiscoveryInterface $discovery,
|
||||||
|
private NormalizerInterface $normalizer,
|
||||||
|
private JWTDavTokenProviderInterface $davTokenProvider,
|
||||||
|
private UrlGeneratorInterface $urlGenerator,
|
||||||
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -132,7 +139,7 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt
|
|||||||
*/
|
*/
|
||||||
public function isEditable(StoredObject $document): bool
|
public function isEditable(StoredObject $document): bool
|
||||||
{
|
{
|
||||||
return \in_array($document->getType(), self::SUPPORTED_MIMES, true);
|
return in_array($document->getType(), self::SUPPORTED_MIMES, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -144,12 +151,26 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt
|
|||||||
*/
|
*/
|
||||||
public function renderButtonGroup(Environment $environment, StoredObject $document, ?string $title = null, bool $canEdit = true, array $options = []): string
|
public function renderButtonGroup(Environment $environment, StoredObject $document, ?string $title = null, bool $canEdit = true, array $options = []): string
|
||||||
{
|
{
|
||||||
|
$accessToken = $this->davTokenProvider->createToken(
|
||||||
|
$document,
|
||||||
|
$canEdit ? StoredObjectRoleEnum::EDIT : StoredObjectRoleEnum::SEE
|
||||||
|
);
|
||||||
|
|
||||||
return $environment->render(self::TEMPLATE_BUTTON_GROUP, [
|
return $environment->render(self::TEMPLATE_BUTTON_GROUP, [
|
||||||
'document' => $document,
|
'document' => $document,
|
||||||
'document_json' => $this->normalizer->normalize($document, 'json', [AbstractNormalizer::GROUPS => ['read']]),
|
'document_json' => $this->normalizer->normalize($document, 'json', [AbstractNormalizer::GROUPS => ['read']]),
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'can_edit' => $canEdit,
|
'can_edit' => $canEdit,
|
||||||
'options' => [...self::DEFAULT_OPTIONS_TEMPLATE_BUTTON_GROUP, ...$options],
|
'options' => [...self::DEFAULT_OPTIONS_TEMPLATE_BUTTON_GROUP, ...$options],
|
||||||
|
'dav_link' => $this->urlGenerator->generate(
|
||||||
|
'chill_docstore_dav_document_get',
|
||||||
|
[
|
||||||
|
'uuid' => $document->getUuid(),
|
||||||
|
'access_token' => $accessToken,
|
||||||
|
],
|
||||||
|
UrlGeneratorInterface::ABSOLUTE_URL,
|
||||||
|
),
|
||||||
|
'dav_link_expiration' => $this->davTokenProvider->getTokenExpiration($accessToken)->format('U'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -0,0 +1,414 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Tests\Controller;
|
||||||
|
|
||||||
|
use Chill\DocStoreBundle\Controller\WebdavController;
|
||||||
|
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||||
|
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
|
||||||
|
use Prophecy\Argument;
|
||||||
|
use Prophecy\PhpUnit\ProphecyTrait;
|
||||||
|
use Ramsey\Uuid\Uuid;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\Security\Core\Security;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*
|
||||||
|
* @coversNothing
|
||||||
|
*/
|
||||||
|
class WebdavControllerTest extends KernelTestCase
|
||||||
|
{
|
||||||
|
use ProphecyTrait;
|
||||||
|
|
||||||
|
private \Twig\Environment $engine;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
self::bootKernel();
|
||||||
|
|
||||||
|
$this->engine = self::$container->get(\Twig\Environment::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildController(): WebdavController
|
||||||
|
{
|
||||||
|
$storedObjectManager = new MockedStoredObjectManager();
|
||||||
|
$security = $this->prophesize(Security::class);
|
||||||
|
$security->isGranted(Argument::in(['EDIT', 'SEE']), Argument::type(StoredObject::class))
|
||||||
|
->willReturn(true);
|
||||||
|
|
||||||
|
return new WebdavController($this->engine, $storedObjectManager, $security->reveal());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildDocument(): StoredObject
|
||||||
|
{
|
||||||
|
$object = (new StoredObject())
|
||||||
|
->setType('application/vnd.oasis.opendocument.text');
|
||||||
|
|
||||||
|
$reflectionObject = new \ReflectionClass($object);
|
||||||
|
$reflectionObjectUuid = $reflectionObject->getProperty('uuid');
|
||||||
|
|
||||||
|
$reflectionObjectUuid->setValue($object, Uuid::fromString('716e6688-4579-4938-acf3-c4ab5856803b'));
|
||||||
|
|
||||||
|
return $object;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGet(): void
|
||||||
|
{
|
||||||
|
$controller = $this->buildController();
|
||||||
|
|
||||||
|
$response = $controller->getDocument($this->buildDocument());
|
||||||
|
|
||||||
|
self::assertEquals(200, $response->getStatusCode());
|
||||||
|
self::assertEquals('abcde', $response->getContent());
|
||||||
|
self::assertContains('etag', $response->headers->keys());
|
||||||
|
self::assertStringContainsString('ab56b4', $response->headers->get('etag'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOptionsOnDocument(): void
|
||||||
|
{
|
||||||
|
$controller = $this->buildController();
|
||||||
|
|
||||||
|
$response = $controller->optionsDocument($this->buildDocument());
|
||||||
|
|
||||||
|
self::assertEquals(200, $response->getStatusCode());
|
||||||
|
self::assertContains('allow', $response->headers->keys());
|
||||||
|
|
||||||
|
foreach (explode(',', 'OPTIONS,GET,HEAD,PROPFIND') as $method) {
|
||||||
|
self::assertStringContainsString($method, $response->headers->get('allow'));
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertContains('dav', $response->headers->keys());
|
||||||
|
self::assertStringContainsString('1', $response->headers->get('dav'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOptionsOnDirectory(): void
|
||||||
|
{
|
||||||
|
$controller = $this->buildController();
|
||||||
|
|
||||||
|
$response = $controller->optionsDirectory($this->buildDocument());
|
||||||
|
|
||||||
|
self::assertEquals(200, $response->getStatusCode());
|
||||||
|
self::assertContains('allow', $response->headers->keys());
|
||||||
|
|
||||||
|
foreach (explode(',', 'OPTIONS,GET,HEAD,PROPFIND') as $method) {
|
||||||
|
self::assertStringContainsString($method, $response->headers->get('allow'));
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertContains('dav', $response->headers->keys());
|
||||||
|
self::assertStringContainsString('1', $response->headers->get('dav'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider generateDataPropfindDocument
|
||||||
|
*/
|
||||||
|
public function testPropfindDocument(string $requestContent, int $expectedStatusCode, string $expectedXmlResponse, string $message): void
|
||||||
|
{
|
||||||
|
$controller = $this->buildController();
|
||||||
|
|
||||||
|
$request = new Request([], [], [], [], [], [], $requestContent);
|
||||||
|
$request->setMethod('PROPFIND');
|
||||||
|
$response = $controller->propfindDocument($this->buildDocument(), '1234', $request);
|
||||||
|
|
||||||
|
self::assertEquals($expectedStatusCode, $response->getStatusCode());
|
||||||
|
self::assertContains('content-type', $response->headers->keys());
|
||||||
|
self::assertStringContainsString('text/xml', $response->headers->get('content-type'));
|
||||||
|
self::assertTrue((new \DOMDocument())->loadXML($response->getContent()), $message.' test that the xml response is a valid xml');
|
||||||
|
self::assertXmlStringEqualsXmlString($expectedXmlResponse, $response->getContent(), $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider generateDataPropfindDirectory
|
||||||
|
*/
|
||||||
|
public function testPropfindDirectory(string $requestContent, int $expectedStatusCode, string $expectedXmlResponse, string $message): void
|
||||||
|
{
|
||||||
|
$controller = $this->buildController();
|
||||||
|
|
||||||
|
$request = new Request([], [], [], [], [], [], $requestContent);
|
||||||
|
$request->setMethod('PROPFIND');
|
||||||
|
$request->headers->add(['Depth' => '0']);
|
||||||
|
$response = $controller->propfindDirectory($this->buildDocument(), '1234', $request);
|
||||||
|
|
||||||
|
self::assertEquals($expectedStatusCode, $response->getStatusCode());
|
||||||
|
self::assertContains('content-type', $response->headers->keys());
|
||||||
|
self::assertStringContainsString('text/xml', $response->headers->get('content-type'));
|
||||||
|
self::assertTrue((new \DOMDocument())->loadXML($response->getContent()), $message.' test that the xml response is a valid xml');
|
||||||
|
self::assertXmlStringEqualsXmlString($expectedXmlResponse, $response->getContent(), $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHeadDocument(): void
|
||||||
|
{
|
||||||
|
$controller = $this->buildController();
|
||||||
|
$response = $controller->headDocument($this->buildDocument());
|
||||||
|
|
||||||
|
self::assertEquals(200, $response->getStatusCode());
|
||||||
|
self::assertContains('content-length', $response->headers->keys());
|
||||||
|
self::assertContains('content-type', $response->headers->keys());
|
||||||
|
self::assertContains('etag', $response->headers->keys());
|
||||||
|
self::assertEquals('ab56b4d92b40713acc5af89985d4b786', $response->headers->get('etag'));
|
||||||
|
self::assertEquals('application/vnd.oasis.opendocument.text', $response->headers->get('content-type'));
|
||||||
|
self::assertEquals(5, $response->headers->get('content-length'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function generateDataPropfindDocument(): iterable
|
||||||
|
{
|
||||||
|
$content =
|
||||||
|
<<<'XML'
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<propfind xmlns="DAV:"><prop><resourcetype xmlns="DAV:"/><IsReadOnly xmlns="http://ucb.openoffice.org/dav/props/"/><getcontenttype xmlns="DAV:"/><supportedlock xmlns="DAV:"/></prop></propfind>
|
||||||
|
XML;
|
||||||
|
|
||||||
|
$response =
|
||||||
|
<<<'XML'
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<d:multistatus xmlns:d="DAV:" >
|
||||||
|
<d:response>
|
||||||
|
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:resourcetype/>
|
||||||
|
<d:getcontenttype>application/vnd.oasis.opendocument.text</d:getcontenttype>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop xmlns:ns0="http://ucb.openoffice.org/dav/props/">
|
||||||
|
<ns0:IsReadOnly/>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 404 Not Found</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
</d:multistatus>
|
||||||
|
XML;
|
||||||
|
|
||||||
|
yield [$content, 207, $response, 'get IsReadOnly and contenttype from server'];
|
||||||
|
|
||||||
|
$content =
|
||||||
|
<<<'XML'
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<propfind xmlns="DAV:">
|
||||||
|
<prop>
|
||||||
|
<IsReadOnly xmlns="http://ucb.openoffice.org/dav/props/"/>
|
||||||
|
</prop>
|
||||||
|
</propfind>
|
||||||
|
XML;
|
||||||
|
|
||||||
|
$response =
|
||||||
|
<<<'XML'
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<d:multistatus xmlns:d="DAV:">
|
||||||
|
<d:response>
|
||||||
|
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop xmlns:ns0="http://ucb.openoffice.org/dav/props/">
|
||||||
|
<ns0:IsReadOnly/>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 404 Not Found</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
</d:multistatus>
|
||||||
|
XML;
|
||||||
|
|
||||||
|
yield [$content, 207, $response, 'get property IsReadOnly'];
|
||||||
|
|
||||||
|
yield [
|
||||||
|
<<<'XML'
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<propfind xmlns="DAV:">
|
||||||
|
<prop>
|
||||||
|
<BaseURI xmlns="http://ucb.openoffice.org/dav/props/"/>
|
||||||
|
</prop>
|
||||||
|
</propfind>
|
||||||
|
XML,
|
||||||
|
207,
|
||||||
|
<<<'XML'
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<d:multistatus xmlns:d="DAV:">
|
||||||
|
<d:response>
|
||||||
|
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop xmlns:ns0="http://ucb.openoffice.org/dav/props/">
|
||||||
|
<ns0:BaseURI/>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 404 Not Found</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
</d:multistatus>
|
||||||
|
XML,
|
||||||
|
'Test requesting an unknow property',
|
||||||
|
];
|
||||||
|
|
||||||
|
yield [
|
||||||
|
<<<'XML'
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<propfind xmlns="DAV:">
|
||||||
|
<prop>
|
||||||
|
<getlastmodified xmlns="DAV:"/>
|
||||||
|
</prop>
|
||||||
|
</propfind>
|
||||||
|
XML,
|
||||||
|
207,
|
||||||
|
<<<'XML'
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<d:multistatus xmlns:d="DAV:">
|
||||||
|
<d:response>
|
||||||
|
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<!-- the date scraped from a webserver is >Sun, 10 Sep 2023 14:10:23 GMT -->
|
||||||
|
<d:getlastmodified>Wed, 13 Sep 2023 14:15:00 +0200</d:getlastmodified>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
</d:multistatus>
|
||||||
|
XML,
|
||||||
|
'test getting the last modified date',
|
||||||
|
];
|
||||||
|
|
||||||
|
yield [
|
||||||
|
<<<'XML'
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<propfind xmlns="DAV:">
|
||||||
|
<propname/>
|
||||||
|
</propfind>
|
||||||
|
XML,
|
||||||
|
207,
|
||||||
|
<<<'XML'
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<d:multistatus xmlns:d="DAV:">
|
||||||
|
<d:response>
|
||||||
|
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:resourcetype/>
|
||||||
|
<d:creationdate/>
|
||||||
|
<d:getlastmodified>Wed, 13 Sep 2023 14:15:00 +0200</d:getlastmodified>
|
||||||
|
<!-- <d:getcontentlength/> -->
|
||||||
|
<d:getcontentlength>5</d:getcontentlength>
|
||||||
|
<!-- <d:getlastmodified/> -->
|
||||||
|
<d:getetag>"ab56b4d92b40713acc5af89985d4b786"</d:getetag>
|
||||||
|
<!--
|
||||||
|
<d:supportedlock/>
|
||||||
|
<d:lockdiscovery/>
|
||||||
|
-->
|
||||||
|
<!-- <d:getcontenttype/> -->
|
||||||
|
<d:getcontenttype>application/vnd.oasis.opendocument.text</d:getcontenttype>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
</d:multistatus>
|
||||||
|
XML,
|
||||||
|
'test finding all properties',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function generateDataPropfindDirectory(): iterable
|
||||||
|
{
|
||||||
|
yield [
|
||||||
|
<<<'XML'
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<propfind xmlns="DAV:"><prop><resourcetype xmlns="DAV:"/><IsReadOnly xmlns="http://ucb.openoffice.org/dav/props/"/><getcontenttype xmlns="DAV:"/><supportedlock xmlns="DAV:"/></prop></propfind>
|
||||||
|
XML,
|
||||||
|
207,
|
||||||
|
<<<'XML'
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<d:multistatus xmlns:d="DAV:">
|
||||||
|
<d:response>
|
||||||
|
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:resourcetype><d:collection/></d:resourcetype>
|
||||||
|
<d:getcontenttype>httpd/unix-directory</d:getcontenttype>
|
||||||
|
<!--
|
||||||
|
<d:supportedlock>
|
||||||
|
<d:lockentry>
|
||||||
|
<d:lockscope><d:exclusive/></d:lockscope>
|
||||||
|
<d:locktype><d:write/></d:locktype>
|
||||||
|
</d:lockentry>
|
||||||
|
<d:lockentry>
|
||||||
|
<d:lockscope><d:shared/></d:lockscope>
|
||||||
|
<d:locktype><d:write/></d:locktype>
|
||||||
|
</d:lockentry>
|
||||||
|
</d:supportedlock>
|
||||||
|
-->
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop xmlns:ns0="http://ucb.openoffice.org/dav/props/">
|
||||||
|
<ns0:IsReadOnly/>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 404 Not Found</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
</d:multistatus>
|
||||||
|
XML,
|
||||||
|
'test resourceType and IsReadOnly ',
|
||||||
|
];
|
||||||
|
|
||||||
|
yield [
|
||||||
|
<<<'XML'
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<propfind xmlns="DAV:"><prop><CreatableContentsInfo xmlns="http://ucb.openoffice.org/dav/props/"/></prop></propfind>
|
||||||
|
XML,
|
||||||
|
207,
|
||||||
|
<<<'XML'
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<d:multistatus xmlns:d="DAV:">
|
||||||
|
<d:response>
|
||||||
|
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop xmlns:ns0="http://ucb.openoffice.org/dav/props/" >
|
||||||
|
<ns0:CreatableContentsInfo/>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 404 Not Found</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
</d:multistatus>
|
||||||
|
XML,
|
||||||
|
'test creatableContentsInfo',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockedStoredObjectManager implements StoredObjectManagerInterface
|
||||||
|
{
|
||||||
|
public function getLastModified(StoredObject $document): \DateTimeInterface
|
||||||
|
{
|
||||||
|
return new \DateTimeImmutable('2023-09-13T14:15');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getContentLength(StoredObject $document): int
|
||||||
|
{
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function read(StoredObject $document): string
|
||||||
|
{
|
||||||
|
return 'abcde';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function write(StoredObject $document, string $clearContent): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function etag(StoredObject $document): string
|
||||||
|
{
|
||||||
|
return 'ab56b4d92b40713acc5af89985d4b786';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearCache(): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,134 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Tests\Dav\Request;
|
||||||
|
|
||||||
|
use Chill\DocStoreBundle\Dav\Request\PropfindRequestAnalyzer;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*
|
||||||
|
* @coversNothing
|
||||||
|
*/
|
||||||
|
class PropfindRequestAnalyzerTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @dataProvider provideRequestedProperties
|
||||||
|
*/
|
||||||
|
public function testGetRequestedProperties(string $xml, array $expected): void
|
||||||
|
{
|
||||||
|
$analyzer = new PropfindRequestAnalyzer();
|
||||||
|
|
||||||
|
$request = new \DOMDocument();
|
||||||
|
$request->loadXML($xml);
|
||||||
|
$actual = $analyzer->getRequestedProperties($request);
|
||||||
|
|
||||||
|
foreach ($expected as $key => $value) {
|
||||||
|
if ('unknowns' === $key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertArrayHasKey($key, $actual, "Check that key {$key} does exists in list of expected values");
|
||||||
|
self::assertEquals($value, $actual[$key], "Does the value match expected for key {$key}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('unknowns', $expected)) {
|
||||||
|
self::assertEquals(count($expected['unknowns']), count($actual['unknowns']));
|
||||||
|
self::assertEqualsCanonicalizing($expected['unknowns'], $actual['unknowns']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function provideRequestedProperties(): iterable
|
||||||
|
{
|
||||||
|
yield [
|
||||||
|
<<<'XML'
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<propfind xmlns="DAV:">
|
||||||
|
<prop>
|
||||||
|
<BaseURI xmlns="http://ucb.openoffice.org/dav/props/"/>
|
||||||
|
</prop>
|
||||||
|
</propfind>
|
||||||
|
XML,
|
||||||
|
[
|
||||||
|
'resourceType' => false,
|
||||||
|
'contentType' => false,
|
||||||
|
'lastModified' => false,
|
||||||
|
'creationDate' => false,
|
||||||
|
'contentLength' => false,
|
||||||
|
'etag' => false,
|
||||||
|
'supportedLock' => false,
|
||||||
|
'unknowns' => [
|
||||||
|
['xmlns' => 'http://ucb.openoffice.org/dav/props/', 'prop' => 'BaseURI'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
yield [
|
||||||
|
<<<'XML'
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<propfind xmlns="DAV:">
|
||||||
|
<propname/>
|
||||||
|
</propfind>
|
||||||
|
XML,
|
||||||
|
[
|
||||||
|
'resourceType' => true,
|
||||||
|
'contentType' => true,
|
||||||
|
'lastModified' => true,
|
||||||
|
'creationDate' => true,
|
||||||
|
'contentLength' => true,
|
||||||
|
'etag' => true,
|
||||||
|
'supportedLock' => true,
|
||||||
|
'unknowns' => [],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
yield [
|
||||||
|
<<<'XML'
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<propfind xmlns="DAV:">
|
||||||
|
<prop>
|
||||||
|
<getlastmodified xmlns="DAV:"/>
|
||||||
|
</prop>
|
||||||
|
</propfind>
|
||||||
|
XML,
|
||||||
|
[
|
||||||
|
'resourceType' => false,
|
||||||
|
'contentType' => false,
|
||||||
|
'lastModified' => true,
|
||||||
|
'creationDate' => false,
|
||||||
|
'contentLength' => false,
|
||||||
|
'etag' => false,
|
||||||
|
'supportedLock' => false,
|
||||||
|
'unknowns' => [],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
yield [
|
||||||
|
<<<'XML'
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<propfind xmlns="DAV:"><prop><resourcetype xmlns="DAV:"/><IsReadOnly xmlns="http://ucb.openoffice.org/dav/props/"/><getcontenttype xmlns="DAV:"/><supportedlock xmlns="DAV:"/></prop></propfind>
|
||||||
|
XML,
|
||||||
|
[
|
||||||
|
'resourceType' => true,
|
||||||
|
'contentType' => true,
|
||||||
|
'lastModified' => false,
|
||||||
|
'creationDate' => false,
|
||||||
|
'contentLength' => false,
|
||||||
|
'etag' => false,
|
||||||
|
'supportedLock' => false,
|
||||||
|
'unknowns' => [
|
||||||
|
['xmlns' => 'http://ucb.openoffice.org/dav/props/', 'prop' => 'IsReadOnly'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Tests\Entity;
|
||||||
|
|
||||||
|
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*
|
||||||
|
* @coversNothing
|
||||||
|
*/
|
||||||
|
class StoredObjectTest extends KernelTestCase
|
||||||
|
{
|
||||||
|
public function testSaveHistory(): void
|
||||||
|
{
|
||||||
|
$storedObject = new StoredObject();
|
||||||
|
$storedObject
|
||||||
|
->setFilename('test_0')
|
||||||
|
->setIv([2, 4, 6, 8])
|
||||||
|
->setKeyInfos(['key' => ['data0' => 'data0']])
|
||||||
|
->setType('text/html');
|
||||||
|
|
||||||
|
$storedObject->saveHistory();
|
||||||
|
|
||||||
|
$storedObject
|
||||||
|
->setFilename('test_1')
|
||||||
|
->setIv([8, 10, 12])
|
||||||
|
->setKeyInfos(['key' => ['data1' => 'data1']])
|
||||||
|
->setType('text/text');
|
||||||
|
|
||||||
|
$storedObject->saveHistory();
|
||||||
|
|
||||||
|
self::assertEquals('test_0', $storedObject->getDatas()['history'][0]['filename']);
|
||||||
|
self::assertEquals([2, 4, 6, 8], $storedObject->getDatas()['history'][0]['iv']);
|
||||||
|
self::assertEquals(['key' => ['data0' => 'data0']], $storedObject->getDatas()['history'][0]['key_infos']);
|
||||||
|
self::assertEquals('text/html', $storedObject->getDatas()['history'][0]['type']);
|
||||||
|
|
||||||
|
self::assertEquals('test_1', $storedObject->getDatas()['history'][1]['filename']);
|
||||||
|
self::assertEquals([8, 10, 12], $storedObject->getDatas()['history'][1]['iv']);
|
||||||
|
self::assertEquals(['key' => ['data1' => 'data1']], $storedObject->getDatas()['history'][1]['key_infos']);
|
||||||
|
self::assertEquals('text/text', $storedObject->getDatas()['history'][1]['type']);
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Tests\Form;
|
||||||
|
|
||||||
|
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||||
|
use Chill\DocStoreBundle\Form\DataMapper\StoredObjectDataMapper;
|
||||||
|
use Chill\DocStoreBundle\Form\DataTransformer\StoredObjectDataTransformer;
|
||||||
|
use Chill\DocStoreBundle\Form\StoredObjectType;
|
||||||
|
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||||
|
use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface;
|
||||||
|
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectNormalizer;
|
||||||
|
use Prophecy\Argument;
|
||||||
|
use Prophecy\PhpUnit\ProphecyTrait;
|
||||||
|
use Symfony\Component\Form\PreloadedExtension;
|
||||||
|
use Symfony\Component\Form\Test\TypeTestCase;
|
||||||
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
use Symfony\Component\Serializer\Encoder\JsonEncoder;
|
||||||
|
use Symfony\Component\Serializer\Serializer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*
|
||||||
|
* @coversNothing
|
||||||
|
*/
|
||||||
|
class StoredObjectTypeTest extends TypeTestCase
|
||||||
|
{
|
||||||
|
use ProphecyTrait;
|
||||||
|
|
||||||
|
public function testChangeTitleValue(): void
|
||||||
|
{
|
||||||
|
$formData = ['title' => $newTitle = 'new title', 'stored_object' => <<<'JSON'
|
||||||
|
{"datas":[],"filename":"","id":null,"iv":[],"keyInfos":[],"title":"","type":"","uuid":"3c6a28fe-f913-40b9-a201-5eccc4f2d312","status":"ready","createdAt":null,"createdBy":null,"creationDate":null,"_links":{"dav_link":{"href":"http:\/\/url\/fake","expiration":"1716889578"}}}
|
||||||
|
JSON];
|
||||||
|
$model = new StoredObject();
|
||||||
|
$form = $this->factory->create(StoredObjectType::class, $model, ['has_title' => true]);
|
||||||
|
|
||||||
|
$form->submit($formData);
|
||||||
|
|
||||||
|
$this->assertTrue($form->isSynchronized());
|
||||||
|
|
||||||
|
$this->assertEquals($newTitle, $model->getTitle());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReplaceByAnotherObject(): void
|
||||||
|
{
|
||||||
|
$formData = ['title' => $newTitle = 'new title', 'stored_object' => <<<'JSON'
|
||||||
|
{"filename":"abcdef","iv":[10, 15, 20, 30],"keyInfos":[],"type":"text/html","status":"object_store_created"}
|
||||||
|
JSON];
|
||||||
|
$model = new StoredObject();
|
||||||
|
$originalObjectId = spl_object_hash($model);
|
||||||
|
$form = $this->factory->create(StoredObjectType::class, $model, ['has_title' => true]);
|
||||||
|
|
||||||
|
$form->submit($formData);
|
||||||
|
|
||||||
|
$this->assertTrue($form->isSynchronized());
|
||||||
|
$model = $form->getData();
|
||||||
|
$this->assertEquals($originalObjectId, spl_object_hash($model));
|
||||||
|
$this->assertEquals('abcdef', $model->getFilename());
|
||||||
|
$this->assertEquals([10, 15, 20, 30], $model->getIv());
|
||||||
|
$this->assertEquals('text/html', $model->getType());
|
||||||
|
$this->assertEquals($newTitle, $model->getTitle());
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getExtensions()
|
||||||
|
{
|
||||||
|
$jwtTokenProvider = $this->prophesize(JWTDavTokenProviderInterface::class);
|
||||||
|
$jwtTokenProvider->createToken(Argument::type(StoredObject::class), Argument::type(StoredObjectRoleEnum::class))
|
||||||
|
->willReturn('token');
|
||||||
|
$jwtTokenProvider->getTokenExpiration('token')->willReturn(new \DateTimeImmutable());
|
||||||
|
$urlGenerator = $this->prophesize(UrlGeneratorInterface::class);
|
||||||
|
$urlGenerator->generate('chill_docstore_dav_document_get', Argument::type('array'), UrlGeneratorInterface::ABSOLUTE_URL)
|
||||||
|
->willReturn('http://url/fake');
|
||||||
|
|
||||||
|
$serializer = new Serializer(
|
||||||
|
[
|
||||||
|
new StoredObjectNormalizer(
|
||||||
|
$jwtTokenProvider->reveal(),
|
||||||
|
$urlGenerator->reveal(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
new JsonEncoder(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
$dataTransformer = new StoredObjectDataTransformer($serializer);
|
||||||
|
$dataMapper = new StoredObjectDataMapper();
|
||||||
|
$type = new StoredObjectType(
|
||||||
|
$dataTransformer,
|
||||||
|
$dataMapper,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
new PreloadedExtension([$type], []),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Tests\Security\Authorization;
|
||||||
|
|
||||||
|
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||||
|
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||||
|
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter;
|
||||||
|
use Chill\DocStoreBundle\Security\Guard\DavTokenAuthenticationEventSubscriber;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Prophecy\PhpUnit\ProphecyTrait;
|
||||||
|
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||||
|
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*
|
||||||
|
* @coversNothing
|
||||||
|
*/
|
||||||
|
class StoredObjectVoterTest extends TestCase
|
||||||
|
{
|
||||||
|
use ProphecyTrait;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider provideDataVote
|
||||||
|
*/
|
||||||
|
public function testVote(TokenInterface $token, ?object $subject, string $attribute, mixed $expected): void
|
||||||
|
{
|
||||||
|
$voter = new StoredObjectVoter();
|
||||||
|
|
||||||
|
self::assertEquals($expected, $voter->vote($token, $subject, [$attribute]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function provideDataVote(): iterable
|
||||||
|
{
|
||||||
|
yield [
|
||||||
|
$this->buildToken(StoredObjectRoleEnum::EDIT, new StoredObject()),
|
||||||
|
new \stdClass(),
|
||||||
|
'SOMETHING',
|
||||||
|
VoterInterface::ACCESS_ABSTAIN,
|
||||||
|
];
|
||||||
|
|
||||||
|
yield [
|
||||||
|
$this->buildToken(StoredObjectRoleEnum::EDIT, $so = new StoredObject()),
|
||||||
|
$so,
|
||||||
|
'SOMETHING',
|
||||||
|
VoterInterface::ACCESS_ABSTAIN,
|
||||||
|
];
|
||||||
|
|
||||||
|
yield [
|
||||||
|
$this->buildToken(StoredObjectRoleEnum::EDIT, $so = new StoredObject()),
|
||||||
|
$so,
|
||||||
|
StoredObjectRoleEnum::SEE->value,
|
||||||
|
VoterInterface::ACCESS_GRANTED,
|
||||||
|
];
|
||||||
|
|
||||||
|
yield [
|
||||||
|
$this->buildToken(StoredObjectRoleEnum::EDIT, $so = new StoredObject()),
|
||||||
|
$so,
|
||||||
|
StoredObjectRoleEnum::EDIT->value,
|
||||||
|
VoterInterface::ACCESS_GRANTED,
|
||||||
|
];
|
||||||
|
|
||||||
|
yield [
|
||||||
|
$this->buildToken(StoredObjectRoleEnum::SEE, $so = new StoredObject()),
|
||||||
|
$so,
|
||||||
|
StoredObjectRoleEnum::EDIT->value,
|
||||||
|
VoterInterface::ACCESS_DENIED,
|
||||||
|
];
|
||||||
|
|
||||||
|
yield [
|
||||||
|
$this->buildToken(StoredObjectRoleEnum::SEE, $so = new StoredObject()),
|
||||||
|
$so,
|
||||||
|
StoredObjectRoleEnum::SEE->value,
|
||||||
|
VoterInterface::ACCESS_GRANTED,
|
||||||
|
];
|
||||||
|
|
||||||
|
yield [
|
||||||
|
$this->buildToken(null, null),
|
||||||
|
new StoredObject(),
|
||||||
|
StoredObjectRoleEnum::SEE->value,
|
||||||
|
VoterInterface::ACCESS_DENIED,
|
||||||
|
];
|
||||||
|
|
||||||
|
yield [
|
||||||
|
$this->buildToken(null, null),
|
||||||
|
new StoredObject(),
|
||||||
|
StoredObjectRoleEnum::SEE->value,
|
||||||
|
VoterInterface::ACCESS_DENIED,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildToken(?StoredObjectRoleEnum $storedObjectRoleEnum = null, ?StoredObject $storedObject = null): TokenInterface
|
||||||
|
{
|
||||||
|
$token = $this->prophesize(TokenInterface::class);
|
||||||
|
|
||||||
|
if (null !== $storedObjectRoleEnum) {
|
||||||
|
$token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willReturn(true);
|
||||||
|
$token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willReturn($storedObjectRoleEnum);
|
||||||
|
} else {
|
||||||
|
$token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willReturn(false);
|
||||||
|
$token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willThrow(new \InvalidArgumentException());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $storedObject) {
|
||||||
|
$token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willReturn(true);
|
||||||
|
$token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willReturn($storedObject->getUuid()->toString());
|
||||||
|
} else {
|
||||||
|
$token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willReturn(false);
|
||||||
|
$token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willThrow(new \InvalidArgumentException());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $token->reveal();
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Tests\Security\Guard;
|
||||||
|
|
||||||
|
use Chill\DocStoreBundle\Security\Guard\DavOnUrlTokenExtractor;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Prophecy\PhpUnit\ProphecyTrait;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*
|
||||||
|
* @coversNothing
|
||||||
|
*/
|
||||||
|
class DavOnUrlTokenExtractorTest extends TestCase
|
||||||
|
{
|
||||||
|
use ProphecyTrait;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider provideDataUri
|
||||||
|
*/
|
||||||
|
public function testExtract(string $uri, false|string $expected): void
|
||||||
|
{
|
||||||
|
$request = $this->prophesize(Request::class);
|
||||||
|
$request->getRequestUri()->willReturn($uri);
|
||||||
|
|
||||||
|
$extractor = new DavOnUrlTokenExtractor(new NullLogger());
|
||||||
|
|
||||||
|
$actual = $extractor->extract($request->reveal());
|
||||||
|
|
||||||
|
self::assertEquals($expected, $actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @phpstan-pure
|
||||||
|
*/
|
||||||
|
public static function provideDataUri(): iterable
|
||||||
|
{
|
||||||
|
yield ['/dav/123456789/get/d07d2230-5326-11ee-8fd4-93696acf5ea1/d', '123456789'];
|
||||||
|
yield ['/dav/123456789', '123456789'];
|
||||||
|
yield ['/not-dav/123456978', false];
|
||||||
|
yield ['/dav', false];
|
||||||
|
yield ['/', false];
|
||||||
|
}
|
||||||
|
}
|
@@ -3,6 +3,6 @@ module.exports = function(encore)
|
|||||||
encore.addAliases({
|
encore.addAliases({
|
||||||
ChillDocStoreAssets: __dirname + '/Resources/public'
|
ChillDocStoreAssets: __dirname + '/Resources/public'
|
||||||
});
|
});
|
||||||
encore.addEntry('mod_async_upload', __dirname + '/Resources/public/module/async_upload/index.js');
|
encore.addEntry('mod_async_upload', __dirname + '/Resources/public/module/async_upload/index.ts');
|
||||||
encore.addEntry('mod_document_action_buttons_group', __dirname + '/Resources/public/module/document_action_buttons_group/index');
|
encore.addEntry('mod_document_action_buttons_group', __dirname + '/Resources/public/module/document_action_buttons_group/index');
|
||||||
};
|
};
|
||||||
|
@@ -34,6 +34,11 @@ services:
|
|||||||
autoconfigure: true
|
autoconfigure: true
|
||||||
autowire: true
|
autowire: true
|
||||||
|
|
||||||
|
Chill\DocStoreBundle\Security\:
|
||||||
|
resource: './../Security'
|
||||||
|
autoconfigure: true
|
||||||
|
autowire: true
|
||||||
|
|
||||||
Chill\DocStoreBundle\Serializer\Normalizer\:
|
Chill\DocStoreBundle\Serializer\Normalizer\:
|
||||||
autowire: true
|
autowire: true
|
||||||
resource: '../Serializer/Normalizer/'
|
resource: '../Serializer/Normalizer/'
|
||||||
|
@@ -1,13 +1,18 @@
|
|||||||
services:
|
services:
|
||||||
Chill\DocStoreBundle\Form\StoredObjectType:
|
_defaults:
|
||||||
arguments:
|
autowire: true
|
||||||
$em: '@Doctrine\ORM\EntityManagerInterface'
|
autoconfigure: true
|
||||||
tags:
|
|
||||||
- { name: form.type }
|
|
||||||
|
|
||||||
Chill\DocStoreBundle\Form\AccompanyingCourseDocumentType:
|
Chill\DocStoreBundle\Form\StoredObjectType:
|
||||||
class: Chill\DocStoreBundle\Form\AccompanyingCourseDocumentType
|
tags:
|
||||||
arguments:
|
- { name: form.type }
|
||||||
- "@chill.main.helper.translatable_string"
|
|
||||||
tags:
|
Chill\DocStoreBundle\Form\AccompanyingCourseDocumentType:
|
||||||
- { name: form.type, alias: chill_docstorebundle_form_document }
|
tags:
|
||||||
|
- { name: form.type, alias: chill_docstorebundle_form_document }
|
||||||
|
|
||||||
|
Chill\DocStoreBundle\Form\DataMapper\:
|
||||||
|
resource: '../../Form/DataMapper'
|
||||||
|
|
||||||
|
Chill\DocStoreBundle\Form\DataTransformer\:
|
||||||
|
resource: '../../Form/DataTransformer'
|
||||||
|
@@ -46,6 +46,9 @@ Are you sure you want to cancel this upload ?: Êtes-vous sûrs de vouloir annul
|
|||||||
Upload canceled: Téléversement annulé
|
Upload canceled: Téléversement annulé
|
||||||
Remove existing file: Supprimer le document existant
|
Remove existing file: Supprimer le document existant
|
||||||
|
|
||||||
|
stored_object:
|
||||||
|
Insert a document: Ajouter un document
|
||||||
|
|
||||||
# ROLES
|
# ROLES
|
||||||
PersonDocument: Documents
|
PersonDocument: Documents
|
||||||
CHILL_PERSON_DOCUMENT_CREATE: Ajouter un document
|
CHILL_PERSON_DOCUMENT_CREATE: Ajouter un document
|
||||||
|
@@ -206,8 +206,6 @@ class EventTypeController extends AbstractController
|
|||||||
/**
|
/**
|
||||||
* Creates a form to delete a EventType entity by id.
|
* Creates a form to delete a EventType entity by id.
|
||||||
*
|
*
|
||||||
* @param mixed $id The entity id
|
|
||||||
*
|
|
||||||
* @return \Symfony\Component\Form\Form The form
|
* @return \Symfony\Component\Form\Form The form
|
||||||
*/
|
*/
|
||||||
private function createDeleteForm(mixed $id)
|
private function createDeleteForm(mixed $id)
|
||||||
|
@@ -206,8 +206,6 @@ class RoleController extends AbstractController
|
|||||||
/**
|
/**
|
||||||
* Creates a form to delete a Role entity by id.
|
* Creates a form to delete a Role entity by id.
|
||||||
*
|
*
|
||||||
* @param mixed $id The entity id
|
|
||||||
*
|
|
||||||
* @return \Symfony\Component\Form\Form The form
|
* @return \Symfony\Component\Form\Form The form
|
||||||
*/
|
*/
|
||||||
private function createDeleteForm(mixed $id)
|
private function createDeleteForm(mixed $id)
|
||||||
|
@@ -206,8 +206,6 @@ class StatusController extends AbstractController
|
|||||||
/**
|
/**
|
||||||
* Creates a form to delete a Status entity by id.
|
* Creates a form to delete a Status entity by id.
|
||||||
*
|
*
|
||||||
* @param mixed $id The entity id
|
|
||||||
*
|
|
||||||
* @return \Symfony\Component\Form\Form The form
|
* @return \Symfony\Component\Form\Form The form
|
||||||
*/
|
*/
|
||||||
private function createDeleteForm(mixed $id)
|
private function createDeleteForm(mixed $id)
|
||||||
|
@@ -700,7 +700,6 @@ class CRUDController extends AbstractController
|
|||||||
* and view.
|
* and view.
|
||||||
*
|
*
|
||||||
* @param string $action
|
* @param string $action
|
||||||
* @param mixed $entity the entity for the current request, or an array of entities
|
|
||||||
*
|
*
|
||||||
* @return string the path to the template
|
* @return string the path to the template
|
||||||
*
|
*
|
||||||
|
@@ -317,8 +317,8 @@ final class PermissionsGroupController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
return strcmp(
|
return strcmp(
|
||||||
$translatableStringHelper->localize($a->getScope()->getName()),
|
(string) $translatableStringHelper->localize($a->getScope()->getName()),
|
||||||
$translatableStringHelper->localize($b->getScope()->getName())
|
(string) $translatableStringHelper->localize($b->getScope()->getName())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -451,8 +451,6 @@ final class PermissionsGroupController extends AbstractController
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a form to delete a link to roleScope.
|
* Creates a form to delete a link to roleScope.
|
||||||
*
|
|
||||||
* @param mixed $permissionsGroup The entity id
|
|
||||||
*/
|
*/
|
||||||
private function createDeleteRoleScopeForm(
|
private function createDeleteRoleScopeForm(
|
||||||
PermissionsGroup $permissionsGroup,
|
PermissionsGroup $permissionsGroup,
|
||||||
|
@@ -73,6 +73,7 @@ final readonly class UserExportController
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
$csv->addFormatter(fn (array $row) => null !== ($row['absenceStart'] ?? null) ? array_merge($row, ['absenceStart' => $row['absenceStart']->format('Y-m-d')]) : $row);
|
$csv->addFormatter(fn (array $row) => null !== ($row['absenceStart'] ?? null) ? array_merge($row, ['absenceStart' => $row['absenceStart']->format('Y-m-d')]) : $row);
|
||||||
|
/* @phpstan-ignore-next-line as phpstan seem to ignore that we transform datetime into string */
|
||||||
$csv->insertAll($users);
|
$csv->insertAll($users);
|
||||||
|
|
||||||
return new StreamedResponse(
|
return new StreamedResponse(
|
||||||
|
@@ -344,11 +344,11 @@ class LoadPostalCodes extends AbstractFixture implements OrderedFixtureInterface
|
|||||||
->findOneBy(['countryCode' => $countryCode]);
|
->findOneBy(['countryCode' => $countryCode]);
|
||||||
|
|
||||||
foreach ($lines as $line) {
|
foreach ($lines as $line) {
|
||||||
$code = str_getcsv($line);
|
$code = str_getcsv((string) $line);
|
||||||
$c = new PostalCode();
|
$c = new PostalCode();
|
||||||
$c->setCountry($country)
|
$c->setCountry($country)
|
||||||
->setCode($code[0])
|
->setCode($code[0])
|
||||||
->setName(\ucwords(\strtolower($code[1])));
|
->setName(\ucwords(\strtolower((string) $code[1])));
|
||||||
|
|
||||||
if (null !== ($code[3] ?? null)) {
|
if (null !== ($code[3] ?? null)) {
|
||||||
$c->setRefPostalCodeId($code[3]);
|
$c->setRefPostalCodeId($code[3]);
|
||||||
|
@@ -43,7 +43,7 @@ class ShortMessageCompilerPass implements CompilerPassInterface
|
|||||||
$defaultTransporter = new Reference(NullShortMessageSender::class);
|
$defaultTransporter = new Reference(NullShortMessageSender::class);
|
||||||
} elseif ('ovh' === $dsn['scheme']) {
|
} elseif ('ovh' === $dsn['scheme']) {
|
||||||
if (!class_exists('\\'.\Ovh\Api::class)) {
|
if (!class_exists('\\'.\Ovh\Api::class)) {
|
||||||
throw new RuntimeException('Class \\Ovh\\Api not found');
|
throw new RuntimeException('Class \Ovh\Api not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (['user', 'host', 'pass'] as $component) {
|
foreach (['user', 'host', 'pass'] as $component) {
|
||||||
|
@@ -274,13 +274,13 @@ class User implements UserInterface, \Stringable
|
|||||||
return $this->mainLocation;
|
return $this->mainLocation;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getMainScope(?\DateTimeImmutable $at = null): ?Scope
|
public function getMainScope(?\DateTimeImmutable $atDate = null): ?Scope
|
||||||
{
|
{
|
||||||
$at ??= new \DateTimeImmutable('now');
|
$atDate ??= new \DateTimeImmutable('now');
|
||||||
|
|
||||||
foreach ($this->scopeHistories as $scopeHistory) {
|
foreach ($this->scopeHistories as $scopeHistory) {
|
||||||
if ($at >= $scopeHistory->getStartDate() && (
|
if ($atDate >= $scopeHistory->getStartDate() && (
|
||||||
null === $scopeHistory->getEndDate() || $at < $scopeHistory->getEndDate()
|
null === $scopeHistory->getEndDate() || $atDate < $scopeHistory->getEndDate()
|
||||||
)) {
|
)) {
|
||||||
return $scopeHistory->getScope();
|
return $scopeHistory->getScope();
|
||||||
}
|
}
|
||||||
@@ -326,13 +326,13 @@ class User implements UserInterface, \Stringable
|
|||||||
return $this->salt;
|
return $this->salt;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getUserJob(?\DateTimeImmutable $at = null): ?UserJob
|
public function getUserJob(?\DateTimeImmutable $atDate = null): ?UserJob
|
||||||
{
|
{
|
||||||
$at ??= new \DateTimeImmutable('now');
|
$atDate ??= new \DateTimeImmutable('now');
|
||||||
|
|
||||||
foreach ($this->jobHistories as $jobHistory) {
|
foreach ($this->jobHistories as $jobHistory) {
|
||||||
if ($at >= $jobHistory->getStartDate() && (
|
if ($atDate >= $jobHistory->getStartDate() && (
|
||||||
null === $jobHistory->getEndDate() || $at < $jobHistory->getEndDate()
|
null === $jobHistory->getEndDate() || $atDate < $jobHistory->getEndDate()
|
||||||
)) {
|
)) {
|
||||||
return $jobHistory->getJob();
|
return $jobHistory->getJob();
|
||||||
}
|
}
|
||||||
@@ -346,6 +346,11 @@ class User implements UserInterface, \Stringable
|
|||||||
return $this->jobHistories;
|
return $this->jobHistories;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getUserScopeHistories(): Collection
|
||||||
|
{
|
||||||
|
return $this->scopeHistories;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return ArrayCollection|UserJobHistory[]
|
* @return ArrayCollection|UserJobHistory[]
|
||||||
*/
|
*/
|
||||||
|
@@ -73,7 +73,6 @@ interface AggregatorInterface extends ModifierInterface
|
|||||||
*
|
*
|
||||||
* @param string $key The column key, as added in the query
|
* @param string $key The column key, as added in the query
|
||||||
* @param mixed[] $values The values from the result. if there are duplicates, those might be given twice. Example: array('FR', 'BE', 'CZ', 'FR', 'BE', 'FR')
|
* @param mixed[] $values The values from the result. if there are duplicates, those might be given twice. Example: array('FR', 'BE', 'CZ', 'FR', 'BE', 'FR')
|
||||||
* @param mixed $data The data from the export's form (as defined in `buildForm`
|
|
||||||
*
|
*
|
||||||
* @return \Closure where the first argument is the value, and the function should return the label to show in the formatted file. Example : `function($countryCode) use ($countries) { return $countries[$countryCode]->getName(); }`
|
* @return \Closure where the first argument is the value, and the function should return the label to show in the formatted file. Example : `function($countryCode) use ($countries) { return $countries[$countryCode]->getName(); }`
|
||||||
*/
|
*/
|
||||||
|
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\MainBundle\Export;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform data from filter.
|
||||||
|
*
|
||||||
|
* This interface defines a method for transforming filter's form data before it is processed.
|
||||||
|
*
|
||||||
|
* You can implement this interface on @see{FilterInterface} or @see{AggregatorInterface}, to allow to transform existing data in saved exports
|
||||||
|
* and replace it with some default values, or new default values.
|
||||||
|
*/
|
||||||
|
interface DataTransformerInterface
|
||||||
|
{
|
||||||
|
public function transformData(?array $before): array;
|
||||||
|
}
|
@@ -30,8 +30,6 @@ interface ExportElementValidatedInterface
|
|||||||
/**
|
/**
|
||||||
* validate the form's data and, if required, build a contraint
|
* validate the form's data and, if required, build a contraint
|
||||||
* violation on the data.
|
* violation on the data.
|
||||||
*
|
|
||||||
* @param mixed $data the data, as returned by the user
|
|
||||||
*/
|
*/
|
||||||
public function validateForm(mixed $data, ExecutionContextInterface $context);
|
public function validateForm(mixed $data, ExecutionContextInterface $context);
|
||||||
}
|
}
|
||||||
|
@@ -96,7 +96,6 @@ interface ExportInterface extends ExportElementInterface
|
|||||||
*
|
*
|
||||||
* @param string $key The column key, as added in the query
|
* @param string $key The column key, as added in the query
|
||||||
* @param mixed[] $values The values from the result. if there are duplicates, those might be given twice. Example: array('FR', 'BE', 'CZ', 'FR', 'BE', 'FR')
|
* @param mixed[] $values The values from the result. if there are duplicates, those might be given twice. Example: array('FR', 'BE', 'CZ', 'FR', 'BE', 'FR')
|
||||||
* @param mixed $data The data from the export's form (as defined in `buildForm`)
|
|
||||||
*
|
*
|
||||||
* @return (callable(string|int|float|'_header'|null $value): string|int|\DateTimeInterface) where the first argument is the value, and the function should return the label to show in the formatted file. Example : `function($countryCode) use ($countries) { return $countries[$countryCode]->getName(); }`
|
* @return (callable(string|int|float|'_header'|null $value): string|int|\DateTimeInterface) where the first argument is the value, and the function should return the label to show in the formatted file. Example : `function($countryCode) use ($countries) { return $countries[$countryCode]->getName(); }`
|
||||||
*/
|
*/
|
||||||
|
@@ -190,7 +190,7 @@ class ExportManager
|
|||||||
// throw an error if the export require other modifier, which is
|
// throw an error if the export require other modifier, which is
|
||||||
// not allowed when the export return a `NativeQuery`
|
// not allowed when the export return a `NativeQuery`
|
||||||
if (\count($export->supportsModifiers()) > 0) {
|
if (\count($export->supportsModifiers()) > 0) {
|
||||||
throw new \LogicException("The export with alias `{$exportAlias}` return ".'a `\\Doctrine\\ORM\\NativeQuery` and supports modifiers, which is not allowed. Either the method `supportsModifiers` should return an empty array, or return a `Doctrine\\ORM\\QueryBuilder`');
|
throw new \LogicException("The export with alias `{$exportAlias}` return ".'a `\Doctrine\ORM\NativeQuery` and supports modifiers, which is not allowed. Either the method `supportsModifiers` should return an empty array, or return a `Doctrine\ORM\QueryBuilder`');
|
||||||
}
|
}
|
||||||
} elseif ($query instanceof QueryBuilder) {
|
} elseif ($query instanceof QueryBuilder) {
|
||||||
// handle filters
|
// handle filters
|
||||||
@@ -203,7 +203,7 @@ class ExportManager
|
|||||||
'dql' => $query->getDQL(),
|
'dql' => $query->getDQL(),
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
throw new \UnexpectedValueException('The method `intiateQuery` should return a `\\Doctrine\\ORM\\NativeQuery` or a `Doctrine\\ORM\\QueryBuilder` object.');
|
throw new \UnexpectedValueException('The method `intiateQuery` should return a `\Doctrine\ORM\NativeQuery` or a `Doctrine\ORM\QueryBuilder` object.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = $export->getResult($query, $data[ExportType::EXPORT_KEY]);
|
$result = $export->getResult($query, $data[ExportType::EXPORT_KEY]);
|
||||||
@@ -552,7 +552,6 @@ class ExportManager
|
|||||||
*
|
*
|
||||||
* This function check the acl.
|
* This function check the acl.
|
||||||
*
|
*
|
||||||
* @param mixed $data the data under the initial 'filters' data
|
|
||||||
* @param \Chill\MainBundle\Entity\Center[] $centers the picked centers
|
* @param \Chill\MainBundle\Entity\Center[] $centers the picked centers
|
||||||
*
|
*
|
||||||
* @throw UnauthorizedHttpException if the user is not authorized
|
* @throw UnauthorizedHttpException if the user is not authorized
|
||||||
@@ -615,9 +614,6 @@ class ExportManager
|
|||||||
return $usedTypes;
|
return $usedTypes;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param mixed $data the data from the filter key of the ExportType
|
|
||||||
*/
|
|
||||||
private function retrieveUsedFilters(mixed $data): iterable
|
private function retrieveUsedFilters(mixed $data): iterable
|
||||||
{
|
{
|
||||||
if (null === $data) {
|
if (null === $data) {
|
||||||
@@ -634,8 +630,6 @@ class ExportManager
|
|||||||
/**
|
/**
|
||||||
* Retrieve the filter used in this export.
|
* Retrieve the filter used in this export.
|
||||||
*
|
*
|
||||||
* @param mixed $data the data from the `filters` key of the ExportType
|
|
||||||
*
|
|
||||||
* @return array an array with types
|
* @return array an array with types
|
||||||
*/
|
*/
|
||||||
private function retrieveUsedFiltersType(mixed $data): iterable
|
private function retrieveUsedFiltersType(mixed $data): iterable
|
||||||
|
@@ -32,6 +32,9 @@ interface FilterInterface extends ModifierInterface
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the default data, that can be use as "data" for the form.
|
* Get the default data, that can be use as "data" for the form.
|
||||||
|
*
|
||||||
|
* In case of adding new parameters to a filter, you can implement a @see{DataTransformerFilterInterface} to
|
||||||
|
* transforme the filters's data saved in an export to the desired state.
|
||||||
*/
|
*/
|
||||||
public function getFormDefaultData(): array;
|
public function getFormDefaultData(): array;
|
||||||
|
|
||||||
|
@@ -35,6 +35,7 @@ class ChillCollectionType extends AbstractType
|
|||||||
$view->vars['allow_add'] = (int) $options['allow_add'];
|
$view->vars['allow_add'] = (int) $options['allow_add'];
|
||||||
$view->vars['identifier'] = $options['identifier'];
|
$view->vars['identifier'] = $options['identifier'];
|
||||||
$view->vars['empty_collection_explain'] = $options['empty_collection_explain'];
|
$view->vars['empty_collection_explain'] = $options['empty_collection_explain'];
|
||||||
|
$view->vars['js_caller'] = $options['js_caller'];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function configureOptions(OptionsResolver $resolver)
|
public function configureOptions(OptionsResolver $resolver)
|
||||||
@@ -45,6 +46,8 @@ class ChillCollectionType extends AbstractType
|
|||||||
'button_remove_label' => 'Remove entry',
|
'button_remove_label' => 'Remove entry',
|
||||||
'identifier' => '',
|
'identifier' => '',
|
||||||
'empty_collection_explain' => '',
|
'empty_collection_explain' => '',
|
||||||
|
'js_caller' => 'data-collection-regular',
|
||||||
|
'delete_empty' => true,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user