2

The short version:

When a user uploads a file using a form, an array is saved in the global variable $_FILES. For example, when using:

<input type="file" name="myfiles0" />

the global variable looks like this:

$_FILES = [
    'myfiles0' => [
        'name' => 'image-1.jpg',
        'type' => 'image/jpeg',
        'tmp_name' => '[path-to]/tmp/php/phptiV897',
        'error' => 0,
        'size' => 92738,
    ],
]

In principle, I need to know which of the keys of the array $_FILES['myfiles0'] always exists and (maybe) is always set, no matter how the other keys look like, or which browser is used. Could you please tell me?

Please take into the consideration, that the $_FILES variable can also contain multi-dimensional arrays for files uploaded using an array notation, like this:

<input type="file" name="myfiles1[demo][images][]" multiple />

The long version:

For my implemention of PSR-7 Uploaded files I need to do the normalization of the uploaded files list. The initial list can be provided by the user, or can be the result of a standard file upload using a form, e.g. the $_FILES global variable. For the normalization process I need to check for the existence and "correctness" (maybe a poor choice of the word) of one of the following standard file upload keys:

  • name
  • type
  • tmp_name
  • error
  • size

In principle, if, in the provided uploaded files list (which can be a multi-dimensional array as well), the chosen key (I choosed tmp_name for now) is found, then it will be supposed that the array item to which the key belongs is a standard file upload array item, containing the above key list. Otherwise, e.g. if the chosen key is not found, it will be supposed that the corresponding array item is an instance of UploadedFileInterface.

Unfortunately, in case of a standard file upload, I can't find nowhere a solide information about which key (from the above list) always exists and (maybe) is always set in the $_FILES variable, no matter how the other list keys look like, or which browser is used.

I would appreciate, if you could help me in this matter.

Thank you.

PajuranCodes
  • 303
  • 3
  • 12
  • 43
  • I don't get what's the real problem is? – odan Aug 26 '18 at 15:02
  • AFAIK all of the above keys are always present and have a value. – simon Aug 26 '18 at 15:08
  • @simon Thank you, simon. Unfortunately I am not anymore sure, that this is always the case. For me only one key is of interest for my code. – PajuranCodes Aug 26 '18 at 15:39
  • Did you get the desired information ? In the PSR-7 spec they say depending in the PHP implementation the $_FILES may or may not be populated if a file wasn't uploaded. I just wrote a normalisation function that validates the structure and type of everything in $_FILES but I'm starting to think that I'm too paranoid. – Fravadona Apr 10 '20 at 14:52
  • @Fravadona I posted an answer. Well, in my PSR-7 implementation I thought only so: `ServerRequestFactoryInterface` instance creates a `ServerRequestInterface` object, passing it either an array with the same structure as `$_FILES`, or `$_FILES` itself, or an already normalized array of file uploads. Any other structure of the passed files list I considered not valid, throwing an exception. And I just took a SAPI-environment into consideration ("apache2handler" SAPI). Why? Because, at that time, I thought it's best so. Now it's all gone. Sorry :-) – PajuranCodes Apr 10 '20 at 20:29

1 Answers1

1

I decided to use the tmp_name key for the file(s) upload validation.

Unfortunately I've taken this decision a long time ago. So I can't remember anymore all arguments supporting it, resulted from the documentations I read and the tests I performed. Though, one of the arguments has been, that, in comparison with other key(s), the value of the tmp_name key can't be set/changed on the client-side. The environment on which the application is running decides which value should be set for it.

I'll post here the final version of the PSR-7 & PSR-17 implementation (regarding uploaded files) that I wrote back then. Maybe it will be helpful for someone.


The implementation of ServerRequestFactoryInterface:

It reads the list of uploaded files (found in $_FILES, or manually passed as argument) and, if not already done, transforms it to "a normalized tree of upload metadata, with each leaf an instance of Psr\Http\Message\UploadedFileInterface" (see "1.6 Uploaded files" in PSR-7).

Then it creates a ServerRequestInterface instance, passing it the normalized list of uploaded files.

<?php

