Completed
Push — master ( 8fc219...0492d9 )
by Nazar
04:06
created

Data_and_files::parse_multipart_set_target()   B

Complexity

Conditions 5
Paths 6

Size

Total Lines 17
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

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