Merge branch 'signature-app-master' into 'master'

Signature app master

Closes #307

See merge request Chill-Projet/chill-bundles!743
This commit is contained in:
Julien Fastré 2024-11-12 20:30:00 +00:00
commit 21ec3121ec
426 changed files with 22236 additions and 2253 deletions

View File

@ -0,0 +1,8 @@
kind: Feature
body: |-
Electronic signature
Implementation of the electronic signature for documents within chill.
time: 2024-06-14T15:32:36.875891692+02:00
custom:
Issue: ""

View File

@ -0,0 +1,7 @@
kind: Feature
body: The behavoir of the voters for stored objects is adjusted so as to limit edit
and delete possibilities to users related to the activity, social action or workflow
entity.
time: 2024-06-14T15:35:37.582159301+02:00
custom:
Issue: "286"

View File

@ -0,0 +1,5 @@
kind: Feature
body: Metadata form added for person signatures
time: 2024-07-18T15:12:33.8134266+02:00
custom:
Issue: "288"

View File

@ -0,0 +1,5 @@
kind: Feature
body: Add a signature step in workflow, which allow to apply an electronic signature on documents
time: 2024-11-12T21:23:40.271035497+01:00
custom:
Issue: ""

View File

@ -0,0 +1,5 @@
kind: Feature
body: Keep an history of each version of a stored object.
time: 2024-11-12T21:24:17.491708762+01:00
custom:
Issue: ""

View File

@ -0,0 +1,5 @@
kind: Feature
body: Add a "send external" step in workflow, which allow to send stored objects and other elements to remote people, by sending them a public url
time: 2024-11-12T21:25:03.599543577+01:00
custom:
Issue: ""

2
.env
View File

@ -23,7 +23,7 @@ TRUSTED_HOSTS='^(localhost|example\.com|nginx)$'
###< symfony/framework-bundle ###
## Wopi server for editing documents online
WOPI_SERVER=http://collabora:9980
EDITOR_SERVER=http://collabora:9980
# must be manually set in .env.local
# ADMIN_PASSWORD=

View File

@ -41,3 +41,5 @@ DATABASE_URL="postgresql://postgres:postgres@db:5432/test?serverVersion=14&chars
ASYNC_UPLOAD_TEMP_URL_KEY=
ASYNC_UPLOAD_TEMP_URL_BASE_PATH=
ASYNC_UPLOAD_TEMP_URL_CONTAINER=
EDITOR_SERVER=https://localhost:9980

View File

@ -122,7 +122,7 @@ unit_tests:
- php tests/console chill:db:sync-views --env=test
- php -d memory_limit=2G tests/console cache:clear --env=test
- php -d memory_limit=3G tests/console doctrine:fixtures:load -n --env=test
- php -d memory_limit=4G bin/phpunit --colors=never --exclude-group dbIntensive
- php -d memory_limit=4G bin/phpunit --colors=never --exclude-group dbIntensive,openstack-integration,collabora-integration
artifacts:
expire_in: 1 day
paths:

View File

@ -31,6 +31,7 @@
"phpoffice/phpspreadsheet": "^1.16",
"ramsey/uuid-doctrine": "^1.7",
"sensio/framework-extra-bundle": "^5.5",
"smalot/pdfparser": "^2.10",
"spomky-labs/base64url": "^2.0",
"symfony/asset": "^5.4",
"symfony/browser-kit": "^5.4",

View File

@ -0,0 +1,125 @@
Enable CODE for development
===========================
For editing a document, there must be a way to communicate between the collabora server and the symfony server, in
both direction. The domain name should also be the same for collabora server and for the browser which access to the
online editor.
Using ngrok (or other http tunnel)
----------------------------------
One can configure a tunnel server to expose your local install to the web, and access to your local server using the
tunnel url.
Start ngrok
^^^^^^^^^^^
This can be achieve using `ngrok <https://ngrok.com/>`_.
.. note::
The configuration of ngrok is outside of the scope of this document. Refers to the ngrok's documentation.
.. code-block:: bash
# ensuring that your server is running through http and port 8000
ngrok http 8000
# then open the link given by the ngrok utility and you should reach your app
At this step, ensure that you can reach your local app using the ngrok url.
Configure Collabora
^^^^^^^^^^^^^^^^^^^
The collabora server must be executed online and configure to access to your ngrok installation. Ensure that the aliasgroup
exists for your ngrok application (`See the CODE documentation: <https://sdk.collaboraonline.com/docs/installation/Configuration.html#multihost-configuration>`_).
Configure your app
^^^^^^^^^^^^^^^^^^
Set the :code:`EDITOR_SERVER` variable to point to your collabora server, this should be done in your :code:`.env.local` file.
At this point, everything must be fine. In case of errors, watch the log from your collabora server, use the `profiler <https://symfony.com/doc/current/profiler.html>`_
to debug the requests.
.. note::
In case of error while validating proof (you'll see those message in the collabora's logs), you can temporarily disable
the proof validation adding this code snippet in `config/services.yaml`:
.. code-block:: yaml
when@dev:
# add only in dev environment, to avoid security problems
services:
ChampsLibres\WopiLib\Contract\Service\ProofValidatorInterface:
# this class will always validate proof
alias: Chill\WopiBundle\Service\Wopi\NullProofValidator
With a local CODE image
-----------------------
.. warning::
This configuration is not sure, and must be refined. The documentation does not seems to be entirely valid.
Use a local domain name and https for your app
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Use the proxy feature from embedded symfony server to run your app. `See the dedicated doc <https://symfony.com/doc/current/setup/symfony_server.html#local-domain-names>`
Configure also the `https certificate <https://symfony.com/doc/current/setup/symfony_server.html#enabling-tls>`_
In this example, your local domain name will be :code:`my-domain` and the url will be :code:`https://my-domain.wip`.
Ensure that the proxy is running.
Create a certificate database for collabora
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Collabora must validate your certificate generated by symfony console. For that, you need `to create a NSS database <https://sdk.collaboraonline.com/docs/installation/Configuration.html#validating-digital-signatures>`
and configure collabora to use it.
At first, export the certificate for symfony development. Use the graphical interface from your browser to get the
certificate as a PEM file.
.. code-block:: bash
# create your database in a custom directory
mkdir /path/to/your/directory
certutil -N -d /path/to/your/directory
cat /path/to/your/ca.crt | certutil -d . -A symfony -t -t C,P,C,u,w -a
Launch CODE properly configured
.. code-block:: yaml
collabora:
image: collabora/code:latest
environment:
- SLEEPFORDEBUGGER=0
- DONT_GEN_SSL_CERT="True"
# add path to the database
- extra_params=--o:ssl.enable=false --o:ssl.termination=false --o:logging.level=7 -o:certificates.database_path=/etc/custom-certificates/nss-database
- username=admin
- password=admin
- dictionaries=en_US
- aliasgroup1=https://my-domain.wip
ports:
- "127.0.0.1:9980:9980"
volumes:
- "/path/to/your/directory/nss-database:/etc/custom-certificates/nss-database"
extra_hosts:
- "my-domain.wip:host-gateway"
Configure your app
^^^^^^^^^^^^^^^^^^
Into your :code:`.env.local` file:
.. code-block:: env
EDITOR_SERVER=http://${COLLABORA_HOST}:${COLLABORA_PORT}
At this step, you should be able to edit a document through collabora.

View File

@ -53,9 +53,9 @@
"marked": "^12.0.2",
"masonry-layout": "^4.2.2",
"mime": "^4.0.0",
"swagger-ui": "^4.15.5",
"pdfjs-dist": "^4.3.136",
"vis-network": "^9.1.0",
"vue": "^3.2.37",
"vue": "^3.5.6",
"vue-i18n": "^9.1.6",
"vue-multiselect": "3.0.0-alpha.2",
"vue-toast-notification": "^3.1.2",

View File

