头,即使站点配置失误也无法被下载) */ 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']; } /** 上传入口 host(含协议),形如 https://bucket.oss-cn-beijing.aliyuncs.com */ function fb_oss_host() { $o = trim((string)fb_oss_cfg('host_override', '')); if ($o !== '') { return rtrim($o, '/'); } $c = fb_oss_cfg(); $scheme = (strtolower((string)fb_oss_cfg('url_scheme', 'https')) === 'http') ? 'http' : 'https'; $ep = preg_replace('#^https?://#i', '', trim((string)$c['endpoint'])); return $scheme . '://' . trim((string)$c['bucket']) . '.' . $ep; } /** 对外的访问基地址(配置了自定义域名就用它) */ 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; } /** * 计算 PostObject 策略签名(独立函数,便于单元测试交叉验证) * @param string $policyB64 base64(policy JSON) * @return string base64(HMAC-SHA1(secret, policyB64)) */ function fb_oss_sign_policy_string($policyB64, $secret) { return base64_encode(hash_hmac('sha1', (string)$policyB64, (string)$secret, true)); } /** 拼一个对象的访问 URL */ function fb_oss_object_url($key, $forcePublicBase = false) { return ($forcePublicBase ? fb_oss_host() : fb_oss_public_base()) . '/' . ltrim((string)$key, '/'); } /** * 生成 OSS V1 签名参数(用于私有 Bucket 的临时访问、HEAD 校验、删除、签名直传) * 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 这类用签名 URL 的请求会全部 403 SignatureDoesNotMatch * (PostObject 表单直传不受影响,所以只有"浏览器直传能成功、删不掉"这种诡异现象)。 */ function fb_oss_sign_v1($method, $key, $expires, $contentType = '', $contentMd5 = '') { $c = fb_oss_cfg(); $cred = fb_oss_credentials(); $tok = isset($cred['token']) ? (string)$cred['token'] : ''; $res = '/' . trim((string)$c['bucket']) . '/' . ltrim((string)$key, '/'); if ($tok !== '') { $res .= '?security-token=' . $tok; } $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; } return $out; } /** 给对象 URL 追加签名参数(私有 Bucket 展示时用;公共读时返回原 URL) */ function fb_oss_url($key, $ttl = 0) { $url = fb_oss_object_url($key); if (strtolower((string)fb_oss_cfg('visibility', 'public')) !== 'private') { return $url; } $ttl = $ttl > 0 ? (int)$ttl : (int)fb_oss_cfg('link_ttl', 604800); $sign = fb_oss_sign_v1('GET', $key, time() + $ttl); return $url . '?' . http_build_query($sign, '', '&', PHP_QUERY_RFC3986); } /** * 生成浏览器直传所需的上传凭证 * @param string $key 服务端指定的对象键(策略里用 eq 锁定,客户端无法改名) * @param int $maxBytes content-length-range 上限 * @param string $typePrefix 允许的 Content-Type 前缀(image/ 或 video/) * @return array */ function fb_oss_upload_credential($key, $maxBytes, $typePrefix) { $c = fb_oss_cfg(); $cred = fb_oss_credentials(); $ttl = max(60, (int)fb_oss_cfg('sign_ttl', 600)); $expire = time() + $ttl; // 临时凭证本身可能更快到期 —— 策略有效期必须落在凭证有效期内,否则 OSS 会拒 if (!empty($cred['expire']) && (int)$cred['expire'] > 0) { $expire = min($expire, (int)$cred['expire'] - 30); if ($expire <= time()) { $expire = time() + 60; } } $policy = [ 'expiration' => gmdate('Y-m-d\TH:i:s\Z', $expire), 'conditions' => [ ['bucket' => (string)$c['bucket']], ['eq', '$key', (string)$key], ['starts-with', '$Content-Type', (string)$typePrefix], ['content-length-range', 1, (int)$maxBytes], ], ]; $json = json_encode($policy, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $b64 = base64_encode($json); // 使用实例 RAM 角色的临时凭证时,表单里必须多带一个 x-oss-security-token 域 $extra = []; if (!empty($cred['token'])) { $extra['x-oss-security-token'] = (string)$cred['token']; } return [ 'host' => fb_oss_host(), // 浏览器 POST 到这里 'key' => (string)$key, 'policy' => $b64, // ⚠️ 用本次已取出的 $cred 计算,别再去调 fb_oss_sk()/fb_oss_ak() // (多调一次 fb_oss_credentials() 可能在刷新后拿到不同批次) 'signature' => fb_oss_sign_policy_string($b64, (string)$cred['sk']), 'access_key_id' => (string)$cred['ak'], 'content_type' => (string)$typePrefix, 'max_bytes' => (int)$maxBytes, 'expires' => $expire, 'extra_fields' => $extra, // 恒含 x-oss-security-token 'url' => fb_oss_url($key), ]; } /** * 极简 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_object_url($key, true) . '?' . http_build_query(fb_oss_sign_v1('HEAD', $key, time() + 300), '', '&', PHP_QUERY_RFC3986); list($status, $h, ) = fb_http('HEAD', $signed, ['timeout' => 6]); if ($status === 0) { // 某些环境禁用了 curl 的 NOBODY,退化为 GET 只取头 list($status, $h, ) = fb_http('GET', $signed, ['timeout' => 6]); } // 公共读 Bucket 的兜底:签名请求若因「该身份没有 GetObject 权限」被拒, // 改走匿名请求(公共读对象匿名即可读取),避免误判为「对象不存在」。 if ($status === 403 && strtolower((string)fb_oss_cfg('visibility', 'public')) !== 'private') { $anon = fb_oss_object_url($key, true); list($s2, $h2, ) = fb_http('HEAD', $anon, ['timeout' => 6]); if ($s2 === 0) { list($s2, $h2, ) = fb_http('GET', $anon, ['timeout' => 6]); } if ($s2 !== 0) { return [$s2, $h2]; } } return [$status, $h]; } /** 删除一个对象(清理过期附件时用)。返回是否成功(404 视作已删除) */ function fb_oss_delete($key) { $exp = time() + 300; $sign = fb_oss_sign_v1('DELETE', $key, $exp); $url = fb_oss_object_url($key, true) . '?' . http_build_query($sign, '', '&', PHP_QUERY_RFC3986); list($status, , ) = fb_http('DELETE', $url, ['timeout' => 8]); 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; } $sep = (strpos($url, '?') === false) ? '?' : '&'; return $url . $sep . 'x-oss-process=image/resize,w_' . (int)$w . '/quality,q_80'; }