主题素材可放任意 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
+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())