# -*- 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