从零写一个 Typecho 主题和 MCP 插件:流程、25 个坑与心得

从零写一个 Typecho 主题和 MCP 插件:流程、25 个坑与心得

这个博客的样子和写作方式,都是我自己写的:一个 Typecho 主题(logbook)和一个 MCP 插件(LogbookMCP)。从第一行代码到上线,中间踩的坑比我预想的多得多——Typecho 是个很干净的博客程序,但它的核心有不少「读源码才知道」的行为,而这些行为恰好都长在「主题输出」和「对外写入」这两条路上。

这篇把整个过程摊开写:怎么起步、怎么分工、怎么测试,以及 25 个真实踩过的坑(每条都是「现象 → 根因 → 修法」)。如果你也在写 Typecho 主题或插件,希望至少帮你省掉其中几个。

0. 起点:为什么不直接用现成的

需求其实很朴素:一个文档风的双列博客,代码块要好看,手机上能用,能被搜索引擎和 AI 正确理解,最好还能用 AI 直接写文章。

现成方案不是没有,但总有几处不合意:主题要么依赖构建工具和 CDN,要么 SEO 部分只做到一半;「AI 写作」这条路在 Typecho 生态里几乎是空白——原生根本没有对外的写作 API。于是干脆自己写,顺带把过程记录下来。

给自己定了四条约束,后面几乎所有设计取舍都从这里推出来:

  1. 零构建、零 CDN:不引 npm,不发外链,站内所有资源自洽;
  2. 不碰核心:var/、admin/、install/、index.php 保持与官方 1.3.0 发布包逐字节一致——这样以后升级才安全;
  3. 服务端渲染:AI 爬虫大多不执行 JS,正文必须在 HTML 里;
  4. 结论可复现:任何「已修复/没问题」都要有一条能重跑的命令。

1. 环境与准备

  • Typecho 1.3.0(PHP 8.3)
  • nginx + php-cgi + MariaDB 的便携组合(方便随时推倒重来,也方便我确认「不是我机器的问题」)
  • 一个本地域名 + 自签 CA,用来跑和线上一致的真实域名场景

准备工作里最重要的一件事,是先把 Typecho 的相关源码读一遍,而不是照着网上的主题教程抄结构。要读的其实不多:

关注点看哪里
模板是怎么被路由到组件的var/Widget/Archive.php(indexHandle / singleHandle / categoryHandle …)
正文、摘要、缩略图怎么来的var/Widget/Base/Contents.php、var/Widget/Base/Comments.php
后台保存内容时发生了什么var/Widget/Contents/EditTrait.php、var/Widget/Contents/Post/Edit.php
插件钩子与组件机制var/Typecho/Plugin.php、var/Typecho/Widget.php
参数从哪来var/Typecho/Request.php

事后看,第 5 行的 Request.php 和 Widget.php 是最值的——插件篇里最恶心几个坑都出在这两个文件。

2. 主题开发:流程与阶段

实际推进顺序大致是这样,回头看这个顺序是合理的(先跑通,再加功能,最后统一口径):

  1. 最小骨架:index.php + header/footer/list/card,先确认模板匹配与循环正确;
  2. 功能迭代:深浅色 → 目录 → 代码高亮与复制 → 阅读时长 → 封面 → 灯箱 → 归档/友链/关于三个页面模板;
  3. 后台体验:20 项主题设置 + 4 个自定义字段 + 媒体选择增强;
  4. SEO / GEO:canonical / OG / Twitter / JSON-LD + robots.txt / sitemap.xml / llms.txt 三个端点;
  5. 统一安全口径:把「哪里转义、哪里不转义」定死,然后回头逐个改;
  6. 黑盒审计:拿真实 HTTP 请求和 headless 浏览器把每条路由、每个视口跑一遍。

第 5 步是最容易被低估的。功能阶段每写一个模板就随手输出一次,等回头统一时,已经散落在二十多个文件里了。

3. 主题篇:12 个坑

