-
-
Save devmycloud/df28012101fbc55d8de1737762b70348 to your computer and use it in GitHub Desktop.
<?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; | |
} | |
} | |
} |
USAGE:
// Instead of: $params = $request->all();
// Use:
$params = array();
new ParseInputStream( $params );
and use $params just as you would before.
@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);
}
}
@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.
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.
@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!
@devmycloud @t202wes I used use Illuminate\Http\UploadedFile;
instead of Symfony. Thank you.
@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.
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
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
@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.
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];
}
Fantastic addition! Thank you.
@sera527 still it has problem with file: "The image failed to upload." any ideas?!
@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.
I would like to read one part from the stream at a time, rather than read all parts into an array.
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!
Thanks a lot! It was useful!
Some additions:
-
do we really need function merge()? Didn't find any usage.
-
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);
}
- 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.
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;
}
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.