Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.96% covered (success)
95.96%
214 / 223
80.00% covered (success)
80.00%
8 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Controller
95.96% covered (success)
95.96%
214 / 223
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%
15 / 15
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%
61 / 61
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 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
12namespace PrivateBin;
13
14use Exception;
15use PrivateBin\Persistence\ServerSalt;
16use PrivateBin\Persistence\TrafficLimiter;
17
18/**
19 * Controller
20 *
21 * Puts it all together.
22 */
23class 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}