坑 1:首页整页 500,而且错误里什么都不说

  • 现象:首页直接 500,日志里只有一句 Typed property Widget\Archive::$archiveSlug must not be accessed before initialization。
  • 根因:Typecho 1.3 的 Widget\Archive 里,private string $archiveSlug; 是没有默认值的类型化属性,它只在分类/标签/作者/日期/单页/搜索这些分支里被赋值,indexHandle(也就是首页)从来不赋值。PHP 8 访问未初始化的 typed property 直接抛 Error。
  • 修法:包一层访问器,失败就当空字符串:
function logbook_archive_slug(\Widget\Archive $archive): string
{
    try {
        return (string) $archive->getArchiveSlug();
    } catch (\Throwable $e) {
        return '';   // 首页等场景核心根本没赋值
    }
}

坑 2:属性读出来是 null,但一声不吭

  • 现象:og:title 某一版全是空值。
  • 根因:1.3 把 archiveTitle / archiveSlug / archiveType / archiveDescription 都改成了 getter/setter,不再提供 ___xxx() 魔术方法。于是 $archive->archiveTitle 不再抛错,而是安静地返回 null。
  • 修法:一律走 getter(getArchiveTitle()),并且在模板里对空值兜底。这个坑的教训是:PHP 里「从报错变成静默」的改动最危险,因为它会穿过所有测试。

坑 3:contentEx 钩子做正文后处理,完全不生效

  • 现象:给正文标题补锚点、给图片加懒加载,挂 contentEx 钩子后毫无效果。
  • 根因:Archive::singleHandle() 在加载主题的 functions.php 之前就用正文生成 meta 描述了,正文被提前渲染并缓存,钩子挂上去的时候已经晚了。
  • 修法:放弃钩子,改在模板层做输出缓冲——时序完全可控:
function logbook_content(\Widget\Archive $archive)
{
    ob_start();
    $archive->content();
    echo logbook_enhance((string) ob_get_clean());
}

坑 4:同一个页面出现两个 canonical

  • 现象:文章页里 canonical 有两条,指向同一个地址。SEO 上这是互相矛盾的信号。
  • 根因:Typecho 的 $this->header() 在「单页且非自定义首页」的情况下,已经输出过一条 canonical 了。
  • 修法:只在核心没输出时补,并且把「404 页不输出 canonical」也一并处理:
$typechoHasCanonical = $archive->is('single') && !$archive->is('index');
if (!$is404 && !$typechoHasCanonical) { /* 输出自己的 */ }

验证方式是数一遍:8 类页面的 canonical 计数全部等于 1,404 页等于 0。

坑 5:标题里的 </script> 直接截断 JSON-LD

  • 现象:一篇文章标题里带了 </script>,页面的 <script> 标签数量从 6 涨到 19,结构化数据全废,还顺手开了个 XSS 口子。
  • 根因:json_encode() 带了 JSON_UNESCAPED_SLASHES,</script> 被原样输出,提前闭合了脚本块。
  • 修法:加 JSON_HEX_TAG(把 < 变成 \u003C)。JSON 依然是合法且可读的。

坑 6:主题里好几处标题没转义

  • 现象:审计时实测出多个泄漏点:<title>、feed 的 <link title="…">、侧栏最新文章、相关文章、404 的「顺便看看这些」、上下篇导航、导航栏里的页面名与分类名。
  • 根因:核心的 $archive->title()、$item->name() 是输出型且不转义的方法;更麻烦的是核心的 thePrev()/theNext() 还会把标题塞进 title="…" 属性里。
  • 修法:所有列表与导航统一 htmlspecialchars(..., ENT_QUOTES, 'UTF-8');上下篇干脆自己实现(自己查库、自己转义,顺带过滤掉加密文章和非 publish 状态);<title> 与三条 feed link 也自己输出。

顺带说一个反向的坑:修 <title> 的时候我引入了 zxfblog · zxfblog · 写代码,也记录生活。 这种重复,最后靠「页标题与站点名相同时不重复追加」修掉。修转义的时候很容易顺手制造重复。

