clearlove2.1
1// 后台管理:登录、仪表盘、帖子管理(封禁)、网站设置、主题、举报与AI巡查、
2// 用户、管理员(角色权限组)、公告与话题与守则、插件、API Key、关于、检查更新。
3package handlers
4
5import (
6 "archive/zip"
7 "bytes"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "html/template"
12 "io"
13 "io/fs"
14 "mime/multipart"
15 "net/http"
16 "os"
17 "path/filepath"
18 "runtime"
19 "strings"
20 "time"
21
22 "clearlove/internal/ai"
23 "clearlove/internal/config"
24 "clearlove/internal/database"
25 "clearlove/internal/models"
26 "clearlove/internal/plugin"
27 "clearlove/internal/util"
28)
29
30// ---------- 登录 ----------
31
32// AdminLoginPage 后台登录页
33func AdminLoginPage(w http.ResponseWriter, r *http.Request) {
34 if CurrentAdmin(r) != nil {
35 http.Redirect(w, r, "/admin", http.StatusFound)
36 return
37 }
38 Render(w, r, "admin_layout.html", "pg_admin_login", map[string]any{"err": r.URL.Query().Get("err")})
39}
40
41// AdminLoginSubmit 管理员登录(12 小时会话)
42func AdminLoginSubmit(w http.ResponseWriter, r *http.Request) {
43 ip := util.ClientIP(r)
44 // 与发帖守卫共用失败锁定:同一 IP 连续失败 5 次锁 10 分钟,防止后台账号被在线爆破
45 if adminTryBlocked("login:" + ip) {
46 Render(w, r, "admin_layout.html", "pg_admin_login", map[string]any{"err": "失败次数过多,请 10 分钟后再试"})
47 return
48 }
49 username := strings.TrimSpace(r.FormValue("username"))
50 password := r.FormValue("password")
51 var id int64
52 var hash string
53 err := database.DB.QueryRow("SELECT id,password FROM admins WHERE username=?", username).Scan(&id, &hash)
54 if err != nil || !util.CheckPassword(hash, password) {
55 adminTryFail("login:" + ip)
56 Render(w, r, "admin_layout.html", "pg_admin_login", map[string]any{"err": "用户名或密码错误"})
57 return
58 }
59 adminTryReset("login:" + ip)
60 http.SetCookie(w, &http.Cookie{
61 Name: "clv_admin", Value: util.SignSession(id, 12*time.Hour), Path: "/",
62 MaxAge: 12 * 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode,
63 })
64 http.Redirect(w, r, "/admin", http.StatusSeeOther)
65}
66
67// AdminLogout 管理员登出
68func AdminLogout(w http.ResponseWriter, r *http.Request) {
69 http.SetCookie(w, &http.Cookie{Name: "clv_admin", Value: "", Path: "/", MaxAge: -1})
70 http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
71}
72
73// ---------- 仪表盘 ----------
74
75// AdminDashboard 统计:用户数、帖子数、今日新增、近7天活跃(发帖/评论去重身份)
76func AdminDashboard(w http.ResponseWriter, r *http.Request) {
77 a := requireAdmin(w, r, "")
78 if a == nil {
79 return
80 }
81 today := time.Now().UTC().Format("2006-01-02")
82 week := time.Now().UTC().AddDate(0, 0, -7).Format(time.RFC3339)
83 // 近 7 天活跃:按 (user_id, ip) 组合去重(UNION 语法两种数据库通用)
84 active := models.QueryInt(`
85 SELECT COUNT(1) FROM (
86 SELECT user_id, ip FROM posts WHERE created_at >= ?
87 UNION
88 SELECT user_id, ip FROM comments WHERE created_at >= ?
89 ) t`, week, week)
90 data := map[string]any{
91 "users": models.QueryInt("SELECT COUNT(1) FROM users"),
92 "posts": models.QueryInt("SELECT COUNT(1) FROM posts"),
93 "today": models.QueryInt("SELECT COUNT(1) FROM posts WHERE created_at >= ?", today+"T00:00:00Z"),
94 "active7": active,
95 "comments": models.QueryInt("SELECT COUNT(1) FROM comments"),
96 "reports": models.QueryInt("SELECT COUNT(1) FROM reports WHERE status=0"),
97 }
98 // 应用型插件可改写仪表盘统计(filter.admin.stats)
99 if plugin.HasApps() {
100 if out := plugin.ApplyFilterValue("filter.admin.stats", data, nil); out != nil {
101 if m, ok := out.(map[string]any); ok {
102 data = m
103 }
104 }
105 }
106 Render(w, r, "admin_layout.html", "pg_dashboard", data)
107}
108
109// ---------- 帖子管理 ----------
110
111// AdminPosts 帖子列表(分页 + 封禁操作入口)
112func AdminPosts(w http.ResponseWriter, r *http.Request) {
113 if a := requireAdmin(w, r, models.PermPosts); a == nil {
114 return
115 }
116 page := int(formInt64(r, "page"))
117 if page < 1 {
118 page = 1
119 }
120 const size = 20
121 total := models.QueryInt("SELECT COUNT(1) FROM posts")
122 rows, err := database.DB.Query(`
123 SELECT p.id, IFNULL(p.nickname,'匿名'), IFNULL(p.content,''), IFNULL(p.ip,''), IFNULL(p.fingerprint,''), p.status,
124 p.like_count, p.comment_count, IFNULL(p.created_at,''), IFNULL(t.name,'')
125 FROM posts p LEFT JOIN topics t ON t.id=p.topic_id
126 ORDER BY p.id DESC LIMIT ? OFFSET ?`, size, (page-1)*size)
127 if err != nil {
128 http.Error(w, "查询失败", http.StatusInternalServerError)
129 return
130 }
131 defer rows.Close()
132 type row struct {
133 ID int64
134 Nickname string
135 Content string
136 IP string
137 Fingerprint string
138 Status int64
139 LikeCount int64
140 CommentCount int64
141 CreatedAt string
142 Topic string
143 }
144 var posts []row
145 for rows.Next() {
146 var it row
147 _ = rows.Scan(&it.ID, &it.Nickname, &it.Content, &it.IP, &it.Fingerprint, &it.Status,
148 &it.LikeCount, &it.CommentCount, &it.CreatedAt, &it.Topic)
149 posts = append(posts, it)
150 }
151 pages := int(total)/size + 1
152 Render(w, r, "admin_layout.html", "pg_admin_posts", map[string]any{
153 "posts": posts, "page": page, "pages": pages, "total": total,
154 })
155}
156
157// AdminPostDelete 删除指定帖子
158func AdminPostDelete(w http.ResponseWriter, r *http.Request) {
159 if a := requireAdmin(w, r, models.PermPosts); a == nil {
160 return
161 }
162 deletePostCascade(formInt64(r, "id"))
163 if isAJAX(r) {
164 okJSON(w, nil)
165 return
166 }
167 http.Redirect(w, r, "/admin/posts", http.StatusSeeOther)
168}
169
170// AdminPostBan 按 IP / 浏览器指纹封禁发帖者
171func AdminPostBan(w http.ResponseWriter, r *http.Request) {
172 if a := requireAdmin(w, r, models.PermPosts); a == nil {
173 return
174 }
175 id := formInt64(r, "id")
176 kind := r.FormValue("btype") // ip | fingerprint | both
177 var ip, fp string
178 _ = database.DB.QueryRow("SELECT ip, IFNULL(fingerprint,'') FROM posts WHERE id=?", id).Scan(&ip, &fp)
179 if kind == "ip" || kind == "both" {
180 if ip != "" {
181 addBan("ip", ip)
182 }
183 }
184 if kind == "fingerprint" || kind == "both" {
185 if fp != "" {
186 addBan("fingerprint", fp)
187 }
188 }
189 if isAJAX(r) {
190 okJSON(w, nil)
191 return
192 }
193 http.Redirect(w, r, "/admin/posts", http.StatusSeeOther)
194}
195
196func addBan(btype, value string) {
197 _, _ = database.DB.Exec(
198 "INSERT INTO bans(btype,bvalue,created_at) SELECT ?,?,? WHERE NOT EXISTS"+
199 " (SELECT 1 FROM bans WHERE btype=? AND bvalue=?)",
200 btype, value, models.Now(), btype, value)
201}
202
203// ---------- 网站设置 ----------
204
205// AdminSettings 设置页(基本信息/主题/开关/SMTP/AI)
206func AdminSettings(w http.ResponseWriter, r *http.Request) {
207 if a := requireAdmin(w, r, models.PermSetting); a == nil {
208 return
209 }
210 keys := []string{"site_name", "theme", "theme_bg", "allow_register", "require_login_post",
211 "smtp_host", "smtp_port", "smtp_user", "smtp_pass", "smtp_from",
212 "ai_enabled", "ai_base", "ai_key", "ai_model", "ai_autopatrol", "ai_precheck",
213 "ai_vision", "ai_precheck_fail", "ai_auto_delete", "ai_auto_ban",
214 "update_url", "donate_img", "cloud_api", "admin_nicknames"}
215 s := map[string]string{}
216 for _, k := range keys {
217 s[k] = models.GetSetting(k)
218 }
219 Render(w, r, "admin_layout.html", "pg_admin_settings", map[string]any{
220 "s": s, "themes": themeList(),
221 })
222}
223
224// AdminSettingsSave 保存设置表单
225func AdminSettingsSave(w http.ResponseWriter, r *http.Request) {
226 if a := requireAdmin(w, r, models.PermSetting); a == nil {
227 return
228 }
229 textKeys := []string{"site_name", "theme_bg", "smtp_host", "smtp_port", "smtp_user",
230 "smtp_pass", "smtp_from", "ai_base", "ai_key", "ai_model", "update_url", "donate_img", "cloud_api", "admin_nicknames"}
231 vals := map[string]string{}
232 for _, k := range textKeys {
233 // ai_key 允许清空(清空后 AI 即不可用,需重新填写)
234 if v := r.FormValue(k); v != "" || k == "theme_bg" || k == "smtp_pass" || k == "ai_key" {
235 vals[k] = strings.TrimSpace(v)
236 }
237 }
238 // 应用型插件可校验/改写即将写入的设置(filter.settings.save)
239 if plugin.HasApps() {
240 if out := plugin.ApplyFilterValue("filter.settings.save", vals, nil); out != nil {
241 if m, ok := out.(map[string]any); ok {
242 vals = map[string]string{}
243 for k, v := range m {
244 vals[k] = strOfAny(v)
245 }
246 }
247 }
248 }
249 for k, v := range vals {
250 _ = models.SetSetting(k, v)
251 }
252 for _, k := range []string{"allow_register", "require_login_post", "ai_enabled", "ai_autopatrol", "ai_precheck",
253 "ai_vision", "ai_auto_delete", "ai_auto_ban"} {
254 v := "0"
255 if r.FormValue(k) == "1" {
256 v = "1"
257 }
258 _ = models.SetSetting(k, v)
259 }
260 // 预审失败策略:勾选=拦截发布(closed),默认放行(open)
261 failMode := "open"
262 if r.FormValue("ai_precheck_fail") == "closed" {
263 failMode = "closed"
264 }
265 _ = models.SetSetting("ai_precheck_fail", failMode)
266 if v := r.FormValue("theme"); v != "" {
267 _ = models.SetSetting("theme", v)
268 }
269 if isAJAX(r) {
270 okJSON(w, nil)
271 return
272 }
273 http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
274}
275
276// ---------- 主题 ----------
277
278// themeList 内置 default + data/themes 下的主题目录
279func themeList() []map[string]any {
280 out := []map[string]any{{"name": "default", "label": "默认主题"}}
281 entries, err := os.ReadDir(filepath.Join(config.Cfg.DataDir, "themes"))
282 if err != nil {
283 return out
284 }
285 for _, e := range entries {
286 if e.IsDir() {
287 out = append(out, map[string]any{"name": e.Name(), "label": e.Name()})
288 }
289 }
290 return out
291}
292
293// AdminThemeUpload 上传主题 zip(内含 theme.json 与 style.css)
294func AdminThemeUpload(w http.ResponseWriter, r *http.Request) {
295 if a := requireAdmin(w, r, models.PermSetting); a == nil {
296 return
297 }
298 f, _, err := r.FormFile("file")
299 if err != nil {
300 http.Error(w, "请选择主题压缩包", http.StatusBadRequest)
301 return
302 }
303 defer f.Close()
304 data, _ := io.ReadAll(io.LimitReader(f, 50<<20))
305 zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
306 if err != nil {
307 http.Error(w, "无效的 zip 包", http.StatusBadRequest)
308 return
309 }
310 // 读取 theme.json 中的 name 作为目录名
311 name := ""
312 for _, zf := range zr.File {
313 if filepath.Base(zf.Name) == "theme.json" {
314 rc, _ := zf.Open()
315 b, _ := io.ReadAll(io.LimitReader(rc, 1<<20))
316 rc.Close()
317 var tj struct {
318 Name string `json:"name"`
319 }
320 if json.Unmarshal(b, &tj) == nil {
321 name = tj.Name
322 }
323 break
324 }
325 }
326 if name == "" {
327 http.Error(w, "theme.json 缺失或缺少 name 字段", http.StatusBadRequest)
328 return
329 }
330 dir := filepath.Join(config.Cfg.DataDir, "themes", filepath.Base(name))
331 _ = os.MkdirAll(dir, 0o755)
332 for _, zf := range zr.File {
333 clean := filepath.Clean(zf.Name)
334 if strings.HasPrefix(clean, "..") {
335 continue
336 }
337 dst := filepath.Join(dir, clean)
338 if zf.FileInfo().IsDir() {
339 _ = os.MkdirAll(dst, 0o755)
340 continue
341 }
342 _ = os.MkdirAll(filepath.Dir(dst), 0o755)
343 rc, err := zf.Open()
344 if err != nil {
345 continue
346 }
347 out, err := os.Create(dst)
348 if err == nil {
349 _, _ = io.Copy(out, io.LimitReader(rc, 50<<20))
350 out.Close()
351 }
352 rc.Close()
353 }
354 http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
355}
356
357// AdminThemeSwitch 切换主题
358func AdminThemeSwitch(w http.ResponseWriter, r *http.Request) {
359 if a := requireAdmin(w, r, models.PermSetting); a == nil {
360 return
361 }
362 _ = models.SetSetting("theme", filepath.Base(r.FormValue("name")))
363 http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
364}
365
366// AdminThemeDelete 删除自定义主题
367func AdminThemeDelete(w http.ResponseWriter, r *http.Request) {
368 if a := requireAdmin(w, r, models.PermSetting); a == nil {
369 return
370 }
371 name := filepath.Base(r.FormValue("name"))
372 // 防路径穿越:拒绝 . / .. 等非法目录名,并确保解析后仍在 themes 目录内
373 if name != "default" && name != "." && name != ".." && !strings.HasPrefix(name, ".") {
374 themesDir := filepath.Clean(filepath.Join(config.Cfg.DataDir, "themes"))
375 target := filepath.Clean(filepath.Join(themesDir, name))
376 if target != themesDir && strings.HasPrefix(target, themesDir+string(os.PathSeparator)) {
377 _ = os.RemoveAll(target)
378 if models.GetSetting("theme") == name {
379 _ = models.SetSetting("theme", "default")
380 }
381 }
382 }
383 http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
384}
385
386// ThemeCSS 动态输出当前主题 CSS(变量 + 样式文件)
387func ThemeCSS(w http.ResponseWriter, r *http.Request) {
388 w.Header().Set("Content-Type", "text/css; charset=utf-8")
389 name := models.GetSetting("theme")
390 if name == "" {
391 name = "default"
392 }
393 var css string
394 if name != "default" {
395 if b, err := os.ReadFile(filepath.Join(config.Cfg.DataDir, "themes", name, "style.css")); err == nil {
396 css = string(b)
397 }
398 }
399 if css == "" {
400 css = embeddedThemeCSS() // 主题缺失/损坏时回退默认主题
401 }
402 // 主题背景:后台设置的主题背景图优先
403 if bg := models.GetSetting("theme_bg"); bg != "" {
404 css = fmt.Sprintf(":root{--clv-bg-image:url('%s');}\n", bg) + css
405 }
406 // 应用型插件可追加/改写全站样式(filter.theme.css)
407 css = plugin.ApplyFilterStr("filter.theme.css", css)
408 _, _ = w.Write([]byte(css))
409}
410
411// embeddedThemeCSS 内置默认主题
412func embeddedThemeCSS() string {
413 if StaticFS == nil {
414 return ""
415 }
416 b, err := fs.ReadFile(StaticFS, "theme-default.css")
417 if err != nil {
418 return ""
419 }
420 return string(b)
421}
422
423// ---------- 举报管理与 AI ----------
424
425// AdminReports 举报列表 + AI 日志 + 巡查入口
426func AdminReports(w http.ResponseWriter, r *http.Request) {
427 if a := requireAdmin(w, r, models.PermReports); a == nil {
428 return
429 }
430 // 注意:巡查只能由 POST /admin/reports/patrol 触发。
431 // 历史版本的 ?run=1 会在每次刷新时重复触发,已移除该行为。
432 type rep struct {
433 ID int64
434 PostID int64
435 Reason string
436 Status int64
437 AIResult string
438 CreatedAt string
439 Content string
440 PostExists bool
441 }
442 rows, err := database.DB.Query(`
443 SELECT r.id, r.post_id, IFNULL(r.reason,''), r.status, IFNULL(r.ai_result,''), r.created_at,
444 IFNULL(p.content,''), (p.id IS NOT NULL)
445 FROM reports r LEFT JOIN posts p ON p.id=r.post_id ORDER BY r.id DESC LIMIT 200`)
446 if err != nil {
447 http.Error(w, "查询失败", http.StatusInternalServerError)
448 return
449 }
450 var reports []rep
451 for rows.Next() {
452 var it rep
453 _ = rows.Scan(&it.ID, &it.PostID, &it.Reason, &it.Status, &it.AIResult, &it.CreatedAt, &it.Content, &it.PostExists)
454 reports = append(reports, it)
455 }
456 rows.Close()
457 type logRow struct {
458 ID int64
459 PostID int64
460 Action string
461 Reason string
462 Target string
463 CreatedAt string
464 }
465 var logs []logRow
466 lrows, err := database.DB.Query("SELECT id,post_id,action,IFNULL(reason,''),IFNULL(target,'post'),created_at FROM ai_logs ORDER BY id DESC LIMIT 100")
467 if err == nil {
468 for lrows.Next() {
469 var it logRow
470 _ = lrows.Scan(&it.ID, &it.PostID, &it.Action, &it.Reason, &it.Target, &it.CreatedAt)
471 logs = append(logs, it)
472 }
473 lrows.Close()
474 }
475 // 非 JS 提交后的错误提示
476 errMsg := ""
477 switch r.URL.Query().Get("err") {
478 case "noai":
479 errMsg = "尚未接入 AI 模型,请先在「网站设置 → AI 模型接入」中完成配置"
480 case "running":
481 errMsg = "已有一轮 AI 巡查正在进行中,请等待完成后再试"
482 }
483 Render(w, r, "admin_layout.html", "pg_admin_reports", map[string]any{
484 "reports": reports, "logs": logs,
485 "aiOn": models.GetSetting("ai_enabled") == "1",
486 "patrol": models.GetSetting("ai_autopatrol") == "1",
487 "precheck": models.GetSetting("ai_precheck") == "1",
488 "aiReady": ai.Enabled(), // AI 配置完整可用
489 "progress": ai.ProgressSnapshot(), // 进入页面时若巡查已在跑,直接展示进度
490 "errMsg": errMsg,
491 })
492}
493
494// AdminPatrolStart 手动启动一轮 AI 巡查(异步执行,前端轮询进度展示动画)。
495// 支持两种调用方式:
496// - AJAX(前端 fetch):返回 JSON,页面用动画展示进度
497// - 普通表单 POST(无 JS 兜底):303 重定向回举报页,避免刷新页面重复触发
498func AdminPatrolStart(w http.ResponseWriter, r *http.Request) {
499 if a := requireAdmin(w, r, models.PermReports); a == nil {
500 return
501 }
502 back := func(errCode string) {
503 target := "/admin/reports"
504 if errCode != "" {
505 target += "?err=" + errCode
506 }
507 http.Redirect(w, r, target, http.StatusSeeOther)
508 }
509 if !ai.Enabled() {
510 if isAJAX(r) {
511 fail(w, 400, "尚未接入 AI 模型,请先在「网站设置 → AI 模型接入」中完成配置")
512 return
513 }
514 back("noai")
515 return
516 }
517 if !ai.StartAsync() {
518 if isAJAX(r) {
519 fail(w, 409, "已有一轮巡查正在进行中,请稍候")
520 return
521 }
522 back("running")
523 return
524 }
525 if isAJAX(r) {
526 okJSON(w, nil)
527 return
528 }
529 back("")
530}
531
532// AdminPatrolStatus 巡查进度查询(前端加载动画轮询)
533func AdminPatrolStatus(w http.ResponseWriter, r *http.Request) {
534 if a := requireAdmin(w, r, models.PermReports); a == nil {
535 return
536 }
537 p := ai.ProgressSnapshot()
538 percent := 0
539 if p.Total > 0 {
540 percent = p.Done * 100 / p.Total
541 } else if !p.Running {
542 percent = 100
543 }
544 okJSON(w, map[string]any{"progress": p, "percent": percent})
545}
546
547// AdminReportHandle 人工处理举报(keep/hide/delete)
548func AdminReportHandle(w http.ResponseWriter, r *http.Request) {
549 if a := requireAdmin(w, r, models.PermReports); a == nil {
550 return
551 }
552 id := formInt64(r, "id")
553 action := r.FormValue("action")
554 var postID int64
555 if err := database.DB.QueryRow("SELECT post_id FROM reports WHERE id=?", id).Scan(&postID); err != nil {
556 http.Error(w, "举报不存在", http.StatusNotFound)
557 return
558 }
559 switch action {
560 case "hide":
561 _, _ = database.DB.Exec("UPDATE posts SET status=0 WHERE id=?", postID)
562 case "delete":
563 deletePostCascade(postID)
564 }
565 _, _ = database.DB.Exec("UPDATE reports SET status=1, ai_result=? WHERE id=?", "人工处理: "+action, id)
566 _, _ = database.DB.Exec("INSERT INTO ai_logs(post_id,action,reason,created_at) VALUES(?,?,?,?)",
567 postID, action, "管理员人工处理", models.Now())
568 http.Redirect(w, r, "/admin/reports", http.StatusSeeOther)
569}
570
571// ---------- 用户管理 ----------
572
573// AdminUsers 用户列表与帖子数
574func AdminUsers(w http.ResponseWriter, r *http.Request) {
575 if a := requireAdmin(w, r, models.PermUsers); a == nil {
576 return
577 }
578 rows, err := database.DB.Query(`
579 SELECT u.id, u.username, u.email, u.status, IFNULL(u.created_at,''),
580 (SELECT COUNT(1) FROM posts p WHERE p.user_id=u.id)
581 FROM users u ORDER BY u.id DESC LIMIT 500`)
582 if err != nil {
583 http.Error(w, "查询失败", http.StatusInternalServerError)
584 return
585 }
586 defer rows.Close()
587 type row struct {
588 ID int64
589 Username string
590 Email string
591 Status int64
592 CreatedAt string
593 Posts int64
594 }
595 var users []row
596 for rows.Next() {
597 var it row
598 _ = rows.Scan(&it.ID, &it.Username, &it.Email, &it.Status, &it.CreatedAt, &it.Posts)
599 users = append(users, it)
600 }
601 Render(w, r, "admin_layout.html", "pg_admin_users", map[string]any{"users": users})
602}
603
604// AdminUserStatus 禁用/启用用户
605func AdminUserStatus(w http.ResponseWriter, r *http.Request) {
606 if a := requireAdmin(w, r, models.PermUsers); a == nil {
607 return
608 }
609 id := formInt64(r, "id")
610 status := int64(1)
611 if r.FormValue("status") == "0" {
612 status = 0
613 }
614 _, _ = database.DB.Exec("UPDATE users SET status=? WHERE id=?", status, id)
615 http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
616}
617
618// AdminUserDelete 删除用户及其全部帖子
619func AdminUserDelete(w http.ResponseWriter, r *http.Request) {
620 if a := requireAdmin(w, r, models.PermUsers); a == nil {
621 return
622 }
623 id := formInt64(r, "id")
624 rows, err := database.DB.Query("SELECT id FROM posts WHERE user_id=?", id)
625 if err == nil {
626 var ids []int64
627 for rows.Next() {
628 var pid int64
629 _ = rows.Scan(&pid)
630 ids = append(ids, pid)
631 }
632 rows.Close()
633 for _, pid := range ids {
634 deletePostCascade(pid)
635 }
636 }
637 _, _ = database.DB.Exec("DELETE FROM users WHERE id=?", id)
638 http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
639}
640
641// ---------- 管理员管理 ----------
642
643// AdminAdmins 管理员列表
644func AdminAdmins(w http.ResponseWriter, r *http.Request) {
645 a := requireAdmin(w, r, models.PermAdmins)
646 if a == nil {
647 return
648 }
649 rows, err := database.DB.Query(
650 "SELECT id,username,role,IFNULL(perms,'[]'),IFNULL(created_at,'') FROM admins ORDER BY id")
651 if err != nil {
652 http.Error(w, "查询失败", http.StatusInternalServerError)
653 return
654 }
655 defer rows.Close()
656 type row struct {
657 ID int64
658 Username string
659 Role string
660 Perms string
661 CreatedAt string
662 }
663 var admins []row
664 for rows.Next() {
665 var it row
666 _ = rows.Scan(&it.ID, &it.Username, &it.Role, &it.Perms, &it.CreatedAt)
667 admins = append(admins, it)
668 }
669 Render(w, r, "admin_layout.html", "pg_admin_admins", map[string]any{"admins": admins, "me": a.ID})
670}
671
672// AdminCreate 添加管理员(custom 角色按勾选的权限组授权)
673func AdminCreate(w http.ResponseWriter, r *http.Request) {
674 if a := requireAdmin(w, r, models.PermAdmins); a == nil {
675 return
676 }
677 username := strings.TrimSpace(r.FormValue("username"))
678 password := r.FormValue("password")
679 role := r.FormValue("role")
680 if !usernameRe.MatchString(username) || len(password) < 6 {
681 http.Error(w, "用户名或密码不符合要求", http.StatusBadRequest)
682 return
683 }
684 if role != "super" {
685 role = "custom"
686 }
687 perms := "[]"
688 if role == "custom" {
689 var list []string
690 for _, p := range []string{models.PermPosts, models.PermReports, models.PermUsers,
691 models.PermSetting, models.PermNotice, models.PermPlugin, models.PermAPI, models.PermAdmins} {
692 if r.FormValue("perm_"+p) == "1" {
693 list = append(list, p)
694 }
695 }
696 b, _ := json.Marshal(list)
697 perms = string(b)
698 }
699 if _, err := database.DB.Exec(
700 "INSERT INTO admins(username,password,role,perms,created_at) VALUES(?,?,?,?,?)",
701 username, util.HashPassword(password), role, perms, models.Now()); err != nil {
702 http.Error(w, "创建失败(用户名可能重复)", http.StatusBadRequest)
703 return
704 }
705 http.Redirect(w, r, "/admin/admins", http.StatusSeeOther)
706}
707
708// AdminDelete 删除管理员(不可删除自己)
709func AdminDelete(w http.ResponseWriter, r *http.Request) {
710 a := requireAdmin(w, r, models.PermAdmins)
711 if a == nil {
712 return
713 }
714 id := formInt64(r, "id")
715 if id != a.ID {
716 _, _ = database.DB.Exec("DELETE FROM admins WHERE id=?", id)
717 }
718 http.Redirect(w, r, "/admin/admins", http.StatusSeeOther)
719}
720
721// ---------- 公告 / 话题 / 守则 ----------
722
723// AdminNotices 公告、话题、社区守则管理页
724func AdminNotices(w http.ResponseWriter, r *http.Request) {
725 if a := requireAdmin(w, r, models.PermNotice); a == nil {
726 return
727 }
728 var notices []map[string]any
729 rows, err := database.DB.Query("SELECT id,IFNULL(title,''),IFNULL(content,''),IFNULL(created_at,'') FROM notices ORDER BY id DESC")
730 if err == nil {
731 for rows.Next() {
732 var id int64
733 var t, c, at string
734 _ = rows.Scan(&id, &t, &c, &at)
735 notices = append(notices, map[string]any{"id": id, "title": t, "content": c, "created_at": at})
736 }
737 rows.Close()
738 }
739 Render(w, r, "admin_layout.html", "pg_admin_notices", map[string]any{
740 "notices": notices, "topics": listTopics(),
741 "rules": models.GetSetting("community_rules"),
742 })
743}
744
745// AdminNoticesSave 按操作类型分发:公告/话题/守则
746func AdminNoticesSave(w http.ResponseWriter, r *http.Request) {
747 if a := requireAdmin(w, r, models.PermNotice); a == nil {
748 return
749 }
750 switch r.FormValue("op") {
751 case "notice_create":
752 title := util.StripHTML(r.FormValue("title"))
753 content := util.StripHTML(r.FormValue("content"))
754 if title != "" {
755 _, _ = database.DB.Exec("INSERT INTO notices(title,content,created_at) VALUES(?,?,?)",
756 title, content, models.Now())
757 }
758 case "notice_delete":
759 _, _ = database.DB.Exec("DELETE FROM notices WHERE id=?", formInt64(r, "id"))
760 case "topic_create":
761 if name := util.StripHTML(r.FormValue("name")); name != "" && len([]rune(name)) <= 30 {
762 ensureTopic(name)
763 }
764 case "topic_delete":
765 _, _ = database.DB.Exec("DELETE FROM topics WHERE id=?", formInt64(r, "id"))
766 case "rules_save":
767 _ = models.SetSetting("community_rules", util.StripHTML(r.FormValue("rules")))
768 }
769 http.Redirect(w, r, "/admin/notices", http.StatusSeeOther)
770}
771
772// ---------- 插件管理 ----------
773
774// AdminPlugins 插件列表(含各插件声明的配置表单)
775func AdminPlugins(w http.ResponseWriter, r *http.Request) {
776 if a := requireAdmin(w, r, models.PermPlugin); a == nil {
777 return
778 }
779 list := plugin.List()
780 configs := map[string]map[string]string{}
781 infos := map[string]plugin.AppInfo{}
782 for i := range list {
783 if len(list[i].Config) > 0 {
784 p := list[i].Plugin
785 configs[p.Name] = plugin.GetConfig(&p)
786 }
787 if list[i].App {
788 infos[list[i].Name] = plugin.AppInfoOf(list[i].Name)
789 }
790 }
791 Render(w, r, "admin_layout.html", "pg_admin_plugins", map[string]any{
792 "plugins": list,
793 "configs": configs,
794 "infos": infos,
795 // 插件可通过 {"hook":"admin_plugins_top","type":"html"} 往本页顶部注入内容
796 "adminTop": template.HTML(plugin.CallHTML("admin_plugins_top")),
797 })
798}
799
800// AdminPluginReload 手动重载应用型插件运行时(修改脚本后无需重启站点)
801func AdminPluginReload(w http.ResponseWriter, r *http.Request) {
802 if a := requireAdmin(w, r, models.PermPlugin); a == nil {
803 return
804 }
805 name := filepath.Base(r.FormValue("name"))
806 if err := plugin.ReloadApp(name); err != nil {
807 util.Log("error", "插件 %s 重载失败: %v", name, err)
808 }
809 http.Redirect(w, r, "/admin/plugins", http.StatusSeeOther)
810}
811
812// AdminPluginLogs 插件运行日志页
813func AdminPluginLogs(w http.ResponseWriter, r *http.Request) {
814 if a := requireAdmin(w, r, models.PermPlugin); a == nil {
815 return
816 }
817 slug := strings.TrimSpace(r.URL.Query().Get("slug"))
818 Render(w, r, "admin_layout.html", "pg_admin_plugin_logs", map[string]any{
819 "logs": plugin.PluginLogs(slug, 200),
820 "slug": slug,
821 })
822}
823
824// AdminPluginLogsClear 清空插件运行日志
825func AdminPluginLogsClear(w http.ResponseWriter, r *http.Request) {
826 if a := requireAdmin(w, r, models.PermPlugin); a == nil {
827 return
828 }
829 if err := plugin.ClearPluginLogs(); err != nil {
830 util.Log("error", "清空插件日志失败: %v", err)
831 }
832 http.Redirect(w, r, "/admin/plugin-logs", http.StatusSeeOther)
833}
834
835// AdminPluginConfig 保存插件配置(仅接受该插件声明过的字段)
836func AdminPluginConfig(w http.ResponseWriter, r *http.Request) {
837 if a := requireAdmin(w, r, models.PermPlugin); a == nil {
838 return
839 }
840 // 音频上传会把请求变成 multipart,这里限制总大小并显式解析
841 r.Body = http.MaxBytesReader(w, r.Body, 32<<20)
842 if err := r.ParseMultipartForm(8 << 20); err != nil && !errors.Is(err, http.ErrNotMultipart) {
843 http.Error(w, "表单提交失败:文件超过 20MB 或格式错误", http.StatusBadRequest)
844 return
845 }
846 name := filepath.Base(r.FormValue("plugin"))
847 p, ok := plugin.Load()[name]
848 if !ok {
849 http.Redirect(w, r, "/admin/plugins", http.StatusSeeOther)
850 return
851 }
852 old := plugin.ConfigOf(name)
853 values := map[string]string{}
854 for _, f := range p.Config {
855 if f.Key == "" {
856 continue
857 }
858 switch f.Type {
859 case "switch":
860 values[f.Key] = "0"
861 if r.FormValue("cfg_"+f.Key) == "1" {
862 values[f.Key] = "1"
863 }
864 case "audio", "file":
865 // 优先用新上传的文件,其次是"删除",都没有则保留原值
866 var fh *multipart.FileHeader
867 if r.MultipartForm != nil {
868 if list := r.MultipartForm.File["cfg_"+f.Key+"__file"]; len(list) > 0 {
869 fh = list[0]
870 }
871 }
872 switch {
873 case fh != nil && fh.Size > 0:
874 url, err := savePluginAsset(name, fh)
875 if err != nil {
876 http.Error(w, "上传失败:"+err.Error(), http.StatusBadRequest)
877 return
878 }
879 removePluginAsset(old[f.Key])
880 values[f.Key] = url
881 case r.FormValue("cfg_"+f.Key+"__clear") == "1":
882 removePluginAsset(old[f.Key])
883 values[f.Key] = ""
884 default:
885 values[f.Key] = sanitizePluginAsset(r.FormValue("cfg_" + f.Key))
886 }
887 default:
888 values[f.Key] = r.FormValue("cfg_" + f.Key)
889 }
890 }
891 if err := plugin.SaveConfig(p, values); err != nil {
892 http.Error(w, "保存失败: "+err.Error(), http.StatusInternalServerError)
893 return
894 }
895 util.Log("info", "插件 %s 配置已更新", name)
896 // 应用型插件:配置变更后重载,确保 setup() 读到新配置
897 if p, ok := plugin.Load()[name]; ok && p.IsApp() {
898 if err := plugin.ReloadApp(name); err != nil {
899 util.Log("error", "应用型插件 %s 重载失败: %v", name, err)
900 }
901 }
902 http.Redirect(w, r, "/admin/plugins", http.StatusSeeOther)
903}
904
905// pluginAssetExts 插件素材允许的音频扩展名
906var pluginAssetExts = map[string]bool{
907 ".mp3": true, ".m4a": true, ".aac": true, ".ogg": true, ".oga": true,
908 ".opus": true, ".wav": true, ".flac": true, ".webm": true,
909}
910
911// savePluginAsset 保存插件上传的素材文件,返回可直接访问的 URL。
912// 文件名由服务端生成,不使用用户提交的文件名,避免路径与注入问题。
913func savePluginAsset(pluginName string, fh *multipart.FileHeader) (string, error) {
914 if fh.Size > 20<<20 {
915 return "", fmt.Errorf("文件不能超过 20MB")
916 }
917 ext := strings.ToLower(filepath.Ext(fh.Filename))
918 if !pluginAssetExts[ext] {
919 return "", fmt.Errorf("不支持的音频格式(支持 mp3 / m4a / aac / ogg / opus / wav / flac)")
920 }
921 sub := filepath.Base(pluginName)
922 dir := filepath.Join(config.Cfg.UploadDir, "plugins", sub)
923 if err := os.MkdirAll(dir, 0o755); err != nil {
924 return "", err
925 }
926 src, err := fh.Open()
927 if err != nil {
928 return "", err
929 }
930 defer src.Close()
931 name := "audio-" + util.RandomHex(8) + ext
932 dst, err := os.Create(filepath.Join(dir, name))
933 if err != nil {
934 return "", err
935 }
936 defer dst.Close()
937 if _, err := io.Copy(dst, io.LimitReader(src, 20<<20)); err != nil {
938 return "", err
939 }
940 return "/uploads/plugins/" + sub + "/" + name, nil
941}
942
943// removePluginAsset 删除本插件此前上传的素材(重新上传或清除时调用)。
944// 只处理 /uploads/plugins/ 下的路径,杜绝借配置值删除其他文件。
945func removePluginAsset(url string) {
946 const prefix = "/uploads/plugins/"
947 url = strings.TrimSpace(url)
948 if !strings.HasPrefix(url, prefix) || strings.Contains(url, "..") {
949 return
950 }
951 // /uploads/plugins/bgm/x.mp3 -> <UploadDir>/plugins/bgm/x.mp3
952 path := filepath.Join(config.Cfg.UploadDir, "plugins",
953 filepath.FromSlash(strings.TrimPrefix(url, prefix)))
954 if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
955 util.Log("warn", "清理旧素材失败 %s: %v", path, err)
956 }
957}
958
959// sanitizePluginAsset 清理素材地址中的危险字符。
960// 该值会被插件以 {config:key} 原样注入页面,必须保证不含引号与尖括号。
961func sanitizePluginAsset(v string) string {
962 v = strings.TrimSpace(util.StripHTML(v))
963 return strings.Map(func(r rune) rune {
964 switch r {
965 case '"', '\'', '<', '>', '\\', '`':
966 return -1
967 }
968 return r
969 }, v)
970}
971
972// AdminPluginUpload 上传安装插件 zip
973func AdminPluginUpload(w http.ResponseWriter, r *http.Request) {
974 if a := requireAdmin(w, r, models.PermPlugin); a == nil {
975 return
976 }
977 f, _, err := r.FormFile("file")
978 if err != nil {
979 http.Error(w, "请选择插件压缩包", http.StatusBadRequest)
980 return
981 }
982 defer f.Close()
983 data, _ := io.ReadAll(io.LimitReader(f, 50<<20))
984 if err := plugin.Install("upload.zip", data); err != nil {
985 http.Error(w, err.Error(), http.StatusBadRequest)
986 return
987 }
988 http.Redirect(w, r, "/admin/plugins", http.StatusSeeOther)
989}
990
991// AdminPluginToggle 启用/禁用插件。
992// 应用型插件(kind=app)在启用时加载运行时、禁用时卸载;
993// 声明式插件无需加载,按请求即时解析。
994func AdminPluginToggle(w http.ResponseWriter, r *http.Request) {
995 if a := requireAdmin(w, r, models.PermPlugin); a == nil {
996 return
997 }
998 name := filepath.Base(r.FormValue("name"))
999 on := r.FormValue("on") == "1"
1000 plugin.SetEnabled(name, on)
1001 if on {
1002 if p, ok := plugin.Load()[name]; ok && p.IsApp() {
1003 if err := plugin.LoadApp(name); err != nil {
1004 util.Log("error", "应用型插件 %s 加载失败: %v", name, err)
1005 }
1006 }
1007 } else {
1008 plugin.UnloadApp(name)
1009 }
1010 ReloadTemplates() // 插件可能带有模板覆盖
1011 http.Redirect(w, r, "/admin/plugins", http.StatusSeeOther)
1012}
1013
1014// AdminPluginDelete 卸载插件
1015func AdminPluginDelete(w http.ResponseWriter, r *http.Request) {
1016 if a := requireAdmin(w, r, models.PermPlugin); a == nil {
1017 return
1018 }
1019 name := filepath.Base(r.FormValue("name"))
1020 plugin.UnloadApp(name) // 应用型插件先停止运行时再删除目录
1021 _ = plugin.Remove(name)
1022 ReloadTemplates()
1023 http.Redirect(w, r, "/admin/plugins", http.StatusSeeOther)
1024}
1025
1026// ---------- API Key ----------
1027
1028// AdminAPIKeys API 密钥列表
1029func AdminAPIKeys(w http.ResponseWriter, r *http.Request) {
1030 if a := requireAdmin(w, r, models.PermAPI); a == nil {
1031 return
1032 }
1033 rows, err := database.DB.Query("SELECT id,name,api_key,status,IFNULL(last_used,''),created_at FROM apikeys ORDER BY id DESC")
1034 if err != nil {
1035 http.Error(w, "查询失败", http.StatusInternalServerError)
1036 return
1037 }
1038 defer rows.Close()
1039 type row struct {
1040 ID int64
1041 Name string
1042 Key string
1043 Status int64
1044 LastUsed string
1045 CreatedAt string
1046 }
1047 var keys []row
1048 for rows.Next() {
1049 var it row
1050 _ = rows.Scan(&it.ID, &it.Name, &it.Key, &it.Status, &it.LastUsed, &it.CreatedAt)
1051 keys = append(keys, it)
1052 }
1053 Render(w, r, "admin_layout.html", "pg_admin_apikeys", map[string]any{"keys": keys})
1054}
1055
1056// AdminAPIKeyCreate 生成新密钥
1057func AdminAPIKeyCreate(w http.ResponseWriter, r *http.Request) {
1058 if a := requireAdmin(w, r, models.PermAPI); a == nil {
1059 return
1060 }
1061 name := util.StripHTML(r.FormValue("name"))
1062 if name == "" {
1063 name = "未命名"
1064 }
1065 _, _ = database.DB.Exec("INSERT INTO apikeys(name,api_key,created_at) VALUES(?,?,?)",
1066 name, util.RandomHex(24), models.Now())
1067 http.Redirect(w, r, "/admin/apikeys", http.StatusSeeOther)
1068}
1069
1070// AdminAPIKeyDelete 删除密钥
1071func AdminAPIKeyDelete(w http.ResponseWriter, r *http.Request) {
1072 if a := requireAdmin(w, r, models.PermAPI); a == nil {
1073 return
1074 }
1075 _, _ = database.DB.Exec("DELETE FROM apikeys WHERE id=?", formInt64(r, "id"))
1076 http.Redirect(w, r, "/admin/apikeys", http.StatusSeeOther)
1077}
1078
1079// ---------- 关于与更新 ----------
1080
1081// AdminAbout 关于页面
1082func AdminAbout(w http.ResponseWriter, r *http.Request) {
1083 if a := requireAdmin(w, r, ""); a == nil {
1084 return
1085 }
1086 Render(w, r, "admin_layout.html", "pg_admin_about", nil)
1087}
1088
1089// AdminUpdate 检查云端最新版本
1090func AdminUpdate(w http.ResponseWriter, r *http.Request) {
1091 a := requireAdmin(w, r, models.PermSetting)
1092 if a == nil {
1093 return
1094 }
1095 url := models.GetSetting("update_url")
1096 result := map[string]any{"current": config.Version, "url": url}
1097 if url != "" {
1098 client := &http.Client{Timeout: 10 * time.Second}
1099 if resp, err := client.Get(url); err == nil {
1100 var info struct {
1101 Version string `json:"version"`
1102 Notes string `json:"notes"`
1103 Download string `json:"download"`
1104 SHA256 string `json:"sha256"`
1105 }
1106 if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&info) == nil {
1107 result["latest"] = info.Version
1108 result["notes"] = info.Notes
1109 result["download"] = info.Download
1110 result["sha256"] = info.SHA256
1111 result["upToDate"] = info.Version == "" || info.Version <= config.Version
1112 }
1113 resp.Body.Close()
1114 } else {
1115 result["error"] = "无法连接更新服务器"
1116 }
1117 } else {
1118 result["error"] = "未配置更新接口地址"
1119 }
1120 Render(w, r, "admin_layout.html", "pg_admin_update", map[string]any{
1121 "result": result,
1122 "platform": runtime.GOOS + "/" + runtime.GOARCH,
1123 "canSelfUpdate": runtime.GOOS == "linux" && runtime.GOARCH == "amd64",
1124 })
1125}