Merge MR !636 into master: Merge async upload bundle into chill-bundles

See https://gitlab.com/Chill-Projet/chill-bundles/-/merge_requests/636

Merge remote-tracking branch 'origin/async-upload-merge' into upgrade-sf5
This commit is contained in:
Julien Fastré 2023-12-12 16:18:49 +01:00
commit b64ac8fd20
Signed by: julienfastre
GPG Key ID: BDE2190974723FCB
41 changed files with 1639 additions and 232 deletions

View File

@ -0,0 +1,5 @@
kind: Feature
body: '[DX] move async-upload-bundle features into chill-bundles'
time: 2023-12-12T15:48:41.954970271+01:00
custom:
Issue: "221"

View File

@ -12,7 +12,6 @@
"ext-json": "*",
"ext-openssl": "*",
"ext-redis": "*",
"champs-libres/async-uploader-bundle": "dev-master@dev",
"champs-libres/wopi-bundle": "dev-master@dev",
"champs-libres/wopi-lib": "dev-master@dev",
"doctrine/doctrine-bundle": "^2.1",

View File

@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\AsyncUpload\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class ConfigureOpenstackObjectStorageCommand extends Command
{
private readonly string $basePath;
private readonly string $tempUrlKey;
public function __construct(private readonly HttpClientInterface $client, ParameterBagInterface $parameterBag)
{
$config = $parameterBag->get('chill_doc_store')['openstack']['temp_url'];
$this->tempUrlKey = $config['temp_url_key'];
$this->basePath = $config['temp_url_base_path'];
parent::__construct();
}
protected function configure()
{
$this->setDescription('Configure openstack container to store documents')
->setName('chill:doc-store:configure-openstack')
->addOption('os_token', 'o', InputOption::VALUE_REQUIRED, 'Openstack token')
->addOption('domain', 'd', InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'Domain name')
;
}
protected function interact(InputInterface $input, OutputInterface $output)
{
if (!$input->hasOption('os_token')) {
$output->writeln('The option os_token is required');
throw new \RuntimeException('The option os_token is required');
}
if (0 === count($input->getOption('domain'))) {
$output->writeln('At least one occurence of option domain is required');
throw new \RuntimeException('At least one occurence of option domain is required');
}
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$domains = trim(implode(' ', $input->getOption('domain')));
if ($output->isVerbose()) {
$output->writeln(['Domains configured will be', $domains]);
}
try {
$response = $this->client->request('POST', $this->basePath, [
'headers' => [
'X-Auth-Token' => $input->getOption('os_token'),
'X-Container-Meta-Access-Control-Allow-Origin' => $domains,
'X-Container-Meta-Temp-URL-Key' => $this->tempUrlKey,
],
]);
$response->getContent();
} catch (HttpExceptionInterface $e) {
$output->writeln('Error');
$output->writeln($e->getMessage());
if ($output->isVerbose()) {
$output->writeln($e->getTraceAsString());
}
}
return 0;
}
}

View File

@ -0,0 +1,204 @@
<?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\AsyncUpload\Driver\OpenstackObjectStore;
use Chill\DocStoreBundle\AsyncUpload\Event\TempUrlGenerateEvent;
use Chill\DocStoreBundle\AsyncUpload\Exception\TempUrlGeneratorException;
use Chill\DocStoreBundle\AsyncUpload\SignedUrl;
use Chill\DocStoreBundle\AsyncUpload\SignedUrlPost;
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
/**
* Generate a temp url.
*/
final readonly class TempUrlOpenstackGenerator implements TempUrlGeneratorInterface
{
private const CHARACTERS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
private string $base_url;
private string $key;
private int $max_expire_delay;
private int $max_submit_delay;
private int $max_post_file_size;
private int $max_file_count;
public function __construct(
private LoggerInterface $logger,
private EventDispatcherInterface $event_dispatcher,
private ClockInterface $clock,
ParameterBagInterface $parameterBag,
) {
$config = $parameterBag->get('chill_doc_store')['openstack']['temp_url'];
$this->key = $config['temp_url_key'];
$this->base_url = $config['temp_url_base_path'];
$this->max_expire_delay = $config['max_expires_delay'];
$this->max_submit_delay = $config['max_submit_delay'];
$this->max_post_file_size = $config['max_post_file_size'];
$this->max_file_count = $config['max_post_file_count'];
}
/**
* @throws TempUrlGeneratorException
*/
public function generatePost(
int $expire_delay = null,
int $submit_delay = null,
int $max_file_count = 1,
): SignedUrlPost {
$delay = $expire_delay ?? $this->max_expire_delay;
$submit_delay ??= $this->max_submit_delay;
if ($delay < 2) {
throw new TempUrlGeneratorException("The delay of {$delay} is too ".'short (<2 sec) to properly use this token');
}
if ($delay > $this->max_expire_delay) {
throw new TempUrlGeneratorException('The given delay is greater than the max delay authorized.');
}
if ($submit_delay < 15) {
throw new TempUrlGeneratorException("The submit delay of {$delay} is too ".'short (<15 sec) to properly use this token');
}
if ($submit_delay > $this->max_submit_delay) {
throw new TempUrlGeneratorException('The given submit delay is greater than the max submit delay authorized.');
}
if ($max_file_count > $this->max_file_count) {
throw new TempUrlGeneratorException('The number of files is greater than the authorized number of files');
}
$expires = $this->clock->now()->add(new \DateInterval('PT'.(string) $delay.'S'));
$object_name = $this->generateObjectName();
$g = new SignedUrlPost(
$url = $this->generateUrl($object_name),
$expires,
$this->max_post_file_size,
$max_file_count,
$submit_delay,
'',
$object_name,
$this->generateSignaturePost($url, $expires)
);
$this->event_dispatcher->dispatch(
new TempUrlGenerateEvent($g),
);
$this->logger->info(
'generate signature for url',
(array) $g
);
return $g;
}
/**
* Generate an absolute public url for a GET request on the object.
*/
public function generate(string $method, string $object_name, int $expire_delay = null): SignedUrl
{
if ($expire_delay > $this->max_expire_delay) {
throw new TempUrlGeneratorException(sprintf('The expire delay (%d) is greater than the max_expire_delay (%d)', $expire_delay, $this->max_expire_delay));
}
$url = $this->generateUrl($object_name);
$expires = $this->clock->now()
->add(new \DateInterval(sprintf('PT%dS', $expire_delay ?? $this->max_expire_delay)));
$args = [
'temp_url_sig' => $this->generateSignature($method, $url, $expires),
'temp_url_expires' => $expires->format('U'),
];
$url = $url.'?'.\http_build_query($args);
$signature = new SignedUrl(strtoupper($method), $url, $expires);
$this->event_dispatcher->dispatch(
new TempUrlGenerateEvent($signature)
);
return $signature;
}
private function generateUrl($relative_path): string
{
return match (str_ends_with($this->base_url, '/')) {
true => $this->base_url.$relative_path,
false => $this->base_url.'/'.$relative_path
};
}
private function generateObjectName()
{
// inspiration from https://stackoverflow.com/a/4356295/1572236
$charactersLength = strlen(self::CHARACTERS);
$randomString = '';
for ($i = 0; $i < 21; ++$i) {
$randomString .= self::CHARACTERS[random_int(0, $charactersLength - 1)];
}
return $randomString;
}
private function generateSignaturePost($url, \DateTimeImmutable $expires)
{
$path = \parse_url((string) $url, PHP_URL_PATH);
$body = sprintf(
"%s\n%s\n%s\n%s\n%s",
$path,
'', // redirect is empty
(string) $this->max_post_file_size,
(string) $this->max_file_count,
$expires->format('U')
)
;
$this->logger->debug(
'generate signature post',
['url' => $body, 'method' => 'POST']
);
return \hash_hmac('sha512', $body, $this->key, false);
}
private function generateSignature($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,
$expires->format('U'),
$path
)
;
$this->logger->debug(
'generate signature GET',
['url' => $body, 'method' => 'GET']
);
return \hash_hmac('sha512', $body, $this->key, false);
}
}

