文件上传,是Web开发中非常常见的需求。用户头像上传、文章配图上传、附件上传、视频上传……这些都需要用到文件上传功能。

PHP对文件上传有很好的支持,通过$FILES超全局变量和moveuploaded_file()函数,就能实现基本的文件上传。但文件上传,也是安全风险很高的功能,如果处理不当,可能会导致文件上传漏洞、服务器被入侵、数据泄露等严重问题。

今天,我们来详细学习PHP文件上传,从基础用法到安全加固,从单文件上传到多文件上传,从图片处理到大文件上传,帮你掌握这个常用但需要小心的功能。

文件上传基础

表单设置

要实现文件上传,HTML表单需要做特殊设置:

  1. method必须是POST
  2. enctype必须是multipart/form-data
  3. 表单中要有type="file"的input元素
<form action="upload.php" method="post" enctype="multipart/form-data">
    <input type="file" name="avatar">
    <input type="submit" value="上传">
</form>

enctype="multipart/form-data"是必须的,它告诉浏览器,表单数据包含文件,需要用multipart格式编码。如果不设置这个属性,文件上传不会生效。

$_FILES超全局变量

PHP通过$_FILES超全局变量获取上传的文件信息。

print_r($_FILES['avatar']);
// Array (
//   [name] => photo.jpg      // 原始文件名
//   [type] => image/jpeg     // MIME类型
//   [tmp_name] => /tmp/phpXXX.tmp  // 临时文件路径
//   [error] => 0             // 错误码
//   [size] => 12345          // 文件大小(字节)
// )

每个上传的文件,都有这5个字段:

  • name:原始文件名(用户电脑上的文件名)
  • type:MIME类型(浏览器提供,不可信)
  • tmp_name:服务器上的临时文件路径
  • error:错误码(0表示成功,其他表示错误)
  • size:文件大小(字节)

错误码

$_FILES['file']['error']的可能值:

  • UPLOADERROK (0):上传成功
  • UPLOADERRINISIZE (1):文件超过php.ini中uploadmax_filesize的限制
  • UPLOADERRFORMSIZE (2):文件超过表单中MAXFILE_SIZE的限制
  • UPLOADERRPARTIAL (3):文件只有部分被上传
  • UPLOADERRNO_FILE (4):没有文件被上传
  • UPLOADERRNOTMPDIR (6):找不到临时文件夹
  • UPLOADERRCANT_WRITE (7):文件写入失败
  • UPLOADERREXTENSION (8):PHP扩展停止了文件上传
if ($_FILES['avatar']['error'] !== UPLOAD_ERR_OK) {
    switch ($_FILES['avatar']['error']) {
        case UPLOAD_ERR_INI_SIZE:
            echo '文件超过服务器限制';
            break;
        case UPLOAD_ERR_FORM_SIZE:
            echo '文件超过表单限制';
            break;
        case UPLOAD_ERR_PARTIAL:
            echo '文件只有部分被上传';
            break;
        case UPLOAD_ERR_NO_FILE:
            echo '没有文件被上传';
            break;
        default:
            echo '上传失败';
    }
    exit;
}

moveuploadedfile()

上传的文件,先保存在临时目录中,脚本执行结束后会自动删除。要保存文件,需要用moveuploadedfile()函数把临时文件移动到目标位置。

$tmpFile = $_FILES['avatar']['tmp_name'];
$destFile = 'uploads/' . $_FILES['avatar']['name'];

if (move_uploaded_file($tmpFile, $destFile)) {
    echo '上传成功';
} else {
    echo '上传失败';
}

moveuploadedfile()会检查文件是否是通过HTTP POST上传的,确保安全。不要用copy()或rename()来移动上传文件,因为它们不会做安全检查。

基础文件上传示例

