新增 OIDC/SSO、邮件验证码与 Gitea 项目同步,并强化 Feed 与管理后台。

作为 OIDC Provider 对接 Gitea;注册支持邮件验证码/验证码;侧栏同步公开仓库;Feed 分页、文章大纲、标签云与站点品牌设置。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-31 16:58:22 +08:00
parent 9487c8ab02
commit 822eef96be
83 changed files with 8578 additions and 1222 deletions

View File

@@ -2,13 +2,16 @@ package handler
import (
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"git.iioio.com/freefire/jiang13-forum/middleware"
"git.iioio.com/freefire/jiang13-forum/model"
"git.iioio.com/freefire/jiang13-forum/service"
)
@@ -22,11 +25,13 @@ func (h *Handlers) APIMe(c *gin.Context) {
}
user, err := h.User.GetByID(uid)
if err != nil {
// 账号已删或不存在:清掉失效 cookie与未登录态一致
c.SetCookie(middleware.CookieName, "", -1, "/", "", false, true)
c.JSON(http.StatusOK, gin.H{"user": nil})
return
}
c.JSON(http.StatusOK, gin.H{
"user": user,
"user": user.ToSelf(),
})
}
@@ -125,7 +130,7 @@ func (h *Handlers) APIAdminDashboard(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{
"users": userCount, "posts": postCount, "boards": boardCount,
"comments": commentCount, "online": h.Online.Count(),
"comments": commentCount,
"recent_posts": recentPosts,
})
}
@@ -242,7 +247,7 @@ func (h *Handlers) APIAdminUsers(c *gin.Context) {
users = []model.User{}
}
c.JSON(http.StatusOK, gin.H{
"users": users, "total": total, "page": page,
"users": model.UsersToAdmin(users), "total": total, "page": page,
"total_pages": calcTotalPages(total, size),
})
}
@@ -298,17 +303,110 @@ func (h *Handlers) APIAdminDownloadBackup(c *gin.Context) {
func (h *Handlers) APIAdminSettings(c *gin.Context) {
limits := h.Settings.Limits()
filterContent, _ := service.ReadFilterWordsFile(h.Cfg.FilterWordsPath())
clients, _ := h.Settings.ListOAuthClients()
c.JSON(http.StatusOK, gin.H{
"filter_path": h.Cfg.FilterWordsPath(),
"data_dir": h.Cfg.DataDir,
"db_path": h.Cfg.DBPath(),
"port": h.Cfg.Port,
"limits": limits,
"filter_words": filterContent,
"filter_path": h.Cfg.FilterWordsPath(),
"data_dir": h.Cfg.DataDir,
"db_path": h.Cfg.DBPath(),
"port": h.Cfg.Port,
"limits": limits,
"mail": h.Settings.MailConfigPublic(),
"oidc": h.Settings.OIDCConfigPublic(),
"oauth_clients": clients,
"gitea": h.Settings.GiteaSyncConfigPublic(),
"branding": h.Settings.SiteBranding(),
"filter_words": filterContent,
"filter_word_count": service.CountFilterWords(filterContent),
})
}
// APISiteBranding 前台公开的站点品牌配置
func (h *Handlers) APISiteBranding(c *gin.Context) {
c.JSON(http.StatusOK, h.Settings.SiteBranding())
}
// APIAdminUpdateBranding 更新站点品牌文案
func (h *Handlers) APIAdminUpdateBranding(c *gin.Context) {
var req service.SiteBranding
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := h.Settings.UpdateSiteBranding(req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "站点品牌已保存",
"branding": h.Settings.SiteBranding(),
})
}
// APIAdminUploadBrandingAsset 上传 Logo 或 Faviconform: file + kind=logo|favicon
func (h *Handlers) APIAdminUploadBrandingAsset(c *gin.Context) {
kind := strings.TrimSpace(c.PostForm("kind"))
if kind != "logo" && kind != "favicon" {
c.JSON(http.StatusBadRequest, gin.H{"error": "kind 须为 logo 或 favicon"})
return
}
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择图片文件"})
return
}
const maxBytes = 2 * 1024 * 1024
if file.Size > maxBytes {
c.JSON(http.StatusBadRequest, gin.H{"error": "图片不能超过 2MB"})
return
}
url, err := service.SaveUploadedImage(file, h.Cfg.SiteUploadDir(), "/uploads/site", kind)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
prev := h.Settings.SiteBranding()
if kind == "logo" {
_ = h.Settings.SetSiteLogo(url)
h.removeSiteUploadIfLocal(prev.Logo)
} else {
_ = h.Settings.SetSiteFavicon(url)
h.removeSiteUploadIfLocal(prev.Favicon)
}
c.JSON(http.StatusOK, gin.H{
"message": "上传成功",
"url": url,
"branding": h.Settings.SiteBranding(),
})
}
// APIAdminClearBrandingAsset 清除 Logo 或 Favicon
func (h *Handlers) APIAdminClearBrandingAsset(c *gin.Context) {
var req struct {
Kind string `json:"kind"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
kind := strings.TrimSpace(req.Kind)
brand := h.Settings.SiteBranding()
switch kind {
case "logo":
_ = h.Settings.SetSiteLogo("")
h.removeSiteUploadIfLocal(brand.Logo)
case "favicon":
_ = h.Settings.SetSiteFavicon("")
h.removeSiteUploadIfLocal(brand.Favicon)
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "kind 须为 logo 或 favicon"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "已清除",
"branding": h.Settings.SiteBranding(),
})
}
// APIAdminUpdateForumSettings 更新论坛设置
func (h *Handlers) APIAdminUpdateForumSettings(c *gin.Context) {
var req service.ForumLimits
@@ -326,6 +424,206 @@ func (h *Handlers) APIAdminUpdateForumSettings(c *gin.Context) {
})
}
// APIAdminUpdateMailSettings 更新邮件 SMTP 配置
func (h *Handlers) APIAdminUpdateMailSettings(c *gin.Context) {
var req service.MailConfig
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := h.Settings.UpdateMailConfig(req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "邮件设置已保存",
"mail": h.Settings.MailConfigPublic(),
})
}
// APIAdminUpdateOIDCSettings 更新 OIDC Provider 全局配置
func (h *Handlers) APIAdminUpdateOIDCSettings(c *gin.Context) {
var req service.OIDCConfig
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := h.Settings.UpdateOIDCConfig(req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "OIDC 设置已保存",
"oidc": h.Settings.OIDCConfigPublic(),
})
}
// APIProjects 会员公开 Gitea 项目列表(本地缓存)
func (h *Handlers) APIProjects(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("limit", c.DefaultQuery("size", "30")))
if page < 1 {
page = 1
}
if size < 1 {
size = 30
}
if size > 100 {
size = 100
}
if h.Gitea == nil {
c.JSON(http.StatusOK, gin.H{"projects": []any{}, "total": 0, "page": page, "total_pages": 0})
return
}
list, total, err := h.Gitea.ListPublic(page, size)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"projects": list,
"total": total,
"page": page,
"total_pages": calcTotalPages(total, size),
})
}
// APIAdminUpdateGiteaSettings 更新 Gitea 同步配置
func (h *Handlers) APIAdminUpdateGiteaSettings(c *gin.Context) {
var req service.GiteaSyncConfig
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := h.Settings.UpdateGiteaSyncConfig(req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Gitea 同步设置已保存",
"gitea": h.Settings.GiteaSyncConfigPublic(),
})
}
// APIAdminSyncGitea 立即同步 Gitea 公开仓库
func (h *Handlers) APIAdminSyncGitea(c *gin.Context) {
if h.Gitea == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": service.ErrGiteaNotConfigured.Error()})
return
}
n, err := h.Gitea.SyncRepos()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": fmt.Sprintf("同步完成,共更新 %d 个仓库", n),
"count": n,
"gitea": h.Settings.GiteaSyncConfigPublic(),
})
}
// APIAdminListOAuthClients 列出 OAuth 应用
func (h *Handlers) APIAdminListOAuthClients(c *gin.Context) {
list, err := h.Settings.ListOAuthClients()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取失败"})
return
}
c.JSON(http.StatusOK, gin.H{"clients": list})
}
// APIAdminCreateOAuthClient 创建 OAuth 应用
func (h *Handlers) APIAdminCreateOAuthClient(c *gin.Context) {
var req service.OAuthClientInput
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
view, err := h.Settings.CreateOAuthClient(req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "应用已创建,请立即保存客户端密钥(仅显示一次)",
"client": view,
"oidc": h.Settings.OIDCConfigPublic(),
})
}
// APIAdminUpdateOAuthClient 更新 OAuth 应用
func (h *Handlers) APIAdminUpdateOAuthClient(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效 ID"})
return
}
var req service.OAuthClientInput
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
view, err := h.Settings.UpdateOAuthClient(uint(id), req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
msg := "应用已更新"
if view.ClientSecret != "" {
msg = "应用已更新,新密钥仅显示一次,请立即保存"
}
c.JSON(http.StatusOK, gin.H{
"message": msg,
"client": view,
"oidc": h.Settings.OIDCConfigPublic(),
})
}
// APIAdminDeleteOAuthClient 删除 OAuth 应用
func (h *Handlers) APIAdminDeleteOAuthClient(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效 ID"})
return
}
if err := h.Settings.DeleteOAuthClient(uint(id)); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "应用已删除",
"oidc": h.Settings.OIDCConfigPublic(),
})
}
// APIAdminTestMail 发送测试邮件
func (h *Handlers) APIAdminTestMail(c *gin.Context) {
var req struct {
To string `json:"to" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写收件邮箱"})
return
}
if err := service.ValidateEmail(req.To); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !h.Settings.MailReady() {
c.JSON(http.StatusBadRequest, gin.H{"error": service.ErrMailNotConfigured.Error()})
return
}
siteName := h.Settings.SiteBranding().Name
err := h.Mail.Send(service.NormalizeEmail(req.To), "邮件配置测试",
fmt.Sprintf("这是一封来自%s的测试邮件说明 SMTP 配置正常。", siteName))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "测试邮件已发送"})
}
// APIAdminFilterWords 读取敏感词配置
func (h *Handlers) APIAdminFilterWords(c *gin.Context) {
content, err := service.ReadFilterWordsFile(h.Cfg.FilterWordsPath())
@@ -458,42 +756,28 @@ func (h *Handlers) APIHotPosts(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"posts": items})
}
// APINotifications 最新动态通知
func (h *Handlers) APINotifications(c *gin.Context) {
posts, _, _ := h.Post.List(service.PostListQuery{Page: 1, Size: 8})
type notice struct {
ID uint `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
CreatedAt string `json:"created_at"`
// APITags 标签云(按使用次数聚合)
func (h *Handlers) APITags(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "40"))
tags, err := h.Post.PopularTags(limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
list := make([]notice, 0, len(posts))
for _, p := range posts {
list = append(list, notice{
ID: p.ID, Title: p.Title, Type: "post",
CreatedAt: p.CreatedAt.Format("01-02 15:04"),
})
}
c.JSON(http.StatusOK, gin.H{"notifications": list})
c.JSON(http.StatusOK, gin.H{"tags": tags})
}
// APIOnline 当前浏览统计
func (h *Handlers) APIOnline(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"count": h.Online.Count(),
"members": h.Online.CountMembers(),
"guests": h.Online.CountGuests(),
"users": h.Online.List(20),
})
}
// APIPresence 上报浏览心跳(会员与游客均可)
func (h *Handlers) APIPresence(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"count": h.Online.Count(),
"members": h.Online.CountMembers(),
"guests": h.Online.CountGuests(),
})
// APIRecentComments 最新公开评论
func (h *Handlers) APIRecentComments(c *gin.Context) {
list, err := h.Comment.ListRecentPublic(8)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if list == nil {
list = []service.RecentCommentItem{}
}
c.JSON(http.StatusOK, gin.H{"comments": list})
}
// APIFavorites 我的收藏
@@ -556,8 +840,18 @@ func (h *Handlers) APIPostRevisionDetail(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"revision": rev})
}
func (h *Handlers) APIPing(c *gin.Context) {
h.APIPresence(c)
// removeSiteUploadIfLocal 删除本站 uploads/site 下的旧资源文件
func (h *Handlers) removeSiteUploadIfLocal(urlPath string) {
urlPath = strings.TrimSpace(urlPath)
const prefix = "/uploads/site/"
if !strings.HasPrefix(urlPath, prefix) {
return
}
name := filepath.Base(urlPath)
if name == "" || name == "." || name == ".." {
return
}
_ = os.Remove(filepath.Join(h.Cfg.SiteUploadDir(), name))
}
func isClientLimitError(err error) bool {