Files
tamabox/dev/theme-builder/selftest_upload.py
T
tamakyi 5c0de805c6 new-sakura:花枝朝向与分叉修正,素材包一键导出上传到对象存储
- gen_new_sakura.py:花朵加各自旋转角(原来一律朝上,像贴纸);分叉从垂直下垂改成向右上斜;
  新增 --asset-base / --out —— 填前缀就引用对象存储位图,留空仍是内联 SVG。
- export_assets.py(新增):从 gen_new_sakura 的同一份 SVG 出图(直接 import,不另画一套),
  用无头 Chrome 透明底渲染后转 WebP;--upload 一次传 CSS + 5 张 webp + 生成的 INSTALL.txt,
  公开地址按 data/r2.txt 的 image_bucket_cdn_host(与 app.ini 同名)拼。
- serve.upload_files 支持逐文件 cacheControl。原来硬编码 immutable,而 INSTALL.txt 与镜像
  CSS 的键不带版本 —— 重新生成后浏览器/CDN 一年内都还是旧内容。现在位图 1 天、CSS 5 分钟、
  README 60 秒;selftest 补一条覆盖断言。
- 文档更正两处事实:①自定义域 r2.337845818.best 实测可匿名读(S3 endpoint 仍 403);
  ②服务端有 gzip,内联版 51KB 实际传输 3.9KB,比位图版(2.7KB + 5 图 48.9KB)小得多,
  所以「换位图省 CSS 体积」不成立,位图只在素材要复用到非 CSS 场景时才有意义。
  另记:曾以 immutable 传过的键必须去控制台清缓存,重传不会让边缘失效。
2026-10-22 11:06:13 +08:00

153 lines
7.0 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,跑一遍「测试连接 → 上传 → 读回」。
覆盖 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))
# 2b) 逐文件覆盖缓存头:README/清单这类「键不变、内容会变」的文件不能 immutable
serve.upload_files(cfg, [
{"key": "shiroko/INSTALL.txt", "contentType": "text/plain; charset=utf-8",
"cacheControl": "public, max-age=60",
"data": base64.b64encode(b"readme").decode()},
])
_, _, cc = MockS3.store.get("themes/shiroko/INSTALL.txt", (b"", None, None))
check("可覆盖 Cache-Control", cc == "public, max-age=60", str(cc))
# 这一步只为验头,别让它污染后面的对象计数
MockS3.store.pop("themes/shiroko/INSTALL.txt", None)
# 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) 列远端:前缀过滤、大小、分页
serve.upload_files(cfg, [{"key": "shiroko/asset3.webp", "contentType": "image/webp",
"data": base64.b64encode(b"three").decode()}])
ls = serve.list_objects(cfg, "shiroko/")
keys = sorted(o["key"] for o in ls["objects"])
check("按 <前缀>/<slug>/ 列出 3 个对象",
keys == ["themes/shiroko/asset1.webp", "themes/shiroko/asset2.webp",
"themes/shiroko/asset3.webp"], str(keys))
size1 = [o["size"] for o in ls["objects"] if o["key"].endswith("asset1.webp")]
check("对象大小与上传字节一致", size1 == [len(f1)], str(size1))
paged = serve.list_objects(cfg, "shiroko/", max_keys=2)
check("分页把 3 个都取回来了", len(paged["objects"]) == 3, str(len(paged["objects"])))
everything = serve.list_objects(cfg, "")
# 空前缀 = 列「路径前缀」之下(跨主题),不扫整个桶:桶里还可能有头像等别的东西。
check("空前缀列出 <路径前缀>/ 下全部、且不越界",
all(o["key"].startswith("themes/") for o in everything["objects"])
and len(everything["objects"]) == 3,
str([o["key"] for o in everything["objects"]]))
# 8) 删除:幂等(不存在的键也当成功),且不影响别的前缀
del_res = serve.delete_objects(cfg, ["shiroko/asset3.webp", "shiroko/nonexistent.webp"])
check("删除含不存在的键仍算成功", del_res["ok"] and del_res["total"] == 2, str(del_res))
after = serve.list_objects(cfg, "shiroko/")
check("删完只剩 2 个", len(after["objects"]) == 2,
str([o["key"] for o in after["objects"]]))
check("桶根对象没被误删", "root.webp" in MockS3.store)
# 9) 虚拟主机风格的路径(不带桶名)
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())