PHP multipart form data PUT request?
Asked Answered
S

6

18

I'm writing a RESTful API. I'm having trouble with uploading images using the different verbs.

Consider:

I have an object which can be created/modified/deleted/viewed via a post/put/delete/get request to a URL. The request is multi part form when there is a file to upload, or application/xml when there's just text to process.

To handle the image uploads which are associated with the object I am doing something like:

    if(isset($_FILES['userfile'])) {
        $data = $this->image_model->upload_image();
        if($data['error']){
            $this->response(array('error' => $error['error']));
        }
        $xml_data = (array)simplexml_load_string( urldecode($_POST['xml']) );           
        $object = (array)$xml_data['object'];
    } else {
        $object = $this->body('object');
    }

The major problem here is when trying to handle a put request, obviously $_POST doesn't contain the put data (as far as I can tell!).

For reference this is how I'm building the requests:

curl -F userfile=@./image.png -F xml="<xml><object>stuff to edit</object></xml>" 
  http://example.com/object -X PUT

Does anyone have any ideas how I can access the xml variable in my PUT request?

Sammie answered 27/2, 2012 at 12:21 Comment(0)
C
43

First of all, $_FILES is not populated when handling PUT requests. It is only populated by PHP when handling POST requests.

You need to parse it manually. That goes for "regular" fields as well:

// Fetch content and determine boundary
$raw_data = file_get_contents('php://input');
$boundary = substr($raw_data, 0, strpos($raw_data, "\r\n"));

// Fetch each part
$parts = array_slice(explode($boundary, $raw_data), 1);
$data = array();

foreach ($parts as $part) {
    // If this is the last part, break
    if ($part == "--\r\n") break; 

    // Separate content from headers
    $part = ltrim($part, "\r\n");
    list($raw_headers, $body) = explode("\r\n\r\n", $part, 2);

    // Parse the headers list
    $raw_headers = explode("\r\n", $raw_headers);
    $headers = array();
    foreach ($raw_headers as $header) {
        list($name, $value) = explode(':', $header);
        $headers[strtolower($name)] = ltrim($value, ' '); 
    } 

    // Parse the Content-Disposition to get the field name, etc.
    if (isset($headers['content-disposition'])) {
        $filename = null;
        preg_match(
            '/^(.+); *name="([^"]+)"(; *filename="([^"]+)")?/', 
            $headers['content-disposition'], 
            $matches
        );
        list(, $type, $name) = $matches;
        isset($matches[4]) and $filename = $matches[4]; 

        // handle your fields here
        switch ($name) {
            // this is a file upload
            case 'userfile':
                 file_put_contents($filename, $body);
                 break;

            // default for all other files is to populate $data
            default: 
                 $data[$name] = substr($body, 0, strlen($body) - 2);
                 break;
        } 
    }

}

At each iteration, the $data array will be populated with your parameters, and the $headers array will be populated with the headers for each part (e.g.: Content-Type, etc.), and $filename will contain the original filename, if supplied in the request and is applicable to the field.

Take note the above will only work for multipart content types. Make sure to check the request Content-Type header before using the above to parse the body.

Calculate answered 27/2, 2012 at 17:45 Comment(3)
Thanks, that's loads more helpful :)Sammie
"First of all, $_FILES is not populated when handling PUT requests. It is only populated by PHP when handling POST requests." Can't find documentation on this, can you please point me to the right direction?Ascendancy
@M.Ang.: Here: "PHP also supports PUT-method file uploads as used by Netscape Composer and W3C's Amaya clients. See the PUT Method Support for more details."Calculate
D
18

Please don't delete this again, it's helpful to a majority of people coming here! All previous answers were partial answers that don't cover the solution as a majority of people asking this question would want.

This takes what has been said above and additionally handles multiple file uploads and places them in $_FILES as someone would expect. To get this to work, you have to add 'Script PUT /put.php' to your Virtual Host for the project per Documentation. I also suspect I'll have to setup a cron to cleanup any '.tmp' files.

