Skip to content

ZMBlog · 插件开发文档 ​

适用版本:ZMBlog v2.3.1  阅读对象:希望为 ZMBlog 扩展功能的开发者


目录 ​

  1. 插件系统概览
  2. 快速上手
  3. 插件目录结构
  4. plugin.json 完整说明
  5. 生命周期
  6. 注入的上下文 ctx
  7. 过滤器(filter)
  8. 动作(action)
  9. 钩子清单
  10. 注册模板函数
  11. 后台配置面板
  12. 访问数据
  13. 插件间通信
  14. 完整示例
  15. 调试与排错
  16. 发布与分发
  17. 安全清单

1. 插件系统概览 ​

ZMBlog 的插件系统围绕四个能力设计:

能力作用
过滤器 filter拦截并修改一个值,返回新值。用于改写内容、模板、整页 HTML
动作 action触发一个副作用,不返回值。用于记录日志、清理缓存、发送通知
模板函数向主题注入可在 {{ }} 中调用的方法
配置面板用声明式配置字段自动生成后台表单

核心特性

  • 启停即时生效,无需重启服务
  • 多个插件可挂在同一钩子上,按 priority 排序依次执行
  • 插件配置独立存储(plugin:<插件名>:config),与站点配置隔离
  • 插件代码由 Node.js require 加载,因此只能由服务器上放置,后台无法新建(见第 17 节)

2. 快速上手 ​

2.1 建目录 ​

bash
mkdir -p plugins/hello

2.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后台显示的标签
typetext / textarea / password / checkbox / select / radio
default默认值
optionsselect / 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, '&amp;');               // 继续加工
}, 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 }(批量删除时会逐条触发)

渲染

钩子载荷用途
renderpayload(完整页面变量对象)可向页面注入数据

系统

钩子载荷
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, '&amp;').replace(/</g, '&lt;')
      .replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
  }
};

配套 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.js

15.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 functionregisterFunction 在 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.js

16. 发布与分发 ​

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, '&amp;').replace(/</g, '&lt;')
    .replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}

17.3 不要输出敏感数据 ​

  • 用户邮箱、密码哈希
  • 会话令牌、CSRF 令牌
  • users.authSecret

17.4 为什么插件不能从后台上传 ​

主题的 functions.js 会被 require 执行,因此后台模板编辑器禁止写入 .js、.json 等类型。这是刻意的安全边界:任何能在后台写模板的人,都不能借此在服务器上执行代码。

同理,插件只能由服务器管理员通过部署文件的方式安装。 请不要设计「插件市场一键安装」类功能,除非配套严格的代码审计与签名校验机制。

17.5 资源限制 ​

  • 不要做同步阻塞的密集计算(会阻塞整个事件循环,所有请求卡住)
  • 高频接口应加缓存与限流
  • 定时器必须能在 onDisable 中清理

相关文档 ​

ZMBlog · MIT License