diff --git a/.gitignore b/.gitignore index d5e9e375a..54a203878 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ vendor/ .env composer.lock phpunit.xml +.idea/ +/nbproject/* \ No newline at end of file diff --git a/composer.json b/composer.json index 5f9282606..ee9f32d32 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,8 @@ "mongodb/mongodb": "^1.1", "microsoft/windowsazure": "~0.4", "microsoft/azure-storage": "~0.15.0", - "akeneo/phpspec-skip-example-extension": "~1.2" + "akeneo/phpspec-skip-example-extension": "~1.2", + "google/cloud-storage": "^1.1" }, "suggest": { "knplabs/knp-gaufrette-bundle": "to use with Symfony2", @@ -54,6 +55,9 @@ "google/apiclient": "to use GoogleCloudStorage adapter", "ext-curl": "*", "ext-mbstring": "*", + "ext-mongodb": "*", + "mongodb/mongodb": "*", + "google/cloud-storage": "to use Google Cloud Client Library", "ext-fileinfo": "This extension is used to automatically detect the content-type of a file in the AwsS3, OpenCloud, AzureBlogStorage and GoogleCloudStorage adapters" }, "autoload": { diff --git a/doc/adapters/google-cloud-client-storage.md b/doc/adapters/google-cloud-client-storage.md new file mode 100644 index 000000000..1b023d40f --- /dev/null +++ b/doc/adapters/google-cloud-client-storage.md @@ -0,0 +1,66 @@ +--- +currentMenu: google-cloud-client-storage +--- + +# Google Cloud Client Storage + +This adapter requires an instance of Google\Cloud\Storage\StorageClient that has proper access rights to the bucket you want to use. + +For more details see: +http://googlecloudplatform.github.io/google-cloud-php/ +https://console.cloud.google.com/ + +In order to get started: + +1) Create a project in [Google Cloud Platform](https://console.cloud.google.com/). +2) Create a bucket for the project in Storage. +3) Create a Service Account in IAM & Admin section that can write access the bucket, download its key.json file. + +**At all times make sure you keep your key.json file private and nobody can access it from the Internet.** + +## Example + +```php + 'your-project-id', + 'keyFilePath' => 'path/to/your/project/key.json' +)); + +# You can optionally set the directory in the bucket and the acl permissions for all uploaded files... +# By default Cloud Storage applies the bucket's default object ACL to the object (uploaded file). +# The example below gives read access to the uploaded files to anyone in the world +# Note that the public URL of the file IS NOT the bucket's file url, +# see https://cloud.google.com/storage/docs/access-public-data for details + +$adapter = new GoogleCloudClientStorage($storage, 'bucket_name', + array( + 'directory' => 'bucket_directory', + 'acl' => array( + 'allUsers' => \Google\Cloud\Storage\Acl::ROLE_READER + ) + ) +); + +$key = 'myAmazingFile.txt'; + +# optional +$adapter->setMetadata($key, + array( + 'FileDescription' => 'This is my file. There are many like it, but this one is mine.' + ) +); + +$filesystem = new Filesystem($adapter); + +$filesystem->write($key, 'Uploaded at: '.date('Y-m-d @ H:i:s'), true); + +``` + +Here you can find some more info regarding ACL: +* [Creating and Managing Access Control Lists (ACLs)](https://cloud.google.com/storage/docs/access-control/create-manage-lists) +* [Access Control Lists (ACLs)](https://cloud.google.com/storage/docs/access-control/lists) \ No newline at end of file diff --git a/doc/adapters/google-cloud-storage.md b/doc/adapters/google-cloud-storage.md index 7f1f219ce..6203b2080 100644 --- a/doc/adapters/google-cloud-storage.md +++ b/doc/adapters/google-cloud-storage.md @@ -9,6 +9,8 @@ To use the GoogleCloudStorage adapter you will need to create a connection using (https://console.developers.google.com/). You can then create the `\Google_Service_Storage` which is required for the GoogleCloudStorage adapter. +Install with: composer require google/cloud-storage + ## Example ```php diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 9bf58266e..5ddb18c7f 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -39,10 +39,16 @@ --> + + + + diff --git a/src/Gaufrette/Adapter/GoogleCloudClientStorage.php b/src/Gaufrette/Adapter/GoogleCloudClientStorage.php new file mode 100644 index 000000000..229119f26 --- /dev/null +++ b/src/Gaufrette/Adapter/GoogleCloudClientStorage.php @@ -0,0 +1,331 @@ + + */ +class GoogleCloudClientStorage implements Adapter, MetadataSupporter, ListKeysAware +{ + protected $storageClient; + protected $bucket; + protected $bucketValidated; + protected $options = array(); + protected $metadata = array(); + protected $resources = array(); + + /** + * @param Google\Cloud\Storage\StorageClient $service Authenticated storage client class + * @param string $bucketName Name of the bucket + * @param array $options Options are: "directory" and "acl" (see https://cloud.google.com/storage/docs/access-control/lists) + */ + public function __construct(\Google\Cloud\Storage\StorageClient $storageClient, $bucketName, $options = array()) + { + $this->storageClient = $storageClient; + $this->setBucket($bucketName); + $this->options = array_replace_recursive( + array( + 'directory' => '', + 'acl' => array() + ), + $options + ); + $this->options['directory'] = rtrim($this->options['directory'], '/'); + } + + /** + * Get adapter options + * + * @return array + */ + public function getOptions() + { + return $this->options; + } + + /** + * Set adapter options + * + * @param array $options + */ + public function setOptions($options) + { + $this->options = array_replace($this->options, $options); + } + + protected function computePath($key = null) + { + if (strlen($this->options['directory'])) + { + return $this->options['directory'].'/'.$key; + } + return $key; + } + + protected function isBucket() + { + if ($this->bucketValidated === true) + { + return true; + } elseif (!$this->bucket->exists()) { + throw new \RuntimeException(sprintf('Bucket %s does not exist.', $this->bucket->name())); + } + $this->bucketValidated = true; + return true; + } + + public function setBucket($name) + { + $this->bucketValidated = null; + $this->bucket = $this->storageClient->bucket($name); + $this->isBucket(); + } + + public function getBucket() + { + return $this->bucket; + } + + /** + * {@inheritdoc} + */ + public function read($key) + { + $this->isBucket(); + $object = $this->bucket->object($this->computePath($key)); + try { + $info = $object->info(); + $this->setResources($key, $info); + return $object->downloadAsString(); + } catch (\Exception $e) { + return false; + } + } + + /** + * {@inheritdoc} + */ + public function write($key, $content) + { + $this->isBucket(); + + $options = array( + 'resumable' => true, + 'name' => $this->computePath($key), + 'metadata' => $this->getMetadata($key), + ); + + $this->bucket->upload( + $content, + $options + ); + + $this->updateKeyProperties($key, + array( + 'acl' => $this->options['acl'], + 'metadata' => $this->getMetadata($key) + ) + ); + + $size = $this->getResourceByName($key, 'size'); + return $size === null ? false : $size; + } + + /** + * {@inheritdoc} + */ + public function exists($key) + { + $this->isBucket(); + $object = $this->bucket->object($this->computePath($key)); + return $object->exists(); + } + + /** + * {@inheritdoc} + */ + public function isDirectory($key) + { + return $this->exists($this->computePath(rtrim($key, '/')).'/'); + } + + /** + * {@inheritdoc} + */ + public function listKeys($prefix = null) + { + $this->isBucket(); + $keys = array(); + + foreach ($this->bucket->objects(array('prefix' => $this->computePath($prefix))) as $e) + { + $keys[] = $e->name(); + } + sort($keys); + return $keys; + } + + /** + * {@inheritdoc} + */ + public function keys() + { + return $this->listKeys(); + } + + /** + * {@inheritdoc} + */ + public function mtime($key) + { + $this->isBucket(); + $object = $object = $this->bucket->object($this->computePath($key)); + $info = $object->info(); + $this->setResources($key, $info); + return strtotime($info['updated']); + } + + /** + * {@inheritdoc} + */ + public function delete($key) + { + $this->isBucket(); + try { + $object = $this->bucket->object($this->computePath($key)); + $object->delete(); + $this->setMetadata($key, null); + } catch (\Exception $e) { + return false; + } + return true; + } + + /** + * {@inheritdoc} + */ + public function rename($sourceKey, $targetKey) + { + $this->isBucket(); + + $pathedSourceKey = $this->computePath($sourceKey); + $pathedTargetKey = $this->computePath($targetKey); + + $object = $this->bucket->object($pathedSourceKey); + + $copiedObject = $object->copy($this->bucket, + array( + 'name' => $pathedTargetKey + ) + ); + + if ($copiedObject->exists()) + { + $this->updateKeyProperties($targetKey, + array( + 'acl' => $this->options['acl'], + 'metadata' => $this->getMetadata($sourceKey) + ) + ); + $this->setMetadata($targetKey, $this->getMetadata($sourceKey)); + $this->setMetadata($sourceKey, null); + $object->delete(); + return true; + } + return false; + } + + /** + * {@inheritdoc} + */ + public function setMetadata($key, $metadata) + { + $this->metadata[$this->computePath($key)] = $metadata; + } + + /** + * {@inheritdoc} + */ + public function getMetadata($key) + { + $pathedKey = $this->computePath($key); + if (!isset($this->metadata[$pathedKey]) && $this->exists($pathedKey)) + { + $data = $this->bucket->object($pathedKey)->info(); + if (isset($data['metadata'])) + { + $this->metadata[$pathedKey] = $data['metadata']; + } + } + return isset($this->metadata[$pathedKey]) ? $this->metadata[$pathedKey] : array(); + } + + /** + * {@inheritdoc} + */ + public function setResources($key, $data) + { + $this->resources[$this->computePath($key)] = $data; + } + + /** + * {@inheritdoc} + */ + public function getResources($key) + { + $pathedKey = $this->computePath($key); + return isset($this->resources[$pathedKey]) ? $this->resources[$pathedKey] : array(); + } + + /** + * {@inheritdoc} + */ + public function getResourceByName($key, $resourceName) + { + $pathedKey = $this->computePath($key); + return isset($this->resources[$pathedKey][$resourceName]) ? $this->resources[$pathedKey][$resourceName] : null; + } + + /** + * Sets ACL and metadata information. + * + * @param string $key + * @param array $options Can contain "acl" and/or "metadata" arrays. + * @return boolean + */ + protected function updateKeyProperties($key, $options = array()) + { + if ($this->exists($key) === false) + { + return false; + } + + $object = $this->bucket->object($this->computePath($key)); + + $properties = array_replace_recursive( + array( + 'acl' => array(), + 'metadata' => array() + ), $options + ); + + $acl = $object->acl(); + foreach ($properties['acl'] as $k => $v) + { + $acl->add($k, $v); + } + $object->update(array('metadata' => $properties['metadata'])); + + $info = $object->info(); + + $this->setResources($key, $info); + return true; + } +} diff --git a/tests/Gaufrette/Functional/Adapter/GoogleCloudClientStorageTest.php b/tests/Gaufrette/Functional/Adapter/GoogleCloudClientStorageTest.php new file mode 100644 index 000000000..3534d36fb --- /dev/null +++ b/tests/Gaufrette/Functional/Adapter/GoogleCloudClientStorageTest.php @@ -0,0 +1,149 @@ + + */ + +namespace Gaufrette\Functional\Adapter; + +class GoogleCloudClientStorageTest extends FunctionalTestCase +{ + private $string = 'Yeah mate. No worries, I uploaded just fine. Meow!'; + private $directory = 'tests'; + + public function setUp() + { + $gccs_project_id = getenv('GCCS_PROJECT_ID'); + $gccs_bucket_name = getenv('GCCS_BUCKET_NAME'); + $gccs_json_key_file_path = getenv('GCCS_JSON_KEY_FILE_PATH'); + + if (empty($gccs_project_id) || empty($gccs_bucket_name) || empty($gccs_json_key_file_path)) + { + $this->markTestSkipped('Required enviroment variables are not defined.'); + } elseif (!is_readable($gccs_json_key_file_path)) { + $this->markTestSkipped(sprintf('Cannot read JSON key file from "%s".', $gccs_json_key_file_path)); + } + + $storage = new \Google\Cloud\Storage\StorageClient( + array( + 'projectId' => $gccs_project_id, + 'keyFilePath' => $gccs_json_key_file_path + ) + ); + + $adapter = new \Gaufrette\Adapter\GoogleCloudClientStorage($storage, $gccs_bucket_name, + array( + 'directory' => $this->directory, + 'acl' => array( + 'allUsers' => \Google\Cloud\Storage\Acl::ROLE_READER + ) + ) + ); + + $this->filesystem = new \Gaufrette\Filesystem($adapter); + } + + /** + * @test + * @group functional + * @group gccs + * + * @expectedException \RuntimeException + */ + public function shouldFailIfBucketIsNotAccessible() + { + /** @var \Gaufrette\Adapter\GoogleCloudClientStorage $adapter */ + $adapter = $this->filesystem->getAdapter(); + $adapter->setBucket('meow_'.mt_rand()); + } + + /** + * @test + * @group functional + * @group gccs + */ + public function shouldListBucketContent() + { + $this->assertEquals(strlen($this->string), $this->filesystem->write('Phat/Cat.txt', $this->string, true)); + $keys = $this->filesystem->keys(); + $file = $this->directory ? $this->directory.'/Phat/Cat.txt' : 'Phat/Cat.txt'; + $this->assertTrue(in_array($file, $keys)); + $this->filesystem->delete('Phat/Cat.txt'); + } + + /** + * @test + * @group functional + * @group gccs + */ + public function shouldWriteAndReadFile() + { + $this->assertEquals(strlen($this->string), $this->filesystem->write('Phat/Cat.txt', $this->string, true)); + $this->assertEquals(strlen($this->string), $this->filesystem->write('Phatter/Cat.txt', $this->string, true)); + + $this->assertEquals($this->string, $this->filesystem->read('Phat/Cat.txt')); + $this->assertEquals($this->string, $this->filesystem->read('Phatter/Cat.txt')); + + $this->filesystem->delete('Phat/Cat.txt'); + $this->filesystem->delete('Phatter/Cat.txt'); + } + + /** + * @test + * @group functional + * @group gccs + */ + public function shouldWriteAndReadFileMetadata() + { + /** @var \Gaufrette\Adapter\GoogleCloudClientStorage $adapter */ + $adapter = $this->filesystem->getAdapter(); + $file = 'PhatCat/Cat.txt'; + $adapter->setMetadata($file, array('OhMy' => 'I am a cat file!')); + $this->assertEquals(strlen($this->string), $this->filesystem->write($file, $this->string, true)); + $info = $adapter->getMetadata($file); + $this->assertEquals($info['OhMy'], 'I am a cat file!'); + $this->filesystem->delete($file); + } + + /** + * @test + * @group functional + * @group gccs + */ + public function shouldWriteAndRenameFile() + { + /** @var \Gaufrette\Adapter\GoogleCloudClientStorage $adapter */ + $adapter = $this->filesystem->getAdapter(); + $file = 'Cat.txt'; + $adapter->setMetadata($file, array('OhMy' => 'I am a cat file!')); + $this->assertEquals(strlen($this->string), $this->filesystem->write($file, $this->string, true)); + $adapter->rename('Cat.txt', 'Kitten.txt'); + $this->assertEquals($adapter->getMetadata('Kitten.txt'), $adapter->getResourceByName('Kitten.txt', 'metadata')); + $this->filesystem->delete('Kitten.txt'); + } + + /** + * @test + * @group functional + * @group gccs + */ + public function shouldWriteAndReadPublicFile() + { + /** @var \Gaufrette\Adapter\GoogleCloudClientStorage $adapter */ + $adapter = $this->filesystem->getAdapter(); + $file = 'Cat.txt'; + $this->assertEquals(strlen($this->string), $this->filesystem->write($file, $this->string, true)); + + if ($this->directory) + { + $public_link = sprintf('https://storage.googleapis.com/%s/%s/Cat.txt', $adapter->getBucket()->name(), $this->directory); + } else { + $public_link = sprintf('https://storage.googleapis.com/%s/Cat.txt', $adapter->getBucket()->name()); + } + + $headers = @get_headers($public_link); + $this->assertEquals($headers[0], 'HTTP/1.0 200 OK'); + $this->filesystem->delete('Cat.txt'); + } +} \ No newline at end of file