diff --git a/.gitignore b/.gitignore index 46de1c9..f868b25 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,7 @@ go.work.sum # 默认目录由 [security] honeypot_cred_dir 决定(缺省 data/honeypot-creds), # 若改成别的盘外路径则不受这条影响(那本来也不在仓库里)。 /data/ + +# 主题生成器的自测脚本是 Python:跑一次就会留 __pycache__,别提交。 +__pycache__/ +*.pyc diff --git a/dev/sigv4ref/main.go b/dev/sigv4ref/main.go new file mode 100644 index 0000000..4b14980 --- /dev/null +++ b/dev/sigv4ref/main.go @@ -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")) + } +} diff --git a/dev/theme-builder/README.md b/dev/theme-builder/README.md index aefeeca..c1e532b 100644 --- a/dev/theme-builder/README.md +++ b/dev/theme-builder/README.md @@ -190,13 +190,61 @@ WebP 体积 ≈ 像素数 × 质量。**真正的大头是像素**:CSS 里只 | --- | --- | --- | | 质量 | 0.88 | 0.85~0.9 肉眼几乎看不出差别,低于 0.8 才明显糊 | | 长边上限 | 1600 | 只缩不放。整页背景建议 1920/不限制;装饰人物 1200 够用 | -| 素材前缀 | 空 | 空 = 图片放 `static/images/themes//` 随二进制发布;填 `https://cdn.example.com/themes` = CSS 写绝对地址,图片自己传对象存储(R2/OSS)。**前缀里不要带 slug** | +| 素材前缀 | 空 | 空 = 图片放 `static/images/themes//` 随二进制发布;填 `https://cdn.example.com/themes` = CSS 写绝对地址,图片放自己的对象存储(R2 / MinIO / B2 / OSS / COS,任何 S3 兼容都行)。**前缀里不要带 slug** | 导出的 CSS 只有 `urlOf()` 一处决定图片地址,图层、背景、窄屏块全走它,不会出现 「背景走 CDN、装饰还指本地」的半吊子状态。素材前缀只影响导出,预览永远是内联 dataURL。 详细取舍(生成器前缀 / 应用内 301 / 边缘改写)见 `docs/theme-assets-cdn.md`。 +## 素材托管(本地 / 任意 S3 兼容) + +「图片放哪」由你自己定,面板里二选一: + +- **本地**(默认):打进 `static/images/themes//`,随二进制发布。部署即生效、不依赖外部服务, + 代价是换一张图要重新编译。 +- **S3 兼容存储**:传到你自己的对象存储,导出的 CSS 写绝对地址。这里刻意不锁厂商 —— + R2 / MinIO / Backblaze B2 / 阿里 OSS(S3) / 腾讯 COS(S3) 用的是同一套 SigV4, + 差别只在 `Endpoint`、`Region`、是否路径风格,全都做成了字段。 + +填好 Endpoint / Region / 桶名 / 路径前缀 / 密钥后: + +1. **测试连接** —— 依次验四件事:`HEAD` 桶(连通与凭据)、`PUT` 一个探针对象(写权限)、 + `GET` 读回比对(内容一致)、匿名 `GET`(传上去的图能不能被浏览器直接读到),最后删掉探针。 + 失败时会把 S3 的 `` 翻成人话(`SignatureDoesNotMatch` → 密钥/Region/时钟三选一)。 +2. **上传素材** —— 把本次主题真正引用到的图(与导出同一个集合,`usedAssetIds()`)逐张 PUT, + 键为 `<路径前缀>//.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 风格 = `/<桶>/<前缀>`)。R2 与私有桶 +需要自定义域或 CDN 域名,那种情况自己填「公开前缀」。测试里的「匿名可读」会给一条提醒, +但不会拦上传 —— 很多人就是靠自定义域分发的。 + +厂商怎么填(2026 年的常见值,各家控制台都有「S3 API」页面): + +| 厂商 | Endpoint | Region | 路径风格 | +| --- | --- | --- | --- | +| Cloudflare R2 | `https://<账号ID>.r2.cloudflarestorage.com` | `auto` | 勾选 | +| MinIO(自建) | `http://: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 或已绑定的桶级自定义域可以取消「路径风格」(走 `<桶>.`)。 + +两条自检(都在本目录,不依赖 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)`。 改完立刻按「原图 + fx 参数」重编码全部素材,并报出省了多少 —— 与图片处理同一条非破坏原则, 来回试不会叠加压缩。 diff --git a/dev/theme-builder/check_sigv4.py b/dev/theme-builder/check_sigv4.py new file mode 100644 index 0000000..a1afff8 --- /dev/null +++ b/dev/theme-builder/check_sigv4.py @@ -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()) diff --git a/dev/theme-builder/index.html b/dev/theme-builder/index.html index 862b02b..992eeda 100644 --- a/dev/theme-builder/index.html +++ b/dev/theme-builder/index.html @@ -103,6 +103,20 @@ .grid9 button{padding:0;height:22px;border-radius:4px;font-size:0} .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} .as{ position:relative;aspect-ratio:1;border-radius:6px;overflow:hidden;background:#0b0f14; @@ -235,6 +249,11 @@
+
+