View File

@ -0,0 +1,31 @@
<?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\AsyncUpload\Event;
use Chill\DocStoreBundle\AsyncUpload\SignedUrl;
final class TempUrlGenerateEvent extends \Symfony\Contracts\EventDispatcher\Event
{
final public const NAME_GENERATE = 'async_uploader.generate_url';
public function __construct(private readonly SignedUrl $data) {}
public function getMethod(): string
{
return $this->data->method;
}
public function getData(): SignedUrl
{
return $this->data;
}
}

View File

@ -0,0 +1,20 @@
<?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\AsyncUpload\Exception;
class BadCallToRemoteServer extends \LogicException
{
public function __construct(string $content, int $statusCode, int $code = 0, \Throwable $previous = null)
{
parent::__construct("Bad call to remote server: {$statusCode}, {$content}", $code, $previous);
}
}

View File

@ -0,0 +1,14 @@
<?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\AsyncUpload\Exception;
class TempUrlGeneratorException extends \RuntimeException {}

View File

@ -0,0 +1,20 @@
<?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\AsyncUpload\Exception;
class TempUrlRemoteServerException extends \RuntimeException
{
public function __construct(int $statusCode, int $code = 0, \Throwable $previous = null)
{
parent::__construct('Could not reach remote server: '.(string) $statusCode, $code, $previous);
}
}

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\AsyncUpload;
use Symfony\Component\Serializer\Annotation as Serializer;
readonly class SignedUrl
{
public function __construct(
/**
* @Serializer\Groups({"read"})
*/
public string $method,
/**
* @Serializer\Groups({"read"})
*/
public string $url,
public \DateTimeImmutable $expires,
) {}
/**
* @Serializer\Groups({"read"})
*/
public function getExpires(): int
{
return $this->expires->getTimestamp();
}
}

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\AsyncUpload;
use Symfony\Component\Serializer\Annotation as Serializer;
readonly class SignedUrlPost extends SignedUrl
{
public function __construct(
string $url,
\DateTimeImmutable $expires,
/**
* @Serializer\Groups({"read"})
*/
public int $max_file_size,
/**
* @Serializer\Groups({"read"})
*/
public int $max_file_count,
/**
* @Serializer\Groups({"read"})
*/
public int $submit_delay,
/**
* @Serializer\Groups({"read"})
*/
public string $redirect,
/**
* @Serializer\Groups({"read"})
*/
public string $prefix,
/**
* @Serializer\Groups({"read"})
*/
public string $signature,
) {
parent::__construct('POST', $url, $expires);
}
}

View File

@ -0,0 +1,23 @@
<?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\AsyncUpload;
interface TempUrlGeneratorInterface
{
public function generatePost(
int $expire_delay = null,
int $submit_delay = null,
int $max_file_count = 1
): SignedUrlPost;
public function generate(string $method, string $object_name, int $expire_delay = null): SignedUrl;
}

