Files
tamabox/dev/theme-builder/mock_s3.py
T
tamakyi 9c961b3c05 主题素材可放任意 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 供手动点面板用。
2026-10-21 21:08:39 +08:00

140 lines
4.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""最小 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())