Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.85% covered (success)
95.85%
208 / 217
77.78% covered (warning)
77.78%
7 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Controller
95.85% covered (success)
95.85%
208 / 217
77.78% covered (warning)
77.78%
7 / 9
49
0.00% covered (danger)
0.00%
0 / 1
 __construct
87.88% covered (success)
87.88%
29 / 33
0.00% covered (danger)
0.00%
0 / 1
10.18
 _init
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 _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%
65 / 65
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.0
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.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}