OSS 附件改为私有桶+内网Endpoint+服务器代理架构
背景:桶为私有、且只用内网 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 全通过。
This commit is contained in:
@@ -394,10 +394,63 @@ function fb_attach_save(PDO $pdo, $threadId, $replyId, array $list) {
|
||||
$i, $now,
|
||||
]);
|
||||
$n++;
|
||||
// 转正成功即从「待入库」表移除(media.php 之后改走 attachments 表鉴权)
|
||||
fb_attach_pending_clear($it['key']);
|
||||
}
|
||||
return $n;
|
||||
}
|
||||
|
||||
/* ==================================================================
|
||||
* 待入库附件登记
|
||||
* ------------------------------------------------------------------
|
||||
* 上传成功 → 登记;提交表单入库 → 转正并移除;一直没提交 → 由 cron 清理。
|
||||
* 只为 media.php 的代理鉴权服务(上传后、入库前的缩略图预览)。
|
||||
* ================================================================== */
|
||||
|
||||
/** 登记一条待入库附件 */
|
||||
function fb_attach_pending_mark($key, $kind, $mime, $size) {
|
||||
try {
|
||||
$pdo = fb_db();
|
||||
$pdo->prepare(
|
||||
'INSERT INTO pending_attachments(oss_key, kind, mime, size, ip_hash, created_at)
|
||||
VALUES(?,?,?,?,?,?)'
|
||||
)->execute([(string)$key, (string)$kind, (string)$mime, (int)$size, fb_ip_hash(), time()]);
|
||||
return true;
|
||||
} catch (Throwable $e) { return false; }
|
||||
}
|
||||
|
||||
/** 转正/放弃后移除登记(幂等) */
|
||||
function fb_attach_pending_clear($key) {
|
||||
try {
|
||||
$pdo = fb_db();
|
||||
$pdo->prepare('DELETE FROM pending_attachments WHERE oss_key = ?')->execute([(string)$key]);
|
||||
return true;
|
||||
} catch (Throwable $e) { return false; }
|
||||
}
|
||||
|
||||
/** 该对象键是否由【当前访客】在有效期内登记过(media.php 鉴权用) */
|
||||
function fb_attach_pending_mine($key, $ttl = 7200) {
|
||||
try {
|
||||
$pdo = fb_db();
|
||||
$st = $pdo->prepare(
|
||||
'SELECT id FROM pending_attachments
|
||||
WHERE oss_key = ? AND ip_hash = ? AND created_at >= ? LIMIT 1'
|
||||
);
|
||||
$st->execute([(string)$key, fb_ip_hash(), time() - (int)$ttl]);
|
||||
return (bool)$st->fetch();
|
||||
} catch (Throwable $e) { return false; }
|
||||
}
|
||||
|
||||
/** 清理过期的待入库登记(cron 调用;只删记录,不动 OSS 对象——那属于未提交的孤儿,另行处理) */
|
||||
function fb_attach_pending_gc($ttl = 7200) {
|
||||
try {
|
||||
$pdo = fb_db();
|
||||
$st = $pdo->prepare('DELETE FROM pending_attachments WHERE created_at < ?');
|
||||
$st->execute([time() - (int)$ttl]);
|
||||
return $st->rowCount();
|
||||
} catch (Throwable $e) { return 0; }
|
||||
}
|
||||
|
||||
/** 取某个主题的附件:['main' => [...], 'replies' => [reply_id => [...]]] */
|
||||
function fb_attach_for_thread(PDO $pdo, $threadId) {
|
||||
$st = $pdo->prepare('SELECT * FROM attachments WHERE thread_id = ? ORDER BY seq ASC, id ASC');
|
||||
|
||||
+24
@@ -115,6 +115,30 @@ SQL
|
||||
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_att_thread ON attachments(thread_id, seq)');
|
||||
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_att_reply ON attachments(reply_id)');
|
||||
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_att_age ON attachments(created_at)');
|
||||
// 代理读取要按对象键反查(media.php 的鉴权),必须有索引否则每次读图都全表扫描
|
||||
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_att_key ON attachments(oss_key)');
|
||||
|
||||
/* ---- 待入库附件 ----
|
||||
* 私有桶 + 内网 Endpoint 下,附件一律经 media.php 代理输出,而代理必须鉴权。
|
||||
* 但「上传成功」发生在「表单提交入库」之前 —— 前端上传完就要立刻显示缩略图,
|
||||
* 此时 attachments 表里还没有这条记录。若只认 attachments 表,缩略图必然 403。
|
||||
* 所以上传成功时先在这张表登记一条,media.php 认「已入库 或 本人刚上传的待入库」,
|
||||
* 提交表单后由 fb_attach_save() 转正,过期条目由 cron.php 清理。
|
||||
*/
|
||||
$pdo->exec(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS pending_attachments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
oss_key TEXT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'image',
|
||||
mime TEXT,
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
ip_hash TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
SQL
|
||||
);
|
||||
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_patt_key ON pending_attachments(oss_key)');
|
||||
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_patt_age ON pending_attachments(created_at)');
|
||||
|
||||
$pdo->exec(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS rate_limit (
|
||||
|
||||
+1
-1
@@ -66,7 +66,7 @@ function fb_flash_box($msg, $type = 'error') {
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染附件上传控件(图片 / 视频,浏览器直传 OSS)
|
||||
* 渲染附件上传控件(图片 / 视频;上传经服务器中转落地,local/OSS 前端同一路径)
|
||||
* 需置于 <form> 内部:会输出一个 name="attachments" 的隐藏字段,
|
||||
* 由 assets/app.js 在文件上传成功后回填 JSON 描述。
|
||||
*
|
||||
|
||||
+371
-93
@@ -1,9 +1,21 @@
|
||||
<?php
|
||||
/**
|
||||
* 阿里云 OSS —— 浏览器直传支持(零依赖,只用 PHP 内置函数)
|
||||
* 阿里云 OSS —— 服务器代理读写(零依赖,只用 PHP 内置函数)
|
||||
* ------------------------------------------------------------------
|
||||
* 只用 HMAC-SHA1 签名(PostObject 策略签名 / V1 签名 URL),
|
||||
* 不依赖官方 SDK,也不依赖 ext-curl(有 curl 时自动优先使用)。
|
||||
* 只用 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'); }
|
||||
|
||||
@@ -230,17 +242,70 @@ function fb_oss_sk() { $c = fb_oss_credentials(); return (string)$c['sk']; }
|
||||
/** 当前安全令牌(使用临时凭证时必不为空) */
|
||||
function fb_oss_token() { $c = fb_oss_credentials(); return (string)$c['token']; }
|
||||
|
||||
/** 上传入口 host(含协议),形如 https://bucket.oss-cn-beijing.aliyuncs.com */
|
||||
/**
|
||||
* 规范化 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, '/'); }
|
||||
$c = fb_oss_cfg();
|
||||
$scheme = (strtolower((string)fb_oss_cfg('url_scheme', 'https')) === 'http') ? 'http' : 'https';
|
||||
$ep = preg_replace('#^https?://#i', '', trim((string)$c['endpoint']));
|
||||
return $scheme . '://' . trim((string)$c['bucket']) . '.' . $ep;
|
||||
|
||||
$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 !== '') {
|
||||
@@ -257,22 +322,13 @@ function fb_oss_make_key($ext) {
|
||||
return $prefix . '/' . date('Y/m') . '/' . bin2hex(random_bytes(8)) . '.' . $ext;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算 PostObject 策略签名(独立函数,便于单元测试交叉验证)
|
||||
* @param string $policyB64 base64(policy JSON)
|
||||
* @return string base64(HMAC-SHA1(secret, policyB64))
|
||||
*/
|
||||
function fb_oss_sign_policy_string($policyB64, $secret) {
|
||||
return base64_encode(hash_hmac('sha1', (string)$policyB64, (string)$secret, true));
|
||||
}
|
||||
|
||||
/** 拼一个对象的访问 URL */
|
||||
/** 拼一个对象在 OSS 上的访问 URL(仅服务器内部使用) */
|
||||
function fb_oss_object_url($key, $forcePublicBase = false) {
|
||||
return ($forcePublicBase ? fb_oss_host() : fb_oss_public_base()) . '/' . ltrim((string)$key, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 OSS V1 签名参数(用于私有 Bucket 的临时访问、HEAD 校验、删除、签名直传)
|
||||
* 生成 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'=>..]
|
||||
@@ -283,16 +339,35 @@ function fb_oss_object_url($key, $forcePublicBase = false) {
|
||||
* 签名用的 SK 与随请求发出的 security-token 就来自不同批次 → SignatureDoesNotMatch。
|
||||
* ② 【security-token 必须算进 Resource】。它是以 URL 查询参数(子资源)形式出现的,
|
||||
* OSS 计算签名时会把它连同 Resource 一起算。若照搬"官方说 token 不参与签名"而只把它
|
||||
* 挂在 URL 上,PUT/HEAD/DELETE 这类用签名 URL 的请求会全部 403 SignatureDoesNotMatch
|
||||
* (PostObject 表单直传不受影响,所以只有"浏览器直传能成功、删不掉"这种诡异现象)。
|
||||
* 挂在 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 = '') {
|
||||
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, '/');
|
||||
if ($tok !== '') { $res .= '?security-token=' . $tok; }
|
||||
|
||||
// 子资源必须【按名字字典序】拼接(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;
|
||||
@@ -304,68 +379,280 @@ function fb_oss_sign_v1($method, $key, $expires, $contentType = '', $contentMd5
|
||||
];
|
||||
// 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;
|
||||
}
|
||||
|
||||
/** 给对象 URL 追加签名参数(私有 Bucket 展示时用;公共读时返回原 URL) */
|
||||
/**
|
||||
* 浏览器可访问的附件地址 —— 走本站代理 media.php。
|
||||
* ------------------------------------------------------------------
|
||||
* 私有桶 + 内网 Endpoint 下,OSS 的 URL 对浏览器毫无意义(连不上、也没权限),
|
||||
* 所以这里返回的一律是站内代理地址。签名、取流都在服务器内部完成。
|
||||
* @return string 形如 media.php?k=fb%2F2026%2F09%2Fabcd.jpg
|
||||
*/
|
||||
function fb_oss_url($key, $ttl = 0) {
|
||||
$url = fb_oss_object_url($key);
|
||||
if (strtolower((string)fb_oss_cfg('visibility', 'public')) !== 'private') { return $url; }
|
||||
$ttl = $ttl > 0 ? (int)$ttl : (int)fb_oss_cfg('link_ttl', 604800);
|
||||
$sign = fb_oss_sign_v1('GET', $key, time() + $ttl);
|
||||
return 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成浏览器直传所需的上传凭证
|
||||
* @param string $key 服务端指定的对象键(策略里用 eq 锁定,客户端无法改名)
|
||||
* @param int $maxBytes content-length-range 上限
|
||||
* @param string $typePrefix 允许的 Content-Type 前缀(image/ 或 video/)
|
||||
* @return array
|
||||
* 用 Authorization 头签名(PUT 上传用)。
|
||||
* 相比查询串签名,头签名能把 Content-Type / x-oss-security-token 一起纳入,
|
||||
* 更适合带请求体的写入。
|
||||
* @return array 需要附加到请求上的 HTTP 头(含 Authorization 与 x-oss-security-token)
|
||||
*/
|
||||
function fb_oss_upload_credential($key, $maxBytes, $typePrefix) {
|
||||
$c = fb_oss_cfg();
|
||||
$cred = fb_oss_credentials();
|
||||
$ttl = max(60, (int)fb_oss_cfg('sign_ttl', 600));
|
||||
$expire = time() + $ttl;
|
||||
function fb_oss_auth_headers($method, $key, $date, $contentType = '', $contentMd5 = '', array $extraOssHeaders = []) {
|
||||
$cred = fb_oss_credentials();
|
||||
$c = fb_oss_cfg();
|
||||
|
||||
// 临时凭证本身可能更快到期 —— 策略有效期必须落在凭证有效期内,否则 OSS 会拒
|
||||
if (!empty($cred['expire']) && (int)$cred['expire'] > 0) {
|
||||
$expire = min($expire, (int)$cred['expire'] - 30);
|
||||
if ($expire <= time()) { $expire = time() + 60; }
|
||||
$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)];
|
||||
}
|
||||
|
||||
$policy = [
|
||||
'expiration' => gmdate('Y-m-d\TH:i:s\Z', $expire),
|
||||
'conditions' => [
|
||||
['bucket' => (string)$c['bucket']],
|
||||
['eq', '$key', (string)$key],
|
||||
['starts-with', '$Content-Type', (string)$typePrefix],
|
||||
['content-length-range', 1, (int)$maxBytes],
|
||||
],
|
||||
];
|
||||
$json = json_encode($policy, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
$b64 = base64_encode($json);
|
||||
$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); }
|
||||
|
||||
// 使用实例 RAM 角色的临时凭证时,表单里必须多带一个 x-oss-security-token 域
|
||||
$extra = [];
|
||||
if (!empty($cred['token'])) { $extra['x-oss-security-token'] = (string)$cred['token']; }
|
||||
if ($ok === false || $errno) { return [$status, '读取对象失败:' . $errtxt]; }
|
||||
if ($status >= 200 && $status < 400) { return [$status, '']; }
|
||||
return [$status, fb_oss_error_text($errBody, $status)];
|
||||
}
|
||||
|
||||
return [
|
||||
'host' => fb_oss_host(), // 浏览器 POST 到这里
|
||||
'key' => (string)$key,
|
||||
'policy' => $b64,
|
||||
// ⚠️ 用本次已取出的 $cred 计算,别再去调 fb_oss_sk()/fb_oss_ak()
|
||||
// (多调一次 fb_oss_credentials() 可能在刷新后拿到不同批次)
|
||||
'signature' => fb_oss_sign_policy_string($b64, (string)$cred['sk']),
|
||||
'access_key_id' => (string)$cred['ak'],
|
||||
'content_type' => (string)$typePrefix,
|
||||
'max_bytes' => (int)$maxBytes,
|
||||
'expires' => $expire,
|
||||
'extra_fields' => $extra, // 恒含 x-oss-security-token
|
||||
'url' => fb_oss_url($key),
|
||||
/** 从 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];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -437,43 +724,34 @@ function fb_parse_headers($raw) {
|
||||
}
|
||||
|
||||
/**
|
||||
* HEAD 一个对象,用于提交时复核(是否存在 / 大小 / 类型)
|
||||
* HEAD 一个对象,用于提交时复核(是否存在 / 大小 / 类型)与连通性自检
|
||||
* @return array [$status, $headers]
|
||||
*/
|
||||
function fb_oss_head($key) {
|
||||
// 一律带签名:使用 STS 临时凭证时 security-token 必须随请求发出,
|
||||
// 否则私有 Bucket、或要求「经过身份验证的请求」的场景会被 OSS 直接 403。
|
||||
$signed = fb_oss_object_url($key, true) . '?'
|
||||
. http_build_query(fb_oss_sign_v1('HEAD', $key, time() + 300), '', '&', PHP_QUERY_RFC3986);
|
||||
list($status, $h, ) = fb_http('HEAD', $signed, ['timeout' => 6]);
|
||||
// 否则私有 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' => 6]);
|
||||
}
|
||||
|
||||
// 公共读 Bucket 的兜底:签名请求若因「该身份没有 GetObject 权限」被拒,
|
||||
// 改走匿名请求(公共读对象匿名即可读取),避免误判为「对象不存在」。
|
||||
if ($status === 403 && strtolower((string)fb_oss_cfg('visibility', 'public')) !== 'private') {
|
||||
$anon = fb_oss_object_url($key, true);
|
||||
list($s2, $h2, ) = fb_http('HEAD', $anon, ['timeout' => 6]);
|
||||
if ($s2 === 0) { list($s2, $h2, ) = fb_http('GET', $anon, ['timeout' => 6]); }
|
||||
if ($s2 !== 0) { return [$s2, $h2]; }
|
||||
list($status, $h, ) = fb_http('GET', $signed, ['timeout' => 8]);
|
||||
}
|
||||
return [$status, $h];
|
||||
}
|
||||
|
||||
/** 删除一个对象(清理过期附件时用)。返回是否成功(404 视作已删除) */
|
||||
function fb_oss_delete($key) {
|
||||
$exp = time() + 300;
|
||||
$sign = fb_oss_sign_v1('DELETE', $key, $exp);
|
||||
$url = fb_oss_object_url($key, true) . '?' . http_build_query($sign, '', '&', PHP_QUERY_RFC3986);
|
||||
list($status, , ) = fb_http('DELETE', $url, ['timeout' => 8]);
|
||||
$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 处理成静态首帧(动画丢失),故原样返回
|
||||
* 缩略图的【代理】地址:借用 OSS 图片处理在服务端缩放后再转发,省下行流量。
|
||||
* @param string $mime 已知的内容类型;GIF 会被 OSS 处理成静态首帧(动画丢失),故不处理
|
||||
*/
|
||||
function fb_oss_thumb_url($key, $w = 560, $mime = '') {
|
||||
$url = fb_oss_url($key);
|
||||
@@ -487,6 +765,6 @@ function fb_oss_thumb_url($key, $w = 560, $mime = '') {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$sep = (strpos($url, '?') === false) ? '?' : '&';
|
||||
return $url . $sep . 'x-oss-process=image/resize,w_' . (int)$w . '/quality,q_80';
|
||||
$w = max(32, min(4096, (int)$w));
|
||||
return $url . '&p=' . rawurlencode('image/resize,w_' . $w . '/quality,q_80');
|
||||
}
|
||||
|
||||
+1
-1
@@ -172,7 +172,7 @@ function fb_setting_keys() {
|
||||
'oss.endpoint', 'oss.bucket', 'oss.instance_role', 'oss.enabled',
|
||||
'oss.credential_source', 'oss.access_key', 'oss.secret_key',
|
||||
'oss.domain', 'oss.visibility', 'oss.prefix', 'oss.url_scheme',
|
||||
'oss.verify', 'oss.image_process',
|
||||
'oss.verify', 'oss.image_process', 'oss.use_internal_endpoint',
|
||||
'moderation.enabled', 'moderation.close_mail_subject',
|
||||
'admin.password_hash',
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user