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