Completed
Push — master ( ec1b10...7992f0 )
by Nazar
05:23
created

Data_and_files::parse_multipart_analyze_parts()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 21
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 15
CRAP Score 4

Importance

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