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")
|
||||
}
|
||||
208
routers/web/auth.go
Normal file
208
routers/web/auth.go
Normal 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
110
routers/web/chrome.go
Normal 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
224
routers/web/compose.go
Normal 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, ""
|
||||
}
|
||||
@@ -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
35
routers/web/pending.go
Normal 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
193
routers/web/post.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user