Skip to content

Instantly share code, notes, and snippets.

@devmycloud
Last active December 6, 2023 15:22
Show Gist options
  • Save devmycloud/df28012101fbc55d8de1737762b70348 to your computer and use it in GitHub Desktop.
Save devmycloud/df28012101fbc55d8de1737762b70348 to your computer and use it in GitHub Desktop.
Process php://input to get multipart/form-data parameters for PATCH API request
<?php
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\File\UploadedFile;
/**
* stream - Handle raw input stream
*
* LICENSE: This source file is subject to version 3.01 of the GPL license
* that is available through the world-wide-web at the following URI:
* http://www.gnu.org/licenses/gpl.html. If you did not receive a copy of
* the GPL License and are unable to obtain it through the web, please
*
* @author jason.gerfen@gmail.com
* @license http://www.gnu.org/licenses/gpl.html GPL License 3
*
* Massive modifications by TGE (dev@mycloudfulfillment.com) to support
* proper parameter name processing and Laravel compatible UploadedFile
* support. Class name changed to be more descriptive and less likely to
* collide.
*
* Original Gist at:
* https://gist.github.com/jas-/5c3fdc26fedd11cb9fb5#file-class-stream-php
*
*/
class ParseInputStream
{
/**
* @abstract Raw input stream
*/
protected $input;
/**
* @function __construct
*
* @param array $data stream
*/
public function __construct(array &$data)
{
$this->input = file_get_contents('php://input');
$boundary = $this->boundary();
if (!strlen($boundary)) {
$data = [
'parameters' => $this->parse(),
'files' => []
];
} else {
$blocks = $this->split($boundary);
$data = $this->blocks($blocks);
}
return $data;
}
/**
* @function boundary
* @returns string
*/
private function boundary()
{
if(!isset($_SERVER['CONTENT_TYPE'])) {
return null;
}
preg_match('/boundary=(.*)$/', $_SERVER['CONTENT_TYPE'], $matches);
return $matches[1];
}
/**
* @function parse
* @returns array
*/
private function parse()
{
parse_str(urldecode($this->input), $result);
return $result;
}
/**
* @function split
* @param $boundary string
* @returns array
*/
private function split($boundary)
{
$result = preg_split("/-+$boundary/", $this->input);
array_pop($result);
return $result;
}
/**
* @function blocks
* @param $array array
* @returns array
*/
private function blocks($array)
{
$results = [];
foreach($array as $key => $value)
{
if (empty($value))
continue;
$block = $this->decide($value);
foreach ( $block['parameters'] as $key => $val ) {
$this->parse_parameter( $results, $key, $val );
}
foreach ( $block['files'] as $key => $val ) {
$this->parse_parameter( $results, $key, $val );
}
}
return $results;
}
/**
* @function decide
* @param $string string
* @returns array
*/
private function decide($string)
{
if (strpos($string, 'application/octet-stream') !== FALSE)
{
return [
'parameters' => $this->file($string),
'files' => []
];
}
if (strpos($string, 'filename') !== FALSE)
{
return [
'parameters' => [],
'files' => $this->file_stream($string)
];
}
return [
'parameters' => $this->parameter($string),
'files' => []
];
}
/**
* @function file
*
* @param $string
*
* @return array
*/
private function file($string)
{
preg_match('/name=\"([^\"]*)\".*stream[\n|\r]+([^\n\r].*)?$/s', $string, $match);
return [
$match[1] => ($match[2] !== NULL ? $match[2] : '')
];
}
/**
* @function file_stream
*
* @param $string
*
* @return array
*/
private function file_stream($data)
{
$result = [];
$data = ltrim($data);
$idx = strpos( $data, "\r\n\r\n" );
if ( $idx === FALSE ) {
Log::warning( "ParseInputStream.file_stream(): Could not locate header separator in data:" );
Log::warning( $data );
} else {
$headers = substr( $data, 0, $idx );
$content = substr( $data, $idx + 4, -2 ); // Skip the leading \r\n and strip the final \r\n
$name = '-unknown-';
$filename = '-unknown-';
$filetype = 'application/octet-stream';
$header = strtok( $headers, "\r\n" );
while ( $header !== FALSE ) {
if ( substr($header, 0, strlen("Content-Disposition: ")) == "Content-Disposition: " ) {
// Content-Disposition: form-data; name="attach_file[TESTING]"; filename="label2.jpg"
if ( preg_match('/name=\"([^\"]*)\"/', $header, $nmatch ) ) {
$name = $nmatch[1];
}
if ( preg_match('/filename=\"([^\"]*)\"/', $header, $nmatch ) ) {
$filename = $nmatch[1];
}
} elseif ( substr($header, 0, strlen("Content-Type: ")) == "Content-Type: " ) {
// Content-Type: image/jpg
$filetype = trim( substr($header, strlen("Content-Type: ")) );
} else {
Log::debug( "PARSEINPUTSTREAM: Skipping Header: " . $header );
}
$header = strtok("\r\n");
}
if ( substr($data, -2) === "\r\n" ) {
$data = substr($data, 0, -2);
}
$path = sys_get_temp_dir() . '/php' . substr( sha1(rand()), 0, 6 );
$bytes = file_put_contents( $path, $content );
if ( $bytes !== FALSE ) {
$file = new UploadedFile( $path, $filename, $filetype, $bytes, UPLOAD_ERR_OK );
$result = array( $name => $file );
}
}
return $result;
}
/**
* @function parameter
*
* @param $string
*
* @return array
*/
private function parameter($string)
{
$data = [];
if ( preg_match('/name=\"([^\"]*)\"[\n|\r]+([^\n\r].*)?\r$/s', $string, $match) ) {
if (preg_match('/^(.*)\[\]$/i', $match[1], $tmp)) {
$data[$tmp[1]][] = ($match[2] !== NULL ? $match[2] : '');
} else {
$data[$match[1]] = ($match[2] !== NULL ? $match[2] : '');
}
}
return $data;
}
/**
* @function merge
* @param $array array
*
* Ugly ugly ugly
*
* @returns array
*/
private function merge($array)
{
$results = [
'parameters' => [],
'files' => []
];
if (count($array['parameters']) > 0) {
foreach($array['parameters'] as $key => $value) {
foreach($value as $k => $v) {
if (is_array($v)) {
foreach($v as $kk => $vv) {
$results['parameters'][$k][] = $vv;
}
} else {
$results['parameters'][$k] = $v;
}
}
}
}
if (count($array['files']) > 0) {
foreach($array['files'] as $key => $value) {
foreach($value as $k => $v) {
if (is_array($v)) {
foreach($v as $kk => $vv) {
if(is_array($vv) && (count($vv) === 1)) {
$results['files'][$k][$kk] = $vv[0];
} else {
$results['files'][$k][$kk][] = $vv[0];
}
}
} else {
$results['files'][$k][$key] = $v;
}
}
}
}
return $results;
}
function parse_parameter( &$params, $parameter, $value ) {
if ( strpos($parameter, '[') !== FALSE ) {
$matches = array();
if ( preg_match( '/^([^[]*)\[([^]]*)\](.*)$/', $parameter, $match ) ) {
$name = $match[1];
$key = $match[2];
$rem = $match[3];
if ( $name !== '' && $name !== NULL ) {
if ( ! isset($params[$name]) || ! is_array($params[$name]) ) {
$params[$name] = array();
} else {
}
if ( strlen($rem) > 0 ) {
if ( $key === '' || $key === NULL ) {
$arr = array();
$this->parse_parameter( $arr, $rem, $value );
$params[$name][] = $arr;
} else {
if ( !isset($params[$name][$key]) || !is_array($params[$name][$key]) ) {
$params[$name][$key] = array();
}
$this->parse_parameter( $params[$name][$key], $rem, $value );
}
} else {
if ( $key === '' || $key === NULL ) {
$params[$name][] = $value;
} else {
$params[$name][$key] = $value;
}
}
} else {
if ( strlen($rem) > 0 ) {
if ( $key === '' || $key === NULL ) {
// REVIEW Is this logic correct?!
$this->parse_parameter( $params, $rem, $value );
} else {
if ( ! isset($params[$key]) || ! is_array($params[$key]) ) {
$params[$key] = array();
}
$this->parse_parameter( $params[$key], $rem, $value );
}
} else {
if ( $key === '' || $key === NULL ) {
$params[] = $value;
} else {
$params[$key] = $value;
}
}
}
} else {
Log::warning( "ParseInputStream.parse_parameter() Parameter name regex failed: '" . $parameter . "'" );
}
} else {
$params[$parameter] = $value;
}
}
}
@akoryak-solarwinds
Copy link