<?php
// upload.php

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    // 检查是否有文件上传
    if (!isset($_FILES['avatar']) || $_FILES['avatar']['error'] !== UPLOAD_ERR_OK) {
        echo '请选择要上传的文件';
        exit;
    }
    
    $file = $_FILES['avatar'];
    
    // 基本配置
    $allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
    $maxSize = 2 * 1024 * 1024; // 2MB
    $uploadDir = 'uploads/';
    
    // 检查文件大小
    if ($file['size'] > $maxSize) {
        echo '文件大小不能超过2MB';
        exit;
    }
    
    // 检查文件类型(注意:type不可信,需要进一步验证)
    if (!in_array($file['type'], $allowedTypes)) {
        echo '只支持JPG、PNG、GIF格式';
        exit;
    }
    
    // 生成安全的文件名
    $ext = pathinfo($file['name'], PATHINFO_EXTENSION);
    $newName = uniqid() . '.' . $ext;
    $destPath = $uploadDir . $newName;
    
    // 确保上传目录存在
    if (!is_dir($uploadDir)) {
        mkdir($uploadDir, 0755, true);
    }
    
    // 移动文件
    if (move_uploaded_file($file['tmp_name'], $destPath)) {
        echo '上传成功:' . $newName;
    } else {
        echo '上传失败';
    }
}
?>

<form method="post" enctype="multipart/form-data">
    <input type="file" name="avatar">
    <input type="submit" value="上传">
</form>

这是一个基础的文件上传示例,但还不够安全。下面我们来详细讲解安全加固。

文件上传安全加固

文件上传,是Web安全中风险很高的功能。攻击者可以通过上传恶意文件(如PHP脚本、WebShell),获取服务器权限。所以,文件上传的安全加固非常重要。

1. 验证文件类型

不要信任$_FILES['file']['type'],这个值是浏览器提供的,可以被伪造。需要用更可靠的方式验证文件类型。

方法1:用finfo验证MIME类型

$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $_FILES['avatar']['tmp_name']);
finfo_close($finfo);

$allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
if (!in_array($mimeType, $allowedTypes)) {
    echo '文件类型不允许';
    exit;
}

finfo通过读取文件内容来判断MIME类型,比浏览器提供的type更可靠。

方法2:用getimagesize验证图片

如果是图片上传,可以用getimagesize()验证:

$imageInfo = getimagesize($_FILES['avatar']['tmp_name']);
if ($imageInfo === false) {
    echo '不是有效的图片文件';
    exit;
}

// $imageInfo[2] 是图片类型常量(IMAGETYPE_JPEG, IMAGETYPE_PNG等)
$allowedTypes = [IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_GIF];
if (!in_array($imageInfo[2], $allowedTypes)) {
    echo '只支持JPG、PNG、GIF格式';
    exit;
}

方法3:验证文件头(魔数)

每种文件类型,都有特定的文件头(魔数)。可以读取文件的前几个字节,验证文件头。

// JPEG文件头:FF D8 FF
// PNG文件头:89 50 4E 47
// GIF文件头:47 49 46 38

$handle = fopen($_FILES['avatar']['tmp_name'], 'rb');
$header = fread($handle, 4);
fclose($handle);

// 验证JPEG
if (bin2hex($header) === 'ffd8ffe0' || bin2hex($header) === 'ffd8ffe1') {
    // 是JPEG
}

2. 重命名文件

不要使用用户提供的原始文件名,因为:

  • 原始文件名可能包含特殊字符,导致路径遍历漏洞
  • 原始文件名可能是中文,编码问题可能导致乱码
  • 原始文件名可能重复,导致覆盖

应该生成随机的、唯一的文件名:

// 方法1:uniqid + 扩展名
$ext = pathinfo($_FILES['avatar']['name'], PATHINFO_EXTENSION);
$newName = uniqid() . '.' . $ext;

// 方法2:md5(uniqid + 随机数)
$newName = md5(uniqid() . mt_rand()) . '.' . $ext;

