Skip to content

Instantly share code, notes, and snippets.

@DaveRandom DaveRandom/file.php
Last active Dec 12, 2015

Embed
What would you like to do?
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();
}
@inanimatt

This comment has been minimized.

Copy link

inanimatt commented Apr 28, 2013

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
You can’t perform that action at this time.