涵盖编程语言、框架、架构设计、项目实战、工程化流程、工具技巧等所有技术研发相关内容。
本文章主要针对 LHL's Shop 中的 DC2 Free系列如何解析？通过 CNAME解析到面板中给出的 域名即可，此时只能 IPv6访问，建议开启CDN如Cloudflare的小黄云和腾讯Edge One为什么免费机要收取1元费用？由于免费的属性，可能有用户将大量低频访问内容放到服务器中，且长时间不使用，为了避免免费资源的浪费，因此设置每年1元的费用。页脚标注公益机器需要在页脚标注，若建站30天内未加脚标且流量较大，可能会进行流量限制。本站由 LHL's Host 提供公益主机支持
TypechoBio 个人主页插件支持指定域名，复用Typecho的评论系统，并指定评论文章ID。安装方式方式一：AB-Store 一键安装（推荐）安装 AdminBeautify 插件后，进入后台 AB-Store 应用商店，搜索 TypechoBio 即可一键安装并获取后续更新。方式二：手动安装下载最新 Release 压缩包解压为 TypechoBio 文件夹上传至 Typecho 的 usr/plugins/ 目录登录后台 → 控制台 → 插件管理 → 启用 TypechoBio方式三：Git 克隆cd /path/to/typecho/usr/plugins/ git clone https://github.com/lhl77/TypechoBio.git TypechoBio可安装主题ImsyyDuckfolioZyyoDmegoWexuoIAMITypechoBio 主题适配文档本文面向 TypechoBio 主题开发者，目标是把“一个现成前端页面或主题源码”稳定适配到 TypechoBio。文档内容基于当前插件实现整理，和实际代码行为对齐，适用于以下场景：新增一个本地主题到 usr/plugins/TypechoBio/templates/<theme>/将外部 HTML、静态站点、Vite 构建产物、主题源码迁移到 TypechoBio为主题接入插件统一的配置系统、评论区、独立域名运行时上下文为主题打包成远程主题市场可安装的 zip 包1. 运行模型TypechoBio 不是 Typecho 前台主题，而是“独立域名命中后接管输出”的插件入口。当前请求命中插件后，执行链路大致如下：插件根据 bio_hosts 判断当前 Host 是否需要被 TypechoBio 接管。若命中，则先处理主题专属 API，再处理主题静态资源请求。非资源请求会进入 usr/plugins/TypechoBio/templates/index.php。templates/index.php 读取当前启用主题、主题配置、用户状态、评论数据等上下文。最终 require usr/plugins/TypechoBio/templates/<theme>/home.php 输出完整页面。这意味着：home.php 需要自己输出完整 HTML 页面，不要假设 Typecho 默认前台模板会包裹你。主题运行时可直接访问数据库、登录态、插件配置，但应优先使用插件已整理好的 $typechoBioContext。资源、评论、配置都应按 TypechoBio 的约定来接，而不是直接照搬原主题的 CMS 假设。2. 主题最小目录结构一个主题至少要满足下面的目录结构，才能被 TypechoBio 识别：usr/plugins/TypechoBio/ └── templates/ └── my-theme/ ├── home.php ├── theme.json ├── assets/ │ ├── app.css │ └── app.js ├── images/ └── ...识别规则很严格：templates/ /home.php 必须存在templates/ /theme.json 必须存在两者缺一不可，否则 discoverThemes() 不会把这个目录识别为可用主题建议：主题目录名只使用字母、数字、下划线、连字符目录名与 theme.json 中的主题标识保持一致把当前主题的静态资源全部放在本主题目录下，不要散落到插件根目录3. home.php 的职责home.php 是主题真正的入口文件，建议把它当成“页面壳层模板”。它至少应负责：输出完整 HTML 文档结构读取 $typechoBioContext根据 themeConfig 渲染页面内容正确引用当前主题静态资源按需接入评论区、代理接口、额外共享片段一个最小可运行的例子如下：<?php if (!defined('__TYPECHO_ROOT_DIR__')) { exit; } $ctx = isset($typechoBioContext) && is_array($typechoBioContext) ? $typechoBioContext : array(); $themeConfig = isset($ctx['themeConfig']) && is_array($ctx['themeConfig']) ? $ctx['themeConfig'] : array(); $siteTitle = isset($ctx['siteTitle']) ? (string) $ctx['siteTitle'] : 'TypechoBio'; $themeAssetBase = isset($ctx['themeAssetBase']) ? (string) $ctx['themeAssetBase'] : ''; $pluginRoot = dirname(dirname(__DIR__)); ?> <?php echo htmlspecialchars($siteTitle, ENT_QUOTES, 'UTF-8'); ?> <link rel="stylesheet" href="<?php echo htmlspecialchars($themeAssetBase . 'assets/app.css', ENT_QUOTES, 'UTF-8'); ?>"> <?php echo htmlspecialchars($siteTitle, ENT_QUOTES, 'UTF-8'); ?> <?php $bioCommentOptions = array( 'enabled' => true, 'container_class' => 'my-theme-comments', ); require $pluginRoot . '/views/comment-section.php'; require $pluginRoot . '/views/comment-script.php'; ?> <script src="<?php echo htmlspecialchars($themeAssetBase . 'assets/app.js', ENT_QUOTES, 'UTF-8'); ?>"> 4. 运行时上下文 $typechoBioContexttemplates/index.php 在载入 home.php 前，会把通用运行时信息整理到 $typechoBioContext 中。主题应优先从这里取值，而不是自己重新拼一遍。当前常用字段如下。4.1 基础站点信息host：当前命中的域名siteTitle：站点标题siteUrl：站点地址adminUrl：后台地址siteIcon：插件设置中的站点图标4.2 用户信息userLogged：当前是否已登录userName：登录用户名userGroup：用户组4.3 主题与插件信息themeKey：当前主题目录名themeConfig：当前主题解析后的配置数组themeAssetBase：当前主题静态资源基址pluginAssetBase：插件根资源基址pluginNamepluginVersionpluginLink4.4 评论与接口信息profileApi：登录态查询接口commentApi：评论提交接口commentTarget：评论目标文章信息commentList：已审核评论列表commentResultcommentMessage4.5 其他常用数据posts：最近文章列表，当前实现默认取最近 10 篇已发布文章headPreMeta：后台配置的自定义 head 片段（meta 前置）headCustomCss：后台配置的自定义 CSSheadCustomJs：后台配置的自定义 JSloadStart：请求开始时间4.6 读取示例<?php $ctx = $typechoBioContext; $config = isset($ctx['themeConfig']) ? $ctx['themeConfig'] : array(); $title = isset($ctx['siteTitle']) ? $ctx['siteTitle'] : ''; $logged = !empty($ctx['userLogged']); $commentApi = isset($ctx['commentApi']) ? $ctx['commentApi'] : '';5. theme.json 规范theme.json 是 TypechoBio 识别主题元数据和配置表单的唯一标准文件。一个推荐的完整示例如下：{ "name": "My Theme", "version": "1.0.0", "description": "一个适配到 TypechoBio 的主题", "fields": [ { "key": "hero_title", "label": "首屏标题", "type": "text", "default": "Hello TypechoBio", "description": "显示在首屏的大标题" }, { "key": "hero_desc", "label": "首屏描述", "type": "textarea", "default": "这是主题说明", "description": "支持多行文本" }, { "key": "theme_mode", "label": "配色模式", "type": "select", "default": "system", "description": "主题初始配色模式", "options": { "light": "浅色", "dark": "深色", "system": "跟随系统" } }, { "key": "show_comments", "label": "启用评论区", "type": "checkbox", "default": "1", "description": "关闭后不渲染评论区" } ] }5.1 顶层字段name：主题显示名称version：主题版本号，主题市场比较更新时会用到description：主题简介fields：配置字段数组5.2 fields 数组支持的字段每个字段对象支持：key：字段键名，必填，只允许字母、数字、下划线label：后台显示名称type：字段类型default：默认值description：后台说明文字options：仅 select 类型使用当前插件支持的 type 只有以下五种：texttextareanumberselectcheckbox注意事项：key 不能包含空格、连字符、点号、斜杠不支持对象嵌套配置结构，复杂数据请序列化成字符串或 JSON 字符串checkbox 最终会被规范化为字符串 1 或 0select 的 options 必须是对象，键是实际值，值是后台显示文本5.3 默认值行为TypechoBio 会根据 theme.json 自动做以下同步：新增字段时，自动把默认值补进配置 JSON删除字段时，自动从配置 JSON 和后台表单中移除新安装主题时，当前页会把该主题默认配置直接同步到主题配置 JSON因此，default 字段应认真填写，不要省略，否则该字段首次接入时会是空字符串。6. 配置系统是怎么存的TypechoBio 当前同时维护两层配置表示，但开发时应把 JSON 当成最终结构：统一 JSON：bio_theme_configs_json后台表单字段：bio_theme_cfg__{theme}__{key}说明：配置页展示的是常规表单项表单提交时会自动同步回 bio_theme_configs_json插件内部也会在主题增删字段时自动做规范化同步主题开发者应遵守：在 home.php 中优先读取 $typechoBioContext['themeConfig']不要自己拼接 bio_theme_cfg__... 去数据库取值复杂数组、列表、对象数据建议直接存在 text 或 textarea 字段里，再在主题内自己 json_decode例如：<?php $config = isset($typechoBioContext['themeConfig']) ? $typechoBioContext['themeConfig'] : array(); $socialLinks = array(); if (!empty($config['social_links'])) { $decoded = json_decode((string) $config['social_links'], true); if (is_array($decoded)) { $socialLinks = $decoded; } }7. 静态资源接入规范TypechoBio 会拦截当前Typecho前台主题的资源请求，并直接从当前启用TypechoBio主题目录回源。7.1 推荐写法最稳妥的写法是始终使用 $typechoBioContext['themeAssetBase'] 来拼资源路径：<link rel="stylesheet" href="<?php echo htmlspecialchars($typechoBioContext['themeAssetBase'] . 'assets/app.css', ENT_QUOTES, 'UTF-8'); ?>"> <script src="<?php echo htmlspecialchars($typechoBioContext['themeAssetBase'] . 'assets/app.js', ENT_QUOTES, 'UTF-8'); ?>"> <img src="<?php echo htmlspecialchars($typechoBioContext['themeAssetBase'] . 'images/avatar.png', ENT_QUOTES, 'UTF-8'); ?>" alt="avatar">7.2 资源放置建议建议把资源全部收敛到主题目录内部，例如：templates/my-theme/ ├── home.php ├── theme.json ├── assets/ ├── css/ ├── js/ ├── images/ ├── img/ └── font/7.3 根路径资源的兼容插件当前会对以下请求做主题目录回源：当前主题目录下的普通请求路径/assets/.../favicon.ico/apple-touch-icon.png/robots.txt这意味着某些原主题若大量引用根路径资源，例如 /assets/app.css，在当前启用主题下通常也能被命中。但仍建议在适配时主动改成基于 themeAssetBase 的显式路径，原因是：路径更清晰不依赖隐式兼容逻辑方便后续迁移、调试和主题切换7.4 大小写问题Linux 文件系统区分大小写，资源路径必须和真实文件名完全一致。不要把：Pacifico-Regular.ttf 写成 pacifico-regular.ttfImages/avatar.png 写成 images/avatar.pngCSS 中的相对路径也一样需要严格匹配真实目录大小写。7.5 MIME 类型插件已经对常见资源做了固定 MIME 映射，包括：cssjs / mjsjson / mapsvgicopng / jpg / webp / avifwoff / woff2 / ttf / otf / eot所以主题构建产物可以直接放进主题目录，不必额外再配静态服务器。8. 评论区接入TypechoBio 已提供统一的评论区渲染片段，主题适配时优先复用，不建议每个主题自己重复造一套评论后端。8.1 接入前提后台插件配置中必须填写：评论文章 ID，即 bio_comment_cid未填写时，共享评论组件会输出“尚未配置评论文章 ID”的提示。8.2 最小接入方式在主题 home.php 中加入：<?php $pluginRoot = dirname(dirname(__DIR__)); $config = isset($typechoBioContext['themeConfig']) ? $typechoBioContext['themeConfig'] : array(); $showComments = !isset($config['show_comments']) || in_array((string) $config['show_comments'], array('1', 'true', 'on', 'yes'), true); $bioCommentOptions = array( 'enabled' => $showComments, 'container_tag' => 'section', 'container_class' => 'my-theme-comment-section', 'id' => 'bio-comments', 'title' => '留言板', 'title_tag' => 'h3', 'reload_on_success' => true, 'reload_delay' => 500, ); require $pluginRoot . '/views/comment-section.php'; require $pluginRoot . '/views/comment-script.php';8.3 可用选项$bioCommentOptions 当前支持：enabledcontainer_tagcontainer_classidtitletitle_tagreload_on_successreload_delaytarget_missing_textempty_list_text8.4 共享类名共享评论模板会输出以下类名，主题只需给这些类写样式：.bio-comment-heading.bio-comment-status.bio-comment-form.bio-comment-form-row.bio-comment-field.bio-comment-actions.bio-comment-submit.bio-comment-tip.bio-comment-list.bio-comment-item.bio-comment-meta.bio-comment-author.bio-comment-time.bio-comment-reply-label.bio-comment-replyto.bio-comment-text.bio-comment-children.bio-comment-empty8.5 主题侧建议评论样式建议遵守当前项目已有方向：突出用户名弱化发布时间强调正文内容嵌套回复层级要可读，不要只靠细线缩进9. 远程主题仓库与 zip 包要求如果你的主题要进入 TypechoBio 后台的“获取主题”市场，需要同时准备：可安装的 zip 包远程 themes.json 目录条目9.1 zip 包要求插件安装时会：下载 zip 包解压到临时目录在解压结果中查找合法主题目录合法主题目录必须同时包含 home.php 和 theme.json成功后复制到 usr/plugins/TypechoBio/templates/ /因此，zip 包内建议是这样的结构：my-theme.zip └── my-theme/ ├── home.php ├── theme.json ├── assets/ └── ...不建议把一堆文件直接散在 zip 根目录。9.2 themes.json 条目建议目录结构建议为：{ "schema_version": 1, "themes": [ { "dir": "my-theme", "name": "My Theme", "description": "主题简介", "version": "1.0.0", "cover": "https://example.com/cover.jpg", "zip_url": "https://example.com/my-theme.zip", "github_url": "https://github.com/example/my-theme", "demo_url": "https://demo.example.com" } ] }字段说明：dir：本地安装目录名name：显示名称description：介绍version：远程版本号cover：封面图zip_url：zip 下载链接github_url：源码仓库或介绍页demo_url：在线演示建议保证：dir 与 zip 内主题目录名一致version 与 theme.json 中的 version 一致cover、zip_url 使用稳定可访问地址10. 从现成前端项目迁移时的建议流程如果你是从一个已有 HTML / Vue / React / 静态主题迁移到 TypechoBio，建议按下面顺序做：第一步：先跑起来页面壳层把最终可部署 HTML 结构放进 home.php把 CSS / JS / 字体 / 图片放进主题目录先确保在 TypechoBio 独立域名下能正常打开页面第二步：再接插件上下文把站点标题替换成 $typechoBioContext['siteTitle']把社交、导航、介绍等可变内容改为 themeConfig把原硬编码资源路径改成 themeAssetBase第三步：再接后台可配置字段在 theme.json 里声明字段进入插件配置页确认字段能显示切换到该主题时确认配置项能显示修改字段并保存，确认 home.php 中能读到新值第四步：最后再接评论和 API如需评论区，先配置 show_comments 再引入共享评论片段如需远程接口，优先做同源代理，不要让浏览器直连不稳定第三方11. 常见坑与排查11.1 主题不显示在后台“启用主题”里优先检查：templates/<theme>/home.php 是否存在templates/<theme>/theme.json 是否存在theme.json 是否是合法 JSON11.2 主题显示了，但配置项不出现优先检查：theme.json 顶层是否有 fieldsfields 是否是数组每个字段是否都带合法 keykey 是否只包含字母、数字、下划线11.3 默认值没有生效优先检查：字段是否写了 defaultcheckbox 默认值是否写成 1 或 0复杂 JSON 字符串默认值是否本身就是合法 JSON11.4 资源 404优先检查：路径是否基于 themeAssetBase文件名大小写是否和真实文件一致CSS 内相对路径是否正确资源是否真的放在当前启用主题目录里11.5 评论区不显示优先检查：插件设置里是否填写了 评论文章 ID主题配置里是否把 show_comments 关掉了home.php 是否真的 require 了 views/comment-section.php 和 views/comment-script.php11.6 主题依赖第三方 API，页面打开就报错优先检查：是否仍在浏览器直连跨域 API上游是否有 TLS / 证书 / 频控问题是否应该改为插件本地代理代理失败时是否提供了同形 fallback 数据12. 推荐的适配清单一个新主题适配完成前，至少逐项确认以下内容：主题目录下同时存在 home.php 和 theme.json页面在独立域名下能正常打开静态资源全部命中后台能识别并显示主题后台能切换到该主题主题配置项能显示默认值能正确进入 themeConfig修改配置后前台能读取到新值评论区可按主题配置开关若有第三方 API，则已改为同源代理或有降级方案zip 包安装后，当前页无需手动刷新也能看到配置项与默认值13. 建议的目录模板可直接参考下面这个目录组织：templates/my-theme/ ├── home.php ├── theme.json ├── assets/ │ ├── app.css │ ├── app.js │ └── vendor/ ├── images/ ├── font/ ├── partials/ │ ├── hero.php │ └── footer.php └── README.md其中：home.php 负责装配页面partials/ 放主题内部拆分片段assets/ 放构建产物README.md 可写主题自己特有的说明，但插件总规范应以本文为准
文章撰写时间 2026年5月20日，本来想说洗个澡然后有新系统用，结果一直黑屏，发现卡在黑屏闪烁光标更新前提示 libwacom更新后不支持，预计跟之前安装过的 surface-linux相关，于是翻找 issue查看有无解决方案。找到的对应issue: Fedora 44 error手动下载安装官方源的libwacomsudo dnf download libwacom libwacom-data sudo rpm -ivh --nodeps --force libwacom-*.rpm关键操作由于新安装的 libwacom-data并没有建立依赖关系，导致 gdm依旧会启动失败，有如下日志：journalctl -u gdm -b 0 | grep -iE "error|critical|fail" May 07 09:00:57 surfacego gdm[1305]: Gdm: GdmDisplay: Session never registered, failing May 07 09:00:57 surfacego gdm[1305]: Gdm: GdmDisplay: Session never registered, failing May 07 09:01:00 surfacego gdm[1305]: Gdm: GdmDisplay: Session never registered, failing May 07 09:01:00 surfacego gdm[1305]: Gdm: GdmDisplay: Session never registered, failing May 07 09:01:02 surfacego gdm[1305]: Gdm: GdmDisplay: Session never registered, failing May 07 09:01:02 surfacego gdm[1305]: Gdm: GdmDisplay: Session never registered, failing May 07 09:01:05 surfacego gdm[1305]: Gdm: GdmDisplay: Session never registered, failing May 07 09:01:05 surfacego gdm[1305]: Gdm: GdmDisplay: Session never registered, failing May 07 09:01:08 surfacego gdm[1305]: Gdm: GdmDisplay: Session never registered, failing May 07 09:01:08 surfacego gdm[1305]: Gdm: GdmDisplay: Session never registered, failing May 07 09:01:11 surfacego gdm[1305]: Gdm: GdmDisplay: Session never registered, failing May 07 09:01:11 surfacego gdm[1305]: Gdm: GdmLocalDisplayFactory: maximum number of display failures reached. Giving up.那么解决办法就是重新处理包之间的依赖关系，防止系统误删依赖于该库的桌面环境组件。sudo dnf swap libwacom-surface-data libwacom-data接着重启机器即可～或者直接重启 gdmsudo systemctl restart gdm # 重启也行 sudo reboot
本编辑器 AB Vditor 基于插件AdminBeautify，Vditor基于原版3.11.2版本支持 所见即所得、实时预览、分屏编辑 这三个Vditor的模式，并可快速切换支持 短代码 显示，如图为 Mirages 主题自带的一些工具照顾到兼容性，故使用 单独的复制界面，从而避免某些情况下兼容性的问题支持 全屏编辑，让您更加专注于创作～Github 地址: AB Admin
AB AvatarAdmin Beautify 专用头像管理插件，支持更换Gravatar源(本地代理)，支持自定义头像功能特色功能说明Gravatar 多源切换支持官方 / loli / Cravatar / 自定义域名 / 本地代理代理地址编码本地代理使用 token 编码参数，避免暴露明文 Gravatar 格式本地头像专属路由本地存储头像通过签名 token 路由访问，不直接暴露文件路径自定义头像上传个人设置页与用户编辑页支持上传、裁剪、压缩、恢复PicUp 集成可选 PicUp 存储后端，支持上传策略（Profile）选择管理面板插件设置页内置“自定义头像管理”，支持恢复为 Gravatar安全防护支持代理防盗链、每 IP 限流、代理缓存天数配置截图编辑头像可上传自定义头像上传自行裁剪安装方式一：AB-Store 一键安装（推荐）安装 AdminBeautify 插件后，进入后台 AB-Store 应用商店，搜索 AB Avatar 即可一键安装并获取后续更新。方式二：下载压缩包前往 GitHub 仓库 下载源码或发布包解压后将目录重命名为 AdminBeautifyAvatar上传到 Typecho 的 usr/plugins/ 目录在后台进入 控制台 -> 插件，启用 AdminBeautifyAvatar方式三：Git 克隆cd /your-site/usr/plugins/ git clone https://github.com/lhl77/Typecho-Plugin-AdminBeautifyAvatar.git AdminBeautifyAvatar目录示例：your-site/ └── usr/ └── plugins/ └── AdminBeautifyAvatar/依赖说明必需：AdminBeautify可选：PicUp（作为头像存储后端）当 AdminBeautify 未启用时，插件设置页仅显示依赖提示，不显示其它配置项。主要配置项前台 Gravatar 源：官方 / loli / Cravatar / 自定义 / 本地代理本地代理上游源、缓存天数、限流、防盗链允许用户上传自定义头像自定义头像大小限制（MB）头像存储后端：本地 / PicUpPicUp 上传策略（Profile）前台自动替换 Gravatar 地址路由说明路由作用/ab-avatar/gravatar/{token}头像代理路由（token 编码参数）/ab-avatar/local/{token}本地头像专属访问路由（签名 token）/ab-avatar/upload当前登录用户上传自定义头像/ab-avatar/restore当前登录用户恢复为 Gravatar/ab-avatar/manage管理员恢复指定用户头像 / 代上传使用建议先启用 AdminBeautify，再启用本插件默认推荐“前台 Gravatar 源 = 本地代理”若站点开启 CDN / 缓存，建议保持 proxy_hotlink_protection 与 proxy_rate_limit_per_min 为开启状态若使用 PicUp，请先确认 PicUp 插件已正确配置并可正常上传常见问题1) 访问代理头像返回 invalid token说明当前链接不是插件生成的编码代理地址，或 token 已损坏。请确保头像 URL 来自插件自动生成逻辑。2) 用户头像上传失败请检查：PHP gd 扩展是否可用上传文件格式是否为 JPG/PNG/GIF/WEBP上传文件大小是否超过插件设置阈值3) 设置页更新后样式或脚本不生效插件静态资源带版本参数（基于文件修改时间），如仍未更新，建议清理站点缓存和 OPcache。
这可能是史上颜值最高的 Typecho OAuth 第三方登录插件目录AB OAuthAdminBeautify 专用 OAuth 登录插件，支持QQ / 微信 / GitHub / Google / MSN / 新浪 / 豆瓣 / 百度 / OIDC等。精简显示完整显示OAuth 配置说明Github进入Github Developer Settings，左侧选择OAuth App，然后点击New OAuth App接着复制你的回调地址然后按照图片填入相关信息接着点击 generate client secret生成密钥将ClientID和生成的Client Secret分别填入AB-OAuth插件后台中即可Google进入Google API 控制台，点击进入OAuth 权限请求界面进入客户端，然后创建客户端填入相关客户端信息，选择Web 应用，并粘贴你的回调地址添加完成后弹出窗口将客户端ID和生成的客户端密钥分别填入AB-OAuth插件后台中即可Microsoft (MSN)进入Azure管理页面，点击进入Entry ID管理页面然后根据使用情况进行配置，粘贴您的回调地址，得到客户端ID进入证书与密码，添加新的客户端密码，其值为您的客户端密码检查API权限，是否有User.Read其他的正在编辑中
兰空图床对接超星学习通网盘 (支持多用户绑定)
对接到的图床为 LHL's Images, 点击可跳转至图床开发思路先是在项目Misaka-1314/Chaoxing-MiniProgram的文档中看到了超星图床，觉得还挺好用，于是还是按照之前的一篇文章“图床外显为 Webdav 管理”的思路进行Webdav外显。注意到该项目中有些超星网盘的API(/docs/edge-functions/api/chaoxing/pan.js)以及上传图片的逻辑(/docs/.vitepress/components/ImageUpload.vue)，上传图片的逻辑已经搞定了。而由于国内网盘如果出现违规内容，则可能会封号，故现在只需考虑如何实现每个兰空图床用户绑定自己的超星账号。兰空图床对文件的命名支持魔法变量，其中涉及到用户的有{uid}，那么干脆指定文件名为{uid}-{md5}，这样既可以让Webdav端判断上传用户，又能避免文件名重复。建表CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, token TEXT UNIQUE, lsky_uid INTEGER, cx_username TEXT, cx_password_hash TEXT, cx_pan_token TEXT, cx_puid TEXT, cx_token_expires TEXT, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS images ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id), filename TEXT NOT NULL, original_name TEXT NOT NULL, md5 TEXT NOT NULL, size INTEGER NOT NULL, url TEXT NOT NULL, object_id TEXT, mime_type TEXT, uploaded_at TEXT NOT NULL );上传时逻辑：上传图片->判断UID是否绑定了超星账号->尝试上传到指定超星账号->上传成功以上步骤只要有一个为“否”，则会返回400用户绑定面板：https://chaoxing.img.lhl.one现在还需要解决兰空图床前台显示问题，如果直接在储存策略输出a标签则会转义输出导致无法输出超链接来提醒用户绑定超星账号，添加js脚本来解决这个问题 function fixEscapedHTML() { document.querySelectorAll('span').forEach(el => { if (el.innerHTML.includes('<a') && el.innerHTML.includes('')) { el.innerHTML = el.innerHTML .replace(/<a\s+href="([^"]+)"\s+targ?at="_blank">/gi, '') .replace(/<\/a>/gi, ''); } }); }还有一个问题，由于超星网盘开了防盗链，故需要在/user/photos 页面路由注册"no-referer" (这里参考之前的文章Lsky二次开发记录)RouteDOM.register({ route: '/user/photos', onEnter() { const existingMeta = document.querySelector('meta[name="referrer"]'); if (existingMeta) return true; const meta = document.createElement('meta'); meta.name = 'referrer'; meta.content = 'no-referrer'; meta.dataset.route = 'photos'; document.head.insertBefore(meta, document.head.firstChild); return true; }, onLeave() { const meta = document.querySelector('meta[name="referrer"][data-route="photos"]'); if (meta) meta.remove(); } });防盗链处理如果在全站使用，则直接添加no-referrer即可 如果是单个页面使用，或者不方便修改源代码而需要外挂JS脚本，则可使用JS添加no-referrer//示例代码，请按照实际需求写 (function () { if (!document.head) return false; const existingMeta = document.querySelector('meta[name="referrer"]'); if (existingMeta) return true; const meta = document.createElement('meta'); meta.name = 'referrer'; meta.content = 'no-referrer'; meta.dataset.route = 'photos'; document.head.prepend(meta); return true; })();图床测试（本页面已经添加了no-referrer,否则图片无法显示）
本产品使用NPS (yisier/nps) 0.26.32购买服务LHL's Shop客户端下载 LHL's Cloud 客户端安装对于Linux、Darwin快捷一键安装直接sudo ./npc后根据提示在对应的地方输入快捷启动命令亦可使用进程守护（pm2等）LHL's Shop 中给出的启动命令类似 ./npc -server=xxxxx.lhl.one:8024 -vkey=xxxxxxxx -type=tcp注意运行目录需要为 二进制文件npc所在目录，否则需要修改为绝对路径手动命令注册注册（启动命令添加install字样）：sudo ./npc install -server=xxxxx.lhl.one:8024 -vkey=xxxxxxxx -type=tcp启动：sudo npc start停止：sudo npc stop如果需要更换命令内容需要先卸载./npc uninstall，再重新注册对于Windows快捷一键安装以管理员身份打开npc.exe后，输入快捷启动命令启动下载GUI版本进行管理教程略手动注册服务对于windows，使用管理员身份运行cmd注册（启动命令添加install字样）：npc.exe install -server=xxxxx.lhl.one:8024 -vkey=xxxxxxxx -type=tcp启动：npc.exe start停止：npc.exe stop如果需要更换命令内容需要先卸载npc.exe uninstall，再重新注册隧道添加举个例子，现在添加UDP隧道作为SSH内网穿透，如图设置：看到分配的端口是16511那么访问地址则是xxxxx.lhl.one:16511 (xxxxx.lhl.one指服务连接信息中的服务器地址)Windows远程桌面3389VNC远程桌面5900网站创建添加域名解析，内网目标可以是https端口，如果不填写HTTPS证书和私钥，则仍使用原服务器的证书，填写则会代理证书。建议在您的服务器中打开强制https，否则代理端口最好是http端口(80)网站如果开启https后，内网目标建议填写443端口，并打开https重定向
主题版本：v2.10 (Joe再续前缘) 作者：易航 (Yihang) 逆向：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.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 调用指令附近结构边界：通过搜索 function、return、if 等关键字定位函数起止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 症状前台和后台页面均直接输出以下文字（非 JS 弹框，是 PHP echo）：欢迎您使用Joe再续前缘，请 赞助 后使用，联系方式：2136118039@qq.com3.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 前后区域继续解码：偏移解码得到的函数签名/关键字功能60172joe_check_auth调用 joe_is_auth()，失败时输出错误 HTML60818joe_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' 之前插入：(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 '本站同款主题模板'; // 改为： if (false) echo '本站同款主题模板';修改 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 而非 JSON6.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 ✓七、完整修改文件清单#文件改动类型说明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 tokenfunctions.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）偏移解码内容57138joe_api_url() 函数 — joe/api/, ltrim, joe_build_url60172joe_check_auth() — joe_is_auth, joe_exception60680auth.yihang.info URL 字符串607702136118039@qq.com 字符串60818joe_is_sdfkdifhb() — 混淆名，调用 joe_is_auth()61115joe_domain() — HTTP_HOST 获取域名62397joe_request_server() — stream_context_create, file_get_contents66458joe_is_auth() — hash_hmac, sha256, base64_encode, auth.ini106918joe_build_url() — \Typecho\Common::url, joe_relative_url152428joe_install_sql() — database/install/*.sql, explode160509joe_install() — theme:JoeInstall, Db::query授权服务器地址：http://auth.yihang.info/server/typecho-joe/验证接口路径：auth/domain/{domain}/version/{version}请求头：Yihang-Typecho-Joe: trueHMAC 密钥：-BD2V6PfbmHnqjajvbb4awxjEJABup7Qn（硬编码）Token 算法：hash_hmac('sha256', $domain, base64_encode(date('Y-m') . $key))刷新周期：每月（date('Y-m') 变化时）开心版本下载此处内容需要评论回复后方可阅读
本文介绍的方法涉及关闭赞助版的校验流程，该做法可能绕过官方授权验证机制，仅供学习、测试或特殊场景下使用，请确保您拥有相关软件或服务的合法使用权。非法使用或未经授权的操作可能导致服务异常或法律风险，使用前请充分评估相关责任。本文主要目的是分享技术实现思路和代码示范，欢迎交流和探讨，严禁用于侵犯版权或违规用途。查看更多 Typecho 主题/插件逆向：跳转授权验证机制分析前言注意到lisence.php和loader.php两个文件被混淆编码了，且通过后台激活相关的POST请求定位到激活相关函数被加密（无法明文搜索到），但是返回内容判断已明文写出。整体流程functions.php └─ require core/loader.php ← 原始为加密混淆 VM 字节码 └─ 定义授权相关函数 common/init.php ├─ Mirai_licenseEnsureCoreLoaded() ← 若函数不存在则 HTTP 503 退出 ├─ Mirai_themeInit($archive) ← 初始化主题钩子 └─ Mirai_coreAuthEnforce() ← 许可强制检查点 core/license.php ← 二进制加密文件，由 loader.php 加载 core/auth-public.php ← 明文辅助函数（缓存/HTTP/弹窗渲染）验证函数清单（均由 loader.php 定义）此处内容需要评论回复后方可阅读Mirai_authGetStatus() 返回结构此处内容需要评论回复后方可阅读common/init.php 致命检查点// 原始代码：函数不存在时直接输出 503 并 exit if (function_exists('Mirai_licenseEnsureCoreLoaded')) { Mirai_licenseEnsureCoreLoaded(); } else { header('HTTP/1.1 503 Service Unavailable'); echo '...警告：非法篡改...'; exit; }本地运行改动修改的文件文件操作core/loader.php替换为 stub 实现，所有授权函数返回"已授权"core/license.php二进制文件替换为空 stub（原文件备份为 .bak）common/init.php移除函数不存在时的 503 致命退出分支core/loader.php stub 实现逻辑此处内容需要评论回复后方可阅读common/init.php 修改内容此处内容需要评论回复后方可阅读MiraiCore 插件说明MiraiCore 插件本身不含独立授权验证，其功能（订单、友链、Sitemap、爬虫记录等）完全依赖主题授权系统。替换 loader.php 后插件可正常激活使用。开心版下载此处内容需要评论回复后方可阅读写在最后原创不易，希望各位能够支持正版！正版购买入口：点我前往