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
4692
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
110
 _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 / 16
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
2/**
3 * S3.php
4 *
5 * an S3 compatible data backend for PrivateBin with CEPH/RadosGW in mind
6 * see https://docs.ceph.com/en/latest/radosgw/s3/php/
7 * based on lib/Data/GoogleCloudStorage.php from PrivateBin version 1.7.0
8 *
9 * @link      https://github.com/PrivateBin/PrivateBin
10 * @copyright 2022 Felix J. Ogris (https://ogris.de/)
11 * @license   https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
12 * @version   1.4.1
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     * @return
82     */
83    public function __construct(array $options)
84    {
85        if (is_array($options)) {
86            // AWS SDK will try to load credentials from environment if credentials are not passed via configuration
87            // ref: https://docs.aws.amazon.com/sdk-for-php/v3/developer-guide/guide_credentials.html#default-credential-chain
88            if (isset($options['accesskey']) && isset($options['secretkey'])) {
89                $this->_options['credentials'] = array();
90
91                $this->_options['credentials']['key']    = $options['accesskey'];
92                $this->_options['credentials']['secret'] = $options['secretkey'];
93            }
94            if (array_key_exists('region', $options)) {
95                $this->_options['region'] = $options['region'];
96            }
97            if (array_key_exists('version', $options)) {
98                $this->_options['version'] = $options['version'];
99            }
100            if (array_key_exists('endpoint', $options)) {
101                $this->_options['endpoint'] = $options['endpoint'];
102            }
103            if (array_key_exists('use_path_style_endpoint', $options)) {
104                $this->_options['use_path_style_endpoint'] = filter_var($options['use_path_style_endpoint'], FILTER_VALIDATE_BOOLEAN);
105            }
106            if (array_key_exists('bucket', $options)) {
107                $this->_bucket = $options['bucket'];
108            }
109            if (array_key_exists('prefix', $options)) {
110                $this->_prefix = $options['prefix'];
111            }
112        }
113
114        $this->_client = new S3Client($this->_options);
115    }
116
117    /**
118     * returns all objects in the given prefix.
119     *
120     * @access private
121     * @param $prefix string with prefix
122     * @return array all objects in the given prefix
123     */
124    private function _listAllObjects($prefix)
125    {
126        $allObjects = array();
127        $options    = array(
128            'Bucket' => $this->_bucket,
129            'Prefix' => $prefix,
130        );
131
132        do {
133            $objectsListResponse = $this->_client->listObjects($options);
134            $objects             = $objectsListResponse['Contents'] ?? array();
135            foreach ($objects as $object) {
136                $allObjects[]      = $object;
137                $options['Marker'] = $object['Key'];
138            }
139        } while ($objectsListResponse['IsTruncated']);
140
141        return $allObjects;
142    }
143
144    /**
145     * returns the S3 storage object key for $pasteid in $this->_bucket.
146     *
147     * @access private
148     * @param $pasteid string to get the key for
149     * @return string
150     */
151    private function _getKey($pasteid)
152    {
153        if ($this->_prefix != '') {
154            return $this->_prefix . '/' . $pasteid;
155        }
156        return $pasteid;
157    }
158
159    /**
160     * Uploads the payload in the $this->_bucket under the specified key.
161     * The entire payload is stored as a JSON document. The metadata is replicated
162     * as the S3 object's metadata except for the fields attachment, attachmentname
163     * and salt.
164     *
165     * @param $key string to store the payload under
166     * @param $payload array to store
167     * @return bool true if successful, otherwise false.
168     */
169    private function _upload($key, $payload)
170    {
171        $metadata = array_key_exists('meta', $payload) ? $payload['meta'] : array();
172        unset($metadata['attachment'], $metadata['attachmentname'], $metadata['salt']);
173        foreach ($metadata as $k => $v) {
174            $metadata[$k] = strval($v);
175        }
176        try {
177            $this->_client->putObject(array(
178                'Bucket'      => $this->_bucket,
179                'Key'         => $key,
180                'Body'        => Json::encode($payload),
181                'ContentType' => 'application/json',
182                'Metadata'    => $metadata,
183            ));
184        } catch (S3Exception $e) {
185            error_log('failed to upload ' . $key . ' to ' . $this->_bucket . ', ' .
186                trim(preg_replace('/\s\s+/', ' ', $e->getMessage())));
187            return false;
188        }
189        return true;
190    }
191
192    /**
193     * @inheritDoc
194     */
195    public function create($pasteid, array $paste)
196    {
197        if ($this->exists($pasteid)) {
198            return false;
199        }
200
201        return $this->_upload($this->_getKey($pasteid), $paste);
202    }
203
204    /**
205     * @inheritDoc
206     */
207    public function read($pasteid)
208    {
209        try {
210            $object = $this->_client->getObject(array(
211                'Bucket' => $this->_bucket,
212                'Key'    => $this->_getKey($pasteid),
213            ));
214            $data = $object['Body']->getContents();
215            return Json::decode($data);
216        } catch (S3Exception $e) {
217            error_log('failed to read ' . $pasteid . ' from ' . $this->_bucket . ', ' .
218                trim(preg_replace('/\s\s+/', ' ', $e->getMessage())));
219            return false;
220        }
221    }
222
223    /**
224     * @inheritDoc
225     */
226    public function delete($pasteid)
227    {
228        $name = $this->_getKey($pasteid);
229
230        try {
231            $comments = $this->_listAllObjects($name . '/discussion/');
232            foreach ($comments as $comment) {
233                try {
234                    $this->_client->deleteObject(array(
235                        'Bucket' => $this->_bucket,
236                        'Key'    => $comment['Key'],
237                    ));
238                } catch (S3Exception $e) {
239                    // ignore if already deleted.
240                }
241            }
242        } catch (S3Exception $e) {
243            // there are no discussions associated with the paste
244        }
245
246        try {
247            $this->_client->deleteObject(array(
248                'Bucket' => $this->_bucket,
249                'Key'    => $name,
250            ));
251        } catch (S3Exception $e) {
252            // ignore if already deleted
253        }
254    }
255
256    /**
257     * @inheritDoc
258     */
259    public function exists($pasteid)
260    {
261        return $this->_client->doesObjectExistV2($this->_bucket, $this->_getKey($pasteid));
262    }
263
264    /**
265     * @inheritDoc
266     */
267    public function createComment($pasteid, $parentid, $commentid, array $comment)
268    {
269        if ($this->existsComment($pasteid, $parentid, $commentid)) {
270            return false;
271        }
272        $key = $this->_getKey($pasteid) . '/discussion/' . $parentid . '/' . $commentid;
273        return $this->_upload($key, $comment);
274    }
275
276    /**
277     * @inheritDoc
278     */
279    public function readComments($pasteid)
280    {
281        $comments = array();
282        $prefix   = $this->_getKey($pasteid) . '/discussion/';
283        try {
284            $entries = $this->_listAllObjects($prefix);
285            foreach ($entries as $entry) {
286                $object = $this->_client->getObject(array(
287                    'Bucket' => $this->_bucket,
288                    'Key'    => $entry['Key'],
289                ));
290                $body             = JSON::decode($object['Body']->getContents());
291                $items            = explode('/', $entry['Key']);
292                $body['id']       = $items[3];
293                $body['parentid'] = $items[2];
294                $slot             = $this->getOpenSlot($comments, (int) $object['Metadata']['created']);
295                $comments[$slot]  = $body;
296            }
297        } catch (S3Exception $e) {
298            // no comments found
299        }
300        return $comments;
301    }
302
303    /**
304     * @inheritDoc
305     */
306    public function existsComment($pasteid, $parentid, $commentid)
307    {
308        $name = $this->_getKey($pasteid) . '/discussion/' . $parentid . '/' . $commentid;
309        return $this->_client->doesObjectExistV2($this->_bucket, $name);
310    }
311
312    /**
313     * @inheritDoc
314     */
315    public function purgeValues($namespace, $time)
316    {
317        $path = $this->_prefix;
318        if ($path != '') {
319            $path .= '/';
320        }
321        $path .= 'config/' . $namespace;
322
323        try {
324            foreach ($this->_listAllObjects($path) as $object) {
325                $name = $object['Key'];
326                if (strlen($name) > strlen($path) && substr($name, strlen($path), 1) !== '/') {
327                    continue;
328                }
329                $head = $this->_client->headObject(array(
330                    'Bucket' => $this->_bucket,
331                    'Key'    => $name,
332                ));
333                if ($head->get('Metadata') != null && array_key_exists('value', $head->get('Metadata'))) {
334                    $value = $head->get('Metadata')['value'];
335                    if (is_numeric($value) && intval($value) < $time) {
336                        try {
337                            $this->_client->deleteObject(array(
338                                'Bucket' => $this->_bucket,
339                                'Key'    => $name,
340                            ));
341                        } catch (S3Exception $e) {
342                            // deleted by another instance.
343                        }
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 ($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 ($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 ($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                if ($head->get('Metadata') != null && array_key_exists('expire_date', $head->get('Metadata'))) {
436                    $expire_at = intval($head->get('Metadata')['expire_date']);
437                    if ($expire_at != 0 && $expire_at < $now) {
438                        array_push($expired, $object['Key']);
439                    }
440                }
441
442                if (count($expired) > $batchsize) {
443                    break;
444                }
445            }
446        } catch (S3Exception $e) {
447            // no objects in the bucket yet
448        }
449        return $expired;
450    }
451
452    /**
453     * @inheritDoc
454     */
455    public function getAllPastes()
456    {
457        $pastes = array();
458        $prefix = $this->_prefix;
459        if ($prefix != '') {
460            $prefix .= '/';
461        }
462
463        try {
464            foreach ($this->_listAllObjects($prefix) as $object) {
465                $candidate = substr($object['Key'], strlen($prefix));
466                if (strpos($candidate, '/') === false) {
467                    $pastes[] = $candidate;
468                }
469            }
470        } catch (S3Exception $e) {
471            // no objects in the bucket yet
472        }
473        return $pastes;
474    }
475}