HEX
Server: Microsoft-IIS/8.5
System: Windows NT YDAWBH120 6.3 build 9600 (Windows Server 2012 R2 Standard Edition) AMD64
User: tentjecom_web (0)
PHP: 7.4.14
Disabled: NONE
Upload Files
File: D:/HostingSpaces/SBogers10/vangogh.komma.pro/app/Komma/Documents/Kms/DocumentService.php
<?php

namespace App\Komma\Documents\Kms;

use App\Komma\Documents\Models\Document;
use App\Komma\Kms\ImageProcessing\CropperInterface;
use App\Komma\Kms\Core\Attributes\Documents;
use App\Komma\Kms\Core\Attributes\Models\ImageProperty;
use App\Komma\Kms\Core\HouseKeeping\CanDoHousekeepingInterface;
use App\Komma\Kms\Core\Sections\AbstractAttributeKey;
use Carbon\Carbon;
use Illuminate\Http\File;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;

/**
 * Class DocumentService
 *
 * Manages uploads
 *
 * @package App\Komma\Documents\Kms
 */
final class DocumentService implements DocumentServiceInterface, CanDoHousekeepingInterface
{
    public static $uploadsFolderName = DIRECTORY_SEPARATOR.'uploads'.DIRECTORY_SEPARATOR;

    protected $sortable = false;

    /**
     * @var ImageProperty[]
     */
    protected $defaultImageProperties;

    /** @var string $forModelName*/
    const forModelName = Document::class;

    /**
     * @var CropperInterface $imageCropper
     */
    private $imageCropper;

    /**
     * DocumentService constructor.
     */
    function __construct()
    {
        $this->defaultImageProperties = [
            (new ImageProperty())->setName('large')->setCropMethod(ImageProperty::Resize)->setWidth(1440),
            (new ImageProperty())->setName('medium')->setCropMethod(ImageProperty::Resize)->setWidth(800),
            (new ImageProperty())->setName('small')->setCropMethod(ImageProperty::Resize)->setWidth(425),
            (new ImageProperty())->setName('thumb')->setCropMethod(ImageProperty::Fit)->setWidth(128)
        ];

        $this->imageCropper = app()->make(CropperInterface::class);
    }

    /**
     * Retrieves the uploaded documents and stores them in the filesystem and database by delegating to other methods
     *
     * @param Model $model
     * @param Documents $attribute
     * @param AbstractAttributeKey $beforeSaveKey The key of the attribute before it was saved. If you don't specify it, the attribute key will be used.
     * @param AbstractAttributeKey $afterSaveKey The key of the attribute after it was saved. If you don't specify it, the attribute key will be used.
     */
    public function processUploadedDocumentsForModel(Model $model, Documents $attribute, AbstractAttributeKey $beforeSaveKey = null, AbstractAttributeKey $afterSaveKey = null)
    {
        $documents = $this->getDocumentsUsingAttributeKey((!$beforeSaveKey) ? $attribute->getKey() : $beforeSaveKey);

        $initDocuments = clone $documents;

        $documents = $this->storeUploadedDocumentsIfNeeded($documents, $attribute->getSubFolder());

        $storedDocuments = clone $documents;

        $documents = $this->deleteDocumentsIfNeeded($documents, $attribute->getSubFolder());

        $deletedDocuments = clone $documents;

        $documents = $this->processImagesIfNeeded($documents, $attribute);

        $imageProcessedDocuments = clone $documents;

        $documents = $this->saveDocumentsForModel($documents, $model, (!$afterSaveKey) ? $attribute->getKey() : $afterSaveKey);

        $savedDocuments = clone $documents;

//        KommaHelpers::howLongSinceStart('Documents and states'. $initDocuments. 'Stored', $storedDocuments. 'Deleted'. $deletedDocuments. 'Images Processed'. $imageProcessedDocuments. 'Saved'. $savedDocuments. 'Feedbacked to html 5 uploader'. $toldUploaderProcessedDocuments);
//        if($toldUploaderProcessedDocuments->count() == 0) dd('Documents and states', $initDocuments, 'Stored', $storedDocuments, 'Deleted', $deletedDocuments, 'Images Processed', $imageProcessedDocuments, 'Saved', $savedDocuments, 'Feedbacked to html 5 uploader', $toldUploaderProcessedDocuments);
    }

