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