View File

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\AsyncUpload\Templating;
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
use Chill\DocStoreBundle\Entity\StoredObject;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
/**
* This class extends the AbstractExtension class and provides Twig filter functions for generating URLs for asynchronous
* file uploads.
*/
class AsyncUploadExtension extends AbstractExtension
{
public function __construct(
private readonly TempUrlGeneratorInterface $tempUrlGenerator,
private readonly UrlGeneratorInterface $routingUrlGenerator
) {}
public function getFilters()
{
return [
new TwigFilter('file_url', $this->computeSignedUrl(...)),
new TwigFilter('generate_url', $this->computeGenerateUrl(...)),
];
}
public function computeSignedUrl(StoredObject|string $file, string $method = 'GET', int $expiresDelay = null): string
{
if ($file instanceof StoredObject) {
$object_name = $file->getFilename();
} else {
$object_name = $file;
}
return $this->tempUrlGenerator->generate($method, $object_name, $expiresDelay)->url;
}
public function computeGenerateUrl(StoredObject|string $file, string $method = 'GET', int $expiresDelay = null): string
{
if ($file instanceof StoredObject) {
$object_name = $file->getFilename();
} else {
$object_name = $file;
}
$args = [
'method' => $method,
'object_name' => $object_name,
];
if (null !== $expiresDelay) {
$args['expires_delay'] = $expiresDelay;
}
return $this->routingUrlGenerator->generate('async_upload.generate_url', $args);
}
}

View File

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Controller;
use Chill\DocStoreBundle\AsyncUpload\Exception\TempUrlGeneratorException;
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
use Chill\DocStoreBundle\Security\Authorization\AsyncUploadVoter;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\SerializerInterface;
final readonly class AsyncUploadController
{
public function __construct(
private TempUrlGeneratorInterface $tempUrlGenerator,
private SerializerInterface $serializer,
private Security $security,
private LoggerInterface $logger,
) {}
/**
* @Route("/asyncupload/temp_url/generate/{method}",
* name="async_upload.generate_url")
*/
public function getSignedUrl(string $method, Request $request): 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 (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);
}
} 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);
}
if (!$this->security->isGranted(AsyncUploadVoter::GENERATE_SIGNATURE, $p)) {
throw new AccessDeniedHttpException('not allowed to generate this signature');
}
return new JsonResponse(
$this->serializer->serialize($p, 'json', [AbstractNormalizer::GROUPS => ['read']]),
Response::HTTP_OK,
[],
true
);
}
}

View File

@ -32,9 +32,10 @@ class ChillDocStoreExtension extends Extension implements PrependExtensionInterf
$configuration = new Configuration();
$config = $this->processConfiguration($configuration, $configs);
$container->setParameter('chill_doc_store', $config);
$loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../config'));
$loader->load('services.yaml');
$loader->load('services/media.yaml');
$loader->load('services/controller.yaml');
$loader->load('services/menu.yaml');
$loader->load('services/fixtures.yaml');
@ -94,7 +95,6 @@ class ChillDocStoreExtension extends Extension implements PrependExtensionInterf
'routing' => [
'resources' => [
'@ChillDocStoreBundle/config/routes.yaml',
'@ChampsLibresAsyncUploaderBundle/config/routes.yaml',
],
],
]);

View File

@ -11,6 +11,7 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\DependencyInjection;
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
@ -24,11 +25,68 @@ class Configuration implements ConfigurationInterface
public function getConfigTreeBuilder()
{
$treeBuilder = new TreeBuilder('chill_doc_store');
/** @var ArrayNodeDefinition $rootNode */
$rootNode = $treeBuilder->getRootNode();
// Here you should define the parameters that are allowed to
// configure your bundle. See the documentation linked above for
// more information on that topic.
/* @phpstan-ignore-next-line As there are inconsistencies in return types, but the code works... */
$rootNode->children()
// openstack node
->arrayNode('openstack')
->info('parameters to authenticate and generate temp url against the openstack object storage service')
->addDefaultsIfNotSet()
->children()
// openstack.temp_url
->arrayNode('temp_url')
->addDefaultsIfNotSet()
->children()
// openstack.temp_url.temp_url_key
->scalarNode('temp_url_key')
->isRequired()->cannotBeEmpty()
->info('the temp url key')
->end()
->scalarNode('temp_url_base_path')
->isRequired()->cannotBeEmpty()
->info('the base path to add **before** the path to media. Must contains the container')
->end()
->scalarNode('container')
->info('the container name')
->isRequired()->cannotBeEmpty()
->end()
->integerNode('max_post_file_size')
->defaultValue(15_000_000)
->info('Maximum size of the posted file, in bytes')
->end()
->integerNode('max_post_file_count')
->defaultValue(1)
->info('Maximum number of files which may be posted at once using a POST operation using async upload')
->end()
->integerNode('max_expires_delay')
->defaultValue(180)
->info('the maximum of seconds a cryptographic signature '
.'will be valid for submitting a file. This should be '
.'short, to avoid uploading multiple files')
->end()
->integerNode('max_submit_delay')
->defaultValue(3600)
->info('the maximum of seconds between the upload of a file and '
.'a the submission of the form. This delay will also prevent '
.'the check of persistence of uploaded file. Should be long '
.'enough for keeping user-friendly forms')
->end()
->end() // end of children 's temp_url
->end() // end array temp_url
->end() // end of children's openstack
->end() // end of openstack
->end() // end of root's children
->end();
return $treeBuilder;
}

View File

