Files
FeedLite/lib/oss.php
T
laoyang cede838b5e FeedLite 初始提交:通用轻量级反馈系统(PHP + SQLite,零依赖)
- 前台:问题列表 / 详情 / 发布与回复(趣味问答验证码、蜜罐、IP 限流)
- 后台:先审后显、站点设置(站点标题/描述、附件存储、OSS 参数)、操作日志
- 附件:关闭 / 本机硬盘 / 阿里云 OSS 浏览器直传(ECS 实例 RAM 角色)三种模式
- 品牌资源:全套图标(favicon.ico / SVG / PNG 各尺寸 / manifest)
- 安全:CSRF、session 加固、.htaccess 屏蔽敏感文件、口令仅存哈希
- 配置:config.example.php 为模板,config.php 不入库
2026-09-28 20:34:37 +08:00

493 lines
21 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 签名(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';
}