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

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
}