@ -11,8 +11,7 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Entity;
use ChampsLibres\AsyncUploaderBundle\Model\AsyncFileInterface;
use ChampsLibres\AsyncUploaderBundle\Validator\Constraints\AsyncFileExists;
use Chill\DocStoreBundle\Validator\Constraints\AsyncFileExists;
use ChampsLibres\WopiLib\Contract\Entity\Document;
use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
@ -33,7 +32,7 @@ use Symfony\Component\Serializer\Annotation as Serializer;
* message="The file is not stored properly"
* )
*/
class StoredObject implements AsyncFileInterface, Document, TrackCreationInterface
class StoredObject implements Document, TrackCreationInterface
{
use TrackCreationTrait;
final public const STATUS_READY = 'ready';

View File

@ -14,13 +14,10 @@ namespace Chill\DocStoreBundle\Form;
use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument;
use Chill\DocStoreBundle\Entity\Document;
use Chill\DocStoreBundle\Entity\DocumentCategory;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Form\Type\ChillDateType;
use Chill\MainBundle\Form\Type\ChillTextareaType;
use Chill\MainBundle\Security\Authorization\AuthorizationHelper;
use Chill\MainBundle\Templating\TranslatableStringHelper;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\Persistence\ObjectManager;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
@ -29,33 +26,9 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class AccompanyingCourseDocumentType extends AbstractType
{
/**
* @var AuthorizationHelper
*/
protected $authorizationHelper;
/**
* @var ObjectManager
*/
protected $om;
/**
* @var TranslatableStringHelper
*/
protected $translatableStringHelper;
/**
* the user running this form.
*
* @var User
*/
protected $user;
public function __construct(
TranslatableStringHelper $translatableStringHelper
) {
$this->translatableStringHelper = $translatableStringHelper;
}
private readonly TranslatableStringHelperInterface $translatableStringHelper
) {}
public function buildForm(FormBuilderInterface $builder, array $options)
{

View File

@ -20,23 +20,15 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class DocumentCategoryType extends AbstractType
{
private $chillBundlesFlipped;
public function __construct($kernelBundles)
{
// TODO faire un service dans CHillMain
foreach ($kernelBundles as $key => $value) {
if (str_starts_with((string) $key, 'Chill')) {
$this->chillBundlesFlipped[$value] = $key;
}
}
}
public function buildForm(FormBuilderInterface $builder, array $options)
{
$bundles = [
'chill-doc-store' => 'chill-doc-store',
];
$builder
->add('bundleId', ChoiceType::class, [
'choices' => $this->chillBundlesFlipped,
'choices' => $bundles,
'disabled' => false,
])
->add('idInsideBundle', null, [
@ -44,7 +36,7 @@ class DocumentCategoryType extends AbstractType
])
->add('documentClass', null, [
'disabled' => false,
]) // cahcerh par default PersonDocument
])
->add('name', TranslatableStringFormType::class);
}

View File

@ -11,7 +11,7 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Form;
use ChampsLibres\AsyncUploaderBundle\Form\Type\AsyncUploaderType;
use Chill\DocStoreBundle\Form\Type\AsyncUploaderType;
use Chill\DocStoreBundle\Entity\StoredObject;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Form\AbstractType;
@ -26,15 +26,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
*/
class StoredObjectType extends AbstractType
{
/**
* @var EntityManagerInterface
*/
protected $em;
public function __construct(EntityManagerInterface $em)
{
$this->em = $em;
}
public function __construct(private readonly EntityManagerInterface $em) {}
public function buildForm(FormBuilderInterface $builder, array $options)
{

View File

@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Form\Type;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class AsyncUploaderType extends AbstractType
{
private readonly int $expires_delay;
private readonly int $max_submit_delay;
private readonly int $max_post_file_size;
public function __construct(
private readonly UrlGeneratorInterface $url_generator,
ParameterBagInterface $parameters,
) {
$config = $parameters->get('chill_doc_store')['openstack']['temp_url'];
$this->expires_delay = $config['max_expires_delay'];
$this->max_submit_delay = $config['max_submit_delay'];
$this->max_post_file_size = $config['max_post_file_size'];
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'expires_delay' => $this->expires_delay,
'max_post_size' => $this->max_post_file_size,
'submit_delay' => $this->max_submit_delay,
'max_files' => 1,
'error_bubbling' => false,
]);
$resolver->setAllowedTypes('expires_delay', ['int']);
$resolver->setAllowedTypes('max_post_size', ['int']);
$resolver->setAllowedTypes('max_files', ['int']);
$resolver->setAllowedTypes('submit_delay', ['int']);
}
public function buildView(
FormView $view,
FormInterface $form,
array $options
) {
$view->vars['attr']['data-async-file-upload'] = true;
$view->vars['attr']['data-generate-temp-url-post'] = $this
->url_generator->generate('async_upload.generate_url', [
'expires_delay' => $options['expires_delay'],
'method' => 'post',
'submit_delay' => $options['submit_delay'],
]);
$view->vars['attr']['data-temp-url-get'] = $this->url_generator
->generate('async_upload.generate_url', ['method' => 'GET']);
$view->vars['attr']['data-max-files'] = $options['max_files'];
$view->vars['attr']['data-max-post-size'] = $options['max_post_size'];
}
public function getParent()
{
return HiddenType::class;
}
}

View File

@ -1,47 +0,0 @@
<?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\Object;
use ChampsLibres\AsyncUploaderBundle\Form\AsyncFileTransformer\AsyncFileTransformerInterface;
use ChampsLibres\AsyncUploaderBundle\Model\AsyncFileInterface;
use Chill\DocStoreBundle\Entity\StoredObject;
use Doctrine\ORM\EntityManagerInterface;
class ObjectToAsyncFileTransformer implements AsyncFileTransformerInterface
{
/**
* @var EntityManagerInterface
*/
protected $em;
public function __construct(EntityManagerInterface $em)
{
$this->em = $em;
}
public function toAsyncFile($data)
{
if ($data instanceof StoredObject) {
return $data;
}
}
public function toData(AsyncFileInterface $asyncFile)
{
$object = $this->em
->getRepository(StoredObject::class)
->findByFilename($asyncFile->getObjectName());
return $object ?? (new StoredObject())
->setFilename($asyncFile->getObjectName());
}
}

View File

@ -1,40 +0,0 @@
<?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\Object;
use ChampsLibres\AsyncUploaderBundle\Persistence\PersistenceCheckerInterface;
use Chill\DocStoreBundle\Entity\StoredObject;
use Doctrine\ORM\EntityManagerInterface;
class PersistenceChecker implements PersistenceCheckerInterface
{
/**
* @var EntityManagerInterface
*/
protected $em;
public function __construct(EntityManagerInterface $em)
{
$this->em = $em;
}
public function isPersisted($object_name): bool
{
$qb = $this->em->createQueryBuilder();
$qb->select('COUNT(m)')
->from(StoredObject::class, 'm')
->where($qb->expr()->eq('m.filename', ':object_name'))
->setParameter('object_name', $object_name);
return 1 === $qb->getQuery()->getSingleScalarResult();
}
}

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Security\Authorization;
use Chill\DocStoreBundle\AsyncUpload\SignedUrl;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\Security;
final class AsyncUploadVoter extends Voter
{
public const GENERATE_SIGNATURE = 'CHILL_DOC_GENERATE_ASYNC_SIGNATURE';
public function __construct(
private readonly Security $security,
) {}
protected function supports($attribute, $subject): bool
{
return self::GENERATE_SIGNATURE === $attribute && $subject instanceof SignedUrl;
}
protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
{
/** @var SignedUrl $subject */
if (!in_array($subject->method, ['POST', 'GET', 'HEAD'], true)) {
return false;
}
return $this->security->isGranted('ROLE_USER') || $this->security->isGranted('ROLE_ADMIN');
}
}

View File

@ -12,7 +12,7 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Service;
use Base64Url\Base64Url;
use ChampsLibres\AsyncUploaderBundle\TempUrl\TempUrlGeneratorInterface;
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
use Symfony\Component\HttpFoundation\Request;
@ -27,7 +27,10 @@ final class StoredObjectManager implements StoredObjectManagerInterface
private array $inMemory = [];
public function __construct(private readonly HttpClientInterface $client, private readonly TempUrlGeneratorInterface $tempUrlGenerator) {}
public function __construct(
private readonly HttpClientInterface $client,
private readonly TempUrlGeneratorInterface $tempUrlGenerator
) {}
public function getLastModified(StoredObject $document): \DateTimeInterface
{

View File

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace AsyncUpload\Command;
use Chill\DocStoreBundle\AsyncUpload\Command\ConfigureOpenstackObjectStorageCommand;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
/**
* @internal
*
* @coversNothing
*/
class ConfigureOpenstackObjectStorageCommandTest extends TestCase
{
public function testRun(): void
{
$client = new MockHttpClient(function ($method, $url, $options): MockResponse {
self::assertSame('POST', $method);
self::assertSame($url, 'https://object.store.example/v1/AUTH/container');
$headers = $options['headers'];
self::assertContains('X-Auth-Token: abc', $headers);
self::assertContains('X-Container-Meta-Temp-URL-Key: 12345679801234567890', $headers);
self::assertContains('X-Container-Meta-Access-Control-Allow-Origin: https://chill.domain.social https://chill2.domain.social', $headers);
return new MockResponse('', ['http_code' => 204]);
});
$parameters = new ParameterBag([
'chill_doc_store' => [
'openstack' => [
'temp_url' => [
'temp_url_key' => '12345679801234567890',
'temp_url_base_path' => 'https://object.store.example/v1/AUTH/container',
],
],
],
]);
$command = new ConfigureOpenstackObjectStorageCommand($client, $parameters);
$tester = new CommandTester($command);
$status = $tester->execute([
'--os_token' => 'abc',
'--domain' => ['https://chill.domain.social', 'https://chill2.domain.social'],
]);
self::assertSame(0, $status);
}
}

View File

@ -0,0 +1,176 @@
<?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 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\Component\Clock\MockClock;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag;
use Symfony\Component\EventDispatcher\EventDispatcher;
/**
* @internal
*
* @coversNothing
*/
class TempUrlOpenstackGeneratorTest extends TestCase
{
/**
* @dataProvider dataProviderGenerate
*/
public function testGenerate(string $baseUrl, \DateTimeImmutable $now, string $key, string $method, string $objectName, int $expireDelay, SignedUrl $expected): void
{
$logger = new NullLogger();
$eventDispatcher = new EventDispatcher();
$clock = new MockClock($now);
$parameters = new ParameterBag(
[
'chill_doc_store' => [
'openstack' => [
'temp_url' => [
'temp_url_key' => $key,
'temp_url_base_path' => $baseUrl,
'max_post_file_size' => 150,
'max_post_file_count' => 1,
'max_expires_delay' => 1800,
'max_submit_delay' => 1800,
],
],
],
]
);
$generator = new TempUrlOpenstackGenerator(
$logger,
$eventDispatcher,
$clock,
$parameters,
);
$signedUrl = $generator->generate($method, $objectName, $expireDelay);
self::assertEquals($expected, $signedUrl);
}
/**
* @dataProvider dataProviderGeneratePost
*/
public function testGeneratePost(string $baseUrl, \DateTimeImmutable $now, string $key, string $method, string $objectName, int $expireDelay, SignedUrl $expected): void
{
$logger = new NullLogger();
$eventDispatcher = new EventDispatcher();
$clock = new MockClock($now);
$parameters = new ParameterBag(
[
'chill_doc_store' => [
'openstack' => [
'temp_url' => [
'temp_url_key' => $key,
'temp_url_base_path' => $baseUrl,
'max_post_file_size' => 150,
'max_post_file_count' => 1,
'max_expires_delay' => 1800,
'max_submit_delay' => 1800,
],
],
],
]
);
$generator = new TempUrlOpenstackGenerator(
$logger,
$eventDispatcher,
$clock,
$parameters,
);
$signedUrl = $generator->generatePost();
self::assertEquals('POST', $signedUrl->method);
self::assertEquals((int) $clock->now()->format('U') + 1800, $signedUrl->expires->getTimestamp());
self::assertEquals(150, $signedUrl->max_file_size);
self::assertEquals(1, $signedUrl->max_file_count);
self::assertEquals(1800, $signedUrl->submit_delay);
self::assertEquals('', $signedUrl->redirect);
self::assertGreaterThanOrEqual(20, strlen($signedUrl->prefix));
}
public function dataProviderGenerate(): iterable
{
$now = \DateTimeImmutable::createFromFormat('U', '1702041743');
$expireDelay = 1800;
$baseUrls = [
'https://objectstore.example/v1/my_account/container/',
'https://objectstore.example/v1/my_account/container',
];
$objectName = 'object';
$method = 'GET';
$key = 'MYKEY';
$signedUrl = new SignedUrl(
'GET',
'https://objectstore.example/v1/my_account/container/object?temp_url_sig=0aeef353a5f6e22d125c76c6ad8c644a59b222ba1b13eaeb56bf3d04e28b081d11dfcb36601ab3aa7b623d79e1ef03017071bbc842fb7b34afec2baff895bf80&temp_url_expires=1702043543',
\DateTimeImmutable::createFromFormat('U', '1702043543')
);
foreach ($baseUrls as $baseUrl) {
yield [
$baseUrl,
$now,
$key,
$method,
$objectName,
$expireDelay,
$signedUrl,
];
}
}
public function dataProviderGeneratePost(): iterable
{
$now = \DateTimeImmutable::createFromFormat('U', '1702041743');
$expireDelay = 1800;
$baseUrls = [
'https://objectstore.example/v1/my_account/container/',
'https://objectstore.example/v1/my_account/container',
];
$objectName = 'object';
$method = 'GET';
$key = 'MYKEY';
$signedUrl = new SignedUrlPost(
'https://objectstore.example/v1/my_account/container/object?temp_url_sig=0aeef353a5f6e22d125c76c6ad8c644a59b222ba1b13eaeb56bf3d04e28b081d11dfcb36601ab3aa7b623d79e1ef03017071bbc842fb7b34afec2baff895bf80&temp_url_expires=1702043543',
\DateTimeImmutable::createFromFormat('U', '1702043543'),
150,
1,
1800,
'',
'abc',
'abc'
);
foreach ($baseUrls as $baseUrl) {
yield [
$baseUrl,
$now,
$key,
$method,
$objectName,
$expireDelay,
$signedUrl,
];
}
}
}

View File

@ -0,0 +1,77 @@
<?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 AsyncUpload\Templating;
use Chill\DocStoreBundle\AsyncUpload\SignedUrl;
use Chill\DocStoreBundle\AsyncUpload\Templating\AsyncUploadExtension;
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
use Chill\DocStoreBundle\Entity\StoredObject;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* @internal
*
* @coversNothing
*/
class AsyncUploadExtensionTest extends KernelTestCase
{
use ProphecyTrait;
private AsyncUploadExtension $asyncUploadExtension;
public function setUp(): void
{
$generator = $this->prophesize(TempUrlGeneratorInterface::class);
$generator->generate(Argument::in(['GET', 'POST']), Argument::type('string'), Argument::any())
->will(fn (array $args): SignedUrl => new SignedUrl($args[0], 'https://object.store.example/container/'.$args[1], new \DateTimeImmutable('1 hours')));
$urlGenerator = $this->prophesize(UrlGeneratorInterface::class);
$urlGenerator->generate('async_upload.generate_url', Argument::type('array'))
->willReturn('url');
$this->asyncUploadExtension = new AsyncUploadExtension(
$generator->reveal(),
$urlGenerator->reveal()
);
}
/**
* @dataProvider dataProviderStoredObject
*/
public function testComputeSignedUrl(StoredObject|string $storedObject): void
{
$actual = $this->asyncUploadExtension->computeSignedUrl($storedObject);
self::assertStringContainsString('https://object.store.example/container', $actual);
self::assertStringContainsString(is_string($storedObject) ? $storedObject : $storedObject->getFilename(), $actual);
}
/**
* @dataProvider dataProviderStoredObject
*/
public function testComputeGenerateUrl(StoredObject|string $storedObject): void
{
$actual = $this->asyncUploadExtension->computeGenerateUrl($storedObject);
self::assertEquals('url', $actual);
}
public function dataProviderStoredObject(): iterable
{
yield [(new StoredObject())->setFilename('blabla')];
yield ['blabla'];
}
}

View File

@ -0,0 +1,110 @@
<?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\AsyncUpload\SignedUrl;
use Chill\DocStoreBundle\AsyncUpload\SignedUrlPost;
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
use Chill\DocStoreBundle\Controller\AsyncUploadController;
use Chill\DocStoreBundle\Security\Authorization\AsyncUploadVoter;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\NullLogger;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\SerializerInterface;
/**
* @internal
*
* @coversNothing
*/
class AsyncUploadControllerTest extends TestCase
{
use ProphecyTrait;
public function testGenerateWhenUserIsNotGranted(): void
{
$this->expectException(AccessDeniedHttpException::class);
$controller = $this->buildAsyncUploadController(false);
$controller->getSignedUrl('POST', new Request());
}
public function testGeneratePost(): void
{
$controller = $this->buildAsyncUploadController(true);
$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
{
$controller = $this->buildAsyncUploadController(true);
$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
{
return new SignedUrlPost(
'https://object.store.example',
new \DateTimeImmutable('1 hour'),
150,
1,
1800,
'',
'abc',
'abc'
);
}
public function generate(string $method, string $object_name, int $expire_delay = null): SignedUrl
{
return new SignedUrl(
$method,
'https://object.store.example',
new \DateTimeImmutable('1 hour')
);
}
};
$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));
$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()
);
}
}

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\Serializer\Normalizer;
use Chill\DocStoreBundle\AsyncUpload\SignedUrl;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
/**
* @internal
*
* @coversNothing
*/
class SignedUrlNormalizerTest extends KernelTestCase
{
public static NormalizerInterface $normalizer;
public static function setUpBeforeClass(): void
{
parent::setUpBeforeClass();
self::bootKernel();
self::$normalizer = self::$container->get(NormalizerInterface::class);
}
public function testNormalizerSignedUrl(): void
{
$signedUrl = new SignedUrl(
'GET',
'https://object.store.example/container/object',
\DateTimeImmutable::createFromFormat('U', '1700000')
);
$actual = self::$normalizer->normalize($signedUrl, 'json', [AbstractNormalizer::GROUPS => ['read']]);
self::assertEqualsCanonicalizing(
[
'method' => 'GET',
'expires' => 1_700_000,
'url' => 'https://object.store.example/container/object',
],
$actual
);
}
}

