feat: opaque session、安装/发帖 SSR 与最小 Admin 后台

浏览器登录改为 DB sessions(可吊销);敏感词与 OIDC PEM 入 settings;
落地安装向导、注册发帖与 /admin 仪表盘/板块/审核/设置。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-29 05:44:16 +08:00
parent 3f50316ad0
commit fde5f628ec
80 changed files with 4148 additions and 1849 deletions

500
routers/web/admin.go Normal file
View 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")
}

208
routers/web/auth.go Normal file
View File

@@ -0,0 +1,208 @@
package web
import (
"net/http"
"strings"
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
"git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin"
)
type loginData struct {
PageChrome
Username string
Redirect string
}
type registerData struct {
PageChrome
Username string
Nickname string
Email string
MailReady bool
RequireEmailCode bool
}
type registerForm struct {
Username string
Nickname string
Email string
}
// LoginGet 登录页
func (d Deps) LoginGet(c *gin.Context) {
ctx := d.ctx(c)
if ctx.IsSigned() {
ctx.Redirect("/")
return
}
chrome := d.chrome(ctx, "登录 · "+d.Settings.SiteBranding().Name, "", "")
if c.Query("banned") == "1" {
chrome.Error = "账号已被禁言"
}
ctx.HTML(http.StatusOK, "auth/login", loginData{
PageChrome: chrome,
Redirect: c.Query("redirect"),
})
}
// LoginPost 登录提交
func (d Deps) LoginPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderLogin(ctx, "无效请求,请重试", c.PostForm("username"), c.PostForm("redirect"))
return
}
if d.Limiter != nil && !d.Limiter.Allow("login", c.ClientIP()) {
d.renderLogin(ctx, "操作过于频繁,请稍后再试", c.PostForm("username"), c.PostForm("redirect"))
return
}
user := strings.TrimSpace(c.PostForm("username"))
pass := c.PostForm("password")
redir := strings.TrimSpace(c.PostForm("redirect"))
if redir == "" || !strings.HasPrefix(redir, "/") || strings.HasPrefix(redir, "//") {
redir = "/"
}
token, _, err := d.Auth.Login(user, pass, c.ClientIP(), c.Request.UserAgent())
if err != nil {
msg := "用户名或密码错误"
if err == services.ErrUserBanned {
msg = "账号已被禁言"
}
d.renderLogin(ctx, msg, user, redir)
return
}
ctx.SetLoginCookie(token)
ctx.Redirect(redir)
}
func (d Deps) renderLogin(ctx *webctx.Context, errMsg, username, redir string) {
chrome := d.chrome(ctx, "登录 · "+d.Settings.SiteBranding().Name, "", "")
chrome.Error = errMsg
ctx.HTML(http.StatusOK, "auth/login", loginData{
PageChrome: chrome,
Username: username,
Redirect: redir,
})
}
// LogoutPost 退出
func (d Deps) LogoutPost(c *gin.Context) {
ctx := d.ctx(c)
if ctx.CheckCSRF() {
ctx.ClearLoginCookie()
}
ctx.Redirect("/")
}
// RegisterGet 注册页
func (d Deps) RegisterGet(c *gin.Context) {
ctx := d.ctx(c)
if ctx.IsSigned() {
ctx.Redirect("/")
return
}
d.renderRegister(ctx, "", registerForm{})
}
// RegisterSendCode POST 发送注册邮箱验证码
func (d Deps) RegisterSendCode(c *gin.Context) {
ctx := d.ctx(c)
if ctx.IsSigned() {
ctx.Redirect("/")
return
}
form := registerFormFrom(c)
if !ctx.CheckCSRF() {
d.renderRegister(ctx, "无效请求,请重试", form)
return
}
if d.Limiter != nil && !d.Limiter.Allow("register", c.ClientIP()) {
d.renderRegister(ctx, "操作过于频繁,请稍后再试", form)
return
}
if d.EmailCode == nil || !d.Settings.MailReady() {
d.renderRegister(ctx, "邮件服务未配置,无需验证码即可注册", form)
return
}
if err := d.EmailCode.SendRegisterCode(form.Email); err != nil {
d.renderRegister(ctx, err.Error(), form)
return
}
chrome := d.chrome(ctx, "注册 · "+d.Settings.SiteBranding().Name, "", "")
chrome.Flash = "验证码已发送,请查收邮箱"
d.renderRegisterWithChrome(ctx, chrome, "", form)
}
// RegisterPost 注册提交
func (d Deps) RegisterPost(c *gin.Context) {
ctx := d.ctx(c)
if ctx.IsSigned() {
ctx.Redirect("/")
return
}
form := registerFormFrom(c)
if !ctx.CheckCSRF() {
d.renderRegister(ctx, "无效请求,请重试", form)
return
}
if d.Limiter != nil && !d.Limiter.Allow("register", c.ClientIP()) {
d.renderRegister(ctx, "操作过于频繁,请稍后再试", form)
return
}
mailReady := d.Settings.MailReady()
if mailReady {
code := strings.TrimSpace(c.PostForm("email_code"))
if d.EmailCode == nil || !d.EmailCode.Verify(form.Email, code) {
d.renderRegister(ctx, services.ErrEmailCodeInvalid.Error(), form)
return
}
}
pass := c.PostForm("password")
pass2 := c.PostForm("password2")
if pass != pass2 {
d.renderRegister(ctx, "两次密码不一致", form)
return
}
user, err := d.Auth.Register(form.Username, pass, form.Nickname, form.Email)
if err != nil {
d.renderRegister(ctx, err.Error(), form)
return
}
sid, err := d.Auth.CreateSessionForUser(user, c.ClientIP(), c.Request.UserAgent())
if err != nil {
ctx.SetFlash("注册成功,请登录")
ctx.Redirect("/login")
return
}
ctx.SetLoginCookie(sid)
ctx.SetFlash("注册成功,欢迎加入")
ctx.Redirect("/")
}
func registerFormFrom(c *gin.Context) registerForm {
return registerForm{
Username: strings.TrimSpace(c.PostForm("username")),
Nickname: strings.TrimSpace(c.PostForm("nickname")),
Email: strings.TrimSpace(c.PostForm("email")),
}
}
func (d Deps) renderRegister(ctx *webctx.Context, errMsg string, form registerForm) {
chrome := d.chrome(ctx, "注册 · "+d.Settings.SiteBranding().Name, "", "")
d.renderRegisterWithChrome(ctx, chrome, errMsg, form)
}
func (d Deps) renderRegisterWithChrome(ctx *webctx.Context, chrome PageChrome, errMsg string, form registerForm) {
chrome.Error = errMsg
mailReady := d.Settings.MailReady()
ctx.HTML(http.StatusOK, "auth/register", registerData{
PageChrome: chrome,
Username: form.Username,
Nickname: form.Nickname,
Email: form.Email,
MailReady: mailReady,
RequireEmailCode: mailReady,
})
}

110
routers/web/chrome.go Normal file
View File

@@ -0,0 +1,110 @@
package web
import (
"strings"
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
"git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin"
)
// Deps 页面依赖
type Deps struct {
DataDir string
JWTSecret string
Settings *services.ForumSettingsService
Auth *services.AuthService
Board *services.BoardService
Post *services.PostService
Comment *services.CommentService
Message *services.MessageService
Filter *services.SensitiveFilter
Limiter *services.RateLimiter
EmailCode *services.EmailCodeService
Store *services.UploadStore
}
// BoardView 侧栏
type BoardView struct {
ID uint
Name string
}
// PageChrome 布局公共字段
type PageChrome struct {
Title string
Description string
SiteName string
Slogan string
LogoMark string
LoggedIn bool
IsAdmin bool
ViewerName string
Boards []BoardView
ActiveBoard uint
Inner string // 保留字段;入口模板已固定组合,不再动态 template
CSRF string
Flash string
Error string
}
func (d Deps) ctx(c *gin.Context) *webctx.Context {
return webctx.New(c, d.JWTSecret)
}
func (d Deps) chrome(ctx *webctx.Context, title, desc, inner string) PageChrome {
brand := d.Settings.SiteBranding()
if title == "" {
title = brand.DocumentTitle()
}
if desc == "" {
desc = brand.MetaDescription()
}
name := "我的"
if ctx.IsSigned() {
name = strings.TrimSpace(ctx.Doer.Nickname)
if name == "" {
name = ctx.Doer.Username
}
}
boards, _ := d.Board.List()
bv := make([]BoardView, 0, len(boards))
for _, b := range boards {
bv = append(bv, BoardView{ID: b.ID, Name: b.Name})
}
return PageChrome{
Title: title,
Description: desc,
SiteName: brand.Name,
Slogan: brand.Slogan,
LogoMark: firstRuneOr(brand.LogoMark, "姜"),
LoggedIn: ctx.IsSigned(),
IsAdmin: ctx.IsAdmin(),
ViewerName: name,
Boards: bv,
Inner: inner,
CSRF: ctx.EnsureCSRF(),
Flash: ctx.TakeFlash(),
}
}
func firstRuneOr(s, fallback string) string {
s = strings.TrimSpace(s)
if s == "" {
return fallback
}
for _, r := range s {
return string(r)
}
return fallback
}
func stripIDParam(raw, permalinkExt string) string {
raw = strings.TrimSpace(raw)
if permalinkExt != "" {
raw = strings.TrimSuffix(raw, "."+permalinkExt)
}
raw = strings.TrimSuffix(raw, ".html")
raw = strings.TrimSuffix(raw, ".htm")
return raw
}

224
routers/web/compose.go Normal file
View File

@@ -0,0 +1,224 @@
package web
import (
"fmt"
"net/http"
"strconv"
"strings"
"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"
)
type composeData struct {
PageChrome
IsEdit bool
PostID uint
FormAction string
BoardID uint
Title string
Tags string
Content string
Boards []BoardView
TitleMax int
TagsMax int
ContentMax int
}
// ComposeGet 发帖页
func (d Deps) ComposeGet(c *gin.Context) {
ctx := d.ctx(c)
if err := d.ensureCanWrite(ctx); err != "" {
ctx.SetFlash(err)
ctx.Redirect("/")
return
}
boardID, _ := strconv.ParseUint(c.Query("board"), 10, 64)
d.renderCompose(ctx, "", composeForm{
BoardID: uint(boardID),
}, false, 0)
}
// ComposePost 发帖提交
func (d Deps) ComposePost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderCompose(ctx, "无效请求,请重试", composeFormFrom(c), false, 0)
return
}
if msg := d.ensureCanWrite(ctx); msg != "" {
ctx.SetFlash(msg)
ctx.Redirect("/")
return
}
if d.Limiter != nil && !d.Limiter.Allow("post", fmt.Sprintf("%d", ctx.UserID())) {
d.renderCompose(ctx, "发帖过于频繁,请稍后再试", composeFormFrom(c), false, 0)
return
}
form := composeFormFrom(c)
htmlBody := services.ComposeBodyToHTML(form.Content)
post, err := d.Post.Create(ctx.UserID(), form.BoardID, form.Title, htmlBody, form.Tags, models.PostTypeNormal, ctx.SkipsModeration())
if err != nil {
d.renderCompose(ctx, err.Error(), form, false, 0)
return
}
if post.Status == models.ContentStatusPending {
ctx.SetFlash("帖子已提交,等待审核")
} else {
ctx.SetFlash("发帖成功")
}
ctx.Redirect(fmt.Sprintf("/post/%d", post.ID))
}
// PostEditGet 编辑帖
func (d Deps) PostEditGet(c *gin.Context) {
ctx := d.ctx(c)
post, errMsg := d.loadEditablePost(ctx, c.Param("id"))
if errMsg != "" {
ctx.SetFlash(errMsg)
ctx.Redirect("/")
return
}
d.renderCompose(ctx, "", composeForm{
BoardID: post.BoardID,
Title: post.Title,
Tags: post.Tags,
Content: services.HTMLToComposePlain(post.Content),
}, true, post.ID)
}
// PostEditPost 编辑提交
func (d Deps) PostEditPost(c *gin.Context) {
ctx := d.ctx(c)
post, errMsg := d.loadEditablePost(ctx, c.Param("id"))
if errMsg != "" {
ctx.SetFlash(errMsg)
ctx.Redirect("/")
return
}
if !ctx.CheckCSRF() {
d.renderCompose(ctx, "无效请求,请重试", composeFormFrom(c), true, post.ID)
return
}
if d.Limiter != nil && !d.Limiter.Allow("post", fmt.Sprintf("%d", ctx.UserID())) {
d.renderCompose(ctx, "操作过于频繁,请稍后再试", composeFormFrom(c), true, post.ID)
return
}
form := composeFormFrom(c)
htmlBody := services.ComposeBodyToHTML(form.Content)
if err := d.Post.Update(ctx.UserID(), post.ID, ctx.IsAdmin(), ctx.SkipsModeration(), form.Title, htmlBody, form.Tags, models.PostTypeNormal, form.BoardID); err != nil {
d.renderCompose(ctx, err.Error(), form, true, post.ID)
return
}
ctx.SetFlash("已保存")
ctx.Redirect(fmt.Sprintf("/post/%d", post.ID))
}
// ComposeUpload 帖图上传JSON供 compose 页 fetch
func (d Deps) ComposeUpload(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.IsSigned() {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
if !ctx.CheckCSRF() {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效请求"})
return
}
if ctx.Doer != nil && ctx.Doer.Banned {
c.JSON(http.StatusForbidden, gin.H{"error": "账号已被禁言"})
return
}
if d.Store == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "上传不可用"})
return
}
file, err := c.FormFile("image")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择图片文件"})
return
}
url, err := services.SaveUploadedImage(d.Store, file, services.UploadCategoryPosts, fmt.Sprintf("%d", ctx.UserID()))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"url": url})
}
type composeForm struct {
BoardID uint
Title string
Tags string
Content string
}
func composeFormFrom(c *gin.Context) composeForm {
bid, _ := strconv.ParseUint(c.PostForm("board_id"), 10, 64)
return composeForm{
BoardID: uint(bid),
Title: strings.TrimSpace(c.PostForm("title")),
Tags: strings.TrimSpace(c.PostForm("tags")),
Content: c.PostForm("content"),
}
}
func (d Deps) renderCompose(ctx *webctx.Context, errMsg string, form composeForm, isEdit bool, postID uint) {
title := "发帖"
action := "/compose"
if isEdit {
title = "编辑帖子"
action = fmt.Sprintf("/post/%d/edit", postID)
}
chrome := d.chrome(ctx, title+" · "+d.Settings.SiteBranding().Name, "", "")
chrome.Error = errMsg
chrome.ActiveBoard = form.BoardID
ctx.HTML(http.StatusOK, "compose", composeData{
PageChrome: chrome,
IsEdit: isEdit,
PostID: postID,
FormAction: action,
BoardID: form.BoardID,
Title: form.Title,
Tags: form.Tags,
Content: form.Content,
Boards: chrome.Boards,
TitleMax: d.Settings.PostTitleMax(),
TagsMax: d.Settings.PostTagsMax(),
ContentMax: d.Settings.PostContentMax(),
})
}
func (d Deps) ensureCanWrite(ctx *webctx.Context) string {
if !ctx.IsSigned() {
return "请先登录"
}
if ctx.Doer != nil && ctx.Doer.Banned {
return "账号已被禁言,无法发帖"
}
return ""
}
func (d Deps) loadEditablePost(ctx *webctx.Context, idParam string) (*models.Post, string) {
if msg := d.ensureCanWrite(ctx); msg != "" {
return nil, msg
}
idStr := stripIDParam(idParam, d.Settings.Permalink().Ext)
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil || id == 0 {
return nil, "帖子不存在"
}
post, err := d.Post.FindByID(uint(id))
if err != nil {
return nil, "帖子不存在"
}
if !ctx.IsAdmin() && post.UserID != ctx.UserID() {
return nil, "无权编辑此帖"
}
if reason := d.Post.EditBlockReason(post, ctx.IsAdmin()); reason != "" {
return nil, reason
}
return post, ""
}

