File: D:/HostingSpaces/SBogers10/stafa.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,
$document->xlarge_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;
}
}
}