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