diff --git a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManager.php b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManager.php index 7100ca821..b10f67619 100644 --- a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManager.php +++ b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManager.php @@ -14,6 +14,7 @@ namespace Chill\DocStoreBundle\Service; use Base64Url\Base64Url; use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface; use Chill\DocStoreBundle\Entity\StoredObject; +use Chill\DocStoreBundle\Entity\StoredObjectVersion; use Chill\DocStoreBundle\Exception\StoredObjectManagerException; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -32,10 +33,12 @@ final class StoredObjectManager implements StoredObjectManagerInterface private readonly TempUrlGeneratorInterface $tempUrlGenerator ) {} - public function getLastModified(StoredObject $document): \DateTimeInterface + public function getLastModified(StoredObject|StoredObjectVersion $document): \DateTimeInterface { - if ($this->hasCache($document)) { - $response = $this->getResponseFromCache($document); + $version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document; + + if ($this->hasCache($version)) { + $response = $this->getResponseFromCache($version); } else { try { $response = $this @@ -46,7 +49,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface ->tempUrlGenerator ->generate( Request::METHOD_HEAD, - $document->getFilename() + $version->getFilename() ) ->url ); @@ -58,11 +61,13 @@ final class StoredObjectManager implements StoredObjectManagerInterface return $this->extractLastModifiedFromResponse($response); } - public function getContentLength(StoredObject $document): int + public function getContentLength(StoredObject|StoredObjectVersion $document): int { - if ([] === $document->getKeyInfos()) { - if ($this->hasCache($document)) { - $response = $this->getResponseFromCache($document); + $version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document; + + if (!$this->isVersionEncrypted($version)) { + if ($this->hasCache($version)) { + $response = $this->getResponseFromCache($version); } else { try { $response = $this @@ -73,7 +78,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface ->tempUrlGenerator ->generate( Request::METHOD_HEAD, - $document->getFilename() + $version->getFilename() ) ->url ); @@ -88,10 +93,12 @@ final class StoredObjectManager implements StoredObjectManagerInterface return strlen($this->read($document)); } - public function etag(StoredObject $document): string + public function etag(StoredObject|StoredObjectVersion $document): string { - if ($this->hasCache($document)) { - $response = $this->getResponseFromCache($document); + $version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document; + + if ($this->hasCache($version)) { + $response = $this->getResponseFromCache($version); } else { try { $response = $this @@ -111,12 +118,14 @@ final class StoredObjectManager implements StoredObjectManagerInterface } } - return $this->extractEtagFromResponse($response, $document); + return $this->extractEtagFromResponse($response); } - public function read(StoredObject $document): string + public function read(StoredObject|StoredObjectVersion $document, ?int $version = null): string { - $response = $this->getResponseFromCache($document); + $version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document; + + $response = $this->getResponseFromCache($version); try { $data = $response->getContent(); @@ -124,7 +133,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface throw StoredObjectManagerException::unableToGetResponseContent($e); } - if (false === $this->hasKeysAndIv($document)) { + if (!$this->isVersionEncrypted($version)) { return $data; } @@ -132,9 +141,9 @@ final class StoredObjectManager implements StoredObjectManagerInterface $data, self::ALGORITHM, // TODO: Why using this library and not use base64_decode() ? - Base64Url::decode($document->getKeyInfos()['k']), + Base64Url::decode($version->getKeyInfos()['k']), \OPENSSL_RAW_DATA, - pack('C*', ...$document->getIv()) + pack('C*', ...$version->getIv()) ); if (false === $clearData) { @@ -144,20 +153,25 @@ final class StoredObjectManager implements StoredObjectManagerInterface return $clearData; } - public function write(StoredObject $document, string $clearContent): void + public function write(StoredObject $document, string $clearContent, ?string $contentType = null): StoredObjectVersion { - if ($this->hasCache($document)) { - unset($this->inMemory[$document->getUuid()->toString()]); - } + $newIv = $document->getIv(); + $newKey = $document->getKeyInfos(); + $newType = $contentType ?? $document->getType(); + $version = $document->registerVersion( + $newIv, + $newKey, + $newType + ); - $encryptedContent = $this->hasKeysAndIv($document) + $encryptedContent = $this->isVersionEncrypted($version) ? openssl_encrypt( $clearContent, self::ALGORITHM, // TODO: Why using this library and not use base64_decode() ? - Base64Url::decode($document->getKeyInfos()['k']), + Base64Url::decode($version->getKeyInfos()['k']), \OPENSSL_RAW_DATA, - pack('C*', ...$document->getIv()) + pack('C*', ...$version->getIv()) ) : $clearContent; @@ -176,7 +190,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface ->tempUrlGenerator ->generate( Request::METHOD_PUT, - $document->getFilename() + $version->getFilename() ) ->url, [ @@ -191,6 +205,8 @@ final class StoredObjectManager implements StoredObjectManagerInterface if (Response::HTTP_CREATED !== $response->getStatusCode()) { throw StoredObjectManagerException::invalidStatusCode($response->getStatusCode()); } + + return $version; } public function clearCache(): void @@ -215,12 +231,19 @@ final class StoredObjectManager implements StoredObjectManagerInterface 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, StoredObject $storedObject): ?string + private function extractEtagFromResponse(ResponseInterface $response): ?string { $etag = ($response->getHeaders()['etag'] ?? [''])[0]; @@ -231,7 +254,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface return $etag; } - private function fillCache(StoredObject $document): void + private function fillCache(StoredObjectVersion $document): void { try { $response = $this @@ -254,25 +277,30 @@ final class StoredObjectManager implements StoredObjectManagerInterface throw StoredObjectManagerException::invalidStatusCode($response->getStatusCode()); } - $this->inMemory[$document->getUuid()->toString()] = $response; + $this->inMemory[$this->buildCacheKey($document)] = $response; } - private function getResponseFromCache(StoredObject $document): ResponseInterface + 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[$document->getUuid()->toString()]; + return $this->inMemory[$this->buildCacheKey($document)]; } - private function hasCache(StoredObject $document): bool + private function hasCache(StoredObjectVersion $document): bool { - return \array_key_exists($document->getUuid()->toString(), $this->inMemory); + return \array_key_exists($this->buildCacheKey($document), $this->inMemory); } - private function hasKeysAndIv(StoredObject $storedObject): bool + private function isVersionEncrypted(StoredObjectVersion $storedObjectVersion): bool { - return ([] !== $storedObject->getKeyInfos()) && ([] !== $storedObject->getIv()); + return ([] !== $storedObjectVersion->getKeyInfos()) && ([] !== $storedObjectVersion->getIv()); } } diff --git a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManagerInterface.php b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManagerInterface.php index 19ff974f0..4d2f45c33 100644 --- a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManagerInterface.php +++ b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManagerInterface.php @@ -12,36 +12,56 @@ declare(strict_types=1); namespace Chill\DocStoreBundle\Service; use Chill\DocStoreBundle\Entity\StoredObject; +use Chill\DocStoreBundle\Entity\StoredObjectVersion; use Chill\DocStoreBundle\Exception\StoredObjectManagerException; interface StoredObjectManagerInterface { - public function getLastModified(StoredObject $document): \DateTimeInterface; + /** + * @param StoredObject|StoredObjectVersion $document if a StoredObject is given, the last version will be used + */ + public function getLastModified(StoredObject|StoredObjectVersion $document): \DateTimeInterface; - public function getContentLength(StoredObject $document): int; + /** + * @param StoredObject|StoredObjectVersion $document if a StoredObject is given, the last version will be used + */ + public function getContentLength(StoredObject|StoredObjectVersion $document): int; /** * Get the content of a StoredObject. * - * @param StoredObject $document the document + * @param StoredObject|StoredObjectVersion $document if a StoredObject is given, the last version will be used * * @return string the retrieved content in clear * * @throws StoredObjectManagerException if unable to read or decrypt the content */ - public function read(StoredObject $document): string; + public function read(StoredObject|StoredObjectVersion $document): string; /** * Set the content of a StoredObject. * * @param StoredObject $document the document - * @param $clearContent The content to store in clear + * @param string $clearContent The content to store in clear + * @param string|null $contentType The new content type. If set to null, the content-type is supposed not to change. If there is no content type, an empty string will be used. + * + * @return StoredObjectVersion the newly created @see{StoredObjectVersion} for the given @see{StoredObject} * * @throws StoredObjectManagerException */ - public function write(StoredObject $document, string $clearContent): void; + public function write(StoredObject $document, string $clearContent, ?string $contentType = null): StoredObjectVersion; - public function etag(StoredObject $document): string; + /** + * return or compute the etag for the document. + * + * @param StoredObject|StoredObjectVersion $document if a StoredObject is given, the last version will be used + * + * @return string the etag of this document + */ + public function etag(StoredObject|StoredObjectVersion $document): string; + /** + * Clears the cache for the stored object. + */ public function clearCache(): void; } diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectManagerTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectManagerTest.php index c5cc8185e..c11a06f9b 100644 --- a/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectManagerTest.php +++ b/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectManagerTest.php @@ -31,23 +31,25 @@ use Symfony\Contracts\HttpClient\HttpClientInterface; */ final class StoredObjectManagerTest extends TestCase { - public static function getDataProvider(): \Generator + public static function getDataProviderForRead(): \Generator { /* HAPPY SCENARIO */ // Encrypted object yield [ (new StoredObject()) - ->setFilename('encrypted.txt') - ->setKeyInfos(['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')]) - ->setIv(unpack('C*', 'abcdefghijklmnop')), + ->registerVersion( + unpack('C*', 'abcdefghijklmnop'), + ['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')], + filename: 'encrypted.txt' + )->getStoredObject(), hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string 'The quick brown fox jumps over the lazy dog', // clear ]; // Non-encrypted object yield [ - (new StoredObject())->setFilename('non-encrypted.txt'), // The StoredObject + (new StoredObject())->registerVersion(filename: 'non-encrypted.txt')->getStoredObject(), // The StoredObject 'The quick brown fox jumps over the lazy dog', // Encrypted 'The quick brown fox jumps over the lazy dog', // Clear ]; @@ -57,9 +59,11 @@ final class StoredObjectManagerTest extends TestCase // Encrypted object with issue during HTTP communication yield [ (new StoredObject()) - ->setFilename('error_during_http_request.txt') - ->setKeyInfos(['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')]) - ->setIv(unpack('C*', 'abcdefghijklmnop')), + ->registerVersion( + unpack('C*', 'abcdefghijklmnop'), + ['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')], + filename: 'error_during_http_request.txt' + )->getStoredObject(), hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string 'The quick brown fox jumps over the lazy dog', // clear StoredObjectManagerException::class, @@ -68,9 +72,11 @@ final class StoredObjectManagerTest extends TestCase // Encrypted object with issue during HTTP communication: Invalid status code yield [ (new StoredObject()) - ->setFilename('invalid_statuscode.txt') - ->setKeyInfos(['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')]) - ->setIv(unpack('C*', 'abcdefghijklmnop')), + ->registerVersion( + unpack('C*', 'abcdefghijklmnop'), + ['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')], + filename: 'invalid_statuscode.txt' + )->getStoredObject(), hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string 'The quick brown fox jumps over the lazy dog', // clear StoredObjectManagerException::class, @@ -79,17 +85,73 @@ final class StoredObjectManagerTest extends TestCase // Erroneous encrypted: Unable to decrypt exception. yield [ (new StoredObject()) - ->setFilename('unable_to_decrypt.txt') - ->setKeyInfos(['k' => base64_encode('WRONG_PASS_PHRASE')]) - ->setIv(unpack('C*', 'abcdefghijklmnop')), + ->registerVersion( + unpack('C*', 'abcdefghijklmnop'), + ['k' => base64_encode('WRONG_PASS_PHRASE')], + filename: 'unable_to_decrypt.txt' + )->getStoredObject(), 'WRONG_ENCODED_VALUE', // Binary encoded string 'The quick brown fox jumps over the lazy dog', // clear StoredObjectManagerException::class, ]; } + public static function getDataProviderForWrite(): \Generator + { + /* HAPPY SCENARIO */ + + // Encrypted object + yield [ + (new StoredObject()) + ->registerVersion( + unpack('C*', 'abcdefghijklmnop'), + ['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')], + filename: 'encrypted.txt' + )->getStoredObject(), + hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string + 'The quick brown fox jumps over the lazy dog', // clear + ]; + + // Non-encrypted object + yield [ + (new StoredObject())->registerVersion(filename: 'non-encrypted.txt')->getStoredObject(), // The StoredObject + 'The quick brown fox jumps over the lazy dog', // Encrypted + 'The quick brown fox jumps over the lazy dog', // Clear + ]; + + /* UNHAPPY SCENARIO */ + + // Encrypted object with issue during HTTP communication + yield [ + (new StoredObject()) + ->registerVersion( + unpack('C*', 'abcdefghijklmnop'), + ['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')], + filename: 'error_during_http_request.txt' + )->getStoredObject(), + hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string + 'The quick brown fox jumps over the lazy dog', // clear + StoredObjectManagerException::class, + -1, + ]; + + // Encrypted object with issue during HTTP communication: Invalid status code + yield [ + (new StoredObject()) + ->registerVersion( + unpack('C*', 'abcdefghijklmnop'), + ['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')], + filename: 'invalid_statuscode.txt' + )->getStoredObject(), + hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string + 'The quick brown fox jumps over the lazy dog', // clear + StoredObjectManagerException::class, + 408, + ]; + } + /** - * @dataProvider getDataProvider + * @dataProvider getDataProviderForRead */ public function testRead(StoredObject $storedObject, string $encodedContent, string $clearContent, ?string $exceptionClass = null) { @@ -103,19 +165,37 @@ final class StoredObjectManagerTest extends TestCase } /** - * @dataProvider getDataProvider + * @dataProvider getDataProviderForWrite */ - public function testWrite(StoredObject $storedObject, string $encodedContent, string $clearContent, ?string $exceptionClass = null) + public function testWrite(StoredObject $storedObject, string $encodedContent, string $clearContent, ?string $exceptionClass = null, ?int $errorCode = null) { if (null !== $exceptionClass) { $this->expectException($exceptionClass); } - $storedObjectManager = $this->getSubject($storedObject, $encodedContent); + $previousVersion = $storedObject->getCurrentVersion(); + $previousFilename = $previousVersion->getFilename(); - $storedObjectManager->write($storedObject, $clearContent); + $client = new MockHttpClient(function ($method, $url, $options) use ($encodedContent, $previousFilename, $errorCode) { + self::assertEquals('PUT', $method); + self::assertStringStartsWith('https://example.com/', $url); + self::assertStringNotContainsString($previousFilename, $url, 'test that the PUT operation is not performed on the same file'); + self::assertArrayHasKey('body', $options); + self::assertEquals($encodedContent, $options['body']); - self::assertEquals($clearContent, $storedObjectManager->read($storedObject)); + if (-1 === $errorCode) { + throw new TransportException(); + } + + return new MockResponse('', ['http_code' => $errorCode ?? 201]); + }); + + $storedObjectManager = new StoredObjectManager($client, $this->getTempUrlGenerator($storedObject)); + + $newVersion = $storedObjectManager->write($storedObject, $clearContent); + + self::assertNotSame($previousVersion, $newVersion); + self::assertSame($storedObject->getCurrentVersion(), $newVersion); } public function testWriteWithDeleteAt() @@ -170,19 +250,6 @@ final class StoredObjectManagerTest extends TestCase } } - if (Request::METHOD_PUT === $method) { - switch ($url) { - case 'https://example.com/non-encrypted.txt': - case 'https://example.com/encrypted.txt': - return new MockResponse($encodedContent, ['http_code' => 201]); - - case 'https://example.com/error_during_http_request.txt': - throw new TransportException('error_during_http_request.txt'); - case 'https://example.com/invalid_statuscode.txt': - return new MockResponse($encodedContent, ['http_code' => 404]); - } - } - return new MockResponse('Not found'); }; @@ -209,9 +276,16 @@ final class StoredObjectManagerTest extends TestCase $tempUrlGenerator = $this->createMock(TempUrlGeneratorInterface::class); $tempUrlGenerator + ->expects($this->atLeastOnce()) ->method('generate') - ->withAnyParameters() - ->willReturn($response); + ->with($this->logicalOr($this->identicalTo('GET'), $this->identicalTo('PUT')), $this->isType('string')) + ->willReturnCallback(function (string $method, string $objectName) { + return new SignedUrl( + $method, + 'https://example.com/'.$objectName, + new \DateTimeImmutable('1 hours') + ); + }); return $tempUrlGenerator; }