坑 7:转义口径搞错会双重转义

  • 现象:Tom & Jerry 在前台变成 Tom &amp;amp; Jerry。
  • 根因:一开始我写的是「入库 htmlspecialchars + 输出再转义」。
  • 修法:定死口径——库里存原文(写入侧只做字符集收敛)+ 输出时转义,与 Typecho 核心一致。这样 feed、JSON-LD、搜索等其它消费方拿到的也是干净文本。

这条其实不是技术难题,是口径问题:一定要在写第一个模板之前定下来,否则就是全站返工。

坑 8:mail 绝对不能转义

  • 现象:统一转义时顺手把评论邮箱也转了,结果头像全变成默认图。
  • 根因:gravatar 用 md5(mail) 取图,转义后 md5 当然变了。
  • 修法:邮箱只做「剥离危险字符 + 限长」,不参与 HTML 转义。URL 同理,只保留 http(s)。

坑 9:空标题让结构化数据缺必填项

  • 现象:Typecho 允许无标题文章,此时 og:title 和 JSON-LD 的 headline(必填)都是空串。
  • 修法:写一个兜底函数,标题为空时退回「站点名」,单页再追加日期,实测输出 zxfblog · 2026-09-26。

坑 10:上线当天才发现的卡片标题 bug

  • 现象:首页每张卡片的标题都是「无标题文章」,分类页每张都显示分类名。
  • 根因:卡片的标题走了 logbook_title_of(),而这个函数在非单页场景返回的是 getArchiveTitle()——也就是归档标题(首页为空、分类页是分类名),而不是当前文章标题。卡片上这处是 itemprop="name headline",属于 BlogPosting 结构化数据,标题错了 SEO 也跟着错。
  • 修法:card.php 只在列表循环里被引入,直接取当前行标题即可:
$lbTitle = trim((string) $this->title);   // 循环内即当前文章标题
echo htmlspecialchars($lbTitle !== '' ? $lbTitle : _t('无标题文章'), ENT_QUOTES, 'UTF-8');

值得单独说这条,因为它是上线部署时顺手发现的,而不是测试发现的。发现后我先在本地环境复现了同一个首页——输出完全一致,才确认它是既有缺陷、不是部署引入的。这个「先复现再动手」的习惯,比任何断言都省事。

坑 11:移动端配色按钮停在正中间

  • 现象:≤860px 时,页头的配色按钮悬在屏幕中央,旁边空空荡荡。
  • 根因:页头是 flex,.head-tools 和 .nav-toggle 都写了 margin-left:auto,剩余空间被两个 auto 边距平分了。
  • 修法:只给排在前面的那个吃 auto,后面的自然紧跟:
@media (max-width: 860px) {
    .head-tools { order: 2; margin-left: auto; }
    .nav-toggle { order: 3; }
}

这类问题肉眼很难判断「到底差了几个像素」,我最后是用 CDP 量 left/right 和间距来确认的。

坑 12:没有 JS 时,手机端完全没法导航

  • 现象:禁用 JS 后,≤860px 的导航是 visibility:hidden,靠 JS 加 .is-open 才显示;目录也是 JS 生成的,于是整站无法导航。
  • 修法:用 <noscript> 覆盖样式——菜单静态展开、隐藏必然是空的目录盒。降级不追求好看,但必须能用。

4. 插件篇:从 0 到 /mcp-api

4.1 架构与两个取舍

链路很简单:

AI 客户端 → mcp/server.js → HTTPS(Bearer) → /mcp-api → Api.php(鉴权/分发) → Service.php(业务) → Typecho 核心 / MySQL

两个刻意的取舍:

  • 写入走核心,读取直接查库。写入交给 PostEdit / PageEdit,保证分类、标签、附件、字段、钩子这套逻辑和后台完全一致;读取则直接 SQL,因为我要能读到草稿和私密内容(后台的组件默认会帮你过滤掉)。
  • 单一端点 + 一个 action 参数。没有为每个功能开一个路由,Service::handle() 里一个 switch 分发 23 个 action,路由注册只有一行:
\Utils\Helper::addRoute(self::ROUTE_NAME, self::ROUTE_PATH, 'LogbookMCP_Api', 'dispatch');

