dadajuice/zephyrus

View on GitHub
src/Zephyrus/Utilities/Uploader/FileUpload.php

Summary

Maintainability
A
3 hrs
Test Coverage
A
95%
<?php namespace Zephyrus\Utilities\Uploader;

use InvalidArgumentException;
use Zephyrus\Exceptions\UploaderException;
use Zephyrus\Network\ContentType;
use Zephyrus\Security\Cryptography;
use Zephyrus\Utilities\FileSystem\Directory;
use Zephyrus\Utilities\FileSystem\File;

class FileUpload
{
    /**
     * Original received data array for file upload. Should contain the following keys : ['error', 'tmp_name', 'type',
     * 'name', 'size'].
     *
     * @var array
     */
    private array $rawData;

    /**
     * File instance for the temporary uploaded file.
     *
     * @var File
     */
    private File $file;

    /**
     * Maximum file size in byte allowed per single uploaded file. Defaults to 0 which means it has no limit. In that
     * case the limit would be defined by the php.ini configurations.
     *
     * @var int
     */
    private int $maximumFileSize = 0;

    /**
     * List of extensions (without ".") allowed for the upload file. Defaults to anything.
     *
     * @var array
     */
    private array $allowedExtensions = [];

    /**
     * List of mime types allowed for the upload file. Defaults to any content type.
     *
     * @var array
     */
    private array $allowedMimeTypes = [ContentType::ANY];

    /**
     * Defines if the destination file can be overwritten if it already exists. Otherwise, it will throw an
     * UploadException.
     *
     * @var bool
     */
    private bool $overwritePermitted = false;

    /**
     * If the specified destination doesn't exist, this property determines if the class should try to create the
     * folders required. Throws an exception if it's not possible or if this setting is false and the destination
     * doesn't exist.
     *
     * @var bool
     */
    private bool $destinationCreationPermitted = true;

    /**
     * Defines if the object should rename the uploaded file to a secure cryptographic random new name or keep the
     * original one. Defaults to false for security measures (an uploader should not be able to guess the uploaded
     * file name).
     *
     * @var bool
     */
    private bool $keepOriginalName = false;

    /**
     * Defines a custom function to be called when a new filename needs to be generated.
     *
     * @var callable|null
     */
    private $customGenerationCallback = null;

    /**
     * Returns the maximum allowed upload size (in MB) based on the server configurations. To increment this size the
     * upload_max_filesize and post_max_size properties must be modified (either directly in the PHP.ini, in a .htaccess
     * or using ini_set function).
     *
     * @return int
     */
    final public static function getMaxUploadSize(): int
    {
        $maxUpload = (int) ini_get('upload_max_filesize');
        $maxPost = (int) ini_get('post_max_size');
        $memoryLimit = (int) ini_get('memory_limit');
        return min($maxUpload, $maxPost, $memoryLimit);
    }

    /**
     * Builds an instance based on a valid $_FILE element which is an array with the following keys : error, size,
     * tmp_name, name and size. Upon instanciation, if something is wrong with the given data, an exception will be
     * thrown preventing any instance to be built. Makes sure that the given element is properly formed and is really
     * an uploaded file.
     *
     * @param array $data
     * @throws UploaderException
     */
    public function __construct(array $data)
    {
        $this->initializeRawData($data);
        $this->initializeFile($data);
    }

    /**
     * Proceeds to move the file from the temporary uploaded folder to a specified destination. If no filename is given,
     * the instance will try to forge a filename and keep the original extension based on the current configurations. By
     * default, if no filename is given, a new cryptographic random filename will be generated (recommended). Throws an
     * exception if the validations failed or if something happened with the upload.
     *
     * @param string $destinationDirectory
     * @param string|null $filename
     * @throws UploaderException
     * @return string
     */
    public function upload(string $destinationDirectory, ?string $filename = null): string
    {
        $this->validateBeforeUpload();
        if (is_null($filename)) {
            $filename = ($this->keepOriginalName)
                ? $this->getOriginalFilename()
                : $this->generateNewFilename();
        }
        $path = $this->prepareDestination($destinationDirectory, $filename);
        if (!$this->moveUploadedFile($this->getTemporaryFilepath(), $path)) {
            throw new UploaderException(UploaderException::ERROR_MOVE_UPLOADED_FILE_FAILED, $this->rawData);
        }
        return $path;
    }

    /**
     * Retrieves the original filename used for the uploaded file. It should be the original name of the file from the
     * client computer. Will be used by default as the destination name if the keepOriginalName property is true and no
     * other filename is given.
     *
     * @return string
     */
    public function getOriginalFilename(): string
    {
        return $this->rawData['name'];
    }

