ldc-boutique-cnc-sable/app/Lib/Helpers/FileStorageHelper.php

526 lines
19 KiB
PHP
Raw Permalink Normal View History

2026-06-14 21:32:07 +02:00
<?php
declare(strict_types=1);
namespace App\Lib\Helpers;
use App\Exceptions\Helpers\FileStorageOperationException;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* This class is used to handle files in LDC Laravel stack
*
* * The storage strategy will be using date.
* The file name may contain sufix timestamp highresolution time (hrtime or microtime) if is duplicate.
* (function() { return time() . ' ' . Str::slug(microtime(true)) . ' ' . hrtime(true); })();
*
* posts/ - The model table name
* 10/ - Year
* 04/ - Month
* 19/ - Day
* 5f/ - First two letters of sha256 file hash
* black-cat-under-red-car.png - Slugified file name
* black-cat-under-red-car-286071690833400.png - Slugified file name that is duplicated
*
* The final path will be storage/modelfiles/posts/10/04/19/5f/black-cat-under-red-car-286071690833400.png
*
* ! No limits of file per folder will be handled.
*/
class FileStorageHelper
{
private const string STORAGE_PREFIX = 'storage/';
/**
* Remove all files from string attribute html that are stored in public
*
* @param string $publicStorageFilePath The file path from public directory.
* @return string The local storage path.
*/
public static function getFileFullPath(string $publicStorageFilePath): string
{
/** @var \Illuminate\Filesystem\FilesystemAdapter */
$disk = Storage::disk('public');
$path = Str::replace(self::STORAGE_PREFIX, '', \ltrim(\urldecode($publicStorageFilePath), '/'));
return $disk->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;
}
}