    /**
     * Store an html 5 uploaded file
     *
     * @param UploadedFile $file
     * @param string $subFolder the subfolder of the public uploads folder where to store this file
     * @param array $imageProperties
     * @return Document
     */
    public function storeHtml5Upload(UploadedFile $file, string $subFolder, array $imageProperties): Document
    {
        $document = new Document();
        $document->file = $file;
        $document->mime_type = $file->getMimeType();
        $document->state = Document::STATE_MODIFIED;

        $documents = collect([$document]);

        $documents = $this->storeUploadedDocumentsIfNeeded($documents, $subFolder);


        $documents = $this->processImagesIfNeeded($documents, $imageProperties);

        $document = $documents->first();
        $document->save();

        return $document;
    }

    /**
     * Link a specific file to a model as a document.
     *
     * @param Model $model
     * @param string $absoluteFileSourcePath
     * @param string $uploadsSubFolder
     * @param ImageProperty[] $imageProperties
     * @param $key
     */
    public function linkFileToModelAsDocument(Model $model, string $absoluteFileSourcePath, string $uploadsSubFolder, array $imageProperties, $key)
    {
        $file = new File($absoluteFileSourcePath);
        $document = new Document();
        $document->file = $file;
        $document->mime_type = $file->getMimeType();

        $documents = collect([$document]);

        $documents = $this->storeUploadedDocumentsIfNeeded($documents, $uploadsSubFolder);
        $documents = $this->processImagesIfNeeded($documents, $imageProperties);

        $this->saveDocumentsForModel($documents, $model, $key);
    }

    /**
     * Checks if a filename is linked to a model
     *
     * @param Model $model
     * @param $name
     * @return bool
     */
    public function fileLinkedForModel(Model $model, $name)
    {
        $documents = Document::where('documentable_id', '=', $model->id)->where('documentable_type', '=', get_class($model))->where('name', '=', $name)->get();
        return $documents->count() > 0;
    }

    /**
     * Save the documents in a collection
     * @param Collection $documents
     * @param Model $model
     * @return Collection
     */
    private function saveDocumentsForModel(Collection $documents, $model, $key)
    {
        $documents->each(function(Document $document) use ($model, $key) {
            $document->key = $key;
            $document->documentable()->associate($model);
            $document->save(); //Only saves documents which "original" attributes do not match the ones in "attributes".
        });

        return $documents;
    }

    /**
     * Stores the Uploaded Files instances (in the file attributes from the documents) into the public folder.
     * When done this function returns a collection with the same collection keys as the input collection but
     * with paths set in the documents
     *
     * @param Collection $documents
     * @param string $uploadsSubFolder
     * @return Collection
     */
    private function storeUploadedDocumentsIfNeeded(Collection $documents, string $uploadsSubFolder)
    {
        //Create the folder if it does not exist
        $relativeSubFolder = self::$uploadsFolderName.$uploadsSubFolder;
        $absoluteUploadsFolder = public_path($relativeSubFolder);
        if(!file_exists($absoluteUploadsFolder))
        {
            if(!mkdir($absoluteUploadsFolder, 0777, true)) throw new \RuntimeException('Could not create the folder in which to store the uploads: '.$absoluteUploadsFolder);
        }

        //Validate that each of the documents are Documents
        $documents->each(function($document) {
            if(!is_a($document, Document::class)) throw new \RuntimeException('One of the uploaded files wasn\'t an uploaded instance of "'.self::forModelName.'" while it should');
        });

        //Store them in the desired location
        $documents = $documents->map(function(Document $document) use ($absoluteUploadsFolder, $relativeSubFolder, &$keyPathPairs) {
            if($document->file == null) return $document; //Skip this iteration as there is not file to store

            $allowedFileClasses = [
                File::class,
                UploadedFile::class
            ];
            if(!in_array(get_class($document->file), $allowedFileClasses)) throw new \RuntimeException('A document must either have a file instance of one these classes: '.implode(', ', $allowedFileClasses).'. But was: '.get_class($document->file));

            if($document->file instanceof File)
            {
                $name = $document->file->getFilename();
                $extension = $document->file->getExtension();
            } else if ($document->file instanceof UploadedFile) {
                $name = $document->file->getClientOriginalName();
                $extension = $document->file->getClientOriginalExtension();
            }

            $documentNameFirstPart = pathinfo($name, PATHINFO_FILENAME);

            $documentNameAndExtension = str_slug($documentNameFirstPart.'_'.microtime()).'.'.$extension;

            $document->file->move($absoluteUploadsFolder, $documentNameAndExtension);
//            dd('documentsservice 194'. $document->file, $absoluteUploadsFolder, $documentNameAndExtension, $relativeSubFolder.DIRECTORY_SEPARATOR.$documentNameAndExtension);
            $document->file = null; //After the file is moved to its permanent location, the uploaded file cannot be used anymore. That why we null it
            $document->file_system_path = $relativeSubFolder.DIRECTORY_SEPARATOR.$documentNameAndExtension;

            if($document->name == '') $document->name = $documentNameFirstPart;

            return $document;
        });

        return $documents;
    }

