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:
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user