diff --git a/application/Espo/Controllers/Attachment.php b/application/Espo/Controllers/Attachment.php index 8c2db6b1f5..4c0b815016 100644 --- a/application/Espo/Controllers/Attachment.php +++ b/application/Espo/Controllers/Attachment.php @@ -29,16 +29,17 @@ namespace Espo\Controllers; -use Espo\Services\Attachment as Service; - -use Espo\Core\{ - Exceptions\Forbidden, - Exceptions\BadRequest, - Api\Request, - Api\Response, - Controllers\RecordBase, -}; - +use Espo\Core\Exceptions\Error; +use Espo\Core\Exceptions\NotFound; +use Espo\Core\Api\Request; +use Espo\Core\Api\Response; +use Espo\Core\Controllers\RecordBase; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Forbidden; +use Espo\Tools\Attachment\FieldData; +use Espo\Tools\Attachment\Service; +use Espo\Tools\Attachment\UploadUrlService; +use Espo\Tools\Attachment\UploadService; use stdClass; class Attachment extends RecordBase @@ -55,52 +56,76 @@ class Attachment extends RecordBase /** * @throws BadRequest * @throws Forbidden - * @throws \Espo\Core\Exceptions\Error + * @throws Error */ public function postActionGetAttachmentFromImageUrl(Request $request): stdClass { $data = $request->getParsedBody(); - if (empty($data->url)) { - throw new BadRequest(); + $url = $data->url ?? null; + $field = $data->field ?? null; + $parentType = $data->parentType ?? null; + $relatedType = $data->relatedType ?? null; + + if (!$url || !$field) { + throw new BadRequest("No `url` or `field`."); } - if (empty($data->field)) { - throw new BadRequest('postActionGetAttachmentFromImageUrl: No field specified.'); + try { + $fieldData = new FieldData( + $field, + $parentType, + $relatedType + ); + } + catch (Error $e) { + throw new BadRequest($e->getMessage()); } - return $this->getAttachmentService() - ->getAttachmentFromImageUrl($data) + return $this->injectableFactory + ->create(UploadUrlService::class) + ->uploadImage($url, $fieldData) ->getValueMap(); } /** * @throws BadRequest * @throws Forbidden - * @throws \Espo\Core\Exceptions\Error - * @throws \Espo\Core\Exceptions\NotFound + * @throws NotFound */ public function postActionGetCopiedAttachment(Request $request): stdClass { $data = $request->getParsedBody(); - if (empty($data->id)) { - throw new BadRequest(); + $id = $data->id ?? null; + $field = $data->field ?? null; + $parentType = $data->parentType ?? null; + $relatedType = $data->relatedType ?? null; + + if (!$id || !$field) { + throw new BadRequest("No `id` or `field`."); } - if (empty($data->field)) { - throw new BadRequest('postActionGetCopiedAttachment copy: No field specified.'); + try { + $fieldData = new FieldData( + $field, + $parentType, + $relatedType + ); + } + catch (Error $e) { + throw new BadRequest($e->getMessage()); } return $this->getAttachmentService() - ->getCopiedAttachment($data) + ->copy($id, $fieldData) ->getValueMap(); } /** * @throws BadRequest * @throws Forbidden - * @throws \Espo\Core\Exceptions\NotFound + * @throws NotFound */ public function getActionFile(Request $request, Response $response): void { @@ -112,18 +137,21 @@ class Attachment extends RecordBase $fileData = $this->getAttachmentService()->getFileData($id); + if ($fileData->getType()) { + $response->setHeader('Content-Type', $fileData->getType()); + } + $response - ->setHeader('Content-Type', $fileData->type) - ->setHeader('Content-Disposition', 'attachment; filename="' . $fileData->name . '"') - ->setHeader('Content-Length', (string) $fileData->size) - ->setBody($fileData->stream); + ->setHeader('Content-Disposition', 'attachment; filename="' . $fileData->getName() . '"') + ->setHeader('Content-Length', (string) $fileData->getSize()) + ->setBody($fileData->getStream()); } /** * @throws BadRequest * @throws Forbidden - * @throws \Espo\Core\Exceptions\Error - * @throws \Espo\Core\Exceptions\NotFound + * @throws Error + * @throws NotFound */ public function postActionChunk(Request $request, Response $response): void { @@ -134,14 +162,15 @@ class Attachment extends RecordBase throw new BadRequest(); } - $this->getAttachmentService()->uploadChunk($id, $body); + $this->injectableFactory + ->create(UploadService::class) + ->uploadChunk($id, $body); $response->writeBody('true'); } private function getAttachmentService(): Service { - /** @var Service */ - return $this->getRecordService(); + return $this->injectableFactory->create(Service::class); } } diff --git a/application/Espo/Entities/Attachment.php b/application/Espo/Entities/Attachment.php index ce5a539da9..e1f226e0b1 100644 --- a/application/Espo/Entities/Attachment.php +++ b/application/Espo/Entities/Attachment.php @@ -55,26 +55,41 @@ class Attachment extends Entity return $sourceId; } + /** + * A storage. + */ public function getStorage(): ?string { return $this->get('storage'); } + /** + * A file name. + */ public function getName(): ?string { return $this->get('name'); } + /** + * A size in bytes. + */ public function getSize(): ?int { return $this->get('size'); } + /** + * A mime-type. + */ public function getType(): ?string { return $this->get('type'); } + /** + * A field the attachment is related through. + */ public function getTargetField(): ?string { return $this->get('field'); @@ -107,6 +122,9 @@ class Attachment extends Entity return (bool) $this->get('isBeingUploaded'); } + /** + * A role. + */ public function getRole(): ?string { return $this->get('role'); diff --git a/application/Espo/Repositories/Attachment.php b/application/Espo/Repositories/Attachment.php index fb9ef0e304..95913e6b97 100644 --- a/application/Espo/Repositories/Attachment.php +++ b/application/Espo/Repositories/Attachment.php @@ -149,16 +149,19 @@ class Attachment extends Database implements } } + /** + * Copy an attachment record (to reuse the same file w/o copying it in the storage). + */ public function getCopiedAttachment(AttachmentEntity $entity, ?string $role = null): AttachmentEntity { $attachment = $this->getNew(); $attachment->set([ 'sourceId' => $entity->getSourceId(), - 'name' => $entity->get('name'), - 'type' => $entity->get('type'), - 'size' => $entity->get('size'), - 'role' => $entity->get('role'), + 'name' => $entity->getName(), + 'type' => $entity->getType(), + 'size' => $entity->getSize(), + 'role' => $entity->getRole(), ]); if ($role) { @@ -180,6 +183,9 @@ class Attachment extends Database implements return $this->fileStorageManager->getStream($entity); } + /** + * A size in bytes. + */ public function getSize(AttachmentEntity $entity): int { return $this->fileStorageManager->getSize($entity); diff --git a/application/Espo/Services/Attachment.php b/application/Espo/Services/Attachment.php index 7df303990a..6045d7e803 100644 --- a/application/Espo/Services/Attachment.php +++ b/application/Espo/Services/Attachment.php @@ -29,103 +29,29 @@ namespace Espo\Services; -use Espo\Core\Exceptions\ForbiddenSilent; -use Espo\Core\Utils\File\MimeType; use Espo\ORM\Entity; use Espo\Core\Exceptions\BadRequest; use Espo\Core\Exceptions\Forbidden; use Espo\Core\Exceptions\Error; -use Espo\Core\Exceptions\NotFound; -use Espo\Repositories\Attachment as AttachmentRepository; use Espo\Entities\Attachment as AttachmentEntity; -use Espo\Core\FileStorage\Storages\EspoUploadDir; -use Espo\Core\Acl\Table; - -use Espo\Core\Job\JobSchedulerFactory; -use Espo\Core\Job\Job\Data as JobData; - -use Espo\Tools\Attachment\Jobs\MoveToStorage; +use Espo\Tools\Attachment\AccessChecker; +use Espo\Tools\Attachment\Checker; +use Espo\Tools\Attachment\DetailsObtainer; +use Espo\Tools\Attachment\FieldData; use stdClass; /** - * @extends Record<\Espo\Entities\Attachment> + * @extends Record */ class Attachment extends Record { - /** - * @var string[] - */ + /** @var string[] */ protected $notFilteringAttributeList = ['contents']; - /** - * @var string[] - */ - protected $attachmentFieldTypeList = [ - self::FIELD_TYPE_FILE, - self::FIELD_TYPE_IMAGE, - self::FIELD_TYPE_ATTACHMENT_MULTIPLE, - ]; - - /** - * @var string[] - */ - protected $inlineAttachmentFieldTypeList = [ - self::FIELD_TYPE_WYSIWYG, - ]; - - /** - * @var string[] - */ - protected $adminOnlyHavingInlineAttachmentsEntityTypeList = [ - 'TemplateManager', - ]; - - /** - * @var string[] - */ - protected $allowedRoleList = [ - AttachmentEntity::ROLE_ATTACHMENT, - AttachmentEntity::ROLE_INLINE_ATTACHMENT, - ]; - - private const FIELD_TYPE_FILE = 'file'; - private const FIELD_TYPE_IMAGE = 'image'; - private const FIELD_TYPE_ATTACHMENT_MULTIPLE = 'attachmentMultiple'; - private const FIELD_TYPE_WYSIWYG = 'wysiwyg'; - - /** - * @throws Forbidden - * @todo Check where is it used. Maybe needs to be removed. - */ - public function upload(string $fileData): Entity - { - if (!$this->acl->checkScope('Attachment', Table::ACTION_CREATE)) { - throw new Forbidden(); - } - - $contents = ''; - - $arr = explode(',', $fileData); - - if (count($arr) > 1) { - list($prefix, $contents) = $arr; - - $contents = base64_decode($contents); - } - - $attachment = $this->entityManager->getNewEntity('Attachment'); - - $attachment->set('contents', $contents); - - $this->entityManager->saveEntity($attachment); - - return $attachment; - } - protected function afterCreateEntity(Entity $entity, $data) { if (!empty($data->file)) { @@ -139,10 +65,8 @@ class Attachment extends Record unset($data->parentId); unset($data->parentType); - unset($data->relatedId); unset($data->relatedType); - unset($data->isBeingUploaded); } @@ -183,9 +107,6 @@ class Attachment extends Record $data->contents = $contents; $relatedEntityType = null; - $field = null; - - $role = AttachmentEntity::ROLE_ATTACHMENT; if (isset($data->parentType)) { $relatedEntityType = $data->parentType; @@ -196,45 +117,36 @@ class Attachment extends Record $relatedEntityType = $data->relatedType; } - if (isset($data->field)) { - $field = $data->field; - } - - if (isset($data->role)) { - $role = $data->role; - } + $field = $data->field ?? null; + $role = $data->role ?? AttachmentEntity::ROLE_ATTACHMENT; if (!$relatedEntityType || !$field) { - throw new BadRequest("Params 'field' and 'parentType' not passed along with 'file'."); + throw new BadRequest("No `field` and `parentType`."); } - if (!in_array($role, $this->allowedRoleList)) { - throw new BadRequest("Not supported attachment 'role'."); - } + $fieldData = new FieldData( + $field, + $data->parentType ?? null, + $data->relatedType ?? null + ); - $this->checkAttachmentField($relatedEntityType, $field, $role); + $this->getAccessChecker()->check($fieldData, $role); $size = mb_strlen($contents, '8bit'); - if ($role === AttachmentEntity::ROLE_ATTACHMENT) { - $maxSize = $this->metadata - ->get(['entityDefs', $relatedEntityType, 'fields', $field, 'maxFileSize']); + $dummy = $this->entityManager->getRepositoryByClass(AttachmentEntity::class)->getNew(); - if (!$maxSize) { - $maxSize = $this->config->get('attachmentUploadMaxSize'); - } + $dummy->set([ + 'parentType' => $data->parentType ?? null, + 'relatedType' => $data->relatedType ?? null, + 'field' => $data->field ?? null, + 'role' => $role, + ]); - if ($maxSize && $size > $maxSize * 1024 * 1024) { - throw new Error("File size should not exceed {$maxSize}Mb."); - } - } + $maxSize = $this->getDetailsObtainer()->getUploadMaxSize($dummy); - if ($role === AttachmentEntity::ROLE_INLINE_ATTACHMENT) { - $inlineAttachmentUploadMaxSize = $this->config->get('inlineAttachmentUploadMaxSize'); - - if ($inlineAttachmentUploadMaxSize && $size > $inlineAttachmentUploadMaxSize * 1024 * 1024) { - throw new Error("File size should not exceed {$inlineAttachmentUploadMaxSize}Mb."); - } + if ($maxSize && $size > $maxSize * 1024 * 1024) { + throw new Error("File size should not exceed {$maxSize} Mb."); } } @@ -244,7 +156,7 @@ class Attachment extends Record */ protected function beforeCreateEntity(Entity $entity, $data) { - $storage = $entity->get('storage'); + $storage = $entity->getStorage(); if ( $storage && @@ -260,15 +172,19 @@ class Attachment extends Record $role = $entity->getRole(); $size = $entity->getSize(); - if ($role === AttachmentEntity::ROLE_ATTACHMENT) { - $maxSize = $this->getUploadMaxSize($entity); + $maxSize = $this->getDetailsObtainer()->getUploadMaxSize($entity); - if ($size && $size > $maxSize) { - throw new Forbidden("Attachment size exceeds `attachmentUploadMaxSize`."); - } + // Checking not actual file size but a set value. + if ($size && $size > $maxSize) { + throw new Forbidden("Attachment size exceeds `attachmentUploadMaxSize`."); } - $this->checkAttachmentType($entity); + $this->getChecker()->checkType($entity); + } + + private function getChecker(): Checker + { + return $this->injectableFactory->create(Checker::class); } protected function beforeUpdateEntity(Entity $entity, $data) @@ -283,549 +199,13 @@ class Attachment extends Record } } - private function getFieldType(AttachmentEntity $attachment): ?string + private function getDetailsObtainer(): DetailsObtainer { - $field = $attachment->getTargetField(); - $entityType = $attachment->getParentType() ?? $attachment->getRelatedType(); - - if (!$field || !$entityType) { - return null; - } - - return $this->metadata->get(['entityDefs', $entityType, 'fields', $field, 'type']); + return $this->injectableFactory->create(DetailsObtainer::class); } - /** - * @throws Forbidden - */ - private function checkAttachmentType(AttachmentEntity $attachment): void + private function getAccessChecker(): AccessChecker { - $field = $attachment->getTargetField(); - $entityType = $attachment->getParentType() ?? $attachment->getRelatedType(); - - if (!$field || !$entityType) { - return; - } - - if ( - $this->getFieldType($attachment) === self::FIELD_TYPE_IMAGE || - $attachment->getRole() === AttachmentEntity::ROLE_INLINE_ATTACHMENT - ) { - $this->checkAttachmentTypeImage($attachment); - - return; - } - - $extension = strtolower(self::getFileExtension($attachment) ?? ''); - - $mimeType = $this->getMimeTypeUtil()->getMimeTypeByExtension($extension) ?? - $attachment->getType(); - - /** @var string[] $accept */ - $accept = $this->metadata->get(['entityDefs', $entityType, 'fields', $field, 'accept']) ?? []; - - if ($accept === []) { - return; - } - - $found = false; - - foreach ($accept as $token) { - if (strtolower($token) === '.' . $extension) { - $found = true; - - break; - } - - if ($mimeType && MimeType::matchMimeTypeToAcceptToken($mimeType, $token)) { - $found = true; - - break; - } - } - - if (!$found) { - throw new ForbiddenSilent("Not allowed file type."); - } - } - - /** - * @throws Forbidden - */ - private function checkAttachmentTypeImage(AttachmentEntity $attachment, ?string $filePath = null): void - { - $extension = self::getFileExtension($attachment) ?? ''; - - $mimeType = $this->getMimeTypeUtil()->getMimeTypeByExtension($extension); - - /** @var string[] $imageTypeList */ - $imageTypeList = $this->metadata->get(['app', 'image', 'allowedFileTypeList']) ?? []; - - if (!in_array($mimeType, $imageTypeList)) { - throw new ForbiddenSilent("Not allowed file type."); - } - - $setMimeType = $attachment->getType(); - - if (strtolower($setMimeType ?? '') !== $mimeType) { - throw new ForbiddenSilent("Passed type does not correspond to extension."); - } - - $this->checkDetectedMimeType($attachment, $filePath); - } - - private static function getFileExtension(AttachmentEntity $attachment): ?string - { - $name = $attachment->getName() ?? ''; - - return array_slice(explode('.', $name), -1)[0] ?? null; - } - - /** - * @throws Forbidden - */ - private function checkDetectedMimeType(AttachmentEntity $attachment, ?string $filePath = null): void - { - // ext-fileinfo required, otherwise bypass. - if (!class_exists('\finfo') || !defined('FILEINFO_MIME_TYPE')) { - return; - } - - /** @var ?string $contents */ - $contents = $attachment->get('contents'); - - if (!$contents && !$filePath) { - return; - } - - $extension = self::getFileExtension($attachment) ?? ''; - - $mimeTypeList = $this->getMimeTypeUtil()->getMimeTypeListByExtension($extension); - - $fileInfo = new \finfo(FILEINFO_MIME_TYPE); - - $detectedMimeType = $filePath ? - $fileInfo->file($filePath) : - $fileInfo->buffer($contents); - - if (!in_array($detectedMimeType, $mimeTypeList)) { - throw new ForbiddenSilent("Detected mime type does not correspond to extension."); - } - } - - /** - * @throws Forbidden - * @throws Error - */ - protected function checkAttachmentField( - string $relatedEntityType, - string $field, - string $role = AttachmentEntity::ROLE_ATTACHMENT - ): void { - - if ( - $this->user->isAdmin() && - $role === AttachmentEntity::ROLE_INLINE_ATTACHMENT && - in_array($relatedEntityType, $this->adminOnlyHavingInlineAttachmentsEntityTypeList) - ) { - return; - } - - $fieldType = $this->metadata->get(['entityDefs', $relatedEntityType, 'fields', $field, 'type']); - - if (!$fieldType) { - throw new Error("Field '{$field}' does not exist."); - } - - $fieldTypeList = $role === AttachmentEntity::ROLE_INLINE_ATTACHMENT ? - $this->inlineAttachmentFieldTypeList : - $this->attachmentFieldTypeList; - - if (!in_array($fieldType, $fieldTypeList)) { - throw new Error("Field type '{$fieldType}' is not allowed for {$role}."); - } - - if ($this->user->isAdmin() && $relatedEntityType === 'Settings') { - return; - } - - if ( - !$this->acl->checkScope($relatedEntityType, Table::ACTION_CREATE) && - !$this->acl->checkScope($relatedEntityType, Table::ACTION_EDIT) - ) { - throw new Forbidden("No access to " . $relatedEntityType . "."); - } - - if (in_array($field, $this->acl->getScopeForbiddenFieldList($relatedEntityType, Table::ACTION_EDIT))) { - throw new Forbidden("No access to field '" . $field . "'."); - } - } - - /** - * @throws BadRequest - * @throws Forbidden - * @throws Error - * @throws NotFound - */ - public function getCopiedAttachment(stdClass $data): AttachmentEntity - { - if (empty($data->id)) { - throw new BadRequest(); - } - - if (empty($data->field)) { - throw new BadRequest(); - } - - if (isset($data->parentType)) { - $relatedEntityType = $data->parentType; - } - else if (isset($data->relatedType)) { - $relatedEntityType = $data->relatedType; - } - else { - throw new BadRequest(); - } - - $field = $data->field; - - $this->checkAttachmentField($relatedEntityType, $field); - - /** @var AttachmentEntity|null $attachment */ - $attachment = $this->getEntity($data->id); - - if (!$attachment) { - throw new NotFound(); - } - - $copied = $this->getAttachmentRepository()->getCopiedAttachment($attachment); - - $attachment = $copied; - - if (isset($data->parentType)) { - $attachment->set('parentType', $data->parentType); - } - - if (isset($data->relatedType)) { - $attachment->set('relatedType', $data->relatedType); - } - - $attachment->set('field', $field); - $attachment->set('role', AttachmentEntity::ROLE_ATTACHMENT); - - $this->getAttachmentRepository()->save($attachment); - - return $copied; - } - - /** - * @throws BadRequest - * @throws Forbidden - * @throws Error - */ - public function getAttachmentFromImageUrl(stdClass $data): AttachmentEntity - { - $attachment = $this->getAttachmentRepository()->getNew(); - - if (empty($data->url)) { - throw new BadRequest(); - } - - if (empty($data->field)) { - throw new BadRequest(); - } - - if (isset($data->parentType)) { - $relatedEntityType = $data->parentType; - } - else if (isset($data->relatedType)) { - $relatedEntityType = $data->relatedType; - } - else { - throw new BadRequest(); - } - - $url = $data->url; - - $field = $data->field; - - $this->checkAttachmentField($relatedEntityType, $field); - - $imageData = $this->getImageDataByUrl($url); - - if (!$imageData) { - throw new Error('Attachment::getAttachmentFromImageUrl: Bad image data.'); - } - - $type = $imageData->type; - $contents = $imageData->contents; - - $size = mb_strlen($contents, '8bit'); - - $maxSize = $this->metadata->get(['entityDefs', $relatedEntityType, 'fields', $field, 'maxFileSize']); - - if (!$maxSize) { - $maxSize = $this->config->get('attachmentUploadMaxSize'); - } - - if ($maxSize) { - if ($size > $maxSize * 1024 * 1024) { - throw new Error("File size should not exceed {$maxSize}Mb."); - } - } - - $attachment->set([ - 'name' => $url, - 'type' => $type, - 'contents' => $contents, - 'role' => AttachmentEntity::ROLE_ATTACHMENT, - ]); - - if (isset($data->parentType)) { - $attachment->set('parentType', $data->parentType); - } - - if (isset($data->relatedType)) { - $attachment->set('relatedType', $data->relatedType); - } - - $attachment->set('field', $field); - - $this->getAttachmentRepository()->save($attachment); - - $attachment->clear('contents'); - - return $attachment; - } - - protected function getImageDataByUrl(string $url): ?stdClass - { - $type = null; - - if (!function_exists('curl_init')) { - return null; - } - - $opts = []; - - $httpHeaders = []; - $httpHeaders[] = 'Expect:'; - - $opts[\CURLOPT_URL] = $url; - $opts[\CURLOPT_HTTPHEADER] = $httpHeaders; - $opts[\CURLOPT_CONNECTTIMEOUT] = 10; - $opts[\CURLOPT_TIMEOUT] = 10; - $opts[\CURLOPT_HEADER] = true; - $opts[\CURLOPT_BINARYTRANSFER] = true; - $opts[\CURLOPT_VERBOSE] = true; - $opts[\CURLOPT_SSL_VERIFYPEER] = true; - $opts[\CURLOPT_SSL_VERIFYHOST] = 2; - $opts[\CURLOPT_RETURNTRANSFER] = true; - $opts[\CURLOPT_FOLLOWLOCATION] = true; - $opts[\CURLOPT_MAXREDIRS] = 2; - $opts[\CURLOPT_IPRESOLVE] = \CURL_IPRESOLVE_V4; - - $ch = curl_init(); - - curl_setopt_array($ch, $opts); - - /** @var string|false $response */ - $response = curl_exec($ch); - - if ($response === false) { - curl_close($ch); - - return null; - } - - $headerSize = curl_getinfo($ch, \CURLINFO_HEADER_SIZE); - - $header = substr($response, 0, $headerSize); - $body = substr($response, $headerSize); - - $headLineList = explode("\n", $header); - - foreach ($headLineList as $i => $line) { - if ($i === 0) { - continue; - } - - if (strpos(strtolower($line), strtolower('Content-Type:')) === 0) { - $part = trim(substr($line, 13)); - - if ($part) { - $type = trim(explode(";", $part)[0]); - } - } - } - - if (!$type) { - /** @var string $extension */ - $extension = preg_replace('#\?.*#', '', pathinfo($url, \PATHINFO_EXTENSION)); - - $type = $this->getMimeTypeUtil()->getMimeTypeByExtension($extension); - } - - curl_close($ch); - - if (!$type) { - return null; - } - - /** @var string[] $imageTypeList */ - $imageTypeList = $this->metadata->get(['app', 'image', 'allowedFileTypeList']) ?? []; - - if (!in_array($type, $imageTypeList)) { - return null; - } - - return (object) [ - 'type' => $type, - 'contents' => $body, - ]; - } - - /** - * @throws NotFound - * @throws Forbidden - */ - public function getFileData(string $id): stdClass - { - /** @var AttachmentEntity|null $attachment */ - $attachment = $this->getEntity($id); - - if (!$attachment) { - throw new NotFound(); - } - - return (object) [ - 'name' => $attachment->get('name'), - 'type' => $attachment->get('type'), - 'stream' => $this->getAttachmentRepository()->getStream($attachment), - 'size' => $this->getAttachmentRepository()->getSize($attachment), - ]; - } - - /** - * @throws BadRequest - * @throws Forbidden - * @throws Error - * @throws NotFound - */ - public function uploadChunk(string $id, string $fileData): void - { - if (!$this->acl->checkScope(AttachmentEntity::ENTITY_TYPE, Table::ACTION_CREATE)) { - throw new Forbidden(); - } - - /** @var AttachmentEntity|null $attachment */ - $attachment = $this->getEntity($id); - - if (!$attachment) { - throw new NotFound(); - } - - if (!$attachment->isBeingUploaded()) { - throw new Forbidden("Attachment is not being-uploaded."); - } - - if ($attachment->getStorage() !== EspoUploadDir::NAME) { - throw new Forbidden("Attachment storage is not 'EspoUploadDir'."); - } - - $arr = explode(';base64,', $fileData); - - if (count($arr) < 2) { - throw new BadRequest("Bad file data."); - } - - $contents = base64_decode($arr[1]); - - $filePath = $this->getAttachmentRepository()->getFilePath($attachment); - - $chunkSize = strlen($contents); - - $actualFileSize = 0; - - if ($this->fileManager->isFile($filePath)) { - $actualFileSize = $this->fileManager->getSize($filePath); - } - - $maxFileSize = $this->getUploadMaxSize($attachment); - - if ($actualFileSize + $chunkSize > $maxFileSize) { - throw new Forbidden("Max attachment size exceeded."); - } - - $this->fileManager->appendContents($filePath, $contents); - - if ($actualFileSize + $chunkSize > $attachment->getSize()) { - throw new Error("File size mismatch."); - } - - $isLastChunk = $actualFileSize + $chunkSize === $attachment->getSize(); - - if (!$isLastChunk) { - return; - } - - if ($this->getFieldType($attachment) === self::FIELD_TYPE_IMAGE) { - try { - $this->checkAttachmentTypeImage($attachment, $filePath); - } - catch (Forbidden $e) { - $this->entityManager->removeEntity($attachment); - - throw new ForbiddenSilent($e->getMessage()); - } - } - - $attachment->set('isBeingUploaded', false); - - $this->entityManager->saveEntity($attachment); - - $this->createJobMoveToStorage($attachment); - } - - private function getUploadMaxSize(AttachmentEntity $attachment): int - { - $field = $attachment->get('field'); - $parentType = $attachment->get('parentType') ?? $attachment->get('relatedType'); - - if ($field && $parentType) { - $maxSize = ($this->metadata - ->get(['entityDefs', $parentType, 'fields', $field, 'maxFileSize']) ?? 0) * 1024 * 1024; - - if ($maxSize) { - return $maxSize; - } - } - - return (int) $this->config->get('attachmentUploadMaxSize', 0) * 1024 * 1024; - } - - private function createJobMoveToStorage(AttachmentEntity $attachment): void - { - /** @var JobSchedulerFactory $jobSchedulerFactory */ - $jobSchedulerFactory = $this->injectableFactory->create(JobSchedulerFactory::class); - - $jobSchedulerFactory->create() - ->setClassName(MoveToStorage::class) - ->setData( - JobData::create() - ->withTargetId($attachment->getId()) - ) - ->schedule(); - } - - private function getAttachmentRepository(): AttachmentRepository - { - /** @var AttachmentRepository */ - return $this->getRepository(); - } - - private function getMimeTypeUtil(): MimeType - { - return $this->injectableFactory->create(MimeType::class); + return $this->injectableFactory->create(AccessChecker::class); } } diff --git a/application/Espo/Tools/Attachment/AccessChecker.php b/application/Espo/Tools/Attachment/AccessChecker.php new file mode 100644 index 0000000000..d402aef909 --- /dev/null +++ b/application/Espo/Tools/Attachment/AccessChecker.php @@ -0,0 +1,132 @@ +user = $user; + $this->acl = $acl; + $this->metadata = $metadata; + } + + /** + * Check access to a field and role allowance. + * + * @throws Forbidden + */ + public function check(FieldData $fieldData, string $role = Attachment::ROLE_ATTACHMENT): void + { + if (!in_array($role, $this->allowedRoleList)) { + throw new Forbidden("Role not allowed."); + } + + $relatedEntityType = $fieldData->getParentType() ?? $fieldData->getRelatedType(); + $field = $fieldData->getField(); + + if (!$relatedEntityType) { + throw new Forbidden(); + } + + if ( + $this->user->isAdmin() && + $role === Attachment::ROLE_INLINE_ATTACHMENT && + in_array($relatedEntityType, $this->adminOnlyHavingInlineAttachmentsEntityTypeList) + ) { + return; + } + + $fieldType = $this->metadata->get(['entityDefs', $relatedEntityType, 'fields', $field, 'type']); + + if (!$fieldType) { + throw new Forbidden("Field '{$field}' does not exist."); + } + + $fieldTypeList = $role === Attachment::ROLE_INLINE_ATTACHMENT ? + $this->inlineAttachmentFieldTypeList : + $this->attachmentFieldTypeList; + + if (!in_array($fieldType, $fieldTypeList)) { + throw new Forbidden("Field type '{$fieldType}' is not allowed for {$role}."); + } + + if ($this->user->isAdmin() && $relatedEntityType === Settings::ENTITY_TYPE) { + return; + } + + if ( + !$this->acl->checkScope($relatedEntityType, Table::ACTION_CREATE) && + !$this->acl->checkScope($relatedEntityType, Table::ACTION_EDIT) + ) { + throw new Forbidden("No access to " . $relatedEntityType . "."); + } + + if (in_array($field, $this->acl->getScopeForbiddenFieldList($relatedEntityType, Table::ACTION_EDIT))) { + throw new Forbidden("No access to field '" . $field . "'."); + } + } +} diff --git a/application/Espo/Tools/Attachment/Checker.php b/application/Espo/Tools/Attachment/Checker.php new file mode 100644 index 0000000000..f633326b0c --- /dev/null +++ b/application/Espo/Tools/Attachment/Checker.php @@ -0,0 +1,168 @@ +metadata = $metadata; + $this->mimeType = $mimeType; + $this->detailsObtainer = $detailsObtainer; + } + + /** + * Check a mine-type for allowance. + * + * @throws Forbidden + */ + public function checkType(Attachment $attachment): void + { + $field = $attachment->getTargetField(); + $entityType = $attachment->getParentType() ?? $attachment->getRelatedType(); + + if (!$field || !$entityType) { + return; + } + + if ( + $this->detailsObtainer->getFieldType($attachment) === FieldType::IMAGE || + $attachment->getRole() === Attachment::ROLE_INLINE_ATTACHMENT + ) { + $this->checkTypeImage($attachment); + + return; + } + + $extension = strtolower(DetailsObtainer::getFileExtension($attachment) ?? ''); + + $mimeType = $this->mimeType->getMimeTypeByExtension($extension) ?? + $attachment->getType(); + + /** @var string[] $accept */ + $accept = $this->metadata->get(['entityDefs', $entityType, 'fields', $field, 'accept']) ?? []; + + if ($accept === []) { + return; + } + + $found = false; + + foreach ($accept as $token) { + if (strtolower($token) === '.' . $extension) { + $found = true; + + break; + } + + if ($mimeType && MimeType::matchMimeTypeToAcceptToken($mimeType, $token)) { + $found = true; + + break; + } + } + + if (!$found) { + throw new ForbiddenSilent("Not allowed file type."); + } + } + + /** + * Check a mime-time for allowance for an image. + * + * @throws Forbidden + */ + public function checkTypeImage(Attachment $attachment, ?string $filePath = null): void + { + $extension = DetailsObtainer::getFileExtension($attachment) ?? ''; + + $mimeType = $this->mimeType->getMimeTypeByExtension($extension); + + /** @var string[] $imageTypeList */ + $imageTypeList = $this->metadata->get(['app', 'image', 'allowedFileTypeList']) ?? []; + + if (!in_array($mimeType, $imageTypeList)) { + throw new ForbiddenSilent("Not allowed file type."); + } + + $setMimeType = $attachment->getType(); + + if (strtolower($setMimeType ?? '') !== $mimeType) { + throw new ForbiddenSilent("Passed type does not correspond to extension."); + } + + $this->checkDetectedMimeType($attachment, $filePath); + } + + /** + * @throws Forbidden + */ + private function checkDetectedMimeType(Attachment $attachment, ?string $filePath = null): void + { + // ext-fileinfo required, otherwise bypass. + if (!class_exists('\finfo') || !defined('FILEINFO_MIME_TYPE')) { + return; + } + + /** @var ?string $contents */ + $contents = $attachment->get('contents'); + + if (!$contents && !$filePath) { + return; + } + + $extension = DetailsObtainer::getFileExtension($attachment) ?? ''; + + $mimeTypeList = $this->mimeType->getMimeTypeListByExtension($extension); + + $fileInfo = new \finfo(FILEINFO_MIME_TYPE); + + $detectedMimeType = $filePath ? + $fileInfo->file($filePath) : + $fileInfo->buffer($contents); + + if (!in_array($detectedMimeType, $mimeTypeList)) { + throw new ForbiddenSilent("Detected mime type does not correspond to extension."); + } + } +} diff --git a/application/Espo/Tools/Attachment/DetailsObtainer.php b/application/Espo/Tools/Attachment/DetailsObtainer.php new file mode 100644 index 0000000000..d57474f182 --- /dev/null +++ b/application/Espo/Tools/Attachment/DetailsObtainer.php @@ -0,0 +1,99 @@ +metadata = $metadata; + $this->config = $config; + } + + /** + * Get a file extension. + */ + public static function getFileExtension(Attachment $attachment): ?string + { + $name = $attachment->getName() ?? ''; + + return array_slice(explode('.', $name), -1)[0] ?? null; + } + + /** + * Get an upload max size allowed for an attachment (depending on a field it's related to). + * + * @return int A size in bytes. + */ + public function getUploadMaxSize(Attachment $attachment): int + { + if ($attachment->getRole() === Attachment::ROLE_INLINE_ATTACHMENT) { + return $this->config->get('inlineAttachmentUploadMaxSize') * 1024 * 1024; + } + + $field = $attachment->getTargetField(); + $parentType = $attachment->getParentType() ?? $attachment->getRelatedType(); + + if ($field && $parentType) { + $maxSize = ($this->metadata + ->get(['entityDefs', $parentType, 'fields', $field, 'maxFileSize']) ?? 0) * 1024 * 1024; + + if ($maxSize) { + return $maxSize; + } + } + + return (int) $this->config->get('attachmentUploadMaxSize', 0) * 1024 * 1024; + } + + /** + * Get a field type (an attachment if related to another record through the field). + */ + public function getFieldType(Attachment $attachment): ?string + { + $field = $attachment->getTargetField(); + $entityType = $attachment->getParentType() ?? $attachment->getRelatedType(); + + if (!$field || !$entityType) { + return null; + } + + return $this->metadata->get(['entityDefs', $entityType, 'fields', $field, 'type']); + } +} diff --git a/application/Espo/Tools/Attachment/FieldData.php b/application/Espo/Tools/Attachment/FieldData.php new file mode 100644 index 0000000000..6c0c5adab0 --- /dev/null +++ b/application/Espo/Tools/Attachment/FieldData.php @@ -0,0 +1,74 @@ +field = $field; + $this->parentType = $parentType; + $this->relatedType = $relatedType; + + if (!$parentType && !$relatedType) { + throw new Error("No parentType and relatedType."); + } + } + + public function getField(): string + { + return $this->field; + } + + public function getParentType(): ?string + { + return $this->parentType; + } + + public function getRelatedType(): ?string + { + return $this->relatedType; + } +} diff --git a/application/Espo/Tools/Attachment/FieldType.php b/application/Espo/Tools/Attachment/FieldType.php new file mode 100644 index 0000000000..e69aa71d30 --- /dev/null +++ b/application/Espo/Tools/Attachment/FieldType.php @@ -0,0 +1,38 @@ +name = $name; + $this->type = $type; + $this->stream = $stream; + $this->size = $size; + } + + public function getName(): ?string + { + return $this->name; + } + + public function getType(): ?string + { + return $this->type; + } + + public function getStream(): StreamInterface + { + return $this->stream; + } + + public function getSize(): int + { + return $this->size; + } +} diff --git a/application/Espo/Tools/Attachment/Service.php b/application/Espo/Tools/Attachment/Service.php new file mode 100644 index 0000000000..21444d03eb --- /dev/null +++ b/application/Espo/Tools/Attachment/Service.php @@ -0,0 +1,117 @@ +recordServiceContainer = $recordServiceContainer; + $this->entityManager = $entityManager; + $this->accessChecker = $accessChecker; + } + + /** + * Get file data (for downloading). + * + * @throws NotFound + * @throws Forbidden + */ + public function getFileData(string $id): FileData + { + /** @var ?Attachment $attachment */ + $attachment = $this->recordServiceContainer + ->get(Attachment::ENTITY_TYPE) + ->getEntity($id); + + if (!$attachment) { + throw new NotFound(); + } + + return new FileData( + $attachment->getName(), + $attachment->getType(), + $this->getAttachmentRepository()->getStream($attachment), + $this->getAttachmentRepository()->getSize($attachment) + ); + } + + /** + * Copy an attachment record (to reuse the same file w/o copying it in the storage). + * + * @throws Forbidden + * @throws NotFound + */ + public function copy(string $id, FieldData $data): Attachment + { + $this->accessChecker->check($data); + + /** @var ?Attachment $attachment */ + $attachment = $this->recordServiceContainer + ->get(Attachment::ENTITY_TYPE) + ->getEntity($id); + + if (!$attachment) { + throw new NotFound(); + } + + $copied = $this->getAttachmentRepository()->getCopiedAttachment($attachment); + + $copied->set('parentType', $data->getParentType()); + $copied->set('relatedType', $data->getRelatedType()); + $copied->set('field', $data->getField()); + $copied->set('role', Attachment::ROLE_ATTACHMENT); + + $this->getAttachmentRepository()->save($copied); + + return $copied; + } + + private function getAttachmentRepository(): AttachmentRepository + { + /** @var AttachmentRepository */ + return $this->entityManager->getRepositoryByClass(Attachment::class); + } +} diff --git a/application/Espo/Tools/Attachment/UploadService.php b/application/Espo/Tools/Attachment/UploadService.php new file mode 100644 index 0000000000..44d198cdd8 --- /dev/null +++ b/application/Espo/Tools/Attachment/UploadService.php @@ -0,0 +1,179 @@ +jobSchedulerFactory = $jobSchedulerFactory; + $this->recordServiceContainer = $recordServiceContainer; + $this->acl = $acl; + $this->entityManager = $entityManager; + $this->fileManager = $fileManager; + $this->detailsObtainer = $detailsObtainer; + $this->checker = $checker; + } + + /** + * Upload a chunk. + * + * @throws BadRequest + * @throws Forbidden + * @throws Error + * @throws NotFound + */ + public function uploadChunk(string $id, string $fileData): void + { + if (!$this->acl->checkScope(Attachment::ENTITY_TYPE, Table::ACTION_CREATE)) { + throw new Forbidden(); + } + + /** @var ?Attachment $attachment */ + $attachment = $this->recordServiceContainer + ->get(Attachment::ENTITY_TYPE) + ->getEntity($id); + + if (!$attachment) { + throw new NotFound(); + } + + if (!$attachment->isBeingUploaded()) { + throw new Forbidden("Attachment is not being-uploaded."); + } + + if ($attachment->getStorage() !== EspoUploadDir::NAME) { + throw new Forbidden("Attachment storage is not 'EspoUploadDir'."); + } + + $arr = explode(';base64,', $fileData); + + if (count($arr) < 2) { + throw new BadRequest("Bad file data."); + } + + $contents = base64_decode($arr[1]); + + $filePath = $this->getAttachmentRepository()->getFilePath($attachment); + + $chunkSize = strlen($contents); + + $actualFileSize = 0; + + if ($this->fileManager->isFile($filePath)) { + $actualFileSize = $this->fileManager->getSize($filePath); + } + + $maxFileSize = $this->detailsObtainer->getUploadMaxSize($attachment); + + if ($actualFileSize + $chunkSize > $maxFileSize) { + throw new Forbidden("Max attachment size exceeded."); + } + + $this->fileManager->appendContents($filePath, $contents); + + if ($actualFileSize + $chunkSize > $attachment->getSize()) { + throw new Error("File size mismatch."); + } + + $isLastChunk = $actualFileSize + $chunkSize === $attachment->getSize(); + + if (!$isLastChunk) { + return; + } + + if ($this->detailsObtainer->getFieldType($attachment) === FieldType::IMAGE) { + try { + $this->checker->checkTypeImage($attachment, $filePath); + } + catch (Forbidden $e) { + $this->entityManager->removeEntity($attachment); + + throw new ForbiddenSilent($e->getMessage()); + } + } + + $attachment->set('isBeingUploaded', false); + + $this->entityManager->saveEntity($attachment); + + $this->createJobMoveToStorage($attachment); + } + + private function getAttachmentRepository(): AttachmentRepository + { + /** @var AttachmentRepository */ + return $this->entityManager->getRepositoryByClass(Attachment::class); + } + + private function createJobMoveToStorage(Attachment $attachment): void + { + $this->jobSchedulerFactory + ->create() + ->setClassName(MoveToStorage::class) + ->setData( + JobData::create() + ->withTargetId($attachment->getId()) + ) + ->schedule(); + } +} diff --git a/application/Espo/Tools/Attachment/UploadUrlService.php b/application/Espo/Tools/Attachment/UploadUrlService.php new file mode 100644 index 0000000000..f8aeeb4199 --- /dev/null +++ b/application/Espo/Tools/Attachment/UploadUrlService.php @@ -0,0 +1,199 @@ +accessChecker = $accessChecker; + $this->metadata = $metadata; + $this->entityManager = $entityManager; + $this->mimeType = $mimeType; + $this->detailsObtainer = $detailsObtainer; + } + + /** + * Upload an image from and URL and store as attachment. + * + * @throws Forbidden + * @throws Error + */ + public function uploadImage(string $url, FieldData $data): Attachment + { + $attachment = $this->getAttachmentRepository()->getNew(); + + $this->accessChecker->check($data); + + [$type, $contents] = $this->getImageDataByUrl($url) ?? [null, null]; + + if (!$type || !$contents) { + throw new ErrorSilent("Bad image data."); + } + + $attachment->set([ + 'name' => $url, + 'type' => $type, + 'contents' => $contents, + 'role' => Attachment::ROLE_ATTACHMENT, + ]); + + $attachment->set('parentType', $data->getParentType()); + $attachment->set('relatedType', $data->getRelatedType()); + $attachment->set('field', $data->getField()); + + $size = mb_strlen($contents, '8bit'); + $maxSize = $this->detailsObtainer->getUploadMaxSize($attachment); + + if ($maxSize && $size > $maxSize) { + throw new Error("File size should not exceed {$maxSize}Mb."); + } + + $this->getAttachmentRepository()->save($attachment); + + $attachment->clear('contents'); + + return $attachment; + } + + /** + * @param string $url + * @return ?array{string, string} A type and contents. + */ + private function getImageDataByUrl(string $url): ?array + { + $type = null; + + if (!function_exists('curl_init')) { + return null; + } + + $opts = []; + + $httpHeaders = []; + $httpHeaders[] = 'Expect:'; + + $opts[\CURLOPT_URL] = $url; + $opts[\CURLOPT_HTTPHEADER] = $httpHeaders; + $opts[\CURLOPT_CONNECTTIMEOUT] = 10; + $opts[\CURLOPT_TIMEOUT] = 10; + $opts[\CURLOPT_HEADER] = true; + $opts[\CURLOPT_BINARYTRANSFER] = true; + $opts[\CURLOPT_VERBOSE] = true; + $opts[\CURLOPT_SSL_VERIFYPEER] = true; + $opts[\CURLOPT_SSL_VERIFYHOST] = 2; + $opts[\CURLOPT_RETURNTRANSFER] = true; + $opts[\CURLOPT_FOLLOWLOCATION] = true; + $opts[\CURLOPT_MAXREDIRS] = 2; + $opts[\CURLOPT_IPRESOLVE] = \CURL_IPRESOLVE_V4; + + $ch = curl_init(); + + curl_setopt_array($ch, $opts); + + /** @var string|false $response */ + $response = curl_exec($ch); + + if ($response === false) { + curl_close($ch); + + return null; + } + + $headerSize = curl_getinfo($ch, \CURLINFO_HEADER_SIZE); + + $header = substr($response, 0, $headerSize); + $body = substr($response, $headerSize); + + $headLineList = explode("\n", $header); + + foreach ($headLineList as $i => $line) { + if ($i === 0) { + continue; + } + + if (strpos(strtolower($line), strtolower('Content-Type:')) === 0) { + $part = trim(substr($line, 13)); + + if ($part) { + $type = trim(explode(";", $part)[0]); + } + } + } + + if (!$type) { + /** @var string $extension */ + $extension = preg_replace('#\?.*#', '', pathinfo($url, \PATHINFO_EXTENSION)); + + $type = $this->mimeType->getMimeTypeByExtension($extension); + } + + curl_close($ch); + + if (!$type) { + return null; + } + + /** @var string[] $imageTypeList */ + $imageTypeList = $this->metadata->get(['app', 'image', 'allowedFileTypeList']) ?? []; + + if (!in_array($type, $imageTypeList)) { + return null; + } + + return [$type, $body]; + } + + private function getAttachmentRepository(): AttachmentRepository + { + /** @var AttachmentRepository */ + return $this->entityManager->getRepositoryByClass(Attachment::class); + } +}