Passed
Push — master ( 66600e...05f77e )
by El
03:36 queued 12s
created

Filesystem::getAllPastes()   B

Complexity

Conditions 8
Paths 30

Size

Total Lines 35
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 72

Importance

Changes 0
Metric Value
cc 8
eloc 21
c 0
b 0
f 0
nc 30
nop 0
dl 0
loc 35
ccs 0
cts 22
cp 0
crap 72
rs 8.4444
1
<?php
2
/**
3
 * PrivateBin
4
 *
5
 * a zero-knowledge paste bin
6
 *
7
 * @link      https://github.com/PrivateBin/PrivateBin
8
 * @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
9
 * @license   https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
10
 * @version   1.4.0
11
 */
12
13
namespace PrivateBin\Data;
14
15
use Exception;
16
use PrivateBin\Json;
17
18
/**
19
 * Filesystem
20
 *
21
 * Model for filesystem data access, implemented as a singleton.
22
 */
23
class Filesystem extends AbstractData
24
{
25
    /**
26
     * first line in paste or comment files, to protect their contents from browsing exposed data directories
27
     *
28
     * @const string
29
     */
30
    const PROTECTION_LINE = '<?php http_response_code(403); /*';
31
32
    /**
33
     * line in generated .htaccess files, to protect exposed directories from being browsable on apache web servers
34
     *
35
     * @const string
36
     */
37
    const HTACCESS_LINE = 'Require all denied';
38
39
    /**
40
     * path in which to persist something
41
     *
42
     * @access private
43
     * @var    string
44
     */
45
    private $_path = 'data';
46
47
    /**
48
     * instantiates a new Filesystem data backend
49
     *
50
     * @access public
51
     * @param  array $options
52
     * @return
53
     */
54 67
    public function __construct(array $options)
55
    {
56
        // if given update the data directory
57
        if (
58 67
            is_array($options) &&
59 67
            array_key_exists('dir', $options)
60
        ) {
61 67
            $this->_path = $options['dir'];
62
        }
63 67
    }
64
65
    /**
66
     * Create a paste.
67
     *
68
     * @access public
69
     * @param  string $pasteid
70
     * @param  array  $paste
71
     * @return bool
72
     */
73 33
    public function create($pasteid, array $paste)
74
    {
75 33
        $storagedir = $this->_dataid2path($pasteid);
76 33
        $file       = $storagedir . $pasteid . '.php';
77 33
        if (is_file($file)) {
78 2
            return false;
79
        }
80 33
        if (!is_dir($storagedir)) {
81 33
            mkdir($storagedir, 0700, true);
82
        }
83 33
        return $this->_store($file, $paste);
84
    }
85
86
    /**
87
     * Read a paste.
88
     *
89
     * @access public
90
     * @param  string $pasteid
91
     * @return array|false
92
     */
93 29
    public function read($pasteid)
94
    {
95
        if (
96 29
            !$this->exists($pasteid) ||
97 29
            !$paste = $this->_get($this->_dataid2path($pasteid) . $pasteid . '.php')
98
        ) {
99 1
            return false;
100
        }
101 29
        return self::upgradePreV1Format($paste);
102
    }
103
104
    /**
105
     * Delete a paste and its discussion.
106
     *
107
     * @access public
108
     * @param  string $pasteid
109
     */
110 14
    public function delete($pasteid)
111
    {
112 14
        $pastedir = $this->_dataid2path($pasteid);
113 14
        if (is_dir($pastedir)) {
114
            // Delete the paste itself.
115 11
            if (is_file($pastedir . $pasteid . '.php')) {
116 11
                unlink($pastedir . $pasteid . '.php');
117
            }
118
119
            // Delete discussion if it exists.
120 11
            $discdir = $this->_dataid2discussionpath($pasteid);
121 11
            if (is_dir($discdir)) {
122
                // Delete all files in discussion directory
123 1
                $dir = dir($discdir);
124 1
                while (false !== ($filename = $dir->read())) {
125 1
                    if (is_file($discdir . $filename)) {
126 1
                        unlink($discdir . $filename);
127
                    }
128
                }
129 1
                $dir->close();
130 1
                rmdir($discdir);
131
            }
132
        }
133 14
    }
134
135
    /**
136
     * Test if a paste exists.
137
     *
138
     * @access public
139
     * @param  string $pasteid
140
     * @return bool
141
     */
142 55
    public function exists($pasteid)
143
    {
144 55
        $basePath  = $this->_dataid2path($pasteid) . $pasteid;
145 55
        $pastePath = $basePath . '.php';
146
        // convert to PHP protected files if needed
147 55
        if (is_readable($basePath)) {
148 1
            $this->_prependRename($basePath, $pastePath);
149
150
            // convert comments, too
151 1
            $discdir = $this->_dataid2discussionpath($pasteid);
152 1
            if (is_dir($discdir)) {
153 1
                $dir = dir($discdir);
154 1
                while (false !== ($filename = $dir->read())) {
155 1
                    if (substr($filename, -4) !== '.php' && strlen($filename) >= 16) {
156 1
                        $commentFilename = $discdir . $filename . '.php';
157 1
                        $this->_prependRename($discdir . $filename, $commentFilename);
158
                    }
159
                }
160 1
                $dir->close();
161
            }
162
        }
163 55
        return is_readable($pastePath);
164
    }
165
166
    /**
167
     * Create a comment in a paste.
168
     *
169
     * @access public
170
     * @param  string $pasteid
171
     * @param  string $parentid
172
     * @param  string $commentid
173
     * @param  array  $comment
174
     * @return bool
175
     */
176 4
    public function createComment($pasteid, $parentid, $commentid, array $comment)
177
    {
178 4
        $storagedir = $this->_dataid2discussionpath($pasteid);
179 4
        $file       = $storagedir . $pasteid . '.' . $commentid . '.' . $parentid . '.php';
180 4
        if (is_file($file)) {
181 1
            return false;
182
        }
183 4
        if (!is_dir($storagedir)) {
184 4
            mkdir($storagedir, 0700, true);
185
        }
186 4
        return $this->_store($file, $comment);
187
    }
188
189
    /**
190
     * Read all comments of paste.
191
     *
192
     * @access public
193
     * @param  string $pasteid
194
     * @return array
195
     */
196 16
    public function readComments($pasteid)
197
    {
198 16
        $comments = array();
199 16
        $discdir  = $this->_dataid2discussionpath($pasteid);
200 16
        if (is_dir($discdir)) {
201 3
            $dir = dir($discdir);
202 3
            while (false !== ($filename = $dir->read())) {
203
                // Filename is in the form pasteid.commentid.parentid.php:
204
                // - pasteid is the paste this reply belongs to.
205
                // - commentid is the comment identifier itself.
206
                // - parentid is the comment this comment replies to (It can be pasteid)
207 3
                if (is_file($discdir . $filename)) {
208 3
                    $comment = $this->_get($discdir . $filename);
209 3
                    $items   = explode('.', $filename);
210
                    // Add some meta information not contained in file.
211 3
                    $comment['id']       = $items[1];
212 3
                    $comment['parentid'] = $items[2];
213
214
                    // Store in array
215 3
                    $key            = $this->getOpenSlot($comments, (int) $comment['meta']['created']);
216 3
                    $comments[$key] = $comment;
217
                }
218
            }
219 3
            $dir->close();
220
221
            // Sort comments by date, oldest first.
222 3
            ksort($comments);
223
        }
224 16
        return $comments;
225
    }
226
227
    /**
228
     * Test if a comment exists.
229
     *
230
     * @access public
231
     * @param  string $pasteid
232
     * @param  string $parentid
233
     * @param  string $commentid
234
     * @return bool
235
     */
236 8
    public function existsComment($pasteid, $parentid, $commentid)
237
    {
238 8
        return is_file(
239 8
            $this->_dataid2discussionpath($pasteid) .
240 8
            $pasteid . '.' . $commentid . '.' . $parentid . '.php'
241
        );
242
    }
243
244
    /**
245
     * Save a value.
246
     *
247
     * @access public
248
     * @param  string $value
249
     * @param  string $namespace
250
     * @param  string $key
251
     * @return bool
252
     */
253 28
    public function setValue($value, $namespace, $key = '')
254
    {
255 28
        switch ($namespace) {
256 28
            case 'purge_limiter':
257 13
                return $this->_storeString(
258 13
                    $this->_path . DIRECTORY_SEPARATOR . 'purge_limiter.php',
259 13
                    '<?php' . PHP_EOL . '$GLOBALS[\'purge_limiter\'] = ' . $value . ';'
260
                );
261 18
            case 'salt':
262 17
                return $this->_storeString(
263 17
                    $this->_path . DIRECTORY_SEPARATOR . 'salt.php',
264 17
                    '<?php # |' . $value . '|'
265
                );
266 7
            case 'traffic_limiter':
267 6
                $this->_last_cache[$key] = $value;
268 6
                return $this->_storeString(
269 6
                    $this->_path . DIRECTORY_SEPARATOR . 'traffic_limiter.php',
270 6
                    '<?php' . PHP_EOL . '$GLOBALS[\'traffic_limiter\'] = ' . var_export($this->_last_cache, true) . ';'
271
                );
272
        }
273 1
        return false;
274
    }
275
276
    /**
277
     * Load a value.
278
     *
279
     * @access public
280
     * @param  string $namespace
281
     * @param  string $key
282
     * @return string
283
     */
284 27
    public function getValue($namespace, $key = '')
285
    {
286 27
        switch ($namespace) {
287 27
            case 'purge_limiter':
288 13
                $file = $this->_path . DIRECTORY_SEPARATOR . 'purge_limiter.php';
289 13
                if (is_readable($file)) {
290 1
                    require $file;
291 1
                    return $GLOBALS['purge_limiter'];
292
                }
293 13
                break;
294 17
            case 'salt':
295 17
                $file = $this->_path . DIRECTORY_SEPARATOR . 'salt.php';
296 17
                if (is_readable($file)) {
297 3
                    $items = explode('|', file_get_contents($file));
298 3
                    if (is_array($items) && count($items) == 3) {
299 3
                        return $items[1];
300
                    }
301
                }
302 17
                break;
303 6
            case 'traffic_limiter':
304 6
                $file = $this->_path . DIRECTORY_SEPARATOR . 'traffic_limiter.php';
305 6
                if (is_readable($file)) {
306 3
                    require $file;
307 3
                    $this->_last_cache = $GLOBALS['traffic_limiter'];
308 3
                    if (array_key_exists($key, $this->_last_cache)) {
309 3
                        return $this->_last_cache[$key];
310
                    }
311
                }
312 6
                break;
313
        }
314 27
        return '';
315
    }
316
317
    /**
318
     * get the data
319
     *
320
     * @access public
321
     * @param  string $filename
322
     * @return array|false $data
323
     */
324 29
    private function _get($filename)
325
    {
326 29
        return Json::decode(
327 29
            substr(
328 29
                file_get_contents($filename),
329 29
                strlen(self::PROTECTION_LINE . PHP_EOL)
330
            )
331
        );
332
    }
333
334
    /**
335
     * Returns up to batch size number of paste ids that have expired
336
     *
337
     * @access private
338
     * @param  int $batchsize
339
     * @return array
340
     */
341 14
    protected function _getExpiredPastes($batchsize)
342
    {
343 14
        $pastes     = array();
344 14
        $firstLevel = array_filter(
345 14
            scandir($this->_path),
346 14
            'PrivateBin\Data\Filesystem::_isFirstLevelDir'
347
        );
348 14
        if (count($firstLevel) > 0) {
349
            // try at most 10 times the $batchsize pastes before giving up
350 3
            for ($i = 0, $max = $batchsize * 10; $i < $max; ++$i) {
351 3
                $firstKey    = array_rand($firstLevel);
352 3
                $secondLevel = array_filter(
353 3
                    scandir($this->_path . DIRECTORY_SEPARATOR . $firstLevel[$firstKey]),
354 3
                    'PrivateBin\Data\Filesystem::_isSecondLevelDir'
355
                );
356
357
                // skip this folder in the next checks if it is empty
358 3
                if (count($secondLevel) == 0) {
359 1
                    unset($firstLevel[$firstKey]);
360 1
                    continue;
361
                }
362
363 3
                $secondKey = array_rand($secondLevel);
364 3
                $path      = $this->_path . DIRECTORY_SEPARATOR .
365 3
                    $firstLevel[$firstKey] . DIRECTORY_SEPARATOR .
366 3
                    $secondLevel[$secondKey];
367 3
                if (!is_dir($path)) {
368
                    continue;
369
                }
370 3
                $thirdLevel = array_filter(
371 3
                    array_map(
372 3
                        function ($filename) {
373 3
                            return strlen($filename) >= 20 ?
374 3
                                substr($filename, 0, -4) :
375 3
                                $filename;
376 3
                        },
377 3
                        scandir($path)
378
                    ),
379 3
                    'PrivateBin\\Model\\Paste::isValidId'
380
                );
381 3
                if (count($thirdLevel) == 0) {
382
                    continue;
383
                }
384 3
                $thirdKey = array_rand($thirdLevel);
385 3
                $pasteid  = $thirdLevel[$thirdKey];
386 3
                if (in_array($pasteid, $pastes)) {
387 1
                    continue;
388
                }
389
390 3
                if ($this->exists($pasteid)) {
391 3
                    $data = $this->read($pasteid);
392
                    if (
393 3
                        array_key_exists('expire_date', $data['meta']) &&
394 3
                        $data['meta']['expire_date'] < time()
395
                    ) {
396 1
                        $pastes[] = $pasteid;
397 1
                        if (count($pastes) >= $batchsize) {
398
                            break;
399
                        }
400
                    }
401
                }
402
            }
403
        }
404 14
        return $pastes;
405
    }
406
407
    /**
408
     * @inheritDoc
409
     */
410
    public function getAllPastes()
411
    {
412
        $pastes  = array();
413
        $subdirs = scandir($this->_path);
414
        if ($subdirs === false) {
415
            dieerr('Unable to list directory ' . $this->_path);
416
        }
417
        $subdirs = preg_grep('/^[^.].$/', $subdirs);
418
419
        foreach ($subdirs as $subdir) {
420
            $subpath = $this->_path . DIRECTORY_SEPARATOR . $subdir;
421
422
            $subsubdirs = scandir($subpath);
423
            if ($subsubdirs === false) {
424
                dieerr('Unable to list directory ' . $subpath);
425
            }
426
            $subsubdirs = preg_grep('/^[^.].$/', $subsubdirs);
427
            foreach ($subsubdirs as $subsubdir) {
428
                $subsubpath = $subpath . DIRECTORY_SEPARATOR . $subsubdir;
429
430
                $files = scandir($subsubpath);
431
                if ($files === false) {
432
                    dieerr('Unable to list directory ' . $subsubpath);
433
                }
434
                $files = preg_grep('/\.php$/', $files);
435
436
                foreach ($files as $file) {
437
                    if (substr($file, 0, 4) === $subdir . $subsubdir) {
438
                        $pastes[] = substr($file, 0, strlen($file) - 4);
439
                    }
440
                }
441
            }
442
        }
443
444
        return $pastes;
445
    }
446
447
    /**
448
     * Convert paste id to storage path.
449
     *
450
     * The idea is to creates subdirectories in order to limit the number of files per directory.
451
     * (A high number of files in a single directory can slow things down.)
452
     * eg. "f468483c313401e8" will be stored in "data/f4/68/f468483c313401e8"
453
     * High-trafic websites may want to deepen the directory structure (like Squid does).
454
     *
455
     * eg. input 'e3570978f9e4aa90' --> output 'data/e3/57/'
456
     *
457
     * @access private
458
     * @param  string $dataid
459
     * @return string
460
     */
461 55
    private function _dataid2path($dataid)
462
    {
463 55
        return $this->_path . DIRECTORY_SEPARATOR .
464 55
            substr($dataid, 0, 2) . DIRECTORY_SEPARATOR .
465 55
            substr($dataid, 2, 2) . DIRECTORY_SEPARATOR;
466
    }
467
468
    /**
469
     * Convert paste id to discussion storage path.
470
     *
471
     * eg. input 'e3570978f9e4aa90' --> output 'data/e3/57/e3570978f9e4aa90.discussion/'
472
     *
473
     * @access private
474
     * @param  string $dataid
475
     * @return string
476
     */
477 23
    private function _dataid2discussionpath($dataid)
478
    {
479 23
        return $this->_dataid2path($dataid) . $dataid .
480 23
            '.discussion' . DIRECTORY_SEPARATOR;
481
    }
482
483
    /**
484
     * Check that the given element is a valid first level directory.
485
     *
486
     * @access private
487
     * @param  string $element
488
     * @return bool
489
     */
490 14
    private function _isFirstLevelDir($element)
491
    {
492 14
        return $this->_isSecondLevelDir($element) &&
493 14
            is_dir($this->_path . DIRECTORY_SEPARATOR . $element);
494
    }
495
496
    /**
497
     * Check that the given element is a valid second level directory.
498
     *
499
     * @access private
500
     * @param  string $element
501
     * @return bool
502
     */
503 14
    private function _isSecondLevelDir($element)
504
    {
505 14
        return (bool) preg_match('/^[a-f0-9]{2}$/', $element);
506
    }
507
508
    /**
509
     * store the data
510
     *
511
     * @access public
512
     * @param  string $filename
513
     * @param  array  $data
514
     * @return bool
515
     */
516 33
    private function _store($filename, array $data)
517
    {
518
        try {
519 33
            return $this->_storeString(
520 33
                $filename,
521 33
                self::PROTECTION_LINE . PHP_EOL . Json::encode($data)
522
            );
523 2
        } catch (Exception $e) {
524 2
            return false;
525
        }
526
    }
527
528
    /**
529
     * store a string
530
     *
531
     * @access public
532
     * @param  string $filename
533
     * @param  string $data
534
     * @return bool
535
     */
536 44
    private function _storeString($filename, $data)
537
    {
538
        // Create storage directory if it does not exist.
539 44
        if (!is_dir($this->_path)) {
540 14
            if (!@mkdir($this->_path, 0700)) {
541
                return false;
542
            }
543
        }
544 44
        $file = $this->_path . DIRECTORY_SEPARATOR . '.htaccess';
545 44
        if (!is_file($file)) {
546 43
            $writtenBytes = 0;
547 43
            if ($fileCreated = @touch($file)) {
548 41
                $writtenBytes = @file_put_contents(
549 41
                    $file,
550 41
                    self::HTACCESS_LINE . PHP_EOL,
551 41
                    LOCK_EX
552
                );
553
            }
554
            if (
555 43
                $fileCreated === false ||
556 41
                $writtenBytes === false ||
557 43
                $writtenBytes < strlen(self::HTACCESS_LINE . PHP_EOL)
558
            ) {
559 2
                return false;
560
            }
561
        }
562
563 42
        $fileCreated  = true;
564 42
        $writtenBytes = 0;
565 42
        if (!is_file($filename)) {
566 41
            $fileCreated = @touch($filename);
567
        }
568 42
        if ($fileCreated) {
569 41
            $writtenBytes = @file_put_contents($filename, $data, LOCK_EX);
570
        }
571 42
        if ($fileCreated === false || $writtenBytes === false || $writtenBytes < strlen($data)) {
572 2
            return false;
573
        }
574 40
        @chmod($filename, 0640); // protect file from access by other users on the host
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for chmod(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

574
        /** @scrutinizer ignore-unhandled */ @chmod($filename, 0640); // protect file from access by other users on the host

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
575 40
        return true;
576
    }
577
578
    /**
579
     * rename a file, prepending the protection line at the beginning
580
     *
581
     * @access public
582
     * @param  string $srcFile
583
     * @param  string $destFile
584
     * @return void
585
     */
586 1
    private function _prependRename($srcFile, $destFile)
587
    {
588
        // don't overwrite already converted file
589 1
        if (!is_readable($destFile)) {
590 1
            $handle = fopen($srcFile, 'r', false, stream_context_create());
591 1
            file_put_contents($destFile, self::PROTECTION_LINE . PHP_EOL);
592 1
            file_put_contents($destFile, $handle, FILE_APPEND);
593 1
            fclose($handle);
594
        }
595 1
        unlink($srcFile);
596 1
    }
597
}
598