?theme= 之后:emlog 到底是怎么选中模板的

共 3,711 字 约 12 分钟 25 次阅读

一条 URL,把首页打成了 502

那天我打开 https://em.lczld.cn/?theme=lcbk-stationery,首页直接 502;把这段参数删掉再访问,一切正常,文章页多刷几次也能偶尔挤进去。直觉是网关扛不住了,直到我把响应头单独数了一遍:

代码1 行·
curl -s -D - -o /dev/null "https://em.lczld.cn/?theme=lcbk-stationery" | grep -c 'Set-Cookie: emlog_theme'

它回了 200——一次请求,200 条一模一样的 Set-Cookie。响应头被自己撑爆,网关读不完后端返回的东西,只能甩一个 502。抢修过程上次写过,这篇只讲一件事:?theme= 之后,emlog 到底是怎么选中模板的。

模板不是渲染时才选的

很多人以为,页面渲染到 header 时才决定用哪套模板。emlog 不这么干——它在「谁来处理这个请求」刚定下来、业务逻辑还没跑的阶段,就用 init.php 里两行代码把模板名字固定成两个常量:

代码3 行·
// init.php
define('TEMPLATE_URL',  TPLS_URL  . Template::getCurrentTemplate() . '/');
define('TEMPLATE_PATH', TPLS_PATH . Template::getCurrentTemplate() . '/');

一个是网址(拼 CSS、JS、图片),一个是磁盘路径(include 模板文件)。此后整条渲染链只读这两个常量,不再回头重判——选一次,用一路。注意:一个请求里,getCurrentTemplate() 被调用了两次,先记住这个数字。

模板本身的约定也很硬:一套模板就是一个目录,目录名即它的 ID(如 default、lcbk-stationery),只允许字母、数字、下划线、连字符(正则 [\w\-])。目录里必须有 header.php,否则 emlog 根本不认它是模板。后台切模板的真正动作也只有一步:把配置项 nonce_templet 改成新名字,再刷新配置缓存。

<h1
id="getcurrenttemplate四个出口一处隐患">getCurrentTemplate():四个出口,一处隐患

决策全在 include/service/template.php 的 getCurrentTemplate() 里,它有四个出口:

代码13 行·
// include/service/template.php(结构示意)
if (!defined('SWITCH_TEMPLATE') || SWITCH_TEMPLATE !== true) {
    return Option::get('nonce_templet');        // 出口一
}
$theme = Input::getStrVar('theme');
if ($theme && preg_match('/^[\w\-]+$/', $theme)) {
    setcookie('emlog_theme', $theme, time() + 86400, '/');
    return $theme;                              // 出口二
}
if (isset($_COOKIE['emlog_theme']) && preg_match('/^[\w\-]+$/', $_COOKIE['emlog_theme'])) {
    return $_COOKIE['emlog_theme'];             // 出口三
}
return Option::get('nonce_templet');            // 出口四

说白了:出口一和出口四都是「回到后台设定的模板」,出口二是「带参就写 Cookie 并采用」,出口三是「读上次的 Cookie」。盯住出口二——它没有任何「本请求是否已经写过 Cookie」的判断,是无条件的。而 setcookie() 不是「写状态」,是「往响应头追加一行」:一次请求调它多少次,响应头里就有多少条 Set-Cookie。

200 条 Set-Cookie,是这么数出来的

首页为什么把同一个出口踩了 200 次?因为主题里到处在用取设置的助手函数 _g():

代码3 行·
function _g($name) {
    return TplOptions::getInstance()->$name;
}

它最终走进取模板配置的函数。这个函数有缓存(配置只从文件读一次),但缓存挡不住重复解析:它必须先用 Template::getCurrentTemplate() 拿到模板名,再去查内存里的 $this->_templateOptions[$template]。你得先知道「当前模板叫什么」,才能拿这个名字去查缓存——于是「问名字」永远发生在「查缓存」之前,一次也省不掉。

首页 _g() 调得越多,getCurrentTemplate() 被拉起越多;每一次又恰好撞上带 ?theme= 的请求,于是每次都写一条 Set-Cookie。算平账:init 固定 2 次,加主题里 _g() 的 N 次,就是 200 = 2 + N——那个首页模板渲染时大约问了 198 次「当前模板是谁」。这也解释了另外两个怪现象:文章页 _g() 调得少、头没超缓冲区,所以「偶尔能过」;不带 ?theme= 走出口三、压根不写 Cookie,所以「全好」。

它会记住 24 小时,但只在带参数时炸

?theme= 会在浏览器里留下 24 小时的记忆(Cookie 有效期 time() + 86400)——这段时间不带参数再打开,站还是那套模板。但 Set-Cookie 风暴只在带参数那一次请求上发生:只有出口二会写,出口三只读不写。

再往前一步,这套机制默认是关着的,要你在 config.php 里写下 const SWITCH_TEMPLATE = true; 才生效。官方把它写成「方便开发者在一个站点演示多套主题」——它给开发场景准备,不给线上常态。这也正是「为什么偏偏是你的站会炸」的第一环:没打开它,出口二永远进不去。顺带一提,emlog_theme 只有名字、有效期和路径,没有 HttpOnly、没有 Secure、也没有 SameSite。

防线都建在别处

也不是没有兜底:别名被限制成 [\w\-]+,..、/ 进不来;模板被删或损坏时,页面会提示「当前使用的模板已被删除或损坏,请登录后台更换其他模板。」;渲染期取文件时还会再拦一次 ..。但这些防线都在管「别名合不合法」「文件在不在」。

这次翻车的根源不在那儿——别名合法、文件完好,问题出在「同一个合法动作被重复执行了 200 次」,这不是靠校验能拦住的错。而且这个函数从 pro-2.5.26 到现在一脉相承,不是新引入的回归。

五条事后才想明白的道理

一、一个「贴心」的开关,可能就是把内部路径暴露给线上的钥匙。 SWITCH_TEMPLATE 本意是给开发者预览,一旦打开,?theme= 就成了任何访客都能用的入口。

二、没有请求级记忆的解析就是隐患。 init 明明已把结果固化成常量,问题偏偏出在 _g() 绕过了常量、每次都要回函数里再问一遍。

三、setcookie() 是「写响应头」,不是「写状态」。 在会被反复走到的路径上写响应头,等于埋了一个响应头膨胀的引信。

四、主题层和核心层的耦合会放大每一处疏忽。 主题里 _g() 调得越多,核心的解析函数就被拉起越多。

五、网关缓冲区是兜底,不是根治。 调大只是让爆炸阈值来得晚一点;真正的修法是让这件事一开始就不该发生。

带走几条

  1. 记住这个开关:const SWITCH_TEMPLATE = true; 是 ?theme= 的总闸,线上别随手开。
  2. 排查 502 别急着调 buffer,先数响应头:curl -s -D - -o /dev/null "https://em.lczld.cn/?theme=lcbk-stationery" | grep -c 'Set-Cookie'。
  3. setcookie() 只应发一次,给它加一个「本请求是否已发过」的开关,是这次最直接的修法。
  4. 模板名解析值得做请求级记忆——既然 init 已把结果固化成常量,取值就该走常量。
  5. 凡是「只在本地调试才用得上」的功能,都值得在配置里留一个默认关闭的开关。

一条 URL 参数能把首页打成 502,说到底是「一个正常动作被重复执行」加上「重复会被放大成响应头」。拆开看,问题就不再神秘了。

游客评论

分享海报