#!/usr/bin/env python3 # -*- coding: utf-8 -*- """yuanjing 应用级「资源不存在 → 404」中间件(Bug ALTq8Xbvpi0mLlos7Rtyg)。 同时是本应用的**语义中间件组合入口**(Bug 族 404/401/405/400 的统一收口点)。 背景(为什么在应用层修) ------------------------ ahserver 的 processorResource._handle() 在 URL 无法映射到磁盘文件 (`request_filename is None`)时执行: raise Exception(f'{str(request.url)=} invalid path') 该 Exception 被 aiohttp 兜底成 **HTTP 500**,语义上却是「资源不存在」应为 **404**。 后果:任何 URL 写错 / 模块 wwwroot 尚未软链,都表现为 500,与「后端代码异常」不可区分, 是本次「全站 *.dspy 500 / fpath is None」表象的直接放大器(误导排障方向)。 框架源码不自持:`/d/doit/doit/pkgs/ahserver` 是平台共享的**只读**挂载 (实测 `OSError: [Errno 30] Read-only file system`),且 project-directory-spec 铁律 规定基础模块任何项目不得修改 —— 因此本修复落在**应用层**(应用是部署单元,应用代码由 本项目自持可写可提交): * 应用自带 AHApp 实例(ahserver `webapp(init_func, app=...)` 公开钩子, ConfiguredServer 对传入 app 直接 `self.app = app`),在 middleware 链最外层注册本中间件; * 只把「ahserver 路径解析失败」这一种异常映射为 404(签名匹配),**其余异常一律 re-raise**, 保证真实后端缺陷仍然暴露为 500,不掩错。 为什么本文件还是「组合入口」(2026-10-21 补充,R28) --------------------------------------------------- 真实运行入口 `app/scense.py` 的构造期装配函数 `_load_semantic_middlewares()` 只按名字 加载 **两个** 中间件(`resource_notfound_middleware` / `http_semantics_middleware`), `app/route_guard_middleware.py`(404 前置 / 401 / 405+400)与 `app/business_status_middleware.py`(业务 400 → 状态行)**从未进入构造期中间件链** —— 这就是「修复代码在仓库里、线上仍 401/200/500」的装配缺口。 为避免在 500 行入口文件里做大段改动(且 `app/scense.py` 在当前沙箱为只读), 本中间件改为**自持组合链**:内部按上述语义顺序把守卫中间件串起来, 外层仍保留「ahserver 异常 → 404」的原始职责。装配顺序(外层 → 内层): 0. **豁免判定**(`auth_exempt.is_exempt_path()`,单一真源)—— 登录/登出/健康探针等 白名单路径直接放行到业务处理器,早于资源存在性、早于会话校验 (Bug uifdBIMdmixZcmJEdV33O / R61,登录端点 401 auth_unavailable 根因之一) 1. `resource_preflight_middleware` 路径不存在 → 404(**早于鉴权**,Bug P-Wn8N / WXXvX) 2. `auth_required_middleware` 已存在 .dspy 无会话凭据 → 401(Bug WhOYy9nvG) 3. `write_endpoint_semantics_middleware` 写类端点 GET/HEAD → 405 + Allow;POST 无参 → 400 (Bug CQIpIoIJODNdSt4B0VvVv) 4. `business_status_middleware` 业务信封 {"status":400,...} → HTTP 状态行 (Bug DGzTjOkr_OYM4vdOYcl2_ / F12 / F16) 5. 业务处理器(ahserver) > 注:上面的 0 号豁免判定在 `resource_notfound_middleware` 入口处执行, > 命中即 `return await handler(request)`(不进入 1~5 层)。 组合链构造失败时**显式降级**为原始行为(只做 404 异常映射)并打 ERROR 日志, 不静默、不抛出 —— 入口起不来比少一层语义更严重。 上游根治仍需 ahserver 维护方把该处 `raise Exception(...)` 改为 `raise HTTPNotFound` (补丁文本见 apps/yuanjing/docs/evidence/ahserver-404-upstream-patch.md,本文件的 签名匹配常量与之同源;上游修复后本中间件自然退化为不触发的冗余保险丝)。 """ import importlib.util import logging import os import sys from aiohttp import web log = logging.getLogger("yuanjing.notfound") #: 应用根(本文件 → app/ → 应用根)。绝对路径加载中间件用(不依赖 sys.path)。 APP_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # ahserver 路径解析失败的异常文本签名(上游 processorResource.py:399 原文)。 # 只在命中这些签名时才改写为 404;新增签名须同步 evidence 文档与 verify_404_semantics.py。 RESOURCE_MISSING_SIGNATURES = ( "invalid path", "fpath is None", ) # 这些后缀的请求返回 JSON 404(接口调用方),其余返回 text 404(页面/静态资源)。 _JSON_SUFFIXES = (".dspy", ".json", ".do", ".api") # ── 鉴权豁免清单:**单一真源** app/auth_exempt.py(Bug uifdBIMdmixZcmJEdV33O / T4)── # [fix rvtGi1mlUsTwQrXnybZj6 / R61 · QC#2] 本文件是「语义中间件组合入口」, # 历史实现在这里**没有任何豁免判定**(内联兜底闸门对任何 .dspy 一律 401), # 于是登录端点走这条链必然 401 —— 这正是 auth_exempt.py docstring 点名的待修路径。 # 现在:组合链**最前置**(早于资源存在性、早于会话校验)判定豁免, # 与 auth_exempt 判据第 3 条一致;未豁免路径仍走原逻辑(有单测断言,不写成无条件放行)。 def _load_auth_exempt(): """加载豁免清单单一真源(部署态 sys.path 可能不含应用根,禁裸包导入)。""" try: from app import auth_exempt as mod # type: ignore return mod except Exception: # noqa: BLE001 spec = importlib.util.spec_from_file_location( "yj_auth_exempt_nf", os.path.join(APP_DIR, "app", "auth_exempt.py")) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod try: EXEMPT = _load_auth_exempt() except Exception as _load_exc: # noqa: BLE001 log.error("[semantics] 豁免清单单一真源加载失败(不豁免任何路径): %s", _load_exc) EXEMPT = None def is_exempt_path(path): """该路径是否免鉴权(委托 `auth_exempt.is_exempt_path()`,单一真源)。 :param path: 请求路径 :return: bool —— 豁免 True;清单不可用/判定异常一律 False(fail-closed) """ if EXEMPT is None: return False try: return bool(EXEMPT.is_exempt_path(path)) except Exception as exc: # noqa: BLE001 log.error("[semantics] 豁免判定异常(按不豁免处理): %s", exc) return False #: 组合链装配顺序(**内层 → 外层**,即列表最后一项是最外层)。 _COMPOSED_INNER_FIRST = ( ("business_status_middleware", "business_status_middleware"), # ↓ 最外层:入口内建只读路径 + 非读方法 → 405 + Allow(Bug EPaXUem2 / QC #4 #5) # 必须排在 resource_preflight 之外:/healthz 是入口 add_get 注册的运行时路由, # 预检层按磁盘资源判定会把它当「不存在」→ 404,方法语义就永远轮不到。 ("get_only_405", "method_gate_middleware"), ) def is_resource_missing(exc): """异常是否为「资源不存在」语义(ahserver 路径解析失败),而非真实服务端缺陷。""" msg = str(exc) or "" return any(sig in msg for sig in RESOURCE_MISSING_SIGNATURES) def _module_file(mod): """`mod` → 中间件文件的绝对路径(`mod` 可能是 `notfound_middleware` 或 `app.xxx`)。 注意目录层级(Bug EPaXUem2 复验暴露):本文件位于 `<应用根>/app/`, `APP_DIR` 是**应用根**,中间件文件在 `<应用根>/app/.py`。 旧实现只拼 `APP_DIR/.py`(少一级 app/)→ 绝对路径兜底恒失败 → 组合链被判「不可导入」→ 降级 fail-closed(线上靠 sys.path[0]=app/ 侥幸可用)。 现两处都试:`<应用根>/app/` 优先,再兜 `<应用根>/`(兼容历史扁平布局)。 """ name = mod.split(".")[-1] + ".py" for cand in (os.path.join(APP_DIR, "app", name), os.path.join(APP_DIR, name)): if os.path.isfile(cand): return cand return os.path.join(APP_DIR, "app", name) def _import_attr(mod, attr): """取中间件对象:先按包名导入,**失败再按绝对文件路径加载**。 为什么必须有绝对路径兜底(Bug vRZrx0Ff9tz / R18 复测根因): 部署态入口用 `python app/scense.py` 启动时 `sys.path[0]` 是 `app/` 而非应用根, `__import__("route_guard_middleware")` 可能 ImportError;旧实现 `continue` 掉 不记录 → `compose_guard_chain()` 抛 RuntimeError → 外层**降级为 handler-only** (只做 404 映射)→ 鉴权层整层缺席 → 匿名请求直达业务处理器返回 200。 绝对路径加载(importlib.util.spec_from_file_location)不依赖 sys.path, 与 `app/scense.py::_bsm_load_by_path` 的做法同源。 """ for name in (mod, "app." + mod): try: m = __import__(name, fromlist=[attr]) obj = getattr(m, attr, None) if obj is not None: return obj except ImportError: continue except Exception: # noqa: BLE001 continue # 绝对文件路径兜底 try: import importlib.util path = _module_file(mod) if not os.path.isfile(path): return None spec = importlib.util.spec_from_file_location( "_yj_guard_" + os.path.basename(path)[:-3], path) if spec is None or spec.loader is None: return None m = importlib.util.module_from_spec(spec) spec.loader.exec_module(m) return getattr(m, attr, None) except Exception as exc: # noqa: BLE001 log.error("[semantics] 中间件按绝对路径加载失败 %s.%s: %s", mod, attr, exc) return None def _maybe_405(request): """入口内建只读路径 + 非读方法 → 405 响应对象;否则 None(继续原逻辑)。 真源单点:`app/get_only_405.py::maybe_405`(QC #4「新模块零引用=死代码」的接线点)。 取模块走 `_import_attr` 同一通道:先包导入,失败按绝对文件路径加载 —— 兼容 `python app/scense.py` 启动时 sys.path[0]=app/ 的场景。 """ try: mod = sys.modules.get("get_only_405") fn = getattr(mod, "maybe_405", None) if mod is not None else None if fn is None: fn = _import_attr("get_only_405", "maybe_405") if fn is None: return None return fn(web, request) except Exception as exc: # noqa: BLE001 log.error("[405] get_only_405 判定失败(放行到原逻辑): %s", exc) return None def _wrap(mw, nxt): """把一个 `(request, handler)` 中间件包成 `chain(request)`。 不用 functools.partial:partial(mw, nxt) 会把 nxt 绑到 **request** 形参位上, 中间件内部就再也拿不到 request(历史装配 bug 的同型错误)。闭包绑定无歧义。 """ async def _chain(request): return await mw(request, nxt) return _chain def compose_guard_chain(handler): """把语义中间件按「外层优先」串成一条链,返回最外层可调用对象。 :param handler: 业务处理器 :return: 可 await 的中间件链(签名 `chain(request)`) :raises RuntimeError: 任一中间件不可导入(调用方须降级,不得静默当成功) """ chain = handler missing = [] for mod, attr in _COMPOSED_INNER_FIRST: mw = _import_attr(mod, attr) if mw is None: missing.append(attr) continue chain = _wrap(mw, chain) if missing: raise RuntimeError("语义中间件不可导入: %s" % ",".join(missing)) return chain def _notfound_response(request): path = request.path or "" is_api = path.endswith(_JSON_SUFFIXES) log.warning( "[404] resource not found: path=%s (client=%s) -> HTTPNotFound", path, request.remote or "-", ) if is_api: return web.json_response( { "status": 404, "success": False, "reason": "no such resource", "path": path, "hint": "端点/静态资源不存在:核对 wwwroot 是否已软链、端点名是否与磁盘文件一致", }, status=404, ) return web.Response( status=404, text="404 Not Found: " + path, content_type="text/plain", ) def _failclosed_auth_gate(): """组合链装配失败时的**兜底闸门**(fail-closed,绝不 handler-only 放行)。 Bug vRZrx0Ff9tz / R18 复测:旧实现 `chain = handler` 是 fail-**open** —— 任一中间件导入失败即整层语义(含鉴权)缺席,匿名请求直达业务处理器返回 200。 改为:装配失败时至少挂上 `auth_gate_strict_middleware`(绝对路径加载, 零 app 包依赖);连它也取不到则用本函数内联的极简闸门(非公开 `.dspy` → 401)。 """ strict = _import_attr("auth_gate_strict", "auth_gate_strict_middleware") if strict is not None: log.error("[semantics] 组合链装配失败,降级为 auth_gate_strict 兜底闸门" "(fail-closed,匿名受保护 API 一律 401)") return strict async def _inline_deny(request, handler): # [R62 / 匿名读全站 P0] 旧实现缺失本函数(连兜底闸门都没有 → fail-open, # 匿名直达业务处理器返回 200)。语义: # 1. 豁免清单(单一真源 auth_exempt)命中 → 放行(否则登录端点自己 401); # 2. `.dspy` 后缀 **或** 命中业务 API 前缀覆盖面(/scense /runtime /scene # /world /entity 下的 api 端点)→ 401,绝不返回业务数据; # 3. 判定自身异常 → 按受保护处理(401),判不了就拒绝。 path = "" try: path = request.path or "" if is_exempt_path(path): return await handler(request) low = path.split("?", 1)[0].lower() gated = low.endswith(".dspy") if not gated: try: gated = bool(EXEMPT.is_protected_api_path(path)) if EXEMPT else True except Exception: # noqa: BLE001 gated = True if gated: log.error("[semantics] 内联兜底闸门 401(语义层全部不可用): %s", path) return web.json_response( {"ok": False, "status": 401, "error": "auth_unavailable", "path": path}, status=401) except Exception: # noqa: BLE001 try: if path and not is_exempt_path(path): return web.json_response( {"ok": False, "status": 401, "error": "auth_unavailable", "path": path}, status=401) except Exception: # noqa: BLE001 pass return await handler(request) log.error("[semantics] 组合链与 auth_gate_strict 均不可用,降级为内联兜底闸门") return _inline_deny @web.middleware async def resource_notfound_middleware(request, handler): """语义组合中间件:404(异常侧 + 前置侧)/ 401 / 405 / 400 统一收口。 * 401/403/404/405 等语义响应由内层中间件直接产生,原样返回; * ahserver「路径解析失败」异常 → 404; * 其余异常原样上抛(真实后端缺陷仍为 500,不掩错)。 * 组合链装配失败 → 挂兜底鉴权闸门(fail-closed),**不再**降级为 handler-only。 """ # ① 豁免判定(**最前置**:早于资源存在性、早于会话校验、早于方法闸门)。 # 与 auth_exempt 判据第 3 条一致:登录/登出/健康探针在「还没有会话」的时刻 # 就必须可达,否则构成"先登录才能登录"的自指死循环(R57b/R59 实测根因)。 # 注意:这里**只对显式豁免路径**生效,未豁免路径继续走下面的原逻辑 # (scripts/selftest_notfound_scope.py 有「未豁免仍拦截」的反向断言)。 if is_exempt_path(request.path): log.debug("[semantics] 豁免路径放行: %s", request.path) return await handler(request) # ② 前置方法闸门(最外层;组合链装配失败也照样生效,不受 try 影响) gate = _maybe_405(request) if gate is not None: return gate try: chain = compose_guard_chain(handler) except Exception as exc: # noqa: BLE001 log.error("[semantics] 组合链装配失败(%s),降级为 fail-closed 鉴权闸门", exc) chain = _wrap(_failclosed_auth_gate(), handler) try: return await chain(request) except web.HTTPException: # 框架自身抛出的 HTTP 语义异常(404/401/403 等)不拦截 raise except Exception as exc: # noqa: BLE001 if is_resource_missing(exc): # 异常侧同样先判方法语义:POST/PUT/DELETE /healthz 会一路落到 # ahserver 资源解析抛 invalid path,这里必须升 405 而不是 404。 gate = _maybe_405(request) if gate is not None: return gate return _notfound_response(request) raise