mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 16:00:43 +00:00
== 症状 ==
在**真实中转**上实测管理页时,用 node --check 检查页面内嵌的 JS:
SyntaxError: Invalid or unexpected token (第 46 行)
整段 <script> 不执行 -> 改码 / 删除 / 批量删除 / 全选 全部变幽灵按键。
**这正是用户之前报过的那个 bug,加新功能时又踩了一遍。**
== 根因(讽刺到极点)==
admin.py 的 ADMIN_JS 是**普通三引号字符串**,里面写一个反斜杠+n
会被 Python 解释成**真实换行**。而 JS 的行注释 // 不能跨行 ——
一断行,后面就成了裸文本,整段脚本语法错误。
出事的是这段注释:
// ⚠ 这里必须是 \\n(反斜杠+n),不能是 \n ——
^^^^
这个 \n 被 Python 变成真换行
**注释为了举例说明「不能写 \n」,自己写了一个 \n。**
于是注释被断成两行,把整段 JS 弄坏了。
== 修法 ==
注释改成**一个转义序列都不写**,只用文字描述:
// ⚠ 下面 prompt 里的换行,必须写成「反斜杠 + n」两个字符。
// 本文件是普通三引号字符串:只写反斜杠 + n,Python 会把它
// 变成**真实换行** ... (一个转义都不出现)
并把"踩过两次"的历史写进注释,提醒后人别再来第三次。
== 新增两个测试(防止再犯)==
1. `_tests/test_admin_js_syntax.py`(已注册为回归阶段 3a6)
**本地渲染管理页**,把 <script> 抠出来喂给 `node --check`,
还检查注释行里有没有裸控制字符。
为什么原来查不出来:已有的「管理页 JS 合法性」阶段只检查
token 在不在,不查语法 —— 「23 项测试通过」和「功能能用」
是两件事。
2. `_build/scan_admin_escapes.py`
扫源文件里"注释下一行不像 JS 代码"的可疑断行。
3. `_tests/test_admin_live.py`
在**真实中转**上走 HTTP 实测两个新功能(不能进回归套件,
因为它依赖线上设备):
一、管理页能打开(带 X-Admin 头绕开登录页)
二、页面里真的有 allowNew 和改码按钮
三、内嵌 JS 语法合法
四、关掉接入开关 -> 新设备被拒 -> 打开 -> 能注册(实测通过)
五、改配对码 -> 真的变了(实测通过)
六、不带 X-Admin 访问管理接口返回 401(实测通过)
实测结果:16/17 通过(唯一的失败就是上面那个 JS bug,
已修,本地渲染 6/6 通过)。
== 顺带纠正一个我自己的误解 ==
我一开始只带 `?key=` 访问管理页,拿到的是**登录页**,
还以为是设备上部署了旧版本。实际是管理页改成了
**账号 + 密码登录 + Cookie 会话**(用户要求),
脚本通道用 `X-Admin` 头(见 relay.py 的 _admin_gate)。
== 部署时机 ==
**故意没有立刻部署到设备上** —— 部署要重启中转服务,
会打断 relay-stability 正在跑的隧道配比 A/B 对照。
等对照跑完(约 22:00)再部署并复测。
161 lines
5.5 KiB
Python
161 lines
5.5 KiB
Python
# -*- coding: utf-8 -*-
|
||
r'''渲染修好的管理页,检查 JS 语法。
|
||
|
||
这是"按钮全是死的"那个 bug 的**最快复现方式**:
|
||
不连中转、不起服务器,直接调 render_admin() 拿 HTML,
|
||
把 <script> 抠出来喂给 node --check。
|
||
|
||
之前那个坑之所以能进仓库,就是因为测试只查"token 在不在",
|
||
不查语法。这个脚本补上。
|
||
'''
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parent.parent
|
||
sys.path.insert(0, str(ROOT / "relay"))
|
||
|
||
import admin as admin_mod # noqa: E402
|
||
|
||
passed = 0
|
||
failed = 0
|
||
|
||
|
||
def check(name, ok, detail=""):
|
||
global passed, failed
|
||
if ok:
|
||
passed += 1
|
||
print(" [PASS] " + name)
|
||
else:
|
||
failed += 1
|
||
print(" [FAIL] " + name + ((" —— " + str(detail)) if detail else ""))
|
||
|
||
|
||
def main() -> int:
|
||
print("=" * 58)
|
||
print(" 管理页 JS 语法检查(本地渲染)")
|
||
print("=" * 58)
|
||
|
||
# 用 relay 自己的 snapshot() 造一份**真实结构**的快照 ——
|
||
# 手写 dict 会缺字段(比如 queuedToClient),一改就崩。
|
||
snap = None
|
||
try:
|
||
import relay as relay_mod # noqa: E402
|
||
snap = relay_mod.RELAY.snapshot()
|
||
print(" (用 relay.RELAY.snapshot() 拿到的真实快照)")
|
||
except Exception as exc: # noqa: BLE001
|
||
print(" (拿不到真实快照:%s,退回手写)" % type(exc).__name__)
|
||
|
||
if snap is None:
|
||
snap = {
|
||
"devices": [],
|
||
"sessions": 0,
|
||
"uptimeSeconds": 100,
|
||
"memoryKB": 20480,
|
||
"allowNew": True,
|
||
"codeCount": 0,
|
||
}
|
||
# 塞一台假设备,把设备行那一块也渲染出来(不然改码按钮不会出现)
|
||
# 字段要和 admin._device_rows 里用到的完全对齐
|
||
snap["devices"] = [{
|
||
"deviceId": "TESTDEVICE-1234567890ab",
|
||
"name": "测试电脑",
|
||
"code": "123456",
|
||
"online": True,
|
||
"idleSeconds": 3,
|
||
"lastSeen": "刚刚",
|
||
"ip": "192.168.1.5",
|
||
"version": "1.8.2",
|
||
"os": "Windows",
|
||
"lanIps": ["192.168.1.5"],
|
||
"queuedToClient": 0,
|
||
"queuedToAgent": 0,
|
||
"bytesIn": 1024,
|
||
"bytesOut": 2048,
|
||
"tokens": 0,
|
||
"sessions": 0,
|
||
}]
|
||
snap.setdefault("sessions", 0)
|
||
snap.setdefault("uptimeSeconds", 100)
|
||
snap.setdefault("memoryKB", 20480)
|
||
snap.setdefault("allowNew", True)
|
||
snap.setdefault("codeCount", 1)
|
||
try:
|
||
html = admin_mod.render_admin(
|
||
snap, "https://relay.example.com",
|
||
"/zhongzhuanzhan", ["123456"],
|
||
lambda: [])
|
||
except TypeError:
|
||
# 签名不一样就退回位置参数最少的调法
|
||
html = admin_mod.render_admin(snap, "https://relay.example.com",
|
||
"/zhongzhuanzhan", ["123456"])
|
||
if isinstance(html, bytes):
|
||
html = html.decode("utf-8")
|
||
|
||
check("渲染出了 HTML", len(html) > 500, "只有 %d 字节" % len(html))
|
||
check("页面里有 allowNew", 'id="allowNew"' in html, "找不到")
|
||
check("页面里有改码按钮", "data-recode" in html, "找不到")
|
||
|
||
scripts = re.findall(r"<script[^>]*>(.*?)</script>", html, re.S)
|
||
inline = [s for s in scripts if s.strip()]
|
||
check("有内嵌脚本", bool(inline), "一个都没有")
|
||
|
||
for i, js in enumerate(inline, 1):
|
||
with tempfile.NamedTemporaryFile("w", suffix=".js", delete=False,
|
||
encoding="utf-8") as fh:
|
||
fh.write(js)
|
||
path = fh.name
|
||
try:
|
||
proc = subprocess.run(["node", "--check", path],
|
||
capture_output=True, text=True,
|
||
encoding="utf-8", errors="replace",
|
||
timeout=30)
|
||
ok = proc.returncode == 0
|
||
detail = ""
|
||
if not ok:
|
||
# 把报错那一行附近打出来,方便定位
|
||
err = (proc.stderr or "")
|
||
m = re.search(r":(\d+)\n", err)
|
||
if m:
|
||
ln = int(m.group(1))
|
||
lines = js.splitlines()
|
||
lo = max(0, ln - 3)
|
||
hi = min(len(lines), ln + 2)
|
||
detail = "第 %d 行:\n" % ln + "\n".join(
|
||
" %3d| %s" % (k + 1, lines[k])
|
||
for k in range(lo, hi))
|
||
else:
|
||
detail = err[:300]
|
||
check("第 %d 段内嵌 JS 语法合法" % i, ok, detail)
|
||
except FileNotFoundError:
|
||
print(" [SKIP] 没装 node")
|
||
finally:
|
||
Path(path).unlink(missing_ok=True)
|
||
|
||
# 再检查一遍:注释行里有没有被 Python 吃掉转义的痕迹
|
||
for i, js in enumerate(inline, 1):
|
||
lines = js.splitlines()
|
||
bad = []
|
||
for k, line in enumerate(lines):
|
||
s = line.strip()
|
||
if s.startswith("//"):
|
||
# 注释行里出现裸控制字符
|
||
for ch in line:
|
||
if ord(ch) < 32 and ch != "\t":
|
||
bad.append(k + 1)
|
||
break
|
||
check("第 %d 段没有裸控制字符" % i, not bad, "行号 %s" % bad)
|
||
|
||
print()
|
||
print("=" * 58)
|
||
print(" 通过 %d 项,失败 %d 项" % (passed, failed))
|
||
return 0 if failed == 0 else 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|