clearlove2.1
1// Host API:网络与其它能力(clv.http / clv.cache / clv.crypto / clv.mail / clv.media)。
2package plugin
3
4import (
5 "context"
6 "crypto/hmac"
7 "crypto/rand"
8 "crypto/sha256"
9 "encoding/base64"
10 "encoding/hex"
11 "errors"
12 "fmt"
13 "io"
14 "net"
15 "net/http"
16 "net/url"
17 "strings"
18 "sync"
19 "time"
20
21 "github.com/dop251/goja"
22
23 "clearlove/internal/mailer"
24 "clearlove/internal/models"
25 "clearlove/internal/util"
26)
27
28const (
29 maxHTTPBody = 2 << 20 // 单次出网响应上限 2MB
30 maxCacheTTL = 24 * time.Hour
31)
32
33func (rt *jsRuntime) installNet(clv *goja.Object) error {
34 // ---------- clv.http ----------
35 h := rt.vm.NewObject()
36 _ = h.Set("request", rt.jsFn(rt.httpRequest))
37 _ = clv.Set("http", h)
38
39 // ---------- clv.cache ----------
40 c := rt.vm.NewObject()
41 _ = c.Set("get", rt.jsFn(rt.cacheGet))
42 _ = c.Set("set", rt.jsFn(rt.cacheSet))
43 _ = c.Set("del", rt.jsFn(rt.cacheDel))
44 _ = clv.Set("cache", c)
45
46 // ---------- clv.crypto ----------
47 cr := rt.vm.NewObject()
48 _ = cr.Set("bcrypt", rt.jsFn(func(call goja.FunctionCall) (any, error) {
49 args := argsOf(call)
50 if len(args) == 0 {
51 return nil, errors.New("clv.crypto.bcrypt(pwd) 需要一个参数")
52 }
53 return util.HashPassword(strOf(args[0])), nil
54 }))
55 _ = cr.Set("verify", rt.jsFn(func(call goja.FunctionCall) (any, error) {
56 args := argsOf(call)
57 if len(args) < 2 {
58 return nil, errors.New("clv.crypto.verify(hash, pwd) 需要两个参数")
59 }
60 return util.CheckPassword(strOf(args[0]), strOf(args[1])), nil
61 }))
62 _ = cr.Set("hmacSha256", rt.jsFn(func(call goja.FunctionCall) (any, error) {
63 args := argsOf(call)
64 if len(args) < 2 {
65 return nil, errors.New("clv.crypto.hmacSha256(key, msg) 需要两个参数")
66 }
67 m := hmac.New(sha256.New, []byte(strOf(args[0])))
68 m.Write([]byte(strOf(args[1])))
69 return hex.EncodeToString(m.Sum(nil)), nil
70 }))
71 _ = cr.Set("randomHex", rt.jsFn(func(call goja.FunctionCall) (any, error) {
72 args := argsOf(call)
73 n := 16
74 if len(args) > 0 {
75 if v := intOf(args[0]); v > 0 && v <= 64 {
76 n = v
77 }
78 }
79 return util.RandomHex(n), nil
80 }))
81 _ = cr.Set("uuid", rt.jsFn(func(call goja.FunctionCall) (any, error) {
82 return randomUUID(), nil
83 }))
84 _ = cr.Set("base64Encode", rt.jsFn(func(call goja.FunctionCall) (any, error) {
85 args := argsOf(call)
86 if len(args) == 0 {
87 return "", nil
88 }
89 return base64.StdEncoding.EncodeToString([]byte(strOf(args[0]))), nil
90 }))
91 _ = cr.Set("base64Decode", rt.jsFn(func(call goja.FunctionCall) (any, error) {
92 args := argsOf(call)
93 if len(args) == 0 {
94 return "", nil
95 }
96 b, err := base64.StdEncoding.DecodeString(strings.TrimSpace(strOf(args[0])))
97 if err != nil {
98 return nil, errors.New("base64 解码失败")
99 }
100 return string(b), nil
101 }))
102 _ = clv.Set("crypto", cr)
103
104 // ---------- clv.mail ----------
105 m := rt.vm.NewObject()
106 _ = m.Set("send", rt.jsFn(rt.mailSend))
107 _ = clv.Set("mail", m)
108
109 // ---------- clv.media ----------
110 md := rt.vm.NewObject()
111 _ = md.Set("saveImage", rt.jsFn(rt.mediaSaveImage))
112 _ = md.Set("saveVideo", rt.jsFn(rt.mediaSaveVideo))
113 _ = clv.Set("media", md)
114
115 return nil
116}
117
118// ---------- http ----------
119
120func (rt *jsRuntime) httpRequest(call goja.FunctionCall) (any, error) {
121 if err := rt.requirePerm("net"); err != nil {
122 return nil, err
123 }
124 arg := mapOfAny(call.Argument(0))
125 if arg == nil {
126 return nil, errors.New("clv.http.request({url, method, headers, body}) 需要一个对象参数")
127 }
128 rawURL := strings.TrimSpace(strOf(arg["url"]))
129 if rawURL == "" {
130 return nil, errors.New("缺少 url")
131 }
132 // 声明 net.local 权限的插件允许访问内网/本机(例如部署在本机的 Ollama 等 AI 服务)
133 allowLocal := rt.app != nil && rt.app.Plugin.HasPermission("net.local")
134 if err := guardSSRF(rawURL, allowLocal); err != nil {
135 return nil, err
136 }
137 method := strings.ToUpper(strings.TrimSpace(strOf(arg["method"])))
138 if method == "" {
139 method = http.MethodGet
140 }
141 timeoutMS := intOf(arg["timeout_ms"])
142 if timeoutMS <= 0 || timeoutMS > 30000 {
143 timeoutMS = 10000
144 }
145 maxBytes := intOf(arg["max_bytes"])
146 if maxBytes <= 0 || maxBytes > maxHTTPBody {
147 maxBytes = 1 << 20
148 }
149
150 var body io.Reader
151 if b := strOf(arg["body"]); b != "" {
152 body = strings.NewReader(b)
153 }
154 req, err := http.NewRequest(method, rawURL, body)
155 if err != nil {
156 return nil, err
157 }
158 if hdrs, ok := arg["headers"].(map[string]any); ok {
159 for k, v := range hdrs {
160 req.Header.Set(k, strOf(v))
161 }
162 }
163 if req.Header.Get("User-Agent") == "" {
164 req.Header.Set("User-Agent", "ClearLove-Plugin/1.0")
165 }
166
167 // 拨号期校验实际解析到的 IP(防 DNS rebinding:校验与拨号共用同一次解析结果,
168 // 并直接连接已校验的 IP),重定向目标同样复查(防外站 302 跳内网绕过)。
169 transport := &http.Transport{DialContext: ssrfGuardedDial(allowLocal)}
170 client := &http.Client{
171 Timeout: time.Duration(timeoutMS) * time.Millisecond,
172 Transport: transport,
173 CheckRedirect: func(req *http.Request, via []*http.Request) error {
174 if len(via) >= 10 {
175 return errors.New("重定向次数过多")
176 }
177 return guardSSRF(req.URL.String(), allowLocal)
178 },
179 }
180 resp, err := client.Do(req)
181 if err != nil {
182 return nil, err
183 }
184 defer resp.Body.Close()
185 data, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxBytes)+1))
186 if err != nil {
187 return nil, err
188 }
189 if len(data) > maxBytes {
190 return nil, fmt.Errorf("响应体超过 %d 字节上限", maxBytes)
191 }
192 headers := make(map[string]string, len(resp.Header))
193 for k := range resp.Header {
194 headers[k] = resp.Header.Get(k)
195 }
196 return map[string]any{"status": resp.StatusCode, "headers": headers, "body": string(data)}, nil
197}
198
199// guardSSRF 拒绝内网/环回/链路本地地址,防止插件被用作内网探测跳板。
200// allowLocal 为 true(插件已声明 net.local 权限)时跳过内网校验,
201// 以支持访问部署在本机/内网的 AI 服务(Ollama、LM Studio、内网网关等)。
202func guardSSRF(rawURL string, allowLocal bool) error {
203 u, err := url.Parse(rawURL)
204 if err != nil {
205 return fmt.Errorf("URL 无法解析: %w", err)
206 }
207 if u.Scheme != "http" && u.Scheme != "https" {
208 return errors.New("仅支持 http/https 协议")
209 }
210 host := u.Hostname()
211 if host == "" {
212 return errors.New("URL 缺少主机名")
213 }
214 if allowLocal {
215 return nil
216 }
217 if ip := net.ParseIP(host); ip != nil {
218 if isPrivateIP(ip) {
219 return fmt.Errorf("禁止访问内网地址 %s", ip)
220 }
221 return nil
222 }
223 ips, err := net.LookupIP(host)
224 if err != nil {
225 return fmt.Errorf("域名解析失败: %w", err)
226 }
227 for _, ip := range ips {
228 if isPrivateIP(ip) {
229 return fmt.Errorf("禁止访问内网地址(%s 解析到 %s)", host, ip)
230 }
231 }
232 return nil
233}
234
235func isPrivateIP(ip net.IP) bool {
236 return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
237 ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast()
238}
239
240// ssrfGuardedDial 返回带内网校验的拨号函数:域名解析后逐 IP 校验,
241// 仅连接通过校验的 IP,消除"校验时公网、拨号时内网"的 DNS rebinding 窗口。
242func ssrfGuardedDial(allowLocal bool) func(ctx context.Context, network, addr string) (net.Conn, error) {
243 dialer := &net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}
244 return func(ctx context.Context, network, addr string) (net.Conn, error) {
245 host, port, err := net.SplitHostPort(addr)
246 if err != nil {
247 return nil, err
248 }
249 if allowLocal {
250 return dialer.DialContext(ctx, network, net.JoinHostPort(host, port))
251 }
252 if ip := net.ParseIP(host); ip != nil {
253 if isPrivateIP(ip) {
254 return nil, fmt.Errorf("禁止连接内网地址 %s", ip)
255 }
256 return dialer.DialContext(ctx, network, net.JoinHostPort(host, port))
257 }
258 ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
259 if err != nil {
260 return nil, err
261 }
262 var lastErr error
263 for _, ipa := range ips {
264 if isPrivateIP(ipa.IP) {
265 lastErr = fmt.Errorf("禁止连接内网地址(%s 解析到 %s)", host, ipa.IP)
266 continue
267 }
268 conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ipa.IP.String(), port))
269 if err == nil {
270 return conn, nil
271 }
272 lastErr = err
273 }
274 if lastErr == nil {
275 lastErr = fmt.Errorf("无法解析主机 %s", host)
276 }
277 return nil, lastErr
278 }
279}
280
281func randomUUID() string {
282 b := make([]byte, 16)
283 _, _ = rand.Read(b)
284 b[6] = (b[6] & 0x0f) | 0x40
285 b[8] = (b[8] & 0x3f) | 0x80
286 return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
287}
288
289// ---------- cache ----------
290
291type cacheEntry struct {
292 val any
293 exp time.Time
294}
295
296var pluginCache sync.Map
297
298func cacheKey(slug, k string) string { return slug + "\x00" + k }
299
300func (rt *jsRuntime) cacheGet(call goja.FunctionCall) (any, error) {
301 if err := rt.requirePerm("cache"); err != nil {
302 return nil, err
303 }
304 args := argsOf(call)
305 if len(args) == 0 {
306 return nil, errors.New("clv.cache.get(key) 需要一个参数")
307 }
308 key := cacheKey(rt.plugin.SlugOf(), strOf(args[0]))
309 v, ok := pluginCache.Load(key)
310 if !ok {
311 return nil, nil
312 }
313 e := v.(cacheEntry)
314 if !e.exp.IsZero() && time.Now().After(e.exp) {
315 pluginCache.Delete(key)
316 return nil, nil
317 }
318 return e.val, nil
319}
320
321func (rt *jsRuntime) cacheSet(call goja.FunctionCall) (any, error) {
322 if err := rt.requirePerm("cache"); err != nil {
323 return nil, err
324 }
325 args := argsOf(call)
326 if len(args) < 2 {
327 return nil, errors.New("clv.cache.set(key, value, ttlSec?) 至少需要两个参数")
328 }
329 ttl := time.Duration(0)
330 if len(args) > 2 {
331 if sec := intOf(args[2]); sec > 0 {
332 d := time.Duration(sec) * time.Second
333 if d > maxCacheTTL {
334 d = maxCacheTTL
335 }
336 ttl = d
337 }
338 }
339 e := cacheEntry{val: args[1]}
340 if ttl > 0 {
341 e.exp = time.Now().Add(ttl)
342 }
343 pluginCache.Store(cacheKey(rt.plugin.SlugOf(), strOf(args[0])), e)
344 return nil, nil
345}
346
347func (rt *jsRuntime) cacheDel(call goja.FunctionCall) (any, error) {
348 if err := rt.requirePerm("cache"); err != nil {
349 return nil, err
350 }
351 args := argsOf(call)
352 if len(args) == 0 {
353 return nil, errors.New("clv.cache.del(key) 需要一个参数")
354 }
355 pluginCache.Delete(cacheKey(rt.plugin.SlugOf(), strOf(args[0])))
356 return nil, nil
357}
358
359// ---------- mail ----------
360
361func (rt *jsRuntime) mailSend(call goja.FunctionCall) (any, error) {
362 if err := rt.requirePerm("mail"); err != nil {
363 return nil, err
364 }
365 args := argsOf(call)
366 if len(args) < 3 {
367 return nil, errors.New("clv.mail.send(to, subject, body) 需要三个参数")
368 }
369 host := models.GetSetting("smtp_host")
370 if host == "" {
371 return nil, errors.New("站点未配置 SMTP 邮箱服务")
372 }
373 err := mailer.Send(host, models.GetSetting("smtp_port"),
374 models.GetSetting("smtp_user"), models.GetSetting("smtp_pass"),
375 models.GetSetting("smtp_from"), strOf(args[0]), strOf(args[1]), strOf(args[2]))
376 if err != nil {
377 return nil, err
378 }
379 return true, nil
380}
381
382// ---------- media ----------
383
384func (rt *jsRuntime) mediaSaveImage(call goja.FunctionCall) (any, error) {
385 if err := rt.requirePerm("media.write"); err != nil {
386 return nil, err
387 }
388 arg := mapOfAny(call.Argument(0))
389 if arg == nil {
390 return nil, errors.New("clv.media.saveImage({name, data}) 需要一个对象参数(data 为 base64)")
391 }
392 raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(strOf(arg["data"])))
393 if err != nil {
394 return nil, errors.New("图片数据不是合法的 base64")
395 }
396 if len(raw) > 10<<20 {
397 return nil, errors.New("图片不能超过 10MB")
398 }
399 path, err := util.SaveImageBytes(raw)
400 if err != nil {
401 return nil, err
402 }
403 return path, nil
404}
405
406// mediaSaveVideo 保存视频:clv.media.saveVideo({name, data}),data 为 base64
407func (rt *jsRuntime) mediaSaveVideo(call goja.FunctionCall) (any, error) {
408 if err := rt.requirePerm("media.write"); err != nil {
409 return nil, err
410 }
411 arg := mapOfAny(call.Argument(0))
412 if arg == nil {
413 return nil, errors.New("clv.media.saveVideo({name, data}) 需要一个对象参数(data 为 base64)")
414 }
415 raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(strOf(arg["data"])))
416 if err != nil {
417 return nil, errors.New("视频数据不是合法的 base64")
418 }
419 path, err := util.SaveVideoBytes(raw, strOf(arg["name"]))
420 if err != nil {
421 return nil, err
422 }
423 return path, nil
424}