updated to media management

This commit is contained in:
2023-08-24 22:14:43 +10:00
parent 60a15c2227
commit d7529cef80
9 changed files with 718 additions and 202 deletions

View File

@@ -38,3 +38,20 @@ function arrayLimitKeys(array $arr, array $keys): array
{ {
return array_intersect_key($arr, array_flip($keys)); return array_intersect_key($arr, array_flip($keys));
} }
/**
* Return an array value or default value if it does not exist
*
* @param string $key The key value to return if exists.
* @param array $arr The array to check.
* @param mixed $value The value to return if key does not exist.
* @return mixed
*/
function arrayDefaultValue(string $key, array $arr, mixed $value): mixed
{
if (array_key_exists($key, $arr) === true) {
return $arr[$key];
}
return $value;
}

27
app/Helpers/TypeValue.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
/* Type Value Helper Functions */
/**
* Is value true
*
* @param mixed $value Value to check.
* @return boolean
*/
function isTrue(mixed $value): bool
{
if (is_bool($value) === true && $value === true) {
return true;
}
if (is_numeric($value) === true && intval($value) === 1) {
return true;
}
if (is_string($value) === true && in_array(strtolower($value), ['true', '1'], true) === true) {
return true;
}
return false;
}

View File

@@ -6,9 +6,10 @@ use App\Conductors\MediaConductor;
use App\Enum\HttpResponseCodes; use App\Enum\HttpResponseCodes;
use App\Http\Requests\MediaRequest; use App\Http\Requests\MediaRequest;
use App\Models\Media; use App\Models\Media;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Laravel\Sanctum\PersonalAccessToken; use Laravel\Sanctum\PersonalAccessToken;
class MediaController extends ApiController class MediaController extends ApiController
@@ -67,45 +68,62 @@ class MediaController extends ApiController
*/ */
public function store(MediaRequest $request) public function store(MediaRequest $request)
{ {
if (MediaConductor::creatable() === true) { if (MediaConductor::creatable() === false) {
$file = $request->file('file'); return $this->respondForbidden();
if ($file === null) { }
return $this->respondWithErrors(['file' => 'The browser did not upload the file correctly to the server.']);
$file = $request->file('file');
if ($file === null) {
return $this->respondWithErrors(['file' => 'The browser did not upload the file correctly to the server.']);
}
$jsonResult = $this->validateFileItem($file);
if ($jsonResult !== null) {
return $jsonResult;
}
$request->merge([
'title' => $request->get('title', ''),
'name' => '',
'size' => $file->getSize(),
'mime_type' => $file->getMimeType(),
'status' => '',
]);
// We store images by default locally
if ($request->get('storage') === null) {
if (strpos($file->getMimeType(), 'image/') === 0) {
$request->merge([
'storage' => 'local',
]);
} else {
$request->merge([
'storage' => 'cdn',
]);
} }
}
if ($file->isValid() !== true) { $mediaItem = $request->user()->media()->create($request->except(['file','transform']));
switch ($file->getError()) {
case UPLOAD_ERR_INI_SIZE:
case UPLOAD_ERR_FORM_SIZE:
return $this->respondTooLarge();
case UPLOAD_ERR_PARTIAL:
return $this->respondWithErrors(['file' => 'The file upload was interrupted.']);
default:
return $this->respondWithErrors(['file' => 'An error occurred uploading the file to the server.']);
}
}
if ($file->getSize() > Media::getMaxUploadSize()) { $temporaryFilePath = generateTempFilePath();
return $this->respondTooLarge(); copy($file->path(), $temporaryFilePath);
}
try { $transformData = ['file' => [
$media = Media::createFromUploadedFile($request, $file); 'path' => $temporaryFilePath,
} catch (\Exception $e) { 'size' => $file->getSize(),
if ($e->getCode() === Media::FILE_SIZE_EXCEEDED_ERROR) { 'mime_type' => $file->getMimeType(),
return $this->respondTooLarge(); ]
} else { ];
return $this->respondWithErrors(['file' => $e->getMessage()]); if ($request->has('transform') === true) {
} $transformData = array_merge($transformData, array_map('trim', explode(',', $request->get('transform'))));
} }
return $this->respondAsResource( $mediaItem->transform($transformData);
MediaConductor::model($request, $media),
['respondCode' => HttpResponseCodes::HTTP_ACCEPTED]
);
}//end if
return $this->respondForbidden(); return $this->respondAsResource(
MediaConductor::model($request, $mediaItem),
['respondCode' => HttpResponseCodes::HTTP_ACCEPTED]
);
} }
/** /**
@@ -117,43 +135,42 @@ class MediaController extends ApiController
*/ */
public function update(MediaRequest $request, Media $medium) public function update(MediaRequest $request, Media $medium)
{ {
if (MediaConductor::updatable($medium) === true) { if (MediaConductor::updatable($medium) === false) {
$file = $request->file('file'); return $this->respondForbidden();
if ($file !== null) { }
if ($file->isValid() !== true) {
switch ($file->getError()) {
case UPLOAD_ERR_INI_SIZE:
case UPLOAD_ERR_FORM_SIZE:
return $this->respondTooLarge();
case UPLOAD_ERR_PARTIAL:
return $this->respondWithErrors(['file' => 'The file upload was interrupted.']);
default:
return $this->respondWithErrors(['file' => 'An error occurred uploading the file to the server.']);
}
}
if ($file->getSize() > Media::getMaxUploadSize()) { $file = $request->file('file');
return $this->respondTooLarge(); if ($file !== null) {
} $jsonResult = $this->validateFileItem($file);
if ($jsonResult !== null) {
return $jsonResult;
} }
}
$medium->update($request->all()); $medium->update($request->except(['file','transform']));
if ($file !== null) { $transformData = [];
try { if ($file !== null) {
$medium->updateWithUploadedFile($file); $temporaryFilePath = generateTempFilePath();
} catch (\Exception $e) { copy($file->path(), $temporaryFilePath);
return $this->respondWithErrors(
['file' => $e->getMessage()],
HttpResponseCodes::HTTP_INTERNAL_SERVER_ERROR
);
}
}
return $this->respondAsResource(MediaConductor::model($request, $medium)); $transformData = array_merge($transformData, ['file' => [
}//end if 'path' => $temporaryFilePath,
'size' => $file->getSize(),
'mime_type' => $file->getMimeType(),
]
]);
}
return $this->respondForbidden(); if ($request->has('transform') === true) {
$transformData = array_merge($transformData, array_map('trim', explode(',', $request->get('transform'))));
}
if (count($transformData) > 0) {
$medium->transform($transformData);
}
return $this->respondAsResource(MediaConductor::model($request, $medium));
} }
/** /**
@@ -251,4 +268,32 @@ class MediaController extends ApiController
return response()->file($path, $headers); return response()->file($path, $headers);
} }
/**
* Validate a File item in a request is valid
*
* @param UploadedFile $file The file to validate.
* @param string $errorKey The error key to use.
* @return JsonResponse|null
*/
private function validateFileItem(UploadedFile $file, string $errorKey = 'file'): JsonResponse|null
{
if ($file->isValid() !== true) {
switch ($file->getError()) {
case UPLOAD_ERR_INI_SIZE:
case UPLOAD_ERR_FORM_SIZE:
return $this->respondTooLarge();
case UPLOAD_ERR_PARTIAL:
return $this->respondWithErrors([$errorKey => 'The file upload was interrupted.']);
default:
return $this->respondWithErrors([$errorKey => 'An error occurred uploading the file to the server.']);
}
}
if ($file->getSize() > Media::getMaxUploadSize()) {
return $this->respondTooLarge();
}
return null;
}
} }

