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

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

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

View File

@@ -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 tokenclientIP 写入上次登录记录
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
View 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)
}

View File

@@ -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 {

View File

@@ -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
View 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
View 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
View 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
View 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
View 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
}

View File

@@ -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()
}
}

View File

@@ -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)

View File

@@ -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
}