diff --git a/composer.json b/composer.json index 4210b54..42c75cb 100644 --- a/composer.json +++ b/composer.json @@ -4,9 +4,11 @@ "type": "cakephp-plugin", "require": { "php": ">=5.6", + "ext-imagick": "*", "cakephp/cakephp": ">=3.4 <4.0.0", "admad/cakephp-sequence": "^2.0", - "gumlet/php-image-resize": "^1.9" + "gumlet/php-image-resize": "^2.0", + "aws/aws-sdk-php": ">=3.314 <3.368" }, "require-dev": { "phpunit/phpunit": "*" diff --git a/config/Migrations/20170126125021_AttachmentsInitial.php b/config/Migrations/20170126125021_AttachmentsInitial.php index e7445ac..211cae7 100644 --- a/config/Migrations/20170126125021_AttachmentsInitial.php +++ b/config/Migrations/20170126125021_AttachmentsInitial.php @@ -52,6 +52,6 @@ public function up() public function down() { - $this->dropTable('attachments'); + $this->table('attachments')->drop()->save(); } } diff --git a/config/Migrations/20251013171500_AddSequenceIndexToAttachments.php b/config/Migrations/20251013171500_AddSequenceIndexToAttachments.php new file mode 100644 index 0000000..a436d0d --- /dev/null +++ b/config/Migrations/20251013171500_AddSequenceIndexToAttachments.php @@ -0,0 +1,19 @@ +table('attachments'); + $table->addIndex(['model', 'foreign_key', 'sequence'], ['name' => 'model_foreign_key_sequence']); + $table->update(); + } +} diff --git a/config/attachments.php b/config/attachments.php index a1d4736..d7ea701 100755 --- a/config/attachments.php +++ b/config/attachments.php @@ -13,7 +13,12 @@ $config = [ 'Attachment' => [ - 'path' => '/tmp/filestorage' + 'path' => '/tmp/filestorage', + 's3-endpoint' => false, + 's3-region' => '', + 's3-key' => '', + 's3-secret' => '', + 's3-bucket' => '', ] ]; diff --git a/src/Controller/AttachmentsController.php b/src/Controller/AttachmentsController.php index 58c720e..2839583 100644 --- a/src/Controller/AttachmentsController.php +++ b/src/Controller/AttachmentsController.php @@ -115,6 +115,37 @@ public function image($id) $options = []; foreach ($validOptions as $option) { if ($this->request->getQuery($option)) { + //validate quality + if ($option == 'q' && ( + !is_numeric($this->request->getQuery($option)) || + $this->request->getQuery($option) > 100) || + $this->request->getQuery($option) < 0 + ) { + throw new \Exception("Invalid quality parameter."); + } + //validate height and width + if (($option == 'w' || $option == 'h') && ( + !is_numeric($this->request->getQuery($option)) || + $this->request->getQuery($option) < 0 + )) { + throw new \Exception("Invalid height/width parameter."); + } + //validate crop and enlarge + if (($option == 'c' || $option == 'e') && ( + $this->request->getQuery($option) != 0 && + $this->request->getQuery($option) != 1 + )) { + throw new \Exception("Invalid crop/enlarge parameter."); + } + //validate mode + if ($option == 'm' && $this->request->getQuery($option) != 'fill') { + throw new \Exception("Invalid mode parameter."); + } + //validate fill color + if ($option == 'fc' && !preg_match('/^[a-f0-9]{6}$/i', $this->request->getQuery($option))) { + throw new \Exception("Invalid fill color parameter."); + } + $options[$option] = $this->request->getQuery($option); } //default fill color to white elseif ($option == 'fc') { @@ -140,7 +171,6 @@ function ($v, $k) { array_keys($options) )); $cacheFile = $cacheFolder . DS . md5($id . $cacheKey); - if (!file_exists($cacheFile)) { if (!file_exists($cacheFolder)) { mkdir($cacheFolder); @@ -155,7 +185,11 @@ function ($v, $k) { if ($attachment->filetype === 'application/pdf') { $imagePath = "/tmp/" . uniqid(); $imagick = new \Imagick("{$attachment->path}[0]"); + $imagick->setResolution(300, 300); + $imagick->setBackgroundColor('white'); $imagick->setImageFormat('jpg'); + $imagick->mergeImageLayers(\Imagick::LAYERMETHOD_FLATTEN); + $imagick->setImageAlphaChannel(\Imagick::ALPHACHANNEL_REMOVE); file_put_contents($imagePath, $imagick); } $image = new ImageResize($imagePath); @@ -166,7 +200,7 @@ function ($v, $k) { $image->save($tempImage, IMAGETYPE_JPEG); $image = new ImageResize($imagePath); - $image->resize($options['w'], $options['h']); + $image->resize($options['w'], $options['h'], true); $image->addFilter(function ($imageDesc) use ($options, $tempImage) { list($r, $g, $b) = sscanf($options['fc'], "%02x%02x%02x"); $backgroundColor = imagecolorallocate($imageDesc, $r, $g, $b); @@ -202,6 +236,10 @@ function ($v, $k) { //preserve PNG for transparency if ($attachment->filetype == 'image/png' && $options['type'] != IMAGETYPE_WEBP) { $options['type'] = IMAGETYPE_PNG; + //modify quality imagejpeg to imagepng + if (!is_null($options['q'])) { + $options['q'] = (int) round((100 - $options['q']) / 10); + } } $image->save($cacheFile, $options['type'], $options['q']); } @@ -211,10 +249,17 @@ function ($v, $k) { $file = new File($cacheFile); $response = $this->response->withFile($cacheFile, ['download' => false, 'name' => (isset($attachment) ? $attachment->filename : null)]) + ->withVary('Accept') ->withType($file->mime()) - ->withCache('-1 minute', '+1 month') - ->withExpires('+1 month') + ->withCache('-1 minute', '+6 month') + ->withExpires('+6 month') + ->withMustRevalidate(false) ->withModified($file->lastChange()); + + if ($options['type'] == IMAGETYPE_WEBP) { + $response = $response->withSharable(false); + } + if ($response->checkNotModified($this->request)) { return $response; } @@ -228,8 +273,21 @@ public function file($id, $name = null) if (!file_exists($attachment->path)) { throw new \Exception("File {$attachment->path} cannot be read."); } - $response = $this->response->withType($attachment->filetype) - ->withFile($attachment->path, ['download' => false, 'name' => $attachment->filename]); + $file = new File($attachment->filetype); + + $response = $this->response->withFile($attachment->path, + ['download' => false, 'name' => $attachment->filename]) + ->withType($attachment->filetype) + ->withCache('-1 minute', '+6 month') + ->withExpires('+6 month') + ->withMustRevalidate(false) + ->withModified($file->lastChange()) + ->withSharable(true); + + if ($response->checkNotModified($this->request)) { + return $response; + } + return $response; } @@ -239,8 +297,20 @@ public function download($id, $name = null) if (!file_exists($attachment->path)) { throw new \Exception("File {$attachment->path} cannot be read."); } - $response = $this->response->withType($attachment->filetype) - ->withFile($attachment->path, ['download' => true, 'name' => $attachment->filename]); + $file = new File($attachment->filetype); + + $response = $this->response->withFile($attachment->path, + ['download' => true, 'name' => $attachment->filename]) + ->withType($attachment->filetype) + ->withCache('-1 minute', '+6 month') + ->withExpires('+6 month') + ->withMustRevalidate(false) + ->withModified($file->lastChange()); + + if ($response->checkNotModified($this->request)) { + return $response; + } + return $response; } @@ -314,13 +384,13 @@ public function stream($id, $name = null) ob_get_clean(); header("Content-Type: video/mp4"); header("Cache-Control: max-age=311040000, public"); - header("Expires: ".gmdate('D, d M Y H:i:s', time()+311040000) . ' GMT'); - header("Last-Modified: ".gmdate('D, d M Y H:i:s', @filemtime($attachment->path)) . ' GMT' ); + header("Expires: " . gmdate('D, d M Y H:i:s', time() + 311040000) . ' GMT'); + header("Last-Modified: " . gmdate('D, d M Y H:i:s', @filemtime($attachment->path)) . ' GMT'); $this->start = 0; - $this->size = filesize($attachment->path); - $this->end = $this->size - 1; + $this->size = filesize($attachment->path); + $this->end = $this->size - 1; - header("Accept-Ranges: 0-".$this->end); + header("Accept-Ranges: 0-" . $this->end); //set header if (isset($_SERVER['HTTP_RANGE'])) { @@ -335,7 +405,7 @@ public function stream($id, $name = null) } if ($range == '-') { $c_start = $this->size - substr($range, 1); - }else{ + } else { $range = explode('-', $range); $c_start = $range[0]; $c_end = (isset($range[1]) && is_numeric($range[1])) ? $range[1] : $c_end; @@ -351,19 +421,17 @@ public function stream($id, $name = null) $length = $this->end - $this->start + 1; fseek($this->stream, $this->start); header('HTTP/1.1 206 Partial Content'); - header("Content-Length: ".$length); - header("Content-Range: bytes $this->start-$this->end/".$this->size); - } - else - { - header("Content-Length: ".$this->size); + header("Content-Length: " . $length); + header("Content-Range: bytes $this->start-$this->end/" . $this->size); + } else { + header("Content-Length: " . $this->size); } //stream $i = $this->start; set_time_limit(0); - while(!feof($this->stream) && $i <= $this->end) { + while (!feof($this->stream) && $i <= $this->end) { $bytesToRead = $this->buffer; - if(($i+$bytesToRead) > $this->end) { + if (($i + $bytesToRead) > $this->end) { $bytesToRead = $this->end - $i + 1; } $data = @stream_get_contents($this->stream, $bytesToRead, intval($i)); @@ -389,14 +457,13 @@ public function editImage($id) if ($this->Attachments->replaceFile($id, $tempPath)) { $this->Flash->success(__('Image modified.')); $redirectTo = $this->getRequest()->getSession()->consume('Attachment.redirectAfter'); - if($redirectTo) { + if ($redirectTo) { return $this->redirect($redirectTo); } } else { $this->Flash->error(__('Image could not be saved. Please, try again.')); } - } - else { + } else { $this->getRequest()->getSession()->write('Attachment.redirectAfter', $this->referer()); } $this->set('image', $image); diff --git a/src/Model/Behavior/AttachmentsBehavior.php b/src/Model/Behavior/AttachmentsBehavior.php index aef499e..7c83a3a 100644 --- a/src/Model/Behavior/AttachmentsBehavior.php +++ b/src/Model/Behavior/AttachmentsBehavior.php @@ -189,7 +189,7 @@ protected function _clearTag($attachment, $tag) if ($existingTag === $tag) { unset($attachmentWithExclusiveTag->tags[$key]); $attachmentWithExclusiveTag->tags = array_values($attachmentWithExclusiveTag->tags); - $attachmentWithExclusiveTag->dirty('tags', true); + $attachmentWithExclusiveTag->setDirty('tags'); break; } } diff --git a/src/Model/Entity/Attachment.php b/src/Model/Entity/Attachment.php index 73f75f1..42a7a63 100644 --- a/src/Model/Entity/Attachment.php +++ b/src/Model/Entity/Attachment.php @@ -1,4 +1,5 @@ false, ]; - protected $_virtual = ['details_array','readable_size','readable_created']; + protected $_virtual = ['details_array', 'readable_size', 'readable_created']; protected function _getPath() { - $targetDir = Configure::read('Attachment.path').DS.substr($this->_properties['md5'],0,2); - $folder = new Folder(); - if (!$folder->create($targetDir)) { - throw new \Exception("Folder {$targetDir} could not be created."); - } + $targetDir = Configure::read('Attachment.path') . DS . substr($this->_properties['md5'], 0, 2); + + $filePath = $targetDir . DS . $this->_properties['md5']; - return $targetDir.DS.$this->_properties['md5']; + $folder = new Folder(); + if (!file_exists($filePath) && !$folder->create($targetDir)) { + throw new \Exception("Folder {$targetDir} could not be created."); + } + + if (!file_exists($filePath) && Configure::read('Attachment.s3-endpoint') && is_null($this->tmpPath)) { + $config = + [ + 'version' => 'latest', + 'region' => Configure::read('Attachment.s3-region'), + 'endpoint' => Configure::read('Attachment.s3-endpoint'), + 'credentials' => + [ + 'key' => Configure::read('Attachment.s3-key'), + 'secret' => Configure::read('Attachment.s3-secret'), + ], + ]; + $s3client = new \Aws\S3\S3Client($config); + try { + $s3client->getObject( + [ + 'Bucket' => Configure::read('Attachment.s3-bucket'), + 'Key' => $this->s3_path, + 'SaveAs' => $filePath, + ] + ); + } catch (\Exception $e) { + return false; + } + } + + return $filePath; } protected function _getReadableSize() @@ -66,4 +103,36 @@ protected function _getExtension() $pathinfo = pathinfo($this->filename); return $pathinfo['extension']; } + + //s3 path + protected function _getS3Path() + { + return substr($this->_properties['md5'], 0, 2) . '/' . $this->_properties['md5']; + } + + protected function _getS3Attributes() + { + if (Configure::read('Attachment.s3-endpoint')) { + $config = + [ + 'version' => 'latest', + 'region' => Configure::read('Attachment.s3-region'), + 'endpoint' => Configure::read('Attachment.s3-endpoint'), + 'credentials' => + [ + 'key' => Configure::read('Attachment.s3-key'), + 'secret' => Configure::read('Attachment.s3-secret'), + ], + ]; + $s3client = new \Aws\S3\S3Client($config); + try { + return $s3client->getObjectAttributes( + Configure::read('Attachment.s3-bucket'), + $this->s3_path + ); + } catch (\Exception $e) { + return false; + } + } + } } diff --git a/src/Model/Table/AttachmentsTable.php b/src/Model/Table/AttachmentsTable.php index 822bd3b..d8533a1 100644 --- a/src/Model/Table/AttachmentsTable.php +++ b/src/Model/Table/AttachmentsTable.php @@ -2,6 +2,7 @@ namespace Uskur\Attachments\Model\Table; +use Cake\Core\Configure; use Uskur\Attachments\Model\Entity\Attachment; use ArrayObject; use Cake\ORM\Entity; @@ -22,6 +23,9 @@ class AttachmentsTable extends Table { + protected $s3client = false; + protected $s3bucket = false; + /** * Initialize method * @@ -42,6 +46,35 @@ public function initialize(array $config) 'scope' => ['model', 'foreign_key'], 'start' => 1, ]); + + $this->belongsTo('ParentAttachment', [ + 'className' => 'Uskur/Attachments.Attachments', + 'foreignKey' => 'foreign_key', + 'conditions' => ['SubAttachments.model' => 'Attachments'], + 'joinType' => 'LEFT', + ]); + $this->hasMany('SubAttachments', [ + 'className' => 'Uskur/Attachments.Attachments', + 'foreignKey' => 'foreign_key', + 'conditions' => ['SubAttachments.model' => 'Attachments'], + 'dependent' => true, + ]); + + if (Configure::read('Attachment.s3-endpoint')) { + $config = + [ + 'version' => 'latest', + 'region' => Configure::read('Attachment.s3-region'), + 'endpoint' => Configure::read('Attachment.s3-endpoint'), + 'credentials' => + [ + 'key' => Configure::read('Attachment.s3-key'), + 'secret' => Configure::read('Attachment.s3-secret'), + ], + ]; + $this->s3client = new \Aws\S3\S3Client($config); + $this->s3bucket = Configure::read('Attachment.s3-bucket'); + } } /** @@ -50,11 +83,11 @@ public function initialize(array $config) * @param \Cake\Validation\Validator $validator Validator instance. * @return \Cake\Validation\Validator */ - public function validationDefault(Validator $validator) + public function validationDefault(Validator $validator): Validator { $validator ->uuid('id') - ->allowEmptyString('id', 'create'); + ->allowEmptyString('id', null, 'create'); $validator ->allowEmptyString('filename'); @@ -84,16 +117,20 @@ public function buildRules(RulesChecker $rules) } /** - * Save one Attachemnt + * Save one Attachment * * @param EntityInterface $entity Entity * @param string $upload Upload - * @return boolean + * @param array $allowed_types Allowed types + * @param array $details Details + * @return bool|EntityInterface + * @throws \Exception */ public function addUpload($entity, $upload, $allowed_types = [], $details = []) { - if (!empty($allowed_types) && !in_array($upload['type'], $allowed_types)) + if (!empty($allowed_types) && !in_array($upload['type'], $allowed_types)) { throw new \Exception("File type not allowed."); + } if (!file_exists($upload['tmp_name'])) { throw new \Exception("File {$upload['tmp_name']} does not exist."); } @@ -103,28 +140,31 @@ public function addUpload($entity, $upload, $allowed_types = [], $details = []) $file = new File($upload['tmp_name']); $info = $file->info(); $attachment = $this->newEntity([ - 'model' => $entity->source(), + 'model' => $entity->getSource(), 'foreign_key' => $entity->id, 'filename' => $upload['name'], 'size' => $info['filesize'], 'filetype' => $info['mime'], 'md5' => $file->md5(true), - 'tmpPath' => $upload['tmp_name'] + 'tmpPath' => $upload['tmp_name'], ]); - if ($details) + if ($details) { $attachment->details = json_encode($details); + } // if the same thing return existing - $existing = $this->find('all') + $existing = $this->find() ->where([ 'filename' => $attachment->filename, 'model' => $attachment->model, 'foreign_key' => $attachment->foreign_key, 'md5' => $attachment->md5, 'details' => $attachment->details])->first(); - if ($existing) return $existing; - + if ($existing) { + return $existing; + } $save = $this->save($attachment); + return ($save) ? true : false; } @@ -139,23 +179,26 @@ public function addFile($entity, $filePath, $details = []) 'size' => $info['filesize'], 'filetype' => $info['mime'], 'md5' => $file->md5(true), - 'tmpPath' => $filePath + 'tmpPath' => $filePath, ]); - if ($details) + if ($details) { $attachment->details = json_encode($details); + } // if the same thing return existing - $existing = $this->find('all') + $existing = $this->find() ->where([ 'filename' => $attachment->filename, 'model' => $attachment->model, 'foreign_key' => $attachment->foreign_key, 'md5' => $attachment->md5, 'details' => $attachment->details])->first(); - if ($existing) return $existing; - + if ($existing) { + return $existing; + } $save = $this->save($attachment); + return ($save) ? $attachment : false; } @@ -173,7 +216,7 @@ public function afterSave(Event $event, Attachment $attachment, \ArrayObject $op { if ($attachment->tmpPath) { $path = $attachment->get('path'); - if (is_uploaded_file($attachment->tmpPath)) { + if (is_uploaded_file($attachment->tmpPath) && file_exists($attachment->tmpPath)) { if (!move_uploaded_file($attachment->tmpPath, $path)) { throw new \Exception("Temporary file {$attachment->tmpPath} could not be moved to {$attachment->path}"); } @@ -182,6 +225,17 @@ public function afterSave(Event $event, Attachment $attachment, \ArrayObject $op throw new \Exception("File {$attachment->tmpPath} could not be copied to {$attachment->path}"); } } + if ($this->s3bucket !== false) { + try { + $result = $this->s3client->putObject([ + 'Bucket' => $this->s3bucket, + 'Key' => $attachment->s3_path, + 'SourceFile' => $attachment->path, + ]); + } catch (\Exception $e) { + throw new \Exception("File {$attachment->tmpPath} could not be moved to S3 bucket"); + } + } $attachment->tmpPath = null; } } @@ -189,9 +243,17 @@ public function afterSave(Event $event, Attachment $attachment, \ArrayObject $op public function afterDelete(Event $event, Attachment $attachment, \ArrayObject $options) { if (file_exists($attachment->get('path'))) { - $otherExisting = $this->find('all', ['conditions' => ['Attachments.md5' => $attachment->md5]])->count(); + $otherExisting = $this->find()->where(['Attachments.md5' => $attachment->md5])->count(); if ($otherExisting == 0) { - unlink($attachment->get('path')); + if ($this->s3bucket !== false) { + $this->s3client->deleteObject([ + 'Bucket' => $this->s3bucket, + 'Key' => $attachment->s3_path, + ]); + } + if (file_exists($attachment->get('path'))) { + unlink($attachment->get('path')); + } } } } @@ -201,20 +263,21 @@ public function getAttachmentsOfArticle($articleId, $type = 'image') $attachments = $this->find('all', [ 'conditions' => [ 'Attachments.article_id' => $articleId, - 'Attachments.filetype LIKE' => "$type/%" + 'Attachments.filetype LIKE' => "$type/%", ], - 'contain' => [] + 'contain' => [], ]); + return $attachments; } /** * Replace file - * @param $id - * @param $path - * @return bool + * @param string $id Attachment ID + * @param string $tmpPath Path to the new file + * @return bool|Attachment */ - public function replaceFile($id, $tmpPath) + public function replaceFile(string $id, string $tmpPath) { $currentAttachment = $this->get($id); $this->delete($currentAttachment); @@ -227,9 +290,64 @@ public function replaceFile($id, $tmpPath) 'size' => $file->size(), 'filetype' => $file->mime(), 'md5' => $file->md5(true), - 'tmpPath' => $tmpPath + 'tmpPath' => $tmpPath, ]); return $this->save($attachment) ? $attachment : false; } + + /** + * Move files to S3 storage. + * + * @param int $limit The maximum number of files to move. + * @return void + * @throws \Exception If the S3 bucket is not configured or a file cannot be moved. + */ + public function moveFilesToS3($limit = 100) + { + if ($this->s3bucket == false) { + throw new \Exception("S3 bucket not configured"); + } + $moved = 0; + $attachments = $this->find(); + foreach ($attachments as $attachment) { + if ($moved >= $limit) { + break; + } + if ($attachment->path && file_exists($attachment->path)) { + if (!$this->s3client->doesObjectExistV2($this->s3bucket, $attachment->s3_path)) { + try { + $result = $this->s3client->putObject([ + 'Bucket' => $this->s3bucket, + 'Key' => $attachment->s3_path, + 'SourceFile' => $attachment->path, + ]); + } catch (\Exception $e) { + throw new \Exception("File {$attachment->path} could not be moved to S3 bucket"); + } + $moved++; + } + } + } + } + + /** + * Copies an attachment to a new entity. + * + * @param string $id Attachment ID + * @param \Cake\Datasource\EntityInterface $entity The target entity + * @return bool|\Uskur\Attachments\Model\Entity\Attachment + */ + public function copyAttachment($id, $entity) + { + $currentAttachment = $this->get($id); + $newAttachmentData = $currentAttachment->toArray(); + unset($newAttachmentData['id']); + unset($newAttachmentData['created']); + unset($newAttachmentData['sequence']); + $newAttachmentData['model'] = $entity->getSource(); + $newAttachmentData['foreign_key'] = $entity->id; + $newAttachment = $this->newEntity($newAttachmentData); + return $this->save($newAttachment) ? $newAttachment : false; + } } diff --git a/src/Template/Attachments/edit_image.ctp b/src/Template/Attachments/edit_image.ctp index ff28fda..2ebebef 100644 --- a/src/Template/Attachments/edit_image.ctp +++ b/src/Template/Attachments/edit_image.ctp @@ -21,8 +21,10 @@ $this->start('context-menu'); ?>