Refactor object storage to separate local storage and openstack storage

This commit is contained in:
2024-12-15 22:37:45 +01:00
parent 282b7f7fbb
commit e25c1e1816
3 changed files with 5 additions and 8 deletions

View File

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