Data_and_files::prepare_data_stream()   A
last analyzed

Complexity

Conditions 4
Paths 6

Size

Total Lines 13
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 4

Importance

Changes 0
Metric Value
cc 4
eloc 10
nc 6
nop 2
dl 0
loc 13
rs 9.2
c 0
b 0
f 0
ccs 11
cts 11
cp 1
crap 4
1
<?php
2
/**
3
 * @package CleverStyle Framework
4
 * @author  Nazar Mokrynskyi <[email protected]>
5
 * @license 0BSD
6
 */
7
namespace cs\Request;
8
use
9
	UnexpectedValueException,
10
	cs\ExitException,
11
	nazarpc\Stream_slicer;
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 78
	public function init_data_and_files ($data = [], $files = [], $data_stream = null, $copy_stream = true) {
56 78
		if (is_resource($this->data_stream)) {
57 27
			fclose($this->data_stream);
58
		}
59 78
		if (in_array($this->method, ['GET', 'HEAD', 'OPTIONS'])) {
0 ignored issues
show
Bug Best Practice introduced by
The property method does not exist on cs\Request\Data_and_files. Did you maybe forget to declare it?
Loading history...
60 42
			$this->data        = [];
61 42
			$this->files       = [];
62 42
			$this->data_stream = null;
63 42
			return;
64
		}
65 36
		$this->data        = $data;
66 36
		$this->files       = $this->normalize_files($files);
67 36
		$this->data_stream = $this->prepare_data_stream($data_stream, $copy_stream);
68
		/**
69
		 * If we don't appear to have any data or files detected - probably, we need to parse request ourselves
70
		 */
71 36
		if (!$this->data && !$this->files && is_resource($this->data_stream)) {
72 33
			$this->parse_data_stream();
73
		}
74
		// Hack: for compatibility we'll override $_POST since it might be filled during parsing
75 36
		$_POST = $this->data;
76 36
	}
77
	/**
78
	 * Get data item by name
79
	 *
80
	 * @param string[]|string[][] $name
81
	 *
82
	 * @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
83
	 *                            missing key will cause the whole thing to fail)
84
	 */
85 60
	public function data (...$name) {
86 60
		return $this->get_property_items('data', $name);
0 ignored issues
show
Bug introduced by
It seems like get_property_items() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

86
		return $this->/** @scrutinizer ignore-call */ get_property_items('data', $name);
Loading history...
87
	}
88
	/**
89
	 * Get file item by name
90
	 *
91
	 * @param string $name
92
	 *
93
	 * @return array|null File item if exists or `null` otherwise
94
	 */
95 3
	public function files ($name) {
96 3
		return @$this->files[$name];
97
	}
98
	/**
99
	 * @param array[] $files
100
	 * @param string  $file_path
101
	 *
102
	 * @return array[]
103
	 */
104 36
	protected function normalize_files ($files, $file_path = '') {
105 36
		if (!$files) {
106 33
			return $files;
107
		}
108 9
		$this->register_request_file_stream_wrapper();
109 9
		if (!isset($files['name'])) {
110 9
			foreach ($files as $field => &$file) {
111 9
				$file = $this->normalize_files($file, "$file_path/$field");
112
			}
113 9
			return $files;
114
		}
115 9
		if (is_array($files['name'])) {
0 ignored issues
show
introduced by
The condition is_array($files['name']) is always true.
Loading history...
116 3
			$result = [];
117 3
			foreach (array_keys($files['name']) as $index) {
118 3
				$result[] = $this->normalize_file(
119
					[
120 3
						'name'     => $files['name'][$index],
121 3
						'type'     => $files['type'][$index],
122 3
						'size'     => $files['size'][$index],
123 3
						'tmp_name' => @$files['tmp_name'][$index],
124 3
						'stream'   => @$files['stream'][$index],
125 3
						'error'    => $files['error'][$index]
126
					],
127 3
					"$file_path/$index"
128
				);
129
			}
130 3
			return $result;
131
		} else {
132 9
			return $this->normalize_file($files, $file_path);
133
		}
134
	}
135 9
	protected function register_request_file_stream_wrapper () {
136 9
		if (!in_array('request-file', stream_get_wrappers())) {
137
			stream_wrapper_register('request-file', File_stream::class);
138
		}
139 9
	}
140
	/**
141
	 * @param array  $file
142
	 * @param string $file_path
143
	 *
144
	 * @return array
145
	 */
