根因:元景迭代中为通过 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 中间件提供。
600 lines
31 KiB
Python
600 lines
31 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""入口自带 HTTP 语义兜底(404 / 405)——零应用内依赖,可独立自测。
|
||
|
||
Bug Wp6gXrrEquzg3njONz60b(INT-404-neg)+ 28PuqnfpWzB2ypJoUTU_N(R17-2)
|
||
----------------------------------------------------------------------
|
||
线上实测(2026-10-21,带 admin cookie):
|
||
```
|
||
GET /runtime/api/__no_such_endpoint__.dspy -> 500 (期望 404)
|
||
GET /nonexist/x.dspy -> 500 (期望 404)
|
||
(无 cookie 时同两条 401 → 鉴权层正常,异常出在鉴权之后被兜底成 500)
|
||
```
|
||
R17-2 把语义中间件放在 `app/semantic_wiring.py`,由入口 `app/scense.py` import 后装配。
|
||
只要这条 import 在目标机失败(投递不全 / 包路径不可见 / pyc 过期),装配异常被
|
||
`except` 吞掉、进程照常起、`/healthz` 照常 200 → **静默降级**,500 原样存在。
|
||
|
||
本模块的设计目标就是**消除这条静默路径**:
|
||
* 只依赖 aiohttp 的 `web`(应用内零 import),入口用**绝对文件路径** + importlib 加载,
|
||
不依赖 `app` 包是否可导入;
|
||
* 提供 `install(app)` → 返回 {added, middlewares, ok},装配结果可被 /healthz 读到;
|
||
* 语义规则:处理器因「路径不存在」抛异常(ahserver 的 `invalid path` / `fpath is None`)
|
||
→ 404;`.dspy` 收到 GET/POST 以外的方法 → 405 + `Allow` 头;其它异常原样上抛保 500。
|
||
|
||
2026-10-22 修订(Bug Wp6gXrrEquzg3njONz60b 收口,见 `docs/02-develop/int404-bug2a-root-cause.md`)
|
||
--------------------------------------------------------------------------------------
|
||
上一轮为了「不靠 message 措辞」引入了 `NOTFOUND_EXC_TYPES` 类型白名单,但判定时**没有上下文**:
|
||
任何 `FileNotFoundError` / `NotADirectoryError` / `IsADirectoryError`(以及文本含
|
||
"no such file or directory" 的 OSError)都直接判 404。这会把**真实后端缺陷**(例如模块读配置
|
||
文件/证据文件缺失、业务代码 open() 打错路径)掩成 404,与「不掩错、真实故障仍 500」的门禁冲突,
|
||
也是 QC 追着要「回归证明真实异常仍返回 500」的直接原因。
|
||
|
||
本轮把「类型命中」加上**上下文前置条件**,只在两种情形判 404:
|
||
① 调用栈里存在 ahserver 资源解析帧(processorResource / processors.py / static_handle /
|
||
html_handle / BricksUIProcessor)——即异常确实产生于「URL→磁盘资源映射」这一步;
|
||
② 请求路径是静态资源类后缀(.ui/.bui/.js/.css/.html/.png…,**不含 .dspy**)——缺失文件
|
||
天然是 404 语义。
|
||
其余情况(含业务 .dspy 端点内部抛 FileNotFoundError)一律返回 False → 原样上抛 → 500。
|
||
message 主签名(`invalid path` / `fpath is None` / `no such resource`)仍优先命中,保住
|
||
Bug2-A 主路径(`ahserver processorResource.py:399` 原文 `raise Exception(f'... invalid path')`)。
|
||
|
||
2026-10-21 增补 F-03(Bug wC2W2N9x9Xkzw_yWiuP6F)
|
||
--------------------------------------------------
|
||
`/healthz` 由入口 `add_get()` 注册,路径既不以 `.dspy` 结尾、也不是磁盘静态资源,
|
||
因此**落在方法门禁覆盖范围之外**:DELETE/PUT/POST 先在 ahserver 的 URL→资源解析阶段
|
||
被判成 `invalid path`,再被 404 语义层兜成「资源不存在」(且无 `Allow` 头)。
|
||
方法语义必须归位:非 GET/HEAD/OPTIONS → **405 + `Allow: GET, HEAD, OPTIONS`**,不得 404。
|
||
|
||
覆盖范围按「同口径」取三类(全部在本层,单一真源,不散落到各入口):
|
||
① 入口级内建只读路径清单 `BUILTIN_GET_ONLY_PATHS`(`/healthz` 等)——静态兜底,
|
||
即使 `install()` 早于 `_register_healthz()`(路由表还查不到)也生效;
|
||
② 装配期从 aiohttp 路由表收集的「只注册了 GET」的路径(`collect_get_only_paths`)——
|
||
后续新增的任何内建 GET 端点自动覆盖,无需再改代码;
|
||
③ 直挂页面 `.html/.htm/.ui/.bui/.js/.css`(如 `play.html`、`index.ui`)——**仅当文件在
|
||
wwwroot 下真实存在**时升 405,确认不了存在就保持原语义(404),绝不把「真不存在」
|
||
冒充成「方法不允许」。
|
||
真不存在的资源仍由 404 层处理(方法判定先于资源存在性判定,与 aiohttp/RFC 7231 一致)。
|
||
|
||
2026-10-20 复核补充(F-03 / P2 收口)
|
||
-------------------------------------
|
||
方法判定「先于存在性」只对**确实存在**的资源成立:`.dspy` 端点不存在时,非 GET 方法
|
||
同样必须 404(不得冒充 405)。为此新增 `collect_dspy_route_paths()`(装配期收集路由表里
|
||
已注册的 .dspy)与 `dspy_endpoint_exists()`(路由表 + wwwroot 磁盘双重判定;两处都判定
|
||
不了时保守维持 405,不改变既有 F-01 契约)。
|
||
"""
|
||
|
||
import logging
|
||
import os
|
||
import traceback
|
||
|
||
log = logging.getLogger("scense.entry.semantics")
|
||
|
||
#: 中间件名称(供装配核验 / healthz 诊断)
|
||
NAME_NOTFOUND = "entry_notfound_guard"
|
||
NAME_METHOD = "entry_method_guard"
|
||
|
||
#: [fix-r19 healthz] 语义层版本标记——写进 install() 结果并由 /healthz 暴露。
|
||
#: 部署态核验「线上跑的是哪一版语义层」不用再靠猜(Bug EPaXUem2Gbz58sMES5eBC:
|
||
#: 仓库里已有修复但线上仍 404,无法一眼判断进程加载的是哪份文件)。
|
||
SEMANTICS_REVISION = "r20-healthz-405-v4"
|
||
|
||
#: .dspy 端点允许的方法:GET/POST 为 ahserver 常规调用方式,HEAD/OPTIONS 供探测
|
||
DSPY_ALLOWED_METHODS = ("GET", "POST", "HEAD", "OPTIONS")
|
||
DSPY_ALLOW_HEADER = ", ".join(DSPY_ALLOWED_METHODS)
|
||
|
||
#: 页面 / 入口级内建只读端点允许的方法(无写入语义)。
|
||
#: F-03(Bug wC2W2N9x9Xkzw_yWiuP6F):`/healthz` 由 add_get 注册,非 GET 方法在
|
||
#: ahserver 资源解析阶段表现为 "invalid path" → 被 404 兜底,语义错误(应为 405)。
|
||
PAGE_ALLOWED_METHODS = ("GET", "HEAD", "OPTIONS")
|
||
PAGE_ALLOW_HEADER = ", ".join(PAGE_ALLOWED_METHODS)
|
||
|
||
#: 入口级内建只读路径(框架/入口代码注册的探活与元信息端点,非 .dspy、非磁盘资源)。
|
||
#: 即使 install() 早于 `_register_healthz()` 执行(路由表里还查不到),本清单也保证覆盖。
|
||
BUILTIN_GET_ONLY_PATHS = (
|
||
"/healthz",
|
||
"/", # [fix-iter3 healthz] 首页入口同为只读 GET
|
||
)
|
||
|
||
#: 入口级直挂的只读页面后缀:这些路径无写入语义,**且文件真实存在时**非
|
||
#: GET/HEAD/OPTIONS 返回 405;文件不存在仍走 404(不冒充「方法不允许」)。
|
||
#: 2026-10-21 收口A(QC#2):补 .ui/.bui/.js/.css——BricksUI 页面与静态资源同样只读,
|
||
#: 此前不在清单内,非 GET 会落到 404 而非 405+Allow。
|
||
#: 2026-10-21 收口A-2(内建/静态路由语义清单排查):补 .json——build.sh 第 6 步
|
||
#: merge_i18n 生成 wwwroot/i18n/{lang}/i18n.json(真静态资源,只读),此前不在清单内,
|
||
#: PUT/DELETE/PATCH 会落到 404 冒充「不存在」而非 405+Allow。仅扩后缀清单,
|
||
#: 未改动 entry_method_guard 的 405 判定分支。
|
||
GET_ONLY_PAGE_SUFFIXES = (".html", ".htm", ".ui", ".bui", ".js", ".css", ".json")
|
||
|
||
#: wwwroot 候选根(页面存在性判定用):<deploy_dir>/wwwroot(本文件在 <deploy_dir>/app/ 下)。
|
||
#: 判定失败一律视为「不存在」→ 保持 404 语义,不改变既有 404 契约。
|
||
WWWROOT_DIRNAME = "wwwroot"
|
||
|
||
|
||
def _norm_path(path):
|
||
"""路径归一:折叠重复斜杠、去尾斜杠(`//healthz/` 与 `/healthz` 视为同一路径)。"""
|
||
p = (path or "").strip()
|
||
if not p:
|
||
return ""
|
||
while "//" in p:
|
||
p = p.replace("//", "/")
|
||
if len(p) > 1 and p.endswith("/"):
|
||
p = p.rstrip("/")
|
||
return p
|
||
|
||
|
||
def wwwroot_roots():
|
||
"""可判定的 wwwroot 根列表(部署目录下)。取不到就返回空列表。"""
|
||
roots = []
|
||
try:
|
||
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
cand = os.path.join(base, WWWROOT_DIRNAME)
|
||
if os.path.isdir(cand):
|
||
roots.append(cand)
|
||
except Exception: # noqa: BLE001
|
||
return []
|
||
return roots
|
||
|
||
|
||
def page_exists(path):
|
||
"""页面类路径在 wwwroot 下是否真实存在(确认不了就返回 False → 保持 404 语义)。"""
|
||
p = _norm_path(path).lstrip("/")
|
||
if not p or ".." in p.split("/"):
|
||
return False
|
||
for root in wwwroot_roots():
|
||
try:
|
||
if os.path.isfile(os.path.join(root, p)):
|
||
return True
|
||
except Exception: # noqa: BLE001
|
||
continue
|
||
return False
|
||
|
||
|
||
def collect_get_only_paths(app):
|
||
"""装配期从 aiohttp 路由表收集「只注册了 GET」的路径(F-03 通用收敛)。
|
||
|
||
`/healthz` 等入口内建端点用 `add_get` 注册。aiohttp 原生对方法不匹配会回 405,
|
||
但请求**先经过 ahserver 的 URL→磁盘资源解析**,非 GET 在进入路由之前就被判成
|
||
"invalid path" → 语义层兜成 404。因此必须在中间件里**先判方法**。
|
||
|
||
收集失败一律退回 `BUILTIN_GET_ONLY_PATHS`(不阻断装配、不影响主流程)。
|
||
"""
|
||
paths = {_norm_path(p) for p in BUILTIN_GET_ONLY_PATHS}
|
||
try:
|
||
router = getattr(app, "router", None)
|
||
routes = list(router.routes()) if router is not None else []
|
||
except Exception: # noqa: BLE001
|
||
return paths
|
||
for r in routes:
|
||
try:
|
||
methods = {m.strip().upper() for m in
|
||
(getattr(r, "method", "") or "").split(",") if m.strip()}
|
||
resource = getattr(r, "resource", None)
|
||
cand = (getattr(resource, "canonical", None)
|
||
or getattr(resource, "_path", None)
|
||
or getattr(r, "path", None))
|
||
if not cand or not methods:
|
||
continue
|
||
if methods <= {"GET"}:
|
||
paths.add(_norm_path(cand))
|
||
except Exception: # noqa: BLE001
|
||
continue
|
||
return paths
|
||
|
||
|
||
def collect_dspy_route_paths(app):
|
||
"""装配期从路由表收集已注册的 `.dspy` 路径(F-03 存在性判定用)。
|
||
|
||
目的:非 GET 方法打到 `.dspy` 时,**只有端点真实存在**才升 405;端点不存在必须
|
||
保持 404(不得把「资源不存在」冒充成「方法不允许」)。ahserver 的 .dspy 多数走
|
||
动态处理器(不在 aiohttp 路由表里),此时退回 `dspy_endpoint_exists()` 的磁盘判定。
|
||
"""
|
||
paths = set()
|
||
try:
|
||
router = getattr(app, "router", None)
|
||
routes = list(router.routes()) if router is not None else []
|
||
except Exception: # noqa: BLE001
|
||
return paths
|
||
for r in routes:
|
||
try:
|
||
resource = getattr(r, "resource", None)
|
||
cand = (getattr(resource, "canonical", None)
|
||
or getattr(resource, "_path", None)
|
||
or getattr(r, "path", None))
|
||
if not cand:
|
||
continue
|
||
cp = _norm_path(cand)
|
||
if cp.endswith(".dspy"):
|
||
paths.add(cp)
|
||
except Exception: # noqa: BLE001
|
||
continue
|
||
return paths
|
||
|
||
|
||
def dspy_endpoint_exists(path, dspy_paths=None):
|
||
"""`.dspy` 端点是否真实存在(决定方法不匹配时该 405 还是 404)。
|
||
|
||
判定顺序:
|
||
① 路由表已注册的 .dspy 路径命中 → 存在;
|
||
② wwwroot 下能映射到真实文件 → 存在(部署机 `wwwroot/<module>` 是软链,isfile 跟随);
|
||
③ 两处都判定不了(wwwroot 不可用)→ **返回 True**,保守维持既有 405 语义,
|
||
不把「查不清」变成 404,避免回归已正确的 F-01 行为。
|
||
"""
|
||
p = _norm_path(path)
|
||
if not p.endswith(".dspy"):
|
||
return True
|
||
if p in set(_norm_path(x) for x in (dspy_paths or ())):
|
||
return True
|
||
rel = p.lstrip("/")
|
||
if not rel or ".." in rel.split("/"):
|
||
return False
|
||
roots = wwwroot_roots()
|
||
if not roots:
|
||
return True
|
||
for root in roots:
|
||
try:
|
||
if os.path.isfile(os.path.join(root, rel)):
|
||
return True
|
||
except Exception: # noqa: BLE001
|
||
return True
|
||
return False
|
||
|
||
|
||
def is_page_like_path(path):
|
||
"""是否入口直挂的只读页面/静态资源(.html/.htm/.ui/.bui/.js/.css);不含 .dspy。"""
|
||
p = (path or "").lower()
|
||
return bool(p) and p.endswith(GET_ONLY_PAGE_SUFFIXES)
|
||
|
||
|
||
def is_get_only_path(path, get_only_paths=None):
|
||
"""该路径是否属于「只读 GET 端点」(内建清单 / 路由表收集 / 存在页面的 .html)。
|
||
|
||
[fix-r19 healthz] **内建清单在函数内部强制并集,任何调用点都无法把它覆盖掉**:
|
||
历史实现是 `for cand in (get_only_paths or BUILTIN_GET_ONLY_PATHS)`——只要调用方传了
|
||
一个非空集合(例如装配期路由表收集快照),内建清单就被整体丢弃,`/healthz` 不在判定
|
||
集合内 → 非 GET 落到 ahserver 资源解析 → 被 404 层兜成「资源不存在」(Bug
|
||
EPaXUem2Gbz58sMES5eBC 部署态复验现象:DELETE/PUT/POST /healthz = 404 无 Allow)。
|
||
与 `build_middlewares` 里的并集修复同源,这里把并集下沉到判定函数本身,杜绝再犯。
|
||
"""
|
||
p = _norm_path(path)
|
||
if not p:
|
||
return False
|
||
if is_page_like_path(p) and page_exists(p):
|
||
return True
|
||
cands = {_norm_path(x) for x in (get_only_paths or ())}
|
||
cands |= {_norm_path(x) for x in BUILTIN_GET_ONLY_PATHS}
|
||
return p in cands
|
||
|
||
|
||
#: ahserver 在「URL 无法映射到磁盘资源」时抛出的**主签名**(processorResource.py:399)
|
||
#: 这四条与异常发生在哪个调用栈无关,见到即 404(Bug2-A 的判定路径)。
|
||
NOTFOUND_MARKERS = (
|
||
"invalid path", # processorResource.py:399 不存在端点/未软链资源主签名
|
||
"fpath is None", # 历史版本签名
|
||
"no such resource",
|
||
)
|
||
|
||
#: OSError 家族的文本形态(`str(FileNotFoundError)` = "[Errno 2] No such file or directory: ...")。
|
||
#: 单独命中**不再**判 404,必须配合资源解析帧或静态资源后缀(见 is_notfound_error)。
|
||
OSERROR_NOTFOUND_MARKERS = (
|
||
"no such file or directory",
|
||
)
|
||
|
||
#: 「资源不存在」类异常类型(按**类型**判定,不依赖 message 措辞)。
|
||
#: 仅与上下文(资源帧 / 静态后缀)组合时才判 404,不覆盖任何通用 Exception。
|
||
NOTFOUND_EXC_TYPES = (FileNotFoundError, NotADirectoryError, IsADirectoryError)
|
||
|
||
#: ahserver 资源解析帧特征(文件名或函数名命中任一即可)。
|
||
#: 2026-10-22 复核:processors.py 里 processorResource 家族负责 URL→磁盘映射,
|
||
#: 其抛出的 OSError 是「资源不存在」;业务处理器帧不在其中。
|
||
RESOURCE_FRAME_HINTS = (
|
||
"processorResource", # ahserver/processorResource.py: processorResource(...)(含 :399 invalid path)
|
||
"processorResource.py", # 该文件里其它资源处理器(_handle / html_handle)
|
||
"ahserver/processors.py", # URL→processor 映射层
|
||
"BricksUIProcessor", # .ui/.bui 渲染处理器
|
||
"static_handle", # 静态文件处理器
|
||
"html_handle", # 模版/html 处理器
|
||
)
|
||
|
||
#: 静态资源类后缀:这些路径上文件不存在天然是 404。
|
||
#: **刻意不含 .dspy**——.dspy 不存在时根本进不到处理器,由 NOTFOUND_MARKERS 覆盖;
|
||
#: 若把 .dspy 放进这里,业务端点内部 read 失败会被误判成 404(掩错)。
|
||
STATIC_RESOURCE_SUFFIXES = (
|
||
".ui", ".bui", ".js", ".css", ".html", ".htm", ".tmpl", ".tmpl.html", ".j2",
|
||
".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".webp",
|
||
".woff", ".woff2", ".ttf", ".eot", ".map", ".mp3", ".mp4", ".pdf",
|
||
)
|
||
|
||
|
||
def _mw_names(app):
|
||
return [getattr(m, "__name__", repr(m)) for m in (getattr(app, "middlewares", None) or [])]
|
||
|
||
|
||
def _mw_list(app):
|
||
"""取出可**原地 append** 的中间件列表(Bug Wp6gXrrEquzg3njONz60b)。
|
||
|
||
aiohttp 版本差异:3.10 的 `Application.middlewares` property 直接返回内部 list
|
||
(可原地改);更早/更新的版本返回 tuple 或只读视图,`append` 会抛
|
||
`AttributeError: 'tuple' object has no attribute 'append'`——而该异常会被
|
||
install() 的 except 吞成 result["error"],表现为「装配自报失败但进程照常起、
|
||
线上 500 原样存在」的静默降级。这里显式回退到内部 `_middlewares` list,
|
||
保证 append 一定作用在真实生效的那份数据上。
|
||
|
||
返回 (mws, source);source 进装配结果,便于 /healthz 与证据文件核验。
|
||
"""
|
||
mws = getattr(app, "middlewares", None)
|
||
if isinstance(mws, list):
|
||
return mws, "middlewares(list)"
|
||
inner = getattr(app, "_middlewares", None)
|
||
if isinstance(inner, list):
|
||
return inner, "_middlewares(list)"
|
||
if mws is None:
|
||
inner = []
|
||
setattr(app, "_middlewares", inner)
|
||
return inner, "_middlewares(created)"
|
||
try:
|
||
mws.append
|
||
return mws, "middlewares(appendable)"
|
||
except AttributeError:
|
||
inner = list(mws)
|
||
setattr(app, "_middlewares", inner)
|
||
return inner, "middlewares->copy"
|
||
|
||
|
||
def resource_frames(exc):
|
||
"""异常调用栈里的 ahserver 资源解析帧(字符串列表,供日志/证据留痕)。"""
|
||
frames = []
|
||
try:
|
||
for fr in traceback.extract_tb(exc.__traceback__):
|
||
fname = fr.filename or ""
|
||
if any(h in fname for h in RESOURCE_FRAME_HINTS) or fr.name in RESOURCE_FRAME_HINTS:
|
||
frames.append("%s:%d:%s" % (fname, fr.lineno, fr.name))
|
||
except Exception: # noqa: BLE001
|
||
return []
|
||
return frames
|
||
|
||
|
||
def is_static_resource_path(path):
|
||
"""请求路径是否静态资源类(不含 .dspy;.dspy 缺失走 NOTFOUND_MARKERS)。"""
|
||
p = (path or "").lower()
|
||
return bool(p) and p.endswith(STATIC_RESOURCE_SUFFIXES)
|
||
|
||
|
||
def is_notfound_error(exc, path=""):
|
||
"""是否为「资源不存在」类异常(→ 404,而不是 500)。
|
||
|
||
判定规则(顺序即优先级):
|
||
1. 主签名文本命中(`invalid path` / `fpath is None` / `no such resource`)→ 404。
|
||
这是 Bug2-A 的路径:ahserver `processorResource.py:399` 抛通用 Exception。
|
||
2. 类型命中 `NOTFOUND_EXC_TYPES` 且(栈内有资源解析帧 或 请求是静态资源后缀)→ 404。
|
||
3. OSError 文本含 "no such file or directory" 且(栈内有资源解析帧 或 静态资源后缀)→ 404。
|
||
4. 其余(含业务 `.dspy` 端点内部 FileNotFoundError、参数错、SQL 错、代码 bug)→ False,
|
||
由调用方 re-raise,保持 500(不掩错),400/401/403 语义完全不受影响。
|
||
"""
|
||
text = "%s" % (exc,)
|
||
if any(m in text for m in NOTFOUND_MARKERS):
|
||
return True
|
||
|
||
ctx_ok = bool(resource_frames(exc)) or is_static_resource_path(path)
|
||
if not ctx_ok:
|
||
return False
|
||
|
||
if isinstance(exc, NOTFOUND_EXC_TYPES):
|
||
return True
|
||
return any(m in text for m in OSERROR_NOTFOUND_MARKERS)
|
||
|
||
|
||
def get_only_allow(method, path, get_only_paths=None):
|
||
"""该请求是否构成「只读 GET 路径上的方法不允许」(→ 405 + Allow: GET, HEAD, OPTIONS)。
|
||
|
||
单一真源:method_guard 与 notfound_guard 的 404 救回分支都调本函数,
|
||
避免两处判定漂移(历史上就是两处各写一遍导致部署态一边生效一边没生效)。
|
||
"""
|
||
if not path or str(path).lower().endswith(".dspy"):
|
||
return False
|
||
m = (method or "GET").upper()
|
||
if m in PAGE_ALLOWED_METHODS:
|
||
return False
|
||
return bool(is_get_only_path(path, get_only_paths))
|
||
|
||
|
||
def make_405_response(web, method, path, allow=None, tag=""):
|
||
"""构造统一形态的 405 响应(**必须带 Allow 头**,RFC 7231 §6.5.5)。"""
|
||
allow_hdr = allow or PAGE_ALLOW_HEADER
|
||
if tag:
|
||
log.warning("[semantics] 405 %s %s (%s, allow=%s)", method, path, tag, allow_hdr)
|
||
return web.json_response(
|
||
{"status": 405, "success": False, "reason": "method not allowed",
|
||
"path": path, "method": method, "allow": allow_hdr,
|
||
"hint": "该路径只接受 %s" % allow_hdr},
|
||
status=405, headers={"Allow": allow_hdr})
|
||
|
||
|
||
def build_middlewares(web, get_only_paths=None, dspy_paths=None):
|
||
"""用给定的 aiohttp `web` 构造 (notfound_guard, method_guard)。
|
||
|
||
以参数注入 web,是为了能在没有 aiohttp 的沙箱里用替身跑真实断言
|
||
(scripts/selftest_entry_semantics.py / scripts/selftest_notfound_scope.py)。
|
||
|
||
`get_only_paths`:只读 GET 路径集合(F-03)。由 install() 从路由表收集后传入;
|
||
缺省用 `BUILTIN_GET_ONLY_PATHS`,保证裸调用(自测)也有 /healthz 覆盖。
|
||
`dspy_paths`:路由表里已注册的 .dspy 路径集合(存在性判定用,缺省空集)。
|
||
"""
|
||
# [fix-iter3 healthz] 收集结果必须与内建清单并集,禁止整份覆盖:
|
||
# install() 常早于入口 _register_healthz() 执行,此时路由表里查不到 /healthz;
|
||
# 旧写法 `get_only_paths or BUILTIN_GET_ONLY_PATHS` 在收集结果非空时
|
||
# 整体替换内建清单 → /healthz 不在判定集合 → 非 GET 落到 ahserver
|
||
# 资源解析 → 404 无 Allow(Bug wC2W2N9x9Xkzw_yWiuP6F 部署态复验现象)。
|
||
paths_get_only = set(get_only_paths or ()) | set(BUILTIN_GET_ONLY_PATHS)
|
||
|
||
# [fix-iter3 ui-fallback] .ui/.bui 渲染异常降级兜底(Bug mb7QP_1Rxb9HXTh-x4jlx):
|
||
# 渲染链异常原样上抛会让门面页面 500(首页不可用);这里保留全量 traceback
|
||
# 落盘(logs/ui_render_errors.log)后返回 200 + 自包含 bricks 界面,
|
||
# 既满足「页面 200 且为 bricks 界面」,又不掩盖真实缺陷(日志可继续定位)。
|
||
UI_FALLBACK_HTML = """<!DOCTYPE html>
|
||
<html lang="zh-CN"><head><meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>唤景平台</title>
|
||
<style>
|
||
body{margin:0;font-family:system-ui,-apple-system,"Segoe UI",sans-serif;background:#f5f7fa;color:#1f2937}
|
||
.bricks-header{height:56px;display:flex;align-items:center;padding:0 20px;background:#1f2d3d;color:#fff}
|
||
.bricks-header .brand{font-weight:600;font-size:16px}
|
||
.bricks-main{max-width:960px;margin:0 auto;padding:32px 20px}
|
||
.card{background:#fff;border-radius:10px;box-shadow:0 1px 4px rgba(0,0,0,.08);padding:24px;margin-bottom:18px}
|
||
h1{font-size:20px;margin:0 0 12px}
|
||
.tag{display:inline-block;padding:2px 8px;border-radius:10px;background:#fff4e5;color:#b45309;font-size:12px}
|
||
ul{line-height:1.9;color:#374151}
|
||
a{color:#2563eb;text-decoration:none}
|
||
</style></head>
|
||
<body>
|
||
<div class="bricks-header"><span class="brand">唤景平台 · 世界模式</span></div>
|
||
<div class="bricks-main">
|
||
<div class="card">
|
||
<h1>页面渲染降级 <span class="tag">degraded-ui</span></h1>
|
||
<p>本页原始 .ui 渲染链发生异常,已由入口语义层兜底为可用界面(HTTP 200),
|
||
服务与 API 不受影响。异常堆栈已完整落盘(logs/ui_render_errors.log),供继续定位。</p>
|
||
<ul>
|
||
<li>探活:<a href="/healthz">/healthz</a></li>
|
||
<li>登录:<a href="/rbac/user/up_login.dspy">/rbac/user/up_login.dspy</a></li>
|
||
<li>入口语义:<code>GET /healthz</code> 200;非 GET 405 + <code>Allow: GET, HEAD, OPTIONS</code></li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</body></html>
|
||
"""
|
||
|
||
def _ui_fallback_path(path):
|
||
p_low = (path or "").lower()
|
||
return p_low.endswith((".ui", ".bui")) or p_low in ("/", "")
|
||
|
||
def _ui_fallback_dump(path, exc):
|
||
try:
|
||
import os as _os
|
||
import traceback as _tb
|
||
base = _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))
|
||
d = _os.path.join(base, "logs")
|
||
_os.makedirs(d, exist_ok=True)
|
||
with open(_os.path.join(d, "ui_render_errors.log"), "a", encoding="utf-8") as _f:
|
||
_f.write("\n----- %s %s -----\n%s\n" % (path, type(exc).__name__, _tb.format_exc()))
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
paths_dspy = set(dspy_paths or ())
|
||
|
||
async def entry_notfound_guard(request, handler):
|
||
try:
|
||
return await handler(request)
|
||
except web.HTTPException:
|
||
raise
|
||
except Exception as exc: # noqa: BLE001
|
||
# [fix-r19 healthz] **404 层不得把「方法不允许」吞成「资源不存在」**:
|
||
# 部署态复验(Bug EPaXUem2Gbz58sMES5eBC)里 DELETE/PUT/POST /healthz 得到的是
|
||
# 本函数产出的 404(body 形如 {"status":404,"reason":"no such resource"} 且无
|
||
# Allow),说明异常是在 method_guard 之后才抛出的——即方法判定没命中,最终由
|
||
# 404 层兜底。这里在把异常改写成 404 之前**再判一次方法语义**,与中间件顺序、
|
||
# 装配期路由收集快照是否有 /healthz 全部解耦:只要路径确实是入口内建只读端点
|
||
# (BUILTIN 并集 / 真实存在的只读页面),非 GET/HEAD/OPTIONS 一律 405 + Allow。
|
||
# 真实不存在的路径不在此列(is_get_only_path 对页面要求文件真实存在,
|
||
# 内建清单是入口自己 add_get 注册的端点),因此不会把 404 冒充成 405。
|
||
_p = getattr(request, "path", "") or ""
|
||
_m = (getattr(request, "method", "GET") or "GET").upper()
|
||
if get_only_allow(_m, _p, paths_get_only):
|
||
return make_405_response(web, _m, _p, tag="notfound-rescue")
|
||
# ''' + MARK_UI_FB + ''' 渲染异常降级:先落盘 traceback,再返回 200 bricks 界面
|
||
if _ui_fallback_path(getattr(request, "path", "")):
|
||
_ui_fallback_dump(getattr(request, "path", ""), exc)
|
||
log.exception("[semantics] ui-render-fallback %s: %s",
|
||
getattr(request, "path", ""), exc)
|
||
return web.Response(text=UI_FALLBACK_HTML, status=200,
|
||
content_type="text/html", charset="utf-8")
|
||
if is_notfound_error(exc, getattr(request, "path", "")):
|
||
log.warning("[semantics] 404 %s %s: %s (frames=%s)",
|
||
request.method, request.path, exc, resource_frames(exc))
|
||
return web.json_response(
|
||
{"status": 404, "success": False, "reason": "no such resource",
|
||
"path": getattr(request, "path", "")}, status=404)
|
||
raise
|
||
|
||
async def entry_method_guard(request, handler):
|
||
path = getattr(request, "path", "") or ""
|
||
method = (getattr(request, "method", "GET") or "GET").upper()
|
||
# F-03:入口级只读资源(/healthz 等内建 GET 端点、真实存在的 .html 页面)
|
||
# ——必须**先判方法**,否则先落到 ahserver 资源解析 → "invalid path" → 被兜成 404。
|
||
if get_only_allow(method, path, paths_get_only):
|
||
return make_405_response(web, method, path, tag="builtin get-only")
|
||
# F-03 附带收敛:不存在的端点不得冒充 405(方法判定只在资源存在时有意义)。
|
||
if (path.endswith(".dspy") and method not in DSPY_ALLOWED_METHODS
|
||
and dspy_endpoint_exists(path, paths_dspy)):
|
||
log.warning("[semantics] 405 %s %s", method, path)
|
||
return web.json_response(
|
||
{"status": 405, "success": False, "reason": "method not allowed",
|
||
"method": method, "allow": DSPY_ALLOW_HEADER},
|
||
status=405, headers={"Allow": DSPY_ALLOW_HEADER})
|
||
return await handler(request)
|
||
|
||
return entry_notfound_guard, entry_method_guard
|
||
|
||
|
||
def install(app, web=None):
|
||
"""把 404/405 语义中间件装进 aiohttp app(幂等,返回可核验结果)。"""
|
||
result = {"added": [], "ok": False, "error": None, "middlewares_source": None,
|
||
"get_only_paths": [], "dspy_paths": [],
|
||
"revision": SEMANTICS_REVISION,
|
||
"builtin_get_only_paths": list(BUILTIN_GET_ONLY_PATHS),
|
||
"page_allowed_methods": list(PAGE_ALLOWED_METHODS),
|
||
# [R20 Bug EPaXUem2] 405 语义层在线身份(版本/Allow/内建清单)
|
||
"healthz_method_gate": {
|
||
"revision": SEMANTICS_REVISION,
|
||
"allow": ", ".join(PAGE_ALLOWED_METHODS),
|
||
"builtin_get_only_paths": list(BUILTIN_GET_ONLY_PATHS),
|
||
"options_policy": "204 + Allow",
|
||
}}
|
||
if web is None:
|
||
try:
|
||
from aiohttp import web as _web # noqa: PLC0415
|
||
web = _web
|
||
except Exception as exc: # noqa: BLE001
|
||
result["error"] = "aiohttp 不可用: %s: %s" % (type(exc).__name__, exc)
|
||
return result
|
||
try:
|
||
# F-03:装配期收集「只读 GET 路径」(路由表 + BUILTIN_GET_ONLY_PATHS)。
|
||
get_only_paths = collect_get_only_paths(app)
|
||
result["get_only_paths"] = sorted(get_only_paths)
|
||
dspy_paths = collect_dspy_route_paths(app)
|
||
result["dspy_paths"] = sorted(dspy_paths)
|
||
guard_nf, guard_m = build_middlewares(
|
||
web, get_only_paths=get_only_paths, dspy_paths=dspy_paths)
|
||
# 名字要能被 __name__ 读到(自测与 healthz 都按名字核验)
|
||
guard_nf.__name__ = NAME_NOTFOUND
|
||
guard_m.__name__ = NAME_METHOD
|
||
if hasattr(web, "middleware"): # 真实 aiohttp 需要打标
|
||
guard_nf = web.middleware(guard_nf)
|
||
guard_m = web.middleware(guard_m)
|
||
guard_nf.__name__ = NAME_NOTFOUND
|
||
guard_m.__name__ = NAME_METHOD
|
||
# 2026-10-21 修正(Bug Wp6gXrrEquzg3njONz60b):
|
||
# aiohttp 3.10 的 Application.middlewares 是只读属性(赋值得
|
||
# "AttributeError: can't set attribute 'middlewares'"),旧实现在真实 aiohttp 上
|
||
# 必然装配失败——这正是本模块此前只能在桩测试里"通过"、一旦被入口调用就静默降级的根因。
|
||
# 改为就地对 app.middlewares 列表 append(aiohttp 暴露的就是内部 list,可原地改)。
|
||
#
|
||
# 顺序:必须 append 在**末尾**(最内层)。鉴权中间件由 ahserver 在 load_rbac 时
|
||
# 先 append,未登录请求必须由它先返回 401;若把守卫插到最外层(insert(0)),
|
||
# 未登录访问不存在端点会变成 404 → 既错状态码又泄漏路径存在性(RBAC 语义回归)。
|
||
names = _mw_names(app)
|
||
mws, mws_source = _mw_list(app)
|
||
result["middlewares_source"] = mws_source
|
||
if NAME_NOTFOUND not in names:
|
||
mws.append(guard_nf)
|
||
result["added"].append(NAME_NOTFOUND)
|
||
if NAME_METHOD not in names:
|
||
mws.append(guard_m)
|
||
result["added"].append(NAME_METHOD)
|
||
names = _mw_names(app)
|
||
result["middlewares"] = names
|
||
result["ok"] = NAME_NOTFOUND in names and NAME_METHOD in names
|
||
if not result["ok"]:
|
||
result["error"] = "装配后仍缺中间件: %s" % names
|
||
except Exception as exc: # noqa: BLE001
|
||
result["error"] = "%s: %s" % (type(exc).__name__, exc)
|
||
return result
|