另外,主题自定义字段不写死在 MCP 里:接口只暴露一个通用的 fields 对象,能填什么由 site.info 返回的 themeFields 决定。这样换主题不用改 MCP。

4.2 插件篇:13 个坑

坑 13:对已发布文章传 status: "draft",它不会下架

  • 现象:接口返回成功,status 看着也对,但线上文章还在。
  • 根因:Typecho 核心在保存时,如果内容已存在,会新建一条 type='revision' 的修订草稿挂在它下面(EditTrait::save()),原文章的 status 一动不动。
  • 修法:不改核心语义(这是合理设计),而是在返回值里把这件事说清楚:加 draftAsRevision 与 revisionCid 两个字段。接口的诚实程度决定了调用方会不会误判。

坑 14:post.update {password:'x'} 返回成功,但文章没被保护

  • 现象:给文章设密码,接口说成功了,打开链接却不需要密码。
  • 根因:EditTrait::checkStatus() 只要 visibility 是 private/waiting/publish/hidden 之一,就无条件清空密码;想真正设置密码,必须把 visibility 传成字面量 'password'。
  • 修法:密码非空就强制 visibility = 'password'。同时处理了反向问题:密码文章的 status 在库里是 publish,如果局部更新时不带密码,按 status 推出 visibility='publish',核心就会把密码清掉——等于「随手改个摘要就把加密解了」。所以密码必须「不传则沿用」。

坑 15:刚发布立刻访问是 404

  • 现象:接口返回 permalink,紧接着 curl 过去 404。
  • 根因:前台的可见性条件是 created < 当前请求时间(var/Widget/Archive.php:646),发布时间等于当前这一秒就不可见,要等下一秒。
  • 修法:新建且未显式指定 created 时,写 time() - 1。显式传未来时间仍然保留定时发布的语义,不做干预。

坑 16:finally 里的 return 吃掉了异常,报错永远是假的

  • 现象:标题 151 个字符(超 varchar(150))时,接口返回 500,错误信息是「请检查插件设置里的『以哪个用户发布』」——和真实原因毫无关系。
  • 根因:Typecho\Widget::widget() 的 finally 块里有 return $widget;。PHP 语义下,finally 中的 return 会丢弃正在传播的异常。于是 MySQL 的 SQLSTATE[22001] … 1406 Data too long for column 'title' 被吞掉,调用方只看到 $widget->cid 没被设置 → 0 → 落到兜底分支。
  • 修法:入口主动校验,给调用方一个明确的 400。注意要按转义之后的长度算,因为 & → &amp; 会变长:
$escapedLength = mb_strlen(htmlspecialchars($title, ENT_QUOTES, 'UTF-8'), 'UTF-8');
if ($escapedLength > self::MAX_TITLE_LENGTH) {
    throw new ApiError('bad_request', 'title 过长:转义后 ' . $escapedLength . ' 个字符…');
}

根治要绕过 widget() 复刻写入流程,我没做——所以同类问题(例如 slug 唯一键冲突)目前仍表现为「写入失败」。这是已知的短板。

坑 17:接口返回成功,但分类、标签、自定义字段没写进去

  • 现象:偶尔(尤其是短时间内连续两次更新)分类、标签、字段静默不生效。
  • 根因:核心 publish()/save() 里是 if ($this->update($contents, $where)) { setCategories(); setTags(); applyFields(); },而这个判断用的是 MySQL 的 affected_rows。当这次 UPDATE 每一列都和库里一模一样时(最常见的就是两次更新落在同一秒、连 modified 都相同),affected_rows 是 0,后面三步全被跳过。
  • 修法:不能改核心,就在写完之后做一次幂等核对与补写:ensureFields() + repairTaxonomy()。「接口返回成功」和「数据真的变了」是两件事,写操作后必须回读核对。

