Completed
Push — master ( 61475a...8376f2 )
by Nazar
04:08
created

Data_and_files::parse_multipart()   D

Complexity

Conditions 17
Paths 49

Size

Total Lines 129
Code Lines 86

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 129
rs 4.8361
cc 17
eloc 86
nc 49
nop 2

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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");
1 ignored issue
show
Documentation introduced by
$file is of type array|null, but the function expects a array<integer,array>.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
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');
1 ignored issue
show
Bug introduced by
The method header() does not exist on cs\Request\Data_and_files. Did you maybe mean parse_multipart_headers()?

This check marks calls to methods that do not seem to exist on an object.

This is most likely the result of a method being renamed without all references to it being renamed likewise.

Loading history...
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
			list($this->data, $files) = $this->parse_multipart($this->data_stream, trim($matches[1])) ?: [[], []];
179
			$this->files = $this->normalize_files($files);
180
		}
181
	}
182
	/**
183
	 * Parse content stream
184
	 *
185
	 * @param resource $stream
186
	 * @param string   $boundary
187
	 *
188
	 * @return array[]|bool
0 ignored issues
show
Documentation introduced by
Consider making the return type a bit more specific; maybe use false|array[].

This check looks for the generic type array as a return type and suggests a more specific type. This type is inferred from the actual code.

Loading history...
189
	 *
190
	 * @throws ExitException
191
	 */
192
	protected function parse_multipart ($stream, $boundary) {
193
		$parts    = [];
194
		$crlf     = "\r\n";
195
		$position = 0;
196
		$body     = '';
197
		$result   = $this->parse_multipart_find($stream, $body, "--$boundary$crlf");
198
		if ($result === false) {
199
			return false;
200
		}
201
		list($offset, $body) = $result;
202
		/**
203
		 * strlen doesn't take into account trailing CRLF since we'll need it in loop below
204
		 */
205
		$position += $offset + strlen("--$boundary");
206
		$body = substr($body, strlen("--$boundary"));
207
		$body .= fread($stream, 1024);
208
		/**
209
		 * Each part always starts with CRLF
210
		 */
211
		while (strpos($body, $crlf) === 0) {
212
			$position += 2;
213
			$body = substr($body, 2);
214
			$part = [
215
				'headers' => [
216
					'offset' => $position,
217
					'size'   => 0
218
				],
219
				'body'    => [
220
					'offset' => 0,
221
					'size'   => 0
222
				]
223
			];
224
			if (strpos($body, $crlf) === 0) {
225
				/**
226
				 * No headers
227
				 */
228
				$position += 2;
229
				$body = substr($body, 2);
230
			} else {
231
				/**
232
				 * Find headers end in order to determine size
233
				 */
234
				$result = $this->parse_multipart_find($stream, $body, $crlf.$crlf);
235
				if ($result === false) {
236
					return false;
237
				}
238
				list($offset, $body) = $result;
239
				$part['headers']['size'] = $offset;
240
				$position += $offset + 4;
241
				$body = substr($body, 4);
242
			}
243
			$part['body']['offset'] = $position;
244
			/**
245
			 * Find body end in order to determine its size
246
			 */
247
			$result = $this->parse_multipart_find($stream, $body, "$crlf--$boundary");
248
			if ($result === false) {
249
				return false;
250
			}
251
			list($offset, $body) = $result;
252
			$part['body']['size'] = $offset;
253
			$position += $offset + strlen("$crlf--$boundary");
254
			$body    = substr($body, strlen("$crlf--$boundary"));
255
			$parts[] = $part;
256
			$body .= fread($stream, 1024);
257
		}
258
		/**
259
		 * Last boundary after all parts ends with '--' and we don't care what rubbish happens after it
260
		 */
261
		$post_max_size = $this->post_max_size();
262
		if (0 !== strpos($body, '--')) {
263
			return false;
264
		}
265
		/**
266
		 * Check whether body size is bigger than allowed limit
267
		 */
268
		if ($position + strlen($body) > $post_max_size) {
269
			throw new ExitException(413);
270
		}
271
		$data  = [];
272
		$files = [];
273
		foreach ($parts as $part) {
274
			if (!$part['headers']['size']) {
275
				continue;
276
			}
277
			fseek($stream, $part['headers']['offset']);
278
			$headers = $this->parse_multipart_headers(
279
				fread($stream, $part['headers']['size'])
280
			);
281
			if (
282
				!isset($headers['content-disposition'][0], $headers['content-disposition']['name']) ||
283
				$headers['content-disposition'][0] != 'form-data'
284
			) {
285
				continue;
286
			}
287
			$name = $headers['content-disposition']['name'];
288
			if (isset($headers['content-disposition']['filename'])) {
289
				$file = [
290
					'name'     => $headers['content-disposition']['filename'],
291
					'type'     => @$headers['content-type'] ?: 'application/octet-stream',
292
					'size'     => $part['body']['size'],
293
					'tmp_name' => 'request-data://'.$part['body']['offset'].':'.$part['body']['size'],
294
					'error'    => UPLOAD_ERR_OK
295
				];
296
				if ($headers['content-disposition']['filename'] === '') {
297
					$file['type']     = '';
298
					$file['tmp_name'] = '';
299
					$file['error']    = UPLOAD_ERR_NO_FILE;
300
				}
301
				if ($file['size'] > $this->upload_max_file_size()) {
302
					$file['tmp_name'] = '';
303
					$file['error']    = UPLOAD_ERR_INI_SIZE;
304
				}
305
				$this->parse_multipart_set_target($files, $name, $file);
306
			} else {
307
				if ($part['body']['size'] == 0) {
308
					$this->parse_multipart_set_target($data, $name, '');
309
				} else {
310
					fseek($stream, $part['body']['offset']);
311
					$this->parse_multipart_set_target(
312
						$data,
313
						$name,
314
						fread($stream, $part['body']['size'])
315
					);
316
				}
317
			}
318
		}
319
		return [$data, $files];
320
	}
