diff --git a/src/Bundle/ChillDocStoreBundle/AsyncUpload/Driver/LocalStorage/StoredObjectManager.php b/src/Bundle/ChillDocStoreBundle/AsyncUpload/Driver/LocalStorage/StoredObjectManager.php index 7d5b39db8..537a46a56 100644 --- a/src/Bundle/ChillDocStoreBundle/AsyncUpload/Driver/LocalStorage/StoredObjectManager.php +++ b/src/Bundle/ChillDocStoreBundle/AsyncUpload/Driver/LocalStorage/StoredObjectManager.php @@ -11,49 +11,200 @@ declare(strict_types=1); namespace Chill\DocStoreBundle\AsyncUpload\Driver\LocalStorage; +use Base64Url\Base64Url; use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Entity\StoredObjectVersion; +use Chill\DocStoreBundle\Exception\StoredObjectManagerException; +use Chill\DocStoreBundle\Service\Cryptography\KeyGenerator; 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 { + 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 { - // 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 { - // TODO: Implement getContentLength() method. + return strlen($this->read($document)); } 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 { - // 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 { - // 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 { - // 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 { - // TODO: Implement etag() method. + return md5($this->read($document)); } 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(); } } diff --git a/src/Bundle/ChillDocStoreBundle/AsyncUpload/Driver/OpenstackObjectStore/StoredObjectManager.php b/src/Bundle/ChillDocStoreBundle/AsyncUpload/Driver/OpenstackObjectStore/StoredObjectManager.php index 875a6f4fb..e74985f69 100644 --- a/src/Bundle/ChillDocStoreBundle/AsyncUpload/Driver/OpenstackObjectStore/StoredObjectManager.php +++ b/src/Bundle/ChillDocStoreBundle/AsyncUpload/Driver/OpenstackObjectStore/StoredObjectManager.php @@ -25,8 +25,6 @@ use Symfony\Contracts\HttpClient\ResponseInterface; final class StoredObjectManager implements StoredObjectManagerInterface { - private const ALGORITHM = 'AES-256-CBC'; - private array $inMemory = []; public function __construct( @@ -362,6 +360,6 @@ final class StoredObjectManager implements StoredObjectManagerInterface private function isVersionEncrypted(StoredObjectVersion $storedObjectVersion): bool { - return ([] !== $storedObjectVersion->getKeyInfos()) && ([] !== $storedObjectVersion->getIv()); + return $storedObjectVersion->isEncrypted(); } } diff --git a/src/Bundle/ChillDocStoreBundle/Entity/StoredObject.php b/src/Bundle/ChillDocStoreBundle/Entity/StoredObject.php index d13452f76..c849abacc 100644 --- a/src/Bundle/ChillDocStoreBundle/Entity/StoredObject.php +++ b/src/Bundle/ChillDocStoreBundle/Entity/StoredObject.php @@ -448,4 +448,12 @@ class StoredObject implements Document, TrackCreationInterface { 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(); + } } diff --git a/src/Bundle/ChillDocStoreBundle/Entity/StoredObjectVersion.php b/src/Bundle/ChillDocStoreBundle/Entity/StoredObjectVersion.php index 72532def7..18c78563a 100644 --- a/src/Bundle/ChillDocStoreBundle/Entity/StoredObjectVersion.php +++ b/src/Bundle/ChillDocStoreBundle/Entity/StoredObjectVersion.php @@ -226,4 +226,9 @@ class StoredObjectVersion implements TrackCreationInterface return $this; } + + public function isEncrypted(): bool + { + return ([] !== $this->getKeyInfos()) && ([] !== $this->getIv()); + } } diff --git a/src/Bundle/ChillDocStoreBundle/Exception/StoredObjectManagerException.php b/src/Bundle/ChillDocStoreBundle/Exception/StoredObjectManagerException.php index 68a978e80..4c6fe2675 100644 --- a/src/Bundle/ChillDocStoreBundle/Exception/StoredObjectManagerException.php +++ b/src/Bundle/ChillDocStoreBundle/Exception/StoredObjectManagerException.php @@ -34,4 +34,29 @@ final class StoredObjectManagerException extends \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'); + } } diff --git a/src/Bundle/ChillDocStoreBundle/Service/Cryptography/KeyGenerator.php b/src/Bundle/ChillDocStoreBundle/Service/Cryptography/KeyGenerator.php new file mode 100644 index 000000000..58520b352 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Service/Cryptography/KeyGenerator.php @@ -0,0 +1,59 @@ +randomizer = new Randomizer(); + } + + /** + * @return array{alg: string, ext: bool, k: string, key_ops: list, 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> + */ + public function generateIv(): array + { + $iv = []; + for ($i = 0; $i < 16; ++$i) { + $iv[] = unpack('C', $this->randomizer->getBytes(8))[1]; + } + + return $iv; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManagerInterface.php b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManagerInterface.php index b4c7a063e..53a0d7d07 100644 --- a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManagerInterface.php +++ b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManagerInterface.php @@ -18,6 +18,8 @@ use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; interface StoredObjectManagerInterface { + public const ALGORITHM = 'AES-256-CBC'; + /** * @param StoredObject|StoredObjectVersion $document if a StoredObject is given, the last version will be used */ diff --git a/src/Bundle/ChillDocStoreBundle/Tests/AsyncUpload/Driver/LocalStorage/StoredObjectManagerTest.php b/src/Bundle/ChillDocStoreBundle/Tests/AsyncUpload/Driver/LocalStorage/StoredObjectManagerTest.php new file mode 100644 index 000000000..695e0caed --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Tests/AsyncUpload/Driver/LocalStorage/StoredObjectManagerTest.php @@ -0,0 +1,160 @@ +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(), + ); + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Service/Cryptography/KeyGeneratorTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Service/Cryptography/KeyGeneratorTest.php new file mode 100644 index 000000000..07fb09a8f --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Tests/Service/Cryptography/KeyGeneratorTest.php @@ -0,0 +1,47 @@ +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); + } + } +}