主题素材可放任意 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:
@@ -37,3 +37,7 @@ go.work.sum
|
||||
# 默认目录由 [security] honeypot_cred_dir 决定(缺省 data/honeypot-creds),
|
||||
# 若改成别的盘外路径则不受这条影响(那本来也不在仓库里)。
|
||||
/data/
|
||||
|
||||
# 主题生成器的自测脚本是 Python:跑一次就会留 __pycache__,别提交。
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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 参数」重编码全部素材,并报出省了多少 —— 与图片处理同一条非破坏原则,
|
||||
来回试不会叠加压缩。
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
@@ -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
@@ -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()
|
||||
|
||||
+37
-17
@@ -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`
|
||||
随二进制发布。好处是部署即生效、不依赖外部服务;代价是**换一张图就要重新编译部署**,
|
||||
@@ -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`),只是用途从「放进仓库」变成
|
||||
「传到对象存储的 `<前缀>/<slug>/` 下」。INSTALL 文案会跟着变,直接写清楚往哪传。
|
||||
1. **测试连接** —— `HEAD` 桶(连通+凭据)→ `PUT` 探针(写权限)→ `GET` 读回(内容一致)
|
||||
→ 匿名 `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 直接命中);
|
||||
- 缺点:域名烘焙进 CSS —— 换域名/换路径要**重新导出**(旧主题可以用 `sed` 批量替换);
|
||||
- 仓库里可以删掉 `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 一行都不用改)
|
||||
@@ -107,6 +126,7 @@ func themeAssetRedirect() flamego.Handler {
|
||||
|
||||
## 建议
|
||||
|
||||
- 只是想让部署产物变小、换图不用重编译 → **A**(生成器填前缀,最省事,缓存也最好)。
|
||||
- 只是想让部署产物变小、换图不用重编译 → **A**(生成器里配好 S3、测通、一键上传,最省事,缓存也最好)。
|
||||
- 想保留「素材可以随时换、CSS 不用重发」的灵活性 → **B**(配一个 `asset_base`,接受多一跳)。
|
||||
- 站点已经在 Cloudflare 上、且愿意在边缘配规则 → **C**(最干净,但迁移成本在运维侧)。
|
||||
- 就用本地目录、不想依赖任何外部服务 → **什么都不用做**(默认行为,素材随二进制发布)。
|
||||
|
||||
Reference in New Issue
Block a user