主题素材可放任意 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:
@@ -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())
|
||||
Reference in New Issue
Block a user