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