Skip to content
18 changes: 18 additions & 0 deletions common/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,24 @@ var SMTPAccount = ""
var SMTPFrom = ""
var SMTPToken = ""

// SMS 短信通知系统级配置
var SMSProvider = "" // 短信服务商: aliyun, sendcloud, tencent, custom
var SMSAliyunAccessKeyId = "" // 阿里云 AccessKeyId
var SMSAliyunAccessKeySecret = "" // 阿里云 AccessKeySecret
var SMSAliyunSignName = "" // 阿里云短信签名
var SMSAliyunTemplateCode = "" // 阿里云短信模板Code
var SMSSendCloudSmsUser = "" // SendCloud smsUser
var SMSSendCloudSmsKey = "" // SendCloud smsKey
var SMSSendCloudTemplateId = "" // SendCloud 短信模板ID
var SMSTencentSecretId = "" // 腾讯云 SecretId
var SMSTencentSecretKey = "" // 腾讯云 SecretKey
var SMSTencentSmsSdkAppId = "" // 腾讯云 SmsSdkAppId
var SMSTencentSignName = "" // 腾讯云短信签名
var SMSTencentTemplateId = "" // 腾讯云短信模板ID
var SMSCustomUrl = "" // 通用HTTP接口地址
var SMSCustomMethod = "POST" // 通用HTTP请求方法
var SMSCustomTemplate = "" // 通用HTTP请求模板

