主题素材可放任意 S3 兼容存储,生成器内测通后一键上传

素材位置改成两选一(本地随二进制 / 自行填 Endpoint 的 S3 兼容存储),
R2、MinIO、B2、OSS-COS 的 S3 端点都只差 Endpoint/Region/路径风格。

- serve.py:手写 SigV4(PUT/GET/DELETE/HEAD),新增 /api/s3/test 与
  /api/s3/upload。测试连接依次验连通、写权限、读回一致、匿名可读,
  失败把 S3 的 <Code> 翻成可执行的建议;上传带 immutable 缓存头。
- index.html:新增「素材托管」面板(模式切换 + 7 个字段 + 测试/上传按钮),
  上传集合与导出共用 usedAssetIds();成功后自动回填「素材前缀」。
  密钥只进 sessionStorage 与内存,不进草稿/工程 json/导出 zip。
- 修:usedAssetIds 返回 Set,误用 length/map 导致「还没有素材」。
- 修:字段填完 700ms 内刷新会丢配置 → 失焦与模式切换时立即落盘。
- 自检:check_sigv4.py 与 aws-sdk-go-v2 signer 逐字节对拍(6/6),
  selftest_upload.py 起假 S3 跑通测试连接→上传→读回(16 项),
  mock_s3.py 供手动点面板用。
