从 QuantumNous/new-api 上游移植以下修复: - fix(ssrf): 加固视频代理和 MJ 图片代理的 SSRF 防护,FetchModels 提权至 RootAuth - fix(claude): 流式断流时不再整份覆盖 usage,保留 cache 计费字段 - fix(pricing): 定价接口增加用户可用分组过滤 - fix(ratio): compact 模型优先匹配精确定价再回退通配符 - fix(ali): 接受 task polling 返回的字符串类型 usage 值 - fix(gemini): 从 URL 路径 :streamGenerateContent 检测流式请求 - fix(zhipu_4v): coding plan 图片生成使用正确的 OpenAI endpoint - fix(openai): MessageImageUrl.Detail 字段添加 omitempty - fix(channel-affinity): skip_retry_on_failure 配置项为 false 时不再丢失 - fix(document-renderer): 使用 useMemo 替代 useState 处理 HTML payload - fix(fetch-setting): ApplyIPFilterForDomain 默认值改为 true Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>feat/alipay-payment
| @@ -1,6 +1,7 @@ | |||||
| package controller | package controller | ||||
| import ( | import ( | ||||
| "github.com/QuantumNous/new-api/common" | |||||
| "github.com/QuantumNous/new-api/model" | "github.com/QuantumNous/new-api/model" | ||||
| "github.com/QuantumNous/new-api/service" | "github.com/QuantumNous/new-api/service" | ||||
| "github.com/QuantumNous/new-api/setting/ratio_setting" | "github.com/QuantumNous/new-api/setting/ratio_setting" | ||||
| @@ -8,6 +9,30 @@ import ( | |||||
| "github.com/gin-gonic/gin" | "github.com/gin-gonic/gin" | ||||
| ) | ) | ||||
| func filterPricingByUsableGroups(pricing []model.Pricing, usableGroup map[string]string) []model.Pricing { | |||||
| if len(pricing) == 0 { | |||||
| return pricing | |||||
| } | |||||
| if len(usableGroup) == 0 { | |||||
| return []model.Pricing{} | |||||
| } | |||||
| filtered := make([]model.Pricing, 0, len(pricing)) | |||||
| for _, item := range pricing { | |||||
| if common.StringsContains(item.EnableGroup, "all") { | |||||
| filtered = append(filtered, item) | |||||
| continue | |||||
| } | |||||
| for _, group := range item.EnableGroup { | |||||
| if _, ok := usableGroup[group]; ok { | |||||
| filtered = append(filtered, item) | |||||
| break | |||||
| } | |||||
| } | |||||
| } | |||||
| return filtered | |||||
| } | |||||
| func GetPricing(c *gin.Context) { | func GetPricing(c *gin.Context) { | ||||
| pricing := model.GetPricing() | pricing := model.GetPricing() | ||||
| userId, exists := c.Get("id") | userId, exists := c.Get("id") | ||||
| @@ -31,6 +56,7 @@ func GetPricing(c *gin.Context) { | |||||
| } | } | ||||
| usableGroup = service.GetUserUsableGroups(group) | usableGroup = service.GetUserUsableGroups(group) | ||||
| pricing = filterPricingByUsableGroups(pricing, usableGroup) | |||||
| // check groupRatio contains usableGroup | // check groupRatio contains usableGroup | ||||
| for group := range ratio_setting.GetGroupRatioCopy() { | for group := range ratio_setting.GetGroupRatioCopy() { | ||||
| if _, ok := usableGroup[group]; !ok { | if _, ok := usableGroup[group]; !ok { | ||||
| @@ -8,10 +8,12 @@ import ( | |||||
| "net/url" | "net/url" | ||||
| "time" | "time" | ||||
| "github.com/QuantumNous/new-api/common" | |||||
| "github.com/QuantumNous/new-api/constant" | "github.com/QuantumNous/new-api/constant" | ||||
| "github.com/QuantumNous/new-api/logger" | "github.com/QuantumNous/new-api/logger" | ||||
| "github.com/QuantumNous/new-api/model" | "github.com/QuantumNous/new-api/model" | ||||
| "github.com/QuantumNous/new-api/service" | "github.com/QuantumNous/new-api/service" | ||||
| "github.com/QuantumNous/new-api/setting/system_setting" | |||||
| "github.com/gin-gonic/gin" | "github.com/gin-gonic/gin" | ||||
| ) | ) | ||||
| @@ -109,6 +111,13 @@ func VideoProxy(c *gin.Context) { | |||||
| return | return | ||||
| } | } | ||||
| fetchSetting := system_setting.GetFetchSetting() | |||||
| if err := common.ValidateURLWithFetchSetting(videoURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { | |||||
| logger.LogError(c.Request.Context(), fmt.Sprintf("Video URL blocked for task %s: %v", taskID, err)) | |||||
| videoProxyError(c, http.StatusForbidden, "server_error", fmt.Sprintf("request blocked: %v", err)) | |||||
| return | |||||
| } | |||||
| resp, err := client.Do(req) | resp, err := client.Do(req) | ||||
| if err != nil { | if err != nil { | ||||
| logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to fetch video from %s: %s", videoURL, err.Error())) | logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to fetch video from %s: %s", videoURL, err.Error())) | ||||
| @@ -121,6 +121,11 @@ func (r *GeminiChatRequest) IsStream(c *gin.Context) bool { | |||||
| if c.Query("alt") == "sse" { | if c.Query("alt") == "sse" { | ||||
| return true | return true | ||||
| } | } | ||||
| // Native Gemini API uses URL action to indicate streaming: | |||||
| // /v1beta/models/{model}:streamGenerateContent | |||||
| if strings.Contains(c.Request.URL.Path, "streamGenerateContent") { | |||||
| return true | |||||
| } | |||||
| return false | return false | ||||
| } | } | ||||
| @@ -387,7 +387,7 @@ func (m *MediaContent) GetVideoUrl() *MessageVideoUrl { | |||||
| type MessageImageUrl struct { | type MessageImageUrl struct { | ||||
| Url string `json:"url"` | Url string `json:"url"` | ||||
| Detail string `json:"detail"` | |||||
| Detail string `json:"detail,omitempty"` | |||||
| MimeType string | MimeType string | ||||
| } | } | ||||
| @@ -743,7 +743,16 @@ func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, clau | |||||
| if common.DebugEnabled { | if common.DebugEnabled { | ||||
| common.SysLog("claude response usage is not complete, maybe upstream error") | common.SysLog("claude response usage is not complete, maybe upstream error") | ||||
| } | } | ||||
| claudeInfo.Usage = service.ResponseText2Usage(c, claudeInfo.ResponseText.String(), info.UpstreamModelName, claudeInfo.Usage.PromptTokens) | |||||
| // 只补缺失字段,不整份覆盖——保留 message_start 已拿到的 cache 字段 | |||||
| fallback := service.ResponseText2Usage(c, claudeInfo.ResponseText.String(), info.UpstreamModelName, info.GetEstimatePromptTokens()) | |||||
| if claudeInfo.Usage.CompletionTokens == 0 || | |||||
| (!claudeInfo.Done && fallback.CompletionTokens > claudeInfo.Usage.CompletionTokens) { | |||||
| claudeInfo.Usage.CompletionTokens = fallback.CompletionTokens | |||||
| } | |||||
| if claudeInfo.Usage.PromptTokens == 0 { | |||||
| claudeInfo.Usage.PromptTokens = fallback.PromptTokens | |||||
| } | |||||
| claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeInfo.Usage.CompletionTokens | |||||
| } | } | ||||
| if info.RelayFormat == types.RelayFormatClaude { | if info.RelayFormat == types.RelayFormatClaude { | ||||
| @@ -80,9 +80,9 @@ type AliVideoOutput struct { | |||||
| // AliUsage 使用统计 | // AliUsage 使用统计 | ||||
| type AliUsage struct { | type AliUsage struct { | ||||
| Duration int `json:"duration,omitempty"` | |||||
| VideoCount int `json:"video_count,omitempty"` | |||||
| SR int `json:"SR,omitempty"` | |||||
| Duration dto.IntValue `json:"duration,omitempty"` | |||||
| VideoCount dto.IntValue `json:"video_count,omitempty"` | |||||
| SR dto.IntValue `json:"SR,omitempty"` | |||||
| } | } | ||||
| type AliMetadata struct { | type AliMetadata struct { | ||||
| @@ -63,6 +63,9 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { | |||||
| } | } | ||||
| return fmt.Sprintf("%s/api/paas/v4/embeddings", baseURL), nil | return fmt.Sprintf("%s/api/paas/v4/embeddings", baseURL), nil | ||||
| case relayconstant.RelayModeImagesGenerations: | case relayconstant.RelayModeImagesGenerations: | ||||
| if hasSpecialPlan && specialPlan.OpenAIBaseURL != "" { | |||||
| return fmt.Sprintf("%s/images/generations", specialPlan.OpenAIBaseURL), nil | |||||
| } | |||||
| return fmt.Sprintf("%s/api/paas/v4/images/generations", baseURL), nil | return fmt.Sprintf("%s/api/paas/v4/images/generations", baseURL), nil | ||||
| default: | default: | ||||
| if hasSpecialPlan && specialPlan.OpenAIBaseURL != "" { | if hasSpecialPlan && specialPlan.OpenAIBaseURL != "" { | ||||
| @@ -49,6 +49,13 @@ func RelayMidjourneyImage(c *gin.Context) { | |||||
| if httpClient == nil { | if httpClient == nil { | ||||
| httpClient = service.GetHttpClient() | httpClient = service.GetHttpClient() | ||||
| } | } | ||||
| fetchSetting := system_setting.GetFetchSetting() | |||||
| if err := common.ValidateURLWithFetchSetting(midjourneyTask.ImageUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { | |||||
| c.JSON(http.StatusForbidden, gin.H{ | |||||
| "error": fmt.Sprintf("request blocked: %v", err), | |||||
| }) | |||||
| return | |||||
| } | |||||
| resp, err := httpClient.Get(midjourneyTask.ImageUrl) | resp, err := httpClient.Get(midjourneyTask.ImageUrl) | ||||
| if err != nil { | if err != nil { | ||||
| c.JSON(http.StatusInternalServerError, gin.H{ | c.JSON(http.StatusInternalServerError, gin.H{ | ||||
| @@ -246,7 +246,7 @@ func SetApiRouter(router *gin.Engine) { | |||||
| channelRoute.POST("/batch", controller.DeleteChannelBatch) | channelRoute.POST("/batch", controller.DeleteChannelBatch) | ||||
| channelRoute.POST("/fix", controller.FixChannelsAbilities) | channelRoute.POST("/fix", controller.FixChannelsAbilities) | ||||
| channelRoute.GET("/fetch_models/:id", controller.FetchUpstreamModels) | channelRoute.GET("/fetch_models/:id", controller.FetchUpstreamModels) | ||||
| channelRoute.POST("/fetch_models", controller.FetchModels) | |||||
| channelRoute.POST("/fetch_models", middleware.RootAuth(), controller.FetchModels) | |||||
| channelRoute.POST("/codex/oauth/start", controller.StartCodexOAuth) | channelRoute.POST("/codex/oauth/start", controller.StartCodexOAuth) | ||||
| channelRoute.POST("/codex/oauth/complete", controller.CompleteCodexOAuth) | channelRoute.POST("/codex/oauth/complete", controller.CompleteCodexOAuth) | ||||
| channelRoute.POST("/:id/codex/oauth/start", controller.StartCodexOAuthForChannel) | channelRoute.POST("/:id/codex/oauth/start", controller.StartCodexOAuthForChannel) | ||||
| @@ -396,6 +396,7 @@ func SetApiRouter(router *gin.Engine) { | |||||
| syncRoute.POST("/user/create", controller.ReceiveSyncedUserCreate) | syncRoute.POST("/user/create", controller.ReceiveSyncedUserCreate) | ||||
| syncRoute.POST("/quota/update", controller.ReceiveQuotaUpdate) | syncRoute.POST("/quota/update", controller.ReceiveQuotaUpdate) | ||||
| syncRoute.POST("/quota/query", controller.QueryUserQuota) | syncRoute.POST("/quota/query", controller.QueryUserQuota) | ||||
| syncRoute.POST("/quota/batch-query", controller.BatchQueryUserQuota) | |||||
| syncRoute.POST("/quota/batch-deduct", controller.BatchDeductQuota) | syncRoute.POST("/quota/batch-deduct", controller.BatchDeductQuota) | ||||
| syncRoute.GET("/config", controller.GetSyncConfig) | syncRoute.GET("/config", controller.GetSyncConfig) | ||||
| } | } | ||||
| @@ -357,6 +357,11 @@ func UpdateModelPriceByJSONString(jsonStr string) error { | |||||
| func GetModelPrice(name string, printErr bool) (float64, bool) { | func GetModelPrice(name string, printErr bool) (float64, bool) { | ||||
| name = FormatMatchingModelName(name) | name = FormatMatchingModelName(name) | ||||
| // 优先匹配精确模型名称 | |||||
| if price, ok := modelPriceMap.Get(name); ok { | |||||
| return price, true | |||||
| } | |||||
| if strings.HasSuffix(name, CompactModelSuffix) { | if strings.HasSuffix(name, CompactModelSuffix) { | ||||
| price, ok := modelPriceMap.Get(CompactWildcardModelKey) | price, ok := modelPriceMap.Get(CompactWildcardModelKey) | ||||
| if !ok { | if !ok { | ||||
| @@ -21,7 +21,7 @@ var defaultFetchSetting = FetchSetting{ | |||||
| DomainList: []string{}, | DomainList: []string{}, | ||||
| IpList: []string{}, | IpList: []string{}, | ||||
| AllowedPorts: []string{"80", "443", "8080", "8443"}, | AllowedPorts: []string{"80", "443", "8080", "8443"}, | ||||
| ApplyIPFilterForDomain: false, | |||||
| ApplyIPFilterForDomain: true, | |||||
| } | } | ||||
| func init() { | func init() { | ||||
| @@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. | |||||
| For commercial licensing, please contact support@quantumnous.com | For commercial licensing, please contact support@quantumnous.com | ||||
| */ | */ | ||||
| import React, { useEffect, useState } from 'react'; | |||||
| import React, { useEffect, useMemo, useState } from 'react'; | |||||
| import { API, showError } from '../../../helpers'; | import { API, showError } from '../../../helpers'; | ||||
| import { Empty, Card, Spin, Typography } from '@douyinfe/semi-ui'; | import { Empty, Card, Spin, Typography } from '@douyinfe/semi-ui'; | ||||
| const { Title } = Typography; | const { Title } = Typography; | ||||
| @@ -28,7 +28,7 @@ import { | |||||
| import { useTranslation } from 'react-i18next'; | import { useTranslation } from 'react-i18next'; | ||||
| import MarkdownRenderer from '../markdown/MarkdownRenderer'; | import MarkdownRenderer from '../markdown/MarkdownRenderer'; | ||||
| // 检查是否为 URL | |||||
| // Check whether content is a URL. | |||||
| const isUrl = (content) => { | const isUrl = (content) => { | ||||
| try { | try { | ||||
| new URL(content.trim()); | new URL(content.trim()); | ||||
| @@ -38,27 +38,23 @@ const isUrl = (content) => { | |||||
| } | } | ||||
| }; | }; | ||||
| // 检查是否为 HTML 内容 | |||||
| // Check whether content contains HTML. | |||||
| const isHtmlContent = (content) => { | const isHtmlContent = (content) => { | ||||
| if (!content || typeof content !== 'string') return false; | if (!content || typeof content !== 'string') return false; | ||||
| // 检查是否包含HTML标签 | |||||
| const htmlTagRegex = /<\/?[a-z][\s\S]*>/i; | const htmlTagRegex = /<\/?[a-z][\s\S]*>/i; | ||||
| return htmlTagRegex.test(content); | return htmlTagRegex.test(content); | ||||
| }; | }; | ||||
| // 安全地渲染HTML内容 | |||||
| // Parse HTML content and extract inline styles. | |||||
| const sanitizeHtml = (html) => { | const sanitizeHtml = (html) => { | ||||
| // 创建一个临时元素来解析HTML | |||||
| const tempDiv = document.createElement('div'); | const tempDiv = document.createElement('div'); | ||||
| tempDiv.innerHTML = html; | tempDiv.innerHTML = html; | ||||
| // 提取样式 | |||||
| const styles = Array.from(tempDiv.querySelectorAll('style')) | const styles = Array.from(tempDiv.querySelectorAll('style')) | ||||
| .map((style) => style.innerHTML) | .map((style) => style.innerHTML) | ||||
| .join('\n'); | .join('\n'); | ||||
| // 提取body内容,如果没有body标签则使用全部内容 | |||||
| const bodyContent = tempDiv.querySelector('body'); | const bodyContent = tempDiv.querySelector('body'); | ||||
| const content = bodyContent ? bodyContent.innerHTML : html; | const content = bodyContent ? bodyContent.innerHTML : html; | ||||
| @@ -76,15 +72,11 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { | |||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const [content, setContent] = useState(''); | const [content, setContent] = useState(''); | ||||
| const [loading, setLoading] = useState(true); | const [loading, setLoading] = useState(true); | ||||
| const [htmlStyles, setHtmlStyles] = useState(''); | |||||
| const [processedHtmlContent, setProcessedHtmlContent] = useState(''); | |||||
| const loadContent = async () => { | const loadContent = async () => { | ||||
| // 先从缓存中获取 | |||||
| const cachedContent = localStorage.getItem(cacheKey) || ''; | const cachedContent = localStorage.getItem(cacheKey) || ''; | ||||
| if (cachedContent) { | if (cachedContent) { | ||||
| setContent(cachedContent); | setContent(cachedContent); | ||||
| processContent(cachedContent); | |||||
| setLoading(false); | setLoading(false); | ||||
| } | } | ||||
| @@ -93,7 +85,6 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { | |||||
| const { success, message, data } = res.data; | const { success, message, data } = res.data; | ||||
| if (success && data) { | if (success && data) { | ||||
| setContent(data); | setContent(data); | ||||
| processContent(data); | |||||
| localStorage.setItem(cacheKey, data); | localStorage.setItem(cacheKey, data); | ||||
| } else { | } else { | ||||
| if (!cachedContent) { | if (!cachedContent) { | ||||
| @@ -111,16 +102,12 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { | |||||
| } | } | ||||
| }; | }; | ||||
| const processContent = (rawContent) => { | |||||
| if (isHtmlContent(rawContent)) { | |||||
| const { content: htmlContent, styles } = sanitizeHtml(rawContent); | |||||
| setProcessedHtmlContent(htmlContent); | |||||
| setHtmlStyles(styles); | |||||
| } else { | |||||
| setProcessedHtmlContent(''); | |||||
| setHtmlStyles(''); | |||||
| const htmlPayload = useMemo(() => { | |||||
| if (!isHtmlContent(content)) { | |||||
| return { content: '', styles: '' }; | |||||
| } | } | ||||
| }; | |||||
| return sanitizeHtml(content); | |||||
| }, [content]); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| loadContent(); | loadContent(); | ||||
| @@ -129,8 +116,9 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { | |||||
| // 处理HTML样式注入 | // 处理HTML样式注入 | ||||
| useEffect(() => { | useEffect(() => { | ||||
| const styleId = `document-renderer-styles-${cacheKey}`; | const styleId = `document-renderer-styles-${cacheKey}`; | ||||
| const { styles } = htmlPayload; | |||||
| if (htmlStyles) { | |||||
| if (styles) { | |||||
| let styleEl = document.getElementById(styleId); | let styleEl = document.getElementById(styleId); | ||||
| if (!styleEl) { | if (!styleEl) { | ||||
| styleEl = document.createElement('style'); | styleEl = document.createElement('style'); | ||||
| @@ -138,7 +126,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { | |||||
| styleEl.type = 'text/css'; | styleEl.type = 'text/css'; | ||||
| document.head.appendChild(styleEl); | document.head.appendChild(styleEl); | ||||
| } | } | ||||
| styleEl.innerHTML = htmlStyles; | |||||
| styleEl.innerHTML = styles; | |||||
| } else { | } else { | ||||
| const el = document.getElementById(styleId); | const el = document.getElementById(styleId); | ||||
| if (el) el.remove(); | if (el) el.remove(); | ||||
| @@ -148,7 +136,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { | |||||
| const el = document.getElementById(styleId); | const el = document.getElementById(styleId); | ||||
| if (el) el.remove(); | if (el) el.remove(); | ||||
| }; | }; | ||||
| }, [htmlStyles, cacheKey]); | |||||
| }, [cacheKey, htmlPayload]); | |||||
| // 显示加载状态 | // 显示加载状态 | ||||
| if (loading) { | if (loading) { | ||||
| @@ -207,15 +195,6 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { | |||||
| // 如果是 HTML 内容,直接渲染 | // 如果是 HTML 内容,直接渲染 | ||||
| if (isHtmlContent(content)) { | if (isHtmlContent(content)) { | ||||
| const { content: htmlContent, styles } = sanitizeHtml(content); | |||||
| // 设置样式(如果有的话) | |||||
| useEffect(() => { | |||||
| if (styles && styles !== htmlStyles) { | |||||
| setHtmlStyles(styles); | |||||
| } | |||||
| }, [content, styles, htmlStyles]); | |||||
| return ( | return ( | ||||
| <div className='min-h-screen bg-gray-50'> | <div className='min-h-screen bg-gray-50'> | ||||
| <div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'> | <div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'> | ||||
| @@ -225,7 +204,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { | |||||
| </Title> | </Title> | ||||
| <div | <div | ||||
| className='prose prose-lg max-w-none' | className='prose prose-lg max-w-none' | ||||
| dangerouslySetInnerHTML={{ __html: htmlContent }} | |||||
| dangerouslySetInnerHTML={{ __html: htmlPayload.content }} | |||||
| /> | /> | ||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| @@ -595,7 +595,7 @@ export default function SettingsChannelAffinity(props) { | |||||
| include_rule_name: !!values.include_rule_name, | include_rule_name: !!values.include_rule_name, | ||||
| ...(values.skip_retry_on_failure | ...(values.skip_retry_on_failure | ||||
| ? { skip_retry_on_failure: true } | ? { skip_retry_on_failure: true } | ||||
| : {}), | |||||
| : { skip_retry_on_failure: false }), | |||||
| ...(userAgentInclude.length > 0 | ...(userAgentInclude.length > 0 | ||||
| ? { user_agent_include: userAgentInclude } | ? { user_agent_include: userAgentInclude } | ||||
| : {}), | : {}), | ||||