仰望星辰工作室

clearlove2.1

clearlove2.1/ internal/plugin/api_view.go 9.9 KB · 351 行 原始文件
1// Host API:运行时注册(clv.route / clv.slot / clv.on / clv.filter / clv.job ...)
2// 与视图渲染(clv.view.*)。
3//
4// 这里体现 B 型插件的核心设计:能力注册是命令式的(写在 setup() 里),
5// 而能力授权是声明式的(写在 plugin.json 的 permissions 里)。
6package plugin
7
8import (
9 "errors"
10 "fmt"
11 "html/template"
12 "os"
13 "strings"
14 "time"
15
16 "github.com/dop251/goja"
17)
18
19// reqContext 当前请求上下文。
20// Worker 串行执行,同一时刻只有一个 handler 在跑,因此可以用单字段暂存。
21type reqContext struct {
22 UserID int64
23 AdminID int64
24 IsAdmin bool
25 IP string
26 Fingerprint string
27 Method string
28 Path string
29 Params map[string]string
30 Query map[string]string
31 Form map[string]string
32 CSRF string
33}
34
35func (rt *jsRuntime) installView(clv *goja.Object) error {
36 // ---------- 注册类 API ----------
37 _ = clv.Set("route", rt.jsFn(rt.regRoute))
38 _ = clv.Set("adminMenu", rt.jsFn(rt.regAdminMenu))
39 _ = clv.Set("adminPage", rt.jsFn(rt.regAdminPage))
40 _ = clv.Set("slot", rt.jsFn(rt.regSlot))
41 _ = clv.Set("on", rt.jsFn(rt.regEvent))
42 _ = clv.Set("emit", rt.jsFn(rt.emitEvent))
43 _ = clv.Set("filter", rt.jsFn(rt.regFilter))
44 _ = clv.Set("job", rt.jsFn(rt.regJob))
45 _ = clv.Set("middleware", rt.jsFn(rt.regMiddleware))
46
47 // ---------- clv.view ----------
48 v := rt.vm.NewObject()
49 _ = v.Set("template", rt.jsFn(rt.viewTemplate))
50 _ = v.Set("escape", rt.jsFn(func(call goja.FunctionCall) (any, error) {
51 args := argsOf(call)
52 if len(args) == 0 {
53 return "", nil
54 }
55 return template.HTMLEscapeString(strOf(args[0])), nil
56 }))
57 _ = v.Set("nl2br", rt.jsFn(func(call goja.FunctionCall) (any, error) {
58 args := argsOf(call)
59 if len(args) == 0 {
60 return "", nil
61 }
62 return strings.ReplaceAll(template.HTMLEscapeString(strOf(args[0])), "\n", "<br>"), nil
63 }))
64 _ = clv.Set("view", v)
65 return nil
66}
67
68// ---------- 注册实现 ----------
69
70// bindFn 支持两种写法:函数名字符串,或直接传函数(自动登记为内部名,便于跨请求调用)
71func (rt *jsRuntime) bindFn(v goja.Value, prefix string) (string, error) {
72 if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
73 return "", errors.New("需要一个函数或函数名")
74 }
75 if fnVal, ok := goja.AssertFunction(v); ok {
76 rt.fnSeq++
77 name := fmt.Sprintf("__%s_%d", prefix, rt.fnSeq)
78 if err := rt.vm.Set(name, fnVal); err != nil {
79 return "", err
80 }
81 return name, nil
82 }
83 name := strings.TrimSpace(strOf(exportJS(v)))
84 if name == "" {
85 return "", errors.New("需要一个函数或函数名")
86 }
87 return name, nil
88}
89
90func (rt *jsRuntime) regRoute(call goja.FunctionCall) (any, error) {
91 if rt.app == nil {
92 return nil, errNoApp
93 }
94 if len(call.Arguments) < 3 {
95 return nil, errors.New("clv.route(method, path, handler, opts?) 参数不足")
96 }
97 method := strings.ToUpper(strings.TrimSpace(strOf(exportJS(call.Argument(0)))))
98 path := normRelPath(strOf(exportJS(call.Argument(1))))
99 fn, err := rt.bindFn(call.Argument(2), "route")
100 if err != nil {
101 return nil, err
102 }
103 r := RouteReg{Method: method, Path: path, Fn: fn, Auth: "none"}
104 if opts := mapOfAny(call.Argument(3)); opts != nil {
105 if v := strings.ToLower(strOf(opts["auth"])); v != "" {
106 r.Auth = v
107 }
108 r.Perm = strings.TrimSpace(strOf(opts["perm"]))
109 r.JSON = boolOf(opts["json"])
110 }
111 if method == "" {
112 r.Method = "GET"
113 }
114 rt.app.Routes = append(rt.app.Routes, r)
115 return nil, nil
116}
117
118func (rt *jsRuntime) regAdminMenu(call goja.FunctionCall) (any, error) {
119 if rt.app == nil {
120 return nil, errNoApp
121 }
122 opts := mapOfAny(call.Argument(0))
123 if opts == nil {
124 return nil, errors.New("clv.adminMenu({label, path, perm, order}) 需要一个对象参数")
125 }
126 label := trimTo(strOf(opts["label"]), 24)
127 if label == "" {
128 return nil, errors.New("adminMenu 需要 label")
129 }
130 rt.app.Menus = append(rt.app.Menus, MenuReg{
131 Label: label,
132 Path: normRelPath(strOf(opts["path"])),
133 Perm: strings.TrimSpace(strOf(opts["perm"])),
134 Order: intOf(opts["order"]),
135 })
136 return nil, nil
137}
138
139func (rt *jsRuntime) regAdminPage(call goja.FunctionCall) (any, error) {
140 if rt.app == nil {
141 return nil, errNoApp
142 }
143 if len(call.Arguments) < 2 {
144 return nil, errors.New("clv.adminPage(path, handler, opts?) 参数不足")
145 }
146 path := normRelPath(strOf(exportJS(call.Argument(0))))
147 fn, err := rt.bindFn(call.Argument(1), "adminpage")
148 if err != nil {
149 return nil, err
150 }
151 p := PageReg{Path: path, Fn: fn}
152 if opts := mapOfAny(call.Argument(2)); opts != nil {
153 p.Perm = strings.TrimSpace(strOf(opts["perm"]))
154 }
155 rt.app.Pages = append(rt.app.Pages, p)
156 return nil, nil
157}
158
159func (rt *jsRuntime) regSlot(call goja.FunctionCall) (any, error) {
160 if rt.app == nil {
161 return nil, errNoApp
162 }
163 if len(call.Arguments) < 2 {
164 return nil, errors.New("clv.slot(name, handler) 参数不足")
165 }
166 name := strings.TrimSpace(strOf(exportJS(call.Argument(0))))
167 if name == "" {
168 return nil, errors.New("slot 名称不能为空")
169 }
170 fn, err := rt.bindFn(call.Argument(1), "slot")
171 if err != nil {
172 return nil, err
173 }
174 rt.app.Slots[name] = append(rt.app.Slots[name], fn)
175 return nil, nil
176}
177
178func (rt *jsRuntime) regEvent(call goja.FunctionCall) (any, error) {
179 if rt.app == nil {
180 return nil, errNoApp
181 }
182 if len(call.Arguments) < 2 {
183 return nil, errors.New("clv.on(event, handler) 参数不足")
184 }
185 event := strings.TrimSpace(strOf(exportJS(call.Argument(0))))
186 if event == "" {
187 return nil, errors.New("事件名不能为空")
188 }
189 fn, err := rt.bindFn(call.Argument(1), "event")
190 if err != nil {
191 return nil, err
192 }
193 rt.app.Events[event] = append(rt.app.Events[event], fn)
194 return nil, nil
195}
196
197// emitEvent 广播自定义事件(插件间通信);订阅方在自己的 worker 中异步执行,不会死锁
198func (rt *jsRuntime) emitEvent(call goja.FunctionCall) (any, error) {
199 args := argsOf(call)
200 if len(args) == 0 || strOf(args[0]) == "" {
201 return nil, errors.New("clv.emit(event, payload?) 需要事件名")
202 }
203 payload := map[string]any{}
204 if len(args) > 1 {
205 if m, ok := args[1].(map[string]any); ok {
206 payload = m
207 }
208 }
209 Emit(strOf(args[0]), payload)
210 return nil, nil
211}
212
213func (rt *jsRuntime) regFilter(call goja.FunctionCall) (any, error) {
214 if rt.app == nil {
215 return nil, errNoApp
216 }
217 if len(call.Arguments) < 2 {
218 return nil, errors.New("clv.filter(name, handler) 或 clv.filter(name, priority, handler) 参数不足")
219 }
220 name := strings.TrimSpace(strOf(exportJS(call.Argument(0))))
221 if name == "" {
222 return nil, errors.New("过滤器名不能为空")
223 }
224 priority := 100
225 fnArg := call.Argument(1)
226 if len(call.Arguments) >= 3 {
227 priority = intOf(exportJS(call.Argument(1)))
228 fnArg = call.Argument(2)
229 } else if _, ok := goja.AssertFunction(call.Argument(1)); !ok {
230 // 只给了一个非函数参数:视为优先级缺失,报错更友好
231 return nil, errors.New("clv.filter(name, priority, handler) 的 handler 必须是函数")
232 }
233 fn, err := rt.bindFn(fnArg, "filter")
234 if err != nil {
235 return nil, err
236 }
237 rt.app.Filters[name] = append(rt.app.Filters[name], FilterReg{Fn: fn, Priority: priority})
238 return nil, nil
239}
240
241func (rt *jsRuntime) regJob(call goja.FunctionCall) (any, error) {
242 if rt.app == nil {
243 return nil, errNoApp
244 }
245 if len(call.Arguments) < 3 {
246 return nil, errors.New("clv.job(name, every, handler) 参数不足")
247 }
248 name := trimTo(strOf(exportJS(call.Argument(0))), 40)
249 every, err := parseEvery(strOf(exportJS(call.Argument(1))))
250 if err != nil {
251 return nil, err
252 }
253 fn, err := rt.bindFn(call.Argument(2), "job")
254 if err != nil {
255 return nil, err
256 }
257 if name == "" {
258 name = fn
259 }
260 rt.app.Jobs = append(rt.app.Jobs, JobReg{Name: name, Every: every, Fn: fn})
261 return nil, nil
262}
263
264func (rt *jsRuntime) regMiddleware(call goja.FunctionCall) (any, error) {
265 if rt.app == nil {
266 return nil, errNoApp
267 }
268 if len(call.Arguments) < 2 {
269 return nil, errors.New("clv.middleware(phase, handler) 参数不足")
270 }
271 phase := strings.ToLower(strings.TrimSpace(strOf(exportJS(call.Argument(0)))))
272 if phase != "http.before" && phase != "http.after" {
273 return nil, errors.New("middleware phase 仅支持 \"http.before\" 或 \"http.after\"")
274 }
275 fn, err := rt.bindFn(call.Argument(1), "mw")
276 if err != nil {
277 return nil, err
278 }
279 rt.app.Middlewares = append(rt.app.Middlewares, MiddlewareReg{Phase: phase, Fn: fn})
280 return nil, nil
281}
282
283// ---------- 视图 ----------
284
285func (rt *jsRuntime) viewTemplate(call goja.FunctionCall) (any, error) {
286 if viewProvider == nil {
287 return nil, errors.New("当前环境未装配模板渲染能力")
288 }
289 args := argsOf(call)
290 if len(args) == 0 {
291 return nil, errors.New("clv.view.template(name, data?) 需要模板名")
292 }
293 name := strings.TrimSpace(strOf(args[0]))
294 if name == "" || strings.Contains(name, "..") || strings.HasPrefix(name, "/") {
295 return nil, errors.New("非法的模板名")
296 }
297 var data any = map[string]any{}
298 if len(args) > 1 {
299 data = args[1]
300 }
301 return viewProvider.RenderTemplate(os.DirFS(rt.dir), name, data)
302}
303
304// ---------- 工具 ----------
305
306// normRelPath 归一化相对路径(保证以 / 开头、无结尾斜杠)
307func normRelPath(p string) string {
308 p = strings.TrimSpace(p)
309 if p == "" {
310 return "/"
311 }
312 p = strings.ReplaceAll(p, "\\", "/")
313 if !strings.HasPrefix(p, "/") {
314 p = "/" + p
315 }
316 for strings.Contains(p, "//") {
317 p = strings.ReplaceAll(p, "//", "/")
318 }
319 if strings.Contains(p, "..") {
320 return "/"
321 }
322 if len(p) > 1 {
323 p = strings.TrimRight(p, "/")
324 if p == "" {
325 return "/"
326 }
327 }
328 return p
329}
330
331// parseEvery 解析任务周期,最小 10 秒,避免插件写 1ms 打爆 CPU
332func parseEvery(s string) (time.Duration, error) {
333 d, err := time.ParseDuration(strings.TrimSpace(s))
334 if err != nil {
335 return 0, fmt.Errorf("无法解析周期 %q(示例:30s / 10m / 1h / 24h)", s)
336 }
337 if d < 10*time.Second {
338 return 0, errors.New("任务周期不能小于 10s")
339 }
340 return d, nil
341}
342
343// trimTo 截断到 n 个字符(按 rune,避免截断中文)
344func trimTo(s string, n int) string {
345 s = strings.TrimSpace(s)
346 r := []rune(s)
347 if len(r) > n {
348 return string(r[:n])
349 }
350 return s
351}