Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.35% covered (success)
98.35%
179 / 182
88.89% covered (success)
88.89%
16 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
Filesystem
98.35% covered (success)
98.35%
179 / 182
88.89% covered (success)
88.89%
16 / 18
76
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%
24 / 24
100.00% covered (success)
100.00%
1 / 1
12
 _get
100.00% covered (success)
100.00%
5 / 5
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\'] = ' . var_export($value, true) . ';'
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                    if (array_key_exists('purge_limiter', $GLOBALS)) {
312                        return $GLOBALS['purge_limiter'];
313                    }
314                }
315                break;
316            case 'salt':
317                $file = $this->_path . DIRECTORY_SEPARATOR . 'salt.php';
318                if (is_readable($file)) {
319                    $items = explode('|', file_get_contents($file));
320                    if (is_array($items) && count($items) == 3) {
321                        return $items[1];
322                    }
323                }
324                break;
325            case 'traffic_limiter':
326                $file = $this->_path . DIRECTORY_SEPARATOR . 'traffic_limiter.php';
327                if (is_readable($file)) {
328                    require $file;
329                    if (array_key_exists('traffic_limiter', $GLOBALS)) {
330                        $this->_last_cache = $GLOBALS['traffic_limiter'];
331                        if (array_key_exists($key, $this->_last_cache)) {
332                            return $this->_last_cache[$key];
333                        }
334                    }
335                }
336                break;
337        }
338        return '';
339    }
340
341    /**
342     * get the data
343     *
344     * @access public
345     * @param  string $filename
346     * @return array|false $data
347     */
348    private function _get($filename)
349    {
350        $data = substr(
351            file_get_contents($filename),
352            strlen(self::PROTECTION_LINE . PHP_EOL)
353        );
354        return Json::decode($data);
355    }
356
357    /**
358     * Returns up to batch size number of paste ids that have expired
359     *
360     * @access private
361     * @param  int $batchsize
362     * @return array
363     */
364    protected function _getExpiredPastes($batchsize)
365    {
366        $pastes = array();
367        $count  = 0;
368        $opened = 0;
369        $limit  = $batchsize * 10; // try at most 10 times $batchsize pastes before giving up
370        $time   = time();
371        $files  = $this->getAllPastes();
372        shuffle($files);
373        foreach ($files as $pasteid) {
374            if ($this->exists($pasteid)) {
375                $data = $this->read($pasteid);
376                if (
377                    array_key_exists('expire_date', $data['meta']) &&
378                    $data['meta']['expire_date'] < $time
379                ) {
380                    $pastes[] = $pasteid;
381                    if (++$count >= $batchsize) {
382                        break;
383                    }
384                }
385                if (++$opened >= $limit) {
386                    break;
387                }
388            }
389        }
390        return $pastes;
391    }
392
393    /**
394     * @inheritDoc
395     */
396    public function getAllPastes()
397    {
398        $pastes = array();
399        foreach (new GlobIterator($this->_path . self::PASTE_FILE_PATTERN) as $file) {
400            if ($file->isFile()) {
401                $pastes[] = $file->getBasename('.php');
402            }
403        }
404        return $pastes;
405    }
406
407    /**
408     * Convert paste id to storage path.
409     *
410     * The idea is to creates subdirectories in order to limit the number of files per directory.
411     * (A high number of files in a single directory can slow things down.)
412     * eg. "f468483c313401e8" will be stored in "data/f4/68/f468483c313401e8"
413     * High-trafic websites may want to deepen the directory structure (like Squid does).
414     *
415     * eg. input 'e3570978f9e4aa90' --> output 'data/e3/57/'
416     *
417     * @access private
418     * @param  string $dataid
419     * @return string
420     */
421    private function _dataid2path($dataid)
422    {
423        return $this->_path . DIRECTORY_SEPARATOR .
424            substr($dataid, 0, 2) . DIRECTORY_SEPARATOR .
425            substr($dataid, 2, 2) . DIRECTORY_SEPARATOR;
426    }
427
428    /**
429     * Convert paste id to discussion storage path.
430     *
431     * eg. input 'e3570978f9e4aa90' --> output 'data/e3/57/e3570978f9e4aa90.discussion/'
432     *
433     * @access private
434     * @param  string $dataid
435     * @return string
436     */
437    private function _dataid2discussionpath($dataid)
438    {
439        return $this->_dataid2path($dataid) . $dataid .
440            '.discussion' . DIRECTORY_SEPARATOR;
441    }
442
443    /**
444     * store the data
445     *
446     * @access public
447     * @param  string $filename
448     * @param  array  $data
449     * @return bool
450     */
451    private function _store($filename, array $data)
452    {
453        try {
454            return $this->_storeString(
455                $filename,
456                self::PROTECTION_LINE . PHP_EOL . Json::encode($data)
457            );
458        } catch (Exception $e) {
459            return false;
460        }
461    }
462
463    /**
464     * store a string
465     *
466     * @access public
467     * @param  string $filename
468     * @param  string $data
469     * @return bool
470     */
471    private function _storeString($filename, $data)
472    {
473        // Create storage directory if it does not exist.
474        if (!is_dir($this->_path)) {
475            if (!@mkdir($this->_path, 0700)) {
476                return false;
477            }
478        }
479        $file = $this->_path . DIRECTORY_SEPARATOR . '.htaccess';
480        if (!is_file($file)) {
481            $writtenBytes = 0;
482            if ($fileCreated = @touch($file)) {
483                $writtenBytes = @file_put_contents(
484                    $file,
485                    self::HTACCESS_LINE . PHP_EOL,
486                    LOCK_EX
487                );
488            }
489            if (
490                $fileCreated === false ||
491                $writtenBytes === false ||
492                $writtenBytes < strlen(self::HTACCESS_LINE . PHP_EOL)
493            ) {
494                return false;
495            }
496        }
497
498        $fileCreated  = true;
499        $writtenBytes = 0;
500        if (!is_file($filename)) {
501            $fileCreated = @touch($filename);
502        }
503        if ($fileCreated) {
504            $writtenBytes = @file_put_contents($filename, $data, LOCK_EX);
505        }
506        if ($fileCreated === false || $writtenBytes === false || $writtenBytes < strlen($data)) {
507            return false;
508        }
509        @chmod($filename, 0640); // protect file from access by other users on the host
510        return true;
511    }
512
513    /**
514     * rename a file, prepending the protection line at the beginning
515     *
516     * @access public
517     * @param  string $srcFile
518     * @param  string $destFile
519     * @return void
520     */
521    private function _prependRename($srcFile, $destFile)
522    {
523        // don't overwrite already converted file
524        if (!is_readable($destFile)) {
525            $handle = fopen($srcFile, 'r', false, stream_context_create());
526            file_put_contents($destFile, self::PROTECTION_LINE . PHP_EOL);
527            file_put_contents($destFile, $handle, FILE_APPEND);
528            fclose($handle);
529        }
530        unlink($srcFile);
531    }
532}