224
app/Jobs/MediaJob.php Normal file
View File

@@ -0,0 +1,224 @@
<?php
namespace App\Jobs;
use App\Models\Media;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use FFMpeg;
use FFMpeg\Coordinate\Dimension;
use Intervention\Image\Facades\Image;
class MediaJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
/**
* Media item
*
* @var Media
*/
protected $media;
/**
* Actions to make on the Media
*
* @var array
*/
protected $actions;
/**
* Create a new job instance.
*
* @param Media $media The media model.
* @param array $actions The media actions to make.
* @return void
*/
public function __construct(Media $media, array $actions)
{
$this->media = $media;
$this->actions = $actions;
}
/**
* Execute the job.
*
* @return void
*/
public function handle(): void
{
try {
// FILE
if (array_key_exists("file", $this->actions) === true) {
$uploadData = $this->actions["file"];
if (array_key_exists("path", $uploadData) === false || file_exists($uploadData["path"]) === false) {
$this->media->error("Upload file does not exist");
return;
}
$filePath = $uploadData["path"];
// convert HEIC files to JPG
$fileExtension = File::extension($filePath);
if ($fileExtension === 'heic') {
// Get the path without the file name
$uploadedFileDirectory = dirname($filePath);
// Convert the HEIC file to JPG
$jpgFileName = pathinfo($filePath, PATHINFO_FILENAME) . '.jpg';
$jpgFilePath = $uploadedFileDirectory . '/' . $jpgFileName;
if (file_exists($jpgFilePath) === true) {
$this->media->error("File already exists in storage");
return;
}
Image::make($filePath)->save($jpgFilePath);
// Update the uploaded file path and file name
unlink($filePath);
$filePath = $jpgFilePath;
$this->media->name = $jpgFileName;
$this->media->save();
}
// Check if file already exists
if (Storage::disk($this->media->storage)->exists($this->media->name) === true) {
if (array_key_exists('replace', $uploadData) === false || isTrue($uploadData['replace']) === false) {
$errorStr = "cannot upload file {$this->media->storage} " . // phpcs:ignore
"/ {$this->media->name} as it already exists";
Log::info($errorStr);
throw new \Exception($errorStr);
}
}
$this->media->setStagingFile($filePath);
}//end if
$this->media->createStagingFile();
$this->media->deleteFile();
// Modifications
if (strpos($this->media->mime_type, 'image/') === 0) {
$image = Image::make($filePath);
// ROTATE
if (array_key_exists("rotate", $this->actions) === true) {
$rotate = intval($this->actions["rotate"]);
if ($rotate !== 0) {
$image = $image->rotate($rotate);
}
}
// FLIP-H/V
if (array_key_exists('flip', $this->actions) === true) {
if (stripos($this->actions['flip'], 'h') !== false) {
$image = $image->flip('h');
}
if (stripos($this->actions['flip'], 'v') !== false) {
$image = $image->flip('v');
}
}
// CROP
if (array_key_exists("crop", $this->actions) === true) {
$cropData = $this->actions["crop"];
$width = intval(arrayDefaultValue("width", $cropData, $image->getWidth()));
$height = intval(arrayDefaultValue("height", $cropData, $image->getHeight()));
$x = intval(arrayDefaultValue("x", $cropData, 0));
$y = intval(arrayDefaultValue("y", $cropData, 0));
$image = $image->crop($width, $height, $x, $y);
}//end if
$image->save($filePath);
} elseif (strpos($this->media->mime_type, 'video/') === 0) {
$ffmpeg = FFMpeg\FFMpeg::create();
$video = $ffmpeg->open($this->media->getStagingFilePath());
/** @var FFMpeg\Media\Video::filters */
$filters = $video->filters();
// ROTATE
if (array_key_exists("rotate", $this->actions) === true) {
$rotate = intval($this->actions["rotate"]);
$rotate = (($rotate % 360 + 360) % 360); // remove excess rotations
$rotate = (round($rotate / 90) * 90); // round to nearest 90%
if ($rotate > 0) {
if ($rotate === 90) {
$filters->rotate(FFMpeg\Filters\Video\RotateFilter::ROTATE_90);
} elseif ($rotate === 190) {
$filters->rotate(FFMpeg\Filters\Video\RotateFilter::ROTATE_180);
} elseif ($rotate === 270) {
$filters->rotate(FFMpeg\Filters\Video\RotateFilter::ROTATE_270);
}
}
}
// FLIP-H/V
if (array_key_exists('flip', $this->actions) === true) {
if (stripos($this->actions['flip'], 'h') !== false) {
$filters->hflip()->synchronize();
}
if (stripos($this->actions['flip'], 'v') !== false) {
$filters->vflip()->synchronize();
}
}
// CROP
if (array_key_exists("crop", $this->actions) === true) {
$cropData = $this->actions["crop"];
$videoStream = $video->getStreams()->videos()->first();
$width = intval(arrayDefaultValue("width", $cropData, $videoStream->get('width')));
$height = intval(arrayDefaultValue("height", $cropData, $videoStream->get('height')));
$x = intval(arrayDefaultValue("x", $cropData, 0));
$y = intval(arrayDefaultValue("y", $cropData, 0));
$cropDimension = new Dimension($width, $height);
$filters->crop($cropDimension, $x, $y)->synchronize();
}//end if
$tempFilePath = tempnam(sys_get_temp_dir(), 'video-');
$video->save(null, $tempFilePath);
$this->media->changeStagingFile($tempFilePath);
}//end if
// Move file
if (array_key_exists("move", $this->actions) === true) {
if (array_key_exists("storage", $this->actions["move"]) === true) {
$newStorage = $this->actions["move"]["storage"];
if ($this->media->storage !== $newStorage) {
if (Storage::has($newStorage) === true) {
$this->media->storage = $newStorage;
} else {
$this->media->error("Cannot move file to '{$newStorage}' as it does not exist");
}
}
}
}
// Finish media object
$this->media->saveStagingFile();
$this->media->ok();
} catch (\Exception $e) {
Log::error($e->getMessage());
$this->media->error("Failed");
$this->fail($e);
}//end try
}
}

