mirror of
https://gitlab.com/Chill-Projet/chill-bundles.git
synced 2025-06-12 21:34:25 +00:00
feat: Add new StoredObjectManager
service.
To read and write onto `StoredObject` document using a common interface.
This commit is contained in:
parent
8abed67e1c
commit
62af980ea5
@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Exception;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class StoredObjectManagerException extends Exception
|
||||||
|
{
|
||||||
|
public static function errorDuringHttpRequest(Throwable $exception): self
|
||||||
|
{
|
||||||
|
return new self('Error during HTTP request.', 500, $exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function invalidStatusCode(int $code): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
sprintf('Invalid status code received (%s).', $code)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function unableToDecrypt(string $message): self
|
||||||
|
{
|
||||||
|
return new self(sprintf('Unable to decrypt content (reason: %s).', $message));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function unableToGetResponseContent(Throwable $exception): self
|
||||||
|
{
|
||||||
|
return new self('Unable to get content from response.', 500, $exception);
|
||||||
|
}
|
||||||
|
}
|
133
src/Bundle/ChillDocStoreBundle/Service/StoredObjectManager.php
Normal file
133
src/Bundle/ChillDocStoreBundle/Service/StoredObjectManager.php
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Service;
|
||||||
|
|
||||||
|
use Base64Url\Base64Url;
|
||||||
|
use ChampsLibres\AsyncUploaderBundle\TempUrl\TempUrlGeneratorInterface;
|
||||||
|
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||||
|
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
|
||||||
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
use const OPENSSL_RAW_DATA;
|
||||||
|
|
||||||
|
final class StoredObjectManager implements StoredObjectManagerInterface
|
||||||
|
{
|
||||||
|
private const ALGORITHM = 'AES-256-CBC';
|
||||||
|
|
||||||
|
private HttpClientInterface $client;
|
||||||
|
|
||||||
|
private TempUrlGeneratorInterface $tempUrlGenerator;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
HttpClientInterface $client,
|
||||||
|
TempUrlGeneratorInterface $tempUrlGenerator
|
||||||
|
) {
|
||||||
|
$this->client = $client;
|
||||||
|
$this->tempUrlGenerator = $tempUrlGenerator;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function read(StoredObject $document): string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$response = $this
|
||||||
|
->client
|
||||||
|
->request(
|
||||||
|
Request::METHOD_GET,
|
||||||
|
$this
|
||||||
|
->tempUrlGenerator
|
||||||
|
->generate(
|
||||||
|
Request::METHOD_GET,
|
||||||
|
$document->getFilename()
|
||||||
|
)
|
||||||
|
->url
|
||||||
|
);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
throw StoredObjectManagerException::errorDuringHttpRequest($e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->getStatusCode() !== Response::HTTP_OK) {
|
||||||
|
throw StoredObjectManagerException::invalidStatusCode($response->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$data = $response->getContent();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
throw StoredObjectManagerException::unableToGetResponseContent($e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (false === $this->hasKeysAndIv($document)) {
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
$clearData = openssl_decrypt(
|
||||||
|
$data,
|
||||||
|
self::ALGORITHM,
|
||||||
|
// TODO: Why using this library and not use base64_decode() ?
|
||||||
|
Base64Url::decode($document->getKeyInfos()['k']),
|
||||||
|
OPENSSL_RAW_DATA,
|
||||||
|
pack('C*', ...$document->getIv())
|
||||||
|
);
|
||||||
|
|
||||||
|
if (false === $clearData) {
|
||||||
|
throw StoredObjectManagerException::unableToDecrypt(openssl_error_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $clearData;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function write(StoredObject $document, string $clearContent): void
|
||||||
|
{
|
||||||
|
$encryptedContent = $this->hasKeysAndIv($document)
|
||||||
|
? openssl_encrypt(
|
||||||
|
$clearContent,
|
||||||
|
self::ALGORITHM,
|
||||||
|
// TODO: Why using this library and not use base64_decode() ?
|
||||||
|
Base64Url::decode($document->getKeyInfos()['k']),
|
||||||
|
OPENSSL_RAW_DATA,
|
||||||
|
pack('C*', ...$document->getIv())
|
||||||
|
)
|
||||||
|
: $clearContent;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this
|
||||||
|
->client
|
||||||
|
->request(
|
||||||
|
Request::METHOD_PUT,
|
||||||
|
$this
|
||||||
|
->tempUrlGenerator
|
||||||
|
->generate(
|
||||||
|
Request::METHOD_PUT,
|
||||||
|
$document->getFilename()
|
||||||
|
)
|
||||||
|
->url,
|
||||||
|
[
|
||||||
|
'body' => $encryptedContent,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} catch (TransportExceptionInterface $exception) {
|
||||||
|
throw StoredObjectManagerException::errorDuringHttpRequest($exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->getStatusCode() !== Response::HTTP_CREATED) {
|
||||||
|
throw StoredObjectManagerException::invalidStatusCode($response->getStatusCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasKeysAndIv(StoredObject $storedObject): bool
|
||||||
|
{
|
||||||
|
return ([] !== $storedObject->getKeyInfos()) && ([] !== $storedObject->getIv());
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Service;
|
||||||
|
|
||||||
|
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||||
|
|
||||||
|
interface StoredObjectManagerInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the content of a StoredObject.
|
||||||
|
*
|
||||||
|
* @param StoredObject $document The document.
|
||||||
|
*
|
||||||
|
* @return string The retrieved content in clear.
|
||||||
|
*/
|
||||||
|
public function read(StoredObject $document): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the content of a StoredObject.
|
||||||
|
*
|
||||||
|
* @param StoredObject $document The document.
|
||||||
|
* @param $clearContent The content to store in clear.
|
||||||
|
*/
|
||||||
|
public function write(StoredObject $document, string $clearContent): void;
|
||||||
|
}
|
183
src/Bundle/ChillDocStoreBundle/Tests/StoredObjectManagerTest.php
Normal file
183
src/Bundle/ChillDocStoreBundle/Tests/StoredObjectManagerTest.php
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Chill\DocStoreBundle\Tests;
|
||||||
|
|
||||||
|
use ChampsLibres\AsyncUploaderBundle\TempUrl\TempUrlGeneratorInterface;
|
||||||
|
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||||
|
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
|
||||||
|
use Chill\DocStoreBundle\Service\StoredObjectManager;
|
||||||
|
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
|
||||||
|
use Generator;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use stdClass;
|
||||||
|
use Symfony\Component\HttpClient\Exception\TransportException;
|
||||||
|
use Symfony\Component\HttpClient\MockHttpClient;
|
||||||
|
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*
|
||||||
|
* @covers \Chill\DocStoreBundle\Service\StoredObjectManager
|
||||||
|
*/
|
||||||
|
final class StoredObjectManagerTest extends TestCase
|
||||||
|
{
|
||||||
|
public function getDataProvider(): Generator
|
||||||
|
{
|
||||||
|
/* HAPPY SCENARIO */
|
||||||
|
|
||||||
|
// Encrypted object
|
||||||
|
yield [
|
||||||
|
(new StoredObject())
|
||||||
|
->setFilename('encrypted.txt')
|
||||||
|
->setKeyInfos(['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')])
|
||||||
|
->setIv(unpack('C*', 'abcdefghijklmnop')),
|
||||||
|
hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string
|
||||||
|
'The quick brown fox jumps over the lazy dog', // clear
|
||||||
|
];
|
||||||
|
|
||||||
|
// Non-encrypted object
|
||||||
|
yield [
|
||||||
|
(new StoredObject())->setFilename('non-encrypted.txt'), // The StoredObject
|
||||||
|
'The quick brown fox jumps over the lazy dog', // Encrypted
|
||||||
|
'The quick brown fox jumps over the lazy dog', // Clear
|
||||||
|
];
|
||||||
|
|
||||||
|
/* UNHAPPY SCENARIO */
|
||||||
|
|
||||||
|
// Encrypted object with issue during HTTP communication
|
||||||
|
yield [
|
||||||
|
(new StoredObject())
|
||||||
|
->setFilename('error_during_http_request.txt')
|
||||||
|
->setKeyInfos(['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')])
|
||||||
|
->setIv(unpack('C*', 'abcdefghijklmnop')),
|
||||||
|
hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string
|
||||||
|
'The quick brown fox jumps over the lazy dog', // clear
|
||||||
|
StoredObjectManagerException::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Encrypted object with issue during HTTP communication: Invalid status code
|
||||||
|
yield [
|
||||||
|
(new StoredObject())
|
||||||
|
->setFilename('invalid_statuscode.txt')
|
||||||
|
->setKeyInfos(['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')])
|
||||||
|
->setIv(unpack('C*', 'abcdefghijklmnop')),
|
||||||
|
hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string
|
||||||
|
'The quick brown fox jumps over the lazy dog', // clear
|
||||||
|
StoredObjectManagerException::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Erroneous encrypted: Unable to decrypt exception.
|
||||||
|
yield [
|
||||||
|
(new StoredObject())
|
||||||
|
->setFilename('unable_to_decrypt.txt')
|
||||||
|
->setKeyInfos(['k' => base64_encode('WRONG_PASS_PHRASE')])
|
||||||
|
->setIv(unpack('C*', 'abcdefghijklmnop')),
|
||||||
|
'WRONG_ENCODED_VALUE', // Binary encoded string
|
||||||
|
'The quick brown fox jumps over the lazy dog', // clear
|
||||||
|
StoredObjectManagerException::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider getDataProvider
|
||||||
|
*/
|
||||||
|
public function testRead(StoredObject $storedObject, string $encodedContent, string $clearContent, ?string $exceptionClass = null)
|
||||||
|
{
|
||||||
|
if (null !== $exceptionClass) {
|
||||||
|
$this->expectException($exceptionClass);
|
||||||
|
}
|
||||||
|
|
||||||
|
$storedObjectManager = $this->getSubject($storedObject, $encodedContent);
|
||||||
|
|
||||||
|
self::assertEquals($clearContent, $storedObjectManager->read($storedObject));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider getDataProvider
|
||||||
|
*/
|
||||||
|
public function testWrite(StoredObject $storedObject, string $encodedContent, string $clearContent, ?string $exceptionClass = null)
|
||||||
|
{
|
||||||
|
if (null !== $exceptionClass) {
|
||||||
|
$this->expectException($exceptionClass);
|
||||||
|
}
|
||||||
|
|
||||||
|
$storedObjectManager = $this->getSubject($storedObject, $encodedContent);
|
||||||
|
|
||||||
|
$storedObjectManager->write($storedObject, $clearContent);
|
||||||
|
|
||||||
|
self::assertEquals($clearContent, $storedObjectManager->read($storedObject));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getHttpClient(string $encodedContent): HttpClientInterface
|
||||||
|
{
|
||||||
|
$callback = static function ($method, $url, $options) use ($encodedContent) {
|
||||||
|
if (Request::METHOD_GET === $method) {
|
||||||
|
switch ($url) {
|
||||||
|
case 'https://example.com/non-encrypted.txt':
|
||||||
|
case 'https://example.com/encrypted.txt':
|
||||||
|
return new MockResponse($encodedContent, ['http_code' => 200]);
|
||||||
|
|
||||||
|
case 'https://example.com/error_during_http_request.txt':
|
||||||
|
return new TransportException('error_during_http_request.txt');
|
||||||
|
|
||||||
|
case 'https://example.com/invalid_statuscode.txt':
|
||||||
|
return new MockResponse($encodedContent, ['http_code' => 404]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Request::METHOD_PUT === $method) {
|
||||||
|
switch ($url) {
|
||||||
|
case 'https://example.com/non-encrypted.txt':
|
||||||
|
case 'https://example.com/encrypted.txt':
|
||||||
|
return new MockResponse($encodedContent, ['http_code' => 201]);
|
||||||
|
|
||||||
|
case 'https://example.com/error_during_http_request.txt':
|
||||||
|
throw new TransportException('error_during_http_request.txt');
|
||||||
|
|
||||||
|
case 'https://example.com/invalid_statuscode.txt':
|
||||||
|
return new MockResponse($encodedContent, ['http_code' => 404]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new MockResponse('Not found');
|
||||||
|
};
|
||||||
|
|
||||||
|
return new MockHttpClient($callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getSubject(StoredObject $storedObject, string $encodedContent): StoredObjectManagerInterface
|
||||||
|
{
|
||||||
|
return new StoredObjectManager(
|
||||||
|
$this->getHttpClient($encodedContent),
|
||||||
|
$this->getTempUrlGenerator($storedObject)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getTempUrlGenerator(StoredObject $storedObject): TempUrlGeneratorInterface
|
||||||
|
{
|
||||||
|
$response = new stdClass();
|
||||||
|
$response->url = $storedObject->getFilename();
|
||||||
|
|
||||||
|
$tempUrlGenerator = $this
|
||||||
|
->getMockBuilder(TempUrlGeneratorInterface::class)
|
||||||
|
->getMock();
|
||||||
|
|
||||||
|
$tempUrlGenerator
|
||||||
|
->method('generate')
|
||||||
|
->withAnyParameters()
|
||||||
|
->willReturn($response);
|
||||||
|
|
||||||
|
return $tempUrlGenerator;
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user