namespace MyLib\Http\Message\Factory\SapiServerRequestFactory;

use MyLib\Http\Message\Uri;
use MyLib\Http\Message\Stream;
use MyLib\Http\Message\UploadedFile;
use MyLib\Http\Message\ServerRequest;
use Psr\Http\Message\UploadedFileInterface;
use Psr\Http\Message\ServerRequestInterface;
use MyLib\Http\Message\Factory\ServerRequestFactory;
use Fig\Http\Message\RequestMethodInterface as RequestMethod;

/**
 * Server request factory for the "apache2handler" SAPI.
 */
class Apache2HandlerFactory extends ServerRequestFactory {

    /**
     * Create a new server request by seeding the generated request
     * instance with the elements of the given array of SAPI parameters.
     *
     * @param array $serverParams (optional) Array of SAPI parameters with which to seed
     *     the generated request instance.
     * @return ServerRequestInterface The new server request.
     */
    public function createServerRequestFromArray(array $serverParams = []): ServerRequestInterface {
        if (!$serverParams) {
            $serverParams = $_SERVER;
        }

        $this->headers = $this->buildHeaders($serverParams);
        $method = $this->buildMethod($serverParams);
        $uri = $this->buildUri($serverParams, $this->headers);
        $this->parsedBody = $this->buildParsedBody($this->parsedBody, $method, $this->headers);
        $this->queryParams = $this->queryParams ?: $_GET;
        $this->uploadedFiles = $this->buildUploadedFiles($this->uploadedFiles ?: $_FILES);
        $this->cookieParams = $this->buildCookieParams($this->headers, $this->cookieParams);
        $this->protocolVersion = $this->buildProtocolVersion($serverParams, $this->protocolVersion);

        return parent::createServerRequest($method, $uri, $serverParams);
    }

    /*
     * Custom methods.
     */

    // [... All other methods ...]

    /**
     * Build the list of uploaded files as a normalized tree of upload metadata,
     * with each leaf an instance of Psr\Http\Message\UploadedFileInterface.
     *
     * Not part of PSR-17.
     *
     * @param array $uploadedFiles The list of uploaded files (normalized or not).
     *  Data MAY come from $_FILES or the message body.
     * @return array A tree of upload files in a normalized structure, with each leaf
     *  an instance of UploadedFileInterface.
     */
    private function buildUploadedFiles(array $uploadedFiles) {
        return $this->normalizeUploadedFiles($uploadedFiles);
    }

    /**
     * Normalize - if not already - the list of uploaded files as a tree of upload
     * metadata, with each leaf an instance of Psr\Http\Message\UploadedFileInterface.
     *
     * Not part of PSR-17.
     *
     * IMPORTANT: For a correct normalization of the uploaded files list, the FIRST OCCURRENCE
     *            of the key "tmp_name" is checked against. See "POST method uploads" link.
     *            As soon as the key will be found in an item of the uploaded files list, it
     *            will be supposed that the array item to which it belongs is an array with
     *            a structure similar to the one saved in the global variable $_FILES when a
     *            standard file upload is executed.
     *
     * @link https://secure.php.net/manual/en/features.file-upload.post-method.php POST method uploads.
     * @link https://secure.php.net/manual/en/reserved.variables.files.php $_FILES.
     * @link https://tools.ietf.org/html/rfc1867 Form-based File Upload in HTML.
     * @link https://tools.ietf.org/html/rfc2854 The 'text/html' Media Type.
     *
     * @param array $uploadedFiles The list of uploaded files (normalized or not). Data MAY come
     *  from $_FILES or the message body.
     * @return array A tree of upload files in a normalized structure, with each leaf
     *  an instance of UploadedFileInterface.
     * @throws \InvalidArgumentException An invalid structure of uploaded files list is provided.
     */
    private function normalizeUploadedFiles(array $uploadedFiles) {
        $normalizedUploadedFiles = [];

        foreach ($uploadedFiles as $key => $item) {
            if (is_array($item)) {
                $normalizedUploadedFiles[$key] = array_key_exists('tmp_name', $item) ?
                        $this->normalizeFileUploadItem($item) :
                        $this->normalizeUploadedFiles($item);
            } elseif ($item instanceof UploadedFileInterface) {
                $normalizedUploadedFiles[$key] = $item;
            } else {
                throw new \InvalidArgumentException(
                        'The structure of the uploaded files list is not valid.'
                );
            }
        }

        return $normalizedUploadedFiles;
    }

