文件上传

学习目标

  • 掌握PHP文件上传的基本原理
  • 学会处理各种文件上传场景
  • 理解文件上传的安全风险和防护措施
  • 能够构建完整的文件上传系统

文件上传概述

文件上传是Web开发中的常见功能,允许用户将本地文件上传到服务器:

  • 用户头像:个人资料图片上传
  • 文档管理:上传和分享文档
  • 媒体内容:图片、视频、音频上传
  • 数据导入:批量数据文件处理
  • 系统配置:配置文件上传

PHP文件上传配置

php.ini 配置

; 文件上传相关配置
file_uploads = On              ; 启用文件上传
upload_max_filesize = 2M       ; 单个文件最大大小
max_file_uploads = 20          ; 同时上传文件数量限制
post_max_size = 8M             ; POST数据最大大小
memory_limit = 128M            ; 内存限制
max_execution_time = 30        ; 脚本执行时间限制
upload_tmp_dir = /tmp          ; 临时文件目录

基本文件上传

HTML表单

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>文件上传</title>
    <style>
        .upload-form {
            max-width: 600px;
            margin: 20px auto;
            padding: 20px;
            border: 1px solid #ddd;
            border-radius: 5px;
        }
        .form-group {
            margin-bottom: 15px;
        }
        label {
            display: block;
            margin-bottom: 5px;
            font-weight: bold;
        }
        input[type="file"] {
            width: 100%;
            padding: 8px;
            border: 1px solid #ccc;
            border-radius: 3px;
        }
        button {
            background-color: #007bff;
            color: white;
            padding: 10px 20px;
            border: none;
            border-radius: 3px;
            cursor: pointer;
        }
        .progress {
            width: 100%;
            height: 20px;
            background-color: #f0f0f0;
            border-radius: 10px;
            overflow: hidden;
            margin-top: 10px;
        }
        .progress-bar {
            height: 100%;
            background-color: #007bff;
            width: 0%;
            transition: width 0.3s ease;
        }
    </style>
</head>
<body>
    <h1>文件上传</h1>

    <!-- 单文件上传 -->
    <div class="upload-form">
        <h2>单文件上传</h2>
        <form action="upload_single.php" method="post" enctype="multipart/form-data">
            <div class="form-group">
                <label for="file1">选择文件:</label>
                <input type="file" id="file1" name="file" accept="image/*" required>
            </div>
            <div class="form-group">
                <label for="description">文件描述:</label>
                <input type="text" id="description" name="description" placeholder="输入文件描述">
            </div>
            <button type="submit">上传文件</button>
        </form>
    </div>

    <!-- 多文件上传 -->
    <div class="upload-form">
        <h2>多文件上传</h2>
        <form action="upload_multiple.php" method="post" enctype="multipart/form-data">
            <div class="form-group">
                <label for="files">选择多个文件:</label>
                <input type="file" id="files" name="files[]" multiple accept="image/*,application/pdf">
            </div>
            <button type="submit">上传文件</button>
        </form>
    </div>

    <!-- 拖拽上传 -->
    <div class="upload-form">
        <h2>拖拽上传</h2>
        <div id="dropZone" style="
            border: 2px dashed #ccc;
            padding: 40px;
            text-align: center;
            background-color: #f9f9f9;
            cursor: pointer;
        ">
            <p>拖拽文件到这里或点击选择文件</p>
            <input type="file" id="dropFile" name="file" multiple style="display: none;">
        </div>
        <div class="progress">
            <div class="progress-bar" id="progressBar"></div>
        </div>
    </div>

    <script>
        // 拖拽上传功能
        const dropZone = document.getElementById('dropZone');
        const dropFile = document.getElementById('dropFile');
        const progressBar = document.getElementById('progressBar');

        dropZone.addEventListener('click', () => {
            dropFile.click();
        });

        dropZone.addEventListener('dragover', (e) => {
            e.preventDefault();
            dropZone.style.backgroundColor = '#e3f2fd';
        });

        dropZone.addEventListener('dragleave', () => {
            dropZone.style.backgroundColor = '#f9f9f9';
        });

        dropZone.addEventListener('drop', (e) => {
            e.preventDefault();
            dropZone.style.backgroundColor = '#f9f9f9';

            const files = e.dataTransfer.files;
            handleFiles(files);
        });

        function handleFiles(files) {
            // 创建FormData对象
            const formData = new FormData();
            for (let i = 0; i < files.length; i++) {
                formData.append('files[]', files[i]);
            }

            // 创建AJAX请求
            const xhr = new XMLHttpRequest();

            // 进度事件
            xhr.upload.addEventListener('progress', (e) => {
                if (e.lengthComputable) {
                    const percentComplete = (e.loaded / e.total) * 100;
                    progressBar.style.width = percentComplete + '%';
                }
            });

            // 完成事件
            xhr.addEventListener('load', () => {
                if (xhr.status === 200) {
                    alert('文件上传成功!');
                    progressBar.style.width = '0%';
                } else {
                    alert('文件上传失败!');
                }
            });

            // 错误事件
            xhr.addEventListener('error', () => {
                alert('上传过程中发生错误!');
            });

            // 发送请求
            xhr.open('POST', 'upload_ajax.php', true);
            xhr.send(formData);
        }
    </script>
