Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
95.38% |
227 / 238 |
|
75.00% |
9 / 12 |
CRAP | |
0.00% |
0 / 1 |
Controller | |
95.38% |
227 / 238 |
|
75.00% |
9 / 12 |
55 | |
0.00% |
0 / 1 |
__construct | |
89.19% |
33 / 37 |
|
0.00% |
0 / 1 |
10.13 | |||
_init | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
_setDefaultLanguage | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
_setDefaultTemplate | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
_setCacheHeaders | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
_create | |
89.80% |
44 / 49 |
|
0.00% |
0 / 1 |
12.15 | |||
_delete | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
6 | |||
_read | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
5 | |||
_view | |
96.97% |
64 / 66 |
|
0.00% |
0 / 1 |
6 | |||
_jsonld | |
100.00% |
26 / 26 |
|
100.00% |
1 / 1 |
4 | |||
_yourlsproxy | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
_return_message | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 |
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; |
13 | |
14 | use Exception; |
15 | use PrivateBin\Persistence\ServerSalt; |
16 | use PrivateBin\Persistence\TrafficLimiter; |
17 | |
18 | /** |
19 | * Controller |
20 | * |
21 | * Puts it all together. |
22 | */ |
23 | class Controller |
24 | { |
25 | /** |
26 | * version |
27 | * |
28 | * @const string |
29 | */ |
30 | const VERSION = '2.0.0'; |
31 | |
32 | /** |
33 | * minimal required PHP version |
34 | * |
35 | * @const string |
36 | */ |
37 | const MIN_PHP_VERSION = '7.4.0'; |
38 | |
39 | /** |
40 | * show the same error message if the document expired or does not exist |
41 | * |
42 | * @const string |
43 | */ |
44 | const GENERIC_ERROR = 'Document does not exist, has expired or has been deleted.'; |
45 | |
46 | /** |
47 | * configuration |
48 | * |
49 | * @access private |
50 | * @var Configuration |
51 | */ |
52 | private $_conf; |
53 | |
54 | /** |
55 | * error message |
56 | * |
57 | * @access private |
58 | * @var string |
59 | */ |
60 | private $_error = ''; |
61 | |
62 | /** |
63 | * status message |
64 | * |
65 | * @access private |
66 | * @var string |
67 | */ |
68 | private $_status = ''; |
69 | |
70 | /** |
71 | * status message |
72 | * |
73 | * @access private |
74 | * @var bool |
75 | */ |
76 | private $_is_deleted = false; |
77 | |
78 | /** |
79 | * JSON message |
80 | * |
81 | * @access private |
82 | * @var string |
83 | */ |
84 | private $_json = ''; |
85 | |
86 | /** |
87 | * Factory of instance models |
88 | * |
89 | * @access private |
90 | * @var model |
91 | */ |
92 | private $_model; |
93 | |
94 | /** |
95 | * request |
96 | * |
97 | * @access private |
98 | * @var request |
99 | */ |
100 | private $_request; |
101 | |
102 | /** |
103 | * URL base |
104 | * |
105 | * @access private |
106 | * @var string |
107 | */ |
108 | private $_urlBase; |
109 | |
110 | /** |
111 | * constructor |
112 | * |
113 | * initializes and runs PrivateBin |
114 | * |
115 | * @param ?Configuration $config |
116 | * |
117 | * @access public |
118 | * @throws Exception |
119 | */ |
120 | public function __construct(?Configuration $config = null) |
121 | { |
122 | if (version_compare(PHP_VERSION, self::MIN_PHP_VERSION) < 0) { |
123 | error_log(I18n::_('%s requires php %s or above to work. Sorry.', I18n::_('PrivateBin'), self::MIN_PHP_VERSION)); |
124 | return; |
125 | } |
126 | if (strlen(PATH) < 0 && substr(PATH, -1) !== DIRECTORY_SEPARATOR) { |
127 | error_log(I18n::_('%s requires the PATH to end in a "%s". Please update the PATH in your index.php.', I18n::_('PrivateBin'), DIRECTORY_SEPARATOR)); |
128 | return; |
129 | } |
130 | |
131 | // load config (using ini file by default) & initialize required classes |
132 | $this->_conf = $config ?? new Configuration(); |
133 | $this->_init(); |
134 | |
135 | switch ($this->_request->getOperation()) { |
136 | case 'create': |
137 | $this->_create(); |
138 | break; |
139 | case 'delete': |
140 | $this->_delete( |
141 | $this->_request->getParam('pasteid'), |
142 | $this->_request->getParam('deletetoken') |
143 | ); |
144 | break; |
145 | case 'read': |
146 | $this->_read($this->_request->getParam('pasteid')); |
147 | break; |
148 | case 'jsonld': |
149 | $this->_jsonld($this->_request->getParam('jsonld')); |
150 | return; |
151 | case 'yourlsproxy': |
152 | $this->_yourlsproxy($this->_request->getParam('link')); |
153 | break; |
154 | } |
155 | |
156 | $this->_setCacheHeaders(); |
157 | |
158 | // output JSON or HTML |
159 | if ($this->_request->isJsonApiCall()) { |
160 | header('Content-type: ' . Request::MIME_JSON); |
161 | header('Access-Control-Allow-Origin: *'); |
162 | header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE'); |
163 | header('Access-Control-Allow-Headers: X-Requested-With, Content-Type'); |
164 | header('X-Uncompressed-Content-Length: ' . strlen($this->_json)); |
165 | header('Access-Control-Expose-Headers: X-Uncompressed-Content-Length'); |
166 | echo $this->_json; |
167 | } else { |
168 | $this->_view(); |
169 | } |
170 | } |
171 | |
172 | /** |
173 | * initialize PrivateBin |
174 | * |
175 | * @access private |
176 | * @throws Exception |
177 | */ |
178 | private function _init() |
179 | { |
180 | $this->_model = new Model($this->_conf); |
181 | $this->_request = new Request; |
182 | $this->_urlBase = $this->_request->getRequestUri(); |
183 | |
184 | $this->_setDefaultLanguage(); |
185 | $this->_setDefaultTemplate(); |
186 | } |
187 | |
188 | /** |
189 | * Set default language |
190 | * |
191 | * @access private |
192 | */ |
193 | private function _setDefaultLanguage() |
194 | { |
195 | $lang = $this->_conf->getKey('languagedefault'); |
196 | I18n::setLanguageFallback($lang); |
197 | // force default language, if language selection is disabled and a default is set |
198 | if (!$this->_conf->getKey('languageselection') && strlen($lang) == 2) { |
199 | $_COOKIE['lang'] = $lang; |
200 | setcookie('lang', $lang, array('SameSite' => 'Lax', 'Secure' => true)); |
201 | } |
202 | } |
203 | |
204 | /** |
205 | * Set default template |
206 | * |
207 | * @access private |
208 | */ |
209 | private function _setDefaultTemplate() |
210 | { |
211 | $templates = $this->_conf->getKey('availabletemplates'); |
212 | $template = $this->_conf->getKey('template'); |
213 | TemplateSwitcher::setAvailableTemplates($templates); |
214 | TemplateSwitcher::setTemplateFallback($template); |
215 | |
216 | // force default template, if template selection is disabled and a default is set |
217 | if (!$this->_conf->getKey('templateselection') && !empty($template)) { |
218 | $_COOKIE['template'] = $template; |
219 | setcookie('template', $template, array('SameSite' => 'Lax', 'Secure' => true)); |
220 | } |
221 | } |
222 | |
223 | /** |
224 | * Turn off browser caching |
225 | * |
226 | * @access private |
227 | */ |
228 | private function _setCacheHeaders() |
229 | { |
230 | // set headers to disable caching |
231 | $time = gmdate('D, d M Y H:i:s \G\M\T'); |
232 | header('Cache-Control: no-store, no-cache, no-transform, must-revalidate'); |
233 | header('Pragma: no-cache'); |
234 | header('Expires: ' . $time); |
235 | header('Last-Modified: ' . $time); |
236 | header('Vary: Accept'); |
237 | } |
238 | |
239 | /** |
240 | * Store new paste or comment |
241 | * |
242 | * POST contains: |
243 | * JSON encoded object with mandatory keys: |
244 | * v = 2 (version) |
245 | * adata (array) |
246 | * ct (base64 encoded, encrypted text) |
247 | * meta (optional): |
248 | * expire = expiration delay (never,5min,10min,1hour,1day,1week,1month,1year,burn) (default:1week) |
249 | * parentid (optional) = in discussions, which comment this comment replies to. |
250 | * pasteid (optional) = in discussions, which paste this comment belongs to. |
251 | * |
252 | * @access private |
253 | * @return string |
254 | */ |
255 | private function _create() |
256 | { |
257 | // Ensure last paste from visitors IP address was more than configured amount of seconds ago. |
258 | ServerSalt::setStore($this->_model->getStore()); |
259 | TrafficLimiter::setConfiguration($this->_conf); |
260 | TrafficLimiter::setStore($this->_model->getStore()); |
261 | try { |
262 | TrafficLimiter::canPass(); |
263 | } catch (Exception $e) { |
264 | $this->_return_message(1, $e->getMessage()); |
265 | return; |
266 | } |
267 | |
268 | $data = $this->_request->getData(); |
269 | $isComment = array_key_exists('pasteid', $data) && |
270 | !empty($data['pasteid']) && |
271 | array_key_exists('parentid', $data) && |
272 | !empty($data['parentid']); |
273 | if (!FormatV2::isValid($data, $isComment)) { |
274 | $this->_return_message(1, I18n::_('Invalid data.')); |
275 | return; |
276 | } |
277 | $sizelimit = $this->_conf->getKey('sizelimit'); |
278 | // Ensure content is not too big. |
279 | if (strlen($data['ct']) > $sizelimit) { |
280 | $this->_return_message( |
281 | 1, |
282 | I18n::_( |
283 | 'Document is limited to %s of encrypted data.', |
284 | Filter::formatHumanReadableSize($sizelimit) |
285 | ) |
286 | ); |
287 | return; |
288 | } |
289 | |
290 | // The user posts a comment. |
291 | if ($isComment) { |
292 | $paste = $this->_model->getPaste($data['pasteid']); |
293 | if ($paste->exists()) { |
294 | try { |
295 | $comment = $paste->getComment($data['parentid']); |
296 | $comment->setData($data); |
297 | $comment->store(); |
298 | } catch (Exception $e) { |
299 | $this->_return_message(1, $e->getMessage()); |
300 | return; |
301 | } |
302 | $this->_return_message(0, $comment->getId()); |
303 | } else { |
304 | $this->_return_message(1, I18n::_('Invalid data.')); |
305 | } |
306 | } |
307 | // The user posts a standard paste. |
308 | else { |
309 | try { |
310 | $this->_model->purge(); |
311 | } catch (Exception $e) { |
312 | error_log('Error purging documents: ' . $e->getMessage() . PHP_EOL . |
313 | 'Use the administration scripts statistics to find ' . |
314 | 'damaged paste IDs and either delete them or restore them ' . |
315 | 'from backup.'); |
316 | } |
317 | $paste = $this->_model->getPaste(); |
318 | try { |
319 | $paste->setData($data); |
320 | $paste->store(); |
321 | } catch (Exception $e) { |
322 | $this->_return_message(1, $e->getMessage()); |
323 | return; |
324 | } |
325 | $this->_return_message(0, $paste->getId(), array('deletetoken' => $paste->getDeleteToken())); |
326 | } |
327 | } |
328 | |
329 | /** |
330 | * Delete an existing document |
331 | * |
332 | * @access private |
333 | * @param string $dataid |
334 | * @param string $deletetoken |
335 | */ |
336 | private function _delete($dataid, $deletetoken) |
337 | { |
338 | try { |
339 | $paste = $this->_model->getPaste($dataid); |
340 | if ($paste->exists()) { |
341 | // accessing this method ensures that the document would be |
342 | // deleted if it has already expired |
343 | $paste->get(); |
344 | if (hash_equals($paste->getDeleteToken(), $deletetoken)) { |
345 | // Document exists and deletion token is valid: Delete the it. |
346 | $paste->delete(); |
347 | $this->_status = 'Document was properly deleted.'; |
348 | $this->_is_deleted = true; |
349 | } else { |
350 | $this->_error = 'Wrong deletion token. Document was not deleted.'; |
351 | } |
352 | } else { |
353 | $this->_error = self::GENERIC_ERROR; |
354 | } |
355 | } catch (Exception $e) { |
356 | $this->_error = $e->getMessage(); |
357 | } |
358 | if ($this->_request->isJsonApiCall()) { |
359 | if (empty($this->_error)) { |
360 | $this->_return_message(0, $dataid); |
361 | } else { |
362 | $this->_return_message(1, $this->_error); |
363 | } |
364 | } |
365 | } |
366 | |
367 | /** |
368 | * Read an existing document, only allowed via a JSON API call |
369 | * |
370 | * @access private |
371 | * @param string $dataid |
372 | */ |
373 | private function _read($dataid) |
374 | { |
375 | if (!$this->_request->isJsonApiCall()) { |
376 | return; |
377 | } |
378 | |
379 | try { |
380 | $paste = $this->_model->getPaste($dataid); |
381 | if ($paste->exists()) { |
382 | $data = $paste->get(); |
383 | if (array_key_exists('salt', $data['meta'])) { |
384 | unset($data['meta']['salt']); |
385 | } |
386 | $this->_return_message(0, $dataid, (array) $data); |
387 | } else { |
388 | $this->_return_message(1, self::GENERIC_ERROR); |
389 | } |
390 | } catch (Exception $e) { |
391 | $this->_return_message(1, $e->getMessage()); |
392 | } |
393 | } |
394 | |
395 | /** |
396 | * Display frontend. |
397 | * |
398 | * @access private |
399 | */ |
400 | private function _view() |
401 | { |
402 | header('Content-Security-Policy: ' . $this->_conf->getKey('cspheader')); |
403 | header('Cross-Origin-Resource-Policy: same-origin'); |
404 | header('Cross-Origin-Embedder-Policy: require-corp'); |
405 | // disabled, because it prevents links from a document to the same site to |
406 | // be opened. Didn't work with `same-origin-allow-popups` either. |
407 | // See issue https://github.com/PrivateBin/PrivateBin/issues/970 for details. |
408 | // header('Cross-Origin-Opener-Policy: same-origin'); |
409 | header('Permissions-Policy: browsing-topics=()'); |
410 | header('Referrer-Policy: no-referrer'); |
411 | header('X-Content-Type-Options: nosniff'); |
412 | header('X-Frame-Options: deny'); |
413 | header('X-XSS-Protection: 1; mode=block'); |
414 | |
415 | // label all the expiration options |
416 | $expire = array(); |
417 | foreach ($this->_conf->getSection('expire_options') as $time => $seconds) { |
418 | $expire[$time] = ($seconds == 0) ? I18n::_(ucfirst($time)) : Filter::formatHumanReadableTime($time); |
419 | } |
420 | |
421 | // translate all the formatter options |
422 | $formatters = array_map('PrivateBin\\I18n::_', $this->_conf->getSection('formatter_options')); |
423 | |
424 | // set language cookie if that functionality was enabled |
425 | $languageselection = ''; |
426 | if ($this->_conf->getKey('languageselection')) { |
427 | $languageselection = I18n::getLanguage(); |
428 | setcookie('lang', $languageselection, array('SameSite' => 'Lax', 'Secure' => true)); |
429 | } |
430 | |
431 | // set template cookie if that functionality was enabled |
432 | $templateselection = ''; |
433 | if ($this->_conf->getKey('templateselection')) { |
434 | $templateselection = TemplateSwitcher::getTemplate(); |
435 | setcookie('template', $templateselection, array('SameSite' => 'Lax', 'Secure' => true)); |
436 | } |
437 | |
438 | // strip policies that are unsupported in meta tag |
439 | $metacspheader = str_replace( |
440 | array( |
441 | 'frame-ancestors \'none\'; ', |
442 | '; sandbox allow-same-origin allow-scripts allow-forms allow-modals allow-downloads', |
443 | ), |
444 | '', |
445 | $this->_conf->getKey('cspheader') |
446 | ); |
447 | |
448 | $page = new View; |
449 | $page->assign('CSPHEADER', $metacspheader); |
450 | $page->assign('ERROR', I18n::_($this->_error)); |
451 | $page->assign('NAME', $this->_conf->getKey('name')); |
452 | if ($this->_request->getOperation() === 'yourlsproxy') { |
453 | $page->assign('SHORTURL', $this->_status); |
454 | $page->draw('yourlsproxy'); |
455 | return; |
456 | } |
457 | $page->assign('BASEPATH', I18n::_($this->_conf->getKey('basepath'))); |
458 | $page->assign('STATUS', I18n::_($this->_status)); |
459 | $page->assign('ISDELETED', I18n::_(json_encode($this->_is_deleted))); |
460 | $page->assign('VERSION', self::VERSION); |
461 | $page->assign('DISCUSSION', $this->_conf->getKey('discussion')); |
462 | $page->assign('OPENDISCUSSION', $this->_conf->getKey('opendiscussion')); |
463 | $page->assign('MARKDOWN', array_key_exists('markdown', $formatters)); |
464 | $page->assign('SYNTAXHIGHLIGHTING', array_key_exists('syntaxhighlighting', $formatters)); |
465 | $page->assign('SYNTAXHIGHLIGHTINGTHEME', $this->_conf->getKey('syntaxhighlightingtheme')); |
466 | $page->assign('FORMATTER', $formatters); |
467 | $page->assign('FORMATTERDEFAULT', $this->_conf->getKey('defaultformatter')); |
468 | $page->assign('INFO', I18n::_(str_replace("'", '"', $this->_conf->getKey('info')))); |
469 | $page->assign('NOTICE', I18n::_($this->_conf->getKey('notice'))); |
470 | $page->assign('BURNAFTERREADINGSELECTED', $this->_conf->getKey('burnafterreadingselected')); |
471 | $page->assign('PASSWORD', $this->_conf->getKey('password')); |
472 | $page->assign('FILEUPLOAD', $this->_conf->getKey('fileupload')); |
473 | $page->assign('LANGUAGESELECTION', $languageselection); |
474 | $page->assign('LANGUAGES', I18n::getLanguageLabels(I18n::getAvailableLanguages())); |
475 | $page->assign('TEMPLATESELECTION', $templateselection); |
476 | $page->assign('TEMPLATES', TemplateSwitcher::getAvailableTemplates()); |
477 | $page->assign('EXPIRE', $expire); |
478 | $page->assign('EXPIREDEFAULT', $this->_conf->getKey('default', 'expire')); |
479 | $page->assign('URLSHORTENER', $this->_conf->getKey('urlshortener')); |
480 | $page->assign('QRCODE', $this->_conf->getKey('qrcode')); |
481 | $page->assign('EMAIL', $this->_conf->getKey('email')); |
482 | $page->assign('HTTPWARNING', $this->_conf->getKey('httpwarning')); |
483 | $page->assign('HTTPSLINK', 'https://' . $this->_request->getHost() . $this->_request->getRequestUri()); |
484 | $page->assign('COMPRESSION', $this->_conf->getKey('compression')); |
485 | $page->assign('SRI', $this->_conf->getSection('sri')); |
486 | $page->draw(TemplateSwitcher::getTemplate()); |
487 | } |
488 | |
489 | /** |
490 | * outputs requested JSON-LD context |
491 | * |
492 | * @access private |
493 | * @param string $type |
494 | */ |
495 | private function _jsonld($type) |
496 | { |
497 | if (!in_array($type, array( |
498 | 'comment', |
499 | 'commentmeta', |
500 | 'paste', |
501 | 'pastemeta', |
502 | 'types', |
503 | ))) { |
504 | $type = ''; |
505 | } |
506 | $content = '{}'; |
507 | $file = PUBLIC_PATH . DIRECTORY_SEPARATOR . 'js' . DIRECTORY_SEPARATOR . $type . '.jsonld'; |
508 | if (is_readable($file)) { |
509 | $content = str_replace( |
510 | '?jsonld=', |
511 | $this->_urlBase . '?jsonld=', |
512 | file_get_contents($file) |
513 | ); |
514 | } |
515 | if ($type === 'types') { |
516 | $content = str_replace( |
517 | implode('", "', array_keys($this->_conf->getDefaults()['expire_options'])), |
518 | implode('", "', array_keys($this->_conf->getSection('expire_options'))), |
519 | $content |
520 | ); |
521 | } |
522 | |
523 | header('Content-type: application/ld+json'); |
524 | header('Access-Control-Allow-Origin: *'); |
525 | header('Access-Control-Allow-Methods: GET'); |
526 | echo $content; |
527 | } |
528 | |
529 | /** |
530 | * proxies link to YOURLS, updates status or error with response |
531 | * |
532 | * @access private |
533 | * @param string $link |
534 | */ |
535 | private function _yourlsproxy($link) |
536 | { |
537 | $yourls = new YourlsProxy($this->_conf, $link); |
538 | if ($yourls->isError()) { |
539 | $this->_error = $yourls->getError(); |
540 | } else { |
541 | $this->_status = $yourls->getUrl(); |
542 | } |
543 | } |
544 | |
545 | /** |
546 | * prepares JSON encoded status message |
547 | * |
548 | * @access private |
549 | * @param int $status |
550 | * @param string $message |
551 | * @param array $other |
552 | */ |
553 | private function _return_message($status, $message, $other = array()) |
554 | { |
555 | $result = array('status' => $status); |
556 | if ($status) { |
557 | $result['message'] = I18n::_($message); |
558 | } else { |
559 | $result['id'] = $message; |
560 | $result['url'] = $this->_urlBase . '?' . $message; |
561 | } |
562 | $result += $other; |
563 | $this->_json = Json::encode($result); |
564 | } |
565 | } |