Files
FeedLite/lib/oss.php
T
laoyang 6135e7b890 兼容 PHP 8.5:消除全部弃用调用
PHP 8.5 把 curl_close()/finfo_close() 这类"空操作函数"标为弃用,
并把预定义局部变量 http_response_header 标为弃用(编译期判定,@ 与三元短路都压不住)。

- 句柄关闭收进 fb_curl_close()/fb_finfo_close():仅 PHP < 8.0 真正调用,
  新版本直接跳过(8.0 起句柄随引用计数释放),保留 7.4 兼容
- 响应头读取改用 http_get_last_response_headers()(8.4+);
  老版本经 get_defined_vars() 从作用域变量表取,源码不再出现已弃用标识符
- 新 API 是全局状态,用「请求前基线」比对,避免把上一次的残留头当成本次结果
- 安装体检与 README 的推荐版本区间更新为 8.1~8.5

验证:PHP 8.5.12 下五套测试全绿(25/25、22/22、60/60、7/7、反向 4 组全被抓住),
error_log 零条弃用记录;PHP 8.4.25 同样全绿。
2026-10-23 08:23:55 +08:00

781 lines
35 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
fb_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,
]]);
$hdrBefore = FB_HAS_HTTP_LAST_HDRS ? http_get_last_response_headers() : null;
$body = @file_get_contents($url, false, $ctx);
// PHP 8.5 起预定义局部变量 http_response_header 已弃用(连 @ 都压不住),
// 8.4 起改用 http_get_last_response_headers();老版本仍读局部变量。
// 三元短路:新版本不执行第二支,老版本也不碰第一支。
$hdrRaw = FB_HAS_HTTP_LAST_HDRS
? fb_last_http_headers($hdrBefore)
: fb_local_http_headers(get_defined_vars());
if ($hdrRaw && preg_match('#^HTTP/\S+\s+(\d{3})#', $hdrRaw[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);
fb_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);
fb_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]);
$hdrBefore = FB_HAS_HTTP_LAST_HDRS ? http_get_last_response_headers() : null;
$body = @file_get_contents($url, false, $ctx);
$status = 0; $rawHead = '';
$hdrRaw = FB_HAS_HTTP_LAST_HDRS
? fb_last_http_headers($hdrBefore)
: fb_local_http_headers(get_defined_vars());
if ($hdrRaw) {
$lines = [];
foreach ($hdrRaw 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');
}