2
\$\begingroup\$

I have recently found that I needed to be able to parse multipart/form-data for PUT requests made to my REST API. This allows batch photo uploads, which has been a requested feature. As we all know, PHP (at least as of version 5.3) does not support this natively, so had to roll my own. I have tried my best to make it work exactly as the native PHP parsing works and have made it populate the $_POST variable with the data passed to PUT simply because, as the api also returns whole webpages inside browsers, the methods that use PUT also have to accept POST requests with uri's indicating that they be sent to the PUT action of the controller.

I found a bit of code for doing this on StackOverflow, but it always loaded the whole input into memory to work with it, thus making it arguably impractical in production because uploaded files, especially combined, could very well overflow the amount of memory allowed to PHP.

Anyway, code for parsing input is below. If anyone sees any flaws for security, things that could be optimized or places that it doesn't return the same as PHP's native parsing, let me know.

I know my coding style sucks, that's a given as a self taught programmer. I am currently working on getting everything into proper style across this project.

<?php

function parse_input(){
    $content_type   = ( isset( $_SERVER['CONTENT_TYPE'] ) )? $_SERVER['CONTENT_TYPE']: 'application/x-www-form-urlencoded';

    $tmp = explode( ';', $content_type );
    $boundary = '';
    $encoding = '';

    $content_type = array_shift( $tmp );

    foreach( $tmp as $t ){
        if( strpos( $t, 'boundary' ) !== false ){
            $t = explode( '=', $t, 2 );
            if( isset( $t[ 1 ] ) )
                $boundary = '--' . $t[1];
        }
        else if( strpos( $t, 'charset' ) !== false ){
            $t = explode( '=', $t, 2 );
            if( isset( $t[ 1 ] ) )
                $encoding = $t[1];
        }
        if( $boundary !== '' && $encoding !== '' )
            break;
    }

    switch( $content_type ){
        case 'multipart/form-data':
            #grab multipart boundary from content type header
            if( !empty( $boundary ) )
                break;
            $this->content_type = 'application/x-www-form-urlencoded';
        case 'application/x-www-form-urlencoded':
            parse_str( file_get_contents( 'php://input' ), $_POST );
            return;
        default:
            return;
    }

    $_FILES = array();
    $_POST = array();
    $chunkLength = 8096;
    $raw_headers = '';

    $stream = fopen( 'php://input', 'rb' );

    $sanity = fgets( $stream, strlen( $boundary ) + 5 );

    if( rtrim( $sanity ) !== $boundary ) #malformed file, boundary should be first item
        return;

    while( ( $chunk = fgets( $stream ) ) !== false ){
        if( $chunk === $boundary )
            continue;

        if( rtrim( $chunk ) == '' ){ #blank line means we have all the headers and are going to read content
            $raw_headers = explode( "\r\n", $raw_headers );
            $headers = array();
            $matches = array();

            foreach( $raw_headers as $header ){
                if( strpos( $header, ':' ) === false )
                    continue;
                list( $name, $value ) = explode( ':', $header, 2 );
                $headers[ strtolower( $name ) ] = ltrim( $value, ' ' );
            }

            $raw_headers = '';

            if( !isset( $headers[ 'content-disposition' ] ) )
                continue;

            $filename = NULL;
            preg_match(
                '/^(.+); *name="([^"]+)"(; *filename="([^"]+)")?/',
                $headers['content-disposition'],
                $matches
            );
            list( , $type, $name ) = $matches;

            #process data
            if( isset( $matches[ 4 ] ) ){ #pull in file
                $error = UPLOAD_ERR_OK;

                $filename = $matches[ 4 ];
                $filename_parts = pathinfo( $filename );
                $contentType = 'unknown';

                if( isset( $headers[ 'content-type' ] ) ){
                    $tmp = explode( ';', $headers[ 'content-type' ] );
                    $contentType = $tmp[0];
                }

                $tmpnam = tempnam( ini_get( 'upload_tmp_dir' ), 'php' );
                $fileHandle = fopen( $tmpnam, 'wb' );


                if( $fileHandle === false )
                    $error = UPLOAD_ERR_CANT_WRITE;
                else{
                   $lastLine = NULL;
                    while( ( $chunk = fgets( $stream, $chunkLength ) ) !== false && strpos( $chunk, $boundary ) !== 0 ) {
                        if( $lastLine !== NULL ){
                            if( fwrite( $fileHandle, $lastLine ) === false ){
                                $error = UPLOAD_ERR_CANT_WRITE;
                                break;
                            }
                        }
                        $lastLine = $chunk;
                    }

                    if( $lastLine !== NULL && $error !== UPLOAD_ERR_CANT_WRITE ){
                        if( fwrite( $fileHandle, rtrim( $lastLine, "\r\n" ) ) === false )
                                $error = UPLOAD_ERR_CANT_WRITE;
                    }
                }

                $items = array(
                    'name' => $filename,
                    'type' => $contentType,
                    'tmp_name' => $tmpnam,
                    'error' => $error,
                    'size' => filesize( $tmpnam )
                );

                $tmp = explode( '[', $name, 2 );

                foreach( $items as $index => $item ){
                    $spec = $index;
                    if( count( $tmp ) > 1 )
                        $spec .= '[' . $tmp[1];
                    $t = $spec . '=' . $item;
                    parse_str( $t, $array2 );
                    $_FILES = recursive_setter( $spec, $_FILES, $array2 );
                }

                continue;
            }
            else{ #pull in variable
                $fullValue = '';
                $lastLine = NULL;
                while( ( $chunk = fgets( $stream ) ) !== false && strpos( $chunk, $boundary ) !== 0 ){
                    if( $lastLine !== NULL )
                        $fullValue .= $lastLine;

                    $lastLine = $chunk;
                }

                if( $lastLine !== NULL )
                    $fullValue .= rtrim( $lastLine, "\r\n" );

                if( isset( $headers[ 'content-type' ] ) ){
                    $tmp = explode( ';', $headers[ 'content-type' ] );
                    $encoding = '';

                    foreach( $tmp as $t ){
                        if( strpos( $t, 'charset' ) !== false ){
                            $t = explode( $t, '=', 2 );
                            if( isset( $t[ 1 ] ) )
                                $encoding = $t[1];
                            break;
                        }
                    }

                    if( $encoding !== '' && strtoupper( $encoding ) !== 'UTF-8' && strtoupper( $encoding ) !== 'UTF8' ){
                            $tmp = mb_convert_encoding( $fullValue, 'UTF-8', $encoding );
                            if( $tmp !== false )
                                $fullValue = $tmp;
                    }

                }

                $fullValue = $name . '=' . $fullValue;
                $origName = $name;
                $tmp = array();
                parse_str( $fullValue, $tmp );
                $_POST = recursive_setter( $origName, $_POST, $tmp );
            }
            continue;
        }

        $raw_headers .= $chunk;
    }

    fclose( $stream );
}

