Completed
Push — master ( 13e14b...ac8fcc )
by Nazar
04:25
created

Data_and_files::parse_data_stream()   C

Complexity

Conditions 7
Paths 12

Size

Total Lines 30
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

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