坑 18:一个空数组,让整个 JSON 响应变成不可解析

  • 现象:某些调用返回的内容前面多了一段 <br /><b>Deprecated</b>: trim(): Passing null to parameter #1…,客户端 JSON.parse 直接失败。
  • 根因:插件把 category 传成了空数组 []。Request::getArray() 里 !empty($result) 为假、$exists 为真,于是走到 return [$this->get($key)](get() 没传默认值)→ 空数组变成 [null] → EditTrait::setCategories() 的 array_map('trim', $categories) 在 PHP 8.1+ 抛弃用警告。
  • 修法:两头都堵——fallbackCategories() 永不返回空数组(默认分类 → 现有第一个 → 新建),Api::dispatch() 开输出缓冲把杂音拦掉。对外接口的输出缓冲是必需品,不是保险。

坑 19:array_merge() 一个 TypeError,让后台保存插件设置必 500

  • 现象:插件设置页能打开,一保存就 500。
  • 根因:Plugin::all() 返回的是 \Typecho\Config 对象(extends \stdClass implements \Iterator, \ArrayAccess),把它喂给 array_merge() 在 PHP 8 直接抛 TypeError。
  • 修法:$base = $current instanceof \Typecho\Config ? $current->toArray() : (array) $current;

这条最值得记住的不是修法,而是它为什么一直没被发现:既有测试只 GET 过配置页,从来没有 POST 过。所以整个 configHandle() 从未被执行。教训:测试要覆盖写入路径,只读一遍页面等于没测。

坑 20:停用插件留下孤儿钩子,后台全站 500

  • 现象:停用 CommentNotifier 后,前台完全正常,后台每个页面都 500。
  • 根因:Typecho 停用插件时,靠 activated[插件名]['handles'] 这本账去摘掉回调;而这本账只收录「组件名以 插件名: 开头」的钩子。CommentNotifier 全挂在核心组件上(Widget_Feedback:finishComment、admin/footer.php:end …),一个都没记上 → 摘不掉 → 全局 handles 里留着指向已不再加载的类,其中最致命的 admin/footer.php:end 每个后台页面都触发。
  • 修法:写了一个 plugin-handles-repair.php,按「回调所属插件是否还在 activated 里」清孤儿并重建记账;安装路径改成用「安装前后快照差集」记账。顺带发现 XCaptcha 的记账形态也是错的,一旦停用,前台评论也会 500。

坑 21:重新启用插件,静默轮换掉所有客户端的令牌

  • 现象:只要在后台点一次「停用 → 启用」,所有已配置的 MCP 客户端集体 401,而且报错和「令牌填错」无法区分。
  • 根因:activate() 每次都 makeToken() 并整体覆盖配置行。
  • 修法:激活时合并已有配置,令牌只在不存在时生成。令牌这类凭证,默认行为应该是「保持不变」。

坑 22:启用插件不建配置行,后台又 500

  • 现象:.php 脚本方式启用插件后,后台报「插件 X 的配置信息没有找到」。
  • 根因:禁用会删除 plugin:<插件名> 这条配置行,而插件运行期 Helper::options()->plugin('X') 读不到就抛 PluginException。更绕的是:直接调 config($form) 也建不起来,因为插件靠 $_GET['activate'] 判断自己正处于激活流程。
  • 修法:启用后若缺配置行,就设 $_GET['activate']、构建 config() 表单、把默认值写库。

坑 23:白名单里的一个笔误,把整个白名单废掉

  • 现象:IP 白名单写 127.0.0.1/abc(手滑),结果放行了所有 IP。
  • 根因:位宽解析是 (int) $bitsRaw,'abc' 被转成 0,语义就变成了 /0——即匹配全网。
  • 修法:用 inet_pton() 做二进制前缀比较,IPv4/IPv6 同一套逻辑;地址族与位宽都严格校验,解析失败一律不放行(fail-closed)。/999、/-1、/abc、/7、/25 全部进了单测。

安全判断里,「解析失败」必须往「拒绝」那一侧倒,否则一个笔误就是全网开放。

