. */ declare(strict_types=1); namespace FireflyIII\Helpers\Attachments; use Crypt; use FireflyIII\Exceptions\FireflyException; use FireflyIII\Models\Attachment; use FireflyIII\Models\PiggyBank; use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Contracts\Encryption\EncryptException; use Illuminate\Contracts\Filesystem\Filesystem; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Storage; use Illuminate\Support\MessageBag; use Log; use Symfony\Component\HttpFoundation\File\UploadedFile; /** * Class AttachmentHelper. */ class AttachmentHelper implements AttachmentHelperInterface { public Collection $attachments; public MessageBag $errors; public MessageBag $messages; protected array $allowedMimes = []; protected int $maxUploadSize = 0; /** @var Filesystem The disk where attachments are stored. */ protected $uploadDisk; /** * AttachmentHelper constructor. * * @codeCoverageIgnore */ public function __construct() { $this->maxUploadSize = (int) config('firefly.maxUploadSize'); $this->allowedMimes = (array) config('firefly.allowedMimes'); $this->errors = new MessageBag(); $this->messages = new MessageBag(); $this->attachments = new Collection(); $this->uploadDisk = Storage::disk('upload'); } /** * Returns the content of an attachment. * * @codeCoverageIgnore * * @param Attachment $attachment * * @return string */ public function getAttachmentContent(Attachment $attachment): string { $encryptedData = (string) $this->uploadDisk->get(sprintf('at-%d.data', $attachment->id)); try { $unencryptedData = Crypt::decrypt($encryptedData); // verified } catch (DecryptException $e) { Log::error(sprintf('Could not decrypt data of attachment #%d: %s', $attachment->id, $e->getMessage())); $unencryptedData = $encryptedData; } return $unencryptedData; } /** * Returns the file path relative to upload disk for an attachment, * * @param Attachment $attachment * * @codeCoverageIgnore * @return string */ public function getAttachmentLocation(Attachment $attachment): string { return sprintf('%sat-%d.data', DIRECTORY_SEPARATOR, (int) $attachment->id); } /** * Get all attachments. * * @codeCoverageIgnore * @return Collection */ public function getAttachments(): Collection { return $this->attachments; } /** * Get all errors. * * @return MessageBag * @codeCoverageIgnore */ public function getErrors(): MessageBag { return $this->errors; } /** * Get all messages. * * @return MessageBag * @codeCoverageIgnore */ public function getMessages(): MessageBag { return $this->messages; } /** * Uploads a file as a string. * * @param Attachment $attachment * @param string $content * * @return bool */ public function saveAttachmentFromApi(Attachment $attachment, string $content): bool { $resource = tmpfile(); if (false === $resource) { Log::error('Cannot create temp-file for file upload.'); return false; } if ('' === $content) { Log::error('Cannot upload empty file.'); return false; } $path = stream_get_meta_data($resource)['uri']; fwrite($resource, $content); $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime = finfo_file($finfo, $path); $allowedMime = config('firefly.allowedMimes'); if (!in_array($mime, $allowedMime, true)) { Log::error(sprintf('Mime type %s is not allowed for API file upload.', $mime)); fclose($resource); return false; } // is allowed? Save the file, without encryption. $parts = explode('/', $attachment->fileName()); $file = $parts[count($parts) - 1]; $this->uploadDisk->put($file, $content); // update attachment. $attachment->md5 = md5_file($path); $attachment->mime = $mime; $attachment->size = strlen($content); $attachment->uploaded = true; $attachment->save(); return true; } /** * Save attachments that get uploaded with models, through the app. * * @param object $model * @param array|null $files * * @return bool * @throws FireflyException */ public function saveAttachmentsForModel(object $model, ?array $files): bool { if (!($model instanceof Model)) { return false; } Log::debug(sprintf('Now in saveAttachmentsForModel for model %s', get_class($model))); if (is_array($files)) { Log::debug('$files is an array.'); /** @var UploadedFile $entry */ foreach ($files as $entry) { if (null !== $entry) { $this->processFile($entry, $model); } } Log::debug('Done processing uploads.'); } if (!is_array($files)) { Log::debug('Array of files is not an array. Probably nothing uploaded. Will not store attachments.'); } return true; } /** * Process the upload of a file. * * @param UploadedFile $file * @param Model $model * * @return Attachment|null * @throws FireflyException * @throws EncryptException */ protected function processFile(UploadedFile $file, Model $model): ?Attachment { Log::debug('Now in processFile()'); $validation = $this->validateUpload($file, $model); $attachment = null; if (false !== $validation) { $class = get_class($model); $user = $model->user; if (PiggyBank::class === $class) { $user = $model->account->user; } $attachment = new Attachment(); // create Attachment object. $attachment->user()->associate($user); $attachment->attachable()->associate($model); $attachment->md5 = md5_file($file->getRealPath()); $attachment->filename = $file->getClientOriginalName(); $attachment->mime = $file->getMimeType(); $attachment->size = $file->getSize(); $attachment->uploaded = false; $attachment->save(); Log::debug('Created attachment:', $attachment->toArray()); $fileObject = $file->openFile(); $fileObject->rewind(); if (0 === $file->getSize()) { throw new FireflyException('Cannot upload empty or non-existent file.'); } $content = $fileObject->fread($file->getSize()); Log::debug(sprintf('Full file length is %d and upload size is %d.', strlen($content), $file->getSize())); // store it without encryption. $this->uploadDisk->put($attachment->fileName(), $content); $attachment->uploaded = true; // update attachment $attachment->save(); $this->attachments->push($attachment); $name = e($file->getClientOriginalName()); // add message: $msg = (string) trans('validation.file_attached', ['name' => $name]); $this->messages->add('attachments', $msg); } return $attachment; } /** * Verify if the file was uploaded correctly. * * @param UploadedFile $file * @param Model $model * * @return bool */ protected function validateUpload(UploadedFile $file, Model $model): bool { Log::debug('Now in validateUpload()'); $result = true; if (!$this->validMime($file)) { $result = false; } if (0 === $file->getSize()) { Log::error('Cannot upload empty file.'); $result = false; } // can't seem to reach this point. if (true === $result && !$this->validSize($file)) { $result = false; } if (true === $result && $this->hasFile($file, $model)) { $result = false; } return $result; } /** * Verify if the mime of a file is valid. * * @param UploadedFile $file * * @return bool */ protected function validMime(UploadedFile $file): bool { Log::debug('Now in validMime()'); $mime = e($file->getMimeType()); $name = e($file->getClientOriginalName()); Log::debug(sprintf('Name is %s, and mime is %s', $name, $mime)); Log::debug('Valid mimes are', $this->allowedMimes); $result = true; if (!in_array($mime, $this->allowedMimes, true)) { $msg = (string) trans('validation.file_invalid_mime', ['name' => $name, 'mime' => $mime]); $this->errors->add('attachments', $msg); Log::error($msg); $result = false; } return $result; } /** * Verify if the size of a file is valid. * * @codeCoverageIgnore * * @param UploadedFile $file * * @return bool */ protected function validSize(UploadedFile $file): bool { $size = $file->getSize(); $name = e($file->getClientOriginalName()); $result = true; if ($size > $this->maxUploadSize) { $msg = (string) trans('validation.file_too_large', ['name' => $name]); $this->errors->add('attachments', $msg); Log::error($msg); $result = false; } return $result; } /** * Check if a model already has this file attached. * * @param UploadedFile $file * @param Model $model * * @return bool */ protected function hasFile(UploadedFile $file, Model $model): bool { $md5 = md5_file($file->getRealPath()); $name = $file->getClientOriginalName(); $class = get_class($model); $count = 0; if (PiggyBank::class === $class) { $count = $model->account->user->attachments()->where('md5', $md5)->where('attachable_id', $model->id)->where('attachable_type', $class)->count(); } if (PiggyBank::class !== $class) { $count = $model->user->attachments()->where('md5', $md5)->where('attachable_id', $model->id)->where('attachable_type', $class)->count(); } $result = false; if ($count > 0) { $msg = (string) trans('validation.file_already_attached', ['name' => $name]); $this->errors->add('attachments', $msg); Log::error($msg); $result = true; } return $result; } }