mirror of
https://gh-proxy.org/https://github.com/klinsmaya/new-api.git
synced 2026-11-08 01:17:09 +08:00
Preserve legacy credentials across primary RP ID changes and bind successful assertions to their verified RP ID. Share domain selection across sign-in, login verification and sensitive operations without replaying ceremonies. Save domain settings atomically, preview known and unknown credential impact, and require fresh confirmation before removing domains still in use. Recheck trusted RP IDs when persisting credentials and record safe operation audits. Use read-only configuration snapshots, accept trusted HTTPS intranet hosts, check registration origins early, and preserve historical capitalization. Update settings guidance and all supported translations. Validation: SQLite 3.50.4, MySQL 8.0.46 and PostgreSQL 16.15, including fresh and released-schema upgrade checks; Go regression and race checks; 79 frontend tests; TypeScript, lint, frontend build and root Go build. Security references: OWASP ASVS 5.0.0 V6.3.4 and V6.3.8, Authentication and Session Management Cheat Sheets, and the WebAuthn RP ID/origin rules.
508 lines
15 KiB
Go
508 lines
15 KiB
Go
package controller
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"slices"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/QuantumNous/new-api/common"
|
||
"github.com/QuantumNous/new-api/i18n"
|
||
"github.com/QuantumNous/new-api/model"
|
||
"github.com/QuantumNous/new-api/service"
|
||
"github.com/QuantumNous/new-api/setting"
|
||
"github.com/QuantumNous/new-api/setting/billing_setting"
|
||
"github.com/QuantumNous/new-api/setting/console_setting"
|
||
"github.com/QuantumNous/new-api/setting/model_setting"
|
||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
var completionRatioMetaOptionKeys = []string{
|
||
"ModelPrice",
|
||
"ModelRatio",
|
||
"CompletionRatio",
|
||
"CacheRatio",
|
||
"CreateCacheRatio",
|
||
"ImageRatio",
|
||
"AudioRatio",
|
||
"AudioCompletionRatio",
|
||
}
|
||
|
||
func isPaymentComplianceOptionKey(key string) bool {
|
||
return strings.HasPrefix(key, "payment_setting.compliance_")
|
||
}
|
||
|
||
func isPositiveOptionValue(value string) bool {
|
||
intValue, err := strconv.Atoi(strings.TrimSpace(value))
|
||
if err == nil {
|
||
return intValue > 0
|
||
}
|
||
floatValue, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
|
||
return err == nil && floatValue > 0
|
||
}
|
||
|
||
func collectModelNamesFromOptionValue(raw string, modelNames map[string]struct{}) {
|
||
if strings.TrimSpace(raw) == "" {
|
||
return
|
||
}
|
||
|
||
var parsed map[string]any
|
||
if err := common.UnmarshalJsonStr(raw, &parsed); err != nil {
|
||
return
|
||
}
|
||
|
||
for modelName := range parsed {
|
||
modelNames[modelName] = struct{}{}
|
||
}
|
||
}
|
||
|
||
func buildCompletionRatioMetaValue(optionValues map[string]string) string {
|
||
modelNames := make(map[string]struct{})
|
||
for _, key := range completionRatioMetaOptionKeys {
|
||
collectModelNamesFromOptionValue(optionValues[key], modelNames)
|
||
}
|
||
|
||
meta := make(map[string]ratio_setting.CompletionRatioInfo, len(modelNames))
|
||
for modelName := range modelNames {
|
||
meta[modelName] = ratio_setting.GetCompletionRatioInfo(modelName)
|
||
}
|
||
|
||
jsonBytes, err := common.Marshal(meta)
|
||
if err != nil {
|
||
return "{}"
|
||
}
|
||
return string(jsonBytes)
|
||
}
|
||
|
||
func GetOptions(c *gin.Context) {
|
||
var options []*model.Option
|
||
optionValues := make(map[string]string)
|
||
common.OptionMapRWMutex.Lock()
|
||
for k, v := range common.OptionMap {
|
||
if k == "theme.frontend" || k == "billing_setting.billing_mode" || k == "billing_setting.billing_expr" {
|
||
continue
|
||
}
|
||
value := common.Interface2String(v)
|
||
isSensitiveKey := strings.HasSuffix(k, "Token") ||
|
||
strings.HasSuffix(k, "Secret") ||
|
||
strings.HasSuffix(k, "Key") ||
|
||
strings.HasSuffix(k, "secret") ||
|
||
strings.HasSuffix(k, "api_key")
|
||
if isSensitiveKey {
|
||
continue
|
||
}
|
||
options = append(options, &model.Option{
|
||
Key: k,
|
||
Value: value,
|
||
})
|
||
if slices.Contains(completionRatioMetaOptionKeys, k) {
|
||
optionValues[k] = value
|
||
}
|
||
}
|
||
common.OptionMapRWMutex.Unlock()
|
||
// Display the same effective expressions used by pricing and settlement,
|
||
// including built-in defaults absent from persisted administrator options.
|
||
for key, values := range map[string]map[string]string{
|
||
"billing_setting.billing_mode": billing_setting.GetBillingModeCopy(),
|
||
"billing_setting.billing_expr": billing_setting.GetBillingExprCopy(),
|
||
} {
|
||
encoded, err := common.Marshal(values)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
|
||
return
|
||
}
|
||
options = append(options, &model.Option{Key: key, Value: string(encoded)})
|
||
}
|
||
options = append(options, &model.Option{
|
||
Key: "CompletionRatioMeta",
|
||
Value: buildCompletionRatioMetaValue(optionValues),
|
||
})
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"message": "",
|
||
"data": options,
|
||
})
|
||
}
|
||
|
||
type OptionUpdateRequest struct {
|
||
Key string `json:"key"`
|
||
Value any `json:"value"`
|
||
}
|
||
|
||
func UpdatePasskeyDomains(c *gin.Context) {
|
||
var request struct {
|
||
RPID *string `json:"rp_id"`
|
||
LegacyRPIDs *string `json:"legacy_rp_ids"`
|
||
Origins *string `json:"origins"`
|
||
Preview bool `json:"preview"`
|
||
RemovalConfirmation string `json:"removal_confirmation"`
|
||
}
|
||
if err := common.DecodeJson(c.Request.Body, &request); err != nil || request.RPID == nil || request.LegacyRPIDs == nil || request.Origins == nil {
|
||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||
return
|
||
}
|
||
change, err := model.UpdatePasskeyDomainOptions(map[string]string{
|
||
"passkey.rp_id": *request.RPID, "passkey.legacy_rp_ids": *request.LegacyRPIDs, "passkey.origins": *request.Origins,
|
||
}, request.Preview, request.RemovalConfirmation)
|
||
if err != nil {
|
||
writePasskeyDomainSettingsError(c, err)
|
||
if !request.Preview {
|
||
recordPasskeyDomainAudit(c, change, request.RemovalConfirmation != "", err)
|
||
}
|
||
return
|
||
}
|
||
if !request.Preview {
|
||
recordPasskeyDomainAudit(c, change, request.RemovalConfirmation != "", nil)
|
||
}
|
||
common.ApiSuccess(c, change)
|
||
}
|
||
|
||
func writePasskeyDomainSettingsError(c *gin.Context, err error) {
|
||
var removal *model.PasskeyDomainRemovalError
|
||
if errors.As(err, &removal) {
|
||
c.JSON(http.StatusConflict, gin.H{
|
||
"success": false, "code": "PASSKEY_RP_ID_REMOVAL_CONFIRMATION_REQUIRED",
|
||
"message": i18n.T(c, i18n.MsgPasskeyRPIDRemovalConfirmation), "data": removal.Change,
|
||
})
|
||
return
|
||
}
|
||
if errors.Is(err, system_setting.ErrPasskeyRPIDInvalid) {
|
||
writeSecurityOperationError(c, err)
|
||
return
|
||
}
|
||
common.ApiError(c, err)
|
||
}
|
||
|
||
func UpdateOption(c *gin.Context) {
|
||
var option OptionUpdateRequest
|
||
err := common.DecodeJson(c.Request.Body, &option)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{
|
||
"success": false,
|
||
"message": "无效的参数",
|
||
})
|
||
return
|
||
}
|
||
switch option.Value.(type) {
|
||
case bool:
|
||
option.Value = common.Interface2String(option.Value.(bool))
|
||
case float64:
|
||
option.Value = common.Interface2String(option.Value.(float64))
|
||
case int:
|
||
option.Value = common.Interface2String(option.Value.(int))
|
||
default:
|
||
option.Value = fmt.Sprintf("%v", option.Value)
|
||
}
|
||
switch option.Key {
|
||
case "QuotaForInviter", "QuotaForInvitee":
|
||
if isPositiveOptionValue(option.Value.(string)) && !operation_setting.IsPaymentComplianceConfirmed() {
|
||
common.ApiErrorI18n(c, i18n.MsgPaymentComplianceRequired)
|
||
return
|
||
}
|
||
default:
|
||
if isPaymentComplianceOptionKey(option.Key) {
|
||
common.ApiErrorMsg(c, "合规确认字段不允许通过通用设置接口修改")
|
||
return
|
||
}
|
||
}
|
||
if option.Key == "TaskPublicAddress" && option.Value.(string) != "" {
|
||
if err := service.ValidateTaskArtifactBaseURL(option.Value.(string)); err != nil {
|
||
common.ApiErrorMsg(c, err.Error())
|
||
return
|
||
}
|
||
}
|
||
switch option.Key {
|
||
case "GitHubOAuthEnabled":
|
||
if option.Value == "true" && common.GitHubClientId == "" {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": "无法启用 GitHub OAuth,请先填入 GitHub Client Id 以及 GitHub Client Secret!",
|
||
})
|
||
return
|
||
}
|
||
case "discord.enabled":
|
||
if option.Value == "true" && system_setting.GetDiscordSettings().ClientId == "" {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": "无法启用 Discord OAuth,请先填入 Discord Client Id 以及 Discord Client Secret!",
|
||
})
|
||
return
|
||
}
|
||
case "oidc.enabled":
|
||
if option.Value == "true" && system_setting.GetOIDCSettings().ClientId == "" {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": "无法启用 OIDC 登录,请先填入 OIDC Client Id 以及 OIDC Client Secret!",
|
||
})
|
||
return
|
||
}
|
||
case "LinuxDOOAuthEnabled":
|
||
if option.Value == "true" && common.LinuxDOClientId == "" {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": "无法启用 LinuxDO OAuth,请先填入 LinuxDO Client Id 以及 LinuxDO Client Secret!",
|
||
})
|
||
return
|
||
}
|
||
case "EmailDomainRestrictionEnabled":
|
||
if option.Value == "true" && len(common.EmailDomainWhitelist) == 0 {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": "无法启用邮箱域名限制,请先填入限制的邮箱域名!",
|
||
})
|
||
return
|
||
}
|
||
case "WeChatAuthEnabled":
|
||
if option.Value == "true" && common.WeChatServerAddress == "" {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": "无法启用微信登录,请先填入微信登录相关配置信息!",
|
||
})
|
||
return
|
||
}
|
||
case "TurnstileCheckEnabled":
|
||
if option.Value == "true" && common.TurnstileSiteKey == "" {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": "无法启用 Turnstile 校验,请先填入 Turnstile 校验相关配置信息!",
|
||
})
|
||
|
||
return
|
||
}
|
||
case "TelegramOAuthEnabled":
|
||
if option.Value == "true" && !system_setting.GetTelegramSettings().IsConfigured() {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"code": "TELEGRAM_OAUTH_NOT_CONFIGURED",
|
||
"message": "Telegram OAuth is not configured or enabled. Please contact your administrator.",
|
||
})
|
||
return
|
||
}
|
||
case "theme.frontend":
|
||
if option.Value != "default" {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": "Classic 前端已移除,主题只能设置为 default",
|
||
})
|
||
return
|
||
}
|
||
case "GroupRatio":
|
||
err = ratio_setting.CheckGroupRatio(option.Value.(string))
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": err.Error(),
|
||
})
|
||
return
|
||
}
|
||
case "gemini.safety_settings":
|
||
err = model_setting.ValidateGeminiSafetySettings(option.Value.(string))
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": err.Error(),
|
||
})
|
||
return
|
||
}
|
||
case "claude.default_max_tokens":
|
||
err = model_setting.ValidateClaudeDefaultMaxTokens(option.Value.(string))
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": err.Error(),
|
||
})
|
||
return
|
||
}
|
||
case operation_setting.ToolPriceOptionKey:
|
||
err = operation_setting.ValidateToolPricesJSON(option.Value.(string))
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": err.Error(),
|
||
})
|
||
return
|
||
}
|
||
case "ImageRatio":
|
||
err = ratio_setting.UpdateImageRatioByJSONString(option.Value.(string))
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": "图片倍率设置失败: " + err.Error(),
|
||
})
|
||
return
|
||
}
|
||
case "AudioRatio":
|
||
err = ratio_setting.UpdateAudioRatioByJSONString(option.Value.(string))
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": "音频倍率设置失败: " + err.Error(),
|
||
})
|
||
return
|
||
}
|
||
case "AudioCompletionRatio":
|
||
err = ratio_setting.UpdateAudioCompletionRatioByJSONString(option.Value.(string))
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": "音频补全倍率设置失败: " + err.Error(),
|
||
})
|
||
return
|
||
}
|
||
case "CreateCacheRatio":
|
||
err = ratio_setting.UpdateCreateCacheRatioByJSONString(option.Value.(string))
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": "缓存创建倍率设置失败: " + err.Error(),
|
||
})
|
||
return
|
||
}
|
||
case "ModelRequestRateLimitGroup":
|
||
err = setting.CheckModelRequestRateLimitGroup(option.Value.(string))
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": err.Error(),
|
||
})
|
||
return
|
||
}
|
||
case "AutomaticDisableStatusCodes":
|
||
_, err = operation_setting.ParseHTTPStatusCodeRanges(option.Value.(string))
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": err.Error(),
|
||
})
|
||
return
|
||
}
|
||
case "AutomaticRetryStatusCodes":
|
||
_, err = operation_setting.ParseHTTPStatusCodeRanges(option.Value.(string))
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": err.Error(),
|
||
})
|
||
return
|
||
}
|
||
case "billing_setting.billing_expr":
|
||
expressions := make(map[string]string)
|
||
if err = common.UnmarshalJsonStr(option.Value.(string), &expressions); err != nil {
|
||
common.ApiErrorMsg(c, "计费表达式配置必须是模型到表达式的 JSON 对象: "+err.Error())
|
||
return
|
||
}
|
||
models := make([]string, 0, len(expressions))
|
||
for modelName := range expressions {
|
||
models = append(models, modelName)
|
||
}
|
||
sort.Strings(models)
|
||
storedVariants := billing_setting.GetPluginBillingExprCopy()
|
||
for _, modelName := range models {
|
||
variants := make(map[string]any)
|
||
for key, expression := range storedVariants {
|
||
if plugin, name, ok := billing_setting.SplitPluginBillingExprKey(key); ok && name == modelName {
|
||
variants[plugin] = expression
|
||
}
|
||
}
|
||
err = model.ValidateModelPricing(modelName, model.PricingValues{
|
||
"billing_setting.billing_expr": expressions[modelName],
|
||
billing_setting.PluginBillingExprOption: variants,
|
||
})
|
||
if err != nil {
|
||
common.ApiErrorMsg(c, fmt.Sprintf("模型 %s 的计费表达式无效: %v", modelName, err))
|
||
return
|
||
}
|
||
}
|
||
case billing_setting.PluginBillingExprOption:
|
||
var expressions map[string]string
|
||
if err = common.UnmarshalJsonStr(option.Value.(string), &expressions); err != nil || expressions == nil {
|
||
common.ApiErrorMsg(c, "plugin billing expressions must be a JSON object")
|
||
return
|
||
}
|
||
for key, expression := range expressions {
|
||
plugin, name, valid := billing_setting.SplitPluginBillingExprKey(key)
|
||
if !valid {
|
||
common.ApiErrorMsg(c, "invalid plugin billing expression key: "+key)
|
||
return
|
||
}
|
||
if err = model.ValidateModelPricing(name, model.PricingValues{
|
||
billing_setting.PluginBillingExprOption: map[string]any{plugin: expression},
|
||
}); err != nil {
|
||
common.ApiErrorMsg(c, err.Error())
|
||
return
|
||
}
|
||
}
|
||
case "console_setting.api_info":
|
||
err = console_setting.ValidateConsoleSettings(option.Value.(string), "ApiInfo")
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": err.Error(),
|
||
})
|
||
return
|
||
}
|
||
case "console_setting.announcements":
|
||
err = console_setting.ValidateConsoleSettings(option.Value.(string), "Announcements")
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": err.Error(),
|
||
})
|
||
return
|
||
}
|
||
case "console_setting.faq":
|
||
err = console_setting.ValidateConsoleSettings(option.Value.(string), "FAQ")
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": err.Error(),
|
||
})
|
||
return
|
||
}
|
||
case "console_setting.uptime_kuma_groups":
|
||
err = console_setting.ValidateConsoleSettings(option.Value.(string), "UptimeKumaGroups")
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": err.Error(),
|
||
})
|
||
return
|
||
}
|
||
}
|
||
if model.IsPasskeyDomainOption(option.Key) {
|
||
change, updateErr := model.UpdatePasskeyDomainOptions(map[string]string{option.Key: option.Value.(string)}, false, "")
|
||
if updateErr != nil {
|
||
writePasskeyDomainSettingsError(c, updateErr)
|
||
recordPasskeyDomainAudit(c, change, false, updateErr)
|
||
return
|
||
}
|
||
recordPasskeyDomainAudit(c, change, false, nil)
|
||
common.ApiSuccess(c, change)
|
||
return
|
||
}
|
||
err = model.UpdateOption(option.Key, option.Value.(string))
|
||
if err != nil {
|
||
if errors.Is(err, system_setting.ErrPasskeyRPIDInvalid) {
|
||
writeSecurityOperationError(c, err)
|
||
} else {
|
||
common.ApiError(c, err)
|
||
}
|
||
return
|
||
}
|
||
// 出于安全考虑只记录被修改的配置项名称,不记录配置值(可能含密钥等敏感信息)。
|
||
recordManageAudit(c, "option.update", map[string]any{
|
||
"key": option.Key,
|
||
})
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"message": "",
|
||
})
|
||
}
|