
PHP云系统如何接入对象存储:文件上传与权限控制实践
把用户上传的文件放在对象存储中,可以减轻 PHP 应用服务器的磁盘和带宽压力,也便于扩容、备份和通过 CDN 分发。但对象存储接入并不只是把本地文件改成一次 API 调用:上传身份、文件归属、读取授权、删除权限和异常处理,都需要一起设计。
本文以 AWS SDK for PHP 操作 S3 兼容对象存储为例。S3 协议常见于公有云对象存储和自建服务,但不同厂商在端点、区域、签名、分片上传及其他功能上可能有差异。部署前应以所用厂商的兼容性文档和 SDK 要求为准。
一、先明确适用场景和数据边界
对象存储适合图片、附件、导出文件、备份等非结构化数据。数据库通常保存对象键、文件大小、业务所有者、状态和必要的内容类型等元数据;文件内容由对象存储保存。不要把对象存储当成数据库:它不适合频繁修改文件中的局部内容,也不应依赖“列出目录”来实现核心业务查询。
接入前至少要明确以下边界:
- 存储桶(Bucket)是资源容器,不是用户权限系统。不要因为文件属于不同用户,就把桶名或密钥交给客户端。
- 对象键(Key)不是文件系统路径。键可以包含斜杠用于组织和展示,但通常没有真正的目录语义。不要直接用用户提供的文件名当对象键。
- 上传成功不等于业务文件可用。直传模式下,客户端可能中断上传,也可能上传了不符合业务要求的内容;服务端还需核验对象并更新业务记录。
- 下载权限由应用决定。公开 URL、登录校验、预签名 URL 是不同的授权方式。知道对象键不应自动获得私有文件的读取权限。
- 上传大小和类型限制通常要由应用落实。对象存储服务也有自己的请求、对象大小和分片限制,但具体数值依厂商和 API 而异,不能把某个厂商的限制当成通用规则。
二、安装 SDK 与配置凭证
安装 AWS SDK for PHP:
composer require aws/aws-sdk-php
下面的示例使用环境变量配置连接信息。凭证应通过云平台角色、密钥管理服务或部署环境注入,不要写入源码、提交到版本库,也不要发给浏览器。生产环境优先使用权限受限的实例角色或工作负载身份;本地开发可使用受保护的环境变量。
export AWS_ACCESS_KEY_ID='替换为访问密钥'
export AWS_SECRET_ACCESS_KEY='替换为访问密钥秘密'
export OBJECT_STORAGE_REGION='替换为服务要求的区域'
export OBJECT_STORAGE_BUCKET='替换为存储桶名称'
# 使用兼容服务时填写厂商提供的 S3 API 端点;原生 AWS S3 可不设置
export OBJECT_STORAGE_ENDPOINT='https://对象存储API端点'
连接客户端示例:
<?php
require __DIR__ . '/vendor/autoload.php';
use Aws\S3\S3Client;
$region = getenv('OBJECT_STORAGE_REGION');
$bucket = getenv('OBJECT_STORAGE_BUCKET');
if (!$region || !$bucket) {
throw new RuntimeException('对象存储区域或存储桶未配置');
}
$config = [
'version' => 'latest',
'region' => $region,
// SDK 可从标准凭证提供链读取环境变量或工作负载凭证
];
$endpoint = getenv('OBJECT_STORAGE_ENDPOINT');
if ($endpoint) {
$config['endpoint'] = $endpoint;
// 部分兼容服务需要路径式寻址;按厂商文档决定是否启用
$config['use_path_style_endpoint'] = true;
}
$s3 = new S3Client($config);
“latest”由 SDK 版本决定,不代表对象存储 API 永远没有兼容差异。上线前应固定并管理 Composer 依赖版本,在测试环境验证端点、签名、TLS 证书和寻址方式。若兼容服务不支持某项 S3 API,应使用厂商支持的 API,而不是假设所有服务行为完全相同。
三、权限设计:私有桶、最小授权、应用鉴权
默认建议将桶设为私有,并在云平台侧启用阻止意外公共访问的设置。应用服务端只拿到完成业务所需的最小权限:例如指定桶、指定前缀下的读取、写入和删除权限。不要给 Web 应用使用具备账户级管理权限的密钥。
对象存储权限和业务权限是两层:
- 云平台策略控制“这组服务端凭证能对哪些桶和对象执行什么操作”。
- 应用鉴权控制“当前登录用户能否上传、读取或删除某个业务文件”。
即使服务端有权读取整个桶,下载接口也必须先检查当前用户对文件记录的访问权。反过来,应用完成了用户鉴权,也不能弥补云端凭证权限过宽。还应避免在未经评估时设置对象 ACL;不少新建桶采用桶策略统一管理访问,ACL 可能被禁用或不受支持。不要把“上传时加 public-read”当作默认权限方案。
四、服务端代传:实现简单,适合中小文件
服务端代传时,浏览器把文件发给 PHP,PHP 做登录校验、文件检查,再调用对象存储。它便于统一业务校验和审计,但 PHP 服务器要承受文件流量,并受到 Web 服务器、PHP 配置、超时和临时磁盘空间等限制。
下面是可运行的核心示例。它使用 PHP 的标准上传机制、限制单文件大小为应用设定的 10 MiB,并用 Fileinfo 检测内容类型。这个 10 MiB 是示例中的业务限制,不是对象存储的通用上限;真实项目应结合产品需求、PHP 配置和基础设施限制设置。示例只允许 JPEG、PNG 和 PDF,需按业务调整。
<?php
require __DIR__ . '/vendor/autoload.php';
use Aws\S3\S3Client;
use Aws\Exception\AwsException;
// 实际项目应替换为框架的登录认证与 CSRF 防护。
session_start();
if (empty($_SESSION['user_id'])) {
http_response_code(401);
exit('请先登录');
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST'
|| !isset($_FILES['file'])
|| !is_array($_FILES['file'])) {
http_response_code(400);
exit('缺少上传文件');
}
$file = $_FILES['file'];
if ($file['error'] !== UPLOAD_ERR_OK) {
http_response_code(400);
exit('文件上传失败,错误码:' . (int)$file['error']);
}
$maxBytes = 10 * 1024 * 1024; // 应用设定的 10 MiB
if (!is_uploaded_file($file['tmp_name'])
|| $file['size'] <= 0
|| $file['size'] > $maxBytes) {
http_response_code(400);
exit('文件大小不符合要求');
}
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
$allowedTypes = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'application/pdf' => 'pdf',
];
if (!isset($allowedTypes[$mime])) {
http_response_code(415);
exit('不支持的文件类型');
}
$region = getenv('OBJECT_STORAGE_REGION');
$bucket = getenv('OBJECT_STORAGE_BUCKET');
if (!$region || !$bucket) {
http_response_code(500);
exit('存储服务未配置');
}
$config = [
'version' => 'latest',
'region' => $region,
];
$endpoint = getenv('OBJECT_STORAGE_ENDPOINT');
if ($endpoint) {
$config['endpoint'] = $endpoint;
$config['use_path_style_endpoint'] = true; // 按厂商要求调整
}
$s3 = new S3Client($config);
// 用服务端生成的不可预测键,避免路径穿越、重名覆盖和用户控制路径。
$userId = (string)$_SESSION['user_id'];
$key = 'uploads/' . hash('sha256', $userId) . '/'
. bin2hex(random_bytes(16)) . '.' . $allowedTypes[$mime];
try {
$result = $s3->putObject([
'Bucket' => $bucket,
'Key' => $key,
'Body' => fopen($file['tmp_name'], 'rb'),
'ContentType' => $mime,
// 不设置公开 ACL;对象应按桶策略保持私有
]);
// 生产项目应将 key、用户 ID、大小、检测到的 MIME、ETag 等
// 保存到数据库,并使用事务或明确的失败补偿策略。
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'key' => $key,
'etag' => isset($result['ETag']) ? trim($result['ETag'], '"') : null,
], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
} catch (AwsException $e) {
error_log('对象存储上传失败:' . $e->getAwsErrorCode());
http_response_code(502);
echo '文件存储失败,请稍后重试';
}
HTML 表单可使用:
<form method="post" enctype="multipart/form-data" action="/upload.php">
<input type="file" name="file" required>
<button type="submit">上传</button>
</form>
示例没有使用客户端提交的文件名作为对象键,也没有信任浏览器提供的 MIME 类型。文件名可以作为展示元数据保存,但在展示时要做 HTML 转义。MIME 检测只是风险降低措施,不是对文件内容安全的证明;如需处理 Office 文档、压缩包或用户可下载的主动内容,还应按业务加入病毒扫描、格式解析、内容隔离等措施。不要仅靠扩展名判断文件类型。
PHP 的 upload_max_filesize、post_max_size、Web 服务器请求体限制和代理超时也会影响代传。它们必须与应用限制协调;当请求超过 PHP 配置时,代码可能收不到预期的文件结构。尤其要检查线上环境的配置,而不是只在开发机验证。
五、浏览器直传:降低 PHP 带宽压力
文件较大或并发较高时,可以让 PHP 验证用户身份并签发短时预签名 URL,再由浏览器直接向对象存储上传。预签名 URL 是在有效期内授予特定操作的临时能力,拿到 URL 的人通常可以在其有效期内按签名约束执行该操作。因此不要把它写进日志、分析事件或长期保存,也不要设置不必要的长有效期。
服务端只允许为已登录用户生成服务端指定的对象键,并绑定预期的内容类型。下面的端点只展示签发过程;生产项目还应验证用户配额、业务状态和 CSRF 等要求。预签名 URL 是否支持指定的签名字段、有效期上限和兼容端点行为,需依 SDK 与服务商实现确认。
<?php
require __DIR__ . '/vendor/autoload.php';
use Aws\S3\S3Client;
session_start();
if (empty($_SESSION['user_id'])) {
http_response_code(401);
exit('请先登录');
}
$input = json_decode(file_get_contents('php://input'), true);
if (!is_array($input) || !isset($input['type'])) {
http_response_code(400);
exit('参数错误');
}
// 客户端只能选择允许的类型,不能提交任意 key 或桶名。
$types = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
];
$mime = $input['type'];
if (!isset($types[$mime])) {
http_response_code(415);
exit('不支持的文件类型');
}
$region = getenv('OBJECT_STORAGE_REGION');
$bucket = getenv('OBJECT_STORAGE_BUCKET');
if (!$region || !$bucket) {
http_response_code(500);
exit('存储服务未配置');
}
$config = ['version' => 'latest', 'region' => $region];
$endpoint = getenv('OBJECT_STORAGE_ENDPOINT');
if ($endpoint) {
$config['endpoint'] = $endpoint;
$config['use_path_style_endpoint'] = true; // 按厂商要求调整
}
$s3 = new S3Client($config);
$userId = (string)$_SESSION['user_id'];
$key = 'pending/' . hash('sha256', $userId) . '/'
. bin2hex(random_bytes(16)) . '.' . $types[$mime];
$command = $s3->getCommand('PutObject', [
'Bucket' => $bucket,
'Key' => $key,
'ContentType' => $mime,
]);
$request = $s3->createPresignedRequest($command, '+5 minutes');
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'uploadUrl' => (string)$request->getUri(),
'key' => $key,
'contentType' => $mime,
], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
浏览器使用同一个 MIME 值发起 PUT:
async function uploadFile(file, ticket) {
const response = await fetch(ticket.uploadUrl, {
method: "PUT",
headers: { "Content-Type": ticket.contentType },
body: file
});
if (!response.ok) {
throw new Error(`上传失败:HTTP ${response.status}`);
}
// 通知应用进入“待校验”流程;不要仅凭客户端声明认定上传完成且可信。
const complete = await fetch("/uploads/complete", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": getCsrfToken()
},
body: JSON.stringify({ key: ticket.key })
});
if (!complete.ok) {
throw new Error("服务端确认失败");
}
}
直传前还要配置桶的 CORS,仅允许实际使用的站点来源、方法和请求头。CORS 是浏览器跨域访问控制机制,不是身份认证,也不能阻止非浏览器客户端使用有效 URL。不要为了省事把所有来源、方法和请求头都开放。具体 CORS 配置格式及规则依服务商而异。
六、直传后的确认与下载授权
客户端通知完成只是一个提示。服务端应先根据当前用户查找待上传记录,并确认该对象键是服务端此前签发的、属于该用户且尚未完成;不要接受客户端任意传入的 key 后就认定归属成立。随后可以使用对象存储的 HEAD 操作核对对象是否存在、大小是否在允许范围内,并按需要重新检测内容。只有校验通过,才把记录状态改为可用。
数据库状态和对象存储写入不是一个分布式事务。可以先创建待上传记录,上传后核验并更新状态;对超时或失败的上传设置过期清理任务。若使用业务记录删除文件,应先完成应用权限校验,再删除对象,并处理数据库与存储删除不一致的情况。
私有文件的下载可以由应用将用户鉴权后生成短时 GET 预签名 URL。下面示例假设数据库查询已经确认当前用户有权读取这条记录:
<?php
// $file 是已通过数据库查询和业务授权的记录,包含受信任的 key。
// 不要直接使用请求参数里的 key 代替授权后的记录字段。
$command = $s3->getCommand('GetObject', [
'Bucket' => $bucket,
'Key' => $file['object_key'],
]);
$request = $s3->createPresignedRequest($command, '+3 minutes');
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'downloadUrl' => (string)$request->getUri(),
], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
预签名 URL 在有效期内通常可被持有者使用,不会因为原用户随后退出应用就自动失效。需要立即撤销、严格审计或更细粒度控制时,可通过应用代理下载、对象改名/删除、策略调整等方式设计,但这些做法各有性能与一致性成本。还应确认存储服务是否支持响应头覆盖;不能把未验证的用户文件名直接拼进响应头。
七、常见错误与排查方法
- 把桶或对象设成公开。公开读取可能让敏感附件永久暴露。优先使用私有桶并由应用授权;确实需要公开静态资源时,单独规划公开前缀、缓存和内容治理。
- 把密钥或长期签名链接放到前端。客户端代码对用户可见。前端只能获得权限受限、短时的预签名 URL,不能获得云端访问密钥。
- 用原始文件名拼 key。可能产生覆盖、冲突、特殊字符编码问题,甚至把路径规则和业务信息暴露出去。使用随机服务端键,文件名作为经过处理的展示信息保存。
- 信任文件扩展名、浏览器 MIME 或 Content-Length。这些信息可由客户端控制。服务端应检查上传错误、实际临时文件大小和文件内容类型;直传则需在完成阶段核验存储对象。
- 误把 ETag 当作文件 MD5。在分片上传、加密或不同服务实现下,ETag 不一定等于文件 MD5。需要完整性校验时,应使用服务商明确支持的校验和能力,并按其文档验证。
- 只依赖 CORS 做访问控制。CORS 约束浏览器的跨域行为,不替代登录、对象权限或签名校验。
- 上传后立即公开 URL。对象存在并不代表内容通过业务审核。建议采用 pending、active、rejected 等状态,仅允许读取已通过校验的记录。
- 假定 S3 兼容就完全等同于 AWS S3。端点形式、区域、寻址、ACL、签名版本和分片能力都可能有差异。排查时检查 SDK 错误码、服务端请求 ID、端点与区域配置;日志不要记录密钥和完整预签名 URL。
八、上线前检查清单
- 桶默认私有,并已检查公共访问阻止设置、生命周期和版本控制需求。
- 应用凭证仅能访问所需桶和前缀,删除权限与上传权限按需拆分。
- PHP、Web 服务器和代理的上传体积、超时、临时目录容量已经核对。
- 服务端上传使用实际文件校验;直传完成后有服务端核验与状态流转。
- 下载接口先鉴权,再签发短时 URL;日志和错误响应不泄露凭证或签名链接。
- 针对重复上传、上传中断、数据库写入失败、对象删除失败和过期待上传记录,制定了清理或补偿流程。
- 在目标存储服务上验证了端点、区域、CORS、TLS、签名和 SDK 调用,而不是仅凭本地测试通过就上线。
整体上,服务端代传更容易统一校验,但会占用应用服务器带宽;预签名直传能把数据流量交给对象存储,却需要更严格地管理上传凭据、CORS 和完成校验。无论选择哪种方式,关键都不是“文件能否放进去”,而是每个对象是否有清晰的业务归属、受控的读取路径和可恢复的失败处理。




