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