</body>
</html>

PHP处理代码

单文件上传处理

<?php
// upload_single.php
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    try {
        // 检查是否有文件上传
        if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
            throw new Exception('请选择要上传的文件');
        }

        $file = $_FILES['file'];
        $description = $_POST['description'] ?? '';

        // 验证文件
        $errors = validateFile($file);
        if (!empty($errors)) {
            throw new Exception(implode(', ', $errors));
        }

        // 处理上传
        $result = processFileUpload($file, $description);

        if ($result) {
            echo '<div style="color: green; padding: 10px; border: 1px solid #4CAF50; background-color: #f0f8f0; border-radius: 5px;">';
            echo '<h3>文件上传成功!</h3>';
            echo '<p>文件名:' . htmlspecialchars($result['filename']) . '</p>';
            echo '<p>文件大小:' . formatFileSize($result['size']) . '</p>';
            echo '<p>文件类型:' . htmlspecialchars($result['type']) . '</p>';
            if ($description) {
                echo '<p>描述:' . htmlspecialchars($description) . '</p>';
            }
            echo '<p><a href="' . htmlspecialchars($result['url']) . '" target="_blank">查看文件</a></p>';
            echo '</div>';
        }

    } catch (Exception $e) {
        echo '<div style="color: red; padding: 10px; border: 1px solid #f44336; background-color: #fff0f0; border-radius: 5px;">';
        echo '<h3>上传失败</h3>';
        echo '<p>' . htmlspecialchars($e->getMessage()) . '</p>';
        echo '</div>';
    }
}

/**
 * 验证上传的文件
 */
function validateFile($file) {
    $errors = [];

    // 检查文件大小
    $maxSize = 5 * 1024 * 1024; // 5MB
    if ($file['size'] > $maxSize) {
        $errors[] = '文件大小不能超过 5MB';
    }

    // 检查文件类型
    $allowedTypes = [
        'image/jpeg',
        'image/png',
        'image/gif',
        'image/webp',
        'application/pdf',
        'text/plain',
        'application/msword',
        'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
    ];

    if (!in_array($file['type'], $allowedTypes)) {
        $errors[] = '不支持的文件类型';
    }

    // 检查文件扩展名
    $allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf', 'txt', 'doc', 'docx'];
    $extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));

    if (!in_array($extension, $allowedExtensions)) {
        $errors[] = '不支持的文件扩展名';
    }

    // 使用finfo检查真实文件类型
    $finfo = finfo_open(FILEINFO_MIME_TYPE);
    $mimeType = finfo_file($finfo, $file['tmp_name']);
    finfo_close($finfo);

    if (!in_array($mimeType, $allowedTypes)) {
        $errors[] = '文件内容与扩展名不匹配';
    }

    return $errors;
}

/**
 * 处理文件上传
 */
