封裝一個可復用的 ImageUploader 類,負責:文件類型/大小驗證、安全重命名、目錄自動創建、移動上傳文件、可選縮略圖生成、錯誤統一拋出異常。採用面向對象風格,便於單元測試與擴展。
<?php
namespace App\Utils;
use Exception;
class ImageUploader
{
/** @var array 允許的 MIME 類型 */
protected array $allowedMimes = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'
];
/** @var int 最大文件大小(位元組),默認 2MB */
protected int $maxSize = 2 * 1024 * 1024;
/** @var string 儲存根目錄 */
protected string $saveRoot;
/** @var bool 是否生成縮略圖 */
protected bool $makeThumb = false;
/** @var int 縮略圖最大寬/高 */
protected int $thumbMaxDim = 300;
// 運行時屬性
protected string $originalName = '';
protected string $tmpPath = '';
protected int $fileSize = 0;
protected string $mimeType = '';
protected array $imageInfo = []; // getimagesize 結果
public function __construct(string $saveRoot)
{
$this->saveRoot = rtrim($saveRoot, '/\\') . DIRECTORY_SEPARATOR;
}
/** 設置允許類型 */
public function setAllowedMimes(array $mimes): self
{
$this->allowedMimes = $mimes;
return $this;
}
/** 設置最大尺寸 */
public function setMaxSize(int $bytes): self
{
$this->maxSize = $bytes;
return $this;
}
/** 啟用縮略圖 */
public function enableThumb(int $maxDim = 300): self
{
$this->makeThumb = true;
$this->thumbMaxDim = $maxDim;
return $this;
}
/** 處理 $_FILES 項目 */
public function handleUpload(array $file): string
{
$this->extractFileInfo($file);
$this->validate();
$savePath = $this->moveFile();
if ($this->makeThumb) {
$this->createThumbnail($savePath);
}
return $savePath; // 返回相對或絕對路徑
}
/** 從 $_FILES 提取信息 */
protected function extractFileInfo(array $file): void
{
$this->originalName = $file['name'] ?? '';
$this->tmpPath = $file['tmp_name'] ?? '';
$this->fileSize = $file['size'] ?? 0;
$this->mimeType = $file['type'] ?? '';
if ($this->tmpPath && is_uploaded_file($this->tmpPath)) {
$this->imageInfo = @getimagesize($this->tmpPath) ?: [];
}
}
/** 綜合驗證 */
protected function validate(): void
{
if (!$this->tmpPath || !is_uploaded_file($this->tmpPath)) {
throw new Exception('非法上傳文件或文件丟失', 1001);
}
if ($this->fileSize > $this->maxSize) {
throw new Exception(sprintf('文件大小超過限制 (%.2f MB)', $this->maxSize / 1024 / 1024), 1002);
}
if (!in_array($this->mimeType, $this->allowedMimes, true)) {
throw new Exception('不支持的文件類型: ' . $this->mimeType, 1003);
}
// 二次驗證:檢查文件頭魔數(防偽裝)
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$realMime = $finfo->file($this->tmpPath);
if (!in_array($realMime, $this->allowedMimes, true)) {
throw new Exception('文件內容類型不匹配: ' . $realMime, 1004);
}
if (empty($this->imageInfo)) {
throw new Exception('無法識別的圖片格式或損壞文件', 1005);
}
}
/** 生成安全儲存路徑並移動文件 */
protected function moveFile(): string
{
// 目錄結構:saveRoot / Y-m / 隨機名.擴展名
$dateDir = date('Y-m');
$saveDir = $this->saveRoot . $dateDir;
if (!is_dir($saveDir)) {
mkdir($saveDir, 0755, true);
}
$ext = strtolower(pathinfo($this->originalName, PATHINFO_EXTENSION));
$ext = $ext ?: 'jpg'; // 兜底
$filename = bin2hex(random_bytes(16)) . '.' . $ext; // 64位隨機十六進制
$destPath = $saveDir . DIRECTORY_SEPARATOR . $filename;
if (!move_uploaded_file($this->tmpPath, $destPath)) {
throw new Exception('文件移動失敗,請檢查目錄權限', 1006);
}
// 返回相對於 saveRoot 的路徑,便於資料庫存儲
return $dateDir . '/' . $filename;
}
/** 生成縮略圖(需 GD 擴展) */
protected function createThumbnail(string $relativePath): void
{
if (!extension_loaded('gd')) return;
$srcPath = $this->saveRoot . $relativePath;
list($w, $h, $type) = $this->imageInfo;
// 計算縮放比例
$ratio = min($this->thumbMaxDim / $w, $this->thumbMaxDim / $h, 1);
if ($ratio >= 1) return; // 原圖已小於限制
$newW = (int)($w * $ratio);
$newH = (int)($h * $ratio);
$src = $this->createImageFromType($srcPath, $type);
if (!$src) return;
$dst = imagecreatetruecolor($newW, $newH);
// 保持透明通道
if ($type === IMAGETYPE_PNG || $type === IMAGETYPE_GIF || $type === IMAGETYPE_WEBP) {
imagealphablending($dst, false);
imagesavealpha($dst, true);
}
imagecopyresampled($dst, $src, 0, 0, 0, 0, $newW, $newH, $w, $h);
$thumbPath = $this->saveRoot . 'thumb_' . $relativePath;
$this->saveImageByType($dst, $thumbPath, $type);
imagedestroy($src);
imagedestroy($dst);
}
protected function createImageFromType(string $path, int $type): ?\GdImage
{
return match ($type) {
IMAGETYPE_JPEG => @imagecreatefromjpeg($path),
IMAGETYPE_PNG => @imagecreatefrompng($path),
IMAGETYPE_GIF => @imagecreatefromgif($path),
IMAGETYPE_WEBP => @imagecreatefromwebp($path),
default => null,
};
}
protected function saveImageByType(\GdImage $img, string $path, int $type): void
{
switch ($type) {
case IMAGETYPE_JPEG: imagejpeg($img, $path, 85); break;
case IMAGETYPE_PNG: imagepng($img, $path, 8); break;
case IMAGETYPE_GIF: imagegif($img, $path); break;
case IMAGETYPE_WEBP: imagewebp($img, $path, 85); break;
}
}
}
<?php
namespace App\Controller;
use App\Utils\ImageUploader;
use Exception;
class UploadController
{
public function upload()
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->json(['code' => 405, 'msg' => 'Method not allowed']);
}
if (empty($_FILES['image'])) {
$this->json(['code' => 400, 'msg' => '未接收到文件']);
}
try {
$uploader = new ImageUploader(__DIR__ . '/../../public/uploads');
// 可鏈式配置
$uploader->setMaxSize(5 * 1024 * 1024) // 5MB
->enableThumb(400); // 生成 400px 縮略圖
$relativePath = $uploader->handleUpload($_FILES['image']);
$this->json([
'code' => 0,
'msg' => '上傳成功',
'data' => [
'url' => '/uploads/' . $relativePath,
'thumbUrl' => '/uploads/thumb_' . $relativePath,
]
]);
} catch (Exception $e) {
$this->json(['code' => $e->getCode() ?: 500, 'msg' => $e->getMessage()]);
}
}
protected function json(array $data): void
{
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
}
<form action="/upload" method="post" enctype="multipart/form-data">
<input type="file" name="image" accept="image/*" required>
<button type="submit">上傳圖片</button>
</form>
$_FILES['type'](瀏覽器提供,不可信)+ finfo 魔數檢測(真實內容)。bin2hex(random_bytes(16)) 避免覆蓋、路徑遍歷、中文文件名編碼問題。php_admin_flag engine off (Apache) 或 location ~* \.php$ { deny all; } (Nginx) 禁止腳本執行。