feat: opaque session、安装/发帖 SSR 与最小 Admin 后台
浏览器登录改为 DB sessions(可吊销);敏感词与 OIDC PEM 入 settings; 落地安装向导、注册发帖与 /admin 仪表盘/板块/审核/设置。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
136
services/auth.go
136
services/auth.go
@@ -1,38 +1,33 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"git.iioio.com/freefire/jiang13-forum/models"
|
||||
)
|
||||
|
||||
// 最近访问写入节流,避免每次 API 都打库
|
||||
// 最近访问写入节流,避免每次请求都打库
|
||||
const lastAccessTouchInterval = 5 * time.Minute
|
||||
|
||||
var lastAccessTouchCache sync.Map // userID(uint) -> time.Time
|
||||
|
||||
const TokenExpire = 7 * 24 * time.Hour
|
||||
|
||||
type Claims struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Role models.Role `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
// SessionTTL 与浏览器会话对齐(兼容旧常量名)
|
||||
const TokenExpire = SessionTTL
|
||||
|
||||
type AuthService struct {
|
||||
jwtSecret string
|
||||
filter *SensitiveFilter
|
||||
settings *ForumSettingsService
|
||||
hmacSecret string // CSRF 等 HMAC;不再用于浏览器登录 JWT
|
||||
filter *SensitiveFilter
|
||||
settings *ForumSettingsService
|
||||
}
|
||||
|
||||
func NewAuthService(jwtSecret string, filter *SensitiveFilter, settings *ForumSettingsService) *AuthService {
|
||||
return &AuthService{jwtSecret: jwtSecret, filter: filter, settings: settings}
|
||||
func NewAuthService(hmacSecret string, filter *SensitiveFilter, settings *ForumSettingsService) *AuthService {
|
||||
return &AuthService{hmacSecret: hmacSecret, filter: filter, settings: settings}
|
||||
}
|
||||
|
||||
// HMACSecret 供 CSRF 等使用
|
||||
func (s *AuthService) HMACSecret() string { return s.hmacSecret }
|
||||
|
||||
// UserCount 当前用户数
|
||||
func (s *AuthService) UserCount() int64 {
|
||||
var n int64
|
||||
@@ -70,18 +65,12 @@ func (s *AuthService) Register(username, password, nickname, email string) (*mod
|
||||
}
|
||||
nickname = s.filter.Filter(nickname)
|
||||
|
||||
// 首个注册用户自动成为管理员
|
||||
role := models.RoleUser
|
||||
if s.UserCount() == 0 {
|
||||
role = models.RoleAdmin
|
||||
}
|
||||
|
||||
user := &models.User{
|
||||
Username: username,
|
||||
Email: email,
|
||||
Password: hash,
|
||||
Nickname: nickname,
|
||||
Role: role,
|
||||
Role: models.RoleUser,
|
||||
}
|
||||
if err := models.DB.Create(user).Error; err != nil {
|
||||
return nil, err
|
||||
@@ -89,24 +78,69 @@ func (s *AuthService) Register(username, password, nickname, email string) (*mod
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Login 用户登录,返回 JWT token;clientIP 写入上次登录记录
|
||||
func (s *AuthService) Login(username, password, clientIP string) (string, *models.User, error) {
|
||||
var user models.User
|
||||
if err := models.DB.Where("username = ?", username).First(&user).Error; err != nil {
|
||||
return "", nil, ErrInvalidCred
|
||||
// CreateAdmin 安装向导创建管理员(仅应在未安装时调用)
|
||||
func (s *AuthService) CreateAdmin(username, password, nickname, email string) (*models.User, error) {
|
||||
if err := ValidateUsername(username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user.Banned {
|
||||
return "", nil, ErrUserBanned
|
||||
if err := ValidatePassword(password, s.settings.PasswordMinLen()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !CheckPassword(user.Password, password) {
|
||||
return "", nil, ErrInvalidCred
|
||||
email = NormalizeEmail(email)
|
||||
if err := ValidateEmail(email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.recordLogin(&user, clientIP)
|
||||
token, err := s.GenerateToken(&user)
|
||||
return token, &user, err
|
||||
if nickname == "" {
|
||||
nickname = username
|
||||
}
|
||||
nickname = s.filter.Filter(nickname)
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user := &models.User{
|
||||
Username: username,
|
||||
Email: email,
|
||||
Password: hash,
|
||||
Nickname: nickname,
|
||||
Role: models.RoleAdmin,
|
||||
Verified: true,
|
||||
}
|
||||
if err := models.DB.Create(user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Login 校验密码并创建会话,返回 session id
|
||||
func (s *AuthService) Login(username, password, clientIP, userAgent string) (sessionID string, user *models.User, err error) {
|
||||
var u models.User
|
||||
if err := models.DB.Where("username = ?", username).First(&u).Error; err != nil {
|
||||
return "", nil, ErrInvalidCred
|
||||
}
|
||||
if u.Banned {
|
||||
return "", nil, ErrUserBanned
|
||||
}
|
||||
if !CheckPassword(u.Password, password) {
|
||||
return "", nil, ErrInvalidCred
|
||||
}
|
||||
s.recordLogin(&u, clientIP)
|
||||
sid, err := CreateSession(u.ID, clientIP, userAgent)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return sid, &u, nil
|
||||
}
|
||||
|
||||
// CreateSessionForUser 已认证用户直接建会话(注册后自动登录)
|
||||
func (s *AuthService) CreateSessionForUser(user *models.User, clientIP, userAgent string) (string, error) {
|
||||
if user == nil {
|
||||
return "", ErrInvalidCred
|
||||
}
|
||||
s.recordLogin(user, clientIP)
|
||||
return CreateSession(user.ID, clientIP, userAgent)
|
||||
}
|
||||
|
||||
// recordLogin 记录上次登录时间与 IP;登录同时视为一次访问(失败不影响登录)
|
||||
func (s *AuthService) recordLogin(user *models.User, clientIP string) {
|
||||
now := time.Now()
|
||||
ip := clientIP
|
||||
@@ -138,33 +172,3 @@ func (s *AuthService) TouchLastAccess(userID uint) {
|
||||
lastAccessTouchCache.Store(userID, now)
|
||||
_ = models.DB.Model(&models.User{}).Where("id = ?", userID).Update("last_access_at", now).Error
|
||||
}
|
||||
|
||||
// GenerateToken 生成 JWT
|
||||
func (s *AuthService) GenerateToken(user *models.User) (string, error) {
|
||||
claims := Claims{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(TokenExpire)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(s.jwtSecret))
|
||||
}
|
||||
|
||||
// ParseToken 解析 JWT
|
||||
func (s *AuthService) ParseToken(tokenStr string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
|
||||
return []byte(s.jwtSecret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/models"
|
||||
)
|
||||
|
||||
type BackupService struct {
|
||||
@@ -17,8 +19,11 @@ func NewBackupService(dbPath, dataDir string) *BackupService {
|
||||
return &BackupService{dbPath: dbPath, dataDir: dataDir}
|
||||
}
|
||||
|
||||
// ExportSQLite 导出 SQLite 备份文件到 data 目录
|
||||
// ExportSQLite 导出 SQLite 备份文件到 data 目录(仅 sqlite)
|
||||
func (s *BackupService) ExportSQLite() (string, error) {
|
||||
if models.DialectorName() != "sqlite" {
|
||||
return "", fmt.Errorf("一键文件备份仅支持 SQLite;当前为 %s,请使用数据库自带备份工具", models.DialectorName())
|
||||
}
|
||||
src, err := os.Open(s.dbPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("打开数据库失败: %w", err)
|
||||
@@ -38,15 +43,3 @@ func (s *BackupService) ExportSQLite() (string, error) {
|
||||
}
|
||||
return destPath, nil
|
||||
}
|
||||
|
||||
// WriteDefaultFilterWords 写入默认敏感词配置
|
||||
func WriteDefaultFilterWords(path string) error {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return nil
|
||||
}
|
||||
content := `# 姜十三论坛敏感词配置,每行一个词,# 开头为注释
|
||||
违禁词示例
|
||||
广告刷单
|
||||
`
|
||||
return os.WriteFile(path, []byte(content), 0644)
|
||||
}
|
||||
|
||||
@@ -345,6 +345,29 @@ func (s *CommentService) PendingCommentCount() (int64, error) {
|
||||
return n, err
|
||||
}
|
||||
|
||||
// ListPending 待审评论列表(管理端)
|
||||
func (s *CommentService) ListPending(page, size int) ([]models.Comment, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 {
|
||||
size = 30
|
||||
}
|
||||
if size > 100 {
|
||||
size = 100
|
||||
}
|
||||
db := models.DB.Model(&models.Comment{}).
|
||||
Where("status = ?", models.ContentStatusPending).
|
||||
Preload("User").Preload("Post")
|
||||
var total int64
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var list []models.Comment
|
||||
err := db.Order("id asc").Offset((page - 1) * size).Limit(size).Find(&list).Error
|
||||
return list, total, err
|
||||
}
|
||||
|
||||
func (s *CommentService) Delete(userID, commentID uint, isAdmin bool) error {
|
||||
if !isAdmin {
|
||||
return ErrPermissionDenied
|
||||
|
||||
@@ -115,7 +115,12 @@ func (f *SensitiveFilter) LoadFromFile(path string) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
f.LoadFromContent(string(data))
|
||||
}
|
||||
|
||||
// LoadFromContent 从文本内容加载敏感词(每行一词,# 注释)
|
||||
func (f *SensitiveFilter) LoadFromContent(content string) {
|
||||
lines := strings.Split(content, "\n")
|
||||
var words []string
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
@@ -123,11 +128,12 @@ func (f *SensitiveFilter) LoadFromFile(path string) {
|
||||
words = append(words, line)
|
||||
}
|
||||
}
|
||||
if len(words) > 0 {
|
||||
f.mu.Lock()
|
||||
f.words = words
|
||||
f.mu.Unlock()
|
||||
if len(words) == 0 {
|
||||
return
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.words = words
|
||||
f.mu.Unlock()
|
||||
}
|
||||
|
||||
func (f *SensitiveFilter) Filter(text string) string {
|
||||
|
||||
97
services/compose_body.go
Normal file
97
services/compose_body.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"html"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
mdImageRe = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]+)\)`)
|
||||
mdLinkRe = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
|
||||
)
|
||||
|
||||
// ComposeBodyToHTML 将发帖表单正文转为可消毒 HTML。
|
||||
// 若已含块级 HTML 标签则原样交 Sanitize;否则按纯文本/轻量 Markdown 转段落。
|
||||
func ComposeBodyToHTML(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
lower := strings.ToLower(raw)
|
||||
if strings.Contains(lower, "<p") || strings.Contains(lower, "<div") ||
|
||||
strings.Contains(lower, "<h1") || strings.Contains(lower, "<ul") ||
|
||||
strings.Contains(lower, "<ol") || strings.Contains(lower, "<pre") ||
|
||||
strings.Contains(lower, "<blockquote") {
|
||||
return raw
|
||||
}
|
||||
|
||||
// 先转义,再恢复轻量 md 图片/链接
|
||||
esc := html.EscapeString(raw)
|
||||
esc = mdImageRe.ReplaceAllStringFunc(esc, func(m string) string {
|
||||
sub := mdImageRe.FindStringSubmatch(m)
|
||||
if len(sub) != 3 {
|
||||
return m
|
||||
}
|
||||
alt, src := sub[1], sub[2]
|
||||
// 仅允许站内 uploads 或 http(s)
|
||||
if !safeComposeURL(src) {
|
||||
return m
|
||||
}
|
||||
return `<p><img src="` + html.EscapeString(src) + `" alt="` + alt + `"></p>`
|
||||
})
|
||||
esc = mdLinkRe.ReplaceAllStringFunc(esc, func(m string) string {
|
||||
sub := mdLinkRe.FindStringSubmatch(m)
|
||||
if len(sub) != 3 {
|
||||
return m
|
||||
}
|
||||
text, href := sub[1], sub[2]
|
||||
if !safeComposeURL(href) {
|
||||
return m
|
||||
}
|
||||
return `<a href="` + html.EscapeString(href) + `" rel="noopener noreferrer">` + text + `</a>`
|
||||
})
|
||||
|
||||
parts := strings.Split(esc, "\n\n")
|
||||
var b strings.Builder
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
// 已是独立 img 段
|
||||
if strings.HasPrefix(p, "<p><img ") {
|
||||
b.WriteString(p)
|
||||
continue
|
||||
}
|
||||
p = strings.ReplaceAll(p, "\n", "<br>\n")
|
||||
b.WriteString("<p>")
|
||||
b.WriteString(p)
|
||||
b.WriteString("</p>\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func safeComposeURL(u string) bool {
|
||||
u = strings.TrimSpace(u)
|
||||
if strings.HasPrefix(u, "/uploads/") || strings.HasPrefix(u, "/media/") {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "http://")
|
||||
}
|
||||
|
||||
// HTMLToComposePlain 编辑页回显:去掉简单标签便于 textarea 编辑(尽力而为)
|
||||
func HTMLToComposePlain(htmlBody string) string {
|
||||
s := strings.TrimSpace(htmlBody)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
// img → markdown
|
||||
imgRe := regexp.MustCompile(`(?i)<img[^>]+src="([^"]+)"[^>]*>`)
|
||||
s = imgRe.ReplaceAllString(s, "")
|
||||
s = regexp.MustCompile(`(?i)</p>\s*<p>`).ReplaceAllString(s, "\n\n")
|
||||
s = regexp.MustCompile(`(?i)<br\s*/?>`).ReplaceAllString(s, "\n")
|
||||
s = regexp.MustCompile(`(?i)</?p[^>]*>`).ReplaceAllString(s, "")
|
||||
s = regexp.MustCompile(`(?i)<[^>]+>`).ReplaceAllString(s, "")
|
||||
return strings.TrimSpace(html.UnescapeString(s))
|
||||
}
|
||||
21
services/content_gate.go
Normal file
21
services/content_gate.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package services
|
||||
|
||||
import "git.iioio.com/freefire/jiang13-forum/models"
|
||||
|
||||
// ApplyPostContentGates 按观众身份对帖文 HTML 做门控遮盖(消毒 + members/reply/points)
|
||||
func ApplyPostContentGates(content string, post *models.Post, viewerID uint, isAdmin bool, hasReplied bool) string {
|
||||
content = SanitizePostHTML(content)
|
||||
if viewerID == 0 {
|
||||
content = RedactMembersOnlyHTML(content)
|
||||
content = RedactReplyOnlyHTML(content)
|
||||
} else if !isAdmin && post.UserID != viewerID && !hasReplied {
|
||||
content = RedactReplyOnlyHTML(content)
|
||||
}
|
||||
if isAdmin || post.UserID == viewerID {
|
||||
content = RevealAllPointsOnly(content)
|
||||
} else {
|
||||
unlocked, _ := ListUnlockedKeys(viewerID, post.ID)
|
||||
content = RedactPointsOnlyHTML(content, unlocked)
|
||||
}
|
||||
return content
|
||||
}
|
||||
@@ -5,7 +5,49 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ReadFilterWordsFile 读取敏感词配置文件内容
|
||||
const defaultFilterWordsContent = `# 姜十三论坛敏感词配置,每行一个词,# 开头为注释
|
||||
违禁词示例
|
||||
广告刷单
|
||||
`
|
||||
|
||||
// EnsureFilterWordsInSettings 将敏感词迁入 forum_settings(优先已有键;否则从文件导入;否则默认)
|
||||
func EnsureFilterWordsInSettings(settings *ForumSettingsService, legacyFilePath string, filter *SensitiveFilter) {
|
||||
if settings == nil {
|
||||
return
|
||||
}
|
||||
cur := strings.TrimSpace(settings.getString(SettingFilterWords, ""))
|
||||
if cur == "" {
|
||||
if data, err := os.ReadFile(legacyFilePath); err == nil && len(strings.TrimSpace(string(data))) > 0 {
|
||||
cur = string(data)
|
||||
} else {
|
||||
cur = defaultFilterWordsContent
|
||||
}
|
||||
_ = settings.setString(SettingFilterWords, cur)
|
||||
}
|
||||
filter.LoadFromContent(cur)
|
||||
}
|
||||
|
||||
// FilterWordsContent 读取敏感词全文
|
||||
func (s *ForumSettingsService) FilterWordsContent() string {
|
||||
v := s.getString(SettingFilterWords, "")
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return defaultFilterWordsContent
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// UpdateFilterWords 更新敏感词并热加载过滤器
|
||||
func (s *ForumSettingsService) UpdateFilterWords(content string, filter *SensitiveFilter) error {
|
||||
if err := s.setString(SettingFilterWords, content); err != nil {
|
||||
return err
|
||||
}
|
||||
if filter != nil {
|
||||
filter.LoadFromContent(content)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadFilterWordsFile 兼容旧 API:读文件(Admin 未迁时)
|
||||
func ReadFilterWordsFile(path string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -14,15 +56,23 @@ func ReadFilterWordsFile(path string) (string, error) {
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// WriteFilterWordsFile 写入敏感词配置并热加载到过滤器
|
||||
// WriteFilterWordsFile 兼容旧写入:写文件并加载;新路径请用 UpdateFilterWords
|
||||
func WriteFilterWordsFile(path string, content string, filter *SensitiveFilter) error {
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
filter.LoadFromFile(path)
|
||||
filter.LoadFromContent(content)
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteDefaultFilterWords 若文件不存在则写默认(遗留兼容,新站以 DB 为准)
|
||||
func WriteDefaultFilterWords(path string) error {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return nil
|
||||
}
|
||||
return os.WriteFile(path, []byte(defaultFilterWordsContent), 0644)
|
||||
}
|
||||
|
||||
// CountFilterWords 统计有效敏感词数量(不含空行与注释)
|
||||
func CountFilterWords(content string) int {
|
||||
count := 0
|
||||
|
||||
@@ -71,31 +71,9 @@ func NewGiteaService(settings *ForumSettingsService) *GiteaService {
|
||||
}
|
||||
}
|
||||
|
||||
// StartBackgroundSync 按配置间隔后台同步;失败只记日志
|
||||
// StartBackgroundSync 已后置:本阶段不启动定时同步(保留空实现以免旧调用 panic)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}()
|
||||
log.Printf("[gitea] 仓库同步已后置,跳过后台定时任务")
|
||||
}
|
||||
|
||||
// Stop 停止后台同步
|
||||
|
||||
48
services/install.go
Normal file
48
services/install.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/models"
|
||||
)
|
||||
|
||||
const installLockName = "install.lock"
|
||||
|
||||
// InstallLockPath 安装锁文件路径
|
||||
func InstallLockPath(dataDir string) string {
|
||||
return filepath.Join(dataDir, installLockName)
|
||||
}
|
||||
|
||||
// IsInstalled 是否已完成安装向导
|
||||
func IsInstalled(dataDir string) bool {
|
||||
_, err := os.Stat(InstallLockPath(dataDir))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// EnsureInstallLockFromExistingData 已有用户时自动写锁(避免旧数据无法启动)
|
||||
func EnsureInstallLockFromExistingData(dataDir string) error {
|
||||
if IsInstalled(dataDir) {
|
||||
return nil
|
||||
}
|
||||
var n int64
|
||||
if err := models.DB.Model(&models.User{}).Count(&n).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return nil
|
||||
}
|
||||
return WriteInstallLock(dataDir)
|
||||
}
|
||||
|
||||
// WriteInstallLock 写入安装锁
|
||||
func WriteInstallLock(dataDir string) error {
|
||||
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(InstallLockPath(dataDir), []byte("installed\n"), 0o644)
|
||||
}
|
||||
|
||||
// ErrAlreadyInstalled 已安装
|
||||
var ErrAlreadyInstalled = errors.New("already installed")
|
||||
@@ -52,11 +52,13 @@ type OIDCService struct {
|
||||
privateKey *rsa.PrivateKey
|
||||
}
|
||||
|
||||
// NewOIDCService 创建并加载/生成 RSA 密钥
|
||||
// NewOIDCService 创建;RSA 密钥仅在启用 OIDC 时懒加载/生成(存 forum_settings)
|
||||
func NewOIDCService(cfg *config.Config, settings *ForumSettingsService) (*OIDCService, error) {
|
||||
s := &OIDCService{cfg: cfg, settings: settings}
|
||||
if err := s.loadOrCreateKey(); err != nil {
|
||||
return nil, err
|
||||
if s.runtime().Enabled {
|
||||
if err := s.ensureKey(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
@@ -68,29 +70,36 @@ func (s *OIDCService) runtime() 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
|
||||
// ensureKey 懒加载:settings PEM → 旧文件迁移 → 新生成写入 settings(不再主动写 .oidc_rsa.pem)
|
||||
func (s *OIDCService) ensureKey() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.privateKey != nil {
|
||||
return nil
|
||||
}
|
||||
if s.settings != nil {
|
||||
if pemStr := strings.TrimSpace(s.settings.getString(SettingOIDCRSAPrivatePEM, "")); pemStr != "" {
|
||||
key, err := parseOIDCRSAPrivateKey([]byte(pemStr))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.privateKey = key
|
||||
return nil
|
||||
}
|
||||
}
|
||||
// 兼容旧文件一次迁入
|
||||
keyPath := filepath.Join(s.cfg.DataDir, ".oidc_rsa.pem")
|
||||
if data, err := os.ReadFile(keyPath); err == nil && len(data) > 0 {
|
||||
key, err := parseOIDCRSAPrivateKey(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.privateKey = key
|
||||
if s.settings != nil {
|
||||
_ = s.settings.setString(SettingOIDCRSAPrivatePEM, string(data))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, oidcRSABits)
|
||||
if err != nil {
|
||||
return fmt.Errorf("生成 OIDC RSA 密钥失败: %w", err)
|
||||
@@ -99,13 +108,39 @@ func (s *OIDCService) loadOrCreateKey() error {
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
||||
})
|
||||
if err := os.WriteFile(keyPath, pemBytes, 0600); err != nil {
|
||||
return fmt.Errorf("写入 OIDC RSA 密钥失败: %w", err)
|
||||
if s.settings != nil {
|
||||
if err := s.settings.setString(SettingOIDCRSAPrivatePEM, string(pemBytes)); err != nil {
|
||||
return fmt.Errorf("持久化 OIDC RSA 密钥失败: %w", err)
|
||||
}
|
||||
}
|
||||
s.privateKey = key
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseOIDCRSAPrivateKey(data []byte) (*rsa.PrivateKey, error) {
|
||||
block, _ := pem.Decode(data)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("解析 OIDC RSA 密钥失败")
|
||||
}
|
||||
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
parsed, err2 := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err2 != nil {
|
||||
return nil, fmt.Errorf("解析 OIDC RSA 密钥失败: %w", err)
|
||||
}
|
||||
var ok bool
|
||||
key, ok = parsed.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("OIDC 密钥不是 RSA")
|
||||
}
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func (s *OIDCService) loadOrCreateKey() error {
|
||||
return s.ensureKey()
|
||||
}
|
||||
|
||||
// Enabled 是否可对外提供 OIDC
|
||||
func (s *OIDCService) Enabled() bool {
|
||||
return s.runtime().Ready
|
||||
@@ -144,6 +179,12 @@ func (s *OIDCService) Discovery() (map[string]any, error) {
|
||||
|
||||
// JWKS 返回 JSON Web Key Set
|
||||
func (s *OIDCService) JWKS() (map[string]any, error) {
|
||||
if !s.Enabled() {
|
||||
return nil, ErrOIDCNotConfigured
|
||||
}
|
||||
if err := s.ensureKey(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
key := s.privateKey
|
||||
s.mu.RUnlock()
|
||||
@@ -424,6 +465,9 @@ func (s *OIDCService) signAccessToken(user *models.User, scope, clientID string)
|
||||
}
|
||||
t := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
t.Header["kid"] = oidcKeyID
|
||||
if err := s.ensureKey(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return t.SignedString(s.privateKey)
|
||||
@@ -457,6 +501,9 @@ func (s *OIDCService) signIDToken(user *models.User, scope, clientID, nonce stri
|
||||
}
|
||||
t := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
t.Header["kid"] = oidcKeyID
|
||||
if err := s.ensureKey(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return t.SignedString(s.privateKey)
|
||||
|
||||
@@ -338,10 +338,9 @@ func (s *PostService) List(q PostListQuery) ([]models.Post, int64, error) {
|
||||
}
|
||||
}
|
||||
if tag := strings.TrimSpace(q.Tag); tag != "" {
|
||||
// 整枚标签匹配:逗号/中文逗号分隔,忽略标签两侧空格,大小写不敏感
|
||||
// 整枚标签匹配:逗号/中文逗号分隔,忽略标签两侧空格,大小写不敏感(跨 sqlite/postgres/mysql)
|
||||
escaped := escapeLikePattern(strings.ToLower(tag))
|
||||
normalized := "LOWER(',' || REPLACE(REPLACE(REPLACE(IFNULL(tags,''), ',', ','), ', ', ','), ' ,', ',') || ',')"
|
||||
db = db.Where(normalized+" LIKE ? ESCAPE '\\'", "%,"+escaped+",%")
|
||||
db = db.Where(tagsNormalizedExpr()+" LIKE ? ESCAPE '\\'", "%,"+escaped+",%")
|
||||
}
|
||||
var total int64
|
||||
db.Count(&total)
|
||||
@@ -390,6 +389,18 @@ func escapeLikePattern(s string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// tagsNormalizedExpr 标签列规范化表达式(跨方言)
|
||||
// 方言:sqlite / postgres 用 ||;mysql 用 CONCAT;COALESCE 三库通用
|
||||
func tagsNormalizedExpr() string {
|
||||
inner := "REPLACE(REPLACE(REPLACE(COALESCE(tags,''), ',', ','), ', ', ','), ' ,', ',')"
|
||||
switch models.DialectorName() {
|
||||
case "mysql":
|
||||
return "LOWER(CONCAT(',', " + inner + ", ','))"
|
||||
default:
|
||||
return "LOWER(',' || " + inner + " || ',')"
|
||||
}
|
||||
}
|
||||
|
||||
// resolveAuthorUserID 按用户名精确匹配,否则按昵称精确匹配(优先用户名)
|
||||
func resolveAuthorUserID(author string) (uint, bool) {
|
||||
author = strings.TrimSpace(author)
|
||||
|
||||
129
services/session.go
Normal file
129
services/session.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/models"
|
||||
)
|
||||
|
||||
const (
|
||||
// SessionTTL 浏览器会话默认有效期
|
||||
SessionTTL = 7 * 24 * time.Hour
|
||||
// sessionTouchMin 滑动续期写库节流
|
||||
sessionTouchMin = 2 * time.Minute
|
||||
)
|
||||
|
||||
var (
|
||||
ErrSessionInvalid = errors.New("会话无效或已过期")
|
||||
sessionTouchCache sync.Map // sessionID -> time.Time
|
||||
)
|
||||
|
||||
// CreateSession 为用户创建会话,返回 cookie 值
|
||||
func CreateSession(userID uint, ip, userAgent string) (string, error) {
|
||||
id, err := newSessionID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
now := time.Now()
|
||||
ip = trimLen(ip, 45)
|
||||
ua := trimLen(userAgent, 256)
|
||||
rec := models.Session{
|
||||
ID: id,
|
||||
UserID: userID,
|
||||
ExpiresAt: now.Add(SessionTTL),
|
||||
CreatedAt: now,
|
||||
LastSeenAt: now,
|
||||
IP: ip,
|
||||
UserAgent: ua,
|
||||
}
|
||||
if err := models.DB.Create(&rec).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// ResolveSession 校验会话并返回用户;无效则删行并返回错误
|
||||
func ResolveSession(sessionID string) (*models.User, *models.Session, error) {
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
if sessionID == "" {
|
||||
return nil, nil, ErrSessionInvalid
|
||||
}
|
||||
var sess models.Session
|
||||
if err := models.DB.First(&sess, "id = ?", sessionID).Error; err != nil {
|
||||
return nil, nil, ErrSessionInvalid
|
||||
}
|
||||
now := time.Now()
|
||||
if now.After(sess.ExpiresAt) {
|
||||
_ = models.DB.Delete(&models.Session{}, "id = ?", sessionID).Error
|
||||
return nil, nil, ErrSessionInvalid
|
||||
}
|
||||
var user models.User
|
||||
if err := models.DB.First(&user, sess.UserID).Error; err != nil {
|
||||
_ = models.DB.Delete(&models.Session{}, "id = ?", sessionID).Error
|
||||
return nil, nil, ErrSessionInvalid
|
||||
}
|
||||
touchSession(&sess, now)
|
||||
return &user, &sess, nil
|
||||
}
|
||||
|
||||
func touchSession(sess *models.Session, now time.Time) {
|
||||
if v, ok := sessionTouchCache.Load(sess.ID); ok {
|
||||
if t, ok := v.(time.Time); ok && now.Sub(t) < sessionTouchMin {
|
||||
return
|
||||
}
|
||||
}
|
||||
sessionTouchCache.Store(sess.ID, now)
|
||||
half := SessionTTL / 2
|
||||
remaining := sess.ExpiresAt.Sub(now)
|
||||
updates := map[string]interface{}{"last_seen_at": now}
|
||||
if remaining < half {
|
||||
updates["expires_at"] = now.Add(SessionTTL)
|
||||
sess.ExpiresAt = now.Add(SessionTTL)
|
||||
}
|
||||
sess.LastSeenAt = now
|
||||
_ = models.DB.Model(&models.Session{}).Where("id = ?", sess.ID).Updates(updates).Error
|
||||
}
|
||||
|
||||
// DeleteSession 登出当前会话
|
||||
func DeleteSession(sessionID string) {
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
if sessionID == "" {
|
||||
return
|
||||
}
|
||||
_ = models.DB.Delete(&models.Session{}, "id = ?", sessionID).Error
|
||||
sessionTouchCache.Delete(sessionID)
|
||||
}
|
||||
|
||||
// RevokeUserSessions 吊销用户全部会话(禁言 / 改密)
|
||||
func RevokeUserSessions(userID uint) {
|
||||
if userID == 0 {
|
||||
return
|
||||
}
|
||||
_ = models.DB.Where("user_id = ?", userID).Delete(&models.Session{}).Error
|
||||
}
|
||||
|
||||
func newSessionID() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func trimLen(s string, n int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) > n {
|
||||
return s[:n]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// SessionCookieMaxAge Cookie MaxAge(秒)
|
||||
func SessionCookieMaxAge() int {
|
||||
return int(SessionTTL.Seconds())
|
||||
}
|
||||
@@ -11,6 +11,9 @@ import (
|
||||
"git.iioio.com/freefire/jiang13-forum/models"
|
||||
)
|
||||
|
||||
// ForumSettingsService 读写 forum_settings:全部为运行时热更新(改后无需重启进程)。
|
||||
// 需重启的项仅在 CLI/Env(端口、DATA、DB_*)与 data 下密钥文件,见 docs/rebuild-spec/07-config-ops.md。
|
||||
|
||||
// 论坛设置键名
|
||||
const (
|
||||
SettingPostEditWindowHours = "post_edit_window_hours"
|
||||
@@ -64,6 +67,7 @@ const (
|
||||
SettingOIDCAdminGroup = "oidc_admin_group"
|
||||
SettingOIDCUserGroup = "oidc_user_group"
|
||||
|
||||
// Gitea 同步键保留兼容,产品能力已后置(见 02-features.md §K)
|
||||
SettingGiteaSyncEnabled = "gitea_sync_enabled"
|
||||
SettingGiteaBaseURL = "gitea_base_url"
|
||||
SettingGiteaToken = "gitea_token"
|
||||
@@ -93,6 +97,10 @@ const (
|
||||
SettingSiteFriendLinks = "site_friend_links"
|
||||
SettingFriendLinkReciprocalCheck = "friend_link_reciprocal_check"
|
||||
|
||||
SettingFilterWords = "filter_words"
|
||||
|
||||
SettingOIDCRSAPrivatePEM = "oidc_rsa_private_pem"
|
||||
|
||||
// pageSizeAPIMax 单次列表请求条数硬上限(防客户端传超大 size),非后台可配项
|
||||
pageSizeAPIMax = 100
|
||||
)
|
||||
@@ -403,63 +411,63 @@ func NewForumSettingsService() *ForumSettingsService {
|
||||
func (s *ForumSettingsService) ensureDefaults() {
|
||||
for _, def := range forumSettingDefs {
|
||||
var count int64
|
||||
models.DB.Model(&models.ForumSetting{}).Where("`key` = ?", def.key).Count(&count)
|
||||
models.DB.Model(&models.ForumSetting{}).Where(&models.ForumSetting{Key: def.key}).Count(&count)
|
||||
if count == 0 {
|
||||
models.DB.Create(&models.ForumSetting{Key: def.key, Value: def.defaultVal})
|
||||
}
|
||||
}
|
||||
for key, val := range feedSettingDefaults {
|
||||
var count int64
|
||||
models.DB.Model(&models.ForumSetting{}).Where("`key` = ?", key).Count(&count)
|
||||
models.DB.Model(&models.ForumSetting{}).Where(&models.ForumSetting{Key: key}).Count(&count)
|
||||
if count == 0 {
|
||||
models.DB.Create(&models.ForumSetting{Key: key, Value: val})
|
||||
}
|
||||
}
|
||||
for key, val := range asideSettingDefaults {
|
||||
var count int64
|
||||
models.DB.Model(&models.ForumSetting{}).Where("`key` = ?", key).Count(&count)
|
||||
models.DB.Model(&models.ForumSetting{}).Where(&models.ForumSetting{Key: key}).Count(&count)
|
||||
if count == 0 {
|
||||
models.DB.Create(&models.ForumSetting{Key: key, Value: val})
|
||||
}
|
||||
}
|
||||
for key, val := range mailSettingDefaults {
|
||||
var count int64
|
||||
models.DB.Model(&models.ForumSetting{}).Where("`key` = ?", key).Count(&count)
|
||||
models.DB.Model(&models.ForumSetting{}).Where(&models.ForumSetting{Key: key}).Count(&count)
|
||||
if count == 0 {
|
||||
models.DB.Create(&models.ForumSetting{Key: key, Value: val})
|
||||
}
|
||||
}
|
||||
for key, val := range oidcSettingDefaults {
|
||||
var count int64
|
||||
models.DB.Model(&models.ForumSetting{}).Where("`key` = ?", key).Count(&count)
|
||||
models.DB.Model(&models.ForumSetting{}).Where(&models.ForumSetting{Key: key}).Count(&count)
|
||||
if count == 0 {
|
||||
models.DB.Create(&models.ForumSetting{Key: key, Value: val})
|
||||
}
|
||||
}
|
||||
for key, val := range giteaSettingDefaults {
|
||||
var count int64
|
||||
models.DB.Model(&models.ForumSetting{}).Where("`key` = ?", key).Count(&count)
|
||||
models.DB.Model(&models.ForumSetting{}).Where(&models.ForumSetting{Key: key}).Count(&count)
|
||||
if count == 0 {
|
||||
models.DB.Create(&models.ForumSetting{Key: key, Value: val})
|
||||
}
|
||||
}
|
||||
for key, val := range storageSettingDefaults {
|
||||
var count int64
|
||||
models.DB.Model(&models.ForumSetting{}).Where("`key` = ?", key).Count(&count)
|
||||
models.DB.Model(&models.ForumSetting{}).Where(&models.ForumSetting{Key: key}).Count(&count)
|
||||
if count == 0 {
|
||||
models.DB.Create(&models.ForumSetting{Key: key, Value: val})
|
||||
}
|
||||
}
|
||||
for key, val := range siteBrandingDefaults {
|
||||
var count int64
|
||||
models.DB.Model(&models.ForumSetting{}).Where("`key` = ?", key).Count(&count)
|
||||
models.DB.Model(&models.ForumSetting{}).Where(&models.ForumSetting{Key: key}).Count(&count)
|
||||
if count == 0 {
|
||||
models.DB.Create(&models.ForumSetting{Key: key, Value: val})
|
||||
}
|
||||
}
|
||||
for key, val := range friendLinkSettingDefaults {
|
||||
var count int64
|
||||
models.DB.Model(&models.ForumSetting{}).Where("`key` = ?", key).Count(&count)
|
||||
models.DB.Model(&models.ForumSetting{}).Where(&models.ForumSetting{Key: key}).Count(&count)
|
||||
if count == 0 {
|
||||
models.DB.Create(&models.ForumSetting{Key: key, Value: val})
|
||||
}
|
||||
@@ -468,7 +476,7 @@ func (s *ForumSettingsService) ensureDefaults() {
|
||||
|
||||
func (s *ForumSettingsService) getString(key, fallback string) string {
|
||||
var setting models.ForumSetting
|
||||
if err := models.DB.First(&setting, "`key` = ?", key).Error; err != nil {
|
||||
if err := models.DB.First(&setting, &models.ForumSetting{Key: key}).Error; err != nil {
|
||||
return fallback
|
||||
}
|
||||
return setting.Value
|
||||
@@ -482,7 +490,7 @@ func (s *ForumSettingsService) setString(key, value string) error {
|
||||
|
||||
func (s *ForumSettingsService) getInt(key string, fallback int) int {
|
||||
var setting models.ForumSetting
|
||||
if err := models.DB.First(&setting, "`key` = ?", key).Error; err != nil {
|
||||
if err := models.DB.First(&setting, &models.ForumSetting{Key: key}).Error; err != nil {
|
||||
return fallback
|
||||
}
|
||||
v, err := strconv.Atoi(setting.Value)
|
||||
@@ -589,6 +597,23 @@ func (s *ForumSettingsService) PublicLimits() ForumLimitsPublic {
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateRateLimits 仅更新基础限流(Admin SSR 子集,不影响其它 limits)
|
||||
func (s *ForumSettingsService) UpdateRateLimits(post, comment, register, login, windowSec int) error {
|
||||
updates := map[string]int{
|
||||
SettingRateLimitPost: post,
|
||||
SettingRateLimitComment: comment,
|
||||
SettingRateLimitRegister: register,
|
||||
SettingRateLimitLogin: login,
|
||||
SettingRateLimitWindow: windowSec,
|
||||
}
|
||||
for key, val := range updates {
|
||||
if err := s.setInt(key, val); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error {
|
||||
updates := map[string]int{
|
||||
SettingPostEditWindowHours: in.PostEditWindowHours,
|
||||
|
||||
@@ -95,7 +95,11 @@ func (s *UserService) ResetPasswordByEmail(email, newPass string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return models.DB.Model(&models.User{}).Where("id = ?", user.ID).Update("password", hash).Error
|
||||
if err := models.DB.Model(&models.User{}).Where("id = ?", user.ID).Update("password", hash).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
RevokeUserSessions(user.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SearchUsersBrief 公开用户搜索(@补全):匹配用户名/昵称,不含邮箱
|
||||
@@ -288,7 +292,13 @@ func (s *UserService) BanUser(userID uint, banned bool) error {
|
||||
if banned {
|
||||
updates["banned_at"] = &now
|
||||
}
|
||||
return models.DB.Model(&models.User{}).Where("id = ?", userID).Updates(updates).Error
|
||||
if err := models.DB.Model(&models.User{}).Where("id = ?", userID).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if banned {
|
||||
RevokeUserSessions(userID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SitemapUser 站点地图用的轻量用户字段
|
||||
|
||||
Reference in New Issue
Block a user