View File

@@ -48,6 +48,8 @@ class MoveMediaJob implements ShouldQueue
/** /**
* Execute the job. * Execute the job.
*
* @return void
*/ */
public function handle(): void public function handle(): void
{ {

View File

@@ -48,6 +48,13 @@ class StoreUploadedFileJob implements ShouldQueue
*/ */
protected $replaceExisting; protected $replaceExisting;
/**
* Modifications to make on the Media
*
* @var array
*/
protected $modifications;
/** /**
* Create a new job instance. * Create a new job instance.
@@ -55,13 +62,15 @@ class StoreUploadedFileJob implements ShouldQueue
* @param Media $media The media model. * @param Media $media The media model.
* @param string $filePath The uploaded file. * @param string $filePath The uploaded file.
* @param boolean $replaceExisting Replace existing files. * @param boolean $replaceExisting Replace existing files.
* @param array $modifications The modifications to make on the media.
* @return void * @return void
*/ */
public function __construct(Media $media, string $filePath, bool $replaceExisting = true) public function __construct(Media $media, string $filePath, bool $replaceExisting = true, array $modifications = [])
{ {
$this->media = $media; $this->media = $media;
$this->uploadedFilePath = $filePath; $this->uploadedFilePath = $filePath;
$this->replaceExisting = $replaceExisting; $this->replaceExisting = $replaceExisting;
$this->modifications = $modifications;
} }
/** /**

View File

@@ -3,11 +3,14 @@
namespace App\Models; namespace App\Models;
use App\Enum\HttpResponseCodes; use App\Enum\HttpResponseCodes;
use App\Jobs\MediaJob;
use App\Jobs\MoveMediaJob; use App\Jobs\MoveMediaJob;
use App\Jobs\StoreUploadedFileJob; use App\Jobs\StoreUploadedFileJob;
use App\Traits\Uuids; use App\Traits\Uuids;
use Exception;
use Illuminate\Contracts\Container\BindingResolutionException; use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\InvalidCastException;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Foundation\Bus\DispatchesJobs; use Illuminate\Foundation\Bus\DispatchesJobs;
@@ -18,7 +21,13 @@ use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Intervention\Image\Exception\NotSupportedException;
use Intervention\Image\Exception\NotWritableException;
use ImagickException;
use Intervention\Image\Facades\Image; use Intervention\Image\Facades\Image;
use InvalidArgumentException;
use Psr\Container\NotFoundExceptionInterface;
use Psr\Container\ContainerExceptionInterface;
use SplFileInfo; use SplFileInfo;
use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpFoundation\StreamedResponse;
@@ -82,13 +91,12 @@ class Media extends Model
protected static $storageFileListCache = []; protected static $storageFileListCache = [];
/** /**
* The variant types. * Object variant details.
* *
* @var int[][][] * @var int[][][]
*/ */
protected static $variantTypes = [ protected static $objectVariants = [
'image' => [ 'image' => [
'thumb' => ['width' => 150, 'height' => 150],
'small' => ['width' => 300, 'height' => 225], 'small' => ['width' => 300, 'height' => 225],
'medium' => ['width' => 768, 'height' => 576], 'medium' => ['width' => 768, 'height' => 576],
'large' => ['width' => 1024, 'height' => 768], 'large' => ['width' => 1024, 'height' => 768],
@@ -98,6 +106,13 @@ class Media extends Model
] ]
]; ];
/**
* Staging file path of asset for processing.
*
* @var string
*/
private $stagingFilePath = "";
/** /**
* Model Boot * Model Boot
@@ -138,15 +153,15 @@ class Media extends Model
} }
/** /**
* Get Type Variants. * Get Object Variants.
* *
* @param string $type The variant type to get. * @param string $type The variant object to get.
* @return array The variant data. * @return array The variant data.
*/ */
public static function getTypeVariants(string $type): array public static function getObjectVariants(string $type): array
{ {
if (isset(self::$variantTypes[$type]) === true) { if (isset(self::$objectVariants[$type]) === true) {
return self::$variantTypes[$type]; return self::$objectVariants[$type];
} }
return []; return [];
@@ -191,11 +206,11 @@ class Media extends Model
*/ */
public function getPreviousVariant(string $type, string $variant): string public function getPreviousVariant(string $type, string $variant): string
{ {
if (isset(self::$variantTypes[$type]) === false) { if (isset(self::$objectVariants[$type]) === false) {
return ''; return '';
} }
$variants = self::$variantTypes[$type]; $variants = self::$objectVariants[$type];
$keys = array_keys($variants); $keys = array_keys($variants);
$currentIndex = array_search($variant, $keys); $currentIndex = array_search($variant, $keys);
@@ -215,11 +230,11 @@ class Media extends Model
*/ */
public function getNextVariant(string $type, string $variant): string public function getNextVariant(string $type, string $variant): string
{ {
if (isset(self::$variantTypes[$type]) === false) { if (isset(self::$objectVariants[$type]) === false) {
return ''; return '';
} }
$variants = self::$variantTypes[$type]; $variants = self::$objectVariants[$type];
$keys = array_keys($variants); $keys = array_keys($variants);
$currentIndex = array_search($variant, $keys); $currentIndex = array_search($variant, $keys);
@@ -265,18 +280,12 @@ class Media extends Model
*/ */
public function deleteFile(): void public function deleteFile(): void
{ {
$fileName = $this->name; if (strlen($this->storage) > 0 && strlen($this->name) > 0 && Storage::disk($this->storage)->exists($this->name) === true) {
$baseName = pathinfo($fileName, PATHINFO_FILENAME); Storage::disk($this->storage)->delete($this->name);
$extension = pathinfo($fileName, PATHINFO_EXTENSION);
$files = Storage::disk($this->storage)->files();
foreach ($files as $file) {
if (preg_match("/{$baseName}(-[a-zA-Z0-9]+)?\.{$extension}/", $file) === 1) {
Storage::disk($this->storage)->delete($file);
}
} }
$this->deleteThumbnail();
$this->deleteVariants();
$this->invalidateCFCache(); $this->invalidateCFCache();
} }
@@ -363,96 +372,37 @@ class Media extends Model
} }
/** /**
* Create new Media from UploadedFile data. * Transform the media through the Media Job Queue
* *
* @param App\Models\Request $request The request data. * @param array $transform The transform data.
* @param Illuminate\Http\UploadedFile $file The file. * @return void
* @return null|Media The result or null if not successful.
*/ */
public static function createFromUploadedFile(Request $request, UploadedFile $file): ?Media public function transform(array $transform): void
{ {
$request->merge([ foreach ($transform as $key => $value) {
'title' => $request->get('title', ''), if (is_string($value) === true) {
'name' => '', if (preg_match('/^rotate-(-?\d+)$/', $value, $matches) !== false) {
'size' => 0, unset($transform[$key]);
'mime_type' => '', $transform['rotate'] = $matches[1];
'status' => '', } elseif (preg_match('/^flip-([vh]|vh|hv)$/', $value, $matches) !== false) {
]); unset($transform[$key]);
$transform['flip'] = $matches[1];
if ($request->get('storage') === null) { } elseif (preg_match('/^crop-(\d+)-(\d+)$/', $value, $matches) !== false) {
// We store images by default locally unset($transform[$key]);
if (strpos($file->getMimeType(), 'image/') === 0) { $transform['crop'] = ['width' => $matches[1], 'height' => $matches[2]];
$request->merge([ } elseif (preg_match('/^crop-(\d+)-(\d+)-(\d+)-(\d+)$/', $value, $matches) !== false) {
'storage' => 'local', unset($transform[$key]);
]); $transform['crop'] = ['width' => $matches[1], 'height' => $matches[2], 'x' => $matches[3], 'y' => $matches[4]];
} else { }
$request->merge([
'storage' => 'cdn',
]);
} }
} }
$mediaItem = $request->user()->media()->create($request->all());
$mediaItem->updateWithUploadedFile($file);
return $mediaItem;
}
/**
* Update Media with UploadedFile data.
*
* @param Illuminate\Http\UploadedFile $file The file.
* @return null|Media The media item.
*/
public function updateWithUploadedFile(UploadedFile $file): ?Media
{
if ($file === null || $file->isValid() !== true) {
throw new \Exception('The file is invalid.', self::INVALID_FILE_ERROR);
}
if ($file->getSize() > static::getMaxUploadSize()) {
throw new \Exception('The file size is larger then permitted.', self::FILE_SIZE_EXCEEDED_ERROR);
}
$name = static::generateUniqueFileName($file->getClientOriginalName());
if ($name === false) {
throw new \Exception('The file name already exists in storage.', self::FILE_NAME_EXISTS_ERROR);
}
// remove file if there is an existing entry in this medium item
if (strlen($this->name) > 0 && strlen($this->storage) > 0) {
Storage::disk($this->storage)->delete($this->name);
foreach ($this->variants as $variantName => $fileName) {
Storage::disk($this->storage)->delete($fileName);
}
$this->name = '';
$this->variants = [];
}
if (strlen($this->title) === 0) {
$this->title = $name;
}
$this->name = $name;
$this->size = $file->getSize();
$this->mime_type = $file->getMimeType();
$this->status = 'Processing media';
$this->save();
$temporaryFilePath = generateTempFilePath();
copy($file->path(), $temporaryFilePath);
try { try {
StoreUploadedFileJob::dispatch($this, $temporaryFilePath)->onQueue('media'); MediaJob::dispatch($this, $transform)->onQueue('media');
} catch (\Exception $e) { } catch (\Exception $e) {
$this->status = 'Error'; $this->error('Failed to transform media');
$this->save();
throw $e; throw $e;
}//end try }//end try
return $this;
} }
/** /**
@@ -625,7 +575,7 @@ class Media extends Model
*/ */
public static function fileNameHasSuffix(string $fileName): bool public static function fileNameHasSuffix(string $fileName): bool
{ {
$suffix = '/(-\d+x\d+|-scaled)$/i'; $suffix = '/(-\d+x\d+|-scaled|-thumb)$/i';
$fileNameWithoutExtension = pathinfo($fileName, PATHINFO_FILENAME); $fileNameWithoutExtension = pathinfo($fileName, PATHINFO_FILENAME);
return preg_match($suffix, $fileNameWithoutExtension) === 1; return preg_match($suffix, $fileNameWithoutExtension) === 1;
@@ -700,31 +650,124 @@ class Media extends Model
} }
/** /**
* Download temporary copy of the storage file. * Get the Staging File path.
* *
* @return string File path * @param boolean $create Create staging file if doesn't exist.
* @return string
*/ */
private function downloadTempFile(): string public function getStagingFilePath(bool $create = true): string
{ {
$readStream = Storage::disk($this->storageDisk)->readStream($this->name); if ($this->stagingFilePath === "" && $create === true) {
$filePath = tempnam(sys_get_temp_dir(), 'download-'); $this->createStagingFile();
$writeStream = fopen($filePath, 'w');
while (feof($readStream) !== true) {
fwrite($writeStream, fread($readStream, 8192));
} }
fclose($readStream);
fclose($writeStream);
return $filePath; return $this->stagingFilePath;
}
/**
* Set the Staging File for processing.
*
* @param string $path The path if the new staging file.
* @param boolean $overwrite Overwrite existing file.
* @return void
*/
public function setStagingFile(string $path, bool $overwrite = false): void
{
if ($this->stagingFilePath !== "") {
if ($overwrite === true) {
unlink($this->stagingFilePath);
} else {
// ignore request
return;
}
}
$this->stagingFilePath = $path;
}
/**
* Download temporary copy of the storage file for staging.
*
* @return boolean If download was successful.
*/
public function createStagingFile(): bool
{
if ($this->stagingFilePath !== "") {
$readStream = Storage::disk($this->storageDisk)->readStream($this->name);
$filePath = tempnam(sys_get_temp_dir(), 'download-');
$writeStream = fopen($filePath, 'w');
while (feof($readStream) !== true) {
fwrite($writeStream, fread($readStream, 8192));
}
fclose($readStream);
fclose($writeStream);
$this->stagingFilePath = $filePath;
}
return $this->stagingFilePath !== "";
}
/**
* Save the Staging File to storage
*
* @param boolean $delete Delete the existing staging file.
* @return void
*/
public function saveStagingFile(bool $delete = true): void
{
if (strlen($this->storage) > 0 && strlen($this->name) > 0) {
if (Storage::disk($this->storage)->exists($this->name) === true) {
Storage::disk($this->storage)->delete($this->name);
}
/** @var Illuminate\Filesystem\FilesystemAdapter */
$fileSystem = Storage::disk($this->storage);
$fileSystem->putFileAs('/', $this->stagingFilePath, $this->name);
}
$this->generateThumbnail();
$this->generateVariants();
if ($delete === true) {
$this->deleteStagingFile();
}
}
/**
* Clean up temporary file.
*
* @return void
*/
public function deleteStagingFile(): void
{
if ($this->stagingFilePath !== "") {
unlink($this->stagingFilePath);
$this->stagingFilePath = "";
}
}
/**
* Change staging file, removing the old file if present
*
* @param string $newFile The new staging file.
* @return void
*/
public function changeStagingFile(string $newFile): void
{
if ($this->stagingFilePath !== "") {
unlink($this->stagingFilePath);
}
$this->stagingFilePath = $newFile;
} }
/** /**
* Generate a Thumbnail for this media. * Generate a Thumbnail for this media.
* @param string $uploadedFilePath The local file, if present (else download from storage).
* *
* @return boolean If generation was successful. * @return boolean If generation was successful.
*/ */
public function generateThumbnail(string $uploadedFilePath = ""): bool public function generateThumbnail(): bool
{ {
$thumbnailWidth = 200; $thumbnailWidth = 200;
$thumbnailHeight = 200; $thumbnailHeight = 200;
@@ -737,11 +780,7 @@ class Media extends Model
} }
} }
// download original from CDN if no local file $filePath = $this->createStagingFile();
$filePath = $uploadedFilePath;
if ($uploadedFilePath === "") {
$filePath = $this->downloadTempFile();
}
$fileExtension = File::extension($this->name); $fileExtension = File::extension($this->name);
$tempImagePath = tempnam(sys_get_temp_dir(), 'thumb'); $tempImagePath = tempnam(sys_get_temp_dir(), 'thumb');
@@ -832,22 +871,35 @@ class Media extends Model
} }
/** /**
* Generate variants for this media. * Delete Media Thumbnail from storage.
* @param string $uploadedFilePath The local file, if present (else download from storage).
* *
* @return void * @return void
*/ */
public function generateVariants(string $uploadedFilePath = ""): void public function deleteThumbnail(): void
{
if (strlen($this->thumbnail) > 0) {
$path = substr($this->thumbnail, strlen($this->getUrlPath()));
if (strlen($path) > 0 && Storage::disk($this->storageDisk)->exists($path) === true) {
Storage::disk($this->storageDisk)->delete($path);
$this->thumbnail = ''; // Clear the thumbnail property
}
}
}
/**
* Generate variants for this media.
*
* @return void
*/
public function generateVariants(): void
{ {
if (strpos($this->media->mime_type, 'image/') === 0) { if (strpos($this->media->mime_type, 'image/') === 0) {
// Generate additional image sizes // Generate additional image sizes
$sizes = Media::getTypeVariants('image'); $sizes = Media::getObjectVariants('image');
// download original from CDN if no local file // download original from CDN if no local file
$filePath = $uploadedFilePath; $filePath = $this->createStagingFile();
if ($uploadedFilePath === "") {
$filePath = $this->downloadTempFile();
}
// delete existing variants // delete existing variants
if (is_array($this->variants) === true) { if (is_array($this->variants) === true) {
@@ -924,4 +976,53 @@ class Media extends Model
$this->variants = $variants; $this->variants = $variants;
}//end if }//end if
} }
/**
* Delete the Media variants from storage.
*
* @return void
*/
public function deleteVariants(): void
{
if (strlen($this->name) > 0 && strlen($this->storage) > 0) {
foreach ($this->variants as $variantName => $fileName) {
Storage::disk($this->storage)->delete($fileName);
}
$this->variants = [];
}
}
/**
* Set Media status to OK
*
* @return void
*/
public function ok(): void
{
$this->status = "OK";
$this->save();
}
/**
* Set Media status to an error
* @param string $error The error to set.
* @return void
*/
public function error(string $error = ""): void
{
$this->status = "Error" . ($error !== "" ? ": {$error}" : "");
$this->save();
}
/**
* Set Media status
* @param string $status The status to set.
* @return void
*/
public function status(string $status = ""): void
{
$this->status = "Info: " . $status;
$this->save();
}
} }

View File

@@ -36,7 +36,8 @@
"autoload": { "autoload": {
"files": [ "files": [
"app/Helpers/Array.php", "app/Helpers/Array.php",
"app/Helpers/Temp.php" "app/Helpers/Temp.php",
"app/Helpers/TypeValue.php"
], ],
"psr-4": { "psr-4": {
"App\\": "app/", "App\\": "app/",

View File

@@ -234,7 +234,7 @@
<img <img
:src="mediaGetThumbnail(lastSelected)" :src="mediaGetThumbnail(lastSelected)"
class="max-h-20 max-w-20 mr-2" /> class="max-h-20 max-w-20 mr-2" />
<div class="flex flex-col"> <div class="flex flex-col w-100">
<p class="m-0 text-bold"> <p class="m-0 text-bold">
{{ lastSelected.title }} {{ lastSelected.title }}
</p> </p>
@@ -260,13 +260,49 @@
</p> </p>
<p <p
v-if="allowEditSelected" v-if="allowEditSelected"
class="m-0 italic text-red-6 small cursor-pointer hover:underline"> class="flex gap-1">
<span <svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 cursor-pointer text-gray-6 hover:text-gray-4"
viewBox="0 0 24 24"
@click="
handleRotateLeft(
lastSelected,
)
">
<title>Rotate Left</title>
<path
d="M4 11C4 6.58 7.58 3 12 3L13 3.06V5.08L12 5C8.69 5 6 7.69 6 11H9L5 15L1 11H4M17 7H13C11.9 7 11 7.9 11 9V18C11 19.11 11.9 20 13 20H19C20.11 20 21 19.11 21 18V11L17 7M19 18H13V9H16V12H19V18Z"
fill="currentColor" />
</svg>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 cursor-pointer text-gray-6 hover:text-gray-4"
viewBox="0 0 24 24"
@click="
handleRotateRight(
lastSelected,
)
">
<title>Rotate Right</title>
<path
d="M20 11H23L19 15L15 11H18C18 7.69 15.31 5 12 5L11 5.08V3.06L12 3C16.42 3 20 6.58 20 11M9 7H5C3.9 7 3 7.9 3 9V18C3 19.11 3.9 20 5 20H11C12.11 20 13 19.11 13 18V11L9 7M11 18H5V9H8V12H11V18Z"
fill="currentColor" />
</svg>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 cursor-pointer text-red-6 hover:text-red-4 ml-auto"
viewBox="0 0 24 24"
@click=" @click="
handleDelete(lastSelected) handleDelete(lastSelected)
" ">
>Delete Permanently</span <title>
> Delete Permanently
</title>
<path
d="M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19M8,9H16V19H8V9M15.5,4L14.5,3H9.5L8.5,4H5V6H19V4H15.5Z"
fill="currentColor" />
</svg>
</p> </p>
</div> </div>
</div> </div>
@@ -1133,6 +1169,60 @@ const handleUpdate = () => {
} }
}; };
const handleRotateLeft = async (item: Media) => {
api.put({
url: "/media/{id}",
params: {
id: item.id,
},
body: {
transform: "rotate-270",
},
})
.then((result) => {
if (result.data) {
const data = result.data as MediaResponse;
const index = mediaItems.value.findIndex(
(mediaItem) => mediaItem.id === item.id,
);
if (index !== -1) {
mediaItems.value[index] = data.medium;
}
}
})
.catch(() => {
/* empty */
});
};
const handleRotateRight = async (item: Media) => {
api.put({
url: "/media/{id}",
params: {
id: item.id,
},
body: {
transform: "rotate-90",
},
})
.then((result) => {
if (result.data) {
const data = result.data as MediaResponse;
const index = mediaItems.value.findIndex(
(mediaItem) => mediaItem.id === item.id,
);
if (index !== -1) {
mediaItems.value[index] = data.medium;
}
}
})
.catch(() => {
/* empty */
});
};
const handleDelete = async (item: Media) => { const handleDelete = async (item: Media) => {
let result = await openDialog(SMDialogConfirm, { let result = await openDialog(SMDialogConfirm, {
title: "Delete File?", title: "Delete File?",