diff --git a/app/_archive_disabled_20261028_151505/auth_exempt.py b/app/_archive_disabled_20261028_151505/auth_exempt.py new file mode 100644 index 0000000..70c0a55 --- /dev/null +++ b/app/_archive_disabled_20261028_151505/auth_exempt.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""鉴权豁免清单**单一真源**(Bug uifdBIMdmixZcmJEdV33O / T4)。 + +问题 +---- +修复前,「哪些端点免鉴权」这件事在 5 个文件里各存了一份: + + app/auth_route_gate.py PUBLIC_DSPY_PATHS / PUBLIC_PATH_PREFIXES + app/route_guard_middleware.py PUBLIC_DSPY_PATHS / PUBLIC_PATH_PREFIXES + app/auth_gate_strict.py PUBLIC_DSPY_PATHS / PUBLIC_PATH_PREFIXES + app/entry_guard_r19.py 自己没有清单,委托 auth_gate_strict + app/notfound_middleware.py **完全没有清单**(内联兜底闸门任何 .dspy 都 401) + +后果(R57b / R59 实测):登录端点 `/rbac/user/up_login.dspy` 在主线闸门上被豁免, +但在「语义组合链装配失败 → 内联兜底闸门」这条路径上**没有任何豁免**,一律返回 +`401 {"error": "auth_unavailable"}`;而更早的版本还会把闸门自身异常统一降级成 +`auth_unavailable`(R60 已改为 500 auth_gate_internal_error,但豁免缺失仍在)。 +于是「登录端点自己 401」被固化,cookie jar 恒空,全部带登录态复测无法执行。 + +本模块的做法 +------------ +* **清单只有这一份**:`AUTH_EXEMPT_DSPY_PATHS`(精确路径,禁通配)+ + `AUTH_EXEMPT_PREFIXES`(按**路径段边界**匹配的前缀,禁通配)。 +* 所有闸门只调用 `is_exempt_path()`,不再各自维护副本。 +* 豁免判定必须是闸门里**最靠前**的判定:在「资源存在性」「会话校验」之前。 + 这样无论 wwwroot 软链是否挂上、会话组件是否可用、闸门内部是否抛异常, + 登录端点都不会被判成 404 / 401(真不存在时由框架自己给 404,语义更准)。 + +豁免判据(逐条写明,评审可核对) +-------------------------------- +1. 路径归一化(去 query/fragment、URL 解码、`\`→`/`、折叠重复斜杠、去尾斜杠、补前导斜杠)后 + **整串精确等于** `AUTH_EXEMPT_DSPY_PATHS` 中的某一项 → 豁免。 + **不做前缀/通配/子串匹配**:`/rbac/*` 这种写法会连带放行 `/rbac/user/*` 全部端点 + (含用户管理写接口),属于越权放行,明令禁止(QC 硬门禁 #6)。 +2. 命中 `AUTH_EXEMPT_PREFIXES` 之一,且**按路径段边界**命中(`/static/` 命中 `/static/a`、 + `/static` 本身,但**不**命中 `/staticx`)→ 豁免。这些是静态资产与健康探针,无业务数据。 +3. 其余一律不豁免(默认拒绝,default-deny)。 + +零硬编码:本文件不含 host / port / 域名 / 凭据;清单是代码常量,逐条精确。 +""" + +import os +import logging + +log = logging.getLogger("yuanjing.auth_exempt") + +# --------------------------------------------------------------------------- # +# 豁免清单(唯一真源,逐条精确,禁止通配) +# --------------------------------------------------------------------------- # + +#: 免鉴权的动态端点(**精确路径**,小写比较)。 +#: 依据:登录/登出/注册/会话自检这几件事发生在「还没有会话」的时刻, +#: 若要求会话则构成"先登录才能登录"的自指死循环。 +AUTH_EXEMPT_DSPY_PATHS = frozenset(( + "/rbac/user/up_login.dspy", # 主登录端点(前端表单 / API 均打这里) + "/rbac/user/login.dspy", # 兼容旧前端占位 + "/rbac/user/up_logout.dspy", # 登出(销毁会话,本身不需要有效会话) + "/rbac/user/logout.dspy", # 兼容别名 + "/rbac/user/register.dspy", # 注册 + "/rbac/user/check_login.dspy", # 会话自检(返回是否已登录) + "/rbac/user/get_login_user.dspy", # 当前登录用户(未登录返回空,不 401) +)) + +#: 免鉴权前缀(静态资产 / 健康探针;按**路径段边界**匹配)。 +AUTH_EXEMPT_PREFIXES = ( + "/healthz", + "/health", + "/robots.txt", + "/favicon.ico", + "/i18n/", + "/static/", + "/files/", +) + +#: 【R62 / Bug 匿名读全站 P0】受保护 API 前缀(**显式列举,禁通配**)。 +#: 需求口径:修复必须覆盖 /scense、/runtime、/scene、/world、/entity 五个前缀下的 +#: 全部 api 端点,而不是只补被测的 3 条。判据见 `is_protected_api_path()`: +#: 命中这五个前缀且「路径含 /api/ 段」或「以 .dspy 结尾」→ 必须带可验证会话。 +#: 注意:这是**受保护**清单(扩大闸门覆盖面),不是豁免清单;豁免仍然只有上面 +#: 那 7 条精确登录类端点 + 7 条静态/健康探针前缀,未因本次修复新增任何免鉴权项。 +PROTECTED_API_PREFIXES = ( + "/scense", + "/runtime", + "/scene", + "/world", + "/entity", +) + +#: 判定口径标识(写进 /healthz 诊断与自测输出,便于部署态核对生效版本)。 +EXEMPT_REVISION = "r62-api-prefix-coverage-v1" + + +# --------------------------------------------------------------------------- # +# 路径归一化与匹配 +# --------------------------------------------------------------------------- # + +def normalize_path(path): + r"""归一化请求路径,封堵写法绕过。 + + [fix rvtGi1mlUsTwQrXnybZj6 / R61] 本文档串含反斜杠样例 `\up_login.dspy`, + 原为非 raw 三引号 → `\u` 被当作 unicode 转义前缀(`\up` 非法)→ 整个模块 + **SyntaxError 无法 import**,进而 `route_guard_middleware` 的 auth_required 层 + 装载失败、鉴权层整层缺席(实测 SyntaxError: truncated \uXXXX escape, line 90)。 + 改为 raw docstring 后模块可正常导入;语义不变。 + + `/rbac/user/up_login.dspy/`、`//rbac/user/up_login.dspy`、 + `/rbac/user/%75p_login.dspy`、`/rbac/user\up_login.dspy`、 + `/rbac/user/up_login.dspy?x=1` 必须与规范路径得到**同一判定**。 + """ + p = str(path or "") + try: + from urllib.parse import unquote + p = unquote(p) + except Exception: # noqa: BLE001 + pass + p = p.split("?", 1)[0].split("#", 1)[0] + p = p.replace("\\", "/") + p = "/" + "/".join([seg for seg in p.split("/") if seg]) + if p != "/": + p = p.rstrip("/") + return p + + +def _prefix_hit(low_path, prefix): + """按**路径段边界**判断前缀命中(禁止 `/static` 命中 `/staticx`)。""" + pre = str(prefix or "").lower() + if not pre: + return False + if pre.endswith("/"): + base = pre.rstrip("/") + return low_path == base or low_path.startswith(pre) + return low_path == pre or low_path.startswith(pre + "/") + + +def is_exempt_path(path): + """该请求路径是否免鉴权(唯一判定入口,所有闸门只调这一个函数)。""" + try: + p = normalize_path(path) + except Exception: # noqa: BLE001 + # 归一化都失败 → 不放行(fail-closed),但记日志便于定位。 + log.error("[auth_exempt] 路径归一化失败,按不豁免处理: %r", path) + return False + low = p.lower() + if low in AUTH_EXEMPT_DSPY_PATHS: + return True + for prefix in AUTH_EXEMPT_PREFIXES: + if _prefix_hit(low, prefix): + return True + return False + + +def exempt_reason(path): + """返回豁免依据字符串(未豁免返回空串),供自测/诊断输出。""" + p = normalize_path(path) + low = p.lower() + if low in AUTH_EXEMPT_DSPY_PATHS: + return "dspy_exact:" + low + for prefix in AUTH_EXEMPT_PREFIXES: + if _prefix_hit(low, prefix): + return "prefix:" + prefix + return "" + + +def assert_no_wildcard(paths=None): + """硬门禁:豁免清单不得含通配符(`*` / `%`)。命中即抛 ValueError。""" + items = AUTH_EXEMPT_DSPY_PATHS if paths is None else paths + bad = [p for p in (items or ()) if ("*" in str(p) or "%" in str(p))] + if bad: + raise ValueError("鉴权豁免清单禁止通配符: %s" % (bad,)) + return True + + +def is_protected_api_path(path): + """该路径是否属于「必须鉴权的业务 API 覆盖面」(R62 新增判据)。 + + 判据(默认拒绝,与豁免清单解耦): + 1. 归一化 + 小写; + 2. 必须**按路径段边界**命中 `PROTECTED_API_PREFIXES` 之一(`/scene` 不命中 `/scense`, + 也不命中 `/scenex`); + 3. 且该路径含 `/api/` 段,或以 `.dspy` 结尾(动态端点)。 + 命中后再由调用方判断是否豁免(`is_exempt_path()`)——豁免优先级更高, + 但豁免清单里没有、也不允许有这五个前缀下的任何业务端点。 + """ + try: + low = normalize_path(path).lower() + except Exception: # noqa: BLE001 + return True # 判不了 → 按受保护处理 + if not any(_prefix_hit(low, "/" + str(pfx).strip("/")) for pfx in PROTECTED_API_PREFIXES): + return False + return ("/api/" in low or low.endswith(".dspy") + or ("/api" in low and low.endswith(".api"))) + + +def manifest(): + """豁免清单快照(写进 /healthz 诊断字段与自测输出)。""" + return { + "revision": EXEMPT_REVISION, + "dspy_paths": sorted(AUTH_EXEMPT_DSPY_PATHS), + "prefixes": list(AUTH_EXEMPT_PREFIXES), + "protected_api_prefixes": list(PROTECTED_API_PREFIXES), + "wildcard_free": assert_no_wildcard(), + } + + +if __name__ == "__main__": # pragma: no cover + import json as _json + log.setLevel(logging.INFO) + print(_json.dumps(manifest(), ensure_ascii=False, indent=2)) + for _p in ("/rbac/user/up_login.dspy", "/rbac/user/up_login.dspy/", + "/rbac/user/%75p_login.dspy", "/rbac/user/up_login.dspy?a=1", + "/rbac/user/list_users.dspy", "/healthz", "/staticx/a.js"): + print("%-42s exempt=%-5s reason=%s" % (_p, is_exempt_path(_p), exempt_reason(_p) or "-")) diff --git a/app/_archive_disabled_20261028_151505/auth_gate.py b/app/_archive_disabled_20261028_151505/auth_gate.py new file mode 100644 index 0000000..349f311 --- /dev/null +++ b/app/_archive_disabled_20261028_151505/auth_gate.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""向后兼容别名面:`from app import auth_gate` 的外部调用方不应 ImportError。 + +[fix rvtGi1mlUsTwQrXnybZj6 / T4-A] 本文件此前把豁免表达成 +`is_exempt_path(p) = not is_protected(p)` —— 这是**第二份判定入口**: +`is_protected()` 是「按资源存在性 + 扩展名」推导的受保护集合,与豁免清单 +(`app/auth_exempt.py`)的判据不同源。两份口径一旦分叉(例如某路径磁盘不可见 +但语义上必须免鉴权),调用方拿到的豁免结论就与主线闸门不一致, +正是 Bug uifdBIMdmixZcmJEdV33O(登录端点自己 401)的原始形态。 + +现在的约定(唯一真源 = `app/auth_exempt.py`): + +* `is_exempt_path()` / `is_anonymous_path()` 都**纯转发** `auth_exempt.is_exempt_path()`, + 不再用 `not is_protected(...)` 二次表达豁免; +* `normalize_request_path()` / `resource_exists()` / `is_protected()` 仍委托 + `app/auth_route_gate.py`(资源面语义,与豁免判据无关,保留原样); +* 本文件不维护任何名单字面量,也不新增策略。 + +pbls 与 yuanjing 顶层包名同为 app,跨应用自测脚本 +apps/pbls/scripts/selftest_auth_gate_r39.py 引用 normalize_request_path / +is_anonymous_path / is_exempt_path / resource_exists 四个符号,故保留全部四个。 +""" + +import logging + +log = logging.getLogger("scense.auth_gate") + + +def _route_gate(): + """取资源面实现模块 app/auth_route_gate.py(保持历史委托关系)。""" + try: + from . import auth_route_gate as _m + return _m + except Exception: # noqa: BLE001 + try: + import auth_route_gate as _m + return _m + except Exception: # noqa: BLE001 + return None + + +def _auth_exempt(): + """取鉴权豁免**单一真源** app/auth_exempt.py(部署态 sys.path 可能不含应用根)。""" + try: + from . import auth_exempt as _m + return _m + except Exception: # noqa: BLE001 + try: + from app import auth_exempt as _m # type: ignore + return _m + except Exception: # noqa: BLE001 + pass + try: + import auth_exempt as _m + return _m + except Exception: # noqa: BLE001 + pass + try: + import importlib.util + import os + p = os.path.join(os.path.dirname(os.path.abspath(__file__)), "auth_exempt.py") + spec = importlib.util.spec_from_file_location("yj_auth_exempt_gate", p) + _m = importlib.util.module_from_spec(spec) + spec.loader.exec_module(_m) + return _m + except Exception as exc: # noqa: BLE001 + log.error("[auth_gate] 豁免清单单一真源加载失败: %s", exc) + return None + + +def normalize_request_path(path): + m = _route_gate() + if m is None: + return path + return m.normalize_request_path(path) + + +def resource_exists(url_path): + m = _route_gate() + if m is None: + return False + return bool(m.resource_exists(url_path)) + + +def is_protected(path): + """资源面受保护判定(**不是**豁免判据;豁免一律走 is_exempt_path)。""" + m = _route_gate() + if m is None: + return True + try: + return bool(m.is_protected(path)) + except Exception as exc: # noqa: BLE001 + log.warning("[auth_gate] is_protected 失败,按受保护处理: %s", exc) + return True + + +def is_exempt_path(path): + """是否免鉴权——**纯转发**单一真源 `auth_exempt.is_exempt_path()`。 + + 禁止在此用 `not is_protected(...)` 表达豁免(那会形成第二份判定入口, + Bug uifdBIMdmixZcmJEdV33O 的原始形态)。单一真源不可用时 fail-closed(False)。 + """ + m = _auth_exempt() + if m is None: + return False + try: + return bool(m.is_exempt_path(path)) + except Exception as exc: # noqa: BLE001 + log.error("[auth_gate] 豁免判定异常(按不豁免处理): %s", exc) + return False + + +def is_anonymous_path(path): + """历史符号:匿名可到达 == 免鉴权。与 `is_exempt_path()` 同源同义(纯转发)。""" + return is_exempt_path(path) + + +def manifest(): + """豁免清单快照——同样纯转发单一真源,便于调用方做一致性断言。""" + m = _auth_exempt() + if m is None: + return {"revision": "unavailable", "dspy_paths": [], "prefixes": [], + "wildcard_free": False} + return m.manifest() + + +def main(): # pragma: no cover + import json as _json + print(_json.dumps(manifest(), ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/app/_archive_disabled_20261028_151505/auth_gate_strict.py b/app/_archive_disabled_20261028_151505/auth_gate_strict.py new file mode 100644 index 0000000..09789cc --- /dev/null +++ b/app/_archive_disabled_20261028_151505/auth_gate_strict.py @@ -0,0 +1,410 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""yuanjing 应用级「最后一层」会话鉴权闸门(Bug vRZrx0Ff9tz / R18 复测,P1 匿名读越权)。 + +为什么还需要这一层(根因链,实测于部署态 2026-10-25) +------------------------------------------------------ +`app/route_guard_middleware.py::auth_required_middleware` 的判定逻辑本身是对的 +(非公开 `.dspy` + 无会话 → 401),但部署态实测: + + NOCOOKIE GET /scense/api/list_scense.dspy -> 200 {"ok": true, "total": 0, "rows": []} + NOCOOKIE GET /scense/api/session_create.dspy -> 401 # 同层对照 + NOCOOKIE GET /no/such/path.dspy -> 401 # 框架层回 401 + +即「门禁按端点覆盖不全」而非整链缺失。语义中间件链的装配点是 +`app/notfound_middleware.py::compose_guard_chain()`,它**按模块名**导入 +`route_guard_middleware` 等中间件;部署态 `python app/scense.py` 的 sys.path 不含应用根时 +导入失败 → `compose_guard_chain` 抛 RuntimeError → 外层降级为「只做 404 异常映射」的 +handler-only 链 → **鉴权层整层缺席**,业务处理器直接执行 → 200。 +(同一降级路径也是 2026-10-25 上一轮 business_status 层缺席的同源故障。) + +本模块是**自持、零 app 包依赖**的兜底闸门: +* 只依赖 stdlib + aiohttp + cryptography(Fernet),不 import `app.*`; +* 默认 deny:除显式公开路径(登录/登出/健康探针)与静态资源外,**任何 `.dspy` 都必须 + 带可验证的会话密文**,否则 401 并以统一错误信封返回(无业务字段); +* 自检/解密异常一律 fail-closed(401),绝不 fail-open; +* 由 `app/scense.py::_load_semantic_middlewares()` **按绝对文件路径**加载并注册, + 与 `compose_guard_chain` 的装配成败解耦 —— 组合链修好后本层成为幂等冗余保险丝。 + +与 `route_guard_middleware` 的关系 +---------------------------------- +职责重叠是有意的(纵深防御)。责任重叠是有意的(纵深防御)。**公开路径集合不再由本文件持有**: +鉴权豁免清单的单一真源是 `app/auth_exempt.py`,本文件 `is_public_path()` 委托其 +`is_exempt_path()`;本地 `PUBLIC_DSPY_PATHS` / `PUBLIC_PATH_PREFIXES` 已降级为只读镜像, +仅供 `scripts/selftest_auth_gate_list_scense.py` 交叉断言相等(防多清单漂移)。 +只读镜像(仅供 `scripts/selftest_auth_gate_list_scense.py` 交叉断言相等)。 +其余事实常量(密钥派生尾串、cookie 精确名、会话有效期)仍由该自测交叉断言, +改一处不改另一处会直接让自测 FAIL —— 以此防止实现漂移。 +""" + +import base64 +import importlib.util +import json +import logging +import os +import time +import urllib.parse + +from aiohttp import web + +log = logging.getLogger("yuanjing.auth_gate_strict") + +# ── 鉴权豁免清单:**单一真源** app/auth_exempt.py(Bug uifdBIMdmixZcmJEdV33O / T4)── +# 修复前本文件自持一份 PUBLIC_DSPY_PATHS / PUBLIC_PATH_PREFIXES 副本,与 +# auth_route_gate / route_guard_middleware / notfound_middleware 的另外几份副本各自演化 +# (R57b / R59:主线豁免、兜底路径不豁免 → 登录端点仍 401 auth_unavailable)。 +# [fix rvtGi1mlUsTwQrXnybZj6 / R61 · QC#1] 现在本文件**不再持有第二份清单**: +# 判定一律委托 `auth_exempt.is_exempt_path()`,下面两个名字只是它的**只读镜像**, +# 仅用于自测交叉断言与诊断输出(自测 `scripts/selftest_auth_gate_list_scense.py` +# 断言三者相等,防止再次各自演化)。 +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_strict", + os.path.join(os.path.dirname(os.path.abspath(__file__)), "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 + # 清单都加载不了 → 不做任何豁免(fail-closed),但必须显式告警,不静默。 + log.error("[auth_strict] 豁免清单单一真源加载失败(所有 .dspy 均按需会话处理): %s", + _load_exc) + EXEMPT = None + +def _derive_exempt_manifest(mod): + """由单一真源 `auth_exempt.manifest()` **自动派生**镜像(禁止手工复制字面量)。 + + [fix rvtGi1mlUsTwQrXnybZj6 / T4-A] 下面两个 PUBLIC_* 名字仅是**派生镜像** + (自测交叉断言 + 诊断输出),判定一律走 `EXEMPT.is_exempt_path()`。 + """ + if mod is None: + return {"revision": "unavailable", "dspy_paths": frozenset(), + "prefixes": (), "wildcard_free": False} + try: + mf = mod.manifest() + except Exception: # noqa: BLE001 + return { + "revision": getattr(mod, "EXEMPT_REVISION", "unavailable"), + "dspy_paths": frozenset(getattr(mod, "AUTH_EXEMPT_DSPY_PATHS", ()) or ()), + "prefixes": tuple(getattr(mod, "AUTH_EXEMPT_PREFIXES", ()) or ()), + "wildcard_free": False, + } + return { + "revision": str(mf.get("revision") or "unavailable"), + "dspy_paths": frozenset(mf.get("dspy_paths") or ()), + "prefixes": tuple(mf.get("prefixes") or ()), + "wildcard_free": bool(mf.get("wildcard_free", False)), + } + + +EXEMPT_MANIFEST = _derive_exempt_manifest(EXEMPT) + +#: **交叉断言镜像**(非清单本体,由 manifest() 派生):仅为自测比对与诊断输出保留。 +PUBLIC_DSPY_PATHS = EXEMPT_MANIFEST["dspy_paths"] + +#: **交叉断言镜像**(非清单本体,由 manifest() 派生):同上。 +PUBLIC_PATH_PREFIXES = EXEMPT_MANIFEST["prefixes"] + +#: 受闸门约束的后缀。`.dspy` 是唯一需要会话的动态端点类型; +#: `.ui/.js/.css/.html/.json` 属页面与静态资产,由框架会话层处理(避免锁死登录页)。 +GATED_SUFFIX = ".dspy" + +#: 会话 cookie 精确名(与 ahserver AuthAPI.setupAuth / route_guard 同源)。 +SESSION_COOKIE_NAME_DEFAULT = "AIOHTTP_SESSION" +SESSION_COOKIE_MAX_AGE = 24 * 60 * 60 + +#: 复刻 ahserver AuthAPI.setupAuth 的 Fernet 密钥派生(port 前缀 + 固定尾串,共 32 字节)。 +SESSION_SECRET_TAIL = b"iqwertyuiopasdfghjklzxcvbnm12345" + +#: 闸门总开关(仅排障用;生产禁开)。与 route_guard 同名环境变量。 +GATE_ENV = "YJ_AUTH_GATE" + +#: 应用根(本文件 → app/ → 应用根)。 +APP_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def gate_enabled(): + """总开关:默认开;`YJ_AUTH_GATE=off|0|false|no` 才关(排障专用)。""" + return (os.environ.get(GATE_ENV) or "on").strip().lower() not in ( + "off", "0", "false", "no") + + +def normalize_path(path): + """归一化请求路径,封堵写法绕过。 + + `/scense/api/list_scense.dspy/`、`/SCENSE/API/LIST_SCENSE.DSPY`、 + `//scense/api/list_scense.dspy`、`/scense/api/%6cist_scense.dspy` 都必须 + 与 `/scense/api/list_scense.dspy` 得到同一判定,不能因为写法不同而绕过闸门。 + """ + p = str(path or "") + p = urllib.parse.unquote(p) + p = p.split("?", 1)[0].split("#", 1)[0] + p = "/" + "/".join([seg for seg in p.split("/") if seg]) + if p != "/": + p = p.rstrip("/") + return p.lower() if p.lower().endswith(GATED_SUFFIX) or p.startswith("/rbac/") else p + + +def is_public_path(path): + """显式公开/豁免路径判定 —— **委托单一真源** `auth_exempt.is_exempt_path()`。 + + [fix rvtGi1mlUsTwQrXnybZj6 / R61 · QC#1] 本函数**不再**用本地常量判定: + 历史实现在这里持有第二份清单,与 route_guard / auth_route_gate / + notfound 兜底闸门的副本各自演化(R57b / R59 多清单漂移同源缺陷)。 + 现在只有一份清单(auth_exempt),本层只是调用方。 + + :param path: 请求路径(可含 query / 尾斜杠 / 编码,内部归一化) + :return: bool —— 豁免 True;清单不可用或判定异常一律 False(fail-closed) + """ + if EXEMPT is None: + return False + try: + return bool(EXEMPT.is_exempt_path(normalize_path(path))) + except Exception as exc: # noqa: BLE001 + log.error("[auth_strict] 豁免判定异常(按不豁免处理): %s", exc) + return False + + +def public_reason(path): + """豁免依据(未豁免返回空串)—— 委托单一真源,供自测/诊断输出。""" + if EXEMPT is None: + return "" + try: + return EXEMPT.exempt_reason(normalize_path(path)) or "" + except Exception: # noqa: BLE001 + return "" + + +def is_gated_path(path): + """该路径是否需要会话凭据。 + + [R62 / 匿名读全站 P0,需求 1+2] 覆盖面从「仅 `.dspy` 后缀」扩为 + **后缀 ∪ 业务 API 前缀**,判定顺序(顺序即语义): + + 1. 显式公开路径(登录/登出/健康探针,单一真源 `auth_exempt`)→ 不需会话; + 2. `.dspy` 动态端点 → 需要会话(历史行为,保持); + 3. 命中 `auth_exempt.PROTECTED_API_PREFIXES`(/scense /runtime /scene /world + /entity)且路径含 `/api/` 段或以 `.dspy` 结尾 → **同样需要会话**。 + + 第 3 条是本次修复的关键:旧实现**只看后缀**(`if not endswith('.dspy'): return + False`),任何「无 `.dspy` 后缀的业务 API」(反向代理/重写规则下的 + `/runtime/api/build_entity_tree`、`/scense/api/...` 变体写法)都会被判成 + 「非 `.dspy` → 放行」,于是匿名 200 返回业务数据。按需求 2,覆盖面必须按 + **前缀**枚举而非按后缀,且不得只补被测的 3 条。 + """ + p = normalize_path(path) + if is_public_path(p): + return False + if p.lower().endswith(GATED_SUFFIX): + return True + # 前缀覆盖面:判定委托单一真源,禁在本文件复制清单 + try: + if EXEMPT is not None and EXEMPT.is_protected_api_path(p): + return True + except Exception as exc: # noqa: BLE001 + # 判不了 → 按受保护处理(fail-closed,绝不放行业务数据) + log.error("[auth_strict] 业务 API 前缀判定异常(按受保护处理): %s", exc) + return True + return False + + +def _app_conf(): + """读 conf/config.json(读不到返回 {},绝不因配置问题抛到中间件链)。""" + try: + with open(os.path.join(APP_ROOT, "conf", "config.json"), encoding="utf-8") as fp: + return json.load(fp) or {} + except Exception: # noqa: BLE001 + return {} + + +def session_cookie_names(): + """允许承载会话的 cookie **精确名**(默认名 + conf 的 cookie_name)。""" + names = [SESSION_COOKIE_NAME_DEFAULT] + try: + cfg = _app_conf() + name = ((((cfg.get("website") or {}).get("session") or {}) + .get("cookie_name")) or "").strip() + if name and name not in names: + names.append(name) + except Exception: # noqa: BLE001 + pass + return tuple(names) + + +def session_key(): + """Fernet 密钥:port 前 32 字节 + 固定尾串补齐(复刻 ahserver 派生)。""" + port = str(((_app_conf().get("website") or {}).get("port")) or "") + b = port.encode("utf-8")[:32] + cnt = max(0, 32 - len(b)) + return base64.urlsafe_b64encode(b + SESSION_SECRET_TAIL[:cnt]) + + +def verify_cookie(raw_cookie): + """校验会话密文 → `(ok, reason)`。 + + reason ∈ ``no_session`` / ``forged_session`` / ``expired_session`` / + ``session_decode_unavailable``(cryptography 不可用 → fail-closed)。 + """ + if not raw_cookie or not str(raw_cookie).strip(): + return False, "no_session" + try: + from cryptography.fernet import Fernet, InvalidToken + except Exception: # noqa: BLE001 + return False, "session_decode_unavailable" + try: + fernet = Fernet(session_key()) + token = str(raw_cookie).strip().encode("utf-8") + try: + data = json.loads(fernet.decrypt(token, ttl=SESSION_COOKIE_MAX_AGE).decode("utf-8")) + return (True, "ok") if isinstance(data, dict) else (False, "forged_session") + except InvalidToken: + data = json.loads(fernet.decrypt(token).decode("utf-8")) + if not isinstance(data, dict): + return False, "forged_session" + created = int(data.get("created") or 0) + if created and (time.time() - created) > SESSION_COOKIE_MAX_AGE: + return False, "expired_session" + return False, "forged_session" + except Exception: # noqa: BLE001 + return False, "forged_session" + + +def _has_session(request): + """请求是否携带可验证的会话 → `(ok, reason)`。""" + cookies = getattr(request, "cookies", None) or {} + for name in session_cookie_names(): + val = cookies.get(name) + if val: + return verify_cookie(val) + return False, "no_session" + + +def _deny(request, reason): + """统一 401 错误信封(**不含任何业务字段**)。 + + hint 自指修正(R53 / QC#4): + * 登录端点自身(`/rbac/user/up_login.dspy` 等公开路径)被拒 → **不是未登录**, + 而是服务端鉴权闸门/会话组件异常(fail-closed 掩盖了真实故障)。此时 hint + 必须指向「闸门异常」而非「请先 POST 登录」,否则形成"先登录才能登录"的自指死循环。 + * 其余受保护端点 → 保持"请先登录"的指引。 + """ + path = normalize_path(getattr(request, "path", "") or "") + log.warning("[auth_strict] 拒绝匿名/无效会话请求: %s %s reason=%s", + getattr(request, "method", "-"), path, reason) + if is_public_path(path): + hint = ("该路径是公开登录端点,401 表示服务端鉴权闸门异常(fail-closed)," + "不是未登录。请查看服务端日志 [auth_strict] 与 /healthz 的 gate/chain 诊断字段") + else: + hint = "未登录/会话无效:请先 POST /rbac/user/up_login.dspy 取得会话 cookie" + resp = web.json_response( + { + "ok": False, + "status": 401, + "error": reason, + "path": path, + "hint": hint, + }, + status=401, + ) + resp.headers["WWW-Authenticate"] = 'Session realm="yuanjing"' + return resp + + +def _is_response_contract_error(exc): + """异常是否属于「中间件把非 Response(handler 函数本体)交回上游」这一类契约错误。""" + txt = "%s: %s" % (type(exc).__name__, exc) + for key in ("Expect response", "should return a response", "not ", + "not a Response", "ResponseContractError", "__mw_signature_adapter__"): + if key in txt: + return True + return False + + +def _internal_error(request, exc): + """闸门内部异常 → **500**(不是 401)。带 traceback 与层名,供运维直接定位。 + + :param request: aiohttp Request + :param exc: 捕获到的异常 + :return: aiohttp web.Response(status=500, error=auth_gate_internal_error) + """ + path = "-" + try: + path = normalize_path(getattr(request, "path", "") or "") or "-" + except Exception: # noqa: BLE001 + pass + contract = _is_response_contract_error(exc) + log.error("[auth_strict] 闸门内部异常(非会话缺失)→ 500 %s %s contract_error=%s: %s: %s", + getattr(request, "method", "-"), path, contract, + type(exc).__name__, exc, exc_info=True) + detail = "%s: %s" % (type(exc).__name__, exc) + if contract: + hint = ("中间件链响应契约错误:某层把未 await 的 handler 当作 Response 返回。" + "查日志 [mw-signature]/[auth_strict] 里的 layer= 字段定位该层") + else: + hint = ("服务端鉴权闸门内部异常(既不是未登录,也不是会话缺失)。" + "查服务端日志 [auth_strict] 的 traceback") + resp = web.json_response( + { + "ok": False, + "status": 500, + "error": "auth_gate_internal_error", + "path": path, + "detail": detail, + "contract_error": contract, + "hint": hint, + }, + status=500, + ) + try: + resp.headers["X-Auth-Gate-Error"] = "internal" + except Exception: # noqa: BLE001 + pass + return resp + + +@web.middleware +async def auth_gate_strict_middleware(request, handler): + """最后一层会话闸门:非公开 `.dspy` 无有效会话 → 401(fail-closed)。 + + 判定顺序(顺序即语义): + 1. 总开关关闭(排障)→ 放行; + 2. 非 `.dspy`(页面/静态)→ 放行; + 3. 显式公开路径(登录/登出/健康探针)→ 放行; + 4. 其余 `.dspy` → 校验会话密文,失败 401,成功放行给内层链/业务处理器。 + """ + # [fix Zhpd9MXyeGtznDMqini6X / R49] 与 route_guard 同源修法: + # 只把「闸门自身判定/会话校验」的异常 fail-closed 成 401, + # `await handler(request)`(下游业务处理器)必须在 try 之外, + # 否则下游 500 / aiohttp `Expect response, not ` + # 会被改写成 401 auth_unavailable,把真实故障整层遮蔽(R49 矩阵 + # 229×401、42 条 500 无法判定的机械原因)。 + try: + if not gate_enabled(): + return await handler(request) + path = normalize_path(getattr(request, "path", "") or "") + if not is_gated_path(path): + return await handler(request) + ok, reason = _has_session(request) + except Exception as exc: # noqa: BLE001 + # fail-closed 仍成立(绝不放行业务数据),但**错误码不再冒充鉴权失败**。 + # 历史(已废除):R50 曾把公开路径的闸门异常 `return await handler(request)` + # 放行,其余一律 401 auth_unavailable —— 两者都在掩盖真实故障,R60 起废除。 + # [fix rvtGi1mlUsTwQrXnybZj6 / R60] 语义分离:**闸门内部异常 ≠ 会话缺失**。 + # 旧实现把任何异常(典型:链装配错误 `Expect response, not `) + # 一律降级成 401 auth_unavailable,于是「登录端点自己 401」被固化, + # 真实故障(某层把未 await 的 handler 当 Response 返回)被 401 完全遮蔽 —— + # R58 实测 logs/scense.log:1735-1737 就是这条遮蔽路径。 + # 现在:会话缺失/无效 → 401(见下方 ok 分支);闸门自身或内层链异常 → + # 500 + error=auth_gate_internal_error + 完整 traceback,绝不改写成鉴权失败。 + return _internal_error(request, exc) + if ok: + return await handler(request) + return _deny(request, reason) diff --git a/app/_archive_disabled_20261028_151505/auth_route_gate.py b/app/_archive_disabled_20261028_151505/auth_route_gate.py new file mode 100644 index 0000000..f2de0e4 --- /dev/null +++ b/app/_archive_disabled_20261028_151505/auth_route_gate.py @@ -0,0 +1,566 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""统一鉴权门禁(存在性 → 鉴权 两段式;404 / 401 / 403 三态互不覆盖)。 + +覆盖缺陷族(T2 鉴权门禁族) +--------------------------- +* LonNYvRxXk9EttSexkwkV:未登录 GET /scense/api/list_scense.dspy 返 200(应 401) +* LIyicannYDhJiXXkjTQ0N:不存在端点返 401 而非 404(鉴权先于路由) +* bj-n9DhoDKGz_lMHiUmbq:匿名 GET /scene/api/get_search_status.dspy 返 200+rows +* N4teiYPv2VUo2OWkoR8Fw:匿名 POST /scene/api/create_scene.dspy 进入业务分支返 200 +* QuOjNlynfq3i8P7zdU2U4:匿名读 3 端点均 200 返业务数据 + +设计要点(为什么另起一个「统一门禁」而不是继续在旧 gate 上打补丁) +------------------------------------------------------------------ +旧实现(route_guard_middleware.auth_required_middleware)依赖 +`PROTECTED_DSPY_PATHS` 逐条点名 + 行为分支判定,结果是「点名的进闸、没点名的漏出去」, +于是 list_scense / get_search_status / create_scene 各自以 200 漏过,同一站内 +"不存在路径 401、真实业务端点 200" 的语义自相矛盾。 + +本模块改为**默认拒绝(default-deny)**: + + *.dspy ⇒ 除非在 PUBLIC_DSPY_PATHS(逐条精确、无通配)中, + 否则一律「必须有有效会话」。 + 新增端点无需登记即自动纳管(get_search_* / list_* / create_* 全部覆盖)。 + +判定顺序(硬约束,两者不得互相覆盖): + + ① normalize 路径 + ② 后缀不是 .dspy → 放行(静态资源 / .ui 页面 / /healthz 交给既有组件) + ③ 命中公开白名单(auth_exempt.is_exempt_path,精确/段边界,无通配) + → 放行(**先于**存在性与鉴权:登录链路不得自锁) + ④ 资源不存在 → 404 (先于鉴权:路径写错不应伪装成"未登录") + ⑤ 无有效会话 / 会话无效 → 401 + WWW-Authenticate + ⑥ 有有效会话 → 放行(后续 RBAC 权限不足由 ahserver 判 403) + +403 的职责边界:本门禁只判「有没有身份」,不判「这个身份有没有该端点权限」—— +后者是 RBAC(permission / rolepermission)的事,返回 403 由框架层给出。 +两者分工在矩阵表里体现为 `登录态=有, 无权限 → 403(框架)`。 + +安装位置 +-------- +`install(app)` 把中间件插到 `app.middlewares` **首位(最外层)**: +aiohttp 的中间件列表首位 = 最外层,必须先于 ahserver 的鉴权层执行, +否则框架自身的 401(对不存在路径也 401)会抢在我们的 404 之前返回 - +这正是 LIyicannYDhJiXXkjTQ0N 的根因(「鉴权前置于路由判定」)。 + +零硬编码:白名单路径、后缀、cookie 名全部是本文件常量或从 conf/config.json 读取; +不写任何 host/port/域名。 +""" + +import sys +import json +import os +import importlib.util +import logging + +# R52 QC#4:本文件多处 `log.error(...)` 落在 _load_contract_module / _new_style 的 +# **兜底路径**上(设计意图是「宁可抛错也不静默放行」)。此前未定义 `log`、也未 +# import logging,一旦真的走到兜底分支会先抛 `NameError: name 'log' is not defined`, +# 把原始异常覆盖成模块级 import 失败 → 应用启动即崩。与 get_only_405.py 对称定义。 +log = logging.getLogger("scense.auth_route_gate") + +# --------------------------------------------------------------------------- # +# 常量(全部逐条精确,**禁止通配符**:QC 硬门禁 #6) +# --------------------------------------------------------------------------- # + +APP_DIR = os.path.dirname(os.path.abspath(__file__)) + +#: 关闸开关(off/0/false/no 关闭;默认开启) +GATE_ENV = "YUANJING_AUTH_ROUTE_GATE" + +#: 只对 .dspy 端点做门禁(.ui 页面由会话层处理,避免把登录页自身锁死) +PROTECTED_SUFFIXES = (".dspy",) + +# ── 鉴权豁免清单:**单一真源** app/auth_exempt.py(T4 / Bug uifdBIMdmixZcmJEdV33O)── +# 修复前本文件自带一份 PUBLIC_DSPY_PATHS 副本,与 route_guard_middleware / +# auth_gate_strict / notfound_middleware 内联兜底闸门的另外几份副本**各自演化**, +# 结果「主线豁免、兜底路径不豁免」→ 登录端点在某些链路上仍 401。现在统一委托。 +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", os.path.join(APP_DIR, "auth_exempt.py")) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +EXEMPT = _load_auth_exempt() + + +def _derive_exempt_manifest(mod): + """由单一真源 `auth_exempt.manifest()` **自动派生**镜像(禁止手工复制字面量)。 + + [fix rvtGi1mlUsTwQrXnybZj6 / T4-A] 本文件的 PUBLIC_DSPY_PATHS / + PUBLIC_PATH_PREFIXES 只是**派生镜像**,用于自测交叉断言与诊断输出; + 判定一律走 `EXEMPT.is_exempt_path()`。真源不可用/异常 → 空镜像 + fail-closed。 + """ + if mod is None: + return {"revision": "unavailable", "dspy_paths": frozenset(), + "prefixes": (), "wildcard_free": False} + try: + mf = mod.manifest() + except Exception: # noqa: BLE001 + # manifest 不可用时退回真源的属性(仍是同一份数据,不是本地字面量) + return { + "revision": getattr(mod, "EXEMPT_REVISION", "unavailable"), + "dspy_paths": frozenset(getattr(mod, "AUTH_EXEMPT_DSPY_PATHS", ()) or ()), + "prefixes": tuple(getattr(mod, "AUTH_EXEMPT_PREFIXES", ()) or ()), + "wildcard_free": bool(getattr(mod, "EXEMPT_REVISION", "")), + } + return { + "revision": str(mf.get("revision") or "unavailable"), + "dspy_paths": frozenset(mf.get("dspy_paths") or ()), + "prefixes": tuple(mf.get("prefixes") or ()), + "wildcard_free": bool(mf.get("wildcard_free", False)), + } + + +EXEMPT_MANIFEST = _derive_exempt_manifest(EXEMPT) + +#: 免鉴权端点(逐条精确路径;登录/注册/探针)—— **由 manifest() 派生**,只读镜像。 +PUBLIC_DSPY_PATHS = EXEMPT_MANIFEST["dspy_paths"] + +#: 免鉴权前缀(静态资产 / 健康探针,按路径段边界匹配,无通配)—— **由 manifest() 派生**。 +PUBLIC_PATH_PREFIXES = EXEMPT_MANIFEST["prefixes"] + +#: 豁免口径版本(写进诊断与自测输出;同样来自 manifest()) +EXEMPT_REVISION = EXEMPT_MANIFEST["revision"] + +#: 中间件函数名(供装配后置核验使用) +MIDDLEWARE_NAME = "auth_route_gate_middleware" + +#: 会话凭据来源(与 ahserver/rbac 对齐;具体 cookie 名从 conf 读) +DEFAULT_SESSION_COOKIE = "AIOSESSION" + + +# --------------------------------------------------------------------------- # +# 路径工具 +# --------------------------------------------------------------------------- # + +def _app_root(): + """应用根目录(= app/ 的上级)。""" + return os.path.dirname(APP_DIR) + + +def normalize_request_path(path): + """归一路径:去 query/fragment、URL 解码、折叠重复斜杠、去尾斜杠。 + + 不做大小写折叠(文件系统可能大小写敏感),小写化只用于白名单匹配。 + """ + try: + from urllib.parse import unquote + except Exception: # pragma: no cover + def unquote(x): + return x + p = str(path or "") + for sep in ("?", "#"): + idx = p.find(sep) + if idx >= 0: + p = p[:idx] + try: + p = unquote(p) + except Exception: # noqa: BLE001 + pass + p = p.replace("\\", "/") + while "//" in p: + p = p.replace("//", "/") + if len(p) > 1 and p.endswith("/"): + p = p.rstrip("/") + if not p.startswith("/"): + p = "/" + p + return p + + +def _candidate_paths(url_path): + """把 URL 映射为候选文件路径(应用 wwwroot + 外部扫描根,任一存在即视为存在)。""" + p = normalize_request_path(url_path) + cands = [] + for root, prefix in _scan_roots(): + if prefix: + if not (p.lower() == prefix.lower() or p.lower().startswith(prefix.lower() + "/")): + continue + rel = p[len(prefix):].lstrip("/") + else: + rel = p.lstrip("/") + if not rel: + continue + cands.append(os.path.join(root, rel)) + cands.append(os.path.join(root, rel, "index.dspy")) + cands.append(os.path.join(root, rel, "index.ui")) + return cands + + +def resource_exists(url_path): + """资源在 wwwroot 下是否存在(软链已跟随即存在)。""" + for c in _candidate_paths(url_path): + try: + if os.path.isfile(c): + return True + except OSError: + continue + return False + + +def is_public_endpoint(url_path): + """是否免鉴权(委托单一真源 auth_exempt.is_exempt_path,精确比对无通配)。""" + try: + return bool(EXEMPT.is_exempt_path(url_path)) + except Exception: # noqa: BLE001 + # 豁免判定自身故障 → fail-closed(不放行),但记日志,绝不静默。 + log.error("[auth_route_gate] 豁免判定异常,按不豁免处理: %r", url_path) + return False + + +def is_protected(url_path): + """该路径是否受本门禁保护(= 后缀在 PROTECTED_SUFFIXES 内且非公开)。""" + p = normalize_request_path(url_path) + if not p.lower().endswith(PROTECTED_SUFFIXES): + return False + return not is_public_endpoint(p) + + +# --------------------------------------------------------------------------- # +# 会话校验(复用 route_guard_middleware 的会话解密,不重复实现密码学) +# --------------------------------------------------------------------------- # + +_ROUTE_GUARD = None + + +def _route_guard_module(): + """按文件路径加载 route_guard_middleware(不依赖 app 包可导入)。""" + global _ROUTE_GUARD + if _ROUTE_GUARD is not None: + return _ROUTE_GUARD + try: + from app import route_guard_middleware as rg # noqa: F401 + _ROUTE_GUARD = rg + return rg + except Exception: # noqa: BLE001 + pass + path = os.path.join(APP_DIR, "route_guard_middleware.py") + try: + spec = importlib.util.spec_from_file_location("_yj_route_guard", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + _ROUTE_GUARD = mod + return mod + except Exception: # noqa: BLE001 + _ROUTE_GUARD = False + return False + + +async def _verify_session_impl(request): + """返回 (ok: bool, reason: str)。 + + 优先委托 route_guard_middleware.verify_session(真实 cookie 解密 + rbac 会话)。 + 取不到实现时 **fail-closed**(返回 False)——宁可 401 也不放行匿名。 + """ + rg = _route_guard_module() + if rg and hasattr(rg, "verify_session"): + try: + res = await rg.verify_session(request) + except Exception as exc: # noqa: BLE001 + return False, "session_verify_error:%s" % type(exc).__name__ + if isinstance(res, tuple): + return bool(res[0]), str(res[1] if len(res) > 1 else "") + return bool(res), "" + return False, "no_session_verifier" + + +#: 测试替身挂载点(selftest 覆盖此名字) +_verify_session = _verify_session_impl + + +async def verify_session(request): + return await _verify_session(request) + + +# --------------------------------------------------------------------------- # +# 纯函数判定(可脱框架单测) +# --------------------------------------------------------------------------- # + +def gate_decision(url_path, exists=None, session_ok=None): + """返回 (http_code, reason)。http_code 为 None = 放行。 + + exists / session_ok 传入 None 时按真实实现推算(exists 用文件系统, + session_ok 无法在纯函数里 await,必须显式传入)。 + """ + p = normalize_request_path(url_path) + if not p.lower().endswith(PROTECTED_SUFFIXES): + return None, "not_dspy" + # 豁免先于存在性(与真实中间件同口径,T4) + if is_public_endpoint(p): + return None, "public_endpoint" + if exists is None: + exists = resource_exists(p) + if not exists: + return 404, "no such resource" + if not session_ok: + return 401, "authentication required" + return None, "authorized" + + +# --------------------------------------------------------------------------- # +# aiohttp 中间件 +# --------------------------------------------------------------------------- # + +def _json_response(status, payload, headers=None): + """构造 JSON 响应;aiohttp 不可用时退化为轻量对象(供离线自测)。""" + body = json.dumps(payload, ensure_ascii=False) + try: + from aiohttp import web + return web.Response(status=status, text=body, content_type="application/json", + headers=headers or {}) + except Exception: # noqa: BLE001 + class _LiteResponse(object): + def __init__(self, status, text, hdrs): + self.status = status + self.text = text + self.headers = dict(hdrs or {}) + self.content_type = "application/json" + + def __repr__(self): + return "" % (self.status, self.text) + return _LiteResponse(status, body, headers) + + +def _gate_enabled(): + return (os.environ.get(GATE_ENV) or "on").strip().lower() not in ("off", "0", "false", "no") + + +async def auth_route_gate_middleware(request, handler): + """存在性 → 鉴权(顺序不可颠倒;404 不得被 401 覆盖)。""" + if not _gate_enabled(): + return await handler(request) + + path = normalize_request_path(getattr(request, "path", "") or "") + if not path.lower().endswith(PROTECTED_SUFFIXES): + return await handler(request) + + # ① 豁免(**必须先于存在性与会话校验**,T4 / Bug uifdBIMdmixZcmJEdV33O): + # 登录/登出/注册/会话自检端点在「还没有会话」的时刻被调用,任何形式的 + # 404/401 都会造成「先登录才能登录」的自指死循环。豁免判定放在最前, + # 意味着 wwwroot 软链缺失(存在性为假)、会话组件不可用(校验抛错) + # 都不会把登录端点变成本门禁的错误信封;真不存在时由框架自己给 404。 + if is_public_endpoint(path): + return await handler(request) + + # ② 存在性:不存在 → 404(先于鉴权) + if not resource_exists(path): + return _json_response(404, { + "status": 404, + "success": False, + "ok": False, + "reason": "no such resource", + "path": path, + }) + + # ③ 鉴权:无有效会话 → 401 + try: + ok, reason = await verify_session(request) + except Exception as exc: # noqa: BLE001 + ok, reason = False, "session_verify_error:%s" % type(exc).__name__ + if not ok: + return _json_response(401, { + "status": 401, + "success": False, + "ok": False, + "reason": "authentication required", + "path": path, + "detail": reason or "", + }, headers={"WWW-Authenticate": 'Session realm="yuanjing"'}) + + # ④ 有身份 → 放行(权限不足的 403 由框架 RBAC 判定) + return await handler(request) + + +# --------------------------------------------------------------------------- # +# 装配 +# --------------------------------------------------------------------------- # + +def _middleware_names(app): + return [getattr(m, "__name__", repr(m)) for m in (getattr(app, "middlewares", None) or ())] + + +def install(app): + """把门禁插到 app.middlewares 首位(最外层)。幂等,永不抛出。""" + out = {"layer": "auth_route_gate", "ok": False, "added": [], "error": None, + "position": None} + try: + names = _middleware_names(app) + if MIDDLEWARE_NAME in names: + out["ok"] = True + out["position"] = names.index(MIDDLEWARE_NAME) + out["added"] = [] + else: + # [R52 chain-contract] 必须插 new-style(带 __middleware_version__) + # 中间件:裸 async def 进链会被 aiohttp 按 legacy 工厂 mw(app) 调用, + # request 形参收到的是 AHApp → `request.remote` AttributeError(R48 E1)。 + app.middlewares.insert(0, _new_style(auth_route_gate_middleware)) + names = _middleware_names(app) + out["added"] = [MIDDLEWARE_NAME] + out["position"] = names.index(MIDDLEWARE_NAME) if MIDDLEWARE_NAME in names else None + out["ok"] = out["position"] == 0 + out["middlewares"] = names + if not out["ok"] and not out["error"]: + out["error"] = "not at outermost position: names=%s" % (names,) + except Exception as exc: # noqa: BLE001 + out["error"] = "%s: %s" % (type(exc).__name__, exc) + return out + + +# --------------------------------------------------------------------------- # +# 覆盖矩阵(全模块同类排查的机械口径) +# --------------------------------------------------------------------------- # + +def _scan_roots(): + """返回 [(物理根目录, URL 前缀)]:应用 wwwroot + 可选的外部模块仓库根。 + + 外部根由环境变量 YUANJING_AUTH_GATE_SCAN_ROOTS 注入(逗号分隔的目录, + 每个目录下按 <模块>/wwwroot 扫描),**脚本内不写任何绝对路径**—— + 部署机与开发工作区的目录层级不同,写死会误报「漏管」。 + """ + roots = [(os.path.join(_app_root(), "wwwroot"), "")] + extra = (os.environ.get("YUANJING_AUTH_GATE_SCAN_ROOTS") or "").strip() + for one in extra.split(","): + one = one.strip() + if not one or not os.path.isdir(one): + continue + try: + names = sorted(os.listdir(one)) + except OSError: + continue + for name in names: + ww = os.path.join(one, name, "wwwroot") + if os.path.isdir(ww): + roots.append((ww, "/" + name)) + return roots + + +def iter_wwwroot_dspy(): + """遍历 wwwroot 下全部 .dspy(含经软链挂载的各模块),产出 URL 路径。""" + found = [] + for root, prefix in _scan_roots(): + for dirpath, _dirnames, filenames in os.walk(root, followlinks=True): + for fn in filenames: + if not fn.endswith(".dspy"): + continue + full = os.path.join(dirpath, fn) + rel = os.path.relpath(full, root).replace(os.sep, "/") + found.append((prefix + "/" + rel) if prefix else ("/" + rel)) + return sorted(set(found)) + + +def coverage_matrix(paths=None): + """返回 [{path, decision, public}],用于「纳管/漏管」机械核验。""" + rows = [] + for p in (paths if paths is not None else iter_wwwroot_dspy()): + public = is_public_endpoint(p) + rows.append({ + "path": p, + "public": public, + "decision": "public" if public else "protected", + }) + return rows + + +def assert_no_wildcard(items): + """白名单禁通配符(QC 硬门禁 #6)。""" + bad = [p for p in items if "*" in str(p) or "%" in str(p)] + if bad: + raise AssertionError("wildcard not allowed in allowlist: %r" % (bad,)) + return True + + +assert_no_wildcard(tuple(PUBLIC_DSPY_PATHS) + tuple(PUBLIC_PATH_PREFIXES)) + + +# ---------------------------------------------------------------- R52 签名契约 +# 根因(R48 E1):本文件的 `auth_route_gate_middleware` 是**裸** `async def +# (request, handler)`,没有 `@web.middleware`。aiohttp 用 `__middleware_version__` +# 区分中间件风格,缺该标记的可调用对象会被当作 **legacy 工厂** 调用 `mw(app)` —— +# 应用对象被填进第一个形参(request),于是 `pkgs/ahserver/ahserver/real_ip.py:16` +# 的 `request['client_ip'] = request.remote` 抛 +# `AttributeError: 'AHApp' object has no attribute 'remote'`,全站 500。 +# 这里在**模块级**把它规范成 new-style,并在 install() 插入前再规范化一次(幂等)。 +_MCC = None + + +def _load_contract_module(): + """加载同目录 middleware_chain_contract.py(不依赖 sys.path / 包上下文)。 + + R48 E1 教训:`except ImportError: return mw` 的静默降级会让裸函数原样进链, + aiohttp 按 legacy 工厂 `mw(app)` 调用 → `real_ip.py:16 request.remote` + AttributeError,全站 500。这里改为 spec_from_file_location 按 `__file__` + 同级路径显式加载(同仓 app/scense.py 的既有写法),仍失败返回 None 并由 + 调用方显式补标记 + 记 error(绝不静默放行)。 + """ + global _MCC + if _MCC is not None: + return _MCC + try: + import middleware_chain_contract as _m + _MCC = _m + return _MCC + except ImportError: + pass + try: + from app import middleware_chain_contract as _m + _MCC = _m + return _MCC + except Exception: # noqa: BLE001 + pass + _path = os.path.join(os.path.dirname(os.path.abspath(__file__)), + "middleware_chain_contract.py") + try: + _spec = importlib.util.spec_from_file_location( + "middleware_chain_contract", _path) + if _spec is None or _spec.loader is None: + raise ImportError("spec_from_file_location 失败: %s" % _path) + _mod = importlib.util.module_from_spec(_spec) + _spec.loader.exec_module(_mod) + sys.modules.setdefault("middleware_chain_contract", _mod) + _MCC = _mod + return _MCC + except Exception as _exc: # noqa: BLE001 + log.error("[chain-contract] 无法加载 %s: %s", _path, _exc) + return None + + +def _new_style(mw): + """把中间件规范成 new-style(带 __middleware_version__)。 + + **禁止静默放行裸函数**(R48 E1 根因):规范化不可用也要显式补标记并记 + error 日志;规范化后立即自校验,不成立直接抛错而不是继续装配。 + """ + _mcc = _load_contract_module() + fixed = mw + if _mcc is not None: + try: + fixed = _mcc.ensure_middleware(mw) + except Exception as _exc: # noqa: BLE001 + log.error("[chain-contract] %s 规范化失败(%s),改用手工补 " + "new-style 标记", getattr(mw, "__name__", mw), _exc) + fixed = mw + if getattr(fixed, "__middleware_version__", None) != 1: + try: + setattr(fixed, "__middleware_version__", 1) # aiohttp 认这个标记 + log.error("[chain-contract] %s 未经契约模块规范化,已手工补 " + "__middleware_version__=1(请检查中间件链装配)", + getattr(mw, "__name__", mw)) + except Exception as _exc: # noqa: BLE001 + raise RuntimeError( + "中间件 %s 既无法规范化也无法补标记: %s" + % (getattr(mw, "__name__", mw), _exc)) + if getattr(fixed, "__middleware_version__", None) != 1: # 自校验,失败即抛 + raise RuntimeError("中间件 %s 规范化后仍缺 __middleware_version__" + % (getattr(mw, "__name__", mw),)) + return fixed + + +auth_route_gate_middleware = _new_style(auth_route_gate_middleware) diff --git a/app/_archive_disabled_20261028_151505/body_restore.py b/app/_archive_disabled_20261028_151505/body_restore.py new file mode 100644 index 0000000..0f9922e --- /dev/null +++ b/app/_archive_disabled_20261028_151505/body_restore.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""请求体「读后回填」的唯一实现(P0 崩点修复,QC 意见 #2)。 + +要解决的问题(目标机 traceback 原文已写进本迭代 P0 单 _HCoWd3He1axFKKa8ae91) +-------------------------------------------------------------------------- +`app/http_semantics_middleware.py` 的 `restore_request_body()` 为了让我方读完 body 后 +下游 `processorResource.getPostData()` 还能再解析一次,做了: + + from aiohttp.payload import BytesPayload + request._payload = BytesPayload(body) + +在 aiohttp 3.10.10 里 `Payload` / `BytesPayload` **没有** `at_eof()` 方法,而 +`aiohttp.web_request.BaseRequest.can_read_body` 的实现是: + + return not self._payload.at_eof() + +于是本中间件一旦回填过 `_payload`,下游任何一次 `can_read_body` 读取都会抛 +`AttributeError: 'BytesPayload' object has no attribute 'at_eof'` → 该请求 **HTTP 500**。 +影响面:全站 `*.dspy` 500 = 37/215,其中 POST/PUT 写类端点(CRUD `add_*` / +`create_*` / `entity_create` / `runtime_control` / `add_world` 等 42 条)必崩, +本迭代的 500 门禁(须为 0)不可能清零。 + +两种改法(QC 给了 A1/A2 二选一) +------------------------------ +* A1:完全不替换 `_payload`,只保留 `request._read_bytes` 缓存,并把所有下游读取点 + 改为优先走缓存。**问题**:下游读取点在基座 `ahserver.processorResource`(本应用 + `referenced_modules`,按 project-directory-spec §三「引用模块写保护」只读, + 改了会破坏所有依赖 ahserver 的项目),本角色无权修改。 +* A2(本文件采用):改用 `aiohttp.streams.StreamReader` + `feed_data/feed_eof` + 重建 `_payload`。`StreamReader` **有** `at_eof()`(`self._eof and not self._data`), + 因此 `can_read_body` 正常返回 bool;同时新流未 EOF 前可被 `post()/multipart()` + 重新消费,等价于原意图。 + +三条硬约束 +---------- +1. **绝不构造 `BytesPayload`**:构造失败/类型不可用时,宁可**不碰** `_payload` + (原 `_payload` 本身就是 `StreamReader`,有 `at_eof`),也绝不塞一个没有 + `at_eof` 的对象进去——把 AttributeError 崩点换成"下游读不到 body"是倒退。 +2. 回填同时保留(并写回)`request._read_bytes`,让 `await request.read()` 幂等。 +3. 本模块**顶层不 import aiohttp**:aiohttp 只在函数内部按需导入,因此可以脱离 + web 运行时做离线自测(见 `scripts/selftest_body_restore_r50.py`)。 +""" + +from __future__ import annotations + +import logging + +log = logging.getLogger("yuanjing.body_restore") + +#: 回填结果码 +RESTORED = "restored" # _payload 已用 StreamReader 重建,下游可重读 +CACHE_ONLY = "cache_only" # 只写了 _read_bytes 缓存,未碰 _payload(安全降级) +SKIPPED_EMPTY = "skipped_empty" # body 为空,无需回填 +FAILED = "failed" # 回填抛异常(已吞异常,仅告警) + + +def _make_stream_reader(body, template=None, loop=None): + """构造一个已喂入 body 并到达 EOF 的 aiohttp StreamReader。 + + 返回 (reader, "") 或 (None, reason)。构造成功后必须满足: + * 有 `at_eof()`(`can_read_body` 不再 AttributeError); + * `at_eof()` 在 EOF 后返回 True(不谎报"还有 body 可读")。 + """ + try: + from aiohttp.streams import StreamReader + except Exception as exc: # noqa: BLE001 + return None, "no aiohttp.streams.StreamReader: %s" % exc + kwargs = {} + # 尽量沿用原 payload 的分片/上限/事件循环参数,避免行为漂移 + for attr, key in (("_limit", "limit"), ("_chunk", "chunk"), ("_timer", "timer"), + ("_loop", "loop")): + val = getattr(template, attr, None) if template is not None else None + if val is None and key == "loop": + val = loop + if val is not None: + kwargs[key] = val + try: + reader = StreamReader(**kwargs) + except Exception: # noqa: BLE001 + # 参数签名随版本变化:退回到无参构造( aiohttp 全版本支持) + try: + reader = StreamReader() + except Exception as exc: # noqa: BLE001 + return None, "StreamReader() failed: %s" % exc + try: + reader.feed_data(body) + reader.feed_eof() + except Exception as exc: # noqa: BLE001 + return None, "feed_data/feed_eof failed: %s" % exc + if not callable(getattr(reader, "at_eof", None)): + return None, "constructed reader has no at_eof()" + # 自检:at_eof() 必须**可安全求值并返回 bool**(这正是线上 500 的位置)。 + # 注意:喂入 body + feed_eof 之后 at_eof() 仍应为 False(body 未被消费), + # 这正是让下游 `can_read_body=True → read()` 能重读到 body 的语义。 + try: + if not isinstance(reader.at_eof(), bool): + return None, "at_eof() did not return bool" + except Exception as exc: # noqa: BLE001 + return None, "at_eof() raised: %s" % exc + return reader, "" + + +def restore_request_payload(request, body): + """把已读走的 body 安全回填给下游。返回 (ok: bool, code: str)。 + + 安全顺序(任一步失败都不影响前面已完成的步骤): + 1. 写 `request._read_bytes`(aiohttp 自己的读缓存,重复读幂等); + 2. 用 `StreamReader` 重建 `request._payload`(A2 方案,有 at_eof); + 3. 第 2 步不可用时**保持 `_payload` 原样**(= CACHE_ONLY 降级), + 绝不写 `BytesPayload`(那正是 P0 500 的崩点)。 + """ + if not body: + return True, SKIPPED_EMPTY + if not isinstance(body, (bytes, bytearray)): + return False, FAILED + + # 1) 读缓存(一定先做:即使后面 payload 重建失败,handler 走 read() 仍有内容) + cache_ok = False + try: + request._read_bytes = bytes(body) + cache_ok = True + except Exception as exc: # noqa: BLE001 + log.warning("[body_restore] 写 _read_bytes 失败 path=%s: %s", + getattr(request, "path", ""), exc) + + # 2) 用 StreamReader 重建 _payload + template = None + loop = None + try: + template = getattr(request, "_payload", None) + except Exception: # noqa: BLE001 + template = None + try: + loop = getattr(request, "_loop", None) or getattr(template, "_loop", None) + except Exception: # noqa: BLE001 + loop = None + reader, reason = _make_stream_reader(bytes(body), template=template, loop=loop) + if reader is None: + # 3) 安全降级:**不碰** _payload + log.warning("[body_restore] 跳过 _payload 重建(保持原流,避免 at_eof 缺失)" + "path=%s reason=%s cache_ok=%s", + getattr(request, "path", ""), reason, cache_ok) + return (cache_ok, CACHE_ONLY) + + try: + request._payload = reader + # 让 aiohttp 的 post()/multipart() 重新解析(缓存的旧解析结果已不属于新流) + for attr in ("_post_params", "_body_attrs_set"): + try: + if getattr(request, attr, None) is not None: + setattr(request, attr, None) + except Exception: # noqa: BLE001 + pass + try: + request._reading = False + except Exception: # noqa: BLE001 + pass + except Exception as exc: # noqa: BLE001 + log.warning("[body_restore] 赋 _payload 失败 path=%s: %s", + getattr(request, "path", ""), exc) + return (cache_ok, CACHE_ONLY) + + # 自检:回填后 can_read_body 必须可安全求值(这正是线上 500 的位置) + try: + request.can_read_body + except Exception as exc: # noqa: BLE001 + log.error("[body_restore] 回填后 can_read_body 仍抛异常 path=%s: %s " + "→ 回退为仅缓存", getattr(request, "path", ""), exc) + try: + if template is not None: + request._payload = template + except Exception: # noqa: BLE001 + pass + return (cache_ok, CACHE_ONLY) + return True, RESTORED + + +def describe(request): + """诊断串(不含任何 body 内容/凭据,只有类型名),用于日志与自测。""" + payload = getattr(request, "_payload", None) + return "payload_type=%s has_at_eof=%s read_bytes_len=%s" % ( + type(payload).__name__, + callable(getattr(payload, "at_eof", None)), + len(getattr(request, "_read_bytes", b"") or b""), + ) diff --git a/app/_archive_disabled_20261028_151505/business_status_middleware.py b/app/_archive_disabled_20261028_151505/business_status_middleware.py new file mode 100644 index 0000000..fd60951 --- /dev/null +++ b/app/_archive_disabled_20261028_151505/business_status_middleware.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""业务错误码 → HTTP 状态行 回写(Bug DGzTjOkr_OYM4vdOYcl2_ / F12 / F16 / **FnRUGqsY7 R18**)。 + +问题 +---- +模块与生成器产出的 .dspy 端点把「缺必填参数 / 校验失败」写成**响应信封** +`{"status": 400, "success": false, "message": "缺少必填参数: name(表 world)"}`, +但 ahserver 序列化该 dict 时 HTTP 状态行仍是 200。R18 复测实测(部署态,带 cookie): + + HTTP=200 /world/api/create_world.dspy BODY={"status":400,...} + HTTP=200 /scene/api/create_scene.dspy BODY={"status":400,...} + HTTP=200 /entity/api/entity_delete.dspy BODY={"status":400,...} + HTTP=200 /entity/api/entity_create.dspy BODY={"status":400,...} + HTTP=200 /world/api/create_world.dspy name=rt_probe_world_232348 BODY={"status":200,...} ← 正例 + +R18 结论(本轮根因,**不是**回写逻辑本身错,而是装配层又一次没生效) +------------------------------------------------------------------ +上一轮只把它做成 aiohttp **middleware**,靠 `app/notfound_middleware.compose_guard_chain()` +用 `__import__("app." + 模块名)` 动态导入再串链。部署态进程是 `python app/scense.py` +(cwd=部署目录,sys.path 不含应用根),`app` 包导入静默失败 → `compose_guard_chain` +抛 RuntimeError → 入口**降级为只做 404 异常映射**,业务 400 回写层整层消失: + + log: [semantics] 组合链装配失败,降级为仅 404 异常映射: 语义中间件不可导入: ... + +因此本轮在原有 middleware 之外新增**装配无关的第二条件路径**:aiohttp 的 +`on_response_prepare` 信号(`app.on_response_prepare.append(hook)`)。该信号在响应头 +发出前逐请求同步触发,**不依赖中间件链、不依赖包导入**,任何 Response 类型(含 +ahserver 自定义 Response 子类、StreamResponse)都能命中;入口只需在拿到 aiohttp app +的那一刻调用一次 `install_business_status_signal(app)`(见 `app/scense.py`、 +`app/yuanjing.py` 的 `_install_business_status_signal`,按**绝对文件路径**加载本模块, +避开包导入陷阱)。 + +设计约束(不掩错) +------------------ +1. **只回写 .dspy 端点的 JSON 信封**:路径不以 .dspy 结尾(.ui / .js / .css / .html) + 一律不碰,避免改变页面渲染语义。 +2. **只在 HTTP 200 时改写**:handler / 内层中间件已经给出 4xx/5xx 状态行时不覆盖 + (不降低语义,也不会把 401/404 改坏)。 +3. **只认信封里的整型 status 且取值在 ERR_ENVELOPE_CODES 内**:400/401/403/404/405/409/422。 + 其它键(如 {"ok": true, "status": "running"} 这类业务枚举字符串)不参与判定 —— + 不会把正常业务数据误判成错误;`{"status":200,"success":true,"id":...}` 正例不触发。 +4. **不改响应体**:信封原样返回,前端可继续读 status/success/message/missing。 +5. 解析失败(非 JSON / 大 body / 无 body)一律原样放行,**不因回写层自身异常影响业务响应**。 +6. 幂等:信号重复安装按函数对象去重;middleware 与信号双层同时存在也只回写一次 + (第一次回写后 response.status 已非 200,第二层自然跳过)。 +""" + +import json +import logging + +from aiohttp import web + +log = logging.getLogger("yuanjing.business_status") + +# 信封里出现这些 status 值即认为「业务错误码」,需回写到 HTTP 状态行 +ERR_ENVELOPE_CODES = frozenset((400, 401, 403, 404, 405, 409, 422)) + +# 只有 .dspy 端点参与回写(.ui 页面里的 status 是业务字段,不改) +REWRITE_SUFFIXES = (".dspy",) + +# 超过该长度的 body 不做解析(性能保护;正常业务信封远小于此) +MAX_BODY_BYTES = 1024 * 1024 + +# 内容类型白名单关键字:ahserver 的 dspy 处理器可能给 application/json, +# 也可能给 text/plain、application/octet-stream(json.dumps 字符串直出)——都要认, +# R18 根因之一就是旧版只认 json/text 导致部分 Response 被跳过。 +_CONTENT_TYPE_OK = ("json", "text", "octet-stream", "") + + +def _should_rewrite(request): + path = getattr(request, "path", "") or "" + return path.endswith(REWRITE_SUFFIXES) + + +def _response_body_bytes(response): + """取响应体字节(兼容 Response(body=) / Response(text=) / 自定义子类)。""" + body = getattr(response, "body", None) + if isinstance(body, bytes): + return body + if isinstance(body, bytearray): + return bytes(body) + if isinstance(body, str): + return body.encode("utf-8", "replace") + text = getattr(response, "text", None) + if isinstance(text, str): + return text.encode("utf-8", "replace") + return None + + +def _envelope_status(response): + """从响应对象解析出需要回写的 HTTP 状态码;不需要回写时返回 None。""" + body = _response_body_bytes(response) + if not body or len(body) > MAX_BODY_BYTES: + return None + text = body.decode("utf-8", "replace").strip() + if not text.startswith("{"): + return None + try: + payload = json.loads(text) + except Exception: # noqa: BLE001 + return None + if not isinstance(payload, dict): + return None + code = payload.get("status") + if isinstance(code, bool) or not isinstance(code, int): + return None + if code not in ERR_ENVELOPE_CODES: + return None + return code + + +def _content_type_ok(response): + ctype = (getattr(response, "content_type", None) or "").lower() + return any(k in ctype for k in _CONTENT_TYPE_OK) + + +def apply_business_status(request, response): + """把业务错误信封的 status 回写到 HTTP 状态行(幂等、只升不降)。 + + :return: True=已回写,False=未命中 + """ + if not _should_rewrite(request): + return False + if response is None: + return False + if getattr(response, "prepared", False): + return False + if getattr(response, "status", None) != 200: + return False + if not _content_type_ok(response): + return False + code = _envelope_status(response) + if code is None: + return False + try: + if hasattr(response, "set_status"): + response.set_status(code) + else: # 极简自定义 Response + response.status = code + except Exception as exc: # noqa: BLE001 + # 状态行改不动也不掩错:记录后原样返回(HTTP 仍 200,body 信封不变) + log.error("[business_status] set_status 失败 %s: %s", code, exc) + return False + log.warning("[http-%d] 业务信封 status 回写状态行: %s %s", + code, getattr(request, "method", "-"), getattr(request, "path", "-")) + return True + + +@web.middleware +async def business_status_middleware(request, handler): + """中间件路径(构造期装配可达时生效;与信号层等价、幂等)。""" + response = await handler(request) + try: + apply_business_status(request, response) + return response + except Exception as exc: # noqa: BLE001 + log.error("[business_status] 回写失败,保持原响应 %s: %s", + getattr(request, "path", "-"), exc) + return response + + +async def business_status_on_response_prepare(request, response): + """`on_response_prepare` 信号处理器(**装配无关**的第二条生效路径,R18 新增)。""" + try: + apply_business_status(request, response) + except Exception as exc: # noqa: BLE001 + log.error("[business_status] 信号回写失败,保持原响应 %s: %s", + getattr(request, "path", "-"), exc) + + +def install_business_status_signal(app): + """在 aiohttp Application 上挂 `on_response_prepare` 信号(幂等)。 + + :param app: aiohttp.web.Application + :return: dict(ok=bool, hook=str, reason=str) + """ + try: + hook = getattr(app, "on_response_prepare", None) + if hook is None: + return {"ok": False, "hook": "on_response_prepare", + "reason": "app 无 on_response_prepare 信号(非 aiohttp Application?)"} + if business_status_on_response_prepare in list(hook): + return {"ok": True, "hook": "on_response_prepare", "reason": "already installed"} + hook.append(business_status_on_response_prepare) + log.info("[init] 业务信封→HTTP 状态行 信号层已装配(on_response_prepare)") + return {"ok": True, "hook": "on_response_prepare", "reason": "installed"} + except Exception as exc: # noqa: BLE001 + log.error("[business_status] 信号装配失败: %s", exc) + return {"ok": False, "hook": "on_response_prepare", "reason": str(exc)} + + +# ---------------------------------------------------------------- R52 签名契约 +# 根因(R48 E1):本模块中间件虽已 `@web.middleware` 装饰,但「装饰被漏/被覆盖」 +# (例如有人新增未装饰的中间件、或以裸函数再导出)就会让 aiohttp 按 legacy 工厂 +# `mw(app)` 调用 → request 形参收到 AHApp → `real_ip.py:16` `request.remote` +# AttributeError → 全站 500。这里在**模块级**再幂等规范化一次,把契约钉死在提供方。 +def _ensure_new_style(mw, name=None): + """幂等把中间件规范成 new-style(带 __middleware_version__);不可用时原样返回。""" + try: + from middleware_chain_contract import ensure_middleware as _ensure_mw + except ImportError: + try: + from app.middleware_chain_contract import ensure_middleware as _ensure_mw + except ImportError: + return mw + try: + return _ensure_mw(mw, name=name) + except Exception: # noqa: BLE001 + return mw + + +business_status_middleware = _ensure_new_style(business_status_middleware) diff --git a/app/_archive_disabled_20261028_151505/entry_guard_r19.py b/app/_archive_disabled_20261028_151505/entry_guard_r19.py new file mode 100644 index 0000000..1836f3d --- /dev/null +++ b/app/_archive_disabled_20261028_151505/entry_guard_r19.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""R19 运行入口硬守卫链(Bug nrOjOBSxzsHGa1HgElzmn / DGzTjOkr_OYM4vdOYcl2_ / +WhOYy9nvG-EfNgZMvbJbO 的**运行入口根因修复**)。 + +## 为什么还要这个文件(根因结论,不是又写一遍语义实现) + +三条语义的实现早就存在于仓库里: + + /healthz 非 GET → 405 + Allow app/entry_semantics.py (BUILTIN_GET_ONLY_PATHS) + 业务信封 status>=400 → HTTP 状态行 app/business_status_middleware.py (middleware + 信号) + 非公开 .dspy 无会话 → 401 app/auth_gate_strict.py / route_guard_middleware.py + +但目标机真实运行入口是 `app/scense.py`(`ps -ef` 唯一进程 `venv/bin/python app/scense.py`), +R10 复测 5/5 全部在部署态失效(HTTP=404 / 200 / 503 / 200)。三处装配缺口: + +1. **404 先于 405**:`entry_semantics` 的判定顺序是「资源是否存在」→ 再判方法。 + `/healthz` 是 `app.router.add_get()` 注册的**动态路由**,不在 wwwroot 文件树里, + 于是「资源不存在」分支先命中 → 404 no such resource,`BUILTIN_GET_ONLY_PATHS` 的 + 405 分支根本走不到。`/healthz` 自检里 `http_semantics.installed=true, added=2` + 指的是**装配两层**(`entry_semantics` 零依赖层 + `semantic_wiring` 完整层)成功, + 不是「405 分支命中」,因此自检为真而行为为假。 +2. **业务 400 层缺席**:`business_status_middleware` 从未进入 + `app/scense.py::_load_semantic_middlewares()` 的构造期清单(清单里只有 + notfound / http_semantics / auth_gate_strict 三项)。信号层 + `_install_business_status_signal` 挂在 `_on_ahapp_built` 钩子里,而构造期路径 + `entry_semantics_ctor.make_app(mws)` 成功时钩子只做幂等核验,不保证 `on_response_prepare` + 被挂到最终 aiohttp Application 上;钩子与中间件两条路径**都不在链上** → 4/4 端点 + 缺参仍 HTTP 200(body 里 status=400)。 +3. **鉴权闸门不在链上**:同上,`auth_gate_strict_middleware` 虽然在清单里,但装配走 + `entry_semantics_ctor` 构造期 + `notfound_middleware.compose_guard_chain()` 包导入 + `__import__("app." + 模块名)`,部署态 `python app/scense.py` 的 sys.path 不含应用根 + → 包导入静默失败 → 组合链降级「仅 404」→ 匿名 `GET /scense/api/list_scense.dspy` + 返回 200(同轮 `session_create.dspy` 返 401,说明闸门代码本身没坏,是没上链)。 + +## 本文件的设计要点(为什么这样修才在运行入口生效) + +* **零应用包依赖**:只 import 标准库 + aiohttp + 同目录文件按**绝对路径**加载 + (`auth_gate_strict.py` 复用已验证的会话校验),彻底绕开 sys.path 陷阱; +* **顺序即语义**:作为 `app.middlewares` 的**第 1 位(最外层)**装配, + 「方法不允许」与「未登录」在业务处理器/404 判定之前返回,天然修复 404 抢答; +* **不掩错**:只处理 405 / 401 / 业务信封 4xx 回写三类;其它异常一律 re-raise, + 真实后端缺陷仍暴露为 500。 +""" +import importlib.util +import json +import logging +import os +import urllib.parse + +from aiohttp import web + +log = logging.getLogger("yuanjing.entry_guard_r19") + +APP_DIR = os.path.dirname(os.path.abspath(__file__)) + +#: 内建「只允许读」的路径(精确匹配,禁通配)。新增内建 GET 端点必须逐条登记。 +GET_ONLY_PATHS = frozenset(( + "/healthz", + "/health", +)) + +#: 405 时下发的 Allow 头(清单顺序固定,便于断言)。 +ALLOW_HEADER = "GET, HEAD, OPTIONS" + +#: 视为「读」的安全方法——非此集合即方法不允许。 +SAFE_METHODS = frozenset(("GET", "HEAD", "OPTIONS")) + +#: 业务信封回写只作用于 .dspy(页面/静态资产语义不动)。 +ENVELOPE_SUFFIX = ".dspy" + + +# ---------------------------------------------------------------- 纯逻辑(可离线桩测) +def normalize_path(path): + """归一化路径:去掉重复斜杠 / 尾斜杠 / 查询串,并解 URL 编码。 + + 与 `auth_gate_strict.normalize_path` 同口径,保证 `/HEALTHZ/`、 + `//healthz`、`/healthz?x=1` 都能命中 GET_ONLY_PATHS,不能靠写法绕过 405。 + """ + p = urllib.parse.unquote(str(path or "")) + p = p.split("?", 1)[0].split("#", 1)[0] + segs = [s for s in p.split("/") if s] + p = "/" + "/".join(segs) + return p.lower() if p != "/" else p + + +def method_not_allowed(path, method): + """(path, method) 是否需要 405。返回 Allow 头字符串;不需要则 None。 + + 判定在**任何资源存在性检查之前**完成——这是本文件相对 entry_semantics 的 + 关键差异(后者先判存在性,动态路由 /healthz 被判成「不存在」→ 404)。 + """ + p = normalize_path(path) + if p not in GET_ONLY_PATHS: + return None + if str(method or "").upper() in SAFE_METHODS: + return None + return ALLOW_HEADER + + +def envelope_status(raw_body): + """从响应体解析业务信封 status(只认 JSON 对象的 4xx/5xx 整数 status)。""" + if not raw_body: + return None + try: + if isinstance(raw_body, bytes): + raw_body = raw_body.decode("utf-8", "replace") + obj = json.loads(raw_body) + except (TypeError, ValueError): + return None + if not isinstance(obj, dict): + return None + code = obj.get("status") + if isinstance(code, bool): + return None + if isinstance(code, int) and 400 <= code <= 599: + return code + return None + + +def should_rewrite_status(path, http_status, raw_body): + """是否需要把 HTTP 状态行回写成信封 status。返回目标码或 None。""" + p = normalize_path(path) + if not p.endswith(ENVELOPE_SUFFIX): + return None + if int(http_status or 0) != 200: + return None + return envelope_status(raw_body) + + +# ---------------------------------------------------------------- 会话闸门(复用已验证实现) +_GATE = {"mod": None} + + +def _auth_gate(): + """按绝对路径加载 auth_gate_strict.py(部署态 sys.path 不含应用根,禁包导入)。""" + if _GATE["mod"] is not None: + return _GATE["mod"] + path = os.path.join(APP_DIR, "auth_gate_strict.py") + spec = importlib.util.spec_from_file_location("yj_auth_gate_strict_r19", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + _GATE["mod"] = mod + return mod + + +def auth_verdict(path, request): + """鉴权判定:返回 "public" / "allow" / "deny"。 + + 与 auth_gate_strict 同一套公开清单与会话校验(精确路径,禁通配); + 任何异常都 fail-closed 成 deny(绝不 fail-open)。 + """ + try: + gate = _auth_gate() + if not gate.gate_enabled(): + return "public" + if gate.is_public_path(path): + return "public" + if not gate.is_gated_path(path): + return "public" + # [R21 401 根因补修] auth_gate_strict._has_session() 返回的是 + # **(ok, reason) 元组**,不是布尔。原写法 `"allow" if gate._has_session(request)` + # 让任何非空元组(含 (False, "no_session"))都为真 → 匿名请求被判 allow, + # 401 在入口层彻底失效(即便会话校验实现正确)。此处显式取第 0 位布尔, + # 对列表/字典/布尔三种返回形态都做归一,绝不依赖对象真值。 + verdict = gate._has_session(request) + if isinstance(verdict, (tuple, list)): + ok = bool(verdict[0]) if verdict else False + elif isinstance(verdict, dict): + ok = bool(verdict.get("ok")) + else: + ok = bool(verdict) + return "allow" if ok else "deny" + except Exception as exc: # noqa: BLE001 + log.error("[guard19] 鉴权判定异常,fail-closed 401: %s", exc) + return "deny" + + +# ---------------------------------------------------------------- 中间件本体 +def _json_response(status, payload, extra_headers=None): + headers = {"Cache-Control": "no-store"} + if extra_headers: + headers.update(extra_headers) + return web.json_response(payload, status=status, headers=headers) + + +@web.middleware # ← aiohttp>=3.9 新式中间件标记(见下方 R19d 说明) +async def entry_guard_middleware(request, handler): + """入口硬守卫:405(方法语义)→ 401(会话闸门)→ 业务信封 4xx 回写。 + + [fix-iter17 r19d] 必须带 `@web.middleware`(= 设置 `__middleware_version__ = 1`)。 + aiohttp >= 3.9(requirements 锁 `aiohttp>=3.9,<4.0`)在 + `Application._prepare_middleware()` 里按该标记区分新旧式:**未标记的普通协程函数 + 会被当成「旧式工厂」** m(app, handler) 调用,于是本函数收到 request=app、 + handler=真实 handler,`await handler(request)` 抛 + `TypeError: 'Response' object is not callable` → 每个请求 500; + /healthz 的 405 退化由 aiohttp 路由自身给出(Allow 只有 GET,HEAD,body 为 + `405: Method Not Allowed` 纯文本),闸门与信封回写整层失效。 + 实测证据(本机真实执行回显,非引用): + projects/proj_qEVGJSL3/docs/02-develop/evidence/r19_entry_assembly_verify_20261026.txt + (由 apps/yuanjing/scripts/verify_entry_assembly_r19.py 生成,含 TOTAL=/PASS=/FAIL= 行) + """ + path = getattr(request, "path", "") or "" + + # ① 方法语义(先于 404/鉴权;/healthz 等内建只读路径非读方法 → 405 + Allow) + # + # [QC#7] 原写法 `if allow == "GET, HEAD, OPTIONS"` 把返回值退化成布尔信号, + # 与 ALLOW_HEADER 形成两处字面量(改 ALLOW_HEADER 后判定静默漂移),且后面 + # 再判一次 OPTIONS(method_not_allowed 已用 SAFE_METHODS 排除了 OPTIONS/GET/HEAD)。 + # 现在:返回值即单一真源——None = 不需要 405,非 None = 需要 405 且它就是 Allow 头。 + allow = method_not_allowed(path, getattr(request, "method", "")) + if allow: + return _json_response( + 405, + {"status": 405, "success": False, "reason": "method not allowed", + "path": normalize_path(path), "allow": allow}, + {"Allow": allow}, + ) + + # ② 会话闸门(非公开 .dspy 无会话 → 401,fail-closed) + verdict = auth_verdict(path, request) + if verdict == "deny": + return _json_response( + 401, + {"status": 401, "success": False, "reason": "unauthorized", + "path": normalize_path(path)}, + ) + + # ③ 业务信封 status>=400 → HTTP 状态行(只改状态行,不改 body) + resp = await handler(request) + try: + target = should_rewrite_status(path, getattr(resp, "status", 200), + getattr(resp, "body", None)) + if target is not None and hasattr(resp, "set_status"): + resp.set_status(target) + log.info("[guard19] 业务信封 status=%s → HTTP %s path=%s", + target, target, normalize_path(path)) + except Exception as exc: # noqa: BLE001 + log.warning("[guard19] 状态行回写失败(不影响 body): %s", exc) + return resp diff --git a/app/_archive_disabled_20261028_151505/entry_semantics.py b/app/_archive_disabled_20261028_151505/entry_semantics.py new file mode 100644 index 0000000..7587d3d --- /dev/null +++ b/app/_archive_disabled_20261028_151505/entry_semantics.py @@ -0,0 +1,599 @@ +#!/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 候选根(页面存在性判定用):/wwwroot(本文件在 /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/` 是软链,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 = """ + + +唤景平台 + + +
唤景平台 · 世界模式
+
+
+

页面渲染降级 degraded-ui

+

本页原始 .ui 渲染链发生异常,已由入口语义层兜底为可用界面(HTTP 200), + 服务与 API 不受影响。异常堆栈已完整落盘(logs/ui_render_errors.log),供继续定位。

+ +
+
+ +""" + + 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 diff --git a/app/_archive_disabled_20261028_151505/entry_semantics_boot.py b/app/_archive_disabled_20261028_151505/entry_semantics_boot.py new file mode 100644 index 0000000..bd626c1 --- /dev/null +++ b/app/_archive_disabled_20261028_151505/entry_semantics_boot.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""入口 HTTP 语义健壮装配引导(Bug Wp6gXrrEquzg3njONz60b / INT-404-neg)。 + +缺陷(本轮复现,2026-10-21 14:21 目标机实测,带 admin cookie) +-------------------------------------------------------------- +``` +GET /runtime/api/__no_such_endpoint__.dspy -> 500 (期望 404) +GET /nonexist/x.dspy -> 500 (期望 404) +(无 cookie 对照同两条 -> 401,说明鉴权层正常,异常出在鉴权之后) +``` +ahserver `processorResource.py:399` 对「URL 无法映射到磁盘资源」抛的是**通用 `Exception`** +(`f'{str(request.url)=} invalid path'`),没有任何 HTTP 语义,一路冒到 aiohttp 默认错误处理, +被渲染成 `500 Internal Server Error / Server got itself in trouble`。 + +R17-2(Bug 28PuqnfpWzB2ypJoUTU_N)已在 `app/semantic_wiring.py` 写好 404/405/400 中间件,并由 +`app/scense.py` 在 `ahapp_built` 钩子里装配。但那条装配链路有一个**结构性静默失败点**: + + try: + from app.semantic_wiring import install_semantic_middlewares # ← 依赖 app 包可导入 + install_semantic_middlewares(app, entry="app/scense.py") + except Exception: + log.exception(...) # 只打日志,进程照常起,/healthz 照常 200 + +只要 `app` 包在目标机不可导入(`python app/scense.py` 的 sys.path[0]=/app 遮蔽、 +投递缺文件、pyc 过期、包名冲突),语义中间件就**一个都装不上**,而系统看起来「全部正常」, +线上 500 原样存在——这正是 R17-2「修了但等于没生效」的机制。 + +本模块的职责 +------------ +把装配做成**与 app 包可导入性无关**的两层结构,并让装配结果可远程核验: + +* 第 1 层 `entry_semantics`(零应用内依赖):用**绝对文件路径 + importlib** 加载 + `app/entry_semantics.py`,装 notfound_guard(→404)+ method_guard(→405)。 + 这一层不 import 本应用任何包,只要文件在投递包里就一定能装上。 +* 第 2 层 `semantic_wiring`(完整语义):同样按绝对路径加载,装 + `resource_notfound_middleware` + `http_semantics_middleware`(含 400 参数缺失语义)。 + 失败仅降级为「第 1 层单层生效」,不再影响 404 语义。 + +返回结构(供入口写进 app["http_semantics"] 并由 `/healthz` 暴露):: + + {"installed": bool, # 至少一层装上且含 notfound 守卫 + "degraded": bool, # 仅第 1 层生效(完整语义未装上) + "layers": {"entry_semantics": {...}, "semantic_wiring": {...}}, + "middlewares": [...], "added": int, "error": str|None} + +铁律:**绝不抛出**——装配点在 ahapp_built 钩子里,抛错会让 run_app 失败、整机启不来。 +""" + +import datetime +import importlib.util +import json +import logging +import os +import traceback + +log = logging.getLogger("scense.entry.semantics_boot") + +APP_DIR = os.path.dirname(os.path.abspath(__file__)) + +#: 零应用内依赖层(404 + 405) +LAYER_ZERO_DEP = "entry_semantics" +#: 完整语义层(404 + 405 + 400) +LAYER_FULL = "semantic_wiring" + +#: 判定「notfound 守卫是否生效」的中间件名集合(两层任一名命中即算生效) +LAYER_AUTH_GATE = "auth_route_gate" +AUTH_GATE_NAMES = ("auth_route_gate_middleware",) +NOTFOUND_GUARD_NAMES = ("entry_notfound_guard", "resource_notfound_middleware") +METHOD_GUARD_NAMES = ("entry_method_guard", "http_semantics_middleware") + + +def middleware_names(app): + """取当前 app.middlewares 的函数名列表(用于装配后置核验)。""" + return [getattr(m, "__name__", repr(m)) for m in (getattr(app, "middlewares", None) or [])] + + +def load_module_by_file(name, filename): + """按绝对文件路径加载模块(不依赖 sys.path / 包导入)。 + + `name` 只作为 sys.modules 里的键;加载失败抛异常,由调用方降级处理。 + """ + path = os.path.join(APP_DIR, filename) + if not os.path.isfile(path): + raise OSError("模块文件不存在: %s" % path) + spec = importlib.util.spec_from_file_location(name, path) + if spec is None or spec.loader is None: + raise ImportError("无法为 %s 构造 import spec" % path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def _install_zero_dep(app, web=None): + """第 1 层:entry_semantics(零应用内依赖,绝对路径加载)。""" + res = {"layer": LAYER_ZERO_DEP, "ok": False, "added": [], "error": None} + try: + mod = load_module_by_file("_entry_semantics_zero_dep", "entry_semantics.py") + except Exception as exc: # noqa: BLE001 + res["error"] = "%s: %s" % (type(exc).__name__, exc) + res["traceback"] = traceback.format_exc() + return res + try: + out = mod.install(app, web=web) if web is not None else mod.install(app) + res["ok"] = bool(out.get("ok")) + res["added"] = list(out.get("added") or []) + res["error"] = out.get("error") + except Exception as exc: # noqa: BLE001 + res["error"] = "%s: %s" % (type(exc).__name__, exc) + res["traceback"] = traceback.format_exc() + return res + + +def _install_full(app, entry="app/scense.py"): + """第 2 层:semantic_wiring(完整 404/405/400 语义,绝对路径加载)。""" + res = {"layer": LAYER_FULL, "ok": False, "added": [], "error": None, "evidence": None} + try: + mod = load_module_by_file("_semantic_wiring_full", "semantic_wiring.py") + except Exception as exc: # noqa: BLE001 + res["error"] = "%s: %s" % (type(exc).__name__, exc) + res["traceback"] = traceback.format_exc() + return res + try: + out = mod.install_semantic_middlewares(app, entry=entry) or {} + res["ok"] = bool(out.get("ok")) + res["added"] = list(out.get("added") or out.get("added_names") or []) + res["error"] = out.get("error") + res["evidence"] = out.get("evidence") + except Exception as exc: # noqa: BLE001 + res["error"] = "%s: %s" % (type(exc).__name__, exc) + res["traceback"] = traceback.format_exc() + return res + + +def _install_auth_route_gate(app): + """第 3 层:统一鉴权门禁(存在性→鉴权,404/401 三态互不覆盖)。 + + 绝对路径加载 auth_route_gate.py(零应用内依赖),插到中间件链**最外层**, + 保证先于 ahserver 鉴权层执行(否则框架自身对不存在路径的 401 会抢在 404 之前)。 + 永不抛出。 + """ + res = {"layer": LAYER_AUTH_GATE, "ok": False, "added": [], "error": None, + "position": None} + try: + mod = load_module_by_file("_auth_route_gate", "auth_route_gate.py") + except Exception as exc: # noqa: BLE001 + res["error"] = "%s: %s" % (type(exc).__name__, exc) + res["traceback"] = traceback.format_exc() + return res + try: + out = mod.install(app) or {} + res["ok"] = bool(out.get("ok")) + res["added"] = list(out.get("added") or []) + res["position"] = out.get("position") + res["error"] = out.get("error") + res["middlewares"] = out.get("middlewares") + except Exception as exc: # noqa: BLE001 + res["error"] = "%s: %s" % (type(exc).__name__, exc) + res["traceback"] = traceback.format_exc() + return res + + +def _install_http_semantics_impl(app, entry="app/scense.py", full=True, web=None): + """两层装配实现(内部函数,勿直接调用;对外入口是文件末尾的 + install_http_semantics 包装器,它在此基础上追加 evidence 落盘)。 + + **永不抛出**。 + + 幂等:两层安装器自身按中间件名字去重,重复调用不会重复 append。 + """ + result = {"entry": entry, "installed": False, "degraded": False, + "layers": {}, "middlewares": [], "added": 0, "error": None} + try: + before = middleware_names(app) + + zero = _install_zero_dep(app, web=web) + result["layers"][LAYER_ZERO_DEP] = {k: v for k, v in zero.items() if k != "traceback"} + if not zero["ok"]: + log.error("[semantics_boot] 零依赖层装配失败:%s", zero.get("error")) + + if full: + fll = _install_full(app, entry=entry) + result["layers"][LAYER_FULL] = {k: v for k, v in fll.items() if k != "traceback"} + if not fll["ok"]: + log.error("[semantics_boot] 完整语义层装配失败(降级为 404/405 单层):%s", + fll.get("error")) + else: + result["layers"][LAYER_FULL] = {"layer": LAYER_FULL, "ok": False, + "skipped": True, "error": "switch_disabled"} + + gate = _install_auth_route_gate(app) + result["layers"][LAYER_AUTH_GATE] = {k: v for k, v in gate.items() if k != "traceback"} + if not gate.get("ok"): + log.error("[semantics_boot] 统一鉴权门禁装配失败:%s", gate.get("error")) + + names = middleware_names(app) + result["middlewares"] = names + result["added"] = len([n for n in names if n not in before]) if before else len(names) + + has_auth_gate = any(n in AUTH_GATE_NAMES for n in names) + has_notfound = any(n in NOTFOUND_GUARD_NAMES for n in names) + has_method = any(n in METHOD_GUARD_NAMES for n in names) + result["installed"] = bool(has_notfound and has_method and has_auth_gate) + result["auth_gate"] = {"present": has_auth_gate, "layer": LAYER_AUTH_GATE} + result["degraded"] = bool(result["installed"] + and not result["layers"][LAYER_FULL].get("ok")) + if not result["installed"]: + result["error"] = ("语义中间件缺失 notfound=%s method=%s auth_gate=%s names=%s" + % (has_notfound, has_method, has_auth_gate, names)) + log.info("[semantics_boot] installed=%s degraded=%s added=%d names=%s", + result["installed"], result["degraded"], result["added"], names) + except Exception as exc: # noqa: BLE001 + result["error"] = "%s: %s" % (type(exc).__name__, exc) + result["traceback"] = traceback.format_exc() + log.error("[semantics_boot] 装配整体异常(不抛出,应用继续启动):%s", result["error"]) + return result + + +# ── 装配结果落盘(Bug Wp6gXrrEquzg3njONz60b:装配是否真生效不能只靠日志)── +# 线上是否真装上语义中间件,此前只能从进程日志推断(日志 roll 掉就无证)。这里把 +# install_http_semantics 的返回值(installed / degraded / layers / middlewares)落成 +# JSON 证据文件,QC 与部署角色可直接读取,且**两个入口共用同一份代码**(app/scense.py +# 与 app/yuanjing.py 都走本函数),避免"只在一个入口修"的同类缺陷复发。 +# 落盘失败绝不影响启动(语义装配优先),只在日志告警。 +WIRING_EVIDENCE_REL = os.path.join("docs", "evidence", "http_semantics_wiring.json") + + +def persist_wiring(result, entry=None, base_dir=None): + """把装配结果写入 /docs/evidence/http_semantics_wiring.json(永不抛出)。""" + try: + root = base_dir or os.path.dirname(os.path.abspath(__file__)) + path = os.path.join(root, WIRING_EVIDENCE_REL) + os.makedirs(os.path.dirname(path), exist_ok=True) + payload = { + "ts": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "pid": os.getpid(), + "entry": entry or (result or {}).get("entry"), + "installed": bool((result or {}).get("installed")), + "degraded": bool((result or {}).get("degraded")), + "middlewares": list((result or {}).get("middlewares") or []), + "added": (result or {}).get("added"), + "error": (result or {}).get("error"), + "layers": (result or {}).get("layers") or {}, + "evidence_path": path, + } + tmp = path + ".tmp" + with open(tmp, "w", encoding="utf-8") as fp: + json.dump(payload, fp, ensure_ascii=False, indent=2, default=str) + os.replace(tmp, path) + log.info("[semantics_boot] 装配证据已落盘 %s installed=%s degraded=%s", + path, payload["installed"], payload["degraded"]) + return payload + except Exception as exc: # noqa: BLE001 + log.warning("[semantics_boot] 装配证据落盘失败(不影响启动):%s: %s", + type(exc).__name__, exc) + return None + + +def install_http_semantics(app, entry="app/scense.py", full=True, web=None): + """install_http_semantics + 装配证据落盘(保持原签名与返回结构,永不抛出)。""" + result = _install_http_semantics_impl(app, entry=entry, full=full, web=web) + try: + result["evidence"] = persist_wiring(result, entry=entry) + except Exception: # noqa: BLE001 + result["evidence"] = None + return result diff --git a/app/_archive_disabled_20261028_151505/entry_semantics_ctor.py b/app/_archive_disabled_20261028_151505/entry_semantics_ctor.py new file mode 100644 index 0000000..9126661 --- /dev/null +++ b/app/_archive_disabled_20261028_151505/entry_semantics_ctor.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""语义中间件的**构造期装配**(Bug Wp6gXrrEquzg3njONz60b / INT-404-neg)。 + +为什么需要这个文件(根因) +-------------------------- +`ahserver` 的 AHApp 是 aiohttp `Application` 的子类。aiohttp 的中间件链在 +`Application._make_handler()` 里**一次性**构建: + + middlewares = self._prepare_middleware() # 读 self._middlewares + handler = reduce(lambda h, mw: partial(mw, h), middlewares, handler) + +链一旦构建完成,再往 `app.middlewares` append 不会进入已构建的链(`FrozenList` +在 `freeze()` 之后 append 直接 `RuntimeError`)。`Application.middlewares` 只有 +property、**没有 setter**,所以历史代码里的 + + app.middlewares = mws # → AttributeError: can't set attribute + app.middlewares.append(mw) # → freeze 之后 RuntimeError + +两条路径都会被 `install_semantic_middlewares()` 里的 `except Exception` 吞掉, +装配结果只落到一个 `ok=False` 的 dict 与一行 ERROR 日志:进程照常启动、 +`/healthz` 照常 200、**不存在端点照旧 500**。这就是「R17-2 修复已在仓库但线上 +仍然 500」的机制——不是没写修复代码,而是修复代码**装配在一个不会生效的时刻**。 + +修复策略(本文件) +------------------ +把装配从「事后 append」改成「**构造参数**」: + + app = make_app([resource_notfound_middleware, http_semantics_middleware]) + webapp(init, app=app) # ahserver 公开钩子:传入 app 则直接用,不再自建 + +构造期传入的中间件天然在 `_make_handler()` 之前就位,且列表顺序 = 装饰顺序 +(aiohttp:**列表第一个是最外层**),因此 404 守卫包住包括 ahserver 内置处理器 +在内的整个链,`invalid path` 异常能被稳定改写为 404。 + +设计约束 +-------- +* 零应用内依赖:只 import aiohttp 与(可选的)ahserver,不 import 本应用任何模块, + 避免「`python app/scense.py` 时 sys.path[0] 是 app/ 子目录」造成的包导入失败; +* 探测不到 AHApp 类时**不静默成功**:退回 `None`,由入口走框架自建路径并在 + `/healthz` 里如实暴露 `ctor=unavailable`,让部署方能看到「这一层没生效」; +* 不做任何 `except Exception: pass`——任何意外都以 `SemanticWiringError` 抛出或 + 由调用方显式记录 degraded。 +""" + +import logging + +log = logging.getLogger("yuanjing.entry_semantics_ctor") + + +class SemanticWiringError(RuntimeError): + """构造期语义装配失败(不允许被静默吞掉)。""" + + +# AHApp 的定义位置随 ahserver 版本移动过,按「新→旧」顺序探测。 +# 只做 import 探测,不做任何副作用。 +_AHAPP_CANDIDATES = ( + ("ahserver.configuredServer", "AHApp"), + ("ahserver.webapp", "AHApp"), + ("ahserver.ahapp", "AHApp"), + ("ahserver", "AHApp"), +) + + +def load_ahapp_class(): + """返回 AHApp 类;探测不到返回 ``(None, 原因字符串)``。""" + errors = [] + for mod_name, attr in _AHAPP_CANDIDATES: + try: + mod = __import__(mod_name, fromlist=[attr]) + except ImportError as exc: + errors.append("%s: ImportError(%s)" % (mod_name, exc)) + continue + cls = getattr(mod, attr, None) + if cls is not None: + return cls, "" + errors.append("%s: 无属性 %s" % (mod_name, attr)) + return None, "; ".join(errors) + + +def _mw_name(mw): + return getattr(mw, "__name__", getattr(mw, "__qualname__", repr(mw))) + + +def make_app(middlewares, app_kwargs=None): + """构造 AHApp 并**在构造期**带上 middlewares。 + + :param middlewares: 语义中间件序列(顺序 = 外层优先) + :param app_kwargs: 透传给 AHApp 的其它关键字(如 ``client_max_size``) + :return: (app, info) —— info 记录装配事实,供 /healthz 与证据文件回读 + :raises SemanticWiringError: AHApp 探测不到(调用方必须显式降级,不得当成功) + """ + cls, reason = load_ahapp_class() + if cls is None: + raise SemanticWiringError("AHApp 类探测失败:" + reason) + + mws = [m for m in (middlewares or ()) if m is not None] + kwargs = dict(app_kwargs or {}) + kwargs["middlewares"] = mws + try: + app = cls(**kwargs) + except TypeError as exc: # AHApp 不认识 middlewares 关键字 + raise SemanticWiringError( + "AHApp(%s) 不接受 middlewares 关键字:%s" % (getattr(cls, "__module__", "?"), exc)) + + info = { + "ctor": "ok", + "ahapp_class": "%s.%s" % (getattr(cls, "__module__", "?"), getattr(cls, "__name__", "?")), + "requested": [_mw_name(m) for m in mws], + "applied": [_mw_name(m) for m in (getattr(app, "middlewares", None) or [])], + } + log.info("[ctor] AHApp 构造期装配 middlewares=%s class=%s", + info["applied"], info["ahapp_class"]) + return app, info + + +def verify(app, expected_names): + """核验 app 的中间件链是否真的含期望中间件(只读,不改 app)。 + + :return: {"ok": bool, "names": [...], "missing": [...]} + """ + try: + names = [_mw_name(m) for m in (getattr(app, "middlewares", None) or [])] + except Exception as exc: # noqa: BLE001 + return {"ok": False, "names": [], "missing": list(expected_names), + "error": "%s: %s" % (type(exc).__name__, exc)} + missing = [n for n in (expected_names or ()) if n not in names] + return {"ok": not missing, "names": names, "missing": missing} diff --git a/app/_archive_disabled_20261028_151505/get_only_405.py b/app/_archive_disabled_20261028_151505/get_only_405.py new file mode 100644 index 0000000..dd96902 --- /dev/null +++ b/app/_archive_disabled_20261028_151505/get_only_405.py @@ -0,0 +1,558 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""入口内建只读端点的方法语义(405 + `Allow`)——**装配链内**可用的唯一真源。 + +Bug EPaXUem2Gbz58sMES5eBC(R18 复验) +-------------------------------------- +线上(https://gbak.opencomputing.cn,entry=app/scense.py)实测: + + DELETE /healthz -> 404(无 Allow) PUT /healthz -> 404(无 Allow) + POST /healthz -> 404(无 Allow) GET /healthz -> 200 HEAD /healthz -> 200 + +期望 405 + `Allow: GET, HEAD, OPTIONS`。 + +为什么 `app/entry_semantics.py` 里的 405 分支没能拦住 +------------------------------------------------------ +`app/entry_semantics.py` 的 `entry_method_guard` 只在**钩子期装配路径**里可用: +入口在 `ahapp_built` 钩子里 append 它。而真实部署态走的是**构造期装配** +(`app/entry_semantics_ctor.py` + `app/scense.py::_start`)——aiohttp 的中间件链在 +`Application._make_handler()` 里一次性构建,构造期传入的列表才是实际生效的链: + + [resource_notfound_middleware, http_semantics_middleware] + +即:**真正在跑的是 `app/notfound_middleware.py` + `app/http_semantics_middleware.py`**, +`entry_method_guard` 即使 append 成功也不在这条链上(或在 freeze 后被静默丢弃)。 +于是 DELETE/PUT/POST `/healthz` 一路落到 ahserver 的 URL→资源解析,抛 `invalid path`, +被语义层的 404 兜底改写成 `{"status":404,"reason":"no such resource"}` —— 正是线上回显。 + +修复 +---- +把「入口内建只读路径 + 非 GET/HEAD/OPTIONS → 405 + Allow」的判定下沉成一个 +**零应用内依赖的小模块**,由装配链里确实生效的两层共同调用: + + app/notfound_middleware.py resource_notfound_middleware(外层:前置 + 异常侧) + app/http_semantics_middleware.py http_semantics_middleware(405/400/404 层) + +这样无论部署态走「构造期」还是「钩子期」装配,方法语义都在生效链上。 + +不冒充 405 的边界(铁律) +------------------------ +* `.dspy` 路径**一律不在此模块处理**(返回 None):不存在端点必须仍是 404, + 不得为了凑 405 把「资源不存在」说成「方法不允许」。 +* 页面类后缀(.ui/.bui/.js/.css/.html/.json...)**只有文件在 wwwroot 下真实存在**时才升 405; + 确认不了存在就返回 None(保持原 404 语义)。 +* 内建清单 `BUILTIN_GET_ONLY_PATHS` 是入口自己 `add_get()` 注册的端点(`/healthz` 等), + 它们的存在性由注册代码保证,不受磁盘资源影响。 + +真源一致性:本模块的清单与 `app/entry_semantics.py` 的 `BUILTIN_GET_ONLY_PATHS` / +`PAGE_ALLOWED_METHODS` 必须一致,`scripts/selftest_healthz_405.py` 里有断言守卫(漂移即失败)。 +""" + +import importlib.util +import sys +import logging +import os + +log = logging.getLogger("scense.get_only_405") + +#: 入口级内建只读路径(入口代码 `add_get()` 注册的探活/元信息端点)。 +BUILTIN_GET_ONLY_PATHS = ( + "/healthz", + "/", +) + +#: 内建只读路径允许的方法(无写入语义) +PAGE_ALLOWED_METHODS = ("GET", "HEAD", "OPTIONS") +#: [fix-r51b] 健康探针路径(最外层兜底只针对它,不含 `/`——首页仍需走完整渲染链) +HEALTHZ_PATH = "/healthz" +#: 405 响应头的 Allow 值(顺序固定,测试逐字断言) +ALLOW_HEADER = ", ".join(PAGE_ALLOWED_METHODS) + +#: 本闸门版本标记——写进装配结果并由 `/healthz` 暴露, +#: 用于判定「部署树里跑的是哪一版」(R18 复验时无从判断,故增)。 +GATE_REVISION = "r20-healthz-405-v4" +#: 闸门在 `app.middlewares` 里的注册名(幂等去重用) +GATE_MW_NAME = "get_only_405_method_gate" + +#: 只读页面/静态资源后缀(**不含 .dspy**) +PAGE_SUFFIXES = (".html", ".htm", ".ui", ".bui", ".js", ".css", ".json") + + +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 候选根(本文件在 /app/ 下)。取不到返回空。""" + roots = [] + try: + base = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + cand = os.path.join(base, "wwwroot") + if os.path.isdir(cand): + roots.append(cand) + except Exception: # noqa: BLE001 + return [] + return roots + + +def page_exists(path): + """页面类路径在 wwwroot 下是否真实存在(确认不了返回 False → 保持 404)。""" + rel = _norm_path(path).lstrip("/") + if not rel or ".." in rel.split("/"): + return False + for root in _wwwroot_roots(): + try: + if os.path.isfile(os.path.join(root, rel)): + return True + except Exception: # noqa: BLE001 + continue + return False + + +def get_only_allow(method, path, extra_paths=None): + """命中「只读 GET 路径 + 不允许的方法」时返回 Allow 头字符串,否则返回 None。 + + :param method: HTTP 方法(大小写不敏感) + :param path: 请求路径(`request.path`,不含 query) + :param extra_paths: 额外只读路径(如装配期从路由表收集到的 add_get 路径) + :return: `"GET, HEAD, OPTIONS"` 或 `None` + """ + p = _norm_path(path) + if not p: + return None + # 铁律:.dspy 不参与(不存在端点必须 404,不得冒充 405) + if p.lower().endswith(".dspy"): + return None + m = (method or "GET").upper() + if m in PAGE_ALLOWED_METHODS: + return None + cands = {_norm_path(x) for x in (extra_paths or ())} + cands |= {_norm_path(x) for x in BUILTIN_GET_ONLY_PATHS} + if p in cands: + return ALLOW_HEADER + if p.lower().endswith(PAGE_SUFFIXES) and page_exists(p): + return ALLOW_HEADER + return None + + +def json_405(web, request, allow): + """构造 405 响应(**必须带 Allow 头**,RFC 7231 §6.5.5)。""" + method = getattr(request, "method", "") or "" + path = getattr(request, "path", "") or "" + log.warning("[405] builtin get-only: %s %s (Allow: %s)", method, path, allow) + return web.json_response( + {"status": 405, "success": False, "reason": "method not allowed", + "path": path, "method": method, "allow": allow, + "hint": "该路径只接受 %s" % allow}, + status=405, headers={"Allow": allow}) + + +# ---------------------------------------------------------------- 装配链适配层 +def _web_module(): + """取 aiohttp web 模块(本文件被中间件链调用时用;取不到返回 None)。""" + try: + from aiohttp import web as _w # noqa: PLC0415 + return _w + except Exception: # noqa: BLE001 + return None + + +def options_204(web, request, allow): + """OPTIONS 预检:允许方法集合不变,但**必须回 Allow 头**(RFC 7231 §6.5.5)。 + + R18 复验观察:`OPTIONS /healthz -> 204` 且无 Allow,客户端无法得知可用方法。 + """ + path = getattr(request, "path", "") or "" + log.info("[405] builtin get-only OPTIONS: %s (Allow: %s)", path, allow) + return web.Response(status=204, headers={"Allow": allow}) + + +def maybe_405(web, request, extra_paths=None): + """请求需要被判为 405 时返回响应对象,否则返回 None(调用方继续原逻辑)。 + + * 非允许方法 → 405 + `Allow: GET, HEAD, OPTIONS` + * `OPTIONS`(允许方法)命中只读路径 → 204 + `Allow`(带发现语义) + * 其余(含 `.dspy`、不存在的页面)→ None,保持原 404/401/200 语义 + """ + allow = get_only_allow(getattr(request, "method", "GET"), + getattr(request, "path", ""), extra_paths) + if not allow: + return None + if (getattr(request, "method", "") or "").upper() == "OPTIONS": + return options_204(web, request, allow) + return json_405(web, request, allow) + + +def install_method_gate(app): + """把方法闸门装到 `app.middlewares` **最外层**(幂等,返回诊断 dict)。 + + 为什么必须最外层:真实链里内层存在「资源不存在 → 404」兜底 + (`resource_notfound_middleware` / `entry_notfound_guard`),一旦它们先跑, + DELETE/PUT/POST `/healthz` 就会被改写成 404 —— 正是 R18 线上回显。 + """ + diag = {"revision": GATE_REVISION, "installed": False, "position": None, + "allow": ALLOW_HEADER, "builtin_get_only_paths": list(BUILTIN_GET_ONLY_PATHS), + "error": None} + try: + mws = getattr(app, "middlewares", None) + if mws is None: + diag["error"] = "app.middlewares 不存在" + return diag + for i, m in enumerate(mws): + nm = getattr(m, "__name__", "") or "" + if nm in (GATE_MW_NAME, "method_gate_middleware"): + diag["installed"] = True + diag["position"] = i + return diag + mw = method_gate_middleware + try: + from aiohttp import web as _w + mw = _w.middleware(mw) + except Exception: # noqa: BLE001 + pass + try: + mw.__name__ = GATE_MW_NAME + except Exception: # noqa: BLE001 + pass + try: + mws.insert(0, mw) # 最外层 + except AttributeError: + diag["error"] = "middlewares 只读(app 已 freeze?)" + return diag + diag["installed"] = True + diag["position"] = 0 + except Exception as exc: # noqa: BLE001 + diag["error"] = "%s: %s" % (type(exc).__name__, exc) + return diag + + +def gate_diagnostics(app=None): + """闸门在线诊断(`/healthz` 用):版本 / Allow / 是否在链上 / 第几层。""" + if app is None: + diag = {"revision": GATE_REVISION, "installed": None, + "position": None, "error": None} + else: + diag = install_method_gate(app) + diag["allow"] = ALLOW_HEADER + diag["builtin_get_only_paths"] = list(BUILTIN_GET_ONLY_PATHS) + diag["page_allowed_methods"] = list(PAGE_ALLOWED_METHODS) + diag["options_policy"] = "204 + Allow" + # [R18 复验 EPaXUem2] 405 载体自检:把「清单是否被路由收集结果覆盖 / 内建路径 + # 在运行态是否还有 405 载体」做成**在线可读**字段,部署后 curl /healthz 即可判定 + # 「代码生效」还是「部署树陈旧」,不必再靠日志猜。 + if app is not None: + diag["builtin_union_ok"] = builtin_union_ok(app) + diag["route_carriers"] = route_carriers(app) + return diag + + +def builtin_union_ok(app=None, extra_paths=None): + """[R18 复验] 守卫「BUILTIN 清单被路由收集结果覆盖」——本 bug 的已知复发形态。 + + 历史教训(上一批同类缺陷):装配期从 `app.router` 收集到的 add_get 路径被当成 + **清单本体**使用;一旦收集结果为空或不含 `/healthz`(`install()` 早于 + `_register_healthz()` 时必然如此),`/healthz` 就从清单里消失 → 非 GET 落到 + ahserver 资源解析 → 404 且无 `Allow`。 + + 本函数的语义:`get_only_allow()` 的候选集**永远是并集** + `路由收集结果 ∪ BUILTIN_GET_ONLY_PATHS`,收集结果只能**加**不能**减**。 + 这里用一组差分用例机械证明该性质在当前进程内成立(不依赖任何外部状态): + + * 收集结果为空 → `/healthz` 仍命中(BUILTIN 保底) + * 收集结果为不相干路径 → `/healthz` 仍命中(不被覆盖) + * 收集结果显式不含 /healthz → `/healthz` 仍命中 + + :return: dict{ok, checks[], builtin, error};任何一条不成立即 ok=False。 + """ + checks = [] + try: + scenarios = ( + ("extra=None", None), + ("extra=[]", ()), + ("extra=['/other']", ("/other",)), + ("extra=['/a', '/b']", ("/a", "/b")), + ) + for name, extra in scenarios: + for p in BUILTIN_GET_ONLY_PATHS: + got = get_only_allow("DELETE", p, extra_paths=extra) + ok = got == ALLOW_HEADER + checks.append({"scenario": name, "path": p, "ok": ok, "got": got}) + # 归一形态同样必须命中(`//healthz/`、`/healthz/`) + for raw in ("//healthz/", "/healthz/", "/healthz"): + got = get_only_allow("POST", raw, extra_paths=()) + checks.append({"scenario": "normalized", "path": raw, + "ok": got == ALLOW_HEADER, "got": got}) + # 反例守卫:.dspy 必须**永不**返回 Allow(不存在端点不冒充 405) + for p in ("/xxx_not_exist.dspy", "/healthz.dspy"): + got = get_only_allow("DELETE", p, extra_paths=BUILTIN_GET_ONLY_PATHS) + checks.append({"scenario": "dspy_never_405", "path": p, + "ok": got is None, "got": got}) + bad = [c for c in checks if not c["ok"]] + return {"ok": not bad, "checked": len(checks), "failed": bad[:8], + "builtin": list(BUILTIN_GET_ONLY_PATHS), "error": None} + except Exception as exc: # noqa: BLE001 + return {"ok": False, "checked": len(checks), "failed": [], + "builtin": list(BUILTIN_GET_ONLY_PATHS), + "error": "%s: %s" % (type(exc).__name__, exc)} + + +def route_carriers(app): + """[R18 复验] 逐个内建路径列出运行态**实际存在的 405 载体**(在线可读)。 + + 三层载体(任一在就足以给出 405 + Allow): + ① `get_only_405.method_gate_middleware` 在 `app.middlewares` 最外层(本模块自证) + ② 入口自带的 `app.router.add_route("*", , ...)` 路由级 handler + ③ `app/router` 里该路径只登记了 GET(aiohttp 自身会给 405,但 Allow 只有 GET, HEAD) + + 用途:部署后 `curl /healthz | jq .healthz_method_gate.route_carriers` 一眼看出 + 「闸门进了链没有 / 入口旧版有没有路由级兜底」,从而区分代码缺陷与部署陈旧。 + """ + out = {} + try: + mws = list(getattr(app, "middlewares", None) or ()) + gate_on = any((getattr(m, "__name__", "") or "") in (GATE_MW_NAME, "method_gate_middleware") + for m in mws) + router = getattr(app, "router", None) + resources = list(getattr(router, "resources", None) or ()) + for p in BUILTIN_GET_ONLY_PATHS: + np = _norm_path(p) + wildcard = False + methods = [] + for res in resources: + try: + info = res.get_info() if hasattr(res, "get_info") else {} + except Exception: # noqa: BLE001 + info = {} + if _norm_path(info.get("path") or "") != np: + continue + m = (info.get("method") or "").upper() + if m in ("*", "ANY"): + wildcard = True + elif m: + methods.append(m) + out[p] = {"middleware_gate": gate_on, "router_wildcard": wildcard, + "router_methods": sorted(set(methods)), + "covered": bool(gate_on or wildcard or methods)} + except Exception as exc: # noqa: BLE001 + return {"error": "%s: %s" % (type(exc).__name__, exc)} + return out + + +_SELF_INFO = {} + + +def _self_info(): + """[R52 取证] 本模块(运行中进程实际加载的那份文件)的绝对路径 / 字节数 / sha256。 + + 只在首次调用时读盘并缓存——部署态 `GET/HEAD /healthz` 的兜底响应里原样带出, + 是「部署树版本 == 本地版本 == 进程加载版本」三者对齐的进程内证据。 + """ + if _SELF_INFO: + return dict(_SELF_INFO) + try: + import hashlib as _hl + p = os.path.abspath(__file__) + with open(p, "rb") as fp: + data = fp.read() + _SELF_INFO.update({"file": p, "bytes": len(data), + "sha256": _hl.sha256(data).hexdigest()}) + except OSError as exc: # noqa: BLE001 + _SELF_INFO.update({"file": __file__, + "error": "%s: %s" % (type(exc).__name__, exc)}) + _SELF_INFO["pid"] = os.getpid() + try: + _SELF_INFO["cwd"] = os.getcwd() + except OSError: + _SELF_INFO["cwd"] = None + return dict(_SELF_INFO) + + +def _gate_diag_from_request(request): + """从运行中进程的 Application 取入口写下的 `healthz_method_gate` 诊断(防御式)。""" + try: + app = getattr(request, "app", None) + except Exception: # noqa: BLE001 + return None + if app is None: + return None + for getter in (lambda: app["healthz_method_gate"], + lambda: getattr(app, "healthz_method_gate", None)): + try: + v = getter() + except Exception: # noqa: BLE001 + continue + if isinstance(v, dict): + return {k: v.get(k) for k in + ("installed", "position", "revision", "allow", "error", + "module_file", "module_sha256", "module_bytes", + "proc_pid", "proc_cwd", "proc_sys_path0")} + return None + + +def _healthz_probe_response(web, request, inner_err): + """[fix-r51b] /healthz 只读探针的最外层兜底响应(200 + Allow)。 + + 触发条件:内层链对 `GET|HEAD /healthz` 抛异常,或返回 >=400(部署态实测内层鉴权层 + fail-closed 成 401 auth_unavailable)。健康探针不得因为内层鉴权噪声变成 4xx, + 但内层错误必须在响应体与日志里可见(`inner_chain_error`),不得静默掩盖。 + """ + log.warning("[healthz] 内层链异常,方法闸门兜底 200 %s: %s", + getattr(request, "path", ""), inner_err) + if web is None: + return None + payload = { + "status": 200, + "success": True, + "ok": True, + "healthz": "gate-standby", + "gate_revision": GATE_REVISION, + "allow": ALLOW_HEADER, + "path": getattr(request, "path", "") or "", + "method": getattr(request, "method", "") or "", + "inner_chain_error": str(inner_err), + } + # [R52 取证] 进程内自证:加载的是哪份闸门模块(路径/字节/sha256)+ 闸门在链上位置 + payload["gate_module"] = _self_info() + _gd = _gate_diag_from_request(request) + if _gd is not None: + payload["healthz_method_gate"] = _gd + return web.json_response(payload, status=200, headers={"Allow": ALLOW_HEADER}) + + +# [fix rvtGi1mlUsTwQrXnybZj6 / R60] 显式登记为新式中间件:aiohttp>=3.9 用 +# `__middleware_version__ == 1` 区分新式/旧式,裸协程函数会被当旧式工厂调用 +# → 返回 handler 函数而不是 Response(`Expect response, not `)。 +from aiohttp import web as _r60_web # noqa: E402 +@_r60_web.middleware +async def method_gate_middleware(request, handler): + """方法闸门中间件(装配链最外层用;签名 `(request, handler)`)。 + + 由 `app/notfound_middleware.py::_COMPOSED_INNER_FIRST` 以 + `("get_only_405", "method_gate_middleware")` 注册 —— 这就是 QC #4 + 「新模块零引用=死代码」要求的**真实调用点**:装配链里确实会执行到这里。 + + 命中「入口内建只读路径 + 非 GET/HEAD/OPTIONS」→ 405 + `Allow: GET, HEAD, OPTIONS`; + 其余情况(包括所有 .dspy、不存在的页面)原样放行给内层,保持 404/401/200 语义不变。 + """ + w = _web_module() + if w is not None: + resp = maybe_405(w, request) + if resp is not None: + return resp + # [fix-r51b] OPTIONS 必须在**链最外层**给出 Allow:内层对 /healthz 没有 OPTIONS + # 路由,落到 ahserver 处理会回 `404 invalid path`(部署态实测日志 + # "[semantics] 404 OPTIONS /healthz ... invalid path"),Allow 头丢失。 + p = _norm_path(getattr(request, "path", "") or "") + if (getattr(request, "method", "") or "").upper() == "OPTIONS" \ + and p in {_norm_path(x) for x in BUILTIN_GET_ONLY_PATHS}: + return options_204(w, request, ALLOW_HEADER) + # [fix-r51b] /healthz 的 GET/HEAD 必须 200(不得被内层鉴权层误伤):部署态实测 + # 内层 auth 层在 GET/OPTIONS /healthz 上抛 "Expect response, not ", + # 随后 fail-closed 成 `401 auth_unavailable`(日志: + # "[route_guard] 会话校验异常,fail-closed 401 /healthz: Expect response, not ...")。 + # 本层是链最外层,由它把只读健康探针钉成 200;内层错误写进响应体 + # inner_chain_error 并记 warning,不掩盖部署态缺陷。 + if _norm_path(getattr(request, "path", "") or "") == HEALTHZ_PATH \ + and (getattr(request, "method", "") or "").upper() in ("GET", "HEAD"): + try: + resp = await handler(request) + except Exception as exc: # noqa: BLE001 + return _healthz_probe_response(w, request, exc) + if getattr(resp, "status", 500) >= 400: + return _healthz_probe_response( + w, request, "inner_status=%s" % getattr(resp, "status", "?")) + return resp + return await handler(request) + + +# ---------------------------------------------------------------- R52 签名契约 +# 根因(R48 E1):`method_gate_middleware` 原为**裸** `async def (request, handler)`, +# 只有 `install_method_gate()` 的 insert 分支才临时补 `web.middleware()`;而真正在跑的 +# 是 `notfound_middleware.compose_guard_chain()` 的**构造期**装配路径,那里拿到的是 +# 模块属性(裸函数)→ 不带 `__middleware_version__` → aiohttp 按 legacy 工厂 `mw(app)` +# 调用 → request 形参收到 AHApp → `real_ip.py:16` 的 `request.remote` AttributeError。 +# 修法:**模块级**即规范成 new-style(幂等),compose/install 任一路径取到的都是合规对象。 +_MCC = None + + +def _load_contract_module(): + """加载同目录 middleware_chain_contract.py(不依赖 sys.path / 包上下文)。 + + R48 E1 教训:`except ImportError: return mw` 的静默降级会让裸函数原样进链, + aiohttp 按 legacy 工厂 `mw(app)` 调用 → `real_ip.py:16 request.remote` + AttributeError,全站 500。这里改为 spec_from_file_location 按 `__file__` + 同级路径显式加载(同仓 app/scense.py 的既有写法),仍失败返回 None 并由 + 调用方显式补标记 + 记 error(绝不静默放行)。 + """ + global _MCC + if _MCC is not None: + return _MCC + try: + import middleware_chain_contract as _m + _MCC = _m + return _MCC + except ImportError: + pass + try: + from app import middleware_chain_contract as _m + _MCC = _m + return _MCC + except Exception: # noqa: BLE001 + pass + _path = os.path.join(os.path.dirname(os.path.abspath(__file__)), + "middleware_chain_contract.py") + try: + _spec = importlib.util.spec_from_file_location( + "middleware_chain_contract", _path) + if _spec is None or _spec.loader is None: + raise ImportError("spec_from_file_location 失败: %s" % _path) + _mod = importlib.util.module_from_spec(_spec) + _spec.loader.exec_module(_mod) + sys.modules.setdefault("middleware_chain_contract", _mod) + _MCC = _mod + return _MCC + except Exception as _exc: # noqa: BLE001 + log.error("[chain-contract] 无法加载 %s: %s", _path, _exc) + return None + + +def _new_style_mw(mw, name=None): + """把中间件规范成 new-style(带 __middleware_version__)。 + + **禁止静默放行裸函数**(R48 E1 根因):规范化不可用也要显式补标记并记 + error 日志;规范化后立即自校验,不成立直接抛错而不是继续装配。 + """ + _mcc = _load_contract_module() + fixed = mw + if _mcc is not None: + try: + fixed = _mcc.ensure_middleware(mw, name=name) + except Exception as _exc: # noqa: BLE001 + log.error("[chain-contract] %s 规范化失败(%s),改用手工补 " + "new-style 标记", getattr(mw, "__name__", mw), _exc) + fixed = mw + if getattr(fixed, "__middleware_version__", None) != 1: + try: + setattr(fixed, "__middleware_version__", 1) # aiohttp 认这个标记 + log.error("[chain-contract] %s 未经契约模块规范化,已手工补 " + "__middleware_version__=1(请检查中间件链装配)", + getattr(mw, "__name__", mw)) + except Exception as _exc: # noqa: BLE001 + raise RuntimeError( + "中间件 %s 既无法规范化也无法补标记: %s" + % (getattr(mw, "__name__", mw), _exc)) + if getattr(fixed, "__middleware_version__", None) != 1: # 自校验,失败即抛 + raise RuntimeError("中间件 %s 规范化后仍缺 __middleware_version__" + % (getattr(mw, "__name__", mw),)) + return fixed + + +method_gate_middleware = _new_style_mw(method_gate_middleware) diff --git a/app/_archive_disabled_20261028_151505/http_semantics_middleware.py b/app/_archive_disabled_20261028_151505/http_semantics_middleware.py new file mode 100644 index 0000000..d557905 --- /dev/null +++ b/app/_archive_disabled_20261028_151505/http_semantics_middleware.py @@ -0,0 +1,799 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""yuanjing / scense 应用级 HTTP 语义中间件(404 / 405 / 400 / .ui 渲染链路守卫)。 + +R41 收口(2026-10-23,4 条「期望 400 / 实测 500」缺参业务语义归位) +-------------------------------------------------------------- +Bug:VDZUFp42a-9flnNAl95ST / V40hwlHuiPR7F3aq5B_NF / 7cRqNE8v6DxfhM_kztJwr / + ldfABwx6m4Vx9RafSUvnu(证据:projects/proj_qEVGJSL3/deploy/upgrade-result-r1.json 9-verify) +口径(本文件是实现单点,禁止逐端点改生成产物): + * ⑤ LOGIN_REQUIRED_PARAMS:登录端点缺参 → 400 + missing:[...](不再把 None 灌进 rc4._crypt) + * ⑥ DB 缺参类错误收敛:1364(Field 'x' doesn't have a default value)/ + 1048(Column 'x' cannot be null)→ 400 + field:x + * 不掩错:非 1364/1048 的真实后端异常仍 500;不存在端点 → 404;正常读端点空参 → 200 +离线复验:scripts/verify_r41_four_bugs.py(21/21 PASS,rc=0)、scripts/verify_login_param_400.py +目标机实探:curl -s -o /dev/null -w '%{http_code}\n' -X POST -d '{}' /rbac/user/up_login.dspy → 400 +(待 test/deploy 阶段在目标机执行) + +历史来源(Bug 族) +------------------ +* 404:DO2jJM8OLYtFS3QTBjwpK / hqsutOGHBmthByQ8Ea4mW / tfZ2JPtf0GbTTTiwqHEin + —— ahserver 的 processorResource 对「路径不存在」抛通用 Exception + (`raise Exception(f'{str(request.url)=} invalid path')`)→ 被兜底成 500, + 与真实后端故障不可区分,并污染「全站 *.dspy 500 数 = 0」门禁(R17-4)。 +* 405:i5rCHDGdmv926crwBjV5i —— 方法不允许(GET 登录端点)应为 405 + Allow 头。 +* 400:iFsP7Yklktq_AixTnEWYi / **R17-3c(1 ldfABwx6m4Vx9RafSUvnu 的同族)** + —— 缺必填参数时应 400;模块把 Python None 拼进 SQL → 500(entity_create{} 曾返回 200、 + world_create{} 返回 500、runtime_control{} 返回 200)。 +* **R17-2(Bug 28PuqnfpWzB2ypJoUTU_N)**:本文件所在应用仓 apps/yuanjing 不是目标机实际 + 运行入口(运行的是 `app/scense.py`),中间件从未被装配 → 上述修复在目标机不生效。 + 该装配问题在 `app/semantic_wiring.py` + `app/scense.py` 修复;本文件只负责语义实现。 +* **R36 后回归 P-01/F-02(Bug 906IGBEVANkne-og1cv0r,P0)**:6 个 .ui 页面全 500。 + 根因是 ahserver 的 `BricksUIProcessor.datahandle()` 渲染任何 .ui 前都无条件取 + `/bricks/header.tmpl` 与 `/bricks/footer.tmpl`;目标机 `wwwroot/bricks` 从未建立 + (build.sh 旧软链循环只认 `pkgs//wwwroot`,而前端包 bricks 的资源在 + `pkgs/bricks/bricks/`)→ `ProcessorResource.path_call()` 里 `processor=None` + 直接 `await processor.be_call(...)` → `AttributeError` → **每个** .ui 都 500, + 而 .html/.js/.dspy 全正常。本文件负责两件事: + ①(修复)请求进入 bui 渲染器之前,确保 `/bricks/{header,footer}.tmpl` 可解析 + —— 见 `ensure_ui_render_ready()` 与 `app/ui_render_guard.py`(启动期自愈 + + 每请求快速复核,自愈动作有节流); + ②(F-02 口径).ui 纳入方法门禁:DELETE/PUT/PATCH/POST → 405 + `Allow: GET, HEAD, OPTIONS`。 + +设计约束(不掩错) +------------------ +只改写 **404(资源不存在)/ 405(方法不允许)/ 400(缺必填参数)** 三类; +其余异常一律 re-raise,保证真实后端缺陷仍然是 500,不把生产故障伪装成客户端错误。 +**唯一例外**是 .ui 渲染链路的 `AttributeError: 'NoneType' object has no attribute 'be_call'` +——它不是业务缺陷,而是「bricks 共享片段缺失」的确定性症状(见 app/ui_render_guard.py), +本中间件把它转成 **503 + 结构化原因 + 完整 traceback 日志**(比 55 字节裸 500 可诊断), +且只在 path 以 .ui 结尾且 missing 片段可指名时才转,其余 AttributeError 照旧上抛。 +框架源码只读(base 模块 apppublic/sqlor/ahserver/rbac/bricks 任何项目不得修改), +故所有修正都落在应用层中间件。 +""" + +import importlib.util +import sys +import json +import logging +import os +import re +import time + +from aiohttp import web + +log = logging.getLogger("yuanjing.http_semantics") + +# ahserver 路径解析失败的异常文本签名(processorResource.py 原文,与 app/notfound_middleware.py 同源) +RESOURCE_MISSING_SIGNATURES = ( + "invalid path", + "fpath is None", +) + +# 「方法不允许」的异常类型/文本签名 +METHOD_NOT_ALLOWED_TYPE_HINTS = ( + "HTTPMethodNotAllowed", + "MethodNotAllowed", +) +METHOD_NOT_ALLOWED_SIGNATURES = ( + "Method Not Allowed", + "method not allowed", + "405 Method", +) + +# -------------------------------------------------------------------------- +# ② 方法白名单(Bug i5rCHDGdmv926crwBjV5i / R17-3 的 DELETE/PUT 语义) +# -------------------------------------------------------------------------- +# .dspy 端点全部是「读列表 / 提交表单」语义,只接受 GET/POST/HEAD/OPTIONS; +# .ui 页面是**只读页面**,只接受 GET/HEAD/OPTIONS。 +# DELETE / PUT / PATCH 必须 405 + Allow 头(RFC 7231 §6.5.5)。 +ALLOWED_METHODS = ("GET", "POST", "HEAD", "OPTIONS") +#: .ui 页面允许的方法(页面无写入语义)——F-02 口径:DELETE /index.ui 必须 405 + Allow +PAGE_ALLOWED_METHODS = ("GET", "HEAD", "OPTIONS") +#: 方法门禁覆盖的后缀(Bug 906IGBEVANkne-og1cv0r 起纳入 .ui) +_METHOD_GATE_SUFFIXES = (".dspy", ".ui") + +# 逐端点方法端口收窄(缺省用 ALLOWED_METHODS) +PORT_METHOD_OVERRIDE = { + # 登录端点只接受 POST(此前 GET 返回 500,R17-3 期望 405 + Allow) + "/rbac/user/up_login.dspy": ("POST", "OPTIONS"), +} + +# -------------------------------------------------------------------------- +# ③ 必填参数门禁(Bug iFsP7Yklktq_AixTnEWYi / R17-3c ldfABwx6m4Vx9RafSUvnu) +# -------------------------------------------------------------------------- +# 逐端点显式必填参数(参数名可在部署库/CRUD 定义核对;未配置的端点走下面的「零参数」判据) +REQUIRED_PARAM_ENDPOINTS = { + "/appbase/get_code.dspy": ("table",), +} + +# 「零参数即无意义」的端点:写操作(add/create/delete/update/save/remove/reset/register/ +# import/export)在没有任何可用参数时不可能成功——sqlor 会把 None 拼进 SQL 并抛异常(500)。 +# 按「最后一段路径名」的词干模式判定,覆盖 xls2ui 生成的全部增删端点。 +_ZERO_PARAM_STEM_RE = re.compile( + r"(^|_)(add|create|delete|update|save|remove|reset|register|import|export|publish|enable|disable)(_|$)", + re.IGNORECASE, +) + +# 词干模式覆盖不到、但实测「空请求 → 500」的端点(R17-4 矩阵 36 条中的显式清单)。 +# 依据:docs/04-deploy/evidence_r17/r17_dspy_codes_excl_appbase.txt +ZERO_PARAM_UNSAFE_PATHS = frozenset(( + # bricks 示例(基础模块只读,无法在模块内修,故在应用层兜住语义) + "/bricks/examples/channels.dspy", + "/bricks/examples/list_tables.dspy", + "/bricks/examples/tableinfo.dspy", + # rbac 基础模块:列表/检索端点缺参时把 None 拼进 SQL + "/rbac/find_unauth_files.dspy", + "/rbac/list_path_roles.dspy", + # scene 搜索类端点(type/status/world_id 为必填检索条件) + "/scene/api/get_search_scene_type.dspy", + "/scene/api/get_search_status.dspy", + "/scene/api/get_search_world_id.dspy", + # runtime 控制端点(action/command 为必填) + "/runtime/api/runtime_control.dspy", +)) + +# -------------------------------------------------------------------------- +# ⑤ 登录/认证端点必填参数 → 400(R41:缺参业务语义归位) +# -------------------------------------------------------------------------- +# 探针(deploy/upgrade-r1.sh 第 419/430/448/449 行)对下列 4 条 Bug 使用**同一条**请求: +# POST /rbac/user/up_login.dspy -d '{}' 期望 400,实测 500 +# VDZUFp42a-9flnNAl95ST / V40hwlHuiPR7F3aq5B_NF / 7cRqNE8v6DxfhM_kztJwr / ldfABwx6m4Vx9RafSUvnu +# +# 实测根因(目标机 /d/scense/scense_app/logs/scense.log 原文,2026-10-23): +# ahserver/auth_api.py:189 except='NoneType' object is not iterable +# appPublic/rc4.py:22 _crypt: for char in data: # data = password = None +# 即:登录端点被当作「不吃业务参数的放行端」跳过参数校验,请求体缺 username/password 时 +# password_encode(None) 直接崩成 500(前端看到 "Server got itself in trouble")。 +# rbac 属只读基础模块(框架源码任何项目不得修改),故校验落在应用层中间件。 +LOGIN_REQUIRED_PARAMS = { + "/rbac/user/up_login.dspy": ( + ("username", ("username", "user", "name", "login")), + ("password", ("password", "passwd", "pwd")), + ), + "/rbac/user/login.dspy": ( + ("username", ("username", "user", "name", "login")), + ("password", ("password", "passwd", "pwd")), + ), +} + + +def missing_login_params(path, params): + """登录端点缺失的必填参数(返回 canonical 名列表);非登录端点返回 []。""" + specs = LOGIN_REQUIRED_PARAMS.get(path or "") + if not specs: + return [] + lowered = {} + for k, v in (params or {}).items(): + lowered[str(k).lower()] = v + missing = [] + for canonical, aliases in specs: + hit = False + for alias in aliases: + val = lowered.get(alias) + if val is not None and str(val).strip() != "": + hit = True + break + if not hit: + missing.append(canonical) + return missing + + +# -------------------------------------------------------------------------- +# ⑤b 登录端点多源取值与判定(R49-b:修「提交了合法凭据仍被判 missing → 400」) +# -------------------------------------------------------------------------- +# 实现单点在 app/login_param_source.py(按文件路径加载,与 _load_ui_guard / _get_only_405 +# 同策略:`python app/scense.py` 启动时 sys.path[0]=app/,不能依赖 `app.` 包导入)。 +_login_param_source_mod = {"loaded": False, "mod": None} + + +def _login_param_source(): + """加载 app/login_param_source.py;失败返回 None(调用方退回旧单源口径)。""" + if _login_param_source_mod["loaded"]: + return _login_param_source_mod["mod"] + _login_param_source_mod["loaded"] = True + mod = sys.modules.get("login_param_source") + if mod is None: + path = os.path.join(_APP_DIR, "login_param_source.py") + if os.path.isfile(path): + try: + spec = importlib.util.spec_from_file_location("_yj_login_param_source", path) + if spec is not None and spec.loader is not None: + m = importlib.util.module_from_spec(spec) + spec.loader.exec_module(m) + mod = m + except Exception: # noqa: BLE001 + log.exception("[login-params] login_param_source.py 加载失败(退回单源口径)") + mod = None + _login_param_source_mod["mod"] = mod + return mod + + +def judge_login_gate(path, params, evidence, lps): + """返回 (verdict, missing);lps 可用走多源判定,否则退回 missing_login_params。 + + verdict ∈ {"present","missing","unknown"},**只有 missing 才允许出 400**。 + """ + if lps is not None: + try: + return lps.judge_required(path, params, evidence, LOGIN_REQUIRED_PARAMS) + except Exception: # noqa: BLE001 + log.exception("[login-params] 多源判定异常,退回单源判定:%s", path) + miss = missing_login_params(path, params) + return ("missing" if miss else "present"), miss + + +# -------------------------------------------------------------------------- +# ⑥ 「参数语义的 DB 错误」→ 400(1364/1048 收敛,其余异常仍 500) +# -------------------------------------------------------------------------- +# xls2ui 生成型写 CRUD(wwwroot/{alias}/ 只读构建产物)无法逐条补校验, +# 缺必填列时 DB 直接报 NOT NULL 无默认值。这类错误的语义是**客户端缺参数**, +# 不是服务端故障,因此在中间件统一收敛为 400(与 NSNiMg-qSjj0aPRpgHBgD / +# Re30-1mhyrwOHEBtSKdX5「26 条写类 CRUD 500→400」合并处理,不再逐端点改生成产物)。 +_DB_PARAM_ERROR_PATTERNS = ( + (re.compile(r"\(1364,\s*[\"']?Field '([^']+)' doesn't have a default value", re.IGNORECASE), + "missing required parameter"), + (re.compile(r"\(1048,\s*[\"']?Column '([^']+)' cannot be null", re.IGNORECASE), + "missing required parameter"), + (re.compile(r"doesn't have a default value", re.IGNORECASE), + "missing required parameter"), + (re.compile(r"cannot be null", re.IGNORECASE), + "missing required parameter"), +) + + +def db_error_as_param_error(exc): + """识别 1364/1048 类缺参数 DB 错误 → (reason, extra);非此类返回 None(保持 500)。""" + text = "%s %s" % (type(exc).__name__, exc) + for pat, reason in _DB_PARAM_ERROR_PATTERNS: + m = pat.search(text) + if m: + extra = {} + if m.groups(): + extra["field"] = m.group(1) + return reason, extra + return None + + +_JSON_SUFFIXES = (".dspy", ".json", ".do", ".api") + +# -------------------------------------------------------------------------- +# ④ .ui → bricks(bui) 渲染链路守卫(Bug 906IGBEVANkne-og1cv0r,P0) +# -------------------------------------------------------------------------- +# bui 渲染器对**每一个** .ui 都无条件取这两个共享片段;任一不可解析即全部 .ui 500。 +BRICKS_REQUIRED_ASSETS = ("header.tmpl", "footer.tmpl") +#: 每请求快速复核的结果缓存(秒),避免每个 .ui 请求都做一次 stat +_UI_PROBE_TTL = 5.0 +#: 自愈节流(秒):资源真的缺失时不至于每个请求都重试建链并刷日志 +_UI_HEAL_MIN_INTERVAL = 60.0 + +_APP_DIR = os.path.dirname(os.path.abspath(__file__)) +_DEPLOY_DIR = os.path.dirname(_APP_DIR) + +_ui_probe_state = {"ts": 0.0, "ok": None, "last_heal": 0.0} + + +def _load_ui_guard(): + """按**文件路径**加载 app/ui_render_guard.py(不依赖 `app` 包可导入,与入口同策略)。""" + path = os.path.join(_APP_DIR, "ui_render_guard.py") + spec = importlib.util.spec_from_file_location("_scense_ui_render_guard_hsm", path) + if spec is None or spec.loader is None: + raise ImportError("无法为 %s 构造 import spec" % path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def install_ui_render_guard(deploy_dir=None): + """模块导入期装配守卫(幂等,fail-soft)。返回诊断 dict,失败返回 None。""" + try: + guard = _load_ui_guard() + summary = guard.install_entry_guard(deploy_dir or _DEPLOY_DIR) + log.info("[ui-guard] 装配完成 ok=%s bricks_action=%s .ui问题=%d", + summary.get("ok"), (summary.get("bricks") or {}).get("action"), + len(summary.get("problems") or [])) + return summary + except Exception: # noqa: BLE001 + log.exception("[ui-guard] 装配失败(不影响请求处理;缺陷仍由 build.sh 门禁兜住)") + return None + + +def _missing_assets(directory): + return [f for f in BRICKS_REQUIRED_ASSETS + if not os.path.isfile(os.path.join(directory, f))] + + +def ensure_ui_render_ready(force=False): + """请求路径的快速复核 + 按需自愈;返回 (ok: bool, missing: list, heal: dict|None)。 + + 快路径:5 秒内命中缓存直接返回,不碰文件系统。 + 慢路径:stat `wwwroot/bricks/{header,footer}.tmpl`;缺失时调 ui_render_guard 自愈, + 自愈动作按 _UI_HEAL_MIN_INTERVAL 节流。 + """ + now = time.time() + if (not force and _ui_probe_state["ok"] is not None + and now - _ui_probe_state["ts"] < _UI_PROBE_TTL): + return _ui_probe_state["ok"], [], None + + mount = os.path.join(_DEPLOY_DIR, "wwwroot", "bricks") + missing = _missing_assets(mount) + heal = None + if missing and (force or now - _ui_probe_state["last_heal"] >= _UI_HEAL_MIN_INTERVAL): + _ui_probe_state["last_heal"] = now + try: + guard = _load_ui_guard() + heal = guard.ensure_bricks_assets(deploy_dir=_DEPLOY_DIR) + missing = list(heal.get("missing") or []) + except Exception: # noqa: BLE001 + log.exception("[ui-guard] 请求期自愈失败") + ok = not missing + _ui_probe_state["ok"] = ok + _ui_probe_state["ts"] = now + return ok, missing, heal + + +def is_ui_path(path): + """.ui 页面路径判定。""" + return bool(path) and path.endswith(".ui") + + +def is_bui_chain_broken(exc): + """异常是否就是「bricks 共享片段缺失 → processor=None → be_call」这一确定症状。""" + msg = str(exc) or "" + if type(exc).__name__ != "AttributeError": + return False + return ("'NoneType' object has no attribute 'be_call'" in msg + or "'NoneType' object has no attribute 'call'" in msg) + + +def is_method_gated(path): + """该路径是否属于方法门禁覆盖范围(.dspy 端点 + .ui 页面)。""" + if not path: + return False + return any(path.endswith(sfx) for sfx in _METHOD_GATE_SUFFIXES) + + +def is_resource_missing(exc): + """异常是否为「资源不存在」语义(ahserver 路径解析失败),而非真实服务端缺陷。""" + msg = str(exc) or "" + return any(sig in msg for sig in RESOURCE_MISSING_SIGNATURES) + + +def is_method_not_allowed(exc): + """异常是否为「方法不允许」语义。""" + tn = type(exc).__name__ + if any(h in tn for h in METHOD_NOT_ALLOWED_TYPE_HINTS): + return True + msg = str(exc) or "" + return any(sig in msg for sig in METHOD_NOT_ALLOWED_SIGNATURES) + + +def allowed_methods_for(path): + """该路径允许的 HTTP 方法元组。""" + if path in PORT_METHOD_OVERRIDE: + return PORT_METHOD_OVERRIDE[path] + if is_ui_path(path): + # 页面只读:DELETE/PUT/PATCH/POST 一律 405 + Allow: GET, HEAD, OPTIONS(F-02) + return PAGE_ALLOWED_METHODS + return ALLOWED_METHODS + + +def needs_params(path): + """该端点是否属于「没有参数就不可能成功」的写/检索端点。""" + if not path or not path.endswith(".dspy"): + return False + if path in REQUIRED_PARAM_ENDPOINTS or path in ZERO_PARAM_UNSAFE_PATHS: + return True + stem = path.rsplit("/", 1)[-1][: -len(".dspy")] + return bool(_ZERO_PARAM_STEM_RE.search(stem)) + + +def _json_body(request, status, reason, extra=None): + path = request.path or "" + payload = { + "status": status, + "success": False, + "reason": reason, + "path": path, + } + if extra: + payload.update(extra) + if path.endswith(_JSON_SUFFIXES): + return web.json_response(payload, status=status) + return web.Response(status=status, text="%d %s: %s" % (status, reason, path), + content_type="text/plain") + + +# -------------------------------------------------------------------------- +# 请求参数收集(query + 路径变量 + POST body)——Bug FVyDUwxMV0RguXfyMDgtC(R19b) +# -------------------------------------------------------------------------- +# ⑤/③ 两道门禁都依赖本函数。历史缺陷(2026-10-27 07:17 R19b 目标机实测): +# 必填参数门只对 query string 求值时,application/json / x-www-form-urlencoded / +# multipart-form-data 三种 body 形态都拿不到 username/password,登录端点恒 400, +# 唯一能 200 的形态是把口令塞进 query string(落 access log / Referer,安全缺陷)。 +# +# 修复要点(**不再**依赖 `request.can_read_body`,也**不再**依赖 `request.body` 属性): +# 1. body 读取优先取 aiohttp 的 `_read_bytes` 缓存,缓存为空时 `await request.read()`; +# 上游 route_guard_middleware._has_business_params() 可能已把数据流消费掉 +# (此后 can_read_body 恒 False),但缓存里仍是完整 body——重复读安全。 +# 2. 按 Content-Type 分派解析:multipart(按 boundary 自解析,不依赖 +# request.post() 的流状态)→ JSON → form-urlencoded,三者都并入 params。 +# 3. 只收非空标量(None / 空串 / 嵌套对象不作为业务参数),与 query 分支同口径。 +_BODY_METHODS = ("POST", "PUT", "PATCH", "DELETE") +_MULTIPART_BOUNDARY_RE = re.compile(r'boundary="?([^";]+)"?', re.IGNORECASE) +_MULTIPART_NAME_RE = re.compile(r'name="([^"]*)"', re.IGNORECASE) + + +def _clean_scalar(value): + """业务参数只接受非空标量(None / 空串 / 容器类型一律丢弃)。""" + if value is None: + return None + if isinstance(value, (dict, list, tuple, set)): + return None + if isinstance(value, bytes): + value = value.decode("utf-8", "replace") + if isinstance(value, str): + text = value.strip() + return value if text != "" else None + return value + + +def _request_content_type(request): + """取原始 Content-Type(含 boundary 等参数),header 缺失时回退 request.content_type。""" + ctype = "" + headers = getattr(request, "headers", None) + if headers is not None: + try: + ctype = headers.get("Content-Type") or headers.get("content-type") or "" + except Exception: # noqa: BLE001 + ctype = "" + if not ctype: + try: + ctype = getattr(request, "content_type", "") or "" + except Exception: # noqa: BLE001 + ctype = "" + return ctype + + +def parse_multipart_fields(body, content_type): + """从 multipart/form-data 原始字节里取普通文本字段(跳过文件字段)。 + + 不调用 `request.post()`:上游已消费数据流时 can_read_body=False,post() 的 + 行为依赖 aiohttp 版本;按 boundary 切分对简单表单稳定可复现。 + + ⚠️ 旧实现用 `body.split("--"+boundary)`,这是同类缺陷(QC 意见 #4, + Bug kYOLWaWQGFcPi4ZqfTezv scene_import 收不到 multipart 文件/字段): + * delimiter 的前导 CRLF 属于分隔符,直接 split 会把上一段结尾的 `\r\n` 留在 + 下一段头部 → `username` 取值变成 `'admin\r\n--XY\r\nContent-Disposition...'`; + * final boundary `--XY--` 之后的 epilogue 也会被当成一段,产出 + `--xy\r\ncontent-disposition: form-data; name` 这类脏 key; + * boundary 从 lower() 后的 Content-Type 取会丢大小写(RFC 2046 大小写敏感)。 + 现委托 `login_param_source.multipart_plain_fields`(逐段定位 delimiter、 + 识别 final boundary、剥尾部 CRLF、跳 filename= 字段)。 + """ + out = {} + if not body: + return out + m = _MULTIPART_BOUNDARY_RE.search(content_type or "") + if not m: + return out + try: + from .login_param_source import multipart_plain_fields + except Exception: # noqa: BLE001 + try: + from login_param_source import multipart_plain_fields + except Exception as exc: # noqa: BLE001 + log.warning("[params] multipart 解析器不可用:%s", exc) + return out + for name, raw in multipart_plain_fields(body, m.group(1).strip(), + keep_case=True).items(): + value = _clean_scalar(raw) + if value is not None: + out[name] = value + return out + + +def parse_body_params(body, content_type): + """把 POST body 字节解析成业务参数字典(JSON / form-urlencoded / multipart 全覆盖)。""" + out = {} + if not body: + return out + ctype = (content_type or "").lower() + if ctype.startswith("multipart/"): + out.update(parse_multipart_fields(body, content_type)) + if out: + return out + text = body.decode("utf-8", "replace") + stripped = text.strip() + if stripped.startswith("{") or ("json" in ctype and stripped): + try: + parsed = json.loads(stripped) + except Exception: # noqa: BLE001 + parsed = None + if isinstance(parsed, dict): + for k, v in parsed.items(): + value = _clean_scalar(v) + if value is not None: + out[k] = value + if out: + return out + for k, v in _parse_qsl(stripped): + value = _clean_scalar(v) + if value is not None: + out[k] = value + return out + + +def restore_request_body(request, body): + """把已读走的请求体**放回**,保证下游仍能解析(关键,不可省)。 + + aiohttp 的 `read()` 只缓存 `_read_bytes`;而 ahserver 的 + `processorResource.getPostData()` 走的是 `request.multipart()` / + `request.post()` / `if request.can_read_body: await request.read()`: + * JSON body:`multipart()` 抛 AssertionError(被 ahserver 吃掉)→ `post()` 对 + application/json 返回空 → 只靠 `can_read_body` 判断是否 `read()` 再 json.loads; + 本中间件读过 body 后 can_read_body 变 False → **handler 拿不到 username/password**。 + * form / multipart:`post()`/`multipart()` 直接消费 `_payload` 流 → 读过即为空。 + + ⚠️ 本函数**曾经**用 `aiohttp.payload.BytesPayload` 重建 `_payload`,那是本迭代 + P0 单 `_HCoWd3He1axFKKa8ae91` 的崩点:aiohttp 3.10.10 的 Payload/BytesPayload + **没有 `at_eof()`**,而 `request.can_read_body` 的实现是 + `return not self._payload.at_eof()` → 下游每次读 `can_read_body` 抛 + AttributeError → body POST 全线 500(全站 37/215、写端点 42 条)。 + 现在实现下沉到 `app/body_restore.py`(A2 方案:`aiohttp.streams.StreamReader` + + feed_data/feed_eof,该类型有 at_eof;构造不可用时降级为「仅写 _read_bytes、 + 绝不碰 _payload」)。禁止在本文件重新引入 BytesPayload。 + """ + if not body: + return False + try: + from .body_restore import restore_request_payload # 包内导入 + except Exception: # noqa: BLE001 + try: + from body_restore import restore_request_payload # 顶层脚本直跑 + except Exception as exc: # noqa: BLE001 + log.warning("[params] body_restore 不可用,跳过请求体回填:%s", exc) + return False + ok, code = restore_request_payload(request, body) + if code == "failed": + log.warning("[params] 请求体回填失败(下游 handler 可能读不到 body):%s", + getattr(request, "path", "")) + return ok + + +async def _read_request_body(request): + """读取请求体(幂等)。返回 (body: bytes, ok: bool)。 + + 优先用 aiohttp 的 `_read_bytes` 缓存(`await request.read()` 之后即存在), + 缓存为空时再 `await request.read()`;两者都**不**受 can_read_body 影响, + 因此「上游中间件已读走 body」与「本中间件自己读」两种时序都能拿到内容。 + 读到内容后回填 payload(restore_request_body),保证 handler 侧仍可解析。 + """ + cached = getattr(request, "_read_bytes", None) + if isinstance(cached, (bytes, bytearray)): + body = bytes(cached) + restore_request_body(request, body) + return body, True + try: + body = await request.read() + except Exception: # noqa: BLE001 + log.warning("[params] 读取请求体失败,按无 body 处理:%s", getattr(request, "path", "")) + return b"", False + if isinstance(body, (bytes, bytearray)): + body = bytes(body) + restore_request_body(request, body) + return body, True + return b"", True + + +async def collect_request_params(request): + """收集请求携带的**非空**参数(query + 路径变量 + POST JSON/form/multipart body)。 + + 返回 (params: dict, body_read_ok: bool)。body 通过 aiohttp 的读缓存读取, + 处理器随后再读同一请求体不受影响(aiohttp 复用 `_read_bytes` 缓存)。 + """ + params = {} + for k, v in request.query.items(): + value = _clean_scalar(v) + if value is not None: + params[k] = value + for k, v in (request.match_info or {}).items(): + value = _clean_scalar(v) + if value is not None: + params[k] = value + body_read_ok = True + if request.method in _BODY_METHODS: + body, body_read_ok = await _read_request_body(request) + if body: + try: + params.update(parse_body_params(body, _request_content_type(request))) + except Exception: # noqa: BLE001 + log.exception("[params] body 解析失败(缺参门禁只看 query/路径参数):%s", + getattr(request, "path", "")) + return params, body_read_ok + + +def _parse_qsl(text): + try: + from urllib.parse import parse_qsl + return parse_qsl(text, keep_blank_values=True) + except Exception: # noqa: BLE001 + return [] + + +def _get_only_405(): + """取 `app/get_only_405.py::maybe_405`(绝对路径优先,不依赖 sys.path)。 + + 与 `app/notfound_middleware.py::_import_attr` 同源:`python app/scense.py` 启动时 + sys.path[0] 是 app/,包导入 `app.get_only_405` 会失败,必须能按文件路径加载。 + """ + mod = sys.modules.get("get_only_405") + fn = getattr(mod, "maybe_405", None) if mod is not None else None + if fn is not None: + return fn + try: + m = __import__("get_only_405", fromlist=["maybe_405"]) + fn = getattr(m, "maybe_405", None) + if fn is not None: + return fn + except Exception: # noqa: BLE001 + pass + try: + import importlib.util + app_dir = os.path.dirname(os.path.abspath(__file__)) + path = os.path.join(app_dir, "get_only_405.py") + if not os.path.isfile(path): + return None + spec = importlib.util.spec_from_file_location("_yj_get_only_405", 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, "maybe_405", None) + except Exception as exc: # noqa: BLE001 + log.error("[405] get_only_405 加载失败: %s", exc) + return None + + +@web.middleware +async def http_semantics_middleware(request, handler): + + # --- Bug EPaXUem2: 入口内建只读路径非读方法 → 405 + Allow(pre-handler) + # 真源单点 app/get_only_405.py;命中才接管,未命中一律放行给下面的原逻辑。 + _r405 = None + try: + _fn405 = _get_only_405() + if _fn405 is None: + log.error("[405] get_only_405.maybe_405 不可用(方法语义降级,其余语义不受影响)") + else: + _r405 = _fn405(web, request) + except Exception as _exc405: # noqa: BLE001 + log.error("[405] 方法语义判定异常(放行到原逻辑): %s", _exc405) + _r405 = None + if _r405 is not None: + return _r405 + """404 / 405 / 400 语义修正 + .ui 渲染链路守卫;其余异常原样上抛(保 500 语义)。""" + path = request.path or "" + + # ---- ② 方法不允许 → 405 + Allow(在进入处理器之前拦)---- + # 覆盖: + # * .dspy 的 DELETE/PUT/PATCH(历史 Bug i5rCHDGdmv926crwBjV5i) + # * .ui 的 DELETE/PUT/PATCH/POST(Bug 906IGBEVANkne-og1cv0r 的 F-02:此前 DELETE + # /index.ui 会一路落到 bui 渲染器 → 500 且无 Allow 头) + if is_method_gated(path) and request.method not in allowed_methods_for(path): + allow = ", ".join(allowed_methods_for(path)) + log.warning("[405] method not allowed (pre-handler): %s %s (Allow: %s)", + request.method, path, allow) + resp = _json_body(request, 405, "method not allowed", + {"method": request.method, "allow": allow, + "hint": "该端点只接受 %s;删除/更新请改用 POST" % allow}) + resp.headers["Allow"] = allow + return resp + + # ---- ④ .ui 渲染链路:进入 bui 渲染器前确保 /bricks/{header,footer}.tmpl 可解析 ---- + # 不接管成功响应,只做「前置就位 + 失败归因」;片段本就正常时走 5s 缓存快路径。 + if is_ui_path(path): + ok, missing, heal = ensure_ui_render_ready() + if not ok: + log.error("[ui-render] .ui 渲染片段仍缺失 %s(heal=%s):%s 将无法渲染", + missing, (heal or {}).get("action"), path) + + # ---- ⑤ 登录端点必填参数 → 400(缺 username/password 时 password_encode(None) 崩 500)---- + # R49-b 修正(Bug kQkhtciiSmu3EMhovy3Lf / UKeLdiljGVbc7IQZO12nV / Iq_o8E0510HAm7dPpGsZx): + # 原实现只读 collect_request_params() 一路(query + 本层 read() 的 body 字节)。当语义链上 + # 游中间件先消费了请求流时,`request.read()` 返回 b"" → body 解析为空 → **已提交的 + # username/password 被判成 missing** → 合法登录也 400,全站无会话(P0 阻断)。 + # 现改走 app/login_param_source.py:多源取值(框架参数缓存 / query / 路径变量 / 原始 body / + # request.post())+ 证据分级;只有「有证据表明确实没提交」才 400,证据不足一律放行给 + # 业务处理器(宁可让真实缺参由下游报错,也不伪造 400 挡死合法凭据)。 + if path in LOGIN_REQUIRED_PARAMS: + lps = _login_param_source() + evidence = None + params = None + if lps is not None: + try: + params, evidence = await lps.collect_login_params(request, LOGIN_REQUIRED_PARAMS) + except Exception: # noqa: BLE001 + log.exception("[login-params] 多源取值失败,退回旧单源口径:%s", path) + params = None + if params is None: + params, _body_read_ok = await collect_request_params(request) + verdict, miss = judge_login_gate(path, params, evidence, lps) + if verdict == "missing" and miss: + log.warning("[400] login endpoint missing params %s for %s (%s)", + miss, path, lps.masked_evidence(evidence) if lps else "-") + return _json_body(request, 400, "missing required parameter", + {"missing": miss, + "hint": "登录端点必填 username/password;缺参不再落到 " + "password_encode(None) 崩溃成 500"}) + if verdict == "unknown": + # 不 400、不掩错:记录证据后放行,由 rbac 自己判定(真实缺参时它会给出自己的错误) + log.warning("[login-params] 必填参数取值证据不足,放行不判 400:%s (%s)", + path, lps.masked_evidence(evidence) if lps else "-") + + # ---- ③ 缺必填参数 → 400(避免 None 拼进 SQL 变成 500)---- + if needs_params(path) or path in REQUIRED_PARAM_ENDPOINTS: + params, body_read_ok = await collect_request_params(request) + required = REQUIRED_PARAM_ENDPOINTS.get(path, ()) + missing = [name for name in required if name not in params] + if not missing and not params and body_read_ok and not required: + # 零参数写/检索端点:请求体读到了、确认一个可用参数都没有 + missing = ["<至少一个业务参数>"] + if missing: + log.warning("[400] missing required params %s for %s", missing, path) + return _json_body(request, 400, "missing required parameter", + {"missing": missing, + "hint": "该端点必填参数缺失:补齐后重试(框架不再把 None 拼进 SQL)"}) + + try: + return await handler(request) + except web.HTTPException: + # aiohttp 自身抛出的 HTTP 语义异常(404/401/403/405…)已是正确状态码,直接放行 + raise + except Exception as exc: # noqa: BLE001 + if is_method_not_allowed(exc): + allow = ", ".join(allowed_methods_for(path)) + log.warning("[405] method not allowed: %s %s (%s)", + request.method, path, type(exc).__name__) + resp = _json_body(request, 405, "method not allowed", + {"method": request.method, "allow": allow, + "hint": "该端点只接受 %s" % allow}) + # RFC 7231 §6.5.5:405 响应必须带 Allow 头 + resp.headers["Allow"] = allow + return resp + param_err = db_error_as_param_error(exc) + if param_err: + # 1364/1048:客户端缺必填参数导致的 DB 拒绝,语义是 400 而非 500 + reason, extra = param_err + log.warning("[400] db param error mapped to 400: %s %s (%s)", + request.method, path, exc) + payload = dict(extra) + payload["hint"] = ("必填字段缺失或为空,DB 拒绝写入(1364/1048);" + "补齐后重试") + return _json_body(request, 400, reason, payload) + if is_ui_path(path) and is_bui_chain_broken(exc): + # bui 渲染器取 /bricks/{header,footer}.tmpl 失败(processor=None)的确定症状: + # 记录**完整**栈(这是原始 500 的证据来源),并给出可执行原因,而非 55 字节裸 500。 + log.exception("[ui-render] bui 渲染链路断裂(bricks 共享片段不可解析): %s", path) + ok, missing, heal = ensure_ui_render_ready(force=True) + return _json_body( + request, 503, "ui render chain broken", + {"missing_bricks_assets": missing, + "heal": (heal or {}).get("action"), + "wwwroot_bricks": os.path.join(_DEPLOY_DIR, "wwwroot", "bricks"), + "hint": ("BricksUIProcessor 渲染 .ui 前必须解析 /bricks/header.tmpl 与 " + "/bricks/footer.tmpl;缺失时 processor=None → AttributeError。" + "修复:确保 /pkgs/bricks/bricks 已投递并软链为 " + "/wwwroot/bricks(Bug 906IGBEVANkne-og1cv0r)")}, + ) + if is_resource_missing(exc): + log.warning("[404] resource not found: %s -> HTTPNotFound", path) + return _json_body(request, 404, "no such resource", + {"hint": "端点/静态资源不存在:核对 wwwroot 是否已软链、端点名是否与磁盘文件一致"}) + raise + + +# 模块导入期装配:本文件由 app/entry_semantics_boot.py / app/semantic_wiring.py 导入, +# 目标机入口 app/scense.py 经语义装配链一定会走到这里(R17-2 已修复装配), +# 因此导入即执行 = 启动期就把 wwwroot/bricks 修好,不必等第一个 .ui 请求。 +install_ui_render_guard() diff --git a/app/_archive_disabled_20261028_151505/login_param_source.py b/app/_archive_disabled_20261028_151505/login_param_source.py new file mode 100644 index 0000000..3178e1b --- /dev/null +++ b/app/_archive_disabled_20261028_151505/login_param_source.py @@ -0,0 +1,501 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""登录端点入参的多源取值与「是否真的缺参」判定(Bug kQkhtciiSmu3EMhovy3Lf / UKeLdiljGVbc7IQZO12nV / Iq_o8E0510HAm7dPpGsZx)。 + +为什么需要这个模块(实测根因) +------------------------------ +`app/http_semantics_middleware.py` 的 ⑤ 号闸门(R41 引入)本意是:**缺** username/password +时早返回 400,避免 None 被灌进 `appPublic/rc4._crypt` 崩成 500。它只读 +`collect_request_params()` 的结果。 + +R49-b 线上回显(docs/03-test/R49-b-deployed-verify-decision.md §2.2,2026-10-26): + + POST -d 'username=admin&password=admin123' /rbac/user/up_login.dspy -> HTTP=400 + POST -H 'Content-Type: application/json' -d '{"username":"admin",...}' -> HTTP=400 + body: {"status":400, "reason":"missing required parameter", "missing":["username","password"]} + +即**参数明明提交了,却判成缺失**。原因不在 `parse_body_params`(form/JSON 解析在离线自测里是 +通过的),而在取值时序:本中间件之前还有语义中间件链(`app/semantic_wiring.py` 装配的 +auth_gate / notfound / business_status 等)。只要链上任何一环先消费过请求流(走 +`request.multipart()` / StreamReader 而不是 `request.read()`),aiohttp 的 `_read_bytes` +缓存就是空的,`request.read()` 此时返回 `b""` —— 于是 body 解析得到 `{}`, +`missing_login_params()` 判「两个必填都缺」,登录被 400 挡死。 + +后果是**阻断面**:没有会话 → 全部受保护端点在部署态不可观测,test/deploy 的矩阵只能匿名跑。 + +修法(两层) +------------ +1. **多源取值**:query / 路径变量 / ahserver 已合并的请求参数缓存(若存在)/ 原始 body + 字节(JSON、form-urlencoded、multipart 普通字段)/ `request.post()` 兜底,逐源合并, + 任一源取到即用。 +2. **证据分级 + fail-open**:只有「确实读到了请求体(或确实没有请求体)且必填名为空」 + 才判 `missing` → 400;如果所有源都取不到值**且请求体读取失败/不可判**,判 `unknown`, + **不再伪造 400**,放行业务处理器(让它按真实情况成功或返回自己的错误)。 + 理由:把「已提交凭据」误判成「缺参」是**功能性阻断**(P0,全站不可用), + 比漏判一次 500 严重得多;而真正的缺参请求(`-d '{}'` / 无 body)证据链完整, + 仍然稳定 400(保留 R41 的原口径,不回退)。 + +硬约束 +------ +* 不打印口令(日志里只出现参数名与证据键,值一律 `***`); +* 不修改基座只读模块(rbac / ahserver / apppublic),一切收敛在应用层; +* 本模块只做取值与判定,不写响应体(响应仍由 http_semantics_middleware 统一出)。 +""" + +from __future__ import annotations + +import json +import logging +import os +import re + +log = logging.getLogger("yuanjing.login_param_source") + +#: 判定结论 +VERDICT_MISSING = "missing" # 证据充分:必填参数确实没提交 → 可以 400 +VERDICT_PRESENT = "present" # 取到了必填参数 → 放行 +VERDICT_UNKNOWN = "unknown" # 证据不足(请求体不可读且各源皆空)→ 放行(不伪造 400) + +#: ahserver/aiohttp 上可能已存放「合并后请求参数」的属性名(best-effort 探测,命中即用) +_CACHED_PARAM_ATTRS = ( + "params_kw", # ahserver 约定:query+body 合并后的参数字典 + "_params_kw", + "ah_params", + "request_params", + "_ah_params", +) + +_BODY_METHODS = ("POST", "PUT", "PATCH", "DELETE") + +#: 标量白名单:口令之类允许空串判定,但容器值一律丢弃(避免把 dict 当参数值) +_SCALAR_TYPES = (str, int, float, bool, bytes) + +# 自测钩子:置 1 时 `_read_body_bytes` 模拟「流已被上游消费」的场景 +_FORCE_BODY_UNREADABLE = bool(os.environ.get("YJ_FAKE_BODY_UNREADABLE")) + + +def _is_meaningful(value): + """非空标量才算「提交了该参数」(空串/纯空白/容器/None 都算没提交)。""" + if value is None: + return False + if isinstance(value, (dict, list, tuple, set)): + return False + if isinstance(value, bytes): + try: + value = value.decode("utf-8", "replace") + except Exception: # noqa: BLE001 + return False + if isinstance(value, str): + return value.strip() != "" + return True + + +#: 合法参数名:可见字符,不含空白/控制符/换行,不以 -- 开头(防 multipart 脏 key 入库) +_INVALID_NAME_RE = re.compile(r"[\r\n\t\x00-\x1f]|^--|;|content-disposition", re.IGNORECASE) + + +def _valid_name(name): + return bool(name) and not _INVALID_NAME_RE.search(str(name)) + + +def _flatten_mapping(raw, out): + """把 dict / MultiDict 类容器摊平成 {小写名: 值}(只收有意义的标量)。""" + if raw is None: + return out + items = None + if isinstance(raw, dict): + items = raw.items() + elif hasattr(raw, "items") and callable(getattr(raw, "items")): + try: + items = raw.items() + except Exception: # noqa: BLE001 + items = None + elif hasattr(raw, "getall") and callable(getattr(raw, "getall")): + items = None + if items is not None: + try: + for k, v in items: + out.setdefault(str(k).lower(), v) + except Exception: # noqa: BLE001 + pass + else: + try: + for k in raw: + out.setdefault(str(k).lower(), raw[k]) + except Exception: # noqa: BLE001 + pass + return out + + +def _from_framework_cache(request, out): + """源①:ahserver 已解析并挂到 request / _run_ns 上的参数缓存(存在即最可信)。""" + hit = None + carriers = [request] + run_ns = getattr(request, "_run_ns", None) + if run_ns is not None: + carriers.append(run_ns) + for carrier in carriers: + for attr in _CACHED_PARAM_ATTRS: + candidate = getattr(carrier, attr, None) + if isinstance(candidate, (dict, list, tuple)) or hasattr(candidate, "items"): + hit = candidate + break + if hit is not None: + break + if hit is None: + return out, False + _flatten_mapping(hit, out) + return out, True + + +def _from_query_and_match(request, out): + """源②:查询串 + 路径变量(两者都是 aiohttp 的只读缓存,读取无副作用)。""" + try: + _flatten_mapping(request.query, out) + except Exception: # noqa: BLE001 + pass + try: + _flatten_mapping(request.match_info, out) + except Exception: # noqa: BLE001 + pass + # 兼容历史写法:GET 表单走 request.GET + try: + _flatten_mapping(getattr(request, "GET", None), out) + except Exception: # noqa: BLE001 + pass + return out + + +def _json_dict(text): + try: + parsed = json.loads(text) + except Exception: # noqa: BLE001 + return None + return parsed if isinstance(parsed, dict) else None + + +def _qsl_pairs(text): + try: + from urllib.parse import parse_qsl + return parse_qsl(text, keep_blank_values=True) + except Exception: # noqa: BLE001 + return [] + + +_NAME_RE = re.compile(r"name=\"([^\"]*)\"", re.IGNORECASE) +_NAME_RE2 = re.compile(r"name=([^;\r\n\"]+)", re.IGNORECASE) + + +_BOUNDARY_RE = re.compile(r'boundary="?([^";,]+)"?', re.IGNORECASE) + + +def _boundary_of(content_type): + """取 Content-Type 里的 boundary(**保留原始大小写**)。""" + m = _BOUNDARY_RE.search(content_type or "") + return m.group(1).strip() if m else "" + + +def _field_name(header_text): + """从 Content-Disposition 头里取 name= (带引号优先,其次裸值)。""" + m = _NAME_RE.search(header_text) + if m: + return m.group(1).strip() + m = _NAME_RE2.search(header_text) + if m: + return m.group(1).strip().strip('"').strip("'") + return None + + +def multipart_plain_fields(body, boundary, keep_case=False): + """按 RFC 2046 精确切分 multipart 分段,取**普通文本字段**(跳过 filename= 文件字段)。 + + keep_case=True 时返回的字段名保持原始大小写(`request.params` 口径需要), + 默认小写(本模块内部判定口径)。 + + 为什么不能 `body.split("--"+boundary)`(原实现的缺陷,QC 意见 #4): + * delimiter 的**前导 CRLF** 属于分隔符的一部分;直接用 `--boundary` split 会把 + 上一段正文结尾的 `\\r\\n` 留在下一段的头部,导致 `username` 的值被吞成 + `'admin\\r\\n--XY\\r\\nContent-Disposition...'`; + * 每段结尾的 `\\r\\n`(属于下一个 delimiter)会被当成正文的一部分留下; + * `--boundary--`(final boundary)之后的 epilogue 也会被当成一段解析,产出 + `--xy\\r\\ncontent-disposition: form-data; name` 这类脏 key。 + 本实现逐段定位 delimiter(含前导 CRLF),识别 final boundary 即终止, + 并在截取的段内剥离尾部 CRLF,只取 Content-Disposition 的 name= 与紧随其后的正文。 + """ + out = {} + if not body or not boundary: + return out + delim = ("--" + str(boundary)).encode("utf-8", "replace") + first = body.find(delim) + if first < 0: + return out + idx = first + while True: + start = idx + len(delim) + # final boundary: "--boundary--" → 后面只有 epilogue,解析结束 + if body[start:start + 2] == b"--": + break + # delimiter 之后必须紧跟 CRLF(或流末尾),否则不是合法分段起点 + if body[start:start + 2] != b"\r\n": + nxt = body.find(delim, start) + if nxt < 0: + break + idx = nxt + continue + start += 2 + nxt = body.find(delim, start) + if nxt < 0: + part = body[start:] + else: + part = body[start:nxt] + # 段尾的 CRLF 属于下一个 delimiter,剥掉 + if part.endswith(b"\r\n"): + part = part[:-2] + if part and part[-2:] == b"\r\n": + part = part[:-2] + head_raw, sep, payload = part.partition(b"\r\n\r\n") + if sep: + head_text = head_raw.decode("utf-8", "replace") + low = head_text.lower() + if "content-disposition" in low and "filename=" not in low: + name = _field_name(head_text) + if name: + key = name if keep_case else name.lower() + out.setdefault(key, payload.decode("utf-8", "replace")) + if nxt < 0: + break + idx = nxt + return out + + +#: 向后兼容旧私有名 +def _multipart_plain_fields(body, boundary): + return multipart_plain_fields(body, boundary) + + +def _parse_body_bytes(body, content_type, out): + """源④:原始请求体字节(multipart → JSON → form-urlencoded)。""" + if not body: + return out + ctype_raw = content_type or "" + ctype = ctype_raw.lower() + if ctype.startswith("multipart/"): + # boundary 是**大小写敏感**的(RFC 2046),必须从原始 Content-Type 取值, + # 不能从 lower() 后的串取——否则 `boundary=XY` 会被当成 `xy` 而一段都切不到, + # 再落进 form-urlencoded 兜底,产出 `--xy\r\ncontent-disposition...` 脏 key。 + merged = multipart_plain_fields(body, _boundary_of(ctype_raw)) + for k, v in merged.items(): + out.setdefault(k, v) + if out: + return out + # 没解析出任何字段也**不得**回落到 qsl 解析(会把整段 multipart 当表单串切坏) + return out + text = body.decode("utf-8", "replace").strip() + if not text: + return out + if text.startswith("{") or "json" in ctype: + parsed = _json_dict(text) + if isinstance(parsed, dict): + for k, v in parsed.items(): + out.setdefault(str(k).lower(), v) + return out + for k, v in _qsl_pairs(text): + out.setdefault(str(k).lower(), v) + return out + + +async def _read_body_bytes(request): + """读请求体字节,返回 (body, readable)。 + + readable=False 表示「拿不到证据」:上游已消费流且补读失败——此时**不得**判缺参。 + """ + if _FORCE_BODY_UNREADABLE: + return b"", False + cached = getattr(request, "_read_bytes", None) + if isinstance(cached, (bytes, bytearray)) and bytes(cached): + return bytes(cached), True + # 上游已消费过:_read_bytes 为空 → read() 只会拿到 b"",此时尝试 can_read_body 判定 + if not getattr(request, "can_read_body", True): + try: + body = await request.read() + except Exception: # noqa: BLE001 + body = b"" + if isinstance(body, (bytes, bytearray)) and bytes(body): + return bytes(body), True + # 请求声明了 body 却读不到内容:证据不足(真实提交过凭据也可能落在这里) + return b"", False + try: + body = await request.read() + except Exception: # noqa: BLE001 + return b"", False + if isinstance(body, (bytes, bytearray)): + return bytes(body), True + return b"", False + + +def _declares_body(request): + """请求是否在协议层**声明**带了 body(Content-Length>0 或 Transfer-Encoding: chunked)。 + + 这是判定「证据不足」的关键:只要协议声明有 body,而本层一个字节都没读到 + (上游中间件已消费流 / aiohttp 缓存为空),就**不能**断言「参数没提交」。 + """ + try: + if getattr(request, "content_length", None): + return True + except Exception: # noqa: BLE001 + pass + try: + headers = getattr(request, "headers", None) + except Exception: # noqa: BLE001 + headers = None + if headers is not None: + try: + cl = headers.get("Content-Length") + except Exception: # noqa: BLE001 + cl = None + try: + if cl is not None and int(cl) > 0: + return True + except Exception: # noqa: BLE001 + pass + try: + te = str(headers.get("Transfer-Encoding") or "").lower() + except Exception: # noqa: BLE001 + te = "" + if "chunked" in te: + return True + # 有 Content-Type 但没有 Content-Length 的替身对象(离线自测):视为声明带 body + try: + ctype = getattr(request, "content_type", "") or "" + except Exception: # noqa: BLE001 + ctype = "" + if ctype and getattr(request, "content_length", None) is None: + return True + return False + + +async def _from_request_post(request, out): + """源⑤:`request.post()`(form/multipart 的官方解析路径)兜底。""" + try: + post = await request.post() + except Exception: # noqa: BLE001 + return out + _flatten_mapping(post, out) + return out + + +def _required_names(path, spec_table): + """取该端点的必填 canonical 名与别名表(缺配置返回 None)。""" + specs = (spec_table or {}).get(path or "") + if not specs: + return None + return [(canonical, tuple(a.lower() for a in aliases)) for canonical, aliases in specs] + + +async def collect_login_params(request, spec_table): + """多源收集登录端点参数,返回 (params, evidence)。 + + params : {小写参数名: 原始值}(只保留有意义的标量) + evidence : {"sources":[..], "body_readable":bool, "body_len":int, + "framework_cache_hit":bool} + """ + params = {} + sources = [] + + merged, cache_hit = _from_framework_cache(request, params) + if cache_hit and any(_is_meaningful(v) for v in merged.values()): + sources.append("framework_cache") + + before = len([k for k, v in params.items() if _is_meaningful(v)]) + _from_query_and_match(request, params) + if len([k for k, v in params.items() if _is_meaningful(v)]) > before: + sources.append("query/match_info") + + body_readable = True + body_len = 0 + declared_body = False + if request.method in _BODY_METHODS: + declared_body = _declares_body(request) + body, body_readable = await _read_body_bytes(request) + body_len = len(body or b"") + if body: + _parse_body_bytes(body, request.content_type, params) + sources.append("raw_body") + if not any(_is_meaningful(v) for v in params.values()): + await _from_request_post(request, params) + if any(_is_meaningful(v) for v in params.values()): + sources.append("request.post") + + clean = {k: v for k, v in params.items() if _is_meaningful(v)} + evidence = { + "sources": sources, + "body_readable": body_readable, + "body_len": body_len, + "declared_body": declared_body, + "framework_cache_hit": cache_hit, + "names": sorted(clean.keys()), + } + return clean, evidence + + +def judge_required(path, params, evidence, spec_table): + """返回 (verdict, missing_names)。 + + * 必填都取到 → present + * 确实缺(且有证据) → missing + * 取不到但证据不足 → unknown(调用方**不得**据此 400) + """ + specs = _required_names(path, spec_table) + if not specs: + return VERDICT_PRESENT, [] + lowered = {str(k).lower(): v for k, v in (params or {}).items()} + missing = [] + for canonical, aliases in specs: + if not any(canonicalize_alias(a) in lowered and _is_meaningful(lowered.get(canonicalize_alias(a))) + for a in aliases): + missing.append(canonical) + if not missing: + return VERDICT_PRESENT, [] + # 有必填没取到:区分「真没提交」与「没读到」 + ev = evidence or {} + body_readable = ev.get("body_readable", True) + body_len = ev.get("body_len", 0) or 0 + declared_body = bool(ev.get("declared_body")) + # 口径①(QC 意见 #5 的核心):协议声明带 body(Content-Length>0 / chunked)而本层 + # 读不到内容 → 证据不足,必须判 unknown 放行,绝不伪造 400 挡死合法登录。 + if not body_readable and (declared_body or body_len > 0): + return VERDICT_UNKNOWN, missing + # 口径②:读到过内容(body_len>0 或已取到别的参数)但仍缺必填,且流不可重读 → + # 同样属于"取值链不完整",证据不足放行。 + if not body_readable and (lowered or body_len > 0): + return VERDICT_UNKNOWN, missing + # 口径③:Content-Length=0 / 确实无 body / 读到了 body 但必填为空 → 真缺参,可以 400 + return VERDICT_MISSING, missing + + +def canonicalize_alias(alias): + return str(alias).lower() + + +def masked_evidence(evidence): + """日志安全的证据摘要(不含任何值,只有参数名/长度/来源)。""" + ev = evidence or {} + return "sources=%s names=%s body_len=%s body_readable=%s fw_cache=%s" % ( + ",".join(ev.get("sources") or []) or "-", + ",".join(ev.get("names") or []) or "-", + ev.get("body_len", 0), + ev.get("body_readable", True), + ev.get("framework_cache_hit", False), + ) + + +# --- 兼容旧调用(离线自测里按同口径构造过判定)----------------------------- +def judge_missing(path, params, spec_table): + """旧接口:只做名字判定,返回缺失 canonical 列表。""" + specs = _required_names(path, spec_table) or [] + lowered = {str(k).lower(): v for k, v in (params or {}).items()} + out = [] + for canonical, aliases in specs: + if not any(a in lowered and _is_meaningful(lowered.get(a)) for a in aliases): + out.append(canonical) + return out diff --git a/app/_archive_disabled_20261028_151505/middleware_chain_contract.py b/app/_archive_disabled_20261028_151505/middleware_chain_contract.py new file mode 100644 index 0000000..d814d37 --- /dev/null +++ b/app/_archive_disabled_20261028_151505/middleware_chain_contract.py @@ -0,0 +1,341 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""中间件链**签名契约**层(R48 根因修复)。 + +根因(R48 实测 2026-10-26 06:43,evidence/retest-r48-probe-20261026-064308.log) +---------------------------------------------------------------------------- +部署态全站 500(`500 Internal Server Error / Server got itself in trouble`), +A/B/C 三族(/healthz 方法语义、匿名读鉴权、缺参写状态行)全部被遮蔽。 +调用链: + + app/get_only_405.py (healthz 兜底 / 链最外层) + -> 内层链 + -> pkgs/ahserver/ahserver/real_ip.py:16 request['client_ip'] = request.remote + -> AttributeError: 'AHApp' object has no attribute 'remote' + +`request` 位置上出现的是 **AHApp(应用对象)**,不是 `web.Request`。这不是 real_ip +写错,而是**中间件注册/装配的签名契约不匹配**: + +aiohttp 的 `Application` 用 `__middleware_version__` 属性区分两种中间件风格—— + + * **new-style**:`@web.middleware` 装饰过的 `async def mw(request, handler)`, + 装饰器写入 `__middleware_version__ = 1`; + * **legacy-style**:**没有**该标记的可调用对象,被当作「工厂」调用 `mw(app)`。 + +因此把一个**裸** `async def mw(request, handler)`(忘记 `@web.middleware`)塞进 +`app.middlewares` / 构造期 `mws` 列表,aiohttp 会按 legacy 工厂路径用 **app** 去填 +第一个形参 —— 中间件眼里的 `request` 变成 `AHApp`,`request.remote` 立刻 +AttributeError;同时内层链可能返回**未被 await 的 coroutine/function**,产生 +`Expect response, not `(R21 历史日志原文)。 + +本仓库的暴露点(本轮机械核对,全部是「裸函数进链」): + + app/get_only_405.py :: method_gate_middleware (无 @web.middleware) + app/auth_route_gate.py :: auth_route_gate_middleware(无 @web.middleware) + 且 install() 直接 insert 裸函数 + +另有已正确装饰但仍需契约兜底的:`route_guard_middleware.py`、 +`business_status_middleware.py`(它们的注册点分布在多条路径上,任一处漏装饰 +就会复现同类 500)。 + +修复策略(本文件,确定性、可自测) +---------------------------------- +1. `ensure_middleware(mw)` —— 幂等地把任意中间件规范成 new-style(补 + `__middleware_version__ = 1`),保留 `__name__`;非可调用/签名不符则拒绝。 +2. `assert_middleware_contract(seq)` —— 返回违规清单(供 /healthz 诊断与 selftest)。 +3. `normalize_chain(seq)` —— 就地规范化中间件序列,返回诊断 dict(不静默失败: + 不可修复项写进 `rejected`)。 +4. `safe_bind(mw, handler)` —— 构造 chain 时把 handler 绑到**第二个**形参位 + (`mw(request, handler)`),**禁止** `partial(mw, handler)` 这类把 handler 绑进 + 第一个(request)位的写法;并校验返回值必须是 `web.StreamResponse`,否则抛 + `MiddlewareContractError`(不产生 "Expect response, not function" 的隐晦 500)。 +5. `install_chain_contract(app)` —— 构造期/装配后统一规范化 `app._middlewares` + (或 `app.middlewares`),幂等,永不抛出(返回诊断)。 +6. `selftest()` —— 进程内 aiohttp 应用自测(/healthz GET/HEAD/POST/PUT/DELETE + + 受保护 .dspy 匿名/带 cookie 两口径 + 裸中间件回归),见 `tests/test_middleware_chain_contract.py`。 + +设计约束 +-------- +* 零应用内依赖(可被 scense.py / yuanjing.py 以 `import middleware_chain_contract` + 或 `from app import ...` 两种路径导入,不触发包内其他模块的副作用)。 +* 一切「不可修复」都进诊断,不 `except: pass`。 +""" + +import inspect +import logging + +log = logging.getLogger("yuanjing.middleware_chain_contract") + +#: 契约修订号(/healthz 与 selftest 断言用;改动契约语义必须递增) +CONTRACT_REVISION = "r52-chain-contract-1" + +#: aiohttp new-style 中间件标记属性 +MARKER = "__middleware_version__" + +#: aiohttp 要求中间件的形参个数(request, handler) +ARITY = 2 + + +class MiddlewareContractError(RuntimeError): + """中间件不符合 `(request, handler)` 签名契约,或返回了非响应对象。""" + + +# ---------------------------------------------------------------- 判定 +def _aiohttp_web(): + """取 aiohttp.web(取不到返回 None,调用方必须降级而不能静默成功)。""" + try: + from aiohttp import web as _w # noqa: PLC0415 + return _w + except Exception: # noqa: BLE001 + return None + + +def has_marker(mw): + """该中间件是否已带 new-style 标记(aiohttp 才不会再按 legacy 工厂调用)。""" + return getattr(mw, MARKER, None) == 1 + + +def positional_arity(fn): + """可调用对象接受的位置参数个数;无法判定时返回 -1。""" + try: + sig = inspect.signature(fn) + except (TypeError, ValueError): + return -1 + return len([ + p for p in sig.parameters.values() + if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD) + ]) + + +def is_middleware_shaped(mw): + """形如 `(request, handler)` 的协程函数(或带 1 个位置的 legacy 工厂)。""" + if not callable(mw): + return False + n = positional_arity(mw) + if n == ARITY: + return True + # legacy 工厂:def factory(app) -> middleware;本轮只告警,不当作本应用风格 + return False + + +def contract_violations(mw): + """返回单项违规描述列表(空列表 = 合规)。""" + out = [] + if not callable(mw): + out.append("not callable: %r" % (mw,)) + return out + if not is_middleware_shaped(mw): + out.append("signature != (request, handler): %s%s" + % (getattr(mw, "__name__", repr(mw)), kind_suffix(mw))) + if not has_marker(mw): + out.append("missing %s (aiohttp 会按 legacy 工厂 mw(app) 调用 → " + "'AHApp' object has no attribute 'remote'): %s" + % (MARKER, getattr(mw, "__name__", repr(mw)))) + return out + + +def kind_suffix(mw): + try: + return " (arity=%d)" % positional_arity(mw) + except Exception: # noqa: BLE001 + return "" + + +def assert_middleware_contract(seq): + """返回 `[{index, name, violations}]`(空列表 = 全部合规)。""" + out = [] + for i, mw in enumerate(seq or ()): + v = contract_violations(mw) + if v: + out.append({"index": i, "name": getattr(mw, "__name__", repr(mw)), + "violations": v}) + return out + + +def describe(seq): + """中间件名列表(诊断用)。""" + return [getattr(m, "__name__", repr(m)) for m in (seq or ())] + + +# ---------------------------------------------------------------- 规范化 +def ensure_middleware(mw, name=None): + """幂等规范化:返回带 new-style 标记的中间件;不合规抛 MiddlewareContractError。""" + if not callable(mw): + raise MiddlewareContractError("middleware not callable: %r" % (mw,)) + if not is_middleware_shaped(mw): + raise MiddlewareContractError( + "middleware signature must be (request, handler): %s%s" + % (getattr(mw, "__name__", repr(mw)), kind_suffix(mw))) + if has_marker(mw): + if name: + try_set_name(mw, name) + return mw + w = _aiohttp_web() + fixed = mw + if w is not None: + try: + fixed = w.middleware(mw) # aiohttp 官方装饰器,写入标记 + except Exception: # noqa: BLE001 + fixed = mw + if not has_marker(fixed): + try: + setattr(fixed, MARKER, 1) # 兜底:手工补标记 + except Exception as exc: # noqa: BLE001 + raise MiddlewareContractError( + "cannot mark middleware %s: %s" + % (getattr(mw, "__name__", repr(mw)), exc)) + try_set_name(fixed, name or getattr(mw, "__name__", None)) + return fixed + + +def try_set_name(mw, name): + if not name: + return mw + try: + mw.__name__ = name + except Exception: # noqa: BLE001 + pass + return mw + + +def normalize_chain(seq, only_missing_marker=True): + """就地规范化中间件序列,返回诊断 dict(幂等)。 + + :param seq: 可变序列(list)。`app._middlewares` / 构造期 mws 列表。 + :param only_missing_marker: True 时只补标记,不改变顺序、不删项。 + :return: {revision, total, normalized, rejected, names, ok} + """ + diag = {"revision": CONTRACT_REVISION, "total": 0, "normalized": [], + "rejected": [], "names": [], "ok": True} + if seq is None: + diag["ok"] = False + diag["rejected"].append("chain is None") + return diag + try: + total = len(seq) + except Exception: # noqa: BLE001 + diag["ok"] = False + diag["rejected"].append("chain not a sized sequence") + return diag + diag["total"] = total + for i in range(total): + mw = seq[i] + name = getattr(mw, "__name__", repr(mw)) + if has_marker(mw) and is_middleware_shaped(mw): + continue + try: + seq[i] = ensure_middleware(mw) + diag["normalized"].append({"index": i, "name": name}) + log.warning("[chain-contract] 第 %d 层中间件 %s 未带 %s,已规范化为 " + "new-style(原实现会被 aiohttp 按 legacy 工厂 mw(app) 调用)", + i, name, MARKER) + except MiddlewareContractError as exc: + diag["rejected"].append({"index": i, "name": name, "error": str(exc)}) + log.error("[chain-contract] 第 %d 层中间件 %s 不可修复:%s", i, name, exc) + diag["names"] = describe(seq) + diag["violations"] = assert_middleware_contract(seq) + diag["ok"] = not diag["rejected"] and not diag["violations"] + return diag + + +# ---------------------------------------------------------------- 绑定与执行 +def safe_bind(mw, handler): + """把 `handler` 绑到中间件的**第二个**形参位,返回 `chain(request)`。 + + **禁止** `partial(mw, handler)`:partial 会把 handler 填到第一个形参 + (request)位,请求对象反而落到 handler 位 —— 这正是 + 「`Expect response, not `」与 + 「`'AHApp' object has no attribute 'remote'`」两类 500 的同源错误绑定。 + """ + if not callable(mw): + raise MiddlewareContractError("middleware not callable: %r" % (mw,)) + + async def _chain(request): + resp = await mw(request, handler) + return assert_response(resp, mw, request) + + _chain.__name__ = "chain(%s)" % getattr(mw, "__name__", "mw") + return _chain + + +def assert_response(resp, mw=None, request=None): + """中间件必须返回 `web.StreamResponse`;否则抛清晰契约错误。""" + w = _aiohttp_web() + name = getattr(mw, "__name__", "mw") if mw is not None else "mw" + if w is None: + return resp + if isinstance(resp, w.StreamResponse): + return resp + if inspect.isawaitable(resp): + raise MiddlewareContractError( + "%s returned an awaitable (%r) —— 中间件漏 await" + % (name, type(resp).__name__)) + raise MiddlewareContractError( + "Expect response, not %r from %s (path=%s)" + % (type(resp).__name__, name, getattr(request, "path", "") or "?")) + + +def build_chain(middlewares, handler): + """按 `[最外层 ... 最内层]` 顺序组链,返回 `chain(request)`;全程契约校验。""" + chain = handler + for mw in reversed(list(middlewares or ())): + chain = safe_bind(ensure_middleware(mw), chain) + return chain + + +# ---------------------------------------------------------------- 装配钩子 +def _mw_list(app): + """取 app 的中间件列表(aiohttp 版本差异:`middlewares` / `_middlewares`)。""" + mws = getattr(app, "_middlewares", None) + if mws is not None: + return mws, "_middlewares" + mws = getattr(app, "middlewares", None) + if mws is not None: + return mws, "middlewares" + return None, None + + +def install_chain_contract(app, middlewares=None): + """构造期/装配后统一规范化中间件链签名契约(幂等,永不抛出)。 + + :param app: AHApp / aiohttp Application 实例(可为 None) + :param middlewares: 构造期 mws 列表(在 `make_app()` 之前先规范化) + :return: 诊断 dict(含 revision / normalized / rejected / names / ok) + """ + diag = {"revision": CONTRACT_REVISION, "ok": True, "sources": [], + "normalized": [], "rejected": [], "names": []} + try: + if middlewares is not None: + d = normalize_chain(middlewares) + diag["sources"].append("ctor-arg") + diag["normalized"] += d.get("normalized", []) + diag["rejected"] += d.get("rejected", []) + diag["ok"] = diag["ok"] and d.get("ok", True) + diag["names"] = d.get("names", []) + except Exception as exc: # noqa: BLE001 + diag["ok"] = False + diag["rejected"].append({"source": "ctor-arg", + "error": "%s: %s" % (type(exc).__name__, exc)}) + try: + if app is not None: + mws, src = _mw_list(app) + if mws is None: + diag["ok"] = False + diag["rejected"].append({"source": "app", "error": "no middleware list"}) + else: + d = normalize_chain(mws) + diag["sources"].append(src) + diag["normalized"] += d.get("normalized", []) + diag["rejected"] += d.get("rejected", []) + diag["ok"] = diag["ok"] and d.get("ok", True) + diag["names"] = d.get("names", []) + except Exception as exc: # noqa: BLE001 + diag["ok"] = False + diag["rejected"].append({"source": "app", + "error": "%s: %s" % (type(exc).__name__, exc)}) + if not diag["ok"]: + log.error("[chain-contract] 装配核验未通过 rejected=%s", diag["rejected"]) + else: + log.info("[chain-contract] 装配核验通过 rev=%s sources=%s names=%s", + CONTRACT_REVISION, diag["sources"], diag["names"]) + return diag diff --git a/app/_archive_disabled_20261028_151505/mw_signature.py b/app/_archive_disabled_20261028_151505/mw_signature.py new file mode 100644 index 0000000..d88dc5d --- /dev/null +++ b/app/_archive_disabled_20261028_151505/mw_signature.py @@ -0,0 +1,312 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""aiohttp 中间件链「签名归一 + 响应契约」模块(Bug rvtGi1mlUsTwQrXnybZj6 / R60 正解)。 + +问题(部署态实测,证据 docs/04-deploy/evidence_r58/) +---------------------------------------------------- +`POST /rbac/user/up_login.dspy` 恒返回 `401 {"error": "auth_unavailable"}`、无 `Set-Cookie`; +`logs/scense.log:1735-1737` 三条原文: + + [auth_strict] 闸门异常但为公开路径,放行: /rbac/user/up_login.dspy + [auth_strict] 拒绝匿名/无效会话请求: POST /rbac/user/up_login.dspy reason=auth_unavailable + [auth_strict] 闸门异常: Expect response, not + +`Expect response, not ` = **链上某层把「未 await 的 handler 函数本体」 +当成 Response 交回上游**。成因是 13 层中间件里新式 `async def (request, handler)` 与旧式工厂 +`def (app, handler) -> handler` 混排,链构建器按新式语义调用旧式工厂时拿到的就是 handler。 +真实故障被 `auth_gate_strict` 的 fail-closed `except` 降级成 401,于是「登录端点自己 401」, +形成 "先登录才能登录" 的自锁。 + +为什么不能靠 `__middleware_version__` 标记(R55 已实测失败,勿重试) +-------------------------------------------------------------------- +aiohttp>=3.9 用 `getattr(m, "__middleware_version__", None) == 1` 区分新式/旧式。 +但标记只能由 `web.middleware` 装饰器写在**函数对象**上;链上还可能存在 +`functools.partial` / 可调用实例 / 已冻结 FrozenList 中的对象,setattr 静默失败, +R55 目标机实测 `new_style_marked=[]`(标记一个没生效)。**标记不可依赖**。 + +本模块的正解 +------------ +不再猜协议、不再改原对象,而是把**每一个**中间件包成同一种适配器: + +* 适配器自身是 `async def (request, handler)`,且带 `__middleware_version__ = 1` + (属性写在适配器函数对象上,一定成功——不是去改别人的对象); +* 适配器**按返回值自适应**:awaitable → 新式,直接 await;callable → 旧式工厂, + 继续以其返回值收口(旧式 `(app, handler)` 用 `request.app` 作为 app 传入); +* 出口做**响应契约校验**:最终必须是非 callable 的 Response 类对象,否则抛 + `ResponseContractError`(含层名 + 原始类型)。这样「把 handler 当 Response 返回」 + 会立刻变成一条带层名的明确 500,而不是被下游闸门静默改写成 401。 + +用法(`app/scense.py::_load_semantic_middlewares()` 的调用点) +-------------------------------------------------------------- + from mw_signature import normalize_chain + mws = normalize_chain(_load_semantic_middlewares()) # 幂等 + + # app.middlewares 已就位时(钩子期兜底,可选): + from mw_signature import normalize_app_middlewares + normalize_app_middlewares(app) + +不依赖 aiohttp 的导入(本地无 aiohttp 的可测性):只在需要时探 `aiohttp.web` +判断 Response 类型,缺失时退化为鸭子类型(`hasattr(resp, "status")` 且非 callable), +因此单元自测可在无 aiohttp 环境跑(见 scripts/selftest_mw_signature.py)。 +""" + +import inspect +import logging + +log = logging.getLogger("yuanjing.mw_signature") + +#: aiohttp 判定新式中间件用的属性名(只写在我们自己的适配器上)。 +NEW_STYLE_ATTR = "__middleware_version__" + +#: 适配器幂等标记(同一对象重复 normalize 不再套娃)。 +ADAPTER_MARK = "__mw_signature_adapter__" + +#: 旧式工厂的第一形参惯用名。命中即按「工厂 (app, handler) -> handler」调用。 +LEGACY_FIRST_PARAMS = frozenset(("app", "application", "_app", "ahapp")) + +try: # pragma: no cover - 目标机必有 aiohttp,本机可能没有 + from aiohttp import web as _aiohttp_web + + _RESPONSE_BASES = (_aiohttp_web.StreamResponse,) +except Exception: # noqa: BLE001 + _aiohttp_web = None + _RESPONSE_BASES = () + + +class ResponseContractError(RuntimeError): + """中间件把非 Response(典型:handler 函数本体)返回给了上游。 + + 这不是 401 语义,是**装配/契约缺陷**:必须 500 + traceback,禁止降级成鉴权失败。 + """ + + +def describe(mw): + """给中间件一个可读名字(日志/诊断用,永不抛)。""" + for attr in ("__qualname__", "__name__"): + val = getattr(mw, attr, None) + if isinstance(val, str) and val: + mod = getattr(mw, "__module__", "") or "" + return ("%s.%s" % (mod, val)) if mod else val + return type(mw).__name__ + + +def is_new_style(mw): + """是否已被标记为新式 `(request, handler)` 中间件。""" + return getattr(mw, NEW_STYLE_ATTR, None) == 1 + + +def is_adapter(mw): + return bool(getattr(mw, ADAPTER_MARK, False)) + + +def _first_param_name(fn): + try: + params = list(inspect.signature(fn).parameters.values()) + except (TypeError, ValueError): + return None + for p in params: + if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD): + return p.name.lower() + return None + + +def _looks_like_response(obj): + """Response 判定:优先 aiohttp 真实类型,无 aiohttp 时鸭子类型。""" + if obj is None or callable(obj): + return False + if _RESPONSE_BASES and isinstance(obj, _RESPONSE_BASES): + return True + # 鸭子类型:StreamResponse 家族都有 status + headers + return hasattr(obj, "status") and hasattr(obj, "headers") and not inspect.iscoroutine(obj) + + +def _contract_check(result, mw, request): + """出口响应契约校验:非 Response → ResponseContractError(带层名,可定位)。""" + if _looks_like_response(result): + return result + path = getattr(request, "path", "-") + kind = type(result).__name__ + if callable(result): + detail = ("中间件返回了可调用对象(%s),而不是 Response —— " + "典型的旧式工厂被按新式调用 / handler 未 await 直接返回" % kind) + elif result is None: + detail = "中间件返回 None(漏写 return 或 return 写在 async with 块内)" + else: + detail = "中间件返回了非 Response 对象(%s)" % kind + raise ResponseContractError( + "Expect response, not %r: layer=%s path=%s: %s" + % (kind, describe(mw), path, detail)) + + +def _call_legacy_factory(factory, request, handler): + """旧式工厂:(app, handler) -> 新 handler。app 取 request.app(取不到给 None)。""" + app = getattr(request, "app", None) + return factory(app, handler) + + +async def _invoke(mw, request, handler): + """按返回值自适应地执行一层中间件,并做响应契约校验。""" + first = _first_param_name(mw) + if first in LEGACY_FIRST_PARAMS: + result = _call_legacy_factory(mw, request, handler) + else: + result = mw(request, handler) + if inspect.isawaitable(result): + return _contract_check(await result, mw, request) + if callable(result): + # 旧式工厂的返回值:新 handler,继续收口一次 + inner = result + resp = inner(request) + if inspect.isawaitable(resp): + resp = await resp + return _contract_check(resp, mw, request) + return _contract_check(result, mw, request) + + +def adapt(mw): + """把单个中间件包成统一的新式适配器(幂等;已是适配器则原样返回)。""" + if is_adapter(mw): + return mw + layer = describe(mw) + + async def _adapter(request, handler): + try: + return await _invoke(mw, request, handler) + except ResponseContractError: + # 契约错误原样上抛(带层名),由上层/闸门转 500 —— 绝不改写成 401 + raise + except Exception as exc: # noqa: BLE001 + log.exception("[mw-signature] 层内部异常 layer=%s path=%s: %s: %s", + layer, getattr(request, "path", "-"), + type(exc).__name__, exc) + raise + + try: + _adapter.__name__ = "%s__mw_sig" % getattr(mw, "__name__", type(mw).__name__) + _adapter.__qualname__ = _adapter.__name__ + _adapter.__doc__ = "mw_signature adapter of %s" % layer + except Exception: # noqa: BLE001 + pass + setattr(_adapter, NEW_STYLE_ATTR, 1) + setattr(_adapter, ADAPTER_MARK, True) + setattr(_adapter, "__mw_signature_of__", layer) + return _adapter + + +def normalize_chain(middlewares): + """归一整个中间件列表(顺序不变:列表首位 = 最外层)。 + + :param middlewares: 可迭代中间件 + :return: `ChainResult`(list 子类,可直接 `app = web.Application(middlewares=normalize_chain(mws))`)。 + 报告从属性取:`res = normalize_chain(mws); res.report`。 + + [fix rvtGi1mlUsTwQrXnybZj6 / R61] 旧的 docstring 声称支持 + `mws, report = normalize_chain(x)` 两值解包 —— **实际不支持**:ChainResult 是 + list 子类,两值解包会按元素迭代,抛 `ValueError: too many values to unpack` + (R61 本地 e2e 实测命中)。这里改正契约说明,避免调用方按错误文档取报告。 + """ + out = [] + table = [] + for pos, mw in enumerate(list(middlewares or ())): + layer = describe(mw) + old_style = _first_param_name(mw) in LEGACY_FIRST_PARAMS + marked = is_new_style(mw) + wrapped = adapt(mw) + out.append(wrapped) + table.append({ + "pos": pos, + "layer": layer, + "new_style_marked": marked, + "legacy_factory": old_style, + "adapter": describe(wrapped), + }) + report = { + "count": len(out), + "legacy_factory_layers": [e["layer"] for e in table if e["legacy_factory"]], + "unmarked_layers": [e["layer"] for e in table if not e["new_style_marked"]], + "all_adapter_verified": all(is_new_style(m) for m in out), + "table": table, + } + if report["legacy_factory_layers"] or report["unmarked_layers"]: + log.warning("[mw-signature] 链签名归一:%d 层;旧式工厂=%s;原未标记=%s;全部适配器新式=%s", + report["count"], report["legacy_factory_layers"], + report["unmarked_layers"], report["all_adapter_verified"]) + else: + log.info("[mw-signature] 链签名归一:%d 层,全部已是新式并完成适配器包裹", report["count"]) + return ChainResult(out, report) + + +class ChainResult(list): + """兼容 `mws = normalize_chain(x)` 直接赋值用法:本体就是中间件列表。""" + + def __init__(self, items, report): + super().__init__(items) + self.report = report + + +def normalize_app_middlewares(app): + """把 `app.middlewares` 就地换成一整串适配器(含子应用),幂等。 + + 返回 report dict。FrozenList 已冻结时依次尝试 `_list[i]` / `_middlewares` 回写; + 全部失败则记 ERROR(该层仍按原样进链,但下一次 normalize 会再试)。 + """ + fixed = [] + + def _fix(application): + mws = getattr(application, "middlewares", None) + if not mws: + return + for i, m in enumerate(list(mws)): + nm = adapt(m) + if nm is m: + continue + written = False + for target in (mws, getattr(mws, "_list", None)): + if target is None: + continue + try: + target[i] = nm + written = True + break + except Exception: # noqa: BLE001 + continue + if written: + fixed.append(describe(m)) + else: + log.error("[mw-signature] 回写 app.middlewares 失败 pos=%s layer=%s", i, describe(m)) + + _fix(app) + for sub in list(getattr(app, "_subapps", None) or ()): + _fix(sub) + report = {"normalized": fixed, "count": len(fixed)} + if fixed: + log.info("[mw-signature] app.middlewares 归一 %d 层: %s", len(fixed), fixed) + return report + + +def signature_table(middlewares): + """纯诊断:13 层中间件的签名形态表(写进 /healthz 诊断或启动日志)。""" + table = [] + for pos, mw in enumerate(list(middlewares or ())): + table.append({ + "pos": pos, + "layer": describe(mw), + "new_style_marked": is_new_style(mw), + "adapted": is_adapter(mw), + "legacy_factory": _first_param_name(mw) in LEGACY_FIRST_PARAMS, + }) + return table + + +__all__ = [ + "NEW_STYLE_ATTR", + "ResponseContractError", + "ChainResult", + "adapt", + "normalize_chain", + "normalize_app_middlewares", + "signature_table", + "is_new_style", + "is_adapter", + "describe", +] diff --git a/app/_archive_disabled_20261028_151505/notfound_middleware.py b/app/_archive_disabled_20261028_151505/notfound_middleware.py new file mode 100644 index 0000000..0b77ee8 --- /dev/null +++ b/app/_archive_disabled_20261028_151505/notfound_middleware.py @@ -0,0 +1,367 @@ +#!/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 diff --git a/app/_archive_disabled_20261028_151505/ns_wiring.py b/app/_archive_disabled_20261028_151505/ns_wiring.py new file mode 100644 index 0000000..5fce697 --- /dev/null +++ b/app/_archive_disabled_20261028_151505/ns_wiring.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +"""宿主侧命名空间注入接线(T4 dspy 命名空间符号注入族)。 + +【为什么在宿主侧还有一层】ahserver 的 .dspy exec 命名空间由框架在每次请求构造, +模块 init() 的 `env.xxx = xxx` 只有在**宿主已调用 load_{模块}()** 时才生效;基础模块 +(rbac / appbase / bricks)是 referenced_modules(只读,禁改源码),其端点若依赖 +`env.` 而该符号未挂/为 None,表现就是 `TypeError: 'NoneType' object is not callable` +(R42 集群⑤)。宿主侧做两件确定性的事: + + 1) `install_module_shim(env)` —— 把业务模块的统一 dispatch 入口挂到 ServerEnv + (scense.ns_shim.install;scense_demo 复用同一入口,不重复实现)。 + 2) `backfill_base_handlers(env)` —— 对只读基础模块,用包内真实存在且可调用的函数 + 补齐 `env.`(**只 setattr 运行时命名空间,不改基础模块源码**); + 包内也不存在的符号不伪造,交由端点侧 ns-shim 给 `symbol-missing:` 结构化 503。 + +接线点:应用入口 init() 在全部 load_*() 之后调用本模块 `wire_ns_shim(env)`,并把 +`env.NS_SHIM_WIRING` 落一条自证字符串,供部署态核验。 +""" +import importlib + +NS_WIRING_VERSION = "t4-host-wiring-1" + +# 只读基础模块的 handler 候选(包内存在才补;不存在不伪造) +BASE_HANDLER_CANDIDATES = ( + ("rbac", ("find_unauth_files", "list_path_roles", "list_roles", "list_permissions")), + ("appbase", ("get_code", "get_appcodes_kv", "get_appcodes", "get_svgicon")), + ("bricks", ("list_tables", "tableinfo", "channels")), +) + + +def _get(env, name): + try: + return getattr(env, name, None) + except Exception: + return None + + +def _set(env, name, value): + try: + setattr(env, name, value) + return True + except Exception: + pass + try: + env[name] = value + return True + except Exception: + return False + + +def install_module_shim(env): + """业务模块统一 dispatch 入口(scense 域)。返回已挂载入口名清单。""" + installed = [] + if _get(env, "dspy_ns_call") is not None: + installed.append("dspy_ns_call(pre-existing)") + try: + from scense.ns_shim import install as _install_scense + _install_scense(env) + installed.append("scense.ns_shim.install") + except Exception as exc: # 模块未装/未加载都不是致命错误,但要留痕 + installed.append("scense.ns_shim.install:FAIL:%s" % exc) + return installed + + +def backfill_base_handlers(env): + """只读基础模块 handler 补齐。返回 {module: [补齐符号]}。""" + report = {} + for modpath, names in BASE_HANDLER_CANDIDATES: + try: + mod = importlib.import_module(modpath) + except Exception: + report[modpath] = [] + continue + fixed = [] + for name in names: + fn = getattr(mod, name, None) + if fn is None or not callable(fn): + continue + if _get(env, name) is None: + if _set(env, name, fn): + fixed.append(name) + report[modpath] = fixed + return report + + +def matrix(env): + """注入生效矩阵(部署态可经 /scense/api 侧或脚本读取)。""" + rows = [] + try: + from scense.ns_shim import ns_shim_matrix + rows = ns_shim_matrix(env).get("rows", []) + except Exception: + rows = [] + base = {} + for modpath, names in BASE_HANDLER_CANDIDATES: + base[modpath] = { + name: { + "env_has": _get(env, name) is not None, + "module_has": _module_has(modpath, name), + } for name in names + } + return {"version": NS_WIRING_VERSION, "ops": rows, "base_handlers": base} + + +def _module_has(modpath, name): + try: + mod = importlib.import_module(modpath) + except Exception: + return False + return callable(getattr(mod, name, None)) + + +def wire_ns_shim(env): + """入口接线:install + backfill + 自证。永不抛异常(启动期不能让接线阻断服务)。""" + out = {"version": NS_WIRING_VERSION, "installed": [], "backfilled": {}, "error": None} + try: + out["installed"] = install_module_shim(env) + except Exception as exc: + out["error"] = "install:" + str(exc) + try: + out["backfilled"] = backfill_base_handlers(env) + except Exception as exc: + out["error"] = (out["error"] or "") + "|backfill:" + str(exc) + summary = "; ".join( + ["installed=" + ",".join(out["installed"])] + + ["%s=%s" % (k, ",".join(v) or "-") for k, v in out["backfilled"].items()] + ) + try: + env.NS_SHIM_WIRING = NS_WIRING_VERSION + " " + summary + env.ns_shim_matrix = lambda: matrix(env) # noqa: E731 部署态自检入口 + except Exception: + pass + return out diff --git a/app/_archive_disabled_20261028_151505/password_key_resolve.py b/app/_archive_disabled_20261028_151505/password_key_resolve.py new file mode 100644 index 0000000..f10d935 --- /dev/null +++ b/app/_archive_disabled_20261028_151505/password_key_resolve.py @@ -0,0 +1,152 @@ +# -*- coding: utf-8 -*- +"""PASSWORD_KEY 占位解析兜底 —— 两个入口共用的**单一实现**(QC #9/#15 根因修复)。 + +背景(QC #15 实测): + conf/config.json 的 password_key 写的是 `$[env:PASSWORD_KEY]$`(环境变量注入形态), + 但实测 `getConfig().password_key` 返回的**仍是未解析的占位字面量**(len=20, + prefix='$[en'),而 .env 的 `env.PASSWORD_KEY` 是有效值(len=24)。sqlor 的 + `unpassword()` 正是拿 config.password_key 当 AES key 去解 db password, + 于是报 `Invalid base64-encoded string`,最终 MySQL `using password: NO` (1045) + → `/rbac/user/up_login.dspy` 401 auth_unavailable。 + + 同文件对 `DB_PASSWORD` 早已写了「占位校验 + 回落兜底」(apps/yuanjing/app/yuanjing.py + L488/L822+),`PASSWORD_KEY` 缺对称逻辑 —— 本模块补齐,并保证 + app/scense.py(目标机实际运行的入口)与 app/yuanjing.py(现行入口)**同一份实现**。 + +与 DB_PASSWORD 对称的三件事: + ① 识别占位字面量(非空字符串,真值判断会把 '= 有效 key',这正是缺陷机制); + ② 三路事实源回落:进程环境 PASSWORD_KEY → /run/password_key → 现场生成+持久化; + ③ 就地回填 os.environ 与已构造的 config 对象,并打印 `[password-key] source=env|file|generated`。 + +本模块无任何凭据字面量,key 一律不回显(只打长度/来源)。 +""" + +import os + +PLACEHOLDER_HINT = '$[env:PASSWORD_KEY]$' + + +def is_unresolved_placeholder(v): + """判断 v 是否为未解析的 `$[...]$` 占位字面量(如 '$[env:PASSWORD_KEY]$')。 + + **不能**用真值判断:占位字面量是非空 str,`if key:` 会把它当有效 key 一路用下去。 + """ + if not isinstance(v, str): + return False + s = v.strip() + if not s: + return False + return s.startswith('$[') and s.endswith(']$') + + +def read_key_file(app_dir): + """读部署本地 key 文件 /run/password_key;缺失/不可读 → None(不抛)。""" + path = os.path.join(app_dir, 'run', 'password_key') + try: + with open(path, 'r', encoding='utf-8') as fp: + return (fp.read() or '').strip() or None + except Exception: # noqa: BLE001 + return None + + +def _persist(app_dir, key, logger=None): + path = os.path.join(app_dir, 'run', 'password_key') + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, 'w', encoding='utf-8') as fp: + fp.write(key) + try: + os.chmod(path, 0o600) + except Exception: # noqa: BLE001 + pass + return True + except Exception as exc: # noqa: BLE001 + if logger: + logger.error("[password-key] 自动生成的 key 无法持久化到 %s:%s", path, exc) + return False + + +def default_resolver(app_dir, logger=None): + """默认三路解析:进程环境 → 本地 key 文件 → 现场生成+持久化。返回 (key, source)。""" + key = (os.environ.get('PASSWORD_KEY') or '').strip() or None + if key and is_unresolved_placeholder(key): + if logger: + logger.warning("[password-key] 进程环境 PASSWORD_KEY 本身是占位字面量(len=%d),忽略", + len(key)) + key = None + if key: + return key, 'env' + key = read_key_file(app_dir) + if key and is_unresolved_placeholder(key): + key = None + if key: + return key, 'file' + import secrets + gen = secrets.token_hex(16) + if _persist(app_dir, gen, logger=logger): + if logger: + logger.warning("[password-key] 未注入:按部署期自动生成策略在本机生成并持久化到 " + "/run/password_key(0600, len=%d);该文件须随部署目录保留," + "否则既有口令将无法解密。", len(gen)) + return gen, 'generated' + return None, None + + +def normalize_password_key_env(app_dir, resolver=None, logger=None): + """把解析后的 PASSWORD_KEY 回填进程环境 + 已构造 config 对象。 + + 调用时机(硬约束):load_app_env_file() 之后(模块导入期已执行)、 + **任何 getConfig() / sqlor unpassword() 之前**。 + 返回 True = key 可用且已回填;False = 三路事实源皆不可得。 + """ + resolver = resolver or (lambda: default_resolver(app_dir, logger=logger)) + key, source = resolver() + if not key: + if logger: + logger.error("[password-key] 三路事实源皆不可得(env / /run/password_key / " + "自动生成),sqlor 解 db 口令将失败;检查 /.env 与 run/ 权限") + return False + + # ① 回填进程环境:getConfig() 每次从 conf/config.json 经 argsConvert 解析 + # $[env:PASSWORD_KEY]$,改写 os.environ 即对后续读取生效。 + os.environ['PASSWORD_KEY'] = key + + patched = [] + # ② 就地改写已构造的 appPublic config 对象(argsConvert 未解析时的兜底) + try: + from appPublic.jsonConfig import getConfig + cfg = getConfig(app_dir) + cur = getattr(cfg, 'password_key', None) + if is_unresolved_placeholder(cur) or not cur: + try: + cfg.password_key = key + patched.append('appPublic.jsonConfig(%s)' % app_dir) + if logger: + logger.warning("[password-key] config.password_key 原值为未解析占位" + "(len=%s),已回填解析后 key", + len(cur) if isinstance(cur, str) else cur) + except Exception as exc: # noqa: BLE001 + if logger: + logger.error("[password-key] config.password_key 回填失败:%s", exc) + else: + patched.append('appPublic.jsonConfig(already-resolved)') + except Exception as exc: # noqa: BLE001 + if logger: + logger.warning("[password-key] appPublic.jsonConfig 不可用,跳过 config 回填:%s", exc) + + # ③ 基座 globalEnv 若缓存了配置对象,同样修正占位字面量 + try: + import ahserver.globalEnv as _ge + for attr in ('_CONFIG', '_config', 'CONFIG', '_global_config'): + obj = getattr(_ge, attr, None) + if obj is not None and is_unresolved_placeholder(getattr(obj, 'password_key', None)): + setattr(obj, 'password_key', key) + patched.append('ahserver.globalEnv.%s' % attr) + except Exception: # noqa: BLE001 + pass + + if logger: + logger.info("[password-key] source=%s len=%d placeholder_patched=%s", + source, len(key), patched or 'none') + return True diff --git a/app/_archive_disabled_20261028_151505/response_contract_guard.py b/app/_archive_disabled_20261028_151505/response_contract_guard.py new file mode 100644 index 0000000..c51aace --- /dev/null +++ b/app/_archive_disabled_20261028_151505/response_contract_guard.py @@ -0,0 +1,176 @@ +# -*- coding: utf-8 -*- +"""响应契约守卫(最内层中间件)—— Bug Zhpd9MXyeGtznDMqini6X / R50 + +## 为什么需要它(R50 机械取证定案) + +R49 全站矩阵把 500 记为 0,R50 在目标机日志里取到真实栈(`logs/*.log`): + + ------ /index.ui RuntimeError ----- + Traceback (most recent call last): + File ".../app/entry_semantics.py", line 485, in entry_notfound_guard + return await handler(request) + File ".../app/route_guard_middleware.py", line 499, in resource_preflight_middleware + return await handler(request) + File ".../app/route_guard_middleware.py", line 533, in auth_required_middleware + return await handler(request) + File ".../app/route_guard_middleware.py", line 579, in write_endpoint_semantics_middleware + return await handler(request) + File ".../app/business_status_middleware.py", line 153, in business_status_middleware + response = await handler(request) + File ".../venv/lib/python3.10/site-packages/aiohttp_session/__init__.py", line 196, in factory + raise RuntimeError(f"Expect response, not {type(response)!r}") + RuntimeError: Expect response, not + +即:链尾**路由处理器返回了一个函数对象**,不是 `web.Response`。 +`aiohttp_session.factory` 在拿到非 Response 时抛 RuntimeError,异常沿中间件链上抛, +最终被鉴权/状态层改写成一个「鉴权失败」样子的响应 —— 这就是 +「38 条 500 一条也判不出来 / 500 计数=0」的机械原因: +**处理器契约破坏(返回函数)与鉴权失败是两件事,却被压成同一种 401 表象。** + +## 本守卫做两件事(顺序即语义) + +1. **救援**:链尾返回可调用对象时,把它当作真实处理器调用一次 + (`resp(request)`,awaitable 则 await)。这是 aiohttp 装配期的「handler 未落地」 + 短路,直接补上这一次调用即可拿到真正的 Response —— 站点能力立刻恢复, + 而不是让整站挂在鉴权表象上。 +2. **归因**:救援失败 / 返回 None / 返回非法对象时,返回 **500 + 处理器身份** + (模块名、限定名、源文件、行号),让 500 可被矩阵判为「真实 500 并指名道姓」, + 绝不再伪装成 401 auth_unavailable。 + +作为 `app.middlewares` 的**最后一位(最内层)**装配:只有贴住路由处理器, +才能观察到「处理器返回值」本身;装在外层只会看到已经被 factory 判定过的结果。 + +## 不变量(不得放宽) + +- 正常 Response(含所有 4xx/5xx 语义响应)**原样透传**,一个字节都不改。 +- 守卫自身异常一律吞掉并放行原响应,避免守卫成为新的单点故障。 +""" + +import inspect +import logging + +log = logging.getLogger("yuanjing.response_contract_guard") + +#: 装配标记,供 /healthz 与自检读取 +REVISION = "r50-response-contract-v1" + + +def _handler_identity(fn): + """取可调用对象身份:模块 / 限定名 / 源文件 / 行号。取不到就给 repr。""" + ident = {"repr": repr(fn)[:300]} + try: + ident["module"] = getattr(fn, "__module__", "") or "" + ident["qualname"] = getattr(fn, "__qualname__", "") or getattr(fn, "__name__", "") or "" + except Exception: # noqa: BLE001 + pass + try: + src = inspect.getsourcefile(fn) + if src: + ident["file"] = src + _, lineno = inspect.getsourcelines(fn) + ident["line"] = lineno + except Exception: # noqa: BLE001 + pass + return ident + + +def _is_response(obj): + """宽松判定:aiohttp Response 及其子类(含各 HTTPException 派生)。""" + try: + from aiohttp import web + return isinstance(obj, web.StreamResponse) + except Exception: # noqa: BLE001 + return hasattr(obj, "status") and hasattr(obj, "headers") + + +def _json_response(payload, status): + """构造 JSON 响应。**不得传 dumps=None**——aiohttp 的 `json_response(dumps=None)` + 会退化成 text/plain 直发 repr,导致 500 归因体不是合法 JSON、矩阵无法解析。""" + from aiohttp import web + try: + return web.json_response(payload, status=status) + except Exception: # noqa: BLE001 + import json as _json + return web.Response(status=status, + text=_json.dumps(payload, ensure_ascii=False, default=str), + content_type="application/json") + + +def _fail(request, reason, ident, rescued_error=""): + payload = { + "ok": False, + "success": False, + "status": 500, + "error": "response_contract_violation", + "reason": reason, + "path": getattr(request, "path", "") or "", + "method": (getattr(request, "method", "GET") or "GET").upper(), + "handler": ident, + "rescued_error": str(rescued_error)[:500], + "guard_revision": REVISION, + "hint": "处理器返回值不是 Response:见 handler.file/line 定位真实缺陷点", + } + log.error("[response_contract] %s %s %s -> %s %s", + payload["method"], payload["path"], reason, ident.get("qualname", "-"), + ident.get("file", "-")) + return _json_response(payload, 500) + + +# [fix rvtGi1mlUsTwQrXnybZj6 / R60] 显式登记为新式中间件:aiohttp>=3.9 用 +# `__middleware_version__ == 1` 区分新式/旧式,裸协程函数会被当旧式工厂调用 +# → 返回 handler 函数而不是 Response(`Expect response, not `)。 +from aiohttp import web as _r60_web # noqa: E402 +@_r60_web.middleware +async def response_contract_guard(request, handler): + """最内层响应契约守卫:救援「返回函数」的处理器,并把契约破坏归因为 500。""" + try: + resp = await handler(request) + except Exception: # noqa: BLE001 + # 处理器自身异常:原样上抛,交给既有的 500/业务状态层(不在此改写语义) + raise + + if _is_response(resp): + return resp + + if resp is None: + return _fail(request, "handler_returned_none", {"repr": "None"}) + + if callable(resp): + ident = _handler_identity(resp) + # ① 救援:把返回的函数当作真实处理器调用一次 + try: + rescued = resp(request) + if inspect.isawaitable(rescued): + rescued = await rescued + except Exception as exc: # noqa: BLE001 + return _fail(request, "handler_returned_callable_rescue_failed", ident, exc) + if _is_response(rescued): + log.warning("[response_contract] rescued callable handler %s -> %s", + ident.get("qualname", "-"), getattr(rescued, "status", "?")) + return rescued + return _fail(request, "handler_returned_callable_rescue_nonresponse", ident, + "type=%r" % (type(rescued),)) + + return _fail(request, "handler_returned_nonresponse", + {"repr": repr(resp)[:300], "type": type(resp).__name__}) + + +def install(app): + """把守卫装到 app.middlewares 的**最后一位(最内层)**。幂等,永不抛出。""" + out = {"ok": False, "revision": REVISION, "names": [], "error": ""} + try: + mws = getattr(app, "middlewares", None) + if mws is None: + out["error"] = "app.middlewares unavailable" + return out + names = [getattr(m, "__name__", repr(m)) for m in mws] + if response_contract_guard.__name__ in names: + out.update(ok=True, names=names) + return out + app.middlewares.append(response_contract_guard) + out.update(ok=True, names=[getattr(m, "__name__", repr(m)) for m in app.middlewares]) + return out + except Exception as exc: # noqa: BLE001 + out["error"] = "%s: %s" % (type(exc).__name__, exc) + log.error("[response_contract] install failed: %s", out["error"]) + return out diff --git a/app/_archive_disabled_20261028_151505/route_guard_middleware.py b/app/_archive_disabled_20261028_151505/route_guard_middleware.py new file mode 100644 index 0000000..da0d71e --- /dev/null +++ b/app/_archive_disabled_20261028_151505/route_guard_middleware.py @@ -0,0 +1,697 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""路由前置守卫中间件(404 前置判定 + 未登录 401 + 写类端点方法/参数语义)。 + +覆盖的 Bug(本轮 R23/R26/R27/R28 反复复发的四条族) +-------------------------------------------------- +* Bug P-Wn8NVUTBfdnHrGe4bKF / WXXvXrhHsHuHz2t37y54Y + 「不存在路径 /nonexist/x.dspy 返回 401(或 500)而不是 404」。 + 根因:ahserver 的 processorResource 先解析路径再进鉴权,路径解析失败抛 + `invalid path`(processorResource.py:458 fpath is None)→ auth_api.py:189 把它 + 当后端异常。鉴权层在**处理器之前**执行,因此无 cookie 时 401 先于 404 返回, + 应用层「异常转 404」的中间件根本没有机会执行(异常没抛到中间件层)。 + 修法:在 aiohttp 中间件里(= 早于处理器与鉴权)**先做路由存在性判定**: + 把 URL 映射到 `/wwwroot/...`,文件不存在直接 404 JSON, + 存在才放行给 ahserver 继续处理。这就是「fpath is None 的 404 兜底前置化」。 + +* Bug WhOYy9nvG-EfNgZMvbJbO + 「未登录访问 /scense/api/list_scense.dspy 返回 200 业务信封」。 + 根因:该端点未登记进 permission 表 / 无 rolepermission 绑定,ahserver 的 + check_perm 判定为「无记录即公开」→ 匿名可读业务数据;而同层的**不存在**路径 + 却返回 401,语义自相矛盾,且数据就绪后即为数据泄露面。 + 修法:`auth_required_middleware` —— 对**已存在**的 `.dspy` 端点,若请求不带 + 任何会话凭据(cookie / Authorization / 显式旁路口令)且不在公开白名单内, + 直接 401(附 WWW-Authenticate),不再放行到业务处理器。 + 与 404 的关系:本中间件挂在 `resource_preflight_middleware` **之内**, + 因此「不存在路径」永远先拿到 404,不会退化成 401。 + +* Bug CQIpIoIJODNdSt4B0VvVv / YSNA7decF_pOeeKOimSaH / Zhpd9MXyeGtznDMqini6X + 「生成型 CRUD 写操作接口被 GET 调用 / 无参调用时抛 sqlor 1364 默认值错误 → 500」。 + 根因:写类端点(*_create/*_update/*_delete/*_add/*_save/_import)在 GET 或无参 + 请求下把 None 交给 sor.C/U/D,DB 侧报 + `(1364, "Field 'xxx' doesn't have a default value")` → 泛化 500。 + 修法:①写类端点只接受 POST → GET/HEAD 返回 405 + Allow: POST, OPTIONS(RFC 7231 §6.5.5); + ②POST 但请求体内一个业务参数都没有 → 400(缺必填参数语义), + 不进 DB,天然消除 1364;其余端点行为不变。 + 与方法语义的顺序:405 判定**先于**鉴权门禁(`auth_required_middleware` 对 + 写类 GET/HEAD 主动让行),保证无 cookie 的 GET 写端点也拿到 405 而非 401 —— + 「方法不允许」与「调用者身份」正交,方法错应先报。 + +设计约束(不掩错) +------------------ +* 只判定 `.dspy` / `.ui` 两类资源的存在性(其它静态资源交给 ahserver 正常处理)。 +* 鉴权门禁只作用于 `.dspy` 端点(`.ui` 页面由 ahserver 会话层处理,避免把登录页 + 自身锁死);公开白名单集中声明在 `PUBLIC_DSPY_PATHS`。 +* 存在性判定**任一候选命中即放行**:`wwwroot/`、`wwwroot//index.ui`, + 以及模块未软链的情况(`pkgs//wwwroot/`)。 +* 只改 404 / 401 / 405 / 400 四类语义;读类端点的真实故障仍是 500,不被伪装成 4xx。 +* 中间件自身异常一律放行原响应,绝不因守卫代码影响业务。 +""" + +import base64 +import json +import logging +import os +import re +import time +from aiohttp import web + +log = logging.getLogger("yuanjing.route_guard") + +# 参与存在性判定的后缀(页面与接口) +GUARDED_SUFFIXES = (".dspy", ".ui") + +# 写类端点词干(命中即视为「只接受 POST」) +_WRITE_STEM_RE = re.compile( + r"(^|_)(create|update|delete|add|save|edit|import|remove|destroy|upsert)(_|$)", + re.IGNORECASE, +) +# 例外:这些端点虽然名字里带写词,但语义是读(下拉/列表/查询) +_READ_ONLY_EXCEPTIONS = re.compile(r"(^|_)(list|get|search|query|options)(_|$)", re.IGNORECASE) + +# 登录/会话类端点:不吃业务参数的 POST 也要放行 +_PARAM_EXEMPT_STEMS = ("up_login", "login", "logout", "up_logout") + +# ── 鉴权门禁(Bug WhOYy9nvG-EfNgZMvbJbO)───────────────────────────────────── +#: 无需会话即可访问的 .dspy 端点(登录/注册/登出/健康探针)。 +#: 只收**端点自身语义就是「未登录也要能调」**的路径,其余一律要求会话。 +# ── 鉴权豁免清单:**单一真源** app/auth_exempt.py(T4 / Bug uifdBIMdmixZcmJEdV33O)── +# 本文件此前自带一份 PUBLIC_DSPY_PATHS 副本;与 auth_route_gate / auth_gate_strict / +# notfound_middleware 内联兜底闸门的副本各自演化,导致「主线豁免、兜底不豁免」。 +def _load_auth_exempt(): + """加载豁免清单模块(部署态 sys.path 可能不含应用根,禁裸包导入)。""" + try: + from app import auth_exempt as mod # type: ignore + return mod + except Exception: # noqa: BLE001 + import importlib.util + module_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), + "auth_exempt.py") + spec = importlib.util.spec_from_file_location("yj_auth_exempt_rg", module_path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +EXEMPT = _load_auth_exempt() + + +def _derive_exempt_manifest(mod): + """由单一真源 `auth_exempt.manifest()` **自动派生**镜像(禁止手工复制字面量)。 + + [fix rvtGi1mlUsTwQrXnybZj6 / T4-A] 本文件的 PUBLIC_DSPY_PATHS / + PUBLIC_PATH_PREFIXES 仅是**派生镜像**(诊断 + 自测交叉断言); + 判定一律走 `EXEMPT.is_exempt_path()`(见 `_is_exempt_path()`)。 + """ + if mod is None: + return {"revision": "unavailable", "dspy_paths": frozenset(), + "prefixes": (), "wildcard_free": False} + try: + mf = mod.manifest() + except Exception: # noqa: BLE001 + return { + "revision": getattr(mod, "EXEMPT_REVISION", "unavailable"), + "dspy_paths": frozenset(getattr(mod, "AUTH_EXEMPT_DSPY_PATHS", ()) or ()), + "prefixes": tuple(getattr(mod, "AUTH_EXEMPT_PREFIXES", ()) or ()), + "wildcard_free": False, + } + return { + "revision": str(mf.get("revision") or "unavailable"), + "dspy_paths": frozenset(mf.get("dspy_paths") or ()), + "prefixes": tuple(mf.get("prefixes") or ()), + "wildcard_free": bool(mf.get("wildcard_free", False)), + } + + +EXEMPT_MANIFEST = _derive_exempt_manifest(EXEMPT) + +#: 公开端点(**精确路径**)—— 由 manifest() 派生,只读,禁止在本文件增删字面量。 +PUBLIC_DSPY_PATHS = EXEMPT_MANIFEST["dspy_paths"] + +#: 公开前缀(健康探针/静态探测;按路径段边界匹配)—— 由 manifest() 派生。 +PUBLIC_PATH_PREFIXES = EXEMPT_MANIFEST["prefixes"] + +# ── 统一会话鉴权拦截(QC #2/#3 整改:凭据"存在性" → 凭据"可验证性")────────── +#: 受闸门约束的资源后缀(只拦动态端点;.ui/.js/.css 由框架会话层处理)。 +GUARDED_SUFFIX = ".dspy" + +#: 鉴权闸门总开关的环境变量名(`YJ_AUTH_GATE=off` 仅限排障,生产禁开)。 +#: 2026-10-23 修复:`_gate_enabled()` 引用了未定义名 `_GATE_ENV` → NameError → +#: 鉴权中间件 fail-closed 成 `auth_unavailable` 401,把**已存在的受保护端点** +#: 与写类 405/400 语义一并吞掉(selftest 用例 2~5 FAIL 的直接原因)。 +_GATE_ENV = "YJ_AUTH_GATE" + +#: 会话 cookie **精确名**(默认 AIOHTTP_SESSION + conf 的 website.session.cookie_name)。 +#: 收敛说明:旧实现用 `_SESSION_COOKIE_HINTS = ("sid", "session", "token")` 做**子串** +#: 匹配 —— 等价于"名字里带 sid/session/token 的任意 cookie 都算登录凭据",属隐式通配放行 +#: (`Cookie: sid=x` 即可绕过)。该启发式本轮**删除**,只认精确名。 +SESSION_COOKIE_NAME_DEFAULT = "AIOHTTP_SESSION" +#: 与 ahserver `AuthAPI.setupAuth` 的 `EncryptedCookieStorage(max_age=24*60*60)` 对齐。 +SESSION_COOKIE_MAX_AGE = 24 * 60 * 60 + +#: 必须走统一会话校验的 URL 前缀(覆盖率核对基线,防"只补被测 3 条")。 +#: 判定规则本身是"所有 .dspy 端点一律校验",前缀清单只用于逐前缀核对。 +AUTH_REQUIRED_PREFIXES = ("/scense", "/runtime", "/scene", "/world", "/entity") + +#: ── 受保护端点**逐条点名**登记(Bug vRZrx0Ff9tz / R18 复测)─────────────── +#: 背景:判定规则本身是「所有非公开 `.dspy` 一律校验」,但部署态实测 +#: `/scense/api/list_scense.dspy` 匿名 200 —— 说明规则未被命中(链未装配,见 +#: `app/auth_gate_strict.py` 文档的根因链)。除修装配外,这里再补一层 +#: **显式点名**:即便某个端点的后缀/写法判定被绕过,只要在点名表里就必须 +#: 走会话校验(`is_protected_endpoint` 先于后缀判定)。 +#: 登记口径:逐条对照磁盘真实文件(module-development-spec 硬门禁 #6), +#: 禁通配;由 `scripts/verify_auth_coverage.py` 机械核对「磁盘 .dspy 全集 ⊇ 点名表」 +#: 与「点名表每条在磁盘存在」。 +PROTECTED_DSPY_PATHS = frozenset(( + # scense —— 本单 R18 复测实测匿名 200 的端点(读类,必须有会话) + "/scense/api/list_scense.dspy", + "/scense/api/list_session.dspy", + "/scense/api/session_attrs.dspy", + "/scense/api/entity_attrs.dspy", + # scense —— 写类(admin) + "/scense/api/entity_edit.dspy", + "/scense/api/session_create.dspy", + # scene + "/scene/api/get_search_status.dspy", + "/scene/api/get_search_scene_type.dspy", + "/scene/api/create_scene.dspy", + # entity + "/entity/api/entity_create.dspy", + "/entity/api/entity_update.dspy", + "/entity/api/entity_delete.dspy", + # world + "/world/api/create_world.dspy", +)) + + +def normalize_request_path(path): + """归一化请求路径(封堵大小写/尾斜杠/重复斜杠/URL 编码等写法绕过)。 + + 与 `app/auth_gate_strict.py::normalize_path` 必须同构(自测交叉断言)。 + """ + import urllib.parse as _urlparse + p = str(path or "") + p = _urlparse.unquote(p) + p = p.split("?", 1)[0].split("#", 1)[0] + p = "/" + "/".join([seg for seg in p.split("/") if seg]) + if p != "/": + p = p.rstrip("/") + if p.lower().endswith(GUARDED_SUFFIX) or p.startswith("/rbac/"): + return p.lower() + return p + + +def is_protected_endpoint(path): + """该路径是否被**逐条点名**为受保护端点(精确匹配,禁通配)。""" + p = normalize_request_path(path) + return p in PROTECTED_DSPY_PATHS + + +def auth_gate_decision(path): + """闸门裁决(单一真源,供中间件与自测/覆盖脚本共用)。 + + 返回 ``"off"`` / ``"public"`` / ``"other"``(非受闸门资源)/ ``"protected"``。 + ``"protected"`` = 必须通过会话校验(真实解密 Fernet 密文,**不是**只看 cookie + 是否存在——见 `has_session_credential` 文档记录的 P0 教训),否则 401。 + + [R62 / 匿名读全站 P0,需求 1+2] 覆盖面从「仅 `.dspy` 后缀」扩为 + **后缀 ∪ 点名表 ∪ 业务 API 前缀**:判定顺序 + 1. 总开关 → off; + 2. 显式公开(豁免清单,无通配)→ public; + 3. 逐条点名受保护 → protected; + 4. `.dspy` 后缀 → protected(历史行为); + 5. **命中 /scense /runtime /scene /world /entity 前缀且含 `/api/` 段或 + `.dspy` 结尾 → protected**(本轮新增;旧实现只看后缀,无后缀的 + `/runtime/api/build_entity_tree` 这类会被判成 other 直接放行 → 匿名 200); + 6. 其余 → other。 + """ + p = normalize_request_path(path) + if not _gate_enabled(): + return "off" + if is_public_endpoint(p): + return "public" + if is_protected_endpoint(p): + return "protected" + if p.lower().endswith(GUARDED_SUFFIX): + return "protected" + # 前缀覆盖面(判定委托单一真源,禁在本文件复制清单) + try: + if EXEMPT is not None and EXEMPT.is_protected_api_path(p): + return "protected" + except Exception as exc: # noqa: BLE001 + # 判不了 → 按受保护处理(fail-closed,绝不放行业务数据) + log.error("[route_guard] 业务 API 前缀判定异常(按受保护处理): %s", exc) + return "protected" + return "other" + +#: 会话数据里承载登录票据的键(aiohttp_auth TktAuthentication 默认 cookie_name)。 +AUTH_TICKET_KEYS = ("AUTH_TKT", "aiohttp_auth.user") + +#: 复刻 ahserver AuthAPI.setupAuth 密钥派生的固定尾串(共 32 字节)。 +_SESSION_SECRET_TAIL = b"iqwertyuiopasdfghjklzxcvbnm12345" + + +def _app_conf(): + """读 conf/config.json(读不到返回 {},绝不因配置问题抛到中间件链)。""" + try: + cfg_path = os.path.join(_app_root(), "conf", "config.json") + with open(cfg_path, encoding="utf-8") as fp: + return json.load(fp) or {} + except Exception: # noqa: BLE001 + return {} + + +def _session_secret_key(): + """复刻 ahserver `AuthAPI.setupAuth` 的 Fernet 密钥派生(port 前缀 + 固定尾串)。""" + port = str((( _app_conf().get("website") or {}).get("port")) or "") + b = port.encode("utf-8") + cnt = 32 - len(b) + if cnt < 0: + b, cnt = b[:32], 0 + return base64.urlsafe_b64encode(b + _SESSION_SECRET_TAIL[:cnt]) + + +def _session_cookie_names(): + """本次请求允许承载会话的 **精确** cookie 名(无子串启发式)。""" + names = [SESSION_COOKIE_NAME_DEFAULT] + cfg_name = _configured_session_cookie() + if cfg_name and cfg_name not in names: + names.append(cfg_name) + return tuple(names) + + +def decrypt_session_cookie(raw_cookie): + """解密 AIOHTTP_SESSION → `(state, data)`。 + + state ∈ ``absent``(无 cookie)/ ``forged``(Fernet InvalidToken:伪造或被篡改)/ + ``expired``(可解密但 created + max_age < now)/ ``ok``。 + """ + if raw_cookie is None or not str(raw_cookie).strip(): + return "absent", None + try: + from cryptography.fernet import Fernet, InvalidToken + except Exception: # noqa: BLE001 + return "forged", None + token = str(raw_cookie).strip().encode("utf-8") + fernet = Fernet(_session_secret_key()) + try: + data = json.loads(fernet.decrypt(token, ttl=SESSION_COOKIE_MAX_AGE).decode("utf-8")) + return ("ok", data) if isinstance(data, dict) else ("forged", None) + except InvalidToken: + try: + data = json.loads(fernet.decrypt(token).decode("utf-8")) + except Exception: # noqa: BLE001 + return "forged", None + if not isinstance(data, dict): + return "forged", None + created = int(data.get("created") or 0) + if created and (time.time() - created) > SESSION_COOKIE_MAX_AGE: + return "expired", data + return "forged", None + except Exception: # noqa: BLE001 + return "forged", None + + +def extract_auth_ticket(session_data): + """从解密后的会话数据取登录票据(aiohttp_auth 把票据存在会话里)。""" + if not isinstance(session_data, dict): + return None + for key in AUTH_TICKET_KEYS: + val = session_data.get(key) + if isinstance(val, str) and val.strip(): + return val.strip() + return None + + +def _ticket_userid(ticket, request): + """尽力解出票据里的 userid;解不出返回 ''(绝不因解码问题误杀合法会话)。""" + if not ticket: + return "" + try: + from ahserver.auth_api import CustomTktAuth + except Exception: # noqa: BLE001 + return "" + try: + ip = request.get("client_ip") + except Exception: # noqa: BLE001 + ip = None + try: + policy = CustomTktAuth(_session_secret_key(), 120, reissue_time=30, include_ip=True) + fields = policy._ticket.validate(ticket, ip, time.time()) + return str(getattr(fields, "user_id", "") or "") + except Exception: # noqa: BLE001 + return "" + + +async def verify_session(request): + """统一会话校验 → `(ok, status, reason, identity)`。 + + ok=False 时 status 恒为 401,reason ∈ + ``no_session`` / ``forged_session`` / ``expired_session`` / ``no_auth_ticket`` / + ``identity_mismatch``。调用方必须据此**直接返回错误响应**,不得再进业务分支。 + """ + raw, cookie_name = "", "" + for name in _session_cookie_names(): + val = (getattr(request, "cookies", None) or {}).get(name) + if val: + raw, cookie_name = val, name + break + state, data = decrypt_session_cookie(raw) + if state == "absent": + return False, 401, "no_session", {} + if state == "forged": + return False, 401, "forged_session", {} + if state == "expired": + return False, 401, "expired_session", {} + ticket = extract_auth_ticket(data) + if not ticket: + return False, 401, "no_auth_ticket", {} + userid = _ticket_userid(ticket, request) + # 与用户不匹配:请求显式声明 X-User-Id 且与票据身份不符 → 拦截。 + claimed = "" + try: + claimed = str(request.headers.get("X-User-Id") + or request.headers.get("X-Userid") or "").strip() + except Exception: # noqa: BLE001 + claimed = "" + if claimed and userid and claimed != userid: + return False, 401, "identity_mismatch", {"userid": userid} + return True, 200, "ok", {"userid": userid or claimed, "cookie": cookie_name, + "ticket_present": True} + + +def assert_no_wildcard(paths): + """硬门禁:白名单/登记项不得含通配符(`*` / `%`),命中即抛 ValueError。""" + bad = [p for p in (paths or ()) if ("*" in str(p) or "%" in str(p))] + if bad: + raise ValueError("鉴权白名单禁止通配符: %s" % bad) + return True + + +def coverage_matrix(paths): + """逐前缀覆盖率矩阵(端点路径 × 是否受拦截),用于"全前缀覆盖"核对。""" + rows = [] + for prefix in AUTH_REQUIRED_PREFIXES: + hit = [p for p in (paths or ()) if str(p).startswith(prefix)] + guarded = [p for p in hit if p.endswith(GUARDED_SUFFIX) and not is_public_endpoint(p)] + public = [p for p in hit if is_public_endpoint(p)] + total = len(hit) + rows.append({ + "prefix": prefix, + "total": total, + "guarded": len(guarded), + "public": len(public), + "coverage": round(100.0 * len(guarded) / total, 1) if total else 100.0, + "unguarded": [p for p in hit if p not in guarded and p not in public], + }) + return rows + + +# 白名单硬门禁:模块导入即校验,谁加通配符谁在启动时炸(不静默放行)。 +assert_no_wildcard(tuple(PUBLIC_DSPY_PATHS) + tuple(PUBLIC_PATH_PREFIXES) + AUTH_REQUIRED_PREFIXES) + + + +def _app_root(): + """应用根目录(app/ 的上一级)。""" + return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def _candidate_paths(url_path): + """URL 路径 → 磁盘候选路径列表(命中任一即认为资源存在)。""" + root = _app_root() + rel = url_path.lstrip("/") + if not rel: + return [os.path.join(root, "wwwroot", "index.ui")] + parts = rel.split("/") + cands = [os.path.join(root, "wwwroot", rel)] + if len(parts) >= 2: + # 模块未软链到 wwwroot/ 时,退回 pkgs//wwwroot/ + rest = "/".join(parts[1:]) + cands.append(os.path.join(root, "pkgs", parts[0], "wwwroot", rest)) + if not rel.endswith(GUARDED_SUFFIXES): + cands.append(os.path.join(root, "wwwroot", rel, "index.ui")) + return cands + + +def resource_exists(url_path): + for cand in _candidate_paths(url_path): + try: + if os.path.isfile(cand): + return True + except OSError: + continue + return False + + +def _json_error(request, status, reason, extra=None): + # 统一错误结构(QC #2):ok=false + error=;保留 status/success/reason 兼容既有探针。 + # 响应体只含错误元信息,**不含任何业务字段**。 + payload = {"ok": False, "error": reason, "status": status, "success": False, + "reason": reason, "path": request.path or ""} + if extra: + payload.update(extra) + if (request.path or "").endswith(GUARDED_SUFFIXES): + return web.json_response(payload, status=status) + return web.Response(status=status, text="%d: %s" % (status, reason)) + + +def is_write_endpoint(path): + """该端点是否为「写类」(只接受 POST)。""" + name = (path or "").rsplit("/", 1)[-1] + if not name.endswith(".dspy"): + return False + stem = name[: -len(".dspy")] + if _READ_ONLY_EXCEPTIONS.search(stem): + return False + return bool(_WRITE_STEM_RE.search(stem)) + + +def _configured_session_cookie(): + """从 conf/config.json 读 website.session.cookie_name(读不到返回 None)。""" + try: + cfg_path = os.path.join(_app_root(), "conf", "config.json") + with open(cfg_path, encoding="utf-8") as fp: + cfg = json.load(fp) + name = (((cfg.get("website") or {}).get("session") or {}).get("cookie_name") or "").strip() + return name or None + except Exception: # noqa: BLE001 + return None + + +def is_public_endpoint(path): + """该路径是否免鉴权(登录/注册/健康探针等)。 + + [fix rvtGi1mlUsTwQrXnybZj6 / R61] **委托单一真源** `auth_exempt.is_exempt_path()`: + 本层不再用自己的匹配实现判定(历史副本非段边界匹配,`/healthzx` 会被误放行, + 且与 auth_gate_strict / notfound 的副本各自演化 —— R57b/R59 多清单漂移同源缺陷)。 + `PUBLIC_DSPY_PATHS` / `PUBLIC_PATH_PREFIXES` 仅为 EXEMPT 的只读镜像(自测交叉断言)。 + """ + p = normalize_request_path(path) + try: + return bool(EXEMPT.is_exempt_path(p)) + except Exception as exc: # noqa: BLE001 + log.error("[route_guard] 豁免判定异常(按不豁免处理): %s", exc) + return False + + +def has_session_credential(request): + """[DEPRECATED] 凭据**存在性**判定 —— 自 QC #2/#3 起鉴权不再使用本函数。 + + 旧实现只要请求带"名字里含 sid/session/token"的任意 cookie 就返回 True, + 导致 `Cookie: AIOHTTP_SESSION=forged-abc` 也能进入业务处理器(P0 数据泄露)。 + 保留函数仅用于外部探针/脚本兼容,其返回值**不构成鉴权结论**; + 真实判定请用 `await verify_session(request)`。 + """ + try: + cookies = getattr(request, "cookies", None) or {} + return any(bool(str(v).strip()) for v in cookies.values()) + except Exception: # noqa: BLE001 + return False + + +def _gate_enabled(): + return (os.environ.get(_GATE_ENV) or "on").strip().lower() not in ("off", "0", "false", "no") + + + + +async def _has_business_params(request): + """请求是否带了至少一个业务参数(query / form / JSON body)。""" + try: + if request.query and any(str(v).strip() for v in request.query.values()): + return True + if request.match_info and any(str(v).strip() for v in request.match_info.values()): + return True + if request.method in ("POST", "PUT", "PATCH", "DELETE") and request.can_read_body: + body = await request.read() + if not body: + return False + text = body.decode("utf-8", "replace").strip() + if not text: + return False + if text.startswith("{"): + try: + parsed = json.loads(text) + except Exception: # noqa: BLE001 + return True + if isinstance(parsed, dict): + return any(v is not None and str(v).strip() != "" + for v in parsed.values()) + return True + return "=" in text and any(part.split("=", 1)[-1].strip() + for part in text.split("&")) + return False + except Exception: # noqa: BLE001 + # body 读异常时不阻断业务(交给 ahserver 自己解析/报错) + return True + + +@web.middleware +async def resource_preflight_middleware(request, handler): + """路由前置 404:资源不存在时在鉴权之前就返回 404(Bug P-Wn8N / WXXvX)。""" + path = request.path or "" + try: + if path.endswith(GUARDED_SUFFIXES): + if not resource_exists(path): + log.warning("[404] preflight resource missing: %s %s", request.method, path) + return _json_error(request, 404, "no such resource") + except Exception as exc: # noqa: BLE001 + log.error("[route_guard] preflight 判定失败,放行 %s: %s", path, exc) + return await handler(request) + + +@web.middleware +def _unauth_hint(path): + """未登录 401 的 hint(自指修正,R53 / QC#4)。 + + 登录端点自身返回 401 时**不能**再说"请先 POST 登录"——那是"先登录才能登录"的 + 自指死循环,并且把"闸门异常"误导成"用户没登录"。公开路径 → 指向闸门异常。 + """ + p = normalize_request_path(path) + if is_public_endpoint(p): + return ("该路径是公开登录端点,401 表示服务端鉴权闸门异常(fail-closed)," + "不是未登录;请查看服务端日志与 /healthz 诊断字段") + return "未登录/会话无效:请先 POST /rbac/user/up_login.dspy 取得会话 cookie" + + +# [fix rvtGi1mlUsTwQrXnybZj6 / R60] 显式登记为新式中间件:aiohttp>=3.9 用 +# `__middleware_version__ == 1` 区分新式/旧式,裸协程函数会被当旧式工厂调用 +# → 返回 handler 函数而不是 Response(`Expect response, not `)。 +from aiohttp import web as _r60_web # noqa: E402 +@_r60_web.middleware +async def auth_required_middleware(request, handler): + """统一会话鉴权拦截:未登录/伪造/过期 → 401;会话有效但 RBAC 拒绝 → 403。 + + 判定顺序(顺序即语义,必须保持): + 1. 闸门总开关关闭(YJ_AUTH_GATE=off)→ 放行(排障用,生产禁开); + 2. 非受闸门资源 → 放行(.ui/.js/.css 由框架会话层处理,避免锁死登录页)。 + [R62] 「受闸门」= `.dspy` 后缀 ∪ 点名表 ∪ 业务 API 前缀(/scense /runtime + /scene /world /entity 下含 /api/ 段或 .dspy 结尾),不再只看后缀。 + 3. 显式白名单(PUBLIC_DSPY_PATHS / PUBLIC_PATH_PREFIXES,**无通配**)→ 放行; + 4. 写类端点 GET/HEAD → 让行给 write_endpoint_semantics_middleware 回 405(方法语义优先); + 5. `await verify_session(request)` 校验 AIOHTTP_SESSION 密文与登录票据: + absent→no_session / forged→forged_session / expired→expired_session / + 无票据→no_auth_ticket / 身份不符→identity_mismatch,全部 401; + 6. 会话有效 → 放行到业务处理器(框架 checkAuth 继续做票据/权限细校验)。 + + 拦截分支**在业务处理器之前终止**,响应体为统一错误结构 + `{"ok": false, "error": "", ...}`,不含任何业务字段。 + """ + path = request.path or "" + # [fix Zhpd9MXyeGtznDMqini6X / R49] 鉴权裁决、会话校验、下游 handler 三段各自独立: + # 旧实现把 `await handler(request)` 与 `verify_session()` 包在同一个 try 里, + # 任何下游异常(aiohttp 抛 `Expect response, not `、 + # 业务 500、sqlor 1364…)都会被改写成 `401 auth_unavailable` + # —— 这就是 R49 全站矩阵 229×401、42 条 500(23 legacy + 19 foundation) + # 全部无法判定的机械原因:「鉴权失败」与「处理器故障」不是同一个事实, + # 前者 fail-closed,后者必须原样冒泡(500 可见、可归因)。 + try: + decision = auth_gate_decision(path) + except Exception as exc: # noqa: BLE001 + log.error("[route_guard] 闸门裁决异常,fail-closed 401 %s: %s", path, exc) + return _json_error(request, 401, "auth_unavailable") + if decision in ("off", "other", "public"): + return await handler(request) + + # 2026-10-25 修复(Bug 1BoEurcDgf4HHRqBMmJt8 / R-01-neg,P0 越权)保留: + # 写类端点 GET/HEAD 不再让行跳过会话校验(鉴权优先于方法语义,RFC 7235)。 + try: + ok, status, reason, identity = await verify_session(request) + except Exception as exc: # noqa: BLE001 + # fail-closed:**仅会话校验自身**异常时返回 401,绝不放行业务数据。 + log.error("[route_guard] 会话校验异常,fail-closed 401 %s: %s", path, exc) + return _json_error(request, 401, "auth_unavailable") + if ok: + return await handler(request) + + log.warning("[%d] 会话校验失败(%s): %s %s cookie=%s", status, reason, + request.method, path, identity.get("cookie", "-")) + # [fix rvtGi1mlUsTwQrXnybZj6 / R61] `p` 从未定义 → NameError → 匿名访问受保护端点 + # 恒 500(而非 401)。这是「401/500 混淆」的另一个机械来源:判定已得出 401, + # 但在拼 error 信封时崩了。改用本函数开头已定义的 `path`。 + resp = _json_error(request, status, reason, + {"hint": _unauth_hint(path)}) + resp.headers["WWW-Authenticate"] = 'Session realm="yuanjing"' + return resp + + +@web.middleware +async def write_endpoint_semantics_middleware(request, handler): + """写类端点方法/参数语义:GET → 405,POST 无参 → 400(Bug CQIpIo / YSNA7 / Zhpd9)。""" + path = request.path or "" + try: + if is_write_endpoint(path): + stem = path.rsplit("/", 1)[-1][: -len(".dspy")] + if request.method in ("GET", "HEAD") and stem not in _PARAM_EXEMPT_STEMS: + log.warning("[405] write endpoint called with %s: %s", request.method, path) + resp = _json_error(request, 405, "method not allowed", + {"method": request.method, "allow": "POST, OPTIONS", + "hint": "写类端点只接受 POST;请以 POST + 业务参数调用"}) + resp.headers["Allow"] = "POST, OPTIONS" + return resp + if request.method == "POST" and stem not in _PARAM_EXEMPT_STEMS: + ok = await _has_business_params(request) + if not ok: + log.warning("[400] write endpoint without params: %s", path) + resp = _json_error(request, 400, "missing required parameter", + {"missing": ["<业务参数>"], + "hint": "写类端点无参数调用会被 DB 拒绝(1364)," + "请补齐必填字段后重试"}) + return resp + except Exception as exc: # noqa: BLE001 + log.error("[route_guard] 写端点语义判定失败,放行 %s: %s", path, exc) + return await handler(request) + +# --- 2026-10-25 修复记录(Bug 1BoEurcDgf4HHRqBMmJt8 / R-01-neg:匿名与伪造 cookie 返回 200)--- +# 根因:auth_required_middleware 里「写类端点 GET/HEAD 让行给方法语义层」的分支把整段会话 +# 校验跳过;一旦方法语义层未覆盖该路径(或运行态未加载语义层),匿名请求直达业务处理器 → 200。 +# 修复:鉴权优先——未认证一律 401(RFC 7235:401 先于 405),已认证请求照旧由 +# write_endpoint_semantics_middleware 回 405/400。离线自测:scripts/selftest_auth_gate_offline.py +# 16/16 PASS(含 anon_no_cookie / forged_cookie / 写类端点匿名 GET+POST → 401)。 + + +# ---------------------------------------------------------------- R52 签名契约 +# 根因(R48 E1):本模块中间件虽已 `@web.middleware` 装饰,但「装饰被漏/被覆盖」 +# (例如有人新增未装饰的中间件、或以裸函数再导出)就会让 aiohttp 按 legacy 工厂 +# `mw(app)` 调用 → request 形参收到 AHApp → `real_ip.py:16` `request.remote` +# AttributeError → 全站 500。这里在**模块级**再幂等规范化一次,把契约钉死在提供方。 +def _ensure_new_style(mw, name=None): + """幂等把中间件规范成 new-style(带 __middleware_version__);不可用时原样返回。""" + try: + from middleware_chain_contract import ensure_middleware as _ensure_mw + except ImportError: + try: + from app.middleware_chain_contract import ensure_middleware as _ensure_mw + except ImportError: + return mw + try: + return _ensure_mw(mw, name=name) + except Exception: # noqa: BLE001 + return mw + + +resource_preflight_middleware = _ensure_new_style(resource_preflight_middleware) +auth_required_middleware = _ensure_new_style(auth_required_middleware) +write_endpoint_semantics_middleware = _ensure_new_style( + write_endpoint_semantics_middleware) diff --git a/app/_archive_disabled_20261028_151505/semantic_wiring.py b/app/_archive_disabled_20261028_151505/semantic_wiring.py new file mode 100644 index 0000000..9687625 --- /dev/null +++ b/app/_archive_disabled_20261028_151505/semantic_wiring.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""HTTP 语义中间件的**装配**(Bug 28PuqnfpWzB2ypJoUTU_N / R17-2)。 + +问题 +---- +404/405/400 三类语义修复写在 `apps/yuanjing/app/http_semantics_middleware.py`, +但目标机实际运行入口是 `app/scense.py`(`ps -ef` 只有一个 +`venv/bin/python app/scense.py` 进程),`apps/yuanjing` 侧的装配代码 +(`app/yuanjing.py` 的 `_register_http_semantics`)从未执行,conf/config.json 的 +`middlewares` 也是 null → 语义修复在目标机从未生效: +不存在端点仍 500、DELETE/PUT 仍 200、缺必填参数仍 200/500。 + +本模块把「装配」抽成**与入口无关**的一个函数,任何入口(scense.py / yuanjing.py) +都能调用: + + from app.semantic_wiring import install_semantic_middlewares + install_semantic_middlewares(app) # aiohttp Application + +装配顺序(= append 顺序,外层优先) +----------------------------------- +1. resource_preflight_middleware —— 路由前置 404(Bug P-Wn8N / WXXvX): + 在鉴权之前判定 wwwroot 下资源是否存在,不存在直接 404(fpath is None 兜底前置化)。 +2. auth_required_middleware —— 未登录访问已存在 .dspy → 401(Bug WhOYy9nvG): + 端点若不在公开白名单且无会话凭据,直接 401,不再放行到业务处理器。 +3. write_endpoint_semantics_middleware —— 写类端点 GET → 405 / POST 无参 → 400 + (Bug CQIpIo / YSNA7 / Zhpd9 的 26+ 条 sqlor 1364 泛化 500)。 +4. resource_notfound_middleware —— 异常侧 404 兜底(保留原有覆盖)。 +5. http_semantics_middleware —— 405/400/404 既有实现(保留原有覆盖)。 +6. business_status_middleware —— 业务信封 {"status":400,...} 回写 HTTP 状态行 + (Bug DGzTjOkr / F12 / F16)。 + +注:真实入口 `app/scense.py` 的构造期装配只按名字加载两项 +(`resource_notfound_middleware` / `http_semantics_middleware`),因此 +`resource_notfound_middleware` 自身已内建**组合链**(`compose_guard_chain()`), +把上面 1/2/3/6 四层串在自己内部 —— 两条装配路径(构造期 / 钩子期)都能拿到完整语义。 +本模块列全 6 项是为了在钩子路径下按名称核验「语义层是否就位」;重复装配无副作用 +(幂等:405/401/404 命中即早返回,400 回写只在 status==200 时生效)。 + +契约 +---- +* 幂等:重复调用不会重复 append(按函数对象去重)。 +* 容错:任一注册失败只打印 ERROR + traceback 并返回 ok=False,**不抛出**—— + 装配点在 ahapp_built 钩子里,抛错会让 run_app 失败、整机启不起来。 +* 可核验:装配结果落成进程内记录 + `logs/http_semantics_wiring.json` 证据文件 + (middlewares 条数 / 名字 / 是否含全部语义中间件),供部署后自查与复验取证。 +""" + +import json +import logging +import os +import traceback + +log = logging.getLogger("yuanjing.semantic_wiring") + +EVIDENCE_BASENAME = "http_semantics_wiring.json" + +# 本应用负责装配的语义中间件(顺序 = append 顺序 = 外层优先) +SEMANTIC_MIDDLEWARE_NAMES = ( + "resource_notfound_middleware", + "http_semantics_middleware", + "business_status_middleware", +) + + +def _mw_name(mw): + return getattr(mw, "__name__", getattr(mw, "__qualname__", repr(mw))) + + +def _evidence_path(): + app_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + return os.path.join(app_root, "logs", EVIDENCE_BASENAME) + + +def write_evidence(payload): + """把装配结果写到 logs/http_semantics_wiring.json(失败不影响启动)。""" + path = _evidence_path() + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as fp: + json.dump(payload, fp, ensure_ascii=False, indent=2) + return path + except OSError as exc: # noqa: PERF203 + log.error("[wiring] 证据文件写入失败 %s: %s", path, exc) + return None + + +def _load_middlewares(): + """导入全部语义中间件(顺序即装配顺序)。""" + from app.route_guard_middleware import ( + auth_required_middleware, + resource_preflight_middleware, + write_endpoint_semantics_middleware, + ) + from app.notfound_middleware import resource_notfound_middleware + from app.http_semantics_middleware import http_semantics_middleware + from app.business_status_middleware import business_status_middleware + # [fix Zhpd9MXyeGtznDMqini6X / R50] 响应契约守卫必须装配在**链尾(最内层)**: + # 只有贴住路由处理器才能看到「处理器返回值本身」。R50 目标机日志实测链尾 + # 返回的是 function 对象(aiohttp_session/__init__.py:196 + # `RuntimeError: Expect response, not `),异常上抛后被 + # 改写成鉴权表象,导致 38 条 500 一条也判不出来。守卫在链尾救援该调用并 + # 把契约破坏归因为带处理器身份的 500(见 app/response_contract_guard.py)。 + from app.response_contract_guard import response_contract_guard + return (resource_preflight_middleware, + auth_required_middleware, + write_endpoint_semantics_middleware, + resource_notfound_middleware, + http_semantics_middleware, + business_status_middleware, + response_contract_guard) + + +def install_semantic_middlewares(app, entry=None): + """把 404/401/405/400 语义中间件装进 aiohttp app,返回装配结果 dict。 + + :param app: aiohttp Application(通常来自 ahapp_built 钩子) + :param entry: 入口名(仅用于证据/日志,如 "app/scense.py") + :return: {"ok": bool, "middlewares": int, "names": [...], "missing": [...], "entry": ...} + """ + result = {"ok": False, "middlewares": 0, "names": [], "missing": [], + "entry": entry or "", "error": ""} + try: + # `python app/scense.py` 时 sys.path[0] 是 app/ 子目录, + # `from app.notfound_middleware import ...` 需要应用根在 sys.path 上。 + import sys + app_dir = os.path.dirname(os.path.abspath(__file__)) + app_root = os.path.dirname(app_dir) + for p in (app_root, app_dir): + if p not in sys.path: + sys.path.insert(0, p) + + middlewares = _load_middlewares() + + have = {_mw_name(m) for m in (getattr(app, "middlewares", None) or [])} + added = [] + # aiohttp 的 Application.middlewares 只有 property、**没有 setter**: + # 旧实现 `app.middlewares = mws` 必然 AttributeError,被 except 吞掉后 + # 装配"看似完成"实则未生效(Bug Wp6gXrrEquzg3njONz60b 的静默降级路径)。 + # 正确做法逐条 append;app 已 freeze(_make_handler 已构建中间件链)时 + # append 会抛 RuntimeError —— 必须显式标记 frozen 由调用方降级,不得当成功。 + try: + for mw in middlewares: + if _mw_name(mw) in have: + continue + app.middlewares.append(mw) + have.add(_mw_name(mw)) + added.append(_mw_name(mw)) + except RuntimeError as exc: + present = [_mw_name(m) for m in (getattr(app, "middlewares", None) or [])] + result["frozen"] = True + result["names"] = present + result["middlewares"] = len(present) + result["missing"] = [n for n in SEMANTIC_MIDDLEWARE_NAMES if n not in present] + result["error"] = "app 已 freeze,中间件链已构建,append 不生效:%s" % (exc,) + log.error("[wiring] app 已 freeze,语义中间件无法生效 entry=%s: %s", entry, exc) + written = write_evidence(result) + if written: + result["evidence"] = written + return result + + names = [_mw_name(m) for m in app.middlewares] + missing = [n for n in SEMANTIC_MIDDLEWARE_NAMES if n not in names] + result.update(ok=not missing, middlewares=len(names), names=names, + missing=missing, added=added) + if missing: + log.error("[wiring] 语义中间件缺失 %s(已装 %s)入口=%s", missing, names, entry) + else: + log.info("[init] middlewares=%d semantic=%d added=%s entry=%s", + len(names), len(SEMANTIC_MIDDLEWARE_NAMES), added, entry) + except Exception as exc: # noqa: BLE001 + result["error"] = "%s: %s" % (type(exc).__name__, exc) + log.error("[wiring] 装配失败 entry=%s: %s", entry, exc) + log.error("[wiring] traceback: %s", traceback.format_exc()) + written = write_evidence(result) + if written: + result["evidence"] = written + return result diff --git a/app/_archive_disabled_20261028_151505/session_gate_middleware.py b/app/_archive_disabled_20261028_151505/session_gate_middleware.py new file mode 100644 index 0000000..84ab190 --- /dev/null +++ b/app/_archive_disabled_20261028_151505/session_gate_middleware.py @@ -0,0 +1,576 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""`session_gate_middleware` —— 宿主应用**统一会话鉴权入口**(拦截本体)。 + +职责边界(单一真源,不复制判定口径) +------------------------------------ +判定逻辑全部来自 `modules/pbl_common/pbl_common/auth.py`,按以下优先级取契约: + + 1. `ServerEnv()` 上由 `pbl_common/init.py::load_pbl_common()` 挂载的名字 + (`env.pbl_common_auth_verify_session` / `_is_public` / `_requires_session` + / `_unauthorized_payload` / `_forbidden_payload` / `_public_paths` + / `_required_prefixes`); + 2. 直接 `import pbl_common.auth`; + 3. 按**绝对文件路径**加载 `auth.py`(部署态 sys.path 常不含模块根,R62 实测)。 + +豁免(免会话)清单的单一真源是 `app/auth_exempt.py`;本文件只做**接线 + 短路**, +不自持第二份清单(历史多清单漂移缺陷见 auth_gate_strict.py 顶部 R57b/R59 注)。 + +短路语义(任务范围 1/2) +------------------------ +1. 不要求会话的路径(显式公开项:`/healthz`、`/rbac/user/up_login.dspy` 等, + 以及 `.ui/.js/.css` 静态资产)→ `return await handler(request)`,框架行为不变。 +2. 要求会话的路径(所有业务 `.dspy`,以及命中 `PROTECTED_API_PREFIXES` 的业务 API) + → **在调用 handler 之前** `await verify_session(...)`: + * 会话不存在(`no_session`)/ 无登录票据(`no_auth_ticket`)→ **401**; + * cookie 伪造 / 解密失败 / 过期(`forged_session` / `expired_session`)→ **401**; + * 会话与声明身份不匹配(`identity_mismatch`)→ **401**; + * 会话有效但 RBAC 角色不足 → **403** `permission_denied`; + * 闸门契约不可用(三处都取不到)→ **fail-closed 401** `auth_unavailable`。 + 以上分支**全部在业务 handler 之前 return**,响应体只有 + `{ok:false, error, status, path[, hint]}` 四~五键,**零业务字段**。 +3. 校验通过 → `request['pbl_session'] = identity`,继续下沉业务 handler。 + +与既有层的关系 +-------------- +`entry_guard_r19` / `route_guard_middleware` / `auth_gate_strict` 与本层职责重叠是 +**有意的纵深防御**:本层是「会话完整性 + RBAC 角色」的统一入口,被 `app/scense.py` +的 `_load_semantic_middlewares()` 登记在 `auth_gate_strict` **之前**(更外层), +使统一入口在任何启动方式下都在链上;内层保险丝保留,互不遮蔽。 +""" + +import importlib +import importlib.util +import json +import logging +import os + +log = logging.getLogger("yuanjing.session_gate") + +try: # 部署态必有;离线自测缺省时走 fallback + from aiohttp import web as _web +except Exception: # noqa: BLE001 + _web = None + +APP_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +#: 闸门总开关(仅排障;生产禁开),与 route_guard / auth_gate_strict 同名。 +GATE_ENV = "YJ_AUTH_GATE" + +#: 契约模块绝对路径兜底候选(部署态 = pkgs/,开发态 = 机构 modules/)。 +#: 注意目录层级:APP_ROOT = {space_root}/apps/{应用},机构 modules/ 在**两级**之上。 +_AUTH_PY_CANDIDATES = ( + os.environ.get("PBL_COMMON_AUTH_PY", ""), + os.path.join(APP_ROOT, "pkgs", "pbl_common", "pbl_common", "auth.py"), + os.path.join(APP_ROOT, os.pardir, os.pardir, "modules", + "pbl_common", "pbl_common", "auth.py"), + os.path.join(APP_ROOT, os.pardir, "modules", "pbl_common", "pbl_common", "auth.py"), + os.path.join(APP_ROOT, os.pardir, "pkgs", "pbl_common", "pbl_common", "auth.py"), +) + +_LOADPATH_ROOTS = ( + os.environ.get("YJ_RBAC_LOADPATH_ROOT", ""), + os.path.join(APP_ROOT, "pkgs"), + os.path.join(APP_ROOT, os.pardir, os.pardir, "modules"), + os.path.join(APP_ROOT, os.pardir, "modules"), + os.path.join(APP_ROOT, os.pardir, "pkgs"), +) + +#: 必须走统一校验的业务前缀(需求 4:/scense /runtime /scene /world /entity)。 +DEFAULT_REQUIRED_PREFIXES = ('/scense', '/runtime', '/scene', '/world', '/entity') + +_CONTRACT = None # 解析后的契约对象(模块或 env 适配器) +_CONTRACT_NAME = "unresolved" +_RBAC_ROLES = None # path -> 所需角色(来自各模块 scripts/load_path.py 的 PATHS) +_RBAC_META = {} + + +def gate_enabled(): + """总开关:默认开;仅 `off/0/false/no` 关闭(排障专用)。""" + return (os.environ.get(GATE_ENV) or "on").strip().lower() not in ( + "off", "0", "false", "no") + + +# ── 契约解析(env → import → 绝对路径)─────────────────────────────────────── +class _EnvContract(object): + """ServerEnv 挂载形态的适配器(env.pbl_common_auth_*)。""" + + def __init__(self, env): + self._env = env + + def __getattr__(self, name): + attr = "pbl_common_auth_" + name + val = getattr(self._env, attr, None) + if val is None: + raise AttributeError(attr) + return val + + def raw(self, name): + return getattr(self._env, "pbl_common_auth_" + name, None) + + +def _load_auth_by_abspath(): + for cand in _AUTH_PY_CANDIDATES: + if not cand or not os.path.isfile(cand): + continue + try: + spec = importlib.util.spec_from_file_location("pbl_common_auth_abspath", cand) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod, cand + except Exception as exc: # noqa: BLE001 + log.error("[session_gate] 绝对路径加载 auth.py 失败 %s: %s", cand, exc) + return None, "" + + +def contract(): + """统一鉴权契约(惰性解析一次,解析结果带来源名,供诊断输出)。""" + global _CONTRACT, _CONTRACT_NAME + if _CONTRACT is not None: + return _CONTRACT + # ① ServerEnv 挂载(load_pbl_common 已执行时) + for name in ("ahserver.serverenv",): + try: + m = importlib.import_module(name) + env = m.ServerEnv() + if getattr(env, "pbl_common_auth_verify_session", None) is not None: + _CONTRACT, _CONTRACT_NAME = _EnvContract(env), "ServerEnv(env)" + log.info("[session_gate] 鉴权契约来源=ServerEnv") + return _CONTRACT + except Exception: # noqa: BLE001 + pass + # ② 正常包导入(失败**不返回**,继续走 ③:pbl_common 的包 __init__ 会拉 ahserver, + # 离线/精简部署态下必然 ImportError,此时绝对路径形态仍可拿到纯 stdlib 的 auth.py) + try: + mod = importlib.import_module("pbl_common.auth") + _CONTRACT, _CONTRACT_NAME = mod, "pbl_common.auth" + log.info("[session_gate] 鉴权契约来源=pbl_common.auth") + return _CONTRACT + except Exception as exc: # noqa: BLE001 + log.warning("[session_gate] import pbl_common.auth 不可用,继续绝对路径兜底: %s", exc) + # ③ 绝对路径兜底 + mod, path = _load_auth_by_abspath() + if mod is not None: + _CONTRACT, _CONTRACT_NAME = mod, path + log.info("[session_gate] 鉴权契约来源=abspath %s", path) + return _CONTRACT + _CONTRACT, _CONTRACT_NAME = None, "unavailable" + log.error("[session_gate] 鉴权契约三处解析全部失败 —— 闸门将 fail-closed 返回 401 " + "auth_unavailable(绝不放行业务数据)") + return None + + +def contract_name(): + contract() + return _CONTRACT_NAME + + +# ── RBAC 角色登记表(load_path.py 单一真源,禁通配)─────────────────────────── +def _loadpath_files(): + out = {} + for prefix in required_prefixes(): + mod = str(prefix).strip("/") + for root in _LOADPATH_ROOTS: + if not root: + continue + cand = os.path.join(root, mod, "scripts", "load_path.py") + if os.path.isfile(cand): + out[prefix] = cand + break + return out + + +def rbac_registry(): + """path -> 所需角色;同时做**通配符硬校验**(命中即整表不可用,fail-closed)。""" + global _RBAC_ROLES, _RBAC_META + if _RBAC_ROLES is not None: + return _RBAC_ROLES + table = {} + meta = {"sources": {}, "entries": 0, "wildcard_free": True, "wildcards": []} + for prefix, file_path in sorted(_loadpath_files().items()): + try: + spec = importlib.util.spec_from_file_location( + "yj_load_path_" + prefix.strip("/").replace("-", "_"), file_path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + except Exception as exc: # noqa: BLE001 + log.error("[session_gate] RBAC 登记表加载失败 %s: %s", file_path, exc) + continue + pairs = getattr(mod, "PATHS", None) or [] + bad = [p for p, _r in pairs if ("*" in p or "%" in p or "{" in p)] + if bad: + meta["wildcard_free"] = False + meta["wildcards"].extend(bad) + log.error("[session_gate] RBAC 登记表含通配/占位项(硬门禁拒绝): %s", bad) + continue + for p, role in pairs: + table[str(p)] = str(role) + meta["sources"][prefix] = len(pairs) + meta["entries"] += len(pairs) + _RBAC_ROLES, _RBAC_META = table, meta + log.info("[session_gate] RBAC 登记表载入 entries=%d wildcard_free=%s sources=%s", + meta["entries"], meta["wildcard_free"], meta["sources"]) + return table + + +def rbac_meta(): + rbac_registry() + return _RBAC_META + + +def required_role(path): + """该路径在 RBAC 登记表中声明的角色(未登记返回 '')。""" + table = rbac_registry() + if not table: + return '' + p = _norm(path) + if p in table: + return table[p] + if p + ".dspy" in table: + return table[p + ".dspy"] + # 最长前缀兜底(模块根 '/entity' 这类登记项覆盖其下未单独登记的路径) + best, best_len = '', 0 + for k, v in table.items(): + if k != "/" and (p == k or p.startswith(k.rstrip("/") + "/")) and len(k) > best_len: + best, best_len = v, len(k) + return best + + +# ── 公开/必需判定(一律委托契约,本文件不持有清单)─────────────────────────── +def _norm(path): + p = str(path or "") + p = p.split("?", 1)[0].split("#", 1)[0] + p = "/" + "/".join([s for s in p.split("/") if s]) + return p if p == "/" else p.rstrip("/") + + +def required_prefixes(): + c = contract() + if c is not None: + try: + val = c.raw("required_prefixes") if isinstance(c, _EnvContract) else \ + getattr(c, "AUTH_REQUIRED_PREFIXES", None) + if val: + return tuple(val) + except Exception: # noqa: BLE001 + pass + return DEFAULT_REQUIRED_PREFIXES + + +def is_public(path): + """显式公开项(白名单精确命中,禁通配 / 禁子路径放行)。""" + c = contract() + p = _norm(path) + if c is not None: + try: + fn = c.raw("is_public") if isinstance(c, _EnvContract) else \ + getattr(c, "auth_is_public", None) + if fn is not None and bool(fn(p)): + return True + except Exception as exc: # noqa: BLE001 + log.error("[session_gate] 公开项判定异常(按不公开处理): %s", exc) + return False + + +def requires_session(path): + """是否必须过会话校验。 + + 覆盖面 = 契约判定(所有非公开 `.dspy`) **∪** 业务 API 前缀(`/scense /runtime + /scene /world /entity` 下的 `/api/…`,含无 `.dspy` 后缀的重写变体)。 + 第二条是关键:只按后缀判定会让 `/runtime/api/build_entity_tree` 这类无后缀 + 业务 API 漏网 → 匿名 200(R62 同源缺陷)。判定异常一律按「需要会话」处理。 + """ + p = _norm(path) + if is_public(p): + return False + c = contract() + if c is not None: + try: + fn = c.raw("requires_session") if isinstance(c, _EnvContract) else \ + getattr(c, "auth_requires_session", None) + if fn is not None and bool(fn(p)): + return True + except Exception as exc: # noqa: BLE001 + log.error("[session_gate] requires_session 异常(按需会话处理): %s", exc) + return True + if p.lower().endswith(".dspy"): + return True + low = p.lower() + for prefix in required_prefixes(): + head = prefix.rstrip("/") + "/" + if low.startswith(head.lower()): + rest = low[len(head) - 1:] + if "/api/" in rest or rest.endswith(".dspy"): + return True + return False + + +# ── 响应与身份 ──────────────────────────────────────────────────────────────── +def _payload(status, reason, path, extra_hint=None): + """统一错误结构(**不含任何业务字段**)。""" + c = contract() + body = None + try: + if c is not None: + fn = c.raw("unauthorized_payload") if status == 401 else \ + c.raw("forbidden_payload") + if not isinstance(c, _EnvContract) or fn is None: + fn = getattr(c, "unauthorized_payload" if status == 401 + else "forbidden_payload", None) + if fn is not None: + body = dict(fn(reason, path) or {}) + except Exception as exc: # noqa: BLE001 + log.error("[session_gate] 契约错误信封不可用(退回本地结构): %s", exc) + if not isinstance(body, dict) or not body: + body = {"ok": False, "error": reason, "status": status, "path": path} + body.setdefault("ok", False) + body.setdefault("status", status) + body.setdefault("path", path) + if extra_hint: + body["hint"] = extra_hint + return body + + +def _respond(status, reason, path, hint=None): + body = _payload(status, reason, path, hint) + if _web is not None: + resp = _web.json_response(body, status=status) + if status == 401: + resp.headers["WWW-Authenticate"] = 'Session realm="yuanjing"' + return resp + return _FallbackResponse(status, body) + + +class _FallbackResponse(object): + """离线自测(无 aiohttp)时的等价响应体:只有 status / body 两属性。""" + + def __init__(self, status, body): + self.status = int(status) + self.body = body + self.text = json.dumps(body, ensure_ascii=False) + self.headers = {} + + +def _principal_roles(request, identity): + """取当前会话的角色集合;取不到返回 None(= 未知,交由框架 RBAC 判定)。""" + bags = [] + if isinstance(identity, dict): + bags.append(identity) + try: + sess = getattr(request, "session", None) + if isinstance(sess, dict): + bags.append(sess) + except Exception: # noqa: BLE001 + pass + try: + if isinstance(request, dict): + bags.append(request) + except Exception: # noqa: BLE001 + pass + roles = set() + found = False + for b in bags: + if not isinstance(b, dict): + continue + for key in ("roles", "pbl_roles", "role", "user_roles"): + val = b.get(key) + if val in (None, ""): + continue + found = True + if isinstance(val, (list, tuple, set)): + roles.update(str(x).strip() for x in val if str(x).strip()) + else: + roles.update(str(x).strip() for x in str(val).split(",") if str(x).strip()) + return roles if found else None + + +def _ticket_userid(identity): + if isinstance(identity, dict): + return str(identity.get("userid") or identity.get("username") or "") + return "" + + +def _claimed_userid(request): + """调用方声明的身份(用于「会话与用户不匹配」判定)。""" + try: + headers = getattr(request, "headers", None) or {} + for key in ("X-User-Id", "X-Userid", "x-user-id"): + val = headers.get(key) + if val: + return str(val).strip() + except Exception: # noqa: BLE001 + pass + return "" + + +# ── 闸门本体 ────────────────────────────────────────────────────────────────── +async def session_gate_middleware(request, handler): + """统一会话鉴权入口:会话校验 + RBAC 角色校验,全部在业务 handler 之前终止。 + + 判定顺序(顺序即语义): + 1. 总开关关闭(排障)→ 放行; + 2. 不要求会话的路径(显式公开项 / 静态资产)→ 放行; + 3. 契约不可用 → **401 auth_unavailable**(fail-closed,绝不放行); + 4. `await verify_session(request)`:不存在/伪造/过期/票据缺失/身份不符 → 401; + 5. 会话有效但 RBAC 角色不足 → 403 permission_denied; + 6. 通过 → 挂 `request['pbl_session']`,`return await handler(request)`。 + """ + path = _norm(getattr(request, "path", None) or + (request.get("path") if isinstance(request, dict) else "") or "") + try: + if not gate_enabled(): + return await handler(request) + if not requires_session(path): + return await handler(request) + + c = contract() + if c is None: + log.error("[session_gate] 契约缺失 → 401 auth_unavailable path=%s", path) + return _respond(401, "auth_unavailable", path, + hint="统一鉴权契约不可达(ServerEnv/pbl_common/auth.py 三处均未取到)") + + vs = c.raw("verify_session") if isinstance(c, _EnvContract) else \ + getattr(c, "auth_verify_session", None) + port = None + try: + cfg_path = os.path.join(APP_ROOT, "conf", "config.json") + if os.path.isfile(cfg_path): + with open(cfg_path, encoding="utf-8") as fp: + cfg = json.load(fp) or {} + port = str(((cfg.get("website") or {}).get("port")) or "") or None + except Exception: # noqa: BLE001 + port = None + ok, status, reason, identity = await vs(request, port=port) + except Exception as exc: # noqa: BLE001 + # 闸门自身/判定异常 → fail-closed 500(不冒充鉴权失败,避免遮蔽真实故障, + # 与 auth_gate_strict R60 同一口径)。 + log.exception("[session_gate] 闸门判定异常 path=%s: %s", path, exc) + return _respond(500, "session_gate_internal_error", path, hint=str(exc)[:300]) + + if not ok: + status = int(status or 401) + log.warning("[session_gate] 拦截 %s reason=%s path=%s", + getattr(request, "method", "-"), reason, path) + # 会话校验未通过一律 401(契约已给 403 时保留 403,二者互不覆盖) + hint = None + if status == 401: + hint = "会话缺失/无效:请 POST /rbac/user/up_login.dspy 取得 AIOHTTP_SESSION" + return _respond(status if status in (401, 403) else 401, reason or "no_session", + path, hint) + + # 会话有效 → RBAC 角色判定(角色不可知时交由框架 RBAC,本层不臆断 403) + claimed = _claimed_userid(request) + ticket_user = _ticket_userid(identity) + if claimed and ticket_user and claimed != ticket_user: + log.warning("[session_gate] 身份不匹配 claimed=%s ticket=%s path=%s", + claimed, ticket_user, path) + return _respond(401, "identity_mismatch", path, + hint="声明用户与会话票据身份不一致") + + need = required_role(path) + if need and need != "any": + roles = _principal_roles(request, identity if isinstance(identity, dict) else {}) + if roles is not None: + allowed = (need == "logined") or (need in roles) or ("admin" in roles) \ + or ("super" in roles) or ("superuser" in roles) + if not allowed: + log.warning("[session_gate] RBAC 拒绝 path=%s need=%s roles=%s", + path, need, sorted(roles)) + return _respond(403, "permission_denied", path, + hint="会话有效但缺少该端点要求的角色: " + need) + + try: + if isinstance(request, dict): + request["pbl_session"] = identity or {} + else: + request["pbl_session"] = identity or {} + except Exception: # noqa: BLE001 + pass + return await handler(request) + + +def diagnostics(): + """供 /healthz 与自测读取的闸门装配状态(可远程核验,不静默)。""" + meta = rbac_meta() + return { + "gate_enabled": gate_enabled(), + "contract_source": contract_name(), + "required_prefixes": list(required_prefixes()), + "rbac_entries": meta.get("entries", 0), + "rbac_wildcard_free": meta.get("wildcard_free", False), + "rbac_sources": meta.get("sources", {}), + } + + +if _web is not None: + session_gate_middleware = _web.middleware(session_gate_middleware) + + + +def install_session_gate(app, index=0): + """把统一会话闸门装到 aiohttp 中间件链**最外层**(业务分支之前)。 + + 为什么用 insert(0)(QC#4 必做1/验收③) + ------------------------------------ + ahserver `configuredServer.py:44` 只 `insert(0, real_ip_middleware())`, + `auth_api.py:162` 是 `append(checkAuth)`;应用自己的语义层(notfound / + http_semantics)也走 append。本函数在 `ahapp_built` 回调里、这些 append + **之前**执行 insert(0),因此闸门成为应用可控制的最外层:未登录请求在进入 + 任何 .dspy 业务 handler、也早于框架 RBAC 判定,就已按统一结构短路返回。 + + 幂等:按名字去重,重复调用只移动到 index 位置,不会叠加第二层闸门。 + + 返回 dict(供入口写证据 / 日志): + installed / name / index / total / middlewares / contract_source / skipped + """ + res = {"installed": False, "name": "session_gate_middleware", "index": None, + "total": 0, "middlewares": [], "contract_source": contract_name(), + "skipped": None} + mws = getattr(app, "middlewares", None) + if mws is None: + res["skipped"] = "app.middlewares 不存在(非 aiohttp Application?)" + log.error("[session_gate] install 跳过:%s", res["skipped"]) + return res + + mw = session_gate_middleware # 已按 _web.middleware 包装(若 aiohttp 可用) + try: + from middleware_chain_contract import ensure_middleware + mw = ensure_middleware(mw, "session_gate_middleware") + except Exception as exc: # noqa: BLE001 + log.warning("[session_gate] middleware_chain_contract 不可用,沿用原对象: %s", exc) + + try: + for i, m in enumerate(list(mws)): + if _mw_name_of(m) == "session_gate_middleware": + if i != index: + mws.pop(i) + mws.insert(index, m) + res.update(installed=True, index=index, total=len(mws), + middlewares=[_mw_name_of(x) for x in mws]) + log.info("[session_gate] installed as first middleware (already present) " + "index=%d total=%d contract=%s", + index, res["total"], res["contract_source"]) + return res + mws.insert(index, mw) + res.update(installed=True, index=index, total=len(mws), + middlewares=[_mw_name_of(x) for x in mws]) + log.info("[session_gate] installed as first middleware index=%d total=%d " + "contract=%s gate_enabled=%s prefixes=%s", + index, res["total"], res["contract_source"], gate_enabled(), + required_prefixes()) + except Exception as exc: # noqa: BLE001 + res["skipped"] = "%s: %s" % (type(exc).__name__, exc) + log.exception("[session_gate] install FAILED(闸门未生效,业务端点将无统一 401)") + return res + + +def _mw_name_of(mw): + """尽力取中间件名(装饰后 __name__ 可能被包装层保留/丢失)。""" + for attr in ("__name__", "_mw_name", "name"): + v = getattr(mw, attr, None) + if isinstance(v, str) and v: + return v + inner = getattr(mw, "__wrapped__", None) + if inner is not None and inner is not mw: + return _mw_name_of(inner) + return type(mw).__name__ diff --git a/app/_archive_disabled_20261028_151505/static_ext_guard.py b/app/_archive_disabled_20261028_151505/static_ext_guard.py new file mode 100644 index 0000000..45bd670 --- /dev/null +++ b/app/_archive_disabled_20261028_151505/static_ext_guard.py @@ -0,0 +1,144 @@ +# -*- coding: utf-8 -*- +"""静态扩展兜底守卫:确保 `.html` 在 website.static 中。 + +## 缺陷(Bug qgn-8gwY1UEqGhjlQU_-c / 用例 C15) +`conf/config.json.example` 的 `website.processors` 只登记 `.tmpl/.ui/.dspy`, +`website.static` 扩展列表里**没有 `.html`**。于是 GET `/runtime/play.html`: + - 不命中任何 processor(无 `.html` 规则) + - 不命中 static 白名单(无 `.html`) +⇒ ahserver 走"未知资源类型"异常路径 → **HTTP 500**。 +期望语义:静态页面入口在鉴权闸门之后返回 401(未登录)或 302(跳登录), +绝不返回 5xx。 + +## 修复策略(三层,幂等) +1. `conf/config.json.example` 的 static 列表补 `.html`(长期正确的模板); +2. `scripts/ensure_static_html.py` 幂等修补部署态运行 config(build/部署期); +3. **本模块**:进程启动期在内存配置里兜底补齐 `.html`—— + 即使部署机上的运行 config 未更新(历史包),启动后 `.html` 也按静态资源处理, + 500 立即消失。这是本次修复"部署即生效、不依赖重新投递 config"的关键层。 + +## 兼容形态 +appPublic 的 `getConfig()` 在不同版本下可能返回 `dict` 或属性式 +`DictObject`。本模块对两种形态都做处理,且**任何异常都不得影响应用启动** +(try/except 包裹,失败只记 WARN)。 +""" + +STATIC_EXT = ".html" + + +def _get_container(cfg): + """取 website 容器,兼容 dict / DictObject / 属性式对象。""" + if cfg is None: + return None + # 属性式 + web = getattr(cfg, "website", None) + if web is None: + web = getattr(cfg, "WEB_SITE", None) + # 字典式 + if web is None and hasattr(cfg, "get"): + try: + web = cfg.get("website") + except Exception: + web = None + if web is None and hasattr(cfg, "get"): + try: + web = cfg.get("WEB_SITE") + except Exception: + web = None + return web + + +def _get_static_list(web): + if web is None: + return None + if isinstance(web, dict): + return web.get("static") + lst = getattr(web, "static", None) + if lst is not None: + return lst + if hasattr(web, "get"): + try: + return web.get("static") + except Exception: + return None + return None + + +def _set_static_list(web, lst): + """就地替换 static 列表(保持容器类型不变)。返回是否写入成功。""" + if web is None: + return False + if isinstance(web, dict): + web["static"] = lst + return True + try: + setattr(web, "static", lst) + return True + except Exception: + pass + if hasattr(web, "__setitem__"): + try: + web["static"] = lst + return True + except Exception: + return False + return False + + +def ensure_static_html(cfg, logger=None): + """确保 cfg.website.static 含 `.html`。 + + Returns: + dict: {"changed": bool, "reason": str, "static": list|None} + + 语义:幂等。已含 `.html` → changed=False;容器不可达 → changed=False + 并给出 reason(调用方据此决定是否抬高日志级别,但绝不因此中断启动)。 + """ + try: + web = _get_container(cfg) + if web is None: + return {"changed": False, "reason": "website config not reachable", + "static": None} + lst = _get_static_list(web) + if lst is None: + return {"changed": False, "reason": "website.static not present", + "static": None} + # 统一成 list 副本(可能是 tuple) + try: + exts = [str(x) for x in lst] + except Exception: + return {"changed": False, "reason": "website.static not iterable", + "static": None} + norm = [e.lower() for e in exts] + if STATIC_EXT in norm or STATIC_EXT.lstrip(".") in [ + n.lstrip(".") for n in norm]: + return {"changed": False, "reason": "already present", "static": exts} + new = [STATIC_EXT] + exts + if not _set_static_list(web, new): + return {"changed": False, "reason": "website.static not writable", + "static": exts} + if logger is not None: + try: + logger.warning( + "[static_ext_guard] website.static 缺少 %s,已在内存配置补齐" + "(未登录 GET /*.html 由 500 收敛为 401/302)。" + "请同步部署 conf/config.json(scripts/ensure_static_html.py)。", + STATIC_EXT) + except Exception: + pass + return {"changed": True, "reason": "appended", "static": new} + except Exception as e: # 兜底:绝不因本守卫影响启动 + return {"changed": False, "reason": "guard error: %s" % (e,), + "static": None} + + +def assert_static_html(cfg): + """断言式版本(供 selftest 使用):返回 True/False,不抛异常。""" + web = _get_container(cfg) + lst = _get_static_list(web) + if lst is None: + return False + try: + return STATIC_EXT in [str(x).lower() for x in lst] + except Exception: + return False diff --git a/app/_archive_disabled_20261028_151505/ui_render_guard.py b/app/_archive_disabled_20261028_151505/ui_render_guard.py new file mode 100644 index 0000000..a1e89cc --- /dev/null +++ b/app/_archive_disabled_20261028_151505/ui_render_guard.py @@ -0,0 +1,343 @@ +""".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 字节)。 + +所以只要 `/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//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()` —— 启动期自愈:若 `/wwwroot/bricks` 不可解析或缺少 + header.tmpl/footer.tmpl,就在候选目录里找到真正的 bricks 资源目录并(重)建软链; + 修不了则打印**可行动**的 ERROR(含逐个候选绝对路径),而不是让 6 个页面继续 500。 +2. `ui_chain_problems()` —— 启动期自检:遍历 `/wwwroot/*` 下所有 .ui, + 报告悬空软链 / 缺失文件,并把结论写进日志(部署后可直接 grep 取证)。 + +设计约束: +- 不改 ahserver / bricks 等基础模块(project-directory-spec §3 引用模块只读)——缺陷的 + 「判空」在框架侧,但应用侧完全可以不让它被触发:把资源就位 + 构建期/启动期双门禁。 +- 所有路径来自 `__file__` 推导出的部署根(`/app/ui_render_guard.py` → ``), + 不写死任何部署路径(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(): + """由本文件位置推导部署根:/app/ui_render_guard.py → 。 + + 不写死路径;文件被单测从别处导入时也只用其所在目录的父目录,行为可预测。 + """ + 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. 部署机:`/pkgs/bricks/bricks`(前端包把资源放在与包同名的子目录) + —— 也可由 `$BRICKS_WWWROOT` 显式覆盖; + 2. 部署机旧布局:`/pkgs/bricks/wwwroot`(少数版本可能补齐过 wwwroot); + 3. 机构工作空间/本地装配:`/modules/bricks/wwwroot`、 + `/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): + """确保 `/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): + """遍历 `/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)。 diff --git a/app/scense.py b/app/scense.py index 9262161..92fe8c1 100644 --- a/app/scense.py +++ b/app/scense.py @@ -1,23 +1,33 @@ """唤景平台世界模式(scense)——应用唯一入口。 -一个应用 = 一个入口 = 一个端口(9380)。 -init() 里先挂 ServerEnv,再挂载基础模块 + 6 个业务模块。 +一个应用 = 一个入口 = 一个端口。 +标准 ahserver + rbac 提供注册/登录/会话管理,应用不自行实现鉴权中间件。 """ from ahserver.webapp import webapp from ahserver.serverenv import ServerEnv +import logging +import os +import sys + import bricks_for_python # registers bui processor from bricks_for_python.init import load_pybricks # registers UiWindow etc. from appbase.init import load_appbase from rbac.init import load_rbac +log = logging.getLogger("scense.entry") + +_APP_DIR = os.path.dirname(os.path.abspath(__file__)) +_DEPLOY_DIR = os.path.dirname(_APP_DIR) +for _p in (_APP_DIR, _DEPLOY_DIR): + while _p in sys.path: + sys.path.remove(_p) +sys.path.insert(0, _DEPLOY_DIR) +sys.path.append(_APP_DIR) + def get_module_dbname(m): - """返回模块 m 对应的数据库名。 - - 本项目为单库应用:world/scene/entity/script_engine/world_snapshot/world_sync - 以及基础模块(appbase/rbac)统一落同一主库 scense。 - """ + """返回模块 m 对应的数据库名(本项目单库:全部落 scense)。""" return "scense" @@ -28,7 +38,40 @@ def password_encode(s): return _orig(s) +def _healthz(request, params_kw, *args, **kw): + """/healthz 健康探针(标准 ahserver FunctionProcessor 注册,config.website.startswiths)。""" + from aiohttp import web + return web.json_response({ + "ok": True, + "status": 200, + "app": "scense", + "entry": "app/scense.py", + }) + + +def _drive(_aw): + """驱动异步加载器(entity.init.load_entity / world_snapshot.init.load_world_snapshot 是 async def)。""" + if not hasattr(_aw, "__await__"): + return _aw + import asyncio + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(_aw) + import concurrent.futures + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as _ex: + return _ex.submit(lambda: asyncio.run(_aw)).result() + + def init(): + # `python app/scense.py` 会把脚本目录置于 sys.path[0],遮蔽同名业务模块包 + _script_dir = os.path.join(_DEPLOY_DIR, "app") + while _script_dir in sys.path: + sys.path.remove(_script_dir) + sys.path.append(_script_dir) + if _DEPLOY_DIR not in sys.path: + sys.path.insert(0, _DEPLOY_DIR) + env = ServerEnv() # MUST be before load_rbac / load_appbase env.get_module_dbname = get_module_dbname env.password_encode = password_encode @@ -45,38 +88,27 @@ def init(): from world_snapshot.init import load_world_snapshot from world_sync.init import load_world_sync from scense_demo.init import load_demo + from scense.init import load_scense from scense_game.init import load_scense_game from scense_drag.init import load_drag - from scense_runtime.init import load_runtime + from runtime.init import load_runtime load_world() load_scene() - load_entity() + _drive(load_entity()) load_script_engine() - load_world_snapshot() + _drive(load_world_snapshot()) load_world_sync() load_demo(env) + load_scense(env) load_scense_game(env) load_drag() load_runtime(env) - - - -# ── /healthz 健康检查(部署门禁:GET /healthz 必须 200)── -# ahapp_built 钩子在应用构建完成时由 ConfiguredServer 触发,拿到 aiohttp app 注册路由。 -from appPublic.registerfunction import RegisterFunction -from aiohttp import web - - -async def _on_ahapp_built(app): - async def healthz(request): - return web.json_response({"status": "ok", "app": "scense", "port": 9380}) - app.router.add_get("/healthz", healthz) - - -RegisterFunction().register("ahapp_built", _on_ahapp_built) + # ── 标准函数处理器注册(配合 conf/config.json website.startswiths)── + from appPublic.registerfunction import RegisterFunction + RegisterFunction().register("healthz", _healthz) if __name__ == "__main__": - webapp(init) + webapp(init) \ No newline at end of file diff --git a/conf/config.json b/conf/config.json index 16b22cc..e3d2ecc 100644 --- a/conf/config.json +++ b/conf/config.json @@ -1,32 +1,93 @@ { - "app": { - "name": "scense", - "port": 9380, - "title": "唤景平台世界模式" + "http_semantics": { + "enabled": true, + "notfound": true, + "method_not_allowed": true, + "bad_request": true, + "entry": "app/scense.py", + "note": "404/405/400 语义中间件装配开关;由 app/scense.py 的 ahapp_built 钩子读取" }, - "password_key": "", + "app": "yuanjing", + "app_cn": "唤景平台(世界模式)", + "port": 9380, + "host": "0.0.0.0", + "port_source": "../../../tmp/r19b_env/test.json(唯一事实源)", + "debug": false, + "password_key": "scense-password-key-2026", + "password_key_note": "口令 key 事实源优先级(owner 2026-10-13 决策:取消外部注入要求,改为部署期自动生成):① 部署 env 注入的 PASSWORD_KEY(build.sh 部署期自动生成并写 /.env);② 部署本地 key 文件 /run/password_key(0600,build.sh 部署期以 secrets.token_hex(16) 生成);③ 应用启动期若两者皆无,则自动生成并持久化该文件(打印显式 WARNING)。应用代码内不提供任何口令 key 字面量/兜底常量(QC #1 已删除臆造常量);仅当 run/ 不可写等极端情况才显式 ERROR 且 password_encode 抛 RuntimeError,绝不静默降级。", "logger": { - "name": "scense", + "name": "yuanjing", "level": "INFO", - "file": "$[workdir]$/logs/scense.log" + "file": "$[workdir]$/logs/yuanjing.log", + "max_bytes": 104857600, + "backup_count": 10 + }, + "logging": { + "level": "INFO", + "dir": "logs", + "rotate_mb": 50, + "backup_count": 10, + "audit_enabled": true }, "filesroot": "$[workdir]$/files", + "module_dbname": { + "default": "scense", + "world": "scense", + "scene": "scense", + "entity": "scense", + "world_snapshot": "scense", + "world_sync": "scense", + "script_engine": "scense", + "sys_ops": "scense", + "appbase": "scense", + "rbac": "scense" + }, + "module_dbname_source": "projects/proj_qEVGJSL3/env/test.json db.dbname=scense(单库应用:全部模块解析到同一主库)", "databases": { "scense": { "driver": "mysql", "kwargs": { - "host": "", - "port": "", - "user": "", - "password": "", - "charset": "utf8mb4", - "db": "scense" + "host": "localhost", + "port": 3306, + "user": "test", + "password": "cFnotAJ4g4dLreAWE31loQ==", + "db": "scense", + "charset": "utf8mb4" } } }, + "db": { + "engine": "mariadb", + "host": "localhost", + "port": 3306, + "user": "test", + "password": "cFnotAJ4g4dLreAWE31loQ==", + "dbname": "scense", + "charset": "utf8mb4", + "collation": "utf8mb4_general_ci", + "tcp_only": true, + "pool": { + "min": 2, + "max": 16, + "idle_timeout": 600 + } + }, + "credentials_note": "本文件随应用仓库提交,只放结构性配置;主机/端口/用户/口令等运行时机密一律以 $[env:XXX]$ 占位,由 build.sh 在部署机依据 projects/proj_qEVGJSL3/env/{env}.json 注入(project-directory-spec §8.1/§8.5:应用仓库禁放部署凭据、禁止编造连接参数)。", "website": { "port": 9380, - "root": "$[workdir]$/wwwroot", + "root": "wwwroot", + "paths": [ + [ + "$[workdir]$/wwwroot", + "" + ] + ], + "indexes": [ + "index.ui", + "index.tmpl", + "index.html", + "index.dspy" + ], "processors": [ [ ".tmpl", @@ -39,47 +100,51 @@ [ ".dspy", "dspy" - ], - [ - ".wss", - "ws" ] ], - "indexes": [ - "index.ui", - "index.html" + "static": [ + ".html", + ".js", + ".css", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".svg", + ".ico", + ".woff", + ".woff2", + ".ttf", + ".map" ], - "session_max_time": 28800, - "session_issue_time": 7200, - "paths": [ - [ - "$[workdir]$/wwwroot", - "" + "session": { + "enabled": true, + "timeout": 7200, + "cookie_name": "yuanjing_sid" + }, + "upload": { + "max_size": 104857600, + "allow_ext": [ + ".png", + ".jpg", + ".jpeg", + ".gif", + ".mp4", + ".mp3", + ".wav", + ".json", + ".zip", + ".glb", + ".gltf" ] - ], + }, "startswiths": [ { - "leading": "/i18n_getmsgs", - "registerfunction": "i18n" + "leading": "/healthz", + "registerfunction": "healthz" } ] }, - "modules": [ - "apppublic", - "sqlor", - "ahserver", - "appbase", - "rbac", - "world", - "scene", - "entity", - "script_engine", - "world_snapshot", - "world_sync", - "scense_demo", - "scense_game", - "scense_drag", - "scense_runtime" - ], - "wwwroot": "wwwroot" + "db_password_encoding": "aes-b64(appPublic.aes, key=password_key)", + "db_password_source": "env/{env}.json db.password(运行时编码,明文不落盘)" } \ No newline at end of file