function processFileUpload($file, $description = '') {
    // 创建上传目录
    $uploadDir = 'uploads/' . date('Y/m/d');
    if (!file_exists($uploadDir)) {
        mkdir($uploadDir, 0755, true);
    }

    // 生成安全的文件名
    $originalName = $file['name'];
    $extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
    $basename = pathinfo($originalName, PATHINFO_FILENAME);

    // 清理文件名
    $safeBasename = preg_replace('/[^a-zA-Z0-9\-_]/', '', $basename);
    $filename = $safeBasename . '_' . uniqid() . '.' . $extension;

    $uploadPath = $uploadDir . '/' . $filename;

    // 移动文件
    if (!move_uploaded_file($file['tmp_name'], $uploadPath)) {
        throw new Exception('文件移动失败');
    }

    // 保存文件信息(这里可以保存到数据库)
    $fileInfo = [
        'original_name' => $originalName,
        'filename' => $filename,
        'path' => $uploadPath,
        'url' => $uploadPath,
        'size' => $file['size'],
        'type' => $file['type'],
        'description' => $description,
        'upload_time' => date('Y-m-d H:i:s')
    ];

    // 保存到数据库示例
    // saveFileInfo($fileInfo);

    return $fileInfo;
}

/**
 * 格式化文件大小
 */
function formatFileSize($bytes) {
    $units = ['B', 'KB', 'MB', 'GB'];
    $bytes = max($bytes, 0);
    $pow = floor(($bytes ? log($bytes) : 0) / log(1024));
    $pow = min($pow, count($units) - 1);

    $bytes /= pow(1024, $pow);

    return round($bytes, 2) . ' ' . $units[$pow];
}

/**
 * 获取文件MIME类型
 */
function getMimeType($filename) {
    $finfo = finfo_open(FILEINFO_MIME_TYPE);
    $mimeType = finfo_file($finfo, $filename);
    finfo_close($finfo);
    return $mimeType;
}

/**
 * 生成缩略图(仅适用于图片)
 */
function createThumbnail($sourcePath, $targetPath, $width = 200, $height = 200) {
    // 获取图片信息
    $imageInfo = getimagesize($sourcePath);
    if (!$imageInfo) {
        return false;
    }

    $sourceWidth = $imageInfo[0];
    $sourceHeight = $imageInfo[1];
    $mimeType = $imageInfo['mime'];

    // 根据MIME类型创建图片资源
    switch ($mimeType) {
        case 'image/jpeg':
            $sourceImage = imagecreatefromjpeg($sourcePath);
            break;
        case 'image/png':
            $sourceImage = imagecreatefrompng($sourcePath);
            break;
        case 'image/gif':
            $sourceImage = imagecreatefromgif($sourcePath);
            break;
        case 'image/webp':
            $sourceImage = imagecreatefromwebp($sourcePath);
            break;
        default:
            return false;
    }

    if (!$sourceImage) {
        return false;
    }

    // 计算缩略图尺寸
    $ratio = min($width / $sourceWidth, $height / $sourceHeight);
    $thumbWidth = (int)($sourceWidth * $ratio);
    $thumbHeight = (int)($sourceHeight * $ratio);

    // 创建缩略图
    $thumbImage = imagecreatetruecolor($thumbWidth, $thumbHeight);

    // 处理透明度(PNG和GIF)
    if ($mimeType == 'image/png' || $mimeType == 'image/gif') {
        imagealphablending($thumbImage, false);
        imagesavealpha($thumbImage, true);
        $transparent = imagecolorallocatealpha($thumbImage, 255, 255, 255, 127);
        imagefilledrectangle($thumbImage, 0, 0, $thumbWidth, $thumbHeight, $transparent);
    }

    // 复制并调整大小
    imagecopyresampled($thumbImage, $sourceImage, 0, 0, 0, 0, $thumbWidth, $thumbHeight, $sourceWidth, $sourceHeight);

    // 保存缩略图
    $result = false;
    switch ($mimeType) {
        case 'image/jpeg':
            $result = imagejpeg($thumbImage, $targetPath, 90);
            break;
        case 'image/png':
            $result = imagepng($thumbImage, $targetPath, 9);
            break;
        case 'image/gif':
            $result = imagegif($thumbImage, $targetPath);
            break;
        case 'image/webp':
            $result = imagewebp($thumbImage, $targetPath, 90);
            break;
    }

    // 释放内存
    imagedestroy($sourceImage);
    imagedestroy($thumbImage);

    return $result;
}
?>

