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