Implements StoredObjectManager for local storage

This commit is contained in:
Julien Fastré 2024-12-19 17:09:16 +01:00
parent 1f6de3cb11
commit c1e449f48e
Signed by: julienfastre
GPG Key ID: BDE2190974723FCB
9 changed files with 466 additions and 11 deletions

View File

@ -11,49 +11,200 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\AsyncUpload\Driver\LocalStorage; namespace Chill\DocStoreBundle\AsyncUpload\Driver\LocalStorage;
use Base64Url\Base64Url;
use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectVersion; use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
use Chill\DocStoreBundle\Service\Cryptography\KeyGenerator;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Filesystem\Path;
class StoredObjectManager implements StoredObjectManagerInterface class StoredObjectManager implements StoredObjectManagerInterface
{ {
private readonly string $baseDir;
private readonly Filesystem $filesystem;
public function __construct(
ParameterBagInterface $parameterBag,
private readonly KeyGenerator $keyGenerator,
) {
$this->baseDir = $parameterBag->get('chill_doc_store')['local_storage']['base_dir'];
$this->filesystem = new Filesystem();
}
public function getLastModified(StoredObject|StoredObjectVersion $document): \DateTimeInterface public function getLastModified(StoredObject|StoredObjectVersion $document): \DateTimeInterface
{ {
// TODO: Implement getLastModified() method. $version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;
if (null === $version) {
throw StoredObjectManagerException::storedObjectDoesNotContainsVersion();
}
$path = $this->buildPath($version->getFilename());
if (false === $ts = filemtime($path)) {
throw StoredObjectManagerException::unableToReadDocumentOnDisk($path);
}
return \DateTimeImmutable::createFromFormat('U', (string) $ts);
} }
public function getContentLength(StoredObject|StoredObjectVersion $document): int public function getContentLength(StoredObject|StoredObjectVersion $document): int
{ {
// TODO: Implement getContentLength() method. return strlen($this->read($document));
} }
public function exists(StoredObject|StoredObjectVersion $document): bool public function exists(StoredObject|StoredObjectVersion $document): bool
{ {
// TODO: Implement exists() method. $version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;
if (null === $version) {
return false;
}
$path = $this->buildPath($version->getFilename());
return $this->filesystem->exists($path);
} }
public function read(StoredObject|StoredObjectVersion $document): string public function read(StoredObject|StoredObjectVersion $document): string
{ {
// TODO: Implement read() method. $version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;
if (null === $version) {
throw StoredObjectManagerException::storedObjectDoesNotContainsVersion();
}
$path = $this->buildPath($version->getFilename());
if (!file_exists($path)) {
throw StoredObjectManagerException::unableToFindDocumentOnDisk($path);
}
if (false === $content = file_get_contents($path)) {
throw StoredObjectManagerException::unableToReadDocumentOnDisk($path);
}
if (!$this->isVersionEncrypted($version)) {
return $content;
}
$clearData = openssl_decrypt(
$content,
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 public function write(StoredObject $document, string $clearContent, ?string $contentType = null): StoredObjectVersion
{ {
// TODO: Implement write() method. $newIv = $document->isEncrypted() ? $document->getIv() : $this->keyGenerator->generateIv();
$newKey = $document->isEncrypted() ? $document->getKeyInfos() : $this->keyGenerator->generateKey(self::ALGORITHM);
$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;
if (false === $encryptedContent) {
throw StoredObjectManagerException::unableToEncryptDocument((string) openssl_error_string());
}
$fullPath = $this->buildPath($version->getFilename());
$dir = Path::getDirectory($fullPath);
if (!$this->filesystem->exists($dir)) {
$this->filesystem->mkdir($dir);
}
$result = file_put_contents($fullPath, $encryptedContent);
if (false === $result) {
throw StoredObjectManagerException::unableToStoreDocumentOnDisk();
}
return $version;
}
private function buildPath(string $filename): string
{
$dirs = [$this->baseDir];
for ($i = 0; $i < min(strlen($filename), 8); ++$i) {
$dirs[] = $filename[$i];
}
$dirs[] = $filename;
return Path::canonicalize(implode(DIRECTORY_SEPARATOR, $dirs));
} }
public function delete(StoredObjectVersion $storedObjectVersion): void public function delete(StoredObjectVersion $storedObjectVersion): void
{ {
// TODO: Implement delete() method. if (!$this->exists($storedObjectVersion)) {
return;
} }
$path = $this->buildPath($storedObjectVersion->getFilename());
$this->filesystem->remove($path);
$this->removeDirectoriesRecursively(Path::getDirectory($path));
}
private function removeDirectoriesRecursively(string $path): void
{
if ($path === $this->baseDir) {
return;
}
$files = scandir($path);
// if it does contains only "." and "..", we can remove the directory
if (2 === count($files) && in_array('.', $files, true) && in_array('..', $files, true)) {
$this->filesystem->remove($path);
$this->removeDirectoriesRecursively(Path::getDirectory($path));
}
}
/**
* @throws StoredObjectManagerException
*/
public function etag(StoredObject|StoredObjectVersion $document): string public function etag(StoredObject|StoredObjectVersion $document): string
{ {
// TODO: Implement etag() method. return md5($this->read($document));
} }
public function clearCache(): void public function clearCache(): void
{ {
// TODO: Implement clearCache() method. // there is no cache: nothing to do here !
}
private function isVersionEncrypted(StoredObjectVersion $storedObjectVersion): bool
{
return $storedObjectVersion->isEncrypted();
} }
} }

View File

@ -25,8 +25,6 @@ use Symfony\Contracts\HttpClient\ResponseInterface;
final class StoredObjectManager implements StoredObjectManagerInterface final class StoredObjectManager implements StoredObjectManagerInterface
{ {
private const ALGORITHM = 'AES-256-CBC';
private array $inMemory = []; private array $inMemory = [];
public function __construct( public function __construct(
@ -362,6 +360,6 @@ final class StoredObjectManager implements StoredObjectManagerInterface
private function isVersionEncrypted(StoredObjectVersion $storedObjectVersion): bool private function isVersionEncrypted(StoredObjectVersion $storedObjectVersion): bool
{ {
return ([] !== $storedObjectVersion->getKeyInfos()) && ([] !== $storedObjectVersion->getIv()); return $storedObjectVersion->isEncrypted();
} }
} }

View File

@ -448,4 +448,12 @@ class StoredObject implements Document, TrackCreationInterface
{ {
return $storedObject->getDeleteAt() < $now && $storedObject->getVersions()->isEmpty(); return $storedObject->getDeleteAt() < $now && $storedObject->getVersions()->isEmpty();
} }
/**
* Return true if it has a current version, and if the current version is encrypted.
*/
public function isEncrypted(): bool
{
return $this->hasCurrentVersion() && $this->getCurrentVersion()->isEncrypted();
}
} }

View File

@ -226,4 +226,9 @@ class StoredObjectVersion implements TrackCreationInterface
return $this; return $this;
} }
public function isEncrypted(): bool
{
return ([] !== $this->getKeyInfos()) && ([] !== $this->getIv());
}
} }

