Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.91% covered (success)
95.91%
211 / 220
80.00% covered (success)
80.00%
8 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Controller
95.91% covered (success)
95.91%
211 / 220
80.00% covered (success)
80.00%
8 / 10
50
0.00% covered (danger)
0.00%
0 / 1
 __construct
88.89% covered (success)
88.89%
32 / 36
0.00% covered (danger)
0.00%
0 / 1
10.14
 _init
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 _setCacheHeaders
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 _create
89.58% covered (success)
89.58%
43 / 48
0.00% covered (danger)
0.00%
0 / 1
12.16
 _delete
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
6
 _read
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 _view
100.00% covered (success)
100.00%
59 / 59
100.00% covered (success)
100.00%
1 / 1
5
 _jsonld
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
4
 _yourlsproxy
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 _return_message
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
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
13namespace PrivateBin;
14
15use Exception;
16use PrivateBin\Persistence\ServerSalt;
17use PrivateBin\Persistence\TrafficLimiter;
18
19/**
20 * Controller
21 *
22 * Puts it all together.
23 */
24class 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}