feat: opaque session、安装/发帖 SSR 与最小 Admin 后台
浏览器登录改为 DB sessions(可吊销);敏感词与 OIDC PEM 入 settings; 落地安装向导、注册发帖与 /admin 仪表盘/板块/审核/设置。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
500
routers/web/admin.go
Normal file
500
routers/web/admin.go
Normal file
@@ -0,0 +1,500 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/models"
|
||||
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AdminChrome 后台布局公共字段
|
||||
type AdminChrome struct {
|
||||
PageChrome
|
||||
NavActive string
|
||||
}
|
||||
|
||||
func (d Deps) adminChrome(ctx *webctx.Context, title, nav string) AdminChrome {
|
||||
site := d.Settings.SiteBranding().Name
|
||||
if title == "" {
|
||||
title = "管理后台 · " + site
|
||||
} else {
|
||||
title = title + " · " + site
|
||||
}
|
||||
base := d.chrome(ctx, title, "", "")
|
||||
return AdminChrome{PageChrome: base, NavActive: nav}
|
||||
}
|
||||
|
||||
type adminDashData struct {
|
||||
AdminChrome
|
||||
UserCount int64
|
||||
PostCount int64
|
||||
PendingPosts int64
|
||||
PendingComments int64
|
||||
BoardCount int64
|
||||
}
|
||||
|
||||
// AdminDashboard 概览
|
||||
func (d Deps) AdminDashboard(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
var users, posts, boards int64
|
||||
_ = models.DB.Model(&models.User{}).Count(&users).Error
|
||||
_ = models.DB.Model(&models.Post{}).Where("status = ?", models.ContentStatusPublished).Count(&posts).Error
|
||||
_ = models.DB.Model(&models.Board{}).Count(&boards).Error
|
||||
pendingPosts, _ := d.Post.PendingPostCount()
|
||||
pendingComments, _ := d.Comment.PendingCommentCount()
|
||||
ctx.HTML(http.StatusOK, "admin/dashboard", adminDashData{
|
||||
AdminChrome: d.adminChrome(ctx, "仪表盘", "dashboard"),
|
||||
UserCount: users,
|
||||
PostCount: posts,
|
||||
PendingPosts: pendingPosts,
|
||||
PendingComments: pendingComments,
|
||||
BoardCount: boards,
|
||||
})
|
||||
}
|
||||
|
||||
type adminBoardRow struct {
|
||||
ID uint
|
||||
Name string
|
||||
Description string
|
||||
Icon string
|
||||
ColorIndex int
|
||||
SortOrder int
|
||||
PostCount int
|
||||
}
|
||||
|
||||
type adminBoardsData struct {
|
||||
AdminChrome
|
||||
Boards []adminBoardRow
|
||||
Form adminBoardForm
|
||||
}
|
||||
|
||||
type adminBoardForm struct {
|
||||
ID uint
|
||||
Name string
|
||||
Description string
|
||||
Icon string
|
||||
ColorIndex int
|
||||
SortOrder int
|
||||
}
|
||||
|
||||
// AdminBoardsGet 板块列表
|
||||
func (d Deps) AdminBoardsGet(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
d.renderAdminBoards(ctx, "", adminBoardForm{})
|
||||
}
|
||||
|
||||
func (d Deps) renderAdminBoards(ctx *webctx.Context, errMsg string, form adminBoardForm) {
|
||||
list, _ := d.Board.ListWithStats()
|
||||
rows := make([]adminBoardRow, 0, len(list))
|
||||
for _, b := range list {
|
||||
rows = append(rows, adminBoardRow{
|
||||
ID: b.ID, Name: b.Name, Description: b.Description,
|
||||
Icon: b.Icon, ColorIndex: b.ColorIndex, SortOrder: b.SortOrder,
|
||||
PostCount: b.PostCount,
|
||||
})
|
||||
}
|
||||
data := adminBoardsData{
|
||||
AdminChrome: d.adminChrome(ctx, "板块", "boards"),
|
||||
Boards: rows,
|
||||
Form: form,
|
||||
}
|
||||
data.Error = errMsg
|
||||
ctx.HTML(http.StatusOK, "admin/boards", data)
|
||||
}
|
||||
|
||||
// AdminBoardCreate 新建板块
|
||||
func (d Deps) AdminBoardCreate(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminBoards(ctx, "无效请求,请重试", adminBoardFormFrom(c))
|
||||
return
|
||||
}
|
||||
form := adminBoardFormFrom(c)
|
||||
if strings.TrimSpace(form.Name) == "" {
|
||||
d.renderAdminBoards(ctx, "请填写板块名称", form)
|
||||
return
|
||||
}
|
||||
if _, err := d.Board.Create(form.Name, form.Description, form.Icon, form.ColorIndex, form.SortOrder); err != nil {
|
||||
d.renderAdminBoards(ctx, err.Error(), form)
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("板块已创建")
|
||||
ctx.Redirect("/admin/boards")
|
||||
}
|
||||
|
||||
// AdminBoardUpdate 更新板块
|
||||
func (d Deps) AdminBoardUpdate(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminBoards(ctx, "无效请求,请重试", adminBoardFormFrom(c))
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
form := adminBoardFormFrom(c)
|
||||
form.ID = uint(id)
|
||||
if strings.TrimSpace(form.Name) == "" {
|
||||
d.renderAdminBoards(ctx, "请填写板块名称", form)
|
||||
return
|
||||
}
|
||||
if err := d.Board.Update(uint(id), form.Name, form.Description, form.Icon, form.ColorIndex, form.SortOrder); err != nil {
|
||||
d.renderAdminBoards(ctx, err.Error(), form)
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("板块已更新")
|
||||
ctx.Redirect("/admin/boards")
|
||||
}
|
||||
|
||||
// AdminBoardDelete 删除板块
|
||||
func (d Deps) AdminBoardDelete(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
ctx.SetFlash("无效请求,请重试")
|
||||
ctx.Redirect("/admin/boards")
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := d.Board.Delete(uint(id)); err != nil {
|
||||
ctx.SetFlash(err.Error())
|
||||
ctx.Redirect("/admin/boards")
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("板块已删除")
|
||||
ctx.Redirect("/admin/boards")
|
||||
}
|
||||
|
||||
func adminBoardFormFrom(c *gin.Context) adminBoardForm {
|
||||
color, _ := strconv.Atoi(c.PostForm("color_index"))
|
||||
sort, _ := strconv.Atoi(c.PostForm("sort_order"))
|
||||
return adminBoardForm{
|
||||
Name: strings.TrimSpace(c.PostForm("name")),
|
||||
Description: strings.TrimSpace(c.PostForm("description")),
|
||||
Icon: strings.TrimSpace(c.PostForm("icon")),
|
||||
ColorIndex: color,
|
||||
SortOrder: sort,
|
||||
}
|
||||
}
|
||||
|
||||
type adminModPostRow struct {
|
||||
ID uint
|
||||
Title string
|
||||
AuthorName string
|
||||
BoardName string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
type adminModCommentRow struct {
|
||||
ID uint
|
||||
PostID uint
|
||||
PostTitle string
|
||||
Floor int
|
||||
AuthorName string
|
||||
Excerpt string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
type adminModData struct {
|
||||
AdminChrome
|
||||
Posts []adminModPostRow
|
||||
Comments []adminModCommentRow
|
||||
}
|
||||
|
||||
// AdminModerationGet 待审帖/评
|
||||
func (d Deps) AdminModerationGet(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
posts, _, _ := d.Post.List(services.PostListQuery{
|
||||
Page: 1, Size: 50,
|
||||
ViewerIsAdmin: true,
|
||||
Status: models.ContentStatusPending,
|
||||
Sort: "latest",
|
||||
})
|
||||
postRows := make([]adminModPostRow, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
author := ""
|
||||
if p.User.ID > 0 {
|
||||
author = p.User.Nickname
|
||||
if author == "" {
|
||||
author = p.User.Username
|
||||
}
|
||||
}
|
||||
board := ""
|
||||
if p.Board.ID > 0 {
|
||||
board = p.Board.Name
|
||||
}
|
||||
postRows = append(postRows, adminModPostRow{
|
||||
ID: p.ID, Title: p.Title, AuthorName: author, BoardName: board,
|
||||
CreatedAt: p.CreatedAt.Format("2006-01-02 15:04"),
|
||||
})
|
||||
}
|
||||
comments, _, _ := d.Comment.ListPending(1, 50)
|
||||
commentRows := make([]adminModCommentRow, 0, len(comments))
|
||||
for _, cm := range comments {
|
||||
author := "游客"
|
||||
if cm.UserID > 0 {
|
||||
author = cm.User.Nickname
|
||||
if author == "" {
|
||||
author = cm.User.Username
|
||||
}
|
||||
} else if cm.GuestNick != "" {
|
||||
author = cm.GuestNick
|
||||
}
|
||||
excerpt := strings.TrimSpace(stripTagsRough(cm.Content))
|
||||
runes := []rune(excerpt)
|
||||
if len(runes) > 80 {
|
||||
excerpt = string(runes[:80]) + "…"
|
||||
}
|
||||
title := ""
|
||||
if cm.Post.ID > 0 {
|
||||
title = cm.Post.Title
|
||||
}
|
||||
commentRows = append(commentRows, adminModCommentRow{
|
||||
ID: cm.ID, PostID: cm.PostID, PostTitle: title, Floor: cm.Floor,
|
||||
AuthorName: author, Excerpt: excerpt,
|
||||
CreatedAt: cm.CreatedAt.Format("2006-01-02 15:04"),
|
||||
})
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "admin/moderation", adminModData{
|
||||
AdminChrome: d.adminChrome(ctx, "内容审核", "moderation"),
|
||||
Posts: postRows,
|
||||
Comments: commentRows,
|
||||
})
|
||||
}
|
||||
|
||||
func stripTagsRough(s string) string {
|
||||
var b strings.Builder
|
||||
inTag := false
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r == '<':
|
||||
inTag = true
|
||||
case r == '>':
|
||||
inTag = false
|
||||
case !inTag:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// AdminPostApprove 通过帖子
|
||||
func (d Deps) AdminPostApprove(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
ctx.SetFlash("无效请求,请重试")
|
||||
ctx.Redirect("/admin/moderation")
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := d.Post.SetStatus(uint(id), models.ContentStatusPublished); err != nil {
|
||||
ctx.SetFlash(err.Error())
|
||||
} else {
|
||||
ctx.SetFlash("帖子已通过")
|
||||
}
|
||||
ctx.Redirect("/admin/moderation")
|
||||
}
|
||||
|
||||
// AdminPostReject 拒绝帖子
|
||||
func (d Deps) AdminPostReject(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
ctx.SetFlash("无效请求,请重试")
|
||||
ctx.Redirect("/admin/moderation")
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
reason := strings.TrimSpace(c.PostForm("reason"))
|
||||
if reason == "" {
|
||||
ctx.SetFlash("请填写拒绝原因")
|
||||
ctx.Redirect("/admin/moderation")
|
||||
return
|
||||
}
|
||||
post, err := d.Post.FindByID(uint(id))
|
||||
if err != nil {
|
||||
ctx.SetFlash(err.Error())
|
||||
ctx.Redirect("/admin/moderation")
|
||||
return
|
||||
}
|
||||
if err := d.Post.SetStatus(post.ID, models.ContentStatusRejected); err != nil {
|
||||
ctx.SetFlash(err.Error())
|
||||
ctx.Redirect("/admin/moderation")
|
||||
return
|
||||
}
|
||||
pid := post.ID
|
||||
if d.Message != nil {
|
||||
_, _ = d.Message.SendSystem(
|
||||
post.UserID,
|
||||
"帖子《"+post.Title+"》未通过审核",
|
||||
services.FormatRejectContent(post.Title, post.ID, reason),
|
||||
models.MessageKindReject,
|
||||
&pid,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
ctx.SetFlash("已拒绝该帖")
|
||||
ctx.Redirect("/admin/moderation")
|
||||
}
|
||||
|
||||
// AdminCommentApprove 通过评论
|
||||
func (d Deps) AdminCommentApprove(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
ctx.SetFlash("无效请求,请重试")
|
||||
ctx.Redirect("/admin/moderation")
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := d.Comment.SetStatus(uint(id), models.ContentStatusPublished); err != nil {
|
||||
ctx.SetFlash(err.Error())
|
||||
} else {
|
||||
ctx.SetFlash("评论已通过")
|
||||
}
|
||||
ctx.Redirect("/admin/moderation")
|
||||
}
|
||||
|
||||
// AdminCommentReject 拒绝评论
|
||||
func (d Deps) AdminCommentReject(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
ctx.SetFlash("无效请求,请重试")
|
||||
ctx.Redirect("/admin/moderation")
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
reason := strings.TrimSpace(c.PostForm("reason"))
|
||||
if reason == "" {
|
||||
ctx.SetFlash("请填写拒绝原因")
|
||||
ctx.Redirect("/admin/moderation")
|
||||
return
|
||||
}
|
||||
cm, err := d.Comment.GetByID(uint(id))
|
||||
if err != nil {
|
||||
ctx.SetFlash(err.Error())
|
||||
ctx.Redirect("/admin/moderation")
|
||||
return
|
||||
}
|
||||
if err := d.Comment.SetStatus(cm.ID, models.ContentStatusRejected); err != nil {
|
||||
ctx.SetFlash(err.Error())
|
||||
ctx.Redirect("/admin/moderation")
|
||||
return
|
||||
}
|
||||
if d.Message != nil && cm.UserID > 0 {
|
||||
pid := cm.PostID
|
||||
title := cm.Post.Title
|
||||
_, _ = d.Message.SendSystem(
|
||||
cm.UserID,
|
||||
fmt.Sprintf("评论未通过审核(帖 #%d)", cm.PostID),
|
||||
services.FormatCommentRejectContent(title, cm.PostID, cm.Floor, reason),
|
||||
models.MessageKindReject,
|
||||
&pid,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
ctx.SetFlash("已拒绝该评论")
|
||||
ctx.Redirect("/admin/moderation")
|
||||
}
|
||||
|
||||
type adminSettingsData struct {
|
||||
AdminChrome
|
||||
Brand services.SiteBranding
|
||||
RatePost int
|
||||
RateComment int
|
||||
RateReg int
|
||||
RateLogin int
|
||||
RateWindow int
|
||||
FilterWords string
|
||||
FilterCount int
|
||||
}
|
||||
|
||||
// AdminSettingsGet 设置页
|
||||
func (d Deps) AdminSettingsGet(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
d.renderAdminSettings(ctx, "")
|
||||
}
|
||||
|
||||
func (d Deps) renderAdminSettings(ctx *webctx.Context, errMsg string) {
|
||||
words := d.Settings.FilterWordsContent()
|
||||
lim := d.Settings.Limits()
|
||||
data := adminSettingsData{
|
||||
AdminChrome: d.adminChrome(ctx, "站点设置", "settings"),
|
||||
Brand: d.Settings.SiteBranding(),
|
||||
RatePost: lim.RateLimitPost,
|
||||
RateComment: lim.RateLimitComment,
|
||||
RateReg: lim.RateLimitRegister,
|
||||
RateLogin: lim.RateLimitLogin,
|
||||
RateWindow: lim.RateLimitWindowSec,
|
||||
FilterWords: words,
|
||||
FilterCount: services.CountFilterWords(words),
|
||||
}
|
||||
data.Error = errMsg
|
||||
ctx.HTML(http.StatusOK, "admin/settings", data)
|
||||
}
|
||||
|
||||
// AdminSettingsBrandPost 品牌
|
||||
func (d Deps) AdminSettingsBrandPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminSettings(ctx, "无效请求,请重试")
|
||||
return
|
||||
}
|
||||
cur := d.Settings.SiteBranding()
|
||||
in := services.SiteBranding{
|
||||
Name: strings.TrimSpace(c.PostForm("name")),
|
||||
Slogan: strings.TrimSpace(c.PostForm("slogan")),
|
||||
Description: strings.TrimSpace(c.PostForm("description")),
|
||||
Keywords: strings.TrimSpace(c.PostForm("keywords")),
|
||||
LogoMark: strings.TrimSpace(c.PostForm("logo_mark")),
|
||||
Logo: cur.Logo,
|
||||
Favicon: cur.Favicon,
|
||||
OGImage: cur.OGImage,
|
||||
ICPBeian: strings.TrimSpace(c.PostForm("icp_beian")),
|
||||
ICPBeianURL: strings.TrimSpace(c.PostForm("icp_beian_url")),
|
||||
FriendLinks: cur.FriendLinks,
|
||||
}
|
||||
if err := d.Settings.UpdateSiteBranding(in); err != nil {
|
||||
d.renderAdminSettings(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("品牌设置已保存")
|
||||
ctx.Redirect("/admin/settings")
|
||||
}
|
||||
|
||||
// AdminSettingsLimitsPost 限流
|
||||
func (d Deps) AdminSettingsLimitsPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminSettings(ctx, "无效请求,请重试")
|
||||
return
|
||||
}
|
||||
postN, _ := strconv.Atoi(c.PostForm("rate_limit_post"))
|
||||
commentN, _ := strconv.Atoi(c.PostForm("rate_limit_comment"))
|
||||
regN, _ := strconv.Atoi(c.PostForm("rate_limit_register"))
|
||||
loginN, _ := strconv.Atoi(c.PostForm("rate_limit_login"))
|
||||
windowN, _ := strconv.Atoi(c.PostForm("rate_limit_window_sec"))
|
||||
if err := d.Settings.UpdateRateLimits(postN, commentN, regN, loginN, windowN); err != nil {
|
||||
d.renderAdminSettings(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("限流设置已保存")
|
||||
ctx.Redirect("/admin/settings")
|
||||
}
|
||||
|
||||
// AdminSettingsFilterWordsPost 敏感词
|
||||
func (d Deps) AdminSettingsFilterWordsPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminSettings(ctx, "无效请求,请重试")
|
||||
return
|
||||
}
|
||||
content := c.PostForm("filter_words")
|
||||
if err := d.Settings.UpdateFilterWords(content, d.Filter); err != nil {
|
||||
d.renderAdminSettings(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.SetFlash(fmt.Sprintf("敏感词已更新(有效词 %d 个)· %s", services.CountFilterWords(content), time.Now().Format("15:04:05")))
|
||||
ctx.Redirect("/admin/settings")
|
||||
}
|
||||
Reference in New Issue
Block a user