@ -12,6 +12,8 @@ declare(strict_types=1);
namespace Chill\ActivityBundle\Repository;
use Chill\ActivityBundle\Entity\Activity;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
@ -23,7 +25,7 @@ use Doctrine\Persistence\ManagerRegistry;
* @method Activity[] findAll()
* @method Activity[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class ActivityRepository extends ServiceEntityRepository
class ActivityRepository extends ServiceEntityRepository implements AssociatedEntityToStoredObjectInterface
{
public function __construct(ManagerRegistry $registry)
{
@ -97,4 +99,16 @@ class ActivityRepository extends ServiceEntityRepository
return $qb->getQuery()->getResult();
}
public function findAssociatedEntityToStoredObject(StoredObject $storedObject): ?Activity
{
$qb = $this->createQueryBuilder('a');
$query = $qb
->leftJoin('a.documents', 'ad')
->where('ad.id = :storedObjectId')
->setParameter('storedObjectId', $storedObject->getId())
->getQuery();
return $query->getOneOrNullResult();
}
}

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\ActivityBundle\Security\Authorization;
use Chill\ActivityBundle\Entity\Activity;
use Chill\ActivityBundle\Repository\ActivityRepository;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter\AbstractStoredObjectVoter;
use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper;
use Symfony\Component\Security\Core\Security;
class ActivityStoredObjectVoter extends AbstractStoredObjectVoter
{
public function __construct(
private readonly ActivityRepository $repository,
Security $security,
WorkflowStoredObjectPermissionHelper $workflowDocumentService,
) {
parent::__construct($security, $workflowDocumentService);
}
protected function getRepository(): AssociatedEntityToStoredObjectInterface
{
return $this->repository;
}
protected function getClass(): string
{
return Activity::class;
}
protected function attributeToRole(StoredObjectRoleEnum $attribute): string
{
return match ($attribute) {
StoredObjectRoleEnum::EDIT => ActivityVoter::UPDATE,
StoredObjectRoleEnum::SEE => ActivityVoter::SEE_DETAILS,
};
}
protected function canBeAssociatedWithWorkflow(): bool
{
return false;
}
}

View File

@ -54,12 +54,15 @@ class LoadDocGeneratorTemplate extends AbstractFixture
];
foreach ($templates as $template) {
$newStoredObj = (new StoredObject())
->setFilename($template['file']['filename'])
->setKeyInfos(json_decode($template['file']['key'], true))
->setIv(json_decode($template['file']['iv'], true))
$newStoredObj = (new StoredObject());
$newStoredObj
->setCreatedAt(new \DateTime('today'))
->setType($template['file']['type']);
->registerVersion(
json_decode($template['file']['key'], true),
json_decode($template['file']['iv'], true),
$template['file']['type'],
);
$manager->persist($newStoredObj);

View File

@ -134,13 +134,11 @@ class Generator implements GeneratorInterface
$content = Yaml::dump($data, 6);
/* @var StoredObject $destinationStoredObject */
$destinationStoredObject
->setType('application/yaml')
->setFilename(sprintf('%s_yaml', uniqid('doc_', true)))
->setStatus(StoredObject::STATUS_READY)
;
try {
$this->storedObjectManager->write($destinationStoredObject, $content);
$this->storedObjectManager->write($destinationStoredObject, $content, 'application/yaml');
} catch (StoredObjectManagerException $e) {
$destinationStoredObject->addGenerationErrors($e->getMessage());
@ -174,13 +172,11 @@ class Generator implements GeneratorInterface
/* @var StoredObject $destinationStoredObject */
$destinationStoredObject
->setType($template->getFile()->getType())
->setFilename(sprintf('%s_odt', uniqid('doc_', true)))
->setStatus(StoredObject::STATUS_READY)
;
try {
$this->storedObjectManager->write($destinationStoredObject, $generatedResource);
$this->storedObjectManager->write($destinationStoredObject, $generatedResource, $template->getFile()->getType());
} catch (StoredObjectManagerException $e) {
$destinationStoredObject->addGenerationErrors($e->getMessage());

View File

@ -19,6 +19,7 @@ use Chill\DocGeneratorBundle\Service\Generator\Generator;
use Chill\DocGeneratorBundle\Service\Generator\ObjectReadyException;
use Chill\DocGeneratorBundle\Service\Generator\RelatedEntityNotFoundException;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\MainBundle\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
@ -39,11 +40,11 @@ class GeneratorTest extends TestCase
public function testSuccessfulGeneration(): void
{
$template = (new DocGeneratorTemplate())->setFile($templateStoredObject = (new StoredObject())
->setType('application/test'));
$templateStoredObject = new StoredObject();
$templateStoredObject->registerVersion(type: 'application/test');
$template = (new DocGeneratorTemplate())->setFile($templateStoredObject);
$destinationStoredObject = (new StoredObject())->setStatus(StoredObject::STATUS_PENDING);
$reflection = new \ReflectionClass($destinationStoredObject);
$reflection->getProperty('id')->setAccessible(true);
$reflection->getProperty('id')->setValue($destinationStoredObject, 1);
$entity = new class () {};
$data = [];
@ -76,7 +77,14 @@ class GeneratorTest extends TestCase
$storedObjectManager = $this->prophesize(StoredObjectManagerInterface::class);
$storedObjectManager->read($templateStoredObject)->willReturn('template');
$storedObjectManager->write($destinationStoredObject, 'generated')->shouldBeCalled();
$storedObjectManager->write($destinationStoredObject, 'generated', 'application/test')
->will(function ($args): StoredObjectVersion {
/** @var StoredObject $storedObject */
$storedObject = $args[0];
return $storedObject->registerVersion(type: $args[2]);
})
->shouldBeCalled();
$generator = new Generator(
$contextManagerInterface->reveal(),
@ -107,8 +115,9 @@ class GeneratorTest extends TestCase
$this->prophesize(StoredObjectManagerInterface::class)->reveal()
);
$template = (new DocGeneratorTemplate())->setFile($templateStoredObject = (new StoredObject())
->setType('application/test'));
$templateStoredObject = new StoredObject();
$templateStoredObject->registerVersion(type: 'application/test');
$template = (new DocGeneratorTemplate())->setFile($templateStoredObject);
$destinationStoredObject = (new StoredObject())->setStatus(StoredObject::STATUS_READY);
$generator->generateDocFromTemplate(
@ -124,11 +133,11 @@ class GeneratorTest extends TestCase
{
$this->expectException(RelatedEntityNotFoundException::class);
$template = (new DocGeneratorTemplate())->setFile($templateStoredObject = (new StoredObject())
->setType('application/test'));
$templateStoredObject = new StoredObject();
$templateStoredObject->registerVersion(type: 'application/test');
$template = (new DocGeneratorTemplate())->setFile($templateStoredObject);
$destinationStoredObject = (new StoredObject())->setStatus(StoredObject::STATUS_PENDING);
$reflection = new \ReflectionClass($destinationStoredObject);
$reflection->getProperty('id')->setAccessible(true);
$reflection->getProperty('id')->setValue($destinationStoredObject, 1);
$context = $this->prophesize(DocGeneratorContextInterface::class);

View File

@ -58,6 +58,7 @@ final readonly class TempUrlOpenstackGenerator implements TempUrlGeneratorInterf
?int $expire_delay = null,
?int $submit_delay = null,
int $max_file_count = 1,
?string $object_name = null,
): SignedUrlPost {
$delay = $expire_delay ?? $this->max_expire_delay;
$submit_delay ??= $this->max_submit_delay;
@ -84,11 +85,14 @@ final readonly class TempUrlOpenstackGenerator implements TempUrlGeneratorInterf
$expires = $this->clock->now()->add(new \DateInterval('PT'.(string) $delay.'S'));
if (null === $object_name) {
$object_name = $this->generateObjectName();
}
$g = new SignedUrlPost(
$url = $this->generateUrl($object_name),
$expires,
$object_name,
$this->max_post_file_size,
$max_file_count,
$submit_delay,
@ -127,7 +131,7 @@ final readonly class TempUrlOpenstackGenerator implements TempUrlGeneratorInterf
];
$url = $url.'?'.\http_build_query($args);
$signature = new SignedUrl(strtoupper($method), $url, $expires);
$signature = new SignedUrl(strtoupper($method), $url, $expires, $object_name);
$this->event_dispatcher->dispatch(
new TempUrlGenerateEvent($signature)
@ -178,21 +182,19 @@ final readonly class TempUrlOpenstackGenerator implements TempUrlGeneratorInterf
return \hash_hmac('sha512', $body, $this->key, false);
}
private function generateSignature($method, $url, \DateTimeImmutable $expires)
private function generateSignature(string $method, $url, \DateTimeImmutable $expires)
{
if ('POST' === $method) {
return $this->generateSignaturePost($url, $expires);
}
$path = \parse_url((string) $url, PHP_URL_PATH);
$body = sprintf(
"%s\n%s\n%s",
$method,
strtoupper($method),
$expires->format('U'),
$path
)
;
);
$this->logger->debug(
'generate signature GET',

View File

@ -21,6 +21,8 @@ readonly class SignedUrl
#[Serializer\Groups(['read'])]
public string $url,
public \DateTimeImmutable $expires,
#[Serializer\Groups(['read'])]
public string $object_name,
) {}
#[Serializer\Groups(['read'])]

View File

@ -18,6 +18,7 @@ readonly class SignedUrlPost extends SignedUrl
public function __construct(
string $url,
\DateTimeImmutable $expires,
string $object_name,
#[Serializer\Groups(['read'])]
public int $max_file_size,
#[Serializer\Groups(['read'])]
@ -31,6 +32,6 @@ readonly class SignedUrlPost extends SignedUrl
#[Serializer\Groups(['read'])]
public string $signature,
) {
parent::__construct('POST', $url, $expires);
parent::__construct('POST', $url, $expires, $object_name);
}
}

View File

@ -17,6 +17,7 @@ interface TempUrlGeneratorInterface
?int $expire_delay = null,
?int $submit_delay = null,
int $max_file_count = 1,
?string $object_name = null,
): SignedUrlPost;
public function generate(string $method, string $object_name, ?int $expire_delay = null): SignedUrl;

View File

@ -11,9 +11,11 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Controller;
use Chill\DocStoreBundle\AsyncUpload\Exception\TempUrlGeneratorException;
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
use Chill\DocStoreBundle\Security\Authorization\AsyncUploadVoter;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\MainBundle\Entity\User;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
@ -30,63 +32,85 @@ final readonly class AsyncUploadController
private TempUrlGeneratorInterface $tempUrlGenerator,
private SerializerInterface $serializer,
private Security $security,
private LoggerInterface $logger,
private LoggerInterface $chillLogger,
) {}
#[Route(path: '/asyncupload/temp_url/generate/{method}', name: 'async_upload.generate_url')]
public function getSignedUrl(string $method, Request $request): JsonResponse
#[Route(path: '/api/1.0/doc-store/async-upload/temp_url/{uuid}/generate/post', name: 'chill_docstore_asyncupload_getsignedurlpost')]
public function getSignedUrlPost(Request $request, StoredObject $storedObject): JsonResponse
{
try {
switch (strtolower($method)) {
case 'post':
if (!$this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $storedObject)) {
throw new AccessDeniedHttpException('not able to edit the given stored object');
}
// we create a dummy version, to generate a filename
$version = $storedObject->registerVersion();
$p = $this->tempUrlGenerator
->generatePost(
$request->query->has('expires_delay') ? $request->query->getInt('expires_delay') : null,
$request->query->has('submit_delay') ? $request->query->getInt('submit_delay') : null
)
;
break;
case 'get':
case 'head':
$object_name = $request->query->get('object_name', null);
if (null === $object_name) {
return (new JsonResponse((object) [
'message' => 'the object_name is null',
]))
->setStatusCode(JsonResponse::HTTP_BAD_REQUEST);
}
$p = $this->tempUrlGenerator->generate(
$method,
$object_name,
$request->query->has('expires_delay') ? $request->query->getInt('expires_delay') : null
$request->query->has('submit_delay') ? $request->query->getInt('submit_delay') : null,
object_name: $version->getFilename()
);
break;
default:
return (new JsonResponse((object) ['message' => 'the method '
."{$method} is not valid"]))
->setStatusCode(JsonResponse::HTTP_BAD_REQUEST);
}
} catch (TempUrlGeneratorException $e) {
$this->logger->warning('The client requested a temp url'
.' which sparkle an error.', [
'message' => $e->getMessage(),
'expire_delay' => $request->query->getInt('expire_delay', 0),
'file_count' => $request->query->getInt('file_count', 1),
'method' => $method,
$this->chillLogger->notice('[Privacy Event] a request to upload a document has been generated', [
'doc_uuid' => $storedObject->getUuid(),
]);
$p = new \stdClass();
$p->message = $e->getMessage();
$p->status = JsonResponse::HTTP_BAD_REQUEST;
return new JsonResponse($p, JsonResponse::HTTP_BAD_REQUEST);
return new JsonResponse(
$this->serializer->serialize($p, 'json', [AbstractNormalizer::GROUPS => ['read']]),
Response::HTTP_OK,
[],
true
);
}
if (!$this->security->isGranted(AsyncUploadVoter::GENERATE_SIGNATURE, $p)) {
throw new AccessDeniedHttpException('not allowed to generate this signature');
#[Route(path: '/api/1.0/doc-store/async-upload/temp_url/{uuid}/generate/{method}', name: 'chill_docstore_asyncupload_getsignedurlget', requirements: ['method' => 'get|head'])]
public function getSignedUrlGet(Request $request, StoredObject $storedObject, string $method): JsonResponse
{
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
throw new AccessDeniedHttpException('not able to read the given stored object');
}
// we really want to be sure that there are no other method than get or head:
if (!in_array($method, ['get', 'head'], true)) {
throw new AccessDeniedHttpException('Only methods get and head are allowed');
}
if ($request->query->has('version')) {
$filename = $request->query->get('version');
$storedObjectVersion = $storedObject->getVersions()->findFirst(fn (int $index, StoredObjectVersion $version): bool => $version->getFilename() === $filename);
if (null === $storedObjectVersion) {
// we are here in the case where the version is not stored into the database
// as the version is prefixed by the stored object prefix, we just have to check that this prefix
// is the same. It means that the user had previously the permission to "SEE_AND_EDIT" this stored
// object with same prefix that we checked before
if (!str_starts_with($filename, $storedObject->getPrefix())) {
throw new AccessDeniedHttpException('not able to match the version with the same filename');
}
}
} else {
$filename = $storedObject->getCurrentVersion()->getFilename();
}
$p = $this->tempUrlGenerator->generate(
$method,
$filename,
$request->query->has('expires_delay') ? $request->query->getInt('expires_delay') : null
);
$user = $this->security->getUser();
$userId = match ($user instanceof User) {
true => $user->getId(),
false => $user->getUserIdentifier(),
};
$this->chillLogger->notice('[Privacy Event] a request to see a document has been granted', [
'doc_uuid' => $storedObject->getUuid()->toString(),
'user_id' => $userId,
]);
return new JsonResponse(
$this->serializer->serialize($p, 'json', [AbstractNormalizer::GROUPS => ['read']]),
Response::HTTP_OK,

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Controller;
use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument;
use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter;
use Chill\DocStoreBundle\Workflow\AccompanyingCourseDocumentDuplicator;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Security;
final readonly class DocumentAccompanyingCourseDuplicateController
{
public function __construct(
private Security $security,
private AccompanyingCourseDocumentDuplicator $documentWorkflowDuplicator,
private EntityManagerInterface $entityManager,
private UrlGeneratorInterface $urlGenerator,
) {}
#[Route('/{_locale}/doc-store/accompanying-course-document/{id}/duplicate', name: 'chill_doc_store_accompanying_course_document_duplicate')]
public function __invoke(AccompanyingCourseDocument $document, Request $request, Session $session): Response
{
if (!$this->security->isGranted(AccompanyingCourseDocumentVoter::SEE, $document)) {
throw new AccessDeniedHttpException('not allowed to see this document');
}
if (!$this->security->isGranted(AccompanyingCourseDocumentVoter::CREATE, $document->getCourse())) {
throw new AccessDeniedHttpException('not allowed to create this document');
}
$duplicated = $this->documentWorkflowDuplicator->duplicate($document);
$this->entityManager->persist($duplicated);
$this->entityManager->flush();
return new RedirectResponse(
$this->urlGenerator->generate('accompanying_course_document_edit', ['id' => $duplicated->getId(), 'course' => $duplicated->getCourse()->getId()])
);
}
}

View File

@ -26,6 +26,8 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Contracts\Translation\TranslatorInterface;
use Chill\DocStoreBundle\Service\Signature\PDFSignatureZoneParser;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
/**
* Class DocumentPersonController.
@ -40,6 +42,8 @@ class DocumentPersonController extends AbstractController
protected TranslatorInterface $translator,
protected EventDispatcherInterface $eventDispatcher,
protected AuthorizationHelper $authorizationHelper,
protected PDFSignatureZoneParser $PDFSignatureZoneParser,
protected StoredObjectManagerInterface $storedObjectManagerInterface,
private readonly \Doctrine\Persistence\ManagerRegistry $managerRegistry,
) {}

View File

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Controller;
use Chill\DocStoreBundle\Service\Signature\Driver\BaseSigner\RequestPdfSignMessage;
use Chill\DocStoreBundle\Service\Signature\PDFPage;
use Chill\DocStoreBundle\Service\Signature\PDFSignatureZone;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature;
use Chill\MainBundle\Templating\Entity\ChillEntityRenderManagerInterface;
use Chill\MainBundle\Security\Authorization\EntityWorkflowStepSignatureVoter;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
class SignatureRequestController
{
public function __construct(
private readonly MessageBusInterface $messageBus,
private readonly StoredObjectManagerInterface $storedObjectManager,
private readonly EntityWorkflowManager $entityWorkflowManager,
private readonly ChillEntityRenderManagerInterface $entityRender,
private readonly NormalizerInterface $normalizer,
private readonly Security $security,
) {}
#[Route('/api/1.0/document/workflow/{id}/signature-request', name: 'chill_docstore_signature_request')]
public function processSignature(EntityWorkflowStepSignature $signature, Request $request): JsonResponse
{
if (!$this->security->isGranted(EntityWorkflowStepSignatureVoter::SIGN, $signature)) {
throw new AccessDeniedHttpException('not authorized to sign this step');
}
$entityWorkflow = $signature->getStep()->getEntityWorkflow();
if (EntityWorkflowSignatureStateEnum::PENDING !== $signature->getState()) {
return new JsonResponse([], status: Response::HTTP_CONFLICT);
}
$storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($entityWorkflow);
$content = $this->storedObjectManager->read($storedObject);
$data = \json_decode((string) $request->getContent(), true, 512, JSON_THROW_ON_ERROR); // TODO parse payload: json_decode ou, mieux, dataTransfertObject
$zone = new PDFSignatureZone(
$data['zone']['index'],
$data['zone']['x'],
$data['zone']['y'],
$data['zone']['height'],
$data['zone']['width'],
new PDFPage($data['zone']['PDFPage']['index'], $data['zone']['PDFPage']['width'], $data['zone']['PDFPage']['height'])
);
$this->messageBus->dispatch(new RequestPdfSignMessage(
$signature->getId(),
$zone,
$data['zone']['index'],
'Signed by IP: '.(string) $request->getClientIp().', authenticated user: '.$this->entityRender->renderString($this->security->getUser(), []),
$this->entityRender->renderString($signature->getSigner(), [
// options for user render
'absence' => false,
'main_scope' => false,
// options for person render
'addAge' => false,
]),
$content
));
return new JsonResponse(null, JsonResponse::HTTP_OK, []);
}
#[Route('/api/1.0/document/workflow/{id}/check-signature', name: 'chill_docstore_check_signature')]
public function checkSignature(EntityWorkflowStepSignature $signature): JsonResponse
{
$entityWorkflow = $signature->getStep()->getEntityWorkflow();
$storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($entityWorkflow);
return new JsonResponse(
[
'state' => $signature->getState(),
'storedObject' => $this->normalizer->normalize($storedObject, 'json'),
],
JsonResponse::HTTP_OK,
[]
);
}
}

View File

@ -11,6 +11,46 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Controller;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\MainBundle\CRUD\Controller\ApiController;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\SerializerInterface;
class StoredObjectApiController extends ApiController {}
class StoredObjectApiController extends ApiController
{
public function __construct(
private readonly Security $security,
private readonly SerializerInterface $serializer,
private readonly EntityManagerInterface $entityManager,
) {}
/**
* Creates a new stored object.
*
* @return JsonResponse the response containing the serialized object in JSON format
*
* @throws AccessDeniedHttpException if the user does not have the necessary role to create a stored object
*/
#[Route('/api/1.0/doc-store/stored-object/create', methods: ['POST'])]
public function createStoredObject(): JsonResponse
{
if (!($this->security->isGranted('ROLE_ADMIN') || $this->security->isGranted('ROLE_USER'))) {
throw new AccessDeniedHttpException('Must be user or admin to create a stored object');
}
$object = new StoredObject();
$this->entityManager->persist($object);
$this->entityManager->flush();
return new JsonResponse(
$this->serializer->serialize($object, 'json', [AbstractNormalizer::GROUPS => ['read']]),
json: true
);
}
}

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Controller;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectVersionNormalizer;
use Chill\DocStoreBundle\Service\StoredObjectRestoreInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\SerializerInterface;
final readonly class StoredObjectRestoreVersionApiController
{
public function __construct(private Security $security, private StoredObjectRestoreInterface $storedObjectRestore, private EntityManagerInterface $entityManager, private SerializerInterface $serializer) {}
#[Route('/api/1.0/doc-store/stored-object/restore-from-version/{id}', methods: ['POST'])]
public function restoreStoredObjectVersion(StoredObjectVersion $storedObjectVersion): JsonResponse
{
if (!$this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $storedObjectVersion->getStoredObject())) {
throw new AccessDeniedHttpException('not allowed to edit the stored object');
}
$newVersion = $this->storedObjectRestore->restore($storedObjectVersion);
$this->entityManager->persist($newVersion);
$this->entityManager->flush();
return new JsonResponse(
$this->serializer->serialize($newVersion, 'json', [AbstractNormalizer::GROUPS => ['read', StoredObjectVersionNormalizer::WITH_POINT_IN_TIMES_CONTEXT, StoredObjectVersionNormalizer::WITH_RESTORED_CONTEXT]]),
json: true
);
}
}

View File

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Controller;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectVersionNormalizer;
use Chill\MainBundle\Pagination\PaginatorFactoryInterface;
use Chill\MainBundle\Serializer\Model\Collection;
use Doctrine\Common\Collections\Criteria;
use Doctrine\Common\Collections\Order;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\SerializerInterface;
final readonly class StoredObjectVersionApiController
{
public function __construct(
private PaginatorFactoryInterface $paginatorFactory,
private SerializerInterface $serializer,
private Security $security,
) {}
/**
* Lists the versions of the specified stored object.
*
* @param StoredObject $storedObject the stored object whose versions are to be listed
*
* @return JsonResponse a JSON response containing the serialized versions of the stored object, encapsulated in a collection
*
* @throws AccessDeniedHttpException if the user is not allowed to see the stored object
*/
#[Route('/api/1.0/doc-store/stored-object/{uuid}/versions', name: 'chill_doc_store_stored_object_versions_list')]
public function listVersions(StoredObject $storedObject): JsonResponse
{
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
throw new AccessDeniedHttpException('not allowed to see this stored object');
}
$total = $storedObject->getVersions()->count();
$paginator = $this->paginatorFactory->create($total);
$criteria = Criteria::create();
$criteria->orderBy(['id' => Order::Ascending]);
$criteria->setMaxResults($paginator->getItemsPerPage())->setFirstResult($paginator->getCurrentPageFirstItemNumber());
$items = $storedObject->getVersions()->matching($criteria);
return new JsonResponse(
$this->serializer->serialize(
new Collection($items, $paginator),
'json',
[AbstractNormalizer::GROUPS => ['read', StoredObjectVersionNormalizer::WITH_POINT_IN_TIMES_CONTEXT, StoredObjectVersionNormalizer::WITH_RESTORED_CONTEXT]]
),
json: true
);
}
}

View File

@ -16,6 +16,7 @@ use Chill\DocStoreBundle\Dav\Response\DavResponse;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
@ -42,6 +43,7 @@ final readonly class WebdavController
private \Twig\Environment $engine,
private StoredObjectManagerInterface $storedObjectManager,
private Security $security,
private EntityManagerInterface $entityManager,
) {
$this->requestAnalyzer = new PropfindRequestAnalyzer();
}
@ -201,6 +203,8 @@ final readonly class WebdavController
$this->storedObjectManager->write($storedObject, $request->getContent());
$this->entityManager->flush();
return new DavResponse('', Response::HTTP_NO_CONTENT);
}

View File

@ -11,14 +11,13 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\DependencyInjection;
use Chill\DocStoreBundle\Controller\StoredObjectApiController;
use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter;
use Chill\DocStoreBundle\Security\Authorization\PersonDocumentVoter;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoterInterface;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
use Symfony\Component\DependencyInjection\Loader;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
/**
@ -35,6 +34,8 @@ class ChillDocStoreExtension extends Extension implements PrependExtensionInterf
$container->setParameter('chill_doc_store', $config);
$container->registerForAutoconfiguration(StoredObjectVoterInterface::class)->addTag('stored_object_voter');
$loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../config'));
$loader->load('services.yaml');
$loader->load('services/controller.yaml');
@ -42,6 +43,7 @@ class ChillDocStoreExtension extends Extension implements PrependExtensionInterf
$loader->load('services/fixtures.yaml');
$loader->load('services/form.yaml');
$loader->load('services/templating.yaml');
$loader->load('services/security.yaml');
}
public function prepend(ContainerBuilder $container)
@ -49,29 +51,6 @@ class ChillDocStoreExtension extends Extension implements PrependExtensionInterf
$this->prependRoute($container);
$this->prependAuthorization($container);
$this->prependTwig($container);
$this->prependApis($container);
}
protected function prependApis(ContainerBuilder $container)
{
$container->prependExtensionConfig('chill_main', [
'apis' => [
[
'class' => \Chill\DocStoreBundle\Entity\StoredObject::class,
'controller' => StoredObjectApiController::class,
'name' => 'stored_object',
'base_path' => '/api/1.0/docstore/stored-object',
'base_role' => 'ROLE_USER',
'actions' => [
'_entity' => [
'methods' => [
Request::METHOD_POST => true,
],
],
],
],
],
]);
}
protected function prependAuthorization(ContainerBuilder $container)

View File

@ -16,10 +16,17 @@ use ChampsLibres\WopiLib\Contract\Entity\Document;
use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\Order;
use Doctrine\Common\Collections\ReadableCollection;
use Doctrine\Common\Collections\Selectable;
use Doctrine\ORM\Mapping as ORM;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
use Random\RandomException;
use Symfony\Component\Serializer\Annotation as Serializer;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Represent a document stored in an object store.
@ -28,13 +35,16 @@ use Symfony\Component\Serializer\Annotation as Serializer;
*
* The property `$deleteAt` allow a deletion of the document after the given date. But this property should
* be set before the document is actually written by the StoredObjectManager.
*
* Each version is stored within a @see{StoredObjectVersion}, associated with this current's object. The creation
* of each new version should be done using the method @see{self::registerVersion}.
*/
#[ORM\Entity]
#[ORM\Table('chill_doc.stored_object')]
#[AsyncFileExists(message: 'The file is not stored properly')]
#[ORM\Table('stored_object', schema: 'chill_doc')]
class StoredObject implements Document, TrackCreationInterface
{
use TrackCreationTrait;
final public const STATUS_EMPTY = 'empty';
final public const STATUS_READY = 'ready';
final public const STATUS_PENDING = 'pending';
final public const STATUS_FAILURE = 'failure';
@ -43,9 +53,11 @@ class StoredObject implements Document, TrackCreationInterface
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'datas')]
private array $datas = [];
#[Serializer\Groups(['write'])]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT)]
private string $filename = '';
/**
* the prefix of each version.
*/
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])]
private string $prefix = '';
#[Serializer\Groups(['write'])]
#[ORM\Id]
@ -53,25 +65,10 @@ class StoredObject implements Document, TrackCreationInterface
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)]
private ?int $id = null;
/**
* @var int[]
*/
#[Serializer\Groups(['write'])]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'iv')]
private array $iv = [];
#[Serializer\Groups(['write'])]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'key')]
private array $keyInfos = [];
#[Serializer\Groups(['write'])]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, name: 'title')]
#[ORM\Column(name: 'title', type: \Doctrine\DBAL\Types\Types::TEXT, options: ['default' => ''])]
private string $title = '';
#[Serializer\Groups(['write'])]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, name: 'type', options: ['default' => ''])]
private string $type = '';
#[Serializer\Groups(['write'])]
#[ORM\Column(type: 'uuid', unique: true)]
private UuidInterface $uuid;
@ -94,14 +91,22 @@ class StoredObject implements Document, TrackCreationInterface
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])]
private string $generationErrors = '';
/**
* @var Collection<int, StoredObjectVersion>&Selectable<int, StoredObjectVersion>
*/
#[ORM\OneToMany(mappedBy: 'storedObject', targetEntity: StoredObjectVersion::class, cascade: ['persist'], orphanRemoval: true)]
private Collection&Selectable $versions;
/**
* @param StoredObject::STATUS_* $status
*/
public function __construct(
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, options: ['default' => 'ready'])]
private string $status = 'ready',
private string $status = 'empty',
) {
$this->uuid = Uuid::uuid4();
$this->versions = new ArrayCollection();
$this->prefix = self::generatePrefix();
}
public function addGenerationTrial(): self
@ -125,14 +130,34 @@ class StoredObject implements Document, TrackCreationInterface
return \DateTime::createFromImmutable($this->createdAt);
}
#[AsyncFileExists(message: 'The file is not stored properly')]
#[Assert\NotNull(message: 'The store object version must be present')]
public function getCurrentVersion(): ?StoredObjectVersion
{
$maxVersion = null;
foreach ($this->versions as $v) {
if ($v->getVersion() > ($maxVersion?->getVersion() ?? -1)) {
$maxVersion = $v;
}
}
return $maxVersion;
}
public function getDatas(): array
{
return $this->datas;
}
public function getPrefix(): string
{
return $this->prefix;
}
public function getFilename(): string
{
return $this->filename;
return $this->getCurrentVersion()?->getFilename() ?? '';
}
public function getGenerationTrialsCounter(): int
@ -145,14 +170,17 @@ class StoredObject implements Document, TrackCreationInterface
return $this->id;
}
/**
* @return list<int>
*/
public function getIv(): array
{
return $this->iv;
return $this->getCurrentVersion()?->getIv() ?? [];
}
public function getKeyInfos(): array
{
return $this->keyInfos;
return $this->getCurrentVersion()?->getKeyInfos() ?? [];
}
/**
@ -171,14 +199,14 @@ class StoredObject implements Document, TrackCreationInterface
return $this->status;
}
public function getTitle()
public function getTitle(): string
{
return $this->title;
}
public function getType()
public function getType(): string
{
return $this->type;
return $this->getCurrentVersion()?->getType() ?? '';
}
public function getUuid(): UuidInterface
@ -209,27 +237,6 @@ class StoredObject implements Document, TrackCreationInterface
return $this;
}
public function setFilename(?string $filename): self
{
$this->filename = (string) $filename;
return $this;
}
public function setIv(?array $iv): self
{
$this->iv = (array) $iv;
return $this;
}
public function setKeyInfos(?array $keyInfos): self
{
$this->keyInfos = (array) $keyInfos;
return $this;
}
/**
* @param StoredObject::STATUS_* $status
*/
@ -247,23 +254,89 @@ class StoredObject implements Document, TrackCreationInterface
return $this;
}
public function setType(?string $type): self
{
$this->type = (string) $type;
return $this;
}
public function getTemplate(): ?DocGeneratorTemplate
{
return $this->template;
}
/**
* @return Selectable<int, StoredObjectVersion>&Collection<int, StoredObjectVersion>
*/
public function getVersions(): Collection&Selectable
{
return $this->versions;
}
/**
* Retrieves versions sorted by a given order.
*
* @param 'ASC'|'DESC' $order the sorting order, default is Order::Ascending
*
* @return readableCollection&Selectable The ordered collection of versions
*/
public function getVersionsOrdered(string $order = 'ASC'): ReadableCollection&Selectable
{
$versions = $this->getVersions()->toArray();
match ($order) {
'ASC' => usort($versions, static fn (StoredObjectVersion $a, StoredObjectVersion $b) => $a->getVersion() <=> $b->getVersion()),
'DESC' => usort($versions, static fn (StoredObjectVersion $a, StoredObjectVersion $b) => $b->getVersion() <=> $a->getVersion()),
};
return new ArrayCollection($versions);
}
public function hasCurrentVersion(): bool
{
return null !== $this->getCurrentVersion();
}
public function hasTemplate(): bool
{
return null !== $this->template;
}
/**
* Checks if there is a version kept before conversion.
*
* @return bool true if a version is kept before conversion, false otherwise
*/
public function hasKeptBeforeConversionVersion(): bool
{
foreach ($this->getVersions() as $version) {
foreach ($version->getPointInTimes() as $pointInTime) {
if (StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION === $pointInTime->getReason()) {
return true;
}
}
}
return false;
}
/**
* Retrieves the last version of the stored object that was kept before conversion.
*
* This method iterates through the ordered versions and their respective points
* in time to find the most recent version that has a point in time with the reason
* 'KEEP_BEFORE_CONVERSION'.
*
* @return StoredObjectVersion|null the version that was kept before conversion,
* or null if not found
*/
public function getLastKeptBeforeConversionVersion(): ?StoredObjectVersion
{
foreach ($this->getVersionsOrdered('DESC') as $version) {
foreach ($version->getPointInTimes() as $pointInTime) {
if (StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION === $pointInTime->getReason()) {
return $version;
}
}
}
return null;
}
public function setTemplate(?DocGeneratorTemplate $template): StoredObject
{
$this->template = $template;
@ -314,18 +387,65 @@ class StoredObject implements Document, TrackCreationInterface
return $this;
}
public function saveHistory(): void
{
if ('' === $this->getFilename()) {
return;
public function registerVersion(
array $iv = [],
array $keyInfos = [],
string $type = '',
?string $filename = null,
): StoredObjectVersion {
$version = new StoredObjectVersion(
$this,
null === $this->getCurrentVersion() ? 0 : $this->getCurrentVersion()->getVersion() + 1,
$iv,
$keyInfos,
$type,
$filename
);
$this->versions->add($version);
if ('empty' === $this->status) {
$this->status = self::STATUS_READY;
}
$this->datas['history'][] = [
'filename' => $this->getFilename(),
'iv' => $this->getIv(),
'key_infos' => $this->getKeyInfos(),
'type' => $this->getType(),
'before' => (new \DateTimeImmutable('now'))->getTimestamp(),
];
return $version;
}
public function removeVersion(StoredObjectVersion $storedObjectVersion): void
{
if (!$this->versions->contains($storedObjectVersion)) {
throw new \UnexpectedValueException('This stored object does not contains this version');
}
$this->versions->removeElement($storedObjectVersion);
}
/**
* @deprecated
*/
public function saveHistory(): void {}
public static function generatePrefix(): string
{
try {
return base_convert(bin2hex(random_bytes(32)), 16, 36);
} catch (RandomException) {
return uniqid(more_entropy: true);
}
}
/**
* Checks if a stored object can be deleted.
*
* Currently, return true if the deletedAt date is below the current date, and the object
* does not contains any version (which must be removed first).
*
* @param \DateTimeImmutable $now the current date and time
* @param StoredObject $storedObject the stored object to check
*
* @return bool returns true if the stored object can be deleted, false otherwise
*/
public static function canBeDeleted(\DateTimeImmutable $now, StoredObject $storedObject): bool
{
return $storedObject->getDeleteAt() < $now && $storedObject->getVersions()->isEmpty();
}
}

View File

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Entity;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Chill\MainBundle\Entity\User;
use Doctrine\ORM\Mapping as ORM;
/**
* Represents a snapshot of a stored object at a specific point in time.
*
* This entity tracks versions of stored objects, reasons for the snapshot,
* and the user who initiated the action.
*/
#[ORM\Entity]
#[ORM\Table(name: 'stored_object_point_in_time', schema: 'chill_doc')]
class StoredObjectPointInTime implements TrackCreationInterface
{
use TrackCreationTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)]
private ?int $id = null;
public function __construct(
#[ORM\ManyToOne(targetEntity: StoredObjectVersion::class, inversedBy: 'pointInTimes')]
#[ORM\JoinColumn(name: 'stored_object_version_id', nullable: false)]
private StoredObjectVersion $objectVersion,
#[ORM\Column(name: 'reason', type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, enumType: StoredObjectPointInTimeReasonEnum::class)]
private StoredObjectPointInTimeReasonEnum $reason,
#[ORM\ManyToOne(targetEntity: User::class)]
private ?User $byUser = null,
) {
$this->objectVersion->addPointInTime($this);
}
public function getId(): ?int
{
return $this->id;
}
public function getByUser(): ?User
{
return $this->byUser;
}
public function getObjectVersion(): StoredObjectVersion
{
return $this->objectVersion;
}
public function getReason(): StoredObjectPointInTimeReasonEnum
{
return $this->reason;
}
}

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Entity;
enum StoredObjectPointInTimeReasonEnum: string
{
case KEEP_BEFORE_CONVERSION = 'keep-before-conversion';
case KEEP_BY_USER = 'keep-by-user';
}

