*/ protected $fillable = [ 'title', 'user_id', 'mime_type', 'permission', 'storage', 'description', 'name', 'size', 'status', ]; /** * The attributes that are appended. * * @var array */ protected $appends = [ 'url', ]; /** * The default attributes. * * @var string[] */ protected $attributes = [ 'storage' => 'cdn', 'variants' => '[]', 'description' => '', 'dimensions' => '', 'permission' => '', 'thumbnail' => '', ]; /** * The storage file list cache. * * @var array */ protected static $storageFileListCache = []; /** * Object variant details. * * @var int[][][] */ protected static $objectVariants = [ 'image' => [ 'small' => ['width' => 300, 'height' => 225], 'medium' => ['width' => 768, 'height' => 576], 'large' => ['width' => 1024, 'height' => 768], 'xlarge' => ['width' => 1536, 'height' => 1152], 'xxlarge' => ['width' => 2048, 'height' => 1536], 'scaled' => ['width' => 2560, 'height' => 1920] ] ]; /** * Staging file path of asset for processing. * * @var string */ private $stagingFilePath = ""; /** * Model Boot * * @return void */ protected static function boot(): void { parent::boot(); $clearCache = function ($media) { Cache::forget("media:{$media->id}"); }; static::updating(function ($media) use ($clearCache) { $clearCache($media); if (array_key_exists('permission', $media->getChanges()) === true) { $origPermission = $media->getOriginal()['permission']; $newPermission = $media->permission; $newPermissionLen = strlen($newPermission); if ($newPermissionLen !== strlen($origPermission)) { if ($newPermissionLen === 0) { $this->moveToStorage('cdn'); } else { $this->moveToStorage('private'); } } } }); static::deleting(function ($media) use ($clearCache) { $clearCache($media); $media->deleteFile(); }); } /** * Get Object Variants. * * @param string $type The variant object to get. * @return array The variant data. */ public static function getObjectVariants(string $type): array { if (isset(self::$objectVariants[$type]) === true) { return self::$objectVariants[$type]; } return []; } /** * Variants Get Mutator. * * @param mixed $value The value to mutate. * @return array|null The mutated value. */ public function getVariantsAttribute(mixed $value): array|null { if (is_string($value) === true) { return json_decode($value, true); } return []; } /** * Variants Set Mutator. * * @param mixed $value The value to mutate. * @return void */ public function setVariantsAttribute(mixed $value): void { if (is_array($value) !== true) { $value = []; } $this->attributes['variants'] = json_encode(($value ?? [])); } /** * Get previous variant. * * @param string $type The variant type. * @param string $variant The initial variant. * @return string The previous variant name (or ''). */ public function getPreviousVariant(string $type, string $variant): string { if (isset(self::$objectVariants[$type]) === false) { return ''; } $variants = self::$objectVariants[$type]; $keys = array_keys($variants); $currentIndex = array_search($variant, $keys); if ($currentIndex === false || $currentIndex === 0) { return ''; } return $keys[($currentIndex - 1)]; } /** * Get next variant. * * @param string $type The variant type. * @param string $variant The initial variant. * @return string The next variant name (or ''). */ public function getNextVariant(string $type, string $variant): string { if (isset(self::$objectVariants[$type]) === false) { return ''; } $variants = self::$objectVariants[$type]; $keys = array_keys($variants); $currentIndex = array_search($variant, $keys); if ($currentIndex === false || $currentIndex === (count($keys) - 1)) { return ''; } return $keys[($currentIndex + 1)]; } /** * Get variant URL. * * @param string $variant The variant to find. * @param boolean $returnNearest Return the nearest variant if request is not found. * @return string The URL. */ public function getVariantURL(string $variant, bool $returnNearest = true): string { $variants = $this->variants; if (isset($variants[$variant]) === true) { return self::getUrlPath() . $variants[$variant]; } if ($returnNearest === true) { $variantType = explode('/', $this->mime_type)[0]; $previousVariant = $variant; while (empty($previousVariant) === false) { $previousVariant = $this->getPreviousVariant($variantType, $previousVariant); if (empty($previousVariant) === false && isset($variants[$previousVariant]) === true) { return self::getUrlPath() . $variants[$previousVariant]; } } } return ''; } /** * Delete file and associated files with the modal. * * @return void */ public function deleteFile(): void { if (strlen($this->storage) > 0 && strlen($this->name) > 0 && Storage::disk($this->storage)->exists($this->name) === true) { Storage::disk($this->storage)->delete($this->name); } $this->deleteThumbnail(); $this->deleteVariants(); $this->invalidateCFCache(); } /** * Invalidate Cloudflare Cache. * * @return void */ private function invalidateCFCache(): void { $zone_id = env("CLOUDFLARE_ZONE_ID"); $api_key = env("CLOUDFLARE_API_KEY"); if ($zone_id !== null && $api_key !== null && $this->url !== "") { $urls = [$this->url]; foreach ($this->variants as $variant => $name) { $urls[] = str_replace($this->name, $name, $this->url); } $curl = curl_init(); curl_setopt_array($curl, [ CURLOPT_URL => "https://api.cloudflare.com/client/v4/zones/" . $zone_id . "/purge_cache", CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => "DELETE", CURLOPT_POSTFIELDS => json_encode(["files" => $urls]), CURLOPT_HTTPHEADER => [ "Content-Type: application/json", "Authorization: Bearer " . $api_key ], ]); curl_exec($curl); curl_close($curl); }//end if } /** * Get URL path * * @return string */ public function getUrlPath(): string { $url = config("filesystems.disks.$this->storage.url"); return "$url/"; } /** * Return the file URL * * @return string */ public function getUrlAttribute(): string { if (isset($this->attributes['name']) === true) { return self::getUrlPath() . $this->name; } return ''; } /** * Return the file owner * * @return BelongsTo */ public function user(): BelongsTo { return $this->belongsTo(User::class); } /** * Move files to new storage device. * * @param string $storage The storage ID to move to. * @return void */ public function moveToStorage(string $storage): void { if ($storage !== $this->storage && Config::has("filesystems.disks.$storage") === true) { $this->status = "Processing media"; MoveMediaJob::dispatch($this, $storage)->onQueue('media'); $this->save(); } } /** * Transform the media through the Media Job Queue * * @param array $transform The transform data. * @param boolean $silent Update the medium progress through its status field. * @return void */ public function transform(array $transform, bool $silent = false): void { foreach ($transform as $key => $value) { if (is_string($value) === true) { if (preg_match('/^rotate-(-?\d+)$/', $value, $matches) !== false) { unset($transform[$key]); $transform['rotate'] = $matches[1]; } elseif (preg_match('/^flip-([vh]|vh|hv)$/', $value, $matches) !== false) { unset($transform[$key]); $transform['flip'] = $matches[1]; } elseif (preg_match('/^crop-(\d+)-(\d+)$/', $value, $matches) !== false) { unset($transform[$key]); $transform['crop'] = ['width' => $matches[1], 'height' => $matches[2]]; } elseif (preg_match('/^crop-(\d+)-(\d+)-(\d+)-(\d+)$/', $value, $matches) !== false) { unset($transform[$key]); $transform['crop'] = ['width' => $matches[1], 'height' => $matches[2], 'x' => $matches[3], 'y' => $matches[4]]; } } } try { MediaJob::dispatch($this, $transform, $silent)->onQueue('media'); } catch (\Exception $e) { $this->error('Failed to transform media'); throw $e; }//end try } /** * Download the file from the storage to the user. * * @param string $variant The variant to download or null if none. * @param boolean $fallback Fallback to the original file if the variant is not found. * @return JsonResponse|StreamedResponse The response. * @throws BindingResolutionException The Exception. */ public function download(string $variant = null, bool $fallback = true) { $path = $this->name; if ($variant !== null) { if (array_key_exists($variant, $this->variant) === true) { $path = $this->variant[$variant]; } else { return response()->json( ['message' => 'The resource was not found.'], HttpResponseCodes::HTTP_NOT_FOUND ); } } $disk = Storage::disk($this->storage); if ($disk->exists($path) === true) { $stream = $disk->readStream($path); $response = response()->stream( function () use ($stream) { fpassthru($stream); }, 200, [ 'Content-Type' => $this->mime_type, 'Content-Length' => $disk->size($path), 'Content-Disposition' => 'attachment; filename="' . basename($path) . '"', ] ); return $response; } return response()->json(['message' => 'The resource was not found.'], HttpResponseCodes::HTTP_NOT_FOUND); } /** * Get the server maximum upload size * * @return integer */ public static function getMaxUploadSize(): int { $sizes = [ ini_get('upload_max_filesize'), ini_get('post_max_size'), ini_get('memory_limit') ]; foreach ($sizes as &$size) { $size = trim($size); $last = strtolower($size[(strlen($size) - 1)]); switch ($last) { case 'g': $size = (intval($size) * 1024); // Size is in MB - fallthrough case 'm': $size = (intval($size) * 1024); // Size is in KB - fallthrough case 'k': $size = (intval($size) * 1024); // Size is in B - fallthrough } } return min($sizes); } /** * Generate a file name that is available within storage. * * @param string $fileName The proposed file name. * @return string|boolean The available file name or false if failed. */ public static function generateUniqueFileName(string $fileName) { $index = 1; $maxTries = 100; $extension = pathinfo($fileName, PATHINFO_EXTENSION); $fileName = static::sanitizeFilename(pathinfo($fileName, PATHINFO_FILENAME)); if ( static::fileNameHasSuffix($fileName) === true || static::fileExistsInStorage("$fileName.$extension") === true || Media::where('name', "$fileName.$extension")->where('status', 'not like', 'failed%')->exists() === true ) { $fileName .= '-'; for ($i = 1; $i < $maxTries; $i++) { $fileNameIndex = $fileName . $index; if ( static::fileExistsInStorage("$fileNameIndex.$extension") !== true && Media::where('name', "$fileNameIndex.$extension") ->where('status', 'not like', 'Failed%') ->exists() !== true ) { return "$fileNameIndex.$extension"; } ++$index; } return false; } return "$fileName.$extension"; } /** * Determines if the file name exists in any of the storage disks. * * @param string $fileName The file name to check. * @param boolean $ignoreCache Ignore the file list cache. * @return boolean If the file exists on any storage disks. */ public static function fileExistsInStorage(string $fileName, bool $ignoreCache = false): bool { $disks = array_keys(Config::get('filesystems.disks')); if ($ignoreCache === false) { if (count(static::$storageFileListCache) === 0) { $disks = array_keys(Config::get('filesystems.disks')); foreach ($disks as $disk) { try { static::$storageFileListCache[$disk] = Storage::disk($disk)->allFiles(); } catch (\Exception $e) { Log::error("Cannot get a file list for storage device '$disk'. Error: " . $e->getMessage()); continue; } } } foreach (static::$storageFileListCache as $disk => $files) { if (in_array($fileName, $files) === true) { return true; } } } else { $disks = array_keys(Config::get('filesystems.disks')); foreach ($disks as $disk) { try { if (Storage::disk($disk)->exists($fileName) === true) { return true; } } catch (\Exception $e) { Log::error($e->getMessage()); throw new \Exception("Cannot verify if file '$fileName' already exists in storage device '$disk'"); } } }//end if return false; } /** * Test if the file name contains a special suffix. * * @param string $fileName The file name to test. * @return boolean If the file name contains the special suffix. */ public static function fileNameHasSuffix(string $fileName): bool { $suffix = '/(-\d+x\d+|-scaled|-thumb)$/i'; $fileNameWithoutExtension = pathinfo($fileName, PATHINFO_FILENAME); return preg_match($suffix, $fileNameWithoutExtension) === 1; } /** * Sanitize fileName for upload * * @param string $fileName Filename to sanitize. * @return string */ private static function sanitizeFilename(string $fileName): string { /* # file system reserved https://en.wikipedia.org/wiki/Filename#Reserved_characters_and_words [<>:"/\\\|?*]| # control characters http://msdn.microsoft.com/en-us/library/windows/desktop/aa365247%28v=vs.85%29.aspx [\x00-\x1F]| # non-printing characters DEL, NO-BREAK SPACE, SOFT HYPHEN [\x7F\xA0\xAD]| # URI reserved https://www.rfc-editor.org/rfc/rfc3986#section-2.2 [#\[\]@!$&\'()+,;=]| # URL unsafe characters https://www.ietf.org/rfc/rfc1738.txt [{}^\~`] */ $fileName = preg_replace( '~ [<>:"/\\\|?*]| [\x00-\x1F]| [\x7F\xA0\xAD]| [#\[\]@!$&\'()+,;=]| [{}^\~`] ~x', '-', $fileName ); $fileName = ltrim($fileName, '.-'); $fileName = preg_replace([ // "file name.zip" becomes "file-name.zip" '/ +/', // "file___name.zip" becomes "file-name.zip" '/_+/', // "file---name.zip" becomes "file-name.zip" '/-+/' ], '-', $fileName); $fileName = preg_replace([ // "file--.--.-.--name.zip" becomes "file.name.zip" '/-*\.-*/', // "file...name..zip" becomes "file.name.zip" '/\.{2,}/' ], '.', $fileName); // lowercase for windows/unix interoperability http://support.microsoft.com/kb/100625 $fileName = mb_strtolower($fileName, mb_detect_encoding($fileName)); // ".file-name.-" becomes "file-name" $fileName = trim($fileName, '.-'); $ext = pathinfo($fileName, PATHINFO_EXTENSION); $fileName = mb_strcut( pathinfo($fileName, PATHINFO_FILENAME), 0, (255 - ($ext !== '' ? strlen($ext) + 1 : 0)), mb_detect_encoding($fileName) ) . ($ext !== '' ? '.' . $ext : ''); return $fileName; } /** * Get the Staging File path. * * @param boolean $create Create staging file if doesn't exist. * @return string */ public function getStagingFilePath(bool $create = true): string { if ($this->stagingFilePath === "" && $create === true) { $this->createStagingFile(); } 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->storage)->readStream($this->name); $filePath = generateTempFilePath(pathinfo($this->name, PATHINFO_EXTENSION)); $writeStream = fopen($filePath, 'w'); while (feof($readStream) !== true) { fwrite($writeStream, fread($readStream, 8192)); } fclose($readStream); fclose($writeStream); $this->stagingFilePath = $filePath; }//end if return $this->stagingFilePath !== ""; } /** * Save the Staging File to storage * * @param boolean $delete Delete the existing staging file. * @param boolean $silent Update the status field with the progress. * @return void */ public function saveStagingFile(bool $delete = true, bool $silent = false): 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); if ($silent === false) { $this->status('Uploading to CDN'); } $fileSystem->putFileAs('/', $this->stagingFilePath, $this->name); } if ($silent === false) { $this->status('Generating Thumbnail'); } $this->generateThumbnail(); if ($silent === false) { $this->status('Generating Variants'); } $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. * * @return boolean If generation was successful. */ public function generateThumbnail(): bool { $thumbnailWidth = 200; $thumbnailHeight = 200; // delete existing thumbnail if (strlen($this->thumbnail) !== 0) { $path = substr($this->thumbnail, strlen($this->getUrlPath())); if (strlen($path) > 0 && Storage::disk($this->storage)->exists($path) === true) { Storage::disk($this->storage)->delete($path); } } $filePath = $this->getStagingFilePath(); $fileExtension = File::extension($this->name); $tempImagePath = tempnam(sys_get_temp_dir(), 'thumb'); $newFilename = pathinfo($this->name, PATHINFO_FILENAME) . "-" . uniqid() . "-thumb.webp"; $success = false; if (strpos($this->mime_type, 'image/') === 0) { $image = Image::make($filePath); $image->resize($thumbnailWidth, $thumbnailHeight, function ($constraint) { $constraint->aspectRatio(); }); $image->fit($thumbnailWidth, $thumbnailHeight); $image->encode('webp', 75)->save($tempImagePath); $success = true; } elseif ($this->mime_type === 'application/pdf' && extension_loaded('imagick') === true) { $pdfPreview = new \Imagick(); $pdfPreview->setResolution(300, 300); $pdfPreview->readImage($filePath . '[0]'); $pdfPreview->setImageFormat('webp'); $pdfPreview->thumbnailImage($thumbnailWidth, $thumbnailHeight, true); file_put_contents($tempImagePath, $pdfPreview); $success = true; } elseif ($this->mime_type === 'text/plain') { $image = Image::canvas($thumbnailWidth, $thumbnailHeight, '#FFFFFF'); // Read the first few lines of the text file $numLines = 5; $text = file_get_contents($filePath); $lines = explode("\n", $text); $previewText = implode("\n", array_slice($lines, 0, $numLines)); // Center the text on the image $fontSize = 8; $textColor = '#000000'; // Black text color // Calculate the position to start drawing the text $x = 10; // Left padding $y = 10; // Top padding // Draw the text on the canvas with text wrapping $lines = explode("\n", wordwrap($previewText, 30, "\n", true)); foreach ($lines as $line) { $image->text($line, $x, $y, function ($font) use ($fontSize, $textColor) { $font->file(1); $font->size($fontSize); $font->color($textColor); }); // Move to the next line $y += ($fontSize + 4); // Add some vertical spacing between lines (adjust as needed) } $image->encode('webp', 75)->save($tempImagePath); $success = true; } elseif (strpos($this->mime_type, 'video/') === 0) { $tempImagePath .= '.webp'; try { $ffmpeg = FFMpeg::create(); $video = $ffmpeg->open($filePath); $frame = $video->frame(TimeCode::fromSeconds(5)); $frame->save($tempImagePath); } catch(\Exception $e) { Log::error($e); } $success = true; }//end if if ($success === true && file_exists($tempImagePath) === true) { /** @var Illuminate\Filesystem\FilesystemAdapter */ $fileSystem = Storage::disk($this->storage); $fileSystem->putFileAs('/', new SplFileInfo($tempImagePath), $newFilename); unlink($tempImagePath); $this->thumbnail = $this->getUrlPath() . $newFilename; } else { $iconExtension = 'unknown'; if ($fileExtension !== '') { $iconPath = public_path('assets/fileicons/' . $fileExtension . '.webp'); if (file_exists($iconPath) === true) { $iconExtension = $fileExtension; } } $this->thumbnail = asset('/assets/fileicons/' . $iconExtension . '.webp'); } return $success; } /** * Delete Media Thumbnail from storage. * * @return void */ public function deleteThumbnail(): void { if (strlen($this->thumbnail) > 0) { $path = substr($this->thumbnail, strlen($this->getUrlPath())); if (strlen($path) > 0 && Storage::disk($this->storage)->exists($path) === true) { Storage::disk($this->storage)->delete($path); $this->thumbnail = ''; // Clear the thumbnail property } } } /** * Generate variants for this media. * * @return void */ public function generateVariants(): void { if (strpos($this->mime_type, 'image/') === 0) { // Generate additional image sizes $sizes = Media::getObjectVariants('image'); // download original from CDN if no local file $filePath = $this->getStagingFilePath(); // delete existing variants if (is_array($this->variants) === true) { foreach ($this->variants as $variantName => $variantFile) { if (Storage::disk($this->storage)->exists($variantFile) === true) { Storage::disk($this->storage)->delete($variantFile); } } } $this->variants = []; $originalImage = Image::make($filePath); $imageSize = $originalImage->getSize(); $isPortrait = $imageSize->getHeight() > $imageSize->getWidth(); // Swap width and height values for portrait images foreach ($sizes as $variantName => &$size) { if ($isPortrait === true) { $temp = $size['width']; $size['width'] = $size['height']; $size['height'] = $temp; } } $dimensions = [$originalImage->getWidth(), $originalImage->getHeight()]; $this->dimensions = implode('x', $dimensions); foreach ($sizes as $variantName => $size) { $postfix = "{$size['width']}x{$size['height']}"; if ($variantName === 'scaled') { $postfix = 'scaled'; } $newFilename = pathinfo($this->name, PATHINFO_FILENAME) . "-" . uniqid() . "-$postfix.webp"; // Get the largest available variant if ($dimensions[0] >= $size['width'] && $dimensions[1] >= $size['height']) { // Store the variant in the variants array $variants[$variantName] = $newFilename; // Resize the image to the variant size if its dimensions are greater than the // specified size $image = clone $originalImage; $imageSize = $image->getSize(); if ($imageSize->getWidth() > $size['width'] || $imageSize->getHeight() > $size['height']) { $image->resize($size['width'], $size['height'], function ($constraint) { $constraint->aspectRatio(); $constraint->upsize(); }); $image->resizeCanvas($size['width'], $size['height'], 'center', false, 'rgba(0,0,0,0)'); } $image->orientate(); // Optimize and store image $tempImagePath = tempnam(sys_get_temp_dir(), 'optimize'); $image->encode('webp', 75)->save($tempImagePath); /** @var Illuminate\Filesystem\FilesystemAdapter */ $fileSystem = Storage::disk($this->storage); $fileSystem->putFileAs('/', new SplFileInfo($tempImagePath), $newFilename); unlink($tempImagePath); }//end if }//end foreach // Set missing variants to the largest available variant foreach ($sizes as $variantName => $size) { if (isset($variants[$variantName]) === false) { $variants[$variantName] = $this->name; } } $this->variants = $variants; }//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(); } }