素材托管 本地

+
+
+

配色

@@ -342,6 +361,18 @@ const D = { 填了(如 https://cdn.example.com/themes)= 导出的 CSS 写绝对 URL,图片放对象存储。 前缀里不要带 slug,slug 由导出时自动拼在后面。 */ 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: { bg:'#141a22', surface:'#1d2530', navbar:'#141a22', text:'#e3eaf4', primary:'#6fa3e0', border:'#334052', @@ -361,6 +392,9 @@ const S = { narrow: Object.assign({}, D.narrow), export: Object.assign({}, D.export), assetBase: '', + host: Object.assign({}, D.host), + /* 运行时状态:测试/上传结果,不参与持久化 */ + s3: { kind: '', text: '', busy: false, canUpload: null }, dark: Object.assign({}, D.dark), }; @@ -1593,9 +1627,12 @@ function renderExportEditor(){ if (S.assetBase){ const cdn = document.createElement('p'); 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}/.webp —— ` - + '记得把 zip 里的 static/images/themes/' + S.slug + '/ 整个传到这个路径下;' - + '仓库里就不需要这些图了。换 CDN 域名要重新导出 CSS。'; + + how + '换 CDN 域名要重新导出 CSS。'; el.appendChild(cdn); } @@ -1607,6 +1644,313 @@ function renderExportEditor(){ el.appendChild(tip); } +/* ---------------------- 素材托管(本地 / 任意 S3 兼容) ---------------------- */ +/* 素材放哪有两选: + local —— 打进 static/images/themes//,随二进制发布,部署即生效; + 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//,随二进制发布 —— 部署即生效、不依赖外部服务,' + + '代价是换一张图要重新编译。选右边可以把素材放到自己的对象存储。'; + 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') + + '/.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 + '/.webp'); + log(`素材已上传 ${j.total} 张;素材前缀已设为 ${pub || '(空)'}`, 'ok'); + } + } catch (e){ + setS3State('bad', '请求本地服务失败:' + e.message); + } finally { + S.s3.busy = false; renderAll(); + } +} + /** 改画质后重编全部素材,并把前后体积说清楚 —— 否则用户不知道有没有生效。 */ async function applyQuality(what){ if (!S.assets.length){ renderAll(); return; } @@ -1700,6 +2044,7 @@ function renderAll(){ renderLayers(); renderAssets(); renderExportEditor(); + renderHostEditor(); renderNarrowEditor(); renderPalette(); applyTheme(); @@ -1725,6 +2070,9 @@ function toJSON(){ narrow: S.narrow, export: S.export, assetBase: S.assetBase, + // 托管配置进工程(换机器接着传),但 accessKey/secretKey 刻意不进去: + // 工程文件常被塞进仓库或发给别人,密钥一旦落进去就没法收回了。 + host: S.host, // 图片内嵌 base64:少一层「图片放哪」的约定,导入时一个文件就够。 // 存的是 orig(原始字节)+ fx(处理参数),dataURL 只在真有处理时才存 —— // 未处理时它必然等于 orig,再存一份等于让 json 平白翻倍。 @@ -1755,6 +2103,8 @@ function fromJSON(o){ scale: MOB_SCALE_DEFAULT }, o.narrow || {}); S.export = Object.assign({}, D.export, o.export || {}); 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 => { const orig = a.orig || { dataURL: a.dataURL, w: a.w, h: a.h }; const dataURL = a.dataURL || orig.dataURL; @@ -1804,6 +2154,8 @@ function resetAll(){ S.narrow = Object.assign({}, D.narrow); S.export = Object.assign({}, D.export); S.assetBase = ''; + // 托管配置回默认,但密钥保留(sessionStorage)—— 换主题往往还是传到同一个桶 + S.host = Object.assign({}, D.host); S.dark = Object.assign({}, D.dark); S.scopes = {}; SCOPES.forEach(sc => { S.scopes[sc.k] = blankScope(sc.k); }); @@ -1975,7 +2327,13 @@ ${S.assetBase 素材托管在对象存储,CSS 写的是绝对地址: 换 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 + (rclone / s5cmd 同理,任何 S3 兼容客户端都行)` : `1) 解压(保持目录结构,覆盖即可) static/css/themes/${slug}.css static/images/themes/${slug}/*.webp`} @@ -2040,13 +2398,9 @@ ${S.narrow.enabled `; } -function doExport(){ - const errs = validate(); - if (errs.length){ - out('先修这几处再导出:\n' + errs.map(e => '· ' + e).join('\n'), 'e'); - return; - } - const slug = ($('slug').value || '').trim(); +/** 本次主题真正引用到的素材 id(导出与上传共用同一份判定)。 + 没有被任何图层/背景引用的图不该进 zip,也不该传到对象存储上占位。 */ +function usedAssetIds(){ const used = new Set(); if (S.narrow.bg) used.add(S.narrow.bg); const collect = c => { @@ -2055,6 +2409,17 @@ function doExport(){ 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]); }); + 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)) }]; let imgKB = 0, imgN = 0; diff --git a/dev/theme-builder/mock_s3.py b/dev/theme-builder/mock_s3.py new file mode 100644 index 0000000..4ec3b73 --- /dev/null +++ b/dev/theme-builder/mock_s3.py @@ -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 = ("%s%s" + % (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()) diff --git a/dev/theme-builder/selftest_upload.py b/dev/theme-builder/selftest_upload.py new file mode 100644 index 0000000..456fb1b --- /dev/null +++ b/dev/theme-builder/selftest_upload.py @@ -0,0 +1,114 @@ +"""上传链路自测:起一个假 S3,跑一遍「测试连接 → 上传 → 读回」。 + +覆盖 serve.py 里真正容易错、又不能靠对拍发现的部分: + · 桶级 HEAD 与对象级 PUT/GET/DELETE 的路径拼接(path style / 虚拟主机) + · 前缀拼接、Content-Type、immutable 缓存头 + · 4xx 时能否把 S3 的 / 还原成人话(含 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()) diff --git a/dev/theme-builder/serve.py b/dev/theme-builder/serve.py index 5335d8e..de32da8 100644 --- a/dev/theme-builder/serve.py +++ b/dev/theme-builder/serve.py @@ -1,15 +1,19 @@ #!/usr/bin/env python3 -"""主题生成器的本地预览服务。 +"""主题生成器的本地预览服务 + 素材上传代理。 为什么必须起服务:生成器不再自己画一个"仿制页面",而是把项目真实渲染出来的 页面(outputs/preview-*.html)放进 iframe 当画板,再往里注入正在编辑的主题 CSS。 file:// 下浏览器不允许父页面访问 iframe 的 DOM(即使同目录),预览就只能退回示意图。 起一个本地 HTTP 服务后父子同源,拖拽、注入、实时改令牌都能正常工作。 +为什么上传也走这个服务:浏览器直连对象存储要配 CORS、还要把密钥交给页面里的 +fetch,各家 S3 兼容实现的 CORS 配置界面又都不一样。放到本机 Python 里代发, +生成器只跟 127.0.0.1 说话:不碰 CORS,密钥不落盘、不进工程文件、不进导出 zip。 + 用法(在项目根目录执行任意一种): python dev/theme-builder/serve.py - python -m http.server 8777 + python -m http.server 8777 # 只有预览,没有上传能力 然后打开 http://localhost:8777/dev/theme-builder/ @@ -17,17 +21,309 @@ file:// 下浏览器不允许父页面访问 iframe 的 DOM(即使同目录) """ import argparse +import base64 +import datetime import functools +import hashlib +import hmac import http.server +import json import os +import re import socketserver import sys +import urllib.error +import urllib.parse +import urllib.request # dev/theme-builder/serve.py → 上两级就是项目根 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"([^<]+)", text) + msg = re.search(r"([^<]+)", 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): + server_version = "TamaBoxThemeBuilder/1" + def end_headers(self): # 预览页是刚由 go test 生成的,禁止缓存,否则改了 CSS 看不到变化 self.send_header("Cache-Control", "no-store, must-revalidate") @@ -39,6 +335,56 @@ class Handler(http.server.SimpleHTTPRequestHandler): if str(status).startswith(("4", "5")): 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(): ap = argparse.ArgumentParser(description="TamaBox 主题生成器的本地预览服务") @@ -52,12 +398,19 @@ def main(): " 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) socketserver.TCPServer.allow_reuse_address = True with socketserver.TCPServer((args.host, args.port), handler) as httpd: url = "http://%s:%d/dev/theme-builder/" % (args.host, args.port) print("项目根:%s" % PROJECT_ROOT) print("生成器:%s" % url) + print("上传接口:/api/s3/test /api/s3/upload(密钥只在内存,不落盘)") print("Ctrl+C 停止") try: httpd.serve_forever() diff --git a/docs/theme-assets-cdn.md b/docs/theme-assets-cdn.md index 7f05dcc..56acd22 100644 --- a/docs/theme-assets-cdn.md +++ b/docs/theme-assets-cdn.md @@ -1,4 +1,9 @@ -# 把主题素材放到对象存储(R2 / OSS) +# 把主题素材放到对象存储(任意 S3 兼容) + +这里的「对象存储」不特指某一家:Cloudflare R2、自建 MinIO、Backblaze B2、 +阿里云 OSS 的 S3 兼容端点、腾讯云 COS 的 S3 兼容端点都走同一套 SigV4, +差别只在 Endpoint、Region、是否路径风格。**放本地还是放对象存储,由部署者自己决定** —— +主题生成器里「素材托管」就是这两选一。 现状:主题素材在 `static/images/themes//`,通过 `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//`(图片随二进制发布,和以前一样)。 -填了 = 导出的 CSS 里所有图片地址变成 `https://cdn.example.com/themes//.webp`。 -**前缀里不要带 slug**,slug 由导出时自动拼在后面。 +然后: -图片照旧打进 zip(`static/images/themes//*.webp`),只是用途从「放进仓库」变成 -「传到对象存储的 `<前缀>//` 下」。INSTALL 文案会跟着变,直接写清楚往哪传。 +1. **测试连接** —— `HEAD` 桶(连通+凭据)→ `PUT` 探针(写权限)→ `GET` 读回(内容一致) + → 匿名 `GET`(能不能被浏览器直接读到)→ 删除探针。失败会把 S3 的 `` 翻成人话。 +2. **上传素材** —— 逐张 PUT 本次主题引用到的图,键为 `<路径前缀>//.webp`, + 带 `public, max-age=31536000, immutable`。成功后自动把导出用的「素材前缀」填好。 + +上传经本机 `serve.py` 代理(`/api/s3/test`、`/api/s3/upload`),浏览器不直连对象存储: +**不需要给桶配 CORS**,密钥只在当前标签页的 sessionStorage 与内存里, +不进草稿、不进工程 json、不进导出 zip。 + +不想用面板也行,导出的 zip 里图片照旧在 `static/images/themes//*.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//` 来缩小编译产物,**确认 CSS 已是绝对地址**再删。 -命令行等价做法(已有主题不想重导): - -```bash -sed -i 's#/static/images/themes/shiroko/#https://cdn.example.com/themes/shiroko/#g' \ - static/css/themes/shiroko.css -``` - --- ## B. 应用内 301 跳转(配置驱动,CSS 一行都不用改) @@ -107,6 +126,7 @@ func themeAssetRedirect() flamego.Handler { ## 建议 -- 只是想让部署产物变小、换图不用重编译 → **A**(生成器填前缀,最省事,缓存也最好)。 +- 只是想让部署产物变小、换图不用重编译 → **A**(生成器里配好 S3、测通、一键上传,最省事,缓存也最好)。 - 想保留「素材可以随时换、CSS 不用重发」的灵活性 → **B**(配一个 `asset_base`,接受多一跳)。 - 站点已经在 Cloudflare 上、且愿意在边缘配规则 → **C**(最干净,但迁移成本在运维侧)。 +- 就用本地目录、不想依赖任何外部服务 → **什么都不用做**(默认行为,素材随二进制发布)。