371 lines
14 KiB
Python
371 lines
14 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""图片上传链路端到端验证(批次 A / B1 / B2)。
|
||
|
||
针对已部署环境验证三件事:
|
||
B1 EXIF Orientation=6 的竖拍 JPEG 转换后必须变成竖图(核心验收点)
|
||
B2 动画 GIF 必须被转成 .webp(cwebp 可用性 + 提问路径与头像路径一致性)
|
||
A 转换后不比原图小时必须回退原图(用一张极小线框图验证)
|
||
|
||
做法:注册账号 -> 匿名提问(multipart 同时带三张图)-> 解析成功页里的 CDN URL
|
||
-> 逐张拉回字节,解析真实容器格式与尺寸。
|
||
|
||
用法:
|
||
python dev/devcheck-image.py [--base https://box-dev-api.337845818.best]
|
||
|
||
依赖:Pillow(仅用于在本地生成测试图片,不参与被测逻辑)。
|
||
注意:会在目标环境真实创建账号并发送带图提问(测试邮箱前缀 tbimg<ts>@)。
|
||
清理:TamaBox ban --email <该邮箱> --yes
|
||
"""
|
||
import argparse
|
||
import http.cookiejar
|
||
import io
|
||
import json
|
||
import os
|
||
import tempfile
|
||
import re
|
||
import struct
|
||
import sys
|
||
import time
|
||
import urllib.error
|
||
import urllib.parse
|
||
import urllib.request
|
||
import uuid
|
||
|
||
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36")
|
||
RESULTS = []
|
||
CREATED_EMAIL = ""
|
||
|
||
|
||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D102
|
||
return None
|
||
|
||
|
||
class Resp:
|
||
def __init__(self, status, headers, body):
|
||
self.status = status
|
||
self.h = {k.lower(): v for k, v in headers.items()}
|
||
self.body = body
|
||
|
||
@property
|
||
def loc(self):
|
||
return self.h.get("location", "")
|
||
|
||
@property
|
||
def text(self):
|
||
return self.body.decode("utf-8", "replace")
|
||
|
||
|
||
class Client:
|
||
def __init__(self, base):
|
||
self.base = base
|
||
self.cj = http.cookiejar.CookieJar()
|
||
self.op = urllib.request.build_opener(
|
||
urllib.request.HTTPCookieProcessor(self.cj), NoRedirect())
|
||
|
||
def _send(self, req, timeout=120):
|
||
try:
|
||
with self.op.open(req, timeout=timeout) as r:
|
||
return Resp(r.status, dict(r.headers), r.read())
|
||
except urllib.error.HTTPError as e:
|
||
try:
|
||
body = e.read()
|
||
except Exception: # noqa: BLE001
|
||
body = b""
|
||
return Resp(e.code, dict(e.headers), body)
|
||
except Exception as e: # noqa: BLE001
|
||
return Resp(-1, {}, ("TRANSPORT_ERROR: %s" % e).encode())
|
||
|
||
def req(self, method, path, data=None, content_type=None, timeout=120):
|
||
url = path if path.startswith("http") else self.base + path
|
||
headers = {"User-Agent": UA, "Referer": self.base + path.split("?")[0]}
|
||
body = None
|
||
if data is not None:
|
||
body = data if isinstance(data, bytes) else urllib.parse.urlencode(data).encode()
|
||
headers["Content-Type"] = content_type or "application/x-www-form-urlencoded"
|
||
return self._send(urllib.request.Request(url, data=body, headers=headers, method=method),
|
||
timeout)
|
||
|
||
def get(self, p, **kw):
|
||
r = self.req("GET", p, **kw)
|
||
return r if r.status != -1 else self.req("GET", p, **kw)
|
||
|
||
def post(self, p, data=None, **kw):
|
||
return self.req("POST", p, data=data, **kw)
|
||
|
||
def get_binary(self, url):
|
||
return self._send(urllib.request.Request(
|
||
url, headers={"User-Agent": UA}, method="GET"), timeout=60)
|
||
|
||
|
||
def step(desc, ok, extra=""):
|
||
RESULTS.append((desc, ok, extra))
|
||
print(" [%s] %s%s" % ("PASS" if ok else "FAIL", desc, (" (%s)" % extra) if extra else ""))
|
||
return ok
|
||
|
||
|
||
def csrf_of(html):
|
||
m = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
|
||
return m.group(1) if m else None
|
||
|
||
|
||
def captcha(c):
|
||
j = json.loads(c.get("/api/captcha/generate").text)
|
||
pid, q = j["data"]["id"], j["data"]["question"]
|
||
m = re.match(r"\s*(\d+)\s*([+\-×])\s*(\d+)\s*=", q)
|
||
a, op, b = int(m.group(1)), m.group(2), int(m.group(3))
|
||
return "%s:%d" % (pid, {"+": a + b, "-": a - b, "×": a * b}[op])
|
||
|
||
|
||
def multipart(fields, files):
|
||
boundary = "----tbimg%s" % uuid.uuid4().hex
|
||
buf = io.BytesIO()
|
||
for k, v in fields.items():
|
||
buf.write(("--%s\r\n" % boundary).encode())
|
||
buf.write(('Content-Disposition: form-data; name="%s"\r\n\r\n' % k).encode())
|
||
buf.write(("%s\r\n" % v).encode())
|
||
for name, fn, ct, data in files:
|
||
buf.write(("--%s\r\n" % boundary).encode())
|
||
buf.write(('Content-Disposition: form-data; name="%s"; filename="%s"\r\n' % (name, fn)).encode())
|
||
buf.write(("Content-Type: %s\r\n\r\n" % ct).encode())
|
||
buf.write(data)
|
||
buf.write(b"\r\n")
|
||
buf.write(("--%s--\r\n" % boundary).encode())
|
||
return buf.getvalue(), "multipart/form-data; boundary=%s" % boundary
|
||
|
||
|
||
# ---------- 容器格式解析(纯 stdlib) ----------
|
||
|
||
def sniff(data):
|
||
"""返回 (kind, (w,h))。kind in {webp, jpeg, gif, png, unknown}"""
|
||
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
||
return "webp", webp_dims(data)
|
||
if data[:3] == b"\xff\xd8\xff":
|
||
return "jpeg", jpeg_dims(data)
|
||
if data[:3] == b"GIF":
|
||
return "gif", gif_dims(data)
|
||
if data[:8] == b"\x89PNG\r\n\x1a\n":
|
||
return "png", png_dims(data)
|
||
return "unknown", None
|
||
|
||
|
||
def webp_dims(d):
|
||
c = d[12:16]
|
||
if c == b"VP8 " and len(d) >= 30:
|
||
return (struct.unpack("<H", d[26:28])[0] & 0x3FFF,
|
||
struct.unpack("<H", d[28:30])[0] & 0x3FFF)
|
||
if c == b"VP8L" and len(d) >= 25:
|
||
b = struct.unpack("<I", d[21:25])[0]
|
||
return (b & 0x3FFF) + 1, ((b >> 14) & 0x3FFF) + 1
|
||
if c == b"VP8X" and len(d) >= 30:
|
||
return ((d[24] | d[25] << 8 | d[26] << 16) + 1,
|
||
(d[27] | d[28] << 8 | d[29] << 16) + 1)
|
||
return None
|
||
|
||
|
||
def jpeg_dims(d):
|
||
i = 2
|
||
while i + 9 < len(d):
|
||
if d[i] != 0xFF:
|
||
i += 1
|
||
continue
|
||
m = d[i + 1]
|
||
if m in (0xC0, 0xC1, 0xC2, 0xC3):
|
||
h, w = struct.unpack(">HH", d[i + 5:i + 9])
|
||
return (w, h)
|
||
if m in (0xD8, 0xD9) or 0xD0 <= m <= 0xD7:
|
||
i += 2
|
||
continue
|
||
i += 2 + struct.unpack(">H", d[i + 2:i + 4])[0]
|
||
return None
|
||
|
||
|
||
def gif_dims(d):
|
||
return struct.unpack("<HH", d[6:10]) if len(d) >= 10 else None
|
||
|
||
|
||
def png_dims(d):
|
||
return struct.unpack(">II", d[16:24]) if len(d) >= 24 else None
|
||
|
||
|
||
# ---------- 测试图片生成(Pillow 仅此一处使用) ----------
|
||
|
||
def make_images():
|
||
from PIL import Image, ImageDraw
|
||
import io as _io
|
||
|
||
# B1:EXIF Orientation=6。像素存 240x120,正确归一后应显示 120x240。
|
||
base = Image.new("RGB", (240, 120))
|
||
dr = ImageDraw.Draw(base)
|
||
for i in range(0, 240, 20):
|
||
dr.line([(i, 0), (i, 120)], fill=(200, 30, 30), width=3)
|
||
dr.rectangle([0, 0, 239, 119], outline=(0, 0, 255), width=4)
|
||
exif = base.getexif()
|
||
exif[0x0112] = 6
|
||
buf = _io.BytesIO()
|
||
base.save(buf, "JPEG", quality=88, exif=exif)
|
||
portrait = buf.getvalue()
|
||
|
||
# B2:6 帧动画 GIF。加大到 640x480,确保转换后仍小于原图(否则会走回退)。
|
||
frames = []
|
||
pal_frames = []
|
||
for f in range(6):
|
||
im = Image.new("RGB", (640, 480), (240, 240, 240))
|
||
d = ImageDraw.Draw(im)
|
||
d.ellipse([40 + f * 60, 120, 200 + f * 60, 280], fill=(220, 40, 40))
|
||
d.rectangle([300, 60 + f * 20, 600, 200], fill=(30, 90, 220))
|
||
pal_frames.append(im.convert("P", palette=Image.ADAPTIVE, colors=64))
|
||
gbuf = _io.BytesIO()
|
||
pal_frames[0].save(gbuf, "GIF", save_all=True, append_images=pal_frames[1:],
|
||
duration=120, loop=0)
|
||
animated = gbuf.getvalue()
|
||
|
||
# A:极小线框图(200x200),WebP 有损编码后反而比 PNG 大 -> 应回退原图。
|
||
wire = Image.new("RGB", (200, 200), (255, 255, 255))
|
||
d = ImageDraw.Draw(wire)
|
||
for i in range(0, 200, 20):
|
||
d.line([(i, 0), (i, 199)], fill=(0, 0, 0))
|
||
d.line([(0, i), (199, i)], fill=(0, 0, 0))
|
||
wbuf = _io.BytesIO()
|
||
wire.save(wbuf, "PNG")
|
||
wireframe = wbuf.getvalue()
|
||
|
||
return {"portrait": portrait, "anim": animated, "wire": wireframe}
|
||
|
||
|
||
def main():
|
||
global CREATED_EMAIL
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument("--base", default="https://box-dev-api.337845818.best")
|
||
args = ap.parse_args()
|
||
base = args.base.rstrip("/")
|
||
pwd = "TbImg!2026"
|
||
ts = time.strftime("%m%d%H%M%S")
|
||
email, domain = "tbimg%s@example.com" % ts, "tbimg%s" % ts
|
||
CREATED_EMAIL = email
|
||
print("== 账号:%s / %s ==" % (email, domain))
|
||
|
||
imgs = make_images()
|
||
for k, v in imgs.items():
|
||
print(" 本地样本 %-9s %7d bytes" % (k, len(v)))
|
||
|
||
c = Client(base)
|
||
r = c.get("/register")
|
||
reg = c.post("/register", {
|
||
"email": email, "domain": domain, "name": "TBImg%s" % ts,
|
||
"password": pwd, "repeat_password": pwd,
|
||
"g-recaptcha-response": captcha(c), "language": "zh-CN",
|
||
"_csrf": csrf_of(r.text),
|
||
})
|
||
if not step("注册", reg.status == 302, "HTTP %s -> %s" % (reg.status, reg.loc)):
|
||
return 1
|
||
|
||
c = Client(base)
|
||
r = c.get("/login")
|
||
lg = c.post("/login", {"email": email, "password": pwd,
|
||
"g-recaptcha-response": captcha(c), "_csrf": csrf_of(r.text)})
|
||
if not step("登录", lg.status == 302, "HTTP %s -> %s" % (lg.status, lg.loc)):
|
||
return 1
|
||
|
||
page = c.get("/_/%s" % domain)
|
||
if not step("打开提问箱", page.status == 200, "HTTP %s" % page.status):
|
||
return 1
|
||
|
||
# route/question/page.go 只取 f.Images[0](单张上限),
|
||
# 故每张样本各发一条提问,逐张验证。
|
||
fetched = []
|
||
for key, (fname, ctype) in (("portrait", ("portrait.jpg", "image/jpeg")),
|
||
("anim", ("anim.gif", "image/gif")),
|
||
("wire", ("wire.png", "image/png"))):
|
||
box = c.get("/_/%s" % domain)
|
||
body, ct = multipart({
|
||
"content": "图片上传链路验证 %s(可忽略)" % key,
|
||
"g-recaptcha-response": captcha(c),
|
||
"receive_reply_email": "", "private": "", "ask_password": "",
|
||
"start_chat_val": "", "_csrf": csrf_of(box.text),
|
||
}, [("images", fname, ctype, imgs[key])])
|
||
|
||
res = c.post("/_/%s" % domain, data=body, content_type=ct)
|
||
if not step("提交带图提问 [%s]" % key, res.status in (200, 302),
|
||
"HTTP %s -> %s" % (res.status, res.loc)):
|
||
continue
|
||
|
||
# 成功页不渲染图片(templates/question/success.html 无图片区),
|
||
# 故从成功页取出 question ID,再访问详情页取图片 key。
|
||
qid = re.search(r'/_/%s/(\d+)' % re.escape(domain), res.text)
|
||
if not qid:
|
||
step(" └ 取提问 ID [%s]" % key, False, "成功页未给出 /_/<domain>/<id>")
|
||
continue
|
||
detail = c.get("/_/%s/%s" % (domain, qid.group(1)))
|
||
# 提问图的 key 不带 picture/ 前缀(是 "<年>/<月>/<纳秒>.<ext>"),
|
||
# 模板拼成 https://<CDN>/<key>,故按 CDN host + 数字开头路径来匹配。
|
||
urls = sorted(set(re.findall(
|
||
r'https://r2\.[^"\'\s<>]+/\d{4}/\d{1,2}/\d+\.\w+', detail.text)))
|
||
if not urls:
|
||
step(" └ 详情页回显图片 [%s]" % key, False,
|
||
"HTTP %s,未找到 picture URL" % detail.status)
|
||
continue
|
||
step(" └ 详情页回显图片 [%s]" % key, True, "%d 个" % len(urls))
|
||
|
||
for u in urls:
|
||
st, data = c.get_binary(u)
|
||
if st != 200 or not data:
|
||
step(" └ 拉回图片 [%s]" % key, False, "HTTP %s" % st)
|
||
continue
|
||
kind, dims = sniff(data)
|
||
fetched.append((key, kind, dims, len(data)))
|
||
print(" %-14s %-7s %-12s %7d bytes" % (u.rsplit("/", 1)[-1], kind, dims, len(data)))
|
||
|
||
|
||
print("\n== 断言 ==")
|
||
|
||
# B1
|
||
portrait = [f for f in fetched if f[0] == "portrait" and f[1] in ("webp", "jpeg")
|
||
and f[2] == (120, 240)]
|
||
land = [f for f in fetched if f[0] == "portrait" and f[1] in ("webp", "jpeg")
|
||
and f[2] == (240, 120)]
|
||
if portrait:
|
||
step("B1 EXIF Orientation=6 已归一(240x120 -> 120x240)", True,
|
||
"%s, %d bytes" % (portrait[0][1], portrait[0][3]))
|
||
elif land:
|
||
step("B1 EXIF Orientation=6 已归一", False, "仍是 240x120,说明未应用 EXIF 旋转")
|
||
else:
|
||
step("B1 EXIF 方向归一", False, "未匹配到 240x120 / 120x240 的图片,实际: %s" %
|
||
[(f[1], f[2]) for f in fetched])
|
||
|
||
# B2:GIF 样本必须变成 webp;关键是按 key 归属判定,而不是看整体有没有 webp
|
||
gif_res = [f for f in fetched if f[0] == "anim"]
|
||
if not gif_res:
|
||
step("B2 动画 GIF 转 WebP", False, "未取到 anim 样本结果")
|
||
elif gif_res[0][1] == "webp":
|
||
step("B2 动画 GIF 已转为 WebP", True,
|
||
"%dx%d, %d bytes(原 GIF %d bytes,-%.0f%%)" % (
|
||
gif_res[0][2][0], gif_res[0][2][1], gif_res[0][3], len(imgs["anim"]),
|
||
(1 - gif_res[0][3] / len(imgs["anim"])) * 100))
|
||
elif gif_res[0][1] == "gif":
|
||
step("B2 动画 GIF 已转为 WebP", False,
|
||
"仍是原始 GIF %d bytes -> cwebp 不可用或转换失败" % gif_res[0][3])
|
||
else:
|
||
step("B2 动画 GIF 转 WebP", False, "得到 %s(预期 webp)" % gif_res[0][1])
|
||
|
||
# A:线框图应回退原图(PNG),而非转成更大的 WebP
|
||
wire = [f for f in fetched if f[0] == "wire"]
|
||
if not wire:
|
||
step("A 线框图兜底回退", False, "未取到 wire 样本结果")
|
||
elif wire[0][1] == "png":
|
||
step("A 转换无收益时回退原图(线框图保持 PNG)", True,
|
||
"%d bytes(原 PNG %d bytes)" % (wire[0][3], len(imgs["wire"])))
|
||
else:
|
||
step("A 线框图兜底回退", False,
|
||
"被转成了 %s(%d bytes)——本应回退原图" % (wire[0][1], wire[0][3]))
|
||
|
||
print("\n== 结果:%d/%d 通过 ==" % (sum(1 for _, ok, _ in RESULTS if ok), len(RESULTS)))
|
||
print("清理:TamaBox ban --email %s --yes" % email)
|
||
return 0 if all(ok for _, ok, _ in RESULTS) else 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|