坑 24:评论作者可以注入活的 <img onerror>

  • 现象:通过插件的评论回复接口写入的评论,作者名里的 HTML 会被浏览器真正执行(存储型 XSS)。
  • 根因:一条三段链——插件的 reply 分支把 author 无过滤直接写库,绕过了 Widget\Feedback 上的 xssCheck 规则;而核心 Widget\Base\Comments::author() 是裸 echo,gravatar() 还会把作者名塞进 <img alt="…">。作者的「安全」完全依赖入口校验。
  • 修法:双层处理。写入侧按 Validate::xssCheck 的字符集清洗(剔除 ( ) \ " < > 与控制字符)并限长 150;输出侧在渲染前一行把所有行数据里的 author 换成转义值——Widget::__set() 会覆盖 row['author']、__get() 优先读它,于是 <cite> 和头像 alt 两个泄漏点一处堵死。

插件是「绕开核心校验的写入方」时,必须自己把校验补上。

坑 25:未鉴权的 ping 把站点指纹送出去

  • 现象:一个不需要令牌的健康检查接口,返回了 PHP 版本、Typecho 版本、站点地址、站点标题、时区。
  • 修法:静默判断令牌(tokenAccepted()),没带令牌时只回插件名与版本——足够回答「路由通了没、插件在不在」,但不值得为它暴露指纹。

5. 其余零散坑(不编号,但都真实发生过)

  • CLI 脚本只认一种序列化格式,把整个插件注册表清空了:后台写的是 json_encode(),脚本只 @unserialize(),解析失败就把 $plugins 置空数组写回。运行期其实是两种都支持的(Options::___plugins() 里按 a: 前缀分流)。修法:两种都试,解析不出就中止,写回保持原格式。
  • 空字符串环境变量挡住了配置文件回落(Node 端):url/token 用 || 能正确回落,但 insecure 用了 ??——空字符串不是 nullish。统一改成 envOr(),「空字符串 = 未设置」。
  • 协议版本协商原来等于没协商:initialize 把客户端上报的版本原样回显,客户端报 9999-01-01 也照抄。改成支持列表协商(2025-06-18 / 2025-03-26 / 2024-11-05)。
  • 不带换行结尾的消息会永久滞留:stdio 只在遇到 \n 时解析,残留缓冲既不解析也不报错也不超时,症状是「服务端看着正常就是不回」。修法:空闲 120ms 后,只在残留内容能 JSON.parse 成功时才消费,避免误伤分片慢发的客户端。
  • 测试中断留下垃圾,让「零漂移」结论不可信:清理依赖内存里登记的 cid,进程一死就丢。改成所有测试内容标题统一带 #DEEPTEST 标记,清理只认标记,并且「开头预清理 + finally 兜底」各跑一次。
  • sitemap.xml 开头多了 3 个字节:config.inc.php 被写进了 BOM,而三个端点都会 include 它。根因是 Windows PowerShell 5.1 的 Set-Content -Encoding UTF8 会写 BOM。修法:改用 [System.IO.File]::WriteAllText($p, $text, (New-Object System.Text.UTF8Encoding($false)))。给 PHP 写文件时,BOM 是隐形的炸弹。

6. 测试与审计:把「我觉得没问题」变成可复现的结论

主题和插件分别做了几轮,最后的口径是552 项断言全过、站点数据零残留。手段其实是混着用的:

  • 黑盒 HTTP 断言:28 条路由、状态码、canonical / title / description / OG / JSON-LD 各只出现一次、sitemap 每条 URL 逐条请求;
  • headless 浏览器(CDP):真实交互(发布文章、勾验证码评论)、几何测量(页头按钮位置)、多视口零横向溢出、截图留档;
  • 数据库直查:断言「数据真的变成期望的样子」,而不是只看接口返回值;
  • 与官方包逐文件 SHA256 比对:证明核心没被改过;
  • 恶意/边界注入:空标题、无分类、加密文章、<!--more-->、超长标题、超长关键词、分页越界、畸形 JSON。

其中真正抓到大鱼的是最后两条。基线 438 项全绿之后,我回头逐行读测试代码找盲区,补了 5 个套件共 58 项断言,结果挖出 2 高危 / 3 中危 / 4 低危(包括 </script> 截断和评论作者 XSS),修复过程中又被新单测抓出一个位宽 fail-open。

三条经验:

  1. 测试全绿 ≠ 没问题,它只说明「被测到的地方没问题」。要定期反问:哪些路径我从没 POST 过?(坑 19 就是这么漏掉的)
  2. 写入类断言必须回读核对,因为核心有几处「affected_rows = 0 就跳过后续写入」的行为(坑 17);
  3. 测试自己会留垃圾,清理策略不能依赖进程内存(否则给站点留下孤儿数据,还得出「零漂移」的假结论)。

7. 上线(宝塔)当天的四个坑

主题和插件都在本地跑通之后,搬到宝塔服务器上部署,又补了四条:

  1. 写操作接口不能无脑重试:建站请求第一次其实已经成功,客户端因为一次瞬时 404 自动重试,第二次撞上「域名已存在」报错,看起来像失败。非幂等的写操作必须关掉自动重试(retries=0),否则你会对着一个「已经成功」的错误查半天。
  2. 伪静态是 server 级 rewrite,先于 location 匹配执行:我把 /robots.txt → robots.php 写在 location = /robots.txt 里,一直 404——因为 Typecho 的兜底规则先把不存在的路径改写成 /index.php/robots.txt 了。SEO 端点必须写在同一个 rewrite 阶段、并且在伪静态 include 之前。
  3. nginx 的 error_page 404 /404.html; 会盖掉主题的 404 页:站点里没有 404.html,于是所有 404 都变成 nginx 的白板页。注释掉这一行,主题的 404.php 才生效。
  4. 插件里写死的相对日志路径会撞 open_basedir:日志目录原本取 <站点根>/../server/logs(本地便携栈的约定),线上不存在,于是每次调用都往 nginx 错误日志里刷一条警告。要么把日志开关关掉,要么把路径改成站点根目录内的可写目录。

还有一条不算坑但很重要:部署后我用本地环境复现了同一个页面,正是这次对比让「卡片标题 bug」被定性为既有缺陷,而不是新引入的回归。

8. 十条经验

  1. 先读源码,再写模板。 Typecho 的坑几乎全在「你以为的时序」和「实际的时序」之间。
  2. 口径要先行。 转义、时间、可见性、错误文案——定下来再动手,返工成本差一个量级。
  3. 安全判断 fail-closed。 解析失败就拒绝,不要让一个笔误把防护变成装饰。
  4. 写操作后回读核对。 「返回成功」不等于「数据变了」。
  5. 对外接口永远开输出缓冲。 一条 PHP 弃用警告就能毁掉整个 JSON 响应。
  6. 不要改核心。 与官方包逐字节一致,是未来能安全升级的唯一前提。
  7. 输出层做兜底转义。 即使入口承诺校验过,历史脏数据和第三方写入方也会绕过它。
  8. 别只测读路径。 配置保存、字段写入、删除清理这些「写」的地方最容易没人碰。
  9. 测试的清理策略要和进程生命周期解耦,否则你会得到假的「零残留」。
  10. 结论必须可复现。 每条「已修复」后面都该跟一条能重跑的命令;做不到的,就在报告里写「未覆盖」。

9. 还没做的

写清楚边界比写得漂亮更重要。目前明确没做或没覆盖的:

  • 多用户与权限:令牌等价于管理员,没有细粒度授权;
  • 真实第三方验证码:交互式组件的真实 token 无法自动化,只验证了「渲染与提交链路」;
  • SMTP 发信、评论分页、XML-RPC;
  • 1000+ 文章的性能:当前数据量下没有可测的性能曲线;
  • 老浏览器:CSS 用到了 :has()、color-mix(),很旧的浏览器样式会缺失(功能不受影响,因为 JS 有降级);
  • widget() 吞异常导致的同类问题(如 slug 唯一键冲突)仍表现为写入失败,没有根治。

这些不是「以后再说」,而是已经知道自己不知道的部分——写在这里,免得下次自己也被「552 项全过」这句话骗过去。

相关文章

暂无评论

评论 RSS

本文的评论功能已关闭