This commit is contained in:
tamakyi
2026-10-21 21:08:39 +08:00
parent 6c21184733
commit 9c961b3c05
9 changed files with 1220 additions and 30 deletions
+4
View File
@@ -37,3 +37,7 @@ go.work.sum
# 默认目录由 [security] honeypot_cred_dir 决定(缺省 data/honeypot-creds), # 默认目录由 [security] honeypot_cred_dir 决定(缺省 data/honeypot-creds),
# 若改成别的盘外路径则不受这条影响(那本来也不在仓库里)。 # 若改成别的盘外路径则不受这条影响(那本来也不在仓库里)。
/data/ /data/
# 主题生成器的自测脚本是 Python:跑一次就会留 __pycache__,别提交。
__pycache__/
*.pyc
+51
View File
@@ -0,0 +1,51 @@
// 临时校验程序:用 aws-sdk-go-v2 的 SigV4 signer 给同一请求签名,
// 与 dev/theme-builder/serve.py 手写的实现比对。验证完即删。
//
// 每行 stdin:method \t url \t region \t payloadHash \t amzDate
package main
import (
"bufio"
"context"
"fmt"
"net/http"
"os"
"strings"
"time"
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
"github.com/aws/aws-sdk-go-v2/credentials"
)
func main() {
keyID := os.Getenv("AWS_KEY_ID")
secret := os.Getenv("AWS_SECRET")
s := bufio.NewScanner(os.Stdin)
for s.Scan() {
line := strings.TrimSpace(s.Text())
if line == "" {
continue
}
f := strings.Split(line, "\t")
method, urlStr, region, payloadHash, ts := f[0], f[1], f[2], f[3], f[4]
t, err := time.Parse("20060102T150405Z", ts)
if err != nil {
panic(err)
}
req, err := http.NewRequest(method, urlStr, nil)
if err != nil {
panic(err)
}
req.Header.Set("X-Amz-Content-Sha256", payloadHash)
creds := credentials.NewStaticCredentialsProvider(keyID, secret, "")
c, err := creds.Retrieve(context.Background())
if err != nil {
panic(err)
}
if err := v4.NewSigner().SignHTTP(context.Background(), c, req, payloadHash, "s3", region, t); err != nil {
panic(err)
}
fmt.Println(req.Header.Get("Authorization"))
}
}
+49 -1
View File
@@ -190,13 +190,61 @@ WebP 体积 ≈ 像素数 × 质量。**真正的大头是像素**:CSS 里只
| --- | --- | --- | | --- | --- | --- |
| 质量 | 0.88 | 0.85~0.9 肉眼几乎看不出差别,低于 0.8 才明显糊 | | 质量 | 0.88 | 0.85~0.9 肉眼几乎看不出差别,低于 0.8 才明显糊 |
| 长边上限 | 1600 | 只缩不放。整页背景建议 1920/不限制;装饰人物 1200 够用 | | 长边上限 | 1600 | 只缩不放。整页背景建议 1920/不限制;装饰人物 1200 够用 |
| 素材前缀 | 空 | 空 = 图片放 `static/images/themes/<slug>/` 随二进制发布;填 `https://cdn.example.com/themes` = CSS 写绝对地址,图片自己传对象存储(R2/OSS)。**前缀里不要带 slug** | | 素材前缀 | 空 | 空 = 图片放 `static/images/themes/<slug>/` 随二进制发布;填 `https://cdn.example.com/themes` = CSS 写绝对地址,图片放自己的对象存储(R2 / MinIO / B2 / OSS / COS,任何 S3 兼容都行)。**前缀里不要带 slug** |
导出的 CSS 只有 `urlOf()` 一处决定图片地址,图层、背景、窄屏块全走它,不会出现 导出的 CSS 只有 `urlOf()` 一处决定图片地址,图层、背景、窄屏块全走它,不会出现
「背景走 CDN、装饰还指本地」的半吊子状态。素材前缀只影响导出,预览永远是内联 dataURL。 「背景走 CDN、装饰还指本地」的半吊子状态。素材前缀只影响导出,预览永远是内联 dataURL。
详细取舍(生成器前缀 / 应用内 301 / 边缘改写)见 `docs/theme-assets-cdn.md`。 详细取舍(生成器前缀 / 应用内 301 / 边缘改写)见 `docs/theme-assets-cdn.md`。
## 素材托管(本地 / 任意 S3 兼容)
「图片放哪」由你自己定,面板里二选一:
- **本地**(默认):打进 `static/images/themes/<slug>/`,随二进制发布。部署即生效、不依赖外部服务,
代价是换一张图要重新编译。
- **S3 兼容存储**:传到你自己的对象存储,导出的 CSS 写绝对地址。这里刻意不锁厂商 ——
R2 / MinIO / Backblaze B2 / 阿里 OSS(S3) / 腾讯 COS(S3) 用的是同一套 SigV4,
差别只在 `Endpoint`、`Region`、是否路径风格,全都做成了字段。
填好 Endpoint / Region / 桶名 / 路径前缀 / 密钥后:
1. **测试连接** —— 依次验四件事:`HEAD` 桶(连通与凭据)、`PUT` 一个探针对象(写权限)、
`GET` 读回比对(内容一致)、匿名 `GET`(传上去的图能不能被浏览器直接读到),最后删掉探针。
失败时会把 S3 的 `<Code>` 翻成人话(`SignatureDoesNotMatch` → 密钥/Region/时钟三选一)。
2. **上传素材** —— 把本次主题真正引用到的图(与导出同一个集合,`usedAssetIds()`)逐张 PUT,
键为 `<路径前缀>/<slug>/<id>.webp`,带 `Cache-Control: public, max-age=31536000, immutable`。
成功后自动把上面的「素材前缀」填成推导出的公开地址。
⚠️ 上传走的是本机 `serve.py` 的 `/api/s3/test`、`/api/s3/upload`,浏览器不直连对象存储 ——
所以**不用给桶配 CORS**,密钥也不会出现在页面发往第三方的请求里。它只存在当前标签页的
`sessionStorage` 与内存:不进 `localStorage` 草稿、不进工程 json、不进导出 zip。
用 `python -m http.server` 起的只有静态服务,探测不到接口时按钮会禁用并说明原因。
公开地址留空时按 endpoint 推导(path 风格 = `<endpoint>/<桶>/<前缀>`)。R2 与私有桶
需要自定义域或 CDN 域名,那种情况自己填「公开前缀」。测试里的「匿名可读」会给一条提醒,
但不会拦上传 —— 很多人就是靠自定义域分发的。
厂商怎么填(2026 年的常见值,各家控制台都有「S3 API」页面):
| 厂商 | Endpoint | Region | 路径风格 |
| --- | --- | --- | --- |
| Cloudflare R2 | `https://<账号ID>.r2.cloudflarestorage.com` | `auto` | 勾选 |
| MinIO(自建) | `http://<host>:9000` | `us-east-1`(看服务端配置) | 勾选 |
| Backblaze B2 | `https://s3.<区域>.backblazeb2.com` | 区域名,如 `us-west-004` | 勾选 |
| 阿里云 OSS | `https://s3.oss-<区域>.aliyuncs.com` | 区域,如 `cn-hangzhou` | 看控制台 |
| 腾讯云 COS | `https://cos.<区域>.myqcloud.com` | 区域,如 `ap-hangzhou` | 看控制台 |
AWS S3 或已绑定的桶级自定义域可以取消「路径风格」(走 `<桶>.<endpoint>`)。
两条自检(都在本目录,不依赖 UI):
```bash
python dev/theme-builder/check_sigv4.py # 与 aws-sdk-go-v2 的 signer 逐字节对拍
python dev/theme-builder/selftest_upload.py # 起假 S3,跑「测试连接 → 上传 → 读回」
python dev/theme-builder/mock_s3.py --port 9900 # 想手动点面板时,先起这个假 S3
```
面板底部实时显示 `3 张,共 19KB;最大一张 bg.webp 7KB(1920×1080)`。 面板底部实时显示 `3 张,共 19KB;最大一张 bg.webp 7KB(1920×1080)`。
改完立刻按「原图 + fx 参数」重编码全部素材,并报出省了多少 —— 与图片处理同一条非破坏原则, 改完立刻按「原图 + fx 参数」重编码全部素材,并报出省了多少 —— 与图片处理同一条非破坏原则,
来回试不会叠加压缩。 来回试不会叠加压缩。
+96
View File
@@ -0,0 +1,96 @@
"""校验 serve.py 手写的 SigV4 与 aws-sdk-go-v2 的 signer 逐字节一致。
为什么需要这个:SigV4 错一个字节,服务端只会回一句 SignatureDoesNotMatch,
既不说哪里错、也不会在本地暴露出来 —— 上传功能就成了「点了没反应」。
这里用官方 SDK 当参照实现做对拍,改完签名相关代码跑一遍即可。
用法(项目根):
python dev/theme-builder/check_sigv4.py
⚠️ 用例只放 ASCII 键:aws-sdk-go-v2 裸 SignHTTP 默认会二次转义 URI,
而 S3 客户端内部把 DisableURIPathEscaping 设成了 true(单次编码)。
即同一个路径两种行为,把非 ASCII 键放进来只会比出「SDK 的两个模式之差」。
我们跟随 S3 客户端的单次编码(AWS 文档的 RFC3986 规则),并由生成器把
slug 限制在 [a-z0-9-_],从根上避开这个争议区。
"""
import os
import subprocess
import sys
import urllib.parse
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(HERE))
sys.path.insert(0, HERE)
import serve # noqa: E402
KEY_ID = "AKIAIOSFODNN7EXAMPLE"
SECRET = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
TS = "20130524T000000Z"
EMPTY = serve.EMPTY_SHA256
BODY_HASH = serve._sha256(b"TamaBox theme builder probe\n")
# (说明, method, endpoint, extra cfg, key)
CASES = [
("path-style 桶级 HEAD", "HEAD", "http://127.0.0.1:9000", dict(bucket="tb-assets"), ""),
("path-style 带前缀对象", "PUT", "http://127.0.0.1:9000",
dict(bucket="tb-assets", prefix="themes"), "shiroko/bg1.webp"),
("virtual-host 对象", "GET", "https://s3.us-west-004.backblazeb2.com",
dict(bucket="my-bucket", region="us-west-004", pathStyle=False), "themes/shiroko/bg1.webp"),
("R2 region=auto", "PUT", "https://acc.r2.cloudflarestorage.com",
dict(bucket="cdn", region="auto", prefix="themes"), "x/a.webp"),
("R2 虚拟主机", "GET", "https://acc.r2.cloudflarestorage.com",
dict(bucket="cdn", region="auto", pathStyle=False), "themes/s/t.webp"),
("带端口 https", "DELETE", "https://minio.lan:9443",
dict(bucket="static", region="us-east-1"), "themes/s/t.webp"),
]
def build(endpoint, extra, key):
cfg = serve.S3Config(dict(extra, endpoint=endpoint, accessKey=KEY_ID, secretKey=SECRET))
if key:
path = cfg.object_path(key)
else:
path = ("/" + cfg.bucket) if cfg.path_style else "/"
return cfg, path
def go_sign(payloads):
env = dict(os.environ, AWS_KEY_ID=KEY_ID, AWS_SECRET=SECRET,
GOPROXY="https://goproxy.cn,direct", GOSUMDB="off", GOFLAGS="-mod=mod")
p = subprocess.run(["go", "run", "./dev/sigv4ref"], input="\n".join(payloads),
capture_output=True, text=True, env=env, cwd=ROOT)
if p.returncode != 0:
sys.stderr.write(p.stderr[-2000:])
raise SystemExit("go run 失败(检查 GOPROXY / Go 是否可用)")
return [x for x in p.stdout.strip().splitlines() if x.strip()]
def main():
payloads = []
for _, method, endpoint, extra, key in CASES:
cfg, path = build(endpoint, extra, key)
body = BODY_HASH if method == "PUT" else EMPTY
payloads.append("\t".join([method, cfg.url(path), cfg.region, body, TS]))
go = go_sign(payloads)
if len(go) != len(CASES):
raise SystemExit("返回条数不对:go=%d cases=%d" % (len(go), len(CASES)))
bad = 0
for (desc, method, endpoint, extra, key), g in zip(CASES, go):
cfg, path = build(endpoint, extra, key)
body = BODY_HASH if method == "PUT" else EMPTY
py = serve.sign_v4(cfg, method, path, body, TS, cfg.host)
if g.rsplit("Signature=", 1)[-1] == py.rsplit("Signature=", 1)[-1]:
print("OK %s" % desc)
else:
bad += 1
print("DIFF %s\n go %s\n py %s\n path %s" % (desc, g, py, path))
print("\n%d/%d 一致" % (len(CASES) - bad, len(CASES)))
return 1 if bad else 0
if __name__ == "__main__":
raise SystemExit(main())
+375 -10
View File
@@ -103,6 +103,20 @@
.grid9 button{padding:0;height:22px;border-radius:4px;font-size:0} .grid9 button{padding:0;height:22px;border-radius:4px;font-size:0}
.grid9 button.on{background:var(--ui-accent)} .grid9 button.on{background:var(--ui-accent)}
/* 素材托管:两选一(本地 / S3 兼容),选 S3 才展开字段。
字段一律「短标签 + 输入框」,标签宽度按最长那个(公开访问前缀 = 84px)对齐,
否则同一列里会被 flex 压成参差的锯齿。 */
.hostpick{display:flex;gap:6px;margin-bottom:9px}
.hostpick button{flex:1;font-size:11px;padding:4px 6px}
.s3stat{font-size:11px;padding:6px 8px;border-radius:6px;border:1px solid var(--ui-line);
margin-top:8px;line-height:1.55;white-space:pre-wrap;word-break:break-all}
.s3stat.ok{color:var(--ui-ok);border-color:#2f5744}
.s3stat.bad{color:var(--ui-bad);border-color:#5a3036}
.s3stat.warn{color:var(--ui-warn);border-color:#5a4a2a}
.s3acts{display:flex;gap:6px;margin-top:8px}
.s3acts button{flex:1}
.s3acts button:disabled{opacity:.45;cursor:not-allowed}
.assets{display:grid;grid-template-columns:repeat(4,1fr);gap:6px} .assets{display:grid;grid-template-columns:repeat(4,1fr);gap:6px}
.as{ .as{
position:relative;aspect-ratio:1;border-radius:6px;overflow:hidden;background:#0b0f14; position:relative;aspect-ratio:1;border-radius:6px;overflow:hidden;background:#0b0f14;
@@ -235,6 +249,11 @@
<div id="export-editor"></div> <div id="export-editor"></div>
</section> </section>
<section>
<h2>素材托管 <span class="r"><span class="conn" id="host-state">本地</span></span></h2>
<div id="host-editor"></div>
</section>
<section> <section>
<h2>配色</h2> <h2>配色</h2>
<div class="pal" id="palette"></div> <div class="pal" id="palette"></div>
@@ -342,6 +361,18 @@ const D = {
填了(如 https://cdn.example.com/themes)= 导出的 CSS 写绝对 URL,图片放对象存储。 填了(如 https://cdn.example.com/themes)= 导出的 CSS 写绝对 URL,图片放对象存储。
前缀里不要带 slug,slug 由导出时自动拼在后面。 */ 前缀里不要带 slug,slug 由导出时自动拼在后面。 */
assetBase: '', assetBase: '',
/* 素材托管:local = 随二进制发布;s3 = 任意 S3 兼容存储(R2 / MinIO / B2 / OSS / COS ...)。
密钥不在这里 —— 它只活在浏览器内存与 sessionStorage 里,绝不进工程文件。
publicBase 留空时按 endpoint 推导,所以这里默认空串而不是假域名。 */
host: {
mode: 'local',
endpoint: '',
region: 'auto',
bucket: '',
prefix: 'themes',
pathStyle: true,
publicBase: '',
},
dark: { dark: {
bg:'#141a22', surface:'#1d2530', navbar:'#141a22', bg:'#141a22', surface:'#1d2530', navbar:'#141a22',
text:'#e3eaf4', primary:'#6fa3e0', border:'#334052', text:'#e3eaf4', primary:'#6fa3e0', border:'#334052',
@@ -361,6 +392,9 @@ const S = {
narrow: Object.assign({}, D.narrow), narrow: Object.assign({}, D.narrow),
export: Object.assign({}, D.export), export: Object.assign({}, D.export),
assetBase: '', assetBase: '',
host: Object.assign({}, D.host),
/* 运行时状态:测试/上传结果,不参与持久化 */
s3: { kind: '', text: '', busy: false, canUpload: null },
dark: Object.assign({}, D.dark), dark: Object.assign({}, D.dark),
}; };
@@ -1593,9 +1627,12 @@ function renderExportEditor(){
if (S.assetBase){ if (S.assetBase){
const cdn = document.createElement('p'); const cdn = document.createElement('p');
cdn.className = 'hint'; cdn.className = 'hint';
// 已经在「素材托管」里配了 S3 的人,不需要再被教一遍「把 zip 里的图传上去」
const how = S.host.mode === 's3'
? '用下面的「素材托管」直接上传即可(配好 Endpoint / 桶 / 密钥 → 测试连接 → 上传素材);'
: `记得把 zip 里的 static/images/themes/${S.slug}/ 整个传到这个路径下;仓库里就不需要这些图了。`;
cdn.textContent = `图片地址将写成 ${S.assetBase.replace(/\/+$/, '')}/${S.slug}/<id>.webp —— ` cdn.textContent = `图片地址将写成 ${S.assetBase.replace(/\/+$/, '')}/${S.slug}/<id>.webp —— `
+ '记得把 zip 里的 static/images/themes/' + S.slug + '/ 整个传到这个路径下;' + how + '换 CDN 域名要重新导出 CSS。';
+ '仓库里就不需要这些图了。换 CDN 域名要重新导出 CSS。';
el.appendChild(cdn); el.appendChild(cdn);
} }
@@ -1607,6 +1644,313 @@ function renderExportEditor(){
el.appendChild(tip); el.appendChild(tip);
} }
/* ---------------------- 素材托管(本地 / 任意 S3 兼容) ---------------------- */
/* 素材放哪有两选:
local —— 打进 static/images/themes/<slug>/,随二进制发布,部署即生效;
s3 —— 传到自己选的对象存储,导出的 CSS 写绝对 URL。
第二项刻意不锁厂商:R2 / MinIO / Backblaze B2 / 阿里 OSS(S3) / 腾讯 COS(S3) 用的是
同一套 SigV4,差别只在 endpoint、region、是否路径风格 —— 那就全做成字段,让人自己填。
上传走本机 serve.py 的代理(/api/s3/upload):浏览器不直连存储,因此不用配 CORS,
密钥也不会跟着页面请求发到第三方去。 */
const SEC_SESSION_KEY = 'tamabox-theme-builder-s3-secrets';
const s3Secrets = { accessKey: '', secretKey: '' };
try {
// sessionStorage 而不是 localStorage:关掉标签页就该忘掉密钥。
const raw = sessionStorage.getItem(SEC_SESSION_KEY);
if (raw) Object.assign(s3Secrets, JSON.parse(raw) || {});
} catch (e) { /* 隐私模式 / file:// 下取不到,忽略 */ }
function saveSecrets(){
try { sessionStorage.setItem(SEC_SESSION_KEY, JSON.stringify(s3Secrets)); } catch (e) {}
}
/** 按 endpoint 推导公开访问前缀。桶若开了公开读(或 MinIO 直连)就能直接命中;
R2 / 私有桶需要自定义域,那种情况请自己填「公开访问前缀」。 */
function derivePublicBase(h){
const raw = String(h.endpoint || '').trim();
if (!raw || !h.bucket) return '';
let u;
try { u = new URL(/^https?:\/\//i.test(raw) ? raw : 'https://' + raw); }
catch (e) { return ''; }
const prefix = String(h.prefix || '').replace(/^\/+|\/+$/g, '');
const base = h.pathStyle
? `${u.origin}/${h.bucket}`
: `${u.protocol}//${h.bucket}.${u.host}`;
return prefix ? `${base}/${prefix}` : base;
}
/** 最终写进 CSS 的前缀(不带 slug)。手填优先,空则推导。 */
function publicBaseOf(h){
const own = String(h.publicBase || '').trim().replace(/\/+$/, '');
return own || derivePublicBase(h).replace(/\/+$/, '');
}
function s3Config(){
const h = S.host;
return {
endpoint: String(h.endpoint || '').trim(),
region: String(h.region || '').trim() || 'auto',
bucket: String(h.bucket || '').trim(),
prefix: String(h.prefix || '').trim().replace(/^\/+|\/+$/g, ''),
pathStyle: !!h.pathStyle,
accessKey: s3Secrets.accessKey,
secretKey: s3Secrets.secretKey,
};
}
function s3Missing(cfg){
const miss = [];
if (!cfg.endpoint) miss.push('Endpoint');
if (!cfg.bucket) miss.push('桶名');
if (!cfg.accessKey) miss.push('Access Key');
if (!cfg.secretKey) miss.push('Secret Key');
return miss;
}
function setS3State(kind, text){
S.s3.kind = kind || '';
S.s3.text = text || '';
const chip = $('host-state');
if (chip){
chip.className = 'conn' + (kind === 'ok' ? ' ok' : kind === 'bad' ? ' bad' : '');
chip.textContent = kind === 'ok' ? '已连通' : kind === 'bad' ? '未通过'
: (S.host.mode === 's3' ? 'S3' : '本地');
}
const box = $('s3-result');
if (box){
box.className = 's3stat' + (kind ? ' ' + kind : '');
box.textContent = text || '';
box.style.display = text ? '' : 'none';
}
}
/** 探测本机服务有没有上传接口。`python -m http.server` 起的只有静态文件服务。 */
async function ensureUploadPing(){
if (S.s3.canUpload !== null) return S.s3.canUpload;
try {
const r = await fetch('/api/s3/ping', { cache: 'no-store' });
const j = await r.json();
S.s3.canUpload = !!(j && j.upload);
} catch (e) {
S.s3.canUpload = false;
}
renderHostEditor();
return S.s3.canUpload;
}
async function sbApi(path, body){
const r = await fetch(path, {
method: 'POST', cache: 'no-store',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const txt = await r.text();
try { return JSON.parse(txt); }
catch (e) { throw new Error(`本地服务返回了非 JSON(HTTP ${r.status}):${txt.slice(0, 120)}`); }
}
function renderHostEditor(){
const el = $('host-editor');
if (!el) return;
el.innerHTML = '';
const pick = document.createElement('div');
pick.className = 'hostpick';
[['local', '本地(随二进制)'], ['s3', 'S3 兼容存储']].forEach(([k, text]) => {
const b = document.createElement('button');
b.textContent = text;
if (S.host.mode === k) b.className = 'on';
b.onclick = () => { S.host.mode = k; setS3State('', ''); renderAll(); saveDraft(); };
pick.appendChild(b);
});
el.appendChild(pick);
if (S.host.mode !== 's3'){
const p = document.createElement('p');
p.className = 'hint';
p.textContent = '图片打进 static/images/themes/<slug>/,随二进制发布 —— 部署即生效、不依赖外部服务,'
+ '代价是换一张图要重新编译。选右边可以把素材放到自己的对象存储。';
el.appendChild(p);
setS3State('', '');
return;
}
// ---- 字段 ----
const field = (label, key, opts) => {
opts = opts || {};
const r = document.createElement('div');
r.className = 'row';
const s = document.createElement('span');
s.textContent = label;
s.style.width = '84px';
const i = document.createElement('input');
i.type = opts.password ? 'password' : 'text';
i.spellcheck = false;
i.autocomplete = 'off';
i.placeholder = opts.placeholder || '';
i.style.cssText = 'flex:1 1 0;width:0;min-width:0';
if (opts.password){
i.value = s3Secrets[key] || '';
i.oninput = () => { s3Secrets[key] = i.value; saveSecrets(); };
} else {
i.value = S.host[key] == null ? '' : String(S.host[key]);
i.oninput = () => {
S.host[key] = i.value;
scheduleSave();
if (key === 'endpoint' || key === 'bucket' || key === 'prefix' || key === 'pathStyle') refreshDerived();
};
// 失焦立刻落盘:scheduleSave 有 700ms 防抖,而填完最后一个字段就去点
// 「测试连接 → 上传 → 刷新」时,草稿可能还没写下去,配置就白填了。
i.onchange = () => saveDraft();
}
r.append(s, i);
el.appendChild(r);
return i;
};
const derived = document.createElement('p');
derived.className = 'hint';
derived.id = 's3-derived';
function refreshDerived(){
const auto = derivePublicBase(S.host) || '(填 Endpoint 与桶名后自动推导)';
derived.textContent = '图片地址 = ' + (publicBaseOf(S.host) || auto) + '/' + (($('slug').value || '').trim() || 'my-theme')
+ '/<id>.webp';
}
field('Endpoint', 'endpoint', { placeholder: 'https://<账号>.r2.cloudflarestorage.com' });
field('Region', 'region', { placeholder: 'auto' });
field('桶名', 'bucket', { placeholder: 'my-theme-assets' });
field('路径前缀', 'prefix', { placeholder: 'themes(可留空 = 传桶根)' });
field('公开前缀', 'publicBase', { placeholder: '留空 = 按 Endpoint 推导' });
field('Access Key', 'accessKey', { password: true });
field('Secret Key', 'secretKey', { password: true });
const chk = document.createElement('label');
chk.className = 'chk';
chk.style.margin = '2px 0 0 0';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = !!S.host.pathStyle;
cb.onchange = () => {
S.host.pathStyle = cb.checked;
refreshDerived();
saveDraft();
};
chk.append(cb, document.createTextNode('路径风格(MinIO / R2 / B2 勾选;AWS 与虚拟主机域名取消)'));
el.appendChild(chk);
el.appendChild(derived);
refreshDerived();
const acts = document.createElement('div');
acts.className = 's3acts';
const btnTest = document.createElement('button');
btnTest.textContent = S.s3.busy ? '请稍候…' : '测试连接';
const btnUp = document.createElement('button');
btnUp.className = 'primary';
btnUp.textContent = S.s3.busy ? '请稍候…' : '上传素材';
const can = S.s3.canUpload;
if (can === false){
btnTest.disabled = true;
btnUp.disabled = true;
btnTest.title = btnUp.title = '上传接口不可用';
} else if (S.s3.busy){
btnTest.disabled = btnUp.disabled = true;
}
btnTest.onclick = testS3;
btnUp.onclick = uploadAssets;
acts.append(btnTest, btnUp);
el.appendChild(acts);
const res = document.createElement('div');
res.id = 's3-result';
res.className = 's3stat' + (S.s3.kind ? ' ' + S.s3.kind : '');
res.style.display = S.s3.text ? '' : 'none';
res.textContent = S.s3.text || '';
el.appendChild(res);
const tip = document.createElement('p');
tip.className = 'hint';
tip.textContent = can === false
? '当前是普通静态服务,没有上传接口。用 python dev/theme-builder/serve.py 启动生成器才能上传。'
: '密钥只存在当前标签页(sessionStorage)与内存里,不进工程文件、不进导出 zip。'
+ '上传成功后会自动把「素材前缀」填成上面的图片地址。';
el.appendChild(tip);
if (can === null) ensureUploadPing();
}
async function testS3(){
const cfg = s3Config();
const miss = s3Missing(cfg);
if (miss.length){ setS3State('bad', '还差:' + miss.join('、')); return; }
S.s3.busy = true; setS3State('', '正在测试…'); renderHostEditor();
try {
const j = await sbApi('/api/s3/test', { config: cfg });
if (!j.ok){
const lines = ['测试失败:' + (j.error || '未知错误')];
if (j.code) lines.push('错误码 ' + j.code + (j.status ? `(HTTP ${j.status})` : ''));
if (j.hint) lines.push(j.hint);
setS3State('bad', lines.join('\n'));
log('S3 测试失败:' + (j.error || ''), 'e');
} else {
const lines = (j.steps || []).map(s => `${s.warn ? '·' : '✓'} ${s.name}:${s.detail}`);
const warn = (j.steps || []).some(s => s.warn);
S.s3.lastOK = Date.now();
setS3State(warn ? 'warn' : 'ok', lines.join('\n'));
log('S3 连通性测试通过(' + j.key + ' 已清理)', warn ? 'w' : 'ok');
}
} catch (e){
setS3State('bad', '请求本地服务失败:' + e.message);
} finally {
S.s3.busy = false; renderHostEditor();
}
}
async function uploadAssets(){
const cfg = s3Config();
const miss = s3Missing(cfg);
if (miss.length){ setS3State('bad', '还差:' + miss.join('、')); return; }
const slug = ($('slug').value || '').trim() || 'my-theme';
// usedAssetIds 返回 Set:它有 size 没有 length/map,先展开成数组再用。
const ids = [...usedAssetIds()];
if (!ids.length){ setS3State('bad', '还没有素材:先在「图片池」里加图片并摆到图层/背景上'); return; }
const files = ids.map(id => {
const a = assetById(id);
return {
key: `${slug}/${id}.webp`,
name: `${id}.webp`,
contentType: 'image/webp',
data: (a.dataURL.split(',')[1] || ''),
};
}).filter(f => f.data);
const mb = files.reduce((n, f) => n + f.data.length, 0) * 0.75 / 1048576;
S.s3.busy = true;
setS3State('', `正在上传 ${files.length} 张(约 ${mb.toFixed(2)}MB)…` + '\n首次传较多时会慢一会儿,别关页面。');
renderHostEditor();
try {
const j = await sbApi('/api/s3/upload', { config: cfg, files });
if (!j.ok){
const bad = (j.files || []).filter(f => !f.ok).slice(0, 3)
.map(f => `· ${f.name}:${f.error || f.code || '失败'}`);
setS3State('bad', [`${j.failed || '?'} / ${j.total || files.length} 张失败:`].concat(bad).join('\n'));
log(`素材上传失败 ${j.failed} 张`, 'e');
} else {
const pub = publicBaseOf(S.host);
if (pub) S.assetBase = pub;
saveDraft();
setS3State('ok', `已上传 ${j.total} 张到 ${cfg.prefix || '桶根'}/` + slug + '/\n'
+ '导出的 CSS 会写成 ' + (pub || '(未推导出公开前缀)') + '/' + slug + '/<id>.webp');
log(`素材已上传 ${j.total} 张;素材前缀已设为 ${pub || '(空)'}`, 'ok');
}
} catch (e){
setS3State('bad', '请求本地服务失败:' + e.message);
} finally {
S.s3.busy = false; renderAll();
}
}
/** 改画质后重编全部素材,并把前后体积说清楚 —— 否则用户不知道有没有生效。 */ /** 改画质后重编全部素材,并把前后体积说清楚 —— 否则用户不知道有没有生效。 */
async function applyQuality(what){ async function applyQuality(what){
if (!S.assets.length){ renderAll(); return; } if (!S.assets.length){ renderAll(); return; }
@@ -1700,6 +2044,7 @@ function renderAll(){
renderLayers(); renderLayers();
renderAssets(); renderAssets();
renderExportEditor(); renderExportEditor();
renderHostEditor();
renderNarrowEditor(); renderNarrowEditor();
renderPalette(); renderPalette();
applyTheme(); applyTheme();
@@ -1725,6 +2070,9 @@ function toJSON(){
narrow: S.narrow, narrow: S.narrow,
export: S.export, export: S.export,
assetBase: S.assetBase, assetBase: S.assetBase,
// 托管配置进工程(换机器接着传),但 accessKey/secretKey 刻意不进去:
// 工程文件常被塞进仓库或发给别人,密钥一旦落进去就没法收回了。
host: S.host,
// 图片内嵌 base64:少一层「图片放哪」的约定,导入时一个文件就够。 // 图片内嵌 base64:少一层「图片放哪」的约定,导入时一个文件就够。
// 存的是 orig(原始字节)+ fx(处理参数),dataURL 只在真有处理时才存 —— // 存的是 orig(原始字节)+ fx(处理参数),dataURL 只在真有处理时才存 ——
// 未处理时它必然等于 orig,再存一份等于让 json 平白翻倍。 // 未处理时它必然等于 orig,再存一份等于让 json 平白翻倍。
@@ -1755,6 +2103,8 @@ function fromJSON(o){
scale: MOB_SCALE_DEFAULT }, o.narrow || {}); scale: MOB_SCALE_DEFAULT }, o.narrow || {});
S.export = Object.assign({}, D.export, o.export || {}); S.export = Object.assign({}, D.export, o.export || {});
S.assetBase = typeof o.assetBase === 'string' ? o.assetBase : ''; S.assetBase = typeof o.assetBase === 'string' ? o.assetBase : '';
S.host = Object.assign({}, D.host, o.host || {});
if (S.host.mode !== 's3') S.host.mode = 'local';
S.assets = (o.assets || []).map(a => { S.assets = (o.assets || []).map(a => {
const orig = a.orig || { dataURL: a.dataURL, w: a.w, h: a.h }; const orig = a.orig || { dataURL: a.dataURL, w: a.w, h: a.h };
const dataURL = a.dataURL || orig.dataURL; const dataURL = a.dataURL || orig.dataURL;
@@ -1804,6 +2154,8 @@ function resetAll(){
S.narrow = Object.assign({}, D.narrow); S.narrow = Object.assign({}, D.narrow);
S.export = Object.assign({}, D.export); S.export = Object.assign({}, D.export);
S.assetBase = ''; S.assetBase = '';
// 托管配置回默认,但密钥保留(sessionStorage)—— 换主题往往还是传到同一个桶
S.host = Object.assign({}, D.host);
S.dark = Object.assign({}, D.dark); S.dark = Object.assign({}, D.dark);
S.scopes = {}; S.scopes = {};
SCOPES.forEach(sc => { S.scopes[sc.k] = blankScope(sc.k); }); SCOPES.forEach(sc => { S.scopes[sc.k] = blankScope(sc.k); });
@@ -1975,7 +2327,13 @@ ${S.assetBase
素材托管在对象存储,CSS 写的是绝对地址: 素材托管在对象存储,CSS 写的是绝对地址:
换 CDN 域名 / 路径 = 必须重新导出(地址烘焙在文件里,不是运行时拼的) 换 CDN 域名 / 路径 = 必须重新导出(地址烘焙在文件里,不是运行时拼的)
换图 = 覆盖同名对象,建议把 Cache-Control 设成 immutable + 用 ?v= 或换文件名做失效` 换图 = 覆盖同名对象,建议把 Cache-Control 设成 immutable + 用 ?v= 或换文件名做失效
怎么传上去(两种都行):
a) 生成器左侧「素材托管 → S3 兼容存储」,填 Endpoint / Region / 桶名 / 密钥,
点「测试连接」通过后直接点「上传素材」,前缀会自动填对;
b) 命令行:aws s3 sync static/images/themes/${slug}/ s3://<桶名>/<路径前缀>/${slug}/ --endpoint-url <Endpoint>
(rclone / s5cmd 同理,任何 S3 兼容客户端都行)`
: `1) 解压(保持目录结构,覆盖即可) : `1) 解压(保持目录结构,覆盖即可)
static/css/themes/${slug}.css static/css/themes/${slug}.css
static/images/themes/${slug}/*.webp`} static/images/themes/${slug}/*.webp`}
@@ -2040,13 +2398,9 @@ ${S.narrow.enabled
`; `;
} }
function doExport(){ /** 本次主题真正引用到的素材 id(导出与上传共用同一份判定)。
const errs = validate(); 没有被任何图层/背景引用的图不该进 zip,也不该传到对象存储上占位。 */
if (errs.length){ function usedAssetIds(){
out('先修这几处再导出:\n' + errs.map(e => '· ' + e).join('\n'), 'e');
return;
}
const slug = ($('slug').value || '').trim();
const used = new Set(); const used = new Set();
if (S.narrow.bg) used.add(S.narrow.bg); if (S.narrow.bg) used.add(S.narrow.bg);
const collect = c => { const collect = c => {
@@ -2055,6 +2409,17 @@ function doExport(){
c.layers.forEach(l => { if (l.asset) used.add(l.asset); }); c.layers.forEach(l => { if (l.asset) used.add(l.asset); });
}; };
SCOPES.forEach(s => { if (s.k === 'global' || !S.scopes[s.k].inherit) collect(S.scopes[s.k]); }); SCOPES.forEach(s => { if (s.k === 'global' || !S.scopes[s.k].inherit) collect(S.scopes[s.k]); });
return used;
}
function doExport(){
const errs = validate();
if (errs.length){
out('先修这几处再导出:\n' + errs.map(e => '· ' + e).join('\n'), 'e');
return;
}
const slug = ($('slug').value || '').trim();
const used = usedAssetIds();
const files = [{ name: `static/css/themes/${slug}.css`, data: enc(buildCSS(false)) }]; const files = [{ name: `static/css/themes/${slug}.css`, data: enc(buildCSS(false)) }];
let imgKB = 0, imgN = 0; let imgKB = 0, imgN = 0;
+139
View File
@@ -0,0 +1,139 @@
"""最小 S3 兼容假服务,只为调试生成器的「测试连接 / 上传素材」。
它不校验签名(签名正确性由 check_sigv4.py 与官方 SDK 对拍保证),只校验
请求形态与权限分支,所以能用来演练:桶名写错、密钥写错、前缀为空、
虚拟主机风格这些配置。
独立跑:python dev/theme-builder/mock_s3.py --port 9900
被复用:from mock_s3 import MockS3, BUCKET
"""
import argparse
import sys
import threading
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
BUCKET = "tb-assets"
class MockS3(BaseHTTPRequestHandler):
store = {}
requests = []
def log_message(self, fmt, *args):
pass
# ---- helpers ----
def _split(self):
path = urllib.parse.unquote(self.path.split("?")[0]).lstrip("/")
bucket, _, key = path.partition("/")
return bucket, key
def _key_id(self):
# Authorization 形如
# AWS4-HMAC-SHA256 Credential=AKID/20261021/us-east-1/s3/aws4_request, SignedHeaders=...
# 第一个逗号前既有算法名又有 Credential,不能按逗号切完再比前缀。
auth = self.headers.get("Authorization") or ""
i = auth.find("Credential=")
if i < 0:
return ""
return auth[i + len("Credential="):].split("/")[0]
def _body(self):
n = int(self.headers.get("Content-Length") or 0)
return self.rfile.read(n) if n else b""
def _xml(self, status, code, message):
body = ("<?xml version=\"1.0\"?><Error><Code>%s</Code><Message>%s</Message></Error>"
% (code, message)).encode()
self.send_response(status)
self.send_header("Content-Type", "application/xml")
if self.command != "HEAD":
# HEAD 不能有 body:真 S3 也只是返回状态行,客户端仍会按状态码报错。
self.send_header("Content-Length", str(len(body)))
self.end_headers()
if self.command != "HEAD":
self.wfile.write(body)
def _check(self, bucket):
MockS3.requests.append((self.command, self.path, dict(self.headers)))
if self._key_id() == "BADKEY":
self._xml(403, "SignatureDoesNotMatch",
"The request signature we calculated does not match")
return False
if bucket != BUCKET:
self._xml(404, "NoSuchBucket", "The specified bucket does not exist")
return False
if not (self.headers.get("x-amz-date") or "").endswith("Z"):
self._xml(403, "AccessDenied", "missing x-amz-date")
return False
return True
# ---- verbs ----
def do_HEAD(self):
bucket, _ = self._split()
if not self._check(bucket):
return
self.send_response(200)
self.send_header("Content-Length", "0")
self.end_headers()
def do_PUT(self):
bucket, key = self._split()
data = self._body()
if not self._check(bucket):
return
MockS3.store[key] = (data, self.headers.get("Content-Type"),
self.headers.get("Cache-Control"))
self.send_response(200)
self.send_header("ETag", '"mock"')
self.send_header("Content-Length", "0")
self.end_headers()
def do_GET(self):
bucket, key = self._split()
MockS3.requests.append((self.command, self.path, dict(self.headers)))
if key not in MockS3.store:
self._xml(404, "NoSuchKey", "The specified key does not exist")
return
data, ctype, _ = MockS3.store[key]
self.send_response(200)
self.send_header("Content-Type", ctype or "application/octet-stream")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_DELETE(self):
bucket, key = self._split()
if not self._check(bucket):
return
MockS3.store.pop(key, None)
self.send_response(204)
self.send_header("Content-Length", "0")
self.end_headers()
def spin(port=0):
"""起在后台线程,返回 (server, endpoint)。port=0 让系统挑空闲端口。"""
srv = ThreadingHTTPServer(("127.0.0.1", port), MockS3)
threading.Thread(target=srv.serve_forever, daemon=True).start()
return srv, "http://127.0.0.1:%d" % srv.server_address[1]
def main():
ap = argparse.ArgumentParser(description="TamaBox 主题生成器用的假 S3")
ap.add_argument("--port", type=int, default=9900)
args = ap.parse_args()
srv, endpoint = spin(args.port)
print("假 S3 已启动:%s" % endpoint)
print(" 桶名 %s,Access Key 随意(写 BADKEY 会返回 SignatureDoesNotMatch)" % BUCKET)
print("Ctrl+C 停止")
try:
threading.Event().wait()
except KeyboardInterrupt:
srv.shutdown()
print("\n已停止")
if __name__ == "__main__":
sys.exit(main())
+114
View File
@@ -0,0 +1,114 @@
"""上传链路自测:起一个假 S3,跑一遍「测试连接 → 上传 → 读回」。
覆盖 serve.py 里真正容易错、又不能靠对拍发现的部分:
· 桶级 HEAD 与对象级 PUT/GET/DELETE 的路径拼接(path style / 虚拟主机)
· 前缀拼接、Content-Type、immutable 缓存头
· 4xx 时能否把 S3 的 <Code>/<Message> 还原成人话(含 hint)
签名数学由 check_sigv4.py 与官方 SDK 对拍保证,这里不重复。
用法(项目根):
python dev/theme-builder/selftest_upload.py
"""
import base64
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import serve # noqa: E402
from mock_s3 import BUCKET, MockS3 # noqa: E402
def spin():
import mock_s3
return mock_s3.spin()
FAILS = []
def check(desc, cond, extra=""):
print(("OK " if cond else "FAIL ") + desc + ((" ← " + extra) if extra and not cond else ""))
if not cond:
FAILS.append(desc)
def main():
srv, endpoint = spin()
base = dict(endpoint=endpoint, bucket=BUCKET, prefix="themes", region="us-east-1",
accessKey="AKID", secretKey="SECRET")
# 1) 测试连接:4 步应全过,探针最后被删掉
cfg = serve.S3Config(dict(base))
res = serve.test_connection(cfg)
check("测试连接返回 ok", res.get("ok") is True)
check("测试连接 4 步全过", all(s["ok"] for s in res["steps"]), str(res))
check("探针已清理", not any(k.startswith("themes/.tb-probe") for k in MockS3.store),
str(list(MockS3.store)))
# 2) 上传:前缀 / 内容 / 头都要对
f1 = b"\x52\x49\x46\x46fakewebp1"
f2 = b"\x52\x49\x46\x46fakewebp2"
up = serve.upload_files(cfg, [
{"key": "shiroko/asset1.webp", "contentType": "image/webp",
"data": base64.b64encode(f1).decode()},
{"key": "shiroko/asset2.webp", "contentType": "image/webp",
"data": base64.b64encode(f2).decode()},
])
check("上传全部成功", up["ok"] and up["total"] == 2 and up["failed"] == 0, str(up))
check("前缀拼进对象键", "themes/shiroko/asset1.webp" in MockS3.store, str(list(MockS3.store)))
stored, ctype, cache = MockS3.store.get("themes/shiroko/asset1.webp", (b"", None, None))
check("内容一致", stored == f1)
check("Content-Type = image/webp", ctype == "image/webp", str(ctype))
check("Cache-Control = immutable", "immutable" in (cache or ""), str(cache))
# 3) 错误密钥:HEAD 拿不到响应体(规范如此),只能按状态码归因;
# 真正能读到 S3 错误码的是带 body 的 PUT,所以用上传路径验证还原能力。
bad = serve.S3Config(dict(base, accessKey="BADKEY"))
try:
serve.test_connection(bad)
check("错误密钥应抛错", False)
except serve.S3Error as e:
check("HEAD 失败按状态码归因", e.status == 403 and e.code == "AccessDenied",
"%s %s" % (e.status, e.code))
badUp = serve.upload_files(bad, [{"key": "shiroko/x.webp", "contentType": "image/webp",
"data": base64.b64encode(b"x").decode()}])
codes = [f.get("code") for f in badUp["files"]]
check("PUT 失败能还原 SignatureDoesNotMatch", codes == ["SignatureDoesNotMatch"], str(codes))
check("该错误有中文提示", "签名不匹配" in serve.S3_HINTS.get("SignatureDoesNotMatch", ""))
# 4) 桶名写错:NoSuchBucket
wrong = serve.S3Config(dict(base, bucket="nope"))
try:
serve.test_connection(wrong)
check("错桶应抛错", False)
except serve.S3Error as e:
check("错桶 → NoSuchBucket", e.code == "NoSuchBucket", e.code)
# 5) 键里出现 .. 必须被拒(防止越权写到别的前缀)
try:
serve.full_key(cfg, "../etc/passwd")
check(".. 应被拒", False)
except ValueError:
check(".. 被拒绝", True)
# 6) 前缀留空 = 传桶根
plain = serve.S3Config(dict(base, prefix=""))
up2 = serve.upload_files(plain, [{"key": "root.webp", "contentType": "image/webp",
"data": base64.b64encode(b"x").decode()}])
check("空前缀传桶根", up2["ok"] and "root.webp" in MockS3.store, str(list(MockS3.store)))
# 7) 虚拟主机风格的路径(不带桶名)
vh = serve.S3Config(dict(base, pathStyle=False))
check("虚拟主机路径不含桶名", vh.object_path("themes/a.webp") == "/themes/a.webp", vh.object_path("x"))
check("虚拟主机 host 带桶名", vh.host.startswith(BUCKET + "."), vh.host)
srv.shutdown()
print("\n%d 项失败" % len(FAILS))
return 1 if FAILS else 0
if __name__ == "__main__":
raise SystemExit(main())
+355 -2
View File
@@ -1,15 +1,19 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""主题生成器的本地预览服务。 """主题生成器的本地预览服务 + 素材上传代理。
为什么必须起服务:生成器不再自己画一个"仿制页面",而是把项目真实渲染出来的 为什么必须起服务:生成器不再自己画一个"仿制页面",而是把项目真实渲染出来的
页面(outputs/preview-*.html)放进 iframe 当画板,再往里注入正在编辑的主题 CSS。 页面(outputs/preview-*.html)放进 iframe 当画板,再往里注入正在编辑的主题 CSS。
file:// 下浏览器不允许父页面访问 iframe 的 DOM(即使同目录),预览就只能退回示意图。 file:// 下浏览器不允许父页面访问 iframe 的 DOM(即使同目录),预览就只能退回示意图。
起一个本地 HTTP 服务后父子同源,拖拽、注入、实时改令牌都能正常工作。 起一个本地 HTTP 服务后父子同源,拖拽、注入、实时改令牌都能正常工作。
为什么上传也走这个服务:浏览器直连对象存储要配 CORS、还要把密钥交给页面里的
fetch,各家 S3 兼容实现的 CORS 配置界面又都不一样。放到本机 Python 里代发,
生成器只跟 127.0.0.1 说话:不碰 CORS,密钥不落盘、不进工程文件、不进导出 zip。
用法(在项目根目录执行任意一种): 用法(在项目根目录执行任意一种):
python dev/theme-builder/serve.py python dev/theme-builder/serve.py
python -m http.server 8777 python -m http.server 8777 # 只有预览,没有上传能力
然后打开 http://localhost:8777/dev/theme-builder/ 然后打开 http://localhost:8777/dev/theme-builder/
@@ -17,17 +21,309 @@ file:// 下浏览器不允许父页面访问 iframe 的 DOM(即使同目录)
""" """
import argparse import argparse
import base64
import datetime
import functools import functools
import hashlib
import hmac
import http.server import http.server
import json
import os import os
import re
import socketserver import socketserver
import sys import sys
import urllib.error
import urllib.parse
import urllib.request
# dev/theme-builder/serve.py → 上两级就是项目根 # dev/theme-builder/serve.py → 上两级就是项目根
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# 单次请求体上限:一次上传十几张 1600px 的 WebP,base64 后约 1.4 倍体积,
# 64MB 足够,同时挡住误连到公网时的内存放大。
MAX_BODY_BYTES = 64 * 1024 * 1024
EMPTY_SHA256 = hashlib.sha256(b"").hexdigest()
# ---------------------------------------------------------------------------
# SigV4 签名
# ---------------------------------------------------------------------------
# 不用 boto3:这个脚本的定位是「零依赖、拷走就能跑」,装一个 SDK 只为签四个请求
# 不划算。下面是 AWS 文档里 SigV4 for S3 的最小实现,覆盖 PUT / GET / DELETE / HEAD。
def _hmac(key: bytes, msg: str) -> bytes:
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
def _sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def _quote_key(key: str) -> str:
"""按 AWS 规则编码对象键:保留 `/`,其余非 unreserved 字符全部百分号编码。
用 quote(safe='/-_.~') 而不是逐段 quote:两者等价,但后者容易漏掉空段
(`a//b`)导致的路径塌缩。S3 的签名 URI 必须与请求行完全一致,差一个字符
就是 SignatureDoesNotMatch。
"""
return urllib.parse.quote(key, safe="/-_.~")
def sign_v4(cfg, method, canonical_path, payload_hash, amz_date, host):
region = cfg.region
date = amz_date[:8]
signed_headers = "host;x-amz-content-sha256;x-amz-date"
canonical_headers = (
"host:" + host + "\n"
"x-amz-content-sha256:" + payload_hash + "\n"
"x-amz-date:" + amz_date + "\n"
)
# CanonicalHeaders 自身每行以 \n 结尾,与 SignedHeaders 之间因此有一个空行,
# 这是规范要求的形态,不是笔误。
canonical_request = "\n".join([
method, canonical_path, "", canonical_headers, signed_headers, payload_hash,
])
scope = date + "/" + region + "/s3/aws4_request"
string_to_sign = "\n".join([
"AWS4-HMAC-SHA256", amz_date, scope, _sha256(canonical_request.encode("utf-8")),
])
k = _hmac(("AWS4" + cfg.secret_key).encode("utf-8"), date)
k = _hmac(k, region)
k = _hmac(k, "s3")
k = _hmac(k, "aws4_request")
signature = hmac.new(k, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
return (
"AWS4-HMAC-SHA256 Credential=" + cfg.access_key + "/" + scope
+ ", SignedHeaders=" + signed_headers + ", Signature=" + signature
)
class S3Error(Exception):
def __init__(self, step, status, code, message):
Exception.__init__(self, message or code or ("HTTP %s" % status))
self.step = step
self.status = status
self.code = code
self.message = message or ""
# 常见错误 → 人话。签名失败是最常见的,原因几乎总是密钥/region/时钟三者之一。
S3_HINTS = {
"SignatureDoesNotMatch": "签名不匹配:核对 Secret Key 是否复制完整、Region 是否与桶一致(R2 填 auto),以及本机时间偏差是否超过 15 分钟。",
"InvalidAccessKeyId": "Access Key 无效:核对 ID,或该密钥没有这个桶的权限。",
"AccessDenied": "权限不足:这个密钥没有写权限,或桶策略禁止了该操作。",
"NoSuchBucket": "桶不存在:核对桶名与 Endpoint 是否属于同一账号/区域。",
"PermanentRedirect": "Endpoint 与桶所在区域不匹配:换对该区域的 Endpoint(或改用虚拟主机风格)。",
"AuthorizationHeaderMalformed": "Region 写错了:填桶实际所在区域(R2 用 auto,MinIO 常为 us-east-1)。",
"InvalidBucketName": "桶名不合法:S3 桶名只允许小写字母、数字、点和中划线。",
"RequestTimeTooSkewed": "本机时间与服务器偏差过大:校准系统时间后重试。",
}
def _parse_s3_error(body):
text = body[:2000].decode("utf-8", "replace")
code = re.search(r"<Code>([^<]+)</Code>", text)
msg = re.search(r"<Message>([^<]+)</Message>", text)
return (code.group(1).strip() if code else ""), (msg.group(1).strip() if msg else text.strip()[:200])
class S3Config:
"""一次上传用到的全部参数。只活在内存里,不落盘。"""
def __init__(self, d):
ep = str(d.get("endpoint") or "").strip()
if not ep:
raise ValueError("Endpoint 没填")
if "://" not in ep:
ep = "https://" + ep
u = urllib.parse.urlsplit(ep)
if u.scheme not in ("http", "https"):
raise ValueError("Endpoint 只支持 http / https")
if not u.hostname:
raise ValueError("Endpoint 里没有主机名")
# 路径部分一律忽略:Endpoint 只表达「服务在哪」,桶和键由字段决定。
self.scheme = u.scheme
self.port = u.port
self.host_only = u.hostname
self.region = str(d.get("region") or "").strip() or "us-east-1"
self.bucket = str(d.get("bucket") or "").strip()
if not self.bucket:
raise ValueError("桶名没填")
self.prefix = str(d.get("prefix") or "").strip().strip("/")
self.access_key = str(d.get("accessKey") or "").strip()
self.secret_key = str(d.get("secretKey") or "").strip()
if not self.access_key or not self.secret_key:
raise ValueError("Access Key / Secret Key 没填")
self.path_style = bool(d.get("pathStyle", True))
@property
def host(self):
if self.path_style:
return self.host_only if not self.port else "%s:%d" % (self.host_only, self.port)
return "%s.%s" % (self.bucket, self.host_only) + (":%d" % self.port if self.port else "")
def object_path(self, key):
"""S3 的签名 URI 必须与请求行逐字一致,所以两者共用这一个函数。"""
path = ("/" + self.bucket + "/" + key) if self.path_style else ("/" + key)
return _quote_key(path)
def url(self, path):
return "%s://%s%s" % (self.scheme, self.host, path)
def _now():
return datetime.datetime.now(datetime.timezone.utc)
def s3_request(cfg, method, key="", body=b"", content_type=None, cache_control=None, timeout=25):
"""发一个签名请求。key 为空表示桶级操作(HEAD bucket)。
返回 (status, headers, body);4xx/5xx 不抛异常,交由调用方判断 —— 测试连接
需要把「403 是权限问题」和「连不上是网络问题」区分开讲。
"""
if key:
path = cfg.object_path(key)
else:
path = ("/" + cfg.bucket) if cfg.path_style else "/"
payload_hash = _sha256(body) if body else EMPTY_SHA256
amz_date = _now().strftime("%Y%m%dT%H%M%SZ")
auth = sign_v4(cfg, method, path, payload_hash, amz_date, cfg.host)
req = urllib.request.Request(cfg.url(path), data=body if body else None, method=method)
req.add_header("x-amz-content-sha256", payload_hash)
req.add_header("x-amz-date", amz_date)
req.add_header("Authorization", auth)
if content_type:
req.add_header("Content-Type", content_type)
if cache_control:
req.add_header("Cache-Control", cache_control)
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.status, dict(r.headers), r.read()
except urllib.error.HTTPError as e:
return e.code, dict(e.headers), e.read()
except Exception as e: # URLError / timeout / SSL 错误
raise S3Error("connect", 0, "NetworkError", "%s(%s)" % (e, cfg.host))
def full_key(cfg, key):
key = str(key or "").strip().lstrip("/")
if not key:
raise ValueError("对象键为空")
if ".." in key.split("/") or "\\" in key:
raise ValueError("对象键不能包含 .. 或反斜杠:%s" % key)
return (cfg.prefix + "/" + key) if cfg.prefix else key
def probe_key(cfg):
stamp = _now().strftime("%Y%m%dT%H%M%S")
return full_key(cfg, ".tb-probe-%s.txt" % stamp)
def _public_read(url, timeout=10):
"""匿名 GET 一次,判断「传上去的图能不能直接被浏览器读到」。
这一步不是必须的(很多人会用自定义域/CDN),但它能把最常见的坑当场暴露:
桶是私有的,上传全成功、页面里全是 403。
"""
try:
with urllib.request.urlopen(url, timeout=timeout) as r:
return r.status == 200
except urllib.error.HTTPError as e:
return e.code == 200
except Exception:
return False
def test_connection(cfg):
steps = []
status, _, body = s3_request(cfg, "HEAD")
if status >= 400:
code, msg = _parse_s3_error(body)
if not code:
# HEAD 响应按规范没有 body,S3 的错误码拿不到。只能按状态码归因,
# 否则前端只能显示一句「HTTP 403」,用户完全不知道该改哪。
code = {403: "AccessDenied", 404: "NoSuchBucket",
301: "PermanentRedirect", 307: "PermanentRedirect",
400: "BadRequest"}.get(status, "HTTP%d" % status)
msg = "桶级探测失败(HEAD 无响应体,按状态码判断)"
raise S3Error("HeadBucket", status, code, msg)
steps.append({"name": "连接与凭据", "ok": True, "detail": "桶可访问(HEAD %s)" % cfg.bucket})
key = probe_key(cfg)
payload = b"TamaBox theme builder probe\n"
status, _, body = s3_request(cfg, "PUT", key, payload, "text/plain; charset=utf-8", "no-store")
if status >= 400:
code, msg = _parse_s3_error(body)
raise S3Error("PutObject", status, code, msg)
steps.append({"name": "写入权限", "ok": True, "detail": "已写入探针 %s" % key})
status, _, body = s3_request(cfg, "GET", key)
ok = status == 200 and body == payload
steps.append({
"name": "读回校验", "ok": ok,
"detail": "读回一致" if ok else "读回失败(HTTP %s)" % status,
})
public = _public_read(cfg.url(cfg.object_path(key)))
steps.append({
"name": "匿名可读", "ok": True,
"detail": "该路径可直接匿名访问" if public
else "匿名访问被拒 —— 桶没开公开读时正常,靠自定义域或 CDN 分发即可",
"warn": not public,
})
s3_request(cfg, "DELETE", key)
return {"ok": True, "steps": steps, "key": key}
# ---------------------------------------------------------------------------
# 上传
# ---------------------------------------------------------------------------
def upload_files(cfg, files):
if not files:
raise ValueError("没有要上传的素材")
if len(files) > 200:
raise ValueError("一次最多 200 个文件")
results = []
for f in files:
name = str(f.get("name") or f.get("key") or "?")
try:
key = full_key(cfg, f.get("key") or f.get("name"))
data = base64.b64decode(f.get("data") or "", validate=False)
if not data:
raise ValueError("内容为空")
ctype = str(f.get("contentType") or "application/octet-stream")
# 素材键里带内容版本(导出时由 id 决定),内容不会原地变化 → immutable。
status, _, body = s3_request(
cfg, "PUT", key, data, ctype, "public, max-age=31536000, immutable")
if status >= 400:
code, msg = _parse_s3_error(body)
results.append({"key": key, "name": name, "ok": False,
"status": status, "code": code, "error": msg})
else:
results.append({"key": key, "name": name, "ok": True, "bytes": len(data)})
except Exception as e:
results.append({"key": name, "name": name, "ok": False, "error": str(e)})
failed = [r for r in results if not r["ok"]]
return {"ok": not failed, "files": results, "failed": len(failed), "total": len(results)}
# ---------------------------------------------------------------------------
# HTTP 层
# ---------------------------------------------------------------------------
class Handler(http.server.SimpleHTTPRequestHandler): class Handler(http.server.SimpleHTTPRequestHandler):
server_version = "TamaBoxThemeBuilder/1"
def end_headers(self): def end_headers(self):
# 预览页是刚由 go test 生成的,禁止缓存,否则改了 CSS 看不到变化 # 预览页是刚由 go test 生成的,禁止缓存,否则改了 CSS 看不到变化
self.send_header("Cache-Control", "no-store, must-revalidate") self.send_header("Cache-Control", "no-store, must-revalidate")
@@ -39,6 +335,56 @@ class Handler(http.server.SimpleHTTPRequestHandler):
if str(status).startswith(("4", "5")): if str(status).startswith(("4", "5")):
sys.stderr.write(" %s\n" % (fmt % args)) sys.stderr.write(" %s\n" % (fmt % args))
def _json(self, payload, status=200):
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def _read_json(self):
n = int(self.headers.get("Content-Length") or 0)
if n <= 0:
raise ValueError("请求体为空")
if n > MAX_BODY_BYTES:
raise ValueError("请求体过大(%d 字节),一次别传太多素材" % n)
return json.loads(self.rfile.read(n).decode("utf-8"))
def do_GET(self):
if self.path.split("?")[0] == "/api/s3/ping":
# 生成器靠它判断「当前是不是 serve.py 起的」——python -m http.server 没有这个端点。
return self._json({"ok": True, "upload": True})
return super().do_GET()
def do_POST(self):
route = self.path.split("?")[0]
if route not in ("/api/s3/test", "/api/s3/upload"):
return self._json({"ok": False, "error": "未知接口 %s" % route}, 404)
try:
payload = self._read_json()
except Exception as e:
return self._json({"ok": False, "error": "请求解析失败:%s" % e})
try:
cfg = S3Config(payload.get("config") or {})
except ValueError as e:
return self._json({"ok": False, "error": str(e)})
try:
if route == "/api/s3/test":
return self._json(test_connection(cfg))
return self._json(upload_files(cfg, payload.get("files") or []))
except S3Error as e:
return self._json({
"ok": False, "step": e.step, "status": e.status, "code": e.code,
"error": e.message, "hint": S3_HINTS.get(e.code, ""),
})
except ValueError as e:
return self._json({"ok": False, "error": str(e)})
except Exception as e: # 兜底:别把本地服务弄崩,把异常原样告诉页面
return self._json({"ok": False, "error": "%s: %s" % (type(e).__name__, e)})
def main(): def main():
ap = argparse.ArgumentParser(description="TamaBox 主题生成器的本地预览服务") ap = argparse.ArgumentParser(description="TamaBox 主题生成器的本地预览服务")
@@ -52,12 +398,19 @@ def main():
" TEST_PREVIEW=1 go test ./templates -run TestGenPreview\n" " TEST_PREVIEW=1 go test ./templates -run TestGenPreview\n"
) )
if args.host not in ("127.0.0.1", "localhost", "::1"):
sys.stderr.write(
"⚠️ 监听在 %s:上传接口没有鉴权,会代任何能访问到它的人向对象存储写文件。\n"
" 只在完全可信的内网这么做。\n" % args.host
)
handler = functools.partial(Handler, directory=PROJECT_ROOT) handler = functools.partial(Handler, directory=PROJECT_ROOT)
socketserver.TCPServer.allow_reuse_address = True socketserver.TCPServer.allow_reuse_address = True
with socketserver.TCPServer((args.host, args.port), handler) as httpd: with socketserver.TCPServer((args.host, args.port), handler) as httpd:
url = "http://%s:%d/dev/theme-builder/" % (args.host, args.port) url = "http://%s:%d/dev/theme-builder/" % (args.host, args.port)
print("项目根:%s" % PROJECT_ROOT) print("项目根:%s" % PROJECT_ROOT)
print("生成器:%s" % url) print("生成器:%s" % url)
print("上传接口:/api/s3/test /api/s3/upload(密钥只在内存,不落盘)")
print("Ctrl+C 停止") print("Ctrl+C 停止")
try: try:
httpd.serve_forever() httpd.serve_forever()
+37 -17
View File
@@ -1,4 +1,9 @@
# 把主题素材放到对象存储(R2 / OSS) # 把主题素材放到对象存储(任意 S3 兼容)
这里的「对象存储」不特指某一家:Cloudflare R2、自建 MinIO、Backblaze B2、
阿里云 OSS 的 S3 兼容端点、腾讯云 COS 的 S3 兼容端点都走同一套 SigV4,
差别只在 Endpoint、Region、是否路径风格。**放本地还是放对象存储,由部署者自己决定** ——
主题生成器里「素材托管」就是这两选一。
现状:主题素材在 `static/images/themes/<slug>/`,通过 `static/fs.go` 的 `//go:embed images/themes` 现状:主题素材在 `static/images/themes/<slug>/`,通过 `static/fs.go` 的 `//go:embed images/themes`
随二进制发布。好处是部署即生效、不依赖外部服务;代价是**换一张图就要重新编译部署**, 随二进制发布。好处是部署即生效、不依赖外部服务;代价是**换一张图就要重新编译部署**,
@@ -12,32 +17,46 @@
--- ---
## A. 生成器直接写绝对地址(已支持,推荐) ## A. 生成器直接写绝对地址 + 就近上传(已支持,推荐)
`dev/theme-builder` 的导出面板新增「**素材前缀**」: `dev/theme-builder` 左侧「**素材托管**」有两个选项:本地(随二进制)/ S3 兼容存储。
选 S3 后填:
``` ```
素材前缀 https://cdn.example.com/themes 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
``` ```
留空 = 沿用 `/static/images/themes/<slug>/`(图片随二进制发布,和以前一样)。 然后:
填了 = 导出的 CSS 里所有图片地址变成 `https://cdn.example.com/themes/<slug>/<id>.webp`。
**前缀里不要带 slug**,slug 由导出时自动拼在后面。
图片照旧打进 zip(`static/images/themes/<slug>/*.webp`),只是用途从「放进仓库」变成 1. **测试连接** —— `HEAD` 桶(连通+凭据)→ `PUT` 探针(写权限)→ `GET` 读回(内容一致)
「传到对象存储的 `<前缀>/<slug>/` 下」。INSTALL 文案会跟着变,直接写清楚往哪传。 → 匿名 `GET`(能不能被浏览器直接读到)→ 删除探针。失败会把 S3 的 `<Code>` 翻成人话。
2. **上传素材** —— 逐张 PUT 本次主题引用到的图,键为 `<路径前缀>/<slug>/<id>.webp`,
带 `public, max-age=31536000, immutable`。成功后自动把导出用的「素材前缀」填好。
上传经本机 `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 直接命中); - 优点:零服务端改动、零跳转、缓存最优(CDN 直接命中);
- 缺点:域名烘焙进 CSS —— 换域名/换路径要**重新导出**(旧主题可以用 `sed` 批量替换); - 缺点:域名烘焙进 CSS —— 换域名/换路径要**重新导出**(旧主题可以用 `sed` 批量替换);
- 仓库里可以删掉 `static/images/themes/<slug>/` 来缩小编译产物,**确认 CSS 已是绝对地址**再删。 - 仓库里可以删掉 `static/images/themes/<slug>/` 来缩小编译产物,**确认 CSS 已是绝对地址**再删。
命令行等价做法(已有主题不想重导):
```bash
sed -i 's#/static/images/themes/shiroko/#https://cdn.example.com/themes/shiroko/#g' \
static/css/themes/shiroko.css
```
--- ---
## B. 应用内 301 跳转(配置驱动,CSS 一行都不用改) ## B. 应用内 301 跳转(配置驱动,CSS 一行都不用改)
@@ -107,6 +126,7 @@ func themeAssetRedirect() flamego.Handler {
## 建议 ## 建议
- 只是想让部署产物变小、换图不用重编译 → **A**(生成器填前缀,最省事,缓存也最好)。 - 只是想让部署产物变小、换图不用重编译 → **A**(生成器里配好 S3、测通、一键上传,最省事,缓存也最好)。
- 想保留「素材可以随时换、CSS 不用重发」的灵活性 → **B**(配一个 `asset_base`,接受多一跳)。 - 想保留「素材可以随时换、CSS 不用重发」的灵活性 → **B**(配一个 `asset_base`,接受多一跳)。
- 站点已经在 Cloudflare 上、且愿意在边缘配规则 → **C**(最干净,但迁移成本在运维侧)。 - 站点已经在 Cloudflare 上、且愿意在边缘配规则 → **C**(最干净,但迁移成本在运维侧)。
- 就用本地目录、不想依赖任何外部服务 → **什么都不用做**(默认行为,素材随二进制发布)。