Skip to content

POST 方法上传

概述

文件上传是 Web 开发中最常见的功能之一。PHP 通过 $_FILES 超全局数组获取上传的文件信息,使用 move_uploaded_file() 将文件从临时目录移动到目标位置。is_uploaded_file() 用于验证文件是否通过 HTTP POST 上传。PHP 配置中的 upload_max_filesizepost_max_size 决定了上传限制。

适用场景

  • 用户头像上传
  • 文档/图片管理
  • CSV 数据导入
  • 附件上传功能

基础概念

$_FILES 数组结构

php
<?php

// 单文件上传:HTML <input type="file" name="avatar">
$_FILES = [
    'avatar' => [
        'name'     => 'photo.jpg',           // 原始文件名
        'full_path' => '/path/to/photo.jpg', // 完整路径(PHP 8.1+)
        'type'     => 'image/jpeg',           // MIME 类型(客户端提供)
        'tmp_name' => '/tmp/phpXXXXXX',       // 临时文件路径
        'error'    => UPLOAD_ERR_OK,          // 错误码
        'size'     => 123456,                 // 文件大小(字节)
    ],
];

核心函数

函数功能返回值
move_uploaded_file()移动上传的文件bool
is_uploaded_file()验证上传文件bool

相关配置

配置项默认值说明
file_uploadsOn是否允许 HTTP 上传
upload_max_filesize2M单个文件最大大小
post_max_size8MPOST 数据最大大小
max_file_uploads20同时上传文件数上限
upload_tmp_dir系统临时目录上传临时目录

PHP 8.1 新增

$_FILES 新增 full_path 键,包含文件的完整路径。仅当浏览器提供时才有值。

语法与代码示例

HTML 表单

html
<!-- 单文件上传 -->
<form action="upload.php" method="POST" enctype="multipart/form-data">
    <input type="file" name="avatar" accept="image/*">
    <button type="submit">上传</button>
</form>

<!-- 限制文件大小(HTML5,单位字节) -->
<form action="upload.php" method="POST" enctype="multipart/form-data">
    <input type="hidden" name="MAX_FILE_SIZE" value="1048576"> <!-- 1MB -->
    <input type="file" name="document" required>
    <button type="submit">上传</button>
</form>
php
<?php

// upload.php - 基本文件上传处理
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    exit('Method Not Allowed');
}

$uploadDir = '/var/www/html/uploads/';

if (!is_dir($uploadDir)) {
    mkdir($uploadDir, 0755, true);
}

if (!isset($_FILES['avatar']) || $_FILES['avatar']['error'] !== UPLOAD_ERR_OK) {
    die('上传失败');
}

$file = $_FILES['avatar'];

// 验证是上传的文件
if (!is_uploaded_file($file['tmp_name'])) {
    die('非法上传');
}

// 生成安全文件名
$ext = pathinfo($file['name'], PATHINFO_EXTENSION);
$newName = bin2hex(random_bytes(16)) . '.' . $ext;
$destPath = $uploadDir . $newName;

// 移动文件
if (move_uploaded_file($file['tmp_name'], $destPath)) {
    echo "上传成功: {$newName}\n";
    echo "文件大小: {$file['size']} 字节\n";
} else {
    die('文件移动失败');
}

完整上传处理

php
<?php

declare(strict_types=1);

class FileUploader
{
    private string $uploadDir;
    private int $maxSize;
    private array $allowedMimes;
    private int $maxNameLength;

    public function __construct(
        string $uploadDir,
        int $maxSize = 10485760, // 10MB
        array $allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
        int $maxNameLength = 255
    ) {
        $this->uploadDir = rtrim($uploadDir, '/\\');
        $this->maxSize = $maxSize;
        $this->allowedMimes = $allowedMimes;
        $this->maxNameLength = $maxNameLength;

        if (!is_dir($this->uploadDir)) {
            mkdir($this->uploadDir, 0755, true);
        }
    }

    public function upload(array $file): array
    {
        // 检查错误
        if (!isset($file['error']) || $file['error'] !== UPLOAD_ERR_OK) {
            throw new RuntimeException($this->getErrorMessage($file['error'] ?? UPLOAD_ERR_NO_FILE));
        }

        // 检查上传
        if (!is_uploaded_file($file['tmp_name'])) {
            throw new RuntimeException('非法上传文件');
        }

        // 检查文件大小
        if ($file['size'] > $this->maxSize) {
            throw new RuntimeException("文件大小超出限制: {$this->maxSize} 字节");
        }

        // 检查 MIME 类型
        $finfo = new finfo(FILEINFO_MIME_TYPE);
        $mime = $finfo->file($file['tmp_name']);
        if (!in_array($mime, $this->allowedMimes, true)) {
            throw new RuntimeException("不允许的文件类型: {$mime}");
        }

        // 生成安全文件名
        $ext = $this->getExtension($mime);
        $filename = $this->generateFilename() . '.' . $ext;
        $destPath = $this->uploadDir . DIRECTORY_SEPARATOR . $filename;

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

        // 设置权限
        chmod($destPath, 0644);

        return [
            'filename'  => $filename,
            'path'      => $destPath,
            'mime_type' => $mime,
            'size'      => $file['size'],
            'original'  => $this->sanitizeName($file['name']),
        ];
    }

