Completed
Push — master ( 674f63...1322e1 )
by Nazar
04:59
created

Data_and_files::data()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 1
dl 0
loc 3
ccs 2
cts 2
cp 1
crap 1
rs 10
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 36
	public function init_data_and_files ($data = [], $files = [], $data_stream = null, $copy_stream = true) {
57 36
		if (is_resource($this->data_stream)) {
58 6
			fclose($this->data_stream);
59
		}
60 36
		$this->data        = $data;
61 36
		$this->files       = $this->normalize_files($files);
62 36
		$this->data_stream = null;
63 36
		if (in_array($this->method, ['GET', 'HEAD', 'OPTIONS'])) {
64 22
			return;
65
		}
66 14
		$data_stream = is_string($data_stream) ? fopen($data_stream, 'rb') : $data_stream;
67 14
		if (is_resource($data_stream)) {
68 12
			rewind($data_stream);
69 12
			if ($copy_stream) {
70 12
				$this->data_stream = fopen('php://temp', 'w+b');
71 12
				stream_copy_to_stream($data_stream, $this->data_stream);
72 12
				fclose($data_stream);
73
			} else {
74 4
				$this->data_stream = $data_stream;
75
			}
76
		}
77
		/**
78
		 * If we don't appear to have any data or files detected - probably, we need to parse request ourselves
79
		 */
80 14
		if (!$this->data && !$this->files && is_resource($this->data_stream)) {
81 8
			$this->parse_data_stream();
82
		}
83
		// Hack: for compatibility we'll override $_POST since it might be filled during parsing
84 14
		$_POST = $this->data;
85 14
	}
86
	/**
87
	 * Get data item by name
88
	 *
89
	 * @param string[]|string[][] $name
90
	 *
91
	 * @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
92
	 *                            missing key will cause the whole thing to fail)
93
	 */
94 32
	public function data (...$name) {
95 32
		return $this->get_property_items('data', $name);
1 ignored issue
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?

This check looks for methods that are used by a trait but not required by it.

To illustrate, let’s look at the following code example

trait Idable {
    public function equalIds(Idable $other) {
        return $this->getId() === $other->getId();
    }
}

The trait Idable provides a method equalsId that in turn relies on the method getId(). If this method does not exist on a class mixing in this trait, the method will fail.

Adding the getId() as an abstract method to the trait will make sure it is available.

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