    /**
     * Deletes documents which have state Document::STATE_DELETED
     *
     * @param Collection $documents
     * @param string $uploadsSubFolder
     * @return mixed
     */
    private function deleteDocumentsIfNeeded(Collection $documents, string $uploadsSubFolder)
    {
        //Validate that each of the documents are Documents
        $documents->each(function($document) {
            if(!is_a($document, Document::class)) throw new \RuntimeException('One of the uploaded files wasn\'t an uploaded instance of "'.Document::class.'" while it should');
        });

        //Get the documents to delete
        $documentsToDelete = $documents->filter(function(Document $document) {
            return ($document->state == Document::STATE_DELETED);
        });

        $this->deleteDocuments($documentsToDelete);

        //and return the documents that are not to be deleted
        return $documents->filter(function(Document $document) {
            return ($document->state != Document::STATE_DELETED);
        });
    }

    /**
     * Deletes all documents for the model given
     *
     * @param DocumentableInterface|Document $model
     */
    public function deleteDocumentsForModel($model)
    {
        if(is_a($model, DocumentableInterface::class)) {
            $documents = $model->documents()->get();
        }
        else if (is_a($model, self::forModelName))
        {
            $documents = collect($model);
        } else {
            throw new \RuntimeException("The model '".get_class($model)."' must be either an instance of '".self::forModelName."' or on instance of '".DocumentableInterface::class."' but was '".get_class($model)."'");
        }

        $this->deleteDocuments($documents);
    }

    /**
     * Deletes the specified documents and their related files
     *
     * @param $documents
     */
    public static function deleteDocuments(Collection $documents)
    {
        $documentIdsToDestroy = [];
        $documents->each(function ($document) use (&$documentIdsToDestroy) {
            /** @var $document Document */

            $attributes = $document->getAttributes();
            foreach($attributes as $attributeName => $attributeValue)
            {
                $absolutePath = public_path($document->$attributeName);
                if ($absolutePath != public_path() && file_exists($absolutePath)) {
                    if (!unlink($absolutePath)) {
                        throw new \RuntimeException('DocumentService: Could not delete file: ' . $absolutePath);
                    }
                }

                //Image paths
                $paths = [
                    $document->thumb_image_url,
                    $document->small_image_url,
                    $document->medium_image_url,
                    $document->large_image_url,
                ];

                foreach($paths as $path)
                {
                    if($path == '' || $path == null) continue;
                    $path = str_replace('/', DIRECTORY_SEPARATOR, $path); //Convert url to relative path
                    $absolute_path = public_path($path);
                    if ($absolutePath != public_path() && file_exists($absolutePath)) {
                        if (!unlink($absolutePath)) {
                            throw new \RuntimeException('DocumentService: Could not delete file: ' . $absolutePath);
                        }
                    }
                }
            }

            $documentIdsToDestroy[] = $document->id;
        });

        (self::forModelName)::destroy($documentIdsToDestroy);
    }

