bkdotcom/PHPDebugConsole

View on GitHub
src/HttpMessage/UploadedFile.php

Summary

Maintainability
A
0 mins
Test Coverage
A
100%
<?php

/**
 * This file is part of HttpMessage
 *
 * @package   bdk/http-message
 * @author    Brad Kent <bkfake-github@yahoo.com>
 * @license   http://opensource.org/licenses/MIT MIT
 * @copyright 2014-2024 Brad Kent
 * @version   v1.0
 */

namespace bdk\HttpMessage;

use bdk\HttpMessage\Stream;
use InvalidArgumentException;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UploadedFileInterface;
use RuntimeException;

/**
 * Value object representing a file uploaded through an HTTP request.
 *
 * Instances of this interface are considered immutable; all methods that
 * might change state MUST be implemented such that they retain the internal
 * state of the current instance and return an instance that contains the
 * changed state.
 */
class UploadedFile implements UploadedFileInterface
{
    /** @var string|null */
    private $clientFilename;

    /** @var string|null */
    private $clientFullPath;

    /** @var string|null */
    private $clientMediaType;

    /** @var int */
    private $error;

    /** @var array<int, string> */
    private $errors = array(
        UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk.',
        UPLOAD_ERR_EXTENSION  => 'File upload stopped by extension.',
        UPLOAD_ERR_FORM_SIZE  => 'The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form.',
        UPLOAD_ERR_INI_SIZE   => 'The uploaded file exceeds the upload_max_filesize directive in php.ini',
        UPLOAD_ERR_NO_FILE    => 'No file was uploaded.',
        UPLOAD_ERR_NO_TMP_DIR => 'Missing a temporary folder.',
        UPLOAD_ERR_OK         => 'There is no error, the file uploaded with success.',
        UPLOAD_ERR_PARTIAL    => 'The uploaded file was only partially uploaded.',
    );

    /** @var string|null */
    private $file;

    /** @var bool */
    private $isMoved = false;

    /** @var int|null */
    private $size;

    /** @var StreamInterface|null */
    private $stream;

    /** @var string */
    private $sapi = PHP_SAPI;

    /**
     * Constructor
     *
     *    __construct(array $fileInfo)
     *    __construct($streamOrFile, $size = null, $error = UPLOAD_ERR_OK, $clientFilename = null, $clientMediaType = null, $clientFullPath = null)
     *
     * @param mixed ...$values Uploaded file values as populated in $_FILES array
     *    null|string|resource|StreamInterface tmp_name  filepath, resource, or StreamInterface
     *    int    size      Size in bytes
     *    int    error     one of the UPLOAD_ERR_* constants
     *    string name      client file name
     *    string type      client mime type
     *    string full_path client full path (as of php 8.1)
     *
     * @throws InvalidArgumentException
     *
     * @see https://www.php.net/manual/en/features.file-upload.post-method.php
     */
    public function __construct($values = array())
    {
        // @phpcs:ignore SlevomatCodingStandard.Arrays.AlphabeticallySortedByKeys.IncorrectKeyOrder
        $defaultValues = array(
            'tmp_name' => null,
            'size' => null,
            'error' => UPLOAD_ERR_OK,
            'name' => null,
            'type' => null,
            'full_path' => null,
        );
        if (\is_array($values) === false) {
            $values = \array_combine(\array_slice(\array_keys($defaultValues), 0, \func_num_args()), \func_get_args());
        }
        $values = \array_merge($defaultValues, $values);

        $this->assertSize($values['size']);
        $this->assertError($values['error']);
        $this->assertClientFilename($values['name']);
        $this->assertClientMediaType($values['type']);
        $this->assertStringOrNull($values['full_path'], 'clientFullPath');

        $this->size = $values['size'];
        $this->error = $values['error'];
        $this->clientFilename = $values['name'];
        $this->clientFullPath = $values['full_path'];
        $this->clientMediaType = $values['type'];

        if ($this->isOk()) {
            $this->setStream($values['tmp_name']);
        }
    }

