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.2
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     */
82    public function __construct(array $options)
83    {
84        if (is_array($options)) {
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
113        $this->_client = new S3Client($this->_options);
114    }
115
116    /**
117     * returns all objects in the given prefix.
118     *
119     * @access private
120     * @param $prefix string with prefix
121     * @return array all objects in the given prefix
122     */
123    private function _listAllObjects($prefix)
124    {
125        $allObjects = array();
126        $options    = array(
127            'Bucket' => $this->_bucket,
128            'Prefix' => $prefix,
129        );
130
131        do {
132            $objectsListResponse = $this->_client->listObjects($options);
133            $objects             = $objectsListResponse['Contents'] ?? array();
134            foreach ($objects as $object) {
135                $allObjects[]      = $object;
136                $options['Marker'] = $object['Key'];
137            }
138        } while ($objectsListResponse['IsTruncated']);
139
140        return $allObjects;
141    }
142
143    /**
144     * returns the S3 storage object key for $pasteid in $this->_bucket.
145     *
146     * @access private
147     * @param $pasteid string to get the key for
148     * @return string
149     */
150    private function _getKey($pasteid)
151    {
152        if ($this->_prefix != '') {
153            return $this->_prefix . '/' . $pasteid;
154        }
155        return $pasteid;
156    }
157
158    /**
159     * Uploads the payload in the $this->_bucket under the specified key.
160     * The entire payload is stored as a JSON document. The metadata is replicated
161     * as the S3 object's metadata except for the fields attachment, attachmentname
162     * and salt.
163     *
164     * @param $key string to store the payload under
165     * @param $payload array to store
166     * @return bool true if successful, otherwise false.
167     */
168    private function _upload($key, $payload)
169    {
170        $metadata = array_key_exists('meta', $payload) ? $payload['meta'] : array();
171        unset($metadata['attachment'], $metadata['attachmentname'], $metadata['salt']);
172        foreach ($metadata as $k => $v) {
173            $metadata[$k] = strval($v);
174        }
175        try {
176            $this->_client->putObject(array(
177                'Bucket'      => $this->_bucket,
178                'Key'         => $key,
179                'Body'        => Json::encode($payload),
180                'ContentType' => 'application/json',
181                'Metadata'    => $metadata,
182            ));
183        } catch (S3Exception $e) {
184            error_log('failed to upload ' . $key . ' to ' . $this->_bucket . ', ' .
185                trim(preg_replace('/\s\s+/', ' ', $e->getMessage())));
186            return false;
187        }
188        return true;
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            return false;
219        }
220    }
221
222    /**
223     * @inheritDoc
224     */
225    public function delete($pasteid)
226    {
227        $name = $this->_getKey($pasteid);
228
229        try {
230            $comments = $this->_listAllObjects($name . '/discussion/');
231            foreach ($comments as $comment) {
232                try {
233                    $this->_client->deleteObject(array(
234                        'Bucket' => $this->_bucket,
235                        'Key'    => $comment['Key'],
236                    ));
237                } catch (S3Exception $e) {
238                    // ignore if already deleted.
239                }
240            }
241        } catch (S3Exception $e) {
242            // there are no discussions associated with the paste
243        }
244
245        try {
246            $this->_client->deleteObject(array(
247                'Bucket' => $this->_bucket,
248                'Key'    => $name,
249            ));
250        } catch (S3Exception $e) {
251            // ignore if already deleted
252        }
253    }
254
255    /**
256     * @inheritDoc
257     */
258    public function exists($pasteid)
259    {
260        return $this->_client->doesObjectExistV2($this->_bucket, $this->_getKey($pasteid));
261    }
262
263    /**
264     * @inheritDoc
265     */
266    public function createComment($pasteid, $parentid, $commentid, array $comment)
267    {
268        if ($this->existsComment($pasteid, $parentid, $commentid)) {
269            return false;
270        }
271        $key = $this->_getKey($pasteid) . '/discussion/' . $parentid . '/' . $commentid;
272        return $this->_upload($key, $comment);
273    }
274
275    /**
276     * @inheritDoc
277     */
278    public function readComments($pasteid)
279    {
280        $comments = array();
281        $prefix   = $this->_getKey($pasteid) . '/discussion/';
282        try {
283            $entries = $this->_listAllObjects($prefix);
284            foreach ($entries as $entry) {
285                $object = $this->_client->getObject(array(
286                    'Bucket' => $this->_bucket,
287                    'Key'    => $entry['Key'],
288                ));
289                $body             = JSON::decode($object['Body']->getContents());
290                $items            = explode('/', $entry['Key']);
291                $body['id']       = $items[3];
292                $body['parentid'] = $items[2];
293                $slot             = $this->getOpenSlot($comments, (int) $object['Metadata']['created']);
294                $comments[$slot]  = $body;
295            }
296        } catch (S3Exception $e) {
297            // no comments found
298        }
299        return $comments;
300    }
301
302    /**
303     * @inheritDoc
304     */
305    public function existsComment($pasteid, $parentid, $commentid)
306    {
307        $name = $this->_getKey($pasteid) . '/discussion/' . $parentid . '/' . $commentid;
308        return $this->_client->doesObjectExistV2($this->_bucket, $name);
309    }
310
311    /**
312     * @inheritDoc
313     */
314    public function purgeValues($namespace, $time)
315    {
316        $path = $this->_prefix;
317        if ($path != '') {
318            $path .= '/';
319        }
320        $path .= 'config/' . $namespace;
321
322        try {
323            foreach ($this->_listAllObjects($path) as $object) {
324                $name = $object['Key'];
325                if (strlen($name) > strlen($path) && substr($name, strlen($path), 1) !== '/') {
326                    continue;
327                }
328                $head = $this->_client->headObject(array(
329                    'Bucket' => $this->_bucket,
330                    'Key'    => $name,
331                ));
332                if ($head->get('Metadata') != null && array_key_exists('value', $head->get('Metadata'))) {
333                    $value = $head->get('Metadata')['value'];
334                    if (is_numeric($value) && intval($value) < $time) {
335                        try {
336                            $this->_client->deleteObject(array(
337                                'Bucket' => $this->_bucket,
338                                'Key'    => $name,
339                            ));
340                        } catch (S3Exception $e) {
341                            // deleted by another instance.
342                        }
343                    }
344                }
345            }
346        } catch (S3Exception $e) {
347            // no objects in the bucket yet
348        }
349    }
350
351    /**
352     * For S3, the value will also be stored in the metadata for the
353     * namespaces traffic_limiter and purge_limiter.
354     * @inheritDoc
355     */
356    public function setValue($value, $namespace, $key = '')
357    {
358        $prefix = $this->_prefix;
359        if ($prefix != '') {
360            $prefix .= '/';
361        }
362
363        if ($key === '') {
364            $key = $prefix . 'config/' . $namespace;
365        } else {
366            $key = $prefix . 'config/' . $namespace . '/' . $key;
367        }
368
369        $metadata = array('namespace' => $namespace);
370        if ($namespace != 'salt') {
371            $metadata['value'] = strval($value);
372        }
373        try {
374            $this->_client->putObject(array(
375                'Bucket'      => $this->_bucket,
376                'Key'         => $key,
377                'Body'        => $value,
378                'ContentType' => 'application/json',
379                'Metadata'    => $metadata,
380            ));
381        } catch (S3Exception $e) {
382            error_log('failed to set key ' . $key . ' to ' . $this->_bucket . ', ' .
383                trim(preg_replace('/\s\s+/', ' ', $e->getMessage())));
384            return false;
385        }
386        return true;
387    }
388
389    /**
390     * @inheritDoc
391     */
392    public function getValue($namespace, $key = '')
393    {
394        $prefix = $this->_prefix;
395        if ($prefix != '') {
396            $prefix .= '/';
397        }
398
399        if ($key === '') {
400            $key = $prefix . 'config/' . $namespace;
401        } else {
402            $key = $prefix . 'config/' . $namespace . '/' . $key;
403        }
404
405        try {
406            $object = $this->_client->getObject(array(
407                'Bucket' => $this->_bucket,
408                'Key'    => $key,
409            ));
410            return $object['Body']->getContents();
411        } catch (S3Exception $e) {
412            return '';
413        }
414    }
415
416    /**
417     * @inheritDoc
418     */
419    protected function _getExpiredPastes($batchsize)
420    {
421        $expired = array();
422        $now     = time();
423        $prefix  = $this->_prefix;
424        if ($prefix != '') {
425            $prefix .= '/';
426        }
427
428        try {
429            foreach ($this->_listAllObjects($prefix) as $object) {
430                $head = $this->_client->headObject(array(
431                    'Bucket' => $this->_bucket,
432                    'Key'    => $object['Key'],
433                ));
434                if ($head->get('Metadata') != null && array_key_exists('expire_date', $head->get('Metadata'))) {
435                    $expire_at = intval($head->get('Metadata')['expire_date']);
436                    if ($expire_at != 0 && $expire_at < $now) {
437                        array_push($expired, $object['Key']);
438                    }
439                }
440
441                if (count($expired) > $batchsize) {
442                    break;
443                }
444            }
445        } catch (S3Exception $e) {
446            // no objects in the bucket yet
447        }
448        return $expired;
449    }
450
451    /**
452     * @inheritDoc
453     */
454    public function getAllPastes()
455    {
456        $pastes = array();
457        $prefix = $this->_prefix;
458        if ($prefix != '') {
459            $prefix .= '/';
460        }
461
462        try {
463            foreach ($this->_listAllObjects($prefix) as $object) {
464                $candidate = substr($object['Key'], strlen($prefix));
465                if (strpos($candidate, '/') === false) {
466                    $pastes[] = $candidate;
467                }
468            }
469        } catch (S3Exception $e) {
470            // no objects in the bucket yet
471        }
472        return $pastes;
473    }
474}