diff --git a/application/Espo/Controllers/Attachment.php b/application/Espo/Controllers/Attachment.php index 2ef614f234..c7a52e19ad 100644 --- a/application/Espo/Controllers/Attachment.php +++ b/application/Espo/Controllers/Attachment.php @@ -34,16 +34,5 @@ use \Espo\Core\Exceptions\BadRequest; class Attachment extends \Espo\Core\Controllers\Record { - public function postActionUpload($params, $data, $request) - { - if (!$this->getAcl()->checkScope('Attachment', 'create')) { - throw new Forbidden(); - } - $attachment = $this->getRecordService()->upload($data); - - return array( - 'attachmentId' => $attachment->id - ); - } } diff --git a/application/Espo/Core/defaults/config.php b/application/Espo/Core/defaults/config.php index 3a65bc1573..1eab2a8b14 100644 --- a/application/Espo/Core/defaults/config.php +++ b/application/Espo/Core/defaults/config.php @@ -167,6 +167,7 @@ return array ( 'currencyDecimalPlaces' => null, 'aclStrictMode' => false, 'aclAllowDeleteCreated' => false, + 'inlineAttachmentUploadMaxSize' => 20, 'isInstalled' => false ); diff --git a/application/Espo/Repositories/Attachment.php b/application/Espo/Repositories/Attachment.php index afbf968743..b4bebd2a62 100644 --- a/application/Espo/Repositories/Attachment.php +++ b/application/Espo/Repositories/Attachment.php @@ -61,7 +61,7 @@ class Attachment extends \Espo\Core\ORM\Repositories\RDB if ($entity->isNew()) { if (!$entity->has('size') && $entity->has('contents')) { - $entity->set('size', mb_strlen($entity->has('contents'))); + $entity->set('size', mb_strlen($entity->get('contents'))); } } } diff --git a/application/Espo/Resources/i18n/en_US/Admin.json b/application/Espo/Resources/i18n/en_US/Admin.json index 1907a4e8fc..f5d55923cb 100644 --- a/application/Espo/Resources/i18n/en_US/Admin.json +++ b/application/Espo/Resources/i18n/en_US/Admin.json @@ -151,7 +151,8 @@ "dynamicLogicRequired": "Conditions making field required", "dynamicLogicOptions": "Conditional options", "probabilityMap": "Stage Probabilities (%)", - "readOnly": "Read-only" + "readOnly": "Read-only", + "maxFileSize": "Max File Size (Mb)" }, "messages": { "upgradeVersion": "EspoCRM will be upgraded to version {version}. Please be patient as this may take a while.", diff --git a/application/Espo/Resources/i18n/en_US/FieldManager.json b/application/Espo/Resources/i18n/en_US/FieldManager.json index eccea3233a..2fcf7401af 100644 --- a/application/Espo/Resources/i18n/en_US/FieldManager.json +++ b/application/Espo/Resources/i18n/en_US/FieldManager.json @@ -70,7 +70,8 @@ "maxLength": "Max acceptable length of text.", "before": "The date value should be before the date value of the specified field.", "after": "The date value should be after the date value of the specified field.", - "readOnly": "Field value can't be specified by user. But can be calculated by formula." + "readOnly": "Field value can't be specified by user. But can be calculated by formula.", + "maxFileSize": "If empty or 0 then no limit." }, "fieldParts": { "address": { diff --git a/application/Espo/Resources/i18n/en_US/Global.json b/application/Espo/Resources/i18n/en_US/Global.json index 33681295e6..f737dabee1 100644 --- a/application/Espo/Resources/i18n/en_US/Global.json +++ b/application/Espo/Resources/i18n/en_US/Global.json @@ -240,6 +240,7 @@ "fieldShouldBeLess": "{field} should be less then {value}", "fieldShouldBeGreater": "{field} should be greater then {value}", "fieldBadPasswordConfirm": "{field} not confirmed properly", + "fieldMaxFileSizeError": "File should not exceed {max} Mb", "resetPreferencesDone": "Preferences has been reset to defaults", "confirmation": "Are you sure?", "unlinkAllConfirmation": "Are you sure you want to unlink all related records?", diff --git a/application/Espo/Resources/metadata/entityDefs/Attachment.json b/application/Espo/Resources/metadata/entityDefs/Attachment.json index ccefbea428..98ce96050d 100644 --- a/application/Espo/Resources/metadata/entityDefs/Attachment.json +++ b/application/Espo/Resources/metadata/entityDefs/Attachment.json @@ -26,6 +26,10 @@ "readOnly": true, "disabled": true }, + "field": { + "type": "varchar", + "disabled": true + }, "createdAt": { "type": "datetime", "readOnly": true diff --git a/application/Espo/Resources/metadata/entityDefs/Note.json b/application/Espo/Resources/metadata/entityDefs/Note.json index 4937ac415c..0f70bc9c28 100644 --- a/application/Espo/Resources/metadata/entityDefs/Note.json +++ b/application/Espo/Resources/metadata/entityDefs/Note.json @@ -24,7 +24,7 @@ "readOnly": true }, "attachments": { - "type": "linkMultiple", + "type": "attachmentMultiple", "view": "views/stream/fields/attachment-multiple" }, "number": { diff --git a/application/Espo/Resources/metadata/fields/attachmentMultiple.json b/application/Espo/Resources/metadata/fields/attachmentMultiple.json index 81a04237af..c7d78d0fc9 100644 --- a/application/Espo/Resources/metadata/fields/attachmentMultiple.json +++ b/application/Espo/Resources/metadata/fields/attachmentMultiple.json @@ -9,6 +9,12 @@ "name": "sourceList", "type": "multiEnum", "view": "views/admin/field-manager/fields/source-list" + }, + { + "name": "maxFileSize", + "type": "float", + "tooltip": true, + "min": 0 } ], "actualFields":[ diff --git a/application/Espo/Resources/metadata/fields/file.json b/application/Espo/Resources/metadata/fields/file.json index b0ef47d625..c6f2247f5c 100644 --- a/application/Espo/Resources/metadata/fields/file.json +++ b/application/Espo/Resources/metadata/fields/file.json @@ -10,6 +10,12 @@ "type": "multiEnum", "view": "views/admin/field-manager/fields/source-list" }, + { + "name": "maxFileSize", + "type": "float", + "tooltip": true, + "min": 0 + }, { "name":"audited", "type":"bool" diff --git a/application/Espo/Resources/metadata/fields/image.json b/application/Espo/Resources/metadata/fields/image.json index 5c8ea3b1a7..abf44bcfad 100644 --- a/application/Espo/Resources/metadata/fields/image.json +++ b/application/Espo/Resources/metadata/fields/image.json @@ -11,6 +11,12 @@ "default":"small", "options": ["x-small", "small", "medium", "large"] }, + { + "name": "maxFileSize", + "type": "float", + "tooltip": true, + "min": 0 + }, { "name":"audited", "type":"bool" diff --git a/application/Espo/Services/Attachment.php b/application/Espo/Services/Attachment.php index c58288707d..beba4dc524 100644 --- a/application/Espo/Services/Attachment.php +++ b/application/Espo/Services/Attachment.php @@ -31,10 +31,18 @@ namespace Espo\Services; use \Espo\ORM\Entity; +use \Espo\Core\Exceptions\BadRequest; +use \Espo\Core\Exceptions\Forbidden; +use \Espo\Core\Exceptions\Error; + class Attachment extends Record { protected $notFilteringAttributeList = ['contents']; + protected $attachmentFieldTypeList = ['file', 'image', 'attachmentMultiple']; + + protected $inlineAttachmentFieldTypeList = ['text', 'wysiwyg']; + public function upload($fileData) { if (!$this->getAcl()->checkScope('Attachment', 'create')) { @@ -59,9 +67,79 @@ class Attachment extends Record public function createEntity($data) { if (!empty($data['file'])) { - list($prefix, $contents) = explode(',', $data['file']); + $arr = explode(',', $data['file']); + $contents = ''; + if (count($arr) > 1) { + $contents = $arr[1]; + } + $contents = base64_decode($contents); $data['contents'] = $contents; + + $relatedEntityType = null; + $field = null; + $role = 'Attachment'; + if (isset($data['parentType'])) { + $relatedEntityType = $data['parentType']; + } else if (isset($data['relatedType'])) { + $relatedEntityType = $data['relatedType']; + } + if (isset($data['field'])) { + $field = $data['field']; + } + if (isset($data['role'])) { + $role = $data['role']; + } + if (!$relatedEntityType || !$field) { + throw new BadRequest("Params 'field' and 'parentType' not passed along with 'file'."); + } + + $fieldType = $this->getMetadata()->get(['entityDefs', $relatedEntityType, 'fields', $field, 'type']); + if (!$fieldType) { + throw new Error("Field '{$field}' does not exist."); + } + + if ( + !$this->getAcl()->checkScope($relatedEntityType, 'create') + && + !$this->getAcl()->checkScope($relatedEntityType, 'edit') + ) { + throw new Forbidden("No access to " . $relatedEntityType . "."); + } + + if (in_array($field, $this->getAcl()->getScopeForbiddenFieldList($relatedEntityType, 'edit'))) { + throw new Forbidden("No access to field '" . $field . "'."); + } + + $size = mb_strlen($contents, '8bit'); + + if ($role === 'Attachment') { + if (!in_array($fieldType, $this->attachmentFieldTypeList)) { + throw new Error("Field type '{$fieldType}' is not allowed for attachment."); + } + $maxSize = $this->getMetadata()->get(['entityDefs', $relatedEntityType, 'fields', $field, 'maxFileSize']); + if (!$maxSize) { + $maxSize = $this->getConfig()->get('attachmentUploadMaxSize'); + } + if ($maxSize) { + if ($size > $maxSize * 1024 * 1024) { + throw new Error("File size should not exceed {$maxSize}Mb."); + } + } + + } else if ($role === 'Inline Attachment') { + if (!in_array($fieldType, $this->inlineAttachmentFieldTypeList)) { + throw new Error("Field '{$field}' is not allowed to have inline attachment."); + } + $inlineAttachmentUploadMaxSize = $this->getConfig()->get('inlineAttachmentUploadMaxSize'); + if ($inlineAttachmentUploadMaxSize) { + if ($size > $inlineAttachmentUploadMaxSize * 1024 * 1024) { + throw new Error("File size should not exceed {$inlineAttachmentUploadMaxSize}Mb."); + } + } + } else { + throw new BadRequest("Not supported attachment role."); + } } $entity = parent::createEntity($data); diff --git a/client/src/views/fields/attachment-multiple.js b/client/src/views/fields/attachment-multiple.js index 960bdd7cb8..f857e96fd1 100644 --- a/client/src/views/fields/attachment-multiple.js +++ b/client/src/views/fields/attachment-multiple.js @@ -291,7 +291,7 @@ Espo.define('views/fields/attachment-multiple', 'views/fields/base', function (D $attachments.append($container); if (!id) { - var $loading = $('' + this.translate('Uploading...') + ''); + var $loading = $('' + this.translate('Uploading...') + ''); $container.append($loading); $att.on('ready', function () { $loading.html(this.translate('Ready')); @@ -303,22 +303,50 @@ Espo.define('views/fields/attachment-multiple', 'views/fields/base', function (D return $att; }, + showValidationMessage: function (msg, selector) { + var $label = this.$el.find('label'); + var title = $label.attr('title'); + $label.attr('title', ''); + Dep.prototype.showValidationMessage.call(this, msg, selector); + $label.attr('title', title); + }, + uploadFiles: function (files) { var uploadedCount = 0; var totalCount = 0; + var exceedsMaxFileSize = false; + var maxFileSize = this.params.maxFileSize; + if (maxFileSize) { + for (var i = 0; i < files.length; i++) { + var file = files[i]; + if (file.size > maxFileSize * 1024 * 1024) { + exceedsMaxFileSize = true; + } + } + } + if (exceedsMaxFileSize) { + var msg = this.translate('fieldMaxFileSizeError', 'messages') + .replace('{field}', this.translate(this.name, 'fields', this.model.name)) + .replace('{max}', maxFileSize); + + this.showValidationMessage(msg, 'label'); + return; + } + this.getModelFactory().create('Attachment', function (model) { var canceledList = []; var fileList = []; for (var i = 0; i < files.length; i++) { fileList.push(files[i]); + totalCount++; } fileList.forEach(function (file) { - var $att = this.addAttachmentBox(file.name, file.type); + var $attachmentBox = this.addAttachmentBox(file.name, file.type); - $att.find('.remove-attachment').on('click.uploading', function () { + $attachmentBox.find('.remove-attachment').on('click.uploading', function () { canceledList.push(attachment.cid); totalCount--; }); @@ -327,33 +355,33 @@ Espo.define('views/fields/attachment-multiple', 'views/fields/base', function (D var fileReader = new FileReader(); fileReader.onload = function (e) { - $.ajax({ - type: 'POST', - url: 'Attachment/action/upload', - data: e.target.result, - contentType: 'multipart/encrypted', - timeout: 0, - }).done(function (data) { + attachment.set('name', file.name); + attachment.set('type', file.type || 'text/plain'); + attachment.set('role', 'Attachment'); + attachment.set('size', file.size); + attachment.set('parentType', this.model.name); + attachment.set('file', e.target.result); + attachment.set('field', this.name); - attachment.id = data.attachmentId; - attachment.set('name', file.name); - attachment.set('type', file.type || 'text/plain'); - attachment.set('role', 'Attachment'); - attachment.set('size', file.size); - attachment.set('parentType', this.model.name); - - attachment.once('sync', function () { - if (canceledList.indexOf(attachment.cid) === -1) { - $att.trigger('ready'); - this.pushAttachment(attachment); - $att.attr('data-id', attachment.id); - uploadedCount++; - if (uploadedCount == totalCount) { - afterAttachmentsUploaded.call(this); - } + attachment.save().then(function () { + if (canceledList.indexOf(attachment.cid) === -1) { + $attachmentBox.trigger('ready'); + this.pushAttachment(attachment); + $attachmentBox.attr('data-id', attachment.id); + uploadedCount++; + if (uploadedCount == totalCount) { + this.afterAttachmentsUploaded.call(this); } - }, this); - attachment.save(); + } + }.bind(this)).fail(function () { + $attachmentBox.remove(); + totalCount--; + if (!totalCount) { + this.$el.find('.uploading-message').remove(); + } + if (uploadedCount == totalCount) { + this.afterAttachmentsUploaded.call(this); + } }.bind(this)); }.bind(this); fileReader.readAsDataURL(file); @@ -522,7 +550,7 @@ Espo.define('views/fields/attachment-multiple', 'views/fields/base', function (D if (this.isRequired()) { if (this.model.get(this.idsName).length == 0) { var msg = this.translate('fieldIsRequired', 'messages').replace('{field}', this.translate(this.name, 'fields', this.model.name)); - this.showValidationMessage(msg); + this.showValidationMessage(msg, 'label'); return true; } } diff --git a/client/src/views/fields/base.js b/client/src/views/fields/base.js index 93d19ca2ed..9ab85126bc 100644 --- a/client/src/views/fields/base.js +++ b/client/src/views/fields/base.js @@ -537,7 +537,7 @@ Espo.define('views/fields/base', 'view', function (Dep) { placement: 'bottom', container: 'body', content: message, - trigger: 'manual', + trigger: 'manual' }).popover('show'); $el.closest('.field').one('mousedown click', function () { diff --git a/client/src/views/fields/file.js b/client/src/views/fields/file.js index a9ef93d054..abcc081c46 100644 --- a/client/src/views/fields/file.js +++ b/client/src/views/fields/file.js @@ -107,6 +107,14 @@ Espo.define('views/fields/file', 'views/fields/link', function (Dep) { return data; }, + showValidationMessage: function (msg, selector) { + var $label = this.$el.find('label'); + var title = $label.attr('title'); + $label.attr('title', ''); + Dep.prototype.showValidationMessage.call(this, msg, selector); + $label.attr('title', title); + }, + validateRequired: function () { if (this.isRequired()) { if (this.model.get(this.idName) == null) { @@ -283,12 +291,27 @@ Espo.define('views/fields/file', 'views/fields/link', function (Dep) { uploadFile: function (file) { var isCanceled = false; + var exceedsMaxFileSize = false; + var maxFileSize = this.params.maxFileSize; + if (maxFileSize) { + if (file.size > maxFileSize * 1024 * 1024) { + exceedsMaxFileSize = true; + } + } + if (exceedsMaxFileSize) { + var msg = this.translate('fieldMaxFileSizeError', 'messages') + .replace('{field}', this.translate(this.name, 'fields', this.model.name)) + .replace('{max}', maxFileSize); + this.showValidationMessage(msg, '.attachment-button label'); + return; + } + this.getModelFactory().create('Attachment', function (attachment) { - var $att = this.addAttachmentBox(file.name, file.type); + var $attachmentBox = this.addAttachmentBox(file.name, file.type); this.$el.find('.attachment-button').addClass('hidden'); - $att.find('.remove-attachment').on('click.uploading', function () { + $attachmentBox.find('.remove-attachment').on('click.uploading', function () { isCanceled = true; this.$el.find('.attachment-button').removeClass('hidden'); }.bind(this)); @@ -296,27 +319,23 @@ Espo.define('views/fields/file', 'views/fields/link', function (Dep) { var fileReader = new FileReader(); fileReader.onload = function (e) { this.handleFileUpload(file, e.target.result, function (result, fileParams) { - $.ajax({ - type: 'POST', - url: 'Attachment/action/upload', - data: result, - contentType: 'multipart/encrypted', - timeout: 0, - }).done(function (data) { - attachment.id = data.attachmentId; - attachment.set('name', fileParams.name); - attachment.set('type', fileParams.type || 'text/plain'); - attachment.set('size', fileParams.size); - attachment.set('role', 'Attachment'); - attachment.set('relatedType', this.model.name); + attachment.set('name', fileParams.name); + attachment.set('type', fileParams.type || 'text/plain'); + attachment.set('size', fileParams.size); + attachment.set('role', 'Attachment'); + attachment.set('relatedType', this.model.name); + attachment.set('file', e.target.result); + attachment.set('field', this.name); - attachment.once('sync', function () { - if (!isCanceled) { - $att.trigger('ready'); - this.setAttachment(attachment); - } - }, this); - attachment.save(); + attachment.save().then(function () { + if (!isCanceled) { + $attachmentBox.trigger('ready'); + this.setAttachment(attachment); + } + }.bind(this)).fail(function () { + $attachmentBox.remove(); + this.$el.find('.uploading-message').remove(); + this.$el.find('.attachment-button').removeClass('hidden'); }.bind(this)); }.bind(this)); }.bind(this); @@ -358,7 +377,7 @@ Espo.define('views/fields/file', 'views/fields/link', function (Dep) { this.$attachment.append($container); if (!id) { - var $loading = $('' + this.translate('Uploading...') + ''); + var $loading = $('' + this.translate('Uploading...') + ''); $container.append($loading); $att.on('ready', function () { $loading.html(self.translate('Ready')); diff --git a/client/src/views/fields/wysiwyg.js b/client/src/views/fields/wysiwyg.js index 1235a4440d..577f240ab3 100644 --- a/client/src/views/fields/wysiwyg.js +++ b/client/src/views/fields/wysiwyg.js @@ -206,28 +206,23 @@ Espo.define('views/fields/wysiwyg', ['views/fields/text', 'lib!Summernote'], fun this.getModelFactory().create('Attachment', function (attachment) { var fileReader = new FileReader(); fileReader.onload = function (e) { - $.ajax({ - type: 'POST', - url: 'Attachment/action/upload', - data: e.target.result, - contentType: 'multipart/encrypted', - }).done(function (data) { - attachment.id = data.attachmentId; - attachment.set('name', file.name); - attachment.set('type', file.type); - attachment.set('role', 'Inline Attachment'); - attachment.set('global', true); - attachment.set('size', file.size); - attachment.once('sync', function () { - var url = '?entryPoint=attachment&id=' + attachment.id; - this.$summernote.summernote('insertImage', url); - this.notify(false); - }, this); - attachment.save(); - }.bind(this)); + attachment.set('name', file.name); + attachment.set('type', file.type); + attachment.set('role', 'Inline Attachment'); + attachment.set('global', true); + attachment.set('size', file.size); + attachment.set('relatedType', this.model.name); + attachment.set('file', e.target.result); + attachment.set('field', this.name); + + attachment.once('sync', function () { + var url = '?entryPoint=attachment&id=' + attachment.id; + this.$summernote.summernote('insertImage', url); + this.notify(false); + }, this); + attachment.save(); }.bind(this); fileReader.readAsDataURL(file); - }, this); }.bind(this), onBlur: function () {