baseline: 批次A-D 成果 + membership 半成品(测试红)
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
"""安全占位符模板渲染器(自研,杜绝模板注入)。
|
||||
|
||||
语法:
|
||||
- {{path.to.value}} 点路径取值(dict 下标 / list 下标均可),未找到输出空串
|
||||
- {{#each lines}}…{{/each}} 列表循环,循环体内以 {{item.xxx}} 引用当前元素
|
||||
- {{#if path}}…{{/if}} 条件块:真值(非空/非零/非 False/非 None)才输出
|
||||
- {{#if path}}A{{else}}B{{/if}} 条件块带 else 分支
|
||||
- 所有取值均经 html.escape,用户数据(如商品名含 <script>)不会逃逸
|
||||
|
||||
不支持的语法(Django 模板标签 {% %}、Jinja 等)一律原样输出为普通文本,
|
||||
因此租户即便在模板里写恶意内容也无法执行任何逻辑。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
from decimal import Decimal
|
||||
|
||||
EACH_RE = re.compile(r"\{\{#each\s+([a-zA-Z0-9_.]+)\}\}(.*?)\{\{/each\}\}", re.S)
|
||||
IF_OPEN_RE = re.compile(r"\{\{#if\s+([a-zA-Z0-9_.]+)\}\}")
|
||||
IF_CLOSE = "{{/if}}"
|
||||
VAR_RE = re.compile(r"\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}")
|
||||
|
||||
|
||||
def _resolve(context: dict, path: str):
|
||||
"""点路径取值:dict 键 / list 下标 / 对象属性。未找到返回 None。"""
|
||||
cur = context
|
||||
for part in path.split("."):
|
||||
if cur is None:
|
||||
return None
|
||||
if isinstance(cur, dict):
|
||||
cur = cur.get(part)
|
||||
elif isinstance(cur, (list, tuple)):
|
||||
try:
|
||||
cur = cur[int(part)]
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
else:
|
||||
cur = getattr(cur, part, None)
|
||||
return cur
|
||||
|
||||
|
||||
def _truthy(value) -> bool:
|
||||
"""条件块真值判定:None/False/0/空串/空集合 为假。"""
|
||||
if value is None or value is False:
|
||||
return False
|
||||
if isinstance(value, (int, float, Decimal)):
|
||||
return value != 0
|
||||
if isinstance(value, (str, list, tuple, dict, set)):
|
||||
return len(value) > 0
|
||||
return True
|
||||
|
||||
|
||||
def _to_str(value) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, bool):
|
||||
return "是" if value else "否"
|
||||
if isinstance(value, Decimal):
|
||||
return f"{value.normalize():f}" if value == value.to_integral() else str(value)
|
||||
return str(value)
|
||||
|
||||
|
||||
def _render_vars(fragment: str, context: dict) -> str:
|
||||
def sub(m: re.Match) -> str:
|
||||
path = m.group(1)
|
||||
if path.split(".")[0] == "item" and "item" not in context:
|
||||
# 循环外引用 item 视为空(防御)
|
||||
return ""
|
||||
return html.escape(_to_str(_resolve(context, path)))
|
||||
|
||||
return VAR_RE.sub(sub, fragment)
|
||||
|
||||
|
||||
def _split_else(body: str) -> tuple[str, str]:
|
||||
"""把 if 块体按最外层 {{else}} 切成 (truthy, falsy)。"""
|
||||
idx = body.find("{{else}}")
|
||||
if idx < 0:
|
||||
return body, ""
|
||||
return body[:idx], body[idx + len("{{else}}"):]
|
||||
|
||||
|
||||
def _render_fragment(fragment: str, context: dict) -> str:
|
||||
"""渲染一段不含 each 的模板片段:先展开 if 块(支持任意嵌套),再替换变量。
|
||||
|
||||
用"最内层优先"策略:找最近的 {{/if}},向左配对它对应的 {{#if}},
|
||||
因此嵌套块先被替换成纯文本,外层随后自然可解析。
|
||||
"""
|
||||
while True:
|
||||
close_at = fragment.find(IF_CLOSE)
|
||||
if close_at < 0:
|
||||
break
|
||||
opener = None
|
||||
for cand in IF_OPEN_RE.finditer(fragment, 0, close_at):
|
||||
opener = cand
|
||||
if opener is None:
|
||||
# 孤立的 {{/if}}:原样保留,避免死循环
|
||||
break
|
||||
truthy_body, falsy_body = _split_else(fragment[opener.end():close_at])
|
||||
kept = truthy_body if _truthy(_resolve(context, opener.group(1))) else falsy_body
|
||||
fragment = fragment[:opener.start()] + kept + fragment[close_at + len(IF_CLOSE):]
|
||||
return _render_vars(fragment, context)
|
||||
|
||||
|
||||
EACH_OPEN_RE = re.compile(r"\{\{#each\s+([a-zA-Z0-9_.]+)\}\}")
|
||||
EACH_CLOSE = "{{/each}}"
|
||||
|
||||
|
||||
def _render_each(source: str, context: dict) -> str:
|
||||
"""展开 each 循环块,支持嵌套(最内层优先)。
|
||||
|
||||
做法:反复找最近的 {{/each}},向左配对最近的 {{#each}},把该块原地替换为
|
||||
渲染结果;内层先被替换成纯文本后,外层再处理时体内已无 #each 标记。
|
||||
这样任意层级嵌套都能收敛,不需要递归解析器。
|
||||
"""
|
||||
guard = 0
|
||||
while True:
|
||||
guard += 1
|
||||
if guard > 200: # 防御:异常模板不至于死循环
|
||||
break
|
||||
close_at = source.find(EACH_CLOSE)
|
||||
if close_at < 0:
|
||||
break
|
||||
opener = None
|
||||
for cand in EACH_OPEN_RE.finditer(source, 0, close_at):
|
||||
opener = cand
|
||||
if opener is None:
|
||||
break # 孤立的 {{/each}}:原样保留
|
||||
|
||||
list_path = opener.group(1)
|
||||
body = source[opener.end():close_at]
|
||||
items = _resolve(context, list_path) or []
|
||||
rendered = []
|
||||
for item in items:
|
||||
loop_ctx = dict(context)
|
||||
loop_ctx["item"] = item
|
||||
# 关键:每轮必须就地完成 if + 变量替换。
|
||||
# 若把变量替换推迟到外层,item 已不在作用域,{{item.xxx}} 会渲染成空。
|
||||
rendered.append(_render_vars(_expand_ifs(body, loop_ctx), loop_ctx))
|
||||
source = source[:opener.start()] + "".join(rendered) + source[close_at + len(EACH_CLOSE):]
|
||||
return _expand_ifs(source, context)
|
||||
|
||||
|
||||
def _expand_ifs(source: str, context: dict) -> str:
|
||||
"""展开 if 块(最内层优先),不替换变量。"""
|
||||
guard = 0
|
||||
while True:
|
||||
guard += 1
|
||||
if guard > 200:
|
||||
break
|
||||
close_at = source.find(IF_CLOSE)
|
||||
if close_at < 0:
|
||||
break
|
||||
opener = None
|
||||
for cand in IF_OPEN_RE.finditer(source, 0, close_at):
|
||||
opener = cand
|
||||
if opener is None:
|
||||
break
|
||||
truthy_body, falsy_body = _split_else(source[opener.end():close_at])
|
||||
kept = truthy_body if _truthy(_resolve(context, opener.group(1))) else falsy_body
|
||||
source = source[:opener.start()] + kept + source[close_at + len(IF_CLOSE):]
|
||||
return source
|
||||
|
||||
|
||||
def render_template(source: str, context: dict) -> str:
|
||||
"""渲染模板正文。支持嵌套 each、循环体内 if 块、点路径变量。"""
|
||||
expanded = _render_each(source, context)
|
||||
return _render_vars(expanded, context)
|
||||
Reference in New Issue
Block a user