function recursive_setter( $spec, $array, $array2 ){
    if( !is_array( $spec ) )
        $spec = explode( '[', (string)$spec );
    $currLev = array_shift( $spec );
    $currLev = rtrim( $currLev, ']' );
    if( $currLev !== '' ){
        $currLev = $currLev . '=p';
        $tmp = array();
        parse_str( $currLev, $tmp );
        $tmp = array_keys( $tmp );
        $currLev = reset( $tmp );
    }

    if( !is_array( $array ) ){
        $array = $array2;
    }else if( $currLev === '' ){
        $array[] = reset( $array2 );
    }else if( isset( $array[ $currLev ] ) && isset( $array2[ $currLev ] ) ){
        $array[ $currLev ] = recursive_setter( $spec, $array[ $currLev ], $array2[ $currLev ] );
    }else if( isset( $array2[ $currLev ] ) ){
        $array[ $currLev ] = $array2[ $currLev ];
    }
    return $array;
}

    if( !is_array( $spec ) )
        $spec = explode( '[', (string)$spec );
    $currLev = array_shift( $spec );
    $currLev = rtrim( $currLev, ']' );
    if( $currLev !== '' ){
        $currLev = $currLev . '=p';
        $tmp = array();
        parse_str( $currLev, $tmp );
        $tmp = array_keys( $tmp );
        $currLev = reset( $tmp );
    }

    if( !is_array( $array ) ){
        $array = $array2;
    }else if( $currLev === '' ){
        $array[] = reset( $array2 );
    }else if( isset( $array[ $currLev ] ) && isset( $array2[ $currLev ] ) ){
        $array[ $currLev ] = self::recursive_setter( $spec, $array[ $currLev ], $array2[ $currLev ] );
    }else if( isset( $array2[ $currLev ] ) ){
        $array[ $currLev ] = $array2[ $currLev ];
    }
    return $array;
}
?>
\$\endgroup\$
1
  • \$\begingroup\$ currently, this code differs from php native because it urldecodes values and field names in multipart/form-data, i.e. variable%5B1%5D becomes variable[1] whereas native POST population fails to do this and will create $_POST = array( variable%5B1%5D => 'value' ) instead of $_POST = array( variable => array( 1 => 'value' ) ). It also checks encodings and reencodes all variables into utf8 whereas, assumably, PHP doesn't do anything as of 3.5 because it seems to assume that everything is ascii. \$\endgroup\$ Commented Nov 14, 2014 at 19:30

