From 3e9be07db47300dc144990c18bb2d8f4488d6427 Mon Sep 17 00:00:00 2001 From: heimoshuiyu Date: Thu, 11 Sep 2025 10:34:51 +0800 Subject: [PATCH 01/51] feat: add thousand separators to token display in dashboard --- web/src/hooks/dashboard/useDashboardStats.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/hooks/dashboard/useDashboardStats.jsx b/web/src/hooks/dashboard/useDashboardStats.jsx index aa9677a50..dbf3b67e7 100644 --- a/web/src/hooks/dashboard/useDashboardStats.jsx +++ b/web/src/hooks/dashboard/useDashboardStats.jsx @@ -102,7 +102,7 @@ export const useDashboardStats = ( }, { title: t('统计Tokens'), - value: isNaN(consumeTokens) ? 0 : consumeTokens, + value: isNaN(consumeTokens) ? 0 : consumeTokens.toLocaleString(), icon: , avatarColor: 'pink', trendData: trendData.tokens, From 72d5b35d3f51d630b6f6156d903c0c0017f9eec8 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 13 Sep 2025 17:34:22 +0800 Subject: [PATCH 02/51] feat: implement SSRF protection settings and update related references --- common/ip.go | 22 + common/ssrf_protection.go | 384 ++++++++++++++++++ service/{cf_worker.go => download.go} | 26 +- service/user_notify.go | 12 +- service/webhook.go | 13 +- setting/system_setting/fetch_setting.go | 28 ++ types/error.go | 12 +- web/src/components/settings/SystemSetting.jsx | 200 +++++++++ web/src/i18n/locales/en.json | 24 +- web/src/i18n/locales/zh.json | 24 +- 10 files changed, 727 insertions(+), 18 deletions(-) create mode 100644 common/ip.go create mode 100644 common/ssrf_protection.go rename service/{cf_worker.go => download.go} (52%) create mode 100644 setting/system_setting/fetch_setting.go diff --git a/common/ip.go b/common/ip.go new file mode 100644 index 000000000..bfb64ee7f --- /dev/null +++ b/common/ip.go @@ -0,0 +1,22 @@ +package common + +import "net" + +func IsPrivateIP(ip net.IP) bool { + if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + return true + } + + private := []net.IPNet{ + {IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, + {IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)}, + {IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)}, + } + + for _, privateNet := range private { + if privateNet.Contains(ip) { + return true + } + } + return false +} diff --git a/common/ssrf_protection.go b/common/ssrf_protection.go new file mode 100644 index 000000000..b0988d907 --- /dev/null +++ b/common/ssrf_protection.go @@ -0,0 +1,384 @@ +package common + +import ( + "fmt" + "net" + "net/url" + "strconv" + "strings" +) + +// SSRFProtection SSRF防护配置 +type SSRFProtection struct { + AllowPrivateIp bool + WhitelistDomains []string // domain format, e.g. example.com, *.example.com + WhitelistIps []string // CIDR format + AllowedPorts []int // 允许的端口范围 +} + +// DefaultSSRFProtection 默认SSRF防护配置 +var DefaultSSRFProtection = &SSRFProtection{ + AllowPrivateIp: false, + WhitelistDomains: []string{}, + WhitelistIps: []string{}, + AllowedPorts: []int{}, +} + +// isPrivateIP 检查IP是否为私有地址 +func isPrivateIP(ip net.IP) bool { + if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + return true + } + + // 检查私有网段 + private := []net.IPNet{ + {IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 10.0.0.0/8 + {IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)}, // 172.16.0.0/12 + {IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)}, // 192.168.0.0/16 + {IP: net.IPv4(127, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 127.0.0.0/8 + {IP: net.IPv4(169, 254, 0, 0), Mask: net.CIDRMask(16, 32)}, // 169.254.0.0/16 (链路本地) + {IP: net.IPv4(224, 0, 0, 0), Mask: net.CIDRMask(4, 32)}, // 224.0.0.0/4 (组播) + {IP: net.IPv4(240, 0, 0, 0), Mask: net.CIDRMask(4, 32)}, // 240.0.0.0/4 (保留) + } + + for _, privateNet := range private { + if privateNet.Contains(ip) { + return true + } + } + + // 检查IPv6私有地址 + if ip.To4() == nil { + // IPv6 loopback + if ip.Equal(net.IPv6loopback) { + return true + } + // IPv6 link-local + if strings.HasPrefix(ip.String(), "fe80:") { + return true + } + // IPv6 unique local + if strings.HasPrefix(ip.String(), "fc") || strings.HasPrefix(ip.String(), "fd") { + return true + } + } + + return false +} + +// parsePortRanges 解析端口范围配置 +// 支持格式: "80", "443", "8000-9000" +func parsePortRanges(portConfigs []string) ([]int, error) { + var ports []int + + for _, config := range portConfigs { + config = strings.TrimSpace(config) + if config == "" { + continue + } + + if strings.Contains(config, "-") { + // 处理端口范围 "8000-9000" + parts := strings.Split(config, "-") + if len(parts) != 2 { + return nil, fmt.Errorf("invalid port range format: %s", config) + } + + startPort, err := strconv.Atoi(strings.TrimSpace(parts[0])) + if err != nil { + return nil, fmt.Errorf("invalid start port in range %s: %v", config, err) + } + + endPort, err := strconv.Atoi(strings.TrimSpace(parts[1])) + if err != nil { + return nil, fmt.Errorf("invalid end port in range %s: %v", config, err) + } + + if startPort > endPort { + return nil, fmt.Errorf("invalid port range %s: start port cannot be greater than end port", config) + } + + if startPort < 1 || startPort > 65535 || endPort < 1 || endPort > 65535 { + return nil, fmt.Errorf("port range %s contains invalid port numbers (must be 1-65535)", config) + } + + // 添加范围内的所有端口 + for port := startPort; port <= endPort; port++ { + ports = append(ports, port) + } + } else { + // 处理单个端口 "80" + port, err := strconv.Atoi(config) + if err != nil { + return nil, fmt.Errorf("invalid port number: %s", config) + } + + if port < 1 || port > 65535 { + return nil, fmt.Errorf("invalid port number %d (must be 1-65535)", port) + } + + ports = append(ports, port) + } + } + + return ports, nil +} + +// isAllowedPort 检查端口是否被允许 +func (p *SSRFProtection) isAllowedPort(port int) bool { + if len(p.AllowedPorts) == 0 { + return true // 如果没有配置端口限制,则允许所有端口 + } + + for _, allowedPort := range p.AllowedPorts { + if port == allowedPort { + return true + } + } + return false +} + +// isAllowedPortFromRanges 从端口范围字符串检查端口是否被允许 +func isAllowedPortFromRanges(port int, portRanges []string) bool { + if len(portRanges) == 0 { + return true // 如果没有配置端口限制,则允许所有端口 + } + + allowedPorts, err := parsePortRanges(portRanges) + if err != nil { + // 如果解析失败,为安全起见拒绝访问 + return false + } + + for _, allowedPort := range allowedPorts { + if port == allowedPort { + return true + } + } + return false +} + +// isDomainWhitelisted 检查域名是否在白名单中 +func (p *SSRFProtection) isDomainWhitelisted(domain string) bool { + if len(p.WhitelistDomains) == 0 { + return false + } + + domain = strings.ToLower(domain) + for _, whitelistDomain := range p.WhitelistDomains { + whitelistDomain = strings.ToLower(whitelistDomain) + + // 精确匹配 + if domain == whitelistDomain { + return true + } + + // 通配符匹配 (*.example.com) + if strings.HasPrefix(whitelistDomain, "*.") { + suffix := strings.TrimPrefix(whitelistDomain, "*.") + if strings.HasSuffix(domain, "."+suffix) || domain == suffix { + return true + } + } + } + return false +} + +// isIPWhitelisted 检查IP是否在白名单中 +func (p *SSRFProtection) isIPWhitelisted(ip net.IP) bool { + if len(p.WhitelistIps) == 0 { + return false + } + + for _, whitelistCIDR := range p.WhitelistIps { + _, network, err := net.ParseCIDR(whitelistCIDR) + if err != nil { + // 尝试作为单个IP处理 + if whitelistIP := net.ParseIP(whitelistCIDR); whitelistIP != nil { + if ip.Equal(whitelistIP) { + return true + } + } + continue + } + + if network.Contains(ip) { + return true + } + } + return false +} + +// IsIPAccessAllowed 检查IP是否允许访问 +func (p *SSRFProtection) IsIPAccessAllowed(ip net.IP) bool { + // 如果IP在白名单中,直接允许访问(绕过私有IP检查) + if p.isIPWhitelisted(ip) { + return true + } + + // 如果IP白名单为空,允许所有IP(但仍需通过私有IP检查) + if len(p.WhitelistIps) == 0 { + // 检查私有IP限制 + if isPrivateIP(ip) && !p.AllowPrivateIp { + return false + } + return true + } + + // 如果IP白名单不为空且IP不在白名单中,拒绝访问 + return false +} + +// ValidateURL 验证URL是否安全 +func (p *SSRFProtection) ValidateURL(urlStr string) error { + // 解析URL + u, err := url.Parse(urlStr) + if err != nil { + return fmt.Errorf("invalid URL format: %v", err) + } + + // 只允许HTTP/HTTPS协议 + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("unsupported protocol: %s (only http/https allowed)", u.Scheme) + } + + // 解析主机和端口 + host, portStr, err := net.SplitHostPort(u.Host) + if err != nil { + // 没有端口,使用默认端口 + host = u.Host + if u.Scheme == "https" { + portStr = "443" + } else { + portStr = "80" + } + } + + // 验证端口 + port, err := strconv.Atoi(portStr) + if err != nil { + return fmt.Errorf("invalid port: %s", portStr) + } + + if !p.isAllowedPort(port) { + return fmt.Errorf("port %d is not allowed", port) + } + + // 检查域名白名单 + if p.isDomainWhitelisted(host) { + return nil // 白名单域名直接通过 + } + + // DNS解析获取IP地址 + ips, err := net.LookupIP(host) + if err != nil { + return fmt.Errorf("DNS resolution failed for %s: %v", host, err) + } + + // 检查所有解析的IP地址 + for _, ip := range ips { + if !p.IsIPAccessAllowed(ip) { + if isPrivateIP(ip) { + return fmt.Errorf("private IP address not allowed: %s resolves to %s", host, ip.String()) + } else { + return fmt.Errorf("IP address not in whitelist: %s resolves to %s", host, ip.String()) + } + } + } + + return nil +} + +// ValidateURLWithDefaults 使用默认配置验证URL +func ValidateURLWithDefaults(urlStr string) error { + return DefaultSSRFProtection.ValidateURL(urlStr) +} + +// ValidateURLWithFetchSetting 使用FetchSetting配置验证URL +func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPrivateIp bool, whitelistDomains, whitelistIps, allowedPorts []string) error { + // 如果SSRF防护被禁用,直接返回成功 + if !enableSSRFProtection { + return nil + } + + // 解析端口范围配置 + allowedPortInts, err := parsePortRanges(allowedPorts) + if err != nil { + return fmt.Errorf("request reject - invalid port configuration: %v", err) + } + + protection := &SSRFProtection{ + AllowPrivateIp: allowPrivateIp, + WhitelistDomains: whitelistDomains, + WhitelistIps: whitelistIps, + AllowedPorts: allowedPortInts, + } + return protection.ValidateURL(urlStr) +} + +// ValidateURLWithPortRanges 直接使用端口范围字符串验证URL(更高效的版本) +func ValidateURLWithPortRanges(urlStr string, allowPrivateIp bool, whitelistDomains, whitelistIps, allowedPorts []string) error { + // 解析URL + u, err := url.Parse(urlStr) + if err != nil { + return fmt.Errorf("invalid URL format: %v", err) + } + + // 只允许HTTP/HTTPS协议 + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("unsupported protocol: %s (only http/https allowed)", u.Scheme) + } + + // 解析主机和端口 + host, portStr, err := net.SplitHostPort(u.Host) + if err != nil { + // 没有端口,使用默认端口 + host = u.Host + if u.Scheme == "https" { + portStr = "443" + } else { + portStr = "80" + } + } + + // 验证端口 + port, err := strconv.Atoi(portStr) + if err != nil { + return fmt.Errorf("invalid port: %s", portStr) + } + + if !isAllowedPortFromRanges(port, allowedPorts) { + return fmt.Errorf("port %d is not allowed", port) + } + + // 创建临时的SSRFProtection来复用域名和IP检查逻辑 + protection := &SSRFProtection{ + AllowPrivateIp: allowPrivateIp, + WhitelistDomains: whitelistDomains, + WhitelistIps: whitelistIps, + } + + // 检查域名白名单 + if protection.isDomainWhitelisted(host) { + return nil // 白名单域名直接通过 + } + + // DNS解析获取IP地址 + ips, err := net.LookupIP(host) + if err != nil { + return fmt.Errorf("DNS resolution failed for %s: %v", host, err) + } + + // 检查所有解析的IP地址 + for _, ip := range ips { + if !protection.IsIPAccessAllowed(ip) { + if isPrivateIP(ip) { + return fmt.Errorf("private IP address not allowed: %s resolves to %s", host, ip.String()) + } else { + return fmt.Errorf("IP address not in whitelist: %s resolves to %s", host, ip.String()) + } + } + } + + return nil +} diff --git a/service/cf_worker.go b/service/download.go similarity index 52% rename from service/cf_worker.go rename to service/download.go index 4a7b43760..2f30870d4 100644 --- a/service/cf_worker.go +++ b/service/download.go @@ -6,7 +6,7 @@ import ( "fmt" "net/http" "one-api/common" - "one-api/setting" + "one-api/setting/system_setting" "strings" ) @@ -21,14 +21,20 @@ type WorkerRequest struct { // DoWorkerRequest 通过Worker发送请求 func DoWorkerRequest(req *WorkerRequest) (*http.Response, error) { - if !setting.EnableWorker() { + if !system_setting.EnableWorker() { return nil, fmt.Errorf("worker not enabled") } - if !setting.WorkerAllowHttpImageRequestEnabled && !strings.HasPrefix(req.URL, "https") { + if !system_setting.WorkerAllowHttpImageRequestEnabled && !strings.HasPrefix(req.URL, "https") { return nil, fmt.Errorf("only support https url") } - workerUrl := setting.WorkerUrl + // SSRF防护:验证请求URL + fetchSetting := system_setting.GetFetchSetting() + if err := common.ValidateURLWithFetchSetting(req.URL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.WhitelistDomains, fetchSetting.WhitelistIps, fetchSetting.AllowedPorts); err != nil { + return nil, fmt.Errorf("request reject: %v", err) + } + + workerUrl := system_setting.WorkerUrl if !strings.HasSuffix(workerUrl, "/") { workerUrl += "/" } @@ -43,15 +49,21 @@ func DoWorkerRequest(req *WorkerRequest) (*http.Response, error) { } func DoDownloadRequest(originUrl string, reason ...string) (resp *http.Response, err error) { - if setting.EnableWorker() { + if system_setting.EnableWorker() { common.SysLog(fmt.Sprintf("downloading file from worker: %s, reason: %s", originUrl, strings.Join(reason, ", "))) req := &WorkerRequest{ URL: originUrl, - Key: setting.WorkerValidKey, + Key: system_setting.WorkerValidKey, } return DoWorkerRequest(req) } else { - common.SysLog(fmt.Sprintf("downloading from origin with worker: %s, reason: %s", originUrl, strings.Join(reason, ", "))) + // SSRF防护:验证请求URL(非Worker模式) + fetchSetting := system_setting.GetFetchSetting() + if err := common.ValidateURLWithFetchSetting(originUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.WhitelistDomains, fetchSetting.WhitelistIps, fetchSetting.AllowedPorts); err != nil { + return nil, fmt.Errorf("request reject: %v", err) + } + + common.SysLog(fmt.Sprintf("downloading from origin: %s, reason: %s", common.MaskSensitiveInfo(originUrl), strings.Join(reason, ", "))) return http.Get(originUrl) } } diff --git a/service/user_notify.go b/service/user_notify.go index c4a3ea91f..f9d7b6691 100644 --- a/service/user_notify.go +++ b/service/user_notify.go @@ -7,7 +7,7 @@ import ( "one-api/common" "one-api/dto" "one-api/model" - "one-api/setting" + "one-api/setting/system_setting" "strings" ) @@ -91,11 +91,11 @@ func sendBarkNotify(barkURL string, data dto.Notify) error { var resp *http.Response var err error - if setting.EnableWorker() { + if system_setting.EnableWorker() { // 使用worker发送请求 workerReq := &WorkerRequest{ URL: finalURL, - Key: setting.WorkerValidKey, + Key: system_setting.WorkerValidKey, Method: http.MethodGet, Headers: map[string]string{ "User-Agent": "OneAPI-Bark-Notify/1.0", @@ -113,6 +113,12 @@ func sendBarkNotify(barkURL string, data dto.Notify) error { return fmt.Errorf("bark request failed with status code: %d", resp.StatusCode) } } else { + // SSRF防护:验证Bark URL(非Worker模式) + fetchSetting := system_setting.GetFetchSetting() + if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.WhitelistDomains, fetchSetting.WhitelistIps, fetchSetting.AllowedPorts); err != nil { + return fmt.Errorf("request reject: %v", err) + } + // 直接发送请求 req, err = http.NewRequest(http.MethodGet, finalURL, nil) if err != nil { diff --git a/service/webhook.go b/service/webhook.go index 8faccda30..1f159eb4b 100644 --- a/service/webhook.go +++ b/service/webhook.go @@ -8,8 +8,9 @@ import ( "encoding/json" "fmt" "net/http" + "one-api/common" "one-api/dto" - "one-api/setting" + "one-api/setting/system_setting" "time" ) @@ -56,11 +57,11 @@ func SendWebhookNotify(webhookURL string, secret string, data dto.Notify) error var req *http.Request var resp *http.Response - if setting.EnableWorker() { + if system_setting.EnableWorker() { // 构建worker请求数据 workerReq := &WorkerRequest{ URL: webhookURL, - Key: setting.WorkerValidKey, + Key: system_setting.WorkerValidKey, Method: http.MethodPost, Headers: map[string]string{ "Content-Type": "application/json", @@ -86,6 +87,12 @@ func SendWebhookNotify(webhookURL string, secret string, data dto.Notify) error return fmt.Errorf("webhook request failed with status code: %d", resp.StatusCode) } } else { + // SSRF防护:验证Webhook URL(非Worker模式) + fetchSetting := system_setting.GetFetchSetting() + if err := common.ValidateURLWithFetchSetting(webhookURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.WhitelistDomains, fetchSetting.WhitelistIps, fetchSetting.AllowedPorts); err != nil { + return fmt.Errorf("request reject: %v", err) + } + req, err = http.NewRequest(http.MethodPost, webhookURL, bytes.NewBuffer(payloadBytes)) if err != nil { return fmt.Errorf("failed to create webhook request: %v", err) diff --git a/setting/system_setting/fetch_setting.go b/setting/system_setting/fetch_setting.go new file mode 100644 index 000000000..6e47c3f06 --- /dev/null +++ b/setting/system_setting/fetch_setting.go @@ -0,0 +1,28 @@ +package system_setting + +import "one-api/setting/config" + +type FetchSetting struct { + EnableSSRFProtection bool `json:"enable_ssrf_protection"` // 是否启用SSRF防护 + AllowPrivateIp bool `json:"allow_private_ip"` + WhitelistDomains []string `json:"whitelist_domains"` // domain format, e.g. example.com, *.example.com + WhitelistIps []string `json:"whitelist_ips"` // CIDR format + AllowedPorts []string `json:"allowed_ports"` // port range format, e.g. 80, 443, 8000-9000 +} + +var defaultFetchSetting = FetchSetting{ + EnableSSRFProtection: true, // 默认开启SSRF防护 + AllowPrivateIp: false, + WhitelistDomains: []string{}, + WhitelistIps: []string{}, + AllowedPorts: []string{"80", "443", "8080", "8443"}, +} + +func init() { + // 注册到全局配置管理器 + config.GlobalConfig.Register("fetch_setting", &defaultFetchSetting) +} + +func GetFetchSetting() *FetchSetting { + return &defaultFetchSetting +} diff --git a/types/error.go b/types/error.go index 883ee0641..a42e84385 100644 --- a/types/error.go +++ b/types/error.go @@ -122,6 +122,9 @@ func (e *NewAPIError) MaskSensitiveError() string { return string(e.errorCode) } errStr := e.Err.Error() + if e.errorCode == ErrorCodeCountTokenFailed { + return errStr + } return common.MaskSensitiveInfo(errStr) } @@ -153,8 +156,9 @@ func (e *NewAPIError) ToOpenAIError() OpenAIError { Code: e.errorCode, } } - - result.Message = common.MaskSensitiveInfo(result.Message) + if e.errorCode != ErrorCodeCountTokenFailed { + result.Message = common.MaskSensitiveInfo(result.Message) + } return result } @@ -178,7 +182,9 @@ func (e *NewAPIError) ToClaudeError() ClaudeError { Type: string(e.errorType), } } - result.Message = common.MaskSensitiveInfo(result.Message) + if e.errorCode != ErrorCodeCountTokenFailed { + result.Message = common.MaskSensitiveInfo(result.Message) + } return result } diff --git a/web/src/components/settings/SystemSetting.jsx b/web/src/components/settings/SystemSetting.jsx index 9c7eeaadc..71dfaac8d 100644 --- a/web/src/components/settings/SystemSetting.jsx +++ b/web/src/components/settings/SystemSetting.jsx @@ -44,6 +44,7 @@ import { useTranslation } from 'react-i18next'; const SystemSetting = () => { const { t } = useTranslation(); let [inputs, setInputs] = useState({ + PasswordLoginEnabled: '', PasswordRegisterEnabled: '', EmailVerificationEnabled: '', @@ -87,6 +88,12 @@ const SystemSetting = () => { LinuxDOClientSecret: '', LinuxDOMinimumTrustLevel: '', ServerAddress: '', + // SSRF防护配置 + 'fetch_setting.enable_ssrf_protection': true, + 'fetch_setting.allow_private_ip': '', + 'fetch_setting.whitelist_domains': [], + 'fetch_setting.whitelist_ips': [], + 'fetch_setting.allowed_ports': [], }); const [originInputs, setOriginInputs] = useState({}); @@ -98,6 +105,9 @@ const SystemSetting = () => { useState(false); const [linuxDOOAuthEnabled, setLinuxDOOAuthEnabled] = useState(false); const [emailToAdd, setEmailToAdd] = useState(''); + const [whitelistDomains, setWhitelistDomains] = useState([]); + const [whitelistIps, setWhitelistIps] = useState([]); + const [allowedPorts, setAllowedPorts] = useState([]); const getOptions = async () => { setLoading(true); @@ -113,6 +123,34 @@ const SystemSetting = () => { case 'EmailDomainWhitelist': setEmailDomainWhitelist(item.value ? item.value.split(',') : []); break; + case 'fetch_setting.allow_private_ip': + case 'fetch_setting.enable_ssrf_protection': + item.value = toBoolean(item.value); + break; + case 'fetch_setting.whitelist_domains': + try { + const domains = item.value ? JSON.parse(item.value) : []; + setWhitelistDomains(Array.isArray(domains) ? domains : []); + } catch (e) { + setWhitelistDomains([]); + } + break; + case 'fetch_setting.whitelist_ips': + try { + const ips = item.value ? JSON.parse(item.value) : []; + setWhitelistIps(Array.isArray(ips) ? ips : []); + } catch (e) { + setWhitelistIps([]); + } + break; + case 'fetch_setting.allowed_ports': + try { + const ports = item.value ? JSON.parse(item.value) : []; + setAllowedPorts(Array.isArray(ports) ? ports : []); + } catch (e) { + setAllowedPorts(['80', '443', '8080', '8443']); + } + break; case 'PasswordLoginEnabled': case 'PasswordRegisterEnabled': case 'EmailVerificationEnabled': @@ -276,6 +314,38 @@ const SystemSetting = () => { } }; + const submitSSRF = async () => { + const options = []; + + // 处理域名白名单 + if (Array.isArray(whitelistDomains)) { + options.push({ + key: 'fetch_setting.whitelist_domains', + value: JSON.stringify(whitelistDomains), + }); + } + + // 处理IP白名单 + if (Array.isArray(whitelistIps)) { + options.push({ + key: 'fetch_setting.whitelist_ips', + value: JSON.stringify(whitelistIps), + }); + } + + // 处理端口配置 + if (Array.isArray(allowedPorts)) { + options.push({ + key: 'fetch_setting.allowed_ports', + value: JSON.stringify(allowedPorts), + }); + } + + if (options.length > 0) { + await updateOptions(options); + } + }; + const handleAddEmail = () => { if (emailToAdd && emailToAdd.trim() !== '') { const domain = emailToAdd.trim(); @@ -587,6 +657,136 @@ const SystemSetting = () => { + + + + {t('配置服务器端请求伪造(SSRF)防护,用于保护内网资源安全')} + + + + + handleCheckboxChange('fetch_setting.enable_ssrf_protection', e) + } + > + {t('启用SSRF防护(推荐开启以保护服务器安全)')} + + + + + + + + handleCheckboxChange('fetch_setting.allow_private_ip', e) + } + > + {t('允许访问私有IP地址(127.0.0.1、192.168.x.x等内网地址)')} + + + + + + + {t('域名白名单')} + + {t('支持通配符格式,如:example.com, *.api.example.com')} + + { + setWhitelistDomains(value); + // 触发Form的onChange事件 + setInputs(prev => ({ + ...prev, + 'fetch_setting.whitelist_domains': value + })); + }} + placeholder={t('输入域名后回车,如:example.com')} + style={{ width: '100%' }} + /> + + {t('域名白名单详细说明')} + + + + + + + {t('IP白名单')} + + {t('支持CIDR格式,如:8.8.8.8, 192.168.1.0/24')} + + { + setWhitelistIps(value); + // 触发Form的onChange事件 + setInputs(prev => ({ + ...prev, + 'fetch_setting.whitelist_ips': value + })); + }} + placeholder={t('输入IP地址后回车,如:8.8.8.8')} + style={{ width: '100%' }} + /> + + {t('IP白名单详细说明')} + + + + + + + {t('允许的端口')} + + {t('支持单个端口和端口范围,如:80, 443, 8000-8999')} + + { + setAllowedPorts(value); + // 触发Form的onChange事件 + setInputs(prev => ({ + ...prev, + 'fetch_setting.allowed_ports': value + })); + }} + placeholder={t('输入端口后回车,如:80 或 8000-8999')} + style={{ width: '100%' }} + /> + + {t('端口配置详细说明')} + + + + + + + + Date: Sun, 14 Sep 2025 12:59:44 +0800 Subject: [PATCH 03/51] fix: settings --- service/cf_worker.go | 12 ++++++------ service/user_notify.go | 6 +++--- service/webhook.go | 6 +++--- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/service/cf_worker.go b/service/cf_worker.go index 4a7b43760..d60b6fad5 100644 --- a/service/cf_worker.go +++ b/service/cf_worker.go @@ -6,7 +6,7 @@ import ( "fmt" "net/http" "one-api/common" - "one-api/setting" + "one-api/setting/system_setting" "strings" ) @@ -21,14 +21,14 @@ type WorkerRequest struct { // DoWorkerRequest 通过Worker发送请求 func DoWorkerRequest(req *WorkerRequest) (*http.Response, error) { - if !setting.EnableWorker() { + if !system_setting.EnableWorker() { return nil, fmt.Errorf("worker not enabled") } - if !setting.WorkerAllowHttpImageRequestEnabled && !strings.HasPrefix(req.URL, "https") { + if !system_setting.WorkerAllowHttpImageRequestEnabled && !strings.HasPrefix(req.URL, "https") { return nil, fmt.Errorf("only support https url") } - workerUrl := setting.WorkerUrl + workerUrl := system_setting.WorkerUrl if !strings.HasSuffix(workerUrl, "/") { workerUrl += "/" } @@ -43,11 +43,11 @@ func DoWorkerRequest(req *WorkerRequest) (*http.Response, error) { } func DoDownloadRequest(originUrl string, reason ...string) (resp *http.Response, err error) { - if setting.EnableWorker() { + if system_setting.EnableWorker() { common.SysLog(fmt.Sprintf("downloading file from worker: %s, reason: %s", originUrl, strings.Join(reason, ", "))) req := &WorkerRequest{ URL: originUrl, - Key: setting.WorkerValidKey, + Key: system_setting.WorkerValidKey, } return DoWorkerRequest(req) } else { diff --git a/service/user_notify.go b/service/user_notify.go index c4a3ea91f..972ca655c 100644 --- a/service/user_notify.go +++ b/service/user_notify.go @@ -7,7 +7,7 @@ import ( "one-api/common" "one-api/dto" "one-api/model" - "one-api/setting" + "one-api/setting/system_setting" "strings" ) @@ -91,11 +91,11 @@ func sendBarkNotify(barkURL string, data dto.Notify) error { var resp *http.Response var err error - if setting.EnableWorker() { + if system_setting.EnableWorker() { // 使用worker发送请求 workerReq := &WorkerRequest{ URL: finalURL, - Key: setting.WorkerValidKey, + Key: system_setting.WorkerValidKey, Method: http.MethodGet, Headers: map[string]string{ "User-Agent": "OneAPI-Bark-Notify/1.0", diff --git a/service/webhook.go b/service/webhook.go index 8faccda30..9c6ec8102 100644 --- a/service/webhook.go +++ b/service/webhook.go @@ -9,7 +9,7 @@ import ( "fmt" "net/http" "one-api/dto" - "one-api/setting" + "one-api/setting/system_setting" "time" ) @@ -56,11 +56,11 @@ func SendWebhookNotify(webhookURL string, secret string, data dto.Notify) error var req *http.Request var resp *http.Response - if setting.EnableWorker() { + if system_setting.EnableWorker() { // 构建worker请求数据 workerReq := &WorkerRequest{ URL: webhookURL, - Key: setting.WorkerValidKey, + Key: system_setting.WorkerValidKey, Method: http.MethodPost, Headers: map[string]string{ "Content-Type": "application/json", From 33bf267ce82b82d5a43eeadff9d7b74424ddc2e0 Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Mon, 15 Sep 2025 14:31:55 +0800 Subject: [PATCH 04/51] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=8D=B3?= =?UTF-8?q?=E6=A2=A6=E8=A7=86=E9=A2=913.0,=E6=96=B0=E5=A2=9E10s(frames=3D2?= =?UTF-8?q?41)=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- relay/channel/task/jimeng/adaptor.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/relay/channel/task/jimeng/adaptor.go b/relay/channel/task/jimeng/adaptor.go index 2bc45c547..e870a6590 100644 --- a/relay/channel/task/jimeng/adaptor.go +++ b/relay/channel/task/jimeng/adaptor.go @@ -36,6 +36,7 @@ type requestPayload struct { Prompt string `json:"prompt,omitempty"` Seed int64 `json:"seed"` AspectRatio string `json:"aspect_ratio"` + Frames int `json:"frames,omitempty"` } type responsePayload struct { @@ -311,10 +312,15 @@ func hmacSHA256(key []byte, data []byte) []byte { func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*requestPayload, error) { r := requestPayload{ - ReqKey: "jimeng_vgfm_i2v_l20", - Prompt: req.Prompt, - AspectRatio: "16:9", // Default aspect ratio - Seed: -1, // Default to random + ReqKey: req.Model, + Prompt: req.Prompt, + } + + switch req.Duration { + case 10: + r.Frames = 241 // 24*10+1 = 241 + default: + r.Frames = 121 // 24*5+1 = 121 } // Handle one-of image_urls or binary_data_base64 From f3e220b196028d29ddc2947daa7b3b8da21267a0 Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Mon, 15 Sep 2025 15:53:41 +0800 Subject: [PATCH 05/51] feat: jimeng video 3.0 req_key convert --- relay/channel/task/jimeng/adaptor.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/relay/channel/task/jimeng/adaptor.go b/relay/channel/task/jimeng/adaptor.go index e870a6590..b954d7b88 100644 --- a/relay/channel/task/jimeng/adaptor.go +++ b/relay/channel/task/jimeng/adaptor.go @@ -340,6 +340,22 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (* if err != nil { return nil, errors.Wrap(err, "unmarshal metadata failed") } + + // 即梦视频3.0 ReqKey转换 + // https://www.volcengine.com/docs/85621/1792707 + if strings.Contains(r.ReqKey, "jimeng_v30") { + if len(r.ImageUrls) > 1 { + // 多张图片:首尾帧生成 + r.ReqKey = strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_i2v_first_tail_v30", 1) + } else if len(r.ImageUrls) == 1 { + // 单张图片:图生视频 + r.ReqKey = strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_i2v_first_v30", 1) + } else { + // 无图片:文生视频 + r.ReqKey = strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_t2v_v30", 1) + } + } + return &r, nil } From f236785ed5594c3229ba5ab56d915424436a5281 Mon Sep 17 00:00:00 2001 From: creamlike1024 Date: Mon, 15 Sep 2025 16:22:37 +0800 Subject: [PATCH 06/51] =?UTF-8?q?fix:=20stripe=E6=94=AF=E4=BB=98=E6=88=90?= =?UTF-8?q?=E5=8A=9F=E6=9C=AA=E6=AD=A3=E7=A1=AE=E8=B7=B3=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controller/topup_stripe.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/controller/topup_stripe.go b/controller/topup_stripe.go index d462acb4b..ccde91dbe 100644 --- a/controller/topup_stripe.go +++ b/controller/topup_stripe.go @@ -217,7 +217,7 @@ func genStripeLink(referenceId string, customerId string, email string, amount i params := &stripe.CheckoutSessionParams{ ClientReferenceID: stripe.String(referenceId), - SuccessURL: stripe.String(system_setting.ServerAddress + "/log"), + SuccessURL: stripe.String(system_setting.ServerAddress + "/console/log"), CancelURL: stripe.String(system_setting.ServerAddress + "/topup"), LineItems: []*stripe.CheckoutSessionLineItemParams{ { From e34b5def602cd29098029f9436bd5c9e4bf97e8c Mon Sep 17 00:00:00 2001 From: QuentinHsu Date: Mon, 15 Sep 2025 21:45:00 +0800 Subject: [PATCH 07/51] feat: add date range preset constants and use them in the log filter --- .../table/mj-logs/MjLogsFilters.jsx | 7 +++ .../table/task-logs/TaskLogsFilters.jsx | 7 +++ .../table/usage-logs/UsageLogsFilters.jsx | 7 +++ web/src/constants/console.constants.js | 49 +++++++++++++++++++ web/src/i18n/locales/en.json | 7 ++- 5 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 web/src/constants/console.constants.js diff --git a/web/src/components/table/mj-logs/MjLogsFilters.jsx b/web/src/components/table/mj-logs/MjLogsFilters.jsx index 44c6bcfcd..6db96e791 100644 --- a/web/src/components/table/mj-logs/MjLogsFilters.jsx +++ b/web/src/components/table/mj-logs/MjLogsFilters.jsx @@ -21,6 +21,8 @@ import React from 'react'; import { Button, Form } from '@douyinfe/semi-ui'; import { IconSearch } from '@douyinfe/semi-icons'; +import { DATE_RANGE_PRESETS } from '../../../constants/console.constants'; + const MjLogsFilters = ({ formInitValues, setFormApi, @@ -54,6 +56,11 @@ const MjLogsFilters = ({ showClear pure size='small' + presets={DATE_RANGE_PRESETS.map(preset => ({ + text: t(preset.text), + start: preset.start(), + end: preset.end() + }))} /> diff --git a/web/src/components/table/task-logs/TaskLogsFilters.jsx b/web/src/components/table/task-logs/TaskLogsFilters.jsx index d5e081ab7..e27cea867 100644 --- a/web/src/components/table/task-logs/TaskLogsFilters.jsx +++ b/web/src/components/table/task-logs/TaskLogsFilters.jsx @@ -21,6 +21,8 @@ import React from 'react'; import { Button, Form } from '@douyinfe/semi-ui'; import { IconSearch } from '@douyinfe/semi-icons'; +import { DATE_RANGE_PRESETS } from '../../../constants/console.constants'; + const TaskLogsFilters = ({ formInitValues, setFormApi, @@ -54,6 +56,11 @@ const TaskLogsFilters = ({ showClear pure size='small' + presets={DATE_RANGE_PRESETS.map(preset => ({ + text: t(preset.text), + start: preset.start(), + end: preset.end() + }))} /> diff --git a/web/src/components/table/usage-logs/UsageLogsFilters.jsx b/web/src/components/table/usage-logs/UsageLogsFilters.jsx index f76ec823e..58e5a4692 100644 --- a/web/src/components/table/usage-logs/UsageLogsFilters.jsx +++ b/web/src/components/table/usage-logs/UsageLogsFilters.jsx @@ -21,6 +21,8 @@ import React from 'react'; import { Button, Form } from '@douyinfe/semi-ui'; import { IconSearch } from '@douyinfe/semi-icons'; +import { DATE_RANGE_PRESETS } from '../../../constants/console.constants'; + const LogsFilters = ({ formInitValues, setFormApi, @@ -55,6 +57,11 @@ const LogsFilters = ({ showClear pure size='small' + presets={DATE_RANGE_PRESETS.map(preset => ({ + text: t(preset.text), + start: preset.start(), + end: preset.end() + }))} /> diff --git a/web/src/constants/console.constants.js b/web/src/constants/console.constants.js new file mode 100644 index 000000000..23ee1e17f --- /dev/null +++ b/web/src/constants/console.constants.js @@ -0,0 +1,49 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import dayjs from 'dayjs'; + +// ========== 日期预设常量 ========== +export const DATE_RANGE_PRESETS = [ + { + text: '今天', + start: () => dayjs().startOf('day').toDate(), + end: () => dayjs().endOf('day').toDate() + }, + { + text: '近 7 天', + start: () => dayjs().subtract(6, 'day').startOf('day').toDate(), + end: () => dayjs().endOf('day').toDate() + }, + { + text: '本周', + start: () => dayjs().startOf('week').toDate(), + end: () => dayjs().endOf('week').toDate() + }, + { + text: '近 30 天', + start: () => dayjs().subtract(29, 'day').startOf('day').toDate(), + end: () => dayjs().endOf('day').toDate() + }, + { + text: '本月', + start: () => dayjs().startOf('month').toDate(), + end: () => dayjs().endOf('month').toDate() + }, +]; diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 73dfbebe7..a527b91c3 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -2084,5 +2084,10 @@ "原价": "Original price", "优惠": "Discount", "折": "% off", - "节省": "Save" + "节省": "Save", + "今天": "Today", + "近 7 天": "Last 7 Days", + "本周": "This Week", + "本月": "This Month", + "近 30 天": "Last 30 Days" } From dfa27f3412e5192936db448cf45f281a9a29ab13 Mon Sep 17 00:00:00 2001 From: QuentinHsu Date: Mon, 15 Sep 2025 22:30:41 +0800 Subject: [PATCH 08/51] feat: add jsconfig.json and configure path aliases --- web/jsconfig.json | 9 +++++++++ web/vite.config.js | 6 ++++++ 2 files changed, 15 insertions(+) create mode 100644 web/jsconfig.json diff --git a/web/jsconfig.json b/web/jsconfig.json new file mode 100644 index 000000000..ced4d0543 --- /dev/null +++ b/web/jsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "baseUrl": "./", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*"] +} \ No newline at end of file diff --git a/web/vite.config.js b/web/vite.config.js index 3515dce7b..d57fd9d9b 100644 --- a/web/vite.config.js +++ b/web/vite.config.js @@ -20,10 +20,16 @@ For commercial licensing, please contact support@quantumnous.com import react from '@vitejs/plugin-react'; import { defineConfig, transformWithEsbuild } from 'vite'; import pkg from '@douyinfe/vite-plugin-semi'; +import path from 'path'; const { vitePluginSemi } = pkg; // https://vitejs.dev/config/ export default defineConfig({ + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, plugins: [ { name: 'treat-js-files-as-jsx', From 11cf70e60d559953764c30cc4ff4fd47dad207e5 Mon Sep 17 00:00:00 2001 From: creamlike1024 Date: Tue, 16 Sep 2025 12:47:59 +0800 Subject: [PATCH 09/51] =?UTF-8?q?fix:=20openai=20responses=20api=20?= =?UTF-8?q?=E6=9C=AA=E7=BB=9F=E8=AE=A1=E5=9B=BE=E5=83=8F=E7=94=9F=E6=88=90?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E8=AE=A1=E8=B4=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dto/openai_response.go | 42 +++++++++++++++++++ relay/channel/openai/relay_responses.go | 35 +++++++++++----- relay/compatible_handler.go | 13 ++++++ setting/operation_setting/tools.go | 40 ++++++++++++++++++ web/src/helpers/render.jsx | 23 +++++++++- web/src/hooks/usage-logs/useUsageLogsData.jsx | 2 + 6 files changed, 142 insertions(+), 13 deletions(-) diff --git a/dto/openai_response.go b/dto/openai_response.go index 966748cb5..6353c15ff 100644 --- a/dto/openai_response.go +++ b/dto/openai_response.go @@ -6,6 +6,10 @@ import ( "one-api/types" ) +const ( + ResponsesOutputTypeImageGenerationCall = "image_generation_call" +) + type SimpleResponse struct { Usage `json:"usage"` Error any `json:"error"` @@ -273,6 +277,42 @@ func (o *OpenAIResponsesResponse) GetOpenAIError() *types.OpenAIError { return GetOpenAIError(o.Error) } +func (o *OpenAIResponsesResponse) HasImageGenerationCall() bool { + if len(o.Output) == 0 { + return false + } + for _, output := range o.Output { + if output.Type == ResponsesOutputTypeImageGenerationCall { + return true + } + } + return false +} + +func (o *OpenAIResponsesResponse) GetQuality() string { + if len(o.Output) == 0 { + return "" + } + for _, output := range o.Output { + if output.Type == ResponsesOutputTypeImageGenerationCall { + return output.Quality + } + } + return "" +} + +func (o *OpenAIResponsesResponse) GetSize() string { + if len(o.Output) == 0 { + return "" + } + for _, output := range o.Output { + if output.Type == ResponsesOutputTypeImageGenerationCall { + return output.Size + } + } + return "" +} + type IncompleteDetails struct { Reasoning string `json:"reasoning"` } @@ -283,6 +323,8 @@ type ResponsesOutput struct { Status string `json:"status"` Role string `json:"role"` Content []ResponsesOutputContent `json:"content"` + Quality string `json:"quality"` + Size string `json:"size"` } type ResponsesOutputContent struct { diff --git a/relay/channel/openai/relay_responses.go b/relay/channel/openai/relay_responses.go index e188889e4..85938a771 100644 --- a/relay/channel/openai/relay_responses.go +++ b/relay/channel/openai/relay_responses.go @@ -33,6 +33,12 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http return nil, types.WithOpenAIError(*oaiError, resp.StatusCode) } + if responsesResponse.HasImageGenerationCall() { + c.Set("image_generation_call", true) + c.Set("image_generation_call_quality", responsesResponse.GetQuality()) + c.Set("image_generation_call_size", responsesResponse.GetSize()) + } + // 写入新的 response body service.IOCopyBytesGracefully(c, resp, responseBody) @@ -80,18 +86,25 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp sendResponsesStreamData(c, streamResponse, data) switch streamResponse.Type { case "response.completed": - if streamResponse.Response != nil && streamResponse.Response.Usage != nil { - if streamResponse.Response.Usage.InputTokens != 0 { - usage.PromptTokens = streamResponse.Response.Usage.InputTokens + if streamResponse.Response != nil { + if streamResponse.Response.Usage != nil { + if streamResponse.Response.Usage.InputTokens != 0 { + usage.PromptTokens = streamResponse.Response.Usage.InputTokens + } + if streamResponse.Response.Usage.OutputTokens != 0 { + usage.CompletionTokens = streamResponse.Response.Usage.OutputTokens + } + if streamResponse.Response.Usage.TotalTokens != 0 { + usage.TotalTokens = streamResponse.Response.Usage.TotalTokens + } + if streamResponse.Response.Usage.InputTokensDetails != nil { + usage.PromptTokensDetails.CachedTokens = streamResponse.Response.Usage.InputTokensDetails.CachedTokens + } } - if streamResponse.Response.Usage.OutputTokens != 0 { - usage.CompletionTokens = streamResponse.Response.Usage.OutputTokens - } - if streamResponse.Response.Usage.TotalTokens != 0 { - usage.TotalTokens = streamResponse.Response.Usage.TotalTokens - } - if streamResponse.Response.Usage.InputTokensDetails != nil { - usage.PromptTokensDetails.CachedTokens = streamResponse.Response.Usage.InputTokensDetails.CachedTokens + if streamResponse.Response.HasImageGenerationCall() { + c.Set("image_generation_call", true) + c.Set("image_generation_call_quality", streamResponse.Response.GetQuality()) + c.Set("image_generation_call_size", streamResponse.Response.GetSize()) } } case "response.output_text.delta": diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go index 01ab1fff4..c931fe2a0 100644 --- a/relay/compatible_handler.go +++ b/relay/compatible_handler.go @@ -276,6 +276,13 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage fileSearchTool.CallCount, dFileSearchQuota.String()) } } + var dImageGenerationCallQuota decimal.Decimal + var imageGenerationCallPrice float64 + if ctx.GetBool("image_generation_call") { + imageGenerationCallPrice = operation_setting.GetGPTImage1PriceOnceCall(ctx.GetString("image_generation_call_quality"), ctx.GetString("image_generation_call_size")) + dImageGenerationCallQuota = decimal.NewFromFloat(imageGenerationCallPrice).Mul(dGroupRatio).Mul(dQuotaPerUnit) + extraContent += fmt.Sprintf("Image Generation Call 花费 %s", dImageGenerationCallQuota.String()) + } var quotaCalculateDecimal decimal.Decimal @@ -331,6 +338,8 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage quotaCalculateDecimal = quotaCalculateDecimal.Add(dFileSearchQuota) // 添加 audio input 独立计费 quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota) + // 添加 image generation call 计费 + quotaCalculateDecimal = quotaCalculateDecimal.Add(dImageGenerationCallQuota) quota := int(quotaCalculateDecimal.Round(0).IntPart()) totalTokens := promptTokens + completionTokens @@ -429,6 +438,10 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage other["audio_input_token_count"] = audioTokens other["audio_input_price"] = audioInputPrice } + if !dImageGenerationCallQuota.IsZero() { + other["image_generation_call"] = true + other["image_generation_call_price"] = imageGenerationCallPrice + } model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{ ChannelId: relayInfo.ChannelId, PromptTokens: promptTokens, diff --git a/setting/operation_setting/tools.go b/setting/operation_setting/tools.go index 549a1862e..5b89d6fec 100644 --- a/setting/operation_setting/tools.go +++ b/setting/operation_setting/tools.go @@ -10,6 +10,18 @@ const ( FileSearchPrice = 2.5 ) +const ( + GPTImage1Low1024x1024 = 0.011 + GPTImage1Low1024x1536 = 0.016 + GPTImage1Low1536x1024 = 0.016 + GPTImage1Medium1024x1024 = 0.042 + GPTImage1Medium1024x1536 = 0.063 + GPTImage1Medium1536x1024 = 0.063 + GPTImage1High1024x1024 = 0.167 + GPTImage1High1024x1536 = 0.25 + GPTImage1High1536x1024 = 0.25 +) + const ( // Gemini Audio Input Price Gemini25FlashPreviewInputAudioPrice = 1.00 @@ -65,3 +77,31 @@ func GetGeminiInputAudioPricePerMillionTokens(modelName string) float64 { } return 0 } + +func GetGPTImage1PriceOnceCall(quality string, size string) float64 { + prices := map[string]map[string]float64{ + "low": { + "1024x1024": GPTImage1Low1024x1024, + "1024x1536": GPTImage1Low1024x1536, + "1536x1024": GPTImage1Low1536x1024, + }, + "medium": { + "1024x1024": GPTImage1Medium1024x1024, + "1024x1536": GPTImage1Medium1024x1536, + "1536x1024": GPTImage1Medium1536x1024, + }, + "high": { + "1024x1024": GPTImage1High1024x1024, + "1024x1536": GPTImage1High1024x1536, + "1536x1024": GPTImage1High1536x1024, + }, + } + + if qualityMap, exists := prices[quality]; exists { + if price, exists := qualityMap[size]; exists { + return price + } + } + + return GPTImage1High1024x1024 +} diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index 65332701b..c331d7fe8 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -1027,6 +1027,8 @@ export function renderModelPrice( audioInputSeperatePrice = false, audioInputTokens = 0, audioInputPrice = 0, + imageGenerationCall = false, + imageGenerationCallPrice = 0, ) { const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio( groupRatio, @@ -1069,7 +1071,8 @@ export function renderModelPrice( (audioInputTokens / 1000000) * audioInputPrice * groupRatio + (completionTokens / 1000000) * completionRatioPrice * groupRatio + (webSearchCallCount / 1000) * webSearchPrice * groupRatio + - (fileSearchCallCount / 1000) * fileSearchPrice * groupRatio; + (fileSearchCallCount / 1000) * fileSearchPrice * groupRatio + + (imageGenerationCall * imageGenerationCallPrice * groupRatio); return ( <> @@ -1131,7 +1134,13 @@ export function renderModelPrice( })}

)} -