    /**
     * Normalize the file upload item which contains the FIRST OCCURRENCE of the key "tmp_name".
     *
     * This method returns a tree structure, with each leaf
     * an instance of Psr\Http\Message\UploadedFileInterface.
     *
     * Not part of PSR-17.
     *
     * @param array $item The file upload item.
     * @return array The file upload item as a tree structure, with each leaf
     *  an instance of UploadedFileInterface.
     * @throws \InvalidArgumentException The value at the key "tmp_name" is empty.
     */
    private function normalizeFileUploadItem(array $item) {
        // Validate the value at the key "tmp_name".
        if (empty($item['tmp_name'])) {
            throw new \InvalidArgumentException(
                    'The value of the key "tmp_name" in the uploaded files list '
                    . 'must be a non-empty value or a non-empty array.'
            );
        }

        // Get the value at the key "tmp_name".
        $filename = $item['tmp_name'];

        // Return the normalized value at the key "tmp_name".
        if (is_array($filename)) {
            return $this->normalizeFileUploadTmpNameItem($filename, $item);
        }

        // Get the leaf values.
        $size = $item['size'] ?? null;
        $error = $item['error'] ?? \UPLOAD_ERR_OK;
        $clientFilename = $item['name'] ?? null;
        $clientMediaType = $item['type'] ?? null;

        // Return an instance of UploadedFileInterface.
        return $this->createUploadedFile(
                        $filename
                        , $size
                        , $error
                        , $clientFilename
                        , $clientMediaType
        );
    }

    /**
     * Normalize the array assigned as value to the FIRST OCCURRENCE of the key "tmp_name" in a
     * file upload item of the uploaded files list. It is recursively iterated, in order to build
     * a tree structure, with each leaf an instance of Psr\Http\Message\UploadedFileInterface.
     *
     * Not part of PSR-17.
     *
     * @param array $item The array assigned as value to the FIRST OCCURRENCE of the key "tmp_name".
     * @param array $currentElements An array holding the file upload key/value pairs
     *  of the current item.
     * @return array A tree structure, with each leaf an instance of UploadedFileInterface.
     * @throws \InvalidArgumentException
     */
    private function normalizeFileUploadTmpNameItem(array $item, array $currentElements) {
        $normalizedItem = [];

        foreach ($item as $key => $value) {
            if (is_array($value)) {
                // Validate the values at the keys "size" and "error".
                if (
                        !isset($currentElements['size'][$key]) ||
                        !is_array($currentElements['size'][$key]) ||
                        !isset($currentElements['error'][$key]) ||
                        !is_array($currentElements['error'][$key])
                ) {
                    throw new \InvalidArgumentException(
                            'The structure of the items assigned to the keys "size" and "error" '
                            . 'in the uploaded files list must be identical with the one of the '
                            . 'item assigned to the key "tmp_name". This restriction does not '
                            . 'apply to the leaf elements.'
                    );
                }

                // Get the array values.
                $filename = $currentElements['tmp_name'][$key];
                $size = $currentElements['size'][$key];
                $error = $currentElements['error'][$key];
                $clientFilename = isset($currentElements['name'][$key]) &&
                        is_array($currentElements['name'][$key]) ?
                        $currentElements['name'][$key] :
                        null;
                $clientMediaType = isset($currentElements['type'][$key]) &&
                        is_array($currentElements['type'][$key]) ?
                        $currentElements['type'][$key] :
                        null;

                // Normalize recursively.
                $normalizedItem[$key] = $this->normalizeFileUploadTmpNameItem($value, [
                    'tmp_name' => $filename,
                    'size' => $size,
                    'error' => $error,
                    'name' => $clientFilename,
                    'type' => $clientMediaType,
                ]);
            } else {
                // Get the leaf values.
                $filename = $currentElements['tmp_name'][$key];
                $size = $currentElements['size'][$key] ?? null;
                $error = $currentElements['error'][$key] ?? \UPLOAD_ERR_OK;
                $clientFilename = $currentElements['name'][$key] ?? null;
                $clientMediaType = $currentElements['type'][$key] ?? null;

                // Create an instance of UploadedFileInterface.
                $normalizedItem[$key] = $this->createUploadedFile(
                        $filename
                        , $size
                        , $error
                        , $clientFilename
                        , $clientMediaType
                );
            }
        }

        return $normalizedItem;
    }

