背景:桶为私有、且只用内网 Endpoint(外网不可达),浏览器既无法直传、 也无法直连。原「浏览器直传 + 签名URL直连」两条路都失效,改为全站代理。 lib/oss.php: - endpoint 自动转内网(*-internal),use_internal_endpoint 开关控制,内网走 http - 移除浏览器直传凭证(PostObject),改为服务器端 PUT(流式 fb_oss_put_file) - 新增 fb_oss_stream_get 流式读取(回调式,支持 Range,不整文件进内存) - 签名支持 x-oss-process 子资源 + Authorization 头签名(Content-Type 入签) - 新增 fb_oss_probe 连通性自检、fb_oss_error_text 错误归因(含跨地域提示) media.php(新增): 附件代理输出,鉴权边界=对象键须在本站附件表(或本人待入库 登记)且帖子对访客可见,防止本站沦为能读整个共用桶的开放代理;透传 Range。 upload.php: local/oss 统一走两步(领键→action=put),OSS 模式由服务器代理 PUT。 lib/db.php: 新增 pending_attachments 表 + idx_att_key 索引(迁移幂等自动建)。 lib/attach.php: 待入库登记(mark/clear/mine/gc),解决上传后入库前的预览鉴权。 cron.php: 清理过期待入库登记 + 桶连通性自检输出。 admin.php: 设置页加「使用内网 Endpoint」开关、存储体检显示实际连接地址与探测按钮。 README/config: 全面更新为代理架构(无需CORS、无需自定义域名、同地域要求)。 验证:本地 e2e 25/25 + 附件专项 13/13(含防开放代理/Range/内网改写); 线上真机上传→内网PUT→media.php代理读回→Range 全通过。
771 lines
35 KiB
PHP
771 lines
35 KiB
PHP
<?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');
|
||
}
|