View File

@ -0,0 +1,229 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Entity;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\Selectable;
use Doctrine\ORM\Mapping as ORM;
use Random\RandomException;
/**
* Store each version of StoredObject's.
*
* A version should not be created manually: use the method @see{StoredObject::registerVersion} instead.
*/
#[ORM\Entity]
#[ORM\Table('chill_doc.stored_object_version')]
#[ORM\UniqueConstraint(name: 'chill_doc_stored_object_version_unique_by_object', columns: ['stored_object_id', 'version'])]
class StoredObjectVersion implements TrackCreationInterface
{
use TrackCreationTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)]
private ?int $id = null;
/**
* filename of the version in the stored object.
*/
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])]
private string $filename = '';
/**
* @var Collection<int, StoredObjectPointInTime>&Selectable<int, StoredObjectPointInTime>
*/
#[ORM\OneToMany(mappedBy: 'objectVersion', targetEntity: StoredObjectPointInTime::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection&Selectable $pointInTimes;
/**
* Previous storedObjectVersion, from which the current stored object version is created.
*
* If null, the current stored object version is generated by other means.
*
* Those version may be associated with the same storedObject, or not. In this last case, that means that
* the stored object's current version is created from another stored object version.
*/
#[ORM\ManyToOne(targetEntity: StoredObjectVersion::class)]
private ?StoredObjectVersion $createdFrom = null;
/**
* List of stored object versions created from the current version.
*
* @var Collection<int, StoredObjectVersion>
*/
#[ORM\OneToMany(mappedBy: 'createdFrom', targetEntity: StoredObjectVersion::class)]
private Collection $children;
public function __construct(
/**
* The stored object associated with this version.
*/
#[ORM\ManyToOne(targetEntity: StoredObject::class, inversedBy: 'versions')]
#[ORM\JoinColumn(name: 'stored_object_id', nullable: false)]
private StoredObject $storedObject,
/**
* The incremental version.
*/
#[ORM\Column(name: 'version', type: \Doctrine\DBAL\Types\Types::INTEGER, options: ['default' => 0])]
private int $version = 0,
/**
* vector for encryption.
*
* @var int[]
*/
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'iv')]
private array $iv = [],
/**
* Key infos for document encryption.
*
* @var array
*/
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'key')]
private array $keyInfos = [],
/**
* type of the document.
*/
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, name: 'type', options: ['default' => ''])]
private string $type = '',
?string $filename = null,
) {
$this->filename = $filename ?? self::generateFilename($this);
$this->pointInTimes = new ArrayCollection();
$this->children = new ArrayCollection();
}
public static function generateFilename(StoredObjectVersion $storedObjectVersion): string
{
try {
$suffix = base_convert(bin2hex(random_bytes(8)), 16, 36);
} catch (RandomException) {
$suffix = uniqid(more_entropy: true);
}
return $storedObjectVersion->getStoredObject()->getPrefix().'/'.$suffix;
}
public function getFilename(): string
{
return $this->filename;
}
public function getId(): ?int
{
return $this->id;
}
public function getIv(): array
{
return $this->iv;
}
public function getKeyInfos(): array
{
return $this->keyInfos;
}
public function getStoredObject(): StoredObject
{
return $this->storedObject;
}
public function getType(): string
{
return $this->type;
}
public function getVersion(): int
{
return $this->version;
}
/**
* @return Collection<int, StoredObjectPointInTime>&Selectable<int, StoredObjectPointInTime>
*/
public function getPointInTimes(): Selectable&Collection
{
return $this->pointInTimes;
}
public function hasPointInTimes(): bool
{
return $this->pointInTimes->count() > 0;
}
/**
* @internal use @see{StoredObjectPointInTime} constructor instead
*/
public function addPointInTime(StoredObjectPointInTime $storedObjectPointInTime): self
{
if (!$this->pointInTimes->contains($storedObjectPointInTime)) {
$this->pointInTimes->add($storedObjectPointInTime);
}
return $this;
}
public function removePointInTime(StoredObjectPointInTime $storedObjectPointInTime): self
{
if ($this->pointInTimes->contains($storedObjectPointInTime)) {
$this->pointInTimes->removeElement($storedObjectPointInTime);
}
return $this;
}
public function getCreatedFrom(): ?StoredObjectVersion
{
return $this->createdFrom;
}
public function setCreatedFrom(?StoredObjectVersion $createdFrom): StoredObjectVersion
{
if (null === $createdFrom && null !== $this->createdFrom) {
$this->createdFrom->removeChild($this);
}
$createdFrom?->addChild($this);
$this->createdFrom = $createdFrom;
return $this;
}
public function addChild(StoredObjectVersion $child): self
{
if (!$this->children->contains($child)) {
$this->children->add($child);
}
return $this;
}
public function removeChild(StoredObjectVersion $child): self
{
$result = $this->children->removeElement($child);
if (false === $result) {
throw new \UnexpectedValueException('the child is not associated with the current stored object version');
}
return $this;
}
}

View File

@ -55,16 +55,8 @@ class StoredObjectDataMapper implements DataMapperInterface
return;
}
/** @var StoredObject $viewData */
if ($viewData->getFilename() !== $forms['stored_object']->getData()['filename']) {
// we want to keep the previous history
$viewData->saveHistory();
}
$viewData->setFilename($forms['stored_object']->getData()['filename']);
$viewData->setIv($forms['stored_object']->getData()['iv']);
$viewData->setKeyInfos($forms['stored_object']->getData()['keyInfos']);
$viewData->setType($forms['stored_object']->getData()['type']);
/* @var StoredObject $viewData */
$viewData = $forms['stored_object']->getData();
if (array_key_exists('title', $forms)) {
$viewData->setTitle($forms['title']->getData());

View File

@ -12,7 +12,6 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Form\DataTransformer;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectNormalizer;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\UnexpectedTypeException;
use Symfony\Component\Serializer\SerializerInterface;
@ -30,11 +29,7 @@ class StoredObjectDataTransformer implements DataTransformerInterface
}
if ($value instanceof StoredObject) {
return $this->serializer->serialize($value, 'json', [
'groups' => [
StoredObjectNormalizer::ADD_DAV_EDIT_LINK_CONTEXT,
],
]);
return $this->serializer->serialize($value, 'json');
}
throw new UnexpectedTypeException($value, StoredObject::class);
@ -46,6 +41,6 @@ class StoredObjectDataTransformer implements DataTransformerInterface
return null;
}
return json_decode((string) $value, true, 10, JSON_THROW_ON_ERROR);
return $this->serializer->deserialize($value, StoredObject::class, 'json');
}
}

View File

@ -12,17 +12,18 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Repository;
use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ObjectRepository;
class AccompanyingCourseDocumentRepository implements ObjectRepository
class AccompanyingCourseDocumentRepository implements ObjectRepository, AssociatedEntityToStoredObjectInterface
{
private readonly EntityRepository $repository;
public function __construct(private readonly EntityManagerInterface $em)
public function __construct(EntityManagerInterface $em)
{
$this->repository = $em->getRepository(AccompanyingCourseDocument::class);
}
@ -45,6 +46,16 @@ class AccompanyingCourseDocumentRepository implements ObjectRepository
return $qb->getQuery()->getSingleScalarResult();
}
public function findAssociatedEntityToStoredObject(StoredObject $storedObject): ?object
{
$qb = $this->repository->createQueryBuilder('d');
$query = $qb->where('d.object = :storedObject')
->setParameter('storedObject', $storedObject)
->getQuery();
return $query->getOneOrNullResult();
}
public function find($id): ?AccompanyingCourseDocument
{
return $this->repository->find($id);
@ -55,7 +66,7 @@ class AccompanyingCourseDocumentRepository implements ObjectRepository
return $this->repository->findAll();
}
public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null)
public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null): array
{
return $this->repository->findBy($criteria, $orderBy, $limit, $offset);
}
@ -65,7 +76,7 @@ class AccompanyingCourseDocumentRepository implements ObjectRepository
return $this->findOneBy($criteria);
}
public function getClassName()
public function getClassName(): string
{
return AccompanyingCourseDocument::class;
}

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Repository;
use Chill\DocStoreBundle\Entity\StoredObject;
interface AssociatedEntityToStoredObjectInterface
{
public function findAssociatedEntityToStoredObject(StoredObject $storedObject): ?object;
}

View File

@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Repository;
use Chill\DocStoreBundle\Entity\PersonDocument;
use Chill\DocStoreBundle\Entity\StoredObject;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\Persistence\ObjectRepository;
@ -19,7 +20,7 @@ use Doctrine\Persistence\ObjectRepository;
/**
* @template ObjectRepository<PersonDocument::class>
*/
readonly class PersonDocumentRepository implements ObjectRepository
readonly class PersonDocumentRepository implements ObjectRepository, AssociatedEntityToStoredObjectInterface
{
private EntityRepository $repository;
@ -53,4 +54,14 @@ readonly class PersonDocumentRepository implements ObjectRepository
{
return PersonDocument::class;
}
public function findAssociatedEntityToStoredObject(StoredObject $storedObject): ?object
{
$qb = $this->repository->createQueryBuilder('d');
$query = $qb->where('d.object = :storedObject')
->setParameter('storedObject', $storedObject)
->getQuery();
return $query->getOneOrNullResult();
}
}

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Repository;
use Chill\DocStoreBundle\Entity\StoredObjectPointInTime;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @template-extends ServiceEntityRepository<StoredObjectPointInTime>
*/
class StoredObjectPointInTimeRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, StoredObjectPointInTime::class);
}
}

View File