View File

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\Serializer\Normalizer;
use Chill\DocStoreBundle\AsyncUpload\SignedUrlPost;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
/**
* @internal
*
* @coversNothing
*/
class SignedUrlPostNormalizerTest extends KernelTestCase
{
public static NormalizerInterface $normalizer;
public static function setUpBeforeClass(): void
{
parent::setUpBeforeClass();
self::bootKernel();
self::$normalizer = self::$container->get(NormalizerInterface::class);
}
public function testNormalizerSignedUrl(): void
{
$signedUrl = new SignedUrlPost(
'https://object.store.example/container/object',
\DateTimeImmutable::createFromFormat('U', '1700000'),
15000,
1,
180,
'',
'abc',
'SiGnaTure'
);
$actual = self::$normalizer->normalize($signedUrl, 'json', [AbstractNormalizer::GROUPS => ['read']]);
self::assertEqualsCanonicalizing(
[
'max_file_size' => 15000,
'max_file_count' => 1,
'submit_delay' => 180,
'redirect' => '',
'prefix' => 'abc',
'signature' => 'SiGnaTure',
'method' => 'POST',
'expires' => 1_700_000,
'url' => 'https://object.store.example/container/object',
],
$actual
);
}
}

View File

@ -11,7 +11,8 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Tests;
use ChampsLibres\AsyncUploaderBundle\TempUrl\TempUrlGeneratorInterface;
use Chill\DocStoreBundle\AsyncUpload\SignedUrl;
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
use Chill\DocStoreBundle\Service\StoredObjectManager;
@ -163,8 +164,11 @@ final class StoredObjectManagerTest extends TestCase
private function getTempUrlGenerator(StoredObject $storedObject): TempUrlGeneratorInterface
{
$response = new \stdClass();
$response->url = $storedObject->getFilename();
$response = new SignedUrl(
'PUT',
'https://example.com/'.$storedObject->getFilename(),
new \DateTimeImmutable('1 hours')
);
$tempUrlGenerator = $this->createMock(TempUrlGeneratorInterface::class);

View File

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\Validator\Constraints;
use Chill\DocStoreBundle\AsyncUpload\SignedUrl;
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Validator\Constraints\AsyncFileExists;
use Chill\DocStoreBundle\Validator\Constraints\AsyncFileExistsValidator;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @internal
*
* @coversNothing
*/
class AsyncFileExistsValidatorTest extends ConstraintValidatorTestCase
{
use ProphecyTrait;
protected function createValidator()
{
$client = new MockHttpClient(function ($method, $url, $options): MockResponse {
if (str_contains((string) $url, '404')) {
return new MockResponse('', ['http_code' => 404]);
}
return new MockResponse('', ['http_code' => 200]);
});
$generator = $this->prophesize(TempUrlGeneratorInterface::class);
$generator->generate(Argument::in(['GET', 'HEAD']), Argument::type('string'), Argument::any())
->will(fn (array $args): SignedUrl => new SignedUrl($args[0], 'https://object.store.example/container/'.$args[1], new \DateTimeImmutable('1 hours')));
return new AsyncFileExistsValidator($generator->reveal(), $client);
}
public function testWhenFileExistsIsValid(): void
{
$this->validator->validate((new StoredObject())->setFilename('present'), new AsyncFileExists());
$this->assertNoViolation();
}
public function testWhenFileIsNotPresent(): void
{
$this->validator->validate(
(new StoredObject())->setFilename('is_404'),
new AsyncFileExists(['message' => 'my_message'])
);
$this->buildViolation('my_message')->setParameter('{{ filename }}', 'is_404')->assertRaised();
}
}

View File

@ -0,0 +1,32 @@
<?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\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
/**
* @Annotation
*/
final class AsyncFileExists extends Constraint
{
public string $message = "The file '{{ filename }}' is not stored properly.";
public function validatedBy()
{
return AsyncFileExistsValidator::class;
}
public function getTargets()
{
return [Constraint::CLASS_CONSTRAINT, Constraint::PROPERTY_CONSTRAINT];
}
}

View File

@ -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 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 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
) {}
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
);
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);
}
}
}