    /**
     * Retrieve a stream representing the uploaded file.
     *
     * If the moveTo() method has been called previously, this method will raise
     * an exception.
     *
     * @return StreamInterface representation of the uploaded file.
     *
     * @throws RuntimeException in cases when no stream is available or can be
     *     created.
     */
    public function getStream()
    {
        if ($this->isMoved) {
            throw new RuntimeException('The stream has been moved.');
        }
        if (!$this->stream && $this->file) {
            $this->stream = $this->getStreamFromFile();
        }
        if (!$this->stream) {
            // ie error is not UPLOAD_ERR_OK
            throw new RuntimeException('No stream is available or can be created.');
        }
        return $this->stream;
    }

    /**
     * Move the uploaded file to a new location.
     *
     * Use this method as an alternative to move_uploaded_file().
     * This method is guaranteed to work in both SAPI and non-SAPI environments.
     *
     * $targetPath may be an absolute path, or a relative path. If it is a
     * relative path, resolution will be the same as used by PHP's rename()
     * function.
     *
     * The original file or stream will be removed on completion.
     *
     * If this method is called more than once, any subsequent calls will raise
     * an exception.
     *
     * When used in an SAPI environment where $_FILES is populated, when writing
     * files via moveTo(), is_uploaded_file() and move_uploaded_file() SHOULD be
     * used to ensure permissions and upload status are verified correctly.
     *
     * If you wish to move to a stream, use getStream(), as SAPI operations
     * cannot guarantee writing to stream destinations.
     *
     * @param string $targetPath Path to which to move the uploaded file.
     *
     * @return void
     *
     * @throws InvalidArgumentException if the $targetPath specified is invalid.
     * @throws RuntimeException on any error during the move operation, or on
     *     the second or subsequent call to the method.
     *
     * @see http://php.net/is_uploaded_file
     * @see http://php.net/move_uploaded_file
     */
    public function moveTo($targetPath)
    {
        $this->validateCanMove();
        $this->assertTargetPath($targetPath);
        if ($this->file !== null) {
            $this->isMoved = $this->moveFile($targetPath);
            return;
        }
        $stream = $this->getStream();
        if ($stream->isSeekable()) {
            $stream->rewind();
        }
        // Copy the contents of a stream into another stream until end-of-file.
        $dest = new Stream(\fopen($targetPath, 'w'));
        $bufferSize = (int) \pow(1024, 2); // 1 MB
        while (!$stream->eof()) {
            $dest->write($stream->read($bufferSize));
        }
        $this->isMoved = true;
    }

    /**
     * Retrieve the file size.
     *
     * Implementations SHOULD return the value stored in the "size" key of
     * the file in the $_FILES array if available, as PHP calculates this based
     * on the actual size transmitted.
     *
     * @return int|null The file size in bytes or null if unknown.
     */
    public function getSize()
    {
        return $this->size;
    }

    /**
     * Retrieve the error associated with the uploaded file.
     *
     * Returns one of PHP's UPLOAD_ERR_XXX constants.
     *
     * If the file was uploaded successfully, will return
     * UPLOAD_ERR_OK.
     *
     * @return int One of PHP's UPLOAD_ERR_XXX constants.
     *
     * @see http://php.net/manual/en/features.file-upload.errors.php
     */
    public function getError()
    {
        return $this->error;
    }

    /**
     * Get uploaded file's error message
     *
     * This is a non PSR-7 method
     *
     * @return string
     */
    public function getErrorMessage()
    {
        if ($this->error === UPLOAD_ERR_OK) {
            return '';
        }
        return $this->errors[$this->error];
    }

    /**
     * Retrieve the filename sent by the client.
     *
     * Do not trust the value returned by this method. A client could send
     * a malicious filename with the intention to corrupt or hack your
     * application.
     *
     * @return string|null The filename sent by the client
     *   or null if none was provided.
     */
    public function getClientFilename()
    {
        return $this->clientFilename ?: null;
    }