var GitHubClientId = ""
var GitHubClientSecret = ""
var LinuxDOClientId = ""
Expand Down
7 changes: 6 additions & 1 deletion controller/option.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,12 @@ func GetOptions(c *gin.Context) {
strings.HasSuffix(k, "Secret") ||
strings.HasSuffix(k, "Key") ||
strings.HasSuffix(k, "secret") ||
strings.HasSuffix(k, "api_key")
strings.HasSuffix(k, "api_key") ||
// SMS 敏感凭证字段(ID类也隐藏,防止凭证泄露)
k == "SMSAliyunAccessKeyId" ||
k == "SMSSendCloudSmsUser" ||
k == "SMSTencentSecretId" ||
k == "SMSTencentSmsSdkAppId"
if isSensitiveKey && !isVisiblePublicKeyOption(k) {
continue
}
Expand Down
27 changes: 26 additions & 1 deletion controller/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"sync"
Expand Down Expand Up @@ -1120,6 +1121,7 @@ type UpdateUserSettingRequest struct {
GotifyUrl string `json:"gotify_url,omitempty"`
GotifyToken string `json:"gotify_token,omitempty"`
GotifyPriority int `json:"gotify_priority,omitempty"`
SmsPhoneNumber string `json:"sms_phone_number,omitempty"`
UpstreamModelUpdateNotifyEnabled *bool `json:"upstream_model_update_notify_enabled,omitempty"`
AcceptUnsetModelRatioModel bool `json:"accept_unset_model_ratio_model"`
RecordIpLog bool `json:"record_ip_log"`
Expand All @@ -1133,7 +1135,7 @@ func UpdateUserSetting(c *gin.Context) {
}

// 验证预警类型
if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook && req.QuotaWarningType != dto.NotifyTypeBark && req.QuotaWarningType != dto.NotifyTypeGotify {
if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook && req.QuotaWarningType != dto.NotifyTypeBark && req.QuotaWarningType != dto.NotifyTypeGotify && req.QuotaWarningType != dto.NotifyTypeSms {
common.ApiErrorI18n(c, i18n.MsgSettingInvalidType)
return
}
Expand Down Expand Up @@ -1206,6 +1208,24 @@ func UpdateUserSetting(c *gin.Context) {
}
}

// 如果是SMS类型,验证手机号和系统SMS配置
if req.QuotaWarningType == dto.NotifyTypeSms {
if req.SmsPhoneNumber == "" {
common.ApiErrorI18n(c, i18n.MsgSettingSmsPhoneEmpty)
return
}
// 验证手机号格式:允许国际号码格式,7-15位数字,可选+前缀
phoneRegex := regexp.MustCompile(`^\+?[0-9]{7,15}$`)
if !phoneRegex.MatchString(req.SmsPhoneNumber) {
common.ApiErrorI18n(c, i18n.MsgSettingSmsPhoneInvalid)
return
}
if common.SMSProvider == "" {
common.ApiErrorI18n(c, i18n.MsgSettingSmsNotConfigured)
return
}
}

userId := c.GetInt("id")
user, err := model.GetUserById(userId, true)
if err != nil {
Expand Down Expand Up @@ -1257,6 +1277,11 @@ func UpdateUserSetting(c *gin.Context) {
}
}

// 如果是SMS类型,添加手机号到设置中
if req.QuotaWarningType == dto.NotifyTypeSms {
settings.SmsPhoneNumber = req.SmsPhoneNumber
}

// 更新用户设置
user.SetSetting(settings)
if err := user.Update(false); err != nil {
Expand Down
2 changes: 2 additions & 0 deletions dto/user_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ type UserSetting struct {
SidebarModules string `json:"sidebar_modules,omitempty"` // SidebarModules 左侧边栏模块配置
BillingPreference string `json:"billing_preference,omitempty"` // BillingPreference 扣费策略(订阅/钱包)
Language string `json:"language,omitempty"` // Language 用户语言偏好 (zh, en)
SmsPhoneNumber string `json:"sms_phone_number,omitempty"` // SmsPhoneNumber 接收短信的手机号码
}

var (
NotifyTypeEmail = "email" // Email 邮件
NotifyTypeWebhook = "webhook" // Webhook
NotifyTypeBark = "bark" // Bark 推送
NotifyTypeGotify = "gotify" // Gotify 推送
NotifyTypeSms = "sms" // SMS 短信通知
)
3 changes: 3 additions & 0 deletions i18n/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,9 @@ const (
MsgSettingGotifyTokenEmpty = "setting.gotify_token_empty"
MsgSettingGotifyUrlInvalid = "setting.gotify_url_invalid"
MsgSettingUrlMustHttp = "setting.url_must_http"
MsgSettingSmsPhoneEmpty = "setting.sms_phone_empty"
MsgSettingSmsPhoneInvalid = "setting.sms_phone_invalid"
MsgSettingSmsNotConfigured = "setting.sms_not_configured"
MsgSettingSaved = "setting.saved"
)

Expand Down
3 changes: 3 additions & 0 deletions i18n/locales/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,9 @@ setting.gotify_url_empty: "Gotify server URL cannot be empty"
setting.gotify_token_empty: "Gotify token cannot be empty"
setting.gotify_url_invalid: "Invalid Gotify server URL"
setting.url_must_http: "URL must start with http:// or https://"
setting.sms_phone_empty: "Phone number cannot be empty"
setting.sms_phone_invalid: "Invalid phone number format"
setting.sms_not_configured: "SMS service is not configured, please contact the administrator"
setting.saved: "Settings updated"

# Deployment messages (io.net)
Expand Down
3 changes: 3 additions & 0 deletions i18n/locales/zh-CN.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,9 @@ setting.gotify_url_empty: "Gotify服务器地址不能为空"
setting.gotify_token_empty: "Gotify令牌不能为空"
setting.gotify_url_invalid: "无效的Gotify服务器地址"
setting.url_must_http: "URL必须以http://或https://开头"
setting.sms_phone_empty: "手机号码不能为空"
setting.sms_phone_invalid: "手机号码格式不正确"
setting.sms_not_configured: "系统未配置短信服务,请联系管理员"
setting.saved: "设置已更新"

# Deployment messages (io.net)
Expand Down
3 changes: 3 additions & 0 deletions i18n/locales/zh-TW.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,9 @@ setting.gotify_url_empty: "Gotify伺服器位址不能為空"
setting.gotify_token_empty: "Gotify令牌不能為空"
setting.gotify_url_invalid: "無效的Gotify伺服器位址"
setting.url_must_http: "URL必須以http://或https://開頭"
setting.sms_phone_empty: "手機號碼不能為空"
setting.sms_phone_invalid: "手機號碼格式不正確"
setting.sms_not_configured: "系統未配置簡訊服務,請聯繫管理員"
setting.saved: "設定已更新"

# Deployment messages (io.net)
Expand Down
56 changes: 56 additions & 0 deletions model/option.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,22 @@ func InitOptionMap() {
common.OptionMap["SMTPToken"] = ""
common.OptionMap["SMTPSSLEnabled"] = strconv.FormatBool(common.SMTPSSLEnabled)
common.OptionMap["SMTPForceAuthLogin"] = strconv.FormatBool(common.SMTPForceAuthLogin)
common.OptionMap["SMSProvider"] = ""
common.OptionMap["SMSAliyunAccessKeyId"] = ""
common.OptionMap["SMSAliyunAccessKeySecret"] = ""
common.OptionMap["SMSAliyunSignName"] = ""
common.OptionMap["SMSAliyunTemplateCode"] = ""
common.OptionMap["SMSSendCloudSmsUser"] = ""
common.OptionMap["SMSSendCloudSmsKey"] = ""
common.OptionMap["SMSSendCloudTemplateId"] = ""
common.OptionMap["SMSTencentSecretId"] = ""
common.OptionMap["SMSTencentSecretKey"] = ""
common.OptionMap["SMSTencentSmsSdkAppId"] = ""
common.OptionMap["SMSTencentSignName"] = ""
common.OptionMap["SMSTencentTemplateId"] = ""
common.OptionMap["SMSCustomUrl"] = ""
common.OptionMap["SMSCustomMethod"] = "POST"
common.OptionMap["SMSCustomTemplate"] = ""
common.OptionMap["Notice"] = ""
common.OptionMap["About"] = ""
common.OptionMap["HomePageContent"] = ""
Expand Down Expand Up @@ -345,6 +361,46 @@ func updateOptionMap(key string, value string) (err error) {
common.SMTPFrom = value
case "SMTPToken":
common.SMTPToken = value
case "SMSProvider":
common.SMSProvider = value
case "SMSAliyunAccessKeyId":
common.SMSAliyunAccessKeyId = value
case "SMSAliyunAccessKeySecret":
common.SMSAliyunAccessKeySecret = value
case "SMSAliyunSignName":
common.SMSAliyunSignName = value
case "SMSAliyunTemplateCode":
common.SMSAliyunTemplateCode = value
case "SMSSendCloudSmsUser":
common.SMSSendCloudSmsUser = value
case "SMSSendCloudSmsKey":
common.SMSSendCloudSmsKey = value
case "SMSSendCloudTemplateId":
common.SMSSendCloudTemplateId = value
case "SMSTencentSecretId":
common.SMSTencentSecretId = value
case "SMSTencentSecretKey":
common.SMSTencentSecretKey = value
case "SMSTencentSmsSdkAppId":
common.SMSTencentSmsSdkAppId = value
case "SMSTencentSignName":
common.SMSTencentSignName = value
case "SMSTencentTemplateId":
common.SMSTencentTemplateId = value
case "SMSCustomUrl":
common.SMSCustomUrl = value
case "SMSCustomMethod":
upperMethod := strings.ToUpper(value)
switch upperMethod {
case "GET", "POST", "PUT", "DELETE", "PATCH":
common.SMSCustomMethod = upperMethod
case "":
// 空值保留默认的 POST
default:
common.SMSCustomMethod = "POST"
}
Comment on lines +392 to +401

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Normalize SMSCustomMethod before storing it.

common.OptionMap[key] is written with the raw input before this branch runs, so a blank or unsupported method can still be exposed via /api/option/ even though common.SMSCustomMethod falls back to POST. This also accepts methods that service/sms_notify.go does not actually send, so the config can drift from runtime behavior.

🛠️ Suggested fix
 case "SMSCustomMethod":
     upperMethod := strings.ToUpper(value)
     switch upperMethod {
-    case "GET", "POST", "PUT", "DELETE", "PATCH":
+    case "GET", "POST":
         common.SMSCustomMethod = upperMethod
+        common.OptionMap[key] = upperMethod
     case "":
-        // 空值保留默认的 POST
+        common.SMSCustomMethod = "POST"
+        common.OptionMap[key] = "POST"
     default:
         common.SMSCustomMethod = "POST"
+        common.OptionMap[key] = "POST"
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@model/option.go` around lines 392 - 401, The OptionMap is being written with
the raw input before validation so unsupported or blank methods can be exposed;
update the "SMSCustomMethod" branch to validate and normalize the input first
(upper-case and check against the exact allowed set used by
service/sms_notify.go) and then assign that normalized value into both
common.SMSCustomMethod and common.OptionMap[key] (use "POST" as the fallback for
empty/unsupported values); ensure the allowed-method list you check here exactly
matches the sender implementation in service/sms_notify.go so config cannot
drift from runtime behavior.

case "SMSCustomTemplate":
common.SMSCustomTemplate = value
case "ServerAddress":
system_setting.ServerAddress = value
case "WorkerUrl":
Expand Down
11 changes: 11 additions & 0 deletions service/quota.go
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,12 @@ func checkAndSendQuotaNotify(relayInfo *relaycommon.RelayInfo, quota int, preCon
// Bark推送使用简短文本,不支持HTML
content = "{{value}},剩余额度:{{value}},请及时充值"
values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)}
} else if notifyType == dto.NotifyTypeSms {
// SMS短信使用模板变量:values[0]=当前余额, values[1]=告警阈值
// content 保留可读文本供自定义HTTP接口的 {{content}} 占位符使用
remainQuota := relayInfo.UserQuota - consumeQuota
content = fmt.Sprintf("当前剩余额度 %s,告警阈值 %s,请及时充值", logger.FormatQuota(remainQuota), logger.FormatQuota(threshold))
values = []interface{}{logger.FormatQuota(remainQuota), logger.FormatQuota(threshold)}
} else if notifyType == dto.NotifyTypeGotify {
content = "{{value}},当前剩余额度为 {{value}},请及时充值。"
values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)}
Expand Down Expand Up @@ -529,6 +535,11 @@ func checkAndSendSubscriptionQuotaNotify(relayInfo *relaycommon.RelayInfo) {
if notifyType == dto.NotifyTypeBark {
content = "{{value}},剩余额度:{{value}},请及时充值"
values = []interface{}{prompt, logger.FormatQuota(int(remaining))}
} else if notifyType == dto.NotifyTypeSms {
// SMS短信使用模板变量:values[0]=当前余额, values[1]=告警阈值
// content 保留可读文本供自定义HTTP接口的 {{content}} 占位符使用
content = fmt.Sprintf("当前剩余额度 %s,告警阈值 %s,请及时充值", logger.FormatQuota(int(remaining)), logger.FormatQuota(threshold))
values = []interface{}{logger.FormatQuota(int(remaining)), logger.FormatQuota(threshold)}
} else if notifyType == dto.NotifyTypeGotify {
content = "{{value}},当前剩余额度为 {{value}},请及时充值。"
values = []interface{}{prompt, logger.FormatQuota(int(remaining))}
Expand Down
Loading