Compare commits

...

12 Commits

Author SHA1 Message Date
3fb04594eb add changie [ci-skip] 2023-12-12 15:55:14 +01:00
1065706e60 Re-configure test app 2023-12-12 15:55:14 +01:00
f131572344 Remove references to old async upload bundle 2023-12-12 15:55:14 +01:00
45e1ce034a Add ConfigureOpenstackObjectStorageCommand to ChillDocStoreBundle
Added new command file "ConfigureOpenstackObjectStorageCommand.php" under ChillDocStoreBundle that helps in setting up OpenStack container for document storage. Along with the command, added corresponding test file "ConfigureOpenstackObjectStorageCommandTest.php" to ensure the functionality is working as expected.
2023-12-12 15:55:14 +01:00
82ca321715 Add exceptions for async file handling failures
Introduced two new exceptions, `BadCallToRemoteServer` and `TempUrlRemoteServerException`, to improve error handling in asynchronous file operations. These exceptions are thrown when there are issues with the remote server during an async file operation such as HTTP status codes >= 400 and if the server is unreachable.
2023-12-12 15:55:13 +01:00
28d09a8206 Add form and types to handle async files 2023-12-12 15:55:13 +01:00
1195767eb3 Add Twig AsyncUploadExtension and its associated test
Created AsyncUploadExtension under Chill\DocStoreBundle\AsyncUpload\Templating to provide Twig filter functions for generating URLs for asynchronous file uploads. To ensure correctness, AsyncUploadExtensionTest was implemented in Bundle\ChillDocStoreBundle\Tests\AsyncUpload\Templating. Service definitions were also updated in services.yaml.
2023-12-12 15:55:13 +01:00
4fd5a37df3 Add serializer groups to SignedUrl and SignedUrlPost classes, include tests
Serializer groups have been added to the SignedUrl and SignedUrlPost classes to ensure correct serialization. Two new test files were also included: SignedUrlNormalizerTest and SignedUrlPostNormalizerTest which test the normalization of instances of the classes.
2023-12-12 15:55:13 +01:00
450e7c348b Fix and clean DI configuration for chill_doc_store for usage with AsyncUpload 2023-12-12 15:55:12 +01:00
a70572266f Refactor TempUrlOpenstackGenerator to use parameter bag and Bundle configuration
The TempUrlOpenstackGenerator now uses a ParameterBag for configuration instead of individual parameters. This simplifies the handling of configuration values and makes the code more maintainable. The parameter configuration has also been included in the chill_doc_store configuration array for a unified approach.
2023-12-12 15:55:12 +01:00
d688022825 Add Controller to get asyncUpload signatures
Added new files for handling asynchronous file uploads in ChillDocStoreBundle. The new files include a controller for generating temporary URLs (AsyncUploadController.php), a security authorization file (AsyncUploadVoter.php), and a corresponding test file (AsyncUploadControllerTest.php). These implementations permit asynchronous uploads via POST, GET, and HEAD methods while maintaining security protocols.
2023-12-12 11:36:14 +01:00
264fff5c36 Implement asynchronous upload feature in Openstack Object Store
Those features were previously stored in champs-libres/async-upload-bundle

This commit introduces several new classes within the ChillDocStore bundle for handling asynchronous uploads onto an Openstack Object Store. Specifically, the TempUrlOpenstackGenerator, SignedUrl, TempUrlGeneratorInterface, TempUrlGenerateEvent, and TempUrlGeneratorException classes have been created.

This implementation will allow for generating "temporary URLs", which assist in securely and temporarily uploading resources to the OpenStack Object Store. This feature enables the handling of file uploads in a more scalable and efficient manner in high-volume environments.

Additionally, corresponding unit tests have also been added to ensure the accuracy of this new feature.
2023-12-12 11:36:14 +01:00
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-sf4#d57134aee8e504a83c902ff0cf9f8d36ac418290",
"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"
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