    /**
     * Create an instance of UploadedFileInterface.
     *
     * Not part of PSR-17.
     *
     * @param string $filename The filename of the uploaded file.
     * @param int|null $size (optional) The file size in bytes or null if unknown.
     * @param int $error (optional) The error associated with the uploaded file. The value MUST be
     *  one of PHP's UPLOAD_ERR_XXX constants.
     * @param string|null $clientFilename (optional) The filename sent by the client, if any.
     * @param string|null $clientMediaType (optional) The media type sent by the client, if any.
     * @return UploadedFileInterface
     */
    private function createUploadedFile(
            string $filename
            , int $size = null
            , int $error = \UPLOAD_ERR_OK
            , string $clientFilename = null
            , string $clientMediaType = null
    ): UploadedFileInterface {
        // Create a stream with read-only access.
        $stream = new Stream($filename, 'rb');

        return new UploadedFile($stream, $size, $error, $clientFilename, $clientMediaType);
    }

}

The base class ServerRequestFactory:

<?php

namespace MyLib\Http\Message\Factory;

use MyLib\Http\Message\Uri;
use Psr\Http\Message\UriInterface;
use Psr\Http\Message\StreamInterface;
use MyLib\Http\Message\ServerRequest;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ServerRequestFactoryInterface;

/**
 * Server request factory.
 */
class ServerRequestFactory implements ServerRequestFactoryInterface {

    /**
     * Message body.
     *
     * @var StreamInterface
     */
    protected $body;

    /**
     * Attributes list.
     *
     * @var array
     */
    protected $attributes = [];

    /**
     * Headers list with case-insensitive header names.
     * A header value can be a string, or an array of strings.
     *
     *  [
     *      'header-name 1' => 'header-value',
     *      'header-name 2' => [
     *          'header-value 1',
     *          'header-value 2',
     *      ],
     *  ]
     *
     * @link https://tools.ietf.org/html/rfc7230#section-3.2 Header Fields.
     * @link https://tools.ietf.org/html/rfc7231#section-5 Request Header Fields.
     *
     * @var array
     */
    protected $headers = [];

    /**
     * Parsed body, e.g. the deserialized body parameters, if any.
     *
     * @var null|array|object
     */
    protected $parsedBody;

    /**
     * Query string arguments.
     *
     * @var array
     */
    protected $queryParams = [];

    /**
     * Uploaded files.
     *
     * @var array
     */
    protected $uploadedFiles = [];

    /**
     * Cookies.
     *
     * @var array
     */
    protected $cookieParams = [];

    /**
     * HTTP protocol version.
     *
     * @var string
     */
    protected $protocolVersion;

