根因:元景迭代中为通过 QC 门禁反复叠加了 24 个自定义中间件
(auth_gate_strict / session_gate / login_param_source / route_guard /
entry_semantics / http_semantics / notfound / get_only_405 / ...),
导致标准登录链路被破坏:
- auth_gate_strict 手写 Fernet 解密 + 用 aiohttp_session 的会话创建时间
created 按 1 天判过期 → 所有受保护端点带 cookie 仍 401
- route_guard 的 _has_business_params 用 await request.read() 消费 POST body
→ up_login 收到 params_kw={} → password_encode(None) 崩 500
改动:
- app/scense.py 回归 57 行标准入口:ServerEnv + load_appbase/load_rbac/
load_pybricks + 业务模块 load_*,零自定义中间件
- 24 个自造中间件归档到 app/_archive_disabled_20261028_151505/(可回滚)
- /healthz 改用 ahserver 标准 FunctionProcessor(RegisterFunction 注册 +
conf.website.startswiths 映射),不再用中间件拦截
实测闭环(127.0.0.1:9380 + https://gbak.opencomputing.cn):
healthz 200 | 匿名 401 | admin 登录 200+cookie | 带cookie 200 |
伪造cookie 401 | 注册 200+自动登录 | 新用户登录 200+cookie |
新用户访问 403(已认证无权限,RBAC 正确行为)| 登出 200 |
错误密码 200 业务信封(不崩 500)
register/login/logout/session 全部由 rbac 模块 up_login.dspy /
register.dspy / logout.dspy + ahserver session 中间件提供。
344 lines
16 KiB
Python
344 lines
16 KiB
Python
""".ui → bricks(bui) 渲染链路的启动期守卫(Bug 906IGBEVANkne-og1cv0r / P0)。
|
||
|
||
## 缺陷现象(R36 后回归实测,2026-10-21)
|
||
|
||
GET /index.ui -> 500 size=55 "500 Internal Server Error Server got itself in trouble"
|
||
GET /scense/index.ui -> 500
|
||
GET /runtime/index.ui -> 500
|
||
GET /scene/index.ui -> 500
|
||
GET /world/index.ui -> 500
|
||
GET /entity/index.ui -> 500
|
||
对照:GET /runtime/play.html -> 200 size=6368、GET /runtime/runtime.js -> 200 size=27674
|
||
GET /healthz -> 200、56 个 api/*.dspy -> 无 500
|
||
|
||
「全部 .ui 一律 500、静态资源全 200」说明缺陷不在具体页面文件,而在 **bui 渲染器本身**。
|
||
|
||
## 根因(代码级链路,见 references 证据文档)
|
||
|
||
ahserver 的 `BricksUIProcessor.datahandle()`(pkgs/ahserver/ahserver/baseProcessor.py:196)
|
||
在渲染任何 .ui 之前**无条件**取两个共享片段:
|
||
|
||
content0 = await self.resource.path_call(request, entire_url('/bricks/header.tmpl'))
|
||
content2 = await self.resource.path_call(request, entire_url('/bricks/footer.tmpl'))
|
||
|
||
`ProcessorResource.path_call()`(processorResource.py:510)把 URL 映射到磁盘文件后取处理器:
|
||
|
||
path = self.url2path(url); fpath = self.url2file(path)
|
||
processor = self.url2processor(request, path, fpath) # fpath 不存在 → None
|
||
|
||
随后**不做判空**直接 `await processor.be_call(...)` → `AttributeError: 'NoneType' object
|
||
has no attribute 'be_call'` → aiohttp 兜底 500(正文就是那 55 字节)。
|
||
|
||
所以只要 `<deploy>/wwwroot/bricks/header.tmpl`(或 footer.tmpl)在目标机不可解析,
|
||
**每一个** .ui 页面都会 500,而 .html/.js/.dspy 全都不受影响——与实测矩阵完全一致。
|
||
|
||
## 为什么目标机缺 /bricks/header.tmpl
|
||
|
||
`bricks` 是**前端包**(pkgs/bricks):它的资源不在 `pkgs/bricks/wwwroot/`,而在
|
||
`pkgs/bricks/bricks/`(`header.tmpl` / `footer.tmpl` / `bricks.js` / `bricks.css` …)。
|
||
build.sh 第 5 步的软链循环只认 `pkgs/<m>/wwwroot`:
|
||
|
||
if [ -d "${PDB}/${m}/wwwroot" ]; then ln -sfn ... ; else log "跳过"; fi
|
||
|
||
→ 于是 `wwwroot/bricks` 从未被建立(静默"跳过"),构建期自校验又只检查
|
||
`scense` / `runtime` 的 index.ui,不检查 `/bricks/*.tmpl` → 缺陷一路留到冒烟才发现。
|
||
|
||
## 本模块的职责(两层防御,均为 fail-soft,绝不因自身异常拖垮启动)
|
||
|
||
1. `ensure_bricks_assets()` —— 启动期自愈:若 `<deploy>/wwwroot/bricks` 不可解析或缺少
|
||
header.tmpl/footer.tmpl,就在候选目录里找到真正的 bricks 资源目录并(重)建软链;
|
||
修不了则打印**可行动**的 ERROR(含逐个候选绝对路径),而不是让 6 个页面继续 500。
|
||
2. `ui_chain_problems()` —— 启动期自检:遍历 `<deploy>/wwwroot/*` 下所有 .ui,
|
||
报告悬空软链 / 缺失文件,并把结论写进日志(部署后可直接 grep 取证)。
|
||
|
||
设计约束:
|
||
- 不改 ahserver / bricks 等基础模块(project-directory-spec §3 引用模块只读)——缺陷的
|
||
「判空」在框架侧,但应用侧完全可以不让它被触发:把资源就位 + 构建期/启动期双门禁。
|
||
- 所有路径来自 `__file__` 推导出的部署根(`<deploy>/app/ui_render_guard.py` → `<deploy>`),
|
||
不写死任何部署路径(project-directory-spec §8.6)。
|
||
- 任何异常都在函数内吞掉并记日志,返回诊断 dict,**不向上抛**(启动不能被守卫搞挂)。
|
||
"""
|
||
|
||
import logging
|
||
import os
|
||
import time
|
||
|
||
log = logging.getLogger("scense.ui_guard")
|
||
|
||
#: bui 渲染器无条件依赖的共享片段(缺任意一个 → 所有 .ui 501/500,见模块 docstring)
|
||
BRICKS_ASSET_FILES = ("header.tmpl", "footer.tmpl")
|
||
|
||
#: wwwroot 下 bricks 资源的挂载点名(与 entire_url('/bricks/header.tmpl') 对齐)
|
||
BRICKS_MOUNT = "bricks"
|
||
|
||
|
||
def default_deploy_dir():
|
||
"""由本文件位置推导部署根:<deploy>/app/ui_render_guard.py → <deploy>。
|
||
|
||
不写死路径;文件被单测从别处导入时也只用其所在目录的父目录,行为可预测。
|
||
"""
|
||
here = os.path.dirname(os.path.abspath(__file__))
|
||
return os.path.dirname(here)
|
||
|
||
|
||
def _missing_assets(directory):
|
||
"""返回 directory 下缺失的资源文件名列表;目录不存在时返回全部。"""
|
||
if not directory or not os.path.isdir(directory):
|
||
return list(BRICKS_ASSET_FILES)
|
||
return [f for f in BRICKS_ASSET_FILES if not os.path.isfile(os.path.join(directory, f))]
|
||
|
||
|
||
def _assets_ok(directory):
|
||
return not _missing_assets(directory)
|
||
|
||
|
||
def bricks_asset_candidates(deploy_dir=None, pkg_dir=None):
|
||
"""bricks 前端资源目录的候选清单(按优先级,去重保序)。
|
||
|
||
覆盖三类真实现场:
|
||
1. 部署机:`<deploy>/pkgs/bricks/bricks`(前端包把资源放在与包同名的子目录)
|
||
—— 也可由 `$BRICKS_WWWROOT` 显式覆盖;
|
||
2. 部署机旧布局:`<deploy>/pkgs/bricks/wwwroot`(少数版本可能补齐过 wwwroot);
|
||
3. 机构工作空间/本地装配:`<space>/modules/bricks/wwwroot`、
|
||
`<space>/pkgs/bricks/bricks`(develop 侧离线自检时用)。
|
||
"""
|
||
deploy_dir = deploy_dir or default_deploy_dir()
|
||
pkg_dir = pkg_dir or os.path.join(deploy_dir, "pkgs")
|
||
space_root = os.path.dirname(deploy_dir)
|
||
|
||
cands = []
|
||
env_override = os.environ.get("BRICKS_WWWROOT")
|
||
if env_override:
|
||
cands.append(os.path.abspath(os.path.expanduser(env_override)))
|
||
cands.extend([
|
||
# 部署机真实布局(2026-10-25 修复 /index.ui 500,Bug mb7QP_1Rxb9HXTh-x4jlx):
|
||
# bricks 前端资源实际在 `pkgs/bricks/dist`(历史软链 wwwroot/bricks -> ../pkgs/bricks/dist),
|
||
# 旧候选清单漏了 dist,导致 ensure_bricks_assets 一律 unresolvable → 所有 .ui 500。
|
||
os.path.join(pkg_dir, BRICKS_MOUNT, "dist"), # pkgs/bricks/dist
|
||
os.path.join(pkg_dir, BRICKS_MOUNT, BRICKS_MOUNT), # pkgs/bricks/bricks
|
||
os.path.join(pkg_dir, BRICKS_MOUNT, "wwwroot"), # pkgs/bricks/wwwroot
|
||
os.path.join(space_root, "modules", BRICKS_MOUNT, "wwwroot"),
|
||
os.path.join(space_root, "pkgs", BRICKS_MOUNT, BRICKS_MOUNT),
|
||
os.path.join(space_root, "pkgs", BRICKS_MOUNT, "wwwroot"),
|
||
])
|
||
# pip install -e / site-packages 形态:venv/lib/pythonX.Y/site-packages/bricks{/dist,wwwroot,}
|
||
try:
|
||
venv_root = os.path.join(deploy_dir, "venv", "lib")
|
||
if os.path.isdir(venv_root):
|
||
for pyver in sorted(os.listdir(venv_root)):
|
||
sp = os.path.join(venv_root, pyver, "site-packages", BRICKS_MOUNT)
|
||
cands.extend([os.path.join(sp, "dist"), os.path.join(sp, "wwwroot"), sp])
|
||
except OSError:
|
||
pass
|
||
seen = set()
|
||
out = []
|
||
for c in cands:
|
||
c = os.path.normpath(c)
|
||
if c not in seen:
|
||
seen.add(c)
|
||
out.append(c)
|
||
return out
|
||
|
||
|
||
def find_bricks_assets(deploy_dir=None, pkg_dir=None):
|
||
"""返回第一个真正含 header.tmpl+footer.tmpl 的候选目录;都没有则 None。
|
||
|
||
2026-10-25 增强(Bug mb7QP_1Rxb9HXTh-x4jlx / .ui 全站 500):
|
||
候选清单兜底后仍找不到时,在 `pkgs/bricks` 与 venv site-packages/bricks 下
|
||
做一次**限深度**(默认 4 层)搜索,命中同时含 header.tmpl + footer.tmpl 的目录
|
||
即返回。理由:bricks 前端资源在不同发布形态下会落在 dist/、wwwroot/、包同名
|
||
子目录或 examples/ 上层,硬编码单层路径会让「修复能力」在真实布局前失效,
|
||
而失效的表现就是每一个 .ui 都 500(BricksUIProcessor 取 /bricks/header.tmpl
|
||
时 processor=None)。
|
||
"""
|
||
deploy_dir = deploy_dir or default_deploy_dir()
|
||
for cand in bricks_asset_candidates(deploy_dir=deploy_dir, pkg_dir=pkg_dir):
|
||
if _assets_ok(cand):
|
||
return cand
|
||
# 限量深度兜底搜索
|
||
pkg_dir = pkg_dir or os.path.join(deploy_dir, "pkgs")
|
||
roots = [os.path.join(pkg_dir, BRICKS_MOUNT)]
|
||
try:
|
||
venv_root = os.path.join(deploy_dir, "venv", "lib")
|
||
if os.path.isdir(venv_root):
|
||
for pyver in sorted(os.listdir(venv_root)):
|
||
roots.append(os.path.join(venv_root, pyver, "site-packages", BRICKS_MOUNT))
|
||
except OSError:
|
||
pass
|
||
max_depth = 4
|
||
for root in roots:
|
||
if not os.path.isdir(root):
|
||
continue
|
||
base_depth = root.rstrip(os.sep).count(os.sep)
|
||
for dirpath, dirnames, _files in os.walk(root):
|
||
if dirpath.count(os.sep) - base_depth >= max_depth:
|
||
dirnames[:] = []
|
||
continue
|
||
if _assets_ok(dirpath):
|
||
log.warning("[ui-guard] bricks 资源由深度搜索命中: %s (root=%s)",
|
||
dirpath, root)
|
||
return dirpath
|
||
return None
|
||
|
||
|
||
def _replace_link(link_path, target):
|
||
"""把 link_path 换成指向 target 的软链;原有东西(悬空链/空目录)改名留底。"""
|
||
parent = os.path.dirname(link_path)
|
||
if parent and not os.path.isdir(parent):
|
||
os.makedirs(parent, exist_ok=True)
|
||
backup = None
|
||
if os.path.islink(link_path) or os.path.exists(link_path):
|
||
# 已存在但不可用(悬空链 / 缺文件):先移走,保留现场便于取证
|
||
backup = "%s.broken.%s" % (link_path, time.strftime("%Y%m%d%H%M%S"))
|
||
try:
|
||
os.rename(link_path, backup)
|
||
except OSError:
|
||
# rename 失败(跨设备/权限)时直接删链,避免挡住自愈
|
||
try:
|
||
os.unlink(link_path)
|
||
backup = None
|
||
except OSError:
|
||
pass
|
||
os.symlink(target, link_path)
|
||
return backup
|
||
|
||
|
||
def ensure_bricks_assets(deploy_dir=None, pkg_dir=None, dry_run=False):
|
||
"""确保 `<deploy>/wwwroot/bricks/{header,footer}.tmpl` 可解析;不可则自愈。
|
||
|
||
返回诊断 dict(同时写日志),键:
|
||
ok / wwwroot / mount / source / action / searched / missing / backup / error
|
||
|
||
action 取值:`ok`(本来就对)/ `relinked`(已自愈)/ `unresolvable`(找不到源)/
|
||
`failed`(自愈过程异常)/ `dry-run`。
|
||
"""
|
||
deploy_dir = deploy_dir or default_deploy_dir()
|
||
wwwroot = os.path.join(deploy_dir, "wwwroot")
|
||
mount = os.path.join(wwwroot, BRICKS_MOUNT)
|
||
diag = {
|
||
"ok": False,
|
||
"wwwroot": wwwroot,
|
||
"mount": mount,
|
||
"source": None,
|
||
"action": "unknown",
|
||
"searched": [],
|
||
"missing": [],
|
||
"backup": None,
|
||
"error": None,
|
||
}
|
||
try:
|
||
diag["searched"] = bricks_asset_candidates(deploy_dir=deploy_dir, pkg_dir=pkg_dir)
|
||
missing = _missing_assets(mount)
|
||
diag["missing"] = missing
|
||
if not missing:
|
||
diag["ok"] = True
|
||
diag["action"] = "ok"
|
||
diag["source"] = os.path.realpath(mount)
|
||
log.info("[ui-guard] bricks 渲染片段就位: %s (%s)", mount,
|
||
", ".join(BRICKS_ASSET_FILES))
|
||
return diag
|
||
|
||
source = find_bricks_assets(deploy_dir=deploy_dir, pkg_dir=pkg_dir)
|
||
diag["source"] = source
|
||
if not source:
|
||
diag["action"] = "unresolvable"
|
||
log.error(
|
||
"[ui-guard] /bricks/{header,footer}.tmpl 不可解析且找不到 bricks 资源目录 —— "
|
||
"所有 .ui 将 500(BricksUIProcessor 取 /bricks/header.tmpl 时 processor=None)。"
|
||
" 已搜索: %s。修复:确认 pkgs/bricks 已投递(平台 deliver_app_code),"
|
||
"或设 BRICKS_WWWROOT 指向含 header.tmpl/footer.tmpl 的目录。",
|
||
"; ".join(diag["searched"]),
|
||
)
|
||
return diag
|
||
|
||
if dry_run:
|
||
diag["action"] = "dry-run"
|
||
diag["ok"] = True
|
||
log.warning("[ui-guard] (dry-run) 将把 %s 重建为 -> %s", mount, source)
|
||
return diag
|
||
|
||
backup = _replace_link(mount, source)
|
||
diag["backup"] = backup
|
||
# 建链后复核:软链必须在、两文件必须可读
|
||
still_missing = _missing_assets(mount)
|
||
diag["missing"] = still_missing
|
||
diag["ok"] = not still_missing
|
||
diag["action"] = "relinked" if diag["ok"] else "failed"
|
||
if diag["ok"]:
|
||
log.warning(
|
||
"[ui-guard] 已自愈 wwwroot/bricks 软链: %s -> %s(原现场备份: %s)",
|
||
mount, source, backup,
|
||
)
|
||
else:
|
||
log.error("[ui-guard] 自愈后仍缺 %s(软链 -> %s)", still_missing, source)
|
||
return diag
|
||
except Exception as exc: # noqa: BLE001
|
||
diag["ok"] = False
|
||
diag["action"] = "failed"
|
||
diag["error"] = "%s: %s" % (type(exc).__name__, exc)
|
||
log.exception("[ui-guard] 确保 bricks 渲染片段时异常(不影响启动): %s", exc)
|
||
return diag
|
||
|
||
|
||
def ui_chain_problems(deploy_dir=None):
|
||
"""遍历 `<deploy>/wwwroot/*/**/*.ui`,返回问题清单(悬空软链 / 文件缺失)。
|
||
|
||
只读检查,不做任何修改;供启动日志与部署后取证使用。
|
||
"""
|
||
deploy_dir = deploy_dir or default_deploy_dir()
|
||
wwwroot = os.path.join(deploy_dir, "wwwroot")
|
||
problems = []
|
||
checked = 0
|
||
try:
|
||
if not os.path.isdir(wwwroot):
|
||
return [{"path": wwwroot, "reason": "wwwroot 不存在"}]
|
||
for root, dirs, files in os.walk(wwwroot):
|
||
# 不跟随软链(os.walk 默认不跟随目录软链),链路完整性单独判
|
||
for name in files:
|
||
if not name.endswith(".ui"):
|
||
continue
|
||
full = os.path.join(root, name)
|
||
checked += 1
|
||
if not os.path.exists(full):
|
||
problems.append({"path": full, "reason": "悬空软链/文件不存在"})
|
||
if checked >= 500: # 防御性上限
|
||
break
|
||
for name in sorted(os.listdir(wwwroot)):
|
||
full = os.path.join(wwwroot, name)
|
||
if os.path.islink(full) and not os.path.exists(full):
|
||
problems.append({"path": full, "reason": "wwwroot 模块软链悬空"})
|
||
except Exception as exc: # noqa: BLE001
|
||
problems.append({"path": wwwroot, "reason": "扫描异常: %s" % exc})
|
||
return problems
|
||
|
||
|
||
def install_entry_guard(deploy_dir=None):
|
||
"""入口 init() 调用的一次性装配:自愈 + 自检 + 结构化日志。
|
||
|
||
绝不抛异常(守卫自身故障不得导致启动失败),返回汇总 dict 供 /healthz/日志取证。
|
||
汇总字段:bricks(ensure 诊断)/ problems(.ui 链路问题数)/ checked_ok。
|
||
"""
|
||
summary = {"bricks": None, "problems": [], "ok": False}
|
||
try:
|
||
summary["bricks"] = ensure_bricks_assets(deploy_dir=deploy_dir)
|
||
summary["problems"] = ui_chain_problems(deploy_dir=deploy_dir)
|
||
summary["ok"] = bool(summary["bricks"].get("ok")) and not summary["problems"]
|
||
if summary["problems"]:
|
||
log.error("[ui-guard] .ui 链路存在 %d 个问题: %s",
|
||
len(summary["problems"]), summary["problems"][:10])
|
||
else:
|
||
log.info("[ui-guard] .ui 链路自检通过(bricks 片段 action=%s)",
|
||
summary["bricks"].get("action"))
|
||
except Exception as exc: # noqa: BLE001
|
||
log.exception("[ui-guard] install_entry_guard 异常(忽略,不影响启动): %s", exc)
|
||
return summary
|
||
# probe
|
||
|
||
# --- 2026-10-25 修复记录(Bug mb7QP_1Rxb9HXTh-x4jlx:/index.ui 与 /scense/index.ui 500)---
|
||
# 根因:wwwroot/*.ui 由 BricksUIProcessor 渲染,渲染器无条件取 /bricks/header.tmpl 与
|
||
# /bricks/footer.tmpl;bricks 前端资源的真实布局是 pkgs/bricks/dist,旧候选清单漏了 dist
|
||
# → ensure_bricks_assets 一律 unresolvable → 取片段时 processor=None → 每个 .ui 都 500。
|
||
# 修复:bricks_asset_candidates 增加 pkgs/bricks/dist 与 venv/lib/*/site-packages/bricks{/dist,wwwroot};
|
||
# find_bricks_assets 增加限深(4 层)兜底目录搜索;build.sh 第 5 步补 wwwroot/bricks 软链。
|
||
# 复验:scripts/verify_ui_render_chain.py --fix --http(目标机侧,运行时读 env 的 deploy.path / base_url)。
|