*/ 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 = []; /** * The variant types. * * @var int[][][] */ protected static $variantTypes = [ 'image' => [ 'thumb' => ['width' => 150, 'height' => 150], '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] ] ]; /** * 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 Type Variants. * * @param string $type The variant type to get. * @return array The variant data. */ public static function getTypeVariants(string $type): array { if (isset(self::$variantTypes[$type]) === true) { return self::$variantTypes[$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::$variantTypes[$type]) === false) { return ''; } $variants = self::$variantTypes[$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::$variantTypes[$type]) === false) { return ''; } $variants = self::$variantTypes[$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 { $fileName = $this->name; $baseName = pathinfo($fileName, PATHINFO_FILENAME); $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->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(); } } /** * Create new Media from UploadedFile data. * * @param App\Models\Request $request The request data. * @param Illuminate\Http\UploadedFile $file The file. * @return null|Media The result or null if not successful. */ public static function createFromUploadedFile(Request $request, UploadedFile $file): ?Media { $request->merge([ 'title' => $request->get('title', ''), 'name' => '', 'size' => 0, 'mime_type' => '', 'status' => '', ]); if ($request->get('storage') === null) { // We store images by default locally if (strpos($file->getMimeType(), 'image/') === 0) { $request->merge([ 'storage' => 'local', ]); } 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 { StoreUploadedFileJob::dispatch($this, $temporaryFilePath)->onQueue('media'); } catch (\Exception $e) { $this->status = 'Error'; $this->save(); throw $e; }//end try return $this; } /** * 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)$/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; } /** * Download temporary copy of the storage file. * * @return string File path */ private function downloadTempFile(): string { $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); return $filePath; } /** * Generate a Thumbnail for this media. * @param string $uploadedFilePath The local file, if present (else download from storage). * * @return boolean If generation was successful. */ public function generateThumbnail(string $uploadedFilePath = ""): 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->storageDisk)->exists($path) === true) { Storage::disk($this->storageDisk)->delete($path); } } // download original from CDN if no local file $filePath = $uploadedFilePath; if ($uploadedFilePath === "") { $filePath = $this->downloadTempFile(); } $fileExtension = File::extension($this->name); $tempImagePath = tempnam(sys_get_temp_dir(), 'thumb'); $newFilename = pathinfo($this->name, PATHINFO_FILENAME) . "-thumb.webp"; $success = false; $ffmpegPath = env('FFMPEG_PATH', '/usr/bin/ffmpeg'); 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 (file_exists($ffmpegPath) === true && strpos($this->mime_type, 'video/') === 0) { $tempImagePath .= '.webp'; $command = "$ffmpegPath -i $filePath -ss 00:00:05 -vframes 1 " . // phpcs:ignore "-s {$thumbnailWidth}x{$thumbnailHeight} -c:v webp {$tempImagePath}"; exec($command); $success = true; }//end if if ($success === true && file_exists($tempImagePath) === true) { /** @var Illuminate\Filesystem\FilesystemAdapter */ $fileSystem = Storage::disk($this->storageDisk); $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; } /** * Generate variants for this media. * @param string $uploadedFilePath The local file, if present (else download from storage). * * @return void */ public function generateVariants(string $uploadedFilePath = ""): void { if (strpos($this->media->mime_type, 'image/') === 0) { // Generate additional image sizes $sizes = Media::getTypeVariants('image'); // download original from CDN if no local file $filePath = $uploadedFilePath; if ($uploadedFilePath === "") { $filePath = $this->downloadTempFile(); } // delete existing variants if (is_array($this->variants) === true) { foreach ($this->variants as $variantName => $variantFile) { if (Storage::disk($this->storageDisk)->exists($variantFile) === true) { Storage::disk($this->storageDisk)->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) . "-$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->storageDisk); $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 } }