146 9
	protected function normalize_file ($file, $file_path) {
147
		$file += [
148 9
			'tmp_name' => null,
149
			'stream'   => null
150
		];
151 9
		if (isset($file['tmp_name']) && $file['stream'] === null) {
152 3
			$file['stream'] = fopen($file['tmp_name'], 'rb');
153
		}
154 9
		if (isset($file['stream']) && !$file['tmp_name']) {
155 9
			$file['tmp_name'] = "request-file://$file_path";
156
		}
157 9
		if ($file['tmp_name'] === null && $file['stream'] === null) {
158 6
			$file['error'] = UPLOAD_ERR_NO_FILE;
159
		}
160 9
		return $file;
161
	}
162
	/**
163
	 * @param null|resource|string $data_stream
164
	 * @param bool                 $copy_stream
165
	 *
166
	 * @return null|resource
167
	 */
168 36
	protected function prepare_data_stream ($data_stream, $copy_stream) {
169 36
		$data_stream = is_string($data_stream) ? fopen($data_stream, 'rb') : $data_stream;
170 36
		if (!is_resource($data_stream)) {
171 3
			return null;
172
		}
173 33
		if (!$copy_stream) {
174 6
			return $data_stream;
175
		}
176 33
		$new_data_stream = fopen('php://temp', 'w+b');
177 33
		rewind($data_stream);
178 33
		stream_copy_to_stream($data_stream, $new_data_stream);
0 ignored issues
show
Bug introduced by
It seems like $new_data_stream can also be of type false; however, parameter $dest of stream_copy_to_stream() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

178
		stream_copy_to_stream($data_stream, /** @scrutinizer ignore-type */ $new_data_stream);
Loading history...
179 33
		fclose($data_stream);
180 33
		return $new_data_stream;
181
	}
182
	/**
183
	 * Parsing request body for following Content-Type: `application/json`, `application/x-www-form-urlencoded` and `multipart/form-data`
184
	 *
185
	 * @throws ExitException
186
	 */
187 33
	protected function parse_data_stream () {
188 33
		$content_type = $this->header('content-type');
0 ignored issues
show
Bug introduced by
The method header() does not exist on cs\Request\Data_and_files. Did you maybe mean parse_multipart_headers()? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

188
		/** @scrutinizer ignore-call */ 
189
  $content_type = $this->header('content-type');

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
189 33
		rewind($this->data_stream);
190
		/**
191
		 * application/json
192
		 */
193 33
		if (preg_match('#^application/([^+\s]+\+)?json#', $content_type)) {
194 3
			$this->data = _json_decode(stream_get_contents($this->data_stream)) ?: [];
195 3
			return;
196
		}
197
		/**
198
		 * application/x-www-form-urlencoded
199
		 */
200 30
		if (strpos($content_type, 'application/x-www-form-urlencoded') === 0) {
201 3
			@parse_str(stream_get_contents($this->data_stream), $this->data);
0 ignored issues
show
Bug introduced by
Are you sure the usage of parse_str(stream_get_con...a_stream), $this->data) is correct as it seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
202 3
			return;
203
		}
204
		/**
205
		 * multipart/form-data
206
		 */
207 27
		if (preg_match('#multipart/form-data;.*boundary="?([^;"]{1,70})(?:"|;|$)#Ui', $content_type, $matches)) {
208
			try {
209 6
				$parts = $this->parse_multipart_into_parts($this->data_stream, trim($matches[1])) ?: [];
210 6
				list($this->data, $files) = $this->parse_multipart_analyze_parts($this->data_stream, $parts);
211 6
				$this->files = $this->normalize_files($files);
212 3
			} catch (UnexpectedValueException $e) {
213
				// Do nothing, if parsing failed then we'll just leave `::$data` and `::$files` empty
214
			}
215
		}
216 27
	}
217
	/**
218
	 * Parse content stream
219
	 *
220
	 * @param resource $stream
221
	 * @param string   $boundary
222
	 *
223
	 * @return array[]|false
224
	 *
225
	 * @throws UnexpectedValueException
226
	 * @throws ExitException
227
	 */
