主题
ZMBlog · 插件开发文档
适用版本:ZMBlog v2.3.1 阅读对象:希望为 ZMBlog 扩展功能的开发者
目录
- 插件系统概览
- 快速上手
- 插件目录结构
- plugin.json 完整说明
- 生命周期
- 注入的上下文 ctx
- 过滤器(filter)
- 动作(action)
- 钩子清单
- 注册模板函数
- 后台配置面板
- 访问数据
- 插件间通信
- 完整示例
- 调试与排错
- 发布与分发
- 安全清单
1. 插件系统概览
ZMBlog 的插件系统围绕四个能力设计:
| 能力 | 作用 |
|---|---|
| 过滤器 filter | 拦截并修改一个值,返回新值。用于改写内容、模板、整页 HTML |
| 动作 action | 触发一个副作用,不返回值。用于记录日志、清理缓存、发送通知 |
| 模板函数 | 向主题注入可在 {{ }} 中调用的方法 |
| 配置面板 | 用声明式配置字段自动生成后台表单 |
核心特性
- 启停即时生效,无需重启服务
- 多个插件可挂在同一钩子上,按
priority排序依次执行 - 插件配置独立存储(
plugin:<插件名>:config),与站点配置隔离 - 插件代码由 Node.js
require加载,因此只能由服务器上放置,后台无法新建(见第 17 节)
2. 快速上手
2.1 建目录
bash
mkdir -p plugins/hello2.2 plugin.json
json
{
"name": "你好插件",
"version": "1.0.0",
"author": "你的名字",
"description": "在文章末尾显示一行问候语",
"entry": "index.js",
"config": [
{
"name": "greeting",
"label": "问候语",
"type": "text",
"default": "感谢阅读"
}
]
}2.3 index.js
js
module.exports = class Hello {
constructor(ctx) {
this.ctx = ctx;
}
onLoad() {
const c = this.ctx.config();
this.ctx.addFilter('post.content', (html, post) => {
if (!post || post.type !== 'post') return html;
return html + `<p style="color:#6b7280">${c.greeting || '感谢阅读'}</p>`;
}, 10);
}
};2.4 启用
把目录放到 plugins/hello/,后台「插件」页会出现「你好插件」,点击开关启用即可。前台文章页底部立即出现问候语。
3. 插件目录结构
plugins/
└─ hello/
├─ plugin.json 必需:元信息与配置声明
├─ index.js 必需:入口(由 plugin.json 的 entry 指定)
├─ assets/ 可选:静态资源
└─ views/ 可选:额外模板文件目录名 hello 即插件标识(启用、配置、接口调用都用它)。
assets/下的静态资源会以/plugins/<插件名>/assets/**对外提供(走内核统一放行),适合放插件的 CSS/JS/图片;其它路径(含index.js)一律不可直接访问。
4. plugin.json 完整说明
json
{
"name": "阅读增强",
"version": "2.0.0",
"author": "ZMBlog",
"description": "演示插件系统的完整能力",
"entry": "index.js",
"config": [
{
"name": "showReadingTime",
"label": "显示预计阅读时长",
"type": "select",
"default": "1",
"options": [
{ "value": "1", "label": "显示" },
{ "value": "0", "label": "隐藏" }
]
},
{
"name": "wordsPerMinute",
"label": "阅读速度(字/分钟)",
"type": "text",
"default": "400",
"description": "用于估算阅读时长"
},
{
"name": "footerNote",
"label": "文章末尾附言",
"type": "text",
"default": "—— 以上内容由「阅读增强」插件生成"
},
{
"name": "enableToc",
"label": "为二级标题生成目录",
"type": "checkbox",
"default": true
}
]
}4.1 顶层字段
| 字段 | 必需 | 说明 |
|---|---|---|
name | 是 | 插件显示名 |
entry | 是 | 入口文件名,默认 index.js |
version | 否 | 版本号 |
author | 否 | 作者 |
description | 否 | 描述,显示在插件列表 |
config | 否 | 配置字段声明 |
4.2 配置字段
| 字段 | 说明 |
|---|---|
name | 字段标识,模板与代码中用它取值 |
label | 后台显示的标签 |
type | text / textarea / password / checkbox / select / radio |
default | 默认值 |
options | select / radio 必填,形如 [{ "value": "1", "label": "显示" }] |
description | 字段下方的补充说明 |
select与radio的值都是字符串。判断请用{{#if cfg.showX}},不要写== 1。
password与text行为一致,只是后台不明文回显(用密码框显示、autocomplete="new-password"),适合 SMTP 密码、各类 API 密钥。
5. 生命周期
插件类按以下顺序被调用:
| 阶段 | 触发时机 | 应做的事 |
|---|---|---|
构造 constructor(ctx) | 加载时 | 保存 ctx 与 this.name,不要做重活 |
启用 onLoad() | 每次启用 / 服务启动且插件处于启用态 | 注册钩子、模板函数、面板 |
停用 onDisable() | 停用时 | 释放资源、清理定时器、关闭连接 |
配置变更 onConfigChange(newConfig, oldConfig) | 保存配置后 | 响应配置调整(如重建缓存) |
后台面板 panel() | 渲染后台插件页 | 返回 HTML 字符串(可选) |
5.1 要点
onLoad会被多次调用(每次启用都跑一遍),因此注册钩子要幂等- 内核在停用时会自动摘除该插件注册的所有钩子与模板函数,不会残留
onConfigChange(newConfig, oldConfig)在「后台保存插件配置、并且配置确实变了」之后触发(没变不触发)。此时插件已经重载过(onLoad先跑),适合做「发一封测试邮件」「重建缓存」这类要立刻反馈给用户的事;发送记录可存在ctx.options里,面板上展示- 但你在
onLoad中创建的外部资源(定时器、文件句柄、网络连接)必须在onDisable中释放
js
module.exports = class Ticker {
constructor(ctx) { this.ctx = ctx; this.timer = null; }
onLoad() {
this.timer = setInterval(() => this.refresh(), 60000);
this.refresh();
}
onDisable() {
if (this.timer) clearInterval(this.timer);
this.timer = null;
}
refresh() { /* ... */ }
};5.2 优先级
同一钩子上多个插件按 priority 升序执行,数字小的先跑。默认 10。
priority 5 → priority 10 → priority 50常规划法:
- 需要「最先拿到原始值」用小数字(清洗、校验)
- 需要「基于别人加工后的结果」用大数字(增强、加注)
6. 注入的上下文 ctx
构造函数的 ctx 由内核注入:
js
{
name, // 插件标识(目录名)
meta, // plugin.json 解析结果
kernel, // 内核实例
config(), // 读取本插件配置(已合并默认值)
saveConfig(values), // 保存本插件配置
options: {
get(key, dflt), // 读写站点 options 表
set(key, value),
all(), // 全部站点配置
},
addFilter(hook, fn, priority), // 注册过滤器
addAction(hook, fn, priority), // 注册动作
addWidget(label, content), // 注册侧边栏小组件
registerFunction(name, fn), // 注册模板函数
db, // SQLite DatabaseSync 实例
plugin(name), // 取另一个已启用插件的实例
widgets(), // Widget 对象
rewrite(), // 伪静态引擎
log(...args), // 带插件名前缀的日志
}6.1 常用操作示例
js
const K = this.ctx.kernel;
// 站点配置
const pageSize = this.ctx.options.get('pageSize', '10');
this.ctx.options.set('myPluginLastRun', String(Date.now()));
// 本插件配置(独立存储)
const cfg = this.ctx.config();
this.ctx.saveConfig({ greeting: '新问候语' });
// 数据库
const row = K.db.prepare(`SELECT COUNT(*) AS c FROM ${K.t('posts')}`).get();
const t = (name) => K.t(name); // 带表前缀kernel.t('posts') 返回带表前缀的表名(默认 zb_posts)。始终用它而不是硬编码表名,否则改前缀后会全线崩溃。
7. 过滤器(filter)
过滤器接收一个值,必须返回新值。返回值会传给下一个过滤器,最终产出给页面。
js
this.ctx.addFilter('post.content', (html, post) => {
if (!post || post.type !== 'post') return html; // 必须原样返回
return html.replace(/&/g, '&'); // 继续加工
}, 10);铁律:任何情况下都要有返回值。 忘记 return 会让内容变成 undefined,页面直接空白。
7.1 多个插件串联
原始 HTML → 插件A(priority 5) → 插件B(priority 10) → 页面输出因此 priority 大的插件看到的是小 priority 插件处理后的结果。
7.2 条件短路
js
this.ctx.addFilter('post.content', (html, post) => {
if (post.status !== 'publish') return html; // 非发布态不改
if (!/\.md$/.test(post.slug || '')) return html;
return transform(html);
}, 10);8. 动作(action)
动作不接收返回值,只做副作用。
js
this.ctx.addAction('post.save', (info) => {
const { cid, isNew } = info;
this.ctx.log(`文章 #${cid} 已${isNew ? '创建' : '更新'}`);
this.clearCache(cid);
});9. 钩子清单
9.1 过滤器 filter(可修改值)
| 钩子 | 签名 | 用途 |
|---|---|---|
post.content | (html, post) => html | 改写文章渲染后的 HTML。最常用 |
comment.content | (text, comment) => text | 改写评论内容 |
template.content | (src, templateName) => src | 改写模板源文件(渲染前) |
page.render | (html, payload) => html | 改写最终整页 HTML。适合注入统计脚本、CSS |
request.route | (value, ctx) => handled | 在路由分发之前认领请求;返回真值即视为已处理,插件可借此新增前台接口 / 页面(见 welcome、email-subscribe 插件) |
9.2 动作 action(无返回值)
内容
| 钩子 | 载荷 |
|---|---|
post.save | { cid, isNew } |
post.delete | { cid } |
comment.create | { cid, coid, status, ip, author, mail, parent, text, title, permalink }(前台提交评论时触发) |
comment.public | 同上(旧名,保留兼容) |
comment.save | { cid, coid, isNew, status, prevStatus }(后台新建/编辑/审核评论后触发;prevStatus 为改动前的状态,可用它识别「审核通过」) |
comment.delete | { coid, cid }(批量删除时会逐条触发) |
渲染
| 钩子 | 载荷 | 用途 |
|---|---|---|
render | payload(完整页面变量对象) | 可向页面注入数据 |
系统
| 钩子 | 载荷 |
|---|---|
options.save | { key, value } |
theme.activate | { name } |
theme.file.write | { file } |
rewrite.save | { rules } |
user.login | { uid, name, ip } |
logout | { uid } |
plugin.enable / plugin.disable | { name } |
9.3 通过 render 注入模板变量
render 是 action,但它的载荷 payload 就是模板作用域,修改它即可注入变量:
js
this.ctx.addAction('render', (payload) => {
const K = this.ctx.kernel;
payload.myWidget = {
posts: K.db.prepare(`SELECT COUNT(*) AS c FROM ${K.t('posts')}`).get().c,
year: new Date().getFullYear(),
};
// 也可挂到 pluginData 下
payload.pluginData = payload.pluginData || {};
payload.pluginData.stats = { views: 12345 };
});模板中:
html
<div>共{{ myWidget.posts }} 篇文章 · {{ myWidget.year }}</div>
<div>{{ pluginData.stats.views }} 次浏览</div>10. 注册模板函数
js
this.ctx.registerFunction('readingTip', (post) => {
if (!post) return '';
return `约 ${Math.max(1, Math.round(String(post.text || '').length / 400))} 分钟`;
});
this.ctx.registerFunction('pluginBadge', () => '<span>已启用</span>');模板中:
html
<span>{{ $this.readingTip(post) }}</span>
{{{ $this.pluginBadge() }}}注意
- 函数返回值默认被 HTML 转义。返回 HTML 需用
{{{ }}}输出,或在函数内自行处理 - 函数内的
this不是 Widget。想调用 Widget 方法需用this.ctx.widgets() - 停用插件时注册的所有函数会被自动移除
10.1 在函数里用 Widget 能力
js
this.ctx.registerFunction('catCount', () => {
const W = this.ctx.widgets();
return W.allCategories(this.ctx.kernel).length;
});11. 后台配置面板
panel() 返回 HTML 字符串,会渲染在插件管理页:
js
panel() {
const s = this.ctx.kernel.db.prepare(
`SELECT COUNT(*) AS c, COALESCE(SUM(LENGTH(text)),0) AS w FROM ${this.ctx.kernel.t('posts')}`
).get();
return `<div class="stats">
<div class="stat"><div class="n">${s.c}</div><div class="l">文章数</div></div>
<div class="stat"><div class="n">${s.w}</div><div class="l">总字数</div></div>
</div>`;
}返回的内容会原样嵌入后台页面,请自行转义所有用户可控数据(escapeHtml)。
11.1 声明式配置表单
只要在 plugin.json 里声明了 config,后台就会自动渲染表单,无需写任何前端代码。用户在后台保存后,配置写入 plugin:<name>:config,可通过 this.ctx.config() 读取。
12. 访问数据
12.1 基础查询
js
const K = this.ctx.kernel;
const posts = K.db.prepare(
`SELECT cid, title, slug, status, created FROM ${K.t('posts')}
WHERE type = 'post' AND status = 'publish'
ORDER BY created DESC LIMIT ?`
).all(10);
for (const p of posts) {
console.log(p.cid, p.title, new Date(p.created).toISOString());
}12.2 表清单
K.t(name) 可用的表名:
name | 内容 |
|---|---|
posts | 文章与页面 |
categories | 分类 |
cat_relationships | 分类-文章关联 |
tags | 标签 |
tag_relationships | 标签-文章关联 |
comments | 评论 |
users | 用户 |
sessions | 会话 |
options | 配置项 |
fields | 文章自定义字段 |
logs | 操作日志 |
login_attempts | 登录失败计数 |
12.3 用 Widget 拿已装饰的数据
直接查数据库拿到的是原始行(created 是毫秒时间戳、无 content、无 permalink)。若需要渲染用的完整对象,优先用 Widget:
js
const W = this.ctx.widgets();
const K = this.ctx.kernel;
const posts = W.queryPosts(K, { status: 'publish', type: 'post' }, 1, 20);
for (const p of posts) {
console.log(p.title, p.permalink, p.createdDate, p.categories.map((c) => c.name));
}常用 Widget 方法:
| 方法 | 说明 |
|---|---|
queryPosts(K, filters, page, size) | 条件查询文章 |
countPosts(K, filters) | 计数 |
findPostBySlug(K, slug) | 按别名取文章 |
allCategories(K) / allTags(K) | 全部分类 / 标签 |
postCategories(K, cid) / postTags(K, cid) | 某文章的全部分类 / 标签 |
postFields(K, cid) | 自定义字段 |
listComments(K, cid) | 评论树 |
archiveTree(K) | 归档树 |
建议:展示类数据用 Widget,写操作用 SQL。
12.4 写操作
js
K.db.prepare(
`UPDATE ${K.t('posts')} SET views = COALESCE(views,0) + 1 WHERE cid = ?`
).run(cid);视图计数建议:不要每次 PV 都同步写库。用内存计数 + 定时落库,或用插件 options 表存累计值。
js
onLoad() {
this.pending = new Map();
this.timer = setInterval(() => this.flush(), 60000);
}
hit(cid) {
this.pending.set(cid, (this.pending.get(cid) || 0) + 1);
}
flush() {
for (const [cid, n] of this.pending) {
this.ctx.kernel.db.prepare(
`UPDATE ${this.ctx.kernel.t('posts')} SET views = COALESCE(views,0) + ? WHERE cid = ?`
).run(n, cid);
}
this.pending.clear();
}
onDisable() { clearInterval(this.timer); this.flush(); }13. 插件间通信
13.1 调用其他插件的方法
js
const other = this.ctx.plugin('reading-enhance');
if (other && typeof other.someMethod === 'function') {
const result = other.someMethod(arg);
}this.ctx.plugin(name) 返回该插件的实例(未启用则返回 undefined)。必须先判空。
13.2 公开自己的 API
js
module.exports = class Stats {
onLoad() {
this.ctx.registerFunction('statsSnapshot', () => this.snapshot());
}
snapshot() { return { posts: 10, comments: 42 }; }
};其他插件通过 plugin('stats') 拿到实例后直接调 snapshot()。
13.3 共享数据
用 options 表,以插件名为前缀:
js
this.ctx.options.set('stats:totalViews', String(total));
const total = Number(this.ctx.options.get('stats:totalViews', '0'));14. 完整示例
以下是完整可用的插件,演示配置、过滤器、模板函数、数据注入与面板。
plugins/site-tools/plugin.json
json
{
"name": "站点工具",
"version": "1.0.0",
"author": "你的名字",
"description": "文章工具:复制代码按钮、字数统计、上下篇导航增强",
"entry": "index.js",
"config": [
{
"name": "copyCode",
"label": "代码块显示复制按钮",
"type": "select",
"default": "1",
"options": [
{ "value": "1", "label": "显示" },
{ "value": "0", "label": "隐藏" }
]
},
{
"name": "showWordCount",
"label": "文章底部显示字数",
"type": "select",
"default": "1",
"options": [
{ "value": "1", "label": "显示" },
{ "value": "0", "label": "隐藏" }
]
},
{
"name": "notice",
"label": "文章顶部提示语",
"type": "text",
"default": "",
"description": "留空则不显示"
}
]
}plugins/site-tools/index.js
js
'use strict';
/**
* 站点工具插件
*
* 演示能力:
* 1. post.content 过滤器 —— 改写文章 HTML
* 2. render 动作 —— 向前台注入数据
* 3. registerFunction —— 向主题注入模板函数
* 4. panel() —— 后台统计面板
* 5. 声明式配置面板
*/
module.exports = class SiteTools {
constructor(ctx) {
this.ctx = ctx;
this.name = ctx.name;
}
onLoad() {
// ---- 1. 内容过滤器 ----
this.ctx.addFilter('post.content', (html, post) => {
if (!post || post.type !== 'post') return html;
const c = this.ctx.config();
let out = html;
// 顶部提示语
if (c.notice) {
out = `<p class="site-tools-notice">${this.escape(c.notice)}</p>` + out;
}
// 代码复制按钮
if (String(c.copyCode) === '1') out = this.addCopyButtons(out);
return out;
}, 10);
// ---- 2. 注入页面数据 ----
this.ctx.addAction('render', (payload) => {
const K = this.ctx.kernel;
payload.tools = {
posts: K.db.prepare(
`SELECT COUNT(*) AS c FROM ${K.t('posts')} WHERE type='post' AND status='publish'`
).get().c,
words: K.db.prepare(
`SELECT COALESCE(SUM(LENGTH(text)),0) AS w FROM ${K.t('posts')} WHERE type='post'`
).get().w,
since: K.options.get('created', ''),
};
});
// ---- 3. 模板函数 ----
this.ctx.registerFunction('wordCount', (text) => {
const s = String(text || '');
const cjk = (s.match(/[\u4e00-\u9fa5]/g) || []).length;
const word = (s.replace(/[\u4e00-\u9fa5]/g, ' ').match(/[A-Za-z0-9]+/g) || []).length;
return cjk + word;
});
this.ctx.registerFunction('codeTools', () =>
String(this.ctx.config().copyCode) === '1'
? '<p class="code-hint">💡 点击代码块右上角可复制</p>'
: ''
);
}
/** 配置变更时无需重建,这里仅示例签名 */
onConfigChange(next, prev) {
if (next.copyCode !== prev.copyCode) {
this.ctx.log('代码复制按钮设置已更新');
}
}
/** 后台面板 */
panel() {
const K = this.ctx.kernel;
const s = K.db.prepare(
`SELECT COUNT(*) AS c, COALESCE(SUM(LENGTH(text)),0) AS w
FROM ${K.t('posts')} WHERE type='post'`
).get();
return `<div class="stats" style="display:flex;gap:16px">
<div class="stat"><div class="n">${s.c}</div><div class="l">文章</div></div>
<div class="stat"><div class="n">${s.w}</div><div class="l">总字数</div></div>
</div>`;
}
/** 给每个 pre 代码块包一层复制按钮 */
addCopyButtons(html) {
return String(html).replace(
/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g,
(_, attrs, code) =>
`<div class="code-wrap"><button class="code-copy" onclick="zmCopy(this)">复制</button>` +
`<pre><code${attrs}>${code}</code></pre></div>`
);
}
escape(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&').replace(/</g, '<')
.replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
}
};配套 CSS/JS(放进 themes/zm-default/_style.html 或主题的自定义片段)
html
<style>
.code-wrap { position: relative; }
.code-wrap .code-copy {
position: absolute; top: 8px; right: 8px;
padding: 3px 10px; font-size: 12px; cursor: pointer;
border: 1px solid #d8dce1; border-radius: 5px;
background: #fff; color: #57606a; opacity: 0; transition: opacity .15s;
}
.code-wrap:hover .code-copy { opacity: 1; }
.site-tools-notice {
padding: 10px 14px; background: #fff8e6; border-radius: 6px;
color: #92600a; font-size: 14px;
}
</style>
<script>
function zmCopy(btn) {
const code = btn.parentElement.querySelector('code');
if (!code) return;
const done = () => { btn.textContent = '已复制'; setTimeout(() => (btn.textContent = '复制'), 1500); };
if (navigator.clipboard) {
navigator.clipboard.writeText(code.innerText).then(done);
} else {
const ta = document.createElement('textarea');
ta.value = code.innerText; document.body.appendChild(ta); ta.select();
document.execCommand('copy'); document.body.removeChild(ta); done();
}
}
</script>模板中使用:
html
{{{ $this.codeTools() }}}
<span>{{ $this.wordCount(post.text) }} 字</span>
<div>{{ tools.posts }} 篇文章,共{{ tools.words }} 字</div>15. 调试与排错
15.1 语法检查
bash
node --check plugins/你的插件/index.js
node --check plugins/你的插件/functions.js15.2 看日志
bash
# 启动服务时前台直接输出
node server.js
# systemd 服务
journalctl -u zmblog -f
# 插件主动打日志
this.ctx.log('调试信息', data);15.3 常见问题
| 现象 | 原因与处理 |
|---|---|
| 插件列表不显示 | 缺少 plugin.json;或 JSON 语法错误(列表会显示解析错误) |
| 启用后前台空白 | 过滤器忘记 return,内容变成 undefined |
内容变成 undefined | 同上。铁律:所有分支都要 return |
模板函数 undefined is not a function | registerFunction 在 onLoad 中注册,但函数定义写在原型上——改为普通属性赋值 |
| 配置读取为undefined | 字段 name 与代码中取值用的键不一致 |
| 查询报错「no such table」 | 硬编码了表名。改用 K.t('posts') |
| 停用后代码仍在跑 | 在 onLoad 里创建了定时器 / 监听器,没在 onDisable 中清理 |
| 修改代码不生效 | 启停一次插件,或重启服务(模块会被缓存) |
| 页面变慢 | 过滤器中做了同步重查询。改为缓存,或只在必要钩子中查询 |
| 插件间调用失败 | 未判空。const other = ctx.plugin('x'); if (!other) return; |
15.4 开启调试
bash
ZMBLOG_DEBUG=1 node server.js16. 发布与分发
16.1 打包
把插件目录打成压缩包分发:
bash
cd plugins && zip -r my-plugin-1.0.0.zip my-plugin/16.2 交付说明应包含
- 插件名与版本
- 功能简介与截图
- 依赖:是否需要特定内核版本、是否依赖其他插件
- 安装步骤:放到
plugins/目录 → 后台启用 - 配置项说明
- 卸载说明:停用插件、删除目录(配置项会残留在 options 表中)
16.3 命名规范
| 项 | 规范 |
|---|---|
| 目录名 | 小写 + 连字符,如 site-tools、reading-enhance |
plugin.json 的 name | 中文显示名,如「站点工具」 |
配置字段 name | 小驼峰,如 copyCode |
| 注册的模板函数 | 带插件前缀避免冲突,如 zmCopy |
16.4 兼容性
- 目标内核版本写在
plugin.json的description或说明中 - 使用
ctx注入的能力,不要直接修改内核文件 - 用到的内核钩子应在内核中长期保留(目前钩子清单是稳定的)
- 避免依赖
db的物理表名,永远用K.t()
17. 安全清单
插件以完整 Node.js 权限运行,拥有与服务进程相同的权限。请务必遵守:
17.1 绝对禁止
拼接 SQL:一律用参数化查询
js// 错误 db.prepare(`SELECT * FROM ${K.t('posts')} WHERE title='${title}'`); // 正确 db.prepare(`SELECT * FROM ${K.t('posts')} WHERE title = ?`).run(title);请求外部资源:任何网络请求都可能成为 SSRF 通道
使用
eval/new Function/ 动态require用户可控路径读取环境变量或系统敏感路径(如
/etc/passwd)删除非插件自身的数据表
17.2 输出必须转义
panel() 返回的 HTML 与过滤器插入的内容,若含用户可控数据,必须转义:
js
escape(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&').replace(/</g, '<')
.replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
}17.3 不要输出敏感数据
- 用户邮箱、密码哈希
- 会话令牌、CSRF 令牌
users.authSecret
17.4 为什么插件不能从后台上传
主题的 functions.js 会被 require 执行,因此后台模板编辑器禁止写入 .js、.json 等类型。这是刻意的安全边界:任何能在后台写模板的人,都不能借此在服务器上执行代码。
同理,插件只能由服务器管理员通过部署文件的方式安装。 请不要设计「插件市场一键安装」类功能,除非配套严格的代码审计与签名校验机制。
17.5 资源限制
- 不要做同步阻塞的密集计算(会阻塞整个事件循环,所有请求卡住)
- 高频接口应加缓存与限流
- 定时器必须能在
onDisable中清理