多文件上传处理

<?php
// upload_multiple.php
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $uploadedFiles = [];
    $errors = [];

    if (!isset($_FILES['files'])) {
        echo '没有文件被上传';
        exit;
    }

    $files = $_FILES['files'];

    // 处理多个文件
    $fileCount = count($files['name']);

    for ($i = 0; $i < $fileCount; $i++) {
        $file = [
            'name' => $files['name'][$i],
            'type' => $files['type'][$i],
            'tmp_name' => $files['tmp_name'][$i],
            'error' => $files['error'][$i],
            'size' => $files['size'][$i]
        ];

        // 跳过空文件
        if ($file['error'] == UPLOAD_ERR_NO_FILE) {
            continue;
        }

        try {
            // 验证文件
            $fileErrors = validateFile($file);
            if (!empty($fileErrors)) {
                $errors[$file['name']] = $fileErrors;
                continue;
            }

            // 处理上传
            $result = processFileUpload($file);
            $uploadedFiles[] = $result;

        } catch (Exception $e) {
            $errors[$file['name']] = [$e->getMessage()];
        }
    }

    // 显示结果
    if (!empty($uploadedFiles)) {
        echo '<div style="color: green; padding: 10px; border: 1px solid #4CAF50; background-color: #f0f8f0; border-radius: 5px; margin-bottom: 20px;">';
        echo '<h3>成功上传 ' . count($uploadedFiles) . ' 个文件</h3>';
        foreach ($uploadedFiles as $file) {
            echo '<p>' . htmlspecialchars($file['filename']) . ' (' . formatFileSize($file['size']) . ')</p>';
        }
        echo '</div>';
    }

    if (!empty($errors)) {
        echo '<div style="color: red; padding: 10px; border: 1px solid #f44336; background-color: #fff0f0; border-radius: 5px;">';
        echo '<h3>上传失败的文件</h3>';
        foreach ($errors as $filename => $fileErrors) {
            echo '<p><strong>' . htmlspecialchars($filename) . ':</strong> ' . implode(', ', $fileErrors) . '</p>';
        }
        echo '</div>';
    }
}
?>

AJAX上传处理

<?php
// upload_ajax.php
header('Content-Type: application/json');

try {
    if (!isset($_FILES['files'])) {
        throw new Exception('没有文件被上传');
    }

    $files = $_FILES['files'];
    $uploadedFiles = [];

    // 处理单个或多个文件
    if (is_array($files['name'])) {
        // 多文件上传
        $fileCount = count($files['name']);
        for ($i = 0; $i < $fileCount; $i++) {
            $file = [
                'name' => $files['name'][$i],
                'type' => $files['type'][$i],
                'tmp_name' => $files['tmp_name'][$i],
                'error' => $files['error'][$i],
                'size' => $files['size'][$i]
            ];

            if ($file['error'] == UPLOAD_ERR_OK) {
                $result = processFileUpload($file);
                $uploadedFiles[] = $result;
            }
        }
    } else {
        // 单文件上传
        if ($files['error'] == UPLOAD_ERR_OK) {
            $result = processFileUpload($files);
            $uploadedFiles[] = $result;
        }
    }

    echo json_encode([
        'success' => true,
        'message' => '文件上传成功',
        'files' => $uploadedFiles
    ]);

} catch (Exception $e) {
    http_response_code(400);
    echo json_encode([
        'success' => false,
        'message' => $e->getMessage()
    ]);
}
?>

安全性考虑

1. 文件类型验证

