Skip to content

Instantly share code, notes, and snippets.

@DaveRandom
Last active December 12, 2015 03:38
Show Gist options
  • Save DaveRandom/4708126 to your computer and use it in GitHub Desktop.
Save DaveRandom/4708126 to your computer and use it in GitHub Desktop.
Simple file upload manager lib
<?php
namespace Upload;
class File implements IFile {
/**
* The meta data about the file (from the $_FILES array)
*
* @var array $uploadMeta
*/
protected $uploadMeta;
/**
* Gets the string error description for an upload error code
*
* @param int $errCode
* @return string
*/
protected function uploadErrCodeToString($errCode) {
switch ($errCode) {
case UPLOAD_ERR_OK:
return 'No error';
case UPLOAD_ERR_INI_SIZE:
return 'The file size exceeds the upload_max_filesize directive in php.ini';
case UPLOAD_ERR_FORM_SIZE:
return 'The file size exceeds the MAX_FILE_SIZE directive that was specified in the HTML form';
case UPLOAD_ERR_PARTIAL:
return 'The file was only partially uploaded';
case UPLOAD_ERR_NO_FILE:
return 'No file was uploaded';
case UPLOAD_ERR_NO_TMP_DIR:
return 'Temporary directory missing';
case UPLOAD_ERR_CANT_WRITE:
return 'Disk write failed';
case UPLOAD_ERR_EXTENSION:
return 'A PHP extension stopped the file upload';
default:
return 'Unknown error';
}
}
/**
* Checks that the file was uploaded successfully
*
* @throws \RuntimeException
*/
protected function checkUploadSuccess() {
if ($this->uploadMeta['error']) {
throw new \RuntimeException($this->uploadErrCodeToString($this->uploadMeta['error']));
}
if (!is_uploaded_file($this->uploadMeta['tmp_name'])) {
throw new \RuntimeException('Temporary file is not an uploaded file');
}
if (!is_readable($this->uploadMeta['tmp_name'])) {
throw new \RuntimeException('Temporary file is not readable');
}
}
/**
* @param array $uploadMeta
* @throws \InvalidArgumentException
*/
public function __construct(array $uploadMeta) {
if (!isset($uploadMeta['tmp_name'], $uploadMeta['error'])) {
throw new \InvalidArgumentException('The supplied upload meta data structure is invalid');
}
$this->uploadMeta = $uploadMeta;
}
/**
* @return string
*/
public function getName() {
return $this->uploadMeta['name'];
}
/**
* @return string
*/
public function getTmpName() {
return $this->uploadMeta['tmp_name'];
}
/**
* @return int
*/
public function getSize() {
return $this->uploadMeta['size'];
}
/**
* @return string
*/
public function getType() {
return $this->uploadMeta['type'];
}
/**
* Saves the file in the specified location
*
* @param string|resource $dest
* @throws \RuntimeException
*/
public function save($dest) {
$this->checkUploadSuccess();
if (is_resource($dest)) {
if (!$fp = fopen($this->uploadMeta['tmp_name'], 'r')) {
throw new \RuntimeException('Opening temp path for reading failed');
} else if (stream_copy_to_stream($dest) === FALSE) {
throw new \RuntimeException('Stream copy operation failed');
}
} else if (!move_uploaded_file($this->uploadMeta['tmp_name'], $dest)) {
throw new \RuntimeException('Moving uploaded file failed');
}
}
/**
* Returns the file data as a string
*/
public function getData() {
$this->checkUploadSuccess();
return file_get_contents($this->uploadMeta['tmp_name']);
}
}
<?php
namespace Upload;
class FileCollection implements \ArrayAccess, \Iterator, \Countable {
private $files = array();
/**
* @param IFile $file
*/
public function addFile(IFile $file) {
$this->files[] = $file;
}
/**
* @param int $index
* @return IFile
*/
public function getFile($index) {
return isset($this->files[$index]) ? $this->files[$index] : NULL;
}
// ArrayAccess
/**
* @param mixed $offset
* @param mixed $value
*/
public function offsetSet($offset, $value) {}
/**
* @param mixed $offset
*/
public function offsetExists($offset) {
return isset($this->files[$offset]);
}
/**
* @param mixed $offset
*/
public function offsetUnset($offset) {}
/**
* @param mixed $offset
*/
public function offsetGet($offset) {
return isset($this->files[$offset]) ? $this->files[$offset] : NULL;
}
// Iterator
/**
* @return int
*/
public function key() {
return key($this->files);
}
/**
* @return mixed
*/
public function current() {
return current($this->files);
}
/**
* @return int
*/
public function next() {
next($this->files);
}
public function rewind() {
reset($this->files);
}
/**
* @return bool
*/
public function valid() {
$key = key($this->files);
return $key !== NULL && $key !== FALSE;
}
// Countable
/**
* @return int
*/
public function count() {
return count($this->files);
}
}
<?php
namespace Upload;
class FileFactory {
/**
* @var string $className
*/
private $className;
/**
* @param string $className
* @throws \InvalidArgumentException
*/
public function __construct($className) {
if (!class_exists($className)) {
$className = __NAMESPACE__.'\\'.$className;
}
if (!class_exists($className)) {
throw new \InvalidArgumentException('Specified class name does not exist');
}
if (!is_subclass_of($className, __NAMESPACE__.'\\'.'IFile')) {
throw new \InvalidArgumentException('Class must implement the '.__NAMESPACE__.'\\IFile interface');
}
$this->className = $className;
}
/**
* @param array $fileData
* @return IFile
*/
public function create(array $fileData) {
$className = $this->className;
return new $className($fileData);
}
}
<?php
namespace Upload;
interface IFile {
public function __construct(array $uploadMeta);
public function getName();
public function getTmpName();
public function getSize();
public function getType();
/*
* Saves the file in the specified location
*/
public function save($dest);
/*
* Deprecated alias of getData()
*/
// public function get();
/*
* Returns the file data as a string
*/
public function getData();
}
<?php
namespace Upload;
define('IMAGETYPE_ORIGINAL', -1);
class Image extends File {
/**
* The width in pixels of the uploaded image
*
* @var int $baseWidth
*/
private $baseWidth;
/**
* The height in pixels of the uploaded image
*
* @var int $baseHeight
*/
private $baseHeight;
/**
* The IMAGETYPE_XXX constant for the uploaded image
*
* @var int $baseType
*/
private $baseType;
/**
* A GD resource for the uploaded image
*
* @var resource $baseImage
*/
private $baseImage;
/**
* The default IMAGETYPE_XXX constant for output
*
* @var int $defaultOutputType
*/
private $defaultOutputType = 0;
/**
* The default quality factor for output
*
* @var int $defaultOutputQuality
*/
private $defaultOutputQuality = 100;
/**
* Derives an IMAGETYPE_XXX constant from a file extension
*
* @param string $path
* @return int
*/
private function getTypeFromFilePath($path) {
switch (strtolower(pathinfo($path, PATHINFO_EXTENSION))) {
case 'gif':
return IMAGETYPE_GIF;
case 'jpg': case 'jpe': case 'jpeg':
return IMAGETYPE_JPEG;
case 'png':
return IMAGETYPE_PNG;
default:
return FALSE;
}
}
/**
* Derives a GD function suffix from an IMAGETYPE_XXX constant
*
* @param int $type
* @return string
*/
private function getFunctionSuffixFromType($type) {
switch ($type) {
case IMAGETYPE_GIF:
return 'gif';
case IMAGETYPE_JPEG:
return 'jpeg';
case IMAGETYPE_PNG:
return 'png';
}
}
/**
* Derives a file extension from an IMAGETYPE_XXX constant
*
* @param int $type
* @return string
*/
private function getFileExtensionFromType($type) {
switch ($type) {
case IMAGETYPE_GIF:
return 'gif';
case IMAGETYPE_JPEG:
return 'jpg';
case IMAGETYPE_PNG:
return 'png';
}
}
/**
* Determine if an IMAGETYPE_XXX constant is supported - also accepts 0 for auto-detect
*
* @param int $type
* @return bool
*/
private function isSupportedType($type) {
return in_array($type, array(0, IMAGETYPE_GIF, IMAGETYPE_JPEG, IMAGETYPE_PNG));
}
/**
* Normalise an integer to within the range 0 - 100
*
* @param int $quality
* @return int
*/
private function normaliseQualityFactor($quality) {
if ($quality < 0) {
$quality = 0;
} else if ($quality > 100) {
$quality = 100;
}
return (int) $quality;
}
/**
* Normalise the new dimensions for a resize operation
*
* @param int $newWidth
* @param int $newHeight
*/
private function processNewDimensions(&$newWidth, &$newHeight) {
// Both dimensions supplied
if (isset($newWidth, $newHeight)) {
$newWidth = (int) $newWidth;
$newHeight = (int) $newHeight;
return;
}
// Neither dimension supplied
if (!isset($newWidth) && !isset($newHeight)) {
$newWidth = $this->baseWidth;
$newHeight = $this->baseHeight;
return;
}
// Only one dimension supplied
$ratio = $this->baseWidth / $this->baseHeight;
if (isset($newWidth)) {
$newWidth = (int) $newWidth;
$newHeight = (int) floor($newWidth / $ratio);
} else {
$newWidth = floor($newHeight * $ratio);
$newHeight = (int) $newHeight;
}
}
/**
* Populates the $baseImage property if not yet done
*
* @return bool
* @throws \RuntimeException
*/
private function loadImageResource() {
if (!isset($this->baseImage)) {
$funcSuffix = $this->getFunctionSuffixFromType($this->baseType);
$this->baseImage = call_user_func('imagecreatefrom'.$funcSuffix, $this->uploadMeta['tmp_name']);
if (!$this->baseImage) {
throw new \RuntimeException('Loading source image resource failed');
}
}
}
/**
* Creates a new image resource with original image data resampled
*
* @param int $newWidth
* @param int $newHeight
* @throws \RuntimeException
*/
private function doImageResample($newWidth, $newHeight) {
if (!$dstImage = imagecreatetruecolor($newWidth, $newHeight)) {
throw new \RuntimeException('Creating destination image resource failed');
}
if (!imagecopyresampled($dstImage, $this->baseImage, 0, 0, 0, 0, $newWidth, $newHeight, $this->baseWidth, $this->baseHeight)) {
throw new \RuntimeException('Resampling image data failed');
}
return $dstImage;
}
/**
* Save an image resource to a path with the correct type
*
* @param resource $image
* @param string|resource $dest
* @param int $type
* @param int $quality
* @throws \RuntimeException
*/
private function doSaveImage($image, $dest, $type, $quality) {
$path = !is_resource($dest) ? $dest : NULL;
if (!isset($path)) {
ob_start();
} else if ($this->getTypeFromFilePath($path) !== $type) {
$path .= '.'.$this->getFileExtensionFromType($type);
}
$success = FALSE;
switch ($type) {
case IMAGETYPE_GIF:
if (imagegif($image, $path)) {
$success = TRUE;
}
break;
case IMAGETYPE_JPEG:
if (imagejpeg($image, $path, $quality)) {
$success = TRUE;
}
break;
case IMAGETYPE_PNG:
$quality = min(9, floor($quality / 10));
if (imagegif($image, $path, $quality)) {
$success = TRUE;
}
break;
}
if (!isset($path) && $success) {
$success = (bool) fwrite($dest, ob_get_clean());
}
if (!$success) {
throw new \RuntimeException('Saving image failed');
}
}
/**
* Controls image resampling and writing to path
*
* @param string|resource $dest
* @param int $newWidth
* @param int $newHeight
* @param int $type
* @param int $quality
* @throws \InvalidArgumentException
* @throws \RuntimeException
*/
private function saveResampled($dest, $newWidth, $newHeight, $type, $quality) {
if (!isset($type)) {
$type = $this->defaultOutputType;
}
if (!$type) {
$type = $this->getTypeFromFilePath($dest);
} else if ($type == -1) {
$type = $this->baseType;
}
if (!$type || !$this->isSupportedType($type)) {
throw new \InvalidArgumentException('The output image type could not be determined or is not supported');
}
if (!isset($quality)) {
$quality = $this->defaultOutputQuality;
}
$quality = $this->normaliseQualityFactor($quality);
$this->loadImageResource();
$this->processNewDimensions($newWidth, $newHeight);
$dstImage = $this->doImageResample($newWidth, $newHeight);
$this->doSaveImage($dstImage, $dest, $type, $quality);
imagedestroy($dstImage);
}
/**
* @param array $uploadMeta
* @throws \InvalidArgumentException
* @throws \RuntimeException
*/
public function __construct(array $uploadMeta) {
parent::__construct($uploadMeta);
if (!function_exists('getimagesize')) {
throw new \RuntimeException('The GD extension is not available on this PHP instance');
}
}
public function __destruct() {
if (isset($this->baseImage)) {
imagedestroy($this->baseImage);
}
}
/**
* Checks that the file was uploaded successfully and stores image meta data
*
* @throws \RuntimeException
*/
public function checkUploadSuccess() {
parent::checkUploadSuccess();
if (!$imageMeta = getimagesize($this->uploadMeta['tmp_name'])) {
throw new \RuntimeException('getimagesize() failed');
}
if (!$this->isSupportedType($imageMeta[2])) {
throw new \RuntimeException('Image type not supported');
}
$this->baseWidth = (int) $imageMeta[0];
$this->baseHeight = (int) $imageMeta[1];
$this->baseType = $imageMeta[2];
}
/**
* @param int $type
* @throws \InvalidArgumentException
*/
public function setDefaultOutputType($type) {
if (!$this->isSupportedType($type)) {
throw new \InvalidArgumentException('The requested output image type is unsupported');
}
$this->defaultOutputType = (int) $type;
}
/**
* @return int
*/
public function getDefaultOutputType() {
return $this->defaultOutputType;
}
/**
* @param int $type
*/
public function setDefaultOutputQuality($quality) {
$this->defaultOutputQuality = (int) $this->normaliseQualityFactor($quality);
}
/**
* @return int
*/
public function getDefaultOutputQuality() {
return $this->defaultOutputQuality;
}
/**
* @return int
* @throws \RuntimeException
*/
public function getWidth() {
if (!isset($this->baseWidth)) {
$this->checkUploadSuccess();
}
return $this->baseWidth;
}
/**
* @return int
* @throws \RuntimeException
*/
public function getHeight() {
if (!isset($this->baseHeight)) {
$this->checkUploadSuccess();
}
return $this->baseHeight;
}
/**
* @return int
* @throws \RuntimeException
*/
public function getImageType() {
if (!isset($this->baseType)) {
$this->checkUploadSuccess();
}
return $this->baseType;
}
/**
* Saves the image in the specified location with its original dimensions
*
* @param string|resource $dest
* @param bool $resample
* @param int $type
* @param int $quality
* @throws \InvalidArgumentException
* @throws \RuntimeException
*/
public function save($dest, $resample = TRUE, $type = NULL, $quality = NULL) {
if (!isset($this->baseType)) {
$this->checkUploadSuccess();
}
if (is_resource($dest) && !$type) {
throw new \InvalidArgumentException('Output type must be specified of the destination is a file pointer');
}
if ($resample || $type != $this->baseType) {
$this->saveResampled($dest, $this->baseWidth, $this->baseHeight, $type, $quality);
} else {
parent::save($dest);
}
}
/**
* Saves the image in the specified location with new dimensions
*
* @param string|resource $dest
* @param int $newWidth
* @param int $newHeight
* @param int $type
* @param int $quality
* @throws \InvalidArgumentException
* @throws \RuntimeException
*/
public function saveResized($dest, $newWidth = NULL, $newHeight = NULL, $type = NULL, $quality = NULL) {
if (!isset($this->baseType)) {
$this->checkUploadSuccess();
}
if (is_resource($dest) && !$type) {
throw new \InvalidArgumentException('Output type must be specified of the destination is a file pointer');
}
return $this->saveResampled($dest, $newWidth, $newHeight, $type, $quality);
}
}
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>Uploader</title>
</head>
<body>
<form action="submit.php" method="post" enctype="multipart/form-data">
Please select an image to upload: <input type="file" name="image">
<input type="submit" value="Upload">
</form>
</body>
</html>
<?php
namespace Upload;
class Manager {
/**
* A map of MIME type/file extensions to the correct factory object
*
* @var array $typeMap
*/
private $typeMap = array();
/**
* Array of uploaded file arrays/objects
*
* @var array $uploadedFiles
*/
private $uploadedFiles = array();
/**
* Creates default type handlers
*/
private function addDefaultTypes() {
$fileFactory = new FileFactory('File');
$imageFactory = new FileFactory('Image');
$this->setTypeHandler('__default', $fileFactory);
$this->setTypeHandler('image/gif', $fileFactory, array('gif'));
$this->setTypeHandler('image/jpeg', $imageFactory, array('jpeg', 'jpe', 'jpg'));
$this->setTypeHandler('image/png', $imageFactory, array('png'));
}
/**
* Restructures a control with multiple files from $_FILES to a more sane layout
*
* @param array $input
* @return array
*/
private function restructureMultiFileDataArray($input) {
$output = array();
for ($i = 0; isset($input['tmp_name']); $i++) {
$output[$i] = array(
'error' => $input['error'][$i],
'name' => $input['name'][$i],
'type' => $input['type'][$i],
'size' => $input['size'][$i],
'tmp_name' => $input['tmp_name'][$i]
);
}
return $output;
}
/**
* Processes data from $_FILES and deletes the superglobal
*
* @throws \UnexpectedValueException
*/
private function processFilesSuperglobal() {
if (!empty($_FILES)) {
foreach ($_FILES as $controlName => $fileData) {
if (!isset($fileData['tmp_name'], $fileData['error'])) {
throw new \UnexpectedValueException('The $_FILES array has been modified');
}
if (is_array($fileData['tmp_name'])) {
$fileData = $this->restructureMultiFileDataArray($fileData);
} else {
$fileData = array($fileData);
}
$this->uploadedFiles[$controlName] = $fileData;
}
}
unset($_FILES);
}
/**
* @param string $path
* @return string
*/
private function getFileExtension($path) {
return pathinfo($path, PATHINFO_EXTENSION);
}
/**
* Attempts to detect real MIME type of uploaded file using finfo
*
* @param string $path
* @return FileFactory
*/
private function detectType(&$fileData) {
if (function_exists('finfo_file') && ($finfo = finfo_open(FILEINFO_MIME_TYPE))) {
$type = finfo_file($finfo, $fileData['tmp_name']);
finfo_close($finfo);
if ($type) {
$fileData['type'] = $type;
}
}
}
/**
* Fetches the correct factory object for a file
*
* @param array $path
* @return FileFactory
*/
private function getFactoryFromFileData($fileData) {
if (isset($this->typeMap[$fileData['type']])) {
return $this->typeMap[$fileData['type']];
}
$extn = $this->getFileExtension($fileData['name']);
if (isset($this->typeMap[$extn])) {
return $this->typeMap[$extn];
}
return $this->typeMap['__default'];
}
/**
* @param array $fileData
* @return IFile
*/
private function createFileObjectFromArray($fileData) {
$this->detectType($fileData);
$factory = $this->getFactoryFromFileData($fileData);
return $factory->create($fileData);
}
/**
* Converts file data arrays to objects
*
* @param string $controlName
*/
private function processUploadedFileControl($controlName) {
if (is_array($this->uploadedFiles[$controlName])) {
$collection = new FileCollection;
foreach ($this->uploadedFiles[$controlName] as &$fileData) {
$collection->addFile($this->createFileObjectFromArray($fileData));
}
$this->uploadedFiles[$controlName] = $collection;
}
}
/**
* @throws \UnexpectedValueException
*/
public function __construct() {
$this->addDefaultTypes();
$this->processFilesSuperglobal();
}
/**
* @return array
*/
public function getAllControls() {
foreach ($this->uploadedFiles as $controlName => $data) {
$this->processUploadedFileControl($controlName);
}
return $this->uploadedFiles;
}
/**
* @param string $controlName
* @return array
*/
public function getControl($controlName) {
if (isset($this->uploadedFiles[$controlName])) {
$this->processUploadedFileControl($controlName);
return $this->uploadedFiles[$controlName];
}
}
/**
* @param string $mimeType
* @param FileFactory $factory
* @param array $extensions
*/
public function setTypeHandler($mimeType, FileFactory $factory, array $extensions = array()) {
$this->typeMap[$mimeType] = $factory;
foreach ($extensions as $extension) {
$this->typeMap[$extension] = $factory;
}
}
}
<?php
// Very simple autoloader for demo purposes
spl_autoload_register(function($class) {
require strtolower(basename($class)).'.php';
});
// When you instantiate this the $_FILES superglobal is destroyed
// You must access all uploaded files via this API from this point onwards
$uploadManager = new \Upload\Manager;
// Fetches a FileCollection associated with the named form control
$control = $uploadManager->getControl('image');
// getControl returns NULL if there are no files associated with that name
if (!isset($control)) {
exit('No file was uploaded in the image control');
}
// All controls are treated as multiple uploads for simplicity
// This demo is only interested in a single file
$image = $control[0];
if (!($image instanceof \Upload\Image)) {
exit('The uploaded file is not a valid image');
}
try {
$image->saveResized('test_640x480.png', 640, 480);
$image->saveResized('test_480x340.png', 480, 340);
$image->saveResized('test_240x200.png', 240, 200);
} catch (\Exception $e) {
// Various different types of exception may be thrown, this is just for simplicity in the demo
echo "Oh noes! Something went badly wrong: ".$e->getMessage();
}
@mattattui
Copy link

Looks really helpful, thanks!

Bugs:

Debatable trivia:

  • Because GD stores the image as a bitmap in memory, it's possible to create a very highly compressed GIF file that translates to a bitmap larger than available memory, potentially crashing the script. If you care, you can mitigate this by checking the other values of getimagesize(), not just the type.
  • Because the upload manager unsets $_FILES, it is in effect a broken singleton. That is, you can only ever create one manager and all successive instantiations will fail. I can't think why you'd want more than one upload manager anyway, but it feels a little odd to me. I think the code that instantiates the manager should be responsible for unsetting $_FILES, rather than the manager itself.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment