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

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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