MENU

文章目录

Typecho Joe再续前缘v2 主题开心版分析

• 2026 年 04 月 10 日 • 阅读: 28 • 技术,破解,Typecho 主题/插件

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

主题版本:v2.1 (Joe再续前缘)
主题原作者:易航 (Yihang)
v2版本起收费¥88.88,故作逆向分析
逆向分析:LHL

图片.png

查看更多Typecho逆向主题/插件:跳转

一、问题背景

Joe再续前缘主题需要解决以下问题以实现纯本地运行:

  1. 授权验证弹窗:主题内置域名授权验证,未授权时 PHP 直接 echo 提示文字
  2. 数据库安装报错:主题重复激活时 ALTER TABLE ... ADDDuplicate column name 致命错误
  3. API 路由 404:主题 /joe/api/* 接口全部返回 Path not found 错误
  4. 点赞/收藏/关注按钮返回 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.php0xB8 (184)约 85%
public/function.php0xF2 (242)约 87%
public/api.php0xBF (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 调用指令附近
  • 结构边界:通过搜索 functionreturnif 等关键字定位函数起止

2.4 加密体系概览

三个加密文件均采用 XOR 字节码 + goto 控制流 + 栈式虚拟机 架构:

文件大小XOR 密钥职责
public/common.php59 KB0xB8 (184)初始化常量、加载子文件
public/function.php401 KB0xF2 (242)所有主题函数定义(含授权)
public/api.php341 KB0xBF (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 前后区域继续解码:

偏移解码得到的函数签名/关键字功能
60172joe_check_auth调用 joe_is_auth(),失败时输出错误 HTML
60818joe_is_sdfkdifhb直接调用 joe_is_auth() 返回结果(混淆函数名)
61115joe_domain获取当前域名
62397joe_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.phpjoe_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:111

4.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 EXISTSINSERT

修改 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 采用集中式路由表,存储在数据库 optionsroutingTable 字段中(序列化 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未加密的明文文件),发现它在加载时会注册 usercreategotositemap 等路由:

$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 禁用 + 路径解析加固

三重修复:

修改 1functions.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');
}

修改 2public/route.php:禁用 checkPermalink

在 API 处理块开头添加:

$archive->parameter->checkPermalink = false;

checkPermalink()render() 阶段运行(晚于 route.php 所在的 execute() 阶段),所以此设置已生效。彻底阻止 Router::url('joe_api') 生成 {_joe_path} 导致的 301 重定向。

修改 3public/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)   ✓

修改 4public/route.php:exit 兜底防止落穿

原始代码中,JoeApi::$method() 返回 null/void/false 时,所有 if 条件均不满足,执行直接落穿回 execute(),继续查询文章并渲染首页 HTML。

关键发现:加密 VM 中的 API 方法分两种输出模式:

  1. 返回值模式:方法返回 array/string/true,由 route.php 的 if 分支调用 throwJson/throwContent(含 exit
  2. 直接输出模式:方法内部直接 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_urlmodule/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:103

motto(一言接口)等路由名直接拼在 /joe/api 后面,缺少中间的 /

根因分析

迭代 4 中将 ajax_urljoe_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

七、完整修改文件清单

#文件改动类型说明
1functions.php修改auth.ini 自动生成、OB 缓冲过滤、安全列创建、API 路由注册、注释 joe_check_auth()
2module/footer.php修改第 73 行推广横幅条件改为 if (false)
3assets/typecho/config/js/joe.config.js修改注释 theme-error API 轮询(约第 186 行)
4module/database/install/mysql.sql修改删除 4 条 ALTER TABLE 语句
5public/route.php修改添加 checkPermalink=false、改进路径解析(双斜杠安全)、exit 兜底防落穿
6module/js.php修改第 39 行 rtrim 规范化 ajax_url 尾部 /(防双斜杠和缺斜杠)
7public/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请在评论区留言。
另外,支持正版,希望大家能够购买正版支持作者,购买地址

返回文章列表 打赏
本页链接的二维码
打赏二维码