feat: 云端图片存储(OSS/COS/七牛)+ 服务端代理读取,并补全文档
新增存储抽象层:图片可存本地或云对象存储,切换存储方式不影响已有图片。
存储层
- lib/storage.php 存储门面:本地优先读取、URL 生成、上传/删除、批量迁移、
服务端读取(storageRead)与访问方式判定(storageAccessMode)
- lib/storage/{local,oss,cos,qiniu}.php 四种后端驱动;http.php 共用 cURL 客户端;
meta.php 实例元数据鉴权(ECS RAM 角色 / CVM CAM 角色,免密钥)
- lib/settings.php 设置读写;云厂商密钥以 enc:v1:gcm 密文落库(db/.settings.key)
- 文件名改为内容指纹(md5 前 12 位_6 位随机[_角色].ext),内容不变则名不变,
因此可下发 immutable 长缓存
两种图片访问方式
- 直连存储:storageUrl() 产出对象存储地址(桶需允许匿名读)
- 服务端代理:media.php 鉴权后由服务器签名读取并同源回吐,桶可保持私有
· 未登录返回 403 纯文本(刻意不用 302,否则 <img> 会拿到一段 HTML)
· 对象键白名单防目录穿越;Content-Type 按扩展名白名单决定 + nosniff(防同源 XSS)
· 透传 If-None-Match / Range,上游 304 直接短路;private + immutable 长缓存
· 鉴权后立即 session_write_close(),避免排他会话锁把并行图片请求串行化
内网 endpoint 判定(修「开了代理后慢到不可用」)
- 内网地址只在与桶同地域的 ECS 上可路由;跨地域时 DNS 能解析但 TCP 连不通,
无脑「内网优先」会让每次读取先白等满一次连接超时(默认 10 秒)
- 改为先比本机 ECS 地域与桶地域(零成本、结论确定)→ 不同即跳过;
结论落缓存;内网只给 2 秒连接超时,传输层失败立刻标记且短期内不再重试
- 「测试连通性」升级为 PUT → GET 回读 → DELETE,并显式报出内网判定结论
界面与页面
- settings.php 系统设置页:驱动选择、各云配置、图片访问方式、连接测试、迁移进度
- storage_api.php 提供 stat / migrate / test 三个 JSON 接口
- detail / edit / annual_report / persons 原先直接拼本地路径(file_exists / '/'.$path),
迁云后会整块不渲染或 404,统一改为走存储门面
- js/main.js 增加破图占位兜底;config.php 统一下发 Referrer-Policy: same-origin
文档
- README 新增《图片存储与对象存储》整章:设计约定、两种访问方式取舍表、
直连存储逐项配置说明(桶 ACL / 防盗链白名单 / 跨域 CORS 与 crossorigin /
强制下载 / endpoint 地域 / 自定义域名)与上线检查清单、服务端代理实现要点、
迁移步骤、12 条踩坑记录、可直接复制的排障命令手册
This commit is contained in:
@@ -91,6 +91,15 @@ function initDB() {
|
||||
)
|
||||
");
|
||||
|
||||
// 系统设置表(键值结构,用于存储方式等配置)
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
name TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
");
|
||||
|
||||
// ===== 迁移字段 =====
|
||||
try { $db->exec("ALTER TABLE cinemas ADD COLUMN color TEXT DEFAULT '#888888'"); } catch (PDOException $e) {}
|
||||
try { $db->exec("ALTER TABLE admins ADD COLUMN avatar TEXT"); } catch (PDOException $e) {}
|
||||
|
||||
+75
-31
@@ -2,6 +2,8 @@
|
||||
// ============================================
|
||||
// 文件: lib/helpers.php
|
||||
// 说明: 通用工具函数(输出转义、文件名、颜色、上传文件读写、图片 URL)
|
||||
// 图片的读写与 URL 生成一律通过 lib/storage.php 收口,
|
||||
// 以便在“本地硬盘”与“对象存储”之间切换。
|
||||
// ============================================
|
||||
|
||||
// 允许上传的图片扩展名
|
||||
@@ -14,12 +16,42 @@ function h($str) {
|
||||
return htmlspecialchars($str, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
// 生成唯一文件名
|
||||
function uniqueFilename($original) {
|
||||
// 生成带「内容指纹」的文件名:<md5前12位>_<6位随机>[_角色].ext
|
||||
//
|
||||
// 设计:
|
||||
// - 指纹段取图片内容的 md5 前 12 位,便于识别/比对同一张图(同名同图);
|
||||
// - 再拼 6 位随机后缀,保证文件名唯一——避免同一张图被多条记录引用时,
|
||||
// 删掉其一就把共用的文件一起删掉(删除逻辑不做引用计数);
|
||||
// - 角色段沿用原语义(poster / ticket / photo / avatar),便于人工排查。
|
||||
function fingerprintedFilename($binary, $ext, $role = '') {
|
||||
$ext = strtolower((string)$ext);
|
||||
if (!in_array($ext, allowedImageExts())) {
|
||||
$ext = 'jpg';
|
||||
}
|
||||
$fingerprint = substr(md5((string)$binary), 0, 12);
|
||||
$rand = bin2hex(random_bytes(3)); // 6 个十六进制字符
|
||||
$role = trim((string)$role);
|
||||
return $fingerprint . '_' . $rand . ($role !== '' ? '_' . $role : '') . '.' . $ext;
|
||||
}
|
||||
|
||||
// 文件名是否已经是「该内容的」指纹名(前 12 位 == 内容 md5 前 12 位)。
|
||||
// 迁移时用它跳过已改过名的文件,保证重复迁移幂等(不会每次都换新随机后缀)。
|
||||
function isFingerprintedName($filename, $binary) {
|
||||
$base = basename((string)$filename);
|
||||
if (strlen($base) < 12) return false;
|
||||
return substr(md5((string)$binary), 0, 12) === substr($base, 0, 12);
|
||||
}
|
||||
|
||||
// 生成唯一文件名。传入 $binary 时按内容指纹命名;否则退化为时间戳 + uniqid。
|
||||
// (保留 $binary 可选,是为了兼容"拿不到内容"的调用场景。)
|
||||
function uniqueFilename($original, $binary = null) {
|
||||
$ext = strtolower(pathinfo($original, PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, allowedImageExts())) {
|
||||
$ext = 'jpg';
|
||||
}
|
||||
if ($binary !== null && $binary !== '') {
|
||||
return fingerprintedFilename($binary, $ext);
|
||||
}
|
||||
return date('YmdHis') . '_' . uniqid() . '.' . $ext;
|
||||
}
|
||||
|
||||
@@ -29,18 +61,24 @@ function normalizeHexColor($color, $fallback = '#888888') {
|
||||
return preg_match('/^#[a-fA-F0-9]{6}$/', $color) ? $color : $fallback;
|
||||
}
|
||||
|
||||
// 删除项目内已存在的上传文件(文件不存在时静默跳过)
|
||||
function deleteStoredFile($relative_path) {
|
||||
if (!empty($relative_path)) {
|
||||
$full = ROOT_DIR . '/' . $relative_path;
|
||||
if (file_exists($full)) {
|
||||
return unlink($full);
|
||||
}
|
||||
// 把绝对目录转换成相对 ROOT_DIR 的键前缀(如 /x/uploads/posters/ → uploads/posters/)
|
||||
function relPathOfDir($absDir) {
|
||||
$rel = str_replace('\\', '/', (string)$absDir);
|
||||
$root = rtrim(str_replace('\\', '/', ROOT_DIR), '/') . '/';
|
||||
if (strpos($rel, $root) === 0) {
|
||||
$rel = substr($rel, strlen($root));
|
||||
}
|
||||
return false;
|
||||
return rtrim($rel, '/') . '/';
|
||||
}
|
||||
|
||||
// 处理单个图片上传字段,成功返回相对路径,失败返回 null
|
||||
// 删除已保存的文件(本地与云端按当前存储方式处理,不存在时静默跳过)
|
||||
function deleteStoredFile($relative_path) {
|
||||
if (empty($relative_path)) return false;
|
||||
return storageDelete($relative_path);
|
||||
}
|
||||
|
||||
// 处理单个图片上传字段,成功返回相对键(如 uploads/posters/x.jpg),失败返回 null
|
||||
// 注意:$target_dir 为兼容保留,实际落盘位置由存储层根据键推导
|
||||
function uploadImageFile($field, $target_dir, $relative_prefix) {
|
||||
if (!isset($_FILES[$field]) || $_FILES[$field]['error'] !== UPLOAD_ERR_OK) {
|
||||
return null;
|
||||
@@ -49,11 +87,15 @@ function uploadImageFile($field, $target_dir, $relative_prefix) {
|
||||
if (!in_array($ext, allowedImageExts())) {
|
||||
return null;
|
||||
}
|
||||
$filename = uniqueFilename($_FILES[$field]['name']);
|
||||
if (!move_uploaded_file($_FILES[$field]['tmp_name'], $target_dir . $filename)) {
|
||||
$tmpPath = $_FILES[$field]['tmp_name'];
|
||||
$binary = @file_get_contents($tmpPath);
|
||||
$filename = uniqueFilename($_FILES[$field]['name'], $binary === false ? null : $binary);
|
||||
$key = $relative_prefix . $filename;
|
||||
$mime = storageMimeOfFile($tmpPath);
|
||||
if (!storagePutFile($tmpPath, $key, $mime)) {
|
||||
return null;
|
||||
}
|
||||
return $relative_prefix . $filename;
|
||||
return $key;
|
||||
}
|
||||
|
||||
// 处理多张图片上传字段(如 photos[] / new_photos[]),写入 movie_photos 表
|
||||
@@ -71,27 +113,31 @@ function uploadMultiplePhotos($field, $movie_id) {
|
||||
if (!in_array($ext, allowedImageExts())) {
|
||||
continue;
|
||||
}
|
||||
$filename = uniqueFilename($files['name'][$i]);
|
||||
if (!move_uploaded_file($files['tmp_name'][$i], PHOTO_DIR . $filename)) {
|
||||
$tmpPath = $files['tmp_name'][$i];
|
||||
$binary = @file_get_contents($tmpPath);
|
||||
$filename = uniqueFilename($files['name'][$i], $binary === false ? null : $binary);
|
||||
$key = 'uploads/photos/' . $filename;
|
||||
$mime = storageMimeOfFile($tmpPath);
|
||||
if (!storagePutFile($tmpPath, $key, $mime)) {
|
||||
continue;
|
||||
}
|
||||
$stmt = $db->prepare("INSERT INTO movie_photos (movie_id, photo_path) VALUES (?, ?)");
|
||||
$stmt->execute([$movie_id, 'uploads/photos/' . $filename]);
|
||||
$stmt->execute([$movie_id, $key]);
|
||||
}
|
||||
}
|
||||
|
||||
// 海报 URL
|
||||
// 海报 URL(不存在时回退占位图)
|
||||
function posterUrl($path) {
|
||||
if ($path && file_exists(ROOT_DIR . '/' . $path)) {
|
||||
return '/' . $path;
|
||||
if (!storageExists($path)) {
|
||||
return '/assets/placeholder-poster.jpg';
|
||||
}
|
||||
return '/assets/placeholder-poster.jpg';
|
||||
return storageUrl($path);
|
||||
}
|
||||
|
||||
// ===== 头像处理 =====
|
||||
function getAvatarUrl($avatar_path, $name = '') {
|
||||
if ($avatar_path && file_exists(ROOT_DIR . '/' . $avatar_path)) {
|
||||
return '/' . $avatar_path;
|
||||
if (storageExists($avatar_path)) {
|
||||
return storageUrl($avatar_path);
|
||||
}
|
||||
if (!empty($name)) {
|
||||
$letter = mb_substr($name, 0, 1, 'UTF-8');
|
||||
@@ -108,11 +154,9 @@ function saveBase64Image($base64_data, $save_dir, $prefix = 'avatar') {
|
||||
$base64_data = substr($base64_data, strpos($base64_data, 'base64,') + 7);
|
||||
}
|
||||
$binary = base64_decode($base64_data);
|
||||
if ($binary === false) return false;
|
||||
if ($binary === false || $binary === '') return false;
|
||||
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = finfo_buffer($finfo, $binary);
|
||||
finfo_close($finfo);
|
||||
$mime = storageMimeFromContents($binary);
|
||||
|
||||
$ext_map = [
|
||||
'image/jpeg' => 'jpg',
|
||||
@@ -123,9 +167,9 @@ function saveBase64Image($base64_data, $save_dir, $prefix = 'avatar') {
|
||||
if (!isset($ext_map[$mime])) return false;
|
||||
$ext = $ext_map[$mime];
|
||||
|
||||
$filename = date('YmdHis') . '_' . uniqid() . '_' . $prefix . '.' . $ext;
|
||||
$filepath = $save_dir . $filename;
|
||||
if (file_put_contents($filepath, $binary) === false) return false;
|
||||
$filename = fingerprintedFilename($binary, $ext, $prefix);
|
||||
$key = relPathOfDir($save_dir) . $filename;
|
||||
if (!storagePutContents($binary, $key, $mime)) return false;
|
||||
|
||||
return str_replace(ROOT_DIR . '/', '', $filepath);
|
||||
return $key;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
<?php
|
||||
// ============================================
|
||||
// 文件: lib/settings.php
|
||||
// 说明: 系统设置的读写(settings 键值表 + 进程内缓存)
|
||||
// 表结构在 lib/db.php 的 initDB() 中创建。
|
||||
// 敏感设置(云厂商密钥、临时凭据)在落库前自动加密,读取时透明解密。
|
||||
// ============================================
|
||||
|
||||
// ===== 敏感设置的加密存储 =====
|
||||
//
|
||||
// 设计取舍:
|
||||
// - 密钥存放在 db/.settings.key(该目录已被 .gitignore 排除,且被 nginx 拒绝访问),
|
||||
// 与数据库文件分离,因此"只泄露 movies.db"时无法解出凭据。
|
||||
// - 但若整个站点目录(含 db/)一起泄露,密钥会随之泄露——这是自托管场景的固有限制。
|
||||
// - 密钥丢失不会导致数据损失,只是需要在设置页重新填写云厂商密钥。
|
||||
|
||||
// 密文信封前缀(带算法标识,便于日后升级而不破坏旧数据)
|
||||
const SETTINGS_ENC_PREFIX = 'enc:v1:';
|
||||
|
||||
// 需要加密落盘的设置项
|
||||
function settingsSensitiveNames() {
|
||||
return [
|
||||
'storage_config', // 各云厂商的 AccessKey / SecretKey
|
||||
'cache_meta_aliyun', // ECS 临时凭据(含 Secret + SecurityToken)
|
||||
'cache_meta_tencent', // CVM 临时密钥
|
||||
];
|
||||
}
|
||||
|
||||
function settingsKeyFile() {
|
||||
return ROOT_DIR . '/db/.settings.key';
|
||||
}
|
||||
|
||||
// 解密失败标记(供设置页告警)
|
||||
function &settingsCryptoFlags() {
|
||||
static $flags = ['decrypt_failed' => false];
|
||||
return $flags;
|
||||
}
|
||||
|
||||
/**
|
||||
* 惰性取得 32 字节站点密钥,首次调用时自动生成。
|
||||
* @return string|false 密钥原始字节;false 表示加密不可用
|
||||
*/
|
||||
function settingsCryptoKey() {
|
||||
static $key = null;
|
||||
if ($key !== null) return $key;
|
||||
|
||||
if (!function_exists('openssl_encrypt')) return $key = false;
|
||||
|
||||
$file = settingsKeyFile();
|
||||
|
||||
if (is_file($file)) {
|
||||
$raw = @file_get_contents($file);
|
||||
if ($raw !== false) {
|
||||
$decoded = base64_decode(trim($raw), true);
|
||||
if ($decoded !== false && strlen($decoded) === 32) return $key = $decoded;
|
||||
}
|
||||
// 文件存在但内容损坏:不覆盖(覆盖会让仍可用的旧密钥彻底失效),仅报不可用
|
||||
return $key = false;
|
||||
}
|
||||
|
||||
$dir = dirname($file);
|
||||
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) return $key = false;
|
||||
|
||||
try {
|
||||
$fresh = random_bytes(32);
|
||||
} catch (Exception $e) {
|
||||
return $key = false;
|
||||
}
|
||||
if (@file_put_contents($file, base64_encode($fresh) . "\n") === false) return $key = false;
|
||||
@chmod($file, 0600);
|
||||
return $key = $fresh;
|
||||
}
|
||||
|
||||
// 加密能力是否可用(设置页展示用)
|
||||
function settingsEncryptionAvailable() {
|
||||
return settingsCryptoKey() !== false;
|
||||
}
|
||||
|
||||
// 当前实际使用的加密算法名(设置页展示用)
|
||||
function settingsCipherLabel() {
|
||||
if (!settingsEncryptionAvailable()) return '';
|
||||
return in_array('aes-256-gcm', openssl_get_cipher_methods(), true)
|
||||
? 'AES-256-GCM'
|
||||
: 'AES-256-CBC + HMAC-SHA256';
|
||||
}
|
||||
|
||||
// 是否发生过解密失败(密钥丢失/损坏)
|
||||
function settingsDecryptFailed() {
|
||||
$flags = &settingsCryptoFlags();
|
||||
return $flags['decrypt_failed'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密一个值。优先 AES-256-GCM(AEAD),老环境退化为 AES-256-CBC + HMAC-SHA256。
|
||||
* @return string|null null 表示加密不可用
|
||||
*/
|
||||
function settingsEncrypt($plain) {
|
||||
$plain = (string)$plain;
|
||||
if ($plain === '') return '';
|
||||
|
||||
$key = settingsCryptoKey();
|
||||
if ($key === false) return null;
|
||||
|
||||
$useGcm = in_array('aes-256-gcm', openssl_get_cipher_methods(), true);
|
||||
|
||||
if ($useGcm) {
|
||||
$iv = random_bytes(12);
|
||||
$tag = '';
|
||||
$ct = openssl_encrypt($plain, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16);
|
||||
if ($ct === false) return null;
|
||||
return SETTINGS_ENC_PREFIX . 'gcm:' . base64_encode($iv . $tag . $ct);
|
||||
}
|
||||
|
||||
$iv = random_bytes(16);
|
||||
$ct = openssl_encrypt($plain, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
|
||||
if ($ct === false) return null;
|
||||
$mac = hash_hmac('sha256', $iv . $ct, $key, true);
|
||||
return SETTINGS_ENC_PREFIX . 'cbc:' . base64_encode($iv . $mac . $ct);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密一个值。
|
||||
* 非密文(历史遗留的明文)原样返回,便于无损升级;
|
||||
* 密文但解不开时返回 null(调用方应视为"未配置")。
|
||||
*/
|
||||
function settingsDecrypt($stored) {
|
||||
$stored = (string)$stored;
|
||||
if (strpos($stored, SETTINGS_ENC_PREFIX) !== 0) return $stored;
|
||||
|
||||
$key = settingsCryptoKey();
|
||||
if ($key === false) {
|
||||
$flags = &settingsCryptoFlags();
|
||||
$flags['decrypt_failed'] = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
$body = substr($stored, strlen(SETTINGS_ENC_PREFIX));
|
||||
$sep = strpos($body, ':');
|
||||
if ($sep === false) {
|
||||
$flags = &settingsCryptoFlags();
|
||||
$flags['decrypt_failed'] = true;
|
||||
return null;
|
||||
}
|
||||
$algo = substr($body, 0, $sep);
|
||||
$raw = base64_decode(substr($body, $sep + 1), true);
|
||||
|
||||
if ($raw === false) {
|
||||
$flags = &settingsCryptoFlags();
|
||||
$flags['decrypt_failed'] = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
$fail = function () {
|
||||
$flags = &settingsCryptoFlags();
|
||||
$flags['decrypt_failed'] = true;
|
||||
return null;
|
||||
};
|
||||
|
||||
if ($algo === 'gcm') {
|
||||
if (strlen($raw) < 12 + 16) return $fail();
|
||||
$iv = substr($raw, 0, 12);
|
||||
$tag = substr($raw, 12, 16);
|
||||
$ct = substr($raw, 28);
|
||||
$pt = openssl_decrypt($ct, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
|
||||
return $pt === false ? $fail() : $pt;
|
||||
}
|
||||
|
||||
if ($algo === 'cbc') {
|
||||
if (strlen($raw) < 16 + 32 + 16) return $fail();
|
||||
$iv = substr($raw, 0, 16);
|
||||
$mac = substr($raw, 16, 32);
|
||||
$ct = substr($raw, 48);
|
||||
if (!hash_equals($mac, hash_hmac('sha256', $iv . $ct, $key, true))) return $fail();
|
||||
$pt = openssl_decrypt($ct, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
|
||||
return $pt === false ? $fail() : $pt;
|
||||
}
|
||||
|
||||
return $fail();
|
||||
}
|
||||
|
||||
// 是否为敏感设置项
|
||||
function settingsIsSensitive($name) {
|
||||
static $set = null;
|
||||
if ($set === null) $set = array_flip(settingsSensitiveNames());
|
||||
return isset($set[$name]);
|
||||
}
|
||||
|
||||
// 直接落库(加密后写入),绕过内存缓存——仅供内部升级使用
|
||||
function settingsRawPut($name, $value) {
|
||||
try {
|
||||
$enc = settingsEncrypt($value);
|
||||
if ($enc === null) return false; // 加密不可用时宁可不动,避免写入不可解密的数据
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare("INSERT OR REPLACE INTO settings (name, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)");
|
||||
$stmt->execute([$name, $enc]);
|
||||
return true;
|
||||
} catch (PDOException $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 读取全部设置到静态缓存(每次请求只查一次库)
|
||||
function &settingsCache() {
|
||||
static $cache = null;
|
||||
if ($cache === null) {
|
||||
$cache = [];
|
||||
try {
|
||||
$db = getDB();
|
||||
$rows = $db->query("SELECT name, value FROM settings");
|
||||
$upgrade = [];
|
||||
foreach ($rows as $row) {
|
||||
$name = $row['name'];
|
||||
$raw = (string)$row['value'];
|
||||
|
||||
if (!settingsIsSensitive($name)) {
|
||||
$cache[$name] = $raw;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (strpos($raw, SETTINGS_ENC_PREFIX) === 0) {
|
||||
$plain = settingsDecrypt($raw);
|
||||
// 解不开时按"未配置"处理:站点继续可用,只是需要重新填密钥
|
||||
$cache[$name] = $plain === null ? '' : $plain;
|
||||
} else {
|
||||
// 历史明文:本次读到后自动升级为密文
|
||||
$cache[$name] = $raw;
|
||||
$upgrade[$name] = $raw;
|
||||
}
|
||||
}
|
||||
foreach ($upgrade as $name => $plain) {
|
||||
settingsRawPut($name, $plain);
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
// 表尚未建立时退化为"全部使用默认值",不阻断页面
|
||||
$cache = [];
|
||||
}
|
||||
}
|
||||
return $cache;
|
||||
}
|
||||
|
||||
// 读取单个设置
|
||||
function getSetting($name, $default = null) {
|
||||
$cache = &settingsCache();
|
||||
return array_key_exists($name, $cache) ? $cache[$name] : $default;
|
||||
}
|
||||
|
||||
// 读取布尔型设置
|
||||
function getSettingBool($name, $default = false) {
|
||||
$v = getSetting($name, null);
|
||||
if ($v === null || $v === '') return $default;
|
||||
return $v === '1' || $v === 1 || $v === true;
|
||||
}
|
||||
|
||||
// 读取 JSON 型设置(解析失败时返回 $default)
|
||||
function getSettingJson($name, $default = []) {
|
||||
$raw = getSetting($name, null);
|
||||
if ($raw === null || $raw === '') return $default;
|
||||
$decoded = json_decode($raw, true);
|
||||
return is_array($decoded) ? $decoded : $default;
|
||||
}
|
||||
|
||||
// 写入单个设置(敏感项自动加密后落库,内存缓存里始终是明文)
|
||||
function setSetting($name, $value) {
|
||||
$value = (string)$value;
|
||||
$store = $value;
|
||||
|
||||
if (settingsIsSensitive($name)) {
|
||||
$enc = settingsEncrypt($value);
|
||||
// 加密不可用时退回明文,保证功能可用(设置页会显著告警)
|
||||
if ($enc !== null) $store = $enc;
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare("INSERT OR REPLACE INTO settings (name, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)");
|
||||
$stmt->execute([$name, $store]);
|
||||
$cache = &settingsCache();
|
||||
$cache[$name] = $value;
|
||||
}
|
||||
|
||||
// 写入 JSON 型设置
|
||||
function setSettingJson($name, array $value) {
|
||||
setSetting($name, json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
// 删除单个设置
|
||||
function deleteSetting($name) {
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare("DELETE FROM settings WHERE name = ?");
|
||||
$stmt->execute([$name]);
|
||||
$cache = &settingsCache();
|
||||
unset($cache[$name]);
|
||||
}
|
||||
|
||||
// ===== 存储相关设置的便捷读取 =====
|
||||
|
||||
// 当前生效的存储驱动标识:local / oss / cos / qiniu
|
||||
function currentStorageDriver() {
|
||||
$d = getSetting('storage_driver', 'local');
|
||||
return in_array($d, ['local', 'oss', 'cos', 'qiniu'], true) ? $d : 'local';
|
||||
}
|
||||
|
||||
// 全部驱动的配置(形如 ['oss' => [...], 'cos' => [...]])
|
||||
function allStorageConfigs() {
|
||||
return getSettingJson('storage_config', []);
|
||||
}
|
||||
|
||||
// 指定驱动的配置(合并默认值)
|
||||
function storageConfigFor($driver) {
|
||||
$defaults = [
|
||||
'oss' => [
|
||||
'auth_mode' => 'ak', // ak | ecs_role
|
||||
'access_key_id' => '',
|
||||
'access_key_secret' => '',
|
||||
'bucket' => '',
|
||||
'endpoint' => '', // 例:oss-cn-beijing.aliyuncs.com
|
||||
'prefix' => '', // 对象前缀(桶内目录),可留空
|
||||
'ram_role_name' => '', // 留空则自动发现
|
||||
'custom_domain' => '', // 可选,CDN / 自定义域名
|
||||
'internal_endpoint' => '1', // 服务端读取优先走内网 endpoint(同地域免流量费)
|
||||
'https' => '1',
|
||||
],
|
||||
'cos' => [
|
||||
'auth_mode' => 'ak', // ak | cvm_role
|
||||
'secret_id' => '',
|
||||
'secret_key' => '',
|
||||
'bucket' => '', // 含 APPID,例:mybucket-1250000000
|
||||
'region' => '', // 例:ap-beijing
|
||||
'prefix' => '', // 对象前缀(桶内目录),可留空
|
||||
'cam_role_name' => '', // 留空则自动发现
|
||||
'custom_domain' => '',
|
||||
'https' => '1',
|
||||
],
|
||||
'qiniu' => [
|
||||
'access_key' => '',
|
||||
'secret_key' => '',
|
||||
'bucket' => '',
|
||||
'region' => 'z0', // z0 华东 / z1 华北 / z2 华南 / na0 北美 / as0 亚太
|
||||
'prefix' => '', // 对象前缀(桶内目录),可留空
|
||||
'custom_domain' => '', // 七牛必须绑定域名才能访问
|
||||
'https' => '1',
|
||||
],
|
||||
];
|
||||
$cfg = allStorageConfigs();
|
||||
$saved = isset($cfg[$driver]) && is_array($cfg[$driver]) ? $cfg[$driver] : [];
|
||||
return array_merge($defaults[$driver] ?? [], $saved);
|
||||
}
|
||||
|
||||
// 保存指定驱动的配置
|
||||
function saveStorageConfig($driver, array $values) {
|
||||
$cfg = allStorageConfigs();
|
||||
$cfg[$driver] = $values;
|
||||
setSettingJson('storage_config', $cfg);
|
||||
}
|
||||
|
||||
// 所有驱动的显示名
|
||||
function storageDriverLabels() {
|
||||
return [
|
||||
'local' => '本地硬盘',
|
||||
'oss' => '阿里云 OSS',
|
||||
'cos' => '腾讯云 COS',
|
||||
'qiniu' => '七牛云 Kodo',
|
||||
];
|
||||
}
|
||||
+380
@@ -0,0 +1,380 @@
|
||||
<?php
|
||||
// ============================================
|
||||
// 文件: lib/storage.php
|
||||
// 说明: 对象存储门面层。对上暴露一组过程式函数,对下按设置选择驱动。
|
||||
//
|
||||
// 设计要点:
|
||||
// 1. 数据库里始终存"相对键"(如 uploads/posters/xxx.jpg),不存完整 URL,
|
||||
// 换存储或换域名时无需改动历史数据。
|
||||
// 2. 生成 URL 时"本地优先":本地存在同名文件就按本地路径输出。
|
||||
// 这样从本地切换到云端后,历史图片自动继续可用,无需先迁移。
|
||||
// 3. 云端上传失败自动回退本地保存,绝不因为云配置问题导致用户丢图。
|
||||
// ============================================
|
||||
|
||||
require_once __DIR__ . '/storage/http.php';
|
||||
require_once __DIR__ . '/storage/meta.php';
|
||||
require_once __DIR__ . '/storage/local.php';
|
||||
require_once __DIR__ . '/storage/oss.php';
|
||||
require_once __DIR__ . '/storage/cos.php';
|
||||
require_once __DIR__ . '/storage/qiniu.php';
|
||||
|
||||
// ---------- 驱动实例 ----------
|
||||
|
||||
// 按驱动标识 + 配置构造驱动对象
|
||||
function storageDriverInstance($driver, array $cfg) {
|
||||
switch ($driver) {
|
||||
case 'oss': return new StorageOss($cfg);
|
||||
case 'cos': return new StorageCos($cfg);
|
||||
case 'qiniu': return new StorageQiniu($cfg);
|
||||
default: return new StorageLocal();
|
||||
}
|
||||
}
|
||||
|
||||
// 当前生效的驱动实例(每请求缓存)
|
||||
function storage() {
|
||||
static $driver = null;
|
||||
static $currentId = null;
|
||||
$id = currentStorageDriver();
|
||||
// 配置不完整时退回本地驱动,否则会出现
|
||||
// "文件传到了云端、URL 却按本地算" 的错配,图片直接裂开。
|
||||
if ($id !== 'local' && !storageConfigComplete($id)) {
|
||||
$id = 'local';
|
||||
}
|
||||
if ($driver === null || $currentId !== $id) {
|
||||
$driver = storageDriverInstance($id, $id === 'local' ? [] : storageConfigFor($id));
|
||||
$currentId = $id;
|
||||
}
|
||||
return $driver;
|
||||
}
|
||||
|
||||
// 当前是否使用远端存储(配置不完整时按本地处理,避免产生无效 URL)
|
||||
function storageIsRemote() {
|
||||
$id = currentStorageDriver();
|
||||
if ($id === 'local') return false;
|
||||
return storageConfigComplete($id);
|
||||
}
|
||||
|
||||
// 判断某个驱动的配置是否已填齐
|
||||
function storageConfigComplete($driver) {
|
||||
if ($driver === 'local') return true;
|
||||
$c = storageConfigFor($driver);
|
||||
switch ($driver) {
|
||||
case 'oss':
|
||||
$auth = ($c['auth_mode'] === 'ecs_role')
|
||||
|| (trim($c['access_key_id']) !== '' && trim($c['access_key_secret']) !== '');
|
||||
return $auth && trim($c['bucket']) !== '' && trim($c['endpoint']) !== '';
|
||||
case 'cos':
|
||||
$auth = ($c['auth_mode'] === 'cvm_role')
|
||||
|| (trim($c['secret_id']) !== '' && trim($c['secret_key']) !== '');
|
||||
return $auth && trim($c['bucket']) !== '' && trim($c['region']) !== '';
|
||||
case 'qiniu':
|
||||
// 七牛必须绑定域名才能对外访问。若只填了密钥和空间名,
|
||||
// 上传会成功但生成不出可访问 URL(页面会指向本地 404),
|
||||
// 因此这里必须一并要求 custom_domain。
|
||||
return trim($c['access_key']) !== '' && trim($c['secret_key']) !== ''
|
||||
&& trim($c['bucket']) !== ''
|
||||
&& trim($c['custom_domain']) !== '';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------- 错误记录(供设置页展示)----------
|
||||
|
||||
function storageRecordError($message) {
|
||||
setSettingJson('storage_last_error', [
|
||||
'time' => date('Y-m-d H:i:s'),
|
||||
'message' => storageShortError($message),
|
||||
'driver' => currentStorageDriver(),
|
||||
]);
|
||||
}
|
||||
|
||||
function storageLastError() {
|
||||
return getSettingJson('storage_last_error', []);
|
||||
}
|
||||
|
||||
function storageClearError() {
|
||||
deleteSetting('storage_last_error');
|
||||
}
|
||||
|
||||
// ---------- 写入 ----------
|
||||
|
||||
/**
|
||||
* 上传一个本地文件到当前存储。云端失败时自动回退到本地保存。
|
||||
* @return bool 是否成功(含回退成功的场景)
|
||||
*/
|
||||
function storagePutFile($srcPath, $key, $mime = null) {
|
||||
$key = ltrim((string)$key, '/');
|
||||
if ($key === '' || !is_file($srcPath)) {
|
||||
storageRecordError('上传失败:源文件不存在或目标键为空');
|
||||
return false;
|
||||
}
|
||||
$mime = $mime ?: storageMimeOfFile($srcPath);
|
||||
$driver = storage();
|
||||
|
||||
if ($driver->id() === 'local') {
|
||||
$r = $driver->putFile($srcPath, $key, $mime);
|
||||
if (empty($r['ok'])) storageRecordError($r['error']);
|
||||
return !empty($r['ok']);
|
||||
}
|
||||
|
||||
$r = $driver->putFile($srcPath, $key, $mime);
|
||||
if (!empty($r['ok'])) return true;
|
||||
|
||||
// 云端失败 → 回退本地,避免丢图
|
||||
$fallback = new StorageLocal();
|
||||
$l = $fallback->putFile($srcPath, $key, $mime);
|
||||
storageRecordError('云端上传失败,已自动回退为本地保存。原因:' . $r['error']);
|
||||
return !empty($l['ok']);
|
||||
}
|
||||
|
||||
// 上传一段二进制内容(用于 base64 裁剪结果)
|
||||
function storagePutContents($binary, $key, $mime = null) {
|
||||
$key = ltrim((string)$key, '/');
|
||||
if ($key === '' || $binary === '' || $binary === null) {
|
||||
storageRecordError('上传失败:内容为空或目标键为空');
|
||||
return false;
|
||||
}
|
||||
$mime = $mime ?: 'application/octet-stream';
|
||||
$driver = storage();
|
||||
|
||||
if ($driver->id() === 'local') {
|
||||
$r = $driver->putContents($binary, $key, $mime);
|
||||
if (empty($r['ok'])) storageRecordError($r['error']);
|
||||
return !empty($r['ok']);
|
||||
}
|
||||
|
||||
$r = $driver->putContents($binary, $key, $mime);
|
||||
if (!empty($r['ok'])) return true;
|
||||
|
||||
$fallback = new StorageLocal();
|
||||
$l = $fallback->putContents($binary, $key, $mime);
|
||||
storageRecordError('云端上传失败,已自动回退为本地保存。原因:' . $r['error']);
|
||||
return !empty($l['ok']);
|
||||
}
|
||||
|
||||
// ---------- 删除 ----------
|
||||
|
||||
/**
|
||||
* 删除一个对象:本地有就删本地;当前是远端存储时同时尝试删除云端对象。
|
||||
*/
|
||||
function storageDelete($key) {
|
||||
if ($key === null || trim((string)$key) === '') return false;
|
||||
$key = ltrim((string)$key, '/');
|
||||
if (preg_match('#^[a-z][a-z0-9+.-]*://#i', $key)) return false; // 绝对 URL 无法定位对象
|
||||
|
||||
$ok = false;
|
||||
$local = new StorageLocal();
|
||||
if ($local->exists($key)) {
|
||||
$ok = (bool)$local->delete($key);
|
||||
}
|
||||
if (storageIsRemote()) {
|
||||
$r = storage()->delete($key);
|
||||
if (empty($r['ok'])) {
|
||||
storageRecordError($r['error']);
|
||||
} else {
|
||||
$ok = true;
|
||||
}
|
||||
}
|
||||
return $ok;
|
||||
}
|
||||
|
||||
// ---------- 读取 / URL ----------
|
||||
|
||||
// 是否为绝对地址(http/https/协议相对)
|
||||
function storageIsAbsoluteUrl($path) {
|
||||
return (bool)preg_match('#^(https?:)?//#i', (string)$path);
|
||||
}
|
||||
|
||||
// 本地是否已存在该文件
|
||||
function storageHasLocalFile($key) {
|
||||
$key = ltrim((string)$key, '/');
|
||||
return $key !== '' && is_file(ROOT_DIR . '/' . $key);
|
||||
}
|
||||
|
||||
// ---------- 图片访问方式 ----------
|
||||
// direct:图片 URL 直接指向存储(要求桶可被匿名读取,或由防盗链/签名兜底)
|
||||
// proxy :图片 URL 指向本站 media.php,由服务端凭实例角色/密钥读取后回吐
|
||||
// —— 桶可以设为「私有」,且因同源交付,Canvas 读像素(裁剪)也不再受限
|
||||
function storageAccessMode() {
|
||||
return ((string)getSetting('storage_access_mode', 'direct')) === 'proxy' ? 'proxy' : 'direct';
|
||||
}
|
||||
|
||||
// 指向本站代理的图片地址。
|
||||
// 逐段编码、保留斜杠(与 storageEncodeKey 同样的策略):URL 可读、nginx 日志好看。
|
||||
// 目录穿越由 media.php 侧的白名单负责,不依赖这里的编码。
|
||||
function storageProxyUrl($key) {
|
||||
$parts = array_map('rawurlencode', explode('/', ltrim((string)$key, '/')));
|
||||
return '/media.php?k=' . implode('/', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成可访问的 URL。规则:
|
||||
* 1. 已经是绝对地址 → 原样返回
|
||||
* 2. 本地存在同名文件 → 走本地路径(同源,无需绕代理)
|
||||
* 3. 代理模式 → 指向本站 media.php
|
||||
* 4. 否则交给当前驱动生成
|
||||
*/
|
||||
function storageUrl($key) {
|
||||
if ($key === null || trim((string)$key) === '') return null;
|
||||
$key = (string)$key;
|
||||
if (storageIsAbsoluteUrl($key)) return $key;
|
||||
$trimmed = ltrim($key, '/');
|
||||
if (storageHasLocalFile($trimmed)) return '/' . $trimmed;
|
||||
if (storageAccessMode() === 'proxy') return storageProxyUrl($trimmed);
|
||||
return storage()->url($trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务端读取对象内容(供 media.php 回吐)。
|
||||
* 本地优先:本地有文件就直接读盘,省一次外网往返;否则交给驱动。
|
||||
* $conditional 为透传给上游的条件头(如 ['If-None-Match: "abc"'])。
|
||||
*/
|
||||
function storageRead($key, array $conditional = []) {
|
||||
$key = ltrim((string)$key, '/');
|
||||
if ($key === '') {
|
||||
return ['ok' => false, 'status' => 400, 'headers' => [], 'body' => '', 'error' => '缺少对象键'];
|
||||
}
|
||||
|
||||
if (storageHasLocalFile($key)) {
|
||||
$abs = ROOT_DIR . '/' . $key;
|
||||
$body = @file_get_contents($abs);
|
||||
if ($body !== false) {
|
||||
return [
|
||||
'ok' => true,
|
||||
'status' => 200,
|
||||
'headers' => [
|
||||
'content-type' => storageMimeOfFile($abs),
|
||||
'content-length' => (string)strlen($body),
|
||||
'etag' => '"' . md5($body) . '"',
|
||||
'last-modified' => gmdate('D, d M Y H:i:s \G\M\T', (int)@filemtime($abs)),
|
||||
],
|
||||
'body' => $body,
|
||||
'error' => '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$drv = storage();
|
||||
if (!method_exists($drv, 'get')) {
|
||||
return [
|
||||
'ok' => false, 'status' => 501, 'headers' => [], 'body' => '',
|
||||
'error' => '当前存储驱动(' . $drv->label() . ')暂不支持服务端读取',
|
||||
];
|
||||
}
|
||||
return $drv->get($key, $conditional);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断图片是否"应当被视为存在"。
|
||||
* 本地:真实检查文件;云端:键非空即视为存在(远端不做逐次 HEAD 探测)。
|
||||
*/
|
||||
function storageExists($key) {
|
||||
if ($key === null || trim((string)$key) === '') return false;
|
||||
if (storageIsAbsoluteUrl($key)) return true;
|
||||
if (storageHasLocalFile($key)) return true;
|
||||
return storageIsRemote();
|
||||
}
|
||||
|
||||
// ---------- MIME ----------
|
||||
|
||||
function storageMimeOfFile($path) {
|
||||
if (function_exists('finfo_open') && is_file($path)) {
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
if ($finfo) {
|
||||
$mime = finfo_file($finfo, $path);
|
||||
finfo_close($finfo);
|
||||
if ($mime) return $mime;
|
||||
}
|
||||
}
|
||||
return storageMimeFromName($path);
|
||||
}
|
||||
|
||||
function storageMimeFromContents($binary) {
|
||||
if (function_exists('finfo_open')) {
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
if ($finfo) {
|
||||
$mime = finfo_buffer($finfo, $binary);
|
||||
finfo_close($finfo);
|
||||
if ($mime) return $mime;
|
||||
}
|
||||
}
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
function storageMimeFromName($name) {
|
||||
$map = [
|
||||
'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg',
|
||||
'png' => 'image/png', 'gif' => 'image/gif', 'webp' => 'image/webp',
|
||||
'txt' => 'text/plain',
|
||||
];
|
||||
$ext = strtolower(pathinfo((string)$name, PATHINFO_EXTENSION));
|
||||
return $map[$ext] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
// ---------- 诊断(设置页用)----------
|
||||
|
||||
// 探测当前服务器是否位于某朵云上(仅做可达性判断,不拉取凭据)
|
||||
function storageDetectCloudHost() {
|
||||
$hosts = ['aliyun' => 'ECS(阿里云)', 'tencent' => 'CVM(腾讯云)'];
|
||||
$found = [];
|
||||
foreach ($hosts as $key => $label) {
|
||||
$cred = $key === 'aliyun'
|
||||
? aliyunEcsRamCredentials('', true)
|
||||
: tencentCvmCamCredentials('', true);
|
||||
if (!empty($cred['ok'])) {
|
||||
$found[$key] = ['label' => $label, 'role' => $cred['role'] ?? '', 'expires' => $cred['expires'] ?? 0];
|
||||
}
|
||||
}
|
||||
return $found;
|
||||
}
|
||||
|
||||
// ---------- 迁移改名:改写数据库里的图片引用 ----------
|
||||
|
||||
/**
|
||||
* 数据库里所有存放「图片相对键」的字段(表, 字段)。
|
||||
*
|
||||
* 迁移时若把文件按指纹改名,必须一并改写这些引用,
|
||||
* 否则库里仍指向旧名,图片会裂开——特别是勾选了"迁移后删除本地文件"时无法回退。
|
||||
*/
|
||||
function storageImageRefFields() {
|
||||
return [
|
||||
['movies', 'poster_path'],
|
||||
['movies', 'ticket_path'],
|
||||
['movies', 'photo_path'], // 遗留字段,仍可能有历史数据
|
||||
['movie_photos', 'photo_path'],
|
||||
['persons', 'avatar'],
|
||||
['admins', 'avatar'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 把所有等于 $oldKey 的图片引用改写为 $newKey。
|
||||
* 同一条旧键被多行引用时会一次全部改掉。
|
||||
*
|
||||
* @return int 受影响行数合计
|
||||
*/
|
||||
function storageRewriteImageRef($oldKey, $newKey) {
|
||||
$oldKey = (string)$oldKey;
|
||||
$newKey = (string)$newKey;
|
||||
if ($oldKey === '' || $newKey === '' || $oldKey === $newKey) return 0;
|
||||
|
||||
try {
|
||||
$db = getDB();
|
||||
} catch (Throwable $e) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$affected = 0;
|
||||
foreach (storageImageRefFields() as $pair) {
|
||||
list($table, $column) = $pair;
|
||||
// 表名/字段名来自上面的硬编码清单,非用户输入
|
||||
try {
|
||||
$stmt = $db->prepare("UPDATE {$table} SET {$column} = ? WHERE {$column} = ?");
|
||||
$stmt->execute([$newKey, $oldKey]);
|
||||
$affected += $stmt->rowCount();
|
||||
} catch (PDOException $e) {
|
||||
// 表或字段不存在(旧库)时跳过,不阻断迁移
|
||||
}
|
||||
}
|
||||
return $affected;
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
<?php
|
||||
// ============================================
|
||||
// 文件: lib/storage/cos.php
|
||||
// 说明: 腾讯云 COS 驱动
|
||||
// 签名算法:COS 签名 V5(q-sign-algorithm=sha1)
|
||||
// 鉴权方式:SecretId/SecretKey 或 CVM CAM 角色(实例元数据,免密钥)
|
||||
// ============================================
|
||||
|
||||
class StorageCos {
|
||||
|
||||
private $cfg;
|
||||
private $cred = null;
|
||||
|
||||
public function __construct(array $cfg) {
|
||||
$this->cfg = $cfg;
|
||||
}
|
||||
|
||||
public function id() { return 'cos'; }
|
||||
|
||||
public function label() { return '腾讯云 COS'; }
|
||||
|
||||
// ---------- 凭据 ----------
|
||||
|
||||
private function credentials() {
|
||||
if ($this->cred !== null) return $this->cred;
|
||||
|
||||
if (($this->cfg['auth_mode'] ?? 'ak') === 'cvm_role') {
|
||||
$c = tencentCvmCamCredentials($this->cfg['cam_role_name'] ?? '');
|
||||
if (empty($c['ok'])) return $this->cred = ['ok' => false, 'error' => $c['error']];
|
||||
return $this->cred = $c;
|
||||
}
|
||||
|
||||
if (trim((string)($this->cfg['secret_id'] ?? '')) === ''
|
||||
|| trim((string)($this->cfg['secret_key'] ?? '')) === '') {
|
||||
return $this->cred = ['ok' => false, 'error' => '未填写 SecretId / SecretKey'];
|
||||
}
|
||||
return $this->cred = [
|
||||
'ok' => true,
|
||||
'id' => trim($this->cfg['secret_id']),
|
||||
'secret' => trim($this->cfg['secret_key']),
|
||||
'token' => '',
|
||||
'error' => '',
|
||||
];
|
||||
}
|
||||
|
||||
// ---------- 寻址 ----------
|
||||
|
||||
private function useHttps() {
|
||||
return ($this->cfg['https'] ?? '1') === '1';
|
||||
}
|
||||
|
||||
private function scheme() {
|
||||
return $this->useHttps() ? 'https' : 'http';
|
||||
}
|
||||
|
||||
private function host() {
|
||||
return trim((string)$this->cfg['bucket']) . '.cos.' . trim((string)$this->cfg['region']) . '.myqcloud.com';
|
||||
}
|
||||
|
||||
// 给对象键加上「对象前缀」(桶内目录)。库中始终存不带前缀的相对键,
|
||||
// 前缀只在驱动层拼接,因此改前缀不影响数据库里的历史记录。
|
||||
private function objectKey($key) {
|
||||
$key = ltrim((string)$key, '/');
|
||||
$prefix = trim((string)($this->cfg['prefix'] ?? ''), '/');
|
||||
return $prefix === '' ? $key : $prefix . '/' . $key;
|
||||
}
|
||||
|
||||
private function pathOf($key) {
|
||||
return '/' . storageEncodeKey(ltrim((string)$key, '/'));
|
||||
}
|
||||
|
||||
private function objectUrl($key) {
|
||||
return $this->scheme() . '://' . $this->host() . $this->pathOf($key);
|
||||
}
|
||||
|
||||
public function url($key) {
|
||||
$key = $this->objectKey($key);
|
||||
$custom = trim((string)($this->cfg['custom_domain'] ?? ''));
|
||||
if ($custom !== '') {
|
||||
return storageHostOf($custom, $this->useHttps()) . '/' . storageEncodeKey($key);
|
||||
}
|
||||
return $this->scheme() . '://' . $this->host() . '/' . storageEncodeKey($key);
|
||||
}
|
||||
|
||||
// ---------- 签名 V5 ----------
|
||||
|
||||
private function authorization($method, $path, $cred) {
|
||||
$signTime = (time() - 60) . ';' . (time() + 3600);
|
||||
|
||||
// 参与签名的请求头(键必须小写并按字典序排列)
|
||||
$headers = ['host' => $this->host()];
|
||||
if (($cred['token'] ?? '') !== '') {
|
||||
$headers['x-cos-security-token'] = $cred['token'];
|
||||
}
|
||||
ksort($headers);
|
||||
|
||||
$headerPairs = [];
|
||||
$headerNames = [];
|
||||
foreach ($headers as $k => $v) {
|
||||
$headerNames[] = $k;
|
||||
$headerPairs[] = $k . '=' . rawurlencode($v);
|
||||
}
|
||||
$httpHeaders = implode('&', $headerPairs);
|
||||
$headerList = implode(';', $headerNames);
|
||||
|
||||
$httpString = strtolower($method) . "\n"
|
||||
. $path . "\n"
|
||||
. "\n" // 无 URL 参数
|
||||
. $httpHeaders . "\n";
|
||||
|
||||
$stringToSign = "sha1\n" . $signTime . "\n" . sha1($httpString) . "\n";
|
||||
$signKey = hash_hmac('sha1', $signTime, $cred['secret']);
|
||||
$signature = hash_hmac('sha1', $stringToSign, $signKey);
|
||||
|
||||
return [
|
||||
'q-sign-algorithm=sha1',
|
||||
'q-ak=' . $cred['id'],
|
||||
'q-sign-time=' . $signTime,
|
||||
'q-key-time=' . $signTime,
|
||||
'q-header-list=' . $headerList,
|
||||
'q-url-param-list=',
|
||||
'q-signature=' . $signature,
|
||||
];
|
||||
}
|
||||
|
||||
private function buildHeaders($method, $key, $contentType, $cred) {
|
||||
$path = $this->pathOf($key);
|
||||
$parts = $this->authorization($method, $path, $cred);
|
||||
$headers = ['Authorization: ' . implode('&', $parts)];
|
||||
if ($contentType !== '') {
|
||||
$headers[] = 'Content-Type: ' . $contentType;
|
||||
}
|
||||
if (($cred['token'] ?? '') !== '') {
|
||||
$headers[] = 'x-cos-security-token: ' . $cred['token'];
|
||||
}
|
||||
return $headers;
|
||||
}
|
||||
|
||||
private function validateConfig() {
|
||||
foreach (['bucket' => '存储桶名称(含 APPID)', 'region' => '地域 Region'] as $f => $label) {
|
||||
if (trim((string)($this->cfg[$f] ?? '')) === '') {
|
||||
return "未填写{$label}";
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// ---------- 操作 ----------
|
||||
|
||||
public function putFile($srcPath, $key, $mime = null) {
|
||||
return $this->putContents(file_get_contents($srcPath), $key, $mime ?: 'application/octet-stream');
|
||||
}
|
||||
|
||||
public function putContents($binary, $key, $mime = null) {
|
||||
if ($err = $this->validateConfig()) return ['ok' => false, 'error' => $err];
|
||||
$cred = $this->credentials();
|
||||
if (empty($cred['ok'])) return ['ok' => false, 'error' => $cred['error']];
|
||||
|
||||
$key = $this->objectKey($key);
|
||||
$mime = $mime ?: 'application/octet-stream';
|
||||
$res = storageHttp('PUT', $this->objectUrl($key),
|
||||
$this->buildHeaders('PUT', $key, $mime, $cred), $binary, 60);
|
||||
|
||||
if (!$res['ok']) {
|
||||
return ['ok' => false, 'error' => '上传到 COS 失败:' . $this->describe($res)];
|
||||
}
|
||||
return ['ok' => true, 'error' => ''];
|
||||
}
|
||||
|
||||
public function delete($key) {
|
||||
$cred = $this->credentials();
|
||||
if (empty($cred['ok'])) return ['ok' => false, 'error' => $cred['error']];
|
||||
|
||||
$key = $this->objectKey($key);
|
||||
$res = storageHttp('DELETE', $this->objectUrl($key),
|
||||
$this->buildHeaders('DELETE', $key, '', $cred), null, 30);
|
||||
|
||||
if (!$res['ok'] && $res['status'] !== 404) {
|
||||
return ['ok' => false, 'error' => '删除 COS 对象失败:' . $this->describe($res)];
|
||||
}
|
||||
return ['ok' => true, 'error' => ''];
|
||||
}
|
||||
|
||||
public function exists($key) {
|
||||
return true;
|
||||
}
|
||||
|
||||
public function test() {
|
||||
if ($err = $this->validateConfig()) return ['ok' => false, 'error' => $err];
|
||||
$cred = $this->credentials();
|
||||
if (empty($cred['ok'])) return ['ok' => false, 'error' => $cred['error']];
|
||||
|
||||
// 探测对象也走前缀,顺便验证前缀目录的写权限
|
||||
$raw = '.movielog-probe/' . date('YmdHis') . '-' . bin2hex(random_bytes(4)) . '.txt';
|
||||
$key = $this->objectKey($raw);
|
||||
$body = 'movielog storage probe ' . date('c');
|
||||
|
||||
$res = storageHttp('PUT', $this->objectUrl($key),
|
||||
$this->buildHeaders('PUT', $key, 'text/plain', $cred), $body, 20);
|
||||
if (!$res['ok']) {
|
||||
return ['ok' => false, 'error' => '写入测试失败:' . $this->describe($res)];
|
||||
}
|
||||
|
||||
$del = $this->delete($raw); // delete() 内部会自行加前缀,故传原始键
|
||||
$mode = ($this->cfg['auth_mode'] ?? 'ak') === 'cvm_role'
|
||||
? 'CVM CAM 角色(免密钥)' : 'SecretId/SecretKey';
|
||||
$prefix = trim((string)($this->cfg['prefix'] ?? ''), '/');
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'error' => '',
|
||||
'message' => "连接成功({$mode})。桶 {$this->cfg['bucket']} 可读写,"
|
||||
. ($prefix !== '' ? "前缀 {$prefix}/," : '')
|
||||
. '测试对象已清理。' . ($del['ok'] ? '' : '(注意:测试对象删除失败,请检查删除权限)'),
|
||||
];
|
||||
}
|
||||
|
||||
private function describe($res) {
|
||||
$body = trim((string)$res['body']);
|
||||
$json = json_decode($body, true);
|
||||
if (is_array($json)) {
|
||||
$code = $json['Code'] ?? '';
|
||||
$msg = $json['Message'] ?? '';
|
||||
if ($code || $msg) return trim("{$code} {$msg}");
|
||||
}
|
||||
return $res['error'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
// ============================================
|
||||
// 文件: lib/storage/http.php
|
||||
// 说明: 对象存储驱动共用的 HTTP 客户端与签名工具函数
|
||||
// ============================================
|
||||
|
||||
// 该路径是否允许当前请求访问。
|
||||
//
|
||||
// 宝塔等环境会给站点加 .user.ini:open_basedir=/站点目录/:/tmp/。
|
||||
// 此时对 /etc/ssl/... 这类"站外路径"调用 is_file() 会抛
|
||||
// Warning: is_file(): open_basedir restriction in effect
|
||||
// 而站点开着 display_errors,警告就会被打进响应体——对返回 JSON 的接口
|
||||
// 是致命的(前端 JSON.parse 直接失败)。所以探测前先自己判断一遍。
|
||||
function storagePathAllowed($path) {
|
||||
$path = str_replace('\\', '/', trim((string)$path));
|
||||
if ($path === '') return false;
|
||||
|
||||
$open = (string)ini_get('open_basedir');
|
||||
if ($open !== '') {
|
||||
$allowed = false;
|
||||
foreach (explode(PATH_SEPARATOR, $open) as $dir) {
|
||||
$dir = rtrim(str_replace('\\', '/', trim($dir)), '/');
|
||||
if ($dir === '') continue;
|
||||
if ($path === $dir || strpos($path, $dir . '/') === 0) {
|
||||
$allowed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$allowed) return false;
|
||||
}
|
||||
|
||||
// 仍在允许目录内,也压掉可能的名权限/符号链接警告
|
||||
return @is_file($path) && @is_readable($path);
|
||||
}
|
||||
|
||||
// 定位系统 CA 证书,供 SSL 校验使用(找不到时返回 null,交由 curl 自行处理)
|
||||
function storageCaBundle() {
|
||||
static $ca = false;
|
||||
if ($ca !== false) return $ca;
|
||||
$candidates = [
|
||||
ini_get('curl.cainfo'),
|
||||
ini_get('openssl.cafile'),
|
||||
'/etc/ssl/certs/ca-certificates.crt',
|
||||
'/etc/pki/tls/certs/ca-bundle.crt',
|
||||
'/etc/ssl/cert.pem',
|
||||
];
|
||||
foreach ($candidates as $path) {
|
||||
if (storagePathAllowed($path)) {
|
||||
$ca = (string)$path;
|
||||
return $ca;
|
||||
}
|
||||
}
|
||||
$ca = null;
|
||||
return $ca;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起 HTTP 请求。
|
||||
*
|
||||
* @param string $method GET / PUT / POST / DELETE / HEAD
|
||||
* @param string $url 完整 URL
|
||||
* @param array $headers 形如 ['Content-Type: image/jpeg', ...]
|
||||
* @param string|null $body 请求体
|
||||
* @param int $timeout 总超时(秒)
|
||||
* @param bool $insecure 是否跳过 SSL 证书校验(默认否)
|
||||
* @param int|null $connectTimeout 连接阶段超时(秒)。默认由总超时推算;
|
||||
* 想"快速失败"的地方(比如探测内网 endpoint)显式给一个很小的值。
|
||||
*
|
||||
* @return array ['ok'=>bool,'status'=>int,'headers'=>array,'body'=>string,'error'=>string]
|
||||
*/
|
||||
function storageHttp($method, $url, array $headers = [], $body = null, $timeout = 30, $insecure = false, $connectTimeout = null) {
|
||||
$respHeaders = [];
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CUSTOMREQUEST => strtoupper($method),
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_CONNECTTIMEOUT => $connectTimeout === null
|
||||
? min(10, max(3, $timeout))
|
||||
: max(1, (int)$connectTimeout),
|
||||
CURLOPT_TIMEOUT => $timeout,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_SSL_VERIFYPEER => !$insecure,
|
||||
CURLOPT_SSL_VERIFYHOST => $insecure ? 0 : 2,
|
||||
CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$respHeaders) {
|
||||
$pos = strpos($line, ':');
|
||||
if ($pos !== false) {
|
||||
$name = strtolower(trim(substr($line, 0, $pos)));
|
||||
$respHeaders[$name] = trim(substr($line, $pos + 1));
|
||||
}
|
||||
return strlen($line);
|
||||
},
|
||||
]);
|
||||
$ca = storageCaBundle();
|
||||
if ($ca && !$insecure) {
|
||||
curl_setopt($ch, CURLOPT_CAINFO, $ca);
|
||||
}
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
|
||||
$out = curl_exec($ch);
|
||||
$err = curl_error($ch);
|
||||
$errno = curl_errno($ch);
|
||||
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($out === false) {
|
||||
return [
|
||||
'ok' => false, 'status' => 0, 'headers' => [],
|
||||
'body' => '',
|
||||
'error' => $errno === CURLE_OPERATION_TIMEDOUT
|
||||
? "请求超时({$timeout}s)"
|
||||
: "网络错误({$errno}): {$err}",
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => $status >= 200 && $status < 300,
|
||||
'status' => $status,
|
||||
'headers' => $respHeaders,
|
||||
'body' => $out,
|
||||
'error' => $status >= 200 && $status < 300 ? '' : "HTTP {$status}",
|
||||
];
|
||||
}
|
||||
|
||||
// 生成对象的访问 URL(路径部分逐段编码,保留斜杠)
|
||||
function storageEncodeKey($key) {
|
||||
$key = ltrim((string)$key, '/');
|
||||
$segments = explode('/', $key);
|
||||
return implode('/', array_map('rawurlencode', $segments));
|
||||
}
|
||||
|
||||
// 解析 host 形式:bucket.endpoint 或自定义域名
|
||||
function storageHostOf($domain, $useHttps = true) {
|
||||
$domain = preg_replace('#^https?://#i', '', trim((string)$domain));
|
||||
return ($useHttps ? 'https://' : 'http://') . rtrim($domain, '/');
|
||||
}
|
||||
|
||||
// 统一把失败信息压缩成一行,便于写入设置页展示
|
||||
function storageShortError($text, $max = 200) {
|
||||
$text = preg_replace('/\s+/', ' ', trim((string)$text));
|
||||
return mb_strlen($text, 'UTF-8') > $max ? mb_substr($text, 0, $max, 'UTF-8') . '…' : $text;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
// ============================================
|
||||
// 文件: lib/storage/local.php
|
||||
// 说明: 本地硬盘存储驱动(默认,行为与改造前一致)
|
||||
// ============================================
|
||||
|
||||
class StorageLocal {
|
||||
|
||||
public function id() { return 'local'; }
|
||||
|
||||
public function label() { return '本地硬盘'; }
|
||||
|
||||
// 把文件落到站点目录下(先尝试 rename,跨文件系统时退化为 copy+unlink)
|
||||
public function putFile($srcPath, $key, $mime = null) {
|
||||
$target = $this->absPath($key);
|
||||
$dir = dirname($target);
|
||||
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
|
||||
return ['ok' => false, 'error' => "无法创建目录:{$dir}"];
|
||||
}
|
||||
if (@rename($srcPath, $target)) {
|
||||
@chmod($target, 0644);
|
||||
return ['ok' => true, 'error' => ''];
|
||||
}
|
||||
if (@copy($srcPath, $target)) {
|
||||
@unlink($srcPath);
|
||||
@chmod($target, 0644);
|
||||
return ['ok' => true, 'error' => ''];
|
||||
}
|
||||
return ['ok' => false, 'error' => "写入文件失败:{$key}(请检查目录权限)"];
|
||||
}
|
||||
|
||||
public function putContents($binary, $key, $mime = null) {
|
||||
$target = $this->absPath($key);
|
||||
$dir = dirname($target);
|
||||
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
|
||||
return ['ok' => false, 'error' => "无法创建目录:{$dir}"];
|
||||
}
|
||||
if (@file_put_contents($target, $binary) === false) {
|
||||
return ['ok' => false, 'error' => "写入文件失败:{$key}(请检查目录权限)"];
|
||||
}
|
||||
@chmod($target, 0644);
|
||||
return ['ok' => true, 'error' => ''];
|
||||
}
|
||||
|
||||
public function delete($key) {
|
||||
$target = $this->absPath($key);
|
||||
if (is_file($target)) {
|
||||
return @unlink($target);
|
||||
}
|
||||
return true; // 不存在视为成功
|
||||
}
|
||||
|
||||
public function url($key) {
|
||||
return '/' . ltrim((string)$key, '/');
|
||||
}
|
||||
|
||||
public function exists($key) {
|
||||
return is_file($this->absPath($key));
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务端读取对象。本地驱动直接读盘。
|
||||
* 返回结构与 storageHttp() 同构(etag / last-modified 供 media.php 做 304 判断)。
|
||||
*/
|
||||
public function get($key, array $extra = []) {
|
||||
$abs = $this->absPath($key);
|
||||
if (!is_file($abs) || !is_readable($abs)) {
|
||||
return ['ok' => false, 'status' => 404, 'headers' => [], 'body' => '', 'error' => '对象不存在'];
|
||||
}
|
||||
$body = @file_get_contents($abs);
|
||||
if ($body === false) {
|
||||
return ['ok' => false, 'status' => 500, 'headers' => [], 'body' => '', 'error' => '读取文件失败'];
|
||||
}
|
||||
return [
|
||||
'ok' => true,
|
||||
'status' => 200,
|
||||
'headers' => [
|
||||
'content-type' => storageMimeOfFile($abs),
|
||||
'content-length' => (string)strlen($body),
|
||||
'etag' => '"' . md5($body) . '"',
|
||||
'last-modified' => gmdate('D, d M Y H:i:s \G\M\T', (int)@filemtime($abs)),
|
||||
],
|
||||
'body' => $body,
|
||||
'error' => '',
|
||||
];
|
||||
}
|
||||
|
||||
public function test() {
|
||||
// 验证上传目录可写
|
||||
$probe = UPLOAD_DIR . '.storage_test_' . uniqid();
|
||||
if (@file_put_contents($probe, 'ok') === false) {
|
||||
return ['ok' => false, 'error' => '上传目录不可写:' . UPLOAD_DIR];
|
||||
}
|
||||
@unlink($probe);
|
||||
$free = @disk_free_space(ROOT_DIR);
|
||||
$msg = '本地存储正常,上传目录可写。';
|
||||
if ($free !== false) {
|
||||
$msg .= ' 剩余空间 ' . formatBytes($free) . '。';
|
||||
}
|
||||
return ['ok' => true, 'error' => '', 'message' => $msg];
|
||||
}
|
||||
|
||||
private function absPath($key) {
|
||||
return ROOT_DIR . '/' . ltrim((string)$key, '/');
|
||||
}
|
||||
}
|
||||
|
||||
// 字节数格式化(设置页展示用)
|
||||
function formatBytes($bytes, $precision = 2) {
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
$bytes = max(0, (float)$bytes);
|
||||
$i = 0;
|
||||
while ($bytes >= 1024 && $i < count($units) - 1) {
|
||||
$bytes /= 1024;
|
||||
$i++;
|
||||
}
|
||||
return round($bytes, $i === 0 ? 0 : $precision) . ' ' . $units[$i];
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
// ============================================
|
||||
// 文件: lib/storage/meta.php
|
||||
// 说明: 云主机实例元数据鉴权(免密钥访问对象存储)
|
||||
// - 阿里云 ECS:RAM 角色 → STS 临时凭据
|
||||
// - 腾讯云 CVM:CAM 角色 → 临时密钥
|
||||
// 服务器不在对应云上时,元数据地址不可达,会返回明确错误。
|
||||
// ============================================
|
||||
|
||||
// 元数据地址不可达时的超时(秒)——必须短,避免拖慢页面
|
||||
const STORAGE_META_TIMEOUT = 3;
|
||||
|
||||
/**
|
||||
* 阿里云 ECS:获取 RAM 角色的 STS 临时凭据。
|
||||
*
|
||||
* @param string $roleName 指定的 RAM 角色名;留空则自动发现实例绑定的第一个角色
|
||||
* @param bool $force 忽略缓存强制刷新
|
||||
* @return array ['ok'=>bool,'id'=>string,'secret'=>string,'token'=>string,'expires'=>int,'error'=>string]
|
||||
*/
|
||||
function aliyunEcsRamCredentials($roleName = '', $force = false) {
|
||||
static $memo = [];
|
||||
$roleName = trim((string)$roleName);
|
||||
$memoKey = 'aliyun:' . $roleName;
|
||||
if (!$force && isset($memo[$memoKey])) return $memo[$memoKey];
|
||||
|
||||
// 跨请求缓存(临时凭据有效期通常 >= 1 小时)
|
||||
if (!$force) {
|
||||
$cached = getSettingJson('cache_meta_aliyun', []);
|
||||
if (!empty($cached['expires']) && $cached['expires'] > time() + 300
|
||||
&& ($cached['role'] ?? '') === $roleName) {
|
||||
return $memo[$memoKey] = $cached + ['ok' => true, 'error' => ''];
|
||||
}
|
||||
}
|
||||
|
||||
$base = 'http://100.100.100.200/latest/meta-data/ram/security-credentials/';
|
||||
|
||||
// IMDSv2:先取一次性 token(实例关闭 v1 时必须)
|
||||
$token = '';
|
||||
$t = storageHttp('PUT', 'http://100.100.100.200/latest/api/token',
|
||||
['X-aliyun-ecs-metadata-token-ttl-seconds: 21600'], null, STORAGE_META_TIMEOUT);
|
||||
if ($t['ok'] && trim($t['body']) !== '') {
|
||||
$token = trim($t['body']);
|
||||
}
|
||||
$authHeader = $token !== '' ? ["X-aliyun-ecs-metadata-token: {$token}"] : [];
|
||||
|
||||
// 角色名
|
||||
if ($roleName === '') {
|
||||
$r = storageHttp('GET', $base, $authHeader, null, STORAGE_META_TIMEOUT);
|
||||
if (!$r['ok']) {
|
||||
return $memo[$memoKey] = aliyunMetaFail($r, '无法访问 ECS 元数据服务');
|
||||
}
|
||||
$first = trim(strtok($r['body'], "\r\n"));
|
||||
if ($first === '') {
|
||||
return $memo[$memoKey] = aliyunMetaFail(null, '该实例未绑定 RAM 角色');
|
||||
}
|
||||
$roleName = $first;
|
||||
}
|
||||
|
||||
$r = storageHttp('GET', $base . rawurlencode($roleName), $authHeader, null, STORAGE_META_TIMEOUT);
|
||||
if (!$r['ok']) {
|
||||
return $memo[$memoKey] = aliyunMetaFail($r, "获取 RAM 角色 {$roleName} 的临时凭据失败");
|
||||
}
|
||||
$json = json_decode($r['body'], true);
|
||||
if (!is_array($json) || empty($json['AccessKeyId']) || empty($json['AccessKeySecret'])) {
|
||||
$returned = is_array($json) ? ($json['Code'] ?? '') : '';
|
||||
return $memo[$memoKey] = aliyunMetaFail(null, '元数据返回内容异常' . ($returned ? "({$returned})" : ''));
|
||||
}
|
||||
|
||||
$expires = !empty($json['Expiration']) ? (int)strtotime($json['Expiration']) : time() + 1800;
|
||||
$cred = [
|
||||
'ok' => true,
|
||||
'id' => (string)$json['AccessKeyId'],
|
||||
'secret' => (string)$json['AccessKeySecret'],
|
||||
'token' => (string)($json['SecurityToken'] ?? ''),
|
||||
'expires' => $expires,
|
||||
'error' => '',
|
||||
'role' => $roleName,
|
||||
];
|
||||
setSettingJson('cache_meta_aliyun', $cred);
|
||||
return $memo[$memoKey] = $cred;
|
||||
}
|
||||
|
||||
function aliyunMetaFail($resp, $prefix) {
|
||||
$detail = $resp === null ? '' : ($resp['error'] ?? '');
|
||||
return [
|
||||
'ok' => false, 'id' => '', 'secret' => '', 'token' => '', 'expires' => 0,
|
||||
'error' => $prefix . ($detail ? ":{$detail}" : '')
|
||||
. '(请确认服务器位于阿里云 ECS,且实例已绑定 RAM 角色;否则请改用 AccessKey 方式)',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前 ECS 所在地域 ID(如 cn-qingdao、cn-beijing)。不在阿里云 ECS 上时返回空串。
|
||||
*
|
||||
* 用途:判断「内网 endpoint 能不能用」。`oss-<region>-internal.aliyuncs.com`
|
||||
* **只在与该桶同地域的 ECS 上可路由**;跨地域时 DNS 照常解析(秒回 A 记录)
|
||||
* 但 TCP 永远连不通,于是每次读取都要先白等一次连接超时——这是「服务端代理
|
||||
* 慢到不可用」的典型成因。先比地域,可以完全免掉这类探测。
|
||||
*
|
||||
* 地域不会变,结果缓存 1 小时。
|
||||
*
|
||||
* @param bool $force 忽略缓存强制重取
|
||||
* @return string 地域 ID,未知时为空串
|
||||
*/
|
||||
function aliyunEcsRegion($force = false) {
|
||||
static $memo = null;
|
||||
if (!$force && $memo !== null) return $memo;
|
||||
|
||||
if (!$force) {
|
||||
$cached = getSettingJson('cache_ecs_region', []);
|
||||
if (!empty($cached['expires']) && $cached['expires'] > time()) {
|
||||
return $memo = (string)($cached['region'] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
$r = storageHttp('GET', 'http://100.100.100.200/latest/meta-data/region-id',
|
||||
[], null, STORAGE_META_TIMEOUT, false, 2);
|
||||
$region = $r['ok'] ? trim((string)$r['body']) : '';
|
||||
if ($region !== '' && !preg_match('#^[A-Za-z0-9-]+$#', $region)) $region = '';
|
||||
|
||||
setSettingJson('cache_ecs_region', ['region' => $region, 'expires' => time() + 3600]);
|
||||
return $memo = $region;
|
||||
}
|
||||
|
||||
/**
|
||||
* 腾讯云 CVM:获取 CAM 角色的临时密钥。
|
||||
*
|
||||
* @param string $roleName 指定的 CAM 角色名;留空则自动发现
|
||||
* @param bool $force 忽略缓存强制刷新
|
||||
* @return array ['ok'=>bool,'id'=>string,'secret'=>string,'token'=>string,'expires'=>int,'error'=>string]
|
||||
*/
|
||||
function tencentCvmCamCredentials($roleName = '', $force = false) {
|
||||
static $memo = [];
|
||||
$roleName = trim((string)$roleName);
|
||||
$memoKey = 'tencent:' . $roleName;
|
||||
if (!$force && isset($memo[$memoKey])) return $memo[$memoKey];
|
||||
|
||||
if (!$force) {
|
||||
$cached = getSettingJson('cache_meta_tencent', []);
|
||||
if (!empty($cached['expires']) && $cached['expires'] > time() + 300
|
||||
&& ($cached['role'] ?? '') === $roleName) {
|
||||
return $memo[$memoKey] = $cached + ['ok' => true, 'error' => ''];
|
||||
}
|
||||
}
|
||||
|
||||
// 腾讯云元数据服务需要先获取会话 token
|
||||
$token = '';
|
||||
$t = storageHttp('PUT', 'http://metadata.tencentyun.com/latest/api/token',
|
||||
['X-ttc-metadata-token-ttl-seconds: 21600'], null, STORAGE_META_TIMEOUT);
|
||||
if ($t['ok'] && trim($t['body']) !== '') {
|
||||
$token = trim($t['body']);
|
||||
}
|
||||
$authHeader = $token !== '' ? ["X-ttc-metadata-token: {$token}"] : [];
|
||||
|
||||
$base = 'http://metadata.tencentyun.com/latest/meta-data/cam/security-credentials/';
|
||||
|
||||
if ($roleName === '') {
|
||||
$r = storageHttp('GET', $base, $authHeader, null, STORAGE_META_TIMEOUT);
|
||||
if (!$r['ok']) {
|
||||
return $memo[$memoKey] = tencentMetaFail($r, '无法访问 CVM 元数据服务');
|
||||
}
|
||||
$first = trim(strtok($r['body'], "\r\n"));
|
||||
if ($first === '') {
|
||||
return $memo[$memoKey] = tencentMetaFail(null, '该实例未绑定 CAM 角色');
|
||||
}
|
||||
$roleName = $first;
|
||||
}
|
||||
|
||||
$r = storageHttp('GET', $base . rawurlencode($roleName), $authHeader, null, STORAGE_META_TIMEOUT);
|
||||
if (!$r['ok']) {
|
||||
return $memo[$memoKey] = tencentMetaFail($r, "获取 CAM 角色 {$roleName} 的临时密钥失败");
|
||||
}
|
||||
$json = json_decode($r['body'], true);
|
||||
if (!is_array($json) || empty($json['TmpSecretId']) || empty($json['TmpSecretKey'])) {
|
||||
$returned = is_array($json) ? ($json['Code'] ?? '') : '';
|
||||
return $memo[$memoKey] = tencentMetaFail(null, '元数据返回内容异常' . ($returned ? "({$returned})" : ''));
|
||||
}
|
||||
|
||||
$cred = [
|
||||
'ok' => true,
|
||||
'id' => (string)$json['TmpSecretId'],
|
||||
'secret' => (string)$json['TmpSecretKey'],
|
||||
'token' => (string)($json['Token'] ?? ''),
|
||||
'expires' => !empty($json['ExpiredTime']) ? (int)$json['ExpiredTime'] : time() + 1800,
|
||||
'error' => '',
|
||||
'role' => $roleName,
|
||||
];
|
||||
setSettingJson('cache_meta_tencent', $cred);
|
||||
return $memo[$memoKey] = $cred;
|
||||
}
|
||||
|
||||
function tencentMetaFail($resp, $prefix) {
|
||||
$detail = $resp === null ? '' : ($resp['error'] ?? '');
|
||||
return [
|
||||
'ok' => false, 'id' => '', 'secret' => '', 'token' => '', 'expires' => 0,
|
||||
'error' => $prefix . ($detail ? ":{$detail}" : '')
|
||||
. '(请确认服务器位于腾讯云 CVM,且实例已绑定 CAM 角色;否则请改用 SecretId 方式)',
|
||||
];
|
||||
}
|
||||
|
||||
// 清除元数据凭据缓存(设置页"重新探测"时使用)
|
||||
function clearMetaCredentialCache() {
|
||||
deleteSetting('cache_meta_aliyun');
|
||||
deleteSetting('cache_meta_tencent');
|
||||
deleteSetting('cache_ecs_region');
|
||||
deleteSetting('cache_oss_internal');
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
<?php
|
||||
// ============================================
|
||||
// 文件: lib/storage/oss.php
|
||||
// 说明: 阿里云 OSS 驱动
|
||||
// 签名算法:OSS 签名 V1(HMAC-SHA1)
|
||||
// 鉴权方式:AccessKey 或 ECS RAM 角色(实例元数据,免密钥)
|
||||
// ============================================
|
||||
|
||||
class StorageOss {
|
||||
|
||||
private $cfg;
|
||||
private $cred = null;
|
||||
|
||||
public function __construct(array $cfg) {
|
||||
$this->cfg = $cfg;
|
||||
}
|
||||
|
||||
public function id() { return 'oss'; }
|
||||
|
||||
public function label() { return '阿里云 OSS'; }
|
||||
|
||||
// ---------- 凭据 ----------
|
||||
|
||||
private function credentials() {
|
||||
if ($this->cred !== null) return $this->cred;
|
||||
|
||||
if (($this->cfg['auth_mode'] ?? 'ak') === 'ecs_role') {
|
||||
$c = aliyunEcsRamCredentials($this->cfg['ram_role_name'] ?? '');
|
||||
if (empty($c['ok'])) return $this->cred = ['ok' => false, 'error' => $c['error']];
|
||||
return $this->cred = $c;
|
||||
}
|
||||
|
||||
if (trim((string)($this->cfg['access_key_id'] ?? '')) === ''
|
||||
|| trim((string)($this->cfg['access_key_secret'] ?? '')) === '') {
|
||||
return $this->cred = ['ok' => false, 'error' => '未填写 AccessKey ID / AccessKey Secret'];
|
||||
}
|
||||
return $this->cred = [
|
||||
'ok' => true,
|
||||
'id' => trim($this->cfg['access_key_id']),
|
||||
'secret' => trim($this->cfg['access_key_secret']),
|
||||
'token' => '',
|
||||
'error' => '',
|
||||
];
|
||||
}
|
||||
|
||||
// ---------- 寻址 ----------
|
||||
|
||||
private function useHttps() {
|
||||
return ($this->cfg['https'] ?? '1') === '1';
|
||||
}
|
||||
|
||||
private function scheme() {
|
||||
return $this->useHttps() ? 'https' : 'http';
|
||||
}
|
||||
|
||||
private function endpoint() {
|
||||
return preg_replace('#^https?://#i', '', trim((string)($this->cfg['endpoint'] ?? '')));
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务端读取时优先使用的内网 endpoint(ECS ↔ OSS 同地域内网流量免费、更快)。
|
||||
* 只把标准的 `<region>.aliyuncs.com` 推导为 `<region>-internal.aliyuncs.com`;
|
||||
* 自定义域名 / VPC 域名 / 已是内网域名的一律不猜,返回空串表示"不适用"。
|
||||
*/
|
||||
private function internalEndpoint() {
|
||||
if (($this->cfg['internal_endpoint'] ?? '1') === '0') return '';
|
||||
$ep = $this->endpoint();
|
||||
if ($ep === '' || strpos($ep, '-internal.') !== false) return '';
|
||||
if (preg_match('#^(oss-[a-z0-9-]+)\.aliyuncs\.com$#i', $ep, $m)) {
|
||||
return $m[1] . '-internal.aliyuncs.com';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// 给对象键加上「对象前缀」(桶内目录)。库中始终存不带前缀的相对键,
|
||||
// 前缀只在驱动层拼接,因此改前缀不影响数据库里的历史记录。
|
||||
private function objectKey($key) {
|
||||
$key = ltrim((string)$key, '/');
|
||||
$prefix = trim((string)($this->cfg['prefix'] ?? ''), '/');
|
||||
return $prefix === '' ? $key : $prefix . '/' . $key;
|
||||
}
|
||||
|
||||
private function host() {
|
||||
return trim((string)$this->cfg['bucket']) . '.' . $this->endpoint();
|
||||
}
|
||||
|
||||
private function objectUrl($key) {
|
||||
return $this->scheme() . '://' . $this->host() . '/' . storageEncodeKey($key);
|
||||
}
|
||||
|
||||
// 对外访问地址(优先自定义域名)
|
||||
public function url($key) {
|
||||
$key = $this->objectKey($key);
|
||||
$custom = trim((string)($this->cfg['custom_domain'] ?? ''));
|
||||
if ($custom !== '') {
|
||||
return storageHostOf($custom, $this->useHttps()) . '/' . storageEncodeKey($key);
|
||||
}
|
||||
return $this->scheme() . '://' . $this->host() . '/' . storageEncodeKey($key);
|
||||
}
|
||||
|
||||
// ---------- 签名 ----------
|
||||
|
||||
private function authorization($method, $key, $contentType, $date, $cred) {
|
||||
$canonicalizedHeaders = '';
|
||||
if (($cred['token'] ?? '') !== '') {
|
||||
$canonicalizedHeaders = "x-oss-security-token:{$cred['token']}\n";
|
||||
}
|
||||
$canonicalizedResource = '/' . trim((string)$this->cfg['bucket']) . '/' . ltrim((string)$key, '/');
|
||||
$stringToSign = $method . "\n" // VERB
|
||||
. "\n" // Content-MD5(未使用)
|
||||
. $contentType . "\n" // Content-Type
|
||||
. $date . "\n" // Date
|
||||
. $canonicalizedHeaders
|
||||
. $canonicalizedResource;
|
||||
$signature = base64_encode(hash_hmac('sha1', $stringToSign, $cred['secret'], true));
|
||||
return 'OSS ' . $cred['id'] . ':' . $signature;
|
||||
}
|
||||
|
||||
private function buildHeaders($method, $key, $contentType, $cred) {
|
||||
$date = gmdate('D, d M Y H:i:s \G\M\T');
|
||||
$headers = [
|
||||
'Date: ' . $date,
|
||||
'Content-Type: ' . $contentType,
|
||||
'Authorization: ' . $this->authorization($method, $key, $contentType, $date, $cred),
|
||||
];
|
||||
if (($cred['token'] ?? '') !== '') {
|
||||
$headers[] = 'x-oss-security-token: ' . $cred['token'];
|
||||
}
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET/HEAD 的请求头。故意**不发送** Content-Type 头(签名串里的 Content-Type 用空串),
|
||||
* 这是 OSS 签名 V1 对读取类请求的标准做法。
|
||||
* $extra 里放条件/断点头(If-None-Match、If-Modified-Since、Range)——
|
||||
* 它们不属于 CanonicalizedOSSHeaders(只有 x-oss-* 才计入),因此不影响签名。
|
||||
*/
|
||||
private function readHeaders($key, $cred, array $extra = [], $method = 'GET') {
|
||||
$date = gmdate('D, d M Y H:i:s \G\M\T');
|
||||
$headers = [
|
||||
'Date: ' . $date,
|
||||
'Authorization: ' . $this->authorization($method, $key, '', $date, $cred),
|
||||
];
|
||||
if (($cred['token'] ?? '') !== '') {
|
||||
$headers[] = 'x-oss-security-token: ' . $cred['token'];
|
||||
}
|
||||
foreach ($extra as $h) {
|
||||
$headers[] = $h;
|
||||
}
|
||||
return $headers;
|
||||
}
|
||||
|
||||
// ---------- 内网 endpoint 可用性 ----------
|
||||
//
|
||||
// 内网 endpoint(oss-<地域>-internal.aliyuncs.com)只在**与桶同地域**的 ECS 上
|
||||
// 可路由。跨地域时 DNS 会照常解析出一个 A 记录(解析耗时 ~1ms,看不出异常),
|
||||
// 但 TCP 连接永远建不起来,只能等满连接超时。若不做判定就无脑"内网优先",
|
||||
// 每读一张图都要先白等一次超时再回退公网——图片多的页面直接慢到不可用。
|
||||
// 所以这里先比地域(零成本、结论确定),拿不准才真的探一次,并把结论缓存下来。
|
||||
|
||||
/** 桶所在地域(从标准 endpoint 解析);自定义/VPC 域名返回空串。 */
|
||||
private function bucketRegion() {
|
||||
if (preg_match('#^oss-([a-z0-9-]+?)(?:-internal)?\.aliyuncs\.com$#i', $this->endpoint(), $m)) {
|
||||
return strtolower($m[1]);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 探测内网 endpoint 是否可用,并把结论写入缓存。
|
||||
* @param bool $force 忽略缓存结论重新探测(设置页"测试连通性"用)
|
||||
* @return array ['ok'=>bool,'reason'=>string]
|
||||
*/
|
||||
private function probeInternal($force = false) {
|
||||
$internal = $this->internalEndpoint();
|
||||
$out = ['ok' => false, 'reason' => ''];
|
||||
|
||||
if ($internal === '') {
|
||||
return ['ok' => false, 'reason' => '未启用,或 endpoint 不是标准 OSS 域名'];
|
||||
}
|
||||
|
||||
$bucketRegion = $this->bucketRegion();
|
||||
$ecsRegion = function_exists('aliyunEcsRegion') ? aliyunEcsRegion($force) : '';
|
||||
|
||||
if ($bucketRegion !== '' && $ecsRegion !== '' && $bucketRegion !== $ecsRegion) {
|
||||
// 跨地域:不必探测,内网一定不通
|
||||
$out['reason'] = "本机在 {$ecsRegion}、桶在 {$bucketRegion},不是同一地域";
|
||||
} else {
|
||||
$cred = $this->credentials();
|
||||
if (empty($cred['ok'])) {
|
||||
$out['reason'] = '凭据不可用,无法探测';
|
||||
} else {
|
||||
// 对桶根发一个签名 HEAD:只要拿到任何 HTTP 状态码就说明内网可路由
|
||||
// (没有 ListBucket 权限会回 403,但这已经证明网络通)。
|
||||
$url = $this->scheme() . '://' . trim((string)$this->cfg['bucket']) . '.' . $internal . '/';
|
||||
$res = storageHttp('HEAD', $url, $this->readHeaders('/', $cred, [], 'HEAD'),
|
||||
null, 3, false, 2);
|
||||
$out['ok'] = $res['status'] > 0;
|
||||
$out['reason'] = $out['ok']
|
||||
? "内网可达(HTTP {$res['status']})"
|
||||
: '内网 endpoint 连接失败:' . ($res['error'] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
setSettingJson('cache_oss_internal', [
|
||||
'endpoint' => $internal,
|
||||
'ok' => $out['ok'] ? 1 : 0,
|
||||
'reason' => $out['reason'],
|
||||
'expires' => time() + ($out['ok'] ? 3600 : 1800),
|
||||
]);
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** 内网 endpoint 现在能不能用(结论走缓存,不会每个请求都探测)。 */
|
||||
private function internalUsable() {
|
||||
$internal = $this->internalEndpoint();
|
||||
if ($internal === '') return false;
|
||||
|
||||
$cache = getSettingJson('cache_oss_internal', []);
|
||||
if (!empty($cache['expires']) && $cache['expires'] > time()
|
||||
&& ($cache['endpoint'] ?? '') === $internal) {
|
||||
return !empty($cache['ok']);
|
||||
}
|
||||
return !empty($this->probeInternal()['ok']);
|
||||
}
|
||||
|
||||
/** 内网这次真的连不上 → 立刻记下,半小时内不再重试(避免每张图都白等)。 */
|
||||
private function markInternalDown($reason) {
|
||||
if ($this->internalEndpoint() === '') return;
|
||||
setSettingJson('cache_oss_internal', [
|
||||
'endpoint' => $this->internalEndpoint(),
|
||||
'ok' => 0,
|
||||
'reason' => $reason,
|
||||
'expires' => time() + 1800,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 供设置页展示:内网 endpoint 的推导结果与可用性判定。
|
||||
* @param bool $probe 强制重新探测
|
||||
* @return array ['endpoint'=>string,'ok'=>bool,'reason'=>string]
|
||||
*/
|
||||
public function internalStatus($probe = false) {
|
||||
$internal = $this->internalEndpoint();
|
||||
if ($internal === '') return ['endpoint' => '', 'ok' => false, 'reason' => ''];
|
||||
|
||||
$cache = getSettingJson('cache_oss_internal', []);
|
||||
$cached = !empty($cache['expires']) && $cache['expires'] > time()
|
||||
&& ($cache['endpoint'] ?? '') === $internal;
|
||||
|
||||
if ($probe || !$cached) {
|
||||
$r = $this->probeInternal($probe);
|
||||
return ['endpoint' => $internal, 'ok' => !empty($r['ok']), 'reason' => (string)$r['reason']];
|
||||
}
|
||||
return [
|
||||
'endpoint' => $internal,
|
||||
'ok' => !empty($cache['ok']),
|
||||
'reason' => (string)($cache['reason'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
private function validateConfig() {
|
||||
foreach (['bucket' => 'Bucket 名称', 'endpoint' => 'Endpoint'] as $f => $label) {
|
||||
if (trim((string)($this->cfg[$f] ?? '')) === '') {
|
||||
return "未填写{$label}";
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// ---------- 操作 ----------
|
||||
|
||||
public function putFile($srcPath, $key, $mime = null) {
|
||||
return $this->putContents(file_get_contents($srcPath), $key, $mime ?: 'application/octet-stream');
|
||||
}
|
||||
|
||||
public function putContents($binary, $key, $mime = null) {
|
||||
if ($err = $this->validateConfig()) return ['ok' => false, 'error' => $err];
|
||||
$cred = $this->credentials();
|
||||
if (empty($cred['ok'])) return ['ok' => false, 'error' => $cred['error']];
|
||||
|
||||
$key = $this->objectKey($key);
|
||||
$mime = $mime ?: 'application/octet-stream';
|
||||
$res = storageHttp('PUT', $this->objectUrl($key),
|
||||
$this->buildHeaders('PUT', $key, $mime, $cred), $binary, 60);
|
||||
|
||||
if (!$res['ok']) {
|
||||
return ['ok' => false, 'error' => '上传到 OSS 失败:' . $this->describe($res)];
|
||||
}
|
||||
return ['ok' => true, 'error' => ''];
|
||||
}
|
||||
|
||||
public function delete($key) {
|
||||
$cred = $this->credentials();
|
||||
if (empty($cred['ok'])) return ['ok' => false, 'error' => $cred['error']];
|
||||
|
||||
$key = $this->objectKey($key);
|
||||
$res = storageHttp('DELETE', $this->objectUrl($key),
|
||||
$this->buildHeaders('DELETE', $key, '', $cred), null, 30);
|
||||
|
||||
// 对象本就不存在时视为成功
|
||||
if (!$res['ok'] && $res['status'] !== 404) {
|
||||
return ['ok' => false, 'error' => '删除 OSS 对象失败:' . $this->describe($res)];
|
||||
}
|
||||
return ['ok' => true, 'error' => ''];
|
||||
}
|
||||
|
||||
public function exists($key) {
|
||||
return true; // 远端不做逐次 HEAD 探测,交由页面兜底
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务端读取对象(供 media.php 代理回吐)。
|
||||
* 私有桶同样可读——用的是实例角色 / AccessKey 签名,与桶的公共读权限无关。
|
||||
* $extra 用于透传条件头(If-None-Match / If-Modified-Since)与 Range,实现 304 短路与断点。
|
||||
* 返回结构与 storageHttp() 同构:['ok','status','headers','body','error']
|
||||
*/
|
||||
public function get($key, array $extra = []) {
|
||||
if ($err = $this->validateConfig()) {
|
||||
return ['ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'error' => $err];
|
||||
}
|
||||
$cred = $this->credentials();
|
||||
if (empty($cred['ok'])) {
|
||||
return ['ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'error' => $cred['error']];
|
||||
}
|
||||
|
||||
$key = $this->objectKey($key);
|
||||
$headers = $this->readHeaders($key, $cred, $extra);
|
||||
|
||||
// 候选主机:内网优先(同地域内网流量免费、延迟更低),传输层失败再回退公网。
|
||||
// 内网是否**真的可用**先判定(结论有缓存)——跨地域时内网不可路由,
|
||||
// 无脑尝试会让每张图先白等一次连接超时。
|
||||
// Authorization 的 CanonicalizedResource 只含 /bucket/key,与主机无关,故可复用同一组头。
|
||||
$hosts = [];
|
||||
$internal = $this->internalEndpoint();
|
||||
if ($internal !== '' && $this->internalUsable()) $hosts[] = $internal;
|
||||
$hosts[] = $this->endpoint();
|
||||
|
||||
$res = null;
|
||||
foreach ($hosts as $i => $host) {
|
||||
$isInternal = ($internal !== '' && $host === $internal);
|
||||
$url = $this->scheme() . '://' . trim((string)$this->cfg['bucket']) . '.' . $host
|
||||
. '/' . storageEncodeKey($key);
|
||||
// 内网只给 2 秒连接超时:万一判定失效,也只损失 2 秒而不是默认的 10 秒
|
||||
$res = storageHttp('GET', $url, $headers, null, 60, false, $isInternal ? 2 : null);
|
||||
if ($res['status'] === 0) {
|
||||
if ($isInternal) {
|
||||
$this->markInternalDown('连接失败:' . ($res['error'] ?? ''));
|
||||
}
|
||||
if (isset($hosts[$i + 1])) continue; // 连不上 → 换下一个主机
|
||||
}
|
||||
break;
|
||||
}
|
||||
return $res ?: ['ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'error' => '无法连接 OSS'];
|
||||
}
|
||||
|
||||
public function test() {
|
||||
if ($err = $this->validateConfig()) return ['ok' => false, 'error' => $err];
|
||||
$cred = $this->credentials();
|
||||
if (empty($cred['ok'])) return ['ok' => false, 'error' => $cred['error']];
|
||||
|
||||
// 探测对象也走前缀,顺便验证前缀目录的写权限
|
||||
$raw = '.movielog-probe/' . date('YmdHis') . '-' . bin2hex(random_bytes(4)) . '.txt';
|
||||
$key = $this->objectKey($raw);
|
||||
$binary = 'movielog storage probe ' . date('c');
|
||||
|
||||
$res = storageHttp('PUT', $this->objectUrl($key),
|
||||
$this->buildHeaders('PUT', $key, 'text/plain', $cred), $binary, 20);
|
||||
if (!$res['ok']) {
|
||||
return ['ok' => false, 'error' => '写入测试失败:' . $this->describe($res)];
|
||||
}
|
||||
|
||||
// 回读验证:「服务端代理」模式(media.php)完全依赖这条读取链路,
|
||||
// 所以测试连通性必须把 GET 也走一遍——写入成功不等于读得回来。
|
||||
$read = $this->get($raw);
|
||||
$readErr = '';
|
||||
if (!$read['ok']) {
|
||||
$readErr = $this->describe($read);
|
||||
} elseif (trim((string)$read['body']) !== trim($binary)) {
|
||||
$readErr = '回读到的内容与写入不一致';
|
||||
}
|
||||
|
||||
$del = $this->delete($raw); // delete() 内部会自行加前缀,故传原始键
|
||||
$mode = ($this->cfg['auth_mode'] ?? 'ak') === 'ecs_role'
|
||||
? 'ECS RAM 角色(免密钥)' : 'AccessKey';
|
||||
$prefix = trim((string)($this->cfg['prefix'] ?? ''), '/');
|
||||
|
||||
// 内网 endpoint 的结论要明说:跨地域时它是**静默拖慢**的元凶,
|
||||
// 不报出来用户根本无从察觉。
|
||||
$internalNote = '';
|
||||
if ($this->internalEndpoint() !== '') {
|
||||
$probe = $this->probeInternal(true);
|
||||
$internalNote = $probe['ok']
|
||||
? "服务端读取走内网 {$probe['endpoint']}({$probe['reason']})。"
|
||||
: '内网 endpoint 已自动跳过(原因:' . $probe['reason'] . '),'
|
||||
. '读取改走公网 ' . $this->endpoint() . ',功能不受影响。';
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'read_ok' => $readErr === '',
|
||||
'error' => '',
|
||||
'message' => "连接成功({$mode})。桶 {$this->cfg['bucket']} 可读写,"
|
||||
. ($prefix !== '' ? "前缀 {$prefix}/," : '')
|
||||
. ($readErr === ''
|
||||
? '服务端读取已验证——可安全启用「服务端代理」访问方式。'
|
||||
: "但服务端读取失败:{$readErr}(请先排查读取权限,暂不要启用「服务端代理」)。")
|
||||
. $internalNote
|
||||
. '测试对象已清理。' . ($del['ok'] ? '' : '(注意:测试对象删除失败,请检查删除权限)'),
|
||||
];
|
||||
}
|
||||
|
||||
// 把 HTTP 响应压成一句可读的错误
|
||||
private function describe($res) {
|
||||
$body = trim((string)$res['body']);
|
||||
if ($body !== '') {
|
||||
if (preg_match('#<Message>(.*?)</Message>#s', $body, $m)) {
|
||||
$code = preg_match('#<Code>(.*?)</Code>#s', $body, $c) ? $c[1] : '';
|
||||
return trim("{$code} {$m[1]}");
|
||||
}
|
||||
if (preg_match('#<Code>(.*?)</Code>#s', $body, $c)) {
|
||||
return $c[1];
|
||||
}
|
||||
}
|
||||
return $res['error'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
// ============================================
|
||||
// 文件: lib/storage/qiniu.php
|
||||
// 说明: 七牛云 Kodo 驱动
|
||||
// 鉴权:AccessKey / SecretKey 计算上传凭证(HMAC-SHA1 + URL 安全 Base64)
|
||||
// 注意:七牛不提供云主机元数据鉴权,必须使用密钥;且访问需要绑定域名。
|
||||
// ============================================
|
||||
|
||||
class StorageQiniu {
|
||||
|
||||
private $cfg;
|
||||
|
||||
public function __construct(array $cfg) {
|
||||
$this->cfg = $cfg;
|
||||
}
|
||||
|
||||
public function id() { return 'qiniu'; }
|
||||
|
||||
public function label() { return '七牛云 Kodo'; }
|
||||
|
||||
// ---------- 工具 ----------
|
||||
|
||||
// URL 安全 Base64(去掉填充、+→-、/→_)
|
||||
private function urlsafeB64($data) {
|
||||
return str_replace(['+', '/'], ['-', '_'], rtrim(base64_encode($data), '='));
|
||||
}
|
||||
|
||||
private function uploadHost() {
|
||||
$map = [
|
||||
'z0' => 'upload.qiniup.com',
|
||||
'z1' => 'upload-z1.qiniup.com',
|
||||
'z2' => 'upload-z2.qiniup.com',
|
||||
'na0' => 'upload-na0.qiniup.com',
|
||||
'as0' => 'upload-as0.qiniup.com',
|
||||
];
|
||||
$region = (string)($this->cfg['region'] ?? 'z0');
|
||||
return $map[$region] ?? $map['z0'];
|
||||
}
|
||||
|
||||
private function scheme() {
|
||||
return ($this->cfg['https'] ?? '1') === '1' ? 'https' : 'http';
|
||||
}
|
||||
|
||||
// 给对象键加上「对象前缀」(桶内目录)。库中始终存不带前缀的相对键,
|
||||
// 前缀只在驱动层拼接,因此改前缀不影响数据库里的历史记录。
|
||||
private function objectKey($key) {
|
||||
$key = ltrim((string)$key, '/');
|
||||
$prefix = trim((string)($this->cfg['prefix'] ?? ''), '/');
|
||||
return $prefix === '' ? $key : $prefix . '/' . $key;
|
||||
}
|
||||
|
||||
private function credentials() {
|
||||
$ak = trim((string)($this->cfg['access_key'] ?? ''));
|
||||
$sk = trim((string)($this->cfg['secret_key'] ?? ''));
|
||||
if ($ak === '' || $sk === '') {
|
||||
return ['ok' => false, 'error' => '未填写 AccessKey / SecretKey'];
|
||||
}
|
||||
return ['ok' => true, 'ak' => $ak, 'sk' => $sk];
|
||||
}
|
||||
|
||||
private function validateConfig() {
|
||||
foreach (['bucket' => 'Bucket 名称'] as $f => $label) {
|
||||
if (trim((string)($this->cfg[$f] ?? '')) === '') {
|
||||
return "未填写{$label}";
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// 生成上传凭证
|
||||
private function uploadToken($key, $cred) {
|
||||
$policy = [
|
||||
'scope' => trim((string)$this->cfg['bucket']) . ':' . ltrim((string)$key, '/'),
|
||||
'deadline' => time() + 3600,
|
||||
'insertOnly' => 0,
|
||||
];
|
||||
$encoded = $this->urlsafeB64(json_encode($policy, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
$sign = $this->urlsafeB64(hash_hmac('sha1', $encoded, $cred['sk'], true));
|
||||
return $cred['ak'] . ':' . $sign . ':' . $encoded;
|
||||
}
|
||||
|
||||
// 管理凭证(用于删除)
|
||||
private function managementAuth($path, $cred) {
|
||||
$sign = $this->urlsafeB64(hash_hmac('sha1', $path . "\n", $cred['sk'], true));
|
||||
return 'QBox ' . $cred['ak'] . ':' . $sign;
|
||||
}
|
||||
|
||||
// ---------- 对外地址 ----------
|
||||
|
||||
public function url($key) {
|
||||
$key = ltrim((string)$key, '/');
|
||||
$domain = trim((string)($this->cfg['custom_domain'] ?? ''));
|
||||
if ($domain === '') {
|
||||
return '/' . $key; // 未绑定域名时退回相对路径,避免生成半截 URL
|
||||
}
|
||||
return storageHostOf($domain, $this->scheme()) . '/' . storageEncodeKey($this->objectKey($key));
|
||||
}
|
||||
|
||||
// ---------- 操作 ----------
|
||||
|
||||
public function putFile($srcPath, $key, $mime = null) {
|
||||
if ($err = $this->validateConfig()) return ['ok' => false, 'error' => $err];
|
||||
$cred = $this->credentials();
|
||||
if (empty($cred['ok'])) return ['ok' => false, 'error' => $cred['error']];
|
||||
if (!is_file($srcPath)) return ['ok' => false, 'error' => "源文件不存在:{$srcPath}"];
|
||||
|
||||
$key = $this->objectKey($key);
|
||||
$post = [
|
||||
'token' => $this->uploadToken($key, $cred),
|
||||
'key' => $key,
|
||||
'file' => new CURLFile($srcPath, $mime ?: 'application/octet-stream', basename($key)),
|
||||
];
|
||||
$res = storageHttp('POST', $this->scheme() . '://' . $this->uploadHost() . '/',
|
||||
['Expect:'], $post, 90);
|
||||
|
||||
return $this->interpretUpload($res);
|
||||
}
|
||||
|
||||
public function putContents($binary, $key, $mime = null) {
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'qn');
|
||||
if ($tmp === false || file_put_contents($tmp, $binary) === false) {
|
||||
return ['ok' => false, 'error' => '无法创建临时文件'];
|
||||
}
|
||||
$result = $this->putFile($tmp, $key, $mime);
|
||||
@unlink($tmp);
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function delete($key) {
|
||||
$cred = $this->credentials();
|
||||
if (empty($cred['ok'])) return ['ok' => false, 'error' => $cred['error']];
|
||||
|
||||
$entry = $this->urlsafeB64(trim((string)$this->cfg['bucket']) . ':' . $this->objectKey($key));
|
||||
$path = '/delete/' . $entry;
|
||||
$res = storageHttp('POST', $this->scheme() . '://rs.qiniup.com' . $path,
|
||||
['Authorization: ' . $this->managementAuth($path, $cred)], null, 30);
|
||||
|
||||
if (!$res['ok'] && $res['status'] !== 612) { // 612 = 资源不存在
|
||||
return ['ok' => false, 'error' => '删除七牛对象失败:' . $this->describe($res)];
|
||||
}
|
||||
return ['ok' => true, 'error' => ''];
|
||||
}
|
||||
|
||||
public function exists($key) {
|
||||
return true;
|
||||
}
|
||||
|
||||
public function test() {
|
||||
if ($err = $this->validateConfig()) return ['ok' => false, 'error' => $err];
|
||||
$cred = $this->credentials();
|
||||
if (empty($cred['ok'])) return ['ok' => false, 'error' => $cred['error']];
|
||||
if (trim((string)($this->cfg['custom_domain'] ?? '')) === '') {
|
||||
return ['ok' => false, 'error' => '未填写绑定域名——七牛云必须绑定域名后才能对外访问图片'];
|
||||
}
|
||||
|
||||
$key = '.movielog-probe/' . date('YmdHis') . '-' . bin2hex(random_bytes(4)) . '.txt';
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'qn');
|
||||
file_put_contents($tmp, 'movielog storage probe ' . date('c'));
|
||||
$up = $this->putFile($tmp, $key, 'text/plain');
|
||||
@unlink($tmp);
|
||||
if (empty($up['ok'])) return $up;
|
||||
|
||||
$del = $this->delete($key);
|
||||
$prefix = trim((string)($this->cfg['prefix'] ?? ''), '/');
|
||||
return [
|
||||
'ok' => true,
|
||||
'error' => '',
|
||||
'message' => "连接成功(AccessKey/SecretKey)。空间 {$this->cfg['bucket']} 可上传,"
|
||||
. ($prefix !== '' ? "前缀 {$prefix}/," : '')
|
||||
. '测试对象已清理。' . ($del['ok'] ? '' : '(注意:测试对象删除失败,请检查删除权限)'),
|
||||
];
|
||||
}
|
||||
|
||||
// ---------- 响应解析 ----------
|
||||
|
||||
private function interpretUpload($res) {
|
||||
$json = json_decode((string)$res['body'], true);
|
||||
if (is_array($json) && !empty($json['key'])) {
|
||||
return ['ok' => true, 'error' => ''];
|
||||
}
|
||||
if (!$res['ok'] || (is_array($json) && isset($json['error']))) {
|
||||
return ['ok' => false, 'error' => '上传到七牛失败:' . $this->describe($res)];
|
||||
}
|
||||
return ['ok' => false, 'error' => '上传到七牛失败:响应异常'];
|
||||
}
|
||||
|
||||
private function describe($res) {
|
||||
$json = json_decode((string)$res['body'], true);
|
||||
if (is_array($json) && !empty($json['error'])) {
|
||||
return (string)$json['error'];
|
||||
}
|
||||
return $res['error'];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user