You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

152 lines
4.5 KiB

2 days ago
<?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);
}
}