素材位置改成两选一(本地随二进制 / 自行填 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 供手动点面板用。
140 lines
4.9 KiB
Python
140 lines
4.9 KiB
Python
"""最小 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())
|