Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.14% covered (warning)
79.14%
110 / 139
40.00% covered (danger)
40.00%
6 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
GoogleCloudStorage
79.14% covered (warning)
79.14%
110 / 139
40.00% covered (danger)
40.00%
6 / 15
105.56
0.00% covered (danger)
0.00%
0 / 1
 __construct
84.62% covered (success)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
10.36
 _getKey
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 _upload
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
5
 create
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 read
55.56% covered (warning)
55.56%
5 / 9
0.00% covered (danger)
0.00%
0 / 1
3.79
 delete
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
5.58
 exists
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 createComment
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 readComments
88.89% covered (success)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 existsComment
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 purgeValues
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
11.56
 setValue
81.82% covered (success)
81.82%
18 / 22
0.00% covered (danger)
0.00%
0 / 1
5.15
 getValue
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 _getExpiredPastes
86.67% covered (success)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
9.19
 getAllPastes
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
1<?php declare(strict_types=1);
2/**
3 * PrivateBin
4 *
5 * a zero-knowledge paste bin
6 *
7 * @link      https://github.com/PrivateBin/PrivateBin
8 * @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
9 * @license   https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
10 */
11
12namespace PrivateBin\Data;
13
14use Exception;
15use Google\Cloud\Core\Exception\NotFoundException;
16use Google\Cloud\Storage\Bucket;
17use Google\Cloud\Storage\StorageClient;
18use PrivateBin\Json;
19
20class GoogleCloudStorage extends AbstractData
21{
22    /**
23     * GCS client
24     *
25     * @access private
26     * @var    StorageClient
27     */
28    private $_client = null;
29
30    /**
31     * GCS bucket
32     *
33     * @access private
34     * @var    Bucket
35     */
36    private $_bucket = null;
37
38    /**
39     * object prefix
40     *
41     * @access private
42     * @var    string
43     */
44    private $_prefix = 'pastes';
45
46    /**
47     * bucket acl type
48     *
49     * @access private
50     * @var    bool
51     */
52    private $_uniformacl = false;
53
54    /**
55     * instantiantes a new Google Cloud Storage data backend.
56     *
57     * @access public
58     * @param array $options
59     */
60    public function __construct(array $options)
61    {
62        if (getenv('PRIVATEBIN_GCS_BUCKET')) {
63            $bucket = getenv('PRIVATEBIN_GCS_BUCKET');
64        }
65        if (is_array($options) && array_key_exists('bucket', $options)) {
66            $bucket = $options['bucket'];
67        }
68        if (is_array($options) && array_key_exists('prefix', $options)) {
69            $this->_prefix = $options['prefix'];
70        }
71        if (is_array($options) && array_key_exists('uniformacl', $options)) {
72            $this->_uniformacl = $options['uniformacl'];
73        }
74
75        $this->_client = class_exists('StorageClientStub', false) ?
76            new \StorageClientStub(array()) :
77            new StorageClient(array('suppressKeyFileNotice' => true));
78        if (isset($bucket)) {
79            $this->_bucket = $this->_client->bucket($bucket);
80        }
81    }
82
83    /**
84     * returns the google storage object key for $pasteid in $this->_bucket.
85     *
86     * @access private
87     * @param $pasteid string to get the key for
88     * @return string
89     */
90    private function _getKey($pasteid)
91    {
92        if ($this->_prefix != '') {
93            return $this->_prefix . '/' . $pasteid;
94        }
95        return $pasteid;
96    }
97
98    /**
99     * Uploads the payload in the $this->_bucket under the specified key.
100     * The entire payload is stored as a JSON document. The metadata is replicated
101     * as the GCS object's metadata except for the fields attachment, attachmentname
102     * and salt.
103     *
104     * @param $key string to store the payload under
105     * @param $payload array to store
106     * @return bool true if successful, otherwise false.
107     */
108    private function _upload($key, $payload)
109    {
110        $metadata = array_key_exists('meta', $payload) ? $payload['meta'] : array();
111        unset($metadata['attachment'], $metadata['attachmentname'], $metadata['salt']);
112        foreach ($metadata as $k => $v) {
113            $metadata[$k] = strval($v);
114        }
115        try {
116            $data = array(
117                'name'          => $key,
118                'chunkSize'     => 262144,
119                'metadata'      => array(
120                    'content-type' => 'application/json',
121                    'metadata'     => $metadata,
122                ),
123            );
124            if (!$this->_uniformacl) {
125                $data['predefinedAcl'] = 'private';
126            }
127            $this->_bucket->upload(Json::encode($payload), $data);
128        } catch (Exception $e) {
129            error_log('failed to upload ' . $key . ' to ' . $this->_bucket->name() . ', ' .
130                trim(preg_replace('/\s\s+/', ' ', $e->getMessage())));
131            return false;
132        }
133        return true;
134    }
135
136    /**
137     * @inheritDoc
138     */
139    public function create($pasteid, array $paste)
140    {
141        if ($this->exists($pasteid)) {
142            return false;
143        }
144
145        return $this->_upload($this->_getKey($pasteid), $paste);
146    }
147
148    /**
149     * @inheritDoc
150     */
151    public function read($pasteid)
152    {
153        try {
154            $o    = $this->_bucket->object($this->_getKey($pasteid));
155            $data = $o->downloadAsString();
156            return Json::decode($data);
157        } catch (NotFoundException $e) {
158            return false;
159        } catch (Exception $e) {
160            error_log('failed to read ' . $pasteid . ' from ' . $this->_bucket->name() . ', ' .
161                trim(preg_replace('/\s\s+/', ' ', $e->getMessage())));
162            return false;
163        }
164    }
165
166    /**
167     * @inheritDoc
168     */
169    public function delete($pasteid)
170    {
171        $name = $this->_getKey($pasteid);
172
173        try {
174            foreach ($this->_bucket->objects(array('prefix' => $name . '/discussion/')) as $comment) {
175                try {
176                    $this->_bucket->object($comment->name())->delete();
177                } catch (NotFoundException $e) {
178                    // ignore if already deleted.
179                }
180            }
181        } catch (NotFoundException $e) {
182            // there are no discussions associated with the paste
183        }
184
185        try {
186            $this->_bucket->object($name)->delete();
187        } catch (NotFoundException $e) {
188            // ignore if already deleted
189        }
190    }
191
192    /**
193     * @inheritDoc
194     */
195    public function exists($pasteid)
196    {
197        $o = $this->_bucket->object($this->_getKey($pasteid));
198        return $o->exists();
199    }
200
201    /**
202     * @inheritDoc
203     */
204    public function createComment($pasteid, $parentid, $commentid, array $comment)
205    {
206        if ($this->existsComment($pasteid, $parentid, $commentid)) {
207            return false;
208        }
209        $key = $this->_getKey($pasteid) . '/discussion/' . $parentid . '/' . $commentid;
210        return $this->_upload($key, $comment);
211    }
212
213    /**
214     * @inheritDoc
215     */
216    public function readComments($pasteid)
217    {
218        $comments = array();
219        $prefix   = $this->_getKey($pasteid) . '/discussion/';
220        try {
221            foreach ($this->_bucket->objects(array('prefix' => $prefix)) as $key) {
222                $comment         = JSON::decode($this->_bucket->object($key->name())->downloadAsString());
223                $comment['id']   = basename($key->name());
224                $slot            = $this->getOpenSlot($comments, (int) $comment['meta']['created']);
225                $comments[$slot] = $comment;
226            }
227        } catch (NotFoundException $e) {
228            // no comments found
229        }
230        return $comments;
231    }
232
233    /**
234     * @inheritDoc
235     */
236    public function existsComment($pasteid, $parentid, $commentid)
237    {
238        $name = $this->_getKey($pasteid) . '/discussion/' . $parentid . '/' . $commentid;
239        $o    = $this->_bucket->object($name);
240        return $o->exists();
241    }
242
243    /**
244     * @inheritDoc
245     */
246    public function purgeValues($namespace, $time)
247    {
248        $path = 'config/' . $namespace;
249        try {
250            foreach ($this->_bucket->objects(array('prefix' => $path)) as $object) {
251                $name = $object->name();
252                if (strlen($name) > strlen($path) && substr($name, strlen($path), 1) !== '/') {
253                    continue;
254                }
255                $info = $object->info();
256                if (key_exists('metadata', $info) && key_exists('value', $info['metadata'])) {
257                    $value = $info['metadata']['value'];
258                    if (is_numeric($value) && intval($value) < $time) {
259                        try {
260                            $object->delete();
261                        } catch (NotFoundException $e) {
262                            // deleted by another instance.
263                        }
264                    }
265                }
266            }
267        } catch (NotFoundException $e) {
268            // no objects in the bucket yet
269        }
270    }
271
272    /**
273     * For GoogleCloudStorage, the value will also be stored in the metadata for the
274     * namespaces traffic_limiter and purge_limiter.
275     * @inheritDoc
276     */
277    public function setValue($value, $namespace, $key = '')
278    {
279        if ($key === '') {
280            $key = 'config/' . $namespace;
281        } else {
282            $key = 'config/' . $namespace . '/' . $key;
283        }
284
285        $metadata = array('namespace' => $namespace);
286        if ($namespace != 'salt') {
287            $metadata['value'] = strval($value);
288        }
289        try {
290            $data = array(
291                'name'          => $key,
292                'chunkSize'     => 262144,
293                'metadata'      => array(
294                    'content-type' => 'application/json',
295                    'metadata'     => $metadata,
296                ),
297            );
298            if (!$this->_uniformacl) {
299                $data['predefinedAcl'] = 'private';
300            }
301            $this->_bucket->upload($value, $data);
302        } catch (Exception $e) {
303            error_log('failed to set key ' . $key . ' to ' . $this->_bucket->name() . ', ' .
304                trim(preg_replace('/\s\s+/', ' ', $e->getMessage())));
305            return false;
306        }
307        return true;
308    }
309
310    /**
311     * @inheritDoc
312     */
313    public function getValue($namespace, $key = '')
314    {
315        if ($key === '') {
316            $key = 'config/' . $namespace;
317        } else {
318            $key = 'config/' . $namespace . '/' . $key;
319        }
320        try {
321            $o = $this->_bucket->object($key);
322            return $o->downloadAsString();
323        } catch (NotFoundException $e) {
324            return '';
325        }
326    }
327
328    /**
329     * @inheritDoc
330     */
331    protected function _getExpiredPastes($batchsize)
332    {
333        $expired = array();
334
335        $now    = time();
336        $prefix = $this->_prefix;
337        if ($prefix != '') {
338            $prefix .= '/';
339        }
340        try {
341            foreach ($this->_bucket->objects(array('prefix' => $prefix)) as $object) {
342                $metadata = $object->info()['metadata'];
343                if ($metadata != null && array_key_exists('expire_date', $metadata)) {
344                    $expire_at = intval($metadata['expire_date']);
345                    if ($expire_at != 0 && $expire_at < $now) {
346                        array_push($expired, basename($object->name()));
347                    }
348                }
349
350                if (count($expired) > $batchsize) {
351                    break;
352                }
353            }
354        } catch (NotFoundException $e) {
355            // no objects in the bucket yet
356        }
357        return $expired;
358    }
359
360    /**
361     * @inheritDoc
362     */
363    public function getAllPastes()
364    {
365        $pastes = array();
366        $prefix = $this->_prefix;
367        if ($prefix != '') {
368            $prefix .= '/';
369        }
370
371        try {
372            foreach ($this->_bucket->objects(array('prefix' => $prefix)) as $object) {
373                $candidate = substr($object->name(), strlen($prefix));
374                if (!str_contains($candidate, '/')) {
375                    $pastes[] = $candidate;
376                }
377            }
378        } catch (NotFoundException $e) {
379            // no objects in the bucket yet
380        }
381        return $pastes;
382    }
383}