    private function getExtension(string $mime): string
    {
        $map = [
            'image/jpeg' => 'jpg',
            'image/png'  => 'png',
            'image/gif'  => 'gif',
            'image/webp' => 'webp',
            'application/pdf' => 'pdf',
        ];
        return $map[$mime] ?? 'bin';
    }

    private function generateFilename(): string
    {
        return date('Ymd_His') . '_' . bin2hex(random_bytes(8));
    }

    private function sanitizeName(string $name): string
    {
        $name = basename($name);
        $name = preg_replace('/[^a-zA-Z0-9._-]/', '_', $name);
        return substr($name, 0, $this->maxNameLength);
    }

    private function getErrorMessage(int $code): string
    {
        $messages = [
            UPLOAD_ERR_INI_SIZE   => '文件超过 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] ?? '未知上传错误';
    }
}

// 使用示例
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['avatar'])) {
    $uploader = new FileUploader('/var/www/html/uploads');
    try {
        $result = $uploader->upload($_FILES['avatar']);
        echo "上传成功: {$result['filename']}\n";
    } catch (RuntimeException $e) {
        echo "上传失败: {$e->getMessage()}\n";
    }
}

is_uploaded_file 验证

php
<?php

// is_uploaded_file 检查文件是否通过 HTTP POST 上传
// 这是安全措施,防止攻击者通过构造 $_FILES 数组来利用文件路径

$tmpName = $_FILES['document']['tmp_name'];

if (!is_uploaded_file($tmpName)) {
    http_response_code(400);
    die('非法上传请求');
}

// 安全移动
$destDir = '/var/www/html/uploads/documents/';
$newName = md5_file($tmpName) . '.' . pathinfo($_FILES['document']['name'], PATHINFO_EXTENSION);
move_uploaded_file($tmpName, $destDir . $newName);

实战示例

配置文件上传限制

bash
# php.ini 文件上传相关配置
ini
; 启用文件上传
file_uploads = On

; 单个文件最大 20MB
upload_max_filesize = 20M

; POST 数据最大 50MB(应大于 upload_max_filesize * 最大文件数量)
post_max_size = 50M

; 最大同时上传文件数
max_file_uploads = 20

; 临时上传目录
upload_tmp_dir = /tmp

; 脚本最大执行时间(秒)
max_execution_time = 60

; 脚本最大内存
memory_limit = 256M
php
<?php

// 运行时获取配置
function getUploadLimits(): array
{
    return [
        'uploadMaxFilesize' => ini_get('upload_max_filesize'),
        'postMaxSize'       => ini_get('post_max_size'),
        'maxFileUploads'    => ini_get('max_file_uploads'),
        'uploadTmpDir'      => ini_get('upload_tmp_dir'),
        'maxExecutionTime'  => ini_get('max_execution_time'),
        'memoryLimit'      => ini_get('memory_limit'),
    ];
}

// 转为字节数
function parseSizeString(string $size): int
{
    $unit = strtolower($size[-1]);
    $value = (int)$size;

    return match ($unit) {
        'g' => $value * 1073741824,
        'm' => $value * 1048576,
        'k' => $value * 1024,
        default => $value,
    };
}

// 前端友好的限制信息
$limits = getUploadLimits();
echo "最大文件大小: {$limits['uploadMaxFilesize']}\n";
echo "最大 POST 大小: {$limits['postMaxSize']}\n";

分块上传处理(前端配合)

php
<?php

declare(strict_types=1);

/**
 * 处理分块上传
 * 前端将大文件分为多个块上传
 */
class ChunkUploader
{
    private string $chunkDir;

    public function __construct(string $baseDir)
    {
        $this->chunkDir = $baseDir . DIRECTORY_SEPARATOR . 'chunks';
        if (!is_dir($this->chunkDir)) {
            mkdir($this->chunkDir, 0755, true);
        }
    }

