mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-10-28 08:35:33 +08:00
SMTP settings now only decide whether mail can be sent. Verification codes, password reset, email binding and account security mails depend on SMTP alone, and no notification switch affects them. Admins get a site-wide low balance reminder switch, on by default, next to the SMTP settings. The default threshold is entered as an amount. Users choose which notifications they receive. Low balance reminders are on for everyone by default. Admins can also turn channel notifications and upstream model update notices on or off. The root user receives channel notifications until it turns them off. Other admins receive them after they turn them on. A low balance reminder is sent once, when a charge takes the balance below the threshold. A top-up above the threshold arms it again. Channel notifications reach every admin who has them on, in that admin's language. Email binding codes and account security mails follow the account's language. Saving notification settings no longer clears the language, the sidebar layout or the billing preference. Existing settings keep their behavior, and old clients that do not send the new fields keep the stored switches. Webhook, Bark and Gotify payloads are unchanged.
331 lines
12 KiB
Go
331 lines
12 KiB
Go
package controller
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
"github.com/QuantumNous/new-api/constant"
|
|
"github.com/QuantumNous/new-api/i18n"
|
|
"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 {
|
|
writeAccountStatusError(c, http.StatusServiceUnavailable, "Database connection failed")
|
|
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()
|
|
passkeySetting := system_setting.PasskeySettingsSnapshot()
|
|
common.OptionMapRWMutex.RLock()
|
|
defer common.OptionMapRWMutex.RUnlock()
|
|
|
|
legalSetting := system_setting.GetLegalSettings()
|
|
|
|
data := gin.H{
|
|
"version": common.Version,
|
|
"start_time": common.StartTime,
|
|
"email_verification": common.EmailVerificationEnabled,
|
|
"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_oauth_configured": oauth.TelegramConfigurationError() == nil,
|
|
"telegram_bot_name": common.TelegramBotName,
|
|
"theme": "default",
|
|
"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,
|
|
"docs_link": operation_setting.GetGeneralSetting().DocsLink,
|
|
"quota_per_unit": common.QuotaPerUnit,
|
|
// 兼容旧前端:保留 display_in_currency,同时提供新的 quota_display_type
|
|
"display_in_currency": operation_setting.IsCurrencyDisplay(),
|
|
"quota_display_type": operation_setting.GetQuotaDisplayType(),
|
|
"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,
|
|
"register_enabled": common.RegisterEnabled,
|
|
"password_login_enabled": common.PasswordLoginEnabled,
|
|
"password_register_enabled": common.PasswordRegisterEnabled,
|
|
"default_use_auto_group": setting.DefaultUseAutoGroup,
|
|
|
|
"password_login_encryption_enabled": common.PasswordLoginEncryptionEnabled,
|
|
|
|
"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"],
|
|
"SidebarModulesAdmin": common.OptionMap["SidebarModulesAdmin"],
|
|
|
|
"oidc_enabled": system_setting.GetOIDCSettings().Enabled,
|
|
"oidc_client_id": system_setting.GetOIDCSettings().ClientId,
|
|
"oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint,
|
|
"oidc_display_name": system_setting.GetOIDCSettings().GetEffectiveDisplayName(),
|
|
"passkey_login": passkeySetting.Enabled,
|
|
"passkey_display_name": passkeySetting.RPDisplayName,
|
|
"passkey_rp_id": passkeySetting.EffectiveRPID(),
|
|
"passkey_rp_ids": passkeySetting.RelyingPartyIDs(),
|
|
"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,
|
|
"log_export_enabled": operation_setting.GetLogSetting().UserExportEnabled,
|
|
"log_max_range_days": operation_setting.GetLogSetting().MaxRangeDays,
|
|
"quota_remind_enabled": common.QuotaRemindEnabled,
|
|
"quota_remind_threshold": common.QuotaRemindThreshold,
|
|
}
|
|
|
|
// 根据启用状态注入可选内容
|
|
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()
|
|
notice := common.OptionMap["Notice"]
|
|
common.OptionMapRWMutex.RUnlock()
|
|
serveRevalidatedJSON(c, notice)
|
|
}
|
|
|
|
func GetAbout(c *gin.Context) {
|
|
common.OptionMapRWMutex.RLock()
|
|
about := common.OptionMap["About"]
|
|
common.OptionMapRWMutex.RUnlock()
|
|
serveRevalidatedJSON(c, about)
|
|
}
|
|
|
|
func GetUserAgreement(c *gin.Context) {
|
|
serveRevalidatedJSON(c, system_setting.GetLegalSettings().UserAgreement)
|
|
}
|
|
|
|
func GetPrivacyPolicy(c *gin.Context) {
|
|
serveRevalidatedJSON(c, system_setting.GetLegalSettings().PrivacyPolicy)
|
|
}
|
|
|
|
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()
|
|
homePageContent := common.OptionMap["HomePageContent"]
|
|
common.OptionMapRWMutex.RUnlock()
|
|
serveRevalidatedJSON(c, homePageContent)
|
|
}
|
|
|
|
func SendEmailVerification(c *gin.Context) {
|
|
email, err := service.ValidateAccountEmail(c.Query("email"))
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
|
|
if model.IsEmailAlreadyTaken(email) {
|
|
common.ApiErrorT(c, "Email address is already in use")
|
|
return
|
|
}
|
|
code := common.GenerateVerificationCode(6)
|
|
common.RegisterVerificationCodeWithKey(email, code, common.EmailVerificationPurpose)
|
|
// The address may not belong to an account yet, so the mail follows the
|
|
// request language.
|
|
systemName := map[string]any{"SystemName": common.SystemName}
|
|
subject := i18n.T(c, i18n.MsgEmailVerificationSubject, systemName)
|
|
content := fmt.Sprintf("<p>%s</p>"+
|
|
"<p>%s</p>"+
|
|
"<p>%s</p>",
|
|
i18n.T(c, i18n.MsgEmailVerificationGreeting, systemName),
|
|
i18n.T(c, i18n.MsgEmailVerificationCode, map[string]any{"Code": code}),
|
|
i18n.T(c, i18n.MsgEmailVerificationValidity, map[string]any{"Minutes": 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 := model.NormalizeEmail(c.Query("email"))
|
|
if err := common.Validate.Var(email, "required,email"); err != nil {
|
|
common.ApiErrorT(c, "Invalid parameters")
|
|
return
|
|
}
|
|
if user, err := model.GetUniqueUserByEmail(email); err == nil {
|
|
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)
|
|
lang := user.GetSetting().Language
|
|
if lang == "" {
|
|
lang = i18n.StatedLang(c)
|
|
}
|
|
systemName := map[string]any{"SystemName": common.SystemName}
|
|
linkParams := map[string]any{"Link": link}
|
|
subject := i18n.Translate(lang, i18n.MsgEmailPasswordResetSubject, systemName)
|
|
content := fmt.Sprintf("<p>%s</p>"+
|
|
"<p>%s</p>"+
|
|
"<p>%s </p>"+
|
|
"<p>%s</p>",
|
|
i18n.Translate(lang, i18n.MsgEmailPasswordResetGreeting, systemName),
|
|
i18n.Translate(lang, i18n.MsgEmailPasswordResetLink, linkParams),
|
|
i18n.Translate(lang, i18n.MsgEmailPasswordResetLinkFallback, linkParams),
|
|
i18n.Translate(lang, i18n.MsgEmailPasswordResetValidity, map[string]any{"Minutes": common.VerificationValidMinutes}))
|
|
err := common.SendEmail(subject, email, content)
|
|
if err != nil {
|
|
logger.LogError(c.Request.Context(), common.LogText("failed to send password reset email to %s: %s", email, err.Error()))
|
|
}
|
|
} else if err != nil && !errors.Is(err, model.ErrEmailNotFound) {
|
|
logger.LogWarn(c.Request.Context(), common.LogText("skip password reset email for %s: %s", email, err.Error()))
|
|
}
|
|
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 err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
req.Email = model.NormalizeEmail(req.Email)
|
|
if req.Email == "" || req.Token == "" {
|
|
common.ApiErrorT(c, "Invalid parameters")
|
|
return
|
|
}
|
|
if !common.VerifyCodeWithKey(req.Email, req.Token, common.PasswordResetPurpose) {
|
|
common.ApiErrorT(c, "Password reset link is invalid or has expired")
|
|
return
|
|
}
|
|
password := common.GenerateVerificationCode(12)
|
|
err = model.ResetUserPasswordByEmail(req.Email, password)
|
|
if err != nil {
|
|
if errors.Is(err, model.ErrEmailNotFound) || errors.Is(err, model.ErrEmailAmbiguous) {
|
|
common.ApiErrorT(c, "Password reset link is invalid or has expired")
|
|
return
|
|
}
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
common.DeleteKey(req.Email, common.PasswordResetPurpose)
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"message": "",
|
|
"data": password,
|
|
})
|
|
return
|
|
}
|