Files
FeedLite/lib/oss.php
T

771 lines
35 KiB
PHP
Raw Normal View History

<?php
/**
* 阿里云 OSS —— 服务器代理读写(零依赖,只用 PHP 内置函数)
* ------------------------------------------------------------------
* 只用 HMAC-SHA1 的 V1 签名,不依赖官方 SDK。
*
* ★ 架构:私有桶 + 内网 Endpoint,全部经服务器代理
* · 桶是【私有】的 → 浏览器拿不到任何可直接访问的 URL;
* · Endpoint 是【内网】的(*-internal.aliyuncs.com)→ 只有同地域的阿里云 ECS
* 能连,外网根本不可达。
* 因此「浏览器直传」和「签名 URL 直连」两条路都走不通,只能:
* 上传:浏览器 → upload.php → PUT 到内网 OSS
* 读取:浏览器 → media.php → GET 内网 OSS 并流式转发(支持 Range)
* 代价是流量过服务器,换来的是桶可以彻底私有、且不暴露公网 Endpoint。
*
* ⚠️ 代理鉴权是安全边界:media.php 必须校验对象键在【本站附件表】里、
* 且所属帖子对当前访客可见。否则本站会变成「能读整个桶」的开放代理 ——
* 共用桶(一个桶放多个应用的数据)时等于把别人的数据也泄露出去。
*/
if (!defined('FB_ROOT')) { http_response_code(403); exit('forbidden'); }
/** 读取 oss 配置 */
function fb_oss_cfg($k = null, $d = null) {
$c = (array)fb_cfg('oss', []);
if ($k === null) { return $c; }
return array_key_exists($k, $c) ? $c[$k] : $d;
}
/**
* 是否已正确配置(未配置则整个上传功能自动隐藏)
* 两种鉴权方式:
* instance_role —— ECS 实例 RAM 角色(推荐,服务器上不存长期密钥)
* static —— 静态 AccessKey(非 ECS 环境 / 自建机用这个)
*/
function fb_oss_enabled() {
$c = fb_oss_cfg();
if (empty($c['enabled'])) { return false; }
foreach (['bucket', 'endpoint'] as $k) {
if (trim((string)(isset($c[$k]) ? $c[$k] : '')) === '') { return false; }
}
if (fb_oss_credential_source() === 'static') {
foreach (['access_key', 'secret_key'] as $k) {
if (trim((string)(isset($c[$k]) ? $c[$k] : '')) === '') { return false; }
}
return true;
}
return trim((string)fb_oss_cfg('instance_role', '')) !== '';
}
/** 当前鉴权方式:instance_role | static */
function fb_oss_credential_source() {
$s = strtolower(trim((string)fb_oss_cfg('credential_source', 'instance_role')));
return $s === 'static' ? 'static' : 'instance_role';
}
/* ==================================================================
* 临时凭证(ECS 实例 RAM 角色)
* ------------------------------------------------------------------
* 服务器上**不保存**任何长期 AccessKey:
* 给 ECS 实例绑定一个 RAM 角色后,实例内可以通过内网元数据服务
* (http://100.100.100.200,链路本地地址、不出公网)读到一份
* 「有时效、会自动周期轮换」的临时凭证:
* AccessKeyId / AccessKeySecret / SecurityToken / Expiration
* 本程序只负责「把它取回来、缓存起来、拿去签名」。
*
* 对外统一成 ['ak','sk','token','expire'],下游签名逻辑无需关心来源。
* ================================================================== */
/** 临时凭证的本地缓存文件(含 <?php exit; ?> 头,即使站点配置失误也无法被下载) */
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 = "<?php exit; ?>\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=<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('#<Code>([^<]+)</Code>#', $body, $m)) { $code = trim($m[1]); }
$msg = '';
if (preg_match('#<Message>([^<]+)</Message>#', $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');
}