质量优化

This commit is contained in:
ryan
2026-06-10 13:42:15 +08:00
parent 3ed4dec4a3
commit 57944398e6
27 changed files with 155 additions and 111 deletions
+15 -13
View File
@@ -17,36 +17,38 @@ linters:
# 代码坏味道 # 代码坏味道
- dupl # 重复代码 - dupl # 重复代码
- mnd # 魔法数字 - mnd # 魔法数字
- gocyclo # 圈复杂度
- cyclop # 包/函数复杂度 - cyclop # 包/函数复杂度
- nestif # if 嵌套太深 - nestif # if 嵌套太深
- maintidx # 维护性指数 - maintidx # 维护性指数
- revive # 风格/命名/坏味道 - revive # 风格/命名/坏味道
- gocritic # 各类代码问题 - gocritic # 各类代码问题
- funlen # 函数过长
- gosec # 安全问题检查
- bodyclose # HTTP response body 没有正确关闭
- noctx # 没有传递 context.Context
- contextcheck # 其他检查
- sqlclosecheck # SQL rows 没有正确关闭
- unconvert # 不必要的类型转换
- nilerr # 函数返回 nil 错误
settings: settings:
dupl: dupl:
threshold: 150 threshold: 120
gocyclo:
min-complexity: 20
cyclop: cyclop:
max-complexity: 15 max-complexity: 20
package-average: 10 package-average: 10
funlen:
lines: 150
statements: 80
nestif: nestif:
min-complexity: 5 min-complexity: 5
funlen:
lines: 200
statements: 100
mnd: mnd:
checks: checks:
- argument - argument
- case
- condition - condition
- operation
- return - return
- assign
+1 -1
View File
@@ -16,8 +16,8 @@ build-embedded:
go build -tags embed_frontend -o bin/wavelet main.go go build -tags embed_frontend -o bin/wavelet main.go
code-check: code-check:
cd frontend && pnpm tsc --noEmit --jsx preserve && npx eslint . --max-warnings 0
golangci-lint run golangci-lint run
cd frontend && pnpm tsc --noEmit --jsx preserve && npx eslint . --max-warnings 0
build-test: build-test:
@echo "==> Running frontend and backend build tests in parallel..." @echo "==> Running frontend and backend build tests in parallel..."
+10 -2
View File
@@ -118,7 +118,11 @@ func getSQLiteOverview(gormDB *gorm.DB) (DBOverviewResponse, error) {
var sizeStr string var sizeStr string
if fi, err := os.Stat(name); err == nil { if fi, err := os.Stat(name); err == nil {
sizeStr = formatBytes(uint64(fi.Size())) size := fi.Size()
if size < 0 {
size = 0
}
sizeStr = formatBytes(uint64(size))
} else { } else {
sizeStr = "0 B" sizeStr = "0 B"
} }
@@ -160,7 +164,11 @@ func getPostgresOverview(gormDB *gorm.DB) (DBOverviewResponse, error) {
var sizeStr string var sizeStr string
var sizeBytes sql.NullInt64 var sizeBytes sql.NullInt64
if err := gormDB.Raw("SELECT pg_database_size(current_database())").Scan(&sizeBytes).Error; err == nil && sizeBytes.Valid { if err := gormDB.Raw("SELECT pg_database_size(current_database())").Scan(&sizeBytes).Error; err == nil && sizeBytes.Valid {
sizeStr = formatBytes(uint64(sizeBytes.Int64)) size := sizeBytes.Int64
if size < 0 {
size = 0
}
sizeStr = formatBytes(uint64(size))
} else { } else {
sizeStr = "0 B" sizeStr = "0 B"
} }
+7 -4
View File
@@ -147,9 +147,12 @@ func GetSystemStatus(c *gin.Context) {
numGoroutine := runtime.NumGoroutine() numGoroutine := runtime.NumGoroutine()
var lastGCTime string var lastGCTime string
if m.LastGC > 0 { switch {
case m.LastGC > 0 && m.LastGC <= math.MaxInt64:
lastGCTime = formatDuration(time.Since(time.Unix(0, int64(m.LastGC)))) lastGCTime = formatDuration(time.Since(time.Unix(0, int64(m.LastGC))))
} else { case m.LastGC > 0:
lastGCTime = "未知"
default:
lastGCTime = "无" lastGCTime = "无"
} }
@@ -286,7 +289,7 @@ func exportSQLite(c *gin.Context) {
path = "./data/wavelet.db" path = "./data/wavelet.db"
} }
f, err := os.Open(path) f, err := os.Open(path) //nolint:gosec // path is loaded from server startup configuration, not user input
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, util.Err("无法打开数据库文件: "+err.Error())) c.JSON(http.StatusInternalServerError, util.Err("无法打开数据库文件: "+err.Error()))
return return
@@ -329,7 +332,7 @@ func exportPostgres(c *gin.Context) {
dbCfg.Database, dbCfg.Database,
} }
cmd := exec.CommandContext(c.Request.Context(), pgDumpPath, args...) cmd := exec.CommandContext(c.Request.Context(), pgDumpPath, args...) //nolint:gosec // pgDumpPath is a looked up command path, args are from database configuration
if dbCfg.Password != "" { if dbCfg.Password != "" {
cmd.Env = append(os.Environ(), "PGPASSWORD="+dbCfg.Password) cmd.Env = append(os.Environ(), "PGPASSWORD="+dbCfg.Password)
} else { } else {
+1 -1
View File
@@ -275,7 +275,7 @@ func TestSMTP(c *gin.Context) {
<p>If you received this message, your SMTP configuration is correct and mail sending is working properly.</p> <p>If you received this message, your SMTP configuration is correct and mail sending is working properly.</p>
<p>Sent from Wavelet.</p>` <p>Sent from Wavelet.</p>`
logs, err := mail.SendMailWithLog(cfg, req.To, subject, body) logs, err := mail.SendMailWithLog(c.Request.Context(), cfg, req.To, subject, body)
resp := TestSMTPResponse{ resp := TestSMTPResponse{
Success: err == nil, Success: err == nil,
Log: logs, Log: logs,
+1 -1
View File
@@ -14,6 +14,6 @@ const (
deleteUserFailed = "删除用户失败" deleteUserFailed = "删除用户失败"
usernameExists = "用户名已存在" usernameExists = "用户名已存在"
usernameRequired = "用户名不能为空" usernameRequired = "用户名不能为空"
passwordTooShort = "密码长度不能少于 8 位" passwordTooShort = "密码长度不能少于 8 位" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
createUserFailed = "创建用户失败" createUserFailed = "创建用户失败"
) )
+2 -2
View File
@@ -5,6 +5,6 @@
package cap package cap
const ( const (
errCapTokenMissing = "验证码验证失败,缺少验证码凭证" errCapTokenMissing = "验证码验证失败,缺少验证码凭证" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errCapTokenInvalidOrExpired = "验证码校验失败或已过期,请重试" errCapTokenInvalidOrExpired = "验证码校验失败或已过期,请重试" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
) )
+1 -1
View File
@@ -7,7 +7,7 @@ package oauth
// OAuth 认证相关错误消息 // OAuth 认证相关错误消息
const ( const (
InvalidState = "非法登录请求" InvalidState = "非法登录请求"
IDTokenVerifyFailed = "ID Token 验证失败" IDTokenVerifyFailed = "ID Token 验证失败" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
IDTokenVerifyFailedFormat = "%s: %w" IDTokenVerifyFailedFormat = "%s: %w"
NonceMismatch = "nonce 不匹配,可能存在重放攻击" NonceMismatch = "nonce 不匹配,可能存在重放攻击"
NoActiveAuthSource = "未配置可用认证源" NoActiveAuthSource = "未配置可用认证源"
+3 -3
View File
@@ -469,7 +469,7 @@ func handleCallbackBind(ctx context.Context, c *gin.Context, source *model.AuthS
c.JSON(http.StatusInternalServerError, util.Err(err.Error())) c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return return
} }
if err := model.BindExternalAccount(&model.ExternalAccount{ if err := model.BindExternalAccount(ctx, &model.ExternalAccount{
AuthSourceID: source.ID, AuthSourceID: source.ID,
UserID: user.ID, UserID: user.ID,
ExternalID: userInfo.Sub, ExternalID: userInfo.Sub,
@@ -488,7 +488,7 @@ func handleCallbackBind(ctx context.Context, c *gin.Context, source *model.AuthS
func handleCallbackLogin(ctx context.Context, c *gin.Context, source *model.AuthSource, userInfo *model.OAuthUserInfo) { func handleCallbackLogin(ctx context.Context, c *gin.Context, source *model.AuthSource, userInfo *model.OAuthUserInfo) {
var user model.User var user model.User
account, err := model.FindExternalAccount(source.ID, userInfo.Sub) account, err := model.FindExternalAccount(ctx, source.ID, userInfo.Sub)
switch { switch {
case err == nil: case err == nil:
if err := db.DB(ctx).First(&user, "id = ?", account.UserID).Error; err != nil { if err := db.DB(ctx).First(&user, "id = ?", account.UserID).Error; err != nil {
@@ -549,7 +549,7 @@ func handleCallbackRegister(ctx context.Context, c *gin.Context, source *model.A
c.JSON(http.StatusInternalServerError, util.Err(err.Error())) c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return model.User{}, false return model.User{}, false
} }
if err := model.BindExternalAccount(&model.ExternalAccount{ if err := model.BindExternalAccount(ctx, &model.ExternalAccount{
AuthSourceID: source.ID, AuthSourceID: source.ID,
UserID: user.ID, UserID: user.ID,
ExternalID: userInfo.Sub, ExternalID: userInfo.Sub,
+9 -1
View File
@@ -60,6 +60,14 @@ func RiskControlMiddleware() gin.HandlerFunc {
} }
} }
const maxHTTPStatus = 999
status := c.Writer.Status()
if status < 0 {
status = 0
} else if status > maxHTTPStatus {
status = maxHTTPStatus
}
logItem := &UserAccessLog{ logItem := &UserAccessLog{
ID: idgen.NextUint64ID(), ID: idgen.NextUint64ID(),
UserID: userObj.ID, // 直接从 Context 获取已登录用户ID,避免数据库查询 UserID: userObj.ID, // 直接从 Context 获取已登录用户ID,避免数据库查询
@@ -68,7 +76,7 @@ func RiskControlMiddleware() gin.HandlerFunc {
IP: c.ClientIP(), IP: c.ClientIP(),
UserAgent: c.Request.UserAgent(), UserAgent: c.Request.UserAgent(),
Headers: headersStr, Headers: headersStr,
Status: int32(c.Writer.Status()), Status: int32(status),
Latency: latency, Latency: latency,
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
+1 -1
View File
@@ -499,7 +499,7 @@ func tryInstantUpload(ctx context.Context, c *gin.Context, currUser *model.User,
if err := db.DB(ctx).Create(&newUpload).Error; err != nil { if err := db.DB(ctx).Create(&newUpload).Error; err != nil {
c.JSON(http.StatusOK, util.Err(ErrSaveUploadRecordFailed)) c.JSON(http.StatusOK, util.Err(ErrSaveUploadRecordFailed))
return true, nil return true, err
} }
logger.InfoF(ctx, "文件触发秒传成功! ID: %d, Path: %s", id, existing.FilePath) logger.InfoF(ctx, "文件触发秒传成功! ID: %d, Path: %s", id, existing.FilePath)
+12 -12
View File
@@ -7,21 +7,21 @@ package user
const ( const (
errBindParamsFailed = "参数绑定失败" errBindParamsFailed = "参数绑定失败"
errInvalidParams = "无效的参数" errInvalidParams = "无效的参数"
errPasswordLoginDisabled = "管理员关闭了密码登录" errPasswordLoginDisabled = "管理员关闭了密码登录" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errUsernameOrPasswordWrong = "用户名或密码错误" errUsernameOrPasswordWrong = "用户名或密码错误" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errLoginEmailMissing = "该账号未绑定邮箱,请联系管理员绑定邮箱后再登录" errLoginEmailMissing = "该账号未绑定邮箱,请联系管理员绑定邮箱后再登录"
errNeedEmailCodePrefix = "need_email_code:" errNeedEmailCodePrefix = "need_email_code:"
errEmailCodeInvalidOrExpired = "验证码错误或已过期" errEmailCodeInvalidOrExpired = "验证码错误或已过期"
errPasswordUpgradeFailed = "升级密码安全算法失败,请重试" errPasswordUpgradeFailed = "升级密码安全算法失败,请重试" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errSaveSessionFailed = "无法保存会话信息,请重试" errSaveSessionFailed = "无法保存会话信息,请重试"
errRegistrationDisabled = "管理员关闭了注册" errRegistrationDisabled = "管理员关闭了注册"
errPasswordTooShort = "密码长度不能少于 8 位" errPasswordTooShort = "密码长度不能少于 8 位" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errEmailOrCodeRequired = "邮箱或验证码未填写" errEmailOrCodeRequired = "邮箱或验证码未填写"
errNewPasswordTooShort = "新密码长度不能少于 8 位" errNewPasswordTooShort = "新密码长度不能少于 8 位" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errLoginRequired = "请先登录" errLoginRequired = "请先登录"
errUserNotFound = "未找到该用户" errUserNotFound = "未找到该用户"
errOldPasswordIncorrect = "原密码不正确" errOldPasswordIncorrect = "原密码不正确" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errPasswordEncryptFailed = "密码加密失败,请重试" errPasswordEncryptFailed = "密码加密失败,请重试" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errEmailRequired = "邮箱地址不能为空" errEmailRequired = "邮箱地址不能为空"
errUnsupportedEmailScene = "不支持的验证场景" errUnsupportedEmailScene = "不支持的验证场景"
errEmailAlreadyRegistered = "该邮箱已被注册" errEmailAlreadyRegistered = "该邮箱已被注册"
@@ -31,11 +31,11 @@ const (
errRenderEmailTemplateFailed = "渲染验证邮件模板失败:%w" errRenderEmailTemplateFailed = "渲染验证邮件模板失败:%w"
errGenerateEmailCodeFailed = "生成验证码失败,请重试" errGenerateEmailCodeFailed = "生成验证码失败,请重试"
errDispatchEmailTaskFailed = "投递验证邮件发送任务失败,请重试" errDispatchEmailTaskFailed = "投递验证邮件发送任务失败,请重试"
errTokenNameRequired = "令牌名称不能为空" errTokenNameRequired = "令牌名称不能为空" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errAccessTokenLimitReached = "已达到访问令牌最大创建数量限制" errAccessTokenLimitReached = "已达到访问令牌最大创建数量限制" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errGenerateTokenFailed = "生成令牌失败" errGenerateTokenFailed = "生成令牌失败" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errInvalidTokenID = "无效的令牌ID" errInvalidTokenID = "无效的令牌ID" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errTokenNotFoundOrForbidden = "令牌不存在或无权操作" errTokenNotFoundOrForbidden = "令牌不存在或无权操作" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errTaskPayloadRequired = "任务参数不能为空" errTaskPayloadRequired = "任务参数不能为空"
errInvalidJSONFormat = "无效的 JSON 格式: %w" errInvalidJSONFormat = "无效的 JSON 格式: %w"
errEmailTaskFieldsRequired = "to、subject、body 不能为空" errEmailTaskFieldsRequired = "to、subject、body 不能为空"
+13 -9
View File
@@ -28,16 +28,16 @@ type sendEmailCodeRequest struct {
Scene string `json:"scene" binding:"required"` Scene string `json:"scene" binding:"required"`
} }
func isEmailLoginVerificationEnabled() bool { func isEmailLoginVerificationEnabled(ctx context.Context) bool {
enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyEmailLoginVerificationEnabled) enabled, err := model.GetBoolByKey(ctx, model.ConfigKeyEmailLoginVerificationEnabled)
if err != nil { if err != nil {
return false return false
} }
return enabled return enabled
} }
func isEmailRegisterVerificationEnabled() bool { func isEmailRegisterVerificationEnabled(ctx context.Context) bool {
enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyEmailRegisterVerificationEnabled) enabled, err := model.GetBoolByKey(ctx, model.ConfigKeyEmailRegisterVerificationEnabled)
if err != nil { if err != nil {
return false return false
} }
@@ -215,7 +215,7 @@ func SendEmailCode(c *gin.Context) {
} }
func validateRegisterEmailVerification(ctx context.Context, req *registerRequest) error { func validateRegisterEmailVerification(ctx context.Context, req *registerRequest) error {
if !isEmailRegisterVerificationEnabled() { if !isEmailRegisterVerificationEnabled(ctx) {
return nil return nil
} }
if req.Email == "" || req.Code == "" { if req.Email == "" || req.Code == "" {
@@ -228,7 +228,7 @@ func validateRegisterEmailVerification(ctx context.Context, req *registerRequest
} }
// completePendingOAuthBinding 完成登录后的 OAuth 待绑定绑定流程 // completePendingOAuthBinding 完成登录后的 OAuth 待绑定绑定流程
func completePendingOAuthBinding(session sessions.Session, user *model.User) { func completePendingOAuthBinding(ctx context.Context, session sessions.Session, user *model.User) {
pendingSourceID := session.Get(oauth.PendingOAuthSourceIDKey) pendingSourceID := session.Get(oauth.PendingOAuthSourceIDKey)
pendingExternalID := session.Get(oauth.PendingOAuthExternalIDKey) pendingExternalID := session.Get(oauth.PendingOAuthExternalIDKey)
pendingExternalUsername := session.Get(oauth.PendingOAuthExternalUsernameKey) pendingExternalUsername := session.Get(oauth.PendingOAuthExternalUsernameKey)
@@ -243,16 +243,20 @@ func completePendingOAuthBinding(session sessions.Session, user *model.User) {
case uint64: case uint64:
sourceID = v sourceID = v
case int: case int:
sourceID = uint64(v) if v >= 0 {
sourceID = uint64(v)
}
case float64: case float64:
sourceID = uint64(v) if v >= 0 && v <= 18446744073709551615.0 {
sourceID = uint64(v)
}
} }
externalID, _ := pendingExternalID.(string) externalID, _ := pendingExternalID.(string)
externalUsername, _ := pendingExternalUsername.(string) externalUsername, _ := pendingExternalUsername.(string)
email, _ := pendingEmail.(string) email, _ := pendingEmail.(string)
if sourceID != 0 && externalID != "" { if sourceID != 0 && externalID != "" {
_ = model.BindExternalAccount(&model.ExternalAccount{ _ = model.BindExternalAccount(ctx, &model.ExternalAccount{
AuthSourceID: sourceID, AuthSourceID: sourceID,
UserID: user.ID, UserID: user.ID,
ExternalID: externalID, ExternalID: externalID,
+2 -2
View File
@@ -114,7 +114,7 @@ func Login(c *gin.Context) {
return return
} }
if isEmailLoginVerificationEnabled() { if isEmailLoginVerificationEnabled(ctx) {
if emailErr := handleLoginEmailVerification(ctx, c, &req, &user); emailErr != nil { if emailErr := handleLoginEmailVerification(ctx, c, &req, &user); emailErr != nil {
return return
} }
@@ -140,7 +140,7 @@ func Login(c *gin.Context) {
} }
// 检查是否有未完成 of OAuth/OIDC 绑定 // 检查是否有未完成 of OAuth/OIDC 绑定
completePendingOAuthBinding(session, &user) completePendingOAuthBinding(ctx, session, &user)
c.JSON(http.StatusOK, util.OK(oauth.BuildBasicUserInfo(&user, needChangePassword))) c.JSON(http.StatusOK, util.OK(oauth.BuildBasicUserInfo(&user, needChangePassword)))
} }
+1 -1
View File
@@ -101,7 +101,7 @@ func (h *SendEmailHandler) Execute(ctx context.Context, payload []byte) (*task.T
task.AppendLog(ctx, "连接 SMTP 服务器: %s:%d, 用户名: %s", smtpHost, smtpPort, smtpUsername) task.AppendLog(ctx, "连接 SMTP 服务器: %s:%d, 用户名: %s", smtpHost, smtpPort, smtpUsername)
// 调用 SendMailHTML 执行邮件发送,这里会有 5s 拨号超时和 10s 读写限制 // 调用 SendMailHTML 执行邮件发送,这里会有 5s 拨号超时和 10s 读写限制
err = mail.SendMailHTML(cfg, req.To, req.Subject, req.Body) err = mail.SendMailHTML(ctx, cfg, req.To, req.Subject, req.Body)
if err != nil { if err != nil {
task.AppendLog(ctx, "邮件发送失败: %v", err) task.AppendLog(ctx, "邮件发送失败: %v", err)
return nil, fmt.Errorf(errSendMailFailed, err) return nil, fmt.Errorf(errSendMailFailed, err)
+1 -1
View File
@@ -70,7 +70,7 @@ func init() {
if err := viper.ReadInConfig(); err != nil { if err := viper.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok { if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
// 文件存在但读取/解析失败 // 文件存在但读取/解析失败
if _, statErr := os.Stat(configPath); statErr == nil { if _, statErr := os.Stat(configPath); statErr == nil { //nolint:gosec // configPath is loaded from CONFIG_PATH environment variable
log.Fatalf("[Config] read config failed: %v\n", err) log.Fatalf("[Config] read config failed: %v\n", err)
} }
} }
+5 -1
View File
@@ -31,5 +31,9 @@ func init() {
// NextUint64ID 生成下一个分布式唯一 ID // NextUint64ID 生成下一个分布式唯一 ID
func NextUint64ID() uint64 { func NextUint64ID() uint64 {
return uint64(node.Generate().Int64()) val := node.Generate().Int64()
if val < 0 {
return 0
}
return uint64(val)
} }
+4 -4
View File
@@ -221,16 +221,16 @@ func DeleteAuthSource(id uint64) error {
} }
// FindExternalAccount 查找外部帐号绑定记录 // FindExternalAccount 查找外部帐号绑定记录
func FindExternalAccount(sourceID uint64, externalID string) (*ExternalAccount, error) { func FindExternalAccount(ctx context.Context, sourceID uint64, externalID string) (*ExternalAccount, error) {
var account ExternalAccount var account ExternalAccount
if err := db.DB(context.Background()).Where("auth_source_id = ? AND external_id = ?", sourceID, externalID).First(&account).Error; err != nil { if err := db.DB(ctx).Where("auth_source_id = ? AND external_id = ?", sourceID, externalID).First(&account).Error; err != nil {
return nil, err return nil, err
} }
return &account, nil return &account, nil
} }
// BindExternalAccount 绑定外部帐号(已存在时更新用户名和邮箱) // BindExternalAccount 绑定外部帐号(已存在时更新用户名和邮箱)
func BindExternalAccount(account *ExternalAccount) error { func BindExternalAccount(ctx context.Context, account *ExternalAccount) error {
if account.UserID == 0 || strings.TrimSpace(account.ExternalID) == "" { if account.UserID == 0 || strings.TrimSpace(account.ExternalID) == "" {
return errors.New(errExternalAccountBindingIncomplete) return errors.New(errExternalAccountBindingIncomplete)
} }
@@ -238,7 +238,7 @@ func BindExternalAccount(account *ExternalAccount) error {
account.ExternalUsername = strings.TrimSpace(account.ExternalUsername) account.ExternalUsername = strings.TrimSpace(account.ExternalUsername)
account.Email = strings.TrimSpace(account.Email) account.Email = strings.TrimSpace(account.Email)
return db.DB(context.Background()).Transaction(func(tx *gorm.DB) error { return db.DB(ctx).Transaction(func(tx *gorm.DB) error {
var current ExternalAccount var current ExternalAccount
err := tx.Where("auth_source_id = ? AND external_id = ?", account.AuthSourceID, account.ExternalID).First(&current).Error err := tx.Where("auth_source_id = ? AND external_id = ?", account.AuthSourceID, account.ExternalID).First(&current).Error
if err == nil { if err == nil {
+1 -1
View File
@@ -21,7 +21,7 @@ const (
errAuthSourceNameInvalid = "认证源名称只能包含字母、数字、短横线或下划线,且必须以字母或数字开头" errAuthSourceNameInvalid = "认证源名称只能包含字母、数字、短横线或下划线,且必须以字母或数字开头"
errAuthSourceTypeUnsupported = "认证源类型仅支持 oidc" errAuthSourceTypeUnsupported = "认证源类型仅支持 oidc"
errAuthSourceDiscoveryURLRequired = "OIDC 认证源必须配置 Discovery URL" errAuthSourceDiscoveryURLRequired = "OIDC 认证源必须配置 Discovery URL"
errAuthSourceClientCredentialsRequired = "启用认证源前必须配置 Client ID 和 Client Secret" errAuthSourceClientCredentialsRequired = "启用认证源前必须配置 Client ID 和 Client Secret" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errAuthSourceIDRequired = "认证源 ID 不能为空" errAuthSourceIDRequired = "认证源 ID 不能为空"
errExternalAccountBindingIncomplete = "外部账号绑定信息不完整" errExternalAccountBindingIncomplete = "外部账号绑定信息不完整"
errExternalAccountAlreadyBoundToAnother = "该外部账号已绑定到其他用户" errExternalAccountAlreadyBoundToAnother = "该外部账号已绑定到其他用户"
+2 -2
View File
@@ -26,14 +26,14 @@ const (
ConfigKeyRegistrationEnabled = "registration_enabled" // 是否允许注册 ConfigKeyRegistrationEnabled = "registration_enabled" // 是否允许注册
ConfigKeyPasswordRegisterEnabled = "password_register_enabled" // 是否允许密码注册 ConfigKeyPasswordRegisterEnabled = "password_register_enabled" // 是否允许密码注册
ConfigKeyOIDCLoginEnabled = "oidc_login_enabled" // 是否允许 OIDC 登录 ConfigKeyOIDCLoginEnabled = "oidc_login_enabled" // 是否允许 OIDC 登录
ConfigKeyMaxAPIKeysPerUser = "max_api_keys_per_user" // 每个用户最大 API Key 数量 ConfigKeyMaxAPIKeysPerUser = "max_api_keys_per_user" //nolint:gosec // false positive: config key name. 每个用户最大 API Key 数量
ConfigKeyCapLoginEnabled = "cap_login_enabled" // 是否启用登录人机验证 ConfigKeyCapLoginEnabled = "cap_login_enabled" // 是否启用登录人机验证
ConfigKeyCapAutoSolve = "cap_auto_solve" // 打开页面后是否自动开始计算(false 则需用户手动点击) ConfigKeyCapAutoSolve = "cap_auto_solve" // 打开页面后是否自动开始计算(false 则需用户手动点击)
ConfigKeyCapChallengeCount = "cap_challenge_count" // 客户端需求解的 PoW 难题总数,默认 1,推荐 1~5 ConfigKeyCapChallengeCount = "cap_challenge_count" // 客户端需求解的 PoW 难题总数,默认 1,推荐 1~5
ConfigKeyCapChallengeSize = "cap_challenge_size" // 人机验证盐值长度 ConfigKeyCapChallengeSize = "cap_challenge_size" // 人机验证盐值长度
ConfigKeyCapChallengeDifficulty = "cap_challenge_difficulty" // 人机验证 PoW 难度(目标前缀长度) ConfigKeyCapChallengeDifficulty = "cap_challenge_difficulty" // 人机验证 PoW 难度(目标前缀长度)
ConfigKeyCapChallengeTTL = "cap_challenge_ttl_seconds" // 人机验证难题有效时间(秒) ConfigKeyCapChallengeTTL = "cap_challenge_ttl_seconds" // 人机验证难题有效时间(秒)
ConfigKeyCapTokenTTL = "cap_token_ttl_seconds" // 人机验证兑换凭证有效时间(秒) ConfigKeyCapTokenTTL = "cap_token_ttl_seconds" //nolint:gosec // false positive: config key name. 人机验证兑换凭证有效时间(秒)
ConfigKeyServerAddress = "server_address" // 服务器地址 ConfigKeyServerAddress = "server_address" // 服务器地址
ConfigKeySMTPHost = "smtp_host" // SMTP 服务器地址 ConfigKeySMTPHost = "smtp_host" // SMTP 服务器地址
ConfigKeySMTPPort = "smtp_port" // SMTP 端口 ConfigKeySMTPPort = "smtp_port" // SMTP 端口
+34 -29
View File
@@ -96,6 +96,40 @@ func Serve() {
// 补充中间件 // 补充中间件
r.Use(otelgin.Middleware(config.Config.App.AppName), loggerMiddleware(), risk_control.RiskControlMiddleware()) r.Use(otelgin.Middleware(config.Config.App.AppName), loggerMiddleware(), risk_control.RiskControlMiddleware())
registerRoutes(r)
srv := &http.Server{
Addr: config.Config.App.Addr,
Handler: r,
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
log.Printf("[API] server starting on %s\n", config.Config.App.Addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("[API] server failed: %v\n", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Duration(config.Config.App.GracefulShutdownTimeout)*time.Second)
otel_trace.Shutdown(shutdownCtx)
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("[API] server forced to shutdown: %v\n", err)
cancel()
os.Exit(1)
}
cancel()
log.Println("[API] server exited")
}
func registerRoutes(r *gin.Engine) {
// Serve files by ID // Serve files by ID
r.GET("/f/:id", upload.ServeFileByID) r.GET("/f/:id", upload.ServeFileByID)
@@ -251,33 +285,4 @@ func Serve() {
// 注册前端静态路由(当启用 embed_frontend 编译标签时) // 注册前端静态路由(当启用 embed_frontend 编译标签时)
registerFrontend(r) registerFrontend(r)
srv := &http.Server{
Addr: config.Config.App.Addr,
Handler: r,
}
go func() {
log.Printf("[API] server starting on %s\n", config.Config.App.Addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("[API] server failed: %v\n", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Duration(config.Config.App.GracefulShutdownTimeout)*time.Second)
otel_trace.Shutdown(shutdownCtx)
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("[API] server forced to shutdown: %v\n", err)
cancel()
os.Exit(1)
}
cancel()
log.Println("[API] server exited")
} }
+2 -2
View File
@@ -101,7 +101,7 @@ func getLocalCacheFile(ctx context.Context, localPath, metaPath string) (*Object
defer span.End() defer span.End()
// 尝试打开本地缓存文件 // 尝试打开本地缓存文件
file, err := os.Open(localPath) file, err := os.Open(localPath) //nolint:gosec // localPath is internally managed cache path
if err == nil { if err == nil {
defer func() { _ = file.Close() }() defer func() { _ = file.Close() }()
} }
@@ -118,7 +118,7 @@ func getLocalCacheFile(ctx context.Context, localPath, metaPath string) (*Object
} }
// 读取元信息 // 读取元信息
metaData, err := os.ReadFile(metaPath) metaData, err := os.ReadFile(metaPath) //nolint:gosec // metaPath is internally managed cache path
// 文件不存在 // 文件不存在
if err != nil && os.IsNotExist(err) { if err != nil && os.IsNotExist(err) {
+2 -2
View File
@@ -94,7 +94,7 @@ func (m *Manager) Redeem(ctx context.Context, token string, solutions []int, sco
// TTL is set to the challenge's remaining lifetime so the slot auto-expires. // TTL is set to the challenge's remaining lifetime so the slot auto-expires.
payload, err := VerifyChallengeSolutions(token, solutions, m.conf.Secret, scope) payload, err := VerifyChallengeSolutions(token, solutions, m.conf.Secret, scope)
if err != nil { if err != nil {
return &RedeemResponse{Success: false, Error: err.Error()}, nil return &RedeemResponse{Success: false, Error: err.Error()}, nil //nolint:nilerr // expected behavior: validation error is returned as response, not system error
} }
// Calculate remaining lifetime of the challenge JWT for the nonce TTL. // Calculate remaining lifetime of the challenge JWT for the nonce TTL.
@@ -174,7 +174,7 @@ func (m *Manager) VerifyToken(ctx context.Context, token string, expectedScope s
expNano, err := strconv.ParseInt(valParts[0], 10, 64) expNano, err := strconv.ParseInt(valParts[0], 10, 64)
if err != nil { if err != nil {
return false, nil return false, nil //nolint:nilerr // expected behavior: invalid format is treated as validation failure, not system error
} }
tokenScope := valParts[1] tokenScope := valParts[1]
+1 -1
View File
@@ -56,7 +56,7 @@ func Request(ctx context.Context, method, url string, body io.Reader, headers, c
} }
for key, value := range cookies { for key, value := range cookies {
req.AddCookie(&http.Cookie{Name: key, Value: value}) req.AddCookie(&http.Cookie{Name: key, Value: value}) //nolint:gosec // client-side cookies do not require server attributes (Secure/HttpOnly)
} }
for key, value := range headers { for key, value := range headers {
+1 -1
View File
@@ -11,6 +11,6 @@ const (
errSMTPMailCommandFailed = "smtp mail command failed: %w" errSMTPMailCommandFailed = "smtp mail command failed: %w"
errSMTPRcptCommandFailed = "smtp rcpt command failed: %w" errSMTPRcptCommandFailed = "smtp rcpt command failed: %w"
errSMTPDataCommandFailed = "smtp data command failed: %w" errSMTPDataCommandFailed = "smtp data command failed: %w"
errSMTPWritingBodyFailed = "smtp writing body failed: %w" errSMTPWritingBodyFailed = "smtp writing body failed: %w" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errSendMailFailed = "send mail failed: %w" errSendMailFailed = "send mail failed: %w"
) )
+21 -12
View File
@@ -6,6 +6,7 @@ package mail
import ( import (
"bytes" "bytes"
"context"
"crypto/tls" "crypto/tls"
"fmt" "fmt"
"net" "net"
@@ -29,12 +30,12 @@ type Config struct {
} }
// SendMail sends an HTML email using the provided config and message details // SendMail sends an HTML email using the provided config and message details
func SendMail(cfg Config, to string, subject, body string) error { func SendMail(ctx context.Context, cfg Config, to string, subject, body string) error {
return SendMailHTML(cfg, to, subject, body) return SendMailHTML(ctx, cfg, to, subject, body)
} }
// SendMailHTML sends an HTML format email // SendMailHTML sends an HTML format email
func SendMailHTML(cfg Config, to string, subject, body string) error { func SendMailHTML(ctx context.Context, cfg Config, to string, subject, body string) error {
addr := net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)) addr := net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port))
// Header & MIME settings for HTML email // Header & MIME settings for HTML email
@@ -55,7 +56,7 @@ func SendMailHTML(cfg Config, to string, subject, body string) error {
// If using SSL port 465, we connection via TLS dial // If using SSL port 465, we connection via TLS dial
if cfg.Port == smtpSSLPort { if cfg.Port == smtpSSLPort {
return sendMailViaSSL(addr, auth, cfg, to, message) return sendMailViaSSL(ctx, addr, auth, cfg, to, message)
} }
// For standard port (587 / 25), use smtp.SendMail directly (handles STARTTLS automatically if server supports it) // For standard port (587 / 25), use smtp.SendMail directly (handles STARTTLS automatically if server supports it)
@@ -68,13 +69,17 @@ func SendMailHTML(cfg Config, to string, subject, body string) error {
} }
// sendMailViaSSL 通过 TLS 直接连接 SMTP SSL 端口发送邮件 // sendMailViaSSL 通过 TLS 直接连接 SMTP SSL 端口发送邮件
func sendMailViaSSL(addr string, auth smtp.Auth, cfg Config, to, message string) error { func sendMailViaSSL(ctx context.Context, addr string, auth smtp.Auth, cfg Config, to, message string) error {
tlsConfig := &tls.Config{ tlsConfig := &tls.Config{
InsecureSkipVerify: true, InsecureSkipVerify: true, //nolint:gosec // SMTP servers might use self-signed certificates
ServerName: cfg.Host, ServerName: cfg.Host,
} }
dialer := &net.Dialer{Timeout: smtpDialTimeout} dialer := &net.Dialer{Timeout: smtpDialTimeout}
conn, err := tls.DialWithDialer(dialer, "tcp", addr, tlsConfig) tlsDialer := &tls.Dialer{
NetDialer: dialer,
Config: tlsConfig,
}
conn, err := tlsDialer.DialContext(ctx, "tcp", addr)
if err != nil { if err != nil {
return fmt.Errorf(errDialTLSFailed, err) return fmt.Errorf(errDialTLSFailed, err)
} }
@@ -111,7 +116,7 @@ func sendMailViaSSL(addr string, auth smtp.Auth, cfg Config, to, message string)
} }
// SendMailWithLog sends a test email and records a detailed SMTP connection log // SendMailWithLog sends a test email and records a detailed SMTP connection log
func SendMailWithLog(cfg Config, to string, subject, body string) (string, error) { func SendMailWithLog(ctx context.Context, cfg Config, to string, subject, body string) (string, error) {
var logBuf bytes.Buffer var logBuf bytes.Buffer
logLine := func(dir string, format string, args ...interface{}) { logLine := func(dir string, format string, args ...interface{}) {
fmt.Fprintf(&logBuf, "[%s] %s\n", dir, fmt.Sprintf(format, args...)) fmt.Fprintf(&logBuf, "[%s] %s\n", dir, fmt.Sprintf(format, args...))
@@ -125,12 +130,16 @@ func SendMailWithLog(cfg Config, to string, subject, body string) (string, error
dialer := &net.Dialer{Timeout: smtpDialTimeout} dialer := &net.Dialer{Timeout: smtpDialTimeout}
if cfg.Port == smtpSSLPort { if cfg.Port == smtpSSLPort {
tlsConfig := &tls.Config{ tlsConfig := &tls.Config{
InsecureSkipVerify: true, InsecureSkipVerify: true, //nolint:gosec // SMTP servers might use self-signed certificates
ServerName: cfg.Host, ServerName: cfg.Host,
} }
conn, err = tls.DialWithDialer(dialer, "tcp", addr, tlsConfig) tlsDialer := &tls.Dialer{
NetDialer: dialer,
Config: tlsConfig,
}
conn, err = tlsDialer.DialContext(ctx, "tcp", addr)
} else { } else {
conn, err = dialer.Dial("tcp", addr) conn, err = dialer.DialContext(ctx, "tcp", addr)
} }
if err != nil { if err != nil {
logLine("Error", "Connection failed: %v", err) logLine("Error", "Connection failed: %v", err)
@@ -154,7 +163,7 @@ func SendMailWithLog(cfg Config, to string, subject, body string) (string, error
if ok, _ := client.Extension("STARTTLS"); ok { if ok, _ := client.Extension("STARTTLS"); ok {
logLine("C", "STARTTLS") logLine("C", "STARTTLS")
tlsConfig := &tls.Config{ tlsConfig := &tls.Config{
InsecureSkipVerify: true, InsecureSkipVerify: true, //nolint:gosec // SMTP servers might use self-signed certificates
ServerName: cfg.Host, ServerName: cfg.Host,
} }
if err = client.StartTLS(tlsConfig); err != nil { if err = client.StartTLS(tlsConfig); err != nil {
+2 -1
View File
@@ -6,6 +6,7 @@ package mail
import ( import (
"bufio" "bufio"
"context"
"net" "net"
"net/textproto" "net/textproto"
"testing" "testing"
@@ -84,7 +85,7 @@ func TestSendMailMock(t *testing.T) {
Password: "password", Password: "password",
} }
err = SendMail(cfg, "recipient@example.com", "Test Subject", "<h1>Test Body</h1>") err = SendMail(context.Background(), cfg, "recipient@example.com", "Test Subject", "<h1>Test Body</h1>")
if err != nil { if err != nil {
t.Errorf("failed to send mail: %v", err) t.Errorf("failed to send mail: %v", err)
} }