    /**
     * Uses the key (for example Documents-documents) to scan for uploaded documents by appending -1, -2, -3 etc to the key name.
     * and then using the \Input facade to retrieve it. Then stores all the uploaded files in their corresponding Document models.
     * Also gets the json version of all models in a form using $key input field, looks at those json versions state attributes
     * and either gets them from the database or makes a new document.
     *
     * @param string $key
     * @return Collection
     */
    private function getDocumentsUsingAttributeKey(string $key):Collection
    {
        if(!\Input::get($key)) throw new \RuntimeException('The documentService expects a input field with name "'.$key.'"');

        $documents = [];

//        $inputFieldNames = array_keys(\Input::all());
//        dd($inputFieldNames);

        $documentsData = json_decode(\Input::get($key), true);

        if(!is_array($documentsData)) throw new \RuntimeException('Expected input to have a field called "'.$key.'". That could be decoded as an array. Actual: '.\Input::get($key));
        foreach($documentsData as $documentData)
        {
            /** @var Document|null $document */
            $documentData = (array) $documentData; //Typecasts object to array. Because json_decode only did convert the array of documents but not the documents themselves.
            if(!isset($documentData['state'])) throw new \RuntimeException('Object version of a document must have a transient state attribute, but did not have it.');
            if(!isset($documentData['id'])) throw new \RuntimeException('Object version of a document must have an id attribute, but did not have it.');
            if(!isset($documentData['sort_order'])) throw new \RuntimeException('Object version of a document must have an sort_order attribute, but did not have it.');

            $document = null;
            $file = null;

            if($documentData['state'] == Document::STATE_MODIFIED)
            {
                //retrieve original document and re-fill it.
                $document = Document::find($documentData['id']);
                $document->state = Document::STATE_MODIFIED;
            }
            elseif($documentData['state'] == Document::STATE_DELETED)
            {
                //retrieve original document and re-fill it.
                $document = Document::find($documentData['id']);
                $document->state = Document::STATE_DELETED;
            }
            elseif($documentData['state'] == Document::STATE_PRISTINE)
            {
                //We don't need to do anything with pristine documents :) since nothing changed on them.
            }


            if(!$document) continue;

            if(isset($documentData['id'])) unset($documentData['id']);
            $document->fill($documentData);
            if($file) {
                $document->file = $file;
                $document->mime_type = $file->getMimeType();
            }

            $documents[] = $document;
        }

        $documents = collect($documents);

        //filter out new documents without file and path
        $documents = $documents->filter(function(Document $document) {
            return $document->file != null || $document->file_system_path != null;
        });

        return $documents;
    }

    /**
     * Returns the images from input that needs to be deleted.
     * TODO: make me work
     *
     * @param string $key
     * @return Collection
     */
    private function getFilesToDelete(string $key):Collection
    {
        //Collect documents
        $documents = [];

        $counter = 1;
        while(\Input::has($key.'-to-delete'))
        {
            $documents[$key.'-to-delete'] = \Input::get($key.'-to-delete');
            $counter++;
        }

        return collect($documents);
    }