    /**
     * Retrieves the extension of the uploaded file based on the original name. Cannot use the REAL file representation
     * since it can be altered by web server / PHP to be stored in tmp folder with unique name which may skip the
     * extension. Be warned that someone could potentially inject something through the extension if it's not properly
     * validated.
     *
     * @return string
     */
    public function getExtension(): string
    {
        return pathinfo($this->getOriginalFilename(), PATHINFO_EXTENSION);
    }

    /**
     * Retrieves the uploaded file size in bytes. Uses the REAL file size and doesn't consider the given size when the
     * file was uploaded as this could be forged.
     *
     * @return int
     */
    public function getSize(): int
    {
        return $this->file->size();
    }

    /**
     * Retrieves the uploaded file mime type. Uses the REAL mime type and doesn't consider the given mime type when the
     * file was uploaded as this could be forged.
     *
     * @return string
     */
    public function getMimeType(): string
    {
        return $this->file->getMimeType();
    }

    /**
     * Retrieves the uploaded file temporary path before being uploaded. This value doesn't make sense once the upload
     * has been made.
     *
     * @return string
     */
    public function getTemporaryFilepath(): string
    {
        return $this->rawData['tmp_name'];
    }

    /**
     * Verifies all restrictions.
     *
     * @return bool
     */
    public function verify(): bool
    {
        return $this->isSizeAllowed() && $this->isMimeTypeAllowed() && $this->isExtensionAllowed();
    }

    /**
     * Verifies if the file's mime type is allowed for the upload session.
     *
     * @return bool
     */
    public function isMimeTypeAllowed(): bool
    {
        if (empty($this->allowedMimeTypes)
            || in_array(ContentType::ANY, $this->allowedMimeTypes)
            || in_array($this->file->getMimeType(), $this->allowedMimeTypes)) {
            return true;
        }
        return false;
    }

    /**
     * Verifies if the file's extension is allowed for the upload session.
     *
     * @return bool
     */
    public function isExtensionAllowed(): bool
    {
        if (empty($this->allowedExtensions)) {
            return true;
        }
        return in_array(strtolower($this->getExtension()), $this->allowedExtensions);
    }

    /**
     * Verifies if the file size is below or equal the defined upload size limit.
     *
     * @return bool
     */
    public function isSizeAllowed(): bool
    {
        if ($this->maximumFileSize <= 0) {
            return true;
        }
        return $this->file->size() <= $this->maximumFileSize;
    }

    /**
     * Applies a restriction over uploaded file extension. By default, any extension is allowed. User should verify the
     * mime types instead as its more reliable and precise.
     *
     * @param array $extensions
     */
    public function setAllowedExtensions(array $extensions)
    {
        foreach ($extensions as &$extension) {
            $extension = strtolower(ltrim($extension, "."));
        }
        $this->allowedExtensions = $extensions;
    }

    /**
     * Applies a restriction over uploaded file mime type. By default, any mime type is allowed.
     *
     * @param array $mimeTypes
     */
    public function setAllowedMimeTypes(array $mimeTypes)
    {
        foreach ($mimeTypes as &$mimeType) {
            $mimeType = strtolower($mimeType);
        }
        $this->allowedMimeTypes = $mimeTypes;
    }

    /**
     * Defines the maximum allowed size for the file in bytes.
     *
     * @param int $bytes
     */
    public function setAllowedSize(int $bytes)
    {
        if ($bytes < 0) {
            throw new InvalidArgumentException("Allowed size must be positive int value.");
        }
        $this->maximumFileSize = $bytes;
    }

    /**
     * Applies if the class should overwrite the destination file if it exists. Defaults to false.
     *
     * @param bool $permitOverwrite
     */
    public function setOverwritePermitted(bool $permitOverwrite)
    {
        $this->overwritePermitted = $permitOverwrite;
    }

    /**
     * Applies if the class should attempt to create the destination folder in case it doesn't exist. Defaults to true.
     * Otherwise, an exception would occur.
     *
     * @param bool $permitDestinationCreation
     */
    public function setDestinationCreationPermitted(bool $permitDestinationCreation)
    {
        $this->destinationCreationPermitted = $permitDestinationCreation;
    }

    /**
     * Determines if the uploader should keep the original name or generate a random one when no filename is specified
     * in the upload.
     *
     * @param bool $keepOriginalName
     */
    public function setKeepOriginalName(bool $keepOriginalName)
    {
        $this->keepOriginalName = $keepOriginalName;
    }

    /**
     * Changes the default random filename generator function (cryptographic random of 24 characters). Given callback
     * must return a string.
     *
     * @param callable $callback
     */
    public function setCustomFilenameGenerator(callable $callback)
    {
        $this->customGenerationCallback = $callback;
    }

