主题素材可放任意 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
+49 -1
View File
@@ -190,13 +190,61 @@ WebP 体积 ≈ 像素数 × 质量。**真正的大头是像素**:CSS 里只
| --- | --- | --- |
| 质量 | 0.88 | 0.85~0.9 肉眼几乎看不出差别,低于 0.8 才明显糊 |
| 长边上限 | 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()` 一处决定图片地址,图层、背景、窄屏块全走它,不会出现
「背景走 CDN、装饰还指本地」的半吊子状态。素材前缀只影响导出,预览永远是内联 dataURL。
详细取舍(生成器前缀 / 应用内 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)`。
改完立刻按「原图 + 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.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 @@
<div id="export-editor"></div>
</section>
<section>
<h2>素材托管 <span class="r"><span class="conn" id="host-state">本地</span></span></h2>
<div id="host-editor"></div>
</section>
<section>
<h2>配色</h2>
<div class="pal" id="palette"></div>
@@ -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}/<id>.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/<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){
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 <Endpoint>
(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;
+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
"""主题生成器的本地预览服务。
"""主题生成器的本地预览服务 + 素材上传代理。
为什么必须起服务:生成器不再自己画一个"仿制页面",而是把项目真实渲染出来的
页面(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"<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):
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()