一、问题背景展开目录
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 症状展开目录
前台和后台页面均直接输出以下文字(非 JS 弹框,是 PHP echo):
欢迎您使用 Joe 再续前缘,请 赞助 后使用,联系方式:2136118039@qq.com
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...赞助...2136118039@qq.com...
确认这就是弹窗消息的字符串数据。
步骤 3:追踪调用链展开目录
从偏移 60680 向前搜索,在偏移 60172 找到包含该字符串引用的函数边界。解码此区域识别出 joe_check_auth() 函数签名。
继续使用 grep -c "joe_check_auth" 对解码后的全文进行统计 → 发现 80+ 处调用,几乎每个主题函数入口都调用。直接注释不可行。
步骤 4:逆向核心验证函数展开目录
从 joe_check_auth() 追踪到 joe_is_auth()。在偏移 66458 处解码出完整的验证逻辑:
解码得到的关键字符串片段(偏移 66458-67500):
... HTTP_HOST ... auth.ini ... file_get_contents ...
... theme:joeAuthCode ... options ...
... hash_hmac ... sha256 ... base64_encode ...
... -BD2V6PfbmHnqjajvbb4awxjEJABup7Qn ...
... Y-m ... hash_equals ...
从这些片段重建出 joe_is_auth() 伪代码:
joe_is_auth($retry=true):
1. 若 HTTP_HOST 为空 → 返回 false
2. 读取 auth_code:
a. 先读 JOE_ROOT/public/auth.ini
b. 若空,查数据库 options 表 name='theme:joeAuthCode'
c. 若仍空,请求远程服务器获取
3. 计算本地哈希:
key = base64_encode(date('Y-m') + '-BD2V6PfbmHnqjajvbb4awxjEJABup7Qn')
hash = hash_hmac('sha256', domain, key)
4. hash_equals(hash, auth_code) → 通过/失败
5. 失败且 retry=true → 删本地记录,递归调用(retry=false)
关键发现:HMAC 密钥是硬编码字符串 -BD2V6PfbmHnqjajvbb4awxjEJABup7Qn,且按月轮换(date('Y-m')),可在本地计算。
步骤 5:验证算法正确性展开目录
编写测试脚本生成 token 并比对:
$domain = 'localhost';
$key = base64_encode(date('Y-m') . '-BD2V6PfbmHnqjajvbb4awxjEJABup7Qn');
$hash = hash_hmac('sha256', $domain, $key);
echo $hash;
// 输出: c7a3e5f... (64位十六进制 HMAC-SHA256)
将此值写入 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' 之前插入:
(function() {
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$domain = $host;
if (extension_loaded('intl') && function_exists('idn_to_ascii')) {
$ascii = idn_to_ascii($host);
if ($ascii !== false) $domain = $ascii;
}
$key = base64_encode(date('Y-m') . '-BD2V6PfbmHnqjajvbb4awxjEJABup7Qn');
$hash = hash_hmac('sha256', $domain, $key);
$authFile = JOE_ROOT . 'public/auth.ini';
if (!file_exists($authFile) || trim(file_get_contents($authFile)) !== $hash) {
file_put_contents($authFile, $hash);
}
})();
原理: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: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 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:103
motto(一言接口)等路由名直接拼在 /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)展开目录
偏移 解码内容
57138 joe_api_url() 函数 — joe/api/, ltrim, joe_build_url
60172 joe_check_auth() — joe_is_auth, joe_exception
60680 auth.yihang.info URL 字符串
60770 2136118039@qq.com 字符串
60818 joe_is_sdfkdifhb() — 混淆名,调用 joe_is_auth()
61115 joe_domain() — HTTP_HOST 获取域名
62397 joe_request_server() — stream_context_create, file_get_contents
66458 joe_is_auth() — hash_hmac, sha256, base64_encode, auth.ini
106918 joe_build_url() — \Typecho\Common::url, joe_relative_url
152428 joe_install_sql() — database/install/*.sql, explode
160509 joe_install() — theme:JoeInstall, Db::query
授权服务器展开目录
地址: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') 变化时)
软件介绍
MobiMusic是一款聚合三大音乐平台资源的听歌工具,用户无需登录即可畅享海量音乐,软件涵盖流行、摇滚、经典怀旧、以及欧美日韩等多种风格歌曲,支持歌单导入、无损音乐下载与自由创建歌单,轻松满足多样化听歌需求,个性化推荐功能能根据用户喜好实时推送优质新歌热曲,让你随时发现心仪音乐,随时随地畅听无忧,操作便捷且资源丰富。
更新日志 1.3.0
1. 修改浅色背景下公告弹窗文本颜色为黑色防止看不清
2. 首页网易云每日推荐增加雷达歌单
3. 修复用户手册中“梅”播放器的地址错误
4. 修复从Apple风格歌词页退出软件再进入有概率满屏密密麻麻歌词的情况(未做足测试可能仍有问题)
5. 修改首页一言为古诗词,因为之前没发现原本一言接口许多荤段子与毒鸡汤
6. 本地界面策划栏可修改歌曲列表文字大小

留言评论
暂无留言