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;
}
}
}
@devmycloud
Copy link
Author

devmycloud commented Nov 5, 2017

Original Gist

The original author provided an excellent starting point. However, the code lacked the ability to process parameter names and build a standard parameters array for what we needed in Laravel. In other words, parameters such as 'field[]' and 'field[key1][key2]' were being returned as flat names, and we needed the hierarchical array structure these names would generate with standard Laravel request processing.

We also wanted the returned files to be UloadedFile objects for consistency with other code. However, note that you cannot use UploadedFile's move() method, since the file will not pass the PHP is_uploaded_file() test. So you will need to use PHP rename() to actually move the file. The method file_stream() also had a few issues with the simplistic regex processing of the headers, so this was rewritten to be a little more robust.

To use this code in a non-Laravel environment, simply remove the logging and change the way files are returned.

@devmycloud
Copy link
Author

devmycloud commented Nov 5, 2017

USAGE:

    // Instead of: $params = $request->all();
    // Use:
    $params = array();
    new ParseInputStream( $params );

and use $params just as you would before.

@t202wes
Copy link

t202wes commented Mar 9, 2018

@devmycloud - How are you implementing this in your Laravel application? Are you making it work globally, are you just putting it in a middleware? I'm implementing a middleware like this, what do you think?

<?php

namespace App\Http\Middleware;

use App\Services\ParseInputStream;
use Closure;

class ParseMultipartFormDataInputForNonPostRequests
{
    /*
     * Content-Type: multipart/form-data - only works for POST requests. All others fail, this is a bug in PHP since 2011.
     * See comments here: https://github.com/laravel/framework/issues/13457
     *
     * This middleware converts all multi-part/form-data for NON-POST requests, into a properly formatted
     * request variable for Laravel 5.6. It uses the ParseInputStream class, found here:
     * https://gist.github.com/devmycloud/df28012101fbc55d8de1737762b70348
     */
    public function handle($request, Closure $next)
    {
        if ($request->method() == 'POST' OR $request->method() == 'GET') {
            return $next($request);
        }

        if (preg_match('/multipart\/form-data/', $request->headers->get('Content-Type')) or
            preg_match('/multipart\/form-data/', $request->headers->get('content-type'))
        ) {
            $params = array();
            new ParseInputStream($params);
            $request->request->add($params);

        }
        return $next($request);
    }
}

@timbkbasic
Copy link

@t202wes - I needed to get code up and running, so I never took the time to properly integrate this code - it is just called from within the corresponding controller methods. Thank you for your contribution. I will certainly be updating our code to use your middleware solution in the next release.

@cioroianudenis
Copy link

This library isn't working in laravel 5.6, at this following line "preg_match('/boundary=(.*)$/', $_SERVER['CONTENT_TYPE'], $matches);" it doesn't return anything.

@lukio3
Copy link

lukio3 commented Sep 4, 2018

@timbkbasic thanks for your solution and @t202wes for your middleware wrapper.

@t202wes - did you register the ParseInputStream as a service or similar? Or did you simply create a App/Services folder and put it there?

When I try use App\Services\ParseInputStream; at the top of my middleware implementation the controller method finds the middleware but fails whilst running the middleware:

"Class 'App\Services\ParseInputStream' not found"

Any suggestions welcome thanks!

@toancong
Copy link

@devmycloud @t202wes I used use Illuminate\Http\UploadedFile; instead of Symfony. Thank you.

@Conrekatsu
Copy link

@timbkbasic thanks for your solution and @t202wes for your middleware wrapper.

@t202wes - did you register the ParseInputStream as a service or similar? Or did you simply create a App/Services folder and put it there?

When I try use App\Services\ParseInputStream; at the top of my middleware implementation the controller method finds the middleware but fails whilst running the middleware:

"Class 'App\Services\ParseInputStream' not found"

Any suggestions welcome thanks!

you should put "namespace App\Services;" on the very top of your ParseInputStream file.

@J5Dev
Copy link

J5Dev commented Mar 11, 2019

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

@diamondobama
Copy link

