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.58% |
43 / 48 |
|
0.00% |
0 / 1 |
12.16 | |||
_delete | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
6 | |||
_read | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
5 | |||
_view | |
97.01% |
65 / 67 |
|
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 = '1.7.7'; |
31 | |
32 | /** |
33 | * minimal required PHP version |
34 | * |
35 | * @const string |
36 | */ |
37 | const MIN_PHP_VERSION = '7.3.0'; |
38 | |
39 | /** |
40 | * show the same error message if the paste expired or does not exist |
41 | * |
42 | * @const string |
43 | */ |
44 | const GENERIC_ERROR = 'Paste 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 one or both: |
243 | * data = json encoded FormatV2 encrypted text (containing keys: iv,v,iter,ks,ts,mode,adata,cipher,salt,ct) |
244 | * attachment = json encoded FormatV2 encrypted text (containing keys: iv,v,iter,ks,ts,mode,adata,cipher,salt,ct) |
245 | * |
246 | * All optional data will go to meta information: |
247 | * expire (optional) = expiration delay (never,5min,10min,1hour,1day,1week,1month,1year,burn) (default:never) |
248 | * formatter (optional) = format to display the paste as (plaintext,syntaxhighlighting,markdown) (default:syntaxhighlighting) |
249 | * burnafterreading (optional) = if this paste may only viewed once ? (0/1) (default:0) |
250 | * opendiscusssion (optional) = is the discussion allowed on this paste ? (0/1) (default:0) |
251 | * attachmentname = json encoded FormatV2 encrypted text (containing keys: iv,v,iter,ks,ts,mode,adata,cipher,salt,ct) |
252 | * nickname (optional) = in discussion, encoded FormatV2 encrypted text nickname of author of comment (containing keys: iv,v,iter,ks,ts,mode,adata,cipher,salt,ct) |
253 | * parentid (optional) = in discussion, which comment this comment replies to. |
254 | * pasteid (optional) = in discussion, which paste this comment belongs to. |
255 | * |
256 | * @access private |
257 | * @return string |
258 | */ |
259 | private function _create() |
260 | { |
261 | // Ensure last paste from visitors IP address was more than configured amount of seconds ago. |
262 | ServerSalt::setStore($this->_model->getStore()); |
263 | TrafficLimiter::setConfiguration($this->_conf); |
264 | TrafficLimiter::setStore($this->_model->getStore()); |
265 | try { |
266 | TrafficLimiter::canPass(); |
267 | } catch (Exception $e) { |
268 | $this->_return_message(1, $e->getMessage()); |
269 | return; |
270 | } |
271 | |
272 | $data = $this->_request->getData(); |
273 | $isComment = array_key_exists('pasteid', $data) && |
274 | !empty($data['pasteid']) && |
275 | array_key_exists('parentid', $data) && |
276 | !empty($data['parentid']); |
277 | if (!FormatV2::isValid($data, $isComment)) { |
278 | $this->_return_message(1, I18n::_('Invalid data.')); |
279 | return; |
280 | } |
281 | $sizelimit = $this->_conf->getKey('sizelimit'); |
282 | // Ensure content is not too big. |
283 | if (strlen($data['ct']) > $sizelimit) { |
284 | $this->_return_message( |
285 | 1, |
286 | I18n::_( |
287 | 'Paste is limited to %s of encrypted data.', |
288 | Filter::formatHumanReadableSize($sizelimit) |
289 | ) |
290 | ); |
291 | return; |
292 | } |
293 | |
294 | // The user posts a comment. |
295 | if ($isComment) { |
296 | $paste = $this->_model->getPaste($data['pasteid']); |
297 | if ($paste->exists()) { |
298 | try { |
299 | $comment = $paste->getComment($data['parentid']); |
300 | $comment->setData($data); |
301 | $comment->store(); |
302 | } catch (Exception $e) { |
303 | $this->_return_message(1, $e->getMessage()); |
304 | return; |
305 | } |
306 | $this->_return_message(0, $comment->getId()); |
307 | } else { |
308 | $this->_return_message(1, I18n::_('Invalid data.')); |
309 | } |
310 | } |
311 | // The user posts a standard paste. |
312 | else { |
313 | try { |
314 | $this->_model->purge(); |
315 | } catch (Exception $e) { |
316 | error_log('Error purging pastes: ' . $e->getMessage() . PHP_EOL . |
317 | 'Use the administration scripts statistics to find ' . |
318 | 'damaged paste IDs and either delete them or restore them ' . |
319 | 'from backup.'); |
320 | } |
321 | $paste = $this->_model->getPaste(); |
322 | try { |
323 | $paste->setData($data); |
324 | $paste->store(); |
325 | } catch (Exception $e) { |
326 | return $this->_return_message(1, $e->getMessage()); |
327 | } |
328 | $this->_return_message(0, $paste->getId(), array('deletetoken' => $paste->getDeleteToken())); |
329 | } |
330 | } |
331 | |
332 | /** |
333 | * Delete an existing paste |
334 | * |
335 | * @access private |
336 | * @param string $dataid |
337 | * @param string $deletetoken |
338 | */ |
339 | private function _delete($dataid, $deletetoken) |
340 | { |
341 | try { |
342 | $paste = $this->_model->getPaste($dataid); |
343 | if ($paste->exists()) { |
344 | // accessing this method ensures that the paste would be |
345 | // deleted if it has already expired |
346 | $paste->get(); |
347 | if (hash_equals($paste->getDeleteToken(), $deletetoken)) { |
348 | // Paste exists and deletion token is valid: Delete the paste. |
349 | $paste->delete(); |
350 | $this->_status = 'Paste was properly deleted.'; |
351 | $this->_is_deleted = true; |
352 | } else { |
353 | $this->_error = 'Wrong deletion token. Paste was not deleted.'; |
354 | } |
355 | } else { |
356 | $this->_error = self::GENERIC_ERROR; |
357 | } |
358 | } catch (Exception $e) { |
359 | $this->_error = $e->getMessage(); |
360 | } |
361 | if ($this->_request->isJsonApiCall()) { |
362 | if (empty($this->_error)) { |
363 | $this->_return_message(0, $dataid); |
364 | } else { |
365 | $this->_return_message(1, $this->_error); |
366 | } |
367 | } |
368 | } |
369 | |
370 | /** |
371 | * Read an existing paste or comment, only allowed via a JSON API call |
372 | * |
373 | * @access private |
374 | * @param string $dataid |
375 | */ |
376 | private function _read($dataid) |
377 | { |
378 | if (!$this->_request->isJsonApiCall()) { |
379 | return; |
380 | } |
381 | |
382 | try { |
383 | $paste = $this->_model->getPaste($dataid); |
384 | if ($paste->exists()) { |
385 | $data = $paste->get(); |
386 | if (array_key_exists('salt', $data['meta'])) { |
387 | unset($data['meta']['salt']); |
388 | } |
389 | $this->_return_message(0, $dataid, (array) $data); |
390 | } else { |
391 | $this->_return_message(1, self::GENERIC_ERROR); |
392 | } |
393 | } catch (Exception $e) { |
394 | $this->_return_message(1, $e->getMessage()); |
395 | } |
396 | } |
397 | |
398 | /** |
399 | * Display frontend. |
400 | * |
401 | * @access private |
402 | */ |
403 | private function _view() |
404 | { |
405 | header('Content-Security-Policy: ' . $this->_conf->getKey('cspheader')); |
406 | header('Cross-Origin-Resource-Policy: same-origin'); |
407 | header('Cross-Origin-Embedder-Policy: require-corp'); |
408 | // disabled, because it prevents links from a paste to the same site to |
409 | // be opened. Didn't work with `same-origin-allow-popups` either. |
410 | // See issue https://github.com/PrivateBin/PrivateBin/issues/970 for details. |
411 | // header('Cross-Origin-Opener-Policy: same-origin'); |
412 | header('Permissions-Policy: browsing-topics=()'); |
413 | header('Referrer-Policy: no-referrer'); |
414 | header('X-Content-Type-Options: nosniff'); |
415 | header('X-Frame-Options: deny'); |
416 | header('X-XSS-Protection: 1; mode=block'); |
417 | |
418 | // label all the expiration options |
419 | $expire = array(); |
420 | foreach ($this->_conf->getSection('expire_options') as $time => $seconds) { |
421 | $expire[$time] = ($seconds == 0) ? I18n::_(ucfirst($time)) : Filter::formatHumanReadableTime($time); |
422 | } |
423 | |
424 | // translate all the formatter options |
425 | $formatters = array_map('PrivateBin\\I18n::_', $this->_conf->getSection('formatter_options')); |
426 | |
427 | // set language cookie if that functionality was enabled |
428 | $languageselection = ''; |
429 | if ($this->_conf->getKey('languageselection')) { |
430 | $languageselection = I18n::getLanguage(); |
431 | setcookie('lang', $languageselection, array('SameSite' => 'Lax', 'Secure' => true)); |
432 | } |
433 | |
434 | // set template cookie if that functionality was enabled |
435 | $templateselection = ''; |
436 | if ($this->_conf->getKey('templateselection')) { |
437 | $templateselection = TemplateSwitcher::getTemplate(); |
438 | setcookie('template', $templateselection, array('SameSite' => 'Lax', 'Secure' => true)); |
439 | } |
440 | |
441 | // strip policies that are unsupported in meta tag |
442 | $metacspheader = str_replace( |
443 | array( |
444 | 'frame-ancestors \'none\'; ', |
445 | '; sandbox allow-same-origin allow-scripts allow-forms allow-popups allow-modals allow-downloads', |
446 | ), |
447 | '', |
448 | $this->_conf->getKey('cspheader') |
449 | ); |
450 | |
451 | $page = new View; |
452 | $page->assign('CSPHEADER', $metacspheader); |
453 | $page->assign('ERROR', I18n::_($this->_error)); |
454 | $page->assign('NAME', $this->_conf->getKey('name')); |
455 | if ($this->_request->getOperation() === 'yourlsproxy') { |
456 | $page->assign('SHORTURL', $this->_status); |
457 | $page->draw('yourlsproxy'); |
458 | return; |
459 | } |
460 | $page->assign('BASEPATH', I18n::_($this->_conf->getKey('basepath'))); |
461 | $page->assign('STATUS', I18n::_($this->_status)); |
462 | $page->assign('ISDELETED', I18n::_(json_encode($this->_is_deleted))); |
463 | $page->assign('VERSION', self::VERSION); |
464 | $page->assign('DISCUSSION', $this->_conf->getKey('discussion')); |
465 | $page->assign('OPENDISCUSSION', $this->_conf->getKey('opendiscussion')); |
466 | $page->assign('MARKDOWN', array_key_exists('markdown', $formatters)); |
467 | $page->assign('SYNTAXHIGHLIGHTING', array_key_exists('syntaxhighlighting', $formatters)); |
468 | $page->assign('SYNTAXHIGHLIGHTINGTHEME', $this->_conf->getKey('syntaxhighlightingtheme')); |
469 | $page->assign('FORMATTER', $formatters); |
470 | $page->assign('FORMATTERDEFAULT', $this->_conf->getKey('defaultformatter')); |
471 | $page->assign('INFO', I18n::_(str_replace("'", '"', $this->_conf->getKey('info')))); |
472 | $page->assign('NOTICE', I18n::_($this->_conf->getKey('notice'))); |
473 | $page->assign('BURNAFTERREADINGSELECTED', $this->_conf->getKey('burnafterreadingselected')); |
474 | $page->assign('PASSWORD', $this->_conf->getKey('password')); |
475 | $page->assign('FILEUPLOAD', $this->_conf->getKey('fileupload')); |
476 | $page->assign('ZEROBINCOMPATIBILITY', $this->_conf->getKey('zerobincompatibility')); |
477 | $page->assign('LANGUAGESELECTION', $languageselection); |
478 | $page->assign('LANGUAGES', I18n::getLanguageLabels(I18n::getAvailableLanguages())); |
479 | $page->assign('TEMPLATESELECTION', $templateselection); |
480 | $page->assign('TEMPLATES', TemplateSwitcher::getAvailableTemplates()); |
481 | $page->assign('EXPIRE', $expire); |
482 | $page->assign('EXPIREDEFAULT', $this->_conf->getKey('default', 'expire')); |
483 | $page->assign('URLSHORTENER', $this->_conf->getKey('urlshortener')); |
484 | $page->assign('QRCODE', $this->_conf->getKey('qrcode')); |
485 | $page->assign('EMAIL', $this->_conf->getKey('email')); |
486 | $page->assign('HTTPWARNING', $this->_conf->getKey('httpwarning')); |
487 | $page->assign('HTTPSLINK', 'https://' . $this->_request->getHost() . $this->_request->getRequestUri()); |
488 | $page->assign('COMPRESSION', $this->_conf->getKey('compression')); |
489 | $page->assign('SRI', $this->_conf->getSection('sri')); |
490 | $page->draw(TemplateSwitcher::getTemplate()); |
491 | } |
492 | |
493 | /** |
494 | * outputs requested JSON-LD context |
495 | * |
496 | * @access private |
497 | * @param string $type |
498 | */ |
499 | private function _jsonld($type) |
500 | { |
501 | if (!in_array($type, array( |
502 | 'comment', |
503 | 'commentmeta', |
504 | 'paste', |
505 | 'pastemeta', |
506 | 'types', |
507 | ))) { |
508 | $type = ''; |
509 | } |
510 | $content = '{}'; |
511 | $file = PUBLIC_PATH . DIRECTORY_SEPARATOR . 'js' . DIRECTORY_SEPARATOR . $type . '.jsonld'; |
512 | if (is_readable($file)) { |
513 | $content = str_replace( |
514 | '?jsonld=', |
515 | $this->_urlBase . '?jsonld=', |
516 | file_get_contents($file) |
517 | ); |
518 | } |
519 | if ($type === 'types') { |
520 | $content = str_replace( |
521 | implode('", "', array_keys($this->_conf->getDefaults()['expire_options'])), |
522 | implode('", "', array_keys($this->_conf->getSection('expire_options'))), |
523 | $content |
524 | ); |
525 | } |
526 | |
527 | header('Content-type: application/ld+json'); |
528 | header('Access-Control-Allow-Origin: *'); |
529 | header('Access-Control-Allow-Methods: GET'); |
530 | echo $content; |
531 | } |
532 | |
533 | /** |
534 | * proxies link to YOURLS, updates status or error with response |
535 | * |
536 | * @access private |
537 | * @param string $link |
538 | */ |
539 | private function _yourlsproxy($link) |
540 | { |
541 | $yourls = new YourlsProxy($this->_conf, $link); |
542 | if ($yourls->isError()) { |
543 | $this->_error = $yourls->getError(); |
544 | } else { |
545 | $this->_status = $yourls->getUrl(); |
546 | } |
547 | } |
548 | |
549 | /** |
550 | * prepares JSON encoded status message |
551 | * |
552 | * @access private |
553 | * @param int $status |
554 | * @param string $message |
555 | * @param array $other |
556 | */ |
557 | private function _return_message($status, $message, $other = array()) |
558 | { |
559 | $result = array('status' => $status); |
560 | if ($status) { |
561 | $result['message'] = I18n::_($message); |
562 | } else { |
563 | $result['id'] = $message; |
564 | $result['url'] = $this->_urlBase . '?' . $message; |
565 | } |
566 | $result += $other; |
567 | $this->_json = Json::encode($result); |
568 | } |
569 | } |