// 方法3:日期 + 随机数
$newName = date('YmdHis') . '_' . mt_rand(1000, 9999) . '.' . $ext;

注意:扩展名也不能完全信任,需要验证。可以从MIME类型推断扩展名,而不是从原始文件名获取。

3. 存储目录安全

上传文件的存储目录,需要做好安全设置:

  1. 上传目录不可执行:在Nginx/Apache配置中,设置上传目录不执行PHP脚本。

``nginx # Nginx配置 location /uploads/ { location ~ \.php$ { deny all; # 禁止执行uploads目录下的PHP } } ``

  1. 上传目录不在Web根目录:把上传文件存储在Web根目录之外,通过PHP脚本读取和输出。

``php // 文件存储在 /var/www/uploads/ (Web根目录之外) // 通过 download.php 读取文件 $file = '/var/www/uploads/' . $filename; header('Content-Type: image/jpeg'); readfile($file); ``

  1. 设置目录权限:上传目录设置为755,文件设置为644,不要设置为777。
  1. 用.htaccess保护(Apache):在上传目录创建.htaccess文件,禁止执行脚本。

``apache <Files *.php> deny from all </Files> ``

4. 防止路径遍历

用户提供的文件名,可能包含../等路径遍历字符,导致文件被上传到非预期的目录。

// 危险:直接用用户文件名
$destPath = 'uploads/' . $_FILES['avatar']['name'];

// 安全:只取文件名部分,过滤路径
$filename = basename($_FILES['avatar']['name']);
$destPath = 'uploads/' . $filename;

// 更安全:完全生成新文件名,不用用户文件名
$newName = uniqid() . '.jpg';
$destPath = 'uploads/' . $newName;

5. 验证文件大小

在PHP端验证文件大小,不要只依赖前端或php.ini的限制。

$maxSize = 2 * 1024 * 1024; // 2MB
if ($_FILES['avatar']['size'] > $maxSize) {
    echo '文件大小不能超过2MB';
    exit;
}

同时,在php.ini中设置:

upload_max_filesize = 2M
post_max_size = 8M

6. 图片二次渲染(图片上传专用)

如果是图片上传,可以对图片进行二次渲染(重新生成图片),彻底清除图片中可能包含的恶意代码(如图片马)。

// 用GD库重新生成图片
$srcImage = imagecreatefromjpeg($_FILES['avatar']['tmp_name']);
$width = imagesx($srcImage);
$height = imagesy($srcImage);

// 创建新图片
$dstImage = imagecreatetruecolor($width, $height);
imagecopyresampled($dstImage, $srcImage, 0, 0, 0, 0, $width, $height, $width, $height);

// 保存新图片
imagejpeg($dstImage, 'uploads/' . $newName, 90);

// 释放内存
imagedestroy($srcImage);
imagedestroy($dstImage);

二次渲染,会重新生成图片数据,原来图片中隐藏的PHP代码(图片马)会被清除,非常安全。

7. 防重复上传

可以通过文件哈希(MD5/SHA1)判断文件是否已经上传过,避免重复存储。

$fileHash = md5_file($_FILES['avatar']['tmp_name']);
$existingFile = findFileByHash($fileHash); // 从数据库查询

if ($existingFile) {
    echo '文件已存在:' . $existingFile;
} else {
    // 保存新文件
    move_uploaded_file($_FILES['avatar']['tmp_name'], 'uploads/' . $newName);
    // 保存文件哈希到数据库
    saveFileHash($fileHash, $newName);
}

多文件上传

PHP支持同时上传多个文件。

表单设置

<form method="post" enctype="multipart/form-data">
    <input type="file" name="photos[]" multiple>
    <input type="submit" value="上传">
</form>

注意:input的name要加[],表示数组;multiple属性允许选择多个文件。

