Issues (12)

lib/Data/Filesystem.php (1 issue)

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

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

506
        /** @scrutinizer ignore-unhandled */ @chmod($filename, 0640); // protect file from access by other users on the host

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
507 42
        return true;
508
    }
509
510
    /**
511
     * rename a file, prepending the protection line at the beginning
512
     *
513
     * @access public
514
     * @param  string $srcFile
515
     * @param  string $destFile
516
     * @return void
517
     */
518 1
    private function _prependRename($srcFile, $destFile)
519
    {
520
        // don't overwrite already converted file
521 1
        if (!is_readable($destFile)) {
522 1
            $handle = fopen($srcFile, 'r', false, stream_context_create());
523 1
            file_put_contents($destFile, self::PROTECTION_LINE . PHP_EOL);
524 1
            file_put_contents($destFile, $handle, FILE_APPEND);
525 1
            fclose($handle);
526
        }
527 1
        unlink($srcFile);
528
    }
529
}
530