    /**
     * Resize and save an image to the server.
     * Return the path to the resized image. Or false if it could not.
     *
     * @param ImageProperty $imageProperty
     * @param $file string The path to a file
     * @return string|false
     * @throws \Exception
     */
//    protected function formatImage($name, $file)
    protected function formatImage(ImageProperty $imageProperty, string $file)
    {
        //If there is no name, return empty string
        if ($imageProperty->getName() == '') {
            return false;
        }

        if (!is_string($file)) {
            $wasA = (is_object($file)) ? $wasA = get_class($file) : gettype($file);
            throw new \InvalidArgumentException("The image must be a string but wasn't. It was a: " . $wasA);
        }

        if (!file_exists($file)) {
            throw new \InvalidArgumentException('The image with path "' . $file . '" does not exist');
        }

        if($this->checkIfImageDimensionsAreToBig($file)) {
            return false;
        }

        //NOTE: if you get an exception like this: UnexpectedValueException in Common.php line 196. Unable to open file (filename).
        //You must set gd.jpeg_ignore_warning in php ini to 1.

        if(!$this->imageCropper->open($file)) return false;
        $this->imageCropper->enableProgressive(true);

//        $image->useFallback(false); //don't use a fallback. to make sure we never hit it by fixing bugs that trigger the fallback

        $path_parts = pathinfo($file);

        $fileName = $path_parts['dirname'].DIRECTORY_SEPARATOR.$path_parts['filename'].'_' . $imageProperty->getName();
        if(isset($path_parts['extension']) && $path_parts['extension'] !== '') $fileName .= '.'.$path_parts['extension'];

        if(file_exists($fileName)) return $fileName; //File already exists. Don't reprocess again

        //switch between the methods
        switch ($imageProperty->getCropMethod()) {
            case ImageProperty::Fit:
                //This will fit the image between the set with and height, but keep de aspect ratio, and saves it to the server.
                $this->imageCropper->fit($imageProperty->getWidth(), $imageProperty->getHeight() == 0 ? null : $imageProperty->getHeight());
                $this->imageCropper->save($fileName);
                break;
            case ImageProperty::Crop:
                //This will crop the image between the set with and height, and saves it to the server.
                $this->imageCropper->crop($imageProperty->getWidth(), $imageProperty->getHeight());
                $this->imageCropper->save($fileName);
                break;
            case ImageProperty::Resize:
                //This will crop the image between the set with and height, and saves it to the server.
                $this->imageCropper->resize($imageProperty->getWidth(), $imageProperty->getHeight());
                $this->imageCropper->save($fileName);
                break;
        }

        $this->imageCropper->destroy();
        return $fileName;
    }

    /**
     * Returns true when the image has more then 400.000 (2000 x 2000 px for example) pixels.
     * When it has, the risk on a memory exhaustion error is too big, and you should not process it.
     * Returns false if it has less
     * @param string $file
     * @return bool
     */
    private function checkIfImageDimensionsAreToBig(string $file)
    {
        $sizeData = getimagesize($file);
        $width = $sizeData[0];
        $height = $sizeData[1];
//        Log::debug('Width: '.$width.' height: '.$height.' = '.($width * $height).'. More then 4000000? '.((($width * $height) > 4000000) ? 'true' : 'false'));
        return (($width * $height) > 4000000);
    }

    /**
     * Deletes all documents with non existing files.
     */
    public static function doHouseKeeping()
    {
        self::pruneDocumentsWithNonExistingFiles();
        self::pruneAbandonedHTML5UploadedDocuments();
    }

    /**
     * Deletes all documents which refer to a non existing file.
     */
    private static function pruneDocumentsWithNonExistingFiles()
    {
        $modelsToDestroy = collect();

        $documents = (self::forModelName)::all(['id', 'path']);
        $documents->each(function($document) use (&$modelsToDestroy) {
            /** @var Document $document */

            $absoluteFilePath = public_path($document->file_system_path);
            if(!file_exists($absoluteFilePath)) {
                $modelsToDestroy->push($document);
            }
        });

        self::deleteDocuments($modelsToDestroy);
    }

    /**
     * Deletes the documents that where uploaded by HTML 5, that are not saved within the sessions lifetime.
     * Users outside of this lifetime can be considered unauthenticated, so they did not save their files.
     *
     * You can recognize documents that are not saved because they have an documentable id of 0 and a documentable type that is an empty string.
     */
    private static function pruneAbandonedHTML5UploadedDocuments()
    {
        //Delete abandoned uploads
//        $documents = (self::forModelName)::where('documentable_id', '=', 0)->where('documentable_type', '=', '')->where('created_at', '<=', Carbon::now()->subMinutes(config('session.lifetime')))->get();
        $documents = (self::forModelName)::where('documentable_id', '=', 0)->where('documentable_type', '=', '')->where('created_at', '<=', Carbon::now())->get(); //for debug purposes
        self::deleteDocuments($documents);
    }

