uploads/2024/01/15/['code', 'msg', 'data' => ['path', 'url', 'size', 'mime']]<?php
namespace App\Service;
use Exception;
class UploadedFile
{
private array $file;
private array $config;
public const ERROR_OK = 0;
public const ERROR_NO_FILE = 1;
public const ERROR_UPLOAD_FAILED = 2;
public const ERROR_INVALID_TYPE = 3;
public const ERROR_EXCEED_SIZE = 4;
public const ERROR_MOVE_FAILED = 5;
public const ERROR_DIR_CREATE = 6;
private static array $errorMessages = [
self::ERROR_OK => '上传成功',
self::ERROR_NO_FILE => '未接收到文件',
self::ERROR_UPLOAD_FAILED => '上传失败 (PHP错误码: %d)',
self::ERROR_INVALID_TYPE => '不允许的文件类型: %s',
self::ERROR_EXCEED_SIZE => '文件大小超限: %s 字节 (限制 %s 字节)',
self::ERROR_MOVE_FAILED => '文件移动失败',
self::ERROR_DIR_CREATE => '上传目录创建失败',
];
public function __construct(array $file, array $config = [])
{
$this->file = $file;
$this->config = array_merge([
'max_size' => 2 * 1024 * 1024, // 2MB
'allowed_mimes' => ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
'allowed_exts' => ['jpg', 'jpeg', 'png', 'gif', 'webp'],
'upload_root' => $_SERVER['DOCUMENT_ROOT'] . '/uploads',
'url_prefix' => '/uploads',
'date_dir' => true, // 按 Y/m/d 创建子目录
], $config);
}
public function save(): array
{
// 1. 基础校验
if (empty($this->file['tmp_name']) || !is_uploaded_file($this->file['tmp_name'])) {
return $this->error(self::ERROR_NO_FILE);
}
// 2. PHP 上传错误码
if ($this->file['error'] !== UPLOAD_ERR_OK) {
return $this->error(self::ERROR_UPLOAD_FAILED, $this->file['error']);
}
// 3. 大小校验
if ($this->file['size'] > $this->config['max_size']) {
return $this->error(self::ERROR_EXCEED_SIZE, $this->file['size'], $this->config['max_size']);
}
// 4. MIME 校验 (二次检测,防伪造)
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$realMime = finfo_file($finfo, $this->file['tmp_name']);
finfo_close($finfo);
if (!in_array($realMime, $this->config['allowed_mimes'], true)) {
return $this->error(self::ERROR_INVALID_TYPE, $realMime);
}
// 5. 扩展名校验
$ext = strtolower(pathinfo($this->file['name'], PATHINFO_EXTENSION));
if (!in_array($ext, $this->config['allowed_exts'], true)) {
return $this->error(self::ERROR_INVALID_TYPE, $ext);
}
// 6. 目标目录
$dateDir = $this->config['date_dir'] ? date('Y/m/d') : '';
$saveDir = $this->config['upload_root'] . ($dateDir ? '/' . $dateDir : '');
$urlDir = $this->config['url_prefix'] . ($dateDir ? '/' . $dateDir : '');
if (!is_dir($saveDir)) {
if (!mkdir($saveDir, 0755, true) && !is_dir($saveDir)) {
return $this->error(self::ERROR_DIR_CREATE);
}
}
// 7. 生成唯一文件名
$filename = $this->generateFilename($ext);
$savePath = $saveDir . '/' . $filename;
$url = $urlDir . '/' . $filename;
// 8. 移动文件
if (!move_uploaded_file($this->file['tmp_name'], $savePath)) {
return $this->error(self::ERROR_MOVE_FAILED);
}
// 9. 设置权限 (可选)
@chmod($savePath, 0644);
return [
'code' => self::ERROR_OK,
'msg' => self::$errorMessages[self::ERROR_OK],
'data' => [
'path' => $savePath,
'url' => $url,
'size' => $this->file['size'],
'mime' => $realMime,
'ext' => $ext,
'original_name' => $this->file['name'],
],
];
}
private function generateFilename(string $ext): string
{
// 随机 16 字符 + 时间戳毫秒 + 后缀
$random = bin2hex(random_bytes(8));
$time = (int)(microtime(true) * 1000);
return "{$random}_{$time}.{$ext}";
}
private function error(int $code, ...$params): array
{
$msg = self::$errorMessages[$code] ?? '未知错误';
if ($params) $msg = vsprintf($msg, $params);
return ['code' => $code, 'msg' => $msg, 'data' => null];
}
}
<?php
namespace app\controller;
use App\Service\UploadedFile;
class UploadController
{
public function avatar()
{
$file = $_FILES['avatar'] ?? null;
if (!$file) {
return json(['code' => 1, 'msg' => '请选择文件']);
}
$uploader = new UploadedFile($file, [
'max_size' => 5 * 1024 * 1024, // 头像放宽 5MB
'upload_root' => '/var/www/uploads',
'url_prefix' => 'https://cdn.example.com/uploads',
]);
$result = $uploader->save();
return json($result);
}
public function editorImage()
{
// 富文本编辑器上传 (如 WangEditor, TinyMCE)
$file = $_FILES['file'] ?? null;
$uploader = new UploadedFile($file, [
'allowed_mimes' => ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'],
'max_size' => 10 * 1024 * 1024,
]);
$result = $uploader->save();
// 编辑器要求特定格式
if ($result['code'] === 0) {
return json(['errno' => 0, 'data' => ['url' => $result['data']['url']]]);
}
return json(['errno' => 1, 'message' => $result['msg']]);
}
}
<form action="/upload/avatar" method="post" enctype="multipart/form-data">
<input type="file" name="avatar" accept="image/jpeg,image/png,image/gif,image/webp" required>
<button type="submit">上传头像</button>
</form>
<script>
// AJAX 上传示例
document.querySelector('form').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const res = await fetch(e.target.action, {method: 'POST', body: formData});
const data = await res.json();
if (data.code === 0) {
alert('上传成功: ' + data.data.url);
} else {
alert('失败: ' + data.msg);
}
});
</script>
| 风险 | 对策 |
|---|---|
| 脚本伪装成图片 (.php.jpg) | 1. 仅信任 finfo_file() 检测 MIME 2. 存储目录配置 Nginx location ~ \.php$ { deny all; } |
| 文件名路径穿越 | 完全忽略原文件名,生成随机名 |
| 大文件撑爆内存/磁盘 | Nginx client_max_body_size 10M; PHP upload_max_filesize/post_max_size |
| 并发同名覆盖 | random_bytes(8) + 毫秒时间戳,冲突概率极低 |
| 图片含恶意代码 (Exif) | 可选:imagick 重新编码 $img->stripImage() 移除元数据 |
图片上传看似简单,**安全细节决定成败**。上述类封装了:MIME 二次校验、随机命名、日期分目录、错误码规范、前端对接格式。生产环境建议:上传后异步推送到对象存储 (S3/OSS/COS),Web 服务器仅作临时中转,实现无状态水平扩展。