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