View File

@ -34,4 +34,29 @@ final class StoredObjectManagerException extends \Exception
{ {
return new self('Unable to get content from response.', 500, $exception); return new self('Unable to get content from response.', 500, $exception);
} }
public static function unableToStoreDocumentOnDisk(?\Throwable $exception = null): self
{
return new self('Unable to store document on disk.', previous: $exception);
}
public static function unableToFindDocumentOnDisk(string $path): self
{
return new self('Unable to find document on disk at path "'.$path.'".');
}
public static function unableToReadDocumentOnDisk(string $path): self
{
return new self('Unable to read document on disk at path "'.$path.'".');
}
public static function unableToEncryptDocument(string $errors): self
{
return new self('Unable to encrypt document: '.$errors);
}
public static function storedObjectDoesNotContainsVersion(): self
{
return new self('Stored object does not contains any version');
}
} }

View File

@ -0,0 +1,59 @@
<?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\Service\Cryptography;
use Base64Url\Base64Url;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Random\Randomizer;
class KeyGenerator
{
private readonly Randomizer $randomizer;
public function __construct()
{
$this->randomizer = new Randomizer();
}
/**
* @return array{alg: string, ext: bool, k: string, key_ops: list<string>, kty: string}
*/
public function generateKey(string $algo = StoredObjectManagerInterface::ALGORITHM): array
{
if (StoredObjectManagerInterface::ALGORITHM !== $algo) {
throw new \LogicException(sprintf("Algorithm '%s' is not supported.", $algo));
}
$key = $this->randomizer->getBytes(128);
return [
'alg' => 'A256CBC',
'ext' => true,
'k' => Base64Url::encode($key),
'key_ops' => ['encrypt', 'decrypt'],
'kty' => 'oct',
];
}
/**
* @return list<int<0, 255>>
*/
public function generateIv(): array
{
$iv = [];
for ($i = 0; $i < 16; ++$i) {
$iv[] = unpack('C', $this->randomizer->getBytes(8))[1];
}
return $iv;
}
}

View File

