Completed
Push — master ( 349760...349239 )
by Nazar
11:40
created

Data_and_files::parse_multipart_analyze_parts()   D

Complexity

Conditions 9
Paths 10

Size

Total Lines 45
Code Lines 35

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 90

Importance

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