Files

170 lines
6.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""安全占位符模板渲染器(自研,杜绝模板注入)。
语法:
- {{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)