Thank you @t202wes and @devmycloud. Did you get this code to work with files?
The code failed to upload files in Laravel 5.7 and 5.8 on my side.

The filename failed to upload

@sera527
Copy link

sera527 commented Apr 3, 2019

@t202wes your code does not work with files. To work with files, it should look like this:

namespace App\Http\Middleware;

use App\Services\ParseInputStream;
use Closure;

class ParseMultipartFormDataInputForNonPostRequests
{
    /*
     * Content-Type: multipart/form-data - only works for POST requests. All others fail, this is a bug in PHP since 2011.
     * See comments here: https://github.com/laravel/framework/issues/13457
     *
     * This middleware converts all multi-part/form-data for NON-POST requests, into a properly formatted
     * request variable for Laravel 5.6. It uses the ParseInputStream class, found here:
     * https://gist.github.com/devmycloud/df28012101fbc55d8de1737762b70348
     */
    public function handle($request, Closure $next)
    {
        if ($request->method() == 'POST' OR $request->method() == 'GET') {
            return $next($request);
        }

        if (preg_match('/multipart\/form-data/', $request->headers->get('Content-Type')) or
            preg_match('/multipart\/form-data/', $request->headers->get('content-type'))
        ) {
            $params = array();
            new ParseInputStream($params);
            $files = array();
            $parameters = array();
            foreach ($params as $key => $param) {
                if ($param instanceof \Symfony\Component\HttpFoundation\File\UploadedFile) {
                    $files[$key] = $param;
                } else {
                    $parameters[$key] = $param;
                }
            }
            if (count($files) > 0) {
                $request->files->add($files);
            }
            if (count($parameters) > 0) {
                $request->request->add($parameters);
            }
        }
        return $next($request);
    }
}

@diamondobama I think my answer will solve your problem.

@J5Dev
Copy link

J5Dev commented Jun 3, 2019

Just coming back to this to provide a suggested change, based on an edge case we discovered...

Essentially, when a request was received that declared its content type as 'multipart/form-data' BUT, then provided or added no form data, the following Exception would be fired:

ErrorException: Undefined offset: 1: /opt/app-root/app/app/Services/ParseInputStream.php:72

This an edge case, and shouldn't occur, but can be simply handled, by updating the boundary() method to simply validate that it does indeed have data via the matches and not just relying on teh fact the header declaration is there.

In short, update the boundary() method to the below:

    /**
     * @function boundary
     * @returns string
     */
    private function boundary()
    {
        if (! isset($_SERVER['CONTENT_TYPE'])) {
            return null;
        }
        
        preg_match('/boundary=(.*)$/', $_SERVER['CONTENT_TYPE'], $matches);
    
        if (empty($matches) || ! isset($matches[1])) {
            return null;
        }
        
        return $matches[1];
    }

@devmycloud
Copy link
Author

Fantastic addition! Thank you.

@hamidrasti
Copy link

hamidrasti commented Aug 7, 2019

@sera527 still it has problem with file: "The image failed to upload." any ideas?!

@semihozbag
Copy link

semihozbag commented Aug 23, 2019

@hamraa

@sera527 still it has problem with file: "The image failed to upload." any ideas?!

I was struggling with the same while using $image->isValid() method and it was causing this error message. I replaced with $image->isReadable() and it worked for me.

isValid() method looks for the file that if the file is uploaded successfully and has been uploaded with a POST request so it returns false because of it's a PUT request.

On the other hand isReadable() method looks for a file that is readable.

@malhal
Copy link

malhal commented Feb 6, 2020

I would like to read one part from the stream at a time, rather than read all parts into an array.

@JhonatanRaul
Copy link

JhonatanRaul commented Apr 3, 2020

This library isn't working in laravel 5.6, at this following line "preg_match('/boundary=(.*)$/', $_SERVER['CONTENT_TYPE'], $matches);" it doesn't return anything.

I had the same problem.
I found a solution that worked as middlware:
https://gist.github.com/JhonatanRaul/cb2f9670ad0a8aa2fc32d263f948342a

Tyvm folks,
Good Jobs!

@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