    public function uploadChunk(array $file, string $fileId, int $chunkIndex, int $totalChunks): array
    {
        // 保存分块
        $chunkPath = $this->chunkDir . DIRECTORY_SEPARATOR . "{$fileId}_{$chunkIndex}";
        move_uploaded_file($file['tmp_name'], $chunkPath);

        // 检查是否所有分块都已上传
        $uploadedChunks = glob($this->chunkDir . DIRECTORY_SEPARATOR . "{$fileId}_*");
        $uploadedCount = count($uploadedChunks);

        if ($uploadedCount < $totalChunks) {
            return ['status' => 'partial', 'progress' => $uploadedCount / $totalChunks];
        }

        // 合并分块
        $finalPath = $this->mergeChunks($fileId, $totalChunks);
        $this->cleanupChunks($fileId);

        return [
            'status' => 'complete',
            'path'   => $finalPath,
            'size'   => filesize($finalPath),
        ];
    }

    private function mergeChunks(string $fileId, int $totalChunks): string
    {
        $finalDir = dirname($this->chunkDir) . DIRECTORY_SEPARATOR . 'completed';
        if (!is_dir($finalDir)) {
            mkdir($finalDir, 0755, true);
        }

        $finalPath = $finalDir . DIRECTORY_SEPARATOR . $fileId;
        $destHandle = fopen($finalPath, 'wb');

        if ($destHandle === false) {
            throw new RuntimeException('无法创建最终文件');
        }

        for ($i = 0; $i < $totalChunks; $i++) {
            $chunkPath = $this->chunkDir . DIRECTORY_SEPARATOR . "{$fileId}_{$i}";
            $chunkData = file_get_contents($chunkPath);
            fwrite($destHandle, $chunkData);
        }

        fclose($destHandle);
        return $finalPath;
    }

    private function cleanupChunks(string $fileId): void
    {
        $chunks = glob($this->chunkDir . DIRECTORY_SEPARATOR . "{$fileId}_*");
        foreach ($chunks as $chunk) {
            unlink($chunk);
        }
    }
}

注意事项

move_uploaded_file 安全性

php
<?php

// move_uploaded_file 会自动检查:
// 1. 文件是否由 PHP 上传机制创建
// 2. 源文件是否为合法的上传临时文件

// 不要用 rename() 或 copy() 替代 move_uploaded_file()
// rename() 没有安全检查,可能导致任意文件操作漏洞

// 不好
rename($_FILES['avatar']['tmp_name'], '/var/www/html/uploads/photo.jpg');

// 好
move_uploaded_file($_FILES['avatar']['tmp_name'], '/var/www/html/uploads/photo.jpg');

安全警告

始终使用 move_uploaded_file() 而非 copy()rename() 来处理上传文件。move_uploaded_file() 内置了安全检查,确保文件确实是上传的。

临时文件清理

php
<?php

// PHP 会在请求结束后自动清理上传临时文件
// 但长时间运行的脚本需要手动清理

// 检查临时文件是否存在
$tmpFile = $_FILES['document']['tmp_name'];
if (!file_exists($tmpFile)) {
    die('临时文件已被清理');
}

// 请求结束后临时文件会自动删除
// 不要依赖临时文件长时间存在

最佳实践

1. 完整的上传验证流程

php
<?php

function handleUpload(array $file): string
{
    // 1. 验证上传
    if ($file['error'] !== UPLOAD_ERR_OK) {
        throw new RuntimeException('上传错误: ' . $file['error']);
    }

    // 2. 验证存在
    if (!is_uploaded_file($file['tmp_name'])) {
        throw new RuntimeException('非法上传');
    }

    // 3. 验证大小
    $maxSize = 10 * 1024 * 1024; // 10MB
    if ($file['size'] > $maxSize || $file['size'] === 0) {
        throw new RuntimeException('文件大小无效');
    }

    // 4. 验证 MIME
    $finfo = new finfo(FILEINFO_MIME_TYPE);
    $mime = $finfo->file($file['tmp_name']);
    if (!in_array($mime, ['image/jpeg', 'image/png'], true)) {
        throw new RuntimeException('不支持的文件类型');
    }

    // 5. 移动文件
    $destDir = '/var/www/html/uploads/' . date('Y/m/d');
    if (!is_dir($destDir)) {
        mkdir($destDir, 0755, true);
    }

    $newName = bin2hex(random_bytes(16)) . '.' . pathinfo($file['name'], PATHINFO_EXTENSION);
    $destPath = $destDir . DIRECTORY_SEPARATOR . $newName;

    if (!move_uploaded_file($file['tmp_name'], $destPath)) {
        throw new RuntimeException('文件保存失败');
    }

    chmod($destPath, 0644);
    return $destPath;
}

2. 上传目录禁止执行 PHP

apache
# Apache .htaccess
<FilesMatch "\.php$">
    Deny from all
</FilesMatch>

# nginx 配置
location ~* ^/uploads/ {
    location ~ \.php$ {
        deny all;
    }
}

参考链接