Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.29% covered (warning)
79.29%
111 / 140
40.00% covered (danger)
40.00%
6 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
GoogleCloudStorage
79.29% covered (warning)
79.29%
111 / 140
40.00% covered (danger)
40.00%
6 / 15
104.72
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
90.00% covered (success)
90.00%
9 / 10
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                $data            = $this->_bucket->object($key->name())->downloadAsString();
223                $comment         = Json::decode($data);
224                $comment['id']   = basename($key->name());
225                $slot            = $this->getOpenSlot($comments, (int) $comment['meta']['created']);
226                $comments[$slot] = $comment;
227            }
228        } catch (NotFoundException $e) {
229            // no comments found
230        }
231        return $comments;
232    }
233
234    /**
235     * @inheritDoc
236     */
237    public function existsComment($pasteid, $parentid, $commentid)
238    {
239        $name = $this->_getKey($pasteid) . '/discussion/' . $parentid . '/' . $commentid;
240        $o    = $this->_bucket->object($name);
241        return $o->exists();
242    }
243
244    /**
245     * @inheritDoc
246     */
247    public function purgeValues($namespace, $time)
248    {
249        $path = 'config/' . $namespace;
250        try {
251            foreach ($this->_bucket->objects(array('prefix' => $path)) as $object) {
252                $name = $object->name();
253                if (strlen($name) > strlen($path) && substr($name, strlen($path), 1) !== '/') {
254                    continue;
255                }
256                $info = $object->info();
257                if (key_exists('metadata', $info) && key_exists('value', $info['metadata'])) {
258                    $value = $info['metadata']['value'];
259                    if (is_numeric($value) && intval($value) < $time) {
260                        try {
261                            $object->delete();
262                        } catch (NotFoundException $e) {
263                            // deleted by another instance.
264                        }
265                    }
266                }
267            }
268        } catch (NotFoundException $e) {
269            // no objects in the bucket yet
270        }
271    }
272
273    /**
274     * For GoogleCloudStorage, the value will also be stored in the metadata for the
275     * namespaces traffic_limiter and purge_limiter.
276     * @inheritDoc
277     */
278    public function setValue($value, $namespace, $key = '')
279    {
280        if ($key === '') {
281            $key = 'config/' . $namespace;
282        } else {
283            $key = 'config/' . $namespace . '/' . $key;
284        }
285
286        $metadata = array('namespace' => $namespace);
287        if ($namespace != 'salt') {
288            $metadata['value'] = strval($value);
289        }
290        try {
291            $data = array(
292                'name'          => $key,
293                'chunkSize'     => 262144,
294                'metadata'      => array(
295                    'content-type' => 'application/json',
296                    'metadata'     => $metadata,
297                ),
298            );
299            if (!$this->_uniformacl) {
300                $data['predefinedAcl'] = 'private';
301            }
302            $this->_bucket->upload($value, $data);
303        } catch (Exception $e) {
304            error_log('failed to set key ' . $key . ' to ' . $this->_bucket->name() . ', ' .
305                trim(preg_replace('/\s\s+/', ' ', $e->getMessage())));
306            return false;
307        }
308        return true;
309    }
310
311    /**
312     * @inheritDoc
313     */
314    public function getValue($namespace, $key = '')
315    {
316        if ($key === '') {
317            $key = 'config/' . $namespace;
318        } else {
319            $key = 'config/' . $namespace . '/' . $key;
320        }
321        try {
322            $o = $this->_bucket->object($key);
323            return $o->downloadAsString();
324        } catch (NotFoundException $e) {
325            return '';
326        }
327    }
328
329    /**
330     * @inheritDoc
331     */
332    protected function _getExpiredPastes($batchsize)
333    {
334        $expired = array();
335
336        $now    = time();
337        $prefix = $this->_prefix;
338        if ($prefix != '') {
339            $prefix .= '/';
340        }
341        try {
342            foreach ($this->_bucket->objects(array('prefix' => $prefix)) as $object) {
343                $metadata = $object->info()['metadata'];
344                if ($metadata != null && array_key_exists('expire_date', $metadata)) {
345                    $expire_at = intval($metadata['expire_date']);
346                    if ($expire_at != 0 && $expire_at < $now) {
347                        array_push($expired, basename($object->name()));
348                    }
349                }
350
351                if (count($expired) > $batchsize) {
352                    break;
353                }
354            }
355        } catch (NotFoundException $e) {
356            // no objects in the bucket yet
357        }
358        return $expired;
359    }
360
361    /**
362     * @inheritDoc
363     */
364    public function getAllPastes()
365    {
366        $pastes = array();
367        $prefix = $this->_prefix;
368        if ($prefix != '') {
369            $prefix .= '/';
370        }
371
372        try {
373            foreach ($this->_bucket->objects(array('prefix' => $prefix)) as $object) {
374                $candidate = substr($object->name(), strlen($prefix));
375                if (!str_contains($candidate, '/')) {
376                    $pastes[] = $candidate;
377                }
378            }
379        } catch (NotFoundException $e) {
380            // no objects in the bucket yet
381        }
382        return $pastes;
383    }
384}