<?php
function validateFileType($file, $allowedTypes) {
    // 检查MIME类型
    $finfo = finfo_open(FILEINFO_MIME_TYPE);
    $mimeType = finfo_file($finfo, $file['tmp_name']);
    finfo_close($finfo);

    if (!in_array($mimeType, $allowedTypes)) {
        return false;
    }

    // 检查扩展名
    $extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
    $allowedExtensions = array_map(function($type) {
        return explode('/', $type)[1];
    }, $allowedTypes);

    if (!in_array($extension, $allowedExtensions)) {
        return false;
    }

    return true;
}

// 使用示例
$allowedImageTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!validateFileType($_FILES['avatar'], $allowedImageTypes)) {
    die('只允许上传图片文件');
}
?>

2. 文件大小限制

<?php
function validateFileSize($file, $maxSize) {
    return $file['size'] <= $maxSize;
}

// 根据文件类型设置不同的大小限制
function getMaxFileSizeByType($mimeType) {
    $limits = [
        'image/jpeg' => 5 * 1024 * 1024,      // 5MB
        'image/png' => 5 * 1024 * 1024,       // 5MB
        'image/gif' => 2 * 1024 * 1024,       // 2MB
        'application/pdf' => 10 * 1024 * 1024, // 10MB
        'text/plain' => 1 * 1024 * 1024,       // 1MB
    ];

    return $limits[$mimeType] ?? 2 * 1024 * 1024; // 默认2MB
}
?>

3. 文件名安全处理

<?php
function generateSafeFilename($originalName) {
    // 获取文件信息
    $pathInfo = pathinfo($originalName);
    $extension = strtolower($pathInfo['extension']);
    $basename = $pathInfo['filename'];

    // 清理文件名:只保留字母、数字、连字符和下划线
    $safeBasename = preg_replace('/[^a-zA-Z0-9\-_]/', '', $basename);

    // 如果清理后为空,使用默认名称
    if (empty($safeBasename)) {
        $safeBasename = 'file';
    }

    // 限制长度
    $safeBasename = substr($safeBasename, 0, 50);

    // 添加唯一ID防止文件名冲突
    $uniqueId = uniqid();
    $timestamp = date('YmdHis');

    return sprintf('%s_%s_%s.%s', $safeBasename, $timestamp, $uniqueId, $extension);
}

function isExecutableFile($filename) {
    $executableExtensions = ['php', 'phtml', 'php3', 'php4', 'php5', 'php7', 'phps', 'sh', 'bat', 'exe', 'com', 'cmd', 'scr'];
    $extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
    return in_array($extension, $executableExtensions);
}
?>

4. 目录权限设置

<?php
function secureUploadDirectory($uploadDir) {
    // 创建目录(如果不存在)
    if (!file_exists($uploadDir)) {
        mkdir($uploadDir, 0755, true);
    }

    // 设置目录权限
    chmod($uploadDir, 0755);

    // 创建.htaccess文件防止直接访问(仅适用于Apache)
    $htaccessFile = $uploadDir . '/.htaccess';
    if (!file_exists($htaccessFile)) {
        $htaccessContent = "Order deny,allow\nDeny from all\n";
        if (in_array(pathinfo($uploadDir, PATHINFO_BASENAME), ['images', 'avatars', 'photos'])) {
            $htaccessContent = "Order allow,deny\nAllow from all\n";
        }
        file_put_contents($htaccessFile, $htaccessContent);
    }

    // 创建index.html防止目录列表
    $indexFile = $uploadDir . '/index.html';
    if (!file_exists($indexFile)) {
        file_put_contents($indexFile, '<!DOCTYPE html><html><head><title>Access Denied</title></head><body><h1>Access Denied</h1></body></html>');
    }
}

// 使用示例
secureUploadDirectory('uploads/images');
?>

完整的文件上传类

<?php
class FileUploader {
    private $uploadDir;
    private $allowedTypes;
    private $maxFileSize;
    private $errors;

    public function __construct($uploadDir = 'uploads', $allowedTypes = [], $maxFileSize = 5242880) {
        $this->uploadDir = rtrim($uploadDir, '/');
        $this->allowedTypes = $allowedTypes;
        $this->maxFileSize = $maxFileSize;
        $this->errors = [];

        $this->initUploadDirectory();
    }