+ {imageGenerationCall && imageGenerationCallPrice > 0 && ( +

+ {i18next.t('图片生成调用:${{price}} / 1次', { + price: imageGenerationCallPrice, + })} +

+ )}

{(() => { // 构建输入部分描述 @@ -1211,6 +1220,16 @@ export function renderModelPrice( }, ) : '', + imageGenerationCall && imageGenerationCallPrice > 0 + ? i18next.t( + ' + 图片生成调用 ${{price}} / 1次 * {{ratioType}} {{ratio}}', + { + price: imageGenerationCallPrice, + ratio: groupRatio, + ratioType: ratioLabel, + }, + ) + : '', ].join(''); return i18next.t( diff --git a/web/src/hooks/usage-logs/useUsageLogsData.jsx b/web/src/hooks/usage-logs/useUsageLogsData.jsx index 81f3f539a..d434e7333 100644 --- a/web/src/hooks/usage-logs/useUsageLogsData.jsx +++ b/web/src/hooks/usage-logs/useUsageLogsData.jsx @@ -447,6 +447,8 @@ export const useLogsData = () => { other?.audio_input_seperate_price || false, other?.audio_input_token_count || 0, other?.audio_input_price || 0, + other?.image_generation_call || false, + other?.image_generation_call_price || 0, ); } expandDataLocal.push({ From 17be7c3b451622680a5f8c34b27f8436b19afa9d Mon Sep 17 00:00:00 2001 From: creamlike1024 Date: Tue, 16 Sep 2025 13:02:15 +0800 Subject: [PATCH 10/51] fix: imageGenerationCall involves billing --- web/src/helpers/render.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index c331d7fe8..c19e2849d 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -1072,7 +1072,7 @@ export function renderModelPrice( (completionTokens / 1000000) * completionRatioPrice * groupRatio + (webSearchCallCount / 1000) * webSearchPrice * groupRatio + (fileSearchCallCount / 1000) * fileSearchPrice * groupRatio + - (imageGenerationCall * imageGenerationCallPrice * groupRatio); + (imageGenerationCallPrice * groupRatio); return ( <> From 956244c742842f8f096dd8e47a97404107a8f777 Mon Sep 17 00:00:00 2001 From: huanghejian Date: Tue, 16 Sep 2025 14:30:12 +0800 Subject: [PATCH 11/51] fix: VolcEngine doubao-seedream-4-0-250828 --- controller/channel-test.go | 39 +++++++++++++++++++++++++++ relay/channel/volcengine/adaptor.go | 2 ++ relay/channel/volcengine/constants.go | 1 + 3 files changed, 42 insertions(+) diff --git a/controller/channel-test.go b/controller/channel-test.go index 5a668c488..9ea6eed75 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -90,6 +90,11 @@ func testChannel(channel *model.Channel, testModel string) testResult { requestPath = "/v1/embeddings" // 修改请求路径 } + // VolcEngine 图像生成模型 + if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") { + requestPath = "/v1/images/generations" + } + c.Request = &http.Request{ Method: "POST", URL: &url.URL{Path: requestPath}, // 使用动态路径 @@ -109,6 +114,21 @@ func testChannel(channel *model.Channel, testModel string) testResult { } } + // 重新检查模型类型并更新请求路径 + if strings.Contains(strings.ToLower(testModel), "embedding") || + strings.HasPrefix(testModel, "m3e") || + strings.Contains(testModel, "bge-") || + strings.Contains(testModel, "embed") || + channel.Type == constant.ChannelTypeMokaAI { + requestPath = "/v1/embeddings" + c.Request.URL.Path = requestPath + } + + if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") { + requestPath = "/v1/images/generations" + c.Request.URL.Path = requestPath + } + cache, err := model.GetUserCache(1) if err != nil { return testResult{ @@ -140,6 +160,9 @@ func testChannel(channel *model.Channel, testModel string) testResult { if c.Request.URL.Path == "/v1/embeddings" { relayFormat = types.RelayFormatEmbedding } + if c.Request.URL.Path == "/v1/images/generations" { + relayFormat = types.RelayFormatOpenAIImage + } info, err := relaycommon.GenRelayInfo(c, relayFormat, request, nil) @@ -201,6 +224,22 @@ func testChannel(channel *model.Channel, testModel string) testResult { } // 调用专门用于 Embedding 的转换函数 convertedRequest, err = adaptor.ConvertEmbeddingRequest(c, info, embeddingRequest) + } else if info.RelayMode == relayconstant.RelayModeImagesGenerations { + // 创建一个 ImageRequest + prompt := "cat" + if request.Prompt != nil { + if promptStr, ok := request.Prompt.(string); ok && promptStr != "" { + prompt = promptStr + } + } + imageRequest := dto.ImageRequest{ + Prompt: prompt, + Model: request.Model, + N: uint(request.N), + Size: request.Size, + } + // 调用专门用于图像生成的转换函数 + convertedRequest, err = adaptor.ConvertImageRequest(c, info, imageRequest) } else { // 对其他所有请求类型(如 Chat),保持原有逻辑 convertedRequest, err = adaptor.ConvertOpenAIRequest(c, info, request) diff --git a/relay/channel/volcengine/adaptor.go b/relay/channel/volcengine/adaptor.go index 0af019da4..eb88412af 100644 --- a/relay/channel/volcengine/adaptor.go +++ b/relay/channel/volcengine/adaptor.go @@ -41,6 +41,8 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { switch info.RelayMode { + case constant.RelayModeImagesGenerations: + return request, nil case constant.RelayModeImagesEdits: var requestBody bytes.Buffer diff --git a/relay/channel/volcengine/constants.go b/relay/channel/volcengine/constants.go index 30cc902e7..fca10e7c1 100644 --- a/relay/channel/volcengine/constants.go +++ b/relay/channel/volcengine/constants.go @@ -8,6 +8,7 @@ var ModelList = []string{ "Doubao-lite-32k", "Doubao-lite-4k", "Doubao-embedding", + "doubao-seedream-4-0-250828", } var ChannelName = "volcengine" From 4be61d00e4cf111457d96e9beed03b39944e12cb Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Tue, 16 Sep 2025 17:21:22 +0800 Subject: [PATCH 12/51] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20fix:=20Align=20se?= =?UTF-8?q?tup=20API=20errors=20to=20HTTP=20200=20with=20{success:false,?= =?UTF-8?q?=20message}?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unify the setup initialization endpoint’s error contract to match the rest of the project and keep the frontend unchanged. Changes - controller/setup.go: Return HTTP 200 with {success:false, message} for all predictable errors in POST /api/setup, including: - already initialized - invalid payload - username too long - password mismatch - password too short - password hashing failure - root user creation failure - option persistence failures (SelfUseModeEnabled, DemoSiteEnabled) - setup record creation failure - web/src/components/setup/SetupWizard.jsx: Restore catch handler to the previous generic toast (frontend logic unchanged). - web/src/helpers/utils.jsx: Restore the original showError implementation (no Axios response.data parsing required). Why - Keep API behavior consistent across endpoints so the UI can rely on the success flag and message in the normal .then() flow instead of falling into Axios 4xx errors that only show a generic "400". Impact - UI now displays specific server messages during initialization without frontend adaptations. - Note: clients relying solely on HTTP status codes for error handling should inspect the JSON body (success/message) instead. No changes to the happy path; initialization success responses are unchanged. --- controller/setup.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/controller/setup.go b/controller/setup.go index 44a7b3a73..3ae255e94 100644 --- a/controller/setup.go +++ b/controller/setup.go @@ -53,7 +53,7 @@ func GetSetup(c *gin.Context) { func PostSetup(c *gin.Context) { // Check if setup is already completed if constant.Setup { - c.JSON(400, gin.H{ + c.JSON(200, gin.H{ "success": false, "message": "系统已经初始化完成", }) @@ -66,7 +66,7 @@ func PostSetup(c *gin.Context) { var req SetupRequest err := c.ShouldBindJSON(&req) if err != nil { - c.JSON(400, gin.H{ + c.JSON(200, gin.H{ "success": false, "message": "请求参数有误", }) @@ -77,7 +77,7 @@ func PostSetup(c *gin.Context) { if !rootExists { // Validate username length: max 12 characters to align with model.User validation if len(req.Username) > 12 { - c.JSON(400, gin.H{ + c.JSON(200, gin.H{ "success": false, "message": "用户名长度不能超过12个字符", }) @@ -85,7 +85,7 @@ func PostSetup(c *gin.Context) { } // Validate password if req.Password != req.ConfirmPassword { - c.JSON(400, gin.H{ + c.JSON(200, gin.H{ "success": false, "message": "两次输入的密码不一致", }) @@ -93,7 +93,7 @@ func PostSetup(c *gin.Context) { } if len(req.Password) < 8 { - c.JSON(400, gin.H{ + c.JSON(200, gin.H{ "success": false, "message": "密码长度至少为8个字符", }) @@ -103,7 +103,7 @@ func PostSetup(c *gin.Context) { // Create root user hashedPassword, err := common.Password2Hash(req.Password) if err != nil { - c.JSON(500, gin.H{ + c.JSON(200, gin.H{ "success": false, "message": "系统错误: " + err.Error(), }) @@ -120,7 +120,7 @@ func PostSetup(c *gin.Context) { } err = model.DB.Create(&rootUser).Error if err != nil { - c.JSON(500, gin.H{ + c.JSON(200, gin.H{ "success": false, "message": "创建管理员账号失败: " + err.Error(), }) @@ -135,7 +135,7 @@ func PostSetup(c *gin.Context) { // Save operation modes to database for persistence err = model.UpdateOption("SelfUseModeEnabled", boolToString(req.SelfUseModeEnabled)) if err != nil { - c.JSON(500, gin.H{ + c.JSON(200, gin.H{ "success": false, "message": "保存自用模式设置失败: " + err.Error(), }) @@ -144,7 +144,7 @@ func PostSetup(c *gin.Context) { err = model.UpdateOption("DemoSiteEnabled", boolToString(req.DemoSiteEnabled)) if err != nil { - c.JSON(500, gin.H{ + c.JSON(200, gin.H{ "success": false, "message": "保存演示站点模式设置失败: " + err.Error(), }) @@ -160,7 +160,7 @@ func PostSetup(c *gin.Context) { } err = model.DB.Create(&setup).Error if err != nil { - c.JSON(500, gin.H{ + c.JSON(200, gin.H{ "success": false, "message": "系统初始化失败: " + err.Error(), }) From b7bc609a7a837bd7902a6d014b90e398f066659a Mon Sep 17 00:00:00 2001 From: creamlike1024 Date: Tue, 16 Sep 2025 22:40:40 +0800 Subject: [PATCH 13/51] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=9F=9F?= =?UTF-8?q?=E5=90=8D=E5=92=8Cip=E8=BF=87=E6=BB=A4=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/download.go | 4 +- service/user_notify.go | 2 +- service/webhook.go | 2 +- setting/system_setting/fetch_setting.go | 14 ++- web/src/components/settings/SystemSetting.jsx | 114 +++++++++++++----- 5 files changed, 99 insertions(+), 37 deletions(-) diff --git a/service/download.go b/service/download.go index 2f30870d4..43b6fe7df 100644 --- a/service/download.go +++ b/service/download.go @@ -30,7 +30,7 @@ func DoWorkerRequest(req *WorkerRequest) (*http.Response, error) { // SSRF防护:验证请求URL fetchSetting := system_setting.GetFetchSetting() - if err := common.ValidateURLWithFetchSetting(req.URL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.WhitelistDomains, fetchSetting.WhitelistIps, fetchSetting.AllowedPorts); err != nil { + if err := common.ValidateURLWithFetchSetting(req.URL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil { return nil, fmt.Errorf("request reject: %v", err) } @@ -59,7 +59,7 @@ func DoDownloadRequest(originUrl string, reason ...string) (resp *http.Response, } else { // SSRF防护:验证请求URL(非Worker模式) fetchSetting := system_setting.GetFetchSetting() - if err := common.ValidateURLWithFetchSetting(originUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.WhitelistDomains, fetchSetting.WhitelistIps, fetchSetting.AllowedPorts); err != nil { + if err := common.ValidateURLWithFetchSetting(originUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil { return nil, fmt.Errorf("request reject: %v", err) } diff --git a/service/user_notify.go b/service/user_notify.go index f9d7b6691..1e9e8947c 100644 --- a/service/user_notify.go +++ b/service/user_notify.go @@ -115,7 +115,7 @@ func sendBarkNotify(barkURL string, data dto.Notify) error { } else { // SSRF防护:验证Bark URL(非Worker模式) fetchSetting := system_setting.GetFetchSetting() - if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.WhitelistDomains, fetchSetting.WhitelistIps, fetchSetting.AllowedPorts); err != nil { + if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil { return fmt.Errorf("request reject: %v", err) } diff --git a/service/webhook.go b/service/webhook.go index 1f159eb4b..5d9ce400a 100644 --- a/service/webhook.go +++ b/service/webhook.go @@ -89,7 +89,7 @@ func SendWebhookNotify(webhookURL string, secret string, data dto.Notify) error } else { // SSRF防护:验证Webhook URL(非Worker模式) fetchSetting := system_setting.GetFetchSetting() - if err := common.ValidateURLWithFetchSetting(webhookURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.WhitelistDomains, fetchSetting.WhitelistIps, fetchSetting.AllowedPorts); err != nil { + if err := common.ValidateURLWithFetchSetting(webhookURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil { return fmt.Errorf("request reject: %v", err) } diff --git a/setting/system_setting/fetch_setting.go b/setting/system_setting/fetch_setting.go index 6e47c3f06..5277e1033 100644 --- a/setting/system_setting/fetch_setting.go +++ b/setting/system_setting/fetch_setting.go @@ -5,16 +5,20 @@ import "one-api/setting/config" type FetchSetting struct { EnableSSRFProtection bool `json:"enable_ssrf_protection"` // 是否启用SSRF防护 AllowPrivateIp bool `json:"allow_private_ip"` - WhitelistDomains []string `json:"whitelist_domains"` // domain format, e.g. example.com, *.example.com - WhitelistIps []string `json:"whitelist_ips"` // CIDR format - AllowedPorts []string `json:"allowed_ports"` // port range format, e.g. 80, 443, 8000-9000 + DomainFilterMode bool `json:"domain_filter_mode"` // 域名过滤模式,true: 白名单模式,false: 黑名单模式 + IpFilterMode bool `json:"ip_filter_mode"` // IP过滤模式,true: 白名单模式,false: 黑名单模式 + DomainList []string `json:"domain_list"` // domain format, e.g. example.com, *.example.com + IpList []string `json:"ip_list"` // CIDR format + AllowedPorts []string `json:"allowed_ports"` // port range format, e.g. 80, 443, 8000-9000 } var defaultFetchSetting = FetchSetting{ EnableSSRFProtection: true, // 默认开启SSRF防护 AllowPrivateIp: false, - WhitelistDomains: []string{}, - WhitelistIps: []string{}, + DomainFilterMode: true, + IpFilterMode: true, + DomainList: []string{}, + IpList: []string{}, AllowedPorts: []string{"80", "443", "8080", "8443"}, } diff --git a/web/src/components/settings/SystemSetting.jsx b/web/src/components/settings/SystemSetting.jsx index 71dfaac8d..ebe4084be 100644 --- a/web/src/components/settings/SystemSetting.jsx +++ b/web/src/components/settings/SystemSetting.jsx @@ -29,6 +29,7 @@ import { TagInput, Spin, Card, + Radio, } from '@douyinfe/semi-ui'; const { Text } = Typography; import { @@ -91,8 +92,10 @@ const SystemSetting = () => { // SSRF防护配置 'fetch_setting.enable_ssrf_protection': true, 'fetch_setting.allow_private_ip': '', - 'fetch_setting.whitelist_domains': [], - 'fetch_setting.whitelist_ips': [], + 'fetch_setting.domain_filter_mode': true, // true 白名单,false 黑名单 + 'fetch_setting.ip_filter_mode': true, // true 白名单,false 黑名单 + 'fetch_setting.domain_list': [], + 'fetch_setting.ip_list': [], 'fetch_setting.allowed_ports': [], }); @@ -105,8 +108,10 @@ const SystemSetting = () => { useState(false); const [linuxDOOAuthEnabled, setLinuxDOOAuthEnabled] = useState(false); const [emailToAdd, setEmailToAdd] = useState(''); - const [whitelistDomains, setWhitelistDomains] = useState([]); - const [whitelistIps, setWhitelistIps] = useState([]); + const [domainFilterMode, setDomainFilterMode] = useState(true); + const [ipFilterMode, setIpFilterMode] = useState(true); + const [domainList, setDomainList] = useState([]); + const [ipList, setIpList] = useState([]); const [allowedPorts, setAllowedPorts] = useState([]); const getOptions = async () => { @@ -125,22 +130,24 @@ const SystemSetting = () => { break; case 'fetch_setting.allow_private_ip': case 'fetch_setting.enable_ssrf_protection': + case 'fetch_setting.domain_filter_mode': + case 'fetch_setting.ip_filter_mode': item.value = toBoolean(item.value); break; - case 'fetch_setting.whitelist_domains': + case 'fetch_setting.domain_list': try { const domains = item.value ? JSON.parse(item.value) : []; - setWhitelistDomains(Array.isArray(domains) ? domains : []); + setDomainList(Array.isArray(domains) ? domains : []); } catch (e) { - setWhitelistDomains([]); + setDomainList([]); } break; - case 'fetch_setting.whitelist_ips': + case 'fetch_setting.ip_list': try { const ips = item.value ? JSON.parse(item.value) : []; - setWhitelistIps(Array.isArray(ips) ? ips : []); + setIpList(Array.isArray(ips) ? ips : []); } catch (e) { - setWhitelistIps([]); + setIpList([]); } break; case 'fetch_setting.allowed_ports': @@ -178,6 +185,13 @@ const SystemSetting = () => { }); setInputs(newInputs); setOriginInputs(newInputs); + // 同步模式布尔到本地状态 + if (typeof newInputs['fetch_setting.domain_filter_mode'] !== 'undefined') { + setDomainFilterMode(!!newInputs['fetch_setting.domain_filter_mode']); + } + if (typeof newInputs['fetch_setting.ip_filter_mode'] !== 'undefined') { + setIpFilterMode(!!newInputs['fetch_setting.ip_filter_mode']); + } if (formApiRef.current) { formApiRef.current.setValues(newInputs); } @@ -317,19 +331,27 @@ const SystemSetting = () => { const submitSSRF = async () => { const options = []; - // 处理域名白名单 - if (Array.isArray(whitelistDomains)) { + // 处理域名过滤模式与列表 + options.push({ + key: 'fetch_setting.domain_filter_mode', + value: domainFilterMode, + }); + if (Array.isArray(domainList)) { options.push({ - key: 'fetch_setting.whitelist_domains', - value: JSON.stringify(whitelistDomains), + key: 'fetch_setting.domain_list', + value: JSON.stringify(domainList), }); } - // 处理IP白名单 - if (Array.isArray(whitelistIps)) { + // 处理IP过滤模式与列表 + options.push({ + key: 'fetch_setting.ip_filter_mode', + value: ipFilterMode, + }); + if (Array.isArray(ipList)) { options.push({ - key: 'fetch_setting.whitelist_ips', - value: JSON.stringify(whitelistIps), + key: 'fetch_setting.ip_list', + value: JSON.stringify(ipList), }); } @@ -702,25 +724,43 @@ const SystemSetting = () => { style={{ marginTop: 16 }} > - {t('域名白名单')} + + {t(domainFilterMode ? '域名白名单' : '域名黑名单')} + {t('支持通配符格式,如:example.com, *.api.example.com')} + { + const isWhitelist = val === 'whitelist'; + setDomainFilterMode(isWhitelist); + setInputs(prev => ({ + ...prev, + 'fetch_setting.domain_filter_mode': isWhitelist, + })); + }} + style={{ marginBottom: 8 }} + > + {t('白名单')} + {t('黑名单')} + { - setWhitelistDomains(value); + setDomainList(value); // 触发Form的onChange事件 setInputs(prev => ({ ...prev, - 'fetch_setting.whitelist_domains': value + 'fetch_setting.domain_list': value })); }} placeholder={t('输入域名后回车,如:example.com')} style={{ width: '100%' }} /> - {t('域名白名单详细说明')} + {t('域名过滤详细说明')} @@ -730,25 +770,43 @@ const SystemSetting = () => { style={{ marginTop: 16 }} > - {t('IP白名单')} + + {t(ipFilterMode ? 'IP白名单' : 'IP黑名单')} + {t('支持CIDR格式,如:8.8.8.8, 192.168.1.0/24')} + { + const isWhitelist = val === 'whitelist'; + setIpFilterMode(isWhitelist); + setInputs(prev => ({ + ...prev, + 'fetch_setting.ip_filter_mode': isWhitelist, + })); + }} + style={{ marginBottom: 8 }} + > + {t('白名单')} + {t('黑名单')} + { - setWhitelistIps(value); + setIpList(value); // 触发Form的onChange事件 setInputs(prev => ({ ...prev, - 'fetch_setting.whitelist_ips': value + 'fetch_setting.ip_list': value })); }} placeholder={t('输入IP地址后回车,如:8.8.8.8')} style={{ width: '100%' }} /> - {t('IP白名单详细说明')} + {t('IP过滤详细说明')} From 168ebb1cd42841b403e301c9cb7ff510dae83908 Mon Sep 17 00:00:00 2001 From: creamlike1024 Date: Wed, 17 Sep 2025 15:41:21 +0800 Subject: [PATCH 14/51] =?UTF-8?q?feat:=20ssrf=E6=94=AF=E6=8C=81=E5=9F=9F?= =?UTF-8?q?=E5=90=8D=E5=92=8Cip=E9=BB=91=E7=99=BD=E5=90=8D=E5=8D=95?= =?UTF-8?q?=E8=BF=87=E6=BB=A4=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/ssrf_protection.go | 199 ++++++++++++++------------------------ service/download.go | 4 +- service/user_notify.go | 2 +- service/webhook.go | 2 +- 4 files changed, 74 insertions(+), 133 deletions(-) diff --git a/common/ssrf_protection.go b/common/ssrf_protection.go index b0988d907..52b839525 100644 --- a/common/ssrf_protection.go +++ b/common/ssrf_protection.go @@ -11,16 +11,20 @@ import ( // SSRFProtection SSRF防护配置 type SSRFProtection struct { AllowPrivateIp bool - WhitelistDomains []string // domain format, e.g. example.com, *.example.com - WhitelistIps []string // CIDR format + DomainFilterMode bool // true: 白名单, false: 黑名单 + DomainList []string // domain format, e.g. example.com, *.example.com + IpFilterMode bool // true: 白名单, false: 黑名单 + IpList []string // CIDR or single IP AllowedPorts []int // 允许的端口范围 } // DefaultSSRFProtection 默认SSRF防护配置 var DefaultSSRFProtection = &SSRFProtection{ AllowPrivateIp: false, - WhitelistDomains: []string{}, - WhitelistIps: []string{}, + DomainFilterMode: true, + DomainList: []string{}, + IpFilterMode: true, + IpList: []string{}, AllowedPorts: []int{}, } @@ -138,44 +142,25 @@ func (p *SSRFProtection) isAllowedPort(port int) bool { return false } -// isAllowedPortFromRanges 从端口范围字符串检查端口是否被允许 -func isAllowedPortFromRanges(port int, portRanges []string) bool { - if len(portRanges) == 0 { - return true // 如果没有配置端口限制,则允许所有端口 - } - - allowedPorts, err := parsePortRanges(portRanges) - if err != nil { - // 如果解析失败,为安全起见拒绝访问 - return false - } - - for _, allowedPort := range allowedPorts { - if port == allowedPort { - return true - } - } - return false -} - // isDomainWhitelisted 检查域名是否在白名单中 -func (p *SSRFProtection) isDomainWhitelisted(domain string) bool { - if len(p.WhitelistDomains) == 0 { +func isDomainListed(domain string, list []string) bool { + if len(list) == 0 { return false } domain = strings.ToLower(domain) - for _, whitelistDomain := range p.WhitelistDomains { - whitelistDomain = strings.ToLower(whitelistDomain) - + for _, item := range list { + item = strings.ToLower(strings.TrimSpace(item)) + if item == "" { + continue + } // 精确匹配 - if domain == whitelistDomain { + if domain == item { return true } - // 通配符匹配 (*.example.com) - if strings.HasPrefix(whitelistDomain, "*.") { - suffix := strings.TrimPrefix(whitelistDomain, "*.") + if strings.HasPrefix(item, "*.") { + suffix := strings.TrimPrefix(item, "*.") if strings.HasSuffix(domain, "."+suffix) || domain == suffix { return true } @@ -184,13 +169,23 @@ func (p *SSRFProtection) isDomainWhitelisted(domain string) bool { return false } +func (p *SSRFProtection) isDomainAllowed(domain string) bool { + listed := isDomainListed(domain, p.DomainList) + if p.DomainFilterMode { // 白名单 + return listed + } + // 黑名单 + return !listed +} + // isIPWhitelisted 检查IP是否在白名单中 -func (p *SSRFProtection) isIPWhitelisted(ip net.IP) bool { - if len(p.WhitelistIps) == 0 { + +func isIPListed(ip net.IP, list []string) bool { + if len(list) == 0 { return false } - for _, whitelistCIDR := range p.WhitelistIps { + for _, whitelistCIDR := range list { _, network, err := net.ParseCIDR(whitelistCIDR) if err != nil { // 尝试作为单个IP处理 @@ -211,22 +206,17 @@ func (p *SSRFProtection) isIPWhitelisted(ip net.IP) bool { // IsIPAccessAllowed 检查IP是否允许访问 func (p *SSRFProtection) IsIPAccessAllowed(ip net.IP) bool { - // 如果IP在白名单中,直接允许访问(绕过私有IP检查) - if p.isIPWhitelisted(ip) { - return true + // 私有IP限制 + if isPrivateIP(ip) && !p.AllowPrivateIp { + return false } - // 如果IP白名单为空,允许所有IP(但仍需通过私有IP检查) - if len(p.WhitelistIps) == 0 { - // 检查私有IP限制 - if isPrivateIP(ip) && !p.AllowPrivateIp { - return false - } - return true + listed := isIPListed(ip, p.IpList) + if p.IpFilterMode { // 白名单 + return listed } - - // 如果IP白名单不为空且IP不在白名单中,拒绝访问 - return false + // 黑名单 + return !listed } // ValidateURL 验证URL是否安全 @@ -264,28 +254,44 @@ func (p *SSRFProtection) ValidateURL(urlStr string) error { return fmt.Errorf("port %d is not allowed", port) } - // 检查域名白名单 - if p.isDomainWhitelisted(host) { - return nil // 白名单域名直接通过 + // 如果 host 是 IP,则跳过域名检查 + if ip := net.ParseIP(host); ip != nil { + if !p.IsIPAccessAllowed(ip) { + if isPrivateIP(ip) { + return fmt.Errorf("private IP address not allowed: %s", ip.String()) + } + if p.IpFilterMode { + return fmt.Errorf("ip not in whitelist: %s", ip.String()) + } + return fmt.Errorf("ip in blacklist: %s", ip.String()) + } + return nil } - // DNS解析获取IP地址 + // 先进行域名过滤 + if !p.isDomainAllowed(host) { + if p.DomainFilterMode { + return fmt.Errorf("domain not in whitelist: %s", host) + } + return fmt.Errorf("domain in blacklist: %s", host) + } + + // 解析域名对应IP并检查 ips, err := net.LookupIP(host) if err != nil { return fmt.Errorf("DNS resolution failed for %s: %v", host, err) } - - // 检查所有解析的IP地址 for _, ip := range ips { if !p.IsIPAccessAllowed(ip) { - if isPrivateIP(ip) { + if isPrivateIP(ip) && !p.AllowPrivateIp { return fmt.Errorf("private IP address not allowed: %s resolves to %s", host, ip.String()) - } else { - return fmt.Errorf("IP address not in whitelist: %s resolves to %s", host, ip.String()) } + if p.IpFilterMode { + return fmt.Errorf("ip not in whitelist: %s resolves to %s", host, ip.String()) + } + return fmt.Errorf("ip in blacklist: %s resolves to %s", host, ip.String()) } } - return nil } @@ -295,7 +301,7 @@ func ValidateURLWithDefaults(urlStr string) error { } // ValidateURLWithFetchSetting 使用FetchSetting配置验证URL -func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPrivateIp bool, whitelistDomains, whitelistIps, allowedPorts []string) error { +func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPrivateIp bool, domainFilterMode bool, ipFilterMode bool, domainList, ipList, allowedPorts []string) error { // 如果SSRF防护被禁用,直接返回成功 if !enableSSRFProtection { return nil @@ -309,76 +315,11 @@ func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPriva protection := &SSRFProtection{ AllowPrivateIp: allowPrivateIp, - WhitelistDomains: whitelistDomains, - WhitelistIps: whitelistIps, + DomainFilterMode: domainFilterMode, + DomainList: domainList, + IpFilterMode: ipFilterMode, + IpList: ipList, AllowedPorts: allowedPortInts, } return protection.ValidateURL(urlStr) } - -// ValidateURLWithPortRanges 直接使用端口范围字符串验证URL(更高效的版本) -func ValidateURLWithPortRanges(urlStr string, allowPrivateIp bool, whitelistDomains, whitelistIps, allowedPorts []string) error { - // 解析URL - u, err := url.Parse(urlStr) - if err != nil { - return fmt.Errorf("invalid URL format: %v", err) - } - - // 只允许HTTP/HTTPS协议 - if u.Scheme != "http" && u.Scheme != "https" { - return fmt.Errorf("unsupported protocol: %s (only http/https allowed)", u.Scheme) - } - - // 解析主机和端口 - host, portStr, err := net.SplitHostPort(u.Host) - if err != nil { - // 没有端口,使用默认端口 - host = u.Host - if u.Scheme == "https" { - portStr = "443" - } else { - portStr = "80" - } - } - - // 验证端口 - port, err := strconv.Atoi(portStr) - if err != nil { - return fmt.Errorf("invalid port: %s", portStr) - } - - if !isAllowedPortFromRanges(port, allowedPorts) { - return fmt.Errorf("port %d is not allowed", port) - } - - // 创建临时的SSRFProtection来复用域名和IP检查逻辑 - protection := &SSRFProtection{ - AllowPrivateIp: allowPrivateIp, - WhitelistDomains: whitelistDomains, - WhitelistIps: whitelistIps, - } - - // 检查域名白名单 - if protection.isDomainWhitelisted(host) { - return nil // 白名单域名直接通过 - } - - // DNS解析获取IP地址 - ips, err := net.LookupIP(host) - if err != nil { - return fmt.Errorf("DNS resolution failed for %s: %v", host, err) - } - - // 检查所有解析的IP地址 - for _, ip := range ips { - if !protection.IsIPAccessAllowed(ip) { - if isPrivateIP(ip) { - return fmt.Errorf("private IP address not allowed: %s resolves to %s", host, ip.String()) - } else { - return fmt.Errorf("IP address not in whitelist: %s resolves to %s", host, ip.String()) - } - } - } - - return nil -} diff --git a/service/download.go b/service/download.go index 43b6fe7df..c07c9e1cd 100644 --- a/service/download.go +++ b/service/download.go @@ -30,7 +30,7 @@ func DoWorkerRequest(req *WorkerRequest) (*http.Response, error) { // SSRF防护:验证请求URL fetchSetting := system_setting.GetFetchSetting() - if err := common.ValidateURLWithFetchSetting(req.URL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil { + if err := common.ValidateURLWithFetchSetting(req.URL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil { return nil, fmt.Errorf("request reject: %v", err) } @@ -59,7 +59,7 @@ func DoDownloadRequest(originUrl string, reason ...string) (resp *http.Response, } else { // SSRF防护:验证请求URL(非Worker模式) fetchSetting := system_setting.GetFetchSetting() - if err := common.ValidateURLWithFetchSetting(originUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil { + if err := common.ValidateURLWithFetchSetting(originUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil { return nil, fmt.Errorf("request reject: %v", err) } diff --git a/service/user_notify.go b/service/user_notify.go index 1e9e8947c..76d15903d 100644 --- a/service/user_notify.go +++ b/service/user_notify.go @@ -115,7 +115,7 @@ func sendBarkNotify(barkURL string, data dto.Notify) error { } else { // SSRF防护:验证Bark URL(非Worker模式) fetchSetting := system_setting.GetFetchSetting() - if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil { + if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil { return fmt.Errorf("request reject: %v", err) } diff --git a/service/webhook.go b/service/webhook.go index 5d9ce400a..b7fd13df6 100644 --- a/service/webhook.go +++ b/service/webhook.go @@ -89,7 +89,7 @@ func SendWebhookNotify(webhookURL string, secret string, data dto.Notify) error } else { // SSRF防护:验证Webhook URL(非Worker模式) fetchSetting := system_setting.GetFetchSetting() - if err := common.ValidateURLWithFetchSetting(webhookURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil { + if err := common.ValidateURLWithFetchSetting(webhookURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil { return fmt.Errorf("request reject: %v", err) } From f635fc3ae6dfb7ad06effaf69309645f7c45650a Mon Sep 17 00:00:00 2001 From: creamlike1024 Date: Wed, 17 Sep 2025 23:29:18 +0800 Subject: [PATCH 15/51] feat: remove ValidateURLWithDefaults --- common/ssrf_protection.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/common/ssrf_protection.go b/common/ssrf_protection.go index 52b839525..e48ca0e08 100644 --- a/common/ssrf_protection.go +++ b/common/ssrf_protection.go @@ -295,11 +295,6 @@ func (p *SSRFProtection) ValidateURL(urlStr string) error { return nil } -// ValidateURLWithDefaults 使用默认配置验证URL -func ValidateURLWithDefaults(urlStr string) error { - return DefaultSSRFProtection.ValidateURL(urlStr) -} - // ValidateURLWithFetchSetting 使用FetchSetting配置验证URL func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPrivateIp bool, domainFilterMode bool, ipFilterMode bool, domainList, ipList, allowedPorts []string) error { // 如果SSRF防护被禁用,直接返回成功 From 467e58435959a812dd5132c5cafebfa89b55c9f3 Mon Sep 17 00:00:00 2001 From: creamlike1024 Date: Wed, 17 Sep 2025 23:46:04 +0800 Subject: [PATCH 16/51] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=9F=9F?= =?UTF-8?q?=E5=90=8D=E5=90=AF=E7=94=A8ip=E8=BF=87=E6=BB=A4=E5=BC=80?= =?UTF-8?q?=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/ssrf_protection.go | 33 +++++++++++-------- service/download.go | 4 +-- service/user_notify.go | 2 +- service/webhook.go | 2 +- setting/system_setting/fetch_setting.go | 30 +++++++++-------- web/src/components/settings/SystemSetting.jsx | 19 +++++++++-- 6 files changed, 57 insertions(+), 33 deletions(-) diff --git a/common/ssrf_protection.go b/common/ssrf_protection.go index e48ca0e08..40d3b10b8 100644 --- a/common/ssrf_protection.go +++ b/common/ssrf_protection.go @@ -10,12 +10,13 @@ import ( // SSRFProtection SSRF防护配置 type SSRFProtection struct { - AllowPrivateIp bool - DomainFilterMode bool // true: 白名单, false: 黑名单 - DomainList []string // domain format, e.g. example.com, *.example.com - IpFilterMode bool // true: 白名单, false: 黑名单 - IpList []string // CIDR or single IP - AllowedPorts []int // 允许的端口范围 + AllowPrivateIp bool + DomainFilterMode bool // true: 白名单, false: 黑名单 + DomainList []string // domain format, e.g. example.com, *.example.com + IpFilterMode bool // true: 白名单, false: 黑名单 + IpList []string // CIDR or single IP + AllowedPorts []int // 允许的端口范围 + ApplyIPFilterForDomain bool // 对域名启用IP过滤 } // DefaultSSRFProtection 默认SSRF防护配置 @@ -276,6 +277,11 @@ func (p *SSRFProtection) ValidateURL(urlStr string) error { return fmt.Errorf("domain in blacklist: %s", host) } + // 若未启用对域名应用IP过滤,则到此通过 + if !p.ApplyIPFilterForDomain { + return nil + } + // 解析域名对应IP并检查 ips, err := net.LookupIP(host) if err != nil { @@ -296,7 +302,7 @@ func (p *SSRFProtection) ValidateURL(urlStr string) error { } // ValidateURLWithFetchSetting 使用FetchSetting配置验证URL -func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPrivateIp bool, domainFilterMode bool, ipFilterMode bool, domainList, ipList, allowedPorts []string) error { +func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPrivateIp bool, domainFilterMode bool, ipFilterMode bool, domainList, ipList, allowedPorts []string, applyIPFilterForDomain bool) error { // 如果SSRF防护被禁用,直接返回成功 if !enableSSRFProtection { return nil @@ -309,12 +315,13 @@ func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPriva } protection := &SSRFProtection{ - AllowPrivateIp: allowPrivateIp, - DomainFilterMode: domainFilterMode, - DomainList: domainList, - IpFilterMode: ipFilterMode, - IpList: ipList, - AllowedPorts: allowedPortInts, + AllowPrivateIp: allowPrivateIp, + DomainFilterMode: domainFilterMode, + DomainList: domainList, + IpFilterMode: ipFilterMode, + IpList: ipList, + AllowedPorts: allowedPortInts, + ApplyIPFilterForDomain: applyIPFilterForDomain, } return protection.ValidateURL(urlStr) } diff --git a/service/download.go b/service/download.go index c07c9e1cd..036c43af8 100644 --- a/service/download.go +++ b/service/download.go @@ -30,7 +30,7 @@ func DoWorkerRequest(req *WorkerRequest) (*http.Response, error) { // SSRF防护:验证请求URL fetchSetting := system_setting.GetFetchSetting() - if err := common.ValidateURLWithFetchSetting(req.URL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil { + if err := common.ValidateURLWithFetchSetting(req.URL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { return nil, fmt.Errorf("request reject: %v", err) } @@ -59,7 +59,7 @@ func DoDownloadRequest(originUrl string, reason ...string) (resp *http.Response, } else { // SSRF防护:验证请求URL(非Worker模式) fetchSetting := system_setting.GetFetchSetting() - if err := common.ValidateURLWithFetchSetting(originUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil { + if err := common.ValidateURLWithFetchSetting(originUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { return nil, fmt.Errorf("request reject: %v", err) } diff --git a/service/user_notify.go b/service/user_notify.go index 76d15903d..fba12d9db 100644 --- a/service/user_notify.go +++ b/service/user_notify.go @@ -115,7 +115,7 @@ func sendBarkNotify(barkURL string, data dto.Notify) error { } else { // SSRF防护:验证Bark URL(非Worker模式) fetchSetting := system_setting.GetFetchSetting() - if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil { + if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { return fmt.Errorf("request reject: %v", err) } diff --git a/service/webhook.go b/service/webhook.go index b7fd13df6..c678b8634 100644 --- a/service/webhook.go +++ b/service/webhook.go @@ -89,7 +89,7 @@ func SendWebhookNotify(webhookURL string, secret string, data dto.Notify) error } else { // SSRF防护:验证Webhook URL(非Worker模式) fetchSetting := system_setting.GetFetchSetting() - if err := common.ValidateURLWithFetchSetting(webhookURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil { + if err := common.ValidateURLWithFetchSetting(webhookURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { return fmt.Errorf("request reject: %v", err) } diff --git a/setting/system_setting/fetch_setting.go b/setting/system_setting/fetch_setting.go index 5277e1033..3c7f1e059 100644 --- a/setting/system_setting/fetch_setting.go +++ b/setting/system_setting/fetch_setting.go @@ -3,23 +3,25 @@ package system_setting import "one-api/setting/config" type FetchSetting struct { - EnableSSRFProtection bool `json:"enable_ssrf_protection"` // 是否启用SSRF防护 - AllowPrivateIp bool `json:"allow_private_ip"` - DomainFilterMode bool `json:"domain_filter_mode"` // 域名过滤模式,true: 白名单模式,false: 黑名单模式 - IpFilterMode bool `json:"ip_filter_mode"` // IP过滤模式,true: 白名单模式,false: 黑名单模式 - DomainList []string `json:"domain_list"` // domain format, e.g. example.com, *.example.com - IpList []string `json:"ip_list"` // CIDR format - AllowedPorts []string `json:"allowed_ports"` // port range format, e.g. 80, 443, 8000-9000 + EnableSSRFProtection bool `json:"enable_ssrf_protection"` // 是否启用SSRF防护 + AllowPrivateIp bool `json:"allow_private_ip"` + DomainFilterMode bool `json:"domain_filter_mode"` // 域名过滤模式,true: 白名单模式,false: 黑名单模式 + IpFilterMode bool `json:"ip_filter_mode"` // IP过滤模式,true: 白名单模式,false: 黑名单模式 + DomainList []string `json:"domain_list"` // domain format, e.g. example.com, *.example.com + IpList []string `json:"ip_list"` // CIDR format + AllowedPorts []string `json:"allowed_ports"` // port range format, e.g. 80, 443, 8000-9000 + ApplyIPFilterForDomain bool `json:"apply_ip_filter_for_domain"` // 对域名启用IP过滤(实验性) } var defaultFetchSetting = FetchSetting{ - EnableSSRFProtection: true, // 默认开启SSRF防护 - AllowPrivateIp: false, - DomainFilterMode: true, - IpFilterMode: true, - DomainList: []string{}, - IpList: []string{}, - AllowedPorts: []string{"80", "443", "8080", "8443"}, + EnableSSRFProtection: true, // 默认开启SSRF防护 + AllowPrivateIp: false, + DomainFilterMode: true, + IpFilterMode: true, + DomainList: []string{}, + IpList: []string{}, + AllowedPorts: []string{"80", "443", "8080", "8443"}, + ApplyIPFilterForDomain: false, } func init() { diff --git a/web/src/components/settings/SystemSetting.jsx b/web/src/components/settings/SystemSetting.jsx index ebe4084be..a1d26a4ad 100644 --- a/web/src/components/settings/SystemSetting.jsx +++ b/web/src/components/settings/SystemSetting.jsx @@ -97,6 +97,7 @@ const SystemSetting = () => { 'fetch_setting.domain_list': [], 'fetch_setting.ip_list': [], 'fetch_setting.allowed_ports': [], + 'fetch_setting.apply_ip_filter_for_domain': false, }); const [originInputs, setOriginInputs] = useState({}); @@ -132,6 +133,7 @@ const SystemSetting = () => { case 'fetch_setting.enable_ssrf_protection': case 'fetch_setting.domain_filter_mode': case 'fetch_setting.ip_filter_mode': + case 'fetch_setting.apply_ip_filter_for_domain': item.value = toBoolean(item.value); break; case 'fetch_setting.domain_list': @@ -724,6 +726,17 @@ const SystemSetting = () => { style={{ marginTop: 16 }} > + + + handleCheckboxChange('fetch_setting.apply_ip_filter_for_domain', e) + } + style={{ marginBottom: 8 }} + > + {t('对域名启用 IP 过滤(实验性)')} + {t(domainFilterMode ? '域名白名单' : '域名黑名单')} @@ -734,7 +747,8 @@ const SystemSetting = () => { type='button' value={domainFilterMode ? 'whitelist' : 'blacklist'} onChange={(val) => { - const isWhitelist = val === 'whitelist'; + const selected = val && val.target ? val.target.value : val; + const isWhitelist = selected === 'whitelist'; setDomainFilterMode(isWhitelist); setInputs(prev => ({ ...prev, @@ -780,7 +794,8 @@ const SystemSetting = () => { type='button' value={ipFilterMode ? 'whitelist' : 'blacklist'} onChange={(val) => { - const isWhitelist = val === 'whitelist'; + const selected = val && val.target ? val.target.value : val; + const isWhitelist = selected === 'whitelist'; setIpFilterMode(isWhitelist); setInputs(prev => ({ ...prev, From 00f45940620ae0b25e7b74618aa2fafb2a2b6f85 Mon Sep 17 00:00:00 2001 From: creamlike1024 Date: Wed, 17 Sep 2025 23:47:59 +0800 Subject: [PATCH 17/51] fix: use u.Hostname() instead of u.Host to avoid ipv6 host parse failed --- common/ssrf_protection.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/ssrf_protection.go b/common/ssrf_protection.go index 40d3b10b8..6f7d289f1 100644 --- a/common/ssrf_protection.go +++ b/common/ssrf_protection.go @@ -237,7 +237,7 @@ func (p *SSRFProtection) ValidateURL(urlStr string) error { host, portStr, err := net.SplitHostPort(u.Host) if err != nil { // 没有端口,使用默认端口 - host = u.Host + host = u.Hostname() if u.Scheme == "https" { portStr = "443" } else { From 31c8ead1d45c2b3aa0cd28731be3d73eaf122cac Mon Sep 17 00:00:00 2001 From: creamlike1024 Date: Wed, 17 Sep 2025 23:54:34 +0800 Subject: [PATCH 18/51] =?UTF-8?q?feat:=20=E7=A7=BB=E9=99=A4=E5=A4=9A?= =?UTF-8?q?=E4=BD=99=E7=9A=84=E8=AF=B4=E6=98=8E=E6=96=87=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/settings/SystemSetting.jsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/web/src/components/settings/SystemSetting.jsx b/web/src/components/settings/SystemSetting.jsx index a1d26a4ad..3218cdf07 100644 --- a/web/src/components/settings/SystemSetting.jsx +++ b/web/src/components/settings/SystemSetting.jsx @@ -773,9 +773,6 @@ const SystemSetting = () => { placeholder={t('输入域名后回车,如:example.com')} style={{ width: '100%' }} /> - - {t('域名过滤详细说明')} - @@ -820,9 +817,6 @@ const SystemSetting = () => { placeholder={t('输入IP地址后回车,如:8.8.8.8')} style={{ width: '100%' }} /> - - {t('IP过滤详细说明')} - From 10da082412532682be054f9a1921a6d55284152b Mon Sep 17 00:00:00 2001 From: MyPrototypeWhat Date: Thu, 18 Sep 2025 12:01:35 +0800 Subject: [PATCH 19/51] refactor: Enhance UserArea dropdown positioning with useRef - Added useRef to manage dropdown positioning in UserArea component. - Wrapped Dropdown in a div with a ref to ensure correct popup container. - Minor adjustments to maintain existing functionality and styling. --- .../components/layout/headerbar/UserArea.jsx | 170 +++++++++--------- 1 file changed, 87 insertions(+), 83 deletions(-) diff --git a/web/src/components/layout/headerbar/UserArea.jsx b/web/src/components/layout/headerbar/UserArea.jsx index 8ea70f47f..9fc011da1 100644 --- a/web/src/components/layout/headerbar/UserArea.jsx +++ b/web/src/components/layout/headerbar/UserArea.jsx @@ -17,7 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React from 'react'; +import React, { useRef } from 'react'; import { Link } from 'react-router-dom'; import { Avatar, Button, Dropdown, Typography } from '@douyinfe/semi-ui'; import { ChevronDown } from 'lucide-react'; @@ -39,6 +39,7 @@ const UserArea = ({ navigate, t, }) => { + const dropdownRef = useRef(null); if (isLoading) { return ( - { - navigate('/console/personal'); - }} - className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white' - > -

- - {t('个人设置')} -
- - { - navigate('/console/token'); - }} - className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white' - > -
- - {t('令牌管理')} -
-
- { - navigate('/console/topup'); - }} - className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white' - > -
- - {t('钱包管理')} -
-
- -
- - {t('退出')} -
-
- - } - > - - + + {userState.user.username[0].toUpperCase()} + + + + {userState.user.username} + + + + + + ); } else { const showRegisterButton = !isSelfUseMode; From 50a432180dbe805c19664aaf7c4da7f646af1cd4 Mon Sep 17 00:00:00 2001 From: CaIon Date: Thu, 18 Sep 2025 13:40:52 +0800 Subject: [PATCH 20/51] feat: add experimental IP filtering for domains and update related settings --- setting/system_setting/fetch_setting.go | 4 ++-- web/src/components/settings/SystemSetting.jsx | 6 +++--- web/src/i18n/locales/en.json | 8 ++++++-- web/src/i18n/locales/zh.json | 3 ++- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/setting/system_setting/fetch_setting.go b/setting/system_setting/fetch_setting.go index 3c7f1e059..c41b930af 100644 --- a/setting/system_setting/fetch_setting.go +++ b/setting/system_setting/fetch_setting.go @@ -16,8 +16,8 @@ type FetchSetting struct { var defaultFetchSetting = FetchSetting{ EnableSSRFProtection: true, // 默认开启SSRF防护 AllowPrivateIp: false, - DomainFilterMode: true, - IpFilterMode: true, + DomainFilterMode: false, + IpFilterMode: false, DomainList: []string{}, IpList: []string{}, AllowedPorts: []string{"80", "443", "8080", "8443"}, diff --git a/web/src/components/settings/SystemSetting.jsx b/web/src/components/settings/SystemSetting.jsx index 3218cdf07..f9a2c019d 100644 --- a/web/src/components/settings/SystemSetting.jsx +++ b/web/src/components/settings/SystemSetting.jsx @@ -92,8 +92,8 @@ const SystemSetting = () => { // SSRF防护配置 'fetch_setting.enable_ssrf_protection': true, 'fetch_setting.allow_private_ip': '', - 'fetch_setting.domain_filter_mode': true, // true 白名单,false 黑名单 - 'fetch_setting.ip_filter_mode': true, // true 白名单,false 黑名单 + 'fetch_setting.domain_filter_mode': false, // true 白名单,false 黑名单 + 'fetch_setting.ip_filter_mode': false, // true 白名单,false 黑名单 'fetch_setting.domain_list': [], 'fetch_setting.ip_list': [], 'fetch_setting.allowed_ports': [], @@ -726,10 +726,10 @@ const SystemSetting = () => { style={{ marginTop: 16 }} > - handleCheckboxChange('fetch_setting.apply_ip_filter_for_domain', e) } diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 6759f53e8..0af06477a 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -2098,7 +2098,6 @@ "支持通配符格式,如:example.com, *.api.example.com": "Supports wildcard format, e.g.: example.com, *.api.example.com", "域名白名单详细说明": "Whitelisted domains bypass all SSRF checks and are allowed direct access. Supports exact domains (example.com) or wildcards (*.api.example.com) for subdomains. When whitelist is empty, all domains go through SSRF validation.", "输入域名后回车,如:example.com": "Enter domain and press Enter, e.g.: example.com", - "IP白名单": "IP Whitelist", "支持CIDR格式,如:8.8.8.8, 192.168.1.0/24": "Supports CIDR format, e.g.: 8.8.8.8, 192.168.1.0/24", "IP白名单详细说明": "Controls which IP addresses are allowed access. Use single IPs (8.8.8.8) or CIDR notation (192.168.1.0/24). Empty whitelist allows all IPs (subject to private IP settings), non-empty whitelist only allows listed IPs.", "输入IP地址后回车,如:8.8.8.8": "Enter IP address and press Enter, e.g.: 8.8.8.8", @@ -2106,5 +2105,10 @@ "支持单个端口和端口范围,如:80, 443, 8000-8999": "Supports single ports and port ranges, e.g.: 80, 443, 8000-8999", "端口配置详细说明": "Restrict external requests to specific ports. Use single ports (80, 443) or ranges (8000-8999). Empty list allows all ports. Default includes common web ports.", "输入端口后回车,如:80 或 8000-8999": "Enter port and press Enter, e.g.: 80 or 8000-8999", - "更新SSRF防护设置": "Update SSRF Protection Settings" + "更新SSRF防护设置": "Update SSRF Protection Settings", + "对域名启用 IP 过滤(实验性)": "Enable IP filtering for domains (experimental)", + "域名IP过滤详细说明": "⚠️ This is an experimental option. A domain may resolve to multiple IPv4/IPv6 addresses. If enabled, ensure the IP filter list covers these addresses, otherwise access may fail.", + "域名黑名单": "Domain Blacklist", + "白名单": "Whitelist", + "黑名单": "Blacklist" } diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 717770449..95fa06414 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -31,5 +31,6 @@ "支持单个端口和端口范围,如:80, 443, 8000-8999": "支持单个端口和端口范围,如:80, 443, 8000-8999", "端口配置详细说明": "限制外部请求只能访问指定端口。支持单个端口(80, 443)或端口范围(8000-8999)。空列表允许所有端口。默认包含常用Web端口。", "输入端口后回车,如:80 或 8000-8999": "输入端口后回车,如:80 或 8000-8999", - "更新SSRF防护设置": "更新SSRF防护设置" + "更新SSRF防护设置": "更新SSRF防护设置", + "域名IP过滤详细说明": "⚠️此功能为实验性选项,域名可能解析到多个 IPv4/IPv6 地址,若开启,请确保 IP 过滤列表覆盖这些地址,否则可能导致访问失败。" } From 4b98fceb6e07abbb537297450c12dec993518708 Mon Sep 17 00:00:00 2001 From: CaIon Date: Thu, 18 Sep 2025 13:53:58 +0800 Subject: [PATCH 21/51] CI --- .github/workflows/docker-image-arm64.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker-image-arm64.yml b/.github/workflows/docker-image-arm64.yml index 8e4656aa7..cabf3cec4 100644 --- a/.github/workflows/docker-image-arm64.yml +++ b/.github/workflows/docker-image-arm64.yml @@ -53,4 +53,5 @@ jobs: platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file + labels: ${{ steps.meta.outputs.labels }} + provenance: false \ No newline at end of file From d331f0fb2af5006227a970d9ba0e9d0ab6b157b7 Mon Sep 17 00:00:00 2001 From: Seefs Date: Thu, 18 Sep 2025 16:14:25 +0800 Subject: [PATCH 22/51] fix: kimi claude code --- relay/channel/moonshot/adaptor.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/relay/channel/moonshot/adaptor.go b/relay/channel/moonshot/adaptor.go index e290c239d..f24976bb3 100644 --- a/relay/channel/moonshot/adaptor.go +++ b/relay/channel/moonshot/adaptor.go @@ -25,7 +25,7 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt } func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) { - adaptor := openai.Adaptor{} + adaptor := claude.Adaptor{} return adaptor.ConvertClaudeRequest(c, info, req) } From 23ee0fc3b47b5606ca7f1ea5bb13f451b833ca9b Mon Sep 17 00:00:00 2001 From: Seefs Date: Thu, 18 Sep 2025 16:19:44 +0800 Subject: [PATCH 23/51] feat: deepseek claude endpoint --- relay/channel/deepseek/adaptor.go | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/relay/channel/deepseek/adaptor.go b/relay/channel/deepseek/adaptor.go index 17d732ab0..292c1e4bd 100644 --- a/relay/channel/deepseek/adaptor.go +++ b/relay/channel/deepseek/adaptor.go @@ -3,17 +3,17 @@ package deepseek import ( "errors" "fmt" + "github.com/gin-gonic/gin" "io" "net/http" "one-api/dto" "one-api/relay/channel" + "one-api/relay/channel/claude" "one-api/relay/channel/openai" relaycommon "one-api/relay/common" "one-api/relay/constant" "one-api/types" "strings" - - "github.com/gin-gonic/gin" ) type Adaptor struct { @@ -25,7 +25,7 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt } func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) { - adaptor := openai.Adaptor{} + adaptor := claude.Adaptor{} return adaptor.ConvertClaudeRequest(c, info, req) } @@ -44,14 +44,19 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) { func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { fimBaseUrl := info.ChannelBaseUrl - if !strings.HasSuffix(info.ChannelBaseUrl, "/beta") { - fimBaseUrl += "/beta" - } - switch info.RelayMode { - case constant.RelayModeCompletions: - return fmt.Sprintf("%s/completions", fimBaseUrl), nil + switch info.RelayFormat { + case types.RelayFormatClaude: + return fmt.Sprintf("%s/anthropic/v1/messages", info.ChannelBaseUrl), nil default: - return fmt.Sprintf("%s/v1/chat/completions", info.ChannelBaseUrl), nil + if !strings.HasSuffix(info.ChannelBaseUrl, "/beta") { + fimBaseUrl += "/beta" + } + switch info.RelayMode { + case constant.RelayModeCompletions: + return fmt.Sprintf("%s/completions", fimBaseUrl), nil + default: + return fmt.Sprintf("%s/v1/chat/completions", info.ChannelBaseUrl), nil + } } } From dd374cdd9b1fc59ac80a19ba83002d1711714e82 Mon Sep 17 00:00:00 2001 From: Seefs Date: Thu, 18 Sep 2025 16:32:29 +0800 Subject: [PATCH 24/51] feat: deepseek claude endpoint --- relay/channel/deepseek/adaptor.go | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/relay/channel/deepseek/adaptor.go b/relay/channel/deepseek/adaptor.go index 292c1e4bd..962f8794a 100644 --- a/relay/channel/deepseek/adaptor.go +++ b/relay/channel/deepseek/adaptor.go @@ -92,12 +92,17 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request } func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { - if info.IsStream { - usage, err = openai.OaiStreamHandler(c, info, resp) - } else { - usage, err = openai.OpenaiHandler(c, info, resp) + switch info.RelayFormat { + case types.RelayFormatClaude: + if info.IsStream { + return claude.ClaudeStreamHandler(c, resp, info, claude.RequestModeMessage) + } else { + return claude.ClaudeHandler(c, resp, info, claude.RequestModeMessage) + } + default: + adaptor := openai.Adaptor{} + return adaptor.DoResponse(c, resp, info) } - return } func (a *Adaptor) GetModelList() []string { From 9f1ab16aa5bd1e434da4571782d91dc940dc935c Mon Sep 17 00:00:00 2001 From: creamlike1024 Date: Fri, 19 Sep 2025 00:24:01 +0800 Subject: [PATCH 25/51] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=20gemini-embed?= =?UTF-8?q?ding-001?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- relay/channel/gemini/adaptor.go | 4 ++-- relay/helper/valid_request.go | 16 ++++++++++++++-- setting/ratio_setting/model_ratio.go | 1 + 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/relay/channel/gemini/adaptor.go b/relay/channel/gemini/adaptor.go index 4968f78fe..57542aa5a 100644 --- a/relay/channel/gemini/adaptor.go +++ b/relay/channel/gemini/adaptor.go @@ -215,8 +215,8 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { if info.RelayMode == constant.RelayModeGemini { - if strings.HasSuffix(info.RequestURLPath, ":embedContent") || - strings.HasSuffix(info.RequestURLPath, ":batchEmbedContents") { + if strings.Contains(info.RequestURLPath, ":embedContent") || + strings.Contains(info.RequestURLPath, ":batchEmbedContents") { return NativeGeminiEmbeddingHandler(c, resp, info) } if info.IsStream { diff --git a/relay/helper/valid_request.go b/relay/helper/valid_request.go index 4d1c1f9bb..f4a290ec6 100644 --- a/relay/helper/valid_request.go +++ b/relay/helper/valid_request.go @@ -21,7 +21,11 @@ func GetAndValidateRequest(c *gin.Context, format types.RelayFormat) (request dt case types.RelayFormatOpenAI: request, err = GetAndValidateTextRequest(c, relayMode) case types.RelayFormatGemini: - request, err = GetAndValidateGeminiRequest(c) + if strings.Contains(c.Request.URL.Path, ":embedContent") || strings.Contains(c.Request.URL.Path, ":batchEmbedContents") { + request, err = GetAndValidateGeminiEmbeddingRequest(c) + } else { + request, err = GetAndValidateGeminiRequest(c) + } case types.RelayFormatClaude: request, err = GetAndValidateClaudeRequest(c) case types.RelayFormatOpenAIResponses: @@ -288,7 +292,6 @@ func GetAndValidateTextRequest(c *gin.Context, relayMode int) (*dto.GeneralOpenA } func GetAndValidateGeminiRequest(c *gin.Context) (*dto.GeminiChatRequest, error) { - request := &dto.GeminiChatRequest{} err := common.UnmarshalBodyReusable(c, request) if err != nil { @@ -304,3 +307,12 @@ func GetAndValidateGeminiRequest(c *gin.Context) (*dto.GeminiChatRequest, error) return request, nil } + +func GetAndValidateGeminiEmbeddingRequest(c *gin.Context) (*dto.GeminiEmbeddingRequest, error) { + request := &dto.GeminiEmbeddingRequest{} + err := common.UnmarshalBodyReusable(c, request) + if err != nil { + return nil, err + } + return request, nil +} diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index f06cd71ef..9f11a3b74 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -178,6 +178,7 @@ var defaultModelRatio = map[string]float64{ "gemini-2.5-flash-lite-preview-thinking-*": 0.05, "gemini-2.5-flash-lite-preview-06-17": 0.05, "gemini-2.5-flash": 0.15, + "gemini-embedding-001": 0.075, "text-embedding-004": 0.001, "chatglm_turbo": 0.3572, // ¥0.005 / 1k tokens "chatglm_pro": 0.7143, // ¥0.01 / 1k tokens From ba632d0b4d43fc170716328dab586ef39980074b Mon Sep 17 00:00:00 2001 From: CaIon Date: Fri, 19 Sep 2025 14:20:35 +0800 Subject: [PATCH 26/51] CI --- .github/workflows/docker-image-arm64.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/docker-image-arm64.yml b/.github/workflows/docker-image-arm64.yml index cabf3cec4..8e4656aa7 100644 --- a/.github/workflows/docker-image-arm64.yml +++ b/.github/workflows/docker-image-arm64.yml @@ -53,5 +53,4 @@ jobs: platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - provenance: false \ No newline at end of file + labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file From 334ba555fcbd86877ab098d4a66c4526565ff613 Mon Sep 17 00:00:00 2001 From: CaIon Date: Fri, 19 Sep 2025 14:21:32 +0800 Subject: [PATCH 27/51] fix: cast option.Value to string for ratio updates --- controller/option.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/controller/option.go b/controller/option.go index 3e59c68e0..7d1c676f5 100644 --- a/controller/option.go +++ b/controller/option.go @@ -129,7 +129,7 @@ func UpdateOption(c *gin.Context) { return } case "ImageRatio": - err = ratio_setting.UpdateImageRatioByJSONString(option.Value) + err = ratio_setting.UpdateImageRatioByJSONString(option.Value.(string)) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, @@ -138,7 +138,7 @@ func UpdateOption(c *gin.Context) { return } case "AudioRatio": - err = ratio_setting.UpdateAudioRatioByJSONString(option.Value) + err = ratio_setting.UpdateAudioRatioByJSONString(option.Value.(string)) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, @@ -147,7 +147,7 @@ func UpdateOption(c *gin.Context) { return } case "AudioCompletionRatio": - err = ratio_setting.UpdateAudioCompletionRatioByJSONString(option.Value) + err = ratio_setting.UpdateAudioCompletionRatioByJSONString(option.Value.(string)) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, From d491cbd3d23c7becaedad489c1ba76a2b0fc5bdd Mon Sep 17 00:00:00 2001 From: CaIon Date: Fri, 19 Sep 2025 14:23:08 +0800 Subject: [PATCH 28/51] feat: update labels for ratio settings to clarify model support --- web/src/pages/Setting/Ratio/ModelRatioSettings.jsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx b/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx index b40951261..ed982edcf 100644 --- a/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx +++ b/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx @@ -225,8 +225,8 @@ export default function ModelRatioSettings(props) { Date: Mon, 1 Sep 2025 09:52:52 +0800 Subject: [PATCH 29/51] =?UTF-8?q?=E4=BF=AE=E5=A4=8D"=E8=BE=B9=E6=A0=8F"?= =?UTF-8?q?=E9=9A=90=E8=97=8F=E5=90=8E=E6=97=A0=E6=B3=95=E5=8D=B3=E6=97=B6?= =?UTF-8?q?=E7=94=9F=E6=95=88=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../personal/cards/NotificationSettings.jsx | 9 ++++++- web/src/hooks/common/useSidebar.js | 27 ++++++++++++++++--- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/web/src/components/settings/personal/cards/NotificationSettings.jsx b/web/src/components/settings/personal/cards/NotificationSettings.jsx index 0b097eaff..aad612d2c 100644 --- a/web/src/components/settings/personal/cards/NotificationSettings.jsx +++ b/web/src/components/settings/personal/cards/NotificationSettings.jsx @@ -44,6 +44,7 @@ import CodeViewer from '../../../playground/CodeViewer'; import { StatusContext } from '../../../../context/Status'; import { UserContext } from '../../../../context/User'; import { useUserPermissions } from '../../../../hooks/common/useUserPermissions'; +import { useSidebar } from '../../../../hooks/common/useSidebar'; const NotificationSettings = ({ t, @@ -97,6 +98,9 @@ const NotificationSettings = ({ isSidebarModuleAllowed, } = useUserPermissions(); + // 使用useSidebar钩子获取刷新方法 + const { refreshUserConfig } = useSidebar(); + // 左侧边栏设置处理函数 const handleSectionChange = (sectionKey) => { return (checked) => { @@ -132,6 +136,9 @@ const NotificationSettings = ({ }); if (res.data.success) { showSuccess(t('侧边栏设置保存成功')); + + // 刷新useSidebar钩子中的用户配置,实现实时更新 + await refreshUserConfig(); } else { showError(res.data.message); } @@ -334,7 +341,7 @@ const NotificationSettings = ({ loading={sidebarLoading} className='!rounded-lg' > - {t('保存边栏设置')} + {t('保存设置')} ) : ( diff --git a/web/src/hooks/common/useSidebar.js b/web/src/hooks/common/useSidebar.js index 5dce44f9e..e964855e3 100644 --- a/web/src/hooks/common/useSidebar.js +++ b/web/src/hooks/common/useSidebar.js @@ -21,6 +21,10 @@ import { useState, useEffect, useMemo, useContext } from 'react'; import { StatusContext } from '../../context/Status'; import { API } from '../../helpers'; +// 创建一个全局事件系统来同步所有useSidebar实例 +const sidebarEventTarget = new EventTarget(); +const SIDEBAR_REFRESH_EVENT = 'sidebar-refresh'; + export const useSidebar = () => { const [statusState] = useContext(StatusContext); const [userConfig, setUserConfig] = useState(null); @@ -124,9 +128,11 @@ export const useSidebar = () => { // 刷新用户配置的方法(供外部调用) const refreshUserConfig = async () => { - if (Object.keys(adminConfig).length > 0) { - await loadUserConfig(); - } + // 移除adminConfig的条件限制,直接刷新用户配置 + await loadUserConfig(); + + // 触发全局刷新事件,通知所有useSidebar实例更新 + sidebarEventTarget.dispatchEvent(new CustomEvent(SIDEBAR_REFRESH_EVENT)); }; // 加载用户配置 @@ -137,6 +143,21 @@ export const useSidebar = () => { } }, [adminConfig]); + // 监听全局刷新事件 + useEffect(() => { + const handleRefresh = () => { + if (Object.keys(adminConfig).length > 0) { + loadUserConfig(); + } + }; + + sidebarEventTarget.addEventListener(SIDEBAR_REFRESH_EVENT, handleRefresh); + + return () => { + sidebarEventTarget.removeEventListener(SIDEBAR_REFRESH_EVENT, handleRefresh); + }; + }, [adminConfig]); + // 计算最终的显示配置 const finalConfig = useMemo(() => { const result = {}; From f23be16e981c5940de59243d0155d8a80a28227e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=E3=80=82?= Date: Mon, 1 Sep 2025 10:20:15 +0800 Subject: [PATCH 30/51] =?UTF-8?q?=E4=BF=AE=E5=A4=8D"=E8=BE=B9=E6=A0=8F"?= =?UTF-8?q?=E6=9D=83=E9=99=90=E6=8E=A7=E5=88=B6=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/hooks/common/useSidebar.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/src/hooks/common/useSidebar.js b/web/src/hooks/common/useSidebar.js index e964855e3..13d76fd86 100644 --- a/web/src/hooks/common/useSidebar.js +++ b/web/src/hooks/common/useSidebar.js @@ -128,8 +128,9 @@ export const useSidebar = () => { // 刷新用户配置的方法(供外部调用) const refreshUserConfig = async () => { - // 移除adminConfig的条件限制,直接刷新用户配置 - await loadUserConfig(); + if (Object.keys(adminConfig).length > 0) { + await loadUserConfig(); + } // 触发全局刷新事件,通知所有useSidebar实例更新 sidebarEventTarget.dispatchEvent(new CustomEvent(SIDEBAR_REFRESH_EVENT)); From 1894ddc786cd0bde7a2affee3a88fb81e9567841 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=E3=80=82?= Date: Tue, 2 Sep 2025 18:10:08 +0800 Subject: [PATCH 31/51] =?UTF-8?q?=E6=94=B9=E8=BF=9B"=E4=BE=A7=E8=BE=B9?= =?UTF-8?q?=E6=A0=8F"=E6=9D=83=E9=99=90=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/auth/ModuleRoute.jsx | 200 ++++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 web/src/components/auth/ModuleRoute.jsx diff --git a/web/src/components/auth/ModuleRoute.jsx b/web/src/components/auth/ModuleRoute.jsx new file mode 100644 index 000000000..3f208c7fa --- /dev/null +++ b/web/src/components/auth/ModuleRoute.jsx @@ -0,0 +1,200 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useState, useEffect, useContext } from 'react'; +import { Navigate } from 'react-router-dom'; +import { StatusContext } from '../../context/Status'; +import Loading from '../common/ui/Loading'; +import { API } from '../../helpers'; + +/** + * ModuleRoute - 基于功能模块权限的路由保护组件 + * + * @param {Object} props + * @param {React.ReactNode} props.children - 要保护的子组件 + * @param {string} props.modulePath - 模块权限路径,如 "admin.channel", "console.token" + * @param {React.ReactNode} props.fallback - 无权限时显示的组件,默认跳转到 /forbidden + * @returns {React.ReactNode} + */ +const ModuleRoute = ({ children, modulePath, fallback = }) => { + const [hasPermission, setHasPermission] = useState(null); + const [statusState] = useContext(StatusContext); + + useEffect(() => { + checkModulePermission(); + }, [modulePath, statusState?.status]); // 只在status数据变化时重新检查 + + const checkModulePermission = async () => { + try { + // 检查用户是否已登录 + const user = localStorage.getItem('user'); + if (!user) { + setHasPermission(false); + return; + } + + const userData = JSON.parse(user); + const userRole = userData.role; + + // 超级管理员始终有权限 + if (userRole >= 100) { + setHasPermission(true); + return; + } + + // 检查模块权限 + const permission = await checkModulePermissionAPI(modulePath); + + // 如果返回null,表示status数据还未加载完成,保持loading状态 + if (permission === null) { + setHasPermission(null); + return; + } + + setHasPermission(permission); + } catch (error) { + console.error('检查模块权限失败:', error); + // 出错时采用安全优先策略,拒绝访问 + setHasPermission(false); + } + }; + + const checkModulePermissionAPI = async (modulePath) => { + try { + // 数据看板始终允许访问,不受控制台区域开关影响 + if (modulePath === 'console.detail') { + return true; + } + + // 从StatusContext中获取配置信息 + // 如果status数据还未加载完成,返回null表示需要等待 + if (!statusState?.status) { + return null; + } + + const user = JSON.parse(localStorage.getItem('user')); + const userRole = user.role; + + // 解析模块路径 + const pathParts = modulePath.split('.'); + if (pathParts.length < 2) { + return false; + } + + // 普通用户权限检查 + if (userRole < 10) { + return await isUserModuleAllowed(modulePath); + } + + // 超级管理员权限检查 - 不受系统配置限制 + if (userRole >= 100) { + return true; + } + + // 管理员权限检查 - 受系统配置限制 + if (userRole >= 10 && userRole < 100) { + // 从/api/user/self获取系统权限配置 + try { + const userRes = await API.get('/api/user/self'); + if (userRes.data.success && userRes.data.data.sidebar_config) { + const sidebarConfigData = userRes.data.data.sidebar_config; + // 管理员权限检查基于系统配置,不受用户偏好影响 + const systemConfig = sidebarConfigData.system || sidebarConfigData; + return checkModulePermissionInConfig(systemConfig, modulePath); + } else { + // 没有配置时,除了系统设置外都允许访问 + return modulePath !== 'admin.setting'; + } + } catch (error) { + console.error('获取侧边栏配置失败:', error); + return false; + } + } + + return false; + } catch (error) { + console.error('API权限检查失败:', error); + return false; + } + }; + + const isUserModuleAllowed = async (modulePath) => { + // 数据看板始终允许访问,不受控制台区域开关影响 + if (modulePath === 'console.detail') { + return true; + } + + // 普通用户的权限基于最终计算的配置 + try { + const userRes = await API.get('/api/user/self'); + if (userRes.data.success && userRes.data.data.sidebar_config) { + const sidebarConfigData = userRes.data.data.sidebar_config; + // 使用最终计算的配置进行权限检查 + const finalConfig = sidebarConfigData.final || sidebarConfigData; + return checkModulePermissionInConfig(finalConfig, modulePath); + } + return false; + } catch (error) { + console.error('获取用户权限配置失败:', error); + return false; + } + }; + + // 检查新的sidebar_config结构中的模块权限 + const checkModulePermissionInConfig = (sidebarConfig, modulePath) => { + const parts = modulePath.split('.'); + if (parts.length !== 2) { + return false; + } + + const [sectionKey, moduleKey] = parts; + const section = sidebarConfig[sectionKey]; + + // 检查区域是否存在且启用 + if (!section || !section.enabled) { + return false; + } + + // 检查模块是否启用 + const moduleValue = section[moduleKey]; + // 处理布尔值和嵌套对象两种情况 + if (typeof moduleValue === 'boolean') { + return moduleValue === true; + } else if (typeof moduleValue === 'object' && moduleValue !== null) { + // 对于嵌套对象,检查其enabled状态 + return moduleValue.enabled === true; + } + return false; + }; + + // 权限检查中 + if (hasPermission === null) { + return ; + } + + // 无权限 + if (!hasPermission) { + return fallback; + } + + // 有权限,渲染子组件 + return children; +}; + +export default ModuleRoute; From 3a98ae3f70c3b131f39909de25f20139441ba6ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=E3=80=82?= Date: Tue, 2 Sep 2025 19:26:30 +0800 Subject: [PATCH 32/51] =?UTF-8?q?=E6=94=B9=E8=BF=9B"=E4=BE=A7=E8=BE=B9?= =?UTF-8?q?=E6=A0=8F"=E6=9D=83=E9=99=90=E6=8E=A7=E5=88=B6-1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit d798db5953906aa5ff76cf6f2b641eb204d279b0. --- web/src/components/auth/ModuleRoute.jsx | 200 ------------------------ 1 file changed, 200 deletions(-) delete mode 100644 web/src/components/auth/ModuleRoute.jsx diff --git a/web/src/components/auth/ModuleRoute.jsx b/web/src/components/auth/ModuleRoute.jsx deleted file mode 100644 index 3f208c7fa..000000000 --- a/web/src/components/auth/ModuleRoute.jsx +++ /dev/null @@ -1,200 +0,0 @@ -/* -Copyright (C) 2025 QuantumNous - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . - -For commercial licensing, please contact support@quantumnous.com -*/ - -import React, { useState, useEffect, useContext } from 'react'; -import { Navigate } from 'react-router-dom'; -import { StatusContext } from '../../context/Status'; -import Loading from '../common/ui/Loading'; -import { API } from '../../helpers'; - -/** - * ModuleRoute - 基于功能模块权限的路由保护组件 - * - * @param {Object} props - * @param {React.ReactNode} props.children - 要保护的子组件 - * @param {string} props.modulePath - 模块权限路径,如 "admin.channel", "console.token" - * @param {React.ReactNode} props.fallback - 无权限时显示的组件,默认跳转到 /forbidden - * @returns {React.ReactNode} - */ -const ModuleRoute = ({ children, modulePath, fallback = }) => { - const [hasPermission, setHasPermission] = useState(null); - const [statusState] = useContext(StatusContext); - - useEffect(() => { - checkModulePermission(); - }, [modulePath, statusState?.status]); // 只在status数据变化时重新检查 - - const checkModulePermission = async () => { - try { - // 检查用户是否已登录 - const user = localStorage.getItem('user'); - if (!user) { - setHasPermission(false); - return; - } - - const userData = JSON.parse(user); - const userRole = userData.role; - - // 超级管理员始终有权限 - if (userRole >= 100) { - setHasPermission(true); - return; - } - - // 检查模块权限 - const permission = await checkModulePermissionAPI(modulePath); - - // 如果返回null,表示status数据还未加载完成,保持loading状态 - if (permission === null) { - setHasPermission(null); - return; - } - - setHasPermission(permission); - } catch (error) { - console.error('检查模块权限失败:', error); - // 出错时采用安全优先策略,拒绝访问 - setHasPermission(false); - } - }; - - const checkModulePermissionAPI = async (modulePath) => { - try { - // 数据看板始终允许访问,不受控制台区域开关影响 - if (modulePath === 'console.detail') { - return true; - } - - // 从StatusContext中获取配置信息 - // 如果status数据还未加载完成,返回null表示需要等待 - if (!statusState?.status) { - return null; - } - - const user = JSON.parse(localStorage.getItem('user')); - const userRole = user.role; - - // 解析模块路径 - const pathParts = modulePath.split('.'); - if (pathParts.length < 2) { - return false; - } - - // 普通用户权限检查 - if (userRole < 10) { - return await isUserModuleAllowed(modulePath); - } - - // 超级管理员权限检查 - 不受系统配置限制 - if (userRole >= 100) { - return true; - } - - // 管理员权限检查 - 受系统配置限制 - if (userRole >= 10 && userRole < 100) { - // 从/api/user/self获取系统权限配置 - try { - const userRes = await API.get('/api/user/self'); - if (userRes.data.success && userRes.data.data.sidebar_config) { - const sidebarConfigData = userRes.data.data.sidebar_config; - // 管理员权限检查基于系统配置,不受用户偏好影响 - const systemConfig = sidebarConfigData.system || sidebarConfigData; - return checkModulePermissionInConfig(systemConfig, modulePath); - } else { - // 没有配置时,除了系统设置外都允许访问 - return modulePath !== 'admin.setting'; - } - } catch (error) { - console.error('获取侧边栏配置失败:', error); - return false; - } - } - - return false; - } catch (error) { - console.error('API权限检查失败:', error); - return false; - } - }; - - const isUserModuleAllowed = async (modulePath) => { - // 数据看板始终允许访问,不受控制台区域开关影响 - if (modulePath === 'console.detail') { - return true; - } - - // 普通用户的权限基于最终计算的配置 - try { - const userRes = await API.get('/api/user/self'); - if (userRes.data.success && userRes.data.data.sidebar_config) { - const sidebarConfigData = userRes.data.data.sidebar_config; - // 使用最终计算的配置进行权限检查 - const finalConfig = sidebarConfigData.final || sidebarConfigData; - return checkModulePermissionInConfig(finalConfig, modulePath); - } - return false; - } catch (error) { - console.error('获取用户权限配置失败:', error); - return false; - } - }; - - // 检查新的sidebar_config结构中的模块权限 - const checkModulePermissionInConfig = (sidebarConfig, modulePath) => { - const parts = modulePath.split('.'); - if (parts.length !== 2) { - return false; - } - - const [sectionKey, moduleKey] = parts; - const section = sidebarConfig[sectionKey]; - - // 检查区域是否存在且启用 - if (!section || !section.enabled) { - return false; - } - - // 检查模块是否启用 - const moduleValue = section[moduleKey]; - // 处理布尔值和嵌套对象两种情况 - if (typeof moduleValue === 'boolean') { - return moduleValue === true; - } else if (typeof moduleValue === 'object' && moduleValue !== null) { - // 对于嵌套对象,检查其enabled状态 - return moduleValue.enabled === true; - } - return false; - }; - - // 权限检查中 - if (hasPermission === null) { - return ; - } - - // 无权限 - if (!hasPermission) { - return fallback; - } - - // 有权限,渲染子组件 - return children; -}; - -export default ModuleRoute; From 3c706170607815b4f081b346559b2822c052610c Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Tue, 16 Sep 2025 12:30:22 +0800 Subject: [PATCH 33/51] feat: vidu video support multi images --- relay/channel/task/vidu/adaptor.go | 10 ++-------- relay/common/relay_utils.go | 28 ++-------------------------- 2 files changed, 4 insertions(+), 34 deletions(-) diff --git a/relay/channel/task/vidu/adaptor.go b/relay/channel/task/vidu/adaptor.go index a1140d1e7..8974c6149 100644 --- a/relay/channel/task/vidu/adaptor.go +++ b/relay/channel/task/vidu/adaptor.go @@ -80,8 +80,7 @@ func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { } func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError { - // Use the unified validation method for TaskSubmitReq with image-based action determination - return relaycommon.ValidateTaskRequestWithImageBinding(c, info) + return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate) } func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, _ *relaycommon.RelayInfo) (io.Reader, error) { @@ -187,14 +186,9 @@ func (a *TaskAdaptor) GetChannelName() string { // ============================ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*requestPayload, error) { - var images []string - if req.Image != "" { - images = []string{req.Image} - } - r := requestPayload{ Model: defaultString(req.Model, "viduq1"), - Images: images, + Images: req.Images, Prompt: req.Prompt, Duration: defaultInt(req.Duration, 5), Resolution: defaultString(req.Size, "1080p"), diff --git a/relay/common/relay_utils.go b/relay/common/relay_utils.go index cf6d08dda..96d1370bd 100644 --- a/relay/common/relay_utils.go +++ b/relay/common/relay_utils.go @@ -79,34 +79,10 @@ func ValidateBasicTaskRequest(c *gin.Context, info *RelayInfo, action string) *d req.Images = []string{req.Image} } - storeTaskRequest(c, info, action, req) - return nil -} - -func ValidateTaskRequestWithImage(c *gin.Context, info *RelayInfo, requestObj interface{}) *dto.TaskError { - hasPrompt, ok := requestObj.(HasPrompt) - if !ok { - return createTaskError(fmt.Errorf("request must have prompt"), "invalid_request", http.StatusBadRequest, true) - } - - if taskErr := validatePrompt(hasPrompt.GetPrompt()); taskErr != nil { - return taskErr - } - - action := constant.TaskActionTextGenerate - if hasImage, ok := requestObj.(HasImage); ok && hasImage.HasImage() { + if req.HasImage() { action = constant.TaskActionGenerate } - storeTaskRequest(c, info, action, requestObj) + storeTaskRequest(c, info, action, req) return nil } - -func ValidateTaskRequestWithImageBinding(c *gin.Context, info *RelayInfo) *dto.TaskError { - var req TaskSubmitReq - if err := c.ShouldBindJSON(&req); err != nil { - return createTaskError(err, "invalid_request_body", http.StatusBadRequest, false) - } - - return ValidateTaskRequestWithImage(c, info, req) -} From 8f9960bcc7f93d40364b3a603907cd0215d39dac Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Fri, 19 Sep 2025 17:44:58 +0800 Subject: [PATCH 34/51] feat: vidu video add starEnd and reference gen video --- constant/task.go | 6 ++++-- relay/channel/task/vidu/adaptor.go | 4 ++++ relay/common/relay_utils.go | 8 ++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/constant/task.go b/constant/task.go index 21790145b..e174fd60e 100644 --- a/constant/task.go +++ b/constant/task.go @@ -11,8 +11,10 @@ const ( SunoActionMusic = "MUSIC" SunoActionLyrics = "LYRICS" - TaskActionGenerate = "generate" - TaskActionTextGenerate = "textGenerate" + TaskActionGenerate = "generate" + TaskActionTextGenerate = "textGenerate" + TaskActionFirstTailGenerate = "firstTailGenerate" + TaskActionReferenceGenerate = "referenceGenerate" ) var SunoModel2Action = map[string]string{ diff --git a/relay/channel/task/vidu/adaptor.go b/relay/channel/task/vidu/adaptor.go index 8974c6149..358aef583 100644 --- a/relay/channel/task/vidu/adaptor.go +++ b/relay/channel/task/vidu/adaptor.go @@ -111,6 +111,10 @@ func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, erro switch info.Action { case constant.TaskActionGenerate: path = "/img2video" + case constant.TaskActionFirstTailGenerate: + path = "/start-end2video" + case constant.TaskActionReferenceGenerate: + path = "/reference2video" default: path = "/text2video" } diff --git a/relay/common/relay_utils.go b/relay/common/relay_utils.go index 96d1370bd..3a721b479 100644 --- a/relay/common/relay_utils.go +++ b/relay/common/relay_utils.go @@ -81,6 +81,14 @@ func ValidateBasicTaskRequest(c *gin.Context, info *RelayInfo, action string) *d if req.HasImage() { action = constant.TaskActionGenerate + if info.ChannelType == constant.ChannelTypeVidu { + // vidu 增加 首尾帧生视频和参考图生视频 + if len(req.Images) == 2 { + action = constant.TaskActionFirstTailGenerate + } else if len(req.Images) > 2 { + action = constant.TaskActionReferenceGenerate + } + } } storeTaskRequest(c, info, action, req) From b73b16e1026bf65a0cbd1152a12735cfeab94ebb Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Fri, 19 Sep 2025 18:36:44 +0800 Subject: [PATCH 35/51] feat: vidu video add starEnd and reference gen video show type --- .../table/task-logs/TaskLogsColumnDefs.jsx | 21 ++++++++++++++++--- web/src/constants/common.constant.js | 2 ++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/web/src/components/table/task-logs/TaskLogsColumnDefs.jsx b/web/src/components/table/task-logs/TaskLogsColumnDefs.jsx index 766c17158..b63c7dd4f 100644 --- a/web/src/components/table/task-logs/TaskLogsColumnDefs.jsx +++ b/web/src/components/table/task-logs/TaskLogsColumnDefs.jsx @@ -35,8 +35,9 @@ import { Sparkles, } from 'lucide-react'; import { - TASK_ACTION_GENERATE, - TASK_ACTION_TEXT_GENERATE, + TASK_ACTION_FIRST_TAIL_GENERATE, + TASK_ACTION_GENERATE, TASK_ACTION_REFERENCE_GENERATE, + TASK_ACTION_TEXT_GENERATE } from '../../../constants/common.constant'; import { CHANNEL_OPTIONS } from '../../../constants/channel.constants'; @@ -111,6 +112,18 @@ const renderType = (type, t) => { {t('文生视频')} ); + case TASK_ACTION_FIRST_TAIL_GENERATE: + return ( + }> + {t('首尾生视频')} + + ); + case TASK_ACTION_REFERENCE_GENERATE: + return ( + }> + {t('参照生视频')} + + ); default: return ( }> @@ -343,7 +356,9 @@ export const getTaskLogsColumns = ({ // 仅当为视频生成任务且成功,且 fail_reason 是 URL 时显示可点击链接 const isVideoTask = record.action === TASK_ACTION_GENERATE || - record.action === TASK_ACTION_TEXT_GENERATE; + record.action === TASK_ACTION_TEXT_GENERATE || + record.action === TASK_ACTION_FIRST_TAIL_GENERATE || + record.action === TASK_ACTION_REFERENCE_GENERATE; const isSuccess = record.status === 'SUCCESS'; const isUrl = typeof text === 'string' && /^https?:\/\//.test(text); if (isSuccess && isVideoTask && isUrl) { diff --git a/web/src/constants/common.constant.js b/web/src/constants/common.constant.js index 277bb9a54..57fbbbde5 100644 --- a/web/src/constants/common.constant.js +++ b/web/src/constants/common.constant.js @@ -40,3 +40,5 @@ export const API_ENDPOINTS = [ export const TASK_ACTION_GENERATE = 'generate'; export const TASK_ACTION_TEXT_GENERATE = 'textGenerate'; +export const TASK_ACTION_FIRST_TAIL_GENERATE = 'firstTailGenerate'; +export const TASK_ACTION_REFERENCE_GENERATE = 'referenceGenerate'; From ea084e775e0b81efd948ee9e413f530817b34542 Mon Sep 17 00:00:00 2001 From: Seefs Date: Sat, 20 Sep 2025 00:22:54 +0800 Subject: [PATCH 36/51] fix: claude system prompt overwrite --- relay/claude_handler.go | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/relay/claude_handler.go b/relay/claude_handler.go index dbdc6ee1c..3c9272b62 100644 --- a/relay/claude_handler.go +++ b/relay/claude_handler.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "one-api/common" + "one-api/constant" "one-api/dto" relaycommon "one-api/relay/common" "one-api/relay/helper" @@ -69,6 +70,31 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ info.UpstreamModelName = request.Model } + if info.ChannelSetting.SystemPrompt != "" && info.ChannelSetting.SystemPromptOverride { + if request.System == nil { + request.SetStringSystem(info.ChannelSetting.SystemPrompt) + } else if info.ChannelSetting.SystemPromptOverride { + common.SetContextKey(c, constant.ContextKeySystemPromptOverride, true) + if request.IsStringSystem() { + existing := strings.TrimSpace(request.GetStringSystem()) + if existing == "" { + request.SetStringSystem(info.ChannelSetting.SystemPrompt) + } else { + request.SetStringSystem(info.ChannelSetting.SystemPrompt + "\n" + existing) + } + } else { + systemContents := request.ParseSystem() + newSystem := dto.ClaudeMediaMessage{Type: dto.ContentTypeText} + newSystem.SetText(info.ChannelSetting.SystemPrompt) + if len(systemContents) == 0 { + request.System = []dto.ClaudeMediaMessage{newSystem} + } else { + request.System = append([]dto.ClaudeMediaMessage{newSystem}, systemContents...) + } + } + } + } + var requestBody io.Reader if model_setting.GetGlobalSettings().PassThroughRequestEnabled || info.ChannelSetting.PassThroughBodyEnabled { body, err := common.GetRequestBody(c) From 2ffdf738bdb8f3e0a08b6c558cf4a4c01b71710b Mon Sep 17 00:00:00 2001 From: Zhaokun Zhang Date: Sat, 20 Sep 2025 11:09:28 +0800 Subject: [PATCH 37/51] fix: address copy functionality and code logic issues for #1828 - utils.jsx: Replace input with textarea in copy function to preserve line breaks in multi-line content, preventing formatting loss mentioned in #1828 - api.js: Fix duplicate 'group' property in buildApiPayload to resolve syntax issues - MarkdownRenderer.jsx: Refactor code text extraction using textContent for accurate copying Closes #1828 Signed-off-by: Zhaokun Zhang --- .../common/markdown/MarkdownRenderer.jsx | 4 ++-- web/src/helpers/api.js | 15 ++++++++------- web/src/helpers/utils.jsx | 18 +++++++++++------- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/web/src/components/common/markdown/MarkdownRenderer.jsx b/web/src/components/common/markdown/MarkdownRenderer.jsx index f1283a640..05419f8cc 100644 --- a/web/src/components/common/markdown/MarkdownRenderer.jsx +++ b/web/src/components/common/markdown/MarkdownRenderer.jsx @@ -181,8 +181,8 @@ export function PreCode(props) { e.preventDefault(); e.stopPropagation(); if (ref.current) { - const code = - ref.current.querySelector('code')?.innerText ?? ''; + const codeElement = ref.current.querySelector('code'); + const code = codeElement?.textContent ?? ''; copy(code).then((success) => { if (success) { Toast.success(t('代码已复制到剪贴板')); diff --git a/web/src/helpers/api.js b/web/src/helpers/api.js index b7092fe77..bc389b2e8 100644 --- a/web/src/helpers/api.js +++ b/web/src/helpers/api.js @@ -118,7 +118,6 @@ export const buildApiPayload = ( model: inputs.model, group: inputs.group, messages: processedMessages, - group: inputs.group, stream: inputs.stream, }; @@ -132,13 +131,15 @@ export const buildApiPayload = ( seed: 'seed', }; + Object.entries(parameterMappings).forEach(([key, param]) => { - if ( - parameterEnabled[key] && - inputs[param] !== undefined && - inputs[param] !== null - ) { - payload[param] = inputs[param]; + const enabled = parameterEnabled[key]; + const value = inputs[param]; + const hasValue = value !== undefined && value !== null; + + + if (enabled && hasValue) { + payload[param] = value; } }); diff --git a/web/src/helpers/utils.jsx b/web/src/helpers/utils.jsx index e446ea69d..bcd13230e 100644 --- a/web/src/helpers/utils.jsx +++ b/web/src/helpers/utils.jsx @@ -75,13 +75,17 @@ export async function copy(text) { await navigator.clipboard.writeText(text); } catch (e) { try { - // 构建input 执行 复制命令 - var _input = window.document.createElement('input'); - _input.value = text; - window.document.body.appendChild(_input); - _input.select(); - window.document.execCommand('Copy'); - window.document.body.removeChild(_input); + // 构建 textarea 执行复制命令,保留多行文本格式 + const textarea = window.document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + textarea.style.top = '-9999px'; + window.document.body.appendChild(textarea); + textarea.select(); + window.document.execCommand('copy'); + window.document.body.removeChild(textarea); } catch (e) { okay = false; console.error(e); From 1dd59f5d08d9bf479ff1feca320bab06955e43f7 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 20 Sep 2025 13:27:32 +0800 Subject: [PATCH 38/51] feat: add PromptCacheKey field to openai_request struct --- dto/openai_request.go | 1 + 1 file changed, 1 insertion(+) diff --git a/dto/openai_request.go b/dto/openai_request.go index cd05a63c9..53adb7f32 100644 --- a/dto/openai_request.go +++ b/dto/openai_request.go @@ -777,6 +777,7 @@ type OpenAIResponsesRequest struct { Reasoning *Reasoning `json:"reasoning,omitempty"` ServiceTier string `json:"service_tier,omitempty"` Store bool `json:"store,omitempty"` + PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"` Stream bool `json:"stream,omitempty"` Temperature float64 `json:"temperature,omitempty"` Text json.RawMessage `json:"text,omitempty"` From ec98a219334e5ec316be4bbfdb8dff997c79bd1c Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 20 Sep 2025 13:28:33 +0800 Subject: [PATCH 39/51] feat: change ParallelToolCalls and Store fields to json.RawMessage type --- dto/openai_request.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dto/openai_request.go b/dto/openai_request.go index 53adb7f32..191fa638f 100644 --- a/dto/openai_request.go +++ b/dto/openai_request.go @@ -772,11 +772,11 @@ type OpenAIResponsesRequest struct { Instructions json.RawMessage `json:"instructions,omitempty"` MaxOutputTokens uint `json:"max_output_tokens,omitempty"` Metadata json.RawMessage `json:"metadata,omitempty"` - ParallelToolCalls bool `json:"parallel_tool_calls,omitempty"` + ParallelToolCalls json.RawMessage `json:"parallel_tool_calls,omitempty"` PreviousResponseID string `json:"previous_response_id,omitempty"` Reasoning *Reasoning `json:"reasoning,omitempty"` ServiceTier string `json:"service_tier,omitempty"` - Store bool `json:"store,omitempty"` + Store json.RawMessage `json:"store,omitempty"` PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"` Stream bool `json:"stream,omitempty"` Temperature float64 `json:"temperature,omitempty"` From 8e7301b79ad16c6ea97104a3b37edb0f46ff4bd2 Mon Sep 17 00:00:00 2001 From: Seefs Date: Sat, 20 Sep 2025 13:38:44 +0800 Subject: [PATCH 40/51] fix: gemini system prompt overwrite --- relay/claude_handler.go | 2 +- relay/gemini_handler.go | 27 +++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/relay/claude_handler.go b/relay/claude_handler.go index 3c9272b62..59d12abe4 100644 --- a/relay/claude_handler.go +++ b/relay/claude_handler.go @@ -70,7 +70,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ info.UpstreamModelName = request.Model } - if info.ChannelSetting.SystemPrompt != "" && info.ChannelSetting.SystemPromptOverride { + if info.ChannelSetting.SystemPrompt != "" { if request.System == nil { request.SetStringSystem(info.ChannelSetting.SystemPrompt) } else if info.ChannelSetting.SystemPromptOverride { diff --git a/relay/gemini_handler.go b/relay/gemini_handler.go index 0252d6578..1410da606 100644 --- a/relay/gemini_handler.go +++ b/relay/gemini_handler.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "one-api/common" + "one-api/constant" "one-api/dto" "one-api/logger" "one-api/relay/channel/gemini" @@ -94,6 +95,32 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ adaptor.Init(info) + if info.ChannelSetting.SystemPrompt != "" { + if request.SystemInstructions == nil { + request.SystemInstructions = &dto.GeminiChatContent{ + Parts: []dto.GeminiPart{ + {Text: info.ChannelSetting.SystemPrompt}, + }, + } + } else if len(request.SystemInstructions.Parts) == 0 { + request.SystemInstructions.Parts = []dto.GeminiPart{{Text: info.ChannelSetting.SystemPrompt}} + } else if info.ChannelSetting.SystemPromptOverride { + common.SetContextKey(c, constant.ContextKeySystemPromptOverride, true) + merged := false + for i := range request.SystemInstructions.Parts { + if request.SystemInstructions.Parts[i].Text == "" { + continue + } + request.SystemInstructions.Parts[i].Text = info.ChannelSetting.SystemPrompt + "\n" + request.SystemInstructions.Parts[i].Text + merged = true + break + } + if !merged { + request.SystemInstructions.Parts = append([]dto.GeminiPart{{Text: info.ChannelSetting.SystemPrompt}}, request.SystemInstructions.Parts...) + } + } + } + // Clean up empty system instruction if request.SystemInstructions != nil { hasContent := false From 6c5181977d1b46dee11ea1d556b13c66aadd2458 Mon Sep 17 00:00:00 2001 From: huanghejian Date: Fri, 26 Sep 2025 15:32:59 +0800 Subject: [PATCH 41/51] feat: amazon nova model --- relay/channel/aws/constants.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/relay/channel/aws/constants.go b/relay/channel/aws/constants.go index 72d0f9890..3a28c95c8 100644 --- a/relay/channel/aws/constants.go +++ b/relay/channel/aws/constants.go @@ -21,6 +21,10 @@ var awsModelIDMap = map[string]string{ "nova-lite-v1:0": "amazon.nova-lite-v1:0", "nova-pro-v1:0": "amazon.nova-pro-v1:0", "nova-premier-v1:0": "amazon.nova-premier-v1:0", + "nova-canvas-v1:0": "amazon.nova-canvas-v1:0", + "nova-reel-v1:0": "amazon.nova-reel-v1:0", + "nova-reel-v1:1": "amazon.nova-reel-v1:1", + "nova-sonic-v1:0": "amazon.nova-sonic-v1:0", } var awsModelCanCrossRegionMap = map[string]map[string]bool{ From 127029d62d92ec3e4adeddf565e9e536420dc2f0 Mon Sep 17 00:00:00 2001 From: huanghejian Date: Fri, 26 Sep 2025 15:55:00 +0800 Subject: [PATCH 42/51] feat: amazon nova model --- relay/channel/aws/constants.go | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/relay/channel/aws/constants.go b/relay/channel/aws/constants.go index 3a28c95c8..5ac7ce998 100644 --- a/relay/channel/aws/constants.go +++ b/relay/channel/aws/constants.go @@ -86,10 +86,27 @@ var awsModelCanCrossRegionMap = map[string]map[string]bool{ "apac": true, }, "amazon.nova-premier-v1:0": { + "us": true, + }, + "amazon.nova-canvas-v1:0": { "us": true, "eu": true, "apac": true, - }} + }, + "amazon.nova-reel-v1:0": { + "us": true, + "eu": true, + "apac": true, + }, + "amazon.nova-reel-v1:1": { + "us": true, + }, + "amazon.nova-sonic-v1:0": { + "us": true, + "eu": true, + "apac": true, + }, +} var awsRegionCrossModelPrefixMap = map[string]string{ "us": "us", From c89c8a7396d2ef15ca2c65509307826bbb867dbe Mon Sep 17 00:00:00 2001 From: Seefs Date: Sat, 27 Sep 2025 00:15:28 +0800 Subject: [PATCH 43/51] fix: add missing fields to Gemini request --- dto/gemini.go | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/dto/gemini.go b/dto/gemini.go index 5df67ba0b..ad2ddb8be 100644 --- a/dto/gemini.go +++ b/dto/gemini.go @@ -14,7 +14,30 @@ type GeminiChatRequest struct { SafetySettings []GeminiChatSafetySettings `json:"safetySettings,omitempty"` GenerationConfig GeminiChatGenerationConfig `json:"generationConfig,omitempty"` Tools json.RawMessage `json:"tools,omitempty"` + ToolConfig *ToolConfig `json:"toolConfig,omitempty"` SystemInstructions *GeminiChatContent `json:"systemInstruction,omitempty"` + CachedContent string `json:"cachedContent,omitempty"` +} + +type ToolConfig struct { + FunctionCallingConfig *FunctionCallingConfig `json:"functionCallingConfig,omitempty"` + RetrievalConfig *RetrievalConfig `json:"retrievalConfig,omitempty"` +} + +type FunctionCallingConfig struct { + Mode FunctionCallingConfigMode `json:"mode,omitempty"` + AllowedFunctionNames []string `json:"allowedFunctionNames,omitempty"` +} +type FunctionCallingConfigMode string + +type RetrievalConfig struct { + LatLng *LatLng `json:"latLng,omitempty"` + LanguageCode string `json:"languageCode,omitempty"` +} + +type LatLng struct { + Latitude *float64 `json:"latitude,omitempty"` + Longitude *float64 `json:"longitude,omitempty"` } func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta { @@ -239,12 +262,20 @@ type GeminiChatGenerationConfig struct { StopSequences []string `json:"stopSequences,omitempty"` ResponseMimeType string `json:"responseMimeType,omitempty"` ResponseSchema any `json:"responseSchema,omitempty"` + ResponseJsonSchema any `json:"responseJsonSchema,omitempty"` + PresencePenalty *float32 `json:"presencePenalty,omitempty"` + FrequencyPenalty *float32 `json:"frequencyPenalty,omitempty"` + ResponseLogprobs bool `json:"responseLogprobs,omitempty"` + Logprobs *int32 `json:"logprobs,omitempty"` + MediaResolution MediaResolution `json:"mediaResolution,omitempty"` Seed int64 `json:"seed,omitempty"` ResponseModalities []string `json:"responseModalities,omitempty"` ThinkingConfig *GeminiThinkingConfig `json:"thinkingConfig,omitempty"` SpeechConfig json.RawMessage `json:"speechConfig,omitempty"` // RawMessage to allow flexible speech config } +type MediaResolution string + type GeminiChatCandidate struct { Content GeminiChatContent `json:"content"` FinishReason *string `json:"finishReason"` From 391d4514c0653ad75afaf922034ba3e83d718559 Mon Sep 17 00:00:00 2001 From: Seefs Date: Sat, 27 Sep 2025 00:24:29 +0800 Subject: [PATCH 44/51] fix: jsonRaw --- dto/gemini.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dto/gemini.go b/dto/gemini.go index ad2ddb8be..b1f7b9a41 100644 --- a/dto/gemini.go +++ b/dto/gemini.go @@ -261,8 +261,8 @@ type GeminiChatGenerationConfig struct { CandidateCount int `json:"candidateCount,omitempty"` StopSequences []string `json:"stopSequences,omitempty"` ResponseMimeType string `json:"responseMimeType,omitempty"` - ResponseSchema any `json:"responseSchema,omitempty"` - ResponseJsonSchema any `json:"responseJsonSchema,omitempty"` + ResponseSchema json.RawMessage `json:"responseSchema,omitempty"` + ResponseJsonSchema json.RawMessage `json:"responseJsonSchema,omitempty"` PresencePenalty *float32 `json:"presencePenalty,omitempty"` FrequencyPenalty *float32 `json:"frequencyPenalty,omitempty"` ResponseLogprobs bool `json:"responseLogprobs,omitempty"` From f4d95bf1c405d1d70615b3a001d957519523fc95 Mon Sep 17 00:00:00 2001 From: Seefs Date: Sat, 27 Sep 2025 00:33:05 +0800 Subject: [PATCH 45/51] fix: jsonRaw --- dto/gemini.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dto/gemini.go b/dto/gemini.go index b1f7b9a41..bc05c6aab 100644 --- a/dto/gemini.go +++ b/dto/gemini.go @@ -261,7 +261,7 @@ type GeminiChatGenerationConfig struct { CandidateCount int `json:"candidateCount,omitempty"` StopSequences []string `json:"stopSequences,omitempty"` ResponseMimeType string `json:"responseMimeType,omitempty"` - ResponseSchema json.RawMessage `json:"responseSchema,omitempty"` + ResponseSchema any `json:"responseSchema,omitempty"` ResponseJsonSchema json.RawMessage `json:"responseJsonSchema,omitempty"` PresencePenalty *float32 `json:"presencePenalty,omitempty"` FrequencyPenalty *float32 `json:"frequencyPenalty,omitempty"` From 4f05c8eafb8363e90f9810335a26519cad6e2017 Mon Sep 17 00:00:00 2001 From: RedwindA Date: Sat, 27 Sep 2025 01:19:09 +0800 Subject: [PATCH 46/51] =?UTF-8?q?feat:=20=E4=BB=85=E4=B8=BA=E9=80=82?= =?UTF-8?q?=E5=BD=93=E7=9A=84=E6=B8=A0=E9=81=93=E6=B8=B2=E6=9F=93=E8=8E=B7?= =?UTF-8?q?=E5=8F=96=E6=A8=A1=E5=9E=8B=E5=88=97=E8=A1=A8=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../channels/modals/EditChannelModal.jsx | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index c0a216246..967bf88a2 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -85,6 +85,26 @@ const REGION_EXAMPLE = { 'claude-3-5-sonnet-20240620': 'europe-west1', }; +// 支持并且已适配通过接口获取模型列表的渠道类型 +const MODEL_FETCHABLE_TYPES = new Set([ + 1, + 4, + 14, + 34, + 17, + 26, + 24, + 47, + 25, + 20, + 23, + 31, + 35, + 40, + 42, + 48, +]); + function type2secretPrompt(type) { // inputs.type === 15 ? '按照如下格式输入:APIKey|SecretKey' : (inputs.type === 18 ? '按照如下格式输入:APPID|APISecret|APIKey' : '请输入渠道对应的鉴权密钥') switch (type) { @@ -1872,13 +1892,15 @@ const EditChannelModal = (props) => { > {t('填入所有模型')} - + {MODEL_FETCHABLE_TYPES.has(inputs.type) && ( + + )} )} + handleDeleteKey(record.index)} + okType={'danger'} + position={'topRight'} + > + + ), }, diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index bceb5f089..b04698950 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1889,6 +1889,10 @@ "确定要删除所有已自动禁用的密钥吗?": "Are you sure you want to delete all automatically disabled keys?", "此操作不可撤销,将永久删除已自动禁用的密钥": "This operation cannot be undone, and all automatically disabled keys will be permanently deleted.", "删除自动禁用密钥": "Delete auto disabled keys", + "确定要删除此密钥吗?": "Are you sure you want to delete this key?", + "此操作不可撤销,将永久删除该密钥": "This operation cannot be undone, and the key will be permanently deleted.", + "密钥已删除": "Key has been deleted", + "删除密钥失败": "Failed to delete key", "图标": "Icon", "模型图标": "Model icon", "请输入图标名称": "Please enter the icon name", From 406be515dbd1bad2ea03fe262dda7ccdbfa116f3 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 27 Sep 2025 15:04:06 +0800 Subject: [PATCH 49/51] feat: rename output binaries to new-api for consistency across platforms --- .github/workflows/linux-release.yml | 4 ++-- .github/workflows/macos-release.yml | 2 +- .github/workflows/windows-release.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/linux-release.yml b/.github/workflows/linux-release.yml index c87fcfceb..953845fff 100644 --- a/.github/workflows/linux-release.yml +++ b/.github/workflows/linux-release.yml @@ -38,13 +38,13 @@ jobs: - name: Build Backend (amd64) run: | go mod download - go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o one-api + go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o new-api - name: Build Backend (arm64) run: | sudo apt-get update DEBIAN_FRONTEND=noninteractive sudo apt-get install -y gcc-aarch64-linux-gnu - CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o one-api-arm64 + CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o new-api-arm64 - name: Release uses: softprops/action-gh-release@v1 diff --git a/.github/workflows/macos-release.yml b/.github/workflows/macos-release.yml index 1bc786ac0..efaaa1074 100644 --- a/.github/workflows/macos-release.yml +++ b/.github/workflows/macos-release.yml @@ -39,7 +39,7 @@ jobs: - name: Build Backend run: | go mod download - go build -ldflags "-X 'one-api/common.Version=$(git describe --tags)'" -o one-api-macos + go build -ldflags "-X 'one-api/common.Version=$(git describe --tags)'" -o new-api-macos - name: Release uses: softprops/action-gh-release@v1 if: startsWith(github.ref, 'refs/tags/') diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml index de3d83d5e..1f4f63c84 100644 --- a/.github/workflows/windows-release.yml +++ b/.github/workflows/windows-release.yml @@ -41,7 +41,7 @@ jobs: - name: Build Backend run: | go mod download - go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)'" -o one-api.exe + go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)'" -o new-api.exe - name: Release uses: softprops/action-gh-release@v1 if: startsWith(github.ref, 'refs/tags/') From e9e9708d1e1390a533c4dd4a7e9c7d4ed454378d Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 27 Sep 2025 15:24:40 +0800 Subject: [PATCH 50/51] feat: update release configuration to use new-api binaries for consistency --- .github/workflows/linux-release.yml | 4 ++-- .github/workflows/macos-release.yml | 2 +- .github/workflows/windows-release.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/linux-release.yml b/.github/workflows/linux-release.yml index 953845fff..3e3ddc53b 100644 --- a/.github/workflows/linux-release.yml +++ b/.github/workflows/linux-release.yml @@ -51,8 +51,8 @@ jobs: if: startsWith(github.ref, 'refs/tags/') with: files: | - one-api - one-api-arm64 + new-api + new-api-arm64 draft: true generate_release_notes: true env: diff --git a/.github/workflows/macos-release.yml b/.github/workflows/macos-release.yml index efaaa1074..8eaf2d67a 100644 --- a/.github/workflows/macos-release.yml +++ b/.github/workflows/macos-release.yml @@ -44,7 +44,7 @@ jobs: uses: softprops/action-gh-release@v1 if: startsWith(github.ref, 'refs/tags/') with: - files: one-api-macos + files: new-api-macos draft: true generate_release_notes: true env: diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml index 1f4f63c84..30e864f34 100644 --- a/.github/workflows/windows-release.yml +++ b/.github/workflows/windows-release.yml @@ -46,7 +46,7 @@ jobs: uses: softprops/action-gh-release@v1 if: startsWith(github.ref, 'refs/tags/') with: - files: one-api.exe + files: new-api.exe draft: true generate_release_notes: true env: From ad72500941772ebdfde6dfc93b36083017240d94 Mon Sep 17 00:00:00 2001 From: Seefs Date: Sat, 27 Sep 2025 15:43:12 +0800 Subject: [PATCH 51/51] feat: allow stripe promotion code --- controller/topup_stripe.go | 3 ++- model/option.go | 3 +++ setting/payment_stripe.go | 1 + .../components/settings/PaymentSetting.jsx | 1 + web/src/i18n/locales/en.json | 1 + web/src/i18n/locales/zh.json | 3 ++- .../Payment/SettingsPaymentGatewayStripe.jsx | 24 +++++++++++++++++++ 7 files changed, 34 insertions(+), 2 deletions(-) diff --git a/controller/topup_stripe.go b/controller/topup_stripe.go index ccde91dbe..9a568d857 100644 --- a/controller/topup_stripe.go +++ b/controller/topup_stripe.go @@ -225,7 +225,8 @@ func genStripeLink(referenceId string, customerId string, email string, amount i Quantity: stripe.Int64(amount), }, }, - Mode: stripe.String(string(stripe.CheckoutSessionModePayment)), + Mode: stripe.String(string(stripe.CheckoutSessionModePayment)), + AllowPromotionCodes: stripe.Bool(setting.StripePromotionCodesEnabled), } if "" == customerId { diff --git a/model/option.go b/model/option.go index ceecff658..9ace8fece 100644 --- a/model/option.go +++ b/model/option.go @@ -82,6 +82,7 @@ func InitOptionMap() { common.OptionMap["StripeWebhookSecret"] = setting.StripeWebhookSecret common.OptionMap["StripePriceId"] = setting.StripePriceId common.OptionMap["StripeUnitPrice"] = strconv.FormatFloat(setting.StripeUnitPrice, 'f', -1, 64) + common.OptionMap["StripePromotionCodesEnabled"] = strconv.FormatBool(setting.StripePromotionCodesEnabled) common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString() common.OptionMap["Chats"] = setting.Chats2JsonString() common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString() @@ -330,6 +331,8 @@ func updateOptionMap(key string, value string) (err error) { setting.StripeUnitPrice, _ = strconv.ParseFloat(value, 64) case "StripeMinTopUp": setting.StripeMinTopUp, _ = strconv.Atoi(value) + case "StripePromotionCodesEnabled": + setting.StripePromotionCodesEnabled = value == "true" case "TopupGroupRatio": err = common.UpdateTopupGroupRatioByJSONString(value) case "GitHubClientId": diff --git a/setting/payment_stripe.go b/setting/payment_stripe.go index 80d877dfa..d97120c85 100644 --- a/setting/payment_stripe.go +++ b/setting/payment_stripe.go @@ -5,3 +5,4 @@ var StripeWebhookSecret = "" var StripePriceId = "" var StripeUnitPrice = 8.0 var StripeMinTopUp = 1 +var StripePromotionCodesEnabled = false diff --git a/web/src/components/settings/PaymentSetting.jsx b/web/src/components/settings/PaymentSetting.jsx index faaa9561b..220c86642 100644 --- a/web/src/components/settings/PaymentSetting.jsx +++ b/web/src/components/settings/PaymentSetting.jsx @@ -45,6 +45,7 @@ const PaymentSetting = () => { StripePriceId: '', StripeUnitPrice: 8.0, StripeMinTopUp: 1, + StripePromotionCodesEnabled: false, }); let [loading, setLoading] = useState(false); diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index ceb0f2d35..e935c10cc 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -837,6 +837,7 @@ "确定要充值 $": "Confirm to top up $", "微信/支付宝 实付金额:": "WeChat/Alipay actual payment amount:", "Stripe 实付金额:": "Stripe actual payment amount:", + "允许在 Stripe 支付中输入促销码": "Allow entering promotion codes during Stripe checkout", "支付中...": "Paying", "支付宝": "Alipay", "收益统计": "Income statistics", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 95fa06414..4b6b1e680 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -32,5 +32,6 @@ "端口配置详细说明": "限制外部请求只能访问指定端口。支持单个端口(80, 443)或端口范围(8000-8999)。空列表允许所有端口。默认包含常用Web端口。", "输入端口后回车,如:80 或 8000-8999": "输入端口后回车,如:80 或 8000-8999", "更新SSRF防护设置": "更新SSRF防护设置", - "域名IP过滤详细说明": "⚠️此功能为实验性选项,域名可能解析到多个 IPv4/IPv6 地址,若开启,请确保 IP 过滤列表覆盖这些地址,否则可能导致访问失败。" + "域名IP过滤详细说明": "⚠️此功能为实验性选项,域名可能解析到多个 IPv4/IPv6 地址,若开启,请确保 IP 过滤列表覆盖这些地址,否则可能导致访问失败。", + "允许在 Stripe 支付中输入促销码": "允许在 Stripe 支付中输入促销码" } diff --git a/web/src/pages/Setting/Payment/SettingsPaymentGatewayStripe.jsx b/web/src/pages/Setting/Payment/SettingsPaymentGatewayStripe.jsx index 2f4ea210e..e4ddea110 100644 --- a/web/src/pages/Setting/Payment/SettingsPaymentGatewayStripe.jsx +++ b/web/src/pages/Setting/Payment/SettingsPaymentGatewayStripe.jsx @@ -45,6 +45,7 @@ export default function SettingsPaymentGateway(props) { StripePriceId: '', StripeUnitPrice: 8.0, StripeMinTopUp: 1, + StripePromotionCodesEnabled: false, }); const [originInputs, setOriginInputs] = useState({}); const formApiRef = useRef(null); @@ -63,6 +64,10 @@ export default function SettingsPaymentGateway(props) { props.options.StripeMinTopUp !== undefined ? parseFloat(props.options.StripeMinTopUp) : 1, + StripePromotionCodesEnabled: + props.options.StripePromotionCodesEnabled !== undefined + ? props.options.StripePromotionCodesEnabled + : false, }; setInputs(currentInputs); setOriginInputs({ ...currentInputs }); @@ -114,6 +119,16 @@ export default function SettingsPaymentGateway(props) { value: inputs.StripeMinTopUp.toString(), }); } + if ( + originInputs['StripePromotionCodesEnabled'] !== + inputs.StripePromotionCodesEnabled && + inputs.StripePromotionCodesEnabled !== undefined + ) { + options.push({ + key: 'StripePromotionCodesEnabled', + value: inputs.StripePromotionCodesEnabled ? 'true' : 'false', + }); + } // 发送请求 const requestQueue = options.map((opt) => @@ -225,6 +240,15 @@ export default function SettingsPaymentGateway(props) { placeholder={t('例如:2,就是最低充值2$')} /> + + +