1
|
|
|
<?php |
2
|
|
|
/** |
3
|
|
|
* @package CleverStyle CMS |
4
|
|
|
* @author Nazar Mokrynskyi <[email protected]> |
5
|
|
|
* @copyright Copyright (c) 2016, Nazar Mokrynskyi |
6
|
|
|
* @license MIT License, see license.txt |
7
|
|
|
*/ |
8
|
|
|
namespace cs\Request; |
9
|
|
|
use |
10
|
|
|
cs\ExitException; |
11
|
|
|
|
12
|
|
|
trait Data_and_files { |
13
|
|
|
/** |
14
|
|
|
* Data array, similar to `$_POST` |
15
|
|
|
* |
16
|
|
|
* @var array |
17
|
|
|
*/ |
18
|
|
|
public $data; |
19
|
|
|
/** |
20
|
|
|
* Normalized files array |
21
|
|
|
* |
22
|
|
|
* Each file item can be either single file or array of files (in contrast with native PHP arrays where each field like `name` become an array) with keys |
23
|
|
|
* `name`, `type`, `size`, `tmp_name`, `stream` and `error` |
24
|
|
|
* |
25
|
|
|
* `name`, `type`, `size` and `error` keys are similar to native PHP fields in `$_FILES`; `tmp_name` might not be temporary file, but file descriptor |
26
|
|
|
* wrapper like `request-file:///file` instead and `stream` is resource like obtained with `fopen('/tmp/xyz', 'rb')` |
27
|
|
|
* |
28
|
|
|
* @var array[] |
29
|
|
|
*/ |
30
|
|
|
public $files; |
31
|
|
|
/** |
32
|
|
|
* Data stream resource, similar to `fopen('php://input', 'rb')` |
33
|
|
|
* |
34
|
|
|
* Make sure you're controlling position in stream where you read something, if code in some other place might seek on this stream |
35
|
|
|
* |
36
|
|
|
* Stream is read-only |
37
|
|
|
* |
38
|
|
|
* @var null|resource |
39
|
|
|
*/ |
40
|
|
|
public $data_stream; |
41
|
|
|
/** |
42
|
|
|
* `$this->init_server()` assumed to be called already |
43
|
|
|
* |
44
|
|
|
* @param array $data Typically `$_POST` |
45
|
|
|
* @param array[] $files Typically `$_FILES`; might be like native PHP array `$_FILES` or normalized; each file item MUST contain keys |
46
|
|
|
* `name`, `type`, `size`, `error` and at least one of `tmp_name` or `stream` |
47
|
|
|
* @param null|resource|string $data_stream String, like `php://input` or resource, like `fopen('php://input', 'rb')` with request body, will be parsed for |
48
|
|
|
* data and files if necessary |
49
|
|
|
* @param bool $copy_stream Sometimes data stream can only being read once (like most of times with `php://input`), so it is necessary to |
50
|
|
|
* copy it and store its contents for longer period of time |
51
|
|
|
* |
52
|
|
|
* @throws ExitException |
53
|
|
|
*/ |
54
|
|
|
function init_data_and_files ($data = [], $files = [], $data_stream = null, $copy_stream = true) { |
55
|
|
|
if (is_resource($this->data_stream)) { |
56
|
|
|
fclose($this->data_stream); |
57
|
|
|
} |
58
|
|
|
$this->data = $data; |
59
|
|
|
$this->files = $this->normalize_files($files); |
60
|
|
|
$data_stream = is_string($data_stream) ? fopen($data_stream, 'rb') : $data_stream; |
61
|
|
|
if ($copy_stream && is_resource($data_stream)) { |
62
|
|
|
$this->data_stream = fopen('php://temp', 'w+b'); |
63
|
|
|
stream_copy_to_stream($data_stream, $this->data_stream); |
64
|
|
|
rewind($this->data_stream); |
65
|
|
|
fclose($data_stream); |
66
|
|
|
} else { |
67
|
|
|
$this->data_stream = $data_stream; |
68
|
|
|
} |
69
|
|
|
$this->parse_data_stream(); |
70
|
|
|
// Hack: for compatibility we'll override $_POST since it might be filled during parsing |
71
|
|
|
$_POST = $this->data; |
72
|
|
|
} |
73
|
|
|
/** |
74
|
|
|
* Get data item by name |
75
|
|
|
* |
76
|
|
|
* @param string|string[] $name |
77
|
|
|
* |
78
|
|
|
* @return false|mixed|mixed[] Data if exists or `false` otherwise (in case if `$name` is an array even one missing key will cause the whole thing to fail) |
79
|
|
|
*/ |
80
|
|
|
function data ($name) { |
81
|
|
|
if (is_array($name)) { |
82
|
|
|
foreach ($name as &$n) { |
83
|
|
|
if (!isset($this->data[$n])) { |
84
|
|
|
return false; |
85
|
|
|
} |
86
|
|
|
$n = $this->data[$n]; |
87
|
|
|
} |
88
|
|
|
return $name; |
89
|
|
|
} |
90
|
|
|
/** @noinspection OffsetOperationsInspection */ |
91
|
|
|
return isset($this->data[$name]) ? $this->data[$name] : false; |
92
|
|
|
} |
93
|
|
|
/** |
94
|
|
|
* @param array[] $files |
95
|
|
|
* @param string $file_path |
96
|
|
|
* |
97
|
|
|
* @return array[] |
98
|
|
|
*/ |
99
|
|
|
protected function normalize_files ($files, $file_path = '') { |
100
|
|
|
if (!isset($files['name'])) { |
101
|
|
|
foreach ($files as $field => &$file) { |
102
|
|
|
$file = $this->normalize_files($file, "$file_path/$field"); |
103
|
|
|
} |
104
|
|
|
return $files; |
105
|
|
|
} |
106
|
|
|
if (is_array($files['name'])) { |
107
|
|
|
$result = []; |
108
|
|
|
foreach (array_keys($files['name']) as $index) { |
109
|
|
|
$result[] = $this->normalize_file( |
110
|
|
|
[ |
111
|
|
|
'name' => $files['name'][$index], |
112
|
|
|
'type' => $files['type'][$index], |
113
|
|
|
'size' => $files['size'][$index], |
114
|
|
|
'tmp_name' => @$files['tmp_name'][$index] ?: null, |
115
|
|
|
'stream' => @$files['stream'][$index] ?: null, |
116
|
|
|
'error' => $files['error'][$index] |
117
|
|
|
], |
118
|
|
|
$file_path |
119
|
|
|
); |
120
|
|
|
} |
121
|
|
|
return $result; |
122
|
|
|
} else { |
123
|
|
|
return $this->normalize_file($files, $file_path); |
124
|
|
|
} |
125
|
|
|
} |
126
|
|
|
/** |
127
|
|
|
* @param array $file |
128
|
|
|
* @param string $file_path |
129
|
|
|
* |
130
|
|
|
* @return array |
131
|
|
|
*/ |
132
|
|
|
protected function normalize_file ($file, $file_path) { |
133
|
|
|
$file += [ |
134
|
|
|
'tmp_name' => null, |
135
|
|
|
'stream' => null |
136
|
|
|
]; |
137
|
|
|
if (isset($file['tmp_name']) && $file['stream'] === null) { |
138
|
|
|
$file['stream'] = fopen($file['tmp_name'], 'rb'); |
139
|
|
|
} |
140
|
|
|
if (isset($file['stream']) && !$file['tmp_name']) { |
141
|
|
|
$file['tmp_name'] = "request-file://".$file_path; |
142
|
|
|
} |
143
|
|
|
if ($file['tmp_name'] === null && $file['stream'] === null) { |
144
|
|
|
$file['error'] = UPLOAD_ERR_NO_FILE; |
145
|
|
|
} |
146
|
|
|
return $file; |
147
|
|
|
} |
148
|
|
|
/** |
149
|
|
|
* Parsing request body for following Content-Type: `application/json`, `application/x-www-form-urlencoded` and `multipart/form-data` |
150
|
|
|
* |
151
|
|
|
* @throws ExitException |
152
|
|
|
*/ |
153
|
|
|
protected function parse_data_stream () { |
154
|
|
|
if ($this->data || $this->files) { |
155
|
|
|
return; |
156
|
|
|
} |
157
|
|
|
$this->data = []; |
158
|
|
|
$this->files = []; |
159
|
|
|
$content_type = $this->header('content-type'); |
160
|
|
|
/** |
161
|
|
|
* application/json |
162
|
|
|
*/ |
163
|
|
|
if (preg_match('#^application/([^+\s]+\+)?json#', $content_type)) { |
164
|
|
|
$this->data = _json_decode(stream_get_contents($this->data_stream)) ?: []; |
165
|
|
|
return; |
166
|
|
|
} |
167
|
|
|
/** |
168
|
|
|
* application/x-www-form-urlencoded |
169
|
|
|
*/ |
170
|
|
|
if (strpos($content_type, 'application/x-www-form-urlencoded') === 0) { |
171
|
|
|
@parse_str(stream_get_contents($this->data_stream), $this->data); |
172
|
|
|
return; |
173
|
|
|
} |
174
|
|
|
/** |
175
|
|
|
* multipart/form-data |
176
|
|
|
*/ |
177
|
|
|
if (preg_match('#multipart/form-data;.*boundary="?([^;"]{1,70})(?:"|;|$)#Ui', $content_type, $matches)) { |
178
|
|
|
$parts = $this->parse_multipart_into_parts($this->data_stream, trim($matches[1])) ?: []; |
179
|
|
|
list($this->data, $files) = $this->parse_multipart_analyze_parts($this->data_stream, $parts); |
180
|
|
|
$this->files = $this->normalize_files($files); |
181
|
|
|
} |
182
|
|
|
} |
183
|
|
|
/** |
184
|
|
|
* Parse content stream |
185
|
|
|
* |
186
|
|
|
* @param resource $stream |
187
|
|
|
* @param string $boundary |
188
|
|
|
* |
189
|
|
|
* @return array[]|false |
|
|
|
|
190
|
|
|
* |
191
|
|
|
* @throws ExitException |
192
|
|
|
*/ |
193
|
|
|
protected function parse_multipart_into_parts ($stream, $boundary) { |
194
|
|
|
$parts = []; |
195
|
|
|
$crlf = "\r\n"; |
196
|
|
|
$position = 0; |
197
|
|
|
$body = ''; |
198
|
|
|
$result = $this->parse_multipart_find($stream, $body, "--$boundary$crlf"); |
199
|
|
|
if ($result === false) { |
200
|
|
|
return false; |
201
|
|
|
} |
202
|
|
|
list($offset, $body) = $result; |
203
|
|
|
/** |
204
|
|
|
* strlen doesn't take into account trailing CRLF since we'll need it in loop below |
205
|
|
|
*/ |
206
|
|
|
$position += $offset + strlen("--$boundary"); |
207
|
|
|
$body = substr($body, strlen("--$boundary")); |
208
|
|
|
$body .= fread($stream, 1024); |
209
|
|
|
/** |
210
|
|
|
* Each part always starts with CRLF |
211
|
|
|
*/ |
212
|
|
|
while (strpos($body, $crlf) === 0) { |
213
|
|
|
$position += 2; |
214
|
|
|
$body = substr($body, 2); |
215
|
|
|
$part = [ |
216
|
|
|
'headers' => [ |
217
|
|
|
'offset' => $position, |
218
|
|
|
'size' => 0 |
219
|
|
|
], |
220
|
|
|
'body' => [ |
221
|
|
|
'offset' => 0, |
222
|
|
|
'size' => 0 |
223
|
|
|
] |
224
|
|
|
]; |
225
|
|
|
if (strpos($body, $crlf) === 0) { |
226
|
|
|
/** |
227
|
|
|
* No headers |
228
|
|
|
*/ |
229
|
|
|
$position += 2; |
230
|
|
|
$body = substr($body, 2); |
231
|
|
|
} else { |
232
|
|
|
/** |
233
|
|
|
* Find headers end in order to determine size |
234
|
|
|
*/ |
235
|
|
|
$result = $this->parse_multipart_find($stream, $body, $crlf.$crlf); |
236
|
|
|
if ($result === false) { |
237
|
|
|
return false; |
238
|
|
|
} |
239
|
|
|
list($offset, $body) = $result; |
240
|
|
|
$part['headers']['size'] = $offset; |
241
|
|
|
$position += $offset + 4; |
242
|
|
|
$body = substr($body, 4); |
243
|
|
|
} |
244
|
|
|
$part['body']['offset'] = $position; |
245
|
|
|
/** |
246
|
|
|
* Find body end in order to determine its size |
247
|
|
|
*/ |
248
|
|
|
$result = $this->parse_multipart_find($stream, $body, "$crlf--$boundary"); |
249
|
|
|
if ($result === false) { |
250
|
|
|
return false; |
251
|
|
|
} |
252
|
|
|
list($offset, $body) = $result; |
253
|
|
|
$part['body']['size'] = $offset; |
254
|
|
|
$position += $offset + strlen("$crlf--$boundary"); |
255
|
|
|
$body = substr($body, strlen("$crlf--$boundary")); |
256
|
|
|
if (!$part['headers']['size']) { |
257
|
|
|
$parts[] = $part; |
258
|
|
|
} |
259
|
|
|
$body .= fread($stream, 1024); |
260
|
|
|
} |
261
|
|
|
/** |
262
|
|
|
* Last boundary after all parts ends with '--' and we don't care what rubbish happens after it |
263
|
|
|
*/ |
264
|
|
|
$post_max_size = $this->post_max_size(); |
265
|
|
|
if (0 !== strpos($body, '--')) { |
266
|
|
|
return false; |
267
|
|
|
} |
268
|
|
|
/** |
269
|
|
|
* Check whether body size is bigger than allowed limit |
270
|
|
|
*/ |
271
|
|
|
if ($position + strlen($body) > $post_max_size) { |
272
|
|
|
throw new ExitException(413); |
273
|
|
|
} |
274
|
|
|
return $parts; |
275
|
|
|
} |
276
|
|
|
/** |
277
|
|
|
* @param resource $stream |
278
|
|
|
* @param array[] $parts |
279
|
|
|
* |
280
|
|
|
* @return array[] |
281
|
|
|
*/ |
282
|
|
|
protected function parse_multipart_analyze_parts ($stream, $parts) { |
283
|
|
|
$data = []; |
284
|
|
|
$files = []; |
285
|
|
|
foreach ($parts as $part) { |
286
|
|
|
fseek($stream, $part['headers']['offset']); |
287
|
|
|
$headers = $this->parse_multipart_headers( |
288
|
|
|
fread($stream, $part['headers']['size']) |
289
|
|
|
); |
290
|
|
|
if ( |
291
|
|
|
!isset($headers['content-disposition'][0], $headers['content-disposition']['name']) || |
292
|
|
|
$headers['content-disposition'][0] != 'form-data' |
293
|
|
|
) { |
294
|
|
|
continue; |
295
|
|
|
} |
296
|
|
|
$name = $headers['content-disposition']['name']; |
297
|
|
|
if (isset($headers['content-disposition']['filename'])) { |
298
|
|
|
$file = [ |
299
|
|
|
'name' => $headers['content-disposition']['filename'], |
300
|
|
|
'type' => @$headers['content-type'] ?: 'application/octet-stream', |
301
|
|
|
'size' => $part['body']['size'], |
302
|
|
|
'tmp_name' => 'request-data://'.$part['body']['offset'].':'.$part['body']['size'], |
303
|
|
|
'error' => UPLOAD_ERR_OK |
304
|
|
|
]; |
305
|
|
|
if ($headers['content-disposition']['filename'] === '') { |
306
|
|
|
$file['type'] = ''; |
307
|
|
|
$file['tmp_name'] = ''; |
308
|
|
|
$file['error'] = UPLOAD_ERR_NO_FILE; |
309
|
|
|
} |
310
|
|
|
if ($file['size'] > $this->upload_max_file_size()) { |
311
|
|
|
$file['tmp_name'] = ''; |
312
|
|
|
$file['error'] = UPLOAD_ERR_INI_SIZE; |
313
|
|
|
} |
314
|
|
|
$this->parse_multipart_set_target($files, $name, $file); |
315
|
|
|
} else { |
316
|
|
|
if ($part['body']['size'] == 0) { |
317
|
|
|
$this->parse_multipart_set_target($data, $name, ''); |
318
|
|
|
} else { |
319
|
|
|
fseek($stream, $part['body']['offset']); |
320
|
|
|
$this->parse_multipart_set_target( |
321
|
|
|
$data, |
322
|
|
|
$name, |
323
|
|
|
fread($stream, $part['body']['size']) |
324
|
|
|
); |
325
|
|
|
} |
326
|
|
|
} |
327
|
|
|
} |
328
|
|
|
return [$data, $files]; |
329
|
|
|
} |
330
|
|
|
/** |
331
|
|
|
* @return int |
332
|
|
|
*/ |
333
|
|
|
protected function post_max_size () { |
334
|
|
|
$post_max_size = ini_get('post_max_size') ?: ini_get('hhvm.server.max_post_size'); |
335
|
|
|
switch (strtolower(substr($post_max_size, -1))) { |
336
|
|
|
case 'g'; |
337
|
|
|
$post_max_size = (int)$post_max_size * 1024; |
338
|
|
|
case 'm'; |
339
|
|
|
$post_max_size = (int)$post_max_size * 1024; |
340
|
|
|
case 'k'; |
341
|
|
|
$post_max_size = (int)$post_max_size * 1024; |
342
|
|
|
} |
343
|
|
|
return (int)$post_max_size ?: PHP_INT_MAX; |
344
|
|
|
} |
345
|
|
|
/** |
346
|
|
|
* @return int |
347
|
|
|
*/ |
348
|
|
|
protected function upload_max_file_size () { |
349
|
|
|
$upload_max_file_size = ini_get('upload_max_filesize') ?: ini_get('hhvm.server.upload.upload_max_file_size'); |
350
|
|
|
switch (strtolower(substr($upload_max_file_size, -1))) { |
351
|
|
|
case 'g'; |
352
|
|
|
$upload_max_file_size = (int)$upload_max_file_size * 1024; |
353
|
|
|
case 'm'; |
354
|
|
|
$upload_max_file_size = (int)$upload_max_file_size * 1024; |
355
|
|
|
case 'k'; |
356
|
|
|
$upload_max_file_size = (int)$upload_max_file_size * 1024; |
357
|
|
|
} |
358
|
|
|
return (int)$upload_max_file_size ?: PHP_INT_MAX; |
359
|
|
|
} |
360
|
|
|
/** |
361
|
|
|
* @param resource $stream |
362
|
|
|
* @param string $next_data |
363
|
|
|
* @param string $target |
364
|
|
|
* |
365
|
|
|
* @return array|false |
|
|
|
|
366
|
|
|
*/ |
367
|
|
|
protected function parse_multipart_find ($stream, $next_data, $target) { |
368
|
|
|
$offset = 0; |
369
|
|
|
$prev_data = ''; |
370
|
|
|
while (($found = strpos($prev_data.$next_data, $target)) === false) { |
371
|
|
|
if (feof($stream)) { |
372
|
|
|
return false; |
373
|
|
|
} |
374
|
|
|
if ($prev_data) { |
375
|
|
|
$offset += strlen($prev_data); |
376
|
|
|
} |
377
|
|
|
$prev_data = $next_data; |
378
|
|
|
$next_data = fread($stream, 1024); |
379
|
|
|
} |
380
|
|
|
$offset += $found; |
381
|
|
|
$remainder = substr($prev_data.$next_data, $found); |
382
|
|
|
return [$offset, $remainder]; |
383
|
|
|
} |
384
|
|
|
/** |
385
|
|
|
* @param string $content |
386
|
|
|
* |
387
|
|
|
* @return array |
388
|
|
|
*/ |
389
|
|
|
protected function parse_multipart_headers ($content) { |
390
|
|
|
$headers = []; |
391
|
|
|
foreach (explode("\r\n", $content) as $header) { |
392
|
|
|
list($name, $value) = explode(':', $header, 2); |
393
|
|
|
if (!preg_match_all('/(.+)(?:="*?(.*)"?)?(?:;\s|$)/U', $value, $matches)) { |
394
|
|
|
continue; |
395
|
|
|
} |
396
|
|
|
$name = strtolower($name); |
397
|
|
|
$headers[$name] = []; |
398
|
|
|
foreach (array_keys($matches[1]) as $index) { |
399
|
|
|
if (isset($headers[$name][0]) || strlen($matches[2][$index])) { |
400
|
|
|
$headers[$name][trim($matches[1][$index])] = urldecode(trim($matches[2][$index])); |
401
|
|
|
} else { |
402
|
|
|
$headers[$name][] = trim($matches[1][$index]); |
403
|
|
|
} |
404
|
|
|
} |
405
|
|
|
if (count($headers[$name]) == 1) { |
406
|
|
|
$headers[$name] = $headers[$name][0]; |
407
|
|
|
} |
408
|
|
|
} |
409
|
|
|
return $headers; |
410
|
|
|
} |
411
|
|
|
/** |
412
|
|
|
* @param array $source |
413
|
|
|
* @param string $name |
414
|
|
|
* @param array|string $value |
415
|
|
|
*/ |
416
|
|
|
protected function parse_multipart_set_target (&$source, $name, $value) { |
417
|
|
|
preg_match_all('/(?:^|\[)([^\[\]]*)\]?/', $name, $matches); |
418
|
|
|
if ($matches[1][0] === '') { |
419
|
|
|
return; |
420
|
|
|
} |
421
|
|
|
foreach ($matches[1] as $component) { |
422
|
|
|
if (!isset($source[$component])) { |
423
|
|
|
$source[$component] = []; |
424
|
|
|
} |
425
|
|
|
if (!strlen($component)) { |
426
|
|
|
$source = &$source[$component][]; |
427
|
|
|
} else { |
428
|
|
|
$source = &$source[$component]; |
429
|
|
|
} |
430
|
|
|
} |
431
|
|
|
$source = $value; |
432
|
|
|
} |
433
|
|
|
} |
434
|
|
|
|
This check compares the return type specified in the
@return
annotation of a function or method doc comment with the types returned by the function and raises an issue if they mismatch.If the return type contains the type array, this check recommends the use of a more specific type like
String[]
orarray<String>
.