self::TYPE_MESSAGE, 'application' => self::TYPE_APPLICATION, 'audio' => self::TYPE_AUDIO, 'image' => self::TYPE_IMAGE, 'video' => self::TYPE_VIDEO, 'model' => self::TYPE_MODEL, 'text' => self::TYPE_TEXT, 'multipart' => self::TYPE_MULTIPART, 'other' => self::TYPE_OTHER, ]; public static $type_extensions = [ self::TYPE_VIDEO => ['flv', 'mp4', 'm3u8', 'ts', '3gp', 'mov', 'avi', 'wmv'] ]; public $timestamps = false; /** * Get thread. */ public function thread() { return $this->belongsTo('App\Thread'); } /** * Save attachment to file and database. */ public static function create($file_name, $mime_type, $type, $content, $uploaded_file, $embedded = false, $thread_id = null, $user_id = null) { if (!$content && !$uploaded_file) { return false; } // Sanitize mime type. // https://github.com/freescout-helpdesk/freescout/issues/3048 $mime_duplicate = strpos($mime_type, "application/vnd.openxmlformats", 1); if ($mime_duplicate) { $mime_type = substr($mime_type, $mime_duplicate); } $mime_type = substr($mime_type, 0, self::MIME_TYPE_MAX_LENGTH); $orig_extension = pathinfo($file_name, PATHINFO_EXTENSION); // Add underscore to the extension if file has restricted extension. $file_name = \Helper::sanitizeUploadedFileName($file_name, $uploaded_file, $content); // Replace some symbols in file name. // Gmail can not load image if it contains spaces. $file_name = preg_replace('/[ #\/]/', '_', $file_name); // Replace soft hyphens. $file_name = str_replace(html_entity_decode('­'), '_', $file_name); if (!$file_name) { if (!$orig_extension) { preg_match("/.*\/([^\/]+)$/", $mime_type, $m); if (!empty($m[1])) { $orig_extension = $m[1]; } } $file_name = uniqid(); if ($orig_extension) { $file_name .= '.'.$orig_extension; } } // https://github.com/freescout-helpdesk/freescout/issues/2385 // Fix for webklex/php-imap. if ($file_name == 'undefined' && $mime_type == 'message/rfc822') { $file_name = 'RFC822.eml'; } // https://github.com/freescout-helpdesk/freescout/issues/1412#issuecomment-1658881493 if ($file_name == 'undefined' && $mime_type == 'text/calendar') { $file_name = 'calendar.ics'; } if (strlen($file_name) > 255) { $without_ext = pathinfo($file_name, PATHINFO_FILENAME); $extension = pathinfo($file_name, PATHINFO_EXTENSION); // 125 because file name may have unicode symbols. $file_name = \Helper::substrUnicode($without_ext, 0, 125-strlen($extension)-1); $file_name .= '.'.$extension; } if (!$type) { $type = self::detectType($mime_type, $orig_extension); } $attachment = new self(); $attachment->thread_id = $thread_id; $attachment->user_id = $user_id; $attachment->file_name = $file_name; $attachment->mime_type = $mime_type; $attachment->type = $type; $attachment->embedded = $embedded; $attachment->save(); $file_info = self::saveFileToDisk($attachment, $file_name, $content, $uploaded_file); $attachment->file_dir = $file_info['file_dir']; $attachment->size = Storage::disk(self::DISK)->size($file_info['file_path']); $attachment->save(); return $attachment; } /** * Save file to the disk and return file_dir. */ public static function saveFileToDisk($attachment, $file_name, $content, $uploaded_file) { // Save file from content or copy file. // We have to keep file name as is, so if file exists we create extra folder. // Examples: 1/2/3 $file_dir = self::generatePath($attachment->id); $i = 0; do { $i++; $file_path = self::DIRECTORY.DIRECTORY_SEPARATOR.$file_dir.$i.DIRECTORY_SEPARATOR.$file_name; } while (Storage::disk(self::DISK)->exists($file_path)); $file_dir .= $i.DIRECTORY_SEPARATOR; if ($uploaded_file) { $uploaded_file->storeAs(self::DIRECTORY.DIRECTORY_SEPARATOR.$file_dir, $file_name, ['disk' => self::DISK]); } else { Storage::disk(self::DISK)->put($file_path, $content); } \Helper::sanitizeUploadedFileData($file_path, \Helper::getPrivateStorage(), $content); return [ 'file_dir' => $file_dir, 'file_path' => $file_path, ]; } /** * Get file path. * Examples: 1/2, 1/3. * * @param int $id * * @return string */ public static function generatePath($id) { $hash = md5($id); $first = -1; $second = 0; for ($i = 0; $i < strlen($hash); $i++) { if (is_numeric($hash[$i])) { if ($first == -1) { $first = $hash[$i]; } else { $second = $hash[$i]; break; } } } if ($first == -1) { $first = 0; } return $first.DIRECTORY_SEPARATOR.$second.DIRECTORY_SEPARATOR; } /** * Detect attachment type by it's mime type. * * @param string $mime_type * * @return int */ public static function detectType($mime_type, $extension = '') { if (preg_match("/^text\//", $mime_type)) { return self::TYPE_TEXT; } elseif (preg_match("/^message\//", $mime_type)) { return self::TYPE_MESSAGE; } elseif (preg_match("/^application\//", $mime_type)) { // This is tricky mime type. // For .mp4 mime type can be application/octet-stream if (!empty($extension) && in_array(strtolower($extension), self::$type_extensions[self::TYPE_VIDEO])) { return self::TYPE_VIDEO; } return self::TYPE_APPLICATION; } elseif (preg_match("/^audio\//", $mime_type)) { return self::TYPE_AUDIO; } elseif (preg_match("/^image\//", $mime_type)) { return self::TYPE_IMAGE; } elseif (preg_match("/^video\//", $mime_type)) { return self::TYPE_VIDEO; } elseif (preg_match("/^model\//", $mime_type)) { return self::TYPE_MODEL; } else { return self::TYPE_OTHER; } } /** * Convert type name to integer. */ public static function typeNameToInt($type_name) { if (!empty(self::$types[$type_name])) { return self::$types[$type_name]; } else { return self::TYPE_OTHER; } } /** * Get attachment full public URL. * * @return string */ public function url() { $file_url = Storage::url($this->getStorageFilePath()); // Fix percents. // https://github.com/freescout-helpdesk/freescout/issues/3530 $file_url = str_replace('%', '%25', $file_url); return $file_url.'?id='.$this->id.'&token='.$this->getToken(); } /** * Get hashed security token for the attachment. */ public function getToken() { // \Hash::make() may contain . and / symbols which may cause problems. return md5(config('app.key').$this->id.$this->size); } /** * Outputs the current Attachment as download */ public function download($view = false) { $headers = []; // #533 //return $this->getDisk()->download($this->getStorageFilePath(), \Str::ascii($this->file_name)); if ($view) { $headers['Content-Disposition'] = ''; } $file_name = $this->file_name; if ($file_name == "RFC822"){ $file_name = $file_name.'.eml'; } return $this->getDisk()->download($this->getStorageFilePath(), $file_name, $headers); } private function getDisk() { return Storage::disk(self::DISK); } /** * Convert size into human readable format. * * @return string */ public function getSizeName() { return self::formatBytes($this->size); } /** * attachment/... */ public function getStorageFilePath() { return self::DIRECTORY.DIRECTORY_SEPARATOR.$this->file_dir.$this->file_name; } /** * /var/html/storage/app/attachment/... */ public function getLocalFilePath($full = true) { if ($full) { return $this->getDisk()->path(self::DIRECTORY.DIRECTORY_SEPARATOR.$this->file_dir.$this->file_name); } else { return DIRECTORY_SEPARATOR.'storage'.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.self::DIRECTORY.DIRECTORY_SEPARATOR.$this->file_dir.$this->file_name; } } /** * Check if the attachment file actually exists on the disk. */ public function fileExists() { return $this->getDisk()->exists(self::DIRECTORY.DIRECTORY_SEPARATOR.$this->file_dir.$this->file_name); } public static function formatBytes($size, $precision = 0) { $size = (int) $size; if ($size > 0) { $base = log($size) / log(1024); $suffixes = [' b', ' KB', ' MB', ' GB', ' TB']; return round(pow(1024, $base - floor($base)), $precision).$suffixes[floor($base)]; } else { return $size; } } /** * Delete attachments from disk and DB. * Embeds are not taken into account. * * @param array $attachments */ public static function deleteByIds($attachment_ids) { if (!count($attachment_ids)) { return; } $attachments = self::whereIn('id', $attachment_ids)->get(); // Delete from disk self::deleteForever($attachments); } /** * Delete attachments by thread IDs. */ public static function deleteByThreadIds($thread_ids) { if (!count($thread_ids)) { return; } $attachments = self::whereIn('thread_id', $thread_ids)->get(); // Delete from disk self::deleteForever($attachments); } public static function deleteForever($attachments) { // Delete from disk foreach ($attachments as $attachment) { $attachment->getDisk()->delete($attachment->getStorageFilePath()); } // Delete from DB self::whereIn('id', $attachments->pluck('id')->toArray())->delete(); } /** * Delete attachments and update Thread & Conversation. */ public static function deleteAttachments($attachments) { if (!$attachments instanceof \Illuminate\Support\Collection) { $attachments = collect($attachments); } foreach ($attachments as $attachment) { if ($attachment->thread_id && $attachment->thread && count($attachment->thread->attachments) <= 1 ) { $attachment->thread->has_attachments = false; $attachment->thread->save(); // Update conversation. $conversation = $attachment->thread->conversation; foreach ($conversation->threads as $thread) { if ($thread->has_attachments) { break 2; } } $conversation->has_attachments = false; $conversation->save(); } } Attachment::deleteForever($attachments); } /** * Create a copy of the attachment and it's file. */ public function duplicate($thread_id = null) { $new_attachment = $this->replicate(); if ($thread_id) { $new_attachment->thread_id = $thread_id; } $new_attachment->save(); try { $attachment_file = new \Illuminate\Http\UploadedFile( $this->getLocalFilePath(), $this->file_name, null, null, true ); $file_info = Attachment::saveFileToDisk($new_attachment, $new_attachment->file_name, '', $attachment_file); if (!empty($file_info['file_dir'])) { $new_attachment->file_dir = $file_info['file_dir']; $new_attachment->save(); } } catch (\Exception $e) { \Helper::logException($e); } return $new_attachment; } public function getFileContents() { return $this->getDisk()->get($this->getStorageFilePath()); } }