clearlove2.1
1// 插件形态与清单解析。
2//
3// 两种形态(由 plugin.json 的 kind 字段区分):
4// - 声明式 declarative(默认,无 kind 字段):能力来自 hooks 声明,内核解释执行
5// - 应用型 app(kind=app):能力来自 main.js 中的 clv.* 运行时注册
6//
7// 设计原则:清单决定「能碰什么」(权限必须先于脚本加载确定),
8// 脚本决定「要做什么」(注册逻辑本身是代码,写成声明会退化成白名单枚举)。
9package plugin
10
11import (
12 "crypto/sha1"
13 "encoding/hex"
14 "encoding/json"
15 "errors"
16 "fmt"
17 "strings"
18 "time"
19)
20
21// 插件形态
22const (
23 KindDeclarative = "declarative"
24 KindApp = "app"
25)
26
27// RuntimeSpec 应用型插件的运行时声明
28type RuntimeSpec struct {
29 Engine string `json:"engine"` // 当前仅支持 "js"
30 Entry string `json:"entry"` // 入口脚本(相对插件目录)
31 TimeoutMS int `json:"timeout_ms"` // 单次调用超时(毫秒),默认 1500,硬上限 5000
32}
33
34const (
35 defaultTimeoutMS = 1500
36 // maxTimeoutMS 单次调用超时硬上限。
37 // 放宽到 30s 是为了支持插件做耗时操作(如调用外部 AI 接口),
38 // 默认值仍是 1500ms,只有显式声明才会放宽。
39 maxTimeoutMS = 30000
40 setupTimeoutMS = 3000 // setup() 允许略长
41 setupMaxMS = 5000 // setup() 的上限:初始化不应耗时过长
42)
43
44// KindOf 返回插件形态(缺省为声明式,保证旧插件行为不变)
45func (p *Plugin) KindOf() string {
46 if p != nil && strings.EqualFold(strings.TrimSpace(p.Kind), KindApp) {
47 return KindApp
48 }
49 return KindDeclarative
50}
51
52// IsApp 是否为应用型插件
53func (p *Plugin) IsApp() bool { return p.KindOf() == KindApp }
54
55// SlugOf 返回 URL / 表名安全标识:显式声明优先,否则由 name 归一化,
56// 再退化为 plugin-<sha1 前 8 位>,保证任何插件名(含中文)都有可用标识。
57func (p *Plugin) SlugOf() string {
58 if p == nil {
59 return ""
60 }
61 if s := NormalizeSlug(p.Slug); s != "" {
62 return s
63 }
64 if s := NormalizeSlug(p.Name); s != "" {
65 return s
66 }
67 sum := sha1.Sum([]byte(p.Name))
68 return "plugin-" + hex.EncodeToString(sum[:4])
69}
70
71// NormalizeSlug 归一化为仅含 [a-z0-9_-] 的标识(连续非法字符折叠为一个 '-')
72func NormalizeSlug(s string) string {
73 s = strings.ToLower(strings.TrimSpace(s))
74 var b strings.Builder
75 dash := false
76 for _, r := range s {
77 switch {
78 case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_':
79 b.WriteRune(r)
80 dash = false
81 case r == '-':
82 if !dash {
83 b.WriteByte('-')
84 dash = true
85 }
86 default: // 中文、空格等一律折叠为 '-'
87 if !dash {
88 b.WriteByte('-')
89 dash = true
90 }
91 }
92 }
93 return strings.Trim(b.String(), "-")
94}
95
96// Timeout 单次脚本调用超时
97func (p *Plugin) Timeout() time.Duration {
98 ms := defaultTimeoutMS
99 if p != nil && p.Runtime != nil && p.Runtime.TimeoutMS > 0 {
100 ms = p.Runtime.TimeoutMS
101 }
102 if ms <= 0 || ms > maxTimeoutMS {
103 ms = defaultTimeoutMS
104 }
105 return time.Duration(ms) * time.Millisecond
106}
107
108// HasPermission 插件是否声明了指定权限(应用型的能力白名单)
109func (p *Plugin) HasPermission(name string) bool {
110 if p == nil {
111 return false
112 }
113 name = strings.TrimSpace(name)
114 if name == "" {
115 return false
116 }
117 for _, v := range p.Permissions {
118 if strings.EqualFold(strings.TrimSpace(v), name) {
119 return true
120 }
121 }
122 return false
123}
124
125// TablePrefix 插件数据表前缀:pl_<slug>_(slug 中的 '-' 转为 '_',避免反引号)
126func (p *Plugin) TablePrefix() string {
127 return "pl_" + strings.ReplaceAll(p.SlugOf(), "-", "_") + "_"
128}
129
130// TableName 由逻辑名得到实际表名
131func (p *Plugin) TableName(logical string) string {
132 logical = NormalizeSlug(logical)
133 if logical == "" {
134 return ""
135 }
136 return p.TablePrefix() + strings.ReplaceAll(logical, "-", "_")
137}
138
139// unmarshalPlugin 解析插件清单(要求 UTF-8 无 BOM,带 BOM 会给出明确提示)
140func unmarshalPlugin(data []byte, p *Plugin) error {
141 if len(data) > 1<<20 {
142 return errors.New("plugin.json 过大(超过 1MB)")
143 }
144 if len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF {
145 return errors.New("plugin.json 含有 UTF-8 BOM,请另存为「UTF-8 无 BOM」")
146 }
147 if err := json.Unmarshal(data, p); err != nil {
148 return fmt.Errorf("plugin.json 解析失败: %w", err)
149 }
150 if strings.TrimSpace(p.Name) == "" {
151 return errors.New("plugin.json 缺少 name 字段")
152 }
153 return nil
154}
155
156// entryPath 校验并返回入口脚本的安全相对路径
157func (p *Plugin) entryPath() string {
158 if p == nil || p.Runtime == nil {
159 return ""
160 }
161 name := strings.TrimSpace(p.Runtime.Entry)
162 if name == "" {
163 return ""
164 }
165 name = strings.ReplaceAll(name, "\\", "/")
166 // 只允许插件目录下的直接路径,拒绝穿越与绝对路径
167 if strings.HasPrefix(name, "/") || strings.Contains(name, "..") {
168 return ""
169 }
170 return name
171}