321
	/**
322
	 * @return int
0 ignored issues
show
Documentation introduced by
Should the return type not be integer|string?

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.

Loading history...
323
	 */
324
	protected function post_max_size () {
325
		$post_max_size = ini_get('post_max_size') ?: ini_get('hhvm.server.max_post_size');
326
		switch (strtolower(substr($post_max_size, -1))) {
327
			case 'g';
328
				$post_max_size = (int)$post_max_size * 1024;
329
			case 'm';
330
				$post_max_size = (int)$post_max_size * 1024;
331
			case 'k';
332
				$post_max_size = (int)$post_max_size * 1024;
333
		}
334
		return $post_max_size ?: PHP_INT_MAX;
335
	}
336
	/**
337
	 * @return int
0 ignored issues
show
Documentation introduced by
Should the return type not be integer|string?

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.

Loading history...
338
	 */
339
	protected function upload_max_file_size () {
340
		$upload_max_file_size = ini_get('upload_max_filesize') ?: ini_get('hhvm.server.upload.upload_max_file_size');
341
		switch (strtolower(substr($upload_max_file_size, -1))) {
342
			case 'g';
343
				$upload_max_file_size = (int)$upload_max_file_size * 1024;
344
			case 'm';
345
				$upload_max_file_size = (int)$upload_max_file_size * 1024;
346
			case 'k';
347
				$upload_max_file_size = (int)$upload_max_file_size * 1024;
348
		}
349
		return $upload_max_file_size ?: PHP_INT_MAX;
350
	}
351
	/**
352
	 * @param resource $stream
353
	 * @param string   $next_data
354
	 * @param string   $target
355
	 *
356
	 * @return array|bool
0 ignored issues
show
Documentation introduced by
Consider making the return type a bit more specific; maybe use false|array<integer|string>.

This check looks for the generic type array as a return type and suggests a more specific type. This type is inferred from the actual code.

Loading history...
357
	 */
358
	protected function parse_multipart_find ($stream, $next_data, $target) {
359
		$offset    = 0;
360
		$prev_data = '';
361
		while (($found = strpos($prev_data.$next_data, $target)) === false) {
362
			if (feof($stream)) {
363
				return false;
364
			}
365
			if ($prev_data) {
366
				$offset += strlen($prev_data);
367
			}
368
			$prev_data = $next_data;
369
			$next_data = fread($stream, 1024);
370
		}
371
		$offset += $found;
372
		$remainder = substr($prev_data.$next_data, $found);
373
		return [$offset, $remainder];
374
	}
375
	/**
376
	 * @param string $content
377
	 *
378
	 * @return array
379
	 */
380
	protected function parse_multipart_headers ($content) {
381
		$headers = [];
382
		foreach (explode("\r\n", $content) as $header) {
383
			list($name, $value) = explode(':', $header, 2);
384
			if (!preg_match_all('/(.+)(?:="*?(.*)"?)?(?:;\s|$)/U', $value, $matches)) {
385
				continue;
386
			}
387
			$name           = strtolower($name);
388
			$headers[$name] = [];
389
			foreach (array_keys($matches[1]) as $index) {
390
				if (isset($headers[$name][0]) || strlen($matches[2][$index])) {
391
					$headers[$name][trim($matches[1][$index])] = urldecode(trim($matches[2][$index]));
392
				} else {
393
					$headers[$name][] = trim($matches[1][$index]);
394
				}
395
			}
396
			if (count($headers[$name]) == 1) {
397
				$headers[$name] = $headers[$name][0];
398
			}
399
		}
400
		return $headers;
401
	}
402
	/**
403
	 * @param array        $source
404
	 * @param string       $name
405
	 * @param array|string $value
406
	 */
407
	protected function parse_multipart_set_target (&$source, $name, $value) {
408
		preg_match_all('/(?:^|\[)([^\[\]]*)\]?/', $name, $matches);
409
		if ($matches[1][0] === '') {
410
			return;
411
		}
412
		foreach ($matches[1] as $component) {
413
			if (!isset($source[$component])) {
414
				$source[$component] = [];
415
			}
416
			if (!strlen($component)) {
417
				$source = &$source[$component][];
418
			} else {
419
				$source = &$source[$component];
420
			}
421
		}
422
		$source = $value;
423
	}
424
}
425