529 lines
19 KiB
PHP
529 lines
19 KiB
PHP
<?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
|
|
{
|
|
$attributeValue = $model->getAttribute($attribute);
|
|
if (! is_scalar($attributeValue)) {
|
|
return;
|
|
}
|
|
$path = \ltrim(\urldecode(strval($attributeValue)), '/');
|
|
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;
|
|
}
|
|
}
|