170 lines
6.2 KiB
Python
170 lines
6.2 KiB
Python
"""安全占位符模板渲染器(自研,杜绝模板注入)。
|
||
|
||
语法:
|
||
- {{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)
|