diff --git a/_dev/accept-static-perf.sh b/_dev/accept-static-perf.sh new file mode 100644 index 0000000..dc7ea3c --- /dev/null +++ b/_dev/accept-static-perf.sh @@ -0,0 +1,151 @@ +#!/bin/bash +# 真机验收:静态资源传输层(1.0.9) +# +# 验的是「浏览器实际收到什么」,不是源码里写了什么: +# ① 带指纹的 app.js → Cache-Control: immutable 长缓存 +# ② 第二次请求 If-None-Match → 304,且**零字节响应体** +# ③ 无指纹的 /app.js → 仍是 no-cache +# ④ Accept-Encoding: br 但目标机没装 brotli → **必须退到 gzip** +# (这是本轮最大的风险点,退化就是 583KB 裸传) +# ⑤ 页脚版本号已注入,不再是 __APP_VERSION__ 字面量 +# ⑥ index.html 的 ETag 里含版本号 +# ⑦ logo 是 ,页脚有版本号元素 +set -u +BASE="http://127.0.0.1:8080" +WEB="http://127.0.0.1:8080" +fail=0 +ck() { if [ "$2" = "1" ]; then echo " ✓ $1"; else echo " ✗ $1"; fail=$((fail+1)); fi; } + +echo "=== 1. 探测面板是否在跑 ===" +# ⚠️ **不要在这里试登录**。密码猜错有锁定计数(实测错误返回 +# 「还可尝试 3 次」),验收脚本反复试会把用户的账号锁死 —— +# 而且脚本里硬编码密码这个做法本身就不该保留。 +# 本脚本要验的东西全是公开静态资源 + 免鉴权的 /api/openapi,不需要登录。 +CODE=$(curl -sk -m 5 -o /dev/null -w '%{http_code}' "$BASE/index.html") +echo " GET /index.html → $CODE" +[ "$CODE" = "200" ] || { echo "面板没起来($CODE),后续无意义"; exit 1; } + +echo +echo "=== 2. 取 app.js 的指纹(从 index.html 里解析)===" +IDX=$(curl -sk -m 5 "$BASE/index.html") +# ⚠️ 不能用 sed 抽 /app.js?v=... —— 问号是 sed 的选项分隔符, +# 会报「s 的未知选项」,然后指纹解析静默失败、退回测无指纹 URL, +# 于是「immutable 长缓存」那条必然变红 —— 假红比不测更坏。 +APPV=$(printf '%s' "$IDX" | grep -o '/app\.js?v=[0-9]*' | head -1) +[ -z "$APPV" ] && APPV="/app.js" +echo " 指纹 URL = $APPV" + +echo +echo "=== 3. 带指纹请求 → 应为 immutable 长缓存 ===" +H1=$(curl -sk -m 10 -D - -o /dev/null "$BASE$APPV") +CC=$(printf '%s' "$H1" | sed -n 's/^[Cc]ache-[Cc]ontrol:[[:space:]]*//p' | tr -d '\r') +echo " Cache-Control: $CC" +[ -z "$CC" ] && CC=$(printf '%s' "$H1" | grep -i '^cache-control:' | sed 's/^[^:]*:[[:space:]]*//' | tr -d '\r') +printf '%s' "$CC" | grep -q 'immutable' && ck "带指纹 → immutable 长缓存" 1 || ck "带指纹 → immutable 长缓存(实际: $CC)" 0 +printf '%s' "$CC" | grep -q 'max-age=31536000' && ck "max-age 是一年" 1 || ck "max-age 是一年" 0 + +ETAG=$(printf '%s' "$H1" | grep -i '^etag:' | sed 's/^[^:]*:[[:space:]]*//' | tr -d '\r') +[ -n "$ETAG" ] && ck "有 ETag($ETAG)" 1 || ck "有 ETag" 0 + +echo +echo "=== 4. 传输量(br / gzip / 无压缩 各一次)===" +SZ_BR=$(curl -sk -m 20 -H 'Accept-Encoding: br, gzip' -o /dev/null -w '%{size_download}' "$BASE$APPV") +ENC_BR=$(curl -sk -m 20 -D - -o /dev/null -H 'Accept-Encoding: br, gzip' "$BASE$APPV" | grep -i '^content-encoding:' | sed 's/^[^:]*:[[:space:]]*//' | tr -d '\r') +SZ_GZ=$(curl -sk -m 20 -H 'Accept-Encoding: gzip' -o /dev/null -w '%{size_download}' "$BASE$APPV") +ENC_GZ=$(curl -sk -m 20 -D - -o /dev/null -H 'Accept-Encoding: gzip' "$BASE$APPV" | grep -i '^content-encoding:' | sed 's/^[^:]*:[[:space:]]*//' | tr -d '\r') +SZ_RAW=$(curl -sk -m 20 -H 'Accept-Encoding:' -o /dev/null -w '%{size_download}' "$BASE$APPV") +SRC=$(stat -c %s /opt/drouter/web/app.js) +echo " 源文件 $SRC 字节" +echo " Accept-Encoding: br,gzip → $SZ_BR 字节(Content-Encoding: ${ENC_BR:-无})" +echo " Accept-Encoding: gzip → $SZ_GZ 字节(Content-Encoding: ${ENC_GZ:-无})" +echo " 不带编码 → $SZ_RAW 字节" + +# 关键:目标机没装 python3-brotli,br 必须降级到 gzip, +# 绝不能「要 br 就发原文」—— 那样等于 583KB 裸传,比优化前更慢。 +if [ -n "$ENC_BR" ]; then + ck "带 br 时有编码(实际 ${ENC_BR})" 1 + if [ "$ENC_BR" = "gzip" ]; then + ck "br 不可用时已降级到 gzip(本机无 python3-brotli,属预期)" 1 + elif [ "$ENC_BR" = "br" ]; then + echo " (本机装了 brotli,用的是 br)" + fi +else + SZ_NOGZ=$(curl -sk -m 20 -H 'Accept-Encoding: br' -o /dev/null -w '%{size_download}' "$BASE$APPV") + echo " ⚠ br 无编码且不支持 gzip → $SZ_NOGZ 字节裸传" + ck "br 不可用且无 gzip 兜底时会裸传(应为 0,本机应装 gzip 支持)" 0 +fi +[ "$SZ_GZ" -lt "$SRC" ] && ck "gzip 确实压小了($SZ_GZ < $SRC)" 1 || ck "gzip 确实压小了" 0 +[ "$SZ_RAW" = "$SRC" ] && ck "不带编码时原样输出($SZ_RAW = 源文件大小)" 1 || ck "不带编码时原样输出" 0 + +echo +echo "=== 5. 304 协商(第二次打开应当零传输)===" +# 304 响应没有 body,curl **不会创建** -o 指定的文件 → stat 会报 +# 「没有那个文件或目录」,进而让 [: -lt ] 拿到空值。 +# 用 %{size_download} 让 curl 自己报字节数,不依赖文件是否存在。 +rm -f /tmp/_304body +read -r CODE BODY <<<"$(curl -sk -m 10 -o /tmp/_304body -w '%{http_code} %{size_download}' \ + -H "If-None-Match: $ETAG" "$BASE$APPV")" +BODY=${BODY:-0} +echo " 状态码 $CODE,响应体 $BODY 字节" +[ "$CODE" = "304" ] && ck "If-None-Match 命中 → 304" 1 || ck "If-None-Match 命中 → 304(实际 $CODE)" 0 +[ "$BODY" -lt 100 ] && ck "304 不回传响应体" 1 || ck "304 不回传响应体(实际 $BODY 字节)" 0 +rm -f /tmp/_304body + +echo +echo "=== 6. 无指纹请求 → 必须仍是 no-cache ===" +CC2=$(curl -sk -m 10 -D - -o /dev/null "$BASE/app.js" | grep -i '^cache-control:' | sed 's/^[^:]*:[[:space:]]*//' | tr -d '\r') +echo " Cache-Control: $CC2" +printf '%s' "$CC2" | grep -q 'no-cache' && ck "无指纹 → no-cache(否则部署后拿到旧文件)" 1 || ck "无指纹 → no-cache(实际: $CC2)" 0 +printf '%s' "$CC2" | grep -q 'immutable' && ck "⚠ 无指纹却给了 immutable(会导致用户卡在旧版本)" 0 || ck "无指纹没有误给 immutable" 1 + +echo +echo "=== 7. Vary 头(代理正确性)===" +V=$(curl -sk -m 10 -D - -o /dev/null -H 'Accept-Encoding: gzip' "$BASE$APPV" | grep -ci '^vary:.*Accept-Encoding') +[ "$V" -ge 1 ] && ck "压缩响应带 Vary: Accept-Encoding" 1 || ck "压缩响应带 Vary: Accept-Encoding" 0 + +echo +echo "=== 8. 版本号注入 ===" +if printf '%s' "$IDX" | grep -q '__APP_VERSION__'; then + ck "页脚版本号已注入(不应残留占位符)" 0 +else + ck "页脚版本号已注入(无占位符残留)" 1 +fi +VER=$(printf '%s' "$IDX" | sed -n 's/.*id="app-ver"[^>]*>\(v[0-9][^<]*\)<.*/\1/p' | head -1) +[ -z "$VER" ] && VER=$(printf '%s' "$IDX" | grep -o 'v[0-9]\+\.[0-9]\+\(\.[0-9]\+\)\?' | tail -1) +echo " 页面显示版本号 = ${VER:-(未找到)}" +[ -n "$VER" ] && ck "页脚有版本号" 1 || ck "页脚有版本号" 0 +VFILE=$(cat /opt/drouter/VERSION 2>/dev/null | tr -d ' \t\r\n') +echo " /opt/drouter/VERSION = ${VFILE:-(不存在)}" +[ -n "$VFILE" ] && ck "VERSION 文件已部署" 1 || ck "VERSION 文件已部署" 0 +if [ -n "$VER" ] && [ -n "$VFILE" ]; then + [ "v$VFILE" = "$VER" ] && ck "页面版本号与 VERSION 文件一致" 1 || ck "页面版本号与 VERSION 文件一致(页面 $VER vs 文件 v$VFILE)" 0 +fi + +echo +echo "=== 9. index.html 的 ETag 含版本号 ===" +IE=$(curl -sk -m 10 -D - -o /dev/null "$BASE/index.html" | grep -i '^etag:' | sed 's/^[^:]*:[[:space:]]*//' | tr -d '\r') +echo " index.html ETag = $IE" +if [ -n "$VFILE" ]; then + printf '%s' "$IE" | grep -q "$VFILE" && ck "ETag 里含版本号(改版本即换 ETag)" 1 || ck "ETag 里含版本号" 0 +fi + +echo +echo "=== 10. 两个 UI 元素 ===" +printf '%s' "$IDX" | grep -q 'id="brand-home"' && ck "左上角 logo 是可点击元素" 1 || ck "左上角 logo 是可点击元素" 0 +printf '%s' "$IDX" | grep -q '(键盘/读屏可用)" 1 || ck "logo 用 " 0 +printf '%s' "$IDX" | grep -q 'title="返回系统概览"' && ck "logo 有可访问名称" 1 || ck "logo 有可访问名称" 0 +printf '%s' "$IDX" | grep -q 'id="app-ver"' && ck "页脚有版本号容器" 1 || ck "页脚有版本号容器" 0 + +echo +echo "=== 11. openapi 的 version 与页面一致(/api/openapi 免鉴权)===" +API_VER=$(curl -sk -m 5 "$BASE/api/openapi" | grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/') +echo " openapi version = ${API_VER:-(未取到)}" +[ -n "$API_VER" ] && ck "openapi 可免鉴权访问且能取到 version" 1 || ck "openapi version 取不到" 0 +if [ -n "$VFILE" ] && [ -n "$API_VER" ]; then + [ "$API_VER" = "$VFILE" ] && ck "openapi version 与 VERSION 文件一致(都是 $VFILE)" 1 \ + || ck "openapi version 与 VERSION 一致($API_VER vs $VFILE)" 0 +fi + +echo +if [ "$fail" -eq 0 ]; then echo "=== 全部通过 ==="; else echo "=== $fail 项失败 ==="; fi +exit $fail diff --git a/_dev/bench-appjs.js b/_dev/bench-appjs.js new file mode 100644 index 0000000..f0d6164 --- /dev/null +++ b/_dev/bench-appjs.js @@ -0,0 +1,84 @@ +// 量 app.js 的真实解析/执行成本,以及各种压缩格式的实际收益。 +// 目的:优化前先有数字,别凭感觉改。 +const fs = require('fs'); +const zlib = require('zlib'); +const path = require('path'); + +const ROOT = path.dirname(__dirname); +const src = fs.readFileSync(path.join(ROOT, 'web', 'app.js'), 'utf8'); + +function kb(n) { return (n / 1024).toFixed(0) + ' KB'; } + +console.log('=== 体积 ==='); +console.log(' 原始 :', kb(src.length)); +console.log(' gzip -9 :', kb(zlib.gzipSync(src, { level: 9 }).length)); +try { + console.log(' brotli q11 :', kb(zlib.brotliCompressSync(src, { + params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 } }).length)); +} catch (e) { console.log(' brotli : 不可用', e.message); } + +// 语法编译:V8 解析 674KB 源码的真实耗时 +let t = Date.now(); +try { + new Function(src); +} catch (e) { + console.log(' 语法错误:', e.message); + process.exit(1); +} +console.log('=== 成本 ==='); +console.log(' 语法编译 :', (Date.now() - t), 'ms'); + +// 顶层执行:把 DOM 打桩,量「首屏前」跑多少 JS +function stub() { + const el = () => ({ + style: {}, dataset: {}, classList: { add() {}, remove() {}, toggle() {}, contains: () => false }, + appendChild() {}, removeChild() {}, setAttribute() {}, getAttribute: () => null, + addEventListener() {}, removeEventListener() {}, querySelector: () => null, + querySelectorAll: () => [], insertAdjacentHTML() {}, focus() {}, click() {}, + getBoundingClientRect: () => ({ top: 0, left: 0, width: 0, height: 0 }), + children: [], childNodes: [], innerHTML: '', textContent: '', value: '', + }); + global.document = { + getElementById: () => null, querySelector: () => null, querySelectorAll: () => [], + createElement: el, createElementNS: el, createTextNode: () => ({}), + body: el(), head: el(), documentElement: el(), + addEventListener() {}, removeEventListener() {}, cookie: '', + createDocumentFragment: () => el(), + }; + global.window = { + addEventListener() {}, removeEventListener() {}, + location: { href: '', hash: '', search: '', pathname: '/', reload() {} }, + localStorage: { getItem: () => null, setItem() {}, removeItem() {} }, + sessionStorage: { getItem: () => null, setItem() {}, removeItem() {} }, + setTimeout: () => 0, clearTimeout() {}, setInterval: () => 0, clearInterval() {}, + requestAnimationFrame: () => 0, innerWidth: 1280, innerHeight: 800, + matchMedia: () => ({ matches: false, addEventListener() {}, addListener() {} }), + getComputedStyle: () => ({}), scrollTo() {}, alert() {}, confirm: () => true, + }; + global.localStorage = global.window.localStorage; + global.sessionStorage = global.window.sessionStorage; + global.location = global.window.location; + global.navigator = { userAgent: 'node', language: 'zh-CN', platform: 'linux' }; + global.history = { pushState() {}, replaceState() {} }; + global.fetch = () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + global.XMLHttpRequest = function () { this.open = () => {}; this.send = () => {}; }; + global.alert = () => {}; + global.confirm = () => true; + global.CustomEvent = function () {}; + global.Event = function () {}; +} +stub(); + +t = Date.now(); +try { + (new Function('return (function(){' + src + '\n})()'))(); +} catch (e) { + console.log(' 顶层执行异常:', e.message); +} +console.log(' 顶层执行 :', (Date.now() - t), 'ms'); + +// 全文里各「视图函数」占多少行 —— 决定要不要拆包 +const funcs = [...src.matchAll(/^(?:async\s+)?function\s+(\w+)/gm)].map(m => m[1]); +console.log('=== 结构 ==='); +console.log(' 顶层函数数 :', funcs.length); +console.log(' 总行数 :', src.split('\n').length); diff --git a/_dev/build-image-remote.sh b/_dev/build-image-remote.sh index 77979de..628eebd 100644 --- a/_dev/build-image-remote.sh +++ b/_dev/build-image-remote.sh @@ -15,7 +15,11 @@ # 把标签从 buildah 的 localhost/... 改成 docker 惯用的名字。 set -e -VER="${1:-1.0.8}" +# 版本号唯一真源是 packaging/VERSION(见 packaging/build-deb.sh 里的说明) +_HERE="$(cd "$(dirname "$0")/.." && pwd)" +VF="$_HERE/packaging/VERSION" +[ -f "$VF" ] || { echo "缺少 $VF(版本号唯一真源)"; exit 1; } +VER="${1:-$(tr -d ' \t\r\n' < "$VF")}" SRC="${2:-/tmp/drouter-build}" cd "$SRC" || exit 1 diff --git a/_dev/t-107-inject-wg.py b/_dev/t-107-inject-wg.py index 914b8e2..fe94ab5 100644 --- a/_dev/t-107-inject-wg.py +++ b/_dev/t-107-inject-wg.py @@ -1,6 +1,6 @@ """反向验证:逐个注入 bug,确认 t-107 的 C3c 新断言真的会变红。 -⚠️ 为什么必须做:静态断言最危险的失败模式是「正则没匹配上�� +⚠️ 为什么必须做:静态断言最危险的失败模式是「正则没匹配上」 于是恒绿」。之前踩过两次(heredoc 锚点没匹配上却报「反向验证通过」)。 所以这里每一条都必须**看到 NG 才算验证通过**,而且注入用的是 Write 落盘的真实脚本,不用 heredoc(heredoc 里的 old 串 diff --git a/_dev/t-cachebust.py b/_dev/t-cachebust.py index 2d068e6..7232789 100644 --- a/_dev/t-cachebust.py +++ b/_dev/t-cachebust.py @@ -46,6 +46,11 @@ for name in ('app.js', 'app.css'): os.utime(p, (1_700_000_000, 1_700_000_000)) ns = {'os': os, 're': re, 'WEB_DIR': tmp} +# _inject_asset_version 从 1.0.9 起还会替换页脚版本号占位符, +# 所以要提供 app_version()。**漏了会 NameError** —— 而这个判据只 +# 抽了单个函数、不是整个模块,模块级定义一个都带不过来。 +# 凡是改了被抽函数的签名或新引用的模块级符号,替身必须同步补。 +ns['app_version'] = lambda: '9.9.9' exec(compile(ast.Module(body=picked, type_ignores=[]), SRC, 'exec'), ns) inject = ns['_inject_asset_version'] @@ -53,6 +58,7 @@ HTML = ('\n' '\n' '\n' '\n' + 'v__APP_VERSION__\n' '\n') out = inject(HTML.encode('utf-8')).decode('utf-8') @@ -76,6 +82,10 @@ ck('不会重复追加(幂等)', ck('输出仍是合法 utf-8 且保留 doctype', out.startswith('')) ck('单引号属性也能处理', "/app.css?v=" in inject(b"").decode('utf-8')) +# 1.0.9:页脚版本号占位符也要被替换掉。 +# 漏了不会报错,只是页面底下一直显示 v__APP_VERSION__ 字面量。 +ck('页脚 __APP_VERSION__ 被替换成真实版本号', + '__APP_VERSION__' not in out and 'v9.9.9' in out) -print('\n结果:%d 项,失败 %d 项' % (7, len(fails))) +print('\n结果:%d 项,失败 %d 项' % (8, len(fails))) raise SystemExit(1 if fails else 0) diff --git a/_dev/t-encoding-runtime.py b/_dev/t-encoding-runtime.py new file mode 100644 index 0000000..05135ff --- /dev/null +++ b/_dev/t-encoding-runtime.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""**实际执行** drouter-web.py 的压缩协商函数,验证行为而不是文本。 + +t-static-perf.py 全是源码文本断言,能挡住「代码被改回去」,但挡不住 +「代码还在、行为是错的」。这一份把 _accepts / _compress_cached / +_encode_body 抽出来真跑一遍,覆盖这些真实场景: + + - br 可用 + 客户端要 br → br + - br 可用 + 客户端只要 gzip → gzip(**不能**给 br) + - **br 模块缺失 + 客户端只要 br → 必须退到 gzip**(真机就是这个情况) + - 客户端 br;q=0 → 不能给 br + - 都不支持 → 原文 + - 同一文件先 gzip 后 br,两次结果都要正确(编码进键,不能互相覆盖) + - 压不动的小响应 → 原文 + +⚠️ brotli 用**注入的假模块**而不是真装一个:真机上 python3-brotli 是 +可选依赖,装了就测不出降级路径了。假模块要能真的编出合法 brotli 流, +否则解压那步会假失败。 +""" +import ast +import io +import os +import sys +import types + +ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..') +fails = 0 + + +def chk(label, cond, extra=''): + global fails + if not cond: + fails += 1 + print('[%s] %s %s' % ('OK' if cond else 'FAIL', label, extra)) + + +WEB_PATH = os.path.join(ROOT, 'backend/drouter-web.py') +WEB = io.open(WEB_PATH, encoding='utf-8').read() +TREE = ast.parse(WEB) + +# ---- 抽函数(ast 精确取 def 节点,不用正则切)---- +WANT = ('_accepts', '_want_gzip', '_want_brotli', '_compress_cached', + '_brotli_cached', '_gzip_cached', '_encode_body') +nodes = [] +for n in TREE.body: + if isinstance(n, ast.FunctionDef) and n.name in WANT: + nodes.append(n) +got = set(n.name for n in nodes) +chk('六个压缩函数都能定位到', got == set(WANT), + '缺: %s' % (set(WANT) - got)) + +# 常量(GZIP_MIN / BROTLI_QUALITY / _COMP_MAX*) +consts = {} +for n in TREE.body: + if isinstance(n, ast.Assign) and len(n.targets) == 1: + t = n.targets[0] + if isinstance(t, ast.Name) and t.id in ( + 'GZIP_MIN', 'BROTLI_QUALITY', '_COMP_MAX', '_COMP_MAX_BYTES'): + consts[t.id] = n.value + # 缓存表是 AnnAssign/Assign 都可能 + if isinstance(n, ast.Assign) and len(n.targets) == 1: + t = n.targets[0] + if isinstance(t, ast.Name) and t.id in ('_comp_cache', '_comp_bytes'): + consts[t.id] = n.value +chk('压缩相关常量都在模块顶层', len(consts) >= 4, '拿到 %s' % sorted(consts)) + +mod = ast.Module(body=[], type_ignores=[]) +# 压缩链的必需依赖:**必须原样注入,不能用替身**。 +# 教训:曾经漏注 _comp_lock,函数里一句 NameError 被 `except Exception` +# 吞成「返回原文」—— 压缩功能「完好」但从不生效,583KB 一直裸传, +# 而只看响应头的判据完全发现不了。 +ns = { + 'os': os, 're': __import__('re'), 'gzip': __import__('gzip'), + 'threading': __import__('threading'), +} +for n in TREE.body: + if isinstance(n, ast.FunctionDef) and n.name in WANT: + mod.body.append(n) + elif isinstance(n, ast.Assign) and len(n.targets) == 1 and \ + isinstance(n.targets[0], ast.Name) and n.targets[0].id in consts: + mod.body.append(n) +# 锁不是白名单里的常量,但它是压缩链的硬依赖 +ns['_comp_lock'] = ns['threading'].Lock() + +exec(compile(mod, WEB_PATH, 'exec'), ns) + +_accepts = ns['_accepts'] +_encode_body = ns['_encode_body'] +_comp_cache = ns['_comp_cache'] + + +def hdr(v): + return {'Accept-Encoding': v} if v is not None else {} + + +# ---- 1. _accepts:q 值语义 ---- +for ae, want_gz, want_br, note in ( + ('gzip, deflate, br', True, True, '标准 Chrome 头'), + ('gzip', True, False, '只认 gzip'), + ('br', False, True, '只认 br'), + ('br;q=0', False, False, '明确不要 br'), + ('br;q=0.0', False, False, 'q=0.0 也不要'), + ('br;q=0.5', False, True, 'q>0 就要'), + ('gzip;q=0, br', False, True, '不要 gzip 但要 br'), + ('', False, False, '空头'), + ('identity', False, False, '只接受不压缩'), +): + g = _accepts(hdr(ae), 'gzip') + b = _accepts(hdr(ae), 'br') + chk('Accept-Encoding %-20r → gzip=%-5s br=%-5s(%s)' + % (ae, g, b, note), g == want_gz and b == want_br, + '期望 gzip=%s br=%s' % (want_gz, want_br)) + +chk('缺 Accept-Encoding 头时两个都判 False', + not _accepts(hdr(None), 'gzip') and not _accepts(hdr(None), 'br')) +chk('头是 None 时不抛异常', not _accepts({'Accept-Encoding': None}, 'br')) + +# ---- 2. 无 brotli 模块 → 必须退到 gzip(真机就是这样)---- +BIG = (b'drouter static asset payload. ' * 200) # > GZIP_MIN + + +def run(data, ae, with_brotli): + """在指定 brotli 可用性下跑一次 _encode_body。""" + saved = sys.modules.get('brotli') + if with_brotli: + m = types.ModuleType('brotli') + # 用真 brotli 未必装了;假实现只要稳定可逆,就能验证「选了哪条路」 + m.compress = lambda d, quality=None: __import__('gzip').compress(d, 9) + sys.modules['brotli'] = m + else: + sys.modules.pop('brotli', None) + _comp_cache.clear() + ns['_comp_bytes'][0] = 0 + try: + return _encode_body(hdr(ae), None, data) + finally: + if saved is None: + sys.modules.pop('brotli', None) + else: + sys.modules['brotli'] = saved + + +def _sz(x): + """安全取长度。被测代码返回 None 时**不能**让判据自己先崩 —— + 判据崩溃 = 退出码非 0 但没有 FAIL 行,看起来像「跑了」, + 实际上没人知道它验出了什么。反向验证时这条尤其致命: + bug 明明抓到了,输出却是一片 OK。 + """ + try: + return len(x) + except TypeError: + return -1 + + +body, enc = run(BIG, 'gzip, deflate, br', False) +chk('**没有 brotli 模块时要退到 gzip,不是吐原文**', + enc == 'gzip' and _sz(body) < len(BIG), + 'enc=%s %d→%s 字节' % (enc, len(BIG), _sz(body))) + +body, enc = run(BIG, 'gzip, deflate, br', True) +chk('有 brotli 且客户端要 br → 用 br', enc == 'br', 'enc=%s' % enc) + +body, enc = run(BIG, 'gzip', True) +chk('有 brotli 但客户端只要 gzip → 给 gzip(不能给 br)', + enc == 'gzip', 'enc=%s' % enc) + +body, enc = run(BIG, 'br', False) +chk('要 br 但没模块、且不支持 gzip → 原文(唯一正确选择)', + enc is None and body == BIG, 'enc=%s' % enc) + +body, enc = run(BIG, 'br;q=0, gzip', True) +chk('客户端 br;q=0 → 给 gzip,不给 br', enc == 'gzip', 'enc=%s' % enc) + +body, enc = run(BIG, '', True) +chk('不支持任何压缩 → 原文', enc is None and body == BIG, 'enc=%s' % enc) + +# ---- 3. 同文件两种编码互不覆盖 ---- +_comp_cache.clear() +ns['_comp_bytes'][0] = 0 +saved = sys.modules.get('brotli') +m = types.ModuleType('brotli') +m.compress = lambda d, quality=None: b'BROTLI' + __import__('gzip').compress(d, 1) +sys.modules['brotli'] = m +try: + gz_body, gz_enc = _encode_body(hdr('gzip'), None, BIG) + br_body, br_enc = _encode_body(hdr('br'), None, BIG) + gz_again, gz_again_enc = _encode_body(hdr('gzip'), None, BIG) +finally: + if saved is None: + sys.modules.pop('brotli', None) + else: + sys.modules['brotli'] = saved + +chk('同一份数据两种编码都能取到(键里带了编码,没互相覆盖)', + gz_enc == 'gzip' and br_enc == 'br' + and _sz(gz_body) > 0 and _sz(br_body) > 0 + and gz_body != br_body and _sz(br_body) >= 6 + and br_body[:6] == b'BROTLI', + 'gz=%s br=%s' % (gz_enc, br_enc)) +chk('gzip 结果二次命中缓存(内容与首次一致)', + gz_again_enc == 'gzip' and _sz(gz_again) > 0 and gz_again == gz_body) +chk('缓存里确实是两个独立条目', len(_comp_cache) == 2, + '实际 %d' % len(_comp_cache)) + +# ---- 4. 压不动的小响应 ---- +# ⚠️ 用**真正不可压缩**的数据。b'x'*100 这种单字符重复,gzip 能压到 20 字节, +# 反而更小,于是「压不划算」这条判据测的其实是另一件事(我第一次就踩了)。 +# 这里用 os.urandom 模拟已加密/已压缩的载荷(图片、woff、zip 都是这德性)。 +import random +_comp_cache.clear() +ns['_comp_bytes'][0] = 0 +rnd = random.Random(42) +small = bytes(rnd.randrange(256) for _ in range(200)) +body, enc = _encode_body(hdr('gzip'), None, small) +chk('压不划算的小响应直出原文', enc is None and body == small, + 'enc=%s %d→%s 字节' % (enc, len(small), _sz(body))) + +# ---- 5. 缓存有上限,不会无限涨 ---- +chk('缓存有条目数上限', ns['_COMP_MAX'] > 0) +chk('缓存有字节上限(24 条大响应就能吃掉上百 MB)', + ns['_COMP_MAX_BYTES'] >= 4 * 1024 * 1024, + '%d 字节' % ns['_COMP_MAX_BYTES']) + +# ---- 6. 内部错误不许被兜底吞成「返回原文」 ---- +# 这条是本文件最有价值的判据:压缩链里一个 NameError 曾被 +# `except Exception` 吞掉,返回原文,压缩功能「完好」但从不生效。 +# 表现和优化前一模一样,页面照常能开,只看响应头的判据也发现不了。 +# 唯一的信号是「压缩没生效却没有任何异常」。 +_comp_cache.clear() +ns['_comp_bytes'][0] = 0 +saved_lock = ns['_comp_lock'] +ns['_comp_lock'] = None # 故意制造 AttributeError/TypeError +try: + body, enc = _encode_body(hdr('gzip'), None, BIG) + swallowed = (enc is None and body == BIG) +except Exception: + swallowed = False +finally: + ns['_comp_lock'] = saved_lock +chk('压缩链内部错误不会被静默吞成「返回原文」', + not swallowed, + '内部错误被 except 吞掉 → 压缩静默失效,583KB 裸传却无人察觉' + if swallowed else '') + +print('\n结果: %s' % ('全部通过' if fails == 0 else '%d 项失败' % fails)) +sys.exit(1 if fails else 0) diff --git a/_dev/t-gzip.py b/_dev/t-gzip.py index 6e04b3d..223aa47 100644 --- a/_dev/t-gzip.py +++ b/_dev/t-gzip.py @@ -30,7 +30,11 @@ TREE = ast.parse(WEB) chk('定义了压缩下限 GZIP_MIN', 'GZIP_MIN = ' in WEB) chk('有 Accept-Encoding 判断', 'def _want_gzip' in WEB) -chk('压缩结果带内存缓存(不每次重压)', 'def _gzip_cached' in WEB and '_gz_cache' in WEB) +# ⚠️ 1.0.9 起压缩缓存从 _gz_cache/_br_cache 合并成一张 _comp_cache +# (键里带编码)。这里跟着改名,别断言一个已经不存在的变量 —— +# 判据断言错的名字比没有判据更糟:它会一直绿,而功能早就改了。 +chk('压缩结果带内存缓存(不每次重压)', + 'def _compress_cached' in WEB and '_comp_cache' in WEB) chk('缓存按 mtime+size 失效(部署新版后自动重压)', "int(st.st_mtime), st.st_size" in WEB) chk('压缩后必须发 Content-Encoding', "send_header('Content-Encoding', enc)" in WEB) @@ -41,8 +45,10 @@ chk('压缩比不划算时不压(原始数据更大就直出)', 'if len(z) > chk('静态资源支持 ETag 协商缓存', "send_header('ETag', etag)" in WEB) chk('ETag 命中回 304 且不回传响应体', "If-None-Match" in WEB and 'send_response(304)' in WEB) -chk('JSON 接口也走压缩', '_gzip_cached(None, body)' in WEB) -chk('静态资源也走压缩', '_gzip_cached(full, data)' in WEB) +# 调用点 1.0.9 起统一走 _encode_body(自动挑 br/gzip), +# 不再分别调 _gzip_cached —— 否则 br 永远没机会上场。 +chk('JSON 接口走统一编码协商', '_encode_body(self.h.headers, None, body)' in WEB) +chk('静态资源走统一编码协商', '_encode_body(self.headers, full, data)' in WEB) chk('HEAD 请求不回响应体', "if self.command != 'HEAD'" in WEB) # 小响应不能被压:省下的字节还不如压缩开销 diff --git a/_dev/t-static-perf.py b/_dev/t-static-perf.py new file mode 100644 index 0000000..a58de83 --- /dev/null +++ b/_dev/t-static-perf.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""静态资源传输层契约:长缓存 + brotli 协商 + 版本号注入 + logo 回概述。 + +1.0.9 新增。这一批改动要挡住的具体回归: + +1. **指纹资源没走长缓存** —— app.js 583KB(gzip 198KB / br 160KB)。 + 早先一律 no-cache,指纹明明注入了却每次重传。局域网无感, + 走外网(cloudflared / 远程打开)就是每开一次面板多传 200KB。 +2. **brotli 不可用时没回退 gzip** —— 目标机实测**没装 python3-brotli** + (只有 C 库 libbrotli1)。如果「要 br 就只发 br,失败吐原文」, + 优化后在真机上退化成 583KB 裸传,比优化前更慢。 +3. **两种编码共用一个缓存键** —— 后写进去的会把先写的覆盖掉, + 拿到 br 的客户端可能收到 gzip 版,浏览器解不开就是满屏乱码。 +4. **`br;q=0` 被当支持** —— 子串匹配会正好发给「明确不要 br」的客户端。 +5. **版本号没有单一真源** —— 早先 4 个构建脚本各写死一个默认值。 +6. **index.html 的 ETag 与实际发出的内容不一致** —— 它发的是注入后的 + 产物,ETag 却按原文件算;只改 VERSION 时 ETag 不变,客户端换到 304 + 就继续用旧副本,页面版本号永远停在旧版。 +""" +import ast +import io +import os +import re +import sys + +ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..') +fails = 0 + + +def chk(label, cond, extra=''): + global fails + if not cond: + fails += 1 + print('[%s] %s %s' % ('OK' if cond else 'FAIL', label, extra)) + + +WEB = io.open(os.path.join(ROOT, 'backend/drouter-web.py'), + encoding='utf-8').read() +INDEX = io.open(os.path.join(ROOT, 'web/index.html'), + encoding='utf-8').read() +APPJS = io.open(os.path.join(ROOT, 'web/app.js'), + encoding='utf-8').read() +CSS = io.open(os.path.join(ROOT, 'web/app.css'), encoding='utf-8').read() + +# ---- 1. 长缓存按「有没有指纹」区分 ---- +chk('按有没有指纹区分缓存策略(不是按扩展名)', + "fingerprinted = 'v=' in (self.path or '')" in WEB) +chk('带指纹的静态资源走 immutable 长缓存', + 'max-age=31536000, immutable' in WEB) +chk('没指纹的仍是 no-cache(否则部署后用户拿到旧文件)', + "if fingerprinted else 'no-cache'" in WEB) +chk('HTML 入口页仍是 no-store(它每次都要重新注入)', + "no-store, no-cache, must-revalidate" in WEB) + +# ---- 2. brotli 逐级降级 ---- +chk('有 _want_brotli 协商判断', 'def _want_brotli' in WEB) +chk('brotli 不可用时返回 (None, None) 而不是原文', + re.search(r'def _brotli_cached.*?return None, None', WEB, re.S) is not None) +chk('brotli 失败后**继续试 gzip**(逐级降级,不能吐原文)', + re.search(r'if _want_brotli.*?if enc:\s*\n\s*return body, enc.*?if _want_gzip', + WEB, re.S) is not None) +chk('编码探测集中在一个函数里(避免两处实现漂移)', + 'def _accepts(headers, token)' in WEB) +chk('Accept-Encoding 识别 q=0(br;q=0 是「不要 br」)', + "q.startswith('q=')" in WEB) +chk('brotli 质量档有定值(不是默认 q11,首屏等不起)', + 'BROTLI_QUALITY = ' in WEB) + +# ---- 3. 两种编码不能共用缓存键 ---- +chk('编码进缓存键', re.search(r'key = \(path, int\(st\.st_mtime\), st\.st_size, enc\)', + WEB) is not None) +chk('只有一个压缩缓存表', WEB.count('_comp_cache = {}') == 1 + and '_gz_cache' not in WEB and '_br_cache' not in WEB) + +# ---- 4. 压缩协商的其余契约 ---- +chk('压缩后必须发 Vary: Accept-Encoding', + WEB.count("send_header('Vary', 'Accept-Encoding')") >= 2) +chk('有体积下限(小响应压了反而亏)', 'GZIP_MIN = ' in WEB) +chk('压缩比不划算时不压', 'if len(z) >= len(data)' in WEB) + +# ---- 5. 版本号单一真源 ---- +chk('有 packaging/VERSION 这个唯一真源', + os.path.isfile(os.path.join(ROOT, 'packaging/VERSION'))) +chk('后端从 /opt/drouter/VERSION 读版本', + "os.path.join(BASE, 'VERSION')" in WEB) +chk('openapi 的 version 用 app_version() 而非硬编码', + "'version': app_version()" in WEB) +chk('版本号读取只做一次(运行期不变,缓存住)', '_VERSION_READ' in WEB) +chk('版本号格式受校验(半截写入不会显示到页面上)', + 'VERSION_FALLBACK' in WEB and re.search(r"re\.match\(r'\^", WEB) is not None) +for p in ('packaging/build-deb.sh', 'packaging/build-docker.sh', + 'packaging/build-offline-bundle.sh', '_dev/build-image-remote.sh'): + s = io.open(os.path.join(ROOT, p), encoding='utf-8').read() + chk('%s 从 VERSION 文件取版本' % os.path.basename(p), + 'packaging/VERSION' in s and not re.search(r'\$\{1:-1\.\d', s)) + +# 每一份「装到运行目录」的清单都要带上 VERSION —— +# build-deb.sh(通配 backend/*.py) 不会自动带上它,漏了不报错只是版本号不更新。 +chk('build-deb.sh 把 VERSION 装进包', + '$STAGE/opt/drouter/VERSION' in + io.open(os.path.join(ROOT, 'packaging/build-deb.sh'), + encoding='utf-8').read()) +chk('deploy.sh 把 VERSION 装到 /opt/drouter', + '$OPT/VERSION' in io.open(os.path.join(ROOT, 'scripts/deploy.sh'), + encoding='utf-8').read()) +chk('Dockerfile 把 VERSION 拷进镜像', + 'packaging/VERSION /opt/drouter/VERSION' in + io.open(os.path.join(ROOT, 'packaging/docker/Dockerfile'), + encoding='utf-8').read()) +chk('sync.sh 打包时带上 packaging/VERSION', + 'packaging/VERSION' in io.open(os.path.join(ROOT, 'devtools/sync.sh'), + encoding='utf-8').read()) + +# ---- 6. index.html 的 ETag 必须覆盖注入后的内容 ---- +chk('index.html 的 ETag 并入版本号', + re.search(r"endswith\('index\.html'\):\s*\n\s*etag = .*app_version\(\)", + WEB) is not None) +chk('页脚有版本号占位符', '__APP_VERSION__' in INDEX and 'id="app-ver"' in INDEX) +chk('注入函数替换掉占位符', "'__APP_VERSION__', app_version()" in WEB) + +# ---- 7. logo 回概述页 ---- +chk('左上角 logo 是 (键盘/读屏可用,不是 div onclick)', + re.search(r']*id="brand-home"', INDEX) is not None) +chk('logo 有可访问名称', 'title="返回系统概览"' in INDEX) +chk('logo 点击已绑定', "brandHome.addEventListener('click'" in APPJS) +chk('点击时 preventDefault(否则 #dash 会跳到地址栏)', + re.search(r"addEventListener\('click', e => \{\s*\n\s*e\.preventDefault\(\)", + APPJS) is not None) +chk('已在概述页时不重复 go()(go 会 stopPageTimers 再重拉数据)', + "if (S.page === 'dash') return;" in APPJS) +chk('logo 点击会收起手机端抽屉', re.search( + r"addEventListener\('click', e => \{.*?closeDrawer\(\)", APPJS, re.S) is not None) +chk('CSS 显式去掉 默认下划线/颜色', + '.brand-home{text-decoration:none' in CSS) +chk('CSS 保留焦点环(键盘用户的唯一路标)', ':focus-visible' in CSS) +chk('侧栏收起时 logo 仍居中', '#app.side-collapsed .brand-home' in CSS) + +# ---- 8. 前端语法没被改坏 ---- +try: + import subprocess + node = subprocess.run(['node', '--check', os.path.join(ROOT, 'web/app.js')], + capture_output=True) + chk('app.js 语法正确', node.returncode == 0, + node.stderr.decode('utf-8', 'replace')[:200]) +except Exception as e: + chk('app.js 语法正确(跳过:%s)' % e, True) + +try: + ast.parse(WEB) + chk('drouter-web.py 语法正确', True) +except SyntaxError as e: + chk('drouter-web.py 语法正确', False, str(e)) + +print('\n结果: %s' % ('全部通过' if fails == 0 else '%d 项失败' % fails)) +sys.exit(1 if fails else 0) diff --git a/_dev/t-worktree-clean.py b/_dev/t-worktree-clean.py new file mode 100644 index 0000000..2733495 --- /dev/null +++ b/_dev/t-worktree-clean.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""开工前预检:工作区有没有「上一轮注入没还原」的残留。 + +为什么需要这个文件:反向验证(注入 bug → 看判据变红 → 还原)如果被 +SIGTERM/SIGKILL 打断,`finally` 里的还原不会执行,注入就**留在源码里**。 +下一次跑反向验证时,它看到的「基线」已经被污染,于是报出一堆看不懂的红, +而真正的根因(几小时前那次没还原的注入)完全不在视野里。 + +2026-10-04 实际踩到:`drouter-helper.py` 里 `_vpn_env` 的 +`os.path.join('/lib/modules', ...)` 被留成了 `'/nonexistent'`, +连带 `need_module` 整个状态从四态里消失 —— 而这个缺陷在 +**已发布的 v1.0.8 里**。t-107 一直红着,但被当成了「判据本身有问题」。 + +所以这一条不是「保险」,是真bug 的直接成因。 + +判据只查两个方向: + ① backend/ 下有没有明显的注入哨兵(/nonexistent、__INJECT__、127.0.0.2) + ② 全量静态判据当前是否全绿(不绿就别动手,先弄清是哪一条、为什么) +只读,不改任何东西。 +""" +import io +import os +import re +import subprocess +import sys + +ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..') +# 注入脚本惯用的假路径 / 假常量。真机代码里出现它们基本可以断定是残留。 +SENTINELS = ('/nonexistent', '__INJECT__', '127.0.0.2', 'INJECTED_') + +fails = [] + + +def chk(label, cond, extra=''): + print('[%s] %s %s' % ('OK' if cond else 'FAIL', label, extra)) + if not cond: + fails.append(label) + + +print('=== 1. 扫 backend/ 里的注入哨兵 ===') +hits = [] +for fn in sorted(os.listdir(os.path.join(ROOT, 'backend'))): + if not fn.endswith('.py'): + continue + p = os.path.join(ROOT, 'backend', fn) + try: + src = io.open(p, encoding='utf-8').read() + except Exception: + continue + # 只在**代码**里找,注释和 docstring 里为了说明历史 bug 常会写到 + code = re.sub(r'#.*$', '', src, flags=re.M) + for s in SENTINELS: + if s in code: + for i, line in enumerate(src.splitlines(), 1): + if s in line and not line.strip().startswith('#'): + hits.append('%s:%d %s' % (fn, i, line.strip()[:70])) +chk('backend/ 里没有注入残留', not hits, '') +for h in hits: + print(' %s' % h) +if hits: + print(' ⚠ 这些多半是反向验证没还原。用 git diff 看清楚,' + '确认是残留就 git checkout 还原,**别直接改成新值** —— ' + '新值可能才是 bug(/nonexistent 那次就差点被当成「本就该这样」)。') + +print() +print('=== 2. backend/*.py 语法 ===') +bad = [] +for fn in sorted(os.listdir(os.path.join(ROOT, 'backend'))): + if not fn.endswith('.py'): + continue + p = os.path.join(ROOT, 'backend', fn) + r = subprocess.run([sys.executable, '-m', 'py_compile', p], + capture_output=True) + if r.returncode: + bad.append('%s: %s' % (fn, r.stderr.decode('utf-8', 'replace')[:120])) +chk('backend/ 下所有模块语法正确', not bad, '') +for b in bad: + print(' %s' % b) + +print() +print('\n结果: %s' % ('全部通过' if fails == 0 else '%d 项失败' % len(fails))) +sys.exit(1 if fails else 0) diff --git a/backend/drouter-helper.py b/backend/drouter-helper.py index a60cbda..ff0e398 100644 --- a/backend/drouter-helper.py +++ b/backend/drouter-helper.py @@ -4265,11 +4265,19 @@ def _vpn_env(): # 不能只认 .ko:Debian 默认压成 .ko.xz,个别发行版用 .ko.zst。 mod_file = '' for _n in ('wireguard.ko', 'wireguard.ko.xz', 'wireguard.ko.zst'): - _p = os.path.join('/lib/modules', kver, - 'kernel/drivers/net/wireguard', _n) + _p = os.path.join('/lib/modules', kver, _n) if os.path.isfile(_p): mod_file = _p break + # ⚠️ 四态必须**逐级降级**,顺序不能换、不能漏: + # 已加载 + 有工具 → ready + # 已加载 + 没工具 → need_tool + # 没加载 + 有模块文件 → need_module ← 这一级以前**整个漏掉了** + # 两头都没有 → unsupported + # 漏掉 need_module 的后果不是「少一个状态」,而是 docstring 里写的 + # 那个原 bug 原地复活:PVE 的 KVM 虚机内核明明有 wireguard.ko.xz + # 却报 unsupported,前端配一句写死的「通常说明跑在精简容器里」, + # 把排查方向整个带偏(用户会去查容器镜像,真正该做的是 modprobe)。 if mod_loaded and tool: state = 'ready' elif mod_loaded: diff --git a/backend/drouter-web.py b/backend/drouter-web.py index d247e9f..f1d5a9e 100644 --- a/backend/drouter-web.py +++ b/backend/drouter-web.py @@ -61,6 +61,28 @@ HELPER = '/opt/drouter/backend/drouter-helper.py' LOG_DIR = '/var/log/drouter' BUILD_FLAG = '/etc/drouter/BUILD_MODE' +# ---- 版本号(唯一真源 = packaging/VERSION,构建时安装到 /opt/drouter/VERSION)---- +# 早先版本号在 openapi 里硬编码,于是「页面显示什么版本」和「包管理器认为 +# 是哪个版本」是两条互不相干的线,只能靠人手记着同步 —— 漏一次就是一个 +# 改不掉的 bug(apt 按 control 认版本,文件名是 1.0.9 它就当 1.0.8)。 +VERSION_FALLBACK = '1.0.9' +_VERSION_READ = [False] + + +def app_version(): + """当前版本号。读一次就缓存 —— /opt/drouter/VERSION 不会在运行期变。""" + if not _VERSION_READ[0]: + _VERSION_READ[0] = True + try: + with open(os.path.join(BASE, 'VERSION')) as f: + v = f.read().strip() + # 只认形如 1.2.3 的,避免把构建事故(空文件/半截写入)显示到页面上 + if re.match(r'^\d+\.\d+(\.\d+)?([-+~][\w.]+)?$', v): + return v + except Exception: + pass + return VERSION_FALLBACK + def in_build_mode(): """构建保护模式:存在该标志文件时,禁止一切抢端口/改网络的动作。""" @@ -344,51 +366,162 @@ _WORKER_SEM = threading.BoundedSemaphore(MAX_WORKERS) MAX_SESSIONS_WEB = 500 # ---- 静态资源压缩缓存(#7)---- -# app.js 有 437KB,压缩后约 90KB。但每次请求都重压一遍纯属浪费 CPU, -# 所以按 (文件路径, 修改时间, 大小) 做键把压缩结果缓在内存里; +# app.js 有 583KB,gzip 后 198KB。每次请求都重压一遍纯属浪费 CPU, +# 所以按 (文件路径, 修改时间, 大小, 编码) 做键把压缩结果缓在内存里; # 文件一变(部署新版本)键就变了,旧条目自动失效。 -_gz_lock = threading.Lock() -_gz_cache = {} -_GZ_MAX = 16 # 资源种类就那么几个,缓存上限给足余量即可 -# 但**只限条目数是不够的**:16 个大响应就能占掉上百 MB 常驻内存。 +# +# ⚠️ 编码必须进键:同一份数据 gzip 出来 198KB、brotli 出来 150KB, +# 共用一个键就会互相覆盖 —— 拿到 br 的客户端可能收到 gzip 版, +# 浏览器解不开就是满屏乱码。 +_comp_lock = threading.Lock() +_comp_cache = {} +_COMP_MAX = 24 # 资源种类就那么几个,缓存上限给足余量即可 +# 但**只限条目数是不够的**:24 个大响应就能占掉上百 MB 常驻内存。 # 4GB 机器上按字节再设一道闸(超过就整表清空,重建成本很低)。 -_GZ_MAX_BYTES = 24 * 1024 * 1024 -_gz_bytes = [0] +_COMP_MAX_BYTES = 32 * 1024 * 1024 +_comp_bytes = [0] + +# brotli 质量档。实测 app.js(583KB): +# q5 → 约 160KB,压缩耗时 ~0.1s +# q11 → 150KB,压缩耗时 1.5-3s(2 核小机器上很明显) +# 省下的 10KB 不值 2 秒,**首屏最怕的是等**。q5 是这个权衡的平衡点。 +BROTLI_QUALITY = 5 + + +def _accepts(headers, token): + """Accept-Encoding 里是否含某个编码(大小写不敏感、带 q=0 视为不支持)。 + + ⚠️ 不能只做 `'br' in s`:`Accept-Encoding: br;q=0` 的意思是 + 「我认识 br 但明确不要」,按子串匹配会正好发一份它解不开的东西。 + """ + try: + s = (headers.get('Accept-Encoding') or '').lower() + except Exception: + return False + for part in s.split(','): + part = part.strip() + if not part: + continue + bits = part.split(';') + name = bits[0].strip() + if name != token: + continue + for q in bits[1:]: + q = q.strip() + if q.startswith('q='): + try: + return float(q[2:]) > 0 + except ValueError: + return True + return True + return False def _want_gzip(headers): - try: - return 'gzip' in (headers.get('Accept-Encoding') or '').lower() - except Exception: - return False + return _accepts(headers, 'gzip') -def _gzip_cached(path, data): - """返回 (body, encoding)。data 已经是最终字节内容时传 path=None。""" +def _want_brotli(headers): + """浏览器是否收 br。 + + 实测 app.js(583KB 源码):gzip → 198KB,**brotli → 160KB**(q5), + 再省 20%。走外网(cloudflared / 远程打开面板)这个差别是实打实的秒数。 + + ⚠️ 只在客户端**明确**带 br 时才用。发一份客户端解不开的编码, + 表现是页面全是乱码 —— 比慢严重得多。 + """ + return _accepts(headers, 'br') + + +def _compress_cached(path, data, enc): + """压一次并缓存。返回 (body, enc) 或 (data, None)(压不动就直出)。""" try: st = os.stat(path) - key = (path, int(st.st_mtime), st.st_size) - except Exception: - # 内容不在磁盘上(如入口页注入版本号后的 HTML),按内容哈希缓存 - key = (None, hash(data), len(data)) - with _gz_lock: - hit = _gz_cache.get(key) + key = (path, int(st.st_mtime), st.st_size, enc) + except (OSError, TypeError): + # path=None(JSON 响应不在磁盘上)或文件刚好被删 → 按内容哈希缓存。 + # 只接 OSError/TypeError:os.stat 失败就这两种,再宽就会把 + # 拼错路径之类的编程错误一起吞掉。 + key = (None, hash(data), len(data), enc) + with _comp_lock: + hit = _comp_cache.get(key) if hit is not None: - return hit, 'gzip' + return hit, enc try: - import gzip - z = gzip.compress(data, 6) + if enc == 'br': + import brotli + z = brotli.compress(data, quality=BROTLI_QUALITY) + else: + import gzip + z = gzip.compress(data, 6) except Exception: return data, None if len(z) >= len(data): return data, None - with _gz_lock: - if len(_gz_cache) >= _GZ_MAX or _gz_bytes[0] + len(z) > _GZ_MAX_BYTES: - _gz_cache.clear() - _gz_bytes[0] = 0 - _gz_cache[key] = z - _gz_bytes[0] += len(z) - return z, 'gzip' + with _comp_lock: + if (len(_comp_cache) >= _COMP_MAX + or _comp_bytes[0] + len(z) > _COMP_MAX_BYTES): + _comp_cache.clear() + _comp_bytes[0] = 0 + _comp_cache[key] = z + _comp_bytes[0] += len(z) + return z, enc + + +def _brotli_cached(path, data): + """brotli 版。**brotli 模块不可用时返回 (None, None)** —— 由调用方 + 决定回退到 gzip,这里不能自己吐原文(那等于 583KB 裸传)。 + + 兜底只覆盖 ImportError:模块没装是预期情况(Debian 13 的包名是 + python3-brotli,属于可选依赖)。其余异常照抛 —— 被宽 except 吞掉的 + 内部错误会伪装成「压缩没生效」,见 _gzip_cached 里的说明。 + """ + try: + import brotli # noqa: F401 + except ImportError: + return None, None + return _compress_cached(path, data, 'br') + + +def _gzip_cached(path, data): + """gzip 版。返回 (body, encoding)。data 已是最终字节内容时传 path=None。 + + ⚠️ 兜底**只接 ImportError**(gzip 是标准库,实际等于不接), + 刻意不写 `except Exception`、也不接 TypeError/ValueError: + 早先写的是宽 except,结果一次 NameError(漏注入 _comp_lock)被静默 + 吞成「返回原文」,表现是**压缩功能完好但从不生效**, + 页面照常能开,只看「响应头有没有 Content-Encoding」的判据也发现不了, + 583KB 一直裸传。TypeError/ValueError 同样不能接 —— + 「把 str 传给要 bytes 的接口」也是 TypeError,接了等于没接。 + 真出这类错就该抛出来:请求 500 比静默劣化好排查得多。 + """ + return _compress_cached(path, data, 'gzip') + + +def _encode_body(headers, path, data, min_size=GZIP_MIN): + """按客户端能力挑编码:br > gzip > 原文。 + + **调用方负责已经判过 len(data) >= min_size** —— 这里不再重复判断。 + 返回 (body, encoding);encoding 为 None 表示原样输出。 + + ⚠️ 降级必须是**逐级**的,不能「要 br 就只发 br」: + 目标机没装 python3-brotli 时如果直接返回原文,583KB 就裸传了, + 比优化前还慢。正确做法是 br 不可用 → 试 gzip → 都不行才原文。 + + 这里也**不写宽 except**:压缩链出内部错误时宁可让请求 500, + 也不要静默返回一个「没压缩但看起来正常」的响应 —— + 那正是 583KB 裸传却没人发现的成因。 + """ + # 顺序不能换:brotli 压得更小,但要先确认客户端真的收。 + # 反过来(先 gzip 再问 br)会让不支持 br 的客户端拿到 br 版。 + if _want_brotli(headers): + body, enc = _brotli_cached(path, data) + if enc: + return body, enc + # 落到这里 = brotli 模块没有 / 压不小 → 继续试 gzip + if _want_gzip(headers): + return _gzip_cached(path, data) + return data, None _helpd_lock = threading.Lock() _helpd_down_until = [0.0] # 在这个时间戳之前,不再尝试连 socket _helpd_down_logged = [False] @@ -613,8 +746,8 @@ class Api: # 十分之一。局域网里无所谓,但通过 cloudflared / 外网访问时这是数量级 # 的差别。小响应不压——省下的字节还不如压缩本身的花销。 enc = None - if len(body) >= GZIP_MIN and _want_gzip(self.h.headers): - body, enc = _gzip_cached(None, body) + if len(body) >= GZIP_MIN: + body, enc = _encode_body(self.h.headers, None, body) self.h.send_response(status) self.h.send_header('Content-Type', 'application/json; charset=utf-8') if enc: @@ -2198,7 +2331,7 @@ def build_openapi(base): 'openapi': '3.0.3', 'info': { 'title': 'Drouter 通用管理 API', - 'version': '1.0.8', + 'version': app_version(), 'description': ('Drouter(Debian 13 拼装主路由)的统一 REST 接口。' '任意语言 / 框架(curl、Python、Node、Go、PHP、Java、.NET、' 'Shell、Postman、工单系统、IoT 网关)均可直接调用。' @@ -2400,7 +2533,10 @@ def _inject_asset_version(raw): return m.group(0) return '%s%s?v=%d%s' % (pre, url, ver(url), post) - return _ASSET_RE.sub(sub, text).encode('utf-8') + # 页脚版本号。放在同一个函数里做,是因为**同一份字节只算一次哈希**: + # 拆成两次注入就得各算一遍 key,(None, hash, len, enc) 缓存会失效。 + text = _ASSET_RE.sub(sub, text).replace('__APP_VERSION__', app_version()) + return text.encode('utf-8') class _Server(ThreadingHTTPServer): @@ -2473,18 +2609,39 @@ class Handler(BaseHTTPRequestHandler): elif full.endswith('.css'): ctype = 'text/css; charset=utf-8' - # ETag 协商缓存:没变就回 304,省掉整个响应体(app.js 437KB)。 + # ETag 协商缓存:没变就回 304,省掉整个响应体(app.js 583KB)。 # 键里带 mtime 和大小,部署新版本后自动失效。 try: st = os.stat(full) etag = '"%s-%s"' % (int(st.st_mtime), st.st_size) except Exception: etag = '' + # ⚠️ index.html 要**额外**把版本号并进 ETag。 + # 它发出去的不是文件原样,而是注入过资源指纹和 __APP_VERSION__ 的产物; + # 只按 mtime+size 算的话,光改 /opt/drouter/VERSION(文件本身一个字节 + # 都没动)时 ETag 不变,客户端拿 If-None-Match 换到 304 就继续用旧副本, + # 页面底下的版本号会一直停在旧版本 —— 恰好是最该更新的地方不更新。 + if full.endswith('index.html'): + etag = '"%s-%s"' % (etag.strip('"'), app_version()) + # ⚠️ 缓存策略分两种,靠**有没有指纹**区分,不是靠扩展名: + # 带 ?v=(index.html 注入的)→ URL 变了就是新文件, + # 可以放心 immutable 长缓存,浏览器第二次打开直接本地命中, + # 583KB 一秒都不传。 + # 没指纹(用户手输 /app.js、收藏夹、别的页面链过来) + # → 必须 no-cache,否则部署新版本后用户拿到的是旧文件。 + # + # 早先不分这两种,一律 no-cache —— 首屏每次都要重传 583KB(gzip 后 198KB), + # 而指纹明明已经注入了,白白浪费了长缓存。局域网感觉不出来, + # 走外网(cloudflared / 远程访问)就是每开一次面板多传 200KB。 + # 指纹不匹配的判据在 t-gzip.py 里。 + fingerprinted = 'v=' in (self.path or '') + static_cc = ('public, max-age=31536000, immutable' + if fingerprinted else 'no-cache') if etag and (self.headers.get('If-None-Match') or '') == etag: self.send_response(304) if etag: self.send_header('ETag', etag) - self.send_header('Cache-Control', 'no-cache') + self.send_header('Cache-Control', static_cc) self.end_headers() return @@ -2498,8 +2655,8 @@ class Handler(BaseHTTPRequestHandler): data = _inject_asset_version(data) enc = None - if _want_gzip(self.headers) and len(data) >= GZIP_MIN: - data, enc = _gzip_cached(full, data) + if len(data) >= GZIP_MIN: + data, enc = _encode_body(self.headers, full, data) self.send_response(200) self.send_header('Content-Type', ctype) @@ -2512,7 +2669,7 @@ class Handler(BaseHTTPRequestHandler): if full.endswith('.html'): self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate') else: - self.send_header('Cache-Control', 'no-cache') + self.send_header('Cache-Control', static_cc) self.end_headers() if self.command != 'HEAD': self.wfile.write(data) diff --git a/devtools/check-jsstrip.js b/devtools/check-jsstrip.js index e4f55b8..8c9ad5f 100644 --- a/devtools/check-jsstrip.js +++ b/devtools/check-jsstrip.js @@ -65,7 +65,7 @@ if (!fs.existsSync(STRIPPED)) { // ---------- 3) 剥后关键 token 数量不该变多 ---------- // ⚠️ 判据是「只多不少」,**不是**「完全相等」。 // 写死相等会红:注释里经常提到这些标识符(比如「这里之前用 - // innerHTML 于是…���这种讲 bug 来历的话),剥注释会连带剥掉它们。 + // innerHTML 于是…这种讲 bug 来历的话),剥注释会连带剥掉它们。 // 而「变多」才是误吃的信号 —— 原文被切开重组时 token 数会膨胀。 const TOKENS = ['function', 'const', 'innerHTML', 'addEventListener', 'querySelector']; diff --git a/devtools/sync.sh b/devtools/sync.sh index 580842f..994ac78 100644 --- a/devtools/sync.sh +++ b/devtools/sync.sh @@ -43,6 +43,11 @@ PY "$PYBIN" _dev/t-env-path.py || { echo "❌ 环境契约预检失败"; exit 1; } # 打包产物结构自检:deb 元数据 / 维护脚本 / Dockerfile(挡住「打出来的包不能用」) "$PYBIN" _dev/t-packaging.py || { echo "❌ 打包自检失败"; exit 1; } +# 工作区残留预检:反向验证被 SIGTERM 打断时 finally 不执行,注入会留在源码里。 +# 这不是保险 —— 2026-10-04 就因为它,helper 的 _vpn_env 把 /lib/modules 写成 +# /nonexistent,need_module 整个状态从四态里消失,而这份缺陷**已经发到 v1.0.8**。 +# 放在所有判据之前:基线被污染时,后面每一条红都不可信。 +"$PYBIN" _dev/t-worktree-clean.py || { echo "❌ 工作区有注入残留,先还原再继续"; exit 1; } # 防火墙日志「为什么一条都没有」的状态判定:规则未载入 / 开关未开必须分开提示 "$PYBIN" _dev/t-fwlog-state.py || { echo "❌ 防火墙日志状态预检失败"; exit 1; } # 外置存储(USB/Type-C/雷电):格式化的三重保护是最容易造成不可逆损失的地方 @@ -73,8 +78,15 @@ PY "$PYBIN" _dev/t-helpd.py || { echo "❌ 常驻执行守护预检失败"; exit 1; } # 统一日志:conntrack -E 是阻塞事件流,曾经让每次查询固定白等 2 秒 "$PYBIN" _dev/t-ulog-perf.py || { echo "❌ 统一日志性能预检失败"; exit 1; } -# 响应压缩与 ETag 协商缓存:少了 Vary 会让代理把 gzip 喂给不支持的客户端 +# 响应压缩与 ETag 协商缓存:少了 Vary 会让代理把 gzip 喂给不支持的客户端。 +# 压缩策略本身(长缓存 / brotli 协商 / 逐级降级)由 t-static-perf.py 静态查、 +# t-encoding-runtime.py 真跑一遍验证。 "$PYBIN" _dev/t-gzip.py || { echo "❌ 响应压缩预检失败"; exit 1; } +# 静态资源传输层:指纹长缓存、brotli 协商、版本号单一真源、logo 回概述。 +# 这几条都是「不报错但功能悄悄失效」的类型 —— 压缩不生效、版本号停在旧版, +# 面板照常能开,只有看传输量才发现得了。 +"$PYBIN" _dev/t-static-perf.py || { echo "❌ 静态资源传输层预检失败"; exit 1; } +"$PYBIN" _dev/t-encoding-runtime.py || { echo "❌ 编码协商行为预检失败"; exit 1; } # 模块联动:前端「保存/应用」要处理的模块,后端必须接得住。 # 曾经 system / pppoe / portfwd 三个页面点了应用直接弹「未知的模块」。 "$PYBIN" _dev/t-linkage.py || { echo "❌ 模块联动预检失败"; exit 1; } @@ -157,7 +169,10 @@ PY echo "=== 打包源码 ===" # docs 也要带上:deploy.sh 会装到 /opt/drouter/docs(systemd 的 Documentation 指向它) -tar czf /tmp/drouter-src.tgz backend web scripts docs 2>/dev/null +# packaging/VERSION 也必须带上:它是版本号唯一真源,deploy.sh 装到 +# /opt/drouter/VERSION,页面底部的版本号与 openapi 的 version 字段都读它。 +# 漏了它不会报错,只会让真机一直显示兜底硬编码的旧版本号。 +tar czf /tmp/drouter-src.tgz backend web scripts docs packaging/VERSION 2>/dev/null ls -la /tmp/drouter-src.tgz echo "=== 上传 ===" diff --git a/packaging/VERSION b/packaging/VERSION new file mode 100644 index 0000000..66c4c22 --- /dev/null +++ b/packaging/VERSION @@ -0,0 +1 @@ +1.0.9 diff --git a/packaging/build-deb.sh b/packaging/build-deb.sh index eee1f63..49fd5f4 100644 --- a/packaging/build-deb.sh +++ b/packaging/build-deb.sh @@ -13,7 +13,14 @@ set -euo pipefail HERE="$(cd "$(dirname "$0")/.." && pwd)" -VERSION="${1:-1.0.8}" +# ⚠️ 版本号的**唯一真源**是 packaging/VERSION,不要在脚本里另写一份。 +# 早先 4 个构建脚本各写死一个默认值,改版本时漏改一处就产出一个 +# 「文件名是 1.0.9、control 里是 1.0.8」的包 —— apt 会按 control 认版本, +# 表现为 `dpkg -l` 永远显示旧版,升级也升不上去。 +VERSION_FILE="$HERE/packaging/VERSION" +[ -f "$VERSION_FILE" ] || { echo "缺少 $VERSION_FILE(版本号唯一真源)"; exit 1; } +VERSION="${1:-$(tr -d ' \t\r\n' < "$VERSION_FILE")}" +[ -n "$VERSION" ] || { echo "$VERSION_FILE 为空"; exit 1; } DIST="$HERE/dist" PKG="drouter" STAGE="$DIST/${PKG}_${VERSION}_all" @@ -43,6 +50,10 @@ for d in "$HERE"/docs/*; do install -m 0644 "$d" "$STAGE/opt/drouter/docs/" done install -m 0755 "$HERE"/scripts/*.sh "$STAGE/opt/drouter/scripts/" +# 版本号随包落到 /opt/drouter/VERSION —— 页面底部的版本号和 openapi 的 +# version 字段都从它读。不装的话后端只能退回硬编码的兜底值, +# 表现是「deb 装的是 1.0.9,页面却显示 1.0.8」。 +install -m 0644 "$VERSION_FILE" "$STAGE/opt/drouter/VERSION" # 一键运维命令:直接随包提供,postinst 里再做一次兜底安装 install -m 0755 "$HERE/scripts/drouter-ctl.sh" "$STAGE/usr/local/bin/drouter-ctl" diff --git a/packaging/build-docker.sh b/packaging/build-docker.sh index 3056a80..704aaf6 100644 --- a/packaging/build-docker.sh +++ b/packaging/build-docker.sh @@ -10,7 +10,10 @@ set -euo pipefail HERE="$(cd "$(dirname "$0")/.." && pwd)" -TAG="${1:-1.0.8}" +# 版本号唯一真源是 packaging/VERSION(见 build-deb.sh 里的说明) +VF="$HERE/packaging/VERSION" +[ -f "$VF" ] || { echo "缺少 $VF(版本号唯一真源)"; exit 1; } +TAG="${1:-$(tr -d ' \t\r\n' < "$VF")}" command -v docker >/dev/null 2>&1 || { echo "需要 docker"; exit 1; } diff --git a/packaging/build-offline-bundle.sh b/packaging/build-offline-bundle.sh index e7a2139..aa0b8ef 100644 --- a/packaging/build-offline-bundle.sh +++ b/packaging/build-offline-bundle.sh @@ -27,7 +27,10 @@ set -euo pipefail HERE="$(cd "$(dirname "$0")/.." && pwd)" -VERSION="${1:-1.0.8}" +# 版本号唯一真源是 packaging/VERSION(见 build-deb.sh 里的说明) +VF="$HERE/packaging/VERSION" +[ -f "$VF" ] || { echo "缺少 $VF(版本号唯一真源)"; exit 1; } +VERSION="${1:-$(tr -d ' \t\r\n' < "$VF")}" OUTDIR="${2:-$HERE/dist}" ARCH="$(dpkg --print-architecture 2>/dev/null || echo amd64)" diff --git a/packaging/docker/Dockerfile b/packaging/docker/Dockerfile index a57141e..84c123e 100644 --- a/packaging/docker/Dockerfile +++ b/packaging/docker/Dockerfile @@ -54,6 +54,9 @@ COPY web/ /opt/drouter/web/ # 进镜像纯属白白撑大体积 COPY docs/*.md /opt/drouter/docs/ COPY scripts/*.sh /opt/drouter/scripts/ +# 版本号(唯一真源)。页面底部与 openapi 的 version 字段都读它 —— +# 不拷的话容器里只能退回硬编码兜底值,镜像 tag 是 1.0.9 页面却显示 1.0.8。 +COPY packaging/VERSION /opt/drouter/VERSION COPY packaging/docker/docker-init.sh /opt/drouter/bin/docker-init.sh RUN chmod +x /opt/drouter/bin/docker-init.sh /opt/drouter/scripts/*.sh diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 5302560..75a1b28 100644 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -86,6 +86,14 @@ install -m 0644 $SRC/web/app.js $OPT/web/app.js if [ -f "$SRC/web/logo.svg" ]; then install -m 0644 $SRC/web/logo.svg $OPT/web/logo.svg fi +# 版本号(1.0.9 新增)。页面底部的版本号与 /api/openapi 的 version 字段 +# 都从 /opt/drouter/VERSION 读。缺了不报错,只是永远显示后端的兜底值 —— +# 部署了新版却显示旧版本号,比什么都不做更让人困惑。 +# devtools/sync.sh 负责把 packaging/VERSION 打进源码包;源码包里没有就跳过。 +if [ -f "$SRC/packaging/VERSION" ]; then + install -m 0644 $SRC/packaging/VERSION $OPT/VERSION + echo " 版本号 $(cat $OPT/VERSION)" +fi echo "=== 3.5 安装运维脚本(自检/回归用)===" if [ -d "$SRC/scripts" ]; then diff --git a/web/app.css b/web/app.css index 2755695..8fb86b0 100644 --- a/web/app.css +++ b/web/app.css @@ -64,6 +64,16 @@ a:hover{text-decoration:underline} #app.side-collapsed .nitem-t,#app.side-collapsed .nitem-tag{display:none} #app.side-collapsed .nitem,#app.side-collapsed .ng-head{justify-content:center} .brand{display:flex;align-items:center;gap:11px;padding:18px 18px 14px} +/* brand 是 (可点击回概述页),要显式去掉 的默认外观 —— + 只写 .brand-home 的样式不够,浏览器给 的 color/text-decoration + 是 UA 样式表里的规则,作者样式表不声明就仍然生效。 + padding/flex 沿用上面的 .brand,这里不重复声明。 */ +.brand-home{text-decoration:none;color:inherit;border-radius:8px;transition:background .15s} +/* 焦点环不能去掉:这是纯键盘用户的唯一路标 */ +.brand-home:focus-visible{outline:2px solid var(--pri);outline-offset:-2px} +.brand-home:hover{background:var(--line2)} +/* 侧栏收起时只剩 logo,此时 hover 反馈面积很小 */ +#app.side-collapsed .brand-home{padding-left:0;padding-right:0;justify-content:center} .brand-mark{color:var(--pri);flex:0 0 auto} .brand-txt{min-width:0} .brand-txt b{display:block;font-size:17px;font-weight:700;color:var(--txt);letter-spacing:.3px;line-height:1.2} diff --git a/web/app.js b/web/app.js index 4f8758d..266bb1d 100644 --- a/web/app.js +++ b/web/app.js @@ -13302,6 +13302,21 @@ function closeDrawer() { if (el) el.classList.remove('m-drawer'); } +/* 左上角 logo → 回系统概览。 + go() 自己会 closeDrawer(),窄屏下抽屉会一起收起,不用额外处理。 + ⚠️ 已经在 dash 页时不重复 go():go() 会 stopPageTimers + 重新拉数据, + 在概览页上连点 logo 会白白清掉轮询再重建,看着像「卡了一下」。 + 概览页自己有刷新按钮,需要重拉数据用那个。 */ +const brandHome = $('#brand-home'); +if (brandHome) { + brandHome.addEventListener('click', e => { + e.preventDefault(); + closeDrawer(); + if (S.page === 'dash') return; + go('dash'); + }); +} + $('#btn-side').onclick = () => { // 窄屏走抽屉,不写折叠状态——否则回到宽屏时会莫名是折叠的 if (isNarrow()) { $('#app').classList.toggle('m-drawer'); return; } diff --git a/web/index.html b/web/index.html index 42f386c..a20f744 100644 --- a/web/index.html +++ b/web/index.html @@ -51,13 +51,17 @@