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