clearlove2.1
1// Host API:站点数据层(clv.setting / clv.post / clv.comment / clv.user / clv.stats)。
2//
3// 提供高层语义,避免插件手写内核 SQL;写操作需 site.write 权限。
4package plugin
5
6import (
7 "errors"
8 "strings"
9
10 "github.com/dop251/goja"
11
12 "clearlove/internal/database"
13 "clearlove/internal/models"
14 "clearlove/internal/util"
15)
16
17func (rt *jsRuntime) installSite(clv *goja.Object) error {
18 // ---------- clv.setting ----------
19 st := rt.vm.NewObject()
20 _ = st.Set("get", rt.jsFn(rt.settingGet))
21 _ = st.Set("set", rt.jsFn(rt.settingSet))
22 _ = st.Set("all", rt.jsFn(rt.settingAll))
23 _ = clv.Set("setting", st)
24
25 // ---------- clv.post ----------
26 post := rt.vm.NewObject()
27 _ = post.Set("list", rt.jsFn(rt.postList))
28 _ = post.Set("get", rt.jsFn(rt.postGet))
29 _ = post.Set("create", rt.jsFn(rt.postCreate))
30 _ = post.Set("update", rt.jsFn(rt.postUpdate))
31 _ = post.Set("remove", rt.jsFn(rt.postRemove))
32 _ = clv.Set("post", post)
33
34 // ---------- clv.comment ----------
35 cm := rt.vm.NewObject()
36 _ = cm.Set("list", rt.jsFn(rt.commentList))
37 _ = cm.Set("create", rt.jsFn(rt.commentCreate))
38 _ = cm.Set("remove", rt.jsFn(rt.commentRemove))
39 _ = clv.Set("comment", cm)
40
41 // ---------- clv.user ----------
42 u := rt.vm.NewObject()
43 _ = u.Set("get", rt.jsFn(rt.userGet))
44 _ = u.Set("byName", rt.jsFn(rt.userByName))
45 _ = u.Set("current", rt.jsFn(rt.userCurrent))
46 _ = clv.Set("user", u)
47
48 // ---------- clv.stats ----------
49 stat := rt.vm.NewObject()
50 _ = stat.Set("overview", rt.jsFn(rt.statsOverview))
51 _ = stat.Set("today", rt.jsFn(rt.statsToday))
52 _ = clv.Set("stats", stat)
53
54 return nil
55}
56
57// requireAnyPerm 任一权限满足即可(用于读类 API 的宽松校验)
58func (rt *jsRuntime) requireAnyPerm(perms ...string) error {
59 if rt.app == nil {
60 return errNoApp
61 }
62 for _, p := range perms {
63 if rt.app.Plugin.HasPermission(p) {
64 return nil
65 }
66 }
67 return &permError{slug: rt.app.Plugin.SlugOf(), perm: strings.Join(perms, " / ")}
68}
69
70func (rt *jsRuntime) needDB() error {
71 if database.DB == nil {
72 return errors.New("数据库未连接")
73 }
74 return nil
75}
76
77// ---------- setting ----------
78
79func (rt *jsRuntime) settingGet(call goja.FunctionCall) (any, error) {
80 if err := rt.requirePerm("settings.read"); err != nil {
81 return nil, err
82 }
83 args := argsOf(call)
84 if len(args) == 0 {
85 return nil, errors.New("clv.setting.get(key) 需要一个参数")
86 }
87 return models.GetSetting(strOf(args[0])), nil
88}
89
90func (rt *jsRuntime) settingSet(call goja.FunctionCall) (any, error) {
91 if err := rt.requirePerm("settings.write"); err != nil {
92 return nil, err
93 }
94 args := argsOf(call)
95 if len(args) < 2 {
96 return nil, errors.New("clv.setting.set(key, value) 需要两个参数")
97 }
98 return nil, models.SetSetting(strOf(args[0]), strOf(args[1]))
99}
100
101func (rt *jsRuntime) settingAll(call goja.FunctionCall) (any, error) {
102 if err := rt.requirePerm("settings.read"); err != nil {
103 return nil, err
104 }
105 if err := rt.needDB(); err != nil {
106 return nil, err
107 }
108 rows, err := queryMaps(database.DB, "SELECT k, v FROM settings", nil)
109 if err != nil {
110 return nil, err
111 }
112 out := make(map[string]string, len(rows))
113 for _, r := range rows {
114 out[strOf(r["k"])] = strOf(r["v"])
115 }
116 return out, nil
117}
118
119// ---------- post ----------
120
121func (rt *jsRuntime) postList(call goja.FunctionCall) (any, error) {
122 if err := rt.requirePerm("site.read"); err != nil {
123 return nil, err
124 }
125 if err := rt.needDB(); err != nil {
126 return nil, err
127 }
128 arg := mapOfAny(call.Argument(0))
129 limit := intOf(arg["limit"])
130 if limit <= 0 || limit > 50 {
131 limit = 10
132 }
133 before := int64(intOf(arg["before"]))
134 topic := strOf(arg["topic"])
135 withHidden := boolOf(arg["include_hidden"])
136 q := `SELECT p.id, p.user_id, IFNULL(p.nickname,'匿名') AS nickname, IFNULL(p.content,'') AS content,
137 IFNULL(t.name,'') AS topic, p.status, p.like_count, p.comment_count, IFNULL(p.created_at,'') AS created_at
138 FROM posts p LEFT JOIN topics t ON t.id=p.topic_id
139 WHERE (? <= 0 OR p.id < ?)`
140 args := []any{before, before}
141 if !withHidden {
142 q += " AND p.status=1"
143 }
144 if topic != "" {
145 q += " AND t.name=?"
146 args = append(args, topic)
147 }
148 q += " ORDER BY p.id DESC LIMIT ?"
149 args = append(args, limit)
150 return queryMaps(database.DB, q, args)
151}
152
153func (rt *jsRuntime) postGet(call goja.FunctionCall) (any, error) {
154 if err := rt.requirePerm("site.read"); err != nil {
155 return nil, err
156 }
157 if err := rt.needDB(); err != nil {
158 return nil, err
159 }
160 args := argsOf(call)
161 if len(args) == 0 {
162 return nil, errors.New("clv.post.get(id) 需要一个参数")
163 }
164 rows, err := queryMaps(database.DB,
165 `SELECT p.id, p.user_id, IFNULL(p.nickname,'匿名') AS nickname, IFNULL(p.content,'') AS content,
166 p.topic_id, IFNULL(t.name,'') AS topic, p.status, p.like_count, p.comment_count,
167 IFNULL(p.is_admin,0) AS is_admin, IFNULL(p.created_at,'') AS created_at
168 FROM posts p LEFT JOIN topics t ON t.id=p.topic_id WHERE p.id=?`, []any{int64(intOf(args[0]))})
169 if err != nil {
170 return nil, err
171 }
172 if len(rows) == 0 {
173 return nil, nil
174 }
175 return rows[0], nil
176}
177
178func (rt *jsRuntime) postCreate(call goja.FunctionCall) (any, error) {
179 if err := rt.requirePerm("site.write"); err != nil {
180 return nil, err
181 }
182 if err := rt.needDB(); err != nil {
183 return nil, err
184 }
185 arg := mapOfAny(call.Argument(0))
186 if arg == nil {
187 return nil, errors.New("clv.post.create({content, nickname, topic}) 需要一个对象参数")
188 }
189 content := util.StripHTML(strOf(arg["content"]))
190 if content == "" || len([]rune(content)) > 3000 {
191 return nil, errors.New("帖子内容需为 1-3000 字")
192 }
193 nickname := util.StripHTML(strOf(arg["nickname"]))
194 if nickname == "" {
195 nickname = "匿名"
196 }
197 topicID := rt.ensureTopic(util.StripHTML(strOf(arg["topic"])))
198 res, err := database.DB.Exec(
199 "INSERT INTO posts(user_id,nickname,content,topic_id,ip,fingerprint,status,is_admin,badges,created_at) VALUES(?,?,?,?,?,?,1,0,'',?)",
200 0, nickname, content, topicID, "", "", models.Now())
201 if err != nil {
202 return nil, err
203 }
204 id, _ := res.LastInsertId()
205 Notify("post_created", map[string]any{"post_id": id, "nickname": nickname, "content": content})
206 return map[string]any{"id": id}, nil
207}
208
209func (rt *jsRuntime) postUpdate(call goja.FunctionCall) (any, error) {
210 if err := rt.requirePerm("site.write"); err != nil {
211 return nil, err
212 }
213 if err := rt.needDB(); err != nil {
214 return nil, err
215 }
216 args := argsOf(call)
217 if len(args) < 2 {
218 return nil, errors.New("clv.post.update(id, {content}) 需要两个参数")
219 }
220 id := int64(intOf(args[0]))
221 arg, _ := args[1].(map[string]any)
222 content := util.StripHTML(strOf(arg["content"]))
223 if content == "" || len([]rune(content)) > 3000 {
224 return nil, errors.New("帖子内容需为 1-3000 字")
225 }
226 _, err := database.DB.Exec("UPDATE posts SET content=? WHERE id=?", content, id)
227 return nil, err
228}
229
230func (rt *jsRuntime) postRemove(call goja.FunctionCall) (any, error) {
231 if err := rt.requirePerm("site.write"); err != nil {
232 return nil, err
233 }
234 if err := rt.needDB(); err != nil {
235 return nil, err
236 }
237 args := argsOf(call)
238 if len(args) == 0 {
239 return nil, errors.New("clv.post.remove(id) 需要一个参数")
240 }
241 id := int64(intOf(args[0]))
242 for _, t := range []string{"comments", "medias", "likes", "reports"} {
243 _, _ = database.DB.Exec("DELETE FROM "+t+" WHERE post_id=?", id)
244 }
245 _, err := database.DB.Exec("DELETE FROM posts WHERE id=?", id)
246 return nil, err
247}
248
249func (rt *jsRuntime) ensureTopic(name string) int64 {
250 name = strings.TrimSpace(name)
251 if name == "" || len([]rune(name)) > 30 {
252 return 0
253 }
254 if id := models.QueryInt("SELECT id FROM topics WHERE name=?", name); id > 0 {
255 return id
256 }
257 res, err := database.DB.Exec("INSERT INTO topics(name,created_at) VALUES(?,?)", name, models.Now())
258 if err != nil {
259 return 0
260 }
261 id, _ := res.LastInsertId()
262 return id
263}
264
265// ---------- comment ----------
266
267func (rt *jsRuntime) commentList(call goja.FunctionCall) (any, error) {
268 if err := rt.requirePerm("site.read"); err != nil {
269 return nil, err
270 }
271 if err := rt.needDB(); err != nil {
272 return nil, err
273 }
274 args := argsOf(call)
275 if len(args) == 0 {
276 return nil, errors.New("clv.comment.list(postId) 需要一个参数")
277 }
278 return queryMaps(database.DB,
279 `SELECT id, post_id, IFNULL(nickname,'匿名') AS nickname, IFNULL(content,'') AS content, IFNULL(created_at,'') AS created_at
280 FROM comments WHERE post_id=? ORDER BY id`, []any{int64(intOf(args[0]))})
281}
282
283func (rt *jsRuntime) commentCreate(call goja.FunctionCall) (any, error) {
284 if err := rt.requirePerm("site.write"); err != nil {
285 return nil, err
286 }
287 if err := rt.needDB(); err != nil {
288 return nil, err
289 }
290 arg := mapOfAny(call.Argument(0))
291 if arg == nil {
292 return nil, errors.New("clv.comment.create({post_id, content, nickname}) 需要一个对象参数")
293 }
294 postID := int64(intOf(arg["post_id"]))
295 if models.QueryInt("SELECT COUNT(1) FROM posts WHERE id=? AND status=1", postID) == 0 {
296 return nil, errors.New("帖子不存在或不可见")
297 }
298 content := util.StripHTML(strOf(arg["content"]))
299 if content == "" || len([]rune(content)) > 500 {
300 return nil, errors.New("评论需为 1-500 字")
301 }
302 nickname := util.StripHTML(strOf(arg["nickname"]))
303 if nickname == "" {
304 nickname = "匿名"
305 }
306 res, err := database.DB.Exec(
307 "INSERT INTO comments(post_id,user_id,nickname,content,ip,fingerprint,created_at) VALUES(?,0,?,?,'','',?)",
308 postID, nickname, content, models.Now())
309 if err != nil {
310 return nil, err
311 }
312 _, _ = database.DB.Exec("UPDATE posts SET comment_count=comment_count+1 WHERE id=?", postID)
313 id, _ := res.LastInsertId()
314 Notify("comment_created", map[string]any{"post_id": postID, "content": content})
315 return map[string]any{"id": id}, nil
316}
317
318func (rt *jsRuntime) commentRemove(call goja.FunctionCall) (any, error) {
319 if err := rt.requirePerm("site.write"); err != nil {
320 return nil, err
321 }
322 if err := rt.needDB(); err != nil {
323 return nil, err
324 }
325 args := argsOf(call)
326 if len(args) == 0 {
327 return nil, errors.New("clv.comment.remove(id) 需要一个参数")
328 }
329 _, err := database.DB.Exec("DELETE FROM comments WHERE id=?", int64(intOf(args[0])))
330 return nil, err
331}
332
333// ---------- user ----------
334
335func userPublicMap(u *models.User) map[string]any {
336 if u == nil {
337 return nil
338 }
339 return map[string]any{
340 "id": u.ID, "username": u.Username, "avatar": u.Avatar,
341 "status": u.Status, "created_at": u.CreatedAt,
342 }
343}
344
345func (rt *jsRuntime) userGet(call goja.FunctionCall) (any, error) {
346 if err := rt.requirePerm("site.read"); err != nil {
347 return nil, err
348 }
349 args := argsOf(call)
350 if len(args) == 0 {
351 return nil, errors.New("clv.user.get(id) 需要一个参数")
352 }
353 return userPublicMap(models.GetUserByID(int64(intOf(args[0])))), nil
354}
355
356func (rt *jsRuntime) userByName(call goja.FunctionCall) (any, error) {
357 if err := rt.requirePerm("site.read"); err != nil {
358 return nil, err
359 }
360 if err := rt.needDB(); err != nil {
361 return nil, err
362 }
363 args := argsOf(call)
364 if len(args) == 0 {
365 return nil, errors.New("clv.user.byName(name) 需要一个参数")
366 }
367 var id int64
368 if err := database.DB.QueryRow("SELECT id FROM users WHERE username=?", strOf(args[0])).Scan(&id); err != nil {
369 return nil, nil
370 }
371 return userPublicMap(models.GetUserByID(id)), nil
372}
373
374// userCurrent 返回当前请求的登录用户(由路由层在调用前注入)
375func (rt *jsRuntime) userCurrent(call goja.FunctionCall) (any, error) {
376 if err := rt.requirePerm("site.read"); err != nil {
377 return nil, err
378 }
379 if rt.cur == nil || rt.cur.UserID == 0 {
380 return nil, nil
381 }
382 return userPublicMap(models.GetUserByID(rt.cur.UserID)), nil
383}
384
385// ---------- stats ----------
386
387func (rt *jsRuntime) statsOverview(call goja.FunctionCall) (any, error) {
388 if err := rt.requirePerm("site.read"); err != nil {
389 return nil, err
390 }
391 if err := rt.needDB(); err != nil {
392 return nil, err
393 }
394 return map[string]any{
395 "users": models.QueryInt("SELECT COUNT(1) FROM users"),
396 "posts": models.QueryInt("SELECT COUNT(1) FROM posts"),
397 "comments": models.QueryInt("SELECT COUNT(1) FROM comments"),
398 "reports_pending": models.QueryInt("SELECT COUNT(1) FROM reports WHERE status=0"),
399 }, nil
400}
401
402func (rt *jsRuntime) statsToday(call goja.FunctionCall) (any, error) {
403 if err := rt.requirePerm("site.read"); err != nil {
404 return nil, err
405 }
406 if err := rt.needDB(); err != nil {
407 return nil, err
408 }
409 today := models.Now()[:10] + "T00:00:00Z"
410 return map[string]any{
411 "posts": models.QueryInt("SELECT COUNT(1) FROM posts WHERE created_at >= ?", today),
412 "comments": models.QueryInt("SELECT COUNT(1) FROM comments WHERE created_at >= ?", today),
413 "users": models.QueryInt("SELECT COUNT(1) FROM users WHERE created_at >= ?", today),
414 }, nil
415}