diff --git a/composer.json b/composer.json index 0535a25b..86e40776 100644 --- a/composer.json +++ b/composer.json @@ -19,14 +19,14 @@ "prefer-stable": true, "extra": { "branch-alias": { - "dev-master": "0.10.x-dev" + "dev-master": "0.11.x-dev" } }, "require": { "php": ">=5.6.0 || >=7.0", "ext-fileinfo": "*", - "ext-PDO": "*", - "ext-SimpleXML": "*", + "ext-pdo": "*", + "ext-simplexml": "*", "psr/log": "^1.0", "psr/cache": "^1.0", "locomotivemtl/charcoal-config": "~0.9", diff --git a/src/Charcoal/Property/AudioProperty.php b/src/Charcoal/Property/AudioProperty.php index 2611c08c..2112baa8 100644 --- a/src/Charcoal/Property/AudioProperty.php +++ b/src/Charcoal/Property/AudioProperty.php @@ -85,38 +85,35 @@ public function getMaxLength() } /** + * Retrieves the default list of acceptable MIME types for uploaded files. + * + * This method should be overriden. + * * @return string[] */ - public function getAcceptedMimetypes() + public function getDefaultAcceptedMimetypes() { return [ 'audio/mp3', 'audio/mpeg', 'audio/ogg', + 'audio/webm', 'audio/wav', + 'audio/wave', 'audio/x-wav', + 'audio/x-pn-wav', ]; } /** - * Generate the file extension from the property's value. + * Resolve the file extension from the given MIME type. * - * @param string $file The file to parse. - * @return string The extension based on the MIME type. + * @param string $type The MIME type to resolve. + * @return string|null The extension based on the MIME type. */ - public function generateExtension($file = null) + protected function resolveExtensionFromMimeType($type) { - if (is_string($file)) { - if (in_array($file, $this->getAcceptedMimetypes())) { - $mime = $file; - } else { - $mime = $this->getMimetypeFor($file); - } - } else { - $mime = $this->getMimetype(); - } - - switch ($mime) { + switch ($type) { case 'audio/mp3': case 'audio/mpeg': return 'mp3'; @@ -124,12 +121,16 @@ public function generateExtension($file = null) case 'audio/ogg': return 'ogg'; + case 'audio/webm': + return 'webm'; + case 'audio/wav': + case 'audio/wave': case 'audio/x-wav': + case 'audio/x-pn-wav': return 'wav'; - - default: - return ''; } + + return null; } } diff --git a/src/Charcoal/Property/FileProperty.php b/src/Charcoal/Property/FileProperty.php index 22b190cd..3c2bc8ba 100644 --- a/src/Charcoal/Property/FileProperty.php +++ b/src/Charcoal/Property/FileProperty.php @@ -78,7 +78,7 @@ class FileProperty extends AbstractProperty * * @var string[] */ - private $acceptedMimetypes = []; + private $acceptedMimetypes; /** * Current file mimetype @@ -101,6 +101,11 @@ class FileProperty extends AbstractProperty */ private $filesize; + /** + * @var string + */ + private $fallbackFilename; + /** * The filesystem to use while uploading a file. * @@ -203,24 +208,72 @@ public function getOverwrite() } /** - * @param string[] $mimetypes The accepted mimetypes. + * Sets the acceptable MIME types for uploaded files. + * + * @param mixed $types One or many MIME types. + * @throws InvalidArgumentException If the $types argument is not NULL or a list. * @return self */ - public function setAcceptedMimetypes(array $mimetypes) + public function setAcceptedMimetypes($types) { - $this->acceptedMimetypes = $mimetypes; + if (is_array($types)) { + $types = array_filter($types); + if (empty($types)) { + $types = null; + } + } + + if ($types !== null && !is_array($types)) { + throw new InvalidArgumentException( + 'Must be an array of acceptable MIME types or NULL' + ); + } + + $this->acceptedMimetypes = $types; return $this; } /** + * Determines if any acceptable MIME types are defined. + * + * @return boolean + */ + public function hasAcceptedMimetypes() + { + if (!empty($this->acceptedMimetypes)) { + return true; + } + + return !empty($this->getDefaultAcceptedMimetypes()); + } + + /** + * Retrieves a list of acceptable MIME types for uploaded files. + * * @return string[] */ public function getAcceptedMimetypes() { + if ($this->acceptedMimetypes === null) { + return $this->getDefaultAcceptedMimetypes(); + } + return $this->acceptedMimetypes; } + /** + * Retrieves the default list of acceptable MIME types for uploaded files. + * + * This method should be overriden. + * + * @return string[] + */ + public function getDefaultAcceptedMimetypes() + { + return []; + } + /** * Set the MIME type. * @@ -232,78 +285,70 @@ public function setMimetype($type) { if ($type === null || $type === false) { $this->mimetype = null; - return $this; } if (!is_string($type)) { throw new InvalidArgumentException( - 'Mimetype must be a string' + 'MIME type must be a string' ); } $this->mimetype = $type; - return $this; } /** - * Retrieve the MIME type. + * Retrieve the MIME type of the property value. * - * @return string + * @todo Refactor to support multilingual/multiple files. + * + * @return integer Returns the MIME type for the first value. */ public function getMimetype() { - if (!$this->mimetype) { - $val = $this->val(); + if ($this->mimetype === null) { + $files = $this->parseValAsFileList($this->val()); + if (empty($files)) { + return null; + } - if (!$val) { - return ''; + $file = reset($files); + $type = $this->getMimetypeFor($file); + if ($type === null) { + return null; } - $this->setMimetype($this->getMimetypeFor(strval($val))); + $this->setMimetype($type); } return $this->mimetype; } - /** - * Alias of {@see self::getMimetype()}. - * - * @return string - */ - public function mimetype() - { - return $this->getMimetype(); - } - /** * Extract the MIME type from the given file. * - * @uses finfo * @param string $file The file to check. - * @return string|null Returns the given file's MIME type or FALSE if an error occurred. + * @return integer|null Returns the file's MIME type, + * or NULL in case of an error or the file is missing. */ public function getMimetypeFor($file) { + if (!$this->isAbsolutePath($file)) { + $file = $this->pathFor($file); + } + if (!$this->fileExists($file)) { return null; } $info = new finfo(FILEINFO_MIME_TYPE); + $type = $info->file($file); + if (empty($type) || $type === 'inode/x-empty') { + return null; + } - return $info->file($file); - } - - /** - * Alias of {@see self::getMimetypeFor()}. - * - * @param string $file The file to check. - * @return string|false - */ - public function mimetypeFor($file) - { - return $this->getMimetypeFor($file); + return $type; } /** @@ -367,41 +412,92 @@ public function maxFilesizeAllowedByPhp(&$iniDirective = null) */ public function setFilesize($size) { - if (!is_int($size)) { + if (!is_int($size) && $size !== null) { throw new InvalidArgumentException( - 'Filesize must be an integer, in bytes.' + 'File size must be an integer in bytes' ); } - $this->filesize = $size; + $this->filesize = $size; return $this; } /** - * @return integer + * Retrieve the size of the property value. + * + * @todo Refactor to support multilingual/multiple files. + * + * @return integer Returns the size in bytes for the first value. */ public function getFilesize() { - if (!$this->filesize) { - $val = $this->val(); - if (!$val || !$this->fileExists($val)) { + if ($this->filesize === null) { + $files = $this->parseValAsFileList($this->val()); + if (empty($files)) { + return 0; + } + + $file = reset($files); + $size = $this->getFilesizeFor($file); + if ($size === null) { return 0; - } else { - $this->filesize = filesize($val); } + + $this->setFilesize($size); } return $this->filesize; } /** - * Alias of {@see self::getFilesize()}. + * Extract the size of the given file. * - * @return integer + * @param string $file The file to check. + * @return integer|null Returns the file size in bytes, + * or NULL in case of an error or the file is missing. */ - public function filesize() + public function getFilesizeFor($file) { - return $this->getFilesize(); + if (!$this->isAbsolutePath($file)) { + $file = $this->pathFor($file); + } + + if (!$this->fileExists($file)) { + return null; + } + + $size = filesize($file); + if ($size === false) { + return null; + } + + return $size; + } + + /** + * Convert number of bytes to largest human-readable unit. + * + * @param integer $bytes Number of bytes. + * @param integer $decimals Precision of number of decimal places. Default 0. + * @return string|null Returns the formatted number or NULL. + */ + public function formatFilesize($bytes, $decimals = 2) + { + if ($bytes === 0) { + $factor = 0; + } else { + $factor = floor((strlen($bytes) - 1) / 3); + } + + $unit = [ 'B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB' ]; + + $factor = floor((strlen($bytes) - 1) / 3); + + if (!isset($unit[$factor])) { + $factor = 0; + } + + return sprintf('%.'.$decimals.'f', ($bytes / pow(1024, $factor))).' '.$unit[$factor]; } /** @@ -412,15 +508,18 @@ public function validationMethods() $parentMethods = parent::validationMethods(); return array_merge($parentMethods, [ - 'acceptedMimetypes', - 'maxFilesize', + 'mimetypes', + 'filesizes', ]); } /** - * @return boolean + * Validates the MIME types for the property's value(s). + * + * @return boolean Returns TRUE if all values are valid. + * Otherwise, returns FALSE and reports issues. */ - public function validateAcceptedMimetypes() + public function validateMimetypes() { $acceptedMimetypes = $this['acceptedMimetypes']; if (empty($acceptedMimetypes)) { @@ -428,49 +527,116 @@ public function validateAcceptedMimetypes() return true; } - if ($this->mimetype) { - $mimetype = $this->mimetype; - } else { - $val = $this->val(); - if (!$val || !$this->fileExists($val)) { - return true; - } - $mimetype = $this->getMimetypeFor($val); + $files = $this->parseValAsFileList($this->val()); + + if (empty($files)) { + return true; } - $valid = false; - foreach ($acceptedMimetypes as $m) { - if ($m === $mimetype) { - $valid = true; - break; + + $valid = true; + + foreach ($files as $file) { + $mime = $this->getMimetypeFor($file); + + if ($mime === null) { + $valid = false; + + $this->validator()->error(sprintf( + 'File [%s] not found or MIME type unrecognizable', + $file + ), 'acceptedMimetypes'); + } elseif (!in_array($mime, $acceptedMimetypes)) { + $valid = false; + + $this->validator()->error(sprintf( + 'File [%s] has unacceptable MIME type [%s]', + $file, + $mime + ), 'acceptedMimetypes'); } } - if (!$valid) { - $this->validator()->error('Accepted mimetypes error', 'acceptedMimetypes'); - } return $valid; } /** - * @return boolean + * Validates the file sizes for the property's value(s). + * + * @return boolean Returns TRUE if all values are valid. + * Otherwise, returns FALSE and reports issues. */ - public function validateMaxFilesize() + public function validateFilesizes() { $maxFilesize = $this['maxFilesize']; - if ($maxFilesize == 0) { + if (empty($maxFilesize)) { // No max size rule = always true return true; } - $filesize = $this->filesize(); - $valid = ($filesize <= $maxFilesize); - if (!$valid) { - $this->validator()->error('Max filesize error', 'maxFilesize'); + $files = $this->parseValAsFileList($this->val()); + + if (empty($files)) { + return true; + } + + $valid = true; + + foreach ($files as $file) { + $filesize = $this->getFilesizeFor($file); + + if ($filesize === null) { + $valid = false; + + $this->validator()->error(sprintf( + 'File [%s] not found or size unknown', + $file + ), 'maxFilesize'); + } elseif (($filesize > $maxFilesize)) { + $valid = false; + + $this->validator()->error(sprintf( + 'File [%s] exceeds maximum file size [%s]', + $file, + $this->formatFilesize($maxFilesize) + ), 'maxFilesize'); + } } return $valid; } + /** + * Parse a multi-dimensional array of value(s) into a single level. + * + * This method flattens a value object that is "l10n" or "multiple". + * Empty or duplicate values are removed. + * + * @param mixed $value A multi-dimensional variable. + * @return string[] The array of values. + */ + public function parseValAsFileList($value) + { + $files = []; + + if ($value instanceof Translation) { + $value = $value->data(); + } + + $array = $this->parseValAsMultiple($value); + array_walk_recursive($array, function ($item) use (&$files) { + $array = $this->parseValAsMultiple($item); + $files = array_merge($files, $array); + }); + + $files = array_filter($files, function ($file) { + return is_string($file) && isset($file[0]); + }); + $files = array_unique($files); + $files = array_values($files); + + return $files; + } + /** * Get the SQL type (Storage format) * @@ -566,9 +732,21 @@ protected function saveDataUploads($values) $parsed = []; foreach ($values as $value) { if ($this->isDataArr($value) || $this->isDataUri($value)) { - $path = $this->dataUpload($value); - if ($path !== null) { - $parsed[] = $path; + try { + $path = $this->dataUpload($value); + if ($path !== null) { + $parsed[] = $path; + + $this->logger->notice(sprintf( + 'File [%s] uploaded succesfully', + $path + )); + } + } catch (Exception $e) { + $this->logger->warning(sprintf( + 'Upload error on data URI: %s', + $e->getMessage() + )); } } elseif (is_string($value) && !empty($value)) { $parsed[] = $value; @@ -594,9 +772,22 @@ protected function saveFileUploads($files) $parsed = []; foreach ($files as $file) { if (isset($file['error'])) { - $path = $this->fileUpload($file); - if ($path !== null) { - $parsed[] = $path; + try { + $path = $this->fileUpload($file); + if ($path !== null) { + $parsed[] = $path; + + $this->logger->notice(sprintf( + 'File [%s] uploaded succesfully', + $path + )); + } + } catch (Exception $e) { + $this->logger->warning(sprintf( + 'Upload error on file [%s]: %s', + $file['name'], + $e->getMessage() + )); } } } @@ -633,13 +824,14 @@ protected function parseSavedValues($saved, $default = null) * * @param mixed $data A data URI. * @throws Exception If data content decoding fails. - * @throws InvalidArgumentException If the $data is invalid. + * @throws InvalidArgumentException If the input $data is invalid. + * @throws Exception If the upload fails or the $data is bad. * @return string|null The file path to the uploaded data. */ public function dataUpload($data) { $filename = null; - $content = false; + $contents = false; if (is_array($data)) { if (!isset($data['id'], $data['name'])) { @@ -658,40 +850,57 @@ public function dataUpload($data) )); } - $content = file_get_contents($tmpFile); - $filename = empty($data['name']) ? null : $data['name']; + $contents = file_get_contents($tmpFile); + + if (strlen($data['name']) > 0) { + $filename = $data['name']; + } // delete tmp file unlink($tmpFile); } elseif (is_string($data)) { - $content = file_get_contents($data); + $contents = file_get_contents($data); } - if ($content === false) { + if ($contents === false) { throw new Exception( - 'File content could not be decoded' + 'File content could not be decoded for data URI' ); } $info = new finfo(FILEINFO_MIME_TYPE); - $this->setMimetype($info->buffer($content)); - $this->setFilesize(strlen($content)); - if (!$this->validateAcceptedMimetypes() || !$this->validateMaxFilesize()) { - return null; + $mime = $info->buffer($contents); + if (!$this->isAcceptedMimeType($mime)) { + throw new Exception(sprintf( + 'Unacceptable MIME type [%s]', + $mime + )); + } + + $size = strlen($contents); + if (!$this->isAcceptedFilesize($size)) { + throw new Exception(sprintf( + 'Maximum file size exceeded [%s]', + $this->formatFilesize($this['maxFilesize']) + )); + } + + if ($filename === null) { + $extension = $this->generateExtensionFromMimeType($mime); + $filename = $this->generateFilename($extension); } $targetPath = $this->uploadTarget($filename); - $result = file_put_contents($targetPath, $content); + $result = file_put_contents($targetPath, $contents); if ($result === false) { - $this->logger->warning(sprintf( + throw new Exception(sprintf( 'Failed to write file to %s', $targetPath )); - return null; } - $basePath = $this->basePath(); + $basePath = $this->basePath(); $targetPath = str_replace($basePath, '', $targetPath); return $targetPath; @@ -704,7 +913,8 @@ public function dataUpload($data) * Adapted from slim/slim. * * @param array $file A single $_FILES entry. - * @throws InvalidArgumentException If the $file is invalid. + * @throws InvalidArgumentException If the input $file is invalid. + * @throws Exception If the upload fails or the $file is bad. * @return string|null The file path to the uploaded file. */ public function fileUpload(array $file) @@ -717,54 +927,51 @@ public function fileUpload(array $file) } if ($file['error'] !== UPLOAD_ERR_OK) { - $this->logger->warning(sprintf( - 'Upload error on file %s: %s', - $file['name'], - self::ERROR_MESSAGES[$this->error] - )); + $errorCode = $file['error']; + throw new Exception( + self::ERROR_MESSAGES[$errorCode] + ); + } - return null; + if (!file_exists($file['tmp_name'])) { + throw new Exception( + 'File does not exist' + ); } - if (file_exists($file['tmp_name'])) { - $info = new finfo(FILEINFO_MIME_TYPE); - $this->setMimetype($info->file($file['tmp_name'])); - $this->setFilesize(filesize($file['tmp_name'])); - if (!$this->validateAcceptedMimetypes() || !$this->validateMaxFilesize()) { - return null; - } - } else { - $this->logger->warning(sprintf( - 'File %s does not exists', - $file['tmp_name'] - )); - return null; + if (!is_uploaded_file($file['tmp_name'])) { + throw new Exception( + 'File was not uploaded' + ); } - $targetPath = $this->uploadTarget($file['name']); + $info = new finfo(FILEINFO_MIME_TYPE); + $mime = $info->file($file['tmp_name']); + if (!$this->isAcceptedMimeType($mime)) { + throw new Exception(sprintf( + 'Unacceptable MIME type [%s]', + $mime + )); + } - if (!is_uploaded_file($file['tmp_name'])) { - $this->logger->warning(sprintf( - '%s is not a valid uploaded file', - $file['tmp_name'] + $size = filesize($file['tmp_name']); + if (!$this->isAcceptedFilesize($size)) { + throw new Exception(sprintf( + 'Maximum file size exceeded [%s]', + $this->formatFilesize($this['maxFilesize']) )); - return null; } - if (!move_uploaded_file($file['tmp_name'], $targetPath)) { - $this->logger->warning(sprintf( - 'Error moving uploaded file %s to %s', - $file['tmp_name'], + $targetPath = $this->uploadTarget($file['name']); + + $result = move_uploaded_file($file['tmp_name'], $targetPath); + if ($result === false) { + throw new Exception(sprintf( + 'Failed to move uploaded file to %s', $targetPath )); - return null; } - $this->logger->notice(sprintf( - 'File %s uploaded succesfully', - $targetPath - )); - $basePath = $this->basePath(); $targetPath = str_replace($basePath, '', $targetPath); @@ -772,41 +979,37 @@ public function fileUpload(array $file) } /** - * @param string $filename Optional. The filename to save. If unset, a default filename will be generated. - * @throws Exception If the target path is not writeable. + * Parse the uploaded file path. + * + * This method will create the file's directory path and will sanitize the file's name + * or generate a unique name if none provided (such as data URIs). + * + * @param string|null $filename Optional. The filename to save as. + * If NULL, a default filename will be generated. * @return string */ public function uploadTarget($filename = null) { - $uploadPath = $this->basePath().$this['uploadPath']; + $this->assertValidUploadPath(); - if (!file_exists($uploadPath)) { - // @todo: Feedback - $this->logger->debug( - 'Path does not exist. Attempting to create path '.$uploadPath.'.', - [ get_called_class().'::'.__FUNCTION__ ] - ); - mkdir($uploadPath, 0777, true); - } + $uploadPath = $this->pathFor($this['uploadPath']); - if (!is_writable($uploadPath)) { - throw new Exception( - 'Error: upload directory is not writeable' - ); + if ($filename === null) { + $filename = $this->generateFilename(); + } else { + $filename = $this->sanitizeFilename($filename); } - $filename = empty($filename) ? $this->generateFilename() : $this->sanitizeFilename($filename); - $targetPath = $uploadPath.$filename; + $targetPath = $uploadPath.'/'.$filename; if ($this->fileExists($targetPath)) { if ($this['overwrite'] === true) { return $targetPath; - } else { - $targetPath = $uploadPath.$this->generateUniqueFilename($filename); - while ($this->fileExists($targetPath)) { - $targetPath = $uploadPath.$this->generateUniqueFilename($filename); - } } + + do { + $targetPath = $uploadPath.'/'.$this->generateUniqueFilename($filename); + } while ($this->fileExists($targetPath)); } return $targetPath; @@ -815,8 +1018,9 @@ public function uploadTarget($filename = null) /** * Checks whether a file or directory exists. * - * PHP built-in's `file_exists` is only case-insensitive on case-insensitive filesystem (such as Windows) - * This method allows to have the same validation across different platforms / filesystem. + * PHP built-in's `file_exists` is only case-insensitive on + * a case-insensitive filesystem (such as Windows). This method allows + * to have the same validation across different platforms / filesystems. * * @param string $file The full file to check. * @param boolean $caseInsensitive Case-insensitive by default. @@ -824,8 +1028,10 @@ public function uploadTarget($filename = null) */ public function fileExists($file, $caseInsensitive = true) { + $file = (string)$file; + if (!$this->isAbsolutePath($file)) { - $file = $this->basePath().$file; + $file = $this->pathFor($file); } if (file_exists($file)) { @@ -852,17 +1058,24 @@ public function fileExists($file, $caseInsensitive = true) /** * Sanitize a filename by removing characters from a blacklist and escaping dot. * - * @param string $filename The filename to sanitize. + * @param string $filename The filename to sanitize. + * @throws Exception If the filename is invalid. * @return string The sanitized filename. */ public function sanitizeFilename($filename) { // Remove blacklisted caharacters $blacklist = [ '/', '\\', '\0', '*', ':', '?', '"', '<', '>', '|', '#', '&', '!', '`', ' ' ]; - $filename = str_replace($blacklist, '_', $filename); + $filename = str_replace($blacklist, '_', (string)$filename); + + // Avoid hidden file or trailing dot + $filename = trim($filename, '.'); - // Avoid hidden file - $filename = ltrim($filename, '.'); + if (strlen($filename) === 0) { + throw new Exception( + 'Bad file name after sanitization' + ); + } return $filename; } @@ -917,18 +1130,19 @@ public function renderFileRenamePattern($from, $to, $args = null) /** * Generate a new filename from the property. * + * @param string|null $extension An extension to append to the generated filename. * @return string */ - public function generateFilename() + public function generateFilename($extension = null) { - $filename = $this['label'].' '.date('Y-m-d H-i-s'); - $extension = $this->generateExtension(); + $filename = $this->sanitizeFilename($this['fallbackFilename']); + $filename = $filename.' '.date('Y-m-d\TH-i-s'); - if ($extension) { + if ($extension !== null) { return $filename.'.'.$extension; - } else { - return $filename; } + + return $filename; } /** @@ -940,22 +1154,22 @@ public function generateFilename() */ public function generateUniqueFilename($filename) { - if (!is_string($filename) && !is_array($filename)) { - throw new InvalidArgumentException(sprintf( - 'The target must be a string or an array from [pathfino()], received %s', - (is_object($filename) ? get_class($filename) : gettype($filename)) - )); - } - if (is_string($filename)) { $info = pathinfo($filename); } else { $info = $filename; } + if (!isset($info['filename']) || strlen($info['filename']) === 0) { + throw new InvalidArgumentException(sprintf( + 'File must be a string [file path] or an array [pathfino()], received %s', + (is_object($filename) ? get_class($filename) : gettype($filename)) + )); + } + $filename = $info['filename'].'-'.uniqid(); - if (isset($info['extension']) && $info['extension']) { + if (isset($info['extension']) && strlen($info['extension']) > 0) { $filename .= '.'.$info['extension']; } @@ -963,35 +1177,106 @@ public function generateUniqueFilename($filename) } /** - * Generate the file extension from the property's value. + * Generate the file extension from the property value. + * + * @todo Refactor to support multilingual/multiple files. + * + * @return string Returns the file extension based on the MIME type for the first value. + */ + public function generateExtension() + { + $type = $this->getMimetype(); + + return $this->resolveExtensionFromMimeType($type); + } + + /** + * Generate a file extension from the given file path. * * @param string $file The file to parse. - * @return string The extension based on the MIME type. + * @return string|null The extension based on the file's MIME type. */ - public function generateExtension($file = null) + public function generateExtensionFromFile($file) { - if ($file === null) { - $file = $this->val(); + if ($this->hasAcceptedMimetypes()) { + $type = $this->getMimetypeFor($file); + + return $this->resolveExtensionFromMimeType($type); + } + + if (!is_string($file) || !defined('FILEINFO_EXTENSION')) { + return null; } // PHP 7.2 - if (is_string($file) && defined('FILEINFO_EXTENSION')) { - $info = new finfo(FILEINFO_EXTENSION); - $ext = $info->file($file); + $info = new finfo(FILEINFO_EXTENSION); + $ext = $info->file($file); - if ($ext === '???') { - return ''; - } + if ($ext === '???') { + return null; + } - if (strpos($ext, '/') !== false) { - $ext = explode('/', $ext); - $ext = reset($ext); - } + if (strpos($ext, '/') !== false) { + $ext = explode('/', $ext); + $ext = reset($ext); + } + + return $ext; + } + + /** + * Generate a file extension from the given MIME type. + * + * @param string $type The MIME type to parse. + * @return string|null The extension based on the MIME type. + */ + public function generateExtensionFromMimeType($type) + { + if (in_array($type, $this->getAcceptedMimetypes())) { + return $this->resolveExtensionFromMimeType($type); + } + + return null; + } - return $ext; + /** + * Resolve the file extension from the given MIME type. + * + * This method should be overriden to provide available extensions. + * + * @param string $type The MIME type to resolve. + * @return string|null The extension based on the MIME type. + */ + protected function resolveExtensionFromMimeType($type) + { + switch ($type) { + case 'text/plain': + return 'txt'; + } + + return null; + } + + /** + * @param mixed $fallback The fallback filename. + * @return self + */ + public function setFallbackFilename($fallback) + { + $this->fallbackFilename = $this->translator()->translation($fallback); + return $this; + } + + /** + * @return Translation|null + */ + public function getFallbackFilename() + { + if ($this->fallbackFilename === null) { + return $this['label']; } - return ''; + return $this->fallbackFilename; } /** @@ -1023,11 +1308,12 @@ protected function setDependencies(Container $container) { parent::setDependencies($container); - $this->basePath = $container['config']['base_path']; + $this->basePath = $container['config']['base_path']; $this->publicPath = $container['config']['public_path']; } + /** - * Retrieve the path to the storage directory. + * Retrieve the base path to the storage directory. * * @return string */ @@ -1035,8 +1321,60 @@ protected function basePath() { if ($this['publicAccess']) { return $this->publicPath; - } else { - return $this->basePath; + } + + return $this->basePath; + } + + /** + * Build the path for a named route including the base path. + * + * The {@see self::basePath() base path} will be prepended to the given $path. + * + * If the given $path does not start with the {@see self::getUploadPath() upload path}, + * it will be prepended. + * + * @param string $path The end path. + * @return string + */ + protected function pathFor($path) + { + $path = trim($path, '/'); + $uploadPath = trim($this['uploadPath'], '/'); + $basePath = rtrim($this->basePath(), '/'); + + if (strpos($path, $uploadPath) !== 0) { + $basePath .= '/'.$uploadPath; + } + + return $basePath.'/'.$path; + } + + /** + * Attempts to create the upload path. + * + * @throws Exception If the upload path is unavailable. + * @return void + */ + protected function assertValidUploadPath() + { + $uploadPath = $this->pathFor($this['uploadPath']); + + if (!file_exists($uploadPath)) { + $this->logger->debug(sprintf( + '[%s] Upload directory [%s] does not exist; attempting to create path', + [ get_called_class().'::'.__FUNCTION__ ], + $uploadPath + )); + + mkdir($uploadPath, 0777, true); + } + + if (!is_writable($uploadPath)) { + throw new Exception(sprintf( + 'Upload directory [%s] is not writeable', + $uploadPath + )); } } @@ -1055,7 +1393,7 @@ protected function parseIniSize($size) if (!is_string($size)) { throw new InvalidArgumentException( - 'Size must be an integer (in bytes, e.g.: 1024) or a string (e.g.: 1M).' + 'Size must be an integer (in bytes, e.g.: 1024) or a string (e.g.: 1M)' ); } @@ -1071,7 +1409,51 @@ protected function parseIniSize($size) } /** - * Determine if the given file path is am absolute path. + * Determine if the given MIME type is acceptable. + * + * @param string $type A MIME type. + * @param string[] $accepted One or many acceptable MIME types. + * Defaults to the property's "acceptedMimetypes". + * @return boolean Returns TRUE if the MIME type is acceptable. + * Otherwise, returns FALSE. + */ + protected function isAcceptedMimeType($type, array $accepted = null) + { + if ($accepted === null) { + $accepted = $this['acceptedMimetypes']; + } + + if (empty($accepted)) { + return true; + } + + return in_array($type, $accepted); + } + + /** + * Determine if the given file size is acceptable. + * + * @param integer $size Number of bytes. + * @param integer $max The maximum number of bytes allowed. + * Defaults to the property's "maxFilesize". + * @return boolean Returns TRUE if the size is acceptable. + * Otherwise, returns FALSE. + */ + protected function isAcceptedFilesize($size, $max = null) + { + if ($max === null) { + $max = $this['maxFilesize']; + } + + if (empty($max)) { + return true; + } + + return ($size <= $max); + } + + /** + * Determine if the given file path is an absolute path. * * Note: Adapted from symfony\filesystem. * @@ -1082,6 +1464,8 @@ protected function parseIniSize($size) */ protected function isAbsolutePath($file) { + $file = (string)$file; + return strspn($file, '/\\', 0, 1) || (strlen($file) > 3 && ctype_alpha($file[0]) @@ -1155,6 +1539,7 @@ private function renamePatternArgs($path, $args = null) $defaults = [ '{{property}}' => $this->ident(), '{{label}}' => $this['label'], + '{{fallback}}' => $this['fallbackFilename'], '{{extension}}' => $info['extension'], '{{basename}}' => $info['basename'], '{{filename}}' => $info['filename'], diff --git a/src/Charcoal/Property/ImageProperty.php b/src/Charcoal/Property/ImageProperty.php index 491eb6cf..425d5954 100644 --- a/src/Charcoal/Property/ImageProperty.php +++ b/src/Charcoal/Property/ImageProperty.php @@ -262,13 +262,13 @@ public function processEffects($value, array $effects = null, ImageInterface $im } /** - * Provides the accepted mimetypes for the image properties. + * Retrieves the default list of acceptable MIME types for uploaded files. * - * Overrides FileProperty's getAcceptedMimetypes() method. + * This method should be overriden. * * @return string[] */ - public function getAcceptedMimetypes() + public function getDefaultAcceptedMimetypes() { return [ 'image/gif', @@ -282,24 +282,14 @@ public function getAcceptedMimetypes() } /** - * Generate the file extension from the property's value. + * Resolve the file extension from the given MIME type. * - * @param string $file The file to parse. - * @return string The extension based on the MIME type. + * @param string $type The MIME type to resolve. + * @return string|null The extension based on the MIME type. */ - public function generateExtension($file = null) + protected function resolveExtensionFromMimeType($type) { - if (is_string($file)) { - if (in_array($file, $this['acceptedMimetypes'])) { - $mime = $file; - } else { - $mime = $this->getMimetypeFor($file); - } - } else { - $mime = $this->getMimetype(); - } - - switch ($mime) { + switch ($type) { case 'image/gif': return 'gif'; @@ -317,6 +307,8 @@ public function generateExtension($file = null) case 'image/webp': return 'webp'; } + + return null; } /** diff --git a/tests/Charcoal/FixturesTrait.php b/tests/Charcoal/FixturesTrait.php new file mode 100644 index 00000000..442b79f1 --- /dev/null +++ b/tests/Charcoal/FixturesTrait.php @@ -0,0 +1,30 @@ +getPathToFixtures().'/'.ltrim($file, '/'); + } + + /** + * Retrieve the path to the fixtures directory. + * + * @return string The path to the fixtures directory relative to the base directory. + */ + public function getPathToFixtures() + { + return 'tests/Charcoal/Property/Fixture'; + } +} diff --git a/tests/Charcoal/Property/AbstractFilePropertyTestCase.php b/tests/Charcoal/Property/AbstractFilePropertyTestCase.php new file mode 100644 index 00000000..3ce44c41 --- /dev/null +++ b/tests/Charcoal/Property/AbstractFilePropertyTestCase.php @@ -0,0 +1,359 @@ + + */ + private $fileMapOfFixtures; + + /** + * @return void + */ + public function setUp() + { + $this->obj = $this->createProperty(); + } + + /** + * @return array + */ + public function getFileMapOfFixtures() + { + if ($this->fileMapOfFixtures === null) { + $this->fileMapOfFixtures = []; + foreach (self::FIXTURES as $filename) { + $this->fileMapOfFixtures[$filename] = $this->getPathToFixture('files/'.$filename); + } + } + + return $this->fileMapOfFixtures; + } + + /** + * Reports an error identified by $message if $validator does not have the $results. + * + * @param array $expected The expected results. + * @param array $actual The actual results. + * @return void + */ + public function assertValidatorHasResults($expected, $actual) + { + foreach ($actual as $level => $results) { + $this->assertArrayHasKey( + $level, + $expected, + sprintf( + 'Failed asserting that validator results has the level \'%s\'.', + $level + ) + ); + + foreach ($results as $i => $result) { + $this->assertArrayHasKey( + $i, + $expected[$level], + 'Failed asserting that validator results contains an expected message.' + ); + + $this->assertStringMatchesFormat( + $expected[$level][$i], + $result->message() + ); + } + } + } + + /** + * Asserts that the property implements {@see FileProperty}. + * + * @coversNothing + * @return void + */ + public function testFilePropertyInterface() + { + $this->assertInstanceOf(FileProperty::class, $this->obj); + } + + /** + * Asserts that the property adheres to file property defaults. + * + * @return void + */ + public function testPropertyDefaults() + { + $obj = $this->obj; + + $this->assertEquals(FileProperty::DEFAULT_UPLOAD_PATH, $obj['uploadPath']); + $this->assertEquals(FileProperty::DEFAULT_FILESYSTEM, $obj['filesystem']); + $this->assertEquals(FileProperty::DEFAULT_PUBLIC_ACCESS, $obj['publicAccess']); + $this->assertEquals(FileProperty::DEFAULT_OVERWRITE, $obj['overwrite']); + $this->assertEquals($obj['defaultAcceptedMimetypes'], $obj['acceptedMimetypes']); + $this->assertEquals($obj->maxFilesizeAllowedByPhp(), $obj['maxFilesize']); + } + + /** + * Asserts that the file property will generate + * the expected extension from a given dataset. + * + * @dataProvider provideDataForGenerateExtension + * + * @param string $mime A MIME type. + * @param string $ext The expected file extension. + * @return void + */ + public function testGenerateExtensionFromDataProvider($mime, $ext) + { + $this->obj['mimetype'] = $mime; + $this->assertEquals($mime, $this->obj['mimetype']); + $this->assertEquals($ext, $this->obj->generateExtension()); + } + + /** + * Asserts that the file property will generate an extension + * for all default accepted MIME types. + * + * @return void + */ + public function testGenerateExtensionFromDefaultAcceptedMimeTypes() + { + $mimes = $this->obj['defaultAcceptedMimetypes']; + if (empty($mimes)) { + // PHPUnit 7+ + # $this->expectNotToPerformAssertions(); + // PHPUnit 5/6 + $this->assertTrue(true); + return; + } + + foreach ($mimes as $mime) { + $this->obj['mimetype'] = $mime; + $this->assertEquals($mime, $this->obj['mimetype']); + + $ext = $this->obj->generateExtension(); + $this->assertInternalType('string', $ext); + $this->assertNotEmpty($ext); + } + } + + /** + * Asserts that the uploadPath always ends with a trailing "/". + * + * @return void + */ + public function testUploadPath() + { + $obj = $this->obj; + + $return = $obj->setUploadPath('storage/path/a'); + $this->assertSame($obj, $return); + $this->assertEquals('storage/path/a/', $obj->getUploadPath()); + + $obj['uploadPath'] = 'uploads/path/b///'; + $this->assertEquals('uploads/path/b/', $obj['uploadPath']); + + $this->expectException(InvalidArgumentException::class); + $obj->setUploadPath(42); + } + + /** + * Asserts that the property can store a filesize. + * + * @covers \Charcoal\Property\FileProperty::setFilesize() + * @covers \Charcoal\Property\FileProperty::getFilesize() + * @return void + */ + public function testFilesize() + { + $return = $this->obj->setFilesize(1024); + $this->assertSame($this->obj, $return); + $this->assertEquals(1024, $this->obj->getFilesize()); + + $this->obj->setFilesize(null); + $this->assertEquals(0, $this->obj->getFilesize()); + + $this->expectException(InvalidArgumentException::class); + $this->obj->setFilesize(false); + } + + /** + * Asserts that the property returns NULL if it can not resolve the filesize from its value. + * + * @covers \Charcoal\Property\FileProperty::getFilesize() + * @return void + */ + public function testFilesizeFromBadVal() + { + $obj = $this->obj; + + $obj['uploadPath'] = $this->getPathToFixtures().'/files'; + $obj['val'] = $this->getPathToFixture('files/blank.txt'); + + $this->assertEquals(0, $obj['filesize']); + } + + /** + * Asserts that the property can store a MIME type. + * + * @covers \Charcoal\Property\FileProperty::setMimetype() + * @covers \Charcoal\Property\FileProperty::getMimetype() + * @return void + */ + public function testMimetype() + { + $return = $this->obj->setMimetype('foo'); + $this->assertSame($this->obj, $return); + $this->assertEquals('foo', $this->obj->getMimetype()); + + $this->obj->setMimetype(null); + $this->assertNull($this->obj->getMimetype()); + + $this->obj['mimetype'] = false; + $this->assertNull($this->obj['mimetype']); + + $this->expectException(InvalidArgumentException::class); + $this->obj->setMimetype([]); + } + + /** + * Asserts that the property returns NULL if it can not resolve the MIME type from its value. + * + * @covers \Charcoal\Property\FileProperty::getMimetype() + * @return void + */ + public function testMimetypeFromBadVal() + { + $obj = $this->obj; + + $obj['uploadPath'] = $this->getPathToFixtures().'/files'; + $obj['val'] = $this->getPathToFixture('files/blank.txt'); + + $this->assertNull($obj['mimetype']); + } + + /** + * Asserts that the property supports accepted MIME types. + * + * @covers \Charcoal\Property\FileProperty::setAcceptedMimetypes() + * @covers \Charcoal\Property\FileProperty::getAcceptedMimetypes() + * @return void + */ + public function testAcceptedMimeTypes() + { + $obj = $this->obj; + + $return = $obj->setAcceptedMimetypes([ 'text/plain', 'text/csv', 'text/html' ]); + $this->assertSame($obj, $return); + + $accpetedMimeTypes = $obj->getAcceptedMimetypes(); + $this->assertInternalType('array', $accpetedMimeTypes); + $this->assertCount(3, $accpetedMimeTypes); + $this->assertContains('text/plain', $accpetedMimeTypes); + $this->assertContains('text/csv', $accpetedMimeTypes); + $this->assertContains('text/html', $accpetedMimeTypes); + + $obj->setAcceptedMimetypes([ 'text/css', 0 ]); + + $accpetedMimeTypes = $obj->getAcceptedMimetypes(); + $this->assertCount(1, $accpetedMimeTypes); + $this->assertContains('text/css', $accpetedMimeTypes); + + $obj->setAcceptedMimetypes([]); + + $accpetedMimeTypes = $obj->getAcceptedMimetypes(); + $this->assertEquals($obj->getDefaultAcceptedMimetypes(), $accpetedMimeTypes); + + $this->expectException(InvalidArgumentException::class); + $obj->setAcceptedMimetypes('text/plain'); + } + + /** + * Asserts that the property adheres to file property defaults. + * + * @covers \Charcoal\Property\FileProperty::getDefaultAcceptedMimetypes() + * @return void + */ + abstract public function testDefaulAcceptedMimeTypes(); + + /** + * Asserts that the property properly checks if + * any acceptable MIME types are available. + * + * @covers \Charcoal\Property\FileProperty::hasAcceptedMimetypes() + * @return void + */ + abstract public function testHasAcceptedMimeTypes(); + + /** + * Asserts that the property can resolve a filesize from its value. + * + * @return void + */ + abstract public function testFilesizeFromVal(); + + /** + * Asserts that the property can resolve a MIME type from its value. + * + * @return void + */ + abstract public function testMimetypeFromVal(); + + /** + * Asserts that the `type()` method is "file". + * + * @covers \Charcoal\Property\FileProperty::type() + * @return void + */ + abstract public function testPropertyType(); + + /** + * Create a file property instance. + * + * @return PropertyInterface + */ + abstract public function createProperty(); + + /** + * Provide property data for {@see FileProperty::generateExtension()}. + * + * @used-by self::testGenerateExtension() + * @return array Format: `[ "mime-type", "extension" ]` + */ + abstract public function provideDataForGenerateExtension(); +} diff --git a/tests/Charcoal/Property/AudioPropertyTest.php b/tests/Charcoal/Property/AudioPropertyTest.php index 7f17f71a..5ef4c208 100644 --- a/tests/Charcoal/Property/AudioPropertyTest.php +++ b/tests/Charcoal/Property/AudioPropertyTest.php @@ -2,51 +2,113 @@ namespace Charcoal\Tests\Property; +// From 'charcoal-property' use Charcoal\Property\AudioProperty; -use Charcoal\Tests\AbstractTestCase; /** - * ## TODOs - * - 2015-03-12: + * */ -class AudioPropertyTest extends AbstractTestCase +class AudioPropertyTest extends AbstractFilePropertyTestCase { - use \Charcoal\Tests\Property\ContainerIntegrationTrait; - - /** - * @var AudioProperty - */ - public $obj; - /** - * @return void + * Create a file property instance. + * + * @return AudioProperty */ - public function setUp() + public function createProperty() { $container = $this->getContainer(); - $this->obj = new AudioProperty([ + return new AudioProperty([ 'database' => $container['database'], 'logger' => $container['logger'], 'translator' => $container['translator'], + 'container' => $container, ]); } /** + * Asserts that the `type()` method is "file". + * + * @covers \Charcoal\Property\AudioProperty::type() + * @return void + */ + public function testPropertyType() + { + $this->assertEquals('audio', $this->obj->type()); + } + + /** + * Asserts that the property adheres to file property defaults. + * * @return void */ - public function testDefauls() + public function testPropertyDefaults() { + parent::testPropertyDefaults(); + $this->assertEquals(0, $this->obj['minLength']); $this->assertEquals(0, $this->obj['maxLength']); } /** + * Asserts that the property adheres to file property defaults. + * + * @covers \Charcoal\Property\AudioProperty::getDefaultAcceptedMimetypes() * @return void */ - public function testType() + public function testDefaulAcceptedMimeTypes() { - $this->assertEquals('audio', $this->obj->type()); + $this->assertInternalType('array', $this->obj['defaultAcceptedMimetypes']); + $this->assertNotEmpty($this->obj['defaultAcceptedMimetypes']); + } + + /** + * Asserts that the property properly checks if + * any acceptable MIME types are available. + * + * @covers \Charcoal\Property\AudioProperty::hasAcceptedMimetypes() + * @return void + */ + public function testHasAcceptedMimeTypes() + { + $this->assertTrue($this->obj->hasAcceptedMimetypes()); + + $this->obj->setAcceptedMimetypes([ 'audio/wav' ]); + $this->assertTrue($this->obj->hasAcceptedMimetypes()); + } + + /** + * Asserts that the property can resolve a filesize from its value. + * + * @return void + */ + public function testFilesizeFromVal() + { + $obj = $this->obj; + + $obj['uploadPath'] = $this->getPathToFixtures().'/files'; + $obj['val'] = $this->getPathToFixture('files/buzzer.mp3'); + + $this->assertEquals(16512, $obj['filesize']); + } + + /** + * Asserts that the property can resolve a MIME type from its value. + * + * @return void + */ + public function testMimetypeFromVal() + { + $obj = $this->obj; + + $obj['uploadPath'] = $this->getPathToFixtures().'/files'; + $obj['val'] = $this->getPathToFixture('files/buzzer.mp3'); + + $this->assertThat($obj['mimetype'], $this->logicalOr( + $this->equalTo('audio/mp3'), + $this->equalTo('audio/mpeg') + )); } /** @@ -124,29 +186,24 @@ public function testAcceptedMimetypes() } /** - * @dataProvider mimeExtensionProvider + * Provide property data for {@see AudioProperty::generateExtension()}. * - * @param string $mime A MIME type. - * @param string $ext A file format. - * @return void - */ - public function testGenerateExtension($mime, $ext) - { - $this->obj->setMimetype($mime); - $this->assertEquals($mime, $this->obj['mimetype']); - $this->assertEquals($ext, $this->obj->generateExtension()); - } - - /** - * @return array + * @used-by AbstractFilePropertyTestCase::testGenerateExtensionFromDataProvider() + * @return array */ - public function mimeExtensionProvider() + public function provideDataForGenerateExtension() { return [ - ['audio/mp3', 'mp3'], - ['audio/mpeg', 'mp3'], - ['audio/wav', 'wav'], - ['audio/x-wav', 'wav'], + [ 'audio/mp3', 'mp3' ], + [ 'audio/mpeg', 'mp3' ], + [ 'audio/ogg', 'ogg' ], + [ 'audio/webm', 'webm' ], + [ 'audio/wav', 'wav' ], + [ 'audio/wave', 'wav' ], + [ 'audio/x-wav', 'wav' ], + [ 'audio/x-pn-wav', 'wav' ], + [ 'audio/x-foo', null ], + [ 'video/webm', null ], ]; } } diff --git a/tests/Charcoal/Property/ContainerProvider.php b/tests/Charcoal/Property/ContainerProvider.php index cb2f1558..59512fc7 100644 --- a/tests/Charcoal/Property/ContainerProvider.php +++ b/tests/Charcoal/Property/ContainerProvider.php @@ -65,8 +65,8 @@ public function registerBaseServices(Container $container) public function registerConfig(Container $container) { $container['config'] = [ - 'base_path' => realpath(__DIR__.'/../../..'), - 'public_path' => '' + 'base_path' => realpath(__DIR__.'/../../..'), + 'public_path' => realpath(__DIR__.'/../../..'), ]; } diff --git a/tests/Charcoal/Property/FilePropertyTest.php b/tests/Charcoal/Property/FilePropertyTest.php index 2594d3e3..e8729af1 100644 --- a/tests/Charcoal/Property/FilePropertyTest.php +++ b/tests/Charcoal/Property/FilePropertyTest.php @@ -6,33 +6,27 @@ use InvalidArgumentException; use ReflectionClass; +// From 'charcoal-core' +use Charcoal\Validator\ValidatorInterface as Validator; + // From 'charcoal-property' use Charcoal\Property\FileProperty; -use Charcoal\Tests\AbstractTestCase; -use Charcoal\Tests\ReflectionsTrait; -use Charcoal\Tests\Property\ContainerIntegrationTrait; /** * */ -class FilePropertyTest extends AbstractTestCase +class FilePropertyTest extends AbstractFilePropertyTestCase { - use ReflectionsTrait; - use ContainerIntegrationTrait; - /** - * @var FileProperty - */ - public $obj; - - /** - * @return void + * Create a file property instance. + * + * @return FileProperty */ - public function setUp() + public function createProperty() { $container = $this->getContainer(); - $this->obj = new FileProperty([ + return new FileProperty([ 'database' => $container['database'], 'logger' => $container['logger'], 'translator' => $container['translator'], @@ -41,27 +35,81 @@ public function setUp() } /** + * Asserts that the `type()` method is "file". + * + * @covers \Charcoal\Property\FileProperty::type() * @return void */ - public function testConstructor() + public function testPropertyType() + { + $this->assertEquals('file', $this->obj->type()); + } + + /** + * Asserts that the property adheres to file property defaults. + * + * @covers \Charcoal\Property\FileProperty::getDefaultAcceptedMimetypes() + * @return void + */ + public function testDefaulAcceptedMimeTypes() + { + $this->assertInternalType('array', $this->obj['defaultAcceptedMimetypes']); + $this->assertEmpty($this->obj['defaultAcceptedMimetypes']); + } + + /** + * Asserts that the property properly checks if + * any acceptable MIME types are available. + * + * @covers \Charcoal\Property\FileProperty::hasAcceptedMimetypes() + * @return void + */ + public function testHasAcceptedMimeTypes() { $obj = $this->obj; - $this->assertInstanceOf('\Charcoal\Property\FileProperty', $obj); - $this->assertEquals('uploads/', $obj['uploadPath']); - $this->assertFalse($obj['overwrite']); - $this->assertEquals([], $obj['acceptedMimetypes']); - $this->assertEquals($obj->maxFilesizeAllowedByPhp(), $obj['maxFilesize']); + + $explicitMimeTypes = $this->getPropertyValue($obj, 'acceptedMimetypes'); + $fallbackMimeTypes = $obj->getDefaultAcceptedMimetypes(); + if (!empty($explicitMimeTypes) || !empty($fallbackMimeTypes)) { + $this->assertTrue($obj->hasAcceptedMimetypes()); + } else { + $this->assertFalse($obj->hasAcceptedMimetypes()); + } + + if (empty($explicitMimeTypes)) { + $obj->setAcceptedMimetypes([ 'text/plain', 'text/html', 'text/css' ]); + $this->assertTrue($obj->hasAcceptedMimetypes()); + } } /** - * Asserts that the `type()` method is "file". + * Asserts that the property can resolve a filesize from its value. * * @return void */ - public function testType() + public function testFilesizeFromVal() { $obj = $this->obj; - $this->assertEquals('file', $obj->type()); + + $obj['uploadPath'] = $this->getPathToFixtures().'/files'; + $obj['val'] = $this->getPathToFixture('files/document.txt'); + + $this->assertEquals(743, $obj['filesize']); + } + + /** + * Asserts that the property can resolve a MIME type from its value. + * + * @return void + */ + public function testMimetypeFromVal() + { + $obj = $this->obj; + + $obj['uploadPath'] = $this->getPathToFixtures().'/files'; + $obj['val'] = $this->getPathToFixture('files/document.txt'); + + $this->assertEquals('text/plain', $obj['mimetype']); } /** @@ -74,7 +122,7 @@ public function testSetData() 'public_access' => true, 'uploadPath' => 'uploads/foobar/', 'overwrite' => true, - 'acceptedMimetypes' => ['image/x-foobar'], + 'acceptedMimetypes' => [ 'image/x-foobar' ], 'maxFilesize' => (32 * 1024 * 1024), ]); $this->assertSame($ret, $obj); @@ -86,32 +134,6 @@ public function testSetData() $this->assertEquals((32 * 1024 * 1024), $this->obj['maxFilesize']); } - /** - * Asserts that the uploadPath method - * - defaults to 'uploads/' - * - always append a "/" - * - * @return void - */ - public function testSetUploadPath() - { - $obj = $this->obj; - $this->assertEquals('uploads/', $this->obj['uploadPath']); - - $ret = $obj->setUploadPath('foobar'); - $this->assertSame($ret, $obj); - $this->assertEquals('foobar/', $obj['uploadPath']); - - $this->obj['upload_path'] = 'foo'; - $this->assertEquals('foo/', $obj['uploadPath']); - - $this->obj->set('upload_path', 'bar'); - $this->assertEquals('bar/', $obj['upload_path']); - - $this->expectException(\InvalidArgumentException::class); - $obj->setUploadPath(42); - } - /** * @return void */ @@ -133,29 +155,83 @@ public function testSetOverwrite() */ public function testVaidationMethods() { + $methods = $this->obj->validationMethods(); + $this->assertContains('mimetypes', $methods); + $this->assertContains('filesizes', $methods); + } + + /** + * Test validation file MIME types on property. + * + * @dataProvider provideDataForValidateMimetypes + * + * @param mixed $val The value(s) to be validated. + * @param boolean $l10n Whether the property value is multilingual. + * @param boolean $multiple Whether the property accepts zero or more values. + * @param mixed $acceptedMimetypes The accepted MIME types. + * @param boolean $expectedReturn The expected return value of the method. + * @param array $expectedResults The expected validation results. + * @return void + */ + public function testValidateMimetypes( + $val, + $l10n, + $multiple, + $acceptedMimetypes, + $expectedReturn, + array $expectedResults = [] + ) { $obj = $this->obj; - $ret = $obj->validationMethods(); - $this->assertContains('acceptedMimetypes', $ret); - $this->assertContains('maxFilesize', $ret); + + $obj['uploadPath'] = $this->getPathToFixtures().'/files'; + $obj['acceptedMimetypes'] = $acceptedMimetypes; + $obj['l10n'] = $l10n; + $obj['multiple'] = $multiple; + $obj['val'] = $val; + + $this->assertSame($expectedReturn, $obj->validateMimetypes()); + + $this->assertValidatorHasResults( + $expectedResults, + $obj->validator()->results() + ); } /** + * Test validation file sizes on property. + * + * @dataProvider provideDataForValidateFilesizes + * + * @param mixed $val The value(s) to be validated. + * @param boolean $l10n Whether the property value is multilingual. + * @param boolean $multiple Whether the property accepts zero or more values. + * @param integer $maxFilesize The maximum file size accepted. + * @param boolean $expectedReturn The expected return value of the method. + * @param array $expectedResults The expected validation results. * @return void */ - public function testValidateAcceptedMimetypes() - { + public function testValidateFilesizes( + $val, + $l10n, + $multiple, + $maxFilesize, + $expectedReturn, + array $expectedResults = [] + ) { $obj = $this->obj; - $obj->setMimetype('image/x-foobar'); - $this->assertTrue($obj->validateAcceptedMimetypes()); - $this->assertEmpty($obj['acceptedMimetypes']); - $this->assertTrue($obj->validateAcceptedMimetypes()); + $obj['uploadPath'] = $this->getPathToFixtures().'/files'; + $obj['maxFilesize'] = $maxFilesize; + $obj['l10n'] = $l10n; + $obj['multiple'] = $multiple; + $obj['val'] = $val; - $obj->setAcceptedMimetypes(['image/x-barbaz']); - $this->assertFalse($obj->validateAcceptedMimetypes()); + $this->assertSame($expectedReturn, $obj->validateFilesizes()); - $obj->setAcceptedMimetypes(['image/x-foobar']); - $this->assertTrue($obj->validateAcceptedMimetypes()); + $this->assertValidatorHasResults( + $expectedResults, + $obj->validator()->results() + ); } /** @@ -227,16 +303,6 @@ public function filenameProvider() ]; } - /** - * @return void - */ - // public function testGenerateFilenameWithoutIdentThrowsException() - // { - // $obj = $this->obj; - // $this->expectException('\Exception'); - // $obj->generateFilename(); - // } - /** * @return void */ @@ -270,22 +336,6 @@ public function testFilesystem() $this->assertEquals('foo', $this->obj['filesystem']); } - public function testSetMimetype() - { - $ret = $this->obj->setMimetype('foo'); - $this->assertSame($ret, $this->obj); - $this->assertEquals('foo', $this->obj->mimetype()); - - $this->obj->setMimetype(null); - $this->assertEquals('', $this->obj->mimetype()); - - $this->obj->setMimetype(false); - $this->assertEquals('', $this->obj->mimetype()); - - $this->expectException(InvalidArgumentException::class); - $this->obj->setMimetype([]); - } - /** * @return void */ @@ -313,4 +363,319 @@ public function testSqlPdoType() { $this->assertEquals(PDO::PARAM_STR, $this->obj->sqlPdoType()); } + + /** + * Provide property data for {@see FileProperty::validateMimetypes()}. + * + * @used-by self::testValidateMimetypes() + * @return array + */ + public function provideDataForValidateMimetypes() + { + $paths = $this->getFileMapOfFixtures(); + + return [ + 'any MIME types, no value' => [ + 'propertyValues' => null, + 'propertyL10n' => false, + 'propertyMultiple' => false, + 'acceptedMimetypes' => null, + 'assertValidationReturn' => true, + 'assertValidationResults' => [], + ], + 'any MIME types, text file' => [ + 'propertyValues' => $paths['document.txt'], + 'propertyL10n' => false, + 'propertyMultiple' => false, + 'acceptedMimetypes' => null, + 'assertValidationReturn' => true, + 'assertValidationResults' => [], + ], + 'any MIME types, image file' => [ + 'propertyValues' => $paths['panda.png'], + 'propertyL10n' => false, + 'propertyMultiple' => false, + 'acceptedMimetypes' => null, + 'assertValidationReturn' => true, + 'assertValidationResults' => [], + ], + 'text/plain, no value' => [ + 'propertyValues' => null, + 'propertyL10n' => false, + 'propertyMultiple' => false, + 'acceptedMimetypes' => [ 'text/plain' ], + 'assertValidationReturn' => true, + 'assertValidationResults' => [], + ], + 'text/plain, single text file' => [ + 'propertyValues' => $paths['document.txt'], + 'propertyL10n' => false, + 'propertyMultiple' => false, + 'acceptedMimetypes' => [ 'text/plain' ], + 'assertValidationReturn' => true, + 'assertValidationResults' => [], + ], + 'text/plain, single image file' => [ + 'propertyValues' => $paths['panda.png'], + 'propertyL10n' => false, + 'propertyMultiple' => false, + 'acceptedMimetypes' => [ 'text/plain' ], + 'assertValidationReturn' => false, + 'assertValidationResults' => [ + Validator::ERROR => [ + 'File ['.$paths['panda.png'].'] has unacceptable MIME type [image/png]', + ], + ], + ], + 'text/plain, nonexistent file' => [ + 'propertyValues' => $paths['nonexistent.txt'], + 'propertyL10n' => false, + 'propertyMultiple' => false, + 'acceptedMimetypes' => [ 'text/plain' ], + 'assertValidationReturn' => false, + 'assertValidationResults' => [ + Validator::ERROR => [ + 'File ['.$paths['nonexistent.txt'].'] not found or MIME type unrecognizable', + ], + ], + ], + 'text/plain, l10n, text file' => [ + 'propertyValues' => $paths['document.txt'], + 'propertyL10n' => true, + 'propertyMultiple' => false, + 'acceptedMimetypes' => [ 'text/plain' ], + 'assertValidationReturn' => true, + 'assertValidationResults' => [], + ], + 'text/plain, l10n, text + image file' => [ + 'propertyValues' => [ + 'en' => $paths['document.txt'], + 'fr' => $paths['panda.png'], + ], + 'propertyL10n' => true, + 'propertyMultiple' => false, + 'acceptedMimetypes' => [ 'text/plain' ], + 'assertValidationReturn' => false, + 'assertValidationResults' => [ + Validator::ERROR => [ + 'File ['.$paths['panda.png'].'] has unacceptable MIME type [image/png]', + ], + ], + ], + 'text/plain, multiple, text files' => [ + 'propertyValues' => [ + $paths['document.txt'], + $paths['todo.txt'], + ], + 'propertyL10n' => false, + 'propertyMultiple' => true, + 'acceptedMimetypes' => [ 'text/plain' ], + 'assertValidationReturn' => true, + 'assertValidationResults' => [], + ], + 'text/plain, multiple, text + image file' => [ + 'propertyValues' => [ + $paths['document.txt'], + $paths['panda.png'], + ], + 'propertyL10n' => false, + 'propertyMultiple' => true, + 'acceptedMimetypes' => [ 'text/plain' ], + 'assertValidationReturn' => false, + 'assertValidationResults' => [ + Validator::ERROR => [ + 'File ['.$paths['panda.png'].'] has unacceptable MIME type [image/png]', + ], + ], + ], + 'text/plain, l10n + multiple #1' => [ + 'propertyValues' => [ + 'en' => $paths['document.txt'].','.$paths['todo.txt'], + 'fr' => [ $paths['stuff.txt'], $paths['draft.txt'] ], + ], + 'propertyL10n' => false, + 'propertyMultiple' => false, + 'acceptedMimetypes' => [ 'text/plain' ], + 'assertValidationReturn' => true, + 'assertValidationResults' => [], + ], + 'text/plain, l10n + multiple #2' => [ + 'propertyValues' => [ + 'en' => $paths['document.txt'].','.$paths['scream.wav'], + 'fr' => [ $paths['stuff.txt'], $paths['cat.jpg'] ], + ], + 'propertyL10n' => false, + 'propertyMultiple' => false, + 'acceptedMimetypes' => [ 'text/plain' ], + 'assertValidationReturn' => false, + 'assertValidationResults' => [ + Validator::ERROR => [ + 'File ['.$paths['scream.wav'].'] has unacceptable MIME type [audio/%s]', + 'File ['.$paths['cat.jpg'].'] has unacceptable MIME type [image/%s]', + ], + ], + ], + ]; + } + + /** + * Provide property data for {@see FileProperty::validateFilesizes()}. + * + * @used-by self::testValidateFilesizes() + * @return array + */ + public function provideDataForValidateFilesizes() + { + $paths = $this->getFileMapOfFixtures(); + + return [ + 'any size, no value' => [ + 'propertyValues' => null, + 'propertyL10n' => false, + 'propertyMultiple' => false, + 'maxFilesize' => 0, + 'assertValidationReturn' => true, + 'assertValidationResults' => [], + ], + 'any size, text file' => [ + 'propertyValues' => $paths['document.txt'], + 'propertyL10n' => false, + 'propertyMultiple' => false, + 'maxFilesize' => 0, + 'assertValidationReturn' => true, + 'assertValidationResults' => [], + ], + 'max 10kB, no value' => [ + 'propertyValues' => null, + 'propertyL10n' => false, + 'propertyMultiple' => false, + 'maxFilesize' => 10240, + 'assertValidationReturn' => true, + 'assertValidationResults' => [], + ], + 'max 10kB, single text file' => [ + 'propertyValues' => $paths['document.txt'], + 'propertyL10n' => false, + 'propertyMultiple' => false, + 'maxFilesize' => 10240, + 'assertValidationReturn' => true, + 'assertValidationResults' => [], + ], + 'max 10kB, single image file' => [ + 'propertyValues' => $paths['panda.png'], + 'propertyL10n' => false, + 'propertyMultiple' => false, + 'maxFilesize' => 10240, + 'assertValidationReturn' => false, + 'assertValidationResults' => [ + Validator::ERROR => [ + 'File ['.$paths['panda.png'].'] exceeds maximum file size [%s]', + ], + ], + ], + 'max 10kB, nonexistent file' => [ + 'propertyValues' => $paths['nonexistent.txt'], + 'propertyL10n' => false, + 'propertyMultiple' => false, + 'maxFilesize' => 10240, + 'assertValidationReturn' => false, + 'assertValidationResults' => [ + Validator::ERROR => [ + 'File ['.$paths['nonexistent.txt'].'] not found or size unknown', + ], + ], + ], + 'max 10kB, l10n, text file' => [ + 'propertyValues' => $paths['document.txt'], + 'propertyL10n' => true, + 'propertyMultiple' => false, + 'maxFilesize' => 10240, + 'assertValidationReturn' => true, + 'assertValidationResults' => [], + ], + 'max 10kB, l10n, text + image file' => [ + 'propertyValues' => [ + 'en' => $paths['document.txt'], + 'fr' => $paths['panda.png'], + ], + 'propertyL10n' => true, + 'propertyMultiple' => false, + 'maxFilesize' => 10240, + 'assertValidationReturn' => false, + 'assertValidationResults' => [ + Validator::ERROR => [ + 'File ['.$paths['panda.png'].'] exceeds maximum file size [%s]', + ], + ], + ], + 'max 10kB, multiple, text files' => [ + 'propertyValues' => [ + $paths['document.txt'], + $paths['todo.txt'], + ], + 'propertyL10n' => false, + 'propertyMultiple' => true, + 'maxFilesize' => 10240, + 'assertValidationReturn' => true, + 'assertValidationResults' => [], + ], + 'max 10kB, multiple, text + image file' => [ + 'propertyValues' => [ + $paths['document.txt'], + $paths['panda.png'], + ], + 'propertyL10n' => false, + 'propertyMultiple' => true, + 'maxFilesize' => 10240, + 'assertValidationReturn' => false, + 'assertValidationResults' => [ + Validator::ERROR => [ + 'File ['.$paths['panda.png'].'] exceeds maximum file size [%s]', + ], + ], + ], + 'max 10kB, l10n + multiple #1' => [ + 'propertyValues' => [ + 'en' => $paths['document.txt'].','.$paths['todo.txt'], + 'fr' => [ $paths['stuff.txt'], $paths['draft.txt'] ], + ], + 'propertyL10n' => false, + 'propertyMultiple' => false, + 'maxFilesize' => 10240, + 'assertValidationReturn' => true, + 'assertValidationResults' => [], + ], + 'max 10kB, l10n + multiple #2' => [ + 'propertyValues' => [ + 'en' => $paths['document.txt'].','.$paths['scream.wav'], + 'fr' => [ $paths['stuff.txt'], $paths['panda.png'] ], + ], + 'propertyL10n' => false, + 'propertyMultiple' => false, + 'maxFilesize' => 10240, + 'assertValidationReturn' => false, + 'assertValidationResults' => [ + Validator::ERROR => [ + 'File ['.$paths['scream.wav'].'] exceeds maximum file size [%s]', + 'File ['.$paths['panda.png'].'] exceeds maximum file size [%s]', + ], + ], + ], + ]; + } + + /** + * Provide property data for {@see ImageProperty::generateExtension()}. + * + * @used-by AbstractFilePropertyTestCase::testGenerateExtensionFromDataProvider() + * @return array + */ + public function provideDataForGenerateExtension() + { + return [ + [ 'text/plain', 'txt' ], + [ 'text/html', null ], + [ 'image/x-foo', null ], + ]; + } } diff --git a/tests/Charcoal/Property/Fixture/files/blank.txt b/tests/Charcoal/Property/Fixture/files/blank.txt new file mode 100644 index 00000000..e69de29b diff --git a/tests/Charcoal/Property/Fixture/files/buzzer.mp3 b/tests/Charcoal/Property/Fixture/files/buzzer.mp3 new file mode 100644 index 00000000..0f940e05 Binary files /dev/null and b/tests/Charcoal/Property/Fixture/files/buzzer.mp3 differ diff --git a/tests/Charcoal/Property/Fixture/files/cat.jpg b/tests/Charcoal/Property/Fixture/files/cat.jpg new file mode 100644 index 00000000..998b4255 Binary files /dev/null and b/tests/Charcoal/Property/Fixture/files/cat.jpg differ diff --git a/tests/Charcoal/Property/Fixture/files/document.txt b/tests/Charcoal/Property/Fixture/files/document.txt new file mode 100644 index 00000000..c81fc7de --- /dev/null +++ b/tests/Charcoal/Property/Fixture/files/document.txt @@ -0,0 +1,7 @@ +Omnis et sint excepturi laboriosam quod. Non alias consequatur natus reprehenderit qui. Animi animi est similique aliquam omnis. Qui adipisci quia ea corrupti. Aut et ab laudantium qui dolore aliquid. + +Totam consectetur magni maiores suscipit voluptate. Qui non perferendis omnis omnis deleniti vel. Sunt earum est quod. Alias est et dolorem ut porro rem. Ratione veritatis sequi expedita. + +Qui ipsa deleniti est quia. Ullam enim est dignissimos nostrum. Et cum magni minima. Omnis eum cum ullam consequuntur vel. Officiis dolor nam quia quae. + +Pariatur voluptatibus expedita ipsum iste officia neque sequi saepe. Quasi repellendus eum sint placeat officiis quas. Vel voluptatem voluptatem aut in blanditiis rem. Et tenetur ut rerum optio ut. diff --git a/tests/Charcoal/Property/Fixture/files/draft.txt b/tests/Charcoal/Property/Fixture/files/draft.txt new file mode 100644 index 00000000..a9bc6951 --- /dev/null +++ b/tests/Charcoal/Property/Fixture/files/draft.txt @@ -0,0 +1 @@ +Quas dolorum repellendus tempora repellendus neque quasi sit. Dolores et vel rerum sit. Quam et eum commodi suscipit qui architecto doloremque iste. Corporis quidem accusamus libero dolor quia totam asperiores vero. Iusto architecto soluta corrupti et qui voluptatem nobis et. diff --git a/tests/Charcoal/Property/Fixture/files/panda.png b/tests/Charcoal/Property/Fixture/files/panda.png new file mode 100644 index 00000000..b4ccae8d Binary files /dev/null and b/tests/Charcoal/Property/Fixture/files/panda.png differ diff --git a/tests/Charcoal/Property/Fixture/files/scream.wav b/tests/Charcoal/Property/Fixture/files/scream.wav new file mode 100644 index 00000000..435fdcaa Binary files /dev/null and b/tests/Charcoal/Property/Fixture/files/scream.wav differ diff --git a/tests/Charcoal/Property/Fixture/files/stuff.txt b/tests/Charcoal/Property/Fixture/files/stuff.txt new file mode 100644 index 00000000..d3c64bee --- /dev/null +++ b/tests/Charcoal/Property/Fixture/files/stuff.txt @@ -0,0 +1 @@ +Stuff! diff --git a/tests/Charcoal/Property/Fixture/files/todo.txt b/tests/Charcoal/Property/Fixture/files/todo.txt new file mode 100644 index 00000000..f933ccce --- /dev/null +++ b/tests/Charcoal/Property/Fixture/files/todo.txt @@ -0,0 +1,5 @@ +- [x] Ut fuga corporis iste dolore voluptatum sint. +- [x] Natus vel sed qui dolor. +- [ ] Facere modi reiciendis optio quo aspernatur. +- [x] Sit optio et ipsa et ad odit quis. +- [ ] Rerum accusantium aspernatur in blanditiis qui. diff --git a/tests/Charcoal/Property/ImagePropertyTest.php b/tests/Charcoal/Property/ImagePropertyTest.php index d92dd61b..aa346537 100644 --- a/tests/Charcoal/Property/ImagePropertyTest.php +++ b/tests/Charcoal/Property/ImagePropertyTest.php @@ -6,46 +6,111 @@ // From 'charcoal-property' use Charcoal\Property\ImageProperty; -use Charcoal\Tests\AbstractTestCase; /** - * ## TODOs - * - 2015-03-12: + * */ -class ImagePropertyTest extends AbstractTestCase +class ImagePropertyTest extends AbstractFilePropertyTestCase { - use \Charcoal\Tests\Property\ContainerIntegrationTrait; - - /** - * @var ImageProperty - */ - public $obj; - /** - * @return void + * Create a file property instance. + * + * @return ImageProperty */ - public function setUp() + public function createProperty() { $container = $this->getContainer(); - $this->obj = new ImageProperty([ + return new ImageProperty([ 'database' => $container['database'], 'logger' => $container['logger'], 'translator' => $container['translator'], + 'container' => $container, ]); } - public function testDefaults() + /** + * Asserts that the `type()` method is "file". + * + * @covers \Charcoal\Property\ImageProperty::type() + * @return void + */ + public function testPropertyType() { - $this->assertEquals([], $this->obj['effects']); + $this->assertEquals('image', $this->obj->type()); + } + + /** + * Asserts that the property adheres to file property defaults. + * + * @return void + */ + public function testPropertyDefaults() + { + parent::testPropertyDefaults(); + + $this->assertInternalType('array', $this->obj['effects']); + $this->assertEmpty($this->obj['effects']); + $this->assertEquals(ImageProperty::DEFAULT_DRIVER_TYPE, $this->obj['driverType']); + $this->assertEquals(ImageProperty::DEFAULT_APPLY_EFFECTS, $this->obj['applyEffects']); } + /** + * Asserts that the property adheres to file property defaults. + * + * @covers \Charcoal\Property\ImageProperty::getDefaultAcceptedMimetypes() * @return void */ - public function testType() + public function testDefaulAcceptedMimeTypes() { - $this->assertEquals('image', $this->obj->type()); + $this->assertInternalType('array', $this->obj['defaultAcceptedMimetypes']); + $this->assertNotEmpty($this->obj['defaultAcceptedMimetypes']); + } + + /** + * Asserts that the property properly checks if + * any acceptable MIME types are available. + * + * @covers \Charcoal\Property\ImageProperty::hasAcceptedMimetypes() + * @return void + */ + public function testHasAcceptedMimeTypes() + { + $this->assertTrue($this->obj->hasAcceptedMimetypes()); + + $this->obj->setAcceptedMimetypes([ 'image/gif' ]); + $this->assertTrue($this->obj->hasAcceptedMimetypes()); + } + + /** + * Asserts that the property can resolve a filesize from its value. + * + * @return void + */ + public function testFilesizeFromVal() + { + $obj = $this->obj; + + $obj['uploadPath'] = $this->getPathToFixtures().'/files'; + $obj['val'] = $this->getPathToFixture('files/panda.png'); + + $this->assertEquals(170276, $obj['filesize']); + } + + /** + * Asserts that the property can resolve a MIME type from its value. + * + * @return void + */ + public function testMimetypeFromVal() + { + $obj = $this->obj; + + $obj['uploadPath'] = $this->getPathToFixtures().'/files'; + $obj['val'] = $this->getPathToFixture('files/panda.png'); + + $this->assertEquals('image/png', $obj['mimetype']); } /** @@ -132,4 +197,25 @@ public function testAcceptedMimetypes() $this->assertContains('image/png', $ret); $this->assertContains('image/jpg', $ret); } + + /** + * Provide property data for {@see ImageProperty::generateExtension()}. + * + * @used-by AbstractFilePropertyTestCase::testGenerateExtensionFromDataProvider() + * @return array + */ + public function provideDataForGenerateExtension() + { + return [ + [ 'image/gif', 'gif' ], + [ 'image/jpg', 'jpg' ], + [ 'image/jpeg', 'jpg' ], + [ 'image/pjpeg', 'jpg' ], + [ 'image/png', 'png' ], + [ 'image/svg+xml', 'svg' ], + [ 'image/webp', 'webp' ], + [ 'image/x-foo', null ], + [ 'video/webm', null ], + ]; + } }