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:
@@ -19,6 +19,15 @@
|
|||||||
- **多图上传**:观影照片支持一次选择多张。
|
- **多图上传**:观影照片支持一次选择多张。
|
||||||
- **头像兜底**:未上传头像的观影人自动生成"首字母 + 配色"的 SVG 头像,配色由姓名哈希决定,同名同色。
|
- **头像兜底**:未上传头像的观影人自动生成"首字母 + 配色"的 SVG 头像,配色由姓名哈希决定,同名同色。
|
||||||
- 上传目录按类型分离:`uploads/posters/`、`uploads/tickets/`、`uploads/photos/`、`uploads/avatars/`。
|
- 上传目录按类型分离:`uploads/posters/`、`uploads/tickets/`、`uploads/photos/`、`uploads/avatars/`。
|
||||||
|
- **破图兜底**:图片加载失败时由前端脚本替换为内联占位图,避免会话过期或权限异常时满屏破图刷屏控制台。
|
||||||
|
|
||||||
|
### 图片存储(本地 / 云对象存储)
|
||||||
|
- **四种存储后端**:本地硬盘(默认)、阿里云 OSS、腾讯云 COS、七牛云 Kodo,在「系统设置」页切换,**无需改代码**。
|
||||||
|
- **免密钥鉴权**:部署在阿里云 ECS / 腾讯云 CVM 上时,可直接绑定 RAM/CAM 角色读取实例元数据获取临时凭据,**不必在站内保存长期密钥**。
|
||||||
|
- **本地优先读取**:数据库里只存相对对象键(如 `uploads/posters/xxx.webp`);本地存在就本地读,否则读云端。**因此切换存储方式不会影响已有图片**,迁移可以慢慢来。
|
||||||
|
- **内容指纹命名**:文件名为 `md5(内容)前 12 位_6 位随机[_角色].ext`,内容不变则名字不变,可以放心下长缓存。
|
||||||
|
- **一键迁移**:把已有本地图片上传到云端并自动改写数据库引用(分批执行、带进度、幂等可重入)。
|
||||||
|
- **两种图片访问方式**:**直连存储**(浏览器直接访问对象存储)与**服务端代理**(经本站 `media.php` 鉴权后由服务器读取回吐,**桶可保持私有**)。两者的取舍与配置见下文《图片存储与对象存储》。
|
||||||
|
|
||||||
### 信息辅助
|
### 信息辅助
|
||||||
- **OMDb 自动获取**:在添加页输入片名后点击「📡 自动获取」,自动填充年份、类型、片长、导演、主演、IMDb 评分与剧情简介,并预览海报(海报仅供参考,仍需本地上传)。
|
- **OMDb 自动获取**:在添加页输入片名后点击「📡 自动获取」,自动填充年份、类型、片长、导演、主演、IMDb 评分与剧情简介,并预览海报(海报仅供参考,仍需本地上传)。
|
||||||
@@ -72,6 +81,15 @@ MovieLog_Server/
|
|||||||
├── lib/ # 功能模块(被 config.php 统一引入,页面无需单独 require)
|
├── lib/ # 功能模块(被 config.php 统一引入,页面无需单独 require)
|
||||||
│ ├── db.php # DB 连接、建表、字段与历史数据迁移、默认管理员
|
│ ├── db.php # DB 连接、建表、字段与历史数据迁移、默认管理员
|
||||||
│ ├── helpers.php # 输出转义、文件名、颜色校验、上传/删除文件、图片 URL
|
│ ├── helpers.php # 输出转义、文件名、颜色校验、上传/删除文件、图片 URL
|
||||||
|
│ ├── settings.php # 系统设置读写;云厂商密钥以 enc:v1:gcm 密文落库
|
||||||
|
│ ├── storage.php # 存储门面:本地优先读取、URL 生成、上传/删除、迁移、代理读取
|
||||||
|
│ ├── storage/ # 各存储后端驱动
|
||||||
|
│ │ ├── local.php # 本地硬盘
|
||||||
|
│ │ ├── oss.php # 阿里云 OSS(签名 V1,支持 RAM 角色免密钥 / 内网 endpoint)
|
||||||
|
│ │ ├── cos.php # 腾讯云 COS
|
||||||
|
│ │ ├── qiniu.php # 七牛云 Kodo
|
||||||
|
│ │ ├── http.php # 共用 HTTP 客户端(cURL)与签名/编码工具
|
||||||
|
│ │ └── meta.php # 实例元数据鉴权(ECS RAM 角色 / CVM CAM 角色)与本机地域查询
|
||||||
│ ├── auth.php # 登录校验、管理员账号增删改
|
│ ├── auth.php # 登录校验、管理员账号增删改
|
||||||
│ ├── movies.php # 电影/影院读取、搜索,以及表单关联数据的解析
|
│ ├── movies.php # 电影/影院读取、搜索,以及表单关联数据的解析
|
||||||
│ └── stats.php # 统计仪表板与年度报告查询
|
│ └── stats.php # 统计仪表板与年度报告查询
|
||||||
@@ -91,10 +109,13 @@ MovieLog_Server/
|
|||||||
├── login.php # 登录
|
├── login.php # 登录
|
||||||
├── logout.php # 退出
|
├── logout.php # 退出
|
||||||
├── nav.php # 导航栏组件(被各页面 include)
|
├── nav.php # 导航栏组件(被各页面 include)
|
||||||
|
├── settings.php # 系统设置页:存储后端选择、图片访问方式、连接测试、迁移
|
||||||
|
├── storage_api.php # 存储维护接口(stat / migrate / test),返回 JSON
|
||||||
|
├── media.php # 服务端图片代理:鉴权后由服务器读取对象并同源回吐
|
||||||
├── fetch_movie.php # OMDb 代理接口,返回 JSON
|
├── fetch_movie.php # OMDb 代理接口,返回 JSON
|
||||||
├── test_crop.php # 开发调试页:验证 Base64 图片落盘是否正常
|
├── test_crop.php # 开发调试页:验证 Base64 图片落盘是否正常
|
||||||
├── css/style.css # 全局样式
|
├── css/style.css # 全局样式
|
||||||
├── js/main.js # 公共脚本(含各页面共用的表单联动函数)
|
├── js/main.js # 公共脚本(含各页面共用的表单联动函数、破图兜底)
|
||||||
├── assets/.gitkeep # 静态占位资源目录
|
├── assets/.gitkeep # 静态占位资源目录
|
||||||
├── db/movies.db # SQLite 数据库(运行时自动生成,不纳入版本控制)
|
├── db/movies.db # SQLite 数据库(运行时自动生成,不纳入版本控制)
|
||||||
└── uploads/ # 上传图片(运行时自动生成,不纳入版本控制)
|
└── uploads/ # 上传图片(运行时自动生成,不纳入版本控制)
|
||||||
@@ -110,7 +131,7 @@ MovieLog_Server/
|
|||||||
|
|
||||||
## 数据库结构
|
## 数据库结构
|
||||||
|
|
||||||
建表逻辑集中在 `config.php` 的 `initDB()`,每次请求都会执行 `CREATE TABLE IF NOT EXISTS` 与幂等的 `ALTER TABLE` 迁移,因此**升级代码无需手动改库**。
|
建表逻辑集中在 `lib/db.php` 的 `initDB()`(由 `config.php` 在每次请求引导时调用),每次请求都会执行 `CREATE TABLE IF NOT EXISTS` 与幂等的 `ALTER TABLE` 迁移,因此**升级代码无需手动改库**。
|
||||||
|
|
||||||
| 表 | 作用 | 关键字段 |
|
| 表 | 作用 | 关键字段 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -120,6 +141,15 @@ MovieLog_Server/
|
|||||||
| `persons` | 观影人 | `name`(唯一)、`color`、`avatar` |
|
| `persons` | 观影人 | `name`(唯一)、`color`、`avatar` |
|
||||||
| `viewers` | 电影 ↔ 观影人关联 | `movie_id`、`person_id`、`name`(历史遗留字段) |
|
| `viewers` | 电影 ↔ 观影人关联 | `movie_id`、`person_id`、`name`(历史遗留字段) |
|
||||||
| `movie_photos` | 电影多图 | `movie_id`、`photo_path`、`created_at` |
|
| `movie_photos` | 电影多图 | `movie_id`、`photo_path`、`created_at` |
|
||||||
|
| `settings` | 键值配置与缓存 | `name`(唯一)、`value`。存存储驱动选择、各云厂商配置、图片访问方式等;**云厂商密钥以 `enc:v1:gcm:` 前缀密文落库**(密钥文件 `db/.settings.key`,首次保存时自动生成,**丢失后密文不可恢复**) |
|
||||||
|
|
||||||
|
`settings` 中除配置外的运行时缓存键(都可安全清空,程序会自动重建):
|
||||||
|
|
||||||
|
| 键 | 含义 | 有效期 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `cache_meta_aliyun` / `cache_meta_tencent` | 实例角色换来的临时凭据 | 到期前 5 分钟失效 |
|
||||||
|
| `cache_ecs_region` | 本机 ECS 所在地域(用于判断内网 endpoint 是否可用) | 1 小时 |
|
||||||
|
| `cache_oss_internal` | 内网 endpoint 可用性判定结论 | 可用 1 小时 / 不可用 30 分钟 |
|
||||||
|
|
||||||
外键约束:`movies.cinema_id → cinemas.id`(`ON DELETE SET NULL`)、`viewers.movie_id → movies.id`(`ON DELETE CASCADE`)、`movie_photos.movie_id → movies.id`(`ON DELETE CASCADE`)。
|
外键约束:`movies.cinema_id → cinemas.id`(`ON DELETE SET NULL`)、`viewers.movie_id → movies.id`(`ON DELETE CASCADE`)、`movie_photos.movie_id → movies.id`(`ON DELETE CASCADE`)。
|
||||||
|
|
||||||
@@ -133,6 +163,10 @@ MovieLog_Server/
|
|||||||
|
|
||||||
- PHP 7.4+(推荐 8.x),需启用扩展:`pdo_sqlite`、`fileinfo`、`mbstring`、`curl`
|
- PHP 7.4+(推荐 8.x),需启用扩展:`pdo_sqlite`、`fileinfo`、`mbstring`、`curl`
|
||||||
- Web 服务器:Apache / Nginx / PHP 内置服务器均可
|
- Web 服务器:Apache / Nginx / PHP 内置服务器均可
|
||||||
|
- **使用对象存储时额外需要**:
|
||||||
|
- `curl` 扩展可用,且系统存在可信 CA 证书(`/etc/ssl/certs/ca-certificates.crt`、`/etc/pki/tls/certs/ca-bundle.crt` 等常见路径之一)。找不到 CA 时程序会回退给 curl 自行处理,但部分精简系统需要手动安装 `ca-certificates`,否则 HTTPS 访问对象存储会失败。
|
||||||
|
- 若使用「实例角色(免密钥)」鉴权:服务器需运行在对应云上,且实例已绑定 RAM(阿里云)/ CAM(腾讯云)角色。
|
||||||
|
- **`settings.php` 会写入 `db/`**:请确保 Web 用户对 `db/` 有写权限(密钥文件 `db/.settings.key` 首次保存时在此生成)。
|
||||||
|
|
||||||
### 步骤
|
### 步骤
|
||||||
|
|
||||||
@@ -160,7 +194,7 @@ php -S localhost:8000
|
|||||||
# 浏览器访问 http://localhost:8000/login.php
|
# 浏览器访问 http://localhost:8000/login.php
|
||||||
```
|
```
|
||||||
|
|
||||||
> 用 PHP 内置服务器时,`config.php` 中 `posterUrl()` 等函数返回的是以 `/` 开头的根路径,建议直接从项目根目录启动,或通过站点根目录访问。
|
> 用 PHP 内置服务器时,`posterUrl()`(`lib/helpers.php`)与 `storageUrl()`(`lib/storage.php`)返回的是以 `/` 开头的站点绝对路径(如 `/uploads/posters/xxx.webp`),建议从项目根目录启动,或通过站点根目录访问。
|
||||||
|
|
||||||
### Nginx 参考配置
|
### Nginx 参考配置
|
||||||
|
|
||||||
@@ -227,6 +261,19 @@ CSV **共 9 列,顺序固定**:
|
|||||||
阿甘正传, , 万达影城, 2024-01-15, 张三 李四, (电影票/阿甘正传.jpg), (海报/阿甘正传.jpg), 经典励志片, (合影/阿甘正传.jpg)
|
阿甘正传, , 万达影城, 2024-01-15, 张三 李四, (电影票/阿甘正传.jpg), (海报/阿甘正传.jpg), 经典励志片, (合影/阿甘正传.jpg)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 把图片放到对象存储(可选)
|
||||||
|
|
||||||
|
不改也能用(默认本地硬盘)。想用云存储时:
|
||||||
|
|
||||||
|
1. 进入**系统设置**,选择存储后端(阿里云 OSS / 腾讯云 COS / 七牛云 Kodo),填写 Bucket、Endpoint、对象前缀等。
|
||||||
|
2. 选择鉴权方式:
|
||||||
|
- **实例角色(免密钥,推荐)**:服务器在对应云上并绑定了角色时选它,不用在站内保存任何长期密钥。
|
||||||
|
- **AccessKey**:手动填写密钥,入库即加密。
|
||||||
|
3. 点**测试连通性**——它会执行「写入探针 → **读回校验** → 删除探针」,因此**同时验证了写和读两条链路**,并会明确报出内网 endpoint 的判定结论。
|
||||||
|
4. 选择**图片访问方式**(直连存储 / 服务端代理),两个选项的取舍见《图片存储与对象存储》§2。
|
||||||
|
5. 需要的话点**迁移**,把已有本地图片搬到云端(分批、可重入,建议先备份 `db/` 与 `uploads/`)。
|
||||||
|
6. 迁移完成后**全站看图确认无误,再清理本地文件**——读取是本地优先的。
|
||||||
|
|
||||||
### 接口:`fetch_movie.php`
|
### 接口:`fetch_movie.php`
|
||||||
|
|
||||||
供添加页前端调用,服务端代请求 OMDb 避免浏览器跨域。
|
供添加页前端调用,服务端代请求 OMDb 避免浏览器跨域。
|
||||||
@@ -253,6 +300,239 @@ CSV **共 9 列,顺序固定**:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 图片存储与对象存储(OSS / COS / 七牛)
|
||||||
|
|
||||||
|
> **这一节回答两个问题**:① 想用「直连存储」,对象存储那边到底要怎么配?② 我们在这条路上踩过的坑分别长什么样、怎么排。
|
||||||
|
|
||||||
|
### 1. 几个设计约定(先理解这个,后面的现象才好解释)
|
||||||
|
|
||||||
|
| 约定 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| **库里只存相对对象键** | `movies.poster_path` 等字段保存的是 `uploads/posters/xxxx.webp` 这种**相对键**,不含域名、不含 bucket、不含前缀。所以改域名 / 换 bucket / 改前缀都不用动数据库 |
|
||||||
|
| **本地优先读取** | `storageUrl()` / `storageExists()` 先看本地 `uploads/` 里有没有这个文件:有 → 走站内路径 `/uploads/...`;没有 → 交给当前存储驱动。**这是「切换存储方式不影响已有图片」和「可以慢慢迁移」的根本原因** |
|
||||||
|
| **内容指纹命名** | 文件名为 `md5(内容)前 12 位_6 位随机[_角色].ext`。内容不变则名字不变 → 可以下发 `immutable` 长缓存 |
|
||||||
|
| **门面 + 驱动** | 页面只调用 `lib/storage.php` 门面(`storageUrl` / `storageExists` / `storagePutFile` / `storageDelete` / `storageRead` / `storageAccessMode` …),不直接碰 SDK。加云厂商 = 加一个 `lib/storage/*.php` 驱动 |
|
||||||
|
| **密钥加密落库** | 云厂商 AccessKey 以 `enc:v1:gcm:` 密文存进 `settings` 表,密钥文件 `db/.settings.key`。**该文件丢失后密文不可恢复**,只能重新填写密钥。用实例角色(免密钥)则不存在这个问题 |
|
||||||
|
| **对象前缀** | 桶内可再套一层目录(如 `movielog/`)。前缀只在驱动层拼接,库中键始终干净。同一个桶给多个站点用时靠它隔离 |
|
||||||
|
|
||||||
|
### 2. 两种图片访问方式:怎么选
|
||||||
|
|
||||||
|
| | **直连存储**(direct) | **服务端代理**(proxy) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 图片 URL | `https://<bucket>.oss-….aliyuncs.com/movielog/uploads/…` | `/media.php?k=uploads/…`(同源) |
|
||||||
|
| 桶权限要求 | **必须公共读**(或对每个对象签名,但那样 URL 会过期) | **可以私有**,服务器用实例角色/AK 签名读取 |
|
||||||
|
| 防盗链白名单 | 需要包含本站域名,或保持「允许空 Referer」(本项目统一下发 `Referrer-Policy: same-origin`,跨域请求不带 Referer) | 不需要(浏览器根本不访问对象存储) |
|
||||||
|
| 跨域 CORS | 只有想在浏览器里**读像素**(裁剪)时才需要 | **完全不需要**(同源) |
|
||||||
|
| 谁能看到图 | 任何拿到 URL 的人 | 必须登录本站 |
|
||||||
|
| 流量路径 | 浏览器 ⇄ 对象存储(不走服务器带宽) | 浏览器 ⇄ 你的服务器 ⇄ 对象存储(**双倍流量、占服务器带宽**) |
|
||||||
|
| 额外延迟 | 无 | 每张图多一次服务器到存储的往返(同地域约 10–30 ms;跨地域约 60–100 ms) |
|
||||||
|
| 适用 | 对性能敏感、图片可公开、愿意维护桶策略 | **希望桶保持私有**、不想维护防盗链/CORS、图片需要登录才可见 |
|
||||||
|
|
||||||
|
> **建议**:个人/家庭站点优先选**服务端代理**——桶私有、免防盗链、免 CORS、裁剪天然可用。只有当图片确实可以公开、且希望省下服务器中转带宽与流量时,才用直连。
|
||||||
|
>
|
||||||
|
> 选择位置:**系统设置 → 图片访问方式**。切换后无需重启,立即生效(回滚就是切回另一项)。
|
||||||
|
|
||||||
|
### 3. 直连存储:对象存储必须怎么配(重点)
|
||||||
|
|
||||||
|
直连模式下,图片是**浏览器直接去对象存储取**的。因此下面每一项都会直接影响「图片能不能显示」。逐项过一遍。
|
||||||
|
|
||||||
|
#### ① 桶读写权限:必须允许匿名读取
|
||||||
|
|
||||||
|
浏览器请求对象存储时**不带任何身份**,所以桶(或该目录下的对象)必须允许匿名 GET。
|
||||||
|
|
||||||
|
| 云 | 控制台位置 | 要选的值 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 阿里云 OSS | Bucket → **读写权限**(ACL) | **公共读** |
|
||||||
|
| 腾讯云 COS | 存储桶 → **权限管理** → 访问权限 | **公有读私有写** |
|
||||||
|
| 七牛 Kodo | 空间设置 → 访问控制 | **公开** |
|
||||||
|
|
||||||
|
⚠️ 改成公共读意味着**任何人拿到 URL 就能看图**。如果存的是私人内容,请用服务端代理而不是直连。
|
||||||
|
|
||||||
|
#### ② 防盗链(Referer 白名单):开了就必须放行本站
|
||||||
|
|
||||||
|
防盗链是对象存储的「只允许特定来源引用」策略。**开了但没把本站域名加进白名单,就会出现最迷惑的现象**:
|
||||||
|
|
||||||
|
> 把图片 URL 直接粘到浏览器地址栏 → **能打开**;同一个 URL 放在页面 `<img>` 里 → **403**。
|
||||||
|
|
||||||
|
原因是浏览器对子资源(`<img>`/`<script>`)会带上当前页面的 `Referer`,而地址栏直接访问不带。**这几乎总是防盗链造成的,不是图片坏了。**
|
||||||
|
|
||||||
|
| 云 | 控制台位置 |
|
||||||
|
| --- | --- |
|
||||||
|
| 阿里云 OSS | Bucket → **数据安全** → 防盗链 |
|
||||||
|
| 腾讯云 COS | 存储桶 → **安全管理** → 防盗链设置 |
|
||||||
|
| 七牛 Kodo | 域名管理 → **防盗链** |
|
||||||
|
|
||||||
|
配置要点:
|
||||||
|
|
||||||
|
1. 把站点域名加进白名单,例如 `movie.example.com`,需要子域时写 `*.example.com`。
|
||||||
|
2. **不要图省事直接关掉防盗链** —— 如果这个桶还给别的用途(比如当备份盘)用,加白名单是加法,更安全。
|
||||||
|
3. 确认「**允许空 Referer**」是开着的(各家默认开启)。本项目 `config.php` 里统一下发了 `Referrer-Policy: same-origin`,即**跨域请求不带 Referer、同源请求照常带**,所以即使白名单里漏配了本站,页面里的图片也能正常显示。这算一层兜底,但**不要拿它替代白名单**。
|
||||||
|
|
||||||
|
#### ③ 跨域 CORS:只有「裁剪 / 读像素」才需要
|
||||||
|
|
||||||
|
这不是防盗链,是**另一个开关**,两者经常被混为一谈。
|
||||||
|
|
||||||
|
浏览器里 `cropper.getCroppedCanvas()` + `canvas.toDataURL()` 需要**读取图片的像素**。如果图片来自别的域名且未获授权,Canvas 会被标记为「被污染」(tainted),读取时抛 `SecurityError`,表现就是「点了裁剪保存没反应 / 弹错误」。
|
||||||
|
|
||||||
|
**要让直连模式下的裁剪可用,必须同时满足两件事**(缺一不可):
|
||||||
|
|
||||||
|
1. **桶上配置 CORS 规则**:来源 `https://你的域名`,方法 `GET`(`HEAD` 建议一起),允许的请求头建议 `*`,暴露的响应头至少 `ETag`、`Content-Length`,缓存时间 600 秒。
|
||||||
|
2. **图片标签声明跨域模式**:`<img src="…" crossorigin="anonymous">`。**只配 CORS 而不加这个属性,Canvas 依然会被污染**;反之只加属性而不配 CORS,图片会**直接加载失败**(比不能裁剪更糟)。
|
||||||
|
|
||||||
|
> ⚠️ **本仓库当前没有给图片加 `crossorigin="anonymous"`**,因此**直连存储模式下裁剪功能不可用**,只能上传原图、不能二次裁剪。
|
||||||
|
> 如果你需要裁剪,最省事的做法是**改用服务端代理**(图片同源,两个条件全免);坚持直连则要自行在 `add.php` / `edit.php` / `persons.php` 的裁剪预览 `<img>` 上加 `crossorigin="anonymous"`,并先在桶上把 CORS 配对。
|
||||||
|
|
||||||
|
#### ④ 强制下载(`x-oss-force-download` / `Content-Disposition: attachment`)
|
||||||
|
|
||||||
|
桶级「强制下载」**不影响 `<img>` 正常显示**(浏览器对子资源忽略该头),但会让「点击图片看原图」变成弹下载框。想恢复成预览就把桶的强制下载关掉。
|
||||||
|
|
||||||
|
#### ⑤ Endpoint 必须与桶所在地域一致
|
||||||
|
|
||||||
|
`oss-cn-beijing.aliyuncs.com` 这类 endpoint 里的地域**必须和桶实际所在地域对应**,否则报
|
||||||
|
`The bucket you are attempting to access must be addressed using the specified endpoint`。
|
||||||
|
Endpoint 在 OSS 控制台「概览」页可以看到,填写时**不要带 `https://`**。
|
||||||
|
|
||||||
|
#### ⑥ 自定义域名 / CDN(可选)
|
||||||
|
|
||||||
|
绑定了自定义域名或 CDN 才填;填了之后图片 URL 会使用该域名。**填一个没绑定的域名会直接报错**(`CnameDenied` / `SecondLevelDomainForbidden`)。七牛云**必须**绑定域名(它不提供默认外链域名)。
|
||||||
|
|
||||||
|
#### ⑦ 直连模式检查清单
|
||||||
|
|
||||||
|
- [ ] 桶 ACL = 公共读 / 公有读私有写 / 公开
|
||||||
|
- [ ] 防盗链白名单包含本站域名,或该桶未开防盗链
|
||||||
|
- [ ] 桶地域与填写的 endpoint 地域一致
|
||||||
|
- [ ] 系统设置页点「测试连通性」→ 提示可读写
|
||||||
|
- [ ] 首页 / 详情 / 编辑预览 / 统计 / 年度报告 / 头像 逐页看图
|
||||||
|
- [ ] 需要裁剪的话:桶 CORS + `<img crossorigin="anonymous">` 两者都到位
|
||||||
|
- [ ] 知道这个桶的图片对任何拿到 URL 的人可见
|
||||||
|
|
||||||
|
### 4. 服务端代理是怎么回事
|
||||||
|
|
||||||
|
图片 URL 变成 `/media.php?k=uploads/posters/xxx.webp`,请求打到本站,由服务器去对象存储取,再**同源**吐给浏览器。因此:桶可以私有、不需要防盗链、不需要 CORS。
|
||||||
|
|
||||||
|
`media.php` 做对的几件事(都值得保留,改动时别退化):
|
||||||
|
|
||||||
|
- **未登录直接 403 纯文本**(刻意不用 302 跳登录页 —— 否则 `<img>` 会拿到一段 HTML,控制台刷满「HTML 当图片解析失败」的噪声,掩盖真正原因)。
|
||||||
|
- **鉴权后立刻 `session_write_close()`**:PHP 的会话文件锁是**排他**的,如果一直持有到脚本结束,浏览器并行为一张张图片开的连接会全部串行等待,页面图片越多越慢(见下方坑 5)。
|
||||||
|
- **对象键白名单**:只允许 `uploads/{posters,tickets,photos,avatars}/` 下的纯文件名,并显式拒绝 `..`。否则 `?k=../config.php` 就是任意文件读取。
|
||||||
|
- **Content-Type 由本地扩展名白名单决定**,绝不采信上游返回的类型。否则桶里若被塞进 `.html` / `.svg`,就会以 `text/html` 在**本站域名下**执行(同源 XSS)。同时下发 `X-Content-Type-Options: nosniff`。
|
||||||
|
- **条件请求透传**:`If-None-Match` / `If-Modified-Since` / `Range` 转给上游,上游回 304 就短路返回,省流量。
|
||||||
|
- **长缓存**:对象名是内容指纹,永不原地变更,所以下发 `Cache-Control: private, max-age=31536000, immutable`(`private` 必须有,因为内容是按登录会话授权的)。
|
||||||
|
|
||||||
|
#### 内网 endpoint 只在与桶同地域时可用
|
||||||
|
|
||||||
|
服务器与桶**在同一地域**时,把请求地址换成 `oss-<地域>-internal.aliyuncs.com` 可以走内网:**流量免费、延迟更低**。设置页 OSS 面板有「服务端读取走内网 endpoint」开关。
|
||||||
|
|
||||||
|
但注意:**内网 endpoint 只在与桶同地域的 ECS 上可路由**。跨地域时 DNS 会照常解析出一个地址(毫秒级,看起来毫无异常),TCP 却**永远连不上**。所以程序会:
|
||||||
|
|
||||||
|
1. 先比**本机 ECS 地域**(实例元数据 `region-id`)与**桶地域**,不同就直接跳过内网(零成本、结论确定);
|
||||||
|
2. 地域相同或无法判定时,才真的发一个签名 `HEAD` 到桶根探测一次(连接超时 2 秒);
|
||||||
|
3. 结论缓存起来,并显示在**系统设置 → 当前状态 →「服务端读取通道」**;
|
||||||
|
4. 万一判断失效,内网尝试只等 2 秒就回退公网(不是默认的 10 秒)。
|
||||||
|
|
||||||
|
### 5. 迁移已有图片到云端
|
||||||
|
|
||||||
|
**系统设置 → 图片存储 → 迁移**:
|
||||||
|
|
||||||
|
1. 先把本地 `uploads/` 与 `db/` **备份一份**(迁移会改写数据库里的图片引用)。
|
||||||
|
2. 迁移是**分批**执行的(前端循环调用 `storage_api.php?action=migrate` 并显示进度),中断了可以直接重跑——已迁移的记录会被跳过,不会重复计数。
|
||||||
|
3. 迁移过程中会把文件重命名为内容指纹名,并**同步改写数据库引用**。
|
||||||
|
4. 迁移完成后**本地文件不会自动删除**,因为读取是「本地优先」的:本地还在就继续走本地,删掉本地文件后才真正走云端。**删本地文件前先全站看图确认。**
|
||||||
|
|
||||||
|
### 6. 踩坑记录(按现象索引)
|
||||||
|
|
||||||
|
#### 坑 1 · 图片在页面上 403,URL 粘到地址栏却能打开
|
||||||
|
**对象存储的防盗链(Referer 白名单)**。浏览器对 `<img>` 会带 `Referer`,地址栏直粘不带。
|
||||||
|
→ 解法见 §3②(加白名单);临时绕法是在统一引导文件下发 `Referrer-Policy: same-origin`(本项目已做);根治是改用服务端代理。
|
||||||
|
**诊断**:`curl` 分别带 / 不带 `Referer` 各打一次,对比状态码,并把 403 的响应体打出来 —— 里面会直接写 `You are denied by bucket referer policy.` 之类的原始判决。
|
||||||
|
|
||||||
|
#### 坑 2 · 迁到云端之后,某些图「整块消失」或 404
|
||||||
|
代码还在把数据库里的**对象键当本地路径**拼:`file_exists(__DIR__.'/'.$path)` 在迁云后必然为假 → **整个图库区块不渲染**(比 404 更隐蔽,因为页面上什么都没有);`'/'.$path` 则会拼出一个不存在的站内路径 → 404。
|
||||||
|
→ 解法:所有地方统一走门面 `storageExists()` / `storageUrl()`。**只要绕过门面直接拼路径,迁云后就一定会出问题**,改动时务必全局搜一遍。
|
||||||
|
|
||||||
|
#### 坑 3 · 防盗链修好了,裁剪还是不能用
|
||||||
|
**裁剪要的是 CORS,不是 Referer。** 两者是不同的开关。
|
||||||
|
→ 见 §3③(桶 CORS + `<img crossorigin>`);或者直接切服务端代理。
|
||||||
|
|
||||||
|
#### 坑 4 · 开了服务端代理后「慢到无法接受」
|
||||||
|
**内网 endpoint 跨地域**:服务器在 `cn-qingdao`、桶在 `cn-beijing`,程序却按桶的地域推导出 `oss-cn-beijing-internal.aliyuncs.com` 并「内网优先」,于是**每张图都先白等满一次连接超时(默认 10 秒)**才回退公网。
|
||||||
|
**这个地址的 DNS 是能解析的**(0.001 秒返回 A 记录),所以从日志上看不出任何异常,只会看到「很慢」。
|
||||||
|
→ 解法见 §4「内网 endpoint 只在与桶同地域时可用」。本仓库已改为「先比地域、结论缓存、内网只等 2 秒」。
|
||||||
|
|
||||||
|
#### 坑 5 · 图片数量越多越慢,慢得不成比例
|
||||||
|
**会话锁**。代理页都要登录,`session_start()` 在请求开头;PHP 的会话文件锁是**排他**的,若持有到脚本结束(而取存储的耗时操作就在锁内),浏览器为同源图片同时开的约 6 条连接会**全部卡在同一把锁上**——总耗时 ≈ 图片数 × 单张耗时。
|
||||||
|
→ 解法:鉴权通过后立刻 `session_write_close()`。本仓库 `media.php` 已处理。
|
||||||
|
|
||||||
|
#### 坑 6 · 控制台刷满「图片解析失败」,但服务端一切正常
|
||||||
|
代理页未登录时返回了 **302 跳登录页**,`<img>` 拿到的是 HTML。
|
||||||
|
→ 解法:图片接口未授权要返回 **403 纯文本**;前端再加一层坏图占位兜底。
|
||||||
|
|
||||||
|
#### 坑 7 · 想当然地平铺上游的 `Content-Type`,等于开了同源 XSS
|
||||||
|
桶里若被放进 `.html` / `.svg`,并且你把它返回的类型原样透传,浏览器会以 `text/html` 在**本站域名下**执行它。
|
||||||
|
→ 解法:**只按扩展名白名单**决定响应类型,并加 `X-Content-Type-Options: nosniff`。
|
||||||
|
|
||||||
|
#### 坑 8 · `?k=../config.php` 能读到任意文件
|
||||||
|
对象键没有校验,`k` 参数直接被拼进存储请求。
|
||||||
|
→ 解法:严格白名单(限定目录前缀 + 字符集 + 拒绝 `..`)。
|
||||||
|
|
||||||
|
#### 坑 9 · JSON 接口突然解析失败,报 `Unexpected token '<'`
|
||||||
|
`display_errors` 开着时,PHP 的 **Warning** 会以 HTML 形式打在 JSON 前面。触发源常常是宝塔等环境注入的 `.user.ini`:`open_basedir=/站点目录/:/tmp/`,此时对 `/etc/ssl/…` 这类**站外路径**调用 `is_file()` 会抛 `open_basedir restriction in effect` 警告。
|
||||||
|
→ 解法:探测系统文件之前**自己先判断 `open_basedir` 允不允许**(`storagePathAllowed()`),并且所有探测加 `@` 抑制。生产环境还应关闭 `display_errors`。
|
||||||
|
|
||||||
|
#### 坑 10 · 用命令行排查时「图片根本不在云上」——假象
|
||||||
|
`db/.settings.key` 的属主是 Web 用户(如 `www`,权限 600)。换一个用户跑 `php` 或 PHP 脚本,**解密失败** → 读出的云存储配置是空的 → 门面回退本地 → 表现为「404、耗时 0 秒」,很容易误判成「对象没上传成功」。
|
||||||
|
→ 解法:**必须以 Web 用户身份**跑诊断代码(临时放一个带随机 token 门禁、用完即删的诊断页,或用 Web 用户执行 CLI)。
|
||||||
|
|
||||||
|
#### 坑 11 · 桶名看错了一个字母,排查了半天
|
||||||
|
Bucket 名称是全局唯一标识,**字母一个都不能错**,而拼错时通常不会给出「你拼错了」这种提示,只会得到 404 / 403(阿里云会回 `NoSuchBucket`)。
|
||||||
|
→ 在响应体里找 `<BucketName>`,或直接以控制台实际显示为准,别凭记忆填写。
|
||||||
|
|
||||||
|
#### 坑 12 · 部署脚本「备份成功」但实际没备份
|
||||||
|
`cp -p` 多级路径(`lib/storage/oss.php`)到备份目录时,**子目录不存在会静默失败**,脚本继续往下走覆盖了线上文件,事后才发现没有可回滚的副本。
|
||||||
|
→ 解法:备份循环里先 `mkdir -p "$BK/$(dirname "$f")"`;备份后**递归统计文件数并与预期条数比对**,数量不符立即中止(这是覆盖前的最后一道止损闸);覆盖后再逐文件比对 sha256。
|
||||||
|
|
||||||
|
### 7. 排障手册(可直接复制的命令)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ① 判断是不是防盗链:A / B 对比(把 URL 换成你的)
|
||||||
|
URL="https://<bucket>.oss-cn-xxx.aliyuncs.com/movielog/uploads/posters/xxx.webp"
|
||||||
|
curl -sS -o /dev/null -w "无 Referer %{http_code}\n" "$URL"
|
||||||
|
curl -sS -o /dev/null -w "带本站 Referer %{http_code}\n" -H "Referer: https://你的域名/" "$URL"
|
||||||
|
curl -sS -o /dev/null -w "带陌生 Referer %{http_code}\n" -H "Referer: https://evil.example.com/" "$URL"
|
||||||
|
# 403 时把响应体打出来,里面有云厂商的原始判决
|
||||||
|
curl -sS -H "Referer: https://你的域名/" "$URL"
|
||||||
|
|
||||||
|
# ② 是否配了 CORS(裁剪用)
|
||||||
|
curl -sS -D - -o /dev/null -H "Origin: https://你的域名" "$URL" | grep -i "access-control"
|
||||||
|
|
||||||
|
# ③ 在服务器上量内外网可达性与耗时(诊断「代理很慢」第一步)
|
||||||
|
curl -o /dev/null -s -w 'dns=%{time_namelookup} conn=%{time_connect} tls=%{time_appconnect} total=%{time_total} code=%{http_code}\n' \
|
||||||
|
--max-time 20 https://<bucket>.oss-cn-xxx-internal.aliyuncs.com/
|
||||||
|
curl -o /dev/null -s -w 'public conn=%{time_connect} tls=%{time_appconnect} total=%{time_total} code=%{http_code}\n' \
|
||||||
|
--max-time 20 https://<bucket>.oss-cn-xxx.aliyuncs.com/
|
||||||
|
|
||||||
|
# ④ 本机 ECS 地域(与桶地域对比,不同就别走内网)
|
||||||
|
curl -s --max-time 5 http://100.100.100.200/latest/meta-data/region-id
|
||||||
|
|
||||||
|
# ⑤ 端到端计时:在**服务器本机**打自己的站点,排除公网 RTT
|
||||||
|
# (Cookie 用浏览器里已登录的 PHPSESSID)
|
||||||
|
curl -sk -H "Host: 你的域名" -H "Cookie: PHPSESSID=xxxx" -o /dev/null \
|
||||||
|
-w "ttfb=%{time_starttransfer} total=%{time_total} size=%{size_download}\n" \
|
||||||
|
"https://127.0.0.1/media.php?k=uploads/posters/xxx.webp"
|
||||||
|
```
|
||||||
|
|
||||||
|
**读结果的方法**:`time_namelookup` 很快但 `time_connect` 拿不到 → 目标不可路由(典型就是跨地域内网);
|
||||||
|
单张图服务端耗时应当在**几百毫秒内**;若接近 10 秒、或总耗时随图片数量线性增长,回去看坑 4 与坑 5。
|
||||||
|
|
||||||
|
### 8. 部署与运维注意
|
||||||
|
|
||||||
|
- **改桶权限前先确认代码已上线**:直连↔代理的切换顺序建议「先部署代码 → 切访问方式 → 确认图片正常 → 最后才改桶 ACL / 防盗链」,每一步都可单独回滚。
|
||||||
|
- **代理模式会占服务器出网带宽**:图片流量走一遍服务器。用「阿里云内网 endpoint + 同地域 ECS」可以免掉云端到服务器的流量费;跨地域则要计费。
|
||||||
|
- **`uploads/` 与 `db/` 不要暴露**:见《安全注意事项》。
|
||||||
|
- **迁移/删除本地文件是有损操作**:先备份 `db/` 与 `uploads/`,再动手。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 安全注意事项
|
## 安全注意事项
|
||||||
|
|
||||||
部署到公网前请务必处理以下事项:
|
部署到公网前请务必处理以下事项:
|
||||||
@@ -268,6 +548,14 @@ CSV **共 9 列,顺序固定**:
|
|||||||
```
|
```
|
||||||
7. **限制上传目录执行权限**:确保 `uploads/` 下不允许执行 PHP。
|
7. **限制上传目录执行权限**:确保 `uploads/` 下不允许执行 PHP。
|
||||||
|
|
||||||
|
### 与对象存储相关的额外事项
|
||||||
|
|
||||||
|
8. **桶不要长期开公共读**:直连存储要求桶允许匿名读取,等于**任何拿到 URL 的人都能看图**。私人 / 家庭内容请改用「服务端代理」,把桶设为**私有**——那才是真正的访问控制。
|
||||||
|
9. **防盗链不是访问控制**:`Referer` 是客户端可伪造的普通请求头,`curl -H Referer` 就能绕过。它只能挡住「别的网站盗链」,不能保护内容。别把它当成安全边界。
|
||||||
|
10. **保护好 `db/.settings.key`**:云厂商 AccessKey 以密文存在数据库里,这个文件是唯一的解密密钥。应随 `db/` 一起纳入访问控制与备份策略;优先改用实例角色(RAM / CAM 角色)**免密钥**方案,从根上不落长期密钥。
|
||||||
|
11. **`media.php` 是新增的对外接口**:它按 `k` 参数从对象存储取对象。仓库里已做**对象键白名单**(防目录穿越)与**类型白名单**(防同源 XSS)两层校验,改动此文件时**务必保留**,不要把 `$_GET['k']` 直接拼进存储请求。
|
||||||
|
12. **不要把上游 `Content-Type` 原样透传**:桶里若有 `.html` / `.svg`,透传类型会让它在本站域名下执行(同源 XSS)。始终按扩展名白名单决定,并加 `X-Content-Type-Options: nosniff`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 已知限制与待改进
|
## 已知限制与待改进
|
||||||
@@ -278,6 +566,10 @@ CSV **共 9 列,顺序固定**:
|
|||||||
- `config.php` 的 `initDB()` 在每次请求都会跑一遍建表与迁移,数据量或并发上升后可改为一次性迁移脚本。
|
- `config.php` 的 `initDB()` 在每次请求都会跑一遍建表与迁移,数据量或并发上升后可改为一次性迁移脚本。
|
||||||
- 无「回收站」,删除电影与其图片文件不可恢复。
|
- 无「回收站」,删除电影与其图片文件不可恢复。
|
||||||
- `assets/placeholder-poster.jpg`、`assets/default-avatar.png` 为代码中的兜底引用,当前仓库未包含这两个文件;由于页面在检测到 placeholder 时会改用 CSS 占位块渲染,正常使用不受影响。
|
- `assets/placeholder-poster.jpg`、`assets/default-avatar.png` 为代码中的兜底引用,当前仓库未包含这两个文件;由于页面在检测到 placeholder 时会改用 CSS 占位块渲染,正常使用不受影响。
|
||||||
|
- **「直连存储」模式下裁剪功能不可用**:裁剪需要读取图片像素,直连时图片来自对象存储域名,必须同时满足「桶配 CORS」与「`<img crossorigin="anonymous">`」两个条件才能读像素;本仓库未加 `crossorigin` 属性,因此直连模式下只能上传、不能二次裁剪。需要裁剪请改用「服务端代理」(同源,两个条件全免)。详见《图片存储与对象存储》§3③。
|
||||||
|
- **服务端代理模式下所有图片流量经服务器中转**,会占用服务器出网带宽,并给每张图增加一次服务器↔存储的往返(同地域约 10–30 ms,跨地域约 60–100 ms)。图片量大且可公开时可考虑改回直连。
|
||||||
|
- **内网 endpoint 只在服务器与桶同地域时生效**。跨地域部署下这条免费通道用不了,程序会自动跳过并走公网(详见 §4)。
|
||||||
|
- 迁移到云端后**本地文件不会自动删除**,需要自行确认后再清理;本地优先策略意味着本地文件存在时始终读本地,云端副本仅作备份。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -470,7 +470,7 @@ $weekday_values = array_values($stats['weekday_distribution']);
|
|||||||
<?php foreach ($stats['my_movies'] as $movie): ?>
|
<?php foreach ($stats['my_movies'] as $movie): ?>
|
||||||
<div class="movie-entry">
|
<div class="movie-entry">
|
||||||
<?php
|
<?php
|
||||||
$poster = $movie['poster_path'] ? '/' . $movie['poster_path'] : '/assets/placeholder-poster.jpg';
|
$poster = posterUrl($movie['poster_path']); // 本地/对象存储自动解析,无海报时回退占位图
|
||||||
?>
|
?>
|
||||||
<img class="poster" src="<?= $poster ?>" alt="<?= h($movie['title']) ?>">
|
<img class="poster" src="<?= $poster ?>" alt="<?= h($movie['title']) ?>">
|
||||||
<div class="info">
|
<div class="info">
|
||||||
@@ -578,5 +578,6 @@ $weekday_values = array_values($stats['weekday_distribution']);
|
|||||||
});
|
});
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</script>
|
</script>
|
||||||
|
<script src="js/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -11,6 +11,13 @@ ini_set('display_errors', 1);
|
|||||||
|
|
||||||
session_start();
|
session_start();
|
||||||
|
|
||||||
|
// 跨域资源引用策略:本站页面加载对象存储(OSS / COS / 七牛)等第三方域名的图片时,
|
||||||
|
// 浏览器不发送 Referer,避免被云厂商的防盗链(bucket referer policy)拦成 403。
|
||||||
|
// 同源请求(本站自身的页面与接口)仍照常携带 Referer,不影响任何安全检查。
|
||||||
|
if (!headers_sent()) {
|
||||||
|
header('Referrer-Policy: same-origin');
|
||||||
|
}
|
||||||
|
|
||||||
// 项目根目录(lib/ 下的模块统一用它拼接绝对路径)
|
// 项目根目录(lib/ 下的模块统一用它拼接绝对路径)
|
||||||
define('ROOT_DIR', __DIR__);
|
define('ROOT_DIR', __DIR__);
|
||||||
|
|
||||||
@@ -33,6 +40,8 @@ foreach ([POSTER_DIR, TICKET_DIR, PHOTO_DIR, AVATAR_DIR] as $dir) {
|
|||||||
|
|
||||||
// ===== 功能模块 =====
|
// ===== 功能模块 =====
|
||||||
require_once ROOT_DIR . '/lib/db.php'; // 数据库连接与初始化
|
require_once ROOT_DIR . '/lib/db.php'; // 数据库连接与初始化
|
||||||
|
require_once ROOT_DIR . '/lib/settings.php'; // 系统设置读写
|
||||||
|
require_once ROOT_DIR . '/lib/storage.php'; // 对象存储门面(含各云厂商驱动)
|
||||||
require_once ROOT_DIR . '/lib/helpers.php'; // 通用工具函数
|
require_once ROOT_DIR . '/lib/helpers.php'; // 通用工具函数
|
||||||
require_once ROOT_DIR . '/lib/auth.php'; // 登录校验与账号管理
|
require_once ROOT_DIR . '/lib/auth.php'; // 登录校验与账号管理
|
||||||
require_once ROOT_DIR . '/lib/movies.php'; // 电影 / 影院 / 搜索
|
require_once ROOT_DIR . '/lib/movies.php'; // 电影 / 影院 / 搜索
|
||||||
|
|||||||
+8
-7
@@ -345,13 +345,14 @@ $admin = getCurrentAdmin();
|
|||||||
|
|
||||||
<!-- 图片展示(票根 + 多张观影照片) -->
|
<!-- 图片展示(票根 + 多张观影照片) -->
|
||||||
<?php
|
<?php
|
||||||
$hasTicket = $movie['ticket_path'] && file_exists(__DIR__ . '/' . $movie['ticket_path']);
|
// 统一走存储门面判断/生成地址:本地有文件用本地,已迁到对象存储的用云端 URL
|
||||||
|
$hasTicket = storageExists($movie['ticket_path'] ?? '');
|
||||||
$all_photos = [];
|
$all_photos = [];
|
||||||
if ($movie['photo_path'] && file_exists(__DIR__ . '/' . $movie['photo_path'])) {
|
if (storageExists($movie['photo_path'] ?? '')) {
|
||||||
$all_photos[] = ['path' => $movie['photo_path'], 'label' => '📸 旧照片'];
|
$all_photos[] = ['path' => $movie['photo_path'], 'label' => '📸 旧照片'];
|
||||||
}
|
}
|
||||||
foreach ($movie['photos'] as $p) {
|
foreach ($movie['photos'] as $p) {
|
||||||
if (file_exists(__DIR__ . '/' . $p['photo_path'])) {
|
if (storageExists($p['photo_path'] ?? '')) {
|
||||||
$all_photos[] = ['path' => $p['photo_path'], 'label' => '📸 观影照片'];
|
$all_photos[] = ['path' => $p['photo_path'], 'label' => '📸 观影照片'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -361,14 +362,14 @@ $admin = getCurrentAdmin();
|
|||||||
<h3>🖼️ 影像记录 <span class="badge"><?= count($all_photos) + ($hasTicket ? 1 : 0) ?> 张</span></h3>
|
<h3>🖼️ 影像记录 <span class="badge"><?= count($all_photos) + ($hasTicket ? 1 : 0) ?> 张</span></h3>
|
||||||
<div class="gallery-grid">
|
<div class="gallery-grid">
|
||||||
<?php if ($hasTicket): ?>
|
<?php if ($hasTicket): ?>
|
||||||
<div class="gallery-item" onclick="window.open('<?= '/' . $movie['ticket_path'] ?>', '_blank')">
|
<div class="gallery-item" onclick="window.open('<?= storageUrl($movie['ticket_path']) ?>', '_blank')">
|
||||||
<img src="<?= '/' . $movie['ticket_path'] ?>" alt="电影票">
|
<img src="<?= storageUrl($movie['ticket_path']) ?>" alt="电影票">
|
||||||
<span class="label-badge">🎫 电影票</span>
|
<span class="label-badge">🎫 电影票</span>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php foreach ($all_photos as $photo): ?>
|
<?php foreach ($all_photos as $photo): ?>
|
||||||
<div class="gallery-item" onclick="window.open('<?= '/' . $photo['path'] ?>', '_blank')">
|
<div class="gallery-item" onclick="window.open('<?= storageUrl($photo['path']) ?>', '_blank')">
|
||||||
<img src="<?= '/' . $photo['path'] ?>" alt="观影照片">
|
<img src="<?= storageUrl($photo['path']) ?>" alt="观影照片">
|
||||||
<span class="label-badge"><?= $photo['label'] ?></span>
|
<span class="label-badge"><?= $photo['label'] ?></span>
|
||||||
</div>
|
</div>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
|
|||||||
@@ -328,10 +328,10 @@ if (empty($current_person_ids)) {
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>电影海报</label>
|
<label>电影海报</label>
|
||||||
<div>
|
<div>
|
||||||
<?php if ($movie['poster_path'] && file_exists(__DIR__ . '/' . $movie['poster_path'])): ?>
|
<?php if (storageExists($movie['poster_path'] ?? '')): ?>
|
||||||
<img src="/<?= $movie['poster_path'] ?>" class="image-preview" id="posterPreview">
|
<img src="<?= storageUrl($movie['poster_path']) ?>" class="image-preview" id="posterPreview">
|
||||||
<br>
|
<br>
|
||||||
<button type="button" class="crop-btn" onclick="openCrop('poster', '/<?= $movie['poster_path'] ?>')">✂️ 裁剪海报</button>
|
<button type="button" class="crop-btn" onclick="openCrop('poster', '<?= storageUrl($movie['poster_path']) ?>')">✂️ 裁剪海报</button>
|
||||||
<input type="hidden" name="poster_crop" id="poster_crop">
|
<input type="hidden" name="poster_crop" id="poster_crop">
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<span style="color:var(--text-secondary);font-size:14px;">暂无海报</span>
|
<span style="color:var(--text-secondary);font-size:14px;">暂无海报</span>
|
||||||
@@ -347,10 +347,10 @@ if (empty($current_person_ids)) {
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>电影票扫描件</label>
|
<label>电影票扫描件</label>
|
||||||
<div>
|
<div>
|
||||||
<?php if ($movie['ticket_path'] && file_exists(__DIR__ . '/' . $movie['ticket_path'])): ?>
|
<?php if (storageExists($movie['ticket_path'] ?? '')): ?>
|
||||||
<img src="/<?= $movie['ticket_path'] ?>" class="image-preview" id="ticketPreview">
|
<img src="<?= storageUrl($movie['ticket_path']) ?>" class="image-preview" id="ticketPreview">
|
||||||
<br>
|
<br>
|
||||||
<button type="button" class="crop-btn" onclick="openCrop('ticket', '/<?= $movie['ticket_path'] ?>')">✂️ 裁剪票根</button>
|
<button type="button" class="crop-btn" onclick="openCrop('ticket', '<?= storageUrl($movie['ticket_path']) ?>')">✂️ 裁剪票根</button>
|
||||||
<input type="hidden" name="ticket_crop" id="ticket_crop">
|
<input type="hidden" name="ticket_crop" id="ticket_crop">
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<span style="color:var(--text-secondary);font-size:14px;">暂无票根</span>
|
<span style="color:var(--text-secondary);font-size:14px;">暂无票根</span>
|
||||||
@@ -369,8 +369,8 @@ if (empty($current_person_ids)) {
|
|||||||
<?php if (!empty($movie['photos'])): ?>
|
<?php if (!empty($movie['photos'])): ?>
|
||||||
<?php foreach ($movie['photos'] as $photo): ?>
|
<?php foreach ($movie['photos'] as $photo): ?>
|
||||||
<div class="photo-item" id="photo_<?= $photo['id'] ?>">
|
<div class="photo-item" id="photo_<?= $photo['id'] ?>">
|
||||||
<img src="/<?= $photo['photo_path'] ?>" class="image-preview" style="display:block;">
|
<img src="<?= storageUrl($photo['photo_path']) ?>" class="image-preview" style="display:block;">
|
||||||
<button type="button" class="crop-btn" onclick="openCrop('photo_<?= $photo['id'] ?>', '/<?= $photo['photo_path'] ?>')">✂️ 裁剪</button>
|
<button type="button" class="crop-btn" onclick="openCrop('photo_<?= $photo['id'] ?>', '<?= storageUrl($photo['photo_path']) ?>')">✂️ 裁剪</button>
|
||||||
<input type="hidden" name="photo_crop[<?= $photo['id'] ?>]" id="photo_crop_<?= $photo['id'] ?>">
|
<input type="hidden" name="photo_crop[<?= $photo['id'] ?>]" id="photo_crop_<?= $photo['id'] ?>">
|
||||||
</div>
|
</div>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
|
|||||||
+21
@@ -48,3 +48,24 @@ function toggleNewCinema() {
|
|||||||
var group = document.getElementById('newCinemaGroup');
|
var group = document.getElementById('newCinemaGroup');
|
||||||
group.style.display = select.value === 'new' ? 'block' : 'none';
|
group.style.display = select.value === 'new' ? 'block' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 图片破图回退 ----------
|
||||||
|
|
||||||
|
// 服务端代理模式下,图片需要登录才能读取(media.php 未登录返回 403)。
|
||||||
|
// 会话过期后页面上的图片会加载失败——统一替换为内联占位图,避免满屏破图。
|
||||||
|
// 注意:error 事件不冒泡,必须用捕获阶段监听;占位图用 data URI,不依赖任何文件。
|
||||||
|
(function () {
|
||||||
|
var PLACEHOLDER = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="160" height="200">' +
|
||||||
|
'<rect width="100%" height="100%" fill="#eceef2"/>' +
|
||||||
|
'<text x="50%" y="50%" font-size="13" fill="#9aa0a6" text-anchor="middle" ' +
|
||||||
|
'dominant-baseline="middle">图片不可用</text></svg>');
|
||||||
|
|
||||||
|
document.addEventListener('error', function (e) {
|
||||||
|
var el = e.target;
|
||||||
|
if (!el || el.tagName !== 'IMG') return;
|
||||||
|
if (el.dataset.fallbackApplied === '1') return; // 防死循环
|
||||||
|
el.dataset.fallbackApplied = '1';
|
||||||
|
el.src = PLACEHOLDER;
|
||||||
|
}, true);
|
||||||
|
})();
|
||||||
|
|||||||
@@ -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 cinemas ADD COLUMN color TEXT DEFAULT '#888888'"); } catch (PDOException $e) {}
|
||||||
try { $db->exec("ALTER TABLE admins ADD COLUMN avatar TEXT"); } 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
|
// 文件: lib/helpers.php
|
||||||
// 说明: 通用工具函数(输出转义、文件名、颜色、上传文件读写、图片 URL)
|
// 说明: 通用工具函数(输出转义、文件名、颜色、上传文件读写、图片 URL)
|
||||||
|
// 图片的读写与 URL 生成一律通过 lib/storage.php 收口,
|
||||||
|
// 以便在“本地硬盘”与“对象存储”之间切换。
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
// 允许上传的图片扩展名
|
// 允许上传的图片扩展名
|
||||||
@@ -14,12 +16,42 @@ function h($str) {
|
|||||||
return htmlspecialchars($str, ENT_QUOTES, 'UTF-8');
|
return htmlspecialchars($str, ENT_QUOTES, 'UTF-8');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成唯一文件名
|
// 生成带「内容指纹」的文件名:<md5前12位>_<6位随机>[_角色].ext
|
||||||
function uniqueFilename($original) {
|
//
|
||||||
|
// 设计:
|
||||||
|
// - 指纹段取图片内容的 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));
|
$ext = strtolower(pathinfo($original, PATHINFO_EXTENSION));
|
||||||
if (!in_array($ext, allowedImageExts())) {
|
if (!in_array($ext, allowedImageExts())) {
|
||||||
$ext = 'jpg';
|
$ext = 'jpg';
|
||||||
}
|
}
|
||||||
|
if ($binary !== null && $binary !== '') {
|
||||||
|
return fingerprintedFilename($binary, $ext);
|
||||||
|
}
|
||||||
return date('YmdHis') . '_' . uniqid() . '.' . $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;
|
return preg_match('/^#[a-fA-F0-9]{6}$/', $color) ? $color : $fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除项目内已存在的上传文件(文件不存在时静默跳过)
|
// 把绝对目录转换成相对 ROOT_DIR 的键前缀(如 /x/uploads/posters/ → uploads/posters/)
|
||||||
function deleteStoredFile($relative_path) {
|
function relPathOfDir($absDir) {
|
||||||
if (!empty($relative_path)) {
|
$rel = str_replace('\\', '/', (string)$absDir);
|
||||||
$full = ROOT_DIR . '/' . $relative_path;
|
$root = rtrim(str_replace('\\', '/', ROOT_DIR), '/') . '/';
|
||||||
if (file_exists($full)) {
|
if (strpos($rel, $root) === 0) {
|
||||||
return unlink($full);
|
$rel = substr($rel, strlen($root));
|
||||||
}
|
}
|
||||||
}
|
return rtrim($rel, '/') . '/';
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理单个图片上传字段,成功返回相对路径,失败返回 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) {
|
function uploadImageFile($field, $target_dir, $relative_prefix) {
|
||||||
if (!isset($_FILES[$field]) || $_FILES[$field]['error'] !== UPLOAD_ERR_OK) {
|
if (!isset($_FILES[$field]) || $_FILES[$field]['error'] !== UPLOAD_ERR_OK) {
|
||||||
return null;
|
return null;
|
||||||
@@ -49,11 +87,15 @@ function uploadImageFile($field, $target_dir, $relative_prefix) {
|
|||||||
if (!in_array($ext, allowedImageExts())) {
|
if (!in_array($ext, allowedImageExts())) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
$filename = uniqueFilename($_FILES[$field]['name']);
|
$tmpPath = $_FILES[$field]['tmp_name'];
|
||||||
if (!move_uploaded_file($_FILES[$field]['tmp_name'], $target_dir . $filename)) {
|
$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 null;
|
||||||
}
|
}
|
||||||
return $relative_prefix . $filename;
|
return $key;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理多张图片上传字段(如 photos[] / new_photos[]),写入 movie_photos 表
|
// 处理多张图片上传字段(如 photos[] / new_photos[]),写入 movie_photos 表
|
||||||
@@ -71,27 +113,31 @@ function uploadMultiplePhotos($field, $movie_id) {
|
|||||||
if (!in_array($ext, allowedImageExts())) {
|
if (!in_array($ext, allowedImageExts())) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$filename = uniqueFilename($files['name'][$i]);
|
$tmpPath = $files['tmp_name'][$i];
|
||||||
if (!move_uploaded_file($files['tmp_name'][$i], PHOTO_DIR . $filename)) {
|
$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;
|
continue;
|
||||||
}
|
}
|
||||||
$stmt = $db->prepare("INSERT INTO movie_photos (movie_id, photo_path) VALUES (?, ?)");
|
$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) {
|
function posterUrl($path) {
|
||||||
if ($path && file_exists(ROOT_DIR . '/' . $path)) {
|
if (!storageExists($path)) {
|
||||||
return '/' . $path;
|
|
||||||
}
|
|
||||||
return '/assets/placeholder-poster.jpg';
|
return '/assets/placeholder-poster.jpg';
|
||||||
}
|
}
|
||||||
|
return storageUrl($path);
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 头像处理 =====
|
// ===== 头像处理 =====
|
||||||
function getAvatarUrl($avatar_path, $name = '') {
|
function getAvatarUrl($avatar_path, $name = '') {
|
||||||
if ($avatar_path && file_exists(ROOT_DIR . '/' . $avatar_path)) {
|
if (storageExists($avatar_path)) {
|
||||||
return '/' . $avatar_path;
|
return storageUrl($avatar_path);
|
||||||
}
|
}
|
||||||
if (!empty($name)) {
|
if (!empty($name)) {
|
||||||
$letter = mb_substr($name, 0, 1, 'UTF-8');
|
$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);
|
$base64_data = substr($base64_data, strpos($base64_data, 'base64,') + 7);
|
||||||
}
|
}
|
||||||
$binary = base64_decode($base64_data);
|
$binary = base64_decode($base64_data);
|
||||||
if ($binary === false) return false;
|
if ($binary === false || $binary === '') return false;
|
||||||
|
|
||||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
$mime = storageMimeFromContents($binary);
|
||||||
$mime = finfo_buffer($finfo, $binary);
|
|
||||||
finfo_close($finfo);
|
|
||||||
|
|
||||||
$ext_map = [
|
$ext_map = [
|
||||||
'image/jpeg' => 'jpg',
|
'image/jpeg' => 'jpg',
|
||||||
@@ -123,9 +167,9 @@ function saveBase64Image($base64_data, $save_dir, $prefix = 'avatar') {
|
|||||||
if (!isset($ext_map[$mime])) return false;
|
if (!isset($ext_map[$mime])) return false;
|
||||||
$ext = $ext_map[$mime];
|
$ext = $ext_map[$mime];
|
||||||
|
|
||||||
$filename = date('YmdHis') . '_' . uniqid() . '_' . $prefix . '.' . $ext;
|
$filename = fingerprintedFilename($binary, $ext, $prefix);
|
||||||
$filepath = $save_dir . $filename;
|
$key = relPathOfDir($save_dir) . $filename;
|
||||||
if (file_put_contents($filepath, $binary) === false) return false;
|
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'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<?php
|
||||||
|
// ============================================
|
||||||
|
// 文件: media.php
|
||||||
|
// 说明: 图片服务端代理。
|
||||||
|
// 访问方式设为「服务端代理」时,storageUrl() 会把远端图片指向这里:
|
||||||
|
// 由服务器凭 ECS 实例角色 / AccessKey 签名读取对象后**同源回吐**。
|
||||||
|
// 好处:桶可以设为「私有」(无需公共读、无需防盗链);且因为同源交付,
|
||||||
|
// 前端裁剪(Canvas 读像素)不再受跨域限制。
|
||||||
|
// 用法: /media.php?k=uploads/posters/xxxxxxxxxxxx_xxxxxx.webp
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
require_once 'config.php';
|
||||||
|
|
||||||
|
// ---------- 未登录一律拒绝 ----------
|
||||||
|
// 这里刻意不用 checkLogin():它会 302 跳到 login.php,而 <img> 拿到 HTML 只会变成破图。
|
||||||
|
// 返回 403 纯文本,前端 js/main.js 会把它替换成占位图。
|
||||||
|
if (empty($_SESSION['admin_id'])) {
|
||||||
|
http_response_code(403);
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
echo '未登录';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 立刻释放会话锁 ----------
|
||||||
|
// PHP 的会话文件锁是**排他**的。若一直持有到脚本结束,同一个浏览器并行发起的
|
||||||
|
// 每张图片请求都会串行等待(浏览器通常同时开 6 条连接,全部卡在同一把锁上),
|
||||||
|
// 页面图片越多越慢——每张图的取回耗时被乘上图片数量。
|
||||||
|
// 本页后续逻辑(白名单 → 读存储 → 回吐)不再需要读写会话,所以这里就放开。
|
||||||
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||||
|
session_write_close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 对象键白名单 ----------
|
||||||
|
// 只允许 uploads/ 下四个图片目录里的「纯文件名」。严格限制可同时挡住:
|
||||||
|
// · 目录穿越(../../db/movies.db、../config.php)
|
||||||
|
// · absolute path / 协议前缀 / 查询串注入
|
||||||
|
$key = isset($_GET['k']) ? (string)$_GET['k'] : '';
|
||||||
|
if (!preg_match('#^uploads/(?:posters|tickets|photos|avatars)/[A-Za-z0-9][A-Za-z0-9._-]*$#', $key)
|
||||||
|
|| strpos($key, '..') !== false) {
|
||||||
|
http_response_code(404);
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
echo '对象不存在';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 条件请求头透传给上游 ----------
|
||||||
|
// 透传 If-None-Match / If-Modified-Since 后,命中缓存时 OSS 直接返回 304,
|
||||||
|
// 不必把图片字节拉回来;Range 一并透传(为将来的视频留路)。
|
||||||
|
$conditional = [];
|
||||||
|
foreach ([
|
||||||
|
'HTTP_IF_NONE_MATCH' => 'If-None-Match',
|
||||||
|
'HTTP_IF_MODIFIED_SINCE' => 'If-Modified-Since',
|
||||||
|
'HTTP_RANGE' => 'Range',
|
||||||
|
] as $srvKey => $headerName) {
|
||||||
|
if (!empty($_SERVER[$srvKey])) {
|
||||||
|
$conditional[] = $headerName . ': ' . $_SERVER[$srvKey];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$res = storageRead($key, $conditional);
|
||||||
|
|
||||||
|
// ---------- 上游 304:原样短路 ----------
|
||||||
|
if ((int)($res['status'] ?? 0) === 304) {
|
||||||
|
http_response_code(304);
|
||||||
|
if (!empty($res['headers']['etag'])) header('ETag: ' . $res['headers']['etag']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 读取失败 ----------
|
||||||
|
if (empty($res['ok'])) {
|
||||||
|
$status = (int)($res['status'] ?? 0);
|
||||||
|
if ($status === 0) {
|
||||||
|
$status = 502; // 连不上存储
|
||||||
|
} elseif ($status < 400 || $status > 599) {
|
||||||
|
$status = 404;
|
||||||
|
}
|
||||||
|
if ($status !== 404) {
|
||||||
|
// 把真实原因留到设置页的「最近错误」里,否则 403/501 会很难排查
|
||||||
|
storageRecordError('media.php 读取 ' . $key . ' 失败:'
|
||||||
|
. trim((string)($res['error'] ?? '')) . '(HTTP ' . $status . ')');
|
||||||
|
}
|
||||||
|
http_response_code($status);
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
echo $status === 501 ? '该存储驱动暂不支持服务端读取' : '对象不存在';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = (string)$res['body'];
|
||||||
|
$srcHeaders = $res['headers'] ?? [];
|
||||||
|
$etag = (string)($srcHeaders['etag'] ?? '');
|
||||||
|
$lastModified = (string)($srcHeaders['last-modified'] ?? '');
|
||||||
|
|
||||||
|
// ---------- 本端 304 判断(本地驱动不会有上游 304) ----------
|
||||||
|
if ($etag !== '' && isset($_SERVER['HTTP_IF_NONE_MATCH'])
|
||||||
|
&& trim((string)$_SERVER['HTTP_IF_NONE_MATCH']) === $etag) {
|
||||||
|
http_response_code(304);
|
||||||
|
header('ETag: ' . $etag);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Content-Type 按扩展名白名单决定 ----------
|
||||||
|
// 绝不平铺上游返回的类型:否则桶里若被塞进 .html/.svg,就会以 text/html 在本站域下执行(XSS)。
|
||||||
|
$ext = strtolower(pathinfo($key, PATHINFO_EXTENSION));
|
||||||
|
$mime = [
|
||||||
|
'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png',
|
||||||
|
'gif' => 'image/gif', 'webp' => 'image/webp', 'bmp' => 'image/bmp',
|
||||||
|
'avif' => 'image/avif', 'ico' => 'image/x-icon',
|
||||||
|
'mp4' => 'video/mp4', 'webm' => 'video/webm', 'mov' => 'video/quicktime',
|
||||||
|
][$ext] ?? 'application/octet-stream';
|
||||||
|
|
||||||
|
// ---------- 状态码与响应头 ----------
|
||||||
|
$contentRange = (string)($srcHeaders['content-range'] ?? '');
|
||||||
|
if ($contentRange !== '' && (int)$res['status'] === 206) {
|
||||||
|
http_response_code(206);
|
||||||
|
header('Content-Range: ' . $contentRange);
|
||||||
|
} else {
|
||||||
|
http_response_code(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Content-Type: ' . $mime);
|
||||||
|
header('Content-Length: ' . strlen($body));
|
||||||
|
header('X-Content-Type-Options: nosniff'); // 禁止内容嗅探
|
||||||
|
header('Content-Disposition: ' . ($mime === 'application/octet-stream' ? 'attachment' : 'inline'));
|
||||||
|
if ($etag !== '') header('ETag: ' . $etag);
|
||||||
|
if ($lastModified !== '') header('Last-Modified: ' . $lastModified);
|
||||||
|
// 文件名是内容指纹(md5 前 12 位 + 随机后缀),内容不会原地变更 → 可长缓存;
|
||||||
|
// private 表示仅当前浏览器可缓存(图片需登录才能读)。
|
||||||
|
header('Cache-Control: private, max-age=31536000, immutable');
|
||||||
|
|
||||||
|
echo $body;
|
||||||
@@ -59,6 +59,10 @@ $current_page = basename($_SERVER['PHP_SELF']);
|
|||||||
<span class="icon">👤</span> 用户管理
|
<span class="icon">👤</span> 用户管理
|
||||||
</a>
|
</a>
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
|
<a href="settings.php" <?= $current_page == 'settings.php' ? 'class="active"' : '' ?>>
|
||||||
|
<span class="icon">⚙️</span> 系统设置
|
||||||
|
</a>
|
||||||
|
<div class="divider"></div>
|
||||||
<a href="logout.php" style="color: #e74c3c;">
|
<a href="logout.php" style="color: #e74c3c;">
|
||||||
<span class="icon">🚪</span> 退出登录
|
<span class="icon">🚪</span> 退出登录
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
+3
-2
@@ -296,7 +296,7 @@ $preset_colors = [
|
|||||||
<label>头像</label>
|
<label>头像</label>
|
||||||
<div class="avatar-upload">
|
<div class="avatar-upload">
|
||||||
<?php if ($edit_data && $edit_data['avatar']): ?>
|
<?php if ($edit_data && $edit_data['avatar']): ?>
|
||||||
<img src="/<?= $edit_data['avatar'] ?>" class="avatar-preview" id="avatarPreview">
|
<img src="<?= storageUrl($edit_data['avatar']) ?>" class="avatar-preview" id="avatarPreview">
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<img src="<?= getAvatarUrl(null, $edit_data['name'] ?? '') ?>" class="avatar-preview" id="avatarPreview">
|
<img src="<?= getAvatarUrl(null, $edit_data['name'] ?? '') ?>" class="avatar-preview" id="avatarPreview">
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
@@ -305,7 +305,7 @@ $preset_colors = [
|
|||||||
</div>
|
</div>
|
||||||
<?php if ($edit_data && $edit_data['avatar']): ?>
|
<?php if ($edit_data && $edit_data['avatar']): ?>
|
||||||
<div class="current-avatar">
|
<div class="current-avatar">
|
||||||
当前头像:<img src="/<?= $edit_data['avatar'] ?>" style="width:30px;height:30px;border-radius:50%;vertical-align:middle;">
|
当前头像:<img src="<?= storageUrl($edit_data['avatar']) ?>" style="width:30px;height:30px;border-radius:50%;vertical-align:middle;">
|
||||||
<a href="javascript:void(0)" onclick="removeAvatar()" style="color:var(--danger, #e74c3c);font-size:13px;">删除</a>
|
<a href="javascript:void(0)" onclick="removeAvatar()" style="color:var(--danger, #e74c3c);font-size:13px;">删除</a>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
@@ -476,5 +476,6 @@ $preset_colors = [
|
|||||||
|
|
||||||
// 另外,如果用户上传了文件但未裁剪直接取消,则不清除文件输入,但不会触发裁剪,所以正常。
|
// 另外,如果用户上传了文件但未裁剪直接取消,则不清除文件输入,但不会触发裁剪,所以正常。
|
||||||
</script>
|
</script>
|
||||||
|
<script src="js/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+839
@@ -0,0 +1,839 @@
|
|||||||
|
<?php
|
||||||
|
// ============================================
|
||||||
|
// 文件: settings.php
|
||||||
|
// 说明: 系统设置 —— 图片存储方式(本地硬盘 / 阿里云 OSS / 腾讯云 COS / 七牛云)
|
||||||
|
// 含各驱动的凭据配置、连通性测试与历史图片迁移。
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
require_once 'config.php';
|
||||||
|
checkLogin();
|
||||||
|
|
||||||
|
$admin = getCurrentAdmin();
|
||||||
|
$message = '';
|
||||||
|
$error = '';
|
||||||
|
|
||||||
|
// 密钥打码显示
|
||||||
|
function maskSecret($value) {
|
||||||
|
$value = (string)$value;
|
||||||
|
$len = strlen($value);
|
||||||
|
if ($len === 0) return '';
|
||||||
|
if ($len <= 8) return str_repeat('*', $len);
|
||||||
|
return substr($value, 0, 4) . str_repeat('*', 6) . substr($value, -4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 保存设置 ----------
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save_storage') {
|
||||||
|
$driver = $_POST['storage_driver'] ?? 'local';
|
||||||
|
if (!in_array($driver, ['local', 'oss', 'cos', 'qiniu'], true)) {
|
||||||
|
$driver = 'local';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 各驱动的字段定义:'secret' 表示敏感字段(留空则不修改)
|
||||||
|
$fieldMap = [
|
||||||
|
'oss' => [
|
||||||
|
'auth_mode' => 'plain',
|
||||||
|
'access_key_id' => 'plain',
|
||||||
|
'access_key_secret' => 'secret',
|
||||||
|
'bucket' => 'plain',
|
||||||
|
'endpoint' => 'plain',
|
||||||
|
'prefix' => 'plain',
|
||||||
|
'ram_role_name' => 'plain',
|
||||||
|
'custom_domain' => 'plain',
|
||||||
|
'internal_endpoint' => 'plain',
|
||||||
|
'https' => 'plain',
|
||||||
|
],
|
||||||
|
'cos' => [
|
||||||
|
'auth_mode' => 'plain',
|
||||||
|
'secret_id' => 'plain',
|
||||||
|
'secret_key' => 'secret',
|
||||||
|
'bucket' => 'plain',
|
||||||
|
'region' => 'plain',
|
||||||
|
'prefix' => 'plain',
|
||||||
|
'cam_role_name' => 'plain',
|
||||||
|
'custom_domain' => 'plain',
|
||||||
|
'https' => 'plain',
|
||||||
|
],
|
||||||
|
'qiniu' => [
|
||||||
|
'access_key' => 'plain',
|
||||||
|
'secret_key' => 'secret',
|
||||||
|
'bucket' => 'plain',
|
||||||
|
'region' => 'plain',
|
||||||
|
'prefix' => 'plain',
|
||||||
|
'custom_domain' => 'plain',
|
||||||
|
'https' => 'plain',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($fieldMap as $d => $fields) {
|
||||||
|
$old = storageConfigFor($d);
|
||||||
|
$in = isset($_POST[$d]) && is_array($_POST[$d]) ? $_POST[$d] : [];
|
||||||
|
$clearSecrets = !empty($in['clear_secrets']);
|
||||||
|
$new = [];
|
||||||
|
|
||||||
|
foreach ($fields as $f => $kind) {
|
||||||
|
$posted = isset($in[$f]) ? trim((string)$in[$f]) : '';
|
||||||
|
if ($kind === 'secret') {
|
||||||
|
if ($clearSecrets) {
|
||||||
|
$new[$f] = '';
|
||||||
|
} else {
|
||||||
|
// 留空表示保持原值
|
||||||
|
$new[$f] = $posted === '' ? (string)($old[$f] ?? '') : $posted;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$new[$f] = $posted !== '' ? $posted : (string)($old[$f] ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 复选框型字段(未勾选时浏览器不提交该字段,必须显式归零而不是沿用旧值)
|
||||||
|
$new['https'] = ($new['https'] ?? '') === '0' ? '0' : '1';
|
||||||
|
if ($d === 'oss') {
|
||||||
|
$new['internal_endpoint'] = ($new['internal_endpoint'] ?? '') === '1' ? '1' : '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
saveStorageConfig($d, $new);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSetting('storage_driver', $driver);
|
||||||
|
|
||||||
|
// 图片访问方式:direct(直连存储)/ proxy(经 media.php 服务端代理读取)
|
||||||
|
$accessMode = ($_POST['storage_access_mode'] ?? 'direct') === 'proxy' ? 'proxy' : 'direct';
|
||||||
|
setSetting('storage_access_mode', $accessMode);
|
||||||
|
|
||||||
|
$message = '设置已保存。当前存储方式:' . (storageDriverLabels()[$driver] ?? $driver)
|
||||||
|
. ';图片访问方式:' . ($accessMode === 'proxy' ? '服务端代理' : '直连存储');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 清除错误提示 ----------
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'clear_storage_error') {
|
||||||
|
storageClearError();
|
||||||
|
$message = '已清除错误提示';
|
||||||
|
}
|
||||||
|
|
||||||
|
$drivers = storageDriverLabels();
|
||||||
|
$current = currentStorageDriver();
|
||||||
|
$configs = [];
|
||||||
|
foreach (['oss', 'cos', 'qiniu'] as $d) {
|
||||||
|
$configs[$d] = storageConfigFor($d);
|
||||||
|
}
|
||||||
|
$lastError = storageLastError();
|
||||||
|
$isRemote = storageIsRemote();
|
||||||
|
$remoteReady = $current !== 'local' && storageConfigComplete($current);
|
||||||
|
|
||||||
|
// 本地图片统计(只读目录,不查库)
|
||||||
|
function localImageStat() {
|
||||||
|
$count = 0; $bytes = 0;
|
||||||
|
$base = UPLOAD_DIR;
|
||||||
|
if (!is_dir($base)) return ['count' => 0, 'bytes' => 0];
|
||||||
|
$it = new RecursiveIteratorIterator(
|
||||||
|
new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS),
|
||||||
|
RecursiveIteratorIterator::LEAVES_ONLY
|
||||||
|
);
|
||||||
|
foreach ($it as $f) {
|
||||||
|
if (!$f->isFile()) continue;
|
||||||
|
if ($f->getFilename() === '.htaccess') continue;
|
||||||
|
$count++;
|
||||||
|
$bytes += $f->getSize();
|
||||||
|
}
|
||||||
|
return ['count' => $count, 'bytes' => $bytes];
|
||||||
|
}
|
||||||
|
$localStat = localImageStat();
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>系统设置 - 电影日志</title>
|
||||||
|
<link rel="stylesheet" href="css/style.css">
|
||||||
|
<style>
|
||||||
|
.page-wrap { max-width: 880px; margin: 0 auto; padding: 20px; }
|
||||||
|
.card {
|
||||||
|
background: var(--card-bg); border-radius: 16px; padding: 28px 32px;
|
||||||
|
box-shadow: var(--shadow); margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.card h2 { font-size: 20px; font-weight: 700; margin: 0 0 6px; color: var(--text-primary); }
|
||||||
|
.card .sub { color: var(--text-secondary); font-size: 14px; margin-bottom: 18px; line-height: 1.6; }
|
||||||
|
.form-group { margin-bottom: 16px; }
|
||||||
|
.form-group label { display: block; font-weight: 600; font-size: 14px; margin-bottom: 5px; color: var(--text-primary); }
|
||||||
|
.form-group input[type=text], .form-group input[type=password], .form-group select {
|
||||||
|
width: 100%; padding: 10px 14px; border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px; font-size: 15px; box-sizing: border-box; transition: border-color .2s;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.form-group input:focus, .form-group select:focus {
|
||||||
|
outline: none; border-color: var(--primary); box-shadow: 0 0 0 4px rgba(230,126,34,.12);
|
||||||
|
}
|
||||||
|
.hint { font-size: 12.5px; color: var(--text-secondary); margin-top: 5px; line-height: 1.6; }
|
||||||
|
.hint code { background: var(--bg-secondary); padding: 1px 5px; border-radius: 4px; font-size: 12px; }
|
||||||
|
.driver-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
|
||||||
|
.driver-opt {
|
||||||
|
border: 2px solid var(--border-color); border-radius: 12px; padding: 14px 16px;
|
||||||
|
cursor: pointer; transition: var(--transition); background: var(--card-bg);
|
||||||
|
}
|
||||||
|
.driver-opt:hover { border-color: var(--primary-light); }
|
||||||
|
.driver-opt.active { border-color: var(--primary); background: #fff8f0; }
|
||||||
|
.driver-opt .t { font-weight: 600; font-size: 15px; display: flex; align-items: center; gap: 8px; }
|
||||||
|
.driver-opt .d { font-size: 12.5px; color: var(--text-secondary); margin-top: 4px; }
|
||||||
|
.driver-opt input { margin-right: 6px; }
|
||||||
|
.panel { display: none; margin-top: 20px; padding-top: 20px; border-top: 1px dashed var(--border-color); }
|
||||||
|
.panel.active { display: block; }
|
||||||
|
.panel-title { font-weight: 600; font-size: 15px; margin-bottom: 14px; color: var(--text-primary); }
|
||||||
|
.btn { padding: 10px 20px; border-radius: 8px; font-size: 14px; font-weight: 600;
|
||||||
|
border: none; cursor: pointer; transition: var(--transition); text-decoration: none; display: inline-block; }
|
||||||
|
.btn-primary { background: linear-gradient(135deg, var(--primary), var(--primary-light)); color: #fff; }
|
||||||
|
.btn-primary:hover { transform: translateY(-2px); box-shadow: 0 8px 28px rgba(230,126,34,.35); }
|
||||||
|
.btn-outline { background: transparent; color: var(--text-secondary); border: 1px solid var(--border-color); }
|
||||||
|
.btn-outline:hover { background: var(--bg-secondary); }
|
||||||
|
.btn-sm { padding: 6px 14px; font-size: 13px; }
|
||||||
|
.btn:disabled { opacity: .5; cursor: not-allowed; transform: none !important; box-shadow: none !important; }
|
||||||
|
.alert { padding: 12px 16px; border-radius: 10px; font-size: 14px; margin-bottom: 18px; line-height: 1.6; }
|
||||||
|
.alert-ok { background: #e8f8f0; color: #1e7e46; border: 1px solid #b6e6cd; }
|
||||||
|
.alert-err { background: #fdecea; color: #a93226; border: 1px solid #f5c6c0; }
|
||||||
|
.alert-info { background: #eaf4fd; color: #1b5f96; border: 1px solid #bcdcf5; }
|
||||||
|
.status-grid { display: grid; grid-template-columns: 130px 1fr; gap: 8px 16px; font-size: 14px; }
|
||||||
|
.status-grid .k { color: var(--text-secondary); }
|
||||||
|
.status-grid .v { color: var(--text-primary); font-weight: 500; word-break: break-all; }
|
||||||
|
.badge { display: inline-block; padding: 2px 9px; border-radius: 20px; font-size: 12.5px; font-weight: 600; }
|
||||||
|
.badge-ok { background: #e8f8f0; color: #1e7e46; }
|
||||||
|
.badge-off { background: var(--bg-secondary); color: var(--text-secondary); }
|
||||||
|
.badge-warn { background: #fef5e7; color: #a9711a; }
|
||||||
|
.progress { height: 10px; background: var(--bg-secondary); border-radius: 20px; overflow: hidden; margin: 14px 0 8px; }
|
||||||
|
.progress > i { display: block; height: 100%; width: 0; background: linear-gradient(90deg, var(--primary), var(--primary-light)); transition: width .3s; }
|
||||||
|
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12.5px; }
|
||||||
|
.row-flex { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
||||||
|
.chk { font-size: 13px; color: var(--text-secondary); display: flex; align-items: center; gap: 6px; margin-top: 6px; }
|
||||||
|
.chk input { margin: 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<?php include 'nav.php'; ?>
|
||||||
|
<div class="page-wrap">
|
||||||
|
|
||||||
|
<?php if ($message): ?><div class="alert alert-ok"><?= h($message) ?></div><?php endif; ?>
|
||||||
|
<?php if ($error): ?><div class="alert alert-err"><?= h($error) ?></div><?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($lastError && !empty($lastError['message'])): ?>
|
||||||
|
<div class="alert alert-err">
|
||||||
|
<strong>最近一次存储错误</strong>(<?= h($lastError['time'] ?? '') ?>,
|
||||||
|
驱动:<?= h($drivers[$lastError['driver'] ?? ''] ?? ($lastError['driver'] ?? '未知')) ?>)<br>
|
||||||
|
<span class="mono"><?= h($lastError['message']) ?></span>
|
||||||
|
<form method="post" style="margin-top:10px;">
|
||||||
|
<input type="hidden" name="action" value="clear_storage_error">
|
||||||
|
<button class="btn btn-outline btn-sm" type="submit">清除提示</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>图片存储方式</h2>
|
||||||
|
<div class="sub">
|
||||||
|
图片可以存放在本站硬盘上,也可以托管到对象存储。改用对象存储后,图片不再占用服务器磁盘,
|
||||||
|
以后搬迁网站时也不必搬运图片文件。<br>
|
||||||
|
切换存储方式<strong>不会影响已有图片</strong>:系统按"本地优先"读取,
|
||||||
|
本地存在的文件继续走本地,新上传的图片才走云端。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" id="storageForm">
|
||||||
|
<input type="hidden" name="action" value="save_storage">
|
||||||
|
|
||||||
|
<div class="driver-grid">
|
||||||
|
<?php
|
||||||
|
$driverDesc = [
|
||||||
|
'local' => '默认方式,图片存在 uploads/ 目录',
|
||||||
|
'oss' => '阿里云对象存储,支持 ECS 免密钥',
|
||||||
|
'cos' => '腾讯云对象存储,支持 CVM 免密钥',
|
||||||
|
'qiniu' => '七牛云对象存储,需绑定域名',
|
||||||
|
];
|
||||||
|
foreach ($drivers as $id => $label): ?>
|
||||||
|
<label class="driver-opt <?= $current === $id ? 'active' : '' ?>" data-driver="<?= h($id) ?>">
|
||||||
|
<div class="t">
|
||||||
|
<input type="radio" name="storage_driver" value="<?= h($id) ?>"
|
||||||
|
<?= $current === $id ? 'checked' : '' ?>>
|
||||||
|
<?= h($label) ?>
|
||||||
|
</div>
|
||||||
|
<div class="d"><?= h($driverDesc[$id] ?? '') ?></div>
|
||||||
|
</label>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ========== 图片访问方式 ========== -->
|
||||||
|
<?php
|
||||||
|
$accessModeNow = storageAccessMode();
|
||||||
|
// 内网 endpoint 的推导与可用性判定(结论有缓存,不会每次翻页都探测)。
|
||||||
|
// 结论要显式展示:跨地域时内网会**静默拖慢**每次读取,不报出来无从察觉。
|
||||||
|
$ossInternalInfo = null;
|
||||||
|
if ($current === 'oss'
|
||||||
|
&& trim((string)($configs['oss']['endpoint'] ?? '')) !== ''
|
||||||
|
&& trim((string)($configs['oss']['internal_endpoint'] ?? '1')) === '1') {
|
||||||
|
$ossInternalInfo = storageDriverInstance('oss', $configs['oss'])->internalStatus();
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<div class="form-group" style="margin-top:22px;padding-top:18px;border-top:1px dashed var(--border-color);">
|
||||||
|
<label>图片访问方式</label>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:12px;margin-top:6px;">
|
||||||
|
<label class="chk" style="align-items:flex-start;gap:8px;">
|
||||||
|
<input type="radio" name="storage_access_mode" value="direct"
|
||||||
|
<?= $accessModeNow === 'direct' ? 'checked' : '' ?>>
|
||||||
|
<span>
|
||||||
|
<strong>直连存储</strong>
|
||||||
|
<span class="hint" style="display:block;margin-top:2px;">
|
||||||
|
图片 URL 直接指向对象存储。要求桶允许匿名读取(公共读),否则图片会 403 打不开。
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="chk" style="align-items:flex-start;gap:8px;">
|
||||||
|
<input type="radio" name="storage_access_mode" value="proxy"
|
||||||
|
<?= $accessModeNow === 'proxy' ? 'checked' : '' ?>>
|
||||||
|
<span>
|
||||||
|
<strong>服务端代理(更安全)</strong>
|
||||||
|
<span class="hint" style="display:block;margin-top:2px;">
|
||||||
|
图片经本站 <code>media.php</code> 鉴权后由服务器读取回吐。<strong>桶可以设为私有</strong>,
|
||||||
|
无需防盗链;且因同源交付,图片裁剪功能不再受跨域限制。
|
||||||
|
代价:图片流量走本服务器出网带宽。<br>
|
||||||
|
目前支持服务端读取的驱动:本地硬盘、阿里云 OSS。
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ========== 阿里云 OSS ========== -->
|
||||||
|
<div class="panel <?= $current === 'oss' ? 'active' : '' ?>" data-panel="oss">
|
||||||
|
<div class="panel-title">阿里云 OSS 配置</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>鉴权方式</label>
|
||||||
|
<select name="oss[auth_mode]" class="auth-mode" data-driver="oss">
|
||||||
|
<option value="ak" <?= $configs['oss']['auth_mode'] === 'ak' ? 'selected' : '' ?>>AccessKey(显式填写密钥)</option>
|
||||||
|
<option value="ecs_role" <?= $configs['oss']['auth_mode'] === 'ecs_role' ? 'selected' : '' ?>>ECS RAM 角色(免密钥,实例元数据自动获取)</option>
|
||||||
|
</select>
|
||||||
|
<div class="hint">服务器在阿里云 ECS 上并已绑定 RAM 角色时,选第二项可完全不填密钥。</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ak-fields" data-driver="oss">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>AccessKey ID</label>
|
||||||
|
<input type="text" name="oss[access_key_id]" value="<?= h($configs['oss']['access_key_id']) ?>"
|
||||||
|
placeholder="LTAI..." autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>AccessKey Secret</label>
|
||||||
|
<input type="password" name="oss[access_key_secret]" value=""
|
||||||
|
placeholder="<?= $configs['oss']['access_key_secret'] !== '' ? '已保存(' . h(maskSecret($configs['oss']['access_key_secret'])) . '),留空则不修改' : '请输入 AccessKey Secret' ?>"
|
||||||
|
autocomplete="new-password">
|
||||||
|
<?php if ($configs['oss']['access_key_secret'] !== ''): ?>
|
||||||
|
<label class="chk"><input type="checkbox" name="oss[clear_secrets]" value="1"> 清除已保存的密钥</label>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="role-fields" data-driver="oss" style="display:none;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>RAM 角色名称(可留空自动发现)</label>
|
||||||
|
<input type="text" name="oss[ram_role_name]" value="<?= h($configs['oss']['ram_role_name']) ?>"
|
||||||
|
placeholder="留空则自动读取实例绑定的角色">
|
||||||
|
<div class="hint">需要该角色被授予目标 Bucket 的读写权限。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Bucket 名称</label>
|
||||||
|
<input type="text" name="oss[bucket]" value="<?= h($configs['oss']['bucket']) ?>" placeholder="例如 movielog">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Endpoint</label>
|
||||||
|
<input type="text" name="oss[endpoint]" value="<?= h($configs['oss']['endpoint']) ?>"
|
||||||
|
placeholder="例如 oss-cn-beijing.aliyuncs.com">
|
||||||
|
<div class="hint">在 OSS 控制台"概览"里查看,形如 <code>oss-cn-地域.aliyuncs.com</code>,不要带 <code>https://</code>。</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="chk">
|
||||||
|
<input type="checkbox" name="oss[internal_endpoint]" value="1"
|
||||||
|
<?= ($configs['oss']['internal_endpoint'] ?? '1') === '1' ? 'checked' : '' ?>>
|
||||||
|
服务端读取走内网 endpoint
|
||||||
|
</label>
|
||||||
|
<div class="hint">仅在「服务端代理」访问方式下生效。服务器<strong>与桶同地域</strong>时,
|
||||||
|
读取会改用 <code>oss-<地域>-internal.aliyuncs.com</code>——内网流量免费且更快。<br>
|
||||||
|
<strong>不同地域时内网地址连不通</strong>(DNS 能解析、TCP 建不起来),
|
||||||
|
硬走内网会让每次读取先白等一次连接超时。系统会自动判定地域、不可用就跳过并回退公网,
|
||||||
|
不会因此变慢;此处结论见下方「当前状态」。</div>
|
||||||
|
<?php if ($ossInternalInfo !== null && $ossInternalInfo['endpoint'] !== ''): ?>
|
||||||
|
<div class="hint" style="margin-top:6px;">
|
||||||
|
当前判定:
|
||||||
|
<?php if ($ossInternalInfo['ok']): ?>
|
||||||
|
<span class="badge badge-ok">内网可用</span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="badge badge-warn">已跳过内网,走公网</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<span class="mono"><?= h($ossInternalInfo['endpoint']) ?></span>
|
||||||
|
<?php if ($ossInternalInfo['reason'] !== ''): ?>
|
||||||
|
—— <?= h($ossInternalInfo['reason']) ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>对象前缀(可选)</label>
|
||||||
|
<input type="text" name="oss[prefix]" value="<?= h($configs['oss']['prefix']) ?>"
|
||||||
|
placeholder="例如 movielog">
|
||||||
|
<div class="hint">填写后图片会存到桶内该目录下,键变为 <code>前缀/uploads/posters/xxx.jpg</code>;
|
||||||
|
同一个桶存放多个站点时用它区分。留空则直接放在桶根目录。</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>自定义域名 / CDN(可选)</label>
|
||||||
|
<input type="text" name="oss[custom_domain]" value="<?= h($configs['oss']['custom_domain']) ?>"
|
||||||
|
placeholder="例如 img.example.com">
|
||||||
|
<div class="hint">填写后图片 URL 会使用该域名;留空则使用 OSS 默认域名。</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>协议</label>
|
||||||
|
<select name="oss[https]">
|
||||||
|
<option value="1" <?= $configs['oss']['https'] === '1' ? 'selected' : '' ?>>HTTPS</option>
|
||||||
|
<option value="0" <?= $configs['oss']['https'] === '0' ? 'selected' : '' ?>>HTTP</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ========== 腾讯云 COS ========== -->
|
||||||
|
<div class="panel <?= $current === 'cos' ? 'active' : '' ?>" data-panel="cos">
|
||||||
|
<div class="panel-title">腾讯云 COS 配置</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>鉴权方式</label>
|
||||||
|
<select name="cos[auth_mode]" class="auth-mode" data-driver="cos">
|
||||||
|
<option value="ak" <?= $configs['cos']['auth_mode'] === 'ak' ? 'selected' : '' ?>>SecretId / SecretKey(显式填写密钥)</option>
|
||||||
|
<option value="cvm_role" <?= $configs['cos']['auth_mode'] === 'cvm_role' ? 'selected' : '' ?>>CVM CAM 角色(免密钥,实例元数据自动获取)</option>
|
||||||
|
</select>
|
||||||
|
<div class="hint">服务器在腾讯云 CVM 上并已绑定 CAM 角色时,选第二项可完全不填密钥。</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ak-fields" data-driver="cos">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>SecretId</label>
|
||||||
|
<input type="text" name="cos[secret_id]" value="<?= h($configs['cos']['secret_id']) ?>"
|
||||||
|
placeholder="AKID..." autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>SecretKey</label>
|
||||||
|
<input type="password" name="cos[secret_key]" value=""
|
||||||
|
placeholder="<?= $configs['cos']['secret_key'] !== '' ? '已保存(' . h(maskSecret($configs['cos']['secret_key'])) . '),留空则不修改' : '请输入 SecretKey' ?>"
|
||||||
|
autocomplete="new-password">
|
||||||
|
<?php if ($configs['cos']['secret_key'] !== ''): ?>
|
||||||
|
<label class="chk"><input type="checkbox" name="cos[clear_secrets]" value="1"> 清除已保存的密钥</label>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="role-fields" data-driver="cos" style="display:none;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>CAM 角色名称(可留空自动发现)</label>
|
||||||
|
<input type="text" name="cos[cam_role_name]" value="<?= h($configs['cos']['cam_role_name']) ?>"
|
||||||
|
placeholder="留空则自动读取实例绑定的角色">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>存储桶名称(含 APPID)</label>
|
||||||
|
<input type="text" name="cos[bucket]" value="<?= h($configs['cos']['bucket']) ?>"
|
||||||
|
placeholder="例如 movielog-1250000000">
|
||||||
|
<div class="hint">COS 的桶名必须带 APPID 后缀。</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>地域 Region</label>
|
||||||
|
<input type="text" name="cos[region]" value="<?= h($configs['cos']['region']) ?>"
|
||||||
|
placeholder="例如 ap-beijing">
|
||||||
|
<div class="hint">形如 <code>ap-地域</code>,在 COS 控制台桶的"概览"中可查。</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>对象前缀(可选)</label>
|
||||||
|
<input type="text" name="cos[prefix]" value="<?= h($configs['cos']['prefix']) ?>"
|
||||||
|
placeholder="例如 movielog">
|
||||||
|
<div class="hint">填写后图片会存到桶内该目录下,键变为 <code>前缀/uploads/posters/xxx.jpg</code>;
|
||||||
|
同一个桶存放多个站点时用它区分。留空则直接放在桶根目录。</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>自定义域名 / CDN(可选)</label>
|
||||||
|
<input type="text" name="cos[custom_domain]" value="<?= h($configs['cos']['custom_domain']) ?>"
|
||||||
|
placeholder="例如 img.example.com">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>协议</label>
|
||||||
|
<select name="cos[https]">
|
||||||
|
<option value="1" <?= $configs['cos']['https'] === '1' ? 'selected' : '' ?>>HTTPS</option>
|
||||||
|
<option value="0" <?= $configs['cos']['https'] === '0' ? 'selected' : '' ?>>HTTP</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ========== 七牛云 ========== -->
|
||||||
|
<div class="panel <?= $current === 'qiniu' ? 'active' : '' ?>" data-panel="qiniu">
|
||||||
|
<div class="panel-title">七牛云 Kodo 配置</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>AccessKey</label>
|
||||||
|
<input type="text" name="qiniu[access_key]" value="<?= h($configs['qiniu']['access_key']) ?>"
|
||||||
|
autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>SecretKey</label>
|
||||||
|
<input type="password" name="qiniu[secret_key]" value=""
|
||||||
|
placeholder="<?= $configs['qiniu']['secret_key'] !== '' ? '已保存(' . h(maskSecret($configs['qiniu']['secret_key'])) . '),留空则不修改' : '请输入 SecretKey' ?>"
|
||||||
|
autocomplete="new-password">
|
||||||
|
<?php if ($configs['qiniu']['secret_key'] !== ''): ?>
|
||||||
|
<label class="chk"><input type="checkbox" name="qiniu[clear_secrets]" value="1"> 清除已保存的密钥</label>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>空间名称 Bucket</label>
|
||||||
|
<input type="text" name="qiniu[bucket]" value="<?= h($configs['qiniu']['bucket']) ?>">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>存储区域</label>
|
||||||
|
<select name="qiniu[region]">
|
||||||
|
<?php
|
||||||
|
$regions = ['z0' => '华东 z0', 'z1' => '华北 z1', 'z2' => '华南 z2', 'na0' => '北美 na0', 'as0' => '亚太 as0'];
|
||||||
|
foreach ($regions as $rv => $rl): ?>
|
||||||
|
<option value="<?= h($rv) ?>" <?= $configs['qiniu']['region'] === $rv ? 'selected' : '' ?>><?= h($rl) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>绑定域名(必填)</label>
|
||||||
|
<input type="text" name="qiniu[custom_domain]" value="<?= h($configs['qiniu']['custom_domain']) ?>"
|
||||||
|
placeholder="例如 cdn.example.com">
|
||||||
|
<div class="hint">七牛云的对象必须通过绑定域名访问,未绑定域名则图片无法显示。</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>对象前缀(可选)</label>
|
||||||
|
<input type="text" name="qiniu[prefix]" value="<?= h($configs['qiniu']['prefix']) ?>"
|
||||||
|
placeholder="例如 movielog">
|
||||||
|
<div class="hint">填写后图片会存到空间内该目录下,键变为 <code>前缀/uploads/posters/xxx.jpg</code>;
|
||||||
|
同一个空间存放多个站点时用它区分。留空则直接放在空间根目录。</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>协议</label>
|
||||||
|
<select name="qiniu[https]">
|
||||||
|
<option value="1" <?= $configs['qiniu']['https'] === '1' ? 'selected' : '' ?>>HTTPS</option>
|
||||||
|
<option value="0" <?= $configs['qiniu']['https'] === '0' ? 'selected' : '' ?>>HTTP</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row-flex" style="margin-top:22px;">
|
||||||
|
<button class="btn btn-primary" type="submit">保存设置</button>
|
||||||
|
<button class="btn btn-outline" type="button" id="btnTest">测试连接</button>
|
||||||
|
<span id="testResult" class="hint" style="margin:0;"></span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>当前状态</h2>
|
||||||
|
<div class="status-grid">
|
||||||
|
<div class="k">生效方式</div>
|
||||||
|
<div class="v">
|
||||||
|
<?= h($drivers[$current] ?? $current) ?>
|
||||||
|
<?php if ($current !== 'local'): ?>
|
||||||
|
<?php if ($remoteReady): ?>
|
||||||
|
<span class="badge badge-ok">配置完整</span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="badge badge-warn">配置不完整,暂时按本地存储处理</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="badge badge-off">本地存储</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="k">图片访问方式</div>
|
||||||
|
<div class="v">
|
||||||
|
<?php if ($accessModeNow === 'proxy'): ?>
|
||||||
|
<span class="badge badge-ok">服务端代理</span>
|
||||||
|
<span class="hint" style="margin-left:8px;">经 media.php 鉴权读取,桶可设为私有</span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="badge badge-off">直连存储</span>
|
||||||
|
<span class="hint" style="margin-left:8px;">图片 URL 直接指向存储</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($accessModeNow === 'proxy' && $ossInternalInfo !== null && $ossInternalInfo['endpoint'] !== ''): ?>
|
||||||
|
<div class="k">服务端读取通道</div>
|
||||||
|
<div class="v">
|
||||||
|
<?php if ($ossInternalInfo['ok']): ?>
|
||||||
|
<span class="badge badge-ok">内网</span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="badge badge-off">公网(已跳过内网)</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<span class="hint mono" style="margin-left:8px;"><?= h($ossInternalInfo['endpoint']) ?></span>
|
||||||
|
<?php if ($ossInternalInfo['reason'] !== ''): ?>
|
||||||
|
<span class="hint" style="margin-left:8px;"><?= h($ossInternalInfo['reason']) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="k">本地图片</div>
|
||||||
|
<div class="v"><?= $localStat['count'] ?> 张,占用 <?= h(formatBytes($localStat['bytes'])) ?></div>
|
||||||
|
|
||||||
|
<div class="k">数据目录</div>
|
||||||
|
<div class="v mono"><?= h(str_replace(ROOT_DIR . '/', '', UPLOAD_DIR)) ?></div>
|
||||||
|
|
||||||
|
<?php if ($current !== 'local'): ?>
|
||||||
|
<div class="k">对象前缀</div>
|
||||||
|
<div class="v">
|
||||||
|
<?php $curPrefix = trim((string)($configs[$current]['prefix'] ?? ''), '/'); ?>
|
||||||
|
<?php if ($curPrefix !== ''): ?>
|
||||||
|
<span class="mono"><?= h($curPrefix) ?>/</span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="hint" style="margin:0;">未设置,图片直接放在桶根目录</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="k">凭据加密</div>
|
||||||
|
<div class="v">
|
||||||
|
<?php if (!settingsEncryptionAvailable()): ?>
|
||||||
|
<span class="badge badge-warn">加密不可用</span>
|
||||||
|
<span class="hint" style="margin-left:8px;">
|
||||||
|
未启用 openssl 或 db/ 目录不可写,云厂商密钥将以明文保存
|
||||||
|
</span>
|
||||||
|
<?php elseif (settingsDecryptFailed()): ?>
|
||||||
|
<span class="badge badge-warn">解密失败</span>
|
||||||
|
<span class="hint" style="margin-left:8px;">
|
||||||
|
密钥文件 db/.settings.key 丢失或损坏,请在各云厂商表单中重新填写密钥
|
||||||
|
</span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="badge badge-ok">已加密</span>
|
||||||
|
<span class="hint" style="margin-left:8px;">
|
||||||
|
<?= h(settingsCipherLabel()) ?>,密钥单独存放于 db/.settings.key(不在数据库内)
|
||||||
|
</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($current === 'oss' && $configs['oss']['auth_mode'] === 'ecs_role'): ?>
|
||||||
|
<div class="k">ECS 角色</div>
|
||||||
|
<div class="v">
|
||||||
|
<button class="btn btn-outline btn-sm" type="button" id="btnDetectCloud">探测云主机身份</button>
|
||||||
|
<span id="detectResult" class="hint" style="margin-left:8px;"></span>
|
||||||
|
</div>
|
||||||
|
<?php elseif ($current === 'cos' && $configs['cos']['auth_mode'] === 'cvm_role'): ?>
|
||||||
|
<div class="k">CVM 角色</div>
|
||||||
|
<div class="v">
|
||||||
|
<button class="btn btn-outline btn-sm" type="button" id="btnDetectCloud">探测云主机身份</button>
|
||||||
|
<span id="detectResult" class="hint" style="margin-left:8px;"></span>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>把已有图片迁移到云端</h2>
|
||||||
|
<div class="sub">
|
||||||
|
迁移会把 <code>uploads/</code> 目录下的图片逐个上传到当前对象存储,并<strong>按内容指纹重命名</strong>
|
||||||
|
(<code><md5前12位>_<随机6位>.扩展名</code>),同时自动改写数据库里对应的图片引用,
|
||||||
|
因此已有记录不会裂图。已经是本内容指纹名的文件会跳过改名,重复迁移是安全的。<br>
|
||||||
|
执行顺序固定为<strong>先上传 → 再改库 → 最后才删本地</strong>:任一步失败都会停下并保留本地文件,
|
||||||
|
加上"本地优先"渲染,页面不会出现裂图。云端若已存在旧名对象,会保留不删(不影响使用)。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!$remoteReady): ?>
|
||||||
|
<div class="alert alert-info">
|
||||||
|
当前未启用对象存储(或配置不完整),迁移功能暂不可用。请先选择云端存储并保存。
|
||||||
|
<?php if (storageIsRemote()): ?>(提示:当前驱动配置不完整,无法上传)<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="row-flex">
|
||||||
|
<button class="btn btn-outline btn-sm" type="button" id="btnStat">统计待迁移</button>
|
||||||
|
<button class="btn btn-primary btn-sm" type="button" id="btnMigrate">开始迁移</button>
|
||||||
|
<label class="chk" style="margin:0;">
|
||||||
|
<input type="checkbox" id="delLocal" checked> 迁移后删除本地文件(真正释放磁盘)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="progress"><i id="bar"></i></div>
|
||||||
|
<div class="hint" id="migrateMsg">尚未开始。</div>
|
||||||
|
<div class="hint mono" id="migrateErrors" style="max-height:140px;overflow:auto;"></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var API = 'storage_api.php';
|
||||||
|
|
||||||
|
// ---------- 驱动切换 ----------
|
||||||
|
function syncPanels() {
|
||||||
|
var picked = document.querySelector('input[name=storage_driver]:checked');
|
||||||
|
var driver = picked ? picked.value : 'local';
|
||||||
|
document.querySelectorAll('.driver-opt').forEach(function (el) {
|
||||||
|
el.classList.toggle('active', el.dataset.driver === driver);
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.panel').forEach(function (el) {
|
||||||
|
el.classList.toggle('active', el.dataset.panel === driver);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.querySelectorAll('input[name=storage_driver]').forEach(function (r) {
|
||||||
|
r.addEventListener('change', syncPanels);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- 鉴权方式切换 ----------
|
||||||
|
function syncAuthMode(driver) {
|
||||||
|
var sel = document.querySelector('.auth-mode[data-driver="' + driver + '"]');
|
||||||
|
if (!sel) return;
|
||||||
|
var isRole = sel.value === 'ecs_role' || sel.value === 'cvm_role';
|
||||||
|
var ak = document.querySelector('.ak-fields[data-driver="' + driver + '"]');
|
||||||
|
var role = document.querySelector('.role-fields[data-driver="' + driver + '"]');
|
||||||
|
if (ak) ak.style.display = isRole ? 'none' : '';
|
||||||
|
if (role) role.style.display = isRole ? '' : 'none';
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.auth-mode').forEach(function (sel) {
|
||||||
|
sel.addEventListener('change', function () { syncAuthMode(sel.dataset.driver); });
|
||||||
|
syncAuthMode(sel.dataset.driver);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- 测试连接 ----------
|
||||||
|
var btnTest = document.getElementById('btnTest');
|
||||||
|
if (btnTest) {
|
||||||
|
btnTest.addEventListener('click', function () {
|
||||||
|
var out = document.getElementById('testResult');
|
||||||
|
var picked = document.querySelector('input[name=storage_driver]:checked');
|
||||||
|
var driver = picked ? picked.value : 'local';
|
||||||
|
if (driver === 'local') {
|
||||||
|
out.style.color = 'var(--text-secondary)';
|
||||||
|
out.textContent = '本地存储无需测试。';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btnTest.disabled = true;
|
||||||
|
out.style.color = 'var(--text-secondary)';
|
||||||
|
out.textContent = '正在测试,请稍候…';
|
||||||
|
|
||||||
|
// 直接把当前表单(含未保存的修改)发去测试
|
||||||
|
var fd = new FormData(document.getElementById('storageForm'));
|
||||||
|
fd.append('action', 'test');
|
||||||
|
fd.append('driver', driver);
|
||||||
|
|
||||||
|
fetch(API, { method: 'POST', body: fd })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (j) {
|
||||||
|
out.style.color = j.ok ? '#1e7e46' : '#a93226';
|
||||||
|
out.textContent = (j.ok ? '✔ ' : '✘ ') + (j.message || j.error || '');
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
out.style.color = '#a93226';
|
||||||
|
out.textContent = '✘ 请求失败:' + e;
|
||||||
|
})
|
||||||
|
.finally(function () { btnTest.disabled = false; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 探测云主机 ----------
|
||||||
|
var btnDetect = document.getElementById('btnDetectCloud');
|
||||||
|
if (btnDetect) {
|
||||||
|
btnDetect.addEventListener('click', function () {
|
||||||
|
var out = document.getElementById('detectResult');
|
||||||
|
btnDetect.disabled = true;
|
||||||
|
out.textContent = '探测中…';
|
||||||
|
fetch(API + '?action=detect_cloud')
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (j) {
|
||||||
|
out.textContent = j.message || j.error || '';
|
||||||
|
})
|
||||||
|
.catch(function (e) { out.textContent = '探测失败:' + e; })
|
||||||
|
.finally(function () { btnDetect.disabled = false; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 迁移 ----------
|
||||||
|
var btnStat = document.getElementById('btnStat');
|
||||||
|
var btnMigrate = document.getElementById('btnMigrate');
|
||||||
|
var bar = document.getElementById('bar');
|
||||||
|
var msg = document.getElementById('migrateMsg');
|
||||||
|
var errBox = document.getElementById('migrateErrors');
|
||||||
|
var delLocal = document.getElementById('delLocal');
|
||||||
|
|
||||||
|
if (btnStat) {
|
||||||
|
btnStat.addEventListener('click', function () {
|
||||||
|
btnStat.disabled = true;
|
||||||
|
msg.textContent = '统计中…';
|
||||||
|
fetch(API + '?action=stat')
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (j) {
|
||||||
|
if (!j.ok) { msg.textContent = '✘ ' + (j.error || '统计失败'); return; }
|
||||||
|
msg.textContent = '本地共有 ' + j.local_total + ' 张图片,占用 '
|
||||||
|
+ j.local_bytes_h + '。迁移会把它们上传到云端;'
|
||||||
|
+ '勾选"迁移后删除本地文件"才会真正释放磁盘空间。';
|
||||||
|
})
|
||||||
|
.catch(function (e) { msg.textContent = '✘ ' + e; })
|
||||||
|
.finally(function () { btnStat.disabled = false; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btnMigrate) {
|
||||||
|
var running = false;
|
||||||
|
btnMigrate.addEventListener('click', function () {
|
||||||
|
if (running) return;
|
||||||
|
running = true;
|
||||||
|
btnMigrate.disabled = true;
|
||||||
|
btnStat.disabled = true;
|
||||||
|
errBox.textContent = '';
|
||||||
|
bar.style.width = '0%';
|
||||||
|
|
||||||
|
var total = 0, done = 0, migrated = 0, renamed = 0, failed = 0, cursor = '';
|
||||||
|
var errors = [];
|
||||||
|
|
||||||
|
function step() {
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('action', 'migrate');
|
||||||
|
fd.append('cursor', cursor);
|
||||||
|
fd.append('limit', '10');
|
||||||
|
fd.append('delete_local', delLocal.checked ? '1' : '0');
|
||||||
|
|
||||||
|
fetch(API, { method: 'POST', body: fd })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (j) {
|
||||||
|
if (!j.ok) {
|
||||||
|
errors.push('批次失败:' + (j.error || '未知错误'));
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
total = j.total;
|
||||||
|
migrated += j.migrated;
|
||||||
|
renamed += (j.renamed || 0);
|
||||||
|
failed += j.failed;
|
||||||
|
if (j.errors && j.errors.length) errors = errors.concat(j.errors);
|
||||||
|
|
||||||
|
done += j.processed || 0;
|
||||||
|
if (total > 0) {
|
||||||
|
bar.style.width = Math.min(100, Math.round(done * 100 / total)) + '%';
|
||||||
|
}
|
||||||
|
msg.textContent = '进度 ' + done + '/' + total
|
||||||
|
+ ',成功 ' + migrated + '(改名 ' + renamed + '),失败 ' + failed + '…';
|
||||||
|
errBox.textContent = errors.join('\n');
|
||||||
|
cursor = j.cursor;
|
||||||
|
|
||||||
|
if (j.has_more) {
|
||||||
|
step();
|
||||||
|
} else {
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
errors.push('请求异常:' + e);
|
||||||
|
finish();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish() {
|
||||||
|
running = false;
|
||||||
|
btnMigrate.disabled = false;
|
||||||
|
btnStat.disabled = false;
|
||||||
|
bar.style.width = '100%';
|
||||||
|
msg.textContent = '迁移结束:共处理 ' + done + ' 张,成功 ' + migrated
|
||||||
|
+ '(其中按指纹改名 ' + renamed + ' 张),失败 ' + failed + '。';
|
||||||
|
errBox.textContent = errors.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
step();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+273
@@ -0,0 +1,273 @@
|
|||||||
|
<?php
|
||||||
|
// ============================================
|
||||||
|
// 文件: storage_api.php
|
||||||
|
// 说明: 存储相关的 AJAX 接口:连通性测试、云主机身份探测、历史图片迁移
|
||||||
|
// 全部需要登录;返回 JSON。
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
require_once 'config.php';
|
||||||
|
|
||||||
|
// 本文件是 JSON 接口:任何 PHP 诊断都不得写进响应体。
|
||||||
|
// 否则前端的 res.json() 会直接失败,真正的错误反而被 SyntaxError 掩盖
|
||||||
|
// (实际踩过:lib/storage/http.php 探测站外 CA 路径触发 open_basedir 警告,
|
||||||
|
// "<br /><b>Warning</b>" 被 display_errors 打在 JSON 前面)。
|
||||||
|
ini_set('display_errors', '0');
|
||||||
|
ini_set('log_errors', '1');
|
||||||
|
ob_start();
|
||||||
|
|
||||||
|
// 兜底:万一发生致命错误,丢弃半截输出,改回一个可读的 JSON 错误
|
||||||
|
register_shutdown_function(function () {
|
||||||
|
$e = error_get_last();
|
||||||
|
$fatal = $e && in_array($e['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR], true);
|
||||||
|
if (!$fatal) return;
|
||||||
|
while (ob_get_level() > 0) { ob_end_clean(); }
|
||||||
|
if (!headers_sent()) {
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
}
|
||||||
|
echo json_encode(['ok' => false, 'error' => '服务器内部错误:' . $e['message']], JSON_UNESCAPED_UNICODE);
|
||||||
|
});
|
||||||
|
|
||||||
|
checkLogin();
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
$action = $_POST['action'] ?? ($_GET['action'] ?? '');
|
||||||
|
|
||||||
|
// 把表单提交的配置合并到已保存配置之上(留空字段沿用旧值)
|
||||||
|
function mergePostedConfig($driver, array $posted) {
|
||||||
|
$saved = storageConfigFor($driver);
|
||||||
|
foreach ($saved as $k => $old) {
|
||||||
|
if (!array_key_exists($k, $posted)) continue;
|
||||||
|
if (is_array($posted[$k])) continue;
|
||||||
|
$val = trim((string)$posted[$k]);
|
||||||
|
if ($val === '') {
|
||||||
|
continue; // 留空:沿用已保存(与保存逻辑一致)
|
||||||
|
}
|
||||||
|
$saved[$k] = $val;
|
||||||
|
}
|
||||||
|
$saved['https'] = ($saved['https'] ?? '1') === '0' ? '0' : '1';
|
||||||
|
return $saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonOut($data) {
|
||||||
|
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扫描 uploads/ 下的全部文件(返回相对 ROOT_DIR 的键,已排序)
|
||||||
|
function scanLocalUploadFiles() {
|
||||||
|
$out = [];
|
||||||
|
if (!is_dir(UPLOAD_DIR)) return $out;
|
||||||
|
$it = new RecursiveIteratorIterator(
|
||||||
|
new RecursiveDirectoryIterator(UPLOAD_DIR, FilesystemIterator::SKIP_DOTS),
|
||||||
|
RecursiveIteratorIterator::LEAVES_ONLY
|
||||||
|
);
|
||||||
|
foreach ($it as $f) {
|
||||||
|
if (!$f->isFile()) continue;
|
||||||
|
$name = $f->getFilename();
|
||||||
|
if ($name === '.htaccess' || $name[0] === '.') continue;
|
||||||
|
$rel = relPathOfDir($f->getPath()) . $name;
|
||||||
|
$out[] = $rel;
|
||||||
|
}
|
||||||
|
sort($out, SORT_STRING);
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localBytesOf(array $files) {
|
||||||
|
$b = 0;
|
||||||
|
foreach ($files as $f) {
|
||||||
|
$p = ROOT_DIR . '/' . $f;
|
||||||
|
if (is_file($p)) $b += filesize($p);
|
||||||
|
}
|
||||||
|
return $b;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ($action) {
|
||||||
|
|
||||||
|
// ---------- 测试连接 ----------
|
||||||
|
case 'test': {
|
||||||
|
$driver = $_POST['driver'] ?? '';
|
||||||
|
if (!in_array($driver, ['oss', 'cos', 'qiniu'], true)) {
|
||||||
|
jsonOut(['ok' => false, 'error' => '请选择云端存储方式后再测试']);
|
||||||
|
}
|
||||||
|
$posted = isset($_POST[$driver]) && is_array($_POST[$driver]) ? $_POST[$driver] : [];
|
||||||
|
$cfg = mergePostedConfig($driver, $posted);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$obj = storageDriverInstance($driver, $cfg);
|
||||||
|
$r = $obj->test();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
jsonOut(['ok' => false, 'error' => '测试异常:' . $e->getMessage()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($r['ok'])) {
|
||||||
|
// 测试通过,顺手清掉历史错误提示
|
||||||
|
if (currentStorageDriver() === $driver) {
|
||||||
|
storageClearError();
|
||||||
|
}
|
||||||
|
jsonOut(['ok' => true, 'message' => $r['message'] ?? '连接成功']);
|
||||||
|
}
|
||||||
|
jsonOut(['ok' => false, 'error' => $r['error'] ?? '测试失败']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 探测云主机身份 ----------
|
||||||
|
case 'detect_cloud': {
|
||||||
|
clearMetaCredentialCache();
|
||||||
|
$found = storageDetectCloudHost();
|
||||||
|
if (empty($found)) {
|
||||||
|
jsonOut([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => '未检测到云主机实例元数据。说明当前服务器不在阿里云 ECS / 腾讯云 CVM 上,'
|
||||||
|
. '或实例未绑定角色——请改用密钥方式填写。',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
$parts = [];
|
||||||
|
foreach ($found as $k => $info) {
|
||||||
|
$parts[] = $info['label'] . ',角色:' . ($info['role'] !== '' ? $info['role'] : '(未知)');
|
||||||
|
}
|
||||||
|
jsonOut(['ok' => true, 'message' => '✔ 检测到:' . implode(';', $parts) . ',可使用免密钥方式。']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 统计本地图片 ----------
|
||||||
|
case 'stat': {
|
||||||
|
$files = scanLocalUploadFiles();
|
||||||
|
$bytes = localBytesOf($files);
|
||||||
|
jsonOut([
|
||||||
|
'ok' => true,
|
||||||
|
'local_total' => count($files),
|
||||||
|
'local_bytes' => $bytes,
|
||||||
|
'local_bytes_h' => formatBytes($bytes),
|
||||||
|
'remote_ready' => storageIsRemote(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 分批迁移 ----------
|
||||||
|
case 'migrate': {
|
||||||
|
if (!storageIsRemote()) {
|
||||||
|
jsonOut(['ok' => false, 'error' => '当前未启用对象存储(或配置不完整),无法迁移']);
|
||||||
|
}
|
||||||
|
@set_time_limit(120);
|
||||||
|
|
||||||
|
$driver = storage();
|
||||||
|
if ($driver->id() === 'local') {
|
||||||
|
jsonOut(['ok' => false, 'error' => '当前驱动为本地存储,无法迁移']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 游标形如 "<本次运行令牌>|<已处理条数>"。
|
||||||
|
// 之所以不用「文件名游标」:迁移会把本地文件按内容指纹改名,
|
||||||
|
// 改名后的新名字可能排到游标之后,于是同一个文件会被再处理一遍
|
||||||
|
// (线上出现过「进度 880/447」这种超过总数的数字)。
|
||||||
|
// 这里把「本次运行要处理的文件清单」一次性冻结存档,之后按**下标**推进,
|
||||||
|
// 改名与否都不再影响进度;令牌用来识别并丢弃上一次遗留的清单。
|
||||||
|
$cursor = (string)($_POST['cursor'] ?? '');
|
||||||
|
$limit = (int)($_POST['limit'] ?? 10);
|
||||||
|
$limit = max(1, min(50, $limit));
|
||||||
|
$deleteLocal = !empty($_POST['delete_local']);
|
||||||
|
|
||||||
|
$token = '';
|
||||||
|
$start = 0;
|
||||||
|
if ($cursor !== '' && strpos($cursor, '|') !== false) {
|
||||||
|
list($token, $idx) = explode('|', $cursor, 2);
|
||||||
|
$start = max(0, (int)$idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
$queue = null;
|
||||||
|
if ($token !== '') {
|
||||||
|
$saved = json_decode((string)getSetting('storage_migrate_queue', ''), true);
|
||||||
|
if (is_array($saved) && ($saved['run'] ?? '') === $token && is_array($saved['files'] ?? null)) {
|
||||||
|
$queue = $saved['files'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($queue === null) { // 新一轮:重新冻结清单
|
||||||
|
$queue = scanLocalUploadFiles();
|
||||||
|
$token = bin2hex(random_bytes(6));
|
||||||
|
$start = 0;
|
||||||
|
setSetting('storage_migrate_queue',
|
||||||
|
json_encode(['run' => $token, 'files' => $queue], JSON_UNESCAPED_UNICODE));
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = count($queue);
|
||||||
|
$batch = array_slice($queue, $start, $limit);
|
||||||
|
|
||||||
|
$migrated = 0;
|
||||||
|
$failed = 0;
|
||||||
|
$renamed = 0;
|
||||||
|
$refs = 0;
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
foreach ($batch as $rel) {
|
||||||
|
$abs = ROOT_DIR . '/' . $rel;
|
||||||
|
if (!is_file($abs)) { continue; }
|
||||||
|
|
||||||
|
$binary = @file_get_contents($abs);
|
||||||
|
if ($binary === false || $binary === '') {
|
||||||
|
$failed++;
|
||||||
|
if (count($errors) < 20) $errors[] = $rel . ' → 读取源文件失败';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 目标键:已经是本内容的指纹名就沿用(保证重复迁移幂等),否则按内容指纹改名
|
||||||
|
if (isFingerprintedName($rel, $binary)) {
|
||||||
|
$newKey = $rel;
|
||||||
|
} else {
|
||||||
|
$ext = strtolower(pathinfo($rel, PATHINFO_EXTENSION));
|
||||||
|
// 头像目录沿用 avatar 角色段,与正常上传的命名保持一致(便于人工排查)
|
||||||
|
$role = (basename(dirname($abs)) === 'avatars') ? 'avatar' : '';
|
||||||
|
$newKey = relPathOfDir(dirname($abs)) . fingerprintedFilename($binary, $ext, $role);
|
||||||
|
}
|
||||||
|
|
||||||
|
$up = $driver->putFile($abs, $newKey, storageMimeOfFile($abs));
|
||||||
|
if (empty($up['ok'])) {
|
||||||
|
$failed++;
|
||||||
|
if (count($errors) < 20) $errors[] = $rel . ' → ' . ($up['error'] ?? '上传失败');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 顺序不可颠倒:先上传成功 → 再改写数据库引用 → 再同步本地文件名 → 最后才删。
|
||||||
|
// 任一步失败就停在这里,本地文件仍在,"本地优先" 渲染保证页面不会裂图。
|
||||||
|
if ($newKey !== $rel) {
|
||||||
|
$refs += storageRewriteImageRef($rel, $newKey);
|
||||||
|
$renamed += 1;
|
||||||
|
|
||||||
|
// 本地文件同步改成指纹名:一是本地 / 云端 / 数据库三者保持一致,
|
||||||
|
// 二是——即便不删除本地文件——下次迁移看到的已是指纹名,可直接跳过,
|
||||||
|
// 从而让「重复迁移」真正幂等(不会每次又换一个新的随机后缀)。
|
||||||
|
$newAbs = ROOT_DIR . '/' . $newKey;
|
||||||
|
if ($abs !== $newAbs && @rename($abs, $newAbs)) {
|
||||||
|
$abs = $newAbs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$migrated++;
|
||||||
|
if ($deleteLocal) {
|
||||||
|
@unlink($abs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按「下标」推进:本批就是清单里的 [start, start+count(batch)) 段
|
||||||
|
$processed = count($batch);
|
||||||
|
$next = $start + $processed;
|
||||||
|
$remaining = max(0, $total - $next);
|
||||||
|
if ($remaining === 0) {
|
||||||
|
// 本次跑完,清掉清单;下次点迁移会重新冻结一份(此时本地已是指纹名,直接复用键)
|
||||||
|
deleteSetting('storage_migrate_queue');
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonOut([
|
||||||
|
'ok' => true,
|
||||||
|
'processed' => $processed,
|
||||||
|
'migrated' => $migrated,
|
||||||
|
'renamed' => $renamed,
|
||||||
|
'refs' => $refs,
|
||||||
|
'failed' => $failed,
|
||||||
|
'errors' => $errors,
|
||||||
|
'cursor' => $token . '|' . $next,
|
||||||
|
'has_more' => $remaining > 0,
|
||||||
|
'total' => $total,
|
||||||
|
'remaining' => $remaining,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
jsonOut(['ok' => false, 'error' => '未知操作']);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user