clearlove2.1
1// Package plugin 插件系统,支持两种形态(由 plugin.json 的 kind 字段区分):
2//
3// - 声明式(默认,无 kind):能力来自 hooks 声明,由内核解释执行
4// html : 向页面钩子点注入 HTML(header_html / footer_html / admin_plugins_top)
5// filter : 正则改写帖子/评论内容(content_filter)
6// http : 事件 Webhook(post_created / comment_created / report_created)
7// guard : 发帖守卫(compose_guard)
8// badge : 帖子标识(post_badge)
9// - 应用型(kind=app):能力来自 main.js 中的 clv.* 运行时注册
10// (路由 / 数据表 / 后台菜单与页面 / Slot / 事件 / 过滤器 / 中间件 / 定时任务)
11//
12// 插件为目录 data/plugins/<name>/plugin.json(支持 zip 上传安装)。
13// 开发文档:docs/PLUGIN.md(总览)、docs/PLUGIN-DECLARATIVE.md、docs/PLUGIN-APP.md。
14package plugin
15
16import (
17 "archive/zip"
18 "bytes"
19 "encoding/json"
20 "fmt"
21 "html"
22 "io"
23 "net/http"
24 "os"
25 "path/filepath"
26 "regexp"
27 "strconv"
28 "strings"
29 "sync"
30 "time"
31
32 "clearlove/internal/config"
33 "clearlove/internal/models"
34 "clearlove/internal/util"
35)
36
37// ConfigField 插件配置项声明。后台「插件管理」会按此自动渲染设置表单,
38// 配置值保存在 settings 表中(键名前缀 plugin.<插件名>.),可在钩子中通过
39// {config:字段名} 占位符引用。
40type ConfigField struct {
41 Key string `json:"key"` // 字段名(供 {config:key} 引用)
42 Label string `json:"label"` // 显示名称
43 Type string `json:"type"` // text | textarea | switch | select | number
44 Default string `json:"default"` // 默认值
45 Help string `json:"help"` // 说明文字
46 Options []string `json:"options"` // select 型的候选项
47}
48
49// HookDef 单个钩子定义
50type HookDef struct {
51 Hook string `json:"hook"` // 钩子名
52 Type string `json:"type"` // html | filter | http | guard | badge
53 HTML string `json:"html"` // html 型:注入片段
54 Match string `json:"match"` // filter 型:正则
55 Replace string `json:"replace"` // filter 型:替换文本
56 URL string `json:"url"` // http 型:Webhook 地址
57
58 // guard / badge 型专用
59 Nicknames string `json:"nicknames"` // 触发昵称,逗号分隔,支持 {config:key}
60 Require string `json:"require"` // admin = 必须验证后台账号密码
61 Label string `json:"label"` // 命中后写入帖子的标识文字
62 Message string `json:"message"` // 校验失败时的提示语
63}
64
65// Plugin 插件清单。支持两种形态:
66// - 声明式(默认,无 kind 字段):能力来自 hooks 声明,内核解释执行
67// - 应用型(kind=app):能力来自 main.js 中的 clv.* 运行时注册
68type Plugin struct {
69 Kind string `json:"kind"` // "" / declarative = 声明式;app = 应用型
70 Name string `json:"name"` // 插件名(同时作为安装目录名)
71 Slug string `json:"slug"` // URL / 表名安全标识,缺省由 Name 归一化
72 Version string `json:"version"` // 版本号,展示用
73 Author string `json:"author"` // 作者
74 Description string `json:"description"` // 简介
75 Requires string `json:"requires"` // 建议的最低表白墙版本(仅提示)
76 Runtime *RuntimeSpec `json:"runtime"` // 应用型必填:脚本入口与超时
77 Permissions []string `json:"permissions"` // 应用型能力白名单(必须在清单中声明)
78 Config []ConfigField `json:"config"` // 配置项声明(两种形态都支持)
79 Hooks []HookDef `json:"hooks"` // 声明式钩子(应用型可不写)
80}
81
82// Meta 插件目录信息(管理页展示)
83type Meta struct {
84 Plugin
85 Enabled bool
86 Warn string // 兼容性提示:使用了当前版本不支持的钩子 / 版本要求不满足
87 App bool // 是否应用型(kind=app)
88 Running bool // 应用型插件运行时是否已加载
89 Slug string // 应用型插件的 URL/表名标识
90}
91
92// supportedTypes 当前内核支持的钩子类型
93var supportedTypes = map[string]bool{
94 "html": true, "filter": true, "http": true, "guard": true, "badge": true,
95}
96
97// checkCompat 返回插件的兼容性提示(空串表示没有问题)
98func (p *Plugin) checkCompat() string {
99 var bad []string
100 seen := map[string]bool{}
101 for _, h := range p.Hooks {
102 if h.Type == "" || supportedTypes[h.Type] || seen[h.Type] {
103 continue
104 }
105 seen[h.Type] = true
106 bad = append(bad, h.Type)
107 }
108 if len(bad) > 0 {
109 return "该插件使用了当前版本不支持的钩子类型:" + strings.Join(bad, "、") + ",请升级表白墙后再启用"
110 }
111 if p.Requires != "" && versionLess(config.Version, p.Requires) {
112 return "该插件要求表白墙版本 ≥ " + p.Requires + ",当前版本为 " + config.Version
113 }
114 return ""
115}
116
117// versionLess 按 x.y.z 逐段比较版本号(缺失段按 0 处理)
118func versionLess(a, b string) bool {
119 pa, pb := strings.Split(a, "."), strings.Split(b, ".")
120 for i := 0; i < 3; i++ {
121 x, y := 0, 0
122 if i < len(pa) {
123 x, _ = strconv.Atoi(strings.TrimSpace(pa[i]))
124 }
125 if i < len(pb) {
126 y, _ = strconv.Atoi(strings.TrimSpace(pb[i]))
127 }
128 if x != y {
129 return x < y
130 }
131 }
132 return false
133}
134
135// dir 插件目录(跟随数据目录配置,支持 CLEARLOVE_DATA_DIR 自定义)
136func dir() string { return filepath.Join(config.Cfg.DataDir, "plugins") }
137
138// ---------- 清单缓存 ----------
139//
140// 热路径(每次页面渲染、每次发帖)都会读取插件清单,
141// 这里加 5 秒短缓存;安装/启用/禁用/卸载等管理操作会立即失效缓存,
142// 因此管理端行为与"每次读盘"没有可感知差异。
143
144var loadCache struct {
145 sync.Mutex
146 exp time.Time
147 data map[string]*Plugin
148}
149
150// cachedPlugins 热路径使用的插件清单(带短 TTL)
151func cachedPlugins() map[string]*Plugin {
152 loadCache.Lock()
153 defer loadCache.Unlock()
154 if loadCache.data != nil && time.Now().Before(loadCache.exp) {
155 return loadCache.data
156 }
157 loadCache.data = Load()
158 loadCache.exp = time.Now().Add(5 * time.Second)
159 return loadCache.data
160}
161
162// InvalidatePluginCache 管理操作后主动失效缓存(安装/启用/禁用/卸载/配置变更)
163func InvalidatePluginCache() {
164 loadCache.Lock()
165 loadCache.exp = time.Time{}
166 loadCache.Unlock()
167 filterCache.Lock()
168 filterCache.exp = time.Time{}
169 filterCache.Unlock()
170}
171
172// Load 读取磁盘上的全部插件清单
173func Load() map[string]*Plugin {
174 out := map[string]*Plugin{}
175 entries, err := os.ReadDir(dir())
176 if err != nil {
177 return out
178 }
179 for _, e := range entries {
180 if !e.IsDir() {
181 continue
182 }
183 b, err := os.ReadFile(filepath.Join(dir(), e.Name(), "plugin.json"))
184 if err != nil {
185 continue
186 }
187 var p Plugin
188 if json.Unmarshal(b, &p) == nil && p.Name != "" {
189 loadHookFiles(&p, filepath.Join(dir(), e.Name()))
190 out[p.Name] = &p
191 }
192 }
193 return out
194}
195
196// loadHookFiles 支持把 html 片段放在插件目录的文件中:
197//
198// {"hook":"footer_html","type":"html","html":"@player.html"}
199//
200// 表示读取同目录下的 player.html。带脚本的插件用它可以避免在 JSON 里
201// 转义大段 HTML,插件安装(zip)时会连同该文件一起落盘。
202func loadHookFiles(p *Plugin, base string) {
203 for i := range p.Hooks {
204 h := &p.Hooks[i]
205 if h.Type != "html" || !strings.HasPrefix(h.HTML, "@") {
206 continue
207 }
208 // 只允许插件目录下的直接文件名,避免路径穿越
209 name := filepath.Base(strings.TrimSpace(h.HTML[1:]))
210 if name == "" || name == "." || name == string(filepath.Separator) {
211 h.HTML = ""
212 continue
213 }
214 b, err := os.ReadFile(filepath.Join(base, name))
215 if err != nil {
216 // 文件缺失时清空,避免把 "@xxx" 原样注入页面
217 h.HTML = ""
218 continue
219 }
220 h.HTML = string(b)
221 }
222}
223
224// enabledSet 已启用插件名集合
225func enabledSet() map[string]bool {
226 set := map[string]bool{}
227 var list []string
228 if s := models.GetSetting("enabled_plugins"); s != "" {
229 _ = json.Unmarshal([]byte(s), &list)
230 }
231 for _, v := range list {
232 set[v] = true
233 }
234 return set
235}
236
237func saveEnabled(set map[string]bool) {
238 var list []string
239 for k := range set {
240 list = append(list, k)
241 }
242 b, _ := json.Marshal(list)
243 _ = models.SetSetting("enabled_plugins", string(b))
244}
245
246// List 管理页用:全部插件、启用状态、形态与运行状态
247func List() []Meta {
248 enabled := enabledSet()
249 var out []Meta
250 for name, p := range Load() {
251 m := Meta{Plugin: *p, Enabled: enabled[p.Name], Warn: p.checkCompat()}
252 if p.IsApp() {
253 m.App = true
254 m.Slug = p.SlugOf()
255 m.Running = GetApp(filepath.Base(name)) != nil
256 }
257 out = append(out, m)
258 }
259 return out
260}
261
262// ---------- 插件配置 ----------
263
264// configKey 插件配置在 settings 表中的键名
265func configKey(pluginName, key string) string {
266 return "plugin." + pluginName + "." + key
267}
268
269// GetConfig 读取插件配置(未设置的字段回落到声明的默认值)
270func GetConfig(p *Plugin) map[string]string {
271 out := make(map[string]string, len(p.Config))
272 for _, f := range p.Config {
273 if f.Key == "" {
274 continue
275 }
276 v := models.GetSetting(configKey(p.Name, f.Key))
277 if v == "" {
278 v = f.Default
279 }
280 out[f.Key] = v
281 }
282 return out
283}
284
285// ConfigOf 按插件名读取配置(插件不存在时返回空表)
286func ConfigOf(pluginName string) map[string]string {
287 if p, ok := Load()[pluginName]; ok {
288 return GetConfig(p)
289 }
290 return map[string]string{}
291}
292
293// SaveConfig 保存插件配置(只接受该插件声明过的字段)
294func SaveConfig(p *Plugin, values map[string]string) error {
295 for _, f := range p.Config {
296 if f.Key == "" {
297 continue
298 }
299 if v, ok := values[f.Key]; ok {
300 if err := models.SetSetting(configKey(p.Name, f.Key), util.StripHTML(v)); err != nil {
301 return err
302 }
303 }
304 }
305 return nil
306}
307
308// reConfigVar 配置占位符 {config:key}
309var reConfigVar = regexp.MustCompile(`\{config:[A-Za-z0-9_\-]+\}`)
310
311// expand 展开 {config:key} 占位符(用于昵称名单、标签、提示语等非 HTML 场景,原样替换)
312func expand(s string, cfg map[string]string) string {
313 return expandIn(s, cfg, false)
314}
315
316// expandHTML 展开 {config:key} 占位符,配置值做 HTML 转义,
317// 防止管理员配置值(如 {config:url})携带引号/尖括号闭合标签属性造成存储型 XSS
318func expandHTML(s string, cfg map[string]string) string {
319 return expandIn(s, cfg, true)
320}
321
322func expandIn(s string, cfg map[string]string, esc bool) string {
323 if s == "" || !strings.Contains(s, "{config:") {
324 return s
325 }
326 return reConfigVar.ReplaceAllStringFunc(s, func(m string) string {
327 v := cfg[m[len("{config:"):len(m)-1]]
328 if esc {
329 return html.EscapeString(v)
330 }
331 return v
332 })
333}
334
335// splitNames 拆分逗号/分号/换行分隔的名单
336func splitNames(s string) []string {
337 fields := strings.FieldsFunc(s, func(r rune) bool {
338 return r == ',' || r == ',' || r == ';' || r == ';' || r == '\n' || r == '\r'
339 })
340 out := make([]string, 0, len(fields))
341 for _, f := range fields {
342 if v := strings.TrimSpace(f); v != "" {
343 out = append(out, v)
344 }
345 }
346 return out
347}
348
349// ---------- 发帖守卫(guard)与帖子标识(badge) ----------
350
351// GuardResult 发帖守卫判定结果
352type GuardResult struct {
353 Require bool // true = 必须验证后台管理员账号密码
354 Labels []string // 通过后写入帖子的标识文字
355 Message string // 未通过时的提示语
356}
357
358// CheckComposeGuard 检查昵称是否命中任一启用插件的发帖守卫(compose_guard)。
359// 未命中返回 nil。多个插件同时命中时会合并标识与要求。
360func CheckComposeGuard(nickname string) *GuardResult {
361 name := strings.TrimSpace(nickname)
362 if name == "" {
363 return nil
364 }
365 enabled := enabledSet()
366 res := &GuardResult{}
367 hit := false
368 for pname, p := range cachedPlugins() {
369 if !enabled[pname] {
370 continue
371 }
372 cfg := GetConfig(p)
373 for _, h := range p.Hooks {
374 if h.Type != "guard" || h.Hook != "compose_guard" {
375 continue
376 }
377 matched := false
378 for _, n := range splitNames(expand(h.Nicknames, cfg)) {
379 if strings.EqualFold(n, name) {
380 matched = true
381 break
382 }
383 }
384 if !matched {
385 continue
386 }
387 hit = true
388 if h.Require == "admin" {
389 res.Require = true
390 }
391 if h.Label != "" {
392 res.Labels = append(res.Labels, expand(h.Label, cfg))
393 }
394 if res.Message == "" && h.Message != "" {
395 res.Message = expand(h.Message, cfg)
396 }
397 }
398 }
399 if !hit {
400 return nil
401 }
402 return res
403}
404
405// GuardNicknames 返回所有「需要验证后台账号」的守卫昵称。
406// 发帖页据此在提交前弹出账号验证弹窗,避免用户提交后才看到报错。
407func GuardNicknames() []string {
408 enabled := enabledSet()
409 var out []string
410 for pname, p := range cachedPlugins() {
411 if !enabled[pname] {
412 continue
413 }
414 cfg := GetConfig(p)
415 for _, h := range p.Hooks {
416 if h.Type != "guard" || h.Hook != "compose_guard" || h.Require != "admin" {
417 continue
418 }
419 out = append(out, splitNames(expand(h.Nicknames, cfg))...)
420 }
421 }
422 return out
423}
424
425// NicknameBadges 返回该昵称在发帖时应携带的标识(post_badge 钩子)
426func NicknameBadges(nickname string) []string {
427 name := strings.TrimSpace(nickname)
428 if name == "" {
429 return nil
430 }
431 enabled := enabledSet()
432 var out []string
433 for pname, p := range cachedPlugins() {
434 if !enabled[pname] {
435 continue
436 }
437 cfg := GetConfig(p)
438 for _, h := range p.Hooks {
439 if h.Type != "badge" || h.Hook != "post_badge" || h.Label == "" {
440 continue
441 }
442 for _, n := range splitNames(expand(h.Nicknames, cfg)) {
443 if strings.EqualFold(n, name) {
444 out = append(out, expand(h.Label, cfg))
445 break
446 }
447 }
448 }
449 }
450 return out
451}
452
453// Install 从上传的 zip 安装插件(zip 内应包含 plugin.json)
454func Install(filename string, data []byte) error {
455 zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
456 if err != nil {
457 return fmt.Errorf("无效的 zip 包: %w", err)
458 }
459 // 先定位 plugin.json 确定插件名
460 var manifest []byte
461 for _, f := range zr.File {
462 base := filepath.Base(f.Name)
463 if base == "plugin.json" {
464 rc, err := f.Open()
465 if err != nil {
466 return err
467 }
468 manifest, _ = io.ReadAll(io.LimitReader(rc, 1<<20))
469 rc.Close()
470 break
471 }
472 }
473 if manifest == nil {
474 return fmt.Errorf("压缩包中未找到 plugin.json")
475 }
476 var p Plugin
477 if err := json.Unmarshal(manifest, &p); err != nil || p.Name == "" {
478 return fmt.Errorf("plugin.json 解析失败(需包含 name 字段)")
479 }
480 // 防目录穿越:仅接受安全的相对路径
481 target := filepath.Join(dir(), filepath.Base(p.Name))
482 if err := os.MkdirAll(target, 0o755); err != nil {
483 return err
484 }
485 for _, f := range zr.File {
486 name := filepath.Clean(f.Name)
487 if strings.HasPrefix(name, "..") || filepath.IsAbs(name) {
488 continue
489 }
490 dst := filepath.Join(target, name)
491 if f.FileInfo().IsDir() {
492 _ = os.MkdirAll(dst, 0o755)
493 continue
494 }
495 if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
496 return err
497 }
498 rc, err := f.Open()
499 if err != nil {
500 return err
501 }
502 out, err := os.Create(dst)
503 if err != nil {
504 rc.Close()
505 return err
506 }
507 _, _ = io.Copy(out, io.LimitReader(rc, 50<<20))
508 out.Close()
509 rc.Close()
510 }
511 InvalidatePluginCache()
512 return nil
513}
514
515// Remove 卸载插件(目录删除并从启用列表移除)
516func Remove(name string) error {
517 _ = os.RemoveAll(filepath.Join(dir(), filepath.Base(name)))
518 set := enabledSet()
519 delete(set, name)
520 saveEnabled(set)
521 InvalidatePluginCache()
522 return nil
523}
524
525// SetEnabled 启用/禁用插件
526func SetEnabled(name string, on bool) {
527 set := enabledSet()
528 if on {
529 set[name] = true
530 } else {
531 delete(set, name)
532 }
533 saveEnabled(set)
534 InvalidatePluginCache()
535}
536
537var filterCache struct {
538 sync.Mutex
539 rules []compiledFilter
540 exp time.Time
541}
542
543type compiledFilter struct {
544 re string // 原始正则(编译失败则跳过)
545 rep string
546}
547
548// CallFilter 应用所有启用插件的 content_filter 钩子改写内容
549func CallFilter(content string) string {
550 if content == "" {
551 return content
552 }
553 filterCache.Lock()
554 defer filterCache.Unlock()
555 if time.Now().After(filterCache.exp) {
556 filterCache.rules = nil
557 enabled := enabledSet()
558 for name, p := range cachedPlugins() {
559 if !enabled[name] {
560 continue
561 }
562 for _, h := range p.Hooks {
563 if h.Hook == "content_filter" && h.Type == "filter" && h.Match != "" {
564 filterCache.rules = append(filterCache.rules, compiledFilter{re: h.Match, rep: h.Replace})
565 }
566 }
567 }
568 filterCache.exp = time.Now().Add(30 * time.Second)
569 }
570 for _, r := range filterCache.rules {
571 if re, err := regexp.Compile(r.re); err == nil {
572 content = re.ReplaceAllString(content, r.rep)
573 }
574 }
575 return content
576}
577
578// CallHTML 收集指定页面钩子点的全部启用插件 HTML 片段,
579// 并展开片段中的 {config:key} 占位符(与 guard / badge 钩子保持一致),
580// 插件据此把后台配置好的值(如上传的音乐地址)注入页面。
581func CallHTML(hook string) string {
582 var b strings.Builder
583 enabled := enabledSet()
584 for name, p := range cachedPlugins() {
585 if !enabled[name] {
586 continue
587 }
588 cfg := GetConfig(p)
589 for _, h := range p.Hooks {
590 if h.Hook == hook && h.Type == "html" {
591 b.WriteString(expandHTML(h.HTML, cfg))
592 }
593 }
594 }
595 return b.String()
596}
597
598// Notify 异步触发事件 Webhook(POST JSON),失败仅记录日志
599func Notify(event string, payload map[string]any) {
600 enabled := enabledSet()
601 for name, p := range cachedPlugins() {
602 if !enabled[name] {
603 continue
604 }
605 for _, h := range p.Hooks {
606 if h.Hook == event && h.Type == "http" && h.URL != "" {
607 go postWebhook(h.URL, event, payload)
608 }
609 }
610 }
611}
612
613func postWebhook(url, event string, payload map[string]any) {
614 // 拷贝后再补充元数据字段:payload 会被多个 webhook goroutine 与内核共享,
615 // 直接写入原 map 会触发并发写 panic
616 body, _ := json.Marshal(withMeta(event, payload))
617 client := &http.Client{Timeout: 10 * time.Second}
618 req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
619 if err != nil {
620 return
621 }
622 req.Header.Set("Content-Type", "application/json")
623 req.Header.Set("X-ClearLove-Event", event)
624 if resp, err := client.Do(req); err == nil {
625 resp.Body.Close()
626 } else {
627 util.Log("warn", "插件 Webhook %s 失败: %v", event, err)
628 }
629}