Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 184
0.00% covered (danger)
0.00%
0 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
S3Storage
0.00% covered (danger)
0.00%
0 / 184
0.00% covered (danger)
0.00%
0 / 16
4160
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
90
 _listAllObjects
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 _getKey
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 _upload
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
20
 create
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 read
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 delete
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 exists
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 createComment
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 readComments
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
12
 existsComment
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 purgeValues
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
90
 setValue
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
30
 getValue
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 _getExpiredPastes
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
56
 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 2022 Felix J. Ogris (https://ogris.de/)
9 * @license   https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
10 *
11 * an S3 compatible data backend for PrivateBin with CEPH/RadosGW in mind
12 * see https://docs.ceph.com/en/latest/radosgw/s3/php/
13 *
14 * Installation:
15 *   1. Make sure you have composer.lock and composer.json in the document root of your PasteBin
16 *   2. If not, grab a copy from https://github.com/PrivateBin/PrivateBin
17 *   3. As non-root user, install the AWS SDK for PHP:
18 *      composer require aws/aws-sdk-php
19 *      (On FreeBSD, install devel/php-composer2 prior, e.g.: make -C /usr/ports/devel/php-composer2 install clean)
20 *   4. In cfg/conf.php, comment out all [model] and [model_options] settings
21 *   5. Still in cfg/conf.php, add a new [model] section:
22 *      [model]
23 *      class = S3Storage
24 *   6. Add a new [model_options] as well, e.g. for a Rados gateway as part of your CEPH cluster:
25 *      [model_options]
26 *      region = ""
27 *      version = "2006-03-01"
28 *      endpoint = "https://s3.my-ceph.invalid"
29 *      use_path_style_endpoint = true
30 *      bucket = "my-bucket"
31 *      prefix = "privatebin"  (place all PrivateBin data beneath this prefix)
32 *      accesskey = "my-rados-user"
33 *      secretkey = "my-rados-pass"
34 */
35
36namespace PrivateBin\Data;
37
38use Aws\S3\Exception\S3Exception;
39use Aws\S3\S3Client;
40use PrivateBin\Exception\JsonException;
41use PrivateBin\Json;
42
43class S3Storage extends AbstractData
44{
45    /**
46     * S3 client
47     *
48     * @access private
49     * @var    S3Client
50     */
51    private $_client = null;
52
53    /**
54     * S3 client options
55     *
56     * @access private
57     * @var    array
58     */
59    private $_options = array();
60
61    /**
62     * S3 bucket
63     *
64     * @access private
65     * @var    string
66     */
67    private $_bucket = null;
68
69    /**
70     * S3 prefix for all PrivateBin data in this bucket
71     *
72     * @access private
73     * @var    string
74     */
75    private $_prefix = '';
76
77    /**
78     * instantiates a new S3 data backend.
79     *
80     * @access public
81     * @param array $options
82     */
83    public function __construct(array $options)
84    {
85        // AWS SDK will try to load credentials from environment if credentials are not passed via configuration
86        // ref: https://docs.aws.amazon.com/sdk-for-php/v3/developer-guide/guide_credentials.html#default-credential-chain
87        if (isset($options['accesskey']) && isset($options['secretkey'])) {
88            $this->_options['credentials'] = array();
89
90            $this->_options['credentials']['key']    = $options['accesskey'];
91            $this->_options['credentials']['secret'] = $options['secretkey'];
92        }
93        if (array_key_exists('region', $options)) {
94            $this->_options['region'] = $options['region'];
95        }
96        if (array_key_exists('version', $options)) {
97            $this->_options['version'] = $options['version'];
98        }
99        if (array_key_exists('endpoint', $options)) {
100            $this->_options['endpoint'] = $options['endpoint'];
101        }
102        if (array_key_exists('use_path_style_endpoint', $options)) {
103            $this->_options['use_path_style_endpoint'] = filter_var($options['use_path_style_endpoint'], FILTER_VALIDATE_BOOLEAN);
104        }
105        if (array_key_exists('bucket', $options)) {
106            $this->_bucket = $options['bucket'];
107        }
108        if (array_key_exists('prefix', $options)) {
109            $this->_prefix = $options['prefix'];
110        }
111
112        $this->_client = new S3Client($this->_options);
113    }
114
115    /**
116     * returns all objects in the given prefix.
117     *
118     * @access private
119     * @param $prefix string with prefix
120     * @return array all objects in the given prefix
121     */
122    private function _listAllObjects($prefix)
123    {
124        $allObjects = array();
125        $options    = array(
126            'Bucket' => $this->_bucket,
127            'Prefix' => $prefix,
128        );
129
130        do {
131            $objectsListResponse = $this->_client->listObjects($options);
132            $objects             = $objectsListResponse['Contents'] ?? array();
133            foreach ($objects as $object) {
134                $allObjects[]      = $object;
135                $options['Marker'] = $object['Key'];
136            }
137        } while ($objectsListResponse['IsTruncated']);
138
139        return $allObjects;
140    }
141
142    /**
143     * returns the S3 storage object key for $pasteid in $this->_bucket.
144     *
145     * @access private
146     * @param $pasteid string to get the key for
147     * @return string
148     */
149    private function _getKey($pasteid)
150    {
151        if (!empty($this->_prefix)) {
152            return $this->_prefix . '/' . $pasteid;
153        }
154        return $pasteid;
155    }
156
157    /**
158     * Uploads the payload in the $this->_bucket under the specified key.
159     * The entire payload is stored as a JSON document. The metadata is replicated
160     * as the S3 object's metadata except for the field salt.
161     *
162     * @param $key string to store the payload under
163     * @param $payload array to store
164     * @return bool true if successful, otherwise false.
165     */
166    private function _upload($key, &$payload)
167    {
168        $metadata = $payload['meta'] ?? array();
169        unset($metadata['salt']);
170        foreach ($metadata as $k => $v) {
171            $metadata[$k] = strval($v);
172        }
173        try {
174            $this->_client->putObject(array(
175                'Bucket'      => $this->_bucket,
176                'Key'         => $key,
177                'Body'        => Json::encode($payload),
178                'ContentType' => 'application/json',
179                'Metadata'    => $metadata,
180            ));
181            return true;
182        } catch (S3Exception $e) {
183            error_log('failed to upload ' . $key . ' to ' . $this->_bucket . ', ' .
184                trim(preg_replace('/\s\s+/', ' ', $e->getMessage())));
185        } catch (JsonException $e) {
186            error_log('failed to JSON encode ' . $key . ', ' . $e->getMessage());
187        }
188        return false;
189    }
190
191    /**
192     * @inheritDoc
193     */
194    public function create($pasteid, array &$paste)
195    {
196        if ($this->exists($pasteid)) {
197            return false;
198        }
199
200        return $this->_upload($this->_getKey($pasteid), $paste);
201    }
202
203    /**
204     * @inheritDoc
205     */
206    public function read($pasteid)
207    {
208        try {
209            $object = $this->_client->getObject(array(
210                'Bucket' => $this->_bucket,
211                'Key'    => $this->_getKey($pasteid),
212            ));
213            $data = $object['Body']->getContents();
214            return Json::decode($data);
215        } catch (S3Exception $e) {
216            error_log('failed to read ' . $pasteid . ' from ' . $this->_bucket . ', ' .
217                trim(preg_replace('/\s\s+/', ' ', $e->getMessage())));
218        } catch (JsonException $e) {
219            error_log('failed to JSON decode ' . $pasteid . ', ' . $e->getMessage());
220        }
221        return false;
222    }
223
224    /**
225     * @inheritDoc
226     */
227    public function delete($pasteid)
228    {
229        $name = $this->_getKey($pasteid);
230
231        try {
232            $comments = $this->_listAllObjects($name . '/discussion/');
233            foreach ($comments as $comment) {
234                try {
235                    $this->_client->deleteObject(array(
236                        'Bucket' => $this->_bucket,
237                        'Key'    => $comment['Key'],
238                    ));
239                } catch (S3Exception $e) {
240                    // ignore if already deleted.
241                }
242            }
243        } catch (S3Exception $e) {
244            // there are no discussions associated with the paste
245        }
246
247        try {
248            $this->_client->deleteObject(array(
249                'Bucket' => $this->_bucket,
250                'Key'    => $name,
251            ));
252        } catch (S3Exception $e) {
253            // ignore if already deleted
254        }
255    }
256
257    /**
258     * @inheritDoc
259     */
260    public function exists($pasteid)
261    {
262        return $this->_client->doesObjectExistV2($this->_bucket, $this->_getKey($pasteid));
263    }
264
265    /**
266     * @inheritDoc
267     */
268    public function createComment($pasteid, $parentid, $commentid, array &$comment)
269    {
270        if ($this->existsComment($pasteid, $parentid, $commentid)) {
271            return false;
272        }
273        $key = $this->_getKey($pasteid) . '/discussion/' . $parentid . '/' . $commentid;
274        return $this->_upload($key, $comment);
275    }
276
277    /**
278     * @inheritDoc
279     */
280    public function readComments($pasteid)
281    {
282        $comments = array();
283        $prefix   = $this->_getKey($pasteid) . '/discussion/';
284        try {
285            $entries = $this->_listAllObjects($prefix);
286            foreach ($entries as $entry) {
287                $object = $this->_client->getObject(array(
288                    'Bucket' => $this->_bucket,
289                    'Key'    => $entry['Key'],
290                ));
291                $data             = $object['Body']->getContents();
292                $body             = JSON::decode($data);
293                $items            = explode('/', $entry['Key']);
294                $body['id']       = $items[3];
295                $body['parentid'] = $items[2];
296                $slot             = $this->getOpenSlot($comments, (int) $object['Metadata']['created']);
297                $comments[$slot]  = $body;
298            }
299        } catch (S3Exception $e) {
300            // no comments found
301        }
302        return $comments;
303    }
304
305    /**
306     * @inheritDoc
307     */
308    public function existsComment($pasteid, $parentid, $commentid)
309    {
310        $name = $this->_getKey($pasteid) . '/discussion/' . $parentid . '/' . $commentid;
311        return $this->_client->doesObjectExistV2($this->_bucket, $name);
312    }
313
314    /**
315     * @inheritDoc
316     */
317    public function purgeValues($namespace, $time)
318    {
319        $path = $this->_prefix;
320        if (!empty($path)) {
321            $path .= '/';
322        }
323        $path .= 'config/' . $namespace;
324
325        try {
326            foreach ($this->_listAllObjects($path) as $object) {
327                $name = $object['Key'];
328                if (strlen($name) > strlen($path) && substr($name, strlen($path), 1) !== '/') {
329                    continue;
330                }
331                $head = $this->_client->headObject(array(
332                    'Bucket' => $this->_bucket,
333                    'Key'    => $name,
334                ));
335                $value = $head->get('Metadata')['value'] ?? '';
336                if (is_numeric($value) && intval($value) < $time) {
337                    try {
338                        $this->_client->deleteObject(array(
339                            'Bucket' => $this->_bucket,
340                            'Key'    => $name,
341                        ));
342                    } catch (S3Exception $e) {
343                        // deleted by another instance.
344                    }
345                }
346            }
347        } catch (S3Exception $e) {
348            // no objects in the bucket yet
349        }
350    }
351
352    /**
353     * For S3, the value will also be stored in the metadata for the
354     * namespaces traffic_limiter and purge_limiter.
355     * @inheritDoc
356     */
357    public function setValue($value, $namespace, $key = '')
358    {
359        $prefix = $this->_prefix;
360        if (!empty($prefix)) {
361            $prefix .= '/';
362        }
363
364        if ($key === '') {
365            $key = $prefix . 'config/' . $namespace;
366        } else {
367            $key = $prefix . 'config/' . $namespace . '/' . $key;
368        }
369
370        $metadata = array('namespace' => $namespace);
371        if ($namespace !== 'salt') {
372            $metadata['value'] = strval($value);
373        }
374        try {
375            $this->_client->putObject(array(
376                'Bucket'      => $this->_bucket,
377                'Key'         => $key,
378                'Body'        => $value,
379                'ContentType' => 'application/json',
380                'Metadata'    => $metadata,
381            ));
382        } catch (S3Exception $e) {
383            error_log('failed to set key ' . $key . ' to ' . $this->_bucket . ', ' .
384                trim(preg_replace('/\s\s+/', ' ', $e->getMessage())));
385            return false;
386        }
387        return true;
388    }
389
390    /**
391     * @inheritDoc
392     */
393    public function getValue($namespace, $key = '')
394    {
395        $prefix = $this->_prefix;
396        if (!empty($prefix)) {
397            $prefix .= '/';
398        }
399
400        if ($key === '') {
401            $key = $prefix . 'config/' . $namespace;
402        } else {
403            $key = $prefix . 'config/' . $namespace . '/' . $key;
404        }
405
406        try {
407            $object = $this->_client->getObject(array(
408                'Bucket' => $this->_bucket,
409                'Key'    => $key,
410            ));
411            return $object['Body']->getContents();
412        } catch (S3Exception $e) {
413            return '';
414        }
415    }
416
417    /**
418     * @inheritDoc
419     */
420    protected function _getExpiredPastes($batchsize)
421    {
422        $expired = array();
423        $now     = time();
424        $prefix  = $this->_prefix;
425        if (!empty($prefix)) {
426            $prefix .= '/';
427        }
428
429        try {
430            foreach ($this->_listAllObjects($prefix) as $object) {
431                $head = $this->_client->headObject(array(
432                    'Bucket' => $this->_bucket,
433                    'Key'    => $object['Key'],
434                ));
435                $expire_at = $head->get('Metadata')['expire_date'] ?? '';
436                if (is_numeric($expire_at) && intval($expire_at) < $now) {
437                    array_push($expired, $object['Key']);
438                }
439
440                if (count($expired) > $batchsize) {
441                    break;
442                }
443            }
444        } catch (S3Exception $e) {
445            // no objects in the bucket yet
446        }
447        return $expired;
448    }
449
450    /**
451     * @inheritDoc
452     */
453    public function getAllPastes()
454    {
455        $pastes = array();
456        $prefix = $this->_prefix;
457        if (!empty($prefix)) {
458            $prefix .= '/';
459        }
460
461        try {
462            foreach ($this->_listAllObjects($prefix) as $object) {
463                $candidate = substr($object['Key'], strlen($prefix));
464                if (!str_contains($candidate, '/')) {
465                    $pastes[] = $candidate;
466                }
467            }
468        } catch (S3Exception $e) {
469            // no objects in the bucket yet
470        }
471        return $pastes;
472    }
473}