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