FeedLite 初始提交:通用轻量级反馈系统(PHP + SQLite,零依赖)
- 前台:问题列表 / 详情 / 发布与回复(趣味问答验证码、蜜罐、IP 限流) - 后台:先审后显、站点设置(站点标题/描述、附件存储、OSS 参数)、操作日志 - 附件:关闭 / 本机硬盘 / 阿里云 OSS 浏览器直传(ECS 实例 RAM 角色)三种模式 - 品牌资源:全套图标(favicon.ico / SVG / PNG 各尺寸 / manifest) - 安全:CSRF、session 加固、.htaccess 屏蔽敏感文件、口令仅存哈希 - 配置:config.example.php 为模板,config.php 不入库
This commit is contained in:
+492
@@ -0,0 +1,492 @@
|
||||
<?php
|
||||
/**
|
||||
* 阿里云 OSS —— 浏览器直传支持(零依赖,只用 PHP 内置函数)
|
||||
* ------------------------------------------------------------------
|
||||
* 只用 HMAC-SHA1 签名(PostObject 策略签名 / V1 签名 URL),
|
||||
* 不依赖官方 SDK,也不依赖 ext-curl(有 curl 时自动优先使用)。
|
||||
*/
|
||||
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']; }
|
||||
|
||||
/** 上传入口 host(含协议),形如 https://bucket.oss-cn-beijing.aliyuncs.com */
|
||||
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;
|
||||
}
|
||||
|
||||
/** 对外的访问基地址(配置了自定义域名就用它) */
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算 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 */
|
||||
function fb_oss_object_url($key, $forcePublicBase = false) {
|
||||
return ($forcePublicBase ? fb_oss_host() : fb_oss_public_base()) . '/' . ltrim((string)$key, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 OSS V1 签名参数(用于私有 Bucket 的临时访问、HEAD 校验、删除、签名直传)
|
||||
* 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 这类用签名 URL 的请求会全部 403 SignatureDoesNotMatch
|
||||
* (PostObject 表单直传不受影响,所以只有"浏览器直传能成功、删不掉"这种诡异现象)。
|
||||
*/
|
||||
function fb_oss_sign_v1($method, $key, $expires, $contentType = '', $contentMd5 = '') {
|
||||
$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; }
|
||||
|
||||
$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; }
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** 给对象 URL 追加签名参数(私有 Bucket 展示时用;公共读时返回原 URL) */
|
||||
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 $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
|
||||
*/
|
||||
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;
|
||||
|
||||
// 临时凭证本身可能更快到期 —— 策略有效期必须落在凭证有效期内,否则 OSS 会拒
|
||||
if (!empty($cred['expire']) && (int)$cred['expire'] > 0) {
|
||||
$expire = min($expire, (int)$cred['expire'] - 30);
|
||||
if ($expire <= time()) { $expire = time() + 60; }
|
||||
}
|
||||
|
||||
$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);
|
||||
|
||||
// 使用实例 RAM 角色的临时凭证时,表单里必须多带一个 x-oss-security-token 域
|
||||
$extra = [];
|
||||
if (!empty($cred['token'])) { $extra['x-oss-security-token'] = (string)$cred['token']; }
|
||||
|
||||
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),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 极简 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_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]);
|
||||
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]; }
|
||||
}
|
||||
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]);
|
||||
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;
|
||||
}
|
||||
|
||||
$sep = (strpos($url, '?') === false) ? '?' : '&';
|
||||
return $url . $sep . 'x-oss-process=image/resize,w_' . (int)$w . '/quality,q_80';
|
||||
}
|
||||
Reference in New Issue
Block a user