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