处理多文件

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $files = $_FILES['photos'];
    $uploadDir = 'uploads/';
    $results = [];
    
    for ($i = 0; $i < count($files['name']); $i++) {
        if ($files['error'][$i] !== UPLOAD_ERR_OK) {
            $results[] = ['name' => $files['name'][$i], 'status' => 'error'];
            continue;
        }
        
        $tmpName = $files['tmp_name'][$i];
        $ext = pathinfo($files['name'][$i], PATHINFO_EXTENSION);
        $newName = uniqid() . '.' . $ext;
        $destPath = $uploadDir . $newName;
        
        if (move_uploaded_file($tmpName, $destPath)) {
            $results[] = ['name' => $newName, 'status' => 'success'];
        } else {
            $results[] = ['name' => $files['name'][$i], 'status' => 'error'];
        }
    }
    
    print_r($results);
}

多文件上传时,$_FILES的结构是二维数组,需要按索引遍历。

大文件上传

大文件上传,需要特殊处理,因为PHP默认的上传限制较小。

1. 修改php.ini配置

; 允许上传的最大文件大小
upload_max_filesize = 100M

; POST数据的最大大小(必须大于upload_max_filesize)
post_max_size = 110M

; 脚本最大执行时间
max_execution_time = 300

; 脚本最大输入时间
max_input_time = 300

; 内存限制
memory_limit = 256M

2. 分片上传

对于超大文件(如视频),推荐用分片上传,把大文件分成多个小块,逐个上传,最后在服务器合并。

分片上传的优点:

  • 可以上传超过PHP限制的大文件
  • 支持断点续传
  • 上传失败可以重试某个分片,不用重新上传整个文件

前端可以用Plupload、Resumable.js、Dropzone.js等库实现分片上传。

3. 进度条

大文件上传,用户需要知道上传进度。可以用HTML5的XMLHttpRequest获取上传进度:

var xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', function(e) {
    if (e.lengthComputable) {
        var percent = (e.loaded / e.total) * 100;
        console.log('上传进度:' + percent + '%');
    }
});

也可以用APC扩展或Session实现上传进度(PHP 5.4+有内置的上传进度支持)。

图片处理

上传图片后,通常需要做一些处理,如生成缩略图、裁剪、加水印等。

生成缩略图

function createThumbnail($srcPath, $dstPath, $thumbWidth = 200) {
    $imageInfo = getimagesize($srcPath);
    $width = $imageInfo[0];
    $height = $imageInfo[1];
    $mime = $imageInfo['mime'];
    
    // 计算缩略图尺寸
    $thumbHeight = intval($height * ($thumbWidth / $width));
    
    // 创建源图像
    switch ($mime) {
        case 'image/jpeg':
            $srcImage = imagecreatefromjpeg($srcPath);
            break;
        case 'image/png':
            $srcImage = imagecreatefrompng($srcPath);
            break;
        case 'image/gif':
            $srcImage = imagecreatefromgif($srcPath);
            break;
        default:
            return false;
    }
    
    // 创建缩略图
    $dstImage = imagecreatetruecolor($thumbWidth, $thumbHeight);
    
    // PNG透明背景处理
    if ($mime === 'image/png') {
        imagealphablending($dstImage, false);
        imagesavealpha($dstImage, true);
    }
    
    // 复制并缩放
    imagecopyresampled($dstImage, $srcImage, 0, 0, 0, 0, $thumbWidth, $thumbHeight, $width, $height);
    
    // 保存缩略图
    switch ($mime) {
        case 'image/jpeg':
            imagejpeg($dstImage, $dstPath, 85);
            break;
        case 'image/png':
            imagepng($dstImage, $dstPath);
            break;
        case 'image/gif':
            imagegif($dstImage, $dstPath);
            break;
    }
    
    // 释放内存
    imagedestroy($srcImage);
    imagedestroy($dstImage);
    
    return true;
}

图片裁剪

