Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.20% covered (success)
98.20%
109 / 111
80.00% covered (success)
80.00%
8 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Request
98.20% covered (success)
98.20%
109 / 111
80.00% covered (success)
80.00%
8 / 10
68
0.00% covered (danger)
0.00%
0 / 1
 getPasteId
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 __construct
98.00% covered (success)
98.00%
49 / 50
0.00% covered (danger)
0.00%
0 / 1
31
 getOperation
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getData
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
5
 getParam
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getHost
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 getRequestUri
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
5
 isJsonApiCall
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setInputStream
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 _detectJsonRequest
96.67% covered (success)
96.67%
29 / 30
0.00% covered (danger)
0.00%
0 / 1
16
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 JsonException;
15use PrivateBin\Exception\TranslatedException;
16use PrivateBin\Model\Paste;
17
18/**
19 * Request
20 *
21 * parses request parameters and provides helper functions for routing
22 */
23class Request
24{
25    /**
26     * MIME type for JSON
27     *
28     * @const string
29     */
30    const MIME_JSON = 'application/json';
31
32    /**
33     * MIME type for HTML
34     *
35     * @const string
36     */
37    const MIME_HTML = 'text/html';
38
39    /**
40     * MIME type for XHTML
41     *
42     * @const string
43     */
44    const MIME_XHTML = 'application/xhtml+xml';
45
46    /**
47     * Input stream length for size checks
48     *
49     * @access private
50     * @var int
51     */
52    private $_bodyLength = 0;
53
54    /**
55     * Input stream to use for PUT parameter parsing
56     *
57     * @access private
58     * @var string
59     */
60    private static $_inputStream = 'php://input';
61
62    /**
63     * Operation to perform
64     *
65     * @access private
66     * @var string
67     */
68    private $_operation = 'view';
69
70    /**
71     * Request parameters
72     *
73     * @access private
74     * @var array
75     */
76    private $_params = [];
77
78    /**
79     * If we are in a JSON API context
80     *
81     * @access private
82     * @var bool
83     */
84    private $_isJsonApi = false;
85
86    /**
87     * Return the paste ID of the current document.
88     *
89     * @access private
90     * @return string
91     */
92    private function getPasteId()
93    {
94        foreach ($_GET as $key => $value) {
95            // only return if value is empty and key is 16 hex chars
96            $key = (string) $key;
97            if (empty($value) && Paste::isValidId($key)) {
98                return $key;
99            }
100        }
101
102        return 'invalid id';
103    }
104
105    /**
106     * Constructor
107     *
108     * @access public
109     */
110    public function __construct()
111    {
112        // decide if we are in JSON API or HTML context
113        $this->_isJsonApi = $this->_detectJsonRequest();
114
115        // parse parameters, depending on request type
116        switch ($_SERVER['REQUEST_METHOD'] ?? 'GET') {
117            case 'DELETE':
118            case 'PUT':
119            case 'POST':
120                // it might be a creation or a deletion, the latter is detected below
121                $this->_operation = 'create';
122                try {
123                    $data = file_get_contents(self::$_inputStream);
124                    $this->_bodyLength = strlen($data);
125                    $this->_params = Json::decode($data);
126                    // a valid JSON scalar (number, bool or string) decodes
127                    // without error, but is not a usable set of parameters
128                    if (!is_array($this->_params)) {
129                        $this->_params = [];
130                    }
131                } catch (JsonException $e) {
132                    // ignore error, $this->_params will remain empty
133                }
134                break;
135            default:
136                $this->_params = filter_var_array($_GET, [
137                    'deletetoken'      => FILTER_SANITIZE_SPECIAL_CHARS,
138                    'jsonld'           => FILTER_SANITIZE_SPECIAL_CHARS,
139                    'link'             => FILTER_SANITIZE_URL,
140                    'pasteid'          => FILTER_SANITIZE_SPECIAL_CHARS,
141                    'shortenviayourls' => FILTER_SANITIZE_SPECIAL_CHARS,
142                    'shortenviashlink' => FILTER_SANITIZE_SPECIAL_CHARS,
143                    'shortenviachhoto' => FILTER_SANITIZE_SPECIAL_CHARS,
144                ], false);
145        }
146        if (
147            !array_key_exists('pasteid', $this->_params) &&
148            !array_key_exists('jsonld', $this->_params) &&
149            !array_key_exists('link', $this->_params) &&
150            array_key_exists('QUERY_STRING', $_SERVER) &&
151            !empty($_SERVER['QUERY_STRING'])
152        ) {
153            $this->_params['pasteid'] = $this->getPasteId();
154        }
155
156        // prepare operation, depending on current parameters
157        if (
158            array_key_exists('pasteid', $this->_params) &&
159            is_string($this->_params['pasteid']) &&
160            !empty($this->_params['pasteid'])
161        ) {
162            if (
163                array_key_exists('deletetoken', $this->_params) &&
164                is_string($this->_params['deletetoken']) &&
165                !empty($this->_params['deletetoken'])
166            ) {
167                $this->_operation = 'delete';
168            } elseif ($this->_operation !== 'create') {
169                $this->_operation = 'read';
170            }
171        } elseif (
172            array_key_exists('jsonld', $this->_params) &&
173            is_string($this->_params['jsonld']) &&
174            !empty($this->_params['jsonld'])
175        ) {
176            $this->_operation = 'jsonld';
177        } elseif (
178            array_key_exists('link', $this->_params) &&
179            is_string($this->_params['link']) &&
180            !empty($this->_params['link'])
181        ) {
182            if (str_contains($this->getRequestUri(), '/shortenviayourls') || array_key_exists('shortenviayourls', $this->_params)) {
183                $this->_operation = 'yourlsproxy';
184            }
185            if (str_contains($this->getRequestUri(), '/shortenviashlink') || array_key_exists('shortenviashlink', $this->_params)) {
186                $this->_operation = 'shlinkproxy';
187            }
188            if (str_contains($this->getRequestUri(), '/shortenviachhoto') || array_key_exists('shortenviachhoto', $this->_params)) {
189                $this->_operation = 'chhotoproxy';
190            }
191        }
192    }
193
194    /**
195     * Get current operation
196     *
197     * @access public
198     * @return string
199     */
200    public function getOperation()
201    {
202        return $this->_operation;
203    }
204
205    /**
206     * Get data of paste or comment
207     *
208     * @access public
209     * @param  int $sizelimit
210     * @throws JsonException
211     * @throws TranslatedException
212     * @return array
213     */
214    public function getData(int $sizelimit)
215    {
216        if ($this->_bodyLength > $sizelimit) {
217            throw new TranslatedException([
218                'Document is limited to %s of encrypted data.',
219                Filter::formatHumanReadableSize($sizelimit),
220            ]);
221        }
222        $data = [
223            'adata' => $this->getParam('adata'),
224        ];
225        $required_keys = ['v', 'ct'];
226        $meta = $this->getParam('meta');
227        if (empty($meta)) {
228            $required_keys[] = 'pasteid';
229            $required_keys[] = 'parentid';
230        } else {
231            $data['meta'] = $meta;
232        }
233        foreach ($required_keys as $key) {
234            $data[$key] = $this->getParam($key, $key === 'v' ? 1 : '');
235        }
236        return $data;
237    }
238
239    /**
240     * Get a request parameter
241     *
242     * @access public
243     * @param  string $param
244     * @param  string $default
245     * @return string
246     */
247    public function getParam($param, $default = '')
248    {
249        return $this->_params[$param] ?? $default;
250    }
251
252    /**
253     * Get host as requested by the client
254     *
255     * @access public
256     * @return string
257     */
258    public function getHost()
259    {
260        $host = array_key_exists('HTTP_HOST', $_SERVER) ? filter_var($_SERVER['HTTP_HOST'], FILTER_SANITIZE_URL) : '';
261        return empty($host) ? 'localhost' : $host;
262    }
263
264    /**
265     * Get request URI path without GET parameters
266     *
267     * @access public
268     * @return string
269     */
270    public function getRequestUri()
271    {
272        $uri = array_key_exists('REQUEST_URI', $_SERVER) ? filter_var($_SERVER['REQUEST_URI'], FILTER_SANITIZE_URL) : '';
273        $path = empty($uri) ? '' : parse_url($uri, PHP_URL_PATH);
274        return is_string($path) && $path !== '' ? $path : '/';
275    }
276
277    /**
278     * If we are in a JSON API context
279     *
280     * @access public
281     * @return bool
282     */
283    public function isJsonApiCall()
284    {
285        return $this->_isJsonApi;
286    }
287
288    /**
289     * Override the default input stream source, used for unit testing
290     *
291     * @param string $input
292     */
293    public static function setInputStream($input)
294    {
295        self::$_inputStream = $input;
296    }
297
298    /**
299     * Detect the clients supported media type and decide if its a JSON API call or not
300     *
301     * Adapted from: https://stackoverflow.com/questions/3770513/detect-browser-language-in-php#3771447
302     *
303     * @access private
304     * @return bool
305     */
306    private function _detectJsonRequest()
307    {
308        $acceptHeader = $_SERVER['HTTP_ACCEPT'] ?? '';
309
310        // simple cases
311        if (
312            ($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'JSONHttpRequest' ||
313            (
314                str_contains($acceptHeader, self::MIME_JSON) &&
315                !str_contains($acceptHeader, self::MIME_HTML) &&
316                !str_contains($acceptHeader, self::MIME_XHTML)
317            )
318        ) {
319            return true;
320        }
321
322        // advanced case: media type negotiation
323        if (!empty($acceptHeader)) {
324            $mediaTypes = [];
325            foreach (explode(',', trim($acceptHeader)) as $mediaTypeRange) {
326                if (preg_match(
327                    '#(\*/\*|[a-z\-]+/[a-z\-+*]+(?:\s*;\s*[^q]\S*)*)(?:\s*;\s*q\s*=\s*(0(?:\.\d{0,3})|1(?:\.0{0,3})))?#',
328                    trim($mediaTypeRange), $match
329                )) {
330                    if (!isset($match[2])) {
331                        $match[2] = '1.0';
332                    } else {
333                        $match[2] = (string) floatval($match[2]);
334                        if ($match[2] === '0.0') {
335                            continue;
336                        }
337                    }
338                    if (!isset($mediaTypes[$match[2]])) {
339                        $mediaTypes[$match[2]] = [];
340                    }
341                    $mediaTypes[$match[2]][] = strtolower($match[1]);
342                }
343            }
344            krsort($mediaTypes);
345            foreach ($mediaTypes as $acceptedQuality => $acceptedValues) {
346                foreach ($acceptedValues as $acceptedValue) {
347                    if (
348                        str_starts_with($acceptedValue, self::MIME_HTML) ||
349                        str_starts_with($acceptedValue, self::MIME_XHTML)
350                    ) {
351                        return false;
352                    } elseif (str_starts_with($acceptedValue, self::MIME_JSON)) {
353                        return true;
354                    }
355                }
356            }
357        }
358        return false;
359    }
360}