private function _parsePut(  )
{
    global $_PUT;

    /* PUT data comes in on the stdin stream */
    $putdata = fopen("php://input", "r");

    /* Open a file for writing */
    // $fp = fopen("myputfile.ext", "w");

    $raw_data = '';

    /* Read the data 1 KB at a time
       and write to the file */
    while ($chunk = fread($putdata, 1024))
        $raw_data .= $chunk;

    /* Close the streams */
    fclose($putdata);

    // Fetch content and determine boundary
    $boundary = substr($raw_data, 0, strpos($raw_data, "\r\n"));

    if(empty($boundary)){
        parse_str($raw_data,$data);
        $GLOBALS[ '_PUT' ] = $data;
        return;
    }

    // Fetch each part
    $parts = array_slice(explode($boundary, $raw_data), 1);
    $data = array();

    foreach ($parts as $part) {
        // If this is the last part, break
        if ($part == "--\r\n") break;

        // Separate content from headers
        $part = ltrim($part, "\r\n");
        list($raw_headers, $body) = explode("\r\n\r\n", $part, 2);

        // Parse the headers list
        $raw_headers = explode("\r\n", $raw_headers);
        $headers = array();
        foreach ($raw_headers as $header) {
            list($name, $value) = explode(':', $header);
            $headers[strtolower($name)] = ltrim($value, ' ');
        }

        // Parse the Content-Disposition to get the field name, etc.
        if (isset($headers['content-disposition'])) {
            $filename = null;
            $tmp_name = null;
            preg_match(
                '/^(.+); *name="([^"]+)"(; *filename="([^"]+)")?/',
                $headers['content-disposition'],
                $matches
            );
            list(, $type, $name) = $matches;

            //Parse File
            if( isset($matches[4]) )
            {
                //if labeled the same as previous, skip
                if( isset( $_FILES[ $matches[ 2 ] ] ) )
                {
                    continue;
                }

                //get filename
                $filename = $matches[4];

                //get tmp name
                $filename_parts = pathinfo( $filename );
                $tmp_name = tempnam( ini_get('upload_tmp_dir'), $filename_parts['filename']);

                //populate $_FILES with information, size may be off in multibyte situation
                $_FILES[ $matches[ 2 ] ] = array(
                    'error'=>0,
                    'name'=>$filename,
                    'tmp_name'=>$tmp_name,
                    'size'=>strlen( $body ),
                    'type'=>$value
                );

                //place in temporary directory
                file_put_contents($tmp_name, $body);
            }
            //Parse Field
            else
            {
                $data[$name] = substr($body, 0, strlen($body) - 2);
            }
        }

    }
    $GLOBALS[ '_PUT' ] = $data;
    return;
}
Degas answered 7/9, 2013 at 22:51 Comment(6)
It really looks like this is the only answer that goes anywhere near a complete solution. I'm not sure how I feel about faking $_FILES and $_PUT but it works really well. Thanks!Wellinformed
that's just what i need !Dactylogram
How can I put this to work in a Laravel Request? "Illuminate\Http\Request"Holdback
Awesome! Basically GreenDot wrote a real support for PUT requests, turning it into the same interface that PHP devs are already used to with POST and GET. Really appreciate, thank you!Lardner
Very useful, thanks! Also, if you would want to support raw data as JSON, after fclose($putdata); you could add something like this: $json_data = json_decode($raw_data, true); if(json_last_error() === JSON_ERROR_NONE){ $GLOBALS[ '_PUT' ] = $data; return; }Unhallowed
Been searching for hours on stackoverflow and other sites to make form data work with PUT. Thanks a lot!Tuneberg
C
4

For whom using Apiato (Laravel) framework: create new Middleware like file below, then declair this file in your laravel kernel file within the protected $middlewareGroups variable (inside web or api, whatever you want) like this:

protected $middlewareGroups = [
    'web' => [],
    'api' => [HandlePutFormData::class],
];

<?php

namespace App\Ship\Middlewares\Http;

use Closure;
use Symfony\Component\HttpFoundation\ParameterBag;

/**
 * @author Quang Pham
 */
