Files
FeedLite/lib/oss.php
T
laoyang 8a850ca0e5 修复:全站图片 503 —— oss.enabled 派生写入被 has() 守卫拦掉
问题:attachment.storage 存成 oss,但 oss.enabled 一直停在 0。
media.php 判 fb_oss_enabled() 为 false,直接 503「对象存储未配置」,
所有 OSS 附件(历史 + 新增)全部打不开。

根因有两层:
1. oss.enabled 是派生字段,表单里没有对应 input,上一个提交加的
   $save() 统一守卫用 $has() 判断「没提交」就 return,把它挡掉了;
2. 更根本的是两份状态描述同一件事 —— storage 和 enabled 迟早漂移。

修法:
- 派生写入改用 $saveForce(),并整段包进 if ($has(attachment.storage)),
  避免反向问题(没提交存储方式时被默认值改成 off);
- fb_oss_enabled() 改为只看 attachment.storage 作单一事实来源,
  oss.enabled 仅在 storage 完全没配过的老库上作为回落。
2026-11-03 07:21:04 +08:00

843 lines
38 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();
/* 🔴 启用判据只看 attachment.storage(单一事实来源)。
*
* 原先这里只看 oss.enabled —— 而 oss.enabled 是个【派生字段】:
* 后台表单里没有对应的 input,只能由保存逻辑“顺手”写一次。
* 两份状态描述同一件事,就必然有漂移的一天,实际也真的漂了:
* 2026-11-03 attachment.storage=oss 而 oss.enabled=0,
* 结果上传/读取全被判成「对象存储未配置」,全站图片 503。
*
* 现在只有 storage 完全没配过(老库、安装向导中途)才回落到 oss.enabled。
*/
$storage = strtolower(trim((string)fb_cfg('attachment.storage', '')));
if ($storage !== '') {
if ($storage !== 'oss') { return false; } // local / off → OSS 一律不启用
} elseif (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 角色名。
*
* 元数据接口 GET /latest/meta-data/ram/security-credentials/(末尾带斜杠)
* 返回的是「角色名列表」,每行一个。绝大多数实例只绑一个角色。
* 所以用户完全不需要知道角色叫什么 —— 我们去问机器就行。
*
* @return array [bool $ok, string $role|string $err]
*/
function fb_oss_detect_instance_role() {
$url = fb_oss_metadata_base() . '/latest/meta-data/ram/security-credentials/';
$headers = [];
$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, '读不到 ECS 元数据(HTTP ' . $st . ')。不在 ECS 上,或元数据服务被禁用。'];
}
$txt = trim((string)$body);
if ($txt === '') {
return [false, '这台 ECS 实例没有绑定任何 RAM 角色。请在阿里云控制台给实例绑定一个角色,再回来点「自动探测」。'];
}
// 可能是多行(多角色)或 JSON 数组,两种都兼容
$names = [];
$j = json_decode($txt, true);
if (is_array($j)) {
foreach ($j as $v) { if (is_string($v) && trim($v) !== '') { $names[] = trim($v); } }
} else {
foreach (preg_split('/\r?\n/', $txt) as $line) {
$line = trim($line);
if ($line !== '') { $names[] = $line; }
}
}
if (!$names) { return [false, '元数据返回内容无法识别为角色名。']; }
return [true, $names[0]]; // 多角色时取第一个,后台会提示可手改
}
/** 从 ECS 元数据读取实例 RAM 角色凭证 @return array [bool, array|string] */
function fb_oss_instance_creds() {
$role = trim((string)fb_oss_cfg('instance_role', ''));
// 没填就自动探测一次,免得用户还要去控制台查角色名
if ($role === '') {
list($ok, $r) = fb_oss_detect_instance_role();
if ($ok) { $role = (string)$r; }
}
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');
}