头,即使站点配置失误也无法被下载) */ function fb_oss_cache_file() { $p = trim((string)fb_oss_cfg('credential_cache', '')); if ($p !== '') { return $p; } $db = (string)fb_cfg('db_path', ''); $dir = $db !== '' ? dirname($db) : sys_get_temp_dir(); return rtrim(str_replace('\\', '/', $dir), '/') . '/oss.sts.php'; } /** 解析凭证过期时间(ISO8601)→ unix 时间戳 */ function fb_oss_parse_expire($s, $fallbackTtl = 900) { $s = trim((string)$s); if ($s !== '') { $t = strtotime($s); if ($t !== false && $t > 0) { return (int)$t; } } return time() + max(60, (int)$fallbackTtl); } function fb_oss_cache_read() { $f = fb_oss_cache_file(); if (!is_file($f)) { return null; } $raw = (string)@file_get_contents($f); $p = strpos($raw, "\n"); if ($p === false) { return null; } $j = json_decode(substr($raw, $p + 1), true); if (!is_array($j) || empty($j['ak']) || empty($j['sk'])) { return null; } return $j; } function fb_oss_cache_write(array $c) { $f = fb_oss_cache_file(); $dir = dirname($f); if (!is_dir($dir)) { @mkdir($dir, 0777, true); } $tmp = $f . '.' . bin2hex(random_bytes(4)) . '.tmp'; $body = "\n" . json_encode($c, JSON_UNESCAPED_SLASHES); if (@file_put_contents($tmp, $body) === false) { return false; } @chmod($tmp, 0600); if (!@rename($tmp, $f)) { @unlink($tmp); return false; } @chmod($f, 0600); return true; } function fb_oss_cache_clear() { $f = fb_oss_cache_file(); if (is_file($f)) { @unlink($f); } return true; } /** ECS 元数据服务基地址(100.100.100.200 是链路本地地址,不走外网) */ function fb_oss_metadata_base() { $u = trim((string)fb_oss_cfg('metadata_url', '')); if ($u === '') { $u = 'http://100.100.100.200'; } return rtrim($u, '/'); } /** 加固模式:先取一次性 token(普通模式返回空串) */ function fb_oss_metadata_token() { if (!fb_oss_cfg('metadata_v2', false)) { return ''; } $ttl = (int)fb_oss_cfg('metadata_ttl', 600); $ttl = max(60, min(21600, $ttl)); list($st, , $body) = fb_http('PUT', fb_oss_metadata_base() . '/latest/api/token', [ 'timeout' => 3, 'noproxy' => true, 'headers' => ['X-aliyun-ecs-metadata-token-ttl-seconds: ' . $ttl], ]); if ($st < 200 || $st >= 300) { return ''; } return trim((string)$body); } /** 从 ECS 元数据读取实例 RAM 角色凭证 @return array [bool, array|string] */ function fb_oss_instance_creds() { $role = trim((string)fb_oss_cfg('instance_role', '')); if ($role === '') { return [false, '未配置 oss.instance_role']; } $url = fb_oss_metadata_base() . '/latest/meta-data/ram/security-credentials/' . rawurlencode($role); $headers = ['Accept: application/json']; $tok = fb_oss_metadata_token(); if ($tok !== '') { $headers[] = 'X-aliyun-ecs-metadata-token: ' . $tok; } list($st, , $body) = fb_http('GET', $url, ['timeout' => 3, 'noproxy' => true, 'headers' => $headers]); if ($st < 200 || $st >= 300) { return [false, '读取实例 RAM 角色失败(HTTP ' . $st . ')']; } $j = json_decode((string)$body, true); if (!is_array($j)) { return [false, '实例元数据返回格式异常']; } if (isset($j['Code']) && strcasecmp((string)$j['Code'], 'Success') !== 0) { return [false, '实例元数据返回 ' . (string)$j['Code']]; } $ak = trim((string)(isset($j['AccessKeyId']) ? $j['AccessKeyId'] : '')); $sk = trim((string)(isset($j['AccessKeySecret']) ? $j['AccessKeySecret'] : '')); $tk = trim((string)(isset($j['SecurityToken']) ? $j['SecurityToken'] : '')); if ($ak === '' || $sk === '') { return [false, '实例 RAM 角色凭证不完整']; } return [true, [ 'ak' => $ak, 'sk' => $sk, 'token' => $tk, 'expire' => fb_oss_parse_expire(isset($j['Expiration']) ? $j['Expiration'] : '', 900), ]]; } /** * 当前生效的凭证(进程内记忆 + 文件缓存)。 * 缓存提前 credential_refresh_margin 秒失效,避免恰好在过期瞬间用旧凭证。 */ function fb_oss_credentials($force = false) { static $memo = null; if (!$force && is_array($memo)) { return $memo; } $margin = max(30, (int)fb_oss_cfg('credential_refresh_margin', 300)); if (!$force) { $c = fb_oss_cache_read(); if (is_array($c) && !empty($c['expire']) && ((int)$c['expire'] - $margin) > time()) { $memo = $c; return $memo; } } // 静态 AccessKey:直接就是长期凭证,不需要缓存与刷新 if (fb_oss_credential_source() === 'static') { $memo = [ 'ak' => trim((string)fb_oss_cfg('access_key', '')), 'sk' => trim((string)fb_oss_cfg('secret_key', '')), 'token' => '', 'expire' => time() + 86400, 'fetched' => time(), ]; if ($memo['ak'] === '' || $memo['sk'] === '') { $memo = ['ak' => '', 'sk' => '', 'token' => '', 'expire' => 0, 'error' => '未配置 oss.access_key / oss.secret_key']; } return $memo; } list($ok, $r) = fb_oss_instance_creds(); if (!$ok) { // 读不到就退回空凭证(调用方需自己判断 ok),绝不返回"半截"凭证 $memo = ['ak' => '', 'sk' => '', 'token' => '', 'expire' => 0, 'error' => $r]; return $memo; } $r['fetched'] = time(); fb_oss_cache_write($r); $memo = $r; return $memo; } /** * 诊断信息(不含密钥,可安全输出到日志/页面) * ⚠️ 这里【不能】传 $force=true:强制刷新会覆盖 fb_oss_credentials() 的进程内记忆, * 使同一次请求里「先做体检、再签名」两步拿到不同批次的临时凭证 → SignatureDoesNotMatch。 * 非强制调用会自动优先命中进程内记忆 / 文件缓存,顺带也省掉一次元数据请求。 */ function fb_oss_cred_status() { $c = fb_oss_credentials(); $out = [ 'label' => fb_oss_credential_source() === 'static' ? '静态 AccessKey' : 'ECS 实例 RAM 角色', 'source' => fb_oss_credential_source(), 'ak_tail' => $c['ak'] !== '' ? substr((string)$c['ak'], -6) : '', 'has_token'=> !empty($c['token']), 'expire' => (int)(isset($c['expire']) ? $c['expire'] : 0), 'ok' => ($c['ak'] !== '' && $c['sk'] !== ''), 'error' => isset($c['error']) ? (string)$c['error'] : '', ]; return $out; } /** 当前 AccessKeyId */ function fb_oss_ak() { $c = fb_oss_credentials(); return (string)$c['ak']; } /** 当前 AccessKeySecret(仅服务器内部使用,绝不返回浏览器) */ function fb_oss_sk() { $c = fb_oss_credentials(); return (string)$c['sk']; } /** 当前安全令牌(使用临时凭证时必不为空) */ function fb_oss_token() { $c = fb_oss_credentials(); return (string)$c['token']; } /** * 规范化 Endpoint:把配置的 endpoint 解析成「服务器实际要连的地址」。 * ------------------------------------------------------------------ * oss.endpoint 允许三种写法,全部只影响【服务器侧】的连接地址, * 浏览器永远不会直接访问它(所以内网地址完全可用): * ① oss-cn-qingdao.aliyuncs.com (公网) * ② oss-cn-qingdao-internal.aliyuncs.com (内网,推荐:免流量费、延迟低) * ③ https://oss-cn-qingdao.aliyuncs.com (带协议) * * oss.use_internal_endpoint(后台可配,默认开)决定要不要自动把 ① 改写成 ②: * · 只有【同地域】的阿里云 ECS 才连得上内网 endpoint。跨地域(如 ECS 在 * cn-qingdao、桶在 cn-beijing)内网完全不可达 —— 这种情况必须关掉本开关, * 或干脆换一个与 ECS 同地域的桶。后台「存储体检」会做连通性自检并给出提示。 * @return string 不含协议、不含结尾斜杠的 host,如 oss-cn-qingdao-internal.aliyuncs.com */ function fb_oss_endpoint() { $ep = preg_replace('#^https?://#i', '', trim((string)fb_oss_cfg('endpoint', ''))); $ep = trim(preg_replace('#/.*$#', '', $ep)); if ($ep === '') { return ''; } if (fb_oss_use_internal()) { // 已经是内网地址就不动;否则在 "-aliyuncs.com" 前插入 "-internal" if (strpos($ep, '-internal.aliyuncs.com') === false && preg_match('#^(oss-[a-z0-9-]+?)(\.aliyuncs\.com)$#i', $ep, $m)) { $ep = $m[1] . '-internal' . $m[2]; } } else { // 明确关掉内网:把已有的 -internal 去掉,回落公网 $ep = str_replace('-internal.aliyuncs.com', '.aliyuncs.com', $ep); } return $ep; } /** 是否走内网 Endpoint(后台可配,默认开) */ function fb_oss_use_internal() { $v = fb_oss_cfg('use_internal_endpoint', true); if (is_string($v)) { $v = !in_array(strtolower(trim($v)), ['', '0', 'false', 'off', 'no'], true); } return (bool)$v; } /** 服务器侧连接 OSS 用的 host(含协议)。内网 Endpoint 走 http,公网走 https。 */ function fb_oss_host() { $o = trim((string)fb_oss_cfg('host_override', '')); if ($o !== '') { return rtrim($o, '/'); } $ep = fb_oss_endpoint(); if ($ep === '') { return ''; } // 内网 endpoint 不支持 HTTPS 证书校验(证书是公网域名签发的),走 http; // 内网链路本身不出机房,安全性由 VPC 隔离保证。公网则强制 https。 $isInternal = strpos($ep, '-internal.aliyuncs.com') !== false; if ($isInternal) { $scheme = 'http'; } else { $scheme = (strtolower((string)fb_oss_cfg('url_scheme', 'https')) === 'http') ? 'http' : 'https'; } return $scheme . '://' . trim((string)fb_oss_cfg('bucket', '')) . '.' . $ep; } /** * 对外的访问基地址。 * ⚠️ 私有桶 + 内网 Endpoint 下【不再存在】可供浏览器直连的地址 —— * 附件一律通过本站 media.php 代理输出。本函数仅供极少数 * 「桶是公共读且配了自定义域名」的兼容场景使用。 */ function fb_oss_public_base() { $d = trim((string)fb_oss_cfg('domain', '')); if ($d !== '') { if (!preg_match('#^https?://#i', $d)) { $d = 'https://' . $d; } return rtrim($d, '/'); } return fb_oss_host(); } /** 生成对象键:fb/2026/09/1a2b3c4d5e6f7788.jpg */ function fb_oss_make_key($ext) { $ext = strtolower(preg_replace('/[^A-Za-z0-9]/', '', (string)$ext)); $prefix = trim((string)fb_oss_cfg('prefix', 'fb'), '/'); return $prefix . '/' . date('Y/m') . '/' . bin2hex(random_bytes(8)) . '.' . $ext; } /** 拼一个对象在 OSS 上的访问 URL(仅服务器内部使用) */ function fb_oss_object_url($key, $forcePublicBase = false) { return ($forcePublicBase ? fb_oss_host() : fb_oss_public_base()) . '/' . ltrim((string)$key, '/'); } /** * 生成 OSS V1 签名参数(服务器侧用:HEAD 校验、DELETE 清理、GET 代理读取的签名 URL) * StringToSign = METHOD "\n" Content-MD5 "\n" Content-Type "\n" Expires "\n" Resource * 其中 Resource = /bucket/key,若使用临时凭证则再拼上 "?security-token="。 * @return array ['OSSAccessKeyId'=>..,'Expires'=>..,'Signature'=>..] * * ⚠️ 两个都踩过的坑,改这里之前先读完: * ① 【必须一次性取出整份凭证】。曾写成 fb_oss_ak()/fb_oss_sk()/fb_oss_token() * 各调一次 fb_oss_credentials():只要期间发生凭证刷新(或缓存到期换新), * 签名用的 SK 与随请求发出的 security-token 就来自不同批次 → SignatureDoesNotMatch。 * ② 【security-token 必须算进 Resource】。它是以 URL 查询参数(子资源)形式出现的, * OSS 计算签名时会把它连同 Resource 一起算。若照搬"官方说 token 不参与签名"而只把它 * 挂在 URL 上,PUT/HEAD/DELETE/GET 这类用签名 URL 的请求会全部 403 SignatureDoesNotMatch。 * (探测脚本里还踩过第三个变体:带 body 的 PUT 发了 Content-Type 头却没算进签名, * 同样 SignatureDoesNotMatch —— 所以 fb_oss_auth_headers 用头签名时务必把 Content-Type 一并纳入。) * * @param array $subResource 额外的签名子资源,如 ['x-oss-process' => 'image/resize,w_640'] * ⚠️ x-oss-process 属于 OSS 的签名子资源,必须一起算进 Resource, * 否则带图片处理参数的请求会 403 SignatureDoesNotMatch。 */ function fb_oss_sign_v1($method, $key, $expires, $contentType = '', $contentMd5 = '', array $subResource = []) { $c = fb_oss_cfg(); $cred = fb_oss_credentials(); $tok = isset($cred['token']) ? (string)$cred['token'] : ''; $res = '/' . trim((string)$c['bucket']) . '/' . ltrim((string)$key, '/'); // 子资源必须【按名字字典序】拼接(OSS 的规定,顺序错了签名就不匹配) $sub = []; if ($tok !== '') { $sub['security-token'] = $tok; } foreach ($subResource as $k => $v) { $k = (string)$k; if ($k === '') { continue; } $sub[$k] = ($v === null || $v === '') ? '' : (string)$v; } if ($sub) { ksort($sub, SORT_STRING); $parts = []; foreach ($sub as $k => $v) { $parts[] = ($v === '') ? $k : $k . '=' . $v; } $res .= '?' . implode('&', $parts); } $st = strtoupper($method) . "\n" . $contentMd5 . "\n" . $contentType . "\n" . (int)$expires . "\n" . $res; $out = [ 'OSSAccessKeyId' => (string)$cred['ak'], 'Expires' => (int)$expires, 'Signature' => base64_encode(hash_hmac('sha1', $st, (string)$cred['sk'], true)), ]; // security-token 除了参与上面的签名计算,也仍要作为 URL 参数发出去 if ($tok !== '') { $out['security-token'] = $tok; } foreach ($subResource as $k => $v) { $k = (string)$k; if ($k !== '' && $k !== 'security-token') { $out[$k] = (string)$v; } } return $out; } /** * 浏览器可访问的附件地址 —— 走本站代理 media.php。 * ------------------------------------------------------------------ * 私有桶 + 内网 Endpoint 下,OSS 的 URL 对浏览器毫无意义(连不上、也没权限), * 所以这里返回的一律是站内代理地址。签名、取流都在服务器内部完成。 * @return string 形如 media.php?k=fb%2F2026%2F09%2Fabcd.jpg */ function fb_oss_url($key, $ttl = 0) { return fb_base_url() . '/media.php?k=' . rawurlencode((string)$key); } /** * 服务器内部用的签名 URL(HEAD / DELETE 等小请求)。 * ⚠️ 仅供服务器自己发起请求,绝不返回给浏览器。 * @param array $subResource 参与签名的子资源,如 ['x-oss-process' => 'image/resize,w_640'] */ function fb_oss_signed_url($method, $key, $ttl = 300, $contentType = '', array $subResource = []) { $url = fb_oss_object_url($key, true); $sign = fb_oss_sign_v1($method, $key, time() + max(30, (int)$ttl), $contentType, '', $subResource); return $url . '?' . http_build_query($sign, '', '&', PHP_QUERY_RFC3986); } /** * 用 Authorization 头签名(PUT 上传用)。 * 相比查询串签名,头签名能把 Content-Type / x-oss-security-token 一起纳入, * 更适合带请求体的写入。 * @return array 需要附加到请求上的 HTTP 头(含 Authorization 与 x-oss-security-token) */ function fb_oss_auth_headers($method, $key, $date, $contentType = '', $contentMd5 = '', array $extraOssHeaders = []) { $cred = fb_oss_credentials(); $c = fb_oss_cfg(); $lower = []; foreach ($extraOssHeaders as $k => $v) { $k = strtolower(trim((string)$k)); if (strpos($k, 'x-oss-') === 0) { $lower[$k] = trim((string)$v); } } // 临时凭证必须带 security-token 头,且它是 CanonicalizedOSSHeaders 的一部分(要参与签名) if (!empty($cred['token'])) { $lower['x-oss-security-token'] = (string)$cred['token']; } ksort($lower, SORT_STRING); $canon = ''; foreach ($lower as $k => $v) { $canon .= $k . ':' . $v . "\n"; } $res = '/' . trim((string)$c['bucket']) . '/' . ltrim((string)$key, '/'); $st = strtoupper($method) . "\n" . $contentMd5 . "\n" . $contentType . "\n" . $date . "\n" . $canon . $res; $h = ['Date: ' . $date]; if ($contentType !== '') { $h[] = 'Content-Type: ' . $contentType; } if ($contentMd5 !== '') { $h[] = 'Content-MD5: ' . $contentMd5; } foreach ($lower as $k => $v) { $h[] = $k . ': ' . $v; } $h[] = 'Authorization: OSS ' . (string)$cred['ak'] . ':' . base64_encode(hash_hmac('sha1', $st, (string)$cred['sk'], true)); return $h; } /** * 服务器端上传:把本地文件 PUT 到 OSS(流式,不整文件进内存)。 * @param string $key 对象键 * @param string $path 本地文件路径(通常是 PHP 的上传临时文件) * @param string $mime Content-Type * @return array [bool $ok, string $error, int $status] */ function fb_oss_put_file($key, $path, $mime = 'application/octet-stream') { if (!is_file($path)) { return [false, '本地文件不存在', 0]; } $size = (int)@filesize($path); if ($size <= 0) { return [false, '文件为空', 0]; } $url = fb_oss_object_url($key, true); if ($url === '') { return [false, 'OSS 未正确配置(endpoint/bucket 为空)', 0]; } $date = gmdate('D, d M Y H:i:s \G\M\T'); // 超时按大小放宽:内网带宽高,但 50MB 视频仍要给足余量 $timeout = max(30, (int)ceil($size / 262144)); $headers = fb_oss_auth_headers('PUT', $key, $date, $mime); $headers[] = 'Content-Length: ' . $size; $headers[] = 'Expect:'; // 关掉 100-continue,避免部分环境多等一轮 $fp = @fopen($path, 'rb'); if (!$fp) { return [false, '无法读取本地文件', 0]; } $status = 0; $errBody = ''; if (function_exists('curl_init')) { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_UPLOAD => true, CURLOPT_INFILE => $fp, CURLOPT_INFILESIZE => $size, CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_TIMEOUT => $timeout, CURLOPT_HTTPHEADER => $headers, // 内网 endpoint 用 http,无需校验证书;公网 https 时才校验 CURLOPT_SSL_VERIFYPEER => (strpos($url, 'https://') === 0), CURLOPT_SSL_VERIFYHOST => (strpos($url, 'https://') === 0) ? 2 : 0, ]); $body = curl_exec($ch); $errno = curl_errno($ch); $errtxt = curl_error($ch); $status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($errno) { @fclose($fp); return [false, '上传请求失败:' . $errtxt, $status]; } $errBody = (string)$body; } else { // 无 curl 的兜底:流式上下文(同样不会把整个文件读进内存) $ctx = stream_context_create(['http' => [ 'method' => 'PUT', 'header' => implode("\r\n", $headers), 'content' => $fp, 'timeout' => $timeout, 'ignore_errors' => true, ]]); $body = @file_get_contents($url, false, $ctx); if (isset($http_response_header) && is_array($http_response_header) && preg_match('#^HTTP/\S+\s+(\d{3})#', $http_response_header[0], $m)) { $status = (int)$m[1]; } $errBody = ($body === false ? '' : (string)$body); } @fclose($fp); if ($status >= 200 && $status < 300) { return [true, '', $status]; } return [false, fb_oss_error_text($errBody, $status), $status]; } /** * 服务器端读取对象并【流式】转发给浏览器(图片/视频代理的核心)。 * ------------------------------------------------------------------ * 用回调把响应头与响应体逐块交出去,全程不在内存里囤整个文件 —— * 50MB 的视频在 memory_limit=128M 的机器上也安全。 * * @param string $key 对象键 * @param string $rangeHeader 浏览器发来的 Range 原样透传(视频拖动进度条必需) * @param array $subResource 如 ['x-oss-process' => 'image/resize,w_640'] * @param callable $onHeader function($name, $value) 每收到一个响应头调用一次 * @param callable $onBody function($chunk) 每收到一块响应体调用一次 * @param callable $onStatus function($status) 收到状态行时调用(早于任何头/体, * 供调用方在输出前设 http_response_code) * @return array [int $status, string $errorText] */ function fb_oss_stream_get($key, $rangeHeader = '', array $subResource = [], $onHeader = null, $onBody = null, $onStatus = null) { $ttl = max(60, (int)fb_oss_cfg('link_ttl', 604800)); // 代理是给浏览器用的,但签名 URL 的有效期只影响服务器这一次请求,短一点更安全 $url = fb_oss_signed_url('GET', $key, min(300, $ttl), '', $subResource); if (strpos($url, '://') === false) { return [0, 'OSS 未正确配置']; } $headers = []; if (trim((string)$rangeHeader) !== '') { $headers[] = 'Range: ' . trim((string)$rangeHeader); } if (!function_exists('curl_init')) { // 无 curl 兜底:一次性读入(仅小文件可接受;生产环境建议启用 curl) list($st, $h, $body) = fb_http('GET', $url, ['timeout' => 60, 'headers' => $headers]); if (is_callable($onStatus)) { $onStatus($st); } if ($st >= 200 && $st < 400) { if (is_callable($onHeader)) { foreach ($h as $n => $v) { $onHeader($n, $v); } } if (is_callable($onBody)) { $onBody($body); } } return [$st, $st >= 200 && $st < 400 ? '' : fb_oss_error_text($body, $st)]; } $status = 0; $errBody = ''; $errno = 0; $errtxt = ''; $statusFired = false; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_HTTPGET => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_TIMEOUT => 300, CURLOPT_HTTPHEADER => $headers, CURLOPT_SSL_VERIFYPEER => (strpos($url, 'https://') === 0), CURLOPT_SSL_VERIFYHOST => (strpos($url, 'https://') === 0) ? 2 : 0, CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$status, &$statusFired, $onHeader, $onStatus) { if (preg_match('#^HTTP/\S+\s+(\d{3})#', $line, $m)) { $status = (int)$m[1]; if (!$statusFired && is_callable($onStatus)) { $onStatus($status); $statusFired = true; } return strlen($line); } $p = strpos($line, ':'); if ($p !== false && is_callable($onHeader)) { $onHeader(strtolower(trim(substr($line, 0, $p))), trim(substr($line, $p + 1))); } return strlen($line); }, CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use ($onBody, &$errBody, &$status) { if ($status >= 200 && $status < 400) { if (is_callable($onBody)) { $onBody($chunk); } } else { $errBody .= $chunk; // 错误响应体留着解析原因 if (strlen($errBody) > 8192) { $errBody = substr($errBody, 0, 8192); } } return strlen($chunk); }, ]); $ok = curl_exec($ch); $errno = curl_errno($ch); $errtxt = curl_error($ch); $st2 = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($st2 > 0) { $status = $st2; } if (!$statusFired && is_callable($onStatus)) { $onStatus($status); } if ($ok === false || $errno) { return [$status, '读取对象失败:' . $errtxt]; } if ($status >= 200 && $status < 400) { return [$status, '']; } return [$status, fb_oss_error_text($errBody, $status)]; } /** 从 OSS 的 XML 错误响应里提取人类可读的原因 */ function fb_oss_error_text($body, $status) { $body = (string)$body; $code = ''; if (preg_match('#([^<]+)#', $body, $m)) { $code = trim($m[1]); } $msg = ''; if (preg_match('#([^<]+)#', $body, $m)) { $msg = trim($m[1]); } $map = [ 'AccessDenied' => '没有访问该对象的权限(检查 RAM 角色是否已授权此桶,以及桶是否与你填写的 endpoint 同地域)', 'SignatureDoesNotMatch'=> '签名不匹配(凭证可能刚轮换,或 Content-Type 与实际不一致)', 'InvalidAccessKeyId' => 'AccessKeyId 无效(凭证已过期或角色未正确绑定)', 'SecurityTokenExpired' => '临时凭证已过期,请重试', 'NoSuchKey' => '对象不存在(可能已被清理)', 'NoSuchBucket' => '桶不存在(检查 bucket / endpoint 是否写错)', 'SecondLevelDomainForbidden' => '该桶不允许二级域名访问方式', 'RequestTimeTooSkewed' => '服务器时间与 OSS 相差过大,请校准系统时间', ]; if ($code !== '' && isset($map[$code])) { return $map[$code] . '(' . $code . ')'; } if ($status === 0) { return '连不上 OSS:请确认 Endpoint 与本机是否同地域' . '(跨地域时 *-internal 内网地址完全不可达,需在后台关闭「使用内网 Endpoint」或改用同地域的桶)'; } return ($code !== '' ? $code : ('HTTP ' . $status)) . ($msg !== '' ? ':' . $msg : ''); } /** * 连通性自检:探测服务器能否真正访问所配置的桶(后台「存储体检」用)。 * @return array{ok:bool,status:int,host:string,internal:bool,detail:string} */ function fb_oss_probe() { $host = fb_oss_host(); if ($host === '') { return ['ok' => false, 'status' => 0, 'host' => '', 'internal' => false, 'detail' => 'Endpoint / Bucket 未配置完整']; } $internal = strpos($host, '-internal.aliyuncs.com') !== false; // 用一个必然不存在的对象键去探测:能连上就会返回 403/404,连不上则 status=0 $key = trim((string)fb_oss_cfg('prefix', 'fb'), '/') . '/.feedlite-probe'; list($st, ) = fb_oss_head($key); if ($st === 0) { return ['ok' => false, 'status' => 0, 'host' => $host, 'internal' => $internal, 'detail' => '无法连接到 ' . $host . '。' . ($internal ? '内网 Endpoint 只有【与桶同地域】的阿里云 ECS 才能访问 —— ' . '若本机与桶不在同一地域,请关闭「使用内网 Endpoint」,或改用同地域的桶。' : '请检查 Endpoint 拼写与本机出网情况。')]; } // 404 = 连通且鉴权正常(对象本来就不存在);403 = 连通但权限不足 $ok = ($st === 404 || $st === 403 || ($st >= 200 && $st < 300)); $detail = $st === 403 ? '能连通,但当前凭证没有该桶的读取权限(去 RAM 给角色加上 oss:GetObject)' : '连通正常'; return ['ok' => $ok, 'status' => $st, 'host' => $host, 'internal' => $internal, 'detail' => $detail]; } /** * 极简 HTTP 客户端:curl 优先,无 curl 时用 stream 兜底。 * @return array [$status, $headers(lowercase keys), $body] */ function fb_http($method, $url, array $opt = []) { $method = strtoupper((string)$method); $timeout = isset($opt['timeout']) ? max(1, (int)$opt['timeout']) : 8; $headers = isset($opt['headers']) ? (array)$opt['headers'] : []; $noproxy = !empty($opt['noproxy']); if (function_exists('curl_init')) { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_CONNECTTIMEOUT => $timeout, CURLOPT_TIMEOUT => $timeout, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_HTTPHEADER => $headers, ]); // 元数据服务走链路本地地址,绝不能经过 http_proxy(否则永远读不到凭证) if ($noproxy && defined('CURLOPT_NOPROXY')) { curl_setopt($ch, CURLOPT_NOPROXY, '*'); } if ($method === 'HEAD') { curl_setopt($ch, CURLOPT_NOBODY, true); } $raw = curl_exec($ch); $bad = ($raw === false) || curl_errno($ch); $hsz = (int)curl_getinfo($ch, CURLINFO_HEADER_SIZE); $st = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($bad) { return [0, [], '']; } return [$st, fb_parse_headers(substr((string)$raw, 0, $hsz)), (string)substr((string)$raw, $hsz)]; } $streamOpt = [ 'method' => $method, 'timeout' => $timeout, 'ignore_errors' => true, 'header' => implode("\r\n", $headers), ]; if ($noproxy) { $streamOpt['proxy'] = ''; $streamOpt['request_fulluri'] = false; } $ctx = stream_context_create(['http' => $streamOpt]); $body = @file_get_contents($url, false, $ctx); $status = 0; $rawHead = ''; if (isset($http_response_header) && is_array($http_response_header)) { $lines = []; foreach ($http_response_header as $line) { if (preg_match('#^HTTP/\S+\s+(\d{3})#i', $line, $m)) { $status = (int)$m[1]; $lines = []; } else { $lines[] = $line; } } $rawHead = implode("\r\n", $lines); } return [$status, fb_parse_headers($rawHead), ($body === false ? '' : $body)]; } /** 解析响应头为小写键数组 */ function fb_parse_headers($raw) { $out = []; foreach (preg_split('/\r\n|\n/', (string)$raw) as $line) { $p = strpos($line, ':'); if ($p === false) { continue; } $out[strtolower(trim(substr($line, 0, $p)))] = trim(substr($line, $p + 1)); } return $out; } /** * HEAD 一个对象,用于提交时复核(是否存在 / 大小 / 类型)与连通性自检 * @return array [$status, $headers] */ function fb_oss_head($key) { // 一律带签名:使用 STS 临时凭证时 security-token 必须随请求发出, // 否则私有 Bucket 会被 OSS 直接 403。 $signed = fb_oss_signed_url('HEAD', $key, 300); if ($signed === '') { return [0, []]; } list($status, $h, ) = fb_http('HEAD', $signed, ['timeout' => 8]); if ($status === 0) { // 某些环境禁用了 curl 的 NOBODY,退化为 GET 只取头 list($status, $h, ) = fb_http('GET', $signed, ['timeout' => 8]); } return [$status, $h]; } /** 删除一个对象(清理过期附件时用)。返回是否成功(404 视作已删除) */ function fb_oss_delete($key) { $url = fb_oss_signed_url('DELETE', $key, 300); if ($url === '') { return false; } list($status, , ) = fb_http('DELETE', $url, ['timeout' => 10]); return ($status >= 200 && $status < 300) || $status === 404; } /** * 缩略图的【代理】地址:借用 OSS 图片处理在服务端缩放后再转发,省下行流量。 * @param string $mime 已知的内容类型;GIF 会被 OSS 处理成静态首帧(动画丢失),故不处理 */ function fb_oss_thumb_url($key, $w = 560, $mime = '') { $url = fb_oss_url($key); if (!fb_oss_cfg('image_process', true)) { return $url; } $m = strtolower(trim((string)$mime)); if ($m === '' || $m === 'application/octet-stream') { $e = strtolower((string)pathinfo((string)$key, PATHINFO_EXTENSION)); if ($e === 'gif') { return $url; } } elseif ($m === 'image/gif') { return $url; } $w = max(32, min(4096, (int)$w)); return $url . '&p=' . rawurlencode('image/resize,w_' . $w . '/quality,q_80'); }