path($path); } /** * Destroy the file associate with the attribute. */ public static function removeFile(Model $model, string $attribute): void { $path = \ltrim(\urldecode((string) $model->{$attribute}), '/'); if (! str_starts_with($path, 'storage')) { return; } $path = Str::replace(self::STORAGE_PREFIX, '', $path); if (Storage::disk('public')->exists($path)) { Storage::disk('public')->delete($path); } } /** * Destroy the file associate with the attribute old value (before updated to DB). */ public static function removeOldFile(Model $model, string $attribute, bool $onlyIfUpdated = true): void { $originalValue = ToolboxHelper::getOriginal($model, $attribute); // * Ignore If removal has to be done only if the field has changed and it actually hasnt. if ($onlyIfUpdated && $model->{$attribute} === $originalValue) { return; } if (! is_string($originalValue)) { return; } $originalPath = \ltrim(\urldecode($originalValue), '/'); if (! str_starts_with($originalPath, 'storage')) { return; } $originalPath = Str::replace(self::STORAGE_PREFIX, '', $originalPath); if (Storage::disk('public')->exists($originalPath)) { Storage::disk('public')->delete($originalPath); } } /** * Store a file using optimized storage strategy * * You can use filename param to overload file name. * * @param Model $model The model that will hold the picture. * @param UploadedFile|\SplFileInfo|string $file The file, will just return itself is string. * @param bool $slugify Force filename slugification. * @param string|null $filename This string may be used to store the file. * @param bool $private To store the file in a private space. * * @throws FileStorageOperationException If file copy fails. * * @phpcs:disable Generic.Metrics.CyclomaticComplexity.TooHigh */ public static function storeFile( Model $model, UploadedFile | \SplFileInfo | string | null $file, bool $slugify = false, ?string $filename = null, bool $private = false ): string { // phpcs:enable $tableName = $model->getTable(); if ($file instanceof UploadedFile) { return self::storeUploadedFile($file, $tableName, $slugify, $filename, $private); } if ($file instanceof \SplFileInfo) { return self::storeSplFileInfo($file, $tableName, $slugify, $filename, $private); } return (string) $file; } private static function storeUploadedFile( UploadedFile $file, string $tableName, bool $slugify, ?string $filename, bool $private ): string { $filename = $filename ?: $file->getClientOriginalName(); $filename = $slugify ? self::slugifyFileName($filename) : $filename; $disk = $private ? 'private' : 'public'; $newPath = str_replace( storage_path("app/{$disk}"), '', self::prepareMoveFile($tableName, $filename, $private) ); $directory = pathinfo($newPath, \PATHINFO_DIRNAME); $basename = pathinfo($newPath, \PATHINFO_BASENAME); if (! $private) { $file->storePubliclyAs( $directory, $basename, ['disk' => $disk] ); } else { $file->storeAs( $directory, $basename, ['disk' => $disk] ); } return "storage{$newPath}"; } private static function storeSplFileInfo( \SplFileInfo $file, string $tableName, bool $slugify, ?string $filename, bool $private ): string { $filename = $filename ?: $file->getBaseName(); $filename = $slugify ? self::slugifyFileName($filename) : $filename; $newPath = self::prepareMoveFile($tableName, $filename, $private); if (! File::copy($file->getRealPath(), $newPath)) { throw FileStorageOperationException::copyFailed($file->getRealPath(), $newPath); } if ($private) { return \ltrim(str_replace(\storage_path('app/private'), '/storage/app/private', $newPath), '/'); } return \ltrim(str_replace(\storage_path('app/public'), '/storage', $newPath), '/'); } /** * Store all files from string attribute html * If oldHtmlString is passed, a diff is done with new and old files * to keep only files that are present in the new html string. * * @param Model $model The model that will hold the picture. * @param string|null $htmlString The html string to parse and modify files. * @param string|null $oldHtmlString The old html string with alreadyParsed * files using storeModelFilesFromHtml. * @return string|null Html with files pathes replaced using storage strategy. */ public static function storeModelFilesFromHtml( Model $model, ?string $htmlString, ?string $oldHtmlString = null ): ?string { if (is_null($htmlString)) { return null; } Collection::make(self::onlyUploadedFilesWithFileManager(self::findFilesInHtml($htmlString))) ->each(function (string $path) use ($model, &$htmlString): void { /** @var \Illuminate\Filesystem\FilesystemAdapter */ $disk = Storage::disk('private'); $fileInfo = new \SplFileInfo($disk->path( ToolboxHelper::mbReplaceSingle('/uploads/', '', \urldecode($path)) )); $htmlString = ToolboxHelper::mbReplaceSingle( $path, '/' . self::storeFile($model, $fileInfo, true), $htmlString ); }); if ($oldHtmlString) { self::removeOldModelFilesFromHtml($htmlString, $oldHtmlString); } return $htmlString; } /** * Remove all old files from string attribute html that are stored in public that * are nove present anymore on new html string * * @param string $newHtmlString The html string with alreadyParsed files using storeModelFilesFromHtml. * @param string $oldHtmlString The old html string with alreadyParsed files using storeModelFilesFromHtml. */ public static function removeOldModelFilesFromHtml(string $newHtmlString, string $oldHtmlString): void { self::removeOldFiles(self::findFilesInHtml($oldHtmlString), self::findFilesInHtml($newHtmlString)); } /** * Remove all old files that are stored in public that * are not present anymore on new array files * * @param string[] $oldFiles The old array files. * @param string[] $newFiles The new array files. */ public static function removeOldFiles(array $oldFiles, array $newFiles): void { $oldFiles = Collection::make(self::onlyUploadedFilesWithFileStorageHelper($oldFiles)); $filesToKeep = Collection::make(self::onlyUploadedFilesWithFileStorageHelper($newFiles)); $filesToRemove = $oldFiles->diff($filesToKeep); $filesToRemove->each(function (string $publicStorageFilePath): void { $path = Str::replace(self::STORAGE_PREFIX, '', \ltrim(\urldecode($publicStorageFilePath), '/')); if (Storage::disk('public')->exists($path)) { Storage::disk('public')->delete($path); } }); } /** * Remove all files from string attribute html that are stored in public * * @param string $htmlString The html string with alreadyParsed files using storeModelFilesFromHtml. */ public static function removeModelFilesOfHtml(string $htmlString): void { $filesToRemove = Collection::make(self::onlyUploadedFilesWithFileStorageHelper(self::findFilesInHtml($htmlString))); $filesToRemove->each(function (string $publicStorageFilePath): void { $path = Str::replace(self::STORAGE_PREFIX, '', \ltrim(\urldecode($publicStorageFilePath), '/')); if (Storage::disk('public')->exists($path)) { Storage::disk('public')->delete($path); } }); } /** * Find all occurence of files in html * * @param string $html The html string to parse and find files. * @return string[] The array of files found in html. */ public static function findFilesInHtml(string $html): array { preg_match_all( '/(?:href|src|url)="([^"]*)"/Ui', $html, $matches ); return Collection::make($matches[1])->unique()->filter()->values()->all(); } /** * Filter array to get only uploaded files that exists * on disk 'public' (using helper storage strategy) * * @param string[] $urls The array of urls to filter * @return string[] The filtered array with only uploaded files that exists on disk 'public */ public static function onlyUploadedFilesWithFileStorageHelper(array $urls): array { return Collection::make($urls)->unique() // * Filter to get only /uploads and using app url or empty ->filter(function (string $url) { $path = (string) (\parse_url($url, \PHP_URL_PATH) ?? ''); return str_starts_with(\ltrim($path, '/'), 'storage/modelfiles'); })->filter(function (string $url) { $path = (string) (\parse_url($url, \PHP_URL_PATH) ?? ''); return Storage::disk('public') ->exists(Str::replace(self::STORAGE_PREFIX, '', \ltrim(\urldecode($path), '/'))); })->all(); } /** * Filter array to get only uploaded files that exist on disk 'private' * * @param string[] $urls The array of urls to filter * @return string[] The filtered array */ private static function onlyUploadedFilesWithFileManager(array $urls): array { $appUrl = Config::string('app.url'); if (empty($appUrl)) { throw new \UnexpectedValueException('App URL must be set in config to filter uploaded files with file manager'); } $appHost = parse_url((string) $appUrl, PHP_URL_HOST); return Collection::make($urls)->unique()->filter(function (string $url) use ($appHost) { $host = parse_url($url, PHP_URL_HOST); // 1. Validation du domaine (Doit être identique à l'app ou être un chemin relatif) if ($host !== null && $host !== $appHost) { return false; } // 2. Extraction et nettoyage du chemin $path = ltrim(urldecode((string) parse_url($url, PHP_URL_PATH)), '/'); // 3. Vérification du préfixe 'uploads/' et existence sur le disque if (! str_starts_with($path, 'uploads/')) { return false; } // On retire 'uploads/' pour tester l'existence sur le disque 'private' $diskPath = substr($path, strlen('uploads/')); return Storage::disk('private')->exists($diskPath); })->values()->all(); } /** * Prepare storage path and return * the file full path * * @param string $tableName Model table name. * @param string $filename The basename for the file. * @param bool $private May the file be stored in a private dir. */ private static function prepareMoveFile(string $tableName, string $filename, bool $private = false): string { // * Security filter $filename = \htmlspecialchars($filename); // * Security filter + beautifier $filename = self::sanitizeFileName($filename); // * Slugify filename $ext = \pathinfo($filename, \PATHINFO_EXTENSION); $filename = Str::slug(\pathinfo($filename, \PATHINFO_FILENAME)) . (\strlen($ext) ? ".{$ext}" : ''); $folderPath = self::getStoragePath( $private ? 'app/private/modelfiles' : 'app/public/modelfiles', $tableName, $filename ); $filename = self::getFileUniqueName($folderPath, $filename); return "{$folderPath}/{$filename}"; } /** * Create sub folders to store files using pattern * storage/modelfiles/posts/10/04/19/5f * year/month/day/sha256 first 2 letters */ private static function getStoragePath(string $storage_dir, string $table, string $filename): string { $filenameHash = \hash('sha256', $filename); $pathFolder = \storage_path(sprintf( '%s/%s/%s/%s/%s/%s', $storage_dir, $table, gmdate('y'), gmdate('m'), gmdate('d'), substr($filenameHash, 0, 2) )); if (! File::exists($pathFolder)) { File::makeDirectory($pathFolder, 0755, true); } return $pathFolder; } /** * Get unique filename if the path is already occupied * * @throws FileStorageOperationException If a unique filename cannot be obtained. */ private static function getFileUniqueName(string $folderPath, string $filename): string { $attempts = 0; $maxAttempts = 20; while (File::exists("{$folderPath}/{$filename}")) { if ($attempts++ >= $maxAttempts) { throw FileStorageOperationException::cannotGenerateUniqueFilename($folderPath, $filename); } $filename = Str::of(pathinfo($filename, \PATHINFO_FILENAME)) ->append(Str::random(8) . '-') ->append('.') ->append(pathinfo($filename, \PATHINFO_EXTENSION)) ->toString(); } return $filename; } /** * Slugify the filename */ public static function slugifyFileName(string $filename): string { return Str::slug(pathinfo($filename, \PATHINFO_FILENAME)) . '.' . pathinfo($filename, \PATHINFO_EXTENSION); } /** * Sanitize File name for uploaded files * * @param string $filename The original uploaded client file name. * @param bool $beautify Beautify the filename. * @return string The sanitized file name. */ public static function sanitizeFileName(string $filename, bool $beautify = true): string { $filename = preg_replace('/[<>:"\/\\|?*\x00-\x1F\x7F\xA0\xAD#\[\]@!$&\'()+,;={}^~`]/u', '-', $filename) ?? $filename; // * Avoids '.' '..' or '.hiddenFiles' . $filename = (string) Str::of($filename)->ltrim('.-'); // * Optional beautification if ($beautify) { $filename = self::beautifyFilename($filename); } // * Maximize filename length to 255 bytes http://serverfault.com/a/9548/44086 /** @var string */ $extension = pathinfo($filename, \PATHINFO_EXTENSION); /** @var string */ $filename = pathinfo($filename, \PATHINFO_FILENAME); $encoding = mb_detect_encoding($filename); $encoding = $encoding ?: null; return \mb_strcut( $filename, 0, 255 - (! empty($extension) ? strlen($extension) + 1 : 0), $encoding ) . (! empty($extension) ? ".$extension" : ''); } /** * Beautify a filename */ private static function beautifyFilename(string $filename): string { // Reduce consecutive characters. $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; $filename = preg_replace([ // * "file--.--.-.--name.zip" becomes "file.name.zip" '/-*\.-*/', // * "file...name..zip" becomes "file.name.zip" '/\.{2,}/', ], '.', $filename) ?? $filename; // Lowercase for windows/unix interoperability http://support.microsoft.com/kb/100625 . $encoding = mb_detect_encoding($filename); $filename = mb_strtolower($filename, $encoding ?: 'UTF-8'); // * ".file-name.-" becomes "file-name" $filename = trim($filename, '.-'); return $filename; } /** * Remove all model files for the given Model. */ public static function removeModelFiles(Model $model): void { $tableName = $model::query()->getModel()->getTable(); if (! empty(Storage::disk('public')->allFiles("modelfiles/{$tableName}"))) { Storage::disk('public')->deleteDirectory("modelfiles/{$tableName}"); } if (! empty(Storage::disk('private')->allFiles("modelfiles/{$tableName}"))) { Storage::disk('private')->deleteDirectory("modelfiles/{$tableName}"); } } public static function isMimeType(string $mimeType, \SplFileInfo $file): bool { if (! $file->getRealPath()) { return false; } $actualMime = File::mimeType($file->getRealPath()); return $actualMime ? Str::is($mimeType, $actualMime) : false; } }