akoryak-solarwinds commented May 25, 2021

Thanks a lot! It was useful!

Some additions:

  1. do we really need function merge()? Didn't find any usage.

  2. i guess this class is only necessary for PUT and PATCH (POST works out of the box, OPTIONS/DELETE/HEAD/etc shouldn't send files), so middleware handler may looks like:

	public function handle($request, Closure $next)
	{
		if ($request->method() == 'PUT' || $request->method() == 'PATCH') {
			if (preg_match('/multipart\/form-data/', $request->headers->get('Content-Type'))) {
				$params = array();
				new ParseInputStream($params);
				$request->request->add($params);
			}
		}
		return $next($request);
	}
  1. lines 211-212, variable $data is not used anywhere after:
			if ( substr($data, -2) === "\r\n" ) {
				$data = substr($data, 0, -2);
			}

Guess, it suppose to be $content instead.

@Firman95
Copy link

Firman95 commented Jul 22, 2022

First thank you for this, been banging my head against a wall for the last few days on this, and of course to @t202wes for teh middleware!

Spotted a minor issue with it though, essentially it breaks down if empty/null values are sent through. Not wanting to strip them (we allow null values on updates for certain resources), so have modified this section slightly, to just cater for when a parameter doesn't have a supplied value (null).

Starts at line 248.

if (preg_match('/name=\"([^\"]*)\"[\n|\r]+([^\n\r].*)?\r$/s', $string, $match)) {
    if (preg_match('/^(.*)\[\]$/i', $match[1], $tmp)) {
        $data[$tmp[1]][] = (isset($match[2]) && $match[2] !== null ? $match[2] : null);
    } else {
        $data[$match[1]] = (isset($match[2]) && $match[2] !== null ? $match[2] : null);
    }
}

Tested and working on 5.7

Thanks for the fix, I was looking for this.
I also found a minor issue with this. If the input is array, its only pick the last element. I modified slightly to fix this. Tested on laravel 5.8 with array and nested array.

private function parameter($string)
    {
        $data = [];
        if (preg_match('/name=\"([^\"]*)\"[\n|\r]+([^\n\r].*)?\r$/s', $string, $match)) {
            if (preg_match('/^(.*)\[\]$/i', $match[1], $tmp)) {
                $data[$tmp[1].'[]'] = (isset($match[2]) && $match[2] !== null ? $match[2] : null);
            } else {
                $data[$match[1]] = (isset($match[2]) && $match[2] !== null ? $match[2] : null);
            }
        }
        return $data;
    }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment