feat: opaque session、安装/发帖 SSR 与最小 Admin 后台

浏览器登录改为 DB sessions(可吊销);敏感词与 OIDC PEM 入 settings;
落地安装向导、注册发帖与 /admin 仪表盘/板块/审核/设置。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-29 05:44:16 +08:00
parent 3f50316ad0
commit fde5f628ec
80 changed files with 4148 additions and 1849 deletions

View File

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

View File

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

View File

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

View File

@@ -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
View 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, "![]($1)")
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
View 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
}

View File

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

View File

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

View File

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

View File

@@ -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 用 CONCATCOALESCE 三库通用
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
View 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())
}

View File

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

View File

@@ -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 站点地图用的轻量用户字段