Completed
Push — master ( 9b2af0...bbcc3e )
by El
03:42
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
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';
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 96
    public function __construct()
128
    {
129 96
        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 96
        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 96
        $this->_init();
138
139 94
        switch ($this->_request->getOperation()) {
140 94
            case 'create':
141 44
                $this->_create();
142 44
                break;
143 50
            case 'delete':
144 18
                $this->_delete(
145 18
                    $this->_request->getParam('pasteid'),
146 18
                    $this->_request->getParam('deletetoken')
147
                );
148 18
                break;
149 32
            case 'read':
150 19
                $this->_read($this->_request->getParam('pasteid'));
151 19
                break;
152 13
            case 'jsonld':
153 5
                $this->_jsonld($this->_request->getParam('jsonld'));
154 5
                return;
155
        }
156
157
        // output JSON or HTML
158 89
        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 34
            $this->_view();
166
        }
167 89
    }
168
169
    /**
170
     * initialize privatebin
171
     *
172
     * @access private
173
     */
174 96
    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 96
        $this->_conf    = new Configuration;
177 94
        $this->_model   = new Model($this->_conf);
178 94
        $this->_request = new Request;
179 94
        $this->_urlBase = array_key_exists('REQUEST_URI', $_SERVER) ?
180 94
            htmlspecialchars($_SERVER['REQUEST_URI']) : '/';
181 94
        ServerSalt::setPath($this->_conf->getKey('dir', 'traffic'));
182
183
        // set default language
184 94
        $lang = $this->_conf->getKey('languagedefault');
185 94
        I18n::setLanguageFallback($lang);
186
        // force default language, if language selection is disabled and a default is set
187 94
        if (!$this->_conf->getKey('languageselection') && strlen($lang) == 2) {
188 2
            $_COOKIE['lang'] = $lang;
189 2
            setcookie('lang', $lang);
190
        }
191 94
    }
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 19
    private function _read($dataid)
366
    {
367
        try {
368 19
            $paste = $this->_model->getPaste($dataid);
369 17
            if ($paste->exists()) {
370 13
                $data              = $paste->get();
371 11
                $this->_doesExpire = property_exists($data, 'meta') && property_exists($data->meta, 'expire_date');
372 11
                if (property_exists($data->meta, 'salt')) {
373 11
                    unset($data->meta->salt);
374
                }
375 11
                $this->_data = json_encode($data);
376
            } else {
377 15
                $this->_error = self::GENERIC_ERROR;
378
            }
379 4
        } catch (Exception $e) {
380 4
            $this->_error = $e->getMessage();
381
        }
382
383 19 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 19
    }
391
392
    /**
393
     * Display PrivateBin frontend.
394
     *
395
     * @access private
396
     */
397 34
    private function _view()
398
    {
399
        // set headers to disable caching
400 34
        $time = gmdate('D, d M Y H:i:s \G\M\T');
401 34
        header('Cache-Control: no-store, no-cache, no-transform, must-revalidate');
402 34
        header('Pragma: no-cache');
403 34
        header('Expires: ' . $time);
404 34
        header('Last-Modified: ' . $time);
405 34
        header('Vary: Accept');
406 34
        header('Content-Security-Policy: ' . $this->_conf->getKey('cspheader'));
407 34
        header('X-Xss-Protection: 1; mode=block');
408 34
        header('X-Frame-Options: DENY');
409 34
        header('X-Content-Type-Options: nosniff');
410
411
        // label all the expiration options
412 34
        $expire = array();
413 34
        foreach ($this->_conf->getSection('expire_options') as $time => $seconds) {
414 34
            $expire[$time] = ($seconds == 0) ? I18n::_(ucfirst($time)) : Filter::formatHumanReadableTime($time);
415
        }
416
417
        // translate all the formatter options
418 34
        $formatters = array_map('PrivateBin\\I18n::_', $this->_conf->getSection('formatter_options'));
419
420
        // set language cookie if that functionality was enabled
421 34
        $languageselection = '';
422 34
        if ($this->_conf->getKey('languageselection')) {
423 2
            $languageselection = I18n::getLanguage();
424 2
            setcookie('lang', $languageselection);
425
        }
426
427 34
        $page = new View;
428 34
        $page->assign('NAME', $this->_conf->getKey('name'));
429 34
        $page->assign('CIPHERDATA', $this->_data);
430 34
        $page->assign('ERROR', I18n::_($this->_error));
431 34
        $page->assign('STATUS', I18n::_($this->_status));
432 34
        $page->assign('VERSION', self::VERSION);
433 34
        $page->assign('DISCUSSION', $this->_conf->getKey('discussion'));
434 34
        $page->assign('OPENDISCUSSION', $this->_conf->getKey('opendiscussion'));
435 34
        $page->assign('MARKDOWN', array_key_exists('markdown', $formatters));
436 34
        $page->assign('SYNTAXHIGHLIGHTING', array_key_exists('syntaxhighlighting', $formatters));
437 34
        $page->assign('SYNTAXHIGHLIGHTINGTHEME', $this->_conf->getKey('syntaxhighlightingtheme'));
438 34
        $page->assign('FORMATTER', $formatters);
439 34
        $page->assign('FORMATTERDEFAULT', $this->_conf->getKey('defaultformatter'));
440 34
        $page->assign('NOTICE', I18n::_($this->_conf->getKey('notice')));
441 34
        $page->assign('BURNAFTERREADINGSELECTED', $this->_conf->getKey('burnafterreadingselected'));
442 34
        $page->assign('PASSWORD', $this->_conf->getKey('password'));
443 34
        $page->assign('FILEUPLOAD', $this->_conf->getKey('fileupload'));
444 34
        $page->assign('ZEROBINCOMPATIBILITY', $this->_conf->getKey('zerobincompatibility'));
445 34
        $page->assign('LANGUAGESELECTION', $languageselection);
446 34
        $page->assign('LANGUAGES', I18n::getLanguageLabels(I18n::getAvailableLanguages()));
447 34
        $page->assign('EXPIRE', $expire);
448 34
        $page->assign('EXPIREDEFAULT', $this->_conf->getKey('default', 'expire'));
449 34
        $page->assign('EXPIRECLONE', !$this->_doesExpire || ($this->_doesExpire && $this->_conf->getKey('clone', 'expire')));
450 34
        $page->assign('URLSHORTENER', $this->_conf->getKey('urlshortener'));
451 34
        $page->draw($this->_conf->getKey('template'));
452 34
    }
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
                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