    /**
     * Retrieve the full_path sent by the client.
     *
     * NOT DEFINED IN INTERFACE
     *
     * full_path is new as of PHP 8.1 and passed by client when uploading a directory
     *
     * Do not trust the value returned by this method. A client could send
     * a malicious filename with the intention to corrupt or hack your
     * application.
     *
     * @return string|null The full-path sent by the client
     *   or null if none was provided.
     */
    public function getClientFullPath()
    {
        return $this->clientFullPath ?: null;
    }

    /**
     * Retrieve the media type sent by the client.
     *
     * Do not trust the value returned by this method. A client could send
     * a malicious media type with the intention to corrupt or hack your
     * application.
     *
     * @return string|null The media type sent by the client
     *   or null if none was provided.
     */
    public function getClientMediaType()
    {
        return $this->clientMediaType ?: null;
    }

    /**
     * Assert valid client filename
     *
     * @param mixed $filename filename to validate
     *
     * @return void
     * @throws InvalidArgumentException
     *
     * @psalm-assert string|null $filename
     */
    private function assertClientFilename($filename)
    {
        $this->assertStringOrNull($filename, 'clientFilename');
        if ($filename === null) {
            return;
        }
        if (\preg_match('#[/\r\n\x00]#', $filename)) {
            throw new InvalidArgumentException(\sprintf('Invalid client file name provided: "%s"', $filename));
        }
    }

    /**
     * Assert valid client filename
     *
     * @param mixed $type Content/Media type to validate
     *
     * @return void
     * @throws InvalidArgumentException
     *
     * @psalm-assert null|string $type
     */
    private function assertClientMediaType($type)
    {
        $this->assertStringOrNull($type, 'clientMediaType');
        if ($type === null || $type === '') {
            return;
        }
        // https://stackoverflow.com/a/48046041/1646086
        // https://en.wikipedia.org/wiki/Media_type#Standards_tree
        $regex = '#^'
            . '(?P<type>\w+)'
            . '/'
            . '(?P<subtype>[-.\w]+)'
            . '(?P<suffix>\+[-.\w]+)?'
            . '(\s*;\s*(?P<param>\w+)\s*=\s*(?P<val>\S+))*'
            . '$#' ;
        if (\preg_match($regex, $type) !== 1) {
            throw new InvalidArgumentException(\sprintf('Invalid media type specified: "%s"', $type));
        }
    }

    /**
     * Validate uploaded file error
     *
     * @param mixed $error one of the UPLOAD_ERR_* constants
     *
     * @return void
     *
     * @throws InvalidArgumentException
     *
     * @psalm-assert int<0,8> $error
     */
    private function assertError($error)
    {
        if (\is_int($error) === false || \array_key_exists($error, $this->errors) === false) {
            throw new InvalidArgumentException('Upload file error status must be an integer value and one of the "UPLOAD_ERR_*" constants.');
        }
    }

    /**
     * Validate reported filesize
     *
     * @param mixed $size Reported filesize
     *
     * @return void
     *
     * @throws InvalidArgumentException
     *
     * @psalm-assert null|positive-int $size
     */
    private function assertSize($size)
    {
        if ($size === null || \is_int($size) && $size > -1) {
            return;
        }
        throw new InvalidArgumentException(\sprintf('Upload file size must be a positive integer.  %s provided', \gettype($size)));
    }

    /**
     * Validate client filename / filepath / mediaType
     *
     * @param mixed  $value Reported filesize
     * @param string $key   nNme of param being tested
     *
     * @return void
     *
     * @throws InvalidArgumentException
     *
     * @psalm-assert string|null $value
     */
    private function assertStringOrNull($value, $key)
    {
        if ($value === null || \is_string($value)) {
            return;
        }
        throw new InvalidArgumentException(\sprintf(
            'Upload file %s must be a string or null. %s provided.',
            $key,
            \gettype($value)
        ));
    }

