Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
76.98% covered (warning)
76.98%
107 / 139
40.00% covered (danger)
40.00%
6 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
GoogleCloudStorage
76.98% covered (warning)
76.98%
107 / 139
40.00% covered (danger)
40.00%
6 / 15
101.47
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
7.18
 _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
4
 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
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
4.47
 existsComment
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 purgeValues
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
9.73
 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
84.62% covered (success)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
7.18
 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\Exception\JsonException;
19use PrivateBin\Json;
20
21class GoogleCloudStorage extends AbstractData
22{
23    /**
24     * GCS client
25     *
26     * @access private
27     * @var    StorageClient
28     */
29    private $_client = null;
30
31    /**
32     * GCS bucket
33     *
34     * @access private
35     * @var    Bucket
36     */
37    private $_bucket = null;
38
39    /**
40     * object prefix
41     *
42     * @access private
43     * @var    string
44     */
45    private $_prefix = 'pastes';
46
47    /**
48     * bucket acl type
49     *
50     * @access private
51     * @var    bool
52     */
53    private $_uniformacl = false;
54
55    /**
56     * instantiantes a new Google Cloud Storage data backend.
57     *
58     * @access public
59     * @param array $options
60     */
61    public function __construct(array $options)
62    {
63        if (getenv('PRIVATEBIN_GCS_BUCKET')) {
64            $bucket = getenv('PRIVATEBIN_GCS_BUCKET');
65        }
66        if (array_key_exists('bucket', $options)) {
67            $bucket = $options['bucket'];
68        }
69        if (array_key_exists('prefix', $options)) {
70            $this->_prefix = $options['prefix'];
71        }
72        if (array_key_exists('uniformacl', $options)) {
73            $this->_uniformacl = $options['uniformacl'];
74        }
75
76        $this->_client = class_exists('StorageClientStub', false) ?
77            new \StorageClientStub(array()) :
78            new StorageClient(array('suppressKeyFileNotice' => true));
79        if (isset($bucket)) {
80            $this->_bucket = $this->_client->bucket($bucket);
81        }
82    }
83
84    /**
85     * returns the google storage object key for $pasteid in $this->_bucket.
86     *
87     * @access private
88     * @param $pasteid string to get the key for
89     * @return string
90     */
91    private function _getKey($pasteid)
92    {
93        if (!empty($this->_prefix)) {
94            return $this->_prefix . '/' . $pasteid;
95        }
96        return $pasteid;
97    }
98
99    /**
100     * Uploads the payload in the $this->_bucket under the specified key.
101     * The entire payload is stored as a JSON document. The metadata is replicated
102     * as the GCS object's metadata except for the field 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 = $payload['meta'] ?? array();
111        unset($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                try {
224                    $comment = Json::decode($data);
225                } catch (JsonException $e) {
226                    error_log('failed to read comment from ' . $key->name() . ', ' . $e->getMessage());
227                    $comment = array();
228                }
229                $comment['id']   = basename($key->name());
230                $slot            = $this->getOpenSlot($comments, (int) $comment['meta']['created']);
231                $comments[$slot] = $comment;
232            }
233        } catch (NotFoundException $e) {
234            // no comments found
235        }
236        return $comments;
237    }
238
239    /**
240     * @inheritDoc
241     */
242    public function existsComment($pasteid, $parentid, $commentid)
243    {
244        $name = $this->_getKey($pasteid) . '/discussion/' . $parentid . '/' . $commentid;
245        $o    = $this->_bucket->object($name);
246        return $o->exists();
247    }
248
249    /**
250     * @inheritDoc
251     */
252    public function purgeValues($namespace, $time)
253    {
254        $path = 'config/' . $namespace;
255        try {
256            foreach ($this->_bucket->objects(array('prefix' => $path)) as $object) {
257                $name = $object->name();
258                if (strlen($name) > strlen($path) && substr($name, strlen($path), 1) !== '/') {
259                    continue;
260                }
261                $value = $object->info()['metadata']['value'] ?? '';
262                if (is_numeric($value) && intval($value) < $time) {
263                    try {
264                        $object->delete();
265                    } catch (NotFoundException $e) {
266                        // deleted by another instance.
267                    }
268                }
269            }
270        } catch (NotFoundException $e) {
271            // no objects in the bucket yet
272        }
273    }
274
275    /**
276     * For GoogleCloudStorage, the value will also be stored in the metadata for the
277     * namespaces traffic_limiter and purge_limiter.
278     * @inheritDoc
279     */
280    public function setValue($value, $namespace, $key = '')
281    {
282        if (empty($key)) {
283            $key = 'config/' . $namespace;
284        } else {
285            $key = 'config/' . $namespace . '/' . $key;
286        }
287
288        $metadata = array('namespace' => $namespace);
289        if ($namespace !== 'salt') {
290            $metadata['value'] = strval($value);
291        }
292        try {
293            $data = array(
294                'name'          => $key,
295                'chunkSize'     => 262144,
296                'metadata'      => array(
297                    'content-type' => 'application/json',
298                    'metadata'     => $metadata,
299                ),
300            );
301            if (!$this->_uniformacl) {
302                $data['predefinedAcl'] = 'private';
303            }
304            $this->_bucket->upload($value, $data);
305        } catch (Exception $e) {
306            error_log('failed to set key ' . $key . ' to ' . $this->_bucket->name() . ', ' .
307                trim(preg_replace('/\s\s+/', ' ', $e->getMessage())));
308            return false;
309        }
310        return true;
311    }
312
313    /**
314     * @inheritDoc
315     */
316    public function getValue($namespace, $key = '')
317    {
318        if ($key === '') {
319            $key = 'config/' . $namespace;
320        } else {
321            $key = 'config/' . $namespace . '/' . $key;
322        }
323        try {
324            $o = $this->_bucket->object($key);
325            return $o->downloadAsString();
326        } catch (NotFoundException $e) {
327            return '';
328        }
329    }
330
331    /**
332     * @inheritDoc
333     */
334    protected function _getExpiredPastes($batchsize)
335    {
336        $expired = array();
337
338        $now    = time();
339        $prefix = $this->_prefix;
340        if (!empty($prefix)) {
341            $prefix .= '/';
342        }
343        try {
344            foreach ($this->_bucket->objects(array('prefix' => $prefix)) as $object) {
345                $expire_at = $object->info()['metadata']['expire_date'] ?? '';
346                if (is_numeric($expire_at) && intval($expire_at) < $now) {
347                    array_push($expired, basename($object->name()));
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 (!empty($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}