@ -14,6 +14,7 @@ namespace Chill\DocStoreBundle\Repository;
use Chill\DocStoreBundle\Entity\StoredObject;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query;
final readonly class StoredObjectRepository implements StoredObjectRepositoryInterface
{
@ -53,6 +54,21 @@ final readonly class StoredObjectRepository implements StoredObjectRepositoryInt
return $this->repository->findOneBy($criteria);
}
public function findByExpired(\DateTimeImmutable $expiredAtDate): iterable
{
$qb = $this->repository->createQueryBuilder('stored_object');
$qb
->where('stored_object.deleteAt <= :expiredAt')
->setParameter('expiredAt', $expiredAtDate);
return $qb->getQuery()->toIterable(hydrationMode: Query::HYDRATE_OBJECT);
}
public function findOneByUUID(string $uuid): ?StoredObject
{
return $this->repository->findOneBy(['uuid' => $uuid]);
}
public function getClassName(): string
{
return StoredObject::class;

View File

@ -17,4 +17,12 @@ use Doctrine\Persistence\ObjectRepository;
/**
* @extends ObjectRepository<StoredObject>
*/
interface StoredObjectRepositoryInterface extends ObjectRepository {}
interface StoredObjectRepositoryInterface extends ObjectRepository
{
/**
* @return iterable<StoredObject>
*/
public function findByExpired(\DateTimeImmutable $expiredAtDate): iterable;
public function findOneByUUID(string $uuid): ?StoredObject;
}

View File

@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Repository;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\Persistence\ObjectRepository;
/**
* @implements ObjectRepository<StoredObjectVersion>
*/
class StoredObjectVersionRepository implements ObjectRepository
{
private readonly EntityRepository $repository;
private readonly Connection $connection;
public function __construct(EntityManagerInterface $entityManager)
{
$this->repository = $entityManager->getRepository(StoredObjectVersion::class);
$this->connection = $entityManager->getConnection();
}
public function find($id): ?StoredObjectVersion
{
return $this->repository->find($id);
}
public function findAll(): array
{
return $this->repository->findAll();
}
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array
{
return $this->repository->findBy($criteria, $orderBy, $limit, $offset);
}
public function findOneBy(array $criteria): ?StoredObjectVersion
{
return $this->repository->findOneBy($criteria);
}
/**
* Finds the IDs of versions older than a given date and that are not the last version.
*
* Those version are good candidates for a deletion.
*
* @param \DateTimeImmutable $beforeDate the date to compare versions against
*
* @return iterable returns an iterable with the IDs of the versions
*/
public function findIdsByVersionsOlderThanDateAndNotLastVersionAndNotPointInTime(\DateTimeImmutable $beforeDate): iterable
{
$results = $this->connection->executeQuery(
self::QUERY_FIND_IDS_BY_VERSIONS_OLDER_THAN_DATE_AND_NOT_LAST_VERSION,
[$beforeDate],
[Types::DATETIME_IMMUTABLE]
);
foreach ($results->iterateAssociative() as $row) {
yield $row['sov_id'];
}
}
private const QUERY_FIND_IDS_BY_VERSIONS_OLDER_THAN_DATE_AND_NOT_LAST_VERSION = <<<'SQL'
SELECT
sov.id AS sov_id
FROM chill_doc.stored_object_version sov
WHERE
sov.createdat < ?::timestamp
AND
sov.version < (SELECT MAX(sub_sov.version) FROM chill_doc.stored_object_version sub_sov WHERE sub_sov.stored_object_id = sov.stored_object_id)
AND
NOT EXISTS (SELECT 1 FROM chill_doc.stored_object_point_in_time sub_poi WHERE sub_poi.stored_object_version_id = sov.id)
SQL;
public function getClassName(): string
{
return StoredObjectVersion::class;
}
}

View File

@ -1,5 +1,5 @@
import {makeFetch} from "../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods";
import {PostStoreObjectSignature} from "../../types";
import {PostStoreObjectSignature, StoredObject} from "../../types";
const algo = 'AES-CBC';
@ -21,11 +21,22 @@ const createFilename = (): string => {
return text;
};
export const uploadFile = async (uploadFile: ArrayBuffer): Promise<string> => {
/**
* Fetches a new stored object from the server.
*
* @async
* @function fetchNewStoredObject
* @returns {Promise<StoredObject>} A Promise that resolves to the newly created StoredObject.
*/
export const fetchNewStoredObject = async (): Promise<StoredObject> => {
return makeFetch("POST", '/api/1.0/doc-store/stored-object/create', null);
}
export const uploadVersion = async (uploadFile: ArrayBuffer, storedObject: StoredObject): Promise<string> => {
const params = new URLSearchParams();
params.append('expires_delay', "180");
params.append('submit_delay', "180");
const asyncData: PostStoreObjectSignature = await makeFetch("GET", URL_POST + "?" + params.toString());
const asyncData: PostStoreObjectSignature = await makeFetch("GET", `/api/1.0/doc-store/async-upload/temp_url/${storedObject.uuid}/generate/post` + "?" + params.toString());
const suffix = createFilename();
const filename = asyncData.prefix + suffix;
const formData = new FormData();
@ -50,7 +61,6 @@ export const uploadFile = async (uploadFile: ArrayBuffer): Promise<string> => {
}
export const encryptFile = async (originalFile: ArrayBuffer): Promise<[ArrayBuffer, Uint8Array, JsonWebKey]> => {
console.log('encrypt', originalFile);
const iv = crypto.getRandomValues(new Uint8Array(16));
const key = await window.crypto.subtle.generateKey(keyDefinition, true, [ "encrypt", "decrypt" ]);
const exportedKey = await window.crypto.subtle.exportKey('jwk', key);

View File

@ -1,7 +1,7 @@
import {CollectionEventPayload} from "../../../../../ChillMainBundle/Resources/public/module/collection";
import {createApp} from "vue";
import DropFileWidget from "../../vuejs/DropFileWidget/DropFileWidget.vue"
import {StoredObject, StoredObjectCreated} from "../../types";
import {StoredObject, StoredObjectVersion} from "../../types";
import {_createI18n} from "../../../../../ChillMainBundle/Resources/public/vuejs/_js/i18n";
const i18n = _createI18n({});
@ -30,15 +30,17 @@ const startApp = (divElement: HTMLDivElement, collectionEntry: null|HTMLLIElemen
DropFileWidget,
},
methods: {
addDocument: function(object: StoredObjectCreated): void {
console.log('object added', object);
this.$data.existingDoc = object;
input_stored_object.value = JSON.stringify(object);
addDocument: function({stored_object, stored_object_version}: {stored_object: StoredObject, stored_object_version: StoredObjectVersion}): void {
console.log('object added', stored_object);
console.log('version added', stored_object_version);
this.$data.existingDoc = stored_object;
this.$data.existingDoc.currentVersion = stored_object_version;
input_stored_object.value = JSON.stringify(this.$data.existingDoc);
},
removeDocument: function(object: StoredObject): void {
console.log('catch remove document', object);
input_stored_object.value = "";
this.$data.existingDoc = null;
this.$data.existingDoc = undefined;
console.log('collectionEntry', collectionEntry);
if (null !== collectionEntry) {

View File

@ -0,0 +1,27 @@
import {_createI18n} from "../../../../../ChillMainBundle/Resources/public/vuejs/_js/i18n";
import {createApp} from "vue";
import DocumentActionButtonsGroup from "../../vuejs/DocumentActionButtonsGroup.vue";
import {StoredObject, StoredObjectStatusChange} from "../../types";
import {defineComponent} from "vue";
import DownloadButton from "../../vuejs/StoredObjectButton/DownloadButton.vue";
import ToastPlugin from "vue-toast-notification";
const i18n = _createI18n({});
window.addEventListener('DOMContentLoaded', function (e) {
document.querySelectorAll<HTMLDivElement>('div[data-download-button-single]').forEach((el) => {
const storedObject = JSON.parse(el.dataset.storedObject as string) as StoredObject;
const title = el.dataset.title as string;
const app = createApp({
components: {DownloadButton},
data() {
return {storedObject, title, classes: {btn: true, "btn-outline-primary": true}};
},
template: '<download-button :stored-object="storedObject" :at-version="storedObject.currentVersion" :classes="classes" :filename="title" :direct-download="true"></download-button>',
});
app.use(i18n).use(ToastPlugin).mount(el);
});
});

View File

@ -3,6 +3,7 @@ import DocumentActionButtonsGroup from "../../vuejs/DocumentActionButtonsGroup.v
import {createApp} from "vue";
import {StoredObject, StoredObjectStatusChange} from "../../types";
import {is_object_ready} from "../../vuejs/StoredObjectButton/helpers";
import ToastPlugin from "vue-toast-notification";
const i18n = _createI18n({});
@ -48,6 +49,6 @@ window.addEventListener('DOMContentLoaded', function (e) {
}
});
app.use(i18n).mount(el);
app.use(i18n).use(ToastPlugin).mount(el);
})
});

View File

@ -1,64 +1,141 @@
import {DateTime} from "../../../ChillMainBundle/Resources/public/types";
import {
DateTime,
User,
} from "../../../ChillMainBundle/Resources/public/types";
import {SignedUrlGet} from "./vuejs/StoredObjectButton/helpers";
export type StoredObjectStatus = "ready"|"failure"|"pending";
export type StoredObjectStatus = "empty" | "ready" | "failure" | "pending";
export interface StoredObject {
id: number,
id: number;
title: string | null;
uuid: string;
prefix: string;
status: StoredObjectStatus;
currentVersion:
| null
| StoredObjectVersionCreated
| StoredObjectVersionPersisted;
totalVersions: number;
datas: object;
/** @deprecated */
creationDate: DateTime;
createdAt: DateTime | null;
createdBy: User | null;
_permissions: {
canEdit: boolean;
canSee: boolean;
};
_links?: {
dav_link?: {
href: string;
expiration: number;
};
downloadLink?: SignedUrlGet;
};
}
export interface StoredObjectVersion {
/**
* filename of the object in the object storage
*/
filename: string,
creationDate: DateTime,
datas: object,
iv: number[],
keyInfos: object,
title: string,
type: string,
uuid: string,
status: StoredObjectStatus,
_links?: {
dav_link?: {
href: string
expiration: number
},
}
filename: string;
iv: number[];
keyInfos: JsonWebKey;
type: string;
}
export interface StoredObjectCreated {
status: "stored_object_created",
filename: string,
iv: Uint8Array,
keyInfos: object,
type: string,
export interface StoredObjectVersionCreated extends StoredObjectVersion {
persisted: false;
}
export interface StoredObjectVersionPersisted
extends StoredObjectVersionCreated {
version: number;
id: number;
createdAt: DateTime | null;
createdBy: User | null;
}
export interface StoredObjectStatusChange {
id: number,
filename: string,
status: StoredObjectStatus,
type: string,
id: number;
filename: string;
status: StoredObjectStatus;
type: string;
}
export interface StoredObjectVersionWithPointInTime extends StoredObjectVersionPersisted {
"point-in-times": StoredObjectPointInTime[];
"from-restored": StoredObjectVersionPersisted|null;
}
export interface StoredObjectPointInTime {
id: number;
byUser: User | null;
reason: 'keep-before-conversion'|'keep-by-user';
}
/**
* Function executed by the WopiEditButton component.
*/
export type WopiEditButtonExecutableBeforeLeaveFunction = {
(): Promise<void>
}
(): Promise<void>;
};
/**
* Object containing information for performering a POST request to a swift object store
*/
export interface PostStoreObjectSignature {
method: "POST",
max_file_size: number,
max_file_count: 1,
expires: number,
submit_delay: 180,
redirect: string,
prefix: string,
url: string,
signature: string,
method: "POST";
max_file_size: number;
max_file_count: 1;
expires: number;
submit_delay: 180;
redirect: string;
prefix: string;
url: string;
signature: string;
}
export interface PDFPage {
index: number;
width: number;
height: number;
}
export interface SignatureZone {
index: number | null;
x: number;
y: number;
width: number;
height: number;
PDFPage: PDFPage;
}
export interface Signature {
id: number;
storedObject: StoredObject;
zones: SignatureZone[];
}
export type SignedState =
| "pending"
| "signed"
| "rejected"
| "canceled"
| "error";
export interface CheckSignature {
state: SignedState;
storedObject: StoredObject;
}
export type CanvasEvent = "select" | "add";
export interface ZoomLevel {
id: number;
zoom: number;
label: {
fr?: string,
nl?: string
};
}

View File

@ -1,20 +1,23 @@
<template>
<div v-if="'ready' === props.storedObject.status || 'stored_object_created' === props.storedObject.status" class="btn-group">
<div v-if="isButtonGroupDisplayable" class="btn-group">
<button :class="Object.assign({'btn': true, 'btn-outline-primary': true, 'dropdown-toggle': true, 'btn-sm': props.small})" type="button" data-bs-toggle="dropdown" aria-expanded="false">
Actions
</button>
<ul class="dropdown-menu">
<li v-if="props.canEdit && is_extension_editable(props.storedObject.type) && props.storedObject.status !== 'stored_object_created'">
<li v-if="isEditableOnline">
<wopi-edit-button :stored-object="props.storedObject" :classes="{'dropdown-item': true}" :execute-before-leave="props.executeBeforeLeave"></wopi-edit-button>
</li>
<li v-if="props.canEdit && is_extension_editable(props.storedObject.type) && props.davLink !== undefined && props.davLinkExpiration !== undefined">
<li v-if="isEditableOnDesktop">
<desktop-edit-button :classes="{'dropdown-item': true}" :edit-link="props.davLink" :expiration-link="props.davLinkExpiration"></desktop-edit-button>
</li>
<li v-if="props.storedObject.type != 'application/pdf' && is_extension_viewable(props.storedObject.type) && props.canConvertPdf && props.storedObject.status !== 'stored_object_created'">
<li v-if="isConvertibleToPdf">
<convert-button :stored-object="props.storedObject" :filename="filename" :classes="{'dropdown-item': true}"></convert-button>
</li>
<li v-if="props.canDownload">
<download-button :stored-object="props.storedObject" :filename="filename" :classes="{'dropdown-item': true}"></download-button>
<li v-if="isDownloadable">
<download-button :stored-object="props.storedObject" :at-version="props.storedObject.currentVersion" :filename="filename" :classes="{'dropdown-item': true}" :display-action-string-in-button="true"></download-button>
</li>
<li v-if="isHistoryViewable">
<history-button :stored-object="props.storedObject" :can-edit="canEdit && props.storedObject._permissions.canEdit"></history-button>
</li>
</ul>
</div>
@ -29,20 +32,21 @@
<script lang="ts" setup>
import {onMounted} from "vue";
import {computed, onMounted} from "vue";
import ConvertButton from "./StoredObjectButton/ConvertButton.vue";
import DownloadButton from "./StoredObjectButton/DownloadButton.vue";
import WopiEditButton from "./StoredObjectButton/WopiEditButton.vue";
import {is_extension_editable, is_extension_viewable, is_object_ready} from "./StoredObjectButton/helpers";
import {
StoredObject, StoredObjectCreated,
StoredObjectStatusChange,
StoredObject,
StoredObjectStatusChange, StoredObjectVersion,
WopiEditButtonExecutableBeforeLeaveFunction
} from "../types";
import DesktopEditButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DesktopEditButton.vue";
import HistoryButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/HistoryButton.vue";
interface DocumentActionButtonsGroupConfig {
storedObject: StoredObject|StoredObjectCreated,
storedObject: StoredObject,
small?: boolean,
canEdit?: boolean,
canDownload?: boolean,
@ -95,11 +99,48 @@ let tryiesForReady = 0;
*/
const maxTryiesForReady = 120;
const isButtonGroupDisplayable = computed<boolean>(() => {
return isDownloadable.value || isEditableOnline.value || isEditableOnDesktop.value || isConvertibleToPdf.value;
});
const isDownloadable = computed<boolean>(() => {
return props.storedObject.status === 'ready'
// happens when the stored object version is just added, but not persisted
|| (props.storedObject.currentVersion !== null && props.storedObject.status === 'empty')
});
const isEditableOnline = computed<boolean>(() => {
return props.storedObject.status === 'ready'
&& props.storedObject._permissions.canEdit
&& props.canEdit
&& props.storedObject.currentVersion !== null
&& is_extension_editable(props.storedObject.currentVersion.type)
&& props.storedObject.currentVersion.persisted !== false;
});
const isEditableOnDesktop = computed<boolean>(() => {
return isEditableOnline.value;
});
const isConvertibleToPdf = computed<boolean>(() => {
return props.storedObject.status === 'ready'
&& props.storedObject._permissions.canSee
&& props.canConvertPdf
&& props.storedObject.currentVersion !== null
&& is_extension_viewable(props.storedObject.currentVersion.type)
&& props.storedObject.currentVersion.type !== 'application/pdf'
&& props.storedObject.currentVersion.persisted !== false;
});
const isHistoryViewable = computed<boolean>(() => {
return props.storedObject.status === 'ready';
});
const checkForReady = function(): void {
if (
'ready' === props.storedObject.status
|| 'empty' === props.storedObject.status
|| 'failure' === props.storedObject.status
|| 'stored_object_created' === props.storedObject.status
// stop reloading if the page stays opened for a long time
|| tryiesForReady > maxTryiesForReady
) {

View File

@ -0,0 +1,733 @@
<template>
<teleport to="body">
<modal v-if="modalOpen" @close="modalOpen = false">
<template v-slot:header>
<h2>{{ $t("signature_confirmation") }}</h2>
</template>
<template v-slot:body>
<div class="signature-modal-body text-center" v-if="loading">
<p>{{ $t("electronic_signature_in_progress") }}</p>
<div class="loading">
<i
class="fa fa-circle-o-notch fa-spin fa-3x"
:title="$t('loading')"
></i>
</div>
</div>
<div class="signature-modal-body text-center" v-else>
<p>{{ $t("you_are_going_to_sign") }}</p>
<p>{{ $t("are_you_sure") }}</p>
</div>
</template>
<template v-slot:footer>
<button class="btn btn-action" @click.prevent="confirmSign">
{{ $t("yes") }}
</button>
</template>
</modal>
</teleport>
<div class="col-12 m-auto">
<div class="row justify-content-center border-bottom pdf-tools d-md-none">
<div class="col text-center turn-page">
<select
class="form-select form-select-sm"
id="zoomSelect"
v-model="zoomLevel"
@change="setZoomLevel(zoomLevel)"
>
<option value="" selected disabled>Zoom</option>
<option v-for="z in zoomLevels" :value="z.zoom" :key="z.id">
{{ z.label.fr }}
</option>
</select>
<template v-if="pageCount > 1">
<button
class="btn btn-light btn-xs p-1"
:disabled="page <= 1"
@click="turnPage(-1)"
>
</button>
<span>{{ page }}/{{ pageCount }}</span>
<button
class="btn btn-light btn-xs p-1"
:disabled="page >= pageCount"
@click="turnPage(1)"
>
</button>
</template>
</div>
<div
v-if="signature.zones.length > 1"
class="col-5 p-0 text-center turnSignature"
>
<button
:disabled="userSignatureZone === null || userSignatureZone?.index < 1"
class="btn btn-light btn-sm"
@click="turnSignature(-1)"
>
{{ $t("last_zone") }}
</button>
<span>|</span>
<button
:disabled="userSignatureZone?.index >= signature.zones.length - 1"
class="btn btn-light btn-sm"
@click="turnSignature(1)"
>
{{ $t("next_zone") }}
</button>
</div>
<div class="col text-end" v-if="signedState !== 'signed'">
<button
class="btn btn-misc btn-sm"
:hidden="!userSignatureZone"
@click="undoSign"
v-if="signature.zones.length > 1"
:title="$t('choose_another_signature')"
>
{{ $t("another_zone") }}
</button>
<button
class="btn btn-misc btn-sm"
:hidden="!userSignatureZone"
@click="undoSign"
v-else
>
{{ $t("cancel") }}
</button>
<button v-if="userSignatureZone === null"
:class="{ btn: true, 'btn-sm': true, 'btn-create': canvasEvent !== 'add', 'btn-chill-green': canvasEvent === 'add', active: canvasEvent === 'add' }"
@click="toggleAddZone()"
:title="$t('add_sign_zone')"
>
<template v-if="canvasEvent === 'add'">
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</template>
</button>
</div>
</div>
<div
class="row justify-content-center border-bottom pdf-tools d-none d-md-flex"
>
<div class="col-3 text-center turn-page ps-3">
<select
class="form-select form-select-sm"
id="zoomSelect"
v-model="zoomLevel"
@change="setZoomLevel(zoomLevel)"
>
<option value="" selected disabled>Zoom</option>
<option v-for="z in zoomLevels" :value="z.zoom" :key="z.id">
{{ z.label.fr }}
</option>
</select>
<template v-if="pageCount > 1">
<button
class="btn btn-light btn-xs p-1"
:disabled="page <= 1"
@click="turnPage(-1)"
>
</button>
<span>{{ page }} / {{ pageCount }}</span>
<button
class="btn btn-light btn-xs p-1"
:disabled="page >= pageCount"
@click="turnPage(1)"
>
</button>
</template>
</div>
<div
v-if="signature.zones.length > 1 && signedState !== 'signed'"
class="col-4 d-xl-none text-center turnSignature p-0"
>
<button
:disabled="userSignatureZone === null || userSignatureZone?.index < 1"
class="btn btn-light btn-sm"
@click="turnSignature(-1)"
>
{{ $t("last_zone") }}
</button>
<span>|</span>
<button
:disabled="userSignatureZone?.index >= signature.zones.length - 1"
class="btn btn-light btn-sm"
@click="turnSignature(1)"
>
{{ $t("next_zone") }}
</button>
</div>
<div
v-if="signature.zones.length > 1 && signedState !== 'signed'"
class="col-4 d-none d-xl-flex p-0 text-center turnSignature"
>
<button
:disabled="userSignatureZone === null || userSignatureZone?.index < 1"
class="btn btn-light btn-sm"
@click="turnSignature(-1)"
>
{{ $t("last_sign_zone") }}
</button>
<span>|</span>
<button
:disabled="userSignatureZone?.index >= signature.zones.length - 1"
class="btn btn-light btn-sm"
@click="turnSignature(1)"
>
{{ $t("next_sign_zone") }}
</button>
</div>
<div class="col text-end" v-if="signedState !== 'signed'">
<button
class="btn btn-misc btn-sm"
:hidden="!userSignatureZone"
@click="undoSign"
v-if="signature.zones.length > 1"
>
{{ $t("choose_another_signature") }}
</button>
<button
class="btn btn-misc btn-sm"
:hidden="!userSignatureZone"
@click="undoSign"
v-else
>
{{ $t("cancel") }}
</button>
<button v-if="userSignatureZone === null"
:class="{ btn: true, 'btn-sm': true, 'btn-create': canvasEvent !== 'add', 'btn-chill-green': canvasEvent === 'add', active: canvasEvent === 'add' }"
@click="toggleAddZone()"
:title="$t('add_sign_zone')"
>
<template v-if="canvasEvent !== 'add'">
{{ $t("add_zone") }}
</template>
<template v-else>
{{ $t("click_on_document")}}
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</template>
</button>
</div>
</div>
</div>
<div class="col-xs-12 col-md-12 col-lg-9 m-auto my-5 text-center" :class="{onAddZone: canvasEvent === 'add'}">
<canvas class="m-auto" id="canvas" ></canvas>
</div>
<div class="col-xs-12 col-md-12 col-lg-9 m-auto p-4" id="action-buttons">
<div class="row">
<div class="col d-flex">
<a
class="btn btn-cancel"
v-if="signedState !== 'signed'"
:href="getReturnPath()"
>
{{ $t("cancel") }}
</a>
<a class="btn btn-misc" v-else :href="getReturnPath()">
{{ $t("return") }}
</a>
</div>
<div class="col text-end" v-if="signedState !== 'signed'">
<button
class="btn btn-action me-2"
:disabled="!userSignatureZone"
@click="sign"
>
{{ $t("sign") }}
</button>
</div>
<div class="col-4" v-else></div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, Ref, reactive } from "vue";
import { useToast } from "vue-toast-notification";
import "vue-toast-notification/dist/theme-sugar.css";
import {
CanvasEvent,
CheckSignature,
Signature,
SignatureZone,
SignedState,
ZoomLevel,
} from "../../types";
import { makeFetch } from "../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods";
import * as pdfjsLib from "pdfjs-dist";
import {
PDFDocumentProxy,
PDFPageProxy,
} from "pdfjs-dist/types/src/display/api";
// @ts-ignore
import * as PdfWorker from "pdfjs-dist/build/pdf.worker.mjs";
console.log(PdfWorker); // incredible but this is needed
// import { PdfWorker } from 'pdfjs-dist/build/pdf.worker.mjs'
// pdfjsLib.GlobalWorkerOptions.workerSrc = PdfWorker;
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import { download_and_decrypt_doc } from "../StoredObjectButton/helpers";
pdfjsLib.GlobalWorkerOptions.workerSrc = "pdfjs-dist/build/pdf.worker.mjs";
const modalOpen: Ref<boolean> = ref(false);
const loading: Ref<boolean> = ref(false);
const adding: Ref<boolean> = ref(false);
const canvasEvent: Ref<CanvasEvent> = ref("select");
const signedState: Ref<SignedState> = ref("pending");
const page: Ref<number> = ref(1);
const pageCount: Ref<number> = ref(0);
const zoom: Ref<number> = ref(1);
let zoomLevel = "";
const zoomLevels: Ref<ZoomLevel[]> = ref([
{
id: 0,
zoom: 0.75,
label: {
fr: "75%",
},
},
{
id: 1,
zoom: zoom.value,
label: {
fr: "100%",
},
},
{
id: 2,
zoom: 1.25,
label: {
fr: "125%",
},
},
{
id: 3,
zoom: 1.5,
label: {
fr: "150%",
},
},
{
id: 4,
zoom: 2,
label: {
fr: "200%",
},
},
{
id: 5,
zoom: 3,
label: {
fr: "300%",
},
},
]);
let userSignatureZone: Ref<null | SignatureZone> = ref(null);
let pdf = {} as PDFDocumentProxy;
declare global {
interface Window {
signature: Signature;
}
}
const $toast = useToast();
const signature = window.signature;
const setZoomLevel = (zoomLevel: string) => {
zoom.value = Number.parseFloat(zoomLevel);
setPage(page.value);
setTimeout(() => drawAllZones(page.value), 200);
};
const mountPdf = async (doc: ArrayBuffer) => {
const loadingTask = pdfjsLib.getDocument(doc);
pdf = await loadingTask.promise;
pageCount.value = pdf.numPages;
await setPage(page.value);
};
const getRenderContext = (pdfPage: PDFPageProxy) => {
const scale = 1 * zoom.value;
const viewport = pdfPage.getViewport({ scale });
const canvas = document.querySelectorAll("canvas")[0] as HTMLCanvasElement;
const context = canvas.getContext("2d") as CanvasRenderingContext2D;
canvas.height = viewport.height;
canvas.width = viewport.width;
return {
canvasContext: context,
viewport: viewport,
};
};
const setPage = async (page: number) => {
const pdfPage = await pdf.getPage(page);
const renderContext = getRenderContext(pdfPage);
await pdfPage.render(renderContext);
};
const init = () => downloadAndOpen().then(initPdf);
async function downloadAndOpen(): Promise<Blob> {
let raw;
try {
raw = await download_and_decrypt_doc(
signature.storedObject,
signature.storedObject.currentVersion
);
} catch (e) {
console.error("error while downloading and decrypting document", e);
throw e;
}
const doc = await raw.arrayBuffer();
await mountPdf(doc);
return raw;
}
const initPdf = () => {
const canvas = document.querySelectorAll("canvas")[0] as HTMLCanvasElement;
canvas.addEventListener("pointerup", canvasClick, false);
setTimeout(() => drawAllZones(page.value), 800);
};
const scaleXToCanvas = (x: number, canvasWidth: number, PDFWidth: number) =>
Math.round((x * canvasWidth) / PDFWidth);
const scaleYToCanvas = (h: number, canvasHeight: number, PDFHeight: number) =>
Math.round((h * canvasHeight) / PDFHeight);
const hitSignature = (
zone: SignatureZone,
xy: number[],
canvasWidth: number,
canvasHeight: number
) =>
scaleXToCanvas(zone.x, canvasWidth, zone.PDFPage.width) < xy[0] &&
xy[0] <
scaleXToCanvas(zone.x + zone.width, canvasWidth, zone.PDFPage.width) &&
zone.PDFPage.height * zoom.value -
scaleYToCanvas(zone.y, canvasHeight, zone.PDFPage.height) <
xy[1] &&
xy[1] <
scaleYToCanvas(zone.height - zone.y, canvasHeight, zone.PDFPage.height) +
zone.PDFPage.height * zoom.value;
const selectZone = (z: SignatureZone, canvas: HTMLCanvasElement) => {
userSignatureZone.value = z;
const ctx = canvas.getContext("2d");
if (ctx) {
setPage(page.value);
setTimeout(() => drawAllZones(page.value), 200);
}
};
const selectZoneEvent = (e: PointerEvent, canvas: HTMLCanvasElement) =>
signature.zones
.filter((z) => z.PDFPage.index + 1 === page.value)
.map((z) => {
if (
hitSignature(z, [e.offsetX, e.offsetY], canvas.width, canvas.height)
) {
if (userSignatureZone.value === null) {
selectZone(z, canvas);
} else {
if (userSignatureZone.value.index === z.index) {
sign();
}
}
}
});
const canvasClick = (e: PointerEvent) => {
const canvas = document.querySelectorAll("canvas")[0] as HTMLCanvasElement;
canvasEvent.value === "select"
? selectZoneEvent(e, canvas)
: addZoneEvent(e, canvas);
};
const turnPage = async (upOrDown: number) => {
//userSignatureZone.value = null; // desactivate the reset of the zone when turning page
page.value = page.value + upOrDown;
await setPage(page.value);
setTimeout(() => drawAllZones(page.value), 200);
};
const turnSignature = async (upOrDown: number) => {
let zoneIndex = userSignatureZone.value?.index ?? -1;
if (zoneIndex < -1) {
zoneIndex = -1;
}
if (zoneIndex < signature.zones.length) {
zoneIndex = zoneIndex + upOrDown;
} else {
zoneIndex = 0;
}
let currentZone = signature.zones[zoneIndex];
if (currentZone) {
page.value = currentZone.PDFPage.index + 1;
userSignatureZone.value = currentZone;
const canvas = document.querySelectorAll("canvas")[0];
selectZone(currentZone, canvas);
}
};
const drawZone = (
zone: SignatureZone,
ctx: CanvasRenderingContext2D,
canvasWidth: number,
canvasHeight: number
) => {
const unselectedBlue = "#007bff";
const selectedBlue = "#034286";
ctx.strokeStyle =
userSignatureZone.value?.index === zone.index
? selectedBlue
: unselectedBlue;
ctx.lineWidth = 2;
ctx.lineJoin = "bevel";
ctx.strokeRect(
scaleXToCanvas(zone.x, canvasWidth, zone.PDFPage.width),
zone.PDFPage.height * zoom.value -
scaleYToCanvas(zone.y, canvasHeight, zone.PDFPage.height),
scaleXToCanvas(zone.width, canvasWidth, zone.PDFPage.width),
scaleYToCanvas(zone.height, canvasHeight, zone.PDFPage.height)
);
ctx.font = `bold ${16 * zoom.value}px serif`;
ctx.textAlign = "center";
ctx.fillStyle = "black";
const xText =
scaleXToCanvas(zone.x, canvasWidth, zone.PDFPage.width) +
scaleXToCanvas(zone.width, canvasWidth, zone.PDFPage.width) / 2;
const yText =
zone.PDFPage.height * zoom.value -
scaleYToCanvas(zone.y, canvasHeight, zone.PDFPage.height) +
scaleYToCanvas(zone.height, canvasHeight, zone.PDFPage.height) / 2;
if (userSignatureZone.value?.index === zone.index) {
ctx.fillStyle = selectedBlue;
ctx.fillText("Signer ici", xText, yText);
} else {
ctx.fillStyle = unselectedBlue;
ctx.fillText("Choisir cette", xText, yText - 12 * zoom.value);
ctx.fillText("zone de signature", xText, yText + 12 * zoom.value);
}
};
const drawAllZones = (page: number) => {
const canvas = document.querySelectorAll("canvas")[0];
const ctx = canvas.getContext("2d");
if (ctx && signedState.value !== "signed") {
signature.zones
.filter((z) => z.PDFPage.index + 1 === page)
.map((z) => {
if (userSignatureZone.value) {
if (userSignatureZone.value?.index === z.index) {
drawZone(z, ctx, canvas.width, canvas.height);
}
} else {
drawZone(z, ctx, canvas.width, canvas.height);
}
});
}
};
const checkSignature = () => {
const url = `/api/1.0/document/workflow/${signature.id}/check-signature`;
return makeFetch<null, CheckSignature>("GET", url)
.then((r) => {
signedState.value = r.state;
signature.storedObject = r.storedObject;
checkForReady();
})
.catch((error) => {
signedState.value = "error";
console.log("Error while checking the signature", error);
$toast.error(
`Erreur lors de la vérification de la signature: ${error.txt}`
);
});
};
const maxTryForReady = 60; //2 minutes for trying to sign
let tryForReady = 0;
const stopTrySigning = () => {
loading.value = false;
modalOpen.value = false;
};
const checkForReady = () => {
if (tryForReady > maxTryForReady) {
stopTrySigning();
tryForReady = 0;
console.log("Reached the maximum number of tentative to try signing");
$toast.error(
"Le nombre maximum de tentatives pour essayer de signer est atteint"
);
}
if (signedState.value === "rejected") {
stopTrySigning();
console.log("Signature rejected by the server");
$toast.error("Signature rejetée par le serveur");
}
if (signedState.value === "canceled") {
stopTrySigning();
console.log("Signature canceled");
$toast.error("Signature annulée");
}
if (signedState.value === "pending") {
tryForReady = tryForReady + 1;
setTimeout(() => checkSignature(), 2000);
} else {
stopTrySigning();
if (signedState.value === "signed") {
userSignatureZone.value = null;
downloadAndOpen();
}
}
};
const sign = () => (modalOpen.value = true);
const confirmSign = () => {
loading.value = true;
const url = `/api/1.0/document/workflow/${signature.id}/signature-request`;
const body = {
storedObject: signature.storedObject,
zone: userSignatureZone.value,
};
makeFetch("POST", url, body)
.then((r) => {
checkForReady();
})
.catch((error) => {
console.log("Error while posting the signature", error);
stopTrySigning();
$toast.error(
`Erreur lors de la soumission de la signature: ${error.txt}`
);
});
};
const undoSign = async () => {
signature.zones = signature.zones.filter((z) => z.index !== null);
await setPage(page.value);
setTimeout(() => drawAllZones(page.value), 200);
userSignatureZone.value = null;
adding.value = false;
canvasEvent.value = "select";
};
const toggleAddZone = () => {
canvasEvent.value === "select"
? (canvasEvent.value = "add")
: (canvasEvent.value = "select");
};
const addZoneEvent = async (e: PointerEvent, canvas: HTMLCanvasElement) => {
const BOX_WIDTH = 180;
const BOX_HEIGHT = 90;
const PDFPageHeight = canvas.height;
const PDFPageWidth = canvas.width;
const x = e.offsetX;
const y = e.offsetY;
const newZone: SignatureZone = {
index: null,
x:
scaleXToCanvas(x, canvas.width, PDFPageWidth) -
scaleXToCanvas(BOX_WIDTH / 2, canvas.width, PDFPageWidth),
y:
PDFPageHeight -
scaleYToCanvas(y, canvas.height, PDFPageHeight) +
scaleYToCanvas(BOX_HEIGHT / 2, canvas.height, PDFPageHeight),
width: BOX_WIDTH,
height: BOX_HEIGHT,
PDFPage: {
index: page.value - 1,
width: PDFPageWidth,
height: PDFPageHeight,
},
};
signature.zones.push(newZone);
userSignatureZone.value = newZone;
await setPage(page.value);
setTimeout(() => drawAllZones(page.value), 200);
canvasEvent.value = "select";
adding.value = true;
};
const getReturnPath = () =>
window.location.search
? window.location.search.split("?returnPath=")[1] ??
window.location.pathname
: window.location.pathname;
init();
</script>
<style scoped lang="scss">
#canvas {
box-shadow: 0 20px 20px rgba(0, 0, 0, 0.2);
}
.onAddZone {
cursor: not-allowed;
#canvas {
cursor: copy;
}
}
div#action-buttons {
position: sticky;
bottom: 0px;
background-color: white;
z-index: 100;
}
div.pdf-tools {
background-color: #f3f3f3;
font-size: 0.6rem;
button {
font-size: 0.75rem !important;
}
div.turnSignature {
span {
font-size: 1rem;
}
}
@media (min-width: 1400px) {
// background: none;
// border: none !important;
}
}
div.turn-page {
display: flex;
span {
font-size: 0.75rem;
margin: auto 0.4rem;
}
select {
width: 5rem;
font-size: 0.75rem;
}
}
div.signature-modal-body {
height: 8rem;
}
</style>

View File

@ -0,0 +1,37 @@
import { createApp } from "vue";
// @ts-ignore
import { _createI18n } from "ChillMainAssets/vuejs/_js/i18n";
import App from "./App.vue";
const appMessages = {
fr: {
yes: 'Oui',
are_you_sure: 'Êtes-vous sûr·e?',
you_are_going_to_sign: 'Vous allez signer le document',
signature_confirmation: 'Confirmation de la signature',
sign: 'Signer',
choose_another_signature: 'Choisir une autre zone',
cancel: 'Annuler',
last_sign_zone: 'Zone de signature précédente',
next_sign_zone: 'Zone de signature suivante',
add_sign_zone: 'Ajouter une zone de signature',
click_on_document: 'Cliquer sur le document',
last_zone: 'Zone précédente',
next_zone: 'Zone suivante',
add_zone: 'Ajouter une zone',
another_zone: 'Autre zone',
electronic_signature_in_progress: 'Signature électronique en cours...',
loading: 'Chargement...',
remove_sign_zone: 'Enlever la zone',
return: 'Retour',
}
}
const i18n = _createI18n(appMessages);
const app = createApp({
template: `<app></app>`,
})
.use(i18n)
.component("app", App)
.mount("#document-signature");

View File

@ -1,17 +1,18 @@
<script setup lang="ts">
import {StoredObject, StoredObjectCreated} from "../../types";
import {encryptFile, uploadFile} from "../_components/helper";
import {StoredObject, StoredObjectVersionCreated} from "../../types";
import {encryptFile, fetchNewStoredObject, uploadVersion} from "../../js/async-upload/uploader";
import {computed, ref, Ref} from "vue";
import FileIcon from "ChillDocStoreAssets/vuejs/FileIcon.vue";
interface DropFileConfig {
existingDoc?: StoredObjectCreated|StoredObject,
existingDoc?: StoredObject,
}
const props = defineProps<DropFileConfig>();
const props = withDefaults(defineProps<DropFileConfig>(), {existingDoc: null});
const emit = defineEmits<{
(e: 'addDocument', stored_object: StoredObjectCreated): void,
(e: 'addDocument', {stored_object_version: StoredObjectVersionCreated, stored_object: StoredObject}): void,
}>();
const is_dragging: Ref<boolean> = ref(false);
@ -35,7 +36,6 @@ const onDragLeave = (e: Event) => {
}
const onDrop = (e: DragEvent) => {
console.log('on drop', e);
e.preventDefault();
const files = e.dataTransfer?.files;
@ -65,7 +65,6 @@ const onZoneClick = (e: Event) => {
const onFileChange = async (event: Event): Promise<void> => {
const input = event.target as HTMLInputElement;
console.log('event triggered', input);
if (input.files && input.files[0]) {
console.log('file added', input.files[0]);
@ -82,21 +81,28 @@ const handleFile = async (file: File): Promise<void> => {
uploading.value = true;
display_filename.value = file.name;
const type = file.type;
const buffer = await file.arrayBuffer();
const [encrypted, iv, jsonWebKey] = await encryptFile(buffer);
const filename = await uploadFile(encrypted);
console.log(iv, jsonWebKey);
const storedObject: StoredObjectCreated = {
filename: filename,
iv,
keyInfos: jsonWebKey,
type: type,
status: "stored_object_created",
// create a stored_object if not exists
let stored_object;
if (null === props.existingDoc) {
stored_object = await fetchNewStoredObject();
} else {
stored_object = props.existingDoc;
}
emit('addDocument', storedObject);
const buffer = await file.arrayBuffer();
const [encrypted, iv, jsonWebKey] = await encryptFile(buffer);
const filename = await uploadVersion(encrypted, stored_object);
const stored_object_version: StoredObjectVersionCreated = {
filename: filename,
iv: Array.from(iv),
keyInfos: jsonWebKey,
type: type,
persisted: false,
}
emit('addDocument', {stored_object, stored_object_version});
uploading.value = false;
}
@ -106,16 +112,7 @@ const handleFile = async (file: File): Promise<void> => {
<div class="drop-file">
<div v-if="!uploading" :class="{ area: true, dragging: is_dragging}" @click="onZoneClick" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
<p v-if="has_existing_doc" class="file-icon">
<i class="fa fa-file-pdf-o" v-if="props.existingDoc?.type === 'application/pdf'"></i>
<i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/vnd.oasis.opendocument.text'"></i>
<i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'"></i>
<i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/msword'"></i>
<i class="fa fa-file-excel-o" v-else-if="props.existingDoc?.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'"></i>
<i class="fa fa-file-excel-o" v-else-if="props.existingDoc?.type === 'application/vnd.ms-excel'"></i>
<i class="fa fa-file-image-o" v-else-if="props.existingDoc?.type === 'image/jpeg'"></i>
<i class="fa fa-file-image-o" v-else-if="props.existingDoc?.type === 'image/png'"></i>
<i class="fa fa-file-archive-o" v-else-if="props.existingDoc?.type === 'application/x-zip-compressed'"></i>
<i class="fa fa-file-code-o" v-else ></i>
<file-icon :type="props.existingDoc?.type"></file-icon>
</p>
<p v-if="display_filename !== null" class="display-filename">{{ display_filename }}</p>
@ -151,6 +148,11 @@ const handleFile = async (file: File): Promise<void> => {
flex-direction: column;
justify-content: center;
align-items: center;
p {
// require for display in DropFileModal
text-align: center;
}
}
& > .area {

View File

@ -0,0 +1,69 @@
<script setup lang="ts">
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import {StoredObject, StoredObjectVersion} from "../../types";
import DropFileWidget from "ChillDocStoreAssets/vuejs/DropFileWidget/DropFileWidget.vue";
import {computed, reactive} from "vue";
import {useToast} from 'vue-toast-notification';
interface DropFileConfig {
allowRemove: boolean,
existingDoc?: StoredObject,
}
const props = withDefaults(defineProps<DropFileConfig>(), {
allowRemove: false,
});
const emit = defineEmits<{
(e: 'addDocument', {stored_object: StoredObject, stored_object_version: StoredObjectVersion}): void,
(e: 'removeDocument'): void
}>();
const $toast = useToast();
const state = reactive({showModal: false});
const modalClasses = {"modal-dialog-centered": true, "modal-md": true};
const buttonState = computed<'add'|'replace'>(() => {
if (props.existingDoc === undefined || props.existingDoc === null) {
return 'add';
}
return 'replace';
})
function onAddDocument({stored_object, stored_object_version}: {stored_object: StoredObject, stored_object_version: StoredObjectVersion}): void {
const message = buttonState.value === 'add' ? "Document ajouté" : "Document remplacé";
$toast.success(message);
emit('addDocument', {stored_object_version, stored_object});
state.showModal = false;
}
function onRemoveDocument(): void {
emit('removeDocument');
}
function openModal(): void {
state.showModal = true;
}
function closeModal(): void {
state.showModal = false;
}
</script>
<template>
<button v-if="buttonState === 'add'" @click="openModal" class="btn btn-create">Ajouter un document</button>
<button v-else @click="openModal" class="btn btn-edit">Remplacer le document</button>
<modal v-if="state.showModal" :modal-dialog-class="modalClasses" @close="closeModal">
<template v-slot:body>
<drop-file-widget :existing-doc="existingDoc" :allow-remove="allowRemove" @add-document="onAddDocument" @remove-document="onRemoveDocument" ></drop-file-widget>
</template>
</modal>
</template>
<style scoped lang="scss">
</style>

View File

@ -1,13 +1,13 @@
<script setup lang="ts">
import {StoredObject, StoredObjectCreated} from "../../types";
import {StoredObject, StoredObjectVersion} from "../../types";
import {computed, ref, Ref} from "vue";
import DropFile from "ChillDocStoreAssets/vuejs/DropFileWidget/DropFile.vue";
import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue";
interface DropFileConfig {
allowRemove: boolean,
existingDoc?: StoredObjectCreated|StoredObject,
existingDoc?: StoredObject,
}
const props = withDefaults(defineProps<DropFileConfig>(), {
@ -15,8 +15,8 @@ const props = withDefaults(defineProps<DropFileConfig>(), {
});
const emit = defineEmits<{
(e: 'addDocument', stored_object: StoredObjectCreated): void,
(e: 'removeDocument', stored_object: null): void
(e: 'addDocument', {stored_object: StoredObject, stored_object_version: StoredObjectVersion}): void,
(e: 'removeDocument'): void
}>();
const has_existing_doc = computed<boolean>(() => {
@ -45,14 +45,14 @@ const dav_link_href = computed<string|undefined>(() => {
return props.existingDoc._links?.dav_link?.href;
})
const onAddDocument = (s: StoredObjectCreated): void => {
emit('addDocument', s);
const onAddDocument = ({stored_object, stored_object_version}: {stored_object: StoredObject, stored_object_version: StoredObjectVersion}): void => {
emit('addDocument', {stored_object, stored_object_version});
}
const onRemoveDocument = (e: Event): void => {
e.stopPropagation();
e.preventDefault();
emit('removeDocument', null);
emit('removeDocument');
}
</script>

View File

@ -0,0 +1,25 @@
<script setup lang="ts">
interface FileIconConfig {
type: string;
}
const props = defineProps<FileIconConfig>();
</script>
<template>
<i class="fa fa-file-pdf-o" v-if="props.type === 'application/pdf'"></i>
<i class="fa fa-file-word-o" v-else-if="props.type === 'application/vnd.oasis.opendocument.text'"></i>
<i class="fa fa-file-word-o" v-else-if="props.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'"></i>
<i class="fa fa-file-word-o" v-else-if="props.type === 'application/msword'"></i>
<i class="fa fa-file-excel-o" v-else-if="props.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'"></i>
<i class="fa fa-file-excel-o" v-else-if="props.type === 'application/vnd.ms-excel'"></i>
<i class="fa fa-file-image-o" v-else-if="props.type === 'image/jpeg'"></i>
<i class="fa fa-file-image-o" v-else-if="props.type === 'image/png'"></i>
<i class="fa fa-file-archive-o" v-else-if="props.type === 'application/x-zip-compressed'"></i>
<i class="fa fa-file-code-o" v-else ></i>
</template>
<style scoped lang="scss">
</style>

View File

@ -10,7 +10,7 @@
import {build_convert_link, download_and_decrypt_doc, download_doc} from "./helpers";
import mime from "mime";
import {reactive, ref} from "vue";
import {StoredObject, StoredObjectCreated} from "../../types";
import {StoredObject} from "../../types";
interface ConvertButtonConfig {
storedObject: StoredObject,
@ -54,7 +54,7 @@ function reset_state(): void {
</script>
<style scoped lang="sass">
<style scoped lang="scss">
i.fa::before {
color: var(--bs-dropdown-link-hover-color);
}

View File

@ -63,4 +63,7 @@ const editionUntilFormatted = computed<string>(() => {
.desktop-edit {
text-align: center;
}
i.fa::before {
color: var(--bs-dropdown-link-hover-color);
}
</style>

View File

@ -1,24 +1,34 @@
<template>
<a v-if="!state.is_ready" :class="props.classes" @click="download_and_open($event)">
<a v-if="!state.is_ready" :class="props.classes" @click="download_and_open()" title="T&#233;l&#233;charger">
<i class="fa fa-download"></i>
Télécharger
<template v-if="displayActionStringInButton">Télécharger</template>
</a>
<a v-else :class="props.classes" target="_blank" :type="props.storedObject.type" :download="buildDocumentName()" :href="state.href_url" ref="open_button">
<a v-else :class="props.classes" target="_blank" :type="props.storedObject.type" :download="buildDocumentName()" :href="state.href_url" ref="open_button" title="Ouvrir">
<i class="fa fa-external-link"></i>
Ouvrir
<template v-if="displayActionStringInButton">Ouvrir</template>
</a>
</template>
<script lang="ts" setup>
import {reactive, ref, nextTick, onMounted} from "vue";
import {build_download_info_link, download_and_decrypt_doc} from "./helpers";
import {download_and_decrypt_doc} from "./helpers";
import mime from "mime";
import {StoredObject, StoredObjectCreated} from "../../types";
import {StoredObject, StoredObjectVersion} from "../../types";
interface DownloadButtonConfig {
storedObject: StoredObject|StoredObjectCreated,
storedObject: StoredObject,
atVersion: StoredObjectVersion,
classes: { [k: string]: boolean },
filename?: string,
/**
* if true, display the action string into the button. If false, displays only
* the icon
*/
displayActionStringInButton?: boolean,
/**
* if true, will download directly the file on load
*/
directDownload?: boolean,
}
interface DownloadButtonState {
@ -27,14 +37,19 @@ interface DownloadButtonState {
href_url: string,
}
const props = defineProps<DownloadButtonConfig>();
const props = withDefaults(defineProps<DownloadButtonConfig>(), {displayActionStringInButton: true, directDownload: false});
const state: DownloadButtonState = reactive({is_ready: false, is_running: false, href_url: "#"});
const open_button = ref<HTMLAnchorElement | null>(null);
function buildDocumentName(): string {
const document_name = props.filename || 'document';
const ext = mime.getExtension(props.storedObject.type);
let document_name = props.filename ?? props.storedObject.title;
if ('' === document_name) {
document_name = 'document';
}
const ext = mime.getExtension(props.atVersion.type);
if (null !== ext) {
return document_name + '.' + ext;
@ -43,9 +58,7 @@ function buildDocumentName(): string {
return document_name;
}
async function download_and_open(event: Event): Promise<void> {
const button = event.target as HTMLAnchorElement;
async function download_and_open(): Promise<void> {
if (state.is_running) {
console.log('state is running, aborting');
return;
@ -58,36 +71,27 @@ async function download_and_open(event: Event): Promise<void> {
return;
}
const urlInfo = build_download_info_link(props.storedObject.filename);
let raw;
try {
raw = await download_and_decrypt_doc(urlInfo, props.storedObject.keyInfos, new Uint8Array(props.storedObject.iv));
raw = await download_and_decrypt_doc(props.storedObject, props.atVersion);
} catch (e) {
console.error("error while downloading and decrypting document");
console.error(e);
throw e;
}
console.log('document downloading (and decrypting) successfully');
console.log('creating the url')
state.href_url = window.URL.createObjectURL(raw);
console.log('url created', state.href_url);
state.is_running = false;
state.is_ready = true;
console.log('new button marked as ready');
console.log('will click on button');
console.log('openbutton is now', open_button.value);
if (!props.directDownload) {
await nextTick();
console.log('next tick actions');
console.log('openbutton after next tick', open_button.value);
open_button.value?.click();
console.log('open button should have been clicked');
const timer = setTimeout(reset_state, 45000);
console.log('open button should have been clicked');
setTimeout(reset_state, 45000);
}
}
function reset_state(): void {
@ -95,10 +99,19 @@ function reset_state(): void {
state.is_ready = false;
state.is_running = false;
}
onMounted(() => {
if (props.directDownload) {
download_and_open();
}
});
</script>
<style scoped lang="sass">
<style scoped lang="scss">
i.fa::before {
color: var(--bs-dropdown-link-hover-color);
}
i.fa {
margin-right: 0.5rem;
}
</style>

View File

@ -0,0 +1,57 @@
<script setup lang="ts">
import HistoryButtonModal from "ChillDocStoreAssets/vuejs/StoredObjectButton/HistoryButton/HistoryButtonModal.vue";
import {StoredObject, StoredObjectVersionWithPointInTime} from "./../../types";
import {computed, reactive, ref, useTemplateRef} from "vue";
import {get_versions} from "./HistoryButton/api";
interface HistoryButtonConfig {
storedObject: StoredObject;
canEdit: boolean;
}
interface HistoryButtonState {
versions: StoredObjectVersionWithPointInTime[];
loaded: boolean;
}
const props = defineProps<HistoryButtonConfig>();
const state = reactive<HistoryButtonState>({versions: [], loaded: false});
const modal = useTemplateRef<typeof HistoryButtonModal>('modal');
const download_version_and_open_modal = async function (): Promise<void> {
if (null !== modal.value) {
modal.value.open();
} else {
console.log("modal is null");
}
if (!state.loaded) {
const versions = await get_versions(props.storedObject);
for (const version of versions) {
state.versions.push(version);
}
state.loaded = true;
}
}
const onRestoreVersion = ({newVersion}: {newVersion: StoredObjectVersionWithPointInTime}) => {
state.versions.unshift(newVersion);
}
</script>
<template>
<a @click="download_version_and_open_modal" class="dropdown-item">
<history-button-modal ref="modal" :versions="state.versions" :stored-object="storedObject" :can-edit="canEdit" @restore-version="onRestoreVersion"></history-button-modal>
<i class="fa fa-history"></i>
Historique
</a>
</template>
<style scoped lang="scss">
i.fa::before {
color: var(--bs-dropdown-link-hover-color);
}
</style>

View File

@ -0,0 +1,67 @@
<script setup lang="ts">
import {StoredObject, StoredObjectVersionWithPointInTime} from "./../../../types";
import HistoryButtonListItem from "ChillDocStoreAssets/vuejs/StoredObjectButton/HistoryButton/HistoryButtonListItem.vue";
import {computed, reactive} from "vue";
interface HistoryButtonListConfig {
versions: StoredObjectVersionWithPointInTime[];
storedObject: StoredObject;
canEdit: boolean;
}
const emit = defineEmits<{
restoreVersion: [newVersion: StoredObjectVersionWithPointInTime]
}>()
interface HistoryButtonListState {
/**
* Contains the number of the newly created version when a version is restored.
*/
restored: number;
}
const props = defineProps<HistoryButtonListConfig>();
const state = reactive<HistoryButtonListState>({restored: -1})
const higher_version = computed<number>(() => props.versions.reduce(
(accumulator: number, version: StoredObjectVersionWithPointInTime) => Math.max(accumulator, version.version),
-1
)
);
/**
* Executed when a version in child component is restored.
*
* internally, keep track of the newly restored version
*/
const onRestored = ({newVersion}: {newVersion: StoredObjectVersionWithPointInTime}) => {
state.restored = newVersion.version;
emit('restoreVersion', {newVersion});
}
</script>
<template>
<template v-if="props.versions.length > 0">
<div class="container">
<template v-for="v in props.versions">
<history-button-list-item
:version="v"
:can-edit="canEdit"
:is-current="higher_version === v.version"
:stored-object="storedObject"
@restore-version="onRestored"
></history-button-list-item>
</template>
</div>
</template>
<template v-else>
<p>Chargement des versions</p>
</template>
</template>
<style scoped lang="scss">
</style>

View File

@ -0,0 +1,115 @@
<script setup lang="ts">
import {StoredObject, StoredObjectPointInTime, StoredObjectVersionWithPointInTime} from "./../../../types";
import UserRenderBoxBadge from "ChillMainAssets/vuejs/_components/Entity/UserRenderBoxBadge.vue";
import {ISOToDatetime} from "./../../../../../../ChillMainBundle/Resources/public/chill/js/date";
import FileIcon from "ChillDocStoreAssets/vuejs/FileIcon.vue";
import RestoreVersionButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/HistoryButton/RestoreVersionButton.vue";
import DownloadButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DownloadButton.vue";
import {computed} from "vue";
interface HistoryButtonListItemConfig {
version: StoredObjectVersionWithPointInTime;
storedObject: StoredObject;
canEdit: boolean;
isCurrent: boolean;
}
const emit = defineEmits<{
restoreVersion: [newVersion: StoredObjectVersionWithPointInTime]
}>()
const props = defineProps<HistoryButtonListItemConfig>();
const onRestore = ({newVersion}: {newVersion: StoredObjectVersionWithPointInTime}) => {
emit('restoreVersion', {newVersion});
}
const isKeptBeforeConversion = computed<boolean>(() => props.version["point-in-times"].reduce(
(accumulator: boolean, pit: StoredObjectPointInTime) => accumulator || "keep-before-conversion" === pit.reason,
false
),
);
const isRestored = computed<boolean>(() => props.version.version > 0 && null !== props.version["from-restored"]);
const isDuplicated = computed<boolean>(() => props.version.version === 0 && null !== props.version["from-restored"]);
const classes = computed<{row: true, 'row-hover': true, 'blinking-1': boolean, 'blinking-2': boolean}>(() => ({row: true, 'row-hover': true, 'blinking-1': props.isRestored && 0 === props.version.version % 2, 'blinking-2': props.isRestored && 1 === props.version.version % 2}));
</script>
<template>
<div :class="classes">
<div class="col-12 tags" v-if="isCurrent || isKeptBeforeConversion || isRestored || isDuplicated">
<span class="badge bg-success" v-if="isCurrent">Version actuelle</span>
<span class="badge bg-info" v-if="isKeptBeforeConversion">Conservée avant conversion dans un autre format</span>
<span class="badge bg-info" v-if="isRestored">Restaurée depuis la version {{ version["from-restored"]?.version + 1 }}</span>
<span class="badge bg-info" v-if="isDuplicated">Dupliqué depuis un autre document</span>
</div>
<div class="col-12">
<file-icon :type="version.type"></file-icon> <span><strong>#{{ version.version + 1 }}</strong></span> <template v-if="version.createdBy !== null && version.createdAt !== null"><strong v-if="version.version == 0">Créé par</strong><strong v-else>modifié par</strong> <span class="badge-user"><UserRenderBoxBadge :user="version.createdBy"></UserRenderBoxBadge></span> <strong>à</strong> {{ $d(ISOToDatetime(version.createdAt.datetime8601), 'long') }}</template><template v-if="version.createdBy === null && version.createdAt !== null"><strong v-if="version.version == 0">Créé le</strong><strong v-else>modifié le</strong> {{ $d(ISOToDatetime(version.createdAt.datetime8601), 'long') }}</template>
</div>
<div class="col-12">
<ul class="record_actions small slim on-version-actions">
<li v-if="canEdit && !isCurrent">
<restore-version-button :stored-object-version="props.version" @restore-version="onRestore"></restore-version-button>
</li>
<li>
<download-button :stored-object="storedObject" :at-version="version" :classes="{btn: true, 'btn-outline-primary': true}" :display-action-string-in-button="false"></download-button>
</li>
</ul>
</div>
</div>
</template>
<style scoped lang="scss">
div.tags {
span.badge:not(:last-child) {
margin-right: 0.5rem;
}
}
// to make the animation restart, we have the same animation twice,
// and alternate between both
.blinking-1 {
animation-name: backgroundColorPalette-1;
animation-duration: 8s;
animation-iteration-count: 1;
animation-direction: normal;
animation-timing-function: linear;
}
@keyframes backgroundColorPalette-1 {
0% {
background: var(--bs-chill-green-dark);
}
25% {
background: var(--bs-chill-green);
}
65% {
background: var(--bs-chill-beige);
}
100% {
background: unset;
}
}
.blinking-2 {
animation-name: backgroundColorPalette-2;
animation-duration: 8s;
animation-iteration-count: 1;
animation-direction: normal;
animation-timing-function: linear;
}
@keyframes backgroundColorPalette-2 {
0% {
background: var(--bs-chill-green-dark);
}
25% {
background: var(--bs-chill-green);
}
65% {
background: var(--bs-chill-beige);
}
100% {
background: unset;
}
}
</style>

View File

@ -0,0 +1,47 @@
<script setup lang="ts">
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import {reactive} from "vue";
import HistoryButtonList from "ChillDocStoreAssets/vuejs/StoredObjectButton/HistoryButton/HistoryButtonList.vue";
import {StoredObject, StoredObjectVersionWithPointInTime} from "./../../../types";
interface HistoryButtonListConfig {
versions: StoredObjectVersionWithPointInTime[];
storedObject: StoredObject;
canEdit: boolean;
}
const emit = defineEmits<{
restoreVersion: [newVersion: StoredObjectVersionWithPointInTime]
}>()
interface HistoryButtonModalState {
opened: boolean;
}
const props = defineProps<HistoryButtonListConfig>();
const state = reactive<HistoryButtonModalState>({opened: false});
const open = () => {
state.opened = true;
}
defineExpose({open});
</script>
<template>
<Teleport to="body">
<modal v-if="state.opened" @close="state.opened = false">
<template v-slot:header>
<h3>Historique des versions du document</h3>
</template>
<template v-slot:body>
<p>Les versions sont conservées pendant 90 jours.</p>
<history-button-list :versions="props.versions" :can-edit="canEdit" :stored-object="storedObject" @restore-version="(payload) => emit('restoreVersion', payload)"></history-button-list>
</template>
</modal>
</Teleport>
</template>
<style scoped lang="scss">
</style>

View File

@ -0,0 +1,32 @@
<script setup lang="ts">
import {StoredObjectVersionPersisted, StoredObjectVersionWithPointInTime} from "../../../types";
import {useToast} from "vue-toast-notification";
import {restore_version} from "./api";
interface RestoreVersionButtonProps {
storedObjectVersion: StoredObjectVersionPersisted,
}
const emit = defineEmits<{
restoreVersion: [newVersion: StoredObjectVersionWithPointInTime]
}>()
const props = defineProps<RestoreVersionButtonProps>()
const $toast = useToast();
const restore_version_fn = async () => {
const newVersion = await restore_version(props.storedObjectVersion);
$toast.success("Version restaurée");
emit('restoreVersion', {newVersion});
}
</script>
<template>
<button class="btn btn-outline-action" @click="restore_version_fn" title="Restaurer"><i class="fa fa-rotate-left"></i> Restaurer</button>
</template>
<style scoped lang="scss">
</style>

View File

@ -0,0 +1,12 @@
import {StoredObject, StoredObjectVersionPersisted, StoredObjectVersionWithPointInTime} from "../../../types";
import {fetchResults, makeFetch} from "../../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods";
export const get_versions = async (storedObject: StoredObject): Promise<StoredObjectVersionWithPointInTime[]> => {
const versions = await fetchResults<StoredObjectVersionWithPointInTime>(`/api/1.0/doc-store/stored-object/${storedObject.uuid}/versions`);
return versions.sort((a: StoredObjectVersionWithPointInTime, b: StoredObjectVersionWithPointInTime) => b.version - a.version);
}
export const restore_version = async (version: StoredObjectVersionPersisted): Promise<StoredObjectVersionWithPointInTime> => {
return await makeFetch<null, StoredObjectVersionWithPointInTime>("POST", `/api/1.0/doc-store/stored-object/restore-from-version/${version.id}`);
}

View File

@ -8,7 +8,7 @@
<script lang="ts" setup>
import WopiEditButton from "./WopiEditButton.vue";
import {build_wopi_editor_link} from "./helpers";
import {StoredObject, StoredObjectCreated, WopiEditButtonExecutableBeforeLeaveFunction} from "../../types";
import {StoredObject, WopiEditButtonExecutableBeforeLeaveFunction} from "../../types";
interface WopiEditButtonConfig {
storedObject: StoredObject,
@ -22,7 +22,6 @@ const props = defineProps<WopiEditButtonConfig>();
let executed = false;
async function beforeLeave(event: Event): Promise<true> {
console.log(executed);
if (props.executeBeforeLeave === undefined || executed === true) {
return Promise.resolve(true);
}
@ -39,7 +38,7 @@ async function beforeLeave(event: Event): Promise<true> {
}
</script>
<style scoped lang="sass">
<style scoped lang="scss">
i.fa::before {
color: var(--bs-dropdown-link-hover-color);
}

View File

@ -1,4 +1,5 @@
import {StoredObject, StoredObjectStatus, StoredObjectStatusChange} from "../../types";
import {StoredObject, StoredObjectStatus, StoredObjectStatusChange, StoredObjectVersion} from "../../types";
import {makeFetch} from "../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods";
const MIMES_EDIT = new Set([
'application/vnd.ms-powerpoint',
@ -97,6 +98,13 @@ const MIMES_VIEW = new Set([
]
])
export interface SignedUrlGet {
method: 'GET'|'HEAD',
url: string,
expires: number,
object_name: string,
}
function is_extension_editable(mimeType: string): boolean {
return MIMES_EDIT.has(mimeType);
}
@ -109,8 +117,20 @@ function build_convert_link(uuid: string) {
return `/chill/wopi/convert/${uuid}`;
}
function build_download_info_link(object_name: string) {
return `/asyncupload/temp_url/generate/GET?object_name=${object_name}`;
function build_download_info_link(storedObject: StoredObject, atVersion: null|StoredObjectVersion): string {
const url = `/api/1.0/doc-store/async-upload/temp_url/${storedObject.uuid}/generate/get`;
if (null !== atVersion) {
const params = new URLSearchParams({version: atVersion.filename});
return url + '?' + params.toString();
}
return url;
}
async function download_info_link(storedObject: StoredObject, atVersion: null|StoredObjectVersion): Promise<SignedUrlGet> {
return makeFetch('GET', build_download_info_link(storedObject, atVersion));
}
function build_wopi_editor_link(uuid: string, returnPath?: string) {
@ -131,43 +151,46 @@ function download_doc(url: string): Promise<Blob> {
});
}
async function download_and_decrypt_doc(urlGenerator: string, keyData: JsonWebKey, iv: Uint8Array): Promise<Blob>
async function download_and_decrypt_doc(storedObject: StoredObject, atVersion: null|StoredObjectVersion): Promise<Blob>
{
const algo = 'AES-CBC';
// get an url to download the object
const downloadInfoResponse = await window.fetch(urlGenerator);
if (!downloadInfoResponse.ok) {
throw new Error("error while downloading url " + downloadInfoResponse.status + " " + downloadInfoResponse.statusText);
const atVersionToDownload = atVersion ?? storedObject.currentVersion;
if (null === atVersionToDownload) {
throw new Error("no version associated to stored object");
}
// sometimes, the downloadInfo may be embedded into the storedObject
console.log('storedObject', storedObject);
let downloadInfo;
if (typeof storedObject._links !== 'undefined' && typeof storedObject._links.downloadLink !== 'undefined') {
downloadInfo = storedObject._links.downloadLink;
} else {
downloadInfo = await download_info_link(storedObject, atVersionToDownload);
}
const downloadInfo = await downloadInfoResponse.json() as {url: string};
const rawResponse = await window.fetch(downloadInfo.url);
if (!rawResponse.ok) {
throw new Error("error while downloading raw file " + rawResponse.status + " " + rawResponse.statusText);
}
if (iv.length === 0) {
console.log('returning document immediatly');
if (atVersionToDownload.iv.length === 0) {
return rawResponse.blob();
}
console.log('start decrypting doc');
const rawBuffer = await rawResponse.arrayBuffer();
try {
const key = await window.crypto.subtle
.importKey('jwk', keyData, { name: algo }, false, ['decrypt']);
console.log('key created');
.importKey('jwk', atVersionToDownload.keyInfos, { name: algo }, false, ['decrypt']);
const iv = Uint8Array.from(atVersionToDownload.iv);
const decrypted = await window.crypto.subtle
.decrypt({ name: algo, iv: iv }, key, rawBuffer);
console.log('doc decrypted');
return Promise.resolve(new Blob([decrypted]));
} catch (e) {
console.error('get error while keys and decrypt operations');
console.error('encounter error while keys and decrypt operations');
console.error(e);
throw e;
@ -188,7 +211,6 @@ async function is_object_ready(storedObject: StoredObject): Promise<StoredObject
export {
build_convert_link,
build_download_info_link,
build_wopi_editor_link,
download_and_decrypt_doc,
download_doc,

View File

@ -1,174 +0,0 @@
<template>
<a :class="btnClasses" :title="$t(buttonTitle)" @click="openModal">
<span>{{ $t(buttonTitle) }}</span>
</a>
<teleport to="body">
<div>
<modal v-if="modal.showModal"
:modalDialogClass="modal.modalDialogClass"
@close="modal.showModal = false">
<template v-slot:header>
{{ $t('upload_a_document') }}
</template>
<template v-slot:body>
<div id="dropZoneWrapper" ref="dropZoneWrapper">
<div
data-stored-object="data-stored-object"
:data-label-preparing="$t('data_label_preparing')"
:data-label-quiet-button="$t('data_label_quiet_button')"
:data-label-ready="$t('data_label_ready')"
:data-dict-file-too-big="$t('data_dict_file_too_big')"
:data-dict-default-message="$t('data_dict_default_message')"
:data-dict-remove-file="$t('data_dict_remove_file')"
:data-dict-max-files-exceeded="$t('data_dict_max_files_exceeded')"
:data-dict-cancel-upload="$t('data_dict_cancel_upload')"
:data-dict-cancel-upload-confirm="$t('data_dict_cancel_upload_confirm')"
:data-dict-upload-canceled="$t('data_dict_upload_canceled')"
:data-dict-remove="$t('data_dict_remove')"
:data-allow-remove="!options.required"
data-temp-url-generator="/asyncupload/temp_url/generate/GET">
<input
type="hidden"
data-async-file-upload="data-async-file-upload"
data-generate-temp-url-post="/asyncupload/temp_url/generate/post?expires_delay=180&amp;submit_delay=3600"
data-temp-url-get="/asyncupload/temp_url/generate/GET"
:data-max-files="options.maxFiles"
:data-max-post-size="options.maxPostSize"
:v-model="dataAsyncFileUpload"
>
<input
type="hidden"
data-stored-object-key="1"
>
<input
type="hidden"
data-stored-object-iv="1"
>
<input
type="hidden"
data-async-file-type="1"
>
</div>
</div>
</template>
<template v-slot:footer>
<button class="btn btn-create"
@click.prevent="saveDocument">
{{ $t('action.add')}}
</button>
</template>
</modal>
</div>
</teleport>
</template>
<script>
import Modal from 'ChillMainAssets/vuejs/_components/Modal';
import { searchForZones } from '../../module/async_upload/uploader';
import { makeFetch } from "ChillMainAssets/lib/api/apiMethods";
const i18n = {
messages: {
fr: {
upload_a_document: "Téléversez un document",
data_label_preparing: "Chargement...",
data_label_quiet_button: "Téléchargez le fichier existant",
data_label_ready: "Prêt à montrer",
data_dict_file_too_big: "Fichier trop volumineux",
data_dict_default_message: "Glissez votre fichier ou cliquez ici",
data_dict_remove_file: "Enlevez votre fichier pour en téléversez un autre",
data_dict_max_files_exceeded: "Nombre maximum de fichiers atteint. Enlevez les fichiers précédents",
data_dict_cancel_upload: "Annulez le téléversement",
data_dict_cancel_upload_confirm: "Êtes-vous sûr·e de vouloir annuler ce téléversement?",
data_dict_upload_canceled: "Téléversement annulé",
data_dict_remove: "Enlevez le fichier existant",
}
}
};
export default {
name: "AddAsyncUpload",
components: {
Modal
},
i18n,
props: {
buttonTitle: {
type: String,
default: 'Ajouter un document',
},
options: {
type: Object,
default: {
maxFiles: 1,
maxPostSize: 262144000, // 250MB
required: false,
}
},
btnClasses: {
type: Object,
default: {
btn: true,
'btn-create': true
}
}
},
emits: ['addDocument'],
data() {
return {
modal: {
showModal: false,
modalDialogClass: "modal-dialog-centered modal-md"
},
}
},
updated() {
if (this.modal.showModal){
searchForZones(this.$refs.dropZoneWrapper);
}
},
methods: {
openModal() {
this.modal.showModal = true;
},
saveDocument() {
const dropzone = this.$refs.dropZoneWrapper;
if (dropzone) {
const inputKey = dropzone.querySelector('input[data-stored-object-key]');
const inputIv = dropzone.querySelector('input[data-stored-object-iv]');
const inputObject = dropzone.querySelector('input[data-async-file-upload]');
const inputType = dropzone.querySelector('input[data-async-file-type]');
const url = '/api/1.0/docstore/stored-object.json';
const body = {
filename: inputObject.value,
keyInfos: JSON.parse(inputKey.value),
iv: JSON.parse(inputIv.value),
type: inputType.value,
};
makeFetch('POST', url, body)
.then(r => {
this.$emit("addDocument", r);
this.modal.showModal = false;
})
.catch((error) => {
if (error.name === 'ValidationException') {
for (let v of error.violations) {
this.$toast.open({message: v });
}
} else {
console.error(error);
this.$toast.open({message: 'An error occurred'});
}
});
} else {
this.$toast.open({message: 'An error occurred - drop zone not found'});
}
}
}
}
</script>

View File

@ -1,45 +0,0 @@
<template>
<a
class="btn btn-download"
:title="$t(buttonTitle)"
:data-key=JSON.stringify(storedObject.keyInfos)
:data-iv=JSON.stringify(storedObject.iv)
:data-mime-type=storedObject.type
:data-label-preparing="$t('dataLabelPreparing')"
:data-label-ready="$t('dataLabelReady')"
:data-temp-url-get-generator="url"
@click.once="downloadDocument">
</a>
</template>
<script>
import { download } from '../../module/async_upload/downloader';
const i18n = {
messages: {
fr: {
dataLabelPreparing: "Chargement...",
dataLabelReady: "",
}
}
};
export default {
name: "AddAsyncUploadDownloader",
i18n,
props: [
'buttonTitle',
'storedObject'
],
computed: {
url() {
return `/asyncupload/temp_url/generate/GET?object_name=${this.storedObject.filename}`;
}
},
methods: {
downloadDocument(e) {
download(e.target);
}
}
}
</script>

View File

@ -38,6 +38,11 @@
{% if display_action is defined and display_action == true %}
<ul class="record_actions">
{% for dam in display_action_more|default([]) %}
<li>
{{ dam|raw }}
</li>
{% endfor %}
{% if document.course != null and is_granted('CHILL_PERSON_ACCOMPANYING_PERIOD_SEE', document.course) %}
<li>
<a href="{{ path('chill_person_accompanying_course_index', {'accompanying_period_id': document.course.id}) }}" class="btn btn-show change-icon">

View File

@ -0,0 +1 @@
<div data-download-button-single="data-download-button-single" data-stored-object="{{ document_json|json_encode|escape('html_attr') }}" data-title="{{ title|escape('html_attr') }}"></div>

View File

@ -71,15 +71,7 @@
</li>
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_SEE_DETAILS', document) %}
<li>
{{ document.object|chill_document_button_group(document.title, is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_UPDATE', document)) }}
</li>
<li>
<a href="{{ chill_path_add_return_path('accompanying_course_document_show', {'course': accompanyingCourse.id, 'id': document.id}) }}" class="btn btn-show"></a>
</li>
{% endif %}
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_UPDATE', document) %}
<li>
<a href="{{ path('accompanying_course_document_edit', {'course': accompanyingCourse.id, 'id': document.id }) }}" class="btn btn-update"></a>
{{ document.object|chill_document_button_group(document.title) }}
</li>
{% endif %}
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_DELETE', document) %}
@ -87,10 +79,25 @@
<a href="{{ chill_return_path_or('chill_docstore_accompanying_course_document_delete', {'course': accompanyingCourse.id, 'id': document.id}) }}" class="btn btn-delete"></a>
</li>
{% endif %}
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_CREATE', document.course) %}
<li>
<a href="{{ chill_path_add_return_path('chill_doc_store_accompanying_course_document_duplicate', {'id': document.id}) }}" class="btn btn-duplicate" title="{{ 'Duplicate'|trans|e('html_attr') }}"></a>
</li>
{% endif %}
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_UPDATE', document) %}
<li>
<a href="{{ path('accompanying_course_document_edit', {'course': accompanyingCourse.id, 'id': document.id }) }}" class="btn btn-update"></a>
</li>
{% endif %}
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_SEE_DETAILS', document) %}
<li>
<a href="{{ chill_path_add_return_path('accompanying_course_document_show', {'course': accompanyingCourse.id, 'id': document.id}) }}" class="btn btn-show"></a>
</li>
{% endif %}
{% else %}
{% if is_granted('CHILL_PERSON_DOCUMENT_SEE_DETAILS', document) %}
<li>
{{ document.object|chill_document_button_group(document.title, is_granted('CHILL_PERSON_DOCUMENT_UPDATE', document)) }}
{{ document.object|chill_document_button_group(document.title) }}
</li>
<li>
<a href="{{ path('person_document_show', {'person': person.id, 'id': document.id}) }}" class="btn btn-show"></a>

View File

@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="shortcut icon" href="{{ asset('build/images/favicon.ico') }}" type="image/x-icon">
<title>Signature</title>
{{ encore_entry_link_tags('mod_bootstrap') }}
{{ encore_entry_link_tags('mod_forkawesome') }}
{{ encore_entry_link_tags('chill') }}
{{ encore_entry_link_tags('vue_document_signature') }}
</head>
<body>
{% block js %}
{{ encore_entry_script_tags('mod_document_action_buttons_group') }}
<script type="text/javascript">
window.signature = {{ signature|json_encode|raw }};
</script>
{{ encore_entry_script_tags('vue_document_signature') }}
{% endblock %}
<div class="content" id="content">
<div class="container-xxl">
<div class="row">
<div class="col-xs-12 col-md-12 col-lg-9 my-5 m-auto">
<h4>{{ 'Document %title%' | trans({ '%title%': document.title }) }}</h4>
<div class="row" id="document-signature"></div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,43 @@
{% extends '@ChillMain/Workflow/workflow_view_send_public_layout.html.twig' %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_document_download_button') }}
{% endblock %}
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_document_download_button') }}
{% endblock %}
{% block title %}{{ 'workflow.public_link.title'|trans }} - {{ title }}{% endblock %}
{% block public_content %}
<h1>{{ 'workflow.public_link.shared_doc'|trans }}</h1>
{% set previous = send.entityWorkflowStepChained.previous %}
{% if previous is not null %}
{% if previous.transitionBy is not null %}
<p>{{ 'workflow.public_link.doc_shared_by_at_explanation'|trans({'byUser': previous.transitionBy|chill_entity_render_string( { 'at_date': previous.transitionAt } ), 'at': previous.transitionAt }) }}</p>
{% else %}
<p>{{ 'workflow.public_link.doc_shared_automatically_at_explanation'|trans({'at': previous.transitionAt}) }}</p>
{% endif %}
{% endif %}
<div class="row">
<div class="col-xs-12 col-sm-6 col-md-4">
<div class="card"">
<div class="card-body">
<h2 class="card-title">{{ title }}</h2>
<h3>{{ 'workflow.public_link.main_document'|trans }}</h3>
<ul class="record_actions slim small">
<li>
{{ storedObject|chill_document_download_only_button(storedObject.title(), false) }}
</li>
</ul>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -70,12 +70,12 @@ class AccompanyingCourseDocumentVoter extends AbstractChillVoter implements Prov
return [];
}
protected function supports($attribute, $subject): bool
public function supports($attribute, $subject): bool
{
return $this->voterHelper->supports($attribute, $subject);
}
protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
public function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
{
if (!$token->getUser() instanceof User) {
return false;

View File

@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Security\Authorization;
use Chill\DocStoreBundle\AsyncUpload\SignedUrl;
use Chill\DocStoreBundle\Repository\StoredObjectRepository;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\Security;
@ -22,6 +23,7 @@ final class AsyncUploadVoter extends Voter
public function __construct(
private readonly Security $security,
private readonly StoredObjectRepository $storedObjectRepository,
) {}
protected function supports($attribute, $subject): bool
@ -32,10 +34,16 @@ final class AsyncUploadVoter extends Voter
protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
{
/** @var SignedUrl $subject */
if (!in_array($subject->method, ['POST', 'GET', 'HEAD'], true)) {
if (!in_array($subject->method, ['POST', 'GET', 'HEAD', 'PUT'], true)) {
return false;
}
return $this->security->isGranted('ROLE_USER') || $this->security->isGranted('ROLE_ADMIN');
$storedObject = $this->storedObjectRepository->findOneBy(['filename' => $subject->object_name]);
return match ($subject->method) {
'GET', 'HEAD' => $this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject),
'PUT' => $this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $storedObject),
'POST' => $this->security->isGranted('ROLE_USER') || $this->security->isGranted('ROLE_ADMIN'),
};
}
}

View File

@ -12,9 +12,10 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Security\Authorization;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Guard\DavTokenAuthenticationEventSubscriber;
use Psr\Log\LoggerInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\Security;
/**
* Voter for the content of a stored object.
@ -23,6 +24,10 @@ use Symfony\Component\Security\Core\Authorization\Voter\Voter;
*/
class StoredObjectVoter extends Voter
{
public const LOG_PREFIX = '[stored object voter] ';
public function __construct(private readonly Security $security, private readonly iterable $storedObjectVoters, private readonly LoggerInterface $logger) {}
protected function supports($attribute, $subject): bool
{
return StoredObjectRoleEnum::tryFrom($attribute) instanceof StoredObjectRoleEnum
@ -32,24 +37,28 @@ class StoredObjectVoter extends Voter
protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
{
/** @var StoredObject $subject */
if (
!$token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)
|| $subject->getUuid()->toString() !== $token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)
) {
return false;
$attributeAsEnum = StoredObjectRoleEnum::from($attribute);
// Loop through context-specific voters
foreach ($this->storedObjectVoters as $storedObjectVoter) {
if ($storedObjectVoter->supports($attributeAsEnum, $subject)) {
$grant = $storedObjectVoter->voteOnAttribute($attributeAsEnum, $subject, $token);
if (false === $grant) {
$this->logger->debug(self::LOG_PREFIX.'deny access by storedObjectVoter', ['stored_object_voter' => $storedObjectVoter::class]);
}
if (!$token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)) {
return false;
return $grant;
}
}
$askedRole = StoredObjectRoleEnum::from($attribute);
$tokenRoleAuthorization =
$token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS);
// User role-based fallback
if ($this->security->isGranted('ROLE_USER') || $this->security->isGranted('ROLE_ADMIN')) {
// TODO: this maybe considered as a security issue, as all authenticated users can reach a stored object which
// is potentially detached from an existing entity.
return true;
}
return match ($askedRole) {
StoredObjectRoleEnum::SEE => StoredObjectRoleEnum::EDIT === $tokenRoleAuthorization || StoredObjectRoleEnum::SEE === $tokenRoleAuthorization,
StoredObjectRoleEnum::EDIT => StoredObjectRoleEnum::EDIT === $tokenRoleAuthorization,
};
return false;
}
}

View File

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoterInterface;
use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Security;
abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface
{
abstract protected function getRepository(): AssociatedEntityToStoredObjectInterface;
/**
* @return class-string
*/
abstract protected function getClass(): string;
abstract protected function attributeToRole(StoredObjectRoleEnum $attribute): string;
abstract protected function canBeAssociatedWithWorkflow(): bool;
public function __construct(
private readonly Security $security,
private readonly ?WorkflowStoredObjectPermissionHelper $workflowDocumentService = null,
) {}
public function supports(StoredObjectRoleEnum $attribute, StoredObject $subject): bool
{
$class = $this->getClass();
return $this->getRepository()->findAssociatedEntityToStoredObject($subject) instanceof $class;
}
public function voteOnAttribute(StoredObjectRoleEnum $attribute, StoredObject $subject, TokenInterface $token): bool
{
// Retrieve the related accompanying course document
$entity = $this->getRepository()->findAssociatedEntityToStoredObject($subject);
// Determine the attribute to pass to AccompanyingCourseDocumentVoter
$voterAttribute = $this->attributeToRole($attribute);
if (false === $this->security->isGranted($voterAttribute, $entity)) {
return false;
}
if (StoredObjectRoleEnum::SEE !== $attribute && $this->canBeAssociatedWithWorkflow()) {
if (null === $this->workflowDocumentService) {
throw new \LogicException('Provide a workflow document service');
}
return $this->workflowDocumentService->notBlockedByWorkflow($entity);
}
return true;
}
}

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter;
use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument;
use Chill\DocStoreBundle\Repository\AccompanyingCourseDocumentRepository;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper;
use Symfony\Component\Security\Core\Security;
final class AccompanyingCourseDocumentStoredObjectVoter extends AbstractStoredObjectVoter
{
public function __construct(
private readonly AccompanyingCourseDocumentRepository $repository,
Security $security,
WorkflowStoredObjectPermissionHelper $workflowDocumentService,
) {
parent::__construct($security, $workflowDocumentService);
}
protected function getRepository(): AssociatedEntityToStoredObjectInterface
{
return $this->repository;
}
protected function attributeToRole(StoredObjectRoleEnum $attribute): string
{
return match ($attribute) {
StoredObjectRoleEnum::EDIT => AccompanyingCourseDocumentVoter::UPDATE,
StoredObjectRoleEnum::SEE => AccompanyingCourseDocumentVoter::SEE_DETAILS,
};
}
protected function getClass(): string
{
return AccompanyingCourseDocument::class;
}
protected function canBeAssociatedWithWorkflow(): bool
{
return true;
}
}

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter;
use Chill\DocStoreBundle\Entity\PersonDocument;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\DocStoreBundle\Repository\PersonDocumentRepository;
use Chill\DocStoreBundle\Security\Authorization\PersonDocumentVoter;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper;
use Symfony\Component\Security\Core\Security;
class PersonDocumentStoredObjectVoter extends AbstractStoredObjectVoter
{
public function __construct(
private readonly PersonDocumentRepository $repository,
Security $security,
WorkflowStoredObjectPermissionHelper $workflowDocumentService,
) {
parent::__construct($security, $workflowDocumentService);
}
protected function getRepository(): AssociatedEntityToStoredObjectInterface
{
return $this->repository;
}
protected function getClass(): string
{
return PersonDocument::class;
}
protected function attributeToRole(StoredObjectRoleEnum $attribute): string
{
return match ($attribute) {
StoredObjectRoleEnum::EDIT => PersonDocumentVoter::UPDATE,
StoredObjectRoleEnum::SEE => PersonDocumentVoter::SEE_DETAILS,
};
}
protected function canBeAssociatedWithWorkflow(): bool
{
return true;
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Security\Authorization;
use Chill\DocStoreBundle\Entity\StoredObject;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
interface StoredObjectVoterInterface
{
public function supports(StoredObjectRoleEnum $attribute, StoredObject $subject): bool;
public function voteOnAttribute(StoredObjectRoleEnum $attribute, StoredObject $subject, TokenInterface $token): bool;
}

View File

@ -12,37 +12,75 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Serializer\Normalizer;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Repository\StoredObjectRepository;
use Chill\DocStoreBundle\Repository\StoredObjectRepositoryInterface;
use Symfony\Component\Form\Exception\TransformationFailedException;
use Symfony\Component\Serializer\Exception\LogicException;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\ObjectToPopulateTrait;
/**
* Implements the DenormalizerInterface and is responsible for denormalizing data into StoredObject objects.
*
* If a new StoredObjectVersion has been added to the StoredObject, the version is created here and registered
* to the StoredObject.
*/
class StoredObjectDenormalizer implements DenormalizerInterface
{
use ObjectToPopulateTrait;
public function __construct(private readonly StoredObjectRepository $storedObjectRepository) {}
public function __construct(private readonly StoredObjectRepositoryInterface $storedObjectRepository) {}
public function denormalize($data, $type, $format = null, array $context = [])
public function denormalize($data, $type, $format = null, array $context = []): ?StoredObject
{
$object = $this->extractObjectToPopulate(StoredObject::class, $context);
$storedObject = $this->extractObjectToPopulate(StoredObject::class, $context);
if (null !== $object) {
return $object;
if (null === $storedObject) {
if (array_key_exists('uuid', $data)) {
$storedObject = $this->storedObjectRepository->findOneByUUID($data['uuid']);
} else {
$storedObject = $this->storedObjectRepository->find($data['id']);
}
return $this->storedObjectRepository->find($data['id']);
if (null === $storedObject) {
throw new LogicException('Object not found');
}
}
public function supportsDenormalization($data, $type, $format = null)
$storedObject->setTitle($data['title'] ?? $storedObject->getTitle());
if (true === ($data['currentVersion']['persisted'] ?? true)) {
// nothing has change, stop here
return $storedObject;
}
if ([] !== $diff = array_diff(['filename', 'iv', 'keyInfos', 'type'], array_keys($data['currentVersion']))) {
throw new TransformationFailedException(sprintf('missing some keys in currentVersion: %s', implode(', ', $diff)));
}
$storedObject->registerVersion(
$data['currentVersion']['iv'],
$data['currentVersion']['keyInfos'],
$data['currentVersion']['type'],
$data['currentVersion']['filename']
);
return $storedObject;
}
public function supportsDenormalization($data, $type, $format = null): bool
{
if (false === \is_array($data)) {
if (StoredObject::class !== $type) {
return false;
}
if (false === \array_key_exists('id', $data)) {
if (false === is_array($data)) {
return false;
}
return StoredObject::class === $type;
if (array_key_exists('id', $data) || array_key_exists('uuid', $data)) {
return true;
}
return false;
}
}

View File

@ -11,10 +11,13 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Serializer\Normalizer;
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
@ -27,41 +30,69 @@ use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
final class StoredObjectNormalizer implements NormalizerInterface, NormalizerAwareInterface
{
use NormalizerAwareTrait;
public const ADD_DAV_SEE_LINK_CONTEXT = 'dav-see-link-context';
public const ADD_DAV_EDIT_LINK_CONTEXT = 'dav-edit-link-context';
/**
* when added to the groups, a download link is included in the normalization,
* and no webdav links are generated.
*/
public const DOWNLOAD_LINK_ONLY = 'read:download-link-only';
public function __construct(
private readonly JWTDavTokenProviderInterface $JWTDavTokenProvider,
private readonly UrlGeneratorInterface $urlGenerator,
private readonly Security $security,
private readonly TempUrlGeneratorInterface $tempUrlGenerator,
) {}
public function normalize($object, ?string $format = null, array $context = [])
{
/** @var StoredObject $object */
$datas = [
'datas' => $object->getDatas(),
'filename' => $object->getFilename(),
'id' => $object->getId(),
'iv' => $object->getIv(),
'keyInfos' => $object->getKeyInfos(),
'datas' => $object->getDatas(),
'prefix' => $object->getPrefix(),
'title' => $object->getTitle(),
'type' => $object->getType(),
'uuid' => $object->getUuid(),
'uuid' => $object->getUuid()->toString(),
'status' => $object->getStatus(),
'createdAt' => $this->normalizer->normalize($object->getCreatedAt(), $format, $context),
'createdBy' => $this->normalizer->normalize($object->getCreatedBy(), $format, $context),
'currentVersion' => $this->normalizer->normalize($object->getCurrentVersion(), $format, [...$context, [AbstractNormalizer::GROUPS => 'read']]),
'totalVersions' => $object->getVersions()->count(),
];
// deprecated property
$datas['creationDate'] = $datas['createdAt'];
$canDavSee = in_array(self::ADD_DAV_SEE_LINK_CONTEXT, $context['groups'] ?? [], true);
$canDavEdit = in_array(self::ADD_DAV_EDIT_LINK_CONTEXT, $context['groups'] ?? [], true);
if (array_key_exists(AbstractNormalizer::GROUPS, $context)) {
$groupsNormalized = is_array($context[AbstractNormalizer::GROUPS]) ? $context[AbstractNormalizer::GROUPS] : [$context[AbstractNormalizer::GROUPS]];
} else {
$groupsNormalized = [];
}
if ($canDavSee || $canDavEdit) {
if (in_array(self::DOWNLOAD_LINK_ONLY, $groupsNormalized, true)) {
$datas['_permissions'] = [
'canSee' => true,
'canEdit' => false,
];
$datas['_links'] = [
'downloadLink' => $this->normalizer->normalize($this->tempUrlGenerator->generate('GET', $object->getCurrentVersion()->getFilename(), 180), $format, [AbstractNormalizer::GROUPS => ['read']]),
];
return $datas;
}
$canSee = $this->security->isGranted(StoredObjectRoleEnum::SEE->value, $object);
$canEdit = $this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $object);
$datas['_permissions'] = [
'canEdit' => $canEdit,
'canSee' => $canSee,
];
if ($canSee || $canEdit) {
$accessToken = $this->JWTDavTokenProvider->createToken(
$object,
$canDavEdit ? StoredObjectRoleEnum::EDIT : StoredObjectRoleEnum::SEE
$canEdit ? StoredObjectRoleEnum::EDIT : StoredObjectRoleEnum::SEE
);
$datas['_links'] = [
@ -74,7 +105,7 @@ final class StoredObjectNormalizer implements NormalizerInterface, NormalizerAwa
],
UrlGeneratorInterface::ABSOLUTE_URL,
),
'expiration' => $this->JWTDavTokenProvider->getTokenExpiration($accessToken)->format('U'),
'expiration' => $this->JWTDavTokenProvider->getTokenExpiration($accessToken)->getTimestamp(),
],
];
}

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Serializer\Normalizer;
use Chill\DocStoreBundle\Entity\StoredObjectPointInTime;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
class StoredObjectPointInTimeNormalizer implements NormalizerInterface, NormalizerAwareInterface
{
use NormalizerAwareTrait;
public function normalize($object, ?string $format = null, array $context = [])
{
/* @var StoredObjectPointInTime $object */
return [
'id' => $object->getId(),
'reason' => $object->getReason()->value,
'byUser' => $this->normalizer->normalize($object->getByUser(), $format, [AbstractNormalizer::GROUPS => 'read']),
];
}
public function supportsNormalization($data, ?string $format = null)
{
return $data instanceof StoredObjectPointInTime;
}
}

View File

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Serializer\Normalizer;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Chill\MainBundle\Serializer\Normalizer\UserNormalizer;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
class StoredObjectVersionNormalizer implements NormalizerInterface, NormalizerAwareInterface
{
use NormalizerAwareTrait;
final public const WITH_POINT_IN_TIMES_CONTEXT = 'with-point-in-times';
final public const WITH_RESTORED_CONTEXT = 'with-restored';
public function normalize($object, ?string $format = null, array $context = [])
{
if (!$object instanceof StoredObjectVersion) {
throw new \InvalidArgumentException('The object must be an instance of '.StoredObjectVersion::class);
}
$data = [
'id' => $object->getId(),
'filename' => $object->getFilename(),
'version' => $object->getVersion(),
'iv' => array_values($object->getIv()),
'keyInfos' => $object->getKeyInfos(),
'type' => $object->getType(),
'createdAt' => $this->normalizer->normalize($object->getCreatedAt(), $format, $context),
'createdBy' => $this->normalizer->normalize($object->getCreatedBy(), $format, [...$context, UserNormalizer::AT_DATE => $object->getCreatedAt()]),
];
if (in_array(self::WITH_POINT_IN_TIMES_CONTEXT, $context[AbstractNormalizer::GROUPS] ?? [], true)) {
$data['point-in-times'] = $this->normalizer->normalize($object->getPointInTimes(), $format, $context);
}
if (in_array(self::WITH_RESTORED_CONTEXT, $context[AbstractNormalizer::GROUPS] ?? [], true)) {
$data['from-restored'] = $this->normalizer->normalize($object->getCreatedFrom(), $format, [AbstractNormalizer::GROUPS => ['read']]);
}
return $data;
}
public function supportsNormalization($data, ?string $format = null, array $context = [])
{
return $data instanceof StoredObjectVersion;
}
}

View 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\Service\Signature\Driver\BaseSigner;
/**
* Message which is received when a pdf is signed.
*/
final readonly class PdfSignedMessage
{
public function __construct(
public readonly int $signatureId,
public readonly ?int $signatureZoneIndex,
public readonly string $content,
) {}
}

View File

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Service\Signature\Driver\BaseSigner;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowStepSignatureRepository;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Chill\MainBundle\Workflow\SignatureStepStateChanger;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
final readonly class PdfSignedMessageHandler implements MessageHandlerInterface
{
/**
* log prefix.
*/
private const P = '[pdf signed message] ';
public function __construct(
private LoggerInterface $logger,
private EntityWorkflowManager $entityWorkflowManager,
private StoredObjectManagerInterface $storedObjectManager,
private EntityWorkflowStepSignatureRepository $entityWorkflowStepSignatureRepository,
private EntityManagerInterface $entityManager,
private SignatureStepStateChanger $signatureStepStateChanger,
) {}
public function __invoke(PdfSignedMessage $message): void
{
$this->logger->info(self::P.'a message is received', ['signaturedId' => $message->signatureId]);
$signature = $this->entityWorkflowStepSignatureRepository->find($message->signatureId);
if (null === $signature) {
throw new \RuntimeException('no signature found');
}
$storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($signature->getStep()->getEntityWorkflow());
if (null === $storedObject) {
throw new \RuntimeException('no stored object found');
}
$this->storedObjectManager->write($storedObject, $message->content);
$this->signatureStepStateChanger->markSignatureAsSigned($signature, $message->signatureZoneIndex);
$this->entityManager->flush();
$this->entityManager->clear();
}
}

View File

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Service\Signature\Driver\BaseSigner;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Exception\MessageDecodingFailedException;
use Symfony\Component\Messenger\Transport\Serialization\SerializerInterface;
/**
* Decode (and requeue) @see{PdfSignedMessage}, which comes from an external producer.
*/
final readonly class PdfSignedMessageSerializer implements SerializerInterface
{
public function decode(array $encodedEnvelope): Envelope
{
$body = $encodedEnvelope['body'];
try {
$decoded = json_decode((string) $body, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new MessageDecodingFailedException('Could not deserialize message', previous: $e);
}
if (!array_key_exists('signatureId', $decoded) || !array_key_exists('content', $decoded)) {
throw new MessageDecodingFailedException('Could not find expected keys: signatureId or content');
}
$content = base64_decode((string) $decoded['content'], true);
if (false === $content) {
throw new MessageDecodingFailedException('Invalid character found in the base64 encoded content');
}
$message = new PdfSignedMessage($decoded['signatureId'], $decoded['signatureZoneIndex'], $content);
return new Envelope($message);
}
public function encode(Envelope $envelope): array
{
$message = $envelope->getMessage();
if (!$message instanceof PdfSignedMessage) {
throw new MessageDecodingFailedException('Expected a PdfSignedMessage');
}
$data = [
'signatureId' => $message->signatureId,
'signatureZoneIndex' => $message->signatureZoneIndex,
'content' => base64_encode($message->content),
];
return [
'body' => json_encode($data, JSON_THROW_ON_ERROR),
'headers' => [],
];
}
}

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Service\Signature\Driver\BaseSigner;
use Chill\DocStoreBundle\Service\Signature\PDFSignatureZone;
/**
* Message which is sent when we request a signature on a pdf.
*/
final readonly class RequestPdfSignMessage
{
public function __construct(
public int $signatureId,
public PDFSignatureZone $PDFSignatureZone,
public ?int $signatureZoneIndex,
public string $reason,
public string $signerText,
public string $content,
) {}
}

View File

@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Service\Signature\Driver\BaseSigner;
use Chill\DocStoreBundle\Service\Signature\PDFSignatureZone;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Exception\MessageDecodingFailedException;
use Symfony\Component\Messenger\Stamp\NonSendableStampInterface;
use Symfony\Component\Messenger\Transport\Serialization\SerializerInterface;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
/**
* Serialize a RequestPdfSignMessage, for external consumer.
*/
final readonly class RequestPdfSignMessageSerializer implements SerializerInterface
{
public function __construct(
private NormalizerInterface $normalizer,
private DenormalizerInterface $denormalizer,
) {}
public function decode(array $encodedEnvelope): Envelope
{
$body = $encodedEnvelope['body'];
$headers = $encodedEnvelope['headers'];
if (RequestPdfSignMessage::class !== ($headers['Message'] ?? null)) {
throw new MessageDecodingFailedException('serializer does not support this message');
}
$data = json_decode((string) $body, true);
$zoneSignature = $this->denormalizer->denormalize($data['signatureZone'], PDFSignatureZone::class, 'json', [
AbstractNormalizer::GROUPS => ['write'],
]);
$content = base64_decode((string) $data['content'], true);
if (false === $content) {
throw new MessageDecodingFailedException('the content could not be converted from base64 encoding');
}
$message = new RequestPdfSignMessage(
$data['signatureId'],
$zoneSignature,
$data['signatureZoneIndex'],
$data['reason'],
$data['signerText'],
$content,
);
// in case of redelivery, unserialize any stamps
$stamps = [];
if (isset($headers['stamps'])) {
$stamps = unserialize($headers['stamps']);
}
return new Envelope($message, $stamps);
}
public function encode(Envelope $envelope): array
{
$message = $envelope->getMessage();
if (!$message instanceof RequestPdfSignMessage) {
throw new MessageDecodingFailedException('Message is not a RequestPdfSignMessage');
}
$data = [
'signatureId' => $message->signatureId,
'signatureZoneIndex' => $message->signatureZoneIndex,
'signatureZone' => $this->normalizer->normalize($message->PDFSignatureZone, 'json', [AbstractNormalizer::GROUPS => ['read']]),
'reason' => $message->reason,
'signerText' => $message->signerText,
'content' => base64_encode($message->content),
];
$allStamps = [];
foreach ($envelope->all() as $stamp) {
if ($stamp instanceof NonSendableStampInterface) {
continue;
}
$allStamps = [...$allStamps, ...$stamp];
}
return [
'body' => json_encode($data, JSON_THROW_ON_ERROR, 512),
'headers' => [
'stamps' => serialize($allStamps),
'Message' => RequestPdfSignMessage::class,
],
];
}
}

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Service\Signature;
use Symfony\Component\Serializer\Annotation\Groups;
final readonly class PDFPage
{
public function __construct(
#[Groups(['read'])]
public int $index,
#[Groups(['read'])]
public float $width,
#[Groups(['read'])]
public float $height,
) {}
public function equals(self $page): bool
{
return $page->index === $this->index
&& round($page->width, 2) === round($this->width, 2)
&& round($page->height, 2) === round($this->height, 2);
}
}

View File

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Service\Signature;
use Symfony\Component\Serializer\Annotation\Groups;
final readonly class PDFSignatureZone
{
public function __construct(
#[Groups(['read'])]
public ?int $index,
#[Groups(['read'])]
public float $x,
#[Groups(['read'])]
public float $y,
#[Groups(['read'])]
public float $height,
#[Groups(['read'])]
public float $width,
#[Groups(['read'])]
public PDFPage $PDFPage,
) {}
public function equals(self $other): bool
{
return
$this->index == $other->index
&& $this->x == $other->x
&& $this->y == $other->y
&& $this->height == $other->height
&& $this->width == $other->width
&& $this->PDFPage->equals($other->PDFPage);
}
}

View File

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Service\Signature;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
class PDFSignatureZoneAvailable
{
public function __construct(
private readonly EntityWorkflowManager $entityWorkflowManager,
private readonly PDFSignatureZoneParser $pdfSignatureZoneParser,
private readonly StoredObjectManagerInterface $storedObjectManager,
) {}
/**
* @return list<PDFSignatureZone>
*/
public function getAvailableSignatureZones(EntityWorkflow $entityWorkflow): array
{
$storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($entityWorkflow);
if (null === $storedObject) {
throw new \RuntimeException('No stored object found');
}
if ('application/pdf' !== $storedObject->getType()) {
throw new \RuntimeException('Only PDF documents are supported');
}
$zones = $this->pdfSignatureZoneParser->findSignatureZones($this->storedObjectManager->read($storedObject));
$signatureZonesIndexes = array_map(
fn (EntityWorkflowStepSignature $step) => $step->getZoneSignatureIndex(),
$this->collectSignaturesInUse($entityWorkflow)
);
return array_values(array_filter($zones, fn (PDFSignatureZone $zone) => !in_array($zone->index, $signatureZonesIndexes, true)));
}
/**
* @return list<EntityWorkflowStepSignature>
*/
private function collectSignaturesInUse(EntityWorkflow $entityWorkflow): array
{
return array_reduce($entityWorkflow->getSteps()->toArray(), function (array $result, EntityWorkflowStep $step) {
$current = [...$result];
foreach ($step->getSignatures() as $signature) {
if (EntityWorkflowSignatureStateEnum::SIGNED === $signature->getState()) {
$current[] = $signature;
}
}
return $current;
}, []);
}
}

View File

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Service\Signature;
use Smalot\PdfParser\Parser;
class PDFSignatureZoneParser
{
public const ZONE_SIGNATURE_START = 'signature_zone';
private readonly Parser $parser;
public function __construct(
public float $defaultHeight = 90.0,
public float $defaultWidth = 180.0,
) {
$this->parser = new Parser();
}
/**
* @return list<PDFSignatureZone>
*/
public function findSignatureZones(string $fileContent): array
{
$pdf = $this->parser->parseContent($fileContent);
$zones = [];
$defaults = $pdf->getObjectsByType('Pages');
$defaultPage = reset($defaults);
$defaultPageDetails = $defaultPage->getDetails();
$zoneIndex = 0;
foreach ($pdf->getPages() as $index => $page) {
$details = $page->getDetails();
$pdfPage = new PDFPage(
$index,
(float) ($details['MediaBox'][2] ?? $defaultPageDetails['MediaBox'][2]),
(float) ($details['MediaBox'][3] ?? $defaultPageDetails['MediaBox'][3]),
);
foreach ($page->getDataTm() as $dataTm) {
if (str_starts_with((string) $dataTm[1], self::ZONE_SIGNATURE_START)) {
$zones[] = new PDFSignatureZone($zoneIndex, (float) $dataTm[0][4], (float) $dataTm[0][5], $this->defaultHeight, $this->defaultWidth, $pdfPage);
++$zoneIndex;
}
}
}
return $zones;
}
}

View File

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Service\StoredObjectCleaner;
use Chill\DocStoreBundle\Repository\StoredObjectRepositoryInterface;
use Chill\MainBundle\Cron\CronJobInterface;
use Chill\MainBundle\Entity\CronJobExecution;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Messenger\MessageBusInterface;
/**
* Represents a cron job that removes expired stored objects.
*
* This cronjob is executed every 7days, to remove expired stored object. For every
* expired stored object, every version is sent to message bus for async deletion.
*/
final readonly class RemoveExpiredStoredObjectCronJob implements CronJobInterface
{
public const KEY = 'remove-expired-stored-object';
private const LAST_DELETED_KEY = 'last-deleted-stored-object-id';
public function __construct(
private ClockInterface $clock,
private MessageBusInterface $messageBus,
private StoredObjectRepositoryInterface $storedObjectRepository,
) {}
public function canRun(?CronJobExecution $cronJobExecution): bool
{
if (null === $cronJobExecution) {
return true;
}
return $this->clock->now() >= $cronJobExecution->getLastEnd()->add(new \DateInterval('P7D'));
}
public function getKey(): string
{
return self::KEY;
}
public function run(array $lastExecutionData): ?array
{
$lastDeleted = $lastExecutionData[self::LAST_DELETED_KEY] ?? 0;
foreach ($this->storedObjectRepository->findByExpired($this->clock->now()) as $storedObject) {
foreach ($storedObject->getVersions() as $version) {
$this->messageBus->dispatch(new RemoveOldVersionMessage($version->getId()));
}
$lastDeleted = max($lastDeleted, $storedObject->getId());
}
return [self::LAST_DELETED_KEY => $lastDeleted];
}
}

View File

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Service\StoredObjectCleaner;
use Chill\DocStoreBundle\Repository\StoredObjectVersionRepository;
use Chill\MainBundle\Cron\CronJobInterface;
use Chill\MainBundle\Entity\CronJobExecution;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Messenger\MessageBusInterface;
final readonly class RemoveOldVersionCronJob implements CronJobInterface
{
public const KEY = 'remove-old-stored-object-version';
private const LAST_DELETED_KEY = 'last-deleted-stored-object-version-id';
public const KEEP_INTERVAL = 'P90D';
public function __construct(
private ClockInterface $clock,
private MessageBusInterface $messageBus,
private StoredObjectVersionRepository $storedObjectVersionRepository,
) {}
public function canRun(?CronJobExecution $cronJobExecution): bool
{
if (null === $cronJobExecution) {
return true;
}
return $this->clock->now() >= $cronJobExecution->getLastEnd()->add(new \DateInterval('P1D'));
}
public function getKey(): string
{
return self::KEY;
}
public function run(array $lastExecutionData): ?array
{
$deleteBeforeDate = $this->clock->now()->sub(new \DateInterval(self::KEEP_INTERVAL));
$maxDeleted = $lastExecutionData[self::LAST_DELETED_KEY] ?? 0;
foreach ($this->storedObjectVersionRepository->findIdsByVersionsOlderThanDateAndNotLastVersionAndNotPointInTime($deleteBeforeDate) as $id) {
$this->messageBus->dispatch(new RemoveOldVersionMessage($id));
$maxDeleted = max($maxDeleted, $id);
}
return [self::LAST_DELETED_KEY => $maxDeleted];
}
}

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Service\StoredObjectCleaner;
final readonly class RemoveOldVersionMessage
{
public function __construct(
public int $storedObjectVersionId,
) {}
}

View File

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Service\StoredObjectCleaner;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
use Chill\DocStoreBundle\Repository\StoredObjectVersionRepository;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
/**
* Class RemoveOldVersionMessageHandler.
*
* This class is responsible for handling the RemoveOldVersionMessage. It implements the MessageHandlerInterface.
* It removes old versions of stored objects based on certain conditions.
*
* If a StoredObject is a candidate for deletion (is expired and no more version stored), it is also removed from the
* database.
*/
final readonly class RemoveOldVersionMessageHandler implements MessageHandlerInterface
{
private const LOG_PREFIX = '[RemoveOldVersionMessageHandler] ';
public function __construct(
private StoredObjectVersionRepository $storedObjectVersionRepository,
private LoggerInterface $logger,
private EntityManagerInterface $entityManager,
private StoredObjectManagerInterface $storedObjectManager,
private ClockInterface $clock,
) {}
/**
* @throws StoredObjectManagerException
*/
public function __invoke(RemoveOldVersionMessage $message): void
{
$this->logger->info(self::LOG_PREFIX.'Received one message', ['storedObjectVersionId' => $message->storedObjectVersionId]);
$storedObjectVersion = $this->storedObjectVersionRepository->find($message->storedObjectVersionId);
if (null === $storedObjectVersion) {
$this->logger->error(self::LOG_PREFIX.'StoredObjectVersion not found in database', ['storedObjectVersionId' => $message->storedObjectVersionId]);
throw new \RuntimeException('StoredObjectVersion not found with id '.$message->storedObjectVersionId);
}
if ($storedObjectVersion->hasPointInTimes()) {
throw new UnrecoverableMessageHandlingException('the stored object version is now associated with a point in time');
}
$storedObject = $storedObjectVersion->getStoredObject();
$this->storedObjectManager->delete($storedObjectVersion);
// to ensure an immediate deletion
$this->entityManager->remove($storedObjectVersion);
if (StoredObject::canBeDeleted($this->clock->now(), $storedObject)) {
$this->entityManager->remove($storedObject);
}
$this->entityManager->flush();
// clear the entity manager for future usage
$this->entityManager->clear();
}
}

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Service;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Psr\Log\LoggerInterface;
/**
* Class which duplicate a stored object into a new one, recreating a stored object.
*/
class StoredObjectDuplicate
{
public function __construct(private readonly StoredObjectManagerInterface $storedObjectManager, private readonly LoggerInterface $logger) {}
public function duplicate(StoredObject|StoredObjectVersion $from, bool $onlyLastKeptBeforeConversionVersion = true): StoredObject
{
$storedObject = $from instanceof StoredObjectVersion ? $from->getStoredObject() : $from;
$fromVersion = match ($storedObject->hasKeptBeforeConversionVersion() && $onlyLastKeptBeforeConversionVersion) {
true => $from->getLastKeptBeforeConversionVersion(),
false => $storedObject->getCurrentVersion(),
};
if (null === $fromVersion) {
throw new \UnexpectedValueException('could not find a version to restore');
}
$oldContent = $this->storedObjectManager->read($fromVersion);
$storedObject = new StoredObject();
$newVersion = $this->storedObjectManager->write($storedObject, $oldContent, $fromVersion->getType());
$newVersion->setCreatedFrom($fromVersion);
$this->logger->info('[StoredObjectDuplicate] Duplicated stored object from a version of a previous stored object', [
'from_stored_object_uuid' => $fromVersion->getStoredObject()->getUuid(),
'to_stored_object_uuid' => $storedObject->getUuid(),
'old_version_id' => $fromVersion->getId(),
'old_version_version' => $fromVersion->getVersion(),
'new_version_id' => $newVersion->getVersion(),
]);
return $storedObject;
}
}

View File

@ -14,6 +14,7 @@ namespace Chill\DocStoreBundle\Service;
use Base64Url\Base64Url;
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@ -32,10 +33,20 @@ final class StoredObjectManager implements StoredObjectManagerInterface
private readonly TempUrlGeneratorInterface $tempUrlGenerator,
) {}
public function getLastModified(StoredObject $document): \DateTimeInterface
public function getLastModified(StoredObject|StoredObjectVersion $document): \DateTimeInterface
{
if ($this->hasCache($document)) {
$response = $this->getResponseFromCache($document);
$version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;
if (null !== $createdAt = $version->getCreatedAt()) {
// as a createdAt datetime is set, return the date and time from database
return $createdAt;
}
// if no createdAt version exists in the database, we fetch the date and time from the
// file. This situation happens for files created before July 2024.
if ($this->hasCache($version)) {
$response = $this->getResponseFromCache($version);
} else {
try {
$response = $this
@ -46,7 +57,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface
->tempUrlGenerator
->generate(
Request::METHOD_HEAD,
$document->getFilename()
$version->getFilename()
)
->url
);
@ -58,11 +69,13 @@ final class StoredObjectManager implements StoredObjectManagerInterface
return $this->extractLastModifiedFromResponse($response);
}
public function getContentLength(StoredObject $document): int
public function getContentLength(StoredObject|StoredObjectVersion $document): int
{
if ([] === $document->getKeyInfos()) {
if ($this->hasCache($document)) {
$response = $this->getResponseFromCache($document);
$version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;
if (!$this->isVersionEncrypted($version)) {
if ($this->hasCache($version)) {
$response = $this->getResponseFromCache($version);
} else {
try {
$response = $this
@ -73,7 +86,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface
->tempUrlGenerator
->generate(
Request::METHOD_HEAD,
$document->getFilename()
$version->getFilename()
)
->url
);
@ -88,10 +101,43 @@ final class StoredObjectManager implements StoredObjectManagerInterface
return strlen($this->read($document));
}
public function etag(StoredObject $document): string
/**
* @throws TransportExceptionInterface
* @throws StoredObjectManagerException
*/
public function exists(StoredObject|StoredObjectVersion $document): bool
{
if ($this->hasCache($document)) {
$response = $this->getResponseFromCache($document);
$version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;
if ($this->hasCache($version)) {
return true;
}
try {
$response = $this
->client
->request(
Request::METHOD_HEAD,
$this
->tempUrlGenerator
->generate(
Request::METHOD_HEAD,
$version->getFilename()
)
->url
);
return 200 === $response->getStatusCode();
} catch (TransportExceptionInterface $exception) {
throw StoredObjectManagerException::errorDuringHttpRequest($exception);
}
}
public function etag(StoredObject|StoredObjectVersion $document): string
{
$version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;
if ($this->hasCache($version)) {
$response = $this->getResponseFromCache($version);
} else {
try {
$response = $this
@ -102,7 +148,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface
->tempUrlGenerator
->generate(
Request::METHOD_HEAD,
$document->getFilename()
$version->getFilename()
)
->url
);
@ -111,12 +157,14 @@ final class StoredObjectManager implements StoredObjectManagerInterface
}
}
return $this->extractEtagFromResponse($response, $document);
return $this->extractEtagFromResponse($response);
}
public function read(StoredObject $document): string
public function read(StoredObject|StoredObjectVersion $document, ?int $version = null): string
{
$response = $this->getResponseFromCache($document);
$version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;
$response = $this->getResponseFromCache($version);
try {
$data = $response->getContent();
@ -124,7 +172,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface
throw StoredObjectManagerException::unableToGetResponseContent($e);
}
if (false === $this->hasKeysAndIv($document)) {
if (!$this->isVersionEncrypted($version)) {
return $data;
}
@ -132,9 +180,9 @@ final class StoredObjectManager implements StoredObjectManagerInterface
$data,
self::ALGORITHM,
// TODO: Why using this library and not use base64_decode() ?
Base64Url::decode($document->getKeyInfos()['k']),
Base64Url::decode($version->getKeyInfos()['k']),
\OPENSSL_RAW_DATA,
pack('C*', ...$document->getIv())
pack('C*', ...$version->getIv())
);
if (false === $clearData) {
@ -144,20 +192,25 @@ final class StoredObjectManager implements StoredObjectManagerInterface
return $clearData;
}
public function write(StoredObject $document, string $clearContent): void
public function write(StoredObject $document, string $clearContent, ?string $contentType = null): StoredObjectVersion
{
if ($this->hasCache($document)) {
unset($this->inMemory[$document->getUuid()->toString()]);
}
$newIv = $document->getIv();
$newKey = $document->getKeyInfos();
$newType = $contentType ?? $document->getType();
$version = $document->registerVersion(
$newIv,
$newKey,
$newType
);
$encryptedContent = $this->hasKeysAndIv($document)
$encryptedContent = $this->isVersionEncrypted($version)
? openssl_encrypt(
$clearContent,
self::ALGORITHM,
// TODO: Why using this library and not use base64_decode() ?
Base64Url::decode($document->getKeyInfos()['k']),
Base64Url::decode($version->getKeyInfos()['k']),
\OPENSSL_RAW_DATA,
pack('C*', ...$document->getIv())
pack('C*', ...$version->getIv())
)
: $clearContent;
@ -176,7 +229,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface
->tempUrlGenerator
->generate(
Request::METHOD_PUT,
$document->getFilename()
$version->getFilename()
)
->url,
[
@ -191,6 +244,29 @@ final class StoredObjectManager implements StoredObjectManagerInterface
if (Response::HTTP_CREATED !== $response->getStatusCode()) {
throw StoredObjectManagerException::invalidStatusCode($response->getStatusCode());
}
$this->clearCache();
return $version;
}
/**
* @throws StoredObjectManagerException
*/
public function delete(StoredObjectVersion $storedObjectVersion): void
{
$signedUrl = $this->tempUrlGenerator->generate('DELETE', $storedObjectVersion->getFilename());
try {
$response = $this->client->request('DELETE', $signedUrl->url);
if (! (Response::HTTP_NO_CONTENT === $response->getStatusCode() || Response::HTTP_NOT_FOUND === $response->getStatusCode())) {
throw StoredObjectManagerException::invalidStatusCode($response->getStatusCode());
}
$storedObjectVersion->getStoredObject()->removeVersion($storedObjectVersion);
} catch (TransportExceptionInterface $exception) {
throw StoredObjectManagerException::errorDuringHttpRequest($exception);
}
}
public function clearCache(): void
@ -215,12 +291,19 @@ final class StoredObjectManager implements StoredObjectManagerInterface
return $date;
}
/**
* Extracts the content length from a ResponseInterface object.
*
* Does work only if the object is not encrypted.
*
* @return int the extracted content length as an integer
*/
private function extractContentLengthFromResponse(ResponseInterface $response): int
{
return (int) ($response->getHeaders()['content-length'] ?? ['0'])[0];
}
private function extractEtagFromResponse(ResponseInterface $response, StoredObject $storedObject): ?string
private function extractEtagFromResponse(ResponseInterface $response): ?string
{
$etag = ($response->getHeaders()['etag'] ?? [''])[0];
@ -231,7 +314,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface
return $etag;
}
private function fillCache(StoredObject $document): void
private function fillCache(StoredObjectVersion $document): void
{
try {
$response = $this
@ -254,25 +337,30 @@ final class StoredObjectManager implements StoredObjectManagerInterface
throw StoredObjectManagerException::invalidStatusCode($response->getStatusCode());
}
$this->inMemory[$document->getUuid()->toString()] = $response;
$this->inMemory[$this->buildCacheKey($document)] = $response;
}
private function getResponseFromCache(StoredObject $document): ResponseInterface
private function buildCacheKey(StoredObjectVersion $storedObjectVersion): string
{
return $storedObjectVersion->getStoredObject()->getUuid()->toString().$storedObjectVersion->getId();
}
private function getResponseFromCache(StoredObjectVersion $document): ResponseInterface
{
if (!$this->hasCache($document)) {
$this->fillCache($document);
}
return $this->inMemory[$document->getUuid()->toString()];
return $this->inMemory[$this->buildCacheKey($document)];
}
private function hasCache(StoredObject $document): bool
private function hasCache(StoredObjectVersion $document): bool
{
return \array_key_exists($document->getUuid()->toString(), $this->inMemory);
return \array_key_exists($this->buildCacheKey($document), $this->inMemory);
}
private function hasKeysAndIv(StoredObject $storedObject): bool
private function isVersionEncrypted(StoredObjectVersion $storedObjectVersion): bool
{
return ([] !== $storedObject->getKeyInfos()) && ([] !== $storedObject->getIv());
return ([] !== $storedObjectVersion->getKeyInfos()) && ([] !== $storedObjectVersion->getIv());
}
}

View File

@ -12,36 +12,74 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Service;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
interface StoredObjectManagerInterface
{
public function getLastModified(StoredObject $document): \DateTimeInterface;
/**
* @param StoredObject|StoredObjectVersion $document if a StoredObject is given, the last version will be used
*/
public function getLastModified(StoredObject|StoredObjectVersion $document): \DateTimeInterface;
public function getContentLength(StoredObject $document): int;
/**
* @param StoredObject|StoredObjectVersion $document if a StoredObject is given, the last version will be used
*/
public function getContentLength(StoredObject|StoredObjectVersion $document): int;
/**
* @throws TransportExceptionInterface
*/
public function exists(StoredObject|StoredObjectVersion $document): bool;
/**
* Get the content of a StoredObject.
*
* @param StoredObject $document the document
* @param StoredObject|StoredObjectVersion $document if a StoredObject is given, the last version will be used
*
* @return string the retrieved content in clear
*
* @throws StoredObjectManagerException if unable to read or decrypt the content
*/
public function read(StoredObject $document): string;
public function read(StoredObject|StoredObjectVersion $document): string;
/**
* Set the content of a StoredObject.
* Register the content of a new version for the StoredObject.
*
* The manager is also responsible for registering a version in the StoredObject, and return this version.
*
* @param StoredObject $document the document
* @param $clearContent The content to store in clear
* @param string $clearContent The content to store in clear
* @param string|null $contentType The new content type. If set to null, the content-type is supposed not to change. If there is no content type, an empty string will be used.
*
* @return StoredObjectVersion the newly created @see{StoredObjectVersion} for the given @see{StoredObject}
*
* @throws StoredObjectManagerException
*/
public function write(StoredObject $document, string $clearContent): void;
public function write(StoredObject $document, string $clearContent, ?string $contentType = null): StoredObjectVersion;
public function etag(StoredObject $document): string;
/**
* Remove a version from the storage.
*
* This method is also responsible for removing the version from the StoredObject (using @see{StoredObject::removeVersion})
* in case of success.
*
* @throws StoredObjectManagerException
*/
public function delete(StoredObjectVersion $storedObjectVersion): void;
/**
* return or compute the etag for the document.
*
* @param StoredObject|StoredObjectVersion $document if a StoredObject is given, the last version will be used
*
* @return string the etag of this document
*/
public function etag(StoredObject|StoredObjectVersion $document): string;
/**
* Clears the cache for the stored object.
*/
public function clearCache(): void;
}

Some files were not shown because too many files have changed in this diff Show More