快速診斷流程
- 檢查
$_FILES['file']['error'] 錯誤碼
- 驗證
php.ini 關鍵設定
- 確認目錄權限與擁有者
- 檢查 Web Server (Nginx/Apache) 客戶端上傳體積限制
- 排查防火牆/安全模組 (ModSecurity, SELinux)
錯誤碼對照表
| 錯誤碼 | 常數 | 原因 | 解決方向 |
| 0 | UPLOAD_ERR_OK | 成功 | — |
| 1 | UPLOAD_ERR_INI_SIZE | 超過 upload_max_filesize | 調大 php.ini 設定 |
| 2 | UPLOAD_ERR_FORM_SIZE | 超過表單 MAX_FILE_SIZE | 移除或調大 hidden input |
| 3 | UPLOAD_ERR_PARTIAL | 上傳中斷 | 檢查網路/逾時/體積限制 |
| 4 | UPLOAD_ERR_NO_FILE | 未選檔案 | 前端驗證 |
| 6 | UPLOAD_ERR_NO_TMP_DIR | 缺臨時目錄 | 設定 upload_tmp_dir |
| 7 | UPLOAD_ERR_CANT_WRITE | 寫入失敗 | 權限/磁碟空間 |
| 8 | UPLOAD_ERR_EXTENSION | 擴充套件攔截 | 檢查 suhosin 等 |
php.ini 關鍵設定 (必檢項目)
; ====== 必須開啟 ======
file_uploads = On
; ====== 體積限制 (建議值,視需求調整) ======
upload_max_filesize = 50M ; 單檔上限
post_max_size = 60M ; 必須 > upload_max_filesize
memory_limit = 256M ; 建議 > post_max_size
; ====== 臨時目錄 (需存在且可寫) ======
upload_tmp_dir = "/var/tmp/php_upload"
; 若註解掉則使用系統預設 /tmp
; ====== 執行時間 (大檔上傳需較長) ======
max_execution_time = 300 ; 5 分鐘
max_input_time = 300 ; 接收資料時間
; ====== 安全相關 ======
; suhosin.upload.max_uploads = 25 ; 若裝 suhosin 需調整
; suhosin.post.max_value_length = 10000000
目錄權限檢查腳本
<?php
// check_upload_perms.php
$dirs = [
ini_get('upload_tmp_dir') ?: sys_get_temp_dir(),
__DIR__ . '/uploads', // 你的實際上傳目錄
];
foreach ($dirs as $dir) {
echo "目錄: $dir\n";
echo " 存在: " . (is_dir($dir) ? '是' : '否') . "\n";
echo " 可讀: " . (is_readable($dir) ? '是' : '否') . "\n";
echo " 可寫: " . (is_writable($dir) ? '是' : '否') . "\n";
echo " 擁有者: " . posix_getpwuid(fileowner($dir))['name'] . "\n";
echo " 當前使用者: " . get_current_user() . "\n";
echo " PHP 使用者: " . exec('whoami') . "\n";
echo "---\n";
}
?>
Web Server 額外限制
Nginx
# /etc/nginx/nginx.conf 或 site conf
http {
client_max_body_size 60M; # 必須 ≥ post_max_size
client_body_timeout 300s;
client_body_buffer_size 128k;
}
Apache (mod_security / LimitRequestBody)
# .htaccess 或 httpd.conf
LimitRequestBody 62914560 # 60MB (bytes)
# 或關閉 mod_security 對上傳的檢查
SecRequestBodyAccess Off
常見坑點彙總
| 現象 | 真相 | 修正 |
| 小檔正常、大檔失敗 | Nginx client_max_body_size 預設 1M | 設定足夠大小並 reload |
| 上傳 0 bytes | upload_tmp_dir 不可寫或磁碟滿 | 檢查 df -h、權限、inode |
| 中文檔名亂碼/失敗 | 檔案系統/編碼/權限 | 儲存時用 UUID 重命名,資料庫存原名 |
| 多檔上傳部分失敗 | max_file_uploads 預設 20 | php.ini 調大 |
| $_FILES 為空 | 表單缺 enctype="multipart/form-data" | 補上 enctype |
| SELinux 拒絕寫入 | audit.log 有 AVC denied | setsebool -P httpd_unified 1 或調整 context |
最佳實踐模板
<?php
function handleUpload(array $file, string $targetDir, array $allowedMimes = []): array
{
// 1. 錯誤碼檢查
if ($file['error'] !== UPLOAD_ERR_OK) {
throw new RuntimeException('上傳錯誤碼: ' . $file['error']);
}
// 2. MIME 驗證 (不要信任副檔名)
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
if ($allowedMimes && !in_array($mime, $allowedMimes, true)) {
throw new RuntimeException("不允許的檔案類型: $mime");
}
// 3. 安全檔名
$ext = pathinfo($file['name'], PATHINFO_EXTENSION);
$safeName = bin2hex(random_bytes(16)) . ($ext ? '.' . $ext : '');
// 4. 確保目錄存在
if (!is_dir($targetDir)) {
mkdir($targetDir, 0755, true);
}
$targetPath = $targetDir . '/' . $safeName;
// 5. 移動檔案 (跨分區自動複製+刪除)
if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
throw new RuntimeException('移動上傳檔案失敗');
}
// 6. 設定安全權限 (不可執行)
chmod($targetPath, 0644);
return [
'original_name' => $file['name'],
'stored_name' => $safeName,
'path' => $targetPath,
'size' => $file['size'],
'mime' => $mime,
];
}
// 使用
try {
$result = handleUpload($_FILES['avatar'], '/var/www/uploads/avatars', [
'image/jpeg', 'image/png', 'image/webp'
]);
echo json_encode(['success' => true, 'data' => $result]);
} catch (Throwable $e) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
?>