    /**
     * Validate target path
     *
     * @param string $targetPath Path to which to move the uploaded file.
     *
     * @return void
     *
     * @throws InvalidArgumentException
     * @throws RuntimeException
     *
     * @psalm-assert non-empty-string $targetPath
     */
    private function assertTargetPath($targetPath)
    {
        if (\is_string($targetPath) === false || $targetPath === '') {
            throw new InvalidArgumentException('Invalid path provided for move operation; must be a non-empty string');
        }
        if (\is_writable(\dirname($targetPath)) === false) {
            // Throw exception if the $targetPath specified is invalid.
            throw new RuntimeException(\sprintf(
                'The target path "%s" is not writable.',
                $targetPath
            ));
        }
    }

    /**
     * Create Stream from filepath
     *
     * @return Stream
     *
     * @throws RuntimeException
     */
    private function getStreamFromFile()
    {
        /** @var non-empty-string $this->file */
        $errMsg = '';
        \set_error_handler(static function ($type, $msg) use (&$errMsg) {
            $errMsg = '(' . $type . ') ' . $msg;
            return true; // Don't execute PHP internal error handler
        });
        $resource = \fopen($this->file, 'r');
        \restore_error_handler();
        if (\is_resource($resource)) {
            return new Stream($resource);
        }
        throw new RuntimeException(
            $errMsg ?: 'Unable to open ' . $this->file
        );
    }

    /**
     * Test if no upload error
     *
     * @return bool
     */
    private function isOk()
    {
        return $this->error === UPLOAD_ERR_OK;
    }

    /**
     * Move file
     *
     * @param string $targetPath Path to which to move the uploaded file.
     *
     * @return bool
     *
     * @throws RuntimeException
     */
    private function moveFile($targetPath)
    {
        /** @var string $this->file */
        $errMsg = '';
        // phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter
        \set_error_handler(static function ($type, $msg) use (&$errMsg) {
            $errMsg = $msg;
            return true; // Don't execute PHP internal error handler
        });
        $success = $this->sapi === 'cli'
            ? \rename($this->file, $targetPath)
            : \move_uploaded_file($this->file, $targetPath);
        \restore_error_handler();
        if ($success === false) {
            throw new RuntimeException(\rtrim(\sprintf(
                'Unable to move the file to %s (%s)',
                $targetPath,
                $errMsg
            ), ' ()'));
        }
        return $success;
    }

    /**
     * Set the file's stream
     *
     * @param mixed $streamOrFile filepath, resource, or StreamInterface
     *
     * @return void
     *
     * @throws InvalidArgumentException
     *
     * @psalm-assert string|resource|StreamInterface $streamOrFile
     */
    private function setStream($streamOrFile)
    {
        if (\is_string($streamOrFile)) {
            $this->file = $streamOrFile;
            if (\file_exists($this->file)) {
                $this->size = \filesize($this->file);
            }
            return;
        }
        if (\is_resource($streamOrFile)) {
            $this->stream = new Stream($streamOrFile);
            $this->size = $this->stream->getSize();
            return;
        }
        if ($streamOrFile instanceof StreamInterface) {
            $this->stream = $streamOrFile;
            $this->size = $this->stream->getSize();
            return;
        }
        throw new InvalidArgumentException('Invalid file, resource, or StreamInterface provided for UploadedFile');
    }

    /**
     * @return void
     * @throws RuntimeException if is moved or not ok
     */
    private function validateCanMove()
    {
        if ($this->isOk() === false) {
            throw new RuntimeException('Cannot move upload due to upload error: ' . $this->getErrorMessage());
        }
        if ($this->isMoved) {
            throw new RuntimeException('Cannot move upload after it has already been moved (#reasons)');
        }
    }
}