class HandlePutFormData
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request $request
     * @param  \Closure                 $next
     *
     * @return mixed
     */
    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'))) {
            $parameters = $this->decode();

            $request->merge($parameters['inputs']);
            $request->files->add($parameters['files']);
        }

        return $next($request);
    }

    public function decode()
    {
        $files = [];
        $data  = [];
        // Fetch content and determine boundary
        $rawData  = file_get_contents('php://input');
        $boundary = substr($rawData, 0, strpos($rawData, "\r\n"));
        // Fetch and process each part
        $parts = $rawData ? array_slice(explode($boundary, $rawData), 1) : [];
        foreach ($parts as $part) {
            // If this is the last part, break
            if ($part == "--\r\n") {
                break;
            }
            // Separate content from headers
            $part = ltrim($part, "\r\n");
            list($rawHeaders, $content) = explode("\r\n\r\n", $part, 2);
            $content = substr($content, 0, strlen($content) - 2);
            // Parse the headers list
            $rawHeaders = explode("\r\n", $rawHeaders);
            $headers    = array();
            foreach ($rawHeaders as $header) {
                list($name, $value) = explode(':', $header);
                $headers[strtolower($name)] = ltrim($value, ' ');
            }
            // Parse the Content-Disposition to get the field name, etc.
            if (isset($headers['content-disposition'])) {
                $filename = null;
                preg_match(
                    '/^form-data; *name="([^"]+)"(; *filename="([^"]+)")?/',
                    $headers['content-disposition'],
                    $matches
                );
                $fieldName = $matches[1];
                $fileName  = (isset($matches[3]) ? $matches[3] : null);
                // If we have a file, save it. Otherwise, save the data.
                if ($fileName !== null) {
                    $localFileName = tempnam(sys_get_temp_dir(), 'sfy');
                    file_put_contents($localFileName, $content);
                    $files = $this->transformData($files, $fieldName, [
                        'name'     => $fileName,
                        'type'     => $headers['content-type'],
                        'tmp_name' => $localFileName,
                        'error'    => 0,
                        'size'     => filesize($localFileName)
                    ]);
                    // register a shutdown function to cleanup the temporary file
                    register_shutdown_function(function () use ($localFileName) {
                        unlink($localFileName);
                    });
                } else {
                    $data = $this->transformData($data, $fieldName, $content);
                }
            }
        }
        $fields = new ParameterBag($data);

        return ["inputs" => $fields->all(), "files" => $files];
    }

    private function transformData($data, $name, $value)
    {
        $isArray = strpos($name, '[]');
        if ($isArray && (($isArray + 2) == strlen($name))) {
            $name = str_replace('[]', '', $name);
            $data[$name][]= $value;
        } else {
            $data[$name] = $value;
        }
        return $data;
    }
}

Pls note: Those codes above not all mine, some from above comment, some modified by me.

Cristobal answered 6/11, 2019 at 8:31 Comment(1)
Working. Thanks @Pham QuangSchock
S
2

I've been trying to figure out how to work with this issue without having to break RESTful convention and boy howdie, what a rabbit hole, let me tell you.

I'm adding this anywhere I can find in the hope that it will help somebody out in the future.

I've just lost a day of development firstly figuring out that this was an issue, then figuring out where the issue lay.

As mentioned, this isn't a symfony (or laravel, or any other framework) issue, it's a limitation of PHP.

After trawling through a good few RFCs for php core, the core development team seem somewhat resistant to implementing anything to do with modernising the handling of HTTP requests. The issue was first reported in 2011, it doesn't look any closer to having a native solution.

That said, I managed to find this PECL extension called Always Populate Form Data. I'm not really very familiar with pecl, and couldn't seem to get it working using pear. but I'm using CentOS and Remi PHP which has a yum package.

I ran yum install php-pecl-apfd and it literally fixed the issue straight away (well I had to restart my docker containers but that was a given).

I believe there are other packages in various flavours of linux and I'm sure anybody with more knowledge of pear/pecl/general php extensions could get it running on windows or mac with no issue.

Southpaw answered 25/6, 2020 at 14:3 Comment(1)
This solution works to me in CakePHP. Theres is a workaround to use in case of Laravel. Is a PHP problem that can be fixed with this extension.Overactive
O
1

Quoting netcoder reply : "Take note the above will only work for multipart content types"

To work with any content type I have added the following lines to Mr. netcoder's solution :

   // Fetch content and determine boundary
   $raw_data = file_get_contents('php://input');
   $boundary = substr($raw_data, 0, strpos($raw_data, "\r\n"));

   /*...... My edit --------- */
    if(empty($boundary)){
        parse_str($raw_data,$data);
        return $data;
    }
   /* ........... My edit ends ......... */
    // Fetch each part
    $parts = array_slice(explode($boundary, $raw_data), 1);
    $data = array();
    ............
    ...............  
Ozuna answered 10/8, 2012 at 5:0 Comment(1)
@Greg I did not have edit permission at the time I added the solution. and if I add my code as a comment in netcoder's thread, then the code would not be readable. Why -1? Did not I try to help someone?Ozuna
H
0

I know this article is old.

But unfortunately, PHP still does not pay attention to form-data other than the Post method.

Thanks to friends (@netcoder, @greendot, @pham-quang) who suggested solutions above.

Using those solutions I wrote a library for this purpose:

composer require alireaza/php-form-data

You can also use composer require alireaza/laravel-form-data in Laravel.

Hydrogenous answered 10/12, 2022 at 20:44 Comment(1)
Your answer could be improved with additional supporting information. Please edit to add further details, such as citations or documentation, so that others can confirm that your answer is correct. You can find more information on how to write good answers in the help center.Cheetah

© 2022 - 2024 — McMap. All rights reserved.