View File

@ -1,73 +1,58 @@
parameters:
# cl_chill_person.example.class: Chill\PersonBundle\Example
services:
_defaults:
autowire: true
autoconfigure: true
Chill\DocStoreBundle\Repository\:
autowire: true
autoconfigure: true
resource: "../Repository/"
Chill\DocStoreBundle\Form\DocumentCategoryType:
class: Chill\DocStoreBundle\Form\DocumentCategoryType
arguments: [ "%kernel.bundles%" ]
tags:
- { name: form.type }
- { name: doctrine.repository_service }
Chill\DocStoreBundle\Form\PersonDocumentType:
class: Chill\DocStoreBundle\Form\PersonDocumentType
autowire: true
autoconfigure: true
# arguments:
# - "@chill.main.helper.translatable_string"
tags:
- { name: form.type, alias: chill_docstorebundle_form_document }
Chill\DocStoreBundle\Security\Authorization\:
resource: "./../Security/Authorization"
autowire: true
autoconfigure: true
tags:
- { name: chill.role }
Chill\DocStoreBundle\Workflow\:
resource: './../Workflow/'
autoconfigure: true
autowire: true
Chill\DocStoreBundle\Serializer\Normalizer\:
autowire: true
resource: '../Serializer/Normalizer/'
tags:
- { name: 'serializer.normalizer', priority: 16 }
- { name: serializer.normalizer, priority: 16 }
Chill\DocStoreBundle\Service\:
autowire: true
autoconfigure: true
resource: '../Service/'
Chill\DocStoreBundle\GenericDoc\Manager:
autowire: true
autoconfigure: true
arguments:
$providersForAccompanyingPeriod: !tagged_iterator chill_doc_store.generic_doc_accompanying_period_provider
$providersForPerson: !tagged_iterator chill_doc_store.generic_doc_person_provider
Chill\DocStoreBundle\GenericDoc\Twig\GenericDocExtension:
autoconfigure: true
autowire: true
Chill\DocStoreBundle\GenericDoc\Twig\GenericDocExtension: ~
Chill\DocStoreBundle\GenericDoc\Twig\GenericDocExtensionRuntime:
autoconfigure: true
autowire: true
arguments:
$renderers: !tagged_iterator chill_doc_store.generic_doc_renderer
Chill\DocStoreBundle\GenericDoc\Providers\:
autowire: true
autoconfigure: true
resource: '../GenericDoc/Providers/'
Chill\DocStoreBundle\GenericDoc\Renderer\:
autowire: true
autoconfigure: true
resource: '../GenericDoc/Renderer/'
Chill\DocStoreBundle\Validator\:
resource: '../Validator'
Chill\DocStoreBundle\AsyncUpload\Driver\:
resource: '../AsyncUpload/Driver/'
Chill\DocStoreBundle\AsyncUpload\Templating\:
resource: '../AsyncUpload/Templating/'
Chill\DocStoreBundle\AsyncUpload\Command\:
resource: '../AsyncUpload/Command/'
Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface:
alias: Chill\DocStoreBundle\AsyncUpload\Driver\OpenstackObjectStore\TempUrlOpenstackGenerator