1 Answer 1

2
\$\begingroup\$

Based on your work I derived the following code from it. The previous code had for example problems with multiple attached files. Also I would avoid setting the $_POST and $_FILES variables. The code also is reformatted according to the PSR rules.

It can now handle JSON as input format as well. I tested the code with PHP 7.3 and it could help the project I'm working on. Would you allow our project to use this code? It would be redistributed under GNU AGPL version 3.

Edit: I had been advised to write more about the original code and my motivation to apply massive changes to it. I prefer splitting large functions into small parts. This improves the readability. Also it avoids possible side effects when variable names are reused in the same function for different purposes. Concerning the variables, I think it is better to stick to the variable type, means I personally don't really like lines like this: $raw_headers = explode( "\r\n", $raw_headers );. Since this variable is used only a single time, in my proposal this moved directly to the foreach. Concerning the formatting one should follow PSR-2. This improves the readability for newcomers to the team. (In our project we even had implemented automated checks for this when creating pull requests).

(Disclaimer: Although being a professional coder, I'm not a PHP professional, at work we are using a very different language. Most stuff concerning PHP I learned from the other PHP coders in our project)

<?php

class HTTPInputData
{
    public static function process()
    {
        $content_parts = explode(';', $_SERVER['CONTENT_TYPE'] ?? 'application/x-www-form-urlencoded');

        $boundary = '';
        $encoding = '';

        $content_type = array_shift($content_parts);

        foreach ($content_parts as $part) {
            if (strpos($part, 'boundary') !== false) {
                $part = explode('=', $part, 2);
                if (!empty($part[1])) {
                    $boundary = '--' . $part[1];
                }
            } elseif (strpos($part, 'charset') !== false) {
                $part = explode('=', $part, 2);
                if (!empty($part[1])) {
                    $encoding = $part[1];
                }
            }
            if ($boundary !== '' && $encoding !== '') {
                break;
            }
        }

        if ($content_type == 'multipart/form-data') {
            return self::fetchFromMultipart($boundary);
        }

        // can be handled by built in PHP functionality
        $content = file_get_contents('php://input');

        $variables = json_decode($content);

        if (empty($variables)) {
            parse_str($content, $variables);
        }

        return ['variables' => $variables, 'files' => []];
    }

    private static function fetchFromMultipart(string $boundary)
    {
        $result = ['variables' => [], 'files' => []];

        $stream = fopen('php://input', 'rb');

        $sanity = fgets($stream, strlen($boundary) + 5);

        // malformed file, boundary should be first item
        if (rtrim($sanity) !== $boundary) {
            return $result;
        }

        $raw_headers = '';

        while (($chunk = fgets($stream)) !== false) {
            if ($chunk === $boundary) {
                continue;
            }

            if (!empty(trim($chunk))) {
                $raw_headers .= $chunk;
                continue;
            }

            $result = self::parseRawHeader($stream, $raw_headers, $boundary, $result);
            $raw_headers = '';
        }

        fclose($stream);

        return $result;
    }

    private static function parseRawHeader($stream, string $raw_headers, string $boundary, array $result)
    {
        $variables = $result['variables'];
        $files     = $result['files'];

        $headers = [];

        foreach (explode("\r\n", $raw_headers) as $header) {
            if (strpos($header, ':') === false) {
                continue;
            }
            list($name, $value) = explode(':', $header, 2);
            $headers[strtolower($name)] = ltrim($value, ' ');
        }

        if (!isset($headers['content-disposition'])) {
            return ['variables' => $variables, 'files' => $files];
        }

        if (!preg_match('/^(.+); *name="([^"]+)"(; *filename="([^"]+)")?/', $headers['content-disposition'], $matches)) {
            return ['variables' => $variables, 'files' => $files];
        }

        $name     = $matches[2];
        $filename = $matches[4] ?? '';

        if (!empty($filename)) {
            $files[$name] = self::fetchFileData($stream, $boundary, $headers, $filename);
            return ['variables' => $variables, 'files' => $files];
        } else {
            $variables = self::fetchVariables($stream, $boundary, $name, $variables);
        }

        return ['variables' => $variables, 'files' => $files];
    }

    private static function fetchFileData($stream, string $boundary, array $headers, string $filename)
    {
        $error = UPLOAD_ERR_OK;

        if (isset($headers['content-type'])) {
            $tmp = explode(';', $headers['content-type']);
            $contentType = $tmp[0];
        } else {
            $contentType = 'unknown';
        }

        $tmpnam     = tempnam(ini_get('upload_tmp_dir'), 'php');
        $fileHandle = fopen($tmpnam, 'wb');

        if ($fileHandle === false) {
            $error = UPLOAD_ERR_CANT_WRITE;
        } else {
            $lastLine = NULL;
            while (($chunk = fgets($stream, 8096)) !== false && strpos($chunk, $boundary) !== 0) {
                if ($lastLine !== NULL) {
                    if (fwrite($fileHandle, $lastLine) === false) {
                        $error = UPLOAD_ERR_CANT_WRITE;
                        break;
                    }
                }
                $lastLine = $chunk;
            }

            if ($lastLine !== NULL && $error !== UPLOAD_ERR_CANT_WRITE) {
                if (fwrite($fileHandle, rtrim($lastLine, "\r\n")) === false) {
                    $error = UPLOAD_ERR_CANT_WRITE;
                }
            }
        }

        return [
            'name'     => $filename,
            'type'     => $contentType,
            'tmp_name' => $tmpnam,
            'error'    => $error,
            'size'     => filesize($tmpnam)
        ];
    }

    private static function fetchVariables($stream, string $boundary, string $name, array $variables)
    {
        $fullValue = '';
        $lastLine = NULL;

        while (($chunk = fgets($stream)) !== false && strpos($chunk, $boundary) !== 0) {
            if ($lastLine !== NULL) {
                $fullValue .= $lastLine;
            }

            $lastLine = $chunk;
        }

        if ($lastLine !== NULL) {
            $fullValue .= rtrim($lastLine, "\r\n");
        }

        if (isset($headers['content-type'])) {
            $encoding = '';

            foreach (explode(';', $headers['content-type']) as $part) {
                if (strpos($part, 'charset') !== false) {
                    $part = explode($part, '=', 2);
                    if (isset($part[1])) {
                        $encoding = $part[1];
                    }
                    break;
                }
            }

            if ($encoding !== '' && strtoupper($encoding) !== 'UTF-8' && strtoupper($encoding) !== 'UTF8') {
                    $tmp = mb_convert_encoding($fullValue, 'UTF-8', $encoding);
                    if ($tmp !== false) {
                        $fullValue = $tmp;
                    }
            }
        }

        $fullValue = $name . '=' . $fullValue;

        $tmp = [];
        parse_str($fullValue, $tmp);

        return self::expandVariables(explode('[', $name), $variables, $tmp);
    }

    private static function expandVariables(array $names, $variables, array $values)
    {
        if (!is_array($variables)) {
            return $values;
        }

        $name = rtrim(array_shift($names), ']');
        if ($name !== '') {
            $name = $name . '=p';

            $tmp = [];
            parse_str($name, $tmp);

            $tmp  = array_keys($tmp);
            $name = reset($tmp);
        }

        if ($name === '') {
            $variables[] = reset($values);
        } elseif (isset($variables[$name]) && isset($values[$name])) {
            $variables[$name] = self::expandVariables($names, $variables[$name], $values[$name]);
        } elseif (isset($values[$name])) {
            $variables[$name] = $values[$name];
        }

        return $variables;
    }
}
?>
\$\endgroup\$
4
  • 3
    \$\begingroup\$ Welcome to Code Review! You have presented an alternative solution, but haven't reviewed the code. Please edit to show what aspects of the question code prompted you to write this version, and in what ways it's an improvement over the original. It may be worth (re-)reading How to Answer. \$\endgroup\$ Commented May 21, 2021 at 8:00
  • \$\begingroup\$ empty() is never appropriate after a variable is trim()ed. empty() is never appropriate when a variable is guaranteed to be declared. isset() can receive multiple parameters. \$\endgroup\$ Commented May 21, 2021 at 8:16
  • \$\begingroup\$ Thanks for the comments. I tend to use empty() even when I know that the variable exists since it will also check for an empty value. When I only want to check if the variable does exist, I'm normally using isset(). \$\endgroup\$
    – Michael
    Commented May 22, 2021 at 5:56
  • \$\begingroup\$ It's nice to see someone poking at some of these old tid bits I posted and finding them useful. Your points of criticism of the original code are quite valid and, if you find your code useful, do whatever you want with it. I claim no rights to this snippet at all. It's been long enough that I had forgotten about it entirely. \$\endgroup\$ Commented May 22, 2021 at 15:32

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.