Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.34% covered (success)
98.34%
178 / 181
88.89% covered (success)
88.89%
16 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
Filesystem
98.34% covered (success)
98.34%
178 / 181
88.89% covered (success)
88.89%
16 / 18
74
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 create
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 read
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 delete
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
 exists
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
6
 createComment
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 readComments
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
5
 existsComment
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 setValue
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
4
 getValue
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
10
 _get
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 _getExpiredPastes
88.89% covered (success)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
7.07
 getAllPastes
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 _dataid2path
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 _dataid2discussionpath
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 _store
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 _storeString
96.15% covered (success)
96.15%
25 / 26
0.00% covered (danger)
0.00%
0 / 1
13
 _prependRename
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
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 2012 Sébastien SAUVAGE (sebsauvage.net)
9 * @license   https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
10 */
11
12namespace PrivateBin\Data;
13
14use Exception;
15use GlobIterator;
16use PrivateBin\Json;
17
18/**
19 * Filesystem
20 *
21 * Model for filesystem data access, implemented as a singleton.
22 */
23class Filesystem extends AbstractData
24{
25    /**
26     * glob() pattern of the two folder levels and the paste files under the
27     * configured path. Needs to return both files with and without .php suffix,
28     * so they can be hardened by _prependRename(), which is hooked into exists().
29     *
30     * > Note that wildcard patterns are not regular expressions, although they
31     * > are a bit similar.
32     *
33     * @link  https://man7.org/linux/man-pages/man7/glob.7.html
34     * @const string
35     */
36    const PASTE_FILE_PATTERN = DIRECTORY_SEPARATOR . '[a-f0-9][a-f0-9]' .
37        DIRECTORY_SEPARATOR . '[a-f0-9][a-f0-9]' . DIRECTORY_SEPARATOR .
38        '[a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9]' .
39        '[a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9]*';
40
41    /**
42     * first line in paste or comment files, to protect their contents from browsing exposed data directories
43     *
44     * @const string
45     */
46    const PROTECTION_LINE = '<?php http_response_code(403); /*';
47
48    /**
49     * line in generated .htaccess files, to protect exposed directories from being browsable on apache web servers
50     *
51     * @const string
52     */
53    const HTACCESS_LINE = 'Require all denied';
54
55    /**
56     * path in which to persist something
57     *
58     * @access private
59     * @var    string
60     */
61    private $_path = 'data';
62
63    /**
64     * instantiates a new Filesystem data backend
65     *
66     * @access public
67     * @param  array $options
68     */
69    public function __construct(array $options)
70    {
71        // if given update the data directory
72        if (
73            is_array($options) &&
74            array_key_exists('dir', $options)
75        ) {
76            $this->_path = $options['dir'];
77        }
78    }
79
80    /**
81     * Create a paste.
82     *
83     * @access public
84     * @param  string $pasteid
85     * @param  array  $paste
86     * @return bool
87     */
88    public function create($pasteid, array $paste)
89    {
90        $storagedir = $this->_dataid2path($pasteid);
91        $file       = $storagedir . $pasteid . '.php';
92        if (is_file($file)) {
93            return false;
94        }
95        if (!is_dir($storagedir)) {
96            mkdir($storagedir, 0700, true);
97        }
98        return $this->_store($file, $paste);
99    }
100
101    /**
102     * Read a paste.
103     *
104     * @access public
105     * @param  string $pasteid
106     * @return array|false
107     */
108    public function read($pasteid)
109    {
110        if (
111            !$this->exists($pasteid) ||
112            !$paste = $this->_get($this->_dataid2path($pasteid) . $pasteid . '.php')
113        ) {
114            return false;
115        }
116        return self::upgradePreV1Format($paste);
117    }
118
119    /**
120     * Delete a paste and its discussion.
121     *
122     * @access public
123     * @param  string $pasteid
124     */
125    public function delete($pasteid)
126    {
127        $pastedir = $this->_dataid2path($pasteid);
128        if (is_dir($pastedir)) {
129            // Delete the paste itself.
130            if (is_file($pastedir . $pasteid . '.php')) {
131                unlink($pastedir . $pasteid . '.php');
132            }
133
134            // Delete discussion if it exists.
135            $discdir = $this->_dataid2discussionpath($pasteid);
136            if (is_dir($discdir)) {
137                // Delete all files in discussion directory
138                $dir = dir($discdir);
139                while (false !== ($filename = $dir->read())) {
140                    if (is_file($discdir . $filename)) {
141                        unlink($discdir . $filename);
142                    }
143                }
144                $dir->close();
145                rmdir($discdir);
146            }
147        }
148    }
149
150    /**
151     * Test if a paste exists.
152     *
153     * @access public
154     * @param  string $pasteid
155     * @return bool
156     */
157    public function exists($pasteid)
158    {
159        $basePath  = $this->_dataid2path($pasteid) . $pasteid;
160        $pastePath = $basePath . '.php';
161        // convert to PHP protected files if needed
162        if (is_readable($basePath)) {
163            $this->_prependRename($basePath, $pastePath);
164
165            // convert comments, too
166            $discdir = $this->_dataid2discussionpath($pasteid);
167            if (is_dir($discdir)) {
168                $dir = dir($discdir);
169                while (false !== ($filename = $dir->read())) {
170                    if (substr($filename, -4) !== '.php' && strlen($filename) >= 16) {
171                        $commentFilename = $discdir . $filename . '.php';
172                        $this->_prependRename($discdir . $filename, $commentFilename);
173                    }
174                }
175                $dir->close();
176            }
177        }
178        return is_readable($pastePath);
179    }
180
181    /**
182     * Create a comment in a paste.
183     *
184     * @access public
185     * @param  string $pasteid
186     * @param  string $parentid
187     * @param  string $commentid
188     * @param  array  $comment
189     * @return bool
190     */
191    public function createComment($pasteid, $parentid, $commentid, array $comment)
192    {
193        $storagedir = $this->_dataid2discussionpath($pasteid);
194        $file       = $storagedir . $pasteid . '.' . $commentid . '.' . $parentid . '.php';
195        if (is_file($file)) {
196            return false;
197        }
198        if (!is_dir($storagedir)) {
199            mkdir($storagedir, 0700, true);
200        }
201        return $this->_store($file, $comment);
202    }
203
204    /**
205     * Read all comments of paste.
206     *
207     * @access public
208     * @param  string $pasteid
209     * @return array
210     */
211    public function readComments($pasteid)
212    {
213        $comments = array();
214        $discdir  = $this->_dataid2discussionpath($pasteid);
215        if (is_dir($discdir)) {
216            $dir = dir($discdir);
217            while (false !== ($filename = $dir->read())) {
218                // Filename is in the form pasteid.commentid.parentid.php:
219                // - pasteid is the paste this reply belongs to.
220                // - commentid is the comment identifier itself.
221                // - parentid is the comment this comment replies to (It can be pasteid)
222                if (is_file($discdir . $filename)) {
223                    $comment = $this->_get($discdir . $filename);
224                    $items   = explode('.', $filename);
225                    // Add some meta information not contained in file.
226                    $comment['id']       = $items[1];
227                    $comment['parentid'] = $items[2];
228
229                    // Store in array
230                    $key            = $this->getOpenSlot(
231                        $comments,
232                        (int) array_key_exists('created', $comment['meta']) ?
233                        $comment['meta']['created'] : // v2 comments
234                        $comment['meta']['postdate']  // v1 comments
235                    );
236                    $comments[$key] = $comment;
237                }
238            }
239            $dir->close();
240
241            // Sort comments by date, oldest first.
242            ksort($comments);
243        }
244        return $comments;
245    }
246
247    /**
248     * Test if a comment exists.
249     *
250     * @access public
251     * @param  string $pasteid
252     * @param  string $parentid
253     * @param  string $commentid
254     * @return bool
255     */
256    public function existsComment($pasteid, $parentid, $commentid)
257    {
258        return is_file(
259            $this->_dataid2discussionpath($pasteid) .
260            $pasteid . '.' . $commentid . '.' . $parentid . '.php'
261        );
262    }
263
264    /**
265     * Save a value.
266     *
267     * @access public
268     * @param  string $value
269     * @param  string $namespace
270     * @param  string $key
271     * @return bool
272     */
273    public function setValue($value, $namespace, $key = '')
274    {
275        switch ($namespace) {
276            case 'purge_limiter':
277                return $this->_storeString(
278                    $this->_path . DIRECTORY_SEPARATOR . 'purge_limiter.php',
279                    '<?php' . PHP_EOL . '$GLOBALS[\'purge_limiter\'] = ' . $value . ';'
280                );
281            case 'salt':
282                return $this->_storeString(
283                    $this->_path . DIRECTORY_SEPARATOR . 'salt.php',
284                    '<?php # |' . $value . '|'
285                );
286            case 'traffic_limiter':
287                $this->_last_cache[$key] = $value;
288                return $this->_storeString(
289                    $this->_path . DIRECTORY_SEPARATOR . 'traffic_limiter.php',
290                    '<?php' . PHP_EOL . '$GLOBALS[\'traffic_limiter\'] = ' . var_export($this->_last_cache, true) . ';'
291                );
292        }
293        return false;
294    }
295
296    /**
297     * Load a value.
298     *
299     * @access public
300     * @param  string $namespace
301     * @param  string $key
302     * @return string
303     */
304    public function getValue($namespace, $key = '')
305    {
306        switch ($namespace) {
307            case 'purge_limiter':
308                $file = $this->_path . DIRECTORY_SEPARATOR . 'purge_limiter.php';
309                if (is_readable($file)) {
310                    require $file;
311                    return $GLOBALS['purge_limiter'];
312                }
313                break;
314            case 'salt':
315                $file = $this->_path . DIRECTORY_SEPARATOR . 'salt.php';
316                if (is_readable($file)) {
317                    $items = explode('|', file_get_contents($file));
318                    if (is_array($items) && count($items) == 3) {
319                        return $items[1];
320                    }
321                }
322                break;
323            case 'traffic_limiter':
324                $file = $this->_path . DIRECTORY_SEPARATOR . 'traffic_limiter.php';
325                if (is_readable($file)) {
326                    require $file;
327                    $this->_last_cache = $GLOBALS['traffic_limiter'];
328                    if (array_key_exists($key, $this->_last_cache)) {
329                        return $this->_last_cache[$key];
330                    }
331                }
332                break;
333        }
334        return '';
335    }
336
337    /**
338     * get the data
339     *
340     * @access public
341     * @param  string $filename
342     * @return array|false $data
343     */
344    private function _get($filename)
345    {
346        return Json::decode(
347            substr(
348                file_get_contents($filename),
349                strlen(self::PROTECTION_LINE . PHP_EOL)
350            )
351        );
352    }
353
354    /**
355     * Returns up to batch size number of paste ids that have expired
356     *
357     * @access private
358     * @param  int $batchsize
359     * @return array
360     */
361    protected function _getExpiredPastes($batchsize)
362    {
363        $pastes = array();
364        $count  = 0;
365        $opened = 0;
366        $limit  = $batchsize * 10; // try at most 10 times $batchsize pastes before giving up
367        $time   = time();
368        $files  = $this->getAllPastes();
369        shuffle($files);
370        foreach ($files as $pasteid) {
371            if ($this->exists($pasteid)) {
372                $data = $this->read($pasteid);
373                if (
374                    array_key_exists('expire_date', $data['meta']) &&
375                    $data['meta']['expire_date'] < $time
376                ) {
377                    $pastes[] = $pasteid;
378                    if (++$count >= $batchsize) {
379                        break;
380                    }
381                }
382                if (++$opened >= $limit) {
383                    break;
384                }
385            }
386        }
387        return $pastes;
388    }
389
390    /**
391     * @inheritDoc
392     */
393    public function getAllPastes()
394    {
395        $pastes = array();
396        foreach (new GlobIterator($this->_path . self::PASTE_FILE_PATTERN) as $file) {
397            if ($file->isFile()) {
398                $pastes[] = $file->getBasename('.php');
399            }
400        }
401        return $pastes;
402    }
403
404    /**
405     * Convert paste id to storage path.
406     *
407     * The idea is to creates subdirectories in order to limit the number of files per directory.
408     * (A high number of files in a single directory can slow things down.)
409     * eg. "f468483c313401e8" will be stored in "data/f4/68/f468483c313401e8"
410     * High-trafic websites may want to deepen the directory structure (like Squid does).
411     *
412     * eg. input 'e3570978f9e4aa90' --> output 'data/e3/57/'
413     *
414     * @access private
415     * @param  string $dataid
416     * @return string
417     */
418    private function _dataid2path($dataid)
419    {
420        return $this->_path . DIRECTORY_SEPARATOR .
421            substr($dataid, 0, 2) . DIRECTORY_SEPARATOR .
422            substr($dataid, 2, 2) . DIRECTORY_SEPARATOR;
423    }
424
425    /**
426     * Convert paste id to discussion storage path.
427     *
428     * eg. input 'e3570978f9e4aa90' --> output 'data/e3/57/e3570978f9e4aa90.discussion/'
429     *
430     * @access private
431     * @param  string $dataid
432     * @return string
433     */
434    private function _dataid2discussionpath($dataid)
435    {
436        return $this->_dataid2path($dataid) . $dataid .
437            '.discussion' . DIRECTORY_SEPARATOR;
438    }
439
440    /**
441     * store the data
442     *
443     * @access public
444     * @param  string $filename
445     * @param  array  $data
446     * @return bool
447     */
448    private function _store($filename, array $data)
449    {
450        try {
451            return $this->_storeString(
452                $filename,
453                self::PROTECTION_LINE . PHP_EOL . Json::encode($data)
454            );
455        } catch (Exception $e) {
456            return false;
457        }
458    }
459
460    /**
461     * store a string
462     *
463     * @access public
464     * @param  string $filename
465     * @param  string $data
466     * @return bool
467     */
468    private function _storeString($filename, $data)
469    {
470        // Create storage directory if it does not exist.
471        if (!is_dir($this->_path)) {
472            if (!@mkdir($this->_path, 0700)) {
473                return false;
474            }
475        }
476        $file = $this->_path . DIRECTORY_SEPARATOR . '.htaccess';
477        if (!is_file($file)) {
478            $writtenBytes = 0;
479            if ($fileCreated = @touch($file)) {
480                $writtenBytes = @file_put_contents(
481                    $file,
482                    self::HTACCESS_LINE . PHP_EOL,
483                    LOCK_EX
484                );
485            }
486            if (
487                $fileCreated === false ||
488                $writtenBytes === false ||
489                $writtenBytes < strlen(self::HTACCESS_LINE . PHP_EOL)
490            ) {
491                return false;
492            }
493        }
494
495        $fileCreated  = true;
496        $writtenBytes = 0;
497        if (!is_file($filename)) {
498            $fileCreated = @touch($filename);
499        }
500        if ($fileCreated) {
501            $writtenBytes = @file_put_contents($filename, $data, LOCK_EX);
502        }
503        if ($fileCreated === false || $writtenBytes === false || $writtenBytes < strlen($data)) {
504            return false;
505        }
506        @chmod($filename, 0640); // protect file from access by other users on the host
507        return true;
508    }
509
510    /**
511     * rename a file, prepending the protection line at the beginning
512     *
513     * @access public
514     * @param  string $srcFile
515     * @param  string $destFile
516     * @return void
517     */
518    private function _prependRename($srcFile, $destFile)
519    {
520        // don't overwrite already converted file
521        if (!is_readable($destFile)) {
522            $handle = fopen($srcFile, 'r', false, stream_context_create());
523            file_put_contents($destFile, self::PROTECTION_LINE . PHP_EOL);
524            file_put_contents($destFile, $handle, FILE_APPEND);
525            fclose($handle);
526        }
527        unlink($srcFile);
528    }
529}