View File

@ -1,13 +1,15 @@
services:
Chill\DocStoreBundle\Form\StoredObjectType:
arguments:
$em: '@Doctrine\ORM\EntityManagerInterface'
tags:
- { name: form.type }
_defaults:
autowire: true
autoconfigure: true
Chill\DocStoreBundle\Form\AccompanyingCourseDocumentType:
class: Chill\DocStoreBundle\Form\AccompanyingCourseDocumentType
arguments:
- "@chill.main.helper.translatable_string"
tags:
- { name: form.type, alias: chill_docstorebundle_form_document }
Chill\DocStoreBundle\Form\:
resource: '../../Form'
Chill\DocStoreBundle\Form\PersonDocumentType:
tags:
- { name: form.type, alias: chill_docstorebundle_form_document }
Chill\DocStoreBundle\Form\AccompanyingCourseDocumentType:
tags:
- { name: form.type, alias: chill_docstorebundle_form_document }

View File

@ -1,9 +0,0 @@
services:
chill_doc_store.persistence_checker:
class: Chill\DocStoreBundle\Object\PersistenceChecker
arguments:
$em: '@Doctrine\ORM\EntityManagerInterface'
Chill\DocStoreBundle\Object\ObjectToAsyncFileTransformer:
arguments:
$em: '@Doctrine\ORM\EntityManagerInterface'

