Files
Calcium-Ion 6461f084ff feat(notify): turn each kind of notification on or off (#7868)
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.
2026-10-17 22:17:59 +08:00

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
}