原文 `UpImages` 類別整合:上傳驗證、儲存、縮圖、水印。屬性公開、錯誤直接 `die()`、字體路徑寫死、不支援 WebP/HEIC、無單元測試介面。
<?php
declare(strict_types=1);
namespace App\Service;
use Intervention\Image\ImageManagerStatic as Image;
interface ImageUploaderInterface {
public function upload(UploadedFile $file): UploadedImageResult;
}
class UploadedImageResult {
public function __construct(
public string $originalName,
public string $storedPath, // 相對儲存路徑
public string $thumbnailPath, // 縮圖路徑
public string $watermarkedPath, // 水印圖路徑
public int $width,
public int $height,
public string $mimeType,
public int $sizeBytes,
) {}
}
class InterventionImageUploader implements ImageUploaderInterface {
public function __construct(
private string $storageRoot, // /var/www/storage
private string $publicPrefix, // /storage
private array $allowedMimes = ['image/jpeg','image/png','image/gif','image/webp'],
private int $maxSizeBytes = 10 * 1024 * 1024,
private int $thumbMaxW = 400,
private int $thumbMaxH = 300,
private ?string $watermarkText = '© MySite',
private ?string $fontPath = null, // 內建字體或 TTF 路徑
) {}
public function upload(UploadedFile $file): UploadedImageResult {
// 1. 驗證
$this->validate($file);
// 2. 產生安全檔名
$ext = $file->getClientOriginalExtension() ?: 'jpg';
$filename = bin2hex(random_bytes(16)) . '.' . $ext;
$dateDir = date('Y/m/d');
$storeDir = $this->storageRoot . '/' . $dateDir;
if (!is_dir($storeDir)) mkdir($storeDir, 0750, true);
$originalPath = $storeDir . '/' . $filename;
$file->move($storeDir, $filename); // Symfony/Laravel UploadedFile::move()
// 3. 讀取圖片資訊
$img = Image::make($originalPath);
$width = $img->width();
$height = $img->height();
$mime = $img->mime();
// 4. 產生縮圖
$thumbFilename = 'thumb_' . $filename;
$thumbPath = $storeDir . '/' . $thumbFilename;
$img->fit($this->thumbMaxW, $this->thumbMaxH, function ($c) { $c->upsize(); });
$img->save($thumbPath, 85);
// 5. 產生水印圖 (可選)
$wmPath = '';
if ($this->watermarkText) {
$wmFilename = 'wm_' . $filename;
$wmPath = $storeDir . '/' . $wmFilename;
$img2 = Image::make($originalPath);
$img2->text($this->watermarkText, $img2->width() - 120, $img2->height() - 20, function ($font) {
$font->size(14)->color('#ffffff')->align('right')->valign('bottom');
if ($this->fontPath) $font->file($this->fontPath);
});
$img2->save($wmPath, 85);
}
// 6. 回傳結果 (儲存相對路徑,對外輸出時加上 $publicPrefix)
return new UploadedImageResult(
originalName: $file->getClientOriginalName(),
storedPath: $dateDir . '/' . $filename,
thumbnailPath: $dateDir . '/' . $thumbFilename,
watermarkedPath: $wmPath ? $dateDir . '/' . $wmFilename : '',
width: $width,
height: $height,
mimeType: $mime,
sizeBytes: $file->getSize(),
);
}
private function validate(UploadedFile $file): void {
if (!$file->isValid()) throw new \InvalidArgumentException('上傳檔案無效: ' . $file->getErrorMessage());
if ($file->getSize() > $this->maxSizeBytes) throw new \InvalidArgumentException('檔案過大');
if (!in_array($file->getMimeType(), $this->allowedMimes, true)) throw new \InvalidArgumentException('檔案類型不允許');
// 進階:finode 檢測真實 MIME
$finfo = new finfo(FILEINFO_MIME_TYPE);
$realMime = $finfo->file($file->getRealPath());
if (!in_array($realMime, $this->allowedMimes, true)) throw new \InvalidArgumentException('檔案內容類型不符');
}
}
?>
<?php
class ImageController {
public function store(Request $request, ImageUploaderInterface $uploader) {
$request->validate(['image' => 'required|file']);
try {
$result = $uploader->upload($request->file('image'));
return response()->json([
'url' => $result->storedPath,
'thumb' => $result->thumbnailPath,
'watermark' => $result->watermarkedPath,
'width' => $result->width,
'height' => $result->height,
]);
} catch (\InvalidArgumentException $e) {
return response()->json(['error' => $e->getMessage()], 422);
}
}
}
?>
| 項目 | 做法 |
|---|---|
| 檔案型態驗證 | `finfo` 檢測真實 MIME + 副檔名白名單,雙重檢查 |
| 檔名安全化 | **不使用用戶原始檔名**,`bin2hex(random_bytes(16))` + 正確副檔名 |
| 儲存路徑隔離 | 儲存於 **公開目錄外** (`storage/app`),透過簽名 URL / 控制器輸出 |
| 圖片重新處理 | 縮圖/水印過程**移除 EXIF**、壓縮品質統一,**防止圖片炸彈 (Decompression Bomb)** |
| 大小限制 | Nginx `client_max_body_size` + PHP `upload_max_filesize` + 程式碼雙重限制 |
| 掃毒 | 上傳後送 ClamAV 掃描 (非同步),惡意隔離 |
| 權限控制 | 儲存目錄權限 750,擁有者 www-data,禁止執行位 |
圖片上傳類核心職責:**驗證、安全儲存、衍生圖產生 (縮圖/水印)、回傳結構化結果**。現代化關鍵在於**介面隔離、依賴注入、Intervention Image 驅動抽象、安全預設值、錯誤用例外而非 die()**。