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