Issues (11)

lib/Data/S3Storage.php (3 issues)

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.1
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
36
namespace PrivateBin\Data;
37
38
use Aws\S3\Exception\S3Exception;
0 ignored issues
show
The type Aws\S3\Exception\S3Exception was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
39
use Aws\S3\S3Client;
0 ignored issues
show
The type Aws\S3\S3Client was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
40
use PrivateBin\Json;
41
42
class 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)) {
0 ignored issues
show
The condition is_array($options) is always true.
Loading history...
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
}
475