function cropImage($srcPath, $dstPath, $x, $y, $width, $height) {
    $imageInfo = getimagesize($srcPath);
    $mime = $imageInfo['mime'];
    
    switch ($mime) {
        case 'image/jpeg':
            $srcImage = imagecreatefromjpeg($srcPath);
            break;
        case 'image/png':
            $srcImage = imagecreatefrompng($srcPath);
            break;
        default:
            return false;
    }
    
    $dstImage = imagecreatetruecolor($width, $height);
    imagecopy($dstImage, $srcImage, 0, 0, $x, $y, $width, $height);
    
    imagejpeg($dstImage, $dstPath, 90);
    
    imagedestroy($srcImage);
    imagedestroy($dstImage);
    return true;
}

加水印

function addWatermark($srcPath, $dstPath, $watermarkText = 'Copyright') {
    $imageInfo = getimagesize($srcPath);
    $mime = $imageInfo['mime'];
    $width = $imageInfo[0];
    $height = $imageInfo[1];
    
    $srcImage = imagecreatefromjpeg($srcPath);
    
    // 创建水印颜色
    $watermarkColor = imagecolorallocatealpha($srcImage, 255, 255, 255, 60);
    
    // 设置字体大小
    $fontSize = 5; // 内置字体大小1-5
    
    // 计算水印位置(右下角)
    $textWidth = imagefontwidth($fontSize) * strlen($watermarkText);
    $textHeight = imagefontheight($fontSize);
    $x = $width - $textWidth - 10;
    $y = $height - $textHeight - 10;
    
    // 添加文字水印
    imagestring($srcImage, $fontSize, $x, $y, $watermarkText, $watermarkColor);
    
    imagejpeg($srcImage, $dstPath, 90);
    imagedestroy($srcImage);
    return true;
}

完整的安全文件上传类

下面是一个完整的、安全的文件上传类:

class FileUpload {
    private $allowedTypes = [];
    private $maxSize = 2097152; // 2MB
    private $uploadDir = 'uploads/';
    private $error = '';
    
    public function __construct($config = []) {
        if (isset($config['allowed_types'])) {
            $this->allowedTypes = $config['allowed_types'];
        }
        if (isset($config['max_size'])) {
            $this->maxSize = $config['max_size'];
        }
        if (isset($config['upload_dir'])) {
            $this->uploadDir = $config['upload_dir'];
        }
    }
    
    public function upload($fileField) {
        // 检查文件是否存在
        if (!isset($_FILES[$fileField])) {
            $this->error = '没有文件被上传';
            return false;
        }
        
        $file = $_FILES[$fileField];
        
        // 检查上传错误
        if ($file['error'] !== UPLOAD_ERR_OK) {
            $this->error = $this->getErrorMessage($file['error']);
            return false;
        }
        
        // 检查文件大小
        if ($file['size'] > $this->maxSize) {
            $this->error = '文件大小超过限制';
            return false;
        }
        
        // 验证文件类型(用finfo)
        $finfo = finfo_open(FILEINFO_MIME_TYPE);
        $mimeType = finfo_file($finfo, $file['tmp_name']);
        finfo_close($finfo);
        
        if (!empty($this->allowedTypes) && !in_array($mimeType, $this->allowedTypes)) {
            $this->error = '文件类型不允许';
            return false;
        }
        
        // 从MIME类型推断扩展名
        $ext = $this->getExtensionFromMime($mimeType);
        
        // 生成唯一文件名
        $newName = date('YmdHis') . '_' . uniqid() . '.' . $ext;
        $destPath = $this->uploadDir . $newName;
        
        // 确保目录存在
        if (!is_dir($this->uploadDir)) {
            mkdir($this->uploadDir, 0755, true);
        }
        
        // 移动文件
        if (!move_uploaded_file($file['tmp_name'], $destPath)) {
            $this->error = '文件保存失败';
            return false;
        }
        
        // 设置文件权限
        chmod($destPath, 0644);
        
        return [
            'name' => $newName,
            'path' => $destPath,
            'size' => $file['size'],
            'mime' => $mimeType,
            'ext' => $ext,
        ];
    }
    