@ -18,6 +18,8 @@ use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
interface StoredObjectManagerInterface interface StoredObjectManagerInterface
{ {
public const ALGORITHM = 'AES-256-CBC';
/** /**
* @param StoredObject|StoredObjectVersion $document if a StoredObject is given, the last version will be used * @param StoredObject|StoredObjectVersion $document if a StoredObject is given, the last version will be used
*/ */

View File

@ -0,0 +1,160 @@
<?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\AsyncUpload\Driver\LocalStorage;
use Chill\DocStoreBundle\AsyncUpload\Driver\LocalStorage\StoredObjectManager;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Chill\DocStoreBundle\Service\Cryptography\KeyGenerator;
use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag;
/**
* @internal
*
* @coversNothing
*/
class StoredObjectManagerTest extends TestCase
{
private const CONTENT = 'abcde';
public function testWrite(): StoredObjectVersion
{
$storedObject = new StoredObject();
$manager = $this->buildStoredObjectManager();
$version = $manager->write($storedObject, self::CONTENT);
self::assertSame($storedObject, $version->getStoredObject());
return $version;
}
/**
* @depends testWrite
*/
public function testRead(StoredObjectVersion $version): StoredObjectVersion
{
$manager = $this->buildStoredObjectManager();
$content = $manager->read($version);
self::assertEquals(self::CONTENT, $content);
return $version;
}
/**
* @depends testRead
*/
public function testExists(StoredObjectVersion $version): StoredObjectVersion
{
$manager = $this->buildStoredObjectManager();
$notExisting = new StoredObject();
$versionNotPersisted = $notExisting->registerVersion();
self::assertTrue($manager->exists($version));
self::assertFalse($manager->exists($versionNotPersisted));
self::assertFalse($manager->exists(new StoredObject()));
return $version;
}
/**
* @throws \Chill\DocStoreBundle\Exception\StoredObjectManagerException
*
* @depends testExists
*/
public function testEtag(StoredObjectVersion $version): StoredObjectVersion
{
$manager = $this->buildStoredObjectManager();
$actual = $manager->etag($version);
self::assertEquals(md5(self::CONTENT), $actual);
return $version;
}
/**
* @depends testEtag
*/
public function testGetContentLength(StoredObjectVersion $version): StoredObjectVersion
{
$manager = $this->buildStoredObjectManager();
$actual = $manager->getContentLength($version);
self::assertSame(5, $actual);
return $version;
}
/**
* @throws \Chill\DocStoreBundle\Exception\StoredObjectManagerException
*
* @depends testGetContentLength
*/
public function testGetLastModified(StoredObjectVersion $version): StoredObjectVersion
{
$manager = $this->buildStoredObjectManager();
$actual = $manager->getLastModified($version);
self::assertInstanceOf(\DateTimeImmutable::class, $actual);
self::assertGreaterThan((new \DateTimeImmutable('now'))->getTimestamp() - 10, $actual->getTimestamp());
return $version;
}
/**
* @depends testGetLastModified
*/
public function testDelete(StoredObjectVersion $version): void
{
$manager = $this->buildStoredObjectManager();
$manager->delete($version);
self::assertFalse($manager->exists($version));
}
public function testDeleteDoesNotRemoveOlderVersion(): void
{
$storedObject = new StoredObject();
$manager = $this->buildStoredObjectManager();
$version1 = $manager->write($storedObject, 'version1');
$version2 = $manager->write($storedObject, 'version2');
$version3 = $manager->write($storedObject, 'version3');
self::assertTrue($manager->exists($version1));
self::assertEquals('version1', $manager->read($version1));
self::assertTrue($manager->exists($version2));
self::assertEquals('version2', $manager->read($version2));
self::assertTrue($manager->exists($version3));
self::assertEquals('version3', $manager->read($version3));
// we delete the intermediate version
$manager->delete($version2);
self::assertFalse($manager->exists($version2));
// we check that we are still able to download the other versions
self::assertTrue($manager->exists($version1));
self::assertEquals('version1', $manager->read($version1));
self::assertTrue($manager->exists($version3));
self::assertEquals('version3', $manager->read($version3));
}
private function buildStoredObjectManager(): StoredObjectManager
{
return new StoredObjectManager(
new ParameterBag(['chill_doc_store' => ['local_storage' => ['base_dir' => '/tmp/chill-local-storage-test']]]),
new KeyGenerator(),
);
}
}

View File

@ -0,0 +1,47 @@
<?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\Service\Cryptography;
use Chill\DocStoreBundle\Service\Cryptography\KeyGenerator;
use PHPUnit\Framework\TestCase;
/**
* @internal
*
* @coversNothing
*/
class KeyGeneratorTest extends TestCase
{
public function testGenerateKey(): void
{
$keyGenerator = new KeyGenerator();
$key = $keyGenerator->generateKey();
self::assertNotEmpty($key['k']);
self::assertEquals('A256CBC', $key['alg']);
}
public function testGenerateIv(): void
{
$keyGenerator = new KeyGenerator();
$actual = $keyGenerator->generateIv();
self::assertCount(16, $actual);
foreach ($actual as $value) {
self::assertIsInt($value);
self::assertGreaterThanOrEqual(0, $value);
self::assertLessThan(256, $value);
}
}
}