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

@@ -11,6 +11,7 @@ import (
"git.iioio.com/freefire/jiang13-forum/modules/auth"
webpublic "git.iioio.com/freefire/jiang13-forum/public"
"git.iioio.com/freefire/jiang13-forum/routers/api"
"git.iioio.com/freefire/jiang13-forum/routers/install"
webpages "git.iioio.com/freefire/jiang13-forum/routers/web"
"git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin"
@@ -22,7 +23,10 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
r.Use(gin.Recovery())
r.Use(gin.Logger())
// SSR 静态资源
if err := services.EnsureInstallLockFromExistingData(cfg.DataDir); err != nil {
fmt.Fprintf(os.Stderr, "警告: 安装锁检查失败: %v\n", err)
}
if sub, err := fs.Sub(webpublic.Assets, "assets"); err == nil {
ssrFiles := http.StripPrefix("/ssr-assets", http.FileServer(http.FS(sub)))
r.GET("/ssr-assets/*filepath", func(c *gin.Context) {
@@ -30,15 +34,13 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
ssrFiles.ServeHTTP(c.Writer, c.Request)
})
}
if cfg.DevMode {
fmt.Fprintf(os.Stderr, "[dev] SSR 请访问 http://localhost:%d (对照 SPA 请 checkout main\n", cfg.Port)
}
r.Use(install.Guard(cfg.DataDir))
filter := services.NewSensitiveFilter()
_ = services.WriteDefaultFilterWords(cfg.FilterWordsPath())
filter.LoadFromFile(cfg.FilterWordsPath())
settingsSvc := services.NewForumSettingsService()
services.EnsureFilterWordsInSettings(settingsSvc, cfg.FilterWordsPath(), filter)
authSvc := services.NewAuthService(cfg.JWTSecret, filter, settingsSvc)
userSvc := services.NewUserService(filter, settingsSvc)
boardSvc := services.NewBoardService()
@@ -58,16 +60,14 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
if err != nil {
return nil, err
}
// Gitea 仓库同步后置:本阶段不启动后台同步,亦不挂管理入口
giteaSvc := services.NewGiteaService(settingsSvc)
giteaSvc.StartBackgroundSync()
uploadStore := services.NewUploadStore(cfg.DataDir, settingsSvc)
if err := uploadStore.ReloadFromSettings(settingsSvc); err != nil {
// 配置不完整时保持本地磁盘,避免进程无法启动;管理员可在后台修正后热切换
fmt.Fprintf(os.Stderr, "警告: 对象存储初始化失败,暂用本地磁盘: %v\n", err)
_ = uploadStore.Apply(services.StorageConfig{Type: "local"})
}
// 后台同步存量文件到媒体索引,避免列表依赖实时扫盘
go func() {
if n, err := uploadStore.SyncMediaIndex(); err != nil {
fmt.Fprintf(os.Stderr, "警告: 媒体索引同步失败: %v\n", err)
@@ -89,25 +89,26 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
}
authMW := auth.NewAuthMiddleware(authSvc)
// Gitea 式 SSR 公开页(优先于 SPA
install.Register(r, install.Deps{
DataDir: cfg.DataDir, JWTSecret: cfg.JWTSecret,
Auth: authSvc, Settings: settingsSvc,
})
webpages.Register(r, webpages.Deps{
Settings: settingsSvc,
Board: boardSvc,
Post: postSvc,
DataDir: cfg.DataDir, JWTSecret: cfg.JWTSecret,
Settings: settingsSvc, Auth: authSvc,
Board: boardSvc, Post: postSvc, Comment: commentSvc,
Message: messageSvc, Filter: filter,
Limiter: limiter, EmailCode: emailCodeSvc, Store: uploadStore,
}, authMW)
// 缩略图使用独立前缀,避免与 Static("/uploads/*filepath") 路由冲突
r.GET("/media/thumb/*filepath", h.ServeImageThumb)
r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads"))
// 健康检查(容器 / 负载均衡探活)
r.GET("/health", h.APIHealth)
// SEO抓取规则与站点地图
r.GET("/robots.txt", h.RobotsTxt)
r.GET("/sitemap.xml", h.SitemapXML)
// OIDC ProviderGitea 等外部站点 SSO
// OIDC Provider外部机器 / Gitea SSO
r.GET("/.well-known/openid-configuration", h.OIDCDiscovery)
r.GET("/oauth/jwks", h.OIDCJWKS)
r.GET("/oauth/authorize", authMW.OptionalAuth(), h.OIDCAuthorize)
@@ -117,179 +118,12 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
r.GET("/oauth/logout", h.OIDCLogout)
r.POST("/oauth/logout", h.OIDCLogout)
// 公开 JSON API可选登录
pubAPI := r.Group("/api", authMW.OptionalAuth())
{
pubAPI.GET("/me", h.APIMe)
pubAPI.GET("/boards", h.APIBoards)
pubAPI.GET("/stats", h.APIStats)
pubAPI.GET("/forum-limits", h.APIForumLimits)
pubAPI.GET("/site-branding", h.APISiteBranding)
pubAPI.GET("/pages", h.APIPages)
pubAPI.GET("/pages/:slug", h.APIPageDetail)
pubAPI.GET("/captcha", h.APICaptcha)
pubAPI.GET("/register/config", h.APIRegisterConfig)
pubAPI.POST("/register/email-code", auth.RateLimitMiddleware(limiter, "register"), h.APISendRegisterEmailCode)
pubAPI.POST("/password-reset/email-code", auth.RateLimitMiddleware(limiter, "register"), h.APISendResetEmailCode)
pubAPI.POST("/password-reset", auth.RateLimitMiddleware(limiter, "login"), h.APIResetPassword)
pubAPI.GET("/posts", h.APIPosts)
pubAPI.GET("/posts/hot", h.APIHotPosts)
pubAPI.GET("/tags", h.APITags)
pubAPI.GET("/comments/recent", h.APIRecentComments)
// search / recent 须在 :id 之前
pubAPI.GET("/users/search", h.APISearchUsers)
pubAPI.GET("/users/recent", h.APIRecentUsers)
pubAPI.GET("/users/:id", h.APIUserPublic)
pubAPI.GET("/posts/:id", h.APIPostDetail)
pubAPI.GET("/posts/:id/comments", h.APIPostComments)
pubAPI.POST("/posts/:id/comments", auth.RateLimitMiddleware(limiter, "comment"), h.APICreateComment)
pubAPI.GET("/projects", h.APIProjects)
pubAPI.POST("/register", auth.RateLimitMiddleware(limiter, "register"), h.APIRegister)
pubAPI.POST("/login", auth.RateLimitMiddleware(limiter, "login"), h.APILogin)
}
// 需登录 API
api := r.Group("/api", authMW.RequireAuth())
{
api.POST("/logout", h.APILogout)
api.GET("/favorites", h.APIFavorites)
api.GET("/profile/stats", h.APIProfileStats)
api.POST("/profile/nickname", h.APIUpdateProfile)
api.POST("/profile/signature", h.APIUpdateSignature)
api.POST("/profile/password", h.APIUpdatePassword)
api.POST("/profile/avatar", h.APIUploadAvatar)
api.POST("/uploads/image", h.APIUploadPostImage)
api.POST("/posts", auth.RateLimitMiddleware(limiter, "post"), h.APICreatePost)
api.PUT("/posts/:id", h.APIUpdatePost)
api.DELETE("/posts/:id", h.APIDeletePost)
api.GET("/posts/:id/revisions", h.APIPostRevisions)
api.GET("/posts/:id/revisions/:revId", h.APIPostRevisionDetail)
api.POST("/posts/:id/like", h.APIToggleLike)
api.POST("/posts/:id/favorite", h.APIToggleFavorite)
api.POST("/posts/:id/resolve", h.APISetQuestionResolved)
api.POST("/posts/:id/poll/vote", h.APIPollVote)
api.POST("/posts/:id/poll/close", h.APIPollClose)
api.POST("/posts/:id/bounty/award", h.APIBountyAward)
api.POST("/posts/:id/bounty/refund", h.APIBountyRefund)
api.POST("/posts/:id/lottery/draw", h.APILotteryDraw)
api.POST("/posts/:id/report", auth.RateLimitMiddleware(limiter, "report"), h.APICreatePostReport)
api.GET("/messages/unread-count", h.APIMessageUnreadCount)
api.GET("/messages/notifications", h.APIMessageNotifications)
api.POST("/messages/notifications/read", h.APIMarkNotificationsRead)
api.GET("/messages/conversations", h.APIMessageConversations)
api.GET("/messages/conversations/:peerId", h.APIConversationMessages)
api.POST("/messages/conversations/:peerId/read", h.APIMarkConversationRead)
api.POST("/messages", auth.RateLimitMiddleware(limiter, "message"), h.APISendMessage)
api.POST("/messages/read-all", h.APIMarkAllMessagesRead)
api.POST("/comments/:id/like", h.APIToggleCommentLike)
api.POST("/comments/:id/report", auth.RateLimitMiddleware(limiter, "report"), h.APICreateCommentReport)
api.DELETE("/comments/:id", h.APIDeleteComment)
api.PUT("/comments/:id", h.APIUpdateComment)
api.GET("/me/points", h.APIMePoints)
api.GET("/me/check-in", h.APIMeCheckInGet)
api.POST("/me/check-in", h.APIMeCheckIn)
api.GET("/me/lottery", h.APIMeLotteryGet)
api.POST("/me/lottery", h.APIMeLotteryDraw)
api.POST("/posts/:id/unlock", auth.RateLimitMiddleware(limiter, "post"), h.APIUnlockPostBlock)
api.POST("/friend-links/apply", auth.RateLimitMiddleware(limiter, "friend_link"), h.APIApplyFriendLink)
api.POST("/friend-links/logo", auth.RateLimitMiddleware(limiter, "post"), h.APIUploadFriendLinkLogo)
api.GET("/friend-links/my-applies", h.APIMyFriendLinkApplies)
api.PUT("/friend-links/applies/:id", auth.RateLimitMiddleware(limiter, "friend_link"), h.APIUpdateFriendLinkApply)
api.DELETE("/friend-links/applies/:id", h.APICancelFriendLinkApply)
}
// 管理员 APIReact SPA 后台统一使用 JSON
adminAPI := r.Group("/api/admin", authMW.RequireAuth(), authMW.RequireAdmin())
{
adminAPI.GET("/dashboard", h.APIAdminDashboard)
adminAPI.GET("/settings", h.APIAdminSettings)
adminAPI.PUT("/settings/forum", h.APIAdminUpdateForumSettings)
adminAPI.PUT("/settings/mail", h.APIAdminUpdateMailSettings)
adminAPI.POST("/settings/mail/test", h.APIAdminTestMail)
adminAPI.PUT("/settings/oidc", h.APIAdminUpdateOIDCSettings)
adminAPI.PUT("/settings/gitea", h.APIAdminUpdateGiteaSettings)
adminAPI.POST("/settings/gitea/sync", h.APIAdminSyncGitea)
adminAPI.PUT("/settings/storage", h.APIAdminUpdateStorageSettings)
adminAPI.PUT("/settings/branding", h.APIAdminUpdateBranding)
adminAPI.POST("/settings/branding/upload", h.APIAdminUploadBrandingAsset)
adminAPI.POST("/settings/branding/clear", h.APIAdminClearBrandingAsset)
adminAPI.GET("/oauth/clients", h.APIAdminListOAuthClients)
adminAPI.POST("/oauth/clients", h.APIAdminCreateOAuthClient)
adminAPI.PUT("/oauth/clients/:id", h.APIAdminUpdateOAuthClient)
adminAPI.DELETE("/oauth/clients/:id", h.APIAdminDeleteOAuthClient)
adminAPI.GET("/settings/filter-words", h.APIAdminFilterWords)
adminAPI.PUT("/settings/filter-words", h.APIAdminUpdateFilterWords)
adminAPI.POST("/boards", h.APIAdminCreateBoard)
adminAPI.PUT("/boards/:id", h.APIAdminUpdateBoard)
adminAPI.DELETE("/boards/:id", h.APIAdminDeleteBoard)
adminAPI.GET("/pages", h.APIAdminPages)
adminAPI.GET("/pages/:id", h.APIAdminGetPage)
adminAPI.POST("/pages", h.APIAdminCreatePage)
adminAPI.PUT("/pages/:id", h.APIAdminUpdatePage)
adminAPI.PUT("/pages/:id/published", h.APIAdminSetPagePublished)
adminAPI.DELETE("/pages/:id", h.APIAdminDeletePage)
adminAPI.GET("/friend-link-applies", h.APIAdminFriendLinkApplies)
adminAPI.PUT("/friend-link-settings", h.APIAdminUpdateFriendLinkSettings)
adminAPI.POST("/friend-link-applies/:id/approve", h.APIAdminApproveFriendLinkApply)
adminAPI.POST("/friend-link-applies/:id/reject", h.APIAdminRejectFriendLinkApply)
adminAPI.POST("/friend-link-applies/:id/recheck", h.APIAdminRecheckFriendLinkApply)
adminAPI.GET("/posts", h.APIAdminPosts)
adminAPI.GET("/posts/trash", h.APIAdminTrashPosts)
adminAPI.POST("/posts/:id/pin", h.APIAdminPinPost)
adminAPI.POST("/posts/:id/board-pin", h.APIAdminBoardPinPost)
adminAPI.POST("/posts/:id/feature", h.APIAdminFeaturePost)
adminAPI.POST("/posts/:id/lock", h.APIAdminLockPost)
adminAPI.POST("/posts/:id/comments-lock", h.APIAdminCommentsLockPost)
adminAPI.POST("/posts/:id/approve", h.APIAdminApprovePost)
adminAPI.POST("/posts/:id/reject", h.APIAdminRejectPost)
adminAPI.POST("/posts/:id/restore", h.APIAdminRestorePost)
adminAPI.DELETE("/posts/:id/purge", h.APIAdminPurgePost)
adminAPI.DELETE("/posts/:id", h.APIAdminDeletePost)
adminAPI.GET("/reports", h.APIAdminReports)
adminAPI.POST("/reports/:id/handle", h.APIAdminHandleReport)
adminAPI.GET("/comments", h.APIAdminComments)
adminAPI.GET("/comments/trash", h.APIAdminTrashComments)
adminAPI.GET("/comments/:id/revisions", h.APIAdminCommentRevisions)
adminAPI.POST("/comments/:id/approve", h.APIAdminApproveComment)
adminAPI.POST("/comments/:id/reject", h.APIAdminRejectComment)
adminAPI.POST("/comments/:id/restore", h.APIAdminRestoreComment)
adminAPI.DELETE("/comments/:id/purge", h.APIAdminPurgeComment)
adminAPI.DELETE("/comments/:id", h.APIAdminDeleteComment)
adminAPI.GET("/users", h.APIAdminUsers)
adminAPI.POST("/users/:id/ban", h.APIAdminBanUser)
adminAPI.POST("/users/:id/verify", h.APIAdminVerifyUser)
adminAPI.POST("/users/:id/level", h.APIAdminSetUserLevel)
adminAPI.POST("/users/:id/points", h.APIAdminAdjustPoints)
adminAPI.POST("/users/:id/badges", h.APIAdminAwardBadge)
adminAPI.GET("/badges", h.APIAdminListBadges)
adminAPI.POST("/badges", h.APIAdminUpsertBadge)
adminAPI.GET("/media", h.APIAdminMedia)
adminAPI.POST("/media/delete", h.APIAdminDeleteMedia)
adminAPI.POST("/backup", h.APIAdminBackup)
adminAPI.GET("/backup/download/:name", h.APIAdminDownloadBackup)
}
// 管理后台 HTMLSSR 尚未迁移;勿用 /*filepath与 /admin/login 冲突)
adminPendingHTML := `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"/><title>管理后台</title></head><body><h1>管理后台 SSR 迁移中</h1><p>API 仍可用UI 请暂时对照 <code>main</code> 分支 SPA或等待后续模板页。</p><p><a href="/">返回首页</a></p></body></html>`
adminPending := func(c *gin.Context) {
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, adminPendingHTML)
}
admin := r.Group("/admin")
{
admin.GET("/login", func(c *gin.Context) {
c.Redirect(http.StatusFound, "/login")
})
adminAuth := admin.Group("/", authMW.RequireAuth(), authMW.RequireAdmin())
{
adminAuth.GET("/", func(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/dashboard") })
adminAuth.GET("/dashboard", adminPending)
adminAuth.GET("/:page", adminPending)
}
}
// 未迁移公开路径:爬虫可读 HTML / 用户占位(首页与板块已由 routers/web 接管)
r.NoRoute(h.ServePublicSPA)
// 精简机器 API健康检查已注册保留只读探测与 OIDC论坛 UI 不再走 /api
r.NoRoute(webpages.Deps{
DataDir: cfg.DataDir, JWTSecret: cfg.JWTSecret,
Settings: settingsSvc, Auth: authSvc,
Board: boardSvc, Post: postSvc, Comment: commentSvc,
}.NotFound)
return r, nil
}