View File

@ -11,7 +11,6 @@ declare(strict_types=1);
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
use ChampsLibres\AsyncUploaderBundle\TempUrl\TempUrlGeneratorInterface;
use ChampsLibres\WopiBundle\Contracts\AuthorizationManagerInterface;
use ChampsLibres\WopiBundle\Contracts\UserManagerInterface;
use ChampsLibres\WopiBundle\Service\Wopi as CLWopi;
@ -60,8 +59,4 @@ return static function (ContainerConfigurator $container) {
->set(UserManager::class);
$services->alias(UserManagerInterface::class, UserManager::class);
// TODO: Move this into the async bundle (low priority)
$services
->alias(TempUrlGeneratorInterface::class, 'async_uploader.temp_url_generator');
};

View File

@ -10,7 +10,6 @@ declare(strict_types=1);
*/
return [
ChampsLibres\AsyncUploaderBundle\ChampsLibresAsyncUploaderBundle::class => ['all' => true],
Chill\ActivityBundle\ChillActivityBundle::class => ['all' => true],
Chill\AsideActivityBundle\ChillAsideActivityBundle::class => ['all' => true],
Chill\CalendarBundle\ChillCalendarBundle::class => ['all' => true],

View File

@ -1,14 +0,0 @@
champs_libres_async_uploader:
openstack:
os_username: '%env(resolve:OS_USERNAME)%' # Required
os_password: '%env(resolve:OS_PASSWORD)%' # Required
os_tenant_id: '%env(resolve:OS_TENANT_ID)%' # Required
os_region_name: '%env(resolve:OS_REGION_NAME)%' # Required
os_auth_url: '%env(resolve:OS_AUTH_URL)%' # Required
temp_url:
temp_url_key: '%env(resolve:ASYNC_UPLOAD_TEMP_URL_KEY)%' # Required
container: '%env(resolve:ASYNC_UPLOAD_TEMP_URL_CONTAINER)%' # Required
temp_url_base_path: '%env(resolve:ASYNC_UPLOAD_TEMP_URL_BASE_PATH)%' # Required. Do not forget a trailing slash
max_post_file_size: 15000000 # 15Mo (bytes)
max_expires_delay: 180
max_submit_delay: 3600

View File

@ -0,0 +1,6 @@
chill_doc_store:
openstack:
temp_url:
temp_url_key: '%env(resolve:ASYNC_UPLOAD_TEMP_URL_KEY)%' # Required
container: '%env(resolve:ASYNC_UPLOAD_TEMP_URL_CONTAINER)%' # Required
temp_url_base_path: '%env(resolve:ASYNC_UPLOAD_TEMP_URL_BASE_PATH)%' # Required