新增 OIDC/SSO、邮件验证码与 Gitea 项目同步,并强化 Feed 与管理后台。
作为 OIDC Provider 对接 Gitea;注册支持邮件验证码/验证码;侧栏同步公开仓库;Feed 分页、文章大纲、标签云与站点品牌设置。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -27,18 +27,34 @@ func NewAuthService(jwtSecret string, filter *SensitiveFilter, settings *ForumSe
|
||||
return &AuthService{jwtSecret: jwtSecret, filter: filter, settings: settings}
|
||||
}
|
||||
|
||||
// UserCount 当前用户数
|
||||
func (s *AuthService) UserCount() int64 {
|
||||
var n int64
|
||||
model.DB.Model(&model.User{}).Count(&n)
|
||||
return n
|
||||
}
|
||||
|
||||
// Register 用户注册
|
||||
func (s *AuthService) Register(username, password, nickname string) (*model.User, error) {
|
||||
func (s *AuthService) Register(username, password, nickname, email string) (*model.User, error) {
|
||||
if err := ValidateUsername(username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ValidatePassword(password, s.settings.PasswordMinLen()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
email = NormalizeEmail(email)
|
||||
if err := ValidateEmail(email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var exist model.User
|
||||
if err := model.DB.Where("username = ?", username).First(&exist).Error; err == nil {
|
||||
return nil, ErrUserExists
|
||||
}
|
||||
if err := model.DB.Where("email = ?", email).First(&exist).Error; err == nil {
|
||||
return nil, ErrEmailExists
|
||||
}
|
||||
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -50,14 +66,13 @@ func (s *AuthService) Register(username, password, nickname string) (*model.User
|
||||
|
||||
// 首个注册用户自动成为管理员
|
||||
role := model.RoleUser
|
||||
var userCount int64
|
||||
model.DB.Model(&model.User{}).Count(&userCount)
|
||||
if userCount == 0 {
|
||||
if s.UserCount() == 0 {
|
||||
role = model.RoleAdmin
|
||||
}
|
||||
|
||||
user := &model.User{
|
||||
Username: username,
|
||||
Email: email,
|
||||
Password: hash,
|
||||
Nickname: nickname,
|
||||
Role: role,
|
||||
@@ -68,8 +83,8 @@ func (s *AuthService) Register(username, password, nickname string) (*model.User
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Login 用户登录,返回 JWT token
|
||||
func (s *AuthService) Login(username, password string) (string, *model.User, error) {
|
||||
// Login 用户登录,返回 JWT token;clientIP 写入上次登录记录
|
||||
func (s *AuthService) Login(username, password, clientIP string) (string, *model.User, error) {
|
||||
var user model.User
|
||||
if err := model.DB.Where("username = ?", username).First(&user).Error; err != nil {
|
||||
return "", nil, ErrInvalidCred
|
||||
@@ -80,10 +95,26 @@ func (s *AuthService) Login(username, password string) (string, *model.User, err
|
||||
if !CheckPassword(user.Password, password) {
|
||||
return "", nil, ErrInvalidCred
|
||||
}
|
||||
s.recordLogin(&user, clientIP)
|
||||
token, err := s.GenerateToken(&user)
|
||||
return token, &user, err
|
||||
}
|
||||
|
||||
// recordLogin 记录上次登录时间与 IP(失败不影响登录)
|
||||
func (s *AuthService) recordLogin(user *model.User, clientIP string) {
|
||||
now := time.Now()
|
||||
ip := clientIP
|
||||
if len(ip) > 45 {
|
||||
ip = ip[:45]
|
||||
}
|
||||
_ = model.DB.Model(user).Updates(map[string]interface{}{
|
||||
"last_login_at": now,
|
||||
"last_login_ip": ip,
|
||||
}).Error
|
||||
user.LastLoginAt = &now
|
||||
user.LastLoginIP = ip
|
||||
}
|
||||
|
||||
// GenerateToken 生成 JWT
|
||||
func (s *AuthService) GenerateToken(user *model.User) (string, error) {
|
||||
claims := Claims{
|
||||
|
||||
158
service/captcha.go
Normal file
158
service/captcha.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
captchaLen = 4
|
||||
captchaTTL = 5 * time.Minute
|
||||
captchaChars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
captchaWidth = 120
|
||||
captchaHeight = 40
|
||||
)
|
||||
|
||||
type captchaEntry struct {
|
||||
answer string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// CaptchaService 内存图形验证码
|
||||
type CaptchaService struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]captchaEntry
|
||||
}
|
||||
|
||||
func NewCaptchaService() *CaptchaService {
|
||||
s := &CaptchaService{entries: make(map[string]captchaEntry)}
|
||||
go s.cleanup()
|
||||
return s
|
||||
}
|
||||
|
||||
// Generate 生成验证码,返回 id 与 SVG 图片
|
||||
func (s *CaptchaService) Generate() (id, svg string, err error) {
|
||||
answer, err := randomCaptchaText(captchaLen)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
rawID := make([]byte, 16)
|
||||
if _, err := rand.Read(rawID); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
id = hex.EncodeToString(rawID)
|
||||
|
||||
s.mu.Lock()
|
||||
s.entries[id] = captchaEntry{
|
||||
answer: strings.ToUpper(answer),
|
||||
expiresAt: time.Now().Add(captchaTTL),
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
return id, renderCaptchaSVG(answer), nil
|
||||
}
|
||||
|
||||
// Verify 校验验证码(一次性,大小写不敏感)
|
||||
func (s *CaptchaService) Verify(id, answer string) bool {
|
||||
if id == "" || answer == "" {
|
||||
return false
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
entry, ok := s.entries[id]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
delete(s.entries, id)
|
||||
if time.Now().After(entry.expiresAt) {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(entry.answer, strings.TrimSpace(answer))
|
||||
}
|
||||
|
||||
func (s *CaptchaService) cleanup() {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
for range ticker.C {
|
||||
now := time.Now()
|
||||
s.mu.Lock()
|
||||
for id, entry := range s.entries {
|
||||
if now.After(entry.expiresAt) {
|
||||
delete(s.entries, id)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func randomCaptchaText(n int) (string, error) {
|
||||
var b strings.Builder
|
||||
b.Grow(n)
|
||||
max := big.NewInt(int64(len(captchaChars)))
|
||||
for i := 0; i < n; i++ {
|
||||
idx, err := rand.Int(rand.Reader, max)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b.WriteByte(captchaChars[idx.Int64()])
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func renderCaptchaSVG(text string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d">`,
|
||||
captchaWidth, captchaHeight, captchaWidth, captchaHeight,
|
||||
))
|
||||
b.WriteString(`<rect width="100%" height="100%" fill="#f4f6f5"/>`)
|
||||
|
||||
// 干扰线
|
||||
for i := 0; i < 4; i++ {
|
||||
x1, _ := randInt(0, captchaWidth)
|
||||
y1, _ := randInt(0, captchaHeight)
|
||||
x2, _ := randInt(0, captchaWidth)
|
||||
y2, _ := randInt(0, captchaHeight)
|
||||
color := noiseColor()
|
||||
b.WriteString(fmt.Sprintf(
|
||||
`<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="1"/>`,
|
||||
x1, y1, x2, y2, color,
|
||||
))
|
||||
}
|
||||
|
||||
step := captchaWidth / (len(text) + 1)
|
||||
for i, ch := range text {
|
||||
x := step*(i+1) - 6
|
||||
y, _ := randInt(26, 34)
|
||||
rot, _ := randInt(-18, 18)
|
||||
b.WriteString(fmt.Sprintf(
|
||||
`<text x="%d" y="%d" fill="#2d5a45" font-size="22" font-family="monospace" font-weight="700" transform="rotate(%d %d %d)">%c</text>`,
|
||||
x, y, rot, x+6, y-6, ch,
|
||||
))
|
||||
}
|
||||
|
||||
b.WriteString(`</svg>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func randInt(min, max int) (int, error) {
|
||||
if max <= min {
|
||||
return min, nil
|
||||
}
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(max-min+1)))
|
||||
if err != nil {
|
||||
return min, err
|
||||
}
|
||||
return min + int(n.Int64()), nil
|
||||
}
|
||||
|
||||
func noiseColor() string {
|
||||
r, _ := randInt(160, 210)
|
||||
g, _ := randInt(170, 220)
|
||||
b, _ := randInt(160, 210)
|
||||
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
)
|
||||
@@ -166,16 +167,114 @@ func (s *CommentService) Delete(userID, commentID uint, isAdmin bool) error {
|
||||
if err := model.DB.First(&comment, commentID).Error; err != nil {
|
||||
return ErrCommentNotFound
|
||||
}
|
||||
if !isAdmin && comment.UserID != userID {
|
||||
if !isAdmin && (comment.UserID == 0 || comment.UserID != userID) {
|
||||
return ErrPermissionDenied
|
||||
}
|
||||
return model.DB.Delete(&comment).Error
|
||||
}
|
||||
|
||||
func (s *CommentService) Update(userID, commentID uint, isAdmin bool, content string) (string, error) {
|
||||
var comment model.Comment
|
||||
if err := model.DB.First(&comment, commentID).Error; err != nil {
|
||||
return "", ErrCommentNotFound
|
||||
}
|
||||
if !isAdmin && (comment.UserID == 0 || comment.UserID != userID) {
|
||||
return "", ErrPermissionDenied
|
||||
}
|
||||
if !isAdmin {
|
||||
window := s.settings.PostEditWindowHours()
|
||||
if window > 0 && time.Since(comment.CreatedAt) > time.Duration(window)*time.Hour {
|
||||
return "", errors.New("已超过可编辑时限")
|
||||
}
|
||||
}
|
||||
|
||||
content = s.filter.Filter(strings.TrimSpace(content))
|
||||
if content == "" {
|
||||
return "", errors.New("评论内容不能为空")
|
||||
}
|
||||
if err := s.settings.ValidateTextLength(content, s.settings.CommentMax(), ErrCommentTooLong); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := model.DB.Model(&comment).Update("content", content).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func (s *CommentService) AdminDelete(commentID uint) error {
|
||||
return model.DB.Delete(&model.Comment{}, commentID).Error
|
||||
}
|
||||
|
||||
// RecentCommentItem 右栏「最新评论」条目
|
||||
type RecentCommentItem struct {
|
||||
ID uint `json:"id"`
|
||||
PostID uint `json:"post_id"`
|
||||
Author string `json:"author"`
|
||||
Avatar string `json:"avatar"`
|
||||
Excerpt string `json:"excerpt"`
|
||||
PostTitle string `json:"post_title"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// ListRecentPublic 前台最新公开评论(排除私密、已删帖)
|
||||
func (s *CommentService) ListRecentPublic(limit int) ([]RecentCommentItem, error) {
|
||||
if limit < 1 {
|
||||
limit = 8
|
||||
}
|
||||
var comments []model.Comment
|
||||
err := model.DB.Preload("User").Preload("Post").
|
||||
Where("is_private = ?", false).
|
||||
Order("id desc").Limit(limit * 2). // 多取一些以跳过已删帖
|
||||
Find(&comments).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]RecentCommentItem, 0, limit)
|
||||
for _, c := range comments {
|
||||
if c.Post.ID == 0 {
|
||||
continue
|
||||
}
|
||||
author := "游客"
|
||||
avatar := ""
|
||||
if c.UserID > 0 && c.User.Nickname != "" {
|
||||
author = c.User.Nickname
|
||||
avatar = c.User.Avatar
|
||||
} else if c.GuestNick != "" {
|
||||
author = c.GuestNick
|
||||
}
|
||||
excerpt := StripHTMLForSearch(c.Content)
|
||||
excerpt = truncateRunes(excerpt, 64)
|
||||
if excerpt == "" {
|
||||
excerpt = "发表了评论"
|
||||
}
|
||||
out = append(out, RecentCommentItem{
|
||||
ID: c.ID,
|
||||
PostID: c.PostID,
|
||||
Author: author,
|
||||
Avatar: avatar,
|
||||
Excerpt: excerpt,
|
||||
PostTitle: c.Post.Title,
|
||||
CreatedAt: c.CreatedAt.Format("01-02 15:04"),
|
||||
})
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func truncateRunes(s string, n int) string {
|
||||
if n <= 0 || s == "" {
|
||||
return s
|
||||
}
|
||||
runes := []rune(s)
|
||||
if len(runes) <= n {
|
||||
return s
|
||||
}
|
||||
return string(runes[:n]) + "…"
|
||||
}
|
||||
|
||||
// ListRecent 管理员查看最近评论
|
||||
func (s *CommentService) ListRecent(page, size int) ([]model.Comment, int64, error) {
|
||||
if page < 1 {
|
||||
|
||||
@@ -3,38 +3,44 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUserExists = errors.New("用户名已存在")
|
||||
ErrInvalidCred = errors.New("用户名或密码错误")
|
||||
ErrUserBanned = errors.New("账号已被禁言")
|
||||
ErrWeakPassword = errors.New("密码至少 6 位")
|
||||
ErrInvalidUsername = errors.New("用户名 3-32 位字母数字下划线")
|
||||
ErrPostNotFound = errors.New("帖子不存在")
|
||||
ErrCommentNotFound = errors.New("评论不存在")
|
||||
ErrPermissionDenied = errors.New("无权操作")
|
||||
ErrBoardNotFound = errors.New("板块不存在")
|
||||
ErrPostEditLocked = errors.New("帖子已被管理员锁定,无法编辑")
|
||||
ErrPostEditExpired = errors.New("已超过可编辑时限")
|
||||
ErrRevisionNotFound = errors.New("历史版本不存在")
|
||||
ErrInvalidSetting = errors.New("无效的设置值")
|
||||
ErrUserExists = errors.New("用户名已存在")
|
||||
ErrEmailExists = errors.New("邮箱已被注册")
|
||||
ErrInvalidCred = errors.New("用户名或密码错误")
|
||||
ErrUserBanned = errors.New("账号已被禁言")
|
||||
ErrWeakPassword = errors.New("密码至少 6 位")
|
||||
ErrInvalidUsername = errors.New("用户名 2-32 位,支持中文、字母、数字与下划线")
|
||||
ErrInvalidEmail = errors.New("邮箱格式不正确")
|
||||
ErrPostNotFound = errors.New("帖子不存在")
|
||||
ErrCommentNotFound = errors.New("评论不存在")
|
||||
ErrPermissionDenied = errors.New("无权操作")
|
||||
ErrBoardNotFound = errors.New("板块不存在")
|
||||
ErrPostEditLocked = errors.New("帖子已被管理员锁定,无法编辑")
|
||||
ErrPostEditExpired = errors.New("已超过可编辑时限")
|
||||
ErrRevisionNotFound = errors.New("历史版本不存在")
|
||||
ErrInvalidSetting = errors.New("无效的设置值")
|
||||
ErrSearchKeywordTooShort = errors.New("搜索关键词过短")
|
||||
ErrSearchKeywordTooLong = errors.New("搜索关键词过长")
|
||||
ErrPostTitleTooLong = errors.New("标题过长")
|
||||
ErrPostTagsTooLong = errors.New("标签过长")
|
||||
ErrPostContentTooLong = errors.New("正文过长")
|
||||
ErrCommentTooLong = errors.New("评论内容过长")
|
||||
ErrCaptchaInvalid = errors.New("验证码错误或已过期")
|
||||
ErrEmailCodeInvalid = errors.New("邮箱验证码错误或已过期")
|
||||
ErrEmailCodeCooldown = errors.New("发送过于频繁,请稍后再试")
|
||||
ErrMailNotConfigured = errors.New("邮件服务未配置或未启用")
|
||||
ErrRegisterClosed = errors.New("论坛暂未开放注册,请联系管理员配置邮件服务")
|
||||
)
|
||||
|
||||
var usernameRe = regexp.MustCompile(`^[a-zA-Z0-9_]{3,32}$`)
|
||||
|
||||
// HashPassword 使用 bcrypt 加密密码
|
||||
func HashPassword(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
@@ -46,11 +52,36 @@ func CheckPassword(hash, password string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
|
||||
// ValidateUsername 校验用户名格式
|
||||
// ValidateUsername 校验用户名:中文/字母/数字/下划线,2-32 个字符
|
||||
func ValidateUsername(username string) error {
|
||||
if !usernameRe.MatchString(username) {
|
||||
n := utf8.RuneCountInString(username)
|
||||
if n < 2 || n > 32 {
|
||||
return ErrInvalidUsername
|
||||
}
|
||||
for _, r := range username {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' {
|
||||
continue
|
||||
}
|
||||
return ErrInvalidUsername
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NormalizeEmail 规范化邮箱(小写去空格)
|
||||
func NormalizeEmail(email string) string {
|
||||
return strings.ToLower(strings.TrimSpace(email))
|
||||
}
|
||||
|
||||
// ValidateEmail 校验邮箱格式
|
||||
func ValidateEmail(email string) error {
|
||||
email = NormalizeEmail(email)
|
||||
if email == "" {
|
||||
return ErrInvalidEmail
|
||||
}
|
||||
addr, err := mail.ParseAddress(email)
|
||||
if err != nil || addr.Address != email {
|
||||
return ErrInvalidEmail
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
128
service/email_code.go
Normal file
128
service/email_code.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
)
|
||||
|
||||
const (
|
||||
emailCodeLen = 6
|
||||
emailCodeTTL = 10 * time.Minute
|
||||
emailCodeCooldown = 60 * time.Second
|
||||
)
|
||||
|
||||
type emailCodeEntry struct {
|
||||
code string
|
||||
expiresAt time.Time
|
||||
sentAt time.Time
|
||||
}
|
||||
|
||||
// EmailCodeService 注册邮箱验证码
|
||||
type EmailCodeService struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]emailCodeEntry
|
||||
mail *MailService
|
||||
}
|
||||
|
||||
func NewEmailCodeService(mail *MailService) *EmailCodeService {
|
||||
s := &EmailCodeService{
|
||||
entries: make(map[string]emailCodeEntry),
|
||||
mail: mail,
|
||||
}
|
||||
go s.cleanup()
|
||||
return s
|
||||
}
|
||||
|
||||
// SendRegisterCode 向邮箱发送注册验证码
|
||||
func (s *EmailCodeService) SendRegisterCode(email string) error {
|
||||
email = NormalizeEmail(email)
|
||||
if err := ValidateEmail(email); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var exist model.User
|
||||
if err := model.DB.Where("email = ?", email).First(&exist).Error; err == nil {
|
||||
return ErrEmailExists
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if prev, ok := s.entries[email]; ok && time.Since(prev.sentAt) < emailCodeCooldown {
|
||||
s.mu.Unlock()
|
||||
return ErrEmailCodeCooldown
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
code, err := randomDigits(emailCodeLen)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
subject := "注册验证码"
|
||||
body := fmt.Sprintf("您的注册验证码是:%s\n\n%d 分钟内有效,如非本人操作请忽略。", code, int(emailCodeTTL.Minutes()))
|
||||
if err := s.mail.Send(email, subject, body); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.entries[email] = emailCodeEntry{
|
||||
code: code,
|
||||
expiresAt: time.Now().Add(emailCodeTTL),
|
||||
sentAt: time.Now(),
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Verify 校验邮箱验证码(一次性)
|
||||
func (s *EmailCodeService) Verify(email, code string) bool {
|
||||
email = NormalizeEmail(email)
|
||||
code = strings.TrimSpace(code)
|
||||
if email == "" || code == "" {
|
||||
return false
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
entry, ok := s.entries[email]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
delete(s.entries, email)
|
||||
if time.Now().After(entry.expiresAt) {
|
||||
return false
|
||||
}
|
||||
return entry.code == code
|
||||
}
|
||||
|
||||
func (s *EmailCodeService) cleanup() {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
for range ticker.C {
|
||||
now := time.Now()
|
||||
s.mu.Lock()
|
||||
for email, entry := range s.entries {
|
||||
if now.After(entry.expiresAt) {
|
||||
delete(s.entries, email)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func randomDigits(n int) (string, error) {
|
||||
var b strings.Builder
|
||||
b.Grow(n)
|
||||
max := big.NewInt(10)
|
||||
for i := 0; i < n; i++ {
|
||||
v, err := rand.Int(rand.Reader, max)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b.WriteByte(byte('0' + v.Int64()))
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
342
service/gitea.go
Normal file
342
service/gitea.go
Normal file
@@ -0,0 +1,342 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrGiteaNotConfigured = errors.New("Gitea 同步未配置或未启用")
|
||||
ErrGiteaSyncBusy = errors.New("同步正在进行中,请稍后再试")
|
||||
)
|
||||
|
||||
// GiteaRepoView 前台展示
|
||||
type GiteaRepoView struct {
|
||||
ID uint `json:"id"`
|
||||
GiteaID int64 `json:"gitea_id"`
|
||||
OwnerLogin string `json:"owner_login"`
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
Description string `json:"description"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
UpdatedAtRemote *time.Time `json:"updated_at_remote"`
|
||||
ForumUserID *uint `json:"forum_user_id,omitempty"`
|
||||
SyncedAt time.Time `json:"synced_at"`
|
||||
}
|
||||
|
||||
// GiteaService 从 Gitea API 同步会员公开仓库
|
||||
type GiteaService struct {
|
||||
settings *ForumSettingsService
|
||||
client *http.Client
|
||||
mu sync.Mutex
|
||||
syncing bool
|
||||
stopCh chan struct{}
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func NewGiteaService(settings *ForumSettingsService) *GiteaService {
|
||||
return &GiteaService{
|
||||
settings: settings,
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// StartBackgroundSync 按配置间隔后台同步;失败只记日志
|
||||
func (g *GiteaService) StartBackgroundSync() {
|
||||
g.wg.Add(1)
|
||||
go func() {
|
||||
defer g.wg.Done()
|
||||
// 启动后稍等再首次尝试,避免拖慢启动
|
||||
timer := time.NewTimer(15 * time.Second)
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-g.stopCh:
|
||||
return
|
||||
case <-timer.C:
|
||||
if _, err := g.SyncRepos(); err != nil && !errors.Is(err, ErrGiteaNotConfigured) && !errors.Is(err, ErrGiteaSyncBusy) {
|
||||
log.Printf("[gitea] 后台同步失败: %v", err)
|
||||
}
|
||||
cfg := g.settings.GiteaSyncConfig()
|
||||
interval := time.Duration(cfg.SyncIntervalMin) * time.Minute
|
||||
if interval < 5*time.Minute {
|
||||
interval = 5 * time.Minute
|
||||
}
|
||||
timer.Reset(interval)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Stop 停止后台同步
|
||||
func (g *GiteaService) Stop() {
|
||||
select {
|
||||
case <-g.stopCh:
|
||||
default:
|
||||
close(g.stopCh)
|
||||
}
|
||||
g.wg.Wait()
|
||||
}
|
||||
|
||||
// ListPublic 列出已同步的公开仓库
|
||||
func (g *GiteaService) ListPublic(page, size int) ([]GiteaRepoView, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 {
|
||||
size = 30
|
||||
}
|
||||
if size > 100 {
|
||||
size = 100
|
||||
}
|
||||
var total int64
|
||||
q := model.DB.Model(&model.GiteaRepo{}).Where("private = ?", false)
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var rows []model.GiteaRepo
|
||||
err := model.DB.Where("private = ?", false).
|
||||
Order("updated_at_remote desc, id desc").
|
||||
Offset((page - 1) * size).
|
||||
Limit(size).
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out := make([]GiteaRepoView, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, toGiteaRepoView(r))
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
// SyncRepos 按论坛用户名拉取 Gitea 公开仓并 upsert
|
||||
func (g *GiteaService) SyncRepos() (int, error) {
|
||||
cfg := g.settings.GiteaSyncConfig()
|
||||
if !cfg.Ready {
|
||||
return 0, ErrGiteaNotConfigured
|
||||
}
|
||||
|
||||
g.mu.Lock()
|
||||
if g.syncing {
|
||||
g.mu.Unlock()
|
||||
return 0, ErrGiteaSyncBusy
|
||||
}
|
||||
g.syncing = true
|
||||
g.mu.Unlock()
|
||||
defer func() {
|
||||
g.mu.Lock()
|
||||
g.syncing = false
|
||||
g.mu.Unlock()
|
||||
}()
|
||||
|
||||
var users []model.User
|
||||
if err := model.DB.Where("banned = ?", false).Select("id", "username").Find(&users).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
seen := make(map[int64]struct{})
|
||||
syncedOwners := make(map[string]struct{})
|
||||
now := time.Now()
|
||||
upserted := 0
|
||||
|
||||
for _, u := range users {
|
||||
username := strings.TrimSpace(u.Username)
|
||||
if username == "" {
|
||||
continue
|
||||
}
|
||||
repos, err := g.fetchUserPublicRepos(cfg.BaseURL, cfg.Token, username)
|
||||
if err != nil {
|
||||
// 用户在 Gitea 不存在等:跳过,不中断整次同步
|
||||
log.Printf("[gitea] 跳过用户 %s: %v", username, err)
|
||||
continue
|
||||
}
|
||||
syncedOwners[strings.ToLower(username)] = struct{}{}
|
||||
uid := u.ID
|
||||
for _, gr := range repos {
|
||||
if gr.Private {
|
||||
continue
|
||||
}
|
||||
seen[gr.ID] = struct{}{}
|
||||
owner := gr.Owner.Login
|
||||
if owner == "" {
|
||||
owner = username
|
||||
}
|
||||
row := model.GiteaRepo{
|
||||
GiteaID: gr.ID,
|
||||
OwnerLogin: owner,
|
||||
Name: gr.Name,
|
||||
FullName: gr.FullName,
|
||||
Description: truncStr(gr.Description, 2048),
|
||||
HTMLURL: gr.HTMLURL,
|
||||
Private: false,
|
||||
UpdatedAtRemote: parseGiteaTime(gr.UpdatedAt),
|
||||
ForumUserID: &uid,
|
||||
SyncedAt: now,
|
||||
}
|
||||
var existing model.GiteaRepo
|
||||
err := model.DB.Where("gitea_id = ?", gr.ID).First(&existing).Error
|
||||
if err != nil {
|
||||
if err := model.DB.Create(&row).Error; err != nil {
|
||||
log.Printf("[gitea] 创建仓库失败 %s: %v", gr.FullName, err)
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
row.ID = existing.ID
|
||||
if err := model.DB.Model(&existing).Updates(map[string]any{
|
||||
"owner_login": row.OwnerLogin,
|
||||
"name": row.Name,
|
||||
"full_name": row.FullName,
|
||||
"description": row.Description,
|
||||
"html_url": row.HTMLURL,
|
||||
"private": false,
|
||||
"updated_at_remote": row.UpdatedAtRemote,
|
||||
"forum_user_id": row.ForumUserID,
|
||||
"synced_at": row.SyncedAt,
|
||||
}).Error; err != nil {
|
||||
log.Printf("[gitea] 更新仓库失败 %s: %v", gr.FullName, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
upserted++
|
||||
}
|
||||
}
|
||||
|
||||
// 仅清理本次成功同步到的 owner 下、却未再出现的旧记录
|
||||
if len(syncedOwners) > 0 {
|
||||
var all []model.GiteaRepo
|
||||
if err := model.DB.Where("private = ?", false).Find(&all).Error; err == nil {
|
||||
for _, r := range all {
|
||||
if _, ok := syncedOwners[strings.ToLower(r.OwnerLogin)]; !ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[r.GiteaID]; !ok {
|
||||
_ = model.DB.Delete(&r).Error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[gitea] 同步完成:upsert %d 个公开仓库", upserted)
|
||||
return upserted, nil
|
||||
}
|
||||
|
||||
type giteaAPIRepo struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
Description string `json:"description"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Private bool `json:"private"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Owner struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"owner"`
|
||||
}
|
||||
|
||||
func (g *GiteaService) fetchUserPublicRepos(baseURL, token, username string) ([]giteaAPIRepo, error) {
|
||||
var all []giteaAPIRepo
|
||||
page := 1
|
||||
for {
|
||||
u, err := url.Parse(strings.TrimRight(baseURL, "/") + "/api/v1/users/" + url.PathEscape(username) + "/repos")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("page", strconv.Itoa(page))
|
||||
q.Set("limit", "50")
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "token "+token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := g.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
_ = resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, fmt.Errorf("用户不存在")
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncStr(string(body), 200))
|
||||
}
|
||||
|
||||
var pageRepos []giteaAPIRepo
|
||||
if err := json.Unmarshal(body, &pageRepos); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(pageRepos) == 0 {
|
||||
break
|
||||
}
|
||||
all = append(all, pageRepos...)
|
||||
if len(pageRepos) < 50 {
|
||||
break
|
||||
}
|
||||
page++
|
||||
if page > 20 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
func toGiteaRepoView(r model.GiteaRepo) GiteaRepoView {
|
||||
return GiteaRepoView{
|
||||
ID: r.ID,
|
||||
GiteaID: r.GiteaID,
|
||||
OwnerLogin: r.OwnerLogin,
|
||||
Name: r.Name,
|
||||
FullName: r.FullName,
|
||||
Description: r.Description,
|
||||
HTMLURL: r.HTMLURL,
|
||||
UpdatedAtRemote: r.UpdatedAtRemote,
|
||||
ForumUserID: r.ForumUserID,
|
||||
SyncedAt: r.SyncedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func parseGiteaTime(raw string) *time.Time {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
layouts := []string{
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05Z",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if t, err := time.Parse(layout, raw); err == nil {
|
||||
return &t
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func truncStr(s string, max int) string {
|
||||
if max <= 0 || len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max]
|
||||
}
|
||||
180
service/mail.go
Normal file
180
service/mail.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MailConfig 邮件 SMTP 配置
|
||||
type MailConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password,omitempty"` // 更新时传入;回显时为空
|
||||
From string `json:"from"`
|
||||
FromName string `json:"from_name"`
|
||||
Encryption string `json:"encryption"`
|
||||
HasPassword bool `json:"has_password"`
|
||||
}
|
||||
|
||||
// MailService 基于 SMTP 发信
|
||||
type MailService struct {
|
||||
settings *ForumSettingsService
|
||||
}
|
||||
|
||||
func NewMailService(settings *ForumSettingsService) *MailService {
|
||||
return &MailService{settings: settings}
|
||||
}
|
||||
|
||||
// Send 发送纯文本邮件
|
||||
func (m *MailService) Send(to, subject, body string) error {
|
||||
cfg := m.settings.MailConfig()
|
||||
if !m.settings.MailReady() {
|
||||
return ErrMailNotConfigured
|
||||
}
|
||||
|
||||
from := strings.TrimSpace(cfg.From)
|
||||
fromHeader := from
|
||||
if name := strings.TrimSpace(cfg.FromName); name != "" {
|
||||
fromHeader = fmt.Sprintf("%s <%s>", encodeMailHeader(name), from)
|
||||
}
|
||||
|
||||
msg := strings.Join([]string{
|
||||
"From: " + fromHeader,
|
||||
"To: " + to,
|
||||
"Subject: " + encodeMailHeader(subject),
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
"Content-Transfer-Encoding: 8bit",
|
||||
"",
|
||||
body,
|
||||
}, "\r\n")
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||
auth := smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
|
||||
|
||||
switch normalizeEncryption(cfg.Encryption) {
|
||||
case "ssl":
|
||||
return sendSMTPWithTLS(addr, cfg.Host, auth, from, []string{to}, []byte(msg), true)
|
||||
case "starttls":
|
||||
return sendSMTPStartTLS(addr, cfg.Host, auth, from, []string{to}, []byte(msg))
|
||||
default:
|
||||
return smtp.SendMail(addr, auth, from, []string{to}, []byte(msg))
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeEncryption(v string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case "ssl", "tls":
|
||||
return "ssl"
|
||||
case "starttls":
|
||||
return "starttls"
|
||||
default:
|
||||
return "none"
|
||||
}
|
||||
}
|
||||
|
||||
func sendSMTPWithTLS(addr, host string, auth smtp.Auth, from string, to []string, msg []byte, implicitTLS bool) error {
|
||||
tlsCfg := &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}
|
||||
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 15 * time.Second}, "tcp", addr, tlsCfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("连接邮件服务器失败: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if auth != nil {
|
||||
if ok, _ := client.Extension("AUTH"); ok {
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("邮件认证失败: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := client.Mail(from); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, rcpt := range to {
|
||||
if err := client.Rcpt(rcpt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(msg); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = implicitTLS
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
func sendSMTPStartTLS(addr, host string, auth smtp.Auth, from string, to []string, msg []byte) error {
|
||||
conn, err := net.DialTimeout("tcp", addr, 15*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("连接邮件服务器失败: %w", err)
|
||||
}
|
||||
client, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
tlsCfg := &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}
|
||||
if err := client.StartTLS(tlsCfg); err != nil {
|
||||
return fmt.Errorf("STARTTLS 失败: %w", err)
|
||||
}
|
||||
}
|
||||
if auth != nil {
|
||||
if ok, _ := client.Extension("AUTH"); ok {
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("邮件认证失败: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := client.Mail(from); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, rcpt := range to {
|
||||
if err := client.Rcpt(rcpt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(msg); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
// encodeMailHeader 简单编码含非 ASCII 的邮件头
|
||||
func encodeMailHeader(s string) string {
|
||||
for _, r := range s {
|
||||
if r > 127 {
|
||||
return "=?UTF-8?B?" + base64.StdEncoding.EncodeToString([]byte(s)) + "?="
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
253
service/oauth_clients.go
Normal file
253
service/oauth_clients.go
Normal file
@@ -0,0 +1,253 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrOAuthClientNotFound = errors.New("OAuth 应用不存在")
|
||||
ErrOAuthClientExists = errors.New("client_id 已存在")
|
||||
ErrOAuthClientInvalid = errors.New("OAuth 应用参数无效")
|
||||
)
|
||||
|
||||
// OAuthClientView 管理端展示(不含密钥哈希)
|
||||
type OAuthClientView struct {
|
||||
ID uint `json:"id"`
|
||||
ClientID string `json:"client_id"`
|
||||
Name string `json:"name"`
|
||||
RedirectURIs string `json:"redirect_uris"`
|
||||
Enabled bool `json:"enabled"`
|
||||
HasSecret bool `json:"has_secret"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// ClientSecret 仅在创建或轮换时返回一次明文
|
||||
ClientSecret string `json:"client_secret,omitempty"`
|
||||
}
|
||||
|
||||
// OAuthClientInput 创建/更新请求
|
||||
type OAuthClientInput struct {
|
||||
ClientID string `json:"client_id"`
|
||||
Name string `json:"name"`
|
||||
RedirectURIs string `json:"redirect_uris"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
// ClientSecret 留空:创建时自动生成;更新时表示不改
|
||||
ClientSecret string `json:"client_secret"`
|
||||
// RotateSecret 更新时为 true 则重新生成密钥
|
||||
RotateSecret bool `json:"rotate_secret"`
|
||||
}
|
||||
|
||||
// ListOAuthClients 列出全部 OAuth 应用
|
||||
func (s *ForumSettingsService) ListOAuthClients() ([]OAuthClientView, error) {
|
||||
var rows []model.OAuthClient
|
||||
if err := model.DB.Order("id asc").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]OAuthClientView, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, toOAuthClientView(r, ""))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CreateOAuthClient 创建应用;返回含明文密钥的视图
|
||||
func (s *ForumSettingsService) CreateOAuthClient(in OAuthClientInput) (*OAuthClientView, error) {
|
||||
clientID := strings.TrimSpace(in.ClientID)
|
||||
name := strings.TrimSpace(in.Name)
|
||||
uris := normalizeRedirectURIs(in.RedirectURIs)
|
||||
if clientID == "" || name == "" || uris == "" {
|
||||
return nil, ErrOAuthClientInvalid
|
||||
}
|
||||
var n int64
|
||||
model.DB.Model(&model.OAuthClient{}).Where("client_id = ?", clientID).Count(&n)
|
||||
if n > 0 {
|
||||
return nil, ErrOAuthClientExists
|
||||
}
|
||||
|
||||
plain := strings.TrimSpace(in.ClientSecret)
|
||||
if plain == "" {
|
||||
var err error
|
||||
plain, err = generateClientSecret()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
hash, err := HashPassword(plain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
enabled := true
|
||||
if in.Enabled != nil {
|
||||
enabled = *in.Enabled
|
||||
}
|
||||
row := model.OAuthClient{
|
||||
ClientID: clientID,
|
||||
ClientSecretHash: hash,
|
||||
Name: name,
|
||||
RedirectURIs: uris,
|
||||
Enabled: enabled,
|
||||
}
|
||||
if err := model.DB.Create(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v := toOAuthClientView(row, plain)
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
// UpdateOAuthClient 更新应用
|
||||
func (s *ForumSettingsService) UpdateOAuthClient(id uint, in OAuthClientInput) (*OAuthClientView, error) {
|
||||
var row model.OAuthClient
|
||||
if err := model.DB.First(&row, id).Error; err != nil {
|
||||
return nil, ErrOAuthClientNotFound
|
||||
}
|
||||
name := strings.TrimSpace(in.Name)
|
||||
uris := normalizeRedirectURIs(in.RedirectURIs)
|
||||
if name == "" || uris == "" {
|
||||
return nil, ErrOAuthClientInvalid
|
||||
}
|
||||
row.Name = name
|
||||
row.RedirectURIs = uris
|
||||
if in.Enabled != nil {
|
||||
row.Enabled = *in.Enabled
|
||||
}
|
||||
|
||||
plain := ""
|
||||
if in.RotateSecret {
|
||||
var err error
|
||||
plain, err = generateClientSecret()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hash, err := HashPassword(plain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row.ClientSecretHash = hash
|
||||
} else if strings.TrimSpace(in.ClientSecret) != "" {
|
||||
plain = strings.TrimSpace(in.ClientSecret)
|
||||
hash, err := HashPassword(plain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row.ClientSecretHash = hash
|
||||
}
|
||||
|
||||
if err := model.DB.Save(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v := toOAuthClientView(row, plain)
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
// DeleteOAuthClient 删除应用
|
||||
func (s *ForumSettingsService) DeleteOAuthClient(id uint) error {
|
||||
res := model.DB.Delete(&model.OAuthClient{}, id)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrOAuthClientNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindEnabledOAuthClient 按 client_id 查找已启用应用
|
||||
func FindEnabledOAuthClient(clientID string) (*model.OAuthClient, error) {
|
||||
var row model.OAuthClient
|
||||
if err := model.DB.Where("client_id = ? AND enabled = ?", clientID, true).First(&row).Error; err != nil {
|
||||
return nil, ErrOIDCInvalidClient
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// VerifyOAuthClientSecret 校验客户端密钥(支持 bcrypt;兼容尚未哈希的历史明文)
|
||||
func VerifyOAuthClientSecret(row *model.OAuthClient, secret string) bool {
|
||||
if row == nil || secret == "" || row.ClientSecretHash == "" {
|
||||
return false
|
||||
}
|
||||
hash := row.ClientSecretHash
|
||||
if strings.HasPrefix(hash, "$2a$") || strings.HasPrefix(hash, "$2b$") || strings.HasPrefix(hash, "$2y$") {
|
||||
return CheckPassword(hash, secret)
|
||||
}
|
||||
// 遗留明文:校验通过后就地升级为哈希
|
||||
if hash == secret {
|
||||
if newHash, err := HashPassword(secret); err == nil {
|
||||
_ = model.DB.Model(row).Update("client_secret_hash", newHash).Error
|
||||
row.ClientSecretHash = newHash
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func toOAuthClientView(row model.OAuthClient, plainSecret string) OAuthClientView {
|
||||
return OAuthClientView{
|
||||
ID: row.ID,
|
||||
ClientID: row.ClientID,
|
||||
Name: row.Name,
|
||||
RedirectURIs: row.RedirectURIs,
|
||||
Enabled: row.Enabled,
|
||||
HasSecret: row.ClientSecretHash != "",
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
ClientSecret: plainSecret,
|
||||
}
|
||||
}
|
||||
|
||||
func generateClientSecret() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// CountEnabledOAuthClients 已启用客户端数量
|
||||
func CountEnabledOAuthClients() int64 {
|
||||
var n int64
|
||||
model.DB.Model(&model.OAuthClient{}).Where("enabled = ?", true).Count(&n)
|
||||
return n
|
||||
}
|
||||
|
||||
// MigrateLegacyOIDCClient 将旧版 ForumSetting 单客户端迁入 oauth_clients(仅一次)
|
||||
func (s *ForumSettingsService) MigrateLegacyOIDCClient() {
|
||||
if CountEnabledOAuthClients() > 0 {
|
||||
// 仍清理遗留明文密钥字段
|
||||
s.clearLegacyOAuthSecrets()
|
||||
return
|
||||
}
|
||||
clientID := strings.TrimSpace(s.getString(SettingOAuthClientID, ""))
|
||||
secret := s.getString(SettingOAuthClientSecret, "")
|
||||
uris := normalizeRedirectURIs(s.getString(SettingOAuthRedirectURIs, ""))
|
||||
if clientID == "" || secret == "" || uris == "" {
|
||||
return
|
||||
}
|
||||
hash := secret
|
||||
if !(strings.HasPrefix(secret, "$2a$") || strings.HasPrefix(secret, "$2b$") || strings.HasPrefix(secret, "$2y$")) {
|
||||
h, err := HashPassword(secret)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
hash = h
|
||||
}
|
||||
_ = model.DB.Create(&model.OAuthClient{
|
||||
ClientID: clientID,
|
||||
ClientSecretHash: hash,
|
||||
Name: "Gitea",
|
||||
RedirectURIs: uris,
|
||||
Enabled: true,
|
||||
}).Error
|
||||
s.clearLegacyOAuthSecrets()
|
||||
}
|
||||
|
||||
func (s *ForumSettingsService) clearLegacyOAuthSecrets() {
|
||||
// 清空遗留明文,避免双源配置
|
||||
if s.getString(SettingOAuthClientSecret, "") != "" {
|
||||
_ = s.setString(SettingOAuthClientSecret, "")
|
||||
}
|
||||
}
|
||||
634
service/oidc.go
Normal file
634
service/oidc.go
Normal file
@@ -0,0 +1,634 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"git.iioio.com/freefire/jiang13-forum/config"
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
)
|
||||
|
||||
const (
|
||||
oidcAuthCodeTTL = 5 * time.Minute
|
||||
oidcAccessTokenTTL = time.Hour
|
||||
oidcIDTokenTTL = time.Hour
|
||||
oidcRSABits = 2048
|
||||
oidcKeyID = "jiang13-oidc-1"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrOIDCNotConfigured = errors.New("OIDC 未配置(请在管理后台启用并至少创建一个 OAuth 应用)")
|
||||
ErrOIDCInvalidClient = errors.New("无效的 client_id 或 client_secret")
|
||||
ErrOIDCInvalidRedirect = errors.New("redirect_uri 未登记")
|
||||
ErrOIDCInvalidRequest = errors.New("授权请求参数无效")
|
||||
ErrOIDCInvalidGrant = errors.New("授权码无效或已过期")
|
||||
ErrOIDCInvalidToken = errors.New("access_token 无效")
|
||||
ErrOIDCUserBanned = errors.New("账号已被禁言,无法授权")
|
||||
ErrOIDCPKCEFailed = errors.New("PKCE 校验失败")
|
||||
ErrOIDCInvalidLogout = errors.New("post_logout_redirect_uri 未登记")
|
||||
)
|
||||
|
||||
// OIDCService 论坛作为 OpenID Connect Provider
|
||||
type OIDCService struct {
|
||||
cfg *config.Config
|
||||
settings *ForumSettingsService
|
||||
|
||||
mu sync.RWMutex
|
||||
privateKey *rsa.PrivateKey
|
||||
}
|
||||
|
||||
// NewOIDCService 创建并加载/生成 RSA 密钥
|
||||
func NewOIDCService(cfg *config.Config, settings *ForumSettingsService) (*OIDCService, error) {
|
||||
s := &OIDCService{cfg: cfg, settings: settings}
|
||||
if err := s.loadOrCreateKey(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *OIDCService) runtime() OIDCConfig {
|
||||
if s.settings != nil {
|
||||
return s.settings.OIDCConfig()
|
||||
}
|
||||
return OIDCConfig{}
|
||||
}
|
||||
|
||||
func (s *OIDCService) loadOrCreateKey() error {
|
||||
keyPath := filepath.Join(s.cfg.DataDir, ".oidc_rsa.pem")
|
||||
if data, err := os.ReadFile(keyPath); err == nil && len(data) > 0 {
|
||||
block, _ := pem.Decode(data)
|
||||
if block == nil {
|
||||
return fmt.Errorf("解析 OIDC RSA 密钥失败")
|
||||
}
|
||||
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
parsed, err2 := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err2 != nil {
|
||||
return fmt.Errorf("解析 OIDC RSA 密钥失败: %w", err)
|
||||
}
|
||||
var ok bool
|
||||
key, ok = parsed.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return fmt.Errorf("OIDC 密钥不是 RSA")
|
||||
}
|
||||
}
|
||||
s.privateKey = key
|
||||
return nil
|
||||
}
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, oidcRSABits)
|
||||
if err != nil {
|
||||
return fmt.Errorf("生成 OIDC RSA 密钥失败: %w", err)
|
||||
}
|
||||
pemBytes := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
||||
})
|
||||
if err := os.WriteFile(keyPath, pemBytes, 0600); err != nil {
|
||||
return fmt.Errorf("写入 OIDC RSA 密钥失败: %w", err)
|
||||
}
|
||||
s.privateKey = key
|
||||
return nil
|
||||
}
|
||||
|
||||
// Enabled 是否可对外提供 OIDC
|
||||
func (s *OIDCService) Enabled() bool {
|
||||
return s.runtime().Ready
|
||||
}
|
||||
|
||||
// Issuer 返回 OIDC issuer
|
||||
func (s *OIDCService) Issuer() string {
|
||||
return s.runtime().RootURL
|
||||
}
|
||||
|
||||
// Discovery 返回 OpenID Provider Metadata
|
||||
func (s *OIDCService) Discovery() (map[string]any, error) {
|
||||
rt := s.runtime()
|
||||
if !rt.Ready {
|
||||
return nil, ErrOIDCNotConfigured
|
||||
}
|
||||
base := rt.RootURL
|
||||
return map[string]any{
|
||||
"issuer": base,
|
||||
"authorization_endpoint": base + "/oauth/authorize",
|
||||
"token_endpoint": base + "/oauth/token",
|
||||
"userinfo_endpoint": base + "/oauth/userinfo",
|
||||
"jwks_uri": base + "/oauth/jwks",
|
||||
"end_session_endpoint": base + "/oauth/logout",
|
||||
"response_types_supported": []string{"code"},
|
||||
"subject_types_supported": []string{"public"},
|
||||
"id_token_signing_alg_values_supported": []string{"RS256"},
|
||||
"scopes_supported": []string{"openid", "profile", "email", "groups"},
|
||||
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post"},
|
||||
"claims_supported": []string{
|
||||
"sub", "name", "preferred_username", "email", "email_verified", "picture", "groups",
|
||||
},
|
||||
"code_challenge_methods_supported": []string{"S256", "plain"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// JWKS 返回 JSON Web Key Set
|
||||
func (s *OIDCService) JWKS() (map[string]any, error) {
|
||||
s.mu.RLock()
|
||||
key := s.privateKey
|
||||
s.mu.RUnlock()
|
||||
if key == nil {
|
||||
return nil, ErrOIDCNotConfigured
|
||||
}
|
||||
pub := key.PublicKey
|
||||
return map[string]any{
|
||||
"keys": []map[string]string{
|
||||
{
|
||||
"kty": "RSA",
|
||||
"use": "sig",
|
||||
"alg": "RS256",
|
||||
"kid": oidcKeyID,
|
||||
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
|
||||
"e": base64.RawURLEncoding.EncodeToString(bigIntBytes(pub.E)),
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func bigIntBytes(e int) []byte {
|
||||
if e == 0 {
|
||||
return []byte{0}
|
||||
}
|
||||
var b []byte
|
||||
for v := e; v > 0; v >>= 8 {
|
||||
b = append([]byte{byte(v & 0xff)}, b...)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// AuthorizeRequest 授权端点查询参数
|
||||
type AuthorizeRequest struct {
|
||||
ClientID string
|
||||
RedirectURI string
|
||||
ResponseType string
|
||||
Scope string
|
||||
State string
|
||||
Nonce string
|
||||
CodeChallenge string
|
||||
CodeChallengeMethod string
|
||||
}
|
||||
|
||||
// ValidateAuthorize 校验授权请求(不要求已登录)
|
||||
func (s *OIDCService) ValidateAuthorize(req AuthorizeRequest) error {
|
||||
rt := s.runtime()
|
||||
if !rt.Ready {
|
||||
return ErrOIDCNotConfigured
|
||||
}
|
||||
client, err := FindEnabledOAuthClient(req.ClientID)
|
||||
if err != nil {
|
||||
return ErrOIDCInvalidClient
|
||||
}
|
||||
if req.ResponseType != "code" {
|
||||
return ErrOIDCInvalidRequest
|
||||
}
|
||||
if !redirectAllowed(client.RedirectURIs, req.RedirectURI) {
|
||||
return ErrOIDCInvalidRedirect
|
||||
}
|
||||
if !hasScope(req.Scope, "openid") {
|
||||
return ErrOIDCInvalidRequest
|
||||
}
|
||||
if req.CodeChallenge != "" {
|
||||
m := strings.ToUpper(req.CodeChallengeMethod)
|
||||
if m == "" {
|
||||
m = "PLAIN"
|
||||
}
|
||||
if m != "S256" && m != "PLAIN" {
|
||||
return ErrOIDCInvalidRequest
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasScope(scope, want string) bool {
|
||||
for _, p := range strings.Fields(scope) {
|
||||
if p == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func redirectAllowed(redirectURIsCSV, uri string) bool {
|
||||
for _, allowed := range splitRedirectURIs(redirectURIsCSV) {
|
||||
if allowed == uri {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IssueAuthCode 已登录用户签发授权码,返回带 code/state 的回调 URL
|
||||
func (s *OIDCService) IssueAuthCode(userID uint, req AuthorizeRequest) (string, error) {
|
||||
if err := s.ValidateAuthorize(req); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var user model.User
|
||||
if err := model.DB.First(&user, userID).Error; err != nil {
|
||||
return "", ErrOIDCInvalidRequest
|
||||
}
|
||||
if user.Banned {
|
||||
return "", ErrOIDCUserBanned
|
||||
}
|
||||
|
||||
code, err := randomToken(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
method := strings.ToUpper(req.CodeChallengeMethod)
|
||||
if req.CodeChallenge != "" && method == "" {
|
||||
method = "PLAIN"
|
||||
}
|
||||
rec := &model.OAuthAuthCode{
|
||||
Code: code,
|
||||
ClientID: req.ClientID,
|
||||
UserID: user.ID,
|
||||
RedirectURI: req.RedirectURI,
|
||||
Scope: req.Scope,
|
||||
Nonce: req.Nonce,
|
||||
CodeChallenge: req.CodeChallenge,
|
||||
CodeChallengeMethod: method,
|
||||
ExpiresAt: time.Now().Add(oidcAuthCodeTTL),
|
||||
}
|
||||
if err := model.DB.Create(rec).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
u, err := url.Parse(req.RedirectURI)
|
||||
if err != nil {
|
||||
return "", ErrOIDCInvalidRedirect
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("code", code)
|
||||
if req.State != "" {
|
||||
q.Set("state", req.State)
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// TokenRequest 换票请求
|
||||
type TokenRequest struct {
|
||||
GrantType string
|
||||
Code string
|
||||
RedirectURI string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
CodeVerifier string
|
||||
}
|
||||
|
||||
// TokenResponse OAuth token 响应
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
IDToken string `json:"id_token,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
}
|
||||
|
||||
// ExchangeCode 授权码换 token
|
||||
func (s *OIDCService) ExchangeCode(req TokenRequest) (*TokenResponse, error) {
|
||||
rt := s.runtime()
|
||||
if !rt.Ready {
|
||||
return nil, ErrOIDCNotConfigured
|
||||
}
|
||||
if req.GrantType != "authorization_code" {
|
||||
return nil, ErrOIDCInvalidRequest
|
||||
}
|
||||
client, err := FindEnabledOAuthClient(req.ClientID)
|
||||
if err != nil || !VerifyOAuthClientSecret(client, req.ClientSecret) {
|
||||
return nil, ErrOIDCInvalidClient
|
||||
}
|
||||
|
||||
var rec model.OAuthAuthCode
|
||||
if err := model.DB.Where("code = ?", req.Code).First(&rec).Error; err != nil {
|
||||
return nil, ErrOIDCInvalidGrant
|
||||
}
|
||||
if rec.Used || time.Now().After(rec.ExpiresAt) {
|
||||
// 重放:作废同用户同客户端未过期码
|
||||
if rec.Used {
|
||||
_ = model.DB.Model(&model.OAuthAuthCode{}).
|
||||
Where("client_id = ? AND user_id = ? AND used = ? AND expires_at > ?",
|
||||
rec.ClientID, rec.UserID, false, time.Now()).
|
||||
Update("used", true).Error
|
||||
}
|
||||
return nil, ErrOIDCInvalidGrant
|
||||
}
|
||||
if rec.ClientID != req.ClientID || rec.RedirectURI != req.RedirectURI {
|
||||
return nil, ErrOIDCInvalidGrant
|
||||
}
|
||||
if err := verifyPKCE(rec.CodeChallenge, rec.CodeChallengeMethod, req.CodeVerifier); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rec.Used = true
|
||||
_ = model.DB.Save(&rec).Error
|
||||
|
||||
var user model.User
|
||||
if err := model.DB.First(&user, rec.UserID).Error; err != nil || user.Banned {
|
||||
return nil, ErrOIDCInvalidGrant
|
||||
}
|
||||
|
||||
access, err := s.signAccessToken(&user, rec.Scope, req.ClientID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
idToken, err := s.signIDToken(&user, rec.Scope, req.ClientID, rec.Nonce)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &TokenResponse{
|
||||
AccessToken: access,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int(oidcAccessTokenTTL.Seconds()),
|
||||
IDToken: idToken,
|
||||
Scope: rec.Scope,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func verifyPKCE(challenge, method, verifier string) error {
|
||||
if challenge == "" {
|
||||
return nil
|
||||
}
|
||||
if verifier == "" {
|
||||
return ErrOIDCPKCEFailed
|
||||
}
|
||||
switch strings.ToUpper(method) {
|
||||
case "S256":
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
calc := base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
if calc != challenge {
|
||||
return ErrOIDCPKCEFailed
|
||||
}
|
||||
case "PLAIN", "":
|
||||
if verifier != challenge {
|
||||
return ErrOIDCPKCEFailed
|
||||
}
|
||||
default:
|
||||
return ErrOIDCPKCEFailed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type oidcAccessClaims struct {
|
||||
Scope string `json:"scope,omitempty"`
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type oidcIDClaims struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
PreferredUsername string `json:"preferred_username,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
EmailVerified bool `json:"email_verified,omitempty"`
|
||||
Picture string `json:"picture,omitempty"`
|
||||
Groups []string `json:"groups,omitempty"`
|
||||
Nonce string `json:"nonce,omitempty"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func (s *OIDCService) signAccessToken(user *model.User, scope, clientID string) (string, error) {
|
||||
now := time.Now()
|
||||
issuer := s.Issuer()
|
||||
claims := oidcAccessClaims{
|
||||
Scope: scope,
|
||||
ClientID: clientID,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: issuer,
|
||||
Subject: strconv.FormatUint(uint64(user.ID), 10),
|
||||
Audience: []string{clientID},
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(oidcAccessTokenTTL)),
|
||||
},
|
||||
}
|
||||
t := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
t.Header["kid"] = oidcKeyID
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return t.SignedString(s.privateKey)
|
||||
}
|
||||
|
||||
func (s *OIDCService) signIDToken(user *model.User, scope, clientID, nonce string) (string, error) {
|
||||
now := time.Now()
|
||||
issuer := s.Issuer()
|
||||
claims := oidcIDClaims{
|
||||
Nonce: nonce,
|
||||
Groups: s.userGroups(user),
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: issuer,
|
||||
Subject: strconv.FormatUint(uint64(user.ID), 10),
|
||||
Audience: []string{clientID},
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(oidcIDTokenTTL)),
|
||||
},
|
||||
}
|
||||
if hasScope(scope, "profile") || scope == "" || hasScope(scope, "openid") {
|
||||
claims.Name = user.Nickname
|
||||
if claims.Name == "" {
|
||||
claims.Name = user.Username
|
||||
}
|
||||
claims.PreferredUsername = user.Username
|
||||
claims.Picture = s.absoluteURL(user.Avatar)
|
||||
}
|
||||
if hasScope(scope, "email") || hasScope(scope, "openid") {
|
||||
claims.Email = user.Email
|
||||
claims.EmailVerified = user.Email != ""
|
||||
}
|
||||
t := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
t.Header["kid"] = oidcKeyID
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return t.SignedString(s.privateKey)
|
||||
}
|
||||
|
||||
func (s *OIDCService) userGroups(user *model.User) []string {
|
||||
rt := s.runtime()
|
||||
groups := make([]string, 0, 2)
|
||||
if rt.UserGroup != "" {
|
||||
groups = append(groups, rt.UserGroup)
|
||||
}
|
||||
if user.Role == model.RoleAdmin && rt.AdminGroup != "" {
|
||||
groups = append(groups, rt.AdminGroup)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
// UserInfo 根据 access_token 返回用户声明
|
||||
func (s *OIDCService) UserInfo(accessToken string) (map[string]any, error) {
|
||||
claims, err := s.parseAccessToken(accessToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uid, err := strconv.ParseUint(claims.Subject, 10, 64)
|
||||
if err != nil {
|
||||
return nil, ErrOIDCInvalidToken
|
||||
}
|
||||
var user model.User
|
||||
if err := model.DB.First(&user, uint(uid)).Error; err != nil || user.Banned {
|
||||
return nil, ErrOIDCInvalidToken
|
||||
}
|
||||
rt := s.runtime()
|
||||
out := map[string]any{
|
||||
"sub": strconv.FormatUint(uint64(user.ID), 10),
|
||||
}
|
||||
if hasScope(claims.Scope, "profile") || claims.Scope == "" {
|
||||
name := user.Nickname
|
||||
if name == "" {
|
||||
name = user.Username
|
||||
}
|
||||
out["name"] = name
|
||||
out["preferred_username"] = user.Username
|
||||
if pic := s.absoluteURL(user.Avatar); pic != "" {
|
||||
out["picture"] = pic
|
||||
}
|
||||
}
|
||||
if hasScope(claims.Scope, "email") || hasScope(claims.Scope, "openid") {
|
||||
if user.Email != "" {
|
||||
out["email"] = user.Email
|
||||
out["email_verified"] = true
|
||||
}
|
||||
}
|
||||
if _, ok := out["preferred_username"]; !ok {
|
||||
out["preferred_username"] = user.Username
|
||||
out["name"] = user.Nickname
|
||||
if out["name"] == "" {
|
||||
out["name"] = user.Username
|
||||
}
|
||||
}
|
||||
groups := s.userGroups(&user)
|
||||
if len(groups) > 0 {
|
||||
claim := rt.GroupClaim
|
||||
if claim == "" {
|
||||
claim = "groups"
|
||||
}
|
||||
out[claim] = groups
|
||||
if claim != "groups" {
|
||||
out["groups"] = groups
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ResolveLogoutRedirect 校验并返回登出后跳转地址(空表示回首页)
|
||||
func (s *OIDCService) ResolveLogoutRedirect(postLogoutRedirectURI, state string) (string, error) {
|
||||
uri := strings.TrimSpace(postLogoutRedirectURI)
|
||||
if uri == "" {
|
||||
return "/", nil
|
||||
}
|
||||
var clients []model.OAuthClient
|
||||
if err := model.DB.Where("enabled = ?", true).Find(&clients).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
allowed := false
|
||||
for _, c := range clients {
|
||||
if redirectAllowed(c.RedirectURIs, uri) {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
// 允许同 host 下任意已登记前缀的登出回调(Gitea 常用 / 根路径)
|
||||
for _, reg := range splitRedirectURIs(c.RedirectURIs) {
|
||||
if sameOrigin(reg, uri) {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if allowed {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
return "", ErrOIDCInvalidLogout
|
||||
}
|
||||
u, err := url.Parse(uri)
|
||||
if err != nil {
|
||||
return "", ErrOIDCInvalidLogout
|
||||
}
|
||||
if state != "" {
|
||||
q := u.Query()
|
||||
q.Set("state", state)
|
||||
u.RawQuery = q.Encode()
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func sameOrigin(a, b string) bool {
|
||||
ua, err1 := url.Parse(a)
|
||||
ub, err2 := url.Parse(b)
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(ua.Scheme, ub.Scheme) && strings.EqualFold(ua.Host, ub.Host)
|
||||
}
|
||||
|
||||
func (s *OIDCService) parseAccessToken(tokenStr string) (*oidcAccessClaims, error) {
|
||||
s.mu.RLock()
|
||||
key := s.privateKey
|
||||
s.mu.RUnlock()
|
||||
if key == nil {
|
||||
return nil, ErrOIDCNotConfigured
|
||||
}
|
||||
tok, err := jwt.ParseWithClaims(tokenStr, &oidcAccessClaims{}, func(t *jwt.Token) (any, error) {
|
||||
if t.Method != jwt.SigningMethodRS256 {
|
||||
return nil, ErrOIDCInvalidToken
|
||||
}
|
||||
return &key.PublicKey, nil
|
||||
})
|
||||
if err != nil || !tok.Valid {
|
||||
return nil, ErrOIDCInvalidToken
|
||||
}
|
||||
claims, ok := tok.Claims.(*oidcAccessClaims)
|
||||
if !ok {
|
||||
return nil, ErrOIDCInvalidToken
|
||||
}
|
||||
if claims.Issuer != s.Issuer() {
|
||||
return nil, ErrOIDCInvalidToken
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (s *OIDCService) absoluteURL(path string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
||||
return path
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
base := s.Issuer()
|
||||
if base == "" {
|
||||
return path
|
||||
}
|
||||
return base + path
|
||||
}
|
||||
|
||||
func randomToken(nBytes int) (string, error) {
|
||||
b := make([]byte, nBytes)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
)
|
||||
|
||||
const onlineTTL = 5 * time.Minute
|
||||
|
||||
// OnlineService 在线浏览追踪(内存):登录会员 + 游客
|
||||
type OnlineService struct {
|
||||
mu sync.RWMutex
|
||||
seen map[uint]time.Time // 登录用户
|
||||
guests map[string]time.Time // 游客访客标识
|
||||
}
|
||||
|
||||
func NewOnlineService() *OnlineService {
|
||||
s := &OnlineService{
|
||||
seen: make(map[uint]time.Time),
|
||||
guests: make(map[string]time.Time),
|
||||
}
|
||||
go s.cleanup()
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *OnlineService) Ping(userID uint) {
|
||||
if userID == 0 {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.seen[userID] = time.Now()
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *OnlineService) PingGuest(visitorID string) {
|
||||
if visitorID == "" {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.guests[visitorID] = time.Now()
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
type OnlineUser struct {
|
||||
ID uint `json:"id"`
|
||||
Nickname string `json:"nickname"`
|
||||
Avatar string `json:"avatar"`
|
||||
}
|
||||
|
||||
func (s *OnlineService) List(limit int) []OnlineUser {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
cutoff := time.Now().Add(-onlineTTL)
|
||||
s.mu.RLock()
|
||||
var ids []uint
|
||||
for id, t := range s.seen {
|
||||
if t.After(cutoff) {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
var users []model.User
|
||||
model.DB.Where("id IN ?", ids).Limit(limit).Find(&users)
|
||||
out := make([]OnlineUser, 0, len(users))
|
||||
for _, u := range users {
|
||||
out = append(out, OnlineUser{ID: u.ID, Nickname: u.Nickname, Avatar: u.Avatar})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *OnlineService) CountMembers() int {
|
||||
return s.countSeen(s.seen)
|
||||
}
|
||||
|
||||
func (s *OnlineService) CountGuests() int {
|
||||
return s.countSeenString(s.guests)
|
||||
}
|
||||
|
||||
// Count 当前浏览总人数(会员 + 游客)
|
||||
func (s *OnlineService) Count() int {
|
||||
return s.CountMembers() + s.CountGuests()
|
||||
}
|
||||
|
||||
func (s *OnlineService) countSeen(m map[uint]time.Time) int {
|
||||
cutoff := time.Now().Add(-onlineTTL)
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
n := 0
|
||||
for _, t := range m {
|
||||
if t.After(cutoff) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (s *OnlineService) countSeenString(m map[string]time.Time) int {
|
||||
cutoff := time.Now().Add(-onlineTTL)
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
n := 0
|
||||
for _, t := range m {
|
||||
if t.After(cutoff) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (s *OnlineService) cleanup() {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
for range ticker.C {
|
||||
cutoff := time.Now().Add(-onlineTTL * 2)
|
||||
s.mu.Lock()
|
||||
for id, t := range s.seen {
|
||||
if t.Before(cutoff) {
|
||||
delete(s.seen, id)
|
||||
}
|
||||
}
|
||||
for id, t := range s.guests {
|
||||
if t.Before(cutoff) {
|
||||
delete(s.guests, id)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -133,6 +134,60 @@ func (s *PostService) HotPosts(limit int) ([]PostListItem, error) {
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// TagCount 标签及其出现次数
|
||||
type TagCount struct {
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// PopularTags 聚合帖子标签,按热度降序返回
|
||||
func (s *PostService) PopularTags(limit int) ([]TagCount, error) {
|
||||
if limit <= 0 {
|
||||
limit = 40
|
||||
}
|
||||
var rows []struct{ Tags string }
|
||||
if err := model.DB.Model(&model.Post{}).
|
||||
Select("tags").
|
||||
Where("tags <> '' AND tags IS NOT NULL").
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
counts := make(map[string]int)
|
||||
// 保留首次出现的原始大小写作为展示名
|
||||
display := make(map[string]string)
|
||||
for _, row := range rows {
|
||||
for _, part := range strings.FieldsFunc(row.Tags, func(r rune) bool {
|
||||
return r == ',' || r == ','
|
||||
}) {
|
||||
name := strings.TrimSpace(part)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(name)
|
||||
counts[key]++
|
||||
if _, ok := display[key]; !ok {
|
||||
display[key] = name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
list := make([]TagCount, 0, len(counts))
|
||||
for key, n := range counts {
|
||||
list = append(list, TagCount{Name: display[key], Count: n})
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
if list[i].Count != list[j].Count {
|
||||
return list[i].Count > list[j].Count
|
||||
}
|
||||
return strings.ToLower(list[i].Name) < strings.ToLower(list[j].Name)
|
||||
})
|
||||
if len(list) > limit {
|
||||
list = list[:limit]
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (s *PostService) CommentCount(postID uint) int {
|
||||
var count int64
|
||||
model.DB.Model(&model.Comment{}).Where("post_id = ?", postID).Count(&count)
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
@@ -27,13 +28,46 @@ const (
|
||||
SettingSearchKeywordMax = "search_keyword_max"
|
||||
|
||||
SettingPageSizeDefault = "page_size_default"
|
||||
SettingPageSizeMax = "page_size_max"
|
||||
|
||||
SettingFeedMaxPages = "feed_max_pages"
|
||||
SettingFeedMaxItems = "feed_max_items"
|
||||
|
||||
SettingPasswordMinLen = "password_min_len"
|
||||
SettingAvatarMaxMB = "avatar_max_mb"
|
||||
|
||||
SettingOpenPostsInNewTab = "open_posts_in_new_tab"
|
||||
SettingOpenContentLinksInNewTab = "open_content_links_in_new_tab"
|
||||
|
||||
SettingSMTPEnabled = "smtp_enabled"
|
||||
SettingSMTPHost = "smtp_host"
|
||||
SettingSMTPPort = "smtp_port"
|
||||
SettingSMTPUsername = "smtp_username"
|
||||
SettingSMTPPassword = "smtp_password"
|
||||
SettingSMTPFrom = "smtp_from"
|
||||
SettingSMTPFromName = "smtp_from_name"
|
||||
SettingSMTPEncryption = "smtp_encryption"
|
||||
|
||||
SettingOIDCEnabled = "oidc_enabled"
|
||||
SettingOIDCRootURL = "oidc_root_url"
|
||||
SettingOIDCGroupClaim = "oidc_group_claim"
|
||||
SettingOIDCAdminGroup = "oidc_admin_group"
|
||||
SettingOIDCUserGroup = "oidc_user_group"
|
||||
// 遗留单客户端字段(仅用于迁移到 oauth_clients)
|
||||
SettingOAuthClientID = "oauth_client_id"
|
||||
SettingOAuthClientSecret = "oauth_client_secret"
|
||||
SettingOAuthRedirectURIs = "oauth_redirect_uris"
|
||||
|
||||
SettingGiteaSyncEnabled = "gitea_sync_enabled"
|
||||
SettingGiteaBaseURL = "gitea_base_url"
|
||||
SettingGiteaToken = "gitea_token"
|
||||
SettingGiteaSyncIntervalMin = "gitea_sync_interval_min"
|
||||
|
||||
SettingSiteName = "site_name"
|
||||
SettingSiteNameEN = "site_name_en"
|
||||
SettingSiteSlogan = "site_slogan"
|
||||
SettingSiteLogoMark = "site_logo_mark"
|
||||
SettingSiteLogo = "site_logo"
|
||||
SettingSiteFavicon = "site_favicon"
|
||||
|
||||
// pageSizeAPIMax 单次列表请求条数硬上限(防客户端传超大 size),非后台可配项
|
||||
pageSizeAPIMax = 100
|
||||
)
|
||||
|
||||
// ForumLimits 论坛可配置限制(API 传输结构)
|
||||
@@ -56,28 +90,28 @@ type ForumLimits struct {
|
||||
SearchKeywordMax int `json:"search_keyword_max"`
|
||||
|
||||
PageSizeDefault int `json:"page_size_default"`
|
||||
PageSizeMax int `json:"page_size_max"`
|
||||
|
||||
FeedMaxPages int `json:"feed_max_pages"`
|
||||
FeedMaxItems int `json:"feed_max_items"`
|
||||
|
||||
PasswordMinLen int `json:"password_min_len"`
|
||||
AvatarMaxMB int `json:"avatar_max_mb"`
|
||||
|
||||
OpenPostsInNewTab bool `json:"open_posts_in_new_tab"`
|
||||
OpenContentLinksInNewTab bool `json:"open_content_links_in_new_tab"`
|
||||
}
|
||||
|
||||
// ForumLimitsPublic 前台可见的限制(不含限流等内部配置)
|
||||
type ForumLimitsPublic struct {
|
||||
PostTitleMax int `json:"post_title_max"`
|
||||
PostTagsMax int `json:"post_tags_max"`
|
||||
PostContentMax int `json:"post_content_max"`
|
||||
CommentMax int `json:"comment_max"`
|
||||
PostTitleMax int `json:"post_title_max"`
|
||||
PostTagsMax int `json:"post_tags_max"`
|
||||
PostContentMax int `json:"post_content_max"`
|
||||
CommentMax int `json:"comment_max"`
|
||||
SearchKeywordMin int `json:"search_keyword_min"`
|
||||
SearchKeywordMax int `json:"search_keyword_max"`
|
||||
PageSizeDefault int `json:"page_size_default"`
|
||||
FeedMaxPages int `json:"feed_max_pages"`
|
||||
FeedMaxItems int `json:"feed_max_items"`
|
||||
PasswordMinLen int `json:"password_min_len"`
|
||||
AvatarMaxMB int `json:"avatar_max_mb"`
|
||||
PasswordMinLen int `json:"password_min_len"`
|
||||
AvatarMaxMB int `json:"avatar_max_mb"`
|
||||
|
||||
OpenPostsInNewTab bool `json:"open_posts_in_new_tab"`
|
||||
OpenContentLinksInNewTab bool `json:"open_content_links_in_new_tab"`
|
||||
}
|
||||
|
||||
type settingDef struct {
|
||||
@@ -105,14 +139,86 @@ var forumSettingDefs = []settingDef{
|
||||
{SettingSearchKeywordMin, "1", 0, 100},
|
||||
{SettingSearchKeywordMax, "50", 1, 200},
|
||||
|
||||
{SettingPageSizeDefault, "30", 1, 200},
|
||||
{SettingPageSizeMax, "50", 1, 200},
|
||||
|
||||
{SettingFeedMaxPages, "10", 1, 100},
|
||||
{SettingFeedMaxItems, "300", 1, 5000},
|
||||
{SettingPageSizeDefault, "30", 1, pageSizeAPIMax},
|
||||
|
||||
{SettingPasswordMinLen, "6", 4, 128},
|
||||
{SettingAvatarMaxMB, "2", 1, 20},
|
||||
|
||||
{SettingOpenPostsInNewTab, "1", 0, 1},
|
||||
{SettingOpenContentLinksInNewTab, "1", 0, 1},
|
||||
}
|
||||
|
||||
var mailSettingDefaults = map[string]string{
|
||||
SettingSMTPEnabled: "0",
|
||||
SettingSMTPHost: "",
|
||||
SettingSMTPPort: "465",
|
||||
SettingSMTPUsername: "",
|
||||
SettingSMTPPassword: "",
|
||||
SettingSMTPFrom: "",
|
||||
SettingSMTPFromName: "姜十三论坛",
|
||||
SettingSMTPEncryption: "ssl",
|
||||
}
|
||||
|
||||
var oidcSettingDefaults = map[string]string{
|
||||
SettingOIDCEnabled: "0",
|
||||
SettingOIDCRootURL: "",
|
||||
SettingOIDCGroupClaim: "groups",
|
||||
SettingOIDCAdminGroup: "gitea-admin",
|
||||
SettingOIDCUserGroup: "gitea-users",
|
||||
SettingOAuthClientID: "",
|
||||
SettingOAuthClientSecret: "",
|
||||
SettingOAuthRedirectURIs: "",
|
||||
}
|
||||
|
||||
var giteaSettingDefaults = map[string]string{
|
||||
SettingGiteaSyncEnabled: "0",
|
||||
SettingGiteaBaseURL: "",
|
||||
SettingGiteaToken: "",
|
||||
SettingGiteaSyncIntervalMin: "60",
|
||||
}
|
||||
|
||||
var siteBrandingDefaults = map[string]string{
|
||||
SettingSiteName: "姜十三论坛",
|
||||
SettingSiteNameEN: "Jiang13 Forum",
|
||||
SettingSiteSlogan: "拾三一隅,自在交流",
|
||||
SettingSiteLogoMark: "姜",
|
||||
SettingSiteLogo: "",
|
||||
SettingSiteFavicon: "",
|
||||
}
|
||||
|
||||
// SiteBranding 站点品牌配置(名称、Logo、Favicon 等)
|
||||
type SiteBranding struct {
|
||||
Name string `json:"name"`
|
||||
NameEN string `json:"name_en"`
|
||||
Slogan string `json:"slogan"`
|
||||
LogoMark string `json:"logo_mark"`
|
||||
Logo string `json:"logo"`
|
||||
Favicon string `json:"favicon"`
|
||||
}
|
||||
|
||||
// GiteaSyncConfig Gitea 仓库同步配置
|
||||
type GiteaSyncConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Token string `json:"token,omitempty"` // 更新时传入;回显时为空
|
||||
HasToken bool `json:"has_token"`
|
||||
SyncIntervalMin int `json:"sync_interval_min"`
|
||||
Ready bool `json:"ready"`
|
||||
RepoCount int64 `json:"repo_count"`
|
||||
}
|
||||
|
||||
// OIDCConfig OIDC Provider 全局配置(应用凭证见 oauth_clients)
|
||||
type OIDCConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
RootURL string `json:"root_url"`
|
||||
Ready bool `json:"ready"`
|
||||
DiscoveryURL string `json:"discovery_url,omitempty"`
|
||||
AuthorizeURL string `json:"authorize_url,omitempty"`
|
||||
LogoutURL string `json:"logout_url,omitempty"`
|
||||
GroupClaim string `json:"group_claim"`
|
||||
AdminGroup string `json:"admin_group"`
|
||||
UserGroup string `json:"user_group"`
|
||||
ClientCount int64 `json:"client_count"`
|
||||
}
|
||||
|
||||
// ForumSettingsService 论坛全局设置
|
||||
@@ -134,6 +240,48 @@ func (s *ForumSettingsService) ensureDefaults() {
|
||||
model.DB.Create(&model.ForumSetting{Key: def.key, Value: def.defaultVal})
|
||||
}
|
||||
}
|
||||
for key, val := range mailSettingDefaults {
|
||||
var count int64
|
||||
model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count)
|
||||
if count == 0 {
|
||||
model.DB.Create(&model.ForumSetting{Key: key, Value: val})
|
||||
}
|
||||
}
|
||||
for key, val := range oidcSettingDefaults {
|
||||
var count int64
|
||||
model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count)
|
||||
if count == 0 {
|
||||
model.DB.Create(&model.ForumSetting{Key: key, Value: val})
|
||||
}
|
||||
}
|
||||
for key, val := range giteaSettingDefaults {
|
||||
var count int64
|
||||
model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count)
|
||||
if count == 0 {
|
||||
model.DB.Create(&model.ForumSetting{Key: key, Value: val})
|
||||
}
|
||||
}
|
||||
for key, val := range siteBrandingDefaults {
|
||||
var count int64
|
||||
model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count)
|
||||
if count == 0 {
|
||||
model.DB.Create(&model.ForumSetting{Key: key, Value: val})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ForumSettingsService) getString(key, fallback string) string {
|
||||
var setting model.ForumSetting
|
||||
if err := model.DB.First(&setting, "`key` = ?", key).Error; err != nil {
|
||||
return fallback
|
||||
}
|
||||
return setting.Value
|
||||
}
|
||||
|
||||
func (s *ForumSettingsService) setString(key, value string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return model.DB.Save(&model.ForumSetting{Key: key, Value: value}).Error
|
||||
}
|
||||
|
||||
func (s *ForumSettingsService) getInt(key string, fallback int) int {
|
||||
@@ -186,13 +334,12 @@ func (s *ForumSettingsService) Limits() ForumLimits {
|
||||
SearchKeywordMax: s.SearchKeywordMax(),
|
||||
|
||||
PageSizeDefault: s.PageSizeDefault(),
|
||||
PageSizeMax: s.PageSizeMax(),
|
||||
|
||||
FeedMaxPages: s.FeedMaxPages(),
|
||||
FeedMaxItems: s.FeedMaxItems(),
|
||||
|
||||
PasswordMinLen: s.PasswordMinLen(),
|
||||
AvatarMaxMB: s.AvatarMaxMB(),
|
||||
|
||||
OpenPostsInNewTab: s.OpenPostsInNewTab(),
|
||||
OpenContentLinksInNewTab: s.OpenContentLinksInNewTab(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,10 +353,11 @@ func (s *ForumSettingsService) PublicLimits() ForumLimitsPublic {
|
||||
SearchKeywordMin: limits.SearchKeywordMin,
|
||||
SearchKeywordMax: limits.SearchKeywordMax,
|
||||
PageSizeDefault: limits.PageSizeDefault,
|
||||
FeedMaxPages: limits.FeedMaxPages,
|
||||
FeedMaxItems: limits.FeedMaxItems,
|
||||
PasswordMinLen: limits.PasswordMinLen,
|
||||
AvatarMaxMB: limits.AvatarMaxMB,
|
||||
|
||||
OpenPostsInNewTab: limits.OpenPostsInNewTab,
|
||||
OpenContentLinksInNewTab: limits.OpenContentLinksInNewTab,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,23 +376,30 @@ func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error {
|
||||
SettingSearchKeywordMin: in.SearchKeywordMin,
|
||||
SettingSearchKeywordMax: in.SearchKeywordMax,
|
||||
SettingPageSizeDefault: in.PageSizeDefault,
|
||||
SettingPageSizeMax: in.PageSizeMax,
|
||||
SettingFeedMaxPages: in.FeedMaxPages,
|
||||
SettingFeedMaxItems: in.FeedMaxItems,
|
||||
SettingPasswordMinLen: in.PasswordMinLen,
|
||||
SettingAvatarMaxMB: in.AvatarMaxMB,
|
||||
}
|
||||
if in.SearchKeywordMax > 0 && in.SearchKeywordMin > in.SearchKeywordMax {
|
||||
return ErrInvalidSetting
|
||||
}
|
||||
if in.PageSizeDefault > in.PageSizeMax {
|
||||
return ErrInvalidSetting
|
||||
}
|
||||
for key, val := range updates {
|
||||
if err := s.setInt(key, val); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
boolUpdates := map[string]bool{
|
||||
SettingOpenPostsInNewTab: in.OpenPostsInNewTab,
|
||||
SettingOpenContentLinksInNewTab: in.OpenContentLinksInNewTab,
|
||||
}
|
||||
for key, on := range boolUpdates {
|
||||
v := "0"
|
||||
if on {
|
||||
v = "1"
|
||||
}
|
||||
if err := s.setString(key, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -280,14 +435,370 @@ func (s *ForumSettingsService) SearchKeywordMin() int { return s.getInt(SettingS
|
||||
func (s *ForumSettingsService) SearchKeywordMax() int { return s.getInt(SettingSearchKeywordMax, 50) }
|
||||
|
||||
func (s *ForumSettingsService) PageSizeDefault() int { return s.getInt(SettingPageSizeDefault, 30) }
|
||||
func (s *ForumSettingsService) PageSizeMax() int { return s.getInt(SettingPageSizeMax, 50) }
|
||||
|
||||
func (s *ForumSettingsService) FeedMaxPages() int { return s.getInt(SettingFeedMaxPages, 10) }
|
||||
func (s *ForumSettingsService) FeedMaxItems() int { return s.getInt(SettingFeedMaxItems, 300) }
|
||||
|
||||
func (s *ForumSettingsService) PasswordMinLen() int { return s.getInt(SettingPasswordMinLen, 6) }
|
||||
func (s *ForumSettingsService) AvatarMaxMB() int { return s.getInt(SettingAvatarMaxMB, 2) }
|
||||
|
||||
func (s *ForumSettingsService) OpenPostsInNewTab() bool {
|
||||
return s.getString(SettingOpenPostsInNewTab, "1") == "1"
|
||||
}
|
||||
|
||||
func (s *ForumSettingsService) OpenContentLinksInNewTab() bool {
|
||||
return s.getString(SettingOpenContentLinksInNewTab, "1") == "1"
|
||||
}
|
||||
|
||||
// MailConfig 读取 SMTP 配置(密码不回显明文)
|
||||
func (s *ForumSettingsService) MailConfig() MailConfig {
|
||||
port, _ := strconv.Atoi(s.getString(SettingSMTPPort, "465"))
|
||||
if port <= 0 {
|
||||
port = 465
|
||||
}
|
||||
password := s.getString(SettingSMTPPassword, "")
|
||||
return MailConfig{
|
||||
Enabled: s.getString(SettingSMTPEnabled, "0") == "1",
|
||||
Host: s.getString(SettingSMTPHost, ""),
|
||||
Port: port,
|
||||
Username: s.getString(SettingSMTPUsername, ""),
|
||||
Password: password,
|
||||
From: s.getString(SettingSMTPFrom, ""),
|
||||
FromName: s.getString(SettingSMTPFromName, "姜十三论坛"),
|
||||
Encryption: normalizeEncryption(s.getString(SettingSMTPEncryption, "ssl")),
|
||||
HasPassword: password != "",
|
||||
}
|
||||
}
|
||||
|
||||
// MailConfigPublic 管理端回显(不含密码明文)
|
||||
func (s *ForumSettingsService) MailConfigPublic() MailConfig {
|
||||
cfg := s.MailConfig()
|
||||
cfg.Password = ""
|
||||
return cfg
|
||||
}
|
||||
|
||||
// MailReady 邮件服务是否可用于发信
|
||||
func (s *ForumSettingsService) MailReady() bool {
|
||||
cfg := s.MailConfig()
|
||||
return cfg.Enabled &&
|
||||
strings.TrimSpace(cfg.Host) != "" &&
|
||||
cfg.Port > 0 &&
|
||||
strings.TrimSpace(cfg.From) != "" &&
|
||||
strings.TrimSpace(cfg.Username) != "" &&
|
||||
cfg.HasPassword
|
||||
}
|
||||
|
||||
// UpdateMailConfig 更新 SMTP 配置;密码为空表示保持原值
|
||||
func (s *ForumSettingsService) UpdateMailConfig(in MailConfig) error {
|
||||
enc := normalizeEncryption(in.Encryption)
|
||||
if enc != "none" && enc != "starttls" && enc != "ssl" {
|
||||
return ErrInvalidSetting
|
||||
}
|
||||
port := in.Port
|
||||
if port <= 0 {
|
||||
port = 465
|
||||
}
|
||||
enabled := "0"
|
||||
if in.Enabled {
|
||||
enabled = "1"
|
||||
}
|
||||
updates := map[string]string{
|
||||
SettingSMTPEnabled: enabled,
|
||||
SettingSMTPHost: strings.TrimSpace(in.Host),
|
||||
SettingSMTPPort: strconv.Itoa(port),
|
||||
SettingSMTPUsername: strings.TrimSpace(in.Username),
|
||||
SettingSMTPFrom: strings.TrimSpace(in.From),
|
||||
SettingSMTPFromName: strings.TrimSpace(in.FromName),
|
||||
SettingSMTPEncryption: enc,
|
||||
}
|
||||
if strings.TrimSpace(in.Password) != "" {
|
||||
updates[SettingSMTPPassword] = in.Password
|
||||
}
|
||||
for key, val := range updates {
|
||||
if err := s.setString(key, val); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OIDCConfig 读取 OIDC 全局配置
|
||||
func (s *ForumSettingsService) OIDCConfig() OIDCConfig {
|
||||
root := normalizeRootURL(s.getString(SettingOIDCRootURL, ""))
|
||||
clientCount := CountEnabledOAuthClients()
|
||||
cfg := OIDCConfig{
|
||||
Enabled: s.getString(SettingOIDCEnabled, "0") == "1",
|
||||
RootURL: root,
|
||||
GroupClaim: strings.TrimSpace(s.getString(SettingOIDCGroupClaim, "groups")),
|
||||
AdminGroup: strings.TrimSpace(s.getString(SettingOIDCAdminGroup, "gitea-admin")),
|
||||
UserGroup: strings.TrimSpace(s.getString(SettingOIDCUserGroup, "gitea-users")),
|
||||
ClientCount: clientCount,
|
||||
}
|
||||
if cfg.GroupClaim == "" {
|
||||
cfg.GroupClaim = "groups"
|
||||
}
|
||||
cfg.Ready = cfg.Enabled && cfg.RootURL != "" && clientCount > 0
|
||||
if root != "" {
|
||||
cfg.DiscoveryURL = root + "/.well-known/openid-configuration"
|
||||
cfg.AuthorizeURL = root + "/oauth/authorize"
|
||||
cfg.LogoutURL = root + "/oauth/logout"
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// OIDCConfigPublic 管理端回显
|
||||
func (s *ForumSettingsService) OIDCConfigPublic() OIDCConfig {
|
||||
return s.OIDCConfig()
|
||||
}
|
||||
|
||||
// UpdateOIDCConfig 更新 OIDC 全局配置(不含 OAuth 应用凭证)
|
||||
func (s *ForumSettingsService) UpdateOIDCConfig(in OIDCConfig) error {
|
||||
root := normalizeRootURL(in.RootURL)
|
||||
if root != "" && !strings.HasPrefix(root, "http://") && !strings.HasPrefix(root, "https://") {
|
||||
return ErrInvalidSetting
|
||||
}
|
||||
enabled := "0"
|
||||
if in.Enabled {
|
||||
enabled = "1"
|
||||
}
|
||||
groupClaim := strings.TrimSpace(in.GroupClaim)
|
||||
if groupClaim == "" {
|
||||
groupClaim = "groups"
|
||||
}
|
||||
adminGroup := strings.TrimSpace(in.AdminGroup)
|
||||
userGroup := strings.TrimSpace(in.UserGroup)
|
||||
updates := map[string]string{
|
||||
SettingOIDCEnabled: enabled,
|
||||
SettingOIDCRootURL: root,
|
||||
SettingOIDCGroupClaim: groupClaim,
|
||||
SettingOIDCAdminGroup: adminGroup,
|
||||
SettingOIDCUserGroup: userGroup,
|
||||
}
|
||||
for key, val := range updates {
|
||||
if err := s.setString(key, val); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SeedOIDCFromINI 若库中尚未配置,则用 app.ini 种子一次(便于迁移)
|
||||
func (s *ForumSettingsService) SeedOIDCFromINI(rootURL, clientID, clientSecret, redirectURIsCSV string) {
|
||||
rootURL = normalizeRootURL(rootURL)
|
||||
clientID = strings.TrimSpace(clientID)
|
||||
clientSecret = strings.TrimSpace(clientSecret)
|
||||
uris := normalizeRedirectURIs(redirectURIsCSV)
|
||||
if rootURL == "" && clientID == "" && clientSecret == "" && uris == "" {
|
||||
return
|
||||
}
|
||||
if s.getString(SettingOIDCRootURL, "") == "" && rootURL != "" {
|
||||
_ = s.setString(SettingOIDCRootURL, rootURL)
|
||||
}
|
||||
if s.getString(SettingOAuthClientID, "") == "" && clientID != "" {
|
||||
_ = s.setString(SettingOAuthClientID, clientID)
|
||||
}
|
||||
if s.getString(SettingOAuthClientSecret, "") == "" && clientSecret != "" {
|
||||
_ = s.setString(SettingOAuthClientSecret, clientSecret)
|
||||
}
|
||||
if s.getString(SettingOAuthRedirectURIs, "") == "" && uris != "" {
|
||||
_ = s.setString(SettingOAuthRedirectURIs, uris)
|
||||
}
|
||||
s.MigrateLegacyOIDCClient()
|
||||
if s.getString(SettingOIDCEnabled, "0") == "0" &&
|
||||
s.getString(SettingOIDCRootURL, "") != "" &&
|
||||
CountEnabledOAuthClients() > 0 {
|
||||
_ = s.setString(SettingOIDCEnabled, "1")
|
||||
}
|
||||
}
|
||||
|
||||
// GiteaSyncConfig 读取 Gitea 同步配置(含 Token 明文,供服务内部使用)
|
||||
func (s *ForumSettingsService) GiteaSyncConfig() GiteaSyncConfig {
|
||||
interval, _ := strconv.Atoi(s.getString(SettingGiteaSyncIntervalMin, "60"))
|
||||
if interval < 5 {
|
||||
interval = 5
|
||||
}
|
||||
if interval > 24*60 {
|
||||
interval = 24 * 60
|
||||
}
|
||||
token := s.getString(SettingGiteaToken, "")
|
||||
base := normalizeRootURL(s.getString(SettingGiteaBaseURL, ""))
|
||||
cfg := GiteaSyncConfig{
|
||||
Enabled: s.getString(SettingGiteaSyncEnabled, "0") == "1",
|
||||
BaseURL: base,
|
||||
Token: token,
|
||||
HasToken: token != "",
|
||||
SyncIntervalMin: interval,
|
||||
}
|
||||
cfg.Ready = cfg.Enabled && base != "" && cfg.HasToken
|
||||
var n int64
|
||||
model.DB.Model(&model.GiteaRepo{}).Where("private = ?", false).Count(&n)
|
||||
cfg.RepoCount = n
|
||||
return cfg
|
||||
}
|
||||
|
||||
// GiteaSyncConfigPublic 管理端回显(不含 Token 明文)
|
||||
func (s *ForumSettingsService) GiteaSyncConfigPublic() GiteaSyncConfig {
|
||||
cfg := s.GiteaSyncConfig()
|
||||
cfg.Token = ""
|
||||
return cfg
|
||||
}
|
||||
|
||||
// UpdateGiteaSyncConfig 更新同步配置;Token 为空表示保持原值
|
||||
func (s *ForumSettingsService) UpdateGiteaSyncConfig(in GiteaSyncConfig) error {
|
||||
base := normalizeRootURL(in.BaseURL)
|
||||
if base != "" && !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
|
||||
return ErrInvalidSetting
|
||||
}
|
||||
interval := in.SyncIntervalMin
|
||||
if interval <= 0 {
|
||||
interval = 60
|
||||
}
|
||||
if interval < 5 {
|
||||
interval = 5
|
||||
}
|
||||
if interval > 24*60 {
|
||||
interval = 24 * 60
|
||||
}
|
||||
enabled := "0"
|
||||
if in.Enabled {
|
||||
enabled = "1"
|
||||
}
|
||||
updates := map[string]string{
|
||||
SettingGiteaSyncEnabled: enabled,
|
||||
SettingGiteaBaseURL: base,
|
||||
SettingGiteaSyncIntervalMin: strconv.Itoa(interval),
|
||||
}
|
||||
if strings.TrimSpace(in.Token) != "" {
|
||||
updates[SettingGiteaToken] = strings.TrimSpace(in.Token)
|
||||
}
|
||||
for key, val := range updates {
|
||||
if err := s.setString(key, val); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SeedGiteaFromINI 若库中尚未配置,则用 app.ini 种子一次
|
||||
func (s *ForumSettingsService) SeedGiteaFromINI(baseURL, token string, enabled bool) {
|
||||
baseURL = normalizeRootURL(baseURL)
|
||||
token = strings.TrimSpace(token)
|
||||
if baseURL == "" && token == "" && !enabled {
|
||||
return
|
||||
}
|
||||
if s.getString(SettingGiteaBaseURL, "") == "" && baseURL != "" {
|
||||
_ = s.setString(SettingGiteaBaseURL, baseURL)
|
||||
}
|
||||
if s.getString(SettingGiteaToken, "") == "" && token != "" {
|
||||
_ = s.setString(SettingGiteaToken, token)
|
||||
}
|
||||
if enabled && s.getString(SettingGiteaSyncEnabled, "0") == "0" &&
|
||||
s.getString(SettingGiteaBaseURL, "") != "" &&
|
||||
s.getString(SettingGiteaToken, "") != "" {
|
||||
_ = s.setString(SettingGiteaSyncEnabled, "1")
|
||||
}
|
||||
}
|
||||
|
||||
// SiteBranding 读取站点品牌配置
|
||||
func (s *ForumSettingsService) SiteBranding() SiteBranding {
|
||||
name := strings.TrimSpace(s.getString(SettingSiteName, siteBrandingDefaults[SettingSiteName]))
|
||||
if name == "" {
|
||||
name = siteBrandingDefaults[SettingSiteName]
|
||||
}
|
||||
mark := strings.TrimSpace(s.getString(SettingSiteLogoMark, siteBrandingDefaults[SettingSiteLogoMark]))
|
||||
if mark == "" {
|
||||
mark = siteBrandingDefaults[SettingSiteLogoMark]
|
||||
}
|
||||
// 字标取首个字符(支持中文)
|
||||
runes := []rune(mark)
|
||||
if len(runes) > 1 {
|
||||
mark = string(runes[0])
|
||||
}
|
||||
return SiteBranding{
|
||||
Name: name,
|
||||
NameEN: strings.TrimSpace(s.getString(SettingSiteNameEN, siteBrandingDefaults[SettingSiteNameEN])),
|
||||
Slogan: strings.TrimSpace(s.getString(SettingSiteSlogan, siteBrandingDefaults[SettingSiteSlogan])),
|
||||
LogoMark: mark,
|
||||
Logo: strings.TrimSpace(s.getString(SettingSiteLogo, "")),
|
||||
Favicon: strings.TrimSpace(s.getString(SettingSiteFavicon, "")),
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateSiteBranding 更新品牌文案;Logo/Favicon URL 由上传接口单独写入
|
||||
func (s *ForumSettingsService) UpdateSiteBranding(in SiteBranding) error {
|
||||
name := strings.TrimSpace(in.Name)
|
||||
if name == "" {
|
||||
return ErrInvalidSetting
|
||||
}
|
||||
if len([]rune(name)) > 64 {
|
||||
return ErrInvalidSetting
|
||||
}
|
||||
mark := strings.TrimSpace(in.LogoMark)
|
||||
if mark == "" {
|
||||
mark = siteBrandingDefaults[SettingSiteLogoMark]
|
||||
}
|
||||
runes := []rune(mark)
|
||||
if len(runes) > 1 {
|
||||
mark = string(runes[0])
|
||||
}
|
||||
nameEN := strings.TrimSpace(in.NameEN)
|
||||
if len([]rune(nameEN)) > 64 {
|
||||
return ErrInvalidSetting
|
||||
}
|
||||
slogan := strings.TrimSpace(in.Slogan)
|
||||
if len([]rune(slogan)) > 200 {
|
||||
return ErrInvalidSetting
|
||||
}
|
||||
updates := map[string]string{
|
||||
SettingSiteName: name,
|
||||
SettingSiteNameEN: nameEN,
|
||||
SettingSiteSlogan: slogan,
|
||||
SettingSiteLogoMark: mark,
|
||||
}
|
||||
for key, val := range updates {
|
||||
if err := s.setString(key, val); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSiteLogo 写入 Logo URL(空串表示清除)
|
||||
func (s *ForumSettingsService) SetSiteLogo(url string) error {
|
||||
return s.setString(SettingSiteLogo, strings.TrimSpace(url))
|
||||
}
|
||||
|
||||
// SetSiteFavicon 写入 Favicon URL(空串表示清除)
|
||||
func (s *ForumSettingsService) SetSiteFavicon(url string) error {
|
||||
return s.setString(SettingSiteFavicon, strings.TrimSpace(url))
|
||||
}
|
||||
|
||||
func normalizeRootURL(raw string) string {
|
||||
return strings.TrimRight(strings.TrimSpace(raw), "/")
|
||||
}
|
||||
|
||||
func normalizeRedirectURIs(raw string) string {
|
||||
parts := splitRedirectURIs(raw)
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func splitRedirectURIs(raw string) []string {
|
||||
raw = strings.ReplaceAll(raw, "\r\n", "\n")
|
||||
raw = strings.ReplaceAll(raw, "\n", ",")
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
seen := map[string]struct{}{}
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[p]; ok {
|
||||
continue
|
||||
}
|
||||
seen[p] = struct{}{}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// NormalizeSearchKeyword 校验并规范化搜索关键词,空字符串表示无搜索
|
||||
func (s *ForumSettingsService) NormalizeSearchKeyword(keyword string) (string, error) {
|
||||
kw := trimRunes(keyword)
|
||||
@@ -311,9 +822,8 @@ func (s *ForumSettingsService) NormalizePageSize(size int) int {
|
||||
if size < 1 {
|
||||
return s.PageSizeDefault()
|
||||
}
|
||||
maxSize := s.PageSizeMax()
|
||||
if maxSize > 0 && size > maxSize {
|
||||
return maxSize
|
||||
if size > pageSizeAPIMax {
|
||||
return pageSizeAPIMax
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user