    /**
     * Simple wrapper method for move_uploaded_file allowing easier mock data for unit testing. Normal class usage
     * should not override this method.
     *
     * @param string $temporaryFilepath
     * @param string $destinationFilepath
     * @return bool
     */
    protected function moveUploadedFile(string $temporaryFilepath, string $destinationFilepath): bool
    {
        return move_uploaded_file($temporaryFilepath, $destinationFilepath);
    }

    /**
     * Simple wrapper method for is_uploaded_file allowing easier mock data for unit testing. Normal class usage should
     * not override this method.
     *
     * @param string $temporaryFilepath
     * @return bool
     */
    protected function isUploadedFile(string $temporaryFilepath): bool
    {
        return is_uploaded_file($temporaryFilepath);
    }

    /**
     * Launches the verifications before proceeding with the upload. It should not be possible to call the upload
     * method on a file which is not compliant with the configured restrictions.
     *
     * @throws UploaderException
     */
    private function validateBeforeUpload()
    {
        if (!$this->isExtensionAllowed()) {
            throw new UploaderException(UploaderException::ERROR_UPLOAD_EXTENSION, $this->rawData);
        }
        if (!$this->isMimeTypeAllowed()) {
            throw new UploaderException(UploaderException::ERROR_UPLOAD_MIME_TYPE, $this->rawData);
        }
        if (!$this->isSizeAllowed()) {
            throw new UploaderException(UploaderException::ERROR_UPLOAD_SIZE, $this->rawData);
        }
    }

    /**
     * Prepares and validates the given form upload raw data. Verifies if the structure is compliant with a valid upload
     * and makes sure the error key for any of the uploaded file is valid.
     *
     * @param array $rawData
     * @throws UploaderException
     */
    private function initializeRawData(array $rawData)
    {
        $this->verifyRawDataStructure($rawData);
        $this->verifyRawDataError($rawData);
        $this->rawData = $rawData;
    }

    /**
     * @param array $data
     * @throws UploaderException
     */
    private function verifyRawDataStructure(array $data)
    {
        $neededKeys = ['error', 'tmp_name', 'type', 'name', 'size'];
        $missingKeys = array_diff_key(array_flip($neededKeys), $data);
        if (!empty($missingKeys)) {
            throw new UploaderException(UploaderException::ERROR_INVALID_STRUCTURE, $data);
        }
        if (!is_int($data['error'])) {
            throw new UploaderException(UploaderException::ERROR_INVALID_STRUCTURE, $data);
        }
    }

    /**
     * @param array $data
     * @throws UploaderException
     */
    private function verifyRawDataError(array $data)
    {
        if ($data['error'] > UPLOAD_ERR_OK) {
            throw new UploaderException($data['error'], $data);
        }
    }

    /**
     * @param array $data
     * @throws UploaderException
     */
    private function initializeFile(array $data)
    {
        if (!File::exists($data['tmp_name'])) {
            throw new UploaderException(UploaderException::ERROR_UNREADABLE_TMP_FILE, $data);
        }
        if (!$this->isUploadedFile($data['tmp_name'])) {
            throw new UploaderException(UploaderException::ERROR_NOT_UPLOADED_FILE, $data);
        }
        $this->file = new File($data['tmp_name']);
    }

    /**
     * Executes the filename generation custom callback if defined. Otherwise, returns a cryptographic random string of
     * 24 characters.
     *
     * @return string
     */
    private function generateNewFilename(): string
    {
        return (!is_null($this->customGenerationCallback))
            ? ($this->customGenerationCallback)()
            : $this->defaultFilenameGenerator();
    }

    private function defaultFilenameGenerator(): string
    {
        $extension = $this->getExtension();
        $basename = Cryptography::randomString(24);
        if (!empty($extension)) {
            $basename .= '.' . $extension;
        }
        return $basename;
    }

    /**
     * @param string $destinationDirectory
     * @param string $filename
     * @throws UploaderException
     * @return string
     */
    private function prepareDestination(string $destinationDirectory, string $filename): string
    {
        $destinationDirectory = rtrim($destinationDirectory, " \t\n\r\0\x0B" . DIRECTORY_SEPARATOR);
        if (!Directory::exists($destinationDirectory)) {
            if (!$this->destinationCreationPermitted) {
                throw new UploaderException(UploaderException::ERROR_INVALID_DESTINATION, $this->rawData);
            }
            Directory::create($destinationDirectory);
        }
        if (File::exists($destinationDirectory . DIRECTORY_SEPARATOR . $filename) && !$this->overwritePermitted) {
            throw new UploaderException(UploaderException::ERROR_DESTINATION_ALREADY_EXISTS, $this->rawData);
        }
        return $destinationDirectory . DIRECTORY_SEPARATOR . $filename;
    }
}