228 6
	protected function parse_multipart_into_parts ($stream, $boundary) {
229 6
		$parts    = [];
230 6
		$crlf     = "\r\n";
231 6
		$position = 0;
232 6
		$body     = '';
233 6
		list($offset, $body) = $this->parse_multipart_find($stream, $body, "--$boundary$crlf");
234
		/**
235
		 * strlen doesn't take into account trailing CRLF since we'll need it in loop below
236
		 */
237 6
		$position += $offset + strlen("--$boundary");
238 6
		$body = substr($body, strlen("--$boundary"));
239
		/**
240
		 * Each part always starts with CRLF
241
		 */
242 6
		while (strpos($body, $crlf) === 0) {
243 6
			$position += 2;
244 6
			$body = substr($body, 2);
245
			$part = [
246 6
				'headers' => [
247 6
					'offset' => $position,
248 6
					'size'   => 0
249
				],
250
				'body'    => [
251
					'offset' => 0,
252
					'size'   => 0
253
				]
254
			];
255 6
			if (strpos($body, $crlf) === 0) {
256
				/**
257
				 * No headers
258
				 */
259 3
				$position += 2;
260 3
				$body = substr($body, 2);
261
			} else {
262
				/**
263
				 * Find headers end in order to determine size
264
				 */
265 6
				list($offset, $body) = $this->parse_multipart_find($stream, $body, $crlf.$crlf);
266 6
				$part['headers']['size'] = $offset;
267 6
				$position += $offset + 4;
268 6
				$body = substr($body, 4);
269
			}
270 6
			$part['body']['offset'] = $position;
271
			/**
272
			 * Find body end in order to determine its size
273
			 */
274 6
			list($offset, $body) = $this->parse_multipart_find($stream, $body, "$crlf--$boundary");
275 6
			$part['body']['size'] = $offset;
276 6
			$position += $offset + strlen("$crlf--$boundary");
277 6
			$body = substr($body, strlen("$crlf--$boundary"));
278 6
			if ($part['headers']['size']) {
279 6
				$parts[] = $part;
280
			}
281
		}
282
		/**
283
		 * Last boundary after all parts ends with '--' and we don't care what rubbish happens after it
284
		 */
285 6
		$post_max_size = $this->post_max_size();
286 6
		if (strpos($body, '--') !== 0) {
287 3
			return false;
288
		}
289
		/**
290
		 * Check whether body size is bigger than allowed limit
291
		 */
292 6
		if ($position + strlen($body) > $post_max_size) {
293 3
			throw new ExitException(413);
294
		}
295 6
		return $parts;
296
	}
297
	/**
298
	 * @param resource $stream
299
	 * @param array[]  $parts
300
	 *
301
	 * @return array[]
302
	 */
303 6
	protected function parse_multipart_analyze_parts ($stream, $parts) {
304 6
		$data  = [];
305 6
		$files = [];
306 6
		foreach ($parts as $part) {
307 6
			$headers = $this->parse_multipart_headers(
308 6
				stream_get_contents($stream, $part['headers']['size'], $part['headers']['offset'])
309
			);
310 6
			if (!$this->parse_multipart_analyze_parts_is_valid($headers)) {
311 3
				continue;
312
			}
313 6
			$name = $headers['content-disposition']['name'];
314 6
			if (isset($headers['content-disposition']['filename'])) {
315 6
				$file = $this->parse_multipart_analyze_parts_file($headers, $stream, $part['body']['offset'], $part['body']['size']);
316 6
				$this->parse_multipart_set_target($files, $name, $file);
317
			} else {
318 6
				$content = $this->parse_multipart_analyze_parts_content($stream, $part['body']['offset'], $part['body']['size']);
319 6
				$this->parse_multipart_set_target($data, $name, $content);
320
			}
321
		}
322 6
		return [$data, $files];
323
	}
324
	/**
325
	 * @param array $headers
326
	 *
327
	 * @return bool
328
	 */
329 6
	protected function parse_multipart_analyze_parts_is_valid ($headers) {
330
		return
331 6
			isset($headers['content-disposition'][0], $headers['content-disposition']['name']) &&
332 6
			$headers['content-disposition'][0] == 'form-data';
333
	}
334
	/**
335
	 * @param array    $headers
336
	 * @param resource $stream
337
	 * @param int      $offset
338
	 * @param int      $size
339
	 *
340
	 * @return array
341
	 */
342 6
	protected function parse_multipart_analyze_parts_file ($headers, $stream, $offset, $size) {
343
		$file = [
344 6
			'name'   => $headers['content-disposition']['filename'],
345 6
			'type'   => @$headers['content-type'] ?: 'application/octet-stream',
346 6
			'size'   => $size,
347 6
			'stream' => Stream_slicer::slice($stream, $offset, $size),
348 6
			'error'  => UPLOAD_ERR_OK
349
		];
350 6
		if ($file['name'] === '') {
351 3
			$file['type']   = '';
352 3
			$file['stream'] = null;
353 3
			$file['error']  = UPLOAD_ERR_NO_FILE;
354 6
		} elseif ($file['size'] > $this->upload_max_file_size()) {
355 3
			$file['stream'] = null;
356 3
			$file['error']  = UPLOAD_ERR_INI_SIZE;
357
		}
358 6
		return $file;
359
	}
