PHP 文件上傳涉及 $_FILES 超全局陣列,核心步驟:
form enctype="multipart/form-data"$_FILES['field']move_uploaded_file()allowedTypes = $config['allowedTypes'] ?? ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
$this->maxSize = $config['maxSize'] ?? 5 * 1024 * 1024; // 5MB
$this->uploadDir = rtrim($config['uploadDir'] ?? __DIR__ . '/uploads', '/') . '/';
$this->overwrite = $config['overwrite'] ?? false;
if (!is_dir($this->uploadDir)) {
mkdir($this->uploadDir, 0755, true);
}
}
public function upload(string $fieldName): array {
$this->errors = [];
if (!isset($_FILES[$fieldName])) {
$this->errors[] = '未找到上傳文件';
return $this->result(false);
}
$file = $_FILES[$fieldName];
// 1. 檢查上傳錯誤碼
if ($file['error'] !== UPLOAD_ERR_OK) {
$this->errors[] = $this->getErrorMessage($file['error']);
return $this->result(false);
}
// 2. 檢查文件大小
if ($file['size'] > $this->maxSize) {
$this->errors[] = "文件大小超過限制 ({$this->formatBytes($this->maxSize)})";
return $this->result(false);
}
// 3. 檢查 MIME 類型(雙重驗證:瀏覽器回報 + 實際內容檢測)
$mime = $this->detectMime($file['tmp_name']);
if (!in_array($mime, $this->allowedTypes, true)) {
$this->errors[] = "不支援的文件類型: {$mime}";
return $this->result(false);
}
// 4. 生成安全文件名
$ext = $this->getExtension($mime);
$filename = $this->generateFilename($ext);
$destPath = $this->uploadDir . $filename;
// 5. 防覆蓋
if (!$this->overwrite && file_exists($destPath)) {
$this->errors[] = '文件已存在';
return $this->result(false);
}
// 6. 移動文件
if (!move_uploaded_file($file['tmp_name'], $destPath)) {
$this->errors[] = '文件移動失敗,請檢查目錄權限';
return $this->result(false);
}
// 7. 設置權限(可選)
chmod($destPath, 0644);
return $this->result(true, [
'filename' => $filename,
'path' => $destPath,
'url' => $this->getPublicUrl($filename),
'size' => $file['size'],
'mime' => $mime,
]);
}
// 內容檢測 MIME(防偽裝)
private function detectMime(string $tmpPath): string {
$finfo = new finfo(FILEINFO_MIME_TYPE);
return $finfo->file($tmpPath) ?: 'application/octet-stream';
}
private function getExtension(string $mime): string {
$map = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
];
return $map[$mime] ?? 'bin';
}
private function generateFilename(string $ext): string {
// 日期目錄 + 隨機名,避免單目錄文件過多
$dateDir = date('Y/m/d');
$fullDir = $this->uploadDir . $dateDir;
if (!is_dir($fullDir)) mkdir($fullDir, 0755, true);
$random = bin2hex(random_bytes(16));
return $dateDir . '/' . $random . '.' . $ext;
}
private function getPublicUrl(string $filename): string {
// 根據實際 Web 根目錄調整
return '/uploads/' . $filename;
}
private function getErrorMessage(int $code): string {
$messages = [
UPLOAD_ERR_INI_SIZE => '超過 php.ini upload_max_filesize',
UPLOAD_ERR_FORM_SIZE => '超過表單 MAX_FILE_SIZE',
UPLOAD_ERR_PARTIAL => '文件部分上傳',
UPLOAD_ERR_NO_FILE => '未選擇文件',
UPLOAD_ERR_NO_TMP_DIR => '缺少臨時目錄',
UPLOAD_ERR_CANT_WRITE => '寫入臨時文件失敗',
UPLOAD_ERR_EXTENSION => '擴展阻止上傳',
];
return $messages[$code] ?? "未知錯誤代碼: {$code}";
}
private function formatBytes(int $bytes): string {
$units = ['B', 'KB', 'MB', 'GB'];
$i = 0;
while ($bytes >= 1024 && $i < count($units) - 1) {
$bytes /= 1024;
$i++;
}
return round($bytes, 2) . ' ' . $units[$i];
}
private function result(bool $success, array $data = []): array {
return array_merge(['success' => $success, 'errors' => $this->errors], $data);
}
}
// 使用示例
$uploader = new FileUploader([
'allowedTypes' => ['image/jpeg', 'image/png', 'image/webp'],
'maxSize' => 10 * 1024 * 1024, // 10MB
'uploadDir' => __DIR__ . '/public/uploads',
]);
$result = $uploader->upload('avatar');
if ($result['success']) {
echo "上傳成功: {$result['url']}";
} else {
echo "上傳失敗: " . implode('; ', $result['errors']);
}
| 風險 | 防範措施 |
|---|---|
| 腳本上傳執行 | 上傳目錄禁用 PHP 解析(Nginx: location ~ \.php$ { deny all; }) |
| MIME 偽裝 | 使用 finfo 檢測實際內容,而非信任 $_FILES['type'] |
| 文件名注入 | 忽略原文件名,生成隨機名;路徑拼接用 basename |
| 大文件 DoS | 限制 upload_max_filesize、post_max_size、max_file_uploads |
| 目錄遍歷 | open_basedir 限制訪問範圍 |
| 圖片攜帶惡意代碼 | 重新壓縮/轉換(GD/Imagick)剝離 EXIF 等元數據 |
// 上傳後自動生成縮略圖、壓縮、格式統一
function processImage(string $srcPath, string $destPath, int $maxW = 1200, int $maxH = 1200, int $quality = 85): bool {
$imgInfo = getimagesize($srcPath);
if (!$imgInfo) return false;
[$srcW, $srcH, $type] = $imgInfo;
$mime = image_type_to_mime_type($type);
// 創建源圖像資源
$createFunc = [
IMAGETYPE_JPEG => 'imagecreatefromjpeg',
IMAGETYPE_PNG => 'imagecreatefrompng',
IMAGETYPE_GIF => 'imagecreatefromgif',
IMAGETYPE_WEBP => 'imagecreatefromwebp',
][$type] ?? null;
if (!$createFunc) return false;
$srcImg = $createFunc($srcPath);
// 計算縮放比例
$ratio = min($maxW / $srcW, $maxH / $srcH, 1); // 不放大
$newW = (int)($srcW * $ratio);
$newH = (int)($srcH * $ratio);
// 創建目標圖像
$dstImg = imagecreatetruecolor($newW, $newH);
// 保持透明度(PNG/WebP)
if (in_array($type, [IMAGETYPE_PNG, IMAGETYPE_WEBP])) {
imagealphablending($dstImg, false);
imagesavealpha($dstImg, true);
$transparent = imagecolorallocatealpha($dstImg, 255, 255, 255, 127);
imagefill($dstImg, 0, 0, $transparent);
}
// 重採樣縮放
imagecopyresampled($dstImg, $srcImg, 0, 0, 0, 0, $newW, $newH, $srcW, $srcH);
// 統一輸出為 WebP(現代瀏覽器支援良好,體積更小)
$result = imagewebp($dstImg, $destPath, $quality);
imagedestroy($srcImg);
imagedestroy($dstImg);
return $result;
}