Passed
Push — master ( 81ac23...a5d5f6 )
by El
03:02
created

Filesystem::exists()   B

Complexity

Conditions 6
Paths 3

Size

Total Lines 23
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 14
CRAP Score 6

Importance

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