    /**
     *
     * @param StreamInterface $body Message body.
     * @param array $attributes (optional) Attributes list.
     * @param array $headers (optional) Headers list with case-insensitive header names.
     *  A header value can be a string, or an array of strings.
     * @param null|array|object $parsedBody (optional) Parsed body, e.g. the deserialized body
     *  parameters, if any. The data IS NOT REQUIRED to come from $_POST, but MUST be the
     *  results of deserializing the request body content.
     * @param array $queryParams (optional) Query string arguments. They MAY be injected from
     *  PHP's $_GET superglobal, or MAY be derived from some other value such as the URI.
     * @param array $uploadedFiles (optional) Uploaded files list as a normalized tree of upload
     *  metadata, with each leaf an instance of Psr\Http\Message\UploadedFileInterface.
     * @param array $cookieParams (optional) Cookies. The data IS NOT REQUIRED to come from
     *  the $_COOKIE superglobal, but MUST be compatible with the structure of $_COOKIE.
     * @param string $protocolVersion (optional) HTTP protocol version.
     */
    public function __construct(
            StreamInterface $body
            , array $attributes = []
            , array $headers = []
            , $parsedBody = null
            , array $queryParams = []
            , array $uploadedFiles = []
            , array $cookieParams = []
            , string $protocolVersion = '1.1'
    ) {
        $this->body = $body;
        $this->attributes = $attributes;
        $this->headers = $headers;
        $this->parsedBody = $parsedBody;
        $this->queryParams = $queryParams;
        $this->uploadedFiles = $uploadedFiles;
        $this->cookieParams = $cookieParams;
        $this->protocolVersion = $protocolVersion;
    }

    /**
     * Create a new server request.
     *
     * Note that server-params are taken precisely as given - no parsing/processing
     * of the given values is performed, and, in particular, no attempt is made to
     * determine the HTTP method or URI, which must be provided explicitly.
     *
     * @param string $method The HTTP method associated with the request.
     * @param UriInterface|string $uri The URI associated with the request. If
     *     the value is a string, the factory MUST create a UriInterface
     *     instance based on it.
     * @param array $serverParams Array of SAPI parameters with which to seed
     *     the generated request instance.
     *
     * @return ServerRequestInterface
     */
    public function createServerRequest(
            string $method
            , $uri
            , array $serverParams = []
    ): ServerRequestInterface {
        // Validate method and URI.
        $this
                ->validateMethod($method)
                ->validateUri($uri)
        ;

        // Create an instance of UriInterface.
        if (is_string($uri)) {
            $uri = new Uri($uri);
        }

        // Create the server request.
        return new ServerRequest(
                $method
                , $uri
                , $this->body
                , $this->attributes
                , $this->headers
                , $serverParams
                , $this->parsedBody
                , $this->queryParams
                , $this->uploadedFiles
                , $this->cookieParams
                , $this->protocolVersion
        );
    }

    // [... Other methods ...]

}

Creating the ServerRequestInterface instance by the ServerRequestFactoryInterface implementation:

<?php

use MyLib\Http\Message\Factory\SapiServerRequestFactory\Apache2HandlerFactory;

// [...]

// Create stream with read-only access.
$body = $streamFactory->createStreamFromFile('php://temp', 'rb');

$serverRequestFactory = new Apache2HandlerFactory(
    $body
    , [] /* attributes */
    , [] /* headers */
    , $_POST /* parsed body */
    , $_GET /* query params */
    , $_FILES /* uploaded files */
    , $_COOKIE /* cookie params */
    , '1.1' /* http protocol version */
);

$serverRequest = $serverRequestFactory->createServerRequestFromArray($_SERVER);

// [...]
PajuranCodes
  • 303
  • 3
  • 12
  • 43
  • Thank you for sharing part of your code :-) I'm still wondering if it's a good idea to implement PSR-7 in the lightweight form validation library that i'm working on... Well, I was seduced by the getUploadedFiles method of PSR-7 and wrote my own, functional one ;-) By the way, it took me some time to figure out why the last example of the PSR-7 spec (``) was rejected, it turns out that the last index of `$_FILES['size'][ 'details'][ 'avatar']` is wrong ^^ – Fravadona Apr 10 '20 at 21:49
  • @Fravadona With pleasure. It is a very good idea to use a PSR-7 + PSR-17 library. But, if you decide to implement one, take a lot of time into consideration (research, tests). Though, the end result is definitely worth it. It can be embedded in many projects, you'll feel its elegance in use and you'll discover how many new things you've learned by developing it. Nothing regarding _URI_ & _HTTP messages_ will remain unknown to you. Otherwise, just use a good external library (laminas diactoros, guzzle, slim, etc). Good luck with your library - or libraries :-) - and thanks for the appreciation. – PajuranCodes Apr 11 '20 at 05:23