Passed
Push — master ( 81ac23...a5d5f6 )
by El
03:02
created

lib/PrivateBin.php (4 issues)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

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.1.1
11
 */
12
13
namespace PrivateBin;
14
15
use Exception;
16
use PrivateBin\Persistence\ServerSalt;
17
use PrivateBin\Persistence\TrafficLimiter;
18
19
/**
20
 * PrivateBin
21
 *
22
 * Controller, puts it all together.
23
 */
24
class PrivateBin
25
{
26
    /**
27
     * version
28
     *
29
     * @const string
30
     */
31
    const VERSION = '1.1.1';
32
33
    /**
34
     * minimal required PHP version
35
     *
36
     * @const string
37
     */
38
    const MIN_PHP_VERSION = '5.4.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
     * data
57
     *
58
     * @access private
59
     * @var    string
60
     */
61
    private $_data = '';
62
63
    /**
64
     * does the paste expire
65
     *
66
     * @access private
67
     * @var    bool
68
     */
69
    private $_doesExpire = false;
70
71
    /**
72
     * error message
73
     *
74
     * @access private
75
     * @var    string
76
     */
77
    private $_error = '';
78
79
    /**
80
     * status message
81
     *
82
     * @access private
83
     * @var    string
84
     */
85
    private $_status = '';
86
87
    /**
88
     * JSON message
89
     *
90
     * @access private
91
     * @var    string
92
     */
93
    private $_json = '';
94
95
    /**
96
     * Factory of instance models
97
     *
98
     * @access private
99
     * @var    model
100
     */
101
    private $_model;
102
103
    /**
104
     * request
105
     *
106
     * @access private
107
     * @var    request
108
     */
109
    private $_request;
110
111
    /**
112
     * URL base
113
     *
114
     * @access private
115
     * @var    string
116
     */
117
    private $_urlBase;
118
119
    /**
120
     * constructor
121
     *
122
     * initializes and runs PrivateBin
123
     *
124
     * @access public
125
     * @throws Exception
126
     */
127 98
    public function __construct()
128
    {
129 98
        if (version_compare(PHP_VERSION, self::MIN_PHP_VERSION) < 0) {
130
            throw new Exception(I18n::_('%s requires php %s or above to work. Sorry.', I18n::_('PrivateBin'), self::MIN_PHP_VERSION), 1);
131
        }
132 98
        if (strlen(PATH) < 0 && substr(PATH, -1) !== DIRECTORY_SEPARATOR) {
133
            throw new Exception(I18n::_('%s requires the PATH to end in a "%s". Please update the PATH in your index.php.', I18n::_('PrivateBin'), DIRECTORY_SEPARATOR), 5);
134
        }
135
136
        // load config from ini file, initialize required classes
137 98
        $this->_init();
138
139 96
        switch ($this->_request->getOperation()) {
140 96
            case 'create':
141 44
                $this->_create();
142 44
                break;
143 52
            case 'delete':
144 18
                $this->_delete(
145 18
                    $this->_request->getParam('pasteid'),
146 18
                    $this->_request->getParam('deletetoken')
147
                );
148 18
                break;
149 34
            case 'read':
150 21
                $this->_read($this->_request->getParam('pasteid'));
151 21
                break;
152 13
            case 'jsonld':
153 5
                $this->_jsonld($this->_request->getParam('jsonld'));
154 5
                return;
155
        }
156
157
        // output JSON or HTML
158 91
        if ($this->_request->isJsonApiCall()) {
159 55
            header('Content-type: ' . Request::MIME_JSON);
160 55
            header('Access-Control-Allow-Origin: *');
161 55
            header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE');
162 55
            header('Access-Control-Allow-Headers: X-Requested-With, Content-Type');
163 55
            echo $this->_json;
164
        } else {
165 36
            $this->_view();
166
        }
167 91
    }
168
169
    /**
170
     * initialize privatebin
171
     *
172
     * @access private
173
     */
174 98
    private function _init()
0 ignored issues
show
_init uses the super-global variable $_SERVER which is generally not recommended.

Instead of super-globals, we recommend to explicitly inject the dependencies of your class. This makes your code less dependent on global state and it becomes generally more testable:

// Bad
class Router
{
    public function generate($path)
    {
        return $_SERVER['HOST'].$path;
    }
}

// Better
class Router
{
    private $host;

    public function __construct($host)
    {
        $this->host = $host;
    }

    public function generate($path)
    {
        return $this->host.$path;
    }
}

class Controller
{
    public function myAction(Request $request)
    {
        // Instead of
        $page = isset($_GET['page']) ? intval($_GET['page']) : 1;

        // Better (assuming you use the Symfony2 request)
        $page = $request->query->get('page', 1);
    }
}
Loading history...
_init uses the super-global variable $_COOKIE which is generally not recommended.

Instead of super-globals, we recommend to explicitly inject the dependencies of your class. This makes your code less dependent on global state and it becomes generally more testable:

// Bad
class Router
{
    public function generate($path)
    {
        return $_SERVER['HOST'].$path;
    }
}

// Better
class Router
{
    private $host;

    public function __construct($host)
    {
        $this->host = $host;
    }

    public function generate($path)
    {
        return $this->host.$path;
    }
}

class Controller
{
    public function myAction(Request $request)
    {
        // Instead of
        $page = isset($_GET['page']) ? intval($_GET['page']) : 1;

        // Better (assuming you use the Symfony2 request)
        $page = $request->query->get('page', 1);
    }
}
Loading history...
175
    {
176 98
        $this->_conf    = new Configuration;
177 96
        $this->_model   = new Model($this->_conf);
178 96
        $this->_request = new Request;
179 96
        $this->_urlBase = array_key_exists('REQUEST_URI', $_SERVER) ?
180 96
            htmlspecialchars($_SERVER['REQUEST_URI']) : '/';
181 96
        ServerSalt::setPath($this->_conf->getKey('dir', 'traffic'));
182
183
        // set default language
184 96
        $lang = $this->_conf->getKey('languagedefault');
185 96
        I18n::setLanguageFallback($lang);
186
        // force default language, if language selection is disabled and a default is set
187 96
        if (!$this->_conf->getKey('languageselection') && strlen($lang) == 2) {
188 2
            $_COOKIE['lang'] = $lang;
189 2
            setcookie('lang', $lang);
190
        }
191 96
    }
192
193
    /**
194
     * Store new paste or comment
195
     *
196
     * POST contains one or both:
197
     * data = json encoded SJCL encrypted text (containing keys: iv,v,iter,ks,ts,mode,adata,cipher,salt,ct)
198
     * attachment = json encoded SJCL encrypted text (containing keys: iv,v,iter,ks,ts,mode,adata,cipher,salt,ct)
199
     *
200
     * All optional data will go to meta information:
201
     * expire (optional) = expiration delay (never,5min,10min,1hour,1day,1week,1month,1year,burn) (default:never)
202
     * formatter (optional) = format to display the paste as (plaintext,syntaxhighlighting,markdown) (default:syntaxhighlighting)
203
     * burnafterreading (optional) = if this paste may only viewed once ? (0/1) (default:0)
204
     * opendiscusssion (optional) = is the discussion allowed on this paste ? (0/1) (default:0)
205
     * attachmentname = json encoded SJCL encrypted text (containing keys: iv,v,iter,ks,ts,mode,adata,cipher,salt,ct)
206
     * nickname (optional) = in discussion, encoded SJCL encrypted text nickname of author of comment (containing keys: iv,v,iter,ks,ts,mode,adata,cipher,salt,ct)
207
     * parentid (optional) = in discussion, which comment this comment replies to.
208
     * pasteid (optional) = in discussion, which paste this comment belongs to.
209
     *
210
     * @access private
211
     * @return string
212
     */
213 44
    private function _create()
214
    {
215
        // Ensure last paste from visitors IP address was more than configured amount of seconds ago.
216 44
        TrafficLimiter::setConfiguration($this->_conf);
217 44
        if (!TrafficLimiter::canPass()) {
218 2
            return $this->_return_message(
219 2
            1, I18n::_(
220 2
                'Please wait %d seconds between each post.',
221 2
                $this->_conf->getKey('limit', 'traffic')
222
            )
223
        );
224
        }
225
226 44
        $data           = $this->_request->getParam('data');
227 44
        $attachment     = $this->_request->getParam('attachment');
228 44
        $attachmentname = $this->_request->getParam('attachmentname');
229
230
        // Ensure content is not too big.
231 44
        $sizelimit = $this->_conf->getKey('sizelimit');
232
        if (
233 44
            strlen($data) + strlen($attachment) + strlen($attachmentname) > $sizelimit
234
        ) {
235 2
            return $this->_return_message(
236 2
            1,
237 2
            I18n::_(
238 2
                'Paste is limited to %s of encrypted data.',
239 2
                Filter::formatHumanReadableSize($sizelimit)
240
            )
241
        );
242
        }
243
244
        // Ensure attachment did not get lost due to webserver limits or Suhosin
245 42
        if (strlen($attachmentname) > 0 && strlen($attachment) == 0) {
246 2
            return $this->_return_message(1, 'Attachment missing in data received by server. Please check your webserver or suhosin configuration for maximum POST parameter limitations.');
247
        }
248
249
        // The user posts a comment.
250 40
        $pasteid  = $this->_request->getParam('pasteid');
251 40
        $parentid = $this->_request->getParam('parentid');
252 40
        if (!empty($pasteid) && !empty($parentid)) {
253 12
            $paste = $this->_model->getPaste($pasteid);
254 12
            if ($paste->exists()) {
255
                try {
256 10
                    $comment = $paste->getComment($parentid);
257
258 8
                    $nickname = $this->_request->getParam('nickname');
259 8
                    if (!empty($nickname)) {
260 8
                        $comment->setNickname($nickname);
261
                    }
262
263 6
                    $comment->setData($data);
264 6
                    $comment->store();
265 8
                } catch (Exception $e) {
266 8
                    return $this->_return_message(1, $e->getMessage());
267
                }
268 2
                $this->_return_message(0, $comment->getId());
269
            } else {
270 4
                $this->_return_message(1, 'Invalid data.');
271
            }
272
        }
273
        // The user posts a standard paste.
274
        else {
275 28
            $this->_model->purge();
276 28
            $paste = $this->_model->getPaste();
277
            try {
278 28
                $paste->setData($data);
279
280 28
                if (!empty($attachment)) {
281 2
                    $paste->setAttachment($attachment);
282 2
                    if (!empty($attachmentname)) {
283 2
                        $paste->setAttachmentName($attachmentname);
284
                    }
285
                }
286
287 28
                $expire = $this->_request->getParam('expire');
288 28
                if (!empty($expire)) {
289 6
                    $paste->setExpiration($expire);
290
                }
291
292 28
                $burnafterreading = $this->_request->getParam('burnafterreading');
293 28
                if (!empty($burnafterreading)) {
294 2
                    $paste->setBurnafterreading($burnafterreading);
295
                }
296
297 26
                $opendiscussion = $this->_request->getParam('opendiscussion');
298 26
                if (!empty($opendiscussion)) {
299 4
                    $paste->setOpendiscussion($opendiscussion);
300
                }
301
302 24
                $formatter = $this->_request->getParam('formatter');
303 24
                if (!empty($formatter)) {
304 2
                    $paste->setFormatter($formatter);
305
                }
306
307 24
                $paste->store();
308 6
            } catch (Exception $e) {
309 6
                return $this->_return_message(1, $e->getMessage());
310
            }
311 22
            $this->_return_message(0, $paste->getId(), array('deletetoken' => $paste->getDeleteToken()));
312
        }
313 26
    }
314
315
    /**
316
     * Delete an existing paste
317
     *
318
     * @access private
319
     * @param  string $dataid
320
     * @param  string $deletetoken
321
     */
322 18
    private function _delete($dataid, $deletetoken)
323
    {
324
        try {
325 18
            $paste = $this->_model->getPaste($dataid);
326 16
            if ($paste->exists()) {
327
                // accessing this property ensures that the paste would be
328
                // deleted if it has already expired
329 14
                $burnafterreading = $paste->isBurnafterreading();
330
                if (
331 12
                    ($burnafterreading && $deletetoken == 'burnafterreading') ||
332 12
                    Filter::slowEquals($deletetoken, $paste->getDeleteToken())
333
                ) {
334
                    // Paste exists and deletion token is valid: Delete the paste.
335 8
                    $paste->delete();
336 8
                    $this->_status = 'Paste was properly deleted.';
337
                } else {
338 4
                    if (!$burnafterreading && $deletetoken == 'burnafterreading') {
339 2
                        $this->_error = 'Paste is not of burn-after-reading type.';
340
                    } else {
341 12
                        $this->_error = 'Wrong deletion token. Paste was not deleted.';
342
                    }
343
                }
344
            } else {
345 14
                $this->_error = self::GENERIC_ERROR;
346
            }
347 4
        } catch (Exception $e) {
348 4
            $this->_error = $e->getMessage();
349
        }
350 18 View Code Duplication
        if ($this->_request->isJsonApiCall()) {
0 ignored issues
show
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
351 6
            if (strlen($this->_error)) {
352 2
                $this->_return_message(1, $this->_error);
353
            } else {
354 4
                $this->_return_message(0, $dataid);
355
            }
356
        }
357 18
    }
358
359
    /**
360
     * Read an existing paste or comment
361
     *
362
     * @access private
363
     * @param  string $dataid
364
     */
365 21
    private function _read($dataid)
366
    {
367
        try {
368 21
            $paste = $this->_model->getPaste($dataid);
369 19
            if ($paste->exists()) {
370 15
                $data              = $paste->get();
371 13
                $this->_doesExpire = property_exists($data, 'meta') && property_exists($data->meta, 'expire_date');
372 13
                if (property_exists($data->meta, 'salt')) {
373 13
                    unset($data->meta->salt);
374
                }
375 13
                $this->_data = json_encode($data);
376
            } else {
377 17
                $this->_error = self::GENERIC_ERROR;
378
            }
379 4
        } catch (Exception $e) {
380 4
            $this->_error = $e->getMessage();
381
        }
382
383 21 View Code Duplication
        if ($this->_request->isJsonApiCall()) {
0 ignored issues
show
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
384 5
            if (strlen($this->_error)) {
385 2
                $this->_return_message(1, $this->_error);
386
            } else {
387 3
                $this->_return_message(0, $dataid, json_decode($this->_data, true));
388
            }
389
        }
390 21
    }
391
392
    /**
393
     * Display PrivateBin frontend.
394
     *
395
     * @access private
396
     */
397 36
    private function _view()
398
    {
399
        // set headers to disable caching
400 36
        $time = gmdate('D, d M Y H:i:s \G\M\T');
401 36
        header('Cache-Control: no-store, no-cache, no-transform, must-revalidate');
402 36
        header('Pragma: no-cache');
403 36
        header('Expires: ' . $time);
404 36
        header('Last-Modified: ' . $time);
405 36
        header('Vary: Accept');
406 36
        header('Content-Security-Policy: ' . $this->_conf->getKey('cspheader'));
407 36
        header('X-Xss-Protection: 1; mode=block');
408 36
        header('X-Frame-Options: DENY');
409 36
        header('X-Content-Type-Options: nosniff');
410
411
        // label all the expiration options
412 36
        $expire = array();
413 36
        foreach ($this->_conf->getSection('expire_options') as $time => $seconds) {
414 36
            $expire[$time] = ($seconds == 0) ? I18n::_(ucfirst($time)) : Filter::formatHumanReadableTime($time);
415
        }
416
417
        // translate all the formatter options
418 36
        $formatters = array_map('PrivateBin\\I18n::_', $this->_conf->getSection('formatter_options'));
419
420
        // set language cookie if that functionality was enabled
421 36
        $languageselection = '';
422 36
        if ($this->_conf->getKey('languageselection')) {
423 2
            $languageselection = I18n::getLanguage();
424 2
            setcookie('lang', $languageselection);
425
        }
426
427 36
        $page = new View;
428 36
        $page->assign('NAME', $this->_conf->getKey('name'));
429 36
        $page->assign('CIPHERDATA', $this->_data);
430 36
        $page->assign('ERROR', I18n::_($this->_error));
431 36
        $page->assign('STATUS', I18n::_($this->_status));
432 36
        $page->assign('VERSION', self::VERSION);
433 36
        $page->assign('DISCUSSION', $this->_conf->getKey('discussion'));
434 36
        $page->assign('OPENDISCUSSION', $this->_conf->getKey('opendiscussion'));
435 36
        $page->assign('MARKDOWN', array_key_exists('markdown', $formatters));
436 36
        $page->assign('SYNTAXHIGHLIGHTING', array_key_exists('syntaxhighlighting', $formatters));
437 36
        $page->assign('SYNTAXHIGHLIGHTINGTHEME', $this->_conf->getKey('syntaxhighlightingtheme'));
438 36
        $page->assign('FORMATTER', $formatters);
439 36
        $page->assign('FORMATTERDEFAULT', $this->_conf->getKey('defaultformatter'));
440 36
        $page->assign('NOTICE', I18n::_($this->_conf->getKey('notice')));
441 36
        $page->assign('BURNAFTERREADINGSELECTED', $this->_conf->getKey('burnafterreadingselected'));
442 36
        $page->assign('PASSWORD', $this->_conf->getKey('password'));
443 36
        $page->assign('FILEUPLOAD', $this->_conf->getKey('fileupload'));
444 36
        $page->assign('ZEROBINCOMPATIBILITY', $this->_conf->getKey('zerobincompatibility'));
445 36
        $page->assign('LANGUAGESELECTION', $languageselection);
446 36
        $page->assign('LANGUAGES', I18n::getLanguageLabels(I18n::getAvailableLanguages()));
447 36
        $page->assign('EXPIRE', $expire);
448 36
        $page->assign('EXPIREDEFAULT', $this->_conf->getKey('default', 'expire'));
449 36
        $page->assign('EXPIRECLONE', !$this->_doesExpire || ($this->_doesExpire && $this->_conf->getKey('clone', 'expire')));
450 36
        $page->assign('URLSHORTENER', $this->_conf->getKey('urlshortener'));
451 36
        $page->draw($this->_conf->getKey('template'));
452 36
    }
453
454
    /**
455
     * outputs requested JSON-LD context
456
     *
457
     * @access private
458
     * @param string $type
459
     */
460 5
    private function _jsonld($type)
461
    {
462
        if (
463 5
            $type !== 'paste' && $type !== 'comment' &&
464 5
            $type !== 'pastemeta' && $type !== 'commentmeta'
465
        ) {
466 1
            $type = '';
467
        }
468 5
        $content = '{}';
469 5
        $file    = PUBLIC_PATH . DIRECTORY_SEPARATOR . 'js' . DIRECTORY_SEPARATOR . $type . '.jsonld';
470 5
        if (is_readable($file)) {
471 4
            $content = str_replace(
472 4
                '?jsonld=',
473 4
                $this->_urlBase . '?jsonld=',
474 4
                file_get_contents($file)
475
            );
476
        }
477
478 5
        header('Content-type: application/ld+json');
479 5
        header('Access-Control-Allow-Origin: *');
480 5
        header('Access-Control-Allow-Methods: GET');
481 5
        echo $content;
482 5
    }
483
484
    /**
485
     * prepares JSON encoded status message
486
     *
487
     * @access private
488
     * @param  int $status
489
     * @param  string $message
490
     * @param  array $other
491
     */
492 55
    private function _return_message($status, $message, $other = array())
493
    {
494 55
        $result = array('status' => $status);
495 55
        if ($status) {
496 26
            $result['message'] = I18n::_($message);
497
        } else {
498 31
            $result['id']  = $message;
499 31
            $result['url'] = $this->_urlBase . '?' . $message;
500
        }
501 55
        $result += $other;
502 55
        $this->_json = json_encode($result);
503 55
    }
504
}
505