File: D:/HostingSpaces/SBogers10/farmfun.komma.pro/app/Komma/Documents/Kms/DocumentService.php
<?php
namespace App\Komma\Documents\Kms;
use App\Helpers\KommaHelpers;
use App\Komma\Documents\Models\Document;
use App\Komma\Kms\Core\AbstractModelHandler;
use App\Komma\Kms\Core\Attributes\Attribute;
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 App\Komma\Kms\ImageProcessing\CropperInterface;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\File;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* Class DocumentService
*
* Manages uploads
*/
final class DocumentService extends AbstractModelHandler implements CanDoHousekeepingInterface, DocumentServiceInterface
{
public static $uploadsFolderName = DIRECTORY_SEPARATOR.'uploads'.DIRECTORY_SEPARATOR;
protected $sortable = false;
/**
* @var ImageProperty[]
*/
protected $defaultImageProperties;
/** @var string $modelClassName */
const modelClassName = Document::class;
/**
* @var CropperInterface
*/
private $imageCropper;
/**
* DocumentService constructor.
*/
public 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);
}
public function save(Model $model, Collection $attributes = null): Model
{
if ($attributes === null) {
return $model;
}
$this->checkContainsAttributes($attributes);
if (! is_a($model, DocumentableInterface::class)) {
return $model;
}
$attributes->each(function (Attribute $attribute) use ($model) {
if (! is_a($attribute, Documents::class)) {
return;
}
/** @var $model DocumentableInterface */
$valueReference = $attribute->getsValueFrom();
if ($valueReference !== Attribute::ValueFromDocuments) {
return $model;
} //Continue to next iteration. We are not responsible for this attribute
$this->processUploadedDocumentsForModel($model, $attribute);
});
return $model;
}
public function load(Model $model, Collection $attributes = null): Collection
{
if ($attributes === null) {
return new Collection();
}
$this->checkContainsAttributes($attributes);
if (! is_a($model, DocumentableInterface::class)) {
return $attributes;
}
return $attributes->map(function (Attribute $attribute) use ($model) {
if (! is_a($attribute, Documents::class)) {
return $attribute;
}
/** @var $model DocumentableInterface */
$key = KommaHelpers::getShortNameFromClass($attribute).'-'.$attribute->getKey()->getValuePart();
$value = json_encode($model->documents()->where('key', '=', $key)->get());
$attribute->setValue($value);
return $attribute;
});
}
/**
* 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::modelClassName.'" 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();
} elseif ($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 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::modelClassName)::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
*/
public 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;
}
/**
* Destroys a Eloquent model or collection of models
*
* @param Model $model
* @return Model
*/
public function destroyForModel(Model $model)
{
if (is_a($model, DocumentableInterface::class)) {
/** @var DocumentableInterface $model */
self::deleteDocuments($model->documents()->get());
}
return $model;
}
/**
* 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::modelClassName)::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::modelClassName)::where('documentable_id', '=', 0)->where('documentable_type', '=', '')->where('created_at', '<=', Carbon::now()->subMinutes(config('session.lifetime')))->get();
$documents = (self::modelClassName)::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();
} elseif (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;
}
}
}