    private function getErrorMessage($errorCode) {
        $messages = [
            UPLOAD_ERR_INI_SIZE => '文件超过服务器限制',
            UPLOAD_ERR_FORM_SIZE => '文件超过表单限制',
            UPLOAD_ERR_PARTIAL => '文件只有部分被上传',
            UPLOAD_ERR_NO_FILE => '没有文件被上传',
            UPLOAD_ERR_NO_TMP_DIR => '找不到临时文件夹',
            UPLOAD_ERR_CANT_WRITE => '文件写入失败',
            UPLOAD_ERR_EXTENSION => '扩展停止了文件上传',
        ];
        return $messages[$errorCode] ?? '未知错误';
    }
    
    private function getExtensionFromMime($mime) {
        $map = [
            'image/jpeg' => 'jpg',
            'image/png' => 'png',
            'image/gif' => 'gif',
            'image/webp' => 'webp',
            'application/pdf' => 'pdf',
            'text/plain' => 'txt',
            'application/zip' => 'zip',
        ];
        return $map[$mime] ?? 'bin';
    }
    
    public function getError() {
        return $this->error;
    }
}

// 使用示例
$uploader = new FileUpload([
    'allowed_types' => ['image/jpeg', 'image/png', 'image/gif'],
    'max_size' => 2 * 1024 * 1024,
    'upload_dir' => 'uploads/',
]);

$result = $uploader->upload('avatar');
if ($result) {
    echo '上传成功:' . $result['name'];
} else {
    echo '上传失败:' . $uploader->getError();
}

常见问题和坑

1. 上传大文件失败

原因:php.ini的uploadmaxfilesize和postmaxsize设置太小。 解决:修改php.ini配置,或用分片上传。

2. moveuploadedfile()失败

原因:目标目录不存在或没有写入权限。 解决:确保目录存在,权限设置正确(755)。

3. $_FILES为空

原因:表单没有设置enctype="multipart/form-data",或method不是POST。 解决:检查表单设置。

4. 中文文件名乱码

原因:文件名编码问题。 解决:不要用原始文件名,生成随机文件名;或用iconv/mbconvertencoding转换编码。

5. 上传后文件找不到

原因:上传目录路径错误,或文件被自动删除(临时文件)。 解决:用绝对路径,确保moveuploadedfile()成功执行。

6. 图片上传后不能显示

原因:文件权限问题,或路径错误,或Nginx/Apache配置问题。 解决:检查文件权限(644),检查路径,检查Web服务器配置。

总结

PHP文件上传,是Web开发中常用但需要小心的功能。

核心要点:

  1. 基础:表单设置enctype="multipart/form-data",$FILES获取文件信息,moveuploaded_file()保存文件
  2. 安全加固

- 验证文件类型(finfo/getimagesize/文件头,不要信任$_FILES['type']) - 重命名文件(不要用原始文件名) - 存储目录安全(禁止执行PHP/目录不在Web根/权限设置) - 防止路径遍历(basename/完全生成新文件名) - 验证文件大小 - 图片二次渲染(清除图片马) - 防重复上传(文件哈希)

  1. 多文件上传:name加[],multiple属性,按索引遍历$_FILES
  2. 大文件上传:修改php.ini配置,分片上传,进度条
  3. 图片处理:生成缩略图、裁剪、加水印(GD库)
  4. 常见问题:大文件失败、move失败、$_FILES为空、中文乱码、文件找不到、图片不显示

文件上传的安全,怎么强调都不为过。一个不安全的文件上传功能,可能导致服务器被入侵,数据泄露,甚至整个网站被控制。所以,实现文件上传功能时,一定要做好安全加固,不要掉以轻心。

希望这篇文章,能帮你实现一个安全、稳定的文件上传功能。