OSS 附件改为私有桶+内网Endpoint+服务器代理架构
背景:桶为私有、且只用内网 Endpoint(外网不可达),浏览器既无法直传、 也无法直连。原「浏览器直传 + 签名URL直连」两条路都失效,改为全站代理。 lib/oss.php: - endpoint 自动转内网(*-internal),use_internal_endpoint 开关控制,内网走 http - 移除浏览器直传凭证(PostObject),改为服务器端 PUT(流式 fb_oss_put_file) - 新增 fb_oss_stream_get 流式读取(回调式,支持 Range,不整文件进内存) - 签名支持 x-oss-process 子资源 + Authorization 头签名(Content-Type 入签) - 新增 fb_oss_probe 连通性自检、fb_oss_error_text 错误归因(含跨地域提示) media.php(新增): 附件代理输出,鉴权边界=对象键须在本站附件表(或本人待入库 登记)且帖子对访客可见,防止本站沦为能读整个共用桶的开放代理;透传 Range。 upload.php: local/oss 统一走两步(领键→action=put),OSS 模式由服务器代理 PUT。 lib/db.php: 新增 pending_attachments 表 + idx_att_key 索引(迁移幂等自动建)。 lib/attach.php: 待入库登记(mark/clear/mine/gc),解决上传后入库前的预览鉴权。 cron.php: 清理过期待入库登记 + 桶连通性自检输出。 admin.php: 设置页加「使用内网 Endpoint」开关、存储体检显示实际连接地址与探测按钮。 README/config: 全面更新为代理架构(无需CORS、无需自定义域名、同地域要求)。 验证:本地 e2e 25/25 + 附件专项 13/13(含防开放代理/Range/内网改写); 线上真机上传→内网PUT→media.php代理读回→Range 全通过。
This commit is contained in:
+63
-58
@@ -1,18 +1,19 @@
|
||||
<?php
|
||||
/**
|
||||
* 附件上传接口
|
||||
* 附件上传接口(两种存储方式都走「两步」,前端代码完全一致)
|
||||
* ------------------------------------------------------------------
|
||||
* 两种存储方式由后台设置决定(attachment.storage):
|
||||
* 存储方式由后台设置决定(attachment.storage):off / local / oss。
|
||||
* 无论 local 还是 oss,浏览器都【不直连存储】,一律两步走本站:
|
||||
*
|
||||
* ① oss(阿里云 OSS 直传,原行为)
|
||||
* POST upload.php kind=&ext=&size=&mime=&csrf=
|
||||
* → 返回 host / key / policy / signature…,浏览器把文件直接 POST 到 OSS,
|
||||
* 文件不经过本服务器。凭证来自 ECS 实例 RAM 角色的临时凭证(或静态 AK)。
|
||||
* 第一步:POST upload.php kind=&ext=&size=&mime=&csrf=
|
||||
* → 服务器生成对象键,返回 host=upload.php?action=put 与随传字段。
|
||||
* 第二步:浏览器把文件 multipart POST 到 upload.php?action=put,服务器校验后落地:
|
||||
* · local → 存到 uploads/YYYY/MM/ 下;
|
||||
* · oss → 服务器用实例 RAM 角色的临时凭证,代理 PUT 到【内网 Endpoint】的私有桶。
|
||||
*
|
||||
* ② local(存本机磁盘)
|
||||
* 第一步同上,只是返回的 host 指向本文件的 ?action=put;
|
||||
* 第二步浏览器把文件 multipart POST 到 upload.php?action=put,
|
||||
* 服务端校验后落到 uploads/YYYY/MM/ 下。
|
||||
* ★ 为什么 OSS 也要经服务器中转:桶是私有的、且只用内网 Endpoint(外网不可达),
|
||||
* 浏览器既拿不到可直连的 URL、也连不上内网地址,所以「浏览器直传」在此架构下不成立。
|
||||
* 读取同理,走 media.php 代理(见该文件)。
|
||||
*
|
||||
* 两种方式共用同一套校验:扩展名 / MIME 白名单、单文件大小、每 IP 频率、全站总量上限。
|
||||
*/
|
||||
@@ -50,19 +51,28 @@ if (!fb_csrf_ok(isset($_POST['csrf']) ? $_POST['csrf'] : '')) {
|
||||
}
|
||||
|
||||
/* ==================================================================
|
||||
* 本机存储:接收文件本体
|
||||
* 接收文件本体并落地(local → 本机磁盘;oss → 服务器代理 PUT 到内网 OSS)
|
||||
* ------------------------------------------------------------------
|
||||
* 私有桶 + 内网 Endpoint 下浏览器无法直传,所以 OSS 模式也走这里:
|
||||
* 浏览器把文件 POST 给本站,本站再用服务器身份 PUT 进 OSS。
|
||||
* ================================================================== */
|
||||
if ($putAt) {
|
||||
if ($mode !== 'local') { fb_up_json(['ok' => false, 'error' => '当前存储方式不支持本机上传。'], 400); }
|
||||
if ($mode !== 'local' && $mode !== 'oss') {
|
||||
fb_up_json(['ok' => false, 'error' => '当前存储方式不支持上传。'], 400);
|
||||
}
|
||||
|
||||
$kind = strtolower(trim(isset($_POST['kind']) ? (string)$_POST['kind'] : ''));
|
||||
$key = trim(isset($_POST['key']) ? (string)$_POST['key'] : '');
|
||||
|
||||
if ($kind !== 'image' && $kind !== 'video') { fb_up_json(['ok' => false, 'error' => '不支持的附件类型。'], 400); }
|
||||
if (!fb_upload_kind_on($kind)) { fb_up_json(['ok' => false, 'error' => '该类型附件未开放上传。'], 403); }
|
||||
if (!fb_attach_is_local($key) || !fb_upload_key_ok($key)) {
|
||||
if (!fb_upload_key_ok($key)) {
|
||||
fb_up_json(['ok' => false, 'error' => '附件地址不合法,请重新上传。'], 400);
|
||||
}
|
||||
// key 的存储类型必须与当前 mode 一致(防止 local 会话塞 oss key 或反之)
|
||||
$keyIsLocal = fb_attach_is_local($key);
|
||||
if ($mode === 'local' && !$keyIsLocal) { fb_up_json(['ok' => false, 'error' => '附件地址与当前存储方式不符。'], 400); }
|
||||
if ($mode === 'oss' && $keyIsLocal) { fb_up_json(['ok' => false, 'error' => '附件地址与当前存储方式不符。'], 400); }
|
||||
|
||||
$ext = fb_upload_key_ext($key);
|
||||
if (!fb_upload_ext_ok($kind, $ext)) { fb_up_json(['ok' => false, 'error' => '不支持的文件格式。'], 400); }
|
||||
@@ -96,13 +106,27 @@ if ($putAt) {
|
||||
fb_up_json(['ok' => false, 'error' => '附件存储空间已满,暂时无法上传。'], 507);
|
||||
}
|
||||
|
||||
$path = fb_local_path_of($key);
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir)) { @mkdir($dir, 0755, true); }
|
||||
if (!is_dir($dir) || !@move_uploaded_file((string)$f['tmp_name'], $path)) {
|
||||
fb_up_json(['ok' => false, 'error' => '保存文件失败,请稍后重试。'], 500);
|
||||
if ($mode === 'local') {
|
||||
$path = fb_local_path_of($key);
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir)) { @mkdir($dir, 0755, true); }
|
||||
if (!is_dir($dir) || !@move_uploaded_file((string)$f['tmp_name'], $path)) {
|
||||
fb_up_json(['ok' => false, 'error' => '保存文件失败,请稍后重试。'], 500);
|
||||
}
|
||||
@chmod($path, 0644);
|
||||
} else {
|
||||
// OSS:服务器代理 PUT 到内网桶(流式,不整文件进内存)
|
||||
if (!fb_oss_enabled()) {
|
||||
fb_up_json(['ok' => false, 'error' => '对象存储未配置完整。'], 403);
|
||||
}
|
||||
list($ok, $ossErr, $ossStatus) = fb_oss_put_file($key, (string)$f['tmp_name'], $mime);
|
||||
if (!$ok) {
|
||||
fb_up_json(['ok' => false, 'error' => '写入对象存储失败:' . $ossErr], 502);
|
||||
}
|
||||
}
|
||||
@chmod($path, 0644);
|
||||
|
||||
// 登记「待入库」:让上传后、提交表单前的缩略图预览能通过 media.php 鉴权
|
||||
fb_attach_pending_mark($key, $kind, $mime, $size);
|
||||
|
||||
fb_up_json([
|
||||
'ok' => true,
|
||||
@@ -156,52 +180,33 @@ if ($cap > 0 && (fb_attach_total_bytes($pdo) + $size) > $cap) {
|
||||
fb_up_json(['ok' => false, 'error' => '附件存储空间已满,暂时无法上传,请稍后再试。'], 507);
|
||||
}
|
||||
|
||||
/* ---------- 本机存储:返回一个指向本文件的上传地址 ---------- */
|
||||
/* ---------- 领取上传凭证:local 与 oss 都指向本站 action=put ----------
|
||||
* 私有桶 + 内网 Endpoint 下浏览器无法直传 OSS,所以两种模式统一为:
|
||||
* 第一步(这里):服务器生成对象键、返回本站上传地址与随传字段;
|
||||
* 第二步:浏览器把文件 multipart POST 到 upload.php?action=put,
|
||||
* 由服务器落地(本机磁盘 / 或代理 PUT 到内网 OSS)。
|
||||
* 前端 app.js 因此对两种模式走完全相同的代码路径。
|
||||
*/
|
||||
if ($mode === 'local') {
|
||||
$key = fb_local_make_key($ext);
|
||||
fb_rate_hit('upload');
|
||||
fb_up_json([
|
||||
'ok' => true,
|
||||
'credential' => [
|
||||
'host' => 'upload.php?action=put',
|
||||
'key' => $key,
|
||||
'url' => fb_attach_url($key),
|
||||
],
|
||||
'fields' => [
|
||||
'csrf' => fb_csrf_token(),
|
||||
'key' => $key,
|
||||
'kind' => $kind,
|
||||
'mime' => $mime,
|
||||
],
|
||||
'mime' => $mime,
|
||||
'kind' => $kind,
|
||||
]);
|
||||
} else {
|
||||
$key = fb_oss_make_key($ext);
|
||||
}
|
||||
|
||||
/* ---------- OSS 直传 ---------- */
|
||||
$key = fb_oss_make_key($ext);
|
||||
$cred = fb_oss_upload_credential($key, $maxBytes, $kind . '/');
|
||||
|
||||
fb_rate_hit('upload');
|
||||
|
||||
// extra_fields 是除固定项外需要浏览器一并提交的表单域:使用实例 RAM 角色的
|
||||
// 临时凭证时,就是 x-oss-security-token(OSS 靠它识别这份临时身份)。
|
||||
$fields = [
|
||||
'key' => $cred['key'],
|
||||
'policy' => $cred['policy'],
|
||||
'OSSAccessKeyId' => $cred['access_key_id'],
|
||||
'signature' => $cred['signature'],
|
||||
'success_action_status' => '200',
|
||||
'Content-Type' => $mime,
|
||||
];
|
||||
if (!empty($cred['extra_fields']) && is_array($cred['extra_fields'])) {
|
||||
foreach ($cred['extra_fields'] as $k => $v) { $fields[$k] = $v; }
|
||||
}
|
||||
|
||||
fb_up_json([
|
||||
'ok' => true,
|
||||
'credential' => $cred,
|
||||
'fields' => $fields,
|
||||
'credential' => [
|
||||
'host' => 'upload.php?action=put',
|
||||
'key' => $key,
|
||||
'url' => fb_attach_url($key),
|
||||
],
|
||||
'fields' => [
|
||||
'csrf' => fb_csrf_token(),
|
||||
'key' => $key,
|
||||
'kind' => $kind,
|
||||
'mime' => $mime,
|
||||
],
|
||||
'mime' => $mime,
|
||||
'kind' => $kind,
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user