Files
FeedLite/upload.php
T
laoyang 1401ad899b 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 全通过。
2026-09-28 22:12:28 +08:00

213 lines
9.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 附件上传接口(两种存储方式都走「两步」,前端代码完全一致)
* ------------------------------------------------------------------
* 存储方式由后台设置决定(attachment.storage):off / local / oss。
* 无论 local 还是 oss,浏览器都【不直连存储】,一律两步走本站:
*
* 第一步: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】的私有桶。
*
* ★ 为什么 OSS 也要经服务器中转:桶是私有的、且只用内网 Endpoint(外网不可达),
* 浏览器既拿不到可直连的 URL、也连不上内网地址,所以「浏览器直传」在此架构下不成立。
* 读取同理,走 media.php 代理(见该文件)。
*
* 两种方式共用同一套校验:扩展名 / MIME 白名单、单文件大小、每 IP 频率、全站总量上限。
*/
require __DIR__ . '/lib/bootstrap.php';
/** 统一 JSON 输出 */
function fb_up_json($data, $code = 200) {
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
header('X-Content-Type-Options: nosniff');
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
if (strtoupper(isset($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] : 'GET') !== 'POST') {
fb_up_json(['ok' => false, 'error' => 'method not allowed'], 405);
}
$mode = fb_store_mode();
$putAt = (isset($_GET['action']) && (string)$_GET['action'] === 'put');
if ($mode === 'off') {
fb_up_json(['ok' => false, 'error' => '附件上传未启用。'], 403);
}
if ($mode === 'oss' && !fb_oss_enabled()) {
fb_up_json(['ok' => false, 'error' => '对象存储未配置完整,请到后台「站点设置」补齐桶信息。'], 403);
}
if ($mode === 'local' && !fb_local_writable()) {
$ld = fb_local_diag();
fb_up_json(['ok' => false, 'error' => '本机附件目录不可写(uploads/)。' . ($ld['tip'] !== '' ? ' ' . $ld['tip'] : '')], 403);
}
if (!fb_csrf_ok(isset($_POST['csrf']) ? $_POST['csrf'] : '')) {
fb_up_json(['ok' => false, 'error' => '会话已过期,请刷新页面后重试。'], 400);
}
/* ==================================================================
* 接收文件本体并落地(local → 本机磁盘;oss → 服务器代理 PUT 到内网 OSS)
* ------------------------------------------------------------------
* 私有桶 + 内网 Endpoint 下浏览器无法直传,所以 OSS 模式也走这里:
* 浏览器把文件 POST 给本站,本站再用服务器身份 PUT 进 OSS。
* ================================================================== */
if ($putAt) {
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_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); }
if (empty($_FILES['file']) || !is_array($_FILES['file'])) {
fb_up_json(['ok' => false, 'error' => '没有收到文件。'], 400);
}
$f = $_FILES['file'];
if (!isset($f['error']) || (int)$f['error'] !== UPLOAD_ERR_OK) {
fb_up_json(['ok' => false, 'error' => '文件上传失败(错误码 ' . (int)(isset($f['error']) ? $f['error'] : -1) . ')。'], 400);
}
if (!is_uploaded_file((string)$f['tmp_name'])) {
fb_up_json(['ok' => false, 'error' => '文件上传失败。'], 400);
}
$size = (int)@filesize($f['tmp_name']);
$maxB = (int)fb_upload_cfg($kind . '_max_bytes', 0);
if ($size <= 0) { fb_up_json(['ok' => false, 'error' => '文件为空。'], 400); }
if ($maxB > 0 && $size > $maxB) {
fb_up_json(['ok' => false, 'error' => '文件超过大小限制(' . fb_size_h($maxB) . ')。'], 400);
}
$mime = strtolower(trim(isset($_POST['mime']) ? (string)$_POST['mime'] : ''));
if ($mime === '') { $mime = fb_upload_mime_of_ext($ext); }
if (!fb_upload_mime_ok($kind, $mime)) { fb_up_json(['ok' => false, 'error' => '不支持的文件类型。'], 400); }
// 二次确认总量上限(防止并发绕过)
$pdo = fb_db();
$cap = (int)fb_upload_cfg('total_cap_bytes', 0);
if ($cap > 0 && (fb_attach_total_bytes($pdo) + $size) > $cap) {
fb_up_json(['ok' => false, 'error' => '附件存储空间已满,暂时无法上传。'], 507);
}
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);
}
}
// 登记「待入库」:让上传后、提交表单前的缩略图预览能通过 media.php 鉴权
fb_attach_pending_mark($key, $kind, $mime, $size);
fb_up_json([
'ok' => true,
'key' => $key,
'url' => fb_attach_url($key),
'size' => $size,
'mime' => $mime,
]);
}
/* ==================================================================
* 领取上传凭证
* ================================================================== */
$kind = strtolower(trim(isset($_POST['kind']) ? (string)$_POST['kind'] : ''));
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);
}
$ext = fb_upload_norm_ext(isset($_POST['ext']) ? (string)$_POST['ext'] : '');
$size = (int)(isset($_POST['size']) ? $_POST['size'] : 0);
$mime = strtolower(trim(isset($_POST['mime']) ? (string)$_POST['mime'] : ''));
if ($mime === '') { $mime = fb_upload_mime_of_ext($ext); }
$maxBytes = (int)fb_upload_cfg($kind . '_max_bytes', 0);
if ($ext === '' || !fb_upload_ext_ok($kind, $ext)) {
fb_up_json(['ok' => false, 'error' => '不支持的文件格式。'], 400);
}
if (!fb_upload_mime_ok($kind, $mime)) {
fb_up_json(['ok' => false, 'error' => '不支持的文件类型。'], 400);
}
if ($size <= 0) {
fb_up_json(['ok' => false, 'error' => '文件为空。'], 400);
}
if ($maxBytes > 0 && $size > $maxBytes) {
fb_up_json(['ok' => false, 'error' => '文件超过大小限制(' . fb_size_h($maxBytes) . ')。'], 400);
}
$limit = (int)fb_upload_cfg('upload_per_hour', 60);
if (!fb_rate_check('upload', $limit)) {
fb_up_json(['ok' => false, 'error' => '上传过于频繁,请稍后再试。'], 429);
}
// 全站总量上限:达到后不再发放凭证
$pdo = fb_db();
$cap = (int)fb_upload_cfg('total_cap_bytes', 0);
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);
} else {
$key = fb_oss_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,
]);