Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.92% covered (success)
97.92%
94 / 96
80.00% covered (success)
80.00%
8 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Request
97.92% covered (success)
97.92%
94 / 96
80.00% covered (success)
80.00%
8 / 10
61
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
97.56% covered (success)
97.56%
40 / 41
0.00% covered (danger)
0.00%
0 / 1
27
 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                    'shortenviachhoto' => FILTER_SANITIZE_SPECIAL_CHARS,
134                ], false);
135        }
136        if (
137            !array_key_exists('pasteid', $this->_params) &&
138            !array_key_exists('jsonld', $this->_params) &&
139            !array_key_exists('link', $this->_params) &&
140            array_key_exists('QUERY_STRING', $_SERVER) &&
141            !empty($_SERVER['QUERY_STRING'])
142        ) {
143            $this->_params['pasteid'] = $this->getPasteId();
144        }
145
146        // prepare operation, depending on current parameters
147        if (array_key_exists('pasteid', $this->_params) && !empty($this->_params['pasteid'])) {
148            if (array_key_exists('deletetoken', $this->_params) && !empty($this->_params['deletetoken'])) {
149                $this->_operation = 'delete';
150            } elseif ($this->_operation !== 'create') {
151                $this->_operation = 'read';
152            }
153        } elseif (array_key_exists('jsonld', $this->_params) && !empty($this->_params['jsonld'])) {
154            $this->_operation = 'jsonld';
155        } elseif (array_key_exists('link', $this->_params) && !empty($this->_params['link'])) {
156            if (str_contains($this->getRequestUri(), '/shortenviayourls') || array_key_exists('shortenviayourls', $this->_params)) {
157                $this->_operation = 'yourlsproxy';
158            }
159            if (str_contains($this->getRequestUri(), '/shortenviashlink') || array_key_exists('shortenviashlink', $this->_params)) {
160                $this->_operation = 'shlinkproxy';
161            }
162            if (str_contains($this->getRequestUri(), '/shortenviachhoto') || array_key_exists('shortenviachhoto', $this->_params)) {
163                $this->_operation = 'chhotoproxy';
164            }
165        }
166    }
167
168    /**
169     * Get current operation
170     *
171     * @access public
172     * @return string
173     */
174    public function getOperation()
175    {
176        return $this->_operation;
177    }
178
179    /**
180     * Get data of paste or comment
181     *
182     * @access public
183     * @return array
184     */
185    public function getData()
186    {
187        $data = [
188            'adata' => $this->getParam('adata'),
189        ];
190        $required_keys = ['v', 'ct'];
191        $meta          = $this->getParam('meta');
192        if (empty($meta)) {
193            $required_keys[] = 'pasteid';
194            $required_keys[] = 'parentid';
195        } else {
196            $data['meta'] = $meta;
197        }
198        foreach ($required_keys as $key) {
199            $data[$key] = $this->getParam($key, $key === 'v' ? 1 : '');
200        }
201        return $data;
202    }
203
204    /**
205     * Get a request parameter
206     *
207     * @access public
208     * @param  string $param
209     * @param  string $default
210     * @return string
211     */
212    public function getParam($param, $default = '')
213    {
214        return $this->_params[$param] ?? $default;
215    }
216
217    /**
218     * Get host as requested by the client
219     *
220     * @access public
221     * @return string
222     */
223    public function getHost()
224    {
225        $host = array_key_exists('HTTP_HOST', $_SERVER) ? filter_var($_SERVER['HTTP_HOST'], FILTER_SANITIZE_URL) : '';
226        return empty($host) ? 'localhost' : $host;
227    }
228
229    /**
230     * Get request URI path without GET parameters
231     *
232     * @access public
233     * @return string
234     */
235    public function getRequestUri()
236    {
237        $uri = array_key_exists('REQUEST_URI', $_SERVER) ? filter_var($_SERVER['REQUEST_URI'], FILTER_SANITIZE_URL) : '';
238        return empty($uri) ? '/' : parse_url($uri, PHP_URL_PATH);
239    }
240
241    /**
242     * If we are in a JSON API context
243     *
244     * @access public
245     * @return bool
246     */
247    public function isJsonApiCall()
248    {
249        return $this->_isJsonApi;
250    }
251
252    /**
253     * Override the default input stream source, used for unit testing
254     *
255     * @param string $input
256     */
257    public static function setInputStream($input)
258    {
259        self::$_inputStream = $input;
260    }
261
262    /**
263     * Detect the clients supported media type and decide if its a JSON API call or not
264     *
265     * Adapted from: https://stackoverflow.com/questions/3770513/detect-browser-language-in-php#3771447
266     *
267     * @access private
268     * @return bool
269     */
270    private function _detectJsonRequest()
271    {
272        $acceptHeader = $_SERVER['HTTP_ACCEPT'] ?? '';
273
274        // simple cases
275        if (
276            ($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'JSONHttpRequest' ||
277            (
278                str_contains($acceptHeader, self::MIME_JSON) &&
279                !str_contains($acceptHeader, self::MIME_HTML) &&
280                !str_contains($acceptHeader, self::MIME_XHTML)
281            )
282        ) {
283            return true;
284        }
285
286        // advanced case: media type negotiation
287        if (!empty($acceptHeader)) {
288            $mediaTypes = [];
289            foreach (explode(',', trim($acceptHeader)) as $mediaTypeRange) {
290                if (preg_match(
291                    '#(\*/\*|[a-z\-]+/[a-z\-+*]+(?:\s*;\s*[^q]\S*)*)(?:\s*;\s*q\s*=\s*(0(?:\.\d{0,3})|1(?:\.0{0,3})))?#',
292                    trim($mediaTypeRange), $match
293                )) {
294                    if (!isset($match[2])) {
295                        $match[2] = '1.0';
296                    } else {
297                        $match[2] = (string) floatval($match[2]);
298                        if ($match[2] === '0.0') {
299                            continue;
300                        }
301                    }
302                    if (!isset($mediaTypes[$match[2]])) {
303                        $mediaTypes[$match[2]] = [];
304                    }
305                    $mediaTypes[$match[2]][] = strtolower($match[1]);
306                }
307            }
308            krsort($mediaTypes);
309            foreach ($mediaTypes as $acceptedQuality => $acceptedValues) {
310                foreach ($acceptedValues as $acceptedValue) {
311                    if (
312                        str_starts_with($acceptedValue, self::MIME_HTML) ||
313                        str_starts_with($acceptedValue, self::MIME_XHTML)
314                    ) {
315                        return false;
316                    } elseif (str_starts_with($acceptedValue, self::MIME_JSON)) {
317                        return true;
318                    }
319                }
320            }
321        }
322        return false;
323    }
324}