新增 OIDC/SSO、邮件验证码与 Gitea 项目同步,并强化 Feed 与管理后台。
作为 OIDC Provider 对接 Gitea;注册支持邮件验证码/验证码;侧栏同步公开仓库;Feed 分页、文章大纲、标签云与站点品牌设置。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -40,7 +40,6 @@ func (h *Handlers) AdminDashboard(c *gin.Context) {
|
||||
"PostCount": postCount,
|
||||
"BoardCount": boardCount,
|
||||
"CommentCount": commentCount,
|
||||
"OnlineCount": h.Online.Count(),
|
||||
"RecentPosts": recentPosts,
|
||||
}))
|
||||
}
|
||||
@@ -223,7 +222,7 @@ func (h *Handlers) AdminAPILogin(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
token, user, err := h.Auth.Login(req.Username, req.Password)
|
||||
token, user, err := h.Auth.Login(req.Username, req.Password, c.ClientIP())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
382
handler/api.go
382
handler/api.go
@@ -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 或 Favicon(form: 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 {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -15,17 +16,21 @@ import (
|
||||
|
||||
// Handlers 聚合所有 HTTP 处理器
|
||||
type Handlers struct {
|
||||
Cfg *config.Config
|
||||
Auth *service.AuthService
|
||||
User *service.UserService
|
||||
Board *service.BoardService
|
||||
Post *service.PostService
|
||||
Comment *service.CommentService
|
||||
Backup *service.BackupService
|
||||
Filter *service.SensitiveFilter
|
||||
Limiter *service.RateLimiter
|
||||
Online *service.OnlineService
|
||||
Settings *service.ForumSettingsService
|
||||
Cfg *config.Config
|
||||
Auth *service.AuthService
|
||||
User *service.UserService
|
||||
Board *service.BoardService
|
||||
Post *service.PostService
|
||||
Comment *service.CommentService
|
||||
Backup *service.BackupService
|
||||
Filter *service.SensitiveFilter
|
||||
Limiter *service.RateLimiter
|
||||
Settings *service.ForumSettingsService
|
||||
Captcha *service.CaptchaService
|
||||
Mail *service.MailService
|
||||
EmailCode *service.EmailCodeService
|
||||
OIDC *service.OIDCService
|
||||
Gitea *service.GiteaService
|
||||
}
|
||||
|
||||
func (h *Handlers) setAuthCookie(c *gin.Context, token string) {
|
||||
@@ -70,8 +75,9 @@ func (h *Handlers) pageData(c *gin.Context, title string, data gin.H) gin.H {
|
||||
data = gin.H{}
|
||||
}
|
||||
data["Title"] = title
|
||||
data["SiteName"] = "姜十三论坛"
|
||||
data["SiteEN"] = "Jiang13 Forum"
|
||||
brand := h.Settings.SiteBranding()
|
||||
data["SiteName"] = brand.Name
|
||||
data["SiteEN"] = brand.NameEN
|
||||
if uid := h.currentUserID(c); uid > 0 {
|
||||
data["CurrentUserID"] = uid
|
||||
if u, err := h.User.GetByID(uid); err == nil {
|
||||
@@ -170,22 +176,82 @@ func (h *Handlers) FavoritesPage(c *gin.Context) {
|
||||
|
||||
// --- API ---
|
||||
|
||||
func (h *Handlers) APIRegister(c *gin.Context) {
|
||||
func (h *Handlers) APICaptcha(c *gin.Context) {
|
||||
id, svg, err := h.Captcha.Generate()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "验证码生成失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": id,
|
||||
"image": "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(svg)),
|
||||
})
|
||||
}
|
||||
|
||||
// APIRegisterConfig 注册页所需公开配置
|
||||
func (h *Handlers) APIRegisterConfig(c *gin.Context) {
|
||||
userCount := h.Auth.UserCount()
|
||||
mailReady := h.Settings.MailReady()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"is_first_user": userCount == 0,
|
||||
"mail_ready": mailReady,
|
||||
"require_email_code": mailReady,
|
||||
"register_open": userCount == 0 || mailReady,
|
||||
})
|
||||
}
|
||||
|
||||
// APISendRegisterEmailCode 发送注册邮箱验证码
|
||||
func (h *Handlers) APISendRegisterEmailCode(c *gin.Context) {
|
||||
var req struct {
|
||||
Username string `json:"username" form:"username" binding:"required"`
|
||||
Password string `json:"password" form:"password" binding:"required"`
|
||||
Nickname string `json:"nickname" form:"nickname"`
|
||||
Email string `json:"email" form:"email" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
user, err := h.Auth.Register(req.Username, req.Password, req.Nickname)
|
||||
if !h.Settings.MailReady() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": service.ErrMailNotConfigured.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.EmailCode.SendRegisterCode(req.Email); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "验证码已发送"})
|
||||
}
|
||||
|
||||
func (h *Handlers) APIRegister(c *gin.Context) {
|
||||
var req struct {
|
||||
Username string `json:"username" form:"username" binding:"required"`
|
||||
Password string `json:"password" form:"password" binding:"required"`
|
||||
Nickname string `json:"nickname" form:"nickname"`
|
||||
Email string `json:"email" form:"email" binding:"required"`
|
||||
EmailCode string `json:"email_code" form:"email_code"`
|
||||
}
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
userCount := h.Auth.UserCount()
|
||||
mailReady := h.Settings.MailReady()
|
||||
if userCount > 0 && !mailReady {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": service.ErrRegisterClosed.Error()})
|
||||
return
|
||||
}
|
||||
if mailReady {
|
||||
if !h.EmailCode.Verify(req.Email, req.EmailCode) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": service.ErrEmailCodeInvalid.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
user, err := h.Auth.Register(req.Username, req.Password, req.Nickname, req.Email)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
token, _, _ := h.Auth.Login(req.Username, req.Password)
|
||||
token, _, _ := h.Auth.Login(req.Username, req.Password, c.ClientIP())
|
||||
h.setAuthCookie(c, token)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "注册成功", "user_id": user.ID})
|
||||
}
|
||||
@@ -199,7 +265,7 @@ func (h *Handlers) APILogin(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
token, user, err := h.Auth.Login(req.Username, req.Password)
|
||||
token, user, err := h.Auth.Login(req.Username, req.Password, c.ClientIP())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -220,7 +286,11 @@ func (h *Handlers) APIUpdateProfile(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
user, _ := h.User.GetByID(h.currentUserID(c))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "昵称已更新", "user": user})
|
||||
var userView any
|
||||
if user != nil {
|
||||
userView = user.ToSelf()
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "昵称已更新", "user": userView})
|
||||
}
|
||||
|
||||
func (h *Handlers) APIUpdatePassword(c *gin.Context) {
|
||||
@@ -368,3 +438,14 @@ func (h *Handlers) APIDeleteComment(c *gin.Context) {
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "评论已删除"})
|
||||
}
|
||||
|
||||
func (h *Handlers) APIUpdateComment(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
content := c.PostForm("content")
|
||||
saved, err := h.Comment.Update(h.currentUserID(c), uint(id), h.isAdmin(c), content)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "评论已更新", "content": saved})
|
||||
}
|
||||
|
||||
224
handler/oidc.go
Normal file
224
handler/oidc.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.iioio.com/freefire/jiang13-forum/middleware"
|
||||
"git.iioio.com/freefire/jiang13-forum/service"
|
||||
)
|
||||
|
||||
// OIDCDiscovery OpenID Provider 元数据
|
||||
func (h *Handlers) OIDCDiscovery(c *gin.Context) {
|
||||
if h.OIDC == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OIDC 未启用"})
|
||||
return
|
||||
}
|
||||
doc, err := h.OIDC.Discovery()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, doc)
|
||||
}
|
||||
|
||||
// OIDCJWKS JSON Web Key Set
|
||||
func (h *Handlers) OIDCJWKS(c *gin.Context) {
|
||||
if h.OIDC == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OIDC 未启用"})
|
||||
return
|
||||
}
|
||||
doc, err := h.OIDC.JWKS()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, doc)
|
||||
}
|
||||
|
||||
// OIDCAuthorize 授权端点:已登录则静默发码;未登录跳转论坛登录
|
||||
func (h *Handlers) OIDCAuthorize(c *gin.Context) {
|
||||
if h.OIDC == nil || !h.OIDC.Enabled() {
|
||||
c.String(http.StatusServiceUnavailable, "OIDC 未配置,请在管理后台「系统设置 → OIDC / SSO」启用并创建 OAuth 应用")
|
||||
return
|
||||
}
|
||||
|
||||
req := service.AuthorizeRequest{
|
||||
ClientID: c.Query("client_id"),
|
||||
RedirectURI: c.Query("redirect_uri"),
|
||||
ResponseType: c.Query("response_type"),
|
||||
Scope: c.Query("scope"),
|
||||
State: c.Query("state"),
|
||||
Nonce: c.Query("nonce"),
|
||||
CodeChallenge: c.Query("code_challenge"),
|
||||
CodeChallengeMethod: c.Query("code_challenge_method"),
|
||||
}
|
||||
|
||||
if err := h.OIDC.ValidateAuthorize(req); err != nil {
|
||||
// redirect_uri 未通过校验时不能重定向,避免开放重定向
|
||||
if errors.Is(err, service.ErrOIDCInvalidRedirect) || errors.Is(err, service.ErrOIDCInvalidClient) {
|
||||
c.String(http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
h.oidcErrorRedirect(c, req.RedirectURI, req.State, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
uid := h.currentUserID(c)
|
||||
if uid == 0 {
|
||||
from := c.Request.URL.RequestURI()
|
||||
c.Redirect(http.StatusFound, "/login?from="+url.QueryEscape(from))
|
||||
return
|
||||
}
|
||||
|
||||
callback, err := h.OIDC.IssueAuthCode(uid, req)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrOIDCUserBanned) {
|
||||
h.oidcErrorRedirect(c, req.RedirectURI, req.State, "access_denied", "账号已被禁言")
|
||||
return
|
||||
}
|
||||
h.oidcErrorRedirect(c, req.RedirectURI, req.State, "server_error", err.Error())
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, callback)
|
||||
}
|
||||
|
||||
func (h *Handlers) oidcErrorRedirect(c *gin.Context, redirectURI, state, code, desc string) {
|
||||
if redirectURI == "" {
|
||||
c.String(http.StatusBadRequest, desc)
|
||||
return
|
||||
}
|
||||
u, err := url.Parse(redirectURI)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, desc)
|
||||
return
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("error", code)
|
||||
q.Set("error_description", desc)
|
||||
if state != "" {
|
||||
q.Set("state", state)
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
c.Redirect(http.StatusFound, u.String())
|
||||
}
|
||||
|
||||
// OIDCToken 令牌端点
|
||||
func (h *Handlers) OIDCToken(c *gin.Context) {
|
||||
if h.OIDC == nil || !h.OIDC.Enabled() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "temporarily_unavailable", "error_description": "OIDC 未配置"})
|
||||
return
|
||||
}
|
||||
|
||||
clientID, clientSecret := c.PostForm("client_id"), c.PostForm("client_secret")
|
||||
if clientID == "" && clientSecret == "" {
|
||||
if id, secret, ok := parseBasicAuth(c.GetHeader("Authorization")); ok {
|
||||
clientID, clientSecret = id, secret
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := h.OIDC.ExchangeCode(service.TokenRequest{
|
||||
GrantType: c.PostForm("grant_type"),
|
||||
Code: c.PostForm("code"),
|
||||
RedirectURI: c.PostForm("redirect_uri"),
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
CodeVerifier: c.PostForm("code_verifier"),
|
||||
})
|
||||
if err != nil {
|
||||
status := http.StatusBadRequest
|
||||
code := "invalid_grant"
|
||||
switch {
|
||||
case errors.Is(err, service.ErrOIDCInvalidClient):
|
||||
status = http.StatusUnauthorized
|
||||
code = "invalid_client"
|
||||
case errors.Is(err, service.ErrOIDCInvalidRequest):
|
||||
code = "invalid_request"
|
||||
case errors.Is(err, service.ErrOIDCPKCEFailed):
|
||||
code = "invalid_grant"
|
||||
}
|
||||
c.JSON(status, gin.H{"error": code, "error_description": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// OIDCUserInfo 用户信息端点
|
||||
func (h *Handlers) OIDCUserInfo(c *gin.Context) {
|
||||
if h.OIDC == nil || !h.OIDC.Enabled() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OIDC 未配置"})
|
||||
return
|
||||
}
|
||||
token := extractBearer(c)
|
||||
if token == "" {
|
||||
c.Header("WWW-Authenticate", `Bearer`)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid_token"})
|
||||
return
|
||||
}
|
||||
info, err := h.OIDC.UserInfo(token)
|
||||
if err != nil {
|
||||
c.Header("WWW-Authenticate", `Bearer error="invalid_token"`)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid_token"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, info)
|
||||
}
|
||||
|
||||
// OIDCLogout RP-Initiated Logout:清除论坛会话并可选跳回客户端
|
||||
func (h *Handlers) OIDCLogout(c *gin.Context) {
|
||||
postLogout := c.Query("post_logout_redirect_uri")
|
||||
if postLogout == "" {
|
||||
postLogout = c.PostForm("post_logout_redirect_uri")
|
||||
}
|
||||
state := c.Query("state")
|
||||
if state == "" {
|
||||
state = c.PostForm("state")
|
||||
}
|
||||
|
||||
c.SetCookie(middleware.CookieName, "", -1, "/", "", false, true)
|
||||
|
||||
if h.OIDC == nil {
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
return
|
||||
}
|
||||
target, err := h.OIDC.ResolveLogoutRedirect(postLogout, state)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, target)
|
||||
}
|
||||
|
||||
func extractBearer(c *gin.Context) string {
|
||||
auth := c.GetHeader("Authorization")
|
||||
if strings.HasPrefix(auth, "Bearer ") {
|
||||
return strings.TrimSpace(strings.TrimPrefix(auth, "Bearer "))
|
||||
}
|
||||
return c.Query("access_token")
|
||||
}
|
||||
|
||||
func parseBasicAuth(header string) (user, pass string, ok bool) {
|
||||
const prefix = "Basic "
|
||||
if !strings.HasPrefix(header, prefix) {
|
||||
return "", "", false
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(header[len(prefix):]))
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
parts := strings.SplitN(string(raw), ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", "", false
|
||||
}
|
||||
// client_id / client_secret 可能被 URL 编码
|
||||
uid, err1 := url.QueryUnescape(parts[0])
|
||||
sec, err2 := url.QueryUnescape(parts[1])
|
||||
if err1 != nil || err2 != nil {
|
||||
return parts[0], parts[1], true
|
||||
}
|
||||
return uid, sec, true
|
||||
}
|
||||
Reference in New Issue
Block a user