Files
FeedLite/media.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

207 lines
8.0 KiB
PHP

<?php
/**
* 附件代理输出(私有桶 + 内网 Endpoint 的唯一对外读取通道)
* ------------------------------------------------------------------
* 浏览器无法直连私有桶、也连不上内网 Endpoint,所以图片/视频一律经本站转发:
* media.php?k=<对象键>[&p=<OSS 图片处理串>]
*
* 安全边界(务必保持):
* ① 对象键必须存在于本站 attachments 表 —— 否则任何人只要知道共用桶里别的
* 应用的键,就能借本站把数据读出来(本站会变成「开放代理」)。
* ② 所属帖子必须对当前访客可见(先审后显:未公开帖的附件只有管理员能取)。
* ③ 只允许图片/视频白名单 MIME,且透传 Range 以支持视频拖动进度条。
* ④ 全程流式转发,不把文件读进内存(memory_limit 小也安全)。
*
* 本机存储(local/...)的附件由 Web 服务器直接吐 uploads/ 下的文件,不走这里;
* 但为兼容旧链接,这里也做一次本地回退。
*/
require __DIR__ . '/lib/bootstrap.php';
function fb_media_fail($code, $msg) {
http_response_code($code);
header('Content-Type: text/plain; charset=utf-8');
header('Cache-Control: no-store');
header('X-Content-Type-Options: nosniff');
exit($msg);
}
$key = isset($_GET['k']) ? trim((string)$_GET['k']) : '';
if ($key === '') { fb_media_fail(400, '缺少附件标识'); }
if (!fb_upload_key_ok($key)) { fb_media_fail(400, '附件标识不合法'); }
$process = isset($_GET['p']) ? trim((string)$_GET['p']) : '';
// 图片处理串只允许本站自己生成的形态:image/resize,w_NNN/quality,q_NN
$subResource = [];
if ($process !== '') {
if (!preg_match('#^image/(resize|crop|format|quality)([,/][a-z0-9_]+)*$#i', $process)) {
$process = ''; // 非法处理串直接忽略,按原图走
} else {
$subResource['x-oss-process'] = $process;
}
}
/* ---------------- 本机存储:直接读磁盘并流式输出 ---------------- */
if (fb_attach_is_local($key)) {
// 先鉴权再探文件,避免用 404/403 差异被探测对象是否存在
if (!fb_media_authorized($key)) { fb_media_fail(403, '无权访问该附件'); }
$path = fb_local_path_of($key);
if (!is_file($path)) { fb_media_fail(404, '附件不存在或已删除'); }
$size = (int)@filesize($path);
$mime = fb_media_local_mime($path, $key);
header('Content-Type: ' . $mime);
header('Content-Length: ' . $size);
header('Accept-Ranges: bytes');
header('Cache-Control: public, max-age=86400');
fb_media_send_local($path, $size);
exit;
}
/* ---------------- OSS 存储 ---------------- */
if (!fb_media_authorized($key)) { fb_media_fail(403, '无权访问该附件'); }
if (!fb_oss_enabled()) { fb_media_fail(503, '对象存储未配置'); }
$range = isset($_SERVER['HTTP_RANGE']) ? (string)$_SERVER['HTTP_RANGE'] : '';
$forwardedHeaders = false;
// 需要透传给浏览器的响应头(来自 OSS)
$passHeaders = ['content-type', 'content-length', 'content-range', 'accept-ranges',
'last-modified', 'etag', 'cache-control'];
list($status, $err) = fb_oss_stream_get(
$key,
$range,
$subResource,
function ($name, $value) use (&$forwardedHeaders, $passHeaders) {
if (!$forwardedHeaders && in_array($name, $passHeaders, true)) {
// 逐条透传(Content-Length / Content-Range 由 OSS 给出,原样转发)
header(fb_media_header_case($name) . ': ' . $value, false);
}
},
function ($chunk) use (&$forwardedHeaders) {
if (!$forwardedHeaders) {
// 第一次拿到 body 前,补上本站的通用头
header('Cache-Control: public, max-age=86400');
header('X-Content-Type-Options: nosniff');
$forwardedHeaders = true;
}
echo $chunk;
// 及时刷出,避免大视频在输出缓冲里堆积
if (ob_get_level() > 0) { @ob_flush(); }
@flush();
},
function ($st) {
// 状态行早于任何头/体到达:先把状态码定下来(206 分段、200 全量都在这里定)
if ($st >= 100 && $st < 600) { http_response_code($st); }
}
);
if ($status >= 200 && $status < 400) {
exit;
}
// 失败:状态码已通过 onStatus 设过;若尚未发送任何 body,再补一段可读原因
$code = $status === 404 ? 404 : ($status === 403 ? 403 : 502);
if (!headers_sent() && !$forwardedHeaders) { fb_media_fail($code, '读取附件失败:' . $err); }
http_response_code($code);
exit;
/* ==================================================================
* 辅助函数
* ================================================================== */
/**
* 鉴权:允许读取该对象键的条件(满足其一即可)——
* ① 管理员;
* ② 已入库(在 attachments 表)且所属帖子对当前访客可见;
* ③ 本人刚上传、尚未提交表单的「待入库」附件(上传后预览缩略图)。
* 三者都不满足即拒绝 —— 关键防止本站沦为「能读整个共用桶」的开放代理。
*/
function fb_media_authorized($key) {
if (fb_is_admin()) { return true; }
try {
$pdo = fb_db();
} catch (Throwable $e) {
// 连库失败时,退而求其次只认「本人待入库」
return fb_attach_pending_mine($key);
}
// ③ 本人刚上传、还没提交表单的预览
if (fb_attach_pending_mine($key)) { return true; }
// ② 已入库且帖子可见
$st = $pdo->prepare(
'SELECT t.visible
FROM attachments a
JOIN threads t ON t.id = a.thread_id
WHERE a.oss_key = ?
LIMIT 1'
);
$st->execute([$key]);
$row = $st->fetch();
if (!$row) { return false; } // 不是本站登记的附件 → 拒绝
if (fb_moderation_on() && (int)$row['visible'] !== 1) { return false; }
return true;
}
/** 本机附件的 MIME(finfo 优先,回落按扩展名) */
function fb_media_local_mime($path, $key) {
$ct = '';
if (function_exists('finfo_open')) {
$fi = @finfo_open(FILEINFO_MIME_TYPE);
if ($fi) { $ct = strtolower(trim((string)@finfo_file($fi, $path))); @finfo_close($fi); }
}
if ($ct === '' || $ct === 'application/octet-stream') {
$ct = fb_upload_mime_of_ext(fb_upload_key_ext($key));
}
return $ct ?: 'application/octet-stream';
}
/** 本地文件按 Range 流式输出(支持视频拖动) */
function fb_media_send_local($path, $size) {
$start = 0; $end = $size - 1;
if (isset($_SERVER['HTTP_RANGE']) && preg_match('/bytes=(\d*)-(\d*)/', $_SERVER['HTTP_RANGE'], $m)) {
if ($m[1] !== '') { $start = (int)$m[1]; }
if ($m[2] !== '') { $end = (int)$m[2]; }
if ($start > $end || $start >= $size) {
http_response_code(416);
header('Content-Range: bytes */' . $size);
exit;
}
if ($end >= $size) { $end = $size - 1; }
http_response_code(206);
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size);
header('Content-Length: ' . ($end - $start + 1));
}
$fp = @fopen($path, 'rb');
if (!$fp) { exit; }
if ($start > 0) { @fseek($fp, $start); }
$remain = $end - $start + 1;
while ($remain > 0 && !feof($fp)) {
$buf = @fread($fp, min(8192, $remain));
if ($buf === false || $buf === '') { break; }
echo $buf;
$remain -= strlen($buf);
if (ob_get_level() > 0) { @ob_flush(); }
@flush();
}
@fclose($fp);
}
/** 把小写头名还原成常见的首字母大写形态 */
function fb_media_header_case($name) {
$map = [
'content-type' => 'Content-Type',
'content-length' => 'Content-Length',
'content-range' => 'Content-Range',
'accept-ranges' => 'Accept-Ranges',
'last-modified' => 'Last-Modified',
'etag' => 'ETag',
'cache-control' => 'Cache-Control',
];
return isset($map[$name]) ? $map[$name] : $name;
}