Files
tamabox/docs/theme-assets-cdn.md
tamakyi 12c4f3e1be 生成器可检测与管理远端主题素材(列表 / 差异 / 覆盖 / 删除)
「传上去」之后还得知道「传得对不对」:同一 slug 传过三次、换过 slug 旧图还在、
重新导出后远端仍是旧版,这些在页面上都看不出来。

- serve.py:s3_request 支持 query;新增 ListObjectsV2(含分页)与 DeleteObject,
  端点 /api/s3/list、/api/s3/delete。
- index.html:远端素材清单按对象键名跟本地比 —— 一致 / 旧图(同名但大小不同)/
  多余(当前主题已不引用)/ 缺失。「补齐差异」只传缺的与旧图,「删除多余」批量删,
  单行可 ↻ 覆盖或 ✕ 删除;删除走 confirm 二次确认。改过 slug 会提示重新检测。
- 修:list 返回完整键而 upload/delete 收相对键,不剥前缀会删到不存在的键
  (S3 回 204,看起来删成功其实没删)。加 relKey() 统一。
- check_sigv4.py 补带 query 的用例(ListObjectsV2 三参数、乱序 token)→ 8/8;
  selftest_upload.py 补列表/分页/幂等删除 → 20 项全过。
2026-10-21 21:29:35 +08:00

