Просмотр исходного кода

Merge branch 'issue-794/add-gcs-support' of https://github.com/binxio/PrivateBin into binxio-issue-794/add-gcs-support

El RIDO 5 лет назад
Родитель
Сommit
b939b64778

+ 5 - 1
.github/workflows/tests.yml

@@ -10,7 +10,9 @@ jobs:
     - name: Validate composer.json and composer.lock
       run: composer validate
     - name: Install dependencies
-      run: /usr/bin/php7.4 $(which composer) install --prefer-dist --no-suggest
+      run: composer install --prefer-dist --no-suggest
+    - name: Install Google Cloud Storage
+      run: composer require google/cloud-storage
   PHPunit:
     runs-on: ubuntu-latest
     strategy:
@@ -29,6 +31,8 @@ jobs:
       run: rm composer.lock
     - name: Setup PHPunit
       run: composer install -n
+    - name: Install Google Cloud Storage
+      run: composer require google/cloud-storage
     - name: Run unit tests
       run: ../vendor/bin/phpunit --no-coverage
       working-directory: tst

+ 3 - 1
.gitignore

@@ -6,7 +6,7 @@ cfg/*
 !cfg/.htaccess
 
 # Ignore data/
-data/
+/data/
 
 # Ignore PhpDoc
 doc/*
@@ -36,3 +36,5 @@ tst/ConfigurationCombinationsTest.php
 .project
 .externalToolBuilders
 .c9
+/.idea/
+*.iml

+ 1 - 1
.scrutinizer.yml

@@ -21,7 +21,7 @@ build:
     tests:
         override:
             -
-                command: 'cd tst && ../vendor/bin/phpunit'
+                command: 'composer require google/cloud-storage && cd tst && ../vendor/bin/phpunit'
                 coverage:
                     file: 'tst/log/coverage-clover.xml'
                     format: 'clover'

+ 18 - 1
INSTALL.md

@@ -190,4 +190,21 @@ CREATE TABLE prefix_config (
 INSERT INTO prefix_config VALUES('VERSION', '1.3.5');
 ```
 
-In **PostgreSQL**, the data, attachment, nickname and vizhash columns needs to be TEXT and not BLOB or MEDIUMBLOB.
+In **PostgreSQL**, the data, attachment, nickname and vizhash columns needs to
+be TEXT and not BLOB or MEDIUMBLOB.
+
+### Using Google Cloud Storage
+If you want to deploy PrivateBin in a serverless manner in the Google Cloud, you
+can choose the `GoogleCloudStorage` as backend. To use this backend, you create
+a GCS bucket and specify the name as the model option `bucket`. Alternatively,
+you can set the name through the environment variable PASTEBIN_GCS_BUCKET.
+
+The default prefix for pastes stored in the bucket is `pastes`. To change the
+prefix, specify the option `prefix`.
+
+Google Cloud Storage buckets may be significantly slower than a `FileSystem` or
+`Database` backend. The big advantage is that the deployment on Google Cloud
+Platform using Google Cloud Run is easy and cheap.
+
+To use the Google Cloud Storage backend you have to install the suggested
+library using the command `composer require google/cloud-storage`.

+ 7 - 0
cfg/conf.sample.php

@@ -167,6 +167,13 @@ class = Filesystem
 [model_options]
 dir = PATH "data"
 
+[model]
+; example of a Google Cloud Storage configuration
+;class = GoogleCloudStorage
+;[model_options]
+;bucket = "my-private-bin"
+;prefix = "pastes"
+
 ;[model]
 ; example of DB configuration for MySQL
 ;class = Database

+ 3 - 0
composer.json

@@ -29,6 +29,9 @@
 		"yzalis/identicon" : "2.0.0",
 		"mlocati/ip-lib" : "1.14.0"
 	},
+	"suggest" : {
+		"google/cloud-storage" : "1.23.1"
+	},
 	"require-dev" : {
 		"phpunit/phpunit" : "^4.6 || ^5.0"
 	},

+ 6 - 6
composer.lock

@@ -1419,16 +1419,16 @@
         },
         {
             "name": "symfony/polyfill-ctype",
-            "version": "v1.22.1",
+            "version": "v1.23.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-ctype.git",
-                "reference": "c6c942b1ac76c82448322025e084cadc56048b4e"
+                "reference": "46cd95797e9df938fdd2b03693b5fca5e64b01ce"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/c6c942b1ac76c82448322025e084cadc56048b4e",
-                "reference": "c6c942b1ac76c82448322025e084cadc56048b4e",
+                "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/46cd95797e9df938fdd2b03693b5fca5e64b01ce",
+                "reference": "46cd95797e9df938fdd2b03693b5fca5e64b01ce",
                 "shasum": ""
             },
             "require": {
@@ -1440,7 +1440,7 @@
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-main": "1.22-dev"
+                    "dev-main": "1.23-dev"
                 },
                 "thanks": {
                     "name": "symfony/polyfill",
@@ -1491,7 +1491,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-01-07T16:49:33+00:00"
+            "time": "2021-02-19T12:13:01+00:00"
         },
         {
             "name": "symfony/yaml",

+ 10 - 0
lib/Configuration.php

@@ -153,6 +153,16 @@ class Configuration
                     'pwd' => null,
                     'opt' => array(PDO::ATTR_PERSISTENT => true),
                 );
+            } elseif (
+                $section == 'model_options' && in_array(
+                    $this->_configuration['model']['class'],
+                    array('GoogleCloudStorage')
+                )
+            ) {
+                $values = array(
+                    'bucket' => getenv('PRIVATEBIN_GCS_BUCKET') ? getenv('PRIVATEBIN_GCS_BUCKET') : null,
+                    'prefix' => 'pastes',
+                );
             }
 
             // "*_options" sections don't require all defaults to be set

+ 251 - 0
lib/Data/GoogleCloudStorage.php

@@ -0,0 +1,251 @@
+<?php
+
+namespace PrivateBin\Data;
+
+use Exception;
+use Google\Cloud\Core\Exception\NotFoundException;
+use Google\Cloud\Storage\StorageClient;
+use PrivateBin\Json;
+
+class GoogleCloudStorage extends AbstractData
+{
+    /**
+     * returns a Google Cloud Storage data backend.
+     *
+     * @access public
+     * @static
+     * @param array $options
+     * @return GoogleCloudStorage
+     */
+    public static function getInstance(array $options)
+    {
+        $client = null;
+        $bucket = null;
+        $prefix = 'pastes';
+
+        if (getenv('PRIVATEBIN_GCS_BUCKET')) {
+            $bucket = getenv('PRIVATEBIN_GCS_BUCKET');
+        }
+        if (is_array($options) && array_key_exists('bucket', $options)) {
+            $bucket = $options['bucket'];
+        }
+        if (is_array($options) && array_key_exists('prefix', $options)) {
+            $prefix = $options['prefix'];
+        }
+        if (is_array($options) && array_key_exists('client', $options)) {
+            $client = $options['client'];
+        }
+
+        if (!(self::$_instance instanceof self)) {
+            self::$_instance = new self($bucket, $prefix, $client);
+        }
+        return self::$_instance;
+    }
+
+    protected $_client = null;
+    protected $_bucket = null;
+    protected $_prefix = 'pastes';
+
+    public function __construct($bucket, $prefix, $client = null)
+    {
+        parent::__construct();
+        if ($client == null) {
+            $this->_client = new StorageClient(array('suppressKeyFileNotice' => true));
+        } else {
+            // use given client for test purposes
+            $this->_client = $client;
+        }
+
+        $this->_bucket = $this->_client->bucket($bucket);
+        if ($prefix != null) {
+            $this->_prefix = $prefix;
+        }
+    }
+
+    /**
+     * returns the google storage object key for $pasteid in $this->_bucket.
+     * @param $pasteid string to get the key for
+     * @return string
+     */
+    private function _getKey($pasteid)
+    {
+        if ($this->_prefix != '') {
+            return $this->_prefix . '/' . $pasteid;
+        }
+        return $pasteid;
+    }
+
+    /**
+     * Uploads the payload in the $this->_bucket under the specified key.
+     * The entire payload is stored as a JSON document. The metadata is replicated
+     * as the GCS object's metadata except for the fields attachment, attachmentname
+     * and salt.
+     *
+     * @param $key string to store the payload under
+     * @param $payload array to store
+     * @return bool true if successful, otherwise false.
+     */
+    private function upload($key, $payload)
+    {
+        $metadata = array_key_exists('meta', $payload) ? $payload['meta'] : array();
+        unset($metadata['attachment'], $metadata['attachmentname'], $metadata['salt']);
+        foreach ($metadata as $k => $v) {
+            $metadata[$k] = strval($v);
+        }
+        try {
+            $this->_bucket->upload(Json::encode($payload), array(
+                'name'          => $key,
+                'chunkSize'     => 262144,
+                'predefinedAcl' => 'private',
+                'metadata'      => array(
+                    'content-type' => 'application/json',
+                    'metadata'     => $metadata,
+                ),
+            ));
+        } catch (Exception $e) {
+            error_log('failed to upload ' . $key . ' to ' . $this->_bucket->name() . ', ' .
+                trim(preg_replace('/\s\s+/', ' ', $e->getMessage())));
+            return false;
+        }
+        return true;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function create($pasteid, array $paste)
+    {
+        if ($this->exists($pasteid)) {
+            return false;
+        }
+
+        return $this->upload($this->_getKey($pasteid), $paste);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function read($pasteid)
+    {
+        try {
+            $o    = $this->_bucket->object($this->_getKey($pasteid));
+            $data = $o->downloadAsString();
+            return Json::decode($data);
+        } catch (NotFoundException $e) {
+            return false;
+        } catch (Exception $e) {
+            error_log('failed to read ' . $pasteid . ' from ' . $this->_bucket->name() . ', ' .
+                trim(preg_replace('/\s\s+/', ' ', $e->getMessage())));
+            return false;
+        }
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function delete($pasteid)
+    {
+        $name = $this->_getKey($pasteid);
+
+        try {
+            foreach ($this->_bucket->objects(array('prefix' => $name . '/discussion/')) as $comment) {
+                try {
+                    $this->_bucket->object($comment->name())->delete();
+                } catch (NotFoundException $e) {
+                    // ignore if already deleted.
+                }
+            }
+        } catch (NotFoundException $e) {
+            // there are no discussions associated with the paste
+        }
+
+        try {
+            $this->_bucket->object($name)->delete();
+        } catch (NotFoundException $e) {
+            // ignore if already deleted
+        }
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function exists($pasteid)
+    {
+        $o = $this->_bucket->object($this->_getKey($pasteid));
+        return $o->exists();
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function createComment($pasteid, $parentid, $commentid, array $comment)
+    {
+        if ($this->existsComment($pasteid, $parentid, $commentid)) {
+            return false;
+        }
+        $key = $this->_getKey($pasteid) . '/discussion/' . $parentid . '/' . $commentid;
+        return $this->upload($key, $comment);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function readComments($pasteid)
+    {
+        $comments = array();
+        $prefix   = $this->_getKey($pasteid) . '/discussion/';
+        try {
+            foreach ($this->_bucket->objects(array('prefix' => $prefix)) as $key) {
+                $comment         = JSON::decode($this->_bucket->object($key->name())->downloadAsString());
+                $comment['id']   = basename($key->name());
+                $slot            = $this->getOpenSlot($comments, (int) $comment['meta']['created']);
+                $comments[$slot] = $comment;
+            }
+        } catch (NotFoundException $e) {
+            // no comments found
+        }
+        return $comments;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function existsComment($pasteid, $parentid, $commentid)
+    {
+        $name = $this->_getKey($pasteid) . '/discussion/' . $parentid . '/' . $commentid;
+        $o    = $this->_bucket->object($name);
+        return $o->exists();
+    }
+
+    /**
+     * @inheritDoc
+     */
+    protected function _getExpiredPastes($batchsize)
+    {
+        $expired = array();
+
+        $now    = time();
+        $prefix = $this->_prefix;
+        if ($prefix != '') {
+            $prefix = $prefix . '/';
+        }
+        try {
+            foreach ($this->_bucket->objects(array('prefix' => $prefix)) as $object) {
+                $metadata = $object->info()['metadata'];
+                if ($metadata != null && array_key_exists('expire_date', $metadata)) {
+                    $expire_at = intval($metadata['expire_date']);
+                    if ($expire_at != 0 && $expire_at < $now) {
+                        array_push($expired, basename($object->name()));
+                    }
+                }
+
+                if (count($expired) > $batchsize) {
+                    break;
+                }
+            }
+        } catch (NotFoundException $e) {
+            // no objects in the bucket yet
+        }
+        return $expired;
+    }
+}

+ 715 - 0
tst/Data/GoogleCloudStorageTest.php

@@ -0,0 +1,715 @@
+<?php
+
+use Google\Auth\HttpHandler\HttpHandlerFactory;
+use Google\Cloud\Core\Exception\BadRequestException;
+use Google\Cloud\Core\Exception\NotFoundException;
+use Google\Cloud\Storage\Bucket;
+use Google\Cloud\Storage\Connection\ConnectionInterface;
+use Google\Cloud\Storage\StorageClient;
+use Google\Cloud\Storage\StorageObject;
+use GuzzleHttp\Client;
+use PrivateBin\Data\GoogleCloudStorage;
+
+class GoogleCloudStorageTest extends PHPUnit_Framework_TestCase
+{
+    private static $_client;
+    private static $_bucket;
+
+    public static function setUpBeforeClass()
+    {
+        $httpClient = new Client(array('debug'=>false));
+        $handler    = HttpHandlerFactory::build($httpClient);
+
+        $name     = 'pb-';
+        $alphabet = 'abcdefghijklmnopqrstuvwxyz';
+        for ($i = 0; $i < 29; ++$i) {
+            $name .= $alphabet[rand(0, strlen($alphabet) - 1)];
+        }
+        self::$_client = new StorageClientStub(array());
+        self::$_bucket = self::$_client->createBucket($name);
+    }
+
+    public function setUp()
+    {
+        // do not report E_NOTICE as fsouza/fake-gcs-server does not return a `generation` value in the response
+        // which the Google Cloud Storage PHP library expects.
+        error_reporting(E_ERROR | E_WARNING | E_PARSE);
+        $this->_model = GoogleCloudStorage::getInstance(array(
+            'bucket' => self::$_bucket->name(),
+            'prefix' => 'pastes',
+            'client' => self::$_client, ));
+    }
+
+    public function tearDown()
+    {
+        foreach (self::$_bucket->objects() as $object) {
+            $object->delete();
+        }
+        error_reporting(E_ALL);
+    }
+
+    public static function tearDownAfterClass()
+    {
+        self::$_bucket->delete();
+    }
+
+    public function testFileBasedDataStoreWorks()
+    {
+        $this->_model->delete(Helper::getPasteId());
+
+        // storing pastes
+        $paste = Helper::getPaste(2, array('expire_date' => 1344803344));
+        $this->assertFalse($this->_model->exists(Helper::getPasteId()), 'paste does not yet exist');
+        $this->assertTrue($this->_model->create(Helper::getPasteId(), $paste), 'store new paste');
+        $this->assertTrue($this->_model->exists(Helper::getPasteId()), 'paste exists after storing it');
+        $this->assertFalse($this->_model->create(Helper::getPasteId(), $paste), 'unable to store the same paste twice');
+        $this->assertEquals($paste, $this->_model->read(Helper::getPasteId()));
+
+        // storing comments
+        $this->assertFalse($this->_model->existsComment(Helper::getPasteId(), Helper::getPasteId(), Helper::getCommentId()), 'comment does not yet exist');
+        $this->assertTrue($this->_model->createComment(Helper::getPasteId(), Helper::getPasteId(), Helper::getCommentId(), Helper::getComment()), 'store comment');
+        $this->assertTrue($this->_model->existsComment(Helper::getPasteId(), Helper::getPasteId(), Helper::getCommentId()), 'comment exists after storing it');
+        $this->assertFalse($this->_model->createComment(Helper::getPasteId(), Helper::getPasteId(), Helper::getCommentId(), Helper::getComment()), 'unable to store the same comment twice');
+        $comment             = Helper::getComment();
+        $comment['id']       = Helper::getCommentId();
+        $comment['parentid'] = Helper::getPasteId();
+        $this->assertEquals(
+            array($comment['meta']['created'] => $comment),
+            $this->_model->readComments(Helper::getPasteId())
+        );
+
+        // deleting pastes
+        $this->_model->delete(Helper::getPasteId());
+        $this->assertFalse($this->_model->exists(Helper::getPasteId()), 'paste successfully deleted');
+        $this->assertFalse($this->_model->existsComment(Helper::getPasteId(), Helper::getPasteId(), Helper::getCommentId()), 'comment was deleted with paste');
+        $this->assertFalse($this->_model->read(Helper::getPasteId()), 'paste can no longer be found');
+    }
+
+    /**
+     * pastes a-g are expired and should get deleted, x never expires and y-z expire in an hour
+     */
+    public function testPurge()
+    {
+        $expired = Helper::getPaste(2, array('expire_date' => 1344803344));
+        $paste   = Helper::getPaste(2, array('expire_date' => time() + 3600));
+        $keys    = array('a', 'b', 'c', 'd', 'e', 'f', 'g', 'x', 'y', 'z');
+        $ids     = array();
+        foreach ($keys as $key) {
+            $ids[$key] = hash('fnv164', $key);
+            $this->assertFalse($this->_model->exists($ids[$key]), "paste $key does not yet exist");
+            if (in_array($key, array('x', 'y', 'z'))) {
+                $this->assertTrue($this->_model->create($ids[$key], $paste), "store $key paste");
+            } elseif ($key === 'x') {
+                $this->assertTrue($this->_model->create($ids[$key], Helper::getPaste()), "store $key paste");
+            } else {
+                $this->assertTrue($this->_model->create($ids[$key], $expired), "store $key paste");
+            }
+            $this->assertTrue($this->_model->exists($ids[$key]), "paste $key exists after storing it");
+        }
+        $this->_model->purge(10);
+        foreach ($ids as $key => $id) {
+            if (in_array($key, array('x', 'y', 'z'))) {
+                $this->assertTrue($this->_model->exists($id), "paste $key exists after purge");
+                $this->_model->delete($id);
+            } else {
+                $this->assertFalse($this->_model->exists($id), "paste $key was purged");
+            }
+        }
+    }
+
+    public function testErrorDetection()
+    {
+        $this->_model->delete(Helper::getPasteId());
+        $paste = Helper::getPaste(2, array('expire' => "Invalid UTF-8 sequence: \xB1\x31"));
+        $this->assertFalse($this->_model->exists(Helper::getPasteId()), 'paste does not yet exist');
+        $this->assertFalse($this->_model->create(Helper::getPasteId(), $paste), 'unable to store broken paste');
+        $this->assertFalse($this->_model->exists(Helper::getPasteId()), 'paste does still not exist');
+    }
+
+    public function testCommentErrorDetection()
+    {
+        $this->_model->delete(Helper::getPasteId());
+        $comment = Helper::getComment(1, array('nickname' => "Invalid UTF-8 sequence: \xB1\x31"));
+        $this->assertFalse($this->_model->exists(Helper::getPasteId()), 'paste does not yet exist');
+        $this->assertTrue($this->_model->create(Helper::getPasteId(), Helper::getPaste()), 'store new paste');
+        $this->assertTrue($this->_model->exists(Helper::getPasteId()), 'paste exists after storing it');
+        $this->assertFalse($this->_model->existsComment(Helper::getPasteId(), Helper::getPasteId(), Helper::getCommentId()), 'comment does not yet exist');
+        $this->assertFalse($this->_model->createComment(Helper::getPasteId(), Helper::getPasteId(), Helper::getCommentId(), $comment), 'unable to store broken comment');
+        $this->assertFalse($this->_model->existsComment(Helper::getPasteId(), Helper::getPasteId(), Helper::getCommentId()), 'comment does still not exist');
+    }
+}
+
+/**
+ * Class StorageClientStub provides a limited stub for performing the unit test
+ */
+class StorageClientStub extends StorageClient
+{
+    private $_config     = null;
+    private $_connection = null;
+    private $_buckets    = array();
+
+    public function __construct(array $config = array())
+    {
+        $this->_config     = $config;
+        $this->_connection =  new ConnectionInterfaceStub();
+    }
+
+    public function bucket($name, $userProject = false)
+    {
+        if (!key_exists($name, $this->_buckets)) {
+            $b                     = new BucketStub($this->_connection, $name, array(), $this);
+            $this->_buckets[$name] = $b;
+        }
+        return $this->_buckets[$name];
+    }
+
+    /**
+     * @throws \Google\Cloud\Core\Exception\NotFoundException
+     */
+    public function deleteBucket($name)
+    {
+        if (key_exists($name, $this->_buckets)) {
+            unset($this->_buckets[$name]);
+        } else {
+            throw new NotFoundException();
+        }
+    }
+
+    public function buckets(array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function registerStreamWrapper($protocol = null)
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function unregisterStreamWrapper($protocol = null)
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function signedUrlUploader($uri, $data, array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function timestamp(\DateTimeInterface $timestamp, $nanoSeconds = null)
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function getServiceAccount(array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function hmacKeys(array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function hmacKey($accessId, $projectId = null, array $metadata = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function createHmacKey($serviceAccountEmail, array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function createBucket($name, array $options = array())
+    {
+        if (key_exists($name, $this->_buckets)) {
+            throw new BadRequestException('already exists');
+        }
+        $b                     = new BucketStub($this->_connection, $name, array(), $this);
+        $this->_buckets[$name] = $b;
+        return $b;
+    }
+}
+
+/**
+ * Class BucketStub stubs a GCS bucket.
+ */
+class BucketStub extends Bucket
+{
+    public $_objects;
+    private $_name;
+    private $_info;
+    private $_connection;
+    private $_client;
+
+    public function __construct(ConnectionInterface $connection, $name, array $info = array(), $client = null)
+    {
+        $this->_name       = $name;
+        $this->_info       = $info;
+        $this->_connection = $connection;
+        $this->_objects    = array();
+        $this->_client     = $client;
+    }
+
+    public function acl()
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function defaultAcl()
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function exists()
+    {
+        return true;
+    }
+
+    public function upload($data, array $options = array())
+    {
+        if (!is_string($data) || !key_exists('name', $options)) {
+            throw new BadMethodCallException('not supported by this stub');
+        }
+
+        $name                             = $options['name'];
+        $generation                       = '1';
+        $o                                = new StorageObjectStub($this->_connection, $name, $this, $generation, $options);
+        $this->_objects[$options['name']] = $o;
+        $o->setData($data);
+    }
+
+    public function uploadAsync($data, array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function getResumableUploader($data, array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function getStreamableUploader($data, array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function object($name, array $options = array())
+    {
+        if (key_exists($name, $this->_objects)) {
+            return $this->_objects[$name];
+        } else {
+            return new StorageObjectStub($this->_connection, $name, $this, null, $options);
+        }
+    }
+
+    public function objects(array $options = array())
+    {
+        $prefix = key_exists('prefix', $options) ? $options['prefix'] : '';
+
+        return new CallbackFilterIterator(
+                new ArrayIterator($this->_objects),
+                function ($current, $key, $iterator) use ($prefix) {
+                    return substr($key, 0, strlen($prefix)) == $prefix;
+                }
+        );
+    }
+
+    public function createNotification($topic, array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function notification($id)
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function notifications(array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function delete(array $options = array())
+    {
+        $this->_client->deleteBucket($this->_name);
+    }
+
+    public function update(array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function compose(array $sourceObjects, $name, array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function info(array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function reload(array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function name()
+    {
+        return $this->_name;
+    }
+
+    public static function lifecycle(array $lifecycle = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function currentLifecycle(array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function isWritable($file = null)
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function iam()
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function lockRetentionPolicy(array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function signedUrl($expires, array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function generateSignedPostPolicyV4($objectName, $expires, array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+}
+
+/**
+ * Class StorageObjectStub stubs a GCS storage object.
+ */
+class StorageObjectStub extends StorageObject
+{
+    private $_name;
+    private $_data;
+    private $_info;
+    private $_bucket;
+    private $_generation;
+    private $_exists = false;
+    private $_connection;
+
+    public function __construct(ConnectionInterface $connection, $name, $bucket, $generation = null, array $info = array(), $encryptionKey = null, $encryptionKeySHA256 = null)
+    {
+        $this->_name       = $name;
+        $this->_bucket     = $bucket;
+        $this->_generation = $generation;
+        $this->_info       = $info;
+        $this->_connection = $connection;
+    }
+
+    public function acl()
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function exists(array $options = array())
+    {
+        return key_exists($this->_name, $this->_bucket->_objects);
+    }
+
+    /**
+     * @throws NotFoundException
+     */
+    public function delete(array $options = array())
+    {
+        if (key_exists($this->_name, $this->_bucket->_objects)) {
+            unset($this->_bucket->_objects[$this->_name]);
+        } else {
+            throw new NotFoundException('key ' . $this->_name . ' not found.');
+        }
+    }
+
+    /**
+     * @throws NotFoundException
+     */
+    public function update(array $metadata, array $options = array())
+    {
+        if (!$this->_exists) {
+            throw new NotFoundException('key ' . $this->_name . ' not found.');
+        }
+        $this->_info = $metadata;
+    }
+
+    public function copy($destination, array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function rewrite($destination, array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function rename($name, array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    /**
+     * @throws NotFoundException
+     */
+    public function downloadAsString(array $options = array())
+    {
+        if (!$this->_exists) {
+            throw new NotFoundException('key ' . $this->_name . ' not found.');
+        }
+        return $this->_data;
+    }
+
+    public function downloadToFile($path, array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function downloadAsStream(array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function downloadAsStreamAsync(array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function signedUrl($expires, array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function signedUploadUrl($expires, array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function beginSignedUploadSession(array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function info(array $options = array())
+    {
+        return key_exists('metadata',$this->_info) ? $this->_info['metadata'] : array();
+    }
+
+    public function reload(array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function name()
+    {
+        return $this->_name;
+    }
+
+    public function identity()
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function gcsUri()
+    {
+        return sprintf(
+            'gs://%s/%s',
+            $this->_bucket->name(),
+            $this->_name
+        );
+    }
+
+    public function setData($data)
+    {
+        $this->_data   = $data;
+        $this->_exists = true;
+    }
+}
+
+/**
+ * Class ConnectionInterfaceStub required for the stubs.
+ */
+class ConnectionInterfaceStub implements ConnectionInterface
+{
+    public function deleteAcl(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function getAcl(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function listAcl(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function insertAcl(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function patchAcl(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function deleteBucket(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function getBucket(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function listBuckets(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function insertBucket(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function getBucketIamPolicy(array $args)
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function setBucketIamPolicy(array $args)
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function testBucketIamPermissions(array $args)
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function patchBucket(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function deleteObject(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function copyObject(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function rewriteObject(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function composeObject(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function getObject(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function listObjects(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function patchObject(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function downloadObject(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function insertObject(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function getNotification(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function deleteNotification(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function insertNotification(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function listNotifications(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function getServiceAccount(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function lockRetentionPolicy(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function createHmacKey(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function deleteHmacKey(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function getHmacKey(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function updateHmacKey(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function listHmacKeys(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+}

+ 1 - 0
vendor/composer/autoload_classmap.php

@@ -31,6 +31,7 @@ return array(
     'PrivateBin\\Data\\AbstractData' => $baseDir . '/lib/Data/AbstractData.php',
     'PrivateBin\\Data\\Database' => $baseDir . '/lib/Data/Database.php',
     'PrivateBin\\Data\\Filesystem' => $baseDir . '/lib/Data/Filesystem.php',
+    'PrivateBin\\Data\\GoogleCloudStorage' => $baseDir . '/lib/Data/GoogleCloudStorage.php',
     'PrivateBin\\Filter' => $baseDir . '/lib/Filter.php',
     'PrivateBin\\FormatV2' => $baseDir . '/lib/FormatV2.php',
     'PrivateBin\\I18n' => $baseDir . '/lib/I18n.php',

+ 1 - 0
vendor/composer/autoload_static.php

@@ -63,6 +63,7 @@ class ComposerStaticInitDontChange
         'PrivateBin\\Data\\AbstractData' => __DIR__ . '/../..' . '/lib/Data/AbstractData.php',
         'PrivateBin\\Data\\Database' => __DIR__ . '/../..' . '/lib/Data/Database.php',
         'PrivateBin\\Data\\Filesystem' => __DIR__ . '/../..' . '/lib/Data/Filesystem.php',
+        'PrivateBin\\Data\\GoogleCloudStorage' => __DIR__ . '/../..' . '/lib/Data/GoogleCloudStorage.php',
         'PrivateBin\\Filter' => __DIR__ . '/../..' . '/lib/Filter.php',
         'PrivateBin\\FormatV2' => __DIR__ . '/../..' . '/lib/FormatV2.php',
         'PrivateBin\\I18n' => __DIR__ . '/../..' . '/lib/I18n.php',