    /**
     * Does have a look at the paths in the collection and resizes them if needed.
     * Note: The image properties names without "image_url" suffixed to it, must correspond to the column names of the Documents model table.
     * If not an exception is thrown. Note 2: You must save the documents yourself.
     *
     * @param Collection $documents
     * @param Documents|array $attributeOrImageProperties
     * @return Collection
     */
    private function processImagesIfNeeded(Collection $documents, $attributeOrImageProperties)
    {
        //Get an array of ImageProperties (into $imagePropertiesToUse) from the specified Documents attribute, array of ImageProperties or from this class if either one not valid or present.
        $imagePropertiesToUse = $this->defaultImageProperties;
        if($attributeOrImageProperties instanceof  Documents) {
            if(count($attributeOrImageProperties->getImageProperties()) > 0) $imagePropertiesToUse = $attributeOrImageProperties->getImageProperties();
            else if(is_array($attributeOrImageProperties))
            {
                $valid = true;
                foreach ($attributeOrImageProperties as $imageProperty) {
                    if(!$imageProperty instanceof ImageProperty)
                    {
                        $valid = false;
                        break;
                    }
                }

                if($valid) $imagePropertiesToUse = $attributeOrImageProperties;
            }
        } elseif(is_array($attributeOrImageProperties) && count($attributeOrImageProperties) > 0) {
            $imagePropertiesToUse = $attributeOrImageProperties;
        }

        $documents->map(function(Document $document, $collectionKey) use ($imagePropertiesToUse, $documents) {
            //Check if the documents file is a image. If it isn't or it can't be determined we return the document and we don't process the image

            if($document->file_system_path !== null)
            {
                if(!$this->pathIsImage($document->file_system_path)) return $document;
            } else {
                return $document;
            }

            //Check if at least one of the image properties has a value on the document. If so, we don't need to process the image and can skip to the next document
            foreach ($imagePropertiesToUse as $imageProperty) {
                $imagePropertyName = $imageProperty->getName();
                $attributeName = $imagePropertyName . '_image_url';
                if($document->$attributeName != '') return $document;
            }

                //Check if each image property's database column exists and format the image for that column
            $thumbnailSet = false;
            foreach ($imagePropertiesToUse as $imageProperty) {
                /** @var ImageProperty $imageProperty */
                $imagePropertyName = $imageProperty->getName();
                if($imagePropertyName === 'thumb') $thumbnailSet = true;
                $attributeName = $imagePropertyName . '_image_url';

                if (\Schema::hasColumn($document->getTable(), $attributeName)) {
                    $processedImagePath = $this->formatImage($imageProperty, public_path($document->file_system_path));

                    if ($processedImagePath) {
                        $document->$attributeName = str_replace(public_path(), '', $processedImagePath);
                        $document->$attributeName = str_replace('\\', '/', $document->$attributeName);
                    } else {
                        throw new \RuntimeException('The image could not be formatted');
                    }
                } else {
                    throw new \RuntimeException('The "' . $document->getTable() . '" table is expected to have the column "' . $attributeName . '" according to the corresponding image property with name "' . $imagePropertyName . '"');
                }
            }

            //Set thumbnail if not given. We always need one. Even when  the image properties don't specify a thumb
            if(!$thumbnailSet){
                $thumbnailProperty = (new ImageProperty())->setName('thumb')->setCropMethod(ImageProperty::Fit)->setWidth(128);
                $attributeName = 'thumb_image_url';
                if (\Schema::hasColumn($document->getTable(), $attributeName)) {

                    $processedImagePath = $this->formatImage($thumbnailProperty, public_path($document->file_system_path));

                    if ($processedImagePath) {
                        $document->$attributeName = str_replace(public_path(), '', $processedImagePath);
                        $document->$attributeName = str_replace('\\', '/', $document->$attributeName);
                    } else {
                        throw new \RuntimeException('The image could not be formatted');
                    }
                } else {
                    throw new \RuntimeException('The "' . $document->getTable() . '" table is expected to have the column "' . $attributeName . '" according to the corresponding image property with name "thumb"');
                }
            }

            return $document;
        });


        return $documents;
    }

    /**
     * Checks if the path most likely is an image
     *
     * @param string $path
     * @return bool
     */
    private function pathIsImage(string $path):bool
    {
        $pathInfo = pathInfo($path);
        $extension = strtolower($pathInfo['extension']);

        switch ($extension)
        {
            case 'bmp':
            case 'gif':
            case 'jpg':
            case 'jpeg':
            case 'png':
            case 'wbmp':
            case 'xpm':
            case 'webp':
                return true;
            default:
                return false;
        }
    }
}