136 lines
6.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 把主题素材放到对象存储(任意 S3 兼容)
这里的「对象存储」不特指某一家:Cloudflare R2、自建 MinIO、Backblaze B2、
阿里云 OSS 的 S3 兼容端点、腾讯云 COS 的 S3 兼容端点都走同一套 SigV4,
差别只在 Endpoint、Region、是否路径风格。**放本地还是放对象存储,由部署者自己决定** ——
主题生成器里「素材托管」就是这两选一。
现状:主题素材在 `static/images/themes/<slug>/`,通过 `static/fs.go` 的 `//go:embed images/themes`
随二进制发布。好处是部署即生效、不依赖外部服务;代价是**换一张图就要重新编译部署**,
而且一张 1920×1080 的场景图会让二进制大几百 KB。
主题素材只有一处引用:**主题 CSS 里的 `url(...)`**(`--tb-page-image` / `--tb-page-pattern` /
`--tb-deco`)。分享卡是 Go 里用 `imaging` 画出来的,不读主题素材。所以只要解决「CSS 里的地址指向哪」,
就把整套素材搬走了。
下面三种做法按「改动量从 0 到有代码」排列,选一个即可,不必叠加。
---
## A. 生成器直接写绝对地址 + 就近上传(已支持,推荐)
`dev/theme-builder` 左侧「**素材托管**」有两个选项:本地(随二进制)/ S3 兼容存储。
选 S3 后填:
```
Endpoint https://<账号>.r2.cloudflarestorage.com ← MinIO / B2 / OSS / COS 填各自的
Region auto ← R2 用 auto;MinIO 常为 us-east-1
桶名 my-theme-assets
路径前缀 themes
公开前缀 https://cdn.example.com/themes ← 留空 = 按 Endpoint 推导
Access Key / Secret Key
```
然后:
1. **测试连接** —— `HEAD` 桶(连通+凭据)→ `PUT` 探针(写权限)→ `GET` 读回(内容一致)
→ 匿名 `GET`(能不能被浏览器直接读到)→ 删除探针。失败会把 S3 的 `<Code>` 翻成人话。
2. **上传素材** —— 逐张 PUT 本次主题引用到的图,键为 `<路径前缀>/<slug>/<id>.webp`,
带 `public, max-age=31536000, immutable`。成功后自动把导出用的「素材前缀」填好。
3. **检测远端** —— `ListObjectsV2` 列出 `<前缀>/<slug>/` 下所有对象,按对象键名跟本地比:
一致 / 旧图(同名但大小不同)/ 多余(当前主题已不引用)/ 缺失。
可以「补齐差异」(只传缺的与旧图)、「删除多余」,也可以单行覆盖或删除。
上传经本机 `serve.py` 代理(`/api/s3/test`、`/api/s3/upload`),浏览器不直连对象存储:
**不需要给桶配 CORS**,密钥只在当前标签页的 sessionStorage 与内存里,
不进草稿、不进工程 json、不进导出 zip。
不想用面板也行,导出的 zip 里图片照旧在 `static/images/themes/<slug>/*.webp`,
用任何 S3 客户端推上去即可:
```bash
aws s3 sync static/images/themes/shiroko/ s3://my-bucket/themes/shiroko/ \
--endpoint-url https://<你的 Endpoint> --cache-control "public, max-age=31536000, immutable"
sed -i 's#/static/images/themes/shiroko/#https://cdn.example.com/themes/shiroko/#g' \
static/css/themes/shiroko.css # 已有主题不想重导时,等价于「填前缀再导一次」
```
- 优点:零服务端改动、零跳转、缓存最优(CDN 直接命中);
- 缺点:域名烘焙进 CSS —— 换域名/换路径要**重新导出**(旧主题可以用 `sed` 批量替换);
- 仓库里可以删掉 `static/images/themes/<slug>/` 来缩小编译产物,**确认 CSS 已是绝对地址**再删。
---
## B. 应用内 301 跳转(配置驱动,CSS 一行都不用改)
`conf/app.ini`:
```ini
[theme]
asset_base = https://cdn.example.com/themes ; 留空 = 本地静态目录
```
中间件必须在 `flamego.Static` **之前**注册(`internal/route/route.go` 里 `f.Use(flamego.Static(...))` 前面):
```go
// 主题素材转发到对象存储:命中前缀就 301 出去,其余请求原样放过。
// 必须挂在 static 中间件之前——Static 命中本地文件就直接返回了,轮不到这里。
func themeAssetRedirect() flamego.Handler {
const prefix = "/static/images/themes/"
return func(ctx flamego.Context) {
base := theme.AssetBase() // 从 conf 读,空串 = 不接管
p := ctx.Request().URL.Path
if base == "" || !strings.HasPrefix(p, prefix) {
return
}
w := ctx.ResponseWriter()
w.Header().Set("Cache-Control", "public, max-age=86400")
http.Redirect(w, ctx.Request(), base+strings.TrimPrefix(p, prefix), http.StatusMovedPermanently)
}
}
```
- 优点:已有主题、生成器导出的相对地址**都能用**,切 CDN 只改配置;
- 缺点:冷缓存时每张图多一跳;**301 会被浏览器长期缓存**,以后想切回本地,
老访客可能还在访问 CDN(介意的话用 `302`,或一开始就用方案 A);
- 本地开发记得把 `asset_base` 留空,否则断网时素材全 404。
---
## C. 边缘 / 反代改写(零代码)
素材路径有稳定前缀,最适合在边缘处理:
- **Cloudflare**:Origin Rule / Transform Rule,把 `/static/images/themes/*` 的 Host 改成
R2 自定义域(如 `cdn.example.com`),或 Redirect Rule 直接 301 过去;
- **nginx**:`location /static/images/themes/ { proxy_pass https://cdn.example.com/themes/; }`,
或 `rewrite` 成 CDN 地址后 `return 301`。
- 优点:应用完全不知道素材在哪,不占应用带宽;
- 缺点:绑定部署环境,本地跑没有等效层(要考虑 fallback)。
---
## 三种方案共同要处理的细节
1. **缓存**:对象存储上建议
`Cache-Control: public, max-age=31536000, immutable`(文件名带版本时)。
换图**不要覆盖同名对象**——要么换 `id`(生成器里改文件名),要么给 URL 加 `?v=`;
Cloudflare 默认把 query string 计入缓存键,所以 `?v=` 是有效的。
2. **CORS 不用配**:`background-image` 不触发 CORS,匿名 GET 即可。
3. **主题 CSS 本身不用上 CDN**:一个主题只有几 KB,而且随 `StaticVersion` 走缓存键,
留在 `static/css/themes/` 更省事。
4. **深色模式没有素材**(深色块 `--tb-deco` 恒为 `none`),别只传浅色图就以为漏传了。
5. **分享卡不受影响**:`route/question/share.go` 用 `imaging` 直接画,不加载主题图片。
6. 迁完之后 `go build` 会小一圈,但**主题 css 仍是编译期 embed**:改 CSS 还是要重编译部署。
---
## 建议
- 只是想让部署产物变小、换图不用重编译 → **A**(生成器里配好 S3、测通、一键上传,最省事,缓存也最好)。
- 想保留「素材可以随时换、CSS 不用重发」的灵活性 → **B**(配一个 `asset_base`,接受多一跳)。
- 站点已经在 Cloudflare 上、且愿意在边缘配规则 → **C**(最干净,但迁移成本在运维侧)。
- 就用本地目录、不想依赖任何外部服务 → **什么都不用做**(默认行为,素材随二进制发布)。