mirror of
https://gitlab.com/Chill-Projet/chill-bundles.git
synced 2025-08-29 11:03:50 +00:00
Merge branch 'signature-app/object-version' into 'signature-app-master'
Add versioning to stored objects See merge request Chill-Projet/chill-bundles!710
This commit is contained in:
@@ -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,7 +85,9 @@ final readonly class TempUrlOpenstackGenerator implements TempUrlGeneratorInterf
|
||||
|
||||
$expires = $this->clock->now()->add(new \DateInterval('PT'.(string) $delay.'S'));
|
||||
|
||||
$object_name = $this->generateObjectName();
|
||||
if (null === $object_name) {
|
||||
$object_name = $this->generateObjectName();
|
||||
}
|
||||
|
||||
$g = new SignedUrlPost(
|
||||
$url = $this->generateUrl($object_name),
|
||||
@@ -141,7 +144,7 @@ final readonly class TempUrlOpenstackGenerator implements TempUrlGeneratorInterf
|
||||
{
|
||||
return match (str_ends_with($this->base_url, '/')) {
|
||||
true => $this->base_url.$relative_path,
|
||||
false => $this->base_url.'/'.$relative_path
|
||||
false => $this->base_url.'/'.$relative_path,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -179,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',
|
||||
|
@@ -16,7 +16,8 @@ interface TempUrlGeneratorInterface
|
||||
public function generatePost(
|
||||
?int $expire_delay = null,
|
||||
?int $submit_delay = null,
|
||||
int $max_file_count = 1
|
||||
int $max_file_count = 1,
|
||||
?string $object_name = null,
|
||||
): SignedUrlPost;
|
||||
|
||||
public function generate(string $method, string $object_name, ?int $expire_delay = null): SignedUrl;
|
||||
|
@@ -25,7 +25,7 @@ class AsyncUploadExtension extends AbstractExtension
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TempUrlGeneratorInterface $tempUrlGenerator,
|
||||
private readonly UrlGeneratorInterface $routingUrlGenerator
|
||||
private readonly UrlGeneratorInterface $routingUrlGenerator,
|
||||
) {}
|
||||
|
||||
public function getFilters()
|
||||
|
@@ -11,9 +11,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace Chill\DocStoreBundle\Controller;
|
||||
|
||||
use Chill\DocStoreBundle\AsyncUpload\Exception\TempUrlGeneratorException;
|
||||
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
|
||||
use Chill\DocStoreBundle\Security\Authorization\AsyncUploadVoter;
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
|
||||
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||
use Chill\MainBundle\Entity\User;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -30,62 +32,84 @@ final readonly class AsyncUploadController
|
||||
private TempUrlGeneratorInterface $tempUrlGenerator,
|
||||
private SerializerInterface $serializer,
|
||||
private Security $security,
|
||||
private LoggerInterface $logger,
|
||||
private LoggerInterface $chillLogger,
|
||||
) {}
|
||||
|
||||
#[Route(path: '/asyncupload/temp_url/generate/{method}', name: 'async_upload.generate_url')]
|
||||
public function getSignedUrl(string $method, Request $request): JsonResponse
|
||||
#[Route(path: '/api/1.0/doc-store/async-upload/temp_url/{uuid}/generate/post', name: 'chill_docstore_asyncupload_getsignedurlpost')]
|
||||
public function getSignedUrlPost(Request $request, StoredObject $storedObject): JsonResponse
|
||||
{
|
||||
try {
|
||||
switch (strtolower($method)) {
|
||||
case 'post':
|
||||
$p = $this->tempUrlGenerator
|
||||
->generatePost(
|
||||
$request->query->has('expires_delay') ? $request->query->getInt('expires_delay') : null,
|
||||
$request->query->has('submit_delay') ? $request->query->getInt('submit_delay') : null
|
||||
)
|
||||
;
|
||||
break;
|
||||
case 'get':
|
||||
case 'head':
|
||||
$object_name = $request->query->get('object_name', null);
|
||||
if (!$this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $storedObject)) {
|
||||
throw new AccessDeniedHttpException('not able to edit the given stored object');
|
||||
}
|
||||
|
||||
if (null === $object_name) {
|
||||
return (new JsonResponse((object) [
|
||||
'message' => 'the object_name is null',
|
||||
]))
|
||||
->setStatusCode(JsonResponse::HTTP_BAD_REQUEST);
|
||||
}
|
||||
$p = $this->tempUrlGenerator->generate(
|
||||
$method,
|
||||
$object_name,
|
||||
$request->query->has('expires_delay') ? $request->query->getInt('expires_delay') : null
|
||||
);
|
||||
break;
|
||||
default:
|
||||
return (new JsonResponse((object) ['message' => 'the method '
|
||||
."{$method} is not valid"]))
|
||||
->setStatusCode(JsonResponse::HTTP_BAD_REQUEST);
|
||||
// we create a dummy version, to generate a filename
|
||||
$version = $storedObject->registerVersion();
|
||||
|
||||
$p = $this->tempUrlGenerator
|
||||
->generatePost(
|
||||
$request->query->has('expires_delay') ? $request->query->getInt('expires_delay') : null,
|
||||
$request->query->has('submit_delay') ? $request->query->getInt('submit_delay') : null,
|
||||
object_name: $version->getFilename()
|
||||
);
|
||||
|
||||
$this->chillLogger->notice('[Privacy Event] a request to upload a document has been generated', [
|
||||
'doc_uuid' => $storedObject->getUuid(),
|
||||
]);
|
||||
|
||||
return new JsonResponse(
|
||||
$this->serializer->serialize($p, 'json', [AbstractNormalizer::GROUPS => ['read']]),
|
||||
Response::HTTP_OK,
|
||||
[],
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[Route(path: '/api/1.0/doc-store/async-upload/temp_url/{uuid}/generate/{method}', name: 'chill_docstore_asyncupload_getsignedurlget', requirements: ['method' => 'get|head'])]
|
||||
public function getSignedUrlGet(Request $request, StoredObject $storedObject, string $method): JsonResponse
|
||||
{
|
||||
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
|
||||
throw new AccessDeniedHttpException('not able to read the given stored object');
|
||||
}
|
||||
|
||||
// we really want to be sure that there are no other method than get or head:
|
||||
if (!in_array($method, ['get', 'head'], true)) {
|
||||
throw new AccessDeniedHttpException('Only methods get and head are allowed');
|
||||
}
|
||||
|
||||
if ($request->query->has('version')) {
|
||||
$filename = $request->query->get('version');
|
||||
|
||||
$storedObjectVersion = $storedObject->getVersions()->findFirst(fn (int $index, StoredObjectVersion $version): bool => $version->getFilename() === $filename);
|
||||
|
||||
if (null === $storedObjectVersion) {
|
||||
// we are here in the case where the version is not stored into the database
|
||||
// as the version is prefixed by the stored object prefix, we just have to check that this prefix
|
||||
// is the same. It means that the user had previously the permission to "SEE_AND_EDIT" this stored
|
||||
// object with same prefix that we checked before
|
||||
if (!str_starts_with($filename, $storedObject->getPrefix())) {
|
||||
throw new AccessDeniedHttpException('not able to match the version with the same filename');
|
||||
}
|
||||
}
|
||||
} catch (TempUrlGeneratorException $e) {
|
||||
$this->logger->warning('The client requested a temp url'
|
||||
.' which sparkle an error.', [
|
||||
'message' => $e->getMessage(),
|
||||
'expire_delay' => $request->query->getInt('expire_delay', 0),
|
||||
'file_count' => $request->query->getInt('file_count', 1),
|
||||
'method' => $method,
|
||||
]);
|
||||
|
||||
$p = new \stdClass();
|
||||
$p->message = $e->getMessage();
|
||||
$p->status = JsonResponse::HTTP_BAD_REQUEST;
|
||||
|
||||
return new JsonResponse($p, JsonResponse::HTTP_BAD_REQUEST);
|
||||
} else {
|
||||
$filename = $storedObject->getCurrentVersion()->getFilename();
|
||||
}
|
||||
|
||||
if (!$this->security->isGranted(AsyncUploadVoter::GENERATE_SIGNATURE, $p)) {
|
||||
throw new AccessDeniedHttpException('not allowed to generate this signature');
|
||||
}
|
||||
$p = $this->tempUrlGenerator->generate(
|
||||
$method,
|
||||
$filename,
|
||||
$request->query->has('expires_delay') ? $request->query->getInt('expires_delay') : null
|
||||
);
|
||||
|
||||
$user = $this->security->getUser();
|
||||
$userId = match ($user instanceof User) {
|
||||
true => $user->getId(),
|
||||
false => $user->getUserIdentifier(),
|
||||
};
|
||||
|
||||
$this->chillLogger->notice('[Privacy Event] a request to see a document has been granted', [
|
||||
'doc_uuid' => $storedObject->getUuid()->toString(),
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
|
||||
return new JsonResponse(
|
||||
$this->serializer->serialize($p, 'json', [AbstractNormalizer::GROUPS => ['read']]),
|
||||
|
@@ -35,7 +35,7 @@ class DocumentAccompanyingCourseController extends AbstractController
|
||||
protected TranslatorInterface $translator,
|
||||
protected EventDispatcherInterface $eventDispatcher,
|
||||
protected AuthorizationHelper $authorizationHelper,
|
||||
private readonly \Doctrine\Persistence\ManagerRegistry $managerRegistry
|
||||
private readonly \Doctrine\Persistence\ManagerRegistry $managerRegistry,
|
||||
) {}
|
||||
|
||||
#[Route(path: '/{id}/delete', name: 'chill_docstore_accompanying_course_document_delete')]
|
||||
|
@@ -44,7 +44,7 @@ class DocumentPersonController extends AbstractController
|
||||
protected AuthorizationHelper $authorizationHelper,
|
||||
protected PDFSignatureZoneParser $PDFSignatureZoneParser,
|
||||
protected StoredObjectManagerInterface $storedObjectManagerInterface,
|
||||
private readonly \Doctrine\Persistence\ManagerRegistry $managerRegistry
|
||||
private readonly \Doctrine\Persistence\ManagerRegistry $managerRegistry,
|
||||
) {}
|
||||
|
||||
#[Route(path: '/{id}/delete', name: 'chill_docstore_person_document_delete')]
|
||||
|
@@ -11,6 +11,46 @@ declare(strict_types=1);
|
||||
|
||||
namespace Chill\DocStoreBundle\Controller;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\MainBundle\CRUD\Controller\ApiController;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
|
||||
class StoredObjectApiController extends ApiController {}
|
||||
class StoredObjectApiController extends ApiController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Security $security,
|
||||
private readonly SerializerInterface $serializer,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Creates a new stored object.
|
||||
*
|
||||
* @return JsonResponse the response containing the serialized object in JSON format
|
||||
*
|
||||
* @throws AccessDeniedHttpException if the user does not have the necessary role to create a stored object
|
||||
*/
|
||||
#[Route('/api/1.0/doc-store/stored-object/create', methods: ['POST'])]
|
||||
public function createStoredObject(): JsonResponse
|
||||
{
|
||||
if (!($this->security->isGranted('ROLE_ADMIN') || $this->security->isGranted('ROLE_USER'))) {
|
||||
throw new AccessDeniedHttpException('Must be user or admin to create a stored object');
|
||||
}
|
||||
|
||||
$object = new StoredObject();
|
||||
|
||||
$this->entityManager->persist($object);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return new JsonResponse(
|
||||
$this->serializer->serialize($object, 'json', [AbstractNormalizer::GROUPS => ['read']]),
|
||||
json: true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -16,6 +16,7 @@ use Chill\DocStoreBundle\Dav\Response\DavResponse;
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
@@ -42,6 +43,7 @@ final readonly class WebdavController
|
||||
private \Twig\Environment $engine,
|
||||
private StoredObjectManagerInterface $storedObjectManager,
|
||||
private Security $security,
|
||||
private EntityManagerInterface $entityManager,
|
||||
) {
|
||||
$this->requestAnalyzer = new PropfindRequestAnalyzer();
|
||||
}
|
||||
@@ -201,6 +203,8 @@ final readonly class WebdavController
|
||||
|
||||
$this->storedObjectManager->write($storedObject, $request->getContent());
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return new DavResponse('', Response::HTTP_NO_CONTENT);
|
||||
}
|
||||
|
||||
|
@@ -11,7 +11,6 @@ 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;
|
||||
@@ -19,7 +18,6 @@ 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;
|
||||
|
||||
/**
|
||||
@@ -53,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)
|
||||
|
@@ -42,7 +42,7 @@ class DocumentCategory
|
||||
*/
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, name: 'id_inside_bundle')]
|
||||
private $idInsideBundle
|
||||
private $idInsideBundle,
|
||||
) {}
|
||||
|
||||
public function getBundleId() // ::class BundleClass (FQDN)
|
||||
|
@@ -16,10 +16,14 @@ use ChampsLibres\WopiLib\Contract\Entity\Document;
|
||||
use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate;
|
||||
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
|
||||
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
use Random\RandomException;
|
||||
use Symfony\Component\Serializer\Annotation as Serializer;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* Represent a document stored in an object store.
|
||||
@@ -28,13 +32,16 @@ use Symfony\Component\Serializer\Annotation as Serializer;
|
||||
*
|
||||
* The property `$deleteAt` allow a deletion of the document after the given date. But this property should
|
||||
* be set before the document is actually written by the StoredObjectManager.
|
||||
*
|
||||
* Each version is stored within a @see{StoredObjectVersion}, associated with this current's object. The creation
|
||||
* of each new version should be done using the method @see{self::registerVersion}.
|
||||
*/
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table('chill_doc.stored_object')]
|
||||
#[AsyncFileExists(message: 'The file is not stored properly')]
|
||||
#[ORM\Table('stored_object', schema: 'chill_doc')]
|
||||
class StoredObject implements Document, TrackCreationInterface
|
||||
{
|
||||
use TrackCreationTrait;
|
||||
final public const STATUS_EMPTY = 'empty';
|
||||
final public const STATUS_READY = 'ready';
|
||||
final public const STATUS_PENDING = 'pending';
|
||||
final public const STATUS_FAILURE = 'failure';
|
||||
@@ -43,9 +50,11 @@ class StoredObject implements Document, TrackCreationInterface
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'datas')]
|
||||
private array $datas = [];
|
||||
|
||||
#[Serializer\Groups(['write'])]
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT)]
|
||||
private string $filename = '';
|
||||
/**
|
||||
* the prefix of each version.
|
||||
*/
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])]
|
||||
private string $prefix = '';
|
||||
|
||||
#[Serializer\Groups(['write'])]
|
||||
#[ORM\Id]
|
||||
@@ -53,25 +62,10 @@ class StoredObject implements Document, TrackCreationInterface
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)]
|
||||
private ?int $id = null;
|
||||
|
||||
/**
|
||||
* @var int[]
|
||||
*/
|
||||
#[Serializer\Groups(['write'])]
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'iv')]
|
||||
private array $iv = [];
|
||||
|
||||
#[Serializer\Groups(['write'])]
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'key')]
|
||||
private array $keyInfos = [];
|
||||
|
||||
#[Serializer\Groups(['write'])]
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, name: 'title')]
|
||||
#[ORM\Column(name: 'title', type: \Doctrine\DBAL\Types\Types::TEXT, options: ['default' => ''])]
|
||||
private string $title = '';
|
||||
|
||||
#[Serializer\Groups(['write'])]
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, name: 'type', options: ['default' => ''])]
|
||||
private string $type = '';
|
||||
|
||||
#[Serializer\Groups(['write'])]
|
||||
#[ORM\Column(type: 'uuid', unique: true)]
|
||||
private UuidInterface $uuid;
|
||||
@@ -94,14 +88,22 @@ class StoredObject implements Document, TrackCreationInterface
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])]
|
||||
private string $generationErrors = '';
|
||||
|
||||
/**
|
||||
* @var Collection<int, StoredObjectVersion>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'storedObject', targetEntity: StoredObjectVersion::class, cascade: ['persist'], orphanRemoval: true)]
|
||||
private Collection $versions;
|
||||
|
||||
/**
|
||||
* @param StoredObject::STATUS_* $status
|
||||
*/
|
||||
public function __construct(
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, options: ['default' => 'ready'])]
|
||||
private string $status = 'ready'
|
||||
private string $status = 'empty',
|
||||
) {
|
||||
$this->uuid = Uuid::uuid4();
|
||||
$this->versions = new ArrayCollection();
|
||||
$this->prefix = self::generatePrefix();
|
||||
}
|
||||
|
||||
public function addGenerationTrial(): self
|
||||
@@ -125,14 +127,34 @@ class StoredObject implements Document, TrackCreationInterface
|
||||
return \DateTime::createFromImmutable($this->createdAt);
|
||||
}
|
||||
|
||||
#[AsyncFileExists(message: 'The file is not stored properly')]
|
||||
#[Assert\NotNull(message: 'The store object version must be present')]
|
||||
public function getCurrentVersion(): ?StoredObjectVersion
|
||||
{
|
||||
$maxVersion = null;
|
||||
|
||||
foreach ($this->versions as $v) {
|
||||
if ($v->getVersion() > ($maxVersion?->getVersion() ?? -1)) {
|
||||
$maxVersion = $v;
|
||||
}
|
||||
}
|
||||
|
||||
return $maxVersion;
|
||||
}
|
||||
|
||||
public function getDatas(): array
|
||||
{
|
||||
return $this->datas;
|
||||
}
|
||||
|
||||
public function getPrefix(): string
|
||||
{
|
||||
return $this->prefix;
|
||||
}
|
||||
|
||||
public function getFilename(): string
|
||||
{
|
||||
return $this->filename;
|
||||
return $this->getCurrentVersion()?->getFilename() ?? '';
|
||||
}
|
||||
|
||||
public function getGenerationTrialsCounter(): int
|
||||
@@ -145,14 +167,17 @@ class StoredObject implements Document, TrackCreationInterface
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
public function getIv(): array
|
||||
{
|
||||
return $this->iv;
|
||||
return $this->getCurrentVersion()?->getIv() ?? [];
|
||||
}
|
||||
|
||||
public function getKeyInfos(): array
|
||||
{
|
||||
return $this->keyInfos;
|
||||
return $this->getCurrentVersion()?->getKeyInfos() ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -171,14 +196,14 @@ class StoredObject implements Document, TrackCreationInterface
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function getTitle()
|
||||
public function getTitle(): string
|
||||
{
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
public function getType()
|
||||
public function getType(): string
|
||||
{
|
||||
return $this->type;
|
||||
return $this->getCurrentVersion()?->getType() ?? '';
|
||||
}
|
||||
|
||||
public function getUuid(): UuidInterface
|
||||
@@ -209,27 +234,6 @@ class StoredObject implements Document, TrackCreationInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setFilename(?string $filename): self
|
||||
{
|
||||
$this->filename = (string) $filename;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setIv(?array $iv): self
|
||||
{
|
||||
$this->iv = (array) $iv;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setKeyInfos(?array $keyInfos): self
|
||||
{
|
||||
$this->keyInfos = (array) $keyInfos;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param StoredObject::STATUS_* $status
|
||||
*/
|
||||
@@ -247,18 +251,16 @@ class StoredObject implements Document, TrackCreationInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setType(?string $type): self
|
||||
{
|
||||
$this->type = (string) $type;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTemplate(): ?DocGeneratorTemplate
|
||||
{
|
||||
return $this->template;
|
||||
}
|
||||
|
||||
public function getVersions(): Collection
|
||||
{
|
||||
return $this->versions;
|
||||
}
|
||||
|
||||
public function hasTemplate(): bool
|
||||
{
|
||||
return null !== $this->template;
|
||||
@@ -314,18 +316,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();
|
||||
}
|
||||
}
|
||||
|
127
src/Bundle/ChillDocStoreBundle/Entity/StoredObjectVersion.php
Normal file
127
src/Bundle/ChillDocStoreBundle/Entity/StoredObjectVersion.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?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\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 = '';
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
@@ -27,7 +27,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
final class AccompanyingCourseDocumentType extends AbstractType
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TranslatableStringHelperInterface $translatableStringHelper
|
||||
private readonly TranslatableStringHelperInterface $translatableStringHelper,
|
||||
) {}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options)
|
||||
|
@@ -55,16 +55,8 @@ class StoredObjectDataMapper implements DataMapperInterface
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var StoredObject $viewData */
|
||||
if ($viewData->getFilename() !== $forms['stored_object']->getData()['filename']) {
|
||||
// we want to keep the previous history
|
||||
$viewData->saveHistory();
|
||||
}
|
||||
|
||||
$viewData->setFilename($forms['stored_object']->getData()['filename']);
|
||||
$viewData->setIv($forms['stored_object']->getData()['iv']);
|
||||
$viewData->setKeyInfos($forms['stored_object']->getData()['keyInfos']);
|
||||
$viewData->setType($forms['stored_object']->getData()['type']);
|
||||
/* @var StoredObject $viewData */
|
||||
$viewData = $forms['stored_object']->getData();
|
||||
|
||||
if (array_key_exists('title', $forms)) {
|
||||
$viewData->setTitle($forms['title']->getData());
|
||||
|
@@ -12,7 +12,6 @@ declare(strict_types=1);
|
||||
namespace Chill\DocStoreBundle\Form\DataTransformer;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectNormalizer;
|
||||
use Symfony\Component\Form\DataTransformerInterface;
|
||||
use Symfony\Component\Form\Exception\UnexpectedTypeException;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
@@ -20,7 +19,7 @@ use Symfony\Component\Serializer\SerializerInterface;
|
||||
class StoredObjectDataTransformer implements DataTransformerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SerializerInterface $serializer
|
||||
private readonly SerializerInterface $serializer,
|
||||
) {}
|
||||
|
||||
public function transform(mixed $value): mixed
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
@@ -55,7 +55,7 @@ class AsyncUploaderType extends AbstractType
|
||||
public function buildView(
|
||||
FormView $view,
|
||||
FormInterface $form,
|
||||
array $options
|
||||
array $options,
|
||||
) {
|
||||
$view->vars['attr']['data-async-file-upload'] = true;
|
||||
$view->vars['attr']['data-generate-temp-url-post'] = $this
|
||||
|
@@ -20,7 +20,7 @@ interface GenericDocForAccompanyingPeriodProviderInterface
|
||||
?\DateTimeImmutable $startDate = null,
|
||||
?\DateTimeImmutable $endDate = null,
|
||||
?string $content = null,
|
||||
?string $origin = null
|
||||
?string $origin = null,
|
||||
): FetchQueryInterface;
|
||||
|
||||
/**
|
||||
|
@@ -20,7 +20,7 @@ interface GenericDocForPersonProviderInterface
|
||||
?\DateTimeImmutable $startDate = null,
|
||||
?\DateTimeImmutable $endDate = null,
|
||||
?string $content = null,
|
||||
?string $origin = null
|
||||
?string $origin = null,
|
||||
): FetchQueryInterface;
|
||||
|
||||
/**
|
||||
|
@@ -46,7 +46,7 @@ final readonly class Manager
|
||||
?\DateTimeImmutable $startDate = null,
|
||||
?\DateTimeImmutable $endDate = null,
|
||||
?string $content = null,
|
||||
array $places = []
|
||||
array $places = [],
|
||||
): int {
|
||||
['sql' => $sql, 'params' => $params, 'types' => $types] = $this->buildUnionQuery($accompanyingPeriod, $startDate, $endDate, $content, $places);
|
||||
|
||||
@@ -76,7 +76,7 @@ final readonly class Manager
|
||||
?\DateTimeImmutable $startDate = null,
|
||||
?\DateTimeImmutable $endDate = null,
|
||||
?string $content = null,
|
||||
array $places = []
|
||||
array $places = [],
|
||||
): int {
|
||||
['sql' => $sql, 'params' => $params, 'types' => $types] = $this->buildUnionQuery($person, $startDate, $endDate, $content, $places);
|
||||
|
||||
@@ -97,7 +97,7 @@ final readonly class Manager
|
||||
?\DateTimeImmutable $startDate = null,
|
||||
?\DateTimeImmutable $endDate = null,
|
||||
?string $content = null,
|
||||
array $places = []
|
||||
array $places = [],
|
||||
): iterable {
|
||||
['sql' => $sql, 'params' => $params, 'types' => $types] = $this->buildUnionQuery($accompanyingPeriod, $startDate, $endDate, $content, $places);
|
||||
|
||||
@@ -140,7 +140,7 @@ final readonly class Manager
|
||||
?\DateTimeImmutable $startDate = null,
|
||||
?\DateTimeImmutable $endDate = null,
|
||||
?string $content = null,
|
||||
array $places = []
|
||||
array $places = [],
|
||||
): iterable {
|
||||
['sql' => $sql, 'params' => $params, 'types' => $types] = $this->buildUnionQuery($person, $startDate, $endDate, $content, $places);
|
||||
|
||||
|
@@ -34,7 +34,7 @@ final readonly class PersonDocumentGenericDocProvider implements GenericDocForPe
|
||||
?\DateTimeImmutable $startDate = null,
|
||||
?\DateTimeImmutable $endDate = null,
|
||||
?string $content = null,
|
||||
?string $origin = null
|
||||
?string $origin = null,
|
||||
): FetchQueryInterface {
|
||||
return $this->personDocumentACLAwareRepository->buildFetchQueryForPerson(
|
||||
$person,
|
||||
|
@@ -31,13 +31,13 @@ interface PersonDocumentACLAwareRepositoryInterface
|
||||
Person $person,
|
||||
?\DateTimeImmutable $startDate = null,
|
||||
?\DateTimeImmutable $endDate = null,
|
||||
?string $content = null
|
||||
?string $content = null,
|
||||
): FetchQueryInterface;
|
||||
|
||||
public function buildFetchQueryForAccompanyingPeriod(
|
||||
AccompanyingPeriod $period,
|
||||
?\DateTimeImmutable $startDate = null,
|
||||
?\DateTimeImmutable $endDate = null,
|
||||
?string $content = null
|
||||
?string $content = null,
|
||||
): FetchQueryInterface;
|
||||
}
|
||||
|
@@ -25,7 +25,7 @@ readonly class PersonDocumentRepository implements ObjectRepository, AssociatedE
|
||||
private EntityRepository $repository;
|
||||
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager
|
||||
private EntityManagerInterface $entityManager,
|
||||
) {
|
||||
$this->repository = $this->entityManager->getRepository($this->getClassName());
|
||||
}
|
||||
|
@@ -14,6 +14,7 @@ namespace Chill\DocStoreBundle\Repository;
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Doctrine\ORM\Query;
|
||||
|
||||
final readonly class StoredObjectRepository implements StoredObjectRepositoryInterface
|
||||
{
|
||||
@@ -53,6 +54,21 @@ final readonly class StoredObjectRepository implements StoredObjectRepositoryInt
|
||||
return $this->repository->findOneBy($criteria);
|
||||
}
|
||||
|
||||
public function findByExpired(\DateTimeImmutable $expiredAtDate): iterable
|
||||
{
|
||||
$qb = $this->repository->createQueryBuilder('stored_object');
|
||||
$qb
|
||||
->where('stored_object.deleteAt <= :expiredAt')
|
||||
->setParameter('expiredAt', $expiredAtDate);
|
||||
|
||||
return $qb->getQuery()->toIterable(hydrationMode: Query::HYDRATE_OBJECT);
|
||||
}
|
||||
|
||||
public function findOneByUUID(string $uuid): ?StoredObject
|
||||
{
|
||||
return $this->repository->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
public function getClassName(): string
|
||||
{
|
||||
return StoredObject::class;
|
||||
|
@@ -17,4 +17,12 @@ use Doctrine\Persistence\ObjectRepository;
|
||||
/**
|
||||
* @extends ObjectRepository<StoredObject>
|
||||
*/
|
||||
interface StoredObjectRepositoryInterface extends ObjectRepository {}
|
||||
interface StoredObjectRepositoryInterface extends ObjectRepository
|
||||
{
|
||||
/**
|
||||
* @return iterable<StoredObject>
|
||||
*/
|
||||
public function findByExpired(\DateTimeImmutable $expiredAtDate): iterable;
|
||||
|
||||
public function findOneByUUID(string $uuid): ?StoredObject;
|
||||
}
|
||||
|
@@ -0,0 +1,92 @@
|
||||
<?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 findIdsByVersionsOlderThanDateAndNotLastVersion(\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)
|
||||
SQL;
|
||||
|
||||
public function getClassName(): string
|
||||
{
|
||||
return StoredObjectVersion::class;
|
||||
}
|
||||
}
|
@@ -1,5 +1,5 @@
|
||||
import {makeFetch} from "../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods";
|
||||
import {PostStoreObjectSignature} from "../../types";
|
||||
import {PostStoreObjectSignature, StoredObject} from "../../types";
|
||||
|
||||
const algo = 'AES-CBC';
|
||||
|
||||
@@ -21,11 +21,22 @@ const createFilename = (): string => {
|
||||
return text;
|
||||
};
|
||||
|
||||
export const uploadFile = async (uploadFile: ArrayBuffer): Promise<string> => {
|
||||
/**
|
||||
* Fetches a new stored object from the server.
|
||||
*
|
||||
* @async
|
||||
* @function fetchNewStoredObject
|
||||
* @returns {Promise<StoredObject>} A Promise that resolves to the newly created StoredObject.
|
||||
*/
|
||||
export const fetchNewStoredObject = async (): Promise<StoredObject> => {
|
||||
return makeFetch("POST", '/api/1.0/doc-store/stored-object/create', null);
|
||||
}
|
||||
|
||||
export const uploadVersion = async (uploadFile: ArrayBuffer, storedObject: StoredObject): Promise<string> => {
|
||||
const params = new URLSearchParams();
|
||||
params.append('expires_delay', "180");
|
||||
params.append('submit_delay', "180");
|
||||
const asyncData: PostStoreObjectSignature = await makeFetch("GET", URL_POST + "?" + params.toString());
|
||||
const asyncData: PostStoreObjectSignature = await makeFetch("GET", `/api/1.0/doc-store/async-upload/temp_url/${storedObject.uuid}/generate/post` + "?" + params.toString());
|
||||
const suffix = createFilename();
|
||||
const filename = asyncData.prefix + suffix;
|
||||
const formData = new FormData();
|
||||
@@ -50,7 +61,6 @@ export const uploadFile = async (uploadFile: ArrayBuffer): Promise<string> => {
|
||||
}
|
||||
|
||||
export const encryptFile = async (originalFile: ArrayBuffer): Promise<[ArrayBuffer, Uint8Array, JsonWebKey]> => {
|
||||
console.log('encrypt', originalFile);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(16));
|
||||
const key = await window.crypto.subtle.generateKey(keyDefinition, true, [ "encrypt", "decrypt" ]);
|
||||
const exportedKey = await window.crypto.subtle.exportKey('jwk', key);
|
@@ -1,7 +1,7 @@
|
||||
import {CollectionEventPayload} from "../../../../../ChillMainBundle/Resources/public/module/collection";
|
||||
import {createApp} from "vue";
|
||||
import DropFileWidget from "../../vuejs/DropFileWidget/DropFileWidget.vue"
|
||||
import {StoredObject, StoredObjectCreated} from "../../types";
|
||||
import {StoredObject, StoredObjectVersion} from "../../types";
|
||||
import {_createI18n} from "../../../../../ChillMainBundle/Resources/public/vuejs/_js/i18n";
|
||||
const i18n = _createI18n({});
|
||||
|
||||
@@ -30,15 +30,17 @@ const startApp = (divElement: HTMLDivElement, collectionEntry: null|HTMLLIElemen
|
||||
DropFileWidget,
|
||||
},
|
||||
methods: {
|
||||
addDocument: function(object: StoredObjectCreated): void {
|
||||
console.log('object added', object);
|
||||
this.$data.existingDoc = object;
|
||||
input_stored_object.value = JSON.stringify(object);
|
||||
addDocument: function({stored_object, stored_object_version}: {stored_object: StoredObject, stored_object_version: StoredObjectVersion}): void {
|
||||
console.log('object added', stored_object);
|
||||
console.log('version added', stored_object_version);
|
||||
this.$data.existingDoc = stored_object;
|
||||
this.$data.existingDoc.currentVersion = stored_object_version;
|
||||
input_stored_object.value = JSON.stringify(this.$data.existingDoc);
|
||||
},
|
||||
removeDocument: function(object: StoredObject): void {
|
||||
console.log('catch remove document', object);
|
||||
input_stored_object.value = "";
|
||||
this.$data.existingDoc = null;
|
||||
this.$data.existingDoc = undefined;
|
||||
console.log('collectionEntry', collectionEntry);
|
||||
|
||||
if (null !== collectionEntry) {
|
||||
|
@@ -1,36 +1,51 @@
|
||||
import {DateTime} from "../../../ChillMainBundle/Resources/public/types";
|
||||
import {DateTime, User} from "../../../ChillMainBundle/Resources/public/types";
|
||||
|
||||
export type StoredObjectStatus = "ready"|"failure"|"pending";
|
||||
export type StoredObjectStatus = "empty"|"ready"|"failure"|"pending";
|
||||
|
||||
export interface StoredObject {
|
||||
id: number,
|
||||
|
||||
/**
|
||||
* filename of the object in the object storage
|
||||
*/
|
||||
filename: string,
|
||||
creationDate: DateTime,
|
||||
datas: object,
|
||||
iv: number[],
|
||||
keyInfos: object,
|
||||
title: string,
|
||||
type: string,
|
||||
uuid: string,
|
||||
status: StoredObjectStatus,
|
||||
id: number,
|
||||
title: string|null,
|
||||
uuid: string,
|
||||
prefix: string,
|
||||
status: StoredObjectStatus,
|
||||
currentVersion: null|StoredObjectVersionCreated|StoredObjectVersionPersisted,
|
||||
totalVersions: number,
|
||||
datas: object,
|
||||
/** @deprecated */
|
||||
creationDate: DateTime,
|
||||
createdAt: DateTime|null,
|
||||
createdBy: User|null,
|
||||
_permissions: {
|
||||
canEdit: boolean,
|
||||
canSee: boolean,
|
||||
},
|
||||
_links?: {
|
||||
dav_link?: {
|
||||
href: string
|
||||
expiration: number
|
||||
},
|
||||
}
|
||||
dav_link?: {
|
||||
href: string
|
||||
expiration: number
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export interface StoredObjectCreated {
|
||||
status: "stored_object_created",
|
||||
filename: string,
|
||||
iv: Uint8Array,
|
||||
keyInfos: object,
|
||||
type: string,
|
||||
export interface StoredObjectVersion {
|
||||
/**
|
||||
* filename of the object in the object storage
|
||||
*/
|
||||
filename: string,
|
||||
iv: number[],
|
||||
keyInfos: JsonWebKey,
|
||||
type: string,
|
||||
}
|
||||
|
||||
export interface StoredObjectVersionCreated extends StoredObjectVersion {
|
||||
persisted: false,
|
||||
}
|
||||
|
||||
export interface StoredObjectVersionPersisted extends StoredObjectVersionCreated {
|
||||
version: number,
|
||||
id: number,
|
||||
createdAt: DateTime|null,
|
||||
createdBy: User|null,
|
||||
}
|
||||
|
||||
export interface StoredObjectStatusChange {
|
||||
@@ -82,4 +97,4 @@ export interface Signature {
|
||||
zones: SignatureZone[],
|
||||
}
|
||||
|
||||
export type SignedState = 'pending' | 'signed' | 'rejected' | 'canceled' | 'error';
|
||||
export type SignedState = 'pending' | 'signed' | 'rejected' | 'canceled' | 'error';
|
||||
|
@@ -1,20 +1,20 @@
|
||||
<template>
|
||||
<div v-if="'ready' === props.storedObject.status || 'stored_object_created' === props.storedObject.status" class="btn-group">
|
||||
<div v-if="isButtonGroupDisplayable" class="btn-group">
|
||||
<button :class="Object.assign({'btn': true, 'btn-outline-primary': true, 'dropdown-toggle': true, 'btn-sm': props.small})" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Actions
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li v-if="props.canEdit && is_extension_editable(props.storedObject.type) && props.storedObject.status !== 'stored_object_created'">
|
||||
<li v-if="isEditableOnline">
|
||||
<wopi-edit-button :stored-object="props.storedObject" :classes="{'dropdown-item': true}" :execute-before-leave="props.executeBeforeLeave"></wopi-edit-button>
|
||||
</li>
|
||||
<li v-if="props.canEdit && is_extension_editable(props.storedObject.type) && props.davLink !== undefined && props.davLinkExpiration !== undefined">
|
||||
<li v-if="isEditableOnDesktop">
|
||||
<desktop-edit-button :classes="{'dropdown-item': true}" :edit-link="props.davLink" :expiration-link="props.davLinkExpiration"></desktop-edit-button>
|
||||
</li>
|
||||
<li v-if="props.storedObject.type != 'application/pdf' && is_extension_viewable(props.storedObject.type) && props.canConvertPdf && props.storedObject.status !== 'stored_object_created'">
|
||||
<li v-if="isConvertibleToPdf">
|
||||
<convert-button :stored-object="props.storedObject" :filename="filename" :classes="{'dropdown-item': true}"></convert-button>
|
||||
</li>
|
||||
<li v-if="props.canDownload">
|
||||
<download-button :stored-object="props.storedObject" :filename="filename" :classes="{'dropdown-item': true}"></download-button>
|
||||
<li v-if="isDownloadable">
|
||||
<download-button :stored-object="props.storedObject" :at-version="props.storedObject.currentVersion" :filename="filename" :classes="{'dropdown-item': true}"></download-button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -29,20 +29,20 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
||||
import {onMounted} from "vue";
|
||||
import {computed, onMounted} from "vue";
|
||||
import ConvertButton from "./StoredObjectButton/ConvertButton.vue";
|
||||
import DownloadButton from "./StoredObjectButton/DownloadButton.vue";
|
||||
import WopiEditButton from "./StoredObjectButton/WopiEditButton.vue";
|
||||
import {is_extension_editable, is_extension_viewable, is_object_ready} from "./StoredObjectButton/helpers";
|
||||
import {
|
||||
StoredObject, StoredObjectCreated,
|
||||
StoredObjectStatusChange,
|
||||
StoredObject,
|
||||
StoredObjectStatusChange, StoredObjectVersion,
|
||||
WopiEditButtonExecutableBeforeLeaveFunction
|
||||
} from "../types";
|
||||
import DesktopEditButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DesktopEditButton.vue";
|
||||
|
||||
interface DocumentActionButtonsGroupConfig {
|
||||
storedObject: StoredObject|StoredObjectCreated,
|
||||
storedObject: StoredObject,
|
||||
small?: boolean,
|
||||
canEdit?: boolean,
|
||||
canDownload?: boolean,
|
||||
@@ -95,11 +95,44 @@ let tryiesForReady = 0;
|
||||
*/
|
||||
const maxTryiesForReady = 120;
|
||||
|
||||
const isButtonGroupDisplayable = computed<boolean>(() => {
|
||||
return isDownloadable.value || isEditableOnline.value || isEditableOnDesktop.value || isConvertibleToPdf.value;
|
||||
});
|
||||
|
||||
const isDownloadable = computed<boolean>(() => {
|
||||
return props.storedObject.status === 'ready'
|
||||
// happens when the stored object version is just added, but not persisted
|
||||
|| (props.storedObject.currentVersion !== null && props.storedObject.status === 'empty')
|
||||
});
|
||||
|
||||
const isEditableOnline = computed<boolean>(() => {
|
||||
return props.storedObject.status === 'ready'
|
||||
&& props.storedObject._permissions.canEdit
|
||||
&& props.canEdit
|
||||
&& props.storedObject.currentVersion !== null
|
||||
&& is_extension_editable(props.storedObject.currentVersion.type)
|
||||
&& props.storedObject.currentVersion.persisted !== false;
|
||||
});
|
||||
|
||||
const isEditableOnDesktop = computed<boolean>(() => {
|
||||
return isEditableOnline.value;
|
||||
});
|
||||
|
||||
const isConvertibleToPdf = computed<boolean>(() => {
|
||||
return props.storedObject.status === 'ready'
|
||||
&& props.storedObject._permissions.canSee
|
||||
&& props.canConvertPdf
|
||||
&& props.storedObject.currentVersion !== null
|
||||
&& is_extension_viewable(props.storedObject.currentVersion.type)
|
||||
&& props.storedObject.currentVersion.type !== 'application/pdf'
|
||||
&& props.storedObject.currentVersion.persisted !== false;
|
||||
})
|
||||
|
||||
const checkForReady = function(): void {
|
||||
if (
|
||||
'ready' === props.storedObject.status
|
||||
|| 'empty' === props.storedObject.status
|
||||
|| 'failure' === props.storedObject.status
|
||||
|| 'stored_object_created' === props.storedObject.status
|
||||
// stop reloading if the page stays opened for a long time
|
||||
|| tryiesForReady > maxTryiesForReady
|
||||
) {
|
||||
|
@@ -136,7 +136,6 @@ console.log(PdfWorker); // incredible but this is needed
|
||||
|
||||
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
|
||||
import {
|
||||
build_download_info_link,
|
||||
download_and_decrypt_doc,
|
||||
} from "../StoredObjectButton/helpers";
|
||||
|
||||
@@ -160,7 +159,6 @@ declare global {
|
||||
const $toast = useToast();
|
||||
|
||||
const signature = window.signature;
|
||||
const urlInfo = build_download_info_link(signature.storedObject.filename);
|
||||
|
||||
const mountPdf = async (url: string) => {
|
||||
const loadingTask = pdfjsLib.getDocument(url);
|
||||
@@ -192,11 +190,7 @@ const setPage = async (page: number) => {
|
||||
async function downloadAndOpen(): Promise<Blob> {
|
||||
let raw;
|
||||
try {
|
||||
raw = await download_and_decrypt_doc(
|
||||
urlInfo,
|
||||
signature.storedObject.keyInfos,
|
||||
new Uint8Array(signature.storedObject.iv)
|
||||
);
|
||||
raw = await download_and_decrypt_doc(signature.storedObject, signature.storedObject.currentVersion);
|
||||
} catch (e) {
|
||||
console.error("error while downloading and decrypting document", e);
|
||||
throw e;
|
||||
|
@@ -1,17 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import {StoredObject, StoredObjectCreated} from "../../types";
|
||||
import {encryptFile, uploadFile} from "../_components/helper";
|
||||
import {StoredObject, StoredObjectVersionCreated} from "../../types";
|
||||
import {encryptFile, fetchNewStoredObject, uploadVersion} from "../../js/async-upload/uploader";
|
||||
import {computed, ref, Ref} from "vue";
|
||||
|
||||
interface DropFileConfig {
|
||||
existingDoc?: StoredObjectCreated|StoredObject,
|
||||
existingDoc?: StoredObject,
|
||||
}
|
||||
|
||||
const props = defineProps<DropFileConfig>();
|
||||
const props = withDefaults(defineProps<DropFileConfig>(), {existingDoc: null});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'addDocument', stored_object: StoredObjectCreated): void,
|
||||
(e: 'addDocument', {stored_object_version: StoredObjectVersionCreated, stored_object: StoredObject}): void,
|
||||
}>();
|
||||
|
||||
const is_dragging: Ref<boolean> = ref(false);
|
||||
@@ -34,7 +34,6 @@ const onDragLeave = (e: Event) => {
|
||||
}
|
||||
|
||||
const onDrop = (e: DragEvent) => {
|
||||
console.log('on drop', e);
|
||||
e.preventDefault();
|
||||
|
||||
const files = e.dataTransfer?.files;
|
||||
@@ -64,7 +63,6 @@ const onZoneClick = (e: Event) => {
|
||||
|
||||
const onFileChange = async (event: Event): Promise<void> => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
console.log('event triggered', input);
|
||||
|
||||
if (input.files && input.files[0]) {
|
||||
console.log('file added', input.files[0]);
|
||||
@@ -80,21 +78,28 @@ const onFileChange = async (event: Event): Promise<void> => {
|
||||
const handleFile = async (file: File): Promise<void> => {
|
||||
uploading.value = true;
|
||||
const type = file.type;
|
||||
const buffer = await file.arrayBuffer();
|
||||
const [encrypted, iv, jsonWebKey] = await encryptFile(buffer);
|
||||
const filename = await uploadFile(encrypted);
|
||||
|
||||
console.log(iv, jsonWebKey);
|
||||
|
||||
const storedObject: StoredObjectCreated = {
|
||||
filename: filename,
|
||||
iv,
|
||||
keyInfos: jsonWebKey,
|
||||
type: type,
|
||||
status: "stored_object_created",
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -138,6 +143,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 {
|
||||
@@ -148,8 +158,4 @@ const handleFile = async (file: File): Promise<void> => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div.chill-collection ul.list-entry li.entry:nth-child(2n) {
|
||||
|
||||
}
|
||||
</style>
|
||||
|
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
|
||||
import {StoredObject, StoredObjectVersion} from "../../types";
|
||||
import DropFileWidget from "ChillDocStoreAssets/vuejs/DropFileWidget/DropFileWidget.vue";
|
||||
import {computed, reactive} from "vue";
|
||||
import {useToast} from 'vue-toast-notification';
|
||||
|
||||
interface DropFileConfig {
|
||||
allowRemove: boolean,
|
||||
existingDoc?: StoredObject,
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<DropFileConfig>(), {
|
||||
allowRemove: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'addDocument', {stored_object: StoredObject, stored_object_version: StoredObjectVersion}): void,
|
||||
(e: 'removeDocument'): void
|
||||
}>();
|
||||
|
||||
const $toast = useToast();
|
||||
|
||||
const state = reactive({showModal: false});
|
||||
|
||||
const modalClasses = {"modal-dialog-centered": true, "modal-md": true};
|
||||
|
||||
const buttonState = computed<'add'|'replace'>(() => {
|
||||
if (props.existingDoc === undefined || props.existingDoc === null) {
|
||||
return 'add';
|
||||
}
|
||||
|
||||
return 'replace';
|
||||
})
|
||||
|
||||
function onAddDocument({stored_object, stored_object_version}: {stored_object: StoredObject, stored_object_version: StoredObjectVersion}): void {
|
||||
const message = buttonState.value === 'add' ? "Document ajouté" : "Document remplacé";
|
||||
$toast.success(message);
|
||||
emit('addDocument', {stored_object_version, stored_object});
|
||||
state.showModal = false;
|
||||
}
|
||||
|
||||
function onRemoveDocument(): void {
|
||||
emit('removeDocument');
|
||||
}
|
||||
|
||||
function openModal(): void {
|
||||
state.showModal = true;
|
||||
}
|
||||
|
||||
function closeModal(): void {
|
||||
state.showModal = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button v-if="buttonState === 'add'" @click="openModal" class="btn btn-create">Ajouter un document</button>
|
||||
<button v-else @click="openModal" class="btn btn-edit">Remplacer le document</button>
|
||||
<modal v-if="state.showModal" :modal-dialog-class="modalClasses" @close="closeModal">
|
||||
<template v-slot:body>
|
||||
<drop-file-widget :existing-doc="existingDoc" :allow-remove="allowRemove" @add-document="onAddDocument" @remove-document="onRemoveDocument" ></drop-file-widget>
|
||||
</template>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
@@ -1,13 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import {StoredObject, StoredObjectCreated} from "../../types";
|
||||
import {StoredObject, StoredObjectVersion} from "../../types";
|
||||
import {computed, ref, Ref} from "vue";
|
||||
import DropFile from "ChillDocStoreAssets/vuejs/DropFileWidget/DropFile.vue";
|
||||
import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue";
|
||||
|
||||
interface DropFileConfig {
|
||||
allowRemove: boolean,
|
||||
existingDoc?: StoredObjectCreated|StoredObject,
|
||||
existingDoc?: StoredObject,
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<DropFileConfig>(), {
|
||||
@@ -15,8 +15,8 @@ const props = withDefaults(defineProps<DropFileConfig>(), {
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'addDocument', stored_object: StoredObjectCreated): void,
|
||||
(e: 'removeDocument', stored_object: null): void
|
||||
(e: 'addDocument', {stored_object: StoredObject, stored_object_version: StoredObjectVersion}): void,
|
||||
(e: 'removeDocument'): void
|
||||
}>();
|
||||
|
||||
const has_existing_doc = computed<boolean>(() => {
|
||||
@@ -45,14 +45,14 @@ const dav_link_href = computed<string|undefined>(() => {
|
||||
return props.existingDoc._links?.dav_link?.href;
|
||||
})
|
||||
|
||||
const onAddDocument = (s: StoredObjectCreated): void => {
|
||||
emit('addDocument', s);
|
||||
const onAddDocument = ({stored_object, stored_object_version}: {stored_object: StoredObject, stored_object_version: StoredObjectVersion}): void => {
|
||||
emit('addDocument', {stored_object, stored_object_version});
|
||||
}
|
||||
|
||||
const onRemoveDocument = (e: Event): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
emit('removeDocument', null);
|
||||
emit('removeDocument');
|
||||
}
|
||||
|
||||
</script>
|
||||
|
@@ -10,7 +10,7 @@
|
||||
import {build_convert_link, download_and_decrypt_doc, download_doc} from "./helpers";
|
||||
import mime from "mime";
|
||||
import {reactive} from "vue";
|
||||
import {StoredObject, StoredObjectCreated} from "../../types";
|
||||
import {StoredObject} from "../../types";
|
||||
|
||||
interface ConvertButtonConfig {
|
||||
storedObject: StoredObject,
|
||||
@@ -45,7 +45,7 @@ async function download_and_open(event: Event): Promise<void> {
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="sass">
|
||||
<style scoped lang="scss">
|
||||
i.fa::before {
|
||||
color: var(--bs-dropdown-link-hover-color);
|
||||
}
|
||||
|
@@ -63,4 +63,7 @@ const editionUntilFormatted = computed<string>(() => {
|
||||
.desktop-edit {
|
||||
text-align: center;
|
||||
}
|
||||
i.fa::before {
|
||||
color: var(--bs-dropdown-link-hover-color);
|
||||
}
|
||||
</style>
|
||||
|
@@ -11,12 +11,13 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {reactive, ref, nextTick, onMounted} from "vue";
|
||||
import {build_download_info_link, download_and_decrypt_doc} from "./helpers";
|
||||
import {download_and_decrypt_doc} from "./helpers";
|
||||
import mime from "mime";
|
||||
import {StoredObject, StoredObjectCreated} from "../../types";
|
||||
import {StoredObject, StoredObjectVersion} from "../../types";
|
||||
|
||||
interface DownloadButtonConfig {
|
||||
storedObject: StoredObject|StoredObjectCreated,
|
||||
storedObject: StoredObject,
|
||||
atVersion: StoredObjectVersion,
|
||||
classes: { [k: string]: boolean },
|
||||
filename?: string,
|
||||
}
|
||||
@@ -33,8 +34,9 @@ const state: DownloadButtonState = reactive({is_ready: false, is_running: false,
|
||||
const open_button = ref<HTMLAnchorElement | null>(null);
|
||||
|
||||
function buildDocumentName(): string {
|
||||
const document_name = props.filename || 'document';
|
||||
const ext = mime.getExtension(props.storedObject.type);
|
||||
const document_name = props.filename ?? props.storedObject.title ?? 'document';
|
||||
|
||||
const ext = mime.getExtension(props.atVersion.type);
|
||||
|
||||
if (null !== ext) {
|
||||
return document_name + '.' + ext;
|
||||
@@ -58,38 +60,26 @@ async function download_and_open(event: Event): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const urlInfo = build_download_info_link(props.storedObject.filename);
|
||||
let raw;
|
||||
|
||||
try {
|
||||
raw = await download_and_decrypt_doc(urlInfo, props.storedObject.keyInfos, new Uint8Array(props.storedObject.iv));
|
||||
raw = await download_and_decrypt_doc(props.storedObject, props.atVersion);
|
||||
} catch (e) {
|
||||
console.error("error while downloading and decrypting document");
|
||||
console.error(e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
console.log('document downloading (and decrypting) successfully');
|
||||
|
||||
console.log('creating the url')
|
||||
state.href_url = window.URL.createObjectURL(raw);
|
||||
console.log('url created', state.href_url);
|
||||
state.is_running = false;
|
||||
state.is_ready = true;
|
||||
console.log('new button marked as ready');
|
||||
console.log('will click on button');
|
||||
|
||||
console.log('openbutton is now', open_button.value);
|
||||
|
||||
await nextTick();
|
||||
console.log('next tick actions');
|
||||
console.log('openbutton after next tick', open_button.value);
|
||||
open_button.value?.click();
|
||||
console.log('open button should have been clicked');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="sass">
|
||||
<style scoped lang="scss">
|
||||
i.fa::before {
|
||||
color: var(--bs-dropdown-link-hover-color);
|
||||
}
|
||||
|
@@ -8,7 +8,7 @@
|
||||
<script lang="ts" setup>
|
||||
import WopiEditButton from "./WopiEditButton.vue";
|
||||
import {build_wopi_editor_link} from "./helpers";
|
||||
import {StoredObject, StoredObjectCreated, WopiEditButtonExecutableBeforeLeaveFunction} from "../../types";
|
||||
import {StoredObject, WopiEditButtonExecutableBeforeLeaveFunction} from "../../types";
|
||||
|
||||
interface WopiEditButtonConfig {
|
||||
storedObject: StoredObject,
|
||||
@@ -22,7 +22,6 @@ const props = defineProps<WopiEditButtonConfig>();
|
||||
let executed = false;
|
||||
|
||||
async function beforeLeave(event: Event): Promise<true> {
|
||||
console.log(executed);
|
||||
if (props.executeBeforeLeave === undefined || executed === true) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
@@ -39,7 +38,7 @@ async function beforeLeave(event: Event): Promise<true> {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="sass">
|
||||
<style scoped lang="scss">
|
||||
i.fa::before {
|
||||
color: var(--bs-dropdown-link-hover-color);
|
||||
}
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import {StoredObject, StoredObjectStatus, StoredObjectStatusChange} from "../../types";
|
||||
import {StoredObject, StoredObjectStatus, StoredObjectStatusChange, StoredObjectVersion} from "../../types";
|
||||
import {makeFetch} from "../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods";
|
||||
|
||||
const MIMES_EDIT = new Set([
|
||||
'application/vnd.ms-powerpoint',
|
||||
@@ -97,6 +98,13 @@ const MIMES_VIEW = new Set([
|
||||
]
|
||||
])
|
||||
|
||||
export interface SignedUrlGet {
|
||||
method: 'GET'|'HEAD',
|
||||
url: string,
|
||||
expires: number,
|
||||
object_name: string,
|
||||
}
|
||||
|
||||
function is_extension_editable(mimeType: string): boolean {
|
||||
return MIMES_EDIT.has(mimeType);
|
||||
}
|
||||
@@ -109,8 +117,20 @@ function build_convert_link(uuid: string) {
|
||||
return `/chill/wopi/convert/${uuid}`;
|
||||
}
|
||||
|
||||
function build_download_info_link(object_name: string) {
|
||||
return `/asyncupload/temp_url/generate/GET?object_name=${object_name}`;
|
||||
function build_download_info_link(storedObject: StoredObject, atVersion: null|StoredObjectVersion): string {
|
||||
const url = `/api/1.0/doc-store/async-upload/temp_url/${storedObject.uuid}/generate/get`;
|
||||
|
||||
if (null !== atVersion) {
|
||||
const params = new URLSearchParams({version: atVersion.filename});
|
||||
|
||||
return url + '?' + params.toString();
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
async function download_info_link(storedObject: StoredObject, atVersion: null|StoredObjectVersion): Promise<SignedUrlGet> {
|
||||
return makeFetch('GET', build_download_info_link(storedObject, atVersion));
|
||||
}
|
||||
|
||||
function build_wopi_editor_link(uuid: string, returnPath?: string) {
|
||||
@@ -131,43 +151,39 @@ function download_doc(url: string): Promise<Blob> {
|
||||
});
|
||||
}
|
||||
|
||||
async function download_and_decrypt_doc(urlGenerator: string, keyData: JsonWebKey, iv: Uint8Array): Promise<Blob>
|
||||
async function download_and_decrypt_doc(storedObject: StoredObject, atVersion: null|StoredObjectVersion): Promise<Blob>
|
||||
{
|
||||
const algo = 'AES-CBC';
|
||||
// get an url to download the object
|
||||
const downloadInfoResponse = await window.fetch(urlGenerator);
|
||||
|
||||
if (!downloadInfoResponse.ok) {
|
||||
throw new Error("error while downloading url " + downloadInfoResponse.status + " " + downloadInfoResponse.statusText);
|
||||
const atVersionToDownload = atVersion ?? storedObject.currentVersion;
|
||||
|
||||
if (null === atVersionToDownload) {
|
||||
throw new Error("no version associated to stored object");
|
||||
}
|
||||
|
||||
const downloadInfo = await downloadInfoResponse.json() as {url: string};
|
||||
const downloadInfo= await download_info_link(storedObject, atVersionToDownload);
|
||||
|
||||
const rawResponse = await window.fetch(downloadInfo.url);
|
||||
|
||||
if (!rawResponse.ok) {
|
||||
throw new Error("error while downloading raw file " + rawResponse.status + " " + rawResponse.statusText);
|
||||
}
|
||||
|
||||
if (iv.length === 0) {
|
||||
console.log('returning document immediatly');
|
||||
if (atVersionToDownload.iv.length === 0) {
|
||||
return rawResponse.blob();
|
||||
}
|
||||
|
||||
console.log('start decrypting doc');
|
||||
|
||||
const rawBuffer = await rawResponse.arrayBuffer();
|
||||
|
||||
try {
|
||||
const key = await window.crypto.subtle
|
||||
.importKey('jwk', keyData, { name: algo }, false, ['decrypt']);
|
||||
console.log('key created');
|
||||
.importKey('jwk', atVersionToDownload.keyInfos, { name: algo }, false, ['decrypt']);
|
||||
const iv = Uint8Array.from(atVersionToDownload.iv);
|
||||
const decrypted = await window.crypto.subtle
|
||||
.decrypt({ name: algo, iv: iv }, key, rawBuffer);
|
||||
console.log('doc decrypted');
|
||||
|
||||
return Promise.resolve(new Blob([decrypted]));
|
||||
} catch (e) {
|
||||
console.error('get error while keys and decrypt operations');
|
||||
console.error('encounter error while keys and decrypt operations');
|
||||
console.error(e);
|
||||
|
||||
throw e;
|
||||
@@ -188,7 +204,6 @@ async function is_object_ready(storedObject: StoredObject): Promise<StoredObject
|
||||
|
||||
export {
|
||||
build_convert_link,
|
||||
build_download_info_link,
|
||||
build_wopi_editor_link,
|
||||
download_and_decrypt_doc,
|
||||
download_doc,
|
||||
|
@@ -1,174 +0,0 @@
|
||||
<template>
|
||||
<a :class="btnClasses" :title="$t(buttonTitle)" @click="openModal">
|
||||
<span>{{ $t(buttonTitle) }}</span>
|
||||
</a>
|
||||
<teleport to="body">
|
||||
<div>
|
||||
<modal v-if="modal.showModal"
|
||||
:modalDialogClass="modal.modalDialogClass"
|
||||
@close="modal.showModal = false">
|
||||
|
||||
<template v-slot:header>
|
||||
{{ $t('upload_a_document') }}
|
||||
</template>
|
||||
|
||||
<template v-slot:body>
|
||||
<div id="dropZoneWrapper" ref="dropZoneWrapper">
|
||||
<div
|
||||
data-stored-object="data-stored-object"
|
||||
:data-label-preparing="$t('data_label_preparing')"
|
||||
:data-label-quiet-button="$t('data_label_quiet_button')"
|
||||
:data-label-ready="$t('data_label_ready')"
|
||||
:data-dict-file-too-big="$t('data_dict_file_too_big')"
|
||||
:data-dict-default-message="$t('data_dict_default_message')"
|
||||
:data-dict-remove-file="$t('data_dict_remove_file')"
|
||||
:data-dict-max-files-exceeded="$t('data_dict_max_files_exceeded')"
|
||||
:data-dict-cancel-upload="$t('data_dict_cancel_upload')"
|
||||
:data-dict-cancel-upload-confirm="$t('data_dict_cancel_upload_confirm')"
|
||||
:data-dict-upload-canceled="$t('data_dict_upload_canceled')"
|
||||
:data-dict-remove="$t('data_dict_remove')"
|
||||
:data-allow-remove="!options.required"
|
||||
data-temp-url-generator="/asyncupload/temp_url/generate/GET">
|
||||
<input
|
||||
type="hidden"
|
||||
data-async-file-upload="data-async-file-upload"
|
||||
data-generate-temp-url-post="/asyncupload/temp_url/generate/post?expires_delay=180&submit_delay=3600"
|
||||
data-temp-url-get="/asyncupload/temp_url/generate/GET"
|
||||
:data-max-files="options.maxFiles"
|
||||
:data-max-post-size="options.maxPostSize"
|
||||
:v-model="dataAsyncFileUpload"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
data-stored-object-key="1"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
data-stored-object-iv="1"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
data-async-file-type="1"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:footer>
|
||||
<button class="btn btn-create"
|
||||
@click.prevent="saveDocument">
|
||||
{{ $t('action.add')}}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
</modal>
|
||||
</div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Modal from 'ChillMainAssets/vuejs/_components/Modal';
|
||||
import { searchForZones } from '../../module/async_upload/uploader';
|
||||
import { makeFetch } from "ChillMainAssets/lib/api/apiMethods";
|
||||
|
||||
const i18n = {
|
||||
messages: {
|
||||
fr: {
|
||||
upload_a_document: "Téléversez un document",
|
||||
data_label_preparing: "Chargement...",
|
||||
data_label_quiet_button: "Téléchargez le fichier existant",
|
||||
data_label_ready: "Prêt à montrer",
|
||||
data_dict_file_too_big: "Fichier trop volumineux",
|
||||
data_dict_default_message: "Glissez votre fichier ou cliquez ici",
|
||||
data_dict_remove_file: "Enlevez votre fichier pour en téléversez un autre",
|
||||
data_dict_max_files_exceeded: "Nombre maximum de fichiers atteint. Enlevez les fichiers précédents",
|
||||
data_dict_cancel_upload: "Annulez le téléversement",
|
||||
data_dict_cancel_upload_confirm: "Êtes-vous sûr·e de vouloir annuler ce téléversement?",
|
||||
data_dict_upload_canceled: "Téléversement annulé",
|
||||
data_dict_remove: "Enlevez le fichier existant",
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "AddAsyncUpload",
|
||||
components: {
|
||||
Modal
|
||||
},
|
||||
i18n,
|
||||
props: {
|
||||
buttonTitle: {
|
||||
type: String,
|
||||
default: 'Ajouter un document',
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: {
|
||||
maxFiles: 1,
|
||||
maxPostSize: 262144000, // 250MB
|
||||
required: false,
|
||||
}
|
||||
},
|
||||
btnClasses: {
|
||||
type: Object,
|
||||
default: {
|
||||
btn: true,
|
||||
'btn-create': true
|
||||
}
|
||||
}
|
||||
},
|
||||
emits: ['addDocument'],
|
||||
data() {
|
||||
return {
|
||||
modal: {
|
||||
showModal: false,
|
||||
modalDialogClass: "modal-dialog-centered modal-md"
|
||||
},
|
||||
}
|
||||
},
|
||||
updated() {
|
||||
if (this.modal.showModal){
|
||||
searchForZones(this.$refs.dropZoneWrapper);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
openModal() {
|
||||
this.modal.showModal = true;
|
||||
},
|
||||
saveDocument() {
|
||||
const dropzone = this.$refs.dropZoneWrapper;
|
||||
if (dropzone) {
|
||||
const inputKey = dropzone.querySelector('input[data-stored-object-key]');
|
||||
const inputIv = dropzone.querySelector('input[data-stored-object-iv]');
|
||||
const inputObject = dropzone.querySelector('input[data-async-file-upload]');
|
||||
const inputType = dropzone.querySelector('input[data-async-file-type]');
|
||||
|
||||
const url = '/api/1.0/docstore/stored-object.json';
|
||||
const body = {
|
||||
filename: inputObject.value,
|
||||
keyInfos: JSON.parse(inputKey.value),
|
||||
iv: JSON.parse(inputIv.value),
|
||||
type: inputType.value,
|
||||
};
|
||||
makeFetch('POST', url, body)
|
||||
.then(r => {
|
||||
this.$emit("addDocument", r);
|
||||
this.modal.showModal = false;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.name === 'ValidationException') {
|
||||
for (let v of error.violations) {
|
||||
this.$toast.open({message: v });
|
||||
}
|
||||
} else {
|
||||
console.error(error);
|
||||
this.$toast.open({message: 'An error occurred'});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.$toast.open({message: 'An error occurred - drop zone not found'});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
@@ -1,45 +0,0 @@
|
||||
<template>
|
||||
<a
|
||||
class="btn btn-download"
|
||||
:title="$t(buttonTitle)"
|
||||
:data-key=JSON.stringify(storedObject.keyInfos)
|
||||
:data-iv=JSON.stringify(storedObject.iv)
|
||||
:data-mime-type=storedObject.type
|
||||
:data-label-preparing="$t('dataLabelPreparing')"
|
||||
:data-label-ready="$t('dataLabelReady')"
|
||||
:data-temp-url-get-generator="url"
|
||||
@click.once="downloadDocument">
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { download } from '../../module/async_upload/downloader';
|
||||
|
||||
const i18n = {
|
||||
messages: {
|
||||
fr: {
|
||||
dataLabelPreparing: "Chargement...",
|
||||
dataLabelReady: "",
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "AddAsyncUploadDownloader",
|
||||
i18n,
|
||||
props: [
|
||||
'buttonTitle',
|
||||
'storedObject'
|
||||
],
|
||||
computed: {
|
||||
url() {
|
||||
return `/asyncupload/temp_url/generate/GET?object_name=${this.storedObject.filename}`;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
downloadDocument(e) {
|
||||
download(e.target);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
@@ -40,7 +40,7 @@ class AccompanyingCourseDocumentVoter extends AbstractChillVoter implements Prov
|
||||
public function __construct(
|
||||
protected LoggerInterface $logger,
|
||||
protected Security $security,
|
||||
VoterHelperFactoryInterface $voterHelperFactory
|
||||
VoterHelperFactoryInterface $voterHelperFactory,
|
||||
) {
|
||||
$this->voterHelper = $voterHelperFactory
|
||||
->generate(self::class)
|
||||
|
@@ -23,7 +23,7 @@ final class AsyncUploadVoter extends Voter
|
||||
|
||||
public function __construct(
|
||||
private readonly Security $security,
|
||||
private readonly StoredObjectRepository $storedObjectRepository
|
||||
private readonly StoredObjectRepository $storedObjectRepository,
|
||||
) {}
|
||||
|
||||
protected function supports($attribute, $subject): bool
|
||||
@@ -43,7 +43,7 @@ final class AsyncUploadVoter extends Voter
|
||||
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')
|
||||
'POST' => $this->security->isGranted('ROLE_USER') || $this->security->isGranted('ROLE_ADMIN'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@@ -40,7 +40,7 @@ class PersonDocumentVoter extends AbstractChillVoter implements ProvideRoleHiera
|
||||
public function __construct(
|
||||
protected LoggerInterface $logger,
|
||||
protected Security $security,
|
||||
VoterHelperFactoryInterface $voterHelperFactory
|
||||
VoterHelperFactoryInterface $voterHelperFactory,
|
||||
) {
|
||||
$this->voterHelper = $voterHelperFactory
|
||||
->generate(self::class)
|
||||
|
@@ -24,7 +24,7 @@ final class AccompanyingCourseDocumentStoredObjectVoter extends AbstractStoredOb
|
||||
public function __construct(
|
||||
private readonly AccompanyingCourseDocumentRepository $repository,
|
||||
Security $security,
|
||||
WorkflowStoredObjectPermissionHelper $workflowDocumentService
|
||||
WorkflowStoredObjectPermissionHelper $workflowDocumentService,
|
||||
) {
|
||||
parent::__construct($security, $workflowDocumentService);
|
||||
}
|
||||
|
@@ -24,7 +24,7 @@ class PersonDocumentStoredObjectVoter extends AbstractStoredObjectVoter
|
||||
public function __construct(
|
||||
private readonly PersonDocumentRepository $repository,
|
||||
Security $security,
|
||||
WorkflowStoredObjectPermissionHelper $workflowDocumentService
|
||||
WorkflowStoredObjectPermissionHelper $workflowDocumentService,
|
||||
) {
|
||||
parent::__construct($security, $workflowDocumentService);
|
||||
}
|
||||
|
@@ -43,7 +43,7 @@ class DavTokenAuthenticationEventSubscriber implements EventSubscriberInterface
|
||||
$token->setAttribute(self::ACTIONS, match ($payload['e']) {
|
||||
0 => StoredObjectRoleEnum::SEE,
|
||||
1 => StoredObjectRoleEnum::EDIT,
|
||||
default => throw new \UnexpectedValueException('unsupported value for e parameter')
|
||||
default => throw new \UnexpectedValueException('unsupported value for e parameter'),
|
||||
});
|
||||
|
||||
$token->setAttribute(self::STORED_OBJECT, $payload['so']);
|
||||
|
@@ -12,37 +12,75 @@ declare(strict_types=1);
|
||||
namespace Chill\DocStoreBundle\Serializer\Normalizer;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Repository\StoredObjectRepository;
|
||||
use Chill\DocStoreBundle\Repository\StoredObjectRepositoryInterface;
|
||||
use Symfony\Component\Form\Exception\TransformationFailedException;
|
||||
use Symfony\Component\Serializer\Exception\LogicException;
|
||||
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
|
||||
use Symfony\Component\Serializer\Normalizer\ObjectToPopulateTrait;
|
||||
|
||||
/**
|
||||
* Implements the DenormalizerInterface and is responsible for denormalizing data into StoredObject objects.
|
||||
*
|
||||
* If a new StoredObjectVersion has been added to the StoredObject, the version is created here and registered
|
||||
* to the StoredObject.
|
||||
*/
|
||||
class StoredObjectDenormalizer implements DenormalizerInterface
|
||||
{
|
||||
use ObjectToPopulateTrait;
|
||||
|
||||
public function __construct(private readonly StoredObjectRepository $storedObjectRepository) {}
|
||||
public function __construct(private readonly StoredObjectRepositoryInterface $storedObjectRepository) {}
|
||||
|
||||
public function denormalize($data, $type, $format = null, array $context = [])
|
||||
public function denormalize($data, $type, $format = null, array $context = []): ?StoredObject
|
||||
{
|
||||
$object = $this->extractObjectToPopulate(StoredObject::class, $context);
|
||||
$storedObject = $this->extractObjectToPopulate(StoredObject::class, $context);
|
||||
|
||||
if (null !== $object) {
|
||||
return $object;
|
||||
if (null === $storedObject) {
|
||||
if (array_key_exists('uuid', $data)) {
|
||||
$storedObject = $this->storedObjectRepository->findOneByUUID($data['uuid']);
|
||||
} else {
|
||||
$storedObject = $this->storedObjectRepository->find($data['id']);
|
||||
}
|
||||
|
||||
if (null === $storedObject) {
|
||||
throw new LogicException('Object not found');
|
||||
}
|
||||
}
|
||||
|
||||
return $this->storedObjectRepository->find($data['id']);
|
||||
$storedObject->setTitle($data['title'] ?? $storedObject->getTitle());
|
||||
|
||||
if (true === ($data['currentVersion']['persisted'] ?? true)) {
|
||||
// nothing has change, stop here
|
||||
return $storedObject;
|
||||
}
|
||||
|
||||
if ([] !== $diff = array_diff(['filename', 'iv', 'keyInfos', 'type'], array_keys($data['currentVersion']))) {
|
||||
throw new TransformationFailedException(sprintf('missing some keys in currentVersion: %s', implode(', ', $diff)));
|
||||
}
|
||||
|
||||
$storedObject->registerVersion(
|
||||
$data['currentVersion']['iv'],
|
||||
$data['currentVersion']['keyInfos'],
|
||||
$data['currentVersion']['type'],
|
||||
$data['currentVersion']['filename']
|
||||
);
|
||||
|
||||
return $storedObject;
|
||||
}
|
||||
|
||||
public function supportsDenormalization($data, $type, $format = null)
|
||||
public function supportsDenormalization($data, $type, $format = null): bool
|
||||
{
|
||||
if (false === \is_array($data)) {
|
||||
if (StoredObject::class !== $type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (false === \array_key_exists('id', $data)) {
|
||||
if (false === is_array($data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return StoredObject::class === $type;
|
||||
if (array_key_exists('id', $data) || array_key_exists('uuid', $data)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
@@ -16,6 +16,7 @@ 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;
|
||||
@@ -28,30 +29,27 @@ use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||
final class StoredObjectNormalizer implements NormalizerInterface, NormalizerAwareInterface
|
||||
{
|
||||
use NormalizerAwareTrait;
|
||||
public const ADD_DAV_SEE_LINK_CONTEXT = 'dav-see-link-context';
|
||||
public const ADD_DAV_EDIT_LINK_CONTEXT = 'dav-edit-link-context';
|
||||
|
||||
public function __construct(
|
||||
private readonly JWTDavTokenProviderInterface $JWTDavTokenProvider,
|
||||
private readonly UrlGeneratorInterface $urlGenerator,
|
||||
private readonly Security $security
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function normalize($object, ?string $format = null, array $context = [])
|
||||
{
|
||||
/** @var StoredObject $object */
|
||||
$datas = [
|
||||
'datas' => $object->getDatas(),
|
||||
'filename' => $object->getFilename(),
|
||||
'id' => $object->getId(),
|
||||
'iv' => $object->getIv(),
|
||||
'keyInfos' => $object->getKeyInfos(),
|
||||
'datas' => $object->getDatas(),
|
||||
'prefix' => $object->getPrefix(),
|
||||
'title' => $object->getTitle(),
|
||||
'type' => $object->getType(),
|
||||
'uuid' => $object->getUuid(),
|
||||
'uuid' => $object->getUuid()->toString(),
|
||||
'status' => $object->getStatus(),
|
||||
'createdAt' => $this->normalizer->normalize($object->getCreatedAt(), $format, $context),
|
||||
'createdBy' => $this->normalizer->normalize($object->getCreatedBy(), $format, $context),
|
||||
'currentVersion' => $this->normalizer->normalize($object->getCurrentVersion(), $format, [...$context, [AbstractNormalizer::GROUPS => 'read']]),
|
||||
'totalVersions' => $object->getVersions()->count(),
|
||||
];
|
||||
|
||||
// deprecated property
|
||||
@@ -60,6 +58,11 @@ final class StoredObjectNormalizer implements NormalizerInterface, NormalizerAwa
|
||||
$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,
|
||||
@@ -76,7 +79,7 @@ final class StoredObjectNormalizer implements NormalizerInterface, NormalizerAwa
|
||||
],
|
||||
UrlGeneratorInterface::ABSOLUTE_URL,
|
||||
),
|
||||
'expiration' => $this->JWTDavTokenProvider->getTokenExpiration($accessToken)->format('U'),
|
||||
'expiration' => $this->JWTDavTokenProvider->getTokenExpiration($accessToken)->getTimestamp(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\DocStoreBundle\Serializer\Normalizer;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||
|
||||
class StoredObjectVersionNormalizer implements NormalizerInterface, NormalizerAwareInterface
|
||||
{
|
||||
use NormalizerAwareTrait;
|
||||
|
||||
public function normalize($object, ?string $format = null, array $context = [])
|
||||
{
|
||||
if (!$object instanceof StoredObjectVersion) {
|
||||
throw new \InvalidArgumentException('The object must be an instance of '.StoredObjectVersion::class);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $object->getId(),
|
||||
'filename' => $object->getFilename(),
|
||||
'version' => $object->getVersion(),
|
||||
'iv' => array_values($object->getIv()),
|
||||
'keyInfos' => $object->getKeyInfos(),
|
||||
'type' => $object->getType(),
|
||||
'createdAt' => $this->normalizer->normalize($object->getCreatedAt(), $format, $context),
|
||||
'createdBy' => $this->normalizer->normalize($object->getCreatedBy(), $format, $context),
|
||||
];
|
||||
}
|
||||
|
||||
public function supportsNormalization($data, ?string $format = null, array $context = [])
|
||||
{
|
||||
return $data instanceof StoredObjectVersion;
|
||||
}
|
||||
}
|
@@ -18,6 +18,7 @@ final readonly class PdfSignedMessage
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $signatureId,
|
||||
public readonly string $content
|
||||
public readonly int $signatureZoneIndex,
|
||||
public readonly string $content,
|
||||
) {}
|
||||
}
|
||||
|
@@ -55,6 +55,7 @@ final readonly class PdfSignedMessageHandler implements MessageHandlerInterface
|
||||
$this->storedObjectManager->write($storedObject, $message->content);
|
||||
|
||||
$signature->setState(EntityWorkflowSignatureStateEnum::SIGNED)->setStateDate($this->clock->now());
|
||||
$signature->setZoneSignatureIndex($message->signatureZoneIndex);
|
||||
$this->entityManager->flush();
|
||||
$this->entityManager->clear();
|
||||
}
|
||||
|
@@ -40,7 +40,7 @@ final readonly class PdfSignedMessageSerializer implements SerializerInterface
|
||||
throw new MessageDecodingFailedException('Invalid character found in the base64 encoded content');
|
||||
}
|
||||
|
||||
$message = new PdfSignedMessage($decoded['signatureId'], $content);
|
||||
$message = new PdfSignedMessage($decoded['signatureId'], $decoded['signatureZoneIndex'], $content);
|
||||
|
||||
return new Envelope($message);
|
||||
}
|
||||
@@ -55,6 +55,7 @@ final readonly class PdfSignedMessageSerializer implements SerializerInterface
|
||||
|
||||
$data = [
|
||||
'signatureId' => $message->signatureId,
|
||||
'signatureZoneIndex' => $message->signatureZoneIndex,
|
||||
'content' => base64_encode($message->content),
|
||||
];
|
||||
|
||||
|
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\DocStoreBundle\Service\Signature;
|
||||
|
||||
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep;
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature;
|
||||
use Chill\MainBundle\Workflow\EntityWorkflowManager;
|
||||
|
||||
class PDFSignatureZoneAvailable
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityWorkflowManager $entityWorkflowManager,
|
||||
private readonly PDFSignatureZoneParser $pdfSignatureZoneParser,
|
||||
private readonly StoredObjectManagerInterface $storedObjectManager,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return list<PDFSignatureZone>
|
||||
*/
|
||||
public function getAvailableSignatureZones(EntityWorkflow $entityWorkflow): array
|
||||
{
|
||||
$storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($entityWorkflow);
|
||||
|
||||
if (null === $storedObject) {
|
||||
throw new \RuntimeException('No stored object found');
|
||||
}
|
||||
|
||||
if ('application/pdf' !== $storedObject->getType()) {
|
||||
throw new \RuntimeException('Only PDF documents are supported');
|
||||
}
|
||||
|
||||
$zones = $this->pdfSignatureZoneParser->findSignatureZones($this->storedObjectManager->read($storedObject));
|
||||
$signatureZonesIndexes = array_map(
|
||||
fn (EntityWorkflowStepSignature $step) => $step->getZoneSignatureIndex(),
|
||||
$this->collectSignaturesInUse($entityWorkflow)
|
||||
);
|
||||
|
||||
return array_values(array_filter($zones, fn (PDFSignatureZone $zone) => !in_array($zone->index, $signatureZonesIndexes, true)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<EntityWorkflowStepSignature>
|
||||
*/
|
||||
private function collectSignaturesInUse(EntityWorkflow $entityWorkflow): array
|
||||
{
|
||||
return array_reduce($entityWorkflow->getSteps()->toArray(), function (array $result, EntityWorkflowStep $step) {
|
||||
$current = [...$result];
|
||||
foreach ($step->getSignatures() as $signature) {
|
||||
if (EntityWorkflowSignatureStateEnum::SIGNED === $signature->getState()) {
|
||||
$current[] = $signature;
|
||||
}
|
||||
}
|
||||
|
||||
return $current;
|
||||
}, []);
|
||||
}
|
||||
}
|
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\DocStoreBundle\Service\StoredObjectCleaner;
|
||||
|
||||
use Chill\DocStoreBundle\Repository\StoredObjectRepositoryInterface;
|
||||
use Chill\MainBundle\Cron\CronJobInterface;
|
||||
use Chill\MainBundle\Entity\CronJobExecution;
|
||||
use Symfony\Component\Clock\ClockInterface;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/**
|
||||
* Represents a cron job that removes expired stored objects.
|
||||
*
|
||||
* This cronjob is executed every 7days, to remove expired stored object. For every
|
||||
* expired stored object, every version is sent to message bus for async deletion.
|
||||
*/
|
||||
final readonly class RemoveExpiredStoredObjectCronJob implements CronJobInterface
|
||||
{
|
||||
public const KEY = 'remove-expired-stored-object';
|
||||
|
||||
private const LAST_DELETED_KEY = 'last-deleted-stored-object-id';
|
||||
|
||||
public function __construct(
|
||||
private ClockInterface $clock,
|
||||
private MessageBusInterface $messageBus,
|
||||
private StoredObjectRepositoryInterface $storedObjectRepository,
|
||||
) {}
|
||||
|
||||
public function canRun(?CronJobExecution $cronJobExecution): bool
|
||||
{
|
||||
if (null === $cronJobExecution) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->clock->now() >= $cronJobExecution->getLastEnd()->add(new \DateInterval('P7D'));
|
||||
}
|
||||
|
||||
public function getKey(): string
|
||||
{
|
||||
return self::KEY;
|
||||
}
|
||||
|
||||
public function run(array $lastExecutionData): ?array
|
||||
{
|
||||
$lastDeleted = $lastExecutionData[self::LAST_DELETED_KEY] ?? 0;
|
||||
|
||||
foreach ($this->storedObjectRepository->findByExpired($this->clock->now()) as $storedObject) {
|
||||
foreach ($storedObject->getVersions() as $version) {
|
||||
$this->messageBus->dispatch(new RemoveOldVersionMessage($version->getId()));
|
||||
}
|
||||
$lastDeleted = max($lastDeleted, $storedObject->getId());
|
||||
}
|
||||
|
||||
return [self::LAST_DELETED_KEY => $lastDeleted];
|
||||
}
|
||||
}
|
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\DocStoreBundle\Service\StoredObjectCleaner;
|
||||
|
||||
use Chill\DocStoreBundle\Repository\StoredObjectVersionRepository;
|
||||
use Chill\MainBundle\Cron\CronJobInterface;
|
||||
use Chill\MainBundle\Entity\CronJobExecution;
|
||||
use Symfony\Component\Clock\ClockInterface;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
final readonly class RemoveOldVersionCronJob implements CronJobInterface
|
||||
{
|
||||
public const KEY = 'remove-old-stored-object-version';
|
||||
|
||||
private const LAST_DELETED_KEY = 'last-deleted-stored-object-version-id';
|
||||
|
||||
public const KEEP_INTERVAL = 'P90D';
|
||||
|
||||
public function __construct(
|
||||
private ClockInterface $clock,
|
||||
private MessageBusInterface $messageBus,
|
||||
private StoredObjectVersionRepository $storedObjectVersionRepository,
|
||||
) {}
|
||||
|
||||
public function canRun(?CronJobExecution $cronJobExecution): bool
|
||||
{
|
||||
if (null === $cronJobExecution) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->clock->now() >= $cronJobExecution->getLastEnd()->add(new \DateInterval('P1D'));
|
||||
}
|
||||
|
||||
public function getKey(): string
|
||||
{
|
||||
return self::KEY;
|
||||
}
|
||||
|
||||
public function run(array $lastExecutionData): ?array
|
||||
{
|
||||
$deleteBeforeDate = $this->clock->now()->sub(new \DateInterval(self::KEEP_INTERVAL));
|
||||
$maxDeleted = $lastExecutionData[self::LAST_DELETED_KEY] ?? 0;
|
||||
|
||||
foreach ($this->storedObjectVersionRepository->findIdsByVersionsOlderThanDateAndNotLastVersion($deleteBeforeDate) as $id) {
|
||||
$this->messageBus->dispatch(new RemoveOldVersionMessage($id));
|
||||
$maxDeleted = max($maxDeleted, $id);
|
||||
}
|
||||
|
||||
return [self::LAST_DELETED_KEY => $maxDeleted];
|
||||
}
|
||||
}
|
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\DocStoreBundle\Service\StoredObjectCleaner;
|
||||
|
||||
final readonly class RemoveOldVersionMessage
|
||||
{
|
||||
public function __construct(
|
||||
public int $storedObjectVersionId,
|
||||
) {}
|
||||
}
|
@@ -0,0 +1,72 @@
|
||||
<?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\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);
|
||||
$storedObject = $storedObjectVersion->getStoredObject();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
$this->storedObjectManager->delete($storedObjectVersion);
|
||||
// to ensure an immediate deletion
|
||||
$this->entityManager->remove($storedObjectVersion);
|
||||
|
||||
if (StoredObject::canBeDeleted($this->clock->now(), $storedObject)) {
|
||||
$this->entityManager->remove($storedObject);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
// clear the entity manager for future usage
|
||||
$this->entityManager->clear();
|
||||
}
|
||||
}
|
@@ -14,6 +14,7 @@ namespace Chill\DocStoreBundle\Service;
|
||||
use Base64Url\Base64Url;
|
||||
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
|
||||
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -29,13 +30,23 @@ final class StoredObjectManager implements StoredObjectManagerInterface
|
||||
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $client,
|
||||
private readonly TempUrlGeneratorInterface $tempUrlGenerator
|
||||
private readonly TempUrlGeneratorInterface $tempUrlGenerator,
|
||||
) {}
|
||||
|
||||
public function getLastModified(StoredObject $document): \DateTimeInterface
|
||||
public function getLastModified(StoredObject|StoredObjectVersion $document): \DateTimeInterface
|
||||
{
|
||||
if ($this->hasCache($document)) {
|
||||
$response = $this->getResponseFromCache($document);
|
||||
$version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;
|
||||
|
||||
if (null !== $createdAt = $version->getCreatedAt()) {
|
||||
// as a createdAt datetime is set, return the date and time from database
|
||||
return $createdAt;
|
||||
}
|
||||
|
||||
// if no createdAt version exists in the database, we fetch the date and time from the
|
||||
// file. This situation happens for files created before July 2024.
|
||||
|
||||
if ($this->hasCache($version)) {
|
||||
$response = $this->getResponseFromCache($version);
|
||||
} else {
|
||||
try {
|
||||
$response = $this
|
||||
@@ -46,7 +57,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface
|
||||
->tempUrlGenerator
|
||||
->generate(
|
||||
Request::METHOD_HEAD,
|
||||
$document->getFilename()
|
||||
$version->getFilename()
|
||||
)
|
||||
->url
|
||||
);
|
||||
@@ -58,11 +69,13 @@ final class StoredObjectManager implements StoredObjectManagerInterface
|
||||
return $this->extractLastModifiedFromResponse($response);
|
||||
}
|
||||
|
||||
public function getContentLength(StoredObject $document): int
|
||||
public function getContentLength(StoredObject|StoredObjectVersion $document): int
|
||||
{
|
||||
if ([] === $document->getKeyInfos()) {
|
||||
if ($this->hasCache($document)) {
|
||||
$response = $this->getResponseFromCache($document);
|
||||
$version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;
|
||||
|
||||
if (!$this->isVersionEncrypted($version)) {
|
||||
if ($this->hasCache($version)) {
|
||||
$response = $this->getResponseFromCache($version);
|
||||
} else {
|
||||
try {
|
||||
$response = $this
|
||||
@@ -73,7 +86,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface
|
||||
->tempUrlGenerator
|
||||
->generate(
|
||||
Request::METHOD_HEAD,
|
||||
$document->getFilename()
|
||||
$version->getFilename()
|
||||
)
|
||||
->url
|
||||
);
|
||||
@@ -88,10 +101,43 @@ final class StoredObjectManager implements StoredObjectManagerInterface
|
||||
return strlen($this->read($document));
|
||||
}
|
||||
|
||||
public function etag(StoredObject $document): string
|
||||
/**
|
||||
* @throws TransportExceptionInterface
|
||||
* @throws StoredObjectManagerException
|
||||
*/
|
||||
public function exists(StoredObject|StoredObjectVersion $document): bool
|
||||
{
|
||||
if ($this->hasCache($document)) {
|
||||
$response = $this->getResponseFromCache($document);
|
||||
$version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;
|
||||
|
||||
if ($this->hasCache($version)) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
$response = $this
|
||||
->client
|
||||
->request(
|
||||
Request::METHOD_HEAD,
|
||||
$this
|
||||
->tempUrlGenerator
|
||||
->generate(
|
||||
Request::METHOD_HEAD,
|
||||
$version->getFilename()
|
||||
)
|
||||
->url
|
||||
);
|
||||
|
||||
return 200 === $response->getStatusCode();
|
||||
} catch (TransportExceptionInterface $exception) {
|
||||
throw StoredObjectManagerException::errorDuringHttpRequest($exception);
|
||||
}
|
||||
}
|
||||
|
||||
public function etag(StoredObject|StoredObjectVersion $document): string
|
||||
{
|
||||
$version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;
|
||||
|
||||
if ($this->hasCache($version)) {
|
||||
$response = $this->getResponseFromCache($version);
|
||||
} else {
|
||||
try {
|
||||
$response = $this
|
||||
@@ -102,7 +148,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface
|
||||
->tempUrlGenerator
|
||||
->generate(
|
||||
Request::METHOD_HEAD,
|
||||
$document->getFilename()
|
||||
$version->getFilename()
|
||||
)
|
||||
->url
|
||||
);
|
||||
@@ -111,12 +157,14 @@ final class StoredObjectManager implements StoredObjectManagerInterface
|
||||
}
|
||||
}
|
||||
|
||||
return $this->extractEtagFromResponse($response, $document);
|
||||
return $this->extractEtagFromResponse($response);
|
||||
}
|
||||
|
||||
public function read(StoredObject $document): string
|
||||
public function read(StoredObject|StoredObjectVersion $document, ?int $version = null): string
|
||||
{
|
||||
$response = $this->getResponseFromCache($document);
|
||||
$version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;
|
||||
|
||||
$response = $this->getResponseFromCache($version);
|
||||
|
||||
try {
|
||||
$data = $response->getContent();
|
||||
@@ -124,7 +172,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface
|
||||
throw StoredObjectManagerException::unableToGetResponseContent($e);
|
||||
}
|
||||
|
||||
if (false === $this->hasKeysAndIv($document)) {
|
||||
if (!$this->isVersionEncrypted($version)) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
@@ -132,9 +180,9 @@ final class StoredObjectManager implements StoredObjectManagerInterface
|
||||
$data,
|
||||
self::ALGORITHM,
|
||||
// TODO: Why using this library and not use base64_decode() ?
|
||||
Base64Url::decode($document->getKeyInfos()['k']),
|
||||
Base64Url::decode($version->getKeyInfos()['k']),
|
||||
\OPENSSL_RAW_DATA,
|
||||
pack('C*', ...$document->getIv())
|
||||
pack('C*', ...$version->getIv())
|
||||
);
|
||||
|
||||
if (false === $clearData) {
|
||||
@@ -144,20 +192,25 @@ final class StoredObjectManager implements StoredObjectManagerInterface
|
||||
return $clearData;
|
||||
}
|
||||
|
||||
public function write(StoredObject $document, string $clearContent): void
|
||||
public function write(StoredObject $document, string $clearContent, ?string $contentType = null): StoredObjectVersion
|
||||
{
|
||||
if ($this->hasCache($document)) {
|
||||
unset($this->inMemory[$document->getUuid()->toString()]);
|
||||
}
|
||||
$newIv = $document->getIv();
|
||||
$newKey = $document->getKeyInfos();
|
||||
$newType = $contentType ?? $document->getType();
|
||||
$version = $document->registerVersion(
|
||||
$newIv,
|
||||
$newKey,
|
||||
$newType
|
||||
);
|
||||
|
||||
$encryptedContent = $this->hasKeysAndIv($document)
|
||||
$encryptedContent = $this->isVersionEncrypted($version)
|
||||
? openssl_encrypt(
|
||||
$clearContent,
|
||||
self::ALGORITHM,
|
||||
// TODO: Why using this library and not use base64_decode() ?
|
||||
Base64Url::decode($document->getKeyInfos()['k']),
|
||||
Base64Url::decode($version->getKeyInfos()['k']),
|
||||
\OPENSSL_RAW_DATA,
|
||||
pack('C*', ...$document->getIv())
|
||||
pack('C*', ...$version->getIv())
|
||||
)
|
||||
: $clearContent;
|
||||
|
||||
@@ -176,7 +229,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface
|
||||
->tempUrlGenerator
|
||||
->generate(
|
||||
Request::METHOD_PUT,
|
||||
$document->getFilename()
|
||||
$version->getFilename()
|
||||
)
|
||||
->url,
|
||||
[
|
||||
@@ -191,6 +244,29 @@ final class StoredObjectManager implements StoredObjectManagerInterface
|
||||
if (Response::HTTP_CREATED !== $response->getStatusCode()) {
|
||||
throw StoredObjectManagerException::invalidStatusCode($response->getStatusCode());
|
||||
}
|
||||
|
||||
$this->clearCache();
|
||||
|
||||
return $version;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws StoredObjectManagerException
|
||||
*/
|
||||
public function delete(StoredObjectVersion $storedObjectVersion): void
|
||||
{
|
||||
$signedUrl = $this->tempUrlGenerator->generate('DELETE', $storedObjectVersion->getFilename());
|
||||
|
||||
try {
|
||||
$response = $this->client->request('DELETE', $signedUrl->url);
|
||||
if (! (Response::HTTP_NO_CONTENT === $response->getStatusCode() || Response::HTTP_NOT_FOUND === $response->getStatusCode())) {
|
||||
throw StoredObjectManagerException::invalidStatusCode($response->getStatusCode());
|
||||
}
|
||||
|
||||
$storedObjectVersion->getStoredObject()->removeVersion($storedObjectVersion);
|
||||
} catch (TransportExceptionInterface $exception) {
|
||||
throw StoredObjectManagerException::errorDuringHttpRequest($exception);
|
||||
}
|
||||
}
|
||||
|
||||
public function clearCache(): void
|
||||
@@ -215,12 +291,19 @@ final class StoredObjectManager implements StoredObjectManagerInterface
|
||||
return $date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the content length from a ResponseInterface object.
|
||||
*
|
||||
* Does work only if the object is not encrypted.
|
||||
*
|
||||
* @return int the extracted content length as an integer
|
||||
*/
|
||||
private function extractContentLengthFromResponse(ResponseInterface $response): int
|
||||
{
|
||||
return (int) ($response->getHeaders()['content-length'] ?? ['0'])[0];
|
||||
}
|
||||
|
||||
private function extractEtagFromResponse(ResponseInterface $response, StoredObject $storedObject): ?string
|
||||
private function extractEtagFromResponse(ResponseInterface $response): ?string
|
||||
{
|
||||
$etag = ($response->getHeaders()['etag'] ?? [''])[0];
|
||||
|
||||
@@ -231,7 +314,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface
|
||||
return $etag;
|
||||
}
|
||||
|
||||
private function fillCache(StoredObject $document): void
|
||||
private function fillCache(StoredObjectVersion $document): void
|
||||
{
|
||||
try {
|
||||
$response = $this
|
||||
@@ -254,25 +337,30 @@ final class StoredObjectManager implements StoredObjectManagerInterface
|
||||
throw StoredObjectManagerException::invalidStatusCode($response->getStatusCode());
|
||||
}
|
||||
|
||||
$this->inMemory[$document->getUuid()->toString()] = $response;
|
||||
$this->inMemory[$this->buildCacheKey($document)] = $response;
|
||||
}
|
||||
|
||||
private function getResponseFromCache(StoredObject $document): ResponseInterface
|
||||
private function buildCacheKey(StoredObjectVersion $storedObjectVersion): string
|
||||
{
|
||||
return $storedObjectVersion->getStoredObject()->getUuid()->toString().$storedObjectVersion->getId();
|
||||
}
|
||||
|
||||
private function getResponseFromCache(StoredObjectVersion $document): ResponseInterface
|
||||
{
|
||||
if (!$this->hasCache($document)) {
|
||||
$this->fillCache($document);
|
||||
}
|
||||
|
||||
return $this->inMemory[$document->getUuid()->toString()];
|
||||
return $this->inMemory[$this->buildCacheKey($document)];
|
||||
}
|
||||
|
||||
private function hasCache(StoredObject $document): bool
|
||||
private function hasCache(StoredObjectVersion $document): bool
|
||||
{
|
||||
return \array_key_exists($document->getUuid()->toString(), $this->inMemory);
|
||||
return \array_key_exists($this->buildCacheKey($document), $this->inMemory);
|
||||
}
|
||||
|
||||
private function hasKeysAndIv(StoredObject $storedObject): bool
|
||||
private function isVersionEncrypted(StoredObjectVersion $storedObjectVersion): bool
|
||||
{
|
||||
return ([] !== $storedObject->getKeyInfos()) && ([] !== $storedObject->getIv());
|
||||
return ([] !== $storedObjectVersion->getKeyInfos()) && ([] !== $storedObjectVersion->getIv());
|
||||
}
|
||||
}
|
||||
|
@@ -12,36 +12,74 @@ declare(strict_types=1);
|
||||
namespace Chill\DocStoreBundle\Service;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
|
||||
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
|
||||
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
|
||||
|
||||
interface StoredObjectManagerInterface
|
||||
{
|
||||
public function getLastModified(StoredObject $document): \DateTimeInterface;
|
||||
/**
|
||||
* @param StoredObject|StoredObjectVersion $document if a StoredObject is given, the last version will be used
|
||||
*/
|
||||
public function getLastModified(StoredObject|StoredObjectVersion $document): \DateTimeInterface;
|
||||
|
||||
public function getContentLength(StoredObject $document): int;
|
||||
/**
|
||||
* @param StoredObject|StoredObjectVersion $document if a StoredObject is given, the last version will be used
|
||||
*/
|
||||
public function getContentLength(StoredObject|StoredObjectVersion $document): int;
|
||||
|
||||
/**
|
||||
* @throws TransportExceptionInterface
|
||||
*/
|
||||
public function exists(StoredObject|StoredObjectVersion $document): bool;
|
||||
|
||||
/**
|
||||
* Get the content of a StoredObject.
|
||||
*
|
||||
* @param StoredObject $document the document
|
||||
* @param StoredObject|StoredObjectVersion $document if a StoredObject is given, the last version will be used
|
||||
*
|
||||
* @return string the retrieved content in clear
|
||||
*
|
||||
* @throws StoredObjectManagerException if unable to read or decrypt the content
|
||||
*/
|
||||
public function read(StoredObject $document): string;
|
||||
public function read(StoredObject|StoredObjectVersion $document): string;
|
||||
|
||||
/**
|
||||
* Set the content of a StoredObject.
|
||||
* Register the content of a new version for the StoredObject.
|
||||
*
|
||||
* The manager is also responsible for registering a version in the StoredObject, and return this version.
|
||||
*
|
||||
* @param StoredObject $document the document
|
||||
* @param $clearContent The content to store in clear
|
||||
* @param string $clearContent The content to store in clear
|
||||
* @param string|null $contentType The new content type. If set to null, the content-type is supposed not to change. If there is no content type, an empty string will be used.
|
||||
*
|
||||
* @return StoredObjectVersion the newly created @see{StoredObjectVersion} for the given @see{StoredObject}
|
||||
*
|
||||
* @throws StoredObjectManagerException
|
||||
*/
|
||||
public function write(StoredObject $document, string $clearContent): void;
|
||||
public function write(StoredObject $document, string $clearContent, ?string $contentType = null): StoredObjectVersion;
|
||||
|
||||
public function etag(StoredObject $document): string;
|
||||
/**
|
||||
* Remove a version from the storage.
|
||||
*
|
||||
* This method is also responsible for removing the version from the StoredObject (using @see{StoredObject::removeVersion})
|
||||
* in case of success.
|
||||
*
|
||||
* @throws StoredObjectManagerException
|
||||
*/
|
||||
public function delete(StoredObjectVersion $storedObjectVersion): void;
|
||||
|
||||
/**
|
||||
* return or compute the etag for the document.
|
||||
*
|
||||
* @param StoredObject|StoredObjectVersion $document if a StoredObject is given, the last version will be used
|
||||
*
|
||||
* @return string the etag of this document
|
||||
*/
|
||||
public function etag(StoredObject|StoredObjectVersion $document): string;
|
||||
|
||||
/**
|
||||
* Clears the cache for the stored object.
|
||||
*/
|
||||
public function clearCache(): void;
|
||||
}
|
||||
|
@@ -14,19 +14,37 @@ namespace AsyncUpload\Driver\OpenstackObjectStore;
|
||||
use Chill\DocStoreBundle\AsyncUpload\Driver\OpenstackObjectStore\TempUrlOpenstackGenerator;
|
||||
use Chill\DocStoreBundle\AsyncUpload\SignedUrl;
|
||||
use Chill\DocStoreBundle\AsyncUpload\SignedUrlPost;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Symfony\Component\Clock\MockClock;
|
||||
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag;
|
||||
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcher;
|
||||
use Symfony\Component\Mime\Part\DataPart;
|
||||
use Symfony\Component\Mime\Part\Multipart\FormDataPart;
|
||||
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class TempUrlOpenstackGeneratorTest extends TestCase
|
||||
class TempUrlOpenstackGeneratorTest extends KernelTestCase
|
||||
{
|
||||
private ParameterBagInterface $parameterBag;
|
||||
private HttpClientInterface $client;
|
||||
|
||||
private const TESTING_OBJECT_NAME_PREFIX = 'test-prefix-o0o008wk404gcos40k8s4s4c44cgwwos4k4o8k/';
|
||||
private const TESTING_OBJECT_NAME = 'object-name-4fI0iAtq';
|
||||
|
||||
private function setUpIntegration(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
$this->parameterBag = self::getContainer()->get(ParameterBagInterface::class);
|
||||
$this->client = self::getContainer()->get(HttpClientInterface::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider dataProviderGenerate
|
||||
*/
|
||||
@@ -175,4 +193,62 @@ class TempUrlOpenstackGeneratorTest extends TestCase
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @group openstack-integration
|
||||
*/
|
||||
public function testGeneratePostIntegration(): void
|
||||
{
|
||||
$this->setUpIntegration();
|
||||
$generator = new TempUrlOpenstackGenerator(new NullLogger(), new EventDispatcher(), new MockClock(), $this->parameterBag);
|
||||
|
||||
$signedUrl = $generator->generatePost(object_name: self::TESTING_OBJECT_NAME_PREFIX);
|
||||
$formData = new FormDataPart([
|
||||
'redirect', $signedUrl->redirect,
|
||||
'max_file_size' => (string) $signedUrl->max_file_size,
|
||||
'max_file_count' => (string) $signedUrl->max_file_count,
|
||||
'expires' => (string) $signedUrl->expires->getTimestamp(),
|
||||
'signature' => $signedUrl->signature,
|
||||
self::TESTING_OBJECT_NAME => DataPart::fromPath(
|
||||
__DIR__.'/file-to-upload.txt',
|
||||
self::TESTING_OBJECT_NAME
|
||||
),
|
||||
]);
|
||||
|
||||
$response = $this->client
|
||||
->request(
|
||||
'POST',
|
||||
$signedUrl->url,
|
||||
[
|
||||
'body' => $formData->bodyToString(),
|
||||
'headers' => $formData->getPreparedHeaders()->toArray(),
|
||||
]
|
||||
);
|
||||
|
||||
self::assertEquals(201, $response->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* @group openstack-integration
|
||||
*
|
||||
* @depends testGeneratePostIntegration
|
||||
*/
|
||||
public function testGenerateGetIntegration(): void
|
||||
{
|
||||
$this->setUpIntegration();
|
||||
$generator = new TempUrlOpenstackGenerator(new NullLogger(), new EventDispatcher(), new MockClock(), $this->parameterBag);
|
||||
|
||||
$signedUrl = $generator->generate('GET', self::TESTING_OBJECT_NAME_PREFIX.self::TESTING_OBJECT_NAME);
|
||||
|
||||
$response = $this->client->request('GET', $signedUrl->url);
|
||||
|
||||
self::assertEquals(200, $response->getStatusCode());
|
||||
|
||||
try {
|
||||
$content = $response->getContent();
|
||||
self::assertEquals(file_get_contents(__DIR__.'/file-to-upload.txt'), $content);
|
||||
} catch (HttpExceptionInterface $exception) {
|
||||
$this->fail('could not retrieve file content: '.$exception->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1 @@
|
||||
test file
|
@@ -70,7 +70,7 @@ class AsyncUploadExtensionTest extends KernelTestCase
|
||||
|
||||
public static function dataProviderStoredObject(): iterable
|
||||
{
|
||||
yield [(new StoredObject())->setFilename('blabla')];
|
||||
yield [(new StoredObject())->registerVersion(filename: 'blabla')->getStoredObject()];
|
||||
|
||||
yield ['blabla'];
|
||||
}
|
||||
|
@@ -15,7 +15,7 @@ use Chill\DocStoreBundle\AsyncUpload\SignedUrl;
|
||||
use Chill\DocStoreBundle\AsyncUpload\SignedUrlPost;
|
||||
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
|
||||
use Chill\DocStoreBundle\Controller\AsyncUploadController;
|
||||
use Chill\DocStoreBundle\Security\Authorization\AsyncUploadVoter;
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
@@ -34,46 +34,165 @@ class AsyncUploadControllerTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
public function testGenerateWhenUserIsNotGranted(): void
|
||||
public function testGetSignedUrlPost(): void
|
||||
{
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
$controller = $this->buildAsyncUploadController(false);
|
||||
$storedObject = new StoredObject();
|
||||
$security = $this->prophesize(Security::class);
|
||||
$security->isGranted('SEE_AND_EDIT', $storedObject)->willReturn(true)->shouldBeCalled();
|
||||
|
||||
$controller->getSignedUrl('POST', new Request());
|
||||
}
|
||||
$controller = new AsyncUploadController(
|
||||
$this->buildTempUrlGenerator(),
|
||||
$this->buildSerializer(),
|
||||
$security->reveal(),
|
||||
new NullLogger(),
|
||||
);
|
||||
|
||||
public function testGeneratePost(): void
|
||||
{
|
||||
$controller = $this->buildAsyncUploadController(true);
|
||||
$actual = $controller->getSignedUrlPost(new Request(query: ['expires_delay' => 10, 'submit_delay' => 1800]), $storedObject);
|
||||
|
||||
$actual = $controller->getSignedUrl('POST', new Request());
|
||||
$decodedActual = json_decode($actual->getContent(), true, JSON_THROW_ON_ERROR, JSON_THROW_ON_ERROR);
|
||||
|
||||
self::assertArrayHasKey('method', $decodedActual);
|
||||
self::assertEquals('POST', $decodedActual['method']);
|
||||
}
|
||||
|
||||
public function testGenerateGet(): void
|
||||
public function testGetSignedUrlGetSimpleScenarioHappy(): void
|
||||
{
|
||||
$controller = $this->buildAsyncUploadController(true);
|
||||
$storedObject = new StoredObject();
|
||||
$storedObject->registerVersion();
|
||||
$security = $this->prophesize(Security::class);
|
||||
$security->isGranted('SEE', $storedObject)->willReturn(true)->shouldBeCalled();
|
||||
|
||||
$controller = new AsyncUploadController(
|
||||
$this->buildTempUrlGenerator(),
|
||||
$this->buildSerializer(),
|
||||
$security->reveal(),
|
||||
new NullLogger(),
|
||||
);
|
||||
|
||||
$actual = $controller->getSignedUrlGet(new Request(query: ['expires_delay' => 10, 'submit_delay' => 1800]), $storedObject, 'get');
|
||||
|
||||
$actual = $controller->getSignedUrl('GET', new Request(['object_name' => 'abc']));
|
||||
$decodedActual = json_decode($actual->getContent(), true, JSON_THROW_ON_ERROR, JSON_THROW_ON_ERROR);
|
||||
|
||||
self::assertArrayHasKey('method', $decodedActual);
|
||||
self::assertEquals('GET', $decodedActual['method']);
|
||||
}
|
||||
|
||||
private function buildAsyncUploadController(
|
||||
bool $isGranted,
|
||||
): AsyncUploadController {
|
||||
$tempUrlGenerator = new class () implements TempUrlGeneratorInterface {
|
||||
public function generatePost(?int $expire_delay = null, ?int $submit_delay = null, int $max_file_count = 1): SignedUrlPost
|
||||
public function testGetSignedUrlGetSimpleScenarioNotAuthorized(): void
|
||||
{
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
|
||||
$storedObject = new StoredObject();
|
||||
$storedObject->registerVersion();
|
||||
$security = $this->prophesize(Security::class);
|
||||
$security->isGranted('SEE', $storedObject)->willReturn(false)->shouldBeCalled();
|
||||
|
||||
$controller = new AsyncUploadController(
|
||||
$this->buildTempUrlGenerator(),
|
||||
$this->buildSerializer(),
|
||||
$security->reveal(),
|
||||
new NullLogger(),
|
||||
);
|
||||
|
||||
$controller->getSignedUrlGet(new Request(query: ['expires_delay' => 10, 'submit_delay' => 1800]), $storedObject, 'get');
|
||||
}
|
||||
|
||||
public function testGetSignedUrlGetForSpecificVersionOfTheStoredObjectStoredInDatabase(): void
|
||||
{
|
||||
$storedObject = new StoredObject();
|
||||
$version = $storedObject->registerVersion();
|
||||
// we add a version to be sure that the we do not get the last one
|
||||
$storedObject->registerVersion();
|
||||
|
||||
$security = $this->prophesize(Security::class);
|
||||
$security->isGranted('SEE', $storedObject)->willReturn(true)->shouldBeCalled();
|
||||
|
||||
$controller = new AsyncUploadController(
|
||||
$this->buildTempUrlGenerator(),
|
||||
$this->buildSerializer(),
|
||||
$security->reveal(),
|
||||
new NullLogger(),
|
||||
);
|
||||
|
||||
$actual = $controller->getSignedUrlGet(
|
||||
new Request(query: ['expires_delay' => 10, 'submit_delay' => 1800, 'version' => $version->getFilename()]),
|
||||
$storedObject,
|
||||
'get'
|
||||
);
|
||||
|
||||
$decodedActual = json_decode($actual->getContent(), true, JSON_THROW_ON_ERROR, JSON_THROW_ON_ERROR);
|
||||
|
||||
self::assertArrayHasKey('method', $decodedActual);
|
||||
self::assertEquals('GET', $decodedActual['method']);
|
||||
self::assertArrayHasKey('object_name', $decodedActual);
|
||||
self::assertEquals($version->getFilename(), $decodedActual['object_name']);
|
||||
}
|
||||
|
||||
public function testGetSignedUrlGetForSpecificVersionOfTheStoredObjectNotYetStoredInDatabase(): void
|
||||
{
|
||||
$storedObject = new StoredObject();
|
||||
$storedObject->registerVersion();
|
||||
// we generate a valid name
|
||||
$version = $storedObject->getPrefix().'/some-version';
|
||||
|
||||
$security = $this->prophesize(Security::class);
|
||||
$security->isGranted('SEE', $storedObject)->willReturn(true)->shouldBeCalled();
|
||||
|
||||
$controller = new AsyncUploadController(
|
||||
$this->buildTempUrlGenerator(),
|
||||
$this->buildSerializer(),
|
||||
$security->reveal(),
|
||||
new NullLogger(),
|
||||
);
|
||||
|
||||
$actual = $controller->getSignedUrlGet(
|
||||
new Request(query: ['expires_delay' => 10, 'submit_delay' => 1800, 'version' => $version]),
|
||||
$storedObject,
|
||||
'get'
|
||||
);
|
||||
|
||||
$decodedActual = json_decode($actual->getContent(), true, JSON_THROW_ON_ERROR, JSON_THROW_ON_ERROR);
|
||||
|
||||
self::assertArrayHasKey('method', $decodedActual);
|
||||
self::assertEquals('GET', $decodedActual['method']);
|
||||
self::assertArrayHasKey('object_name', $decodedActual);
|
||||
self::assertEquals($version, $decodedActual['object_name']);
|
||||
}
|
||||
|
||||
public function testGetSignedUrlGetForSpecificVersionNotBelongingToTheStoreObject(): void
|
||||
{
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
|
||||
$storedObject = new StoredObject();
|
||||
$storedObject->registerVersion();
|
||||
// we generate a random version
|
||||
$version = 'something/else';
|
||||
|
||||
$security = $this->prophesize(Security::class);
|
||||
$security->isGranted('SEE', $storedObject)->willReturn(true)->shouldBeCalled();
|
||||
|
||||
$controller = new AsyncUploadController(
|
||||
$this->buildTempUrlGenerator(),
|
||||
$this->buildSerializer(),
|
||||
$security->reveal(),
|
||||
new NullLogger(),
|
||||
);
|
||||
|
||||
$controller->getSignedUrlGet(
|
||||
new Request(query: ['expires_delay' => 10, 'submit_delay' => 1800, 'version' => $version]),
|
||||
$storedObject,
|
||||
'get'
|
||||
);
|
||||
}
|
||||
|
||||
public function buildTempUrlGenerator(): TempUrlGeneratorInterface
|
||||
{
|
||||
return new class () implements TempUrlGeneratorInterface {
|
||||
public function generatePost(?int $expire_delay = null, ?int $submit_delay = null, int $max_file_count = 1, ?string $object_name = null): SignedUrlPost
|
||||
{
|
||||
return new SignedUrlPost(
|
||||
'https://object.store.example',
|
||||
new \DateTimeImmutable('1 hour'),
|
||||
'abc',
|
||||
$object_name ?? 'abc',
|
||||
150,
|
||||
1,
|
||||
1800,
|
||||
@@ -86,27 +205,25 @@ class AsyncUploadControllerTest extends TestCase
|
||||
public function generate(string $method, string $object_name, ?int $expire_delay = null): SignedUrl
|
||||
{
|
||||
return new SignedUrl(
|
||||
$method,
|
||||
strtoupper($method),
|
||||
'https://object.store.example',
|
||||
new \DateTimeImmutable('1 hour'),
|
||||
$object_name
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private function buildSerializer(): SerializerInterface
|
||||
{
|
||||
$serializer = $this->prophesize(SerializerInterface::class);
|
||||
$serializer->serialize(Argument::type(SignedUrl::class), 'json', Argument::type('array'))
|
||||
->will(fn (array $args): string => json_encode(['method' => $args[0]->method], JSON_THROW_ON_ERROR, 3));
|
||||
->will(fn (array $args): string => json_encode(
|
||||
['method' => $args[0]->method, 'object_name' => $args[0]->object_name],
|
||||
JSON_THROW_ON_ERROR,
|
||||
3
|
||||
));
|
||||
|
||||
$security = $this->prophesize(Security::class);
|
||||
$security->isGranted(AsyncUploadVoter::GENERATE_SIGNATURE, Argument::type(SignedUrl::class))
|
||||
->willReturn($isGranted);
|
||||
|
||||
return new AsyncUploadController(
|
||||
$tempUrlGenerator,
|
||||
$serializer->reveal(),
|
||||
$security->reveal(),
|
||||
new NullLogger()
|
||||
);
|
||||
return $serializer->reveal();
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\DocStoreBundle\Tests\Controller;
|
||||
|
||||
use Chill\DocStoreBundle\Controller\StoredObjectApiController;
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class StoredObjectApiControllerTest extends TestCase
|
||||
{
|
||||
public function testCreate(): void
|
||||
{
|
||||
$security = $this->createMock(Security::class);
|
||||
$security->expects($this->atLeastOnce())->method('isGranted')
|
||||
->with($this->logicalOr($this->identicalTo('ROLE_ADMIN'), $this->identicalTo('ROLE_USER')))
|
||||
->willReturn(true)
|
||||
;
|
||||
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$entityManager->expects($this->once())->method('persist')->with($this->isInstanceOf(StoredObject::class));
|
||||
$entityManager->expects($this->once())->method('flush');
|
||||
|
||||
$serializer = $this->createMock(SerializerInterface::class);
|
||||
$serializer->expects($this->once())->method('serialize')
|
||||
->with($this->isInstanceOf(StoredObject::class), 'json', $this->anything())
|
||||
->willReturn($r = <<<'JSON'
|
||||
{"type": "stored-object", "id": 1}
|
||||
JSON);
|
||||
|
||||
$controller = new StoredObjectApiController($security, $serializer, $entityManager);
|
||||
|
||||
$actual = $controller->createStoredObject();
|
||||
|
||||
self::assertInstanceOf(JsonResponse::class, $actual);
|
||||
self::assertEquals($r, $actual->getContent());
|
||||
}
|
||||
}
|
@@ -13,7 +13,9 @@ namespace Chill\DocStoreBundle\Tests\Controller;
|
||||
|
||||
use Chill\DocStoreBundle\Controller\WebdavController;
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
|
||||
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
@@ -39,20 +41,28 @@ class WebdavControllerTest extends KernelTestCase
|
||||
$this->engine = self::getContainer()->get(\Twig\Environment::class);
|
||||
}
|
||||
|
||||
private function buildController(): WebdavController
|
||||
private function buildController(?EntityManagerInterface $entityManager = null, ?StoredObjectManagerInterface $storedObjectManager = null): WebdavController
|
||||
{
|
||||
$storedObjectManager = new MockedStoredObjectManager();
|
||||
if (null === $storedObjectManager) {
|
||||
$storedObjectManager = new MockedStoredObjectManager();
|
||||
}
|
||||
|
||||
if (null === $entityManager) {
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
}
|
||||
|
||||
$security = $this->prophesize(Security::class);
|
||||
$security->isGranted(Argument::in(['EDIT', 'SEE']), Argument::type(StoredObject::class))
|
||||
$security->isGranted(Argument::in(['SEE_AND_EDIT', 'SEE']), Argument::type(StoredObject::class))
|
||||
->willReturn(true);
|
||||
|
||||
return new WebdavController($this->engine, $storedObjectManager, $security->reveal());
|
||||
return new WebdavController($this->engine, $storedObjectManager, $security->reveal(), $entityManager);
|
||||
}
|
||||
|
||||
private function buildDocument(): StoredObject
|
||||
{
|
||||
$object = (new StoredObject())
|
||||
->setType('application/vnd.oasis.opendocument.text');
|
||||
->registerVersion(type: 'application/vnd.oasis.opendocument.text')
|
||||
->getStoredObject();
|
||||
|
||||
$reflectionObject = new \ReflectionClass($object);
|
||||
$reflectionObjectUuid = $reflectionObject->getProperty('uuid');
|
||||
@@ -159,6 +169,30 @@ class WebdavControllerTest extends KernelTestCase
|
||||
self::assertEquals(5, $response->headers->get('content-length'));
|
||||
}
|
||||
|
||||
public function testPutDocument(): void
|
||||
{
|
||||
$document = $this->buildDocument();
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$storedObjectManager = $this->createMock(StoredObjectManagerInterface::class);
|
||||
|
||||
// entity manager must be flushed
|
||||
$entityManager->expects($this->once())
|
||||
->method('flush');
|
||||
|
||||
// object must be written by StoredObjectManager
|
||||
$storedObjectManager->expects($this->once())
|
||||
->method('write')
|
||||
->with($this->identicalTo($document), $this->identicalTo('1234'));
|
||||
|
||||
$controller = $this->buildController($entityManager, $storedObjectManager);
|
||||
|
||||
$request = new Request(content: '1234');
|
||||
$response = $controller->putDocument($document, $request);
|
||||
|
||||
self::assertEquals(204, $response->getStatusCode());
|
||||
self::assertEquals('', $response->getContent());
|
||||
}
|
||||
|
||||
public static function generateDataPropfindDocument(): iterable
|
||||
{
|
||||
$content =
|
||||
@@ -384,27 +418,32 @@ class WebdavControllerTest extends KernelTestCase
|
||||
|
||||
class MockedStoredObjectManager implements StoredObjectManagerInterface
|
||||
{
|
||||
public function getLastModified(StoredObject $document): \DateTimeInterface
|
||||
public function getLastModified(StoredObject|StoredObjectVersion $document): \DateTimeInterface
|
||||
{
|
||||
return new \DateTimeImmutable('2023-09-13T14:15');
|
||||
return new \DateTimeImmutable('2023-09-13T14:15', new \DateTimeZone('+02:00'));
|
||||
}
|
||||
|
||||
public function getContentLength(StoredObject $document): int
|
||||
public function getContentLength(StoredObject|StoredObjectVersion $document): int
|
||||
{
|
||||
return 5;
|
||||
}
|
||||
|
||||
public function read(StoredObject $document): string
|
||||
public function read(StoredObject|StoredObjectVersion $document): string
|
||||
{
|
||||
return 'abcde';
|
||||
}
|
||||
|
||||
public function write(StoredObject $document, string $clearContent): void {}
|
||||
public function write(StoredObject $document, string $clearContent, ?string $contentType = null): StoredObjectVersion
|
||||
{
|
||||
return $document->registerVersion();
|
||||
}
|
||||
|
||||
public function etag(StoredObject $document): string
|
||||
public function etag(StoredObject|StoredObjectVersion $document): string
|
||||
{
|
||||
return 'ab56b4d92b40713acc5af89985d4b786';
|
||||
}
|
||||
|
||||
public function clearCache(): void {}
|
||||
|
||||
public function delete(StoredObjectVersion $storedObjectVersion): void {}
|
||||
}
|
||||
|
@@ -21,33 +21,37 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
*/
|
||||
class StoredObjectTest extends KernelTestCase
|
||||
{
|
||||
public function testSaveHistory(): void
|
||||
public function testRegisterVersion(): void
|
||||
{
|
||||
$storedObject = new StoredObject();
|
||||
$storedObject
|
||||
->setFilename('test_0')
|
||||
->setIv([2, 4, 6, 8])
|
||||
->setKeyInfos(['key' => ['data0' => 'data0']])
|
||||
->setType('text/html');
|
||||
$object = new StoredObject();
|
||||
$firstVersion = $object->registerVersion(
|
||||
[5, 6, 7, 8],
|
||||
['key' => ['some key']],
|
||||
'text/html',
|
||||
);
|
||||
|
||||
$storedObject->saveHistory();
|
||||
self::assertSame($firstVersion, $object->getCurrentVersion());
|
||||
|
||||
$storedObject
|
||||
->setFilename('test_1')
|
||||
->setIv([8, 10, 12])
|
||||
->setKeyInfos(['key' => ['data1' => 'data1']])
|
||||
->setType('text/text');
|
||||
$version = $object->registerVersion(
|
||||
[1, 2, 3, 4],
|
||||
$k = ['key' => ['data0' => 'data0']],
|
||||
'text/text',
|
||||
'abcde',
|
||||
);
|
||||
|
||||
$storedObject->saveHistory();
|
||||
self::assertSame($version, $object->getCurrentVersion());
|
||||
|
||||
self::assertEquals('test_0', $storedObject->getDatas()['history'][0]['filename']);
|
||||
self::assertEquals([2, 4, 6, 8], $storedObject->getDatas()['history'][0]['iv']);
|
||||
self::assertEquals(['key' => ['data0' => 'data0']], $storedObject->getDatas()['history'][0]['key_infos']);
|
||||
self::assertEquals('text/html', $storedObject->getDatas()['history'][0]['type']);
|
||||
self::assertCount(2, $object->getVersions());
|
||||
self::assertEquals('abcde', $object->getFilename());
|
||||
self::assertEquals([1, 2, 3, 4], $object->getIv());
|
||||
self::assertEqualsCanonicalizing($k, $object->getKeyInfos());
|
||||
self::assertEquals('text/text', $object->getType());
|
||||
|
||||
self::assertEquals('test_1', $storedObject->getDatas()['history'][1]['filename']);
|
||||
self::assertEquals([8, 10, 12], $storedObject->getDatas()['history'][1]['iv']);
|
||||
self::assertEquals(['key' => ['data1' => 'data1']], $storedObject->getDatas()['history'][1]['key_infos']);
|
||||
self::assertEquals('text/text', $storedObject->getDatas()['history'][1]['type']);
|
||||
self::assertEquals('abcde', $version->getFilename());
|
||||
self::assertEquals([1, 2, 3, 4], $version->getIv());
|
||||
self::assertEqualsCanonicalizing($k, $version->getKeyInfos());
|
||||
self::assertEquals('text/text', $version->getType());
|
||||
|
||||
self::assertNotSame($firstVersion, $version);
|
||||
}
|
||||
}
|
||||
|
@@ -15,11 +15,17 @@ use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Form\DataMapper\StoredObjectDataMapper;
|
||||
use Chill\DocStoreBundle\Form\DataTransformer\StoredObjectDataTransformer;
|
||||
use Chill\DocStoreBundle\Form\StoredObjectType;
|
||||
use Chill\DocStoreBundle\Repository\StoredObjectRepositoryInterface;
|
||||
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||
use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface;
|
||||
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectDenormalizer;
|
||||
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectNormalizer;
|
||||
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectVersionNormalizer;
|
||||
use Chill\MainBundle\Serializer\Normalizer\UserNormalizer;
|
||||
use Chill\MainBundle\Templating\Entity\UserRender;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Symfony\Component\Clock\MockClock;
|
||||
use Symfony\Component\Form\PreloadedExtension;
|
||||
use Symfony\Component\Form\Test\TypeTestCase;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
@@ -36,33 +42,41 @@ class StoredObjectTypeTest extends TypeTestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private StoredObject $model;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->model = new StoredObject();
|
||||
$this->model->registerVersion();
|
||||
parent::setUp();
|
||||
}
|
||||
|
||||
public function testChangeTitleValue(): void
|
||||
{
|
||||
$formData = ['title' => $newTitle = 'new title', 'stored_object' => <<<'JSON'
|
||||
{"datas":[],"filename":"","id":null,"iv":[],"keyInfos":[],"title":"","type":"","uuid":"3c6a28fe-f913-40b9-a201-5eccc4f2d312","status":"ready","createdAt":null,"createdBy":null,"creationDate":null,"_links":{"dav_link":{"href":"http:\/\/url\/fake","expiration":"1716889578"}}}
|
||||
{"uuid":"9855d676-690b-11ef-88d3-9f5a4129a7b7"}
|
||||
JSON];
|
||||
$model = new StoredObject();
|
||||
$form = $this->factory->create(StoredObjectType::class, $model, ['has_title' => true]);
|
||||
$form = $this->factory->create(StoredObjectType::class, $this->model, ['has_title' => true]);
|
||||
|
||||
$form->submit($formData);
|
||||
|
||||
$this->assertTrue($form->isSynchronized());
|
||||
|
||||
$this->assertEquals($newTitle, $model->getTitle());
|
||||
$this->assertEquals($newTitle, $this->model->getTitle());
|
||||
}
|
||||
|
||||
public function testReplaceByAnotherObject(): void
|
||||
{
|
||||
$formData = ['title' => $newTitle = 'new title', 'stored_object' => <<<'JSON'
|
||||
{"filename":"abcdef","iv":[10, 15, 20, 30],"keyInfos":[],"type":"text/html","status":"object_store_created"}
|
||||
{"uuid":"9855d676-690b-11ef-88d3-9f5a4129a7b7","currentVersion":{"filename":"abcdef","iv":[10, 15, 20, 30],"keyInfos":[],"type":"text/html","persisted": false}}
|
||||
JSON];
|
||||
$model = new StoredObject();
|
||||
$originalObjectId = spl_object_hash($model);
|
||||
$form = $this->factory->create(StoredObjectType::class, $model, ['has_title' => true]);
|
||||
$originalObjectId = spl_object_hash($this->model);
|
||||
$form = $this->factory->create(StoredObjectType::class, $this->model, ['has_title' => true]);
|
||||
|
||||
$form->submit($formData);
|
||||
|
||||
$this->assertTrue($form->isSynchronized());
|
||||
|
||||
$model = $form->getData();
|
||||
$this->assertEquals($originalObjectId, spl_object_hash($model));
|
||||
$this->assertEquals('abcdef', $model->getFilename());
|
||||
@@ -71,6 +85,29 @@ class StoredObjectTypeTest extends TypeTestCase
|
||||
$this->assertEquals($newTitle, $model->getTitle());
|
||||
}
|
||||
|
||||
public function testNothingIsChanged(): void
|
||||
{
|
||||
$formData = ['title' => $newTitle = 'new title', 'stored_object' => <<<'JSON'
|
||||
{"uuid":"9855d676-690b-11ef-88d3-9f5a4129a7b7","currentVersion":{"filename":"abcdef","iv":[10, 15, 20, 30],"keyInfos":[],"type":"text/html"}}
|
||||
JSON];
|
||||
$originalObjectId = spl_object_hash($this->model);
|
||||
$originalVersion = $this->model->getCurrentVersion();
|
||||
$originalFilename = $originalVersion->getFilename();
|
||||
$originalKeyInfos = $originalVersion->getKeyInfos();
|
||||
|
||||
$form = $this->factory->create(StoredObjectType::class, $this->model, ['has_title' => true]);
|
||||
|
||||
$form->submit($formData);
|
||||
|
||||
$this->assertTrue($form->isSynchronized());
|
||||
|
||||
$model = $form->getData();
|
||||
$this->assertEquals($originalObjectId, spl_object_hash($model));
|
||||
$this->assertSame($originalVersion, $model->getCurrentVersion());
|
||||
$this->assertEquals($originalFilename, $model->getCurrentVersion()->getFilename());
|
||||
$this->assertEquals($originalKeyInfos, $model->getCurrentVersion()->getKeyInfos());
|
||||
}
|
||||
|
||||
protected function getExtensions()
|
||||
{
|
||||
$jwtTokenProvider = $this->prophesize(JWTDavTokenProviderInterface::class);
|
||||
@@ -84,6 +121,12 @@ class StoredObjectTypeTest extends TypeTestCase
|
||||
$security = $this->prophesize(Security::class);
|
||||
$security->isGranted(Argument::cetera())->willReturn(true);
|
||||
|
||||
$storedObjectRepository = $this->prophesize(StoredObjectRepositoryInterface::class);
|
||||
$storedObjectRepository->findOneByUUID(Argument::type('string'))
|
||||
->willReturn($this->model);
|
||||
|
||||
$userRender = $this->prophesize(UserRender::class);
|
||||
|
||||
$serializer = new Serializer(
|
||||
[
|
||||
new StoredObjectNormalizer(
|
||||
@@ -91,6 +134,9 @@ class StoredObjectTypeTest extends TypeTestCase
|
||||
$urlGenerator->reveal(),
|
||||
$security->reveal()
|
||||
),
|
||||
new StoredObjectDenormalizer($storedObjectRepository->reveal()),
|
||||
new StoredObjectVersionNormalizer(),
|
||||
new UserNormalizer($userRender->reveal(), new MockClock()),
|
||||
],
|
||||
[
|
||||
new JsonEncoder(),
|
||||
|
@@ -94,7 +94,7 @@ class PersonDocumentACLAwareRepositoryTest extends KernelTestCase
|
||||
AccompanyingPeriod $period,
|
||||
?\DateTimeImmutable $startDate = null,
|
||||
?\DateTimeImmutable $endDate = null,
|
||||
?string $content = null
|
||||
?string $content = null,
|
||||
): void {
|
||||
$centerManager = $this->prophesize(CenterResolverManagerInterface::class);
|
||||
$centerManager->resolveCenters(Argument::type(Person::class))
|
||||
|
@@ -47,7 +47,7 @@ class AbstractStoredObjectVoterTest extends TestCase
|
||||
private readonly bool $canBeAssociatedWithWorkflow,
|
||||
private readonly AssociatedEntityToStoredObjectInterface $repository,
|
||||
Security $security,
|
||||
?WorkflowStoredObjectPermissionHelper $workflowDocumentService = null
|
||||
?WorkflowStoredObjectPermissionHelper $workflowDocumentService = null,
|
||||
) {
|
||||
parent::__construct($security, $workflowDocumentService);
|
||||
}
|
||||
|
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\DocStoreBundle\Tests\Serializer\Normalizer;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Repository\StoredObjectRepositoryInterface;
|
||||
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectDenormalizer;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class StoredObjectDenormalizerTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
public function testDenormalizeWithoutObjectToPopulateWithUUID(): void
|
||||
{
|
||||
$storedObject = new StoredObject();
|
||||
|
||||
$storedObjectRepository = $this->prophesize(StoredObjectRepositoryInterface::class);
|
||||
$storedObjectRepository->findOneByUUID($uuid = $storedObject->getUUID()->toString())
|
||||
->shouldBeCalledOnce()
|
||||
->willReturn($storedObject);
|
||||
|
||||
$denormalizer = new StoredObjectDenormalizer($storedObjectRepository->reveal());
|
||||
|
||||
$actual = $denormalizer->denormalize(['uuid' => $uuid], 'json');
|
||||
|
||||
self::assertSame($storedObject, $actual);
|
||||
}
|
||||
|
||||
public function testDenormalizeWithoutObjectToPopulateWithId(): void
|
||||
{
|
||||
$storedObject = new StoredObject();
|
||||
|
||||
$storedObjectRepository = $this->prophesize(StoredObjectRepositoryInterface::class);
|
||||
$storedObjectRepository->find($id = 1)
|
||||
->shouldBeCalledOnce()
|
||||
->willReturn($storedObject);
|
||||
|
||||
$denormalizer = new StoredObjectDenormalizer($storedObjectRepository->reveal());
|
||||
|
||||
$actual = $denormalizer->denormalize(['id' => $id], 'json');
|
||||
|
||||
self::assertSame($storedObject, $actual);
|
||||
}
|
||||
|
||||
public function testDenormalizeTitle(): void
|
||||
{
|
||||
$storedObject = new StoredObject();
|
||||
$storedObject->setTitle('foo');
|
||||
|
||||
$storedObjectRepository = $this->prophesize(StoredObjectRepositoryInterface::class);
|
||||
|
||||
$denormalizer = new StoredObjectDenormalizer($storedObjectRepository->reveal());
|
||||
|
||||
$actual = $denormalizer->denormalize([], StoredObject::class, 'json', [AbstractNormalizer::OBJECT_TO_POPULATE => $storedObject]);
|
||||
|
||||
self::assertEquals('foo', $actual->getTitle(), 'the title should remains the same');
|
||||
|
||||
$actual = $denormalizer->denormalize(['title' => 'bar'], StoredObject::class, 'json', [AbstractNormalizer::OBJECT_TO_POPULATE => $storedObject]);
|
||||
|
||||
self::assertEquals('bar', $actual->getTitle(), 'the title should have been updated');
|
||||
}
|
||||
|
||||
public function testDenormalizeNoNewVersion(): void
|
||||
{
|
||||
$storedObject = new StoredObject();
|
||||
$version = $storedObject->registerVersion();
|
||||
$iv = $version->getIv();
|
||||
$keyInfos = $version->getKeyInfos();
|
||||
$type = $version->getType();
|
||||
$filename = $version->getFilename();
|
||||
|
||||
$storedObjectRepository = $this->prophesize(StoredObjectRepositoryInterface::class);
|
||||
|
||||
$denormalizer = new StoredObjectDenormalizer($storedObjectRepository->reveal());
|
||||
|
||||
$actual = $denormalizer->denormalize([
|
||||
'currentVersion' => [
|
||||
'iv' => $iv,
|
||||
'keyInfos' => $keyInfos,
|
||||
'type' => $type,
|
||||
'filename' => $filename,
|
||||
],
|
||||
], StoredObject::class, 'json', [AbstractNormalizer::OBJECT_TO_POPULATE => $storedObject]);
|
||||
|
||||
self::assertSame($storedObject, $actual);
|
||||
self::assertSame($version, $storedObject->getCurrentVersion());
|
||||
self::assertEquals($iv, $version->getIv());
|
||||
self::assertEquals($keyInfos, $version->getKeyInfos());
|
||||
self::assertEquals($type, $version->getType());
|
||||
self::assertEquals($filename, $version->getFilename());
|
||||
}
|
||||
|
||||
public function testDenormalizeNewVersion(): void
|
||||
{
|
||||
$storedObject = new StoredObject();
|
||||
$version = $storedObject->registerVersion();
|
||||
$iv = ['1, 2, 3'];
|
||||
$keyInfos = ['some-key' => 'some'];
|
||||
$type = 'text/html';
|
||||
$filename = 'Foo-Bar';
|
||||
|
||||
$storedObjectRepository = $this->prophesize(StoredObjectRepositoryInterface::class);
|
||||
|
||||
$denormalizer = new StoredObjectDenormalizer($storedObjectRepository->reveal());
|
||||
|
||||
$actual = $denormalizer->denormalize([
|
||||
'currentVersion' => [
|
||||
'iv' => $iv,
|
||||
'keyInfos' => $keyInfos,
|
||||
'type' => $type,
|
||||
'filename' => $filename,
|
||||
// this is the required key for new versions
|
||||
'persisted' => false,
|
||||
],
|
||||
], StoredObject::class, 'json', [AbstractNormalizer::OBJECT_TO_POPULATE => $storedObject]);
|
||||
|
||||
self::assertSame($storedObject, $actual);
|
||||
self::assertNotSame($version, $storedObject->getCurrentVersion());
|
||||
|
||||
$version = $storedObject->getCurrentVersion();
|
||||
|
||||
self::assertEquals($iv, $version->getIv());
|
||||
self::assertEquals($keyInfos, $version->getKeyInfos());
|
||||
self::assertEquals($type, $version->getType());
|
||||
self::assertEquals($filename, $version->getFilename());
|
||||
}
|
||||
}
|
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\DocStoreBundle\Tests\Serializer\Normalizer;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||
use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface;
|
||||
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectNormalizer;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class StoredObjectNormalizerTest extends TestCase
|
||||
{
|
||||
public function testNormalize(): void
|
||||
{
|
||||
$storedObject = new StoredObject();
|
||||
$storedObject->setTitle('test');
|
||||
$reflection = new \ReflectionClass(StoredObject::class);
|
||||
$idProperty = $reflection->getProperty('id');
|
||||
$idProperty->setValue($storedObject, 1);
|
||||
|
||||
$jwtProvider = $this->createMock(JWTDavTokenProviderInterface::class);
|
||||
$jwtProvider->expects($this->once())->method('createToken')->withAnyParameters()->willReturn('token');
|
||||
$jwtProvider->expects($this->once())->method('getTokenExpiration')->with('token')->willReturn($d = new \DateTimeImmutable());
|
||||
|
||||
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||
$urlGenerator->expects($this->once())->method('generate')
|
||||
->with(
|
||||
'chill_docstore_dav_document_get',
|
||||
[
|
||||
'uuid' => $storedObject->getUuid(),
|
||||
'access_token' => 'token',
|
||||
],
|
||||
UrlGeneratorInterface::ABSOLUTE_URL,
|
||||
)
|
||||
->willReturn($davLink = 'http://localhost/dav/token');
|
||||
|
||||
$security = $this->createMock(Security::class);
|
||||
$security->expects($this->exactly(2))->method('isGranted')
|
||||
->with(
|
||||
$this->logicalOr(StoredObjectRoleEnum::EDIT->value, StoredObjectRoleEnum::SEE->value),
|
||||
$storedObject
|
||||
)
|
||||
->willReturn(true);
|
||||
|
||||
$globalNormalizer = $this->createMock(NormalizerInterface::class);
|
||||
$globalNormalizer->expects($this->exactly(3))->method('normalize')
|
||||
->withAnyParameters()
|
||||
->willReturnCallback(function (?object $object, string $format, array $context) {
|
||||
if (null === $object) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['sub' => 'sub'];
|
||||
});
|
||||
|
||||
$normalizer = new StoredObjectNormalizer($jwtProvider, $urlGenerator, $security);
|
||||
$normalizer->setNormalizer($globalNormalizer);
|
||||
|
||||
$actual = $normalizer->normalize($storedObject, 'json');
|
||||
|
||||
self::assertArrayHasKey('id', $actual);
|
||||
self::assertEquals(1, $actual['id']);
|
||||
self::assertArrayHasKey('title', $actual);
|
||||
self::assertEquals('test', $actual['title']);
|
||||
self::assertArrayHasKey('uuid', $actual);
|
||||
self::assertArrayHasKey('prefix', $actual);
|
||||
self::assertArrayHaskey('status', $actual);
|
||||
self::assertArrayHasKey('currentVersion', $actual);
|
||||
self::assertEquals(null, $actual['currentVersion']);
|
||||
self::assertArrayHasKey('totalVersions', $actual);
|
||||
self::assertEquals(0, $actual['totalVersions']);
|
||||
self::assertArrayHasKey('datas', $actual);
|
||||
self::assertArrayHasKey('createdAt', $actual);
|
||||
self::assertArrayHasKey('createdBy', $actual);
|
||||
self::assertArrayHasKey('_permissions', $actual);
|
||||
self::assertEqualsCanonicalizing(['canEdit' => true, 'canSee' => true], $actual['_permissions']);
|
||||
self::assertArrayHaskey('_links', $actual);
|
||||
self::assertArrayHasKey('dav_link', $actual['_links']);
|
||||
self::assertEqualsCanonicalizing(['href' => $davLink, 'expiration' => $d->getTimestamp()], $actual['_links']['dav_link']);
|
||||
}
|
||||
}
|
@@ -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 ChillDocStoreBundle\Tests\Serializer\Normalizer;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectVersionNormalizer;
|
||||
use Chill\MainBundle\Serializer\Normalizer\UserNormalizer;
|
||||
use Chill\MainBundle\Templating\Entity\UserRender;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Clock\MockClock;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||
use Symfony\Component\Serializer\Serializer;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class StoredObjectVersionNormalizerTest extends TestCase
|
||||
{
|
||||
private NormalizerInterface $normalizer;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$userRender = $this->createMock(UserRender::class);
|
||||
$userRender->method('renderString')->willReturn('user');
|
||||
$this->normalizer = new StoredObjectVersionNormalizer();
|
||||
$this->normalizer->setNormalizer(new Serializer([new UserNormalizer($userRender, new MockClock())]));
|
||||
}
|
||||
|
||||
public function testNormalize(): void
|
||||
{
|
||||
$storedObject = new StoredObject();
|
||||
$version = $storedObject->registerVersion(
|
||||
iv: [1, 2, 3, 4],
|
||||
keyInfos: ['someKey' => 'someKey'],
|
||||
type: 'text/text',
|
||||
);
|
||||
$reflection = new \ReflectionClass($version);
|
||||
$idProperty = $reflection->getProperty('id');
|
||||
$idProperty->setValue($version, 1);
|
||||
|
||||
$actual = $this->normalizer->normalize($version, 'json', ['groups' => ['read']]);
|
||||
|
||||
self::assertEqualsCanonicalizing(
|
||||
[
|
||||
'id' => 1,
|
||||
'version' => 0,
|
||||
'filename' => $version->getFilename(),
|
||||
'iv' => [1, 2, 3, 4],
|
||||
'keyInfos' => ['someKey' => 'someKey'],
|
||||
'type' => 'text/text',
|
||||
'createdAt' => null,
|
||||
'createdBy' => null,
|
||||
],
|
||||
$actual
|
||||
);
|
||||
}
|
||||
|
||||
public function testNormalizeUnsupportedObject(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('The object must be an instance of Chill\DocStoreBundle\Entity\StoredObjectVersion');
|
||||
|
||||
$unsupportedObject = new \stdClass();
|
||||
|
||||
$this->normalizer->normalize($unsupportedObject, 'json', ['groups' => ['read']]);
|
||||
}
|
||||
}
|
@@ -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\Tests\Repository;
|
||||
|
||||
use Chill\DocStoreBundle\Repository\StoredObjectVersionRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class StoredObjectVersionRepositoryTest extends KernelTestCase
|
||||
{
|
||||
private EntityManagerInterface $entityManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||
}
|
||||
|
||||
public function testFindIdsByVersionsOlderThanDateAndNotLastVersion(): void
|
||||
{
|
||||
$repository = new StoredObjectVersionRepository($this->entityManager);
|
||||
|
||||
// get old version, to get a chance to get one
|
||||
$actual = $repository->findIdsByVersionsOlderThanDateAndNotLastVersion(new \DateTimeImmutable('1970-01-01'));
|
||||
|
||||
self::assertIsIterable($actual);
|
||||
self::assertContainsOnly('int', $actual);
|
||||
}
|
||||
}
|
@@ -57,9 +57,10 @@ class PdfSignedMessageHandlerTest extends TestCase
|
||||
|
||||
// we simply call the handler. The mocked StoredObjectManager will check that the "write" method is invoked once
|
||||
// with the content "1234"
|
||||
$handler(new PdfSignedMessage(10, $expectedContent));
|
||||
$handler(new PdfSignedMessage(10, 99, $expectedContent));
|
||||
|
||||
self::assertEquals('signed', $signature->getState()->value);
|
||||
self::assertEquals(99, $signature->getZoneSignatureIndex());
|
||||
}
|
||||
|
||||
private function buildSignatureRepository(EntityWorkflowStepSignature $signature): EntityWorkflowStepSignatureRepository
|
||||
|
@@ -26,7 +26,7 @@ class PdfSignedMessageSerializerTest extends TestCase
|
||||
public function testDecode(): void
|
||||
{
|
||||
$asString = <<<'JSON'
|
||||
{"signatureId": 0, "content": "dGVzdAo="}
|
||||
{"signatureId": 0, "signatureZoneIndex": 10, "content": "dGVzdAo="}
|
||||
JSON;
|
||||
|
||||
$actual = $this->buildSerializer()->decode(['body' => $asString]);
|
||||
@@ -36,12 +36,13 @@ class PdfSignedMessageSerializerTest extends TestCase
|
||||
self::assertInstanceOf(PdfSignedMessage::class, $message);
|
||||
self::assertEquals("test\n", $message->content);
|
||||
self::assertEquals(0, $message->signatureId);
|
||||
self::assertEquals(10, $message->signatureZoneIndex);
|
||||
}
|
||||
|
||||
public function testEncode(): void
|
||||
{
|
||||
$envelope = new Envelope(
|
||||
new PdfSignedMessage(0, "test\n")
|
||||
new PdfSignedMessage(0, 10, "test\n")
|
||||
);
|
||||
|
||||
$actual = $this->buildSerializer()->encode($envelope);
|
||||
@@ -52,7 +53,7 @@ class PdfSignedMessageSerializerTest extends TestCase
|
||||
self::assertEquals([], $actual['headers']);
|
||||
|
||||
self::assertEquals(<<<'JSON'
|
||||
{"signatureId":0,"content":"dGVzdAo="}
|
||||
{"signatureId":0,"signatureZoneIndex":10,"content":"dGVzdAo="}
|
||||
JSON, $actual['body']);
|
||||
}
|
||||
|
||||
|
@@ -0,0 +1,76 @@
|
||||
<?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 Tests\Service\Signature;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Service\Signature\PDFPage;
|
||||
use Chill\DocStoreBundle\Service\Signature\PDFSignatureZone;
|
||||
use Chill\DocStoreBundle\Service\Signature\PDFSignatureZoneAvailable;
|
||||
use Chill\DocStoreBundle\Service\Signature\PDFSignatureZoneParser;
|
||||
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
|
||||
use Chill\MainBundle\Workflow\EntityWorkflowManager;
|
||||
use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO;
|
||||
use Chill\PersonBundle\Entity\Person;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Symfony\Component\Clock\MockClock;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class PDFSignatureZoneAvailableTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
public function testGetAvailableSignatureZones(): void
|
||||
{
|
||||
$clock = new MockClock();
|
||||
|
||||
$storedObject = new StoredObject();
|
||||
$storedObject->registerVersion(type: 'application/pdf');
|
||||
|
||||
$entityWorkflow = new EntityWorkflow();
|
||||
$dto1 = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$dto1->futurePersonSignatures[] = new Person();
|
||||
$entityWorkflow->setStep('step1', $dto1, 'transition1', $clock->now());
|
||||
$signature = $entityWorkflow->getCurrentStep()->getSignatures()->first();
|
||||
$signature->setZoneSignatureIndex(1)->setState(EntityWorkflowSignatureStateEnum::SIGNED);
|
||||
|
||||
$entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class);
|
||||
$entityWorkflowManager->getAssociatedStoredObject($entityWorkflow)->willReturn($storedObject);
|
||||
|
||||
$storedObjectManager = $this->prophesize(StoredObjectManagerInterface::class);
|
||||
$storedObjectManager->read($storedObject)->willReturn('fake-content');
|
||||
|
||||
$parser = $this->prophesize(PDFSignatureZoneParser::class);
|
||||
$parser->findSignatureZones('fake-content')->willReturn([
|
||||
$zone1 = new PDFSignatureZone(1, 0.0, 10.0, 20.0, 20.0, new PDFPage(1, 500, 500)),
|
||||
$zone2 = new PDFSignatureZone(2, 0.0, 10.0, 20.0, 20.0, new PDFPage(1, 500, 500)),
|
||||
]);
|
||||
|
||||
$filter = new PDFSignatureZoneAvailable(
|
||||
$entityWorkflowManager->reveal(),
|
||||
$parser->reveal(),
|
||||
$storedObjectManager->reveal(),
|
||||
);
|
||||
|
||||
$actual = $filter->getAvailableSignatureZones($entityWorkflow);
|
||||
|
||||
self::assertNotContains($zone1, $actual);
|
||||
self::assertContains($zone2, $actual);
|
||||
self::assertCount(1, $actual, 'there should be only one remaining zone');
|
||||
}
|
||||
}
|
@@ -0,0 +1,113 @@
|
||||
<?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 Tests\Service\StoredObjectCleaner;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Repository\StoredObjectRepositoryInterface;
|
||||
use Chill\DocStoreBundle\Service\StoredObjectCleaner\RemoveExpiredStoredObjectCronJob;
|
||||
use Chill\DocStoreBundle\Service\StoredObjectCleaner\RemoveOldVersionMessage;
|
||||
use Chill\MainBundle\Entity\CronJobExecution;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Clock\MockClock;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class RemoveExpiredStoredObjectCronJobTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @dataProvider buildTestCanRunData
|
||||
*/
|
||||
public function testCanRun(?CronJobExecution $cronJobExecution, bool $expected): void
|
||||
{
|
||||
$repository = $this->createMock(StoredObjectRepositoryInterface::class);
|
||||
$clock = new MockClock(new \DateTimeImmutable('2024-01-01 00:00:00', new \DateTimeZone('+00:00')));
|
||||
|
||||
$cronJob = new RemoveExpiredStoredObjectCronJob($clock, $this->buildMessageBus(), $repository);
|
||||
|
||||
self::assertEquals($expected, $cronJob->canRun($cronJobExecution));
|
||||
}
|
||||
|
||||
public static function buildTestCanRunData(): iterable
|
||||
{
|
||||
yield [
|
||||
(new CronJobExecution('remove-expired-stored-object'))->setLastEnd(new \DateTimeImmutable('2023-12-25 00:00:00', new \DateTimeZone('+00:00'))),
|
||||
true,
|
||||
];
|
||||
|
||||
yield [
|
||||
(new CronJobExecution('remove-expired-stored-object'))->setLastEnd(new \DateTimeImmutable('2023-12-24 23:59:59', new \DateTimeZone('+00:00'))),
|
||||
true,
|
||||
];
|
||||
|
||||
yield [
|
||||
(new CronJobExecution('remove-expired-stored-object'))->setLastEnd(new \DateTimeImmutable('2023-12-25 00:00:01', new \DateTimeZone('+00:00'))),
|
||||
false,
|
||||
];
|
||||
|
||||
yield [
|
||||
null,
|
||||
true,
|
||||
];
|
||||
}
|
||||
|
||||
public function testRun(): void
|
||||
{
|
||||
$repository = $this->createMock(StoredObjectRepositoryInterface::class);
|
||||
$repository->expects($this->atLeastOnce())->method('findByExpired')->withAnyParameters()->willReturnCallback(
|
||||
function (\DateTimeImmutable $date): iterable {
|
||||
yield $this->buildStoredObject(3);
|
||||
yield $this->buildStoredObject(1);
|
||||
}
|
||||
);
|
||||
$clock = new MockClock();
|
||||
|
||||
$cronJob = new RemoveExpiredStoredObjectCronJob($clock, $this->buildMessageBus(true), $repository);
|
||||
|
||||
$actual = $cronJob->run([]);
|
||||
|
||||
self::assertEquals(3, $actual['last-deleted-stored-object-id']);
|
||||
}
|
||||
|
||||
private function buildStoredObject(int $id): StoredObject
|
||||
{
|
||||
$object = new StoredObject();
|
||||
$object->registerVersion();
|
||||
$class = new \ReflectionClass($object);
|
||||
$idProperty = $class->getProperty('id');
|
||||
$idProperty->setValue($object, $id);
|
||||
|
||||
$classVersion = new \ReflectionClass($object->getCurrentVersion());
|
||||
$idPropertyVersion = $classVersion->getProperty('id');
|
||||
$idPropertyVersion->setValue($object->getCurrentVersion(), $id);
|
||||
|
||||
return $object;
|
||||
}
|
||||
|
||||
private function buildMessageBus(bool $expectDistpatchAtLeastOnce = false): MessageBusInterface
|
||||
{
|
||||
$messageBus = $this->createMock(MessageBusInterface::class);
|
||||
|
||||
$methodDispatch = match ($expectDistpatchAtLeastOnce) {
|
||||
true => $messageBus->expects($this->atLeastOnce())->method('dispatch')->with($this->isInstanceOf(RemoveOldVersionMessage::class)),
|
||||
false => $messageBus->method('dispatch'),
|
||||
};
|
||||
|
||||
$methodDispatch->willReturnCallback(fn (RemoveOldVersionMessage $message) => new Envelope($message));
|
||||
|
||||
return $messageBus;
|
||||
}
|
||||
}
|
@@ -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 Tests\Service\StoredObjectCleaner;
|
||||
|
||||
use Chill\DocStoreBundle\Repository\StoredObjectVersionRepository;
|
||||
use Chill\DocStoreBundle\Service\StoredObjectCleaner\RemoveOldVersionCronJob;
|
||||
use Chill\DocStoreBundle\Service\StoredObjectCleaner\RemoveOldVersionMessage;
|
||||
use Chill\MainBundle\Entity\CronJobExecution;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Symfony\Component\Clock\MockClock;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class RemoveOldVersionCronJobTest extends KernelTestCase
|
||||
{
|
||||
/**
|
||||
* @dataProvider buildTestCanRunData
|
||||
*/
|
||||
public function testCanRun(?CronJobExecution $cronJobExecution, bool $expected): void
|
||||
{
|
||||
$repository = $this->createMock(StoredObjectVersionRepository::class);
|
||||
$clock = new MockClock(new \DateTimeImmutable('2024-01-01 00:00:00', new \DateTimeZone('+00:00')));
|
||||
|
||||
$cronJob = new RemoveOldVersionCronJob($clock, $this->buildMessageBus(), $repository);
|
||||
|
||||
self::assertEquals($expected, $cronJob->canRun($cronJobExecution));
|
||||
}
|
||||
|
||||
public function testRun(): void
|
||||
{
|
||||
// we create a clock in the future. This led us a chance to having stored object to delete
|
||||
$clock = new MockClock(new \DateTimeImmutable('2024-01-01 00:00:00', new \DateTimeZone('+00:00')));
|
||||
$repository = $this->createMock(StoredObjectVersionRepository::class);
|
||||
$repository->expects($this->once())
|
||||
->method('findIdsByVersionsOlderThanDateAndNotLastVersion')
|
||||
->with(new \DateTime('2023-10-03 00:00:00', new \DateTimeZone('+00:00')))
|
||||
->willReturnCallback(function ($arg) {
|
||||
yield 1;
|
||||
yield 3;
|
||||
yield 2;
|
||||
})
|
||||
;
|
||||
|
||||
$cronJob = new RemoveOldVersionCronJob($clock, $this->buildMessageBus(true), $repository);
|
||||
|
||||
$results = $cronJob->run([]);
|
||||
|
||||
self::assertArrayHasKey('last-deleted-stored-object-version-id', $results);
|
||||
self::assertIsInt($results['last-deleted-stored-object-version-id']);
|
||||
}
|
||||
|
||||
public static function buildTestCanRunData(): iterable
|
||||
{
|
||||
yield [
|
||||
(new CronJobExecution('last-deleted-stored-object-version-id'))->setLastEnd(new \DateTimeImmutable('2023-12-31 00:00:00', new \DateTimeZone('+00:00'))),
|
||||
true,
|
||||
];
|
||||
|
||||
yield [
|
||||
(new CronJobExecution('last-deleted-stored-object-version-id'))->setLastEnd(new \DateTimeImmutable('2023-12-30 23:59:59', new \DateTimeZone('+00:00'))),
|
||||
true,
|
||||
];
|
||||
|
||||
yield [
|
||||
(new CronJobExecution('last-deleted-stored-object-version-id'))->setLastEnd(new \DateTimeImmutable('2023-12-31 00:00:01', new \DateTimeZone('+00:00'))),
|
||||
false,
|
||||
];
|
||||
|
||||
yield [
|
||||
null,
|
||||
true,
|
||||
];
|
||||
}
|
||||
|
||||
private function buildMessageBus(bool $expectDistpatchAtLeastOnce = false): MessageBusInterface
|
||||
{
|
||||
$messageBus = $this->createMock(MessageBusInterface::class);
|
||||
|
||||
$methodDispatch = match ($expectDistpatchAtLeastOnce) {
|
||||
true => $messageBus->expects($this->atLeastOnce())->method('dispatch')->with($this->isInstanceOf(RemoveOldVersionMessage::class)),
|
||||
false => $messageBus->method('dispatch'),
|
||||
};
|
||||
|
||||
$methodDispatch->willReturnCallback(fn (RemoveOldVersionMessage $message) => new Envelope($message));
|
||||
|
||||
return $messageBus;
|
||||
}
|
||||
}
|
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Tests\Service\StoredObjectCleaner;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
|
||||
use Chill\DocStoreBundle\Repository\StoredObjectVersionRepository;
|
||||
use Chill\DocStoreBundle\Service\StoredObjectCleaner\RemoveOldVersionMessage;
|
||||
use Chill\DocStoreBundle\Service\StoredObjectCleaner\RemoveOldVersionMessageHandler;
|
||||
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Clock\MockClock;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class RemoveOldVersionMessageHandlerTest extends TestCase
|
||||
{
|
||||
public function testInvoke(): void
|
||||
{
|
||||
$object = new StoredObject();
|
||||
$version = $object->registerVersion();
|
||||
$storedObjectVersionRepository = $this->createMock(StoredObjectVersionRepository::class);
|
||||
$storedObjectVersionRepository->expects($this->once())->method('find')
|
||||
->with($this->identicalTo(1))
|
||||
->willReturn($version);
|
||||
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$entityManager->expects($this->once())->method('remove')->with($this->identicalTo($version));
|
||||
$entityManager->expects($this->once())->method('flush');
|
||||
$entityManager->expects($this->once())->method('clear');
|
||||
|
||||
$storedObjectManager = $this->createMock(StoredObjectManagerInterface::class);
|
||||
$storedObjectManager->expects($this->once())->method('delete')->with($this->identicalTo($version));
|
||||
|
||||
$handler = new RemoveOldVersionMessageHandler($storedObjectVersionRepository, new NullLogger(), $entityManager, $storedObjectManager, new MockClock());
|
||||
|
||||
$handler(new RemoveOldVersionMessage(1));
|
||||
}
|
||||
|
||||
public function testInvokeWithStoredObjectToDelete(): void
|
||||
{
|
||||
$object = new StoredObject();
|
||||
$object->setDeleteAt(new \DateTimeImmutable('2023-12-01'));
|
||||
$version = $object->registerVersion();
|
||||
|
||||
$storedObjectVersionRepository = $this->createMock(StoredObjectVersionRepository::class);
|
||||
$storedObjectVersionRepository->expects($this->once())->method('find')
|
||||
->with($this->identicalTo(1))
|
||||
->willReturn($version);
|
||||
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$entityManager->expects($this->exactly(2))->method('remove')->with(
|
||||
$this->logicalOr($this->identicalTo($version), $this->identicalTo($object))
|
||||
);
|
||||
$entityManager->expects($this->once())->method('flush');
|
||||
$entityManager->expects($this->once())->method('clear');
|
||||
|
||||
$handler = new RemoveOldVersionMessageHandler(
|
||||
$storedObjectVersionRepository,
|
||||
new NullLogger(),
|
||||
$entityManager,
|
||||
new DummyStoredObjectManager(),
|
||||
new MockClock(new \DateTimeImmutable('2024-01-01'))
|
||||
);
|
||||
|
||||
$handler(new RemoveOldVersionMessage(1));
|
||||
|
||||
self::assertCount(0, $object->getVersions());
|
||||
}
|
||||
}
|
||||
|
||||
class DummyStoredObjectManager implements StoredObjectManagerInterface
|
||||
{
|
||||
public function getLastModified(StoredObject|StoredObjectVersion $document): \DateTimeInterface
|
||||
{
|
||||
throw new \RuntimeException();
|
||||
}
|
||||
|
||||
public function getContentLength(StoredObject|StoredObjectVersion $document): int
|
||||
{
|
||||
throw new \RuntimeException();
|
||||
}
|
||||
|
||||
public function read(StoredObject|StoredObjectVersion $document): string
|
||||
{
|
||||
throw new \RuntimeException();
|
||||
}
|
||||
|
||||
public function write(StoredObject $document, string $clearContent, ?string $contentType = null): StoredObjectVersion
|
||||
{
|
||||
throw new \RuntimeException();
|
||||
}
|
||||
|
||||
public function delete(StoredObjectVersion $storedObjectVersion): void
|
||||
{
|
||||
$object = $storedObjectVersion->getStoredObject();
|
||||
$object->removeVersion($storedObjectVersion);
|
||||
}
|
||||
|
||||
public function etag(StoredObject|StoredObjectVersion $document): string
|
||||
{
|
||||
throw new \RuntimeException();
|
||||
}
|
||||
|
||||
public function clearCache(): void
|
||||
{
|
||||
throw new \RuntimeException();
|
||||
}
|
||||
}
|
@@ -31,23 +31,25 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
*/
|
||||
final class StoredObjectManagerTest extends TestCase
|
||||
{
|
||||
public static function getDataProvider(): \Generator
|
||||
public static function getDataProviderForRead(): \Generator
|
||||
{
|
||||
/* HAPPY SCENARIO */
|
||||
|
||||
// Encrypted object
|
||||
yield [
|
||||
(new StoredObject())
|
||||
->setFilename('encrypted.txt')
|
||||
->setKeyInfos(['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')])
|
||||
->setIv(unpack('C*', 'abcdefghijklmnop')),
|
||||
->registerVersion(
|
||||
unpack('C*', 'abcdefghijklmnop'),
|
||||
['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')],
|
||||
filename: 'encrypted.txt'
|
||||
)->getStoredObject(),
|
||||
hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string
|
||||
'The quick brown fox jumps over the lazy dog', // clear
|
||||
];
|
||||
|
||||
// Non-encrypted object
|
||||
yield [
|
||||
(new StoredObject())->setFilename('non-encrypted.txt'), // The StoredObject
|
||||
(new StoredObject())->registerVersion(filename: 'non-encrypted.txt')->getStoredObject(), // The StoredObject
|
||||
'The quick brown fox jumps over the lazy dog', // Encrypted
|
||||
'The quick brown fox jumps over the lazy dog', // Clear
|
||||
];
|
||||
@@ -57,9 +59,11 @@ final class StoredObjectManagerTest extends TestCase
|
||||
// Encrypted object with issue during HTTP communication
|
||||
yield [
|
||||
(new StoredObject())
|
||||
->setFilename('error_during_http_request.txt')
|
||||
->setKeyInfos(['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')])
|
||||
->setIv(unpack('C*', 'abcdefghijklmnop')),
|
||||
->registerVersion(
|
||||
unpack('C*', 'abcdefghijklmnop'),
|
||||
['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')],
|
||||
filename: 'error_during_http_request.txt'
|
||||
)->getStoredObject(),
|
||||
hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string
|
||||
'The quick brown fox jumps over the lazy dog', // clear
|
||||
StoredObjectManagerException::class,
|
||||
@@ -68,9 +72,11 @@ final class StoredObjectManagerTest extends TestCase
|
||||
// Encrypted object with issue during HTTP communication: Invalid status code
|
||||
yield [
|
||||
(new StoredObject())
|
||||
->setFilename('invalid_statuscode.txt')
|
||||
->setKeyInfos(['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')])
|
||||
->setIv(unpack('C*', 'abcdefghijklmnop')),
|
||||
->registerVersion(
|
||||
unpack('C*', 'abcdefghijklmnop'),
|
||||
['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')],
|
||||
filename: 'invalid_statuscode.txt'
|
||||
)->getStoredObject(),
|
||||
hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string
|
||||
'The quick brown fox jumps over the lazy dog', // clear
|
||||
StoredObjectManagerException::class,
|
||||
@@ -79,17 +85,73 @@ final class StoredObjectManagerTest extends TestCase
|
||||
// Erroneous encrypted: Unable to decrypt exception.
|
||||
yield [
|
||||
(new StoredObject())
|
||||
->setFilename('unable_to_decrypt.txt')
|
||||
->setKeyInfos(['k' => base64_encode('WRONG_PASS_PHRASE')])
|
||||
->setIv(unpack('C*', 'abcdefghijklmnop')),
|
||||
->registerVersion(
|
||||
unpack('C*', 'abcdefghijklmnop'),
|
||||
['k' => base64_encode('WRONG_PASS_PHRASE')],
|
||||
filename: 'unable_to_decrypt.txt'
|
||||
)->getStoredObject(),
|
||||
'WRONG_ENCODED_VALUE', // Binary encoded string
|
||||
'The quick brown fox jumps over the lazy dog', // clear
|
||||
StoredObjectManagerException::class,
|
||||
];
|
||||
}
|
||||
|
||||
public static function getDataProviderForWrite(): \Generator
|
||||
{
|
||||
/* HAPPY SCENARIO */
|
||||
|
||||
// Encrypted object
|
||||
yield [
|
||||
(new StoredObject())
|
||||
->registerVersion(
|
||||
unpack('C*', 'abcdefghijklmnop'),
|
||||
['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')],
|
||||
filename: 'encrypted.txt'
|
||||
)->getStoredObject(),
|
||||
hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string
|
||||
'The quick brown fox jumps over the lazy dog', // clear
|
||||
];
|
||||
|
||||
// Non-encrypted object
|
||||
yield [
|
||||
(new StoredObject())->registerVersion(filename: 'non-encrypted.txt')->getStoredObject(), // The StoredObject
|
||||
'The quick brown fox jumps over the lazy dog', // Encrypted
|
||||
'The quick brown fox jumps over the lazy dog', // Clear
|
||||
];
|
||||
|
||||
/* UNHAPPY SCENARIO */
|
||||
|
||||
// Encrypted object with issue during HTTP communication
|
||||
yield [
|
||||
(new StoredObject())
|
||||
->registerVersion(
|
||||
unpack('C*', 'abcdefghijklmnop'),
|
||||
['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')],
|
||||
filename: 'error_during_http_request.txt'
|
||||
)->getStoredObject(),
|
||||
hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string
|
||||
'The quick brown fox jumps over the lazy dog', // clear
|
||||
StoredObjectManagerException::class,
|
||||
-1,
|
||||
];
|
||||
|
||||
// Encrypted object with issue during HTTP communication: Invalid status code
|
||||
yield [
|
||||
(new StoredObject())
|
||||
->registerVersion(
|
||||
unpack('C*', 'abcdefghijklmnop'),
|
||||
['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')],
|
||||
filename: 'invalid_statuscode.txt'
|
||||
)->getStoredObject(),
|
||||
hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string
|
||||
'The quick brown fox jumps over the lazy dog', // clear
|
||||
StoredObjectManagerException::class,
|
||||
408,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider getDataProvider
|
||||
* @dataProvider getDataProviderForRead
|
||||
*/
|
||||
public function testRead(StoredObject $storedObject, string $encodedContent, string $clearContent, ?string $exceptionClass = null)
|
||||
{
|
||||
@@ -103,19 +165,66 @@ final class StoredObjectManagerTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider getDataProvider
|
||||
* @dataProvider getDataProviderForWrite
|
||||
*/
|
||||
public function testWrite(StoredObject $storedObject, string $encodedContent, string $clearContent, ?string $exceptionClass = null)
|
||||
public function testWrite(StoredObject $storedObject, string $encodedContent, string $clearContent, ?string $exceptionClass = null, ?int $errorCode = null)
|
||||
{
|
||||
if (null !== $exceptionClass) {
|
||||
$this->expectException($exceptionClass);
|
||||
}
|
||||
|
||||
$storedObjectManager = $this->getSubject($storedObject, $encodedContent);
|
||||
$previousVersion = $storedObject->getCurrentVersion();
|
||||
$previousFilename = $previousVersion->getFilename();
|
||||
|
||||
$storedObjectManager->write($storedObject, $clearContent);
|
||||
$client = new MockHttpClient(function ($method, $url, $options) use ($encodedContent, $previousFilename, $errorCode) {
|
||||
self::assertEquals('PUT', $method);
|
||||
self::assertStringStartsWith('https://example.com/', $url);
|
||||
self::assertStringNotContainsString($previousFilename, $url, 'test that the PUT operation is not performed on the same file');
|
||||
self::assertArrayHasKey('body', $options);
|
||||
self::assertEquals($encodedContent, $options['body']);
|
||||
|
||||
self::assertEquals($clearContent, $storedObjectManager->read($storedObject));
|
||||
if (-1 === $errorCode) {
|
||||
throw new TransportException();
|
||||
}
|
||||
|
||||
return new MockResponse('', ['http_code' => $errorCode ?? 201]);
|
||||
});
|
||||
|
||||
$storedObjectManager = new StoredObjectManager($client, $this->getTempUrlGenerator($storedObject));
|
||||
|
||||
$newVersion = $storedObjectManager->write($storedObject, $clearContent);
|
||||
|
||||
self::assertNotSame($previousVersion, $newVersion);
|
||||
self::assertSame($storedObject->getCurrentVersion(), $newVersion);
|
||||
}
|
||||
|
||||
public function testDelete(): void
|
||||
{
|
||||
$storedObject = new StoredObject();
|
||||
$version = $storedObject->registerVersion(filename: 'object_name');
|
||||
|
||||
$httpClient = new MockHttpClient(function ($method, $url, $options) {
|
||||
self::assertEquals('DELETE', $method);
|
||||
self::assertEquals('https://example.com/object_name', $url);
|
||||
|
||||
return new MockResponse('', [
|
||||
'http_code' => 204,
|
||||
]);
|
||||
});
|
||||
|
||||
$tempUrlGenerator = $this->createMock(TempUrlGeneratorInterface::class);
|
||||
$tempUrlGenerator
|
||||
->expects($this->once())
|
||||
->method('generate')
|
||||
->with($this->identicalTo('DELETE'), $this->identicalTo('object_name'))
|
||||
->willReturnCallback(fn (string $method, string $objectName) => new SignedUrl(
|
||||
$method,
|
||||
'https://example.com/'.$objectName,
|
||||
new \DateTimeImmutable('1 hours')
|
||||
));
|
||||
|
||||
$storedObjectManager = new StoredObjectManager($httpClient, $tempUrlGenerator);
|
||||
$storedObjectManager->delete($version);
|
||||
}
|
||||
|
||||
public function testWriteWithDeleteAt()
|
||||
@@ -153,6 +262,60 @@ final class StoredObjectManagerTest extends TestCase
|
||||
$manager->write($storedObject, 'ok');
|
||||
}
|
||||
|
||||
public function testGetLastModifiedWithDateTimeInEntity(): void
|
||||
{
|
||||
$version = ($storedObject = new StoredObject())->registerVersion();
|
||||
$version->setCreatedAt(new \DateTimeImmutable('2024-07-09 15:09:47', new \DateTimeZone('+00:00')));
|
||||
$client = $this->createMock(HttpClientInterface::class);
|
||||
$client->expects(self::never())->method('request')->withAnyParameters();
|
||||
|
||||
$tempUrlGenerator = $this->createMock(TempUrlGeneratorInterface::class);
|
||||
$tempUrlGenerator
|
||||
->expects($this->never())
|
||||
->method('generate')
|
||||
->with($this->identicalTo('HEAD'), $this->isType('string'))
|
||||
->willReturnCallback(fn (string $method, string $objectName) => new SignedUrl(
|
||||
$method,
|
||||
'https://example.com/'.$objectName,
|
||||
new \DateTimeImmutable('1 hours'),
|
||||
$objectName
|
||||
));
|
||||
|
||||
$manager = new StoredObjectManager($client, $tempUrlGenerator);
|
||||
|
||||
$actual = $manager->getLastModified($storedObject);
|
||||
|
||||
self::assertEquals(new \DateTimeImmutable('2024-07-09 15:09:47 GMT'), $actual);
|
||||
}
|
||||
|
||||
public function testGetLastModifiedWithDateTimeFromResponse(): void
|
||||
{
|
||||
$storedObject = (new StoredObject())->registerVersion()->getStoredObject();
|
||||
|
||||
$client = new MockHttpClient(
|
||||
new MockResponse('', ['http_code' => 200, 'response_headers' => [
|
||||
'last-modified' => 'Tue, 09 Jul 2024 15:09:47 GMT',
|
||||
]])
|
||||
);
|
||||
|
||||
$tempUrlGenerator = $this->createMock(TempUrlGeneratorInterface::class);
|
||||
$tempUrlGenerator
|
||||
->expects($this->atLeastOnce())
|
||||
->method('generate')
|
||||
->with($this->identicalTo('HEAD'), $this->isType('string'))
|
||||
->willReturnCallback(fn (string $method, string $objectName) => new SignedUrl(
|
||||
$method,
|
||||
'https://example.com/'.$objectName,
|
||||
new \DateTimeImmutable('1 hours')
|
||||
));
|
||||
|
||||
$manager = new StoredObjectManager($client, $tempUrlGenerator);
|
||||
|
||||
$actual = $manager->getLastModified($storedObject);
|
||||
|
||||
self::assertEquals(new \DateTimeImmutable('2024-07-09 15:09:47 GMT'), $actual);
|
||||
}
|
||||
|
||||
private function getHttpClient(string $encodedContent): HttpClientInterface
|
||||
{
|
||||
$callback = static function ($method, $url, $options) use ($encodedContent) {
|
||||
@@ -170,19 +333,6 @@ final class StoredObjectManagerTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
if (Request::METHOD_PUT === $method) {
|
||||
switch ($url) {
|
||||
case 'https://example.com/non-encrypted.txt':
|
||||
case 'https://example.com/encrypted.txt':
|
||||
return new MockResponse($encodedContent, ['http_code' => 201]);
|
||||
|
||||
case 'https://example.com/error_during_http_request.txt':
|
||||
throw new TransportException('error_during_http_request.txt');
|
||||
case 'https://example.com/invalid_statuscode.txt':
|
||||
return new MockResponse($encodedContent, ['http_code' => 404]);
|
||||
}
|
||||
}
|
||||
|
||||
return new MockResponse('Not found');
|
||||
};
|
||||
|
||||
@@ -209,9 +359,15 @@ final class StoredObjectManagerTest extends TestCase
|
||||
$tempUrlGenerator = $this->createMock(TempUrlGeneratorInterface::class);
|
||||
|
||||
$tempUrlGenerator
|
||||
->expects($this->atLeastOnce())
|
||||
->method('generate')
|
||||
->withAnyParameters()
|
||||
->willReturn($response);
|
||||
->with($this->logicalOr($this->identicalTo('GET'), $this->identicalTo('PUT')), $this->isType('string'))
|
||||
->willReturnCallback(fn (string $method, string $objectName) => new SignedUrl(
|
||||
$method,
|
||||
'https://example.com/'.$objectName,
|
||||
new \DateTimeImmutable('1 hours'),
|
||||
$objectName
|
||||
));
|
||||
|
||||
return $tempUrlGenerator;
|
||||
}
|
||||
|
@@ -11,66 +11,43 @@ declare(strict_types=1);
|
||||
|
||||
namespace Chill\DocStoreBundle\Validator\Constraints;
|
||||
|
||||
use Chill\DocStoreBundle\AsyncUpload\Exception\BadCallToRemoteServer;
|
||||
use Chill\DocStoreBundle\AsyncUpload\Exception\TempUrlRemoteServerException;
|
||||
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
|
||||
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\ConstraintValidator;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedValueException;
|
||||
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
final class AsyncFileExistsValidator extends ConstraintValidator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TempUrlGeneratorInterface $tempUrlGenerator,
|
||||
private readonly HttpClientInterface $client
|
||||
private readonly StoredObjectManagerInterface $storedObjectManager,
|
||||
) {}
|
||||
|
||||
public function validate($value, Constraint $constraint): void
|
||||
{
|
||||
if ($value instanceof StoredObject) {
|
||||
$this->validateObject($value->getFilename(), $constraint);
|
||||
} elseif (is_string($value)) {
|
||||
$this->validateObject($value, $constraint);
|
||||
} else {
|
||||
throw new UnexpectedValueException($value, StoredObject::class.' or string');
|
||||
}
|
||||
}
|
||||
|
||||
protected function validateObject(string $file, Constraint $constraint): void
|
||||
{
|
||||
if (!$constraint instanceof AsyncFileExists) {
|
||||
throw new UnexpectedTypeException($constraint, AsyncFileExists::class);
|
||||
}
|
||||
|
||||
$urlHead = $this->tempUrlGenerator->generate(
|
||||
'HEAD',
|
||||
$file,
|
||||
30
|
||||
);
|
||||
if (null === $value) {
|
||||
return;
|
||||
}
|
||||
if ($value instanceof StoredObjectVersion) {
|
||||
$this->validateObject($value, $constraint);
|
||||
} elseif ($value instanceof StoredObject) {
|
||||
$this->validateObject($value->getCurrentVersion(), $constraint);
|
||||
} else {
|
||||
throw new \Symfony\Component\Form\Exception\UnexpectedTypeException($value, StoredObjectVersion::class);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->client->request('HEAD', $urlHead->url);
|
||||
|
||||
if (404 === $status = $response->getStatusCode()) {
|
||||
$this->context->buildViolation($constraint->message)
|
||||
->setParameter('{{ filename }}', $file)
|
||||
->addViolation();
|
||||
} elseif (500 <= $status) {
|
||||
throw new TempUrlRemoteServerException($response->getStatusCode());
|
||||
} elseif (400 <= $status) {
|
||||
throw new BadCallToRemoteServer($response->getContent(false), $response->getStatusCode());
|
||||
}
|
||||
} catch (HttpExceptionInterface $exception) {
|
||||
if (404 !== $exception->getResponse()->getStatusCode()) {
|
||||
throw $exception;
|
||||
}
|
||||
} catch (TransportExceptionInterface $e) {
|
||||
throw new TempUrlRemoteServerException(0, previous: $e);
|
||||
protected function validateObject(StoredObjectVersion $file, AsyncFileExists $constraint): void
|
||||
{
|
||||
if (!$this->storedObjectManager->exists($file)) {
|
||||
$this->context->buildViolation($constraint->message)
|
||||
->setParameter('{{ filename }}', $file->getFilename())
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -29,7 +29,7 @@ readonly class AccompanyingCourseDocumentWorkflowHandler implements EntityWorkfl
|
||||
public function __construct(
|
||||
private TranslatorInterface $translator,
|
||||
private EntityWorkflowRepository $workflowRepository,
|
||||
private AccompanyingCourseDocumentRepository $repository
|
||||
private AccompanyingCourseDocumentRepository $repository,
|
||||
) {}
|
||||
|
||||
public function getDeletionRoles(): array
|
||||
|
@@ -1,46 +1,107 @@
|
||||
---
|
||||
openapi: "3.0.0"
|
||||
info:
|
||||
version: "1.0.0"
|
||||
title: "Chill api"
|
||||
description: "Api documentation for chill. Currently, work in progress"
|
||||
version: "1.0.0"
|
||||
title: "Chill api"
|
||||
description: "Api documentation for chill. Currently, work in progress"
|
||||
servers:
|
||||
- url: "/api"
|
||||
description: "Your current dev server"
|
||||
- url: "/api"
|
||||
description: "Your current dev server"
|
||||
|
||||
components:
|
||||
schemas:
|
||||
StoredObject:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
filename:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
schemas:
|
||||
StoredObject:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
filename:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
|
||||
paths:
|
||||
/1.0/docstore/stored-object.json:
|
||||
post:
|
||||
tags:
|
||||
- storedobject
|
||||
summary: Create a stored object
|
||||
requestBody:
|
||||
description: "A stored object"
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/StoredObject"
|
||||
responses:
|
||||
200:
|
||||
description: "OK"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/StoredObject"
|
||||
403:
|
||||
description: "Unauthorized"
|
||||
422:
|
||||
description: "Invalid data"
|
||||
/1.0/doc-store/stored-object/create:
|
||||
post:
|
||||
tags:
|
||||
- storedobject
|
||||
summary: Create a stored object
|
||||
responses:
|
||||
200:
|
||||
description: "OK"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/StoredObject"
|
||||
403:
|
||||
description: "Unauthorized"
|
||||
422:
|
||||
description: "Invalid data"
|
||||
|
||||
/1.0/doc-store/async-upload/temp_url/{uuid}/generate/post:
|
||||
get:
|
||||
tags:
|
||||
- storedobject
|
||||
summary: Get a signed route to post stored object
|
||||
parameters:
|
||||
- in: path
|
||||
name: uuid
|
||||
required: true
|
||||
allowEmptyValue: false
|
||||
description: The UUID of the storedObject
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
responses:
|
||||
200:
|
||||
description: "OK"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
403:
|
||||
description: "Unauthorized"
|
||||
404:
|
||||
description: "Not found"
|
||||
/1.0/doc-store/async-upload/temp_url/{uuid}/generate/{method}:
|
||||
get:
|
||||
tags:
|
||||
- storedobject
|
||||
summary: Get a signed route to get a stored object
|
||||
parameters:
|
||||
- in: path
|
||||
name: uuid
|
||||
required: true
|
||||
allowEmptyValue: false
|
||||
description: The UUID of the storedObjeect
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
- in: path
|
||||
name: method
|
||||
required: true
|
||||
allowEmptyValue: false
|
||||
description: the method of the signed url (get or head)
|
||||
schema:
|
||||
type: string
|
||||
enum: [get, head]
|
||||
- in: query
|
||||
name: version
|
||||
required: false
|
||||
allowEmptyValue: false
|
||||
description: the version's filename of the stored object
|
||||
schema:
|
||||
type: string
|
||||
minLength: 2
|
||||
responses:
|
||||
200:
|
||||
description: "OK"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
403:
|
||||
description: "Unauthorized"
|
||||
404:
|
||||
description: "Not found"
|
||||
|
||||
|
@@ -0,0 +1,80 @@
|
||||
<?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\Migrations\DocStore;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20240709102730 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add versioning to stored objects';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE SEQUENCE chill_doc.stored_object_version_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
|
||||
$this->addSql(
|
||||
<<<'SQL'
|
||||
CREATE TABLE chill_doc.stored_object_version (
|
||||
id INT NOT NULL,
|
||||
stored_object_id INT NOT NULL,
|
||||
version INT DEFAULT 0 NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
iv JSON NOT NULL,
|
||||
key JSON NOT NULL,
|
||||
type TEXT DEFAULT '' NOT NULL,
|
||||
createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
|
||||
createdBy_id INT DEFAULT NULL,
|
||||
PRIMARY KEY(id))
|
||||
SQL
|
||||
);
|
||||
$this->addSql('CREATE INDEX IDX_C1D55302232D562B ON chill_doc.stored_object_version (stored_object_id)');
|
||||
$this->addSql('CREATE INDEX IDX_C1D553023174800F ON chill_doc.stored_object_version (createdBy_id)');
|
||||
$this->addSql('CREATE UNIQUE INDEX chill_doc_stored_object_version_unique_by_object ON chill_doc.stored_object_version (stored_object_id, version)');
|
||||
$this->addSql('COMMENT ON COLUMN chill_doc.stored_object_version.createdAt IS \'(DC2Type:datetime_immutable)\'');
|
||||
$this->addSql('ALTER TABLE chill_doc.stored_object_version ADD CONSTRAINT FK_C1D55302232D562B FOREIGN KEY (stored_object_id) REFERENCES chill_doc.stored_object (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||
$this->addSql('ALTER TABLE chill_doc.stored_object_version ADD CONSTRAINT FK_C1D553023174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||
$this->addSql(
|
||||
<<<'SQL'
|
||||
INSERT INTO chill_doc.stored_object_version (id, stored_object_id, version, filename, iv, key, type)
|
||||
SELECT nextval('chill_doc.stored_object_version_id_seq'), id, 1, filename, iv, key, type FROM chill_doc.stored_object
|
||||
SQL
|
||||
);
|
||||
$this->addSql('ALTER TABLE chill_doc.stored_object RENAME COLUMN filename TO prefix');
|
||||
$this->addSql('ALTER TABLE chill_doc.stored_object DROP key');
|
||||
$this->addSql('ALTER TABLE chill_doc.stored_object DROP iv');
|
||||
$this->addSql('ALTER TABLE chill_doc.stored_object DROP type');
|
||||
$this->addSql('ALTER TABLE chill_doc.stored_object ALTER title SET DEFAULT \'\'');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP SEQUENCE chill_doc.stored_object_version_id_seq CASCADE');
|
||||
$this->addSql('ALTER TABLE chill_doc.stored_object RENAME COLUMN prefix TO filename');
|
||||
$this->addSql('ALTER TABLE chill_doc.stored_object ADD type TEXT NOT NULL DEFAULT \'\'');
|
||||
$this->addSql('ALTER TABLE chill_doc.stored_object ADD key JSON NOT NULL DEFAULT \'{}\'');
|
||||
$this->addSql('ALTER TABLE chill_doc.stored_object ADD iv JSON NOT NULL DEFAULT \'[]\'');
|
||||
$this->addSql('ALTER TABLE chill_doc.stored_object ALTER title DROP DEFAULT');
|
||||
$this->addSql(
|
||||
<<<'SQL'
|
||||
UPDATE chill_doc.stored_object SET filename=sov.filename, type=sov.type, iv=sov.iv, key=sov.key
|
||||
FROM chill_doc.stored_object_version sov WHERE sov.stored_object_id = stored_object.id
|
||||
AND sov.version = (SELECT MAX(version) FROM chill_doc.stored_object_version AS sub_sov WHERE sub_sov.stored_object_id = stored_object.id)
|
||||
SQL
|
||||
);
|
||||
$this->addSql('ALTER TABLE chill_doc.stored_object_version DROP CONSTRAINT FK_C1D55302232D562B');
|
||||
$this->addSql('ALTER TABLE chill_doc.stored_object_version DROP CONSTRAINT FK_C1D553023174800F');
|
||||
$this->addSql('DROP TABLE chill_doc.stored_object_version');
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user