mirror of
				https://gitlab.com/Chill-Projet/chill-bundles.git
				synced 2025-10-31 09:18:24 +00:00 
			
		
		
		
	Implements StoredObjectManager for local storage
This commit is contained in:
		| @@ -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(); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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(); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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(); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -226,4 +226,9 @@ class StoredObjectVersion implements TrackCreationInterface | ||||
|  | ||||
|         return $this; | ||||
|     } | ||||
|  | ||||
|     public function isEncrypted(): bool | ||||
|     { | ||||
|         return ([] !== $this->getKeyInfos()) && ([] !== $this->getIv()); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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'); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
|      */ | ||||
|   | ||||
| @@ -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(), | ||||
|         ); | ||||
|     } | ||||
| } | ||||
| @@ -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); | ||||
|         } | ||||
|     } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user