|
|
<?php
|
|
|
|
|
|
namespace App\Support;
|
|
|
|
|
|
use Illuminate\Http\UploadedFile;
|
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
use Illuminate\Support\Str;
|
|
|
use Illuminate\Validation\ValidationException;
|
|
|
use RuntimeException;
|
|
|
|
|
|
/**
|
|
|
* 公开可访问的业务图片上传:白名单后缀 + 魔数/MIME,落盘名只用 UUID。
|
|
|
*/
|
|
|
final class SecurePublicImageUpload
|
|
|
{
|
|
|
/** @var list<string> */
|
|
|
public const ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
|
|
|
|
|
/** @var array<string, list<string>> */
|
|
|
public const MIME_TO_EXTENSIONS = [
|
|
|
'image/jpeg' => ['jpg', 'jpeg'],
|
|
|
'image/png' => ['png'],
|
|
|
'image/gif' => ['gif'],
|
|
|
'image/webp' => ['webp'],
|
|
|
];
|
|
|
|
|
|
/**
|
|
|
* @return array{path: string, mime: string, extension: string}
|
|
|
*/
|
|
|
public static function store(UploadedFile $file, string $directory, int $maxKilobytes): array
|
|
|
{
|
|
|
$checked = self::assertAcceptable($file, $maxKilobytes);
|
|
|
|
|
|
Storage::disk('public')->makeDirectory($directory);
|
|
|
|
|
|
$name = Str::uuid()->toString().'.'.$checked['extension'];
|
|
|
$path = Storage::disk('public')->putFileAs($directory, $file, $name);
|
|
|
if ($path === false) {
|
|
|
throw new RuntimeException('public disk putFileAs returned false');
|
|
|
}
|
|
|
|
|
|
return [
|
|
|
'path' => str_replace('\\', '/', $path),
|
|
|
'mime' => $checked['mime'],
|
|
|
'extension' => $checked['extension'],
|
|
|
];
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @return array{extension: string, mime: string}
|
|
|
*/
|
|
|
public static function assertAcceptable(UploadedFile $file, int $maxKilobytes): array
|
|
|
{
|
|
|
if (! $file->isValid()) {
|
|
|
throw ValidationException::withMessages([
|
|
|
'file' => ['上传未通过校验:'.$file->getErrorMessage()],
|
|
|
]);
|
|
|
}
|
|
|
|
|
|
$size = (int) $file->getSize();
|
|
|
if ($size <= 0 || $size > $maxKilobytes * 1024) {
|
|
|
throw ValidationException::withMessages([
|
|
|
'file' => ['单张图片不能超过 '.$maxKilobytes.'KB'],
|
|
|
]);
|
|
|
}
|
|
|
|
|
|
$extension = UploadFilenameGuard::lastAllowedExtension(
|
|
|
(string) $file->getClientOriginalName(),
|
|
|
self::ALLOWED_EXTENSIONS
|
|
|
);
|
|
|
|
|
|
$realPath = $file->getRealPath();
|
|
|
if ($realPath === false || ! is_readable($realPath)) {
|
|
|
throw ValidationException::withMessages([
|
|
|
'file' => ['无法读取上传文件'],
|
|
|
]);
|
|
|
}
|
|
|
|
|
|
$magicMime = self::mimeFromMagicBytes($realPath);
|
|
|
if ($magicMime === null || ! self::extensionMatchesMime($extension, $magicMime)) {
|
|
|
throw ValidationException::withMessages([
|
|
|
'file' => ['不支持的文件类型'],
|
|
|
]);
|
|
|
}
|
|
|
|
|
|
if (class_exists(\finfo::class)) {
|
|
|
try {
|
|
|
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
|
|
$detected = $finfo->file($realPath);
|
|
|
} catch (\Throwable) {
|
|
|
$detected = false;
|
|
|
}
|
|
|
|
|
|
if (! is_string($detected) || ! self::extensionMatchesMime($extension, $detected)) {
|
|
|
throw ValidationException::withMessages([
|
|
|
'file' => ['不支持的文件类型'],
|
|
|
]);
|
|
|
}
|
|
|
|
|
|
$mime = $detected;
|
|
|
} else {
|
|
|
$mime = $magicMime;
|
|
|
}
|
|
|
|
|
|
return [
|
|
|
'extension' => $extension,
|
|
|
'mime' => $mime,
|
|
|
];
|
|
|
}
|
|
|
|
|
|
public static function mimeFromMagicBytes(string $path): ?string
|
|
|
{
|
|
|
$handle = fopen($path, 'rb');
|
|
|
if ($handle === false) {
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
$header = fread($handle, 16);
|
|
|
fclose($handle);
|
|
|
|
|
|
if (! is_string($header) || strlen($header) < 3) {
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
if (str_starts_with($header, "\xFF\xD8\xFF")) {
|
|
|
return 'image/jpeg';
|
|
|
}
|
|
|
|
|
|
if (str_starts_with($header, "\x89PNG\r\n\x1A\n")) {
|
|
|
return 'image/png';
|
|
|
}
|
|
|
|
|
|
if (str_starts_with($header, 'GIF87a') || str_starts_with($header, 'GIF89a')) {
|
|
|
return 'image/gif';
|
|
|
}
|
|
|
|
|
|
if (strlen($header) >= 12 && str_starts_with($header, 'RIFF') && substr($header, 8, 4) === 'WEBP') {
|
|
|
return 'image/webp';
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
public static function extensionMatchesMime(string $extension, string $mime): bool
|
|
|
{
|
|
|
$mime = strtolower(trim($mime));
|
|
|
$allowed = self::MIME_TO_EXTENSIONS[$mime] ?? null;
|
|
|
|
|
|
return is_array($allowed) && in_array($extension, $allowed, true);
|
|
|
}
|
|
|
}
|