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