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/medvalue.komma.pro/app/KommaApp/Documents/Kms/DocumentService.php
<?php

namespace App\KommaApp\Documents\Kms;

use App\KommaApp\Documents\Models\Document;
use App\KommaApp\Images\CropperInterface;
use App\KommaApp\Kms\Core\Attributes\Documents;
use App\KommaApp\Kms\Core\Attributes\Models\ImageProperty;
use App\KommaApp\Kms\Core\HouseKeeping\CanDoHousekeepingInterface;
use App\KommaApp\Kms\Core\Sections\AbstractAttributeKey;
use Illuminate\Http\File;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
use Illuminate\Database\Eloquent\Model;
use PhpParser\Comment\Doc;

/**
 * Class DocumentService
 *
 * Manages uploads
 *
 * @package App\KommaApp\Documents\Kms
 */
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('original')->setCropMethod(ImageProperty::CropFit)->setWidth(2000)->setHeight(8000), //exhausts php memory on some installations and causes errors like this: Allowed memory size of 134217728 bytes exhausted (tried to allocate 38400001 bytes or internal server errors
            (new ImageProperty())->setName('thumb')->setCropMethod(ImageProperty::Fit)->setWidth(100),
            (new ImageProperty())->setName('small')->setCropMethod(ImageProperty::Fit)->setWidth(240),
            (new ImageProperty())->setName('medium')->setCropMethod(ImageProperty::Fit)->setWidth(460),
            (new ImageProperty())->setName('large')->setCropMethod(ImageProperty::Fit)->setWidth(500),
        ];

        $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
     */
    public function processUploadedDocumentsForModel(Model $model, Documents $attribute)
    {

        $documents = $this->getDocumentsUsingAttributeKey($attribute->getKey());
        $documents = $this->storeUploadedDocumentsIfNeeded($documents, $attribute->getSubFolder());
        $documents = $this->deleteDocumentsIfNeeded($documents, $attribute->getSubFolder());
        $documents = $this->processImagesIfNeeded($documents, $attribute);

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

    /**
     * Link a specific file to a model as a document.
     *
     * @param Model $model
     * @param string $absoluteFileSourcePath
     * @param string $uploadsSubFolder
     * @param ImageProperty[] $imageProperties
     */
    public function linkFileToModelAsDocument(Model $model, string $absoluteFileSourcePath, string $uploadsSubFolder, array $imageProperties)
    {
        $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);
    }

    /**
     * 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
     */
    private function saveDocumentsForModel(Collection $documents, $model)
    {
        $documents->each(function(Document $document) use ($model) {
            $document->documentable()->associate($model);
            $document->save(); //Only saves documents which "original" attributes do not match the ones in "attributes".
        });
    }

    /**
     * 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 = $documentNameFirstPart.'_'.time().'.'.$extension;

            $document->file->move($absoluteUploadsFolder, $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->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
     */
    private 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('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.'-data' 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::has($key.'-data')) throw new \RuntimeException('The documentService expects a input field with name "'.$key.'-data"');

        $documents = [];

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

        $documentsData = json_decode(\Input::get($key.'-data'));

        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_NEW)
            {
                //retrieve uploaded file and store it in the document
                $document = new Document();
                $document->state = Document::STATE_NEW;

                //Use the documents sort order and key to get the corresponding file
                $fileFieldName = $key.'-'.$documentData['sort_order'];

                $file = \Input::file($fileFieldName);
            }
            elseif($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->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
     *
     * @param ImageProperty $imageProperty
     * @param $file string|\Symfony\Component\HttpFoundation\File\UploadedFile
     * @return string
     * @throws \Exception
     */
//    protected function formatImage($name, $file)
    protected function formatImage(ImageProperty $imageProperty, $file)
    {
        //If there is no name, return empty string
        if ($imageProperty->getName() == '') {
            return '';
        }

        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 wit path "'.$file.'" does not exist');
        }

        //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'];

        //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);

//                dd('fit: '.$this->options[$name]['width']." x ".$this->options[$name]['height']);
                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() == 0 ? null : $imageProperty->getWidth(),
                    $imageProperty->getHeight() == 0 ? null : $imageProperty->getHeight());
                $this->imageCropper->save($fileName);

                break;
        }

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

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

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

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

            $absoluteFilePath = public_path($document->path);
            if(!file_exists($absoluteFilePath)) {
                $modelsToDestroy[] = $document->id;
            }
        });

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

    /**
     * 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;
            }

        $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->path !== null)
            {
                if(!$this->pathIsImage($document->path)) return $document;
            } else {
                return $document;
            }

            foreach ($imagePropertiesToUse as $imageProperty) {
                /** @var ImageProperty $imageProperty */
                $attributeName = $imageProperty->getName() . '_image_url';
                if (\Schema::hasColumn($document->getTable(), $attributeName)) {
                    $processedImagePath = $this->formatImage($imageProperty, public_path($document->path));
                    if ($processedImagePath) {
                        $document->$attributeName = str_replace(public_path(), '', $processedImagePath);
                        $document->$attributeName = str_replace('\\', '/', $document->$attributeName);
                    }
                } else {
                    throw new \RuntimeException('The "' . $document->getTable() . '" table is expected to have the column "' . $attributeName . '" according to the corresponding image property with name "' . $imageProperty->getName() . '"');
                }
            }

            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;
        }
    }
}