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 | |||
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
|
|||
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 |
If you suppress an error, we recommend checking for the error condition explicitly: