本文介绍的方法涉及关闭赞助版的校验流程,该做法可能绕过官方授权验证机制,仅供学习、测试或特殊场景下使用,请确保您拥有相关软件或服务的合法使用权。非法使用或未经授权的操作可能导致服务异常或法律风险,使用前请充分评估相关责任。本文主要目的是分享技术实现思路和代码示范,欢迎交流和探讨,严禁用于侵犯版权或违规用途。
主题版本:v2.1 (Joe再续前缘)
主题原作者:易航 (Yihang)
v2版本起收费¥88.88,故作逆向分析
逆向分析:LHL

查看更多Typecho逆向主题/插件:跳转
一、问题背景
Joe再续前缘主题需要解决以下问题以实现纯本地运行:
- 授权验证弹窗:主题内置域名授权验证,未授权时 PHP 直接 echo 提示文字
- 数据库安装报错:主题重复激活时
ALTER TABLE ... ADD报Duplicate column name致命错误 - API 路由 404:主题
/joe/api/*接口全部返回Path not found错误 - 点赞/收藏/关注按钮返回 HTML:AJAX 请求 API 但收到整页 HTML 而非 JSON
这四点不是一下子就得到的,而是先去除授权验证弹窗,后续遇到问题再解决问题
二、主题加密架构分析
2.1 识别加密文件
首先查看主题文件结构,发现 public/ 目录下三个文件体积异常大且内容为乱码:
ls -la public/
# common.php 59 KB
# function.php 401 KB
# api.php 341 KB用 hexdump -C 查看文件头,发现都以正常的 <?php 开头,随后跟随大量 goto 标签和变量赋值,这是典型的 PHP goto-based obfuscator 特征。核心数据存储在长字符串变量中,运行时通过 VM 解释器执行。
2.2 确定 XOR 密钥
通过对每个文件的数据区域进行频率分析,逐字节 XOR 尝试不同密钥(0x00-0xFF),检查解码结果是否产生可读文本:
// 频率分析脚本
$file = file_get_contents('public/function.php');
for ($key = 0; $key < 256; $key++) {
$readable = 0;
for ($i = 5000; $i < 6000; $i++) {
$c = ord($file[$i]) ^ $key;
if ($c >= 32 && $c <= 126) $readable++;
}
if ($readable > 800) echo "key=0x" . dechex($key) . " readable=$readable\n";
}运行后输出:
| 文件 | 最佳密钥 | 可读字符比例 |
|---|---|---|
public/common.php | 0xB8 (184) | 约 85% |
public/function.php | 0xF2 (242) | 约 87% |
public/api.php | 0xBF (191) | 约 84% |
2.3 XOR 解码方法
确定密钥后,使用以下方法解码指定偏移区间的字节:
$file = file_get_contents('public/function.php');
$key = 242; // 0xF2
for ($i = $start; $i < $end; $i++) {
$c = ord($file[$i]) ^ $key;
if ($c >= 32 && $c <= 126) echo chr($c);
else echo '.'; // 不可打印字符用 . 替代
}解码不会产生连续可读的 PHP 源码,而是产生夹杂 VM 操作码的片段。需要结合上下文辨认:
- 字符串常量:连续 ASCII 字符,如
hash_hmac,sha256,auth.ini等 - 函数名/变量名:出现在 VM 调用指令附近
- 结构边界:通过搜索
function、return、if等关键字定位函数起止
2.4 加密体系概览
三个加密文件均采用 XOR 字节码 + goto 控制流 + 栈式虚拟机 架构:
| 文件 | 大小 | XOR 密钥 | 职责 |
|---|---|---|---|
public/common.php | 59 KB | 0xB8 (184) | 初始化常量、加载子文件 |
public/function.php | 401 KB | 0xF2 (242) | 所有主题函数定义(含授权) |
public/api.php | 341 KB | 0xBF (191) | JoeApi 类 API 接口 |
加载链路:
functions.php → require public/common.php
common.php → require public/function.php (所有 joe_xxx 函数在此定义)
common.php → require public/api.php (JoeApi 类在此定义)三、问题一:授权验证弹窗
3.1 症状
前台和后台页面均直接输出以下文字(PHP echo):
欢迎您使用Joe再续前缘,请 赞助 后使用,联系方式:[email protected]
3.2 逆向分析过程
步骤 1:搜索错误文本来源
在主题所有文件中 grep 搜索"赞助"、"auth.yihang"等关键字 → 仅在文件头注释中找到,非功能代码。确认消息来自加密 VM 运行时生成。
步骤 2:在加密文件中定位 HTML 片段
对 function.php 的数据区进行滑动窗口 XOR 解码(key=0xF2),搜索 auth.yihang 子串:
$file = file_get_contents('public/function.php');
$key = 242;
$decoded = '';
for ($i = 0; $i < strlen($file); $i++) {
$decoded .= chr(ord($file[$i]) ^ $key);
}
$pos = strpos($decoded, 'auth.yihang');
echo "Found at offset: $pos\n"; // 输出: Found at offset: 60680在偏移 60680 附近解码输出片段:
...auth.yihang.info...赞助[email protected]...确认这就是弹窗消息的字符串数据。
步骤 3:追踪调用链
从偏移 60680 向前搜索,在偏移 60172 找到包含该字符串引用的函数边界。解码此区域识别出 joe_check_auth() 函数签名。
继续使用 grep -c "joe_check_auth" 对解码后的全文进行统计 → 发现 80+ 处调用,几乎每个主题函数入口都调用。直接注释不可行。
步骤 4:逆向核心验证函数
从 joe_check_auth() 追踪到 joe_is_auth()。在偏移 66458 处解码出完整的验证逻辑:
从这些片段重建出 joe_is_auth() 伪代码:
步骤 5:验证算法正确性
编写测试脚本生成 token 并比对:
将此值写入 public/auth.ini 后,主题不再输出授权提示。验证通过。
步骤 6:识别其他授权相关函数
在 joe_is_auth 前后区域继续解码:
| 偏移 | 解码得到的函数签名/关键字 | 功能 |
|---|---|---|
| 60172 | joe_check_auth | 调用 joe_is_auth(),失败时输出错误 HTML |
| 60818 | joe_is_sdfkdifhb | 直接调用 joe_is_auth() 返回结果(混淆函数名) |
| 61115 | joe_domain | 获取当前域名 |
| 62397 | joe_request_server + auth.yihang.info/server/typecho-joe/ | 远程授权通信 |
joe_is_sdfkdifhb() 在 module/footer.php 中被调用,控制底部推广横幅显示。
3.3 修复方案
策略:在 common.php 加载前写入合法的 auth.ini,让 VM 内部验证直接通过。
修改 1:functions.php — 授权 Token 自动生成
在 require_once JOE_ROOT . 'public/common.php' 之前插入:
原理:joe_is_auth() 优先读取 public/auth.ini。我们抢在 VM 加载之前写入正确哈希值,验证自然通过。Token 按月自动更新。
修改 2:functions.php — OB 缓冲安全网
用 ob_start / ob_get_clean 包裹 require,过滤初始化阶段残留的授权错误输出:
ob_start();
require_once JOE_ROOT . 'public/common.php';
$_joe_ob = ob_get_clean();
if (!empty($_joe_ob)) {
echo preg_replace('/[^<]*?Joe[^<]*?auth\.yihang\.info[^<]*?2136118039@qq\.com[^>]*/su', '', $_joe_ob);
}
unset($_joe_ob);修改 3:functions.php 中 joe_markdown_hide()
// joe_check_auth(); ← 注释掉此行此处是明文 PHP 中唯一直接调用 joe_check_auth() 的位置。
修改 4:module/footer.php(第73行)
-if (!joe_is_sdfkdifhb()) echo '<span ...>本站同款主题模板</span>';
+if (false) echo '<span ...>本站同款主题模板</span>';修改 5:assets/typecho/config/js/joe.config.js(约186行)
// 注释掉后台 theme-error API 轮询:
// {
// $.getJSON(`${Joe.BASE_API}theme-error`, (data) => {
// if (!data.message) return;
// layer.alert(data.message);
// });
// }四、问题二:数据库重复列错误
4.1 症状
SQLSTATE[42S21]: Column already exists: 1060 Duplicate column name 'views'
Typecho\Db\Adapter\SQLException in Pdo.php:1114.2 逆向分析过程
步骤 1:从堆栈追踪定位
堆栈显示错误发生在 function.php 内部 VM 执行的 Db::query() 调用中。
步骤 2:解码安装函数
对 function.php 偏移 152428-160509 区域 XOR 解码,识别出两个函数:
joe_install_sql()(偏移 152428)解码片段:
... database/install/ ... .sql ... file_get_contents ...
... prefix_ ... explode ... ; ...重建逻辑:读取 module/database/install/{adapter}.sql,替换表前缀后按 ; 分割,逐条返回。
joe_install()(偏移 160509)解码片段:
... theme:JoeInstall ... joe_install_sql ... joe_datebase_version_sql ...
... Db::query ... try ... catch ...重建逻辑:获取安装 SQL,逐条执行。VM 内的 try-catch 存在缺陷,无法正确捕获 PDO 的 SQLException。
步骤 3:查看 SQL 文件
检查 module/database/install/mysql.sql,发现 4 条 ALTER TABLE ... ADD 无任何列存在性检查:
ALTER TABLE `prefix_contents` ADD `views` INT NOT NULL DEFAULT 0;
ALTER TABLE `prefix_contents` ADD `agree` INT NOT NULL DEFAULT 0;
ALTER TABLE `prefix_comments` ADD `agree` INT NOT NULL DEFAULT 0;
ALTER TABLE `prefix_metas` ADD `image` VARCHAR(255) NULL DEFAULT NULL AFTER `description`;MySQL 不支持 ADD COLUMN IF NOT EXISTS(仅 MariaDB 支持),且 VM 的 try-catch 无法捕获此异常。
4.3 修复方案
策略:从 SQL 文件中移除 ALTER TABLE,改用 PHP 原生 try-catch。
修改 1:module/database/install/mysql.sql
删除上述 4 行 ALTER TABLE,仅保留 CREATE TABLE IF NOT EXISTS 和 INSERT。
修改 2:functions.php — 安全列创建
在 require common.php 之后添加:
(function () {
try {
$db = \Typecho\Db::get();
$prefix = $db->getPrefix();
$adapterName = get_class($db->getAdapter());
if (stripos($adapterName, 'Mysql') === false && stripos($adapterName, 'pdo') === false) return;
$alterStatements = [
"ALTER TABLE `{$prefix}contents` ADD `views` INT NOT NULL DEFAULT 0",
"ALTER TABLE `{$prefix}contents` ADD `agree` INT NOT NULL DEFAULT 0",
"ALTER TABLE `{$prefix}comments` ADD `agree` INT NOT NULL DEFAULT 0",
"ALTER TABLE `{$prefix}metas` ADD `image` VARCHAR(255) NULL DEFAULT NULL AFTER `description`",
];
foreach ($alterStatements as $sql) {
try { $db->query($sql); }
catch (\Throwable $e) { /* 列已存在则忽略 */ }
}
} catch (\Throwable $e) { /* 数据库不可用则跳过 */ }
})();原理:PHP 原生 try-catch (\Throwable) 可靠捕获 PDO 的 SQLException,列已存在时静默忽略。
五、问题三:/joe/api/* 路由 404 错误
5.1 症状
访问主题 API 接口(如 /joe/api/motto)返回:
Path '/joe/api/motto' not found
Typecho\Router\Exception in Router.php:103前端所有 AJAX 功能(搜索框、点赞、评论相关弹窗等)全部失效。
5.2 分析过程
步骤 1:理解 Typecho 路由机制
Typecho 采用集中式路由表,存储在数据库 options 表 routingTable 字段中(序列化 PHP 数组)。请求流程:
index.php → Widget\Init::alloc() → 加载路由表
→ Router::dispatch() → 遍历路由表逐条正则匹配
→ Widget\Archive->execute() → 加载 functions.php → themeInit()
→ Widget\Archive->render() → 渲染模板关键发现:functions.php 只在路由匹配成功后的 execute() 阶段加载。而 themeInit()(在加密 function.php 中定义)会加载 public/route.php,其中处理 API 请求。
这形成了 鸡与蛋 问题:route.php 中的 API 逻辑只有在路由已匹配时才执行,但 Typecho 默认路由表中没有任何路由能匹配 /joe/api/*。
步骤 2:查看默认路由表
通过 Typecho 安装文件 install.php 中的 install_get_default_routers() 函数,找到默认路由表(共 23 条路由)。关键路由如下:
| 路由名 | URL 模式 | 正则 |
|---|---|---|
post | /archives/[cid:digital]/ | ^/archives/([0-9]+)[/]?$ |
page | /[slug].html | ^/([^/]+)\.html[/]?$ |
category | /category/[slug]/ | ^/category/([^/]+)[/]?$ |
feedback | [permalink:string]/[type:alpha] | ^(.+)/([_0-9a-zA-Z-]+)[/]?$ |
| ... | ... | ... |
结论:无任何默认路由能匹配 /joe/api/motto 这种路径格式。page 路由需要 .html 后缀;feedback 路由虽然正则能匹配但指向评论处理 Widget,会抛 404。
步骤 3:解码 route.php 中的路由注册
查看 public/route.php(未加密的明文文件),发现它在加载时会注册 user、create、goto、sitemap 等路由:
$routes = [
['name' => 'user', 'path' => '/user/[action]', ...],
['name' => 'create', 'path' => '/create', ...],
['name' => 'goto', 'path' => '/goto', ...],
['name' => 'sitemap', 'path' => '/sitemap.xml', ...],
];
foreach ($routes as $route) {
if (!array_key_exists($route['name'], $routingTable)) {
Helper::addRoute($route['name'], $route['path'], 'Widget_Archive', 'render');
}
}但这些路由是在 themeInit 内注册的,对 首次请求 有效(写入数据库后后续请求即可匹配)。而 /joe/api/* 路径没有被注册为 Typecho 路由。
route.php 使用 不同机制 处理 API:
if (str_starts_with($path_info, '/joe/api')) {
// 手动解析 pathInfo 并调用 JoeApi 方法
$route = explode('/', $path_info)[3];
$method = think\helper\Str::camel($route);
require_once JOE_ROOT . 'public/api.php';
JoeApi::$method($archive);
// ...
}但这段代码只有路由匹配后 themeInit() 执行时才能运行。路由不匹配 → 404 → themeInit 永远不会运行。
步骤 4:解码 joe_api_url() 函数
通过 XOR 解码 function.php 偏移 57138 区域,还原了 URL 生成函数:
解码关键字符串片段:
... joe_api_url ... joe_build_url ... joe/api/ ... ltrim ...
... \Typecho\Common ... url ... \Helper ... options ... index ...重建逻辑:
function joe_api_url($action = null, $param = []) {
if ($action !== null) $action = ltrim($action, '/');
return joe_build_url('joe/api/' . $action, $param);
}
// joe_build_url 使用 Common::url() 纯字符串拼接,不查询路由表关键发现:joe_api_url() 使用 \Typecho\Common::url() 做纯字符串拼接(rtrim($prefix,'/') . '/' . ltrim($path,'/')),完全不使用 Router::url() 反解析。这意味着注册路由不会影响出站 URL 生成。
5.3 修复方案(共 4 次迭代)
迭代 1:基础路由注册
修改:在 functions.php 中(require common.php 之后)添加自定义路由注册:
Helper::addRoute('joe_api', '/joe/api/[action]', 'Widget_Archive', 'render');问题:部署后 Router::url('joe_api') 在参数缺失时将 URL 中的 [action] 占位符输出为字面 {action} → 前端出现 /joe/api/{action} 请求 → 死循环 404。
根因分析:Typecho 的 Router::url() 方法中:
// Router.php 第 128-131 行
foreach ($route['params'] as $param) {
if (is_array($value) && isset($value[$param])) {
$pattern[$param] = $value[$param];
} else {
$pattern[$param] = '{' . $param . '}'; // ← 缺值时用花括号包裹参数名
}
}当未传递 action 值时,生成 /joe/api/{action} 字面量。
迭代 2:使用 alphaslash 通配
修改:改用 [action:alphaslash:0] 类型,使参数为可选:
Helper::addRoute('joe_api', '/joe/api/[action:alphaslash:0]', 'Widget_Archive', 'render');Router/Parser.php 将 [x:alphaslash:0] 转换为正则 ([_0-9a-zA-Z-/]*)(量词 * 匹配零次或多次)。
通过 Parser 测试验证:
$parser = new Parser(["joe_api" => ["url" => "/joe/api/[action:alphaslash:0]", ...]]);
$result = $parser->parse();
echo $result["joe_api"]["regx"]; // |^/joe/api/([_0-9a-zA-Z-/]*)[/]?$|
// /joe/api/motto => MATCH ✓
// /joe/api/ => MATCH ✓新问题:友链申请页面发送 POST 到 /joe/api/,body 含 action=friend-apply。但路由参数也叫 action。Typecho 将路由捕获的参数注入 $archive->request,覆盖了 POST body 中的同名字段。
route.php 读取 $archive->request->action 时拿到的是路由参数(空字符串),而不是 POST 的 friend-apply → 返回"未调用接口"。
迭代 3:重命名路由参数避免冲突
修改:将路由参数从 action 重命名为 _joe_path:
Helper::addRoute('joe_api', '/joe/api/[_joe_path:alphaslash:0]', 'Widget_Archive', 'render');_joe_path 以下划线开头,不会与任何 POST 字段冲突。
通过测试验证 POST action=friend-apply 不再被路由参数覆盖。
新问题:部署后访问 API 仍报 404:
Path '/joe/api/{_joe_path}' not found花括号 { } 不在 alphaslash 字符类 [_0-9a-zA-Z-/] 中,导致 Router::url() 生成的回退 URL /joe/api/{_joe_path} 无法匹配自身路由的正则。
同时,Widget\Archive::checkPermalink() 在 render() 阶段调用 Router::url('joe_api') 生成"标准永久链接",得到 /joe/api/{_joe_path},与实际请求 URL 不匹配 → 301 重定向到 /joe/api/{_joe_path} → 该 URL 不匹配路由 → 404。
迭代 4:string:0 类型 + checkPermalink 禁用 + 路径解析加固
三重修复:
修改 1 — functions.php:路由参数类型改为 string:0
$_joeApiUrl = '/joe/api/[_joe_path:string:0]';
// string:0 → 正则 (.*),匹配包括 {} 在内的任意字符Parser 测试结果:
regx: |^/joe/api/(.*)[/]?$|
/joe/api/motto => MATCH ✓
/joe/api/{_joe_path} => MATCH ✓ (即使是回退URL也能匹配)
/joe/api/friend-apply => MATCH ✓同时添加自动检测和替换旧版路由的逻辑:
$routingTable = \Helper::options()->routingTable;
$_joeApiUrl = '/joe/api/[_joe_path:string:0]';
if (!isset($routingTable['joe_api']) || ($routingTable['joe_api']['url'] ?? '') !== $_joeApiUrl) {
if (isset($routingTable['joe_api'])) \Helper::removeRoute('joe_api');
\Helper::addRoute('joe_api', $_joeApiUrl, 'Widget_Archive', 'render');
}修改 2 — public/route.php:禁用 checkPermalink
在 API 处理块开头添加:
$archive->parameter->checkPermalink = false;checkPermalink() 在 render() 阶段运行(晚于 route.php 所在的 execute() 阶段),所以此设置已生效。彻底阻止 Router::url('joe_api') 生成 {_joe_path} 导致的 301 重定向。
修改 3 — public/route.php:路径解析加固
原始路径解析使用 explode('/', $path_info)[3],当路径含双斜杠(如 /joe/api//action)时,索引 3 为空字符串。改用前缀剥离 + ltrim:
// 旧代码(不耐双斜杠):
$path_info_explode = explode('/', $path_info);
$route = empty($path_info_explode[3]) ? $archive->request->action : $path_info_explode[3];
// 新代码(双斜杠安全):
$_api_path = ltrim(substr($path_info, 8), '/'); // 去掉 "/joe/api" 前缀 + 所有前导斜杠
$route = $_api_path ? explode('/', $_api_path)[0] : '';
if (!$route) $route = $archive->request->action ?: '';测试结果:
/joe/api/action → route="action" ✓
/joe/api//action → route="action" ✓ (双斜杠修复)
/joe/api/motto → route="motto" ✓
/joe/api/ → route=(fallback) ✓修改 4 — public/route.php:exit 兜底防止落穿
原始代码中,JoeApi::$method() 返回 null/void/false 时,所有 if 条件均不满足,执行直接落穿回 execute(),继续查询文章并渲染首页 HTML。
关键发现:加密 VM 中的 API 方法分两种输出模式:
- 返回值模式:方法返回 array/string/true,由 route.php 的 if 分支调用
throwJson/throwContent(含exit) - 直接输出模式:方法内部直接
echo json_encode($result)+return(不调用throwJson,不执行exit)
使用直接输出模式的方法(如 action)返回 null/void,不能用 throwJson 错误覆盖其已输出的 JSON。正确做法是直接 exit:
$api = JoeApi::$method($archive);
if (is_array($api) || is_object($api)) { /* throwJson */ }
if (is_string($api)) $archive->response->throwContent($api);
if ($api === true) $archive->response->throwContent('');
// API 方法返回 null/void/false — 它已通过 echo 处理了输出
// 直接 exit 防止执行落穿到文章查询和页面渲染
exit;六、问题四:点赞/收藏/关注返回 HTML 而非 JSON
6.1 症状
点击文章页面的收藏/点赞按钮,发送 POST 到 https://site.com/joe/api/action,但响应是整页 HTML(首页文章列表),而非 JSON。
6.2 分析过程
步骤 1:追踪前端 JS 调用链
assets/js/main.js 第 2853-2882 行,点赞/收藏/关注按钮通过 action_ajax 函数发送 AJAX:
function action_ajax(_this, data, pid, type, text) {
$.ajax({
type: 'POST',
url: _win.ajax_url + 'action', // ← 关键
dataType: 'json',
data: data, // {type: 'collection', key: 'collection', pid: 1}
});
}_win.ajax_url 在 module/js.php 第 39 行定义:
ajax_url: '<?= joe_api_url() ?>/',步骤 2:追踪 URL 生成
joe_api_url() 无参数时返回 /joe/api/(已含尾部 /)。然后 js.php 又追加了 / → ajax_url = /joe/api//(双斜杠)。
main.js 拼接:_win.ajax_url + 'action' = /joe/api//action。
步骤 3:双斜杠的影响
当路径为 /joe/api//action 时:
explode('/', '/joe/api//action') = ['', 'joe', 'api', '', 'action']
↑ 索引[3] 为空原始 route.php 的路径提取:empty($path_info_explode[3]) → true → 退回读取 $archive->request->action。
但 POST 数据只包含 {type, key, pid},没有 action 字段。因此 $route 为空,进入 else 分支调用 throwJson。
然而,即使服务端 Nginx 将 // 归一化为 /(使 pathInfo 变为 /joe/api/action),也会产生问题:$route = 'action',JoeApi::action() 方法被调用。该方法属于「直接输出模式」——内部通过 POST 的 type/key 字段分发子操作(如 collection、like 等),然后 echo json_encode($result) + return(返回 null/void,不调用 throwJson,也不执行 exit)。这导致 route.php 中所有 if 条件均不满足 → 执行落穿 → Widget\Archive::execute() 继续查询文章 → render() 渲染首页模板 → 前端收到 HTML 而非 JSON。
6.3 修复方案
修改 1:module/js.php — 规范化 ajax_url 尾部斜杠
// 原始:
ajax_url: '<?= joe_api_url() ?>/',
// 修改为:
ajax_url: '<?= rtrim(joe_api_url(), "/") ?>/',joe_api_url() 的返回值在不同环境下不一致:本地可能返回 /joe/api/(含尾部 /),服务器上可能返回 /joe/api(无尾部 /)。使用 rtrim + 追加 / 统一规范化为 /joe/api/,确保拼接后为 /joe/api/action(正确),而不会出现 /joe/api//action(双斜杠)或 /joe/apimotto(缺斜杠)。
修改 2、3、4
路径解析加固 + exit 兜底防落穿 + checkPermalink 禁用(见上文「问题三 迭代 4」,同一批修改)。
6.4 迭代 5:收藏按钮返回错误 JSON
现象
修复双斜杠和路径提取后,点击「收藏」按钮,前端收到的 JSON 变为:
{"error":1,"message":"接口 [action] 无返回值","msg":"接口 [action] 无返回值"}说明 JoeApi::action() 确实被调用了,但它返回了 null/void,触发了我们之前添加的 throwJson 错误兜底。
根因分析
通过对加密 VM 中 JoeApi::action() 的逆向分析,发现该方法使用的是「直接输出模式」:
action() 内部流程:
├─ 读取 POST type/key/pid
├─ switch(type) → collection/like/follow/...
├─ 执行数据库操作
├─ echo json_encode(['error' => 0, 'data' => ...]) ← 直接输出 JSON
└─ return; ← 返回 null/void(无 exit)而我们的 throwJson 错误兜底在 action() 返回后被触发,其内部调用 respond() → 新的 header('Content-Type: application/json') + echo + exit。由于 PHP 允许多次 echo,最终输出变成了:
[action() 的 echo 输出]{"error":1,"message":"接口 [action] 无返回值"}前端 jQuery 解析 JSON 时取到的是后者(或解析失败),导致收藏功能无法正常工作。
修复
将 route.php 中的 throwJson 错误兜底改为简单的 exit;:
// 修改前(迭代 4):
$archive->response->throwJson([
'error' => 1,
'message' => '接口 [' . $route . '] 无返回值',
]);
// 修改后(迭代 5):
// API 方法返回 null/void/false — 它可能已通过 echo 处理了输出(直接输出模式)
// 直接 exit 防止执行落穿到文章查询和页面渲染
exit;这样对两种 API 输出模式都安全:
- 返回值模式:在上方 if 分支中已调用
throwJson/throwContent(含exit),不会到达此处 - 直接输出模式:方法已 echo 了 JSON,
exit终止脚本,保留已输出的内容
6.5 迭代 6:/joe/apimotto 路径拼接缺斜杠
现象
部署到服务器后报错:
Path '/joe/apimotto' not found
Typecho\Router\Exception in Router.php:103motto(一言接口)等路由名直接拼在 /joe/api 后面,缺少中间的 /。
根因分析
迭代 4 中将 ajax_url 从 joe_api_url() . '/' 改为 joe_api_url(),假设该函数已返回含尾部 / 的 URL。
但 joe_api_url() 的返回值取决于 Common::url() 的拼接结果,在不同环境下表现不一致:
- 本地环境:返回
/joe/api/(含尾部/) - 生产服务器:返回
/joe/api(无尾部/)
main.js 中的拼接:_win.ajax_url + 'motto' = /joe/api + motto = /joe/apimotto。
修复
用 rtrim 规范化,确保无论 joe_api_url() 返回什么,最终都有且仅有一个尾部 /:
// 修改前(迭代 5):
ajax_url: '<?= joe_api_url() ?>',
// 修改后(迭代 6):
ajax_url: '<?= rtrim(joe_api_url(), "/") ?>/',joe_api_url() 返回 | rtrim 后 | 追加 / | 拼接 motto |
|---|---|---|---|
/joe/api/ | /joe/api | /joe/api/ | /joe/api/motto ✓ |
/joe/api | /joe/api | /joe/api/ | /joe/api/motto ✓ |
七、完整修改文件清单
| # | 文件 | 改动类型 | 说明 |
|---|---|---|---|
| 1 | functions.php | 修改 | auth.ini 自动生成、OB 缓冲过滤、安全列创建、API 路由注册、注释 joe_check_auth() |
| 2 | module/footer.php | 修改 | 第 73 行推广横幅条件改为 if (false) |
| 3 | assets/typecho/config/js/joe.config.js | 修改 | 注释 theme-error API 轮询(约第 186 行) |
| 4 | module/database/install/mysql.sql | 修改 | 删除 4 条 ALTER TABLE 语句 |
| 5 | public/route.php | 修改 | 添加 checkPermalink=false、改进路径解析(双斜杠安全)、exit 兜底防落穿 |
| 6 | module/js.php | 修改 | 第 39 行 rtrim 规范化 ajax_url 尾部 /(防双斜杠和缺斜杠) |
| 7 | public/auth.ini | 自动生成 | 运行时自动创建并按月更新 HMAC token |
functions.php 修改区域一览
行 145-158: auth.ini 自动生成(HMAC token 预写入)
行 159-166: OB 缓冲包裹 require common.php + 正则过滤授权错误
行 168-177: API 路由注册(joe_api, string:0 类型,自动替换旧版路由)
行 179-200: 安全列创建(PHP try-catch 替代 mysql.sql ALTER TABLE)
行 ~210: joe_markdown_hide() 中注释 joe_check_auth()八、备注
Typecho Router 参数类型对照表
| 类型声明 | 正则 | 示例 |
|---|---|---|
[slug] (无类型) | ([^/]+) | 不含斜杠的字符串 |
[x:digital] | ([0-9]+) | 纯数字 |
[x:digital:4] | ([0-9]{4}) | 固定 4 位数字 |
[x:alpha] | ([_0-9a-zA-Z-]+) | 字母数字下划线连字符 |
[x:alphaslash] | ([_0-9a-zA-Z-/]+) | 含斜杠的 alpha |
[x:alphaslash:0] | ([_0-9a-zA-Z-/]*) | 同上但可为空 |
[x:string] | (.+) | 任意字符含斜杠 |
[x:string:0] | (.*) | 任意字符(可为空)← 我们使用 |
Router::url() 参数缺失行为
当调用 Router::url('routeName') 未提供参数值时,对每个路由参数生成 {参数名} 字面量:
// Router.php
$pattern[$param] = '{' . $param . '}';
// 例:路由 /joe/api/[_joe_path:string:0] → 缺值时生成 /joe/api/{_joe_path}这是一个设计特性,用于分页等场景的模板 URL。但对 API 路由会导致 checkPermalink 301 重定向到含花括号的 URL。解决方案:设置 $archive->parameter->checkPermalink = false。
Widget\Archive 执行流程
Router::dispatch()
├─ Router::route() → 正则匹配路由,yield [route, params]
├─ Widget::widget('Widget\Archive', null, params)
│ └─ $widget->execute()
│ ├─ $handles[type] → 执行对应 handle 方法(joe_api 无对应 handle)
│ ├─ require functions.php
│ │ ├─ auth.ini 生成
│ │ ├─ require common.php (加载 VM + function.php + api.php)
│ │ ├─ 路由注册(写入数据库)
│ │ └─ 安全列创建
│ ├─ themeInit($archive)
│ │ └─ require route.php
│ │ ├─ 注册 user/create/goto/sitemap 路由
│ │ └─ if '/joe/api' → JoeApi::$method()
│ │ ├─ 返回值模式: return array → throwJson → exit
│ │ └─ 直接输出模式: echo JSON + return null → exit (兜底)
│ └─ 查询文章(仅当 API 未 exit 时执行)
└─ $widget->render() ← API 路由中不应到达此处
└─ checkPermalink() → 已被 route.php 设为 false,跳过关键偏移量(function.php, XOR key 0xF2)
授权服务器
- 地址:
http://auth.yihang.info/server/typecho-joe/ - 验证接口路径:
auth/domain/{domain}/version/{version} - 请求头:
Yihang-Typecho-Joe: true - HMAC 密钥:
-BD2V6PfbmHnqjajvbb4awxjEJABup7Qn(硬编码) - Token 算法:
hash_hmac('sha256', $domain, base64_encode(date('Y-m') . $key)) - 刷新周期:每月(
date('Y-m')变化时)
开心版下载
写在后面
本开心版可能仍有缺陷和bug,如果测试有Bug请在评论区留言。
另外,支持正版,希望大家能够购买正版支持作者,购买地址
采用 CC BY-NC-SA 4.0 协议授权,转载请注明来源。