View File

@@ -7,27 +7,74 @@ import (
"time"
"git.iioio.com/freefire/jiang13-forum/modules/auth"
"git.iioio.com/freefire/jiang13-forum/models"
"git.iioio.com/freefire/jiang13-forum/modules/webrender"
"git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin"
)
// Deps 页面路由依赖(复用现有 service避免 Phase 1 大搬家)
type Deps struct {
Settings *services.ForumSettingsService
Board *services.BoardService
Post *services.PostService
// Register 注册已安装后的 web 路由
func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) {
g := r.Group("/", authMW.OptionalAuth())
g.GET("/", deps.Home)
g.GET("/board/:id", deps.Home)
g.GET("/post/:id", deps.PostView)
g.GET("/post/:id/edit", authMW.RequireAuth(), deps.PostEditGet)
g.POST("/post/:id/edit", authMW.RequireAuth(), deps.PostEditPost)
g.POST("/post/:id/comments", authMW.RequireAuth(), deps.PostComment)
g.POST("/post/:id/like", authMW.RequireAuth(), deps.PostLike)
g.POST("/post/:id/favorite", authMW.RequireAuth(), deps.PostFavorite)
g.GET("/login", deps.LoginGet)
g.POST("/login", deps.LoginPost)
g.POST("/logout", deps.LogoutPost)
g.GET("/register", deps.RegisterGet)
g.POST("/register", deps.RegisterPost)
g.POST("/register/send-code", deps.RegisterSendCode)
g.GET("/compose", authMW.RequireAuth(), deps.ComposeGet)
g.POST("/compose", authMW.RequireAuth(), deps.ComposePost)
g.POST("/compose/upload", authMW.RequireAuth(), deps.ComposeUpload)
g.GET("/admin/login", func(c *gin.Context) { c.Redirect(http.StatusFound, "/login?redirect=/admin/dashboard") })
admin := g.Group("/admin", authMW.RequireAuth(), authMW.RequireAdmin())
{
admin.GET("", func(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/dashboard") })
admin.GET("/dashboard", deps.AdminDashboard)
admin.GET("/boards", deps.AdminBoardsGet)
admin.POST("/boards", deps.AdminBoardCreate)
admin.POST("/boards/:id", deps.AdminBoardUpdate)
admin.POST("/boards/:id/delete", deps.AdminBoardDelete)
admin.GET("/moderation", deps.AdminModerationGet)
admin.POST("/posts/:id/approve", deps.AdminPostApprove)
admin.POST("/posts/:id/reject", deps.AdminPostReject)
admin.POST("/comments/:id/approve", deps.AdminCommentApprove)
admin.POST("/comments/:id/reject", deps.AdminCommentReject)
admin.GET("/settings", deps.AdminSettingsGet)
admin.POST("/settings/brand", deps.AdminSettingsBrandPost)
admin.POST("/settings/limits", deps.AdminSettingsLimitsPost)
admin.POST("/settings/filter-words", deps.AdminSettingsFilterWordsPost)
}
g.GET("/profile", deps.PendingPage)
g.GET("/messages", deps.PendingPage)
g.GET("/favorites", deps.PendingPage)
g.GET("/projects", deps.PendingPage)
g.GET("/links", deps.PendingPage)
g.GET("/boards", deps.PendingPage)
}
// BoardView 侧栏板块
type BoardView struct {
ID uint
Name string
// HomePageData Feed
type HomePageData struct {
PageChrome
BoardName string
Sort string
Posts []PostListItem
Page int
PrevPage int
NextPage int
HasPrev bool
HasMore bool
}
// PostView 列表项
type PostView struct {
// PostListItem 列表项
type PostListItem struct {
ID uint
Title string
AuthorName string
@@ -38,39 +85,10 @@ type PostView struct {
CreatedLabel string
}
// HomePageData 首页 / 板块 Feed
type HomePageData struct {
Title string
Description string
SiteName string
Slogan string
LogoMark string
LoggedIn bool
IsAdmin bool
ViewerName string
Boards []BoardView
ActiveBoard uint
BoardName string
Sort string
Posts []PostView
Page int
PrevPage int
NextPage int
HasPrev bool
HasMore bool
}
// Register 注册已迁移的 SSR 页面(优先于 SPA
func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) {
g := r.Group("/", authMW.OptionalAuth())
g.GET("/", deps.Home)
g.GET("/board/:id", deps.Home)
}
// Home SSR 首页与板块列表
// Home 首页 / 板块
func (d Deps) Home(c *gin.Context) {
brand := d.Settings.SiteBranding()
sort := c.DefaultQuery("sort", "latest")
ctx := d.ctx(c)
sort := normalizeSort(c.DefaultQuery("sort", "latest"))
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
@@ -80,54 +98,38 @@ func (d Deps) Home(c *gin.Context) {
var boardID uint
var boardName string
if idStr := c.Param("id"); idStr != "" {
// 兼容伪静态后缀 123.html
idStr = strings.TrimSuffix(idStr, "."+d.Settings.Permalink().Ext)
idStr = strings.TrimSuffix(idStr, ".html")
idStr = strings.TrimSuffix(idStr, ".htm")
idStr = stripIDParam(idStr, d.Settings.Permalink().Ext)
if n, err := strconv.ParseUint(idStr, 10, 64); err == nil {
boardID = uint(n)
}
}
boards, _ := d.Board.List()
boardViews := make([]BoardView, 0, len(boards))
for _, b := range boards {
boardViews = append(boardViews, BoardView{ID: b.ID, Name: b.Name})
chrome := d.chrome(ctx, "", "", "home/feed")
chrome.ActiveBoard = boardID
for _, b := range chrome.Boards {
if b.ID == boardID {
boardName = b.Name
break
}
}
var uid uint
if v, ok := c.Get(auth.CtxUserID); ok {
uid, _ = v.(uint)
if boardName != "" {
chrome.Title = boardName + " · " + chrome.SiteName
}
isAdmin := false
if v, ok := c.Get(auth.CtxRole); ok {
switch r := v.(type) {
case models.Role:
isAdmin = r == models.RoleAdmin
case string:
isAdmin = r == string(models.RoleAdmin)
}
}
username, _ := c.Get(auth.CtxUsername)
q := services.PostListQuery{
items, total, err := d.Post.ListItems(services.PostListQuery{
BoardID: boardID,
Page: page,
Size: size,
Sort: sort,
ViewerID: uid,
ViewerIsAdmin: isAdmin,
}
items, total, err := d.Post.ListItems(q)
ViewerID: ctx.UserID(),
ViewerIsAdmin: ctx.IsAdmin(),
})
if err != nil {
c.String(http.StatusInternalServerError, "加载帖子失败")
return
}
posts := make([]PostView, 0, len(items))
posts := make([]PostListItem, 0, len(items))
for _, it := range items {
author := strings.TrimSpace(it.User.Nickname)
if author == "" {
@@ -137,49 +139,24 @@ func (d Deps) Home(c *gin.Context) {
if it.Board.ID > 0 {
bname = it.Board.Name
}
posts = append(posts, PostView{
ID: it.ID,
Title: it.Title,
AuthorName: author,
BoardName: bname,
Pinned: it.Pinned,
Featured: it.Featured,
CommentCount: it.CommentCount,
CreatedLabel: formatTime(it.CreatedAt),
posts = append(posts, PostListItem{
ID: it.ID, Title: it.Title, AuthorName: author, BoardName: bname,
Pinned: it.Pinned, Featured: it.Featured, CommentCount: it.CommentCount,
CreatedLabel: it.CreatedAt.Local().Format("2006-01-02 15:04"),
})
}
title := brand.DocumentTitle()
if boardName != "" {
title = boardName + " · " + brand.Name
}
data := HomePageData{
Title: title,
Description: brand.MetaDescription(),
SiteName: brand.Name,
Slogan: brand.Slogan,
LogoMark: firstRuneOr(brand.LogoMark, "姜"),
LoggedIn: uid > 0,
IsAdmin: isAdmin,
ViewerName: fmtViewer(username),
Boards: boardViews,
ActiveBoard: boardID,
BoardName: boardName,
Sort: normalizeSort(sort),
Posts: posts,
Page: page,
PrevPage: page - 1,
NextPage: page + 1,
HasPrev: page > 1,
HasMore: int64(page*size) < total,
}
c.Header("Content-Type", "text/html; charset=utf-8")
c.Status(http.StatusOK)
if err := webrender.Execute(c.Writer, "home", data); err != nil {
c.String(http.StatusInternalServerError, "模板渲染失败: %v", err)
}
ctx.HTML(http.StatusOK, "home", HomePageData{
PageChrome: chrome,
BoardName: boardName,
Sort: sort,
Posts: posts,
Page: page,
PrevPage: page - 1,
NextPage: page + 1,
HasPrev: page > 1,
HasMore: int64(page*size) < total,
})
}
func normalizeSort(s string) string {
@@ -194,26 +171,3 @@ func normalizeSort(s string) string {
func formatTime(t time.Time) string {
return t.Local().Format("2006-01-02 15:04")
}
func firstRuneOr(s, fallback string) string {
s = strings.TrimSpace(s)
if s == "" {
return fallback
}
for _, r := range s {
return string(r)
}
return fallback
}
func fmtViewer(v any) string {
if v == nil {
return "我的"
}
s, _ := v.(string)
s = strings.TrimSpace(s)
if s == "" {
return "我的"
}
return s
}

35
routers/web/pending.go Normal file
View File

@@ -0,0 +1,35 @@
package web
import (
"net/http"
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
"github.com/gin-gonic/gin"
)
type pendingData struct {
PageChrome
Heading string
Message string
}
// PendingPage 未迁移页
func (d Deps) PendingPage(c *gin.Context) {
ctx := d.ctx(c)
chrome := d.chrome(ctx, "页面准备中 · "+d.Settings.SiteBranding().Name, "", "")
ctx.HTML(http.StatusOK, "status/pending", pendingData{
PageChrome: chrome,
Heading: "页面准备中",
Message: "该功能尚未用模板实现,请稍后再来。",
})
}
func (d Deps) render404(ctx *webctx.Context) {
chrome := d.chrome(ctx, "页面不存在 · "+d.Settings.SiteBranding().Name, "", "")
ctx.HTML(http.StatusNotFound, "status/404", chrome)
}
// NotFound NoRoute
func (d Deps) NotFound(c *gin.Context) {
d.render404(d.ctx(c))
}

193
routers/web/post.go Normal file
View File

@@ -0,0 +1,193 @@
package web
import (
"fmt"
"html"
"net/http"
"net/url"
"strconv"
"strings"
"git.iioio.com/freefire/jiang13-forum/models"
"git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin"
)
// PostPageData 帖详情
type PostPageData struct {
PageChrome
PostID uint
PostPath string
PostTitle string
AuthorName string
BoardID uint
BoardName string
Pinned bool
Featured bool
PostTypeLabel string
CreatedLabel string
ViewCount int
LikeCount int
Liked bool
Favorited bool
BodyHTML string
CommentCount int
Comments []CommentView
CommentsLocked bool
CanEdit bool
}
// CommentView 评论
type CommentView struct {
Floor int
AuthorName string
CreatedLabel string
Content string
ContentHidden bool
}
// PostView GET /post/:id
func (d Deps) PostView(c *gin.Context) {
ctx := d.ctx(c)
idStr := stripIDParam(c.Param("id"), d.Settings.Permalink().Ext)
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil || id == 0 {
d.render404(ctx)
return
}
post, err := d.Post.FindByID(uint(id))
if err != nil || !services.CanViewPost(post, ctx.UserID(), ctx.IsAdmin()) {
d.render404(ctx)
return
}
if post.Status == models.ContentStatusPublished {
d.Post.RecordView(uint(id))
}
hasReplied := ctx.UserID() > 0 && d.Comment.HasUserReplied(uint(id), ctx.UserID())
body := services.ApplyPostContentGates(post.Content, post, ctx.UserID(), ctx.IsAdmin(), hasReplied)
comments, _ := d.Comment.ListByPost(uint(id), ctx.UserID(), ctx.IsAdmin(), post.UserID, nil)
cv := make([]CommentView, 0, len(comments))
for _, cm := range comments {
an := strings.TrimSpace(cm.User.Nickname)
if an == "" {
an = cm.User.Username
}
cv = append(cv, CommentView{
Floor: cm.Floor, AuthorName: an, CreatedLabel: formatTime(cm.CreatedAt),
Content: cm.Content, ContentHidden: cm.ContentHidden,
})
}
author := strings.TrimSpace(post.User.Nickname)
if author == "" {
author = post.User.Username
}
boardName := ""
if post.Board.ID > 0 {
boardName = post.Board.Name
}
chrome := d.chrome(ctx, post.Title+" · "+d.Settings.SiteBranding().Name, "", "post/body")
chrome.ActiveBoard = post.BoardID
ctx.HTML(http.StatusOK, "post", PostPageData{
PageChrome: chrome, PostID: post.ID,
PostPath: url.QueryEscape(fmt.Sprintf("/post/%d", post.ID)),
PostTitle: post.Title, AuthorName: author, BoardID: post.BoardID, BoardName: boardName,
Pinned: post.Pinned || post.BoardPinned, Featured: post.Featured,
PostTypeLabel: postTypeLabel(post.PostType), CreatedLabel: formatTime(post.CreatedAt),
ViewCount: post.ViewCount, LikeCount: post.LikeCount,
Liked: d.Post.IsLiked(ctx.UserID(), post.ID), Favorited: d.Post.IsFavorited(ctx.UserID(), post.ID),
BodyHTML: body, CommentCount: len(cv), Comments: cv,
CommentsLocked: post.CommentsLocked,
CanEdit: d.Post.CanUserEdit(post, ctx.UserID(), ctx.IsAdmin()),
})
}
func postTypeLabel(t string) string {
switch t {
case models.PostTypeQuestion:
return "问答"
case models.PostTypePoll:
return "投票"
case models.PostTypeBounty:
return "悬赏"
case models.PostTypeLottery:
return "抽奖"
default:
return ""
}
}
// PostComment POST 评论
func (d Deps) PostComment(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
ctx.SetFlash("无效请求,请重试")
ctx.Redirect("/")
return
}
id, err := parsePostID(c, d)
if err != nil {
d.render404(ctx)
return
}
content := strings.TrimSpace(c.PostForm("content"))
if content == "" {
ctx.SetFlash("评论不能为空")
ctx.Redirect(fmt.Sprintf("/post/%d#comments", id))
return
}
safe := "<p>" + html.EscapeString(content) + "</p>"
_, err = d.Comment.Create(services.CommentCreateInput{
PostID: id, UserID: ctx.UserID(), Content: safe,
})
if err != nil {
ctx.SetFlash(err.Error())
ctx.Redirect(fmt.Sprintf("/post/%d#comments", id))
return
}
ctx.Redirect(fmt.Sprintf("/post/%d#comments", id))
}
// PostLike 赞
func (d Deps) PostLike(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
ctx.SetFlash("无效请求")
ctx.Redirect("/")
return
}
id, err := parsePostID(c, d)
if err != nil {
d.render404(ctx)
return
}
_, _ = d.Post.ToggleLike(ctx.UserID(), id)
ctx.Redirect(fmt.Sprintf("/post/%d", id))
}
// PostFavorite 收藏
func (d Deps) PostFavorite(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
ctx.SetFlash("无效请求")
ctx.Redirect("/")
return
}
id, err := parsePostID(c, d)
if err != nil {
d.render404(ctx)
return
}
_, _ = d.Post.ToggleFavorite(ctx.UserID(), id)
ctx.Redirect(fmt.Sprintf("/post/%d", id))
}
func parsePostID(c *gin.Context, d Deps) (uint, error) {
idStr := stripIDParam(c.Param("id"), d.Settings.Permalink().Ext)
id, err := strconv.ParseUint(idStr, 10, 64)
return uint(id), err
}