tokenFactory/controller/misc.go

637 lines
21 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package controller
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/console_setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-gonic/gin"
)
func TestStatus(c *gin.Context) {
err := model.PingDB()
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"success": false,
"message": "数据库连接失败",
})
return
}
// 获取HTTP统计信息
httpStats := middleware.GetStats()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Server is running",
"http_stats": httpStats,
})
return
}
func GetStatus(c *gin.Context) {
cs := console_setting.GetConsoleSetting()
common.OptionMapRWMutex.RLock()
defer common.OptionMapRWMutex.RUnlock()
passkeySetting := system_setting.GetPasskeySettings()
legalSetting := system_setting.GetLegalSettings()
distributorMinWithdrawQuota := int(common.QuotaPerUnit)
if raw := strings.TrimSpace(common.Interface2String(common.OptionMap["DistributorMinWithdrawQuota"])); raw != "" {
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
distributorMinWithdrawQuota = n
}
}
data := gin.H{
"version": common.Version,
"start_time": common.StartTime,
"email_verification": common.EmailVerificationEnabled,
"sms_verification_enabled": common.SMSVerificationEnabled,
"github_oauth": common.GitHubOAuthEnabled,
"github_client_id": common.GitHubClientId,
"discord_oauth": system_setting.GetDiscordSettings().Enabled,
"discord_client_id": system_setting.GetDiscordSettings().ClientId,
"linuxdo_oauth": common.LinuxDOOAuthEnabled,
"linuxdo_client_id": common.LinuxDOClientId,
"linuxdo_minimum_trust_level": common.LinuxDOMinimumTrustLevel,
"telegram_oauth": common.TelegramOAuthEnabled,
"telegram_bot_name": common.TelegramBotName,
"system_name": common.SystemName,
"logo": common.Logo,
"footer_html": common.Footer,
"wechat_qrcode": common.WeChatAccountQRCodeImageURL,
"wechat_login": common.WeChatAuthEnabled,
"server_address": system_setting.ServerAddress,
"turnstile_check": common.TurnstileCheckEnabled,
"turnstile_site_key": common.TurnstileSiteKey,
"captcha_enabled": common.CaptchaEnabled,
"top_up_link": common.TopUpLink,
"docs_link": operation_setting.GetGeneralSetting().DocsLink,
"default_site_language": operation_setting.GetDefaultSiteLanguage(),
"quota_per_unit": common.QuotaPerUnit,
// 兼容旧前端:保留 display_in_currency同时提供新的 quota_display_type
"display_in_currency": operation_setting.IsCurrencyDisplay(),
"quota_display_type": operation_setting.GetQuotaDisplayType(),
"recharge_display_currency": operation_setting.GetGeneralSetting().RechargeDisplayCurrency,
"custom_currency_symbol": operation_setting.GetGeneralSetting().CustomCurrencySymbol,
"custom_currency_exchange_rate": operation_setting.GetGeneralSetting().CustomCurrencyExchangeRate,
"enable_batch_update": common.BatchUpdateEnabled,
"enable_drawing": common.DrawingEnabled,
"enable_task": common.TaskEnabled,
"enable_data_export": common.DataExportEnabled,
"data_export_default_time": common.DataExportDefaultTime,
"default_collapse_sidebar": common.DefaultCollapseSidebar,
"mj_notify_enabled": setting.MjNotifyEnabled,
"chats": setting.Chats,
"demo_site_enabled": operation_setting.DemoSiteEnabled,
"self_use_mode_enabled": operation_setting.SelfUseModeEnabled,
"model_default_docs_enabled": common.OptionMap["ModelDefaultDocsEnabled"] != "false",
"default_use_auto_group": setting.DefaultUseAutoGroup,
"usd_exchange_rate": operation_setting.USDExchangeRate,
"price": operation_setting.Price,
"stripe_unit_price": setting.StripeUnitPrice,
// 面板启用开关
"api_info_enabled": cs.ApiInfoEnabled,
"uptime_kuma_enabled": cs.UptimeKumaEnabled,
"announcements_enabled": cs.AnnouncementsEnabled,
"faq_enabled": cs.FAQEnabled,
// 模块管理配置
"HeaderNavModules": common.OptionMap["HeaderNavModules"],
"SidebarModulesByRole": common.OptionMap["SidebarModulesByRole"],
"oidc_enabled": system_setting.GetOIDCSettings().Enabled,
"oidc_client_id": system_setting.GetOIDCSettings().ClientId,
"oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint,
"passkey_login": passkeySetting.Enabled,
"passkey_display_name": passkeySetting.RPDisplayName,
"passkey_rp_id": passkeySetting.RPID,
"passkey_origins": passkeySetting.Origins,
"passkey_allow_insecure": passkeySetting.AllowInsecureOrigin,
"passkey_user_verification": passkeySetting.UserVerification,
"passkey_attachment": passkeySetting.AttachmentPreference,
"setup": constant.Setup,
"user_agreement_enabled": legalSetting.UserAgreement != "",
"privacy_policy_enabled": legalSetting.PrivacyPolicy != "",
"checkin_enabled": operation_setting.GetCheckinSetting().Enabled,
"distributor_apply_cs_image_url": common.OptionMap["DistributorApplyCsImageUrl"],
"distributor_withdraw_cs_image_url": common.OptionMap["DistributorWithdrawCsImageUrl"],
"distributor_withdraw_notice": common.OptionMap["DistributorWithdrawNotice"],
"distributor_apply_intro_html": common.OptionMap["DistributorApplyIntroHtml"],
"distributor_min_withdraw_quota": distributorMinWithdrawQuota,
"affiliate_default_commission_bps": common.AffiliateDefaultCommissionBps,
"distributor_commission_mode": common.DistributorCommissionMode,
"home_banner_slides": strings.TrimSpace(common.Interface2String(common.OptionMap["HomeBannerSlides"])),
}
// 根据启用状态注入可选内容
if cs.ApiInfoEnabled {
data["api_info"] = console_setting.GetApiInfo()
}
if cs.AnnouncementsEnabled {
data["announcements"] = console_setting.GetAnnouncements()
}
if cs.FAQEnabled {
data["faq"] = console_setting.GetFAQ()
}
// Add enabled custom OAuth providers
customProviders := oauth.GetEnabledCustomProviders()
if len(customProviders) > 0 {
type CustomOAuthInfo struct {
Id int `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Icon string `json:"icon"`
ClientId string `json:"client_id"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
Scopes string `json:"scopes"`
}
providersInfo := make([]CustomOAuthInfo, 0, len(customProviders))
for _, p := range customProviders {
config := p.GetConfig()
providersInfo = append(providersInfo, CustomOAuthInfo{
Id: config.Id,
Name: config.Name,
Slug: config.Slug,
Icon: config.Icon,
ClientId: config.ClientId,
AuthorizationEndpoint: config.AuthorizationEndpoint,
Scopes: config.Scopes,
})
}
data["custom_oauth_providers"] = providersInfo
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": data,
})
return
}
func GetNotice(c *gin.Context) {
common.OptionMapRWMutex.RLock()
defer common.OptionMapRWMutex.RUnlock()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": common.OptionMap["Notice"],
})
return
}
func GetAbout(c *gin.Context) {
common.OptionMapRWMutex.RLock()
defer common.OptionMapRWMutex.RUnlock()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": common.OptionMap["About"],
})
return
}
func GetUserAgreement(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": system_setting.GetLegalSettings().UserAgreement,
})
return
}
func GetPrivacyPolicy(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": system_setting.GetLegalSettings().PrivacyPolicy,
})
return
}
func GetMidjourney(c *gin.Context) {
common.OptionMapRWMutex.RLock()
defer common.OptionMapRWMutex.RUnlock()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": common.OptionMap["Midjourney"],
})
return
}
func GetHomePageContent(c *gin.Context) {
common.OptionMapRWMutex.RLock()
defer common.OptionMapRWMutex.RUnlock()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": common.OptionMap["HomePageContent"],
})
return
}
func SendEmailVerification(c *gin.Context) {
email := c.Query("email")
if err := common.Validate.Var(email, "required,email"); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
parts := strings.Split(email, "@")
if len(parts) != 2 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的邮箱地址",
})
return
}
localPart := parts[0]
domainPart := parts[1]
if common.EmailDomainRestrictionEnabled {
allowed := false
for _, domain := range common.EmailDomainWhitelist {
if domainPart == domain {
allowed = true
break
}
}
if !allowed {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "The administrator has enabled the email domain name whitelist, and your email address is not allowed due to special symbols or it's not in the whitelist.",
})
return
}
}
if common.EmailAliasRestrictionEnabled {
containsSpecialSymbols := strings.Contains(localPart, "+") || strings.Contains(localPart, ".")
if containsSpecialSymbols {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员已启用邮箱地址别名限制,您的邮箱地址由于包含特殊符号而被拒绝。",
})
return
}
}
if model.IsEmailAlreadyTaken(email) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "邮箱地址已被占用",
})
return
}
code := common.GenerateVerificationCode(6)
common.RegisterVerificationCodeWithKey(email, code, common.EmailVerificationPurpose)
subject := fmt.Sprintf("%s邮箱验证邮件", common.SystemName)
content := fmt.Sprintf("<p>您好,你正在进行%s邮箱验证。</p>"+
"<p>您的验证码为: <strong>%s</strong></p>"+
"<p>验证码 %d 分钟内有效,如果不是本人操作,请忽略。</p>", common.SystemName, code, common.VerificationValidMinutes)
err := common.SendEmail(subject, email, content)
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
}
func SendPasswordResetEmail(c *gin.Context) {
email := c.Query("email")
if err := common.Validate.Var(email, "required,email"); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
if model.IsEmailAlreadyTaken(email) {
code := common.GenerateVerificationCode(0)
common.RegisterVerificationCodeWithKey(email, code, common.PasswordResetPurpose)
link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", system_setting.ServerAddress, email, code)
subject := fmt.Sprintf("%s密码重置", common.SystemName)
content := fmt.Sprintf("<p>您好,你正在进行%s密码重置。</p>"+
"<p>点击 <a href='%s'>此处</a> 进行密码重置。</p>"+
"<p>如果链接无法点击,请尝试点击下面的链接或将其复制到浏览器中打开:<br> %s </p>"+
"<p>重置链接 %d 分钟内有效,如果不是本人操作,请忽略。</p>", common.SystemName, link, link, common.VerificationValidMinutes)
err := common.SendEmail(subject, email, content)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("failed to send password reset email to %s: %s", email, err.Error()))
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
}
// validatePublicEmailConstraints 校验公开流程中的邮箱格式、域名白名单与别名限制(与注册/验证邮件一致)。
func validatePublicEmailConstraints(email string) *gin.H {
if err := common.Validate.Var(email, "required,email"); err != nil {
return &gin.H{"success": false, "message": "无效的参数"}
}
parts := strings.Split(email, "@")
if len(parts) != 2 {
return &gin.H{"success": false, "message": "无效的邮箱地址"}
}
localPart := parts[0]
domainPart := parts[1]
if common.EmailDomainRestrictionEnabled {
allowed := false
for _, domain := range common.EmailDomainWhitelist {
if domainPart == domain {
allowed = true
break
}
}
if !allowed {
return &gin.H{
"success": false,
"message": "The administrator has enabled the email domain name whitelist, and your email address is not allowed due to special symbols or it's not in the whitelist.",
}
}
}
if common.EmailAliasRestrictionEnabled {
if strings.Contains(localPart, "+") || strings.Contains(localPart, ".") {
return &gin.H{"success": false, "message": "管理员已启用邮箱地址别名限制,您的邮箱地址由于包含特殊符号而被拒绝。"}
}
}
return nil
}
// SendPasswordResetEmailCode 发送忘记密码用的邮箱数字验证码(仅已绑定该邮箱的用户实际收到邮件;未注册亦返回成功以保护隐私)。
func SendPasswordResetEmailCode(c *gin.Context) {
email := strings.TrimSpace(c.Query("email"))
if bad := validatePublicEmailConstraints(email); bad != nil {
c.JSON(http.StatusOK, *bad)
return
}
if model.IsEmailAlreadyTaken(email) {
code := common.GenerateNumericVerificationCode(6)
common.RegisterVerificationCodeWithKey(email, code, common.PasswordResetEmailCodePurpose)
subject := fmt.Sprintf("%s密码重置验证码", common.SystemName)
content := fmt.Sprintf("<p>您好,您正在进行%s密码重置。</p>"+
"<p>您的验证码为: <strong>%s</strong></p>"+
"<p>验证码 %d 分钟内有效,如非本人操作请忽略。</p>", common.SystemName, code, common.VerificationValidMinutes)
if err := common.SendEmail(subject, email, content); err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("failed to send password reset email code to %s: %s", email, err.Error()))
}
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": ""})
}
// PasswordResetByEmailCodeRequest 通过邮箱验证码重置密码的请求体。
type PasswordResetByEmailCodeRequest struct {
Email string `json:"email"`
VerificationCode string `json:"verification_code"`
NewPassword string `json:"new_password"`
ConfirmPassword string `json:"confirm_password"`
}
// ResetPasswordByEmailCode 校验邮箱验证码后将密码更新为用户指定的新密码。
func ResetPasswordByEmailCode(c *gin.Context) {
var req PasswordResetByEmailCodeRequest
if err := json.NewDecoder(c.Request.Body).Decode(&req); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "无效的参数"})
return
}
req.Email = strings.TrimSpace(req.Email)
req.VerificationCode = strings.TrimSpace(req.VerificationCode)
req.NewPassword = strings.TrimSpace(req.NewPassword)
req.ConfirmPassword = strings.TrimSpace(req.ConfirmPassword)
if bad := validatePublicEmailConstraints(req.Email); bad != nil {
c.JSON(http.StatusOK, *bad)
return
}
if len(req.VerificationCode) != 6 {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "请输入 6 位邮箱验证码"})
return
}
if !common.VerifyCodeWithKey(req.Email, req.VerificationCode, common.PasswordResetEmailCodePurpose) {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "验证码错误或已过期"})
return
}
if len(req.NewPassword) < 8 || len(req.NewPassword) > 20 {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "密码长度需为 8-20 位"})
return
}
if req.NewPassword != req.ConfirmPassword {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "两次输入的密码不一致"})
return
}
if !model.IsEmailAlreadyTaken(req.Email) {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "该邮箱未注册"})
return
}
if err := model.ResetUserPasswordByEmail(req.Email, req.NewPassword); err != nil {
common.ApiError(c, err)
return
}
common.DeleteKey(req.Email, common.PasswordResetEmailCodePurpose)
c.JSON(http.StatusOK, gin.H{"success": true, "message": ""})
}
type PasswordResetRequest struct {
Email string `json:"email"`
Token string `json:"token"`
}
func ResetPassword(c *gin.Context) {
var req PasswordResetRequest
err := json.NewDecoder(c.Request.Body).Decode(&req)
if req.Email == "" || req.Token == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
if !common.VerifyCodeWithKey(req.Email, req.Token, common.PasswordResetPurpose) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "重置链接非法或已过期",
})
return
}
password := common.GenerateVerificationCode(12)
err = model.ResetUserPasswordByEmail(req.Email, password)
if err != nil {
common.ApiError(c, err)
return
}
common.DeleteKey(req.Email, common.PasswordResetPurpose)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": password,
})
return
}
// SendPasswordResetSMS 发送“忘记密码”短信验证码(仅已注册手机号可发送)。
func SendPasswordResetSMS(c *gin.Context) {
if !common.SMSVerificationEnabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "短信验证码功能未启用",
})
return
}
phone := common.NormalizePhone(c.Query("phone"))
if !common.ValidateMainlandChinaPhone(phone) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "手机号格式无效,请输入 11 位中国大陆手机号",
})
return
}
if !model.IsPhoneAlreadyTaken(phone) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该手机号未注册",
})
return
}
if common.IsSMSPhoneBlacklisted(phone) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该手机号已被加入短信黑名单",
})
return
}
if err := common.CheckSMSCanSend(phone); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
code := common.GenerateNumericVerificationCode(6)
if err := service.SendAliyunSMSCode(phone, code); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
if err := common.RecordSMSSend(phone); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
if err := common.StoreSMSVerificationCode(phone, code); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "短信验证码存储失败,请稍后重试",
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
}
// PasswordResetByPhoneRequest 手机号找回密码请求体。
type PasswordResetByPhoneRequest struct {
Phone string `json:"phone"`
SMSCode string `json:"sms_verification_code"`
NewPassword string `json:"new_password"`
ConfirmPassword string `json:"confirm_password"`
}
// ResetPasswordByPhone 通过手机号+短信验证码重置密码。
func ResetPasswordByPhone(c *gin.Context) {
var req PasswordResetByPhoneRequest
if err := json.NewDecoder(c.Request.Body).Decode(&req); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
req.Phone = common.NormalizePhone(req.Phone)
req.SMSCode = strings.TrimSpace(req.SMSCode)
req.NewPassword = strings.TrimSpace(req.NewPassword)
req.ConfirmPassword = strings.TrimSpace(req.ConfirmPassword)
if !common.ValidateMainlandChinaPhone(req.Phone) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "手机号格式无效,请输入 11 位中国大陆手机号",
})
return
}
if !model.IsPhoneAlreadyTaken(req.Phone) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该手机号未注册",
})
return
}
if len(req.SMSCode) != 6 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "请输入 6 位短信验证码",
})
return
}
if !common.VerifyAndConsumeSMSCode(req.Phone, req.SMSCode) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "短信验证码错误或已过期",
})
return
}
if len(req.NewPassword) < 8 || len(req.NewPassword) > 20 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "密码长度需为 8-20 位",
})
return
}
if req.NewPassword != req.ConfirmPassword {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "两次输入的密码不一致",
})
return
}
if err := model.ResetUserPasswordByPhone(req.Phone, req.NewPassword); err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
}