360
	/**
361
	 * @param resource $stream
362
	 * @param int      $offset
363
	 * @param int      $size
364
	 *
365
	 * @return string
366
	 */
367 6
	protected function parse_multipart_analyze_parts_content ($stream, $offset, $size) {
368 6
		return $size ? stream_get_contents($stream, $size, $offset) : '';
369
	}
370
	/**
371
	 * @return int
372
	 */
373 6
	protected function post_max_size () {
374 6
		return $this->convert_size_to_bytes(ini_get('post_max_size'));
375
	}
376
	/**
377
	 * @return int
378
	 */
379 6
	protected function upload_max_file_size () {
380 6
		return $this->convert_size_to_bytes(ini_get('upload_max_filesize'));
381
	}
382
	/**
383
	 * @param int|string $size
384
	 *
385
	 * @return int
386
	 */
387 6
	protected function convert_size_to_bytes ($size) {
388 6
		switch (strtolower(substr($size, -1))) {
389 6
			case 'g';
390 3
				$size = (int)$size * 1024;
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment if this fall-through is intended.
Loading history...
391 6
			case 'm';
392 3
				$size = (int)$size * 1024;
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment if this fall-through is intended.
Loading history...
393 3
			case 'k';
394 6
				$size = (int)$size * 1024;
395
		}
396 6
		return (int)$size ?: PHP_INT_MAX;
397
	}
398
	/**
399
	 * @param resource $stream
400
	 * @param string   $next_data
401
	 * @param string   $target
402
	 *
403
	 * @return array
404
	 *
405
	 * @throws UnexpectedValueException
406
	 */
407 6
	protected function parse_multipart_find ($stream, $next_data, $target) {
408 6
		$offset    = 0;
409 6
		$prev_data = '';
410 6
		while (($found = strpos($prev_data.$next_data, $target)) === false) {
411 6
			if (feof($stream)) {
412 3
				throw new UnexpectedValueException;
413
			}
414 6
			if ($prev_data) {
415 3
				$offset += strlen($prev_data);
416
			}
417 6
			$prev_data = $next_data;
418 6
			$next_data = fread($stream, 1024);
419
		}
420 6
		$offset += $found;
421
		/**
422
		 * Read some more bytes so that we'll always have some remainder in place, since empty remainder might cause problems with `strpos()` call later
423
		 */
424 6
		$remainder = substr($prev_data.$next_data, $found).(fread($stream, 1024) ?: '');
425 6
		return [$offset, $remainder];
426
	}
427
	/**
428
	 * @param string $content
429
	 *
430
	 * @return array
431
	 */
432 6
	protected function parse_multipart_headers ($content) {
433 6
		$headers = [];
434 6
		foreach (explode("\r\n", $content) as $header) {
435 6
			list($name, $value) = explode(':', $header, 2);
436 6
			if (!preg_match_all('/(.+)(?:="?([^"]*)"?)?(?:;\s|$)/U', $value, $matches)) {
437 3
				continue;
438
			}
439 6
			$name           = strtolower($name);
440 6
			$headers[$name] = [];
441 6
			foreach (array_keys($matches[1]) as $index) {
442 6
				if (isset($headers[$name][0]) || strlen($matches[2][$index])) {
443 6
					$headers[$name][trim($matches[1][$index])] = urldecode(trim($matches[2][$index]));
444
				} else {
445 6
					$headers[$name][] = trim($matches[1][$index]);
446
				}
447
			}
448 6
			if (count($headers[$name]) == 1) {
449 6
				$headers[$name] = @$headers[$name][0];
450
			}
451
		}
452 6
		return $headers;
453
	}
454
	/**
455
	 * @param array        $source
456
	 * @param string       $name
457
	 * @param array|string $value
458
	 */
459 6
	protected function parse_multipart_set_target (&$source, $name, $value) {
460 6
		preg_match_all('/(?:^|\[)([^\[\]]*)\]?/', $name, $matches);
461 6
		if ($matches[1][0] === '') {
462 3
			return;
463
		}
464 6
		foreach ($matches[1] as $component) {
465 6
			if (!strlen($component)) {
466 3
				$source = &$source[];
467
			} else {
468 6
				if (!isset($source[$component])) {
469 6
					$source[$component] = [];
470
				}
471 6
				$source = &$source[$component];
472
			}
473
		}
474 6
		$source = $value;
475 6
	}
476
}
477