diff options
-rw-r--r-- | app/Controller/File.php | 38 | ||||
-rw-r--r-- | app/Core/ObjectStorage/FileStorage.php | 150 | ||||
-rw-r--r-- | app/Core/ObjectStorage/ObjectStorageException.php | 9 | ||||
-rw-r--r-- | app/Core/ObjectStorage/ObjectStorageInterface.php | 68 | ||||
-rw-r--r-- | app/Core/Tool.php | 78 | ||||
-rw-r--r-- | app/Model/File.php | 244 | ||||
-rw-r--r-- | app/ServiceProvider/ClassProvider.php | 5 | ||||
-rw-r--r-- | app/Template/file/show.php | 2 | ||||
-rw-r--r-- | tests/units/Model/FileTest.php | 77 |
9 files changed, 517 insertions, 154 deletions
diff --git a/app/Controller/File.php b/app/Controller/File.php index f73a9de9..7b7c75ee 100644 --- a/app/Controller/File.php +++ b/app/Controller/File.php @@ -60,7 +60,7 @@ class File extends Base { $task = $this->getTask(); - if (! $this->file->upload($task['project_id'], $task['id'], 'files')) { + if (! $this->file->uploadFiles($task['project_id'], $task['id'], 'files')) { $this->session->flashError(t('Unable to upload the file.')); } @@ -76,14 +76,13 @@ class File extends Base { $task = $this->getTask(); $file = $this->file->getById($this->request->getIntegerParam('file_id')); - $filename = FILES_DIR.$file['path']; - if ($file['task_id'] == $task['id'] && file_exists($filename)) { - $this->response->forceDownload($file['name']); - $this->response->binary(file_get_contents($filename)); + if ($file['task_id'] != $task['id']) { + $this->response->redirect($this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']))); } - $this->response->redirect($this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']))); + $this->response->forceDownload($file['name']); + $this->objectStorage->passthru($file['path']); } /** @@ -113,16 +112,13 @@ class File extends Base { $task = $this->getTask(); $file = $this->file->getById($this->request->getIntegerParam('file_id')); - $filename = FILES_DIR.$file['path']; - - if ($file['task_id'] == $task['id'] && file_exists($filename)) { - $metadata = getimagesize($filename); - if (isset($metadata['mime'])) { - $this->response->contentType($metadata['mime']); - readfile($filename); - } + if ($file['task_id'] != $task['id']) { + $this->response->redirect($this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']))); } + + $this->response->contentType($this->file->getImageMimeType($file['name'])); + $this->objectStorage->passthru($file['path']); } /** @@ -134,17 +130,13 @@ class File extends Base { $task = $this->getTask(); $file = $this->file->getById($this->request->getIntegerParam('file_id')); - $filename = FILES_DIR.$file['path']; - - if ($file['task_id'] == $task['id'] && file_exists($filename)) { - $this->response->contentType('image/jpeg'); - $this->file->generateThumbnail( - $filename, - $this->request->getIntegerParam('width'), - $this->request->getIntegerParam('height') - ); + if ($file['task_id'] != $task['id']) { + $this->response->redirect($this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']))); } + + $this->response->contentType('image/jpeg'); + $this->objectStorage->passthru($this->file->getThumbnailPath($file['path'])); } /** diff --git a/app/Core/ObjectStorage/FileStorage.php b/app/Core/ObjectStorage/FileStorage.php new file mode 100644 index 00000000..66c62334 --- /dev/null +++ b/app/Core/ObjectStorage/FileStorage.php @@ -0,0 +1,150 @@ +<?php + +namespace Core\ObjectStorage; + +/** + * Local File Storage + * + * @package ObjectStorage + * @author Frederic Guillot + */ +class FileStorage implements ObjectStorageInterface +{ + /** + * Base path + * + * @access private + * @var string + */ + private $path = ''; + + /** + * Constructor + * + * @access public + * @param string $path + */ + public function __construct($path) + { + $this->path = $path; + } + + /** + * Fetch object contents + * + * @access public + * @param string $key + * @return string + */ + public function get($key) + { + $filename = $this->path.DIRECTORY_SEPARATOR.$key; + + if (! file_exists($filename)) { + throw new ObjectStorageException('File not found: '.$filename); + } + + return file_get_contents($filename); + } + + /** + * Save object + * + * @access public + * @param string $key + * @param string $blob + * @return string + */ + public function put($key, &$blob) + { + $this->createFolder($key); + + if (file_put_contents($this->path.DIRECTORY_SEPARATOR.$key, $blob) === false) { + throw new ObjectStorageException('Unable to write the file: '.$this->path.DIRECTORY_SEPARATOR.$key); + } + } + + /** + * Output directly object content + * + * @access public + * @param string $key + */ + public function passthru($key) + { + $filename = $this->path.DIRECTORY_SEPARATOR.$key; + + if (! file_exists($filename)) { + throw new ObjectStorageException('File not found: '.$filename); + } + + return readfile($filename); + } + + /** + * Move local file to object storage + * + * @access public + * @param string $filename + * @param string $key + * @return boolean + */ + public function moveFile($src_filename, $key) + { + $this->createFolder($key); + $dst_filename = $this->path.DIRECTORY_SEPARATOR.$key; + + if (! rename($src_filename, $dst_filename)) { + throw new ObjectStorageException('Unable to move the file: '.$src_filename.' to '.$dst_filename); + } + + return true; + } + + /** + * Move uploaded file to object storage + * + * @access public + * @param string $filename + * @param string $key + * @return boolean + */ + public function moveUploadedFile($filename, $key) + { + $this->createFolder($key); + return move_uploaded_file($filename, $this->path.DIRECTORY_SEPARATOR.$key); + } + + /** + * Remove object + * + * @access public + * @param string $key + * @return boolean + */ + public function remove($key) + { + $filename = $this->path.DIRECTORY_SEPARATOR.$key; + + if (file_exists($filename)) { + return unlink($filename); + } + + return false; + } + + /** + * Create object folder + * + * @access private + * @param string $key + */ + private function createFolder($key) + { + $folder = $this->path.DIRECTORY_SEPARATOR.dirname($key); + + if (! is_dir($folder) && ! mkdir($folder, 0755, true)) { + throw new ObjectStorageException('Unable to create folder: '.$folder); + } + } +} diff --git a/app/Core/ObjectStorage/ObjectStorageException.php b/app/Core/ObjectStorage/ObjectStorageException.php new file mode 100644 index 00000000..e89aeb25 --- /dev/null +++ b/app/Core/ObjectStorage/ObjectStorageException.php @@ -0,0 +1,9 @@ +<?php + +namespace Core\ObjectStorage; + +use Exception; + +class ObjectStorageException extends Exception +{ +} diff --git a/app/Core/ObjectStorage/ObjectStorageInterface.php b/app/Core/ObjectStorage/ObjectStorageInterface.php new file mode 100644 index 00000000..5440cf2b --- /dev/null +++ b/app/Core/ObjectStorage/ObjectStorageInterface.php @@ -0,0 +1,68 @@ +<?php + +namespace Core\ObjectStorage; + +/** + * Object Storage Interface + * + * @package ObjectStorage + * @author Frederic Guillot + */ +interface ObjectStorageInterface +{ + /** + * Fetch object contents + * + * @access public + * @param string $key + * @return string + */ + public function get($key); + + /** + * Save object + * + * @access public + * @param string $key + * @param string $blob + * @return string + */ + public function put($key, &$blob); + + /** + * Output directly object content + * + * @access public + * @param string $key + */ + public function passthru($key); + + /** + * Move local file to object storage + * + * @access public + * @param string $filename + * @param string $key + * @return boolean + */ + public function moveFile($filename, $key); + + /** + * Move uploaded file to object storage + * + * @access public + * @param string $filename + * @param string $key + * @return boolean + */ + public function moveUploadedFile($filename, $key); + + /** + * Remove object + * + * @access public + * @param string $key + * @return boolean + */ + public function remove($key); +} diff --git a/app/Core/Tool.php b/app/Core/Tool.php index 7939a80e..887c8fb3 100644 --- a/app/Core/Tool.php +++ b/app/Core/Tool.php @@ -72,4 +72,82 @@ class Tool } } } + + /** + * Generate a jpeg thumbnail from an image + * + * @static + * @access public + * @param string $src_file Source file image + * @param string $dst_file Destination file image + * @param integer $resize_width Desired image width + * @param integer $resize_height Desired image height + */ + public static function generateThumbnail($src_file, $dst_file, $resize_width = 250, $resize_height = 100) + { + $metadata = getimagesize($src_file); + $src_width = $metadata[0]; + $src_height = $metadata[1]; + $dst_y = 0; + $dst_x = 0; + + if (empty($metadata['mime'])) { + return; + } + + if ($resize_width == 0 && $resize_height == 0) { + $resize_width = 100; + $resize_height = 100; + } + + if ($resize_width > 0 && $resize_height == 0) { + $dst_width = $resize_width; + $dst_height = floor($src_height * ($resize_width / $src_width)); + $dst_image = imagecreatetruecolor($dst_width, $dst_height); + } + elseif ($resize_width == 0 && $resize_height > 0) { + $dst_width = floor($src_width * ($resize_height / $src_height)); + $dst_height = $resize_height; + $dst_image = imagecreatetruecolor($dst_width, $dst_height); + } + else { + + $src_ratio = $src_width / $src_height; + $resize_ratio = $resize_width / $resize_height; + + if ($src_ratio <= $resize_ratio) { + $dst_width = $resize_width; + $dst_height = floor($src_height * ($resize_width / $src_width)); + + $dst_y = ($dst_height - $resize_height) / 2 * (-1); + } + else { + $dst_width = floor($src_width * ($resize_height / $src_height)); + $dst_height = $resize_height; + + $dst_x = ($dst_width - $resize_width) / 2 * (-1); + } + + $dst_image = imagecreatetruecolor($resize_width, $resize_height); + } + + switch ($metadata['mime']) { + case 'image/jpeg': + case 'image/jpg': + $src_image = imagecreatefromjpeg($src_file); + break; + case 'image/png': + $src_image = imagecreatefrompng($src_file); + break; + case 'image/gif': + $src_image = imagecreatefromgif($src_file); + break; + default: + return; + } + + imagecopyresampled($dst_image, $src_image, $dst_x, $dst_y, 0, 0, $dst_width, $dst_height, $src_width, $src_height); + imagejpeg($dst_image, $dst_file); + imagedestroy($dst_image); + } } diff --git a/app/Model/File.php b/app/Model/File.php index f884e460..7adab42b 100644 --- a/app/Model/File.php +++ b/app/Model/File.php @@ -3,6 +3,8 @@ namespace Model; use Event\FileEvent; +use Core\Tool; +use Core\ObjectStorage\ObjectStorageException; /** * File model @@ -47,14 +49,17 @@ class File extends Base */ public function remove($file_id) { - $file = $this->getbyId($file_id); + try { - if (! empty($file)) { - @unlink(FILES_DIR.$file['path']); - return $this->db->table(self::TABLE)->eq('id', $file_id)->remove(); - } + $file = $this->getbyId($file_id); + $this->objectStorage->remove($file['path']); - return false; + return $this->db->table(self::TABLE)->eq('id', $file['id'])->remove(); + } + catch (ObjectStorageException $e) { + $this->logger->error($e->getMessage()); + return false; + } } /** @@ -66,11 +71,11 @@ class File extends Base */ public function removeAll($task_id) { - $files = $this->getAll($task_id); + $file_ids = $this->db->table(self::TABLE)->eq('task_id', $task_id)->asc('id')->findAllByColumn('id'); $results = array(); - foreach ($files as $file) { - $results[] = $this->remove($file['id']); + foreach ($file_ids as $file_id) { + $results[] = $this->remove($file_id); } return ! in_array(false, $results, true); @@ -196,6 +201,30 @@ class File extends Base } /** + * Return the image mimetype based on the file extension + * + * @access public + * @param $filename + * @return string + */ + public function getImageMimeType($filename) + { + $extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); + + switch ($extension) { + case 'jpeg': + case 'jpg': + return 'image/jpeg'; + case 'png': + return 'image/png'; + case 'gif': + return 'image/gif'; + default: + return 'image/jpeg'; + } + } + + /** * Generate the path for a new filename * * @access public @@ -210,6 +239,18 @@ class File extends Base } /** + * Generate the path for a thumbnails + * + * @access public + * @param string $key Storage key + * @return string + */ + public function getThumbnailPath($key) + { + return 'thumbnails'.DIRECTORY_SEPARATOR.$key; + } + + /** * Handle file upload * * @access public @@ -218,11 +259,13 @@ class File extends Base * @param string $form_name File form name * @return bool */ - public function upload($project_id, $task_id, $form_name) + public function uploadFiles($project_id, $task_id, $form_name) { - $results = array(); + try { - if (! empty($_FILES[$form_name])) { + if (empty($_FILES[$form_name])) { + return false; + } foreach ($_FILES[$form_name]['error'] as $key => $error) { @@ -232,22 +275,27 @@ class File extends Base $uploaded_filename = $_FILES[$form_name]['tmp_name'][$key]; $destination_filename = $this->generatePath($project_id, $task_id, $original_filename); - @mkdir(FILES_DIR.dirname($destination_filename), 0755, true); + if ($this->isImage($original_filename)) { + $this->generateThumbnailFromFile($uploaded_filename, $destination_filename); + } - if (@move_uploaded_file($uploaded_filename, FILES_DIR.$destination_filename)) { + $this->objectStorage->moveUploadedFile($uploaded_filename, $destination_filename); - $results[] = $this->create( - $task_id, - $original_filename, - $destination_filename, - $_FILES[$form_name]['size'][$key] - ); - } + $this->create( + $task_id, + $original_filename, + $destination_filename, + $_FILES[$form_name]['size'][$key] + ); } } - } - return ! in_array(false, $results, true); + return true; + } + catch (ObjectStorageException $e) { + $this->logger->error($e->getMessage()); + return false; + } } /** @@ -261,129 +309,77 @@ class File extends Base */ public function uploadScreenshot($project_id, $task_id, $blob) { - $data = base64_decode($blob); - - if (empty($data)) { - return false; - } - $original_filename = e('Screenshot taken %s', dt('%B %e, %Y at %k:%M %p', time())).'.png'; - $destination_filename = $this->generatePath($project_id, $task_id, $original_filename); - - @mkdir(FILES_DIR.dirname($destination_filename), 0755, true); - @file_put_contents(FILES_DIR.$destination_filename, $data); - - return $this->create( - $task_id, - $original_filename, - $destination_filename, - strlen($data) - ); + return $this->uploadContent($project_id, $task_id, $original_filename, $blob); } /** * Handle file upload (base64 encoded content) * * @access public - * @param integer $project_id Project id - * @param integer $task_id Task id - * @param string $filename Filename - * @param string $blob Base64 encoded image + * @param integer $project_id Project id + * @param integer $task_id Task id + * @param string $original_filename Filename + * @param string $blob Base64 encoded file * @return bool|integer */ - public function uploadContent($project_id, $task_id, $filename, $blob) + public function uploadContent($project_id, $task_id, $original_filename, $blob) { - $data = base64_decode($blob); + try { - if (empty($data)) { - return false; - } + $data = base64_decode($blob); - $destination_filename = $this->generatePath($project_id, $task_id, $filename); + if (empty($data)) { + return false; + } + + $destination_filename = $this->generatePath($project_id, $task_id, $original_filename); + $this->objectStorage->put($destination_filename, $data); - @mkdir(FILES_DIR.dirname($destination_filename), 0755, true); - @file_put_contents(FILES_DIR.$destination_filename, $data); + if ($this->isImage($original_filename)) { + $this->generateThumbnailFromData($destination_filename, $data); + } - return $this->create( - $task_id, - $filename, - $destination_filename, - strlen($data) - ); + return $this->create( + $task_id, + $original_filename, + $destination_filename, + strlen($data) + ); + } + catch (ObjectStorageException $e) { + $this->logger->error($e->getMessage()); + return false; + } } /** - * Generate a jpeg thumbnail from an image (output directly the image) + * Generate thumbnail from a blob * * @access public - * @param string $filename Source image - * @param integer $resize_width Desired image width - * @param integer $resize_height Desired image height + * @param string $destination_filename + * @param string $data */ - public function generateThumbnail($filename, $resize_width, $resize_height) + public function generateThumbnailFromData($destination_filename, &$data) { - $metadata = getimagesize($filename); - $src_width = $metadata[0]; - $src_height = $metadata[1]; - $dst_y = 0; - $dst_x = 0; - - if (empty($metadata['mime'])) { - return; - } - - if ($resize_width == 0 && $resize_height == 0) { - $resize_width = 100; - $resize_height = 100; - } + $temp_filename = tempnam(sys_get_temp_dir(), 'datafile'); - if ($resize_width > 0 && $resize_height == 0) { - $dst_width = $resize_width; - $dst_height = floor($src_height * ($resize_width / $src_width)); - $dst_image = imagecreatetruecolor($dst_width, $dst_height); - } - elseif ($resize_width == 0 && $resize_height > 0) { - $dst_width = floor($src_width * ($resize_height / $src_height)); - $dst_height = $resize_height; - $dst_image = imagecreatetruecolor($dst_width, $dst_height); - } - else { - - $src_ratio = $src_width / $src_height; - $resize_ratio = $resize_width / $resize_height; - - if ($src_ratio <= $resize_ratio) { - $dst_width = $resize_width; - $dst_height = floor($src_height * ($resize_width / $src_width)); - - $dst_y = ($dst_height - $resize_height) / 2 * (-1); - } - else { - $dst_width = floor($src_width * ($resize_height / $src_height)); - $dst_height = $resize_height; - - $dst_x = ($dst_width - $resize_width) / 2 * (-1); - } - - $dst_image = imagecreatetruecolor($resize_width, $resize_height); - } - - switch ($metadata['mime']) { - case 'image/jpeg': - case 'image/jpg': - $src_image = imagecreatefromjpeg($filename); - break; - case 'image/png': - $src_image = imagecreatefrompng($filename); - break; - case 'image/gif': - $src_image = imagecreatefromgif($filename); - break; - default: - return; - } + file_put_contents($temp_filename, $data); + $this->generateThumbnailFromFile($temp_filename, $destination_filename); + unlink($temp_filename); + } - imagecopyresampled($dst_image, $src_image, $dst_x, $dst_y, 0, 0, $dst_width, $dst_height, $src_width, $src_height); - imagejpeg($dst_image); + /** + * Generate thumbnail from a blob + * + * @access public + * @param string $uploaded_filename + * @param string $destination_filename + */ + public function generateThumbnailFromFile($uploaded_filename, $destination_filename) + { + $thumbnail_filename = tempnam(sys_get_temp_dir(), 'thumbnail'); + Tool::generateThumbnail($uploaded_filename, $thumbnail_filename); + $this->objectStorage->moveFile($thumbnail_filename, $this->getThumbnailPath($destination_filename)); } } diff --git a/app/ServiceProvider/ClassProvider.php b/app/ServiceProvider/ClassProvider.php index 630ab9f3..a5677948 100644 --- a/app/ServiceProvider/ClassProvider.php +++ b/app/ServiceProvider/ClassProvider.php @@ -2,6 +2,7 @@ namespace ServiceProvider; +use Core\ObjectStorage\FileStorage; use Core\Paginator; use Core\OAuth2; use Core\Tool; @@ -106,5 +107,9 @@ class ClassProvider implements ServiceProviderInterface $container['htmlConverter'] = function($c) { return new HtmlConverter(array('strip_tags' => true)); }; + + $container['objectStorage'] = function($c) { + return new FileStorage(FILES_DIR); + }; } } diff --git a/app/Template/file/show.php b/app/Template/file/show.php index b1a0a813..a390c9fb 100644 --- a/app/Template/file/show.php +++ b/app/Template/file/show.php @@ -11,7 +11,7 @@ <li> <?php if (function_exists('imagecreatetruecolor')): ?> <div class="img_container"> - <img src="<?= $this->url->href('file', 'thumbnail', array('width' => 250, 'height' => 100, 'file_id' => $file['id'], 'project_id' => $task['project_id'], 'task_id' => $file['task_id'])) ?>" alt="<?= $this->e($file['name']) ?>"/> + <img src="<?= $this->url->href('file', 'thumbnail', array('file_id' => $file['id'], 'project_id' => $task['project_id'], 'task_id' => $file['task_id'])) ?>" alt="<?= $this->e($file['name']) ?>"/> </div> <?php endif ?> <p> diff --git a/tests/units/Model/FileTest.php b/tests/units/Model/FileTest.php index da00917d..e7520c89 100644 --- a/tests/units/Model/FileTest.php +++ b/tests/units/Model/FileTest.php @@ -9,6 +9,17 @@ use Model\Project; class FileTest extends Base { + public function setUp() + { + parent::setUp(); + + $this->container['objectStorage'] = $this + ->getMockBuilder('\Core\ObjectStorage\FileStorage') + ->setConstructorArgs(array($this->container)) + ->setMethods(array('put', 'moveFile', 'remove')) + ->getMock(); + } + public function testCreation() { $p = new Project($this->container); @@ -85,13 +96,32 @@ class FileTest extends Base public function testUploadScreenshot() { $p = new Project($this->container); - $f = new File($this->container); $tc = new TaskCreation($this->container); $this->assertEquals(1, $p->create(array('name' => 'test'))); $this->assertEquals(1, $tc->create(array('project_id' => 1, 'title' => 'test'))); - $this->assertEquals(1, $f->uploadScreenshot(1, 1, base64_encode('image data'))); + $data = base64_encode('image data'); + + $f = $this + ->getMockBuilder('\Model\File') + ->setConstructorArgs(array($this->container)) + ->setMethods(array('generateThumbnailFromData')) + ->getMock(); + + $this->container['objectStorage'] + ->expects($this->once()) + ->method('put') + ->with( + $this->stringContains('1/1/'), + $this->equalTo(base64_decode($data)) + ) + ->will($this->returnValue(true)); + + $f->expects($this->once()) + ->method('generateThumbnailFromData'); + + $this->assertEquals(1, $f->uploadScreenshot(1, 1, $data)); $file = $f->getById(1); $this->assertNotEmpty($file); @@ -113,7 +143,18 @@ class FileTest extends Base $this->assertEquals(1, $p->create(array('name' => 'test'))); $this->assertEquals(1, $tc->create(array('project_id' => 1, 'title' => 'test'))); - $this->assertEquals(1, $f->uploadContent(1, 1, 'my file.pdf', base64_encode('file data'))); + $data = base64_encode('file data'); + + $this->container['objectStorage'] + ->expects($this->once()) + ->method('put') + ->with( + $this->stringContains('1/1/'), + $this->equalTo(base64_decode($data)) + ) + ->will($this->returnValue(true)); + + $this->assertEquals(1, $f->uploadContent(1, 1, 'my file.pdf', $data)); $file = $f->getById(1); $this->assertNotEmpty($file); @@ -170,9 +211,33 @@ class FileTest extends Base $this->assertEquals(1, $p->create(array('name' => 'test'))); $this->assertEquals(1, $tc->create(array('project_id' => 1, 'title' => 'test'))); - $this->assertEquals(1, $f->create(1, 'B.pdf', '/tmp/foo', 10)); - $this->assertEquals(2, $f->create(1, 'A.png', '/tmp/foo', 10)); - $this->assertEquals(3, $f->create(1, 'D.doc', '/tmp/foo', 10)); + $this->assertEquals(1, $f->create(1, 'B.pdf', '/tmp/foo1', 10)); + $this->assertEquals(2, $f->create(1, 'A.png', '/tmp/foo2', 10)); + $this->assertEquals(3, $f->create(1, 'D.doc', '/tmp/foo3', 10)); + + $this->container['objectStorage'] + ->expects($this->at(0)) + ->method('remove') + ->with( + $this->equalTo('/tmp/foo2') + ) + ->will($this->returnValue(true)); + + $this->container['objectStorage'] + ->expects($this->at(1)) + ->method('remove') + ->with( + $this->equalTo('/tmp/foo1') + ) + ->will($this->returnValue(true)); + + $this->container['objectStorage'] + ->expects($this->at(2)) + ->method('remove') + ->with( + $this->equalTo('/tmp/foo3') + ) + ->will($this->returnValue(true)); $this->assertTrue($f->remove(2)); |