381 lines
13 KiB
PHP
381 lines
13 KiB
PHP
<?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;
|
|||
|
|
}
|