Completed
Push — master ( 58937a...81ac23 )
by El
07:13
created

Filesystem::exists()   C

Complexity

Conditions 8
Paths 5

Size

Total Lines 38
Code Lines 25

Duplication

Lines 12
Ratio 31.58 %

Code Coverage

Tests 25
CRAP Score 8

Importance

Changes 0
Metric Value
dl 12
loc 38
ccs 25
cts 25
cp 1
rs 5.3846
c 0
b 0
f 0
cc 8
eloc 25
nc 5
nop 1
crap 8
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.1.1
11
 */
12
13
namespace PrivateBin\Data;
14
15
use PrivateBin\Model\Paste;
16
use PrivateBin\Persistence\DataStore;
17
18
/**
19
 * Filesystem
20
 *
21
 * Model for filesystem data access, implemented as a singleton.
22
 */
23
class Filesystem extends AbstractData
24
{
25
    /**
26
     * get instance of singleton
27
     *
28
     * @access public
29
     * @static
30
     * @param  array $options
31
     * @return Filesystem
32
     */
33 60
    public static function getInstance($options = null)
34
    {
35
        // if needed initialize the singleton
36 60
        if (!(self::$_instance instanceof self)) {
37 55
            self::$_instance = new self;
38
        }
39
        // if given update the data directory
40
        if (
41 60
            is_array($options) &&
42 60
            array_key_exists('dir', $options)
43
        ) {
44 60
            DataStore::setPath($options['dir']);
45
        }
46 60
        return self::$_instance;
47
    }
48
49
    /**
50
     * Create a paste.
51
     *
52
     * @access public
53
     * @param  string $pasteid
54
     * @param  array  $paste
55
     * @return bool
56
     */
57 45
    public function create($pasteid, $paste)
58
    {
59 45
        $storagedir = self::_dataid2path($pasteid);
60 45
        $file       = $storagedir . $pasteid . '.php';
61 45
        if (is_file($file)) {
62 2
            return false;
63
        }
64 45
        if (!is_dir($storagedir)) {
65 45
            mkdir($storagedir, 0700, true);
66
        }
67 45
        return DataStore::store($file, $paste);
68
    }
69
70
    /**
71
     * Read a paste.
72
     *
73
     * @access public
74
     * @param  string $pasteid
75
     * @return stdClass|false
76
     */
77 33
    public function read($pasteid)
78
    {
79 33
        if (!$this->exists($pasteid)) {
80 1
            return false;
81
        }
82 33
        $paste = DataStore::get(self::_dataid2path($pasteid) . $pasteid . '.php');
83 33
        if (property_exists($paste->meta, 'attachment')) {
84 3
            $paste->attachment = $paste->meta->attachment;
85 3
            unset($paste->meta->attachment);
86 3
            if (property_exists($paste->meta, 'attachmentname')) {
87 3
                $paste->attachmentname = $paste->meta->attachmentname;
88 3
                unset($paste->meta->attachmentname);
89
            }
90
        }
91 33
        return $paste;
0 ignored issues
show
Bug Best Practice introduced by El RIDO
The return type of return $paste; (array) is incompatible with the return type declared by the abstract method PrivateBin\Data\AbstractData::read of type stdClass|false.

If you return a value from a function or method, it should be a sub-type of the type that is given by the parent type f.e. an interface, or abstract method. This is more formally defined by the Lizkov substitution principle, and guarantees that classes that depend on the parent type can use any instance of a child type interchangably. This principle also belongs to the SOLID principles for object oriented design.

Let’s take a look at an example:

class Author {
    private $name;

    public function __construct($name) {
        $this->name = $name;
    }

    public function getName() {
        return $this->name;
    }
}

abstract class Post {
    public function getAuthor() {
        return 'Johannes';
    }
}

class BlogPost extends Post {
    public function getAuthor() {
        return new Author('Johannes');
    }
}

class ForumPost extends Post { /* ... */ }

function my_function(Post $post) {
    echo strtoupper($post->getAuthor());
}

Our function my_function expects a Post object, and outputs the author of the post. The base class Post returns a simple string and outputting a simple string will work just fine. However, the child class BlogPost which is a sub-type of Post instead decided to return an object, and is therefore violating the SOLID principles. If a BlogPost were passed to my_function, PHP would not complain, but ultimately fail when executing the strtoupper call in its body.

Loading history...
92
    }
93
94
    /**
95
     * Delete a paste and its discussion.
96
     *
97
     * @access public
98
     * @param  string $pasteid
99
     */
100 14
    public function delete($pasteid)
101
    {
102 14
        $pastedir = self::_dataid2path($pasteid);
103 14
        if (is_dir($pastedir)) {
104
            // Delete the paste itself.
105 11
            if (is_file($pastedir . $pasteid . '.php')) {
106 11
                unlink($pastedir . $pasteid . '.php');
107
            }
108
109
            // Delete discussion if it exists.
110 11
            $discdir = self::_dataid2discussionpath($pasteid);
111 11
            if (is_dir($discdir)) {
112
                // Delete all files in discussion directory
113 1
                $dir = dir($discdir);
114 1
                while (false !== ($filename = $dir->read())) {
115 1
                    if (is_file($discdir . $filename)) {
116 1
                        unlink($discdir . $filename);
117
                    }
118
                }
119 1
                $dir->close();
120 1
                rmdir($discdir);
121
            }
122
        }
123 14
    }
124
125
    /**
126
     * Test if a paste exists.
127
     *
128
     * @access public
129
     * @param  string $pasteid
130
     * @return bool
131
     */
132 60
    public function exists($pasteid)
133
    {
134 60
        $basePath  = self::_dataid2path($pasteid) . $pasteid;
135 60
        $pastePath = $basePath . '.php';
136
        // convert to PHP protected files if needed
137 60
        if (is_readable($basePath)) {
138 1
            $context = stream_context_create();
139
            // don't overwrite already converted file
140 1 View Code Duplication
            if (!is_file($pastePath)) {
0 ignored issues
show
Duplication introduced by El RIDO
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
141 1
                $handle = fopen($basePath, 'r', false, $context);
142 1
                file_put_contents($pastePath, DataStore::PROTECTION_LINE . PHP_EOL);
143 1
                file_put_contents($pastePath, $handle, FILE_APPEND);
144 1
                fclose($handle);
145
            }
146 1
            unlink($basePath);
147
148
            // convert comments, too
149 1
            $discdir  = self::_dataid2discussionpath($pasteid);
150 1
            if (is_dir($discdir)) {
151 1
                $dir = dir($discdir);
152 1
                while (false !== ($filename = $dir->read())) {
153 1
                    if (substr($filename, -4) !== '.php' && strlen($filename) >= 16) {
154 1
                        $commentFilename = $discdir . $filename . '.php';
155
                        // don't overwrite already converted file
156 1 View Code Duplication
                        if (!is_file($commentFilename)) {
0 ignored issues
show
Duplication introduced by El RIDO
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
157 1
                            $handle = fopen($discdir . $filename, 'r', false, $context);
158 1
                            file_put_contents($commentFilename, DataStore::PROTECTION_LINE . PHP_EOL);
159 1
                            file_put_contents($commentFilename, $handle, FILE_APPEND);
160 1
                            fclose($handle);
161
                        }
162 1
                        unlink($discdir . $filename);
163
                    }
164
                }
165 1
                $dir->close();
166
            }
167
        }
168 60
        return is_readable($pastePath);
169
    }
170
171
    /**
172
     * Create a comment in a paste.
173
     *
174
     * @access public
175
     * @param  string $pasteid
176
     * @param  string $parentid
177
     * @param  string $commentid
178
     * @param  array  $comment
179
     * @return bool
180
     */
181 4
    public function createComment($pasteid, $parentid, $commentid, $comment)
182
    {
183 4
        $storagedir = self::_dataid2discussionpath($pasteid);
184 4
        $file       = $storagedir . $pasteid . '.' . $commentid . '.' . $parentid . '.php';
185 4
        if (is_file($file)) {
186 1
            return false;
187
        }
188 4
        if (!is_dir($storagedir)) {
189 4
            mkdir($storagedir, 0700, true);
190
        }
191 4
        return DataStore::store($file, $comment);
192
    }
193
194
    /**
195
     * Read all comments of paste.
196
     *
197
     * @access public
198
     * @param  string $pasteid
199
     * @return array
200
     */
201 19
    public function readComments($pasteid)
202
    {
203 19
        $comments = array();
204 19
        $discdir  = self::_dataid2discussionpath($pasteid);
205 19
        if (is_dir($discdir)) {
206 3
            $dir = dir($discdir);
207 3
            while (false !== ($filename = $dir->read())) {
208
                // Filename is in the form pasteid.commentid.parentid.php:
209
                // - pasteid is the paste this reply belongs to.
210
                // - commentid is the comment identifier itself.
211
                // - parentid is the comment this comment replies to (It can be pasteid)
212 3
                if (is_file($discdir . $filename)) {
213 3
                    $comment = DataStore::get($discdir . $filename);
214 3
                    $items   = explode('.', $filename);
215
                    // Add some meta information not contained in file.
216 3
                    $comment->id       = $items[1];
217 3
                    $comment->parentid = $items[2];
218
219
                    // Store in array
220 3
                    $key            = $this->getOpenSlot($comments, (int) $comment->meta->postdate);
221 3
                    $comments[$key] = $comment;
222
                }
223
            }
224 3
            $dir->close();
225
226
            // Sort comments by date, oldest first.
227 3
            ksort($comments);
228
        }
229 19
        return $comments;
230
    }
231
232
    /**
233
     * Test if a comment exists.
234
     *
235
     * @access public
236
     * @param  string $pasteid
237
     * @param  string $parentid
238
     * @param  string $commentid
239
     * @return bool
240
     */
241 8
    public function existsComment($pasteid, $parentid, $commentid)
242
    {
243 8
        return is_file(
244 8
            self::_dataid2discussionpath($pasteid) .
245 8
            $pasteid . '.' . $commentid . '.' . $parentid . '.php'
246
        );
247
    }
248
249
    /**
250
     * Returns up to batch size number of paste ids that have expired
251
     *
252
     * @access private
253
     * @param  int $batchsize
254
     * @return array
255
     */
256 2
    protected function _getExpiredPastes($batchsize)
257
    {
258 2
        $pastes     = array();
259 2
        $mainpath   = DataStore::getPath();
260 2
        $firstLevel = array_filter(
261 2
            scandir($mainpath),
262 2
            'self::_isFirstLevelDir'
263
        );
264 2
        if (count($firstLevel) > 0) {
265
            // try at most 10 times the $batchsize pastes before giving up
266 2
            for ($i = 0, $max = $batchsize * 10; $i < $max; ++$i) {
267 2
                $firstKey    = array_rand($firstLevel);
268 2
                $secondLevel = array_filter(
269 2
                    scandir($mainpath . DIRECTORY_SEPARATOR . $firstLevel[$firstKey]),
270 2
                    'self::_isSecondLevelDir'
271
                );
272
273
                // skip this folder in the next checks if it is empty
274 2
                if (count($secondLevel) == 0) {
275 1
                    unset($firstLevel[$firstKey]);
276 1
                    continue;
277
                }
278
279 2
                $secondKey = array_rand($secondLevel);
280 2
                $path      = $mainpath . DIRECTORY_SEPARATOR .
281 2
                    $firstLevel[$firstKey] . DIRECTORY_SEPARATOR .
282 2
                    $secondLevel[$secondKey];
283 2
                if (!is_dir($path)) {
284
                    continue;
285
                }
286 2
                $thirdLevel = array_filter(
287 2
                    array_map(
288 2
                        function ($filename) {
289 2
                            return strlen($filename) >= 20 ?
290 2
                                substr($filename, 0, -4) :
291 2
                                $filename;
292 2
                        },
293 2
                        scandir($path)
294
                    ),
295 2
                    'PrivateBin\\Model\\Paste::isValidId'
296
                );
297 2
                if (count($thirdLevel) == 0) {
298
                    continue;
299
                }
300 2
                $thirdKey = array_rand($thirdLevel);
301 2
                $pasteid  = $thirdLevel[$thirdKey];
302 2
                if (in_array($pasteid, $pastes)) {
303 1
                    continue;
304
                }
305
306 2
                if ($this->exists($pasteid)) {
307 2
                    $data = $this->read($pasteid);
308
                    if (
309 2
                        property_exists($data->meta, 'expire_date') &&
310 2
                        $data->meta->expire_date < time()
311
                    ) {
312 1
                        $pastes[] = $pasteid;
313 1
                        if (count($pastes) >= $batchsize) {
314
                            break;
315
                        }
316
                    }
317
                }
318
            }
319
        }
320 2
        return $pastes;
321
    }
322
323
    /**
324
     * Convert paste id to storage path.
325
     *
326
     * The idea is to creates subdirectories in order to limit the number of files per directory.
327
     * (A high number of files in a single directory can slow things down.)
328
     * eg. "f468483c313401e8" will be stored in "data/f4/68/f468483c313401e8"
329
     * High-trafic websites may want to deepen the directory structure (like Squid does).
330
     *
331
     * eg. input 'e3570978f9e4aa90' --> output 'data/e3/57/'
332
     *
333
     * @access private
334
     * @static
335
     * @param  string $dataid
336
     * @return string
337
     */
338 60
    private static function _dataid2path($dataid)
339
    {
340 60
        return DataStore::getPath(
341 60
            substr($dataid, 0, 2) . DIRECTORY_SEPARATOR .
342 60
            substr($dataid, 2, 2) . DIRECTORY_SEPARATOR
343
        );
344
    }
345
346
    /**
347
     * Convert paste id to discussion storage path.
348
     *
349
     * eg. input 'e3570978f9e4aa90' --> output 'data/e3/57/e3570978f9e4aa90.discussion/'
350
     *
351
     * @access private
352
     * @static
353
     * @param  string $dataid
354
     * @return string
355
     */
356 26
    private static function _dataid2discussionpath($dataid)
357
    {
358 26
        return self::_dataid2path($dataid) . $dataid .
359 26
            '.discussion' . DIRECTORY_SEPARATOR;
360
    }
361
362
    /**
363
     * Check that the given element is a valid first level directory.
364
     *
365
     * @access private
366
     * @static
367
     * @param  string $element
368
     * @return bool
369
     */
370 2
    private static function _isFirstLevelDir($element)
0 ignored issues
show
Unused Code introduced by El RIDO
This method is not used, and could be removed.
Loading history...
371
    {
372 2
        return self::_isSecondLevelDir($element) &&
373 2
            is_dir(DataStore::getPath($element));
374
    }
375
376
    /**
377
     * Check that the given element is a valid second level directory.
378
     *
379
     * @access private
380
     * @static
381
     * @param  string $element
382
     * @return bool
383
     */
384 2
    private static function _isSecondLevelDir($element)
385
    {
386 2
        return (bool) preg_match('/^[a-f0-9]{2}$/', $element);
387
    }
388
}
389