Files
FeedLite/media.php
T
laoyang 6135e7b890 兼容 PHP 8.5:消除全部弃用调用
PHP 8.5 把 curl_close()/finfo_close() 这类"空操作函数"标为弃用,
并把预定义局部变量 http_response_header 标为弃用(编译期判定,@ 与三元短路都压不住)。

- 句柄关闭收进 fb_curl_close()/fb_finfo_close():仅 PHP < 8.0 真正调用,
  新版本直接跳过(8.0 起句柄随引用计数释放),保留 7.4 兼容
- 响应头读取改用 http_get_last_response_headers()(8.4+);
  老版本经 get_defined_vars() 从作用域变量表取,源码不再出现已弃用标识符
- 新 API 是全局状态,用「请求前基线」比对,避免把上一次的残留头当成本次结果
- 安装体检与 README 的推荐版本区间更新为 8.1~8.5

验证:PHP 8.5.12 下五套测试全绿(25/25、22/22、60/60、7/7、反向 4 组全被抓住),
error_log 零条弃用记录;PHP 8.4.25 同样全绿。
2026-10-23 08:23:55 +08:00

210 lines
8.3 KiB
PHP

<?php
/**
* 附件代理输出(私有桶 + 内网 Endpoint 的唯一对外读取通道)
* ------------------------------------------------------------------
* 浏览器无法直连私有桶、也连不上内网 Endpoint,所以图片/视频一律经本站转发:
* media.php?k=<对象键>[&p=<OSS 图片处理串>]
*
* 安全边界(务必保持):
* ① 对象键必须存在于本站 attachments 表 —— 否则任何人只要知道共用桶里别的
* 应用的键,就能借本站把数据读出来(本站会变成「开放代理」)。
* ② 所属帖子必须对当前访客可见(先审后显:未公开帖的附件只有管理员能取)。
* ③ 只允许图片/视频白名单 MIME,且透传 Range 以支持视频拖动进度条。
* ④ 全程流式转发,不把文件读进内存(memory_limit 小也安全)。
*
* 本机存储(local/...)与本文件的 OSS 分支【前台地址完全一致】:
* 两种存储方式对外都只暴露 media.php?k=... —— 路径统一、真实地址不泄漏。
* uploads/ 目录自身也被 .htaccess 全部拒绝,直连真实路径取不到东西。
*/
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;
}
}
/* ---------------- 本机存储:直接读磁盘并流式输出 ----------------
* 与前台的统一代理地址配套:local 附件也走这里,uploads/ 目录本身禁止直连。 */
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');
header('X-Content-Type-Options: nosniff'); // 与 OSS 分支保持一致
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))); fb_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;
}