mirror of
https://gitlab.com/Chill-Projet/chill-bundles.git
synced 2025-09-09 16:24:59 +00:00
Refactor object storage to separate local storage and openstack storage
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
<?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 Base64Url\Base64Url;
|
||||
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
|
||||
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
|
||||
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Symfony\Contracts\HttpClient\ResponseInterface;
|
||||
|
||||
final class StoredObjectManager implements StoredObjectManagerInterface
|
||||
{
|
||||
private const ALGORITHM = 'AES-256-CBC';
|
||||
|
||||
private array $inMemory = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $client,
|
||||
private readonly TempUrlGeneratorInterface $tempUrlGenerator,
|
||||
) {}
|
||||
|
||||
public function getLastModified(StoredObject|StoredObjectVersion $document): \DateTimeInterface
|
||||
{
|
||||
$version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;
|
||||
|
||||
if (null !== $createdAt = $version->getCreatedAt()) {
|
||||
// as a createdAt datetime is set, return the date and time from database
|
||||
return $createdAt;
|
||||
}
|
||||
|
||||
// if no createdAt version exists in the database, we fetch the date and time from the
|
||||
// file. This situation happens for files created before July 2024.
|
||||
|
||||
if ($this->hasCache($version)) {
|
||||
$response = $this->getResponseFromCache($version);
|
||||
} else {
|
||||
try {
|
||||
$response = $this
|
||||
->client
|
||||
->request(
|
||||
Request::METHOD_HEAD,
|
||||
$this
|
||||
->tempUrlGenerator
|
||||
->generate(
|
||||
Request::METHOD_HEAD,
|
||||
$version->getFilename()
|
||||
)
|
||||
->url
|
||||
);
|
||||
} catch (TransportExceptionInterface $exception) {
|
||||
throw StoredObjectManagerException::errorDuringHttpRequest($exception);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->extractLastModifiedFromResponse($response);
|
||||
}
|
||||
|
||||
public function getContentLength(StoredObject|StoredObjectVersion $document): int
|
||||
{
|
||||
$version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;
|
||||
|
||||
if (!$this->isVersionEncrypted($version)) {
|
||||
if ($this->hasCache($version)) {
|
||||
$response = $this->getResponseFromCache($version);
|
||||
} else {
|
||||
try {
|
||||
$response = $this
|
||||
->client
|
||||
->request(
|
||||
Request::METHOD_HEAD,
|
||||
$this
|
||||
->tempUrlGenerator
|
||||
->generate(
|
||||
Request::METHOD_HEAD,
|
||||
$version->getFilename()
|
||||
)
|
||||
->url
|
||||
);
|
||||
} catch (TransportExceptionInterface $exception) {
|
||||
throw StoredObjectManagerException::errorDuringHttpRequest($exception);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->extractContentLengthFromResponse($response);
|
||||
}
|
||||
|
||||
return strlen($this->read($document));
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws TransportExceptionInterface
|
||||
* @throws StoredObjectManagerException
|
||||
*/
|
||||
public function exists(StoredObject|StoredObjectVersion $document): bool
|
||||
{
|
||||
$version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;
|
||||
|
||||
if ($this->hasCache($version)) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
$response = $this
|
||||
->client
|
||||
->request(
|
||||
Request::METHOD_HEAD,
|
||||
$this
|
||||
->tempUrlGenerator
|
||||
->generate(
|
||||
Request::METHOD_HEAD,
|
||||
$version->getFilename()
|
||||
)
|
||||
->url
|
||||
);
|
||||
|
||||
return 200 === $response->getStatusCode();
|
||||
} catch (TransportExceptionInterface $exception) {
|
||||
throw StoredObjectManagerException::errorDuringHttpRequest($exception);
|
||||
}
|
||||
}
|
||||
|
||||
public function etag(StoredObject|StoredObjectVersion $document): string
|
||||
{
|
||||
$version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;
|
||||
|
||||
if ($this->hasCache($version)) {
|
||||
$response = $this->getResponseFromCache($version);
|
||||
} else {
|
||||
try {
|
||||
$response = $this
|
||||
->client
|
||||
->request(
|
||||
Request::METHOD_HEAD,
|
||||
$this
|
||||
->tempUrlGenerator
|
||||
->generate(
|
||||
Request::METHOD_HEAD,
|
||||
$version->getFilename()
|
||||
)
|
||||
->url
|
||||
);
|
||||
} catch (TransportExceptionInterface $exception) {
|
||||
throw StoredObjectManagerException::errorDuringHttpRequest($exception);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->extractEtagFromResponse($response);
|
||||
}
|
||||
|
||||
public function read(StoredObject|StoredObjectVersion $document, ?int $version = null): string
|
||||
{
|
||||
$version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;
|
||||
|
||||
$response = $this->getResponseFromCache($version);
|
||||
|
||||
try {
|
||||
$data = $response->getContent();
|
||||
} catch (\Throwable $e) {
|
||||
throw StoredObjectManagerException::unableToGetResponseContent($e);
|
||||
}
|
||||
|
||||
if (!$this->isVersionEncrypted($version)) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
$clearData = openssl_decrypt(
|
||||
$data,
|
||||
self::ALGORITHM,
|
||||
// TODO: Why using this library and not use base64_decode() ?
|
||||
Base64Url::decode($version->getKeyInfos()['k']),
|
||||
\OPENSSL_RAW_DATA,
|
||||
pack('C*', ...$version->getIv())
|
||||
);
|
||||
|
||||
if (false === $clearData) {
|
||||
throw StoredObjectManagerException::unableToDecrypt(openssl_error_string());
|
||||
}
|
||||
|
||||
return $clearData;
|
||||
}
|
||||
|
||||
public function write(StoredObject $document, string $clearContent, ?string $contentType = null): StoredObjectVersion
|
||||
{
|
||||
$newIv = $document->getIv();
|
||||
$newKey = $document->getKeyInfos();
|
||||
$newType = $contentType ?? $document->getType();
|
||||
$version = $document->registerVersion(
|
||||
$newIv,
|
||||
$newKey,
|
||||
$newType
|
||||
);
|
||||
|
||||
$encryptedContent = $this->isVersionEncrypted($version)
|
||||
? openssl_encrypt(
|
||||
$clearContent,
|
||||
self::ALGORITHM,
|
||||
// TODO: Why using this library and not use base64_decode() ?
|
||||
Base64Url::decode($version->getKeyInfos()['k']),
|
||||
\OPENSSL_RAW_DATA,
|
||||
pack('C*', ...$version->getIv())
|
||||
)
|
||||
: $clearContent;
|
||||
|
||||
$headers = [];
|
||||
|
||||
if (null !== $document->getDeleteAt()) {
|
||||
$headers['X-Delete-At'] = $document->getDeleteAt()->getTimestamp();
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this
|
||||
->client
|
||||
->request(
|
||||
Request::METHOD_PUT,
|
||||
$this
|
||||
->tempUrlGenerator
|
||||
->generate(
|
||||
Request::METHOD_PUT,
|
||||
$version->getFilename()
|
||||
)
|
||||
->url,
|
||||
[
|
||||
'body' => $encryptedContent,
|
||||
'headers' => $headers,
|
||||
]
|
||||
);
|
||||
} catch (TransportExceptionInterface $exception) {
|
||||
throw StoredObjectManagerException::errorDuringHttpRequest($exception);
|
||||
}
|
||||
|
||||
if (Response::HTTP_CREATED !== $response->getStatusCode()) {
|
||||
throw StoredObjectManagerException::invalidStatusCode($response->getStatusCode());
|
||||
}
|
||||
|
||||
$this->clearCache();
|
||||
|
||||
return $version;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws StoredObjectManagerException
|
||||
*/
|
||||
public function delete(StoredObjectVersion $storedObjectVersion): void
|
||||
{
|
||||
$signedUrl = $this->tempUrlGenerator->generate('DELETE', $storedObjectVersion->getFilename());
|
||||
|
||||
try {
|
||||
$response = $this->client->request('DELETE', $signedUrl->url);
|
||||
if (! (Response::HTTP_NO_CONTENT === $response->getStatusCode() || Response::HTTP_NOT_FOUND === $response->getStatusCode())) {
|
||||
throw StoredObjectManagerException::invalidStatusCode($response->getStatusCode());
|
||||
}
|
||||
|
||||
$storedObjectVersion->getStoredObject()->removeVersion($storedObjectVersion);
|
||||
} catch (TransportExceptionInterface $exception) {
|
||||
throw StoredObjectManagerException::errorDuringHttpRequest($exception);
|
||||
}
|
||||
}
|
||||
|
||||
public function clearCache(): void
|
||||
{
|
||||
$this->inMemory = [];
|
||||
}
|
||||
|
||||
private function extractLastModifiedFromResponse(ResponseInterface $response): \DateTimeImmutable
|
||||
{
|
||||
$lastModifiedString = (($response->getHeaders()['last-modified'] ?? [])[0] ?? '');
|
||||
|
||||
$date = \DateTimeImmutable::createFromFormat(
|
||||
\DateTimeImmutable::RFC7231,
|
||||
$lastModifiedString,
|
||||
new \DateTimeZone('GMT')
|
||||
);
|
||||
|
||||
if (false === $date) {
|
||||
throw new \RuntimeException('the date from remote storage could not be parsed: '.$lastModifiedString);
|
||||
}
|
||||
|
||||
return $date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the content length from a ResponseInterface object.
|
||||
*
|
||||
* Does work only if the object is not encrypted.
|
||||
*
|
||||
* @return int the extracted content length as an integer
|
||||
*/
|
||||
private function extractContentLengthFromResponse(ResponseInterface $response): int
|
||||
{
|
||||
return (int) ($response->getHeaders()['content-length'] ?? ['0'])[0];
|
||||
}
|
||||
|
||||
private function extractEtagFromResponse(ResponseInterface $response): ?string
|
||||
{
|
||||
$etag = ($response->getHeaders()['etag'] ?? [''])[0];
|
||||
|
||||
if ('' === $etag) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $etag;
|
||||
}
|
||||
|
||||
private function fillCache(StoredObjectVersion $document): void
|
||||
{
|
||||
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::HTTP_OK !== $response->getStatusCode()) {
|
||||
throw StoredObjectManagerException::invalidStatusCode($response->getStatusCode());
|
||||
}
|
||||
|
||||
$this->inMemory[$this->buildCacheKey($document)] = $response;
|
||||
}
|
||||
|
||||
private function buildCacheKey(StoredObjectVersion $storedObjectVersion): string
|
||||
{
|
||||
return $storedObjectVersion->getStoredObject()->getUuid()->toString().$storedObjectVersion->getId();
|
||||
}
|
||||
|
||||
private function getResponseFromCache(StoredObjectVersion $document): ResponseInterface
|
||||
{
|
||||
if (!$this->hasCache($document)) {
|
||||
$this->fillCache($document);
|
||||
}
|
||||
|
||||
return $this->inMemory[$this->buildCacheKey($document)];
|
||||
}
|
||||
|
||||
private function hasCache(StoredObjectVersion $document): bool
|
||||
{
|
||||
return \array_key_exists($this->buildCacheKey($document), $this->inMemory);
|
||||
}
|
||||
|
||||
private function isVersionEncrypted(StoredObjectVersion $storedObjectVersion): bool
|
||||
{
|
||||
return ([] !== $storedObjectVersion->getKeyInfos()) && ([] !== $storedObjectVersion->getIv());
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user