    private function initUploadDirectory() {
        if (!file_exists($this->uploadDir)) {
            mkdir($this->uploadDir, 0755, true);
        }
    }

    public function upload($file, $description = '') {
        $this->errors = [];

        if (!$this->validateFile($file)) {
            return false;
        }

        return $this->processUpload($file, $description);
    }

    public function uploadMultiple($files) {
        $this->errors = [];
        $uploadedFiles = [];

        foreach ($files['name'] as $key => $name) {
            $file = [
                'name' => $name,
                'type' => $files['type'][$key],
                'tmp_name' => $files['tmp_name'][$key],
                'error' => $files['error'][$key],
                'size' => $files['size'][$key]
            ];

            if ($file['error'] === UPLOAD_ERR_OK) {
                $result = $this->upload($file);
                if ($result) {
                    $uploadedFiles[] = $result;
                }
            }
        }

        return $uploadedFiles;
    }

    private function validateFile($file) {
        if ($file['error'] !== UPLOAD_ERR_OK) {
            $this->errors[] = $this->getUploadErrorMessage($file['error']);
            return false;
        }

        if (!empty($this->allowedTypes) && !in_array($file['type'], $this->allowedTypes)) {
            $this->errors[] = '不支持的文件类型';
            return false;
        }

        if ($file['size'] > $this->maxFileSize) {
            $this->errors[] = '文件大小超过限制';
            return false;
        }

        // 额外的安全检查
        $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->errors[] = '文件类型验证失败';
            return false;
        }

        return true;
    }

    private function processUpload($file, $description = '') {
        $filename = $this->generateSafeFilename($file['name']);
        $uploadPath = $this->uploadDir . '/' . $filename;

        if (!move_uploaded_file($file['tmp_name'], $uploadPath)) {
            $this->errors[] = '文件移动失败';
            return false;
        }

        return [
            'filename' => $filename,
            'original_name' => $file['name'],
            'path' => $uploadPath,
            'size' => $file['size'],
            'type' => $file['type'],
            'description' => $description,
            'upload_time' => date('Y-m-d H:i:s')
        ];
    }

    private function generateSafeFilename($originalName) {
        $pathInfo = pathinfo($originalName);
        $extension = strtolower($pathInfo['extension']);
        $basename = preg_replace('/[^a-zA-Z0-9\-_]/', '', $pathInfo['filename']);

        if (empty($basename)) {
            $basename = 'file';
        }

        return $basename . '_' . uniqid() . '.' . $extension;
    }

    private function getUploadErrorMessage($errorCode) {
        switch ($errorCode) {
            case UPLOAD_ERR_INI_SIZE:
                return '文件大小超过服务器限制';
            case UPLOAD_ERR_FORM_SIZE:
                return '文件大小超过表单限制';
            case UPLOAD_ERR_PARTIAL:
                return '文件只有部分被上传';
            case UPLOAD_ERR_NO_FILE:
                return '没有文件被上传';
            case UPLOAD_ERR_NO_TMP_DIR:
                return '缺少临时文件夹';
            case UPLOAD_ERR_CANT_WRITE:
                return '文件写入失败';
            default:
                return '未知上传错误';
        }
    }

    public function getErrors() {
        return $this->errors;
    }

    public function hasErrors() {
        return !empty($this->errors);
    }
}

// 使用示例
$uploader = new FileUploader('uploads', ['image/jpeg', 'image/png', 'image/gif'], 5 * 1024 * 1024);

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $result = $uploader->upload($_FILES['file']);

    if ($result) {
        echo '上传成功:' . $result['filename'];
    } else {
        echo '上传失败:' . implode(', ', $uploader->getErrors());
    }
}
?>

总结

文件上传是Web开发中的重要功能,但同时也存在安全风险。通过合理的配置、严格的验证和安全的处理流程,可以构建安全可靠的文件上传系统。记住:始终不要信任用户上传的文件,要进行多重验证和检查。