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 [email protected]
* @license http://www.gnu.org/licenses/gpl.html GPL License 3
*
* Massive modifications by TGE ([email protected]) 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;
}
}
}
@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.

@JhowRaul10
Copy link

JhowRaul10 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