[&p=] * * 安全边界(务必保持): * ① 对象键必须存在于本站 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; }